☰
armclang函数插桩:破解Cortex-M调用链追踪难题
2026/10/17 7:48:25 网站建设 项目流程

做嵌入式的人应该都有过这种经历:某个模块逻辑看起来天衣无缝,跑起来却就是不对。你盯着调试器想知道它到底走了哪条分支、先调了谁又调了谁,结果断点打少了一个关键跳变看不出来,打多了在中断风暴里来回打断又根本没法看。今天想聊的 armclang instrument function 思路,就是用来解决这个问题的。

armclang 是 Arm Compiler 6 的 C/C++ 编译器前端,底层基于 LLVM,很多从 GCC 时代养成的调试习惯都能直接带过来。其中-finstrument-functions这个编译选项特别实用:它会在每个函数的入口和出口自动安插两个钩子函数调用,我只需要实现这两个钩子,就能在不改业务代码的前提下,把函数执行流程、嵌套关系、调用周期全部记录下来。对裸机程序、RTOS 环境、以及那些"卡在某个分支但不知道具体走到哪"的疑难问题,这一招往往能直接锁定范围。

这篇文章我会从编译原理、钩子实现、地址解析、性能开销这几个角度完整拆一遍,最后给出一套可以在 Cortex-M 上直接跑的最小实现。无论你是刚接触 armclang,还是已经用了很久但对插桩一直半信半疑,看完应该都能自己搭一套出来。

1. 调试函数调用链,常规手段为什么不够用

1.1 断点、打印和手动插桩的三种痛点

先说说我为什么最终会走上函数插桩这条路。做嵌入式调试,最常见的三种手段各有各的难受之处。

第一种是打断点。断点的问题是它只能让你看到"当前这一刻"的状态。程序停在断点上,你通过 Call Stack 窗口能看到当前调用链,但如果程序在几毫秒内从 A 分支跳到 B 分支又跳回 A,你根本不知道中间到底发生了什么。条件断点能解决一部分问题,但硬件支持有限,而且条件断点本身会让目标机运行速度大幅下降,很多时序问题反而没法复现。

第二种是往代码里塞打印。printf大法在 Linux 上调试很香,在嵌入式上就不是那么回事了。串口波特率一般 115200 到几兆,而你的主频可能是几十上百兆,一个高频函数每秒被调用几千次,串口根本来不及输出,日志直接丢失。更麻烦的是打印本身会改变程序时序,有时候把打印加上去故障就消失了,删掉故障又回来,这类玄学问题我相信做嵌入式的朋友都遇到过。

第三种是手动写插桩函数。也就是在关键函数的入口和出口手动加一行类似LOG_FUNC_ENTER("xxx")的代码。这个方法的问题是侵入性太强。你需要在每个想看的地方都改源码,改完还得记得删;而且编译器一旦做了内联,函数入口、出口的代码可能被优化得七零八落,日志顺序和真实执行路径对不上。一个稍微大一点的项目,几十个关键函数,手动维护这套东西的工程量相当可观。

这三种手段本质上是"用人的注意力去跟机器的执行流赛跑"。断点让你看一帧,打印让你看一条线,手动插桩让你看一小段,但程序真正的执行路径往往是网状交织的,尤其是在中断、任务切换、嵌套调用混合在一起的时候。

1.2 armclang 自动插桩的破局思路

-finstrument-functions的做法完全不一样。它是在编译器层面解决的:你不需要在源码里写任何打点,编译器在生成汇编代码的时候,自动在每个非内联函数的入口指令之后、返回指令之前,插入对两个特殊钩子函数的调用。这两个钩子是谁呢?就是__cyg_profile_func_enter和__cyg_profile_func_exit。

你只需要在工程的任意一个 C 文件里实现这两个函数,编译器插桩的代码就能在运行时真正调到你写的实现。钩子会收到两个地址参数:一个是当前函数的入口地址,另一个是调用点的返回地址。有这两个地址,你就能拼出这样一张图:F 被谁调用、F 调用了谁、F 在什么时间进去的、又在什么时间出来的。

这个方法最大的好处是业务源码完全零侵入。你想分析某个模块,只需要在编译该模块时加上-finstrument-functions,然后把整个工程的钩子函数实现一次,剩下的就是跑程序、收日志、离线解析。而且因为插桩发生在编译阶段,它对 ARM 汇编、链接脚本、启动代码这些底层细节是透明的,比那些依赖特定 IDE 或调试器的方案通用得多。

