Rust in Everything
基于 probe-rs-debug 0.32.0

变量与单步

主线位置:调试主线后半段的末端。栈展开给出一列帧,每帧带一个空的变量缓存;VS Code 展开「Locals」时,这个缓存才按 DWARF 一层层填起来。单步则反过来:从当前 PC 出发,用行表找下一个该停的地址,再让核跑到那里。本页先讲变量,再讲单步。

stateDiagram-v2
  Deferred: 延迟:TypeOffset、DirectLookup、UnitsLookup
  Expanded: 已展开:子节点在缓存里
  Leaf: 不再展开:DoNotRecurse
  [*] --> Deferred: unwind 时只建根节点
  Deferred --> Expanded: cache_deferred_variables
  Expanded --> Deferred: 子节点是结构体或指针
  Expanded --> Leaf: 基本类型或出错
  Expanded --> Expanded: RecurseToBaseType 的子节点立即展开

VariableCache:一棵平铺的树

VariableCache 只有两个字段:

// probe-rs-debug/src/variable_cache.rs
pub struct VariableCache {
    root_variable_key: ObjectRef,

    variable_hash_map: BTreeMap<ObjectRef, Variable>,
}

树是平铺在一张 BTreeMap 里的:每个 Variable 记着自己的 parent_key,get_children 就是在整张表里按 parent_key 过滤。键 ObjectRef 来自一个全局的原子计数器(get_object_reference),它和 StackFrame::id 共用一个计数器,这样在整个进程里唯一。这个数直接就是 DAP 协议里的 variablesReference:VS Code 点开一个变量时发回这个数,服务端按它找到变量、展开它的子节点(DAP server 与 VS Code)。

一个帧上有三种缓存:

缓存根节点什么时候建
局部变量LocalScopeRoot,类型 DirectLookup(单元, 函数 DIE)(new_dwarf_cache)展开每帧时,只有根节点
静态变量StaticScopeRoot,类型 UnitsLookup(create_static_scope_cache)调用方需要时;DAP 服务器每个核建一份
寄存器RegistersRootDAP 服务器自己建

什么时候展开

展开的入口是 DebugInfo::cache_deferred_variables:给一个父变量,如果它还没有子节点,就按它的 variable_node_type 找到对应的 DIE,交给 UnitInfo::process_tree(DebugInfo 与 DWARF)。UnitsLookup 要遍历所有编译单元。process_tree 给每个子 DIE 建一个变量,再决定这个子变量要不要立刻往下展开。规则写在 VariableNodeType 的文档里:

类型何时用展开时机
DirectLookup帧的局部变量根、寄存器根延迟
UnitsLookup静态变量根延迟
TypeOffset结构体延迟,等用户点开
RecurseToBaseType(默认)基本类型、枚举、数组、联合、指向 const 的指针,以及语言规则说要自动展开的结构体立即,一直到基本类型
DoNotRecurse指向 () 的指针、处理出错的节点不再展开

一次展开多层用 recurse_deferred_variables,它从根开始对每个节点调 cache_deferred_variables,直到给定的深度。要注意它吞掉错误:

// probe-rs-debug/src/variable_cache.rs,recurse_deferred_variables_internal
        if debug_info
            .cache_deferred_variables(self, memory, parent_variable, frame_info)
            .is_err()
        {
            return;
        };

某个节点展开失败,只是这棵子树停在那里,调用方看不到错误。仓库的测试 full_unwind 和 static_variables 就是对每帧的局部变量和全部静态变量各调一次深度 10 的 recurse_deferred_variables,再把整棵树序列化成 YAML 快照(debug_info.rs:2036)。

实验:RP2040 夹具里的变量

RP2040 夹具第 6 帧是 setup_data_types,这个测试函数故意声明了各种类型的局部变量。实验程序展开调用栈后,对这一帧的局部变量缓存调 recurse_deferred_variables,深度从 1 加到 10:

深度缓存里的变量数耗时
展开前1(只有根)—
1991.8 ms
23667.0 ms
33789.5 ms
540413.6 ms
1045424.1 ms

(耗时是实验程序的 debug 构建在本机上的数,只看量级。)深度 1 时 99 个:顶层 42 个变量,加上它们中立即展开的子节点。节选(数组的多行输出合成了一行):

int8_minus_twenty_three: i8 = -23   @0x200033F4
local_reference_to_global_static: &str = A 'global' static variable   @0x20003CBC
  data_ptr: *raw u8 = *raw u8 @ 0x20003CBC   @0x20003CBC
    *data_ptr: u8 = 65   @0x1000C2E8
  length: usize = 26   @0x20003CC0
int128: i128 = -196710231994021419720322   @0x20003410
float64: f64 = 1.7608695652173911   @0x20003438
emoji: char = 💩   @0x20003424
my_array_of_i8: [i8; 10] = [i8; 10] = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 0]   @0x20003C24
three_level_recursive_struct: RecursiveStruct = RecursiveStruct @ 0x20003A8C   @0x20003A8C

每行是「名字: 类型 = 值 @位置」。&str 和数组在深度 1 就已经展开到底(RecurseToBaseType),结构体 three_level_recursive_struct 只有一行,类型是 TypeOffset,没有子节点。深度 2 时它展开了一层:

three_level_recursive_struct: RecursiveStruct = RecursiveStruct @ 0x20003A8C
  depth: u32 = 1   @0x20003A8C
  next_self: Option<&mut probe_rs_debugger_test::RecursiveStruct> = ...   @0x20003A90
    Some: Some = Some @ 0x20003A90
      __0: &mut probe_rs_debugger_test::RecursiveStruct = ...   @0x20003A90
        *__0: RecursiveStruct = RecursiveStruct @ 0x20003A98   @0x20003A98

Option 自动展开到 Some,Some 里的指针也跟着解引用,但解出来的又是一个 RecursiveStruct,再次停下等下一次请求。Result 和 Rust 的数据枚举只显示当前的变体:

function_result: Result<(), &str> = Result<(), &str> @ 0x20003CE4
  Err: Err = Err @ 0x20003CE4
    __0: &str = Forcing the return of an Error variant   @0x20003CE4
a6: Enum<i32> = Enum<i32> @ 0x20003BB8
  Variant2: Variant2 = Variant2 @ 0x20003BB8
    __0: i64 = 7   @0x20003BC0
    __1: i32 = 6   @0x20003BBC

静态变量的量大得多。同一个夹具,静态变量缓存深度 1 展开后是 737 个变量、耗时 0.53 s,深度 10 是 2909 个、1.2 s:它要把所有编译单元(包括 rp2040_pac 的中断向量表、rp_pico 的 256 字节二级引导程序)都走一遍。create_static_scope_cache 的注释说明了静态变量为什么默认不展开:大多数时候用户只看局部变量和寄存器。测试固件自己的那些静态变量在 probe_rs_debugger_test 命名空间下:

probe_rs_debugger_test: namespace =    @<value not available>
  B: bool = false   @0x2000007B
  I: isize = -1   @0x20000000
  C: char = a   @0x20000004
  I8: i8 = 68   @0x20000008
  ...

这些值是从 coredump 里 0x20000000 起的那段 RAM 读出来的。换成 FakeProbe,同样的地址只会读出 0:它按 ELF 的加载地址返回内容,RAM 的运行地址上什么也没有。

Rust 的特殊处理

