1. 为什么我最终放弃了串口打印,转向 RTT Viewer
嵌入式调试这件事,做过几年的人都有一个共同的痛点:板子一旦跑飞或者进 HardFault,串口打印就跟着一起没了。你辛辛苦苦在代码里埋了几十个printf,结果关键时刻一个都出不来,只能靠点灯大法一个个试。更别提串口打印本身还占着 UART 外设,波特率一高就丢数据,波特率一低又拖慢主循环,遇到时序敏感的场景根本不敢用。
RTT(Real Time Transfer)这套机制就是冲着这个痛点来的。它的核心思路很直接:既然调试器本来就已经通过 SWD 接口连到了芯片的调试单元,那为什么不直接借用调试器这条通道来传数据?于是 SEGGER 搞出了 RTT,在目标芯片的内存里开一块环形缓冲区,目标代码往缓冲区里写,调试器从缓冲区里读,整个过程不占用任何外设,也不需要停机。速度比串口快几个数量级,而且即使 CPU 在断点停住的时候,RTT 缓冲区里的数据依然能被读出来——这一点在排查死机问题时简直是救命稻草。
而 DAPLink 是 ARM 官方维护的开源调试器固件,市面上大量国产调试器(比如各种 CMSIS-DAP 兼容的板子)刷的都是它。DAPLink V0.0.20 这个版本在 RTT 支持上已经比较成熟了,配合 J-Link 的 RTT Viewer 工具或者 SEGGER 官方的 RTT Viewer,就能实现上面说的这套调试流程。我手头这块板子用的是 DAPLink 固件,之前一直用串口调试助手凑合,直到有一次调一个电机 FOC 的时序问题,串口打印直接把控制周期拖崩了,才下决心把 RTT 这套流程彻底跑通。
这篇内容适合谁看?如果你手上有 DAPLink 调试器(或者 CMSIS-DAP 兼容的调试器),想在 Keil、IAR 或者 VS Code 环境下用 RTT 做实时调试,又不想额外买 J-Link,那这篇就是写给你的。我会从固件确认、驱动安装、RTT 代码集成、Viewer 连接、到常见问题排查,一步步走完。中间踩过的坑我也会原样写出来,省得你再花时间。
2. DAPLink 固件与 RTT 支持的底层逻辑
2.1 DAPLink 到底是个什么东西
很多人把 DAPLink 和 CMSIS-DAP 混为一谈,其实两者是不同层面的东西。CMSIS-DAP 是 ARM 定义的一套调试接口协议,规定了调试器怎么通过 USB 和上位机通信、怎么把 SWD/JTAG 时序打包成 USB 数据包。而 DAPLink 是这套协议的一个具体实现,是一套运行在调试器 MCU 上的固件。你可以理解为:CMSIS-DAP 是"普通话"的标准,DAPLink 是一个说普通话的具体人。
DAPLink 固件通常跑在调试器板子上的那颗 MCU 里(常见的是 STM32F103 或者 NXP 的 LPC11U35),它对外提供几个 USB 端点:一个用于 CMSIS-DAP 调试通信,一个用于拖拽下载的 MSC 大容量存储,还有一个虚拟串口 CDC。RTT 功能就是挂在 CMSIS-DAP 这个通道上的——上位机通过 DAP 协议发送特定的命令,DAPLink 固件去读写目标芯片内存里的 RTT 控制块。
这里有个关键点:RTT 能不能用,取决于 DAPLink 固件有没有实现 DAP_RTT 相关的命令。早期的一些 DAPLink 固件只实现了基本的调试和下载功能,RTT 命令是缺失的,这种情况下你在 RTT Viewer 里怎么连都连不上。V0.0.20 这个版本已经包含了 RTT 支持,但市面上流通的固件版本很杂,有些厂商自己改过的固件可能把这块裁掉了。
2.2 RTT 控制块在内存里长什么样
RTT 的工作机制说白了就是"共享内存 + 轮询"。目标芯片上电后,RTT 的初始化代码会在 RAM 里分配一块结构体,叫_SEGGER_RTT,这个结构体就是控制块。控制块里记录了上行通道(目标到主机)和下行通道(主机到目标)的缓冲区地址、大小、读写指针等信息。
主机端的 RTT Viewer 通过调试器读取目标芯片内存,先找到_SEGGER_RTT这个符号的地址,然后按照控制块里的描述去读环形缓冲区。因为是直接读内存,所以不需要目标 CPU 参与,CPU 停住也不影响。缓冲区默认大小是 1KB 上行、16 字节下行,这个大小可以在SEGGER_RTT_Conf.h里改。
我实测下来,上行缓冲区调到 4KB 之后,即使在高频打印场景下也很少出现缓冲区满导致的丢数据。但要注意,缓冲区是在 RAM 里分配的,如果你的芯片 RAM 很紧张(比如只有 20KB 的 STM32F030),那就得权衡一下,别为了调试把业务代码的 RAM 挤没了。
2.3 为什么 DAPLink 的 RTT 比 J-Link 慢一些
这里得说句实话:DAPLink 的 RTT 速度确实不如 J-Link。原因在于 J-Link 固件对 RTT 通道做了专门的优化,读写内存的效率很高;而 DAPLink 走的是通用的 CMSIS-DAP 内存读写命令,每次读写的开销更大。实测在同样的 1MHz SWD 时钟下,J-Link 的 RTT 能跑到几百 KB/s,DAPLink 大概只有几十 KB/s。
但这个速度对于绝大多数调试场景已经足够了。你想想,串口 115200 波特率也就 11KB/s 左右,DAPLink 的 RTT 已经比它快好几倍了。除非你要传大量波形数据做实时分析,否则完全够用。如果真遇到带宽瓶颈,可以把 SWD 时钟调高(比如 4MHz 或 8MHz),RTT 速度会跟着上去。
3. 从零跑通 RTT Viewer 的完整操作链路
3.1 确认你的 DAPLink 固件版本和 RTT 支持
第一步不是急着装软件,而是先确认你手上的调试器到底刷的是什么固件。把调试器插到电脑上,它会枚举出一个名为DAPLINK或者MBED的 U 盘,打开里面的DETAILS.TXT文件,里面会写明固件版本号。如果版本低于 V0.0.20,建议先升级固件。
升级固件的方法很简单:把调试器进入 bootloader 模式(通常是按住复位键再插入 USB),它会枚举成一个MAINTENANCE或者BOOTLOADER的 U 盘,把对应版本的.bin或.hex固件文件拖进去,拔插一次就完成了。固件文件可以从 DAPLink 的官方仓库或者你调试器厂商的页面下载,注意要选对芯片型号对应的固件,刷错了会变砖。
提示:刷固件前一定要确认固件和你的调试器硬件匹配。我见过有人把 STM32F103 版本的固件刷到了 LPC11U35 的板子上,结果调试器直接不认了,只能拆开用 SWD 重新刷。
刷完之后再打开DETAILS.TXT,确认版本号是 V0.0.20 或更高。同时留意文件里有没有CMSIS-DAP和RTT相关的字样,有些固件的 DETAILS 文件里会列出支持的功能。
3.2 驱动安装:WinUSB 还是 HID
DAPLink 调试器在 Windows 下有两种驱动模式:HID 和 WinUSB。HID 模式不需要装驱动,插上就能用,但速度慢;WinUSB 模式需要装驱动,速度快很多。RTT Viewer 建议用 WinUSB 模式,否则 RTT 的吞吐量会很难看。
装 WinUSB 驱动最省事的办法是用 Zadig 这个工具。打开 Zadig,在设备列表里找到你的 DAPLink 设备(通常显示为CMSIS-DAP或者DAPLink),然后在驱动选择里选WinUSB,点Replace Driver就行。装完之后在设备管理器里应该能看到WinUSB device或者CMSIS-DAP之类的条目。
如果你用的是 Keil MDK,Keil 自带的驱动安装包里其实也包含了 DAPLink 的 WinUSB 驱动,装 Keil 的时候勾选上就行。但有时候 Keil 装的驱动版本比较老,和 V0.0.20 固件配合会有问题,这时候还是建议用 Zadig 重装一遍。
3.3 在工程里集成 RTT 代码
RTT 要在目标芯片上跑起来,需要把 SEGGER 提供的 RTT 源码编译进你的工程。源码就几个文件:SEGGER_RTT.c、SEGGER_RTT.h、SEGGER_RTT_Conf.h,还有可选的SEGGER_RTT_printf.c。这些文件可以从 SEGGER 官网下载,或者从 J-Link 的安装目录里找。
集成步骤以 Keil 为例:
- 把
SEGGER_RTT.c和SEGGER_RTT_printf.c添加到工程的源文件组里。 - 把 RTT 源码所在的目录加到 Include Paths 里。
- 在
SEGGER_RTT_Conf.h里根据你的芯片调整缓冲区大小和内存段配置。 - 在
main函数开头调用SEGGER_RTT_Init(),虽然不调用也能用(第一次打印时会自动初始化),但显式调用更稳妥。 - 用
SEGGER_RTT_printf(0, "value = %d\n", val)替代原来的printf。
这里有个细节要注意:SEGGER_RTT_Conf.h里有个SEGGER_RTT_SECTION宏,默认是空的,表示控制块放在默认的 RAM 段。如果你的工程用了分散加载文件(scatter file)或者链接脚本,可能需要把 RTT 控制块放到一个固定的段里,方便调试器定位。不过对于大多数场景,默认配置就够了,RTT Viewer 会自动扫描内存找控制块。
注意:如果你同时用了 FreeRTOS 或者其他 RTOS,RTT 的打印函数本身是线程安全的(内部有锁),但在中断里调用
SEGGER_RTT_printf要小心,因为锁可能导致优先级反转。中断里建议用SEGGER_RTT_Write这种不带格式化的函数,或者干脆用SEGGER_RTT_WriteNoLock。
3.4 RTT Viewer 的连接配置
代码编译下载之后,打开 J-Link RTT Viewer(SEGGER 官网免费下载)。虽然名字叫 J-Link RTT Viewer,但它其实支持 CMSIS-DAP 设备。在连接配置界面里:
Connection to J-Link选USB,然后在Target Device里选你的芯片型号。如果列表里没有,选Cortex-M0/M3/M4之类的通用内核也行。- 关键的一步:在
Connection下拉框里,把J-Link改成CMSIS-DAP或者DAPLink。不同版本的 RTT Viewer 这个选项的位置可能不一样,有的在高级设置里。 Interface选SWD,Speed先设 1000kHz,跑通之后再往上调。RTT Control Block选Auto Detection,让软件自动扫描内存找控制块。如果自动扫描失败,可以手动填_SEGGER_RTT的地址(从 map 文件里查)。
点OK之后,如果一切正常,你会看到 RTT Viewer 的终端窗口里开始刷出打印信息。如果没反应,先别急着怀疑人生,往下看常见问题那节。
4. 那些让我折腾了半天的常见问题
4.1 RTT Viewer 连不上,一直提示 "Could not find control block"
这是最常见的问题,没有之一。原因通常有三个:
第一,目标芯片根本没跑起来。RTT 控制块是在目标代码第一次调用 RTT 函数时初始化的。如果你的程序卡在启动阶段(比如时钟没配好、HardFault 了),控制块就不存在,Viewer 自然找不到。解决办法是先确认程序能正常跑,点个灯或者用调试器单步一下。
第二,控制块被优化掉了。有些编译器会把没被引用的全局变量优化掉。虽然_SEGGER_RTT是被 RTT 函数引用的,一般不会被优化,但如果你开了 LTO(链接时优化),偶尔会出问题。可以在SEGGER_RTT_Conf.h里把SEGGER_RTT_SECTION设成一个特定的段,或者在代码里加一句volatile引用,强制保留。
第三,自动扫描的内存范围不对。RTT Viewer 的自动扫描默认只扫 RAM 的前面一段,如果你的控制块地址比较靠后,就扫不到。这时候需要手动指定地址。从 Keil 的 map 文件里搜_SEGGER_RTT,找到它的地址,填到 RTT Viewer 的RTT Control Block Address里。
我遇到过一次特别坑的情况:控制块地址是 0x2000_1234,但 RTT Viewer 自动扫描只扫到 0x2000_1000 就停了,怎么都找不到。手动填地址之后秒连。所以自动扫描失败的时候,手动填地址是首选排查手段。
4.2 能连上但打印乱码或者丢数据
打印乱码通常是缓冲区配置的问题。检查SEGGER_RTT_Conf.h里的SEGGER_RTT_MAX_NUM_UP_BUFFERS和BUFFER_SIZE_UP,确保上行缓冲区数量和你使用的通道号匹配。如果你用SEGGER_RTT_printf(0, ...),通道 0 必须存在。
丢数据一般是缓冲区满了。RTT 的写操作是非阻塞的,缓冲区满了就直接丢弃新数据。解决办法有两个:一是加大缓冲区,二是降低打印频率。我一般会在打印频率高的地方加个计数器,每 N 次才打印一次,避免刷屏。
还有一种丢数据的情况是 SWD 时钟太低。DAPLink 的 RTT 读取速度受 SWD 时钟影响很大,100kHz 的时钟下,主机读取速度可能跟不上目标写入速度,导致缓冲区频繁满。把 SWD 时钟调到 1MHz 以上,问题基本就解决了。
4.3 Keil 调试模式下 RTT 和断点冲突
这个坑我踩得最深。在 Keil 里进入 Debug 模式后,如果设置了断点,CPU 停住的时候 RTT Viewer 有时候会断开连接。原因是 Keil 在断点停住时会独占调试接口,RTT Viewer 抢不到 DAP 通道。
解决办法是不要在 Keil 的 Debug 模式下同时开 RTT Viewer。正确的流程是:用 Keil 下载完程序后,退出 Debug 模式,让程序自由运行,然后再打开 RTT Viewer 连接。这样两者就不冲突了。
如果你确实需要在调试的同时看 RTT 输出,可以考虑用 VS Code + Cortex-Debug 插件,它对 RTT 的支持更好,可以在调试会话里直接集成 RTT Viewer 的输出,不用切来切去。这也是为什么热词里"vs code嵌入式"和"使用vscode开发嵌入式编程"一直很火的原因,VS Code 这套工具链在调试体验上确实比 Keil 灵活。
4.4 DAPLink 固件刷完后设备不识别
刷固件刷出问题的情况我也遇到过。症状是插上调试器后电脑没有任何反应,设备管理器里出现一个未知设备。这通常是固件和硬件不匹配,或者刷写过程中断电导致固件损坏。
补救办法是让调试器进入 bootloader 模式。大多数 DAPLink 调试器都有一个复位按键或者 BOOT 跳线,按住复位键插入 USB,如果 bootloader 没坏,会枚举出一个 U 盘,重新拖入正确的固件即可。如果 bootloader 也坏了,那就只能拆开外壳,用另一个调试器通过 SWD 接口给调试器 MCU 重新刷 bootloader,这就比较麻烦了。
提示:刷固件之前,先把
DETAILS.TXT里的原始版本信息截图保存。万一刷坏了,至少知道原来是什么版本,方便找回。
5. 把 RTT 用出花来的几个进阶技巧
5.1 多通道分离:把日志和波形分开
RTT 支持多个上行通道,默认通道 0 用于文本打印。你可以再开一个通道 1 专门传二进制数据,比如把 ADC 采样的原始数据通过通道 1 发出来,用 SEGGER 的 RTT Viewer 或者自己写个上位机解析。这样文本日志和波形数据互不干扰,调试效率高很多。
配置方法是在SEGGER_RTT_Conf.h里把SEGGER_RTT_MAX_NUM_UP_BUFFERS改成 2 或更多,然后用SEGGER_RTT_Write(1, buf, len)往通道 1 写数据。RTT Viewer 里可以切换通道查看,或者用RTT Viewer的Terminal功能同时显示多个通道。
5.2 用 RTT 做简易的性能分析
RTT 的写入开销很小,可以在关键代码段前后打时间戳,算出执行时间。比如:
uint32_t t0 = DWT->CYCCNT; // 被测代码 uint32_t t1 = DWT->CYCCNT; SEGGER_RTT_printf(0, "cycles = %u\n", t1 - t0);DWT 的周期计数器需要先使能,在main里加几句配置就行。这样测出来的时间精度是 CPU 周期级的,比用示波器测 GPIO 翻转还准。我调 FOC 的时候就是用这招测出了电流环的执行时间,发现有一版代码因为浮点运算太多,单次执行要 8us,超过了 PWM 周期的一半,果断优化。
5.3 RTT 配合 SystemView 做 RTOS 可视化
如果你跑的是 FreeRTOS 或者 embOS,SEGGER 的 SystemView 可以通过 RTT 通道采集 RTOS 的事件,画出任务切换、中断、信号量的时序图。配置稍微复杂一点,需要在 RTOS 的钩子函数里调用 SystemView 的 API,但效果非常直观。任务优先级反转、中断延迟这些问题,看时序图一眼就能定位。
不过 SystemView 对 RTT 带宽要求比较高,DAPLink 的 RTT 速度可能有点吃力。实测在 4MHz SWD 时钟下,SystemView 能跑起来,但事件密度高的时候会丢事件。如果只是偶尔看看任务切换,够用了。
5.4 在 VS Code 里集成 RTT 输出
VS Code 的 Cortex-Debug 插件支持 RTT,配置方法是在launch.json里加上rttConfig段:
"rttConfig": { "enabled": true, "address": "auto", "decoders": [ { "port": 0, "type": "console" } ] }这样调试的时候,RTT 的输出会直接显示在 VS Code 的调试控制台里,不用再开一个 RTT Viewer 窗口。配合 Cortex-Debug 的寄存器查看、变量监视功能,整个调试体验比 Keil 舒服不少。这也是我后来逐渐把主力开发环境从 Keil 迁到 VS Code 的原因之一。
6. 关于 RTT 调试的一些个人体会
RTT 这套东西刚上手的时候会觉得配置有点繁琐,但一旦跑通,就再也回不去串口打印了。我现在的习惯是:新板子到手,第一件事就是把 RTT 集成进去,哪怕这个项目最后不用,也留着备用。因为调试这件事,工具越顺手,排查问题的效率越高。
DAPLink 的 RTT 支持虽然不如 J-Link 那么丝滑,但考虑到 DAPLink 调试器的价格通常只有 J-Link 的几分之一,这个性价比已经很可以了。V0.0.20 这个版本在稳定性上比早期版本好了很多,我连续跑几个小时也没遇到过掉线。
最后分享一个小技巧:如果你在 RTT Viewer 里看到的数据偶尔有截断,可以在SEGGER_RTT_Conf.h里把SEGGER_RTT_MODE_DEFAULT设成SEGGER_RTT_MODE_BLOCK_IF_FIFO_FULL。这样缓冲区满的时候写操作会阻塞等待,而不是直接丢弃数据。代价是可能影响实时性,所以只建议在调试阶段用,量产固件里还是用非阻塞模式。
另外,RTT 控制块的地址在每次重新编译后可能会变,如果你用手动填地址的方式连接,记得每次改完代码重新查一下 map 文件。用自动扫描就没这个烦恼,但自动扫描偶尔会抽风,所以两种方式都得会。