ITM/SWO 打印实战:printf 把 100μs 的控制中断拖到超时,我才明白调试输出也是一种”外设”
上周有个读者在后台问我:”为什么我在定时器中断里加了一行 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 输出口。
整个链路是这样的:
- 内核侧的 ITM(Instrumentation Trace Macrocell) 提供 32 个 Stimulus Port(激励端口),固件往
ITM_PORT[0]写一个字节,就完成”输出”——写进硬件 FIFO,不等发送; - ITM 的数据混合时间戳包,交给 TPIU(Trace Port Interface Unit);
- TPIU 把数据按 NRZ 曼彻斯特编码,从 SWO 引脚以设定波特率串行发出;
- 调试器把 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 那几篇时强调的原则是一脉相承的:调试手段不能有运行时代价,要么编译期消除,要么开销可忽略。
我踩过的五个坑,一次讲完
- ITM 死活不出字:LAR 没解锁。
ITM->LAR = 0xC5ACCE55这个魔数网上很难搜到全的,Cortex-M4/M7 都要。 - 输出全是乱码:ACPR 算出的波特率和调试器设置不一致。重新算一遍
fCPU/(ACPR+1),两边填同一个数。 - 调试器一断点就停输出:
DBGMCU->CR的 TRACE_IOEN 没开。 - 中断里偶发卡死:用了
while(ITM->PORT[0].u32==0)等待版重定向,FIFO 满了就挂。中断路径必须用”满则丢”版本,靠丢包计数器事后发现。 - 换板子后 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、放电管和接地策略从头盘了一遍》,回到嵌入式硬件,聊聊接口防护那些事。