HAL设计原则:分层设计、接口与实现分离、可移植性、可测试性

好,咱们直接进入正题。HAL 这东西,说白了就是一层「翻译官」。它把芯片厂商那些千奇百怪的寄存器操作,翻译成你上层应用看得懂、用得顺的 API。但翻译官也有好坏之分。怎么才算一个好的 HAL?我总结了四个核心原则。

这四个原则不是拍脑袋想出来的。是我在好几个项目里踩坑踩出来的。你想想看,一个项目做两年,中间换一次芯片方案,如果 HAL 设计得烂,那基本等于重写。嗯,咱们一个一个说。

1. 分层设计:别让上层知道底层在干嘛

分层设计,核心就一句话:每一层只关心自己的事。

我见过最糟糕的代码,是应用层直接操作 GPIO 寄存器。比如这样:

// 糟糕的设计:应用层直接操作寄存器
#define GPIO_BASE 0x40021000
*(volatile uint32_t *)(GPIO_BASE + 0x00) |= (1 << 5);  // 直接置位GPIO

这种代码,换个芯片就废了。正确的做法是分层:

层次 职责 依赖
应用层 业务逻辑、状态机、协议栈 只调用 HAL API
HAL 层 抽象硬件功能,提供统一接口 调用 LL 层
LL 层 直接操作寄存器,处理时序 依赖芯片手册

我个人习惯,在 HAL 层和 LL 层之间再加一个 设备描述结构体。比如:

// HAL 层:用户看到的是这个
typedef struct {
    void (*init)(void);
    void (*write)(uint8_t data);
    uint8_t (*read)(void);
} UART_Device_t;

// LL 层:具体芯片实现
static void uart_stm32_init(void) {
    // 操作 STM32 的 USART 寄存器
    USART1->CR1 |= USART_CR1_UE;
}

static void uart_imx_init(void) {
    // 操作 i.MX 的 UART 寄存器
    UART1->UCR1 |= UCR1_UARTEN;
}

// 设备实例
UART_Device_t uart_device = {
    .init = uart_stm32_init,   // 换芯片时只改这里
    .write = uart_stm32_write,
    .read = uart_stm32_read
};

这样,应用层永远只调用 uart_device.init()。底层换了?跟我没关系。

核心要点: 分层不是把代码拆开就完事。关键是 依赖方向——上层依赖下层,但下层绝不能反向依赖上层。否则就是循环依赖,改一处崩一片。

2. 接口与实现分离:换芯片就像换灯泡

这个原则,说白了就是「面向接口编程」。你写 HAL 的时候,先定义接口,再写实现。接口放在头文件里,实现藏在 .c 文件里。

举个例子。我定义一个 SPI 接口:

// spi_hal.h —— 接口定义
#ifndef SPI_HAL_H
#define SPI_HAL_H

#include <stdint.h>

typedef struct {
    void (*init)(uint32_t freq);
    void (*transfer)(const uint8_t *tx, uint8_t *rx, uint32_t len);
    void (*cs_control)(uint8_t level);
} SPI_Interface_t;

// 对外暴露的注册函数
void SPI_Register(SPI_Interface_t *iface);

#endif
// spi_stm32.c —— 具体实现
#include "spi_hal.h"

static void stm32_spi_init(uint32_t freq) {
    // STM32 的 SPI 初始化代码
    SPI1->CR1 = (freq << 3);
}

static void stm32_spi_transfer(const uint8_t *tx, uint8_t *rx, uint32_t len) {
    // 轮询或 DMA 传输
}

static SPI_Interface_t stm32_spi_iface = {
    .init = stm32_spi_init,
    .transfer = stm32_spi_transfer,
    .cs_control = stm32_cs_control
};

// 在系统初始化时调用
void HAL_SPI_Init(void) {
    SPI_Register(&stm32_spi_iface);
}

换芯片时,我只需要新写一个 spi_imx.c,把 stm32_spi_iface 换成 imx_spi_iface。应用层代码一行都不用改。

