Fil-C 优化调用约定

Hacker News Top 工具

摘要

Fil-C 优化调用约定确保 C 程序即使在恶意滥用情况下也能保持内存安全性,同时通过在常见情况下省略安全检查来保持效率。它解释了通过 panic 或定义明确的行为来处理类型违规的通用优化和寄存器传递优化。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/05/18 18:57

# Fil-C 优化调用约定 来源:https://fil-c.org/calling_convention Fil-C 能够实现内存安全,即使程序行为具有对抗性也是如此。这包括将函数指针转换为错误签名然后调用、在一个模块中以一个签名导出函数然后在另一个模块中以不同签名导入,甚至在一个模块中将符号作为函数导出而在另一个模块中作为数据导入(反之亦然)。传递的参数过少、参数类型错误、误用 `va_list`(包括将其转义)、期望返回过多值——这些都是 Fil-C 调用约定要么以 panic 捕获,要么赋予安全行为的情况。但在常见情况下——例如程序员循规蹈矩时——Fil-C 会为调用生成相当高效的代码。例如,像这样的调用: ``` int x = 42; const char* y = "hello"; int z = foo(x, y); ``` 在一个模块(比如 `caller.c`)中,而 `foo` 定义在另一个模块(比如 `foo.c`)中: ``` int foo(int x, const char* y) { ... /* whatever */ } ``` 在调用点会被编译为,就好像你在 Yolo-C 中使用了优化后的参数寄存器 ABI 进行了以下调用: ``` foo(my_thread, x, y); ``` 其中 `my_thread` 是指向当前 Fil-C 线程的指针,Fil-C 在所有调用中将其作为第一个参数传递。因此,`my_thread`、`x` 和 `y` 将通过寄存器传递。`foo` 的实现不会检查 `x` 是 `int` 且 `y` 是 `const char*`(但如果你使用 `y`,它会检查指针是否在能力范围内,以及该能力是否允许你所进行的访问类型)。返回值也将通过寄存器传递。在这方面,Fil-C 几乎和 Yolo-C 一样高效!然而,如果我们改变 `foo` 以接受额外参数,我们会得到一个 panic。如果我们以任何方式改变签名(比如 `x` 变成指针,`y` 变成 `double`),我们要么会得到一个 panic,要么会得到一个定义良好的按位类型转换。 本文档解释了 Fil-C 如何**在常见调用情况下避免任何安全检查**,同时在调用因违反类型而被误用时,要么 **panic**,要么严格遵循明确定义的 GIMSO 语义(https://fil-c.org/gimso.html)。 首先,解释通用调用约定。所有优化都遵循与通用调用约定相同的语义,当优化在该语义下不合法时,通用调用约定作为回退。其次,描述寄存器调用约定优化。这使得在常见情况下参数和返回值可以通过寄存器传递。最后,描述直接调用优化。这些优化使调用者无需检查被调用者是否同意函数签名。 ## 通用调用约定 *本节与 GIMSO 文档(https://fil-c.org/gimso.html#call)中的调用部分几乎相同,只是结合了如何获取被调用者以及执行调用。* 在通用情况下,调用按如下步骤进行。 1. 解析被调用者。对于间接调用,被调用者是一个我们已经拥有的飞行指针(https://fil-c.org/invisicaps.html#flightptr)(能力指针和指针 intval 的元组),因此这一步在这种情况下是空操作。对于直接调用,被调用者是一个符号名称。ELF 链接器提供内置功能来自动将符号名称解析为函数指针。但是,为了支持 Fil-C 中的内存安全链接和加载,我们需要符号名称解析为飞行指针,这样我们就可以检查指针所指向的对象是否适合我们想要对其进行的操作(调用的下一步是检查我们是否拥有函数能力;对于全局变量访问,我们会检查全局变量是数据能力,并且访问在范围内)。因此,Fil-C 编译器将符号解析降级为 getter 调用(https://fil-c.org/runtime.html#linking)。getter 返回被调用者的飞行指针。 2. 检查被调用者。必须满足以下要求,否则会发生 panic: - 能力不得为空。 - 能力必须是函数能力。 - 指针的 intval 必须与能力的可调用指针值匹配。 3. 通过将每个参数的大小向上舍入到 8 来计算参数缓冲区的大小。此外,遵守参数类型的对齐要求,这可能需要添加填充。注意,`byref` 参数将其值复制到参数缓冲区中,因此用于计算的参数类型是被引用类型,而不是 `ptr`。分配两个该大小的线程本地 CC 缓冲区。这些缓冲区的存活时间仅足以让被调用者检索参数。一个缓冲区用于载荷,另一个用于能力。 4. 将每个参数复制到 CC 缓冲区中。对于 `byref` 参数,将所指向的值复制到缓冲区中。 5. 控制权转移到被调用者的序言,并将调用点地址保存到私有调用栈。存储调用点地址的栈位于 Fil-C 内存之外,无法通过任何能力访问。被调用者被告知参数的大小以及函数能力。传递函数能力对于实现闭包的 `libffi` 很有用,但在其他情况下未使用。 6. 被调用者的序言堆分配(如同使用 `alloca`)任何 `byref` 参数。 7. 将所有参数从 CC 缓冲区中复制出来。对于非 `byref` 参数,将参数复制到本地数据流中。对于 `byref` 参数,将参数复制到步骤 6 中的分配中。 8. 如果被调用者使用了任何参数内省(如 `va_arg` 或 `zargs`),则将 CC 缓冲区复制到新创建的只读堆对象中。此时,CC 缓冲区死亡。实际上,实现可能会重复使用同一个 CC 缓冲区。 9. **被调用者执行。** 如果发生异常抛出,则我们返回到调用点,并带有一个标志指示异常正在飞行中。 10. 当被调用者正常返回时,会执行一个与参数传递几乎相同的过程,只是针对返回值。首先,通过将返回类型的大小向上舍入到 8 来计算返回缓冲区的大小。分配该大小的 CC 缓冲区。它将一直存活到调用点完成检索结果。 11. 将返回值复制到 CC 缓冲区中。 12. 控制权转移回调用点,并带有一个标志指示异常未飞行,以及返回值的大小。 13. 调用点从 CC 缓冲区加载返回值,并将其产生到本地数据流中。如果调用点观察到异常标志被设置,则调用者返回并设置异常标志。 让我们考虑一个间接调用的例子,如: ``` int arg1 = ...; char* arg2 = ...; double arg3 = ...; char* result = function_pointer(arg1, arg2, arg3); ``` 通用调用约定——在我们进行本文档中的任何优化之前——看起来像: ``` check_function_call(function_pointer); /* all of the capability checks */ (int*)(my_thread->cc_inline_buffer + 0) = arg1; (void**)(my_thread->cc_inline_aux_buffer + 0) = NULL; (void**)(my_thread->cc_inline_buffer + 8) = arg2.intval; (void**)(my_thread->cc_inline_aux_buffer + 8) = arg2.lower; (double*)(my_thread->cc_inline_buffer + 16) = arg3; (void**)(my_thread->cc_inline_aux_buffer + 16) = NULL; struct pizlonated_return_value { bool has_exception; size_t return_size; }; struct pizlonated_return_value rv = ((pizlonated_function_type)function_pointer.intval)( my_thread, function_pointer.lower, 24); if (rv.has_exception) goto unwind_handler; if (rv.return_size < 8) goto panic; flight_ptr result; result.intval = *(void**)(my_thread->cc_inline_buffer + 0); result.lower = *(void**)(my_thread->cc_inline_aux_buffer + 0); ``` 这种调用约定在三个主要方面效率低下: 1. 参数和返回值通过线程本地 CC 缓冲区传递,而不是通过寄存器。 2. 必须检查被调用者的能力。 3. 直接调用需要调用 getter(https://fil-c.org/runtime.html#linking)来获取指向被调用者的能力。 接下来的两节将描述在常见情况下消除这些开销的优化。紧接着的一节描述如何在常见情况下通过寄存器传递参数和返回值。再下一节描述如何避免检查被调用者的能力,甚至避免调用 getter。 ## 使用算术编码签名和通用调用 thunk 的寄存器调用约定 Fil-C 函数指针非常丰富: - 用户看到的指针值(*intval*)可以是 Fil-C 实现者希望的任何值,只要保持一致。我们可以让它只是一个指向某种对象基址的指针,而不是实际代码指针,只要 LLVM 的 call 和 invoke 操作码的实现知道如何处理它。 - 所有指针都有一个不可见的(https://fil-c.org/invisicaps.html)*lower* 指针,它指向能力对象的上方。对于函数等特殊对象,*lower* 指针也指向一个内部对象的底部,该对象由 Fil-C 控制,用户不允许编辑(所有读写都被禁止,因为特殊对象的上限恰好设置为等于下限)。 - Fil-C 支持闭包(https://fil-c.org/stdfil.html#zclosure_new),它是携带额外状态的函数指针,调用者可以检索这些状态。对于任何已定义的函数,用户可以创建任意数量的闭包对象,每个对象附加不同的数据。这是支持 `libffi` 闭包而无需使用 JIT 权限所必需的。此功能的存在意味着在调用 Fil-C 函数时,我们已经将指向函数对象的指针(即函数能力)作为参数之一传递给它。 这种能力给了我们很多机会! 第一个优化使函数对象具有以下字段。记住——这些字段不能直接被 Fil-C 程序访问,因此它们可以使用原始指针。 - `fast_entrypoint` — 这是一个原始指针,指向一个使用原生、基于寄存器的调用约定(针对函数定义所使用的任何签名)的函数入口点。该调用约定与 Yolo-C 的不同之处仅在于:前两个参数是线程和函数对象,返回值是一个包含异常发生位的结构体,指针作为 *lower* 和 *intval* 的元组传递。 - `generic_entrypoint` — 这是一个原始指针,指向一个使用基于线程本地 CC 缓冲区的通用调用约定的函数入口点。注意,此入口点将函数对象作为其参数之一。我们将利用这一事实! - `signature` — 一个 64 位**算术编码**的函数签名。可以将其视为签名的完美哈希。如果此值为 0,则表示该函数只有通用入口点(因此 `fast_entrypoint` 将为 NULL)。 - 如果函数对象是闭包,则还有一个字段:`data_ptr`,它是一个用户控制的飞行指针(https://fil-c.org/invisicaps.html#flightptr)(*lower* 和 *intval* 的元组)。如果 `READONLY` 对象标志未设置,我们就知道函数对象是闭包。 我们将如下讨论此优化。首先,调用点做什么。第二,调用者和被调用者发出哪些 thunk 来挽救签名不匹配的情况。最后,签名的算术编码如何工作。 ### 调用点 现在我们考虑指向函数指针的调用,因为直接函数调用需要链接器解析步骤来产生函数指针。我们将在后面的优化中优化掉它。因此,给定一个源级别的调用,像我们之前看到的那样: ``` int arg1 = ...; char* arg2 = ...; double arg3 = ...; char* result = function_pointer(arg1, arg2, arg3); ``` 我们现在生成如下代码: ``` check_function_call(function_pointer); /* all of the capability checks */ filc_function* function_object = (filc_function*)function_pointer.lower; struct typed_return_value { bool has_exception; flight_ptr result; }; struct typed_return_value (*fast_function_pointer)( filc_thread*, filc_function_object*, int, flight_ptr, double); if (LIKELY(function_object->signature == 60125)) fast_function_pointer = function_object->fast_entrypoint; else fast_function_pointer = pizlonated1ET60125; struct typed_return_value rv = fast_function_pointer( my_thread, function_object, arg1, arg2, arg3); if (rv.has_exception) goto unwind; result = rv.result; ``` 让我们深入了解这是如何工作的!函数调用本身使用一种类似原生的调用约定,所有参数都将通过寄存器传递。所有返回值也将通过寄存器传递。与实际原生调用约定的唯一区别是我们有两个额外参数(线程和函数对象)和一个额外返回值(`has_exception`)。在函数调用之前,我们必须检查被调用者是否使用我们期望的调用约定。60125 是 `char* (*)(int, char*, double)` 签名的算术编码。如果匹配,我们直接使用 `fast_entrypoint`。如果不匹配,我们使用本地定义的 `pizlonated1ET60125` thunk。此 thunk 对我们的被调用者一无所知,也不需要知道,因为调用的第二个参数是函数对象。下一节讨论 thunk 如何工作。 请注意,某些调用点会选择使用通用调用约定。在这种情况下,它们将直接调用 `generic_entrypoint`,并且不需要调用者入口点 thunk。如果调用点函数签名无法使用我们的算术编码进行编码,就会发生这种情况。 ### Thunk 在调用约定不匹配的情况下,我们使用一对 thunk 在调用者和被调用者之间进行转换: - 调用者入口点 thunk(如 `pizlonated1ET60125`),它根据快速调用约定接受参数,并使用通用调用约定调用函数对象的 `generic_entrypoint`。 - 被调用者入口点 thunk(会被命名为类似 `pizlonated2ET60125` 的名称),它根据通用调用约定接受参数,并使用快速调用约定调用函数对象的 `fast_entrypoint`。 两个 thunk 都在 LLVM IR 中生成为 `linkonce_odr`,这对应于 ELF 中的弱定义。这意味着如果多个模块定义了相同的调用者或被调用者入口点 thunk,链接器仅基于符号名称选择一个。某些函数将选择仅具有通用入口点。如果它们使用可变参数、可变返回(https://fil-c.org/stdfil.html#zreturn),或者它们的签名无法使用我们的算术编码进行编码,就会发生这种情况。在这种情况下,被调用者不会生成入口点 thunk,并且函数对象的通用入口点将直接指向函数实现。 ### 调用者入口点 Thunk 示例 调用者入口点 thunk 接受基于寄存器的快速调用,并调用通用入口点。签名 60125 的调用者入口点 thunk 在 x86 汇编中如下所示: ``` 00000000000001d0 <pizlonated1ET60125>: 1d0: push %rbx 1d1: mov %rdi,%rbx 1d4: mov %rdx,0x80(%rdi) 1db: mov %rcx,0x88(%rdi) 1e2: movsd %xmm0,0x90(%rdi) 1ea: movq $0x0,0x180(%rdi) 1f5: mov %r8,0x188(%rdi) 1fc: movq $0x0,0x190(%rdi) 207: mov $0x18,%edx 20c: call *0x8(%rsi) 20f: test $0x1,%al 211: jne 229 213: cmp $0x7,%rdx 217: jbe 22b 219: mov 0x80(%rbx),%rdx 220: mov 0x180(%rbx),%rcx 227: pop %rbx 228: ret 229: pop %rbx 22a: ret ```

相似文章

C++ 编译器何时可以反虚拟化调用?

Hacker News Top

探讨 C++ 编译器何时可以对虚函数调用进行去虚拟化,涵盖已知动态类型和 final 关键字等情况,并在 GCC、Clang、MSVC 和 ICC 之间进行比较。

优化CPU密集型Go热路径的笔记

Hacker News Top

本文讨论了CPU密集型Go代码的性能优化技术,指出了泛型和接口抽象因无法内联而产生的局限性,并主张在热路径中使用代码复制。文章通过一个Brotli移植示例和深入基准测试进行了说明。

当编译器让你惊喜

Lobsters Hottest

Matt Godbolt 探讨了编译器优化如何将 O(n) 求和循环转换为 O(1) 的闭式解,突出了 Clang 和 GCC 如何采用循环展开和数学简化等复杂技术来大幅提升代码性能。

改进 C# 内存安全

Hacker News Top

微软宣布对 C# 16 中的 unsafe 关键字进行重新设计,以强制执行内存安全契约,使 unsafe 操作变得可见并由编译器强制执行,预览版将在 .NET 11 中发布,正式版在 .NET 12 中发布。