许式伟

c2go: 通过 sqlite3 迁移实践重新认识 C 语言

自从 Go+ 决定不沿用 Go 的 cgo 能力,而是采用全新的方式来支持 C 语言(详细参见《Go+ 下个里程碑:超越 cgo,无缝对接 C 语言》一文的内容)后,我们花了 10 天时间把 C 语言的语法支持实现了个七七八八(详细参见《Go+ 探究:如何 10 天实现工业级的 C 编译器》一文的内容)。

按照原计划,我们第二步开始迁移 C 标准库,然后第三步迁移一个知名的开源库,大概率我们会选 sqlite3 作为案例。但是最终执行的时候,我决定把第三步,也就是迁移 sqlite3 提前来做。

这样做其实比较容易理解。C 标准库太多内容了,到底应该先迁移哪一个,谁比谁更重要,是很难有客观的衡量标准。但是如果我们把需求方摆在前头,以 sqlite3 的需求为蓝本,那么争议就可以终止了。这实际上对任何一个研发团队来说都是一个很好的思考方式:不要自己去臆想,如果觉得事情不够清晰,先去研究一下客户。

所以把 sqlite3 的迁移提前做,其实有两个明显的好处:其一,我们第一步 C 语法支持是否真的比较完整了,是驴是马总该拿出来溜溜。其二,C 标准库到底应该先迁移谁,有个客观的衡量依据。

于是 10 天前我就创建了 github.com/goplus/sqlite 这个 repo,开启了 sqlite3 的移植。到今天,sqlite3 本身代码的迁移接近尾声,我们终于生成一个 10 万行的 sqlite3.c.i.go 文件(原始通过 C 预编译器处理后的 sqlite3.c.i 文件是 11 万行),见下图。

Image

当然生成的 .go 文件还无法编译通过。原因有两个:其一,还有部分 goto 语句不满足 Go 的语法要求。这是我们需要解决的,问题原因及解决方案我们下文详细展开。其二,依赖的 C 标准库还没有翻译,所以链接上会找不到一些符号。这是预期中的,不是本阶段要解决的问题。

开启 sqlite3 之前 c2go 的最新版本是 v0.3.3,昨天我们刚刚发布了 v0.3.9 版本。结合这些版本的变更历史,我们详细聊一聊 sqlite3 迁移中我们遇到的挑战是什么,它是如何刷新我对 C 语言的认知(最精彩的部分我放到了最后)。

先说说最常规的,最容易预期的部分,它们主要包括:

一,缺失的语法。如 goto、变量初始化(之前没实现结构体中包含数组的情形)、可变参数的遍历(之前只支持了可变参数类型,但不支持操作它)、offsetof 等。

二,已实现的语法有 bug,如:对 sizeof 的 C AST 理解有误导致实现不正确,对 switch..case 的 C AST 理解有误导致实现不正确等等。

三,缺失的内建函数。当前,我们已经支持的内建函数包括:

  • __sync_synchronize

  • __builtin___memset_chk

  • __builtin___memcpy_chk

  • __builtin___memmove_chk

  • __builtin___strlcpy_chk

  • __builtin___strlcat_chk

  • __builtin_object_size

  • __builtin_bswap32 / __builtin_bswap64

  • __builtin_fabsf / __builtin_fabsl / __builtin_fabs

  • __builtin_inff / __builtin_infl / __builtin_inf

  • __builtin_huge_valf

  • __atomic_store_n

  • __atomic_load_n

等等。

四,Go 关键字被作为了变量或类型名,包括:map, type, range, chan, var, func, go, select, defer, package, import, interface, fallthrough。

五,指针的支持。虽然我们之前已经支持了指针运算,包括:p+n, p-n, p-q, p++, p--, ++p, --p, p[n], *p,但是还是会有所缺失。比如:n+p,if (p) 的隐式转 bool 操作等。还有 p[n] 语法有一个特殊场景是下标为负数,比如 p[-1] 这样的写法。这在 Go 里面并不被允许,我们把它转换为了 *(p-1) 来进行处理。

