Linux内核模块编写和调试
黄昏时分
接上一篇文章的内容 编译和运行新版本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_init
和module_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_64
和 kernel-core-5.14.0-503.40.1.el9_5.x86_64
这2个包。然后修改 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 menuconfig
对Linux内核做如下定制。
- 禁用内核的地址随机化,方便后续的内核调试
- 关闭多媒体、声卡和虚拟化支持,加快编译速度
- 开启内核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内核 中用到的QEMU和busybox等技术。
制作 initramfs
安装busybox和QEMU
$ # 用 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"
这个Makefile里3个目标:
-
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在宿主机上卸载
kvm
和kvm_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 # 继续启动虚拟机
b
、c
、n
、p
、l
都是gdb调试的基础指令。
上面的gdb命令先是检查了command_line
和 linux_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