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技术演进与对比 CPU / 应用程序 printf + 串口 阻塞式I/O,速度慢 环形缓冲区 + 后台输出 非阻塞,速度中等 SEGGER RTT 极速,零CPU开销 UART串口线 UART / USB JTAG/SWD调试接口 串口助手 / Putty 串口助手 / 自定义工具 J-Link RTT Viewer 性能对比:printf ≈ 10KB/s | 环形缓冲区 ≈ 100KB/s | RTT ≈ 1MB/s+ RTT速度是串口的100倍,且对实时系统几乎零影响

如何选择?我的建议

不同的场景,用不同的Trace方案。我一般这样选:

  • 原型验证阶段:用printf+串口,简单直接。但记得加条件编译,方便后续关闭。
  • 实时性要求高的系统:上环形缓冲区。中断里写日志,主循环里通过串口或USB发送。
  • 性能调优阶段:用DWT的CYCCNT测量代码执行时间,精确到CPU周期。
  • 正式调试/数据采集:SEGGER RTT是我的首选。速度快、不阻塞、配合J-Link的RTT Viewer用起来很舒服。

一句话总结:

printf是「能用」,环形缓冲区是「好用」,RTT是「好用且快」。而DWT+ITM,是ARM内核送给你的免费硬件加速器——别浪费了。