3. Linux驱动框架:内核模块结构、字符设备驱动模型、platform驱动框架
好,咱们进入第三章。这一章我打算聊聊Linux驱动框架的三大基石——内核模块、字符设备驱动模型、以及platform驱动框架。说实话,这三个东西你搞明白了,市面上八成以上的外设驱动你都能看懂,甚至自己写出来。
我记得刚入行那会儿,看一个简单的GPIO驱动源码,愣是看了三天没看懂。后来才发现,不是我笨,是没搞懂框架。框架这东西,说白了就是一套“约定”。你按它的规矩来,内核就帮你干活;你不按规矩来,内核就罢工给你看。
3.1 内核模块结构——驱动的最小单元
Linux内核是宏内核,但支持模块化加载。什么意思呢?就是你可以把一段代码编译成.ko文件,然后动态插进内核,不用重新编译整个内核。我当年第一次insmod成功的时候,那种成就感,嗯,跟点亮第一个LED差不多。
一个标准的内核模块,长这样:
#include <linux/module.h>
#include <linux/init.h>
static int __init my_driver_init(void)
{
printk(KERN_INFO "My driver loaded\n");
return 0;
}
static void __exit my_driver_exit(void)
{
printk(KERN_INFO "My driver unloaded\n");
}
module_init(my_driver_init);
module_exit(my_driver_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple example driver");
这里有几个关键点,我挨个说一下:
- module_init / module_exit:这是入口和出口。内核加载模块时调用init,卸载时调用exit。这两个宏是必须的,少一个模块就加载不了。
- __init / __exit:这两个标记告诉内核,init函数用完后可以扔掉,省内存。exit函数如果模块被编译进内核(非模块),也可以扔掉。
- MODULE_LICENSE:这个很重要。不声明GPL,有些内核符号你调用不了。我见过有人因为这个导致模块加载失败,查了半天。
小技巧:调试内核模块时,别用printf,用printk。printk的输出可以通过dmesg查看。我个人习惯在init和exit函数里各加一条printk,这样模块加载卸载时能确认是否成功。
3.2 字符设备驱动模型——外设驱动的核心
字符设备是Linux驱动里最常见的一类。键盘、鼠标、串口、I2C设备,基本都是字符设备。它们的特点是:数据按字节流访问,顺序读写,不支持随机访问(像块设备那样)。
字符设备驱动的核心,是file_operations结构体。这个结构体定义了设备文件的操作函数指针。你想想看,当用户在用户空间调用open、read、write时,内核怎么知道该调用哪个函数?就是靠这个结构体。
一个典型的字符设备驱动框架:
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
static int __init my_init(void)
{
// 1. 分配设备号
alloc_chrdev_region(&dev_num, 0, 1, "my_device");
// 2. 初始化cdev结构体
cdev_init(&my_cdev, &my_fops);
// 3. 添加cdev到内核
cdev_add(&my_cdev, dev_num, 1);
// 4. 创建设备类和设备节点(自动生成/dev/xxx)
my_class = class_create(THIS_MODULE, "my_class");
device_create(my_class, NULL, dev_num, NULL, "my_device");
return 0;
}
这里我重点说一下流程:
- 分配设备号:每个字符设备需要一个主设备号和次设备号。主设备号标识驱动,次设备号标识具体设备。可以用alloc_chrdev_region动态分配,也可以用register_chrdev_region指定。
- 初始化cdev:cdev是内核里代表字符设备的结构体。把它和file_operations绑定。
- 添加cdev:告诉内核,这个设备号归我管了。
- 创建设备节点:用户空间通过/dev/xxx访问设备。这一步不是必须的,但建议做。我早期写驱动时没创建设备节点,结果用户态程序打不开设备,排查了半天才发现是没mknod。
核心要点:字符设备驱动的本质,就是实现file_operations里的回调函数,然后注册到内核。内核在用户操作设备文件时,自动调用你的回调。
3.3 platform驱动框架——设备与驱动的分离
好,接下来是platform驱动。这个框架是Linux为了解决“设备信息与驱动代码耦合”的问题而引入的。你想想看,在嵌入式系统里,很多设备不是可热插拔的(比如I2C控制器、SPI控制器、GPIO控制器),它们直接焊在主板上。这类设备怎么描述?怎么匹配驱动?
platform框架的答案是:把设备信息放在设备树(Device Tree)或板级文件里,驱动只关心怎么操作硬件。两者通过name匹配。
一个platform驱动的基本结构:
static struct platform_driver my_platform_driver = {
.probe = my_probe,
.remove = my_remove,
.driver = {
.name = "my_device",
.of_match_table = my_of_match,
},
};
module_platform_driver(my_platform_driver);
probe函数是驱动的入口。当内核发现一个设备与驱动的name匹配时,就会调用probe。在probe里,你通常要做这些事:
- 获取硬件资源(IO地址、中断号、时钟等)
- 注册字符设备或其它子系统
- 初始化硬件状态
我曾经在一个项目里,设备树里reg属性写错了,导致probe函数拿到的IO地址是0。结果驱动加载成功,但一操作硬件就死机。排查了两天才发现是设备树的问题。所以,设备树和驱动一定要对得上。
避坑指南:我曾经遇到过一个问题——platform_driver注册成功,但probe就是不调用。后来发现是设备树里compatible属性写错了。驱动里写的是"vendor,device",设备树里写的是"vendor,device-v2",就差一个后缀,匹配不上。所以,compatible字符串必须完全一致,一个字符都不能差。
3.4 三者的关系
这三个东西不是孤立的。我画了一张图,帮你理清它们的关系:
从这张图你可以看到:
- 内核模块是所有驱动的基础。没有模块机制,驱动就只能编译进内核,调试起来很麻烦。
- 字符设备是驱动与用户空间的接口。用户通过文件操作来访问硬件。
- platform驱动是设备与驱动的匹配框架。它帮你把设备信息从驱动代码里剥离出来。
实际项目中,这三个东西通常是组合使用的。比如一个I2C控制器驱动,它本身是一个platform驱动,在probe里注册一个字符设备,然后整个驱动编译成一个内核模块。你看,三层都用上了。
我的建议:学习驱动开发,不要一上来就啃复杂驱动。先写一个最简单的内核模块,打印"Hello World"。然后把它改成字符设备,实现open和read。最后再套上platform框架,用设备树传参数。一步一步来,你会发现其实没那么难。
3.5 总结
这一章我们聊了Linux驱动框架的三个核心概念:
| 概念 | 核心作用 | 关键函数/结构体 |
|---|---|---|
| 内核模块 | 动态加载/卸载驱动 | module_init, module_exit, MODULE_LICENSE |
| 字符设备 | 提供文件操作接口 | file_operations, cdev, alloc_chrdev_region |
| platform驱动 | 设备与驱动匹配 | platform_driver, probe, of_match_table |
嗯,这三个东西搞明白了,后面讲具体外设驱动的时候,你就会发现——原来都是换汤不换药。框架是固定的,变的只是硬件操作细节。