ITM/SWO 打印实战:printf 把 100μs 的控制中断拖到超时,我才明白调试输出也是一种”外设”

2026年9月25日 0 By admin

上周有个读者在后台问我:”为什么我在定时器中断里加了一行 printf,电机就抖了?”我一看就乐了——这个坑我三年前踩得比他惨多了。当时我在一个 100μs 周期的电流环中断里 printf 调试变量,UART 115200 波特率,一行日志二十多个字符,光发送就要 2ms,中断直接被拖成 20 倍周期,电流环当场失控。后来我花了一个周末把 ITM/SWO 这套东西啃透,调试输出从此再没拖过后腿。这篇文章把整个来龙去脉和落地代码都整理出来。

先算一笔账:printf 到底有多贵

很多人对 printf 的开销没有概念,我们直接算。115200 波特率下,一个字节 10 位(8N1),耗时约 87μs。一行典型的调试日志 I=1234, V=2432, err=15\r\n 算 24 个字节,就是 2.1ms——这还没算 printf 格式化本身的开销。

输出方式 一行日志的阻塞时间 需要的硬件条件 中断里能用吗
UART + 阻塞发送 ~2ms(115200) 占用一个 UART + 引脚 用了就死
UART + DMA/缓冲 ~几μs(入队) UART + DMA 通道 勉强,但缓冲要自己管
Semihosting 100ms 级(走调试器断点) 必须连着调试器 绝对不能用
SEGGER RTT ~1μs(写 RAM 环形缓冲) J-Link + RAM 里一段缓冲区 可以
ITM/SWO ~0.2μs(写 FIFO 寄存器) SWO 引脚 + 任意 SWD 调试器 可以,最轻

Semihosting 是最阴的坑:它靠 BKPT 指令陷入调试器完成 I/O,断开调试器程序就死在那,而且每次输出都要停 CPU 几十毫秒。我见过有人在量产固件里留着半句半启用的 semihosting,设备到客户手里就随机卡死,查了一个礼拜。

RTT 和 ITM/SWO 都能解决问题,怎么选?我的结论是:手头是 J-Link 且不想折腾引脚,用 RTT;想要 CMSIS 标准、任何调试器(J-Link/OpenOCD/pyOCD)都通用、时序最确定,用 ITM/SWO。我最后落在 ITM/SWO 上,因为它走的是硬件 FIFO,时序和主频严格挂钩,做时间戳分析更可靠。

ITM/SWO 的硬件原理:一条只有一根线的 trace 口

SWD 调试口其实藏着第五根线没用——PB3(TRACESWO)。正常当 GPIO 或者 SWD 的空闲态,一旦使能 TPIU,它就变成单线异步 trace 输出口。

整个链路是这样的:

  1. 内核侧的 ITM(Instrumentation Trace Macrocell) 提供 32 个 Stimulus Port(激励端口),固件往 ITM_PORT[0] 写一个字节,就完成”输出”——写进硬件 FIFO,不等发送;
  2. ITM 的数据混合时间戳包,交给 TPIU(Trace Port Interface Unit);
  3. TPIU 把数据按 NRZ 曼彻斯特编码,从 SWO 引脚以设定波特率串行发出;
  4. 调试器把 SWO 线上的数据解码还原成控制台文本。

关键在于第 1 步:写 ITM 端口寄存器只要几个时钟周期,数据进了 FIFO 就返回,发送由硬件在后台完成。这就是它能进中断的原因——你只是”投递”了一条日志,不是在”发送”它。

一句话理解:UART printf 是”你亲自去送快递”,ITM/SWO 是”扔进顺丰柜就走人”。柜子(FIFO)只有 32 字节,扔太快满了会丢包,这点后面有专门的坑。

上了三层保险的输出函数:不阻塞、不丢、可丢弃

printf 重定向到 ITM,最网上常见的写法是这样的:

#include "stm32f4xx.h"

volatile int32_t ITM_RxBuffer = ITM_RXBUFFER_READY; // CMSIS 标准

int fputc(int ch, FILE *f)
{
    if ((CoreDebug->DEMCR & CoreDebug_DEMCR_TRCENA_Msk) == 0)
        return ch;                          // trace 没开,静默丢弃
    if ((ITM->TCR & ITM_TCR_ITMENA_Msk) == 0)
        return ch;                          // ITM 没使能,静默丢弃
    if ((ITM->TER & (1UL << 0)) == 0)
        return ch;                          // 端口 0 没使能,静默丢弃
    while (ITM->PORT[0].u32 == 0) { }       // 等 FIFO 有空位
    ITM->PORT[0].u8 = (uint8_t)ch;
    return ch;
}

