Cortex-M 内核:halt、寄存器、断点、单步
主线位置:调试主线前半段。从「让核停下」到「读出 PC、设一个断点、单步一条指令」,每一步都是对 SCS 里几个调试寄存器的读写;本页按
armv7m.rs逐个讲,再说 Armv6-M、Armv8-M 哪里不一样。
stateDiagram-v2 Running --> Halted: 写 DHCSR C_HALT Halted --> Running: 写 DHCSR 清 C_HALT C_STEP Halted --> Stepping: 写 DHCSR C_STEP 清 C_HALT Stepping --> Halted: 执行一条指令后 S_HALT Running --> Halted: FPB 比较器命中 或 BKPT Running --> Halted: vector catch 复位 HardFault Running --> LockedUp: 不可恢复的异常
几个调试寄存器
Cortex-M 的调试功能全在系统控制空间里,调试器通过 MEM-AP 读写这些地址(MEM-AP 与 ROM table):
| 地址 | 寄存器 | probe-rs 用到的位 |
|---|---|---|
0xE000EDF0 | DHCSR | 写:C_DEBUGEN(0)、C_HALT(1)、C_STEP(2)、C_MASKINTS(3),高 16 位必须写钥匙 0xA05F;读:S_REGRDY(16)、S_HALT(17)、S_SLEEP(18)、S_LOCKUP(19)、S_RESET_ST(25) |
0xE000ED30 | DFSR | 停下的原因:HALTED(0)、BKPT(1)、DWTTRAP(2)、VCATCH(3)、EXTERNAL(4),写 1 清除 |
0xE000EDF4 | DCRSR | REGSEL(寄存器号)、REGWnR(16,1 为写) |
0xE000EDF8 | DCRDR | 寄存器值的中转 |
0xE000EDFC | DEMCR | VC_CORERESET(0)、VC_HARDERR(10)等 vector catch 位,TRCENA(24) |
0xE0002000 | FP_CTRL | ENABLE(0)、KEY(1,写时必须为 1)、比较器个数 NUM_CODE(bit[14:12] 和 [7:4] 拼起来)、版本 REV(bit[31:28]) |
0xE0002008 起 | FP_COMPn | 每个比较器一个字 |
0xE000EF40 | MVFR0 | 有没有 FPU |
这些定义在 armv7m.rs、cortex_m.rs 和 core/mod.rs(DFSR)里,用 bitfield! 或 memory_mapped_bitfield_register! 生成。DHCSR 的写钥匙由 enable_write 填上;忘了它,内核会忽略这次写。
Armv7m 的状态
Armv7m 只持有三样东西:一个内存接口、一份跨调用保留的 CortexMState、芯片的调试序列。Core 是短命的(每次 session.core(i) 新建),状态放在 Session 里的 CortexMState:上次看到的核状态、硬件断点是否已全局打开、有没有 FPU、当前 PC 上是否有待处理的 semihosting 命令,以及 0.32 新加的 pending_step(#4040)。
第一次拿到 Core 时,new 读一次 DHCSR 判断核在跑、在睡还是已停;已停就读 DFSR 记下原因;然后写 DFSR 清掉所有粘滞位,读 MVFR0 看有没有 FPU。之后就不再重复。
status:停了没有,为什么停
status 是命令行轮询循环每 100 ms 调一次的函数(一条 probe-rs run)。它读 DHCSR:S_LOCKUP 置位就是 LockedUp;S_SLEEP 是 Sleeping;S_HALT 置位就读 DFSR,按 halt_reason 翻译成原因,然后写回去清掉。DFSR 有多个位同时置位时,只要其中有 BKPT 就报断点:注释的理由是「For debuggers, it's important to know if the core halted because of a breakpoint.」
原因是断点时还要多看一眼:读 PC 处的两个字节,如果是 0xBEAB(BKPT 0xAB),这不是断点,是 semihosting 调用(check_for_semihosting),原因改成 Breakpoint(Semihosting(...)),见 defmt 与 semihosting。
C_HALT 和 C_STEP 停下来时 DFSR 都只置 HALTED,从寄存器上分不出是哪一种。0.32 的办法是在发 C_STEP 前记一个 pending_step,status 读到 HALTED 时如果有这个标记,就把原因从 Request 改成 Step(resolve_halt_reason)。
halt、run、step
halt 写 C_HALT | C_DEBUGEN,轮询到停下,再读 PC。模拟目标上(只列内存一层的访问,每次访问前的写 TAR 省略):
== halt
W DHCSR 0xa05f0003
R DHCSR 0x00030003
R DFSR 0x00000001
W DFSR 0x0000001f
W DCRSR 0x0000000f
R DHCSR 0x00030003
R DCRDR 0x08000134
## halt: pc=0x8000134; raw transfers=22 est. DAP commands=7
0x00030003 是 S_HALT | S_REGRDY 加上回读的 C_HALT | C_DEBUGEN;DFSR 是 HALTED。后三行就是读 PC,下一节讲。
step 是最讲究的一个:
// probe-rs/src/architecture/arm/core/armv7m.rs
// First check if we stopped on a breakpoint, because this requires special handling before we can continue.
let breakpoint_at_pc = if matches!(
self.state.current_state,
CoreStatus::Halted(HaltReason::Breakpoint(_))
) {
let pc_before_step = self.read_core_reg(self.program_counter().into())?;
self.enable_breakpoints(false)?;
Some(pc_before_step)
} else {
None
};
// ...
if !dhcsr.c_maskints() {
dhcsr.set_c_maskints(true); // This must be reset to false when we run() again.
// ... 写 DHCSR
}
// Leave halted state.
// Step one instruction.
self.state.begin_step();
dhcsr.set_c_step(true);
dhcsr.set_c_halt(false);
// ... 写 DHCSR,等停下(或 lockup)
四个要点:
- 停在断点上时先关断点。否则 FPB 比较器在当前 PC 上立刻再次命中,核一条指令也走不了。
- 屏蔽中断。
C_MASKINTS让单步不会一脚踩进一个挂起的中断处理函数;它要等到run时才清掉。 - 等「停下或 lockup」。单步可能让核进入 lockup,lockup 不算 halted,所以等的条件是两者之一,进了 lockup 就再发一次 halt。
- 停在
BKPT指令上时手动跳过。如果单步前后 PC 没变,而且这个地址上没有硬件断点,说明 PC 处是一条BKPT指令(软件断点):执行它只会再次停下。代码把 PC 加 2(BKPT是 16 位指令)跳过它,再把断点重新打开。
模拟目标上,核因为 halt 请求停着(不是断点),单步的序列是:
== step
R DHCSR 0x00030003
W DHCSR 0xa05f000b
W DHCSR 0xa05f000d
R DHCSR 0x0003000d
R DFSR 0x00000001
W DFSR 0x0000001f
R DHCSR 0x0003000d
R DFSR 0x00000000
W DFSR 0x0000001f
W DCRSR 0x0000000f
R DHCSR 0x0003000d
R DCRDR 0x08000136
## step: pc=0x8000136; raw transfers=32 est. DAP commands=12
0xa05f000b 在保持 halt 的同时置 C_MASKINTS,0xa05f000d 清 C_HALT、置 C_STEP;接着两轮 status(一轮在等待里,一轮检查 lockup),最后读 PC。模拟目标的单步是「PC 加 2」,所以 PC 从 0x8000134 到 0x8000136。
run 一开头就调 step:
// probe-rs/src/architecture/arm/core/armv7m.rs
fn run(&mut self) -> Result<(), Error> {
// Before we run, we always perform a single instruction step, to account for possible breakpoints that might get us stuck on the current instruction.
self.step()?;
self.state.clear_pending_step();
let mut dhcsr = Dhcsr(self.memory.read_word_32(Dhcsr::get_mmio_address())?);
// First disable the DHCSR->C_MASKINTS.
if dhcsr.c_maskints() {
dhcsr.set_c_maskints(false);
// ... 写 DHCSR
}
// Exit halt state ..
dhcsr.set_c_step(false);
dhcsr.set_c_halt(false);
// ... 写 DHCSR
理由和 step 的第 1、4 点一样:如果停在断点上直接放行,核会立刻在同一个地方再停下。先单步过去(单步里会关、开断点,必要时跳过 BKPT),再放行。模拟目标上 run 的最后三行是:
R DHCSR 0x0003000d
W DHCSR 0xa05f0005
W DHCSR 0xa05f0001
0xa05f0005 是清 C_MASKINTS 的那一写:值从刚读回的 DHCSR 改来,C_STEP 还是 1,C_HALT 已经是 0。下一写 0xa05f0001 才清 C_STEP。probe-rs gdb --dry-run 的日志里是同样的两行(FakeProbe)。按 Armv7-M 对 DHCSR 的定义,核停着时写入 C_HALT = 0、C_STEP = 1 会让它执行一条指令再停下,也就是说 run 在真实内核上可能先多走一条、而且是在中断不再屏蔽的状态下走;这对结果是否有可见影响,待实测。
读写寄存器
核心寄存器不在内存地址空间里,要经过 DCRSR / DCRDR 中转(cortex_m.rs:133):
// probe-rs/src/architecture/arm/core/cortex_m.rs
pub(crate) fn read_core_reg(
memory: &mut dyn ArmMemoryInterface,
addr: RegisterId,
) -> Result<u32, ArmError> {
// Write the DCRSR value to select the register we want to read.
let mut dcrsr_val = Dcrsr(0);
dcrsr_val.set_regwnr(false); // Perform a read.
dcrsr_val.set_regsel(addr.into()); // The address of the register to read.
memory.write_word_32(Dcrsr::get_mmio_address(), dcrsr_val.into())?;
wait_for_core_register_transfer(memory, Duration::from_millis(100))?;
let value = memory.read_word_32(Dcrdr::get_mmio_address())?;
Ok(value)
}
写 DCRSR 选寄存器,轮询 DHCSR 的 S_REGRDY(最多 100 ms),再读 DCRDR。写寄存器顺序相反:先写 DCRDR,再写带 REGWnR 的 DCRSR,再等 S_REGRDY。模拟目标上 write_core_reg(R0, 0x1234):
W DCRDR 0x00001234
W DCRSR 0x00010000
R DHCSR 0x00030003
寄存器号在 REGSEL 里:R0–R12 是 0–12,SP 13,LR 14,PC 15(日志里的 0x0000000f),xPSR 16;Armv8-M 的 REGSEL 扩到 7 位。核在跑时 read_core_reg 直接返回 CoreNotHalted,不去碰 DCRSR。一次寄存器读是 3 次内存访问;读一整组寄存器(比如 DAP 服务器每次停下都要的那些)就是几十次,栈展开和 DAP server 两章会看到这个代价。
硬件断点:FPB
Cortex-M 的硬件断点由 FPB(Flash Patch and Breakpoint)单元提供,比较器个数由芯片决定。available_breakpoint_units 读 FP_CTRL,NUM_CODE 就是个数;FPB 版本 REV 只认 0 和 1,其他版本直接报不支持。
两个版本的比较器编码不同(armv7m.rs:471):
| 版本 | 编码 | 限制 |
|---|---|---|
rev 1(REV = 0,Armv7-M 常见) | COMP 存地址的 bit[28:2],REPLACE(bit[31:30])选匹配低半字(0b01)还是高半字(0b10),bit 0 使能 | 只能断在 0x2000_0000 以下(代码区) |
rev 2(REV = 1) | BPADDR 存地址的 bit[31:1],bit 0 使能 | 整个 4 GiB |
Core::set_hw_breakpoint 先确保 FPB 已全局打开,再用 hw_breakpoints 读出每个比较器的当前地址,找一个已有同地址的或空闲的,最后调 Armv7m::set_hw_breakpoint 写比较器;一个空闲的都没有就报 No available hardware breakpoints。模拟目标设了 6 个比较器(真实芯片的个数待实测),在 0x08000200 下断点:
== set_hw_breakpoint(0x08000200)
W FP_CTRL 0x00000003
R FP_CTRL 0x00000061
R FP_CTRL 0x00000061
R FP_COMPn 0x00000000
R FP_CTRL 0x00000061
R FP_COMPn 0x00000000
... 共 6 对
R FP_CTRL 0x00000061
W FP_COMPn 0x48000201
## set_hw_breakpoint(0x08000200): Ok(()); raw transfers=40 est. DAP commands=18
FP_CTRL = 0x61:NUM_CODE 6、ENABLE 1、REV 0。比较器值 0x48000201:REPLACE = 0b01(地址低两位为 0,匹配低半字),COMP 是 0x08000200 的 bit[28:2],bit 0 使能。日志里能看到 hw_breakpoints 在循环里每看一个比较器就重新读一次 FP_CTRL,6 个比较器多读了 6 次;在 CMSIS-DAP 上这些读每个都是一次 USB 往返。
hw_breakpoints_enabled 缓存在 CortexMState 里。芯片复位会清掉 FP_CTRL 的 ENABLE,0.32 之前复位后缓存没有作废,断点比较器写好了却全局关着,断点被静默忽略;#3810 在 reset 和 reset_and_halt 里加了 self.state.hw_breakpoints_enabled = false。
FakeProbe 上 FP_CTRL 读出 0,所以 probe-rs gdb --dry-run 加 lldb 时,continue 必然报 No available hardware breakpoints;常见坑把这个报错当作一个坑讲。
vector catch 与复位
DEMCR 的 vector catch 位让核在进入某个异常时自动停下。probe-rs run 默认打开复位和 HardFault 两个(enable_vector_catch),所以固件一 HardFault,轮询循环就看到核停下、原因是 Exception,接着打印调用栈。
reset_and_halt 就是:置 VC_CORERESET,让调试序列做系统复位(默认写 AIRCR 的 SYSRESETREQ),等核停在复位向量上,确认 xPSR 的 Thumb 位,清掉 VC_CORERESET。复位怎么做由芯片决定,所以它走序列的 reset_system,见调试序列与 vendor。
Armv6-M 与 Armv8-M
三个文件结构几乎一样,step、run 的逻辑逐行相同,差别在寄存器:
Armv6-M(armv6m.rs) | Armv7-M(armv7m.rs) | Armv8-M(armv8m.rs) | |
|---|---|---|---|
| 断点单元 | BPU:BP_CTRL 只有 bit[7:4] 一段 NUM_CODE,比较器格式同 FPB rev 1,同样只能断在 0x2000_0000 以下 | FPB rev 1 或 rev 2 | FPB 的 FP_COMPn,只有 BPADDR 一种编码 |
| FPU 检测 | 无(Armv6-M 没有 FPU) | MVFR0 | MVFR0 |
| 安全扩展 | 无 | 无 | 读 ID_PFR1,有安全扩展时寄存器组多出安全状态的栈指针等,vector catch 多一个 SecureFault |
| 寄存器组 | 基础的 16 个加 xPSR 等 | 视有无 FPU | 视主线 / 基线、有无安全扩展、有无 FPU 选 8 种之一 |
Armv8-M 选寄存器组的代码里有一个固定值:
// probe-rs/src/architecture/arm/core/armv8m.rs
let main = true; // TODO m33 is mainline, no one has m23 (baseline) yet
(armv8m.rs:475)也就是说一律按主线(如 Cortex-M33)处理。而 0.32 恰好加了 GigaDevice GD32E23x(Cortex-M23,基线)的目标描述(#3993),它的核类型是 armv8m。基线内核没有主线才有的一些寄存器,按主线的寄存器组去读会怎样,待实测。
小结
- Cortex-M 的调试就是读写 SCS 里的几个寄存器:DHCSR 控制停、跑、单步,DFSR 给原因,DCRSR / DCRDR 中转核心寄存器,DEMCR 管 vector catch,FPB 管硬件断点。
Core每次新建,跨调用的状态(核状态、断点是否全局打开、FPU、pending_step)在CortexMState里;0.32 用pending_step区分单步和 halt 请求,并在复位时作废断点缓存。step先关断点、屏蔽中断,停在BKPT上时手动把 PC 加 2;run先单步一次再放行,清C_MASKINTS那一写里C_STEP仍为 1,真实内核上的效果待实测。- 读一个寄存器是 3 次内存访问;设一个断点在 6 个比较器的模拟目标上是 40 次 DP / AP 访问,其中
hw_breakpoints每个比较器都重读一次 FP_CTRL。 - FPB rev 1 只能断在
0x2000_0000以下,rev 2 覆盖整个地址空间;Armv6-M 用 BPU,Armv8-M 还要处理安全扩展,基线 Armv8-M 目前按主线的寄存器组对待。