优化调用约定以实现内存安全:深入探讨 Fil-C
系统编程中的内存安全通常伴随着高昂的性能代价。对于表现出对抗性行为的程序——例如将函数指针强制转换为错误的签名,或误用 va_list——确保安全性通常需要详尽的运行时检查。Fil-C 是 Filip Pizlo 的一个项目,旨在通过实现一种调用约定来解决这个问题,该约定可以在不牺牲常见情况效率的情况下,通过 panic 或赋予其安全行为来捕获类型违规。
为了实现这一点,Fil-C 对函数调用采用了分层方法:一种作为后备方案的、默认安全的通用约定,以及一系列激进的优化,允许编译器在能够证明调用是安全的情况下绕过检查。
通用调用约定:安全基准
在优化之前,Fil-C 定义了一种通用调用约定,无论函数如何被调用,它都能保证安全性。该过程非常严谨:
- 解析:直接调用被降低为“获取器调用”(getter calls),将符号名称解析为 flight 指针(能力指针和整数值的元组)。
- 验证:系统验证能力是否不为空,是否专门是函数能力,并且指针的整数值是否与能力的可调用值匹配。
- 缓冲:参数被对齐到 8 字节,并分配两个线程局部(thread-local)的调用约定(CC)缓冲区——一个用于负载,一个用于能力。
- 传输:控制权转移到被调用者,被调用者在堆上分配
byref参数,并将参数从 CC 缓冲区复制到局部数据流中。 - 返回:返回过程镜像了参数传递,使用 CC 缓冲区将结果传回调用者。
虽然这种方式很稳健,但效率低下。它完全避免了寄存器,需要不断访问线程局部缓冲区,并且每次调用都需要多层间接性。
通过算术签名编码实现寄存器优化
为了消除 CC 缓冲区的开销,Fil-C 引入了一种基于寄存器的调用约定。这里核心的创新在于使用算术编码将函数签名表示为 64 位整数。
算术编码的工作原理
Fil-C 将签名(最多 16 个参数和 2 个返回值)编码为单个 int64。通过为类型分配数值(例如,int = 0,double = 2,pointer = 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),并使用特定的命名约定(pizlonatedFIP 与 pizlonatedFI)来区分实现与别名。
对于 C++ 内联函数——它们通常是 COMDAT 组中的弱定义——链接器可能会丢弃调用者试图引用的实现。Fil-C 通过以下方式解决此问题:
- 修改 LLVM 以允许承认局部定义的 COMDAT 符号可能为 NULL。
- 为这些符号的直接调用发出 NULL 检查。
这确保了如果一个函数被 COMDAT 解析时丢弃,错误会在链接时被捕获,而不是导致运行时崩溃。
性能收益总结
通过从通用的基于缓冲区的方案转向直接调用的基于寄存器的方案,Fil-C 移除了函数调用常见情况下的几乎所有开销。转换过程如下:
| 特性 | 通用约定 | 优化后的约定 |
|---|---|---|
| 参数传递 | 线程局部缓冲区 | CPU 寄存器 |
| 解析 | Getter call | 直接跳转到修饰后的符号 |
| 安全检查 | 完整的能力与大小检查 | 单个签名匹配或 NULL 检查 |
| 返回值 | 基于缓冲区 | CPU 寄存器 |
结合起来,这些优化在保持严格内存安全保证的同时,提供了显著的性能提升,证明了高层级的安全性并不一定需要高层级的性能惩罚。