3、Linux内核模块编程:模块概念、模块加载/卸载、printk日志级别、模块参数与符号导出

Linux内核采用宏内核架构,但为了兼顾功能扩展性与运行时灵活性,引入了内核模块(Loadable Kernel Module, LKM)机制。模块机制允许开发者将设备驱动、文件系统、网络协议等内核功能编译为独立的.ko文件,在系统运行期间动态加载或卸载,而无需重新编译内核或重启系统。对于嵌入式Linux驱动开发而言,模块编程是入门的第一道门槛——它既是驱动代码的载体,也是调试驱动逻辑的主要手段。本章将围绕模块的构建、加载/卸载流程、日志输出、参数传递与符号共享五个核心维度展开,帮助读者建立驱动开发的基本工程框架。

Linux内核模块编程核心机制 模块源码 (.c) module_init() module_exit() MODULE_LICENSE() Makefile + Kbuild obj-m += hello.o make -C /lib/modules/ $(uname -r) build hello.ko ELF格式 包含符号表 .modinfo段 insmod / modprobe 调用 module_init() 注册设备/驱动 返回0表示成功 rmmod 调用 module_exit() 释放资源/注销设备 引用计数为0才可卸载 运行期交互 printk → dmesg /var/log/kern.log module_param → /sys/module/参数 EXPORT_SYMBOL → 其他模块调用 依赖关系与版本校验 vermagic: 内核版本 + SMP + PREEMPT + 编译器版本 必须匹配 modprobe 自动解析依赖,insmod 不解析

上图展示了内核模块从源码到运行期交互的完整生命周期。模块编程的核心在于理解“内核空间”与“用户空间”的边界:模块运行在内核态,拥有最高特权级,任何内存访问错误都可能导致系统崩溃(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则标记清理函数,在模块编译进内核(而非模块)时会被忽略。

经验提醒:在嵌入式开发中,交叉编译时Makefile的KDIR应指向目标板的内核源码树,而非主机内核。例如: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!
风险提示:在嵌入式目标板上,若模块初始化函数中注册了设备或申请了中断,但卸载函数未正确释放,会导致资源泄漏。更严重的是,如果模块代码中有内存越界访问,可能在卸载时触发内核Oops,导致系统挂起。务必在卸载函数中逆序释放所有资源。

3.3 printk日志级别与调试技巧

3.3.1 日志级别机制

printk是内核空间的“printf”,但它与用户空间的printf有本质区别:printk的输出不依赖终端,而是写入内核环形缓冲区(ring buffer),可通过dmesg或/proc/kmsg读取。printk的日志级别决定了消息的紧急程度,内核根据console_loglevel变量决定哪些级别的消息会打印到控制台。

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

宏定义数值含义典型场景
KERN_EMERG0系统不可用内核崩溃前
KERN_ALERT1必须立即处理硬件故障
KERN_CRIT2严重错误驱动无法初始化
KERN_ERR3错误I/O操作失败
KERN_WARNING4警告参数不合法
KERN_NOTICE5正常但重要设备注册成功
KERN_INFO6信息驱动加载信息
KERN_DEBUG7调试信息函数调用跟踪

使用方式为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),防止多模块同时调用导致竞态。

总结复盘:模块编程是Linux驱动开发的基石。掌握模块的加载/卸载机制、日志调试方法、参数传递和符号共享,是后续学习字符设备驱动、设备树的基础。建议读者动手编写一个简单的模块,并尝试在QEMU或真实开发板上完成加载、参数修改、符号调用的完整流程。