但注意那个 while——在中断里这是定时炸弹。FIFO 只有 32 字节,调试器拔了或者 SWO 速率太低,FIFO 一直满,中断就被拖死了。所以我在中断路径上用的是不等待版本:满了就直接丢,并计数。

/* 两个等级的输出:
 * log_itm_block()  —— 任务级,满了愿意等(几乎不会真等)
 * log_itm_try()    —— 中断级,满了立刻丢,绝不阻塞
 */
volatile uint32_t g_log_drop_cnt = 0;       // 丢包计数,空闲时看一眼

static inline void log_itm_try(uint8_t ch)
{
    if ((DEMCR & TRCENA) && (ITM->TCR & ITMENA) && (ITM->TER & 1)) {
        if (ITM->PORT[0].u32 != 0) {        // FIFO 有空位才写
            ITM->PORT[0].u8 = ch;
        } else {
            g_log_drop_cnt++;               // 满了:丢弃 + 记数
        }
    }
}

丢包计数器是这套系统的”体温计”。如果它非零,说明你的日志带宽超过了 SWO 链路容量,要么降频打印,要么调低 TPIU 分频。

时钟配置:SWO 波特率不是乱填的

TPIU 的波特率由 CPU 主频分频而来,寄存器是 TPIU->ACPR:

SWO 波特率 = fCPU / (ACPR + 1)

比如 168MHz 主频想要 2MHz 的 SWO 速率:ACPR = 168/2 – 1 = 83。这个速率必须和调试器端设置的 SWO 速率完全一致,否则收上来就是乱码。这是 ITM/SWO 排查”输出全是乱字符”的第一嫌疑。

使能顺序也有讲究,顺序错了 ITM 就是不出字:

void itm_init(void)
{
    CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;   // 1. 总开关:trace 使能

    TPI->SPPR = 2;                                    // 2. 协议选 NRZ(串行线)
    TPI->ACPR = 83;                                   // 3. 168MHz/2MHz - 1

    ITM->LAR  = 0xC5ACCE55;                           // 4. ITM 解锁(必须先解锁再配置!)
    ITM->TCR  = (1 << 0)                              // ITMENA
              | (1 << 1)                              // TSENA:时间戳使能
              | (0x0F << 10)                          // 同步包周期(协议要求)
              | (1 << 16);                            // TXENA:FIFO→TPIU
    ITM->TER |= (1UL << 0);                           // 5. 打开 stimulus port 0
}

三个最容易翻车的点,我各给一句口诀:

  • LAR 解锁:ITM->LAR = 0xC5ACCE55 不写,后面 TCR 全都写不进去。Cortex-M7 上尤其常见,K7 就是”叩”不开的意思(bushi),是 Access Lock 的固定密钥;
  • 引脚复用:使能 TPIU 后 PB3 自动切到 trace 功能,但如果你的代码后面又把 PB3 初始化成了普通 GPIO 输出,SWO 就废了——检查 GPIO 初始化表里有没有人动了 PB3;
  • DBGMCU:想让低功耗/调试停核时 trace 还活着,别忘 DBGMCU->CR |= DBGMCU_CR_TRACE_IOEN,不然一进调试断点输出就停。

调试器这一头怎么收

J-Link:J-Link Commander 里 SWOStart 之后开 SWO Viewer,或者直接用 Ozone 的 SWO 窗口;SWO CPU 频率填 168MHz,SWO 波特率填 2MHz,和固件里 ACPR 算出来的对上。

OpenOCD:配置里加一行,然后用 tpiu config + itm port:

# openocd.cfg 附加
tpiu config internal swon.log uart off 168000000
itm port 0 on

OpenOCD 会把 SWO 解码流写进 swon.log 文件,tail -f 就是实时控制台。这个方案对没有图形界面的 CI 或 Linux 服务器特别友好。

pyOCD:pyocd commander 里直接支持 ITM,itm --port 0 就能看到输出。

中断里打日志的正确姿势:投递 + 时间戳,分析放后台

解决了”能打”,还得解决”打得有意义”。中断里真正想要的往往不是文本,而是带时间戳的变量轨迹。我的做法是双通道:

  • Port 0:文本日志(printf 重定向);
  • Port 1:中断里直接 ITM->PORT[1].u32 = x,把关键变量当 32 位原始数据投出去,一拍时钟就完事,连格式化都省了。

TPIU 的数据流自带 ITM 时间戳包(前提是 TCR 里 TSENA 开了),调试器解码后每条数据都带精确到 CPU 周级的时间。电流环里那组 I、V、err 三个变量,我用 Port 1 连续投递,抓出来一条完整的时间轴轨迹,比 UART 半天吐一行强太多。

