第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 层去做。
4.6 整体架构图
下面这张图展示了 HAL 层在整个软件架构中的位置:
4.7 避坑指南与最佳实践
最后,分享几个我在实际项目中积累的经验:
- 不要过度抽象:有些外设(比如 SPI Flash 的特定指令)不适合抽象到 HAL 层。HAL 层只负责“怎么读写”,不负责“读写什么内容”。
- 错误处理要统一:我习惯用返回值表示状态,用全局错误码记录详细信息。比如
HAL_UART_SendByte()返回bool,失败时设置hal_errno。 - 中断回调要轻量:HAL 层的中断回调里不要做复杂处理,只做“标记事件 + 唤醒任务”。真正的业务逻辑交给上层状态机。
- 预留调试接口:每个 HAL 模块都加一个
HAL_XXX_DumpStatus()函数,方便在调试时打印内部状态。这个习惯帮我定位过好几个诡异的问题。