mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
4617 字
12 分钟
内核模块
2024-07-09

第 11 章:设备驱动模型中,我们了解到 kobject、sysfs 等基础设施如何将设备、驱动、总线组织为统一的层次结构。但有一个核心问题始终没有深入展开:驱动程序是如何进入内核的?第 13 章:中断与软中断中,我们看到了中断处理程序注册到内核的过程,但那些处理程序本身又来自哪里?

答案就是本章的主题——内核模块(Kernel Module)

Linux 内核是一个宏内核(Monolithic Kernel),所有核心功能——调度器、内存管理、文件系统、网络协议栈——都运行在同一个地址空间中。宏内核的优势是性能极高(没有进程间切换开销),劣势是扩展性差:每增加一个驱动或功能,就必须重新编译、重启整个内核。Linux 通过内核模块机制巧妙地化解了这一矛盾——在保持宏内核架构的同时,获得了类似微内核的动态扩展能力

本章将从模块的设计动机出发,深入剖析 .ko 文件的 ELF 结构、模块加载与卸载的完整流程、符号导出与依赖解析、运行时传参机制、调试接口、模块签名安全机制,以及编写内核模块的最佳实践。

一、模块的设计动机#

1.1 宏内核的困境#

Linux 采用宏内核架构,所有子系统共享同一地址空间,函数调用直接进行,无需进程间通信(IPC)的开销。这种设计带来了卓越的性能,但也带来了一个严峻的问题:内核体积膨胀

考虑以下场景:

  • 服务器需要支持 10 种网卡驱动,但实际只插了 1 块网卡
  • 桌面系统需要支持 50 种文件系统,但实际只用了 ext4 和 tmpfs
  • 嵌入式设备需要支持 USB 摄像头驱动,但该设备根本没接摄像头

如果把所有可能的驱动都编译进内核,内核镜像将从几 MB 膨胀到数百 MB,启动时间大幅增加,内存也被大量无用代码占据。

1.2 模块化方案的核心思想#

Linux 的解决方案是内核模块——一种可以在内核运行时动态加载和卸载的目标代码文件。模块在加载时被链接到内核地址空间,成为内核的一部分;卸载时从内核中移除,释放占用的资源。

这种设计实现了三个关键目标:

目标说明效果
按需加载只在需要时加载驱动和功能减少内核内存占用
动态扩展无需重启即可添加新功能提高系统可用性
开发便捷修改驱动只需重新编译模块加速开发迭代
Note

模块并非”运行在内核之外”——加载后的模块与内核其他代码处于同一地址空间,拥有相同的特权级,可以直接调用内核导出的任何函数。模块本质上就是”延迟链接的内核代码”。

1.3 模块与微内核的对比#

特性Linux 模块微内核服务进程
地址空间与内核共享独立地址空间
通信方式直接函数调用IPC 消息传递
故障隔离模块 bug 可导致内核崩溃服务崩溃不影响内核
性能接近零开销IPC 开销显著
加载方式insmod/modprobe启动服务进程

Linux 选择了性能优先的路径:模块与内核共享地址空间,调用开销极低,但代价是模块中的 bug 可能导致整个系统崩溃。这是 Linux 设计哲学的一贯体现——信任开发者,提供最大灵活性,由开发者自己负责正确性

二、.ko 文件的 ELF 结构#

内核模块文件以 .ko(Kernel Object)为扩展名,它本质上是一个经过特殊处理的 ELF(Executable and Linkable Format)可重定位目标文件。与普通 .o 文件不同,.ko 文件包含了内核模块子系统所需的额外元数据。

2.1 ELF 基本结构#

一个典型的 .ko 文件包含以下 ELF 段:

hello.ko 的 ELF 段布局(简化):
ELF Header
├── .text — 模块代码(module_init/module_exit 等)
├── .rodata — 只读数据(字符串常量等)
├── .data — 可读写全局变量
├── .bss — 未初始化全局变量
├── .modinfo — 模块元信息(作者、许可证、描述等)
├── __versions — 模块使用的内核符号 CRC 校验
├── .gnu.linkonce.this_module — struct module 实例
├── .symtab — 符号表
├── .strtab — 字符串表
└── .shstrtab — 段名字符串表

2.2 .modinfo 段:模块的”身份证”#

.modinfo 段存储模块的元信息,由 MODULE_INFO() 宏和一系列专用宏生成。这些信息可以通过 modinfo 命令查看:

// include/linux/module.h — 模块信息宏
#define MODULE_INFO(tag, info) __MODULE_INFO(tag, tag, info)
// 常用信息宏
MODULE_LICENSE("GPL"); // 许可证(必须)
MODULE_AUTHOR("Zhang San"); // 作者
MODULE_DESCRIPTION("A demo"); // 描述
MODULE_VERSION("1.0"); // 版本
MODULE_ALIAS("demo-module"); // 别名

.modinfo 段中的信息以 key=value\0 格式存储:

