第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 三者的协作关系

这三者不是孤立的,它们是一条完整的链路:

  1. 设备树描述硬件资源(地址、中断、时钟等)
  2. 内核启动时解析设备树,为每个compatible匹配的节点创建platform_device
  3. platform_driver通过of_match_table匹配到对应的platform_device,调用probe()
  4. 在probe()里,驱动获取资源、初始化硬件,然后注册字符设备(通过cdev或miscdevice)
  5. 用户空间通过/dev/xxx设备节点,使用file_operations提供的接口操作硬件

下面这张图把整个流程串起来了,我画图时习惯把关键函数和数据结构都标上,方便调试时对照。

Linux内核驱动框架协作关系图 设备树 (.dts/.dtb) compatible = "vendor,spi" 内核OF解析 of_platform_populate() platform_device 资源: reg, irq, clk platform_driver .probe() → 获取资源 → 初始化硬件 .of_match_table → 匹配compatible 字符设备注册 cdev_init() + cdev_add() /dev/spidev0.0 用户空间访问入口 用户空间应用 open/read/write/ioctl 关键匹配机制 compatible → of_match_table name → platform_device.name

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控制器。到时候你会发现,理解了这套框架,写任何外设驱动都只是"填空"而已。