Jason Pan

CGO 基本用法与构建过程

潘忠显 / 2021-06-25


Go 语言使用 CGO 的工具来调用 C 语言函数,也可以将 Go 语言函数导出为 C 动态库接口给其它语言使用。

本文结合代码(cgo-stage-by-stage),介绍一些 demo 的构建,帮助理解 CGO 的基本用法及其构建过程。

本文的所有示例使用 Go 1.6 构建,安装或升级过程可以参考安装 Go 1.6

一、基本用法

Stage 1 中主要介绍使用 CGO 的基本方式,每个例子都可以在对应的目录下(stage1-*),使用 go build -o main . 指令进行构建。也可以使用根目录下的工具脚本进行一步构建 sh tools.sh build

对应每个 Case,主要会展示:

  1. 只有 import "C"
  2. Go 中使用 C 标准库
  3. 使用 Go 注释中定义的 C 函数
  4. 使用 extern 的 C 函数
  5. Go 中使用的 C 头文件和源文件
  6. Go 中使用的 C++ 头文件和源文件
  7. 在 Go 中使用 Go 中定义的 C 函数
  8. Go 函数的导出(export)
  9. C 代码中调用导出的 Go 函数

这 9 个 Case 主要展示了 import "C" 的作用、Go 中引用 C 代码、C 种引用 Go 代码,接下来做详细的说明。

1. import "C" 的作用

Go 中使用 CGO 是通过一条 import "C" 指令来指明。

“C” 是一个伪包(“pseudo-package”),import C 之前的注释是 C 代码,可以包含:

2. Go 中引用 C 代码

可以做的:

n, err = C.sqrt(-1)
_, err := C.voidFunc()
var n, err = C.sqrt(1)

不能做的:

特殊情况:

3. C 中引用 Go 函数

Go 中定义的函数,通过会通过 CGO 导出(export) 成 C 代码,所有定义都能在 header 文件 _cgo_export.h 中找到。在 C 代码中 include "_cgo_export.h",使用 extern 函数定义进行使用。

