第4章 硬件抽象层(HAL)设计:GPIO、UART、Timer等外设的抽象与封装

做嵌入式开发久了,你会发现一个规律:换芯片如换刀。但问题是,每次换刀都得重新练一套刀法,这谁受得了?

硬件抽象层(HAL)就是为了解决这个痛点而生的。说白了,就是在你的应用代码和芯片寄存器之间,加一层“翻译官”。你只管调用 HAL_GPIO_WritePin(),至于这背后是操作 STM32 的 ODR 寄存器,还是 GD32 的 BOP 寄存器,HAL 层帮你搞定。

我个人习惯,在项目启动的第一天,哪怕只点个灯,也要先把 HAL 层搭起来。别嫌麻烦,后面你就知道有多香了。

4.1 为什么要做硬件抽象?

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

// 直接操作寄存器点亮LED
#define GPIOB_BASE      0x40010C00
#define GPIOB_BSRR      (*(volatile uint32_t *)(GPIOB_BASE + 0x10))

void led_on(void) {
    GPIOB_BSRR = (1 << 12);  // 设置PB12为高电平
}

这段代码有什么问题?

  • 硬编码地址:换个芯片,GPIOB_BASE 就变了,所有代码都得改。
  • 业务耦合:应用层知道“PB12”这个引脚编号,哪天改到 PA0,牵一发动全身。
  • 不可测试:没法在 PC 上模拟测试,必须烧录到硬件上。

我在一个物联网项目中就吃过这个亏。当时赶工期,直接在应用层写寄存器操作。结果中期换了一颗国产替代芯片,好家伙,GPIO 的位操作方式完全不同,我整整花了两周改代码,还改出了几个隐蔽的 bug。

⚠️ 我曾经踩过的坑: 不要以为“先跑起来再说”是捷径。硬件抽象层就像保险,你希望永远用不上,但真出事的时候,它能救你一命。

4.2 HAL 层的设计原则

设计 HAL 层,我总结了三个核心原则:

原则 说明 反例
接口统一 同一类外设,API 签名完全一致 GPIO_A 用 SetHigh(),GPIO_B 用 Write(1)
参数抽象 用枚举/结构体代替魔法数字 HAL_UART_Init(115200, 8, 1, 0) 鬼知道参数是啥
平台无关 应用层不包含任何寄存器操作 在业务代码里写 #include "stm32f1xx.h"

你想想看,如果每个外设的接口都长一个样,上层调度器和状态机是不是就能像搭积木一样随意组合了?这就是分层架构的魅力。

4.3 GPIO 抽象层实战

GPIO 是最基础的外设。我的抽象思路是这样的:

// hal_gpio.h - 用户看到的接口
#ifndef _HAL_GPIO_H_
#define _HAL_GPIO_H_

#include <stdint.h>
#include <stdbool.h>

/* 引脚电平定义 */
typedef enum {
    HAL_GPIO_LOW  = 0,
    HAL_GPIO_HIGH = 1
} hal_gpio_level_t;

/* 引脚方向定义 */
typedef enum {
    HAL_GPIO_INPUT  = 0,
    HAL_GPIO_OUTPUT = 1
} hal_gpio_dir_t;

/* 引脚编号 - 平台无关的抽象编号 */
typedef enum {
    HAL_GPIO_LED_RED    = 0,   // 红色LED
    HAL_GPIO_LED_GREEN  = 1,   // 绿色LED
    HAL_GPIO_KEY_BOOT   = 2,   // 启动按键
    HAL_GPIO_UART1_TX   = 3,   // UART1发送
    HAL_GPIO_UART1_RX   = 4,   // UART1接收
    // ... 按功能命名,不按物理引脚命名
    HAL_GPIO_NUM_MAX
} hal_gpio_id_t;

/* 初始化 */
void HAL_GPIO_Init(hal_gpio_id_t id, hal_gpio_dir_t dir);

/* 写操作 */
void HAL_GPIO_WritePin(hal_gpio_id_t id, hal_gpio_level_t level);

/* 读操作 */
hal_gpio_level_t HAL_GPIO_ReadPin(hal_gpio_id_t id);

/* 翻转操作 */
void HAL_GPIO_TogglePin(hal_gpio_id_t id);

