许式伟

Go+ c2go:假期 3 天迁移 C 标准库的进展

5 月 1 日,我发表了《Go+ c2go 项目一个月:我们遇到的挑战和成果》一文,总结了自 c2go 项目启动一个月以来的进展情况。简单说,我们这一个月支持了几乎所有的 C 文法,并且迁移成功了 sqlite3 自身的代码。从 5 月 2 日开始,我们正式启动 C 标准库的迁移工作。今天就让我们聊聊这 3 天的假期,我们迁移 C 标准库的进展情况。
这一个月,我们基本上以一周一次的频度,来向大家同步 c2go 项目遇到的挑战,以及我们怎么克服这些挑战。阅读这些内容,将有助于大家理解 C 与 Go 这两门语言之间的异同,以便更深去掌握这两门语言。如果你想更多了解 c2go 项目,可以参考以下文章:
和其他 C 库不太一样,C 标准库有很多个实现版本。首先,每一种编译器会有自己的 C 标准库。最常见的有:gcc/g++ 版本、llvm 版本、vc++ 版本。当然,也有一些知名的独立于编译器的 C 标准库的实现版本,如:glibc、musl 等。
所以要迁移 C 标准库,第一个话题就是:选哪个版本来迁移。以下是这些 C 标准库源代码在 github 上的仓库(vc++ 除外):
  • gcc: https://github.com/gcc-mirror/gcc(非官方)
  • llvm: https://github.com/llvm/llvm-project
  • glibc: https://github.com/bminor/glibc(非官方)
  • musl: https://github.com/bminor/musl(非官方)
从客户视角来说,当然兼容性最好的选择是选最流行的 C 编译器带所带的 C 标准库来迁移是最好的。但是一方面,有的编译器的 C 标准库已经开始用 C++ 来写了(比较典型的是 llvm),另一方面,编译器带的 C 标准库通常有比较多的历史包袱(向前兼容),代码量一般较大。
所以,在决定选择哪个 C 标准库进行迁移时,我做了一个最简单的判断标准:谁代码量最少。最终,我们选择了 musl。
我们遇到的第二个问题是:如何支持编译多个 C 源文件?虽然我们之前已经成功编译了 11 万行代码(预处理后)的 sqlite3,但是它是单文件的。
你可能奇怪:编译多个 C 源文件有什么难的?其实主要问题在于:多个 C 源文件单独编译生成的 Go 源代码,放在一起会有大量重复定义的类型(主要是头文件中定义的 struct、union、enum 和 typedef)和函数(主要是头文件中定义的 inline 函数),从而导致编译失败。
解决这个问题的思路比较直白:开启多源文件编译模式时,把源代码中属于头文件定义的符号(主要是类型和函数,可能还会有变量)都不再直接放到源文件对应的 .c.i.go 文件中,而是统一放到 c2go_header.i.go 文件。在 c2go_header.i.go 文件中定义的符号,我们自动进行消重。
除此之外,编译 C 标准库还会遇到以下基础问题:
其一,头文件的搜索路径。大部分 C 库编译时需要指定多个 include 目录作为搜索路径。
其二,编译时宏定义(一般用于平台指示)以及编译开关(一般用于抑制编译器的 warning)。
其三,明确哪些函数需要公开。
于是我们开始引入 c2go 的工程文件。
我们引入了 c2go.cfg 和 c2go.pub 两个文件。为了做最小验证,我们并不是一上来就迁移 musl,而是在 testdata 目录建了一个迷你的 libc 工程:
  • github.com/goplus/c2go/tree/v0.6.1/testdata/libc