在 armclang 上使用这个特性,理论上和 GCC 是同一套机制,所以网上大量 GCC 下的插桩经验都能直接迁移。不过 ARM 架构毕竟有些自己的脾气,尤其是 Cortex-M 上只有 Thumb 指令、函数地址最低位还藏着 Thumb 标志这一类细节,底下我会专门讲。

2. 编译期自动插桩:armclang 到底做了什么

2.1 两个钩子函数的签名与调用约定

先明确钩子函数的原型。在 armclang 里,这个机制完全沿用 GCC 的规范,所以签名是这样的:

void __cyg_profile_func_enter(void *this_fn, void *call_site); void __cyg_profile_func_exit(void *this_fn, void *call_site);

两个参数都是地址,具体含义如下表:

参数含义典型用途
this_fn被插桩的函数入口地址通过符号表反查函数名
call_site调用点地址(当前函数返回后要回的位置)反查"谁在调用本函数"

call_site这个参数很有意思。在 ARM 架构下,BL 指令会往 LR 寄存器写入返回地址,编译器把当前函数内某个调用点的相邻地址作为call_site传进来。利用它你可以构建出调用关系图:比如 A 函数里调用 B,B 的call_site就落在 A 的地址区间内,说明 A 是 B 的调用者。

这里有一个极其重要的坑:钩子函数本身绝对不能继续被插桩,否则就是无限递归。编译器虽然在生成插桩代码时会特殊处理这两个钩子,但我仍然强烈建议在钩子实现上明确加上__attribute__((no_instrument_function)),让编译器和你自己都放心。另外,如果钩子函数定义在某个 C 文件里,而这个文件也用了-finstrument-functions,不加这个属性就可能出现"在钩子入口又插一次对钩子的调用"的情况,一旦触发就是栈溢出。

__attribute__((no_instrument_function)) void __cyg_profile_func_enter(void *this_fn, void *call_site) { /* 这里不能调用任何被插桩的函数 */ }

2.2 反汇编验证插桩前后的差异

很多人可能会觉得"编译器插桩"这个说法太抽象,我建议大家直接反汇编看一眼,印象最深。假设你有一个特别简单的 C 函数:

int helper(int a) { return a * 2 + 1; } int foo(int x) { return helper(x) + 3; }

用下面的命令编译,生成目标文件:

armclang --target=arm-arm-none-eabi -mcpu=cortex-m4 -mthumb \ -O1 -g -finstrument-functions -c foo.c -o foo.o

再反汇编:

arm-none-eabi-objdump -d foo.o

你会看到foo函数的汇编已经不是原本那几行加减和跳转,而是变成了类似这样的结构(这里省略了具体地址和部分指令,展示主要逻辑):

foo: push {r4, lr} mov r4, r0 ldr r0, .Lfoo_addr // this_fn = foo mov r1, lr // call_site = 调用 foo 的位置 bl __cyg_profile_func_enter ; ... 原始函数体 ... ldr r0, .Lfoo_addr mov r1, lr bl __cyg_profile_func_exit pop {r4, pc}

也就是说,你的业务代码旁边被编译器硬塞进了对钩子的调用。这也解释了为什么插桩会影响性能:每个函数的进入和退出都会多出至少一次函数跳转、寄存器保存和地址装载。

有一点需要特别说明:插桩后的代码在O1和O0下结构还算清晰,但在O2、O3下,函数内联会让"函数入口"变得模糊。编译器可能把小函数直接内联进调用者,那么插桩点就跟着上移到外层函数,你解析出来的调用树会跟源码结构有很大出入。后面讲优化等级的时候我再细说。

2.3 Thumb 地址位、符号表与地址对齐问题

这是 ARM 架构特有的一个坑,也是最容易被忽略的。

Cortex-M 系列处理器只支持 Thumb 指令集,而 Thumb 函数在 ELF 符号表和运行时地址里,最低位常常承载一个特殊标志:为 1 表示 Thumb 状态,为 0 表示 ARM 状态。虽然 Cortex-M 上不存在 ARM 状态的函数,但运行时你拿到的函数入口地址最低位多半是 1。比如foo函数的实际入口地址是0x08000102,那么this_fn里拿到的可能是0x08000103。

如果你直接把0x08000103拿去和符号表比对,就会偏一个字节。所以在我自己的实现里,处理函数地址的第一件事永远是:

