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接口 HAL层核心 设备实例池 uart_dev_t pool[UART_MAX] 操作接口表 uart_ops_t ops 配置结构体 uart_config_t cfg BSP层(硬件相关) 硬件描述符表 寄存器操作宏 中断服务函数 💡 关键设计原则: • 用户只操作句柄,不接触指针 • 硬件描述符放在BSP层,HAL层不直接依赖硬件

从这张图可以看出:

  • 用户代码通过句柄与HAL层交互,不直接操作指针
  • HAL层内部维护设备实例池,句柄就是实例池的索引
  • 每个设备实例包含配置结构体,配置可以按参数块分组
  • BSP层提供硬件描述符和寄存器操作,HAL层通过函数指针调用

总结一下:数据结构设计没有银弹。设备对象模型要分层,句柄要对外隐藏实现细节,配置结构体要按功能分组。这些原则看起来简单,但真正做到位了,你的HAL层移植起来会轻松很多。