EFR32 BLE主机日志调试实战:从阻塞打印到异步缓冲
2026/9/11 23:36:20 网站建设 项目流程

最近在做一个基于芯科EFR32系列芯片的BLE主机项目,简单说就是让设备作为Central端去扫描、连接周边的心率计、环境传感器这些外设,再把数据汇聚起来统一上报。整个开发过程中,最让我头疼的不是BLE协议本身,反而是看起来最简单的打印LOG这件事。芯片端不像手机端有现成的Logcat可以用,串口、RTT、SWO各种通道各有脾气,踩了一堆坑之后,我觉得有必要把这段经历完整记录下来,给准备上手芯科BLE方案、尤其是做主机Central模式的工程师一点参考。

这个项目会牵扯到扫描、多连接管理、GATT操作、连接参数维护,每一层出了问题都要靠日志来定位。但无线协议栈项目里的日志打印,和普通MCU开发完全不是一回事——它不只是“加个printf就行”,打印太慢会干扰协议栈时序,打印位置不对会直接卡死,打印口没选好还会把功耗测试整个毁掉。这篇文章里我会把自己实际遇到的坑、排查过程和最终方案都讲清楚,从日志通道选型到缓冲队列实现,再到发布版怎么把日志关干净,尽量做到看完了就能上手。

1. 项目概述与整体思路

1.1 这个项目在做什么:EFR32跑BLE Central

先说清楚项目背景。设备端用的是芯科EFR32BG22系列SoC,这颗芯片在低功耗蓝牙市场很常见,Cortex-M33内核,BLE 5.x协议栈,片上资源够用,关键是低功耗表现确实好。我们的设备作为BLE主机(Central),主要任务是周期性扫描周围的BLE外设,比如心率计、温湿度传感器、电量监测模块这些,主动发起连接,然后通过GATT读写特征值把数据取回来,最多同时维护3~4路连接。

这个“主机”角色看着简单,实际写起来比从机麻烦不少。从机只需要广播然后等连接,而主机要自己管理扫描窗口、连接间隔、连接超时,还有多路连接之间的调度。我第一次拿到板子的时候,觉得只要照着SDK里的例子把Scanner和Central跑起来就行,结果真正调试起来才发现,出问题的时候你根本不知道是协议栈配置错了,还是外设那边没广播,还是连接建立了但GATT操作失败——这时候唯一能依靠的就是日志。可以说,日志系统就是无线调试时的眼睛,眼睛好不好用,直接决定项目进度。

1.2 无线协议栈项目的日志调试,和普通MCU开发有什么区别

我以前做普通MCU开发时,日志打印的套路很简单:串口初始化一下,重定向printf,想打哪儿打哪儿。但在BLE这类带无线协议栈的项目里,这个思路行不通,原因有两个层面:

第一是时序敏感性。BLE协议栈底层是一个状态机,连接事件、扫描事件、广播事件都是按特定时间窗调度的。比如连接间隔设成7.5ms,意味着每7.5ms就要有一次射频收发。如果你在某个回调里打印了100字节日志,在115200波特率下大约要耗时8.7ms,这一个连接事件就整个错过了,几次下来就会触发连接监督超时,连接直接断开。这个坑我在3.1节会详细讲。

第二是上下文约束。BLE协议栈有很多回调函数,这些回调运行在协议栈的调度上下文里,在里面做耗时操作会阻塞整个栈的事件处理。更危险的是中断里打日志,printf这类标准库函数不是可重入的,在中断上下文调用轻则丢数据,重则死锁。所以日志方案必须从一开始就设计好,而不是最后随便加个打印。

除了这两点,芯科的SDK和开发环境也有它自己的脾气。GSDK(现在新版叫Simplicity SDK)采用组件化设计,日志相关的功能要自己选组件、自己配置,不像Arduino那样默认就给你串口输出。SDK版本不同,API还会有差异,网上很多教程拿到新版SDK上直接编译不过。这些坑我都会在后面写到。

2. 日志通道选型与搭建

2.1 VCOM、RTT、SWO三种通道怎么选

