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 接口对接的通用流程
好了,接口定义好了,怎么对接?我总结了一个三步法:
- 查手册:找到你芯片的 SDK 文档,看 GPIO、UART、SPI、Timer 的 API 是什么。
- 写映射:把 HAL 接口里的参数,转换成芯片 SDK 的参数。比如 STM32 的波特率是
UART_HandleTypeDef结构体里的BaudRate字段。 - 测试验证:写一个简单的测试程序,循环发送数据,看接收是否正确。用逻辑分析仪抓波形,确认时序没问题。
我曾经在一个项目里,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 接口集成到蓝牙协议栈里,真正让协议栈「动」起来。到时候你会发现,前面这些准备工作,值了。