4、设备驱动框架设计:驱动模型分层(HAL-Driver-Device)、字符设备与块设备模型、设备树(Device Tree)概念、驱动注册与回调机制

好,咱们今天聊聊设备驱动框架。说实话,这是嵌入式软件里最容易被低估的一块。很多人觉得驱动嘛,不就是读写寄存器?其实不然。驱动框架设计得好不好,直接决定了你后续的移植工作量、调试效率,甚至芯片能不能按时上市。

我个人习惯把驱动框架比作「乐高积木」。你得先搭好底座,然后每一块积木都能灵活插拔。这个底座,就是咱们要讲的驱动模型分层。

4.1 驱动模型分层:HAL-Driver-Device

先看这张分层图,我画了很多年了,每次给团队新人讲都会拿出来:

层级 职责 依赖关系
Device(设备层) 描述设备实例、设备属性、设备状态 依赖 Driver 层
Driver(驱动层) 实现操作函数集(open/read/write/ioctl) 依赖 HAL 层
HAL(硬件抽象层) 封装寄存器操作、平台相关代码 直接操作硬件

为什么要分三层?说白了,就是为了「解耦」。我在项目中遇到过最头疼的事:芯片换了一个版本,寄存器地址变了,结果整个驱动代码要重写。这就是没分层的后果。

HAL 层,我习惯叫它「硬件保姆」。它只做一件事:把寄存器的读写封装成函数。比如:

// hal_uart.h
void hal_uart_set_baudrate(uint32_t base_addr, uint32_t baud);
void hal_uart_send_byte(uint32_t base_addr, uint8_t data);
uint8_t hal_uart_recv_byte(uint32_t base_addr);

你看,这里没有任何业务逻辑,就是纯粹的寄存器操作。换芯片?只改 HAL 层就行。

Driver 层,这是核心。它调用 HAL 层的接口,实现具体的设备操作。比如 UART 驱动:

// driver_uart.c
static int uart_open(struct device *dev) {
    hal_uart_init(dev->base_addr, dev->config);
    return 0;
}

static int uart_write(struct device *dev, const uint8_t *buf, size_t len) {
    for (size_t i = 0; i < len; i++) {
        hal_uart_send_byte(dev->base_addr, buf[i]);
    }
    return len;
}

Device 层,就是具体的设备实例。比如板子上有两个 UART,每个 UART 就是一个 device 对象,包含基地址、中断号、波特率等配置。

关键原则:HAL 层不知道 Driver 层的存在,Driver 层不知道 Device 层的存在。数据流向是单向的:Device → Driver → HAL。

4.2 字符设备与块设备模型

嗯,这里要区分两个经典模型。你想想看,为什么 Linux 要把设备分成字符设备和块设备?

字符设备:数据流是串行的,一个字节一个字节地处理。典型代表:UART、SPI、I2C。操作方式就是 open/read/write/close。

块设备:数据以块为单位(通常是 512 字节或 4KB),支持随机访问。典型代表:NAND Flash、SD 卡、eMMC。

我在项目中遇到过最坑的事:有人把 SPI Flash 当字符设备驱动写,结果上层文件系统一挂载就崩溃。为什么?因为块设备需要支持缓冲、缓存、坏块管理,字符设备模型根本扛不住。

看个对比表:

特性 字符设备 块设备
数据单位 字节流 块(固定大小)
访问方式 顺序/随机 随机(支持 seek)
缓冲机制 无或简单 FIFO 有 page cache / buffer
典型接口 open/read/write/ioctl open/read/write/seek/ioctl
应用场景 传感器、串口、GPIO 存储设备、文件系统

我的建议:在通信芯片里,90% 的外设都是字符设备。但如果你要挂文件系统,一定要用块设备模型。别偷懒,否则后面调试会哭。

4.3 设备树(Device Tree)概念

设备树,说白了就是「硬件的配置文件」。以前我们怎么做?直接在代码里写死:

// 老式做法
#define UART0_BASE_ADDR  0x40001000
#define UART0_IRQ_NUM    33

换一个板子?改代码,重新编译。这太蠢了。设备树就是来解决这个问题的。

设备树用 .dts 文件描述硬件:

// uart.dts
uart0: uart@40001000 {
    compatible = "vendor,uart-v1";
    reg = <0x40001000 0x1000>;
    interrupts = <33>;
    clock-frequency = <16000000>;
    status = "okay";
};

驱动代码里,通过设备树 API 获取配置:

// 驱动代码
static int uart_probe(struct platform_device *pdev) {
    struct resource *res;
    res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    dev->base_addr = res->start;
    
    dev->irq_num = platform_get_irq(pdev, 0);
    // ...
}

我曾经踩过一个坑:设备树里 reg 属性写错了大小,结果驱动访问到了别人的地址空间,系统直接挂死。嗯,这里要注意:设备树的 reg 属性,第一个是基地址,第二个是地址范围大小,单位是字节。

避坑指南:设备树编译成 .dtb 后,一定要用 fdtdump 工具检查。我曾经因为 .dts 里少写了一个分号,编译通过了但生成的 .dtb 是坏的,查了三天才找到原因。

4.4 驱动注册与回调机制

驱动注册,说白了就是「告诉内核:我来了,我能管哪些设备」。回调机制,就是「内核有事找你,你响应一下」。

看一个典型的驱动注册流程:

// 驱动结构体
static struct platform_driver uart_driver = {
    .probe  = uart_probe,    // 设备匹配成功时调用
    .remove = uart_remove,   // 设备移除时调用
    .driver = {
        .name = "uart",
        .of_match_table = uart_of_match,  // 设备树匹配表
    },
};

// 模块初始化
static int __init uart_init(void) {
    return platform_driver_register(&uart_driver);
}

// 模块卸载
static void __exit uart_exit(void) {
    platform_driver_unregister(&uart_driver);
}

module_init(uart_init);
module_exit(uart_exit);

回调机制的核心就是 probe 函数。当内核发现设备树里有一个 compatible 为 "vendor,uart-v1" 的节点时,就会调用 uart_probe。你在 probe 里做什么?

  1. 解析设备树,获取硬件资源
  2. 分配设备结构体
  3. 初始化硬件(调用 HAL 层)
  4. 注册字符设备或块设备
  5. 创建 sysfs 节点(可选)

我个人的习惯是:probe 函数里只做「轻量级初始化」,把耗时的操作放到一个工作队列里。为什么?因为 probe 是在系统启动的早期调用的,你在这里卡太久,整个系统启动都会变慢。

看一个完整的回调链:

// 用户空间调用
fd = open("/dev/uart0", O_RDWR);
write(fd, "hello", 5);

// 内核空间回调
// VFS → 字符设备层 → uart_driver 的 write 回调
static ssize_t uart_write(struct file *file, const char __user *buf, 
                          size_t count, loff_t *ppos) {
    struct uart_device *dev = file->private_data;
    char kbuf[256];
    
    // 从用户空间拷贝数据
    if (copy_from_user(kbuf, buf, count))
        return -EFAULT;
    
    // 调用 HAL 层发送
    for (int i = 0; i < count; i++) {
        hal_uart_send_byte(dev->base_addr, kbuf[i]);
    }
    
    return count;
}

核心要点:驱动注册是「静态绑定」,回调机制是「动态响应」。注册告诉内核「我在这里」,回调告诉内核「我怎么做」。两者缺一不可。

最后说一句:驱动框架设计,本质上是在「灵活性」和「性能」之间找平衡。分层太多,性能下降;分层太少,移植困难。我个人经验是:对于通信芯片,三层模型(HAL-Driver-Device)是最优解。既保证了代码的可移植性,又不会引入过多的函数调用开销。

好,这一章就到这里。下一章咱们聊聊中断处理与底半部机制,那可是实时性的关键。