内核模块基础:模块加载与卸载机制、module_init/module_exit宏、模块参数传递

驱动开发的第一步,就是搞清楚内核模块怎么跑起来的。说白了,Linux驱动有两种玩法:一种是直接编译进内核,另一种就是编译成模块,按需加载。我个人更偏爱后者——调试方便,不用每次改一行代码就重新烧整个内核。

模块加载与卸载的完整流程

先看一个最简单的模块骨架。你写驱动,本质上就是告诉内核:我这段代码,什么时候初始化,什么时候退出。

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

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

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

module_init(my_driver_init);
module_exit(my_driver_exit);

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

这段代码,你编译出来就是 .ko 文件。用 insmod 加载,用 rmmod 卸载。流程其实很简单:

  1. insmod 触发 module_init 指定的函数
  2. 内核分配资源、注册设备、初始化硬件
  3. rmmod 触发 module_exit 指定的函数
  4. 内核释放资源、注销设备、关闭硬件

关键点:__init 和 __exit 这两个标记不是摆设。加了 __init 的函数,初始化完成后内核会直接释放它占用的内存。我见过不少新手没加这个标记,白白浪费几百字节内核空间。

module_init / module_exit 宏的底层逻辑

这两个宏到底干了什么?其实它们把函数指针塞进了内核的特定段。加载时内核遍历这些段,找到入口函数并调用。

宏 作用 对应段
module_init() 注册模块初始化函数 .init.text
module_exit() 注册模块退出函数 .exit.text

嗯,这里要注意:如果你把模块编译进内核(built-in),module_init 就变成了 device_initcall,执行时机会提前到内核启动阶段。而作为模块加载时,它就是个普通的函数调用。

我曾经踩过的坑:在 module_init 里做 msleep() 或 schedule()。模块初始化时,内核可能还没完全准备好调度器,直接睡下去会导致系统卡死。后来我改成用 probe 回调来做延时操作,再也没出过问题。

模块参数传递:让驱动更灵活

你写驱动,不可能把所有值都硬编码。比如设备地址、中断号、缓冲区大小,这些应该让用户加载时指定。内核提供了 module_param 机制。

#include <linux/moduleparam.h>

static int irq_num = 10;
static char *device_name = "mydev";
static int debug_level = 0;

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

MODULE_PARM_DESC(irq_num, "IRQ number for the device");
MODULE_PARM_DESC(device_name, "Device name string");
MODULE_PARM_DESC(debug_level, "Debug level (0-3)");

加载时这样传参:

insmod my_driver.ko irq_num=17 device_name="uart0" debug_level=2

参数类型支持:

  • byte、short、ushort、int、uint、long、ulong
  • charp(字符串指针)
  • bool、invbool
  • 数组用 module_param_array()

个人习惯:第三个参数是文件权限,0644 表示 root 可读写,其他人只读。设为 0 的话,/sys/module/ 下就看不到这个参数了。调试阶段我一般开 0644,方便运行时改。

参数运行时修改

参数传进去之后,还能在 /sys/module/ 下动态改。比如:

echo 3 > /sys/module/my_driver/parameters/debug_level

但要注意,不是所有参数都支持运行时修改。这取决于你代码里怎么用这个变量。如果初始化时只读一次,改了也没用。我一般会在 debug_level 的 set 回调里加个 printk,确认修改生效。

模块加载与卸载的完整生命周期

为了让你更直观地理解整个过程,我画了一张流程图:

内核模块加载与卸载生命周期 insmod / modprobe rmmod 内核模块加载器 module_init() 执行 分配资源 / 注册设备 模块运行中 module_exit() 执行 释放资源 / 注销设备 用户空间操作 insmod / modprobe 加载 .ko 文件 rmmod 卸载模块 内核空间操作 module_init 初始化 module_exit 清理 参数传递与修改

模块加载的几种方式对比

命令 特点 依赖处理 适用场景
insmod 直接加载,不处理依赖 手动解决 调试单个模块
modprobe 自动处理依赖 自动加载依赖模块 生产环境
modprobe -r 卸载模块及其依赖 递归卸载 清理模块树

避坑指南:modprobe 依赖 /lib/modules/$(uname -r)/ 下的 modules.dep 文件。如果你自己编译的内核模块没跑 depmod,modprobe 会报 modprobe: FATAL: Module not found。我刚开始折腾时,经常忘了这步,还以为是代码写错了。

模块参数的最佳实践

参数传递看起来简单,但用不好会埋坑。我总结了几条经验:

  • 参数名要有意义:别用 a、b、tmp 这种名字,三个月后你自己都看不懂
  • 加 MODULE_PARM_DESC:描述参数用途,modinfo 能看到,方便别人用你的驱动
  • 校验参数值:比如 irq_num 不能是负数,debug_level 不能超过最大值
  • 数组参数用 module_param_array:比如指定多个设备地址
static int gpio_pins[4] = {10, 11, 12, 13};
static int gpio_count;
module_param_array(gpio_pins, int, &gpio_count, 0644);
MODULE_PARM_DESC(gpio_pins, "Array of GPIO pin numbers");

加载时:

insmod gpio_driver.ko gpio_pins=20,21,22,23

我建议:调试阶段把 debug_level 参数暴露出来,配合 printk 分级打印。上线前再关掉或者设为 0。这样既不影响性能,出问题时又能快速打开日志。

模块计数与安全卸载

内核用 try_module_get 和 module_put 来管理模块引用计数。如果计数不为 0,rmmod 会失败。你想想看,如果设备正在读写,你把驱动卸载了,系统不崩才怪。

我遇到过最典型的情况:写了一个字符设备驱动,用户空间程序打开了 /dev/mydev 没关闭,然后执行 rmmod。内核直接报 Device or resource busy。这不是 bug,是保护机制。

所以,写 module_exit 时一定要检查引用计数,确保所有用户都释放了资源再卸载。嗯,这里有个小技巧:在 exit 函数里加个 printk 打印当前引用计数,调试时很有用。