点击上方“嵌入式与Linux那些事”
选择“置顶/星标公众号”
module_init()梳理了一遍内核模块的入口问题。我们先来回顾一下:module_init()其实并不是普通意义上的函数调用,对于.ko模块来说,他会通过宏和符号别名机制,把初始化函数接到模块的标准入口上,在后面使用insmod加载对应模块时,最终会通过模块标准入口调用到初始化函数。更详细的内容可以参考上一篇文章。在查资料,实验并完成上一篇文章之后,我获得的最大收获就是:内核模块可能并不是我以前想象的那么简单,它的底层实现是一套复杂而又严谨的机制。所以这篇文章我们来学习一个看似简单,但其实能够很大程度上提升你对内核模块理解的一个内容,就是内核模块宏。以前看这些宏:__init __exit module_param() MODULE_LICENSE() MODULE_AUTHOR() MODULE_DESCRIPTION()我更多的是把他们当成模板代码,每次敲代码的时候走个形式,至于它们在编译时到底干了什么,在.ko中留下了什么东西,加载模块时又怎么用这些东西,我没有想清楚,甚至可以说当时根本没意识到这块能够继续深入学习。就算是C语言初学者,也知道宏是在预处理阶段进行的简单文本替换,但是看了这部分的源码之后,我发现这些内核宏绝大多数都不只是进行了文本替换,它们中有的宏会影响这个函数被放在ELF的哪个段里,有的会生成参数描述结构体,有的会把许可证、作者、描述信息写进模块元信息里。换句话说,内核模块里的很多宏,本质上是在给.ko生成一份说明书。这篇文章就主要围绕这个问题展开,看看内核模块的这些宏到底在编译阶段干了什么。1. 我最初对宏的刻板印象
当初学C语言的时候,宏给我的第一印象通常是预处理阶段的文本替换,大家应该也是这样学的。
比如:
#define PI 3.14或者:
#define MAX(a, b) ((a) > (b) ? (a) : (b))这种宏确实比较直观,预处理器把宏展开,后面再交给编译器处理。
所以我刚开始看内核模块时,也很容易用这种方式理解:
staticint__initmydrv_init(void){return0;}当时我的想法是:“__init应该就是一个标记,说明这个函数是初始化函数。”
再比如:
module_param(ret_value,int,0644);我一开始也只是把它理解成“这个宏让ret_value可以从insmod命令行传进来。”
这些理解不能说完全错,但都太表面了,这两句话都只是描述了宏最终达成的效果,而没有说明宏是怎么一步步实现这个结果的。
真正继续往源码里看,会发现内核里的宏通常不只是替换几个字符这么简单,它们往往会配合GCC扩展、链接脚本、ELF section、特殊的结构体和用户态工具一起工作。
比如__init,它不是单纯提醒人这是初始化函数,而是会影响这个函数最终被放到.init.text这种特殊代码段里。再比如module_param(),它不是在宏展开的位置直接给变量赋值,而是生成一个struct kernel_param,并把它放进__param段,模块加载时,内核再根据这些描述项解析insmod传进来的参数。还有MODULE_LICENSE("GPL"),它也不是注释,而是会生成模块元信息,使用modinfo就能看到license: GPL,就是因为编译出来的.ko里真的携带了这些信息。
所以我后来对内核模块宏形成了一个新的理解:在内核里,宏经常是生成信息的工具,而不只是简单的替换代码的工具。
它生成的东西可能是一个标准入口符号,一个特殊ELF段里的函数,一个模块参数描述结构体,一条.modinfo元信息,一个用于模块加载器识别的信息表。这也是为什么内核代码里宏特别多,而且有些宏看起来不像普通C代码。因为它们很多时候不只是给当前这段C代码服务,而是在给后面的编译、链接、模块加载、sysfs、modinfo等机制提供信息。
下面就从最常见的__init和__exit开始。它们看起来只是修饰函数,但实际上涉及到一个很重要的概念:函数和变量在编译后会被放进不同的段里。
2. __init和__exit函数为什么会被放到特殊段里?
我们先看最常见的一种写法:
staticint__initmydrv_init(void){return0;}staticvoid__exitmydrv_exit(void){}最初我看到__init和__exit,更多是把它们当成一种语义标记。比如__init说明这是初始化函数,__exit说明这是退出函数,这个理解不能说错,但这只是表面现象。
继续看源码会发现,__init背后真正关键的是这一层:

也就是说,__init最重要的作用,不是给我们看的注释,而是把被它修饰的函数放到一个特殊的代码段里,即.init.text。
我们还可以简要看一下注释,注释说:这是适用于所有架构的通用代码,但后面补充说明,并不是所有架构在编译内核模块时,都会真正丢弃这个段。
在Linux内核中,.init.text段通常存放初始化代码,这些代码在内核启动完成后可以被释放以节省内存。然而,对于可加载模块,某些架构可能不会在模块加载或卸载过程中丢弃该段,因此这个宏在模块中可能不会起到节省内存的作用,但定义依然存在以保证代码的可移植性。
我们再继续深入往下看,__section本身也是一个宏:

所以:
__section(".init.text")最终会变成GCC的section属性:
__attribute__((__section__(".init.text")))套到函数上,就可以理解成:
staticint__attribute__((__section__(".init.text")))mydrv_init(void){return0;}这样一来,mydrv_init()编译后就不会被放到普通的.text代码段里,而是会进入.init.text这种初始化代码段。
这让我意识到一个以前没怎么认真想过的问题:C代码编译出来以后,不是所有东西都随便堆在一起。函数、变量、常量都会被放进不同的段里。
常见的段大概有:

内核之所以要把初始化代码单独放到.init.text,就是因为这些代码通常只在启动阶段或者模块加载阶段用一次,初始化完成后,它们就不应该长期占着内核内存。
所以__init的核心链路可以这样理解:

__exit也是类似思路:

它会把退出函数放到.exit.text段里,对于.ko可加载模块来说,rmmod卸载模块时会用到退出函数,但对于内建进内核的驱动来说,因为不能像模块一样动态卸载,所以__exit修饰的退出逻辑通常没有实际的意义。
这里还有一个需要注意的点,被__init修饰的函数,原则上只应该在初始化阶段调用。因为它所在的.init.text段在初始化完成后就已经被释放,如果后面的普通运行路径里还去调用这个函数,就可能访问到已经释放的代码区域。
所以现在再看:
staticint__initmydrv_init(void)它表达的意思就不只是这是一个初始化函数,而应该更准确地理解成这是一个只在初始化阶段使用的函数,把它放到初始化代码段里,初始化结束后它不需要长期存在。
这就是我看完源码后对__init的第一感受:它不是一个普通的修饰符,也不是单纯给人看的标记,而是通过宏、GCC属性和ELF段的共同作用,改变了函数在模块文件里的组织方式。
也就是说,内核模块里的宏开始变得不只是文本替换了,它已经在参与生成.ko文件的结构。
3. module_param():为什么insmod能改变模块里的变量?
上一篇文章里,我为了测试init返回值,写过这样一个参数:
staticintret_value=0;module_param(ret_value,int,0644);然后加载模块时可以这样传参:
sudo insmod mod_ret_test.ko ret_value=-22结果模块里的ret_value真的就变成了 -22。
最初我对这个现象的理解也很简单:“module_param()让这个变量可以从命令行传进去。”但这句话还是太表面了。
继续看源码之后会发现,module_param()并不是在当前位置直接给ret_value赋值,它真正做的事情,是在编译期生成一份参数描述信息,告诉内核这个模块有一个参数,名字叫ret_value,类型是int,对应的变量地址是&ret_value,权限是0644。
我们下面从第一层开始看:

也就是说:
module_param(ret_value,int,0644);会先展开成:
module_param_named(ret_value,ret_value,int,0644);大家可能疑惑这里为什么有两个ret_value?这其实是因为module_param_named()支持对外暴露的参数名和模块内部变量名不一样。
比如:
staticintdebug_level;module_param_named(debug,debug_level,int,0644);这样用户加载模块时写的是sudo insmod xxx.ko debug=3,但真正被赋值的是模块里的变量debug_level。
而普通的module_param(ret_value, int, 0644),只是默认让参数名和变量名相同而已。
我们继续深入往下看,module_param_named()的核心是:

把ret_value和int代进去,可以先理解成:
param_check_int(ret_value,&(ret_value));module_param_cb(ret_value,¶m_ops_int,&ret_value,0644);__MODULE_PARM_TYPE(ret_value,"int");第一句param_check_int()用来做类型检查,避免你明明声明的是int类型参数,却传了一个不匹配的变量。
第二句最关键:
module_param_cb(ret_value,¶m_ops_int,&ret_value,0644);它把参数名、类型操作函数、变量地址和权限继续传下去。这里的param_ops_int很重要,它说明这个参数按int类型处理,以后内核解析ret_value=-22时,真正负责把字符串"-22"转成整数-22的,就是param_ops_int里面的set函数。
第三句:
__MODULE_PARM_TYPE(ret_value,"int");用来生成参数类型相关的模块元信息,比如以后用modinfo看.ko模块时,可以看到参数类型信息。
再继续深入能看到:


这段看着很复杂,但按ret_value代进去,主干就清楚了:
staticconstchar__param_str_ret_value[]="ret_value";staticstructkernel_param__param_ret_value__used__section("__param")={__param_str_ret_value,THIS_MODULE,¶m_ops_int,0644,-1,0,{&ret_value}};也就是说,module_param()最终生成了一个struct kernel_param结构体,并且把它放到了一个特殊段__param里面。
struct kernel_param这个结构体里记录了:
.name = "ret_value".ops = ¶m_ops_int.arg = &ret_value.perm = 0644这就很清楚了。
模块参数不是运行时临时靠变量名去查找的,而是编译时就生成了一张参数描述表。模块加载时,内核扫描这个模块里的__param段,找到所有struct kernel_param描述项,然后根据用户传入的参数名,比如ret_value=-22,找到名字匹配的那一项。
找到对应的参数描述项之后,内核还需要解决一个问题:ret_value=-22里面的-22本质上还是字符串,怎么把它变成真正的整数,并写进ret_value这个变量里呢?
关键就在struct kernel_param里的这个字段:
.ops=¶m_ops_int这里的ops不是一个普通变量,而是一张参数操作函数表。源码里可以看到它的结构大概是这样:

其中set用来设置参数值。比如执行:
sudo insmod mod_ret_test.ko ret_value=-22内核解析到ret_value=-22后,会找到前面生成的struct kernel_param,然后通过它里面的ops->set()去设置参数。
对于int类型来说:
.ops = ¶m_ops_int所以实际会走到param_ops_int里的set方法。
get 则用来读取参数值,比如模块加载成功后执行:
cat /sys/module/mod_ret_test/parameters/ret_value内核就会通过ops->get()把当前的整数值转换成字符串,再返回给用户态。
所以可以先把关系理解成:

那param_ops_int又是从哪里来的?
我们继续看源码,会发现它不是手写出来的一段普通定义,而是通过一个宏批量生成的:

把int代进去以后,就会变成:
intparam_set_int(constchar*val,conststructkernel_param*kp){returnkstrtoint(val,0,(int*)kp->arg);}intparam_get_int(char*buffer,conststructkernel_param*kp){returnscnprintf(buffer,PAGE_SIZE,"%i\n",*((int*)kp->arg));}conststructkernel_param_opsparam_ops_int={.set=param_set_int,.get=param_get_int,};这样逻辑就闭环了。
我们再完整的梳理一下整个流程:
对于:
staticintret_value=0;module_param(ret_value,int,0644);module_param()会生成一个参数描述项:
name = "ret_value"ops = ¶m_ops_intarg = &ret_valueperm = 0644当执行:
sudo insmod mod_ret_test.ko ret_value=-22内核会根据参数名找到这个描述项,然后调用:
param_ops_int.set("-22",kp)也就是:
param_set_int("-22",kp)而 param_set_int() 内部最终会调用:
kstrtoint("-22",0,(int*)kp->arg);因为:
kp->arg = &ret_value所以实际效果就是把字符串"-22"转成整数-22,再写入ret_value变量。
到这里,module_param() 的完整链路就能串起来了:

所以,module_param()并不是在源码这一行直接修改变量,它真正做的是提前生成一张参数描述表,等模块加载时,内核再根据这张表完成参数匹配、类型转换和变量写入。
4. 参数为什么会出现在sysfs?
前面看module_param()的时候,第三个参数一直写的是:
module_param(ret_value,int,0644);一开始我也只是照着教程写0644,知道它大概和权限有关,但并没有仔细想过它到底影响了什么。
继续看源码和实验后会发现,这个0644主要决定的是:模块加载成功后,这个参数是否会暴露到sysfs,以及暴露出来以后能不能读写。
可以看到,没有加载模块时这个路径并不存在,加载之后就能看见了,这正是模块参数系统根据module_param()生成的参数描述信息,在模块加载成功后自动暴露出来的:

代码里默认写的是:
staticintret_value=0;module_param(ret_value,int,0644);那么继续在板子上做一个简单实验:

刚加载时ret_value的值为0,说明insmod mod_ret_test.ko ret_value=0传进去的字符串"0",已经转换成int类型并写进了ret_value变量。
然后在命令行向ret_value写入10,再读一次,确实变成10了,就说明模块加载成功后,可以通过sysfs参数文件继续修改这个变量。
这里要注意一点,通过sysfs写参数时,内核也不是直接改变量,它仍然会走前面那套参数系统。
也就是说:

再回到0644本身,它就是Linux里常见的文件权限:
0:八进制前缀6:所有者可读可写4:组用户可读4:其他用户可读所以0644表示root可以读写这个参数文件,普通用户一般只能读。
如果写成:
module_param(ret_value,int,0444);那参数文件就是只读的,模块加载时仍然可以通过:
sudo insmod mod_ret_test.ko ret_value=10传参,但模块加载成功后,就不能再通过sysfs修改它。
如果写成:
module_param(ret_value,int,0);那含义又不一样。
这表示参数仍然可以在insmod时传入,但不会在/sys/module/mod_test/parameters/下暴露成参数文件。也就是说,加载时可以用,加载成功后用户态看不到这个sysfs参数入口。
到这里,module_param(ret_value, int, 0644)这句话我们已经理解的差不多了,它不只是说ret_value可以从insmod传进来,还表明:
生成一个 int 类型模块参数描述项;参数名是 ret_value;变量地址是 &ret_value;加载时可以解析 ret_value=xxx;模块加载成功后,根据 0644 权限在 sysfs 下暴露参数文件;后续读写这个文件时,仍然通过 param_ops_int 的 get/set 方法完成。它表面上只是一行宏,但背后同时连接了编译期的__param段、模块加载时的参数解析、运行时的sysfs文件,以及不同类型参数的set/get函数。
5. MODULE_LICENSE和.modinfo
前面讲module_param()的时候,其实已经能看出一个规律:内核模块里的宏,很多不是在当前位置执行一段普通C逻辑,而是在编译期往.ko里生成某种描述信息。
module_param()生成的是参数描述信息,放到__param段里。
接下来这些宏也是类似思路:
MODULE_LICENSE("GPL");MODULE_AUTHOR("xlp");MODULE_DESCRIPTION("Test module init return value");MODULE_PARM_DESC(ret_value,"return value of module init");以前我更多把它们当成模块注释,但继续看源码后会发现,它们不是普通注释。注释不会进入编译产物,而这些信息会真的写进.ko文件里。
比如MODULE_LICENSE("GPL")的宏定义:

先不管MODULE_FILE,主干就是MODULE_INFO(license, "GPL"),可以理解成给当前模块记录一条元信息:
key = licensevalue = GPL再继续往下,MODULE_INFO会展开到更底层的__MODULE_INFO:


__MODULE_INFO__用于在Linux内核模块的.modinfo段中存放键值对形式的元数据,比如模块的作者、描述、许可证等信息。
__UNIQUE_ID(name)是GCC内置宏,能生成全局唯一的标识符。__section("_modinfo")表示将定义好的字符串数组放入目标文件的_modinfo段。stringify(tag)会将tag原样转为字符串。
所以完整链路可以这样理解:

MODULE_AUTHOR()、MODULE_DESCRIPTION()也是类似的。
MODULE_PARM_DESC()则和参数描述有关,它会给某个模块参数增加说明文字。前面module_param(ret_value, int, 0644)已经让内核知道了参数名和类型,而MODULE_PARM_DESC(ret_value, "...")会让modinfo能显示这个参数的说明。
可以看到,最后一行就是模块参数的说明:

此外,还能看到author,license,description等信息。
这说明modinfo不是在读你源码里的注释,也不是在读当前系统里模块的运行状态,而是在读取.ko文件里的.modinfo元信息段。
所以再看这些宏,就不能把它们当成可有可无的注释,尤其是MODULE_LICENSE("GPL")。如果不写它,或者写了非GPL兼容许可证,内核可能会认为这个模块不是GPL-compatible,这样不仅可能导致内核被标记为tainted,还会影响模块能否使用EXPORT_SYMBOL_GPL导出的符号。
不过我在实际实验是还发现了另一种现象,需要进行区分。有时候即使你写了:
MODULE_LICENSE("GPL");加载外部模块时,dmesg里仍然可能看到:
loading out-of-tree module taints kernel这不一定是license问题。因为taint有不同原因,你写了MODULE_LICENSE("GPL"),只是说明模块声明了GPL-compatible许可证,但这个模块如果是内核源码树外单独编译出来的out-of-tree模块,仍然可能因为外部模块这个身份触发out-of-tree taint。
也就是说,MODULE_LICENSE("GPL")不是让所有taint都消失,它主要解决的是license相关判断。
6. 小结
回顾一下,本文主线在讲很多内核宏并不只是简单的文本替换,而是在编译期生成各种信息,这些信息不会停留在源码层面,而是会进入最终的.ko文件,模块加载、参数解析、sysfs参数文件、modinfo查看、license判断,后面都会用到它们。
所以现在再看内核模块里的宏,就不会只把它们理解成替换一下代码,更准确地说,它们是在帮内核模块生成说明书。
这也是我觉得内核源码难读但又很有意思的地方,它表面上还是C语言,但很多机制已经不只是普通函数调用,而是宏、编译器属性、ELF段、链接过程和内核加载流程一起配合完成的。
本文结束,感谢阅读。
原文链接:https://zhuanlan.zhihu.com/p/2038730245646328458;版权归原作者所有,如有侵权,请联系作者删除;
end
往期推荐


扫码加我微信
进技术交流群


分享

收藏

点赞

在看
夜雨聆风