4、内核模块开发入门:模块编写、编译、加载卸载、printk调试与模块参数传递

在Linux驱动开发中,内核模块(Kernel Module)是扩展内核功能的主要手段,尤其对于RK3588这类复杂的ARM SoC,外设驱动的开发几乎都以模块形式进行。模块机制允许驱动代码在系统运行期间动态加载到内核地址空间,无需重新编译整个内核或重启设备,这极大提升了开发与调试效率。本章将围绕模块的编写规范、交叉编译流程、加载与卸载操作、printk调试机制以及模块参数传递展开,帮助读者建立驱动开发的基本工程能力。

RK3588 内核模块开发链路 开发主机 (x86_64) 模块源码 hello.c / gpio_led.c Makefile + 交叉编译工具链 生成 .ko 目标文件 scp/ NFS RK3588 目标板 (aarch64) insmod hello.ko 内核空间执行 init/exit printk → dmesg 日志 Linux 内核 5.10 模块加载器 (load_module) sysfs / proc / dev 接口 内核日志缓冲区 (__log_buf) 系统调用 调试与参数传递链路 module_param(name, type, perm) insmod xxx.ko param=value dmesg / cat /proc/sys 调试输出 关键约束: • 内核版本与源码必须匹配(uname -r 与 Makefile 中 KDIR 指向一致) • 交叉编译工具链必须为 aarch64-linux-gnu- 系列,不可使用 x86 原生 gcc • 模块许可证声明(MODULE_LICENSE)缺失会导致加载失败或内核污染警告

4.1 内核模块的基本结构与编写规范

一个标准的内核模块至少包含两个核心函数:模块加载入口(module_init)和模块卸载出口(module_exit)。在RK3588平台上,模块代码运行在ARMv8-A架构的内核态,拥有完全的内核地址空间访问权限。模块的编写必须遵循内核编码风格,并且只能使用内核导出的API,不能链接用户态的标准C库。

以下是一个最小可用的模块源码示例,该模块在加载时打印一条信息,卸载时打印另一条信息:

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

static int __init hello_init(void)
{
    printk(KERN_INFO "[Hello] RK3588 module loaded.\n");
    return 0;  // 返回0表示加载成功
}

