第2章:Linux内核驱动框架:字符设备驱动模型、platform驱动框架、设备树(DTS)基础语法
好,咱们直接进入正题。这一章我打算把Linux内核驱动开发的三个核心骨架讲透。字符设备模型、platform驱动框架、设备树语法——这三样东西,说白了就是你在ARM上写外设驱动的"三板斧"。我当年刚接触内核时,就是被这三者的关系绕晕过,后来花了整整一个项目周期才理清楚。
2.1 字符设备驱动模型——最基础的"文件操作"思维
Linux的设计哲学里有一句经典的话:"一切皆文件"。字符设备驱动,就是把你的硬件外设包装成一个文件。用户空间用open()、read()、write()、ioctl()来操作它,内核空间则通过file_operations结构体来响应。
核心结构体:struct file_operations
这个结构体里挂满了函数指针。你只需要把想支持的操作填进去,不支持的置为NULL就行。我习惯把它的定义打印出来贴在工位上,写驱动时随时对照。
// 一个典型的字符设备驱动骨架
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
static int mydev_open(struct inode *inode, struct file *filp) {
pr_info("mydev: device opened\n");
return 0;
}
static ssize_t mydev_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos) {
// 这里实现从硬件读取数据
return 0;
}
static ssize_t mydev_write(struct file *filp, const char __user *buf,
size_t count, loff_t *f_pos) {
// 这里实现向硬件写入数据
return 0;
}
static int mydev_release(struct inode *inode, struct file *filp) {
pr_info("mydev: device released\n");
return 0;
}
static struct file_operations mydev_fops = {
.owner = THIS_MODULE,
.open = mydev_open,
.read = mydev_read,
.write = mydev_write,
.release = mydev_release,
};
// 模块初始化与退出
static int __init mydev_init(void) {
// 注册字符设备、分配设备号、创建cdev、创建类、创建设备节点
return 0;
}
static void __exit mydev_exit(void) {
// 反向销毁所有资源
}
module_init(mydev_init);
module_exit(mydev_exit);
MODULE_LICENSE("GPL");
嗯,这里要注意一个坑:设备号。主设备号标识驱动类型,次设备号标识具体设备。早期内核里主设备号是静态分配的,现在推荐动态分配——用alloc_chrdev_region(),省得跟别人冲突。我在一个项目里就吃过静态分配撞车的亏,两个驱动抢同一个主设备号,系统直接崩了。
| 步骤 | 函数/宏 | 说明 |
|---|---|---|
| 1. 分配设备号 | alloc_chrdev_region() |
动态分配,避免冲突 |
| 2. 初始化cdev | cdev_init() |
绑定file_operations |
| 3. 添加cdev到系统 | cdev_add() |
注册到内核 |
| 4. 创建设备类 | class_create() |
在/sys/class下出现 |
| 5. 创建设备节点 | device_create() |
自动生成/dev/xxx |
2.2 platform驱动框架——让驱动和设备"解耦"
字符设备模型解决了"怎么操作硬件"的问题,但没解决"驱动怎么找到硬件"的问题。在ARM这种SoC高度集成的平台上,外设的地址、中断号、时钟等信息通常硬编码在板级文件或设备树里。如果每个驱动都自己去解析这些信息,代码会变得又臭又硬。
platform驱动框架就是干这个的。它把驱动拆成两部分:
- platform_driver:负责驱动逻辑,比如probe、remove、suspend/resume
- platform_device:负责硬件资源描述,比如寄存器基址、中断号
两者通过name字段匹配。内核维护一个总线(platform_bus),专门做这个匹配工作。匹配成功后,调用驱动的probe()函数,驱动就可以从device里拿资源了。
我的经验:写platform驱动时,probe()函数里第一件事就是用platform_get_resource()拿资源。别自己硬编码地址,否则换个板子就得改驱动源码,那就尴尬了。
// platform驱动骨架
#include <linux/platform_device.h>
#include <linux/module.h>
static int my_platform_probe(struct platform_device *pdev) {
struct resource *res;
void __iomem *base;
int irq;
// 获取IO内存资源
res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
if (!res) {
dev_err(&pdev->dev, "failed to get memory resource\n");
return -ENXIO;
}
// 映射物理地址到虚拟地址
base = devm_ioremap_resource(&pdev->dev, res);
if (IS_ERR(base))
return PTR_ERR(base);
// 获取中断号
irq = platform_get_irq(pdev, 0);
if (irq < 0)
return irq;
// 注册中断、初始化硬件、创建设备节点...
dev_info(&pdev->dev, "probe success, base=%p, irq=%d\n", base, irq);
return 0;
}
static int my_platform_remove(struct platform_device *pdev) {
dev_info(&pdev->dev, "device removed\n");
return 0;
}
static const struct of_device_id my_of_match[] = {
{ .compatible = "vendor,my-device" },
{ /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, my_of_match);
static struct platform_driver my_platform_driver = {
.probe = my_platform_probe,
.remove = my_platform_remove,
.driver = {
.name = "my_platform_driver",
.of_match_table = my_of_match,
},
};
module_platform_driver(my_platform_driver);
MODULE_LICENSE("GPL");
看到of_match_table了吗?这就是连接platform驱动和设备树的桥梁。compatible属性值必须跟设备树里写的完全一致,否则probe不会被调用。我曾经因为少写了一个逗号,排查了整整一个下午。
2.3 设备树(DTS)基础语法——硬件配置的"说明书"
设备树(Device Tree)是一套描述硬件拓扑的数据结构。它用树形节点描述CPU、内存、外设、中断控制器、时钟等所有硬件信息。内核启动时解析它,动态构建platform_device和其他总线设备。
说白了,设备树就是硬件的"身份证"。以前ARM Linux用板级文件(board-xxx.c)硬编码硬件信息,每个板子一个文件,维护起来简直是噩梦。设备树出现后,一个.dts文件对应一个板子,驱动从设备树读配置,代码干净多了。
避坑指南:设备树编译工具是dtc,源文件后缀.dts,编译后生成.dtb。千万别把.dts直接扔给内核,内核只认.dtb。我刚开始就犯过这个错,折腾半天没反应。
2.3.1 基本语法结构
/dts-v1/;
/ {
model = "My ARM Board";
compatible = "vendor,my-board";
chosen {
bootargs = "console=ttyS0,115200 root=/dev/mmcblk0p2";
};
memory@80000000 {
device_type = "memory";
reg = <0x80000000 0x10000000>; // 256MB内存
};
soc {
compatible = "simple-bus";
#address-cells = <1>;
#size-cells = <1>;
ranges;
uart0: serial@10000000 {
compatible = "vendor,uart";
reg = <0x10000000 0x1000>;
interrupts = <0 33 4>;
clock-frequency = <24000000>;
status = "okay";
};
spi0: spi@20000000 {
compatible = "vendor,spi-controller";
reg = <0x20000000 0x1000>;
interrupts = <0 34 4>;
#address-cells = <1>;
#size-cells = <0>;
spidev@0 {
compatible = "vendor,spi-device";
reg = <0>; // 片选0
spi-max-frequency = <10000000>;
};
};
};
};
2.3.2 关键语法点
| 语法元素 | 示例 | 说明 |
|---|---|---|
| 根节点 | / { }; |
设备树的根,所有节点都是它的子节点 |
| 节点名 | uart0: serial@10000000 |
uart0是标签,serial是节点名,@10000000是地址 |
| 属性 | compatible = "vendor,uart"; |
驱动匹配的关键字符串,格式"厂商,设备名" |
| reg | reg = <0x10000000 0x1000>; |
地址和长度,格式由#address-cells和#size-cells决定 |
| interrupts | interrupts = <0 33 4>; |
中断描述:中断类型、中断号、触发方式 |
| status | status = "okay"; |
设备状态,okay启用,disabled禁用 |
| 标签引用 | &uart0 { ... }; |
在.dtsi中定义,在.dts中追加或覆盖属性 |
2.3.3 设备树中的地址编码
#address-cells和#size-cells这两个属性决定了reg属性里地址和长度各占几个32位整数。比如:
#address-cells = <1>; #size-cells = <1>;→ reg = <地址 长度>#address-cells = <2>; #size-cells = <1>;→ reg = <高32位地址 低32位地址 长度>
64位ARM处理器上,地址经常用2个cell表示。我刚开始写64位设备树时,经常搞错cell数量,导致驱动拿到的地址完全不对。后来养成了一个习惯:先看父节点的#address-cells和#size-cells,再写子节点的reg。
2.4 三者的协作关系
这三者不是孤立的,它们是一条完整的链路:
- 设备树描述硬件资源(地址、中断、时钟等)
- 内核启动时解析设备树,为每个
compatible匹配的节点创建platform_device - platform_driver通过
of_match_table匹配到对应的platform_device,调用probe() - 在
probe()里,驱动获取资源、初始化硬件,然后注册字符设备(通过cdev或miscdevice) - 用户空间通过
/dev/xxx设备节点,使用file_operations提供的接口操作硬件
下面这张图把整个流程串起来了,我画图时习惯把关键函数和数据结构都标上,方便调试时对照。
2.5 实战中的常见问题
我曾经踩过的坑:
- 设备树编译错误:漏了分号、括号不匹配——用
dtc -I dts -O dtb -o xxx.dtb xxx.dts编译,报错信息很明确,别怕看。 - probe不调用:检查compatible字符串是否完全一致,包括大小写和逗号。我习惯从设备树里复制粘贴到驱动里。
- ioremap失败:检查reg属性里的地址范围是否被其他设备占用,或者地址对齐有问题。
- 中断不触发:检查interrupts属性的触发方式(上升沿、下降沿、高电平、低电平),跟硬件手册对一下。
调试小技巧:
- 在设备树里加
status = "okay";,否则内核默认禁用该设备 - 用
ls /dev/查看设备节点是否生成 - 用
cat /proc/device-tree/查看内核实际解析的设备树 - 在probe函数里加
dev_info()打印,看驱动是否被加载
好了,这一章的内容就到这里。字符设备模型、platform框架、设备树语法——这三者构成了ARM Linux驱动开发的基石。后面的章节里,我们会用这些知识去驱动具体的SPI和I2C控制器。到时候你会发现,理解了这套框架,写任何外设驱动都只是"填空"而已。