今天我们要重点讲的,其中牵涉面相对广,比较有挑战的部分。它们包括:

  • 隐式类型转换。大家都知道,C 有隐式类型转换,Go 没有。隐式类型转换是比较全局性的功能,到处都会用到。怎么以一种收敛的方式来支持这个功能,而不是散落在各处?

  • 类型的语法定义及类型系统差异。这块内容比较多,下文展开。

  • switch 和 goto 语句。虽然 Go 语言也有 switch 和 goto,但是细究来看,和 C 有非常巨大的差异,下文展开。

隐式类型转换

我们先来说说隐式类型转换。

最常见的是各种整型类型之间、以及整型与浮点类型之间的隐式类型转换。对 Go 程序员难理解的是 bool 类型相关的隐式转换,比如 if (cond) 会自动把 cond 条件转为 bool,最典型的是整数类型和指针类型。if (p) 可以理解为 if (p != nil),这对 Go 程序员来说更好理解。反过来,C 也可以隐式转 bool 为整数,比如:n + (a == b),或者 p + (a > 0)。当然这对 C 来说其实是自然不过了,因为 C 认为 bool 也是一种整型类型。

这个功能本身比较简单,有挑战的是它的全局性,任何可以出现表达式的地方,都可以有隐式类型转换。

怎么办?我们最终选择让 gox(关于 gox 的的介绍和来由,请参阅《Go+ 两周年:我们遇到过的那些坑》进行了解)支持它。我们在 gox.Config 配置类中增加了 CanImplicitCast 字段:

Image

如果我们不配置 CanImplicitCast,比如 Go+,那么不进行隐式类型转换。而对 c2go,我们则实现了 CanImplicitCast 函数,在其中实现各种隐式转换的能力,这样我们就无需理会隐式类型会出现在哪些场景中了。

我认为 C 语言把 bool 看做一种只能取 0 和 1 的整型类型是个好主意。在 Go 里面我们要把 bool 转为 int 太费劲了。我们得这样写:

var n int

if boolExpr {

   n = 1

} else {

   n = 0

}

这种复杂代码对语义理解是有害的。所以我决定在 Go+ 中支持 bool 到各种数值类型(包括整型、浮点型、复数类型)的强制类型转换。比如 int(true) 就可以得到 1。当前 Go+ 已经实现该功能,见下:

Image

类型的语法定义及类型系统差异

我们再来看一下 C 与 Go 在类型的语法定义及类型系统在语义上的差异。

从语法上来说,首先 C 与 Go 就有非常显著的差异。它表现在:

对于一些复合类型,C 的类型与变量定义混在一起,最典型的是:

  • 数组:T arr[N]

  • 函数指针:T (*f)(T1, T2, ..., Tn)

  • 数组指针:T (*parr)[N]

而如果把这些情况再组合一些就更复杂了:

  • 返回数组的函数指针:T (*f)(T1, T2, ..., Tn)[N]

  • 函数指针的数组:T (*arr[N])(T1, T2, ..., Tn)

  • ...

不幸的是,clang 实现的 C Parser 生成 C AST 里面类型简单以字符串(叫 qualType)表示,而不是 AST。这意味着我们需要自己实现 C types parser(见 github.com/goplus/c2go/clang/types/parser 包),这样就要手工解析 C 的类型定义语法。

从语义上来说,C 的类型系统也与 Go 有较多差别。

首先,在 C 语言中函数类型与函数指针类型是有差别的,前者是 T (T1, T2, ..., Tn),后者是 T (*)(T1, T2, ..., Tn)。而在 Go 里面两者没有差别,都是 func (T1, T2, ..., Tn) T。

为了消除这个差别,我们引入了 C types 模块(见 github.com/goplus/c2go/clang/types,为了和 go/types 区分开来,我们不妨简称其为 ctypes)。

在 ctypes 里面有 ctypes.Void、ctypes.Int 等 C 语言的基础类型。但更重要的是,它引入了 ctypes.NewFunc 函数,可以创建一个函数类型实例,也就是 ctypes.Func。

