cgo实践一:Go封装动态库供C使用

2,707 阅读5分钟

Go 语言中,使用 cgo 无非就两种主要场景:一种是 Go 生成动态库so文件(或静态文件),供其他语言如c来使用;另一种是使用 C 语言封装动态库,供 Go 调用。说白了就是谁负责生产so文件,谁来使用的问题。

截屏2022-03-24 下午8.22.36.png

首先来梳理下 Go 生成 c-shared 类动态库供其他语言使用的具体流程和细节。整体步骤大概就分为三个步骤:

  1. 开发接口函数;
  2. 构建so文件;
  3. 接入使用.

再开始之前需要做些前期准备,这里我们设计一个极简的 Go 工具函数库项目:crypto,项目目录如下:

crypto/
├── go.mod
└── main.go

项目功能简单,所以接口函数直接定义在 main.go 文件中, 文件名没有特殊要求,但是注意:对于一个要 build so 库的项目,Go 还是有些基础要求

  1. 整个导出项目必须要有一个 mainpackage 包;
  2. 既然需要有main包,那么就必须要有一个 main 入口函数,即便是个空函数;
  3. 如果要激活 cgo,还需要有 import "C" 语句,否则不会生成对应的 .h 头文件。

所以,最终的 main.go 最低要求代码结构如下:

// 以下三项必不可少
package main

import "C"

func main() {}

1. 开发接口函数

接口函数即是希望被调用的函数。如果想在其他语言中使用 Go 定义的函数,需要把函数使用 //export 注释标记。

package main

import "C"

func main() {}

//export encode_data
func encode_data(data *C.char) *C.char {
   // 逻辑代码
}

这里我们定义了一个 encode_data 接口函数,又有另一些细节需要注意

注意点一:

//export 其后的名称和下一行的函数名必须要保持一致,但函数命名是驼峰还是snake不做要求,也不要求以大写字母开头。如上函数最终生成的头文件函数如下:

extern char* encode_data(char* data);

注意点二:

此外,要注意函数形参和返回参数数据类型问题,Go的数据类型和C的不一样,尤其是指针、内存管理都不同,也不能直接使用。所以接口 Go 函数不能直接返回Go指针类型的数据,比如 string

// 错误做法
// string 是Go指针类型,C不能使用的。
func encode_data(data *C.char) string {}

如上代码直接返回一个 Go 字符串 string 类型,Go 字符串内部是包含Go指针的。如果直接供 C 调用会抛出:cgo result has Go pointer 错误。

正确的做法是使用cgo提供的包装函数如 C.CString() 来转一下,要么直接构造C对应的指针类型结构数据。

func encode_data(data *C.char) *C.char {
   return C.CString("c string") // 使用C类型指针数据
}

注意点三:

但这还没完!!虽然使用 C.CString 函数可以轻松返回 C类型的字符串(或者叫字符数组指针),但是这里会触发另一的潜在的问题。使用C.CString 构造的数据是在堆内存中,而不是在静态区。所以官方明确说明,这些数据需要手动释放:

var cmsg *C.char = C.CString("hi")
defer C.free(unsafe.Pointer(cmsg))

但在这个场景中,我们还不能立即释放,因为我们需要返回给调用方,他们还需要使用!

func encode_data(data *C.char) *C.char {
    var cmsg *C.char = C.CString("c string") 
    //defer C.free(unsafe.Pointer(cmsg)) <------- 不能释放,不然调用方拿不到数据了
   
   return cmsg
}

所以只能由调用方来释放这个数据,所以你需要和调用方进行沟通,让他们手动释放这类似的数据,虽然理解这个字符串被放在了堆里有些费劲。

int main() {
    char * encodeData = encode_data("test"); // 返回数据在heap中,不在静态区
    
    // 记得要释放
    free(encodeData);
}

2. 构建so文件

如果设计好函数接口,接下来的事情就很简单,直接使用 go build 工具即可搞定。

# 构建动态库文件
go build -o libcrypto.so -buildmode=c-shared main.go

# 构建静态库文件
go build -o libcrypto.a -buildmode=c-archive main.go

推荐输出文件命令以 lib* 作为前缀,这是 linux下约定俗成的标准, go build 会生成对应的头文件和库文件:

libcrypto.h
libcrypto.so #或 libcrypto.a

另外,如果你的导出函数功能设计还没准备好,想提前导出.h头文件,也是没问题的,可以直接使用:

go tool cgo -exportheader libcrypto.h main.go

go tool cgogo build 关于cgo还有很多更详细操作参数,具体可查看 help 手册。

3. 接入使用

有了 .h 文件和 .so 库,接入使用并不复杂,不光是 C 语言,luac++ 其实都可以使用。只需要在项目中使用这个头文件定义的函数即可,并在编译连接和运行环节配置好这个 libcrypto.so 库的路径,或者直接把它注册到系统路径下。

#include <stdio.h>
#include "libs/libcrypto.h"
#include <stdlib.h>

int main() {
    char * encoded = encode_data("hi cgo");
    printf("code: %s \n", encoded);
    
    // 注意内存释放
    free(encoded);

    return 0;
}

编译运行

# 编译
gcc -L./libs -lcrypto main.c -o app

# 运行
LD_LIBRARY_PATH=./libs/:${LD_LIBRARY_PATH} ./app

传输结果:

c string

这里演示项目是的 gcc,复杂项目推荐 cmake友情提示:macOS 下的动态库变量是:DYLD_LIBRARY_PATH

其外

在函数接口设计环节我们强调过,C.CString 语言的的字符串数据是在堆内存, 而不是在静态区,所以需要我们手动执行 free 函数进行手动内存回收,这里我们使用另一个牛逼的内存溢出检测工具来验证一下:valgrind

工具的介绍和安装请自行Google

LD_LIBRARY_PATH=./libs valgrind --leak-check=full ./app

输出结果

==29641== HEAP SUMMARY:
==29641==     in use at exit: 3,465 bytes in 7 blocks
==29641==   total heap usage: 13 allocs, 6 frees, 3,641 bytes allocated
==29641==
==29641== 9 bytes in 1 blocks are definitely lost in loss record 1 of 6
==29641==    at 0x4C29E03: malloc (vg_replace_malloc.c:309)
==29641==    by 0x4ECE904: _cgo_50f941cac2e5_Cfunc__Cmalloc (_cgo_export.c:47)
==29641==    by 0x4EC7C07: runtime.asmcgocall (/usr/local/go1.15/src/runtime/asm_amd64.s:656)
==29641==    by 0x4EA0249: runtime.newextram (/usr/local/go1.15/src/runtime/proc.go:1607)
==29641==    by 0xC0000005FF: ???
==29641==    by 0x4E9F41F: ??? (in /home/liangqi1/crypto/libs/libcrypto.so)
==29641==    by 0x1FFF00016F: ???
...
...
==29641== LEAK SUMMARY:
==29641==    definitely lost: 9 bytes in 1 blocks
==29641==    indirectly lost: 0 bytes in 0 blocks
==29641==      possibly lost: 3,456 bytes in 6 blocks
==29641==    still reachable: 0 bytes in 0 blocks
==29641==         suppressed: 0 bytes in 0 blocks

如上部分只保留了关键代码,如上溢出总结里:definitely lost 表示绝对存在内存溢出,溢出了9字节的数据,恰好是demo函数返回的:c string 外加一个C语言结束符号\0,刚好9字节。

由于 valgrind 对于.so动态内部函数栈的追踪也无能为力,所以定位不到具体是代码哪一行,只能是??? 表示。不过可以通过打开或注释掉 free() 函数代码,对比排查。