Cgo基础和原理

文章目录
  1. Go references to C
    1. 基本使用
    2. cgo伪指令
    3. cgo tool
    4. 伪包的默认导入
    5. 结构体
      1. 基本访问
      2. 成员忽略
      3. 变长结构体
    6. 函数
      1. 函数指针
      2. 函数数组参数
      3. 函数可变参数
      4. malloc和free
  2. C references to Go
    1. 函数
    2. Go数据类型
    3. export指令限制
  3. 指针传递
  4. 总结
  5. 参考

为了能够直接复用优秀的历史资产,Go提供了Cgo这个特性,允许我们在Go代码中调用C/C++的代码。CGO工具作为Go编译器的一部分,负责将Go代码和C代码混合在一起,以便在Go程序中使用C语言。而我现在了解Cgo,其实是因为历史包袱的原因,不得不使用遗留的C的资产。

跨语言的调用实现,做的无非是将caller语言的数据通过wrapper层转换成callee语言的格式,然后调用callee的ABI进行生效,然后callee的ABI返回后,wrapper层再将其转换成caller语言的数据,仅此而已。这里的wrapper层就是一个adaptor,适配caller和callee的调用约定。

Cgo的使用需要在Go 源文件中导入伪包import "C"来开启。Cgo支持双向的调用生成:

  • Go references to C:Go调用C的代码,即为C代码生成Go层的Wrapper进行调用。
  • C references to Go:C调用Go的代码,即为Go代码生成C层的Wrapper,以供C函数调用。

下面基于双向的调用,进行Cgo相关规则的介绍:

Go references to C

Go导入C的代码是通过在Go 源文件中导入伪包import "C",然后再在导入伪包的前面包含特殊的注释来实现的,这里这个preamble一词不知该如何翻译,我就翻译为:前导注释吧。这些前导注释指示编译器对需要调用的C语言代码,进行编译,wrapper,并在 Go 代码中通过wrapper代码进行 C 语言的调用。

如下是最简单的Cgo使用方法,只通过导入伪包import "C",就可以在Go代码直接引用诸如C.int之类的基本数据类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"
import "C"

func main() {
cChar := C.char('A')
cStr := C.CString("Hello, C!")
var cSize C.size_t
cInt := C.int(1)

fmt.Println("cgo type:", cChar, cStr, cSize, cInt)
}

输出结果为:cgo type: 65 0x15b5050 0 1

基本使用

Cgo的前导注释可以包含任意C代码,包括函数和变量的申明和定义。然后可以在Go代码中直接引用它们,感觉上它们在包"C"中定义一样。前导注释中包含的是要导入的C代码,所以只要符合C的语法就可以。

有一个特例:静态变量不能被导入,静态函数可以。这个原因应该可以理解,因为静态变量的作用域是限制在其模块内部的,源文件外是无法引用的,Cgo针对Go和C源文件是独立编译的,所以无法进行访问。

下面我们开始一个Cgo的基本使用的基本示例:从Go中调用C++操作map的接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// mycppcode.cpp
#include "mycppcode.h"
#include <iostream>
#include <unordered_map>

std::unordered_map<int, std::string> my_map;

void hello_from_cpp() {
my_map[1] = "one1";
my_map[2] = "two2";
my_map[3] = "three3";

for (const auto &entry : my_map) {
std::cout << entry.first << ": " << entry.second << std::endl;
}
}

// mycppcode.h
#ifndef MYCPPCODE_H
#define MYCPPCODE_H

#ifdef __cplusplus
extern "C" {
#endif

void hello_from_cpp();

#ifdef __cplusplus
}
#endif

#endif

然后进行编译:g++ -shared -fPIC mycppcode.cpp -std=c++11 -o mycppcode.so生成so,这里要导入的函数必须导出方式为C的,不支持C++的符号,因为C+中为了支持函数重载、命名空间和模板等特性在编译时对符号进行了Name Mangling。然后在Go的代码如下:

1
2
3
4
5
6
7
8
9
package main

// #cgo LDFLAGS: ${SRCDIR}/mycppcode.so
// #include "mycppcode.h"
import "C"

func main() {
C.hello_from_cpp()
}

通过Cgo功能,进行编译和连接C的代码,运行如下:

1
2
3
4
# go run main.go 
3: three3
2: two2
1: one1