芯科平台常用的日志输出通道主要有三种:板载VCOM、SEGGER RTT、SWO。先说结论:调试阶段我选了VCOM,但整套设计里预留了RTT作为备用方案。下面这个表是我实际对比后的结果:

通道物理载体速度是否占用UART功耗影响使用便利性
VCOM(虚拟串口)板载J-Link桥接的CDC串口默认115200占用一组UART引脚调试时会增加功耗插USB即可,用任意串口工具查看
RTTJ-Link调试器内存通道很高(可达MB级)不占用UART需要在RAM中开缓冲区需要J-Link RTT Viewer或IDE支持
SWO调试器SWO引脚较高不占用UART需要配置SWO时钟和工具

VCOM的优势是直观、门槛低,打开串口助手就能看到数据,适合最开始跑通逻辑。但它有个致命弱点:是阻塞式的,打印速度慢。RTT速度快得多,它本质上是J-Link调试器直接读写芯片内存里的缓冲区,不经过UART,不占用额外引脚,风险是调试器断开时看不到数据,而且有些场景RTT Viewer配置起来比较繁琐。

我在选型时的思路是这样的:初期逻辑验证用VCOM,因为方便,团队里几个人连上串口都能看。到了后期调试时序、排查连接问题时,如果发现日志打印本身影响了协议栈,再切RTT。不过实际上,后期我并没有切换到RTT,而是改用“事件回调里不打印、只入队,主循环里再打印”的异步日志方案,既保留了VCOM的便利性,又不阻塞协议栈。这个方案在第三章详细说。

2.2 搭建VCOM+printf重定向的完整步骤

在Simplicity Studio里搭建VCOM日志输出的步骤,官方文档写得不算差,但有几个细节容易忽略。第一步是在工程里添加UARTVCOM组件,它会自动配置好开发板上VCOM对应的UART,通常是在EUSART0上。注意这里说的是开发板自带的VCOM,如果你用的是自己画的板子,没有板载J-Link,那就要自己添加一个EUSART组件,把TX/RX引脚接到USB转串口模块上,两者不是一个东西。

第二步是重定向printf。GSDK里提供了Retarget Serial组件,添加之后它会接管printf的底层输出。如果你用的是App Log组件,那就更方便,它会提供app_log函数,同时支持日志级别控制,我在4.3节会讲。我自己是用了Retarget Serial加标准printf,代码里只需要在main函数初始化阶段调用:

#include "sl_retarget_serial.h" #include <stdio.h> void log_init(void) { sl_retarget_serial_init(); printf("\r\n---- BLE Central Log Init OK ----\r\n"); }

这一步看起来简单,但有一个顺序问题很关键:sl_retarget_serial_init()必须在协议栈初始化之前调用,否则早期协议栈打印的日志会丢失,而且一些外设模块可能在初始化时依赖串口做状态输出。我一开始是把打印初始化放在sl_bt_enable()之后,结果前几十条带时间戳的消息全没了,排查了半天才发现是初始化顺序反了。

第三步是验证。把SDK自带的Empty例程跑起来,在while(1)里加一个循环打印,然后用串口助手连上,波特率设为115200,正常情况下应该能看到输出。如果看不到输出,大概率是引脚配置问题或者DTR信号问题,这一节末尾的坑会细说。

2.3 日志串口的引脚与硬件接线坑

关于硬件的坑,我提两个。第一个是引脚冲突:EFR32BG22的很多GPIO是内部外设复用的,你配置UART时选的TX/RX引脚,可能和SPI、I2C、PWM或者其他外设的引脚冲突。尤其是在做多外设项目时,引脚分配要非常小心。我遇到的情况是日志TX脚和某个传感器的I2C时钟线选了同一个引脚,I2C初始化之后串口输出就乱了。解决方法是画板子之前就把日志串口的引脚固定下来,尽量避开多功能引脚,或者在软件配置时通过Simplicity Studio的Pin Tool检查冲突。

第二个坑是DTR信号。很多串口工具(比如SSCOM、PuTTY)在连接VCOM时要正确控制DTR或者RTS,否则J-Link桥接的CDC串口可能不工作。我最初用某款串口助手,日志一条都收不到,换了一款串口工具却正常,后来才发现是DTR的问题。如果遇到“串口能打开但没输出”的情况,先换个串口工具试试,或者检查一下工具里DTR和RTS的勾选状态,这个排查成本很低,但很实用。

提醒一下:VCOM是开发板调试阶段最方便的输出,但它只在你插着USB调试线的场景下工作。如果用电池供电做低功耗验证,或者设备独立运行,就必须考虑日志通道断电或者自动关闭的问题,我在3.4节会专门讲。

3. 核心踩坑记录与解决过程

3.1 坑一:日志一多BLE就断连,真正的元凶是阻塞打印

这个坑是整个项目里最折磨人的一个,值得单独写一节。

现象是这样的:设备以7.5ms的连接间隔连接了一个心率计,单路连接时一切正常。后来我加了日志,在扫描到外设的时候打印一些调试信息,比如设备地址、广播数据、RSSI这些,大概每遇到一个扫描包就打50字节左右。然后奇怪的事情出现了:连接建立大约几十秒后,设备莫名其妙地断连了,而且不是每次都断,是随机断,特别难复现。一开始我以为是协议栈配置问题,把连接参数调来调去,结果问题依旧。

后来我把日志调大打印,用逻辑分析仪抓了串口和射频的时序关系,才明白根因。BLE的连接事件是有严格时间窗的,连接间隔7.5ms意味着协议栈每隔7.5ms就要处理一次射频收发的任务。而我打印50字节日志,在115200波特率下,一个字节大概耗时86.8微秒,50字节就是4.3毫秒。这个时间已经超过了连接事件的处理窗口,导致射频收发任务被推迟,底层收不到对端的包就开始重传,重传多了超出了连接监督超时(Connection Supervision Timeout)的时间,协议栈就判定连接无效,主动断开了。

时间上的对应关系很清楚:串口打印的阻塞时间直接叠加到了协议栈调度上。我当时打印一行日志,处理完一个事件再进下一个事件时,时间已经晚了,连接就崩了。用公式表示就是:日志耗时 = 字节数 / 波特率 * 10(起始位+8数据位+停止位)。所以日志越长,波特率越低,对时序破坏越严重。

解决思路有两个方向:一是提高波特率,比如从115200提到921600,同样50字节的耗时从4.3ms降到0.54ms,效果立竿见影。二是不在关键回调里直接打印,改成异步输出——先把日志内容放进内存缓冲区,由外层循环慢慢往串口发。这两个方向我都试了,波特率提高治标不治本,因为日志量一大还是会堵;真正彻底解决是后面3.2节要讲的异步方案。

3.2 坑二:回调函数里直接printf会导致协议栈卡死

前面说了,BLE协议栈的事件回调是运行在调度实体内部的。我之前习惯在sl_bt_evt_connection_opened_id这个事件回调里打印连接成功消息,看起来天经地义。项目初期日志量不大,确实没出问题,但从某个版本加了更多调试信息之后,开始出现偶发性的卡死——整个设备像死机了一样,协议栈完全不再响应,只有复位才能恢复。

查了很久才定位到问题:printf不是线程安全的,也不可重入,它内部会调用底层UART驱动,如果UART驱动的发送是轮询等待方式,printf就会一直忙等。而这个printf是在BLE协议栈的事件处理函数里被调用的,一旦UART发送被某个更高优先级的事情打断,或者和另一个上下文的打印产生了竞争,就可能死锁。更隐蔽的是,有些SDK的协议栈事件回调里会关中断保护临界区,如果你在里面调用printf,而printf又依赖UART中断,就形成了一个“中断等待死锁”——临界区没退出时UART中断永远进不来,UART发送永远不完成,然后协议栈永远挂在回调里。

解决方法是“回调里只记录,不输出”。我写了一个简单的异步日志模块,核心是一个环形缓冲区:

#define LOG_BUF_SIZE 1024 static volatile uint8_t log_ring[LOG_BUF_SIZE]; static volatile uint16_t log_head = 0; static volatile uint16_t log_tail = 0; void log_enqueue(const char *msg, uint16_t len) { for (uint16_t i = 0; i < len; i++) { log_ring[log_head] = msg[i]; log_head = (log_head + 1) % LOG_BUF_SIZE; if (log_head == log_tail) { // 缓冲区满,丢掉最旧的数据 log_tail = (log_tail + 1) % LOG_BUF_SIZE; } } }

