概述
按照不成文的传统,诸多教程都会以 Hello World 程序作为最简单的例子来作为开篇的实例。因此本篇也依循惯例,通过 Hello World 的例子,来展示的简单的内核模块的代码编写、构建、运行的过程。
编写代码
首先,我们需要创建一个目录来存放我们所编写的代码。为了后续内容的条理性,我们新建一个 Drivers 文件夹作为本系列代码示例的根目录,后续的代码皆按照内容及学习顺序,在此目录下创建对应的目录来存放相应的代码文件。
本节,在 Drivers 目录下创建 01-HelloWorlds 目录,用作本节内容代码示例的存放:
进入到该目录下,新建 helloworld.c 文件:
编辑 helloworld.c 文件,输入以下内容:
1 |
|
编译模块
与一般的用户程序不同,为了编译此示例,需要用到内核源码的部分编译功能,因此,还需要编写一个 Makefile 文件来帮助我们编译该程序:
在创建的 Makefile 文件中输入以下代码:
1 | ifnq ($(KERNELRELEASE),) |
在终端中使用 make 命令编译代码:
上述输出结果证明了代码成功通过编译,可以通过 ls 命令查看编译出的文件:
可以看到,编译后,在目录中成功生成了以下文件:
helloworld.ko
helloworld.mod
helloworld.mod.c
helloworld.mod.o
helloworld.o
modules.order
Module.symvers
装载模块
与一般的用户程序所生成的目标文件为可执行文件不同,内核模块生成的目标文件为:
helloworld.ko
该文件并不同于一般的可执行文件,无法直接运行,想要运行它,需要将其装载到内核中。使用 $sudo\ insmod$ 命令可将该目标文件装载到内核中:
1 | sudo insmod helloworld.ko |
可以看到,该命令没有产生任何输出,这是因为内核模块程序的输出不会显示在控制台中,而是输出到某个系统日志文件中。因此,想要看到我们编写的模块的输出内容。需要使用 sudo dmesg 命令来查看:
可以看到,我们 helloworld.c 程序中的输出成功显示在 dmesg 信息中,此时,证明我们的编写的模块已经成功加载到了内核中。
同时,我们也可以通过 lsmod 命令来查看已经加载到内核中的模块,来查看我们编写的模块是否已经正常加载到内核中:
卸载模块
在成功装载模块后,可以通过 sudo rmmod 命令来将模块从内核中卸载:
同样的,该命令也不会中断中输出信息,我们同样要借助 dmesg 命令来查看该命令所产生的输出信息:
再次使用 lsmod 命令,已经无法在列出的已装载模块列表中找到 hellowrold 模块的信息。
结下来,将对上述的代码、编译、装载、卸载等步骤逐过程进行详细的解释。
详述
helloworld.c 代码解读
再次阅读我们所编写的源代码:
1 |
|
头文件
在代码的前三行,我们包含了三个头文件:
linux/init.h
linux/module.h
linux/kernel.h
与用户程序相似,我们在调用系统功能的时候,需要包含所需的头文件,内核模块程序同样如此。
我们来与用户程序的 $helloworld$ 程序代码作一个对比:
1 | // 用户程序 helloworld.c |
我们在编写用户程序时,用到了系统提供的 printf 函数,而该函数包含在 stdio.h 头文件中,因此我们在调用 printf 函数前,需要先包含 stdio.h 头文件,内核模块同样如此,只不过内核层编程所用的函数库与用户层编程所用到的库函数不同,所包含的头文件也不一样。
一般来讲,在 Linux 中,内核层编程所用到的头文件存放于: /usr/src/linux-headers-x.x.x/include/ 目录中。
其中,linux-headers-x.x.x 为系统的内核版本,系统内核版本不同,该目录名称也不相同,我们可以通过以下命令来看一下该目录下都有哪些内容:
1 | ls /usr/src/linux-headers-$(uname -r)/include |
而用户层编程所使用的库函数所在的头文件存放于:/usr/include/ 目录下,我们同样使用 ls 命令来看一下该目录下的内容:
在该目录中,乐意看到许多熟悉的身影,如: $stdio.h、stdlib.h、math.h、string.h$ 等头文件。
而当前所用到的三个头文件的大概作用如下:
linux/init.h
包含了模块初始化与清除的函数
linux/module.h
包含了许多符号与函数的定义,这些符号大多与加载模块有关
linux/kernel.h
包含了内核信息的头文件
一般来讲,在编写内核模块程序时,这三个头文件是必须的。
开源协议声明
接下来我们来看内核模块程序代码的第 4 行:
1 | MODULE_LICENSE("Dual BDS/GPL"); |
该行代码描述了内核模块的所遵循的开源许可协议,一般声明的协议有如下几种:
1 | “GPL” GNU General Public License的任意版本 |
如果模块未声明开源协议版本,则内核会默认该模块是一个私有的模块,当模块被加载时,将收到内核的警告。
除开源协议声明外,我们还可以在代码中进行以下内容的声明:
1 | MODULE_AUTHOR // 声明作者 如:MODULE_AUTHOR("xuarh") |
MODULE_ 声明可以写在模块的任何地方(但必须在函数外面)。
Init 函数
在代码的第 7-10 行,定义了一个 static int hello_init(void) 函数,该函数在模块被装载到内核时调用,执行该模块的初始化工作。
在该函数内,也就是代码的第 8 行,我们有以下语句:
1 | printk(KERN_INFO "Hello, world!\n"); |
该语句的作用与 printf 函数相似,即打印输出传入的字符串。但与 printf 函数不同的是,我们在内核模块中使用 printk 来打印输出信息。
并且。两个函数所输出的信息方式也不同,一般来讲 printf 函数将输出信息打印在控制台上,我们在终端中运行用户程序,即可在终端中看到 printf 函数所输出的内容,而 printk 函数则将打印输出的信息输出到日志文件中,我们必须借助 dmesg 指令才能看到该函数所输出的内容。
同时,在使用 printk 函数时,在要输出的字符串前往往会加上对该条信息优先级的定义,如代码所示的 KERN_INFO。
优先级是定义在 linux/kern_levels.h 中的宏,其他优先级的定义及含义如下:
1 |
因此,我们在通过 insmod 命令加载函数后,可以通过 demesg 命令看到模块加载时所打印的 Hello, world! 信息。
代码第 9 行,为 hello_init 函数的返回值,因为 hello_init 函数被定义为 static int hello_init(void) ,所以该函数必须要有返回值的存在。
static int hello_init(void) 函数的返回值标识着该函数的执行状态,一般使用 return 0; 来表示初始化函数正常执行完毕,返回其他值来标识该函数执行时所出现的错误。
但是,仅仅定义 static int hello_init(void) 函数是不够的,因为不同于用户程序中固定以 main 函数作为程序的入口,我们当前只是仅仅定义了 static int hello_init(void) 函数,想让它作为模块初始化的入口,但是编译器和内核是无法知道该模块初始化时的入口时哪个函数,因此我们必须指定 static int hello_init(void) 为模块的初始化函数,这样编译器及内核才能明确知道我们所定义的初始化函数是哪一个。
代码的第 16 行:
1 | module_init(hello_init); |
该语句的作用便是实现上述需求。并且,该语句是强制行的,这个宏会在模块的目标代码中增加一个特殊的段,用于说明内核初始化函数所在的位置。没有这个定义,初始化函数永远不会被调用。
同时,初始化函数还可以被声明为以下形式:
1 | static int __init hello_init(void){ |
该形式中,对初始化函数使用了 __init 的标记,它对内核来讲是一种暗示。表明该函数仅在初始化期间使用。在模块被装载后,模块装载器就会将初始化函数本身所占用的内存(指的是函数生成的目标文件的内存,而非初始化函数内申请的内存)释放掉,来节省系统内存,该声明是可选的。
在函数初始化期间,可能会使用到一些结构体或者数据,若想内核加载模块在加载完成后自动释放这些数据所占用的内存,可以在这些数据的声明前加上 __initdate 进行标记。
但是要注意的是,不要在初始化之后仍然可能会被调用的函数或者数据前使用这两个标记。
exit 函数
代码的第 12 -14 行定义了 static void hello_exit(void) 函数,该函数的作用是在模块被移除时,注销模块运行期间所申请的所有资源,本模块只是作为基础的演示,并未申请任何资源,因此在 hello_exit 函数中并未释放任何资源,只是简单地输出了一句 Bye, world! ,该条信息在模块释放后,也可通过 dmesg 命令进行查看。
与 hello_init 一样,要想让系统在释放模块时能够正确识别到所定义的 hello_exit 函数,在代码的 17 行有一个声明:
1 | module_exit(hello_exit); |
该声明帮助内核找到模块的清除函数,若一个模块未使用 module_exit 来声明清除函数,则内核不允许卸载该模块。
清除函数没有返回值,因此被声明为 void。
同样的,exit 函数的声明也可写成如下形式:
1 | static void __exit hello_exit(void){ |
__exit 修饰词标记该代码仅用于模块卸载(编译器会把该函数放在特殊的 ELF 段中)。如果模块被直接内嵌到内核中,或者内核的配置不允许卸载模块,则被标记的函数将简单地被丢弃。因此,被标记为 __exit 的函数只能在模块被卸载或者系统关闭的时候被调用,其他任何用法都是错误的。
同理,在仅用作清除阶段的数据,可使用 __exitdata 来进行标记。
编译
为了能够将我们所写的内核模块编译成可执行的 .ko 文件,我们编写了以下 Makefile 文件来编译我们的程序:
1 | ifnq ($(KERNELRELEASE),) |
obj-m
先来看 Makefile 文件的第二行:
1 | obj-m := helloworld.o |
这一行中,obj-m 代表编译生成可加载的 .ko 文件。值得注意的是,在这一行中,并没有显示地指明要变异的 helloworld.c 文件,这是得益于 Makefile 的自动推导功能,在需要编译生成 helloworld.o 文件而没有显示地指定 helloworld.c 文件时,make 将在同级目录下查找 helloworld.c 是否存在,若存在则正常编译,若不存在则报错。
obj-m+=helloworld.o 这条语句就是显式地将 helloworld.o 编译成 helloworld.ko,而 helloworld.o 则由 make 的自动推导功能编译 helloworld.c 文件生成。
相对应的,若想将模块直接编译进内核,可以使用 obj-y 。而我们在前期更想能够了解一个内核模块是如何编译、加载的,因此选择 obj-m 来生成 .ko 文件,然后手动加载与卸载模块。
KDIR、PWD
在 Makefile 文件的第 4 行与第 5 行,分别定义了两个变量: KDIR 与 PWD 。
其中, KDIR 定义为: /lib/modules/$(shell uname -r)/build
PWD 定义为:$(shell pwd)
在对以上两个变量的定义中,使用了 $(shell + 命令) 的方式来引用 shell 命令的返回值,如:$(shell uname -r) 中,相当于调用了 shell 执行 uname -r 命令,并引用返回值。在终端中,我们调用一下 uname -r 命令,看一下返回结果:
可以看到 uname -r 命令的返回结果是系统的内核版本 5.15.0-52-generic,我们使用了 $(shell + 命令) 来讲返回结果当作一个字符串来处理,所以对 KDIR 的定义等同于:
1 | KDIR := /lib/modules/5.15.0-52-generic/build |
那么,为什么我们要采用第一种写法呢?
这是因为,系统内核版本不同,我们在编译内核模块时,所依赖的内核代码的源码路径不同,在不同的内核上编译时,我们首先要确定内核代码所在的路径,更改 Makefile 文件,才能正确编译,而借助 $(shell + 命令) 的方式,在执行 make 命令时,能够根据内核的版本获取内核源码的相应路径,而无需修改 Makefile 文件。
简单点来讲,这种写法更加通用,在不同的内核版本的系统中,可以直接使用这个 Makefile 文件而无需修改。
那么,对 PWD 的定义的也就理解了,其定义相当于:
1 | PWD := /home/xuarh/Drivers/01-HelloWorld |
all、clean
在 Makefile 文件的第 7 行与第 9 行,分别定义了 ”all“,与 “clean” 两个伪目标,伪目标的含义并不是真正的编译目标,而是代表着一些列我们想要执行的命令集合,通常一个 Makefile 会对应多个操作,例如编译,清除编译结果,安装等,那么就可以使用这些伪目标来进行标记。在执行时就可以键入 make + 伪目标 ,即可执行伪目标所包含的指令,如在这个 Makefile 中,我们就可以使用:
1 | make all |
来执行不同的操作。
简单点来讲,就是如果我们想用一个 Makefile 来实现不同的操作,就可以定义一个伪目标,并在该伪目标的作用域内编写我们想要的指令,然后通过执行 make + 伪目标 来实现不同的目的。
当 make 后不带参数时,默认执行第一个伪目标的操作。
Makefile 文件的第 8 行,在 all 定义域中的指令:
1 | make -C $(KDIR) M=$(PWD) modules |
就是我们真正想执行的目的了,下面分别介绍每个字段的含义。
-C 参数指的是指将当前工作目录转移到所指定的位置,执行该目录下的 Makefile 文件。我们知道,KDIR 是一个变量,它的值是对应内核源码目录下的 build 目录的路径,在我的系统环境中,它的值为:/lib/modules/5.15.0-52-generic/build ,因此,- C 参数会跳转到/lib/modules/5.15.0-52-generic/build 目录下,使用该目录下的 Makefile文件。
M=$(PWD) 让上述/lib/modules/5.15.0-52-generic/build 下的 Makefile 文件在构造 modules 目标之前返回到源代码目录,即 /home/xuarh/Drivers/01-HelloWorld。
modules 实际上是一个可选项,它代表我们要将源文件编译成的目标,即内核模块。
那么
1 | make -C $(KDIR) M=$(PWD) modules |
指令的真正含义是:使用 /lib/modules/5.15.0-52-generic/build 下的 makefie 文件,将 /home/xuarh/Drivers/01-HelloWorld 中的源文件编译成内核模块(modules)。
这时,我们能够发现,真正将我们所编写的 helloworld.c 文件编译成 .ko 文件的并不是我们自己所编写的 Makefile 文件,而是内核对应源码路径下的 Makefile 文件,我们自己所编写的 Makefile 文件更加类似于一个脚本,借助系统内核 Makefile 来编译我们的 helloworld.c 。
其实,从我们所编写的 Makefile 文件内容也可以看出端倪,在 helloworld.c 中,我们引用了几个头文件,而这些头文件并不是标准的 C 库头文件,但是我们自己所编写的 Makefile 并没有对这些头文件的依赖进行指定,但是还是能够成功编译,原因就在于真正在 helloworld.c 编译中使用的是内核源码路径下的 Makefile 文件。
/lib/modules/5.15.0-52-generic/build 目录是一个软连接,链接到源码头文件的安装位置。而内核真正的源码库则直接引用正在运行的内核镜像。
那么,第 10 行的内容:
1 | make -C $(KDIR) M=$(PWD) clean |
也好理解了,其含义是:对 /home/xuarh/Drivers/01-HelloWorld 目录执行 /lib/modules/5.15.0-52-generic/build 下的 makefie 文件的 clean 伪目标,即将 /home/xuarh/Drivers/01-HelloWorld 目录下在编译过程中所产生的中间文件清除。
KERNELRELEASE
再回过头来看 Makefile 文件的前 5 行,使用了一个 ifneq 与 else 条件判断。为什么要在这里加上这个条件判断呢?
这要从 Linux 内核模块的 make 执行过程说起:当我们在终端中键入 make 时,make 在当前目录下寻找Makefile 并执行,KERNELRELEASE 在顶层的 (源码路径下的)Makefile 中被定义,所以在执行当前Makefile 时 KERNELRELEASE 并没有被定义,走 else 分支,直接执行
1 | make -C $(KDIR) M=$(PWD) modules |
而这条指令会进入到 $(KDIR)/build 目录,调用顶层的 Makefile,在顶层 Makefile 中定义了 KERNELRELEASE变量。
在顶层 Makefile 中会递归地再次调用到当前目录下的 Makefile 文件,这时 KERNELRELEASE 变量已经非空,所以执行 if 分支,在可加载模块编译列表添加 hello world 模块,由此将模块编译成可加载模块,并放在当前目录下。
归根结底,各级子目录中的 Makefile 文件的作用就是先切换到顶层 Makefile,然后通过 obj-m 在可加载模块编译列表中添加当前模块,kbuild 就会将其编译成可加载模块。
需要注意的一个基本概念是:每一次编译,顶层 Makefile 都试图递归地进入每个子目录调用子目录的 Makefile,只是当目标子目录中没有任何修改时,默认不再进行重复编译以节省编译时间。
这里同时解决了上面的一个疑问:既然是从顶层目录开始编译,那么只要顶层目录中指定了架构 (ARCH) 和交叉编译工具链地址 (CROSS_COMPILE),各子目录中就不再需要指定这两个参数。
make 执行过程
经过以上的解释,我们对编译的过程大概有了了解,但是可能还是不够清晰,接下来,我们对 make 的过程进行一个梳理,来加深我们对编译过程的理解。
为了能够更清晰的对 make 的过程进行表述而不产生歧义,我们先作以下假定:
1、我们将我们所编写的 helloworld.c 代码文件所在的目录(/home/xuarh/Drivers/01-HelloWorld)称之为工作目录;
2、将内核版本对应的源码目录(/lib/modules/5.15.0-52-generic/build)称之为内核源码目录;
3、将工作目录下的 Makefile 文件称为 Sub-Makefile(子 MakeFile);
4、将内核源码目录下的 Makefile 文件称之为 Top-Makefile(顶层 Makefile);
当我们在工作目录的终端中,输入 make 命令后,make 工具会在当前目录下寻找 Makefile 文件,于是找到了 Sub-Makefile 文件并开始执行。
在执行到 Sub-Makefile 的第一行时,此时 make 工具发现 KERNELRELEASE 变量没有定义,于是执行到了 else 分支之中,通过:
1 | KDIR := /lib/modules/$(shell uname -r)/build |
两条语句定义了变量 KDIR 、与 PWD。
接下来,执行 all 中的语句:
1 | make -C $(KDIR) M=$(PWD) modules |
我们先来看 make -C $(KDIR) ,这个语句相当于对内核源码目录执行 make 命令,于是, make 工具进入到内核源码目录,并在该目录下寻找 Makefile 文件,于是找到了 Top-Makefile 文件,并使用该文件中的规则来进行编译。
M=$(PWD) ,make 工具在使用 Top-Makefile 要编译 modules 之前,返回到工作目录中,于是 make 工具又进入到工作目录中,寻找该目录下的 MakeFile 文件,于是又找到了 Sub-Makefile ,并进入该文件开始执行。
在执行到 Sub-Makefile 的第一行时,因为 Top-Makefile 中对 KERNELRELEASE 进行了定义,于是,进入到 if 分支,开始执行:
1 | obj-m := helloworld.o |
于是 make -C $(KDIR) M=$(PWD) modules 中的 modules 目标指向了 obj-m 中设定的模块,即 helloworld.o,此时,才真正开始对 helloworld.c 文件的编译。
其实,在我们使用 make 命令编译我们的代码的时候,make 命令执行时的输出,也能看到这个过程:
在输出的信息中,我们可以看到 make[1]: 进入目录“/usr/src/linux-headers-5.15.0-52-generic” 与 make[1]: 离开目录“/usr/src/linux-headers-5.15.0-52-generic” 的提示。
One More Thing
头文件的放置
当编译的目标模块依赖多个头文件时,kbuild 对头文件的放置有这样的规定:
直接放置在 Makefile 同在的目录下,在编译时当前目录会被添加到头文件搜索目录。
放置在系统目录,这个系统目录是源代码目录中的 include/linux/。
与通用的 Makefile 一样,使用 -I$(DIR) 来指定,不同的是,代表编译选项的变量是固定的,为 ccflag.
一般用法是这样的:
1
ccflags-y := -I$(DIR)/include
kbuild 就会将$(DIR)/includ目录添加到编译时的头文件搜索目录中。
编译多个源文件
在本章中只是列举了一个很简单的例子,但是在实际开发中,就会出现更复杂的情况,这时候就需要了解更多的Makefile 选项。
比如,当一个 .o 目标文件的生成依赖多个源文件时,显然 make 的自动推导规则就力不从心了(它只能根据同名推导,比如编译 filename.o,只会去查找 filename.c)。
举个例子,假如我们编写的 helloworld.c 文件中包含 a.c、b.c 文件中实现的函数,那么在生成 helloworld.o 文件时,需要首先将 a.c 、b.c 生成 a.o、b.o,再将这两个 .o 文件链接到 helloworld.o 中。这时,我们的 Makefile 文件就可以这样写:
1 | obj-m += helloworld.o |
这里的 a.o 和 b.o 并没有指定源文件,这是因为 make 会根据推导规则找到对应的源文件 a.c ,b.c。
除了 helloworld-y,也可以用 helloworld-objs,两者实现效果是一样的。
同时编译多个可加载模块
kbuild 支持同时编译多个可加载模块,也就是生成多个 .ko 文件。
如要编译 helloworld1、helloworld2 两个可加载模块,且 helloworld1 依赖于 a.c 、b.c 所生成的 a.o 、b.o, helloworld2 依赖于 c.c 、d.c 所生成的 c.o 、d.o,则 Makefile 中可以这样写:
1 | obj-m := helloworld1.o helloworld2.o |