2. Linux内核模块编程基础:模块的加载与卸载、模块参数传递、模块依赖与符号导出、内核模块的Makefile编写

Linux内核模块(Loadable Kernel Module, LKM)是嵌入式Linux驱动开发的核心机制。它允许在运行时动态地向内核添加或移除功能,而无需重新编译整个内核或重启系统。本章将深入讲解模块编程的四大基石:模块的加载与卸载流程、模块参数传递方法、模块间的依赖与符号导出规则,以及标准化的Makefile编写规范。

学习提示: 模块编程是驱动开发的“Hello World”。掌握本章内容后,你将能够独立编写、编译、加载和调试一个完整的内核模块,为后续字符设备、平台设备驱动打下坚实基础。

2.1 模块的加载与卸载

每个内核模块都必须实现两个核心入口函数:module_init() 和 module_exit()。前者在模块加载时被内核调用,后者在模块卸载时被调用。这两个函数通过特定的宏注册到内核模块框架中。

2.1.1 模块生命周期与核心API

阶段 用户态命令 内核态回调 典型操作
加载 insmod mymodule.ko module_init(init_func) 注册设备、分配内存、初始化硬件
运行 — 模块代码常驻内核 处理系统调用、中断、文件操作
卸载 rmmod mymodule module_exit(exit_func) 注销设备、释放内存、关闭硬件
查看 lsmod — 列出已加载模块及依赖关系
关键区别: insmod 用于加载指定路径的 .ko 文件,不自动解析依赖;modprobe 则会自动加载依赖模块,推荐在生产环境中使用。

2.1.2 最小模块代码示例

下面是一个完整的最小内核模块,包含加载和卸载函数,以及模块信息声明。

#include <linux/module.h>    // 所有模块都需要
#include <linux/kernel.h>    // printk 和 KERN_INFO
#include <linux/init.h>      // __init 和 __exit 宏

static int __init hello_init(void)
{
    printk(KERN_INFO "Hello, Kernel Module loaded!\n");
    return 0;   // 返回0表示加载成功
}