我们在《Go+ 探究:如何 10 天实现工业级的 C 编译器》一文中有提到,为了测试 C 文法的支持情况,我们尝试移植了 strlen、qsort 和 printf 三个 C 库函数。其中 strlen、qsort 是纯算法,没有额外的依赖,而 printf 本身代码比较简单,但是它依赖 vfprintf 函数,我们为其 mock 了一个。
于是这三个迁移成功的 C 库函数就成了我们第一个验证的目标。它的 c2go.cfg 文件如下:
Image
在这个 c2go.cfg 文件中,我们定义了 target(编译的目标,主要是生成的 Go 工程在什么目录,这个工程的 package name 是什么)、source(源代码的位置,我们可以指定源代码的文件,也可以指定目录)和 include(头文件的搜索路径)。其实我们还支持指定预处理(preprocessor)使用的编译器(默认是 clang),以及指定编译的条件宏和编译开关。
它的 c2go.pub 文件如下:
Image
这个文件比较简单,每行是一个导出的符号(可以是函数、类、变量和常量)。每个公开的 C 符号其对应的在 Go 中的名字默认是自动计算出来的,但也允许自己指定。例如上面 qsort 我们指定了它的 Go 符号为 QSort。
理论上来说,这个 testdata/libc 工程迁移成功后,我们已经可以在 Go+ 中去直接尝试对 C 的支持了(虽然 printf 还没有迁移完它依赖的 vfprintf 是 mock 的状态)。但是这个迷你的 libc 中因为不涉及到 syscall,它只能验证 Go+ 调用 C 的算法没有问题,并不能验证更大风险的部分:C 语言中涉及到操作系统调用的算法是否也可以很好得到支持。
所以,接着我们就开始正式迁移 musl。
我们第一个小目标很简单:迁移完成一个没有任何 mock 的 printf 函数。
它的工程文件 c2go.cfg 如下:
Image
和前面 testdata/libc 工程相比,有两个比较显眼的变化:一个是多了 include(头文件的搜索路径),一个是多了 flags(编译器的编译开关,主要用于抑制 warning)。
这里有一个值得注意的小细节,是 include 搜索路径中有 ./c2go/include 目录,并且排在第一个。这是因为有一些头文件我们是需要进行替换的,最为典型的是 syscall 相关。在 musl 中,syscall 是在头文件中以内嵌汇编的 inline 函数实现的,我们需要对其进行替换。如下:
Image
完成了这些,我们就可以开始代码的迁移了。printf 本身我们之前(在 testdata/libc 中用的 strlen、qsort、printf 函数都来自 musl 这个 C 标准库)已经迁移好了,所以我们第一个正式要迁移的是 vfprintf 函数。
迁移 vfprintf 遇到的第一个问题是一个灵异事件,到现在我还没有找到根因:在命令行通过clang 对 vfprintf.c.i 文件生成 C AST 成功,但是在 c2go 中调用却失败了(clang 的 exit code 非 0)。为了防止是因为 c2go 本身潜在的未知问题引起,我单独建立了一个小测试,写死所有命令行参数,调用 Go 标准库的 exec.Command 来执行,复现了这个问题。
简单检查没找到问题,我选择先跳过它。好在生成的 C AST 是正常可用的,我在 c2go 中直接硬编码如果输入文件是 vfprintf.c.i 则跳过 clang exit code 的错误检查,回避了该问题。
接着就是标准的 C 文法支持的问题。尽管在 sqlite3 中我们已经被蹂躏许多,但是可预期的,每个 C 库作者使用习惯不同,多多少少会遇到一些新问题。下面我们就聊聊这些问题。
第一个问题是之前遇到过的 const 转换问题:把一个负数常量(比如 -1)转成 unsigned 类型。之前处理了赋值、函数调用等场景,这次增加了在 binary operator 中,比如类似 (unsigned int)expr & -1 这样的表达式。
第二个遇到的问题是我们实现的 clang/types/parser 对多维数组支持的 bug。在 C 语言中的 int[3][100] 对应在 Go 中是 [3][100]int,之前错误转为了 [100][3]int 了。
第三个问题是已知的:if (floatExpr) 浮点运算表达式自动转 bool 类型问题。之所以之前没有实现这个,还是想看看到底谁会用这样的语句。大家都知道,比较浮点数需要考虑精度问题,所以这个 if 条件是否合理需要根据具体情况进行分析。
最后一个问题最有意思。我们先看代码:
Image
很简单的 3 行代码,但是这里实际上有两个问题。
其一,初始化单个变量(我们把 char[16] 看做整体,因为里面初始化它用的是字符串)却用了 { ... }。对此如果你觉得问题还不够直观,其实有更简单的:
int a = { 100 };
我们初始化一个整数 a,但是用了 { 100 },而非直接用 100。这样的写法正确么?你可能会疑惑,但是我明确告诉你,100% 符合 C spec。
启动 c2go 这个项目前,我对 C spec 做过比较详细的评估。虽然我的确不清楚 C 标准委员会是怎么脑洞大开想到这样的语法的,但是我的确之前就知道这个语法的存在。我没有实现它,是因为不知道是否会有人用到它。
其二,我们去 { ... } 它就变成了:
static const char xdigits[16] = "0123456789ABCDEF";
这段代码有问题么?有。按照 C 字符串的定义,它的 "0123456789ABCDEF" 在 Go 语言中实际上是 "0123456789ABCDEF\x00",总共 17 个字符,而非 16 个。所以把这个初始化实际上会对赋值进行截断,去掉了最后的 '\x00' 字符。
总体来说,经过 sqlite3 迁移的历练后,C 文法问题已经剩得不多了。
在 vfprintf 迁移成功后,和 sqlite3 一样,我对它执行 c2go -gendeps 命令来自动生成了所有还没有被实现的函数列表。如下:
func ___errno_location() *int32func __fpclassifyl(float64) int32func __fwritex(*uint8, uint, *FILE) uintfunc __lockfile(*FILE) int32func __signbitl(float64) int32func __syscall6(n int64, a int64, b int64, c int64, d int64, e int64, f int64) int64func __syscall_cp(int64, int64, int64, int64, int64, int64, int64) int64func __towrite(*FILE) int32func __unlockfile(*FILE)func frexpl(float64, *int32) float64func isdigit(int32) int32func memset(unsafe.Pointer, int32, uint) unsafe.Pointerfunc strerror(int32) *int8func strnlen(*int8, uint) uintfunc wctomb(*int8, uint) int32

共计有 15 个函数被 vfprintf 依赖。原始的文件可以到这里查看:
  • github.com/goplus/libc/blob/musl-go/c2go_autogen.go
看起来,我们离最后的成功不远了。