4、HAL层数据结构设计:设备对象模型、句柄与指针的选择、配置结构体设计模式
数据结构设计,说白了就是HAL层的骨架。骨架搭歪了,后面写再多代码也是白搭。我见过太多项目,前期图省事,随便定义几个结构体就开干,结果到了移植阶段,改得想哭。
这一章,咱们就聊聊设备对象模型怎么建、句柄和指针怎么选、配置结构体有哪些套路。都是我在实际项目中踩过的坑,希望能帮你少走弯路。
4.1 设备对象模型:从抽象到具体
先问个问题:一个UART设备,在代码里应该长什么样?
很多人第一反应是:定义一个结构体,里面放寄存器基地址、波特率、数据位这些参数。没错,但这只是最基础的。真正的设备对象模型,要能支撑跨平台移植。
我个人习惯把设备对象分成三层:
| 层次 | 名称 | 职责 | 生命周期 |
|---|---|---|---|
| L1 | 设备描述符 | 硬件资源信息(基址、IRQ、DMA通道) | 编译时确定 |
| L2 | 设备实例 | 运行时状态(缓冲区、回调、配置) | 运行时创建/销毁 |
| L3 | 设备操作接口 | 函数指针表(open/read/write/ioctl) | 全局静态 |
为什么要分三层?
举个例子。你在STM32上写了个UART驱动,后来要移植到GD32上。硬件资源变了——基地址不同、中断号不同——但操作逻辑基本一样。如果你把硬件信息和运行时状态混在一起,移植时就得把整个结构体翻一遍,很容易改漏。
我建议这样设计:
/* L1: 设备描述符 - 硬件相关,放在BSP层 */
typedef struct {
uint32_t base_addr;
uint32_t irq_num;
uint32_t dma_tx_ch;
uint32_t dma_rx_ch;
} uart_hw_desc_t;
/* L2: 设备实例 - 运行时状态,放在HAL层 */
typedef struct {
const uart_hw_desc_t *hw; /* 指向L1描述符 */
uart_config_t config; /* 当前配置 */
uint8_t *tx_buf;
uint32_t tx_len;
volatile uint32_t status; /* 状态标志 */
void *user_data; /* 用户私有数据 */
} uart_dev_t;
/* L3: 操作接口 - 函数指针表 */
typedef struct {
int32_t (*init)(uart_dev_t *dev);
int32_t (*deinit)(uart_dev_t *dev);
int32_t (*read)(uart_dev_t *dev, uint8_t *buf, uint32_t len);
int32_t (*write)(uart_dev_t *dev, const uint8_t *buf, uint32_t len);
int32_t (*ioctl)(uart_dev_t *dev, uint32_t cmd, void *arg);
} uart_ops_t;
核心思想:硬件描述符是const的,放在Flash里;设备实例是动态的,放在RAM里。这样移植时只需要改BSP层的描述符表,HAL层的逻辑代码一行都不用动。
4.2 句柄 vs 指针:一个老生常谈的问题
嗯,这里要注意。句柄和指针的选择,在嵌入式圈子里争论了很久。我的观点很明确:对外暴露句柄,内部使用指针。
为什么?
指针太直接了。你把一个结构体指针传给用户,用户就能随意修改里面的内容。今天改个状态位,明天改个缓冲区地址,出了bug你根本查不到是谁干的。
句柄就不一样。它是个不透明的标识符,用户拿到的只是一个整数,想操作设备必须通过HAL提供的接口。这就相当于给设备加了一把锁。
我常用的句柄实现方式:
/* 句柄类型定义 */
typedef uint32_t hal_handle_t;
/* 句柄到指针的转换宏 */
#define HANDLE_TO_PTR(handle, pool, type) \
((handle == HAL_INVALID_HANDLE) ? NULL : \
&pool[handle - 1])
/* 句柄分配示例 */
hal_handle_t uart_open(uint32_t dev_id) {
for (int i = 0; i < UART_MAX_DEV; i++) {
if (uart_pool[i].state == DEV_STATE_FREE) {
uart_pool[i].state = DEV_STATE_OPEN;
return (hal_handle_t)(i + 1); /* 句柄从1开始 */
}
}
return HAL_INVALID_HANDLE;
}
小技巧:句柄值从1开始,0保留给HAL_INVALID_HANDLE。这样初始化后的全局变量默认就是无效句柄,省去了手动赋值的麻烦。
我曾经在一个项目中吃过亏。当时图省事,直接暴露了设备结构体指针。结果有个同事在中断服务函数里直接修改了设备状态,导致主循环里的状态机跑飞了。查了两天才找到原因。从那以后,我所有HAL接口都强制使用句柄。
4.3 配置结构体设计模式
配置结构体,看起来简单,但设计不好真的很坑。我总结了三种常用模式:
4.3.1 全量配置模式
最直接的方式。把所有配置项放在一个结构体里,一次性设置。
typedef struct {
uint32_t baudrate;
uint8_t data_bits; /* 5/6/7/8 */
uint8_t stop_bits; /* 1/2 */
uint8_t parity; /* NONE/ODD/EVEN */
uint8_t flow_ctrl; /* NONE/RTS/CTS/RTSCTS */
uint8_t mode; /* TX/RX/TXRX */
uint32_t tx_buf_size;
uint32_t rx_buf_size;
} uart_config_t;
优点:简单明了,一次调用就能完成所有配置。
缺点:如果只改一个参数,也得传整个结构体。而且结构体变大后,栈开销也大。
4.3.2 增量配置模式
每次只设置一个参数,用set/get接口。
int32_t uart_set_baudrate(uart_dev_t *dev, uint32_t baudrate);
int32_t uart_set_data_bits(uart_dev_t *dev, uint8_t data_bits);
int32_t uart_set_parity(uart_dev_t *dev, uint8_t parity);
优点:灵活,改一个参数不影响其他。
缺点:接口数量爆炸,每个参数都要写一个函数。而且如果配置之间有依赖关系(比如改数据位后要重新计算校验),容易出问题。
4.3.3 参数块模式(我推荐)
把配置项按功能分组,每组一个子结构体。
typedef struct {
uint32_t baudrate;
uint8_t data_bits;
uint8_t stop_bits;
uint8_t parity;
} uart_baud_config_t; /* 波特率相关 */
typedef struct {
uint8_t flow_ctrl;
uint8_t mode;
} uart_mode_config_t; /* 模式相关 */
typedef struct {
uint32_t tx_buf_size;
uint32_t rx_buf_size;
} uart_buf_config_t; /* 缓冲区相关 */
typedef struct {
uart_baud_config_t baud;
uart_mode_config_t mode;
uart_buf_config_t buf;
} uart_config_t; /* 总配置 */
注意:分组不要太细。我见过有人把每个寄存器位都拆成一个配置项,结果一个UART配置结构体嵌套了七八层。适可而止,分组到功能级别就够了。
参数块模式的好处是:
- 改波特率时只传baud子结构体,栈开销小
- 配置之间有依赖关系时,可以在子结构体的setter里做校验
- 新增功能时,只需要加一个新的子结构体,不影响现有代码
4.4 设备对象模型与配置结构体的关系
最后,我用一张图来总结设备对象模型、句柄、配置结构体之间的关系。
从这张图可以看出:
- 用户代码通过句柄与HAL层交互,不直接操作指针
- HAL层内部维护设备实例池,句柄就是实例池的索引
- 每个设备实例包含配置结构体,配置可以按参数块分组
- BSP层提供硬件描述符和寄存器操作,HAL层通过函数指针调用
总结一下:数据结构设计没有银弹。设备对象模型要分层,句柄要对外隐藏实现细节,配置结构体要按功能分组。这些原则看起来简单,但真正做到位了,你的HAL层移植起来会轻松很多。