Skip to content

Repository files navigation

MRamTrace

轻量级 MCU 函数追踪与离线可视化工具。它使用 GCC/Clang -finstrument-functions 将函数进入/退出事件写入用户提供的 RAM 环形缓冲区,冻结后通过 UART、日志、文件、蓝牙或自定义后端导出,再结合 对应 ELF 生成 Perfetto、Speedscope、KCachegrind 和火焰图数据。

Lightweight RAM-backed function tracing and offline visualization for bare-metal and RTOS firmware.

为什么需要它

MRamTrace 面向无法长期连接调试器、没有 ETM/SWO,或者只能通过普通通信 链路取回现场数据的设备。采集阶段不依赖串口、文件系统、RTOS 或 Python; 目标侧只保存固定16字节事件,复杂分析全部在电脑端完成。

它可以回答:

  • 哪条函数调用路径消耗最多时间?
  • 某个函数由谁调用,又把时间花在了哪些子函数?
  • 两个 RTOS 任务的调用行为是否不同?
  • 某一时间点正在运行哪层调用栈?
  • 环形缓冲是否覆盖、传输是否丢块、ELF 是否匹配?

输出视图

输出 工具 主要用途
trace.chrome.json Perfetto 精确时间线、任务切换视角、调用顺序
trace.speedscope.json Speedscope Time Order、Left Heavy、Sandwich
trace.callgrind KCachegrind 调用关系图、Self/Inclusive cycles、调用次数
trace.<task>-<id>.callgrind KCachegrind 单独分析某个任务/上下文
trace.folded FlameGraph 标准折叠栈和差分火焰图输入
trace.flamegraph.svg 浏览器 无依赖、可分享的静态火焰图
trace.report.* 文本/JSON 覆盖率、事件速率、异常与符号解析健康度

截图

KCachegrind

聚合视图同时展示函数的 Inclusive/Self cycles、调用次数、Callee Map 和 caller→callee 调用图,适合分析“成本如何沿调用链传递”。

KCachegrind aggregate call graph

查看 KCachegrind 成本、调用图与 Callee Map 动态演示

KCachegrind interactive call graph demo

workload_maintenance_check 仅出现6次,这个视图展示了 KCachegrind 对低频分支的调用者、被调用者和边计数分析。

查看低频 maintenance 调用路径

KCachegrind rare maintenance path

Speedscope

Left Heavy 将相同调用路径合并,可快速看到主路径、分支权重, 并在 worker-aworker-b 之间切换。

Speedscope Left Heavy

查看 Sandwich 与 Time Order 视图

Sandwich 视图展示函数 Total/Self 排行,选中函数后同时展示它的 callers 和 callees:

Speedscope Sandwich

Time Order 保留真实进入/退出顺序,可放大到某一次具体调用:

Speedscope Time Order

Perfetto

Perfetto 按任务展示函数时间线,适合查看不同 RTOS 上下文的调用层级、 开始时间和持续时间。

Perfetto task timeline

查看 Perfetto 缩放与时间线导航动态演示

Perfetto interactive timeline demo

三种可视化工具怎么选

三个工具读取的是同一次采集产生的不同输出格式,底层事件和时间戳相同,区别 主要在观察视角,而不是数据精度。实际分析时通常先用 Perfetto 找到异常时段, 再用 Speedscope 判断主要调用栈,最后用 KCachegrind 深挖函数之间的成本传递。

Perfetto:看“什么时候发生了什么”

Perfetto 保留事件的先后顺序,将每个任务或上下文展示为独立时间线。它最适合 观察某次具体调用的开始时间、持续时间、嵌套层级以及不同任务在同一时段的行为。 可以连续缩放到单次函数调用,但不擅长直接回答整个采集窗口内哪个调用路径累计 最热。使用网页版即可打开,无需安装桌面软件。

Speedscope:看“时间主要花在哪条栈上”

Speedscope 兼顾时间顺序和聚合分析。Time Order 还原调用过程,Left Heavy 将 相同调用栈合并以突出主路径,Sandwich 则从选中函数同时查看 callers、callees、 Total 和 Self。它界面轻量、容易分享,适合快速定位热点和比较不同任务;但调用 边、调用次数和大型调用关系图不如 KCachegrind 完整。

KCachegrind:看“成本如何沿调用关系传播”

