4、移植第一步:硬件抽象层(HAL)适配:GPIO、UART、SPI、Timer的驱动接口实现与对接

好,咱们正式开始动手了。

前面三章我们把蓝牙协议栈的架构、内存管理、任务调度都捋了一遍。说实话,那些都是「内功」。现在这一章,才是真正把协议栈跑在你硬件上的第一步。

硬件抽象层,简称 HAL。说白了,就是给协议栈一个「统一的操作界面」。不管你的 MCU 是 STM32、NXP、还是国产的 GD32,协议栈不关心这些。它只关心:你给我提供这几个函数,我就能干活。

4.1 为什么需要 HAL?

你想想看,蓝牙协议栈要控制射频芯片,要读写寄存器,要产生精确的时序。这些操作最终都要落到具体的硬件引脚和总线上。

但不同的芯片,GPIO 的寄存器地址不一样,UART 的配置方式也不一样。如果协议栈直接操作寄存器,那每换一个芯片就得重写一遍协议栈——这谁受得了?

所以,我们抽象出一层接口。协议栈只调用这些接口,具体的实现由你根据芯片来写。这就是 HAL 的核心思想。

核心原则:协议栈永远不直接操作硬件寄存器。所有硬件操作都通过 HAL 接口转发。

4.2 GPIO 接口适配

GPIO 是最基本的。协议栈可能需要用 GPIO 来复位蓝牙模块、控制电源、或者读取中断状态。

我一般会提供这 4 个接口:

// 初始化引脚模式
void hal_gpio_init(uint8_t pin, uint8_t mode);

// 设置引脚输出电平
void hal_gpio_write(uint8_t pin, uint8_t level);

// 读取引脚输入电平
uint8_t hal_gpio_read(uint8_t pin);

// 配置引脚中断(可选)
void hal_gpio_irq_config(uint8_t pin, uint8_t trigger, void (*callback)(void));

嗯,这里要注意:mode 参数怎么定义?我个人习惯用枚举:

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

你在移植的时候,只需要把这些函数映射到你的芯片 SDK 里就行。比如 STM32 上:

void hal_gpio_init(uint8_t pin, uint8_t mode) {
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    // 根据 pin 计算端口和引脚号
    // 根据 mode 设置 GPIO_InitStruct.Mode
    HAL_GPIO_Init(GPIOx, &GPIO_InitStruct);
}

我的经验:GPIO 编号建议统一用「端口号 * 16 + 引脚号」的方式。比如 PA0 就是 0,PB3 就是 19。这样协议栈里只需要一个 uint8_t 就能表示所有引脚。

4.3 UART 接口适配

UART 是蓝牙协议栈最常用的通信接口。HCI 命令、数据包、事件通知,基本都走 UART。

协议栈需要的 UART 接口通常包括:

// 初始化 UART,配置波特率、数据位、停止位等
void hal_uart_init(uint32_t baudrate);

// 发送数据(阻塞或 DMA)
void hal_uart_send(uint8_t *data, uint16_t len);

// 接收数据(非阻塞,通常配合中断)
uint16_t hal_uart_receive(uint8_t *buffer, uint16_t max_len);

// 注册接收回调(协议栈收到数据后通知)
void hal_uart_set_rx_callback(void (*callback)(uint8_t *data, uint16_t len));

这里有个坑,我曾经踩过:波特率匹配问题。蓝牙模块和主控的 UART 波特率必须一致。但有些蓝牙模块默认是 115200,有些是 9600。我建议在 hal_uart_init 里加一个参数校验,如果波特率不支持,直接返回错误码。

避坑指南:我曾经在一个项目里,UART 接收用了 DMA 循环模式。结果蓝牙协议栈发来的数据包长度不固定,DMA 缓冲区经常被覆盖。后来改成 IDLE 中断 + 单字节接收才解决。如果你的蓝牙协议栈数据包长度变化大,建议用中断方式接收。