static void __exit hello_exit(void)
{
    printk(KERN_INFO "Goodbye, Kernel Module unloaded!\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Embedded Driver Expert");
MODULE_DESCRIPTION("A simple Hello World kernel module");
MODULE_VERSION("1.0");

2.1.3 模块加载与卸载流程图

下图展示了从用户态命令到内核态回调的完整流程,以及模块在内核中的状态转换。

模块加载与卸载生命周期流程图 insmod / modprobe rmmod 内核模块加载器 内核模块卸载器 module_init() hello_init() module_exit() hello_exit() 模块已加载 (Live) 模块已卸载 (Dead) 说明: 加载时内核调用 module_init 注册的回调;卸载时调用 module_exit 注册的回调。返回非0值表示加载失败。
常见错误: 如果 module_init 返回负数(如 -ENODEV),模块加载会被内核拒绝,且不会调用 module_exit。务必在 init 函数中做好错误处理,确保资源在失败时全部回滚。

2.2 模块参数传递

内核模块支持在加载时通过命令行传递参数,这使得模块的行为可以在不重新编译的情况下灵活调整。参数通过 module_param() 和 module_param_array() 宏声明。

2.2.1 参数声明与类型

宏 数据类型 用户态传递示例 说明
module_param(name, type, perm) byte, short, ushort, int, uint, long, ulong, charp, bool, invbool insmod mymod.ko count=10 name="test" 声明单个参数
module_param_array(name, type, nump, perm) 数组类型 insmod mymod.ko arr=1,2,3,4 声明数组参数,nump 指向数组长度变量
MODULE_PARM_DESC(name, desc) — — 为参数添加描述文本,可通过 modinfo 查看

2.2.2 参数权限位 (perm)

perm 参数控制参数在 /sys/module/<name>/parameters/ 下的文件权限:

  • 0:参数完全对用户空间不可见,仅内核内部使用。
  • S_IRUGO (0444):所有用户可读,不可写。
  • S_IWUSR | S_IRUGO (0644):所有者可读写,其他用户只读。
  • S_IRUGO | S_IWUSR | S_IWGRP (0664):所有者和组可读写,其他只读。

2.2.3 带参数的模块示例

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

static int count = 1;
static char *name = "default_device";
static int arr[4];
static int arr_len;

module_param(count, int, S_IRUGO);
module_param(name, charp, S_IRUGO);
module_param_array(arr, int, &arr_len, S_IRUGO);

MODULE_PARM_DESC(count, "Number of devices to initialize (default: 1)");
MODULE_PARM_DESC(name, "Device name string");
MODULE_PARM_DESC(arr, "Array of integer parameters");

static int __init param_init(void)
{
    int i;
    printk(KERN_INFO "Module loaded with count=%d, name=%s\n", count, name);
    for (i = 0; i < arr_len; i++) {
        printk(KERN_INFO "arr[%d] = %d\n", i, arr[i]);
    }
    return 0;
}

static void __exit param_exit(void)
{
    printk(KERN_INFO "Module unloaded\n");
}

module_init(param_init);
module_exit(param_exit);

MODULE_LICENSE("GPL");

加载命令示例:

sudo insmod param_module.ko count=5 name="mydev" arr=10,20,30

2.3 模块依赖与符号导出

内核模块之间可以存在依赖关系:一个模块可以导出符号(函数或变量),供其他模块使用。这种机制实现了模块间的代码复用和解耦。

2.3.1 符号导出宏

宏 作用 使用场景
EXPORT_SYMBOL(symbol) 导出符号,所有模块均可使用 通用驱动库、核心功能接口
EXPORT_SYMBOL_GPL(symbol) 仅导出给 GPL 许可的模块 保护专有代码,强制 GPL 兼容

2.3.2 模块依赖关系图

下图展示了一个典型的模块依赖场景:core_driver 导出底层硬件操作函数,sub_driver_a 和 sub_driver_b 分别依赖它实现不同功能。

内核模块依赖与符号导出关系图 core_driver.ko EXPORT_SYMBOL(hw_init) EXPORT_SYMBOL(hw_write) sub_driver_a.ko 调用 hw_init() sub_driver_b.ko 调用 hw_write() 依赖 依赖 加载顺序要求: 必须先加载 core_driver.ko,再加载 sub_driver_a.ko 和 sub_driver_b.ko。 卸载顺序相反:先卸载依赖模块,再卸载核心模块。

2.3.3 符号导出示例

core_driver.c — 导出符号:

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

void hw_init(void)
{
    printk(KERN_INFO "Hardware initialized\n");
}
EXPORT_SYMBOL(hw_init);

void hw_write(int data)
{
    printk(KERN_INFO "Writing data: %d\n", data);
}
EXPORT_SYMBOL_GPL(hw_write);

static int __init core_init(void) { return 0; }
static void __exit core_exit(void) { }
module_init(core_init);
module_exit(core_exit);
MODULE_LICENSE("GPL");

sub_driver_a.c — 使用导出的符号:

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

extern void hw_init(void);   // 声明外部符号

static int __init sub_init(void)
{
    hw_init();   // 调用 core_driver 导出的函数
    printk(KERN_INFO "Sub driver A initialized\n");
    return 0;
}

static void __exit sub_exit(void) { }
module_init(sub_init);
module_exit(sub_exit);
MODULE_LICENSE("GPL");
调试技巧: 使用 cat /proc/kallsyms | grep hw_init 可以查看内核中所有已导出的符号及其地址。如果符号未导出,该命令不会显示。

2.4 内核模块的Makefile编写

编写正确的 Makefile 是内核模块开发的基本功。Linux 内核提供了一套 Kbuild 系统,开发者只需遵循特定格式即可自动完成编译。

2.4.1 标准Makefile模板

# 指定内核源码树路径(根据实际环境修改)
KERNEL_DIR ?= /lib/modules/$(shell uname -r)/build

# 模块名称(最终生成 .ko 文件)
obj-m := mymodule.o

# 如果模块由多个源文件组成,使用如下格式:
# mymodule-objs := file1.o file2.o

# 默认编译目标
all:
    $(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules

# 清理编译产物
clean:
    $(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean

# 安装模块到系统目录
install:
    $(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules_install

2.4.2 Makefile关键变量解析

变量/语法 含义 示例
obj-m 指定要编译为模块的目标文件 obj-m := mydriver.o
obj-y 指定要编译进内核(非模块) obj-y := built-in.o
<module>-objs 多源文件模块的目标文件列表 mymod-objs := a.o b.o
ccflags-y 传递给编译器的额外标志 ccflags-y := -DDEBUG -Wall
KERNEL_DIR 内核源码树路径,包含已配置的 .config /lib/modules/5.10.0/build
M=$(PWD) 告诉 Kbuild 当前模块源码所在目录 必须与 -C 配合使用

2.4.3 多文件模块的Makefile示例

假设模块 complex_driver 由 main.c、ioctl.c 和 utils.c 组成:

KERNEL_DIR ?= /lib/modules/$(shell uname -r)/build
obj-m := complex_driver.o

# 指定组成模块的目标文件
complex_driver-objs := main.o ioctl.o utils.o

# 添加调试信息
ccflags-y := -DDEBUG -g -O0

all:
    $(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules

clean:
    $(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean

2.4.4 交叉编译Makefile(嵌入式场景)

在嵌入式开发中,需要指定交叉编译工具链和架构:

# 交叉编译工具链前缀
CROSS_COMPILE ?= arm-linux-gnueabihf-
# 目标架构
ARCH ?= arm
# 嵌入式板卡的内核源码树路径
KERNEL_DIR ?= /home/user/linux-5.10

obj-m := my_embedded_driver.o

all:
    $(MAKE) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) \
           -C $(KERNEL_DIR) M=$(PWD) modules

clean:
    $(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean
交叉编译注意事项:
  • 确保 KERNEL_DIR 指向已为嵌入式目标板配置并编译过的内核源码树。
  • CROSS_COMPILE 前缀必须与工具链名称完全匹配,例如 arm-linux-gnueabihf-gcc 对应前缀 arm-linux-gnueabihf-。
  • 编译生成的 .ko 文件只能在相同架构的内核上加载,不可在 x86 主机上使用。

2.5 本章小结

本章系统性地介绍了 Linux 内核模块编程的四个核心基础:

  • 加载与卸载:通过 module_init 和 module_exit 管理模块生命周期,使用 insmod/rmmod 操作。
  • 参数传递:利用 module_param 系列宏实现运行时配置,提高模块灵活性。
  • 符号导出与依赖:通过 EXPORT_SYMBOL 实现模块间代码复用,注意加载顺序。
  • Makefile 编写:掌握 Kbuild 系统语法,支持单文件、多文件及交叉编译场景。

这些基础知识将贯穿整个驱动开发课程,是后续学习字符设备驱动、中断处理、并发控制等内容的前提。建议读者在真实的内核环境中动手实践每个示例,加深理解。