第三章 硬件抽象层(HAL)设计:让代码与硬件“解耦”

各位同学,咱们今天聊聊硬件抽象层。说白了,HAL 就是给上层应用和底层硬件之间加一层“翻译官”。

我刚开始做单片机开发那会儿,代码全是直接操作寄存器。换个芯片型号,好家伙,整个工程得重写。后来被折磨多了,才明白 HAL 的重要性。

3.1 HAL 的作用:为什么需要这层“皮”?

HAL 的核心价值就三个字:可移植性

你想想看,项目做到一半,客户突然说要换 MCU。如果没有 HAL,你所有外设驱动都得重写。有了 HAL,你只需要改底层实现,上层逻辑纹丝不动。

具体来说,HAL 干这几件事:

  • 屏蔽硬件差异:不同芯片的 GPIO 寄存器地址、配置方式都不一样,HAL 把它们统一成一套 API
  • 提供标准接口:比如 GPIO_WritePin(),不管底层是 STM32 还是 GD32,调用方式一样
  • 降低耦合度:业务逻辑不直接操作寄存器,而是调用 HAL 函数

我的经验:曾经有个项目,从 STM32F103 换到 GD32F303,因为前期 HAL 设计得好,只改了底层 3 个文件,上层 20000 多行代码一行没动。省下来的时间,够喝好几杯咖啡了。

3.2 分层原则:怎么搭这层“架子”?

HAL 的分层,我习惯分成三层:

层级 名称 职责 示例
第1层 寄存器层 直接操作硬件寄存器 *(uint32_t*)0x40020000 = 0x01
第2层 外设驱动层 封装外设基本操作 GPIO_Init(), UART_SendByte()
第3层 接口抽象层 定义统一接口函数指针 HAL_UART_Write()

嗯,这里要注意:不要试图一层搞定所有事。我见过有人把 HAL 写成“万能层”,结果代码比直接操作寄存器还复杂。

避坑指南:我曾经在一个项目中,把 SPI 和 I2C 的 HAL 接口设计得一模一样。结果发现 SPI 是全双工,I2C 是半双工,强行统一反而让代码变得别扭。后来我学乖了——接口可以相似,但不要强求完全一致。

3.3 GPIO 的 HAL 封装示例

GPIO 是最简单的,但也是最容易出问题的。咱们直接看代码:

// hal_gpio.h
#ifndef __HAL_GPIO_H
#define __HAL_GPIO_H

#include "stdint.h"

// GPIO 方向枚举
typedef enum {
    GPIO_DIR_INPUT  = 0,
    GPIO_DIR_OUTPUT = 1
} GPIO_Direction_t;

// GPIO 电平枚举
typedef enum {
    GPIO_LEVEL_LOW  = 0,
    GPIO_LEVEL_HIGH = 1
} GPIO_Level_t;

// GPIO 初始化结构体
typedef struct {
    uint32_t        pin;        // 引脚号
    GPIO_Direction_t direction; // 方向
    GPIO_Level_t    initLevel;  // 初始电平
} GPIO_Config_t;

// HAL 接口函数
void    GPIO_Init(GPIO_Config_t* config);
void    GPIO_WritePin(uint32_t pin, GPIO_Level_t level);
GPIO_Level_t GPIO_ReadPin(uint32_t pin);
void    GPIO_TogglePin(uint32_t pin);

#endif

你看,这个接口跟具体芯片无关。换芯片时,只需要重新实现 GPIO_Init() 这些函数就行。

注意:GPIO 的引脚号定义,我建议用宏定义统一管理,别在代码里写死数字。比如 #define LED_PIN 5,这样改起来方便。

3.4 UART 的 HAL 封装示例

UART 比 GPIO 复杂一些,涉及到波特率、数据位、停止位等参数。我的封装思路是这样的:

// hal_uart.h
#ifndef __HAL_UART_H
#define __HAL_UART_H

#include "stdint.h"

// UART 句柄结构体
typedef struct {
    uint32_t    baudRate;   // 波特率
    uint8_t     dataBits;   // 数据位:5/6/7/8
    uint8_t     stopBits;   // 停止位:1/2
    uint8_t     parity;     // 校验位:0=无, 1=奇, 2=偶
    void*       privateData; // 底层私有数据(指向具体芯片的句柄)
} UART_Handle_t;

// HAL 接口函数
void    UART_Init(UART_Handle_t* handle);
void    UART_SendByte(UART_Handle_t* handle, uint8_t data);
uint8_t UART_ReceiveByte(UART_Handle_t* handle);
void    UART_SendBuffer(UART_Handle_t* handle, uint8_t* buffer, uint32_t length);
uint32_t UART_ReceiveBuffer(UART_Handle_t* handle, uint8_t* buffer, uint32_t maxLength);

