2、Linux内核驱动框架:字符设备驱动模型、platform驱动框架、设备树匹配机制
好,咱们今天聊聊驱动框架。说实话,我刚入行那会儿,面对Linux内核里那一堆结构体和回调函数,头都是大的。但后来我发现,搞懂这三个东西——字符设备驱动模型、platform驱动框架、设备树匹配机制——基本上就能应付90%的嵌入式驱动开发场景了。
我个人习惯把这套东西叫做「驱动三板斧」。你想想看,不管你是写一个简单的LED灯驱动,还是搞一个复杂的摄像头传感器驱动,底层逻辑都离不开它们。咱们一个一个来拆解。
2.1 字符设备驱动模型:最基础的驱动形态
字符设备,说白了就是像文件一样操作硬件。你打开一个设备节点,读它、写它、控制它,内核帮你把这些操作映射到硬件上。我最早接触的驱动就是字符设备驱动,一个简单的GPIO按键。
核心结构体是 struct file_operations。它定义了设备能干什么:
struct file_operations {
struct module *owner;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
int (*open) (struct inode *, struct file *);
int (*release) (struct inode *, struct file *);
// ... 还有不少,但常用的就这些
};
注册一个字符设备,流程其实很固定:
- 分配设备号(动态或静态)
- 初始化
cdev结构体 - 调用
cdev_add添加到内核 - 创建设备节点(手动或通过udev自动创建)
我举个例子,一个最简单的虚拟字符设备:
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#define DEVICE_NAME "my_camera_dev"
#define CLASS_NAME "camera_class"
static int major;
static struct class *camera_class;
static struct cdev camera_cdev;
static int camera_open(struct inode *inode, struct file *file) {
pr_info("Camera device opened\n");
return 0;
}
static ssize_t camera_read(struct file *file, char __user *buf,
size_t len, loff_t *off) {
// 这里模拟从摄像头读取数据
char data[] = "frame_data";
if (*off >= sizeof(data)) return 0;
if (copy_to_user(buf, data, sizeof(data))) return -EFAULT;
*off += sizeof(data);
return sizeof(data);
}
static struct file_operations fops = {
.owner = THIS_MODULE,
.open = camera_open,
.read = camera_read,
};
static int __init camera_init(void) {
dev_t dev;
// 动态分配设备号
alloc_chrdev_region(&dev, 0, 1, DEVICE_NAME);
major = MAJOR(dev);
// 初始化cdev
cdev_init(&camera_cdev, &fops);
cdev_add(&camera_cdev, dev, 1);
// 创建设备类
camera_class = class_create(THIS_MODULE, CLASS_NAME);
device_create(camera_class, NULL, dev, NULL, DEVICE_NAME);
pr_info("Camera driver loaded, major=%d\n", major);
return 0;
}
static void __exit camera_exit(void) {
dev_t dev = MKDEV(major, 0);
device_destroy(camera_class, dev);
class_destroy(camera_class);
cdev_del(&camera_cdev);
unregister_chrdev_region(dev, 1);
pr_info("Camera driver unloaded\n");
}
module_init(camera_init);
module_exit(camera_exit);
MODULE_LICENSE("GPL");
我的经验: 写字符设备驱动时,copy_to_user 和 copy_from_user 这两个函数一定要用对。我曾经犯过一个低级错误,直接在内核空间用 memcpy 往用户空间写数据,结果系统直接崩溃。记住,内核和用户空间的数据拷贝,必须用专用函数。
2.2 platform驱动框架:让驱动和设备解耦
字符设备驱动虽然简单,但有个问题——它把硬件信息和驱动逻辑混在一起了。比如你的摄像头挂在哪条I2C总线上、中断号是多少,这些信息如果硬编码在驱动里,换个板子就得改代码。
platform驱动框架就是来解决这个问题的。它把「设备」和「驱动」分开管理。设备负责描述硬件资源,驱动负责操作逻辑。内核通过一个叫 platform_bus 的虚拟总线来匹配它们。
核心是两个结构体:
struct platform_device:描述硬件设备(资源、名称等)struct platform_driver:描述驱动(probe、remove、id_table等)
匹配流程是这样的:
- 内核注册一个platform设备
- 内核注册一个platform驱动
- platform总线根据名称或设备树信息进行匹配
- 匹配成功,调用驱动的
probe函数
看个例子,一个虚拟的摄像头platform驱动:
static int camera_probe(struct platform_device *pdev) {
struct resource *res;
int irq;
// 获取I/O内存资源
res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
if (!res) {
dev_err(&pdev->dev, "No memory resource\n");
return -ENODEV;
}
// 获取中断号
irq = platform_get_irq(pdev, 0);
if (irq < 0) {
dev_err(&pdev->dev, "No IRQ resource\n");
return -ENXIO;
}
dev_info(&pdev->dev, "Camera probed: mem=0x%llx, irq=%d\n",
(unsigned long long)res->start, irq);
return 0;
}
static int camera_remove(struct platform_device *pdev) {
dev_info(&pdev->dev, "Camera removed\n");
return 0;
}
static const struct of_device_id camera_of_match[] = {
{ .compatible = "mycompany,my-camera" },
{ /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, camera_of_match);
static struct platform_driver camera_driver = {
.probe = camera_probe,
.remove = camera_remove,
.driver = {
.name = "my_camera",
.of_match_table = camera_of_match,
},
};
module_platform_driver(camera_driver);
关键点: probe 函数是驱动的入口。所有硬件初始化、内存申请、中断注册都应该放在这里。如果 probe 返回非0值,内核会认为驱动加载失败。
2.3 设备树匹配机制:硬件描述的标准化
说到设备树,我刚开始接触的时候觉得这东西很玄乎。说白了,设备树就是一个描述硬件拓扑结构的文本文件。它用节点和属性的方式,告诉内核「我这块板子上有什么硬件、接在哪个引脚上、用哪个中断」。内核启动时解析这个文件,然后动态创建设备。
设备树的匹配机制,核心就是 compatible 属性。驱动里声明自己支持哪些设备,设备树里声明自己是什么设备,两者匹配上了,驱动就干活。
一个典型的设备树节点:
// 在 .dts 文件中
i2c@ff110000 {
compatible = "snps,designware-i2c";
reg = <0xff110000 0x1000>;
interrupts = <0 56 4>;
#address-cells = <1>;
#size-cells = <0>;
camera: camera@10 {
compatible = "mycompany,my-camera";
reg = <0x10>;
clocks = <&clkc 15>;
reset-gpios = <&gpio0 5 GPIO_ACTIVE_LOW>;
power-gpios = <&gpio0 6 GPIO_ACTIVE_HIGH>;
};
};
驱动里怎么获取这些信息?用设备树API:
// 在 probe 函数中
struct device *dev = &pdev->dev;
struct device_node *np = dev->of_node;
u32 reg_val;
int ret;
// 读取 reg 属性
ret = of_property_read_u32(np, "reg", ®_val);
if (ret) {
dev_err(dev, "Failed to read reg property\n");
return ret;
}
// 读取 GPIO
struct gpio_desc *reset_gpio;
reset_gpio = devm_gpiod_get(dev, "reset", GPIOD_OUT_LOW);
if (IS_ERR(reset_gpio)) {
dev_err(dev, "Failed to get reset GPIO\n");
return PTR_ERR(reset_gpio);
}
// 读取时钟
struct clk *clk;
clk = devm_clk_get(dev, NULL);
if (IS_ERR(clk)) {
dev_err(dev, "Failed to get clock\n");
return PTR_ERR(clk);
}
我曾经踩过的坑: 设备树里 reg 属性的地址和长度,一定要和硬件手册一致。有一次我写摄像头驱动,I2C地址写成了0x10,但实际芯片地址是0x30,结果驱动一直probe失败。查了两天才发现是设备树写错了。所以,拿到一块新板子,第一件事就是核对设备树里的地址和中断号。
2.4 三者如何协同工作?
现在咱们把这三个东西串起来。一个完整的摄像头驱动,通常是这样工作的:
- 设备树 描述摄像头挂在哪条I2C总线上、用哪个GPIO做复位、用哪个中断
- platform驱动 通过
of_match_table匹配设备树节点,进入probe函数 - 在
probe函数里,字符设备驱动模型 被用来注册设备节点,提供open/read/ioctl等操作接口给用户空间
我画个简单的流程:
设备树 (.dts)
↓ 内核解析
platform_device (自动创建)
↓ 匹配
platform_driver → probe()
↓
字符设备注册 (cdev_add)
↓
用户空间通过 /dev/camera 访问
这个架构的好处是:硬件变了,只需要改设备树文件,驱动代码不用动。比如你从A板子换到B板子,摄像头从I2C-0换到I2C-1,中断号从56变成57,改一下 .dts 文件重新编译就行。
我的建议: 写驱动时,尽量把硬件相关的信息(地址、中断、GPIO)都放到设备树里。哪怕你现在只有一个板子,也养成这个习惯。因为项目后期换硬件是常有的事,到时候你会发现这个习惯救了你一命。
2.5 实战中的注意事项
| 组件 | 常见问题 | 解决方案 |
|---|---|---|
| 字符设备 | 设备节点不自动创建 | 检查udev规则,或手动 mknod |
| platform驱动 | probe不执行 | 检查 compatible 是否匹配,设备树是否被正确加载 |
| 设备树 | 语法错误导致启动失败 | 用 dtc -I dts -O dtb 编译检查,用 fdtdump 查看二进制内容 |
嗯,这套框架说复杂也复杂,说简单也简单。你只要记住:设备树描述硬件长什么样,platform驱动负责找到硬件并初始化,字符设备驱动负责给用户提供操作接口。这三者配合好了,一个稳定、可移植的驱动就出来了。
下一章咱们会基于这个框架,真正开始写一个摄像头传感器的驱动。到时候你会看到,这些理论是怎么落到实处的。