3. Linux内核模块基础

Linux 内核模块(Loadable Kernel Module,LKM)是 Platform 驱动开发的最小可插拔单元。它允许我们在不重新编译整个内核、不重启系统的前提下,把设备驱动、文件系统、协议栈等能力动态注入内核空间。理解模块的加载与卸载机制、入口出口函数、参数传递以及符号依赖关系,是后续编写 Platform 驱动、设备树匹配和 probe 流程的必备基础。

一、核心原理:模块如何进入与离开内核

内核模块本质上是一个经过特殊编译的 .ko 目标文件,它使用内核提供的符号表,并在加载时被链接进内核地址空间。用户空间通过 insmod、modprobe 或 rmmod 触发系统调用,内核侧的模块加载器完成校验、重定位、符号解析,最后调用模块的初始化函数。

模块的生命周期可以概括为四个阶段:编译生成(生成 .ko)、加载(insmod/modprobe)、运行(内核调用入口函数并驻留)、卸载(rmmod 调用出口函数并释放)。其中加载与卸载是本节最核心的两条路径。

Linux 内核模块加载与卸载流程 用户空间 insmod / modprobe 用户空间 rmmod 内核空间 模块加载器 校验 / 重定位 符号解析 resolve_symbol module_init 入口函数 module_exit 出口函数 引用计数检查 refcount == 0 释放资源 unload_module 模块参数 module_param insmod xxx.ko param=value EXPORT_SYMBOL 符号导出

二、module_init 与 module_exit:模块的入口与出口

每个内核模块都必须至少定义入口函数,通常用 module_init() 宏注册;如果模块可卸载,还需要用 module_exit() 注册出口函数。这两个宏在 <linux/module.h> 中定义,它们把函数指针登记到模块的 struct module 结构中,供内核加载器在合适时机回调。

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

static int __init mydrv_init(void)
{
    pr_info("mydrv: init\n");
    return 0;   /* 返回 0 表示加载成功 */
}

static void __exit mydrv_exit(void)
{
    pr_info("mydrv: exit\n");
}

module_init(mydrv_init);
module_exit(mydrv_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("platform-course");
MODULE_DESCRIPTION("A simple kernel module demo");

入口函数返回 0 表示加载成功,返回负值(如 -ENODEV、-EINVAL)则加载失败并自动回滚。出口函数没有返回值,必须保证释放所有在入口中申请的资源,否则会造成内存泄漏或设备状态残留。

入口函数应尽量“轻”,只做注册、申请资源、初始化硬件等必要动作;耗时操作可放到工作队列或线程中,避免阻塞模块加载过程。

三、模块参数:让模块在加载时可变

模块参数允许在 insmod 或 modprobe 时向模块传递配置值,常用于指定 GPIO 编号、设备索引、调试级别等。使用 module_param() 或 module_param_named() 声明,并通过 MODULE_PARM_DESC() 添加说明。

static int gpio_num = 18;
static char *dev_name = "platdev";
static bool debug_en = false;

module_param(gpio_num, int, 0644);
MODULE_PARM_DESC(gpio_num, "GPIO number used by driver");

module_param(dev_name, charp, 0644);
MODULE_PARM_DESC(dev_name, "device name string");

module_param(debug_en, bool, 0644);
MODULE_PARM_DESC(debug_en, "enable debug log");

/* 加载示例:
 * insmod mydrv.ko gpio_num=23 dev_name=led0 debug_en=1
 */

参数类型支持 int、uint、long、charp、bool、数组等。权限位 0644 表示参数会在 /sys/module/模块名/parameters/ 下生成可读写节点,方便运行时查看和调整。

四、符号导出与模块依赖

模块之间可以互相调用函数,前提是被调用方通过 EXPORT_SYMBOL() 或 EXPORT_SYMBOL_GPL() 导出符号。导出后,该符号进入内核全局符号表,其他模块在加载时由内核完成符号解析和地址重定位。

机制 作用 典型使用场景
EXPORT_SYMBOL 导出符号给所有模块使用 通用驱动框架、公共工具函数
EXPORT_SYMBOL_GPL 仅导出给 GPL 兼容模块 内核内部核心接口
MODULE_LICENSE("GPL") 声明许可证,影响 GPL 符号可用性 所有正式驱动模块
depmod / modprobe 生成依赖关系并自动加载依赖模块 多模块协同的驱动栈

模块依赖关系记录在 /lib/modules/$(uname -r)/modules.dep 中,由 depmod 生成。使用 modprobe 加载模块时,内核会先加载其依赖模块;而 insmod 不会自动处理依赖,因此更适合调试单个模块。

# 查看模块信息与依赖
modinfo mydrv.ko
lsmod | grep mydrv
cat /proc/modules | grep mydrv

# 查看模块参数
ls /sys/module/mydrv/parameters/
cat /sys/module/mydrv/parameters/gpio_num

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

五、案例场景:一个可加载的 Platform 驱动骨架

下面是一个典型的 Platform 驱动模块骨架,它把入口、出口、参数和许可证组合在一起,后续章节将在此基础上加入 platform_driver 注册与设备树匹配。

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

static int irq_num = -1;
module_param(irq_num, int, 0444);
MODULE_PARM_DESC(irq_num, "IRQ number for the platform device");

static int __init platdrv_init(void)
{
    pr_info("platdrv: loaded, irq=%d\n", irq_num);
    return 0;
}

static void __exit platdrv_exit(void)
{
    pr_info("platdrv: unloaded\n");
}

module_init(platdrv_init);
module_exit(platdrv_exit);
MODULE_LICENSE("GPL");

编译使用内核构建系统,典型 Makefile 如下:

obj-m += platdrv.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

六、常见误区与复盘

  • 忘记 MODULE_LICENSE:未声明 GPL 时,使用 EXPORT_SYMBOL_GPL 导出的符号会解析失败,模块加载报 Unknown symbol。
  • 出口函数不释放资源:入口申请的内存、中断、设备节点未释放,反复加载卸载会导致资源泄漏。
  • 入口返回正值:入口函数应返回 0 或负错误码,返回正值会被内核视为异常。
  • 参数权限过大:0644 允许运行时修改,若参数影响硬件行为,应使用 0444 只读。
  • 依赖顺序错误:被依赖模块未先加载时,insmod 会报 Unknown symbol,应使用 modprobe 或手动按序加载。
卸载模块前务必确认引用计数为 0。若模块正被其他模块或用户态程序使用,rmmod 会返回 Module is in use,强行卸载可能导致内核崩溃。