Rust in Everything
基于 probe-rs-tools 0.32.0

一条 probe-rs run 从命令到日志

主线位置:命令主线全程。从 cargo run 调起 probe-rs run --chip ... fw.elf,到终端上出现第一行日志。每一步只讲到能看清全貌的深度,细节在后面各部分。

sequenceDiagram
  participant C as CLI 客户端
  participant S as RPC 服务端
  participant P as 探针
  participant T as 目标芯片
  C->>C: 注册插件 解析参数
  C->>S: probe/select
  S->>P: Lister 枚举 USB
  C->>S: probe/attach
  S->>P: open 选协议 attach
  P->>T: 线复位 读 DPIDR 上电
  S->>T: 调试序列 使能 C_DEBUGEN
  C->>S: flash/build 和 flash/flash
  S->>T: flash 算法 擦除 编程
  C->>S: monitor
  loop 每 100 ms
    S->>T: 读 DHCSR 读 RTT 缓冲区
    S-->>C: RttEvent 原始字节
    C->>C: defmt 解码 打印
  end

cargo runner

README 推荐的配置是在 .cargo/config.toml 里写:

[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "probe-rs run --chip nRF52840_xxAA"

cargo 编译完,会把 ELF 的路径追加在 runner 后面执行,于是 cargo run 等于 probe-rs run --chip nRF52840_xxAA target/thumbv7em-none-eabihf/debug/app。如果固件依赖了 probe-rs-meta 并用 probe_rs_meta::chip!("...") 把芯片名写进了 ELF,--chip 可以省掉;两者都给而且不一致时,attach_probe 直接报 elf_chip does not match probe_chip。

第一步:main 和本地 RPC

main 做四件事:注册 ESP 和 Linux 插件(见源码地图);读配置文件 .probe-rs.toml 等;检查自己是不是以 cargo-flash / cargo-embed 的名字被调用;解析命令行,然后把真正的工作交给 run_app。

run_app 是理解整个命令行的关键:

// probe-rs-tools/src/bin/probe-rs/main.rs
async fn run_app<R>(
    _connection_params: Option<(String, Option<String>)>,
    cb: impl AsyncFnOnce(RpcClient) -> Result<R>,
) -> Result<R> {
    #[cfg(feature = "remote")]
    if let Some((host, token)) = _connection_params {
        // Run the command remotely.
        let client = rpc::client::connect(&host, token).await?;

        return cb(client).await;
    }

    // Create a local server to run commands against.
    let (mut local_server, tx, rx) = RpcApp::create_server(16, rpc::functions::ProbeAccess::All);
    let handle = tokio::spawn(async move { local_server.run().await });

    // Run the command locally.
    let client = RpcClient::new_local_from_wire(tx, rx);
    let result = cb(client).await;
    // ...

即使探针就插在本机上,命令也不直接调库,而是在同一个进程里起一个 RPC 服务端(一个 tokio 任务),客户端和服务端之间用两条内存通道传 postcard 编码的消息。开了 remote feature 时,同一个客户端改连 websocket 或 unix socket 上的远端 probe-rs serve,命令的代码一行不改。服务端有哪些「远程调用」,全写在 rpc/functions.rs 的 endpoints! 表里:probe/select、probe/attach、flash/build、flash/flash、monitor、memory/read32 等 30 来个。这层 RPC 的设计在 CLI 的 RPC 架构,取舍在设计取舍。

第二步:选探针

Cmd::run 先看 ELF 里有没有 .embedded_test 段(有就进测试模式),读 ELF 元数据,然后调 cli::attach_probe。它第一件事是选探针:

// probe-rs-tools/src/bin/probe-rs/util/cli.rs
    let probe = match select_probe(client, probe_options.probe.map(Into::into)).await {
        Ok(probe) => probe,
        Err(error) => {
            print_setup_hints_if_relevant(client).await;
            return Err(error);
        }
    };

select_probe 发一个 probe/select 请求。服务端的 select_probe 用 Lister 让每个注册的驱动枚举自己的 USB 设备(见 DebugProbe 与驱动注册表):零个就返回 NoProbesFound,一个就选它,多个就把列表交回客户端,让用户在终端里输一个序号。

第三步:attach

选好探针后,客户端发 probe/attach。服务端的 attach 打开探针、按 --protocol、--speed 配置,再调 Probe::attach_with_registry,最后把 Session 存进服务端的上下文,只把一个 key 交回客户端;之后客户端每次操作目标,都拿这个 key 发请求。

attach_with_registry 直接进 Session::new(session.rs:180):按芯片名从注册表里找到目标描述;ARM 目标走 attach_arm_debug_interface,依次做线复位、读 DPIDR、给调试端口上电、跑芯片的 debug_device_unlock 序列、给每个核写 C_DEBUGEN;最后清掉所有硬件断点。这一串在调试序列与 vendor里用模拟目标逐条记录,一共 142 次 DP / AP 访问。

第四步:烧录

cli::flash 先发 flash/build 让服务端读 ELF、建 FlashLoader,再发 flash/flash。服务端在目标的 RAM 里装入 CMSIS flash 算法,擦除、编程、可选地校验;进度通过 flash/progress 这个 topic 推回客户端画进度条。在 cli::flash 之前,Cmd::run 已经建好了 RTT 客户端(它要先从 ELF 里找 _SEGGER_RTT 的地址),烧录时把它一并交给服务端:如果控制块所在的地址本身就在这次要下载的数据里(固件直接下载到 RAM 运行),就不能在启动前清掉控制块(configure_from_loader)。细节在 06 烧录、07 RTT。

第五步:轮询

最后是 cli::monitor,它发一个 monitor 请求。服务端把它放进一个单独的任务,跑 RunLoop::run_until:先按参数打开 vector catch(默认在 HardFault 和复位时停下),让核跑起来,然后循环调 poll_once:

// probe-rs-tools/src/bin/probe-rs/rpc/utils/run_loop.rs
        let mut next_poll = Duration::from_millis(100);

        // check for halt first, poll rtt after.
        // this is important so we do one last poll after halt, so we flush all messages
        // the core printed before halting, such as a panic message.
        let return_reason = match core.status()? {
            probe_rs::CoreStatus::Halted(reason) => match predicate(reason, &mut core) {
                Ok(Some(r)) => Some(Ok(ReturnReason::Predicate(r))),
                Err(e) => Some(Err(e)),
                Ok(None) => {
                    // Re-poll immediately if the core was halted, to speed up reading strings
                    // from semihosting. The core is not expected to be halted for other reasons.
                    next_poll = Duration::ZERO;
                    core.run()?;
                    None
                }
            },
            // ...
        };

        let poller_result = poller.poll(&mut core);

每一轮先读一次 DHCSR 看核停了没有。停了就交给 predicate:semihosting 调用在这里被处理掉,然后马上让核继续跑;真正的异常(比如 HardFault)让循环结束、打印调用栈。没停,就让 RttPoller 读一遍所有 up channel(monitor.rs:265):读到数据就把下次轮询的间隔设成 0,连续读空;读不到就是 100 ms。do_run_until 里的注释说明了为什么不轮询得更勤:「If the polling frequency is too high, the USB connection to the probe can become unstable.」

读到的 RTT 数据是原始字节,以 RttEvent::Output 的形式推回客户端;defmt 帧的解码在客户端的 handle_monitor_event 里做,用的是本机那份 ELF 里的 defmt 字符串表。所以远程模式下,目标端的 probe-rs 不需要 ELF 的调试信息就能转发日志。

实验:没有探针时,命令停在哪一步

本机没有探针。list 正常返回:

$ probe-rs list
No debug probes were found.

run、download、info 都有 --dry-run 参数,但加上它也一样失败,即使传了一个真实的 ELF:

$ probe-rs run --dry-run --chip STM32F103C8 <项目>/fixtures/nRF52833_xxAA_hardfault_from_usagefault.elf
Error: No connected probes were found.
$ probe-rs download --dry-run --chip STM32F103C8 <项目>/fixtures/nRF52833_xxAA_hardfault_from_usagefault.elf
Error: No connected probes were found.

原因在第二步和第三步的顺序上。--dry-run 的处理在服务端的 attach_probe:

// probe-rs-tools/src/bin/probe-rs/util/common_options.rs
    pub fn attach_probe(&self, lister: &Lister) -> Result<Probe, OperationError> {
        let mut probe = if self.0.dry_run {
            Probe::from_specific_probe(Box::new(FakeProbe::with_mocked_core()))
        } else {

但走 RPC 的命令在发 probe/attach 之前,先在客户端发了 probe/select,这一步不看 dry_run,列表为空就返回 NoProbesFound,显示成「No connected probes were found.」。probe-rs gdb 不走 RPC:Cli::run 只借用客户端里的目标注册表,然后直接调 simple_attach,后者直接调上面的 attach_probe,所以 --dry-run 真的换上了 FakeProbe:

$ probe-rs gdb --dry-run --chip STM32F103C8 --gdb-connection-string 127.0.0.1:13581
Read 0xe0042004 = 0
Write 0xe0042004 = 0x00000007
Read  DHCSR: 0xe000edf0 = 0x10000
Write DHCSR = 0xa05f0001
...
Firing up GDB stub for Armv7m cores at [127.0.0.1:13581]

这些 Read / Write 行是 FakeProbe 的模拟核打印的,前两行是 STM32 的调试序列在写 DBGMCU_CR,后面是给核使能调试。本卷后面会反复用这个办法,配合 lldb 观察每条调试命令变成了哪些寄存器读写;FakeProbe 能做什么、做不了什么见 FakeProbe。「--dry-run 对 run 不起作用」在常见坑里作为一个坑再讲。

小结

  • probe-rs run 是 cargo runner:cargo 把 ELF 路径追加在 runner 命令后面;芯片名可以来自 --chip,也可以来自固件用 probe-rs-meta 写进 ELF 的段。
  • 命令行在本进程里起一个 postcard-rpc 服务端,本地和远程用同一套客户端代码;Session 留在服务端,客户端只拿到一个 key。
  • 顺序是:probe/select → probe/attach(Session::new)→ flash/build、flash/flash → monitor;轮询每 100 ms 一次,先查 halt 再读 RTT,有数据时连续读;defmt 在客户端解码。
  • --dry-run 只在 gdb 上有效:run、download、info 在 RPC 的 probe/select 这一步就因为没有探针失败了。

💬 关联讨论

发起讨论
加载中...