变量与单步
主线位置:调试主线后半段的末端。栈展开给出一列帧,每帧带一个空的变量缓存;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 服务器每个核建一份 |
| 寄存器 | RegistersRoot | DAP 服务器自己建 |
什么时候展开
展开的入口是 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(只有根) | — |
| 1 | 99 | 1.8 ms |
| 2 | 366 | 7.0 ms |
| 3 | 378 | 9.5 ms |
| 5 | 404 | 13.6 ms |
| 10 | 454 | 24.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 找「地址大于等于给定值的第一个合法停点」。三种模式的目标:
| 模式 | 目标地址 |
|---|---|
| over | for_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 后强行停下;写比较器失败时的逐条单步退路,照字面读只走一步,未核实。