4.4 SPI 接口适配

有些蓝牙模块用 SPI 通信,速度比 UART 快。SPI 的 HAL 接口相对简单:

// 初始化 SPI,配置模式、时钟极性、相位等
void hal_spi_init(uint8_t mode, uint32_t clock_hz);

// 发送并接收数据(全双工)
void hal_spi_transfer(uint8_t *tx_data, uint8_t *rx_data, uint16_t len);

// 片选控制
void hal_spi_cs_select(uint8_t cs_pin);
void hal_spi_cs_deselect(uint8_t cs_pin);

为什么 SPI 要单独做片选控制?因为协议栈可能需要在一个事务中多次传输数据,片选要保持拉低。如果每次 transfer 都自动控制片选,反而会出问题。

我记得有一次调试一个蓝牙音频模块,SPI 时钟频率设得太高,导致数据错位。后来降到 10MHz 就稳定了。所以 clock_hz 参数一定要可配置,别写死。

4.5 Timer 接口适配

蓝牙协议栈对时间精度要求很高。比如连接间隔、广播间隔、超时检测,都需要精确的定时器。

协议栈通常需要这 3 个定时器功能:

// 获取当前系统时间(微秒级)
uint64_t hal_timer_get_us(void);

// 启动一个单次定时器
void hal_timer_start(uint32_t timeout_us, void (*callback)(void));

// 停止定时器
void hal_timer_stop(void);

这里有个关键点:hal_timer_get_us 返回的时间必须是单调递增的,不能回绕。我一般用 64 位计数器,配合硬件定时器溢出中断来累加。

重要提醒:定时器回调函数是在中断上下文执行的。里面不要做耗时操作,比如打印日志、申请内存。否则会影响蓝牙协议栈的实时性。

4.6 接口对接的通用流程

好了,接口定义好了,怎么对接?我总结了一个三步法:

  1. 查手册:找到你芯片的 SDK 文档,看 GPIO、UART、SPI、Timer 的 API 是什么。
  2. 写映射:把 HAL 接口里的参数,转换成芯片 SDK 的参数。比如 STM32 的波特率是 UART_HandleTypeDef 结构体里的 BaudRate 字段。
  3. 测试验证:写一个简单的测试程序,循环发送数据,看接收是否正确。用逻辑分析仪抓波形,确认时序没问题。

我曾经在一个项目里,SPI 的极性配置反了,导致蓝牙模块不响应。查了两天才发现是 CPOL 和 CPHA 没对上。所以第三步的测试验证,千万别省。

4.7 常见问题与调试技巧

问题 可能原因 解决方法
UART 接收数据乱码 波特率不匹配、时钟源不对 用示波器测 UART 引脚波形,确认波特率
SPI 通信无响应 片选没拉低、时钟极性反了 逻辑分析仪抓 SPI 四根线,看时序
定时器不准 定时器时钟源分频不对 检查定时器时钟树配置,确认时钟频率
GPIO 中断不触发 中断优先级配置、NVIC 没使能 检查中断向量表,确认中断服务函数注册

调试小技巧:在 HAL 接口里加一个调试打印宏。比如 #define HAL_DEBUG(fmt, ...) printf("[HAL] " fmt "\r\n", ##__VA_ARGS__)。每次调用接口时打印参数,方便定位问题。正式发布时关掉就行。

4.8 小结

这一章我们做了三件事:

  • 定义了 GPIO、UART、SPI、Timer 的 HAL 接口
  • 说明了每个接口的注意事项和坑
  • 给出了对接流程和调试方法

说白了,HAL 适配就是「翻译」工作。把协议栈的通用请求,翻译成你芯片能听懂的语言。翻译得准不准,直接决定了协议栈能不能跑起来。

下一章,我们会把这些 HAL 接口集成到蓝牙协议栈里,真正让协议栈「动」起来。到时候你会发现,前面这些准备工作,值了。