#endif /* _HAL_GPIO_H_ */

注意看,这里我用的是 hal_gpio_id_t 枚举,而不是直接传 GPIO_PIN_12。这样做的好处是:应用层只关心“我要控制红色LED”,至于红色LED接在哪个引脚上,那是 HAL 层实现的事。

底层实现长这样:

// hal_gpio_stm32f1.c - STM32F1平台实现
#include "hal_gpio.h"
#include "stm32f1xx.h"  // 只在.c文件中包含芯片头文件

/* 引脚映射表:抽象ID -> 物理端口+引脚 */
typedef struct {
    GPIO_TypeDef *port;
    uint16_t      pin;
} gpio_map_t;

static const gpio_map_t s_gpio_map[HAL_GPIO_NUM_MAX] = {
    [HAL_GPIO_LED_RED]   = {GPIOB, GPIO_PIN_12},
    [HAL_GPIO_LED_GREEN] = {GPIOB, GPIO_PIN_13},
    [HAL_GPIO_KEY_BOOT]  = {GPIOA, GPIO_PIN_0},
    // ...
};

void HAL_GPIO_Init(hal_gpio_id_t id, hal_gpio_dir_t dir) {
    GPIO_TypeDef *port = s_gpio_map[id].port;
    uint16_t      pin  = s_gpio_map[id].pin;
    
    // 使能时钟、配置模式...
    if (dir == HAL_GPIO_OUTPUT) {
        // 配置为推挽输出
    } else {
        // 配置为浮空输入
    }
}
💡 我的习惯: 映射表用 const 数组 + 指定初始化器,既清晰又高效。换平台时,只需要改这个表和对应的寄存器操作函数,上层代码一行都不用动。

4.4 UART 抽象层设计

UART 比 GPIO 复杂一些,涉及到波特率、数据位、校验位等参数。我的做法是把这些参数封装成一个结构体:

// hal_uart.h
#ifndef _HAL_UART_H_
#define _HAL_UART_H_

#include <stdint.h>

/* UART编号 */
typedef enum {
    HAL_UART_1 = 0,   // 调试串口
    HAL_UART_2 = 1,   // 蓝牙模块
    HAL_UART_3 = 2,   // GPS模块
    HAL_UART_NUM_MAX
} hal_uart_id_t;

/* 波特率枚举 */
typedef enum {
    HAL_UART_BAUD_9600   = 9600,
    HAL_UART_BAUD_19200  = 19200,
    HAL_UART_BAUD_115200 = 115200,
} hal_uart_baud_t;

/* 数据位 */
typedef enum {
    HAL_UART_DATA_7 = 7,
    HAL_UART_DATA_8 = 8,
} hal_uart_data_bits_t;

/* 停止位 */
typedef enum {
    HAL_UART_STOP_1   = 0,
    HAL_UART_STOP_2   = 1,
} hal_uart_stop_bits_t;

/* 校验位 */
typedef enum {
    HAL_UART_PARITY_NONE = 0,
    HAL_UART_PARITY_EVEN = 1,
    HAL_UART_PARITY_ODD  = 2,
} hal_uart_parity_t;

/* 初始化参数结构体 */
typedef struct {
    hal_uart_baud_t      baud;
    hal_uart_data_bits_t data_bits;
    hal_uart_stop_bits_t stop_bits;
    hal_uart_parity_t    parity;
} hal_uart_config_t;

/* API */
void HAL_UART_Init(hal_uart_id_t id, hal_uart_config_t *config);
void HAL_UART_SendByte(hal_uart_id_t id, uint8_t data);
uint8_t HAL_UART_RecvByte(hal_uart_id_t id);
bool HAL_UART_IsDataAvailable(hal_uart_id_t id);

#endif /* _HAL_UART_H_ */

为什么要把参数封装成结构体?

  • 可读性强:调用时一目了然,不会搞错参数顺序。
  • 扩展性好:以后想加个流控参数,直接在结构体里加字段,不影响已有代码。
  • 默认配置:可以提供一个默认配置宏,减少重复代码。
