做RISC-V平台上的安全启动和可信执行环境是在调一套自研固件时彻底把PMP、安全监视器和CoVE这几层关系理清的。当时要在同一个RV64核上同时跑普通虚拟机和机密虚拟机软件层做访问控制根本不靠谱必须靠物理内存隔离、M-mode安全监视器以及CoVE那套机密虚机规范来兜底。这篇就把RISC-V机密计算与可信执行的设计思路、PMP配置细节、安全监视器的实现方式以及CoVE的整体架构和踩坑经验完整摊开。文章尽量照顾两类读者一类是做RISC-V CPU和固件底层的可以直接拿配置代码和链接脚本去改另一类是搞虚拟化或安全应用的主要帮你理解这层架构能提供什么边界、不能提供什么以及上层软件怎么对接。我不写那种“填满概念”的科普所有内容都按我在实际调试中的顺序来先想清楚威胁模型再看硬件怎么隔离然后写安全监视器最后串起CoVE机密虚机每个环节都有可复现的配置和可查的坑。1. 先理清这套架构到底要解决什么问题1.1 从一个大系统里的“信任边界”说起在讲PMP和CoVE之前必须先回答一个问题机密计算在RISC-V上到底防谁答案跟x86和ARM上的TEE不太一样RISC-V目前没有像SGX那样复杂的CPU内部加密引擎也没有ARM TrustZone那种物理上独立的“安全世界”总线设计。RISC-V的思路更朴素靠特权级隔离加物理内存访问控制把“不可信”的软件困在一个明确划定的内存区域里。一个典型部署场景是这样的服务器上有一个宿主机系统运行着Hypervisor和多个虚拟机其中某个虚拟机要处理用户的私钥、模型参数或者业务敏感数据。传统虚拟化里宿主机Hypervisor拥有全部内存的访问权恶意的高权限软件只要攻破Hypervisor所有VM数据都是透明的。机密计算要做的事情就是让一个高权限VM在执行机密业务时连Hypervisor本身也访问不到它的内存。这在x86上靠EPT和MKTME那套机制在ARM上靠两阶段页表和Secure EL2而在RISC-V上第一道防线就是PMP。PMP全称Physical Memory Protection是RISC-V特权规范里定义的一套物理内存访问控制机制。它挂在CPU的物理地址通路旁边S模式和U模式下任何一次访问物理内存的请求都要先过PMP这关。这跟MMU的虚拟地址转换是两码事MMU管的是“虚拟页怎么映射到物理页”PMP管的是“物理页到底允不允许这个特权级去读写”。哪怕MMU映射得天花乱坠PMP不让你碰这块物理地址你照样会收到访问异常。我个人的理解是可以把PMP想象成小区围墙MMU是每栋楼的门禁。楼内门禁再严围墙外的人进不来就是进不来。RISC-V的TEE第一步就是先把这堵围墙修正确否则后面安全监视器和机密虚机全白搭。1.2 TEE的目标与威胁模型设计RISC-V可信执行环境之前先要明确威胁模型这点很多初学的朋友容易忽略。不是所有场景都需要“防死Hypervisor”那么高的安全等级。如果你的威胁模型只是防普通用户进程之间的互相偷窥那只开U模式隔离就够了甚至不需要M-mode来参与。但如果你的目标是机密计算要防的对手名单通常包括宿主机上的Hypervisor、特权服务进程、带调试接口的攻击者甚至物理接触设备的人。在RISC-V上做机密虚机威胁模型基本分成两类。第一类是软件攻击攻击者控制宿主机部分软件栈试图通过Hypervisor漏洞、设备驱动漏洞或者共享内存泄露来读机密VM的数据。第二类是硬件攻击调试接口被打开、总线探针、内存芯片被物理读取等。PMP和CoVE主要封堵第一类第二类需要额外的内存加密和防调试机制不在这一层讨论范围内。明确了威胁模型设计目标也就清晰了第一机密VM的物理内存区域必须被硬件强制隔离不让任何S/HS模式代码读到第二从普通VM切换到机密VM的过程必须可信上下文不能泄露第三启动时必须能量化出“当前固件到底是什么版本”也就是可测量启动第四即使Hypervisor被打穿机密VM也还有一块独立的安全飞地。后面讲PMP、安全监视器、CoVE全部是围绕这四条目标展开的。1.3 CoVE在RISC-V生态中的位置CoVE全称Confidential VM Extension是RISC-V生态里面向机密虚机场景的一套规范集合。它不是一个单独的指令而是一整套包括页表保护机制、安全监视器接口、启动协议和API约定在内的方案目标是在标准RISC-V虚拟化扩展H扩展之上提供“宿主不可信但机密VM仍然安全”的能力。CoVE在生态系统里的定位可以参考x86上的TDX和ARM上的CCA。它定义了三种角色TEE- Host也就是运行在HS模式下负责创建和管理机密VM的HypervisorTVM即受TEE保护的机密虚拟机本身运行在VS/VU模式TEE-SMSecurity Manager运行在M-mode是整条信任链的锚点。TEE-SM手里攥着PMP配置、MMTT页表的根表指针以及所有状态切换的入口是真正“高权限但不能被攻击者控制”的部分。理解CoVE的位置对后续读规范非常有帮助。它不是在某个CPU微架构里新增一块神秘硬件而是把RISC-V已有的PMP、H扩展、SBI等能力组合起来再补上缺失的“页表级物理内存隔离机制”和“安全世界切换协议”。所以先把PMP理解透再看CoVE会轻松得多。2. PMP隔离最低层、最可靠的硬件防线2.1 PMP的原理与工作机制PMP属于RISC-V特权架构里M-mode独有的配置机制S模式和U模式完全看不到、改不了PMP寄存器除非M-mode通过SBI或固件接口主动提供了操作入口。每个PMP条目由两个CSR组成一个8位的pmpcfg配置寄存器一个地址寄存器pmpaddr。标准规范实现了16个条目有些平台扩展到了64个但软件至少要兼容16个条目的情况。PMP每个条目的8位配置字段分成几组bit0是R读权限bit1是W写权限bit2是X执行权限bit3和bit4的组合决定地址匹配模式bit5是L锁定位。这里有几个关键点需要记住。第一W权限位的置1隐含要求R也为1也就是说硬件不支持“只写不可读”的区域这跟很多工程师习惯的内存保护思路不一样。第二L位一旦置1整个条目就锁死了后续M-mode软件也无法修改直到系统复位这样做可以防止固件被攻破后篡改内存保护配置。第三如果当前有任何一个PMP条目存在那么没有被任何条目匹配的物理地址S/U模式的访问全部默认拒绝这一条是“默认拒绝”策略的精髓也是安全的根本。权限匹配发生在物理地址访问通路里无论指令取指、数据读写还是DMA通过CPU侧发起的访问都要经过PMP检查。不过需要注意PMP一般只能挡CPU访存通路对于外设DMA直接访问内存的场景需要额外的IOMMU或者总线侧的防火墙来配合这一点做RISC-V TEE时特别容易踩空。我们当时调试时发现一个不经常用的外设DMA居然可以绕过PMP直接写内存后来才知道PMP的覆盖范围是CPU不是全总线。2.2 地址匹配模式NAPOT、TOR与OFFPMP的地址匹配模式有三种OFF、NAPOT和TOR。OFF自然是不启用。NAPOT全称Naturally Aligned Power-Of-Two也就是“自然对齐的2的幂次大小区域”一个条目就能覆盖一段连续区域。TOR全称Top of Range需要两个连续的条目配合使用一对条目夹出一个区间。从使用频率来看NAPOT用得最多因为一个条目能搞定一段区域不占用额外条目。它的编码方式稍微反直觉pmpaddr寄存器里低若干位为1表示区域的大小。具体来说4字节粒度时低2位为1也就是写入的地址后两位必须是二进制38字节粒度时低3位为1以此类推。所以软件配置NAPOT时必须保证基址是按区域大小对齐的否则就会把区域悄悄“扩大”甚至导致配置无效。举一个我常用的具体配置例子要把物理地址0x10000000开始的4MB区域设为S/U模式只读做法是往pmpaddr写入0x043FFFFF同时pmpcfg设为R1、W0、X0、ANAOPT、L1。数值拆解是这样的0x10000000右移2位是0x040000004MB区域对应低22位全为1所以掩码是0x3FFFFF两者按位或得到0x043FFFFF。这个值写进PMP后从0x10000000到0x13FFFFFF的4MB物理空间就被严格保护了。用NAPOT时有个常见误区软件总想用“pmpaddr base 2”再去单独设置size字段实际上NAPOT的size信息就编码在地址的低位里没有独立的size寄存器。你写进去的地址低位一旦有0有1实际保护范围立刻变得古怪。如果不想被这种编码折磨最简单的做法是用TOR模式entry N写起始地址entry N1写结束地址两个条目都设为TOR逻辑上清晰很多代价是多占一个条目。对条目数量敏感的芯片设计一般还是用NAPOT。2.3 实操写一段配置PMP的裸机代码在RISC-V裸机环境里配置PMP核心就是读写几个CSR。以下是我在QEMU虚拟的sifive_u机器上实际跑过的一段代码配置两块区域一块是机密内存只允许M-mode访问另一块是共享内存S/U模式可读可写。#include stdint.h #define PMPCFG0 0x3A0 #define PMPADDR0 0x3B0 #define PMP_R 0x01 #define PMP_W 0x02 #define PMP_X 0x04 #define PMP_NAOPT 0x18 #define PMP_TOR 0x08 #define PMP_LOCK 0x80 static inline uint64_t read_csr(uint64_t csr) { uint64_t val; asm volatile(csrr %0, %1 : r(val) : i(csr)); return val; } static inline void write_csr(uint64_t csr, uint64_t val) { asm volatile(csrw %0, %1 :: i(csr), r(val)); } static void pmp_set_napot(int idx, uint64_t base, uint64_t gran, uint8_t flags) { // gran必须是2的幂且base按gran对齐 uint64_t pmpaddr (base 2) | ((gran 2) - 1); write_csr(PMPADDR0 idx, pmpaddr); // 先写addr再写cfg避免中间态 uint64_t cfg read_csr(PMPCFG0); cfg (cfg ~(0xFFULL (idx * 8))) | ((uint64_t)(flags | PMP_NAOPT | PMP_LOCK) (idx * 8)); write_csr(PMPCFG0, cfg); } void tee_pmp_init(void) { // 0x10000000 ~ 0x103FFFFF 共4MB作为机密区只允许M模式读写、执行 pmp_set_napot(0, 0x10000000, 0x400000, PMP_R | PMP_W | PMP_X); // 0x20000000 ~ 0x20000FFF 共4KB作为共享区S/U可读可写 pmp_set_napot(1, 0x20000000, 0x1000, PMP_R | PMP_W); }这段代码虽然简单但有两个细节希望各位注意。第一个细节是“先写pmpaddr再写pmpcfg”的顺序如果先写cfg把条目激活再写addr那么在某些乱序执行的CPU上可能出现中间态访问异常特别是对地址范围做缩小时尤其危险。第二个细节是L位我在安全初始化里直接加了PMP_LOCK固件启动阶段配置完后立刻锁死之后谁也没法改这块区域包括TEE-SM自己这样做防止后续M-mode固件被恶意篡改后把PMP放开。有个常见面试题值得顺带一提为什么RISC-V规定W置1的时候R必须为1从硬件实现角度看地址总线上读和写是两个独立通道允许“只写不读”会让cache和总线协议的处理复杂很多从安全角度看允许只写不读意味着攻击者可以向受保护区域注入数据这种区域的实用价值有限干脆禁止。理解了这条规定的背景写代码时就不会试图配置“只写”区域了。2.4 容易被忽略的坑默认策略、粒度与锁定PMP相关的坑十个有八个出在默认策略和粒度上。先说默认策略如果你一个PMP条目都没配S/U模式是可以访问全部物理内存的因为硬件默认放行但只要你配置了任何一个条目所有未被匹配的地址立刻变成禁止访问。很多工程师在一开始调试时配了一个4KB区域然后发现系统连串口都跑不了了其实就是默认拒绝生效了串口所在的内存没被任何PMP覆盖自然被挡了下来。解决办法很简单在安全固件里要么把需要的设备内存区全部显式配置出来要么在最后故意留一个“宽泛区域”作为兜底。比如你可以用TOR模式配置一个从0x80000000到0xFFFFFFFF的宽区域允许S/U可读写这样未列入安全范围的普通内存就不会被全部卡死。这种兜底策略也提醒我们PMP的“默认拒绝”是把双刃剑安全性提高的同时系统集成的工作量也增加了。第二个坑是粒度。RISC-V特权规范里PMP条目的最小粒度是4字节但很多实现在硬件上做了简化比如某些低端RV64核只支持4KB粒度你配置一个4KB以下的区域硬件可能直接当0处理或者把相邻区域也一起包进去。这个问题在QEMU里不明显QEMU对PMP的模拟比大多数真实核更“宽容”。所以我建议在写固件时加一个自检函数往PMP写入一个最小区域然后尝试访问边界内外的地址看实际行为是否符合预期。这条自检逻辑在CPU核移植时尤其有用能快速发现硬件实现和软件假设之间的偏差。第三个坑是锁定后的调试困境。L位一旦置1连M-mode自己都改不了这在生产环境是好事在调试阶段却非常难受。你想临时调一块区域结果发现之前初始化代码已经把PMP锁死了只能重新复位整个系统或者靠JTAG调试器去强制改写寄存器。我个人的习惯是调试版固件里不锁L位只在release版里锁用同一个宏控制。这样既不影响线上安全又不折磨自己的调试体验。3. 安全监视器把“切换”做成可信的3.1 M-mode与S-mode的分工边界RISC-V特权级一共四级M-mode机器模式、HS-modeHypervisor扩展后的监管模式、VS-mode虚拟化guest的监管模式、U-mode用户模式。在CoVE体系里安全监视器通常跑在M-mode因为只有M-mode能配置PMP、操作物理内存保护相关的CSR并且对全局异常拥有最终处置权。安全监视器这个角色可以类比成一座大楼的总控室。S-mode里的Hypervisor是各个楼层的物业经理可以管理各房间VM的资源但不能擅自修改大楼的承重墙物理内存布局和总水阀PMP、全局中断。任何涉及“世界切换”的操作比如把CPU从普通VM上下文切到机密VM上下文都要从低特权级通过一条确定的通道进入M-mode由安全监视器来执行。这个通道在RISC-V里就是ecall指令加一组约定的调用号CoVE的API也是建立在同样的机制之上。我在设计安全监视器时第一原则是“最小代码量”。M-mode这段代码是所有信任的锚点它一旦有漏洞整个TEE就归零了。所以它应该只做三件事接收受支持的调用请求、切换上下文、返回结果其余业务逻辑一律下沉到HS或VS模式。用Rust还是C写这段代码争论很多但比语言更关键的是代码量和审计路径。精简到极致的安全监视器可能也就几百行C这样的代码是可以被人类逐行审计的。3.2 世界切换与上下文保存安全监视器最核心的功能是世界切换具体来说就是保存当前CPU上下文、换取另一套上下文、然后改变PMP或内存保护配置。这个过程非常容易出错因为RISC-V的CSR中有大量状态不是简单保存恢复就能一致的比如satp地址翻译寄存器、medeleg/mideleg异常委托寄存器、hgatpguest地址翻译寄存器甚至还包括PMP配置本身。我踩过最深刻的一个坑是上下文切换时忘了恢复hgatp。第一次写TEE-SM的切换逻辑时我习惯性地只保存了satp因为之前做RTOS切换时只需要管它。结果切到机密VM之后guest的物理地址翻译完全错乱表现为host侧偶尔能通、guest侧一跑就grade fault。排查了很久才发现RISC-V H扩展里guest的访存要经过两阶段翻译VS模式的satp管虚拟到guest物理HS模式的hgatp管guest物理到系统物理。切换上下文时这两者必须同时换少一个都不行。上下文保存的完整清单在不同实现里略有差异但至少包括通用寄存器、status寄存器、satp/hgatp、stvec/vstvec、sscratch/vsscratch、以及中断相关的CSR。一个实用的做法是使用一张固定的内存结构体把整套上下文按固定偏移排放切换时先用汇编保存现场再调用C函数进行PMP配置等慢速操作。这个固定结构体本身要放在PMP保护的区域内并且不能放在普通VM能访问的共享内存里否则攻击者可以通过篡改保存的上下文来实现提权这个隐患我们内部review时专门强调过。3.3 以OpenSBI为底座扩展TEE-SMOpenSBI是RISC-V生态里最常用的M-mode固件它实现了SBI规范给HS模式提供一系列运行时服务。在真实项目里很少有人从零写TEE-SM更常见的做法是在OpenSBI基础上扩展增加安全相关的SBI调用或者CoVE API入口。选择OpenSBI作为底座有几个现实理由。第一它已经处理好了平台初始化和串口重定向等基础工作省去重复造轮子。第二OpenSBI具备完整的异常委托配置使能H扩展后的mideleg、medeleg设置都有现成代码。第三OpenSBI社区对PMP的管理有统一的封装接口包括sbi_platform提供的pmp区域注册机制扩展新区域只需要按接口实现即可。但使用OpenSBI也要当心。默认的OpenSBI源码里PMP配置是为了满足SBI规范“让S模式能访问全域内存”的要求它会尽量给S模式开放访问权限这跟TEE-SM“限制Hypervisor访问机密区”的目标存在冲突。所以安全监视器分支里启动流程会重写PMP配置把一部分OpenSBI默认开放的权限收回来同时保留必要的设备访问区。这个过程顺序很重要先初始化内存控制器再配置PMP最后再开放S模式否则在PMP收紧前S模式已经有短暂窗口可以访问敏感区域。3.4 安全监视器与SBI的边界划分安全监视器和SBI的关系常让人困惑。简单说SBI是“M-mode给S-mode提供基础服务”的标准接口负责时钟、控制台、IPI等安全监视器是“M-mode给TEE提供安全服务”的专有接口负责世界切换、内存保护、启动度量和机密VM生命周期管理。两者都跑在M-mode但定位不同、调用方也不同。实际工程里一个请求进来后怎么区分是普通SBI调用还是安全调用通常通过ecall的function ID来区分。普通SBI命令走EID 0x10~0x135等标准定义CoVE相关API则使用独立的EID范围。TEE-SM在处理请求时先按EID分发再检查调用方的来源——如果是普通VM发来的安全调用请求直接拒绝只有合法注册过的TEE相关组件才能进入安全处理逻辑。边界划清之后还有个现实问题设备中断怎么处理。安全监视器不能把所有中断都关掉那会影响实时性也不能让普通VM轻易触发M-mode中断。通用做法是安全监视器只接收自己注册的定时器和IPI中断其他设备中断全部通过PLIC直接路由给HS或VS模式。这样M-mode的中断入口代码量极少安全攻击面也随之降低。我们的代码里M-mode中断处理函数只有二三十行汇编加一个C函数专门应对安全环境下的时间片切换。4. CoVE机密虚机把机密计算做成“虚拟化”4.1 CoVE的整体架构TVM、TEE-Host与TEE-SMCoVE架构里三个核心角色要再强调一遍TEE-SM运行在M-mode是安全锚点持有全部物理内存保护配置和机密VM状态TEE-Host运行在HS-mode本质上是带有特权的Hypervisor负责创建和管理普通VM同时可以请求TEE-SM创建机密VMTVM则是运行在VS/VU模式下的机密虚拟机它的物理内存区域被PMP和MMTT双层保护起来连TEE-Host都访问不到。这种三角色设计和ARM CCA、x86 TDX在思路上有很强的对应关系但CoVE有个特点更贴近RISC-V风格TEE-Host本身不被完全信任它只是“受约束的管理者”。TEE-Host可以发起创建机密VM的请求但真正的内存隔离转换、状态初始化、启动测量全都在TEE-SM里完成。TEE-Host如果试图读取机密VM的内存硬件层会直接拒绝它连数据影子都看不到。这对Hypervisor的开发方式影响很大。传统Hypervisor可以用任意内存布局管理所有VM但CoVE场景下Hypervisor必须把机密VM的物理内存区域交给TEE-SM管理自己手里的仅仅是一段“凭据”。创建流程大概是TEE-Host向TEE-SM申请一块物理内存区域TEE-SM把该区域从TEE-Host的地址映射中摘除再配置PMP/MMTT标记为仅TVM可访问最后把控制权交给TVM的启动固件。这之后TEE-Host对这片区域发起的访问硬件直接拦下。4.2 CoVE API与机密VM的生命周期CoVE API是TEE-SM暴露给上层安全软件的系统调用接口形式上就是一组带特定编号的ecall。标准里定义了一系列操作包括创建TVM上下文、添加内存页面、销毁TVM、获取安全报告等。每个调用都要经过严格的参数校验因为TEE-SM的输入来自不可信的Hypervisor任何未经验证的指针和长度都可能成为攻击入口。机密VM的生命周期和普通VM很不一样。普通VM是Hypervisor直接创建和销毁的而机密VM的创建分成两个阶段第一阶段由TEE-Host向TEE-SM申请上下文并填入初始内存镜像第二阶段由TEE-SM对镜像做度量然后启动TVM。过程中还有个关键点是“私密启动”TVM的初始代码和数据通常以密文形式提供给TEE-Host由TEE-SM在受保护环境内解密这样连TEE-Host都不知道机密VM的初始内存内容是什么。销毁阶段也一样敏感。普通VM销毁时Hypervisor直接把内存回收就行机密VM销毁时必须由TEE-SM先将对应物理内存清零或做去初始化处理防止残留数据被下一个VM读到。我们调试时发现过一种情况TVM被销毁后Hypervisor复用物理内存结果从内存里读出了上一个TVM残留的密钥片段原因就是销毁流程没有走TEE-SM的清理接口而只是把内存标记为空闲。找TEE-SM加回清理逻辑后这个问题才消失。4.3 内存隔离不只是PMP还有MMTT/MMTT页表机制CoVE解决的一个核心问题是PMP的条目实在太少了。标准PMP只有16条就算扩展也就是64条要覆盖机密VM里动态变化的页级内存是做不到的。所以CoVE配套定义了Smmtt扩展核心是MMTT——一套由M-mode安全监视器控制的物理内存页表让TEE-SM可以像操作普通页表一样为每个物理页面设置访问权限。PMP在CoVE里的角色退回到“兜底”只保护MMTT页表本身和少数关键区域而不是保护每一个机密VM页面。MMTT机制可以理解为“物理地址空间的二级页表”。常规MMU翻译的是虚拟地址到物理地址MMTT翻译的是物理地址到访问权限。CPU在发起物理访问时先查MMTT不通过就直接产生异常根本到不了PMP。TEE-SM写MMTT时要保证自身代码和数据所在的区域始终可访问否则会出现“安全监视器把自己关在门外”的尴尬。第一次实现MMTT时我就在切换页表根指针的时候没处理好映射结果TEE-SM下一次访问自己代码就直接崩溃查了整整一天。MMTT和PMP还有一个配合细节MMTT页表本身存放在内存里如果PMP不保护这块内存攻击者可以在TEE-SM修改MMTT之后篡改页面权限。所以正确配置顺序是先把MMTT页表所在物理区域用PMP保护起来再开启MMTT机制。这就是“PMP兜底”的实义它不是保护每个页面而是保护那本“写着每个页面权限的账本”。4.4 中断、设备与直通问题机密VM不能完全脱离设备I/O单独存在现实业务里VM总需要读写磁盘、网卡或其他外设。CoVE处理设备I/O的策略分为几种第一不需要保护的中断直接走虚拟化中断机制给TVM一个虚拟中断控制器第二需要访问的设备通过直通方式分配给TVM但直通设备带来的DMA问题必须谨慎处理第三对安全敏感的设备则完全绕过TEE-Host直接与TEE-SM对接。中断这块有个经典坑普通VM的时钟中断和IPI是TEE-Host帮忙模拟的但机密VM不能信任TEE-Host因为它可能在中断注入时做手脚。CoVE方案通常让TEE-SM直接给TVM提供安全定时器中断而不是让TEE-Host通过虚拟中断接口来触发。这样TEE-Host最多知道“TVM收到一个中断”但不知道具体中断类型和时间戳。DNS这类时间侧信道攻击的防御也在这一层上做文章。设备直通时DMA访问必须经过IOMMU翻译因为PMP挡不住设备发起的DMA。TEE-SM在给TVM分配直通设备前要把对应设备的IOMMU页表配置成“只允许访问TVM的物理内存区域”否则设备DMA可以越过MMTT访问任意内存。一个常见的错误是TEE-Host把已分配TVM的PCIe设备忘记从IOMMU映射中摘除导致设备在收到中断后DMA写入了普通VM的内存。这类问题在CoVE里表现得非常隐蔽因为IOMMU页表是TEE-Host管理的它可能出于性能考量保留缓存映射却不知道这些映射已经被用于攻击。5. 实操实录在QEMU上跑一个最小可信执行环境5.1 准备工具链与环境纸上谈兵够了来点能跑的东西。我在QEMU上搭过一个最小RISC-V可信执行环境包含OpenSBI、一个简化版TEE-SM以及一个只能运行在受保护内存里的裸机VM。整个环境用QEMU的virt机器加RV64GC配置启用H扩展。需要准备的工具包括riscv64-linux-gnu工具链QEMU建议7.0以上版本OpenSBI源码。构建OpenSBI时要打开PLATFORM_RISCV_XLEN64并且确认启用了SBI扩展的HSM支持因为后面要用它来切换CPU状态。# 构建OpenSBI make PLATFORMgeneric CROSS_COMPILEriscv64-linux-gnu- -j$(nproc) # 启动QEMU4核2GB内存 qemu-system-riscv64 -M virt -smp 4 -m 2G \ -bios opensbi/build/platform/generic/firmware/fw_jump.bin \ -kernel test_tee.elf -nographic如果你手头有真实的RISC-V开发板比如VisionFive 2或者类似的芯片原理一样只是PMP的条目数量和粒度要按芯片手册重新核对。我在QEMU上验证过的逻辑放到真实平台后通常要调的一是内存地址范围二是PMP粒度假设三是中断控制器的差异这部分没有任何捷径只能逐项对照手册。5.2 用链接脚本把机密区固定到物理内存一个很容易被忽略的点是普通编译出来的裸机程序内存布局是随意的但可信执行环境要求关键数据必须落在PMP保护区域里。这时候就需要链接脚本这也是risc-v工程里经常见到link.ld的原因。下面这个链接脚本是我专门为最小TEE环境写的把所有安全相关的段固定到0x10000000开始的机密区。OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { /* 机密区受PMP保护4MB */ TEE_SECURE (RWX) : ORIGIN 0x10000000, LENGTH 4M /* 共享区S/U可见4KB */ TEE_SHARED (RW) : ORIGIN 0x20000000, LENGTH 4K /* 普通内存作为栈等用途 */ TEE_NORMAL (RWX) : ORIGIN 0x80000000, LENGTH 512M } SECTIONS { . ORIGIN(TEE_SECURE); .text : { *(.text.init) *(.text*) } TEE_SECURE .rodata : { *(.rodata*) } TEE_SECURE .data : { *(.data*) *(.sdata*) } TEE_SECURE .bss : { *(.bss*) *(.sbss*) } TEE_SECURE /* 共享区放固定地方TEE-Host与TVM都能看到 */ . ORIGIN(TEE_SHARED); .shared : { KEEP(*(.shared*)) } TEE_SHARED /* 栈放普通内存避免机密区耗尽 */ . ORIGIN(TEE_NORMAL); .stack : { . ALIGN(16); . 0x10000; __stack_top .; } TEE_NORMAL }这个脚本有两个值得一提的点。第一我把.text、.rodata、.data、.bss全部塞进了机密区这样编译出来的TEE-SM和TVM镜像整体都在PMP保护范围内不用担心哪个段漏在外面。第二共享区和栈是分开的共享区放少量通信结构体栈放普通内存既满足隔离需求又不会因为机密区太小而导致栈溢出。编译命令也很常规riscv64-linux-gnu-gcc -nostdlib -static -T link.ld -marchrv64gc -mabilp64d \ -O2 -o test_tee.elf start.S tee_main.c riscv64-linux-gnu-objcopy -O binary test_tee.elf test_tee.bin这里要注意-nostdlib意味着没有标准库所有运行时初始化都得自己写包括清零bss段、设置栈指针、初始化PMP。我见过不少朋友直接拿编译Linux内核的方式编译裸机TEE镜像结果链接脚本和运行时环境完全不匹配跑起来各种莫名崩溃。裸机镜像的核心就三件事正确入口、正确栈、正确内存布局链接脚本必须和启动汇编一一对应。5.3 一个最小TEE-SM与VM的启动流程最小TEE-SM的启动流程我建议按下面这个顺序写每一步都有明确的目的第一步进入M-mode入口汇编设置栈指针然后调用C函数tee_sm_init。第二步tee_sm_init里先清BSS再初始化串口和定时器然后配置PMP把机密区和共享区按前面讲的方式锁定。第三步配置MMTT页表先把TEE-SM自身的代码和数据映射为M-mode独占再映射共享区为S/U可访问。第四步设置好异常向量表将普通中断委托给HS模式仅保留M-mode必须处理的安全相关中断。第五步执行mret降到HS模式启动TEE-Host。这里有个容易混淆的地方TEE-SM本身运行在M-mode它并没有像Linux那样的“用户态”它的一切都在M模式下直接访问物理内存。所以MMTT对TEE-SM自身的保护主要体现在“防止S/HS模式访问”而不是限制M-mode自己。给TEE-SM自身配置PMP时一般不留W权限这样即使TEE-SM代码有缓冲区溢出漏洞也无法改自己的指令段只能通过异常来暴露问题。VM侧的最小实现就更简单了。一个裸机VM从0x10010000开始执行先读共享区里的命令字然后根据命令返回一个状态再进入WFI循环。关键是这个VM的物理地址区域被PMP保护后TEE-Host从0x10010000去读数据会直接触发访问异常。验证隔离效果就是让TEE-Host读这一块看是否按预期报错。5.4 验证隔离效果边界访问与性能开销验证阶段我用了一段很土但很有效的代码在TEE-Host侧访问机密区外的一个字节、机密区边界上的一个字节、机密区内部的一个字节分别记录结果。预期结果是机密区内部访问触发PMFPhysical Memory Protection Fault异常边界外正常边界上取决于PMP区域的对齐边界。实测下来QEMU的行为跟预期完全一致并且异常原因码是Load/Store/AMO access fault足够定位问题。隔离验证通过之后还要测性能开销。PMP检查本身是并行硬件逻辑理论上命中延迟极低但MMTT查找需要额外的内存访问如果设计不好可能造成显著的性能回退。我在QEMU上用简单的循环计时测过开关MMTT后普通内存访问的延迟增加并不明显QEMU模拟下基本测不出来。真机上的主要开销通常来自TLB和MMTT缓存的刷新频率——世界切换越频繁开销越大。优化方向一般有两个一是减少世界切换次数把多个安全调用合并二是使用ASID和VMID的区分避免每次切换都完全冲刷TLB。最后分享一个小提示QEMU里看PMP状态可以用info registers或者info cpus也能通过gdb连接QEMU的GDB stub来查看CSR。当PMP配置不生效时先用gdb确认pmpcfg和pmpaddr的CSR值到底写进去没有这一步能省下大量瞎猜时间。我调试时经常用到这种方式比盲目加打印高效得多。6. 常见问题与排查技巧实录6.1 PMP配置不生效CSR写不进去现象读pmpcfg显示全0或者写PMP后S/U模式依然能访问受保护内存。排查步骤第一步确认当前是否在M-modeS-mode写PMP CSR会直接触发非法指令异常。第二步确认CSR地址对不对注意pmpcfg0是0x3A0pmpaddr0是0x3B0但RV64下pmpcfg0同时包含entry0和entry1两个8位配置写的时候要小心位偏移。第三步检查是不是其他代码在你配置后又把PMP覆盖了比如OpenSBI在启动后期会重建PMP这就很常见。第四步确认没有开启某个调试特性比如某些模拟器或调试器会屏蔽PMP异常导致访问违例被吞掉。还有一个容易被忽略的点PMP是支持按hart独立配置的。多核系统里如果只在hart0配了PMPhart1上跑的程序依然能访问受保护内存。我们遇到过真实案例一个攻击者从hart1发起DMA式访问直接绕过了hart0上配置的隔离。所以在TEE-SM初始化时必须对每个hart都执行同样的PMP配置流程而且要考虑hart启动顺序。6.2 世界切换后上下文丢失或状态错乱最典型的现象是切换后程序跑飞或者返回后寄存器值和切换前不一致。我的排查顺序是先确认保存上下文的内存区域是否可写如果这份内存在PMP保护范围外可能被其他软件改写。再确认保存现场时有没有漏掉CSR尤其是mstatus的MPP位和MPIE位它们决定mret之后回到哪个特权级以及中断是否使能这个坑我踩过三四次。第三检查切换过程中有没有中断插入如果切换程序本身没关中断一个外部中断可能打断保存现场的过程导致上下文不完整。最简单的防护就是在世界切换入口处先清掉MIE保存完现场再恢复。另外切换到TVM后TVM自己的启动环境也要准备好比如gp寄存器全局指针、栈指针和BSS段清零。估计有人会问这些通用初始化为什么不能交给TVM自己可以但你要确保TVM的入口代码能安全完成初始化也就是说它不需要依赖任何来自TEE-Host的启动参数。如果TVM把启动参数放在共享区而这个共享区被TEE-Host篡改过那么TVM的启动行为就可能被攻击者控制。所以CoVE初创TVM的标准做法是TEE-SM把初始参数放在机密内存内部而不是共享区。6.3 CoVE API调用失败或死锁CoVE API调用失败的常见原因有两类一类是EID或function ID不对导致TEE-SM不认识这个请求另一类是参数校验不过比如传了一个指向普通VM内存的指针TEE-SM访问不到只能返回错误。排查方法很简单在TEE-SM入口处打印EID和error code再对照规范比对调用参数。比较少人注意到的是TEE-SM内部使用锁保护状态时如果某个调用路径在持锁状态下做内存访问而这个内存页刚好不在当前MMTT映射里就会触发异常死锁。所以TEE-SM的锁设计应该保持“短临界区”最好只在状态表的更新时才持锁其他操作一律无锁。死锁问题还有一个隐蔽来源同一个hart上先进入TEE-SM调用了某个APIAPI内部又触发了需要TEE-SM处理的事件形成递归调用。这种情况在使用MMTT后尤其容易遇到因为MMTT自身缺页或者权限异常会作为TEE-SM的异常进来如果异常处理逻辑里又尝试获取同一把锁就直接死锁。我最终的方案是给TEE-SM异常处理设置了重入标志一旦发现重入就立刻终止并返回错误绝不尝试解除同一个锁。6.4 测量启动与attestation对不上CoVE的attestation基于启动过程的度量值如果你的固件里某个字节没按预期加载度量结果就会对不上。常见原因是启动流程里存在非确定性行为比如读取了时钟或者随机数然后这个值又被固件代码改变了内存布局。度量环境必须保证同一份固件镜像在任何一次启动中都产生完全相同的度量序列所以任何用到随机数的操作都只能在度量完成后再进行。另一个常见问题是哈希算法选择不一致。OpenSBI默认可能用sha256而你的attestation服务默认用sha384两边对不上。一定要把度量算法、基线值和事件日志的格式在TEE-SM与attestation验证服务之间统一起来最好在编译时通过一个固定的头文件来定义不要靠运行时发现。这个问题看着简单但真实项目里因为算法不一致导致联调几天的例子不少。6.5 问题定位速查表为了方便快速定位我把平时调试中积累的高频问题整理成一张表基本能覆盖RISC-V可信执行环境初期的90%疑难杂症。现象可能原因快速排查动作S/U模式仍能读受保护区域PMP条目没生效或被覆盖gdb检查pmpcfg和pmpaddr访问受保护区域不报错调试器/模拟器屏蔽了PMP异常换到真机或检查QEMU参数切到TVM后崩在入口上下文恢复不完整hgatp/satp错乱检查切换函数中satp/hgatp恢复顺序安全调用返回错误EID或参数校验失败在TEE-SM打印error codeTVM销毁后内存残留未走TEE-SM清理流程检查销毁API是否完成内存清零测量值每次启动都不一样启动流程里用到了随机数或实时时钟调整启动顺序度量阶段禁用随机源设备DMA写坏内存IOMMU映射未同步检查IOMMU页表和TEE-SM的MMTT映射M-mode死锁异常重入或持锁访问缺失映射开启重入标志缩小临界区这张表里的每一行都是我实际调试时对着崩溃现场一页页翻出来的。建议你把这些场景固化到自己的测试用例里每次在真实平台或QEMU上跑一遍回归安全功能最怕的就是“之前能用改完不能用”的隐性回归。写在最后一份来自实操的经验提醒折腾完这一整套RISC-V可信执行环境我最深的一点体会是安全功能从来不是靠某一个单独的机制而是靠“层级间互相约束”的综合效果。PMP是墙MMTT是门禁TEE-SM是保安CoVE API是访客登记制度任何一个环节失效另外几层还能顶住这才是可信设计该有的样子。如果你的方案里只有一层防护那无论它多强我都会觉得不踏实。最后再分享一个小技巧在调试TEE-SM时一定要把“断言”编译选项打开并且在每次异常时打印完整CSR转储包括mepc、mcause、mtval、satp和pmpcfg。这些信息组合起来基本能让你在三分钟内定位出是权限配置错、地址翻译错还是状态恢复错。等你调完整个系统再把这些调试开关关掉release固件里只保留最精简的错误码通道。这套“调试版丰富、release版精简”的思路能让你少熬很多夜也更接近一线工程里的真实节奏。