CGO编程入门
过去的经验往往是走向未来的枷锁,因为在过时技术中投入的沉没成本会阻碍人们拥抱新技术。
{--:}—— chai2010
曾经一度因未能习得C++令人眼花缭乱的新标准而痛苦不已;Go语言“少即是多”的大道至简的理念让我重拾信心,寻回了久违的编程乐趣。
{--:}——Ending
C/C++经过几十年的发展,已经积累了庞大的软件资产,它们很多久经考验而且性能已经足够优化。Go语言必须能够站在C/C++这个巨人的肩膀之上,有了海量的C/C++软件资产兜底之后,我们才可以放心愉快地用Go语言编程。C语言作为一个通用语言,很多库会选择提供一个C兼容的API,然后用其他不同的编程语言实现。Go语言通过自带的一个叫CGO的工具来支持C语言函数调用,同时我们可以用Go语言导出C动态库接口给其他语言使用。
本章主要讨论CGO编程中涉及的一些问题。
2.1 快速入门
本节将通过一系列由浅入深的小例子来快速掌握CGO的基本用法。
2.1.1 最简CGO程序
真实的CGO程序一般都比较复杂,不过我们可以由浅入深。一个最简CGO程序该是什么样的呢?要构造一个最简CGO程序,首先要忽视一些复杂的CGO特性,同时要展示CGO程序和纯Go程序的差别来。下面是我们构建的最简CGO程序:
代码通过import "C"
语句启用CGO特性,主函数只是通过Go内置的println()
函数输出字符串,其中没有任何和CGO相关的代码。虽然没有调用CGO的相关函数,但是go build
命令会在编译和链接阶段启动gcc编译器,这已经是一个完整的CGO程序了。
2.1.2 基于C标准库函数输出字符串
前面那个CGO程序还不够简单,现在来看看更简单的版本:
这个版本不仅通过import "C"
语句启用CGO特性,还包含C语言的<stdio.h>
头文件。然后通过cgo
包的C.CString()
函数将Go语言字符串转换为C语言字符串,最后调用cgo
包的C.puts()
函数向标准输出窗口打印转换后的C字符串。
与1.2节中的CGO程序的最大不同是:我们改用C.Cstring
来创建C语言字符串,而且改用puts()
函数直接向标准输出打印,之前是采用fputs
向标准输出打印。
没有释放使用C.CString
创建的C语言字符串会导致内存泄漏。但是对这个小程序来说,这样是没有问题的,因为程序退出后操作系统会自动回收程序的所有资源。
2.1.3 使用自己的C函数
前面使用了标准库中已有的函数。现在我们先自定义一个叫作SayHello
的C函数来实现打印,然后从Go语言环境中调用这个SayHello()
函数:
除SayHello()
函数是我们自己实现的之外,其他部分和前面的例子基本相似。
我们也可以将SayHello()
函数放到当前目录下的一个C语言源文件中(扩展名必须是.c
)。因为是编写在独立的C文件中,为了允许外部引用,所以需要去掉函数的static
修饰符。
然后在CGO部分先声明SayHello()
函数,其他部分不变:
既然SayHello()
函数已经放到独立的C文件中了,我们自然可以将对应的C文件编译打包为静态库或动态库文件供使用。如果是以静态库或动态库方式引用SayHello()
函数,需要将对应的C源文件移出当前目录(CGO构建程序会自动构建当前目录下的C源文件,从而导致C函数名冲突)。关于静态库等细节将在稍后章节讲解。
2.1.4 C代码的模块化
在编程过程中,抽象和模块化是将复杂问题简化的通用手段。当代码语句变多时,可以将相似的代码封装到一个个函数中;当程序中的函数变多时,将函数拆分到不同的文件或模块中。而模块化编程的核心是面向程序接口编程(这里的接口并不是Go语言的interface
,而是API的概念)。
在前面的例子中,我们可以抽象一个名为hello
的模块,模块的全部接口函数都在hello.h
头文件定义:
其中只有一个SayHello()
函数的声明。但是作为hello
模块的用户,可以放心地使用SayHello()
函数,而无须关心函数的具体实现。而作为SayHello()
函数的实现者,函数的实现只要满足头文件中函数的声明的规范即可。下面是SayHello()
函数的C语言实现,对应hello.c
文件:
在hello.c
文件的开头,实现者通过#include "hello.h"
语句包含SayHello()
函数的声明,这样可以保证函数的实现满足模块对外公开的接口。
接口文件hello.h
是hello
模块的实现者和使用者共同的约定,但是该约定并没有要求必须使用C语言来实现SayHello()
函数。我们也可以用C++语言来重新实现这个C语言函数:
在C++版本的SayHello()
函数实现中,我们通过C++特有的std::cout
输出流输出字符串。不过,为了保证C++语言实现的SayHello()
函数满足C语言头文件hello.h
定义的函数规范,需要通过extern "C"
语句指示该函数的链接符号遵循C语言的规则。
在采用面向C语言API接口编程之后,我们彻底解放了模块实现者的语言枷锁:实现者可以用任何编程语言实现模块,只要最终满足公开的API约定即可。我们可以用C语言实现SayHello()
函数,也可以使用更复杂的C++语言来实现SayHello()
函数,当然也可以用汇编语言甚至Go语言来重新实现SayHello()
函数。
2.1.5 用Go重新实现C函数
其实CGO不仅用于Go语言中调用C语言函数,还可以用于导出Go语言函数给C语言函数调用。在前面的例子中,我们已经抽象一个名为hello
的模块,模块的全部接口函数都在hello.h
头文件定义:
现在我们创建一个hello.go
文件,用Go语言重新实现C语言接口的SayHello()
函数:
我们通过CGO的//export SayHello
指令将Go语言实现的函数SayHello()
导出为C语言函数。为了适配CGO导出的C语言函数,我们禁止了在函数的声明语句中的const
修饰符。需要注意的是,这里其实有两个版本的SayHello()
函数:一个是Go语言环境的;另一个是C语言环境的。CGO生成的C语言版本的SayHello()
函数最终会通过桥接代码调用Go语言版本的SayHello()
函数。
通过面向C语言接口的编程技术,不仅解放了函数的实现者,同时也简化了函数的使用。现在我们可以将SayHello()
当作一个标准库的函数使用(和puts()
函数的使用方式类似):
一切似乎都回到了开始的CGO代码,但是代码内涵更丰富了。
2.1.6 面向C接口的Go编程
在开始的例子中,全部CGO代码都在一个Go文件中。然后,通过面向C接口编程的技术将SayHello()
分别拆分到不同的C文件,而main
依然是Go文件。再用Go函数重新实现了C语言接口的SayHello()
函数。但是对目前的例子来说只有一个函数,要拆分到3个不同的文件确实有些烦琐了。
正所谓合久必分、分久必合,我们现在尝试将例子中的几个文件重新合并到一个Go文件。下面是合并后的结果:
现在版本的CGO代码中C语言代码的比例已经很少了,但是我们依然可以进一步以Go语言的思维来提炼我们的CGO代码。通过分析可以发现SayHello()
函数的参数如果可以直接使用Go字符串是最直接的。在Go 1.10中CGO新增加了一个_GoString_
预定义的C语言类型,用来表示Go语言字符串。下面是改进后的代码:
虽然看起来全部是Go语言代码,但是执行的时候是先从Go语言的main()
函数到CGO自动生成的C语言版本SayHello()
桥接函数,最后又回到Go语言环境的SayHello()
函数。这段代码包含了CGO编程的精华,读者需要深入理解。
思考
main()
函数和SayHello()
函数是否在同一个Goroutine里 执行?
2.2 CGO基础
要使用CGO特性,需要安装C/C++构建工具链,在macOS和Linux下需要安装GCC,在Windows下需要安装MinGW工具。同时需要保证环境变量CGO_ENABLED
被设置为1,这表示CGO是被启用的状态。在本地构建时CGO
默认是启用的,在交叉构建时CGO默认是禁止的。例如要交叉构建ARM环境运行的Go程序,需要手工设置好C/C++交叉构建的工具链,同时开启CGO_ENABLED
环境变量。然后通过import "C"
语句启用CGO特性。
2.2.1 import "C"
语句
如果在Go代码中出现了import "C"
语句,则表示使用了CGO特性,紧临这行语句前面的注释是一种特殊语法,里面包含的是正常的C语言代码。当确保CGO启用的情况下,还可以在当前目录中包含C/C++对应的源文件。
举个最简单的例子:
这个例子展示了CGO的基本使用方法。开头的注释中写了要调用的C函数和相关的头文件,头文件被include
之后里面所有的C语言元素都会被加入"C"
这个虚拟的包中。需要注意的是,import "C"
导入语句需要单独占一行,不能与其他包一同import
。向C函数传递参数也很简单,直接转换成对应的C语言类型传递就可以。例如上例中C.int(v)
用于将一个Go中的int
类型值强制转换为C语言中的int
类型值,然后调用C语言定义的printint()
函数进行打印。
需要注意的是,Go是强类型语言,所以CGO中传递的参数类型必须与声明的类型完全一致,而且传递前必须用"C"
中的转换函数转换成对应的C类型,不能直接传入Go中类型的变量。同时通过虚拟的C包导入的C语言符号并不需要以大写字母开头,它们不受Go语言的导出规则约束。
CGO将当前包引用的C语言符号都放到了虚拟的C包中,同时当前包依赖的其他Go语言包内部可能也通过CGO引入了相似的虚拟C包,但是不同的Go语言包引入的虚拟的C包之间的类型是不能通用的。这个约束对于要自己构造一些CGO辅助函数有可能会造成一点影响。
例如我们希望在Go中定义一个C语言字符指针对应的CChar
类型,然后增加一个GoString()
方法返回Go语言字符串:
现在我们可能会想在其他Go语言包中也使用这个辅助函数:
这段代码是不能正常工作的,因为当前main
包引入的C.cs
变量的类型是当前main
包的CGO构造的虚拟的C包下的*char
类型(具体点是*C.char
,更具体点是*main.C.char
),它和cgo_helper
包引入的*C.char
类型(具体点是*cgo_helper.C.char
)是不同的。在Go语言中方法是依附于类型存在的,不同Go包中引入的虚拟的C包的类型是不同的(main.C
与cgo_helper.C
类型不同),这导致从它们延伸出来的Go类型也是不同的类型(*main.C.char
与*cgo_helper.C.char
类型不同),这最终导致了上面代码不能正常工作。
有Go语言使用经验的用户可能会建议参数类型转换后再传入。但是这个方法似乎也是不可行的,因为cgo_helper.PrintCString
的参数是它自身包引入的*C.char
类型,在外部是无法直接获取这个类型的。换言之,一个包如果在公开的接口中直接使用了*C.char
等类似的虚拟C包的类型,其他Go包是无法直接使用这些类型的,除非这个Go包同时也提供了*C.char
类型的构造函数。因为这些因素,如果想在go test
环境直接测试上述CGO导出的类型也会有相同的限制。
2.2.2 #cgo
语句
在import "C"
语句前的注释中可以通过#cgo
语句设置编译阶段和链接阶段的相关参数。编译阶段的参数主要用于定义相关宏和指定头文件检索路径。链接阶段的参数主要是指定库文件检索路径和要链接的库文件。
上面的代码中,CFLAGS
部分,-D
部分定义了宏PNG_DEBUG
,值为1
;-I
定义了头文件包含的检索目录。LDFLAGS
部分,-L
指定了链接时库文件检索目录,-l
指定了链接时需要链接png
库。
由于C/C++遗留的问题,C头文件检索目录可以是相对路径,但是库文件检索目录则需要绝对路径。在库文件的检索目录中可以通过${SRCDIR}
变量表示当前包目录的绝对路径:
上面的代码在链接时将被展开为:
#cgo
语句主要影响CFLAGS
、CPPFLAGS
、CXXFLAGS
、FFLAGS
和LDFLAGS
几个编译器环境变量。LDFLAGS
用于设置链接时的参数,除此之外的几个变量用于改变编译阶段的构建参数(CFLAGS
用于针对C语言代码设置编译参数)。
对于在CGO环境混合使用C和C++的用户来说,可能有3种不同的编译选项:CFLAGS
对应C语言特有的编译选项,CXXFLAGS
对应C++特有的编译选项,CPPFLAGS
则对应C和C++共有的编译选项。但是在链接阶段,C和C++的链接选项是通用的,因此这个时候已经不再有C和C++语言的区别,它们的目标文件的类型是相同的。
#cgo
语句还支持条件选择,当满足某个操作系统或某个CPU架构类型时,后面的编译或链接选项生效。例如下面是分别针对Windows和非Windows平台下的编译和链接选项:
其中在Windows平台下,编译前会预定义X86
宏为1
;在非Windows平台下,在链接阶段会要求链接math
数学库。这种用法对于在不同平台下只有少数编译选项差异的场景比较适用。
如果在不同的系统下CGO对应着不同的C代码,那么我们可以先使用#cgo
语句定义不同的C语言的宏,然后通过宏来区分不同的代码:
这样就可以用C语言中常用的技术来处理不同平台之间的差异代码。
2.2.3 build
标志条件编译
build
标志是在Go或CGO环境下的C/C++文件开头的一种特殊的注释。条件编译类似于前面通过#cgo
语句针对不同平台定义的宏,只有在对应平台的宏被定义之后才会构建对应的代码。但是,通过#cgo
语句定义宏有个限制,即它只能是基于Go语言支持的Windows、Darwin和Linux等已经支持的操作系统,如果希望定义一个DEBUG
标志的宏,#cgo
语句就无能为力了。而Go语言提供的build
标志条件编译特性则容易做到。
例如,下面的源文件只有在设置debug
构建标志时才会被构建:
可以用以下命令构建:
可以通过-tags
命令行参数同时指定多个build
标志,它们之间用空格分隔。
当有多个build
标志时,可以通过逻辑操作的规则来组合使用多个标志。例如,以下构建标志表示只有在“Linux/386”或“Darwin平台下非CGO环境”才进行构建:
其中linux,386
中linux
和386
用逗号连接表示“与”的意思;而linux,386
和darwin,!cgo
之间通过空格分隔来表示“或”的意思。
补充说明
CGO是C语言和Go语言混合编程的技术,因此要想熟练地使用CGO就需要了解这两门语言。C语言推荐两本书:第一本是C语言之父编写的《C程序设计语言》;第二本是讲述C语言模块化编程的《C语言接口与实现:创建可重用软件的技术》。Go语言推荐官方出版的The Go Programming Language 和Go语言自带的全部文档和全部代码。
为何要花费巨大的精力学习CGO呢?任何技术和语言都有它自身的优点和不足,Go语言不是“银弹”,它无法解决全部问题。而通过CGO可以继承C/C++将近半个世纪的软件遗产,通过CGO可以用Go给其他系统写C接口的共享库,通过CGO还可以让Go语言编写的代码很好地融入现有的软件生态——而现在的软件正是建立在C/C++语言之上的。因此CGO是一个保底的后备技术,它是Go的一个重量级的替补技术,值得任何一位严肃的Go语言开发人员学习。
本文截选自《Go语言高级编程》
作者:柴树杉 曹春晖
一本能满足Gopher好奇心的Go语言进阶读物
汇集了作者多年来学习和使用Go语言的经验
更倾向于描述实现细节,极大地满足开发者的探索欲望
本书作者是国内第一批Go语言实践者和Go语言代码贡献者,创建了Go语言中国讨论组,并组织了早期Go语言相关中文文档的翻译工作。作者从2011年开始分享Go语言和C/C++语言混合编程技术。本书汇集了作者多年来学习和使用Go语言的经验,内容涵盖CGO特性、Go汇编语言、RPC实现、Protobuf插件实现、Web框架实现、分布式系统等高阶主题。其中,CGO特性实现了Go语言对C语言和C++语言混合编程的支持,使Go语言可以无缝继承C/C++世界数十年来积累的巨大软件资产。Go汇编语言更是提供了直接调用底层机器指令的方法,让我们可以最大限度地提升程序中热点代码的性能。
本书适合有一定Go语言经验,并想深入了解Go语言各种高级用法的开发人员。对于Go语言新手,建议在阅读本书前先阅读一些基础Go语言编程图书。
本文转载自异步社区。
原文链接:https://www.epubit.com/articleDetails?id=NN40916b1a-cd50-410a-a050-72daf2365c73
- 点赞
- 收藏
- 关注作者
评论(0)