4. 硬件抽象层(HAL)构建:GPIO驱动封装、UART驱动封装、定时器驱动封装
做Bootloader,最头疼的是什么?不是协议设计,也不是Flash操作——而是底层硬件代码跟应用代码搅在一起,换一颗芯片就得重写一大片。我早年吃过这个亏,一个项目从STM32F1迁移到F4,GPIO的寄存器地址全变了,改得我头皮发麻。
所以这一章,咱们就来聊聊硬件抽象层,也就是HAL。说白了,就是在芯片厂商的寄存器操作之上,包一层自己的接口。这样Bootloader的主体逻辑就不直接碰寄存器了,换芯片时只需要换底层那几份文件。
4.1 HAL的分层设计思路
我个人习惯把HAL分成两层:
- 芯片级驱动(BSP):直接操作寄存器,跟具体芯片绑定。比如设置GPIO模式、配置UART波特率。
- 接口级封装(HAL):定义统一的结构体和函数原型,调用BSP实现。Bootloader的其他模块只跟这一层打交道。
你想想看,这样做的好处是什么?Bootloader里的命令解析、固件校验、Flash写入,这些逻辑完全不用关心底层跑在哪个寄存器上。只要HAL接口不变,换芯片就像换零件一样简单。
4.2 GPIO驱动封装
GPIO在Bootloader里主要用来做什么?状态指示(LED)、按键检测、使能控制。别小看这几根引脚,封装好了能省不少事。
4.2.1 定义GPIO结构体
我建议用枚举和结构体把引脚信息抽象出来,而不是到处写宏定义:
/* gpio_hal.h */
#ifndef GPIO_HAL_H
#define GPIO_HAL_H
#include <stdint.h>
/* GPIO端口枚举 */
typedef enum {
GPIO_PORT_A,
GPIO_PORT_B,
GPIO_PORT_C,
/* ... 按芯片扩展 */
GPIO_PORT_MAX
} gpio_port_t;
/* GPIO引脚号枚举 */
typedef enum {
GPIO_PIN_0,
GPIO_PIN_1,
/* ... */
GPIO_PIN_15
} gpio_pin_t;
/* GPIO模式枚举 */
typedef enum {
GPIO_MODE_OUTPUT_PP, /* 推挽输出 */
GPIO_MODE_OUTPUT_OD, /* 开漏输出 */
GPIO_MODE_INPUT_FLOAT, /* 浮空输入 */
GPIO_MODE_INPUT_PU, /* 上拉输入 */
GPIO_MODE_INPUT_PD /* 下拉输入 */
} gpio_mode_t;
/* GPIO引脚状态 */
typedef enum {
GPIO_LOW = 0,
GPIO_HIGH = 1
} gpio_state_t;
/* GPIO配置结构体 */
typedef struct {
gpio_port_t port;
gpio_pin_t pin;
gpio_mode_t mode;
} gpio_cfg_t;
/* HAL接口函数 */
void gpio_init(const gpio_cfg_t *cfg);
void gpio_set(gpio_port_t port, gpio_pin_t pin, gpio_state_t state);
gpio_state_t gpio_get(gpio_port_t port, gpio_pin_t pin);
void gpio_toggle(gpio_port_t port, gpio_pin_t pin);
#endif /* GPIO_HAL_H */
4.2.2 BSP实现示例(以STM32为例)
/* gpio_bsp_stm32.c */
#include "gpio_hal.h"
#include "stm32f1xx.h" /* 芯片头文件 */
void gpio_init(const gpio_cfg_t *cfg) {
GPIO_TypeDef *gpio;
uint32_t rcc_bit;
uint32_t pin_mask = (1 << cfg->pin);
/* 根据端口选择基地址和时钟使能位 */
switch (cfg->port) {
case GPIO_PORT_A:
gpio = GPIOA;
rcc_bit = RCC_APB2ENR_IOPAEN;
break;
case GPIO_PORT_B:
gpio = GPIOB;
rcc_bit = RCC_APB2ENR_IOPBEN;
break;
/* ... 其他端口 */
default:
return;
}
/* 使能时钟 */
RCC->APB2ENR |= rcc_bit;
/* 配置模式(简化版,实际需处理CRL/CRH) */
if (cfg->mode == GPIO_MODE_OUTPUT_PP) {
gpio->CRL &= ~(0xF << (cfg->pin * 4));
gpio->CRL |= (0x3 << (cfg->pin * 4)); /* 50MHz推挽输出 */
} else {
/* 输入模式配置 */
gpio->CRL &= ~(0xF << (cfg->pin * 4));
gpio->CRL |= (0x8 << (cfg->pin * 4)); /* 上拉/下拉输入 */
}
}
void gpio_set(gpio_port_t port, gpio_pin_t pin, gpio_state_t state) {
GPIO_TypeDef *gpio = get_gpio_base(port);
if (state == GPIO_HIGH) {
gpio->BSRR = (1 << pin);
} else {
gpio->BRR = (1 << pin);
}
}
4.3 UART驱动封装
UART是Bootloader的生命线——固件下载、调试信息、命令行交互全得靠它。封装UART驱动时,我重点关注三个点:初始化、单字节收发、以及可选的环形缓冲区。
4.3.1 定义UART接口
/* uart_hal.h */
#ifndef UART_HAL_H
#define UART_HAL_H
#include <stdint.h>
/* UART编号枚举 */
typedef enum {
UART_ID_1,
UART_ID_2,
/* ... */
UART_ID_MAX
} uart_id_t;
/* UART配置结构体 */
typedef struct {
uart_id_t id;
uint32_t baudrate;
uint8_t data_bits; /* 5/6/7/8 */
uint8_t stop_bits; /* 1/2 */
uint8_t parity; /* 0=none, 1=odd, 2=even */
} uart_cfg_t;
/* HAL接口函数 */
void uart_init(const uart_cfg_t *cfg);
void uart_send_byte(uart_id_t id, uint8_t data);
uint8_t uart_recv_byte(uart_id_t id);
int32_t uart_recv_byte_timeout(uart_id_t id, uint8_t *data, uint32_t timeout_ms);
void uart_send_buffer(uart_id_t id, const uint8_t *buf, uint32_t len);
#endif /* UART_HAL_H */
这里我特意加了一个 uart_recv_byte_timeout 函数。为什么?因为Bootloader在等待上位机发送固件时,不能死等——万一线断了或者协议出错了,得有超时机制跳出去。我在项目中遇到过,没有超时机制的Bootloader,一旦通信卡死就只能看门狗复位,用户体验极差。
4.3.2 超时接收的实现
/* uart_bsp_stm32.c - 超时接收示例 */
#include "uart_hal.h"
#include "timer_hal.h" /* 后面会封装定时器 */
int32_t uart_recv_byte_timeout(uart_id_t id, uint8_t *data, uint32_t timeout_ms) {
USART_TypeDef *usart = get_usart_base(id);
uint32_t start_tick = timer_get_tick(); /* 获取系统滴答 */
while ((usart->SR & USART_SR_RXNE) == 0) {
/* 检查超时 */
if (timer_get_tick() - start_tick >= timeout_ms) {
return -1; /* 超时返回负值 */
}
}
*data = (uint8_t)(usart->DR & 0xFF);
return 0; /* 成功返回0 */
}
4.4 定时器驱动封装
Bootloader里定时器主要干三件事:提供系统滴答(用于超时计算)、生成精确延时、以及可能的看门狗喂狗定时。我不建议在Bootloader里搞复杂的PWM或输入捕获,那是应用层的事。
4.4.1 系统滴答定时器
最简单的方式是用SysTick(Cortex-M内核自带),1ms中断一次,维护一个全局tick计数器:
/* timer_hal.h */
#ifndef TIMER_HAL_H
#define TIMER_HAL_H
#include <stdint.h>
/* 初始化系统滴答,产生1ms中断 */
void timer_init_systick(void);
/* 获取当前tick值(单位ms) */
uint32_t timer_get_tick(void);
/* 忙等待延时(单位ms) */
void timer_delay_ms(uint32_t ms);
/* 微秒级延时(通常用循环实现) */
void timer_delay_us(uint32_t us);
#endif /* TIMER_HAL_H */
/* timer_bsp_cm3.c - 基于Cortex-M3的SysTick实现 */
static volatile uint32_t system_tick = 0;
void SysTick_Handler(void) {
system_tick++;
}
void timer_init_systick(void) {
/* 配置SysTick为1ms中断 */
SysTick_Config(SystemCoreClock / 1000);
}
uint32_t timer_get_tick(void) {
uint32_t tick;
/* 关中断读,防止原子性问题 */
__disable_irq();
tick = system_tick;
__enable_irq();
return tick;
}
void timer_delay_ms(uint32_t ms) {
uint32_t start = timer_get_tick();
while ((timer_get_tick() - start) < ms) {
/* 空循环等待 */
}
}
timer_get_tick() 可能导致tick值不更新。我一般把SysTick优先级设为最高(0),UART中断次之。
4.4.2 定时器功能对比
| 功能 | 实现方式 | 精度 | 是否阻塞 | 典型用途 |
|---|---|---|---|---|
| 系统滴答 | SysTick中断 + 全局计数器 | 1ms | 否 | 超时计算、非阻塞延时 |
| 毫秒延时 | 查询系统滴答 | 1ms | 是 | 等待外设就绪、LED闪烁 |
| 微秒延时 | 指令循环或硬件定时器 | ~1us | 是 | 时序要求严格的协议(如单总线) |
| 硬件定时器 | 芯片通用定时器 | 可配置 | 否 | 精确PWM、输入捕获(Bootloader少用) |
4.5 封装后的使用示例
来看看封装好之后,Bootloader的main函数长什么样:
/* main.c - Bootloader入口 */
#include "gpio_hal.h"
#include "uart_hal.h"
#include "timer_hal.h"
/* 板级配置 */
static const gpio_cfg_t led_cfg = {
.port = GPIO_PORT_B,
.pin = GPIO_PIN_12,
.mode = GPIO_MODE_OUTPUT_PP
};
static const uart_cfg_t debug_uart = {
.id = UART_ID_1,
.baudrate = 115200,
.data_bits = 8,
.stop_bits = 1,
.parity = 0
};
int main(void) {
uint8_t cmd;
/* 初始化HAL */
timer_init_systick();
gpio_init(&led_cfg);
uart_init(&debug_uart);
/* 启动指示 */
gpio_set(led_cfg.port, led_cfg.pin, GPIO_HIGH);
uart_send_buffer(debug_uart.id, (uint8_t*)"Bootloader Start\r\n", 18);
while (1) {
/* 等待命令,带超时 */
if (uart_recv_byte_timeout(debug_uart.id, &cmd, 1000) == 0) {
/* 处理命令... */
} else {
/* 超时,翻转LED指示 */
gpio_toggle(led_cfg.port, led_cfg.pin);
}
}
}
你看,main函数里完全没有寄存器操作,全是HAL接口调用。如果哪天要换芯片,只需要重写BSP层的三个文件(gpio_bsp_xxx.c、uart_bsp_xxx.c、timer_bsp_xxx.c),上层的逻辑代码一行都不用改。