首页 / 资讯中心 / 文章详情

STM32蓝牙模块通信全链路:选型、AT指令与串口协议解析

STM32蓝牙模块通信全链路:选型、AT指令与串口协议解析 ★ FEATURED ARTICLE
1. 先把需求框死STM32配蓝牙模块选型比写代码更影响成败A同学前段时间做一个小车项目主控是STM32F103加一个蓝牙模块做手机遥控。他前后折腾了两天代码改了三版最后发现问题出在模块买错了型号——他以为所有蓝色小板子都是蓝牙串口结果手里那块是BLE从机手机连上之后发过来的数据是GATT特征值写入而不是他想象中的串口透传流。这个场景在嵌入式入门阶段太常见了。STM32 和蓝牙模块的组合硬件上只有两根线软件上却横跨了串口配置、协议理解、状态机设计三层东西任何一层想当然都会表现为连上了但没数据这种没有报错信息的死局。这篇内容想解决的就是这件事从选模块开始把 STM32 侧串口怎么配、AT 指令怎么打、应用层协议怎么定、出了问题是哪一层引起的按一条完整的链路走一遍。适合刚接触 STM32 外设、想用蓝牙做短距离无线通信的开发者也适合已经能点亮灯但被数据不通卡住的人。文中给的代码基于 HAL 库F1、F4、G0 系列都能直接搬差异点我会单独标出来。1.1 经典蓝牙SPP和BLE到底该选哪一类这一步选错后面所有代码都白写。市面上那些带引脚的蓝牙小模块粗分两类。第一类是经典蓝牙 SPP串口透传协议代表型号 HC-05、HC-06。它内部封装了一个虚拟串口配对成功之后手机发一个字节模块的 TX 脚就吐一个字节出来完全不需要关心 GATT、服务、特征值这些东西。HC-05 可以设主从角色HC-06 基本只能做从机。做遥控、传调试信息、短指令交互这类模块是性价比最高的选择。第二类是低功耗蓝牙 BLE代表型号 JDY-31、AT-09、HC-08 这类很多是同一颗 BLE SoC 的不同封装。BLE 的优势是功耗低、能和手机原生生态配合但它的串口透传其实是模块固件模拟出来的模块内部建了一个透明传输服务手机端必须用特定 App 或者自己写 GATT 客户端去连接指定服务和特征值才能收发数据。用系统自带的蓝牙设置直接配对通常是连不上的。我的判断标准很朴素场景推荐类型理由手机遥控车、传传感器数值、调参经典蓝牙 SPP透传直白任何蓝牙串口 App 都能用电池供电、要跑几个月BLE广播休眠的功耗比 SPP 低一个量级要对接手机 App 或小程序BLE主流移动端对 BLE 支持更规范两个 STM32 之间互传经典蓝牙一主一从免去 GATT 客户端开发传音频、大数据量都不合适该上 Wi-Fi 或专用方案还有一点必须提前确认模块是从机还是主从一体。HC-06 这类纯从机模块你没法让它主动去连另一块模块两个 STM32 想无线对话就做不成。买之前看清单页上的 ROLE 说明这一步花三十秒能省两天。1.2 供电、引脚和天线三个最容易被忽略的硬件前提选完型号很多人直接对着丝印把 VCC、GND、TX、RX 四根线插上去然后开始怀疑人生。实际上有三个硬件细节值得单独拿出来说。供电电压和 IO 电平不是一回事。很多 HC-05 底板标称 VCC 支持 3.6V~6V因为它板上带了 LDO但它的信号引脚电平是 3.3V 逻辑。如果你用 5V 给模块供电同时把模块的 TX 直接接到 STM32 的 RX 上那根线上跑的就是 5V 电平。STM32 大部分引脚虽然是 5V 容忍FT 标注但不是全部PA0~PA7 这类模拟引脚往往不容忍。稳妥做法是模块统一用 3.3V 供电或者至少在模块 TX 到 STM32 RX 之间串一个 1kΩ 电阻做限流。反过来 STM32 的 TX 到模块 RX 一般不用管3.3V 输出对模块来说是合法高电平。峰值电流比手册上的静态电流重要得多。模块在广播、建链、发射瞬间的电流会突然抬升几十毫安是常态。如果和 STM32 共用一路 LDO又没有做去耦很容易出现一连接就复位或者数据发一半就断的现象。我的习惯是在模块 VCC 和 GND 之间并一颗 100μF 的钽电容加一颗 0.1μF 的陶瓷电容位置尽量靠近模块引脚而不是放在板子另一头。这颗电容的钱不能省。天线的净空区必须留出来。模块板载的 PCB 天线或者陶瓷天线下方和正前方不能铺铜、不能走线、不能贴电池、不能有金属外壳紧贴。有些同学把模块塞进一个铁皮盒子做外壳结果通信距离从十米掉到半米还以为是模块坏了。装配的时候至少保证天线区域往外 5mm 到 10mm 是空的。提示模块底板上印的 TX/RX 是相对模块自身而言的。模块的 TX 要接 STM32 的 RX模块的 RX 要接 STM32 的 TX。这个交叉关系每个人都知道但真正接线时接反的比例高得离谱。2. 接线这一步把方向感和电平问题一次解决接线看起来是最没有技术含量的环节但我在帮人排查问题时十次里有三四次最终定位到这一层。所以这里不写接好四根线这种废话而是把接线过程中真正会出岔子的地方摊开说。2.1 TX/RX 交叉与串口选择STM32 的串口不止一个选哪个有讲究。USART1 挂在 APB2 上时钟频率通常比挂在 APB1 上的 USART2/3 高能支持的波特率上限也更高同时它的引脚复用组合更多。如果只是 9600 或 115200 的蓝牙通信任何一个串口都够用这时候优先考虑的是这块板子上哪个串口的引脚没有被别的外设占用。举个具体的例子一块常见的 STM32F103 最小系统板USART1 默认是 PA9TX和 PA10RX。这两个脚经常被拿去接 LED 或者按键冲突了就得重映射到 PB6/PB7。重映射不是改个宏就完事需要在初始化里打开 AFIO 时钟并且调用重映射函数CubeMX 里勾选了会自动生成手写代码时容易漏。接线之后第一件事不是烧程序而是用万用表通断档确认四根线的连通性尤其是用杜邦线的时候。劣质杜邦线内部断芯、母头簧片松垮的情况非常多表现就是有时候通有时候不通这种间歇性故障会让你怀疑一切软件逻辑。2.2 电平转换的两种做法与各自的代价前面提到模块 TX 到 STM32 RX 的电平问题这里给两个具体方案。方案一是串电阻1kΩ 到 2kΩ 都行。原理是限制流入 STM32 引脚钳位二极管的电流。成本几分钱缺点是它只降低电流不改变电压幅值如果模块输出真的是 5V 推挽那 STM32 引脚上测到的还是接近 5V只是电流被限制了。对短时间调试够用长期可靠性一般。方案二是用双向电平转换芯片比如常见的 MOS 管方案或者专用转换芯片。成本高一点占板面积大一点但电压幅值是真的被转换了。如果项目要做成产品、要过长时间老化我建议上这个方案。还有第三种更省事的做法选一块原生 3.3V 逻辑的模块底板。很多模块的底板上有电平选择焊盘或者跳线翻过来看看背面说不定已经给你做好了。方案成本可靠性适用阶段串 1kΩ 电阻极低一般打样验证、短期调试双向电平转换中高小批量、长期运行选 3.3V 原生底板无额外成本高采购阶段就该决定2.3 上电顺序与模块状态灯的读法模块上电后板上一般有个 LED它的闪烁模式是免费的状态指示器学会读它能省很多时间。快闪大约每秒闪好几次模块在广播或者等待配对没连上。双闪、慢闪HC-05 在 AT 模式下的典型表现之一或者模块进入了可发现状态。常亮已经建立连接。这时候串口上才可能有透传数据。完全不亮先查供电和地线别急着怀疑模块坏。我个人的经验是先让模块单独上电看灯再接 STM32。这样能把模块本身有没有问题和STM32 有没有问题隔离开。如果模块单独上电灯都不闪那问题百分之百在供电或者模块本身跟你的串口代码一毛钱关系都没有。另外提一句上电顺序如果 STM32 先上电、模块后上电模块上电瞬间的浪涌有可能在 TX 线上产生一个毛刺被 STM32 当成一个字节收进去。如果你的协议没有帧头校验这个毛刺就会变成一个假的指令。所以协议设计那章我会强调帧结构的重要性这不是过度设计。3. STM32侧串口收数的三种写法以及我为什么最后留下两种串口发送简单HAL_UART_Transmit一行搞定。真正麻烦的是接收因为数据什么时候来、来多长主机事先都不知道。这里把三种常见写法摆出来说清楚各自适合什么场合。3.1 轮询接收只适合验证阶段最简单的写法是主循环里不停调HAL_UART_Receive(huart1, ch, 1, 10)超时 10ms收到就处理。代码三行逻辑清晰。uint8_t ch; while (1) { if (HAL_UART_Receive(huart1, ch, 1, 10) HAL_OK) { handle_byte(ch); } // 其他任务 }问题在于它是阻塞的。哪怕超时设成 1ms主循环也被切得七零八落一旦你有定时器任务、有屏幕刷新、有电机控制时序立刻就乱。所以我只在两种情况下用它一是刚焊好板子验证串口通不通二是做一个极简的调试输出口只收单字符命令。真要长期用至少得把它挪到定时器中断里但那样又引入了新的复杂度——中断里调 HAL 的阻塞函数是禁忌。3.2 中断逐字节接收可控但要注意节奏用HAL_UART_Receive_IT(huart1, rx_byte, 1)开一个字节的中断接收每收到一个字节进一次回调在回调里把字节塞进缓冲区并重新开启接收。uint8_t rx_byte; uint8_t frame_buf[64]; uint16_t frame_len 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { if (frame_len sizeof(frame_buf)) { frame_buf[frame_len] rx_byte; } HAL_UART_Receive_IT(huart1, rx_byte, 1); } }这个写法的好处是每个字节的到达时刻都是确定的你可以在回调里直接判断帧头甚至做简单的状态机解析。缺点是中断频率高115200 波特率下一个字节大约 87μs也就是说每秒可能进上千次中断。如果主频不高、中断里还干了别的事就容易丢字节。一个必须注意的细节是在回调里重新调用HAL_UART_Receive_IT之前不能有太长的耗时操作。如果你在回调里直接跑协议解析、甚至点亮屏幕那么在解析期间到达的字节就丢了。正确做法是回调里只做搬运把字节丢进环形缓冲区就立刻返回解析放到主循环里做。3.3 IDLE中断配合DMA不定长数据的正解蓝牙透传的数据天然是一包一包来的中间有天然的空闲间隔。STM32 的串口有一个 IDLE 空闲中断正好对应这个特性总线上一段时间没有新数据就触发一次中断告诉你这一包结束了。配合 DMA可以做到 CPU 完全不参与搬运。初始化时开一次 DMA 接收长度设成缓冲区大小然后在 IDLE 中断里算出实际收到了多少字节。新版本 HAL 库F4、G0、G4、H7 等较新的固件包提供了HAL_UARTEx_ReceiveToIdle_DMA一行就能开启#define RX_BUF_SIZE 256 uint8_t rx_dma_buf[RX_BUF_SIZE]; void uart_start_rx(void) { HAL_UARTEx_ReceiveToIdle_DMA(huart1, rx_dma_buf, RX_BUF_SIZE); __HAL_DMA_DISABLE_IT(huart1.hdmarx, DMA_IT_HT); // 关掉半传输中断避免频繁打断 } void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { ringbuf_push(rx_dma_buf, Size); // 只搬运不解析 HAL_UARTEx_ReceiveToIdle_DMA(huart, rx_dma_buf, RX_BUF_SIZE); __HAL_DMA_DISABLE_IT(huart-hdmarx, DMA_IT_HT); } }如果是老版本 HAL比如 F1 系列的固件包没有这个接口就手动开 IDLE 中断// 初始化时 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); HAL_UART_Receive_DMA(huart1, rx_dma_buf, RX_BUF_SIZE); // 在 stm32f1xx_it.c 的 USART1_IRQHandler 里调用 void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart1); HAL_UART_DMAStop(huart1); uint16_t len RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx); if (len 0) { ringbuf_push(rx_dma_buf, len); } HAL_UART_Receive_DMA(huart1, rx_dma_buf, RX_BUF_SIZE); } HAL_UART_IRQHandler(huart1); }这段代码有两个隐形坑。第一__HAL_UART_CLEAR_IDLEFLAG本质上要求先读 SR 再读 DR不同系列的 HAL 宏实现不完全一样如果你的芯片上这个宏不生效中断会反复触发表现为程序卡死。第二在 IDLE 中断里HAL_UART_DMAStop之后必须重新启动 DMA忘了这一步就只收得到第一包。我见过不少人卡在这里现象是第一帧正常后面全无响应。3.4 环形缓冲区把接收和解析彻底解耦不管用中断还是 DMA最后都应该落到一个环形缓冲区上。中断/DMA 负责生产主循环负责消费两边通过读写指针解耦。#define RB_SIZE 512 // 必须是 2 的幂 static uint8_t rb[RB_SIZE]; static volatile uint16_t rb_head 0; // 写入位置中断里改 static volatile uint16_t rb_tail 0; // 读出位置主循环里改 void ringbuf_push(const uint8_t *data, uint16_t len) { for (uint16_t i 0; i len; i) { uint16_t next (rb_head 1) (RB_SIZE - 1); if (next rb_tail) { rb_tail (rb_tail 1) (RB_SIZE - 1); // 满了就丢最旧的 } rb[rb_head] data[i]; rb_head next; } } uint16_t ringbuf_pop(uint8_t *out, uint16_t max_len) { uint16_t n 0; while (rb_tail ! rb_head n max_len) { out[n] rb[rb_tail]; rb_tail (rb_tail 1) (RB_SIZE - 1); } return n; }用 2 的幂做长度取模运算就变成位与速度快很多。volatile不能省否则编译器优化之后主循环可能永远看不到rb_head的变化。注意如果缓冲区满了选择丢弃最旧数据那么协议层必须能容忍丢包。如果你的业务不能丢包那缓冲区别设太小或者加流控让对端等 ACK 再发下一包。4. AT指令阶段先让模块听话再谈通信模块能正常上电、串口通了下一步是配置。这一步的目标是把模块的波特率、名称、角色、配对密码设成你想要的值并且把配置持久化断电重启后依然有效。4.1 进AT模式的两种做法以及为什么大多数失败都在这一步这是新手翻车率最高的地方。不同模块进入 AT 命令模式的条件完全不一样弄错了你发什么它都不回。经典蓝牙模块以 HC-05 为例需要在上电的瞬间或者上电之前把 EN有的板子标 KEY引脚拉高。具体做法是按住模块上的小按键再上电或者用 STM32 的一个 GPIO 在模块上电前输出高电平。进入 AT 模式后通信波特率固定为 9600与你之前设的透传波特率无关。这一点极其关键——很多人把透传波特率改成了 115200然后试着用 115200 进 AT 模式当然没反应。BLE 模块JDY-31、AT-09 之类大多不需要额外的使能引脚直接上电后用 9600 波特率发 AT 就能回。但也有例外某些模块要求上电后 3 秒内发 AT超时退出配置状态。还有HC-06它是纯从机AT 指令集比 HC-05 少没有设置角色的命令。进入 AT 模式的方式也因板子而异有的是上电时按住按键。不管哪种模块AT 命令的结尾必须是回车换行\r\n只发AT两个字母不发换行模块永远不会理你。用串口助手的时候这一点尤其容易忽略很多助手的发送新行选项默认是关的。4.2 常用指令与关键参数的取舍以经典的 SPP 模块为例常用命令大致如下不同固件版本会有差异以模块手册为准指令作用典型参数备注AT测试连通无返回 OK 说明进入 AT 模式ATNAMExxx设置设备名不超过 32 字节手机搜索时看到的名字ATPSWD1234设置配对密码4 位数字有些固件要求 4 位ATUART115200,0,0设置通信波特率波特率,停止位,校验位AT模式下不生效重上电后才变ATROLE0/1从机/主机0 从 1 主只有主从一体的模块支持ATCMODE0/1连接模式0 指定地址 1 任意地址做主机时才有意义ATORGL恢复出厂无调乱了的救命指令几个参数值得单独说。波特率改完必须重新上电。ATUART改的是透传模式下的波特率AT 模式本身始终是 9600。而且改完之后如果立刻发下一条指令很可能收不到回复因为模块已经切成新波特率了。正确做法是改完波特率断电重新上电这次不拉 EN 引脚再用新波特率去通信。密码位数有约束。有些固件只接受 4 位有些接受 4 到 16 位。设错了返回 ERROR但很多人不看返回值直接往下走最后手机连不上的时候才发现密码根本没设进去。每发一条 AT 指令都要确认返回了 OK这是一条铁律。恢复出厂是个好习惯。拿到模块第一件事先发一条恢复出厂指令把它从可能被前任用户改乱的状态里捞回来。你永远不知道买到的模块之前被怎么折腾过。4.3 BLE模块的AT体系别拿经典蓝牙的经验去套BLE 模块的 AT 指令和经典蓝牙不通用的。举几个典型差异BLE 模块设置名称往往用ATNAME不带等号后跟参数查询用ATNAME?返回里还会带OK前缀波特率命令可能是ATBAUD有些模块还有一个ATRESET用于软复位。更关键的是BLE 模块的连接概念不一样。经典蓝牙是配对连接蓝牙串口 App 里点一下设备就能收发。BLE 则要求手机端 App 去发现服务Service和特征值Characteristic往特定的特征值里写数据才能到模块的串口。如果你的手机端用的是原生蓝牙设置那基本连不上必须用专门的 BLE 调试 App。我一般会在项目文档里写清楚模块型号、固件版本、通信波特率、AT模式波特率、手机端用的App名字。这五行信息看着不起眼等半年后回来改代码能救命。// AT指令发送的一个小封装注意结尾的 \r\n void bt_send_at(const char *cmd) { char buf[64]; int n snprintf(buf, sizeof(buf), %s\r\n, cmd); HAL_UART_Transmit(huart1, (uint8_t *)buf, n, 100); HAL_Delay(200); // 给模块留出处理时间太急会丢响应 }HAL_Delay(200)这个延时很多人觉得粗暴但 AT 配置阶段是上电初始化的一部分快这 200ms 没有任何意义稳妥优先。等进了透传模式再谈效率。5. 应用层协议透传不等于可以裸奔蓝牙模块是透传的发什么收什么——这句话只对了一半。透传确实意味着模块不改你的数据但这恰恰带来了一个问题底层没有任何帧边界和校验你的应用必须自己造一套。5.1 为什么必须有帧结构考虑一个真实场景手机通过蓝牙发一条前进指令给小车。如果没有帧结构你收到的就是一个孤零零的字节0x01。现在假设因为干扰或者上电毛刺STM32 在不该收到数据的时候收到了一个0x01小车就自己往前冲了。这在遥控车上可能只是撞墙在别的场景里就不好说了。再考虑另一个场景一包数据应该是 5 个字节但 DMA 恰好在中途切了一刀你收到了 3 个字节。没有帧头帧尾的话你根本不知道自己收全了没有。所以协议层要解决三件事边界识别、完整性校验、指令区分。5.2 一套够用的帧格式我常用的格式是这样的字节位置内容说明0帧头 0xAA固定值用于同步1帧头 0x55两个字节帧头误判概率大幅下降2长度 LEN后续数据域的字节数不含校验3命令字 CMD区分不同功能4 ~ 4LEN-1数据域参数4LEN校验和从字节 2 到字节 4LEN-1 的累加和取低 8 位用两个字节做帧头而不是一个字节是因为单个 0xAA 在数据域里出现的概率太高会导致误同步。两个字节连续出现的概率就低得多。校验用累加和就够了不必上 CRC16。蓝牙链路本身有很强的纠错和重传机制应用层校验主要是防半包和程序自身的拼接错误累加和完全胜任而且计算快中断里也能跑。#define FRAME_HEAD1 0xAA #define FRAME_HEAD2 0x55 #define FRAME_MAX 64 typedef enum { SEEK_HEAD1 0, SEEK_HEAD2, WAIT_LEN, WAIT_CMD, WAIT_DATA, WAIT_SUM, } parse_state_t; static parse_state_t st SEEK_HEAD1; static uint8_t frame_len, frame_cmd; static uint8_t data_buf[FRAME_MAX]; static uint8_t data_idx; static uint8_t calc_sum; // 每来一个字节调用一次返回 1 表示收到完整帧 int frame_parse_byte(uint8_t b, uint8_t *cmd_out, uint8_t *data_out, uint8_t *len_out) { switch (st) { case SEEK_HEAD1: if (b FRAME_HEAD1) st SEEK_HEAD2; break; case SEEK_HEAD2: st (b FRAME_HEAD2) ? WAIT_LEN : (b FRAME_HEAD1 ? SEEK_HEAD2 : SEEK_HEAD1); break; case WAIT_LEN: frame_len b; calc_sum b; if (frame_len FRAME_MAX) { st SEEK_HEAD1; break; } st WAIT_CMD; break; case WAIT_CMD: frame_cmd b; calc_sum b; data_idx 0; st (frame_len 0) ? WAIT_DATA : WAIT_SUM; break; case WAIT_DATA: data_buf[data_idx] b; calc_sum b; if (data_idx frame_len) st WAIT_SUM; break; case WAIT_SUM: st SEEK_HEAD1; if (b calc_sum) { *cmd_out frame_cmd; *len_out frame_len; memcpy(data_out, data_buf, frame_len); return 1; } break; default: st SEEK_HEAD1; break; } return 0; }这段状态机有个细节值得注意在SEEK_HEAD2状态下收到0xAA时我保留了SEEK_HEAD2状态而不是退回SEEK_HEAD1。因为0xAA 0xAA 0x55这种序列里第二个 0xAA 应该被当作新的帧头起点。如果退回起点就会漏掉这一帧。这类帧头重叠的边界情况用真实数据跑一百遍也不一定能碰上但一旦碰上就是偶发丢包极难排查。5.3 主循环里的消费逻辑接收侧缓存在环形缓冲区里主循环里一次取一些字节出来喂给解析器uint8_t tmp[64]; uint16_t n ringbuf_pop(tmp, sizeof(tmp)); uint8_t cmd, data[FRAME_MAX], len; for (uint16_t i 0; i n; i) { if (frame_parse_byte(tmp[i], cmd, data, len)) { dispatch_command(cmd, data, len); } }注意dispatch_command里不要做耗时操作。如果某个命令需要驱动电机转两秒应该在命令处理函数里设置一个状态和目标值然后立刻返回让主循环的其他部分去执行。把阻塞动作放在协议处理里很快就会遇到发下一条指令没反应的问题。6. 从灯不亮到数据能通一条完整的排查链路前面讲的都是正常流程这一章还原真正卡住人的时刻。我把它整理成一个分层排查的路径遇到问题从上往下走不跳步。6.1 分层定位法层现象检查手段典型原因供电层模块灯不亮或闪烁异常万用表测 VCC-GND 电压电压不足、LDO 带不动、地线没接物理层灯正常但串口无任何数据短接模块 TX/RX 做回环杜邦线断、接反、焊点虚焊串口层收到乱码串口助手看实际字节波特率不对、时钟配置错、编码问题AT层发指令无响应确认是否进了AT模式没拉 EN、没发 \r\n、波特率不是 9600连接层AT正常但手机连不上看手机端报错密码错、模块是从机模式但手机没发起配对协议层连接正常但解析不出帧打印原始字节帧头不对、校验算法不一致、大小端我见过最典型的一个案例一位开发者发来的截图里串口助手收到的是成片的FF或者乱码。他一度以为是模块固件坏了。实际原因是STM32 的系统时钟没有按外部晶振配置还在用内部 HSI 跑导致 USART 的实际波特率和设定值差了百分之几累积到十个位就偏了半个位宽收到的自然全是垃圾。这个问题的特征是偶尔能收到正确的字节非常有欺骗性。波特率误差可以算一下如果 USART 挂在 72MHz 的 APB2 上目标 115200分频系数是 72000000/(16×115200)39.0625HAL 库会自动取最接近的整数分频加小数部分误差通常在千分之几以内通信没问题。但如果系统时钟实际是 8MHz 的内部 RC 振荡器还没校准那就是几倍的偏差必然乱码。所以第一步永远是确认 SystemClock_Config 有没有被正确调用、外部晶振有没有起振。6.2 几个我实际踩过的坑坑一模块的回环测试做不了。想验证串口通路常见做法是把模块拔掉把 STM32 的 TX 和 RX 短接自己发自己收。但模块插在板子上时模块的 TX 引脚可能一直在输出比如在广播时输出调试信息你短接上去就是两个输出对打读到的数据是乱的。测试前一定要把模块从插座上拔下来。坑二AT 指令回显造成的误判。有些固件默认开启回显你发AT它先原样回一个AT再回OK。如果你的代码只看有没有收到字符来判断成功就会把回显当成响应。判断成功的正确姿势是匹配OK这两个字符。坑三DMA 缓冲区被覆盖。用 DMA 接收时如果主循环处理太慢还没读走上一包数据DMA 已经把新数据写进同一个缓冲区了。表现为数据时好时坏好像被截断。解决办法是把 DMA 缓冲区设大一些或者用双缓冲HAL 的HAL_UARTEx_ReceiveToIdle_DMA配合半传输中断可以做到乒乓或者干脆在回调里立刻拷走。坑四手机端 App 的隐藏设置。某些蓝牙串口 App 默认发送的是十六进制还是文本、有没有加换行、编码是 UTF-8 还是 GBK这些设置会直接影响你收到的字节。调试时先用最简单的单字符测试比如发一个0x01在 STM32 端把收到的原始字节打印出来确认无误后再上协议。6.3 逻辑分析仪该看什么如果软件层面查不出问题就得上仪器。一个几十块的分析仪接到 TX、RX 两根线上采样率设到 1MHz 以上能直接看到波形。重点看三件事波特率对不对量一个位宽1/位宽就是实际波特率、数据位是不是 8 位、有没有帧错误。如果看到起始位之后电平一直不对那基本可以确定是波特率或者时钟问题。如果波形很干净但数据不对那就是软件配置比如校验位、停止位和模块不匹配。我自己遇到过一次偶发丢字节分析仪抓了半天都没问题最后发现是杜邦线太长加上没有共地地线上有几百毫伏的压差。所以接线长度尽量控制在 20cm 以内且务必保证 STM32 和模块共地这两条比什么调试技巧都管用。7. 让它从能跑变成跑得住教会模块收发数据只是起点。真正用在项目里还有几个问题必须提前处理。7.1 连接状态机别假设链路永远在线蓝牙链路是会断的。手机走远了、手机锁屏了、模块供电抖了一下都会断。如果你的代码只在初始化时配置一次模块然后假设它一直连着那断开之后整个系统就哑了。我一般会加一个简单的状态机用模块的状态指示灯引脚很多模块有 STATE 或者 LINK 引脚连接成功输出高电平作为输入之一配合超时判断typedef enum { BT_IDLE, // 未连接 BT_CONNECTED, // 已连接 BT_TIMEOUT, // 连接超时准备重连 } bt_state_t; static bt_state_t bt_st BT_IDLE; static uint32_t last_rx_tick 0; void bt_state_poll(void) { uint8_t link HAL_GPIO_ReadPin(BT_STATE_GPIO_Port, BT_STATE_Pin); switch (bt_st) { case BT_IDLE: if (link) { bt_st BT_CONNECTED; last_rx_tick HAL_GetTick(); } break; case BT_CONNECTED: if (!link) { bt_st BT_IDLE; } else if (HAL_GetTick() - last_rx_tick 5000) { // 5 秒没收到任何数据链路可能已经死了但状态脚没变 bt_st BT_TIMEOUT; } break; case BT_TIMEOUT: bt_soft_reconnect(); // 发 AT 或复位模块 bt_st BT_IDLE; break; } }那个 5 秒超时是我实际踩坑之后加的。有些情况下链路已经废了但状态脚还保持高电平光看状态脚会一直以为连着。加一个心跳超时能兜住这类情况。心跳可以由手机端定时发一个固定帧也可以由 STM32 主动发查询。7.2 功耗BLE省电不是自动的如果用 BLE 模块做电池供电的设备有几个地方决定成败。广播间隔。模块在未连接时会周期性广播间隔越短越容易被搜到也越费电。做产品时通常设到 100ms 到 500ms 之间调试阶段设短一点方便发现。从机的连接参数。BLE 连接建立后主从双方会协商一个连接间隔。间隔越长越省电但延迟越大。遥控场景下 20ms 到 50ms 比较合适只上报数据的传感器可以设到几百毫秒。STM32 自己的功耗。模块休眠了主机还在满速跑整体电流照样下不来。空闲时把主频降下来、关掉不用的外设、进 STOP 模式这些收益往往比调蓝牙参数更大。只是要注意进低功耗模式之后串口时钟可能停了唤醒之后要重新初始化接收。7.3 抗干扰与结构上的几个细节写了这么多代码最后往往败在物理层面。几条经验。电源和天线离远点。DC-DC 开关电源的噪声对 2.4GHz 频段影响很大模块天线尽量不要贴着电感放。用 LDO 给模块单独供一路是更稳的做法。外壳材质要注意。金属外壳会把信号挡得死死的如果必须用天线位置开个塑料窗口。手握住模块天线附近时信号也会明显衰减这是正常的设计时留出余量就行。数据速率别顶格用。模块标称支持某个波特率不代表在电磁环境复杂的地方能稳定跑。我通常会把实际业务速率控制在标称值的一半以内留出重传和重发的余量。比如每 50ms 发一包 20 字节算下来才 4kbps接 115200 的波特率绰绰有余。最后说一个我认为最值得养成的习惯把蓝牙模块当成一个独立的、可能出故障的外部设备来对待而不是当成 STM32 的一个外设。它有自己的供电、自己的固件、自己的状态机、自己的通信协议。你的代码要对它做健康检查、要在断线时重连、要对它的数据做校验。想清楚这一点很多玄学问题就不再玄学了。
阅读完成 · 觉得有帮助?
咨询建站