cgo关键技术
2021.07

9.2 cgo关键技术

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

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

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

cgo命令

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

import "C"

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

package main

/*
#include "stdio.h"

void test(int n) {
  char dummy[10240];

  printf("in c test func iterator %d\n", n);
  if(n <= 0) {
    return;
  }
  dummy[n] = '\a';
  test(n-1);
}
#cgo CFLAGS: -g
*/
import "C"

func main() {
    C.test(C.int(2))
}

对它执行cgo命令:

go tool cgo test.go

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

.
├── _cgo_.o
├── _cgo_defun.c
├── _cgo_export.c
├── _cgo_export.h
├── _cgo_flags
├── _cgo_gotypes.go
├── _cgo_main.c
├── test.cgo1.go
└── test.cgo2.c

桩文件

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

void
_cgo_1b9ecf7f7656_Cfunc_test(void *v)
{
    struct {
        int p0;
        char __pad4[4];
    } __attribute__((__packed__)) *a = v;
    test(a->p0);
}

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

void
·_Cfunc_test(struct{uint8 x[8];}p)
{
    runtime·cgocall(_cgo_1b9ecf7f7656_Cfunc_test, &p);
}

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

func main() {
    _Cfunc_test(_Ctype_int(2))
}

cgo做这些封装原因来自两方面,一方面是Go运行时调用cgo代码时要做特殊处理,比如runtime.cgocall。另一方面是由于Go和C使用的命名空间不一样,需要加一层转换,像·Cfunctest中的·字符是Go使用的命令空间区分,而在C这边使用的是cgo1b9ecf7f7656_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类型会被转换为Cxxx。

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

mpz_t zero;

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

var _C_zero *C.mpz_t

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

cgo转换中最重要的部分是函数。如果xxx是一个C函数,那么cgo会重写C.xxx为一个新的函数Cxxx,这个函数会在一个标准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代码一个非分段的栈空间并让它脱离与调度系统的交互。