收到这个题目,很多人第一反应是:CHERI 不是那个还在论文里、实验室里的东西吗?Sonata Board 又是什么开发板?我也是抱着“先看看能跑成什么样”的心态入手的。结果这块板子给我的冲击,比想象中大得多——它把一套完整的内存安全硬件机制放到了低成本的 FPGA 平台上,而且真的能跑 CheriBSD,能交叉编译 C 程序,能亲手触发一次“越界保护 fault”。这篇文章就记录一下我在 Sonata Board 上折腾 CHERI 的完整过程,包括环境准备、第一个 capability 程序、内存安全检查实验,以及一堆新手才会踩的坑。想接触硬件内存安全、RISC-V 扩展、C/C++ 指针安全的朋友,这篇文章应该能帮你省下不少试错时间。
1. CHERI 到底是什么,以及为什么值得在 Sonata 上玩
1.1 从“指针只是个地址”说起
传统的 C/C++ 里,指针就是一段内存的地址。它告诉你“在哪儿”,但完全不告诉你“能往哪走”“能走多远”“能不能写”。这就相当于一张只写了门牌号的便条,拿到它的人理论上可以打开这个门牌对应的整栋楼,甚至顺着地址改几个字节就进了隔壁楼。
CHERI(Capability Hardware Enhanced RISC Instructions)的思路,是给这个“便条”升级成一张“门禁卡”。每个指针相关的 capability 不再只是一个地址,而是一段带有元数据的对象:它包含当前地址、基址、上界、访问权限、对象类型,以及一个硬件维护的 tag 位。这个 tag 位极其关键,普通 store 指令往内存里写数据是不可能伪造出有效 tag 的,只有硬件层面的 capability 加载指令才能把它置为有效。所以一个 capability 不能被内存漏洞伪造,也不能被简单改几个字节来绕过。
我一开始用另一个类比来理解:传统指针像“房间号+房卡”,CHERI capability 像“门禁卡+可以活动的区域+允许做的操作+防伪标识”。你可以在门禁卡允许的范围内到处走,但一旦越过范围边界,感应门会瞬间锁死。而且这个锁死不是靠软件检查——比如 AddressSanitizer 那种运行时插桩——而是在 CPU 流水线里,每次内存访问发生时硬件自动检查的。
这里有一个关键点:CHERI 不是单纯加几个寄存器,而是改变了 CPU 对指针和内存访问的底层语义。传统架构里,一次 load 就是“取地址,读内存”;CHERI 架构里,一次 load 会先确认这个 capability 的 tag 是否有效、地址是否落在 bounds 内、权限是否包含读。这一套检查在硬件流水线里完成,不需要软件在每条指令前插桩,所以性能开销比纯软件方案要小得多,语义上也更彻底——因为它连恶意代码的操作都拦得住,而不仅仅约束“好程序”的 UB 行为。
1.2 Sonata Board 在 CHEI 生态里的位置
CHERI 目前有几个落地平台:Arm 的 Morello 是一块比较出名的实验板,但门槛不低,渠道也受限;QEMU 模拟器也可以跑 CheriBSD,但模拟器里你不会感受到“真实的硬件页面错误、真实的中断、真实的 DMA 和网卡交互”。Sonata Board 是 NewAE 和 lowRISC 等团队合作推出的开源硬件开发板,基于 RISC-V,板子上集成了支持 CHERI 扩展的处理器核,可以直接跑 CheriBSD。
它的定位就很适合“想用真硬件做实验但又不想等实验室渠道”的开发者。板子是 FPGA 方案,整个 SoC 设计在 GitHub 上开源,甚至可以自己改一改 CPU 或外设,重新生成 bitstream。这意味着你不只是“用 CHERI”,如果你愿意,还可以“改 CHERI”。我这次虽然没改硬件,但那种“自己烧固件、跑实验、看 fault 日志”的感觉,比纯软件模拟扎实太多。
我建议先想清楚自己的实验目标。如果只是想看 CHERI 的内存保护效果,那 Sonata 足够;如果想对比 CHERI、MPK、ASan 的性能差异,那你需要更多测量工具,Sonata 只是一个起点。我这次的目标很明确:跑通交叉编译流程,写几个能触发内存安全问题的程序,观察硬件保护行为。
2. 环境准备:从开箱到进入 CheriBSD 控制台
2.1 硬件和连接
我手上的装备清单很简单:一块 Sonata Board、一条 USB-C 数据线、一张 microSD 卡、一个 USB 转串口模块(或者直接用板载的 USB 串口)。Sonata 板上有调试串口,插上 USB 后,在 Linux 下一般会出现/dev/ttyUSB0或者/dev/ttyACM0这样的设备。我用串口工具picocom连接,波特率是 115200,8N1:
picocom -b 115200 /dev/ttyUSB0如果你用的是 Windows,可以用 MobaXterm 或者 PuTTY 的串口会话,参数一样。注意别接错线,USB 数据线最好用质量好一点的,有些线只能充电不能传数据,我第一次就栽在这上面,折腾了半小时发现设备根本枚举不出来。
关于 SD 卡,官方文档里会给出镜像和烧录方式。烧录步骤其实就是把官方发布或自己构建的镜像用dd写到 SD 卡上。注意 SD 卡容量不要太小,至少 4GB 起步,我用的 16GB 卡没有遇到问题。烧录前一定确认设备名,别把自己的硬盘写没了:
# 以 /dev/sdX 为例,务必确认这是 SD 卡 sudo dd if=cheribsd-sonata.img of=/dev/sdX bs=4M status=progress conv=fsync烧完把卡插到板子上,接好串口和电源,上电后串口窗口里就会滚动启动日志。
2.2 获取 bitstream 和固件
Sonata 不是那种“插上就能用”的板子,它需要先把 FPGA bitstream 配置进去。这里有两个路线:一种是直接用官方 release 里编译好的 bitstream,省事;另一种是自己用 Vivado 和开源硬件流程生成,耗时但可定制。我这次偷懒,直接用官方提供的 bitstream,后续如果要改 SoC 再走构建流程。
写 bitstream 的方式和具体工具,以当前 Sonata 仓库文档为准,不同版本的工具链差异较大。我的建议是:第一次玩,直接下载预编译好的 bitstream 和 CheriBSD 镜像,把重点放在软件侧;等确认硬件能跑起来,再回头研究 FuseSoC 和 Vivado 的构建细节。
2.3 进入系统后的第一眼
板子启动后,会看到 CheriBSD 的 boot loader 日志,然后进入登录提示。默认账号密码在官方文档里有,我的实验环境里是以 root 身份登录的。登录后我做的第一件事是看系统信息:
uname -a dmesg | grep -i cheri如果内核消息里能看到 CHERI 相关的能力开启提示,说明整个链路已经通了。此时你已经拥有一个运行在真实 CHERI 硬件上的操作系统,接下来可以交叉编译自己的程序跑进去。
3. 交叉编译第一个 CHERI 程序
3.1 工具链怎么选
要在 Sonata 上编译程序,有两种做法。一种是在板子上直接编译,但板子的性能和存储有限,不适合跑大工程;另一种是在 PC 上使用 CHERI 交叉编译器,生成 RISC-V purecap 二进制,再拷到板子上运行。我推荐第二种,也是大多数人的选择。
官方提供的 CHERI SDK 基于 Clang/LLVM,支持riscv64cheri-unknown-freebsd这个 target triple。我一开始直接用普通 x86 Clang 编译,结果生成的二进制在板子上根本跑不起来,提示 Illegal instruction 或者直接段错误。原因很简单:普通 Clang 生成的是标准 RISC-V 指令,没有启用 CHERI capability 相关的指令和 ABI。
正确做法是用 CHERI SDK 里的 clang,或者自己用 cheribuild 构建一套工具链。构建工具链的命令大致是这样(具体路径以你 clone 下来的脚本为准):
git clone https://github.com/CTSRD-CHERI/cheribuild cd cheribuild ./cheribuild --build cheribsd-riscv64-purecap这条命令会下载源码、构建编译器、库、内核等,耗时比较长,我建议在磁盘空间充足的 Linux 机器上跑,至少预留几十 GB。构建完以后,编译一个 hello world 就可以用:
/path/to/sdk/bin/clang --target=riscv64cheri-unknown-freebsd -O2 -static -o hello hello.c这里-static是建议加上的,因为纯 capability 环境下动态链接的库路径比较麻烦,静态链接能让二进制直接跑。
3.2 第一次看到 capability 的样子
第一个程序不要写太复杂,先输出一个 malloc 指针的 capability 信息。代码如下:
#include <cheri/cheric.h> #include <stdint.h> #include <stdio.h> #include <stdlib.h> int main(void) { void * __capability p = malloc(64); if (p == NULL) { return 1; } printf("addr: %#lx\n", (unsigned long)cheri_address_get(p)); printf("base: %#lx\n", (unsigned long)cheri_base_get(p)); printf("len : %#lx\n", (unsigned long)cheri_length_get(p)); printf("perm: %#lx\n", (unsigned long)cheri_perms_get(p)); free(p); return 0; }编译后拷到板子上运行,你会看到base和len并不是随意的:malloc 返回的指针绑定了实际可访问区间,perm里也明确了读写权限。这里有一点很值得体会:同样的代码,在传统 C 里你拿到的是一个裸地址,在 CHERI 里你拿到的是一个“自带边界和权限的令牌”。如果代码越界,硬件会直接拒绝访问。
3.3 传输程序到板子
最简单的传输方式有两种。一种是在板子的 CheriBSD 上启用网络,然后用scp传文件;另一种是把编译好的二进制放到一个 FAT 格式的 U 盘或额外分区里。我这次没有配网络,直接用了后一种,把二进制放在 SD 卡的 FAT 分区,然后在板子上挂载并 cp 到/tmp里执行。这样一个来回下来,交叉编译的整个流程就闭环了。
4. 核心实验:亲眼看到越界和 UAF 被硬件拦截
4.1 越界访问实验
CHERI 最出名的能力是空间内存安全,也就是禁止越界访问。我写了一个非常简单的越界程序:
#include <stdio.h> #include <stdlib.h> int main(void) { char *buf = malloc(8); for (int i = 0; i < 16; ++i) { buf[i] = (char)i; } printf("done\n"); return 0; }这段代码在普通 x86 Linux 上编译运行,大概率不会立刻崩溃,因为堆的 8 字节后面可能还有可写内存,越界写会悄悄破坏堆元数据或者其他对象,问题被推迟到后续某个不可预测的时刻。这正是传统内存安全漏洞最难排查的地方:错误发生了,但现场早就被破坏了。
在 CHERI 环境下,程序运行到buf[8]附近时,硬件会检测到当前地址已经超过 capability 的 bounds,于是产生一个 CHERI protection fault。如果你是 root 或者有足够权限,内核会打印出类似 protection fault 的信息,进程被信号终止。整个过程没有任何模糊地带,错误精确发生在越界的那一条指令。
我当时的感受是:这太“不讲道理”了,硬件直接把指针的边界给焊死了。你可以在循环里看到i=8之后程序就断了,而不会等到最后才输出“done”。对调试来说,这种定位粒度比 ASan 报告还要精准一些,因为它是 CPU 在访问时实时拦截的。
4.2 释放后使用(UAF)实验
空间边界只是一个维度,CHERI 还处理时间上的安全,也就是 use-after-free。原理是:free()之后,存放在内存里的 capability tag 会被标记为无效。如果你的代码还保留着原来的指针,并且试图通过它访问内存,硬件检查 tag 时会发现这个 capability 已经“报废”,直接拒绝访问。
我写的实验代码更短:
#include <stdio.h> #include <stdlib.h> int main(void) { char *p = malloc(16); free(p); p[0] = 1; printf("ok\n"); return 0; }这段代码在传统环境里大多数时候也能“正常”跑完,因为 free 只是把内存块还给了 allocator,物理内存还在,p[0] = 1只是改了一块已经被释放的内存而已。但在 CHERI 上,p这个 capability 的 tag 在free后已经失效,任何 dereference 都会触发硬件异常。这个机制对漏洞利用的打击是致命的,因为 UAF 是很多真实漏洞的根因,攻击者通常需要反复利用悬挂指针来操控堆布局,CHERI 让这一招从第一下访问就直接失败。
4.3 为什么这些实验很有意义
我在做这些实验之前,其实对 CHERI 的“安全收益”多少有些将信将疑。通过这几个小例子,我发现它不是在现有体系上打补丁,而是在 ISA 层面重新定义了“指针到底是什么”。传统 C/C++ 的内存安全是“事后修补”——先出问题,再想办法检测;CHERI 是“事前约束”——CPU 根本不允许非法的指针操作被完成。
当然,CHERI 不是银弹。它解决的是内存安全中空间边界、指针完整性和释放后使用这一大类问题,但逻辑漏洞、整数溢出导致的边界算错、类型混淆等,依然需要其他手段配合。不过它至少把“内存破坏后程序行为不可预测”这个最让人头疼的问题,变成了“访问非法时立刻终止”的可预期行为。对系统软件和嵌入式开发来说,这种确定性是极大的安全感来源。
4.4 实验过程中可能遇到的“假阳性”
我在跑实验时一开始遇到一个奇怪现象:一个看起来很正常的程序,启动后立刻就 fault。后来发现是因为代码里用了不安全的指针类型转换,把一个普通地址强制转成 capability,导致 tag 无效。这提醒我,CHERI 环境里不能随便做整数和指针之间的转换,所有指针必须来自合法的 capability 派生操作。如果是从嵌入式裸机那边带过来的习惯——习惯用(uintptr_t)转来转去——到 CHERI 上第一个要改的就是这个。
5. 往深走一步:用 Capsicum 组合出进程沙箱
5.1 Capsicum 和 CHERI 的关系
Capsicum 是 FreeBSD/CheriBSD 里的一套沙箱框架,它基于 capability 的思想来限制进程对文件描述符的操作。一个进程调用cap_enter()之后,就无法再打开全局路径、创建网络 socket 等,只能使用已经传入的 fd,并且每个 fd 的权限还可以进一步通过cap_rights_limit()裁剪。这和 CHERI 的“能力模型”天然契合,因为两者都在强调“把权力收窄到最小必要范围”。
Sonata 上跑的是 CheriBSD,天然支持 Capsicum。我想试试在 CHERI 的纯 capability 环境下,Capsicum 是否会更严格。理论上,fd 本身可以携带更强的信息,编译器甚至能在编译期帮你检查某些非法用法。
5.2 一个小demo:限制文件描述符权限
下面这个程序在进入 capability mode 之前,打开一个文件,然后只保留读权限:
#include <sys/capsicum.h> #include <fcntl.h> #include <unistd.h> #include <stdio.h> #include <errno.h> #include <string.h> int main(void) { cap_rights_t rights; int fd = open("/etc/passwd", O_RDONLY); if (fd < 0) { perror("open"); return 1; } cap_rights_init(&rights, CAP_READ); if (cap_rights_limit(fd, &rights) < 0) { perror("cap_rights_limit"); return 1; } if (cap_enter() < 0) { perror("cap_enter"); return 1; } char c; if (read(fd, &c, 1) < 0) { perror("read"); return 1; } int newfd = open("/etc/passwd", O_RDONLY); if (newfd < 0) { printf("open after cap_enter failed: %s\n", strerror(errno)); } return 0; }编译后放到板子上运行,你会看到open在cap_enter()之后返回ECAPMODE或者类似错误,而之前打开的 fd 依然可以读。这个实验和 CHERI 没有直接关系,但在同一块板子上,你能同时感受到“软件级能力模型”(Capsicum)和“硬件级能力模型”(CHERI)的配合。
5.3 合起来的价值
如果你一个人开发一套网络服务,传统写法是进程启动后打开一堆文件、监听端口,然后进入事件循环。如果被攻击者利用漏洞,攻击者可以调用任意系统调用,读取敏感文件。Capsicum 可以把服务在启动后立刻“囚禁”在已有 fd 的集合里,攻击者即使拿到代码执行能力,也无法用open("/etc/shadow")去读东西。而 CHERI 则进一步保证,即使攻击者想通过内存破坏修改 fd 的状态或绕过检查,也会因为 capability 的 tag 和 permissions 被限制而失败。两者不是一个层面的东西,但可以组成纵深防御。
我把这套 demo 跑通之后,最大的体会是:安全不只是一个“特性”,而是一种贯穿硬件、操作系统、应用层的设计哲学。Sonata 和 CheriBSD 之所以让人兴奋,是因为你可以在一个真实的板子上,把这条链路完整地走一遍。
6. 排错与实战技巧:那些文档不会明说的坑
6.1 常见问题速查
我把这次实验过程中遇到的典型问题整理成了表格,方便你快速定位。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 串口无输出 | USB线不支持数据传输、串口参数错误、未供电 | 换数据线,确认 115200 8N1,检查电源指示灯 |
| SD卡无法启动 | 镜像损坏、卡速太慢、分区未写对 | 重新烧录,换知名品牌卡,用conv=fsync写完再拔 |
| 程序提示 Illegal instruction | 用了普通编译器生成的非 CHERI 指令 | 使用 CHERI SDK 的 clang,target 设为 riscv64cheri |
| 程序启动即 protection fault | 指针被强行用整数转换伪造 | 不要用(uintptr_t)到处转,保持指针来自合法操作 |
| cross compile 找不到头文件 | 缺少 sysroot | 编译时用--sysroot指向 SDK 里对应的 purecap sysroot |
| 在 cap_enter 之后 open 还能成功 | Capsicum 编译参数或内核配置问题 | 确认 CheriBSD 内核包含 capsicum 支持,检查错误码 |
6.2 调试能力:利用好 dmesg 和 core dump
在 CHERI 环境下,程序崩溃时得到的日志比传统环境要“啰嗦”得多。这其实是好事。遇到 protection fault 时,先看dmesg末尾的内核消息,里面通常会包含出错的虚拟地址、CPU 的 capability 状态,甚至能看出来是在哪条指令触发检查。如果你编译时加了-g,还可以用 CheriBSD 的调试工具把 core dump 的 capability 信息解析出来,精确定位到源码行。我第一次跑越界实验时,就是靠 dmesg 里的报告确认了触发点在循环内第 9 次迭代左右。
6.3 新手最容易犯的三个错误
第一个错误:把 CHERI 和普通的 64 位指针混为一谈。在纯 capability ABI 下,指针不是一个整数,不能随意按整数运算。很多传统代码里(uintptr_t)p和(char *)addr的来回转换,到这里都要重新设计。
第二个错误:忽略权限位。capability 不仅有边界,还有权限集。比如从共享内存映射得到的 capability,可能没有执行权限;如果你试图用它调函数,就会触发权限错误。你在设计数据缓冲区时,应该最少化权限,而不是拷贝一份“万能指针”。
第三个错误:没搞清楚静态链接和动态链接的差别。CHERI 的纯 capability 环境里,动态库的加载和重定位涉及很多 capability 操作,如果工具链版本和板子上的 CheriBSD 版本不一致,很容易出现运行时找不到库或符号的问题。我建议初学者一律用-static,先把功能跑通,再回去研究动态链接的细节。
7. 还能怎么继续玩
这块板子后续值得玩的方向很多。一个方向是跑网络栈测试,看看在真实 DMA 和网卡驱动下,CHERI 是否真的能把驱动里的内存安全问题也拦住。另一个方向是研究 CHERI 和编译器的配合,比如在 CHERI C/C++ 里开启更强健壮性模式,让指针的 bounds 尽量贴近对象的实际大小,从而把“绕一圈访问对象内越界”也拦下来。还可以深入研究 CHERI 在嵌入式场景里的变体,比如 CHERIoT,它把 capability 模型带入更小的 MCU。
对我来说,这次实验最有价值的地方,不是记住了几个命令,而是彻底改变了我对“指针安全”的理解。以前我总觉得内存安全是软件工程问题,要靠程序员自觉加 sanitizer,靠代码审查;但看了 Sonata 上的表现之后,我意识到硬件可以在指令级别把很多危险操作变得根本不可能完成。你不需要信任每个程序员都记得检查边界,因为 CPU 会在越界的那一瞬间替你把门关上。
如果让我给想入门的人一句实在的建议:先别急着买板子,先在 QEMU 模拟器上跑一遍 CheriBSD 的 hello world,再决定要不要上 Sonata。因为工具链和交叉编译的熟悉程度,决定了你拿到真板之后是“立刻开工”还是“一脸茫然”。一旦流程摸清了,Sonata 带来的那种实打实跑在真实硬件上的感觉,绝对是模拟器给不了的。