4、内核模块编程:模块加载与卸载、module_param传递参数、printk调试技巧

好,咱们进入第四讲。说实话,内核模块编程是驱动开发的入门砖,也是你每天都要打交道的活儿。很多新手觉得写个"hello world"模块就完事了,其实不然。模块怎么加载、怎么卸载、参数怎么传、调试信息怎么看,这里面门道不少。我当年刚接触内核时,就因为一个参数没传对,导致网卡直接挂死,重启了三次才找到原因。

今天咱们就把这些基本功彻底讲透。

4.1 模块的加载与卸载:不只是insmod和rmmod

先看一个最基础的Intel网卡驱动模块框架。嗯,代码不长,但每个细节都值得琢磨。

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

static int __init igb_init_module(void)
{
    printk(KERN_INFO "Intel IGB driver loading...\n");
    // 这里注册PCI驱动,后面章节会细讲
    return 0;
}

static void __exit igb_cleanup_module(void)
{
    printk(KERN_INFO "Intel IGB driver unloading...\n");
    // 这里注销PCI驱动
}

module_init(igb_init_module);
module_exit(igb_cleanup_module);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("Intel IGB Ethernet Driver");

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

  • __init 和 __exit 宏:这两个宏不是摆设。加了__init的函数,在模块加载完成后,内核会把这段代码占用的内存释放掉。说白了就是省内存。我在项目中见过有人没加,结果模块加载后多占了4KB内存,虽然不多,但服务器上几百个模块累积起来就吓人了。
  • module_init / module_exit:这是模块的入口和出口。注意,入口函数名可以随便起,但必须用这两个宏来注册。
  • MODULE_LICENSE:这个必须写。如果不写或者写错,内核会报"tainted kernel"警告。我建议统一写"GPL",除非你有特殊原因。

个人小技巧:我习惯在init函数里加一个版本号打印,比如"IGB driver v2.1.0 loading..."。这样dmesg里一眼就能看出加载的是哪个版本,排查问题时特别有用。

加载和卸载的命令很简单:

# 加载模块
sudo insmod igb.ko

# 查看模块是否加载成功
lsmod | grep igb

# 卸载模块
sudo rmmod igb

但这里有个坑——模块依赖。如果你的模块依赖了其他模块,直接rmmod会失败。我曾经在调试时忘了这个,折腾了半小时才发现是依赖链没断开。

注意:rmmod失败时,先检查一下模块的引用计数。用"lsmod"看第三列,如果引用计数不为0,说明有其他模块或进程在使用它。这时候要么先卸载依赖模块,要么用"rmmod -f"强制卸载(不推荐在生产环境用)。

4.2 module_param:让驱动更灵活

写死参数的驱动,说白了就是给自己找麻烦。你想想看,网卡的中断延迟、队列数量、调试开关,这些如果都写死在代码里,每次改参数都得重新编译模块,多累啊。

内核提供了module_param机制,让我们可以在加载模块时动态传参。来看个例子:

#include <linux/moduleparam.h>

// 定义参数
static int debug = 0;
static int rx_rings = 4;
static char *iface_name = "eth0";

// 注册参数
module_param(debug, int, 0644);
module_param(rx_rings, int, 0644);
module_param(iface_name, charp, 0644);

// 参数描述(可选,但推荐)
MODULE_PARM_DESC(debug, "Enable debug output (0=off, 1=on)");
MODULE_PARM_DESC(rx_rings, "Number of RX rings (default 4)");
MODULE_PARM_DESC(iface_name, "Network interface name");

加载时传参:

sudo insmod igb.ko debug=1 rx_rings=8 iface_name="eth1"

这里有几个细节,我当年踩过坑:

  • 参数权限0644:这个权限码决定了参数在sysfs中是否可见。0644表示root可读写,其他人只读。如果设成0,参数就完全隐藏了,只能在加载时传一次。
  • 类型选择:int、uint、long、charp(字符串指针)是最常用的。注意,bool类型用module_param_bool,别搞混了。
  • 数组参数:如果参数是数组,用module_param_array。比如指定多个MAC地址过滤。

实战经验:我在调试Intel I350网卡时,遇到过一个问题——默认的rx_rings=4在某个服务器上导致中断分配不均。后来我加了module_param,允许运维人员在加载时动态调整,问题就解决了。你想想看,如果没有这个机制,我得编译多少个版本的驱动?

4.3 printk调试技巧:别再用printk了

嗯,这句话有点标题党。其实printk还是得用,但要用对方法。

很多新手写驱动,满屏都是printk。结果dmesg里刷了几万条日志,真正有用的信息全被淹没了。我见过最夸张的,一个中断处理函数里放了三个printk,直接把系统卡死——因为printk在中断上下文里可能会睡眠。

正确的做法是:

4.3.1 使用日志级别

