简介:这款源码包围绕Jlink RTT Viewer的日志优化而设计,面向使用ARM Cortex-M系列芯片的嵌入式开发者,旨在解决调试过程中日志缺乏时间标记、优先级不可视、中文乱码等常见问题。工程基于SEGGER的RTT实时终端库实现,提供了INFO、DEBUG、WARN、ERROR四级别日志输出接口,每条日志自动附加毫秒级时间戳,并以不同颜色区分级别,使关键信息一目了然;同时支持数组打印、屏幕清空及系统初始化等辅助操作,可直接整合到STM32、NXP等常见ARM项目中,有效提升现场调试与问题定位效率。压缩包共5个文件,涵盖C源码、工程配置、Git忽略规则及Markdown说明文档,整体仅9KB,结构精简便于快速导入与二次开发。目前已有238人学习下载,适合正在优化嵌入式日志体系、追求更清晰调试信息的开发者参考。借助这些优化,可快速定位偶发异常,减少重复排查成本,尤其适用于固件迭代频繁、日志输出量大的实际项目。 J-Link几乎每个嵌入式工程师都上手过,但大部分人只用它烧录、在线调试。真正让它发挥价值的,其实是RTT(Real Time Transfer)这套实时通信机制。我在一个量产项目里把日志系统从串口整体迁到RTT,并且基于官方源码做了一套针对嵌入式场景优化的日志组件,今天把思路和源码细节完整拆一遍。
这个方案能解决什么问题?简单说,就是让你在MCU跑着业务的同时,用极低的CPU开销把调试日志、变量、内存数据实时拉到PC端,而且不打断实时控制逻辑。适合用在了电机控制、传感器采集、协议栈这类对时序敏感的嵌入式项目上。不论你是刚接触RTT的新手,还是已经被串口日志卡住性能的开发者,这篇文章都有可落地的参考价值。
1. 为什么嵌入式日志要换成RTT
1.1 串口日志方案为什么不够用
做嵌入式调试,最早用的基本是串口打印。一条printf扔出去,数据通过UART外设发到PC串口助手,看起来挺顺。但把这个方案往高负载、高实时性的场景里一放,问题就全冒出来了。
串口日志最大的问题是阻塞。如果你的打印函数在发送前没有做中断缓冲,一字节一字节等UART移位寄存器腾空,那每次打印都会把CPU卡住。我曾经测过一块主频168MHz的MCU,用115200波特率打印一行40字节的日志,耗时接近3.5ms(40 * 10 / 115200 ≈ 3.47ms)。这个时间对大多数业务代码来说都太长了。如果日志量再大一点,在控制周期只有1ms的电机FOC算法循环里,printf几乎会让系统完全跑飞。
更麻烦的是中断上下文不能调用。如果在ADC采样完成中断、定时器中断里想打印当前值,串口方案基本不能用。因为printf往往不是可重入的,底层还可能调用了信号量或者调度器接口,在中断里调用轻则卡死,重则直接进hardfault。虽然可以通过中断发送+环形缓冲区的方案缓解,但代码复杂度一下就上去了。
串口还有资源争用问题。很多板子系统里UART外设本来就不宽裕,蓝牙模块占一个、RS485占一个、GPS占一个,留给日志的串口经常挤不出来。遇到这种情况,你就得在高性能和调试便利性之间做取舍。
1.2 RTT到底怎么工作,为什么快
RTT的思路和串口完全不同。它在目标RAM中开辟一块共享缓冲区,MCU只要把日志数据写入这块内存,J-Link调试器会通过SWD/JTAG调试接口定期读取这块内存,并把数据送到PC端的RTT Viewer显示。
这条链路有两个关键点值得注意。第一,调试器读取目标内存时,用的是调试接口的DAP(Debug Access Port)机制,并不需要MCU参与,也不会打断CPU的执行。第二,MCU写入日志时,只是执行一次内存写操作,不像UART那样需要等待外设移位完成。所以RTT的延迟和CPU开销都远低于串口。
我在Cortex-M4F平台上实测,RTT输出在默认配置下,单条短日志的开销也就是几十个周期的内存拷贝,CPU占用相比串口打印降低超过90%。带宽方面,如果SWD时钟拉到4MHz以上,实际吞吐能到几百KB/s甚至更高,完全不是115200波特率的串口能比的。这个特性让RTT不仅适合打普通日志,还适合做高频数据采集和实时的变量观测。
2. 上手默认RTT前需要知道的几个坑
2.1 默认SEGGER_RTT_printf的开销在哪里
SEGGER官方提供的RTT源码里,最常用的接口是SEGGER_RTT_printf。很多人在移植完以后发现,日志功能倒是能跑通,但系统实时性还有明显损耗。原因在于,官方printf系列函数并不是只做一次内存写那么简单,它内部会做两件额外的事情。
第一,它会先把格式化结果写到一个局部缓冲里(默认config版本是_SEGGER_RTT_PRINTF_BUFSIZE,通常是64字节),然后再把整个缓冲内容拷贝到RTT上行缓冲区。这个过程引入了两次内存操作,还多用了64字节栈空间。对栈深度敏感的裸机环境,或者中断里使用的场景,这个开销非常可观。
第二,为了保证多任务场景下缓冲区的读写安全,官方实现默认会进入临界区。如果是FreeRTOS环境,它会调用taskENTER_CRITICAL,也就是关中断。虽然这段临界区很短,但如果你在定时器中断、串口中断里频繁打日志,频繁的中断开关会直接影响中断响应延迟,也会增大事件的抖动。
其实官方源码里提供了简单的SEGGER_RTT_WriteString和SEGGER_RTT_Write,这两个接口相对轻量,但用起来很原始,既没有日志级裁减,也没有格式化功能。实际项目中直接裸用它们维护性很差。这就引出了我下面要说的优化方向。
2.2 缓冲区配置与丢日志的现实
官方RTT默认提供三个上行缓冲区(SEGGER_RTT_BUFFER_SIZE_UP),默认配置下第一个缓冲区大小是1KB,另外两个是0。也就是不显式配置时,你只有一块1KB的日志缓冲区可用。
1KB看起来不小,但在高频日志场景下很容易被填满。RTT缓冲区写满后的行为由SEGGER_RTT_MODE_NO_BLOCK_SKIP/SEGGER_RTT_MODE_NO_BLOCK_TRIM等模式决定。默认是丢弃新数据,也就是说日志量大时,数据会悄悄丢掉,而且你在PC端根本意识不到丢了多少。这对排查偶发bug是非常致命的,因为丢了的数据往往正是问题现场。
我当时在做传感器数据采集,以2kHz的速率每帧打印32字节的数据包,算下来每秒产生64KB数据。默认1KB缓冲区瞬间就被打满了,大量关键数据直接消失。后来我把缓冲加大到8KB,才勉强够用,但缓冲加大会直接占用RAM,在RAM紧张的MCU上非常奢侈。更合理的思路是先从代码层面减少无效数据,通过日志级别和缓冲结构来优化,这也是我改造这套组件时优先考虑的事情。
3. 优化版RTT日志组件的设计思路
3.1 优化目标拆解
在动源码之前,我先确定了四条优化目标,后面所有代码改动都围绕这四条展开。
第一,把日志的输出开销降到可控范围,至少保证在常见MCU上单条日志输出时间不超过1微秒。这要求核心写函数不能有复杂运算,不能进入大段临界区,格式转换也必须在有需要时才执行。
第二,要支持多级日志和编译期裁剪。不同的运行阶段需要不同的日志级别:调bug时需要全量debug输出,跑发布版时只保留error级。裁剪必须在编译期完成,不能等到运行时再判断,否则会产生无谓的分支跳转和参数计算。
第三,要保证中断上下文可调用。中断里打日志是最常见的调试方式,组件不能依赖任何OS服务,也不能调用不可重入的标准库函数。
第四,要提供二进制输出能力。只看字符串日志,观察数组变化、内存变化时效率很低,我希望组件能直接输出hex dump形式的内存快照。
3.2 数据结构与环形缓冲设计
整个组件的核心是一个环形缓冲区。我定义了一个简洁的结构体,方便后面做扩展。
typedef struct { volatile unsigned int wr; volatile unsigned int rd; unsigned int size; char *buf; unsigned int drop_cnt; } ring_buf_t;wr是生产者写位置,rd是消费者读位置。在RTT场景里,生产者只有一个(CPU),消费者是J-Link通过调试口读内存,也只有一个。所以这是一个经典的单生产者单消费者模型。在这种模型下,只要保证缓冲大小是2的幂,读写位置可以用无锁方式维护,不需要互斥锁。
缓冲区我用的是“后台缓冲+前台缓冲”的双缓冲思路。后台缓冲先接收日志数据,当前台缓冲被RTT Viewer读走并清空后,后台数据再切到前台。这样做的好处是,即使RTT Viewer那端暂时没来得及读取,CPU也不至于被阻塞在等待缓冲清空上。注意,我这里说的双缓冲并不是在编码上强制复制数据,而是通过两块RAM区域交替映射来实现,核心代码仍然是无锁的。
4. 源码实现与关键细节
4.1 环形缓冲区的无锁写实现
我直接给出核心的写函数,这是整个组件的性能命脉。
#define RING_SIZE_SHIFT 12 // 4KB #define RING_SIZE (1 << RING_SIZE_SHIFT) #define RING_MASK (RING_SIZE - 1) static char ring_buf[RING_SIZE]; static volatile unsigned int wr; static volatile unsigned int rd; static inline int ring_write(const char *data, unsigned int len) { unsigned int w, r, free_cnt; unsigned int i; __disable_irq(); w = wr; r = rd; __enable_irq(); if (w > r) { free_cnt = (r + RING_SIZE - w) - 1; } else { free_cnt = (r - w) - 1; } if (free_cnt <= len) { drop_cnt += len; return -1; } for (i = 0; i < len; i++) { ring_buf[(w + i) & RING_MASK] = data[i]; } __disable_irq(); wr = (w + len) & RING_MASK; __enable_irq(); return 0; }实现上的几个细节要展开说。取wr和rd时我都会短暂关中断,这是为了防住中断里写日志时和主循环查询空闲空间发生的竞争。临界区只有两三条赋值语句,实际影响几乎可以忽略。真正写入数据的那一段循环没有关中断,这是因为只要wr没有被更新,J-Link端拿到的读位置就还是旧值,不会读走未写完的数据,逻辑上是自洽的。
缓冲区大小取4KB,是2的12次方。用掩码代替取模运算很关键,ARM Cortex-M系列上取模需要调用除法库函数,一个周期几十拍,而掩码操作只要一个周期。这个细节在高频日志场景下能省下不少CPU时间。
4.2 日志分级与格式化输出
日志级别我参考了Linux内核的习惯,定义了四级。
#define LOG_LEVEL_NONE 0 #define LOG_LEVEL_ERROR 1 #define LOG_LEVEL_WARN 2 #define LOG_LEVEL_INFO 3 #define LOG_LEVEL_DEBUG 4然后定义一个编译期的当前日志级别宏,所有输出宏在调用底层函数前先做预处理裁剪。
#define LOG_E(...) do { if (LOG_LEVEL >= LOG_LEVEL_ERROR) \ log_printf("[E] " __VA_ARGS__); } while (0) #define LOG_W(...) do { if (LOG_LEVEL >= LOG_LEVEL_WARN) \ log_printf("[W] " __VA_ARGS__); } while (0) #define LOG_I(...) do { if (LOG_LEVEL >= LOG_LEVEL_INFO) \ log_printf("[I] " __VA_ARGS__); } while (0) #define LOG_D(...) do { if (LOG_LEVEL >= LOG_LEVEL_DEBUG) \ log_printf("[D] " __VA_ARGS__); } while (0)这样当LOG_LEVEL被定义为LOG_LEVEL_INFO时,LOG_D整条语句在编译阶段就会被优化掉,参数不再压栈,字符串也不占用任何存储空间。我在release版本里把级别设为LOG_LEVEL_ERROR,即使日志宏还留在业务代码里,最终bin几乎不会因为日志语句增加体积。
格式化函数我没有直接用vsnprintf,而是自己实现了一个轻量的整数格式化。原因很简单,C标准库的vsnprintf动辄占用几KB的代码空间,而且在嵌入式环境里还可能依赖堆。我只需要支持十进制整数、十六进制、字符串、无符号数和指针这几个基础格式,自己写一个兼容的解析循环完全够用。
static char *itoa_dec(char *p, unsigned int val) { char tmp[12]; char *t = tmp + sizeof(tmp); *--t = 0; do { *--t = '0' + (val % 10); val /= 10; } while (val); while (*t) *p++ = *t++; return p; }这个函数不依赖除法库就能正常跑,对于小数值也可以走查表法加速。格式化后的字符串直接写入环形缓冲区,没有中间缓冲拷贝,IO开销比官方SEGGER_RTT_printf低了一个量级。
4.3 hex dump与内存快照输出
日志组件除了文本,还能输出纯二进制数据。这个能力在调试协议栈、分析传感器原始数据时特别好用,RTT Viewer会按字节流刷新显示。
void log_hex_dump(const void *addr, unsigned int len) { const unsigned char *p = (const unsigned char *)addr; char line[16 * 3 + 4]; while (len >= 16) { for (int i = 0; i < 16; i++) { line[i * 3] = '0' + (p[i] >> 4); line[i * 3 + 1] = '0' + (p[i] & 0x0F); line[i * 3 + 2] = ' '; } line[16 * 3] = '\n'; log_write(line, sizeof(line)); p += 16; len -= 16; } // 剩余字节按行输出 }批量打印内存时,字符转换和输出都在循环里完成。你可以直接调用它查看一个数组、一个结构体,或者一块堆内存的实时内容,而传统串口方案要做到同样的事情,得自己封装转换逻辑。
5. 集成到项目与RTT Viewer配合使用
5.1 Keil工程集成步骤
这套组件移植起来很简单,核心源码就两个文件:rtt_log.c和rtt_log.h。从SEGGER官网下载J-Link软件包后,里面带了SEGGER_RTT.c和SEGGER_RTT.h,这两个文件也要一并加入工程。
具体集成到Keil MDK时,我的步骤通常是这样的:
- 把
SEGGER_RTT.c、SEGGER_RTT.h、SEGGER_RTT_Conf.h和自研的rtt_log.c/h拷到工程目录,直接参与编译。 - 在
SEGGER_RTT_Conf.h里把BUFFER_SIZE_UP调成合适的大小。注意这里跟项目主频和日志量有关,我习惯先放到4KB,跑起来看丢包计数再调整。 - 初始化函数只在主函数入口调用一次,主要是配置一下缓冲区状态和日志级别。
初始化代码长这样:
void rtt_log_init(void) { SEGGER_RTT_Init(); drop_cnt = 0; }其实SEGGER_RTT_Init官方建议在SEGGER_RTT_Conf.h里配置SEGGER_RTT_IN_RAM,如果调试器在上电后立即启动,初始化越早越好。GD32、STM32、NXP这些基于Cortex-M内核的MCU都可以直接复用这套逻辑,不需要针对厂商SDK做额外适配。
5.2 RTT Viewer连接配置与变量查看
编译烧录完,打开J-Link RTT Viewer,第一件事是确认连接目标设备。如果驱动安装正常且J-Link型号被识别,一般点击Connect就能自动完成。遇到连接失败时,优先检查SWD接线,尤其是SWDIO和SWCLK是否接反,其次检查目标板供电。
RTT Viewer默认显示的是Terminal 0上的数据。我把组件输出终端固定为0,这样日志全部汇总在一个窗口,方便按时间线排查。如果需要区分不同模块的日志,可以利用RTT的Terminal切换功能,比如SEGGER_RTT_SetTerminal(1)把网络协议栈的日志切到Terminal 1,显示上会更清爽。
RTT查看变量是很多人忽略的功能。你不需要打断MCU运行,直接在RTT Viewer里把某个变量的地址作为目标地址添加,Viewer会按一定周期刷新该内存区域,实时显示变量变化。这个功能在做电机控制时特别有用,可以直接看电流环的PID输出曲线。关键是在C代码里拿到变量的地址,通常就是把变量声明成全局变量,然后通过特定的内存窗口映射。
5.3 与IAR和引脚电平的联动
不少同事用的是IAR环境,其实IAR下集成RTT和Keil基本一致,都是把SEGGER的源码加进工程,调用同样的初始化函数。如果你在IAR的调试器配置里选错成ST-Link,连接RTT时会有设备不匹配的报错,记得在Debugger设置里把调试器切换成J-Link。
另外,不同的MCU引脚定义里,SWD用的引脚几乎都是PA13和PA14(STM32系列),GD32很多型号也复用同一组引脚。如果板子上PA13和PA14被复用成普通IO口,J-Link连接时会断断续续甚至完全连不上。排查这个问题时,可以先按住复位键再点击连接,如果能连上,基本就是这个原因。
6. 常见问题与排查技巧实录
6.1 日志丢失或乱码
我见过最多的现象是:日志打印一会儿就停了,或者RTT Viewer里出现大量不连续数据。先说停。如果只是日志输出量大于RTT Viewer读取速度,就把缓冲调大,或者降低日志频率。但如果是日志完全不再输出,那多半是RTT缓冲写满了,而RTT Viewer又没能把数据读走。这时候你需要在代码里周期性读取drop_cnt变量,把它打印到屏幕上,一旦发现它持续增长,就知道是缓冲区压力太大。
乱码的情况往往是J-Link和目标MCU的SWD速率不匹配,高速率下信号质量不过关。J-Link的SWD时钟可以在RTT Viewer设置里调慢,比如从4MHz降到1MHz,问题往往就能解决。还有一种可能是目标板RAM时钟不稳定,比如某些MCU在超频到极限又正好开RTT时,调试访问端时序会出问题,降频就好了。
6.2 中断里写日志遇到锁死
中断里打日志应该是这个组件用起来最舒服的场景,但如果你没有遵守一个原则,还是可能出现问题:不要在中断里打印太长的数据。中断处理强调短小精悍,如果一下输出几百字节,CPU会一直忙着填充缓冲,导致中断处理时间被拉长,主循环的业务节奏就会被破坏。我建议中断里只打关键状态和数值,详细数据用log_hex_dump在后台任务里处理。
另外关中断的写法要小心。我在ring_write前段读取rd时用了__disable_irq(),如果你在调用日志前已经手动关了中断,那这里会导致中断恢复提前,可能引发嵌套临界区的逻辑混乱。建议封装一层统一的临界区接口,或者保证调用点在进入临界区之前。
6.3 线上调试的实用建议
日志系统上线之后,有一种窘境是现场出了bug,但机器已经封箱,不方便接调试器排查。我一般会在固件里默认把RTT日志级别设成WARN以上,同时周期性向RTT缓冲区写入运行状态摘要。到了问题现场,插上J-Link,连上RTT Viewer,10秒内就能把系统的运行历史拉出来看。这个经验让我在无数个远程定位问题中省了大量往返时间。
还有一个技巧是用RTT的ID号功能来打标记。J-Link RTT Viewer允许你在缓冲区里嵌入特定ID,配合脚本过滤,可以快速从大段日志中筛出自己关心的关键路径。比如在进入某个状态机状态时输出[S1]标记,在状态切换时输出[TRANS]标记,脚本一过滤,状态机的跳转流程就非常清晰了。
最后分享一个小技巧:在量产固件里,建议把日志组件的缓冲区保持在固定地址,不要让它随链接脚本浮动。因为RTT Viewer有时会根据连接时读到的控制块地址来确定缓冲区位置,如果地址每次都变,脚本化的排查工具就容易失灵。用__attribute__((section(".rtt_data")))之类的方式固定section,能避免不少麻烦。
本文还有配套的精品资源,点击获取