KCachegrind 将整段采集聚合为调用图,重点展示 Inclusive/Self cycles、调用次数、 caller/callee 边和 Callee Map。它适合量化某个函数为何昂贵、成本来自哪个子函数, 也容易发现低频但单次代价很高的路径。代价是丢失单次事件的时间顺序,而且通常 需要在 Linux/WSL 图形环境中安装桌面程序。

工具 核心视角 时间顺序 聚合热点 调用关系与次数 多任务/上下文 最适合
Perfetto 时间线 基础嵌套 强,同屏分轨 时序、延迟、某次具体调用
Speedscope 调用栈/火焰图 中等,Sandwich 视图 强,可切换 profile 快速找热点、比较主调用路径
KCachegrind 聚合调用图 最强,含边和调用次数 可打开汇总或单任务文件 成本归因、调用关系、量化优化

如果只选一个工具:排查时序问题优先 Perfetto,做日常热点分析优先 Speedscope, 准备深入优化函数和调用关系时优先 KCachegrind。三者结合使用能覆盖“发生时间、 热点路径、成本来源”三个互补维度。

架构

__cyg_profile_func_enter/exit
              |
              v
       16-byte trace event
              |
              v
 caller-owned RAM ring buffer
              |
          stop/freeze
              |
              v
   registered save sink callback
    UART / log / file / BLE / custom
              |
              v
     serial log + matching ELF
              |
              v
 Perfetto / Speedscope / KCachegrind / SVG

核心没有堆分配、没有后台任务,保存过程同步执行。应用决定缓冲区来自静态数组、 链接段、FreeRTOS heap、RT-Thread heap 或其他分配器,也决定在哪个普通任务执行 慢速 I/O。

RAM 占用与性能影响

缓冲区需要多大

Version 1 的每个事件固定占用 16 字节。一次被插桩函数的完整调用通常产生 一个 enter 和一个 exit,因此需要 2 个事件,即 32 字节。用户事件也各占 16 字节。传给 ram_trace_init() 的字节数会向下取整为完整事件数:

事件容量 = floor(buffer_size / 16)
完整函数调用数 ≈ 事件容量 / 2
可保留时长(秒)≈ 事件容量 / event_rate

下表中的保留时长按约 5,000 events/s 估算,接近本项目 STM32F407 示例的 实测 4,932.8 events/s。实际值取决于插桩范围和业务调用频率。

缓冲区 事件数(采样点) 约等于完整函数调用数 约可保留时长 @ 5k events/s
4 KiB 256 128 51 ms
8 KiB 512 256 102 ms
16 KiB 1,024 512 205 ms
24 KiB 1,536 768 307 ms
32 KiB 2,048 1,024 410 ms
64 KiB 4,096 2,048 819 ms
128 KiB 8,192 4,096 1.64 s

STM32F407 示例配置为 24 KiB,200 ms 抓取实际生成 984 个事件,没有覆盖。 除用户缓冲区外,当前 ARM GCC 示例中核心状态、service 和 6 个 sink 注册槽约占 96 字节常驻 RAM(具体值随 ABI、编译器和配置变化)。导出阶段还会使用任务栈: 默认 12 events/block 时,编码数组共 448 字节;当前示例构建中 ram_trace_export() 自身栈帧约 552 字节,尚不包含 sink 驱动及其调用链。因此 应通过链接器 map/stack-usage 文件和 RTOS 栈高水位检查最终栈余量;示例的导出 任务使用 1,536 字节栈。

如何选择和配置大小

第一次接入建议先分配 16–32 KiB,进行一次短时抓取。分析器会根据实测 event rate 和默认 1.25 安全系数,在 trace.report.txt/json 中自动给出 100、500、1000 ms 三档建议,也可以指定其他窗口:

python3 tools/ram_trace/ram_trace_analyze.py serial.log \
  --elf firmware.elf \
  --recommend-window-ms 200 \
  --buffer-safety-factor 1.5

计算方法为:

buffer_bytes >= event_rate × target_seconds × 16 × safety_factor

建议 safety_factor 取 1.2–1.5,并将结果向上取整到 16 字节。RAM 紧张时,优先 排除高频叶子函数,而不是盲目扩大缓冲区。RAM_TRACE_MODE_WRAP 保留停止前最新的 一段数据,适合复现故障前现场;RAM_TRACE_MODE_STOP_WHEN_FULL 保留从启动开始的 完整前缀,适合启动过程分析。