license=GPL\0
author=Zhang San\0
description=A demo\0
version=1.0\0
depends=net_drv,usbcore\0
Important

MODULE_LICENSE("GPL") 不仅仅是声明——内核会检查许可证标签。标记为非 GPL 的模块(如 “Proprietary”)无法使用内核导出的 GPL-only 符号,且内核社区不为非 GPL 模块提供支持。这是 Linux 内核保护开源生态的重要机制。

2.3 __versions 段:版本一致性校验#

内核模块与内核之间的接口可能随版本变化。为了防止加载与当前内核不兼容的模块,Linux 使用 CRC(循环冗余校验) 机制进行版本校验。

__versions 段存储了模块引用的每个内核符号的 CRC 值:

// 模块编译时生成,存储在 __versions 段
struct modversion_info {
unsigned long crc;
char name[MODULE_NAME_LEN]; // 符号名
};

加载模块时,内核会比较模块中记录的 CRC 与当前内核导出符号的 CRC。如果不匹配,说明模块编译时使用的内核头文件与当前运行的内核不一致,接口可能已经变化,加载将被拒绝(除非设置了 TAINT_FORCED_MODULE)。

2.4 .gnu.linkonce.this_module 段#

这个段存储了一个预分配的 struct module 结构体实例。编译器在编译模块时,会将模块名、init/exit 函数指针等信息填入这个结构体,加载时内核直接使用它作为模块的主数据结构。

// include/linux/module.h — 模块核心结构体(简化)
struct module {
enum module_state state; // 模块状态
/* 模块基本信息 */
char name[MODULE_NAME_LEN]; // 模块名
const struct kernel_symbol *syms; // 模块导出的符号
const s32 *crcs; // 导出符号的 CRC
unsigned int num_syms; // 导出符号数量
/* 依赖关系 */
struct list_head list; // 链入全局模块链表
struct list_head source_list; // 依赖本模块的模块列表
struct list_head target_list; // 本模块依赖的模块列表
/* 初始化与清理 */
int (*init)(void); // 模块初始化函数
void (*exit)(void); // 模块卸载函数
/* 代码与数据段 */
void *module_core; // 模块代码+数据在内核中的起始地址
unsigned int core_size; // 代码+数据总大小
unsigned int core_text_size; // 代码段大小
/* 符号表与字符串表 */
Elf_Sym *symtab; // 模块符号表
unsigned int num_symtab; // 符号数量
char *strtab; // 字符串表
/* 参数 */
struct kernel_param *kp; // 模块参数数组
unsigned int num_kp; // 参数数量
/* 状态标志 */
unsigned int taints; // 污染标志
/* 引用计数 */
atomic_t refcnt; // 模块引用计数
};

三、模块加载流程#

模块加载是从用户空间发起、由内核完成的一系列复杂操作。理解这个流程对于排查模块加载问题至关重要。

3.1 完整加载流程#

sequenceDiagram participant U as 用户空间 participant SI as sys_init_module participant LM as load_module participant K as 内核链接器 participant MI as module_init U->>SI: insmod hello.ko Note over SI: 1. 读取 .ko 文件到用户缓冲区 Note over SI: 2. syscall __NR_init_module SI->>LM: 传入 ELF 数据 + 参数 Note over LM: 3. 验证 ELF 头与段 Note over LM: 4. 分配内核内存(module_core) Note over LM: 5. 拷贝 ELF 段到内核空间 Note over K: 6. 重定位符号引用 Note over K: 7. 解析依赖模块 Note over K: 8. 版本 CRC 校验 Note over LM: 9. 设置 module 结构体 Note over LM: 10. 加入全局模块链表 LM->>MI: 调用 module->init() Note over MI: 11. 执行模块初始化代码 MI-->>LM: 返回 0(成功) LM-->>SI: 返回 0 SI-->>U: 加载成功

3.2 insmod 与 init_module 系统调用#

insmod 是最基础的模块加载命令,它的工作非常简单:

  1. 读取 .ko 文件到用户空间缓冲区
  2. 调用 init_module() 系统调用,将 ELF 数据和参数传递给内核
// kernel/module/main.c — init_module 系统调用入口
SYSCALL_DEFINE3(init_module, void __user *, umod, unsigned long, len,
const char __user *, uargs)
{
int err;
struct load_info info = { };
// 基本安全检查
err = may_init_module();
if (err)
return err;
// 将用户空间的 ELF 数据拷贝到内核空间
err = copy_module_from_user(umod, len, &info);
if (err)
return err;
// 核心加载逻辑
return load_module(&info, uargs, 0);
}

3.3 load_module:核心加载逻辑#

load_module() 是模块加载的核心函数,它完成了从 ELF 解析到模块初始化的全部工作。其主要步骤如下:

步骤 1:ELF 验证与段布局

