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

内核模块,说白了就是一段可以在系统运行时动态加载进内核的代码。不用重新编译整个内核,不用重启机器,就能给内核加功能。我刚开始接触嵌入式Linux时,觉得这玩意儿特别神奇——一个.ko文件,insmod一下,系统就多了个驱动。后来自己写模块踩了不少坑,才慢慢摸清楚里面的门道。

3.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 World!\n");
    return 0;
}

static void __exit my_module_exit(void)
{
    printk(KERN_INFO "Goodbye, Kernel World!\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:这两个标记告诉内核,初始化函数和退出函数在完成后可以释放内存。我在项目中见过有人忘了加,结果模块占着内存不放。
  • MODULE_LICENSE:不写这个,内核会报“tainted kernel”警告。我曾经因为漏了这一行,被同事追着问“你的模块是不是闭源的?”

编译生成.ko文件后,操作很简单:

操作命令说明
加载模块insmod my_module.ko直接插入内核
查看已加载模块lsmod列出所有模块及其依赖
卸载模块rmmod my_module从内核移除
查看模块信息modinfo my_module.ko显示作者、描述、参数等

注意:rmmod卸载时,内核会检查模块的引用计数。如果其他模块或进程正在使用它,卸载会失败。我曾经在调试一个USB驱动时,忘了拔设备就直接rmmod,结果内核直接Oops了。

3.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");

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_uart"

参数类型支持:

类型宏C类型说明
module_param(name, int, perm)int整型参数
module_param(name, charp, perm)char *字符串指针
module_param(name, bool, perm)bool布尔值(y/n, 1/0)
module_param_array(name, type, num, perm)数组数组参数,num返回元素个数

那个perm参数是文件权限,如果设为0,参数就不会出现在/sys/module/下。我个人习惯设成0644,这样可以在运行时通过sysfs查看和修改。

小技巧:调试阶段,我经常加一个debug_level参数。上线前把默认值设成0,调试时insmod传个3,所有调试信息全开。省得来回改代码。

3.3 模块依赖与符号导出:模块之间怎么协作

一个模块可能需要另一个模块提供的函数。比如你的字符设备驱动要用到某个核心模块的辅助函数。这时候就需要符号导出。

先看导出方:

// 在模块A中
#include <linux/module.h>
#include <linux/kernel.h>

int my_helper_function(int data)
{
    return data * 2;
}
EXPORT_SYMBOL(my_helper_function);

// 也可以导出GPL-only的符号
void my_gpl_function(void)
{
    printk(KERN_INFO "This is GPL-only\n");
}
EXPORT_SYMBOL_GPL(my_gpl_function);

再看使用方:

// 在模块B中
extern int my_helper_function(int data);

static int __init module_b_init(void)
{
    int result = my_helper_function(10);
    printk(KERN_INFO "Result: %d\n", result);
    return 0;
}

加载顺序很重要。必须先加载模块A,再加载模块B。否则B会报“Unknown symbol”错误。

核心概念:

  • EXPORT_SYMBOL:导出符号,任何模块都能用
  • EXPORT_SYMBOL_GPL:只允许GPL许可的模块使用
  • 符号表:通过/proc/kallsyms查看所有内核符号
  • 依赖关系:lsmod会显示模块间的依赖链

我曾经在一个项目中,两个模块互相依赖,结果加载时死锁了。后来改成单向依赖,才解决问题。记住:内核模块不允许循环依赖。

3.4 printk调试技巧:内核里的printf

printk是内核模块最常用的调试手段。它和用户空间的printf很像,但有几个关键区别。

先看日志级别:

宏数值含义使用场景
KERN_EMERG0紧急消息系统崩溃前
KERN_ALERT1需要立即处理严重错误
KERN_CRIT2临界条件硬件故障
KERN_ERR3错误条件驱动错误
KERN_WARNING4警告非致命问题
KERN_NOTICE5正常但重要启动信息
KERN_INFO6信息一般调试
KERN_DEBUG7调试信息开发阶段

使用方式:

printk(KERN_ERR "DMA transfer failed, status: 0x%x\n", status);
printk(KERN_DEBUG "Entering interrupt handler, irq=%d\n", irq);

查看日志:

  • dmesg:查看内核环形缓冲区
  • dmesg -w:实时跟踪新日志
  • cat /proc/kmsg:直接读取内核消息(需要root)
  • dmesg -n 3:只显示级别≤3的消息(KERN_ERR及以上)

避坑指南:我曾经在中断处理函数里用了printk,结果系统直接卡死。为什么?因为printk可能触发调度,而中断上下文不允许睡眠。记住:中断处理函数里用printk要极其小心,最好用printk_ratelimited或者干脆不用。

几个实用技巧:

  • 动态控制日志级别:通过/proc/sys/kernel/printk文件,可以运行时调整控制台日志级别
  • printk_ratelimited:限制打印频率,防止刷屏
  • pr_devel/pr_debug:只在DEBUG配置下生效,上线后自动消失
  • hex_dump:打印二进制数据,调试协议栈时特别好用
// 限制频率的printk
if (printk_ratelimit()) {
    printk(KERN_WARNING "Too many interrupts!\n");
}

// 打印二进制数据
print_hex_dump(KERN_DEBUG, "rx_data: ", DUMP_PREFIX_OFFSET,
               16, 1, buffer, len, true);

3.5 本章知识体系

下面这张图把模块编程的核心流程串起来了:

Linux内核模块编程核心流程 insmod加载模块 module_init()执行 模块参数传递 EXPORT_SYMBOL 模块依赖解析 printk调试输出 模块运行中(内核空间) rmmod卸载模块 module_exit()清理资源 关键:加载顺序 → 参数解析 → 符号解析 → 初始化 → 运行 → 清理

这张图把模块从加载到卸载的完整生命周期画清楚了。你写模块时,脑子里要有这个流程:加载时传了什么参数?依赖的符号有没有先加载?初始化成功了吗?卸载时资源释放干净了吗?每一步都可能出问题。

我的习惯:每个模块的init函数里,我都会加一个printk(KERN_INFO "Module loaded with debug_level=%d\n", debug_level);。这样dmesg一看就知道模块有没有加载成功,参数传对了没有。省得怀疑人生。