使用事件数组配置最直观,并天然满足对齐要求:

#define TRACE_EVENT_CAPACITY  1536U /* 24 KiB */
static RamTraceEvent_t trace_events[TRACE_EVENT_CAPACITY];

config.buffer = trace_events;
config.buffer_size = sizeof(trace_events);

也可以从 RTOS heap 分配字节缓冲区:

#define TRACE_BUFFER_SIZE  (24U * 1024U)
void *trace_buffer = pvPortMalloc(TRACE_BUFFER_SIZE);

config.buffer = trace_buffer;
config.buffer_size = TRACE_BUFFER_SIZE;

需要确保分配结果非空且至少按 RamTraceEvent_t 对齐;采集期间不得释放或复用。

对运行性能的影响

性能影响不能用一个与平台无关的固定百分比描述。每个已启用事件会执行一次短 临界区、读取时间戳和上下文、顺序写入 16 字节并更新计数器;一次完整函数调用 会执行两次。采集过程中没有格式化、CRC、Base64 或 UART/BLE I/O,这些操作都在 停止采集后执行,不会污染已冻结的时间线。

可用下面的公式估算目标板 CPU 开销:

CPU 开销比例 ≈ event_rate × cycles_per_event / CPU_frequency
单次函数调用附加周期 ≈ 2 × cycles_per_event

STM32F407 实测结果

以下数据来自 LXB407ZG(STM32F407ZG,168 MHz)、ARM GCC 9.2.1、Release 构建。 Benchmark 每组执行 512 次调用、重复7轮并取中位数,测量区间关闭中断且没有 串口 I/O:

测量项 实测周期 约合时间 @ 168 MHz
无插桩 plain_cycles_per_call 17.04 cycles/call 101 ns/call
已插桩、未采集 stopped_cycles_per_call 121.03 cycles/call 720 ns/call
已插桩、正在采集 active_cycles_per_call 400.98 cycles/call 2.39 μs/call
事件写入增量 capture_cycles_per_event 139.97 cycles/event 833 ns/event

同一次 FULL workload 抓取在 199.513 ms 内生成 984 个事件,即约 4,932 events/s,没有发生环形覆盖。按这个实际事件率估算:

开销来源 当前 workload 的 CPU 占用
Hook 存在但采集关闭(stopped − plain) 约 0.153%
采集期间写入事件(active − stopped) 约 0.411%
综合软件追踪开销 约 0.564%

Benchmark 同时报告 slowdown_percent=2252.52%,这是一个函数体仅约17周期的 极短测试函数从 17.04 增至 400.98 cycles/call 后的相对结果,不代表整个系统 变慢22倍。函数本身越耗时,固定插桩成本的相对占比越低;实际系统影响应使用 业务事件率按上述公式计算。该结果只代表此芯片、编译器、优化配置和插桩范围, 更换平台或构建参数后应重新测量。

STM32F407 示例默认启用 DWT 基准,在正式采集前分别测量无插桩、已插桩但未采集、 正在采集三种状态,并输出多轮测试的中位数:

RTRACE:BENCH iterations=512 trials=7 events=1024 plain_cycles_per_call=... stopped_cycles_per_call=... active_cycles_per_call=... capture_cycles_per_event=... slowdown_percent=...

其中 capture_cycles_per_event 是 active 相对 stopped 的增量,避免把禁用状态下 仍然存在的函数 hook 成本混入事件写入成本。可以通过 CMake 参数 -DRAM_TRACE_ENABLE_BENCHMARK=OFF 关闭示例基准。其他目标应使用 DWT cycle counter 或硬件定时器做同类测量。分析器会识别 RTRACE:BENCH,并将这些实测值 写入 trace.report.txt/json。还应分别关注:

  • 插桩本身的函数进入/退出调用,即使 trace 未启动也存在少量开销;
  • 高频小函数的相对扰动会明显高于耗时较长的业务函数;
  • 临界区会短暂增加中断延迟,硬实时 ISR 应排除插桩;
  • -fno-inline 会额外改变性能和代码尺寸,仅建议演示调用层级时使用;
  • 缩小插桩范围通常比增加 RAM 更能同时降低性能扰动和事件速率。