printk(KERN_EMERG   "系统崩溃了!\n");   // 0,最高级别
printk(KERN_ALERT   "需要立即处理\n");    // 1
printk(KERN_CRIT    "严重错误\n");        // 2
printk(KERN_ERR     "错误条件\n");        // 3
printk(KERN_WARNING "警告信息\n");        // 4
printk(KERN_NOTICE  "正常但重要\n");      // 5
printk(KERN_INFO    "信息性消息\n");      // 6
printk(KERN_DEBUG   "调试信息\n");        // 7

为什么要分级别?因为你可以通过/proc/sys/kernel/printk来控制哪些级别的日志能打印出来。默认值是"7 4 1 7",意思是:

字段含义默认值
console_loglevel打印到控制台的最高级别7(所有都打印)
default_message_loglevel未指定级别的默认级别4(KERN_WARNING)
minimum_console_loglevel控制台最低级别1
default_console_loglevel控制台默认级别7

我个人的习惯是:

  • 开发调试时:echo "8 4 1 7" > /proc/sys/kernel/printk,让所有调试信息都出来
  • 生产环境:echo "4 4 1 7" > /proc/sys/kernel/printk,只打印警告和错误

4.3.2 使用pr_系列宏

从内核2.6开始,推荐用pr_系列宏替代printk:

pr_emerg("系统崩溃了!\n");
pr_alert("需要立即处理\n");
pr_crit("严重错误\n");
pr_err("错误条件\n");
pr_warn("警告信息\n");
pr_notice("正常但重要\n");
pr_info("信息性消息\n");
pr_debug("调试信息\n");

好处很明显:代码更简洁,而且pr_debug在非调试内核中会被自动优化掉,不影响性能。

避坑指南:我曾经在中断处理函数里用了pr_info,结果导致系统响应变慢。后来才发现,pr_info在默认配置下会打印到控制台,而控制台输出是同步的,会阻塞中断。解决方案是:中断里只用pr_debug,或者干脆用tracepoint。

4.3.3 动态调试:dev_dbg和netdev_dbg

对于驱动开发,更推荐使用dev_dbgnetdev_dbg。它们会自动带上设备名,方便定位问题:

// 设备驱动
dev_dbg(&pdev->dev, "Registering device %s\n", pdev->name);

// 网络驱动
netdev_dbg(netdev, "Packet received, len=%d\n", skb->len);

这些宏配合dynamic_debug功能,可以在运行时动态开启或关闭某个文件的调试信息,而不需要重新编译模块。具体用法:

# 开启某个文件的调试
echo 'file igb_main.c +p' > /sys/kernel/debug/dynamic_debug/control

# 开启某个函数的调试
echo 'func igb_xmit_frame +p' > /sys/kernel/debug/dynamic_debug/control

这个功能太实用了。我在调试Intel I210的发送路径时,就靠它精准定位了一个DMA映射错误,省去了反复编译模块的时间。

4.4 综合示例:一个带参数的调试模块

最后,咱们把今天的内容串起来,写一个完整的示例模块:

#include <linux/module.h>
#include <linux/moduleparam.h>
#include <linux/kernel.h>
#include <linux/pci.h>

static int debug_level = 0;
static int tx_queues = 4;
static char *pci_slot = NULL;

module_param(debug_level, int, 0644);
module_param(tx_queues, int, 0644);
module_param(pci_slot, charp, 0644);

MODULE_PARM_DESC(debug_level, "Debug level: 0=none, 1=basic, 2=verbose");
MODULE_PARM_DESC(tx_queues, "Number of TX queues");
MODULE_PARM_DESC(pci_slot, "PCI slot address (e.g., 0000:02:00.0)");

static int __init my_driver_init(void)
{
    pr_info("My driver loading...\n");
    pr_info("Debug level: %d\n", debug_level);
    pr_info("TX queues: %d\n", tx_queues);
    
    if (pci_slot)
        pr_info("Target PCI slot: %s\n", pci_slot);
    
    if (debug_level > 0)
        pr_debug("Debug mode enabled\n");
    
    // 这里做实际的初始化...
    return 0;
}

static void __exit my_driver_exit(void)
{
    pr_info("My driver unloading...\n");
}

module_init(my_driver_init);
module_exit(my_driver_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Driver Expert");
MODULE_DESCRIPTION("Example driver with module parameters");

加载测试:

# 基本加载
sudo insmod mydriver.ko

# 带参数加载
sudo insmod mydriver.ko debug_level=2 tx_queues=8 pci_slot="0000:02:00.0"

# 查看参数(在sysfs中)
cat /sys/module/mydriver/parameters/debug_level

好了,这一讲的内容就到这里。模块加载卸载、参数传递、调试打印,这三板斧看似简单,但用好了能让你在驱动开发中事半功倍。下一讲咱们会深入PCI子系统,看看网卡是怎么和内核打交道的。