2、Linux内核驱动框架:字符设备驱动模型、platform总线、设备树基础
好,咱们进入第二章。这一章可以说是机顶盒驱动开发的基石。你想想看,不管你是写红外遥控、还是调HDMI输出,最终都绕不开这三个东西:字符设备、platform总线、设备树。
我刚开始接触机顶盒驱动时,也觉得很乱。一会儿说设备模型,一会儿说设备树,到底谁管谁?后来做多了才明白——这三者其实是层层递进的关系。今天我就把这块掰开揉碎了讲给你听。
2.1 字符设备驱动模型——最基础的驱动形态
先说说字符设备。说白了,就是按字节流读写的设备。键盘、鼠标、串口,包括机顶盒里的红外接收头,都属于字符设备。
在Linux里,字符设备通过cdev结构体来抽象。我习惯把驱动注册流程记成三步:
- 分配设备号——给设备一个身份证
- 初始化cdev——告诉内核你的操作函数在哪
- 添加到内核——让用户空间能找到它
来看个最简单的例子:
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
static int demo_open(struct inode *inode, struct file *file)
{
pr_info("demo: device opened\n");
return 0;
}
static struct file_operations demo_fops = {
.owner = THIS_MODULE,
.open = demo_open,
};
static dev_t dev_num;
static struct cdev demo_cdev;
static int __init demo_init(void)
{
// 第一步:分配设备号
alloc_chrdev_region(&dev_num, 0, 1, "demo_dev");
// 第二步:初始化cdev
cdev_init(&demo_cdev, &demo_fops);
// 第三步:添加到内核
cdev_add(&demo_cdev, dev_num, 1);
pr_info("demo: loaded, major=%d minor=%d\n",
MAJOR(dev_num), MINOR(dev_num));
return 0;
}
static void __exit demo_exit(void)
{
cdev_del(&demo_cdev);
unregister_chrdev_region(dev_num, 1);
pr_info("demo: unloaded\n");
}
module_init(demo_init);
module_exit(demo_exit);
MODULE_LICENSE("GPL");
我的小经验: 调试阶段建议用alloc_chrdev_region动态分配设备号。我以前图省事用静态分配,结果跟别的驱动冲突,查了半天才找到原因。动态分配省心多了。
这里有个关键点——file_operations结构体。它就像一张函数表,把用户空间的open/read/write调用,映射到你的驱动函数上。你想想看,用户程序调用read(fd, buf, 100),内核怎么知道该调用哪个函数?就是靠这张表。
2.2 platform总线——让设备和驱动“配对”
字符设备模型解决了“怎么操作设备”的问题,但没解决“怎么找到设备”的问题。在早期的Linux里,驱动代码里直接硬编码了硬件地址和中断号。比如:
// 老式写法,不推荐
#define MY_BASE_ADDR 0xFE200000
#define MY_IRQ_NUM 42
这种做法有什么问题?我遇到过最典型的情况:同一款芯片用在两个不同的机顶盒型号上,GPIO引脚分配不一样。结果呢?得维护两份驱动代码,改一个宏定义就得重新编译整个模块。
platform总线就是来解决这个问题的。它把设备和驱动解耦了。设备只管说“我有什么资源”,驱动只管说“我能操作什么设备”,剩下的配对工作交给总线。
来看一个platform驱动的骨架:
static struct resource demo_resources[] = {
{
.start = 0xFE200000,
.end = 0xFE200FFF,
.flags = IORESOURCE_MEM,
},
{
.start = 42,
.end = 42,
.flags = IORESOURCE_IRQ,
},
};
static struct platform_device demo_device = {
.name = "demo-platform",
.id = -1,
.num_resources = ARRAY_SIZE(demo_resources),
.resource = demo_resources,
};
static int demo_probe(struct platform_device *pdev)
{
struct resource *res;
void __iomem *base;
int irq;
// 从platform_device中获取资源
res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
base = devm_ioremap_resource(&pdev->dev, res);
irq = platform_get_irq(pdev, 0);
pr_info("demo: probed, base=%p, irq=%d\n", base, irq);
return 0;
}
static int demo_remove(struct platform_device *pdev)
{
pr_info("demo: removed\n");
return 0;
}
static struct platform_driver demo_driver = {
.probe = demo_probe,
.remove = demo_remove,
.driver = {
.name = "demo-platform",
.of_match_table = NULL, // 后面讲设备树时会用到
},
};
核心思想: platform驱动只关心probe和remove回调。设备来了,probe被调用,驱动初始化硬件;设备走了,remove被调用,驱动清理资源。就这么简单。
嗯,这里要注意——probe函数里一定要用devm_系列函数(比如devm_ioremap_resource)。为什么?因为devm_会自动管理资源释放。我以前在probe里手动申请内存,结果remove时忘了释放,模块卸载后内存泄漏。用devm_之后,再也没出过这种问题。
2.3 设备树基础——硬件描述的语言
platform总线虽然解耦了设备和驱动,但设备信息还是写在C代码里。每次硬件改版,都得改驱动源码重新编译。这显然不够灵活。
设备树(Device Tree)就是用来解决这个问题的。它用一种文本格式来描述硬件,驱动通过解析设备树来获取硬件信息。硬件变了?改设备树文件就行,驱动代码不用动。
一个典型的设备树节点长这样:
/dts-v1/;
/ {
model = "My STB Board";
compatible = "vendor,stb-v1";
soc {
compatible = "simple-bus";
#address-cells = <1>;
#size-cells = <1>;
ranges;
demo: demo@fe200000 {
compatible = "vendor,demo";
reg = <0xfe200000 0x1000>;
interrupts = <0 42 4>;
status = "okay";
};
};
};
我来解释一下关键字段:
| 字段 | 含义 | 我的理解 |
|---|---|---|
compatible |
匹配字符串,驱动用它来找设备 | 相当于设备的“身份证号” |
reg |
地址和大小,描述寄存器空间 | 告诉驱动硬件在哪、占多大地方 |
interrupts |
中断号、触发类型 | 硬件出事了怎么通知CPU |
status |
设备状态,okay或disabled | 调试时很有用,可以临时禁用设备 |
驱动这边怎么读取设备树信息?用of_系列函数:
static int demo_probe(struct platform_device *pdev)
{
struct device *dev = &pdev->dev;
struct device_node *np = dev->of_node;
const char *model;
u32 val;
// 读取字符串属性
of_property_read_string(np, "model", &model);
// 读取32位整数属性
of_property_read_u32(np, "some-value", &val);
// 检查属性是否存在
if (of_property_read_bool(np, "enable-feature")) {
// 启用某个特性
}
return 0;
}
// 在platform_driver中指定匹配表
static const struct of_device_id demo_of_match[] = {
{ .compatible = "vendor,demo" },
{ /* sentinel */ }
};
static struct platform_driver demo_driver = {
.probe = demo_probe,
.remove = demo_remove,
.driver = {
.name = "demo",
.of_match_table = demo_of_match,
},
};
我曾经踩过的坑: 设备树里的reg地址一定要和芯片手册一致。有一次我写机顶盒的I2C驱动,设备树里把寄存器基址写错了,结果驱动加载后读写全是0xFF。查了两天才发现是设备树的问题。所以,设备树写完后,一定要用dtc工具反编译检查一遍。
2.4 三者的关系——一张图说清楚
这三者的关系,我习惯这么理解:
- 设备树:描述硬件长什么样(有什么外设、地址多少、中断多少)
- platform总线:负责把设备树里的设备,和对应的驱动配对
- 字符设备模型:驱动找到设备后,通过它向用户空间提供操作接口
启动流程是这样的:
- 内核启动时,解析设备树,为每个节点创建
platform_device - platform总线遍历所有
platform_driver,通过compatible匹配 - 匹配成功,调用驱动的
probe函数 probe里注册字符设备,用户空间就能通过/dev/xxx访问了
调试小技巧: 在/sys/bus/platform/devices/目录下,可以看到所有platform设备。如果驱动没加载成功,先来这里看看设备有没有创建出来。这是我最常用的排查手段。
好了,这一章的内容就这些。说白了就是三句话:设备树描述硬件,platform总线配对,字符设备提供接口。下一章我们会把这些知识串起来,写一个完整的机顶盒外设驱动。到时候你就知道,这些基础有多重要了。