cgo伪指令

在Cgo的前导注释中,支持以#cgo 开头的伪指令,指令可以定义:CFLAGS、CPPFLAGS、CXXFLAGS、FFLAGS 和 LDFLAGS变量,以调整 C、C++ 或 Fortran 编译器的行为。多个指令中定义的值连接在一起。该指令可以包含一系列构建约束,限制其对满足其中一个约束的系统的影响。具体使用说明:

  • 包中所有 CPPFLAGS 和 CFLAGS 指令都连接起来并用于编译该包中的 C 文件。
  • 包中的所有 CPPFLAGS 和 CXXFLAGS 指令都连接起来并用于编译该包中的 C++ 文件。
  • 包中的所有 CPPFLAGS 和 FFLAGS 指令都连接起来并用于编译该包中的 Fortran 文件。
  • 程序中任何包中的所有 LDFLAGS 指令都会连接起来并在链接时使用。
  • 所有 pkg-config 指令都连接起来并同时发送到 pkg-config 以添加到每个适当的命令行标志集。

其实构建时,CGO_CFLAGS、CGO_CPPFLAGS、CGO_CXXFLAGS、CGO_FFLAGS 和 CGO_LDFLAGS 这些环境变量将添加对应的构建中。需要注意的是,这些环境变量应该只用于全局设置,而不应该用于设置特定包的参数。对于特定包的参数设置,应该使用#cgo指令来进行设置,而不是使用环境变量。这样可以确保在未修改的环境中构建也能正常工作

解析 cgo 指令时,任何出现的字符串 ${SRCDIR} 都将替换为包含源文件的目录的绝对路径。这允许预编译的静态库包含在包目录中并正确链接。

1
2
3
// #cgo CXXFLAGS: -std=c++11
// #cgo LDFLAGS: ${SRCDIR}/mycppcode.so
// #include "mycppcode.h"

如下:${SRCDIR}会被替换为源文件mycppcode.h的所在目录的绝对路径。

1
2
3
4
5
6
$ ldd main
linux-vdso.so.1 => (0x00007fff941d8000)
/$LIB/libonion_block.so => /lib64/libonion_block.so (0x00007f99d8898000)
/$LIB/libonion.so => /lib64/libonion.so (0x00007f99d8bb4000)
/data/home/walkerdu/test/cgo_test/a/mycppcode.so (0x00007f99d868a000)
...

Cgo官方手册指出:

When the Go tool sees that one or more Go files use the special import “C”, it will look for other non-Go files in the directory and compile them as part of the Go package.Any .c, .s, .S or .sx files will be compiled with the C compiler. Any .cc, .cpp, or .cxx files will be compiled with the C++ compiler. Any .f, .F, .for or .f90 files will be compiled with the fortran compiler. Any .h, .hh, .hpp, or .hxx files will not be compiled separately, but, if these header files are changed, the package (including its non-Go source files) will be recompiled.

按照上面的说法,当Go文件导入伪包import "C"时,Cgo会扫描Go文件所在当前目录下的non-Go文件,并进行编译链接。这里只会检查Go源文件所在的目录,目录下的字目录不会考虑。

但是经过测试发现,只有import “C”的前导注释#include的文件,Cgo工具才会调用对应的编译器进行编译,不在前导注释中#include的文件,即使在Go源文件的目录下的文件,也不会进行自动编译。

Cgo支持通过环境变量CCCXX来修改默认的C和C++的编译器,如下:

1
CC=gcc CXX=g++ go build

cgo tool

Go提供的Cgo工具来直接使用Cgo的特性,使用方式如下:

1
go tool cgo [cgo options] [-- compiler options] gofiles...

通过cgo tool可以将Go文件中使用的Cgo的部分需要生成Go和C代码的Wrapper代码直接生成出来,供用户进行分析和调试。下面的示例中生成的Wrapper代码都是通过Cgo tool来生成的,Cgo工具还有很多option可以选择,这里就不列出来了,可以参考:「Cgo tool option列表」。

伪包的默认导入

当我们仅仅导入伪包import "C",默认导入的C的特性目前没有任何地方有说明,Go 编译器预定义的行为,只在官方文档中说明了关于cgo默认可以使用的类型和函数,下面是默认Cgo可以使用关于C的类型和函数,以及行为规范。

