4. 驱动封装设计模式:面向对象思想在C语言中的应用
驱动封装,说白了就是让硬件操作变得“优雅”。
我早年做项目时,代码里全是 *(volatile uint32_t *)0x40021000 = 0x01 这种写法。改一个引脚,得翻遍整个工程。后来我意识到——C语言虽然面向过程,但完全可以写出面向对象的味道。
4.1 面向对象思想在C语言中的落地
面向对象的三大特征:封装、继承、多态。在C语言里,我们靠结构体和函数指针来实现。
核心思路:用结构体装数据,用函数指针装行为。
4.1.1 封装:结构体 + 操作函数
把外设的寄存器地址、配置参数打包成一个结构体。再配一组操作函数,只暴露接口,隐藏实现。
// gpio_device.h
typedef struct {
uint32_t base_addr; // 寄存器基址
uint8_t pin; // 引脚号
uint8_t mode; // 输入/输出模式
} gpio_device_t;
void gpio_init(gpio_device_t *dev);
void gpio_set(gpio_device_t *dev, uint8_t level);
uint8_t gpio_get(gpio_device_t *dev);
用户只需要关心 gpio_device_t 这个“对象”,不需要知道寄存器怎么配。我在一个工业控制项目里用过这种模式,后来换MCU平台,只改了底层实现,上层逻辑一行没动。
4.1.2 继承:结构体嵌套
C语言没有继承关键字,但可以用结构体嵌套模拟。基类放公共字段,子类追加私有字段。
// 基类:通用外设
typedef struct {
uint32_t base_addr;
void (*init)(void *self);
} device_t;
// 子类:UART设备
typedef struct {
device_t super; // 继承基类
uint32_t baudrate; // 私有属性
uint8_t data_bits;
} uart_device_t;
使用时,把子类指针强转为基类指针,就能调用统一接口。嗯,这里要注意:内存布局必须一致,基类字段要放在子类最前面。
4.1.3 多态:函数指针表(虚函数表)
这才是C语言驱动封装的精髓。
每个驱动实例持有一个函数指针表,类似C++的虚函数表(vtable)。不同硬件实现,挂接不同的函数指针。
// 虚函数表结构
typedef struct {
void (*init)(void *self);
void (*write)(void *self, uint8_t *data, uint32_t len);
void (*read)(void *self, uint8_t *data, uint32_t len);
} driver_ops_t;
// 驱动对象
typedef struct {
driver_ops_t *ops; // 指向虚函数表
void *hw_ctx; // 硬件上下文
} driver_t;
// 调用方式
void driver_write(driver_t *drv, uint8_t *data, uint32_t len) {
drv->ops->write(drv->hw_ctx, data, len);
}
避坑指南:我曾经在函数指针表里漏了同步锁,结果多任务环境下两个任务同时调用write,导致数据错乱。后来我在每个操作函数入口加了互斥锁,问题解决。
4.2 分层驱动模型
驱动不能平铺直叙,要分层。我习惯分三层:
| 层次 | 职责 | 示例 |
|---|---|---|
| 硬件抽象层(HAL) | 直接操作寄存器,最底层 | 读写GPIO、配置定时器 |
| 驱动层(Driver) | 封装外设功能,提供API | UART发送、I2C读写 |
| 服务层(Service) | 业务逻辑,与硬件无关 | 数据解析、协议栈 |
为什么这么分?
你想想看,如果业务代码里直接调寄存器,换一个MCU就得重写全部。分层之后,HAL层换掉,驱动层微调,服务层完全不动。
注意:分层不是越多越好。我在一个项目里见过7层驱动,调用链太长,性能损耗严重。嵌入式系统资源有限,3层足够。
4.3 设备树思想
设备树(Device Tree)最早来自Linux,用来描述硬件拓扑。在裸机或RTOS里,我们可以借鉴它的思想:用数据结构描述硬件资源,而不是硬编码。
4.3.1 设备树的核心概念
- 节点(Node):代表一个硬件设备,比如UART、GPIO、SPI。
- 属性(Property):描述设备的参数,比如基地址、中断号、时钟频率。
- 兼容性(Compatible):标识设备类型,用于匹配驱动。
4.3.2 裸机下的简化实现
我们不需要解析DTS文件,直接用C结构体定义设备表。
// 设备节点
typedef struct {
const char *name; // 设备名
uint32_t reg_base; // 寄存器基址
uint32_t irq_num; // 中断号
uint32_t clock_freq; // 时钟频率
void *priv; // 私有数据
} dev_node_t;
// 设备树表
const dev_node_t device_tree[] = {
{"uart0", 0x40001000, 33, 16000000, NULL},
{"gpio0", 0x40002000, 40, 0, NULL},
{"spi0", 0x40003000, 45, 8000000, NULL},
};
驱动初始化时,遍历设备树,根据名字或兼容性找到对应节点,提取参数。
好处:硬件配置集中管理,换板子只改设备树表,驱动代码不用动。我在一个多产品线项目里用了这个方案,维护成本降低了60%。
4.4 知识体系总览
下面这张图把本章的核心逻辑串起来了:
4.5 实战建议
- 从简单开始:先封装一个GPIO驱动,用函数指针表实现高低电平控制。跑通了再扩展到UART、SPI。
- 设备表先行:写驱动前,先把硬件资源整理成设备树表。我习惯用Excel管理,然后转成C数组。
- 接口要稳定:驱动API一旦发布,不要轻易改。我吃过亏,改了一个函数签名,导致5个模块跟着改。
- 性能要权衡:函数指针调用比直接函数调用多几条指令。对中断频繁的场景,可以考虑宏展开或内联。
小技巧:调试驱动时,可以在函数指针表里加一个“钩子”函数,用来打印调用日志。上线前再移除,不影响性能。
驱动封装不是炫技,是为了让代码活得更久。你想想看,一个产品做三五年,换两次芯片,如果驱动架构设计得好,每次迁移都是改配置表,而不是重写代码。这才是嵌入式工程师该有的远见。