Rust in Everything
基于 probe-rs-debug 0.32.0

DebugInfo 与 DWARF

主线位置:调试主线后半段的起点。核停下、寄存器读出来以后(Cortex-M 内核),第一件事是把固件 ELF 里的 DWARF 读进内存,建好「地址 → 函数」「地址 → 源码行」两张查找表。本页讲 DebugInfo 读了什么、怎么查,以及变量的位置表达式怎么求值;栈展开和变量展开分别在后两页。

flowchart TB
  elf["固件 ELF"] --> obj["object:按节名取数据"]
  obj --> dwarf["gimli::Dwarf:debug_info、debug_abbrev、debug_str 等"]
  obj --> frame["DebugFrame:只有 .debug_frame"]
  obj --> loc["LocationLists:debug_loc、debug_loclists"]
  obj --> line["DebugLine:行表"]
  dwarf --> units["每个编译单元一个 UnitInfo"]
  units --> fdie["function_dies:地址范围到函数 DIE"]
  units --> parents["parents:子 DIE 到父 DIE"]
  units --> lang["language:Rust、C 或未知"]

DebugInfo 里有什么

DebugInfo 把 gimli 的几个节对象和一组 UnitInfo 放在一起:

// probe-rs-debug/src/debug_info.rs
pub struct DebugInfo {
    pub(crate) dwarf: gimli::Dwarf<DwarfReader>,
    pub(crate) frame_section: gimli::DebugFrame<DwarfReader>,
    pub(crate) locations_section: gimli::LocationLists<DwarfReader>,
    pub(crate) address_section: gimli::DebugAddr<DwarfReader>,
    pub(crate) debug_line_section: gimli::DebugLine<DwarfReader>,

    pub(crate) unit_infos: Vec<UnitInfo>,
    pub(crate) endianness: gimli::RunTimeEndian,

    pub(crate) addr2line: Option<addr2line::Loader>,
}

from_raw 用 object 解析 ELF,按节名取数据(压缩的节先解压,找不到的节当成空),交给 gimli::Dwarf::load;然后遍历所有编译单元,每个建一个 UnitInfo。整个文件一次读进内存,之后的查找都在内存里做,不再碰 ELF。

调用帧信息只有一个来源:gimli::DebugFrame,也就是 .debug_frame 节。整个 crate 里没有 EhFrame:Linux 用户态程序常见的 .eh_frame 在这里不读。对裸机固件这通常够用:本卷看到的 thumbv* 固件都把 CFI 放在 .debug_frame 里。用自己写的小程序把四个夹具 ELF 的调试节列出来:

夹具 ELF.debug_frame.eh_frame.debug_info
nRF52833_xxAA_hardfault_from_usagefault68 360 字节无600 320 字节
RP2040_full_unwind79 468 字节无778 217 字节
stm32u585_nested_exceptions20 912 字节无176 849 字节
stm32u585_exception_no_debuginfo无无176 361 字节

最后一个夹具故意没有 .debug_frame(它的异常处理函数是 naked 汇编),用来测「没有 CFI 时展开能走多远」,下一页会看到结果。只读 .debug_frame 的取舍见设计取舍。

from_file 比 from_raw 多做一件事:用同一个路径再建一个 addr2line::Loader(debug_info.rs:45)。它只用在一个地方:某个地址找不到函数 DIE 时,按符号表猜一个函数名(get_stackframe_from_symbols),再用 Rust / C++ 的规则 demangle。从内存缓冲区建的 DebugInfo 没有这个后备。

UnitInfo:一个编译单元一张表

UnitInfo::new 先读单元根 DIE 的 DW_AT_language 决定用哪种语言的格式化规则(读不到就假定 Rust),再在 process_unit 里把整个 DIE 树深度优先走一遍,顺手建两张表:

  • parents:每个 DIE 的父 DIE。gimli 的游标只能往下走,变量展开时要往上找作用域,就查这张表。
  • function_dies:每个 DW_TAG_subprogram 的地址范围到它的 DIE 偏移。

