RISC-V机密计算落地:PMP、安全监视器与CoVE实战解析
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本身要跑完整OS,Hypervisor偷偷改页表也不行,需要两级地址转换和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的幂大小S,S至少是8字节。设k使得S=2^k,那么pmpaddr寄存器的值就是 (base >> 2) | (S/4 - 1)。换句话说,低k-2位全部置1,说明这个范围跨度是2的几次方。
举一个实际计算例子。我们给机密TEE预留的64KB区域,基地址0x80010000。S=0x10000,k=16,S/4=0x4000,S/4-1=0x3FFF。base右移两位得到0x20004000,再和0x3FFF做或运算,最终pmpaddr是0x20007FFF。这个值写到PMPADDR0之后,PMPCFG0里配置A=NAPOT,这条PMP项就精确覆盖0x80010000到0x8001FFFF这64KB。
我见过不止一个人把PMPADDR写成整个物理地址然后发现访问全被拒,原因就是忘了PMPADDR里存的是物理地址除以4以后的值,再加上一堆低位1用来代表范围长度。对于不熟悉二进制的人,这个坑百踩百中。
2.2 一套最小安全内存规划
在我的原型里,内存布局长这样,可以当作教学参考,RAM都是从0x80000000开始的简单QEMU virt环境:
| 段名 | 地址范围 | 用途 | PMP策略 |
|---|---|---|---|
| boot | 0x80000000 - 0x8000FFFF | 启动固件、栈 | M模式可读写可执行 |
| tee | 0x80010000 - 0x8001FFFF | 安全监视器、TEE内代码数据 | M模式读写执行,进入VM时PMP强制隔离 |
| host | 0x80020000 - 0x8002FFFF | 非机密VM、VMM | S/HS模式可访问 |
| vm-conf | 0x80030000 - 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); // L=1, A=NAPOT, X=1, W=1, R=10x9F这个值要拆开看:bit7的L为1,bit4和bit3的A为NAPOT的二进制11,bit2的X为1,bit1的W为1,bit0的R为1。这里我直接给了可执行权限,因为TEE代码段确实要从这里取指。
后面连接器脚本里我也会把TEE的代码段、数据段、BSS都放在0x80010000这个区间内,保证PMP设置一次就全部覆盖。
2.3 PMP 配置中的几个反直觉细节
第一个反直觉的细节,PMP对M模式同样生效。初学者往往以为M模式是全能的上帝模式,实际PMP设计恰恰想限制M模式的一部分数据访问。它是出现内存访问错误时给所有特权级别都套上锁。所以在配置PMP时,你写配置的那段代码本身就活在PMP保护之下,顺序错了就当场死机。
第二个反直觉的点是匹配优先级。RISC-V规范中说,PMP按编号从小到大匹配,匹配成功后就停止,不取交集并集。所以别想着“较低编号项允许,较高编号项拒绝”,这种逻辑不成立。你只能把更具体的区域放在小的编号项,把宽松通配区放在后面。我们这一版里PMP0精确到TEE,PMP1、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栈里,然后写PMP:PMP0保留TEE区域,PMP1关闭,PMP2关闭。第二阶段,配置G-stage页表,把机密VM的虚拟地址映射到受保护物理页,设置HS的satp指向被冻结的G-stage表,然后mret进S模式。这时候哪怕VM拼命访问0x80010000,PMP0是允许的,坏就坏在程序会成功,然后因为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/O(St被裁剪时是S模式OS),运行普通虚拟机。
- VS世界:机密VM的vCPU,运行在虚拟化的S模式下。
世界状态机里合法转移只有四种:正常启动M进入HS;宿主发起机密VM启动请求,HS通过Ecall进入M;M配置完成后mret进入VS;VM执行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 的 VM
4.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原型。做机密计算最怕的不是难度,而是你被一堆硬件细节淹没之后,根本不知道哪里漏了。