Cgo默认会导入C的基本数据类型,如下:

C类型 Go导入类型 说明
char, signed char, unsigned char C.char, C.schar, C.uchar 字符类型
short, unsigned short C.short, C.ushort
int, unsigned int C.int, C.uint
long, unsigned long C.long, C.ulong
long long, unsigned long long C.longlong, C.ulonglong
float C.float
double C.double
complex float C.complexfloat
complex double C.complexdouble
void* unsafe.Pointer.
__int128_t , __uint128_t [16]byte

同时会Cgo默认导入Go和C类型的转换函数,这些转换都是通过数据拷贝完成的,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Go string to C string
// C的string是通过malloc在堆上分配的,这要求调用者在使用完后调用C.free进行释放
func C.CString(string) *C.char

// Go []byte slice to C array
// C array也是通过malloc在堆上分配的,同样要求调用者调用C.free进行释放
func C.CBytes([]byte) unsafe.Pointer

// C string to Go string
func C.GoString(*C.char) string

// C data with explicit length to Go string
func C.GoStringN(*C.char, C.int) string

// C data with explicit length to Go []byte
func C.GoBytes(unsafe.Pointer, C.int) []byte

结构体

基本访问

Cgo针对 struct、union 或 enum 类型,生成的wrapper代码会在其前面加上 struct_union_enum_ 前缀,如 C.struct_xxxC.enum_yyy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main
import "fmt"
/*
struct Book{
char* name;
int pages;
int type;
};
*/
import "C"

func main() {
var book C.struct_Book
fmt.Println("cgo test:", book.name, book.pages, book._type, C.sizeof_struct_Book)
}

可以查看Cgo生成的wrapper代码如下:

1
2
3
4
5
6
// _cgo_gotypes.go
type _Ctype_struct_Book struct {
name *_Ctype_char
pages _Ctype_pages
_type _Ctype_int
}

为什么type成员生成的wrapper代码变成了_type呢?是因为type是Go的关键字,如果C 的结构体字段名称是 Go 中的关键字,那么Cgo会在它们前面加上下划线前缀来访问

上面示例中,可以知道,通过Cgo导入的结构体的大小可以通过C.sizeof_struct_T来判断。

针对union需要注意的是,Go并不支持union,针对union,Cgo在导入到的时候会忽略union的成员,只保留union的长度,如下:

1
2
3
4
5
6
7
8
/*
union Dog {
int a;
char b;
long c;
};
*
import "C"

导入生成的wrapper代码为:

1
2
// _cgo_gotypes.go
type _Ctype_union_Dog = [8]byte

成员忽略

如果C 结构体字段包含位字段或未对齐的数据,会在 Go 结构体中被省略,并被适当的填充替换以到达下一个字段或结构体的末尾。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*
struct Bob {
int age;
int flag1 : 1;
int flag2 : 1;
int height;
};

#pragma pack(1)
struct Alice {
int age;
char flag1;
int flag2;
int height;
};
*/
import "C"

Cgo的wrapper代码如下:可以看到,struct Bob中的两个位字段flag1flag2被忽略且被填充为[4]bytestruct Alice中的flag2height由于未4字节对齐,所以被忽略,且被填充为[8]byte

1
2
3
4
5
6
7
8
9
10
11
// _cgo_gotypes.go
type _Ctype_struct_Alice struct {
age _Ctype_int
flag1 _Ctype_char
_ [8]byte
}
type _Ctype_struct_Bob struct {
age _Ctype_int
_ [4]byte
height _Ctype_int
}

变长结构体

Go中无法访问C struct中结尾是长度为0的字段,Cgo在生成代码的时候,会自动忽略,获取该字段的地址的唯一方式就是获取该结构体的地址然后加上其长度。如下:

1
2
3
4
5
6
7
8
/*
struct Msg {
char data1[0]; // 这里只是测试Cgo,真实在C中不会在其他成员之前定义一个长度为0的字段
int data_len;
char data[0];
};
*/
import "C"

Cgo生成的wrapper代码为:

1
2
3
4
type _Ctype_struct_Msg struct {
data1 [0]_Ctype_char
data_len _Ctype_int
}

如下测试获取动态数组的data的地址的方式:

1
2
3
4
5
6
func main() {
var msg C.struct_Msg
dataPtr := uintptr(unsafe.Pointer(&msg.data_len)) + uintptr(C.sizeof_struct_Msg)
newPtr := (*int)(unsafe.Pointer(dataPtr))
fmt.Println("cgo test, Msg:", C.sizeof_struct_Msg, msg, &msg.data_len, newPtr)
}

输出结果为:

1
cgo test, Msg: 4 {[] 0} 0xc0000180a8 0xc0000180ac

Cgo 将 C 类型转换为等价的未导出的 Go 类型。由于翻译未导出,因此 Go 包不应在其导出的 API 中公开 C 类型:一个 Go 包中使用的 C 类型与另一个 Go 包中使用的相同 C 类型不同。

函数

任何C函数,包括void function()都可以在Go的多重赋值上下文中被调用,以获取其返回值和C errno 变量作为error,void function()的返回值用_进行忽略。

如下测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
#include <stdio.h>
void print(int a, int b) {
printf("%d, %d", a, b);
}
*/
import "C"

func main() {
_, err := C.print(1, 2)
if err != nil {
fmt.Println(err)
}
}

我们通过Cgo工具来看一下,Cgo导出的C和Go Wrapper代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// _obj/test_func.cgo2.c
#include <stdio.h>
void print(int a, int b) {
printf("%d, %d", a, b);
}
...

CGO_NO_SANITIZE_THREAD
int
_cgo_fcf8c1c6beec_C2func_print(void *v)
{
int _cgo_errno;
struct {
int p0;
int p1;
} __attribute__((__packed__, __gcc_struct__)) *_cgo_a = v;
_cgo_tsan_acquire();
errno = 0;
print(_cgo_a->p0, _cgo_a->p1);
_cgo_errno = errno;
_cgo_tsan_release();
return _cgo_errno;
}

CGO_NO_SANITIZE_THREAD
void
_cgo_fcf8c1c6beec_Cfunc_print(void *v)
{
struct {
int p0;
int p1;
} __attribute__((__packed__, __gcc_struct__)) *_cgo_a = v;
_cgo_tsan_acquire();
print(_cgo_a->p0, _cgo_a->p1);
_cgo_tsan_release();
}

可以看到,Cgo为void print()函数生成了两个C的Wrapper代码,一个是带errno返回值的C2func_print,另一个是不带返回值的Cfunc_print。就这是为了支持Go调用C代码时,透传回系统调用或者库函数执行过程中遇到的错误

下面是Cgo生成Go Wrapper的代码,也是生成了两个对应的函数调用,一个带error,一个不带。其实你会发现,如果调用的void print()的时候不判断返回值的话,Cgo并不会生成对应包含带error相关的函数Wrapper,也就是说Cgo是按需生成需要Wrapper代码的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// _obj/_cgo_gotypes.go

//go:cgo_unsafe_args
func _C2func_print(p0 _Ctype_int, p1 _Ctype_int) (r1 _Ctype_void, r2 error) {
errno := _cgo_runtime_cgocall(_cgo_fcf8c1c6beec_C2func_print, uintptr(unsafe.Pointer(&p0)))
if errno != 0 { r2 = syscall.Errno(errno) }
if _Cgo_always_false {
_Cgo_use(p0)
_Cgo_use(p1)
}
return
}
...
//go:cgo_unsafe_args
func _Cfunc_print(p0 _Ctype_int, p1 _Ctype_int) (r1 _Ctype_void) {
_cgo_runtime_cgocall(_cgo_fcf8c1c6beec_Cfunc_print, uintptr(unsafe.Pointer(&p0)))
if _Cgo_always_false {
_Cgo_use(p0)
_Cgo_use(p1)
}
return
}

函数指针

Go中,目前不支持通过C 函数指针进行函数调用,但是可以通过在C中定义一个桥接函数,传入C函数指针,由其进行调用。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/*
typedef int (*intFunc) ();

int fortytwo() {
return 42;
}
intFunc get_a_func() {
return fortytwo;
}
int bridge_int_func(intFunc f) {
return f();
}
*/
import "C"

func main() {
fmt.Println(C.fortytwo())

cFunc := C.get_a_func()
//fmt.Println(reflect.TypeOf(cFunc), cFunc()) // 这里无法直接通过函数指针调用
fmt.Println(reflect.TypeOf(cFunc), C.bridge_int_func(cFunc))
}