// kernel/module/main.c — ELF 头验证(简化)
static int elf_header_check(struct load_info *info)
{
if (info->len < sizeof(*(info->hdr)))
return -ENOEXEC;
// 验证 ELF 魔数
if (memcmp(info->hdr->e_ident, ELFMAG, SELFMAG) != 0)
return -ENOEXEC;
// 验证架构匹配(x86_64 模块不能加载到 ARM 内核)
if (info->hdr->e_machine != CONFIG_ARCH_MCU_TYPE)
return -ENOEXEC;
return 0;
}

内核计算各段所需的总大小,通过 module_alloc() 分配一块连续的内核虚拟地址空间(使用 vmalloc 机制),然后将各段拷贝到对应位置。

步骤 2:符号重定位

模块代码中引用的内核函数(如 printkkmalloc)在编译时地址未知,需要通过重定位在加载时解析。内核查找模块的 .rela / .rel 重定位段,对每个重定位项:

  1. 根据符号名在内核导出符号表中查找实际地址
  2. 根据重定位类型(R_X86_64_PC32、R_X86_64_64 等)计算修正值
  3. 将修正值写入模块代码的对应位置

步骤 3:依赖解析

模块可以依赖其他模块。.modinfo 段中的 depends= 字段列出了依赖的模块名。内核逐一检查这些依赖是否已经加载,如果有未满足的依赖,加载将失败。

步骤 4:版本校验

如果内核启用了 CONFIG_MODVERSIONS,内核会比较模块 __versions 段中记录的 CRC 与当前内核符号的 CRC。不匹配则拒绝加载,防止接口不兼容导致的内核崩溃。

步骤 5:状态转换与初始化

模块状态机如下:

stateDiagram-v2 [*] --> UNLOAD: 编译完成 UNLOAD --> COMING: load_module 开始 COMING --> LIVE: module_init() 成功 COMING --> UNLOAD: module_init() 失败 LIVE --> GOING: rmmod 请求卸载 GOING --> UNLOAD: module_exit() 完成 UNLOAD --> [*]: 资源释放

模块从 MODULE_STATE_UNLOAD 开始,进入 MODULE_STATE_COMING 状态表示正在加载。加载成功后转为 MODULE_STATE_LIVE,此时模块的 init() 函数已被调用。如果 init() 返回非零值,模块将被回滚卸载。

3.4 modprobe:智能模块加载#

insmod 只能加载单个模块,且不会自动处理依赖。modprobe 是更高级的工具,它:

  1. /lib/modules/$(uname -r)/modules.dep.bin 中查找模块及其依赖
  2. 按依赖顺序依次加载所有依赖模块
  3. 最后加载目标模块
# insmod 需要指定完整路径,不处理依赖
insmod /lib/modules/6.12.7/kernel/drivers/net/ethernet/intel/e1000e/e1000e.ko
# modprobe 只需模块名,自动处理依赖
modprobe e1000e
# modprobe 还支持别名和通配符
modprobe -r e1000e # 卸载模块及其未使用的依赖

modprobe 依赖 depmod 生成的模块依赖数据库。每次安装新内核或新模块后,应运行 depmod -a 更新数据库。

四、模块卸载流程#

4.1 完整卸载流程#

模块卸载是加载的逆过程,但需要更多的安全检查——一个正在被使用的模块如果被强制卸载,将导致内核崩溃。

// kernel/module/main.c — delete_module 系统调用
SYSCALL_DEFINE2(delete_module, const char __user *, name_user,
unsigned int, flags)
{
struct module *mod;
char name[MODULE_NAME_LEN];
int ret, forced = 0;
// 查找目标模块
ret = strncpy_from_user(name, name_user, MODULE_NAME_LEN);
mod = find_module(name);
if (!mod)
return -ENOENT;
// 检查模块是否有其他模块依赖
if (list_empty(&mod->source_list) == false) {
// 有其他模块依赖本模块,不允许卸载
return -EBUSY;
}
// 检查引用计数
if (atomic_read(&mod->refcnt) != 0) {
if (flags & O_TRUNC) // 强制卸载
forced = 1;
else
return -EBUSY;
}
// 等待 RCU 宽限期结束
wait_for_zero_refcount(mod);
// 设置状态为 GOING
mod->state = MODULE_STATE_GOING;
// 调用模块的 exit 函数
if (mod->exit != NULL)
mod->exit();
// 从全局链表中移除
list_del_rcu(&mod->list);
// 释放模块占用的内存
free_module(mod);
return 0;
}

4.2 卸载的安全检查#

内核在卸载模块时执行以下安全检查:

检查项说明失败后果
依赖检查是否有其他模块依赖本模块返回 -EBUSY
引用计数是否有内核组件正在使用本模块返回 -EBUSY(除非强制)
状态检查模块是否处于 LIVE 状态返回 -EPERM
权限检查调用者是否有 CAP_SYS_MODULE 权能返回 -EPERM
exit 函数模块是否提供了 exit 函数不允许卸载(无 exit 意味着不支持卸载)
Warning