快速接入

1. 加入构建

纯裸机或自定义 RTOS:

add_subdirectory(path/to/MRamTrace/ram_trace)
target_link_libraries(your_firmware PRIVATE ram_trace)

FreeRTOS:

set(RAM_TRACE_WITH_FREERTOS ON CACHE BOOL "" FORCE)
add_subdirectory(path/to/MRamTrace/ram_trace)
target_link_libraries(your_firmware PRIVATE ram_trace)

只给需要观察的应用源文件开启插桩:

set_source_files_properties(src/application.c PROPERTIES
    COMPILE_OPTIONS "-finstrument-functions;-fno-optimize-sibling-calls")

Trace核心、平台回调、导出代码、串口/文件/BLE驱动必须使用 -fno-instrument-functions

2. 初始化与采集

RamTraceConfig_t config = {
    .buffer = trace_buffer,
    .buffer_size = sizeof(trace_buffer),
    .timestamp_frequency = CPU_CLOCK_HZ,
    .mode = RAM_TRACE_MODE_WRAP,
    .platform = platform_ops,
};

ram_trace_service_init(&config);
ram_trace_service_register_sink(&uart_sink);
ram_trace_service_start(1U); /* reset then start */

/* application runs */

ram_trace_service_stop();
ram_trace_service_save("uart");

底层 ram_trace_init/start/stop/export API 仍可直接使用;service 只是一个轻量 统一入口。

3. 注册保存方式

static RamTraceResult_t log_write(void *context,
                                  const uint8_t *data,
                                  size_t length)
{
    app_log_write(context, data, length);
    return RAM_TRACE_OK;
}

static const RamTraceSink_t log_sink = {
    .name = "log",
    .write = log_write,
    .context = NULL,
};

ram_trace_service_register_sink(&log_sink);
ram_trace_service_save("log");

文件后端可在 begin 中打开文件,在 write 中写入,在 end 中关闭。蓝牙后端 使用相同接口完成 MTU 分片或发送队列适配,模块本身不绑定具体文件系统和协议栈。

4. Shell/命令调用

将 Shell、FreeRTOS CLI 或 AT 命令解析得到的 argc/argv 转发给 ram_trace_command_execute()

trace status
trace start
trace stop
trace reset
trace sinks
trace save uart
trace save file trace.rtrace
trace save ble

生成分析结果

python3 tools/ram_trace/ram_trace_analyze.py serial.log \
  --elf firmware.elf \
  --output trace-output

分析器仅使用 Python 标准库。符号解析需要 arm-none-eabi-nm,也可以在主机 测试 ELF 上使用普通 nm。日志既可以是纯文本,也可以是 WSL Serial Monitor 生成的 NDJSON。

打开结果:

kcachegrind trace-output/trace.callgrind

必须使用与烧录固件严格匹配的 ELF。分析器会验证每个数据块和完整数据流的 CRC32,并报告事件序号缺口、环形覆盖、调用栈错配和未解析地址。

示例

examples/stm32f407-freertos/ 提供 STM32F407 + FreeRTOS 参考接入,包括:

  • DWT cycle counter 时间戳;
  • FreeRTOS task 上下文识别;
  • UART保存后端;
  • 两个工作任务;
  • 25个测试函数和最长6层调用路径。

该示例不复制 FreeRTOS、CMSIS、启动文件和链接脚本,请通过 CMake 参数指向 本机已有依赖,详见示例目录 README。

本机测试

cmake -S . -B build -G Ninja
cmake --build build
ctest --test-dir build --output-on-failure

当前限制

  • Version 1 面向单核、32位、小端目标。
  • 函数耗时是墙钟时间;任务被抢占的时间尚未从函数耗时扣除。
  • 高频叶子函数可能快速填满缓冲区,应通过编译期排除列表控制范围。
  • 环形覆盖后可能从半条调用栈开始,分析器会报告并丢弃不完整前缀。
  • 保存是同步操作,必须在停止采集后由普通任务执行,不能在 ISR 中调用。
  • 它是低成本软件追踪方案,不替代 ETM 等无侵入硬件追踪。

License

MIT

About

Lightweight RAM-backed function tracing and offline visualization for MCU and RTOS firmware

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages