Linux内核模块编写和调试

May 24,2025 linux kernel

light-in-the-sky

黄昏时分

接上一篇文章的内容 编译和运行新版本Linux内核,本文中,我们继续学习内核模块相关的基础知识。为了方便以后为Linux内核贡献代码,这次看看如何编写一个简单的内核模块,以及将其集成到Linux内核代码中,并使用GDB调试内核和我们写的内核模块。

这篇文章紧接上文,因此会简化上文已经详细介绍的内核编译、QEMU等基础知识,如果过程中卡壳了,建议从上文 编译和运行新版本Linux内核 中寻找答案试试。也可以在评论区留言。

一个简单的内核模块

Linux内核模块是一种动态可加载的内核对象(LKM, Loadable Kernel Module),用于在运行时扩展内核功能而无需重新编译或重启系统。这种模块化机制实现了核心内核与功能组件的解耦,使得文件系统、网络协议栈、设备驱动程序以及各类硬件支持能够以独立模块的形式存在。如果不模块化,而是将这些功能全部集成到内核中,不仅会导致内核过于庞大,还会有许多不需要的功能被默认加载。此外,每次功能变动都需要安装最新内核,并且必须重启系统。因此,动态加载的内核模块是Linux内核保持轻量和灵活的重要原因。

内核模块通常使用C语言编写,本文中只需要编写一个简单的“Hello Word”演示程序,用于了解如何编写和使用内核模块。不会有太多的知识门槛。

编写内核模块

创建hello_module目录 ,编写如下的模块代码 hello.c

#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Xnow");
MODULE_DESCRIPTION("A simple Linux kernel module.");
MODULE_VERSION("0.1");

static char message[] = "Hello, World!";

static int __init hello_init(void) {
    printk(KERN_INFO "********** %s **********\n", message);
    return 0;
}

static void __exit hello_exit(void) {
    printk(KERN_INFO "Goodbye, World!\n");
}

module_init(hello_init);
module_exit(hello_exit);

这个和我们日常写的hello world还是挺不一样的:

  • include 语句包含必要的头文件;
  • MODULE_ 行,明确这个模块的开源协议、作者、版本和描述等信息。
  • staic char message[] 定义一个全局字符串message。
  • hello_init()hello_exit() 两个函数分别输出 message和“Goodbye, World!\n” 字符串。
  • module_initmodule_exit 两个宏分别指定模块加载时的初始化函数和卸载时的清理函数。

在同一目录下继续创建 Makefile,内容如下:

注意,Makefile中的缩进是 tab,而不是空格

obj-m += hello.o

KDIR ?= /lib/modules/$(shell uname -r)/build

all:
	make -C $(KDIR) M=$(PWD) modules

clean:
	make -C $(KDIR) M=$(PWD) clean
  • obj-m += hello.o:指定将hello.c 文件编译成hello.ko模块文件。
  • KDIR:指向当前系统的内核源码目录(通常是 /lib/modules/$(uname -r)/build)。
  • make -C $(KDIR) M=$(PWD) modules
    • -C $(KDIR): 切换到内核源码目录 $(KDIR),在该目录下执行 make。
    • M=$(PWD): 指定模块源码的目录为当前目录 $(PWD)$(PWD) 是一个环境变量,表示当前工作目录。
    • modules: 表示要编译模块。

这里$(KDIR) 指向当前运行中内核的源码目录。有时候,软件仓库里并没有提供当前运行内核开发包,可能是运行的内核版本已经过时了。这时,也可以安装略有版本差异的内核开发包试试,在 Makefile文件为KDIR指定具体的内核开发包版本的路径即可。

比如我运行的内核是 5.14.0-503.14.1.el9_5.x86_64,但是仓库里只能安装 5.14.0-503.40.1.el9_5.x86_64。这时,可以安装kernel-devel-5.14.0-503.40.1.el9_5.x86_64kernel-core-5.14.0-503.40.1.el9_5.x86_642个包。然后修改 KDIR, 修改为 KDIR ?= /lib/modules/5.14.0-503.40.1.el9_5.x86_64/build 这个版本路径,也是一样能编译和加载的。

这里涉及到了vermagic 变量和兼容性等问题,不做过多展开。实际上面举例的两个包是同一个版本的内核(5.14.0),只是补丁版本不一致。

编译和测试内核模块

先安装内核的开发包,之后执行编译和加载

$ sudo yum -y install make kernel-devel-$(uname -r) kernel-core-$(uname -r)
$ make   # 编译
$ ls     # 检查编译结果
hello.c  hello.ko  hello.mod  hello.mod.c  hello.mod.o  hello.o  Makefile  modules.order  Module.symvers

$ file hello.ko 
hello.ko: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), BuildID[sha1]=4ffdbb56e4c75c31b728d4fc1b07dc65c51159d8, with debug_info, not stripped

编译完成后,生成了模块文件和各类临时文件, hello.ko 是需要的内核模块文件,也可以使用modinfo 查看内核模块的信息。

$ modinfo ./hello.ko 
filename:       ./hello.ko
version:        0.1
description:    A simple Linux kernel module.
author:         Xnow
license:        GPL
rhelversion:    9.5
srcversion:     EE1A7DC8C21A87AB57A923B
depends:        
retpoline:      Y
name:           hello
vermagic:       5.14.0-503.40.1.el9_5.x86_64 SMP preempt mod_unload modversions

接下来试试加载和卸载模块

$ sudo insmod hello.ko  # 加载模块
$ sudo dmesg  | tail -1 
[xxxxxx] ********** Hello, World! **********

$ sudo lsmod  | grep -i hello   # 查看
hello                  16384  0

$ sudo rmmod hello	# 卸载模块
$ sudo dmesg  | tail -1 
[xxxxxx] Goodbye, World!
  • insmod 用于加载内核模块
  • 模块代码中的 printk() 函数会将内容输出到 dmesg 内核日志文件中
  • lsmod 和 cat /proc/modules 命令可以看到已经加载的模块
  • rmmod 用于卸载模块,卸载时的函数也执行了。

模块集成到内核并编译

将模块集成到内核代码中

上面的步骤是动态的编译和加载内核模块。但是如何将模块内置到Linux源码中,并使用gdb对内核代码进行调试呢?

创建 debug_linux 目录,这个目录将作为我们调试内核的主要目录。在目录下下载最新的Linux 的内核源码,解压。之后把上面的 hello.c 文件放到内核源码中的 drivers 目录下。

$ wget https://mirrors.aliyun.com/linux-kernel/v6.x/linux-6.14.6.tar.xz
$ tar xvf linux-6.14.6.tar.xz
$ cd linux-6.14.6
$ cp ../../hello_module/hello.c drivers/

修改 drivers/Kconfig,在menu行的后面添加如下行

config HELLO
	tristate "Hello World Module"

tristate表示这个配置选项为三态选项,即编译内核时对该模块有三种选择:

  • <*>,即y:编译并内置到内核中。
  • <M>,编译为模块,可以在运行时加载。
  • < >,即n,表示不编译,不包含在内核中。

除了 tristate,还有bool类型的选项,有两种状态

  • [*]:通常用于非模块化选项,表示启用某个内核配置选项。
  • [ ]:表示不启用

这几个选项类型在编译内核的make menuconfig阶段中会看得到。

继续修改 drivers/Makefile,在文件内容起始处添加如下行:

obj-$(CONFIG_HELLO)     = hello.o
  • CONFIG_HELLO 也就是对应上面Kconfig文件中配置的 config HELLO

内核 drivers 目录的逻辑是十分严格的,所有模块都有各自的目录,各安其位,组织严密。我们直接将代码放到 drivers 目录的做法是临时测试的走捷径方法,实际中是不允许的。

编译内核:启用hello模块和调试选项

接下来又是熟悉的编译内核环节。