强制卸载(rmmod -f)是极其危险的操作。它跳过引用计数检查,可能导致内核中使用该模块代码的线程跳转到已释放的内存地址,引发 Oops 或死锁。生产环境绝对禁止使用强制卸载。

4.3 rmmod 与 modprobe -r#

# rmmod:直接卸载指定模块
rmmod hello
# modprobe -r:卸载模块及其不再被使用的依赖
modprobe -r e1000e
# 查看模块引用计数
cat /proc/modules | grep e1000e
lsmod | grep e1000e

lsmod 命令读取 /proc/modules 文件,显示当前已加载的模块列表,包括模块大小和引用计数:

Module Size Used by
e1000e 327680 0
nvme 57344 3
nvme_core 184320 5 nvme
ext4 921600 2
jbd2 1228800 1 ext4

Used by 列显示引用该模块的模块列表和计数。只有 Used by 为 0 的模块才能被卸载。

五、符号导出与依赖解析#

5.1 EXPORT_SYMBOL:模块间的”公共 API”#

内核模块之间的协作依赖于符号导出机制。一个模块通过 EXPORT_SYMBOL() 将自己的函数或变量导出为公共符号,其他模块就可以引用这些符号。

// 导出符号——任何模块都可以使用
EXPORT_SYMBOL(symbol_name);
// 仅 GPL 许可的模块可以使用
EXPORT_SYMBOL_GPL(symbol_name);
// 仅 GPL + 未来版本的模块可以使用
EXPORT_SYMBOL_GPL_FUTURE(symbol_name);

这些宏在模块的 __ksymtab 段中创建一个 kernel_symbol 条目:

include/linux/export.h
struct kernel_symbol {
int value; // 符号地址(相对或绝对)
const char *name; // 符号名
const char *namespace; // 符号命名空间(可选)
};

5.2 符号查找过程#

当模块 A 引用模块 B 导出的符号时,加载器按以下顺序查找:

  1. 内核导出符号表 — 内核自身导出的符号(vmlinux__ksymtab 段)
  2. 已加载模块的导出符号 — 按模块加载顺序查找
  3. 未加载的依赖模块 — 如果 modprobe 处理,会先加载依赖模块
// kernel/module/main.c — 符号查找(简化)
static const struct kernel_symbol *
resolve_symbol(struct module *mod, const struct load_info *info,
const char *name, char ownername[])
{
const struct kernel_symbol *sym;
const unsigned long *crc;
int err;
// 在内核和已加载模块中查找符号
sym = find_symbol(name, &ownername, &crc,
true, true);
if (!sym)
return ERR_PTR(-ENOENT);
// CRC 版本校验
err = check_version(info, name, mod, crc);
if (err)
return ERR_PTR(err);
// 检查 GPL 许可兼容性
if (!check_export_symbol(sym, mod))
return ERR_PTR(-EPERM);
return sym;
}

5.3 符号命名空间#

从 Linux 5.4 开始,内核引入了**符号命名空间(Symbol Namespace)**机制,允许开发者将导出符号分组到不同的命名空间中,模块必须通过 MODULE_IMPORT_NS() 声明导入才能使用:

// 导出符号到特定命名空间
EXPORT_SYMBOL_NS(my_func, MY_NAMESPACE);
// 使用方模块需要显式导入
MODULE_IMPORT_NS(MY_NAMESPACE);

命名空间的好处:

  • 接口分层:将内部符号和公共 API 分开
  • 依赖声明:模块必须显式声明对特定命名空间的依赖
  • 重构安全:修改命名空间可以追踪所有使用者

5.4 /proc/kallsyms:运行时符号表#

/proc/kallsyms 文件列出了内核和所有已加载模块导出的符号及其地址:

# 查看内核导出的符号
cat /proc/kallsyms | head -5
# ffffffff81000000 T _text
# ffffffff81001000 T startup_64
# ffffffff81002000 T do_one_initcall
# 查看特定模块导出的符号
cat /proc/kallsyms | grep "e1000e" | head -5
# ffffffffc0420000 T e1000_probe [e1000e]
# ffffffffc0420100 T e1000_remove [e1000e]
# 非特权用户看到的地址为 0(安全保护)
# 需要设置 /proc/sys/kernel/kptr_restrict = 0 才能看到真实地址
Note

出于安全考虑,kptr_restrict 默认为 1,非 root 用户在 /proc/kallsyms 中看到的地址全为 0。这防止了攻击者利用符号地址进行内核漏洞利用。

六、模块参数:运行时传参#

6.1 module_param 机制#

内核模块支持在加载时通过命令行传递参数,这通过 module_param() 宏族实现:

include/linux/moduleparam.h
#define module_param(name, type, perm) \
module_param_named(name, name, type, perm)
// 常用类型:bool, int, long, charp, ulong, uint 等
static int count = 1;
module_param(count, int, 0644);
MODULE_PARM_DESC(count, "Number of instances to create");
static char *name = "default";
module_param(name, charp, 0644);
MODULE_PARM_DESC(name, "Instance name");
static bool debug = false;
module_param(debug, bool, 0644);
MODULE_PARM_DESC(debug, "Enable debug output");