uintptr_t fn_addr = (uintptr_t)this_fn & ~1U;

把最低位洗干净,再去做符号解析。这个问题在 x86 上不存在,所以网上很多 GCC 插桩教程根本不会提,但你在 ARM 上做的时候必须处理。

另外,如果你用arm-none-eabi-nm查看符号表,也会看到 Thumb 函数的符号类型带有t/T标记,比如:

arm-none-eabi-nm -n app.axf | grep " foo$"

输出可能是:

08000102 t foo

看到小写t就知道这是一个局部 Thumb 函数。符号值本身和实际地址理论上应该一致,但保险起见,你的解析脚本里最好统一做一次位掩码操作。

3. 从裸地址到函数名:离线解析流水线

3.1 用 arm-none-eabi-addr2line 批量翻译

插桩日志里记录的是一个个裸地址,直接在内存里看0x08000102并没有太大意义,你得把它们翻译成foo、bar.c:42这样的人类语言。最方便的工具是arm-none-eabi-addr2line,它是标准 ARM 工具链里自带的。

假设你已经从目标机上导出了一份地址列表addrs.txt,每一行一个十六进制地址,那么解析命令就是:

arm-none-eabi-addr2line -e app.axf -f -C -a < addrs.txt

参数解释一下:

参数作用
-e app.axf指定带调试信息的 ELF 固件
-f输出函数名
-C对 C++ 名字做 demangle
-a在每行前显示原始地址,方便核对

输出会长这样:

0x08000102 foo .../src/main.c:10 0x08000120 bar .../src/core.c:55

这里有个前置条件:编译时必须带-g选项。addr2line靠的是 ELF 里的 DWARF 调试信息,没有行号表它就只输出函数名,甚至什么都输出不了。所以建议在测试阶段统一编译成-O1 -g的调试固件,解析完再出正式固件。

3.2 ELF 符号表、map 文件与固件版本对齐

如果出于某种原因不方便用addr2line,或者你想要更快的大批量解析,可以自己建一份符号表。用nm输出按地址排序的符号表,然后对每一个日志地址,找它落在哪个符号的地址区间里:

arm-none-eabi-nm -n -S app.axf > symtab.txt

-n表示按地址排序,-S会显示符号大小。有了这个文件,你可以在脚本里对每个地址做二分查找,找到第一个满足sym_addr <= target_addr < sym_addr + sym_size的符号,就是目标函数。

这里有一个我踩过很多次的坑:固件版本对齐问题。插桩日志拿到手之后,你拿来做解析的 ELF 必须是目标机上正在运行的那份固件。哪怕只改了一行代码重新编译,所有函数的链接地址都可能整体偏移,拿旧 ELF 去解析新日志得到的函数名全是错的。

我现在的习惯是:每次构建固件的时候,把对应的.axf文件按构建时间归档一份,同时导出一份符号表文本。日志导出时记下自己的构建时间戳,解析前先核对固件和时间戳是否匹配。这个习惯帮我省掉了不少排查"为什么解析结果对不上"的时间。

3.3 C++ 符号重整后的函数名

如果你用的是 C++,函数名在符号表里是重度"重整"过的。像下面这个样子:

_ZN12SomeClassC2Ev _ZNK8Handler4NameEv

直接用addr2line -f的-C参数可以还原一部分,但如果日志量很大,我更推荐用arm-none-eabi-c++filt做批量处理:

arm-none-eabi-c++filt _ZN12SomeClassC2Ev

输出:

SomeClass::SomeClass()

函数插桩机制本身对 C++ 是完全无感的,它管你名字多长,反正只传地址。只是你解析的时候要记得把 demangle 这一步加进流水线,否则看到一屏幕_ZN开头的字符串很容易崩溃。

4. 一套可落地的 Cortex-M 调用序列追踪器

4.1 环形缓冲区里的钩子最小实现

理论铺垫完了,下面给一套我实际用过的实现。场景假设:Cortex-M4 主控,armclang 编译,裸机或者 RTOS 都行。整套追踪器的核心是一个环形缓冲区和一个周期计数器。

Cortex-M3/M4 内核有一个免费的周期计数器DWT->CYCCNT,只要使能之后它就按内核时钟不停累加。我们可以拿它给每次函数调用打时间戳,精度到单周期。

先使能 DWT:

void trace_init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; g_enabled = 1; }

