关于生成 C 的思考
Source: Hacker News
静态内联函数实现数据抽象
当我学习 C 语言时,在 GStreamer 的早期(哦,保佑它的心——它仍然使用同一个网页!),我们大量使用预处理宏。
随着时间的推移,我们收到信息:许多宏的使用本应改为 内联函数;宏用于标记拼接和生成名称,而不是用于数据访问或其他实现细节。
我直到很久以后才意识到,always‑inline(始终内联)函数可以消除数据抽象可能带来的任何性能惩罚。
示例
在 Wastrel 中,我通过 memory 结构体描述 WebAssembly 内存的有界范围,并在另一个结构体中访问该内存:
struct memory {
uintptr_t base;
uint64_t size;
};
struct access {
uint32_t addr;
uint32_t len;
};
如果我想要该内存的可写指针,可以这样写:
#define static_inline \
static inline __attribute__((always_inline))
static_inline void *write_ptr(struct memory m, struct access a) {
BOUNDS_CHECK(m, a);
char *base = __builtin_assume_aligned((char *)m.base, 4096);
return (void *)(base + a.addr);
}
BOUNDS_CHECK通常会被省略;内存被映射到一个大小合适的PROT_NONE区域。- 我们使用宏来实现
BOUNDS_CHECK,这样如果检查失败并导致进程终止时,可以嵌入__FILE__和__LINE__。
无论是否启用了显式的边界检查,static_inline 属性都确保抽象成本被完全消除。当边界检查被省略时,内存的 size 与访问的 len 根本不会被分配。
如果 write_ptr 不是 static_inline,我会担心这些结构体的值可能会通过内存传递。这主要在返回结构体值的函数中成为问题;例如,在 AArch64 上返回 struct memory 会使用与调用 void (*)(struct memory) 时参数相同的寄存器,而 SysV x86‑64 ABI 只为返回值分配两个通用寄存器。我不想去思考这种瓶颈,而 static inline 函数帮我解决了这个问题。
避免隐式整数转换
C 有一套奇怪的默认整数转换规则(例如,将 uint8_t 提升为 int)以及对有符号整数的怪异边界条件。在生成 C 代码时,规避这些规则并显式处理会更安全:
- 定义 static‑inline 的转换辅助函数,例如
u8_to_u32、s16_to_s32等。 - 打开
-Wconversion以捕获意外的隐式转换。
使用 static‑inline 转换函数还能让生成的代码 断言 操作数属于特定类型。理想情况下,所有转换都放在你的辅助函数中,生成的代码本身不出现任何显式转换。
用意图包装原始指针和整数
Whippet 是用 C 编写的垃圾回收器。GC 跨越所有数据抽象:对象可以被视为绝对地址、分页空间中的范围、对齐区域起始处的偏移量,等等。用普通的 size_t 或 uintptr_t 来表示所有这些概念很快就会变成噩梦。
因此 Whippet 引入了 单成员结构体,为每个概念提供独立的类型:
/* api/gc-ref.h */
struct gc_ref {
uintptr_t addr;
};
/* api/gc-edge.h */
struct gc_edge {
uintptr_t edge_addr;
};
这些结构体可以防止意外误用——gc_edge_address 永远不会接受 struct gc_ref,反之亦然。
这对编译器有什么帮助
当编译器确切知道一个项的类型时,它可以在生成的 C 代码中避免许多错误。
以编译 WebAssembly 的 struct.set 操作为例。文本语义是:
“断言:由于已通过验证,val 是某个
ref.structstructaddr。”
我们可以通过构建 指针子类型森林 来将该断言翻译成 C:
typedef struct anyref { uintptr_t value; } anyref;
typedef struct eqref { anyref p; } eqref;
typedef struct i31ref { eqref p; } i31ref;
typedef struct arrayref { eqref p; } arrayref;
typedef struct structref{ eqref p; } structref;
对于具体类型,例如 (type $type_0 (struct (mut f64))),我们生成:
typedef struct type_0ref { structref p; } type_0ref;
$type_0 的字段写入函数随后接受一个 type_0ref:
static inline void
type_0_set_field_0(type_0ref obj, double val) {
/* ... */
}
于是源层面的类型信息一直传播到目标语言。
实际对象表示也有类似的类型森林:
typedef struct wasm_any { uintptr_t type_tag; } wasm_any;
typedef struct wasm_struct{ wasm_any p; } wasm_struct;
typedef struct type_0 { wasm_struct p; double field_0; } type_0;
我们生成极小的转换例程,在需要时在 type_0ref 与 type_0 * 之间来回转换。由于所有例程都是 static inline,没有运行时开销,并且我们免费获得了指针子类型。
这些模式帮助我从编译器中得到可靠且高性能的 C 代码。它们并不是任何官方意义上的“最佳实践”,但对我有效,欢迎你也采用。
struct.set $type_0 0
The instruction is passed a subtype of `$type_0`; the compiler can generate an up‑cast that type‑checks.
不必害怕 memcpy
在 WebAssembly 中,对线性内存的访问 不一定对齐,因此我们不能直接把地址强转为(例如)int32_t* 并解引用。
相反我们这样做:
memcpy(&i32, addr, sizeof(int32_t));
并相信编译器在可能的情况下会生成非对齐加载指令(它可以做到)。这里不需要再多说什么!
对于 ABI 与尾调用,手动进行寄存器分配
GCC 终于有了 __attribute__((musttail)):值得赞扬。然而,在编译到 WebAssembly 时,你可能会得到一个拥有 30 个参数 或 30 个返回值 的函数。我不相信 C 编译器能够在这种函数的尾调用中可靠地在不同的栈参数需求之间切换。它甚至可能因为无法满足 musttail 要求而拒绝编译某个文件——这对目标语言来说显然不是我们想要的特性。
你真正想要的是 所有函数参数都分配到寄存器。例如,你可以只把前 n 个值放入寄存器,其余的放入全局变量。无需把它们压栈,因为被调函数可以在序言中把它们加载回局部变量。
有趣的是,这同样可以在编译到 C 时自然支持多返回值:
- 列举程序中使用的函数类型集合。
- 为所有返回值分配足够的全局变量,类型要与返回值相匹配,以存放 全部 返回值。
Source: …
.
3. 在函数尾部(epilogue),将任何“多余”的返回值(第一个之外的)存入这些全局变量。
4. 调用者在调用后立即重新加载这些值。
不足之处
生成 C 代码是一种 局部最优:
- 你可以获得 GCC 或 Clang 那样的工业级指令选择和寄存器分配。
- 你不必实现大量的窥孔式(peephole)优化。
- 你可以链接可能内联的 C 运行时例程。
在边际上很难在此设计点上进一步改进。
当然也有缺点。作为一名 Scheme 程序员,我最大的烦恼是 我无法控制栈:
- 我不知道给定函数需要多少栈空间。
- 我无法以任何合理的方式扩展程序的栈。
- 我无法遍历栈来精确枚举嵌入的指针(也许这没问题)。
- 我当然也无法切片栈来捕获有界的 continuation。
另一个主要的恼人之处是 侧表(side tables):人们希望实现所谓的 零成本异常,但没有编译器和工具链的支持,这是不可能的。
最后,源级调试相当棘手。你希望为你残余化的代码嵌入对应的 DWARF 信息,但在生成 C 时我不知道该怎么做。
为什么不选 Rust?
说实话,我发现生命周期是前端的问题;如果我有一个带显式生命周期的源语言,我会考虑生成 Rust,因为我可以机器检查输出是否具备与输入相同的保证。同样,如果我使用的是 Rust 标准库。但如果你是从一个没有花哨生命周期的语言编译,我不知道你能从 Rust 中得到什么:更少的隐式转换,确实是,但尾调用支持不够成熟,编译时间更长……我觉得两者各有利弊。
算了。没有什么是完美的,最好是睁大眼睛去面对。如果你看到这里,我希望这些笔记能帮助你进行代码生成。对我来说,一旦生成的 C 代码通过了类型检查,它就能正常工作:几乎不需要调试。黑客工作并不总是如此,但当它出现时,我会欣然接受。
下次再见,祝 hacking 愉快!