3、Linux内核模块编程:模块概念、模块加载/卸载、printk日志级别、模块参数与符号导出
Linux内核采用宏内核架构,但为了兼顾功能扩展性与运行时灵活性,引入了内核模块(Loadable Kernel Module, LKM)机制。模块机制允许开发者将设备驱动、文件系统、网络协议等内核功能编译为独立的.ko文件,在系统运行期间动态加载或卸载,而无需重新编译内核或重启系统。对于嵌入式Linux驱动开发而言,模块编程是入门的第一道门槛——它既是驱动代码的载体,也是调试驱动逻辑的主要手段。本章将围绕模块的构建、加载/卸载流程、日志输出、参数传递与符号共享五个核心维度展开,帮助读者建立驱动开发的基本工程框架。
上图展示了内核模块从源码到运行期交互的完整生命周期。模块编程的核心在于理解“内核空间”与“用户空间”的边界:模块运行在内核态,拥有最高特权级,任何内存访问错误都可能导致系统崩溃(Oops或panic),而非用户程序的段错误。因此,模块代码必须谨慎管理资源,并遵循内核编程规范。
3.1 模块概念与最小框架
3.1.1 核心原理:模块的本质
内核模块本质上是一种可动态链接的目标文件,它在编译时与内核源码树中的头文件关联,但在运行时才被内核装载器(如insmod)链接到内核地址空间。模块与内核共享同一个地址空间,因此可以直接调用内核导出的函数(如kmalloc、printk、copy_to_user等),也可以访问内核全局数据结构。模块的加载过程包括:分配内核内存、复制模块代码段和数据段、解析符号引用、调用module_init函数。卸载过程则相反,先调用module_exit,再释放内存。
一个最小的模块只需要两个函数:初始化函数(在加载时执行)和清理函数(在卸载时执行)。Linux内核通过module_init()和module_exit()宏来标记这两个入口点。此外,MODULE_LICENSE()声明模块许可证,若声明为"GPL",则可使用GPL-only的内核符号;若声明为"Proprietary",则无法使用这些符号,且内核会标记该模块为“污染”(tainted)。
3.1.2 最小模块代码与Makefile
下面是一个标准的“Hello World”内核模块,它展示了模块的基本骨架:
// hello.c
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
static int __init hello_init(void)
{
printk(KERN_INFO "Hello, kernel module!\n");
return 0; // 返回0表示加载成功
}
static void __exit hello_exit(void)
{
printk(KERN_INFO "Goodbye, kernel module!\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple hello module");
MODULE_VERSION("1.0");
对应的Makefile使用内核Kbuild系统编译:
# Makefile
obj-m := hello.o
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
$(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KDIR) M=$(PWD) clean
编译后生成hello.ko文件。注意__init和__exit宏的作用:__init会将初始化函数放入特殊的.init.text段,该段内存在模块加载成功后会被释放,以节省内存;__exit则标记清理函数,在模块编译进内核(而非模块)时会被忽略。
KDIR := /home/user/linux-imx,并需设置ARCH=arm和CROSS_COMPILE=arm-linux-gnueabihf-。
3.2 模块加载与卸载:insmod、rmmod与modprobe
3.2.1 操作流程与工具对比
模块加载/卸载涉及三个主要命令:insmod、rmmod和modprobe。它们的区别如下表所示:
| 命令 | 功能 | 依赖解析 | 常用场景 |
|---|---|---|---|
| insmod | 加载指定.ko文件 | 不解析依赖 | 调试单个模块 |
| rmmod | 卸载指定模块 | 不处理依赖 | 卸载单个模块 |
| modprobe | 加载/卸载模块及依赖 | 自动解析/lib/modules/$(uname -r)/modules.dep | 管理有依赖关系的模块 |
| modinfo | 查看模块信息 | — | 查看作者、描述、参数等 |
加载模块后,可以通过lsmod查看已加载模块列表,/sys/module/<module_name>/目录下会生成模块的运行时信息。卸载模块时,内核会检查模块的引用计数(refcnt),若计数不为0(表示有其他模块或进程正在使用),则拒绝卸载并返回“Module is in use”。
3.2.2 加载/卸载的完整操作示例
# 加载模块
sudo insmod hello.ko
# 查看内核日志
dmesg | tail -5
# 输出: [12345.678] Hello, kernel module!
# 查看模块信息
modinfo hello.ko
# 查看模块是否加载
lsmod | grep hello
# 卸载模块
sudo rmmod hello
dmesg | tail -3
# 输出: [12346.123] Goodbye, kernel module!
3.3 printk日志级别与调试技巧
3.3.1 日志级别机制
printk是内核空间的“printf”,但它与用户空间的printf有本质区别:printk的输出不依赖终端,而是写入内核环形缓冲区(ring buffer),可通过dmesg或/proc/kmsg读取。printk的日志级别决定了消息的紧急程度,内核根据console_loglevel变量决定哪些级别的消息会打印到控制台。
日志级别定义在<linux/kernel.h>中,常用级别如下:
| 宏定义 | 数值 | 含义 | 典型场景 |
|---|---|---|---|
| KERN_EMERG | 0 | 系统不可用 | 内核崩溃前 |
| KERN_ALERT | 1 | 必须立即处理 | 硬件故障 |
| KERN_CRIT | 2 | 严重错误 | 驱动无法初始化 |
| KERN_ERR | 3 | 错误 | I/O操作失败 |
| KERN_WARNING | 4 | 警告 | 参数不合法 |
| KERN_NOTICE | 5 | 正常但重要 | 设备注册成功 |
| KERN_INFO | 6 | 信息 | 驱动加载信息 |
| KERN_DEBUG | 7 | 调试信息 | 函数调用跟踪 |
使用方式为printk(KERN_ERR "error code %d\n", ret);。注意KERN_ERR与格式字符串之间没有逗号,这是printk的特殊语法。
3.3.2 控制台日志级别与动态调试
可以通过/proc/sys/kernel/printk文件查看和修改控制台日志级别,该文件包含4个数值:console_loglevel、default_message_loglevel、minimum_console_loglevel、default_console_loglevel。例如,要查看所有级别的日志输出到控制台:
# 查看当前级别
cat /proc/sys/kernel/printk
# 输出: 4 4 1 7
# 设置控制台级别为8(显示所有)
echo 8 > /proc/sys/kernel/printk
在驱动开发中,更推荐使用动态调试(dynamic debug)机制。在源码中使用pr_debug()、dev_dbg()等宏,编译时通过CONFIG_DYNAMIC_DEBUG启用,运行时通过/sys/kernel/debug/dynamic_debug/control文件动态控制某个文件的日志输出,无需重新编译模块。
printk级别设为KERN_DEBUG,并通过dmesg -n 8或echo 8 > /proc/sys/kernel/printk打开所有日志。正式发布时,将调试日志改为pr_debug(),并关闭动态调试,避免日志刷屏影响性能。
3.4 模块参数:向内核模块传递配置
3.4.1 参数声明与类型
模块参数允许用户在加载模块时通过命令行传递配置值,例如insmod hello.ko count=10 name="test"。内核提供module_param()宏来声明参数:
#include <linux/moduleparam.h>
static int count = 1;
static char *name = "default";
static int arr[3] = {0, 0, 0};
module_param(count, int, 0644);
module_param(name, charp, 0644);
module_param_array(arr, int, &arr_num, 0644);
MODULE_PARM_DESC(count, "Number of items");
MODULE_PARM_DESC(name, "Device name");
参数类型支持byte、short、ushort、int、uint、long、ulong、charp(字符指针)、bool、invbool。第三个参数是权限位,若为0,则参数在/sys/module/<module>/parameters/下只读;若设置为0644,则允许用户在运行时通过sysfs修改参数值。
3.4.2 参数的使用场景与注意事项
模块参数常用于:设备驱动的主设备号、I/O端口地址、中断号、缓冲区大小、调试开关等。例如,一个虚拟串口驱动可以这样设计:
static int major = 240;
static int minor_start = 0;
static int buffer_size = 4096;
module_param(major, int, 0444);
module_param(minor_start, int, 0444);
module_param(buffer_size, int, 0644);
加载时指定:insmod vser.ko major=250 buffer_size=8192。运行时修改:echo 16384 > /sys/module/vser/parameters/buffer_size。
module_init函数执行前就已经被内核解析并赋值,因此不能在init函数中重新声明或初始化参数。另外,charp类型的参数指向的字符串内存由内核管理,模块不应尝试修改或释放该指针。
3.5 符号导出:模块间的函数共享
3.5.1 导出符号的机制
内核模块之间可以相互调用函数,但前提是被调用的函数必须通过EXPORT_SYMBOL()或EXPORT_SYMBOL_GPL()宏显式导出。导出的符号会出现在内核符号表/proc/kallsyms中,其他模块在加载时通过符号表解析地址。
// 在模块A中导出函数
#include <linux/export.h>
int my_add(int a, int b)
{
return a + b;
}
EXPORT_SYMBOL(my_add); // 所有模块可用
// 或
EXPORT_SYMBOL_GPL(my_add); // 仅GPL模块可用
在模块B中声明并调用:
extern int my_add(int a, int b);
static int __init init_b(void)
{
int sum = my_add(3, 4);
printk(KERN_INFO "sum = %d\n", sum);
return 0;
}
加载顺序很重要:必须先加载模块A,再加载模块B。若使用modprobe,它会根据modules.dep文件自动处理依赖顺序。
3.5.2 符号导出的工程实践
在复杂的嵌入式系统中,通常将公共功能(如硬件抽象层、加密算法、通用工具函数)放在一个基础模块中导出,供多个驱动模块调用。例如,一个I2C控制器驱动可以导出i2c_read_reg()、i2c_write_reg()函数,供触摸屏、传感器等客户端驱动使用。
| 导出宏 | 适用场景 | 符号可见性 |
|---|---|---|
| EXPORT_SYMBOL() | 通用接口,允许所有模块使用 | /proc/kallsyms可见 |
| EXPORT_SYMBOL_GPL() | 仅允许GPL模块使用,保护知识产权 | /proc/kallsyms可见 |
| static + 不导出 | 模块内部函数,不对外暴露 | 不可见 |
mydrv_reset()比reset()更安全。同时,导出的函数应做好参数校验,因为调用方可能来自任何模块,无法保证传入的参数合法。
3.6 案例拆解:一个带参数和符号导出的LED控制模块
下面通过一个完整的案例,将本章知识点串联起来。假设嵌入式目标板上有两个LED,我们需要编写一个LED控制模块,支持参数配置LED的默认状态,并导出一个函数供其他模块(如按键驱动)调用。
3.6.1 需求与设计
- 功能:控制两个LED的亮灭,导出
led_set_state()函数。 - 参数:
led1_on(默认亮)、led2_on(默认灭)、gpio_base(GPIO基地址)。 - 约束:模块可重复加载,卸载时关闭所有LED。
3.6.2 代码实现
// led_ctrl.c
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/moduleparam.h>
#include <linux/export.h>
static int gpio_base = 0x40000000;
static bool led1_on = true;
static bool led2_on = false;
module_param(gpio_base, int, 0444);
module_param(led1_on, bool, 0644);
module_param(led2_on, bool, 0644);
MODULE_PARM_DESC(gpio_base, "GPIO controller base address");
MODULE_PARM_DESC(led1_on, "LED1 initial state (true=on)");
MODULE_PARM_DESC(led2_on, "LED2 initial state (true=on)");
/* 模拟GPIO寄存器操作 */
static void gpio_write(int offset, int val)
{
/* 实际项目中此处为 iowrite32(val, gpio_base + offset) */
printk(KERN_DEBUG "GPIO write offset=%d val=%d\n", offset, val);
}
/* 导出函数:设置LED状态 */
void led_set_state(int led_num, bool on)
{
if (led_num == 1)
gpio_write(0x00, on ? 1 : 0);
else if (led_num == 2)
gpio_write(0x04, on ? 1 : 0);
else
pr_warn("Invalid LED number %d\n", led_num);
}
EXPORT_SYMBOL_GPL(led_set_state);
static int __init led_init(void)
{
pr_info("LED module init: gpio_base=0x%X\n", gpio_base);
led_set_state(1, led1_on);
led_set_state(2, led2_on);
return 0;
}
static void __exit led_exit(void)
{
led_set_state(1, false);
led_set_state(2, false);
pr_info("LED module exit, all LEDs off\n");
}
module_init(led_init);
module_exit(led_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Embedded Course");
MODULE_DESCRIPTION("LED control module with params and export");
3.6.3 编译、加载与验证
# 编译
make
# 加载模块,指定参数
sudo insmod led_ctrl.ko gpio_base=0x48000000 led1_on=0
dmesg | tail -3
# 输出: LED module init: gpio_base=0x48000000
# GPIO write offset=0 val=0
# GPIO write offset=4 val=0
# 查看参数
cat /sys/module/led_ctrl/parameters/gpio_base
# 输出: 0x48000000
# 运行时修改参数
echo 1 > /sys/module/led_ctrl/parameters/led1_on
# 查看导出符号
grep led_set_state /proc/kallsyms
# 输出: ffffffc000123456 T led_set_state
# 卸载
sudo rmmod led_ctrl
dmesg | tail -2
# 输出: GPIO write offset=0 val=0
# LED module exit, all LEDs off
3.6.4 复盘与经验
该案例展示了模块参数、符号导出、printk日志级别的综合运用。实际项目中,gpio_write应替换为真实的寄存器读写操作(如iowrite32),并需要处理GPIO复用、时钟使能等硬件细节。此外,导出函数led_set_state应增加并发保护(如mutex),防止多模块同时调用导致竞态。