Rust in Everything
基于 probe-rs-debug 0.32.0

用 QEMU 的 GDB 服务端展开活固件

主线位置:调试主线的后半段,换一个后端。08 调试信息说 probe-rs-debug 只认 MemoryInterface,所以能接活芯片也能接 coredump;本章再接第三种:QEMU 里正在跑的固件,经 QEMU 自带的 GDB 服务端读。

sequenceDiagram
  participant Q as QEMU:-gdb tcp:127.0.0.1:13600 -S
  participant C as 自写 RSP 客户端
  participant D as probe-rs-debug
  C->>Q: c,300 ms 后发 0x03
  Q-->>C: T02:停住了
  C->>Q: p0 到 pf、p19、p2b、p2c
  Q-->>C: 19 个寄存器
  C->>D: CoreDump 只填寄存器,DebugRegisters::from_coredump
  C->>D: unwind(&mut 客户端, ...)
  loop 每读一次栈
    D->>C: read_32(addr)
    C->>Q: m addr,4
    Q-->>C: 十六进制
  end
  D-->>C: 9 帧

为什么要自己写

probe-rs 源码里有 GDB 远程协议(RSP)的服务端(probe-rs gdb,基于 gdbstub,见 gdb server),没有客户端;QEMU 的 -gdb 也是服务端。两个服务端接不起来,所以 embassy 卷在 QEMU 上跑的固件,probe-rs 碰不到。要接,得自己写一个客户端,再找 probe-rs 公开 API 里能接受「外来数据」的入口。

先确定入口,试的结果:

想用的能不能在库外用结论
DebugInfo::unwind(&mut impl MemoryInterface, DebugRegisters, ..)能。MemoryInterface 是公开 trait读内存这一半自己实现
DebugRegisters是公开元组结构体,但要 &'static CoreRegister;from_coredump(&CoreDump) 能造借 CoreDump:它的 7 个字段全是 pub
Rtt::attach_at(core: &mut Core, ..)不能。Core::new 是 pub(crate)RTT 只能手工解析
RttControlBlockHeader::try_from_header不能。类型本身不是 pub同上
rtt::find_rtt_control_block_in_raw_file(&[u8])能用它从 ELF 拿控制块地址

固件和 QEMU

固件就是cargo runner 与 defmt 三件套里那个:defmt-rtt 打日志,递归三层,到底以后原地自旋:

// 本站实验固件
#[inline(never)]
fn level(depth: u32) -> u32 {
  if depth == 0 {
    defmt::info!("reached the bottom, spinning");
    loop {
      SPINS.fetch_add(1, Ordering::Relaxed);
    }
  }
  defmt::info!("level {=u32}", depth);
  level(depth - 1) + depth
}

QEMU 11.1.3 用 embassy 卷同一个机器,GDB 服务端只绑本机,-S 让 CPU 停在复位处等客户端:

qemu-system-arm -M b-l475e-iot01a -display none -kernel fw.elf -gdb tcp:127.0.0.1:13600 -S

QEMU 的 target.xml 给出了寄存器编号:r0–r15 是 0–15,xpsr 是 25,msp、psp 是 43、44(M-profile 系统寄存器那个 feature)。probe-rs 的 Cortex-M RegisterId 是 R0–R15 为 0–15、XPSR 16、MSP 17、PSP 18,和 DCRSR 的 REGSEL 编号一致(Cortex-M 内核)。两张表对一下就是 19 行的映射。

客户端:三个函数

RSP 的包是 $内容#两位校验和,收到后回一个 +。客户端只需要发包、收包和一个「发 0x03 打断」:

// 本站实验:RSP 客户端的核心
fn send(&mut self, body: &str) -> Result<()> {
  let sum = body.bytes().fold(0u8, |a, b| a.wrapping_add(b));
  self.stream.write_all(format!("${body}#{sum:02x}").as_bytes())?;
  Ok(())
}

fn read_mem(&mut self, addr: u64, out: &mut [u8]) -> Result<()> {
  let reply = self.request(&format!("m{addr:x},{:x}", out.len()))?;
  if reply.starts_with('E') {
    bail!("RSP {reply} reading {addr:#x}");
  }
  out.copy_from_slice(&hex_bytes(&reply)?);
  Ok(())
}

recv 跳过 +、读到 # 为止再读两位校验和、回 +,十几行。让固件跑一会儿再停:发 c,睡 300 ms,往 socket 写一个字节 0x03,QEMU 回停止包。

接到 probe-rs-debug

读内存:把客户端实现成 MemoryInterface。按宽度的块读都落到一个 m 包,小端拼回去;写方法返回「未实现」,展开用不到:

// 本站实验
macro_rules! read_words {
  ($name:ident, $t:ty) => {
    fn $name(&mut self, address: u64, data: &mut [$t]) -> Result<(), probe_rs::Error> {
      const N: usize = size_of::<$t>();
      let mut raw = vec![0u8; data.len() * N];
      self.read_mem(address, &mut raw).map_err(rsp_err)?;
      for (d, c) in data.iter_mut().zip(raw.chunks_exact(N)) {
        *d = <$t>::from_le_bytes(c.try_into().unwrap());
      }
      Ok(())
    }
  };
}

impl MemoryInterface for Rsp {
  read_words!(read_32, u32);
  // ... read_64、read_16、read_8,write_* 返回 Error::NotImplemented
}

寄存器:造一个没有内存段的 CoreDump,只填寄存器表,再交给 DebugRegisters::from_coredump:它按 core_type 找到 Cortex-M 的寄存器描述,给前 32 个配上 DWARF 编号,这些都是我们在库外做不了的事。

