3. 模块化设计思想

做嵌入式开发久了,你会发现一个规律:业务逻辑往往很稳定,但底层硬件总在变。换一颗MCU、换一个外设型号,甚至只是PCB改版,都可能让之前的代码大面积重写。怎么破?硬件抽象层(HAL)就是用来解决这个问题的。

说白了,HAL就是在硬件寄存器和你的业务代码之间,加一层“翻译官”。业务代码只管调用接口,不用关心底层是GPIO还是I2C,也不用管芯片是STM32还是GD32。我最早接触这个概念是在一个量产项目上——客户临时要求换主控,当时没有HAL,改代码改到怀疑人生。从那以后,我每个项目都会先搭好HAL层。

3.1 为什么要分离硬件与业务逻辑?

先看一个反面例子。很多新手写代码是这样的:

// 直接操作寄存器
void led_on(void) {
    GPIOA->BSRR = (1 << 5);  // PA5 置高
}

void delay_ms(uint32_t ms) {
    for(uint32_t i = 0; i < ms * 1000; i++) {
        __NOP();
    }
}

void blink_task(void) {
    while(1) {
        GPIOA->BSRR = (1 << 5);
        delay_ms(500);
        GPIOA->BRR = (1 << 5);
        delay_ms(500);
    }
}

这段代码有什么问题?业务逻辑和硬件操作完全耦合在一起。如果换一个MCU,GPIO寄存器地址变了,延时函数也要重写,整个blink_task都得改。更麻烦的是,测试的时候没法在PC上模拟——你总不能跑个寄存器操作吧?

分离的好处很明显:

  • 可移植性:换硬件平台时,只改HAL层,业务代码不动
  • 可测试性:业务逻辑可以在PC上单元测试,Mock掉HAL接口就行
  • 可维护性:硬件驱动出问题,定位范围缩小到HAL层
  • 团队协作:硬件工程师和软件工程师可以并行开发

核心原则:业务代码永远不要直接看到寄存器地址、位操作、外设中断标志。这些都应该封装在HAL内部。

3.2 HAL接口规范:怎么设计才算“好”?

我见过很多团队自己写的HAL,接口五花八门。有的把寄存器直接暴露出来,有的接口参数多到十几个。我个人习惯遵循几条硬性规范:

3.2.1 接口命名规范

类别 命名格式 示例
初始化/反初始化 HAL_<模块>_Init/DeInit HAL_UART_Init
收发数据 HAL_<模块>_Transmit/Receive HAL_SPI_Transmit
控制命令 HAL_<模块>_Control HAL_GPIO_Control
状态查询 HAL_<模块>_GetStatus HAL_ADC_GetStatus
中断回调 HAL_<模块>_Callback HAL_UART_RxCallback

命名要自解释。看到函数名就知道它干什么,不需要翻文档。我曾经在一个项目里看到过 uart_send_data_byte 和 uart_tx_byte 同时存在,后来发现是一个人写的两个版本……这种混乱一定要避免。

3.2.2 参数规范

  • 参数数量不超过4个。超过4个,要么用结构体传参,要么重新设计接口
  • 使用枚举代替魔数。比如 HAL_OK、HAL_ERROR、HAL_BUSY,而不是返回0、-1、1
  • 所有接口返回统一错误码。我习惯用 HAL_StatusTypeDef:
typedef enum {
    HAL_OK       = 0x00U,
    HAL_ERROR    = 0x01U,
    HAL_BUSY     = 0x02U,
    HAL_TIMEOUT  = 0x03U
} HAL_StatusTypeDef;

3.2.3 分层调用规范

HAL内部可以再分两层:

  1. 底层驱动层:直接操作寄存器,处理中断,管理DMA。这部分和MCU强相关。
  2. 抽象接口层:封装底层驱动,提供统一API。这部分是业务代码看到的。

举个例子,UART发送:

// 底层驱动层(芯片相关)
static void UART_HardwareInit(UART_HandleTypeDef *huart) {
    // 配置波特率寄存器、数据位、停止位
    huart->Instance->BRR = 0x1A0;
    huart->Instance->CR1 |= USART_CR1_TE;
}

// 抽象接口层(统一API)
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, 
                                     uint8_t *pData, uint16_t Size) {
    // 检查参数
    if(pData == NULL || Size == 0) return HAL_ERROR;
    // 调用底层发送
    for(uint16_t i = 0; i < Size; i++) {
        while(!(huart->Instance->SR & USART_SR_TXE));
        huart->Instance->DR = pData[i];
    }
    return HAL_OK;
}

我的经验:抽象接口层不要做太多“聪明”的事。比如自动重发、协议解析这些,应该放在更上层的中间件层。HAL只负责“发出去”和“收进来”,不负责“发对了没有”。

3.3 典型实现案例:GPIO和UART的HAL设计

光说不练假把式。我们拿两个最常用的模块——GPIO和UART,看看HAL具体怎么落地。

3.3.1 GPIO HAL设计

GPIO看似简单,但坑不少。不同芯片的GPIO模式定义不一样,有的叫 GPIO_Mode_Out_PP,有的叫 GPIO_MODE_OUTPUT_PP。HAL要做的就是统一这些差异。

