diff --git a/docs/zhihu/part0b-ai-workflow.md b/docs/zhihu/part0b-ai-workflow.md index cca54cc..bedcf80 100644 --- a/docs/zhihu/part0b-ai-workflow.md +++ b/docs/zhihu/part0b-ai-workflow.md @@ -71,7 +71,7 @@ git 历史诚实地反映了这一点。早期的 commit 是杂乱的大块头 - **构建命令**:每个 `make` 目标对应什么功能、开启什么编译选项 - **内存布局**:hypervisor、guest 内核、设备,每一段地址的用途 - **关键坑位**:"用 HPFAR_EL2 不要用 FAR_EL2"、"永远不要改 guest 的 SPSR_EL2(处理器状态寄存器),否则内核自旋锁死锁"、"inject_spi() 不能拿 DEVICES 锁,否则死锁" -- **测试清单**:33 个测试套件,每个有多少断言 +- **测试清单**:34 个测试套件,每个有多少断言 这个文件是怎么长出来的?模式永远一样: @@ -177,10 +177,10 @@ AI 对此毫无感觉。它看到测试绿了就认为搞定了。**人的职责 30 天的粗略统计: - **193 个 commit**(平均每天 6.4 个) -- **约 282 个测试断言**,分布在 33 个测试套件里 +- **约 457 个测试断言**,分布在 34 个测试套件里 - **人的贡献**:约 30%——架构决策、翻 ARM 手册、调试集成问题、review 每一行 AI 生成的代码 - **AI 的贡献**:约 70%——代码生成、写测试、模板代码、初步调试方向 -- **CLAUDE.md**:从 0 行增长到 500 多行 +- **CLAUDE.md**:从 0 行长到 500 多行,后期回头精简压到 ~280 行(信息密度优先) 但 30/70 这个比例有误导性。那 30% 的人类工作承担了不成比例的**承重**——就像一座桥,70% 的材料是混凝土,但承重的是那 30% 的钢筋。如果钢筋不对,混凝土再多也会塌。 diff --git a/docs/zhihu/part4-war-stories.md b/docs/zhihu/part4-war-stories.md index ce91f44..c64bdd8 100644 --- a/docs/zhihu/part4-war-stories.md +++ b/docs/zhihu/part4-war-stories.md @@ -1,174 +1,133 @@ -# Rust 裸机四大坑 — 在 ARM EL2 写 Hypervisor 踩过的雷 +# 裸机四大坑 · 收尾篇 —— ARM 规范没写、TF-A 源码里挖出来的 SPMD per-CPU 握手 ## 写在前面 -前三篇讲了怎么从零开始写一个 ARM64 hypervisor:四个文件启动 EL2、陷入-模拟-恢复循环、让 Linux 跑起来。那些是"怎么做对"的故事。 +这是"裸机四大坑"系列的最后一篇。前三个坑后来都各自展开过独立的深度版,这篇专门补完第四个,顺便收个尾。 -这篇讲"怎么做错"的故事。 +为什么要拆?因为越往后查,越发现每个坑单独都够撑一篇长文,合在一起反而看不清各自的根因层。所以现在的分布是: -项目进入 Phase 4 之后,hypervisor 从 NS-EL2(Normal World)扩展到了 S-EL2(Secure World),变成了一个真正的 SPMC(Secure Partition Manager Core),跟 Google 的 pKVM 跑在同一颗芯片上。这个阶段的 bug 有一个共同特点:**它们都不报错**。没有 panic,没有 fault,没有任何输出。CPU 只是停在那里,或者你的数据默默地变成了零。 +| 坑 | 根因所在层 | 在哪篇 | +|---|---|---| +| 一、`debug_assert!` 里藏 NEON 指令(SIMD trap) | 编译器代码生成 | [Part 7: 裸机 Rust 的三个"Rust 没问题,硬件有话说"的坑](./part7-bare-metal-rust-pitfalls.md) | +| 二、写成功了但读回全是零(NS bit) | 物理地址空间属性 | [Part 6: TrustZone 的 NS 位不只是权限,更是物理地址空间的选择](./part6-trustzone-ns-bit.md) | +| 三、70% 正常 30% Data Abort(跨核共享 buffer) | 跨 CPU 缓存一致性 + 同上 Part 7 的 SMC 屏障章节 | [Part 7](./part7-bare-metal-rust-pitfalls.md) | +| **四、secondary CPU 永远挂起(SPMD per-CPU 状态机)** | **固件状态机** | **本篇** | -以下四个 bug,每个都花了至少半天到一天才定位。它们的根因分布在四个完全不同的层面:编译器代码生成、物理地址空间模型、跨 CPU 缓存一致性、固件状态机。 +如果你刚跟下来 Part 6 和 Part 7,这篇是最后一块拼图。如果你是直接进来的,后面的引用都点得开。 -如果你在做 Rust no_std / 裸机 / ARM 底层开发,这些坑迟早会遇到。 +四个坑有一个共同点——**它们在你的层面看起来都是对的**。代码逻辑对、地址对、调用顺序对。但在更低的层面(编译器、MMU、cache、固件),有一些你不知道的约束正在被违反。前三个我已经把那"更低的层面"讲清楚了,第四个的"更低层"是 TF-A 的 SPMD 状态机——而它**根本没出现在任何 ARM 规范里**。 --- -## 坑一:编译器往你代码里塞了 SIMD 指令 +## 现象:CPU 0 起来了,1/2/3 永远挂起 -**现象**:SPMC 在 release 模式下正常启动,换成 debug 模式就死在第一次 `read_volatile` 调用。没有任何输出,串口完全静默。 +进入 Phase 4.5 集成 pKVM 之后,启动序列是这样的: -**排查过程**: - -GDB attach 上去,发现 CPU 卡在 EL3 的异常处理函数里死循环。查 `ESR_EL3`,异常类型是 FP/SIMD trap(EC=0x07)。 - -但我的代码没有用浮点数。 - -反汇编 `read_volatile` 的调用点,找到了罪魁祸首: - -```asm -cnt v0.8b, v0.8b ; NEON SIMD population count ``` - -这是 Rust debug 模式下 `read_volatile` 内部的对齐检查代码。编译器为了计算 popcount,选择了一条 NEON 指令。在有操作系统的环境里这完全没问题——操作系统会在启动时使能 FP/SIMD。但在 bare-metal 环境下,我们跑在 TF-A 固件之上。TF-A 的默认配置是 `CPTR_EL3.TFP=1`,意思是:**从任何异常级别执行 FP/SIMD 指令都会陷入到 EL3**。 - -EL3 的默认异常处理并不知道怎么处理这个陷入,于是进入死循环。不报错,不 print,CPU 就静静地转圈。 - -**根因**:在操作系统之下写代码,**编译器的代码生成是硬件契约的一部分**。你不用浮点不代表你的二进制里没有浮点指令。Rust 的 debug 模式会插入大量安全检查(对齐、溢出),这些检查的实现可能用到 SIMD。 - -**修复**:在 TF-A 编译时加一个 flag: - -```makefile -CTX_INCLUDE_FPREGS=1 +EL3 TF-A BL31 + SPMD ← ARM 安全监控器 +S-EL2 我们的 SPMC ← 管理 Secure Partition +S-EL1 SP1/SP2/SP3 ← 三个秘密分区 +NS-EL2 pKVM ← Google 的 protected KVM +NS-EL1 Linux/Android ← guest 内核 ``` -这会让 TF-A 在上下文切换时保存/恢复 FP 寄存器,同时清除 `CPTR_EL3.TFP`,不再 trap FP/SIMD 指令。需要同时设置 `ENABLE_SVE_FOR_NS=0` 和 `ENABLE_SME_FOR_NS=0`,否则 TF-A 构建会报冲突。 - -**教训**:如果你的 bare-metal Rust 程序在 debug 模式下莫名其妙挂掉,但 release 模式正常——**先查 SIMD trap**。反汇编你的二进制,搜索所有 `v0`-`v31` 寄存器引用。这不是 Rust 独有的问题,Clang/GCC 的 debug 模式同样可能插入 NEON 指令,但 Rust 因为 safety check 更多,触发概率更高。 - ---- - -## 坑二:写成功了,但数据不在那里 - -**现象**:`PARTITION_INFO_GET` 这个 FF-A 调用,从 BL33 测试程序(跑在 NS-EL2)调用完全正常,SPMC 往调用者的 RX buffer 写 SP 描述符,调用者读回来,24 字节一个 partition,数据完全正确。 - -换成 pKVM 来调同一个函数。同样的代码路径,同样的描述符格式。pKVM 读回来——**全是零**。 +QEMU `secure=on,virtualization=on` 起来,日志看着不错: -**排查过程**: +- BL31 v2.12.0 启动 ✓ +- 我们的 SPMC 在 S-EL2 起来 ✓ +- SP1/SP2/SP3 各自 boot 到 Idle ✓ +- pKVM 在 NS-EL2 起来,`Protected hVHE mode initialized successfully` ✓ -GDB 确认写入成功了(没有 fault),地址也对(0x42a16000,就是 pKVM 注册的 RX buffer 地址)。数据写进去了,但读的时候不见了。 +然后 pKVM 启动 secondary CPU——`smp: Bringing up secondary CPUs ...`——**永远停在这里。** CPU 1 / 2 / 3 一个都不上来,pKVM 直接卡死等待 PSCI CPU_ON 的回复。 -这不是缓存问题,不是对齐问题,不是时序问题。是**物理地址空间属性**的问题。 +奇怪的是 EL3 / S-EL2 / SP 这条链上一切看起来正常。我们的 SPMC 没崩,SP 也没崩,日志没任何错误。CPU 0 上的所有功能都好用——FF-A discovery、PARTITION_INFO_GET、DIRECT_REQ 都过得去。问题只在 secondary CPU 起不来。 -ARM 的内存事务带一个 `NS` 属性位。架构上,TrustZone 定义了**两个独立的物理地址空间**:Secure 和 Non-secure。同一个数值地址 `0x42a16000` 在两个空间下是两个不同的架构地址,事务的 `NS` 属性决定这次访问归哪个空间——具体怎么裁决由内存系统(TZASC、TZC 系列、或 SoC 自己的 NS 控制器)实现,可能落到同一颗 DRAM 的不同区段,也可能是真正分离的存储。架构层面要记住的是:**这是两个不同的物理地址空间**。 - -我们的 SPMC 跑在 S-EL2,当时 MMU 关着。MMU 关闭时,S-EL2 发出的所有内存访问默认都是 `NS=0`——走 Secure 物理地址空间。pKVM 注册的 RX buffer 在 Non-secure 物理地址空间,只接受 `NS=1` 的事务。 +--- -所以 SPMC 往 `0x42a16000` 写——事务带 `NS=0`,走 Secure 物理地址空间,成功落地。pKVM 从 `0x42a16000` 读——事务带 `NS=1`,走的是 Non-secure 物理地址空间,里面还是原来的值。两边都"访问成功"了,但从架构看根本走的是两个不同的地址空间。 +## 排查:FF-A 规范只字未提 secondary CPU -```text -SPMC 写入: 0x42a16000 NS=0 → Secure 物理地址空间 ← 数据落在这里 -pKVM 读取: 0x42a16000 NS=1 → Non-secure 物理地址空间 ← 这里是空的 -``` +第一反应是 PSCI CPU_ON 的转发出了问题——pKVM 在 NS-EL2 发 `smc #0` 调 PSCI_CPU_ON,TF-A BL31 的 PSCI 服务收到后应该执行物理 CPU 上电。这条路径不经过我们的 SPMC,理论上跟 SPMC 无关。 -**修复**:给 S-EL2 启用 Stage-1 MMU,建立一个恒等映射(identity map),把所有 Normal World DRAM 区域标记为 `NS=1`: +但日志显示 PSCI 调用确实**进了 EL3** 又**没出来**。那就是 BL31 内部出了问题。 -```rust -// src/sel2_mmu.rs -const NS_BIT: u64 = 1 << 5; +翻 FF-A v1.1 规范(DEN0077A)——找"secondary CPU"。**只字未提。** 找"CPU_ON"——只在 PSCI 章节出现,跟 SPMC 完全切割。找"warm boot"——零结果。规范完整描述了: -// L1[1]: 0x40000000-0x7FFFFFFF — NWd DRAM, NS=1 -const NORMAL_NS_XN: u64 = ATTR_NORMAL_WB | NS_BIT | AP_RW | SH_ISH | AF | XN; -``` +- 怎么发 FFA_VERSION 握手 +- 怎么 RXTX_MAP 注册邮箱 +- 怎么 PARTITION_INFO_GET 列 SP +- DIRECT_REQ / MEM_SHARE 的协议格式 -当 S-EL2 的 Stage-1 页表里 NS bit 为 1 时,硬件会把这个地址的访问路由到 Non-Secure 物理地址空间。写入才能真正到达 pKVM 的内存。 +但 secondary CPU 怎么进入 Secure World、SPMC 这边要不要做什么准备——**完全没写**。规范在"Lifecycle"章节只讲了 primary CPU 的初始化(`FFA_MSG_WAIT` 握手通知 SPMD 自己就绪),对 secondary 一句话也没有。 -**教训**:Secure/Non-Secure 不只是权限模型,它在架构上对应**两个独立的物理地址空间**,事务的 `NS` 属性决定这次访问归哪边。跨世界通信的时候,正确的地址数值对上错误的 `NS`,写和读就会各走各的地址空间——两边都没报错,但谁也读不到对方的数据。 +合理的猜测:secondary 是不是也要做同样的 `FFA_MSG_WAIT`?但谁调用?在哪里调用?入口点怎么注册? -如果你在做跨世界(Secure ↔ Non-Secure)共享内存,**第一件事是确认 S-EL2 这边的 Stage-1 MMU 已经启用,并且 NWd DRAM 区域的 PTE 打了 `NS=1`**。 +规范这里就断了。只能去读源码。 --- -## 坑三:70% 的时候正常,30% 的时候 Data Abort - -**现象**:pKVM 的 `MEM_SHARE`(FF-A 内存共享)大约 70% 的时间能正常工作。剩下 30%,SPMC 崩溃,Data Abort 异常,fault 地址是类似 `0x240f` 这样的值——明显不是一个合法的物理地址。 - -**排查过程**(这部分讲的是修 parser 边界检查**之前**的旧症状): - -`addr2line` 定位到 `parse_mem_region` 函数——FF-A 内存描述符解析器。描述符里的 `composite_offset` 字段应该是 80,但读出来是一个看起来随机的值。当时的 parser 还没把 offset/长度的边界检查做严,离谱的 offset 直接被拿去做 `base + offset` 的指针运算 → Data Abort。每次测试拿到的"垃圾值"都不一样。后来这套 parser 单独加了 bounded check(看 `src/ffa/descriptors.rs`),所以现在哪怕读到坏数据也只会返回 `FFA_INVALID_PARAMETERS`,不再崩——但根本问题(**为什么会读到坏数据**)还是要解决。 - -描述符放在 pKVM 的 TX buffer 里——这是 Normal World DRAM。问题出在这条链上: - -```text -pKVM (CPU 0): 写描述符到 TX buffer → SMC → 进入 EL3 -SPMD (EL3): 切到 S-EL2 -SPMC (S-EL2): 读 TX buffer → 解析描述符 +## 翻 TF-A 源码:`spmd_cpu_on_finish_handler` + +TF-A 的代码在 `services/std_svc/spm/spmd/` 目录下。grep `cpu_on`,找到这个函数: + +```c +// services/std_svc/spm/spmd/spmd_main.c +static void *spmd_cpu_on_finish_handler(const void *arg) +{ + /* On every CPU, after PSCI CPU_ON completes, SPMD needs to + * activate the SPMC on this CPU. The SPMC must respond with + * FFA_MSG_WAIT to indicate it's ready before SPMD returns + * control to the Non-Secure caller. */ + ... + spmd_spm_core_sync_entry(ctx); + ... +} ``` -这里的陷阱是:SPMC 的**后续处理可能被调度到另一个 pCPU 上跑**。比如 pKVM 从 CPU 2 发起的一个 FFA_RUN 让 SPMC 恢复了 SP,SP 在 CPU 2 的 S-EL2 上读那段由 pKVM 在 CPU 0 写过的 TX buffer——CPU 2 这边的 cache line 状态不一定反映 CPU 0 的最新写。需要先讲清楚一点:**`dsb` 屏障只 order 执行它的那个 CPU 的访问**,它不会"反向"去 flush 别人 L1。让 pCPU 0 的写最终对 pCPU 2 可见,靠的是 ARM 的 Inner Shareable cache coherency 协议;写入方在合适时机做 `dsb ish/sy`、读取方按需做 `dsb` + 必要的 cache 维护,两边凑齐才完整。单凭一条 `smc` 既不是屏障,也不触发任何一致性流程。 +读注释 + 配合状态机看明白了:**SPMD 为每个物理 CPU 维护完全独立的状态机**。每次 PSCI CPU_ON 在某个 secondary 上完成,SPMD 在那个 CPU 上做一次 "S-EL2 进入"——它会 ERET 到我们预先**注册过的 secondary entry point**,然后**阻塞**等我们调 `FFA_MSG_WAIT` 回去。只有等到这次握手,SPMD 才会继续 secondary 的 PSCI CPU_ON,通知 pKVM"这个 CPU 起来了"。 -第一次尝试:读 TX buffer 之前加 `DSB SY`(全系统范围的数据同步屏障)。 +也就是说,每个 CPU 的启动流程都是: -```rust -unsafe { core::arch::asm!("dsb sy", options(nostack, nomem)); } ``` - -仍然偶发失败——这其实可以预料。读侧的 `dsb sy` 只能 order SPMC 自己 CPU 上的访问;写入侧(pKVM)写完有没有做正确的屏障,是我控制不了的代码。靠一条读侧 barrier 不可靠,得换个思路。 - -**最终修复**:DSB + 整块拷贝到本地缓冲区,然后只从本地缓冲区解析: - -```rust -// src/spmc_handler.rs -unsafe { core::arch::asm!("dsb sy", options(nostack, nomem)) } -let mut local_buf = [0u8; 4096]; -unsafe { - core::ptr::copy_nonoverlapping( - tx_pa as *const u8, - local_buf.as_mut_ptr(), - total_length, - ); -} -// 绝不直接解析共享 buffer——所有解析都在本地副本上 -let parsed = parse_mem_region(local_buf.as_ptr(), total_length); +CPU N PSCI CPU_ON 发起 (pKVM 在 EL2) + → SMC 进 EL3,TF-A BL31 接到 + → BL31 PSCI 服务上电 CPU N + → BL31 ERET 到 EL3 入口,把 CPU N 转交给 SPMD + → SPMD 在 CPU N 上 ERET 到 SPMC 的 secondary entry (我们注册的) + → SPMC 在 CPU N 上初始化必要的 EL2 状态 + → SPMC 调 FFA_MSG_WAIT ← 这一步告诉 SPMD:"CPU N 的 Secure 侧就绪" + → SPMD 收到 WAIT,继续 PSCI CPU_ON 的后半段 + → ERET 回 EL2,pKVM 看到 CPU N 起来了 ``` -原理(小心别多承诺):`copy_nonoverlapping` 把共享 buffer 的字节先一次性挪到本地副本——拷贝过程仍可能横跨多个新旧 cache line,**它不保证你拿到的是 producer 那个时刻的快照**。但有一点变了:解析器从此只读这份不会再变的本地字节流。bounded parse 跑在固定字节流上,要么解析成功,要么 offset/长度越界返回 `FFA_INVALID_PARAMETERS`——不会去追共享 buffer 里的指针、不会因为再次读时字节又变了而崩。加上这段修复后,测试里不再复现原来那种间歇性 Data Abort。 - -**教训**:跨世界、跨 CPU 的共享内存原地解析是个坏习惯。跨安全世界的共享属性 / 缓存一致性的实际效果跟你在 Normal World 里习惯的那套不一样。防御性做法是:**`dsb sy` + 先拷到本地、解析只读本地**。这个 pattern 不解决跨核可见性问题,但它把"读到坏数据"的后果从"不可恢复的 Data Abort"降级成"返回错误码"——后者要好 debug 几个数量级。 - ---- - -## 坑四:SPMD 是 per-CPU 的(或者说:去读固件源码) - -**现象**:pKVM 在 CPU 0 上正常启动。Secondary CPU(CPU 1/2/3)全部挂起,永远无法完成 PSCI CPU_ON。 - -**排查过程**: - -FF-A 规范描述了 SPMC 的初始化流程,但对 secondary CPU 几乎只字不提。规范告诉你**做什么**,不告诉你**怎么串起来**。 - -花了几个小时读 TF-A 的源码(不是文档,是 C 代码),找到了 `spmd_cpu_on_finish_handler()` 函数。真相是:**SPMD 为每个物理 CPU 维护完全独立的状态**。每个 secondary CPU 进入 S-EL2 之后,必须调用 `FFA_MSG_WAIT` 完成一个握手——这个握手告诉 SPMD:"这个 CPU 的 Secure World 已经就绪了。"如果任何一个 CPU 跳过了这个握手,SPMD 就不会完成对应的 PSCI CPU_ON 调用,于是 Normal World 的 secondary CPU 也永远启动不了。 +而我最初的代码:**只有 primary CPU 做了 `FFA_MSG_WAIT`,根本没注册 secondary entry**。secondary CPU 进入 S-EL2 之后,SPMD 不知道往哪 ERET,直接挂在那里 → pKVM 看到的就是 secondary 永远不上来。 ``` CPU 0: SPMC init → boot SPs → FFA_MSG_WAIT ✓ → SPMD 完成 → pKVM 启动 -CPU 1: ??? → 没有 FFA_MSG_WAIT → SPMD 阻塞 → pKVM CPU_ON 挂起 -CPU 2: ??? → 同上 -CPU 3: ??? → 同上 +CPU 1: ??? → 没有 secondary entry → SPMD 阻塞 → pKVM CPU_ON 挂起 +CPU 2: ??? → 同上 +CPU 3: ??? → 同上 ``` -我最初的代码让 secondary CPU 做了 `WFE`(Wait For Event)然后等待——这是 Normal World 的标准模式。但在 Secure World 里,SPMD 需要 per-CPU 的握手。 +--- + +## 修法:三步 -**修复**:关键有三步。 +### 第一步:注册 secondary entry point -第一步,在 SPMC 初始化阶段注册 secondary CPU 的入口地址: +primary CPU 初始化完成后,调用 `FFA_SECONDARY_EP_REGISTER`(0x84000087)把 secondary entry 的物理地址告诉 SPMD: ```rust -// src/main.rs — SPMC init +// src/main.rs — SPMC init,primary CPU 路径 extern "C" { fn secondary_entry_sel2(); } let ep = secondary_entry_sel2 as *const () as usize as u64; let result = forward_smc8(FFA_SECONDARY_EP_REGISTER, ep, 0, 0, 0, 0, 0, 0); ``` -第二步,给每个 secondary CPU 分配独立的栈(3 × 32KB,在 `.bss.sel2_pcpu_stacks` 段): +这个调用必须在 primary 完成所有初始化、做第一次 `FFA_MSG_WAIT` 之前发出去,否则后续 secondary 上电时 SPMD 根本不知道往哪跳。 + +### 第二步:给每个 secondary 准备独立的栈 + +S-EL2 跑在裸机,栈得自己分配。primary 用的是 `boot_sel2.S` 里的 `_stack`。secondary 必须用独立栈,否则它们 boot 起来会互相踩 primary 的栈帧。 ```asm // arch/aarch64/boot_sel2.S @@ -178,52 +137,89 @@ sel2_pcpu_stacks: .space 3 * 32 * 1024 // CPU 1, 2, 3 各 32KB ``` -第三步,每个 secondary 上电后**按顺序**初始化 EL2 执行环境,`FFA_MSG_WAIT` 握手,然后进入 per-CPU 事件循环: +`secondary_entry_sel2` 入口先做的事就是按 `MPIDR_EL1` 的 CPU index 算出本核的栈顶,装进 SP: + +```asm +secondary_entry_sel2: + mrs x0, mpidr_el1 + and x0, x0, #0xff // CPU index (Aff0) + sub x0, x0, #1 // CPU N → 栈 index N-1 + adrp x1, sel2_pcpu_stacks + add x1, x1, :lo12:sel2_pcpu_stacks + mov x2, #(32 * 1024) + madd x0, x0, x2, x1 // 栈底 = base + N*32KB + add sp, x0, x2 // SP = 栈底 + 32KB(向下增长) + bl rust_main_sel2_secondary +``` + +(这里硬假设了 Aff0 ∈ {1,2,3},生产代码要做边界检查;QEMU virt 4 核场景下够用。) + +### 第三步:secondary 上的初始化顺序(顺序不能乱) + +`rust_main_sel2_secondary()` 是 secondary CPU 进入 Rust 后做的事: ```text -CPU 1..3: secondary_entry_sel2 - → 设置每 CPU 栈 - → 安装 VBAR_EL2(exception::init) - → 开 HCR_EL2.VM(Secure Stage-2) - → 清 CPTR_EL2 / MDCR_EL2 的 trap 位(FP、SVE、SME、debug) - → 复用 primary 的 S-EL2 Stage-1 页表(install_sel2_stage1_secondary) - → 开该 CPU 的 GICR PPI 26/29(poll 定时器 + 安全物理 timer) - → FFA_MSG_WAIT ← 握手,SPMD 放行该 CPU 的 PSCI CPU_ON - → run_event_loop() ← 持续处理后续到达该 CPU 的 SMC 请求 +1. 安装 VBAR_EL2(exception::init) ← 异常向量到位,否则后面任何 trap 都不可恢复 +2. 清 CPTR_EL2 / MDCR_EL2 的 trap 位 ← 把 FP/SVE/SME/debug 的 trap 关掉,防止后面 isb 自陷 +3. 复用 primary 的 S-EL2 Stage-1 页表 ← install_sel2_stage1_secondary() + (页表是 primary 装好的,secondary 只需要把 TTBR0_EL2/TCR_EL2/MAIR_EL2 + SCTLR_EL2.M 打开) +4. 开 HCR_EL2.VM ← Secure Stage-2 开始生效 +5. 开本 CPU 的 GICR PPI 26/29 ← poll 定时器 + 安全物理 timer 中断使能 +6. FFA_MSG_WAIT ← 关键握手!告诉 SPMD 本 CPU 的 Secure 侧已就绪 +7. run_event_loop() ← 持续处理后续到达本 CPU 的 SMC 请求 ``` -**顺序不能乱**:必须先放开 trap(CPTR/MDCR)再开 MMU,否则某些 TF-A 配置下会在打开 Stage-1 的 `isb` 处直接陷入 EL3。 +**顺序不能乱**——必须先放开 trap(CPTR/MDCR)**再**开 MMU。某些 TF-A 配置下,如果 CPTR 还没放开就走到 `SCTLR_EL2.M=1` 后面的 `isb`,会在 `isb` 处直接 trap 进 EL3,而我们的 EL3 默认 handler 不知道怎么处理这个 trap → 永久挂死。我第一次写错的就是这个顺序,排查又花了几小时。 -`FFA_MSG_WAIT` 只是入口;真正让 secondary 持续可用的是后面那个 `run_event_loop()`——每个 CPU 都要留在自己的事件循环里处理后续请求,否则下一次 pKVM 在这个 CPU 上发 SMC,SPMC 不会响应。 +`FFA_MSG_WAIT` 只是"入场券"。真正让 secondary 持续可用的是后面那个 `run_event_loop()`——每个 CPU 都得留在自己的循环里处理后续 SMC,否则下一次 pKVM 在这个 CPU 上发 FF-A 调用,SPMC 不会响应,直接超时。 -**教训**:ARM 的规范文档(DEN0077A 等)是"接口定义",不是"实现指南"。它告诉你有哪些 SMC 调用、参数格式、返回值。但怎么把它们串起来——什么时候调用、在哪个 CPU 上调用、调用顺序——你得去读固件源码。 +--- -TF-A 的代码是事实上的参考实现。`services/std_svc/spm/` 目录下的 SPMD 代码比规范文档的"Lifecycle"章节信息量大得多。如果你要跟 TF-A 打交道,把它的源码当文档读。 +## 教训:规范 vs 源码 + +回到那个让我卡了几小时的问题:**为什么 ARM 规范不写这件事?** + +我现在的理解是:ARM 规范文档(DEN0077A FF-A、ARM ARM 等)定位是**"接口定义"** —— 它告诉你有哪些 SMC 调用、参数格式、返回值、状态转换。但**怎么把这些调用串成一个能跑的系统**——什么时候调、在哪个 CPU 上调、调用顺序——属于"实现指南"层,规范刻意不写。理由也合理:实现可以有不同选择,规范只锁接口语义,留实现自由。 + +代价是:**事实上的参考实现就是规范的延伸**。TF-A 的 `services/std_svc/spm/spmd/` 整个目录是 SPMD 的真实状态机,信息量比规范的"Lifecycle"章节大几个数量级。规范告诉你"SPMC 在每个 CPU 上要 ready",源码告诉你"通过 `FFA_MSG_WAIT` 握手通知 SPMD,SPMD 会阻塞等这一刻"。前者是 1 行抽象,后者是 200 行状态转换。 + +实战 checklist: + +- **跟 ARM 固件打交道,把 TF-A 源码当文档读**。`services/std_svc/spm/` 是 SPM 的源头,`services/std_svc/spm/spmd/spmd_main.c` 是 SPMD 主入口 +- **遇到"规范没说但显然得做"的事**,grep TF-A 源码里相关的 handler 名字(`*_on_finish_handler`、`*_off_handler`、`*_suspend_handler`) +- **secondary CPU 永远是最容易踩坑的地方**——大多数规范默认讲 primary 流程,secondary 的额外约束散落在源码里 +- 如果你写 SPMC、TEE OS 或任何 S-EL2 软件,**先把 `spmd_cpu_on_finish_handler` 整段读一遍** --- -## 总结 +## 四大坑的共同模式 -| 坑 | 层面 | 表现 | 根因 | -|----|------|------|------| -| SIMD trap | 编译器代码生成 | Debug 模式静默挂起 | Rust 安全检查编译为 NEON 指令,被 CPTR_EL3.TFP trap | -| NS bit 写入 | 事务属性 | 数据写成功但读回全零 | Secure/Non-secure 是两个独立物理地址空间,S-EL2 Stage-1 要打 NS=1 | -| 幽灵失败 | 共享内存解析 | 间歇性 Data Abort(旧 parser)→ 间歇性 INVALID_PARAMETERS(修过 bounds check 后) | 跨 CPU/跨世界共享 TX buffer 不能原地解析,需 `dsb sy` + 本地拷贝 | -| SPMD per-CPU | 固件状态机 | Secondary CPU 永远挂起 | 每个 CPU 都要完成 `FFA_MSG_WAIT` 握手 + 进自己的事件循环,规范没写 | +回头看四个坑: -四个 bug 有一个共同点:**它们在你的层面看起来都是对的**。代码逻辑对,地址对,调用顺序对。但在更低的层面(编译器、MMU、cache、固件),有一些你不知道的约束正在被违反。 +| 坑 | 你以为对的层面 | 真正在被违反的层面 | +|---|---|---| +| 一、SIMD trap | "我没写浮点,二进制里就没有浮点指令" | LLVM 把 popcount 降成 NEON,而 `CPTR_EL3.TFP=1` 在 trap 这些 | +| 二、NS bit | "0x42a16000 是个物理地址,读写都到那" | Secure / Non-secure 是两个独立物理地址空间,数值相同地址不同 | +| 三、跨核 buffer | "写了,SMC 切了世界,然后读,正常" | SMC 不是屏障,跨核 cache 一致性靠 DSB + 一致性协议凑齐 | +| 四、SPMD 握手 | "PSCI CPU_ON 是 EL3 的事,我 SPMC 不管" | SPMD 为每个 CPU 维护独立状态,等 SPMC 的握手才放行 | -在裸机开发中,你的抽象层下面没有操作系统帮你兜底。你的对手不是你的代码——是你对硬件的心智模型跟硬件实际行为之间的差距。 +共同点很清楚:**抽象层下面没有 OS 给你兜底**。在 userspace 写代码,你可以默认"OS 已经把硬件处理好了";写 kernel 至少 OS 是你的;写 hypervisor、写 SPMC,你**就是**那个"应该处理好硬件"的层。再下面的层(硬件、固件、编译器)只按它们自己的契约工作,你违反契约,它们也不会报错——只会让你以为程序对了。 -缩小这个差距的唯一办法是:**反汇编你的二进制,读硬件手册,读固件源码**。没有捷径。 +缩小"心智模型 vs 硬件实际行为"的差距,没有捷径:**反汇编、读 ARM ARM、读 TF-A / Hafnium / Linux KVM 源码**。这是裸机开发真正的核心技能。 --- -*这是 ARM64 Hypervisor 开发系列的第四篇。之前的文章:* -- *Part 0a: 为什么写一个 Hypervisor* -- *Part 0b: AI 辅助系统编程* -- *Part 1: 从零到 "Hello from EL2!"* -- *Part 2: 陷入-模拟-恢复* -- *Part 3: 四个 CPU、一块磁盘 — 让 Linux 启动* +代码: + +博客: + +*这是 ARM64 Hypervisor 开发系列的第四篇(单独成篇的"四大坑·收尾")。之前的文章:* -*项目开源:[github.com/willamhou/hypervisor](https://github.com/willamhou/hypervisor)* +- *Part 0a: [为什么写一个 Hypervisor](./part0a-why.md)* +- *Part 0b: [AI 辅助系统编程](./part0b-ai-workflow.md)* +- *Part 1: [从零到 "Hello from EL2!"](./part1-first-boot.md)* +- *Part 2: [陷入-模拟-恢复](./part2-trap-emulate-resume.md)* +- *Part 3: [让 Linux 启动](./part3-linux-boot.md)* +- *Part 5: [Rust enum 状态机的真相](./part5-enum-state-machine.md)* +- *Part 6: [TrustZone 的 NS 位](./part6-trustzone-ns-bit.md)* —— 坑二深度版 +- *Part 7: [bare-metal Rust 三个坑](./part7-bare-metal-rust-pitfalls.md)* —— 坑一 + 坑三深度版