柴树杉:深入 CGO 编程

百家 作者:QingCloud 2018-05-31 09:57:06

作者简介

柴树杉
青云QingCloud 应用平台研发工程师,开源的多云应用管理平台 OpenPitrix 开发者,Go 语言代码的贡献者,《Go 语言圣经》翻译者,《Go 语言高级编程》开源免费图书作者。2010 年开始参与和组织 Go 语言早期文档翻译,2013 年正式转向 Go 语言开发,CGO 资深用户。


目录


1. CGO 的价值

2. 快速入门

3. 类型转换

4. 函数调用

5. CGO 内部机制

6. 实践:包装 c.qsort

7. 内存模型

8. Go 和 C++ 对象


背景


在 2017 年底初步完成了《Go 语言高级编程》的第二章 CGO 编程部分。当时刚好 GopherChina2018 在招聘讲师,我就找谢孟军申请了 CGO 的分享主题。


《Go 语言高级编程》第二章 CGO 编程:

https://github.com/chai2010/advanced-go-programming-book


选择 CGO 作为分享主题的原因有二:


  • 一是国内外对 CGO 编程的分享主题比较少;


  • 二是我想借此机会重新将 CGO 编程的部分的内容彻底梳理一次。


CGO的价值


1. 没有银弹,Go 也不是银弹,无法解决全部问题。

2. 通过 CGO 可以继承 C/C++ 将近半个世纪的软件积累,站在巨人的肩膀之上。

3. 通过可以用 CGO 可以用 Go 给其他系统写 C 接口的共享库。

4. CGO 是 Go 给其他语言直接通讯的桥梁。


CGO 是一个保底的后备技术。


