第4章:Linux内核模块基础

说实话,很多刚接触驱动开发的朋友,第一个拦路虎就是——搞不清楚内核模块到底是个啥。我当年也一样,看着那些.ko文件,心里直犯嘀咕:这玩意儿跟普通程序有啥区别?

嗯,今天咱们就把这事儿彻底聊透。

4.1 内核模块的结构

内核模块,说白了就是一段可以动态加载到内核里的代码。它不像应用程序那样有main()函数,而是用两个固定的入口函数:module_init()module_exit()

来看一个最基础的模块骨架:

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

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

  • __init 和 __exit:这两个宏告诉内核,初始化函数用完后可以扔掉,节省内存。我在项目里见过有人忘了加,结果模块占着内存不放,虽然问题不大,但总觉得不够优雅。
  • module_init / module_exit:这是注册入口和出口的宏。注意,它们不是函数调用,是宏定义。
  • MODULE_* 宏:这些是模块的描述信息,虽然不写也能跑,但我建议你养成好习惯。有一次我调试一个第三方驱动,死活找不到作者,就是因为没写MODULE_AUTHOR

核心要点:内核模块没有main(),它的生命周期完全由initexit函数控制。加载时执行init,卸载时执行exit

4.2 模块加载与卸载

写好了模块代码,怎么让它跑起来?Linux提供了几个命令行工具:

命令 作用 常用参数
insmod 加载模块 直接跟模块文件路径
rmmod 卸载模块 跟模块名(不带.ko)
modprobe 智能加载/卸载 自动处理依赖关系
lsmod 查看已加载模块 无参数

我个人习惯用modprobe而不是insmod。为什么?因为modprobe会自动帮你解决模块依赖。比如你的驱动依赖i2c-core,用insmod你得先手动加载i2c-core,而modprobe会帮你搞定这一切。

举个例子:

# 编译模块
make

# 加载模块(推荐)
sudo modprobe my_driver

# 或者用 insmod
sudo insmod my_driver.ko

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

# 卸载模块
sudo modprobe -r my_driver
# 或者
sudo rmmod my_driver

我曾经踩过的坑:有一次我写了个存储芯片驱动,加载时一切正常,但卸载时系统直接崩溃。查了半天,发现是exit函数里没有正确释放中断。记住,卸载模块时,一定要把init里申请的所有资源都还回去——内存、中断、GPIO,一个都不能漏。

4.3 printk调试技巧

做驱动开发,调试手段有限。没有printf,没有gdb(至少早期没有),你靠什么?printk

printkprintf很像,但多了个日志级别。来看:

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_INFO    "信息\n");            // 级别5
printk(KERN_DEBUG   "调试信息\n");        // 级别6

你想想看,如果所有printk都往控制台输出,那系统日志还不乱套了?所以内核用日志级别来控制哪些信息显示在控制台上。默认情况下,只有级别低于console_loglevel的消息才会显示。

查看当前控制台日志级别:

cat /proc/sys/kernel/printk
# 输出示例:4 4 1 7
# 第一个数字就是 console_loglevel

调试时我经常临时调高级别:

# 让所有级别的消息都显示在控制台
echo 8 > /proc/sys/kernel/printk

我的调试小技巧:开发阶段,我习惯在关键路径上加printk(KERN_DEBUG "..."),然后通过dmesg查看。发布前再用条件编译把调试信息关掉,比如:

#define DEBUG
#ifdef DEBUG
    #define dbg_printk(fmt, ...) \
        printk(KERN_DEBUG pr_fmt(fmt), ##__VA_ARGS__)
#else
    #define dbg_printk(fmt, ...) do { } while (0)
#endif

4.4 模块参数传递

写驱动时,你肯定不想把一些配置值写死在代码里。比如存储芯片的I2C地址、中断号、采样频率等。这时候就需要模块参数了。

内核提供了module_param宏,用法很简单:

#include <linux/moduleparam.h>

static int irq_num = 10;
static char *device_name = "default_dev";

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

MODULE_PARM_DESC(irq_num, "Interrupt number for the device");
MODULE_PARM_DESC(device_name, "Device name string");

加载时传参:

sudo insmod my_driver.ko irq_num=33 device_name="my_storage_chip"

或者用modprobe

sudo modprobe my_driver irq_num=33 device_name="my_storage_chip"

这里有个细节要注意:module_param的第三个参数是文件权限,0644表示允许用户通过sysfs在运行时修改参数。我在项目中就遇到过这种情况——硬件调试时发现中断号不对,不用重新编译模块,直接:

echo 45 > /sys/module/my_driver/parameters/irq_num

嗯,这比重新编译快多了。

参数类型支持intlongcharp(字符串指针)、boolintarray(数组)等。数组参数用module_param_array宏。

4.5 一个完整的示例

把上面所有知识点串起来,写一个带参数的存储芯片驱动模块:

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

static int chip_addr = 0x50;
static int bus_speed = 400;
static char *chip_model = "AT24C256";

module_param(chip_addr, int, 0644);
module_param(bus_speed, int, 0644);
module_param(chip_model, charp, 0644);

MODULE_PARM_DESC(chip_addr, "I2C address of the storage chip");
MODULE_PARM_DESC(bus_speed, "I2C bus speed in kHz");
MODULE_PARM_DESC(chip_model, "Storage chip model name");

static int __init storage_chip_init(void)
{
    printk(KERN_INFO "Storage chip driver loading...\n");
    printk(KERN_INFO "Chip model: %s\n", chip_model);
    printk(KERN_INFO "I2C address: 0x%x\n", chip_addr);
    printk(KERN_INFO "Bus speed: %d kHz\n", bus_speed);
    
    // 这里做实际的硬件初始化
    // 比如 i2c_add_driver() 等
    
    return 0;
}

static void __exit storage_chip_exit(void)
{
    printk(KERN_INFO "Storage chip driver unloading...\n");
    // 清理资源
}

module_init(storage_chip_init);
module_exit(storage_chip_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Storage Driver Expert");
MODULE_DESCRIPTION("A configurable storage chip driver");

加载时传参:

sudo insmod storage_chip.ko chip_addr=0x52 bus_speed=100 chip_model="M24M02"

查看dmesg输出:

[ 1234.567890] Storage chip driver loading...
[ 1234.567895] Chip model: M24M02
[ 1234.567898] I2C address: 0x52
[ 1234.567901] Bus speed: 100 kHz

注意:模块参数虽然方便,但别滥用。我见过有人把整个驱动的配置项都做成参数,结果insmod命令行长得像天书。合理的做法是:把那些硬件相关的、需要灵活调整的参数暴露出来,其他的用#define或设备树配置。

好了,这一章的内容就到这里。内核模块是驱动开发的基础,说白了就是「加载-运行-卸载」这三个动作。把printk用好,把参数传递搞明白,后面写存储芯片驱动就会顺手很多。

下一章,咱们聊聊字符设备驱动框架——那才是真正开始写驱动的地方。