#endif

这里有个小技巧:privateData 指针。它指向底层芯片的 UART 句柄,这样上层代码完全不用关心底层细节。

我遇到过一个问题:不同芯片的 UART 接收中断处理方式不一样。后来我在 HAL 里加了一个回调函数注册机制,让用户自己决定中断怎么处理。嗯,这样灵活多了。

3.5 SPI 的 HAL 封装示例

SPI 的特点是速度快、全双工。封装时要注意区分主从模式:

// hal_spi.h
#ifndef __HAL_SPI_H
#define __HAL_SPI_H

#include "stdint.h"

// SPI 模式
typedef enum {
    SPI_MODE_MASTER = 0,
    SPI_MODE_SLAVE  = 1
} SPI_Mode_t;

// SPI 时钟极性/相位
typedef enum {
    SPI_CPOL_0_CPHA_0 = 0,  // 模式0
    SPI_CPOL_0_CPHA_1 = 1,  // 模式1
    SPI_CPOL_1_CPHA_0 = 2,  // 模式2
    SPI_CPOL_1_CPHA_1 = 3   // 模式3
} SPI_ModeConfig_t;

// SPI 句柄
typedef struct {
    SPI_Mode_t       mode;
    SPI_ModeConfig_t config;
    uint32_t         speed;  // 时钟频率,单位 Hz
    void*            privateData;
} SPI_Handle_t;

// HAL 接口
void    SPI_Init(SPI_Handle_t* handle);
uint8_t SPI_TransferByte(SPI_Handle_t* handle, uint8_t data);
void    SPI_TransferBuffer(SPI_Handle_t* handle, uint8_t* txBuf, uint8_t* rxBuf, uint32_t length);

#endif

我的经验:SPI 的时钟极性配置最容易出错。我曾经在一个项目中,因为 CPOL 和 CPHA 搞反了,调试了整整两天。后来我养成了一个习惯——每次初始化 SPI 后,先用逻辑分析仪抓一下波形,确认时序对不对。

3.6 I2C 的 HAL 封装示例

I2C 比 SPI 更复杂,因为它有地址、有应答机制。封装时要考虑多主机场景:

// hal_i2c.h
#ifndef __HAL_I2C_H
#define __HAL_I2C_H

#include "stdint.h"

// I2C 状态
typedef enum {
    I2C_OK       = 0,
    I2C_ERROR    = 1,
    I2C_BUSY     = 2,
    I2C_TIMEOUT  = 3
} I2C_Status_t;

// I2C 句柄
typedef struct {
    uint32_t    speed;          // 标准模式100k,快速模式400k
    uint8_t     ownAddress;     // 本机地址(从机模式用)
    void*       privateData;
} I2C_Handle_t;

// HAL 接口
I2C_Status_t I2C_Init(I2C_Handle_t* handle);
I2C_Status_t I2C_MasterWrite(I2C_Handle_t* handle, uint8_t slaveAddr, uint8_t* data, uint32_t length);
I2C_Status_t I2C_MasterRead(I2C_Handle_t* handle, uint8_t slaveAddr, uint8_t* buffer, uint32_t length);
I2C_Status_t I2C_MasterWriteRead(I2C_Handle_t* handle, uint8_t slaveAddr, uint8_t* txData, uint32_t txLen, uint8_t* rxData, uint32_t rxLen);

#endif

你想想看,I2C 的 WriteRead 函数为什么重要?因为很多传感器都是先写寄存器地址,再读数据。如果分开调用,中间可能会被其他任务打断。

避坑指南:我曾经在 I2C 通信中遇到过“总线锁死”的问题。原因是某个从机没有释放 SCL 线。后来我在 HAL 里加了一个超时机制,超过 10ms 就强制复位总线。这个机制救了我好几次。

3.7 HAL 设计的最佳实践总结

说了这么多,我总结几条经验:

  1. 接口要稳定:HAL 接口一旦确定,尽量不要改。上层代码依赖它,改一次牵一发动全身。
  2. 错误处理要统一:所有 HAL 函数返回错误码,别有的返回 int,有的返回 void。
  3. 支持多实例:比如两个 UART 同时工作,HAL 要能区分。用句柄结构体实现。
  4. 不要过度抽象:有些芯片特有的功能(比如 DMA 与 UART 的联动),该暴露就暴露,别硬塞进 HAL 里。

最后说一句:HAL 设计没有标准答案。每个项目都有自己的特点。我的建议是——先写一个最小可用的 HAL,然后在实际项目中不断迭代。别一开始就想设计一个“万能”的 HAL,那往往是过度设计的开始。

好了,这一章就到这里。下一章咱们聊聊“中间件层设计”,看看怎么把 RTOS、文件系统这些东西集成进来。