目录
前言
这个部分是从内核学习中划分出来,单独讨论模块和驱动的编程的一些资料和整理的内容。他跟内核之间的差别在于这个子系列的部分更加倾向于介绍驱动开发的部分而不是内核的实现细节,对于一些内核内容也是一笔带过。对于这个系列的文章是为ARM开发板上搞外设驱动的朋友而言参考价值稍大的文章。
基本的驱动框架
比起来上来就堆冗杂的概念,常看笔者文章的同志知道,笔者更喜欢先上代码,有一个大致的印象大伙更知道自己可能需要看啥
#include <linux/init.h>
#include <linux/module.h>
MODULE_AUTHOR("Charliechen<725610365@qq.com>");
MODULE_LICENSE("Dual MIT/GPL");
MODULE_DESCRIPTION("A simple hello world module");
MODULE_VERSION("1.0");
static __init int hello_init(void)
{
pr_info("Hello, world!\n");
return 0;
}
static __exit void hello_exit(void)
{
pr_info("Goodbye, world!\n");
}
module_init(hello_init);
module_exit(hello_exit);
好像我看到所有的模块的课本都是这个东西0帧起手的,我也这样做,因为对于驱动开发,这就是最简单的代码
pr_info的介绍,笔者在之前的博客早就说过了,这里给出连接:深入理解Linux内核1——printk系列-CSDN博客,这里是笔者对于内核的打印函数的介绍。不会特别细,但是够用了
如何使用呢?答案是借用内核的Makefile功能
obj-m += hello.o # 注意,这里文件名(filename,没有suffix)要跟你的源文件的名称一致
all:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
新手们需要注意的是——出于ABI的一致性原则,你必须要用你准备让驱动跑在的内核版本的源代码(对,连最小的版本号都不要差出来)的Makefile来指导编译你的驱动,不然的话编译会通过,但是你无法insmod(挂载模块),抱怨符号协议版本对不上的毛病。
这是因为模块加载过程由
insmod
、modprobe
等工具发起,内核首先根据模块的 vermagic 校验当前运行内核的版本标识,若不匹配则拒绝加载,以避免 ABI 不兼容导致的系统崩溃。通过在编译时将CONFIG_LOCALVERSION
和 SCM 版本戳入 vermagic 中,开发者可精确指定模块针对的内核版本;modpost
在生成.mod.o
时会将此信息嵌入,使得模块与/lib/modules/$(uname -r)/
路径完全对应,保障模块加载的安全性与正确性
现在如果你编译成功了,就可以到开发板/自己的上位机上试着挂载了。Linux几乎大大小小的发行版或者是我们自己搓根文件系统的时候都有insmod和rmmod等一系列模块操作的工具,直接insmod模块即可。
这个时候,dmesg就会打印出来Hello, world!,卸载模块的时候(rmmod)就会打印Goodbye, world!
源代码技术细节
加载到卸载
我们发现模块是没有main的,实际上,我们的模块的入口是被定义的,对于那些被定义成模块入口和出口的函数,需要依次打上标记符__init
和__exit
才是对的,实现的细节是:
执行 insmod mymod.ko
时,用户态工具首先打开模块文件,并通过 init_module(2)
系统调用将二进制内容传递至内核;内核收到后在内核空间分配与映射内存页,解析 ELF 重定位条目,根据内核符号表完成符号链接,并执行注册在 .init
节的初始化函数。初始化函数成功返回后,内核将 .init
节回收,腾出内存;如初始化失败,则自动调用清理路径卸载已注册的子组件并释放资源。卸载时调用清理函数(.exit
),撤销 procfs
、sysfs
和设备注册,然后解除符号导出并回收所有内存分配,最终将模块从运行时内核实体中移除
换而言之,实际上module_init和module_exit告知了内核如何初始化和回收我们的模块而已,没啥太多特别的东西。
一些Module的宏
下面我打算说的是四个宏。
MODULE_LICENSE
MODULE_LICENSE
宏用于声明模块的许可证类型,内核在加载模块时会检查该字段,若与内核许可不兼容,会在 dmesg
中打印警告并将内核置为 “tainted” 状态,从而阻止部分 GPL-only 符号的导出。
MODULE_LICENSE("Dual MIT/GPL");
支持的标识包括 GPL
、GPL v2
、LGPL
、Dual BSD/GPL
、Proprietary
等,开发者可根据自身代码与依赖的许可证要求进行选择;若省略此宏,内核默认视为 Proprietary
,并在加载时发出警告。填写的具体定义在module.h中,配好环境之后你可以跳转查看
MODULE_AUTHOR
MODULE_AUTHOR
宏用于指定模块作者信息,通常填写开发者的姓名和联系方式,以便用户在发现问题或贡献补丁时能找到对应人。该信息同样嵌入 .modinfo
,可通过命令:
modinfo your_module.ko
MODULE_AUTHOR("Charliechen <xxxdemo@demo.com>");
此宏可多次调用以列出多位作者,也可留空或省略,但不影响模块加载,仅作为文档用途。
MODULE_DESCRIPTION
MODULE_DESCRIPTION
宏为模块提供简短的功能说明,帮助使用者快速了解模块用途,格式如下:
MODULE_DESCRIPTION("A simple hello world module");
该描述会在 modinfo
的 description:
字段显示,并在分发时作为 README 或文档的一部分出现;良好的描述有助于运维人员快速定位模块功能,避免盲目加载或卸载错误模块。
MODULE_VERSION
MODULE_VERSION
宏用于给模块指定版本号,便于跟踪发布和调试,尤其在 CI/CD 流程中可与代码仓库的版本标签保持一致。示例:
MODULE_VERSION("1.0");
modinfo
将在输出中展示 version:
字段,用户可据此判断是否需要升级或回滚模块版本。该宏仅影响元数据,不参与符号解析,也不会作为加载条件,但在发布管理中极为重要。
模块的技术细节
模块的引用计数性质
内核为每个加载的模块维护引用计数,初始为零,调用 try_module_get()
或 module_put()
时会递增或递减该计数,确保模块在被其他组件引用时不会被卸载,从而避免内存访问冲突或野指针问题。当使用 rmmod
请求卸载模块时,内核会检查引用计数,只有当计数回到零且模块未标记为 tainted(被专有模块污染)时,才会调用清理函数并释放模块占用的内存。这个事情,会在我们马上道来的模块的库性质部分阐述道,当一些模块作为了其他模块的库模块的时候,我们就会选择加这个模块的引用计数,代表这个模块现在正在被依赖。具体的引用计数你可以在lsmod中看到,被引用的模块会告知引用计数和是那些模块正在引用。
模块的库性质——模块是可以像应用层那样开发的
应用层咱们都喜欢用库,驱动开发也行,代表性的就是USB驱动,所有的USB驱动都依赖更加底层的USB Core驱动。模块堆叠本质是内核模块间的依赖关系,其中下层模块导出符号(如函数、变量),上层模块通过 EXPORT_SYMBOL
调用这些接口。在加载时,内核符号解析器会将上层模块对下层符号的引用重定位到实际地址,完成链接后再执行初始化函数;卸载时则按相反顺序清理,确保不产生悬空引用。
堆叠实现通常依赖于以下几方面机制:
-
编译期:
modpost
在生成.ko
文件时收集模块间依赖信息,并在.modinfo
节中记录depends=
,为动态加载时的自动依赖解析提供依据。 -
加载期:
modprobe
读取modules.dep
,按依赖顺序先加载下层模块,确保所有符号可用,再加载上层模块;若版本或符号不匹配,则拒绝加载以保障内核稳定。 -
运行期:依赖模块在内核空间共享同一地址空间,调用开销与本地函数相同,但需注意同步与错误处理,避免跨模块调用引发死锁或资源竞争。
使用到的场景:
可堆叠文件系统
Stackable File System(如 WrapFS、UnionFS、OverlayFS)在 VFS 层与真实文件系统之间插入包装模块,对上层发起的文件操作进行拦截与加工,再传递给下层文件系统,或结合多个下层分支形成统一视图。这种设计无需重新实现完整 VFS 接口,仅关注差异逻辑,加快文件系统原型开发与功能扩展
设备驱动堆叠
在并行端口、USB 或网络驱动中,通用底层驱动(如 parport、USB core、net core)导出设备控制接口,上层特定设备驱动仅需注册设备 ID 并调用底层接口完成数据收发与控制,实现高度模块化设计。
网络协议栈扩展
网络子系统中,协议层(TCP/UDP)、网络接口层与硬件驱动层通过堆叠方式组合,不同协议模块可以复用底层协议提供的报文收发函数,也可在现有栈上动态插入流量监测、包过滤等功能模块。
安全模块堆叠
Linux Security Modules(LSM)框架最初仅允许单一安全模块(如 SELinux 或 AppArmor),后续通过堆叠(chaining)技术可同时加载 minor LSM 与主 LSM,实现多种安全策略并行应用。
实现机制细节
堆叠模块间的依赖与引用计数管理由内核统一维护。每个模块在加载时,引用计数初始为零;若被其他模块通过 use_module()
、try_module_get()
引用,则引用计数递增,确保其在生命周期内不会被卸载。当模块不再被引用且显式调用 rmmod
时,引用计数归零后才执行清理函数并释放资源,防止出现野指针或崩溃。堆叠模块需谨慎处理同步与内存管理问题。若下层模块提供异步或睡眠的接口,上层模块在调用时应确保在可睡眠上下文使用互斥锁;反之在硬实时或自旋锁保护环境下避免调用睡眠接口,以免死锁
对于这样的性质的模块,使用modprobe
modprobe是一个针对这种复杂的依赖链的模块开发的工具集。
如何工作的?流程是啥
1. 依赖解析
每次运行 depmod
时,都会扫描 /lib/modules/$(uname -r)
下的所有 .ko
文件,生成包含模块间依赖信息的 modules.dep
与 modules.alias
文件。modprobe
在加载模块时,先读取 modules.dep
,按照依赖顺序递归加载所需的底层模块,确保符号解析无缺失。
2. 符号与版本校验
模块编译阶段,modpost
将vermagic嵌入到 .ko
文件中,包括内核版本号、编译选项及 SMP/Preempt 标志。modprobe
在加载时检查该标记,若与当前运行内核不符则拒绝加载,以避免 ABI 不兼容导致的系统崩溃。
3. 加载与卸载
执行 modprobe <module>
时,若模块未加载,则调用 init_module(2)
将模块映像传至内核,并执行其中的初始化函数;执行 modprobe -r <module>
时,则按引用计数和依赖关系先卸载上层模块,再卸载目标模块,最后调用 delete_module(2)
完成清理。
/etc/modprobe.d 及 modprobe.d
/etc/modprobe.d/
目录下的所有 .conf
文件均会被 modprobe
读取,用于设置模块选项、别名(alias)、安装/卸载脚本(install/remove)以及黑名单(blacklist)等。各条指令会被合并处理,且后加载的文件可覆盖前者man7.orgUbuntu Manpage。
别名(alias)
通过alias可将设备或模块名称重映射,以便在 udev 或内核请求驱动时,通过别名自动加载目标模块(这个就是模块的renaming)
options 设置
可为模块传入参数,例如:
options snd_hda_intel index=1 power_save=0
与 insmod/rmmod 的比较
insmod
直接加载指定 .ko
文件,不做依赖解析,也不读取配置;rmmod
则直接卸载模块且不校验引用计数。相比之下,modprobe
更智能、更安全、更可定制,适用于生产环境
流程杂文:整合我们上面的讨论
当我们输入 insmod mymod.ko
并回车时,首先是 insmod
工具在用户态打开指定的 .ko
文件,读取其全部二进制内容,然后通过系统调用接口 init_module(2)
将模块镜像连同任何传入的参数一起拷贝到内核空间。内核接收到这次系统调用后,进入系统调用分发逻辑,最终执行模块子系统的初始化入口。在这一阶段,内核会为模块分配一块连贯的内核虚拟地址空间,用于存放各个 ELF 节(如 .text、.rodata、.bss、.modinfo、.init.text、.exit.text 等)。同时,内核会依据模块头部的 vermagic(版本魔术)信息比对当前运行的内核版本、SMP 与预取选项,若不匹配则拒绝加载并返回错误,避免因 ABI 差异导致的内核崩溃。
完成基础内存映射后,内核会遍历模块中的重定位表,将所有对内核导出符号(EXPORT_SYMBOL)的引用解析到真实的内核地址。这一步骤中,符号查找机制会在内核的全局符号表(由内核在启动时以及通过先前加载的模块不断更新)中查找与模块所需符号名完全一致的导出地址,若任一符号未能解析,模块加载会被终止,并触发卸载回滚路径,释放已申请的资源。符号解析成功后,模块子系统向模块结构体(struct module
)中填充诸如名称、大小、导出符号列表、依赖模块列表、引用计数等信息,并将其挂载到全局模块链表中,以供后续的卸载检查或其他模块查询。
一旦内存与符号准备妥当,内核便调用模块中用 module_init()
宏注册的初始化函数。初始化函数通常会在这里注册字符设备或网络设备、在 procfs/sysfs 下创建入口、申请其他子资源(如中断、DMA 通道等),并可能调用 try_module_get()
为自身增加引用。若初始化函数返回失败,内核立即回退:先调用已注册的 module_exit()
清理例程,撤销所有注册、释放所有内存、注销所有导出符号、并从模块链表中摘除该模块,再将最初映射的内核虚拟内存归还。
当初始化函数成功返回后,内核便认为该模块已“正式”加载,并将标记位由“正在加载”切换至“已加载”。这时,.init.text
段所占用的内存会被自动回收;如果模块是内置于内核(非可卸模块),.exit.text
段同样会被丢弃以节省空间。至此,模块已全功能地融入到了内核运行时,能够响应用户态通过 ioctl
、文件操作或网络接口等发起的请求。
与加载相对,卸载过程由 rmmod mymod
或 modprobe -r mymod
发起。内核通过 delete_module(2)
系统调用进入卸载分支,首先检查该模块的当前引用计数,若大于零则拒绝卸载并返回错误以防止其他正在运行的代码段崩溃。当引用计数为零且模块允许卸载(即没有被标记 tainted),内核调用注册在 module_exit()
宏中的清理函数。清理函数中应撤销在初始化中创建的所有设备节点、文件系统接口、procfs/sysfs 节点,注销字符或网络设备,释放分配的内存,以及解除对其他模块的 try_module_get()
引用。
清理函数运行完毕后,内核在内部会移除该模块在全局模块链表中的节点,解除所有对该模块符号的全局导出,并撤销与内核符号表的绑定。随后再次回收模块占用的内核虚拟内存区域,包括代码段和数据段。最终,内核返回用户态,rmmod
工具打印成功信息。至此,模块的加载和卸载生命周期完结,所有资源均已规范释放,系统恢复到加载前的干净状态。