优化调用约定以实现内存安全:深入探讨 Fil-C

系统编程中的内存安全通常伴随着高昂的性能代价。对于表现出对抗性行为的程序——例如将函数指针强制转换为错误的签名,或误用 va_list——确保安全性通常需要详尽的运行时检查。Fil-C 是 Filip Pizlo 的一个项目,旨在通过实现一种调用约定来解决这个问题,该约定可以在不牺牲常见情况效率的情况下,通过 panic 或赋予其安全行为来捕获类型违规。

为了实现这一点,Fil-C 对函数调用采用了分层方法:一种作为后备方案的、默认安全的通用约定,以及一系列激进的优化,允许编译器在能够证明调用是安全的情况下绕过检查。

通用调用约定:安全基准

在优化之前,Fil-C 定义了一种通用调用约定,无论函数如何被调用,它都能保证安全性。该过程非常严谨:

  1. 解析:直接调用被降低为“获取器调用”(getter calls),将符号名称解析为 flight 指针(能力指针和整数值的元组)。
  2. 验证:系统验证能力是否不为空,是否专门是函数能力,并且指针的整数值是否与能力的可调用值匹配。
  3. 缓冲:参数被对齐到 8 字节,并分配两个线程局部(thread-local)的调用约定(CC)缓冲区——一个用于负载,一个用于能力。
  4. 传输:控制权转移到被调用者,被调用者在堆上分配 byref 参数,并将参数从 CC 缓冲区复制到局部数据流中。
  5. 返回:返回过程镜像了参数传递,使用 CC 缓冲区将结果传回调用者。

虽然这种方式很稳健,但效率低下。它完全避免了寄存器,需要不断访问线程局部缓冲区,并且每次调用都需要多层间接性。

通过算术签名编码实现寄存器优化

为了消除 CC 缓冲区的开销,Fil-C 引入了一种基于寄存器的调用约定。这里核心的创新在于使用算术编码将函数签名表示为 64 位整数。

算术编码的工作原理

Fil-C 将签名(最多 16 个参数和 2 个返回值)编码为单个 int64。通过为类型分配数值(例如,int = 0double = 2pointer = 7),它创建了签名的完美哈希。例如,签名 char* (*)(int, char*, double) 被编码为 60125

快速路径与 Thunks

Fil-C 中的每个函数对象都包含一个 signature 字段和两个入口点:一个 fast_entrypoint(原生寄存器方式)和一个 generic_entrypoint(基于缓冲区方式)。

当进行调用时,调用者会检查被调用者的签名是否与预期的编码匹配。如果匹配,调用者直接跳转到 fast_entrypoint,通过寄存器传递参数。如果签名不同,系统会采用一对 thunks

  • Caller Entrypoint Thunk:将基于寄存器的调用转换为通用基于缓冲区的约定。
  • Callee Entrypoint Thunk:将通用基于缓冲区的调用转换为基于寄存器的调用。

这些 thunks 在 LLVM IR 中作为 linkonce_odr 生成,确保链接器在不同模块间只保留一份副本。这种机制允许 Fil-C 在保持安全性的同时(通过通用路径),在 PizBench9019 基准测试中实现了 >1% 的加速。

消除直接调用者解析

即使使用寄存器传递,直接调用仍然需要 getter call 和能力检查。Fil-C 通过利用 ELF 符号修饰(mangling)进一步优化了这一点。

签名修饰的实现

编译器不再调用 getter,而是为实现本身导出一个 ELF 符号,并进行修饰以包含签名(例如,pizlonatedFI60125_foo)。如果调用者和被调用者对签名达成一致,调用就会变成对实现的直接跳转,从而完全绕过 getter、能力检查和签名检查。

使用弱符号处理边缘情况

这种优化在 ELF 加载和 C++ 内联函数方面引入了复杂性。为了防止 thunk 调用自身的无限循环,Fil-C 对调用点 thunks 使用了隐藏可见性(hidden visibility),并使用特定的命名约定(pizlonatedFIPpizlonatedFI)来区分实现与别名。

对于 C++ 内联函数——它们通常是 COMDAT 组中的弱定义——链接器可能会丢弃调用者试图引用的实现。Fil-C 通过以下方式解决此问题:

  1. 修改 LLVM 以允许承认局部定义的 COMDAT 符号可能为 NULL。
  2. 为这些符号的直接调用发出 NULL 检查。

这确保了如果一个函数被 COMDAT 解析时丢弃,错误会在链接时被捕获,而不是导致运行时崩溃。

性能收益总结

通过从通用的基于缓冲区的方案转向直接调用的基于寄存器的方案,Fil-C 移除了函数调用常见情况下的几乎所有开销。转换过程如下:

特性 通用约定 优化后的约定
参数传递 线程局部缓冲区 CPU 寄存器
解析 Getter call 直接跳转到修饰后的符号
安全检查 完整的能力与大小检查 单个签名匹配或 NULL 检查
返回值 基于缓冲区 CPU 寄存器

结合起来,这些优化在保持严格内存安全保证的同时,提供了显著的性能提升,证明了高层级的安全性并不一定需要高层级的性能惩罚。

Sources