按地址找函数就是在这张表里线性查找:

// probe-rs-debug/src/unit_info.rs,get_function_dies
        // TODO: assuming the ranges don't overlap, binary-search for the function DIE containing the address
        let Some((_, start_offset)) = self
            .function_dies
            .iter()
            .find(|(range, _)| range.contains(&address))
            .cloned()
        else {
            return Ok(vec![]);
        };

(unit_info.rs:121)。DebugInfo 一层的 get_function_dies 再在所有单元里依次找,第一个非空的结果就返回。两层都是线性的:单元数乘以函数数。源码里的 TODO 说了可以排序后二分,0.32 还没做。

找到外层函数后,find_inlined_functions 从它的 DIE 往下找所有包含这个地址的 DW_TAG_inlined_subroutine,一层套一层。返回的向量第一个是真正的(非内联)函数,最后一个是最内层的内联函数。一个 PC 因此可能对应好几个「函数」:

#0  pc=0x080008dc inl cfa=0x200bff80 __nop  ./asm/lib.rs:48
#1  pc=0x080008dc     cfa=0x200bff80 __nop  ./asm/lib.rs:51

这是 STM32U585 夹具展开结果的前两帧:cortex_m::asm::nop 里的 __nop 内联进了一个同名的外层函数,同一个 PC 出了两帧,第一帧标着 inl。展开时这些帧怎么排、每帧的 PC 和源码位置取什么,在下一页讲。

地址到源码行

get_source_location 自己走行表,没有用 addr2line:

  1. 依次看每个单元,用 unit_ranges 判断地址是否落在这个单元里;
  2. 取单元的行号程序,拆成若干「序列」(一段地址单调递增的指令),找到包含该地址的那一个;
  3. 从序列开头一行行往下走:遇到地址正好相等的行,返回它;遇到第一个地址比目标大的行,返回上一行。

第 3 步的注释说明了为什么取上一行:否则从函数里单步出来时,调试器会跳到文件开头。返回的 SourceLocation 带文件路径、行、列和这一行的起始地址。

在 STM32U585 夹具上查几个地址(自写的程序调 get_source_location):

0x080008a8 -> <rustup>/.../library/core/src/ptr/mod.rs:Some(2095):Some(Column(9)) (row address Some("0x80008a8"))
0x080008aa -> <rustup>/.../library/core/src/ptr/mod.rs:Some(2095):Some(Column(9)) (row address Some("0x80008a8"))
0x080008ac -> <项目>/src/bin/nested_exceptions.rs:Some(51):Some(Column(14)) (row address Some("0x80008ac"))
0x0800086a -> <项目>/src/bin/nested_exceptions.rs:Some(87):Some(Column(5)) (row address Some("0x8000868"))

0x080008a8 在 level_c 里,但行表给的是标准库 ptr/mod.rs 的第 2095 行:read_volatile 内联进来了,行表只记最内层的位置。0x080008aa 没有自己的行,落到上一行。所以「这个地址在哪一行」有两种答案:最内层的源码位置,和外层函数调用内联函数的那一行。展开时外层帧用后者(DWARF 里内联 DIE 的 DW_AT_call_file / DW_AT_call_line),下一页会看到 level_c 那一帧显示的是 nested_exceptions.rs:52。

反方向(给一个文件和行号,找能下断点的地址)在 source_instructions.rs 里,它还要排除函数序言和尾声,变量与单步一页讲。

类型和位置

变量展开(变量与单步)的核心是两个函数:

  • process_tree 遍历一个 DIE 的子节点,按标签分派:DW_TAG_namespace 变成一个命名空间节点(空的会被删掉);DW_TAG_formal_parameter、DW_TAG_variable、DW_TAG_member 变成一个变量,再递归它的类型;DW_TAG_variant_part 只保留判别值匹配的那个变体(Rust 枚举就是这样表示的);遇到没实现的标签记一条「Unimplemented」。
  • process_memory_location 调 extract_location 算出变量在哪,再按结果填变量的位置或值。

