第2章:Linux内核模块基础

好,咱们正式开始写驱动了。不过别急,先得把内核模块这块地基打牢。我刚开始学驱动开发时,上来就想着怎么写复杂的硬件控制代码,结果连模块怎么加载都没搞明白,折腾了一下午。嗯,咱们一步步来。

2.1 内核模块的概念

Linux内核模块,说白了就是一段可以动态加载到内核中的代码。它不像应用程序那样跑在用户空间,而是直接运行在内核空间,拥有最高权限。

为什么要用模块?我举个例子。你写了一个POS机的打印机驱动,总不能每次改个bug就重新编译整个内核吧?那得等到猴年马月。模块机制允许你单独编译、单独加载,调试起来方便得多。

核心特点:

  • 动态加载:不用重启系统,insmod一下就能用
  • 独立编译:模块和内核源码可以分开编译
  • 节省内存:用的时候加载,不用就卸载
  • 版本兼容:但要注意,模块必须和内核版本匹配

你想想看,POS机这种嵌入式设备,内存本来就紧张。如果所有驱动都编译进内核,那得浪费多少资源?模块化设计,按需加载,这才是嵌入式开发的精髓。

2.2 编写第一个内核模块:Hello World

老规矩,学任何新东西都从Hello World开始。我当年写第一个内核模块时,打印了一行"Hello, POS World!",虽然简单,但看到内核日志里出现自己的输出,那感觉还是很爽的。

来看代码:

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

static int __init hello_init(void)
{
    printk(KERN_INFO "Hello, POS World!\n");
    return 0;
}

static void __exit hello_exit(void)
{
    printk(KERN_INFO "Goodbye, POS World!\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple POS driver module");

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

  • module_init和module_exit:这是模块的入口和出口,必须要有。加载时调用hello_init,卸载时调用hello_exit
  • printk:内核版的printf,输出到内核日志。KERN_INFO是日志级别,调试时常用
  • __init和__exit:这两个宏告诉内核,这些函数只在初始化/退出时使用,用完后可以释放内存
  • MODULE_LICENSE:必须声明许可证,不写的话内核会警告。我习惯用GPL

个人经验:printk的日志级别很重要。调试时用KERN_DEBUG,正式发布时用KERN_INFO或KERN_NOTICE。我曾经在量产版本里忘了改级别,结果控制台被调试信息刷屏,被测试同事追着骂了一周。

2.3 模块的加载与卸载

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

命令 作用 常用选项
insmod 加载模块 直接跟模块文件路径
rmmod 卸载模块 跟模块名(不含.ko)
modprobe 智能加载/卸载 自动处理依赖关系
lsmod 查看已加载模块 显示模块列表和依赖
modinfo 查看模块信息 显示作者、描述、参数等

实际操作一下:

# 编译模块
make

# 加载模块
sudo insmod hello.ko

# 查看内核日志
dmesg | tail

# 卸载模块
sudo rmmod hello

# 再看日志,应该能看到退出信息
dmesg | tail

注意:我曾经在调试POS机串口驱动时,加载模块后忘了卸载就直接拔了设备,结果内核panic了。记住,卸载模块前要确保没有进程在使用它。rmmod失败时,用lsmod看看引用计数,不为0就别硬来。

2.4 模块参数传递

写驱动时,你肯定不希望把硬件地址、中断号这些参数写死在代码里。模块参数机制就是干这个的。

看个例子:

#include <linux/moduleparam.h>

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

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

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

加载时传参:

sudo insmod mydriver.ko irq_num=17 device_name="printer"

参数类型支持int、long、charp(字符串指针)、bool等。权限0644表示可以通过sysfs在运行时查看和修改。

我建议:调试阶段把参数权限设成0644,方便动态调整。但产品发布时,最好改成0444(只读),防止用户误操作。我在一个项目中就吃过这个亏,用户通过sysfs改了中断号,设备直接罢工了。

2.5 模块依赖与符号导出

实际项目中,模块之间经常有依赖关系。比如你写了一个通用的I2C核心模块,然后打印机驱动、密码键盘驱动都依赖它。怎么处理?

内核提供了符号导出机制:

// 在核心模块中导出函数
int i2c_read_data(int addr, char *buf, int len)
{
    // 实现I2C读取
}
EXPORT_SYMBOL(i2c_read_data);

// 在打印机驱动中调用
extern int i2c_read_data(int addr, char *buf, int len);

依赖关系处理:

  • EXPORT_SYMBOL:导出符号给其他模块使用
  • EXPORT_SYMBOL_GPL:只允许GPL许可证的模块使用
  • modprobe:自动处理依赖,先加载被依赖的模块
  • depmod:生成模块依赖关系文件

避坑指南:我曾经写过一个POS机电源管理模块,导出了几个函数给其他驱动用。结果有个同事用了EXPORT_SYMBOL_GPL,但他的模块声明的是BSD许可证,加载时报错"Unknown symbol"。折腾了两天才发现是许可证不匹配。所以,团队协作时一定要统一许可证策略。

查看模块间的依赖关系:

# 查看模块依赖
lsmod

# 查看具体模块的依赖
modinfo mydriver.ko

# 手动加载依赖模块
sudo modprobe i2c_core
sudo insmod mydriver.ko

嗯,到这里内核模块的基础知识就差不多了。这些内容看着简单,但都是后面写复杂驱动的基础。我建议你亲手把Hello World模块编译加载一遍,感受一下内核模块的开发流程。下一章咱们就开始接触真正的硬件——GPIO操作,那才是驱动开发的重头戏。