1.1 最简CGO程序

// hello.go
package main

import "C"

func main() {
    println("hello cgo")
}
  • 代码通过import "C"语句启用CGO特性,主函数只是通过Go内置的println函数输出字符串,其中并没有任何和CGO相关的代码。
  • 虽然没有调用CGO的相关函数,但是go build命令会在编译和链接阶段启动gcc编译器,这已经是一个完整的CGO程序了。

1.2 基于C标准库函数输出字符串

// hello.go
package main

//#include <stdio.h>
import "C"

func main() {
    C.puts(C.CString("Hello, World\n"))
}
  • 通过import "C"语句启用CGO特性,
  • 同时包含C语言的<stdio.h>头文件。
  • 然后通过CGO包的C.CString函数将Go语言字符串转为C语言字符串,
  • 最后调用CGO包的C.puts函数向标准输出窗口打印转换后的C字符串。
  • 我们没有在程序退出前释放C.CString创建的C语言字符串;
  • 还有我们改用puts函数直接向标准输出打印,之前是采用fputs向标准输出打印。
  • 没有释放使用C.CString创建的C语言字符串会导致内存泄漏。

1.3 使用自己的C函数

// hello.go
package main

/*
#include <stdio.h>

static void SayHello(const char* s) {
    puts(s);
}
*/
import "C"

func main() {
    C.SayHello(C.CString("Hello, World\n"))
}
  • 现在我们先自定义一个叫SayHello的C函数来实现打印,然后从Go语言环境中调用这个SayHello函数
  • 可以将SayHello函数放到当前目录下的一个C语言源文件中(后缀名必须是.c)。因为是编写在独立的C文件中,为了允许外部引用,所以需要去掉函数的static修饰符。
  • 然后在CGO部分先声明SayHello函数,其它部分不变:
  • 如果是以静态库或动态库方式引用SayHello函数的话,需要将对应的C源文件移出当前目录(CGO构建程序会自动构建当前目录下的C源文件,从而导致C函数名冲突)

1.4 用Go重新实现C函数

CGO不仅仅用于Go语言中调用C语言函数,还可以用于导出Go语言函数给C语言函数调用。

// hello.go
package main

import "C"

import "fmt"

//export SayHello
func SayHello(s *C.char) {
    fmt.Print(C.GoString(s))
}
  • 我们通过CGO的//export SayHello指令将Go语言实现的函数SayHello导出为C语言函数。
  • 为了适配CGO导出的C语言函数,我们禁止了在函数的声明语句中的const修饰符。
  • 需要注意的是,这里其实有两个版本的SayHello函数:一个Go语言环境的;另一个是C语言环境的。
  • cgo生成的C语言版本SayHello函数最终会通过桥接代码调用Go语言版本的SayHello函数。

2.1 CGO基础

  • 在macOS和Linux下是要安装GCC,在windows下是需要安装MinGW工具。
  • 同时需要保证环境变量CGO_ENABLED被设置为1,这表示CGO是被启用的状态。

2.2import "C"语句

  • Go代码中出现了import "C"语句则表示使用了CGO特性,紧跟在这行语句前面的注释是一种特殊语法,里面包含的是正常的C语言代码。
package main

/*
#include <stdio.h>

void printint(int v) {
    printf("printint: %d\n", v);
}
*/
import "C"

func main() {
    v := 42
    C.printint(C.int(v))
}
  • 开头的注释中写了要调用的C函数和相关的头文件,头文件被include之后里面的所有的C语言元素都会被加入到”C”这个虚拟的包中。
  • 需要注意的是,import “C"导入语句需要单独一行,不能与其他包一同import。
  • 向C函数传递参数也很简单,就直接转化成对应C语言类型传递就可以。
  • Go是强类型语言,所以cgo中传递的参数类型必须与声明的类型完全一致,而且传递前必须用”C”中的转化函数转换成对应的C类型,不能直接传入Go中类型的变量。
  • 同时通过虚拟的C包导入的C语言符号并不需要是大写字母开头,它们不受Go语言的导出规则约束。

2.2 #cgo语句

  • import "C"语句前的注释中可以通过#cgo语句设置编译阶段和链接阶段的相关参数。
  • 编译阶段的参数主要用于定义相关宏和指定头文件检索路径。
  • 链接阶段的参数主要是指定库文件检索路径和要链接的库文件。
