Go作为当下最火的开发语言之一,它的优势不必多说。Go对于高并发的支持,使得它可以很方便的作为独立模块嵌入业务系统。大量的C/C++存量代码出现,如何将Go和C/C++进行打通尤为重要。Golang自带的CGO可以支持与C语言接口的互通。本文首先介绍了CGO的常见用法,然后根据底层代码分析其实现机制,最后在特定场景下进行CGO实践。
一、CGO快速入门

(一)启用CGO特性

在golang代码中加入import“C”语句就可以启动CGO特性。这样在进行go build命令时,就会在编译和连接阶段启动gcc编译器。

  1. // go.1.15
  2. // test1.go
  3. package main
  4. import "C" // import "C"更像是一个关键字,CGO工具在预处理时会删掉这一行
  5. func main() {
  6. }

使用-x选项可以查看go程序编译过程中执行的所有指令。可以看到golang编译器已经为test1.go创建了CGO编译选项:

  1. [root@VM-centos ~/cgo_test/golink2]# go build -x test1.go
  2. WORK=/tmp/go-build330287398
  3. mkdir -p $WORK/b001/
  4. cd /root/cgo_test/golink2
  5. CGO_LDFLAGS='"-g" "-O2"' /usr/lib/golang/pkg/tool/linux_amd64/cgo -objdir $WORK/b001/ -importpath command-line-arguments -- -I $WORK/b001/ -g -O2 ./test1.go # CGO编译选项
  6. cd $WORK
  7. gcc -fno-caret-diagnostics -c -x c - -o /dev/null || true
  8. gcc -Qunused-arguments -c -x c - -o /dev/null || true
  9. gcc -fdebug-prefix-map=a=b -c -x c - -o /dev/null || true
  10. gcc -gno-record-gcc-switches -c -x c - -o /dev/null || true
  11. .......

(二)Hello Cgo

通过import“C”语句启用CGO特性后,CGO会将上一行代码所处注释块的内容视为C代码块,被称为序文(preamble)

  1. // test2.go
  2. package main
  3. //#include <stdio.h> // 序文中可以链接标准C程序库
  4. import "C"
  5. func main() {
  6. C.puts(C.CString("Hello, Cgo\n"))
  7. }

在序文中可以使用C.func的方式调用C代码块中的函数,包括库文件中的函数。对于C代码块的变量,类型也可以使用相同方法进行调用。
test2.go通过CGO提供的C.CString函数将Go语言字符串转化为C语言字符串,最后再通过C.puts 调用中的puts函数向标准输出打印字符串。

(三)cgo工具

当你在包中引用import“C”,go build就会做很多额外的工作来构建你的代码,构建就不仅仅是向go tool compile传递一堆.go文件了,而是要先进行以下步骤:

  • cgo工具就会被调用,在C转换Go、Go转换C的之间生成各种文件。
  • 系统的C编译器会被调用来处理包中所有的C文件。
  • 所有独立的编译单元会被组合到一个.o文件。
  • 生成的.o文件会在系统的连接器中对它的引用进行一次检查修复。

cgo是一个Go语言自带的特殊工具,可以使用命令go tool cgo来运行。它可以生成能够调用C语言代码的Go语言源文件,也就是说所有启用了CGO特性的Go代码,都会首先经过cgo的“预处理”。
对test2.go,cgo工具会在同目录生成以下文件:

  1. _obj--|
  2. |--_cgo.o // C代码编译出的链接库
  3. |--_cgo_main.c // C代码部分的main函数
  4. |--_cgo_flags // C代码的编译和链接选项
  5. |--_cgo_export.c //
  6. |--_cgo_export.h // 导出到C语言的Go类型
  7. |--_cgo_gotypes.go // 导出到Go语言的C类型
  8. |--test1.cgo1.go // 经过“预处理”的Go代码
  9. |--test1.cgo2.c // 经过“预处理”的C代码

二、CGO的N种用法

CGO作为Go语言和C语言之间的桥梁,其使用场景可以分为两种:Go调用C程序和C调用Go程序。

(一)Go调用自定义C程序

  1. // test3.go
  2. package main
  3. /*
  4. #cgo LDFLAGS: -L/usr/local/lib
  5. #include <stdio.h>
  6. #include <stdlib.h>
  7. #define REPEAT_LIMIT 3 // CGO会保留C代码块中的宏定义
  8. typedef struct{ // 自定义结构体
  9. int repeat_time;
  10. char* str;
  11. }blob;
  12. int SayHello(blob* pblob) { // 自定义函数
  13. for ( ;pblob->repeat_time < REPEAT_LIMIT; pblob->repeat_time++){
  14. puts(pblob->str);
  15. }
  16. return 0;
  17. }
  18. */
  19. import "C"
  20. import (
  21. "fmt"
  22. "unsafe"
  23. )
  24. func main() {
  25. cblob := C.blob{} // 在GO程序中创建的C对象,存储在Go的内存空间
  26. cblob.repeat_time = 0
  27. cblob.str = C.CString("Hello, World\n") // C.CString 会在C的内存空间申请一个C语言字符串对象,再将Go字符串拷贝到C字符串
  28. ret := C.SayHello(&cblob) // &cblob 取C语言对象cblob的地址
  29. fmt.Println("ret", ret)
  30. fmt.Println("repeat_time", cblob.repeat_time)
  31. C.free(unsafe.Pointer(cblob.str)) // C.CString 申请的C空间内存不会自动释放,需要显示调用C中的free释放
  32. }

CGO会保留序文中的宏定义,但是并不会保留注释,也不支持#program,C代码块中的#program语句极可能产生未知错误
CGO中使用#cgo关键字可以设置编译阶段和链接阶段的相关参数,可以使用${SRCDIR}来表示Go包当前目录的绝对路径。
使用C.结构名或C.struct_结构名可以在Go代码段中定义C对象,并通过成员名访问结构体成员。
test3.go中使用C.CString将Go字符串对象转化为C字符串对象,并将其传入C程序空间进行使用,由于C的内存空间不受Go的GC管理,因此需要显示的调用C语言的free来进行回收。详情见第三章。