module_param() 的三个参数:

参数说明示例
name变量名(既是变量名也是参数名)count
type参数类型int, charp, bool
permsysfs 中的文件权限(0 表示不在 sysfs 中暴露)0644, 0

6.2 参数的 sysfs 表示#

perm 非零时,参数会在 /sys/module/<模块名>/parameters/ 下创建对应的文件:

# 查看模块参数
ls /sys/module/hello/parameters/
# count name debug
# 读取参数值
cat /sys/module/hello/parameters/count
# 1
# 运行时修改参数(需要 perm 允许写入)
echo 5 > /sys/module/hello/parameters/count

这种设计使得模块参数可以在加载时通过 insmod/modprobe 命令行设置,也可以在运行时通过 sysfs 动态调整——非常适合调试和性能调优。

6.3 高级参数宏#

// 自定义参数名(参数名与变量名不同)
static int max_size = 1024;
module_param_named(maximum, max_size, int, 0644);
// insmod hello.ko maximum=2048
// 字符串数组参数
static int num_names;
static char *names[10];
module_param_array(names, charp, &num_names, 0644);
// insmod hello.ko names=alice,bob,charlie
// 参数回调函数(参数被修改时触发)
static int notify_param(const char *val, const struct kernel_param *kp)
{
int ret = param_set_int(val, kp);
if (ret == 0)
pr_info("Parameter changed to %d\n", *(int *)kp->arg);
return ret;
}
static const struct kernel_param_ops notify_ops = {
.set = notify_param,
.get = param_get_int,
};
module_param_cb(threshold, &notify_ops, &threshold, 0644);

七、内核调试接口#

7.1 printk:内核的 printf#

printk() 是内核中最基本的调试输出函数。它与 C 标准库的 printf() 类似,但运行在内核空间,输出到内核环形缓冲区(kernel ring buffer)。

// include/linux/printk.h — 日志级别
#define KERN_EMERG "<0>" // 系统不可用
#define KERN_ALERT "<1>" // 必须立即处理
#define KERN_CRIT "<2>" // 严重条件
#define KERN_ERR "<3>" // 错误条件
#define KERN_WARNING "<4>" // 警告条件
#define KERN_NOTICE "<5>" // 正常但值得注意
#define KERN_INFO "<6>" // 信息性
#define KERN_DEBUG "<7>" // 调试信息
// 使用示例
printk(KERN_INFO "Hello from module\n");
printk(KERN_ERR "Failed to allocate memory: %d\n", err);
pr_info("Module loaded with count=%d\n", count); // 等价于 printk(KERN_INFO ...)
pr_err("Something went wrong\n"); // 等价于 printk(KERN_ERR ...)
pr_warn("Suspicious condition\n"); // 等价于 printk(KERN_WARNING ...)

7.2 日志级别控制#

内核日志级别决定了哪些消息会显示到控制台:

# 查看当前控制台日志级别
cat /proc/sys/kernel/printk
# 4 4 1 7
# │ │ │ └── 默认日志级别(printk 未指定级别时使用)
# │ │ └───── 最小控制台日志级别
# │ └──────── 默认控制台日志级别
# └─────────── 当前控制台日志级别
# 只有级别 < 当前控制台日志级别的消息才会显示到控制台
# 例如:当前级别为 4,则 KERN_EMERG(0)~KERN_ERR(3) 会显示
# 修改控制台日志级别
echo 8 > /proc/sys/kernel/printk # 显示所有级别的日志
# 使用 dmesg 查看环形缓冲区中的所有日志
dmesg | tail -20
dmesg -w # 实时跟踪(类似 tail -f)
dmesg -l err,warn # 只显示错误和警告

7.3 pr_debug 与动态调试#

printk(KERN_DEBUG ...) 的问题在于:调试信息默认不输出,但代码仍然存在,增加了内核镜像体积。更糟糕的是,修改调试输出需要重新编译模块。

动态调试(Dynamic Debug) 解决了这个问题:

// 使用 pr_debug 代替 printk(KERN_DEBUG ...)
pr_debug("Processing item %d\n", item_id);
// 使用 dev_dbg(设备驱动推荐)
dev_dbg(dev, "Register %x = %x\n", reg, val);

pr_debug 在未启用动态调试时编译为空操作(零开销),启用后可以通过 sysfs 接口在运行时按需开关:

# 启用所有动态调试
echo 'p' > /sys/kernel/debug/dynamic_debug/control
# 启用特定文件的调试输出
echo 'file hello.c +p' > /sys/kernel/debug/dynamic_debug/control
# 启用特定函数的调试输出
echo 'func my_init +p' > /sys/kernel/debug/dynamic_debug/control
# 启用特定模块的调试输出
echo 'module my_module +p' > /sys/kernel/debug/dynamic_debug/control
# 启用并附加行号
echo 'file hello.c +pl' > /sys/kernel/debug/dynamic_debug/control
# 关闭调试输出
echo 'file hello.c -p' > /sys/kernel/debug/dynamic_debug/control
# 查看当前启用的调试规则
cat /sys/kernel/debug/dynamic_debug/control