函数数组参数

我们知道,在C中函数的参数如果是一个数组的话,编译器会自动将其处理为指针参数,调用的地方也会自动进行数组名到指针的转换,但Ggo中,无法直接通过将获取的C的数组传递给一个参数是数组参数的C函数,需要显示的取C数组第一个元素的地址,然后进行C函数的调用,这是Wrapper层代码设计上的显示要求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*
int arr[] = {1,2,3,4};
int sum(int arr[], int length) {
int result = 0;
int i;
for(i = 0; i < length; ++i) {
result += arr[i];
}

return result;
}
*/
import "C"

func main() {
fmt.Println(reflect.TypeOf(C.arr), C.arr)
// fmt.Println(C.sum(C.arr, 4)) // 编译报错
fmt.Println(C.sum(&C.arr[0], 4)) // 正确调用
}

函数可变参数

Cgo不支持调用函数参数是可变参数的C函数,如果要调用,需要和使用函数指针一样,需要在C中定义一个Wrapper的桥接器来实现,如下:

1
2
3
4
5
6
7
8
9
10
11
12
/*
#include<stdio.h>
void printf_wrapper(int a, int b) {
printf("%d, %d\n", a, b);
}
*/
import "C"

func main() {
//C.printf(1, 2) // 编译error,提示:unexpected type: ...
C.printf_wrapper(1, 2)
}

其实现实调用完全没有必要做这种wrapper,因为这只能固定的参数,还不错通过构建一个builder,来wrapper通用的多参数调用。

malloc和free

从前面的学习我们知道,只有显示的告诉Cgo,我们要导入一个C函数,Cgo才会为其定义Wrapper函数封装,但是有一个特殊的case,那就是C.malloc

C.malloc并不是直接调用C的库函数,我们在Go中使用C.malloc不需要显示的告诉Cgo导入#include <stdlib.h>。Cgo为了C.malloc单独封装了一个helper函数来调用C的malloc库函数,保证调用永远不返回 nil 。如果 C 的 malloc 指示内存不足,则辅助函数会使程序崩溃,就像 Go 本身内存不足一样。因为 C.malloc 不会失败,所以它没有返回 errno 的双结果形式。我们看一下Cgo是如何封装的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//_obj/_cgo_export.c 

CGO_NO_SANITIZE_THREAD
void _cgo_415e14295df5_Cfunc__Cmalloc(void *v) {
struct {
unsigned long long p0;
void *r1;
} __attribute__((__packed__, __gcc_struct__)) *a = v;
void *ret;
_cgo_tsan_acquire();
ret = malloc(a->p0);
if (ret == 0 && a->p0 == 0) {
ret = malloc(1);
}
a->r1 = ret;
_cgo_tsan_release();
}

可以看到_cgo_export.c 生成的Cmalloc处理了正常情况下传入size=0的malloc请求,也就是说Cgo调用C.malloc至少会分配一个字节,其他情况的失败只能是内存不足。我们看一下Cgo生成的Go层的wrapper代码,如下:

1
2
3
4
5
6
7
8
9
10
// _obj/_cgo_gotypes.go

//go:cgo_unsafe_args
func _cgo_cmalloc(p0 uint64) (r1 unsafe.Pointer) {
_cgo_runtime_cgocall(_cgo_415e14295df5_Cfunc__Cmalloc, uintptr(unsafe.Pointer(&p0)))
if r1 == nil {
runtime_throw("runtime: C malloc failed")
}
return
}

可以看到如果C层的malloc返回nil后,直接抛出了异常。

这里Cgo自动生成C.malloc代码被放到了_cgo_export.c 里面有点让人奇怪,这个文件按道理是Cgo为了导出Go的函数给C函数使用而设计的,但是C.malloc其实是Cgo为了给Go调用的,而不是给C使用的,所以感觉很奇怪

正常malloc和free的使用如下,可以在Go内自由使用内存,摆脱GC的限制。

1
2
3
4
5
6
7
8
9
10
11
12
package main
/*
#include <stdlib.h>
*/
import "C"
import "fmt"

func main() {
addr := C.malloc(1024)
defer C.free(addr)
fmt.Println(addr)
}

C references to Go

