内核模块编程基础:模块的加载与卸载、模块参数传递、模块依赖与符号导出

说实话,很多刚接触嵌入式Linux驱动开发的朋友,第一个拦路虎就是「内核模块」。大家总觉得内核是个神秘的东西,模块加载卸载更是玄学。其实没那么复杂——内核模块说白了就是一段可以在运行时动态插入内核的代码。我当年第一次写Hello World模块时,看着insmod之后终端打印出那行字,心里还挺激动的。

一、模块的加载与卸载

先看一个最基础的模块框架。你写驱动,本质上就是实现两个函数:入口和出口。

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

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

static void __exit my_driver_exit(void)
{
    printk(KERN_INFO "Goodbye, kernel 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");

这里有几个关键点,我重点说一下:

  • __init 和 __exit 宏:这两个宏会把函数放到特定的内存段。__init 标记的函数在模块加载完成后会被内核释放掉,省内存。我在项目里见过有人把大数组也放在 __init 段里,结果模块加载完数据就丢了——嗯,这是个坑。
  • module_init 和 module_exit:这是告诉内核「我的入口和出口在哪儿」。注意,不是函数名匹配,而是通过这两个宏注册的。
  • MODULE_LICENSE:不写这个,内核会报「tainted kernel」警告。我个人习惯一律写 "GPL",省事。

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

操作 命令 说明
加载模块 insmod my_module.ko 直接插入,不处理依赖
卸载模块 rmmod my_module 按模块名卸载
查看已加载模块 lsmod 显示模块列表及依赖关系
带依赖加载 modprobe my_module 自动处理依赖,推荐使用
避坑提醒:我曾经在调试一个I2C驱动时,用 insmod 加载后系统直接panic。后来发现是模块的init函数里访问了一个未初始化的指针。记住:init函数里一定要做错误检查,返回非0值表示加载失败,内核会帮你回滚。

二、模块参数传递

你写驱动时,有些配置不想写死在代码里——比如设备地址、缓冲区大小、调试开关。这时候就需要模块参数。

内核提供了 module_param 和 module_param_array 宏,用法很直接:

#include <linux/moduleparam.h>

static int debug_level = 0;
static char *device_name = "default_dev";
static int gpio_pins[4] = {17, 18, 19, 20};
static int gpio_count;

module_param(debug_level, int, 0644);
module_param(device_name, charp, 0644);
module_param_array(gpio_pins, int, &gpio_count, 0644);

MODULE_PARM_DESC(debug_level, "Debug level (0-3)");
MODULE_PARM_DESC(device_name, "Device name string");
MODULE_PARM_DESC(gpio_pins, "GPIO pin numbers array");

加载时传参:

insmod my_module.ko debug_level=2 device_name="my_i2c_dev" gpio_pins=21,22,23,24

这里有几个细节:

  • 权限位 0644:表示这个参数在 /sys/module/<模块名>/parameters/ 下可见,用户可以通过sysfs在运行时修改。如果不想暴露,传 0 就行。
  • charp:这是内核为字符串参数定义的类型,本质是 char *。注意,传进来的字符串是内核分配的,你不需要手动释放。
  • module_param_array:第三个参数是数组长度变量的指针,内核会把实际传入的元素个数写进去。我遇到过有人忘了传这个指针,结果数组越界——嗯,调试了一下午。
我的习惯:调试阶段把所有参数权限都设成 0644,这样可以在 /sys 下直接 echo 修改,不用反复卸载加载模块。等代码稳定了再改成 0。

三、模块依赖与符号导出

实际项目中,很少有模块是孤立的。比如你写一个GPIO子系统驱动,上层的外设驱动需要调用你提供的接口。这时候就需要符号导出。

内核用 EXPORT_SYMBOL 和 EXPORT_SYMBOL_GPL 来导出符号:

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

int gpio_set_value(int pin, int value)
{
    // 实际硬件操作
    printk(KERN_DEBUG "GPIO %d set to %d\n", pin, value);
    return 0;
}
EXPORT_SYMBOL(gpio_set_value);

int gpio_get_value(int pin)
{
    return 1; // 模拟读取
}
EXPORT_SYMBOL_GPL(gpio_get_value);

另一个模块就可以直接调用:

// led_driver.c
extern int gpio_set_value(int pin, int value);

static int __init led_init(void)
{
    gpio_set_value(18, 1);
    return 0;
}

模块依赖关系通过 depmod 工具管理,modprobe 加载时会自动先加载依赖模块。你可以在 /lib/modules/$(uname -r)/modules.dep 里看到依赖关系。

导出宏 适用范围 说明
EXPORT_SYMBOL 所有模块 任何模块都可以使用
EXPORT_SYMBOL_GPL 仅GPL模块 非GPL模块调用会报错
我曾经踩过的坑:在一个商业项目中,我们导出了一个核心函数用 EXPORT_SYMBOL,结果第三方模块直接调用了我们的内部接口,导致系统不稳定。后来全部改成 EXPORT_SYMBOL_GPL,并在代码里加了调用者检查。记住:符号导出就是你的API边界,设计时要想清楚哪些是公开的,哪些是内部的。

四、知识体系总览

下面这张图把本章的核心逻辑串起来了。你写驱动时,心里要有这个流程:

内核模块编程核心知识体系 模块加载与卸载 module_init / module_exit __init / __exit 内存段 insmod / rmmod / modprobe lsmod 查看依赖 模块参数传递 module_param / charp module_param_array 权限位 0644 / 0 sysfs 运行时修改 符号导出与依赖 EXPORT_SYMBOL EXPORT_SYMBOL_GPL depmod 依赖管理 modprobe 自动加载 编译流程:Makefile → .ko → 目标板 调试手段:printk / dmesg / /sys/parameters 核心原则:模块是内核的一部分,任何错误都可能导致系统崩溃,务必在开发板上测试 符号导出即API边界,设计时想清楚哪些公开、哪些内部

核心要点回顾:

  • 每个模块必须有 module_init 和 module_exit,这是内核与模块的契约
  • 模块参数让驱动配置灵活,调试阶段建议开放sysfs权限
  • 符号导出要谨慎,能用 EXPORT_SYMBOL_GPL 就别用 EXPORT_SYMBOL
  • 依赖管理用 modprobe + depmod,别手动 insmod 一堆模块

好了,这一章的内容就这些。你先把这几个基础操作练熟,后面写字符设备驱动、中断处理、并发控制时,这些知识会反复用到。记住:模块编程是驱动开发的基石,基础打牢了,后面才能走得稳。