
这一个月,我们基本上以一周一次的频度,来向大家同步 c2go 项目遇到的挑战,以及我们怎么克服这些挑战。阅读这些内容,将有助于大家理解 C 与 Go 这两门语言之间的异同,以便更深去掌握这两门语言。如果你想更多了解 c2go 项目,可以参考以下文章:上周我们已经初步完成了迁移 libc 的第一个小目标:迁移 printf 及相关的函数。看似简单的目标,实际上却并不简单:为了迁移 printf,我们差不多迁移了整个 libc 10% 的代码量。
小目标实现了,接下来的问题是:我们如何确保迁移的代码是对的?对 c2go 来说,我们基本上可以把它面临的挑战分为以下三个阶段:其一,让它编译通过(Make it passed)。通过 sqlite3 和 libc printf 的迁移,这个阶段的目标我们完成得差不多了。接下来零零星星会有一些语法兼容的 bug 被发现,但是不再是我们的工作重心。其二,让它正确地工作(Make it correct)。这是我们接下来的工作重点,也是我们今天探讨的话题核心。详细下文展开。其三,让它性能足够好(Make it fast)。我的目标是性能要基本接近于 C。如果不如 C,我们就看不如在什么地方,改掉它。可能有人会觉得 Go 的 gc 会拖累性能,但是我认为这并不成立。我们 C 代码翻译得到 Go 代码并不依赖 gc,它也是手工管理内存的。这个优化工作可能有一些会由 c2go 完成,一些可能会由 Go 语言本身来进行优化。对于后者,当前已经有非常多人参与其中(据我所知包括 gollvm 等项目也都很活跃,在各种尝试去提升 Go 的执行性能)。对 c2go 来说,良好的品质保障,让它正确地工作是至关重要的。两周前我开始考虑这个问题。我的第一直觉是要给 libc 增加足够强度的测试案例,提升它的代码覆盖率。这样做好处是双重的,既保障了 libc 的质量,也验证了 c2go 的质量。自己手写测试案例当然是一个可能性,但是 libc 必然会有很多现成的测试案例。很快,我们就锁定了 musl 这个 C 标准库自己的 libc-test 项目。我们把它录入到 libc 中:- github.com/goplus/libc/tree/musl-go/test
这个过程中,第一个有意思的话题是如何自动实现模块化。我们知道,C 的所有符号是平面的,虽然基于链接(link)工作的需要,它也需要模块(module)这样的实体。但光从语法层面来说它并不存在模块的概念,你无法通过 C 文法来快速确定某个符号(包括函数、类型、变量及常量)它隶属于哪个模块。其一,符号归属问题。我们需要快速确认某个模块属于哪个模块,把 foo 改为 xxx.Foo 这样的引用形式。其二,头文件归属问题。之前多个 C 源文件属于同一个模块的时候,我们把他们依赖的所有头文件的代码,统一生成到 c2go_header.i.go 中。但是现在多了一种可能:某个头文件是隶属于另外一个模块的,编译的时候应该忽略它(改为引用已经编译好的模块)。怎么用它?我们给 c2go.cfg 增加 deps 选项,让一个模块能够声明自己依赖的模块列表。这样 c2go 在编译改模块前,会先读取它依赖的所有模块的 c2go.pub 文件加载所有这些公开的符号。这样在后面编译时遇到这些符号的时候,就可以正确识别它了。当然这里有一个小细节需要补充交代一下:考虑到一些模块的导出符号会很多,我们给 c2go 增加了一个自动根据指定的头文件列表来生成导出符号表(详细参见 c2go.cfg 中的 public.from 字段)。例如 libc 当前会通过 public.from 指定导出所有的基础类型:我们会合并通过 public.from 抽取的符号表和通过 c2go.pub 手工指定的符号表,最终得到该模块所有公开符号,把它存入到 c2go.a.pub 文件中。所以实际符号归属检测功能读取的不是 c2go.pub,而是 c2go.a.pub 文件。我们假定 c2go.pub 不一定会存在,但是 c2go.a.pub 文件一定存在(对于非 main 模块,用 c2go 编译就会自动生成它)。这个 c2go.a.pub 文件并不是只有 c2go 会用到它,实际上 Go+ 编译器可能也会用到它。这是为什么呢?我们考虑以下 Go+ 代码:import "C/sqlite"
var db *C.sqlite3rc := C.sqlite3_open(dbfile, &db)if rc != 0 { ... // 错误处理}defer C.sqlite3_close(db)... // 正常使用 sqlite
这种风格的好处是保持了和 C 代码完全一致的写法,但是它需要依赖读取 c2go.a.pub 文件来确定符号归属的模块。对于头文件归属问题,我们采用了一种智能判断归属的方式。由于每个模块编译的时候都会有自己的 include 搜索路径,我们做了一个基础的假定:不同模块不会把头文件放到在同一个目录。这样,只要发现该头文件在哪个模块的 include 搜索路径中被找到,就可以界定这个头文件隶属于谁。如果某个头文件同时在多个模块(比如 A 和 B)的 include 搜索路径找到,那么界定为属于更基础的模块拥有(比如如果模块 B 依赖模块 A,那么我们就判定隶属于 A)。接下来我们聊聊 musl libc-test 的迁移工作。libc-test 测试系统的设计思路非常简洁:每个测试案例是一个独立的可执行文件,如果执行正常,则不产生任何输出,程序的 exit status 为 0;如果测试出错,则通过 t_printf 函数打印错误信息,程序的 exit status 非 0。首先,之前我们每个 c2go 工程都代表了一个模块(对应到 Go 语言中是包)。但是现在每个测试案例都是一个独立的程序,我们不可能建立很多 c2go 工程单独进行转换。我们的应对策略是:增加 target.cmds 字段。也就是说,一个模块除了主模块外,允许额外定义若干个命令(cmds)形式的辅助模块。以下是 libc test 工程 target.cmds 的样子:另一个问题是,虽然我们可以在 libc 中通过调用 exec.Command 执行这些测试案例,但是这种测试方式有一个问题是,它测试了哪些代码没法进行统计,我们拿不到正确的测试覆盖率。我们给 c2go 引入了一个新的编译开关:c2go -testmain。它指示编译器生成 func TestMain(t *testing.T) 作为该模块的 entry,而不是常规的 func main() 函数。这样,这些 target.cmds 中的程序同时支持两种编译方式:一种是编译成可执行程序执行,一种是编译成一个测试工程,供 libc 的单元测试调用。在命名上,如果编译的可执行程序放在 cmd/test_qsort 目录下的话,那么编译出来的测试工程放到 cmd/test/qsort 下。这样,这些 libc-test 的代码就如同我们自己写的常规 Go 测试代码一样工作了。当前 libc 测试代码的覆盖率在 37% 左右。这比上周 printf 刚移植完成时 7% 的测试覆盖率要好很多了(这还是在我们增加了不少 C 标准库函数的情况下)。当然目前的测试覆盖率具体值并不是重点。最重要的是有了前面这些准备工作,后面我们就可以以非常快的节奏地去迭代 c2go 和 libc 的品质。接下来我们照例说说我们迁移中遇到的 C 文法上的问题。整体来说,文法上的问题已经越来越少了。本周遇到最大的挑战主要是两个:va_arg 和 static 关键字。va_arg(ap, type) 是用来获取函数的可变参数。之前我们简单用类型断言(type assert)实现,但这并不太符合 C 的语义。在 C 中,你可以传入一个 int,但是取的时候用 unsigned int。传入一个 char*,取的时候用 void*。本来我想是不是字节数要一样,但是实际上传入 size_t(在 64 位平台下是 uint64),取的时候用 int(和 Go 的 int 不同,C 的 int 实际上是 int32 类型)也是可以的。完备实现 va_arg 是很繁琐的,需要我们实现 any 类型到某个具体 type 的转换。理论上来说,这里面需要判断的东西非常多,整个 va_arg 这个指令(它不是常规意义上的函数)会非常复杂。当前我们的实现并不求通用性,而只是把最常规的一些用户使用姿势给覆盖了。另一个问题是 static。在上一篇《Go+ c2go: 实现迁移 C 标准库的第一个小目标》中我们已经提到过全局的 static 变量或函数的问题。这次我们遇到的问题是 static 局部变量。在 C/C++ 中,static 局部变量类似全局变量,只不过它在函数首次被调用时进行初始化,但是它的状态在这个函数下一次被调用的时候会被保留下来。C 标准库中利用这个特性最知名的函数是 strtok。由于 c2go 没有正确实现 static 局部变量的逻辑,strtok 函数的测试案例一测试就暴露了。当前我们采用了一种简化逻辑来修复 static 局部变量的实现:直接把 static 变量及其初始化调整为全局变量。这个简化对语义是有变化的,主要是函数没有调用时这个初始化就被执行了。这对 C 来说一般不是什么问题,但 C++ 程序(包括我自己以前写的)往往会利用这里的 static 变量的初始化时机来达成某些目的,在这种使用方式下,有一定概率程序逻辑会不符合预期(主要是变量初始化次序上的问题)。另外实际上 static 变量本身会有多线程问题。这一点和 C 全局的 errno 是完全一样的。由于 static 变量并不基于 TLS,所以它在多线程情况下可能会产生竞争。在 C 库的使用规范中,strtok 一般来说都是极不被推荐使用的库函数。最后总结一下。从这篇文章开始,c2go 项目的侧重点发生了很重要的变化。我们已经过了 Make it passed 阶段,开始把重心放到了 Make it correct,做好品质保障这件事情上了。为此,c2go 在过去的这一周重点添加了模块自动切分和品质保障的基础能力建设。而所有这一切,都为了最终交付足够鲁棒好用的 Go+ 语言下引用 C 的体验。