Cgo也提供了将Go编写的内容导出给C用,但是应该很少有需要这么用的,C/C++很少会这么反向操作,大多时候我们还是将C的成熟的库导入给Go使用。下面就简单说明一下如何将Go编写的内容导入给C/C++使用。

函数

Go中可以通过//export指定将Go function导出,然后在C代码中进行调用,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// test.go

package main

import "errors"
import "C"

//export Add
func Add(arg1, arg2 int) int {
return arg1 + arg2
}

//export Add2
func Add2(arg1, arg2 int) (int, error) {
return arg1 + arg2, errors.New("")
}

func main() {
}

通过//export指令将AddAdd2函数导出,注意:这里导出指令只能是严格的//export,中间不能有空格,也不能用/**/替代//

这里我们查看导出的C函数的结构,有两种方式:可以通过

  • 通过go tool cgo test.go来生成导出的源码和头文件;
  • 通过go build -o add.so -buildmode=c-shared test.go来生成导出的头文件和so(当然也可以导出为static);

这里我们go tool cgo test.go来看一下Go函数导出的C实现,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//  _obj/_cgo_export.h

#ifdef __cplusplus
extern "C" {
#endif

extern GoInt Add(GoInt arg1, GoInt arg2);

/* Return type for Add2 */
struct Add2_return {
GoInt r0;
GoInterface r1;
};
extern struct Add2_return Add2(GoInt arg1, GoInt arg2);

#ifdef __cplusplus
}
#endif

我们可以看到针对Go函数的多返回值,在导出后,多返回值会被定义为一个结构体,名字组成为:函数名+_return。我们再看一下具体导出函数的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
extern void _cgoexp_71a61de09c23_Add(void *);

CGO_NO_SANITIZE_THREAD
GoInt Add(GoInt arg1, GoInt arg2)
{
size_t _cgo_ctxt = _cgo_wait_runtime_init_done();
typedef struct {
GoInt p0;
GoInt p1;
GoInt r0;
} __attribute__((__packed__, __gcc_struct__)) _cgo_argtype;
static _cgo_argtype _cgo_zero;
_cgo_argtype _cgo_a = _cgo_zero;
_cgo_a.p0 = arg1;
_cgo_a.p1 = arg2;
_cgo_tsan_release();
crosscall2(_cgoexp_71a61de09c23_Add, &_cgo_a, 24, _cgo_ctxt);
_cgo_tsan_acquire();
_cgo_release_context(_cgo_ctxt);
return _cgo_a.r0;
}
extern void _cgoexp_71a61de09c23_Add2(void *);

CGO_NO_SANITIZE_THREAD
struct Add2_return Add2(GoInt arg1, GoInt arg2)
{
size_t _cgo_ctxt = _cgo_wait_runtime_init_done();
typedef struct {
GoInt p0;
GoInt p1;
GoInt r0;
GoInterface r1;
} __attribute__((__packed__, __gcc_struct__)) _cgo_argtype;
static _cgo_argtype _cgo_zero;
_cgo_argtype _cgo_a = _cgo_zero;
struct Add2_return r;
_cgo_a.p0 = arg1;
_cgo_a.p1 = arg2;
_cgo_tsan_release();
crosscall2(_cgoexp_71a61de09c23_Add2, &_cgo_a, 40, _cgo_ctxt);
_cgo_tsan_acquire();
_cgo_release_context(_cgo_ctxt);
r.r0 = _cgo_a.r0;
r.r1 = _cgo_a.r1;
return r;
}

可以看到导入的C函数,主要还是负责将C层的数据结构进行封装,转换为Go的结构,然后调用Go的导出实现进行调用。如果想要使用该导出函数只需要引入该导出的头文件,然后链接对应的库即可。如下:

先将上面的Go函数导出:

1
go build -o add.so -buildmode=c-shared test.go 

然后编写一个简单的C++代码进行Add函数的调用,如下:

1
2
3
4
5
6
7
8
#include <iostream>

#include "add.h"

int main() {
std::cout << Add(1, 2) << std::endl;
return 0;
}

编译执行如下:

1
2
3
$ g++ test.cpp libadd.so
$./a.out
3

这里我再演示一下,将Go导出给C用的函数,再通过Cgo导入到Go中使用,如下:编写一个Go代码,通过Cgo导入刚刚导出的C函数,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "fmt"
import "errors"

