绑定生成器的绑定里的绑定生成器的绑定里的绑定生成器
语言绑定与绑定生成器
如果想让一种编程语言能够调用另一种编程语言(尤其是 C/C++ 这样的底层语言),通常需要编写一层“翻译/桥接”代码,这层代码称为语言绑定(Language binding)。以在 D 语言中调用 C 函数为例,您大概要这么做:
main.d
import std.stdio; extern (C) int add(int a, int b); // <-- void main() { writeln(add(3, 4)); }
mathlib.c
int add(int a, int b) { return a + b; }
$ gcc -c mathlib.c -o mathlib.o $ dmd main.d mathlib.o -of=main $ ./main 7
其中 extern (C) int add(int a, int b); 就是绑定代码。
随着需要绑定的函数/类型定义增多,手写 binding 变得极其劳累,试想一下为 Raylib 编写一份 bindings:
1enum PI = 3.14159265358979323846f; 2enum DEG2RAD = PI / 180.0f; 3enum RAD2DEG = 180.0f / PI; 4 5struct Texture 6{ 7 uint id; 8 int width; 9 int height; 10 int mipmaps; 11 int format; 12} 13 14alias Texture2D = Texture; 15 16enum CameraMode 17{ 18 CAMERA_CUSTOM = 0, 19 CAMERA_FREE = 1, 20 CAMERA_ORBITAL = 2, 21 CAMERA_FIRST_PERSON = 3, 22 CAMERA_THIRD_PERSON = 4 23} 24 25alias TraceLogCallback = void function (int logLevel, const(char)* text, va_list args); 26alias LoadFileDataCallback = ubyte* function (const(char)* fileName, int* dataSize); 27alias SaveFileDataCallback = bool function (const(char)* fileName, void* data, int dataSize); 28alias LoadFileTextCallback = char* function (const(char)* fileName); 29alias SaveFileTextCallback = bool function (const(char)* fileName, char* text); 30 31Shader LoadShader (const(char)* vsFileName, const(char)* fsFileName); 32Shader LoadShaderFromMemory (const(char)* vsCode, const(char)* fsCode); 33bool IsShaderValid (Shader shader); 34int GetShaderLocation (Shader shader, const(char)* uniformName); 35int GetShaderLocationAttrib (Shader shader, const(char)* attribName); 36void SetShaderValue (Shader shader, int locIndex, const(void)* value, int uniformType); 37void SetShaderValueV (Shader shader, int locIndex, const(void)* value, int uniformType, int count); 38void SetShaderValueMatrix (Shader shader, int locIndex, Matrix mat); 39void SetShaderValueTexture (Shader shader, int locIndex, Texture2D texture); 40void UnloadShader (Shader shader);
恭喜您已经完成了 3%, 只要再写类似的代码 30 多次就差不多完成了。
程序员们共有的缺点——懒惰,绝不会允许这种事发生,他们发明了绑定生成器(binding generator),某种自动生成跨语言调用绑定代码的工具。
如 D 语言的 C/Objective-C 绑定生成器 dstep,Rust 的 C/C++ 绑定生成器 bindgen,以及支持各种语言的 SWIG,总之这不是什么新鲜事物,大家都很懒。
我也准备为 Saya 语言写一个绑定生成器,叫作 saya-bindgen,其中 bindgen 一词来自 bindgen,整个 Rust 生态都使用类似命名,由于 Saya 编译器是用 Rust 写的,就也借用了这个名称。
引言写了太多「绑定」,希望您还认识这两个字。
AI 太好用了你们知道吗
「为什么不用 LLM 直接生成呢?」
我得承认这确实在 LLM 擅长的领域内,将某种结构化文本转换为另一种结构化文本。我也用 LLM 生成过许多次 bindings,只要把 Saya 的 ABNF 语法和示例代码库给它,以现在的 LLM 能力,正确率相当高。
但这终究是不确定的自动化,我还是偏好更具确定性的代码自动化。
如果您追问「为什么不用 LLM 生成一个 bindgen 程序?」,这就是另一个问题范畴了,我疲于回答。
解析 C 头文件
对我来说,最好上手的方式是用 libclang 解析 C 头文件,在 Rust 里写生成逻辑,类似 rust-bindgen 的技术栈。
可我用不上 libclang 那么大型的宏展开和类型检查功能,而且用熟悉的语言写一个依赖其他成熟软件库的程序,这也太无趣了。
起初,我打算用 stb_c_lexer.h 实现一个 C 语言解析器,再基于解析出的 AST 生成 Saya 代码。但这……是不是又有点“太有趣”了呢,估计大半的时间都要花在实现 C 标准和宏展开上了。
这时我想起两年前看过 TinyCC 和 chibicc 的代码,也许能基于它们的解析功能获得 C 头文件定义信息。
- TinyCC 是 single-pass 的,没有 AST 生成阶段,只是声明级别的信息都不太好拿到。
- chibicc 是 multi-pass 的,tokenize 生成 token 流 -> parse 生成 AST -> codegen 生成汇编代码,三个阶段边界清晰,虽说没有暴露出 API,也总算是有改造的希望。
如果要和 chibicc 交互,还是用 C 语言更方便吧,所以最终的方案是:C 语言调用 chibicc 函数解析头文件,生成 Saya 代码。
记忆力衰退是比想象中更严重的病症,我没有“想起”两年前的事,这是衔接文章的狡猾手段,如果不写下那篇文章我甚至会忘记更多细节,但这不重要,当我说起它,您应该思考两年前的今天您在做什么,寻找锚点,以此刷新记忆索引,并将现在的记忆存放到其他介质,而不是指责我如何不真诚。
制作 libchibicc.a
要调用 chibicc 的函数,最直接的方式是将它的源文件一起编译链接进来。chibicc 的文件中,chibicc/main.c 有自己的程序入口,chibicc/codegen.c 负责生成汇编代码,两者都用不上,其余的词法分析、预处理、解析等文件才是真正需要的。
chibicc 原本用 Makefile 构建,但我一直喜欢用 Just 写编译命令,最终 saya-bindgen 肯定是要使用 justfile 构建的。一个项目如果依赖两种不同的构建工具就会非常让人恼火,所以我用 Just 重写了 chibicc 的编译过程。
由于要对 saya-bindgen 用更严格的警告规则,这里区分了两套 cflags:
cc := "gcc"
chibicc_cflags := "-std=c11 -g -fno-common -Wall -Wno-switch"
my_cflags := "-std=c11 -g -Wall -Wextra -pedantic"
chibicc_dir := "chibicc"
chibicc_srcs := "hashmap parse preprocess strings tokenize type unicode"
_build_objs:
mkdir -p build/chibicc/
for name in {{chibicc_srcs}}; do \
{{cc}} {{chibicc_cflags}} -I{{chibicc_dir}} -c -o build/chibicc/$name.o {{chibicc_dir}}/$name.c; \
done
为了使用更加方便,还可以把它打包为静态库(.a 文件):
libchibicc: _build_objs
ar rcs build/libchibicc.a build/chibicc/*.o
这样 chibicc 解析相关代码都打包进了 build/libchibicc.a,多么美好。
解决符号依赖
然而打包成功不代表能链接成功,chibicc 各文件之间存在跨文件的符号依赖,某些依赖由 chibicc/main.c 和 chibicc/codegen.c 提供,去掉它们后,这些依赖就得在 saya-bindgen 的 main.c 再写一遍。
chibicc.h 声明了一批 extern 变量:
extern StringArray include_paths; extern char *base_file;
它们的实际定义在 chibicc/main.c 中。chibicc/preprocess.c 在处理 #include 时需要 include_paths 来查找头文件路径,实现 __BASE_FILE__ 宏时需要 base_file。
把它们都加入 main.c:
StringArray include_paths; char *base_file = NULL;
情况类似的还有两个函数:
file_exists定义在chibicc/main.c中,chibicc/preprocess.c用它判断候选路径下头文件是否存在align_to定义在chibicc/codegen.c中,chibicc/parse.c在计算结构体成员布局时调用它
两者的实现都很简短,继续在 main.c 里补上即可:
// Returns true if a given file exists. bool file_exists(char *path) { struct stat st; return !stat(path, &st); } // Round up `n` to the nearest multiple of `align`. For instance, // align_to(5, 8) returns 8 and align_to(11, 8) returns 16. int align_to(int n, int align) { return (n + align - 1) / align * align; }
解析测试
chibicc 的解析流程分三步:tokenize -> preprocess -> parse,可以照着 chibicc/main.c 的 main 函数抄:
int main(int argc, char **argv) { (void)argc; init_macros(); add_default_include_paths(argv[0]); Token *tok = tokenize_file("-"); // stdin tok = preprocess(tok); Obj *prog = parse(tok); for (Obj *p = prog; p; p = p->next) { if (p->is_function) { printf("%s\n", p->name); } } return 0; }
tokenize_file("-") 中的 "−" 表示从标准输入读取:
940static char *read_file(char *path) { 941 FILE *fp; 942 943 if (strcmp(path, "-") == 0) { 944 // By convention, read from stdin if a given filename is "-". 945 fp = stdin; 946 } else { 947 fp = fopen(path, "r"); 948 if (!fp) 949 return NULL; 950 }
add_default_include_paths 是 chibicc/main.c 定义的函数,负责建立 #include 的搜索路径。第一条是可执行文件旁边的 include/ 目录,这是 chibicc 自带的一批头文件,用于替换系统版本,后面几条是标准系统路径。整个函数同样可以照抄。
构建时需要把 chibicc/include 复制到 build/include,与二进制文件在一起。
build-c: libchibicc
mkdir -p build/
{{cc}} {{my_cflags}} -I{{chibicc_dir}} -c -o build/main.o main.c
{{cc}} -o build/bindgen build/main.o build/libchibicc.a
cp -r {{chibicc_dir}}/include build/include
./build/bindgen < some_header.h 能看到输出函数名就说明解析没问题。
完整 main.c
1#include "chibicc/chibicc.h" 2 3StringArray include_paths; 4 5char *base_file = NULL; 6 7static StringArray std_include_paths; 8 9// Returns true if a given file exists. 10bool file_exists(char *path) { 11 struct stat st; 12 return !stat(path, &st); 13} 14 15// Round up `n` to the nearest multiple of `align`. For instance, 16// align_to(5, 8) returns 8 and align_to(11, 8) returns 16. 17int align_to(int n, int align) { return (n + align - 1) / align * align; } 18 19static void add_default_include_paths(char *argv0) { 20 // We expect that chibicc-specific include files are installed 21 // to ./include relative to argv[0]. 22 strarray_push(&include_paths, format("%s/include", dirname(strdup(argv0)))); 23 24 // Add standard include paths. 25 strarray_push(&include_paths, "/usr/local/include"); 26 strarray_push(&include_paths, "/usr/include/x86_64-linux-gnu"); 27 strarray_push(&include_paths, "/usr/include"); 28 29 // Keep a copy of the standard include paths for -MMD option. 30 for (int i = 0; i < include_paths.len; i++) 31 strarray_push(&std_include_paths, include_paths.data[i]); 32} 33 34int main(int argc, char **argv) { 35 (void)argc; 36 37 init_macros(); 38 add_default_include_paths(argv[0]); 39 40 Token *tok = tokenize_file("-"); // stdin 41 tok = preprocess(tok); 42 43 Obj *prog = parse(tok); 44 45 for (Obj *p = prog; p; p = p->next) { 46 if (p->is_function) { 47 printf("%s\n", p->name); 48 } 49 } 50 51 return 0; 52}
来真正生成点什么
生成函数声明
Saya 调用外部 C 函数的声明长这样:
@symbol("malloc") pub extern fn malloc(size: u64) -> *opaque;
@symbol 属性存放实际的 C 符号名,告诉编译器链接时去找哪个符号,它和函数名不总是一样,比如函数名有时会携带模块名前缀:
@symbol("BeginDrawing") extern fn raylib::BeginDrawing();
parse 返回的 Obj 链表包含头文件里所有的顶层声明,从中找出函数声明只需两个条件:
ty->kind == TY_FUNC筛选函数类型!is_definition排除带函数体的定义
static void emit_function(Obj *prog) { for (Obj *fn = prog; fn; fn = fn->next) { if (fn->ty->kind != TY_FUNC || fn->is_definition) { continue; } printf("@symbol(\"%s\") pub extern fn %s(", fn->name, fn->name); // ... } }
函数参数从 fn->ty->params 链表逐个取出。每个 param 的 name 字段类型是 Token *,其中记录着指向源文件缓冲区的指针 loc 和长度 len,可以用 format("%.*s", param->name->len, param->name->loc) 取出参数名。
C 还允许函数声明只写类型、省略参数名,这时 param->name 为 NULL,Saya 暂时用 _ 代替。
类型转换的工作全交给 type_to_saya 函数,一会再介绍。
Type *param = fn->ty->params; while (param) { if (!param->name) { fprintf( stderr, "%s:%d: warning: anonymous parameter in %s, using '_' as name\n", fn->ty->name->filename, fn->ty->name->line_no, fn->name); printf("_: %s", type_to_saya(param)); } else { char *pname = ident(format("%.*s", param->name->len, param->name->loc); printf("%s: %s", pname, type_to_saya(param)); } if (param->next) { printf(", "); } param = param->next; }
返回类型从 fn->ty->return_ty 取,如果是 void 就省略 -> ():
Type *return_ty = fn->ty->return_ty; if (return_ty->kind != TY_VOID) { printf(" -> %s", type_to_saya(return_ty)); }
类型转换
基本类型
type_to_saya 接收 chibicc 的 Type * 返回对应的 Saya 类型字符串,基本类型是直接的映射(当然我只考虑了 x86-64 架构):
| C 类型 | Saya 类型 |
|---|---|
void |
() |
_Bool |
bool |
char / unsigned char |
i8 / u8 |
short / unsigned short |
i16 / u16 |
int / unsigned int |
i32 / u32 |
long / unsigned long |
i64 / u64 |
float |
f32 |
double / long double |
f64 |
有无符号取 ty->is_unsigned:
case TY_INT: return ty->is_unsigned ? "u32" : "i32";
long double 映射为 f64 其实是错误的,它应该是 f80 或 f128,取决于编译器实现。chibicc 中 long double 是 f80,实际存储 16 字节,有意义的数据在低 80 位里,加载时使用 x87 的指令 fldt:
712case TY_LDOUBLE: { 713 union { long double f80; uint64_t u64[2]; } u; 714 memset(&u, 0, sizeof(u)); 715 u.f80 = node->fval; 716 println(" mov $%lu, %%rax # long double %Lf", u.u64[0], node->fval); 717 println(" mov %%rax, -16(%%rsp)"); 718 println(" mov $%lu, %%rax", u.u64[1]); 719 println(" mov %%rax, -8(%%rsp)"); 720 println(" fldt -16(%%rsp)"); 721 return; 722}
Saya 没有 f80 对应类型,我打算参考 rust-bindgen,因为 Rust 也没有 f80。搜罗了一圈,发现 rust-bindgen 尝试过直接映射 f64,也改成过生成 u128,最后还是没有好的解决方案:
- https://github.com/rust-lang/rust-bindgen/issues/550
- https://github.com/rust-lang/rust-bindgen/issues/1549
- https://github.com/rust-lang/rust-bindgen/issues/2378
所以,saya-bindgen 只好转换成 f64 了,也许之后改成 [u8; 16] 更加合理,至少不影响整体布局。
指针类型
指针类型需要对 void * 特殊处理:在 C 里 void * 表示任意类型指针,Saya 中用 *opaque 表示;其余指针递归处理 ty->base:
case TY_PTR: if (ty->base->kind == TY_VOID) { return "*opaque"; } return format("*%s", type_to_saya(ty->base));
数组和函数指针
数组和函数指针同样递归:
case TY_ARRAY: return format("[%s; %d]", type_to_saya(ty->base), ty->array_len); case TY_FUNC: { char *params = ""; for (Type *p = ty->params; p; p = p->next) { if (p == ty->params) params = type_to_saya(p); else params = format("%s, %s", params, type_to_saya(p)); } return format("fn(%s) -> %s", params, type_to_saya(ty->return_ty)); }
结构体、联合体和枚举
struct、union、enum 需要输出对应的类型名称。
首先想到用 ty->name,它在 chibicc/parse.c 的 declarator 函数里赋值,存的是声明语句中该类型所在 declarator 位置的标识符 Token。
对于 typedef struct Foo {...} Bar;,declarator 看到的是 Bar,所以 ty->name 存的是别名 Bar,很合理。
但 ty->name 不稳定,每次该类型出现在新的声明里都会被覆盖。比如:
typedef struct Color { unsigned char r; unsigned char g; unsigned char b; unsigned char a; } Color; Color GetColor(unsigned int hexValue); void DrawPixel(int posX, int posY, Color color); void DrawCircle(int centerX, int centerY, float radius, Color color);
chibicc 从上到下解析,ty->name 随 declarator 函数不断被覆盖:
typedef ... Color:ty->name为"Color"Color GetColor(...):返回类型走type_suffix函数,ty->name仍是"Color"void DrawPixel(..., Color color):declarator看到参数名color,ty->name变成"color"void DrawCircle(..., Color color):ty->name还是"color"
此时如果直接用 ty->name 生成,参数类型会输出 "color":
@symbol("DrawPixel") pub extern fn DrawPixel(posX: i32, posY: i32, color: color); @symbol("DrawCircle") pub extern fn DrawCircle(centerX: i32, ..., color: color);
因此不能直接用 ty->name 获取类型名称,需要从其他地方获得真正的类型名。
获取类型名称
改造 chibicc
真正的类型名藏在全局作用域链表 scope 中。
90static Scope *scope = &(Scope){};
链表的每个节点包含两张哈希表:
31typedef struct Scope Scope; 32struct Scope { 33 Scope *next; 34 35 // C has two block scopes; one is for variables/typedefs and 36 // the other is for struct/union/enum tags. 37 HashMap vars; 38 HashMap tags; 39};
vars:存放变量、typedef 和枚举常量tags:存放 struct/union/enum 标签
tags 是不含 typedef 的 struct/union/enum 定义,我还没想好它如何对应 Saya 的 bindings,暂时忽略。
vars 中每个条目的值是一个 VarScope,也就是类型名称的数据来源:
23typedef struct { 24 Obj *var; 25 Type *type_def; 26 Type *enum_ty; 27 int enum_val; 28} VarScope;
但在原始 chibicc 中,VarScope 和 Scope 只定义在 parse.c 内部,对外不可见;scope 本身也是 static 的,无法从其他地方访问。
为此需要两处改动:
- 将
VarScope和Scope的定义移入chibicc/chibicc.h - 去掉
chibicc/parse.c中scope的static限定,在chibicc.h中添加extern Scope *scope声明
改动量很小,实际上是把内部实现细节变成了公开接口。
diff chibicc-orgin/ chibicc/
diff '--color=auto' chibicc-orgin/chibicc.h chibicc/chibicc.h 292a293,301 > // Scope for local variables, global variables, typedefs > // or enum constants > typedef struct { > Obj *var; > Type *type_def; > Type *enum_ty; > int enum_val; > } VarScope; > 446a456,468 > > // Represents a block scope. > typedef struct Scope Scope; > struct Scope { > Scope *next; > > // C has two block scopes; one is for variables/typedefs and > // the other is for struct/union/enum tags. > HashMap vars; > HashMap tags; > }; > > extern Scope *scope; Only in chibicc-orgin/: .git Common subdirectories: chibicc-orgin/include and chibicc/include diff '--color=auto' chibicc-orgin/parse.c chibicc/parse.c 21,39d20 < // Scope for local variables, global variables, typedefs < // or enum constants < typedef struct { < Obj *var; < Type *type_def; < Type *enum_ty; < int enum_val; < } VarScope; < < // Represents a block scope. < typedef struct Scope Scope; < struct Scope { < Scope *next; < < // C has two block scopes; one is for variables/typedefs and < // the other is for struct/union/enum tags. < HashMap vars; < HashMap tags; < }; 90c71 < static Scope *scope = &(Scope){}; --- > Scope *scope = &(Scope){}; Common subdirectories: chibicc-orgin/test and chibicc/test
构建映射表
有了可访问的 scope,遍历 scope->vars,把其中所有 type_def != NULL 的条目收集起来,以 Type * 指针值为 key,别名字符串为 value,存入哈希表 type_names:
static HashMap type_names; static void build_type_names() { for (int i = 0; i < scope->vars.capacity; i++) { HashEntry *entry = &scope->vars.buckets[i]; if (!entry->key) { continue; } VarScope *var_scope = entry->val; if (!var_scope->type_def) { continue; } hashmap_put2(&type_names, (char *)&var_scope->type_def, sizeof(Type *), entry->key); } }
HashMap 是 chibicc 提供的,偷来用一下,只是 key 类型必须是 char *,用的时候总要转换。选用 scope->vars["Foo"]->type_def 作为 key,是因为它持有的地址是稳定且唯一的。
在 type_to_saya 里查表时还有一个回退:
case TY_ENUM: case TY_STRUCT: case TY_UNION: { char *name = hashmap_get2(&type_names, (char *)&ty, sizeof(Type *)); if (!name && ty->origin) { name = hashmap_get2(&type_names, (char *)&ty->origin, sizeof(Type *)); }
chibicc 在某些地方会通过 copy_type 克隆类型,克隆体的 origin 字段指向原始类型。如果 ty 本身不在表里,通过 origin 再查一次,就能找回原始指针对应的别名。
生成类型定义
Struct
Struct 成员存在 ty->members 链表里,每个 Member 的名字是 Token * 类型,用 loc / len 取出:
case TY_STRUCT: printf("pub struct %s {\n", name); for (Member *member = ty->members; member; member = member->next) { if (!member->name) { fprintf(stderr, "%s:%d: warning: anonymous member in struct %s, skiping\n", ty->name->filename, ty->name->line_no, name); continue; } char *member_name = format("%.*s", member->name->len, member->name->loc); printf(" %s: %s,\n", member_name, type_to_saya(member->ty)); } printf("}\n"); break;
Enum
Saya 没有 C 式枚举,Enum 类型映射成整数的 type alias,枚举常量映射成带类型前缀的 const:
typedef enum { RED = 0, GREEN = 1, BLUE = 2 } Color;
生成:
pub type Color = i32; pub const Color_RED: Color = 0; pub const Color_GREEN: Color = 1; pub const Color_BLUE: Color = 2;
整数底层类型从 ty->size 和 ty->is_unsigned 推断。枚举常量散落在 scope->vars 里,通过 enum_ty == ty 筛出属于当前枚举的条目:
case TY_ENUM: { char *base; switch (ty->size) { case 1: base = ty->is_unsigned ? "u8" : "i8"; break; case 2: base = ty->is_unsigned ? "u16" : "i16"; break; case 4: base = ty->is_unsigned ? "u32" : "i32"; break; case 8: base = ty->is_unsigned ? "u64" : "i64"; break; default: base = "i32"; break; } printf("pub type %s = %s;\n", name, base); EnumConst consts[scope->vars.capacity]; int nconsts = 0; for (int j = 0; j < scope->vars.capacity; j++) { HashEntry *const_entry = &scope->vars.buckets[j]; if (!const_entry->key) { continue; } VarScope *const_vs = const_entry->val; if (!const_vs->enum_ty || const_vs->enum_ty != ty) { continue; } consts[nconsts] = (EnumConst){const_entry->key, const_vs->enum_val}; nconsts += 1; } qsort(consts, nconsts, sizeof(EnumConst), cmp_enum_const); for (int j = 0; j < nconsts; j++) { printf("pub const %s_%s: %s = %d;\n", name, consts[j].key, name, consts[j].val); } break; }
也许您注意到这里有两次 for 遍历,第一次把枚举条目存放到列表,第二次才输出绑定代码。这是因为遍历哈希表的 buckets 会获得乱序条目,为了枚举条目输出时按数值排序,我把它收集到 EnumConst 数组 consts,用 qsort 给它排序。
typedef struct { char *key; int val; } EnumConst; int cmp_enum_const(const void *a, const void *b) { return ((EnumConst *)a)->val - ((EnumConst *)b)->val; }
Union
Saya 暂不支持 union,遇到直接输出警告并跳过。
其他 typedef
剩余情况是基本类型或指针的 typedef 别名,统一生成 pub type Name = ...;。指针需要单独处理:直接调用 type_to_saya(ty) 会查到 type_names 里的别名自身,输出 pub type Foo = Foo,所以改为展开 ty->base:
default: if (ty->kind == TY_PTR) { printf("pub type %s = *%s;\n", name, type_to_saya(ty->base)); } else { printf("pub type %s = %s;\n", name, type_to_saya(ty)); } break;
标识符消毒
C 语言与 Saya 关键字不完全重合,比如 type 在 Saya 中是关键字,不能作为标识符,但 C 中就可以。
有必要对标识符进行消毒,Rust 可以 r#type 转义,但 Saya 还没有,我的做法是在关键字后加下划线 _:
static const char *saya_keywords[] = { "pub", "use", "as", "fn", "extern", "return", "struct", "type", "let", "if", "else", "while", "loop", "break", "continue", "const", "static", "true", "false", NULL, }; static char *sanitize_ident(char *name) { for (int i = 0; saya_keywords[i]; i++) { if (strcmp(name, saya_keywords[i]) == 0) return format("%s_", name); } return name; }
在任何输出标识符的地方套上 sanitize_ident 就可以:
printf(
"@symbol(\"%s\") pub extern fn %s(",
fn->name,
sanitize_ident(fn->name)
);
Bindgen Inside Bindings for a Bindgen
saya-bindgen 现在看起来还不错,简陋但功能稳定,我已经在 snake-saya 中用上它生成的 Raylib 绑定了。
那接下来呢,我们要去哪,我好累,写代码与写博客时都很累,我没有预想到它能消耗这么多精力,我应该用更多实际例子测试它的功能,或者拿它去制作更有趣的东西,来获得 补 偿。
您带着轻松愉快的心情草草略过,偶然停在这一章,所以您不累,否则您可以跟随我一起深呼吸,深深地吸气,停两秒钟,缓缓地吐出去。
有什么好的试验品呢……chibicc 怎么样?用 saya-bindgen 生成 chibicc.h 的 bindings chibicc.saya,再借着 chibicc.saya 用 Saya 重新实现 saya-bindgen。
哦,saya-bindgen 要自举了。此刻可您以为它感到兴奋,破例允许您欢呼一声。
chibicc.saya
生成 chibicc.saya……
./build/bindgen < chibicc/chibicc.h > chibicc.saya
看一下内容……
wc -l chibicc.saya 1122 chibicc.saya
啊,1122 行,为什么?chibicc.h 也才 400 多行啊。
pub type __int_least8_t = i8; pub type fpregset_t = *(unknown); pub struct ucontext_t { __uc_flags: u64, uc_link: *ucontext_t, uc_stack: stack_t, uc_mcontext: mcontext_t, uc_sigmask: __sigset_t, __fpregs_mem: (unknown), __ssp: [u64; 4], } ...
这些都是什么……
@symbol("fchmod") pub extern fn fchmod(__fd: i32, __mode: u32) -> i32; @symbol("chmod") pub extern fn chmod(__file: *i8, __mode: u32) -> i32; @symbol("lstat") pub extern fn lstat(__file: *i8, __buf: *(unknown)) -> i32; @symbol("fstatat") pub extern fn fstatat(__fd: i32, __file: *i8, __buf: *(unknown), __flag: i32) -> i32; @symbol("fstat") pub extern fn fstat(__fd: i32, __buf: *(unknown)) -> i32; @symbol("stat") pub extern fn stat(__file: *i8, __buf: *(unknown)) -> i32; @symbol("strncasecmp_l") pub extern fn strncasecmp_l(__s1: *i8, __s2: *i8, __n: u64, __loc: __locale_t) -> i32; @symbol("strcasecmp_l") pub extern fn strcasecmp_l(__s1: *i8, __s2: *i8, __loc: __locale_t) -> i32; @symbol("strncasecmp") pub extern fn strncasecmp(__s1: *i8, __s2: *i8, __n: u64) -> i32; @symbol("strcasecmp") pub extern fn strcasecmp(__s1: *i8, __s2: *i8) -> i32; @symbol("stpncpy") pub extern fn stpncpy(__dest: *i8, __src: *i8, __n: u64) -> *i8; ...
chibicc 里可没有这些函数……看着像是从系统标准库拿出来的,也对,preprocess(tok) 时肯定会展开 #include 宏,所有的头文件内容全都涌进来了。
2#include <assert.h> 3#include <ctype.h> 4#include <errno.h> 5#include <glob.h> 6#include <libgen.h> 7#include <stdarg.h> 8#include <stdbool.h> 9#include <stdint.h> 10#include <stdio.h> 11#include <stdlib.h> 12#include <stdnoreturn.h> 13#include <string.h> 14#include <strings.h> 15#include <sys/stat.h> 16#include <sys/types.h> 17#include <sys/wait.h> 18#include <time.h> 19#include <unistd.h>
难道要修改 saya-bindgen 代码,提供某种“预处理但不导入文件”的选项?好麻烦,很可能又要修改 chibicc。
如果我单独生成这些头文件的 bindings 呢?是不是就能和完整的 chibicc.h bindings 比较差异,获得剩下的部分?
真是个好办法,想出它的人值得奖励再一杯咖啡。
生成所有 #inlcude 代码的 bindings:
./build/bindgen < chibicc_includes.h > chibicc_includes.saya
查看差异:
unknown | 522 +++++++++++++++++++++++++++++++++---------------------------------------------- 1 file changed, 219 insertions(+), 303 deletions(-)
哈……不太正常,不应该只有删除么。在仔细看过差异后,我发现加入 #include 让输出的 bindings 顺序改变了,或者说打乱了,这些头文件的内容不像想象中那样“插入”在其他定义前面。
这一点在函数 bindings 里尤为明显,您可以明显看到 strcasecmp 函数只是挪了个位置而已:
@symbol("fstat") pub extern fn fstat(__fd: i32, __buf: *(unknow @symbol("fstat") pub extern fn fstat(__fd: i32, __buf: *(unknow @symbol("stat") pub extern fn stat(__file: *i8, __buf: *(unknow @symbol("stat") pub extern fn stat(__file: *i8, __buf: *(unknow @symbol("strncasecmp_l") pub extern fn strncasecmp_l(__s1: *i8, | @symbol("strlcat") pub extern fn strlcat(__dest: *i8, __src: *i @symbol("strcasecmp_l") pub extern fn strcasecmp_l(__s1: *i8, _ | @symbol("strlcpy") pub extern fn strlcpy(__dest: *i8, __src: *i @symbol("strncasecmp") pub extern fn strncasecmp(__s1: *i8, __s < @symbol("strcasecmp") pub extern fn strcasecmp(__s1: *i8, __s2: < @symbol("stpncpy") pub extern fn stpncpy(__dest: *i8, __src: *i @symbol("stpncpy") pub extern fn stpncpy(__dest: *i8, __src: *i @symbol("__stpncpy") pub extern fn __stpncpy(__dest: *i8, __src @symbol("__stpncpy") pub extern fn __stpncpy(__dest: *i8, __src @symbol("stpcpy") pub extern fn stpcpy(__dest: *i8, __src: *i8) @symbol("stpcpy") pub extern fn stpcpy(__dest: *i8, __src: *i8) @symbol("__stpcpy") pub extern fn __stpcpy(__dest: *i8, __src: @symbol("__stpcpy") pub extern fn __stpcpy(__dest: *i8, __src: @symbol("strsignal") pub extern fn strsignal(__sig: i32) -> *i8 @symbol("strsignal") pub extern fn strsignal(__sig: i32) -> *i8 > @symbol("strsep") pub extern fn strsep(__stringp: **i8, __delim > @symbol("explicit_bzero") pub extern fn explicit_bzero(__s: *op > @symbol("strncasecmp_l") pub extern fn strncasecmp_l(__s1: *i8, > @symbol("strcasecmp_l") pub extern fn strcasecmp_l(__s1: *i8, _ > @symbol("strncasecmp") pub extern fn strncasecmp(__s1: *i8, __s > @symbol("strcasecmp") pub extern fn strcasecmp(__s1: *i8, __s2:
逐行判断删除的话,函数是没问题,但结构体类型定义会乱掉,字段可能被误伤,末尾的花括号 } 也没有了:
pub struct Relocation { next: *Relocation, offset: i32, label: **i8, addend: i64, pub struct Member { next: *Member, ty: *Type, tok: *Token, name: *Token, idx: i32, align: i32, offset: i32, is_bitfield: bool, bit_offset: i32, bit_width: i32, pub type TypeKind = i32; pub const TypeKind_TY_VOID: TypeKind = 0; ...
生成绑定时除了函数是行行相连,每个定义之间都有两个 \n 换行,很简单写个脚本分出所有的“块”,将它们转换为 set 取得差集(还可以 sorted 一次让输出稳定):
def parse_blocks(path): with open(path) as f: text = f.read() blocks = [] for block in text.split("\n\n"): if block.startswith("@symbol"): blocks.extend(block.splitlines()) else: blocks.append(block) return blocks a = set(parse_blocks("chibicc.saya")) b = set(parse_blocks("chibicc_includes.saya")) result = sorted(a - b) print("\n\n".join(result))
python subtract.py > chibicc_native.saya
有零星几个字段名称不同导致没有删干净,我选择手动删掉这几个定义(不明白为什么会少两个下划线)。
// chibicc.saya pub struct mcontext_t { __gregs: [i64; 23], __fpregs: fpregset_t, __reserved1: [u64; 8], } // chibicc_includes.saya pub struct mcontext_t { gregs: [i64; 23], fpregs: fpregset_t, __reserved1: [u64; 8], }
除此之外都还不错,352 行才对嘛。
$ mv chibicc_native.saya $ wc -l chibicc.saya 352 chibicc.saya
main.saya
用 Saya 重写 main.c 的过程中遇到了几处需要变通的地方。
struct stat
file_exists 函数调用 stat 函数,需要传入 struct stat 的指针。我不打算用 bindgen 再生成 struct stat 的绑定,因为不需要直接操作它。
测一下实际大小:
#include <stdio.h> #include <sys/stat.h> int main() { printf("sizeof(struct stat) = %zu\n", sizeof(struct stat)); return 0; }
sizeof(struct stat) = 144
在 Saya 里用等大的字节数组代替:
struct StatBuf { _data: [u8; 144], }
栈上分配时用零初始化:
let st = StatBuf { _data: [0; 144] };
这样就不用在意 struct stat 内部到底什么样了。
shim.c
有三处操作在 Saya 里无法直接完成,缺乏各种特性(我还在纠结设计或没来得及实现)。
&scope->vars.buckets[i]理应取 hashmap 桶的地址,但 Saya 不支持对指针做下标运算pub struct HashMap { buckets: *HashEntry, capacity: i32, used: i32, }
C 是可以将
*HashEntry作为HashEntry数组的,如果 Saya 足够完善,应该实现类似 Zig 的 Many-item Pointers,将buckets定义为[*]HashEntryentry->val是void *,Saya 还不支持指针的类型转换,*opaque无法转换为*VarScope使用unreachable()是 chibicc 定义的 C 宏,saya-bindgen 没法生成相关代码宏展开,Saya 也还没有宏
我的方案是写进 shim.c 做薄封装:
#include "chibicc/chibicc.h" HashEntry *hashmap_bucket_at(HashMap *map, int i) { return &map->buckets[i]; } VarScope *hashentry_var_scope(HashEntry *entry) { return entry->val; } _Noreturn void chibicc_unreachable(void) { unreachable(); }
extern fn hashmap_bucket_at(map: *chibicc::HashMap, i: i32) -> *chibicc::HashEntry; extern fn hashentry_var_scope(entry: *chibicc::HashEntry) -> *chibicc::VarScope; extern fn chibicc_unreachable() -> !;
VLA and sizeof
C 版用 VLA 按 scope->vars.capacity 动态分配枚举常量数组,Saya 还不支持 VLA,也不想封装 malloc,就暂时写成固定大小 1024:
// TODO: EnumConst consts[scope.vars.capacity]; let consts = [EnumConst { key: null, val: 0 }; 1024];
qsort 的元素大小参数同理,sizeof(EnumConst) 硬编码为 16。
long double
long double 的报应来得那么快……
Token 的 fval 字段是 long double 的,转换为 f64 后大小不一致,影响了后面的 loc 和 len 字段。
只把它改成 [u8; 16] 仍有问题,loc 位置不对,下面的表格说明了 loc 错位的情况:
C 编译器
| Offset | Field | Type | Size | 说明 |
|---|---|---|---|---|
| 0 | kind |
TokenKind |
4 | enum (int) |
| 4 | [padding] |
4 | 对齐 next 到 8 字节 | |
| 8 | next |
*Token |
8 | |
| 16 | val |
int64_t |
8 | |
| 24 | [padding] |
8 | 对齐 fval 到 16 字节 | |
| 32 | fval |
long double |
16 | align=16 |
| 48 | loc |
*char |
8 | 指针 |
saya 编译器 fval(16 byte)
| Offset | Field | Type | Size | 说明 |
|---|---|---|---|---|
| 0 | kind |
TokenKind |
4 | enum (int) |
| 4 | [padding] |
4 | 对齐 next 到 8 字节 | |
| 8 | next |
*Token |
8 | |
| 16 | val |
i64 |
8 | |
| 24 | fval |
[u8; 16] |
16 | align=1 |
| 40 | loc |
*i8 |
8 |
saya 编译器 _pad(8 byte) + fval(16 byte)
| Offset | Field | Type | Size | 说明 |
|---|---|---|---|---|
| 0 | kind |
TokenKind |
4 | enum (int) |
| 4 | [padding] |
4 | 对齐 next 到 8 字节 | |
| 8 | next |
*Token |
8 | |
| 16 | val |
i64 |
8 | |
| 24 | _pad |
[u8; 8] |
8 | 手动 padding |
| 32 | fval |
[u8; 16] |
16 | long double |
| 48 | loc |
*i8 |
8 |
在前面加上 _pad: [u8; 8]:
224pub struct Token { 225 kind: TokenKind, 226 next: *Token, 227 val: i64, 228 // fval: f64, 229 _pad: [u8; 8], // padding(8 byte) 230 fval: [u8; 16], // long double(16 byte) 231 loc: *i8, 232 len: i32,
saya-bindgen in saya
用 Saya 重新实现时虽然遇到不少问题,但看看 main.saya,可以拿自己生成的代码给自己使用,多酷。
而且用 Saya 实现也有比较舒适的地方,比如基于表达式,不少地方比 C 版更简洁清晰。
type_to_saya 里判断类型 kind 对应的字符串,C 版需要嵌套三目运算:
const char *kind = ty->kind == TY_ENUM ? "enum" : ty->kind == TY_STRUCT ? "struct" : "union";
Saya 版直接用 if 表达式赋值,不需要三目嵌套:
let kind = if ty.kind == chibicc::TypeKind_TY_ENUM { c"enum" } else if ty.kind == chibicc::TypeKind_TY_STRUCT { c"struct" } else { c"union" };
align_to 这样的小函数则直接省去 return:
pub fn align_to(n: i32, align: i32) -> i32 { (n + align - 1) / align * align }
说再见,请再见
这是一篇过长的博客文章,从 Bindgen Inside Bindings for a Bindgen 开始应该划分为新的一篇。
但我也说不清楚,就是不喜欢,也说不上不喜欢,只是纠结着一直写到现在。
两部分倒的确是不同时期写的,前一部分完成在六月中旬,后一部分靠近七月,只有后半部分是写文章的动力,前面的像是汇报工作。(文章的日期是折中,这足够公平吗)
saya-bindgen 用 Saya 重新实现时,由于缺乏特性,逼迫着推进了语言特性实施,还找到了几处编译器的错误;而编写这篇文章时,也推进博客支持了剧透块。似乎很有收获,取决于收获到底是什么。
小时候,假期读本书扫个地都需要写下感想和收获,感想我有很多,收获却总是没有,对着作文纸发愁。收获的意义太重大,要积极,普遍意义上的正能量,我找寻到的“能量”难以通过这一严苛筛选,只剩下大家不喜欢的部分。大家的切实所指我不愿意回忆,现在宁愿认为那是自己,我应该先坦诚地喜欢那些不该喜欢的想法,消灭其中一个人。
这些联想同样属于“感想”的一部分,也许还能从中提炼出某些“生活指导”——所谓收获,可作为废料丢掉的剩余想法分明更有价值,博客也应该是用来帮助发展感受和想法的。
所以 saya-bindgen 有收获,写这篇文章也有收获,但我不该在乎,要少写总结。