许式伟

Go+ c2go 项目一个月:我们遇到的挑战和成果

一个月前,我写下了《Go+ 下个里程碑:超越 cgo,无缝对接 C 语言》一文,正式宣布启动 c2go 项目。一个月过去了,今天我想谈谈我们遇到的挑战,以及我们收获的成果是什么。当然如果你想了解更多,也可以参考以下两篇文章:

在谈挑战前,我们先来说说成果。
首先,我们几乎完成了所有 C 语言语法的支持。明细如下:

Image

可以看到,未支持的 C 语法功能已经不多了。其中工作量最大的可能是 int128、uint128 类型的支持。这是 C 语言有而 Go 语言没有的数据类型。但是实际上这块工作的大头我们已经做了:Go+ 语言已经支持 int128、uint128 类型。

Image

所以之所以 c2go 还没有去支持它们,只不过是因为我们在实际工程中还没有遇到有 C 代码用到而已。
其次,我们已经用当前版本的 c2go,成功编译了 sqlite3 这个知名的 C 库。编译成功后的结果放在了 goplus 组织下:
  • https://github.com/goplus/sqlite
再次强调一下,编译 sqlite3 过程中我们没有做任何人工干预。为什么我会一再强调没有任何人工干预?因为 C 世界的资源太多了,我们今天任何手工干预实现某个 C 库的迁移得到的成果,对全局来说都不值一提。所以无干预的方式实现 C 库迁移,是 c2go 项目最重要的指导方针。
最后,我们给 c2go 增加了一个 -gendeps 命令行开关,以编译某个 C 库的时候自动生成它的外部依赖信息(放到了一个名为 c2go_autogen.go 文件中)。例如,sqlite3 所依赖的所有外部函数如下:
func __atomic_load_n_i32(*int32, int32) int32func __atomic_load_n_u16(*uint16, int32) uint16func __atomic_load_n_u32(*uint32, int32) uint32func __atomic_store_n_i32(*int32, int32, int32)func __atomic_store_n_u16(*uint16, int32, uint16)func __atomic_store_n_u32(*uint32, int32, uint32)func __builtin___memcpy_chk(unsafe.Pointer, unsafe.Pointer, uint, uint) unsafe.Pointerfunc __builtin___memmove_chk(unsafe.Pointer, unsafe.Pointer, uint, uint) unsafe.Pointerfunc __builtin___memset_chk(unsafe.Pointer, int32, uint, uint) unsafe.Pointerfunc __builtin___strlcat_chk(*int8, *int8, uint, uint) uintfunc __builtin___strlcpy_chk(*int8, *int8, uint, uint) uintfunc __builtin_bswap32(uint32) uint32func __builtin_bswap64(uint64) uint64func __builtin_fabs(float64) float64func __builtin_fabsf(float32) float32func __builtin_fabsl(float64) float64func __builtin_huge_valf() float32func __builtin_inf() float64func __builtin_inff() float32func __builtin_infl() float64func __builtin_object_size(unsafe.Pointer, int32)func __darwin_check_fd_set_overflow(int32, unsafe.Pointer, int32) int32func __error() *int32func __sincos_stret(float64) struct___double2func __sincosf_stret(float32) struct___float2func __sincospi_stret(float64) struct___double2func __sincospif_stret(float32) struct___float2func __swbuf(int32, *FILE) int32func __sync_synchronize()func access(*int8, int32) int32func atoi(*int8) int32func close(int32) int32func confstr(int32, *int8, uint64)func dlclose(__handle unsafe.Pointer)func dlerror() *int8func dlopen(__path *int8, __mode int32) unsafe.Pointerfunc dlsym(__handle unsafe.Pointer, __symbol *int8) unsafe.Pointerfunc fchmod(int32, uint16) int32func fchown(int32, uint32, uint32) int32func fcntl(int32, int32, ...interface{}) int32func flock(int32, int32) int32func fprintf(*FILE, *int8, ...interface{}) int32func fsctl(*int8, uint64, unsafe.Pointer, uint32) int32func fstat(int32, *struct_stat) int32func fstatfs(int32, *struct_statfs) int32func fsync(int32) int32func ftruncate(int32, int64) int32func futimes(int32, *struct_timeval) int32func getcwd(*int8, uint64) *int8func getenv(*int8) *int8func geteuid() uint32func gethostuuid(*uint8, *struct_timespec) int32func getpid() int32func gettimeofday(*struct_timeval, unsafe.Pointer) int32func localtime(*int64) *struct_tmfunc lstat(*int8, *struct_stat) int32func malloc_create_zone(start_size uint64, flags uint32) *struct__malloc_zone_tfunc malloc_default_zone() *struct__malloc_zone_tfunc malloc_set_zone_name(zone *struct__malloc_zone_t, name *int8)func malloc_size(ptr unsafe.Pointer) uintfunc malloc_zone_free(zone *struct__malloc_zone_t, ptr unsafe.Pointer)func malloc_zone_malloc(zone *struct__malloc_zone_t, size uint) unsafe.Pointerfunc malloc_zone_realloc(zone *struct__malloc_zone_t, ptr unsafe.Pointer, size uint) unsafe.Pointerfunc memcmp(__s1 unsafe.Pointer, __s2 unsafe.Pointer, __n uint64) int32func mkdir(*int8, uint16) int32func mmap(unsafe.Pointer, uint, int32, int32, int32, int64) unsafe.Pointerfunc munmap(unsafe.Pointer, uint) int32func open(*int8, int32, ...interface{}) int32func pread(__fd int32, __buf unsafe.Pointer, __nbyte uint, __offset int64) intfunc pthread_create(**struct__opaque_pthread_t, *struct__opaque_pthread_attr_t, func(unsafe.Pointer) unsafe.Pointer, unsafe.Pointer) int32func pthread_join(*struct__opaque_pthread_t, *unsafe.Pointer) int32func pthread_mutex_destroy(*struct__opaque_pthread_mutex_t) int32func pthread_mutex_init(*struct__opaque_pthread_mutex_t, *struct__opaque_pthread_mutexattr_t) int32func pthread_mutex_lock(*struct__opaque_pthread_mutex_t) int32func pthread_mutex_trylock(*struct__opaque_pthread_mutex_t) int32func pthread_mutex_unlock(*struct__opaque_pthread_mutex_t) int32func pthread_mutexattr_destroy(*struct__opaque_pthread_mutexattr_t) int32func pthread_mutexattr_init(*struct__opaque_pthread_mutexattr_t) int32func pthread_mutexattr_settype(*struct__opaque_pthread_mutexattr_t, int32) int32func pwrite(__fd int32, __buf unsafe.Pointer, __nbyte uint64, __offset int64) int64func random() int64func read(int32, unsafe.Pointer, uint) intfunc readlink(*int8, *int8, uint) intfunc rename(__old *int8, __new *int8) int32func rmdir(*int8) int32func sleep(uint32) uint32func srandomdev()func stat(*int8, *struct_stat) int32func statfs(*int8, *struct_statfs) int32func strcmp(__s1 *int8, __s2 *int8) int32func strcspn(__s *int8, __charset *int8) uintfunc strlen(__s *int8) uintfunc strncmp(__s1 *int8, __s2 *int8, __n uint) int32func strrchr(__s *int8, __c int32) *int8func sysconf(int32) intfunc sysctlbyname(*int8, unsafe.Pointer, *uint, unsafe.Pointer, uint) int32func time(*int64) int64func unlink(*int8) int32func utimes(*int8, *struct_timeval) int32func write(__fd int32, __buf unsafe.Pointer, __nbyte uint) int
 
外部依赖的函数刚好凑整 100 个。
这个信息给我们下一步迁移 C 标准库的工作指明了方向。我们可以对这 100 个函数简单归类,分为以下几类:
其一,纯算法类。如字符串操作、浮点运算等。这一类最好移植,基本上不太依赖外部的东西。
其二,内建函数。这类函数本身不复杂,它的挑战有可能在于性能,由于在意性能,往往采用了汇编代码。原子操作(atomic)是典型的例子。好在这类函数不多,移植的工作量基本可控。
其三,系统调用相关。如文件、时间、进程相关等。这类无非到最后依赖一个 syscall,其他的代码通常是纯算法相关。这里比较典型的是 printf、fprintf 系列的函数,虽然复杂,但是到最底层也无非就是一个 fd 的写操作。
其四,动态库相关。动态库相关的支持是比较复杂的。这主要是因为它和我们 c2go 选择的源代码兼容的方向相反,它基于的是二进制兼容的方式。而二进制兼容我们不得不又回到 Go 的老路:用 cgo 的方式。
当然,二进制兼容是否可以不选择 cgo 这个方向?存在这个可能性,我们前段时间对此专门进行过一次技术论证,确定有机会。
好在 sqlite3 虽然依赖动态库但是只是用来加载插件,所以这个话题暂时不在优先级上。我们以后再展开。
其五,pthread 相关。这块的支持也比较复杂。我们前面在《Go+ 探究:如何 10 天实现工业级的 C 编译器》一文中已经略微展开了这个话题,我们今天展开讨论。
这里的难点在于 Go 与 C 线程模型的差异。Go 语言中是 goroutine,C 语言用的是操作系统的线程。最典型的,是 goroutine 并不支持 TLS(线程局部存储)。如果我们用 goroutine 来实现 C 的 pthread 标准库,无疑工作量会非常大。但如果我们用操作系统的线程,则与 Go 的代码在协同上会有很多坑。我们不能在普通 Go 代码中调用 pthread 的线程协同机制进行协同,也不能在 pthread 线程下运行的 Go 代码中调用 Go 的 "sync" 标准库,这些行为都可能将导致 Go 程序的行为不可预期。
如何选择?我们决定基于 goroutine 重新实现 pthread 标准库。这样做虽然工作量大,但是是一劳永逸的:以后 C 代码再也不会和 Go 打架了。
说完了我们的成果和下一步的行动,我们来说说这一个月我们遇到的挑战。
如果我们从难到简单排序,第一大挑战还是 switch 和 goto 语句的支持。这个话题我们在《c2go: 通过 sqlite3 迁移实践重新认识 C 语言》中已经详细展开过分析。但是分析归分析,我们当时实现的复杂语句标记算法实际上是非常简陋的。
而 sqlite3 对 switch 和 goto 的使用,那真叫一个变态。这其中又以一个名为 sqlite3VdbeExec 的函数为最。我们贴一小段代码感受一下。

