C 语言里有一个特性,可以说是「不安全中的不安全」——内联汇编(inline assembly)。它允许开发者在 C 代码中直接嵌入 CPU 指令,绕过编译器的所有安全检查,直接读写寄存器、标志位、甚至内存。一个错误的寄存器约束或者遗漏的 clobber 声明,就可能导致编译器生成错误的代码,造成内存损坏、数据泄露。
Filipp Pizlo 的 Fil-C 项目最近做到了一个之前没人做到的事:实现了内存安全的内联汇编。更让人意外的是,这个实现的大部分代码是由 AI agent 循环自动完成的——它在作者打游戏的时候默默实现了数百条 x86_64 指令的支持。
Fil-C 的安全模型
Fil-C 是一个内存安全的 C/C++ 编译器,基于 LLVM/Clang。它的安全模型和 Rust 的借用检查器不同——不需要开发者改变已有的代码风格,而是通过编译时的指针能力验证和运行时的边界检查来保证安全。
具体的实现方式是在 LLVM IR 层面插入检查:每次指针解引用前,Fil-C 验证指针的基址、偏移和访问大小是否在合法范围内。这类似于 CHERI 架构的能力模型,但是纯软件实现——不需要特殊的 CPU 硬件支持。等效于给每个指针配了一把「钥匙」,这把钥匙规定了它能访问的内存区域。任何超出范围的访问都会被运行时捕获。
Fil-C 的承诺是:只要代码能通过 Fil-C 的编译,就保证没有缓冲区溢出、释放后使用、双重释放、空指针解引用这类内存安全漏洞。这个承诺覆盖了 C 标准库、系统调用、信号处理等传统上认为无法检查的领域。但在 0.6 版本之前,有一个盲区始终没解决——内联汇编。
为什么需要安全的内联汇编
你可能会想:「既然要内存安全,把内联汇编禁掉不就行了?」作者在审查了大量 C/C++ 代码后,发现这个想法太天真了。内联汇编有几个无法替代的真实用途:
CPUID 和 XGETBV。 这是最常见的用法。任何用到 SIMD 指令(AVX、AVX2、AVX-512)的库都需要先查询 CPU 是否支持这些特性。zstd、simdutf、simdjson、甚至 OpenSSL 都在启动时通过内联汇编调用 cpuid 指令来检测 CPU 特性。标准库的 __get_cpuid API 在 GCC 和 Clang 中都有 bug,开发者只能自己写汇编。
密码学中的常数时间运算。 OpenSSH 的 sntrup761 大量使用内联汇编来确保关键的算术操作生成确定的机器指令序列,不会被编译器优化成不同执行时间的变体。这对于抵抗时序攻击是生死攸关的——一个分支预测失误就可能泄露私钥。
原子操作。 编译器在 ARM64 上实现 CAS(compare-and-swap)时曾出现过 bug,导致多线程程序在特定条件下产生数据竞争。认真的无锁编程者有时候只能用内联汇编来确保生成正确的指令序列。
优化屏障。 asm volatile("" : : : "memory") 和 asm("" : "+r"(x)) 这类空汇编是 C 语言中唯一可靠的编译屏障,用于阻止编译器重排内存访问。在常量时间密码学、驱动开发、嵌入式系统中广泛使用。
x87 长双精度浮点。 在 x86 上做 80 位浮点运算时,内联汇编是调用 x87 FPU 原生指令的最可靠方式。
这些用途的特点是:指令本身没有内存副作用,不执行控制流跳转,不调用函数。理论上它们是「安全的」——只要约束和 clobber 声明正确。
安全检测的三层验证
Fil-C 的安全检测在 LLVM 的 FilPizlonator pass 中完成。这个 pass 在编译过程的 IR 优化阶段运行,此时内联汇编已经被翻译成 LLVM IR 的 call asm 指令。核心函数 validateSafeInlineAsm 逐层检查:
第一层:解析汇编字符串。 需要一个 x86_64 AT&T 语法解析器。考虑这个实际的例子——OpenSSH sntrup761.c 中的内联汇编:
__asm__("sarw $15,%0" : "+r"(crypto_int16_x) : : "cc");
在 LLVM IR 层面被翻译成:
%0 = call i32 asm "sarw $$15,$0", "=r,0,~{cc},~{dirflag},~{fpsr},~{flags}"
解析器需要理解 sarw 是算术右移指令,操作数宽度是 word(16 位),%0 是一个寄存器操作数,$$15 是立即数。它还需要验证操作数类型是否正确——sarw 的第二个操作数必须是寄存器,不能是内存引用。
cpuid 的例子更复杂,因为它隐式使用了四个寄存器:
asm volatile("cpuid\n\t"
: "+a"(a), "=b"(b), "+c"(c), "=d"(d));
LLVM IR 形式:
%0 = call { i32, i32, i32, i32 } asm sideeffect
"cpuid\0A\09",
"={ax},={bx},={cx},={dx},0,2,
~{dirflag},~{fpsr},~{flags}"
这里 ={ax} 表示输出到 %eax 寄存器,0 表示输入 0 和输出 0 共享寄存器,2 表示输入 2 和输出 2 共享寄存器。解析器必须验证所有被 cpuid 隐式修改的寄存器都出现在了约束字符串中——如果开发者只写 "=a"(a) 而漏掉了 "=b"(b)、"=c"(c)、"=d"(d),检测就会失败并生成运行时 panic。
第二层:解析 LLVM IR 约束字符串。 约束字符串的格式比大多数人想象的要复杂。一个实际的例子:
=1,{cx},0,~{cc},~{dirflag},~{fpsr},~{flags}
这里:
- =1 表示输出使用输入 1 所在的寄存器
- {cx} 表示输入限制在 %ecx/%rcx
- 0 表示输入 0 和输出 0 是同一个值
- ~{cc} 表示 clobber 条件码寄存器
- {dirflag}、、~{flags} 是 LLVM 自动附加的 clobber(方向标志、浮点状态、通用标志)
解析器需要理解这些标记,验证:
- 所有输出操作数都有对应的寄存器分配
- 所有被指令隐式修改的寄存器都列入了 clobber 列表
- 没有指针类型的操作数(指针操作意味着内存访问,目前超出安全范围)
第三层:对照允许列表验证。 允许列表包含经过安全审计的指令,每一条都标注了:它影响哪些寄存器、是否修改条件码、合法操作数组合。验证通过的标准是:指令必须在允许列表中,所有被指令修改的寄存器和标志位都已在约束中声明。
如果验证失败——这是 Fil-C 特有的选择——不会报编译错误,而是生成一个运行时 panic。
为什么选择运行时 panic
这是 Fil-C 设计上一个很有意思的决策。大多数安全检查会选择编译时报错,但内联汇编的场景更复杂:
很多库采用条件编译。sntrup761.c 里同时包含了内联汇编实现和 C 语言的 fallback 实现,用 #ifdef 决定用哪个。如果 Fil-C 编译时报错,整个包会编译失败,用户不得不手动修改源码。但如果只是运行时 panic,死代码路径中的内联汇编根本不会被执行,库可以正常使用。
这个「宽松编译、严格运行」的哲学降低了迁移门槛,但也意味着一些安全问题会被推迟到运行时才暴露。Fil-C 的权衡是:对于死代码路径,安全风险为零;对于活代码路径,panic 会在第一次执行时立即触发,不会静默地产生错误结果。
AI Agent 螺旋:自动实现了几百条指令
最让我感兴趣的不是验证逻辑本身,而是实现过程的工程架构。
作者最初的提示词写了一个相对完整的规格说明:描述了对解析器、约束检查、允许列表的需求,给出了 sarw、cpuid、xgetbv 等示例,以及 sntrup761.c 中的实际代码。然后他让私人 agent 框架 T800(跑 Kimi K2.7-code)去实现。
初始 agent 完成了 validateSafeInlineAsm 函数的核心代码,包括一个基本的 x86_64 汇编解析器、约束检查逻辑和测试用例。但这只是开始。接下来作者建立了一个循环(loop):
1. instructions_list.txt ← 列出所有 x86_64 指令
2. 脚本找第一个未处理的指令
3. 子 agent 实现该指令的解析规则 + 测试用例
4. 跳回步骤 2
子 agent 每次实现一条指令,包括将其加入允许列表、编写通过测试(正确用法)和拒绝测试(不安全用法)。完成一条标记一条。作者设置了一个停止文件(instructions_stop)来控制循环的终止条件。
前半个月用 Kimi K2.7-code,后半个月切换到 GLM 5.2。作者对两个模型的评价很有意思:
Kimi K2.7-code 更「多疑」,对同样的指令会要求写更多测试。GLM 5.2 更快、更激进。但最难的底层工作——包括 x87 指令的静态分析和复杂约束处理——是 Kimi 完成的,也许它的多疑在这里反而是优势。
这个循环在作者打《巫师 3》和处理 OpenSSH Fedora 补丁的时候自动运转。几百条 pre-AVX512 的 x86_64 指令被实现并测试完成,包括 x87 浮点、SIMD 位运算、标志管理、fence 指令。
AI 循环开发的工程启示
这个开发模式本身就是一个有意思的话题。传统的「用 AI 写代码」是人坐在终端前,输入 prompt,看生成的代码,修改,再输入。人全程被占着。
T800 的循环模式把人类从循环中解放了——只有子 agent 遇到超出范围的问题时才需要人类介入。大多数时候代理在 autonomously 地执行模板化任务。
这种模式有几个前提条件:
- 任务必须模式固定。每条指令的实现逻辑高度相似——解析 mnemonic、确定操作数类型、标注隐式寄存器、写测试。
- 验证标准必须明确。allowlist reject 的条件简单:不安全 = 拒绝,安全且未实现 = 继续。没有模糊地带。
- 失败必须可控。子 agent 实现错了不会导致灾难性后果——最多是一条指令的验证规则写错,回头改就行。
我觉得未来会有更多这种模式的工程实践——不是让人去适应 AI 的速度,而是让 AI 去填满人的空闲时间。你白天做架构决策,晚上 AI agent 在后台把决策拆成成百上千个模板化任务逐个落地,第二天早上你 review 结果。
一个开发者的视角
内联汇编是 C 语言最后一块「法外之地」。Rust 的 unsafe 块至少还有明确定义的规则和检查,C 的内联汇编完全是丛林法则——编译器几乎无条件信任开发者写的每一个字符。
Fil-C 的做法比 Rust 更激进:它不是在语法层面限制内联汇编,而是在编译器的中间表示层做静态验证。这意味着现有的 C 代码库不需要改一行源码就能获得安全保障——只要它的内联汇编通过了验证。这个思路对遗留系统的安全改造特别有价值:你不需要重写代码,只需要换一个编译器。
说实话,这个领域的实用意义可能不如 Rust 的全面推广来得直接——毕竟新项目完全可以选 Rust,不需要在 C 的内联汇编上折腾。但对于那些不可能用 Rust 重写的系统——Linux 内核、OpenSSL、zstd——Fil-C 提供了一个渐进式的安全升级路径。换编译器不换代码,这个门槛比重写低得多。
另外一点值得关注的是这个 AI 循环开发的模式。T800 的 loop 本质上实现了一个自动化「实现→验证→提交」的流水线。人只负责定义任务模板和审核结果,中间的所有实现工作由 agent 自动完成。这不是「AI 辅助编程」而是「AI 执行编程」——把开发者从重复劳动中解放出来,去做更高层的架构决策。我觉得未来一年会有更多这种模式的项目出现,特别是那些「任务明确、重复性强、结果可验证」的工程领域。
浙公网安备 33010602011771号