我的习惯: 接口定义里不要出现任何芯片相关的宏或类型。比如 uint32_t 是标准类型,可以用。但 STM32_SPI_BASE 这种,绝对不能出现在接口头文件里。

3. 可移植性:一次编写,到处编译

可移植性不是玄学。它是有具体指标的。我一般用这张表来评估:

评估项 好 差
编译器依赖 只用标准 C(C99/C11) 用了 GNU C 扩展、内联汇编
数据类型 使用 stdint.h 定长类型 直接用 int、unsigned char
字节序 提供 __BIG_ENDIAN / __LITTLE_ENDIAN 宏开关 硬编码小端或大端
中断处理 用回调函数封装 直接写中断向量表
内存模型 通过宏控制 __RAM_FUNC 直接写 __attribute__((section(".ramfunc")))

我曾经在一个项目里,因为用了 __attribute__((packed)),导致从 GCC 换到 IAR 时结构体对齐全乱了。从那以后,我强制团队:所有结构体对齐,用 #pragma pack(push, 1) 这种标准预处理指令。

还有一个容易忽略的点:空指针检查。不同芯片的 NULL 指针访问行为不一样。有的直接 HardFault,有的返回 0。所以我在 HAL 层统一做检查:

void HAL_UART_Write(UART_Handle_t *huart, uint8_t data) {
    if (huart == NULL || huart->write == NULL) {
        // 统一错误处理,而不是直接崩溃
        HAL_ErrorHandler(HAL_ERROR_NULL_PTR);
        return;
    }
    huart->write(data);
}

4. 可测试性:让 Bug 死在实验室里

嵌入式软件测试难,难在硬件依赖。但 HAL 设计得好,测试就不难。核心思路是:把硬件依赖「注入」到 HAL 里。

看这个例子:

// 可测试的 HAL 设计
typedef struct {
    void (*pin_write)(uint8_t pin, uint8_t level);  // 可被 Mock
    uint8_t (*pin_read)(uint8_t pin);
} GPIO_Device_t;

// 测试代码(在 PC 上运行)
static uint8_t mock_pin_state[16];

static void mock_pin_write(uint8_t pin, uint8_t level) {
    mock_pin_state[pin] = level;  // 写入模拟数组
}

static uint8_t mock_pin_read(uint8_t pin) {
    return mock_pin_state[pin];   // 从模拟数组读取
}

void test_gpio_toggle(void) {
    GPIO_Device_t mock_gpio = {
        .pin_write = mock_pin_write,
        .pin_read = mock_pin_read
    };
    
    GPIO_Toggle(&mock_gpio, 5);
    assert(mock_pin_state[5] == 1);  // 验证结果
}

你看,测试时把真实的硬件函数换成 Mock 函数。这样在 Linux 或 Windows 上就能跑单元测试。我习惯用 Ceedling + Unity + CMock 这套工具链,自动生成 Mock 代码。

避坑指南: 我曾经在 HAL 层里直接调用了 HAL_Delay(ms) 做延时等待。结果测试时发现,Mock 环境里没有硬件定时器,整个测试套件卡死了。后来我改成:所有延时函数都通过函数指针注入,测试时替换成 mock_delay,直接返回,不阻塞。

一张图总结:HAL 设计原则

下面这张图,是我做 HAL 架构设计时贴在白板上的。每次评审新模块,都拿它来对照:

HAL 设计四原则架构图 应用层(业务逻辑) HAL 接口层(API 定义 + 函数指针) 实现 A(STM32 寄存器操作) 实现 B(i.MX 寄存器操作) LL 层(时序、中断、DMA) LL 层(时序、中断、DMA) 硬件 A(STM32F4) 硬件 B(i.MX RT) 原则映射 ① 分层:上中下三层 ② 接口与实现分离:中间层 ③ 可移植:实现 A ↔ 实现 B 可测试性 Mock 实现 A/B 在 PC 上跑单元测试

这张图从左到右看,就是一次完整的「换芯片」过程。应用层不动,HAL 接口层不动,只换底下的实现和 LL 层。这就是四个原则共同作用的结果。