Image

上图几个到处乱飞的 goto 语句就不说了,比较神奇的是 case 语句。注意这里 case 101 和 case 112 是在 case 100 里面的,这种诡异的 switch..case 别说我们没见过,vscode 也没见过,所以截图中后两个 case 关键字都没高亮。
这个函数是做什么的呢?它实际上是 sqlite3 最核心的代码:字节码执行引擎。它相当于实现了一个虚拟的 CPU。你输入 SQL 语句给 sqlite 执行,sqlite 会先把 SQL 编译成字节码,最后交给 sqlite3VdbeExec 这个函数执行。
并不是只有这个函数特殊。整个 sqlite3 总共 11 万行代码,到处都是 switch 和 goto。我试了几次简单的经验判定复杂语句的方法都行不通,总会遇到新情况,最后下定决心,从头实现了最严谨的复杂语句标记算法(见 c2go/cl/stmtchk.go 文件,大约 300 行代码)。
这还不算。由于复杂语句的存在,所有的结构控制语句(包括:简单 block、if..else、switch..case、do..while、for、while 等),都需要实现两个版本:常规版和“汇编版(这里的汇编只是形象比喻,实际指所有语句推平到一个平面,不能有复合语句)”。除了 for 语句目前汇编版还只是简单 panic 没有实现(sqlite3 中没遇到)外,其他所有都已经涉及。
第二大挑战是类型转换。它的麻烦在于各种情况太多了。我们一一来看。
其一,指针类型转换。A* p 转为 B*,在 C 语言中直接写 (B*)p 就行。但是 Go 不行,你需要借助 unsafe.Pointer 中转一下:(*B)(unsafe.Pointer(p))。
其二,整数与指针类型转换。在 C 语言中直接写 (T*)n 就行,但是在 Go 语言中你需要额外增加二次转换:先转成 uintptr,然后转 unsafe.Pointer,最后才能转具体的指针类型。特别地,在 C 语言中没有 nil,整数 0 代表 nil,这个特殊情况需要处理一下,避免多余的转换,让代码显得冗长。
其三,函数指针类型转换。这个是最麻烦的,和普通指针类型不同,在 Go 语言中函数指针无法和 unsafe.Pointer 之间进行转换。比如我们把 fn func() 转为 func(int) int 类型,你不能用 (func(int) int)(unsafe.Pointer(fn))。
怎么办?我们得用到闭包:
func(fn func()) func(int) int {    return *(*func(int) int)(unsafe.Pointer(&fn))}(fn)