language/rust.rs 管三件事:

  • 哪些结构体自动展开。auto_resolve_children 对类型名以 &str、&[、Option、Some、Result、Ok、Err 开头的结构体返回真,它们被标成 RecurseToBaseType,于是上面实验里 Option 和 Result 不用点就展开了。
  • 基本类型怎么读。read_variable_value 按类型名分派到 i8 … u128、f32、f64、char、bool 的读取;&str 按 data_ptr 和 length 读出字符串。usize 和 isize 固定按 32 位读,源码里有 TODO 说应该从 DWARF 取字长;在 RV64 目标上这会读错,未核实有没有别的地方补救。
  • 切片。&[T] 的 data_ptr 被改写成指向 [T; len] 的指针,展开后能看到每个元素。

修改变量(DAP 的 setVariable)走同一个文件里的 update_variable,也只支持基本类型和这几种结构。

单步:从行表找停点

单步的入口是 SteppingMode::step,五种模式:

// probe-rs-debug/src/debug_step.rs
pub enum SteppingMode {
    BreakPoint,
    StepInstruction,
    OverStatement,
    IntoStatement,
    OutOfStatement,
}

StepInstruction 直接调 core.step()(Cortex-M 内核讲过它的 DHCSR 序列)。其他三种语句级的单步分两步:先用 get_halt_location 算出目标地址,再用 run_to_address 让核跑过去。

「合法的停点」由 source_instructions.rs 定义:行表里标了 is_stmt 的行(一条语句的开始),而且不在函数序言里。序言结束靠 prologue_end 标记;GCC 编的 C 代码不设这个标记,就用和 GDB 一样的经验规则(同一文件里行号第一次变化)。VerifiedBreakpoint::for_address 找「地址大于等于给定值的第一个合法停点」。三种模式的目标:

模式目标地址
overfor_address(PC + 1):当前序列里的下一个停点;没有就退化成 out
into先算出 over 的目标,然后逐条指令单步,直到 PC 跑出「当前 PC 到下一停点」这个区间(进了被调用的函数)或到达下一停点,再在新位置找停点。注释说明了原因:rustc 生成的 DWARF 没有 DW_TAG_call_site,不知道这一行里有没有调用
out当前函数是内联的:跑到它的 high_pc;不是内联的:返回地址处的第一个停点。函数标了 DW_AT_noreturn(比如 main() -> !)就报错

找不到停点时(PC 落在没有行信息的指令上),step 会先单步一条指令再重算,最多 10 次。

单步:借一个硬件断点

run_to_address 让核跑到目标地址:

// probe-rs-debug/src/debug_step.rs,run_to_address
    let breakpoints = core.hw_breakpoints()?;
    let bp_to_use = breakpoints.iter().position(|bp| bp.is_none()).unwrap_or(0);

    if core.set_hw_breakpoint(bp_to_use, target_address).is_ok() {
        core.run()?;
        // It is possible that we are stepping over long running instructions.
        let status = core.wait_for_core_halted(Duration::from_millis(1000));

        // Restore the original breakpoint.
        // ...

挑一个空闲的比较器;全占满了就借 0 号,跑完再把原来的地址写回去。在目标地址下断点、run、最多等 1 s,等不到就强行 halt,把停在哪里报告给用户(注释说长时间运行的单步「are not currently supported」)。只有写比较器失败时才退回逐条单步(step_to_address)。

用本卷的软件模拟目标(见 Cortex-M 内核)试一下(它模拟 DP、MEM-AP 和 Cortex-M 调试寄存器,不执行指令;本实验给它加了一条:run 时如果有使能的 FPB 比较器,就把 PC 直接设到那个地址并以断点原因停下)。DWARF 用 STM32U585 夹具的 ELF,把 PC 设成 main 里 level_a() 那一行的 0x08000868,做一次 step over(日志按内存访问整理过,右边是注释):

== over from 0x08000868 (Some(Some(87))), fp units=6
  ...读 PC、LR、PC,各 3 次访问
  R FP_CTRL / R FP_COMPn  × 6 对       hw_breakpoints:找空闲比较器
  W FP_CTRL    0x00000003              全局打开 FPB
  W FP_COMPn   0x4800086d              0 号比较器设在 0x0800086c
  W DHCSR      0xa05f000b / 0xa05f000d run() 先单步一次
  W DHCSR      0xa05f0005 / 0xa05f0001 放行
  ** sim: core ran to FP_COMP0 at 0x0800086c
  W FP_COMPn   0x00000000              还原 0 号比较器
== result Halted(Breakpoint(Unknown)) pc=0x0800086c line=Some(Some(19))

目标地址 0x0800086c 是 BL level_a 之后的那条指令,它属于 main 末尾的 loop { nop() },行表给的是内联进来的 call_asm.rs:19。整个 step over 在模拟目标上是 117 次 DP / AP 访问,其中 58 次是对 DRW 的内存读写;真实探针上的耗时待实测。

把模拟目标的比较器个数设成 0(和 FakeProbe 一样)再做一次,结果是:

== over from 0x08000868 (Some(Some(87))), fp units=0
  W FP_CTRL    0x00000003
  W FP_COMPn   0x4800086d
  ...
  W DHCSR      0xa05f0003
== result Halted(Request) pc=0x0800086c line=Some(Some(19))

hw_breakpoints 返回空列表,unwrap_or(0) 选了 0 号;而 Core 的 set_hw_breakpoint_unit 不检查下标是否超过比较器个数,往一个不存在的比较器地址写成功了。于是不会走逐条单步的退路,而是 run 后等满 1 s、强行 halt(整个程序耗时从 0.19 s 变成 1.19 s),停下的原因是 Request。这里 PC 正好落在目标上,是模拟目标的单步语义造成的巧合,真实内核会停在 1 s 后跑到的任意位置。真实芯片的比较器个数不会是 0,这条路径更可能在「比较器全被用户断点占满」时出现:那时借用的是确实存在的 0 号,行为是对的。

退路 step_to_address 本身也值得留意。比较器写失败时(比如 FPB rev 1 上目标地址在 0x2000_0000 以上,见 Cortex-M 内核),调用的是 step_to_address(target_address..=u64::MAX, core),而它的循环条件是「单步后的 PC 还在这个区间里就继续」。照字面读,从低于目标的地址出发,第一步之后 PC 仍低于目标,循环就结束了,只走一条指令。模拟目标上没能触发这条路径(FPB 版本不受支持时,hw_breakpoints 本身先报错),实际行为未核实。

最后,step 的文档写明了一个限制:单步期间不处理中断,「The user must ensure that interrupts are disabled during stepping, or accept that stepping may be diverted」。指令级单步时 Armv7m::step 会置 C_MASKINTS,但语句级单步用的是断点加 run,run 会清掉 C_MASKINTS,挂起的中断照样会进。

小结

  • VariableCache 是平铺在 BTreeMap 里的一棵树,键是全局唯一的 ObjectRef,也就是 DAP 的 variablesReference。每帧的局部变量、静态变量都只建一个根节点,展开到哪一层由调用方决定;展开出错只截断子树,不报错。
  • 结构体延迟展开,基本类型、枚举、数组、&str、Option、Result 立即展开到底。RP2040 夹具的 setup_data_types 一帧深度 1 是 99 个变量、深度 10 是 454 个;静态变量深度 10 是 2909 个。
  • Rust 的规则在 language/rust.rs:自动展开的类型名前缀、基本类型的读取(usize 固定按 32 位)、切片改写。
  • 语句级单步先用行表(is_stmt、序言之后)找目标地址,再借一个硬件断点 run 过去,最多等 1 s;step into 靠逐条单步发现调用,step out 遇到 noreturn 函数报错。
  • 比较器个数为 0 时 step over 仍往 0 号比较器写,等满 1 s 后强行停下;写比较器失败时的逐条单步退路,照字面读只走一步,未核实。

💬 关联讨论

发起讨论
加载中...