然后定义一个记录结构体。为了控制 RAM 占用,每条记录 12 个字节,包含函数地址、时间戳、事件类型和上下文编号:

#define LOG_SIZE 4096 struct trace_rec { uint32_t fn; /* 函数地址,已清掉 Thumb 位 */ uint32_t ts; /* DWT->CYCCNT */ uint8_t evt; /* 0 = enter, 1 = exit */ uint8_t ctx; /* 0 = 线程/裸机, 1..255 = ISR 或任务编号 */ }; static volatile struct trace_rec g_log[LOG_SIZE]; static volatile uint32_t g_head; static volatile uint32_t g_enabled;

接下来是钩子实现。两个钩子几乎一样,区别只在于evt字段:

__attribute__((no_instrument_function)) void __cyg_profile_func_enter(void *this_fn, void *call_site) { (void)call_site; if (!g_enabled) return; uint32_t idx = g_head; g_log[idx].fn = ((uint32_t)this_fn) & ~1U; g_log[idx].ts = DWT->CYCCNT; g_log[idx].evt = 0; g_log[idx].ctx = ctx_current(); g_head = (g_head + 1) & (LOG_SIZE - 1); } __attribute__((no_instrument_function)) void __cyg_profile_func_exit(void *this_fn, void *call_site) { (void)call_site; if (!g_enabled) return; uint32_t idx = g_head; g_log[idx].fn = ((uint32_t)this_fn) & ~1U; g_log[idx].ts = DWT->CYCCNT; g_log[idx].evt = 1; g_log[idx].ctx = ctx_current(); g_head = (g_head + 1) & (LOG_SIZE - 1); }

可能有人会质疑:钩子函数里没有关中断,如果一个系统调用发生到一半,硬件中断进来又产生新的记录,环形缓冲区会不会写乱了?答案是会,g_head的读取和写入不是原子的。我的处理方式是:这种情况虽然存在,但head的错乱通常只是让一两条记录的上下文编号串掉,不会破坏整个缓冲区;如果你要求严格,可以在钩子前后用PRIMASK短暂关中断。代价是每次函数调用多几十个周期,对调试追踪器来说完全可以接受。

还有一个细节:钩子函数用到的全局变量最好加volatile,否则优化器可能把g_head的读写重排或者缓存到寄存器里,导致缓冲区索引不刷新。编译器插桩调用钩子的时候,它并不知道这些全局变量是共享的,volatile是最省事的约束。

编译的时候,把业务代码和钩子实现所在的文件一起编进去:

armclang --target=arm-arm-none-eabi -mcpu=cortex-m4 -mthumb \ -O1 -g -finstrument-functions \ -o app.axf main.c hooks.c common.c

链接时不需要额外选项,只要hooks.c里的钩子函数是全局可见的,编译器生成的未定义引用就能被满足。为了防止链接器把钩子当无用符号裁掉,可以在声明上再加__attribute__((used))。

4.2 从一串记录还原调用树和时间戳

现在日志已经存在g_log里了,怎么导出来呢?最简单的方法是在调试器里用 GDB 的 dump 命令,直接把整块缓冲区拉成二进制文件:

dump binary memory trace.bin &g_log ((char *)&g_log + sizeof(g_log))

导出的文件是按地址排列的原始结构体数组,解析脚本按 12 字节一个记录去读即可。

接下来写一个 Python 解析脚本,核心逻辑是维护每个上下文自己的深度计数器。遇到enter就缩进打印,深度加一;遇到exit就深度减一。不同ctx之间互不干扰,否则一个中断插入会把线程上下文的缩进打乱。

import struct def parse_trace(path): recs = [] with open(path, 'rb') as f: data = f.read() for i in range(0, len(data), 12): fn, ts, evt, ctx = struct.unpack_from('<IIBB', data, i) recs.append((fn, ts, evt, ctx)) return recs def addr2line(elf, addrs): # 这里可以一次把所有地址传给 arm-none-eabi-addr2line 做批处理 # 返回 {addr: name} 字典,为了简洁只写思路 return {} depth = {} for fn, ts, evt, ctx in parse_trace("trace.bin"): d = depth.get(ctx, 0) name = addr2line("app.axf", fn) if evt == 0: print(" " * d + f"[{ctx}] > {name} @ {ts}") depth[ctx] = d + 1 else: if d > 0: depth[ctx] = d - 1 print(" " * (d - 1) + f"[{ctx}] < {name} @ {ts}") else: print(f"[!] unbalanced exit {name}")

