9.2 cgo关键技术

上一节我们看了一些预备知识,解答了前面的一点疑惑。这一节我们将接着从宏观上分析cgo实现中使用到的一些关键技术。而对于其中一些细节部分将留到下一节具体分析。

整个cgo的实现依赖于几个部分,依赖于cgo命令生成桩文件,依赖于6c和6g对Go这一端的代码进行编译,依赖gcc对C那一端编译成动态链接库,同时,还依赖于运行时库实现Go和C互操作的一些支持。

cgo命令会生成一些桩文件,这些桩文件是给6c和6g命令使用的,它们是Go和C调用之间的桥梁。原始的C文件会使用gcc编译成动态链接库的形式使用。

cgo命令

gc编译器在编译源文件时,如果识别出go源文件中的

  1. import "C"

字段,就会先调用cgo命令。cgo提取出相应的C函数接口部分,生成桩文件。比如我们写一个go文件test.go,内容如下:

  1. package main
  2. /*
  3. #include "stdio.h"
  4. void test(int n) {
  5. char dummy[10240];
  6. printf("in c test func iterator %d\n", n);
  7. if(n <= 0) {
  8. return;
  9. }
  10. dummy[n] = '\a';
  11. test(n-1);
  12. }
  13. #cgo CFLAGS: -g
  14. */
  15. import "C"
  16. func main() {
  17. C.test(C.int(2))
  18. }

对它执行cgo命令:

  1. go tool cgo test.go

在当前目录下会生成一个_obj的文件夹,文件夹里会包含下列文件:

  1. .
  2. ├── _cgo_.o
  3. ├── _cgo_defun.c
  4. ├── _cgo_export.c
  5. ├── _cgo_export.h
  6. ├── _cgo_flags
  7. ├── _cgo_gotypes.go
  8. ├── _cgo_main.c
  9. ├── test.cgo1.go
  10. └── test.cgo2.c

桩文件

cgo生成了很多文件,其中大多数作用都是包装现有的函数,或者进行声明。比如在test.cgo2.c中,它生成了一个函数来包装test函数:

  1. void
  2. _cgo_1b9ecf7f7656_Cfunc_test(void *v)
  3. {
  4. struct {
  5. int p0;
  6. char __pad4[4];
  7. } __attribute__((__packed__)) *a = v;
  8. test(a->p0);
  9. }

在_cgo_defun.c中是封装另一个函数来调用它:

  1. void
  2. ·_Cfunc_test(struct{uint8 x[8];}p)
  3. {
  4. runtime·cgocall(_cgo_1b9ecf7f7656_Cfunc_test, &p);
  5. }

test.cgo1.go文件中包含一个main函数,它调用封装后的函数:

  1. func main() {
  2. _Cfunc_test(_Ctype_int(2))
  3. }

cgo做这些封装原因来自两方面,一方面是Go运行时调用cgo代码时要做特殊处理,比如runtime.cgocall。另一方面是由于Go和C使用的命名空间不一样,需要加一层转换,像·_Cfunc_test中的·字符是Go使用的命令空间区分,而在C这边使用的是_cgo_1b9ecf7f7656_Cfunc_test。

cgo会识别任意的C.xxx关键字,使用gcc来找到xxx的定义。C中的算术类型会被转换为精确大小的Go的算术类型。C的结构体会被转换为Go结构体,对其中每个域进行转换。无法表示的域将会用byte数组代替。C的union会被转换成一个结构体,这个结构体中包含第一个union成员,然后可能还会有一些填充。C的数组被转换成Go的数组,C指针转换为Go指针。C的函数指针会被转换为Go中的uinptr。C中的void指针转换为Go的unsafe.Pointer。所有出现的C.xxx类型会被转换为_C_xxx。

如果xxx是数据,那么cgo会让C.xxx引用那个C变量(先做上面的转换)。为此,cgo必须引入一个Go变量指向C变量,链接器会生成初始化指针的代码。例如,gmp库中:

  1. mpz_t zero;

cgo会引入一个变量引用C.zero:

  1. var _C_zero *C.mpz_t

然后将所有引用C.zero的实例替换为(*_C_zero)。

cgo转换中最重要的部分是函数。如果xxx是一个C函数,那么cgo会重写C.xxx为一个新的函数_C_xxx,这个函数会在一个标准pthread中调用C的xxx。这个新的函数还负责进行参数转换,转换输入参数,调用xxx,然后转换返回值。

参数转换和返回值转换与前面的规则是一致的,除了数组。数组在C中是隐式地转换为指针的,而在Go中要显式地将数组转换为指针。

处理垃圾回收是个大问题。如果是Go中引用了C的指针,不再使用时进行释放,这个很容易。麻烦的是C中使用了Go的指针,但是Go的垃圾回收并不知道,这样就会很麻烦。

运行时库部分

运行时库会对cgo调用做一些处理,就像前面说过的,执行C函数之前会运行runtime.entersyscall,而C函数执行完返回后会调用runtime.exitsyscall。让cgo的运行仿佛是在另一个pthread中执行的,然后函数执行完毕后将返回值转换成Go的值。

比较难处理的情况是,在cgo调用的C函数中,发生了C回调Go函数的情况,这时处理起来会比较复杂。因为此时是没有Go运行环境的,所以必须再进行一次特殊处理,回到Go的goroutine中调用相应的Go函数代码,完成之后继续回到C的运行环境。看上去有点复杂,但是cgo对于在C中调用Go函数也是支持的。

从宏观上来讲cgo的关键技术就是这些,由cgo命令生成一些桩代码,负责C类型和Go类型之间的转换,命名空间处理以及特殊的调用方式处理。而运行时库部分则负责处理好C的运行环境,类似于给C代码一个非分段的栈空间并让它脱离与调度系统的交互。