(二)Go调用C/C++模块

简单Go调C

直接将完整的C代码放在Go源文件中,这种编排方式便于开发人员快速在C代码和Go代码间进行切换。

  1. // demo/test4.go
  2. package main
  3. /*
  4. #include <stdio.h>
  5. int SayHello() {
  6. puts("Hello World");
  7. return 0;
  8. }
  9. */
  10. import "C"
  11. import (
  12. "fmt"
  13. )
  14. func main() {
  15. ret := C.SayHello()
  16. fmt.Println(ret)
  17. }

但是当CGO中使用了大量的C语言代码时,将所有的代码放在同一个go文件中即不利于代码复用,也会影响代码的可读性。此时可以将C代码抽象成模块,再将C模块集成入Go程序中。

Go调用C模块

将C代码进行抽象,放到相同目录下的C语言源文件hello.c中:

  1. // demo/hello.c
  2. #include <stdio.h>
  3. int SayHello() {
  4. puts("Hello World");
  5. return 0;
  6. }

在Go代码中,声明SayHello()函数,再引用hello.c源文件,就可以调起外部C源文件中的函数了。同理也可以将C源码编译打包为静态库或动态库进行使用。

  1. // demo/test5.go
  2. package main
  3. /*
  4. #include "hello.c"
  5. int SayHello();
  6. */
  7. import "C"
  8. import (
  9. "fmt"
  10. )
  11. func main() {
  12. ret := C.SayHello()
  13. fmt.Println(ret)
  14. }

test5.go中只对SayHello函数进行了声明,然后再通过链接C程序库的方式加载函数的实现。那么同样的,也可以通过链接C++程序库的方式,来实现Go调用C++程序。

Go调用C++模块

基于test4。可以抽象出一个hello模块,将模块的接口函数在hello.h头文件进行定义:

  1. // demo/hello.h
  2. int SayHello();

再使用C++来重新实现这个C函数:

  1. // demo/hello.cpp
  2. #include <iostream>
  3. extern "C" {
  4. #include "hello.h"
  5. }
  6. int SayHello() {
  7. std::cout<<"Hello World";
  8. return 0;
  9. }

最后再在Go代码中,引用hello.h头文件,就可以调用C++实现的SayHello函数了:

  1. // demo/test6.go
  2. package main
  3. /*
  4. #include "hello.h"
  5. */
  6. import "C"
  7. import (
  8. "fmt"
  9. )
  10. func main() {
  11. ret := C.SayHello()
  12. fmt.Println(ret)
  13. }

CGO提供的这种面向C语言接口的编程方式,使得开发者可以使用是任何编程语言来对接口进行实现,只要最终满足C语言接口即可。

(三)C调用Go模块

C调用Go相对于Go调C来说要复杂多,可以分为两种情况。一是原生Go进程调用C,C中再反调Go程序。另一种是原生C进程直接调用Go。

Go实现的C函数

如前述,开发者可以用任何编程语言来编写程序,只要支持CGO的C接口标准,就可以被CGO接入。那么同样可以用Go实现C函数接口
在test6.go中,已经定义了C接口模块hello.h:

  1. // demo/hello.h
  2. void SayHello(char* s);

可以创建一个hello.go文件,来用Go语言实现SayHello函数:

  1. // demo/hello.go
  2. package main
  3. //#include <hello.h>
  4. import "C"
  5. import "fmt"
  6. //export SayHello
  7. func SayHello(str *C.char) {
  8. fmt.Println(C.GoString(str))
  9. }

CGO的//export SayHello指令将Go语言实现的SayHello函数导出为C语言函数。这样再Go中调用C.SayHello时,最终调用的是hello.go中定义的Go函数SayHello:

  1. // demo/test7.go
  2. // go run ../demo
  3. package main
  4. //#include "hello.h"
  5. import "C"
  6. func main() {
  7. C.SayHello(C.CString("Hello World"))
  8. }

Go程序先调用C的SayHello接口,由于SayHello接口链接在Go的实现上,又调到Go。

看起来调起方和实现方都是Go,但实际执行顺序是Go的main函数,调到CGO生成的C桥接函数,最后C桥接函数再调到Go的SayHello。这部分会在第四章进行分析。

原生C调用Go

C调用到Go这种情况比较复杂,Go一般是便以为c-shared/c-archive的库给C调用。

  1. // demo/hello.go
  2. package main
  3. import "C"
  4. //export hello
  5. func hello(value string)*C.char { // 如果函数有返回值,则要将返回值转换为C语言对应的类型
  6. return C.CString("hello" + value)
  7. }
  8. func main(){
  9. // 此处一定要有main函数,有main函数才能让cgo编译器去把包编译成C的库
  10. }

如果Go函数有多个返回值,会生成一个C结构体进行返回,结构体定义参考生成的.h文件
生成c-shared文件命令:

  1. go build -buildmode=c-shared -o hello.so hello.go

在C代码中,只需要引用go build生成的.h文件,并在编译时链接对应的.so程序库,即可从C调用Go程序

  1. // demo/test8.c
  2. #include <stdio.h>
  3. #include <string.h>
  4. #include "hello.h" //此处为上一步生成的.h文件
  5. int main(){
  6. char c1[] = "did";
  7. GoString s1 = {c1,strlen(c1)}; //构建Go语言的字符串类型
  8. char *c = hello(s1);
  9. printf("r:%s",c);
  10. return 0;
  11. }

编译命令:

  1. gcc -o c_go main.c hello.so

