RH850 OS 上下文切换与栈保护:从 Context Save/Restore 到 MPU 栈边界
一次 RH850 内存异常中,FEPC 和 LP 都指向了一个任务 Context 对象的首地址。这个巧合很容易把排查带向“OS 恢复错了上下文”。但沿着 MAP、结构体布局和栈帧继续看,会发现几件不同的事:对象地址可以作为普通数据被压栈;LP 可以从栈上恢复,也可以从 Context 字段恢复;异常处理函数里看到的 SP,还可能已经属于内核栈。
理解这些关系,才能判断眼前的是被破坏的控制流、保存得很正常的数据,还是另一个时间点留下的现场。
本文讨论 RH850/U2A 的 G4MH 内核与一个 MICROSAR OS 实现。Context 字段和 Region0 用途来自该案例的静态资料,不构成 AUTOSAR 对所有实现的布局规定。示意代码只表达机制。
1. Context 把任务接回原来的执行位置
任务切换时,CPU 不会保留两套同时活动的普通寄存器。OS 必须把当前任务恢复执行所需的信息留在内存里,再将下一任务的状态装回硬件。对于独立栈任务,这些信息通常分散在两个地方:一个是 OS 管理的 Context 对象,另一个是任务自身的栈。
Context 负责找到恢复点和栈,栈帧则承载函数调用过程中保存的寄存器、局部变量与返回链。某个寄存器没有出现在 Context 结构里,往往意味着它由汇编入口、编译器调用约定或已有栈帧负责保存。判断“上下文是否保存完整”,必须沿实际切换入口检查,不能只数结构体成员。
还要区分首次启动与恢复执行。首次启动没有一条真实的历史调用链,OS 根据配置构造入口、初始 SP、PSW 和返回处理函数。任务被抢占或主动让出 CPU 后,恢复使用的是已经运行过的状态。相同的 LP 字段,在这两个阶段可能分别表示初始返回处理入口与某次切换保存的返回地址。
Config 与 Dyn 的分工
本案例的静态 Config 存在常量区,提供任务入口、返回处理入口、栈边界及状态初值;Dyn 位于可写 RAM,保存运行时可变状态。初始化将 Config 中的相关值写入 Dyn,后续切换更新或消费 Dyn。
把 NOINIT 段里的 Dyn 展开后,看到的值也需要绑定生命周期:OS 尚未初始化时可能是旧 RAM 内容,任务运行中可能是上次切出时的保存值,停在切换中间时还可能只有部分字段已经更新。字段可读,并不保证整份对象是同一瞬间的原子快照。
2. 九个字段连接四个层次
案例资料给出的 Os_Hal_ContextType 大小为 0x24,包含九个 32 位字段。以下偏移只用于解释这份实现;在另一个工程里,应通过类型定义和当前 ELF 的调试信息重新确认。初始化函数的赋值顺序本身不能证明结构体布局。
| 偏移 | 字段 | 初始来源 | 运行中的意义 |
|---|---|---|---|
+0x00 | PC | 任务 Entry | 恢复执行的位置,具体消费方式由启动/切换汇编决定 |
+0x04 | PSW | 配置状态字 | 恢复所需的处理器状态;应与现场实时 PSW 区分 |
+0x08 | PMR | 配置初值 | HAL 管理的中断屏蔽相关状态;不能仅凭字段名认定具体硬件寄存器 |
+0x0C | LP | ReturnAddress | 与 r31 对应的保存值 |
+0x10 | SP | 初始栈指针 | 与 r3 对应的保存值 |
+0x14 | MPLA0 | 栈低界 | 此实现为当前任务栈使用的 MPU 区域下界 |
+0x18 | MPUA0 | 配置栈高界 | 同一区域的上界编码 |
+0x1C | ASID | HAL 配置值 | 内存区域身份相关的抽象字段,需继续追到硬件写入宏 |
+0x20 | PARAM | 初始化参数 | 启动或调用时传递的参数,寄存器映射以 ABI 和入口实现为准 |
这九个字段连接了 CPU 寄存器、OS 软件对象、函数栈和 MPU 配置。它们不属于同一个抽象层。例如,PC 字段是内存中的软件状态,并非 CPU 始终自动同步的 PC 镜像;ASID 也是 HAL 的命名,不能直接套用其他 RH850 内核或其他架构同名机制。
案例中,这两个任务的 ASID 字段均配置为零。这不能证明“全局区域匹配已开启”或“MPU 已关闭”。硬件判断还需要区域属性、SPID/区域匹配设置、控制位以及实际执行模式。
对象地址与字段值各占一个位置
设 Context_A 的首地址为 C_A。在上述布局中:
PC 字段的地址 = C_A
LP 字段的地址 = C_A + 0x0C
LP 字段的内容 = 该地址中存放的 32 位值若异常时 FEPC = LP = C_A,目前确认的是:程序计数相关现场和 r31 都出现了这个数据对象的地址。它不能单独证明 Context_A.LP 被写坏,甚至不能证明被污染的就是 TaskA。
C_A 可能原本在一个普通寄存器里,随后正常压入某个任务栈;当另一个执行流错误地把同一物理槽位当作返回地址时,数据才变成了控制流。也可能确有指令改写了某个 Context 的 LP 字段。两条来源都需要继续追踪。
3. 沿保存、切栈与恢复理解执行顺序
案例资料中的调用链涉及 ContextInit、ContextSwitch、ContextStartNext、ContextSetStack 和底层 StartNextThread。它们分担不同阶段的工作,不能把每个函数都理解成“保存全部寄存器,再恢复全部寄存器”。
可以用下面的概念模型串起这些动作。它不是厂商源码,也不是可用于目标机的替代实现:
/* 概念示意:每一步实际由 C、汇编及调用约定共同完成。 */
SaveTaskFrames(current);
SaveContextState(current);
InstallProtectionFor(next);
RestoreContextState(next);
ResumeWithStackAndContinuation(next);先保存当前状态,是为了避免装载下一任务时覆盖尚未留存的硬件值。保护配置需要与即将使用的栈一致;但具体先后、同步指令、特权切换和异常屏蔽范围,必须以当前 HAL 为准。不能从这段模型推导安全关键代码应怎样改写。
审查恢复路径时,关键在于区分“取指针”与“取字段”。比如将 Next 本身装入 r31,与读取 Next 指向对象的 LP 成员后装入 r31,是两种完全不同的操作。静态资料显示案例路径读取 LP 字段;它排除了这一处把对象指针直接当 LP 的简单误读,却没有排除运行时的对象指针错误或字段提前损坏。
异常现场保存还有另一套布局。MEMF 入口通常要保存通用寄存器、FE 级异常状态及保护相关寄存器,随后才调用高级处理函数。它保存的内容、基址和偏移与任务 Context 不同。拿任务 Dyn 的 +0x0C 去解释异常缓冲区,会把不同类型的对象混在一起。
4. 栈边界、MPU 上界与 SP 各有自己的语义
把案例的共享边界记为 B,MAP 中两个 2 KiB 栈可表示为:
| 任务 | 链接分配范围 | 向下越界后的落点 |
|---|---|---|
| TaskA,位于低地址 | [B−0x800, B) | 更低地址空间 |
| TaskB,位于高地址 | [B, B+0x800) | TaskA 的栈区 |
两个分配区间首尾相接,中间没有额外预留的 Guard 空间。TaskB 向下增长时,一旦越过 B,后续栈访问的地址就可能进入 TaskA 的物理范围。
这里需要同时核对三类数值:链接器给出的字节分配区间、OS 宏产生的初始 SP,以及 MPU 上界寄存器的编码。半开区间的末端、最后一个 32 位字的地址和 MPU 实际覆盖的最后一个字节,不一定相等。资料中出现“分配末端减 4”的高地址值,不能因此直接判定少分配了 4 字节。
此实现将任务栈边界写入 MPU Region0,并在切换时更新区域。Region0 只是该实现选择的用途,不是 RH850 架构固定规定的“栈专用区域”。关于 MPLA/MPUA/MPAT 的硬件定义,应回到对应 U2A 硬件手册的 CPU/MPU 部分;涉及虚拟化时还要确认当前区域银行和执行模式。[1][2]
SP 低于边界不等于执行该指令时已触发保护
SP 是一个寄存器。一次仅改变 SP 的算术操作,并不必然发起访存;CPU 通常在后续实际加载、存储或取指时检查相应访问权限。即使发生访存,还要考虑 MPU 是否使能、当前特权、其他有效区域及访问属性。
因此,“保存的 SP 低于这个任务的栈低界”可以作为栈状态异常的证据,但“该次 SP 更新必然立即触发 MEMF”并不成立。相邻栈也不意味着保护必然失效;如果当前执行上下文没有邻栈写权限,越界写可能在造成覆盖之前被拦下。
案例的一份记录给出 TaskB Dyn 中的 SP 为 B−0xE8,配置的栈低界为 B,相差 232 字节。这是值得追查的越界保存值。要将它升级为“本次故障瞬间 TaskB 的真实 SP”,还需确认 Dyn 已初始化、属于当前镜像,并弄清它是在何次切换中保存的。
5. 任务栈、ISR 栈与异常处理栈一起核对
CAT1/CAT2 描述 OS 对中断的管理方式,不能直接决定 ISR 用哪个物理栈。硬件异常接收、编译器生成的序言和 OS 包装层也各有职责。RH850 接收异常时保存相应系统状态;普通寄存器压栈通常由软件入口完成,不能把所有栈写入都称作“硬件自动压栈”。
本案例能从已有汇编资料确认两点:一个 CAT2 包装入口先使用进入时的 SP 保存部分状态;MEMF 处理路径保存原现场后切到内核栈。前者只证明这一层入口的行为,不能排除更深层再次切栈;后者说明停在高级异常处理函数时的实时 SP,可能已与故障 SP 不同。
EIC9 的 CAT1 分类已经由编译分支和中断配置支持,但现有资料不足以给出它整个入口链的切栈结论。若要评估 ISR 对任务栈的额外开销,应在目标镜像反汇编中查找保存序列与 SP 更新,再由入口前后的运行时快照确认。CAT1 的保护边界也不同于 OS 管理的 CAT2,上层规范对此有明确限制。[3]
6. 让栈测量与保护覆盖不同的风险
栈规划需要同时考虑任务调用最深路径、编译器保存开销、OS 切换帧、可发生的 ISR 嵌套和异常入口。只测一个正常业务周期,很容易漏掉初始化深调用、错误处理路径和中断叠加。
栈水印提供历史使用痕迹,MPU 对实际访问施加约束,两者适合一起使用。水印也有边界:SP 可能跨过一段空间却未逐字写入,写入值可能恰好等于填充值,检查可能尚未执行,旧 NOINIT 内容也可能混入结果。一次水印检查未报警,只能说明该检查没有报告越界。
Guard 区同样需要落实到保护配置。链接器留出空隙能够降低邻栈立即被覆盖的风险;要让越界访问及时异常,还应确认空隙没有被其他宽泛可写区域重新覆盖。完成栈扩容后,也要重看链接布局,而不是沿用旧观察点地址。
一次有用的现场记录,应至少将当前核、任务身份、实时 r3/r31、任务 Dyn、故障保存缓冲区与 MPU 状态放在同一采样说明里。检查两个 Context 是否相邻、栈是否相邻,只是定位对象关系的起点;真正决定故障的,是哪个执行流在什么权限下写了哪个槽位,以及后续谁消费了它。
接下来可读 ECM EIC9 的错误通知过程。对返回地址来源的逐指令追踪,见 GHS Watchpoint 与汇编定位。
参考资料
- Renesas:RH850/U2A 产品与文档入口;页面列出 U2A-EVA Hardware Manual
R01UH0864EJ0150Rev.1.50(2024-08-30)。本文核实了文档入口,未取得该 44.95 MB 手册全文;具体 MPU 位域与器件差异仍需用目标器件手册核对。 - Renesas:RH850G4MH Virtualization User’s Manual: Hardware,
R01UH0865EJ0140Rev.1.40,MPU 寄存器与区域银行的补充说明;引用它不表示案例已启用虚拟化。 - AUTOSAR:Specification of Operating System,CP R24-11,OS-Application 保护与 Category 1 ISR 的边界。此版本用于公开机制对照,不代表案例软件版本。