4. 硬件抽象层(HAL)构建:GPIO驱动封装、UART驱动封装、定时器驱动封装

做Bootloader,最头疼的是什么?不是协议设计,也不是Flash操作——而是底层硬件代码跟应用代码搅在一起,换一颗芯片就得重写一大片。我早年吃过这个亏,一个项目从STM32F1迁移到F4,GPIO的寄存器地址全变了,改得我头皮发麻。

所以这一章,咱们就来聊聊硬件抽象层,也就是HAL。说白了,就是在芯片厂商的寄存器操作之上,包一层自己的接口。这样Bootloader的主体逻辑就不直接碰寄存器了,换芯片时只需要换底层那几份文件。

核心原则: Bootloader的HAL只封装最基础的外设——GPIO、UART、定时器。不需要像RTOS驱动那样搞复杂框架,够用、可移植、无动态内存分配,这三点就够了。

4.1 HAL的分层设计思路

我个人习惯把HAL分成两层:

  • 芯片级驱动(BSP):直接操作寄存器,跟具体芯片绑定。比如设置GPIO模式、配置UART波特率。
  • 接口级封装(HAL):定义统一的结构体和函数原型,调用BSP实现。Bootloader的其他模块只跟这一层打交道。

你想想看,这样做的好处是什么?Bootloader里的命令解析、固件校验、Flash写入,这些逻辑完全不用关心底层跑在哪个寄存器上。只要HAL接口不变,换芯片就像换零件一样简单。

HAL 分层架构 Bootloader 应用层 HAL 接口层(统一API) BSP 芯片级驱动(寄存器操作) 硬件(MCU外设寄存器) Bootloader只调用HAL接口,不直接操作BSP和寄存器。换芯片只需重写BSP层。

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);
    }
}
注意: 不同芯片的GPIO寄存器布局差异很大。比如STM32F1的CRL/CRH和STM32F4的MODER/OTYPER完全不同。BSP层就是用来消化这些差异的,别偷懒把寄存器操作暴露到上层。

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 */
}
避坑指南: 我曾经在UART接收里直接用循环等待,没有加超时。结果有一次调试时,上位机突然断连,Bootloader就卡死在接收函数里,连看门狗都喂不了。从那以后,我所有阻塞式接收都强制加超时参数。

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) {
        /* 空循环等待 */
    }
}
注意: 在Bootloader里,中断优先级要小心设置。SysTick中断优先级不能太低,否则在UART中断处理中调用 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),上层的逻辑代码一行都不用改。

总结一下: GPIO封装要抽象端口和模式,UART封装要带超时机制,定时器封装以系统滴答为核心。这三板斧砍下去,Bootloader的硬件依赖就被牢牢锁在BSP层了。后面章节讲Flash驱动、固件校验时,你会发现这个HAL层带来的好处越来越大。