C函数调入进Go,必须按照Go的规则执行,当主程序是C调用Go时,也同样有一个Go的runtime与C程序并行执行。这个runtime的初始化在对应的c-shared的库加载时就会执行。因此,在进程启动时就有两个线程执行,一个C的,一 (多)个是Go的。
三、类型转换
想要更好的使用CGO必须了解Go和C之间类型转换的规则。

(一)数值类型

在Go语言中访问C语言的符号时,一般都通过虚拟的“C”包进行。比如C.int,C.char就对应与C语言中的int和char,对应于Go语言中的int和byte。
C语言和Go语言的数值类型对应如下:
CGO让Go与C手牵手,打破双方“壁垒”! - 图1

Go语言的int和uint在32位和64位系统下分别是4个字节和8个字节大小。它在C语言中的导出类型GoInt和GoUint在不同位数系统下内存大小也不同。
如下是64位系统中,Go数值类型在C语言的导出列表:

  1. // _cgo_export.h
  2. typedef signed char GoInt8;
  3. typedef unsigned char GoUint8;
  4. typedef short GoInt16;
  5. typedef unsigned short GoUint16;
  6. typedef int GoInt32;
  7. typedef unsigned int GoUint32;
  8. typedef long long GoInt64;
  9. typedef unsigned long long GoUint64;
  10. typedef GoInt64 GoInt;
  11. typedef GoUint64 GoUint;
  12. typedef __SIZE_TYPE__ GoUintptr;
  13. typedef float GoFloat32;
  14. typedef double GoFloat64;
  15. typedef float _Complex GoComplex64;
  16. typedef double _Complex GoComplex128;

需要注意的是在C语言符号名前加上Ctype,便是其在Go中的导出名,因此在启用CGO特性后,Go语言中禁止出现以Ctype开头的自定义符号名,类似的还有Cfunc
可以在序文中引入_obj/_cgo_export.h来显式使用cgo在C中的导出类型:

  1. // test9.go
  2. package main
  3. /*
  4. #include "_obj/_cgo_export.h" // _cgo_export.h由cgo工具动态生成
  5. GoInt32 Add(GoInt32 param1, GoInt32 param2) { // GoInt32即为cgo在C语言的导出类型
  6. return param1 + param2;
  7. }
  8. */
  9. import "C"
  10. import "fmt"
  11. func main() {
  12. // _Ctype_ // _Ctype_ 会在cgo预处理阶段触发异常,
  13. fmt.Println(C.Add(1, 2))
  14. }

如下是64位系统中,C数值类型在Go语言的导出列表:

  1. // _cgo_gotypes.go
  2. type _Ctype_char int8
  3. type _Ctype_double float64
  4. type _Ctype_float float32
  5. type _Ctype_int int32
  6. type _Ctype_long int64
  7. type _Ctype_longlong int64
  8. type _Ctype_schar int8
  9. type _Ctype_short int16
  10. type _Ctype_size_t = _Ctype_ulong
  11. type _Ctype_uchar uint8
  12. type _Ctype_uint uint32
  13. type _Ctype_ulong uint64
  14. type _Ctype_ulonglong uint64
  15. type _Ctype_void [0]byte

为了提高C语言的可移植性,更好的做法是通过C语言的C99标准引入的头文件,不但每个数值类型都提供了明确内存大小,而且和Go语言的类型命名更加一致。

(二)切片

Go中切片的使用方法类似C中的数组,但是内存结构并不一样。C中的数组实际上指的是一段连续的内存,而Go的切片在存储数据的连续内存基础上,还有一个头结构体,其内存结构如下:

CGO让Go与C手牵手,打破双方“壁垒”! - 图2

因此Go的切片不能直接传递给C使用,而是需要取切片的内部缓冲区的首地址(即首个元素的地址)来传递给C使用。使用这种方式把Go的内存空间暴露给C使用,可以大大减少Go和C之间参数传递时内存拷贝的消耗。

  1. // test10.go
  2. package main
  3. /*
  4. int SayHello(char* buff, int len) {
  5. char hello[] = "Hello Cgo!";
  6. int movnum = len < sizeof(hello) ? len:sizeof(hello);
  7. memcpy(buff, hello, movnum); // go字符串没有'\0',所以直接内存拷贝
  8. return movnum;
  9. }
  10. */
  11. import "C"
  12. import (
  13. "fmt"
  14. "unsafe"
  15. )
  16. func main() {
  17. buff := make([]byte, 8)
  18. C.SayHello((*C.char)(unsafe.Pointer(&buff[0])), C.int(len(buff)))
  19. a := string(buff)
  20. fmt.Println(a)
  21. }

(三)字符串

Go的字符串与C的字符串在底层的内存模型也不一样:

CGO让Go与C手牵手,打破双方“壁垒”! - 图3
Go的字符串并没有以’\0’结尾,因此使用类似切片的方式,直接将Go字符串的首元素地址传递给C是不可行的。

Go与C的字符串传递

cgo给出的解决方案是标准库函数C.CString(),它会在C内存空间内申请足够的空间,并将Go字符串拷贝到C空间中。因此C.CString申请的内存在C空间中,因此需要显式的调用C.free来释放空间,如test3。
如下是C.CString()的底层实现:

  1. func _Cfunc_CString(s string) *_Ctype_char { // 从Go string 到 C char* 类型转换
  2. p := _cgo_cmalloc(uint64(len(s)+1))
  3. pp := (*[1<<30]byte)(p)
  4. copy(pp[:], s)
  5. pp[len(s)] = 0
  6. return (*_Ctype_char)(p)
  7. }
  8. //go:cgo_unsafe_args
  9. func _cgo_cmalloc(p0 uint64) (r1 unsafe.Pointer) {
  10. _cgo_runtime_cgocall(_cgo_bb7421b6328a_Cfunc__Cmalloc, uintptr(unsafe.Pointer(&p0)))
  11. if r1 == nil {
  12. runtime_throw("runtime: C malloc failed")
  13. }
  14. return
  15. }