// 统一的GPIO模式枚举
typedef enum {
    HAL_GPIO_MODE_INPUT      = 0x00,
    HAL_GPIO_MODE_OUTPUT_PP  = 0x01,  // 推挽输出
    HAL_GPIO_MODE_OUTPUT_OD  = 0x02,  // 开漏输出
    HAL_GPIO_MODE_AF_PP      = 0x03,  // 复用推挽
    HAL_GPIO_MODE_AF_OD      = 0x04,  // 复用开漏
    HAL_GPIO_MODE_ANALOG     = 0x05
} HAL_GPIO_ModeTypeDef;

// 统一的初始化结构体
typedef struct {
    uint16_t            Pin;       // 引脚号
    HAL_GPIO_ModeTypeDef Mode;     // 模式
    uint8_t             Pull;      // 上下拉
    uint8_t             Speed;     // 速度
} HAL_GPIO_InitTypeDef;

// 接口声明
HAL_StatusTypeDef HAL_GPIO_Init(GPIO_TypeDef *GPIOx, HAL_GPIO_InitTypeDef *GPIO_Init);
void HAL_GPIO_WritePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin, GPIO_PinState PinState);
uint8_t HAL_GPIO_ReadPin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin);

业务代码里用起来是这样的:

// 业务代码完全不关心底层寄存器
HAL_GPIO_InitTypeDef led_cfg = {
    .Pin = GPIO_PIN_5,
    .Mode = HAL_GPIO_MODE_OUTPUT_PP,
    .Pull = GPIO_NOPULL,
    .Speed = GPIO_SPEED_FREQ_LOW
};
HAL_GPIO_Init(GPIOA, &led_cfg);

// 控制LED
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);

换芯片?只需要重写 HAL_GPIO_Init 内部的寄存器操作,业务代码一行都不用改。

3.3.2 UART HAL设计

UART比GPIO复杂一些,涉及到中断、DMA、超时处理。我建议把UART的HAL设计成同步和异步两套接口:

// 同步接口(阻塞)
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, 
                                     uint8_t *pData, uint16_t Size, uint32_t Timeout);
HAL_StatusTypeDef HAL_UART_Receive(UART_HandleTypeDef *huart, 
                                    uint8_t *pData, uint16_t Size, uint32_t Timeout);

// 异步接口(中断/DMA)
HAL_StatusTypeDef HAL_UART_Transmit_IT(UART_HandleTypeDef *huart, 
                                        uint8_t *pData, uint16_t Size);
HAL_StatusTypeDef HAL_UART_Receive_IT(UART_HandleTypeDef *huart, 
                                       uint8_t *pData, uint16_t Size);

// 中断回调(由HAL内部调用,业务代码重写)
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart);
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart);

这里有个关键点:中断回调函数要放在HAL层,而不是业务层。为什么?因为中断处理涉及硬件标志清除、数据搬运等操作,这些应该由HAL完成。业务代码只需要在回调里处理数据。

我曾经踩过的坑:在一个多串口项目里,我把中断回调直接写在业务代码中,每个串口的中断处理逻辑都不同。后来要加一个串口,发现中断优先级、嵌套处理全乱套了。正确的做法是:HAL层统一管理中断入口,通过句柄分发到不同的回调实例。

3.4 HAL设计中的避坑指南

做HAL设计,有几个坑我几乎在每个项目里都见过:

  1. 过度抽象:把HAL做成“万能接口”,参数列表长得吓人。比如一个GPIO初始化接口传10个参数,其中8个根本用不到。记住:80%的场景用80%的简化接口,剩下的20%特殊场景可以单独加接口。
  2. 性能损耗:HAL层每调用一次都做大量参数检查、状态判断。在高速数据流场景(比如SPI刷屏),这种损耗不可接受。我的做法是:提供 _Fast 后缀的接口,跳过安全检查,直接操作寄存器。
  3. 状态管理混乱:HAL内部维护的状态机(比如UART的收发状态)要和业务代码的状态机解耦。不要出现“HAL正在发送,业务代码又调了一次发送”这种冲突。用 HAL_BUSY 返回值来通知调用方。

3.5 知识结构图:HAL设计全景

下面这张图总结了HAL设计的核心要素和分层关系:

硬件抽象层(HAL)设计全景 业务逻辑层(应用代码) HAL抽象接口层(统一API) HAL_GPIO_Init / HAL_UART_Transmit / HAL_SPI_Receive HAL底层驱动层(寄存器操作) GPIO->BSRR / USART->DR / SPI->DR 硬件层(MCU / 外设) 接口规范 命名统一 / 参数≤4个 返回枚举错误码 / 自解释 设计原则 不过度抽象 / 不牺牲性能 状态机解耦 / 分层清晰 避坑指南 万能接口 / 性能损耗 状态冲突 / 回调混乱

从这张图可以看得很清楚:业务逻辑只和HAL抽象接口层对话,底层驱动怎么实现、寄存器怎么操作,业务代码一概不管。这就是“隔离”的精髓。

3.6 小结

HAL设计不是什么高深的技术,但它体现了一个嵌入式工程师的架构意识。好的HAL设计,能让你的代码在3年后、5年后、甚至换了3代MCU后,依然能跑起来。我见过太多项目因为一开始没做HAL,后期维护成本高到直接重写。

记住三个关键词:统一接口、分层隔离、适度抽象。做到这三点,你的HAL就算合格了。