4. 内核模块编程:模块的加载与卸载、模块参数、模块依赖、内核符号导出

好,咱们进入第四章。前面几章我们把开发环境搭好了,也编译了内核。但说实话,真正的驱动开发,大部分时间是在写内核模块,而不是直接往内核源码里塞代码。

内核模块,说白了就是一段可以在运行时动态加载进内核的代码。不用重新编译整个内核,不用重启机器。我刚开始学的时候觉得这玩意儿挺神奇的——后来自己写多了才发现,它其实就是内核的“插件机制”。

4.1 模块的加载与卸载

先看一个最基础的内核模块长什么样。我习惯把它叫做“Hello World”的升级版。

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

static int __init my_module_init(void)
{
    printk(KERN_INFO "Hello, kernel module loaded!\n");
    return 0;
}

static void __exit my_module_exit(void)
{
    printk(KERN_INFO "Goodbye, kernel module unloaded!\n");
}

module_init(my_module_init);
module_exit(my_module_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple kernel module example");

这个结构其实很固定:一个入口函数 module_init(),一个出口函数 module_exit()。加载时内核会调用你注册的 init 函数,卸载时调用 exit 函数。

小提示: __init 和 __exit 这两个宏不是摆设。加了 __init 的代码,在模块加载完成后会被内核回收掉,省内存。我在项目里见过有人没加,结果模块占着内存不放,虽然不影响功能,但总觉得不专业。

编译这个模块,你需要一个 Makefile。嗯,这里有个坑,我踩过好几次。

obj-m := my_module.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

编译完成后,你会得到一个 .ko 文件。操作命令很简单:

操作 命令 说明
加载模块 insmod my_module.ko 直接插入,不处理依赖
卸载模块 rmmod my_module 按名字卸载,不加 .ko
查看已加载模块 lsmod 列出所有模块及其依赖
查看模块信息 modinfo my_module.ko 显示作者、描述、参数等
注意: 千万别用 insmod 去加载一个依赖没满足的模块。我曾经在调试一个 USB 驱动时,忘了先加载 USB 核心模块,结果 insmod 直接报错“Unknown symbol”。后来养成了习惯,先用 modprobe 试试。

4.2 模块参数

写驱动的时候,你经常需要让用户在加载模块时传一些参数进来。比如指定设备号、缓冲区大小、调试级别等。内核模块支持通过 module_param 宏来声明参数。

#include <linux/moduleparam.h>

static int debug_level = 0;
static char *device_name = "default_dev";

module_param(debug_level, int, 0644);
module_param(device_name, charp, 0644);

MODULE_PARM_DESC(debug_level, "Debug level (0-3)");
MODULE_PARM_DESC(device_name, "Device name string");

加载时这样传参:

insmod my_module.ko debug_level=2 device_name="my_device"

参数类型支持 int、long、charp(字符串指针)、bool 等。第三个参数是文件权限,如果设为 0644,模块加载后你可以在 /sys/module/<模块名>/parameters/ 下看到这些参数,甚至可以在运行时修改它们。

实战经验: 我一般把调试开关做成模块参数。产品阶段把 debug_level 默认设为 0,出问题时让现场人员 echo 3 > /sys/module/my_drv/parameters/debug_level 就能打开全量日志,不用重新编译模块。这个技巧帮我省了好几次出差。

4.3 模块依赖

模块之间是有依赖关系的。比如你写了一个 I2C 温度传感器驱动,它肯定依赖 I2C 核心模块。内核通过 MODULE_DEPEND 或者符号引用来管理这种关系。

看一个实际例子:

// i2c_temp_sensor.c
#include <linux/i2c.h>

static struct i2c_driver temp_sensor_driver = {
    .driver = {
        .name = "tmp102",
    },
    .id_table = temp_sensor_id,
    .probe = temp_sensor_probe,
    .remove = temp_sensor_remove,
};

module_i2c_driver(temp_sensor_driver);

这个模块引用了 I2C 子系统的符号(比如 i2c_add_driver)。当你用 insmod 加载它时,如果 I2C 核心模块没加载,就会报“Unknown symbol”。

正确的做法是用 modprobe:

modprobe i2c_temp_sensor

modprobe 会自动解析 depmod 生成的依赖文件 /lib/modules/$(uname -r)/modules.dep,先把 I2C 核心模块加载上,再加载你的传感器模块。

工具 特点 适用场景
insmod 简单直接,不处理依赖 调试单个模块,已知依赖已满足
modprobe 自动处理依赖,支持别名 生产环境,模块有复杂依赖
rmmod 卸载单个模块 模块无被引用
modprobe -r 递归卸载依赖模块 卸载整个模块树
踩坑记录: 我曾经用 rmmod 卸载一个正在被其他模块引用的模块,结果内核直接 panic 了。所以卸载前一定用 lsmod 看看 Used by 那一列是不是 0。

4.4 内核符号导出

有时候你写了一个模块,里面的函数想给其他模块用。比如你实现了一个通用的 GPIO 操作库,别的驱动想直接调用你的 gpio_set_value_custom()。这时候就需要导出符号。

内核提供了两个宏:

// 导出给所有模块
EXPORT_SYMBOL(gpio_set_value_custom);

// 只导出给 GPL 许可的模块
EXPORT_SYMBOL_GPL(gpio_set_value_custom);

用法很简单,在函数定义后面加上宏就行:

// gpio_lib.c
#include <linux/module.h>

int gpio_set_value_custom(unsigned int gpio, int value)
{
    // 自定义 GPIO 操作
    return 0;
}
EXPORT_SYMBOL_GPL(gpio_set_value_custom);

其他模块只要在编译时声明这个函数为外部函数,链接时就能找到它:

// my_driver.c
extern int gpio_set_value_custom(unsigned int gpio, int value);

static int __init my_init(void)
{
    gpio_set_value_custom(17, 1);
    return 0;
}

加载顺序很重要——必须先加载导出符号的模块,再加载使用它的模块。否则内核会报“Unknown symbol”。

我的习惯: 能用 EXPORT_SYMBOL_GPL 就别用 EXPORT_SYMBOL。一方面遵守 GPL 协议,另一方面也提醒自己——这个接口是给内核内部模块用的,不是给用户态程序调的。

4.5 知识结构图

下面这张图把本章的核心知识点串起来了。你可以看到模块从编写、编译到加载、卸载的完整流程,以及参数、依赖、符号导出在其中的位置。

内核模块编程知识结构图 内核模块编程核心知识结构 模块编写 module_init / module_exit 模块参数 module_param / sysfs 编译 Makefile / Kbuild 加载 / 卸载 insmod / rmmod / modprobe 模块依赖 depmod / modules.dep 符号导出 EXPORT_SYMBOL_GPL 关键流程说明 1. 编写模块代码 → 使用 module_init/module_exit 注册入口/出口 2. 编译生成 .ko 文件 → 通过 Makefile 调用内核构建系统 3. 加载模块 → insmod/modprobe,自动处理依赖和符号解析 💡 模块参数可在加载时传入,也可通过 /sys/module/<name>/parameters/ 运行时修改 💡 符号导出要遵循 GPL 协议,优先使用 EXPORT_SYMBOL_GPL

4.6 本章小结

内核模块编程是驱动开发的基本功。你想想看,掌握了模块的加载卸载、参数传递、依赖管理和符号导出,基本上就能应对大部分模块化开发的场景了。

  • 加载卸载:insmod 和 rmmod 是最直接的,但生产环境推荐 modprobe。
  • 模块参数:灵活配置驱动行为,调试时特别好用。
  • 模块依赖:用 depmod 生成依赖树,modprobe 自动处理。
  • 符号导出:模块间共享函数,注意协议合规。