// 本站实验
let dump = CoreDump {
  registers, // HashMap<RegisterId, RegisterValue>,19 个,来自 p 包
  data: vec![],
  instruction_set: InstructionSet::Thumb2,
  supports_native_64bit_access: false,
  core_type: CoreType::Armv7em,
  fpu_support: false,
  floating_point_register_count: None,
};
let frames = debug_info.unwind(
  &mut rsp,
  DebugRegisters::from_coredump(&dump),
  exception_handler_for_core(CoreType::Armv7em).as_ref(),
  Some(InstructionSet::Thumb2),
  50,
)?;

结果

== qSupported -> PacketSize=20020;qXfer:features:read+;vContSupported+;multiprocess+
== ?          -> T05core:01;
== c, 300 ms, ^C -> T02thread:01;
   pc=0x08000522 sp=0x20017f80 lr=0x08000a57 xpsr=0x61000000
== unwind: 9 frames
#0  pc=0x08000522 inl cfa=0x20017f98 atomic_add<u32, u32>  <rustup>/.../library/core/src/sync/atomic.rs:4029
#1  pc=0x08000522 inl cfa=0x20017f98 Atomic<u32>::fetch_add  <rustup>/.../library/core/src/sync/atomic.rs:3220
#2  pc=0x08000522     cfa=0x20017f98 level  <项目>/src/main.rs:18
#3  pc=0x08000500     cfa=0x20017fb0 level  <项目>/src/main.rs:22
#4  pc=0x08000500     cfa=0x20017fc8 level  <项目>/src/main.rs:22
#5  pc=0x08000500     cfa=0x20017fe0 level  <项目>/src/main.rs:22
#6  pc=0x080004ae     cfa=0x20017ff8 __cortex_m_rt_main  <项目>/src/main.rs:28
#7  pc=0x08000536     cfa=0x20018000 __cortex_m_rt_main_trampoline  <项目>/src/main.rs:25
#8  pc=0x0800043a     cfa=-          Reset
   RSP during unwind: 20 packets, 20 m-packets, 80 bytes

逐帧对得上源码:停在第 18 行的 fetch_add 里(两层内联帧来自 core::sync::atomic);往上是三层递归 level,每层 CFA 差 0x18,PC 都落在第 22 行 level(depth - 1) 的调用处((LR - 2) & !1 的效果,见栈展开);再往上是 main 的第 28 行、cortex-m-rt 的跳板和 Reset。最外一帧的 CFA 0x20018000 是 RAM 的顶端(96 KiB 的 RAM 从 0x20000000 起),也就是初始栈指针。

整个展开读了 20 次、每次 4 字节,共 80 字节。换成真探针,这 20 次读各是一次 MEM-AP 访问,加上 DebugRegisters::from_core 读寄存器的开销(栈展开)。

把读回来的 R7 改成坏值再展开,能复现 ERROR: UNWIND 这个坑的两种形态:指到没映射的地址时最后一帧挂着 RSP E14;指到 RAM 里的错误位置时调用栈静默地少了 4 帧。

RTT:只能手工

Rtt::attach_at 要 &mut Core,而 Core 只能由 Session::core 造,Session 又只能经探针 attach 得到。能用的只有「从 ELF 找控制块地址」那一个函数,剩下照 RTT 控制块与通道的布局手工读:

== _SEGGER_RTT at 0x20000008: id="SEGGER RTT" max_up=1 max_down=0
   up[0] name="defmt" buffer=0x20000048 size=1024 WrOff=29 RdOff=0 flags=1
   first 29 bytes of the ring: [00, 03, 08, 70, 3a, 72, 6f, 62, 65, 2d, 72, 73, 80, 00, 04, 03, 7a, 00, 04, 02, 7a, 00, 04, 01, 7a, 00, 05, 7e, 00]
  • flags=1 是 defmt-rtt 的初值 NoBlockTrim:没有 probe-rs 连上过,没人把它改成 BlockIfFull。RdOff=0:也没人读过。
  • 29 字节是 5 帧 defmt 日志,以 0x00 分隔(rzCOBS 编码)。能直接认出来的:中间三帧 04 03 7a、04 02 7a、04 01 7a,首字节 4 是 .defmt 里 level {=u32} 的索引,第二个字节是参数 3、2、1;最后一帧首字节 5 是「reached the bottom, spinning」;第一帧里夹着 {=str} 参数「probe-rs」的 ASCII。完整解码要用 defmt-decoder,本机缓存里没有,这里是按 ELF 里的索引手工对照的。

想让 probe-rs 的 Rtt、单步、断点这些需要 Core 的功能也作用在 QEMU 上,得换一层接:实现一个 DebugProbe,在它的 RawDapAccess 里把 MEM-AP 的 TAR / DRW 访问翻译成 m / M 包,把 DHCSR 的 C_HALT 翻译成 0x03、清 C_HALT 翻译成 c,把 DCRSR / DCRDR 翻译成 p / P,FP_COMPn 翻译成 Z1 / z1。那样 Probe::new(..).attach(..) 得到的就是一个真正的 Session。这比本章大一个量级,留给课程。

小结

  • probe-rs 只有 GDB 服务端,QEMU 也只有服务端;要接,得自己写一个 RSP 客户端,核心是收发 $…#xx 包和 0x03 打断。
  • probe-rs-debug 的展开只要两样外来数据:MemoryInterface(公开 trait,m 包实现)和 DebugRegisters(借 CoreDump 的公开字段和 from_coredump 造)。
  • 对 QEMU 里自旋的固件展开出 9 帧,一直到 Reset,只读了 20 次、80 字节。
  • RTT 卡在 Rtt::attach_at(&mut Core):Core 和控制块头的类型都不能在库外构造,只能从 ELF 拿地址后手工解析;要完整接入,得把 RSP 包成一个 DebugProbe。

💬 关联讨论

发起讨论
加载中...