3. 第一个字符设备驱动

在 Linux 内核中,字符设备驱动通常以内核模块的形式存在。模块是内核在运行时可以动态加载和卸载的一段代码,无需重新编译整个内核或重启系统。本章将带领你编写第一个完整的字符设备驱动模块,重点掌握模块的加载/卸载机制、入口/出口函数声明以及模块信息的规范填写。

3.1 内核模块的基本结构

一个最简单的内核模块至少包含两个核心函数:模块初始化函数(在模块加载时执行)和模块清理函数(在模块卸载时执行)。这两个函数通过特定的宏与内核框架绑定。

宏 / 函数 作用 执行时机
module_init() 指定模块加载时调用的入口函数 insmod 或内核自动加载时
module_exit() 指定模块卸载时调用的出口函数 rmmod 或内核自动卸载时
__init 标记 将函数放入 .init.text 段,加载完成后释放内存 仅初始化阶段
__exit 标记 将函数放入 .exit.text 段,模块编译进内核时忽略 仅卸载阶段
关键概念: module_init() 和 module_exit() 是宏定义,它们将函数指针注册到内核模块表中。内核在加载模块时会查找 module_init 指定的函数地址并执行;卸载时则执行 module_exit 指定的函数。

3.2 第一个模块代码:hello_module

下面是一个完整的字符设备驱动模块框架,它不操作硬件,仅演示模块的加载与卸载流程。

#include <linux/module.h>      // 包含 module_init / module_exit 宏
#include <linux/kernel.h>      // 包含 printk 等内核打印函数
#include <linux/init.h>        // 包含 __init / __exit 标记

static int __init hello_init(void)
{
    printk(KERN_INFO "Hello, char device module loaded!\n");
    return 0;   // 返回 0 表示初始化成功
}

static void __exit hello_exit(void)
{
    printk(KERN_INFO "Goodbye, char device module unloaded!\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name <your.email@example.com>");
MODULE_DESCRIPTION("A simple character device module example");
MODULE_VERSION("1.0");
代码要点:
  • __init 和 __exit 标记帮助内核优化内存布局,非强制但推荐使用。
  • printk 是内核空间的打印函数,KERN_INFO 指定日志级别。
  • 初始化函数返回 0 表示成功,返回负数表示失败,模块将无法加载。
  • 清理函数返回类型为 void,不允许失败。

3.3 模块信息与 LICENSE 规范

每个内核模块必须声明许可证信息,否则内核会认为该模块是“污染”的,加载时打印警告。常用的模块信息宏如下:

宏 含义 示例值
MODULE_LICENSE() 声明模块许可证 "GPL", "GPL v2", "BSD", "Proprietary"
MODULE_AUTHOR() 作者信息 "Zhang San <zhangsan@example.com>"
MODULE_DESCRIPTION() 模块功能简述 "A simple char driver"
MODULE_VERSION() 模块版本号 "1.0"
MODULE_ALIAS() 模块别名(用于自动加载) "char-device-example"
许可证警告: 如果模块使用非 GPL 兼容许可证(如 "Proprietary"),内核将禁止调用标记为 EXPORT_SYMBOL_GPL 的符号。对于字符设备驱动开发,强烈建议使用 "GPL" 或 "GPL v2"。

3.4 模块加载与卸载流程

下图展示了从用户空间执行 insmod 和 rmmod 命令时,内核模块的生命周期变化:

模块加载与卸载流程 insmod hello.ko rmmod hello 内核模块加载器 模块生命周期状态机 UNLOADED LOADING ACTIVE UNLOADING module_init 成功 rmmod module_exit 执行完毕 失败
流程说明:
  1. UNLOADED → LOADING: 执行 insmod 后,内核加载器将模块二进制插入内存,调用 module_init 指定的函数。
  2. LOADING → ACTIVE: 初始化函数返回 0,模块进入活跃状态,可以被用户空间访问。
  3. LOADING → UNLOADED: 初始化函数返回负数,模块自动卸载,回到未加载状态。
  4. ACTIVE → UNLOADING: 执行 rmmod,内核调用 module_exit 指定的函数。
  5. UNLOADING → UNLOADED: 清理函数执行完毕,模块从内核中移除。

3.5 编译与测试

使用 Makefile 编译内核模块是标准做法。以下是一个适用于本示例的 Makefile:

obj-m := hello_module.o

KERNELDIR ?= /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)

all:
    $(MAKE) -C $(KERNELDIR) M=$(PWD) modules

clean:
    $(MAKE) -C $(KERNELDIR) M=$(PWD) clean

编译完成后,执行以下命令验证模块行为:

# 加载模块
sudo insmod hello_module.ko

# 查看内核日志,确认加载信息
dmesg | tail -5
# 输出示例: [ 1234.5678] Hello, char device module loaded!

# 查看已加载模块列表
lsmod | grep hello_module

# 卸载模块
sudo rmmod hello_module

# 再次查看内核日志,确认卸载信息
dmesg | tail -5
# 输出示例: [ 1234.9876] Goodbye, char device module unloaded!
常见问题排查:
  • insmod: ERROR: could not insert module: Operation not permitted → 需要使用 sudo 或以 root 身份执行。
  • insmod: ERROR: could not insert module: Invalid module format → 内核版本或配置不匹配,重新编译模块。
  • rmmod: ERROR: Module hello_module is in use → 有其他进程正在使用该模块,先关闭相关应用。
  • dmesg 无输出 → 检查 printk 日志级别,或使用 dmesg -w 实时监控。

3.6 模块加载/卸载的完整时序

下表总结了模块从加载到卸载过程中,内核与模块函数之间的交互时序:

步骤 用户操作 内核行为 模块函数
1 insmod hello.ko 读取 ELF 文件,分配内核内存 —
2 — 解析模块依赖,解决符号引用 —
3 — 调用 module_init 注册的函数 hello_init()
4 — 检查返回值:0 成功,负数回滚 返回 0
5 — 模块加入内核模块链表 —
6 rmmod hello 检查模块引用计数是否为 0 —
7 — 调用 module_exit 注册的函数 hello_exit()
8 — 释放模块占用的内核内存 —
引用计数机制: 内核通过 try_module_get() 和 module_put() 维护模块的引用计数。只有当引用计数为 0 时,rmmod 才能成功卸载模块。在后续章节中,当设备文件被打开时,驱动需要主动增加引用计数以防止模块被意外卸载。

3.7 本章小结

通过本章的学习,你已经掌握了 Linux 内核模块的基本骨架:

  • 使用 module_init() 和 module_exit() 注册模块的入口和出口函数。
  • 使用 __init 和 __exit 标记优化内存布局。
  • 规范填写 MODULE_LICENSE、MODULE_AUTHOR 等模块信息。
  • 理解模块从加载到卸载的完整生命周期状态机。
  • 能够编写 Makefile 并编译、加载、卸载一个真实的内核模块。