_Cfunc_CString
_Cfunc_CString是cgo定义的从Go string到C char*的类型转换函数。

  • 使用_cgo_cmalloc在C空间内申请内存(即不受Go GC控制的内存)。
  • 使用该段C内存初始化一个[]byte对象。
  • 将string拷贝到[]byte对象。
  • 将该段C空间内存的地址返回。

它的实现方式类似前述,切片的类型转换。不同在于切片的类型转换,是将Go空间内存暴露给C函数使用。而_Cfunc_CString是将C空间内存暴露给Go使用。
_cgo_cmalloc
定义了一个暴露给Go的C函数,用于在C空间申请内存。
与C.CString()对应的是从C字符串转Go字符串的转换函数C.GoString()。C.GoString()函数的实现较为简单,检索C字符串长度,然后申请相同长度的Go-string对象,最后内存拷贝。
如下是C.GoString()的底层实现:

  1. //go:linkname _cgo_runtime_gostring runtime.gostring
  2. func _cgo_runtime_gostring(*_Ctype_char) string
  3. func _Cfunc_GoString(p *_Ctype_char) string { // 从C char* 到 Go string 类型转换
  4. return _cgo_runtime_gostring(p)
  5. }
  6. //go:linkname gostring
  7. func gostring(p *byte) string { // 底层实现
  8. l := findnull(p)
  9. if l == 0 {
  10. return ""
  11. }
  12. s, b := rawstring(l)
  13. memmove(unsafe.Pointer(&b[0]), unsafe.Pointer(p), uintptr(l))
  14. return s
  15. }

更高效的字符串传递方法

C.CString简单安全,但是它涉及了一次从Go到C空间的内存拷贝,对于长字符串而言这会是难以忽视的开销。
Go官方文档中声称string类型是“不可改变的”,但是在实操中可以发现,除了常量字符串会在编译期被分配到只读段,其他的动态生成的字符串实际上都是在堆上。
因此如果能够获得string的内存缓存区地址,那么就可以使用类似切片传递的方式将字符串指针和长度直接传递给C使用。
查阅源码,可知String实际上是由缓冲区首地址和长度构成的。这样就可以通过一些方式拿到缓存区地址。

  1. type stringStruct struct {
  2. str unsafe.Pointer //str首地址
  3. len int //str长度
  4. }

test11.go将fmt动态生成的string转为自定义类型MyString便可以获得缓冲区首地址,将地址传入C函数,这样就可以在C空间直接操作Go-String的内存空间了,这样可以免去内存拷贝的消耗。

  1. // test11.go
  2. package main
  3. /*
  4. #include <string.h>
  5. int SayHello(char* buff, int len) {
  6. char hello[] = "Hello Cgo!";
  7. int movnum = len < sizeof(hello) ? len:sizeof(hello);
  8. memcpy(buff, hello, movnum);
  9. return movnum;
  10. }
  11. */
  12. import "C"
  13. import (
  14. "fmt"
  15. "unsafe"
  16. )
  17. type MyString struct {
  18. Str *C.char
  19. Len int
  20. }
  21. func main() {
  22. s := fmt.Sprintf(" ")
  23. C.SayHello((*MyString)(unsafe.Pointer(&s)).Str, C.int((*MyString)(unsafe.Pointer(&s)).Len))
  24. fmt.Print(s)
  25. }

这种方法背离了Go语言的设计理念,如非必要,不要把这种代码带入你的工程,这里只是作为一种“黑科技”进行分享。

(四)结构体,联合,枚举

cgo中结构体,联合,枚举的使用方式类似,可以通过C.struct_XXX来访问 C语言中struct XXX类型。union,enum也类似。

结构体

如果结构体的成员名字中碰巧是Go语言的关键字,可以通过在成员名开头添加下划线来访问。
如果有2个成员:一个是以Go语言关键字命名,另一个刚好是以下划线和Go语言关键字命名,那么以Go语言关键字命名的成员将无法访问(被屏蔽)。
C语言结构体中位字段对应的成员无法在Go语言中访问,如果需要操作位字段成员,需要通过在C语言中定义辅助函数来完成。对应零长数组的成员(C中经典的变长数组),无法在Go语言中直接访问数组的元素,但同样可以通过在C中定义辅助函数来访问。
结构体的内存布局按照C语言的通用对齐规则,在32位Go语言环境C语言结构体也按照32位对齐规则,在64位Go语言环境按照64位的对齐规则。对于指定了特殊对齐规则的结构体,无法在CGO中访问

  1. // test11.go
  2. package main
  3. /*
  4. struct Test {
  5. int a;
  6. float b;
  7. double type;
  8. int size:10;
  9. int arr1[10];
  10. int arr2[];
  11. };
  12. int Test_arr2_helper(struct Test * tm ,int pos){
  13. return tm->arr2[pos];
  14. }
  15. #pragma pack(1)
  16. struct Test2 {
  17. float a;
  18. char b;
  19. int c;
  20. };
  21. */
  22. import "C"
  23. import "fmt"
  24. func main() {
  25. test := C.struct_Test{}
  26. fmt.Println(test.a)
  27. fmt.Println(test.b)
  28. fmt.Println(test._type)
  29. //fmt.Println(test.size) // 位数据
  30. fmt.Println(test.arr1[0])
  31. //fmt.Println(test.arr) // 零长数组无法直接访问
  32. //Test_arr2_helper(&test, 1)
  33. test2 := C.struct_Test2{}
  34. fmt.Println(test2.c)
  35. //fmt.Println(test2.c) // 由于内存对齐,该结构体部分字段Go无法访问
  36. }

联合

