CLI 的 RPC 架构
主线位置:命令主线的骨架。一条 probe-rs run 讲过
run_app在本进程里起一个 RPC 服务端、命令通过它操作目标;本页拆开这层 RPC:协议怎么定义、Session放在哪、长时间的操作怎么一边跑一边往回推数据,以及开了remotefeature 之后同一套东西怎么跨机器。
flowchart TB
subgraph client["客户端:命令的代码"]
cmd["Cmd::run"] --> rc["RpcClient:postcard-rpc HostClient"]
end
subgraph server["服务端:RpcApp"]
disp["define_dispatch:31 个 endpoint"] --> ctx["RpcContext:ConnectionState"]
ctx --> store["ObjectStorage:Session、FlashLoader、RttClient"]
disp --> blk["spawn_blocking:调同步的 probe-rs 库"]
blk --> pub["topic:flash/progress、rtt、semihosting、info/data"]
end
rc -->|"本地:两条 tokio mpsc"| disp
rc -->|"remote:websocket 或 unix socket"| disp
pub --> rc
rpc/ 一共 6 373 行。远程服务端在 cmd/serve.rs(340 行),只在 remote feature 下编译;本机安装的 0.32.0 没开这个 feature(源码地图),probe-rs --help 里既没有 serve 子命令也没有 --host / --token 参数,所以本页的远程部分只读源码。
协议:endpoint 和 topic
协议用的是 postcard-rpc 0.12:请求和响应都是 postcard 编码的 Rust 类型,按「路径」区分,类型带 postcard_schema::Schema,两端编译期就对得上。全部定义在 rpc/functions.rs 的三张表里:
| 表 | 内容 |
|---|---|
endpoints!(459–501) | 31 个请求 / 响应对:probe/list、probe/select、probe/attach;resume、create_rtt、rtt/down、stack_trace;flash/build、flash/flash、flash/erase、flash/verify、monitor;tests/list、tests/run;temp_file/new、temp_file/append;chips/list、chips/info、chips/load;target、info、reset、reset_and_halt;memory/read8 到 read64、memory/write8 到 write64 |
topics!(503–520) | 服务端到客户端 4 个:info/data、flash/progress、rtt、semihosting;客户端到服务端 1 个:cancel |
define_dispatch!(522–583) | 每个 endpoint 的处理函数和执行方式:blocking(只有 probe/list)、async(大多数),spawn(monitor、tests/list、tests/run) |
monitor 和测试是 spawn:处理函数被放进单独的任务,服务端的分发循环不被它占住,于是在 monitor 运行期间,客户端仍然能发 rtt/down(往 down channel 写数据,0.32 新增)和 cancel(Ctrl-C)。
响应统一是 RpcResult<T>,错误只是一个字符串(RpcError(String));客户端 send_resp 把它变回 anyhow 错误。所以 probe/select 返回的 NoProbesFound 到客户端只剩一句话,类型信息在边界上丢掉了。
Session 留在服务端
Session 不能序列化,也不该跨进程移动。服务端的做法是放进一个按类型擦除的对象仓库,只把一个 key 交给客户端(rpc/mod.rs):
Key<T>是一个全局递增的u64加PhantomData<T>,带Schema,可以放进请求里。类型参数只在编译期区分「这是 Session 的 key」和「这是 FlashLoader 的 key」,线上就是一个整数。ObjectStorage是HashMap<u64, Arc<Mutex<dyn Any + Send>>>,取的时候按Tdowncast。- 每条连接一个
ConnectionState:对象仓库、一份服务端自己的Registry、一个取消令牌,以及哪些 Session 是 dry-run 的集合。
probe/attach 的处理函数最后一行就是 ctx.set_session(session, common_options.dry_run()),返回的 Key<Session> 交回客户端(rpc/functions/probe.rs:296)。之后 flash/build 返回 Key<FlashLoader>,create_rtt 返回 RTT 客户端的 key,都是同一个套路。
probe-rs 库是同步的。处理函数要调库时走 run_blocking:
// probe-rs-tools/src/bin/probe-rs/rpc/functions.rs
let token = self.cancellation_token();
let (sender, publisher) = T::create(token);
let ctx = self.clone();
let blocking = tokio::task::spawn_blocking(move || task(ctx, request, sender));
tokio::select! {
_ = publisher.publish(&self.sender) => unreachable!(),
response = blocking => {
response.unwrap()
}
}
同步代码在 tokio 的阻塞线程池里跑,手里拿着一个普通的 mpsc::Sender;另一边 publisher 把发进来的消息作为 topic 推给客户端。烧录进度、RTT 数据、semihosting 输出都是这样一边跑一边推的。Session 在仓库里由 tokio::sync::Mutex 保护,阻塞线程里用 blocking_lock 取。
客户端怎么收:send_and_read_stream 先订阅 topic,再发请求,两者 join,请求返回时通知流结束。monitor 的 RttEvent::Output 就这样到达客户端,defmt 在客户端解码。
本地:两条内存通道
RpcApp::create_server 建两条 tokio mpsc(深度由调用方给,本地是 16),一个 1 MiB 的接收缓冲区,返回「服务端、客户端到服务端的发送端、服务端到客户端的接收端」三样。本地模式下 run_app 把后两样直接交给 RpcClient::new_local_from_wire,中间没有 socket,但消息仍然完整地经过 postcard 编码和解码。
new_local_from_wire 唯一多做的事是把 is_localhost 设成 true,它影响文件怎么传:upload_file 在本地直接返回原路径;远程时先 temp_file/new 在服务端建临时文件,再按 512 KiB 一块 temp_file/append 传过去,同一个文件只传一次。ELF、ESP-IDF 的 bootloader 和分区表都走它。--chip-description-path 给的 YAML 则是读成字符串,用 chips/load 发给服务端的那份 Registry(util/cli.rs:89)。
哪些子命令走 RPC
Cli::run 拿到的是 RpcClient,但不是每个子命令都用它;is_remote_cmd 列出了「完全由 RPC 调用实现」的那些,注释是「TODO: refactor other commands」:
| 类别 | 子命令 | 说明 |
|---|---|---|
| 全走 RPC,可远程 | list、info、reset、read、write、chip、download、run、attach、erase、verify;mi 的一部分 | 远程模式下只有这些能用,其他命令报 The subcommand is not supported in remote mode. |
拿 RpcClient 但不在列表里 | debug | 本地可用,远程被拒 |
只借客户端里的 Registry,自己直接调库 | gdb、trace、itm、benchmark、profile | simple_attach 直接建 Session;gdb --dry-run 有效就是因为这个(gdb server) |
| 完全不碰 RPC | dap-server、complete | DAP server 自己建 Session(DAP server) |
不管走不走,run_app 都会先起本地服务端,命令结束后等它关闭;对 gdb 这类命令,那个服务端从头到尾是空转的。
两个 multicall 入口也分属两边(multicall 本身见源码地图):cargo-flash 的 main 调 run_app,走 RPC(cmd/cargo_flash/mod.rs:106);cargo-embed 用 simple_attach 直接建 Session(cmd/cargo_embed/mod.rs:233),不走。
remote:同一套协议换一根线
开了 remote 之后,全局参数多出 --host(环境变量 PROBE_RS_REMOTE_HOST)和 --token(PROBE_RS_REMOTE_TOKEN)(main.rs:76-94),run_app 改调 rpc::client::connect。服务端是 probe-rs serve,配置来自配置文件的 server 段(ServerConfig:用户列表、地址、端口,以及 cycle_power:启动时打开所有 USB hub 的端口供电控制,只在 Linux 上实现)。两种传输:
sequenceDiagram participant C as 客户端 connect participant S as probe-rs serve C->>S: GET /worker(websocket 升级) S-->>C: 101,头 Probe-Rs-Challenge:64 字节随机数的 base64 C->>S: 第一帧:SHA-512(challenge 加 token) S->>S: 逐个用户算一遍,比对 S->>S: create_server(16, 该用户的 ProbeAccess) C->>S: postcard-rpc 帧,websocket 二进制消息 S-->>C: 响应与 topic
- websocket(
run_tcp):axum 默认监听0.0.0.0:3000,/是一个列出探针的状态页,/worker升级成 websocket。认证是上图的挑战应答(handle_socket),每个 postcard 帧是一条二进制消息。服务端本身不做 TLS;客户端在wss://时用 rustls,但证书校验器是NoCertificateVerification,源码注释是「TODO: implement something more secure」。挑战用fastrand生成,注释说明不需要密码学安全的随机数,「just something to make replays harder」。 - unix socket(
run_unix):地址写成socket://<路径>时启用,socket 文件权限设成0o660,靠文件权限控制访问,没有 token 认证,ProbeAccess::All。帧格式是 4 字节小端长度加 postcard 帧(transport/unix.rs)。
每个用户可以配一个 ProbeAccess:All、Allow(列表) 或 Deny(列表),列表项是 VID:PID 或 VID:PID:序列号。服务端用 LimitedLister 包住探针枚举,不在许可范围内的探针既列不出来也打不开。这让一台插着多个探针的机器可以分给不同的人用。
远程时 defmt 解码仍在客户端,服务端只转发 RTT 原始字节,所以服务端不需要固件的调试信息;但烧录要用 ELF,flash/build 之前客户端会先把 ELF 上传过去。远程模式的延迟、websocket 上 RTT 的吞吐,待实测(需要两台机器和真探针)。
小结
- 命令行内部是 postcard-rpc:31 个 endpoint、4 个出向 topic 和 1 个入向 topic,全在
rpc/functions.rs的三张宏表里;错误在边界上变成字符串。 Session、FlashLoader、RTT 客户端都留在服务端的对象仓库里,客户端只拿Key<T>;同步的库调用进spawn_blocking,进度和日志作为 topic 一边跑一边推。- 本地模式是两条内存通道,消息照样编解码;
is_localhost只决定文件是传路径还是上传。 - 只有 11 个子命令(加
mi的一部分)完全走 RPC、能远程;gdb、dap-server、trace、benchmark、profile和cargo-embed直接调库。 remotefeature 下probe-rs serve提供 websocket(挑战应答认证、按用户限定探针)和 unix socket(靠文件权限)两种接入;本机的 0.32.0 没开这个 feature。