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_usagefault | 68 360 字节 | 无 | 600 320 字节 |
RP2040_full_unwind | 79 468 字节 | 无 | 778 217 字节 |
stm32u585_nested_exceptions | 20 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:
- 依次看每个单元,用
unit_ranges判断地址是否落在这个单元里; - 取单元的行号程序,拆成若干「序列」(一段地址单调递增的指令),找到包含该地址的那一个;
- 从序列开头一行行往下走:遇到地址正好相等的行,返回它;遇到第一个地址比目标大的行,返回上一行。
第 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 的位置和没实现的操作只让这个变量不可用。