/*
#cgo LDFLAGS: ./libadd.so
#include "add.h"
*/
import "C"

func main() {
fmt.Println(C.Add(1, 2))
}

就可以自产自销啦,哈哈哈。

Go数据类型

除了内置基本标量数据类型,其他Go的数据结构基本不支持导出为C的类型,例如:struct,slice,map,channel等。

如果导出的Go函数包含struct,那就需要先定义对应的C struct,然后导入到Go,Go中定义C struct到Go的转换函数,这样Go函数使用C struct,才可以正常导出使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package main

import "fmt"

/*
struct TestStruct {
int a;
int b;
};
*/
import "C"

type TestStruct struct {
a int
b int
}

func (ts TestStruct) sum() int {
return ts.a + ts.b
}

//export Add
func Add(arg C.struct_TestStruct) int { // 直接导出Go的struct会编译报错
ts := TestStruct{
a: int(arg.a),
b: int(arg.b),
}

return ts.sum()
}

func main() {
//fmt.Println(C.Add(1, 2))
fmt.Println("")
}

同样Go slice也是不支持导出的,需要通过C 指针进行转换。

所以,通常情况下,在使用Cgo时,最好将Go和C代码之间的交互保持尽可能简单和直接。如果需要传递复杂的数据结构或执行复杂的操作,请考虑将问题分解为更简单的组件,并使用C函数执行所需的操作

export指令限制

在使用Cgo时,如果在一个文件中使用了//export,那么这个文件的前导注释preamble部分就会受到限制:它不能包含任何定义,只能包含声明。官方文档给出的原因是:

这个前导注释部分会被复制到两个不同的C输出文件中,如果一个文件同时包含定义和声明,那么这两个输出文件就会产生重复的符号,导致链接器失败。为了避免这种情况,定义必须放在其他文件的前导部分或C源文件中。

如下测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

/*
int add (int a, int b) {
return a + b;
}
*/
import "C"
import "fmt"

func Test1() {
fmt.Println(C.add(1, 3))
}

//export Test2
func Test2() {}

func main() {}

编译会失败,提示:

1
2
3
4
5
6
7
$ go run test_func.go 
# command-line-arguments
/usr/local/go/pkg/tool/linux_amd64/link: running gcc failed: exit status 1
/tmp/go-link-3828858063/000001.o: In function `add':
/data/home/walkerdu/test/cgo_test/test_func.go:5: multiple definition of `add'
/tmp/go-link-3828858063/000000.o:/tmp/go-build/test_func.go:5: first defined here
collect2: error: ld returned 1 exit status

我们通过cgo命令go tool cgo test_func.go直接导出可以看到,导出给C使用的_cgo_export.h中包含了要导入的C函数add()的定义以及要导出的Go函数Test2()的定义:

1
2
3
4
5
6
7
8
9
10
11
12
// _obj/_cgo_export.h

/* Start of preamble from import "C" comments. */
...
int add (int a, int b) {
return a + b;
}
...
/* End of preamble from import "C" comments. */
...

extern void Test2();

然后要导入的C函数add()test_func.cgo2.c中也有定义:

1
2
3
4
5
// _obj/test_func.cgo2.c

int add (int a, int b) {
return a + b;
}

但是按道理说前导注释部分是Cgo导入C的函数给Go使用的,不应该被输出Go导出给C使用的_cgo_export.h才是,有点搞不清这个设计意图。

指针传递

我们都知道,Go是有GC的,GC需要知道每个指向Go内存的指针。因此Go 和 C 之间传递指针是有限制的,否则会发生C访问的Go的指针指向的内存已经被GC释放了,或者移动了位置。

下面讨论中会用到两个词:Go pointer和C pointer,Go pointer表明指向由Go分配的内存,C pointer指向有C分配的内存。

在Go中,复合数据类型内部都包含Go pointer(当然类型零值为nil),例如stringsliceinterfacechannelmapfunction类型。一个pointer持有的可能是一个Go pointer,也可能是一个C pointer。

所有传递给C的Go pointer必须指向pinned Go内存。pinned Go内存称之为固定内存,可以在传递给C处理期间,保证该内存不会受到 Go GC的影响,因为C无法和Go的GC进行协同工作,在C访问Go内存的期间,需要保证内存位置不变,否则会出现未定义的行为。

