写一个软件探针
主线位置:访问主线的最底层,但站在目标那一侧。位级探针讲了 probe-rs 怎么把一次 DP / AP 访问拼成 46 位的 SWD 包;这一章写一个收这些包的「芯片」,先自己当主机驱动它,再让 probe-rs 驱动它。
flowchart TB own["主机 A:自己写的 packet 和 transfer"] --> io["swd_io:一串 IoSequenceItem"] pr["主机 B:probe-rs 的 Session、ArmCommunicationInterface、polyfill"] --> io io --> wire["Wire:解包,AP 读 posted,按相位回填"] wire --> tgt["Target:DP 寄存器、MEM-AP、内存、调试寄存器"]
整个程序约 400 行,分三块:目标模型(Target)、线(Wire)、两个主机。所有数字都来自这个模拟目标,反映的是 probe-rs 的代码行为,不是任何一块真芯片。
第一块:目标模型
只实现 probe-rs attach 一颗 Cortex-M 时真正会碰到的寄存器:
| 部分 | 寄存器 | 行为 |
|---|---|---|
| SW-DP | DPIDR、CTRL/STAT、SELECT、RDBUFF | DPIDR 固定 0x1BA01477;CTRL/STAT 把上电请求位(bit 30、28)镜像到应答位(bit 31、29) |
| MEM-AP(AP 0) | CSW、TAR、DRW、IDR | IDR 是 AHB-AP;CSW 的自增在 1 KiB 内回绕;CFG、BASE 读 0 |
| 内存 | 字粒度的 HashMap | 0x20000000 放 0xCAFEF00D |
| 调试寄存器 | DHCSR、DFSR、DCRSR、DCRDR、FP_CTRL | 见下 |
DP / AP 访问已经是「解码后」的形式:ap 选 DP 还是 AP,a 是 A[3:2],AP 寄存器的高 4 位来自 SELECT 的 bank:
// 本站实验:Target::access
fn access(&mut self, ap: bool, read: bool, a: u8, wdata: u32) -> u32 {
let a = a as u32;
if !ap {
return match (a, read) {
(0x0, true) => 0x1BA0_1477, // DPIDR
(0x4, true) => self.ctrl | ((self.ctrl & 0x5000_0000) << 1), // power-up ACKs mirror REQs
// ... 写 CTRL/STAT、SELECT
_ => 0,
};
}
match ((self.select & 0xF0) | a, read) {
(0x04, false) => { self.tar = wdata; 0 }
(0x0C, true) => { let v = self.mem_read(self.tar & !3); self.autoinc(); v }
(0xFC, true) => 0x2477_0011, // IDR: AHB-AP
// ... CSW、写 DRW
_ => 0,
}
}
调试寄存器决定了「停核」「读寄存器」能不能工作。要点有三个,前两个是第一次写错、被 probe-rs 的日志揭穿的:
- DHCSR 读回控制位。probe-rs 对 DHCSR 做读改写;读出来只有状态位(
S_REGRDY、S_HALT)而没有C_DEBUGEN时,下一次写就把C_DEBUGEN清掉了。 - 没有
C_DEBUGEN时C_HALT、C_STEP无效,和架构规定一致。 - DCRSR 写一次就完成传输:
REGWnR为 0 时把寄存器值放进 DCRDR,为 1 时把 DCRDR 写进寄存器;S_REGRDY恒为 1。单步(C_STEP置位)就把 PC 加 2,这是唯一的「执行」。
第二块:线
Wire::swd_io 收一串 IoSequenceItem(主机驱动的位或采样槽),从输出位里认出包,交给 Target,再把目标该驱动的位填进对应的采样槽:
// 本站实验:Wire::swd_io 的核心
let (ap, read) = (out_bit(pos + 1), out_bit(pos + 2));
let a = ((out_bit(pos + 3) as u8) << 2) | ((out_bit(pos + 4) as u8) << 3);
let trn = pos + 8;
let wdata = (0..32).fold(0u32, |v, i| v | ((out_bit(trn + 5 + i) as u32) << i));
let data = match (ap, read, a) {
(true, true, _) => std::mem::replace(&mut self.posted, self.target.access(true, true, a, 0)),
(false, true, 0xC) => self.posted, // RDBUFF
_ => self.target.access(ap, read, a, wdata),
};
let mut driven = vec![true, false, false]; // ACK = OK (0b001, LSB first)
if read {
driven.extend((0..32).map(|i| data >> i & 1 != 0));
driven.push(data.count_ones() % 2 == 1);
}
for (k, bit) in driven.into_iter().enumerate() {
sampled[trn + k] = bit; // one bit early: the turnaround slot holds ACK[0]
}
两条规则都来自 位级探针:AP 读是 posted 的,数据阶段送回的是上一次 AP 读的结果,这一次的结果要读 RDBUFF 才拿到;polyfill 假设采样提前一位,所以 ACK 的第 0 位填在 turnaround 那一槽。线这一层不关心包是谁拼的。
第三块 A:自己当主机
主机这一侧只要两个函数:packet 拼出一个包,transfer 发出去、解出 ACK 和数据、校验奇偶:
// 本站实验:一个 SWD 包,8 位请求 + turnaround + 3 位 ACK + 33 位数据 + turnaround,再加 2 个空闲周期
fn packet(ap: bool, read: bool, a: u8, wdata: u32) -> Vec<IoSequenceItem> {
use IoSequenceItem::{Input, Output};
let (a2, a3) = (a & 4 != 0, a & 8 != 0);
let mut v = vec![Output(true), Output(ap), Output(read), Output(a2), Output(a3)];
v.extend([Output(ap ^ read ^ a2 ^ a3), Output(false), Output(true), Input, Input, Input, Input]);
if read {
v.extend(std::iter::repeat_n(Input, 34)); // 32 data + parity + turnaround
} else {
v.push(Input); // turnaround
v.extend((0..32).map(|i| Output(wdata >> i & 1 != 0)));
v.push(Output(wdata.count_ones() % 2 == 1));
}
v.extend([Output(false), Output(false)]); // idle
v
}
fn mem_read(w: &mut Wire, addr: u32) -> Result<u32> {
transfer(w, true, false, 0x4, addr)?; // TAR
transfer(w, true, true, 0xC, 0)?; // DRW: posted, data is stale
transfer(w, false, true, 0xC, 0) // RDBUFF
}
上面就是「写 TAR、读 DRW」:一次内存读三个包。主程序按 ADI 的顺序走一遍:读 DPIDR、请求上电、选 AP 0、写 CSW,然后读内存、写 DHCSR 停核、写 DCRSR 选 15 号寄存器(PC)、读 DCRDR:
== part A: our own SWD host
DPIDR = 0x1ba01477
CTRL/STAT = 0xf0000000
mem read 0x20000000 -> 0xcafef00d
read 0x20000000 = 0xcafef00d
mem write 0xe000edf0 <- 0xa05f0003
mem read 0xe000edf0 -> 0x00030003
DHCSR = 0x00030003
mem write 0xe000edf4 <- 0x0000000f
mem read 0xe000edf8 -> 0x08000130
PC = 0x08000130
## part A: 18 packets, 864 SWCLK cycles
18 个包,每个 48 个周期(46 加 2 个空闲)。缩进的行是目标一侧看到的总线访问。DHCSR = 0x00030003:S_HALT、S_REGRDY 加上刚写进去的 C_HALT、C_DEBUGEN。
第三块 B:交给 probe-rs
同一个 Wire 包进一个结构体,实现 RawSwdIo:
// 本站实验
impl RawSwdIo for SoftProbe {
fn swd_io<S>(&mut self, swdio: S) -> Result<Vec<bool>, DebugProbeError>
where
S: IntoIterator<Item = IoSequenceItem>,
{
let items: Vec<_> = swdio.into_iter().collect();
Ok(self.wire.0.swd_io(&items))
}
// ... swj_pins、swd_settings
}
polyfill 的 blanket impl 要求同时实现 RawJtagIo,给几个返回「未实现」的方法就行;DebugProbe 的十几个方法和 DebugProbe 与驱动注册表里那个 DAP 层的模拟探针一样,try_get_arm_debug_interface 里调 ArmCommunicationInterface::create(self, sequence, true),最后一个参数 true 表示位级探针、打开溢出检测。然后:
let mut session = Probe::new(probe).attach("STM32F103C8", Permissions::default())?;
let v = session.core(0)?.read_word_32(0x2000_0000)?;
== part B: the same target under probe-rs (RawSwdIo + polyfill)
## attach: 258 packets, 13895 SWCLK cycles
read_word_32(0x20000000) = 0xcafef00d
## read_word_32: 20 packets, 1060 SWCLK cycles
halt -> pc = 0x08000134
## halt: 44 packets, 2350 SWCLK cycles
read_core_reg(PC) = 0x08000134
## read_core_reg: 28 packets, 1490 SWCLK cycles
step -> pc = 0x08000136
## step: 64 packets, 3420 SWCLK cycles
breakpoint units = 4; set 5 breakpoints: [Ok(()), Ok(()), Ok(()), Ok(()), Err("No available hardware breakpoints")]
对照着看:
| 操作 | 自己当主机 | probe-rs | 多出来的是什么 |
|---|---|---|---|
| 读一个字 | 3 个包 | 20 个包 | 每次 session.core(0) 重建 MEM-AP 状态(读 IDR、CSW、CFG);每个单独的访问后面补一个 RDBUFF 确认(位级探针) |
| 停核加读 PC | 6 个包 | 44 个包 | 读 DHCSR 确认停下、读 DFSR 判断原因、读寄存器前等 S_REGRDY |
数字和 位级探针 用另一个更完整的模拟目标测到的一样(read_word_32 1 060 周期、halt 2 350 周期):这些开销来自 probe-rs 的代码路径,与目标模型的细节无关。
halt 读出的 PC 是 0x08000134 而不是初始的 0x08000130:attach 结束前 probe-rs 让核跑起来,run 先单步一次,接着写 0xA05F0005(C_STEP 仍为 1)再写 0xA05F0001,这个模型把每次 C_STEP 置位都当作走一条指令,于是走了两条。真实内核上第二次写会不会多走一条,正是硬件待测清单 HW-8 要回答的问题(Cortex-M 内核)。最后一行是 FPB 比较器用完时的报错,见常见坑。
attach 的最低要求
这个模型故意做得比 02 部分用的模拟目标少:没有 ROM table(AP 的 BASE 读 0),没有 DBGMCU 之类的芯片寄存器(当普通内存处理)。attach STM32F103C8 照样成功。把 attach 期间目标看到的内存读按地址数一遍,全在调试寄存器上(DHCSR 10 次、FP_CTRL 5 次、DFSR 3 次、DCRDR 2 次,FP_COMP0–3、MVFR0、DBGMCU_CR 各 1 次),一次 ROM table 也没读:核在哪个 AP 上、是什么架构,都来自目标描述。反过来,把 CTRL/STAT 改成不回应上电请求:
== part B: the same target under probe-rs (RawSwdIo + polyfill)
Error: A timeout occurred.
默认调试序列的 debug_port_start 写完上电请求后循环读 CTRL/STAT,等 CSYSPWRUPACK 和 CDBGPWRUPACK 都置位,1 秒内等不到就是 ArmError::Timeout(sequences.rs:680,DP、AP 与 SELECT)。真芯片上出现这句,往往是调试端口没上电或者被固件关掉了。
小结
- 一个能被 probe-rs attach 的软件目标只需要:DPIDR、能回应上电请求的 CTRL/STAT、SELECT、带 posted 语义的 RDBUFF,一个 AHB-AP 的 IDR / CSW / TAR / DRW,以及 DHCSR(读回控制位)、DFSR、DCRSR / DCRDR、FP_CTRL。ROM table 不是必需的。
- 自己拼包读一个字是 3 个包;经 probe-rs 是 20 个,差别在每次重建
Core和每个访问单独补 RDBUFF,与目标无关。 - 同一个
Wire既能被自己的主机代码驱动,也能作为RawSwdIo交给 probe-rs:位级探针驱动在 probe-rs 里要做的,就是这一个函数。 - 加 WAIT 应答、ROM table、真正执行 Thumb 指令,留作课程里的作业。