动态调试的查询语法支持多种过滤条件:

过滤器说明示例
file源文件名file drivers/net/e1000e/*
module模块名module e1000e
func函数名func e1000_probe
format格式字符串format "timeout"
line行号范围line 100-200

八、模块签名与安全#

8.1 模块签名机制#

从 Linux 3.7 开始,内核支持模块签名验证。当启用 CONFIG_MODULE_SIG 时,内核在加载模块时会验证模块的数字签名,确保模块未被篡改。

签名流程:

  1. 编译时签名:使用私钥对 .ko 文件的哈希值签名,签名附加到模块文件末尾
  2. 加载时验证:内核使用内嵌的公钥验证签名,验证失败则拒绝加载
# 内核配置选项
CONFIG_MODULE_SIG=y # 启用模块签名
CONFIG_MODULE_SIG_ALL=y # 自动为模块签名
CONFIG_MODULE_SIG_SHA256=y # 使用 SHA-256 哈希
CONFIG_MODULE_SIG_FORCE=y # 强制模式:签名验证失败则拒绝加载

8.2 强制模式与非强制模式#

模式行为适用场景
非强制(默认)签名验证失败则标记内核为”污染”(tainted),但仍允许加载开发环境
强制签名验证失败则拒绝加载模块生产环境、安全敏感系统

8.3 内核污染标志#

内核维护了一个”污染”(taint)标志位图,记录了各种可能导致内核行为异常的情况:

# 查看内核污染状态
cat /proc/sys/kernel/tainted
# 污染标志含义(位图):
# P (0) - 加载了非 GPL 模块
# F (1) - 强制加载了模块
# S (2) - 在不支持的架构上运行
# R (3) - 模块被强制卸载
# M (4) - 处理器报告了机器检查异常
# B (5) - 引用了错误的页
# U (6) - 用户空间导致 taint
# D (7) - 内核最近死锁
# A (8) - ACPI 表被覆盖
# W (9) - 警告
# C (10) - 加载了 staging 驱动
# I (11) - 平台固件问题
# O (12) - 加载了外部构建的模块
# E (13) - 签名验证失败的模块
# L (14) - 软锁定
# K (15) - 实时补丁
# X (16) - aux 污染

当向内核社区报告 bug 时,污染内核的 bug 报告通常不会被处理——因为非 GPL 模块或强制加载的模块可能引入了不可预测的行为,无法确定 bug 是内核自身还是模块导致的。

8.4 限制模块加载#

系统管理员可以通过多种方式限制模块加载:

# 方式一:通过 sysctl 禁止加载新模块
echo 1 > /proc/sys/kernel/modules_disabled
# 注意:此设置一旦启用,无法在运行时关闭(需要重启)
# 方式二:通过 /etc/modprobe.d/ 黑名单
echo "blacklist e1000e" > /etc/modprobe.d/blacklist.conf
echo "install e1000e /bin/true" >> /etc/modprobe.d/blacklist.conf
# 方式三:通过 Linux Security Module (LSM) 限制
# SELinux、AppArmor 等可以限制模块加载操作
# 方式四:内核编译时禁用模块支持
# CONFIG_MODULES=n — 完全禁止模块加载(嵌入式系统常用)

九、编写内核模块的最佳实践#

9.1 最小可运行示例#

// hello.c — 最简单的内核模块
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Linux Explorer");
MODULE_DESCRIPTION("Hello World Kernel Module");
MODULE_VERSION("1.0");
static int count = 1;
module_param(count, int, 0644);
MODULE_PARM_DESC(count, "Number of greetings");
static int __init hello_init(void)
{
int i;
for (i = 0; i < count; i++)
pr_info("Hello, kernel world! (greeting %d)\n", i + 1);
return 0;
}
static void __exit hello_exit(void)
{
pr_info("Goodbye, kernel world!\n");
}
module_init(hello_init);
module_exit(hello_exit);

对应的 Makefile:

obj-m += hello.o
KDIR := /lib/modules/$(shell uname -r)/build
all:
make -C $(KDIR) M=$(PWD) modules
clean:
make -C $(KDIR) M=$(PWD) clean

9.2 关键编码规范#

1. 使用 __init__exit 标记

static int __init my_init(void) { ... } // 初始化完成后释放代码内存
static void __exit my_exit(void) { ... } // 不支持卸载时释放代码内存

__init 标记的函数在初始化完成后会被内核回收(其代码占用的内存被释放),因为它们不会再被调用。__exit 标记的函数在内建模块(编译进内核的模块)中会被完全优化掉——内建模块永远不会被卸载。

2. 错误处理必须完整

static int __init my_init(void)
{
int err;
err = register_chrdev(MY_MAJOR, "mydev", &my_fops);
if (err < 0)
return err; // 直接返回,无需清理
err = device_create(my_class, NULL, MKDEV(MY_MAJOR, 0), NULL, "mydev");
if (IS_ERR(err)) {
unregister_chrdev(MY_MAJOR, "mydev"); // 回滚之前的注册
return PTR_ERR(err);
}
return 0;
}

3. init 失败必须回滚所有已分配资源

// 错误示范:泄漏资源
static int __init bad_init(void)
{
if (request_irq(irq, handler, 0, "mydev", NULL))
return -EBUSY; // 如果下面的注册失败,IRQ 不会被释放!
if (misc_register(&my_misc)) {
// 忘记 free_irq!
return -ENOMEM;
}
return 0;
}
// 正确示范:goto 链式清理
static int __init good_init(void)
{
int err;
err = request_irq(irq, handler, 0, "mydev", NULL);
if (err)
goto err_irq;
err = misc_register(&my_misc);
if (err)
goto err_misc;
return 0;
err_misc:
free_irq(irq, NULL);
err_irq:
return err;
}

内核社区广泛使用 goto 进行错误处理——这不是糟糕的编程风格,而是内核代码的惯用模式,它避免了深层嵌套和代码重复。

4. 避免全局可变状态

模块应尽量减少全局变量。使用设备特定结构体(device-specific struct)将所有状态集中管理:

struct my_device {
struct cdev cdev;
struct device *dev;
void __iomem *regs;
spinlock_t lock;
wait_queue_head_t wq;
// ... 所有设备相关状态
};
// 在 open 时分配,在 release 时释放
static int my_open(struct inode *inode, struct file *filp)
{
struct my_device *dev = container_of(inode->i_cdev,
struct my_device, cdev);
filp->private_data = dev;
return 0;
}

5. 正确处理并发

// 共享数据必须保护
static DEFINE_SPINLOCK(my_lock);
static LIST_HEAD(my_list);
static void add_item(struct my_item *item)
{
unsigned long flags;
spin_lock_irqsave(&my_lock, flags);
list_add(&item->list, &my_list);
spin_unlock_irqrestore(&my_lock, flags);
}

9.3 Makefile 与构建系统#

内核模块使用 Kbuild 构建系统。一个多文件模块的 Makefile:

# 多文件编译为一个模块
obj-m += mydriver.o
mydriver-objs := main.o utils.o io.o
# 条件编译
ccflags-y := -DDEBUG
ccflags-y += -I$(src)/include
# 构建目标
KDIR := /lib/modules/$(shell uname -r)/build
all:
$(MAKE) -C $(KDIR) M=$(PWD) modules
install:
$(MAKE) -C $(KDIR) M=$(PWD) modules_install
clean:
$(MAKE) -C $(KDIR) M=$(PWD) clean

十、向上游贡献模块#

10.1 内核代码风格#

Linux 内核有严格的代码风格规范,由 scripts/checkpatch.pl 工具检查:

# 检查补丁是否符合代码风格
scripts/checkpatch.pl my_patch.patch
# 检查单个文件
scripts/checkpatch.pl --file drivers/my_driver.c

关键规则:

  • 缩进使用 Tab(8 字符宽度),而非空格
  • 行宽不超过 80 字符
  • 左花括号不换行(函数定义除外)
  • 变量声明放在块的开头
  • 使用 /* */ 风格注释,而非 //

