Rust in Everything
基于 probe-rs-debug 0.32.0

栈展开:CFI 与异常帧

主线位置:调试主线后半段的核心。输入是停下时的一组寄存器和一个能读内存的东西(活的 Core 或一个 coredump),输出是一列 StackFrame:每帧的函数名、源码位置、寄存器、CFA 和一个还没展开的变量缓存。命令行在 HardFault 后打印的调用栈、VS Code 的调用栈窗口都来自这一个函数。

flowchart TB
  start["初始寄存器:PC、SP、LR、xPSR 等"] --> pc{"PC 为 0 或全 1,或帧数到上限"}
  pc -->|是| done[结束]
  pc -->|否| p1["第 1 步:按 PC 找函数和内联函数,建 1 到 n 帧"]
  p1 --> fde{".debug_frame 里有这个 PC 的 FDE"}
  fde -->|有| p2["第 2 步:按 CFI 规则算 CFA 和调用者的寄存器"]
  fde -->|没有| nodbg["只靠 LR 推调用者 PC"]
  p2 --> p3{"第 3 步:LR 是 EXC_RETURN"}
  nodbg --> p3
  p3 -->|是| exc["从栈上取回被打断的 8 个寄存器,插入异常帧"]
  p3 -->|否| lr["调用者 PC 取 LR 推出来的值"]
  exc --> pc
  lr --> pc

入口

DebugInfo::unwind 的签名把它的依赖说得很清楚:

// probe-rs-debug/src/debug_info.rs
    pub fn unwind(
        &self,
        core: &mut impl MemoryInterface,
        initial_registers: DebugRegisters,
        exception_handler: &dyn ExceptionInterface,
        instruction_set: Option<InstructionSet>,
        max_stack_frame_count: usize,
    ) -> Result<Vec<StackFrame>, Error> {
  • 读内存只要 MemoryInterface,活目标和 coredump 一视同仁(会话与目标描述)。
  • 初始寄存器由调用方准备:活目标用 DebugRegisters::from_core,它把核的每个整数寄存器读一遍;三个夹具里都是 20 个(R0–R15、MSP、PSP、xPSR 和一个 EXTRA)。按 Cortex-M 内核量出来的「一个寄存器 3 次内存访问」,活目标上光准备这一步就是约 60 次访问(推算,待实测)。前 32 个寄存器同时带上 DWARF 编号,CFI 规则用这个编号指寄存器。
  • ExceptionInterface 按核的类型选(exception_handler_for_core):Armv6-M、Armv7-M、Armv8-M、RISC-V、Xtensa 各一个,Cortex-A / R 用一个什么都不做的占位实现。
  • 帧数上限由调用方给:命令行打印 HardFault 调用栈时用请求里的 stack_frame_limit(stack_trace.rs:130),DAP 服务器每次停下固定传 500,见 DAP server 与 VS Code。

循环的三步

真正的实现是 unwind_impl,一个 'unwind: while 循环,只要 PC 不是 0 也不是全 1 就继续。源码里每一步都用注释标了 PART 1 / 2 / 3。

第 1 步:为当前 PC 建帧。 先用 get_unwind_info 在 .debug_frame 里找覆盖这个 PC 的 FDE,算出这一行的 CFI 规则;再用 determine_cfa 算 CFA(canonical frame address:调用当前函数之前、调用者的 SP 值)。然后 get_stackframe_info 按 PC 找函数 DIE 和其中的内联函数(DebugInfo 与 DWARF),一个函数一帧,最内层的排在前面:

帧PC源码位置变量
最内层(实际在执行的内联函数,或非内联函数本身)当前 PCget_source_location(PC),即行表给的位置这个函数 DIE 下的局部变量
外面的每一层下一层内联函数的 low_pc内联 DIE 记录的调用处(DW_AT_call_file / DW_AT_call_line)同上

所有这些帧共享同一组寄存器、同一个帧基址和 CFA:内联函数没有自己的栈帧。每帧的 local_variables 是一个只有根节点的 VariableCache,变量要等有人来要时才展开(变量与单步)。按地址找不到函数,就退到符号表猜名字,再不行就是 <unknown function @ 0x...>。

第 2 步:算调用者的寄存器。 对每个寄存器(PC 除外)调 unwind_register,按 CFI 规则求调用者那一帧里它的值(unwind_register_using_rule):

CFI 规则含义处理
Offset(N)调用者的值存在 CFA + N 处从内存读 4 或 8 字节;读失败就结束展开,错误接在最后一帧的函数名后面
SameValue没动过照抄
Undefined(没有规则)—交给 ExceptionInterface::unwind_undefined_register 用经验规则补
其他(Register、Expression 等)—unimplemented!(),会 panic

CFA 规则也只支持「某寄存器 + 偏移」一种,遇到表达式形式同样是 unimplemented!()。thumbv* 目标上 rustc 和 LLVM 生成的 CFI 基本只用这几种,夹具里没有触发 panic。

没有规则时的经验规则在 unwind_undefined_register 的默认实现里:SP 取 CFA(低两位清零);帧指针如果不是「被调用者保存」的寄存器也取 CFA;LR 如果和当前 PC 不同就沿用当前值(内联代码的情况);其他寄存器按 CoreRegister 上标的 UnwindRule 保留或清空。

第 3 步:检查异常边界,推出调用者 PC。 如果算出来的 LR 是 EXC_RETURN,说明当前帧是异常处理函数,下一节讲;否则调用者的 PC 由 LR 推出(unwind_program_counter_register):

// probe-rs-debug/src/debug_info.rs,unwind_program_counter_register
                Some(InstructionSet::Thumb2) => {
                    // ...
                    // We have to clear the last bit to ensure the PC is half-word aligned. (on ARM architecture,
                    // when in Thumb state for certain instruction types will set the LSB to 1)
                    (
                        Some(RegisterValue::U32((return_address - 2) & !0b1)),
                        "PC=(unwound (LR - 2) & !0b1) (dwarf Undefined)",
                    )
                }

LR 里是返回地址加上 Thumb 位,指向 BL 的下一条指令;减 2 再清掉最低位,PC 就落在调用指令内部,查行表时得到的是调用所在的那一行,而不是下一行。所以展开结果里调用者帧的 PC 都是「返回地址减 2」:STM32U585 夹具里 main 一帧的 PC 是 0x0800086a,而 BL level_a 从 0x08000868 开始、返回地址是 0x0800086c。RISC-V 减 2 或 4,Xtensa 减 3 并补回高两位。

两次循环之间寄存器完全没变时,循环直接退出,防止死循环。

跨过异常帧

Cortex-M 进入异常时,硬件把 R0–R3、R12、LR、返回地址、xPSR 这 8 个字压到当前栈上,然后把 LR 设成一个特殊值 EXC_RETURN(高位全 1,比如 0xFFFFFFF9)。异常处理函数的 CFI 只描述它自己压的东西,对硬件压的这 8 个字一无所知。展开到异常处理函数时,按 CFI 推出来的「返回地址」就是 EXC_RETURN,再往下推就没有意义了。这时由 ExceptionInterface 接手。

Armv6-M 和 Armv7-M 共用 armv6m_armv7m_shared::exception_details:

  1. LR 的 bit[31:28] 不是 0xF 就不是异常返回,返回 None,循环照常往下走。
  2. 从 xPSR 的低 9 位取异常号;异常号是 1(复位)就插一帧 Reset 并清空所有寄存器:复位之前没有调用者。
  3. 否则从当前 SP 读 8 个字,按 EXCEPTION_STACK_REGISTERS 的顺序填回 R0、R1、R2、R3、R12、LR、PC、xPSR(calling_frame_registers);异常描述由各架构读 SCB 的故障状态寄存器拼出来,比如「Escalated UsageFault <Cause: Undefined instruction>」。Armv7-M 对不精确的总线错误把 PC 减 1,让它指回出错的那条指令(armv7m.rs:329)。
  4. 插一帧以异常描述为名的帧,再把 SP 加上异常帧的大小:EXC_RETURN 的 bit 4 为 1 是标准帧 0x20 字节,为 0 是带 FPU 寄存器的扩展帧 0x68 字节。然后 continue 'unwind,用取回的寄存器继续。

Armv8-M 的 exception_details 和 calling_frame_registers 是单独写的,多考虑了几件事:

Armv6-M / v7-M(共用)Armv8-M
判定LR bit[31:28] 为 0xFLR 高 8 位为 0xFF;0xFE(安全态到非安全态的 FNC_RETURN)是 todo!()
异常帧在哪个栈当前 SPSPSEL = 1 时读 PSP(有安全扩展时分 PSP_S / PSP_NS,先读 ID_PFR1 判断);SPSEL = 0 时用按 CFI 推出来的 SP
帧大小0x20 或 0x680x20,加上 DCRS = 0 时的附加状态 0x28、FP 上下文 0x48、xPSR bit 9(SPREALIGN)为 1 时的 4 字节对齐填充

最后一行有一个差别:Armv6/7-M 的共用实现定义了 xPSR 的 bit 9(stack_was_realigned),但计算帧大小时没有用它。按架构手册,进入异常时如果 SP 不是 8 字节对齐,硬件会先空出 4 字节再压栈,并在压栈的 xPSR 里置 bit 9;这时异常帧实际是 0x24 字节,按 0x20 算出来的 SP 少 4,后面各帧的 CFA 都会跟着偏。nRF52833 夹具里压栈的 xPSR 是 0x01000000(bit 9 为 0),夹具没有覆盖这种情况;真实芯片上会不会因此展开出错,待实测。

实验:三个夹具的展开

实验程序把夹具的 ELF 和 coredump 读进来,按上面的签名调 unwind(上限 50 帧),打印每帧。nRF52833(Armv7-M),UsageFault 升级成 HardFault:

6 frames
#0  pc=0x000060de inl cfa=0x20003f38 software_breakpoint  <项目>/src/lib.rs:371
#1  pc=0x000060de     cfa=0x20003f38 __cortex_m_rt_HardFault  <项目>/src/bin/nRF52833_xxAA.rs:93
#2  pc=0x00000632     cfa=-          HardFault <Cause: Escalated UsageFault <Cause: Undefined instruction>>
#3  pc=0x00000632 inl cfa=0x20003f58 __udf  <cargo registry>/cortex-m-0.7.7/src/../asm/inline.rs:181
#4  pc=0x00000632     cfa=0x20003f58 udf  <cargo registry>/cortex-m-0.7.7/src/asm.rs:43
#5  pc=0x00000184     cfa=0x20003f60 trigger_hardfault_from_usagefault : ERROR: UNWIND: Failed to read value for register R14/LR from address 0x0000000020003f5c (4 bytes): The coredump does not include the memory for address 0x20003f5c of size 0x4  <项目>/src/bin/nRF52833_xxAA.rs:55

第 2 帧就是异常帧:它没有 CFA,名字是异常描述。停下时 LR 是 0xFFFFFFF9、SP 是 0x20003f30,处理函数的 CFI 给出 CFA 0x20003f38,从那里读出 8 个字,压栈的 PC 是 0x632(udf 指令),压栈的 LR 是 0x187;SP 加 0x20 得 0x20003f58,正好是第 3、4 帧的 CFA。

RP2040(Armv6-M),递归 6 层:

9 frames
#0  pc=0x10007404     cfa=0x20002ff0 test_deep_stack  <项目>/src/lib.rs:344
#1  pc=0x1000740c     cfa=0x20003080 test_deep_stack  <项目>/src/lib.rs:338
...(#2 到 #5 同样是 test_deep_stack:338,CFA 每帧加 0x90)
#6  pc=0x10007316     cfa=0x20003eb8 setup_data_types  <项目>/src/lib.rs:325
#7  pc=0x1000028c     cfa=0x20003ff8 __cortex_m_rt_main  <项目>/src/bin/RP2040.rs:55
#8  pc=0x10000202     cfa=0x20004000 __cortex_m_rt_main_trampoline : ERROR: UNWIND: Failed to read value for register R14/LR from address 0x0000000020003ffc (4 bytes): The coredump does not include the memory for address 0x20003ffc of size 0x4  <项目>/src/bin/RP2040.rs:21

STM32U585(Armv8-M),同一个 MSP 上两层嵌套异常,一直展开到 Reset:

17 frames
#0  pc=0x080008dc inl cfa=0x200bff80 __nop  ./asm/lib.rs:48
#1  pc=0x080008dc     cfa=0x200bff80 __nop  ./asm/lib.rs:51
#2  pc=0x08000878 inl cfa=0x200bff88 nop  <cargo registry>/cortex-m-0.7.7/src/call_asm.rs:19
#3  pc=0x08000876     cfa=0x200bff88 hf_inner  <项目>/src/bin/nested_exceptions.rs:81
#4  pc=0x0800090a inl cfa=0x200bff90 __cortex_m_rt_HardFault  <项目>/src/bin/nested_exceptions.rs:72
#5  pc=0x08000908     cfa=0x200bff90 __cortex_m_rt_HardFault_trampoline  <项目>/src/bin/nested_exceptions.rs:70
#6  pc=0x080008ce     cfa=-          HardFault <Cause: Escalated BusFault (Precise data access error) at location: 0xfffffff0>
#7  pc=0x080008ce inl cfa=0x200bffb8 write_volatile<u32>  <rustup>/.../library/core/src/ptr/mod.rs:2182
#8  pc=0x080008ce     cfa=0x200bffb8 svc_inner  <项目>/src/bin/nested_exceptions.rs:66
#9  pc=0x080008ae     cfa=-          SVC
#10 pc=0x080008ae inl cfa=0x200bffe0 read_volatile<u32>  <rustup>/.../library/core/src/ptr/mod.rs:2095
#11 pc=0x080008a8     cfa=0x200bffe0 level_c  <项目>/src/bin/nested_exceptions.rs:52
#12 pc=0x08000896     cfa=0x200bffe8 level_b  <项目>/src/bin/nested_exceptions.rs:43
#13 pc=0x08000882     cfa=0x200bfff0 level_a  <项目>/src/bin/nested_exceptions.rs:35
#14 pc=0x0800086a     cfa=0x200bfff8 __cortex_m_rt_main  <项目>/src/bin/nested_exceptions.rs:87
#15 pc=0x080008bc     cfa=0x200c0000 __cortex_m_rt_main_trampoline  <项目>/src/bin/nested_exceptions.rs:85
#16 pc=0x0800083a     cfa=-          Reset

几处值得对照:

  • 第 2、3 帧:停下时 LR 是 0x0800087b,调用者 PC 是 (0x87b - 2) & !1 = 0x878,落在内联进 hf_inner 的 nop 里;外层 hf_inner 一帧的 PC 是那个内联函数的 low_pc 0x876。
  • 第 6、9 帧是两个异常帧。BusFault 的描述带出了出错地址 0xfffffff0:coredump 里存了 0xE000E000 起的 4 KiB 系统控制空间,故障地址寄存器读得到。
  • 第 10、11 帧:level_c 的源码位置是第 52 行(内联 read_volatile 的调用处),而同一个 PC 用 get_source_location 查出来是 ptr/mod.rs:2095(DebugInfo 与 DWARF)。
  • 最后一帧 Reset 不是异常帧,而是 cortex-m-rt 的复位处理函数本身:PC 0x0800083a 落在它里面,它没有 DWARF 函数信息,名字是按符号表查出来的,所以也没有源码位置和 CFA。再往下推,LR 0x0800083d 算出的 PC 还是 0x0800083a,寄存器没有变化,循环退出。

三个夹具的帧数是 6、9、17,和仓库里 insta 快照的帧数一致。用一个包了计数器的 MemoryInterface 数展开期间的内存读:nRF52833 8 次共 60 字节,RP2040 21 次共 84 字节,STM32U585 23 次共 148 字节。换到活目标上,每次读是一次或几次 MEM-AP 访问,加上前面准备寄存器的约 60 次;真实的耗时待实测(硬件待测清单 HW-5)。

展开停在哪

nRF52833 和 RP2040 两个夹具都不是展开到 Reset,而是停在「coredump 不包含这段内存」上。原因在夹具的内存范围:

range 0x00000000..0x0000018b (395 bytes)
range 0x00000632..0x00000636 (4 bytes)
range 0x000060de..0x00006ba0 (2754 bytes)
range 0x00007228..0x00007240 (24 bytes)
range 0x20000000..0x200008cc (2252 bytes)
range 0x20003f30..0x20003f5c (44 bytes)
range 0xfffffff9..0xfffffffd (4 bytes)
range 0xe000e000..0xe000efff (4095 bytes)

栈上只存了 0x20003f30..0x20003f5c 这 44 字节,第 5 帧要的 LR 存在 0x20003f5c,刚好在范围外。这些零碎的范围(甚至包括把 EXC_RETURN 当成地址的 0xfffffff9)的形状,和 DAP 服务器 REPL 的 dump 命令不带参数时的做法一致:它只转储当前作用域里变量和寄存器指向的内存,再把相距 0x400 字节以内的范围合并(core_data.rs:481)。夹具是不是这样生成的,未核实。STM32U585 的夹具则直接存了栈顶的 8 KiB 和系统控制空间,所以能展开到底。

把 STM32U585 夹具的 RAM 范围从顶上截掉一段,再展开一次,能看到两种停法:

== 截掉 0x8 字节:16 帧
#15 pc=0x080008bc     cfa=0x200c0000 __cortex_m_rt_main_trampoline : ERROR: UNWIND: Failed to read value for register R7/FP from address 0x00000000200bfff8 (4 bytes): The coredump does not include the memory for address 0x200bfff8 of size 0x4  ...
== 截掉 0x30 字节:10 帧
#8  pc=0x080008ce     cfa=0x200bffb8 svc_inner  <项目>/src/bin/nested_exceptions.rs:66
#9  pc=0x080008ce     cfa=-          UNWIND: Error while checking for exception context. The stack trace will not include the calling frames.
Probe(Other("The coredump does not include the memory for address 0x200bffb8 of size 0x20"))

按 CFI 读寄存器失败,错误接在最后一帧的函数名后面(第 2 步);读异常帧的 8 个字失败,则多插一个以错误信息为名的帧(第 3 步)。两种都不是 Err:unwind 返回 Ok,能展开多少帧就给多少帧。活目标上对应的情况是栈指针跑飞、指到了不可读的地址,常见坑把这个报错当作一个坑,还演示了帧指针坏了却不报错的情形。

最后一种停法是没有 CFI。stm32u585_exception_no_debuginfo 夹具整个 ELF 没有 .debug_frame:

5 frames
#0  pc=0x08000858     cfa=-          <unknown function @ 0x08000858>
#1  pc=0x0800088e     cfa=-          SVC
#2  pc=0x0800088e inl cfa=-          read_volatile<u32>  <rustup>/.../library/core/src/ptr/mod.rs:2095
#3  pc=0x08000888     cfa=-          nodbg_b  <项目>/src/bin/exception_no_debuginfo.rs:41
#4  pc=0x08000876     cfa=-          nodbg_a  <项目>/src/bin/exception_no_debuginfo.rs:32

找不到 FDE 时走 unwind_pc_without_debuginfo:只用当前 LR 推调用者 PC,其他寄存器不动;而且这条路径上同样检查 EXC_RETURN(0.32 的 #4087 加的,夹具源码的注释说修之前会在第一帧就停下)。于是从 naked 的 SVCall 处理函数跨过异常帧到了 nodbg_b,再靠 nodbg_b 的 LR 到了 nodbg_a;nodbg_a 的返回地址存在栈上,没有 CFI 就不知道在哪,展开到此为止,main 和 Reset 都没出来。第 0 帧连名字也没有:SVCall 在符号表里,但按符号表查名字也没查到,原因未核实。

小结

  • unwind 只要一个 MemoryInterface、一组初始寄存器和一个按核类型选的 ExceptionInterface;活目标上准备寄存器本身就要读 20 个寄存器。
  • 每轮循环:按 PC 建帧(内联函数各占一帧,共享寄存器和 CFA)→ 按 .debug_frame 的 CFI 规则推调用者的寄存器 → 检查 EXC_RETURN → 调用者 PC 取 (LR - 2) & !1。CFI 只支持「寄存器加偏移」的 CFA 和 Offset / SameValue / Undefined 规则,其余是 unimplemented!()。
  • 遇到 EXC_RETURN 时从栈上取回硬件压的 8 个字、插一个异常帧再继续;Armv8-M 还处理 PSP / MSP、附加状态、FP 上下文和对齐填充,Armv6/7-M 的共用实现没有算对齐填充,真实芯片上的影响待实测。
  • 三个夹具展开出 6、9、17 帧;展开失败不返回 Err,而是把错误写进最后一帧的名字或多插一帧;coredump 缺内存和没有 CFI 是夹具里看得到的两种停法。

💬 关联讨论

发起讨论
加载中...