2、Linux内核驱动框架:字符设备驱动模型、platform总线、设备树基础

好,咱们进入第二章。这一章可以说是机顶盒驱动开发的基石。你想想看,不管你是写红外遥控、还是调HDMI输出,最终都绕不开这三个东西:字符设备、platform总线、设备树。

我刚开始接触机顶盒驱动时,也觉得很乱。一会儿说设备模型,一会儿说设备树,到底谁管谁?后来做多了才明白——这三者其实是层层递进的关系。今天我就把这块掰开揉碎了讲给你听。

2.1 字符设备驱动模型——最基础的驱动形态

先说说字符设备。说白了,就是按字节流读写的设备。键盘、鼠标、串口,包括机顶盒里的红外接收头,都属于字符设备。

在Linux里,字符设备通过cdev结构体来抽象。我习惯把驱动注册流程记成三步:

  1. 分配设备号——给设备一个身份证
  2. 初始化cdev——告诉内核你的操作函数在哪
  3. 添加到内核——让用户空间能找到它

来看个最简单的例子:

#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驱动只关心proberemove回调。设备来了,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总线:负责把设备树里的设备,和对应的驱动配对
  • 字符设备模型:驱动找到设备后,通过它向用户空间提供操作接口

启动流程是这样的:

  1. 内核启动时,解析设备树,为每个节点创建platform_device
  2. platform总线遍历所有platform_driver,通过compatible匹配
  3. 匹配成功,调用驱动的probe函数
  4. probe里注册字符设备,用户空间就能通过/dev/xxx访问了

调试小技巧:/sys/bus/platform/devices/目录下,可以看到所有platform设备。如果驱动没加载成功,先来这里看看设备有没有创建出来。这是我最常用的排查手段。

好了,这一章的内容就这些。说白了就是三句话:设备树描述硬件,platform总线配对,字符设备提供接口。下一章我们会把这些知识串起来,写一个完整的机顶盒外设驱动。到时候你就知道,这些基础有多重要了。