Go中可以直接将Go pointer传递给C,Cgo保证了该指针指向的Go内存是固定的,但需要确保该指针指向的内存不包含指向未固定内存的其他Go指针

当传递一个指向结构体字段的指针时,相关的 Go 内存是该字段所占用的内存,而不是整个结构体,只需要确保该字段所占用的内存是固定的。当传递一个指向数组或切片元素的指针时,相关的 Go 内存是整个数组或切片的整个背景数组,需要确保整个数组或切片的背景数组的内存都是固定的。

Go 1.21 版本,增加了runtime.Pinner类型,可以用来固定在Go中通过调用 new 函数创建的值、获取复合字面量的地址或获取局部变量的地址创建的 Go 值。runtime.Pinner 可以确保在 C 函数调用期间以及之后,这些值的内存保持固定。需要注意的是,内存可以被多次固定,但必须与其被固定的次数相同的次数解除固定。这意味着如果您固定了内存两次,那么您需要解除固定两次才能让垃圾回收器处理该内存。

一个被 C 代码调用的 Go 函数可以返回一个指向固定内存的 Go 指针(这意味着它不能返回字符串、切片、通道等)。一个被 C 代码调用的 Go 函数可以接受 C 指针作为参数,并且可以通过这些指针存储非指针数据、C 指针或指向固定内存的 Go 指针。它不能将指向非固定内存的 Go 指针存储在 C 指针指向的内存中(这再次意味着它不能存储字符串、切片、通道等)。一个被 C 代码调用的 Go 函数可以接受一个 Go 指针,但它必须保持该属性,即指向的 Go 内存(以及该内存指向的 Go 内存,依此类推)是固定的。

上述这些规则,Go提供了runtime期间的动态检测机制,可以通过设置cgocheck环境变量来修改检测机制:

  • GODEBUG=cgocheck=1:指针传递的高效的动态检测,默认此设置。
  • GODEBUG=cgocheck=0:关闭检测;
  • GODEBUG=cgocheck=2:指针传递的完整检测,会有一定的开销。

通过使用 unsafe 包可以绕过这些限制,然而,违反这些规则的程序可能会发生难以预测的失败。

总结

这里总结一下cgo 的工作原理:

  1. 预处理阶段: 在编译开始之前,Go 编译器首先会将所有源文件传递给 cgo 预处理器,这是一个单独的工具。预处理器会检查源文件中的 import "C" 语句,找到所有的 C 代码块,将它们从 Go 代码中分离出来,并生成一个临时的包含 C 代码的文件。
  2. 调用 C 编译器: 一旦预处理器生成了包含 C 代码的临时文件,cgo 就会调用 C 编译器(例如 gcc)来处理这些 C 代码。它会生成共享库(.so.dll 文件)或静态库(.a 文件),这取决于你的操作系统和平台。
  3. 生成 Go 代码: C 编译器完成后,cgo 会生成一些 C 代码的 Go 封装。这些封装代码会映射 C 函数和数据类型到 Go 代码中,以便你可以在 Go 代码中调用 C 函数和使用 C 数据类型。
  4. 编译 Go 代码: 现在,Go 编译器会将整个 Go 代码与生成的 C 封装代码一起编译。这时,Go 编译器会将 C 函数的调用连接到生成的共享库或静态库上。
  5. 链接阶段: 最后,Go 编译器将所有编译的代码与可能的共享库一起链接,生成最终的可执行文件。

需要注意的是,cgo 的工作过程涉及多个阶段的协调,包括预处理、C 代码的编译、封装代码的生成和 Go 代码的编译。它需要与操作系统、C 编译器和 Go 编译器进行密切的交互,以确保生成的可执行文件能够正确地调用 C 函数和使用 C 数据类型。

cgo 的使用虽然为 Go 与 C 之间的交互提供了便利,但也需要注意内存安全性、性能和平台差异等问题。在使用 cgo 时,应该仔细阅读官方文档和最佳实践,以确保代码的正确性和可维护性。

参考

cgo - Go Programming Language Wiki (zchee.github.io)

https://uncledou.site/2023/go-1.21-new-pinner-type/

https://www.linkinstars.com/post/19c0fd4e.html