内存安全的内联汇编
摘要
Fil-C 引入了内存安全的内联汇编,确保程序员错误导致 panic 或 trap,而不是错误编译。
暂无内容
查看缓存全文
缓存时间: 2026/06/22 04:30
# 内存安全的内联汇编
来源:https://fil-c.org/inlineasm
**注意:这是一个预发布特性。Fil-C 0.679 版本(https://github.com/pizlonator/fil-c/releases/tag/v0.679)未包含此特性。要测试此特性,您需要从源码构建(https://github.com/pizlonator/fil-c/)。**
GCC 和 clang 都支持极其强大的内联汇编语法。例如:
```c
unsigned rotate(unsigned x, unsigned char c) {
asm("roll %1, %0" : "+r"(x) : "c"(c) : "cc");
return x;
}
```
这指示编译器基于 `roll %1, %0` 模板生成汇编代码,其中 `%1` 被替换为 `%cl`,`%0` 被替换为保存 `x` 的寄存器,并且 `c` 在 `roll` 指令之前被移入 `%ecx` 寄存器。此外,编译器被告知该指令会改变 `x` 的值以及控制标志位的值。
这看起来绝对不可能安全!如果程序员犯了错误,比如遗漏了 `"\+r"` 中的 `+`,或者忘记了 `"cc"` 破坏说明,那会怎样?在 Yolo-C 中,如果你犯了这样的错误,编译器会在这些情况下愉快地错误编译你的代码。然而,**Fil-C 支持这种内联汇编语法**,而且**完全安全!** 本文档解释了为什么 Fil-C 要支持内联汇编,然后详细说明了如何在保持程序员意图(你仍然能得到你要求的汇编模板)和完全内存安全(如果你做错了什么,最坏情况下会触发 panic 或非法指令陷阱)的同时实现这种支持。
## 为什么使用内联汇编?
在审查人们的 C 和 C++ 代码(https://fil-c.org/programs_that_work.html)时,我发现使用内联汇编的原因如下,其中 1 最为常见:
1. **空白内联汇编以防止编译器分析。** 这包括 `asm volatile("" : : : "memory")`,这是一种老派的写法,相当于 `atomic_signal_fence(memory_order_seq_cst)`。它能工作是因为我们告诉编译器内联汇编破坏了所有内存,这迫使编译器序列化内存访问,就像信号栅栏(signal fence)所做的那样。与编译器的约定很明确:编译器必须精确地生成我们要求的汇编代码(这里是空白),**而不会怀疑我们关于破坏说明的声明**。也就是说,编译器不能因为汇编是空白的就推断不可能有内存破坏。我们说了内存破坏,所以编译器就按那样处理。
类似地,人们也会做 `asm("" : "+r"(x))` 这样的事情。这意味着:汇编可能读取然后写入 `x`。汇编是空白的,所以除了迫使编译器假设在汇编执行后对 `x` 的值一无所知之外,没有其他开销。这种数据流栅栏对于编写常量时间加密代码非常有用。
**Fil-C 长期以来一直支持空白内联汇编**,因为它显然是安全的。Fil-C 甚至支持指针上的 `"\+r"` 约束,在这种情况下,intval 和 lower(https://fil-c.org/invisicaps.html)都在 LLVM IR 级别通过它们自己的类似 `"\+r"` 的约束进行传递。
2. **`cpuid` 和 `xgetbv`。** 这两个指令的内联汇编片段最常出现在随后会使用 SIMD 内联函数的代码中。我认为这是因为 `cpuid.h` 中的 `__get_cpuid` API 使用起来令人困惑,而且据我所知,在 GCC 或 clang 中都不能正常工作。因此,像 zstd、simdutf、simdjson 以及其他使用 SIMD 的程序往往通过使用调用 `cpuid` 的内联汇编来识别 CPU 特性。它们也经常使用内联汇编来调用 `xgetbv`。在 Fil-C 中,`__get_cpuid` 已修复,因此你可以使用它,并且提供了 `zxgetbv` 作为内联函数。然而,更好的方法是支持那些内联汇编片段,而不需要人们修改他们的代码!而且只要代码指定了正确的破坏说明和约束,调用 `cpuid` 和 `xgetbv` 是**没有任何不安全**的。
3. **加密代码中对秘密进行算术运算。** 一个很好的例子是 OpenSSH 的 sntrup761(https://github.com/mfriedl/openssh/blob/8933369b33c17b5f02479503d0a92d87bc3a574b/sntrup761.c)实现,它将关键算术运算包装在内联汇编中,以确保得到完全正确的指令,而不是一些可能因输入不同而执行时间变化的指令。请注意,这类代码通常有后备方案,试图让编译器在即使不支持内联汇编的情况下也能发出常量时间代码,但这些后备方案不太可能经过严格验证,并且通常依赖“优化阻塞”惯用法,这会损害性能,而且可能被足够聪明的编译器规避。因此,支持这类内联汇编片段是最安全的。幸运的是,只要约束和破坏说明正确,这些片段也是完全安全的。
4. **原子操作。** 编译器长期以来一直支持原子指令的内联函数。编译器也有很长一段错误实现这些内联函数的历史!最近,clang 在 ARM64 上将 CAS 降低为 LL/SC 时出现了 bug。因此,认真的无锁程序员至少在某些时候会使用内联汇编来编写原子指令,比如当他们遇到错误编译时,降级到汇编是修复 bug 的唯一途径。支持内联汇编中的原子操作需要允许访问内存的内联汇编,这意味着需要以某种方式推断 Fil-C 的边界检查应该做什么。
**目前,访问内存的内联汇编不在支持范围内。** 然而,内存安全的内联汇编支持栅栏指令(`lfence`、`sfence`、`mfence` 和 `serialize`)。
5. **系统调用。** 目前,Fil-C 中内联汇编不支持系统调用,这没问题,因为在 libc 实现的核心之外,使用内联汇编进行系统调用并非必要。Fil-C 已经移植了 musl 和 glibc,在这两种情况下,用于系统调用的内联汇编都被替换为对 Fil-C 提供的 `pizlonated_syscalls.h` API 的调用。不过,我可以想象未来会增加对内联汇编执行系统调用的支持,以便更容易地将新的 libc 移植到 Fil-C。
6. **x87 `long double` 函数。** 如果你在 x86 上使用 `long double`,那么你就在使用 x87 80 位浮点运算。如果你想访问 x87 FPU 对各种数学函数的实现,那么最好的方法通常是降级到内联汇编。这是完全安全的,只要内联汇编没有压入或弹出 x87 栈,并且约束正确说明了哪些 x87 栈寄存器被破坏了。
人们可能出于其他目的使用内联汇编,但以上列表是我在调查 Linux 用户空间程序时所看到的全部内容。
总结一下:
- 内联汇编仍然有许多合法用途。
- 内联汇编在 C 和 C++ 库中被广泛使用。你可能在阅读这篇文章的同时正在使用多个这样的库,这些库中的内联汇编处于关键路径上。
- 大部分内联汇编是平凡安全的:它不访问内存,没有控制流,并且使用的指令没有其他隐蔽的副作用。
继续阅读以了解世界上第一个内存安全内联汇编实现的细节!
## 安全地支持内联汇编
当 Fil-C 编译器(https://fil-c.org/compiler.html)的安全性插桩 pass(称为 `FilPizlonator`)运行时,内联汇编在 LLVM IR 中作为一对字符串存在:
- 汇编字符串,几乎与 C 源代码中的完全相同,只是替换了一些字符。例如,`roll` 示例变成了 `roll $1, $0`。
- 约束字符串。这使用 LLVM 特定的语法来表达约束和破坏说明。对于 `roll` 示例,这是 `=r,\{cx\},0,~\{cc\},~\{dirflag\},~\{fpsr\},~\{flags\}`。
因此,我们可以通过以下方式验证内联汇编表达式是否安全:
- 解析并分析汇编。如果它包含内存访问、控制流或任何我们无法识别的内容,我们就拒绝它。
- 解析并分析约束。如果约束做了我们无法识别或不支持的事情,就拒绝。
- 确保汇编的效果完全被约束所捕获。例如,如果汇编指令修改了一个寄存器,那么约束必须捕获该寄存器突变。如果任何指令设置了某些 CPU 标志,那么这些标志必须被列为破坏说明。
在 AI 出现之前,为 x86_64 汇编编写解析器将是一项非常烦人的任务,以至于我可能永远不会实现除了平凡类型(汇编为空)之外的内存安全内联汇编的支持。但现在,实现这样的特性就像写一个好的提示词一样简单!
**下一节是我最初的提示词**,我用它来开始这个特性的工作。我将其输入到我自己的私有 agent harness(称为 T800)中,该 harness 运行在 Kimi K2.7-code 上。
### 初始 Agent 提示词
让我们在 Fil-C 中增加对安全、无害的内联汇编的更多支持!
请阅读 T800.txt(https://github.com/pizlonator/fil-c/blob/041d1500b8d80ff753127ede1be6d0687a2d13ef/T800.txt)、README.md(https://github.com/pizlonator/fil-c/blob/1aa7e99e92667f007872912846d12924415b30be/README.md)和 https://fil-c.org/howto 以了解我们正在做的事情的背景。
Fil-C 目前拒绝所有内联汇编,*除了*像这样的平凡安全的东西:
```c
asm volatile ("" : : : "memory")
```
甚至:
```c
asm ("" : "+r"(x))
```
基本上,如果汇编字符串为空,Fil-C 会接受内联汇编,并尽力处理内联汇编片段中变量被传递的情况。这类情况非常常见,因为它允许程序员对编译器隐藏数据流以抑制优化,这对于常量时间加密等事情可能很重要。
让我们更进一步,支持汇编片段不为空但仍然无害的情况!
以下是应该可以工作的例子:
```c
__asm__ ("sarw $15,%0" : "+r"(crypto_int16_x) : : "cc");
```
或者:
```c
asm volatile("cpuid\n\t" : "+a"(a), "=b"(b), "+c"(c), "=d"(d));
```
或者:
```c
asm volatile("xgetbv\n\t" : "=a" (xcr0_lo), "=d" (xcr0_hi) : "c" (0));
```
这些例子是安全的,因为:
- `sarw`、`cpuid` 和 `xgetbv` 除了设置寄存器外没有其他有意义的副作用。
- 这些指令的操作数没有内存效果。
- 内联汇编中没有显式的内存操作数。
- 在 LLVM IR 级别,像 `"\+r"` 这样的约束涉及将 `crypto_int16_x` 变量作为数据流通过汇编调用传递,这不会变成内存访问,除非变量被溢出(这没问题——溢出在 Fil-C 中是完全合法的,并且溢出发生在栈的某个 Fil-C 无法获取指针的部分)。
- 受这些指令影响的寄存器在 `asm` 中被枚举。
- 在 `sarw` 的例子中,我们让编译器选择寄存器。
- 在其他两个例子中,`asm` 修饰符正确列出了该指令破坏的所有寄存器的破坏说明。
- 没有从内联汇编中流出控制流。例如,没有调用。
因此,我们完全知道内联汇编做了什么。
注意,这三个例子在 LLVM IR 中看起来像这样。
`sarw` 的例子是:
```
%0 = call i32 asm "sarw $$15,$0", "=r,0,~{cc},~{dirflag},~{fpsr},~{flags}"(i32 %x) #3
```
`cpuid` 的例子是:
```
%0 = call { i32, i32, i32, i32 } asm sideeffect "cpuid\0A\09", "={ax},={bx},={cx},={dx},0,2,~{dirflag},~{fpsr},~{flags}"(i32 undef, i32 undef) #5
```
`xgetbv` 的例子是:
```
%0 = call { i32, i32 } asm sideeffect "xgetbv\0A\09", "={ax},={dx},{cx},~{dirflag},~{fpsr},~{flags}"(i32 0) #5
```
能够支持任何符合这些标准的内联汇编将是很棒的。要做到这一点,我们需要将以下内容集成到 `llvm/lib/Transforms/Instrumentation/FilPizlonator.cpp`(https://github.com/pizlonator/fil-c/blob/deluge/llvm/lib/Transforms/Instrumentation/FilPizlonator.cpp)的 `handleInlineAsm` 函数中:
- 一个 x86_64 AT&T 语法解析器,针对 LLVM IR 级别可见的汇编语法进行了增强。
- 注意,这包括处理像 `sarb`、`sarw`、`sarl` 和 `sarq` 这样的指令,它们都是相同的指令但操作数宽度不同。以及 `sar`,其操作数宽度需要从操作数推断。
- 对汇编约束解析器的改进。
- 一个可接受指令的数据库(那些除了寄存器之外没有副作用的指令)——对于这些指令中那些破坏特定寄存器但没有显式命名这些寄存器的情况,数据库需要知道这些寄存器是什么。例如,它必须知道 `cpuid` 破坏了 ax/bx/cx/dx。
- 这应该包括跟踪指令是否破坏了 `cc`,如果是,则确保汇编约束也列出了 `cc` 被破坏。
- 全面的错误检查,拒绝:
- 不在白名单中的指令。
- 没有考虑破坏说明的汇编约束(例如,如果使用 `cpuid` 时 `={ax}` 不是约束的一部分)。
- 包含指针并导致内存访问发生的汇编约束。
- 对于这种情况,我们最终可以处理,通过发出 Fil-C 检查!但我们现在不应该实现这个。
确保如果你拒绝内联汇编,那么 `handleInlineAsm` 返回一个合理的 `Reason`,解释原因。
你应该拒绝不使用 AT&T 方言的 `InlineAsm`。
注意,你的汇编解析器甚至不需要知道如何解析任何不在白名单中的汇编。我认为这意味着你甚至不需要实现内存操作数语法或任何不在白名单中的指令助记符的解析!
现在,添加对以下内容的支持:
- sar, shr, and, shl, xor, mov, test, cmp, bsf(任何宽度或隐式)
- cmov(注意它有许多后缀,取决于条件是什么)
- cpuid
- xgetbv
关于应该可以工作的汇编片段的例子,请看 `projects/openssh-10.3p1/sntrup761.c`(https://github.com/pizlonator/fil-c/blob/deluge/projects/openssh-10.3p1/sntrup761.c)。注意,该文件当前有一个 `#undef __GNUC__` 来阻止使用内联汇编。还要注意,该文件有用 C 语言实现的所有内联汇编的替代版本。
因此,我建议创建一个 filc/tests 测试,其中包含所有这些内联汇编片段,并且针对各种输入,将它们与对应的 C 语言实现进行测试。同时,确保为每个白名单指令创建大量测试,检查我们是否拒绝了内联汇编的不安全用法(内存操作数等)。还要为那些明显不安全或尚未支持的指令创建测试,以确保我们拒绝了它们。
注意,拒绝是在运行时的,因此测试的清单应该说明结果是 `failure`,输出包含 `filc safety error`。
可能有一些现有的测试断言了内联汇编的这种失败,而你将使这些测试成功,因为这些测试可能使用了我在你白名单中要求的某个指令。在这种情况下,只需在它们的清单中修复这些测试的结果期望。
如果你对任何 x86 指令不确定,请记住有 https://www.felixcloutier.com/x86/ 这个网站。
我建议将此任务分解为由单独的 subagent 处理的步骤:
1. x86_64 解析器。实现此步骤后,clang 应该仍然可以编译(使用 `./build_clang.sh` 测试),但可能无法通过 `filc/run-tests`,并且 `./build_base.sh` 可能会失败,因为我们可能会宽松地或不健全地通过汇编。你应该尝试通过 `build/bin/clang -c testfile.c` 手动输入一些代码,看看解析器至少不会崩溃,你可以添加 `llvm::errs()` 打印语句来输出解析器看到了什么以及是否成功(但在测试后禁用这些打印语句,或者将它们放在 `if (verbose)` 之后)。
相似文章
Fil-C中内存安全的上下文切换(longjmp, setjmp)
Fil-C引入了setjmp/longjmp和ucontext API的内存安全实现,防止悬空栈损坏,并在误用时触发panic。
Fil-C 优化调用约定
Fil-C 优化调用约定确保 C 程序即使在恶意滥用情况下也能保持内存安全性,同时通过在常见情况下省略安全检查来保持效率。它解释了通过 panic 或定义明确的行为来处理类型违规的通用优化和寄存器传递优化。
Fil-C: Garbage In, Memory Safety Out
Fil-C 是一个完全内存安全的 C/C++ 实现,通过 LLVM IR 阶段的不可见能力(invisicaps)将指针值与边界信息捆绑,实现高兼容性,性能损失约 4 倍。
InvisiCaps:Fil-C 能力模型
描述了 Fil-C 的 InvisiCaps 能力模型,该模型通过跟踪指针权限来确保 C 和 C++ 的内存安全,同时追求极致的兼容性和合理的性能。
实用内存安全
文章讨论了最近通过 Fil-C 提出的 Zig 内存安全方案,比较了 Fil-C 与 Rust 对内存安全的定义,并主张内存安全是一个具有实用定义的频谱,同时使用了一个 Fil-C 仍然不安全的具体示例。