我们得先得到函数指针的指针,把它转为 unsafe.Pointer,然后再转为另一种函数指针的指针,最后取值即可。
其四,整数/普通指针类型与函数指针类型间转换。除了整数需要 uintptr 类型做中转,其他方面大逻辑和上一条差不多,这里不展开。
其五,常数(untyped int)转整型类型。你可能会奇怪,这不是自动的么,怎么会遇到麻烦?这主要涉及常量值与整型类型范围的匹配问题。在 C 语言中,你写 (unsigned int)-1 或者 (short)0x8000 都是没问题的。但是在 Go 语言中,uint(-1) 和 int16(0x8000) 都会编译不过,因为 -1 不在 uint 表达的范围内,0x8000 不在 int16 的表达范围内。
怎么办?我们得在 c2go 中算清楚,到底 (unsigned int)-1 具体值是多少,然后把 -1 换成正确的值。这里会遇到跨平台的挑战,因为不同平台下可能 int 的长度不同,这里得到的值也不同。如果要想生成的代码跨平台,而不是每个平台重新编译一遍,需要额外花点心思。跨平台要考虑的问题不只是这一点,这个话题我们下文再展开。
第三大挑战是各种 Go 中没有直接对应的功能。最典型的是:联合体(union)、位域(bit field)以及大整数(int128/uint128)。关于这一点,我们在《c2go: 通过 sqlite3 迁移实践重新认识 C 语言》中已经详细展开过讨论,不再赘述。
剩下来的,算不上大挑战,但是不少内容也值得一说。
比如,在 C 语言中各种相对随意的功能要兼容。最典型的是:函数有返回值却可以漏写 return 语句(这个大家应该比较熟知)、同一个变量允许声明多遍(不需要加 extern,只需要初始化只做一遍,这一点估计大家不熟知)等等。
还有一个细节是完全意想不到的,那就是 sqlite3 定义了 uint 类型,和 Go 的内置 uint 类型同名了。本来这不是什么问题,Go/Go+ 都允许你这么做。但是不幸的是 c2go 用内建的 uint 类型来表达 C 的 size_t 等类型了。在 go/types 这个层面,这两个 uint 的是不同的类型,能够非常准确的区分。但是翻译成 Go 代码时这种区分没法表达,所有的 uint 都会被理解为 sqlite3 定义的 uint 类型了。为了避开此问题,我们不再使用 uint,而是在 64bits 平台下用 uint64,在 32bits 平台下用 uint32。但这意味着他和前面常数(untyped int)转整型类型一样,转换后的代码是不跨平台的,需要每个平台单独执行 c2go 转换。
关于如何确保不同平台生成的 Go 代码一致,是一个隐含的大挑战。除了今天我们谈的两点外,最大的坑其实是 C 语言的预处理(preprocessor)过程,这个过程有大量的宏定义和平台分支的判断,才是最麻烦的事情。
我们未来再找机会进一步探讨此问题。
最后总结一下:一句话,这一个月我们的成果是斐然的。我们用一个月实际上完成了其他同类项目 5 年没有完成的事情。我们前面在 《Go+ 下个里程碑:超越 cgo,无缝对接 C 语言》 中吹牛说 6 个月完成第一个 c2go 可用的版本,相信大多数人是完全不信,或者将信将疑,没有多少人会真相信。但是通过这一个月的实践,多数人可以选择相信了。
当然接下来 C 标准库的移植工作仍然工作量很大,我们仍需努力。