DAP server 与 VS Code
主线位置:调试主线的终点。核停下之后,08 调试信息讲的栈展开、变量、单步都要有人来调用;在 VS Code 里,调用者就是
probe-rs dap-server。本页沿一个调试会话的生命周期,看每个 DAP 请求落到哪一行库代码。
sequenceDiagram
participant V as VS Code
participant D as dap-server
participant G as probe-rs-debug
participant T as 目标
V->>D: initialize
D-->>V: capabilities
V->>D: launch(chip、programBinary、svdFile)
D->>T: attach、烧录、halt、reset_and_halt
D-->>V: initialized
V->>D: setBreakpoints(源码行)
D->>G: get_breakpoint_location
D->>T: set_hw_breakpoint
V->>D: configurationDone
D->>T: run
loop 每 50 ms
D->>T: status、读 RTT
end
T-->>D: 停在断点
D->>G: unwind(最多 500 帧)
D-->>V: stopped
V->>D: stackTrace、scopes、variables
D->>G: 读缓存的帧、延迟展开变量
cmd/dap_server/ 一共 11 214 行 .rs(另有 4 617 行的 debugProtocol.json,是 DAP 规范的 JSON Schema,dap_types.rs 从它生成类型)。它和 gdb server 的根本区别是:栈展开、变量解析都在服务端用 probe-rs-debug 做,VS Code 只负责显示。VS Code 一侧的 launch.json 怎么写,见 VS Code 调试配置。
入口:TCP 或 stdio
Cmd 只有三个参数:
| 参数 | 含义 |
|---|---|
--port | 给了就在 --ip(默认 127.0.0.1)上监听 TCP;不给就走 stdin / stdout(0.32 新增,#3926) |
--ip | 监听地址 |
--single-session(别名 --vscode) | 一个会话结束就退出;不加时 TCP 模式会继续等下一个客户端。stdio 模式隐含单会话 |
两种传输最后都变成同一个 DapAdapter<R, W>,差别只在读端:
debug_tcp:accept之后把 socket 设成非阻塞,try_clone出读写两半。每个会话新建一个Registry::from_builtin_families()。debug_stdio:stdin 没法设非阻塞,于是起一个线程阻塞地读 stdin,按块塞进mpsc通道;ChannelReader在通道空时返回WouldBlock,假装自己是一个非阻塞 socket。
非阻塞是整个设计的前提:服务端在等 VS Code 下一条请求的同时,要不停地查核的状态、读 RTT。DAP 的帧格式是 Content-Length: N\r\n\r\n 加 N 字节 JSON,解析在 codec/decoder.rs。
DAP server 也不走 RPC:launch 里的 SessionData::new 直接调 attach_probe 和 attach_session。它自己的「远程」是另一回事:launch.json 里设 remote_server_mode 时,客户端把 ELF、SVD、芯片描述用 base64 内联在请求里发过来,服务端落到会话专用的临时目录(uploaded_files.rs),源码路径原样交回客户端解析。
会话的三个阶段
run_debug_session 按 DAP 规定的启动顺序分三段:
- initialize:
handle_initialize回一份Capabilities。打开的有configurationDone、restart、readMemory/writeMemory、setVariable、disassemble、指令断点、按指令粒度单步、completions;注释掉的有函数断点、异常选项(「TODO: Use DEMCR register to implement exception breakpoints」),没有 data breakpoint,也就是没有 watchpoint。客户端要求行号、列号从 0 开始时直接报错。 - launch / attach:
handle_launch_attach解析参数、建SessionData(core_configs必须恰好一个,否则报「requires that one, and only one, core be configured」)、按需烧录、attach 到核并立刻 halt、加载 SVD;launch还要reset_and_halt一次。然后发initialized事件。 - 请求循环:反复调
process_next_request,直到disconnect或出错。
请求循环与轮询节奏
process_next_request 每次只做一件事:有请求就处理请求,没有就轮询。两条路都先调 poll_cores。
没有请求时,no_request_poll 决定下一轮之前睡多久:
| 情况 | 睡眠 |
|---|---|
| 所有核都停着 | 100 ms |
| 核在跑,上一轮没读到 RTT 数据 | 50 ms |
核在跑,上一轮读到了 RTT 数据,或还没 configurationDone | 0 |
对比命令行 run 的 100 ms(一条 probe-rs run),DAP server 在核运行时轮询得快一倍。每一轮 poll_cores 至少读一次 DHCSR;在 CMSIS-DAP 上这些读各是多少次 USB 往返、占多少带宽,待实测。
有请求时,handle_request 先固定取 0 号核配置(「TODO: Currently, we only use poll_cores() results from the first core」)。如果核在睡眠(WFI),而请求是 stackTrace、variables、readMemory 这类要读目标的,就先 halt,处理完再 run 回去;注释提醒这会把目标从睡眠里叫醒。
命令表
dispatch_request 是一个按字符串匹配的 match,21 个分支,其余一律回「not supported or not implemented yet」:
| DAP 请求 | DebugAdapter 方法 | 最后落到 |
|---|---|---|
setBreakpoints | set_breakpoints | 先清掉这个源文件的所有断点,再逐个 DebugInfo::get_breakpoint_location(行表找地址,见 DebugInfo 与 DWARF)→ Core::set_hw_breakpoint |
setInstructionBreakpoints | set_instruction_breakpoints | 直接按地址设硬件断点 |
configurationDone | configuration_done | 核停在断点上或设了 haltAfterReset 就发 stopped,否则 run |
continue / pause | r#continue / pause | Core::run / Core::halt |
next / stepIn / stepOut | next 等 | SteppingMode::OverStatement / IntoStatement / OutOfStatement;客户端要求指令粒度时是 StepInstruction;都进 SteppingMode::step,见变量与单步 |
stackTrace | stack_trace | 不展开,只从 core_data.stack_frames 里切片;帧是停下时 poll_cores 算好的 |
scopes | scopes | 拼出 Peripherals(有 SVD 时)、Static、Registers、Variables 四个作用域 |
variables | variables | 先查 SVD 缓存,再查各帧的 VariableCache;子节点还没展开就调 cache_deferred_variables 读目标内存 |
evaluate / completions | evaluate | 监视表达式按变量名查缓存;调试控制台里的输入当作 REPL 命令 |
setVariable / readMemory / writeMemory | 同名 | MemoryInterface 读写 |
disassemble | disassemble | 读目标内存,用 capstone 反汇编 |
threads | threads | 一个核就是一个线程 |
restart | 先 halt,返回 Restart | 外层 restart:ELF 比上次新就重新加载调试信息、重算断点、重新烧录 |
disconnect | disconnect | 结束会话 |
rttWindowOpened | 内联 | 记下 VS Code 是否打开了某个 RTT 通道的窗口 |
REPL 命令是 repl_commands/ 下的一张表,模仿 GDB 的语法:bt、break、clear、c、step、reset、info、frame、locals、reg、var、p、x、dump、wreg、rtt、write、test 等。
setBreakpoints 有一个 DAP 带来的约束:协议只给「这个文件现在应该有哪些断点」的全集,不给增删,所以 probe-rs 每次先清掉该文件的所有断点再重设;FPB 比较器只有几个,一次设不下的断点在响应里标 verified: false,message 是错误原文。比较器的实际个数待实测(登记在硬件待测清单的 HW-4)。
停下的那一刻:全量展开 500 帧
栈帧不是在 stackTrace 请求时算的,而是在 poll_cores 发现「这一轮停着、上一轮还在跑」时算的:
// probe-rs-tools/src/bin/probe-rs/cmd/dap_server/server/session_data.rs
let initial_registers = DebugRegisters::from_core(&mut target_core.core);
let exception_interface = exception_handler_for_core(target_core.core.core_type());
let instruction_set = target_core.core.instruction_set().ok();
if target_core.core_data.static_variables.is_none() {
target_core.core_data.static_variables =
Some(debug_info.create_static_scope_cache());
}
target_core.core_data.stack_frames = debug_info.unwind(
&mut target_core.core,
initial_registers,
exception_interface.as_ref(),
instruction_set,
500, // TODO: we should be able to unwind incrementally as the user requests more frames on the UI
)?;
几个要点:
- 每次从运行到停下都全量展开,上限 500 帧。
DebugRegisters::from_core先把整组核心寄存器读一遍(每个寄存器 3 次内存访问,见 Cortex-M 内核),然后unwind沿 CFI 一帧帧往上读栈内存(栈展开)。源码里的 TODO 承认这应该按 UI 的需要增量展开。 - 单步也算一次停下。
step_impl开头reset_core_status把all_cores_halted置为 false,下一轮poll_cores看到「停着、上一轮没停」,于是每按一次 F10 都重新展开整个调用栈。 stackTrace支持分页(supportsDelayedStackTraceLoading,PAGE_SIZE50),但分页只是对已经算好的数组切片,省掉的是 JSON 的体积,不是目标上的读。- 局部变量也不是一次读完:
unwind给每帧建VariableCache,复合类型的子节点标成延迟,等variables请求展开时才读(变量与单步)。静态变量的根只建一次。
一次停下到底读多少字节、在 CMSIS-DAP 上花多少毫秒,取决于调用深度和探针,待实测;同样的展开代码在三个 coredump 夹具上的结果见栈展开。
停下的通知走 poll_core:状态和上次一样就什么都不发;变成运行发 continued;停下发 stopped。两个例外:HaltReason::Step 不在这里发(step_impl 自己发了,因为单步是用临时断点实现的,核报的原因是断点,step_impl 把它改写成 Step);semihosting 停下也不发,由 handle_semihosting 处理完直接让核继续跑(defmt 与 semihosting)。
SVD 外设视图
launch.json 给了 svdFile 时,attach 后 SvdCache::new 用 svd-parser 解析整个文件(expand 展开数组和派生外设,ignore_enums 不要枚举值),建一棵外设 → 寄存器 → 位域的树,在 scopes 里作为 Peripherals 出现,expensive: true,VS Code 默认不展开。
树只建一次,值每次都现读:get_value 按寄存器宽度调 read_word_8 / 16 / 32。读外设寄存器可能有副作用(读清标志位、弹出 FIFO),所以 restricted_read 在寄存器或位域带 readAction、或者 access 不可读时置位,界面上显示「cannot be read without side effects」而不去读。一个位域受限,整个寄存器都受限。0.32 修过一处(#4123):没有 <access> 标签的寄存器以前被当成受限,STM32H5 这类省略 <access> 的 SVD 于是什么都看不到;现在先把设备级默认属性下推,下推后仍没有 access 的才当作可读。setVariable 对 SVD 变量有一个 TODO:「prevent SVD Peripheral/Register/Field values from being updated, until such time as we can do it safely」。
小结
dap-server有 TCP 和 stdio 两种传输,stdio 用一个读线程加通道伪装成非阻塞;不走 RPC,直接建Session;只能调试一个核。- 请求循环每轮先
poll_cores:核在跑时 50 ms 一轮(有 RTT 数据时不睡),全停时 100 ms;dispatch_request是 21 个分支的字符串match。 - 栈帧在停下的那一刻全量算好(最多 500 帧),
stackTrace只切片;每次单步也会重新全量展开;变量的子节点延迟到variables请求时才读。 - 断点全是硬件断点:源码行经行表换成地址,
setBreakpoints每次先清后设;没有 watchpoint 和异常断点。 - SVD 视图是一棵只建一次的变量树,值每次现读,有读副作用的寄存器不读。