第21章 SMP调试工具:JTAG调试、Trace工具、内核日志分析、性能计数器
多核调试,说实话比单核麻烦不少。单核时代你打断点、看寄存器,基本就能定位问题。到了SMP环境下,四个核同时在跑,一个核停下来了,其他核还在疯跑,时序全乱套了。我刚开始做SMP调试时,就吃过这个亏——一个核停在断点,其他核访问共享资源,直接死锁。
所以这一章,我把自己这些年积累的SMP调试经验梳理一下。咱们从四个维度来聊:JTAG调试、Trace工具、内核日志分析、性能计数器。每个工具都有它的适用场景,也有它的坑。
21.1 JTAG调试:多核断点的艺术
JTAG调试器,像J-Link、ST-Link、OpenOCD这些,是嵌入式调试的基石。但在SMP环境下,用法跟单核完全不同。
21.1.1 多核同步断点
单核调试时,你设一个断点,CPU停下来,你慢慢看。多核呢?你希望所有核都在同一个位置停下来,这样才能观察全局状态。
RT-Thread SMP支持多核同步断点机制。原理是这样的:当某个核触发断点时,它会发送一个IPI(核间中断)给其他核,让它们也停下来。
关键配置:在rtconfig.h中启用
#define RT_SMP_DEBUG_ENABLE 1
#define RT_SMP_BREAKPOINT_SYNC 1
我个人习惯在调试共享资源竞争问题时,用同步断点。比如你在临界区入口和出口各设一个断点,所有核同时停住,你就能看到哪个核在临界区里,哪个在等待。
小技巧:用OpenOCD调试时,可以这样设置多核断点:
# 暂停所有核
halt
# 在core0上设断点
bp 0x08001234 4 hw
# 在core1上设断点
bp 0x08001234 4 hw core 1
# 恢复所有核
resume
21.1.2 调试时的常见陷阱
我曾经遇到过一个问题:在某个核上设了断点,单步执行时,其他核还在跑。结果单步到一半,其他核修改了全局变量,导致当前核看到的变量值瞬间变了。嗯,这就是所谓的「调试器干扰效应」。
避坑指南:
- 调试共享数据时,最好暂停所有核
- 单步执行在SMP下基本不可靠,尽量用断点+日志
- 硬件断点数量有限(通常4-6个),别浪费在无关代码上
21.2 Trace工具:时间线上的真相
JTAG调试适合定位「点」上的问题。但SMP系统里,很多问题是「时序」问题——谁先谁后、等了多久、哪个核抢到了锁。这时候,Trace工具就派上用场了。
21.2.1 硬件Trace:ETM/ETB
ARM Cortex-A系列处理器内置了ETM(嵌入式跟踪宏单元)和ETB(嵌入式跟踪缓冲区)。它能记录CPU执行的每一条指令,包括指令地址、数据访问、异常事件等。
用ETM做SMP调试,最大的好处是:零干扰。它不占用CPU时间,不修改代码,纯粹是硬件在背后默默记录。
| 特性 | ETM硬件Trace | 软件Trace |
|---|---|---|
| 时间精度 | 纳秒级 | 微秒级(受中断影响) |
| 存储深度 | 有限(通常几MB) | 取决于内存 |
| 对系统影响 | 无 | 有(约5-10%性能损失) |
| 调试难度 | 需要专用工具 | 简单,printf即可 |
我记得有一次排查一个偶发的死锁问题,用JTAG怎么都复现不了。后来用ETM连续跑了8小时,终于抓到了那个「万分之一概率」的时序冲突。说白了,硬件Trace就是SMP调试的「黑匣子」。
21.2.2 软件Trace:rt_kprintf与SEGGER RTT
硬件Trace虽好,但专用工具贵啊。大部分项目还是用软件Trace。RT-Thread提供了rt_kprintf,配合SEGGER RTT(实时传输),效率比串口高得多。
RTT配置示例:
#include <rtthread.h>
#include <SEGGER_RTT.h>
void trace_spinlock_acquire(int cpu_id, void *lock)
{
SEGGER_RTT_printf(0, "[%d] %s: cpu=%d, lock=0x%08x\n",
rt_tick_get(), __func__, cpu_id, lock);
}
你想想看,用RTT输出日志,带宽能到几MB/s,串口才115200bps。而且RTT不占用中断,对实时性影响小。
21.3 内核日志分析:从海量信息中找线索
日志打出来了,怎么看?我见过不少工程师,日志一屏一屏地刷,眼睛都看花了,还是找不到问题。其实内核日志分析是有套路的。
21.3.1 日志分级与过滤
RT-Thread的日志系统支持分级:DEBUG、INFO、WARNING、ERROR。调试SMP问题时,我建议这样配置:
# 正常运行时只输出WARNING及以上
#define RT_DEBUG_SMP_LOG_LEVEL LOG_LEVEL_WARNING
# 调试特定模块时临时打开DEBUG
#define RT_DEBUG_SCHEDULER_LOG_LEVEL LOG_LEVEL_DEBUG
#define RT_DEBUG_IPC_LOG_LEVEL LOG_LEVEL_DEBUG
为什么要分级?因为SMP的调度日志量太大了。每个核每次调度都输出一行,4个核1秒钟调度几千次,日志量直接爆炸。我一般先定位到怀疑的模块,再单独打开那个模块的DEBUG日志。
21.3.2 关键日志模式识别
分析SMP日志时,有几个模式要特别留意:
- 频繁的核间迁移:如果一个线程在1ms内换了3个核,说明负载均衡可能有问题
- 长时间的自旋等待:某个核在spinlock上等了超过100μs,说明锁竞争激烈
- 优先级反转:低优先级线程占着锁,高优先级线程在等,中间还有中优先级线程抢CPU
我的习惯:写一个Python脚本,把日志里的时间戳、CPU ID、事件类型提取出来,画成时序图。一眼就能看出哪个时间段、哪个核在干什么。比盯着文本看效率高十倍。
21.4 性能计数器:用数据说话
有时候你觉得系统「卡」,但说不清卡在哪里。性能计数器就是用来回答这个问题的。
21.4.1 硬件性能计数器
现代ARM处理器内置了PMU(性能监视单元),可以统计:
- CPU周期数
- 指令数
- 缓存命中/未命中
- 分支预测错误
- 内存访问延迟
在SMP调试中,我最常用的是缓存未命中计数。为什么?因为多核竞争的核心问题就是缓存一致性。如果一个核频繁修改共享数据,其他核的缓存行就会失效,导致性能骤降。
读取PMU计数器的代码:
static inline uint32_t read_pmu_counter(int counter_id)
{
uint32_t value;
asm volatile("mrc p15, 0, %0, c9, c13, 0" : "=r"(value));
return value;
}
void dump_cache_miss_stats(void)
{
uint32_t l1_misses = read_pmu_counter(PMU_L1_DCACHE_MISS);
uint32_t l2_misses = read_pmu_counter(PMU_L2_CACHE_MISS);
rt_kprintf("L1 misses: %d, L2 misses: %d\n", l1_misses, l2_misses);
}
21.4.2 软件性能计数器
硬件计数器不够用怎么办?自己造。RT-Thread提供了rt_tick_get(),精度是系统tick(通常1ms)。但1ms对于SMP调试来说太粗了。
我建议用DWT循环计数器(Cortex-M系列)或通用定时器(Cortex-A系列),精度能到纳秒级。
// 使用DWT获取高精度时间
static inline uint32_t get_cycle_count(void)
{
uint32_t cycles;
asm volatile("mrs %0, PMCCNTR_EL0" : "=r"(cycles));
return cycles;
}
// 测量临界区执行时间
void profile_critical_section(void)
{
uint32_t start = get_cycle_count();
rt_enter_critical();
// 临界区代码
rt_exit_critical();
uint32_t end = get_cycle_count();
rt_kprintf("Critical section took %d cycles\n", end - start);
}
注意:性能计数器本身也会引入开销。测量一个只有10个指令的临界区,计数器读取就占了5个指令,这误差就大了。我一般测量执行时间超过1000个循环的代码段,这样计数器开销可以忽略。
21.5 综合调试策略
讲了这么多工具,到底怎么用?我总结了一个「三板斧」策略:
- 先用性能计数器做宏观扫描:看看哪个核的缓存未命中率高、哪个函数执行时间长
- 再用Trace工具做微观记录:锁定怀疑的函数后,用ETM或RTT记录详细执行轨迹
- 最后用JTAG做定点突破:在关键路径上设断点,确认变量值和执行顺序
我曾经用这个策略解决过一个棘手的SMP问题:一个网络驱动在4核上偶尔丢包。先用PMU发现某个核的L2缓存未命中率异常高,然后用ETM跟踪发现是DMA描述符被多个核同时修改,最后用JTAG断点确认了竞争窗口。整个过程用了半天,比之前瞎猜一周效率高多了。
嗯,调试工具说到底就是你的「眼睛」和「耳朵」。多核系统看不见摸不着,用好这些工具,才能把问题从「玄学」变成「科学」。