4、Linux内核模块开发:模块编写、编译、加载与卸载,模块参数与依赖,printk调试方法
在嵌入式ARM Linux开发中,内核模块(Kernel Module)是扩展硬件驱动能力的主要手段。RK3588作为瑞芯微旗舰级SoC,集成了四核Cortex-A76与四核Cortex-A55,运行标准Linux内核(5.10或6.1分支)。驱动开发者通常以模块方式开发外设驱动,以便在开发阶段快速迭代,避免每次修改都重新编译整个内核并烧写镜像。本章围绕模块的编写、编译、加载/卸载、参数传递、依赖关系以及printk调试展开,帮助学员建立完整的模块开发闭环。
上图展示了RK3588内核模块从编写到调试的完整工程链路。模块开发并非简单的“写代码-编译-加载”,而是涉及交叉编译环境配置、内核符号表解析、依赖关系管理以及运行时调试的闭环过程。下面逐一深入讲解。
4.1 内核模块的基本结构:编写规范与生命周期
一个标准的内核模块至少包含两个核心函数:模块加载入口(module_init)和模块卸载出口(module_exit)。在RK3588平台上,模块代码运行在ARM64内核态,使用GNU C语法,不能链接用户态库(如glibc),只能使用内核导出的API。
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
static int __init hello_init(void)
{
printk(KERN_INFO "Hello, RK3588 Module Loaded!\n");
return 0;
}
static void __exit hello_exit(void)
{
printk(KERN_INFO "Module Unloaded.\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("RK3588 Driver Course");
MODULE_DESCRIPTION("A simple hello module for RK3588");
MODULE_VERSION("1.0");
关键点说明:__init和__exit宏会将函数放入特定的内存段,加载完成后该段内存可被释放以节省空间。在RK3588的64位内核中,printk默认输出到内核环形缓冲区,可通过dmesg或串口控制台查看。模块必须使用MODULE_LICENSE声明许可证,否则加载时会出现“kernel tainted”警告。
rockchip_get_soc_id()获取),便于确认模块运行在正确的硬件平台上。
4.2 交叉编译环境与Makefile编写
RK3588使用ARM64架构,必须在x86主机上通过交叉编译工具链生成目标模块。SDK通常提供预编译工具链(如aarch64-linux-gnu-gcc)和内核源码树。模块编译依赖内核源码树中生成的Module.symvers文件(记录导出的符号)和include头文件。
| 编译要素 | 配置项 | 说明 |
|---|---|---|
| 架构 | ARCH=arm64 | 指定目标CPU架构 |
| 交叉编译器 | CROSS_COMPILE=aarch64-linux-gnu- | 工具链前缀 |
| 内核源码目录 | KDIR := /path/to/kernel | 必须已配置并编译过 |
| 输出目录 | O=out | 避免污染源码树 |
# Makefile 示例
obj-m := hello.o
KDIR := /home/user/rk3588/kernel
PWD := $(shell pwd)
all:
$(MAKE) ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- -C $(KDIR) M=$(PWD) modules
clean:
$(MAKE) ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- -C $(KDIR) M=$(PWD) clean
编译完成后生成hello.ko文件。使用file hello.ko可验证ELF格式为64位ARM。将.ko文件通过NFS、adb push或TFTP拷贝到RK3588目标板后,即可进行加载测试。
+后缀(如5.10.110-rockchip+),必须使用SDK配套的内核源码编译模块,否则无法加载。
4.3 模块加载、卸载与依赖管理
模块加载有两种方式:insmod和modprobe。insmod直接加载指定路径的.ko文件,不自动处理依赖;modprobe会搜索标准模块目录(/lib/modules/$(uname -r)/)并自动加载依赖模块。在RK3588开发阶段,推荐使用insmod配合modprobe手动控制。
# 加载模块
insmod hello.ko
# 查看模块信息
lsmod | grep hello
# 查看模块详细信息
modinfo hello.ko
# 卸载模块
rmmod hello
模块依赖通过内核符号表(EXPORT_SYMBOL)建立。例如,一个I2C驱动模块依赖I2C核心模块导出的i2c_add_adapter函数。当模块B引用了模块A导出的符号时,加载B之前必须先加载A。使用depmod工具可生成模块依赖关系文件modules.dep,modprobe依赖该文件自动处理加载顺序。
gpio_key.ko,它依赖gpio_subsys.ko(GPIO子系统封装模块)。在目标板上执行:
insmod gpio_subsys.ko
insmod gpio_key.ko
# 若gpio_key.ko中引用了gpio_subsys导出的gpio_request_pin(),则必须先加载gpio_subsys
4.4 模块参数:向内核模块传递配置
模块参数允许在加载时动态指定配置值,避免硬编码。RK3588驱动中常用于指定GPIO编号、中断号、轮询间隔等。使用module_param宏声明参数,支持int、charp(字符串指针)、bool、array等类型。
static int gpio_num = 100;
static char *desc = "default";
module_param(gpio_num, int, 0644);
module_param(desc, charp, 0644);
MODULE_PARM_DESC(gpio_num, "GPIO number for LED");
MODULE_PARM_DESC(desc, "Device description");
static int __init param_init(void)
{
printk(KERN_INFO "GPIO=%d, desc=%s\n", gpio_num, desc);
return 0;
}
加载时传递参数:
insmod gpio_led.ko gpio_num=88 desc="rk3588-led"
参数权限位0644表示在/sys/module/gpio_led/parameters/目录下创建对应的sysfs节点,可在运行时通过echo修改参数值(需模块内使用int类型且无锁保护)。对于数组参数,使用module_param_array。
4.5 printk调试方法:从入门到高效
printk是内核态最直接的调试手段。它根据日志级别决定输出位置,级别从高到低为:KERN_EMERG(0)、KERN_ALERT(1)、KERN_CRIT(2)、KERN_ERR(3)、KERN_WARNING(4)、KERN_NOTICE(5)、KERN_INFO(6)、KERN_DEBUG(7)。RK3588的串口控制台通常只显示级别≤4的日志,而dmesg可查看全部。
| 调试场景 | 推荐级别 | 示例 |
|---|---|---|
| 驱动加载/卸载 | KERN_INFO | printk(KERN_INFO "driver init ok\n") |
| 硬件访问错误 | KERN_ERR | printk(KERN_ERR "i2c read failed: %d\n", ret) |
| 中断处理高频日志 | KERN_DEBUG | printk(KERN_DEBUG "irq %d triggered\n", irq) |
| 关键数据变化 | KERN_NOTICE | printk(KERN_NOTICE "voltage changed to %d mV\n", mv) |
调试技巧:在RK3588上,可使用echo 8 > /proc/sys/kernel/printk临时提高控制台日志级别,使所有printk信息直接输出到串口。对于高频中断中的打印,建议使用trace_printk或dev_dbg(需开启CONFIG_DYNAMIC_DEBUG),避免影响实时性。
printk可能导致死锁或系统崩溃。RK3588的CPU频率较高,但串口输出速度有限,大量打印会严重拖慢系统。应使用printk_ratelimited()限制打印频率,或使用dev_info等带设备信息的宏。
4.6 案例拆解:RK3588 GPIO LED驱动模块开发
下面通过一个完整的GPIO LED驱动模块,串联本章所有知识点。该模块控制RK3588的GPIO4_B5引脚(对应物理引脚编号可通过gpiochip信息计算),实现LED闪烁。
背景与约束:目标板为RK3588 EVB,LED接在GPIO4_B5(bank4,group B,pin5),高电平点亮。内核版本5.10.110,使用SDK自带交叉编译工具链。
实现步骤:
- 编写驱动代码:使用
gpio_request申请GPIO,gpio_direction_output设置方向,通过定时器实现闪烁。 - 添加模块参数:允许用户指定闪烁频率(
freq)和GPIO编号(gpio_num)。 - 编译:编写Makefile,指定
obj-m := gpio_led.o,执行make生成.ko文件。 - 加载测试:
insmod gpio_led.ko gpio_num=147 freq=2(147为GPIO4_B5的全局编号),观察LED以2Hz频率闪烁。 - 调试验证:使用
dmesg查看打印信息,确认GPIO申请成功、定时器初始化正常。
# 关键代码片段
static int gpio_num = 147;
static int freq = 1;
module_param(gpio_num, int, 0644);
module_param(freq, int, 0644);
static struct timer_list led_timer;
static int led_state;
static void led_timer_cb(struct timer_list *t)
{
led_state = !led_state;
gpio_set_value(gpio_num, led_state);
mod_timer(&led_timer, jiffies + msecs_to_jiffies(500 / freq));
}
static int __init gpio_led_init(void)
{
int ret;
ret = gpio_request(gpio_num, "led");
if (ret) {
printk(KERN_ERR "gpio_request failed: %d\n", ret);
return ret;
}
gpio_direction_output(gpio_num, 0);
timer_setup(&led_timer, led_timer_cb, 0);
mod_timer(&led_timer, jiffies + msecs_to_jiffies(500 / freq));
printk(KERN_INFO "LED module loaded, gpio=%d, freq=%dHz\n", gpio_num, freq);
return 0;
}
结果与复盘:加载后LED正常闪烁,dmesg输出显示“LED module loaded, gpio=147, freq=2Hz”。通过cat /sys/module/gpio_led/parameters/freq可查看当前频率,echo 5 > /sys/module/gpio_led/parameters/freq可动态修改频率(需注意定时器回调中读取freq时存在数据竞争,实际产品中应使用atomic_t或加锁)。该案例验证了模块参数、定时器、GPIO操作和printk调试的完整流程。
4.7 常见误区与总结复盘
- 版本魔法不匹配:编译模块的内核源码树与目标板运行内核不一致。解决:使用SDK统一版本,或通过
modinfo查看vermagic对比。 - 符号未导出:模块引用了未
EXPORT_SYMBOL的内核函数。解决:检查/proc/kallsyms确认符号是否导出,或修改内核源码重新编译。 - GPIO编号错误:RK3588的GPIO编号计算复杂,建议通过
gpiochip信息计算:base + bank*32 + group*8 + pin。使用cat /sys/kernel/debug/gpio可查看当前GPIO状态。 - printk滥用:在中断或原子上下文打印导致系统卡死。应使用
printk_ratelimited或dev_dbg。 - 模块卸载不彻底:
rmmod失败通常因为模块被占用(lsmod显示引用计数不为0)。检查是否有进程持有设备文件或定时器未删除。
dev_info/dev_err宏(需配合struct device)以增强日志可读性。下一阶段可深入学习字符设备驱动框架,将模块与用户空间交互。