内核模块基础:模块的加载与卸载、模块参数传递、模块依赖与符号导出、printk调试技巧

说实话,很多刚接触驱动开发的朋友,第一个坎儿就是搞不懂内核模块到底是个什么东西。我当年也一样,总觉得它跟普通的应用程序差不多,结果一跑就崩,连个报错都看不懂。

内核模块,说白了就是一段可以在系统运行时动态加载到内核的代码。它不像应用程序那样有自己的进程空间,而是直接跑在内核态,跟内核共享地址空间。这意味着——你写错了,系统就挂了,连个招呼都不打。

核心概念:内核模块是Linux内核实现"按需加载"的机制。驱动、文件系统、网络协议等都可以编译成模块,需要时插入,不用时卸载,避免内核镜像过于臃肿。

2.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 的函数,在模块加载完成后,内核会把它的代码从内存中释放掉,省空间。

编译成 .ko 文件后,操作就简单了:

# 加载模块
insmod my_module.ko

# 查看已加载的模块
lsmod

# 卸载模块
rmmod my_module

insmod 和 modprobe 有什么区别?我刚开始也搞混过。insmod 是硬加载,你给它什么文件它就加载什么,不处理依赖。modprobe 则会自动解析依赖关系,把需要的其他模块一起加载进来。所以实际项目中,我基本只用 modprobe。

避坑指南:我曾经在调试一个USB驱动时,用 insmod 加载模块,结果系统直接卡死。后来发现是因为模块依赖的另一个核心模块没加载。从那以后,但凡涉及多个模块的场景,我都用 modprobe。

2.2 模块参数传递

驱动写死了参数值,每次改都要重新编译?太蠢了。内核模块支持在加载时传递参数,这个功能我几乎每个项目都会用。

#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");

static int __init my_module_init(void)
{
    printk(KERN_INFO "Debug level: %d\n", debug_level);
    printk(KERN_INFO "Device name: %s\n", device_name);
    return 0;
}

加载时这样传参:

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

module_param 宏的三个参数:变量名、类型、权限。权限这里我多说一句——0644 表示在 /sys/module/ 下创建对应的参数文件,允许用户查看,只有 root 能写。如果你设成 0,那就只能在加载时传参,运行时改不了。

类型 描述 示例
byte / short / int / long 基本整数类型 module_param(count, int, 0)
uint / ulong 无符号整数 module_param(flags, uint, 0644)
charp 字符串指针 module_param(name, charp, 0)
bool / invbool 布尔值(支持0/1, y/n) module_param(enable, bool, 0644)
数组 用 module_param_array module_param_array(arr, int, &num, 0)

我的习惯:调试阶段,我会把参数权限设成 0644,这样可以在运行时通过 echo 3 > /sys/module/my_module/parameters/debug_level 动态调整日志级别,不用反复加载卸载模块。

2.3 模块依赖与符号导出

一个驱动往往不是孤立的。比如你的字符设备驱动可能需要依赖一个底层的GPIO控制器模块。这时候就需要符号导出机制。

内核用 EXPORT_SYMBOL 和 EXPORT_SYMBOL_GPL 来导出符号(函数或变量),供其他模块使用。

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

int gpio_set_value(int pin, int value)
{
    // 实际的 GPIO 操作
    return 0;
}
EXPORT_SYMBOL(gpio_set_value);

// 在 my_driver.c 中
extern int gpio_set_value(int pin, int value);

static int __init my_driver_init(void)
{
    gpio_set_value(17, 1);  // 调用导出的函数
    return 0;
}

加载顺序很重要。必须先加载 gpio_ctrl.ko,再加载 my_driver.ko。用 modprobe 的话,它会自动处理这个顺序。

查看模块导出了哪些符号:

# 查看所有导出的符号
cat /proc/kallsyms | grep gpio_set_value

# 查看某个模块导出的符号
cat /proc/kallsyms | grep gpio_ctrl

