第1章:内核模块基础
大家好,我是你们的老朋友。今天咱们聊聊内核模块——这玩意儿可以说是嵌入式Linux驱动开发的入门砖。我记得刚入行那会儿,第一次接触内核模块,心里直犯嘀咕:「这到底是个啥?跟普通程序有啥区别?」后来踩了不少坑,才慢慢摸清门道。
说白了,内核模块就是一段可以在系统运行时动态加载到内核的代码。它不像普通应用程序那样跑在用户空间,而是直接在内核空间执行。你想想看,这意味着什么?意味着它拥有最高权限,能直接操作硬件、访问系统核心数据结构。当然,也意味着一旦写崩了,系统可能直接挂掉。
1.1 什么是内核模块
内核模块(Kernel Module)本质上是一个目标文件,通常以.ko(Kernel Object)结尾。它可以在不重新编译整个内核、不重启系统的情况下,动态扩展内核功能。
我个人的习惯是,把内核模块想象成「乐高积木」。内核是底座,模块就是一块块积木。你需要什么功能,就插上对应的模块。不需要了,随时拔掉。这种机制对嵌入式开发特别友好——你想啊,产品已经跑起来了,突然要加个新驱动,总不能为了这个重启整个系统吧?
内核模块 vs 普通应用程序
- 运行空间:内核模块运行在内核空间,应用程序运行在用户空间
- 入口函数:模块用
module_init(),应用程序用main() - 退出函数:模块用
module_exit(),应用程序用exit() - 内存访问:模块可以直接访问物理内存,应用程序只能通过系统调用
- 调试难度:模块出问题通常直接内核崩溃,应用程序最多段错误
1.2 模块的加载与卸载
写一个最简单的内核模块,其实就两个函数:加载时执行的初始化函数,卸载时执行的清理函数。来看个例子:
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
static int __init hello_init(void)
{
printk(KERN_INFO "Hello, Kernel Module!\n");
return 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 world module");
嗯,这里要注意几个点:
__init和__exit是宏,告诉内核这些函数只在特定阶段使用,用完后可以释放内存printk是内核空间的打印函数,跟用户空间的printf类似,但输出到内核日志MODULE_LICENSE必须声明,否则内核会抱怨「tainted kernel」
编译这个模块需要写个Makefile。我刚开始学的时候,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 文件。加载和卸载的命令很简单:
# 加载模块
sudo insmod hello.ko
# 查看已加载的模块
lsmod | grep hello
# 卸载模块
sudo rmmod hello
# 查看内核日志
dmesg | tail
我曾经犯过一个低级错误:加载模块后忘了看 dmesg,以为模块没跑起来,反复加载卸载了好几次。后来才发现,printk 的输出默认不会显示在终端上,得去内核日志里找。
1.3 模块参数传递
有时候,我们希望模块在加载时能接收一些参数,比如设备号、缓冲区大小等。内核模块支持通过 module_param 宏来声明参数。
#include <linux/moduleparam.h>
static int count = 1;
static char *name = "default";
module_param(count, int, 0644);
module_param(name, charp, 0644);
MODULE_PARM_DESC(count, "Number of times to print");
MODULE_PARM_DESC(name, "The name to print");
static int __init param_init(void)
{
int i;
for (i = 0; i < count; i++)
printk(KERN_INFO "Hello, %s!\n", name);
return 0;
}
加载时传参:
sudo insmod param.ko count=3 name="EmbeddedLinux"
这里 0644 是文件权限,表示这个参数在 /sys/module/param/parameters/ 下会生成对应的文件,可以在运行时通过读写文件来修改参数值。我个人习惯把调试开关做成模块参数,这样调试时不用重新编译模块,直接改文件就行。
小技巧: 如果你不想让参数在运行时被修改,可以把权限设为 0。这样 /sys 下就不会生成对应的文件,参数只能在加载时指定。
1.4 模块依赖与符号导出
实际项目中,模块之间往往有依赖关系。比如一个I2C总线驱动模块,会被多个I2C设备驱动模块依赖。这时候就需要用到符号导出。
内核提供了两个宏来导出符号:
EXPORT_SYMBOL(symbol_name):导出符号,所有模块都可以使用EXPORT_SYMBOL_GPL(symbol_name):只允许GPL许可的模块使用
来看个例子。假设我们有一个基础模块 base_module.c:
#include <linux/module.h>
int add(int a, int b)
{
return a + b;
}
EXPORT_SYMBOL(add);
static int __init base_init(void)
{
printk(KERN_INFO "Base module loaded\n");
return 0;
}
static void __exit base_exit(void)
{
printk(KERN_INFO "Base module unloaded\n");
}
module_init(base_init);
module_exit(base_exit);
MODULE_LICENSE("GPL");
另一个模块 user_module.c 就可以调用 add 函数:
extern int add(int a, int b);
static int __init user_init(void)
{
int result = add(3, 4);
printk(KERN_INFO "3 + 4 = %d\n", result);
return 0;
}
module_init(user_init);
module_exit(user_exit);
MODULE_LICENSE("GPL");
加载顺序很重要——必须先加载 base_module.ko,再加载 user_module.ko。卸载时顺序相反。如果你搞反了,内核会报错:
sudo insmod user_module.ko
insmod: ERROR: could not insert module user_module.ko: Unknown symbol in module
我曾经在一个项目里,因为模块依赖关系没理清楚,导致系统启动时模块加载顺序混乱,设备死活认不出来。后来用 modprobe 代替 insmod 解决了——modprobe 会自动处理依赖关系,加载所有需要的模块。
警告: 使用 EXPORT_SYMBOL_GPL 时要注意,如果你的模块不是GPL许可,调用这些符号会导致内核标记为「污染」(tainted)。商业驱动开发时尤其要注意这一点。
1.5 模块的Makefile进阶
当模块有多个源文件时,Makefile需要稍作调整:
obj-m := mydriver.o
mydriver-objs := main.o helper.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
这里 mydriver-objs 指定了组成最终模块的目标文件。内核构建系统会自动把它们链接成 mydriver.ko。
1.6 避坑指南
最后,分享几个我踩过的坑:
- 不要在内核模块里用
printf——要用printk,否则编译报错 - 模块卸载时一定要释放资源——申请了内存、注册了设备,卸载时都要清理干净,否则下次加载可能出问题
- 调试时多用
dmesg——模块的打印信息都在这里,别傻等终端输出 - 注意内核版本兼容性——不同内核版本的API可能有变化,编译时最好用目标系统的内核头文件
好了,这一章的内容就到这里。内核模块是驱动开发的基础,把这些概念吃透了,后面学字符设备驱动、平台驱动就会轻松很多。下一章咱们聊聊字符设备驱动——那才是真正跟硬件打交道的开始。