Rust in Everything
基于 probe-rs 0.32.0

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 用到的位
0xE000EDF0DHCSR写: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)
0xE000ED30DFSR停下的原因:HALTED(0)、BKPT(1)、DWTTRAP(2)、VCATCH(3)、EXTERNAL(4),写 1 清除
0xE000EDF4DCRSRREGSEL(寄存器号)、REGWnR(16,1 为写)
0xE000EDF8DCRDR寄存器值的中转
0xE000EDFCDEMCRVC_CORERESET(0)、VC_HARDERR(10)等 vector catch 位,TRCENA(24)
0xE0002000FP_CTRLENABLE(0)、KEY(1,写时必须为 1)、比较器个数 NUM_CODE(bit[14:12] 和 [7:4] 拼起来)、版本 REV(bit[31:28])
0xE0002008 起FP_COMPn每个比较器一个字
0xE000EF40MVFR0有没有 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)

四个要点:

  1. 停在断点上时先关断点。否则 FPB 比较器在当前 PC 上立刻再次命中,核一条指令也走不了。
  2. 屏蔽中断。C_MASKINTS 让单步不会一脚踩进一个挂起的中断处理函数;它要等到 run 时才清掉。
  3. 等「停下或 lockup」。单步可能让核进入 lockup,lockup 不算 halted,所以等的条件是两者之一,进了 lockup 就再发一次 halt。
  4. 停在 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 2FPB 的 FP_COMPn,只有 BPADDR 一种编码
FPU 检测无(Armv6-M 没有 FPU)MVFR0MVFR0
安全扩展无无读 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 目前按主线的寄存器组对待。

💬 关联讨论

发起讨论
加载中...