/* 中断内:三个变量投 Port 1,各 4 字节,<0.1μs */
void TIM2_IRQHandler(void)
{
    static uint8_t tag = 0;
    if (ITM->PORT[1].u32 != 0) ITM->PORT[1].u32 = ((uint32_t)tag << 24) | adc_i;
    if (ITM->PORT[1].u32 != 0) ITM->PORT[1].u32 = adc_v;
    if (ITM->PORT[1].u32 != 0) ITM->PORT[1].u32 = (uint32_t)pwm_err;
    tag++;
}

用 tag 字节做帧序号,后台解码时能对齐三个变量的归属,丢了哪一帧也看得出来。

SWO 的 FIFO 是 32 字节,Port 1 连续投三个 32 位包占 12 字节,加上协议开销,只要调试器端解码速度跟得上就不会满。真出现丢包,优先把 SWO 波特率往上调——168MHz 主频跑 4~6MHz 的 SWO 毫无压力。

printf 本身还是重:轻量日志宏才是正解

ITM 解决了”通道快不快”,但 printf 的格式化开销还在——%d 在 newlib-nano 上也要几百个时钟周期,%f 更是又慢又占栈。我现在的做法是全站禁用 printf,改成分级日志宏,格式化降级到”整数拼接”级别:

#define LOG_LEVEL_CUR 2        /* 0 关 1 错误 2 信息 3 调试 */

#define LOG_E(...)  do { if (LOG_LEVEL_CUR >= 1) log_puts("[E] "); log_line(__VA_ARGS__); } while (0)
#define LOG_I(...)  do { if (LOG_LEVEL_CUR >= 2) log_puts("[I] "); log_line(__VA_ARGS__); } while (0)
#define LOG_D(...)  do { if (LOG_LEVEL_CUR >= 3) log_puts("[D] "); log_line(__VA_ARGS__); } while (0)

/* log_line 只支持 %s %d %u %x,自己拼字符串;浮点用定点格式化。
 * 这套代码零 malloc、不用 %f,Release 版照样安全 */

发布版把 LOG_LEVEL_CUR 收到 1,所有调试日志在编译期就剪掉了,flash 一字节不多占。这和之前写 DMA、ADC 那几篇时强调的原则是一脉相承的:调试手段不能有运行时代价,要么编译期消除,要么开销可忽略。

我踩过的五个坑,一次讲完

  1. ITM 死活不出字:LAR 没解锁。ITM->LAR = 0xC5ACCE55 这个魔数网上很难搜到全的,Cortex-M4/M7 都要。
  2. 输出全是乱码:ACPR 算出的波特率和调试器设置不一致。重新算一遍 fCPU/(ACPR+1),两边填同一个数。
  3. 调试器一断点就停输出:DBGMCU->CR 的 TRACE_IOEN 没开。
  4. 中断里偶发卡死:用了 while(ITM->PORT[0].u32==0) 等待版重定向,FIFO 满了就挂。中断路径必须用”满则丢”版本,靠丢包计数器事后发现。
  5. 换板子后 SWO 失效:PB3 被别的初始化代码占成了 GPIO。trace 引脚一旦启用就是调试器资产,GPIO 初始化表里要显式跳过。

迁移自查清单

如果你也要把调试输出从 UART 搬到 ITM/SWO,按这个清单过一遍:

  • ☐ ITM->LAR = 0xC5ACCE55 解锁写在所有 ITM/TPIU 配置之前
  • ☐ ACPR 分频用 fCPU/(目标波特率)-1 实算,与调试器端速率逐字核对
  • ☐ CoreDebug->DEMCR 的 TRCENA、ITM->TCR 的 ITMENA/TXENA/TSENA、ITM->TER 的端口位,三层开关全开
  • ☐ DBGMCU->CR 打开 TRACE_IOEN(需要断点期间输出时)
  • ☐ GPIO 配置确认 PB3/SWO 没有被任何模块重新初始化成普通 IO
  • ☐ printf 重定向函数只用于任务级;中断路径使用”满则丢 + 计数”版本
  • ☐ 关键变量用 Port 1 投 32 位原始值 + tag 序号,后台按时间戳分析
  • ☐ 丢包计数器接入常规巡检(空闲任务或看门狗喂狗路径打印一次)
  • ☐ 日志宏分级,Release 版编译期裁剪到 ERROR 级
  • ☐ 全链路验证:调高日志频率压测 10 分钟,丢包计数保持为 0

调试输出这事,做完这一次就再也不用回头了——SWO 那根线一直都在,只是很多人从没发现。

下一篇预告:《ESD/浪涌防护设计实战:一台设备在雷雨季批量”挂机”,我把 TVS、放电管和接地策略从头盘了一遍》,回到嵌入式硬件,聊聊接口防护那些事。