Go语言中并不支持C语言联合类型,它们会被转为对应大小的字节数组。
如果需要操作C语言的联合类型变量,一般有三种方法:第一种是在C语言中定义辅助函数;第二种是通过Go语言的“encoding/binary”手工解码成员(需要注意大端小端问题);第三种是使用unsafe包强制转型为对应类型(这是性能最好的方式)。
test12给出了union的三种访问方式:

  1. // test12.go
  2. package main
  3. /*
  4. #include <stdint.h>
  5. union SayHello {
  6. int Say;
  7. float Hello;
  8. };
  9. union SayHello init_sayhello(){
  10. union SayHello us;
  11. us.Say = 100;
  12. return us;
  13. }
  14. int SayHello_Say_helper(union SayHello * us){
  15. return us->Say;
  16. }
  17. */
  18. import "C"
  19. import (
  20. "fmt"
  21. "unsafe"
  22. "encoding/binary"
  23. )
  24. func main() {
  25. SayHello := C.init_sayhello()
  26. fmt.Println("C-helper ",C.SayHello_Say_helper(&SayHello)) // 通过C辅助函数
  27. buff := C.GoBytes(unsafe.Pointer(&SayHello), 4)
  28. Say2 := binary.LittleEndian.Uint32(buff)
  29. fmt.Println("binary ",Say2) // 从内存直接解码一个int32
  30. fmt.Println("unsafe modify ", *(*C.int)(unsafe.Pointer(&SayHello))) // 强制类型转换
  31. }

枚举

对于枚举类型,可以通过C.enum_xxx来访问C语言中定义的enum xxx结构体类型。使用方式和C相同,这里就不列例子了。

指针

在Go语言中两个指针的类型完全一致则不需要转换可以直接通用。如果一个指针类型是用type命令在另一个指针类型基础之上构建的,换言之两个指针底层是相同完全结构的指针,那么也可以通过直接强制转换语法进行指针间的转换。
但是C语言中,不同类型的指针是可以显式或隐式转换。cgo经常要面对的是2个完全不同类型的指针间的转换,实现这一转换的关键就是unsafe.Pointer,类似于C语言中的Void*类型指针。

CGO让Go与C手牵手,打破双方“壁垒”! - 图4

使用这种方式就可以实现不同类型间的转换,如下是从Go-int32到*C.char的转换:

CGO让Go与C手牵手,打破双方“壁垒”! - 图5

四、内部机制
go tool cgo是分析CGO内部运行机制的重要工具,本章根据cgo工具生成的中间代码,再辅以Golang源码中runtime部分,来对cgo的内部运行机制进行分析。
cgo的工作流程为:代码预处理->gcc编译->Go Complier编译。其产生的中间文件如图所示:
CGO让Go与C手牵手,打破双方“壁垒”! - 图6

(一)Go调C

Go调C的过程比较简单。test13中定义了一个C函数sum,并在Go中调用了C.sum。

  1. package main
  2. //int sum(int a, int b) { return a+b; }
  3. import "C"
  4. func main() {
  5. println(C.sum(1, 1))
  6. }

下面是cgo工具产生的中间文件,最重要的是test13.cgo1.go,test13.cgo1.c,_cgo_gotypes.go。

test13.cgo1.go

test13.cgo1.go是原本test13.go被cgo处理之后的文件。

  1. // Code generated by cmd/cgo; DO NOT EDIT.
  2. //line test4.go:1:1
  3. package main
  4. //int sum(int a, int b) { return a+b; }
  5. import _ "unsafe"
  6. func main() {
  7. println(( /*line :7:10*/_Cfunc_sum /*line :7:14*/)(1, 1))
  8. }

这个文件才是go complier真正编译的代码。可以看到原本的C.sum被改写为_Cfunc_sum,_Cfunc_sum的定义在_cgo_gotypes.go中。

_cgo_gotypes.go

  1. // Code generated by cmd/cgo; DO NOT EDIT.
  2. package main
  3. import "unsafe"
  4. import _ "runtime/cgo"
  5. import "syscall"
  6. var _ syscall.Errno
  7. func _Cgo_ptr(ptr unsafe.Pointer) unsafe.Pointer { return ptr }
  8. //go:linkname _Cgo_always_false runtime.cgoAlwaysFalse
  9. var _Cgo_always_false bool // 永远为 false
  10. //go:linkname _Cgo_use runtime.cgoUse
  11. func _Cgo_use(interface{}) // 返回一个 Error
  12. type _Ctype_int int32 // CGO类型导出
  13. type _Ctype_void [0]byte // CGO类型导出
  14. //go:linkname _cgo_runtime_cgocall runtime.cgocall
  15. func _cgo_runtime_cgocall(unsafe.Pointer, uintptr) int32 // Go调C的入口函数
  16. //go:linkname _cgo_runtime_cgocallback runtime.cgocallback
  17. func _cgo_runtime_cgocallback(unsafe.Pointer, unsafe.Pointer, uintptr, uintptr) // 回调入口
  18. //go:linkname _cgoCheckPointer runtime.cgoCheckPointer
  19. func _cgoCheckPointer(interface{}, interface{}) // 检查传入C的指针,防止传入了指向Go指针的Go指针
  20. //go:linkname _cgoCheckResult runtime.cgoCheckResult
  21. func _cgoCheckResult(interface{}) // 检查返回值,防止返回了一个Go指针
  22. //go:cgo_import_static _cgo_53efb99bd95c_Cfunc_sum
  23. //go:linkname __cgofn__cgo_53efb99bd95c_Cfunc_sum _cgo_53efb99bd95c_Cfunc_sum
  24. var __cgofn__cgo_53efb99bd95c_Cfunc_sum byte // 指向C空间的sum函
  25. var _cgo_53efb99bd95c_Cfunc_sum = unsafe.Pointer(&__cgofn__cgo_53efb99bd95c_Cfunc_sum) // 将sum函数指针赋值给_cgo_53efb99bd95c_Cfunc_sum
  26. //go:cgo_unsafe_args
  27. func _Cfunc_sum(p0 _Ctype_int, p1 _Ctype_int) (r1 _Ctype_int) {
  28. _cgo_runtime_cgocall(_cgo_53efb99bd95c_Cfunc_sum, uintptr(unsafe.Pointer(&p0))) // 将参数塞到列表中,调用C函数
  29. if _Cgo_always_false {
  30. _Cgo_use(p0) // 针对编译器的优化操作,为了将C函数的参数分配在堆上,实际永远不会执行
  31. _Cgo_use(p1)
  32. }
  33. return
  34. }

