在上一章中,我们通过一个简单的 helloworld 例子,来认识了内核模块程序的编写、编译、装载、卸载的过程,并对代码及编译过程进行了详细的阐述。
但是,很多额外的细节知识并没有在上一章中进行讲述。本章的内容就是在上一章的基础上,展开叙述一些进阶知识。
模块参数
用户程序传参
上一章中的 helloworld 程序仅仅实现了 module_init、module_exit 两个函数,完成了模块程序所必须实现的初始化函数与退出函数,来完成模块的装载和卸载。
现在,我们想让我们的模块程序能够完成更多的功能,比如说能够在初始化的时候接收参数。
在用户程序中,我们一般使用以下方法进行参数的传入:
1 |
|
在标准的 main 函数中,接受两个参数 argc、argv 。其中 argc 代表命令行总的参数个数, argv[] 是 argc 个参数,其中 argv[0] 是程序的全名,以后的参数是命令行后面用户输入的参数。
用一个简单的例子进行说明,我们新建一个 hello.c 文件,写入以下代码:
1 |
|
编译并运行 hello.c :
1 | gcc hello.c -o hello |
内核模块传参
对于内核模块程序来讲,参数的传递要求要更严格一些。在内核模块中,传入的参数要想对 insmod 命令可见,必须使用 module_param 宏来对参数进行声明。
这个宏定义在 moduleparam.h 中,使用的时候必须放在任何函数之外,且需要三个参数:变量的名称、变量的类型、用于 sysfs 入口项的访问许可掩码,如:
1 | static int num; |
我们将上一章的代码稍加改造,来编写一个可以接收参数的内核模块:
1 |
|
在代码的第 8、9 两行中,定义了两个变量 num 、words ,并在代码的第 13 行将两个变量的值打印出来。
需要注意的是,想要进行参数的传递,必须要在代码中将要传递的参数声明为全局的 static 变量,并进行初始化。
编译完成后,使用 insmod 将模块加载:
1 | make |
使用 dmesg 查看信息:
1 | sudo dmesg |
注意,这里在使用 insmod 加载编译完成的模块时,并没有输入参数,因此,在 hello_init 中,打印 num 、words 两个变量的值是在变量定义的时候赋予的初始值。
现在,我们先使用 rmmod 命令将模块卸载,并用 dmesg 命令查看 hello_exit 函数是否正常执行:
1 | sudo rmmod helloworld |
为了防止后续加载模块时打印的信息与之前的产生混淆,先用 dimes - C 命令将 dmesg 清空:
1 | sudo dmesg -C |
再使用 dmesg 命令,可以看到并未有任何信息输出:
1 | sudo dmesg |
这次,我们使用 insmod 命令将模块加载,并传入参数 num=20 、words=”helloworld!”:
1 | sudo insmod helloworld.ko num=20 words="helloworld!" |
通过查看 dmesg 信息,可以看到传入的参数被正常打印了出来。
通过 insmod 时的命令可以看出,与用户程序传参不同,在使用 insmod 加载模块并对模块传参时,必须显示的指定变量的名称:
num=20 words=”helloworld!”
module_param 宏定义
接下来,让我们对模块参数的声明进行详细的解释:
1 | module_param(name, type, perm); |
module_param 是一个宏定义,定义在 moduleparam.h 中,其原型是:
1 |
这个宏的使用需要三个参数:name、type、perm。
name 参数
其中,name 参数表明要声明的变量名称,即如果我们要想作为外部参数传入的变量的名称,如上述示例中定义的 num 、words 两个变量。
type 参数
type 表明了参数的类型,内核支持的参数类型如下:
变量类型 | 含义 |
---|---|
bool | 布尔类型,值取为 true或 false,关联的变量应该是 int 型,其值只能为 0(false) 或 1(true)。 |
invbool | 布尔类型的反转值,也就是说传入 0,则其为 true,传入 1 则其为 false。 |
charp | 字符指针型,内核回味用户提供的字符串分配内存,并相应设置指针。 |
int | 整型变量。 |
long | 长整型变量 |
short | 短整型变量。 |
uint | 无符号整型变量。 |
ulong | 无符号长整型变量。 |
ushort | 无符号整型变量。 |
perm 参数
perm 是 sysfs 入口项的访问许可掩码,定义在 /uapi/linux/stat.h 文件中:
1 |
该值用来控制谁能够访问 sysfs 对模块参数的表述。在使用时,我们应该使用在该头文件中存在的定义,但是我们可以使用这些值可以通过或的方式进行组合,比如 S_IRUSR | S_IWUSR 表示用户拥有读写权限。当然, Linux 内核中也对几种常用的组合在 /linux/stat.h 文件中进行了定义:
1 | #define S_IRWXUGO (S_IRWXU|S_IRWXG|S_IRWXO) |
简单点来讲,在通过 insmod 命令并将参数传递进去之后,我们在模块代码中使用 module_param 声明的参数,就会以文件的形式存放在 /sys/module/modname/parameters/ 目录下,我们可以使用 ll命令查看一下:
1 | ll /sys/module/helloworld/parameters/ |
可以看到,在 /sys/module/helloworld/parameters/ 下存在 num 、words 两个文件,并且其权限都是 root 用户只读权限。
我们使用 cat 命令来查看一下这两个文件的内容:
1 | cat /sys/module/helloworld/parameters/num |
正是我们 insmod 时传入的值。
sysfs 修改参数值
结下来,我们做一个实验,首先使用 rmmod 将模块卸载:
1 | sudo rmmod helloworld |
再修改一下代码,将两个变量的权限参数修改为:S_IRUGO|S_IWUSR ,即允许 root 用户修改该参数,并且,在模块的 hello_exit 函数中再次打印这两个变量的值:
1 |
|
编译,并编译、加载:
1 | make |
使用 dmesg 查看:
1 | sudo dmesg |
可以看到,这两个变量已经成功被传入,其值分别是:
num = 20
words = helloworld!
再使用:
1 | ll /sys/module/helloworld/parameters/ |
可以看到,这两个文件已经的权限已经变成了 root 用户可读可写。
再使用 cat 命令来查看一下这两个文件的内容:
接下来,我们使用 vim 对这两个文件进行修改,由于这两个文件都是 root 用户可写的,所以要使用 sudo vi 来对这两个文件进行修改:
1 | sudo vi /sys/module/helloworld/parameters/num |
我们将 /sys/module/helloworld/parameters/num 文件中的数据修改为 40 并保存:
同理,我们使用:
1 | sudo vi /sys/module/helloworld/parameters/words |
将 /sys/module/helloworld/parameters/words 文件中的内容修改为 the_same_world! 并保存:
我们使用 dmesg 查看一下信息:
1 | sudo dmesg |
可以发现, dmesg 并没有产生任何新的信息。
再使用 cat 命令查看这两个文件的内容:
1 | cat /sys/module/helloworld/parameters/num |
可以确认,这两个文件的内容确实已经被修改了,但是 dmesg 信息中却并没有产生新的信息,难道是我们只修改了文件,而模块并没有接收到修改的值?模块中的这两个变量并没有被修改成功?
接下来,我们通过 rmmod 将模块卸载,通过查看 hello_exit 函数中打印的信息,再来看一下这两个变量的值:
1 | sudo rmmod helloworld |
通过 dmesg 信息,可以看出,模块中的这两个变量确实被我们以修改文件内容的形式将值成功得修改了。
这里要注意的是,如果模块的某个参数通过 sysfs 修改,其实如同模块修改了这个参数的值一样,但是内核不会以任何方式通知模块。大多数情况下,我们不应该让模块的参数是可写的,除非我们打算检测这种修改并做出相应的动作。
通过以上的实验,我们可以知道,模块的参数是可以通过修改文件的形式对其值进行修改的,那我们就应当注意要对参数进行适当的保护,以防止未知情况下,参数被外部进行修改。这就是 module_param 中 perm 参数的作用了。
module_param_named 宏定义
在我们使用 insmod 加载并传参的命令:
1 | sudo insmod helloworld.ko num=20 words="helloworld!" |
命令中,显示的使用了变量名来表明对哪个变量传如哪个值,变量名与模块代码中的变量名是一致的,也就是说传参的时候所指定的变量名必须在代码中已经定义且使用了 module_param 进行声明,否则参数无法正常传入:
那么,假如我们在传参的时候,就是想用其它的变量名称来传入,有什么办法呢?
其实,内核提供了另一个宏来帮助我们实现这种操作:
1 | module_param_named(name, name, type, perm) |
这个宏同样定义在 moduleparam.h 中:
1 |
这个宏有四个参数,第一个 name 参数代表着我们在使用 insmod 时传入参数时所使用的变量名称,第二个 name 代表着模块代码中真正定义的变量的名称,其他的参数含义与 module_param 中的一致。这个函数相当于给变量起了一个“别名”。
我们将源码的第 22、23 行
1 | module_param(num, int, S_IRUGO|S_IWUSR); |
修改为:
1 | module_param_named(number, num, int, S_IRUGO|S_IWUSR); |
然后重新编译,并使用新的名称将参数传入:
1 | sudo insmod helloworld.ko number=20 printwords="helloworld!" |
可以看到,这一次参数可以被正常传入。
那么,在使用了 module_param_named 给变量定义了别名之后,我们还能够使用变量真正的名字进行加载传参吗?
我们先将模块卸载,清除 dmesg 信息,使用最开始的 insmod 命令传参:
可以看出,已经无法使用变量真正的名称传参。
我们上面提到了,通过 module_param 声明的变量会在 /sys/module/modname/parameters/ 以文件的形式存在,那么 module_param_named 声明的变量又是如何存放的呢?
我们使用 ls 命令看一下 /sys/module/helloworld/parameters/ 目录下的内容:
1 | ls /sys/module/helloworld/parameters/ |
可以看到,/sys/module/helloworld/parameters/ 目录下的文件名称已经变为我们起的“别名”。
由此可知, module_param_named 不仅能够改变我们传参时使用的变量名称,也会改变 /sys/module/modname/parameters/ 下的文件名称,且已经不能使用变量原本的名称来进行传参。
module_param_array 宏定义
模块装载器也支持数组参数,在提供数组值时用逗号划分各数组成员。要声明数组参数,需要使用以下宏定义:
1 | module_param_array(name, type, nump, perm); |
num 参数
module_param_array 接收四个参数:name、type、nump、perm,其中 name、type、perm 三个参数与module_param 中的三个参数含义相同,nump 参数是一个整型变量的地址,模块装载器会将用户传入数组的元素的数量写入该地址,从而我们可以在模块内获取到用户传入的数组的大小。如果设置该值为 NULL ,装载器不会传递元素个数。
要注意的是,模块装载器不会接收超过数组大小的元素数量。
我们通过一个简单的例子来进行说明:
1 |
|
代码的第7 行定义了一个 int 型的变量,用来存储用户输入元素的数量,第 8 行定义了一个大小为 4 的 int 型数组,并进行了初始化,在第在初始化及退出函数中打印了数组中的数据。
我们将代码编译,并暂时不使用传参的形式进行加载,再查看 dmesg 的信息:
1 | make |
可以看到,打印出的是初始化值,并且并没有改变 count 变量的值。
再将模块卸载,清除 dmesg 信息:
1 | sudo rmmod helloworld |
在加载模块的时传入参数:
1 | sudo insmod helloworld.ko nums=1,2,3,4 |
如果我们传入的元素小于 4 个:
1 | sudo insmod helloworld.ko nums=1,2,3 |
装载器会将元素的个数传递给 count。
如果我们传入的元素大于 4 个:
1 | sudo insmod helloworld.ko nums=1,2,3,4,5 |
模块装载器会提示输入参数无效,demesg 中也会有进一步的提示。
如果我们将修改代码的第20行,将 module_param_array(name, type, nump, perm) 中的 nump 的值设置为 NULL:
1 | module_param_array(nums, int, NULL, S_IRUGO|S_IWUSR); |
重新编译并加载模块:
1 | make |
可以看到,此时 count 的值就不会改变了。
传参顺序影响
我们来思考一个有趣的事情,module_param_array(name, type, nump, perm) 会改变 nump 指向的整型变量的值,如果该整型变量通过 module_param 将其声明,并在模块加载时将其传入,那么会发生什么呢?
修改代码:
1 |
|
在代码的第 31 行,我们将 count 变量使用 module_param 进行声明,我们来探究以下几种情况:
第一种情况,不传参:
1 | sudo insmod helloworld.ko |
可以看到,count 的值没有被改变;
第二种情况,只传入 count:
1 | sudo insmod helloworld.ko count=2 |
可以看到,count 的值为我们传入的值。
第三种情况, 只传入 nums:
1 | sudo insmod helloworld.ko nums=1,2,3 |
由于传入了 nums,count 的值被改变为 3;
第四种情况,先传入 count 再传入 nums:
1 | sudo insmod helloworld.ko count=2 nums=1,2,3,4 |
可以看到 count 的值尽管我们对其进行了传入,但是在经过对 nums 传值之后,其值又被改变为传入元素的个数了。
第五种情况,先传入 nums 再传入 count:
1 | sudo insmod helloworld.ko nums=1,2,3,4 count=2 |
此时,count 为我们传入的值;那么这种情况下,nums 究竟接收到了几个值呢?
我们修改一下代码的第 15-17 行的 for 循环内容:
1 | for (i = 0; i < 4; i++){ |
重新编译后再尝试一下第五种情况:
1 | sudo insmod helloworld.ko nums=1,2,3,4 count=2 |
可以看到,尽管 count 的值为传入的 2,但是 nums 的 4 个元素还是全部传入了。
通过对第四种与第五种情况的实验,我们可以得知,在传参时,若某个变量的传参会影响到其他变量的值的话,不同的参数传入顺序会导致不同的结果。
再看三种情况:
第一种:重复传入 count 两次:
1 | sudo insmod helloworld.ko count=2 count=3 |
可以看到,对于变量,重复传入 count 两次,count 的值为第二次传入的值;
第二种,传入 nums 两次:
1 | sudo insmod helloworld.ko nums=1,2 nums=3,4,5,6 |
第三种:传入 nums 两次:
1 | sudo insmod helloworld.ko nums=1,2,3,4 nums=5,6 |
对于数组,传入两次分两种情况,若第一次传入的元素个数比第二次少,则最终的值为第二次传入的值;若第一次传入的值比第二次多,则第二次传入只会将本次传入的元素进行修改,而剩下的元素还是第一次传入的值。
module_param_array_named 宏定义
同样的, Linux 内核也提供了 module_param_array_named(name, name, type, nump, perm) 宏定义,可以为变量起别名。
sysfs 修改参数
我们知道,对数组初始化的时候,模块装载器不允许传入的元素个数超过数组的大小,但是,我们又可以使用 sysfs 来修改模块中变量的值,那么如果在模块加载后,我们通过 sysfs 修改了存储变量的文件会发生什么呢?
为了能够看到修改变量之后的影响,我们将 hello_exit 中第 25-27 行中的 for 循环也稍作修改:
1 |
|
这份代码中,hello_init 函数及 hello_exit 函数的循环会将 nums 数组的四个元素全部打印而不关心 count 变量的值,并且去掉了对 count 变量的 module_param 声明。
首先,我们看一下正常初始化的情况下 nums 文件的内容:
1 | sudo insmod helloworld.ko nums=1,2,3,4 |
再看一下只传入两个元素的情况下 nums 文件的内容:
1 | sudo insmod helloworld.ko nums=1,2 |
可以看到,此时 nums 文件中只有两个数据,若我们此时修改 nums 文件,nums 变量和 count 会发生什么呢?
我们使用
1 | sudo vi /sys/module/helloworld/parameters/nums |
将 /sys/module/helloworld/parameters/nums 文件内容修改为:
卸载模块,查看 hello_exit 打印的信息:
1 | sudo rmmod helloworld |
可以看到,通过修改 /sys/module/helloworld/parameters/nums 文件的形式,不仅修改了 nums 变量的内容,count 变量的值也同样改变了。
那么如果我们将 /sys/module/helloworld/parameters/nums 里的元素修改为 5 个,超过 nums 数组的大小,会发生什么?
重新加载模块:
1 | sudo insmod helloworld.ko nums=1,2,3,4 |
使用
1 | sudo vi /sys/module/helloworld/parameters/nums |
将 /sys/module/helloworld/parameters/nums 文件内容修改为:
此时, vim 会提示写入错误:
可以看出内核已经对这方面的安全性做出了限制,无法使用 sysfs 来强行设置超出数组变量大小的内容。
资源的创建以及释放
在以上的例子中,我们的模块程序的初始化和关闭函数,只是实现了简单的打印功能。但是,在正常的模块程序中,通常会在模块的初始化函数中进行内存、设备等资源的申请,在模块的退出函数中,应当将模块运行过程中所申请的资源进行释放。
但是值得注意的是,我们在内核中注册设施时,要时刻谨记即使最简单的资源申请动作都有失败的可能。因此,我们在初始化函数中所有涉及到资源申请的时候,都应该检查返回值,以确认所请求的操作是否真正成功运行。在遇到任何错误的时候,首先要判断模块是否能够继续初始化,即尽管某样资源申请失败,模块是否能够在该资源缺失的情况下能否通过降低功能来继续运转。因此,只要有可能,模块应该继续向前运行并尽可能提供其功能。
在遇到某中特定类型的错误而导致模块因缺失某中资源而无法正常继续执行的时候,则要将该动作之前所申请到的资源全部释放掉。Linux 没有记录每个模块都注册了那些设施,因此当模块的初始化出现错误之后,模块必须自行对已经申请到的资源进行释放,否则内核中会存在一些指向并不存在的代码的指针,会导致内核运行处于一种不稳定的状态。在这种情况下,位移有效的办法就是重启系统。
接下来,我们以内存申请为例,来展示内核模块的资源创建和释放的过程,以及在遇到错误的时候,该如何处理。
1 |
|
在这个示例代码中,我们要做的事情就是使用 kmalloc 函数申请一个 4 x 5 大小的矩阵, kmalloc 函数定义在 slab.h 头文件中,同 malloc 函数功能类似,都是申请一块特定大小的内存,如果申请成功,返回申请到的内存的首地址,否则返回 NULL。
kmalloc 与 malloc 函数不同的是,malloc 在用户空间中申请内存,而 kmalloc 函数在内核空间中申请内存,并且 kmalloc 需要传入一个标志参数,并根据不同的标志来采取不同的内存分配策略。
其函数原型如下:
1 | static __always_inline void *kmalloc(size_t size, gfp_t flags); |
kmalloc 需要两个参数,第一个参数 size 是要申请的内存的大小,以字节为单位。通常情况下,kmalloc 分配的最大内存大小为 128k。
flags 参数指定了内存分配的标志,传入不同的标志会以不同的策略进行内存的分配,本章的内容暂时不对该参数作深入的探究,代码中使用的标志为内核内存常用的分配方法:GFP_KERNEL。
对应的,释放由 kmalloc 函数申请到的内存的函数为:
1 | void kfree(const void *objp); |
objp 参数为 kmalloc 函数返回的指针,不能为空。
现在再来看代码,在代码的第 8,9 两行,分别定义了的矩阵的行(row = 4),列(col = 5),代码第 10 行定义了一个二级指针(mat)。
在代码的第 16 行使用 kmalloc 函数申请了一个大小为 row x sizeof(int *) 的数组,值得注意的是,kmalloc 申请内存又可能失败,因此需要对函数的返回值进行判断。
代码的第 22-27 行,使用循环的方式,为矩阵的每一行申请一个数组,在这里,同样要注意对 kmalloc 的的返回值进行判断。
goto 处理错误
在处理 kmalloc 申请失败的情况时,这里使用了 goto 语句来针对每一种情况进行处理。尽管在用户程序的编写中,通常很少使用 goto 语句,但是在处理错误的恢复处理时使用 goto 语句却比较有效,它可以避免大量复杂的、高度缩进的“结构化”逻辑。
我们举一个例子来进行说明:假如说我们的某一项功能需要依次申请四个资源 A、B、C、D,那么在对其中的任何一个资源进行申请的时候,都要判断该资源是否申请成功,若某一个资源为能成功申请,则需要将之前申请的资源全部释放,在不使用 goto 的情况下,我们的处理方法一般是这样:
1 | if 申请 A 失败: |
可以看到随着错误发生的越晚,我们需要对错误发生之前申请的资源进行释放就越复杂,如果使用 goto 语句,则可以写成以下形式:
1 | if 申请 A 失败: |
使用 goto 语句来处理错误,代码更加清晰明了,不易出错。
同样的,在模块的退出函数中,最好也按照资源申请的相反顺序来进行资源释放,以防止出现释放错误或资源漏释放的情况出现。
One More Thing
模块的运行机制
模块一般在将其加载进内核后,以内核文件的形式存在,等待用户调用时才执行某些操作,这一点和用户程序中的动态库文件相似,平时以文件的形式存在,调用时才运行。但是与动态库文件有所不同的是,模块本身在未被其他用户调用时,其本身也可以运行某些任务,额u系统监控模块、守护进程等。
模块调用函数
在内核模块的编码过程中,所能够使用的函数有下面几种:
1、内核本身定义并实现的函数;
2、模块自身定义并实现的函数;
3、通常来讲,无法调用 C 库或是用户程序(如第三方开源库)的函数,除非将其移植进内核;
4、其他已经加载进内核的模块通过 export 导出的函数。
在内核中调用系统的带有 __ (双下划线)开头的一些函数时,可能这些函数的调用时需要有响应的环境的,只有在具备对应的环境时,该函数才能够正常运行,否则该函数可能会出现意想不到的问题。因此,在使用这类函数时,需要谨慎使用。
模块卸载
在卸载模块时,当该模块被占用的时候,该模块是无法被卸载的。比如说该模块中的某个函数正在运行(被调用),或某个其他的模块使用了本模块的一些导出函数时,尽管本模块没有任何代码在运行,模块依旧无法被卸载,因为卸载会破坏模块之间的依赖关系。
浮点运算
内核代码中尽量不要做浮点运算,如果必须要进行浮点运算,需要在浮点运算前后进行浮点运算的标记声明,但是有可能其他的 CUP 并不支持该声明。