【GO】Golang/C++混合编程 - 进阶一

文章系列
【GO】Golang/C++混合编程 - SWIG
【GO】Golang/C++混合编程 - 初识
【GO】Golang/C++混合编程 - 入门
【GO】Golang/C++混合编程 - 基础
【GO】Golang/C++混合编程 - 进阶一
【GO】Golang/C++混合编程 - 进阶二
【GO】Golang/C++混合编程 - 实战

Golang/C++混合编程

编译过程

GO 调用 C

对于比较简单的 CGO 代码我们可以直接通过手动调用go tool cgo命令来查看生成的中间文件。

// 例:
package main

//int sum(int a, int b) { return a+b; }
import "C"

func main() {
    println(C.sum(1, 1))
}
go tool cgo main.go
# 生成的中间文件目录

$ ls _obj | awk '{print $NF}'
_cgo_.o
_cgo_export.c
_cgo_export.h
_cgo_flags
_cgo_gotypes.go
_cgo_main.c
main.cgo1.go
main.cgo2.c

# 其中_cgo_.o、_cgo_flags和_cgo_main.c文件和我们的代码没有直接的逻辑关联,可以暂时忽略
// main.cgo1.go,它是`main.go`文件展开虚拟 C 包相关函数和变量后的 GO 代码

package main
//int sum(int a, int b) { return a+b; }

import _ "unsafe"
func main() {
    println((_Cfunc_sum)(1, 1))
}

// 其中`C.sum(1, 1)`函数调用被替换成了`(_Cfunc_sum)(1, 1)`, 每一个`C.xxx`形式的函数都会被替换为`_Cfunc_xxx`格式的纯 GO 函数,其中前缀`_Cfunc_`表示这是一个C函数,对应一个私有的 GO 桥接函数
// _cgo_gotypes.go

//go:cgo_unsafe_args
func _Cfunc_sum(p0 _Ctype_int, p1 _Ctype_int) (r1 _Ctype_int) {
    _cgo_runtime_cgocall(_cgo_506f45f9fa85_Cfunc_sum, uintptr(unsafe.Pointer(&p0)))
    if _Cgo_always_false {
        _Cgo_use(p0)
        _Cgo_use(p1)
    }
    return
}

// `_Cfunc_sum`函数在 CGO 生成的
// 其参数和返回值`_Ctype_int`类型对应`C.int`类型,命名的规则和`_Cfunc_xxx`类似,不同的前缀用于区分函数和类型
// 
// 其中`_cgo_runtime_cgocall`对应`runtime.cgocall`函数,函数的声明如下:
// func runtime.cgocall(fn, arg unsafe.Pointer) int32
// 第一个参数是 C 语言函数的地址,第二个参数是存放 C 语言函数对应的参数结构体的地址
// main.cgo2.c
// 
// 被传入C语言函数`_cgo_506f45f9fa85_Cfunc_sum`也是 CGO 生成的中间函数
void _cgo_506f45f9fa85_Cfunc_sum(void *v) {
    struct {
        int p0;
        int p1;
        int r;
        char __pad12[4];
    } __attribute__((__packed__)) *a = v;
    char *stktop = _cgo_topofstack();
    __typeof__(a->r) r;
    _cgo_tsan_acquire();
    r = sum(a->p0, a->p1);
    _cgo_tsan_release();
    a = (void*)((char*)a + (_cgo_topofstack() - stktop));
    a->r = r;
}
// 这个函数参数只有一个void范型的指针,函数没有返回值。真实的sum函数的函数参数和返回值均通过唯一的参数指针类实现
//
// _cgo_506f45f9fa85_Cfunc_sum函数的指针指向的结构为:
//      struct {
//         int p0;
//         int p1;
//         int r;
//         char __pad12[4];
//     } __attribute__((__packed__)) *a = v;
// 其中p0成员对应sum的第一个参数,p1成员对应sum的第二个参数,r成员,__pad12用于填充结构体保证对齐CPU机器字的整倍数
// 然后从参数指向的结构体获取调用参数后开始调用真实的C语言版sum函数,并且将返回值保持到结构体内返回值对应的成员

因为 GO 语言和 C 语言有着不同的内存模型和函数调用规范。其中_cgo_topofstack函数相关的代码用于 C 函数调用后恢复调用栈。_cgo_tsan_acquire_cgo_tsan_release则是用于扫描 CGO 相关的函数则是对 CGO 相关函数的指针做相关检查,C.sum的整个调用流程图如下:

cgo1.png

C 调用 GO

package main

//int sum(int a, int b);
import "C"

//export sum
func sum(a, b C.int) C.int {
    return a + b
}

func main() {}

// 同上一讲,export 关键字,指明 sum 函数是导出的,可以被 C 代码调用
// go build -buildmode=c-archive -o sum.a sum.go -> 编译为 C 静态库
// 此时会生成一个`sum.a`静态库和`sum.h`头文件
go tool cgo main.go
# 生成的中间文件目录

$ ls _obj | awk '{print $NF}'
_cgo_export.c
_cgo_export.h
_cgo_gotypes.go
main.cgo1.go
main.cgo2.c

# 其中仅包含了需要关心的文件

其中_cgo_export.h文件的内容和生成C静态库时产生的sum.h头文件是同一个文件,里面同样包含sum函数的声明

// _cgo_export.c

int sum(int p0, int p1)
{
    __SIZE_TYPE__ _cgo_ctxt = _cgo_wait_runtime_init_done();
    struct {
        int p0;
        int p1;
        int r0;
        char __pad0[4];
    } __attribute__((__packed__)) a;
    a.p0 = p0;
    a.p1 = p1;
    _cgo_tsan_release();
    crosscall2(_cgoexp_8313eaf44386_sum, &a, 16, _cgo_ctxt);
    _cgo_tsan_acquire();
    _cgo_release_context(_cgo_ctxt);
    return a.r0;
}

// sum 函数的内容采用和前面类似的技术,将 sum 函数的参数和返回值打包到一个结构体中,然后通过`runtime/cgo.crosscall2`函数将结构体传给`_cgoexp_8313eaf44386_sum`函数执行
//
// `runtime/cgo.crosscall2`函数采用汇编语言实现,它对应的函数声明如下
// func runtime/cgo.crosscall2(
//     fn func(a unsafe.Pointer, n int32, ctxt uintptr),
//     a unsafe.Pointer, n int32,
//     ctxt uintptr,
// )
// fn是中间代理函数的指针,a是对应调用参数和返回值的结构体指针
// 中间的`_cgoexp_8313eaf44386_sum`代理函数在`_cgo_gotypes.go`文件
// _cgo_gotypes.go

func _cgoexp_8313eaf44386_sum(a unsafe.Pointer, n int32, ctxt uintptr) {
    fn := _cgoexpwrap_8313eaf44386_sum
    _cgo_runtime_cgocallback(**(**unsafe.Pointer)(unsafe.Pointer(&fn)), a, uintptr(n), ctxt);
}
func _cgoexpwrap_8313eaf44386_sum(p0 _Ctype_int, p1 _Ctype_int) (r0 _Ctype_int) {
    return sum(p0, p1)
}
// 内部将 sum 的包装函数`_cgoexpwrap_8313eaf44386_sum`作为函数指针,然后由`_cgo_runtime_cgocallback`函数完成 C 语言到 GO 函数的回调工作
// `_cgo_runtime_cgocallback`函数对应`runtime.cgocallback`函数,函数的类型如下:
// func runtime.cgocallback(fn, frame unsafe.Pointer, framesize, ctxt uintptr)

其调用流程,大致如下:


cgo2.png

内存模型

CGO 是架接 GO 语言和 C 语言的桥梁,它使二者在二进制接口层面实现了互通,但是我们要注意因两种语言的内存模型的差异而可能引起的问题。如果在 CGO 处理的跨语言函数调用时涉及到了指针的传递,则可能会出现 GO 语言和 C 语言共享某一段内存的场景。我们知道 C 语言的内存在分配之后就是稳定的,但是 GO 语言因为函数栈的动态伸缩可能导致栈中内存地址的移动。如果 C 语言持有的是移动之前的 GO 指针,那么以旧指针访问 GO 对象时会导致程序崩溃。

GO 访问 C 内存

C语言空间的内存是稳定的,只要不是被人为提前释放,那么在Go语言空间可以放心大胆地使用。

package main
/*
#include <stdlib.h>
void* makeslice(size_t memsize) {
    return malloc(memsize);
}
*/
import "C"
import "unsafe"

func makeByteSlize(n int) []byte {
    p := C.makeslice(C.size_t(n))
    return ((*[1 << 31]byte)(p))[0:n:n]
}