_cgo_gotypes.go是Go调C的精髓,这里逐段分析。

_Cgo_always_false&_Cgo_use

  1. //go:linkname _Cgo_always_false runtime.cgoAlwaysFalse
  2. var _Cgo_always_false bool // 永远为 false
  3. //go:linkname _Cgo_use runtime.cgoUse
  4. func _Cgo_use(interface{}) // 返回一个 Error
  5. ..........
  6. if _Cgo_always_false {
  7. _Cgo_use(p0) // 针对编译器的优化操作,为了将C函数的参数分配在堆上,实际永远不会执行
  8. _Cgo_use(p1)
  9. }

_Cgo_always_false 是一个“常量”,正常情况下永远为false。
_Cgo_use的函数实现如下

  1. // runtime/cgo.go
  2. func cgoUse(interface{}) { throw("cgoUse should not be called") }

Go中变量可以分配在栈或者堆上。栈中变量的地址会随着go程调度,发生变化。堆中变量则不会。
而程序进入到C空间后,会脱离Go程的调度机制,所以必须保证C函数的参数分配在堆上。
Go通过在编译器里做逃逸分析来决定一个对象放栈上还是放堆上,不逃逸的对象放栈上,可能逃逸的放堆上。
由于栈上内存存在不需要gc,内存碎片少,分配速度快等优点,所以Go会将变量更多的放在栈上。
_Cgo_use以interface类型为入参,编译器很难在编译期知道,变量最后会是什么类型,因此它的参数都会被分配在堆上。

_cgo_runtime_cgocall

  1. //go:linkname _cgo_runtime_cgocall runtime.cgocall
  2. func _cgo_runtime_cgocall(unsafe.Pointer, uintptr) int32 // Go调C的入口函数

_cgo_runtime_cgocall是从Go调C的关键函数,这个函数里面做了一些调度相关的安排。

  1. // Call from Go to C.
  2. //
  3. // This must be nosplit because it's used for syscalls on some
  4. // platforms. Syscalls may have untyped arguments on the stack, so
  5. // it's not safe to grow or scan the stack.
  6. //
  7. //go:nosplit
  8. func cgocall(fn, arg unsafe.Pointer) int32 {
  9. if !iscgo && GOOS != "solaris" && GOOS != "illumos" && GOOS != "windows" {
  10. throw("cgocall unavailable")
  11. }
  12. if fn == nil {
  13. throw("cgocall nil")
  14. }
  15. if raceenabled { // 数据竞争检测,与CGO无瓜
  16. racereleasemerge(unsafe.Pointer(&racecgosync))
  17. }
  18. mp := getg().m
  19. mp.ncgocall++ // 统计 M 调用CGO次数
  20. mp.ncgo++ // 周期内调用次数
  21. // Reset traceback.
  22. mp.cgoCallers[0] = 0 // 如果在cgo中creash,记录CGO的Traceback
  23. // Announce we are entering a system call
  24. // so that the scheduler knows to create another
  25. // M to run goroutines while we are in the
  26. // foreign code.
  27. //
  28. // The call to asmcgocall is guaranteed not to
  29. // grow the stack and does not allocate memory,
  30. // so it is safe to call while "in a system call", outside
  31. // the $GOMAXPROCS accounting.
  32. //
  33. // fn may call back into Go code, in which case we'll exit the
  34. // "system call", run the Go code (which may grow the stack),
  35. // and then re-enter the "system call" reusing the PC and SP
  36. // saved by entersyscall here.
  37. entersyscall() // 将M与P剥离,防止系统调用阻塞P的调度,保存上下文
  38. // Tell asynchronous preemption that we're entering external
  39. // code. We do this after entersyscall because this may block
  40. // and cause an async preemption to fail, but at this point a
  41. // sync preemption will succeed (though this is not a matter
  42. // of correctness).
  43. osPreemptExtEnter(mp) // 关闭异步抢占
  44. mp.incgo = true
  45. errno := asmcgocall(fn, arg) // 调用C函数fn
  46. // Update accounting before exitsyscall because exitsyscall may
  47. // reschedule us on to a different M.
  48. mp.incgo = false
  49. mp.ncgo--
  50. osPreemptExtExit(mp) // 打开异步抢占
  51. exitsyscall() // 寻找P来承载从C空间返回的Go程
  52. // Note that raceacquire must be called only after exitsyscall has
  53. // wired this M to a P.
  54. if raceenabled {
  55. raceacquire(unsafe.Pointer(&racecgosync))
  56. }
  57. // From the garbage collector's perspective, time can move
  58. // backwards in the sequence above. If there's a callback into
  59. // Go code, GC will see this function at the call to
  60. // asmcgocall. When the Go call later returns to C, the
  61. // syscall PC/SP is rolled back and the GC sees this function
  62. // back at the call to entersyscall. Normally, fn and arg
  63. // would be live at entersyscall and dead at asmcgocall, so if
  64. // time moved backwards, GC would see these arguments as dead
  65. // and then live. Prevent these undead arguments from crashing
  66. // GC by forcing them to stay live across this time warp.
  67. KeepAlive(fn) // 防止Go的gc,在C函数执行期间,回收相关参数,用法与前述_Cgo_use类似
  68. KeepAlive(arg)
  69. KeepAlive(mp)
  70. return errno
  71. }