然后在主循环里做真正的打印:

void log_flush(void) { while (log_tail != log_head) { uint8_t c = log_ring[log_tail]; log_tail = (log_tail + 1) % LOG_BUF_SIZE; uart_write_byte(c); // 底层UART单字节发送 } }

事件回调里需要打日志时,只调用log_enqueue,把数据丢进缓冲区就返回,不回等待串口。主循环的log_flush在空闲时慢慢往外发,这样即使一秒钟产生很多日志,也不会阻塞协议栈的调度。这个方案的代价是内存占用多了一些,但对于日志调试这个目的来说,1KB的缓冲区完全不算什么。

3.3 坑三:printf不输出或者乱码

这个坑虽然不像断连那么致命,但浪费了我几乎一天的时间,写出来希望大家少走弯路。

现象之一是printf完全不输出。我当时把例程跑起来,代码里写了打印,串口助手打开一点反应都没有。排查过程是这样的:先确认串口助手波特率没问题,115200;然后检查sl_retarget_serial_init()有没有被调用,结果发现Simplicity Studio生成的项目里,初始化顺序是自动生成的,但Retarget Serial组件的初始化在platform_init阶段就执行了,按理说没问题。最后想到去查UART引脚配置,才发现例程默认的VCOM引脚和我在引脚配置工具里改掉的引脚不一致——我为了方便布线改了引脚配置,但UARTVCOM组件还指向原来的引脚,导致数据根本没从预期引脚出来。

乱码的问题则主要出在两个地方:一是波特率不匹配,这个好排查;二是时钟配置。EFR32的EUSART时钟源如果配置错了,或者外部晶振频率和SDK默认值不一致,会导致波特率实际偏差很大。比如实际波特率偏差超过2%,串口接收就会开始出现乱码。我遇到的情况是用的32768低频外部晶振,结果UART时钟源配到了HFXO上,频率对不上,输出全是乱码,改成正确的时钟源后恢复正常。

还遇到过一种特殊情况:printf里忘记加\n,终端工具不会刷新。有些串口助手默认遇到\n才刷新显示,如果你打印的一句日志末尾没有换行,它会一直存在缓冲区里,看起来就像没输出。这个很好解决,统一在日志后面加\r\n就行。

3.4 坑四:低功耗测量被日志口毁了

我们做BLE设备,低功耗是重点指标。项目后期测功耗时,发现整机电流比规格书高了不少,排查了半天,罪魁祸首就是开发板上的日志串口:调试时一直插着USB线,VCOM一直接在供电状态,再加上printf往UART发数据带来的功耗,直接把电流拉高了几百微安甚至毫安级别。

低功耗测量有一套标准做法,在此之前得先把日志关干净。我总结了三个层级:

  • 硬件层:测量功耗时要用电池或者电流表直连供电,插着J-Link测出来的电流没有任何参考意义,因为J-Link本身就有功耗,还会给板子额外供3.3V电压。
  • 外设层:进入睡眠前要主动关闭UART外设时钟,把TX、RX引脚配置成普通GPIO并拉低或者拉高,避免悬空引脚产生漏电流。如果引脚浮空,CMOS输入级的漏电路径会造成微安级甚至几十微安的额外功耗。
  • 软件层:发布版里把日志全部关掉,只保留错误级别,或者干脆用一个宏彻底关闭日志功能。

关于软件层怎么做,我在4.3节会给出具体的开关方案。总之,日志和低功耗天然是矛盾的,必须在设计阶段就想好怎么切换,而不是等到测功耗那天再临时改代码。

4. 常见问题与排查技巧实录

4.1 三类典型日志问题的定位流程

调试过程中反复出现的日志问题,归纳起来就是三类:不打印、乱码、打印到一半卡死。我整理了一个快速排查思路,遇到问题直接按顺序走一遍,省很多时间。

问题现象排查顺序常见根因
完全不打印1. 串口工具和波特率 2. 引脚配置 3. 组件是否添加DTR未启用、引脚冲突、Retarget组件缺失
乱码1. 波特率 2. 时钟源 3. 接线强弱上拉时钟频率不匹配、线路干扰、VCOM电平不符
打印到一半卡死1. 是否在中断/回调中打印 2. 缓冲区溢出 3. 死锁在回调中阻塞打印、环形缓冲未做溢出保护、临界区嵌套

具体来说,“完全不打印”先换串口工具,排除DTR问题。接着查工程里有没有UARTVCOM或Retarget Serial组件,没有就添加。然后查引脚配置工具,看UART的TX/RX引脚是否被其他外设占用。“乱码”优先确认波特率,特别是SDK版本升级后默认波特率可能变化;接着查时钟树,确保UART时钟源和实际晶体频率一致。“打印到一半卡死”是最值得警惕的,优先检查是否在BLE回调或中断里调用了printf,如果是,立刻改成异步日志。

4.2 日志缓冲队列的实现思路与注意事项

前面给出了环形缓冲区的核心代码,这里补充几个实现上的注意事项,这些是我实际踩过之后总结出来的。

第一个是缓冲区大小的选择。1KB对调试日志来说够用,但如果你的协议栈事件非常密集,比如扫描到大量设备、多路连接同时有数据进来,1KB可能不够,会出现日志被覆盖的情况。建议刚开始设成2KB,然后根据实际调试过程中的丢失情况调整。需要区分的是:日志丢失本身不代表功能错误,它只说明日志量超过了串口输出能力,这时候要么减小日志量,要么增大缓冲区,要么提高串口波特率。

第二个是缓冲区满时的策略。我在示例代码里用的是覆盖最旧的策略,这对调试场景更友好,因为最新的事件才是你最需要关注的。如果你希望保留早期的日志用于事后分析,可以在覆盖时设置一个溢出标志,把“发生过溢出”这个事实也打到日志里,这样你就知道日志是否完整。

第三个是单字节发送函数。uart_write_byte在芯科SDK里可以用EUSART的阻塞发送接口,也可以用查询状态的方式等待上一个字节发完再发下一个。注意这个函数只在主循环的log_flush里调用,不要在中断里调,否则又回到了老问题。

4.3 用日志级别与条件编译,把日志关在发布版外面

日志代码写得再优雅,最终发布版里也不能带着一大堆调试打印,既影响性能又可能泄露调试信息。芯科GSDK里App Log组件提供了日志级别控制,但如果你用的是自己写的日志模块,也可以用条件编译的方式来做。

一种做法是定义日志级别宏:

#define LOG_LEVEL_ERROR 0 #define LOG_LEVEL_WARN 1 #define LOG_LEVEL_INFO 2 #define LOG_LEVEL_DEBUG 3 #define CURRENT_LOG_LEVEL LOG_LEVEL_DEBUG #define log_error(...) do { if (CURRENT_LOG_LEVEL >= LOG_LEVEL_ERROR) printf("[E] " __VA_ARGS__); } while(0) #define log_warn(...) do { if (CURRENT_LOG_LEVEL >= LOG_LEVEL_WARN) printf("[W] " __VA_ARGS__); } while(0) #define log_info(...) do { if (CURRENT_LOG_LEVEL >= LOG_LEVEL_INFO) printf("[I] " __VA_ARGS__); } while(0) #define log_debug(...) do { if (CURRENT_LOG_LEVEL >= LOG_LEVEL_DEBUG) printf("[D] " __VA_ARGS__); } while(0)

开发阶段把CURRENT_LOG_LEVEL设成LOG_LEVEL_DEBUG,日志全开;发布前改成LOG_LEVEL_ERROR,甚至直接把日志宏定义成空操作,这样编译出来的二进制完全不包含调试字符串,既减小了代码体积,也彻底消除了日志对时序的影响。

这个方法看起来很基础,但很多人一开始图省事,直接在代码里写printf,到发布时再一行一行去删,既费时又容易误删功能代码。从一开始就用宏包一层,后面切换调试和发布版本只需要改一个宏定义,非常省心。

4.4 扫描密集场景下日志丢数据的处理思路

还有一个我在主机模式下遇到的特定问题:扫描阶段如果设备比较密集,比如展会环境或者实验室里同时开着几十个BLE设备,扫描报告事件会连续不断到来。如果你在sl_bt_evt_scanner_scan_report_id回调里做日志打印,即使用了异步缓冲,日志量还是会瞬间爆炸,缓冲区被写满,日志大量丢失。

这个问题要从两个方向解。一是减小日志量,扫描阶段不要每收到一个扫描报告就打印完整信息,可以只打印设备地址的最后两个字节,或者只打印RSSI值,把这些关键信息浓缩成一行。二是调整日志策略,扫描阶段优先输出到缓冲队列,连接建立之后再把缓冲区的历史日志逐渐刷出来。这样既不会因为日志量过大拖累扫描性能,也能保留足够的信息用于分析连接建立前后的状态变化。

5. 几个让调试效率翻倍的小技巧

5.1 打上时间戳,定位时序黑洞

掌握了异步日志之后,我加了一个更有效的功能:给每条日志打上时间戳。时间戳不一定要精确到微秒,毫秒级别就够了,关键是能帮助你看到事件之间的时序关系。

我用的是芯科SDK里的sl_sleeptimer_get_tick_count(),转换成毫秒后格式化进日志。这样打印出来的日志长这样:

[12345] [I] Scan report from 00:11:22:33:44:55, RSSI=-42 [12389] [I] Connection opened to 00:11:22:33:44:55 [12501] [W] GATT service discovery started [13201] [E] GATT service discovery timeout

有了时间戳,很多问题一眼就能看出来:比如GATT服务发现花了800ms,说明对端设备响应慢或者MTU配置不合理;扫描到连接之间隔了400ms,说明连接参数没有优化到位。这些时序信息在调试无线项目时比什么都值钱。

5.2 把日志通道和业务数据通道分开

这个项目后期我对日志模块做了一次重构,把调试日志和业务数据的输出通道彻底分开。方法很简单:日志走VCOM,业务数据走另一个独立的UART,或者通过板上的LED灯做简单的状态指示。这样做的好处是,平时开发调试时打开日志串口看细节,做整机联调时只关注业务数据串口,互不干扰。

如果硬件上只有一个串口可用,也可以指定某一个特定前缀标记日志消息,然后在PC端用工具过滤。比如业务数据统一用#开头,调试日志统一用[D]开头,用串口助手的日志过滤功能就能切换。总之,日志通道越独立,后期分析问题越省力。

5.3 现场排查经验:一套日志排查的固定路径

最后分享一个我自己沉淀下来的排查路径。遇到BLE问题,先抓协议栈的原始上报事件日志,看事件序列对不对;再看GATT操作的关键节点,比如服务发现、使能通知、读特征值这些有没有按顺序完成;最后才加业务层的业务日志。如果一上来就打一堆业务日志,很难定位问题到底出在链路层还是应用层。

具体来说,我会先用芯片原厂的health check例程验证硬件环境和基本射频状态;然后把日志级别开到DEBUG,跑一版带完整事件日志的固件;问题复现后,把日志导出来,按时间线画出事件顺序,和正常流程对比,差异点基本就是问题所在。用这个思路,前面说的断连、卡死、扫描异常这些问题都能在几轮之内定位。

6. 写在最后的一些体会

这次做BLE主机的经历,让我对“打印日志”这件事有了非常不一样的认识。以前总觉得日志是代码里最简单的部分,现在才知道在无线协议栈项目里,日志设计的好坏直接影响项目进度和调试效率。日志不是你想打就能打的,它的输出位置、输出方式、输出量,每一个细节都可能影响到协议栈的时序和稳定性。

我个人在实际调试中的最大感触是:越早把日志系统搭好,后面越省心。不要觉得项目刚开始没必要折腾日志缓冲、级别控制这些“不着急”的功能,等真正遇到问题时再补,往往已经定位不到复现条件了。如果你也是刚开始做芯科BLE方案,建议第一步就把异步日志模块搭好,顺手把时间戳加上,这几个小时投入,后面一定会加倍的回报给你。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询