2. Linux内核模块基础

字符设备驱动在Linux中通常以“内核模块”的形态存在。理解模块的加载与卸载机制,是编写、调试和部署字符设备驱动的第一步。内核模块并不是一个独立运行的程序,而是一段可以在系统运行期间动态插入内核地址空间的目标代码。它没有自己的main()函数,而是通过内核提供的注册入口和退出入口与内核交互。本节围绕模块生命周期、入口出口函数、许可证声明以及内核日志输出四个核心点展开。

一、模块加载与卸载的基本流程

用户空间通过insmod、modprobe、rmmod等命令触发模块操作。内核收到请求后,会完成一系列动作:将.ko文件读入内存、解析ELF段、进行符号重定位、检查模块版本与依赖关系,然后调用模块的初始化函数。若初始化成功,模块进入“已加载”状态;若失败,内核会回收已分配资源并返回错误码。

卸载时,内核先检查模块引用计数。如果计数不为零,说明仍有其他模块或内核对象依赖它,卸载会被拒绝。计数归零后,内核调用模块的退出函数,释放资源,最后将模块代码从内核空间移除。

# 加载模块
sudo insmod hello_dev.ko

# 查看已加载模块
lsmod | grep hello_dev

# 查看模块信息
modinfo hello_dev.ko

# 卸载模块
sudo rmmod hello_dev

与insmod相比,modprobe会自动处理模块依赖关系,更适合生产环境。而insmod只加载指定文件,不解决依赖,常用于开发调试阶段。

二、module_init与module_exit

每个可加载模块都必须至少提供初始化入口。最常见的方式是使用module_init()和module_exit()宏来声明入口和出口函数。它们告诉内核:模块被加载时调用哪个函数,被卸载时调用哪个函数。

#include <linux/module.h>
#include <linux/init.h>

static int __init hello_init(void)
{
    printk(KERN_INFO "hello_dev: module init\n");
    return 0;
}

static void __exit hello_exit(void)
{
    printk(KERN_INFO "hello_dev: module exit\n");
}

module_init(hello_init);
module_exit(hello_exit);

入口函数返回int类型:返回0表示加载成功,返回负值表示失败。出口函数返回void,不返回错误码。函数前的__init和__exit是内核优化标记:__init段在初始化完成后可被释放,__exit段在模块被编译进内核时会被丢弃。

入口函数中应完成资源申请、设备号注册、字符设备注册等动作;出口函数中应按相反顺序释放资源。顺序错误可能导致卸载时崩溃或资源泄漏。

三、MODULE_LICENSE与模块元信息

MODULE_LICENSE用于声明模块的许可证。它不仅是法律声明,也直接影响内核行为。如果模块声明为GPL兼容许可证,就可以使用内核中仅对GPL模块开放的符号;否则,加载时可能遇到“Unknown symbol”或“module verification failed”等问题。

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Course Example");
MODULE_DESCRIPTION("A simple char device module");
MODULE_VERSION("1.0");

常见的许可证字符串包括"GPL"、"GPL v2"、"Dual BSD/GPL"、"Proprietary"等。字符设备驱动通常使用"GPL",以便合法调用内核导出符号。除许可证外,MODULE_AUTHOR、MODULE_DESCRIPTION、MODULE_VERSION等宏用于补充模块元信息,可通过modinfo查看。

四、printk与内核日志

内核模块不能使用用户空间的printf,而应使用printk输出日志。printk支持日志级别,格式为KERN_XXX,级别越低越紧急。常见级别包括:

级别宏 数值 含义
KERN_EMERG 0 系统不可用
KERN_ALERT 1 必须立即处理
KERN_ERR 3 错误条件
KERN_WARNING 4 警告条件
KERN_INFO 6 一般信息
KERN_DEBUG 7 调试信息

日志输出后,可通过dmesg、/var/log/kern.log或journalctl -k查看。开发阶段常用dmesg -w实时观察模块加载和卸载时的打印信息。

# 实时查看内核日志
dmesg -w

# 查看最近日志
dmesg | tail -n 20

# 清空日志缓冲区
sudo dmesg -C
printk没有自动换行,建议在字符串末尾显式添加\n。另外,printk在中断上下文和高频路径中应谨慎使用,避免日志风暴影响系统性能。

五、一个完整的模块骨架与加载验证

下面给出一个最小但完整的字符设备模块骨架,包含入口、出口、许可证和日志输出。它尚未实现具体的file_operations,但足以验证模块生命周期是否正常。

#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>

static int __init chardev_init(void)
{
    printk(KERN_INFO "chardev: init, loading...\n");
    return 0;
}

static void __exit chardev_exit(void)
{
    printk(KERN_INFO "chardev: exit, unloading...\n");
}

module_init(chardev_init);
module_exit(chardev_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Course Example");
MODULE_DESCRIPTION("Char device module skeleton");

对应的Makefile可写成:

obj-m += chardev.o
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)

all:
	make -C $(KDIR) M=$(PWD) modules

clean:
	make -C $(KDIR) M=$(PWD) clean

执行make后生成chardev.ko,再执行sudo insmod chardev.ko,通过dmesg | tail应能看到“chardev: init, loading...”。执行sudo rmmod chardev后,应看到“chardev: exit, unloading...”。

六、常见误区与复盘

  • 忘记MODULE_LICENSE:模块可能加载失败或无法使用GPL符号,内核日志会给出明确提示。
  • 入口函数返回正值:内核只把0视为成功,非零值会被当作错误处理,导致加载失败。
  • 出口函数中访问已释放资源:卸载顺序应与初始化顺序相反,先注册的后释放。
  • printk缺少换行:多条日志会粘连在一起,影响阅读和排查。
  • 用printf代替printk:内核空间没有用户态C库,链接阶段就会报错。
模块加载与卸载是字符设备驱动的“生命开关”。掌握module_init、module_exit、MODULE_LICENSE和printk,就具备了编写可加载、可卸载、可观测驱动模块的基础能力。
Linux内核模块生命周期与日志链路 用户空间 insmod / modprobe rmmod dmesg / journalctl 内核空间 模块加载 解析ELF → 重定位 → 检查依赖 module_init() 返回0成功 / 负值失败 模块卸载 检查引用计数 → 归零后卸载 module_exit() 释放资源,无返回值 MODULE_LICENSE("GPL") 声明许可证,决定能否使用GPL符号 printk(KERN_INFO "...") 日志级别:EMERG/ALERT/ERR/WARNING/INFO/DEBUG 内核日志缓冲区 → dmesg / /var/log/kern.log 模块状态 未加载 加载中 已加载(引用计数≥0) 卸载中 / 卸载失败