func freeByteSlice(p []byte) {
    C.free(unsafe.Pointer(&p[0]))
}

func main() {
    s := makeByteSlize(1<<32+1)
    s[len(s)-1] = 255
    print(s[len(s)-1])
    freeByteSlice(s)
}
// 我们通过`makeByteSlize`来创建大于4G内存大小的切片,从而绕过了 GO 语言实现的限制(需要代码验证)。而`freeByteSlice`辅助函数则用于释放从 C 语言函数创建的切片。

C 访问 GO 内存

参考值传递,每次都对内存进行拷贝:

package main
/*
void printString(const char* s) {
    printf("%s", s);
}
*/
import "C"

func printString(s string) {
    cs := C.CString(s)
    defer C.free(unsafe.Pointer(cs))
    C.printString(cs)
}

func main() {
    s := "hello"
    printString(s)
}

在 CGO 调用的 C 语言函数返回前,CGO 保证传入的 Go 语言内存在此期间不会发生移动,C 语言函数可以大胆地使用 GO 语言的内存

package main
/*
#include<stdio.h>
void printString(const char* s, int n) {
    int i;
    for(i = 0; i < n; i++) {
        putchar(s[i]);
    }
    putchar('\n');
}
*/
import "C"

func printString(s string) {
    p := (*reflect.StringHeader)(unsafe.Pointer(&s))
    C.printString((*C.char)(unsafe.Pointer(p.Data)), C.int(len(s)))
}

func main() {
    s := "hello"
    printString(s)
}

我们通过reflect.StringHeader结构体来获取字符串的指针和长度,然后直接将指针和长度传递给 C 语言函数。
但此时存在几个可能发生的问题,比如字符串的长度超过 C 语言函数的参数限制,或者字符串的长度大于 C 语言函数的栈空间,那么 C 语言函数就会发生栈溢出;另外,如果 C 语言函数持有这块内存过久,这会导致 GO 语言内协程注册的栈内存无法被回收,从而导致这个协程阻塞,并影响 GO 语言 GC 的效率。

C 长期持有 GO 指针对象

当 C 语言调用 GO 函数时,若存在返回值情况,那么此时 GO 对象内存的生命周期就超出了 GO 语言的管理范围,因为 C 语言可能正在使用这块内存,此时如果 GO 语言 GC 回收了这块内存,那么 C 语言就会访问到一块无效的内存,导致程序崩溃。所以,我们不能在 C 语言中直接使用 GO 语言对象的内存。
但是这种情况是存在的,比如 C 语言需要持有 GO 语言对象的内存,并在后续的调用中使用,那么这种情况下,我们需要将内存拷贝到 C 语言中,或者借鉴内存管理的思路,让 C 语言和 GO 语言共同管理这块内存(GO 语言提供方法给 C 语言使用)。
为了减少拷贝带来的性能开销,我们主要使用方法二,其例子如下:

package main

import "sync"

type ObjectId int32

var refs struct {
    sync.Mutex
    objs map[ObjectId]interface{}
    next ObjectId
}

func init() {
    refs.Lock()
    defer refs.Unlock()
    refs.objs = make(map[ObjectId]interface{})
    refs.next = 1000
}

func NewObjectId(obj interface{}) ObjectId {
    refs.Lock()
    defer refs.Unlock()
    id := refs.next
    refs.next++
    refs.objs[id] = obj
    return id
}

func (id ObjectId) IsNil() bool {
    return id == 0
}

func (id ObjectId) Get() interface{} {
    refs.Lock()
    defer refs.Unlock()
    return refs.objs[id]
}

func (id *ObjectId) Free() interface{} {
    refs.Lock()
    defer refs.Unlock()
    obj := refs.objs[*id]
    delete(refs.objs, *id)
    *id = 0
    return obj
}

上述代码可以看到,我们通过一个map来管理Go语言对象和id对象的映射关系。其中NewObjectId用于创建一个和对象绑定的id,而id对象的方法可用于解码出原始的Go对象,也可以用于结束id和原始Go对象的绑定。
下面一组函数以C接口规范导出,可以被C语言函数调用:

package main

/*
extern char* NewGoString(char* );
extern void FreeGoString(char* );
extern void PrintGoString(char* );
static void printString(const char* s) {
    char* gs = NewGoString(s);
    PrintGoString(gs);
    FreeGoString(gs);
}
*/
import "C"

