Linux内核模块开发之hello-world
-
还是得接触一下
Linux内核驱动开发,这样才更了解Linux内核pwn。 -
首先我们给一个
C语言编写的内核驱动版本的Hello world,逐行理解这个代码,并配置环境。
1 | /* |
内核模块基础
内核模块相关概念
- 内核模块,英文名称为
Kernel Module,内核模块的可执行文件后缀通常是.ko,如hello.ko:- 内核模块是一段可以根据需要加载和卸载内核中的代码。
- 内核模块可以在不重启系统的情况下扩展内核的功能。
- 最常见的内核模块其实就是设备驱动程序,它使得内核能够访问连接到系统上的硬件。例如,鼠标其实就是一个硬件,鼠标连接到电脑上,就是由某个内核模块使得内核能与鼠标相互交互,进而操作电脑。
- 如果没有内核模块,在添加一个新的内核功能,我们就必须修改内核文件,并重新编译内核,这不仅会使得内核变得很大,而且编译内核之后还需要重启电脑,非常不方便。
注意:在具体了解内核模块之前,我将设备驱动等同于内核模块了,这是不对的,设备驱动是内核模块的其中一种。
拓展:内核模块除了有设备驱动程序,还包括了文件系统模块,网络协议模块,非硬件驱动类型的模块,内核功能拓展模块,安全模块,虚拟化或容器相关模块,伪文件系统、接口模块,其实任何可以
动态插入内核的功能单元都可以是模块。
内核模块操作
-
在
Linux中我们将介绍几个对内核模块的操作,注意在执行这些操作一般都需要root权限:- 查看已经载入到内核正在运行的内核模块
- 将未被载入的内核模块载入到内核中。
- 将被载入的内核模块从内核中移除。
-
要查看已经载入内核的内核模块,在
Linux中可以使用lsmod命令来查看。- 运行
lsmod就会显示出已经载入内核的内核模块

- 运行
lsmod命令本质上是读取/proc/modules这个文件,并输出到终端上。

- 运行
-
要将内核模块载入到内核中(其实就是运行内核模块,与运行用户程序是不一样的。)
insmod和modprobe这两个命令都可以使用。而这两个命令稍微会有区别:modprobe:最后是调用insmod命令将内核模块载入内核,该命令会自动寻找模块的默认位置,懂得如何找出依赖关系并按正确的顺序加载模块。相对来说更灵活。insmod:这个就是直接将内核模块载入内核,在载入内核模块的时候需要输入内核模块完整的路径,相对来说比较死板。- 注意:对于初学来说,就使用
insmod进行内核模块的载入即可,modprobe等之后再说。 - 假设我们这边有一个内核模块,使用
insmod载入内核模块就需要输入如下命令,如下图所示,当然图中还是没有hello.ko模块的:

1 | insmod /home/myheart/program/my_module/hello.ko |
- 要将已经载入到内核的内核模块移除,可以使用
rmmod命令,也可以使用modprobe -r命令。
1 | rmmod 模块名 |
环境准备
在编写一个内核程序之前,还需要解决几个问题,也就是环境问题:
- 内核版本问题:选择内核版本,有些魔改后的内核,或者打了补丁这就会导致一些问题,并且有些
Linux发行版/usr/src/是缺少头文件的,就导致无法编译,所以我们尽量下载、编译并启动一个全新的、纯净的Linux内核。 - 模块版本管理问题:当一个内核模块编译是给
linux kernel 5.8.1使用的,那如果在linux kernel 5.8.2载入该模块基本上会报错,因为大部分Linux发行版的stock内核都默认开启了modversioning选项。最好是编译一个关闭modversioning的内核。 - 编译问题:注意到前面的内核模块的
hello world程序,引入了与用户态的C语言不同的头文件<linux/module.h>和<linux/kernel.h>。而gcc默认引用在/usr/src/这个文件夹中的这两个头文件。如果/usr/src/文件夹中没有这两个头文件,gcc编译就会报错。 - 调试问题:内核模块不能像
printf()那样打印到屏幕上,它会记录一些信息和警告,这些信息最终只会打印到控制台中这个控制台指的是tty终端,而不是xterm等图形终端,如果使用xterm载入内核模块,只会有日志、信息和警告会被记录,而不会被输出到控制台中。我们可以输入tty命令,如果输出的是/dev/pts/tty0那么就是伪终端信息不会打印,如果是/dev/tty1那么就是真实的控制台。
我的环境:
- 使用重新编译内核后的
WSL2这样支持使用qemu,再下载一个干净的内核源码和与之对应的头文件。- 编译干净的内核源码,并关闭
modversioning,并使用busybox文件系统,配合qemu模拟出一个简单的系统,而qemu模拟出来的系统终端是/dev/tty- 在
WSL2中修改gcc编译的一些设置,使得编译时能正确引用头文件,这样实现在WSL2使用gcc编译好的内核模块,可以与内核打包成cpio文件格式,使用qemu模拟的时候放入所模拟的操作系统中,从而对内核进行调试与操作。
WSL2内核重新编译
- 重新编译
WSL2内核是为了能够使用qemu,这一步在我博客上已经写过了,这里不多说了。
编译纯净内核源码
- 由于
WSL2版本对应的是5.15.153.1-microsoft-standard-WSL2,为了兼容,同时也算是省事吧,我就选择与5.15.153.1-microsoft-standard-WSL2相近的Linux kernel 5.15版本。 - 使用
wget命令从linux kernel网站上下载对应版本的内核源码,这里选择下载的是linux kernel 5.15.1版本的:
1 | wget https://www.kernel.org/pub/linux/kernel/v5.x/linux-5.15.1.tar.gz |

- 下载完整后使用命令将源码的压缩包进行解压操作:
1 | tar -zxvf linux-5.15.1.tar.gz |
- 接着下载编译
linux kernel所需要的工具
1 | sudo apt update |
- 这里为了不多事,就直接将
WSL2的内核配置文件作为该内核编译的配置文件(不太建议这样,因为不能保证WSL2的编译配置有没很大改动)
1 | cd linux-5.15.1/ |
- 接着关闭内核模块版本管理:
1 | scripts/config --disable MODVERSIONS |
- 接着使用
make menuconfig命令,进入配置界面,把虚拟化部分给关了,可以减少一点编译时间,然后保存配置。

- 接着给编译所要运行的脚本文件添加可执行权限:
1 | chmod +x scripts/*.sh |
- 接着直接使用全部
cpu核心开始编译内核
1 | make -j$(nproc) |

- 在编译的时候还是遇到报错了,处理方案在这里

1 | scripts/config --disable DEBUG_INFO_BTF --disable DEBUG_INFO_BTF_MODULES |
- 编译完成

- 将内核复制一份过来,这样就有编译好的内核了。
1 | cp ./arch/x86/boot/bzImage ../ |

使用qemu启动内核
- 接着使用
busybox作为文件系统,配合qemu进行模拟从而启动内核。编译busybox这里也不多说了。这里先使用命令创建文件
1 | mkdir -p initramfs/{bin,sbin,proc,sys,dev,etc} |
- 接下来复制
busybox并创建符号链接,先将busybox复制到该文件夹的/bin目录下,并进入initramfs/bin目录中
1 | cp /home/myheart/busybox-1.36.1/busybox initramfs/bin/busybox |
- 接着将进行创建符号链接操作,并回到
initramfs的父目录中
1 | for cmd in $(./busybox --list); do ln -sf busybox $cmd; done |
- 在内核启动之后,会执行一个
init文件,这个了解一下即可,具体是在linux启动流程中会具体介绍,在initramfs这个目录下创建init文件
1 | cat > initramfs/init << 'EOF' |
- 接着给这个文件赋予可执行权限:
1 | chmod +x initramfs/init |
- 接着就是将文件打包为
cpio文件
1 | cd initramfs |
- 然后使用
qemu命令直接启动即可,这里我直接创建一个sh文件,就免得总是要输入这么一大串命令:
1 | # vim qemu.sh |
- 运行之后查看
tty,发现是ttyS0,而不是/dev/pts/tty0,所以控制台的环境也解决了。


编译hello_world.ko文件
- 对于编译内核的时候,
gcc编译在引用内核头文件的时候会稍微有点问题,所以我们直接采用写Makefile来构建。 - 我们直接将上面
hello world的代码复制到hello_world.c文件中。
1 | /* |
- 接下来我们要编译该文件,编译内核的时候通常不用
gcc命令,而是使用make文件,所以我们要写一个简单的make文件,先确保有make这个程序。

- 接着创建
Makefile文件,并写入这些编译信息:
1 | obj-m += hello_world.o |
- 接着输入
make命令即可进行编译操作,运行之后就会看到编译好的内核模块文件.ko
1 | make |

- 此时我们就可以将编译好的内核模块文件复制一份到
initramfs中,然后再重新打包cpio文件。
1 | cp ./hello_world.ko ./initramfs/hello_world.ko |
- 重新打包
cpio:
1 | find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../initramfs.cpio.gz |
- 接着再运行
qemu.sh脚本,尝试挂载hello_world.ko这个内核模块,可以成功挂载。


- 在内核模块中
printk,并不会将字符串输出到控制台终端,而是写入内核日志缓冲区。我们可以使用dmesg | tail查看内核日志缓冲区。

- 至此,内核模块的环境就已经搭建好了。如果我们想要在控制台中显示
Hello world 1.我们需要将printk(KERN_INFO "Hello world 1.\n");替换成printk(KERN_EMERG "Hello world 1.\n");,并重新编译内核文件打包cpio文件,挂载即可。

内核模块基础知识
内核模块基础知识1
- 首先我们将详细学习一下,上面遇到的一些内容,大致如下内容:
hello_world.c中的每一行代码。- 包括引入的头文件。
init_module()函数、cleanup_module()函数、printk()函数、MODULE_LICENSE()宏。
makefile的语法
- 首先回顾一下上面的
hello_world.c的代码:
1 | /* |
内核头文件
- 在使用
Windows进行系统编程的时候,一般我们有这么一个头文件引入,该头文件就是Windows开发提供的系统级的API:
1 |
- 在开发
Linux内核模块的时候,我们一般是引入下面两个头文件,也为开发者提供了一些与内核交互的规范接口,允许用户空间程序或内核模块通过这些接口与内核交互:- 位置1:如果使用的是当前
Linux系统自带的内核头文件,那么该头文件一般就会放在/usr/src/linux-headers-<内核版本>/这个目录下,在安装第三方库或软件时,其头文件会被放置在/usr/local/include目录下,与系统的头文件区分开,避免发生冲突 - 位置2:如果使用的是通过下载内核源码编译过的内核,一般是位于源码的
linux_kernel/include/linux/这个目录下。 - 与用户空间头文件的区别:内核头文件专注于内核内部接口,而进程头文件主要用于标准库函数的调用。
- 位置1:如果使用的是当前
1 |
三函数、一宏
- 在源码中有编写两个函数,首先是
int init_module(void)函数,以及int cleanup_module(void)函数。这两个的功能如下:- 对于
int init_module(void)函数:在使用挂载内核模块的命令时会自动调用这个函数 - 对于
int cleanup_module(void)函数:在使用移除内核模块的命令时会自动调用这个函数。 - 注1:这两个函数的功能就像进程程序中的
main函数一样,运行进程程序的时候程序会自动从main函数开始执行用户编写的程序。 - 注2:除了固定编写这两个函数的名称,后续还可以使用
module_init()和module_exit()这两个宏,将自己编写的函数绑定为挂载内核时执行和移除内核时执行。
- 对于
- 在学习进程程序的编写,一开始学习的是
printf(),而对于内核模块开发的学习一开始也从打印函数开始,内核模块的打印函数为printk。 printk函数的用法与printf函数的用法差不多,只不过printk多了一个日志级别,关于日志级别的宏定义如下所示,在内核源码linux_kernel/include/linux中:
1 |
- 所以
printk函数的大致用法就和示例程序差不多:
1 | int init_module(void) |
- 在内核
2.4以及更高版本中加载专有模块的时候出现了一些警告,该警告是关于使用无许可证代码污染内核的提示。- 从内核
2.4开始引入了一套系统标识基于 GPL(及同类许可证)授权的代码,以便在代码为非开源时向用户发出警告。 - 这个警告可以通过
MODULE_LICENSE()宏来实现,如果要消除警告,则可以将许可证设置为GPL,就和上面的示例代码所示:MODULE_LICENSE("GPL"); - 下面是可接受的自由软件许可证标识符:
GPL:GNU通用公共许可证v2或更高版本GPL v2:GNU 通用公共许可证 v2Dual BSD/GPL:GPL v2 或 BSD 许可证选择Dual MIT/GPL:GPL v2 或 MIT 许可证选择Dual MPL/GPL:GPL v2 或 Mozilla 许可证选择Proprietary:非自由产品
- 从内核
- 其他文档宏如下:
MODULE_DESCRIPTION():描述模块的功能MODULE_AUTHOR():声明模块的作者MODULE_SUPPORTED_DEVICE():声明模块支持的设备类型(这个宏在更后面的内核版本好像被移除了。)- 注意:这些宏定义可以在
linux_kernel/module.h中
makefile
- 首先回顾一下
makefile这个文件里面的内容:
1 | obj-m += hello_world.o |
- 接下来逐句介绍一下这个语法:
obj-m:是内核构建系统里面的一个变量,表示要编译成内核模块,所以obj-m += hello_world.o也就是要将hello_world.c编译成hello_world.koall表示目标:make -C:其中-C的意思是切换到Linux内核源码目录去执行Make。M=$(PWD):M表示模块文件所在的目录,$(PWD)当前目录,你执行Make命令所在的目录。modules:表示编译模块- 其中
make -C /home/myheart/program/my_module/linux-5.15.1 M=$(PWD) modules表示的是我要编译$(PWD)目录下的模块。
clean也是调用内核构建系统:清理当前模块目录生成的文件
- 基本过程如下:
- 当输入
make命令,首先会执行all中的命令cd到/home/myheart/program/my_module/linux-5.15.1这个目录中- 然后再读取
obj-m += hello_world.o - 接着会自动进行
gcc -c hello_world.c,以及对编译好的hello_world.ko进行链接操作。(具体过程还是比较复杂,之后再了解)
- 接着编译好之后执行
clean命令。
- 当输入
- 使用
make命令就会输出如下编译的内容:

- 内核 2.6 为内核模块引入了
.ko扩展名(取代了旧的.o扩展名),以区别于常规的目标文件。这些.ko文件包含一个额外的.modinfo段,用于存放模块的附加元数据。我们可以使用modinfo查看.modinfo段:

补充知识
- 对于所有已经加载的模块,都会出现在
/proc/modules文件中。如下图所示,我们可以看到这个模块运行的起始地址。

- 查看该文件即可确认你的模块已作为内核的一部分加载。使用
rmmod hello_world.ko移除模块,然后查看/var/log/messages来观察系统中记录的模块日志。
内核模块练习1
-
将
init_module()中的返回值改为负数,重新编译并加载模块。观察会发生什么。 -
首先我们打开上面的
hello_world.c文件,并定位到init_module的返回值中。

- 修改返回值为
-1:

- 重新编译该文件、复制该文件到文件系统、重新打包文件系统,最后使用
qemu进行模拟。

- 接下来进行挂载操作,输入挂载命令之后会发现操作无法挂载该模块,提示操作不许可。

内核模块练习2
- 编写一个简单的内核模块要求如下:
- 只要写
init_module函数和cleanup_module函数即可 - 使用许可证宏,添加上许可证
GPL - 使用描述模块宏,简单描述一下这个模块。
- 使用声明模块作者宏,指定作者为
"aaa"
- 只要写
- 编写的代码如下:
1 | /* |
内核模块基础知识2
- 主要学习以下内容:
- 使用宏绑定的方法编写模块。
- 使用
Makefile包含两个模块的编译。
宏绑定编写模块
- 从
Linux2.4开始,模块的初始化和清理函数不再必须命名为init_module()和cleanup_module()。 - 开发者可以使用
module_init()和module_exit()这两个宏linux/init.h中,允许使用自定义的函数名。 - 但一个关键的要求是:初始化和清理函数必须在宏调用之前定义,否则会导致编译错误。下面给出一个示例:
1 | /* |
Makefile两个模块
- 通过上面的编写,已经有了两个文件
hello_world.c和hello_world2.c。使用Makefile可以编译独立编译这两个文件为两个ko文件。 - 可以通过
obj-m +=***.o这样的形式添加要编译的文件,这样就可以实现一次make编译两个独立文件成为两个ko文件。 - 回顾一下
obj-m:obj-m:是内核构建系统里面的一个变量,表示要编译成内核模块,所以obj-m += hello_world.o也就是要将hello_world.c编译成hello_world.ko
1 | obj-m += hello_world.o |
- 将
hello_world.c和hello_world2.c放入到相同文件夹下面,使用make命令就可以进行独立的编译操作了。


内核模块基础知识3
- 主要学习:
__init和__exit等几个宏 - 在内核
2.2以及后续版本引入了一项新特性,对初始化和清理函数的定义进行了改进。多出了几个宏:__init宏:使得初始化函数在执行后被丢弃并释放其内存。仅适用于内置驱动程序,对可加载模块则无效。__initdata宏:其作用于__init类似,但它用于变量而非函数。__exit宏:使函数在模块被编译进内核时被忽略,与__init类似,对可加载模块没有影响。因为内置驱动不需要清理函数,而可加载模块则需要。注意:编译进内核的时候是直接编译成内核vmlinux中,而不是编译成独立ko模块- 在一些书上说:当启动系统可能就会看的
Freeing unused kernel memory: 236k freed,这就是上面几个宏的作用,内核正在释放相关内存。 - 对于上面
__init宏和__initdat宏的具体行为,在gdb调试linux内核模块中会详细调试。
- 对于这几个宏详细的说明和定义,在
linux/init.h中有比较详细的说明:
1 | /* These macros are used to mark some functions or |
- 接下来给出一个示例:
1 | /* |
内核模块基础知识4
- 内核模块是支持命令行参数的,但不是像用户态程序那样使用
main函数的argc/argv机制。- 实现方式是声明全局变量,然后使用
module_param()宏进行绑定,该宏定义在linux/moduleparam.h中。 - 通过使用
insmod加载模块时,可以通过类似./insmod mymodule.ko my_variable=5这样的语法进行参数值的传递。 - 规范:变量声明和宏的调用应放在模块文件的顶部附近,以保持代码清晰。
- 实现方式是声明全局变量,然后使用
- 接下来先介绍一下
module_param()宏如何使用:- 首先该宏接收三个参数:变量名、变量类型以及
sysfs文件权限。 - 其中整数类型支持有符号和无符号两种变体
- 其中整数数组或字符串,需要使用
module_param_array()宏定义和module_param_string()宏定义。 - 基本用法示例:
- 首先该宏接收三个参数:变量名、变量类型以及
1 | int myint = 3; // 声明一个变量 |
- 对于数字的使用,旧版本内核
2.4内核和新版本内核不太一样。需要传入一个指向计数变量的指针作为第三个参数(如果不需要计数,则可以传NULL) - 示例如下:
1 | // 不传入计数 |
拓展1:一个实际使用场景:为端口或 IO 地址等变量设置默认值。如果默认值保持不变,则执行自动检测;否则保留用户提供的值。
拓展2:文件权限一般会设置为
0444只读,或者说是0644root可写。而参数0不导出到sysfs,并且权限位也有相关的宏定义。
- 内核模块设置好参数之后还需要写记录参数的文档,这个时候就需要使用
MODULE_PARM_DESC()宏用于描述模块接收的参数。它接受变量名和一段自由格式的描述字符串。 - 一个简单的示例如下:
1 | /* |
- 编写好后直接挂载看看:

- 接下来移除该模块,重新挂载并赋予参数:
insmod hello_world5.ko myintArray=1,2 mylong=1234 myshort=1

- 接下来继续移除,并将程序继续挂载,并在整数参数变量输入非数字看看系统如何处理,发现是这样的。

内核模块基础知识5
-
该部分主要是学习如何将多个
c文件编写成一个内核模块文件.ko。这个部分要与前面使用Makefile编译独立的多个文件区别开来。 -
首先创建一个
start.c,用于编写init_module函数:
1 | /* |
- 接着编写一个
stop.c,用于编写cleanup_module()函数:
1 | /* |
- 接着编写
Makefile:- 其中
obj-m += startstop.o表示要构建的内核模块文件,最终文件为startstop.ko文件 - 其中
startstop-objs := start.o stop.o表示startstop.ko这个文件是由start.o和stop.o链接起来的。大致流程如下:- 首先使用
gcc编译器gcc -c start.c -> start.o、gcc -c stop.c -> stop.o,得到start.o和stop.o - 再使用链接器
ld将start.o和stop.o链接成startstop.o - 最后再利用链接器将
startstop.o转换为内核模块startstop.ko
- 首先使用
- 其中
1 | obj-m += startstop.o |
- 使用
make命令之前:

- 使用
make命令之后:

内核模块基础知识6
- 本部分主要说明一下如何为
预编译内核构建模块,首先来理解一下两个名词:- 自行编译内核:从源码中编译完整的内核,可以开启特定功能。
- 预编译内核:别人已经编译好的内核,比如
Linux发行版自带的内核。 - 为预编译内核构建模块:在别人已经编译好的内核中添加新的模块功能。
- 首先第一个要考虑的就是内核模块在加载时,预编译的内核会检查
其版本魔数。版本魔数是一个字符串,记录了如下内容:- 编译内核模块时的内核版本
- 编译内核模块的编译器版本
- 编译内核模块支持的CPU架构,等其他的关键信息
- 注:只有当内核模块的
版本魔数与当前内核的版本魔术完全一致的模块才能被内核加载进去。 - 注:这就出现了一个问题,当内核添加新模块,但是条件不允许重新编译内核(如服务器在给用户提供服务,编译重新编译内核会使得服务器宕机,造成经济损失)
- 核心问题:虽然我们有内核源码,并且使用它编译了模块;但系统所运行的内核是由别人编译的,两者的编译环境不完全相同,这就导致版本魔数不匹配,无法成功加载内核模块。
- 一个经典场景就是使用
insmod ***..ko,会出现如下报错,并且/var/log/messages会显示版本魔数不匹配的信息
1 | // 命令行报错 |
-
解决这个问题的方法有两种:
-
第一种就是直接使用
--force-vermagic选项,但是这个选项是有风险的。 -
第二种就是正常的安全的方法:
- 首选确保有一个与当前内核版本匹配的内核源码
- 找到用于构建预编译内核的配置文件,一般位于
/boot目录下面,文件名类似于config-2.6.x,将其复制到内核源码文件中。
1
cp /boot/config-`uname -r` /usr/src/linux-`uname -r`/.config
- 接着仔细审查版本魔数,对比还有哪些不同,有的时候即使配置文件完全相同,版本魔数上的微小差异也会阻止模块插入。
- 有的时候模块模数出现
custom字符串(内核魔数没有),这个原因是发行版对Makefile的修改,这个时候就需要检查/usr/src/linux/Makefile确认版本信息与当前内核匹配例如其开头可能是这样:
1
2
3
4
5VERSION = 2
PATCHLEVEL = 6
SUBLEVEL = 5
EXTRAVERSION = -1.358custom
...- 将
EXTRAVERSION恢复为-1.538。保留保留/lib/modules/2.6.5-1.358/build中原始 Makefile 的备份。可以使用以下简单的复制命令:
1
cp /lib/modules/`uname -r`/build/Makefile /usr/src/linux-`uname -r`
- 如果你已经使用了错误的 Makefile 启动了内核构建,可以重新运行 make,或者直接修改
/usr/src/linux-2.6.x/include/linux/version.h中的UTS_RELEASE,使其与/lib/modules/2.6.x/build/include/linux/version.h中的内容一致。然后运行 make 来更新配置、版本头文件和目标文件:
1
2
3
4
5
6
7
8
9
10
11
12[root@pcsenonsrv linux-2.6.x]
CHK include/linux/version.h
UPD include/linux/version.h
SYMLINK include/asm -> include/asm-i386
SPLIT include/linux/autoconf.h -> include/config/*
HOSTCC scripts/basic/fixdep
HOSTCC scripts/basic/split-include
HOSTCC scripts/basic/docproc
HOSTCC scripts/conmakehash
HOSTCC scripts/kallsyms
CC scripts/empty.o
...
-
-
还可以试着重新编译内核以启用调试功能,例如:
MODULE_FORCE_UNLOAD,该选项允许通过rmmod -f module强制卸载模块,但这样做不安全。

