3、硬件抽象层(HAL)移植:GPIO、UART、SPI、Timer底层驱动接口实现

好,咱们进入正题。硬件抽象层,简称HAL,说白了就是给协议栈和芯片硬件之间加一层「翻译官」。协议栈不关心你用的是STM32还是NXP,它只认HAL接口。你把这层写好了,换芯片就像换手机壳一样简单。

我个人习惯,移植HAL的第一步不是写代码,而是先看芯片手册。嗯,这一步很多人会跳过去,直接抄例程。我吃过这个亏——有一次项目赶工期,我直接拿现成的GPIO驱动往上怼,结果蓝牙连上就断,查了三天才发现是某个引脚的中断触发方式没配对。所以,先看手册,再看代码。

3.1 GPIO接口实现

蓝牙协议栈里,GPIO主要干三件事:控制芯片复位、控制电源使能、以及中断唤醒。你想想看,如果复位引脚拉高拉低的时序不对,芯片可能压根不工作。

我一般会封装成这样的结构:

// hal_gpio.h
typedef struct {
    void (*init)(uint8_t pin, uint8_t mode);
    void (*write)(uint8_t pin, uint8_t level);
    uint8_t (*read)(uint8_t pin);
    void (*irq_enable)(uint8_t pin, uint8_t trigger);
    void (*irq_disable)(uint8_t pin);
} hal_gpio_t;

这里要注意的是 mode 参数。不同芯片的GPIO模式定义不一样,有的叫 GPIO_MODE_OUTPUT,有的叫 GPIO_MODE_OUT_PP。我建议你在HAL层统一用枚举,比如:

typedef enum {
    HAL_GPIO_MODE_INPUT,
    HAL_GPIO_MODE_OUTPUT,
    HAL_GPIO_MODE_AF_PP,   // 复用推挽
    HAL_GPIO_MODE_AF_OD    // 复用开漏
} hal_gpio_mode_t;

然后在具体芯片的驱动里做映射。这样换芯片时,你只需要改底层映射表,上层代码纹丝不动。

小技巧: 中断回调函数一定要用弱定义(__weak),这样用户可以在应用层重写。我见过有人把回调写死在HAL层,结果用户想加个打印都不行。

3.2 UART接口实现

UART是蓝牙协议栈的「生命线」。HCI命令、ACL数据、事件通知,全走这条通道。所以UART驱动必须稳定,不能丢字节,不能乱序。

我建议你实现以下接口:

// hal_uart.h
typedef struct {
    void (*init)(uint32_t baudrate, uint8_t parity, uint8_t stopbits);
    void (*send)(uint8_t *data, uint16_t len);
    void (*send_byte)(uint8_t byte);
    uint8_t (*receive_byte)(void);
    uint16_t (*available)(void);
    void (*irq_handler)(void);
} hal_uart_t;

这里有个坑——波特率。蓝牙协议栈对时序要求很严,波特率偏差超过2%就可能丢包。我曾经在一个项目里,芯片的UART时钟源是内部RC振荡器,精度只有±3%,结果蓝牙配对总是超时。后来我换成外部晶振,问题才解决。

另外,接收中断一定要用FIFO或者环形缓冲区。为什么?因为蓝牙协议栈的数据包是变长的,你没法预判什么时候收完。用环形缓冲区,中断只管往里塞数据,主循环或者协议栈任务再从缓冲区取,这样就不会丢数据。

注意: 不要在UART中断里直接调用协议栈的API。中断上下文里能做的工作越少越好。我习惯在中断里只做数据搬运,然后发一个信号量或者事件标志给协议栈任务。

3.3 SPI接口实现

SPI在蓝牙芯片里通常用来接外部Flash、传感器,或者某些高速数据通道。SPI的难点在于时钟极性和相位配置,搞错了数据就全乱套。

我一般这样封装:

// hal_spi.h
typedef struct {
    void (*init)(uint32_t freq, uint8_t mode);  // mode: 0-3
    void (*cs_enable)(void);
    void (*cs_disable)(void);
    uint8_t (*transfer)(uint8_t tx_data);
    void (*transfer_dma)(uint8_t *tx, uint8_t *rx, uint16_t len);
} hal_spi_t;

这里 mode 参数对应SPI的四种模式(CPOL和CPHA的组合)。我建议你在注释里写清楚每种模式的适用场景,比如模式0(CPOL=0, CPHA=0)最常用,大多数Flash芯片都支持。

DMA传输这块,我个人建议能上DMA就上DMA。蓝牙协议栈的数据量不大,但SPI传输如果占用CPU太久,会影响蓝牙的实时性。我记得有一次,我用轮询方式读Flash里的蓝牙配置,结果导致蓝牙广播间隔抖动,手机连接不稳定。换成DMA后,问题立刻消失。

核心要点: SPI的片选信号(CS)一定要用GPIO控制,不要用硬件自动控制。因为蓝牙协议栈可能需要连续发送多个SPI帧,中间CS不能拉高。硬件自动CS会在每帧结束后自动释放,这会导致通信失败。

3.4 Timer接口实现

Timer是蓝牙协议栈的「心跳」。协议栈内部有很多超时管理、定时任务、以及微秒级的延迟。比如蓝牙广播间隔、连接间隔、扫描窗口,全依赖Timer。

我建议提供以下接口:

// hal_timer.h
typedef struct {
    void (*init)(uint32_t freq);           // 初始化定时器频率
    void (*start)(void);
    void (*stop)(void);
    uint32_t (*get_us)(void);              // 获取微秒级时间戳
    void (*delay_us)(uint32_t us);         // 微秒级阻塞延时
    void (*delay_ms)(uint32_t ms);         // 毫秒级阻塞延时
    void (*set_callback)(void (*cb)(void)); // 设置定时中断回调
} hal_timer_t;

这里最关键的接口是 get_us()。蓝牙协议栈需要高精度的时间戳来计算超时和调度任务。我建议用硬件定时器的计数器直接读,不要用软件累加。软件累加会有中断延迟,精度不够。

另外,delay_us() 这个接口,很多人会用循环等待来实现。但循环等待在低功耗模式下会出问题——CPU休眠了,循环就不跑了。我建议用定时器的捕获比较功能来实现精确延时,这样CPU可以继续休眠,定时器到时间了再唤醒。

经验之谈: 定时器的中断优先级要设置得比UART高,但比协议栈的临界区低。为什么?因为定时器中断如果被UART中断打断,时间戳会偏移;但如果定时器中断打断了协议栈的临界区,会导致数据竞争。这个优先级顺序,我调了整整一个下午才试出来。

3.5 移植验证 checklist

接口写完了,怎么验证对不对?我列一个自检清单,你照着测一遍:

接口 验证方法 常见问题
GPIO 写一个LED闪烁程序,观察波形 引脚复用配置错误,输出电平反相
UART 回环测试:发送0x55 0xAA,接收对比 波特率偏差、停止位配置错误
SPI 读写Flash ID,对比数据手册 时钟极性/相位错误、CS时序不对
Timer 测量1ms延时,用逻辑分析仪看误差 时钟源选择错误、预分频系数算错

嗯,这个清单看起来简单,但每一条背后都有血泪史。我记得有一次SPI验证通过了,但蓝牙就是连不上。后来发现是Flash的写保护引脚没拉高,导致配置写不进去。所以,验证的时候不仅要测功能,还要测边界条件——比如UART在最大波特率下连续收发10000个字节,看有没有丢包。

最后说一句,HAL移植完成后,不要急着跑协议栈。先写一个简单的测试程序,把每个接口单独测一遍。这一步省不了,省了后面debug的时间会翻倍。我当年就是太自信,结果在协议栈里查了一个星期的bug,最后发现是GPIO中断触发方式配错了。你说冤不冤?