A Bindgen Inside Bindings for a Bindgen Inside Bindings for a Bindgen

绑定生成器的绑定里的绑定生成器的绑定里的绑定生成器

13m0n4de/saya-bindgen

语言绑定与绑定生成器

如果想让一种编程语言能够调用另一种编程语言(尤其是 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 标准和宏展开上了。

这时我想起两年前看过 TinyCCchibicc 的代码,也许能基于它们的解析功能获得 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.cchibicc/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.cmain 函数抄:

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_pathschibicc/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 链表逐个取出。每个 paramname 字段类型是 Token *,其中记录着指向源文件缓冲区的指针 loc 和长度 len,可以用 format("%.*s", param->name->len, param->name->loc) 取出参数名。

C 还允许函数声明只写类型、省略参数名,这时 param->nameNULL,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 其实是错误的,它应该是 f80f128取决于编译器实现。chibicc 中 long doublef80,实际存储 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,最后还是没有好的解决方案:

所以,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.cdeclarator 函数里赋值,存的是声明语句中该类型所在 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->namedeclarator 函数不断被覆盖:

  1. typedef ... Colorty->name"Color"
  2. Color GetColor(...):返回类型走 type_suffix 函数,ty->name 仍是 "Color"
  3. void DrawPixel(..., Color color)declarator 看到参数名 colorty->name 变成 "color"
  4. 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 中,VarScopeScope 只定义在 parse.c 内部,对外不可见;scope 本身也是 static 的,无法从其他地方访问。

为此需要两处改动:

  1. VarScopeScope 的定义移入 chibicc/chibicc.h
  2. 去掉 chibicc/parse.cscopestatic 限定,在 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->sizety->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 定义为 [*]HashEntry

  • entry->valvoid *,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 的报应来得那么快……

Tokenfval 字段是 long double 的,转换为 f64 后大小不一致,影响了后面的 loclen 字段。

只把它改成 [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 有收获,写这篇文章也有收获,但我不在乎,要少写总结。

Copyright © 2024 13m0n4de · CC BY-NC 4.0