CGO入门剖析与实践
作者:panhuili,腾讯 IEG 后台开发工程师
一、CGO 快速入门
1.1、启用 CGO 特性
在 golang 代码中加入 import “C” 语句就可以启动 CGO 特性。这样在进行 go build 命令时,就会在编译和连接阶段启动 gcc 编译器。
// go.1.15// test1.go
package main
import "C" // import "C"更像是一个关键字,CGO工具在预处理时会删掉这一行
func main() {
}
使用 -x 选项可以查看 go 程序编译过程中执行的所有指令。可以看到 golang 编译器已经为 test1.go 创建了 CGO 编译选项
[root@VM-centos ~/cgo_test/golink2]# go build -x test1.go
WORK=/tmp/go-build330287398
mkdir -p $WORK/b001/
cd /root/cgo_test/golink2
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编译选项
cd $WORK
gcc -fno-caret-diagnostics -c -x c - -o /dev/null || true
gcc -Qunused-arguments -c -x c - -o /dev/null || true
gcc -fdebug-prefix-map=a=b -c -x c - -o /dev/null || true
gcc -gno-record-gcc-switches -c -x c - -o /dev/null || true
.......
1.2 、Hello Cgo
通过 import “C” 语句启用 CGO 特性后,CGO 会将上一行代码所处注释块的内容视为 C 代码块,被称为序文(preamble)。
// test2.go
package main
//#include <stdio.h> // 序文中可以链接标准C程序库
import "C"
func main() {
C.puts(C.CString("Hello, Cgo\n"))
}
在序文中可以使用 C.func 的方式调用 C 代码块中的函数,包括库文件中的函数。对于 C 代码块的变量,类型也可以使用相同方法进行调用。
test2.go 通过 CGO 提供的 C.CString 函数将 Go 语言字符串转化为 C 语言字符串,最后再通过 C.puts 调用 <stdio.h>中的 puts 函数向标准输出打印字符串。
1.3 cgo 工具
当你在包中引用 import "C",go build 就会做很多额外的工作来构建你的代码,构建就不仅仅是向 go tool compile 传递一堆 .go 文件了,而是要先进行以下步骤:
-
1)cgo 工具就会被调用,在 C 转换 Go、Go 转换 C 的之间生成各种文件。
-
2)系统的 C 编译器会被调用来处理包中所有的 C 文件。
-
3)所有独立的编译单元会被组合到一个 .o 文件。
-
4)生成的 .o 文件会在系统的连接器中对它的引用进行一次检查修复。
cgo 是一个 Go 语言自带的特殊工具,可以使用命令 go tool cgo 来运行。它可以生成能够调用 C 语言代码的 Go 语言源文件,也就是说所有启用了 CGO 特性的 Go 代码,都会首先经过 cgo 的"预处理"。
对 test2.go,cgo 工具会在同目录生成以下文件
_obj--|
|--_cgo.o // C代码编译出的链接库
|--_cgo_main.c // C代码部分的main函数
|--_cgo_flags // C代码的编译和链接选项
|--_cgo_export.c //
|--_cgo_export.h // 导出到C语言的Go类型
|--_cgo_gotypes.go // 导出到Go语言的C类型
|--test1.cgo1.go // 经过“预处理”的Go代码
|--test1.cgo2.c // 经过“预处理”的C代码
二、CGO 的 N 种用法
CGO 作为 Go 语言和 C 语言之间的桥梁,其使用场景可以分为两种:Go 调用 C 程序 和 C 调用 Go 程序。
2.1、Go 调用自定义 C 程序
// test3.go
package main
/*
#cgo LDFLAGS: -L/usr/local/lib
#include <stdio.h>
#include <stdlib.h>
#define REPEAT_LIMIT 3 // CGO会保留C代码块中的宏定义
typedef struct{ // 自定义结构体
int repeat_time;
char* str;
}blob;
int SayHello(blob* pblob) { // 自定义函数
for ( ;pblob->repeat_time < REPEAT_LIMIT; pblob->repeat_time++){
puts(pblob->str);
}
return 0;
}
*/
import "C"
import (
"fmt"
"unsafe"
)
func main() {
cblob := C.blob{} // 在GO程序中创建的C对象,存储在Go的内存空间
cblob.repeat_time = 0
cblob.str = C.CString("Hello, World\n") // C.CString 会在C的内存空间申请一个C语言字符串对象,再将Go字符串拷贝到C字符串
ret := C.SayHello(&cblob) // &cblob 取C语言对象cblob的地址
fmt.Println("ret", ret)
fmt.Println("repeat_time", cblob.repeat_time)
C.free(unsafe.Pointer(cblob.str)) // C.CString 申请的C空间内存不会自动释放,需要显示调用C中的free释放
}
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 来进行回收。详情见第三章。
2.2、Go 调用 C/C++模块
2.2.1、简单 Go 调 C
直接将完整的 C 代码放在 Go 源文件中,这种编排方式便于开发人员快速在 C 代码和 Go 代码间进行切换。
// demo/test4.go
package main
/*
#include <stdio.h>
int SayHello() {
puts("Hello World");
return 0;
}
*/
import "C"
import (
"fmt"
)
func main() {
ret := C.SayHello()
fmt.Println(ret)
}
但是当 CGO 中使用了大量的 C 语言代码时,将所有的代码放在同一个 go 文件中即不利于代码复用,也会影响代码的可读性。此时可以将 C 代码抽象成模块,再将 C 模块集成入 Go 程序中。
2.2.2、Go 调用 C 模块
将 C 代码进行抽象,放到相同目录下的 C 语言源文件 hello.c 中
// demo/hello.c
#include <stdio.h>
int SayHello() {
puts("Hello World");
return 0;
}
在 Go 代码中,声明 SayHello() 函数,再引用 hello.c 源文件,就可以调起外部 C 源文件中的函数了。同理也可以将C 源码编译打包为静态库或动态库进行使用。
// demo/test5.go
package main
/*
#include "hello.c"
int SayHello();
*/
import "C"
import (
"fmt"
)
func main() {
ret := C.SayHello()
fmt.Println(ret)
}
test5.go 中只对 SayHello 函数进行了声明,然后再通过链接 C 程序库的方式加载函数的实现。那么同样的,也可以通过链接 C++程序库的方式,来实现 Go 调用 C++程序。
2.2.3、Go 调用 C++模块
基于 test4。可以抽象出一个 hello 模块,将模块的接口函数在 hello.h 头文件进行定义
// demo/hello.h
int SayHello();
再使用 C++来重新实现这个 C 函数
// demo/hello.cpp
#include <iostream>
extern "C" {
#include "hello.h"
}
int SayHello() {
std::cout<<"Hello World";
return 0;
}
最后再在 Go 代码中,引用 hello.h 头文件,就可以调用 C++实现的 SayHello 函数了
// demo/test6.go
package main
/*
#include "hello.h"
*/
import "C"
import (
"fmt"
)
func main() {
ret := C.SayHello()
fmt.Println(ret)
}
CGO 提供的这种面向 C 语言接口的编程方式,使得开发者可以使用是任何编程语言来对接口进行实现,只要最终满足 C 语言接口即可。
2.3、C 调用 Go 模块
C 调用 Go 相对于 Go 调 C 来说要复杂多,可以分为两种情况。一是原生 Go 进程调用 C,C 中再反调 Go 程序。另一种是原生 C 进程直接调用 Go。
2.3.1、Go 实现的 C 函数
如前述,开发者可以用任何编程语言来编写程序,只要支持 CGO 的 C 接口标准,就可以被 CGO 接入。那么同样可以用 Go 实现 C 函数接口。
在 test6.go 中,已经定义了 C 接口模块 hello.h
// demo/hello.h
void SayHello(char* s);
可以创建一个 hello.go 文件,来用 Go 语言实现 SayHello 函数
// demo/hello.go
package main
//#include <hello.h>
import "C"
import "fmt"
//export SayHello
func SayHello(str *C.char) {
fmt.Println(C.GoString(str))
}
CGO 的//export SayHello 指令将 Go 语言实现的 SayHello 函数导出为 C 语言函数。这样再 Go 中调用 C.SayHello 时,最终调用的是 hello.go 中定义的 Go 函数 SayHello
// demo/test7.go
// go run ../demo
package main
//#include "hello.h"
import "C"
func main() {
C.SayHello(C.CString("Hello World"))
}
Go 程序先调用 C 的 SayHello 接口,由于 SayHello 接口链接在 Go 的实现上,又调到 Go。
看起来调起方和实现方都是 Go,但实际执行顺序是 Go 的 main 函数,调到 CGO 生成的 C 桥接函数,最后 C 桥接函数再调到 Go 的 SayHello。这部分会在第四章进行分析。
2.3.2、原生 C 调用 Go
C 调用到 Go 这种情况比较复杂,Go 一般是便以为 c-shared/c-archive 的库给 C 调用。
// demo/hello.go
package main
import "C"
//export hello
func hello(value string)*C.char { // 如果函数有返回值,则要将返回值转换为C语言对应的类型
return C.CString("hello" + value)
}
func main(){
// 此处一定要有main函数,有main函数才能让cgo编译器去把包编译成C的库
}
如果 Go 函数有多个返回值,会生成一个 C 结构体进行返回,结构体定义参考生成的.h 文件
生成 c-shared 文件 命令
go build -buildmode=c-shared -o hello.so hello.go
在 C 代码中,只需要引用 go build 生成的.h 文件,并在编译时链接对应的.so 程序库,即可从 C 调用 Go 程序
// demo/test8.c
#include <stdio.h>
#include <string.h>
#include "hello.h" //此处为上一步生成的.h文件
int main(){
char c1[] = "did";
GoString s1 = {c1,strlen(c1)}; //构建Go语言的字符串类型
char *c = hello(s1);
printf("r:%s",c);
return 0;
}
编译命令
gcc -o c_go main.c hello.so
C 函数调入进 Go,必须按照 Go 的规则执行,当主程序是 C 调用 Go 时,也同样有一个 Go 的 runtime 与 C 程序并行执行。这个 runtime 的初始化在对应的 c-shared 的库加载时就会执行。因此,在进程启动时就有两个线程执行,一个 C 的,一 (多)个是 Go 的。
三、类型转换
想要更好的使用 CGO 必须了解 Go 和 C 之间类型转换的规则
3.1、数值类型
在 Go 语言中访问 C 语言的符号时,一般都通过虚拟的“C”包进行。比如 C.int,C.char 就对应与 C 语言中的 int 和 char,对应于 Go 语言中的 int 和 byte。
C 语言和 Go 语言的数值类型对应如下:
Go 语言的 int 和 uint 在 32 位和 64 位系统下分别是 4 个字节和 8 个字节大小。它在 C 语言中的导出类型 GoInt 和 GoUint 在不同位数系统下内存大小也不同。
如下是 64 位系统中,Go 数值类型在 C 语言的导出列表
// _cgo_export.h
typedef signed char GoInt8;
typedef unsigned char GoUint8;
typedef short GoInt16;
typedef unsigned short GoUint16;
typedef int GoInt32;
typedef unsigned int GoUint32;
typedef long long GoInt64;
typedef unsigned long long GoUint64;
typedef GoInt64 GoInt;
typedef GoUint64 GoUint;
typedef __SIZE_TYPE__ GoUintptr;
typedef float GoFloat32;
typedef double GoFloat64;
typedef float _Complex GoComplex64;
typedef double _Complex GoComplex128;
需要注意的是在 C 语言符号名前加上 Ctype, 便是其在 Go 中的导出名,因此在启用 CGO 特性后,Go 语言中禁止出现以Ctype 开头的自定义符号名,类似的还有Cfunc等。
可以在序文中引入_obj/_cgo_export.h 来显式使用 cgo 在 C 中的导出类型
// test9.go
package main
/*
#include "_obj/_cgo_export.h" // _cgo_export.h由cgo工具动态生成
GoInt32 Add(GoInt32 param1, GoInt32 param2) { // GoInt32即为cgo在C语言的导出类型
return param1 + param2;
}
*/
import "C"
import "fmt"
func main() {
// _Ctype_ // _Ctype_ 会在cgo预处理阶段触发异常,
fmt.Println(C.Add(1, 2))
}
如下是 64 位系统中,C 数值类型在 Go 语言的导出列表
// _cgo_gotypes.go
type _Ctype_char int8
type _Ctype_double float64
type _Ctype_float float32
type _Ctype_int int32
type _Ctype_long int64
type _Ctype_longlong int64
type _Ctype_schar int8
type _Ctype_short int16
type _Ctype_size_t = _Ctype_ulong
type _Ctype_uchar uint8
type _Ctype_uint uint32
type _Ctype_ulong uint64
type _Ctype_ulonglong uint64
type _Ctype_void [0]byte
为了提高 C 语言的可移植性,更好的做法是通过 C 语言的 C99 标准引入的头文件,不但每个数值类型都提供了明确内存大小,而且和 Go 语言的类型命名更加一致。
3.2、切片
Go 中切片的使用方法类似 C 中的数组,但是内存结构并不一样。C 中的数组实际上指的是一段连续的内存,而 Go 的切片在存储数据的连续内存基础上,还有一个头结构体,其内存结构如下
因此 Go 的切片不能直接传递给 C 使用,而是需要取切片的内部缓冲区的首地址(即首个元素的地址)来传递给 C 使用。使用这种方式把 Go 的内存空间暴露给 C 使用,可以大大减少 Go 和 C 之间参数传递时内存拷贝的消耗。
// test10.go
package main
/*
int SayHello(char* buff, int len) {
char hello[] = "Hello Cgo!";
int movnum = len < sizeof(hello) ? len:sizeof(hello);
memcpy(buff, hello, movnum); // go字符串没有'\0',所以直接内存拷贝
return movnum;
}
*/
import "C"
import (
"fmt"
"unsafe"
)
func main() {
buff := make([]byte, 8)
C.SayHello((*C.char)(unsafe.Pointer(&buff[0])), C.int(len(buff)))
a := string(buff)
fmt.Println(a)
}
3.3 字符串
Go 的字符串与 C 的字符串在底层的内存模型也不一样:
Go 的字符串并没有以'\0' 结尾,因此使用类似切片的方式,直接将 Go 字符串的首元素地址传递给 C 是不可行的。
3.3.1、Go 与 C 的字符串传递
cgo 给出的解决方案是标准库函数 C.CString(),它会在 C 内存空间内申请足够的空间,并将 Go 字符串拷贝到 C 空间中。因此 C.CString 申请的内存在 C 空间中,因此需要显式的调用 C.free 来释放空间,如 test3。
如下是 C.CString()的底层实现
func _Cfunc_CString(s string) *_Ctype_char { // 从Go string 到 C char* 类型转换
p := _cgo_cmalloc(uint64(len(s)+1))
pp := (*[1<<30]byte)(p)
copy(pp[:], s)
pp[len(s)] = 0
return (*_Ctype_char)(p)
}
//go:cgo_unsafe_args
func _cgo_cmalloc(p0 uint64) (r1 unsafe.Pointer) {
_cgo_runtime_cgocall(_cgo_bb7421b6328a_Cfunc__Cmalloc, uintptr(unsafe.Pointer(&p0)))
if r1 == nil {
runtime_throw("runtime: C malloc failed")
}
return
}
_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()的底层实现
//go:linkname _cgo_runtime_gostring runtime.gostring
func _cgo_runtime_gostring(*_Ctype_char) string
func _Cfunc_GoString(p *_Ctype_char) string { // 从C char* 到 Go string 类型转换
return _cgo_runtime_gostring(p)
}
//go:linkname gostring
func gostring(p *byte) string { // 底层实现
l := findnull(p)
if l == 0 {
return ""
}
s, b := rawstring(l)
memmove(unsafe.Pointer(&b[0]), unsafe.Pointer(p), uintptr(l))
return s
}
3.3.2、更高效的字符串传递方法
C.CString 简单安全,但是它涉及了一次从 Go 到 C 空间的内存拷贝,对于长字符串而言这会是难以忽视的开销。
Go 官方文档中声称 string 类型是”不可改变的“,但是在实操中可以发现,除了常量字符串会在编译期被分配到只读段,其他的动态生成的字符串实际上都是在堆上。
因此如果能够获得 string 的内存缓存区地址,那么就可以使用类似切片传递的方式将字符串指针和长度直接传递给 C 使用。
查阅源码,可知 String 实际上是由缓冲区首地址 和 长度构成的。这样就可以通过一些方式拿到缓存区地址。
type stringStruct struct {
str unsafe.Pointer //str首地址
len int //str长度
}
test11.go 将 fmt 动态生成的 string 转为自定义类型 MyString 便可以获得缓冲区首地址,将地址传入 C 函数,这样就可以在 C 空间直接操作 Go-String 的内存空间了,这样可以免去内存拷贝的消耗。
// test11.go
package main
/*
#include <string.h>
int SayHello(char* buff, int len) {
char hello[] = "Hello Cgo!";
int movnum = len < sizeof(hello) ? len:sizeof(hello);
memcpy(buff, hello, movnum);
return movnum;
}
*/
import "C"
import (
"fmt"
"unsafe"
)
type MyString struct {
Str *C.char
Len int
}
func main() {
s := fmt.Sprintf(" ")
C.SayHello((*MyString)(unsafe.Pointer(&s)).Str, C.int((*MyString)(unsafe.Pointer(&s)).Len))
fmt.Print(s)
}
这种方法背离了 Go 语言的设计理念,如非必要,不要把这种代码带入你的工程,这里只是作为一种“黑科技”进行分享。
3.4、结构体,联合,枚举
cgo 中结构体,联合,枚举的使用方式类似,可以通过 C.struct_XXX 来访问 C 语言中 struct XXX 类型。union,enum 也类似。
3.4.1、结构体
如果结构体的成员名字中碰巧是 Go 语言的关键字,可以通过在成员名开头添加下划线来访问
如果有 2 个成员:一个是以 Go 语言关键字命名,另一个刚好是以下划线和 Go 语言关键字命名,那么以 Go 语言关键字命名的成员将无法访问(被屏蔽)
C 语言结构体中位字段对应的成员无法在 Go 语言中访问,如果需要操作位字段成员,需要通过在 C 语言中定义辅助函数来完成。对应零长数组的成员(C 中经典的变长数组),无法在 Go 语言中直接访问数组的元素,但同样可以通过在 C 中定义辅助函数来访问。
结构体的内存布局按照 C 语言的通用对齐规则,在 32 位 Go 语言环境 C 语言结构体也按照 32 位对齐规则,在 64 位 Go 语言环境按照 64 位的对齐规则。对于指定了特殊对齐规则的结构体,无法在 CGO 中访问。
// test11.go
package main
/*
struct Test {
int a;
float b;
double type;
int size:10;
int arr1[10];
int arr2[];
};
int Test_arr2_helper(struct Test * tm ,int pos){
return tm->arr2[pos];
}
#pragma pack(1)
struct Test2 {
float a;
char b;
int c;
};
*/
import "C"
import "fmt"
func main() {
test := C.struct_Test{}
fmt.Println(test.a)
fmt.Println(test.b)
fmt.Println(test._type)
//fmt.Println(test.size) // 位数据
fmt.Println(test.arr1[0])
//fmt.Println(test.arr) // 零长数组无法直接访问
//Test_arr2_helper(&test, 1)
test2 := C.struct_Test2{}
fmt.Println(test2.c)
//fmt.Println(test2.c) // 由于内存对齐,该结构体部分字段Go无法访问
}
3.4.2、联合
Go 语言中并不支持 C 语言联合类型,它们会被转为对应大小的字节数组。
如果需要操作 C 语言的联合类型变量,一般有三种方法:第一种是在 C 语言中定义辅助函数;第二种是通过 Go 语言的"encoding/binary"手工解码成员(需要注意大端小端问题);第三种是使用 unsafe
包强制转型为对应类型(这是性能最好的方式)。
test12 给出了 union 的三种访问方式
// test12.go
package main
/*
#include <stdint.h>
union SayHello {
int Say;
float Hello;
};
union SayHello init_sayhello(){
union SayHello us;
us.Say = 100;
return us;
}
int SayHello_Say_helper(union SayHello * us){
return us->Say;
}
*/
import "C"
import (
"fmt"
"unsafe"
"encoding/binary"
)
func main() {
SayHello := C.init_sayhello()
fmt.Println("C-helper ",C.SayHello_Say_helper(&SayHello)) // 通过C辅助函数
buff := C.GoBytes(unsafe.Pointer(&SayHello), 4)
Say2 := binary.LittleEndian.Uint32(buff)
fmt.Println("binary ",Say2) // 从内存直接解码一个int32
fmt.Println("unsafe modify ", *(*C.int)(unsafe.Pointer(&SayHello))) // 强制类型转换
}
3.4.3、枚举
对于枚举类型,可以通过C.enum_xxx来访问 C 语言中定义的enum xxx结构体类型。
使用方式和 C 相同,这里就不列例子了
3.5、指针
在 Go 语言中两个指针的类型完全一致则不需要转换可以直接通用。如果一个指针类型是用 type 命令在另一个指针类型基础之上构建的,换言之两个指针底层是相同完全结构的指针,那么也可以通过直接强制转换语法进行指针间的转换。
但是 C 语言中,不同类型的指针是可以显式或隐式转换。cgo 经常要面对的是 2 个完全不同类型的指针间的转换,实现这一转换的关键就是 unsafe.Pointer,类似于 C 语言中的 Void*类型指针。
使用这种方式就可以实现不同类型间的转换,如下是从 Go - int32 到 *C.char 的转换。