内核模块基础:模块的加载与卸载、模块参数传递、模块依赖关系、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— 切换到内核目录执行MakeM=$(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。
2.6 实战建议
学完这些,你可以动手写一个带参数的小模块了。我个人建议的练习路径:
- 先写一个最简单的 "hello world" 模块,确认编译环境没问题
- 加上两个参数(一个int,一个charp),验证传参功能
- 写两个模块,一个导出函数,一个调用它,体验依赖关系
- 把Makefile改成支持交叉编译的版本
调试小工具: 用 dmesg -w 实时查看内核日志,配合 printk 调试模块。比加断点快多了。
嗯,这一章的内容就这些。模块化开发是驱动开发的基石,后面的设备树、中断、IO操作全都要基于这套机制。先把基础打牢,后面才跑得快。