2、硬件抽象层(HAL)核心概念:分层架构、接口与实现分离、依赖倒置原则

好,咱们正式开始啃 HAL 的核心概念。

很多工程师写 MCU 驱动,上来就对着寄存器一顿操作。功能跑通了,代码也跑起来了。但等到换芯片、换平台的时候——嗯,那叫一个酸爽。我早年就吃过这个亏,一个项目从 STM32F1 迁移到 GD32,光重写驱动就花了两周,还踩了一堆坑。

后来我才真正理解:硬件抽象层的本质,不是“封装寄存器”,而是“隔离变化”。

2.1 分层架构:把系统切成三块

先看一张图,这是我个人习惯用的 HAL 分层模型:

HAL 分层架构模型 应用层(Application Layer) 业务逻辑、任务调度、协议栈 硬件抽象层(HAL Layer) 统一接口定义、设备句柄管理、平台无关调度 接口:hal_uart_init() / hal_uart_send() / hal_uart_recv() MCU 抽象层(MCU Abstraction Layer) 寄存器操作、时钟配置、中断向量、DMA 通道 核心原则:上层只依赖相邻下层,不跨层调用。应用层永远不直接操作寄存器。 每一层只通过接口与相邻层通信,层内实现可以随时替换。

说白了,就是把系统切成三块:

  • 应用层:只关心业务逻辑。比如“每 100ms 发一次温度数据”。它不知道 UART 是哪个型号的,也不关心。
  • HAL 层:定义统一的接口。比如 hal_uart_send(),应用层只管调用它。
  • MCU 抽象层:真正操作寄存器的地方。换芯片时,只改这一层。

关键点:每一层只依赖相邻下层。应用层不跨层调用 MCU 抽象层。这是分层架构的底线。

2.2 接口与实现分离:写代码就像做乐高

接口与实现分离,听起来高大上。其实就一句话:头文件里只放声明,不放实现。

我见过不少项目,头文件里直接写函数体。当时觉得方便,后来维护起来简直噩梦。你想想看,如果 uart.h 里直接写了寄存器操作,换芯片时你得改多少地方?

正确的做法是这样:

// hal_uart.h —— 接口定义,不依赖任何芯片
#ifndef HAL_UART_H
#define HAL_UART_H

#include <stdint.h>

typedef struct {
    uint32_t baudrate;
    uint8_t  data_bits;
    uint8_t  stop_bits;
    uint8_t  parity;
} hal_uart_config_t;

typedef void* hal_uart_handle_t;

hal_uart_handle_t hal_uart_init(hal_uart_config_t *config);
int32_t           hal_uart_send(hal_uart_handle_t handle, uint8_t *data, uint32_t len);
int32_t           hal_uart_recv(hal_uart_handle_t handle, uint8_t *data, uint32_t len);
void              hal_uart_deinit(hal_uart_handle_t handle);

#endif /* HAL_UART_H */
// hal_uart_stm32f4.c —— 针对 STM32F4 的实现
#include "hal_uart.h"
#include "stm32f4xx_hal.h"

typedef struct {
    UART_HandleTypeDef huart;
    uint8_t initialized;
} hal_uart_stm32_t;

hal_uart_handle_t hal_uart_init(hal_uart_config_t *config) {
    hal_uart_stm32_t *dev = malloc(sizeof(hal_uart_stm32_t));
    if (!dev) return NULL;

    dev->huart.Instance = USART1;
    dev->huart.Init.BaudRate = config->baudrate;
    // ... 其他配置
    if (HAL_UART_Init(&dev->huart) != HAL_OK) {
        free(dev);
        return NULL;
    }
    dev->initialized = 1;
    return (hal_uart_handle_t)dev;
}

int32_t hal_uart_send(hal_uart_handle_t handle, uint8_t *data, uint32_t len) {
    hal_uart_stm32_t *dev = (hal_uart_stm32_t *)handle;
    if (!dev || !dev->initialized) return -1;
    return HAL_UART_Transmit(&dev->huart, data, len, 1000);
}
// ... 其他函数实现

我的习惯:接口头文件放在 hal/inc/,实现文件放在 hal/src/stm32f4/ 或 hal/src/gd32/。换芯片时,只需要换 hal/src/ 下的目录,应用层代码一行都不用改。

2.3 依赖倒置原则:让高层定义规则,底层去遵守

依赖倒置原则(DIP)是 SOLID 原则里的“D”。它的核心思想是:高层模块不应该依赖低层模块,两者都应该依赖抽象。

翻译成人话:应用层不应该知道“我用的是 STM32 还是 GD32”。它只应该知道“我有一个 UART,能发数据就行”。

我曾经在一个项目中,应用层代码里直接调用了 HAL_UART_Transmit()。当时觉得省事,结果客户要求换芯片——嗯,应用层 80% 的代码都得重写。从那以后,我再也不敢让应用层直接依赖底层库了。

正确的做法是:

错误做法(违反 DIP) 正确做法(遵循 DIP)
应用层直接调用 HAL_UART_Transmit() 应用层调用 hal_uart_send()
应用层包含 stm32f4xx_hal.h 应用层只包含 hal_uart.h
换芯片时应用层代码大面积修改 换芯片时只替换 HAL 实现文件
代码耦合度高,难以测试 可以轻松编写 Mock 进行单元测试

避坑指南:我曾经见过有人把 HAL 接口定义成 stm32_uart_init() 这种名字。这其实违背了“接口与实现分离”的原则。接口名字里不应该带芯片型号。正确的命名是 hal_uart_init(),这样换芯片时接口名不变,应用层代码完全不受影响。

2.4 三者如何协同工作

分层架构、接口与实现分离、依赖倒置原则,这三者不是孤立的。它们是一个整体:

  • 分层架构 决定了代码的组织方式——把系统切成三层。
  • 接口与实现分离 决定了每一层的边界——头文件里只放声明。
  • 依赖倒置原则 决定了依赖的方向——高层依赖抽象,不依赖具体实现。

我习惯用一个简单的流程图来描述它们的关系:

三者协同工作关系 应用层 依赖抽象接口 抽象接口 hal_uart.h 实现层 stm32f4.c 协同工作流程 1. 分层架构:应用层 → HAL 接口层 → MCU 实现层 2. 接口与实现分离:hal_uart.h 只声明,不实现 3. 依赖倒置:应用层依赖 hal_uart.h(抽象),不依赖 stm32f4.c(具体) 4. 换芯片时:替换 stm32f4.c → gd32.c,应用层和接口层完全不变

嗯,这就是 HAL 的核心骨架。你把这个骨架搭好了,后面的驱动移植、单元测试、多平台支持,都会变得非常轻松。我这些年做过的项目,凡是前期把这三件事做扎实的,后期几乎没有因为换芯片而返工过。