打印出来的效果类似这样:

[0] > baz @ 100120 [0] > foo @ 100140 [0] > helper @ 100160 [0] < helper @ 100190 [0] < foo @ 100210 [0] > bar @ 100230 [0] > helper @ 100250 [0] < helper @ 100280 [0] < bar @ 100300 [0] < baz @ 100320

缩进一眼就能看出调用嵌套关系。时间戳是DWT->CYCCNT的原始计数值,想换算成微秒就除以内核频率,例如 168MHz 下每个 tick 约 5.95ns。相减就可以得到每个函数消耗的周期数,用来判断热点。

不过我要提醒一点:enter和exit并不总是严格配对。如果你用了setjmp/longjmp、异常长跳转、或者某些 C 库函数内部做了不常规的跳转,可能出现只 enter 不 exit 的情况。解析脚本要能容忍这种不平衡,别因为一个exit把深度减成负数就崩溃。

4.3 多任务与中断上下文怎么区分

上面代码里的ctx_current()函数在裸机环境下最简单,直接返回 0 就行。但如果跑的是 RTOS,或者你的程序里有大量中断,单纯一个 0 会让不同任务的调用序列混在一起,解析出来根本没法看。

区分中断上下文有一个非常简单的方法:读IPSR寄存器。在 Cortex-M 上,IPSR的值表示当前正在执行的异常编号,0 表示线程模式,非 0 就是某个中断或 SysTick。我的ctx_current()是这样写的:

__attribute__((no_instrument_function)) static uint8_t ctx_current(void) { uint32_t ipsr; __asm volatile("mrs %0, ipsr" : "=r"(ipsr)); return (uint8_t)ipsr; }

这样每个中断源都有独立的ctx,在线程模式下统一是 0。解析脚本按ctx分组,就能看到线程里被哪个中断打断、中断嵌套了两三层时各自的函数调用路径。

如果你想进一步区分不同任务,可以在 RTOS 环境里读当前任务控制块的信息,比如拿到当前 TCB 指针之后把它映射成一个小整数编号,存到ctx里。这个映射关系最好简单直接,你在钩子里无论如何都不能做复杂运算,否则插桩开销会爆炸。一般来说,取当前任务 ID 的低 8 位就够用了。

还有个细节:中断服务函数在执行时如果调用了普通函数,那些普通函数同样会被插桩,所以你会看到 ISR 的ctx下挂着嵌套的 enter/exit。这正是我们想要的效果。反过来也要注意,插桩钩子本身会运行在中断上下文里,所以钩子代码不要碰任何可能违反中断安全的东西,比如malloc、复杂 RTOS API、长时间关中断等。

5. 插桩的实际开销与范围控制

5.1 一条记录的真实成本与缓冲预算

很多人第一次用-finstrument-functions都会被它的开销吓一跳,这是正常的。每次函数调用多出两次钩子调用,每次钩子调用又有读地址、读计数器、写缓冲区、移动头指针这些操作,在 Cortex-M4 上粗略估算,每个钩子大约 30 到 60 个周期。如果你的业务函数本身只有几十周期,插桩后执行时间直接翻两三倍都不奇怪。

数据量更吓人。假设内核主频 168MHz,每秒钟函数调用次数在百万级别也不是不可能。每条记录 12 字节,一秒就是 12MB 的日志量。就算你把环形缓冲区开到 4096 条记录,也就是大约 6 万周期之前的信息,随时会被新记录覆盖。所以软件插桩不适合做长时间的连续记录,它适合的是"短时间抓一段窗口"或者"触发后停止抓取"。

我自己常用的模式是:程序启动后插桩默认开启,环形缓冲一直覆盖着写。等到目标故障出现(比如某个断言触发、某个看门狗复位标志置位),就是正好写满了最近一段时间的历史。把这最后一段日志导出来,往往就能看到故障前最后一次函数调用链是什么。

如果你的板子 RAM 紧张,可以把LOG_SIZE降到 1024,那就是 12KB。也可以在trace_init里先把g_enabled置 0,等程序运行到感兴趣的时间点,再通过调试器写一个变量或者触发一个 GPIO 中断把g_enabled置 1。这样只有你想要的那一段会被记录,缓冲区利用率更高。

5.2 no_instrument_function 豁免与优化等级选择

