内核模块基础:模块的加载与卸载、模块参数传递、模块依赖关系、Makefile编写规范

好,咱们进入第二章。内核模块,说白了就是Linux内核的"插件"。你想想看,如果每次加个驱动都要重新编译整个内核,那得多痛苦?我早年刚接触嵌入式时,就干过这种傻事——改一行代码,编译半小时。后来学会了模块化开发,整个人都轻松了。

2.1 模块的加载与卸载

内核模块有两种存在形式:一种是编译进内核(built-in),另一种就是咱们今天要讲的动态模块(.ko文件)。动态模块可以在系统运行时加载和卸载,非常灵活。

加载模块用 insmod 或 modprobe,卸载用 rmmod。区别在哪?insmod 是硬加载,你得指定完整路径;modprobe 会自动处理依赖关系。我个人习惯调试阶段用 insmod,正式部署用 modprobe。

核心API:

  • module_init() — 指定模块入口函数,加载时调用
  • module_exit() — 指定模块出口函数,卸载时调用
  • MODULE_LICENSE() — 声明许可证,不写的话内核会报"tainted"
  • MODULE_AUTHOR() / MODULE_DESCRIPTION() — 模块信息
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>

static int __init my_driver_init(void)
{
    printk(KERN_INFO "Hello, module loaded!\n");
    return 0;
}

static void __exit my_driver_exit(void)
{
    printk(KERN_INFO "Goodbye, module unloaded!\n");
}

module_init(my_driver_init);
module_exit(my_driver_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple example module");

这里有个细节:__init 和 __exit 宏。加了 __init 的函数,模块加载完后内核会把它占用的内存释放掉。嗯,省一点是一点,嵌入式设备内存金贵着呢。

我曾经踩过的坑: 模块初始化函数返回非0值,但没做资源清理。结果模块加载失败,但之前申请的内存、注册的设备全成了孤儿。后来我养成了习惯——每个 goto 错误标签都配上对应的释放操作。

2.2 模块参数传递

模块写死了参数值,每次改都要重新编译?太傻了。内核提供了 module_param() 宏,允许在加载时传参。

#include <linux/moduleparam.h>

static int debug_level = 0;
static char *device_name = "default_dev";

module_param(debug_level, int, 0644);
module_param(device_name, charp, 0644);

MODULE_PARM_DESC(debug_level, "Debug level (0-3)");
MODULE_PARM_DESC(device_name, "Device name string");

加载时这样传参:

insmod my_module.ko debug_level=2 device_name="my_uart"

module_param() 的第三个参数是文件权限。设成0表示不在sysfs中暴露,设成0644表示允许用户通过 /sys/module/<name>/parameters/ 查看甚至修改。我在项目中经常把调试开关设成0644,这样产品现场出了问题,远程改个参数就能打开日志,不用重新编译。

参数类型 宏 示例
整型 module_param(name, int, perm) module_param(count, int, 0);
字符串 module_param(name, charp, perm) module_param(name, charp, 0644);
布尔 module_param(name, bool, perm) module_param(enable, bool, 0);
数组 module_param_array(name, type, num, perm) module_param_array(irqs, int, &count, 0);

2.3 模块依赖关系

模块A调用了模块B导出的函数,那A就依赖B。怎么导出符号?用 EXPORT_SYMBOL() 或 EXPORT_SYMBOL_GPL()。

// 在模块B中
int my_add(int a, int b)
{
    return a + b;
}
EXPORT_SYMBOL(my_add);

// 在模块A中
extern int my_add(int a, int b);

static int __init a_init(void)
{
    int result = my_add(3, 5);
    printk("3 + 5 = %d\n", result);
    return 0;
}

加载顺序很重要。必须先加载B,再加载A。用 modprobe 它会自动处理,但 insmod 不会。我记得有次在客户现场,手滑先加载了依赖模块,系统直接报 Unknown symbol 错误,场面一度很尴尬。

查看模块依赖可以用 modinfo 或 lsmod:

# 查看模块信息
modinfo my_module.ko

# 查看已加载模块的依赖
lsmod

lsmod 输出中有一列 "Used by",显示哪些模块在使用当前模块。如果引用计数不为0,rmmod 会失败。我常用的调试手法:rmmod 失败时先 lsmod | grep <模块名> 看看谁占着。

小技巧: 用 depmod 生成模块依赖文件 modules.dep,之后 modprobe 就能自动解析依赖。交叉编译环境下,记得指定 -b <根文件系统路径>。

2.4 Makefile编写规范

写内核模块的Makefile,跟普通应用层程序完全不同。内核模块的编译需要依赖内核源码树的 Kbuild 系统。

# Makefile for a simple kernel module
obj-m := my_module.o

# 如果模块由多个源文件组成
# my_module-objs := file1.o file2.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

解释一下:

  • obj-m — 告诉Kbuild我要编译成模块(如果是 obj-y 就是编译进内核)
  • KDIR — 内核源码树路径,Ubuntu下通常是 /lib/modules/$(uname -r)/build
  • -C — 切换到内核目录执行Make
  • M=$(PWD) — 返回当前目录编译模块

交叉编译时,需要指定 ARCH 和 CROSS_COMPILE:

ARCH := arm
CROSS_COMPILE := arm-linux-gnueabihf-
KDIR := /path/to/kernel/tree

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

我在项目中见过不少新人把 KDIR 设成自己电脑的内核路径,交叉编译时一堆头文件找不到。记住:KDIR 必须指向目标平台的内源码树,不是本机的。

Makefile变量速查表:

变量 含义 示例
obj-m 目标模块名 obj-m := led_driver.o
obj-y 编译进内核 obj-y := core.o
<模块名>-objs 多文件模块 my_mod-objs := a.o b.o
ccflags-y 附加编译选项 ccflags-y := -DDEBUG -Wall
KBUILD_EXTRA_SYMBOLS 外部符号文件 export KBUILD_EXTRA_SYMBOLS=/path/Module.symvers

2.5 知识体系总览

下面这张图把本章的核心脉络串起来了。你可以把它当作一个快速索引,写代码时瞄一眼就知道该用哪个API。

内核模块基础知识体系 模块生命周期 加载:insmod / modprobe 运行:module_init() 卸载:rmmod 模块参数传递 module_param() MODULE_PARM_DESC() sysfs 运行时修改 模块依赖关系 EXPORT_SYMBOL() modprobe 自动解析 lsmod 查看引用计数 Makefile 编写规范 obj-m / obj-y KDIR 指向内核树 ARCH + CROSS_COMPILE ccflags-y 附加选项 核心原则:模块化开发 = 动态加载 + 参数化配置 + 依赖管理 + 自动化构建 调试阶段用 insmod,部署阶段用 modprobe + depmod

2.6 实战建议

学完这些,你可以动手写一个带参数的小模块了。我个人建议的练习路径:

  1. 先写一个最简单的 "hello world" 模块,确认编译环境没问题
  2. 加上两个参数(一个int,一个charp),验证传参功能
  3. 写两个模块,一个导出函数,一个调用它,体验依赖关系
  4. 把Makefile改成支持交叉编译的版本

调试小工具: 用 dmesg -w 实时查看内核日志,配合 printk 调试模块。比加断点快多了。

嗯,这一章的内容就这些。模块化开发是驱动开发的基石,后面的设备树、中断、IO操作全都要基于这套机制。先把基础打牢,后面才跑得快。