// 使用示例
hal_uart_config_t config = {
    .baud      = HAL_UART_BAUD_115200,
    .data_bits = HAL_UART_DATA_8,
    .stop_bits = HAL_UART_STOP_1,
    .parity    = HAL_UART_PARITY_NONE
};
HAL_UART_Init(HAL_UART_1, &config);
HAL_UART_SendByte(HAL_UART_1, 'A');

4.5 Timer 抽象层设计

Timer 的抽象稍微特殊一点。因为 Timer 的用途很多:定时中断、PWM 输出、输入捕获、脉冲计数……我的策略是“按功能抽象”,而不是“按硬件抽象”。

// hal_timer.h
#ifndef _HAL_TIMER_H_
#define _HAL_TIMER_H_

#include <stdint.h>
#include <stdbool.h>

/* 定时器用途枚举 */
typedef enum {
    HAL_TIMER_SYS_TICK = 0,   // 系统滴答(1ms)
    HAL_TIMER_PWM_LED  = 1,   // LED呼吸灯
    HAL_TIMER_SERVO    = 2,   // 舵机控制
    HAL_TIMER_CAPTURE  = 3,   // 脉冲捕获
    HAL_TIMER_NUM_MAX
} hal_timer_id_t;

/* 定时器模式 */
typedef enum {
    HAL_TIMER_MODE_ONESHOT,    // 单次
    HAL_TIMER_MODE_PERIODIC,   // 周期
} hal_timer_mode_t;

/* 回调函数类型 */
typedef void (*hal_timer_callback_t)(void *arg);

/* 初始化 - 配置周期和模式 */
void HAL_Timer_Init(hal_timer_id_t id, uint32_t period_us, hal_timer_mode_t mode);

/* 启动/停止 */
void HAL_Timer_Start(hal_timer_id_t id);
void HAL_Timer_Stop(hal_timer_id_t id);

/* 注册回调 */
void HAL_Timer_RegisterCallback(hal_timer_id_t id, hal_timer_callback_t cb, void *arg);

/* 获取当前计数值(用于微秒级延时) */
uint32_t HAL_Timer_GetTick(hal_timer_id_t id);

#endif /* _HAL_TIMER_H_ */

这里有个关键点:period_us 的单位是微秒,而不是定时器的时钟周期数。为什么?因为应用层不关心你的定时器时钟是 72MHz 还是 48MHz,它只关心“我要 1 毫秒中断一次”。换算的事,交给 HAL 层去做。

🎯 核心思想: HAL 层是“服务提供者”,不是“寄存器搬运工”。它应该提供更高级、更易用的接口,而不是把芯片手册里的寄存器定义换个名字就扔给用户。

4.6 整体架构图

下面这张图展示了 HAL 层在整个软件架构中的位置:

HAL层在软件架构中的位置 应用层(任务调度 + 状态机) 硬件抽象层(HAL) GPIO 抽象 hal_gpio.h/c UART 抽象 hal_uart.h/c Timer 抽象 hal_timer.h/c MCU 底层驱动(寄存器操作) 硬件(STM32 / GD32 / AT32 ...) 换芯片时,只需要重写 MCU 底层驱动层,HAL 接口保持不变 应用层代码完全不需要修改,实现“一次编写,多平台运行”

4.7 避坑指南与最佳实践

最后,分享几个我在实际项目中积累的经验:

  1. 不要过度抽象:有些外设(比如 SPI Flash 的特定指令)不适合抽象到 HAL 层。HAL 层只负责“怎么读写”,不负责“读写什么内容”。
  2. 错误处理要统一:我习惯用返回值表示状态,用全局错误码记录详细信息。比如 HAL_UART_SendByte() 返回 bool,失败时设置 hal_errno。
  3. 中断回调要轻量:HAL 层的中断回调里不要做复杂处理,只做“标记事件 + 唤醒任务”。真正的业务逻辑交给上层状态机。
  4. 预留调试接口:每个 HAL 模块都加一个 HAL_XXX_DumpStatus() 函数,方便在调试时打印内部状态。这个习惯帮我定位过好几个诡异的问题。
⚠️ 我曾经踩过的坑: 有一次我把 SPI 的片选控制也抽象到了 HAL 层,结果发现不同芯片的 SPI 从设备对片选时序要求完全不同。最后不得不把片选控制权交还给上层,HAL 层只负责数据收发。记住:抽象不是万能的,该放手时就放手。