// #cgo CFLAGS: -DPNG_DEBUG=1 -I./include
// #cgo LDFLAGS: -L/usr/local/lib -lpng
// #include <png.h>
import "C"
  • CFLAGS部分,-D部分定义了宏PNG_DEBUG,值为1;

  • -I定义了头文件包含的检索目录。LDFLAGS部分

  • -L指定了链接时库文件检索目录,

  • -l指定了链接时需要链接png库。

  • 因为C/C++遗留的问题,C头文件检索目录可以是相对目录,但是库文件检索目录则需要绝对路径。

  • 在库文件的检索目录中可以通过${SRCDIR}变量表示当前包目录的绝对路径:

// #cgo LDFLAGS: -L${SRCDIR}/libs -lfoo
  • #cgo语句主要影响CFLAGS、CPPFLAGS、CXXFLAGS、FFLAGS和LDFLAGS几个编译器环境变量。
  • LDFLAGS用于设置链接时的参数,除此之外的几个变量用于改变编译阶段的构建参数(CFLAGS用于针对C语言代码设置编译参数)。
  • 对于在cgo环境混合使用C和C++的用户来说,可能有三种不同的编译选项:
    • CFLAGS对应C语言特有的编译选项、
    • CXXFLAGS对应是C++特有的编译选项、
    • CPPFLAGS则对应C和C++共有的编译选项。
  • 但是在链接阶段,C和C++的链接选项是通用的,因此这个时候已经不再有C和C++语言的区别,它们的目标文件的类型是相同的。

2.3 build tag 条件编译

#cgo指令针对不同平台定义的宏,只有在对应平台的宏被定义之后才会构建对应的代码。

比如下面的源文件只有在设置debug构建标志时才会被构建:

// +build debug

package main

var buildMode = "debug"

可以用以下命令构建:

go build -tags="debug"
go build -tags="windows debug"

我们可以通过-tags命令行参数同时指定多个build标志,它们之间用空格分隔。

当有多个build tag时,我们将多个标志通过逻辑操作的规则来组合使用。

3.1 类型转换

  • Go语言中访问C语言的符号时,一般是通过虚拟的“C”包访问,比如C.int对应C语言的int类型。
  • 有些C语言的类型是由多个关键字组成,但通过虚拟的“C”包访问C语言类型时名称部分不能有空格字符,比如unsigned int不能直接通过C.unsigned int访问。
  • 因此CGO为C语言的基础数值类型都提供了相应转换规则,比如C.uint对应C语言的unsigned int

应关系表2-1所示。

C语言类型 CGO类型 Go语言类型
char C.char byte
singed char C.schar int8
unsigned char C.uchar uint8
short C.short int16
unsigned short C.ushort uint16
int C.int int32
unsigned int C.uint uint32
long C.long int32
unsigned long C.ulong uint32
long long int C.longlong int64
unsigned long long int C.ulonglong uint64
float C.float float32
double C.double float64
size_t C.size_t uint

表 2-1 Go语言和C语言类型对比

<stdint.h>文件中,不但每个数值类型都提供了明确内存大小,而且和Go语言的类型命名更加一致。Go语言类型<stdint.h>头文件类型对比如表2-2所示。

C语言类型 CGO类型 Go语言类型
int8_t C.int8_t int8
uint8_t C.uint8_t uint8
int16_t C.int16_t int16
uint16_t C.uint16_t uint16
int32_t C.int32_t int32
uint32_t C.uint32_t uint32
int64_t C.int64_t int64
uint64_t C.uint64_t uint64

表 2-2 <stdint.h>类型对比

3.2 结构体、联合、枚举类型

  • C语言的结构体、联合、枚举类型不能作为匿名成员被嵌入到Go语言的结构体中。
  • 在Go语言中,我们可以通过C.struct_xxx来访问C语言中定义的struct xxx结构体类型。
/*
struct A {
    int i;
    float f;
};
*/
import "C"
import "fmt"

func main() {
    var a C.struct_A
    fmt.Println(a.i)
    fmt.Println(a.f)
}
/*
struct A {
    int type; // type 是 Go 语言的关键字
};
*/
import "C"
import "fmt"

func main() {
    var a C.struct_A
    fmt.Println(a._type) // _type 对应 type
}
  • 如果结构体的成员名字中碰巧是Go语言的关键字,可以通过在成员名开头添加下划线来访问:
  • 但是如果有2个成员:一个是以Go语言关键字命名,另一个刚好是以下划线和Go语言关键字命名,那么以Go语言关键字命名的成员将无法访问(被屏蔽):
/*
struct A {
    int   type;  // type 是 Go 语言的关键字
    float _type; // 将屏蔽CGO对 type 成员的访问
};
*/
import "C"
import "fmt"

func main() {
    var a C.struct_A
    fmt.Println(a._type) // _type 对应 _type
}

对应零长数组的成员,无法在Go语言中直接访问数组的元素,但其中零长的数组成员所在位置的偏移量依然可以通过unsafe.Offsetof(a.arr)来访问。