Go调入C之后,程序的运行将不受Go的runtime的管控。一个正常的Go函数是需要runtime的管控的,即函数的运行时间过长会导致goroutine的抢占,以及GC的执行会导致所有的goroutine被拉齐。
C程序的执行,限制了Go的runtime的调度行为。为此,Go的runtime会在进入到C程序之后,会标记这个运行C的线程M将其排除出调度。
此外,由于正常的Go程序运行在一个2K的栈上,而C程序需要一个无穷大的栈。因此在进去C函数之前需要把当前线程的栈从2K的栈切换到线程本身的系统栈上,即切换到g0。
cgocall中几个重要函数功能说明:

  • entersyscall()将当前的M与P剥离,防止C程序独占M时,阻塞P的调度。
  • asmcgocall()将栈切换到g0的系统栈,并执行C函数调用。
  • exitsyscall()寻找合适的P来运行从C函数返回的Go程,优先选择调用C之前依附的P,其次选择其他空闲的P。

下图是Go调C函数过程中,MPG的调度过程。
CGO让Go与C手牵手,打破双方“壁垒”! - 图7
当Go程在调用C函数时,会单独占用一个系统线程。因此如果在Go程中并发调用C函数,而C函数中又存在阻塞操作,就很可能会造成Go程序不停的创建新的系统线程,而Go并不会回收系统线程,过多的线程数会拖垮整个系统

_cgoCheckPointer&_cgoCheckResult

  1. //go:linkname _cgoCheckPointer runtime.cgoCheckPointer
  2. func _cgoCheckPointer(interface{}, interface{}) // 检查传入C的指针,防止传入了指向Go指针的Go指针
  3. //go:linkname _cgoCheckResult runtime.cgoCheckResult
  4. func _cgoCheckResult(interface{}) // 检查返回值,防止返回了一个Go指针

_cgoCheckPointer检查传入C函数的参数,防止其中包含了指向Go指针的Go指针,防止间接指向的对象在Go调度中发生内存位置变化。
_cgoCheckResult与_cgoCheckPointer类似用于检测C函数调Go函数后,Go函数的返回值。防止其包含了Go指针。

cgofncgo_53efb99bd95c_Cfunc_sum

  1. //go:cgo_import_static _cgo_53efb99bd95c_Cfunc_sum
  2. //go:linkname __cgofn__cgo_53efb99bd95c_Cfunc_sum _cgo_53efb99bd95c_Cfunc_sum
  3. var __cgofn__cgo_53efb99bd95c_Cfunc_sum byte // 指向C空间的sum函
  4. var _cgo_53efb99bd95c_Cfunc_sum = unsafe.Pointer(&__cgofn__cgo_53efb99bd95c_Cfunc_sum) // 将sum函数指针赋值给_cgo_53efb99bd95c_Cfunc_sum
  • go:cgo_import_static将C函数_cgo_53efb99bd95c_Cfunc_sum加载到Go空间中。
  • go:linkname将Go的byte对象cgofncgo_53efb99bd95c_Cfunc_sum的内存空间链接到C函数 _cgo_53efb99bd95c_Cfunc_sum的内存空间。
  • 创建Go对象_cgo_53efb99bd95c_Cfunc_sum并赋值C函数地址。前两行的_cgo_53efb99bd95c_Cfunc_sum指的是C函数的符号。最后一行的_cgo_53efb99bd95c_Cfunc_sum指的是Go的unsafe指针。

通过上面三步,cgo将C函数_cgo_53efb99bd95c_Cfunc_sum的地址赋值给了Go指针_cgo_53efb99bd95c_Cfunc_sum。

_Cfunc_sum

_Cfunc_sum是C函数sum在Go空间的入口。它的参数p0,p1通过_Cgo_use逃逸到了堆上。
再将存储C函数地址的指针和参数列表传入_cgo_runtime_cgocall,即可完成从Go调C函数。

  1. //go:cgo_unsafe_args
  2. func _Cfunc_sum(p0 _Ctype_int, p1 _Ctype_int) (r1 _Ctype_int) {
  3. _cgo_runtime_cgocall(_cgo_53efb99bd95c_Cfunc_sum, uintptr(unsafe.Pointer(&p0))) // 将参数塞到列表中,调用C函数
  4. if _Cgo_always_false {
  5. _Cgo_use(p0) // 针对编译器的优化操作,为了将C函数的参数分配在堆上,实际永远不会执行
  6. _Cgo_use(p1)
  7. }
  8. return
  9. }

其函数调用流程如图示:
CGO让Go与C手牵手,打破双方“壁垒”! - 图8

(二)C调Go

C调Go的过程相对Go调C来说更为复杂,又可以分为两种情况。一种是从Go调用C,然后C再调Go。另一种是原生的C线程调Go。
在test14中,分别创建了test14.go和hello.go,两者之间通过C函数调起。

  1. // demo/hello.go
  2. package main
  3. /*
  4. */
  5. import "C"
  6. import "fmt"
  7. //export GSayHello
  8. func GSayHello(value *C.char) C.int{ // 如果函数有返回值,则要将返回值转换为C语言对应的类型
  9. fmt.Print(C.GoString(value))
  10. return C.int(1)
  11. }
  1. // demo/test14.go
  2. package main
  3. /*
  4. void CSayHello(char * s, int a){
  5. GSayHello(s, a);
  6. }
  7. */
  8. import "C"
  9. func main(){
  10. buff := C.CString("hello cgo")
  11. C.CSayHello(buff, C.int(10))
  12. }

可以看到test14的工作流程是,从Go调到C的CSayHello函数,再从CSayHello调用Go的GSayHello函数。从Go调C的流程上节已经分析,这里主要关注从C调Go的部分。使用cgo工具对hello.go进行分析,C调Go函数主要在_cgo_gotypes.go(Go函数导出) 和_cgo_export.c(C调Go入口)。

