Trace 基本原理:从硬件到软件

做多媒体启动优化,说白了就是跟时序、状态、数据打交道。但真正让人头疼的是——系统到底在执行什么?

我最早做裸机启动时,全靠串口打印"hello world"来猜流程。那时候觉得凑合着能用。直到一次量产前,TPU死活起不来,串口最后一条输出是"Loading model..."然后就没了。我怎么找问题?完全抓瞎。

从那以后,我养成了一个习惯:任何嵌入式项目,先搭好Trace通道。今天要讲的,就是Trace的底层原理——硬件怎么抓、软件怎么写、数据长什么样。

一句话总结:Trace ≠ 日志。硬件Trace是CPU硬线输出的实时流,软件Trace是CPU主动插入的标记点。两者互补,缺一不可。

硬件 Trace:ETM 与 ITM

硬件Trace,靠的是芯片内部专门的调试单元。你想想看,在不打断CPU运行的前提下,还要把它的执行路径、数据访问、时间戳一股脑儿送出来——软件根本做不到。只有硬件能行。

ETM(Embedded Trace Macrocell)

ETM 是 ARM 体系中的"执行轨迹记录器"。它会实时监控CPU的指令地址总线,每执行一条指令,ETM就把PC值、指令类型、条件码等信息打包成数据包,通过 Trace 端口输出。

我在项目中遇到过一种情况:某ISP算法在特定条件下会进入死循环,用断点根本定位不到,因为一停就改变时序了。后来我用 ETM 全时记录——发现是某个循环计数器在特定输入下归零,导致循环出口条件永远不满足。嗯,ETM 抓这种"活"的Bug,简直是手术刀。

实用建议:ETM 输出带宽有限,普通调试器(如 J-Link Pro)只能跑几十MHz的Trace时钟。如果你要跟踪主频1GHz以上的多核CPU,建议使用专用Trace probe(如 Lauterbach 或 ARM DSTREAM)。

ITM(Instrumentation Trace Macrocell)

ITM 是 ARM Cortex-M 系列内置的"软件→硬件"桥梁。它既能输出软件printf,也能输出硬件产生的调试信息(如DWT计数器溢出)。典型的用法是这样的:

/* 初始化ITM端口 */
ITM->TER = 0x01;          // 使能 Stimulus Port 0
ITM->TCR = 0x0001000D;    // 使能ITM, 使能DWT, 设置SWO时钟

/* 发送一个字节 */
while (ITM->PORT[0].u32 == 0);  // 等待FIFO空
ITM->PORT[0].u8 = 'A';         // 发送数据

为什么我推荐用 ITM 而不是串口?因为ITM不会阻塞CPU。串口发送一个字符串可能要几十微秒,但ITM只写一个寄存器,硬件自己搬走。在时间敏感的启动流程中,这点差别可能就是"启动成功"和"超时被看门狗复位"的天壤之别。

注意:ITM 只在 Cortex-M3/M4/M7/M33 等内核上可用,Cortex-M0/M0+ 不支持。另外 SWO(单线输出)引脚不能与其它功能复用,PCB设计时一定要预留出来——我曾经吃过这个亏,板子回来发现SWO被当GPIO用了,只能飞线。

软件 Trace:日志与环形缓冲区

硬件Trace虽好,但受限于引脚数和调试器带宽。大部分量产产品没有外接Trace接口。这时候,软件Trace就成了常规武器。

日志字符串(printf 类)

最原始的方式:在关键路径上放printf,通过UART/SWO串口输出。优点是直观,缺点是:

  • 阻塞:printf本质是轮询发送,上G的CPU被几十kbps的串口拖住
  • 尺寸大:字符串本身就占用大量Flash和RAM
  • 时序影响:一句日志可能改变函数调用关系(比如编译器优化)

