RISC-V平台做机密计算和X86那套“整机TEE内存加密”的思路完全不同。这里的信任根不是一块又大又封闭的Secure World而是由PMP、M模式安全监视器、G-stage地址翻译这些小得不起眼的硬件机制一点点拼出来的。最近我在给一颗内部RISC-V核做机密计算原型梳理下来核心就三件事PMP怎么隔离、安全监视器怎么切换世界、CoVE怎么把机密虚机做成能跑操作系统的形态。这篇把设计思路和可复现的细节写一遍适合正在做RISC-V CPU设计、TEE固件或者虚拟化软件层的同学也适合只听说过“机密计算”但想搞清楚落地路径的人。1. 为什么偏偏是 RISC-V机密计算从 ARM 到 RISC-V 的火苗机密计算解决的是“数据在内存里不干净”的问题。传统TEE只保护执行环境X86的SGX和ARM的TrustZone都做了多年但RISC-V生态起步晚反而有机会在一套更干净、更体积小的架构上重新设计。你在RISC-V上做机密计算不是把ARM TrustZone写到RISC-V里而是用RISC-V自己的扩展来凑齐四个能力第一物理隔离。机密VM的内存不能被普通VM、Hypervisor甚至其他特权软件读走这里靠PMP也就是Physical Memory Protection物理内存保护。第二世界切换。同一个CPU核要在普通世界和机密世界之间切换不能靠裸机程序一拍脑袋跳转需要一层很薄的M模式代码控制这就是安全监视器Security Monitor。第三虚拟化隔离。机密VM本身要跑完整OSHypervisor偷偷改页表也不行需要两级地址转换和G-stage翻译的配合这也是CoVE——Confidential VM Extension——所规定的核心。第四中断与I/O隔离。DMA、中断控制器、调试接口要全部纳入边界否则CPU里保护得再好外设也能把机密内存取走。这四个能力不是同一家公司发明的而是RISC-V基金会各个Task Group分别推进最后在机密计算这个场景上汇合。所以你现在想入门不必看堆积如山的规范只要围绕PMP加安全监视器加CoVE这条路走就能把大多数概念串起来。做这颗核的时候我的体感是X86的MKTME和AMD SEV把内存加密做在内存控制器里CPU自身几乎不用知道“机密”是什么软件只需要分配物理页硬件自动加密。ARM的RME则在EL2与EL3之间插了一个RMM作为中介。RISC-V的CoVE设计更有“软硬协同”味道——它把隔离的责任大量压在PMP和M模式固件身上SoC集成了内存加密的话就配合加密不集成的话先把物理隔离做对。这种可裁剪性正是RISC-V适合被做成多种形态的原因。对于初学者我强烈建议先在QEMU或者FPGA上把PMP和M模式SM跑通再谈CoVE。因为CoVE的一切IO隔离、中断重定向、CPU上下文切换本质上都建立在“PMP能可靠挡住非授权访问”这个前提下。PMP这一关没过后面全是沙地起塔。2. 第一块基石PMP 隔离的完整拆解与配置实战2.1 PMP 寄存器模型和 NAPOT 编码PMP是一组CSR按地址范围做物理内存的访问控制。每个RISC-V核不一定实现全部16条PMP但现代设计普遍支持至少8到16条。每条PMP项由两个CSR描述PMPCFGx负责配置项的控制位PMPADDRx负责告诉你这条规则盖住哪块物理地址。PMPCFG里的关键字段是R、W、X、A、L解释一下R、W、X分别置位时代表该区域允许读、写、执行。如果一个区域没有R权限还想执行在大多数实现里是不允许的执行权限往往要求读权限这一点和很多人的直觉相反。A字段表示地址匹配模式。有三种常见模式OFF、NA4、NAPOT。NA4是精确匹配4字节地址但实际很难用因为你要保护的内存块通常都是KB甚至MB级别。NAPOT是“自然对齐的2的幂大小”匹配一个PMP项可以直接盖住4KB、64KB这种连续区间。L是锁定位。一旦L位写1之后M模式写入也不能再改这条PMP项直到CPU复位。这个特性用来做安全启动时很有用固件按顺序把每一段受信内存配置好顺手锁死后阶段代码想偷偷开放权限都不行。NAPOT的编码是个容易写错的地方。基础公式是这样你要覆盖的物理范围必须是与基地址对齐的2的幂大小SS至少是8字节。设k使得S2^k那么pmpaddr寄存器的值就是 (base 2) | (S/4 - 1)。换句话说低k-2位全部置1说明这个范围跨度是2的几次方。举一个实际计算例子。我们给机密TEE预留的64KB区域基地址0x80010000。S0x10000k16S/40x4000S/4-10x3FFF。base右移两位得到0x20004000再和0x3FFF做或运算最终pmpaddr是0x20007FFF。这个值写到PMPADDR0之后PMPCFG0里配置ANAPOT这条PMP项就精确覆盖0x80010000到0x8001FFFF这64KB。我见过不止一个人把PMPADDR写成整个物理地址然后发现访问全被拒原因就是忘了PMPADDR里存的是物理地址除以4以后的值再加上一堆低位1用来代表范围长度。对于不熟悉二进制的人这个坑百踩百中。2.2 一套最小安全内存规划在我的原型里内存布局长这样可以当作教学参考RAM都是从0x80000000开始的简单QEMU virt环境段名地址范围用途PMP策略boot0x80000000 - 0x8000FFFF启动固件、栈M模式可读写可执行tee0x80010000 - 0x8001FFFF安全监视器、TEE内代码数据M模式读写执行进入VM时PMP强制隔离host0x80020000 - 0x8002FFFF非机密VM、VMMS/HS模式可访问vm-conf0x80030000 - 0x8003FFFF机密VM内存仅M模式可临时代管进入VM后映射G-stage注意这里说的PMP策略不是静态的而是随着世界切换动态变化。PMP项不像页表那样可以按进程切换但安全监视器可以在世界切换时重写PMP项目这也是“世界”这个概念在RISC-V上的实现基础。初始化代码我放在M模式里写成一段C语言加内嵌汇编static void pmp_write_cfg(uint64_t cfg, uint32_t idx) { uintptr_t csr CSR_PMPCFG0 (idx / 4); uintptr_t shift (idx % 4) * 8; uintptr_t mask 0xFFULL shift; uintptr_t old csr_read(csr); csr_write(csr, (old ~mask) | (cfg shift)); }配置TEE区域作为第一个PMP项uintptr_t tee_base 0x80010000; uintptr_t tee_size 0x10000; uintptr_t pmpaddr (tee_base 2) | ((tee_size / 4) - 1); csr_write(CSR_PMPADDR0, pmpaddr); pmp_write_cfg(0x9F, 0); // L1, ANAPOT, X1, W1, R10x9F这个值要拆开看bit7的L为1bit4和bit3的A为NAPOT的二进制11bit2的X为1bit1的W为1bit0的R为1。这里我直接给了可执行权限因为TEE代码段确实要从这里取指。后面连接器脚本里我也会把TEE的代码段、数据段、BSS都放在0x80010000这个区间内保证PMP设置一次就全部覆盖。2.3 PMP 配置中的几个反直觉细节第一个反直觉的细节PMP对M模式同样生效。初学者往往以为M模式是全能的上帝模式实际PMP设计恰恰想限制M模式的一部分数据访问。它是出现内存访问错误时给所有特权级别都套上锁。所以在配置PMP时你写配置的那段代码本身就活在PMP保护之下顺序错了就当场死机。第二个反直觉的点是匹配优先级。RISC-V规范中说PMP按编号从小到大匹配匹配成功后就停止不取交集并集。所以别想着“较低编号项允许较高编号项拒绝”这种逻辑不成立。你只能把更具体的区域放在小的编号项把宽松通配区放在后面。我们这一版里PMP0精确到TEEPMP1、PMP2再去覆盖宿主和大内存一旦地址没落在PMP0区间才会落到PMP1的通配规则。第三个容易忽略的是在S模式下访问某个地址可能同时检查PMP和页表两重检查都通过才放行。页表给权限不够PMP也得允许反过来PMP允不允许也救不了页表拒了的情况。这样理解就清楚PMP是物理层关卡页表是虚拟层关卡VM要访问一块内存需要“双重签证”。第四个坑体现在对外设寄存器的访问上。如果PMP只覆盖了内存RAM没给MMIO外设区域留PMP项那么在S模式下访问串口和中断控制器就会因PMP不匹配而触发访问错误。所以配置PMP不能只看DRAM必须把所有MMIO区域也规划进去通常的做法是用最后的几条PMP项把剩余整个物理地址空间配置为通配读写把需要严格隔离的区域放在前几条。2.4 世界切换时 PMP 如何变阵这是整个TEE设计里最关键的一步在虚拟机进入机密执行之前安全监视器要把PMP配置从“全开放”切换到“机密模式”。手段就是重新写PMPCFG和PMPADDR。但是写PMP本身有触发trap的风险尤其是你想故意关掉某些权限后紧接着又做一次非法访问来测试这一步会直接导致系统卡死。我们在设计时把世界切换分成两个阶段。第一阶段安全监视器仍然以M模式运行保存当前主机的寄存器上下文到TEE栈里然后写PMPPMP0保留TEE区域PMP1关闭PMP2关闭。第二阶段配置G-stage页表把机密VM的虚拟地址映射到受保护物理页设置HS的satp指向被冻结的G-stage表然后mret进S模式。这时候哪怕VM拼命访问0x80010000PMP0是允许的坏就坏在程序会成功然后因为G-stage里的物理页没有映射到TEE区域真实访问会被虚拟化地址翻译挡住。PMP挡第一层G-stage挡第二层两层互为备份。反过来从机密VM退出时安全监视器要先让CPU回到M模式恢复主机的PMP通配配置再恢复主机VMM的寄存器状态。顺序反了会导致VM exit之后主机代码访问不到任何内存直接panic。这个顺序问题我花了好几天排查才看明白。3. 安全监视器M 模式下的小型 TCB 设计3.1 为什么需要安全监视器而不是裸机跳转很多人以为TEE安全监视器就是一段直接运行在M模式的大裸机程序其实它的核心不是一个普通循环而是一个以固定入口等待服务请求的状态机。它要处理的事情是安全世界和普通世界之间的有序切换、PMP配置的更新、对非安全中断的截获与转发、对SBI调用的路由。如果没有安全监视器各VM之间就没有统一的中介来约束“谁在什么时候能进机密世界”。裸机程序可以随便跳到M模式也可以随便写PMP这会让安全模型瞬间瓦解。所以M模式本身的代码量要最小、逻辑尽可能简单最好只提供Ecall入口和一个按编号分发的中断处理帧。用一句通俗的表达M模式是那个握着大钥匙但不乱开门的人。3.2 三种执行世界与合法的状态转移RISC-V CPU执行状态按特权级别划分M模式、HS模式、VS模式。在CoVE语境下我们把世界划分为三层M世界安全监视器最高信任等级。HS世界普通宿主Hypervisor/OSt被裁剪时是S模式OS运行普通虚拟机。VS世界机密VM的vCPU运行在虚拟化的S模式下。世界状态机里合法转移只有四种正常启动M进入HS宿主发起机密VM启动请求HS通过Ecall进入MM配置完成后mret进入VSVM执行Ecall或产生异常触发VMexit回到M模式。任何“HS直接跳到VS”这种旁路是不允许的因为这意味着绕过PMP切换。在实现里我用一个枚举来跟踪当前世界状态typedef enum { WORLD_M 0, WORLD_HS 1, WORLD_VS 2 } world_t;每次切换前先检查状态如果当前状态不匹配一个合法的转移那么直接拒绝执行而不是进入切换逻辑。这种防御式设计虽然笨但在第一版原型里能拦住绝大多数手滑导致的配置错乱。3.3 切换路径里的现场保护代码写切换代码时要保存的最小集合是通用寄存器、mepc、mstatus、satp、PMP配置状态。我们直接在汇编里做放一个指针在tp寄存器指向当前CPU的上下文结构体.macro SAVE_GPRS sd ra, 0*8(tp) sd sp, 1*8(tp) sd gp, 2*8(tp) sd s0, 3*8(tp) sd s1, 4*8(tp) sd s2, 5*8(tp) # ... 依次保存全部通用寄存器 .endm然后在C入口处写分发函数__attribute__((noreturn)) void sm_entry(void) { uint64_t cause csr_read(CSR_MCAUSE); uint64_t mepc csr_read(CSR_MEPC); if ((cause 63) 1) { // 中断转发给当前世界对应中断控制器 handle_irq(cause 0x7FFF); return; } // 异常或Ecall switch (cause 0xFF) { case 9: // ecall from S/VS handle_sbi(); break; case CAUSE_LOAD_ACCESS: case CAUSE_STORE_ACCESS: case CAUSE_FETCH_ACCESS: handle_pmp_fault(cause); break; default: panic(unknown trap); } }真正的mret切换由一段跳板汇编完成先写mstatus的MPP字段把下一条入口写到mepc然后把PMP配置设置成目标世界所需的值最后mret。mstatus里MPP字段决定mret后回到哪个特权级别是S还是VS要一并设置好。这个字段写错会让CPU直接落回一个完全错误的权限环境比署名问题更难排查。3.4 中断隔离是安全监视器最容易疏漏的半边天我们第一版安全监视器跑起来后机密VM在串口输出正常但一产生定时器中断就整体锁死。后来定位到问题根因是中断控制器没有做中断隔离配置。在不开AIA相关特性时中断可能直接路由到HS世界导致HS代码在非机密上下文里访问了机密中断的上下文。常规做法是对AIA或者老的PLIC做中断源分区所有和机密VM相关的interrupt source要配置成只能由M模式的SM接管普通VM中断则委托到HS。不要把中断全部一股脑委托给S/HS因为那是把肆虐的门敞开。如果你现在投影环境里还没支持AIA至少在SM里显式声明哪些中断源是敏感的其余全部兜底处理。4. CoVE 机密虚机把机密计算做成可以跑 Linux 的 VM4.1 CoVE 到底在规范什么CoVE是RISC-V基金会机密计算工作小组提出的机密虚机扩展全称是Confidential VM Extension。它本质上定义了一套“硬件加软件协同”的规范什么样的特权状态组合、什么样的PMP配置、什么样的地址转换行为能让一台普通CPU变成一个支持机密VM的CPU。它和ARM RME最大的区别是CoVE没有像RMM那样强制指定一个巨大的管理固件形态而是把更多自主权留给平台实现。这意味着同一套CoVE前缀可以落在不同风格的SoC上可落地为QEMU教学环境也可落地为带内存加密的服务器级CPU。灵活性是好处但也意味着作为实现者你必须自己把“最高管理权限”这一层看住规范并不会替你管。4.2 两级地址翻译里 G-Stage 的精髓CoVE要安全运行Linux首先得解决一个根本问题Hypervisor可以自由操纵普通VM的页表如果机密VM还用普通页表那宿主只要改一下映射就能把机密内存重映射出来。RISC-V H扩展提供了两级地址转换第一是VS页表由机密VM自己的内核维护把虚拟地址翻译成中间物理地址第二是G-stage页表由主机Hypervisor或者安全监视器维护把中间物理地址翻译成真实物理地址。CoVE的安全关键在于机密VM的G-stage中所有允许映射的真实物理页都必须落在被PMP保护好的机密内存区域内。也就是说Hypervisor虽然能改G-stage映射但它写不出一个能把非机密物理页当作机密VM主内存的映射因为那部分地址在PMP层就过不去。实际操作时我们为机密VM建立一个单独的G-stage表R--W--X权限按需分配同时把和机密VM对应的PMP项锁定为物理RAM区。当VM尝试访问自己的内存时VS页表和G-stage表都顺利通过在PMP处也通过当VM尝试访问你没给它分配的区域时PMP直接拒绝。4.3 QEMU 上搭 CoVE 教学环境如果你没有真实RISC-V开发板完全可以拿QEMU搭一个缩减版CoVE原型。QEMU的virt机器支持RISC-V特权模式中的M和S模式也支持PMP相关CSR。虽然它不会真的加密内存但PMP访问错误和trap行为是仿真的足够学习隔离逻辑。我的搭建顺序是编写一个最小bare-metal固件放在0x80000000启动时初始化串口。把PMP配置成“TEE区域只有M可访问”的状态。用S模式写一个普通VM入口故意访问TEE区域确认收到access fault。加上H扩展相关特征配置G-stage表把VM虚拟地址映射到机密内存。最后在SM里加入世界切换让VM在S模式下正常跑起一个测试程序。每一步都先打印一行日志通过串口输出确认在哪一步出了问题。我在经验里特别推荐这种渐进式开发不要一口气写完整个SM再调试因为PMP配置错和G-stage表错的表现极其相似都是访问被拒只看最终错误会浪费大量时间。4.4 链接脚本里的机密边界很多做软件的人不太重视链接脚本但在RISC-V裸机TEE里链接脚本就是机密内存边界的最终执行者。你的代码段和数据段必须在PMP保护范围内链接脚本一旦越界所有PMP都白配。我用的链接脚本大概是这样一个结构OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { flash : ORIGIN 0x80000000, LENGTH 128K tee : ORIGIN 0x80010000, LENGTH 64K host : ORIGIN 0x80020000, LENGTH 256K vmem : ORIGIN 0x80030000, LENGTH 256K } SECTIONS { .text : { KEEP(*(.text.entry)) *(.text*) } tee .rodata : { *(.rodata*) } tee .data : { *(.data*) } tee .bss : { *(.bss*) } tee }这里最重要的是把TEE相关的所有section都放进tee这个memory区域。如果某一节因为尺寸膨胀溢出了64KB的边界链接器会报错这时候就要调整PMPADDR里的尺寸参数不能只改链接脚本而忘了同步PMP配置。一个常见问题是安全监视器需要在RAM里维护动态状态但把.bss放进tee内存后每次启动都需要清0这部分初始化本身要在M模式提前完成。不然VM启动后访问未初始化数据读到的可能是上一个VM残留的秘密。5. 实战中容易踩的坑问题与排查实录现象可能原因排查方式进入VM后访问TEE内存死机PMP中TEE区域未单独在前端锁定打印PMPCFG核对地址掩码是0x20007FFF不是0x20004000虚拟机一跑定时器就死中断隔离未配置敏感中断被宿主接管在SM里显式接管所有敏感中断源把普通中断委托出去mret后CPU落在错误环境mstatus.MPP字段写错检查切换汇编确认MPP被写成VS模式对应值裸机打印正常但MMIO串口不可用MMIO外设区域没有对应PMP项在PMP末尾配置一条物理内存空间通配项保留权限R/W虚拟机访问自己内存被拒G-stage表缺少对应映射或PMP地址范围过小打开G-stage页表debug逐一验证中间物理地址PMP向后再写时会锁定某条PMP的L位被置1后无法再修改如果不需要锁定去掉L位设计上保留切换可能性链接脚本溢出但PMP未同步TEE镜像膨胀超过64KB先调大MEMORY长度再同步重算PMPADDR位地址在PNAPOT区域内但范围不对低bit全1位数算错用板子上的CSR读回核对RISC-V手册中的编码公式这些问题基本都是我在真实调试中反复遇到的尤其是PMP地址范围和链接器溢出这两类属于不做设计就无法发现的隐性关联。任何依赖“我改一下PMP数字就行”的临时修复最后都会在某个边界条件上咬你一口。6. 从 CPU 设计者视角看PMP、SM 和 CoVE 的落地顺序如果后面你要在自己的RISC-V核上塞进机密计算能力我的建议是严格按三个里程碑推进每步都留下测试用例。第一步PMP的硅前验证。这是CPU设计者最常见的任务在RTL里验证PMP traps是否合规NAPOT匹配是否和软件跑在QEMU上一致。这里我强烈建议写GPIO或者串口打印的测试程序来验证每个内存访问的失败行为。第二步SM的世界切换。硬件虚拟化扩展H支持后让SM在HS和VS之间切换这是在验证硬件上下文切换和mret路径是否符合预期。这一步跑通后你已经有能力承载最基础的可信执行环境。第三步CoVE整合内存加密。如果你计划在PCIe控制器或者内存控制器里做内存加密把PMP和双级地址翻译与加密引擎的密钥管理结合起来。真正安全的机密VM才在大规模云平台上有实战意义。没有硬件加密的话物理意义上的cold boot attack仍然能读走敏感数据。这一阶段需要验证的是机密物理页只会被对应VM的密钥读出主机就算使用PMP通配也无济于事。我个人做一个明确建议别急着一步到位做CoVE完整实现先用一天时间把PMP和链接脚本对齐能用S模式跑非安全VM再用一天做SM世界切换最后才碰G-stage和内存加密。这个顺序不是最炫的但一定能让你在第三天拿到一个能看日志、能加测试、能快速定位问题的TEE原型。做机密计算最怕的不是难度而是你被一堆硬件细节淹没之后根本不知道哪里漏了。
阅读完成 · 觉得有帮助?