_cgo_gotypes.go

首先对被C调用的GSayHello函数的分析。GSayHello的实现在_cgo_gotypes.go,剔除与4.1中重复部分,_cgo_gotypes.go源码如下:

  1. // _cgo_gotypes.go
  2. //go:cgo_export_dynamic GSayHello
  3. //go:linkname _cgoexp_25bb4eb897ab_GSayHello _cgoexp_25bb4eb897ab_GSayHello
  4. //go:cgo_export_static _cgoexp_25bb4eb897ab_GSayHello
  5. //go:nosplit
  6. //go:norace
  7. func _cgoexp_25bb4eb897ab_GSayHello(a unsafe.Pointer, n int32, ctxt uintptr) {
  8. fn := _cgoexpwrap_25bb4eb897ab_GSayHello
  9. _cgo_runtime_cgocallback(**(**unsafe.Pointer)(unsafe.Pointer(&fn)), a, uintptr(n), ctxt);
  10. }
  11. func _cgoexpwrap_25bb4eb897ab_GSayHello(p0 *_Ctype_char) (r0 _Ctype_int) {
  12. return GSayHello(p0)
  13. }
  • go:cgo_export_dynamic在内链模式(internal linking)下将Go的hello函数符号暴露给C。
  • go:linkname _cgoexp_bb7421b6328a_hello _cgoexp_bb7421b6328a_hello将Go函数_cgoexp_bb7421b6328a_hello链接到符号_cgoexp_bb7421b6328a_hello上。
  • go:cgo_export_static _cgoexp_bb7421b6328a_hello在外链模式(external linking)下将_cgoexp_bb7421b6328a_hello符号暴露给C。
  • go:nosplit go:norace关闭溢出检测,关闭竞态管理。

_cgoexp_bb7421b6328a_hello即为C调用Go函数的入口函数,之后调用到_cgoexpwrap_25bb4eb897ab_GSayHello,最后调用到用户定义的Go函数GSayHello。

_cgo_export.c

_cgo_export.c包含了C调用Go函数的入口和暴露给Go的内存分配函数_Cfunc__Cmalloc(void*v)。
C代码较为简单,不过多分析:

  1. /* Code generated by cmd/cgo; DO NOT EDIT. */
  2. #include <stdlib.h>
  3. #include "_cgo_export.h"
  4. #pragma GCC diagnostic ignored "-Wunknown-pragmas"
  5. #pragma GCC diagnostic ignored "-Wpragmas"
  6. #pragma GCC diagnostic ignored "-Waddress-of-packed-member"
  7. extern void crosscall2(void (*fn)(void *, int, __SIZE_TYPE__), void *, int, __SIZE_TYPE__); // 保存C环境的上下文,并调起Go函数
  8. extern __SIZE_TYPE__ _cgo_wait_runtime_init_done(void);
  9. extern void _cgo_release_context(__SIZE_TYPE__);
  10. extern char* _cgo_topofstack(void);
  11. #define CGO_NO_SANITIZE_THREAD
  12. #define _cgo_tsan_acquire()
  13. #define _cgo_tsan_release()
  14. #define _cgo_msan_write(addr, sz)
  15. extern void _cgoexp_25bb4eb897ab_GSayHello(void *, int, __SIZE_TYPE__);
  16. CGO_NO_SANITIZE_THREAD
  17. int GSayHello(char* value) // test1.cgo2.c中调用的 GSayHello
  18. {
  19. __SIZE_TYPE__ _cgo_ctxt = _cgo_wait_runtime_init_done();
  20. struct {
  21. char* p0;
  22. int r0;
  23. char __pad0[4];
  24. } __attribute__((__packed__, __gcc_struct__)) _cgo_a;
  25. _cgo_a.p0 = value;
  26. _cgo_tsan_release();
  27. crosscall2(_cgoexp_25bb4eb897ab_GSayHello, &_cgo_a, 16, _cgo_ctxt);
  28. _cgo_tsan_acquire();
  29. _cgo_release_context(_cgo_ctxt);
  30. return _cgo_a.r0;
  31. }

crosscall2对应的底层函数是runtime.cgocallback,cgocallback会恢复Golang运行时所需的环境包括Go函数地址,栈帧和上下文,然后会调用到cgocallback_gofunc。
cgocallback_gofunc,首先判断当前线程是否为Go线程,再讲线程栈切到Go程栈,再将函数地址,参数地址等信息入Go程栈,最后调用到cgocallbackg。
cgocallbackg确认Go程准备完毕后,就将线程从系统调用状态退出(见上节 exitsyscall),此时程序运行在G栈上,进入cgocallbackg1函数。
cgocallbackg1调用reflectcall,正式进入到用户定义的Go函数。
如下是函数调用关系:
CGO让Go与C手牵手,打破双方“壁垒”! - 图9
从Go调入到C函数时,系统线程会被切到G0运行,之后从C再回调到Go时,会直接在同一个M上从G0切回到普通的Go程,在这个过程中并不会创建新的系统线程。
从原生C线程调用Go函数的流程与这个类似,C程序在一开始就有两个线程,一个是C原生线程,一个是Go线程,当C函数调起Go函数时,会切到Go线程运行。
如下是Go调C,C再调Go过程中,MPG的调度流程。

CGO让Go与C手牵手,打破双方“壁垒”! - 图10
五、总结
CGO是一个非常优秀的工具,大部分使用CGO所造成的问题,都是因为使用方法不规范造成的。希望本文可以帮助大家更好的使用CGO。
参考资料:
1. Golang源码
2. Go语言高级编程
3.https://mp.weixin.qq.com/s/I9IvJnKeJY8IpVB4lcWXNA