3、Linux内核驱动框架:字符设备驱动基础、platform驱动模型、设备树解析、驱动与设备的匹配过程
好,咱们进入第三章。这一章可以说是嵌入式GPU驱动开发的「地基工程」。你想想看,GPU再厉害,如果跟内核的沟通方式不对,那一切都是白搭。我这些年调试过的驱动,十有八九的问题都出在「驱动和设备没对上号」——说白了,就是内核不知道你的GPU硬件在哪,也不知道该用哪个驱动去管它。
这一章我会带你走一遍Linux内核驱动框架的核心脉络。从最基础的字符设备驱动,到嵌入式领域最常用的platform驱动模型,再到设备树(Device Tree)的解析,最后把驱动与设备的匹配过程串起来。嗯,内容有点多,但都是硬通货。
3.1 字符设备驱动基础
字符设备驱动,是Linux驱动里最基础、最经典的一种。像我们的GPU,很多时候就是作为一个字符设备暴露给用户空间的。为什么?因为GPU需要频繁地跟应用层交换数据——你传个渲染命令,它回个帧缓冲,这种流式操作,字符设备最合适。
核心概念:字符设备以字节流的方式访问,不支持随机访问(不像块设备可以跳来跳去)。每个字符设备都有一个主设备号和次设备号。
我记得刚入行那会儿,写第一个字符设备驱动时,被设备号分配搞得一头雾水。后来才明白,其实就三步:
- 分配设备号——可以用静态分配(自己指定),也可以用动态分配(让内核给你分)。我建议新手用动态分配,省得跟别人冲突。
- 初始化cdev结构体——把file_operations结构体挂上去。这个结构体里定义了open、read、write、ioctl等操作函数。
- 注册设备——把cdev添加到内核中。
来看一个最简单的骨架代码:
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
static int gpu_major = 0; // 动态分配主设备号
static struct cdev gpu_cdev;
static struct class *gpu_class;
static int gpu_open(struct inode *inode, struct file *filp) {
// 这里可以初始化GPU硬件
printk(KERN_INFO "GPU device opened\n");
return 0;
}
static long gpu_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) {
// 处理GPU命令,比如提交渲染任务
switch(cmd) {
case 0x1001: // 假设是提交命令缓冲区
// 处理逻辑...
break;
default:
return -EINVAL;
}
return 0;
}
static struct file_operations gpu_fops = {
.owner = THIS_MODULE,
.open = gpu_open,
.unlocked_ioctl = gpu_ioctl,
};
static int __init gpu_init(void) {
dev_t dev_num;
int ret;
// 第一步:动态分配设备号
ret = alloc_chrdev_region(&dev_num, 0, 1, "gpu_driver");
if (ret < 0) {
printk(KERN_ERR "Failed to allocate device number\n");
return ret;
}
gpu_major = MAJOR(dev_num);
// 第二步:初始化cdev
cdev_init(&gpu_cdev, &gpu_fops);
gpu_cdev.owner = THIS_MODULE;
// 第三步:注册cdev
ret = cdev_add(&gpu_cdev, dev_num, 1);
if (ret < 0) {
unregister_chrdev_region(dev_num, 1);
return ret;
}
// 创建设备类,方便udev自动创建设备节点
gpu_class = class_create(THIS_MODULE, "gpu_class");
device_create(gpu_class, NULL, dev_num, NULL, "gpu0");
printk(KERN_INFO "GPU driver loaded, major number: %d\n", gpu_major);
return 0;
}
static void __exit gpu_exit(void) {
dev_t dev_num = MKDEV(gpu_major, 0);
device_destroy(gpu_class, dev_num);
class_destroy(gpu_class);
cdev_del(&gpu_cdev);
unregister_chrdev_region(dev_num, 1);
printk(KERN_INFO "GPU driver unloaded\n");
}
module_init(gpu_init);
module_exit(gpu_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
个人经验:我在做GPU驱动时,ioctl是用户空间和内核空间交互的主通道。建议把ioctl命令号定义在一个头文件里,用户态和内核态共用,避免两边不一致。我曾经因为命令号定义错位,调试了整整两天才发现是宏定义冲突了。
3.2 platform驱动模型
好,字符设备驱动是基础,但嵌入式系统里,很多硬件不是PCIe或USB那种可枚举的总线。它们直接焊在主板上,地址是固定的。这时候,platform驱动模型就派上用场了。
说白了,platform驱动模型就是内核为「非可枚举设备」准备的一套虚拟总线机制。它把硬件资源(内存地址、中断号等)通过platform_device来描述,把驱动逻辑通过platform_driver来实现。内核负责把它们配对。
我习惯把platform驱动拆成两部分来看:
- platform_device:描述硬件在哪里,有什么资源。通常在板级文件或设备树中定义。
- platform_driver:描述怎么操作硬件。由驱动开发者实现probe、remove等回调。
来看一个platform驱动的典型结构:
// platform_driver 结构
static struct platform_driver gpu_platform_driver = {
.probe = gpu_probe,
.remove = gpu_remove,
.driver = {
.name = "my-gpu",
.of_match_table = gpu_of_match, // 设备树匹配表
},
};
module_platform_driver(gpu_platform_driver);
这里有个关键点:module_platform_driver这个宏,它帮我们自动处理了module_init和module_exit。你想想看,少写多少样板代码?
避坑指南:我曾经在probe函数里忘记检查platform_get_resource的返回值,结果在空指针上解引用,内核直接panic。记住:所有资源获取都要判空,尤其是中断号和内存区域。
3.3 设备树(Device Tree)解析
设备树,这是嵌入式Linux开发的必修课。以前用板级文件(board-xxx.c),每次换硬件都要重新编译内核,太痛苦了。设备树把硬件描述和内核代码解耦,换板子只需要改.dts文件,重新编译设备树二进制(.dtb)就行。
设备树的语法其实不复杂,就是一套树形结构的键值对。我挑几个GPU驱动里常用的节点来说:
// gpu.dtsi 片段
gpu: gpu@ffa00000 {
compatible = "vendor,my-gpu"; // 匹配驱动的关键
reg = <0x0 0xffa00000 0x0 0x10000>; // 寄存器基地址和大小
interrupts = <0 42 4>; // 中断号:GIC SPI 42, 上升沿触发
clocks = <&gpu_clk>; // 时钟
resets = <&gpu_rst>; // 复位
};
解析设备树,内核提供了丰富的API。在probe函数里,我们通常这样拿资源:
static int gpu_probe(struct platform_device *pdev) {
struct resource *res;
void __iomem *base;
int irq;
// 获取寄存器地址
res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
if (!res) {
dev_err(&pdev->dev, "No memory resource\n");
return -ENODEV;
}
// 映射到内核虚拟地址空间
base = devm_ioremap_resource(&pdev->dev, res);
if (IS_ERR(base)) {
return PTR_ERR(base);
}
// 获取中断号
irq = platform_get_irq(pdev, 0);
if (irq < 0) {
dev_err(&pdev->dev, "No IRQ resource\n");
return irq;
}
// 解析时钟
struct clk *gpu_clk = devm_clk_get(&pdev->dev, NULL);
if (IS_ERR(gpu_clk)) {
dev_err(&pdev->dev, "Failed to get clock\n");
return PTR_ERR(gpu_clk);
}
// ... 初始化GPU硬件 ...
return 0;
}
我的习惯:在设备树里,reg属性我通常会多留一点余量。比如GPU寄存器实际只用了8KB,我会在设备树里写16KB。为什么?因为硬件版本升级时,寄存器空间可能会扩展。改设备树比改驱动代码简单多了。
3.4 驱动与设备的匹配过程
好,前面讲了字符设备、platform驱动、设备树,现在把它们串起来——驱动和设备到底是怎么匹配上的?
这个过程,我把它总结为「三步相亲」:
- 注册阶段:内核启动时,会遍历所有注册的platform_driver和platform_device。每个driver都有一个
of_match_table,里面列出了它支持的设备类型(通过compatible属性)。 - 匹配阶段:内核拿device的compatible值,去driver的of_match_table里找。找到了,就调用driver的probe函数。找不到?那这个设备就没人管了。
- 绑定阶段:probe函数被调用,驱动开始初始化硬件。如果probe成功,驱动和设备就「绑定」在一起了。
匹配的优先级是这样的:
| 匹配方式 | 说明 | 优先级 |
|---|---|---|
| 设备树compatible匹配 | 最常用,通过of_match_table | 高 |
| platform ID表匹配 | 通过id_table中的名字匹配 | 中 |
| 驱动名字匹配 | 直接比较driver.name和device.name | 低 |
你可能会问:「如果多种方式都匹配上了怎么办?」内核会按优先级顺序尝试,一旦某个probe成功,就不再尝试其他方式。
我曾经踩过的坑:有一次我改了设备树里的compatible字符串,但忘了更新驱动里的of_match_table。结果驱动加载时,内核说「找不到匹配的设备」。我查了半天,才发现compatible写成了"vendor,my-gpu-v2",而驱动里还是"vendor,my-gpu"。这种低级错误,真的让人抓狂。所以,改设备树一定要同步改驱动,反之亦然。
最后,我画个简单的流程图,帮你理解整个匹配过程:
内核启动/模块加载
|
v
注册platform_driver到内核
|
v
遍历所有已注册的platform_device
|
v
对每个device,检查driver的of_match_table
|
+-- 找到匹配项? --> 调用probe()
| |
| v
| probe成功? --> 绑定完成
| |
| v (失败)
| 继续尝试下一个driver
|
+-- 没找到? --> 设备处于"无人认领"状态
嗯,这一章的内容就到这里。字符设备驱动是基础,platform模型是骨架,设备树是血肉,匹配过程是灵魂。把这四块吃透了,你写GPU驱动时就不会再迷茫——至少知道该从哪里下手。
下一章,我们会深入GPU硬件的内存管理单元(MMU),看看怎么给GPU分配和管理显存。那才是真正考验功底的地方。