Fil-C 是一个追求 "狂热兼容性" 的内存安全 C/C++ 实现,它通过 InvisiCaps(隐形能力)机制在不改变指针位宽(64 位系统上保持 64 位)的前提下实现完整的内存安全检查。然而,这种安全保证并非没有代价 —— 函数调用作为程序执行的高频操作,其开销直接影响整体性能。本文深入分析 Fil-C 的调用约定优化策略,揭示其如何在保持内存安全语义的同时,通过寄存器直通、签名编码和 ELF 符号技巧实现接近原生 C 的性能。
通用调用约定的性能瓶颈
Fil-C 的通用调用约定采用基于线程本地 CC(Calling Convention)缓冲区的方案,这一设计源于其内存安全模型的根本要求。每个函数调用需要经历以下步骤:首先通过 getter 调用解析符号获取函数能力指针,验证该指针确实指向一个函数对象;然后计算参数缓冲区大小(按 8 字节对齐),将参数复制到 CC 缓冲区(payload 和 capability 分离存储);控制权转移后,被调函数从 CC 缓冲区提取参数;返回时同样需要将返回值写入 CC 缓冲区,由调用方提取。
这种设计的开销是显著的:参数和返回值无法使用寄存器传递,必须通过内存中转;每次调用都需要进行能力检查;直接调用也需要通过 getter 解析符号。对于热点代码路径,这些额外操作会累积成可观的性能损失。
寄存器调用约定:算术编码与 Thunk 机制
Fil-C 的优化核心在于利用函数对象的丰富元数据。每个函数对象包含三个关键字段:fast_entrypoint 指向使用寄存器传参的优化入口;generic_entrypoint 指向通用 CC 缓冲区入口;signature 是一个 64 位算术编码的函数签名哈希。
签名编码支持最多 16 个参数和 2 个返回值(覆盖 C/C++ 中返回小结构体的场景),参数类型包括整数(最大 64 位)、浮点(float/double/long double)、向量(128/256/512 位)和指针。编码采用变长序列编码:空序列为 0,单类型 T 编码为 1+T,双类型编码为 1+11+T1+11×T2,依此类推。这种编码方式使得 char* (*)(int, char*, double) 的签名为 60125。
调用点的代码生成逻辑如下:首先检查函数对象的 signature 是否与预期匹配(使用 LIKELY 提示分支预测);若匹配则直接使用 fast_entrypoint,否则回退到本地生成的 thunk。以签名 60125 为例,thunk 的 x86 汇编代码显示其将寄存器中的参数(% rdx、% rcx、% xmm0、% r8)存入线程本地 CC 缓冲区(偏移 0x80、0x88、0x90、0x188),然后调用 generic_entrypoint。
Thunk 机制采用双向设计:调用方 thunk(pizlonated1ET*)将寄存器参数转为 CC 缓冲区格式调用通用入口;被调方 thunk(pizlonated2ET*)则从 CC 缓冲区提取参数并转为寄存器调用快速入口。两者均标记为 linkonce_odr(ELF 弱定义),确保链接时去重。这一优化在 PizBench9019 基准测试中带来超过 1% 的性能提升。
直接调用优化:消除 Getter 与能力检查
寄存器调用约定虽然解决了参数传递效率问题,但直接调用仍需通过 getter 解析符号并验证能力。Fil-C 的进一步优化目标是:在签名匹配的情况下,直接调用实现函数,完全消除 getter 调用和能力检查。
实现方案依赖于 ELF 符号技巧:函数定义时同时导出两个符号 ——pizlonated_foo(getter)和 pizlonatedFIP<signature>_foo(实现)。调用点直接调用签名修饰的实现符号(如 pizlonatedFI60125_foo),并将函数对象参数设为 LLVM undef(对应寄存器不设置)。若函数在当前模块定义且签名匹配,链接器直接解析到实现;若为外部符号或签名不匹配,则链接到本地定义的弱符号 thunk。
该 thunk 执行完整的安全检查序列:调用 getter、验证能力、检查签名、必要时回退到通用调用。关键技巧在于使用 hidden visibility 确保 loader 不参与符号解析,避免动态链接时的无限循环问题。
ELF 复杂性:弱定义、COMDAT 与边界情况
直接调用优化面临 ELF 链接模型的多重挑战。首先是弱定义问题:若目标函数为弱定义,无法创建 "比弱更弱" 的 thunk,否则会导致无限递归。解决方案是将实现导出为 pizlonatedFIP* 符号,仅在函数强定义时创建到 pizlonatedFI* 的强别名。
更复杂的是 C++ inline 函数。这类函数使用 COMDAT 组,要求链接器全有或全无地选择某一模块的实现。Fil-C 需要确保:若模块内定义了匹配的 pizlonatedFIP 符号则直接调用;但必须处理 COMDAT 解析导致目标函数被丢弃的情况。解决方案包括修改 LLVM 的 ValueTracking.cpp 和 ConstantFold.cpp,使其认识到 COMDAT 符号可能为 NULL;同时在调用前插入 NULL 检查,使用不同于调用的重定位类型,确保链接时错误而非运行时崩溃。
跨动态库边界的调用无法完全优化,必须回退到 thunk 路径。这是 hidden visibility 设计的直接后果,但基于库内调用远多于跨库调用的观察,这一权衡是可接受的。
性能评估与工程实践
Fil-C 的调用约定优化采用渐进式策略:通用调用约定保证正确性,寄存器调用约定优化常见路径,直接调用优化消除链接时开销。实测数据显示,寄存器优化带来超过 1% 的基准测试加速,直接调用优化再贡献超过 1% 的额外提升。
对于希望应用类似技术的系统,关键参数包括:签名编码的位宽(64 位)、参数数量上限(16)、返回值上限(2)、CC 缓冲区在 TLS 中的偏移(0x80 起始)、thunk 的弱符号命名约定(pizlonated1ET/pizlonated2ET 前缀)。同时需要注意,使用闭包特性(zcallee、zcallee_closure_data)的函数无法应用直接调用优化,因为必须传递有效的函数对象参数。
结论
Fil-C 的调用约定优化展示了在严格内存安全约束下实现高性能的技术路径。通过将函数签名编码为 64 位整数、使用双向 thunk 处理边界情况、以及精巧的 ELF 符号操作,Fil-C 在保持 "零逃逸舱口" 内存安全保证的同时,使常见情况下的函数调用开销接近原生 C。这一设计为其他安全语言运行时提供了有价值的参考:安全检查不必以牺牲调用性能为代价,关键在于区分常见路径与异常路径,并为后者提供兼容的降级机制。
参考来源
- Fil-C 官方文档:调用约定优化详解 https://fil-c.org/calling_convention.html
- Fil-C InvisiCaps 能力模型 https://fil-c.org/invisicaps.html
- Fil-C 编译器架构 https://fil-c.org/compiler.html
内容声明:本文无广告投放、无付费植入。
如有事实性问题,欢迎发送勘误至 i@hotdrydog.com。