RH850 内存异常定位实战:用 GHS Watchpoint、MAP/ELF 与汇编追踪写入链
硬件观察点终于命中了,停机 PC 却落在一个任务入口的 prepare 序言附近。内存里出现的可疑数值,恰好就是当时几个普通寄存器中的数据。这种现场比“某行代码越界了”更难解释:写入本身可能完全正常,问题发生在另一个执行流随后怎样使用这块内存。
本文沿一次 RH850/U2A 内存异常的记录,说明如何从异常地址找到对象,再从写入 PC 找到汇编和栈槽。现有线索支持继续调查两个相邻任务栈的重叠使用;尚未记录到完整的“写坏返回槽、恢复错误 LP、跳到数据区”过程,因而没有最终根因和修复回归结论。
平台为 G4MH 内核、MICROSAR OS、GHS 工具链与 MULTI 7.1.6。下文使用 TaskA/TaskB、边界 B 和对象地址 C_A,只保留定位所需的地址关系。
1. 先辨认让 CPU 停下来的事件
同一轮调试中,可能先后遇到五种不同的入口。先记录停机原因,再开始解释调用栈。
| 看到的现象 | 它首先说明什么 | 下一步保存什么 |
|---|---|---|
| CPU 内存保护异常 | 某次访问触发了相应的异常条件 | 异常原因、返回 PC/状态、访问附加信息、原寄存器 |
| ECM EIC9 ISR | 相应硬件中断请求得到处理 | 原始 ECM 状态、路由、屏蔽与有效位 |
| Data Watchpoint 命中 | 调试硬件观察到满足条件的数据访问 | 停机 PC 附近指令、访问地址、宽度与写入数据 |
| OS ProtectionHook | OS 已把某个保护事件交给 Hook | OS 错误类型及它对应的底层现场 |
| 栈水印或边界检查报告 | 某次软件检查发现栈使用异常 | 检查对象、采样时机、判定条件 |
这些入口可能属于同一事件链,也可能独立出现。例如,观察点命中会暂停调试,ECM ISR 处理错误通知,MEMF 则处理 CPU 异常。仅因它们在一次会话中出现,不能画出彼此必然相连的因果箭头。
2. 保存“故障时的状态”,并标明是谁保存的
异常处理过程还会执行函数、使用寄存器,甚至切换栈。停在 C 层处理函数时,寄存器窗口里的值可能已属于处理程序。最有价值的是入口保存前的硬件现场,或已经核对布局的故障保存缓冲区。
RH850 有 EI 级和 FE 级的系统状态。采集时应把各组配套记录,避免把旧的 EI 级返回信息与新的 FE 级故障混为一谈。
| 寄存器或数据 | 主要用途 | 解释时的限制 |
|---|---|---|
| FEIC、FEPC、FEPSW | FE 级原因与返回 PC/状态 | PC 指向故障指令还是返回点,依异常种类定义 |
| EIIC、EIPC、EIPSW | EI 级原因与返回 PC/状态 | 不天然表示“刚才最后执行的分支” |
| PSW | 当前执行状态 | 可能已经是异常处理模式;与保存的 FEPSW/EIPSW 分开 |
| r3 / SP | 当前栈指针 | 可能已经切到内核或其他处理栈 |
| r31 / LP | 当前链接寄存器 | 可能已被处理函数调用更新 |
| MEA、MEI 等访问附加信息 | 辅助判断访问地址与访问属性 | 先核对该异常是否更新它们及其有效条件 |
| OS ExceptionContext | 软件保存的原现场 | 与任务 Dyn 是不同对象,布局须独立确认 |
| Task Dyn.SP/LP | 任务切换保存值 | 可能来自较早切换,并非本次故障瞬间 |
G4MH 公开寄存器资料使用 MEA/MEI 等名称。其他调试说明中出现 FERA 一类名称时,应先确认对应内核、设备描述和 HAL 定义,不能仅因用途相近就机械替换。[1]
案例中一组 FE 级现场满足 FEPC = LP = C_A,其中 C_A 位于可写 Context 对象的首地址。另有 EIPC 落在一个中断级别设置函数内部。前者值得调查非法控制转移,后者提供了另一组系统状态的线索;在没有事件关系证明之前,不能把 EIPC 直接称为“导致 FEPC 异常的那条返回指令”。
先保存 FEIC 的完整原值,再按目标手册解析原因和附加位。本文未取得目标器件硬件手册全文,因此不把资料中的原因码强行译成某个已核实的具体子类型。
3. 先让 ELF、MAP 与运行镜像对上
MAP 告诉我们链接时哪些符号占据什么范围;ELF 包含目标代码、符号及可能存在的调试信息。它们能一起解释某个地址,但前提是属于同一次构建,而且加载的镜像确实与其一致。
文件名相同或时间接近只能提供线索。更稳妥的做法是保存构建标识和文件哈希,确认实际装载文件,再用若干稳定符号交叉检查地址与代码字节。若代码会重定位,还要考虑链接地址和实际运行地址的差别。
地址分类可以先做成一张表:
| 地址线索 | 由 MAP/ELF 确认的对象 | 后续检查 |
|---|---|---|
C_A | TaskA 的动态 Context,长度 0x24 | 它为何被用作控制流目标 |
C_A + 0x0C | 上述布局中的 LP 字段 | 字段是否真的被修改 |
[B−0x800, B) | TaskA 栈 | TaskB 是否进入了同一物理区间 |
[B, B+0x800) | TaskB 栈 | 保存 SP 与边界、采样时刻的关系 |
| 一个返回路径附近的代码地址 | 某函数范围内 | 该处的实际指令及寄存器来源 |
函数范围通常按 [start, start+size) 理解,不能把末端地址再次算进前一个符号。落在函数内部只确认归属;它不能代替反汇编确认该处是什么指令。
同样,对 Context 的手工偏移计算要在当前类型布局确认后进行。设置字段观察点时,优先由调试器求字段地址,再交叉检查基址加偏移的算术,避免观察了旁边的另一个对象。
4. 观察点监视的是地址区间
案例使用 Write、Length=8 的数据观察点,并反复得到 Stopped by write ... :8。这个消息说明观察条件发生了匹配,不能仅凭末尾的 :8 认定执行了一次 64 位写入。
把该观察点写成区间 W = [B−0x0C, B−0x04),它覆盖 8 字节。后续内存记录里的可疑值位于 B−0x28 与 B−0x20。这两个位置都不在 W 内。观察点命中与邻近位置存在可疑数据,是两项需要继续关联的证据。
调试器界面中的几个字段,可以按下面的顺序核对:
| 字段 | 本轮定位需要确认的内容 |
|---|---|
| Code / Execute | 在指令执行位置停机,用于观察即将执行的恢复或跳转 |
| Data / Read / Write | 匹配哪些数据访问;本例先关心写入 |
| Location | 观察表达式最终求得的地址,是否随任务变化 |
| Length | 观察区域长度;需与硬件实际支持的范围和对齐核对 |
| Access Size | 若设置,用于限制单次访问宽度,与 Length 分开理解 |
| Any-Task | 任务选择范围;不能据此推导所有核或 DMA 的访问都可见 |
| Rolling / Count | 与版本、目标适配及模式有关;需查 MULTI 7.1.6 本地帮助后使用 |
公开的 GHS 产品资料确认 MULTI 支持执行与数据断点,但不足以核实 7.1.6 中 Rolling/Count 的精确语义。本文保留这个文档缺口,不给它们编造默认行为。[3]
初次捕获可以只保留一个必要的数据观察点,使用基本写入匹配和无任务限制,再逐次记录 PC、SP、当前任务与访问指令。任务过滤可能漏掉真正的写入者;不过取消任务过滤也不会自动扩大硬件比较器到其他总线主设备。
观察点停下来的 PC 还要结合目标的停机时序解释。它可能已经位于造成访问的指令之后,因此既看当前指令,也检查紧邻的前一条完整指令。不能固定对 PC 减去 2 或 4:RH850 指令长度并不统一,必须从可信边界反汇编。
5. 用 PREPARE 计算一次正常的栈写入
现场有一次命中靠近 TaskA 的入口序言,指令为:
prepare {r20-r29,lp}, 31按 G4MH 指令定义,指定寄存器按编号升序保存,每个占 4 字节,栈向低地址增长;随后再分配立即数乘 4 的空间。[2]
这里保存 r20~r29 共 10 个寄存器,加上 lp/r31,总计 11 个,保存区为 44 字节;立即数 31 对应 124 字节。完整执行这条指令后,SP 总共减少:
11 × 4 + 31 × 4 = 168 字节 = 0xA8
SP_after = SP_before − 0xA8为理解位置关系,假设这次指令执行前 S = B−4,则保存位置可以直接计算:
| 被保存寄存器 | 保存地址 | 这次示例中的位置 |
|---|---|---|
| r20 | S−4 | B−0x08,位于观察区间内 |
| r21 | S−8 | B−0x0C,位于观察区间内 |
| r26 | S−28 | B−0x20 |
| r27 | S−32 | B−0x24 |
| r28 | S−36 | B−0x28 |
| r29 | S−40 | B−0x2C |
| lp/r31 | S−44 | B−0x30 |
这是基于假定的指令执行前 S 计算的布局,需要用该次运行的 SP 验证。它并不表示所有任务入口都使用 B−4,也不代表该序言之后没有其他栈操作。
现场记录中,r26 与 r28 保存同一个 Context 指针,r27 与 r29 保存另一个数据指针。对应内存中间隔 8 字节重复出现相同指针,与这种寄存器保存序列相容。一个含多个存储动作的序言,也能同时解释“观察区间被写入”和“邻近栈槽出现这些数据”。
但这只识别了一种正常写入模式。TaskA 的 SP 本来就在它自己的栈内,SP < B 对 TaskA 是正常状态,不能用来证明 TaskB 越界。要证明覆盖,还需要找到 TaskB 实际使用了这些槽位的现场,并确认其中哪一个承担了返回地址的职责。
PREPARE 还可能在保存过程中被异常或中断打断,此时不能简单用“完整指令结束后的 SP”解释所有已写入字。若停在这类边界,需按手册的指令中断与重执行语义继续检查。[2]
6. JMP 方括号表达寄存器间接跳转
这次材料核对中,需要修正一个会改变整条推理的细节:
jmp [r31]在 RH850 G4MH 中,它使用 r31 的内容作为目标地址,最低位按指令定义处理。这里的方括号不表示先读取 MEM32(r31) 再跳转。官方操作定义为 PC ← GR[reg1]。[2]
所以,“看到 jmp [r31] 就查 r31 指向的栈字,看到 jmp r31 就查 r31 本身”不是有效的 H1/H2 分界。某个工具若显示不同拼写,应核对它实际解码的机器指令,不能凭书写差异推导多一次内存访问。
正确的调查对象是:在跳转前,哪条指令最近一次为 r31 赋值?
例如,以下是自行编写的教学片段,用于区分两个数据来源,不代表案例中已捕获的真实指令:
; 路径一:从当前栈中的一个槽位恢复 LP。
ld.w 0x10[sp], r31
jmp [r31]
; 路径二:从某个 Context 的 LP 字段恢复。
ld.w 0x0c[r10], r31
jmp [r31]两条路径都可以以 jmp [r31] 结束。第一条需要计算 SP+0x10 是否真是被改写的返回槽;第二条需要证明 r10 指向哪个 Context、该对象的 LP 字段此前由谁更新。实际恢复也可能由 DISPOSE 等指令完成,必须按当前操作数和栈布局解码。
仅捕获最后一条跳转,最多能确认控制流用了什么目标;要定位根因,需要向前追到产生坏值的加载,再追到内存中那个字的写入者。
7. 现有线索支持到哪里
把案例按“观察到什么、还能推出什么”重新排列,较容易看清剩余工作:
- FE 级记录中,LP 与 FEPC 出现同一个 Context 对象首地址,需要追查数据地址进入控制流的来源。
- MAP 显示 TaskA 与 TaskB 各有 2 KiB 栈,共享边界 B,没有额外 Guard 空间,存在重叠使用的物理条件。
- TaskB Dyn 中保存的 SP 为
B−0xE8。如果确认这是有效的切换保存值,就说明该次保存的栈指针超出了配置范围;它仍不是自动等同于 MEMF 瞬间的硬件 r3。 - TaskA 序言附近的寄存器与内存重复值,支持普通寄存器保存产生了那些栈数据。
- 尚缺少同一次运行中,TaskB 从哪个槽位恢复 r31、该槽位此前怎样被写入,以及随后如何进入异常的连续记录。
| 调查方向 | 当前能够保留的判断 | 还缺什么 |
|---|---|---|
| TaskB 越界,与 TaskA 栈重叠并损坏返回链 | 多项线索支持继续调查,最终因果未确认 | TaskB 对相关槽位的使用、覆盖和恢复全过程 |
| 其他路径让 r31 先变成错误目标 | 未排除;与“栈恢复错误”不天然互斥 | 跳转前 r31 最近一次赋值 |
| Context.LP 字段直接被写坏 | 是实现允许存在的传播路径,现场尚未证实 | 字段值变化及其写入指令 |
| 已审查保存/恢复偏移存在简单不对称 | 静态资料未发现该类差异 | 不能据此排除运行时对象指针错误或其他路径 |
| 已检查 EIC9 数组越界 | 当前编译组合下,这些已列访问在范围内 | 结论不扩展到所有 ISR 内存行为 |
| 现有观察点命中只是无关的正常写入 | 仍可能成立 | 它与最终被消费的坏值之间的关联 |
旧材料曾将“栈覆盖”和“寄存器直接跳转”分成依靠 JMP 方括号即可裁决的两种情况。指令语义核实后,这个分类需要改写;它不再提供原先声称的排他性证据。相邻布局和保存 SP 仍然有价值,但不能沿用错误判据把假设升级成结论。
8. 下一次复现,围绕一个坏值组织采样
与其增加更多互不关联的截图,不如追踪一个值从内存到寄存器再到 PC 的传播。
先在已定位的恢复/返回路径设置无条件代码断点,记录执行前后的 r31。若 r31 来自栈,就计算那次加载的有效地址;若来自 Context,就求出实际字段地址。随后将数据观察点移动到这个确定的源字,捕获它被改写的时刻,再核对写入者的 PC、SP、任务身份和数据寄存器。
每一次命中可保留下面这组记录:
Run / 镜像标识 / Core / 事件序号
停机原因与采样阶段
PC 与附近机器指令
SP、LP、相关基址寄存器
有效访问地址与实际宽度
写入数据,或加载前后的 r31
当前任务、被中断任务、对应的栈分配范围
后续消费该内存字的指令遇到 NOINIT 内容时,先确认该次 OS 启动是否完成初始化、水印是否由系统重填,以及是否确为新一轮运行。复位和下载对 RAM 的处理受芯片、启动代码和工具设置影响,不能一概认为全部清空或全部保留。需要重新填水印时,应通过受控初始化完成,不能覆盖正在使用的活动栈。
最后还要把修复验证接回故障链:即使栈扩容后暂时不再复现,也应确认峰值开销、嵌套路径和链接布局确已满足需求。当前资料没有提供这组回归结果,本文也不补写它。
9. MULTI 7.1.6 的表达式与命令逐项确认
本次操作记录里,输入 go 返回 Unknown name。这只能说明当前命令窗口和环境不接受这个 token;后续采用工具栏 Run/Resume 或本地帮助确认的命令即可,没必要继续套用其他调试器的习惯。
$r31 条件表达式和 uint32 类型也遇到过识别问题。前者取决于 MULTI 当前表达式环境的寄存器语法,后者取决于该作用域加载的类型信息。需要查看寄存器时,先无条件停住,在寄存器窗口读取;需要按地址读 32 位数据时,确认环境支持的类型宽度与表达式语法。MEM32 也应先确认本地确有定义,不能把它当作所有 MULTI 安装的统一内建函数。
数据断点通道有限,先保留一个能够解释的观察点,比叠加尚未核实的任务过滤、计数或 Rolling 设置更容易建立连续记录。命中之后,真正推进定位的是那条访问指令及其有效地址;调试器上的红点只是入口。
参考资料
- Renesas:RH850G4MH Virtualization User’s Manual: Hardware,
R01UH0865EJ0140Rev.1.40,寄存器模型;不以此推定案例启用了虚拟化。 - Renesas:RH850G4MH User’s Manual: Software,
R01US0209EJ0220Rev.2.20(2023-12-20),JMP、PREPARE 与相关恢复指令。JMP 见 2.2.3.37,PREPARE 见 2.2.3.66。 - Green Hills:MULTI Integrated Development Environment,数据/执行断点与多核调试能力概览。该页面不替代 MULTI 7.1.6 的本地命令和字段说明。
- Renesas:RH850/U2A 产品与文档,目标硬件异常原因、MPU 和调试资源的核对入口。