/*
struct A {
    int   size: 10; // 位字段无法访问
    float arr[];    // 零长的数组也无法访问
};
*/
import "C"
import "fmt"

func main() {
    var a C.struct_A
    fmt.Println(a.size) // 错误: 位字段无法访问
    fmt.Println(a.arr)  // 错误: 零长的数组也无法访问
}

对于联合类型,我们可以通过C.union_xxx来访问C语言中定义的union xxx类型。但是Go语言中并不支持C语言联合类型,它们会被转为对应大小的字节数组。

/*
#include <stdint.h>

union B1 {
    int i;
    float f;
};

union B2 {
    int8_t i8;
    int64_t i64;
};
*/
import "C"
import "fmt"

func main() {
    var b1 C.union_B1;
    fmt.Printf("%T\n", b1) // [4]uint8

    var b2 C.union_B2;
    fmt.Printf("%T\n", b2) // [8]uint8
}

对于枚举类型,我们可以通过C.enum_xxx来访问C语言中定义的enum xxx结构体类型。

/*
enum C {
    ONE,
    TWO,
};
*/
import "C"
import "fmt"

func main() {
    var c C.enum_C = C.TWO
    fmt.Println(c)
    fmt.Println(C.ONE)
    fmt.Println(C.TWO)
}

在C语言中,枚举类型底层对应int类型,支持负数类型的值。我们可以通过C.ONEC.TWO等直接访问定义的枚举值。

4.1 函数调用

4.1.1 C函数的返回值

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

func main() {
    v := C.div(6, 3)
    fmt.Println(v)
}

因为C语言不支持返回多个结果,因此<errno.h>标准库提供了一个errno宏用于返回错误状态。我们可以近似地将errno看成一个线程安全的全局变量,可以用于记录最近一次错误的状态码。

改进后的div函数实现如下:

#include <errno.h>

int div(int a, int b) {
    if(b == 0) {
        errno = EINVAL;
        return 0;
    }
    return a/b;
}

CGO也针对<errno.h>标准库的errno宏做的特殊支持:在CGO调用C函数时如果有两个返回值,那么第二个返回值将对应errno错误状态。

/*
#include <errno.h>

static int div(int a, int b) {
    if(b == 0) {
        errno = EINVAL;
        return 0;
    }
    return a/b;
}
*/
import "C"
import "fmt"

func main() {
    v0, err0 := C.div(2, 1)
    fmt.Println(v0, err0)

    v1, err1 := C.div(1, 0)
    fmt.Println(v1, err1)
}

运行这个代码将会产生以下输出:

2 <nil>
0 invalid argument

我们可以近似地将div函数看作为以下类型的函数:

func C.div(a, b C.int) (C.int, [error])

5 内部机制

  • CGO特性主要是通过一个叫cgo的命令行工具来辅助输出Go和C之间的桥接代码。本节我们尝试从生成的代码分析Go语言和C语言函数直接相互调用的流程。

  • 我们可以在构建一个cgo包时增加一个-work输出中间生成文件所在的目录并且在构建完成时保留中间文件。

  • 包中有4个Go文件,其中nocgo开头的文件中没有import "C"指令,其它的2个文件则包含了cgo代码。

  • cgo命令会为每个包含了cgo代码的Go文件创建2个中间文件,比如 main.go 会分别创建 main.cgo1.go 和 main.cgo2.c 两个中间文件。

  • 然后会为整个包创建一个 _cgo_gotypes.go Go文件,其中包含Go语言部分辅助代码。

  • 此外还会创建一个 _cgo_export.h_cgo_export.c 文件,对应Go语言导出到C语言的类型和函数。

6.C++ 类包装

  • CGO是C语言和Go语言之间的桥梁,原则上无法直接支持C++的类。
  • CGO不支持C++语法的根本原因是C++至今为止还没有一个二进制接口规范(ABI)。
  • 一个C++类的构造函数在编译为目标文件时如何生成链接符号名称、方法在不同平台甚至是C++的不同版本之间都是不一样的。
  • 但是C++是兼容C语言,所以我们可以通过增加一组C语言函数接口作为C++类和CGO之间的桥梁,这样就可以间接地实现C++和Go之间的互联。
  • 当然,因为CGO只支持C语言中值类型的数据类型,所以我们是无法直接使用C++的引用参数等特性的。

6.1 C++ 类到 Go 语言对象

  • 实现C++类到Go语言对象的包装需要经过以下几个步骤:首先是用纯C函数接口包装该C++类;
  • 其次是通过CGO将纯C函数接口映射到Go函数;
  • 最后是做一个Go包装对象,将C++类到方法用Go对象的方法实现。