如 Go 中定义函数(//export MyFunction该注释是必须的):

//export MyFunction
func MyFunction(arg1, arg2 int, arg3 string) int64 {...}

在 C 中使用:

extern GoInt64 MyFunction(int arg1, int arg2, GoString arg3);

上边的 Go 代码中的 int 类型和 string 类型分别被映射到 C 中的 intGoString 类型,但并不是所有的 Go 中的类型都能进行映射到 C 类型:

二、使用 CGO 构建时发生了什么

无论是 Go 调用 C 代码,还是导出 Go 函数供 C 调用,都能通过一条 go build 指令,进行构建成功。这条指令屏蔽了中间的过程,包括编译、汇编、链接。就像调用一条 gcc main.c,就能直接得到二进制文件一样。本章将两种情况下的构建细节进行拆解,看看详细的构建过程。

本章内容参考自 Yugui Sonoda 的两篇文章,Yugui 还提供了两个示例和 Makefile,为了更好的理解构建过程,我将 make 的过程改写为 build.sh 的脚本。

本章代码对应的示例代码和构建脚本在 Stage 2-1 和 Stage 2-2 中。

1. Go 调用导入的 C 函数

Stage 2-1 中包括了两个源文件:

依赖关系:main.go -printSqrt()-> import_example.go -sqrt()-> libm.a

1.1 构建过程

a. 生成代码

env CGO_LDFLAGS=-lm go tool cgo -objdir $tmpobjdir -importpath ./ import_example.go

利用 cgo 命令在内部调用 C 的预处理器处理 import "C" 及其中的 C 代码片段,并在 $tmpobjdir 目录下构建出以下文件:

b. 编译 C 代码

将自动生成的 .c 文件进行编译,得到对应文件名的 .o 文件

gcc -c _cgo_export.c _cgo_main.c import_example.cgo2.c

c. C代码链接

将产生的 .o 文件进行链接,产生 _cgo_.o 文件,因为例子中有使用到 libm 库中的 sqrt 函数,因此在链接的时候需要携带 -lm 的选项,将 libm.a 静态的链接进去。

gcc -o _cgo_.o _cgo_export.o _cgo_main.o import_example.cgo2.o -lm

d. 生成导入声明

将前面产生的 _cgo_.o 作为 cgo 的输入,产生 _cgo_import.go 文件,用于后续传递指令给 Go 链接器。

go tool cgo -objdir $tmpobjdir -dynpackage main \
  -dynimport $tmpobjdir/_cgo_.o -dynout $tmpobjdir/_cgo_import.go

e. 编译 Go 代码

将之前产生的包含 C 函数调用相关的 _cgo_gotypes.go、已替换 C 调用的 import_example.cgo1.go 以及 用于指示链接的 _cgo_import.go 以及主函数文件 main.go 文件进行编译,并生成 example1.a 的静态库。

go tool compile -o example1.a -p ./ -pack \
  $tmpobjdir/_cgo_gotypes.go $tmpobjdir/import_example.cgo1.go $tmpobjdir/_cgo_import.go main.go

f. 重新链接 C 代码

将之前编译的 C 对象文件重新链接成另外一个 _all.o 文件。与 _cgo_.o 的不同的,由于指定了 -Wl,-r_all.o 并没有链接 _cgo_main.o 以及所依赖的库 (-r 的意思是"Produce a relocatable object as output. This is also known as partial linking")。

gcc -nostdlib -o _all.o _cgo_export.o import_example.cgo2.o -Wl,-r

g. 打包 C 对象符号

_all.o 添加到 example1.a 中:

go tool pack r example1.a $tmpobjdir/_all.o

h. 链接 .a 中的对象

最后链接 ar 文件中的对象和依赖包中的对象,产生 ELF 文件 example1

go tool link -o example1 example1.a

1.2 派生文件过程与关系图

img

这一过程展示了 go build 的整个过程中对 C 遍历器工具链的使用:

  1. 产生桥接代码
  2. 使用 C 编译器和连接器解析 C 代码中所有的依赖,并产生 _cgo_.o 文件
  3. 解析 _cgo_.o 文件并提取以供 _cgo_import.go 文件使用
  4. 将 Go 和 C 各自生成的对象文件合并成同一个文件
  5. 链接成可执行文件,而可执行文件依赖于一些 C 动态库

2. Go 导出函数供 C 调用

除了 Go 等调用 C 函数外,Go 还能将 Go 中定义的函数导出,以供 C 语言调用。

Stage 2-2 会介绍 C 调用导出 Go 函数的构建过程,其中包括了源文件:

依赖关系: main.go -print_go_version()-> used_exported.c -goVersion()-> export_example.go

其实 Case 2 中不仅包含了 C 调用 Go 函数的过程,还包含了 Go 调用 C 函数的过程。

2.1 构建过程

a. 生成桥接代码

go tool cgo -objdir $tmpobjdir export_example.go main.go

这条指令产生的文件中包括一个 _cgo_export.h,该文件由三个主要部分组成:

b. 编译 C 代码

gcc -c _cgo_export.c _cgo_main.c export_example.cgo2.c main.cgo2.c ../use_exported.c -I.. -I.

c. 链接 C 代码

gcc -o _cgo_.o _cgo_export.o _cgo_main.o export_example.cgo2.o main.cgo2.o use_exported.o

d. 生成导出声明

go tool cgo -objdir $tmpobjdir -dynpackage main \
  -dynimport $tmpobjdir/_cgo_.o -dynout $tmpobjdir/_cgo_export.go

e. 编译 Go 代码

go tool compile -o example2.a -p ./ -pack \
  $tmpobjdir/_cgo_gotypes.go $tmpobjdir/export_example.cgo1.go \
  $tmpobjdir/_cgo_export.go $tmpobjdir/main.cgo1.go

f. 重新链接 C 代码

gcc -nostdlib -o _all.o _cgo_export.o export_example.cgo2.o main.cgo2.o use_exported.o -Wl,-r

g. 打包 C 对象符号

go tool pack r example2.a $tmpobjdir/_all.o

h. 链接 .a 中的对象

go tool link -o example2 example2.a

2.2 派生文件过程与关系图

img

构建步骤、文件关系图和导入 C 代码的情形基本上相同,有几点不同之处是:

  1. go tool cgo 由于源文件由一个变成了两个(因为两个go中都由 import "C"),文件数量有所增加
  2. 增加了由 use_exported.c 创建出的 use_exported.o,而其中 #include 了文件_cgo_export.h 是在 go tool cgo时生成的

3. 胶水代码

在 Go 导出函数供 C 调用的示例中,go tool cgo 产生的 _cgo_export.c,通过这个文件,export_example.go 文件中可以调用函数 func goVersion() string,但在 C 文件中调用的 goVersion() 函数原型和 func goVersion() string 并不相同。

_cgo_export.c 文件内容如下:

/* Created by cgo - DO NOT EDIT. */
#include "_cgo_export.h"

extern void crosscall2(void (*fn)(void *, int), void *, int);
extern void _cgo_wait_runtime_init_done();

extern void _cgoexp_5e3e4b09c83e_goVersion(void *, int);

GoString goVersion()
{
        _cgo_wait_runtime_init_done();
        struct {
                GoString r0;
        } __attribute__((__packed__, __gcc_struct__)) a;
        crosscall2(_cgoexp_5e3e4b09c83e_goVersion, &a, 16);
        return a.r0;
}

其中,crosscall2() 函数实际是使用 runtime.cgo.crosscall2(),该函数是用汇编代码实现的,实现的功能相当于调用了 _cgoexp_5e3e4b09c83e_goVersion(),调用时传入结构体 a

_cgo_wait_runtime_init_done 函数如字面含义所示,是保持阻塞直到 runtime 包被初始化。而这里被调用的函数 _cgoexp_5e3e4b09c83e_goVersion() 是在 _cgo_gotypes.go 文件中被定义的:

/* ... */

//go:linkname _cgo_runtime_cgocallback runtime.cgocallback
func _cgo_runtime_cgocallback(unsafe.Pointer, unsafe.Pointer, uintptr)

/* ... */

//go:cgo_export_dynamic goVersion
//go:linkname _cgoexp_5e3e4b09c83e_goVersion _cgoexp_5e3e4b09c83e_goVersion
//go:cgo_export_static _cgoexp_5e3e4b09c83e_goVersion
//go:nosplit
//go:norace
func _cgoexp_5e3e4b09c83e_goVersion(a unsafe.Pointer, n int32) {
    fn := _cgoexpwrap_5e3e4b09c83e_goVersion
    _cgo_runtime_cgocallback(**(**unsafe.Pointer)(unsafe.Pointer(&fn)), a, uintptr(n));
}

func _cgoexpwrap_5e3e4b09c83e_goVersion() (r0 string) {
    defer func() {
        _cgoCheckResult(r0)
    }()
    return goVersion()
}

最后会调用 _cgo_runtime_cgocallback() 函数(对应runtime.cgocallback())通过调整栈,来完成 C 语言到 Go 函数的回调工作。

通过以上的胶水代码,Go 中定义的 goVersion() 就可以被 C 函数 print_go_version() 调用了。

4. 函数调用关系图

之前提到过,Case 2 中不仅包含了 C 调用 Go 函数的过程,还包含了 Go 调用 C 函数的过程,因此分析胶水代码以及调用关系时,只需要分析 Case 2 就能较全面的了解。

这里刻意的将 main.go 和 export_example.go 两个 .go 文件放在最左侧,将 use_exported.c 放在最右侧,CGO 工具生成的中间代码放在中间:

xx

三、其他

1. 安装 Go 1.6

wget https://storage.googleapis.com/golang/go1.6.linux-amd64.tar.gz
tar -C /usr/local -xzf go1.6.linux-amd64.tar.gz
echo 'export PATH=/usr/local/go/bin:$PATH' >> ~/.bashrc
echo 'export GOROOT=/usr/local/go' >> ~/.bashrc
source ~/.bashrc

参考资料