另外,我们还有 ctypes.NewPointer。与 go/types.NewPointer 不太一样的是,它特殊处理了 ctypes.Void 和 ctypes.Func 两个类型,对于前者它对应的指针类型是 unsafe.Pointer,而后者对应的指针类型是 types.Signature(也就是 Go 里面的函数指针)。

此外,我们还处理了 C 与 Go 的不定参数类型的差异。总之,通过引入 ctypes,我们完成 C 与 Go 的类型系统的映射关系与差异处理。

但除此之外,还有一些 C 类型定义上是很有特殊性的。

一个是结构体的嵌套。比如以下这段代码:

struct foo {

   struct bar {

       int a;

   } b;

};

你可能会认为 bar 是 foo 的嵌套子结构体(用 C++ 的语法表达是 foo::bar 类型)。但是这只是我们的惯性想法,其实在这个语法上,C 和 C++ 的语义是不同的。的确在 C++ 中 bar 是 foo 的嵌套子结构体,但是在 C 中并不是。它与以下代码等价:

struct bar {

   int a;

};

struct foo {

   struct bar b;

};

发现这个差异的时候我的确很惊讶。在我的潜意识里,认为 C++ 是完全兼容 C 的。但是从这一刻开始我意识到它并不是。你把一个 .c 文件后缀改名为 .cpp,它的语义可能就发生了变化。

另一个特殊的语法是 T[]。它在不同场景有不同的含义。

我们大家最熟悉的,是在函数参数中,T[] 和 T[N] 类型都等价于指针类型(T*)。

它还可以出现在 extern 变量声明中。例如:

extern int a[];

此时 int[] 代表的是未知长度的数组,类似于 Go 语言的 [...]int 语法(当然这只是类比,实际是有差别的)。extern 声明出现的原因主要是 C 语言是以单个 .c 源文件为编译单位的语言,所以不在本文件内定义的变量或函数,都需要先进行声明。而 Go 语言以整个包为编译单位,不需要去声明其他 .go 文件中出现的符号。

此外,对 T[] 大家相对陌生的是它出现在结构体中。例如:

struct foo {

   int a;

   int b[];

};

这里 b 到底是什么东西?这个题目我扔到 Go+ 贡献者群中,不少小伙伴都答错了,认为是指针。但实际上它代表的是 0 长度的数组。也就是它等价于:

struct foo {

   int a;

   int b[0];

};

可能一些人会认为把 T[0] 简写为 T[] 是小题大做。但实际上它背后的逻辑并不是省掉 0 长度,而是支持不定长度的结构体。所以 T[] 这个定义必须出现在结构体的末尾。例如以下代码是编译不过的:

struct foo {

   int a[];

   int b;

};

这个语法对 C 在解析磁盘文件格式或者网络协议的时候会很有用。在很多场景下,我们都会去定义类似这样的一种数据格式:

struct foo {

   uint32_t size;

   byte data[]; // byte[size]

};

switch 和 goto 语句

最后我们来谈谈 switch 和 goto 语句。

刚学完 Go 的 switch 语句后,给我一个直观的印象是 Go 的 switch 功能比 C 的 switch 强大多了,基本上 C switch 能够做的,Go switch 应该都能够完成。

但 c2go 的实践让我打脸了。原来我一直没有学会 C 的 switch 语法。我相信大部分的读者,也都没学会。如果不信,可以做个题目:请问以下程序的输出是什么?

Image

多数小伙伴看完以后,一定和我一样傻眼了:switch 里面套 while 语句,这是什么东东?实际上这段代码的机关不止一个,还有另一个问题:里面的 break 语句的含义是什么,它跳转到哪里?

要理解这段代码的含义,首先要真正搞清楚 C 语言中 switch..case 语句的含义。

我们不妨用这样抽象(实际上本质上也是如此):switch 语句它类似 goto,只不过跳转的 label 是通过计算出来的值来确定。而 case 语句类似 label,只不过这个 label 是有值的。在上面的例子中,多个 case 语句的排列还算规整的,其实 C 语言支持相当不规整的 case 语句。例如,上面这个例子中,我们把 case 0 这个分支放到 while 循环之外,这仍然是一个合法的 C 语言的 switch..case。

值得注意的是,我们这里说 case 语句相当于 label,这是真的。这个从 case 语句对应的 C AST 也可以看出来。我们来看一个例子。对于我们经常看到的 case 代码序列:

case expr:

   stmt1;

   stmt2;

   ...

   stmtN

它对应的 C AST 是:case expr: stmt1 是一个 statement,而 stmt2 .. stmtN 这些句子和 case expr 并不相干,属于并列关系。这与其他语言,包括 Go 语言的 switch..case 是非常不一样的。在 Go 语言中,stmt1; stmt2 ... stmtN 这些语句构成复合 statement,作为 case 语句的 body 部分。

理解清楚 switch..case 的逻辑后,理解 break 语句就简单了。在 switch..case 语句中,break 的语义就是跳出 switch 语句,和 case 语句无关。但是上面这个 switch 里面套 where 循环的例子,break 语句属于 where 循环,它跳出的是 while 语句。当然这个例子中 where 结束的地方也是 switch 结束的地方,就算理解有问题也可以得到正确的结果。

接下来我们一起看看 C 语言的 goto 语句。虽然都是 goto,但是在 Go 语言中 goto 的限制有很多。比如,以下这些 C 语言中的 goto 用法,在 Go 里面都不支持。

情形一,在 if 分支中跳转到 else 分支的某处:

if (cond) {

   ...

   goto label

   ...

} else {

   ...

label:

   ...

}

情形二,在 switch..case 的不同分支间跳转:

switch (tag) {

case expr1:

   ...

   goto label

   ...

case expr2:

   ...

label:

   ...

}

情形三,在程序某处跳转到某个复合语句内:

...

goto label

...

{

   ...

label:

   ...

};

在 Go 语言中,goto 语句只能在本 block 或者父 block 中的某个 label(但不能是子 block 中,例如上面的情形三;也不能是兄弟 block,例如上面的情形一和情形二),且跳转时不能跳过变量定义。例如下面这段 Go 代码也是编译不过的:

   ...

   goto label

   ...

   var a int

   ...

label:

   ...

理解了 switch 和 goto 语句在 C 和 Go 语言中的差别后,你可能要问:那怎么将 C 代码翻译成 Go 代码呢?

答案只能是:那就只好把 Go 语言当做汇编来用了,只用 jmp(也就是 goto)和 je/jne(也就是 if cond goto)了。

所以 c2go 会这么做:对于所有 C 的流程控制语句(包括:if..else、switch..case、for、while、do..while),如果这些语句中出现了 Go 不支持的 goto label,则将其标记为复杂语句(complicated)。当然 switch..case 会额外做一些检测,凡是 Go 的 switch..case 不支持的情况,也标记为复杂语句(complicated)。

对于那些标记为复杂语句的流程控制语句,我们用类最原始的 jmp、je/jne 指令来实现。例如,对于如下的 if..else 语句:

if (cond) {

   stmt1

} else {

   stmt2

}

我们会翻译成:

   if (!cond) { goto label_else }

   stmt1

   goto label_done

label_else:

   stmt2

label_done:

可以看到,通过这种方式翻译后的所有语句都在一个相同的大 block 里面,这样用户代码里面的 goto 就可以随意跳转了。

我们再举一个 while 循环语句作为例子:

while (cond) {

   stmt

}

我们会将其翻译成:

label_start:

   if (!cond) { goto label_done }

   stmt

   goto label_start

label_done:

这样,我们就解决了 C 语言中的 switch 和 goto 语句如何翻译成 Go 代码的问题。

哦,对了我们漏了一个细节:如果代码中间出现变量定义怎么办(早期 C 代码中变量定义统一在函数的开头,后来放开了,能够在 block 的开头定义)?

这个简单:我们学习早期版本的 C,把变量定义都挪到函数开头就好了。

当前,我们针对 switch 和 do..while 语句已经做了相关的尝试,验证可行。至于更完整的对所有 C 的流程控制语句的支持,就留给后续 c2go 的版本迭代了。