全量插桩虽然省事,但不是所有代码都需要分析。对于中断里高频执行的函数、已经硬实时约束的代码段、以及你想让它保持原始时序的模块,可以用__attribute__((no_instrument_function))单独豁免。

__attribute__((no_instrument_function)) void hard_realtime_task(void) { /* 这里不会被插桩 */ }

启动代码在 C 运行时环境初始化之前就可能开始执行,插桩钩子在那时会依赖一堆还没准备好的全局变量,容易出现不可预知的问题。我建议对启动阶段、内存初始化之前的函数一律豁免,或者在最开始的钩子里做一个"已初始化"标志判断,没初始化就直接返回。

优化等级方面,我最常用的是-O1。O0插桩结构最清晰,但运行太慢,有时候程序跑着跑着就超时了;O2、O3会让内联改变函数边界,嵌套层级变得很奇怪。O1是一个折中:编译器保留足够多的函数边界,插桩调用顺序基本符合源码,同时运行速度还扛得住。

如果你实在需要在O2下跑,也可以对关键函数强制加__attribute__((noinline)),阻止内联导致插桩点上移。不过这是手工和编译器博弈,量大的时候维护成本很高,能不用就不用。

6. 软件插桩和硬件 trace 怎么选,以及我的经验

6.1 硬件事 Trace 与软件插桩的对比

既然在 ARM 上有硬件 trace 方案,为什么还要费劲做软件插桩?因为硬件 trace 不是每个平台都玩得转。我列一个对比表:

维度软件插桩(-finstrument-functions)硬件 trace
侵入性编译期改代码,运行时跑钩子不动代码,CPU 硬件辅助记录
时间精度依赖 DWT 周期计数,受钩子开销影响周期级甚至指令级精确
缓冲区用 RAM,一般几 KB 到几十 KB靠专用 trace 存储或调试器内存
数据量每条函数调用约 12~24 字节更大,能覆盖更长时间
部署门槛只要 armclang 和一个调试接口要求芯片带 ETM/ITM/SWO 且调试器支持
适用场景功能逻辑、调用顺序、启动流程时序分析、性能 profiling、低侵入追踪

硬件 trace 遇到的一个现实问题是,很多板子的 SWO 引脚根本没接出来,或者调试器不支持 trace 模式,甚至芯片型号不带 trace 单元。这时候软件插桩反而成了唯一能用的自动化追踪手段。另外,硬件 trace 通常只能告诉你"在哪扇区跑了很多周期",要还原出函数级调用关系,还是得手动配置 filter,远不如插桩日志直观。

6.2 我用 instrument function 用得最顺手的几个场景

我自己用过几次之后,总结出三个最适合-finstrument-functions的场景。

第一个是系统启动流程分析。多模块初始化顺序复杂,代码里各种#ifdef开关切换编译分支,光靠读源码很难确定实际执行顺序。插桩固件跑一遍,启动阶段的调用树清清楚楚,哪个模块在哪个模块之前初始化一目了然。

第二个是复现率很低的挂死。程序不定期死在某处,打断点又不知道打断点。我先做一个开启插桩的固件挂上去跑,等死机了读取最后一段环形缓冲,看到最后一个 enter 的函数、它调用了什么、在哪一步没有 exit,基本能锁定问题区域。这个场景下,缓冲数据不需要很长,只要覆盖死机前最后几十毫秒就够。

第三个是第三方库的代码路径梳理。很多 SDK、协议栈、驱动库里逻辑是层层封装的,你不知道某个回调最终会进到哪个深层函数。插桩之后,回调触发的完整路径直接打出来,比对着源码一层层追快得多。

6.3 最后分享一个我自己的习惯

如果你准备把这套东西用在自己的项目里,我建议你做两件小事。第一,把解析脚本里的addr2line调用改成批量模式,也就是把所有地址一次性地传给工具,而不是一个地址启动一次进程。日志多的时候,一次性调用能快几十倍。第二,每次出固件时把.axf和符号表按构建日期归档好,日志文件里也顺手记一个构建号,否则隔几天再来分析旧日志,你会想不起来该用哪个固件解析。

函数插桩并不能替代断点和硬件 trace,但它确实是调试工具箱里一个常被低估的组合拳。当别的办法都试过、程序依然像一团迷雾的时候,编译选项加上两个钩子函数,往往就是拨开迷雾的那只手。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询