4、Linux内核架构:内核模块机制、内核源码结构、内核编译与加载、模块参数与依赖
Linux内核采用宏内核与微内核的混合设计,但本质上更偏向宏内核架构,即所有核心服务(进程调度、内存管理、文件系统、网络协议栈)均运行在内核态,共享同一地址空间。这种设计带来了高性能,但也导致内核体积庞大、功能扩展困难。为了解决这一问题,Linux引入了内核模块(Loadable Kernel Module, LKM)机制,允许在运行时动态加载或卸载功能代码,而无需重新编译整个内核。对于驱动开发者而言,理解内核模块机制、源码目录结构、编译流程、加载方式以及参数与依赖管理,是进行设备驱动开发的基础能力。本章将围绕这一主题,从原理到实战展开系统讲解。
上图展示了内核模块机制的整体链路:用户态通过 insmod、modprobe 或 rmmod 命令触发系统调用,内核模块加载器负责将 .ko 文件中的代码段、数据段、符号表等装入内核地址空间,并解析模块间的符号依赖。模块通过 module_init() 和 module_exit() 宏声明初始化与退出函数,驱动开发者只需关注这两个入口即可完成驱动的生命周期管理。
4.1 内核模块机制核心原理
内核模块本质上是一种可动态链接的目标文件,其格式为 ELF(Executable and Linkable Format)的 ET_REL 类型,通常以 .ko 后缀命名。与普通应用程序不同,模块不经过标准 C 库链接,而是直接与内核符号表进行链接。模块中引用的内核函数(如 printk、kmalloc)在编译时并不解析地址,而是在加载时由内核模块加载器根据 /proc/kallsyms 或内核自身的符号表进行动态重定位。
模块的加载过程分为以下几个关键步骤:
- 文件读取与校验:内核通过
finit_module()系统调用读取.ko文件,校验 ELF 头、版本信息(vermagic)以及 CRC 校验值。 - 内存分配:为模块的代码段、数据段、BSS 段分配连续的虚拟内存区域,通常位于模块区域(module region)。
- 符号解析:遍历模块的重定位表,将模块引用的外部符号(如
printk)与内核已导出的符号进行匹配。若符号未导出或版本不匹配,则加载失败。 - 重定位:根据符号解析结果,修改模块代码中的地址引用,使其指向正确的内核函数或全局变量。
- 调用初始化函数:执行
module_init()指定的函数,完成设备注册、资源申请等初始化工作。
模块的卸载过程则相反:先调用 module_exit() 函数释放资源,再卸载模块代码段和数据段。需要注意的是,如果模块被其他模块依赖(即其他模块引用了该模块导出的符号),则无法直接卸载,必须先卸载依赖模块。
EXPORT_SYMBOL 导出必要的接口,避免导出过多内部实现细节。
4.2 Linux内核源码结构
Linux内核源码采用分层目录结构,每个顶层目录承担特定的功能职责。理解这些目录的作用,有助于驱动开发者快速定位相关代码。下表列出了主要目录及其功能:
| 目录 | 功能说明 | 驱动开发相关重点 |
|---|---|---|
arch/ | 体系结构相关代码,如 x86、ARM、RISC-V | 板级启动、内存映射、DMA 配置 |
drivers/ | 设备驱动代码,占内核源码的 50% 以上 | 驱动开发的主战场,按子系统分子目录 |
fs/ | 文件系统实现,如 ext4、proc、sysfs | 虚拟文件系统接口、proc/sysfs 节点创建 |
include/ | 内核头文件,包含公共 API 定义 | linux/ 子目录下的设备模型、内核 API |
kernel/ | 核心内核功能,如调度、信号、模块加载 | 模块加载器实现(kernel/module.c) |
mm/ | 内存管理子系统 | 页分配、DMA 缓冲区、内存映射 |
net/ | 网络协议栈 | 网络设备驱动、套接字接口 |
sound/ | 音频子系统 | ALSA 驱动框架 |
block/ | 块设备层 | 块设备驱动、I/O 调度 |
Documentation/ | 内核文档 | 驱动开发指南、设备树绑定说明 |
在 drivers/ 目录下,驱动代码按子系统进一步细分,例如 drivers/i2c/、drivers/spi/、drivers/gpio/、drivers/net/、drivers/char/ 等。每个子系统通常包含核心框架代码(如 i2c-core.c)和具体设备驱动代码(如 i2c-xxx.c)。驱动开发者在新增驱动时,应优先选择对应的子系统目录,遵循已有的框架约定。
4.3 内核编译与模块加载
内核编译分为内核镜像编译和模块编译两种场景。对于驱动开发者而言,通常只需要编译模块,但理解整体编译流程有助于排查环境问题。
内核编译的基本流程如下:
- 配置内核:使用
make menuconfig、make defconfig或直接编辑.config文件,选择需要的功能模块。配置项有三种状态:y(编入内核)、m(编译为模块)、n(不编译)。 - 生成依赖:执行
make prepare和make modules_prepare,生成版本头文件、符号表等中间文件。 - 编译内核镜像:执行
make或make bzImage,生成vmlinux和压缩后的内核镜像。 - 编译模块:执行
make modules,将所有配置为m的模块编译为.ko文件。 - 安装:执行
make modules_install将模块安装到/lib/modules/$(uname -r)/目录,执行make install安装内核镜像。
对于单个模块的编译,可以使用内核提供的 Makefile 模板。以下是一个典型的驱动模块 Makefile 示例:
# Makefile for a simple character device driver
obj-m := mydriver.o
mydriver-objs := mydriver_main.o mydriver_ops.o
KERNEL_DIR ?= /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
$(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean
模块加载的常用命令如下:
insmod mydriver.ko:直接加载模块,不自动处理依赖。rmmod mydriver:卸载模块,需要模块未被使用。modprobe mydriver:智能加载模块,自动解析依赖并加载相关模块。modprobe -r mydriver:智能卸载模块,同时卸载不再使用的依赖模块。lsmod:查看当前已加载的模块列表及依赖关系。modinfo mydriver.ko:查看模块信息,包括作者、描述、参数、依赖等。
insmod 加载模块时,如果出现 Invalid module format 错误,通常是因为模块的 vermagic 与当前运行内核不一致。解决方法是使用与当前内核完全相同的源码树进行编译,或使用 modprobe --force 强制加载(不推荐用于生产环境)。
4.4 模块参数与依赖管理
内核模块支持在加载时传递参数,这为驱动提供了灵活的配置能力。模块参数通过 module_param() 宏声明,支持 int、charp(字符串指针)、bool、int array 等类型。参数声明后,可以通过 insmod mydriver.ko param_name=value 或 modprobe mydriver param_name=value 进行赋值。
以下是一个带参数的模块示例:
#include <linux/module.h>
#include <linux/moduleparam.h>
static int irq_num = 10;
static char *dev_name = "mydrv";
static int debug_level = 0;
module_param(irq_num, int, 0644);
module_param(dev_name, charp, 0644);
module_param(debug_level, int, 0644);
MODULE_PARM_DESC(irq_num, "IRQ number for the device");
MODULE_PARM_DESC(dev_name, "Device name");
MODULE_PARM_DESC(debug_level, "Debug level (0-3)");
static int __init mydriver_init(void)
{
pr_info("Loading mydriver: irq=%d, name=%s, debug=%d\n",
irq_num, dev_name, debug_level);
return 0;
}
static void __exit mydriver_exit(void)
{
pr_info("Unloading mydriver\n");
}
module_init(mydriver_init);
module_exit(mydriver_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple parameterized driver");
模块依赖管理由 depmod 工具负责。当模块 A 引用了模块 B 导出的符号时,A 就依赖于 B。内核通过 EXPORT_SYMBOL 和 EXPORT_SYMBOL_GPL 宏导出符号,其中 EXPORT_SYMBOL_GPL 要求使用模块必须声明 GPL 许可证。依赖关系被记录在 /lib/modules/$(uname -r)/modules.dep 文件中,modprobe 命令通过该文件自动加载依赖模块。
i2c_get_clientdata、gpiod_get),而不是自定义导出符号。这有助于提高模块的可移植性和可维护性。
4.5 案例拆解:一个 I2C 温度传感器驱动的完整开发流程
下面通过一个 I2C 温度传感器驱动的开发案例,完整展示模块机制的应用。假设硬件平台为 ARM 嵌入式 Linux,内核版本 5.15,传感器芯片为 LM75,挂接在 I2C 总线 0 上,地址为 0x48。
背景与约束:系统需要实时读取环境温度,温度精度要求 ±0.5°C,更新频率 1Hz。硬件上 LM75 通过 I2C 接口连接,中断引脚未使用。驱动需要支持设备树配置,以便在不同板卡上灵活调整 I2C 总线号和设备地址。
初始现象:系统启动后,I2C 总线上未检测到 LM75 设备,i2cdetect -y 0 命令无法扫描到 0x48 地址。
分析过程:首先检查设备树中是否正确描述了 LM75 节点。查看 arch/arm/boot/dts/xxx.dts 文件,发现 I2C 节点下缺少 LM75 子节点。添加设备树节点如下:
&i2c0 {
status = "okay";
lm75: lm75@48 {
compatible = "national,lm75";
reg = <0x48>;
status = "okay";
};
};
驱动实现:在 drivers/hwmon/ 目录下创建 lm75_custom.c 驱动文件,核心代码如下:
#include <linux/module.h>
#include <linux/i2c.h>
#include <linux/hwmon.h>
#include <linux/hwmon-sysfs.h>
#include <linux/err.h>
static int lm75_custom_probe(struct i2c_client *client)
{
struct device *hwmon_dev;
hwmon_dev = devm_hwmon_device_register_with_groups(&client->dev,
client->name,
client,
NULL);
return PTR_ERR_OR_ZERO(hwmon_dev);
}
static const struct i2c_device_id lm75_custom_id[] = {
{ "lm75_custom", 0 },
{ }
};
MODULE_DEVICE_TABLE(i2c, lm75_custom_id);
static const struct of_device_id lm75_custom_of_match[] = {
{ .compatible = "national,lm75" },
{ }
};
MODULE_DEVICE_TABLE(of, lm75_custom_of_match);
static struct i2c_driver lm75_custom_driver = {
.driver = {
.name = "lm75_custom",
.of_match_table = lm75_custom_of_match,
},
.probe = lm75_custom_probe,
.id_table = lm75_custom_id,
};
module_i2c_driver(lm75_custom_driver);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Driver Developer");
MODULE_DESCRIPTION("Custom LM75 temperature sensor driver");
编译与加载:在驱动目录下执行 make -C /lib/modules/$(uname -r)/build M=$(pwd) modules,生成 lm75_custom.ko。使用 modprobe lm75_custom 加载模块,此时 dmesg 输出显示驱动成功绑定到 I2C 设备。
结果验证:执行 cat /sys/class/hwmon/hwmon0/temp1_input,输出温度值 25000(表示 25.000°C),符合预期。通过 lsmod 可以看到模块已加载,且依赖关系正确。
风险与复盘:在开发过程中,最初未添加设备树节点导致驱动无法匹配设备。通过查阅内核文档和 Documentation/devicetree/bindings/hwmon/lm75.txt 确认了 compatible 属性的正确写法。此外,模块参数未使用,因为设备树已经提供了足够的配置灵活性。复盘经验:驱动开发应优先使用设备树描述硬件,模块参数仅用于调试或运行时调整。
4.6 常见误区与总结复盘
在驱动开发面试中,以下误区经常被提及,需要特别注意:
- 误区一:认为模块加载后无法卸载。实际上,只要模块没有正在被使用(引用计数为 0),且没有其他模块依赖它,就可以通过
rmmod卸载。驱动开发者应在exit函数中完整释放所有资源。 - 误区二:忽略版本兼容性。模块的 vermagic 必须与运行内核完全一致,否则加载失败。使用
modinfo查看模块的 vermagic,使用uname -r查看内核版本。 - 误区三:滥用
EXPORT_SYMBOL。导出符号会增加模块间的耦合,且导出的符号会成为内核 ABI 的一部分。应尽量使用内核提供的标准接口,避免自定义导出。 - 误区四:混淆
insmod与modprobe。insmod不处理依赖,modprobe自动处理依赖。在开发阶段使用insmod便于调试,在部署阶段使用modprobe更安全。 - 误区五:忽略模块参数权限。
module_param的第三个参数是 sysfs 权限位,设置为 0 表示不创建 sysfs 节点,设置为 0644 表示允许用户读取和 root 写入。权限设置不当可能导致安全风险。
总结而言,内核模块机制是 Linux 设备驱动开发的基石。开发者需要熟练掌握模块的编写、编译、加载、参数传递和依赖管理,同时理解内核源码的组织结构,以便快速定位框架代码和参考实现。在实际项目中,应遵循内核社区的编码规范,使用设备树描述硬件,合理设计模块边界,避免过度设计。通过本章的学习,读者应能够独立完成一个标准内核模块的开发与调试,并为后续深入设备模型、中断处理、并发控制等高级主题打下坚实基础。