static void __exit hello_exit(void)
{
    printk(KERN_INFO "[Hello] RK3588 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这种内存资源相对丰富的平台,这一机制仍然有效,但更重要的是养成正确的代码段使用习惯。

核心原理:模块本质上是一个ELF格式的可重定位目标文件(.o),但通过内核的modpost工具处理后生成.ko文件。加载时,内核的load_module()函数会完成符号解析、重定位、内存分配和初始化调用。模块中的全局符号默认不导出,如需导出需使用EXPORT_SYMBOL宏。

4.2 交叉编译环境配置与Makefile编写

RK3588是ARM64架构,开发主机通常是x86_64,因此必须使用交叉编译工具链。在瑞芯微官方SDK中,通常已经预置了工具链路径,例如prebuilts/gcc/linux-x86/aarch64/gcc-arm-10.3-linux-gnu/bin。编译模块前,需要确保目标板的内核源码树已经配置并生成了Module.symvers文件。

一个典型的模块Makefile如下:

# Makefile
# 指定交叉编译工具链前缀
CROSS_COMPILE ?= aarch64-linux-gnu-
# 指定内核源码树路径(RK3588 SDK中的内核目录)
KDIR ?= /home/user/rk3588_sdk/kernel
# 当前目录
PWD := $(shell pwd)

obj-m := hello.o

all:
    $(MAKE) ARCH=arm64 CROSS_COMPILE=$(CROSS_COMPILE) -C $(KDIR) M=$(PWD) modules

clean:
    $(MAKE) ARCH=arm64 CROSS_COMPILE=$(CROSS_COMPILE) -C $(KDIR) M=$(PWD) clean

编译时执行make,会生成hello.ko文件。使用file hello.ko可以验证架构是否为ARM aarch64。如果误用了x86的gcc,生成的模块在加载时会报invalid module format错误。

编译要素要求错误示例
工具链aarch64-linux-gnu-gccgcc(x86原生)
内核源码与目标板运行内核版本一致版本不匹配导致 vermagic 错误
架构参数ARCH=arm64ARCH=x86_64
模块目标obj-m := xxx.oobj-y(编入内核)

4.3 模块的加载、卸载与状态管理

将编译好的.ko文件拷贝到RK3588目标板后,使用insmod命令加载模块,使用rmmod卸载模块。加载时,内核会执行模块的init函数;卸载时执行exit函数。常用命令如下:

# 加载模块
insmod hello.ko

# 查看已加载模块
lsmod | grep hello

# 查看模块详细信息
modinfo hello.ko

# 卸载模块
rmmod hello

# 查看内核日志
dmesg | tail -20

在开发调试阶段,modprobe命令也可以使用,但它会尝试解决模块依赖,对于简单模块两者效果相同。需要注意的是,如果模块的init函数返回非0值,加载会失败,且内核会调用cleanup函数进行清理。

风险提示:在RK3588平台上,如果模块访问了未映射的内存地址,会导致内核Oops甚至系统崩溃。建议在开发初期使用mem=内核参数限制内存范围,或使用QEMU模拟环境进行初步验证。另外,不要在生产环境中随意加载未经验证的模块。

4.4 printk调试机制与日志级别

printk是内核态最常用的调试工具,其用法类似用户态的printf,但增加了日志级别控制。日志级别决定了消息是否立即显示在控制台上,以及记录到内核缓冲区的方式。RK3588的调试串口默认输出所有级别低于console_loglevel的消息。

printk的日志级别定义在<linux/kernel.h>中,常用级别如下:

KERN_EMERG   "0"  // 紧急事件,系统崩溃
KERN_ALERT   "1"  // 必须立即处理
KERN_CRIT    "2"  // 严重错误
KERN_ERR     "3"  // 错误
KERN_WARNING "4"  // 警告
KERN_NOTICE  "5"  // 正常但重要
KERN_INFO    "6"  // 信息
KERN_DEBUG   "7"  // 调试信息

使用示例:

printk(KERN_ERR "GPIO %d config failed, ret=%d\n", gpio_num, ret);
printk(KERN_DEBUG "Register value: 0x%08x\n", reg_val);

在RK3588开发中,建议在驱动初始化、中断处理、IO操作等关键路径添加适当的printk。但要注意,在中断上下文或原子操作中,printk可能导致睡眠,应使用printk_ratelimited或pr_err等变体。

经验建议:在调试阶段,可以通过echo 8 > /proc/sys/kernel/printk将控制台日志级别设为8,使所有printk信息都输出到串口。但生产环境应恢复默认级别(通常为4),避免日志刷屏影响系统性能。

4.5 模块参数传递与sysfs接口

模块参数允许在加载时向模块传递配置值,这对于驱动开发非常实用。例如,可以指定GPIO编号、中断号、设备名称等。使用module_param宏声明参数,并指定权限位。

// param_demo.c
#include <linux/moduleparam.h>

static int gpio_num = 100;
static char *dev_name = "rk3588_led";
static int debug_level = 0;

module_param(gpio_num, int, 0644);
module_param(dev_name, charp, 0644);
module_param(debug_level, int, 0644);

MODULE_PARM_DESC(gpio_num, "GPIO number for LED control");
MODULE_PARM_DESC(dev_name, "Device name for misc device");
MODULE_PARM_DESC(debug_level, "Debug level (0-3)");

加载时传递参数:

insmod param_demo.ko gpio_num=88 dev_name="rk3588_led0" debug_level=2

如果权限位设为0644,模块加载后会在/sys/module/param_demo/parameters/目录下生成对应的参数文件,可以在运行时通过echo修改参数值(前提是模块内没有使用const修饰)。

参数类型module_param类型标识用户态对应
整型int, uint, long, ulongecho 100 > /sys/.../gpio_num
布尔bool, invboolecho 1 > /sys/.../enable
字符指针charpecho "name" > /sys/.../dev_name
数组array of intinsmod xxx.ko arr=1,2,3,4

4.6 实战案例:RK3588 GPIO控制模块开发

下面通过一个完整的GPIO控制模块案例,串联本章所有知识点。该模块实现一个简单的LED控制功能,使用模块参数指定GPIO编号,并通过printk输出调试信息。

场景背景:在RK3588 EVB板上,需要控制一个连接在GPIO4_B5(对应GPIO编号149)的LED灯。要求模块加载时初始化GPIO为输出并点亮LED,卸载时关闭LED并释放GPIO。

关键实现:

// gpio_led.c
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/gpio.h>
#include <linux/of_gpio.h>
#include <linux/delay.h>

static int gpio_led_num = 149;  // 默认GPIO4_B5
static bool active_high = true;

module_param(gpio_led_num, int, 0644);
module_param(active_high, bool, 0644);
MODULE_PARM_DESC(gpio_led_num, "GPIO number for LED");
MODULE_PARM_DESC(active_high, "Active high polarity");

static int __init gpio_led_init(void)
{
    int ret;
    
    if (!gpio_is_valid(gpio_led_num)) {
        pr_err("Invalid GPIO %d\n", gpio_led_num);
        return -EINVAL;
    }
    
    ret = gpio_request(gpio_led_num, "rk3588_led");
    if (ret) {
        pr_err("GPIO %d request failed: %d\n", gpio_led_num, ret);
        return ret;
    }
    
    ret = gpio_direction_output(gpio_led_num, active_high ? 1 : 0);
    if (ret) {
        pr_err("GPIO %d direction set failed\n", gpio_led_num);
        gpio_free(gpio_led_num);
        return ret;
    }
    
    gpio_set_value(gpio_led_num, active_high ? 1 : 0);
    pr_info("LED on GPIO %d initialized (active_high=%d)\n", 
            gpio_led_num, active_high);
    return 0;
}

static void __exit gpio_led_exit(void)
{
    gpio_set_value(gpio_led_num, active_high ? 0 : 1);
    gpio_free(gpio_led_num);
    pr_info("LED on GPIO %d released\n", gpio_led_num);
}

module_init(gpio_led_init);
module_exit(gpio_led_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("RK3588 Driver Course");
MODULE_DESCRIPTION("GPIO LED control module for RK3588");

编译与测试流程:

  1. 编写Makefile,指定KDIR为RK3588 SDK内核路径。
  2. 执行make生成gpio_led.ko。
  3. 拷贝到目标板,执行insmod gpio_led.ko gpio_led_num=149 active_high=1。
  4. 观察LED状态,使用dmesg查看日志确认初始化信息。
  5. 执行rmmod gpio_led,确认LED熄灭且日志正常。

结果与复盘:该模块在RK3588上测试通过,加载后LED点亮,卸载后熄灭。调试过程中发现,如果GPIO编号错误,gpio_request会返回-EINVAL,通过printk可以快速定位问题。此外,在设备树已占用该GPIO的情况下,gpio_request会失败,此时需要检查设备树配置。

常见误区:1. 忘记检查gpio_is_valid导致非法GPIO访问;2. 在模块卸载时未释放GPIO,导致后续模块无法申请;3. 使用printk时未指定日志级别,默认KERN_WARNING可能被控制台过滤;4. 模块参数权限设为0,导致无法在运行时通过sysfs调整。

4.7 总结与工程建议

内核模块开发是RK3588驱动开发的基础技能。本章涵盖了模块结构、交叉编译、加载卸载、printk调试和参数传递五大核心内容。在实际项目中,建议遵循以下工程规范:

  • 所有模块必须声明MODULE_LICENSE,推荐使用"GPL"以避免内核符号导出限制。
  • 在模块加载和卸载路径中,添加详细的printk日志,便于现场问题定位。
  • 模块参数尽量提供默认值,并设置合理的权限位(如0644),方便运行时调整。
  • 使用modinfo验证模块元数据,确保版本和描述信息完整。
  • 在RK3588平台上,优先使用设备树描述硬件资源,模块中通过of_系列API获取资源,而非硬编码。