//export NewGoString
func NewGoString(s *C.char) *C.char {
    gs := C.GoString(s)
    id := NewObjectId(gs)
    return (*C.char)(unsafe.Pointer(uintptr(id)))
}

//export FreeGoString
func FreeGoString(p *C.char) {
    id := ObjectId(uintptr(unsafe.Pointer(p)))
    id.Free()
}

//export PrintGoString
func PrintGoString(s *C.char) {
    id := ObjectId(uintptr(unsafe.Pointer(p)))
    gs := id.Get().(string)
    print(gs)
}

func main() {
    C.printString("hello")
}

printString函数中,我们通过NewGoString创建一个对应的 GO 字符串对象,返回的其实是一个 id,不能直接使用。我们借助PrintGoString函数将 id 解析为 GO 语言字符串后打印。该字符串在 C 语言函数中完全跨越了 GO 语言的内存管理,在PrintGoString调用前即使发生了栈伸缩导致的 GO 字符串地址发生变化也依然可以正常工作,因为该字符串对应的 id 是稳定的,在 GO 语言空间通过 id 解码得到的字符串也就是有效的。

导出 C 函数不能返回 GO 内存

在 GO 语言中,GO 是从一个固定的虚拟地址空间分配内存。而 C 语言分配的内存则不能使用 GO 语言保留的虚拟内存空间。在CGO 环境,GO 语言运行时默认会检查导出返回的内存是否是由 GO 语言分配的,如果是则会抛出运行时异常。

/*
extern int* getGoPtr();
static void Main() {
    int* p = getGoPtr();
    *p = 42;
}
*/
import "C"

func main() {
    C.Main()
}

//export getGoPtr
func getGoPtr() *C.int {
    return new(C.int)
}

其中getGoPtr返回的虽然是 C 语言类型的指针,但是内存本身是从 GO 语言的new函数分配,也就是由 GO 语言运行时统一管理的内存。然后我们在 C 语言的Main函数中调用了getGoPtr函数,此时默认将发送运行时异常

$ go run main.go

panic: runtime error: cgo result has Go pointer
goroutine 1 [running]:
main._cgoexpwrap_cfb3840e3af2_getGoPtr.func1(0xc420051dc0)
  command-line-arguments/_obj/_cgo_gotypes.go:60 +0x3a
main._cgoexpwrap_cfb3840e3af2_getGoPtr(0xc420016078)
  command-line-arguments/_obj/_cgo_gotypes.go:62 +0x67
main._Cfunc_Main()
  command-line-arguments/_obj/_cgo_gotypes.go:43 +0x41
main.main()
  /Users/chai/go/src/github.com/chai2010 \
  /advanced-go-programming-book/examples/ch2-xx \
  /return-go-ptr/main.go:17 +0x20
exit status 2

异常说明 CGO 函数返回的结果中含有 GO 语言分配的指针。指针的检查操作发生在 C 语言版的getGoPtr函数中,它是由 CGO 生成的桥接 C 语言和 GO 语言的函数。
下面是cgo生成的C语言版本getGoPtr函数的具体细节(在cgo生成的_cgo_export.c文件定义):

int* getGoPtr()
{
    __SIZE_TYPE__ _cgo_ctxt = _cgo_wait_runtime_init_done();
    struct {
        int* r0;
    } __attribute__((__packed__)) a;
    _cgo_tsan_release();
    crosscall2(_cgoexp_95d42b8e6230_getGoPtr, &a, 8, _cgo_ctxt);
    _cgo_tsan_acquire();
    _cgo_release_context(_cgo_ctxt);
    return a.r0;
}

其中_cgo_tsan_acquire是从LLVM项目移植过来的内存指针扫描函数,它会检查 CGO 函数返回的结果是否包含 GO 指针。
需要说明的是,CGO 默认对返回结果的指针的检查是有代价的,特别是 CGO 函数返回的结果是一个复杂的数据结构时将花费更多的时间。如果已经确保了 CGO 函数返回的结果是安全的话,可以通过设置环境变量GODEBUG=cgocheck=0来关闭指针检查行为。
其中,0:关闭,1:默认,2:更严格的检查。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,122评论 6 505
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,070评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,491评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,636评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,676评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,541评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,292评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,211评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,655评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,846评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,965评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,684评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,295评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,894评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,012评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,126评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,914评论 2 355

推荐阅读更多精彩内容