我见过不少团队在调试阶段用这种"暴力法",发布时注释掉所有printf。结果呢?现场出了问题,连个日志都没有——想重现问题都无从下手。所以后来我坚持:正式固件里也要保留Trace能力,只是级别降低。

环形缓冲区(Ring Buffer)

更好的做法是把日志先扔到内存里,Post-mortem再倒出来。这就用到了环形缓冲区。

#define RING_BUF_SIZE 1024
static uint8_t ring_buf[RING_BUF_SIZE];
static volatile uint32_t write_idx = 0;

void trace_put(char c) {
    ring_buf[write_idx] = c;
    write_idx = (write_idx + 1) % RING_BUF_SIZE;
}

void trace_dump(void) {
    /* 从write_idx开始读,绕一圈 */
    for (uint32_t i = write_idx; i != write_idx - 1;
         i = (i + 1) % RING_BUF_SIZE) {
        uart_send(ring_buf[i]);
    }
}

这种方案的好处:写入只要3条指令,不阻塞CPU。坏处是如果写太快,旧数据会被覆盖。我一般把缓冲区开成4KB以上,配合时间戳,足够记录整个启动过程的关键跳转了。

我的习惯:先在SRAM里开一个环形缓冲区,启动时只记录"错误/警告"级别的Trace。一旦系统crash,在Reset Handler里把整个缓冲区通过串口或SPI flash dump出来。这样既能保证线上性能,又能拿到事后分析的所有数据。

Trace 数据格式

无论硬件还是软件,最终都要把数据编码成一种能被上位机软件解析的格式。常见的格式有这几类:

格式 典型场景 优点 缺点
ASCII 文本 printf日志、ITM字符串 人类可读,简单 带宽利用率低,解析慢
二进制包(自定义) 硬件ETM数据流 紧凑,带宽高 需要规范解码器
CTF(Common Trace Format) LTTng, TraceCompass 标准化,工具链丰富 库体积大,不适合裸机
Google Perfetto 格式 Android / Linux 可视化Web界面强 生态绑定Linux

我在多媒体启动场景里,最推荐的是轻量二进制包。举个例子:每个Trace记录固定8字节——4字节时间戳、2字节事件ID、2字节载荷。这样解析速度极快,上位机只要按字节对齐就能解析。

typedef struct {
    uint32_t timestamp;  // 32位系统时钟节拍
    uint16_t event_id;   // 事件编号(如 0x01 = "DDR初始化开始")
    uint16_t payload;    // 附加数据(如DDR延迟计数)
} __attribute__((packed)) trace_record_t;

这种格式的弊端是不好直接看。所以我一般会配套一个Python脚本,把二进制文件按事件ID表转成文本时间线。这样既有了调试时的实时观看能力,又有了离线分析的原子数据。

核心原则:Trace数据格式的设计,要同时满足低开销(运行时)和易解析(事后)。我个人偏好在固件里用二进制包,在上位机用文本/XML展示——两层分离,互不相干。

Trace 基本原理:硬件 & 软件协同 硬件 Trace 软件 Trace ETM(指令轨迹) ITM(软件桥接) printf / 日志 环形缓冲区 数据格式(ASCII/二进制/CTF/Perfetto) 数据格式(ASCII/二进制包) Trace Probe SWO / UART RAM dump Flash日志 上位机工具:DS-5 / Ozone / TraceCompass / 自定义Python解析器

上图展示了我理解的Trace全链路:从硬件/软件源头,经数据格式编码,通过不同物理通道(Trace Probe / SWO / RAM / Flash),最后汇入上位机工具做可视化分析。你会发现,不管哪一路径,数据格式都是中间的关键桥梁——格式选错了,后面所有解析都是灾难。

个人踩坑教训:我曾经图省事,直接用ASCII文本做ITM输出,结果在50MHz的SWO链路上,ASCII字符串占用了大量带宽,导致部分Trace包丢失。换成二进制事件ID后,带宽占用降了80%,丢包率归零。记住:能用二进制,就别用字符串。