diff --git a/docs/zhihu/part10-ffa-mem-share.md b/docs/zhihu/part10-ffa-mem-share.md new file mode 100644 index 0000000..0cb36e6 --- /dev/null +++ b/docs/zhihu/part10-ffa-mem-share.md @@ -0,0 +1,307 @@ +# 一块 4 KB 内存,从 pKVM 到 SP,再回来 + +pKVM 在 Normal 世界发了一条 SMC,函数号 `0x84000073`,FF-A `MEM_SHARE`。它说:这块 4 KB 内存,从 IPA `0x4200_0000` 起,我借给 SP2。 + +那块内存里此刻有半张 Android 启动用的字体位图。pKVM 把它借给 SP2 用,但**pKVM 自己也还要继续读这块内存**——这是 SHARE 跟 LEND 的关键差别:SHARE 时 sender 还能读这块,LEND 时 sender 自己彻底放手。这次是前者,所以 SHARE,不是 LEND,更不是 DONATE(那是不可逆转让)。 + +这条 SMC 走过 EL3 的 SPMD,落到 S-EL2 我这边。从这一刻开始,这块 4 KB 内存就要换好几次属性、在两本账上记多次,最后才能回到原状。本篇讲这一来一回怎么走完。 + +代码主线在 `src/ffa/memory.rs`、`src/ffa/stage2_walker.rs`、`src/spmc_handler.rs` 的 `handle_spmc_mem_*` 这一组函数里。 + +--- + +## 先把两本账亮开 + +记账有两本。**第一本在 pKVM(发送方)的 Stage-2 页表里**,标这块内存现在是什么所有权状态。 + +四个状态,占 PTE 的 SW bits[56:55],两个比特够用: + +```rust +// src/ffa/memory.rs +pub enum PageOwnership { + Owned = 0b00, // 还在我手上,没分享给谁 + SharedOwned = 0b01, // 我分享出去了,但所有权还在我 + SharedBorrowed = 0b10, // 我借来用的,别人是真主人 + Donated = 0b11, // 我永久转让出去了,不可逆 +} +``` + +为什么放 PTE 里、不放别处?因为内存共享要做的事大多绕着页表走——查所有权、改访问权、解映射——把状态贴在 PTE 上,这些操作都不用查第二张表。SW bits[56:55] 是 ARM Stage-2 PTE 里专门留给软件用的两个比特,跟硬件翻译完全不冲突。 + +**第二本账在我这边**,记录这一次 SHARE 的元数据——句柄、发送方、接收方、范围: + +```rust +// src/spmc_handler.rs +struct SpmcShareRecord { + handle: u64, // 64 位句柄 + sender_id: u16, // 谁发起的 + receiver_id: u16, // 给谁 + ranges: [(u64, u32); MAX_SHARE_RANGES], // (起始 IPA, 页数) × N + range_count: usize, + active: bool, + is_lend: bool, // SHARE / LEND / DONATE 三选一 + is_donate: bool, + retrieved: bool, // 接收方拿走没 +} +``` + +这本账放在 SPMC 的全局表里: + +```rust +static SPMC_SHARES: SpinLock<[SpmcShareRecord; MAX_SPMC_SHARES]> = ...; +``` + +两本账要一直对上。哪一本错位,后续就没法收尾。 + +--- + +## 第一个 SMC:MEM_SHARE + +pKVM 的 SMC 进来,x0 = `0x84000073`,x1 = 总长度,x2 = 第一片长度。描述符放在 pKVM 之前注册过的 TX buffer 里。 + +描述符不能直接原地读。原因 [Part 4](./part4-war-stories.md) 讲过——跨核 + 跨世界共享 buffer,内存可见性不可靠。这里我先 `dsb sy`,然后 `copy_nonoverlapping` 整块拷到本地栈缓冲,所有解析只读本地副本: + +```rust +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 as usize, + ); +} +``` + +解析按 FF-A v1.1 规范 DEN0077A Table 5.19-5.25 嵌套结构走: + +``` +FfaMemRegion (48 B 头) + ├── sender_id + ├── attributes (cacheability / shareability) + └── FfaMemAccessDesc (16 B) + ├── receiver_id + ├── permissions (RW / RO) + └── FfaCompositeMemRegion (16 B) + └── FfaMemRegionAddrRange × N (16 B 每段) + ├── start_ipa + └── page_count +``` + +每个结构都是 `#[repr(C, packed)]`。解析用 `core::ptr::read_unaligned` 单字段读,不能直接 deref——packed 字段不对齐,Rust 的 `MaybeUninit::assume_init_read` 在 ARM 上会塞 NEON 对齐检查([Part 7](./part7-bare-metal-rust-pitfalls.md) 那个坑)。 + +解析出来:`sender=pKVM(0x0)`、`receiver=SP2(0x8002)`、`ranges=[(0x42000000, 1)]`。 + +接下来要做的事**只剩一件**:验证发送方真的拥有这块内存。 + +验证靠 pKVM 这边的 Stage-2 SW bits。我从 `PER_VM_VTTBR[pKVM_id]` 拿到 pKVM 的页表根,构造一个 `Stage2Walker`(`src/ffa/stage2_walker.rs`),按 IPA 走表读出 PTE,从 PTE 取 SW bits: + +```rust +let walker = Stage2Walker::from_vttbr(pkvm_vttbr); +let sw_bits = walker.read_sw_bits(0x42000000).ok_or(FFA_INVALID_PARAMETERS)?; +validate_page_for_share(sw_bits)?; // 要求 Owned +``` + +`validate_page_for_share` 只在 `Owned` 时返回 `Ok`。其他三种状态都不能再 SHARE——不能把别人借给你的东西转借给第三个人,这是协议级别的约束,不是工程约束。 + +验证通过之后,改状态:pKVM 这边的 SW bits 从 `Owned` 翻成 `SharedOwned`。这一笔是 atomic 的(`walker.write_sw_bits()`,带 cmpxchg)。本实现里 S2AP 同步收紧到只读——SHARE 的语义这边是"我借出去,自己不写"。FF-A 规范本身并不强制 sender 必须 RO,这是我们的策略选择,但它让"发送方在共享期间不会改写内容"成为可观测属性,后面 receiver 端的不变式才能稳: + +```rust +walker.write_sw_bits(ipa, PageOwnership::SharedOwned as u8)?; +walker.write_s2ap(ipa, S2AP_RO)?; +``` + +第二本账也写上:`SpmcShareRecord` 填好,`active=true`,`retrieved=false`,分配一个 64 位句柄返回给 pKVM。 + +``` +x0 = FFA_SUCCESS +x2 = handle & 0xFFFFFFFF +x3 = handle >> 32 +``` + +到这,内存还在原地,pKVM 那边的 PTE 已经变了——能读不能写。一笔账记下,等着 SP2 来取。 + +--- + +## 第二个 SMC:SP2 的 RETRIEVE_REQ + +SP2 那边还不知道有这块内存。要 pKVM(或者 NWd 的某个组件)把句柄传给 SP2,SP2 自己发一条 `MEM_RETRIEVE_REQ` 来"取": + +``` +x0 = FFA_MEM_RETRIEVE_REQ (0x84000074) +x1 = handle low 32 +x2 = handle high 32 ++ 描述符在 SP2 的 TX buffer 里(谁是 receiver、能不能 RW 等等) +``` + +这条 SMC 是 SP2 主动发的,但 SP2 跑在 S-EL1。它的 SMC 进 S-EL2(我),不进 EL3——这是 Secure World 内部调用。 + +我在 `handle_spmc_mem_retrieve()` 里做两件事。 + +第一件:查账。按句柄查 `SPMC_SHARES`,确认 `receiver_id == 0x8002`,确认 `retrieved == false`(同一个 receiver 不能 retrieve 两次)。 + +第二件,也是关键的一件:**真正把这块内存映射到 SP2 的 Secure Stage-2 里**。在此之前,SP2 的 Stage-2 完全没这个 IPA。从这条 SMC 之后,SP2 才能用普通 load/store 访问它。 + +`Stage2Walker` 的 `map_page()` 干的就是这件事: + +```rust +// src/spmc_handler.rs:2598 +walker.map_page(ipa, 0b11, 0b10); // S2AP_RW, SW=SharedBorrowed +``` + +`0b11` 是 RW 访问权——SP2 不只能读,如果协议允许,也能写。`0b10` 是 `SharedBorrowed`,标志这块内存是借来的,不是 SP2 自己的。 + +为什么 SP2 这边可以 RW?因为 FF-A 的访问权是两边对称的——pKVM 端 SHARE 时收紧到 RO(我自己不写),意思是允许 receiver 写(否则就该 LEND 了)。SP2 RETRIEVE 时拿到 RW,语义自洽。 + +`map_page()` 实现里要分配 Stage-2 的 L2/L3 表(从 SPMC 的 secure heap),把 4 KB 单页插进去。表如果不存在还要先建——`walk_or_create()`。整个过程加 `STAGE2_LOCK`,因为 pKVM 那边的 per-CPU SPMD 可能让两条不同 CPU 的 SMC 同时撞进来。 + +最后,`SpmcShareRecord.retrieved = true`。SP2 那边收到 RESP,带回描述符——告诉 SP2 这块内存在它 Stage-2 里的 IPA 在哪。 + +SP2 现在能读这块字体位图了。pKVM 端的物理内存没动一根毫毛。 + +--- + +## 中间这段,SP2 在用 + +SP2 在这段时间里做它要做的事——读字体,处理,可能也写一些状态回去。它的 `load`/`store` 都走自己的 Secure Stage-2,翻译到同一块物理地址,跟 pKVM 看到的是同一块内存,但属性、所有权、访问权都是各自记的。 + +注意 pKVM 这时候**也还能读**。它的 SW bits 是 `SharedOwned`、S2AP 是 RO——能读不能写。SHARE 的"共享"是真共享,不是"借出去就没了"。如果它在这时候要写,会被 Stage-2 拦下,触发 fault。 + +LEND 不一样。LEND 的语义是"借走之后我自己也不能碰",所以 LEND 走的路径里,发送方的 S2AP 收紧到 `0b00`(None): + +```rust +// LEND path +walker.write_sw_bits(ipa, PageOwnership::SharedOwned as u8)?; +walker.write_s2ap(ipa, S2AP_NONE)?; // ← LEND 的关键差别 +``` + +`SharedOwned` 状态相同(都是"我分享出去但所有权还在"),区别只在 S2AP 那一位。 + +DONATE 又不一样。DONATE 是不可逆转让,发送方直接 unmap——`walker.unmap_page(ipa)`——这块内存就在发送方的 Stage-2 里消失了。PTE 已经无效,所以 SW bits 也不再有意义;`Donated` 这个状态值真正起作用的地方在 receiver 端,它从 PTE 里读到 `Donated` 时知道"这是别人转让给我的"(还可以拿去 SHARE 给第三方)。我们在 sender 端还是把 SW bits 写一次 `Donated`,纯粹是为了让全局账本的两端 SW 编码统一,方便日志和断言。 + +DONATE 之后 `is_donate=true` 写进 `SpmcShareRecord`。本实现里后面的 RELINQUISH 和 RECLAIM 看到这个 flag 都返回 `FFA_DENIED`——这是我们对 FF-A "DONATE 是单程票"语义的强制实现,规范允许但不强求这条具体的 deny 路径。 + +--- + +## 第三个 SMC:SP2 的 RELINQUISH + +SP2 用完了。发 `MEM_RELINQUISH`: + +``` +x0 = FFA_MEM_RELINQUISH (0x84000076) ++ 句柄在 SP2 的 TX buffer 里(描述符格式比 RETRIEVE_REQ 简单) +``` + +`handle_spmc_mem_relinquish()` 干的事是 `map_page()` 的逆操作:**把这块内存从 SP2 的 Secure Stage-2 里 unmap**。 + +```rust +walker.unmap_page(ipa)?; // 把对应 L3 PTE 清零 +``` + +清完之后 `SpmcShareRecord.retrieved = false`。但 `active` 还是 `true`——账还没销,只是 receiver 那边放手了。pKVM 现在可以回收,也可以让别的 SP 再 RETRIEVE。 + +`unmap_page()` 还做一件保险的事:**清完 PTE 后跑一次 TLBI**(`tlbi vae2is`),把这条映射从所有 Inner Shareable 域的 CPU TLB 里赶出去。不清的话,SP2 在某个 pCPU 上的 TLB 里还有这一项,即使 PTE 已经清零,它还是能用旧映射访问——直到下一次 context 切换。 + +--- + +## 第四个 SMC:pKVM 的 RECLAIM + +pKVM 想把内存彻底要回来。发 `MEM_RECLAIM`: + +``` +x0 = FFA_MEM_RECLAIM (0x84000077) +x1 = handle low 32 +x2 = handle high 32 +``` + +`handle_spmc_mem_reclaim()` 做三件事。 + +第一,查账。`SPMC_SHARES` 里按句柄找,确认 `sender_id` 是当前调用者(pKVM),确认 `retrieved == false`——SP2 还没放手时不能 reclaim,返回 `FFA_DENIED`。 + +第二,改 pKVM 端的 Stage-2 SW bits 和 S2AP: + +```rust +walker.write_sw_bits(ipa, PageOwnership::Owned as u8)?; +walker.write_s2ap(ipa, S2AP_RW)?; +``` + +`Owned` 回来了,RW 也回来了。pKVM 这边的页表完全恢复到 SHARE 之前的状态。 + +第三,销账:`SpmcShareRecord.active = false`。槽位释放,等下一次 SHARE 来用。 + +--- + +## 这六个调用之间不能错位 + +SHARE 之后没 RETRIEVE,RELINQUISH 走不通(根本没什么可 RELINQUISH 的)。RETRIEVE 之后没 RELINQUISH,RECLAIM 走不通(SP 还在用,RECLAIM 出去 SP 会读到无效映射)。DONATE 之后没有 RELINQUISH 也没有 RECLAIM——单程票。 + +每条路径都在 `SpmcShareRecord` 上做一次状态检查:`active`、`retrieved`、`is_donate`。出错了就 `FFA_DENIED`,不修改任何状态。这一点很重要——FF-A 没有"撤销"操作,任何中间状态如果让错误的 SMC 改了一半,后续就没法恢复。 + +```rust +// RELINQUISH 入口的检查链 +if !record.active { return DENIED; } // 已 reclaim +if !record.retrieved { return DENIED; } // 还没 retrieve +if record.is_donate { return DENIED; } // 不可逆 +if record.receiver_id != caller { return DENIED; } // 不是你借的 +``` + +每条 SMC 都自带这一段。看起来重复,但写出来不能省——FF-A 的合规靠这一层兜底。 + +--- + +## 跨 NWd 的 fragmentation + +实战里描述符常常不止一段。一个 MEM_SHARE 可能包 16 个 range,描述符长度超过单条 SMC 能携带的 4 KB TX buffer。FF-A 给了一对调用处理这件事:**`MEM_FRAG_TX`** 是发送方继续传剩余分片,**`MEM_FRAG_RX`** 是接收方继续取剩余分片。 + +发送方 fragmentation 这边的代码: + +```rust +// 第一片进来,total_length > fragment_length +if total_length != fragment_length && fragment_length > 0 { + // 启动重组 + frag.total_length = total_length; + frag.received = fragment_length; + frag.handle = spmc_alloc_handle(); + frag.active = true; + // 返回 FFA_MEM_FRAG_RX,告诉发送方继续发 + return SmcResult8 { + x0: FFA_MEM_FRAG_RX, + x1: handle & 0xFFFF_FFFF, + x2: handle >> 32, + x3: fragment_length, + ... + }; +} +``` + +之后发送方按句柄继续 `MEM_FRAG_TX`,我累加进 `NWD_FRAG.accum_buf`,直到 `received == total_length`。这时候才真正做完整的 share 解析、Stage-2 映射、记账。 + +中途任何一片格式有问题——比如 `received > total_length`、`fragment_length > total_length`、句柄不对——重组直接作废,`NWD_FRAG.active = false`,返回错误。绝不能让半截描述符进到真实路径里,因为它会让后面的 IPA 校验过不去,但已经被错误地分配了句柄。 + +--- + +## 关于 PTE SW bits 的一个小观察 + +四个状态用两个比特,刚好够。但仔细想想其实有一个问题——**借来的内存不能再借出去**这条规则,是协议级的,代码里通过 `validate_page_for_share` 强制("非 Owned 不许 SHARE")。那 `SharedBorrowed` 这个状态在发送方端永远不会出现——它只在接收方端有意义。 + +这就是为什么 receiver 在 RETRIEVE 时映射的 PTE 直接打成 `SharedBorrowed`(`0b10`)——它从 SP2 的视角看,这块内存确实是借来的。SP2 自己如果想再 SHARE 给 SP3,代码会读它的 PTE 拿到 `SharedBorrowed`,`validate_page_for_share` 返回 `FFA_DENIED`,转嫁不成立。 + +ARM 留这两个 SW bits 给软件用,有人用来做 PTE 缓存标记、有人用来做引用计数。这套用来做所有权状态机,刚好够,而且天然 per-page 颗粒度——没有第二张表要维护一致性,加上 atomic 写,多 pCPU 下也不会撕裂。 + +--- + +## 现在的页表 + +走到最后,pKVM 那块 IPA `0x4200_0000` 的 PTE 又是 `Owned + RW`,SW bits[56:55] 是 `0b00`,S2AP 是 `0b11`。`SPMC_SHARES` 里那一条记录的 `active` 已经翻成 `false`,槽位空着,等下一次。SP2 的 Secure Stage-2 里那条曾经存在的 4 KB 映射已经被清掉,TLB 也被 `tlbi vae2is` 赶过一次。 + +跟 SHARE 发出之前比,什么都没多,什么都没少——只有一段 SMC 历史和一段日志。 + +下一篇我想讲 GICv3 的 List Register:vtimer 中断怎么从物理 GIC 一路注入到 guest,EOI 在虚拟世界点完之后又怎么把物理那边的 active 位也一并清掉。换条主线,从协议切到硬件。 + +--- + +代码: + +博客: + +*这是 ARM64 Hypervisor 开发系列的第十篇。之前的文章索引在 [Part 0a](./part0a-why.md) 的末尾。* diff --git a/docs/zhihu/part11-stage2-heap-gap.md b/docs/zhihu/part11-stage2-heap-gap.md new file mode 100644 index 0000000..a53acce --- /dev/null +++ b/docs/zhihu/part11-stage2-heap-gap.md @@ -0,0 +1,217 @@ +# 把堆放在 guest 看得到的物理范围里——然后把它藏起来 + +我的 hypervisor 把堆放在 `0x4100_0000`。16 MB。这块物理地址在 guest 声称的 RAM 范围里——guest 看到的是从 `0x4000_0000` 开始的一大段 RAM。 + +但 guest 永远摸不到 `0x4100_0000`。 + +不是因为 guest 自我克制,不是因为我用 TrustZone 的 NS bit 隔了它,也不是因为代码里有 if-check 拦截。是因为 hypervisor 的 Stage-2 页表里**那一段根本没映射**——guest 用 IPA `0x4100_0000` 发起的任何访问都触发 Stage-2 fault,被 hypervisor 接管,拒绝。 + +这一篇讲为什么要这样做、怎么做、以及做到一半发现 GICR 需要 4 KB 精度的时候,2 MB 块的 IdentityMapper 怎么演化成支持 2 MB / 4 KB 混合的 DynamicIdentityMapper。 + +主线代码在 `src/arch/aarch64/mm/mmu.rs` 里那两个 mapper 类型,以及 `split_2mb_block()` 那一段。 + +--- + +## 为什么 guest 不会撞到堆 + +Guest Linux 启动靠 DTB 来知道 RAM 在哪。我们给 guest 的 DTB 里 `/memory` 节点写的是这样: + +``` +memory@48000000 { + device_type = "memory"; + reg = <0x0 0x48000000 0x0 0x40000000>; // 0x4800_0000 起,1 GB +}; +``` + +Linux 启动时 parse 这一条,把它的物理内存分配器(`memblock`)初始化成"可用 RAM 是 `0x4800_0000` 到 `0x8800_0000`"。从这一刻开始,Linux 的 `alloc_pages()` 不会返回 `0x4100_0000` 附近的地址——它根本不知道那里有 RAM 可用。 + +那段地址确实是 RAM——QEMU 的 `-m 2G` 让 `0x4000_0000` 起的 2 GB 都有真实 DRAM 在背后。Linux 不去访问只是因为 DTB 没告诉它。 + +但**安全不能靠 guest 自觉**。Linux 内核里有 bug、有越界、有指针计算错位。如果某条野指针刚好踩到 `0x4100_0001`,我们的 hypervisor 页表就被踩了——`l0_table`、`l1_table`、`l2_tables` 全在堆里,改一个比特,下一次进 guest 的 Stage-2 翻译就疯。 + +所以 Stage-2 页表里那一段必须不映射。DTB 把 Linux 引到 `0x4800_0000`;真有人伸手到 `0x4100_0000`,Stage-2 fault 当场拦下。一前一后,两层防御。 + +``` +guest 看到的 RAM: [ 0x40000000 ...... 0x88000000 ] + ^ + 堆在这里 (0x41000000, 16MB) + 但 Stage-2 不映射 → fault +guest 实际用的 RAM: [ 0x48000000 ...... 0x88000000 ] + ^ + Linux 从这里开始 alloc +``` + +heap 既不在 guest 用的范围 (Linux 的 memblock 不知道它),也不在 hypervisor 视角隔离区域 (它本来就是 hypervisor 自己的)。设计上这是一段"在 RAM 物理范围但不可达"的 deliberate gap。 + +--- + +## IdentityMapper 第一版:静态 2 MB 块 + +最早的 Stage-2 mapper 是这样的——hypervisor 启动时,把 `0x4800_0000` 到 `0x8800_0000` 这 1 GB 标成 RAM 可访问,每个映射条目是一个 2 MB 块。512 条 PTE 装在一张 4 KB 的 L2 表里,加上 L0 / L1 两张表指过去,一共三张页表,完全 static 分配——不需要堆。 + +```rust +pub struct IdentityMapper { + l0_table: u64, + l1_table: u64, + l2_table: u64, // 单张 L2,512 个 2MB 块 = 1GB +} +``` + +2 MB 块的好处一目了然:L2 一格就是 2 MB,TLB 轻,走表只到 L2 那一层就停,启动时一张表填完就完事。 + +代价也摆在同一格里:4 KB 的 GICR 不能单独剔出去。一个 2 MB 块要么整块映射、要么整块不映射,中间挖不出一个 4 KB 的洞——而 GICR 的 trap 恰好需要这种洞。 + +第一版 hypervisor 还没有 GICR 虚拟化,这个限制不重要。等到要加 trap-and-emulate GICR 的时候,这一版就走不下去了。 + +--- + +## 演进:GICR 是 4 KB,需要 4 KB 粒度的 Stage-2 控制 + +GICv3 的 Redistributor (GICR) 是每颗 CPU 一个 0x2_0000 (128 KB) 的 MMIO 区域,分成 RD_base 和 SGI_base 两个 64 KB frame,但实际有意义的寄存器只在每个 frame 的前 4 KB,其余是 reserved。我们要 trap 的就是 SGI_base 那 4 KB——里面的 ISENABLER0/ICFGR1/IPRIORITYR 等寄存器,guest 直接写会改物理中断状态,跟我们 SPI 路由表对不上。 + +trap 的实现方式是:**把 GICR 那 4 KB 页从 Stage-2 里 unmap**。Guest 访问就触发 Data Abort,hypervisor 接管,在 `VirtualGicr` 的 shadow 状态上模拟一遍读写,然后透传给物理 GICR(如果需要),最后 ERET 回去。 + +要 unmap 4 KB,Stage-2 在那一块必须是 4 KB 粒度。但 2 MB 块覆盖了它——整个 2 MB 块 unmap 会把周围合法的 GIC 区域也带走。 + +唯一的办法是把那个 2 MB 块**拆成 512 个 4 KB 页**。拆完之后,512 个 4 KB PTE 里有 511 个保留原映射、1 个标 invalid。访问到 invalid 那一页才 fault,其他 511 页继续直通硬件。 + +但 IdentityMapper 没办法做这件事——它的 L3 表根本没分配过。要拆 2 MB 块,你得能动态从 heap 拿一张新的 4 KB 页做 L3 表,把 512 个 4 KB PTE 写进去,然后改 L2 entry 让它指向新的 L3 表。 + +于是有了 DynamicIdentityMapper。 + +--- + +## DynamicIdentityMapper:heap-backed,2 MB / 4 KB 都行 + +```rust +pub struct DynamicIdentityMapper { + l0_table: u64, + l1_table: u64, + l2_tables: [u64; 4], // 最多 4 张 L2,覆盖 4 GB + l2_count: usize, +} +``` + +跟 IdentityMapper 的区别有两个。第一,`l2_tables` 是 `[u64; 4]`——四个槽位,每个槽位装一个 L2 表的物理地址,可以按需分配多张(每张覆盖 1 GB),启动时分配一张就够。第二,所有页表都从 heap 拿(`crate::mm::heap::alloc_page()`),而不是 static `.bss`。 + +`map_region()` 仍然按 2 MB 块走,跟老版本接口一样。新增的是 `map_4kb_page()` 和 `unmap_4kb_page()`,这两个会触发拆块。 + +```rust +pub fn map_4kb_page(&mut self, ipa: u64, attr: MemoryAttribute) -> Result<(), &'static str> { + let l1_idx = ((ipa >> 30) & PT_INDEX_MASK) as usize; + let l2_table = self.get_or_create_l2(l1_idx)?; + let l2_idx = ((ipa >> 21) & PT_INDEX_MASK) as usize; + let l3_idx = ((ipa >> 12) & PT_INDEX_MASK) as usize; + + let l2_entry = Self::read_pte(Self::entry_ptr(l2_table, l2_idx)); + + let l3_table = if l2_entry & PTE_VALID != 0 && l2_entry & PTE_TABLE == 0 { + // L2 entry 是 2 MB 块 — 拆成 L3 表 + self.split_2mb_block(l2_table, l2_idx, l2_entry)? + } else if l2_entry & (PTE_VALID | PTE_TABLE) == (PTE_VALID | PTE_TABLE) { + // L2 entry 已经指向 L3 表,直接用 + l2_entry & PTE_ADDR_MASK + } else { + // L2 entry 无效 — 新建空 L3 表 + let l3 = crate::mm::heap::alloc_page().ok_or("Failed to allocate L3 table")?; + unsafe { core::ptr::write_bytes(l3 as *mut u8, 0, PAGE_SIZE) } + Self::write_pte(Self::entry_ptr(l2_table, l2_idx), l3 | PTE_VALID | PTE_TABLE); + l3 + }; + // ... 写 L3 PTE +} +``` + +三个分支按 L2 entry 当前状态走。第三个最简单——L2 那里本来就没映射,直接装一张空 L3 表上去。第二个也简单——L3 表已经存在,直接用。 + +麻烦的是第一个分支:`split_2mb_block`。 + +--- + +## split_2mb_block:拆 2 MB 块的细节 + +把一个 2 MB 块拆成 512 个 4 KB 页要走 5 步,**不能省任何一步**: + +1. 从 heap 拿一张新的 4 KB 页做 L3 表 +2. 把这张 L3 表填上 512 个 PTE,每个指向 2 MB 块原本覆盖的对应 4 KB 物理地址,继承原来的属性 +3. **把 L2 entry 写成 0**(invalid) +4. **TLBI**——把旧的 2 MB block 映射从 TLB 里赶出去 +5. 把 L2 entry 改成"指向 L3 表"(从 block descriptor 改成 table descriptor),再 TLBI 一次 + +第 3、4 步是 ARMv8-A D5.10.1 明文要求的——任何把一个 valid PTE 替换成另一个 valid PTE 的操作,中间必须经过一次 invalid 状态加 TLBI,否则可能触发 TLB conflict abort。原因是 TLB 可能同时缓存"旧块 PTE"和"新表 PTE",硬件不知道哪个是真的,直接报错中止。 + +源码里走得也是这个顺序(`src/arch/aarch64/mm/mmu.rs::split_2mb_block`): + +```rust +// Fill L3 with 512 page entries first +unsafe { + let l3_ptr = l3 as *mut u64; + for i in 0..512u64 { + let pa = block_pa + i * PAGE_SIZE_4KB; + let page = pa | block_attr_bits | PTE_TABLE | PTE_VALID; + *l3_ptr.add(i as usize) = page; + } +} + +// Break-before-make: invalidate old L2 block entry first +Self::write_pte(Self::entry_ptr(l2_table, l2_idx), 0); +Self::tlbi_all(); + +// Then write new L2 table descriptor pointing to L3 +let l2_desc = l3 | PTE_VALID | PTE_TABLE; +Self::write_pte(Self::entry_ptr(l2_table, l2_idx), l2_desc); +Self::tlbi_all(); +``` + +`map_4kb_page` 后续往 L3 PTE 里写新页的时候,也走同一套 break-before-make: + +```rust +let l3_ptr = Self::entry_ptr(l3_table, l3_idx); +let old_l3 = Self::read_pte(l3_ptr); +if old_l3 & PTE_VALID != 0 { + Self::write_pte(l3_ptr, 0); // break: 先写 invalid + Self::tlbi_ipa(ipa); // flush +} + +let page_entry = self.make_page_entry(ipa & !PAGE_MASK_4KB, attr); +Self::write_pte(l3_ptr, page_entry); // make: 再写新值 +Self::tlbi_ipa(ipa); +``` + +两次 `tlbi_ipa`——一次在 break 之后,一次在 make 之后。第一次是合规要求,第二次是确保 guest 之后访问能看到新 PTE。 + +跳过第一次会怎样?多数时候没事——TLB miss 直接 walk 表,看到新值。但某些 microarchitecture 在两个 valid PTE 同时被缓存时会报 TLB Conflict Abort,在某些 CPU 模型上随机触发,很难复现。页表看上去换好了,机器却会在某次启动里停住。等查到现场,破坏现场早已经离开。D5.10.1 不是 nice-to-have,是必须做的。 + +--- + +## 那为什么 heap 还在 guest 的 RAM 物理范围里? + +绕回开头的问题。既然要把 heap 藏起来,为什么不干脆放到 guest 的 RAM 范围之外?比如放到 `0x3000_0000`? + +两个原因。 + +第一,我们走的是 identity mapping——guest 看到的 IPA 直接等于真实 PA。如果 hypervisor 自己的代码段、数据段、堆都在 `0x4000_0000` 起的低端 RAM,而 guest RAM 从 `0x4800_0000` 起,这一段的地址布局很自然:linker script 把 hypervisor 装在 `0x4020_0000`,堆紧接着放 `0x4100_0000`,然后留个 7 MB 空当到 guest kernel 装载点 `0x4800_0000`。物理上是连续的一大块 RAM,只是中间有个 deliberate 的"洞"在 Stage-2 上不映射。 + +第二,也是更技术性的——hypervisor 启动时还没建好 Stage-2 页表,自己代码访问堆走的是物理地址(此时 MMU off / Stage-1 identity)。Stage-2 是后来给 guest 用的。所以堆的物理地址必须是 hypervisor 自己物理上能访问的——QEMU `-m 2G` 给的就是 `0x4000_0000` 起的 2 GB。 + +把这两点合起来:**堆在低端 RAM(0x41000000),hypervisor 自己能用;Stage-2 上不映射,guest 不能用**。 + +--- + +## DynamicIdentityMapper 的局限 + +四张 L2 表,覆盖 4 GB,够 QEMU virt 玩。换到大内存的物理板子上要扩。 + +但更隐性的限制是**L3 表的回收**。`split_2mb_block` 一旦把 2 MB 块拆成 L3 表,即使后来那些 4 KB 页全部 unmap 了,L3 表本身不会回收回 heap——因为没办法判断"是不是所有 512 个 entry 都已经 invalid 了"而不付出每次 unmap 都遍历一次的代价。 + +在 hypervisor 这种"启动期建好就不动"的工作模式下,这个限制无所谓——`BumpAllocator` 配上 free list 处理得了,且 4 KB 的 L3 表数量本来就少(只有需要 4 KB 粒度的地方才拆)。但如果有人把这套 mapper 拿去做"频繁映射/解映射 4 KB 页"的工作(比如用户态 mmap 模拟),L3 表泄漏会成为问题。 + +下一篇我打算讲 GICv3 虚拟化,正好是 4 KB 拆块这一招的用户。GICR 的那 4 KB MMIO 区域不映射,guest 访问就陷下来,我们在 `VirtualGicr` 的 shadow 上响应,该透传的写透给物理 GIC,该挡的挡。整条路径下一篇见。 + +--- + +代码: + +博客: + +*这是 ARM64 Hypervisor 开发系列的第十一篇。之前的文章索引在 [Part 0a](./part0a-why.md) 的末尾。* diff --git a/docs/zhihu/part12-gicv3-virt.md b/docs/zhihu/part12-gicv3-virt.md new file mode 100644 index 0000000..70cdf7c --- /dev/null +++ b/docs/zhihu/part12-gicv3-virt.md @@ -0,0 +1,220 @@ +# 四个寄存器装下整个虚拟中断世界——GICv3 的 List Register + +Guest 的 Linux 内核刚刚拿到一个 vtimer 中断。它的 IRQ handler 跑完,在 `EOIR_EL1` 写一次,告诉中断控制器"处理完了,下一个"。 + +这一笔写理应同时做两件事:**在虚拟世界里,把这条 vtimer 中断标成已处理**(让 guest 不再看见它 pending);**在物理世界里,把刚刚那条物理 vtimer 中断 deactivate**(让硬件可以再次触发它)。 + +guest 的 EOIR 写只是一条系统寄存器指令,既不进 hypervisor、也不直接戳物理 GIC。它走了一条很巧的路径:**ICH_LR_EL2 里那一条 List Register 的 HW=1 让物理和虚拟 EOI 自动配对**——guest 在虚拟侧的一笔 EOI,硬件自动同步到物理侧 deactivation。 + +这一篇讲这条路径,以及让它成立的几个 GICv3 虚拟化设施:**ICH_HCR_EL2.En 启用虚拟接口、4 个 ICH_LR_EL2 装载待注入中断、ICC_CTLR_EL1.EOImode=1 把 priority drop 和 deactivation 拆成两步、ICH_HCR_EL2.TALL1=1 拦下 guest 的 ICC_SGI1R 写来做 IPI 仿真**。 + +代码主线在 `src/arch/aarch64/peripherals/gicv3.rs` 和 `src/vm.rs::inject_pending_sgis()` 这一带。 + +--- + +## 两个 GIC,各自记账 + +物理 GICv3 有自己一套寄存器——`ICC_*_EL1`(CPU interface)、`GICD_*`(distributor)、`GICR_*`(redistributor per CPU)。这些寄存器,hypervisor 在 EL2 可以直接访问,因为 EL2 不受 Stage-2 翻译影响,直接 MMIO。 + +虚拟 GICv3 也有一套寄存器——`ICV_*_EL1`。从名字看跟物理那套像,但前缀从 `ICC` 变成 `ICV`。硬件在某个开关打开后,**guest 在 EL1 写 `ICC_*` 时,硬件自动重定向到 `ICV_*`**——guest 自己以为在动物理 GIC,实际上动的是虚拟接口。 + +那个开关是 `ICH_HCR_EL2.En`,在 EL2 里设: + +```rust +// src/arch/aarch64/peripherals/gicv3.rs +Self::write_hcr((ICH_HCR_TALL1 | ICH_HCR_EN) as u32); +``` + +`En=1` 之后,guest 接下来执行的每一条 `mrs`/`msr` 访问 `ICC_*_EL1` 都被透传到对应的 `ICV_*_EL1`——没有 trap,没有进 hypervisor,纯硬件自动做。 + +这意味着 hypervisor **不用拦截**绝大多数 GIC 系统寄存器访问。Guest 读 `ICC_IAR1_EL1` 想拿当前 pending IRQ 编号——硬件直接返回 `ICV_IAR1_EL1` 的值,我们在 List Register 里写了什么,这里就读得到什么。Guest 写 `ICC_EOIR1_EL1` 处理完一个 IRQ——硬件清掉 `ICV_LR_EL2` 里对应那条,我们下一次 inject 才能重用这条 LR。 + +整个流程没人在 hypervisor 这边花周期,中断处理快得几乎跟 native 一样。 + +--- + +## List Register:四个槽位 + +虚拟世界要看到的待处理中断,得从 hypervisor 这边塞进去。"塞"的容器就是 **List Register**:`ICH_LR0_EL2` 到 `ICH_LR3_EL2`,一共四个。 + +四个,不是四十个,也不是任意。这是 ARM 架构最低保证,具体实现可能更多——QEMU virt 给的就是 4 个。所以**任意时刻,hypervisor 注入到 guest 的待处理虚拟中断最多只能有 4 条**。 + +每条 LR 是一个 64 位的字,里面塞着这条虚拟中断的全部信息:INTID、优先级、状态(pending / active / pending-and-active)、HW bit、(HW=1 时)对应的物理 INTID: + +``` +ICH_LR_EL2 字段(简化): + [63:62] State (00=invalid, 01=pending, 10=active, 11=pending-and-active) + [61] HW (0=virtual only, 1=hardware-linked) + [55:48] Priority + [44:32] Physical INTID (HW=1 时有效) + [31:0] Virtual INTID +``` + +写一条 LR = 给虚拟世界塞了一条 pending 中断。写完之后 ERET 回 guest,硬件会自动把这条 IRQ 信号送到 vCPU 的虚拟 CPU 接口,guest 看见 `ICV_IAR1_EL1` 里有东西,跑 IRQ handler。 + +LR 是稀缺资源。Inject 的时候要先扫一遍 `ELRSR_EL2`(Empty List Register Status Register)看哪一条是空的,然后写那一条: + +```rust +// src/vm.rs::inject_pending_sgis (节选) +let pending = vm_state.pending_sgis[vcpu_id].swap(0, Ordering::AcqRel); +let mut bits = pending; +while bits != 0 { + let intid = bits.trailing_zeros(); + bits &= !(1 << intid); + + let lr_idx = find_free_lr().unwrap_or_else(|| { + // 没空 LR — 把这条 INTID 重新放回 pending,下次再注入 + vm_state.pending_sgis[vcpu_id].fetch_or(1 << intid, Ordering::Release); + return; + }); + write_lr_sgi(lr_idx, intid); +} +``` + +如果 pending 的中断数超过 4,多出来的留在 `pending_sgis` 原子位图里,下一次 vCPU exit 进 hypervisor 时再尝试 inject。这种"多了排队"模式是 GIC 虚拟化的常态——4 个 LR 在 SMP 大压力时会饱和,但下次 exit 通常很快(WFI、计时器到期、新 IRQ 都会触发),队列消化得过来。 + +--- + +## HW=1:让物理 EOI 和虚拟 EOI 联动 + +文章开头那个 vtimer 例子是这样跑通的: + +1. 物理 vtimer 触发,INTID 27,硬件把这条物理中断进 hypervisor(EL2 trap) +2. Hypervisor 看见是 vtimer,挑一个空的 LR,写入 `state=pending, HW=1, virtual_intid=27, physical_intid=27` +3. ERET 回 guest +4. 硬件把虚拟 INTID 27 送给 guest 的虚拟 CPU 接口 +5. Guest 内核 IRQ handler 跑完,在 `ICC_EOIR1_EL1` 写 27 +6. 硬件做两件事:**在虚拟侧把这条 LR 标成 invalid**(回收槽位),**在物理侧把 GICD/GICR 里的 INTID 27 deactivate** + +第 6 步那个"两件事自动同步"就是 HW=1 的作用。如果 HW=0(纯虚拟中断,没有对应物理 INTID),guest 的 EOI 只清虚拟侧 LR;hypervisor 自己得在合适时机去物理 GIC deactivate。但 vtimer、virtio 这些**真有物理 INTID 对应的中断**,HW=1 让 deactivation 完全免去 trap。 + +代码里的写法是这样: + +```rust +// src/arch/aarch64/peripherals/gicv3.rs:436 +pub fn inject_virtual_irq_hw(lr_idx: u8, virtual_intid: u32, physical_intid: u32, priority: u8) { + let lr_value = (LR_STATE_PENDING as u64) << 62 + | LR_HW_BIT + | ((priority as u64) << 48) + | ((physical_intid as u64 & 0x1FFF) << 32) + | (virtual_intid as u64 & 0xFFFFFFFF); + Self::write_lr(lr_idx, lr_value); +} +``` + +`LR_HW_BIT` 是 bit[61]。`physical_intid` 占 `[44:32]`,可以指向 INTID 0-8191。虚拟 INTID 跟物理 INTID 在 vtimer 这里都是 27——guest 看到的中断号跟硬件看到的一样。Linux 内核在 `request_irq(27, ...)` 注册的 handler 就是为这条服务的。 + +--- + +## EOImode=1:为什么要把 priority drop 和 deactivation 拆开 + +`ICC_CTLR_EL1.EOImode=1` 是 Linux 内核运行 GICv3 时**无条件**设的状态——不光 HW=1 链接中断要它,所有虚拟化中断处理流程都依赖这套语义。我们 hypervisor 这边在初始化阶段就把它设好。 + +`EOImode=0`(默认):guest 写 `EOIR_EL1` 时,硬件同时做 **priority drop**(把这条 IRQ 的优先级从 running 状态降下来,允许更低优先级的 IRQ 抢占)和 **deactivation**(把这条 IRQ 从 active 状态变成 inactive)。一气呵成,简单。 + +`EOImode=1`:guest 写 `EOIR_EL1` 只做 priority drop。Deactivation 要另外写 `DIR_EL1` 来触发。 + +为什么要拆?因为 **HW=1 的 LR 在 deactivation 时,硬件要把动作同步到物理 GIC**——这是关键。如果 priority drop 和 deactivation 绑在一起,硬件没办法判断哪个 EOI 是给虚拟中断(只清虚拟侧)、哪个是给硬件链接中断(同步到物理)。拆开之后:**priority drop 仍是 guest 的 EOIR 触发,纯虚拟侧动作;deactivation 是后续的 DIR 触发,这时硬件根据 LR 里的 HW 位决定是否同步到物理**。 + +Linux 内核知道这件事。它的 GIC 驱动会按 EOImode 设置走对应路径: + +- EOImode=0: 只写 EOIR +- EOImode=1: 先写 EOIR,再写 DIR + +Hypervisor 这边只要在启动时设好 EOImode=1,后面 Linux 自己会跟上: + +```rust +// src/arch/aarch64/peripherals/gicv3.rs:655 +let mut ctlr = Self::read_ctlr(); +ctlr |= ICC_CTLR_EOIMODE; +Self::write_ctlr(ctlr); +crate::log_info!("[GIC] ICC_CTLR_EL1.EOImode=1 (split priority drop/deactivation)\n"); +``` + +`ICC_CTLR_EL1` 是被透传到 `ICV_CTLR_EL1` 还是真物理的,看 `ICH_HCR_EL2.En` 状态。这里在 GIC init 阶段(hypervisor 自己设),走的是物理 CTLR。但因为虚拟接口启用后两套 CTLR 都遵循这一 EOImode 语义,guest 的 EOI 路径自动是 split 模式。 + +--- + +## TALL1=1:拦 ICC_SGI1R 来做 IPI + +绝大多数 ICC_* 寄存器访问被硬件自动重定向到 ICV_*,但 `ICC_SGI1R_EL1` 是个例外。 + +ICC_SGI1R 是 IPI(Inter-Processor Interrupt)入口。Guest CPU 0 想给 CPU 1 发一条 SGI,写 ICC_SGI1R,字段里包 TargetList(哪些 CPU 要收)和 INTID(哪个 SGI 编号)。 + +为什么不能纯虚拟化?因为 IPI 的语义涉及**跨 vCPU 调度**——CPU 0 写完 SGI,得让 CPU 1 真的醒过来处理。在多 pCPU 模式里,这意味着要让另一颗物理 CPU 进入 IRQ handler 路径(物理 SGI 0 唤醒,见 [Part 9](./part9-multi-pcpu.md))。在单 pCPU 模式里,这意味着要在 vCPU 调度器里把目标 vCPU 排上下一轮。 + +两件事都需要 hypervisor 介入。所以我们把 ICC_SGI1R 的写**强制 trap**: + +```rust +// ICH_HCR_TALL1 = bit 11 +Self::write_hcr((ICH_HCR_TALL1 | ICH_HCR_EN) as u32); +``` + +TALL1=1 让 guest 的所有 `ICC_SGI1R_EL1` 写产生一个 EC=0x18(unknown trap) 的异常,带着 guest 写入的值。hypervisor 在 trap handler 里解码这个值,提取 TargetList 和 INTID,然后: + +1. 在 `pending_sgis[target_vcpu]` 原子位图里置位 +2. (多 pCPU)向目标物理 CPU 发物理 SGI 0 唤醒 +3. (单 pCPU)目标 vCPU 在下一次被调度时,inject 路径会从 pending_sgis 把这条 SGI 写到 LR + +ICC_SGI1R 的位域见 [Part 9 的相关段落](./part9-multi-pcpu.md)——`TargetList[15:0]`、`Aff1[23:16]`、`INTID[27:24]`。位置容易写反,我自己第一次写错过。 + +--- + +## ELRSR:LR 用完了怎么知道 + +LR 是稀缺资源,inject 之前要找空闲槽位。`ELRSR_EL2`(Empty List Register Status Register)是个 32 位寄存器,bit i 等于 1 表示 LR i 此刻空闲(已 EOI 完成,或从未写过): + +```rust +fn find_free_lr() -> Option { + let elrsr = read_elrsr(); + for i in 0..4 { + if elrsr & (1 << i) != 0 { + return Some(i); + } + } + None +} +``` + +Guest 处理完一个 IRQ、写 EOIR 之后,对应的 LR 状态从 active 翻成 invalid,ELRSR 对应位自动变 1。下一次 vCPU exit 时,我们扫 ELRSR,装新的 pending IRQ 进去。 + +如果 ELRSR 全是 0(四个 LR 都还在 pending / active 状态),说明 guest 还没处理完之前的中断,我们这一轮就 inject 不了任何新的。那些 pending 暂存在 `pending_sgis` / `pending_spis` 位图里。下次 exit 通常很快,队列消化得过来。 + +观察一下这套机制的两个特点。**第一,inject 是同步的——hypervisor 主动把 LR 写好再 ERET,guest 醒来就看见**。不像有些虚拟化方案要中断异步信号。**第二,EOI 是异步的——guest 自己写 EOIR 触发,hypervisor 完全不参与**。这种"一边主动写、一边硬件代劳"的不对称,是 GICv3 虚拟化效率的关键。 + +--- + +## 一次完整的 vtimer 注入 + +把上面这些串起来。一条 vtimer 中断从触发到 guest 处理完,完整流程: + +``` +1. 物理 vtimer 到期 (CNTV_CTL.ISTATUS=1) → 物理 INTID 27 触发 +2. 物理 GIC 把这条中断送到当前 pCPU 的 ICC_HPPIR1_EL1 +3. HCR_EL2.IMO=1 → IRQ 在 EL1 不能处理,trap 到 EL2 +4. Hypervisor 进 IRQ handler, mrs ICC_IAR1_EL1 → 27, ack physical +5. find_free_lr() → LR_2 +6. write_lr(2, state=pending, HW=1, virtual=27, physical=27, prio=...) +7. ERET 回 guest +8. 虚拟 GIC 把 ICV_HPPIR1_EL1 = 27 送给 guest +9. Guest IRQ handler: mrs ICC_IAR1_EL1 → 27 (硬件自动重定向到 ICV_IAR1_EL1) +10. 跑 timer handler +11. msr ICC_EOIR1_EL1, 27 → 虚拟侧 priority drop。LR_2 的 state + 由 active → invalid(EOImode=1 + HW=1 路径), + ELRSR[2]=1。物理侧不动作。 +12. msr ICC_DIR_EL1, 27 → 触发 deactivation。因 LR 之前是 HW=1, + 硬件把物理 GIC INTID 27 也 deactivate。 +13. 下次 vtimer 到期可以重新触发 +``` + +Hypervisor 介入只在第 4-6 步——读物理 IAR、写 LR、ERET。三条指令。Guest 看不到第 4 步存在,以为自己直接在处理物理硬件。EOI 路径里 hypervisor 完全不出现,纯硬件代劳。 + +下一篇我想讲 HPFAR_EL2:guest MMU 开起来之后,`FAR_EL2` 里装的不再是 IPA 而是 guest 的虚拟地址——做 MMIO emulation 时取错寄存器会调一整天。 + +--- + +代码: + +博客: + +*这是 ARM64 Hypervisor 开发系列的第十二篇。之前的文章索引在 [Part 0a](./part0a-why.md) 的末尾。* diff --git a/docs/zhihu/part13-hpfar-el2.md b/docs/zhihu/part13-hpfar-el2.md new file mode 100644 index 0000000..ec856cf --- /dev/null +++ b/docs/zhihu/part13-hpfar-el2.md @@ -0,0 +1,112 @@ +# FAR_EL2 给我的不是 IPA——Stage-2 fault 时该读哪个寄存器 + +Guest Linux 启动到一半。某条指令访问了一个 device,触发 Stage-2 fault,进我这边的异常 handler。我照着早期写好的代码读 `FAR_EL2`,准备 dispatch 给对应的 MMIO 仿真设备。 + +`FAR_EL2 = 0xffff_8000_0900_0000`。 + +UART 在 `0x0900_0000`,这个地址末尾对得上,但前面那一长串 `0xffff_8000` 哪里来的?那不是 IPA,那是 Linux 内核地址空间的某个虚拟地址。我的 device manager 按 IPA 路由,这个值过去当然找不到任何设备。MMIO 仿真直接走 fallback,guest 看见的就是"读到 0"或者"写入丢失",然后挂死在某个奇怪的位置。 + +修这个 bug 花了我一整天。回头看,根因一行话:**Stage-2 enabled 时,`FAR_EL2` 装的是 guest 的虚拟地址,不是 IPA。** + +--- + +## 翻 ARM ARM 之前我猜了什么 + +我先怀疑 device manager 路由错了。改了几版地址范围匹配,加了 log 打印 dispatch 路径。`FAR_EL2 = 0xffff_8000_0900_0000` 在我修改后的 dispatch 里依然不命中,因为它依然不像一个合法 device 地址。 + +然后怀疑 device 注册时初始化顺序有问题,UART 是不是太晚才进 manager 数组。给 `register_device()` 加 log,确认 UART 在第一次访问之前就已注册。 + +再后来怀疑 ESR_EL2 decoded 错了,是不是把 PERM fault 当成 TRANSLATION fault。读 `ESR_EL2.EC` 字段,EC=0x24,确实是 Data Abort from lower EL,没解错。 + +兜了一圈之后才去翻 ARM ARM——这本来该是第一件事。 + +--- + +## ARM ARM D13.2.55 那一段 + +> For exceptions taken from EL1 or EL0 due to a Stage-2 fault, the value of the virtual address is reported in FAR_EL2. Software must use HPFAR_EL2 to determine the IPA at which the fault occurred. + +一句话。**Stage-2 fault 时,FAR_EL2 报的是 virtual address**。 + +为什么这样设计?因为 Stage-2 fault 发生在 Stage-1 翻译**之后**——guest 的 MMU 已经把虚拟地址翻译成 IPA,然后 IPA 在 Stage-2 翻译时撞到 fault。硬件在 fault 那一刻能"忠实"报告的是 guest 软件最初使用的地址,也就是 VA(虚拟地址,在 guest 看来是它的物理地址)。IPA 在那时候也有,但 ARM 选择把它放进一个**专门**的寄存器:`HPFAR_EL2`(Hypervisor IPA Fault Address Register)。 + +设计上这是有理由的——FAR_EL2 在 EL2 异常里通用,**任何**异常都可能写它,语义需要保持一致。Stage-2 fault 把 IPA 塞 FAR_EL2 会让"FAR_EL2 在 Stage-1 fault 里是 VA,在 Stage-2 fault 里是 IPA"这种条件性混淆开来。HPFAR_EL2 单独的好处是清楚:**它只在 Stage-2 fault 时有意义,其他时候无视它**。 + +--- + +## 正确的 IPA 提取 + +HPFAR_EL2 不是直接的 IPA,而是 **IPA 的页帧号**。在 48 位 IPA 配置下(QEMU virt 默认),位域是: + +``` +HPFAR_EL2: + [39:4] FIPA[47:12] — Faulting Intermediate Physical Address (page number) + [3:0] RES0 +``` + +(开了 FEAT_LPA2 的 52 位 IPA 实现里,这一段扩成 `[43:4] = IPA[51:12]`。位宽对得上,只是覆盖范围更大。) + +注意 `[39:4]` 这个范围:**HPFAR_EL2 里存的是 IPA 右移 8 位的页号**。要还原出页基址,要左移 8 位回去: + +```rust +let ipa_page = (hpfar & 0x0000_0FFF_FFFF_FFF0) << 8; +``` + +Mask `0x0000_0FFF_FFFF_FFF0` 在 48 位 IPA 配置下实际只有 `[39:4]` 这 36 位会有意义的值,`[43:40]` 是 RES0(硬件保证为 0)。左移 8 位之后得到 `IPA[47:12] << 12`,就是 4 KB 页对齐的基址。如果以后开 FEAT_LPA2,这套 mask 也能直接覆盖 52 位 IPA 不用改。 + +页内偏移得另外取——`FAR_EL2[11:0]` 是页 offset(VA 的低 12 位等于 IPA 的低 12 位,因为 Stage-1 翻译以 4 KB 为粒度): + +```rust +let page_offset = far_el2 & 0xFFF; +let ipa = ipa_page | page_offset; +``` + +代码里这一段在 `src/arch/aarch64/hypervisor/exception.rs:455-459`: + +```rust +// HPFAR_EL2[43:4] = IPA[47:12] (page number) +// FAR_EL2[11:0] = page offset within the 4KB page +let ipa_page = (hpfar & 0x0000_0FFF_FFFF_FFF0) << 8; +let page_offset = context.sys_regs.far_el2 & 0xFFF; +let ipa = ipa_page | page_offset; +``` + +这一行注释比代码本身重要。后面任何人维护这段代码,都得知道"为什么 FAR_EL2 在这里只取低 12 位"——不然就会犯跟我一样的错。 + +--- + +## 为什么前期没踩坑 + +回头看,这个 bug 在 boot 的早期阶段**应该**早就触发的——hypervisor 启动后做的第一件事就有 MMIO 仿真(UART 打 log)。为什么前几周一直没事? + +因为前几周 guest 的 MMU 还是关着的。 + +Stage-1 翻译在 guest MMU off 时是 passthrough——VA == IPA。这种状态下 `FAR_EL2` 装的"虚拟地址"恰好等于"IPA",我那段错误的代码用 FAR_EL2 当 IPA,跑得跟对的没区别。 + +Linux 内核启动到 `arch/arm64/kernel/head.S` 后期会开 MMU(`SCTLR_EL1.M=1`),从此 VA != IPA。我的代码就在那一刻开始拿到"看起来不对的 FAR_EL2",但仍然按 IPA 拿去 dispatch——找不到设备,丢访问,挂死。 + +这种"前期无症状、跑到某个里程碑触发"的 bug,在裸机系统里特别多。Boot 流程里**任何一个状态开关**(MMU on、cache on、IRQ enable、virtualization enable)都可能改变某个寄存器的语义,而你之前的代码恰好绕开了那条语义。等到状态开关翻过去,bug 才显形。 + +--- + +## 一个推论 + +如果你写 hypervisor 时遇到"MMIO 仿真在 guest MMU off 时正常、开了之后挂"这种症状,**优先怀疑 FAR_EL2 取错**。不是其他几十件可能错的事。 + +更广义的检查:任何在异常里读地址的代码,看看那个寄存器的语义是不是依赖 fault 类型。`ESR_EL2.EC` 决定该读哪个寄存器,**永远先看 EC**。Hypervisor 关心的 guest 异常是"from lower EL"那一组: + +- Instruction Abort from lower EL (EC=0x20) — `FAR_EL2` 是 fault PC +- Data Abort from lower EL (EC=0x24) — `FAR_EL2` 是 guest VA,IPA 在 `HPFAR_EL2` +- Watchpoint from lower EL (EC=0x34) — `FAR_EL2` 是 trigger 地址 + +对应的 0x21 / 0x25 / 0x35 是 same EL 触发(hypervisor 自己代码崩了),要单独走 fault diag 路径——别跟 guest 异常用同一段处理。 + +写注释的时候把这个判断条件写下来,下一次任何人(包括你自己)动这段代码,都不用再花一天去 ARM ARM 里挖一遍。 + +--- + +代码: + +博客: + +*这是 ARM64 Hypervisor 开发系列的第十三篇。之前的文章索引在 [Part 0a](./part0a-why.md) 的末尾。* diff --git a/docs/zhihu/part14-tfa-boot-chain.md b/docs/zhihu/part14-tfa-boot-chain.md new file mode 100644 index 0000000..e7d53f9 --- /dev/null +++ b/docs/zhihu/part14-tfa-boot-chain.md @@ -0,0 +1,169 @@ +# TF-A 启动链上几个"规范没写但必须知道"的坑 + +QEMU 起来,TF-A BL1 → BL2 → BL31 都过了,日志里看到 BL31 准备转交给 BL32(我的 SPMC,挂在 S-EL2 那一层),然后**SPMC 入口直接挂死**——串口没新输出,没有 panic,没有 exception 日志,什么都没有。 + +我跑 `fiptool info build/qemu/debug/fip.bin` 想看 FIP 内容。每一项都对得上,SP UUID 列出来,size 不为零。再跑一次 `objdump -d` 看 SPMC 入口附近,反汇编出来是一串 `0x4750_4B53`、`0x0000_0001`、`0x0000_1000`——根本不是 ARM64 指令,而是 ASCII "SPKG" + 一段头部数据。 + +这就是症状。BL31 把我加载到 `0x0e100000`,我以为我的代码就在那个地址,实际上代码在那个地址**加 0x4000** 之后。中间 16 KB 是 SPKG 头加 manifest。 + +这一篇记四个让我反复栽跟头的具体细节——**FIP 里 SP 镜像怎么找、SPKG 头那 0x4000 偏移、UUID 字节序换没换、CTX_INCLUDE_FPREGS 跟谁互斥**。文档把"BL1 → BL2 → BL31 → BL32 → BL33"这条链画得清清楚楚,但两个 BL 之间约定的细节都藏在源码里。 + +--- + +## FIP 是个扁平容器,里面找东西靠 UUID + +TF-A 把所有 BL 镜像打成一个文件叫 FIP(Firmware Image Package)。BL1 / BL2 / BL31 / BL32 / BL33 / 各种 config DTB / SP 镜像,全部塞一个 `flash.bin` 里。物理介质上看就是 4 MB 一段、4 MB 一段的二进制连续排着。 + +要让 BL1 / BL2 知道每个东西在哪,FIP 头部有一个 `fip_toc`(目录)。每一项记录 `uuid + offset + size`,顺着 UUID 找对应内容。所以 FIP 不是按"名字"找,是**按 UUID 找**。 + +`fiptool info fip.bin` 把目录列出来给你看,大概长这样: + +``` +Trusted Boot Firmware BL2: offset=0xB0, size=0xBDD0, ... +EL3 Runtime Firmware BL31: offset=0xBE80, size=0xFF5D, ... +Secure Payload BL32: offset=0x1BDDD, size=0x475D9, ... +Non-Trusted Firmware BL33: offset=0x633B6, size=0x1064, ... +TB_FW_CONFIG: offset=0x6441A, size=0x1C7, ... +TOS_FW_CONFIG: offset=0x645E1, size=0x189, ... +78563412-7856-3412-7856-341278563412: offset=0x6476A, size=0x5111, ... # SP1 +DDCCBBAA-DDCC-BBAA-DDCC-BBAADDCCBBAA: offset=0x6987B, size=0x5088, ... # SP2 +00112233-0011-2233-0011-223300112233: offset=0x6E903, size=0x41EC, ... # SP3 +``` + +前几条是 TF-A 已知的"标准"组件,有人类可读的名字。SP 镜像不在 TF-A 的"标准"清单里,FIP 直接把 UUID 当 ID。BL2 启动 SP 的时候要靠这串 UUID 找。 + +--- + +## UUID 字节序换了一次 + +这就是第一个坑。`sp_manifest.dts` 里写 UUID 是这样: + +``` +sp_hello { + uuid = <0x12345678 0x12345678 0x12345678 0x12345678>; +}; +``` + +四个 32 位字。直觉上以为 `fiptool` 看到的应该是 `12345678-1234-5678-1234-567812345678`。但你前面那个表看到的是 `78563412-7856-3412-...`——每个 32 位字都被**字节反序**了。 + +为什么?TF-A 的 SP 打包工具 `sp_mk_generator.py` 在生成 SPKG 头时,把 manifest 里的 UUID 按 little-endian 序列化。`0x12345678` 在 LE 序列化下是 `78 56 34 12`,人类读出来就是 `78563412`。 + +工具这么做有它的理由(C 结构体里 UUID 是 16 字节数组,按 LE 写直接 memcpy 就对),但**这一步在 sp_manifest.dts 文档里没写**。你照着 manifest 写 UUID,然后想验证 fip 里有没有,直接 grep 原始 UUID 是 grep 不到的——它在 fip 里是字节反序之后的形态。 + +紧接着一个推论:**`tb_fw_config.dts` 里写的 UUID 必须用反序之后的形态**: + +``` +sp1 { + uuid = "78563412-7856-3412-7856-341278563412"; # 反序后的! + load-address = <0x0e300000>; +}; +``` + +BL2 启动时按 `tb_fw_config.dts` 里的 UUID 去 FIP 找 SP——这边 UUID 跟 FIP 里的 UUID 必须**字节级**对得上。我第一次写的时候把 `12345678` 直接抄进 `tb_fw_config`,BL2 找不到 SP1,日志显示 "SP UUID not found",这种错误信息让你完全猜不到症结在字节序。 + +`sp_manifest.dts` 的 UUID 是写**原始**形态、`tb_fw_config.dts` 是写**反序**形态。这件事我每次写新 SP 都要去翻一次 commit 提醒自己。 + +--- + +## SPKG 头那 0x4000 字节偏移 + +第二个坑。BL2 把 SP 镜像从 FIP 加载到 `tb_fw_config.dts` 里写的 `load-address`(比如 `0x0e300000`)之后,SPMC 拿到的不是"SP 入口在 `0x0e300000`",而是"SP 入口在 `0x0e300000 + 0x4000`"。 + +那 0x4000 字节是 **SPKG 头**(SP Package header),24 字节 LE 结构 + 一堆补齐: + +``` +Offset 0: magic[4] = "SPKG" +Offset 4: version (u32 LE) +Offset 8: pm_offset = 0x1000 (manifest 在包内的 offset) +Offset 12: pm_size (manifest 大小) +Offset 16: img_offset = 0x4000 (镜像在包内的 offset) +Offset 20: img_size (镜像大小) +``` + +`sp_mk_generator.py` 生成的 SPKG 把 manifest 放在 `[0x1000, 0x1000 + pm_size)`、镜像放在 `[0x4000, 0x4000 + img_size)`。中间 padding 是为对齐。 + +所以 SPMC 启动 SP 的时候要做: + +```rust +// src/main.rs: +const SPKG_IMG_OFFSET: u64 = 0x4000; +let sp_entry = sp_load_addr + SPKG_IMG_OFFSET; +``` + +24 字节头看起来很简单,但**TF-A 文档里不提**——它假设你直接用 sp_mk_generator 生成包,不会自己手动设入口地址。等你像我这样自己写 SPMC,把 SP 加载到一个地址然后 ERET 过去,你得**自己**知道镜像不在那个地址的开头,而在偏移 0x4000 处。 + +第一次没注意时,直接 ERET 到 `0x0e300000`,CPU 取指到 SPKG 头的 "SPKG" 四个字节——`0x4750_4B53`,在 ARM64 里随便解码成一条无效指令,Undefined Instruction exception,挂死。日志里你只看到 EC=0x00 (unknown),根本看不出"哦我跳到 header 上了"。 + +--- + +## CTX_INCLUDE_FPREGS 不能跟 ENABLE_SVE/SME_FOR_NS 一起 + +第三个坑,这个是我跨了一整天的。 + +[Part 7](./part7-bare-metal-rust-pitfalls.md) 讲过 `CPTR_EL3.TFP=1` 会把 S-EL2 的 FP/SIMD 指令 trap 到 EL3,Rust debug 模式的 NEON 指令会让 SPMC 静默挂死。修法是 TF-A 编译时加 `CTX_INCLUDE_FPREGS=1`,这个 flag 让 TF-A 保存/恢复 FP 寄存器,顺带清掉 `CPTR_EL3.TFP`。 + +我加进去之后 TF-A 构建直接报错: + +``` +ENABLE_SVE_FOR_NS is mutually exclusive with CTX_INCLUDE_FPREGS +``` + +TF-A 默认 `ENABLE_SVE_FOR_NS=1`(允许 Normal world 用 SVE)。SVE 寄存器跟 FP 寄存器在硬件层重叠,TF-A 内部两种保存路径互斥。要么 TF-A 自己管 SVE(`ENABLE_SVE_FOR_NS=1` + 不开 CTX_INCLUDE_FPREGS,SVE 保存路径同时处理 FP),要么 hypervisor 自己管 FP(`ENABLE_SVE_FOR_NS=0` + `CTX_INCLUDE_FPREGS=1`)。 + +SME 同理。完整组合是: + +```makefile +CTX_INCLUDE_FPREGS=1 +ENABLE_SVE_FOR_NS=0 +ENABLE_SME_FOR_NS=0 +``` + +三个一起设才编得过。错过任何一个 TF-A 都会在构建阶段卡。文档**有**写,但散在 `docs/getting_started/build-options.rst` 三个不同地方,不连续读容易漏。我第一次开了 `CTX_INCLUDE_FPREGS` 没 disable SVE,构建报错;关 SVE 没关 SME,构建又报错;来回试了三轮才把三个 flag 凑齐。 + +写下来:**S-EL2 跑 Rust(或者任何会 emit NEON 的语言)的 hypervisor,这套三个 flag 是固定组合,记下来一次别再忘**。 + +--- + +## fiptool info 是你最好的朋友 + +调 TF-A 时,任何关于"BL2 找不到 X / 启动到 Y 就挂"的怀疑,**先跑一次 `fiptool info fip.bin`**。它把 FIP 里所有组件的位置、大小、UUID 列出来。 + +```bash +$ tools/fiptool/fiptool info build/qemu/debug/fip.bin +Trusted Boot Firmware BL2: offset=0xB0, size=0xBDD0, cmdline="--tb-fw" +EL3 Runtime Firmware BL31: offset=0xBE80, size=0xFF5D, cmdline="--soc-fw" +Secure Payload BL32: offset=0x1BDDD, size=0x475D9, cmdline="--tos-fw" +Non-Trusted Firmware BL33: offset=0x633B6, size=0x1064, cmdline="--nt-fw" +TB_FW_CONFIG: offset=0x6441A, size=0x1C7, cmdline="--tb-fw-config" +TOS_FW_CONFIG: offset=0x645E1, size=0x189, cmdline="--tos-fw-config" +78563412-7856-3412-7856-341278563412: offset=0x6476A, size=0x5111, cmdline="--blob" +DDCCBBAA-DDCC-BBAA-DDCC-BBAADDCCBBAA: offset=0x6987B, size=0x5088, cmdline="--blob" +00112233-0011-2233-0011-223300112233: offset=0x6E903, size=0x41EC, cmdline="--blob" +``` + +这一段输出能验证: + +- FIP 包了所有该有的组件(BL2/BL31/BL32/BL33/configs/SPs) +- SP UUID 形态(看是反序还是原始,跟 `tb_fw_config.dts` 对比) +- 每个组件的实际 size(SP 镜像 size > 0x4000 + 你预期的 img_size,因为含 SPKG 头 + manifest + padding) +- 没有意外的 size=0 项(说明某个 build step 没产出对应文件) + +`fiptool info` 是 FIP 的 X 光片。出问题的时候它 99% 能告诉你"哪一块不对"。 + +--- + +## 收尾 + +TF-A 这套启动链上的隐性约定,大半藏在源码、build 系统、Python 工具脚本里。文档把概念讲清楚,**约定**让你自己挖。 + +我自己的经验:**碰到症状不明的 boot 阶段错误,先做这三件事**——`fiptool info` 看 FIP 内容、grep 二进制看 SPKG 头 magic 在不在 `+0x4000`、对比 `tb_fw_config.dts` 跟 FIP 里的 UUID 字节序。三件事不到五分钟,80% 的 boot 阶段错误都能定位。 + +下一篇我想讲 FF-A v1.1 的协议层细节——RXTX mailbox 怎么注册、composite memory region descriptor 是怎样的嵌套结构、fragmentation 怎么把超长描述符切成多次 SMC 传过来。FF-A 这一层,代码本身比规范文档更像规范。 + +--- + +代码: + +博客: + +*这是 ARM64 Hypervisor 开发系列的第十四篇。之前的文章索引在 [Part 0a](./part0a-why.md) 的末尾。* diff --git a/docs/zhihu/part15-ffa-protocol-mechanics.md b/docs/zhihu/part15-ffa-protocol-mechanics.md new file mode 100644 index 0000000..e15e912 --- /dev/null +++ b/docs/zhihu/part15-ffa-protocol-mechanics.md @@ -0,0 +1,186 @@ +# FF-A 描述符的四层套娃,以及包不下一个 SMC 时怎么办 + +[Part 10](./part10-ffa-mem-share.md) 讲了 MEM_SHARE 走完一遍是怎样的流程——六个 SMC、两本账、四个状态。这一篇换个角度,讲那一笔 MEM_SHARE 真正塞进 TX buffer 的**那段二进制**长什么样、怎么 parse,以及描述符长到放不下一条 SMC 的时候,**MEM_FRAG_TX / MEM_FRAG_RX** 这对调用怎么把它切开传过来。 + +FF-A 文档(DEN0077A Table 5.19-5.25)把这些结构画在表里,**字段偏移、字节宽度、含义**列得明白,但落到代码上的几个细节——比如 `#[repr(C, packed)]` 怎么避免对齐坑、RXTX mailbox 的 `rx_held_by_proxy` 状态机、分片状态机怎么用句柄串起来——都是文档不会教的。 + +主线在 `src/ffa/descriptors.rs`、`src/ffa/mailbox.rs` 和 `src/ffa/proxy.rs` 的几个 fragment state 那一段。 + +--- + +## 一个真实的描述符,层层剥开 + +NWd 用 MEM_SHARE 共享一段两个 range 的内存给 SP1。它把这个描述符写在自己的 TX buffer 里。二进制布局(48 + 16 + 16 + 16×2 = 112 字节): + +``` +Offset 0 [FfaMemRegion] 48 字节头 + 0: sender_id (u16) = 0x0000 (NWd) + 2: attributes (u16) = ... (cacheability / shareability) + 4: flags (u32) = ... + 28: receiver_count (u32) = 1 + 32: receivers_offset (u32) = 48 (第一个 receiver desc 在哪) + +Offset 48 [FfaMemAccessDesc] 16 字节 + 0: receiver_id (u16) = 0x8001 (SP1) + 2: permissions (u8) = ... (RW) + 4: composite_offset (u32) = 64 (composite desc 在哪) + 8: flags (u64) = 0 + +Offset 64 [FfaCompositeMemRegion] 16 字节 + 0: total_page_count (u32) = 3 (两个 range 加起来 3 页) + 4: address_range_count (u32) = 2 (两个 range) + 8: reserved (u64) = 0 + +Offset 80 [FfaMemRegionAddrRange × 2] 16 × 2 = 32 字节 + 0: address (u64) = 0x42000000, page_count = 1 + 16: address (u64) = 0x42010000, page_count = 2 +``` + +四层套娃。**`FfaMemRegion`** 是顶层 header,记总体信息(发送方、receiver 列表在哪)。**`FfaMemAccessDesc`** 一个 receiver 一份,记某个特定 receiver 的访问权和它对应的 composite descriptor 在哪。**`FfaCompositeMemRegion`** 是内存区域的元信息(总页数 + 子区段数)。**`FfaMemRegionAddrRange`** 是真正的 (addr, page_count) 对,可以多个,描述非连续内存。 + +为什么不直接拍平?因为同一段内存可以同时共享给多个 receiver,每个 receiver 的权限不同——**`FfaMemAccessDesc` 一对多**,共享同一个 composite 但权限各自记。 + +--- + +## `#[repr(C, packed)]` 跟 `read_unaligned` 的搭配 + +四个结构在 Rust 里都标了 `#[repr(C, packed)]`: + +```rust +// src/ffa/descriptors.rs +#[repr(C, packed)] +pub struct FfaMemRegion { + pub sender_id: u16, + pub attributes: u16, + pub flags: u32, + // ... 一直到 48 字节 +} + +#[repr(C, packed)] +pub struct FfaCompositeMemRegion { + pub total_page_count: u32, + pub address_range_count: u32, + pub reserved: u64, +} +``` + +`packed` 让结构体按 1 字节对齐(打消编译器为了对齐自动加 padding),这是规范要求的——FF-A 描述符在不同 endian / 不同 word size 的实现之间要二进制兼容,padding 不能由编译器决定。 + +代价是:**字段读出来不能直接 `&desc.composite_offset`**——packed 字段地址可能不对齐,在 ARM64 上对一个 misaligned `*const u32` 直接 deref 是 UB,Rust 给你警告或者直接拒绝编译。 + +正确做法是 `core::ptr::read_unaligned`: + +```rust +// src/ffa/descriptors.rs:131-132 +let receiver_count = core::ptr::read_unaligned(tx_ptr.add(28) as *const u32); +let receivers_offset = core::ptr::read_unaligned(tx_ptr.add(32) as *const u32); +``` + +`read_unaligned` 在硬件层用字节级 load 凑出来,**不会 emit 任何依赖对齐的指令**——具体说,不会 emit `ldr w0, [x1]`(这条要求 4 字节对齐),会 emit 一串 `ldrb` 凑成一个 32 位字。性能略差,但对 packed FF-A 描述符这种"只 parse 一次再丢弃"的场景完全可接受。 + +[Part 7](./part7-bare-metal-rust-pitfalls.md) 那个 NEON 坑里讲过,`read_volatile` 在 Rust debug 模式会塞 NEON 对齐检查。`read_unaligned` 是另一条路径,不走对齐检查——但它跟 `read_volatile` 不能合用,如果你要"既 volatile 又 unaligned"得自己用 `core::arch::asm!` 写一串 `ldrb`。 + +--- + +## RXTX Mailbox:三个状态位串起一对 buffer + +FF-A 每个 endpoint(VM 或 SP)发起任何"内容超过 8 个 64 位寄存器能装下"的调用,都得先注册一对 buffer——TX(自己写、对方读)和 RX(对方写、自己读)。这是 `FFA_RXTX_MAP`。 + +我们这边 per-VM 存的 `FfaMailbox` 结构: + +```rust +// src/ffa/mailbox.rs +pub struct FfaMailbox { + pub tx_ipa: u64, // guest TX buffer IPA + pub rx_ipa: u64, // guest RX buffer IPA + pub page_count: u32, // 通常是 1 + pub mapped: bool, // 注册了没 + pub rx_held_by_proxy: bool, // RX 当前归谁 + pub msg_pending: bool, // RX 里有没有未读消息 + pub msg_sender_id: u16, // 那条消息的发送方 +} +``` + +最关键的是 `rx_held_by_proxy`。FF-A 的 RX buffer 有"所有权"概念——**写 RX 的人和读 RX 的人不能同时碰**。初始注册之后 RX 归 proxy 所有(proxy 可以往里写,作为 PARTITION_INFO_GET 之类调用的响应);proxy 写完之后通过 `FFA_SUCCESS` 把所有权交给 VM(VM 可以读);VM 读完之后发 `FFA_RX_RELEASE` 把所有权交还给 proxy。 + +``` +状态机: + proxy_owned --[proxy writes + returns success]--> vm_owned + vm_owned --[FFA_RX_RELEASE]--> proxy_owned +``` + +如果 proxy 在 `vm_owned` 状态下再次想往 RX 写,得返回 `FFA_BUSY` 让调用方稍后再试。这是 FF-A 防止 race 的简单办法——基于状态位的所有权,而不是真的加锁(锁的对象是 buffer,锁的边界跨调用方很难定义)。 + +`msg_pending` + `msg_sender_id` 这两个字段是给 `FFA_MSG_SEND2`(间接消息)用的——proxy 把发送方塞进 RX,等接收方主动 `FFA_MSG_WAIT` 来取。轮询模型。 + +--- + +## 分片:描述符 > 4 KB 怎么办 + +NWd 这次的 MEM_SHARE 要共享 30 段不连续的内存给 SP1。每段一个 `FfaMemRegionAddrRange` 占 16 字节,加上 48+16+16 字节的三层 header,描述符长度算出来 580 字节——还行,塞得进 4 KB TX buffer。 + +但如果是 100 段、300 段呢?或者多个 receiver,每个 receiver 一份 `FfaMemAccessDesc` + 一份 composite?到 4 KB 装不下的时候,FF-A 给的解法是 **fragmentation**: + +- 发送方第一片塞进 TX 里发 `MEM_SHARE`,带上"总长度 total_length"和"这一片长度 fragment_length" +- proxy 收到,如果 `total_length > fragment_length`,意识到这是第一片,返回 `FFA_MEM_FRAG_RX` 和一个临时 handle,**告诉发送方继续** +- 发送方下次发 `MEM_FRAG_TX`,带 handle 和下一段 +- proxy 累加到本地 buffer,直到 `received == total_length` +- 这时候才**真正**做 share 解析、Stage-2 映射、记账 + +代码里维护这个状态机的是 `FragmentState`(发送侧)和 `FragRxState`(接收侧): + +```rust +// src/ffa/proxy.rs +struct FragmentState { + active: bool, + handle: u64, + total_length: u32, + received: u32, + accum_buf: [u8; 4096], + is_lend: bool, + is_donate: bool, + sender_id: u16, +} +``` + +per-VM 一个状态。同时刻一个 VM 至多有一笔 MEM_SHARE 在分片中(由 handle 串联),不同 VM 互不影响。**`accum_buf` 4096 字节是本实现给描述符总长度设的上限**——`total_length > 4096` 直接返回 `FFA_INVALID_PARAMETERS`。也就是说这套 fragmentation 不是为"任意大描述符"准备的,而是为"超过单条 SMC 寄存器装载能力(8×8 字节)、但仍在单 TX page 范围内"的场景。生产环境如果真要传更大的描述符,这一段得换成动态分配 + 流式 parse。 + +接收侧的 `FragRxState` 是镜像问题:`MEM_RETRIEVE_REQ` 的 RESP 描述符比 RX buffer 大时,receiver 第一次拿到的是头部,然后通过 `MEM_FRAG_RX` 一次次取剩余: + +```rust +struct FragRxState { + active: bool, + handle: u64, + total_length: u32, + delivered: u32, + sender_id: u16, +} +``` + +发送和接收的分片各自有 5 个字段、各自有状态机,而且**两边都得做边界校验**——`fragment_length > total_length`、`received > total_length`、`delivered > total_length`、`handle` 对不上,全部返回错误。这种"FF-A 规范定义错误码、实现负责具体校验"的代码很多,占整个 FF-A 模块约一半行数。 + +--- + +## 接收侧分片:两个状态机在 RX 那一页上撞到一起 + +receiver 这边发 `MEM_RETRIEVE_REQ`,proxy 写了头一片到 RX,把 RX 移交给 VM 读;VM 读完发 `FFA_RX_RELEASE`,RX 还给 proxy;然后 VM 发 `MEM_FRAG_RX` 要下一片,proxy 再写。这中间**两套状态机**在动:`FragRxState` 跟 handle 走,记"这一笔 RETRIEVE 已经送出去多少字节";`FfaMailbox.rx_held_by_proxy` 跟 RX page 走,记"现在 RX 归谁"。 + +如果两套状态对不齐——比如 VM 还没 release RX,proxy 想写下一片——proxy 这边的 `MEM_FRAG_RX` 处理就要返回 `FFA_BUSY`,逼 VM 先做 RX_RELEASE。FF-A 把"协议层的分片状态"跟"传输层的 buffer 所有权"分开维护,正是为了让这种 race 有明确的恢复路径——出错就 BUSY,VM 自己来选什么时候解。 + +--- + +## 收尾 + +FF-A 协议表面是"几十个 SMC 函数 ID"。真正复杂的是这些 SMC 之间共享的状态——RXTX 所有权、分片重组、句柄追踪、描述符 packed 解析。**规范定义状态,实现把状态分配到具体字段,每加一笔 FF-A 调用都要看清这些状态怎么动**。 + +代码里 `descriptors.rs` + `mailbox.rs` + `proxy.rs` 加起来约 2000 行,大部分是状态校验和边界 check。如果只看"功能本身",可能 600 行就够。多出来那 1400 行,是 FF-A 把"两个互不信任的 endpoint 通过共享 buffer 交换数据"这件事做对的代价。 + +下一篇我想讲 virtio-blk 和 virtio-net 从零搭起来——virtqueue 描述符链怎么走、`virtio_net_hdr_v1` 那 12 字节前缀容易踩、RX 异步注入怎么用 SPSC ring 解开 producer/consumer 边界。 + +--- + +代码: + +博客: + +*这是 ARM64 Hypervisor 开发系列的第十五篇。之前的文章索引在 [Part 0a](./part0a-why.md) 的末尾。* diff --git a/docs/zhihu/part16-virtio-from-scratch.md b/docs/zhihu/part16-virtio-from-scratch.md new file mode 100644 index 0000000..00e9d67 --- /dev/null +++ b/docs/zhihu/part16-virtio-from-scratch.md @@ -0,0 +1,242 @@ +# virtio-blk 和 virtio-net 从描述符那一头讲起 + +Guest 的 Linux 内核往 virtio-blk 的 MMIO 寄存器 `QueueNotify` 写了一个 0。它告诉 hypervisor:"我刚在 virtqueue 0 里放了一个新请求,你处理一下。" + +hypervisor 的异常处理 trap 这个 MMIO 写,进入 `VirtioMmioTransport` 的 dispatch。它要做的事:从 guest 内存里读 virtqueue 的 available ring 头部,拿到这个请求对应的描述符链(descriptor chain),按 virtio-blk 的协议解析出请求类型、起始扇区、数据缓冲区、状态写回缓冲区,真正去硬盘镜像文件读/写,然后把结果写回 used ring,inject SPI 48 通知 guest。 + +这一篇拆开这条路径上的几个具体细节:**MMIO 寄存器布局、virtqueue 的三环结构、descriptor 链怎么走、virtio-blk 请求的三段式格式、virtio-net 那 12 字节 `virtio_net_hdr_v1` 前缀容易踩**。代码在 `src/devices/virtio/{mmio,queue,blk,net}.rs` 这一组。 + +--- + +## virtio-mmio:寄存器层 + +virtio 有 PCI、CCW、MMIO 三种 transport。我们用 MMIO,因为最简单——一段连续 MMIO 地址,寄存器按 offset 排好,guest 直接 load/store 访问。MMIO transport 的寄存器布局在 virtio v1.0 spec 4.2.2: + +``` +0x000 MagicValue (RO) = "virt" +0x004 Version (RO) = 2 +0x008 DeviceID (RO) = 1 (net) / 2 (blk) / ... +0x00C VendorID (RO) +0x010 DeviceFeatures (RO) +... +0x030 QueueSel (WO) 选择当前操作哪个 virtqueue +0x038 QueueNumMax (RO) +0x03C QueueNum (WO) +0x044 QueueReady (RW) +0x050 QueueNotify (WO) ← guest 往这写,通知有新请求 +... +0x070 Status (RW) +0x080 QueueDescLow/High Descriptor table 物理地址(2x32位 = 64位) +0x090 QueueAvailLow/High Available ring 地址 +0x0A0 QueueUsedLow/High Used ring 地址 +``` + +hypervisor 这边在 `VirtioMmioTransport::mmio_read/write` 实现这些寄存器的访问。大多数寄存器是返回常量(MagicValue / Version / DeviceID)或者记录 guest 选择(QueueSel / QueueNum / QueueDescLow/High 之类)。**真正引发"开始处理请求"动作的主要是两个**——`Status` 写到 `DRIVER_OK`(guest 完成了初始化握手,后端可以开工)和 `QueueNotify` 写(guest 提交了新请求,后端开始处理)。其他写法主要是更新配置,把映射地址、队列大小记下来留给后续 Notify 用。 + +--- + +## Virtqueue 是三个 ring + +每个 virtqueue 由三块共享内存组成,**都在 guest 物理内存里**,hypervisor 通过 IPA 访问。三块的角色: + +``` +Descriptor table: 16B × N entries — 描述单个 buffer 的 (addr, len, flags, next) +Available ring: guest → hypervisor 提交 (descriptor index 数组) +Used ring: hypervisor → guest 返回 (idx, len) +``` + +**Descriptor 链**:一个请求可以由多个 descriptor 串成链,通过 `next` 字段。flags 里的 `VIRTQ_DESC_F_NEXT` 标志这一项还有下一段。这种"分段描述符"是 virtio 的 scatter-gather 语义——guest 可以把请求拆成"控制信息块"+"数据块"+"状态写回块",每块在 guest 内存里物理上不连续,但通过链串起来。 + +**Available ring**:guest 把要提交的描述符链头部 index 写进 ring,递增 idx。hypervisor 轮询 ring 找新项目处理。简化结构: + +```rust +struct VirtqAvail { + flags: u16, + idx: u16, // 写到哪了 + ring: [u16; QUEUE_SIZE], + // used_event: u16 (可选 feature) +} +``` + +**Used ring**:hypervisor 处理完一个请求,把 (descriptor head index, written length) 写进 used ring,递增 used.idx。Guest 看到 used.idx 增长,知道有完成的请求。 + +hypervisor 处理一次 `QueueNotify` 的流程: + +``` +1. last_avail_idx 跟 avail.idx 比 — 有几个新提交 +2. 循环每个新项目: + a. head = avail.ring[last_avail_idx % QUEUE_SIZE] + b. 走描述符链:descs[head], descs[head].next, ... + c. 把整条链丢给 backend (process_request) + d. backend 处理完,返回写回 used: written + e. used.ring[used.idx % QUEUE_SIZE] = (head, written) + f. used.idx += 1, last_avail_idx += 1 +3. inject SPI 通知 guest +``` + +整条循环里 hypervisor 不分配内存——所有 buffer 都在 guest 内存里,我们只 read/write,不 copy。性能这一段决定了 virtio 的基本上限。 + +--- + +## virtio-blk 请求:三段式 + +virtio-blk 的请求按 spec 5.2.6 是三段:**header (16B)**、**data (变长)**、**status (1B)**: + +``` +header (RO from device): + type: u32 (0=in, 1=out, 4=flush, ...) + reserved: u32 + sector: u64 + +data (RO from device for write, WO for read): + size 由 descriptor.len 决定 + +status (WO from device): + 0=ok, 1=ioerr, 2=unsupported +``` + +Guest 用三个 descriptor 串成一条链:头部、数据缓冲、状态缓冲。flags 区分: + +- 头部:flags = NEXT(链向下一个) +- 数据:flags = NEXT | WRITE(WRITE 表示 device 写入这个 buffer,即 guest 读) +- 状态:flags = WRITE(无 NEXT,最后一段) + +`process_request` 解析这三段: + +```rust +// src/devices/virtio/blk.rs (简化) +fn process_request(&mut self, queue: &mut Virtqueue, head: u16, + descs: &[VirtqDesc], count: usize) { + if count < 2 { return; } // 至少头 + 状态 + + // 第 0 段是 header + let header_addr = descs[0].addr; + let req_type = read_u32(header_addr); + let sector = read_u64(header_addr + 8); + + // 中间是 data,最后是 status + let status_idx = count - 1; + let data_descs = &descs[1..status_idx]; + + let result = match req_type { + 0 => self.do_read(sector, data_descs), + 1 => self.do_write(sector, data_descs), + _ => Err(VIRTIO_BLK_S_UNSUPP), + }; + + let status_byte = match result { Ok(_) => 0, Err(e) => e }; + write_u8(descs[status_idx].addr, status_byte); +} +``` + +读写真实数据时,`data_descs` 可能多段——guest 的物理内存如果不连续(典型情况),会拆成多个 4 KB descriptor。`do_read` 走整个 data_descs 顺序写,`do_write` 顺序读。 + +`addr` 是 guest physical address (IPA)。我们这边 Stage-2 是 identity mapping([Part 11](./part11-stage2-heap-gap.md) 讲过),所以 `addr` 直接当物理地址用,`unsafe { read_volatile / copy_nonoverlapping }` 就行。 + +--- + +## virtio-net 的 12 字节前缀 + +virtio-net 跟 virtio-blk 的结构类似——两个 virtqueue(RX queue 0,TX queue 1),descriptor 链,scatter-gather。但有个特别的细节:**每个数据包前面有一个 header 前缀**——协商了 `VIRTIO_NET_F_MRG_RXBUF` feature(我们这边默认协商)就是 `virtio_net_hdr_v1`,**12 字节**;没协商就是更老的 `virtio_net_hdr`,**10 字节**(差那 2 字节就是 `num_buffers` 字段)。我们这边一律按 12 字节走。 + +``` +virtio_net_hdr_v1 (12 B): + flags: u8 + gso_type: u8 + hdr_len: u16 + gso_size: u16 + csum_start: u16 + csum_offset: u16 + num_buffers: u16 ← VIRTIO_NET_F_MRG_RXBUF 协商后这字段存在 +``` + +guest 发包时(TX queue),descriptor 链第一段是这 12 字节 header,后面是真正的 Ethernet 帧。hypervisor 处理 TX: + +```rust +// src/devices/virtio/net.rs (简化) +fn process_tx(&mut self, descs: &[VirtqDesc]) { + // 跳过前 12 字节,取真正的 frame + let header_addr = descs[0].addr; + let frame_start = header_addr + 12; + let frame_len = descs[0].len - 12; + + // 直接交给 vswitch + let frame = read_buf(frame_start, frame_len); + crate::vswitch::vswitch_forward(self.port_id, &frame); +} +``` + +12 字节里我们一字都不看——guest 发包时设这些字段是给"硬件 offload"用的(校验和卸载、TSO、GSO),我们的 vSwitch 是纯软件,什么都不卸载,直接转发。 + +RX 反过来——把帧塞回 guest 时**必须**先写 12 字节 header,再写帧: + +```rust +fn inject_rx(&mut self, frame: &[u8], descs: &[VirtqDesc]) { + let head_addr = descs[0].addr; + + // 写 12 字节 header,全部 0 除了 num_buffers=1 + unsafe { + core::ptr::write_bytes(head_addr as *mut u8, 0, 12); + core::ptr::write_unaligned((head_addr + 10) as *mut u16, 1); + } + + // 写帧 + let frame_dst = head_addr + 12; + unsafe { + core::ptr::copy_nonoverlapping( + frame.as_ptr(), + frame_dst as *mut u8, + frame.len(), + ); + } +} +``` + +第一次写 virtio-net 时我没注意到这 12 字节前缀的事——直接把帧从 vSwitch 投递到 guest 的 RX queue,guest 的网络栈跑出来全是错的(它以为前 12 字节是 IP header,后面是 IP payload,但偏移整体错了 12 字节)。tcpdump 在 guest 里抓出来的包看起来"对不上结构",查了半天才意识到 virtio 协议要求这个 header。 + +--- + +## RX 路径的异步问题 + +virtio-net 的 TX 是同步的——guest 写 QueueNotify,trap 进 hypervisor,hypervisor 立刻处理,return,一气呵成。 + +RX 不能这样。RX 帧的"发起点"是另一台 VM 通过 vSwitch forward 过来的(见 [Part 8](./part8-multi-vm-vswitch.md))——发生在 hypervisor 的某次异常上下文里(那一台 VM 的 virtio-net TX 处理过程中)。这时候我们想把帧塞给本 VM,但**本 VM 此刻可能并没在跑**——它在另一台 pCPU 上,或者根本没被调度到。 + +vSwitch forward 不能直接调本 VM 的 `inject_rx`——会撞 `DEVICES` 锁(我们已经在持有 source VM 的 `DEVICES` 锁)。所以 RX 走一个 per-port SPSC ring buffer: + +```rust +// src/vswitch.rs +pub static PORT_RX: [NetRxRing; MAX_PORTS] = [...]; + +// vSwitch forward 路径 (在 EL2 异常上下文里): +PORT_RX[dst_port].store(frame); + +// VM 调度循环里 (主循环上下文): +fn drain_net_rx(vm_id: usize) { + while let Some(frame) = PORT_RX[vm_id].take() { + DEVICES[vm_id].inject_net_rx(&frame); + } +} +``` + +SPSC ring 让 producer(vSwitch forward, 异常上下文)和 consumer(drain, 主循环)解耦——producer 不需要等 consumer,consumer 也不需要等 producer。两边用原子 head/tail 索引,acquire/release ordering 保证内存可见性。 + +这种"trap 时只入队,真正处理放到主循环"的 pattern,在裸机里反复出现。**异常上下文要尽量短**——它持有的锁可能在主循环里也要拿。两者解耦的代价是一个 ring buffer,收益是 hypervisor 整体的可调度性。 + +--- + +## 收尾 + +virtio 协议层不长——MMIO 寄存器 30 个、virtqueue 三个 ring、descriptor 16 字节、协议 header 几个字段。难的不是协议,是**协议跟 hypervisor 自己执行模型的耦合**:RX 不能同步、descriptor 不能跨页假设、IPA 翻译要看 Stage-2 配置。这些约束让 virtio 后端代码比看起来多 30%。 + +代码量上,`virtio/mmio.rs` + `virtqueue.rs` + `blk.rs` + `net.rs` 加起来约 1100 行,其中 mmio.rs 占一半(寄存器分发 + descriptor chain walk)。剩下的是设备特定逻辑。 + +下一篇我想讲 Secondary CPU 在 S-EL2 的完整 warm-boot 流程——[Part 4](./part4-war-stories.md) 是"发现 SPMD 状态机",这一篇是"装一颗 secondary 完整要走哪几步、为什么顺序敏感"。 + +--- + +代码: + +博客: + +*这是 ARM64 Hypervisor 开发系列的第十六篇。之前的文章索引在 [Part 0a](./part0a-why.md) 的末尾。* diff --git a/docs/zhihu/part17-secondary-warmboot.md b/docs/zhihu/part17-secondary-warmboot.md new file mode 100644 index 0000000..69d9203 --- /dev/null +++ b/docs/zhihu/part17-secondary-warmboot.md @@ -0,0 +1,154 @@ +# Secondary CPU 在 S-EL2 醒来,接下来要走的六步 + +[Part 4](./part4-war-stories.md) 讲过我花了几小时调通的那个 SPMD per-CPU 握手——发现 `FFA_MSG_WAIT` 是 secondary 上电的必经动作。这一篇接着讲**握手前面那五步**:secondary CPU 从 `secondary_entry_sel2` 那个标号开始执行,到能调 `FFA_MSG_WAIT` 之间,EL2 上要装配多少东西。 + +六步,顺序敏感。漏一步 hypervisor 在那颗 CPU 上要么静默挂死,要么不响应中断,要么响应中断时崩。代码在 `src/main.rs::rust_main_sel2_secondary`,从 boot 进 Rust 之后的第一行起。 + +--- + +## 第 1 步:装异常向量 + +```rust +exception::init(); +``` + +`exception::init()` 把 `VBAR_EL2` 设成我们 vector table 的物理地址。**这必须是第一步**——之前任何 trap、page fault、中断都会去硬件默认的位置(通常是 ROM 中的某段无效区域),hypervisor 直接挂。 + +Primary CPU 启动时也做同样的事,但 secondary 不能共享 primary 那一次设置——`VBAR_EL2` 是**每颗物理 CPU 独立的(per-PE)**,你在 primary 上写的值跟 secondary 上的值是两个不同的实例。每颗 CPU 醒来都得自己 `msr vbar_el2, ...`。 + +--- + +## 第 2 步:开 Secure Stage-2 + +```rust +let hcr: u64; +asm!("mrs {}, hcr_el2", out(reg) hcr, ...); +asm!("msr hcr_el2, {hcr}", "isb", + hcr = in(reg) hcr | HCR_VM, ...); +``` + +`HCR_EL2.VM=1` 让 secure Stage-2 翻译生效。SP1/SP2/SP3 在 S-EL1 跑,它们的 IPA 经过 Secure Stage-2 翻译才到真物理地址。 + +为什么 primary CPU init 阶段已经设过这位,secondary 还要再设?因为 `HCR_EL2` 跟 `VBAR_EL2` 一样,**每颗物理 CPU 一份独立的(per-PE)**,从 reset 状态出来值为 0。TF-A 的 secondary warm-boot 把 secondary 转交给 SPMC 时不会替你保留 primary 的设置。 + +`isb` 不能省。`msr hcr_el2` 之后下一条指令的取指阶段可能用旧的 trap 配置去判断,要 `isb` 强制 pipeline 同步才能让新值生效。 + +--- + +## 第 3 步:清 trap 位(CPTR / MDCR) + +```rust +asm!( + "mrs x0, cptr_el2", + "bic x0, x0, {cptr_tz}", // 不 trap SVE + "bic x0, x0, {cptr_tfp}", // 不 trap FP/SIMD + "bic x0, x0, {cptr_tsm}", // 不 trap SME + "bic x0, x0, {cptr_tcpac}", // 不 trap CPACR + "msr cptr_el2, x0", + "msr mdcr_el2, xzr", // 不 trap debug + "isb", ...); +``` + +Primary CPU 启动时也清这些 trap 位,但 secondary CPU 走 TF-A 的 warm-boot 路径上电,**`CPTR_EL2` / `MDCR_EL2` 的状态跟 primary 不一样**——可能保留 reset 值,可能保留 TF-A 临时设置的某个状态。 + +这一步关键是要在**MMU 开之前做**。后面要 `SCTLR_EL2.M=1` 启用 Stage-1 MMU,启用瞬间硬件会取指、检查权限、跑 `isb`。如果 `CPTR_EL2.TFP=1` 还在,而 Rust debug 模式编译进的 NEON 对齐检查在 `install_sel2_stage1_secondary` 的某条 `read_volatile` 里被 emit 出来,那条 `cnt v0.8b` 就会 trap 进 EL3——而 EL3 的 default handler 不知道怎么处理 S-EL2 来的 SIMD trap,**死循环**。 + +这个坑 [Part 7](./part7-bare-metal-rust-pitfalls.md) 详细讲过。顺序错一次就是几小时 debug。 + +--- + +## 第 4 步:装 S-EL2 Stage-1 MMU + +```rust +hypervisor::sel2_mmu::install_sel2_stage1_secondary(); +``` + +S-EL2 跟 NS-EL2 不同——我们要访问 NWd 的 DRAM(给 pKVM 写 FF-A descriptor RESP),这一段地址必须以 `NS=1` 走 Non-secure 物理地址空间。MMU off 时 S-EL2 的访问默认走 Secure 物理地址空间,**两个空间是不同的内存视图**([Part 6](./part6-trustzone-ns-bit.md) 讲过)。 + +所以 S-EL2 必须开 Stage-1 MMU,页表里 NWd DRAM 那一段打 `NS=1`,Secure DRAM 那一段打 `NS=0`。 + +Secondary CPU 不用从头建页表——primary 早就建好了一份。`install_sel2_stage1_secondary()` 把 primary 的 TTBR0_EL2 / TCR_EL2 / MAIR_EL2 加载到这颗 CPU,然后 `SCTLR_EL2.M=1` 打开: + +```rust +pub fn install_sel2_stage1_secondary() { + let ttbr0 = PRIMARY_TTBR0.load(Ordering::Acquire); + let mair = PRIMARY_MAIR.load(Ordering::Acquire); + let tcr = PRIMARY_TCR.load(Ordering::Acquire); + unsafe { + asm!( + "msr ttbr0_el2, {ttbr0}", + "msr mair_el2, {mair}", + "msr tcr_el2, {tcr}", + "isb", + "mrs x0, sctlr_el2", + "orr x0, x0, {sctlr_bits}", // SCTLR_EL2 |= M | C | I + "msr sctlr_el2, x0", + "isb", + ... + ); + } +} +``` + +复用 primary 的页表是这套架构的关键。**S-EL2 的 Stage-1 映射对所有 CPU 是一致的**——secondary 不需要分配自己的页表,只要把 TTBR0 指向同一个表。这跟 EL1 那种"每个进程一个 TTBR0"完全不同。 + +--- + +## 第 5 步:使能本 CPU 的 GIC PPI 26 + 29(Secure Group 1) + +```rust +let gicr_sgi_base = hypervisor::dtb::gicr_sgi_base(core_id); +let ppi_mask: u32 = (1 << 26) | (1 << 29); + +// GICR_IGROUPR0: clear bits → 不是 NS Group 1 +let igroupr0 = (gicr_sgi_base + 0x0080) as *mut u32; +write_volatile(igroupr0, read_volatile(igroupr0) & !ppi_mask); + +// GICR_IGRPMODR0: set bits → 是 Secure Group 1 +let igrpmodr0 = (gicr_sgi_base + 0x0D00) as *mut u32; +write_volatile(igrpmodr0, read_volatile(igrpmodr0) | ppi_mask); + +// GICR_ISENABLER0: enable +let isenabler0 = (gicr_sgi_base + 0x0100) as *mut u32; +write_volatile(isenabler0, ppi_mask); +``` + +每颗 CPU 一个 GICR(GIC Redistributor),互相独立。Primary CPU 在 init 时使能了它自己那个 GICR 上的 PPI 26+29,但 secondary 这边的 GICR 还是 reset 状态——PPI 不使能,中断永远不送过来。 + +这一步走三个寄存器:`IGROUPR0` + `IGRPMODR0` 一起决定中断的 group(Group 0 / NS Group 1 / Secure Group 1),`ISENABLER0` 使能。三个一起做,中断才能进 S-EL2。 + +不做会怎么样?[Part 9](./part9-multi-pcpu.md) 里讲过的 CNTHP poll 定时器(PPI 26)在这颗 CPU 上永远不触发。如果 SP 在这颗 CPU 上 idle 等中断,SPMC 就接管不了——它依赖 CNTHP 定时唤醒,但定时器中断没使能,就一直在 WFI 状态死着。pKVM 那边发的 FF-A SMC 都拿不到响应。 + +--- + +## 第 6 步:FFA_MSG_WAIT,跟 SPMD 握手 + +```rust +let first_request = forward_smc8(FFA_MSG_WAIT, 0, 0, 0, 0, 0, 0, 0); +``` + +[Part 4](./part4-war-stories.md) 讲过这一笔。前面五步都做对了,但少了这一笔,SPMD 不知道我们 S-EL2 就绪——它会阻塞,pKVM 那边的 PSCI CPU_ON 永远不返回,Linux 看到 secondary CPU 没上来。 + +`FFA_MSG_WAIT` 返回的不是 success,**是阻塞**——这一笔 SMC 在 SPMD 那边一直挂着,直到 NWd(pKVM)第一次往这颗 CPU 发 FF-A 请求。那时候 SPMD 把 NWd 的 SMC 内容塞进 `FFA_MSG_WAIT` 的返回值(x0-x7),secondary 这边才真正"返回"。 + +返回之后下一步是进 `run_event_loop(first_request)`——同 primary,处理 FF-A 调用、跑 SP、转发结果。 + +--- + +## 收尾 + +六步全部走完,这颗 secondary CPU 才真正"活着"——能接 FF-A 请求、能让 SP 跑、能响应中断。 + +顺序硬性:清 trap 位必须在开 MMU 之前;开 MMU 必须在握手之前(SPMD 来的返回值要读,读得有 MMU);PPI 使能可以稍微往后挪,但绝不能放到事件循环里(进事件循环之后中断该响应了)。 + +调 secondary CPU 时做不出来的现象常常是**第三步漏了某一位**(`CPTR_EL2.TFP` 没清)或者**第五步顺序乱了**(IGROUPR / IGRPMODR / ISENABLER 写反了)。这些都是看起来"功能性的小细节",但漏一行整个 CPU 就废。 + +下一篇我想讲 `HCR_EL2.TSC` 那个非对称 trap——它能拦 guest 的 SMC,但 hypervisor 自己发的 SMC 直通 EL3,这种"我能拦你你拦不了我"的语义为什么是对的。 + +--- + +代码: + +博客: + +*这是 ARM64 Hypervisor 开发系列的第十七篇。之前的文章索引在 [Part 0a](./part0a-why.md) 的末尾。* diff --git a/docs/zhihu/part18-hcr-tsc.md b/docs/zhihu/part18-hcr-tsc.md new file mode 100644 index 0000000..e4fb4ad --- /dev/null +++ b/docs/zhihu/part18-hcr-tsc.md @@ -0,0 +1,138 @@ +# 我能拦你的 SMC,你拦不了我的——HCR_EL2.TSC 的非对称语义 + +Guest 在 EL1 执行了一条 `smc #0`,trap 进我这边的 EL2 异常 handler。ESR_EL2.EC = 0x17(SMC64 from lower EL),我看到这是一条 FF-A 调用,在 hypervisor 这边解析、决定要不要转发给 SPMD。 + +要转发,hypervisor 自己也得发一条 `smc #0`——到 EL3。 + +我执行那一条 SMC 时,**不会** trap 回 EL2 自己。它直接进 EL3,SPMD 处理完返回到我。 + +同一条机器指令(`smc #0`)、同一颗 CPU、相同的 `HCR_EL2.TSC` 设置——guest 发它 trap,我发它通过。这种非对称语义不是 bug,是 EL2 这一层之所以能当 hypervisor 的本质原因。 + +这篇讲为什么 `HCR_EL2.TSC` 是这样设计、以及一系列类似 trap 位(TWI / TWE / TGE / ...)共享的"只拦下方"规律。 + +--- + +## TSC 的字面意思 + +`HCR_EL2.TSC` 是 Hypervisor Configuration Register 第 19 位,**T**rap **S**MC **C**alls。文档原话(ARM ARM D13.2.46): + +> Traps EL1 execution of SMC instructions to EL2 when EL2 is enabled in the current Security state. + +注意那句关键词:**EL1 execution**。这一位**只**拦 EL1(以及 EL0,如果 EL0 有权限发 SMC 的话——通常没有)。EL2 自己执行的 SMC 不受影响,直接走原本的路径——也就是 trap 到 EL3。 + +整张 HCR_EL2 的设计都遵循这条规律:**HCR_EL2 是 EL2 用来管 EL1/EL0 的开关。它不会反过来约束 EL2 自己。** + +--- + +## 为什么非对称 + +ARM 的特权级模型是单向信任的。EL0 信任 EL1,EL1 信任 EL2,EL2 信任 EL3。下一级要做受限的事得通过上一级——`HCR_EL2.TSC` 这种 trap 位是 EL2 决定"哪些下层操作我要审查、哪些放行"的工具。 + +如果 EL2 自己也被 TSC 拦,会出现死锁:hypervisor 想转发 FF-A 调用给 SPMD,但它发的 SMC 又 trap 回它自己。**没有人能调 EL3**。整个安全世界就跟 normal 世界之间完全断绝通信。 + +非对称的语义让 EL2 保留"我有路径出去"的能力——它可以决定要不要拦 EL1,但它自己永远能跟 EL3 沟通。这是 trust hierarchy 的工程后果。 + +类似的非对称规则在 HCR_EL2 里到处都是: + +| Bit | 作用 | 谁被拦? | +|---|---|---| +| TSC(19) | SMC | EL1/EL0 | +| TWI(13) | WFI | EL1/EL0 | +| TWE(14) | WFE | EL1/EL0 | +| TGE(27) | 把异常路由到 EL2 而不是 EL1 | 影响 EL0 路由,但 EL2 自己不受 | +| TIDCP(20) | IMPLEMENTATION DEFINED 系统寄存器访问 | EL1 | + +每一位都是"EL2 给自己留出口、把下方拦住"的同一套思路的变体。 + +--- + +## 在 FF-A proxy 里这件事的具体表现 + +我的 hypervisor 当 NS-EL2 时做 FF-A proxy——guest 的 FF-A SMC trap 进来,我解析、可能修改参数,然后转发给 SPMD。代码大致这样: + +```rust +// src/arch/aarch64/hypervisor/exception.rs (简化) +fn handle_smc_exception(ctx: &mut VcpuContext) -> bool { + let fid = ctx.gp_regs.x0 as u32; + if is_ffa_function(fid) { + // 解析 FF-A 调用 + let result = ffa::proxy::handle_ffa_call(ctx); + ctx.gp_regs.x0 = result; + advance_pc(ctx); + return true; + } + // 不认识的 SMC — 透明转发到 EL3 + let res = ffa::smc_forward::forward_smc8( + ctx.gp_regs.x0, ctx.gp_regs.x1, ..., + ); + ctx.gp_regs.x0 = res.x0; ...; + advance_pc(ctx); + true +} +``` + +`forward_smc8` 这一段做的就是 EL2 自己发 SMC: + +```rust +// src/ffa/smc_forward.rs +pub fn forward_smc8(...) -> SmcResult8 { + let res = SmcResult8::default(); + unsafe { + asm!( + "smc #0", + inout("x0") fid => res.x0, + // ... 完整的 SMCCC 寄存器约定 + ); + } + res +} +``` + +这一条 `smc #0` 在 EL2 执行,**不会**回到 `handle_smc_exception`。它直接进 EL3,SPMD 处理,返回。如果 TSC 拦 EL2 自己,这里就死循环了——我们永远转发不出去。 + +--- + +## ELR_EL2 的细节:SMC trap 不会自动前进 PC + +HCR_TSC 还有一个跟其他 trap 不同的小细节——**`ELR_EL2` 在 SMC trap 进来时指向 SMC 指令本身**,不是下一条指令。Hypervisor 处理完得手动把 PC 往前推 4 个字节。 + +```rust +fn advance_pc(ctx: &mut VcpuContext) { + ctx.elr_el2 += 4; // SMC 是 32 位指令 +} +``` + +为什么是这种语义?因为 HVC trap 和 SVC trap 都会把 ELR 指向"下一条"——异常被认为是"在执行 HVC/SVC 期间发生",retiring 时回到后面。SMC 在 ARM ARM 里被分类为 **synchronous exception**,跟一般同步异常一样:`ELR_EL2 = 故障指令地址`。 + +漏掉 `advance_pc` 的话,ERET 回 guest 之后会再执行同一条 SMC,**死循环 trap**。我第一次写 SMC handler 漏了这一句,然后用 GDB 看到 `ELR_EL2` 一直停在同一个 PC 值,才意识到问题。 + +(HVC 也是 synchronous exception,但 HVC 的 ELR 是指向**下一条**的——这个差别在 ARM ARM D1.10 里有专门一段讲。) + +--- + +## 把这条规律记下来 + +任何 trap 位语义不清的时候,**先问"它拦不拦 EL2 自己"**。绝大多数情况下不拦——HCR_EL2 / CPTR_EL2 / MDCR_EL2 这些都是 EL2 管下层用的。如果你想限制 EL2 自己,得到 EL3 的 SCR_EL3 那一层(比如 `SCR_EL3.SMD=1` 能禁掉所有 SMC 包括 EL2 发的)。 + +把这条规律写进代码注释里: + +```rust +// HCR_TSC = 1 << 19. Traps guest SMC to EL2 as EC_SMC64 (0x17). +// EL2's own `smc #0` is unaffected — that's what lets us forward. +// ELR_EL2 points to the SMC instruction itself, advance by 4 after handling. +const HCR_TSC: u64 = 1 << 19; +``` + +下一次维护这段代码的人(可能是几个月后的你自己),不用再去翻 ARM ARM 才能知道这一位的语义边界。 + +--- + +下一篇我想讲 `ICC_SGI1R_EL1` 那笔糊涂账——TargetList 在 bits[15:0] 不是[23:16],INTID 在 bits[27:24] 不是 [3:0],位域不在你以为的位置,写反了 SGI 发不出去。 + +--- + +代码: + +博客: + +*这是 ARM64 Hypervisor 开发系列的第十八篇。之前的文章索引在 [Part 0a](./part0a-why.md) 的末尾。* diff --git a/docs/zhihu/part19-icc-sgi1r-bitfield.md b/docs/zhihu/part19-icc-sgi1r-bitfield.md new file mode 100644 index 0000000..14a0325 --- /dev/null +++ b/docs/zhihu/part19-icc-sgi1r-bitfield.md @@ -0,0 +1,127 @@ +# `ICC_SGI1R_EL1` 那笔糊涂账——TargetList 不在 bits[23:16] + +我想给 CPU 1 发一条 SGI 编号 4,从 CPU 0 上。手册看了一眼,几个字段往 `ICC_SGI1R_EL1` 里塞: + +```rust +// 错误版本 — 拼错了所有位置 +let val: u64 = (1 << 23) // TargetList: bit for CPU 1 ← 我以为 TargetList 在 [23:16] + | (4 << 0); // INTID: 4 ← 我以为 INTID 在 [3:0] +asm!("msr icc_sgi1r_el1, {val}", val = in(reg) val, ...); +``` + +跑下来 SGI 根本没发出去。GIC 没有任何反应,CPU 1 在 WFE 里继续睡。 + +正确的字段位置: + +```rust +let val: u64 = (1u64 << 1) // TargetList[15:0]: bit 1 = Aff0 == 1 (CPU 1) + | (4u64 << 24); // INTID[27:24]: 4 +``` + +`TargetList` 在 **bits[15:0]**——不是 [23:16]。`INTID` 在 **bits[27:24]**——不是 [3:0]。bit 位置全错。这一篇讲为什么这么排,以及怎么记住不忘。 + +--- + +## ARM ARM 里的实际布局 + +`ICC_SGI1R_EL1` 是 64 位寄存器,字段按从高到低: + +``` +[63:56] RES0 +[55:48] Aff3 — 目标 CPU 的 Aff3 +[47:44] RES0 +[43:40] RS — Range Selector(targeting超过 16 颗 CPU 时用) +[39:32] Aff2 — Aff2 +[31:28] RES0 +[27:24] INTID — SGI 编号(0-15) +[23:16] Aff1 — Aff1 +[15:0] TargetList — Aff0 域内 16 颗 CPU 的位图 +``` + +具体语义:**`TargetList[i] = 1` 表示发送给 Aff0 = i 的那颗 CPU**(在某个 Aff3:Aff2:Aff1 组合内)。一笔写最多可以同时给 16 颗 CPU。 + +要发给 Aff0 = 1、Aff1 = 0、INTID = 4,就是: + +```rust +TargetList = 1u16 << 1; // bit 1 = Aff0=1 +Aff1 = 0; +INTID = 4; +let val: u64 = ((Aff1 as u64) << 16) | ((INTID as u64) << 24) | (TargetList as u64); +// = 0x0000_0000_0400_0002 +``` + +(0x0000_0000_0400_0002 的来源:INTID 4 在 bits[27:24] = 高 4 位之 0x4,放到字节 3 是 `0x04_00_00_00`;TargetList bit 1 是 `0x00_00_00_02`;Aff1=0 不贡献。) + +--- + +## 为什么字段不按"自然顺序"排 + +`Aff3 / Aff2 / Aff1 / Aff0` 在 MPIDR 里按从高到低排是 `[63:32] / [23:16] / [15:8] / [7:0]`,直觉上 ICC_SGI1R 应该跟着同样排——但它没有。`ICC_SGI1R_EL1` 把 TargetList(隐含 Aff0)放在最低端,然后 Aff1 / INTID / Aff2 / Aff3 按反向"高低交替"。 + +这有它的设计理由——`TargetList` + `Aff1` + `Aff2` + `Aff3` 共同定位一个 affinity group,在那个 group 里 TargetList 是 16 位位图(可以一笔多目标);**INTID 跟 affinity 字段交错**是为了让常用的"单 Aff0 group + INTID"场景能用 32 位整数表达——bits[31:0] 就装下了 TargetList + Aff1 + INTID。 + +代价是字段位置反直觉,**人类阅读 ARM ARM 时大概率读错**。我自己读错过、看别人代码也看到过把 TargetList 写成 bits[23:16] 的(看起来"在中间应该是这个位置")。 + +--- + +## 给自己写个 helper + +不要直接位运算。每次写 ICC_SGI1R 都用一个有名字字段的 helper: + +```rust +fn build_sgi1r(target_aff0_mask: u16, intid: u8, aff1: u8) -> u64 { + debug_assert!(intid < 16, "SGI INTID must be 0..15"); + (target_aff0_mask as u64) + | ((aff1 as u64) << 16) + | ((intid as u64) << 24) +} + +fn send_sgi(target_cpu: u8, intid: u8) { + let val = build_sgi1r(1u16 << target_cpu, intid, 0); + unsafe { + asm!("msr icc_sgi1r_el1, {val}", + "isb", + val = in(reg) val, + options(nostack, nomem)); + } +} +``` + +这套写法的好处: + +- **位置错了一个,debug_assert 直接帮你查**(`intid < 16` 这个限制如果你把 INTID 写到 bits[3:0] 等于把它放到了 TargetList 位置,但 SGI INTID 又只能 0-15,所以 mask 之后值也对——这种"看起来对但发到错地方"的 bug 是最难抓的) +- **传 `target_cpu` 是 CPU 编号、不是 mask**——避免把"目标 CPU 1"写成 `1u16` 而不是 `1u16 << 1` 这种 off-by-bit 错误 +- **每次回头看代码,字段意思清晰可读** + +--- + +## 一个小推论 + +任何 GICv3 system register 的位域,**先看 ARM ARM 的位序图,不要看文字描述**。文字描述常常按"逻辑顺序"列字段(Aff3 → Aff2 → Aff1 → Aff0 → INTID),但实际位置可能是反着的。 + +读位序图时盯着每个字段的 `[high:low]` 范围,**不要根据上下文猜**。`ICC_SGI1R_EL1` 不是孤例——`MPIDR_EL1`、`ICC_BPR1_EL1`、`ICH_LR_EL2` 都有类似"字段位置不按直觉"的情况,具体每个寄存器要单独看。 + +写代码时给自己留一份位域常量表: + +```rust +mod sgi { + pub const TARGET_LIST_SHIFT: u32 = 0; + pub const TARGET_LIST_MASK: u64 = 0xFFFF; + pub const AFF1_SHIFT: u32 = 16; + pub const AFF1_MASK: u64 = 0xFF << 16; + pub const INTID_SHIFT: u32 = 24; + pub const INTID_MASK: u64 = 0xF << 24; + pub const AFF2_SHIFT: u32 = 32; + pub const AFF3_SHIFT: u32 = 48; +} +``` + +这一份常量表是 ARM ARM 翻成 Rust 的工程沉淀。每读一次手册做对一次,后面就不用再读了。 + +--- + +代码: + +博客: + +*这是 ARM64 Hypervisor 开发系列的第十九篇。之前的文章索引在 [Part 0a](./part0a-why.md) 的末尾。*