extract_location 看三种属性:DW_AT_location、DW_AT_frame_base、DW_AT_data_member_location。属性值是一个常数时,就是「父变量地址 + 偏移」(结构体成员的常见情况);是一个位置列表的引用时,按当前 PC 选出那一段的表达式;是一段表达式时,交给 gimli 求值。gimli 的求值器是一个可以暂停的状态机,缺什么数据就停下来要,expression_to_piece 逐个喂给它:

// probe-rs-debug/src/unit_info.rs,expression_to_piece
        loop {
            result = match result {
                EvaluationResult::Complete => return Ok(evaluation.result()),
                EvaluationResult::RequiresMemory { address, size, .. } => {
                    read_memory(size, memory, address, &mut evaluation)?
                }
                EvaluationResult::RequiresFrameBase => {
                    provide_frame_base(frame_info.frame_base, &mut evaluation)?
                }
                EvaluationResult::RequiresRegister {
                    register,
                    base_type,
                } => provide_register(frame_info.registers, register, base_type, &mut evaluation)?,
                // ...
                EvaluationResult::RequiresCallFrameCfa => {
                    provide_cfa(frame_info.canonical_frame_address, &mut evaluation)?
                }
                unimplemented_expression => {
                    return Err(DebugError::WarnAndContinue { /* ... */ });
                }
            }
        }

要内存就通过 MemoryInterface 去读目标(或 coredump),要寄存器、帧基址、CFA 就从这一帧的 StackFrameInfo 里取。所以变量必须和某一帧绑在一起求值:同一个局部变量,在不同的帧里寄存器值不同,位置也就不同。帧基址和 CFA 是展开时算好存进 StackFrame 的。

求值结果分几种,evaluate_expression 把它们翻译成 VariableLocation:

gimli 的结果probe-rs 的处理
Location::Address { address }变量在内存里,VariableLocation::Address;地址为 0 时报「may have been optimized out」
Location::Register变量在寄存器里,取这一帧的寄存器值
Location::Value值本身就编码在表达式里(常量传播),直接当作值
Location::Empty优化掉了,显示 <value optimized away by compiler, out of scope, or dropped>
多于一个 piece显示 <unsupported memory implementation>:一个变量被拆到几个寄存器或几段内存里的情况(DW_OP_piece)还不支持

最后一行在优化过的代码里常见,这也是 probe-rs 的报错里反复建议「reduce optimization levels」的原因。表达式里出现没实现的操作时不报硬错误,只把这个变量标成不可用,其余变量照常显示。

语言

UnitInfo 按 DW_AT_language 选一个 ProgrammingLanguage 实现:C89 / C99 / C11 / C17 用 C,Rust 用 Rust,其他用 UnknownLanguage。它管的是「怎么显示」:基本类型怎么读、数组和指针的类型名怎么拼、哪些结构体要自动展开。Rust 的规则在变量与单步里讲。同一个 ELF 里 Rust 和 C 的单元可以混在一起(比如链进来的 C 库),每个变量按它所在单元的语言处理。

小结

  • DebugInfo::from_raw 一次把 ELF 的 DWARF 节读进内存;调用帧信息只来自 .debug_frame,不读 .eh_frame,四个夹具 ELF 都没有 .eh_frame。from_file 额外建一个 addr2line 的加载器,只用来在没有 DWARF 函数信息时按符号表给函数起名。
  • 每个编译单元一个 UnitInfo,建好「地址范围 → 函数 DIE」和「DIE → 父 DIE」两张表;按地址找函数在单元和函数两层上都是线性查找。找到外层函数后再往下找内联函数,同一个 PC 可以对应多帧。
  • 地址到源码行自己走行表,落在两行之间时取上一行;行表只记最内层内联函数的位置,外层帧显示的是调用处。
  • 变量位置由 gimli 求值,probe-rs 在它暂停时补内存、寄存器、帧基址和 CFA,所以变量总是相对某一帧求值;多 piece 的位置和没实现的操作只让这个变量不可用。

💬 关联讨论

发起讨论
加载中...