$ make mrproper  
$ make x86_64_defconfig   # 生成针对 x86_64架构的默认配置文件
$ make menuconfig

make menuconfigLinux内核做如下定制。

  • 禁用内核的地址随机化,方便后续的内核调试
  • 关闭多媒体、声卡和虚拟化支持,加快编译速度
  • 开启内核debug模式,用于调试内核
  • 启用我们自己编写的Hello World模块
# 关闭地址随机化不然断点处无法停止
Processor type and features
 [] Randomize the address of the kernel image (KASLR)   # 取消勾选

[ ] Virtualization  ----

Device Drivers  --->
  <*> Hello World Module   # 编写的模块启用
  < > Multimedia support  ----
  < > Sound card support  ---- 

# 启用内核debug
Kernel hacking
  [*] Kernel debugging
  Compile-time checks and compiler options
	Debug information (Rely on the toolchain's implicit default DWARF version)  --->  # 这个要修改
      [*]   Provide GDB scripts for kernel debugging  # 上一步修改之后才有这个选项

检查下配置,

$ grep -E "(CONFIG_DEBUG_INFO|CONFIG_GDB_SCRIPTS|CONFIG_HELLO)=" .config
CONFIG_HELLO=y   # 编写的测试模块
CONFIG_DEBUG_INFO=y
CONFIG_GDB_SCRIPTS=y

上面这3项必须全都是y才行,然后开始编译。

$ make -j $(nproc)

检查编译成果:

$ ls arch/x86/boot/bzImage
$ ls vmlinux

$ cp arch/x86/boot/bzImage vmlinux ../   # 都copy到debug_linux目录下
  • bzImage 是 Linux 内核的标准引导映像,用于启动系统。
  • vmlinux 文件是未压缩的Linux内核映像,包含完整的内核代码、数据和符号信息,后续会用于调试

上面新编译出的内核中开启了debug选项,也内置了我们自己编写的模块。继续往下就可以测试新版本的内核了。

调试内核

为方便调试,这次就基于initramfs启动新编译的内核,主要还是使用上篇文章 编译和运行新版本Linux内核 中用到的QEMUbusybox等技术。

制作 initramfs

安装busyboxQEMU

$ # 用 yum 安装qemu-kvm
$ sudo yum install -y qemu-kvm

$ # 编译busybox
$ cd debug_linux
$ wget https://busybox.net/downloads/busybox-1.37.0.tar.bz2
$ tar xvf busybox-1.37.0.tar.bz2
$ cd busybox-1.37.0

$ make mrproper   
$ make menuconfig  # 一定要选择 Build static binary
$ make -j $(nproc)
$ make install

注意:编译busybox时,一定要选择编译成静态二进制文件。选项的路径是 Settings --> Build Options --> [*] Build static binary (no shared libs)

准备制作 initramfs 文件的目录结构。

$ cd debug_linux
$ mkdir initramfs_dir
$ cp -r busybox-1.37.0/_install/* initramfs_dir/

$ cat >> initramfs_dir/init << EOF
#!/bin/sh

mkdir /proc && mount -t proc none /proc

/bin/sh
EOF

$ chmod +x initramfs_dir/init

创建Makefile

initramfs:
	cd initramfs_dir && find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../initramfs.img

run:
	/usr/libexec/qemu-kvm \
        -kernel bzImage \
        -initrd initramfs.img \
        -m 256M \
        -nographic \
        -append "earlyprintk=serial,ttyS0 console=ttyS0 loglevel=8"

debug:
	/usr/libexec/qemu-kvm \
        -kernel bzImage \
        -initrd initramfs.img \
        -m 256M \
        -machine accel=tcg \
        -s \
        -S \
        -nographic \
        -append "earlyprintk=serial,ttyS0 console=ttyS0 loglevel=8 nokaslr"

这个Makefile3个目标:

  • initramfs:制作initramfs.img 镜像文件

  • run:用新的Linux内核和initramfs启动虚拟机。

  • debug:以调试模式启动QEMU虚拟机,启动的时候会暂停住,等待GDB的连接和指令

    • -machine accel=tcg ,由于用的是 qemu-kvm,虚拟机会默认启用kvm,导致无法正常调试。-machine accel=tcg配置关闭了kvm,使得调试得以进行。
    • -s:表示在1234端口接受GDB的调试连接。
    • -S:表示QEMU虚拟机会冻结CPU,直到远程的GDB输入相应控制命令。
  • nokaslr:用于禁用内核地址空间布局随机化(KASLR),以便内核的内存地址保持一致。KASLR 是用于随机化内核加载的内存地址的安全措施。

    在实际测试的过程中,用rmmod在宿主机上卸载kvmkvm_intel两个内核模块后也可以顺利开展GDB调试。

测试运行

启动虚拟机前,先制作 initramfs.img 文件

$ make initramfs
$ ls
bzImage  initramfs_dir  initramfs.img  linux-6.14.6  linux-6.14.6.tar.xz  Makefile  vmlinux

运行 make run 正常启动QEMU。

$ make run

QEMU虚拟机的启动过程中,可以看到其中有一行输出了 ********** Hello, World! ********** 。之后系统进入正常的shell环境中。

使用 Ctrl-a x 快捷键退出虚拟机。接下来调试kernel的启动过程。

运行和调试Linux内核

以调试模式启动QEMU虚拟机

$ make debug

启动后,QEMU 会监听在1234端口。打开另一个终端,使用gdb连接到QEMU的调试端口

$ cd debug_linux  # 这个目录下应该有 vmlinux 才行

$ gdb -ex "target remote 127.0.0.1:1234" vmlinux 
(gdb) b start_kernel   # 在linux的入口函数start_kernel上打断点
(gdb) b hello_init     # 打在hello模块上打断点  

(gdb) c   # 继续向下运行,QEMU会停留在 Booting the kernel. 对应的是 start_kernel 函数
(gdb) n 15   # 往下执行15行代码
925             setup_nr_cpu_ids();
(gdb) p command_line    # 检查传递给内核的参数
$1 = 0xffffffff8332e020 <command_line> "earlyprintk=serial,ttyS0 console=ttyS0 loglevel=8 nokaslr"
(gdb) p linux_banner
$2 = 0xffffffff823747e0 <linux_banner> "Linux version 6.14.6 (xxx) (gcc (GCC)
.1-3), GNU ld version 2.35.2-54.el9) #2 SMP PREEMPT_DYNAMIC xxx\n"
(gdb) c

(gdb) p message   # 检查变量值
$3 = "Hello, World!"
(gdb) set message = "Hello, Linux!"  # 修改message的值
(gdb) n	2	# 向下执行2行
13          printk(KERN_INFO "********** %s **********\n", message);
$(gdb) c  # 继续启动虚拟机

bcnpl 都是gdb调试的基础指令。

上面的gdb命令先是检查了command_linelinux_banner变量,分别是qemu-kvm 命令中传递给内核的参数,与内核的版本号以及编译环境信息。

之后又修改了自定义模块hello中的message变量的值,并检查修改之后的结果。修改之后向下执行2代码,观察QEMU中的情况,正好刚刚输出 ********** Hello Linux! **********。 在GDB中对变量message的修改生效了!

通过上面的方法,可以一步步调试Linux内核的启动过程和执行过程,用于深入了解Linux的底层逻辑。

以上就是用gdb调试内核的基本用法。

总结

本文首先阐释了Linux内核模块的概念,并编写一个极简的hello代码,将其编译为内核模块,尝试加载到运行中的系统。之后又将代码集成到Linux源码中,编译到Linux内核里,使用gdb对该模块进行调试。

以上就是关于内核模块编写、使用,以及开发Linux内核的基础知识了。

如果想进一步了解关于Linux模块的相关内容,建议阅读 The Linux Kernel Module Programming Guide 这本小书。

total 0 comments