10.2 提交流程#

  1. 订阅邮件列表linux-kernel@vger.kernel.org 和相关子系统列表
  2. 使用 git format-patch 生成补丁
  3. 使用 git send-email 发送补丁(不要用附件)
  4. 补丁说明格式
[PATCH v2] subsystem: brief description
Detailed description of what the patch does, why it is needed,
and how it achieves the goal. Wrap at 72 columns.
Signed-off-by: Your Name <your@email.com>
  1. 响应审阅意见,修改后发送新版本([PATCH v3]

10.3 staging 目录:新驱动的入口#

新驱动通常先进入 drivers/staging/ 目录。staging 驱动是”尚不够成熟”的代码,需要在社区审阅和改进后才能”毕业”到正式目录。staging 驱动会设置 TAINT_CRAP 污染标志。

# 查看 staging 驱动
ls drivers/staging/
# staging 驱动的 TODO 文件列出了需要改进的地方
cat drivers/staging/rtl8192e/TODO

十一、动手实践#

实践 1:编写带参数的内核模块#

// param_demo.c — 带参数的内核模块
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/moduleparam.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Linux Explorer");
MODULE_DESCRIPTION("Parameter Demo Module");
static int count = 1;
static char *name = "world";
static bool verbose = false;
module_param(count, int, 0644);
MODULE_PARM_DESC(count, "Number of greetings (default: 1)");
module_param(name, charp, 0644);
MODULE_PARM_DESC(name, "Name to greet (default: world)");
module_param(verbose, bool, 0644);
MODULE_PARM_DESC(verbose, "Verbose output (default: false)");
static int __init param_init(void)
{
int i;
pr_info("param_demo: loaded with count=%d, name=%s, verbose=%d\n",
count, name, verbose);
for (i = 0; i < count; i++) {
if (verbose)
pr_info("param_demo: greeting #%d: Hello, %s!\n", i + 1, name);
else
pr_info("param_demo: Hello, %s!\n", name);
}
return 0;
}
static void __exit param_exit(void)
{
pr_info("param_demo: Goodbye, %s!\n", name);
}
module_init(param_init);
module_exit(param_exit);
obj-m += param_demo.o
KDIR := /lib/modules/$(shell uname -r)/build
all:
make -C $(KDIR) M=$(PWD) modules
clean:
make -C $(KDIR) M=$(PWD) clean

实践 2:insmod/rmmod 与 dmesg#

# 编译模块
make
# 加载模块(带参数)
sudo insmod param_demo.ko count=3 name=Linux verbose=1
# 查看内核日志
dmesg | tail -10
# [ 1234.567890] param_demo: loaded with count=3, name=Linux, verbose=1
# [ 1234.567891] param_demo: greeting #1: Hello, Linux!
# [ 1234.567892] param_demo: greeting #2: Hello, Linux!
# [ 1234.567893] param_demo: greeting #3: Hello, Linux!
# 查看模块信息
cat /proc/modules | grep param_demo
# param_demo 16384 0 - Live 0xffffffffc0500000 (OE)
# 查看模块参数
ls /sys/module/param_demo/parameters/
# count name verbose
cat /sys/module/param_demo/parameters/name
# Linux
# 运行时修改参数
echo 5 | sudo tee /sys/module/param_demo/parameters/count
# 卸载模块
sudo rmmod param_demo
# 再次查看日志
dmesg | tail -5
# [ 1250.123456] param_demo: Goodbye, Linux!

实践 3:modinfo 与模块元信息#

/home/user/param_demo.ko
# 查看模块文件元信息(无需加载)
modinfo param_demo.ko
# license: GPL
# author: Linux Explorer
# description: Parameter Demo Module
# depends:
# name: param_demo
# vermagic: 6.12.7 SMP mod_unload modversions
# 查看模块参数描述
modinfo -p param_demo.ko
# count:Number of greetings (default: 1) (int)
# name:Name to greet (default: world) (charp)
# verbose:Verbose output (default: false) (bool)
# 查看已加载模块的信息
modinfo param_demo

实践 4:/proc/kallsyms 符号查找#

# 查看内核导出的符号数量
cat /proc/kallsyms | wc -l
# 约 100,000+ 个符号
# 查找特定函数的地址
cat /proc/kallsyms | grep " printkl$"
# ffffffff8110a340 T printk
# 查看模块导出的符号(方括号中为模块名)
cat /proc/kallsyms | grep "\[e1000e\]" | head -5
# 查看当前内核的污染状态
cat /proc/sys/kernel/tainted
# 0 表示未污染
# 加载非 GPL 模块后再次检查
# 会出现非零值,表示内核已被污染

实践 5:动态调试实验#

# 确保内核启用了动态调试
cat /boot/config-$(uname -r) | grep DYNAMIC_DEBUG
# CONFIG_DYNAMIC_DEBUG=y
# 启用特定模块的调试输出
echo 'module param_demo +p' > /sys/kernel/debug/dynamic_debug/control
# 查看已启用的调试规则
cat /sys/kernel/debug/dynamic_debug/control | grep param_demo
# 关闭调试输出
echo 'module param_demo -p' > /sys/kernel/debug/dynamic_debug/control

参考资料#

内核源码#

路径说明
kernel/module/main.c模块加载/卸载核心逻辑
kernel/module/signing.c模块签名验证
kernel/module/strict.c模块加载限制
include/linux/module.h模块相关宏和数据结构定义
include/linux/moduleparam.h模块参数宏定义
include/linux/export.h符号导出宏定义
include/linux/printk.hprintk 接口定义
include/linux/dynamic_debug.h动态调试接口
kernel/params.c模块参数处理实现
scripts/mod/模块构建工具(modpost 等)

权威书籍#

书籍作者相关章节
《Linux 设备驱动程序》第 3 版Jonathan Corbet 等第 2 章:构建和运行模块
《Linux 内核设计与实现》第 3 版Robert Love第 7 章:内核同步引言、第 8 章:内核数据结构
《深入理解 Linux 内核》第 3 版Daniel P. Bovet 等附录 B:模块
《Professional Linux Kernel Architecture》Wolfgang MauererChapter 7: Modules

在线文档#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

内核模块
https://blog.souloss.com/posts/linux-internals/kernel-modules/
作者
Souloss
发布于
2024-07-09
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时