第二章:HAL架构设计原则

做嵌入式开发这些年,我见过太多“一次编写,到处重写”的惨案。换了个MCU,整个驱动层推倒重来。说白了,就是没把硬件抽象层当回事。这一章,咱们聊聊HAL架构的几个核心设计原则。这些原则不是书本上飘着的理论,是我在项目里踩过坑、填过土之后,一点点沉淀下来的。

核心观点:HAL架构设计的本质,是用“接口的稳定”对抗“硬件的多变”。你想想看,底层芯片年年换,但上层业务逻辑不该跟着折腾。

2.1 分层设计思想

分层,是解决复杂系统最朴素也最有效的手段。HAL在嵌入式软件里,通常扮演中间层的角色。

我习惯把嵌入式软件分成三层:

  • 应用层:业务逻辑,跟硬件无关。比如PID控制算法、通信协议栈。
  • HAL层:硬件抽象接口。提供统一的API,屏蔽底层差异。
  • 驱动层:直接操作寄存器。跟具体芯片绑定。

每一层只跟相邻层打交道。应用层调用HAL接口,HAL调用驱动接口。驱动换了,HAL接口不变,应用层代码一行都不用改。

我的习惯:在HAL层和驱动层之间,再加一层薄薄的“芯片适配层”。这样换芯片时,只需要重写适配层,HAL核心逻辑几乎不动。这个做法帮我省了至少两个月的移植时间。

下面这张图,是我常用的分层结构:

嵌入式软件分层架构 应用层 业务逻辑 · 协议栈 · 算法 硬件抽象层 (HAL) 统一API · 接口定义 · 资源管理 芯片适配层 寄存器映射 · 中断路由 · 时钟配置 硬件驱动层 SPI · I²C · UART · GPIO 不依赖硬件 纯C/业务逻辑 接口稳定 跨平台复用 芯片相关 寄存器操作

2.2 接口与实现分离

这是HAL设计的灵魂。接口是“承诺”,实现是“兑现”。调用者只看接口,不看实现。

在C语言里,我通常用结构体函数指针来实现:

/* hal_timer.h — 接口定义 */
typedef struct {
    int32_t (*init)(uint32_t freq_hz);
    int32_t (*start)(void);
    int32_t (*stop)(void);
    uint64_t (*get_ticks)(void);
} hal_timer_t;

extern const hal_timer_t HAL_Timer;

不同的芯片,提供不同的实现文件:

/* hal_timer_stm32.c — STM32实现 */
static int32_t stm32_init(uint32_t freq_hz) {
    /* 操作STM32的TIM寄存器 */
    return 0;
}

const hal_timer_t HAL_Timer = {
    .init      = stm32_init,
    .start     = stm32_start,
    .stop      = stm32_stop,
    .get_ticks = stm32_get_ticks,
};
/* hal_timer_gd32.c — GD32实现 */
static int32_t gd32_init(uint32_t freq_hz) {
    /* 操作GD32的定时器寄存器 */
    return 0;
}

const hal_timer_t HAL_Timer = {
    .init      = gd32_init,
    .start     = gd32_start,
    .stop      = gd32_stop,
    .get_ticks = gd32_get_ticks,
};

我曾经踩过的坑:早期我直接把函数指针放在头文件里,结果不同模块互相引用,编译依赖乱成一团。后来我学乖了——接口头文件只放结构体声明和extern变量,实现文件各自包含各自的寄存器头文件。这样接口层零依赖,清爽多了。

2.3 平台无关性设计

平台无关性,说白了就是“别让上层代码知道你在用什么芯片”。

我总结了几个关键点:

  • 数据类型统一:别直接用int、long。用uint8_t、uint32_t这种明确宽度的类型。不同平台int长度不一样,坑你没商量。
  • 寄存器地址隐藏:所有寄存器地址、位定义,只出现在驱动层。HAL接口里只传逻辑参数。
  • 中断处理标准化:不要在上层直接写中断服务函数。用回调函数注册机制。
  • 时钟抽象:提供统一的时钟获取接口,比如HAL_GetTick(),底层用SysTick还是TIM,上层不管。
设计维度 平台相关做法(不推荐) 平台无关做法(推荐)
数据类型 int, long, unsigned char int32_t, uint8_t, size_t
寄存器访问 *(volatile uint32_t*)0x40020000 HAL_GPIO_WritePin(PIN_5, HIGH)
延时实现 for(i=0;i<100000;i++) HAL_Delay(10) // 单位ms
中断处理 直接在startup.s里写函数 HAL_IRQ_RegisterCallback()

我的经验:做平台无关设计时,多想想“如果明天换芯片,这段代码改不改?”如果答案是“改”,那就说明抽象得不够彻底。

2.4 模块化与可扩展性

模块化不是把代码拆成多个文件就完事了。真正的模块化,是每个模块职责单一、依赖清晰、可独立测试。

我一般按外设类型划分模块:

  • hal_gpio.c/h:GPIO控制
  • hal_uart.c/h:串口通信
  • hal_spi.c/h:SPI总线
  • hal_i2c.c/h:I²C总线
  • hal_timer.c/h:定时器
  • hal_adc.c/h:模数转换

每个模块内部,再按“接口层 + 芯片适配层”拆分。这样新加一个芯片,只需要新增适配文件,已有的模块不受影响。

可扩展性方面,我习惯用“注册机制”:

/* hal_spi.h */
typedef struct {
    uint32_t cs_pin;
    uint32_t speed_hz;
    uint8_t  mode;  /* 0-3 */
} hal_spi_config_t;

typedef int32_t (*spi_xfer_t)(uint8_t *tx, uint8_t *rx, uint32_t len);

int32_t HAL_SPI_Register(const char *name, spi_xfer_t xfer_func);
int32_t HAL_SPI_Transfer(const char *name, uint8_t *tx, uint8_t *rx, uint32_t len);

这样设计的好处是:

  • 新外设只需要调用HAL_SPI_Register注册自己的收发函数
  • 上层代码通过名字调用,完全不知道底层是谁在干活
  • 甚至可以动态切换底层实现,调试时用模拟器,量产时用真实硬件

注意:模块化不是越细越好。我见过有人把GPIO的读和写拆成两个模块,结果接口数量翻倍,维护成本反而更高。模块的粒度,以“一个工程师能在一周内独立完成开发和测试”为准。

最后说一句心里话:HAL架构设计没有银弹。这些原则是方向,不是教条。你在实际项目中,要根据团队规模、产品生命周期、芯片更换频率来灵活调整。但有一点我始终坚持——接口一旦发布,就不要轻易改。改接口的成本,远比你想象的高。