EXPORT_SYMBOL_GPL 和 EXPORT_SYMBOL 的区别?前者只允许 GPL 协议的模块使用。我个人建议,如果你的代码是开源项目,用 EXPORT_SYMBOL_GPL 更合适,能避免一些许可证纠纷。

我曾经踩过的坑:有一次我写了一个驱动,依赖另一个模块导出的函数。结果那个模块更新后,导出的函数签名变了,我的模块加载时直接报 Unknown symbol 错误。从那以后,我养成了一个习惯——在模块的 MODULE_DESCRIPTION 里明确写明依赖的模块和版本,方便排查。

2.4 printk 调试技巧

驱动开发调试,说白了大部分时间就是靠 printk。没有 GDB,没有断点,系统一崩就是 panic,你只能靠日志来还原现场。

printk 跟 printf 很像,但多了个日志级别:

printk(KERN_EMERG   "System is unusable\n");     // 0
printk(KERN_ALERT   "Action must be taken\n");    // 1
printk(KERN_CRIT    "Critical condition\n");      // 2
printk(KERN_ERR     "Error condition\n");         // 3
printk(KERN_WARNING "Warning condition\n");       // 4
printk(KERN_NOTICE  "Normal but significant\n");  // 5
printk(KERN_INFO    "Informational\n");           // 6
printk(KERN_DEBUG   "Debug-level message\n");     // 7

日志级别从 0(最紧急)到 7(最不紧急)。内核会根据 /proc/sys/kernel/printk 中的配置决定哪些级别的消息能输出到控制台。

# 查看当前 printk 配置
cat /proc/sys/kernel/printk
# 输出示例:7 4 1 7
# 分别对应:控制台级别、默认级别、最低级别、最大级别

第一个数字 7 表示只有级别小于 7 的消息才会输出到控制台。也就是说,KERN_DEBUG(级别 7)默认是看不到的。想看调试信息?调低这个值:

echo 8 > /proc/sys/kernel/printk

或者直接用 dmesg 查看所有日志,不管级别:

# 查看所有内核日志
dmesg

# 实时跟踪
dmesg -w

# 清空日志
dmesg -c

我的调试三板斧:

  • 开发阶段:把所有 printk 都写成 KERN_INFO 或 KERN_DEBUG,用 dmesg -w 实时看
  • 定位问题:在关键路径上加 KERN_ERR,即使控制台级别高也能看到
  • 性能敏感路径:用 printk_ratelimited 或 pr_debug(编译时通过 DEBUG 宏控制开关),避免刷屏

说到 pr_debug,这是内核提供的一个便捷宏。它在编译时如果定义了 DEBUG 才会生效,否则就是个空操作。我习惯在模块开头加一行:

#define DEBUG
#include <linux/module.h>

这样调试信息只在调试版本中输出,发布时去掉 #define DEBUG 就行,不用改代码。

重要提醒:生产环境中,printk 是有性能开销的。我曾经在一个中断处理函数里加了 printk,结果系统响应变得极慢。原因很简单——printk 在中断上下文中可能会引起调度延迟。所以,调试完记得删掉或者用条件编译关掉。

内核模块生命周期与调试流程 insmod / modprobe module_init() 执行 模块注册完成 模块运行中 printk / dmesg 查看日志 /sys/module/ 动态调参 /proc/kallsyms 查符号 rmmod 触发卸载 module_exit() 执行 模块从内核移除 关键:模块依赖通过 EXPORT_SYMBOL 建立,加载顺序由 modprobe 自动处理 调试时优先用 dmesg -w 实时跟踪,避免在中断上下文使用 printk

这张图把模块从加载到卸载的完整流程串起来了。你可以看到,调试手段贯穿整个生命周期——加载时看 printk 输出,运行时调参数、查符号,卸载时确认清理函数是否正常执行。

嗯,这一章的内容就这些。模块加载卸载、参数传递、符号导出、printk 调试,这四个点是你写驱动的基本功。我当年花了整整一周才把这些搞清楚,现在你有了这份总结,应该能快很多。