4. Trace数据采集基础
调试嵌入式系统,尤其是实时系统,最头疼的是什么?我个人觉得,是「看不见」。代码在跑,但里面发生了什么,你只能靠猜。今天我们就来聊聊软件Trace技术——说白了,就是怎么让芯片开口说话。
printf与串口日志:最朴素的调试方式
我刚入行那会儿,调试全靠串口。代码里到处塞printf,然后接个USB转串口线,在电脑上看打印信息。这个方法简单粗暴,但坑也不少。
printf的典型用法:
printf("Task A: count = %d, timestamp = %lu\n", count, get_tick());
看起来没问题对吧?但你在实时系统里试试看——
- 阻塞问题:printf是同步的,要等字符全部发送完才返回。一个printf可能耗时几毫秒,你的实时任务早就超时了。
- 中断安全:在中断服务函数里调printf?我劝你别这么干。重入问题、死锁风险,够你查一宿的。
- 带宽瓶颈:115200波特率,每秒也就传11KB左右。你打印几行日志,CPU时间全耗在等串口上了。
我曾经踩过的坑:有一次调试电机控制算法,在控制循环里加了几个printf。结果电机转速直接掉了一半——printf把控制周期从100μs拖到了500μs。从那以后,我养成了习惯:正式发布前,一定把调试打印全部关掉或条件编译掉。
环形缓冲区(Ring Buffer):让日志不再阻塞
怎么解决printf的阻塞问题?答案是用环形缓冲区。说白了,就是开一块内存,日志先写到缓冲区里,后台再慢慢往外发。
环形缓冲区的核心思想:写操作只修改指针,不涉及I/O。毫秒级的阻塞,变成微秒级的指针移动。
一个典型的环形缓冲区实现长这样:
typedef struct {
uint8_t *buffer;
uint32_t head; // 写指针
uint32_t tail; // 读指针
uint32_t size; // 缓冲区大小
} ring_buffer_t;
bool rb_write(ring_buffer_t *rb, uint8_t data) {
uint32_t next = (rb->head + 1) % rb->size;
if (next == rb->tail) {
return false; // 缓冲区满了
}
rb->buffer[rb->head] = data;
rb->head = next;
return true;
}
bool rb_read(ring_buffer_t *rb, uint8_t *data) {
if (rb->head == rb->tail) {
return false; // 缓冲区空了
}
*data = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) % rb->size;
return true;
}
你看,写操作就几行代码,没有I/O等待。中断里写日志?没问题。实时任务里打Trace?随便打。
| 特性 | printf直接输出 | 环形缓冲区+后台输出 |
|---|---|---|
| 单次写入耗时 | 1-10ms(取决于波特率) | 0.5-2μs |
| 中断安全 | ❌ 容易死锁 | ✅ 原子操作即可 |
| 数据完整性 | ✅ 实时发送 | ⚠️ 缓冲区满会丢数据 |
| 对实时性影响 | 严重 | 几乎无影响 |
DWT与ITM:ARM内核自带的Trace硬件
软件缓冲区虽然好,但还是要占用CPU时间。有没有不占CPU的Trace方式?有——ARM Cortex-M内核里藏着两个好东西:DWT和ITM。
DWT(Data Watchpoint and Trace):数据观察点与跟踪。它可以监控变量的读写,触发断点,还能提供周期计数。我常用它来测量代码执行时间:
// 使用DWT的CYCCNT寄存器测量函数执行周期数
uint32_t start = DWT->CYCCNT;
my_function();
uint32_t cycles = DWT->CYCCNT - start;
// cycles就是函数消耗的CPU周期数
ITM(Instrumentation Trace Macrocell):指令跟踪宏单元。它允许软件通过特定的端口向调试器发送数据,而且不阻塞CPU。
ITM的典型用法:
// 向ITM端口0写入一个字符
ITM->PORT[0].u8 = 'A';
// 封装成类似printf的函数
void itm_printf(const char *fmt, ...) {
va_list args;
va_start(args, fmt);
vsnprintf(itm_buf, sizeof(itm_buf), fmt, args);
va_end(args);
char *p = itm_buf;
while (*p) {
while (!(ITM->PORT[0].u32 & 1)); // 等待ITM就绪
ITM->PORT[0].u8 = *p++;
}
}
ITM的好处是:CPU只管往寄存器里写数据,硬件自动通过SWO引脚把数据送出去。CPU开销几乎为零。你想想看,这对实时系统意味着什么?
注意:ITM需要调试器支持SWO接口。J-Link、ST-Link/V2以上版本都支持。但如果你用的是普通USB转串口,那ITM就用不了。
SEGGER RTT:把Trace速度推到极致
说到软件Trace,不得不提SEGGER的RTT(Real-Time Transfer)技术。这玩意儿,说实话,是我用过最顺手的Trace方案。
RTT的原理其实不复杂:它在RAM里维护一个环形缓冲区,然后通过调试器的JTAG/SWD接口,在CPU空闲时把数据读走。关键点在于——读操作不占用CPU时间。
RTT vs 串口:串口是CPU把数据一个一个bit送出去,RTT是调试器主动来RAM里取数据。速度差距有多大?RTT可以跑到1MB/s以上,串口通常只有115200bps(约11KB/s)。差了将近100倍。
RTT的使用也很简单:
// 包含RTT头文件
#include "SEGGER_RTT.h"
// 直接使用RTT的printf
SEGGER_RTT_printf(0, "Task %s: value = %d\n", task_name, value);
// 或者用更高效的Write接口
SEGGER_RTT_WriteString(0, "Hello from RTT!\n");
我在项目中用过RTT做实时数据可视化。电机转速、电流、温度,每秒采样1000次,全部通过RTT送到PC端,用Python脚本实时绘图。串口?根本扛不住这个数据量。
如何选择?我的建议
不同的场景,用不同的Trace方案。我一般这样选:
- 原型验证阶段:用printf+串口,简单直接。但记得加条件编译,方便后续关闭。
- 实时性要求高的系统:上环形缓冲区。中断里写日志,主循环里通过串口或USB发送。
- 性能调优阶段:用DWT的CYCCNT测量代码执行时间,精确到CPU周期。
- 正式调试/数据采集:SEGGER RTT是我的首选。速度快、不阻塞、配合J-Link的RTT Viewer用起来很舒服。
一句话总结:
printf是「能用」,环形缓冲区是「好用」,RTT是「好用且快」。而DWT+ITM,是ARM内核送给你的免费硬件加速器——别浪费了。