可能的 CGO 场景:


  • 通过 OpenGL 或 OpenCL 使用显示卡的计算能力;

  • 通过 OpenCV 来进行图像分析;

  • 通过 Go 编写 Python 扩展;

  • 通过 Go 编写移动应用。


    快速入门


    其实 CGO 程序可以非常简单:只要包含一个 import “C” 语句就表示已经启用 CGO。当然这这种程序没有多少实际用途。


    下是相对简单一点的 CGO 程序:通过 C 语言的输出函数puts输出一段信息。



    在 import “C" 语句前面增加来 语句,通过包含这个头文件,我们可以使用 C 语言的 C.puts 函数实现输出字符串的功能。


    然后输入 go run main.go 命令就执行就可以执行该 CGO 程序了。当然,构建 CGO 程序的一个前提是要安装 GCC 编译器。


    1.1 调用自定义的C函数



    刚才是使用 puts 输出字符串,现在可以前进一步:通过自定义的函数实现输出。


    我们同样在 import "C" 语句之前的注释里面实现自定义的 Sayhello 函数。调用自己定义的函数实现某些功能,这是任何编程语言学习过程中非常重要的一个阶段。


    1.2 C 代码模块化



    模块化是一种重要的编程方法:


    • 当程序中的一行语句太长的时候,我们希望将代码拆分为多行;

    • 当代码语句多到一定层度,我们就会将代码拆分为函数;

    • 如果一个文件中的函数太多,则希望将函数拆分到多个文件中重新组织。


    以上这些都是采用模块化的思路来简化代码的组织。


    对于前面的 SayHello 函数,我们也可以采用模块化的测试来重新组织。首选创建一个 hello.h 头文件,里面包含 SayHello 函数的声明。然后将 SayHello 函数的实现放到 hello.c 文件中。


    在 CGO 代码中,就可以通过 #include "hello.h" 的方式直接引用 SayHello 函数。


    1.3 Go 语言实现 C 模块


    创建 hello.h 头文件是模块化编程的一个重要里程碑。


    对于 SayHello 函数的用户来说,我们只需要知道 SayHello 函数满足 C 语言函数的调用规约即可。至于 SayHello 函数是采用 C 语言或 C++ 语言、甚至是其它任何语言实现的,对于SayHello 函数的用户并没有区别。


    因此,我们可以该 Go 语言重新实现SayHello 函数。



    hello.h 头文件包含 SyaHello 函数声明,但是 hello.c 变成了 hello.go,函数本身从 C 语言实现改成了用 Go 语言实现。


    Go 语言实现的函数和 C 语言版本的函数名字和参数类型几乎是完全一致的(Go 导出的 C 函数不支持 const 修饰),因此对于 SayHello 函数的用户来说并没有太多的差异。


    现在可以说我是采用 C 语言思维编程的 Go 语言码农。


    1.4 手中无剑,心中有剑


    在模块化的基础上,我们采用 Go 重新实现了 C 语言规格的 SayHello 函数。现在我们可以尝试打破模块化编程的思路:删除 hello.h 头文件,将全部的 CGO 代码统一到一个 Go 源文件中:



    这时候虽然没有了头文件的函数声明,但是 SayHello 函数的声明在我们 Go 语言码农的心中。


    我们通过 extern 的方式在 CGO 中手工声明 SayHello 函数。然后在 main 函数中调用一个目前还不真是存在的 SayHello 函数进行字符串输出。


    这个例子其实90%以上是 Go 语言代码,但是编程的思维是 C 语言。


    1.5 忘掉心中之剑


    前面的实现中,虽然手中无剑,但是心中有剑:在导出 SayHello 函数时,依然采用了 C 语言的字符串格式。


    为此,在输出 Go 语言字符串时,需要先转换为C 语言格式的字符串;然后在 Go 语言中输出 C 语言字符串时,有需要转回 Go语言字符串;最后还需要释放中间临时创建的 C 语言字符串。


    这是心中编程思维被 C 语言字符串固化的结果。


    我们需要忘掉 C 语言原有的字符串结构:其实从 C 语言角度看来,Go 语言字符串也是一种特殊格式的字符串。



    新的实现采用 Go 语言格式的字符串作为 SayHello 函数的参数,中间将不再有额外的字符串转换的开销。


    思考题:main 函数和 SayHello 函数是否是运行在同一个 Goroutine?


    类型转换



    有些编程语言的教程中将“数据结构+算法”作为程序的定义。数据结构对应一切变量和变量对应的结构化数据,算法可以近似看作是函数的内在逻辑。


    因此如何解决不同类型变量之间的数据转换是第一个要解决的问题。


    在 C 语言中,对不同类型之间的转换相当灵活,甚至普通整数和函数指针也可以自由直接转换。但是 Go 则对不同类型之间的转换有着非常严格的限制。指针是 C 语言的核心类型,因此 CGO 中围绕指针周边的类型转换也是第一个要解决的问题。


    为此 Go 语言提供了一个 unsafe 包,用于提供不安全的类型转换。其实 unsafe包是一个非常安全的包,但是前提是你要彻底理解 unsafe 操作底层的含义。如果离开了 unsafe 包,CGO 编程将寸步难行!


    CGO 编程中会涉及到 Go 指针和 C 指针之间的转换,还有数值类型和指针之间的转换。不同类型的指针转换,字符串和切片的转换,基本上主要布局在指针类型、数值类型,字符串和切片。


    2.1 指针- unsafe包的灵魂



    指针是 C 语言的灵魂,自然也是 unsafe 包的灵魂。


    unsafe.Pointer 对应 C 语言的 void 类型指针,是 GC 垃圾回收器要管理的对象;uintptr 则是数值化的指针,并不参与 GC 的管理。


    在 C 语言中 uintptr 和指针并无太大差异,但是在Go 语言中二者确实完全不同的类型。因为 Go 语言的指针可能会因为栈的伸缩而被移动,移动时 GC 会自动维护指针的变化,uintptr 类型的变量将无法实现指针被移动时的自动更新。 


    2.2 unsafe 包




    unsafe 包的每个用法在 C/C++ 语言中都有对应的特性,熟悉 C 语言的用户应该比较容易理解。


    2.3 Go 字符串和切片的结构



    Go 语言和 C 语言是不同的语言,CGO 是二者连接的桥梁。


    CGO 也可以实现两者的数据共享,底层的基础正是扁平化的内存。因此有着扁平化内存结构的Go 字符串和 Go 字节切片将是 CGO 中需要频繁处理的类型。


    字符串和切片的结构在 reflect.StringHeader 和  reflect.SliceHeader 定义,CGO 中会生成对应的 C 语言结构体。


    GoString 和 GoSlice 和头部结构是兼容的。这样可以保证字符串和切片是兼容的,如果一个字符串转成切片或者反向转过来,某种优化的时候就是一个指针加一个长度。


    2.4  实践:int32 和 C.char 指针相互转换



    第一种是普通整数类型到指针类型的转换。


    中间需要数值化的指针类型 unitptr 和通用指针类型 unsafe.Pointer 作为转换中介。基于这个技术,就可以实现任何数值类型到任何指针类型的强制转换。



    这个代码主要是刚才图的描述。


    2.5  X 和 Y 的相互转换



    然后是 X* 和 Y* 相互转换比较简单,通过 unsafe.Pointer 做中介就可以达成转换。



    这个是 P 到 Q 的转换,然后可以转成 X* 和 Y*。


    2.6  [ ]X 和 [ ]Y 的相互转换



    不同类型的切片之间的转换则比较复杂。因为切片是值类型,我们无法对两个不同的值类型对象做转换,因为两个类型在内存的大小可能并不相同。



    转换不同的值类型的第一步是将值类型转为指针类型(因为任何类型的指针大小是相同的)。


    对于切片来说,通过 PX 和 PY 转换为两个指向不同切片类型的指针,切片指针的底层结构都是对应同一个 reflect.SliceHeader 类型。


    然后通过指针实现不同类型切片头部的复制,就是实现了 X 和 Y 切片的转换。


    2.7  实例:float64 数组排序优化



    比如要对正规的 float64 数组就行排序。


    如果 CPU 没有浮点运算的指令,我们可以将 float64 切片作为 int64 切片来排序(可能会快一点)。


    具体的原理是:float64 是遵循 IEEE754 标准的浮点数,当浮点数有序时作为整数也是有序的(不考虑非数和正负 0 的问题)。


    在 AMD64 平台,我们用前面的技术将 [ ]float64 转为 [ ]int,然后就可以用sort.Int 对浮点数进行排序了。


    函数调用


    在准备好正确类型的参数之后,函数调用本身的语法比较简单,因为底层繁琐的细节已经由 CGO 处理掉了。


    函数调用有2个方向:最常见的是 Go 调用 C 函数,然后是 C 函数回调 Go 函数,以及这两种调用的相互嵌套。


    3.1  Go调用C函数



    Go 调用 C 是通过一个虚拟的 C 包访问,最终 C.add 会被转为 _Cfunc_add 调用。隐含的推论是 C 包的全部 Go 符号都是私有的,只有在当前包可以访问。


    因此在不同的 Go 包之间的函数调用中,如果出现 C 包符号跨越构造,基本是无法编译通过的(因为是各自包的私有类型,无法共享)。


    C 函数最多只能返回一个值。但是 CGO 中的 C 函数,可以返回两个值:



    C 语言标准库有个 errno 全局变量(好像是每个线程一个),用于保存错误状态。因此 C 语言函数的错误状态是用这个全局的 errno 变量返回的(因为 C 函数只能有一个返回值)。


    为了简化 errno 的读取,CGO 可以将 errno 作为第二个返回值返回。



    这个例子中 seterrno 将参数保存到 errno,然后用第二个返回值返回了。第二个返回值虽然是一个 error 接口类型,但是底层其实对应的是 syscall.Errno 类型。


    因为有第二个返回值这个奇怪的特性,我们甚至获取一个 void 类型的 C 函数的返回值:



    这个 seterrno 的函数是返回 void 类型,但是作为 CGO 来说,第一个返回值是一个占位,虽然是空的,但是可以拿出来。


    通过这种方式,我们可以查看 void  在CGO 中对应的 Go 实现:内部对应 type_ctype_void [0] byte 类型,这是一个内存大小为 0 的类型。


    虽然没有什么实际用途,但是可以加深对CGO底层实现的理解。


    3.2 导出 Go 函数


    正因为 Go 函数可以导出为 C 函数,CGO 生态才真正地实现了闭环。正如毛主席的群众路线所言:CGO 让 Go 码农从 C 语言中来,也可以回到 C 语言中去。



    Go 语言导出 C 函数很简单,通过 export 注释导出 add。


    要注意的是,导出 C 函数的名字要和 Go 函数的名字保持一致,同时函数的参数和返回值类型要尽量采用 C 语言类型。


    CGO会 生成一个 _cgo_export.h 头文件,通过该函数就可以引用导出的 C 函数:



    虽然使用自动生成的 _cgo_export.h 文件会比较简单,但是我们并不推荐这样做。


    其实我们完全可以先给要导出的函数创建头文件(先设计 API,然后才是API 的实现),也就是在快速入门章节建议的先定义 C 函数接口。然后才是用 Go 语言实现了自定义的 C 头文件夹定义的 C 函数。


    目前我们可以简单声明导出的函数就可以使用了:



    割断对 _cgo_export.h 文件的依赖还有一个好处:可以摆脱对 CGO 文件的依赖。最小化依然是每个 C 程序员都要追求的目标,因为这可以极大减少编译时的各自问题。



    但是这个例子比较特殊,导出 C 函数的参数是 Go 语言字符串类型,在 C 语言中对应的 GoString 类型在 _cgo_export.h 文件定义。如果依赖 _cgo_export.h 文件,那么间接导致对自身的依赖,也就是出现了环形依赖。


    解决的一个办法是通过拆分文件,将 SayHello 函数的定义和使用放到不同的 Go 文件中。但是对于这个小小的程序来说,拆分为多个文件就有点小题大做了。


    我们不是一个人在战斗,因为世界上也有其它的 Gopher 遇到了同样的问题。因此,Go1.10 新增加了一种 _GoString_ 预定义的类型,可用于摆脱为 _cgo_export.h 文件的依赖:



    这样我们就可以不拆分文件构造一个闭环使用的 CGO 例子。


    Go 内部机制


    CGO 会生成很多中间文件,而理清中间文件的类型是理解 CGO 工作的第一步。


    4.1 CGO 生成的中间文件


    每个 CGO 文件会展开为一个 Go 文件和 C 文件,分别以 .cgo1.go 和 .cgo2.c 为后缀名。



    然后 _cgo_gotypes.go 对应 C 导入到 Go 语言中相关函数或变量的桥接代码。而 _cgo_export.h 对应导出的 Go 函数和类型,_cgo_export.c 对应相关包装代码的实现。


    4.2  内部调用流程 Go->C


    先构造一个最简单的调用 C 函数的例子:



    虽然 CGO 代码看起来简单,但是内部实现非常复杂,下面是 C.sun 函数详细的调用流程:



    白色的部分,是我们自己写的代码,黄色部分是 CGO 生成的代码,左边两列浅黄色是 Go 语言的空间,右边就是 C 语言运行空间。


    在中间位置出现了两个黑的横杠隔开了,黑的横杠中间为 C 语言运行空间。


    4.3 内部调用流程:C->Go


    构造一个 Go 导出 C 函数的例子:



    内存调用流程:



    对应的函数调用流程图:



    途中的细节我们不再赘述,感兴趣的同学可以自行研究。


    实践:包装 C.qsort



    因为时间关系就不展开 qsort 这个例子了,对 CGO 感兴趣的同学可以挑战一下。基于 C 语言的 qsort 函数包装出一个类似标准库中 sort.Slice 的函数来。


    内存模型


    前面讲述的语法部分是属于 CGO 的招式,招式出问题编译器就可以马上纠正。


    而内存模型则是 CGO 的内功,如果运行时内存出现问题则是大问题,编译器是无法提前发现的。


    5.1 结构体对齐



    为何有结构体对齐这个问题呢?


    这是 CPU 对基础类型有对齐要求,只有对齐的数据才能提高效率,甚至是只有对齐的数据才能产生正确的结果。一个原则:结构体数组的每个元素的每个成员必须对齐。


    如果 C 代码中没有涉及结构体对齐部分,则可以忽略该内容。



    这个左边是 32 体位的对齐,右边是 64 位的。


    5.2 堆和栈



    堆和栈是目前程序的约定概念。Go 语言中虽然有堆有栈,但是我们不知道在哪里。


    Go 语言的堆在哪里,不知道,栈在哪里也不知道。函数局部变量是在栈上还是堆上也不知道。甚至 Go 语言的规范中都没有提到堆和栈这种概念!



    C语言用户第一次看到 getv 函数,会觉得是一个 BUG:将栈上的变量地址返回了,而函数返回后栈地址将失效!


    但是,Go 语言并不是 C 语言,拿 C 语言的标准来分析 Go 语言的程序显然是不合理的。这和九品芝麻官中拿前朝的上方宝剑来斩今朝的官是一样的可笑。


    在 Go 语言中,如果一个变量需要在堆上,那么它就是在堆上;如果一个变量在栈上更好,那么它也可以在栈上。


    5.3  GC/ 动态栈有何影响 



    动态栈是 Go 语言的特色:不用担心深度递归的函数调用不会爆栈。


    但是动态内存的代价是(Go1.4+),每个函数的入口需要增加是否增长栈的代码(性能要满一点点);同时栈的移动导致栈上变量地址变化,需要同步更新栈指针,也导致了无法将 Go 对象的指针直接传入 C 函数(假设内存没有被回收)。


    5.4 顺序一致性内存模型



    顺序一致性内存模型主要和并发编程有关系,和 CGO 的交集并不多,这里不再展开。


    5.5 CGO 指针的使用原则



    5.6  C 内存到 Go 内存




    C 内存分配后就不会变化,传入 Go 语言空间后可以放心使用。


    5.7 Go 内存临时到 C 内存



    临时作为参数传入 C 函数的 Go 内存,在 C 函数返回前有效,此时 Go 运行时会锁定被引用的 Go 内存。


    但是任何好处都是有代价的,如果 C 函数 1 个小时不返回,那么调用 C 函数的 Goroutine 将被彻底锁定。


    5.8 C长期持有 Go 内存


    C 中无法长期持有 Go 内存:因为 Go 内存指针可能会发生变化,而 GC 无法管理被 C 持有的 Go 指针。


    一个折中的办法是在 Go 语言空间将易变的指针绑定一个不变而且唯一的整数变量 ID。




    当指针不再需要时,直接释放 ID 对应的指针资源:



    而在导出的 C 语言函数参数中,可以将 ID 直接当作指针类型。只是在使用 ID 类型的指针前,需要将 ID 解包为真正的  Go 指针。



    通过这种方法,可以将 Go 对象指针传入任何语言中引用。


    Go 访问 C++ 对象


    因为时间关系,Go 访问 C++ 对象我就不展开了,都是前面理论的延伸。


    把 C++ 转成 C 接口,通过 Go 来访问 C++ 对象,反过来就是 Go 对象导出为 C++ 对象展开成全局的 Go 函数,导出为 C 语言函数,再把 C 语言函数包装成 C++对象。


    给大家看一个比较有意思的 C++ 用法,叫还我自由的 C++ 指针:



    如果你是 Go 语言用户,看到这个代码会有似曾相识的感觉。这个 C++ 类没有成员,我定义了普通整数 x,在使用 x 的时候,强制转移成自定义的 Int 类型,调用 Twice 方法。


    这里的核心技巧是我们手工构造了 THIS 参数。


    这正是 Go 语言中方法函数的核心:我们的 Twice 方法也是绑定到了 Int 类型之上。C++ 语言的一个限制是 THIS 被固化为了指针类型,对于原始对象的大小和指针大小不同,则必须通过指针类型中转。


    而 Go 语言则是将 THIS 提取出来,作为一个普通的参数,用户可以根据需要随意选择 THIS 参数的类型。


    如果在手工构造 THIS 的基础上再进一步,那就是在运行时动态构造 C++ 的虚表,这也是 Go 语言中 interface 的做法。


    更多内容请点击阅读原文。

    - FIN -


    关注公众号:拾黑(shiheibook)了解更多

    [广告]赞助链接:

    四季很好,只要有你,文娱排行榜:https://www.yaopaiming.com/
    让资讯触达的更精准有趣:https://www.0xu.cn/

    公众号 关注网络尖刀微信公众号
    随时掌握互联网精彩
    赞助链接