CGO编程入门

举报
且听风吟 发表于 2019/09/04 23:22:28 2019/09/04
【摘要】 过去的经验往往是走向未来的枷锁,因为在过时技术中投入的沉没成本会阻碍人们拥抱新技术。{--:}—— chai2010曾经一度因未能习得C++令人眼花缭乱的新标准而痛苦不已;Go语言“少即是多”的大道至简的理念让我重拾信心,寻回了久违的编程乐趣。{--:}——EndingC/C++经过几十年的发展,已经积累了庞大的软件资产,它们很多久经考验而且性能已经足够优化。Go语言必须能够站在C/C++这...

过去的经验往往是走向未来的枷锁,因为在过时技术中投入的沉没成本会阻碍人们拥抱新技术。

{--:}—— 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程序:

image.png

代码通过import "C"语句启用CGO特性,主函数只是通过Go内置的println()函数输出字符串,其中没有任何和CGO相关的代码。虽然没有调用CGO的相关函数,但是go build命令会在编译和链接阶段启动gcc编译器,这已经是一个完整的CGO程序了。

2.1.2 基于C标准库函数输出字符串

前面那个CGO程序还不够简单,现在来看看更简单的版本:

image.png

这个版本不仅通过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()函数:

image.png

SayHello()函数是我们自己实现的之外,其他部分和前面的例子基本相似。

我们也可以将SayHello()函数放到当前目录下的一个C语言源文件中(扩展名必须是.c)。因为是编写在独立的C文件中,为了允许外部引用,所以需要去掉函数的static修饰符。

image.png

然后在CGO部分先声明SayHello()函数,其他部分不变:

image.png


既然SayHello()函数已经放到独立的C文件中了,我们自然可以将对应的C文件编译打包为静态库或动态库文件供使用。如果是以静态库或动态库方式引用SayHello()函数,需要将对应的C源文件移出当前目录(CGO构建程序会自动构建当前目录下的C源文件,从而导致C函数名冲突)。关于静态库等细节将在稍后章节讲解。

2.1.4 C代码的模块化

在编程过程中,抽象和模块化是将复杂问题简化的通用手段。当代码语句变多时,可以将相似的代码封装到一个个函数中;当程序中的函数变多时,将函数拆分到不同的文件或模块中。而模块化编程的核心是面向程序接口编程(这里的接口并不是Go语言的interface,而是API的概念)。

在前面的例子中,我们可以抽象一个名为hello的模块,模块的全部接口函数都在hello.h头文件定义:

image.png

其中只有一个SayHello()函数的声明。但是作为hello模块的用户,可以放心地使用SayHello()函数,而无须关心函数的具体实现。而作为SayHello()函数的实现者,函数的实现只要满足头文件中函数的声明的规范即可。下面是SayHello()函数的C语言实现,对应hello.c文件:

image.png


hello.c文件的开头,实现者通过#include "hello.h"语句包含SayHello()函数的声明,这样可以保证函数的实现满足模块对外公开的接口。

接口文件hello.hhello模块的实现者和使用者共同的约定,但是该约定并没有要求必须使用C语言来实现SayHello()函数。我们也可以用C++语言来重新实现这个C语言函数:

image.png


在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头文件定义:

image.png

现在我们创建一个hello.go文件,用Go语言重新实现C语言接口的SayHello()函数:

image.png


我们通过CGO的//export SayHello指令将Go语言实现的函数SayHello()导出为C语言函数。为了适配CGO导出的C语言函数,我们禁止了在函数的声明语句中的const修饰符。需要注意的是,这里其实有两个版本的SayHello()函数:一个是Go语言环境的;另一个是C语言环境的。CGO生成的C语言版本的SayHello()函数最终会通过桥接代码调用Go语言版本的SayHello()函数。

通过面向C语言接口的编程技术,不仅解放了函数的实现者,同时也简化了函数的使用。现在我们可以将SayHello()当作一个标准库的函数使用(和puts()函数的使用方式类似):

image.png


一切似乎都回到了开始的CGO代码,但是代码内涵更丰富了。

2.1.6 面向C接口的Go编程

在开始的例子中,全部CGO代码都在一个Go文件中。然后,通过面向C接口编程的技术将SayHello()分别拆分到不同的C文件,而main依然是Go文件。再用Go函数重新实现了C语言接口的SayHello()函数。但是对目前的例子来说只有一个函数,要拆分到3个不同的文件确实有些烦琐了。

正所谓合久必分、分久必合,我们现在尝试将例子中的几个文件重新合并到一个Go文件。下面是合并后的结果:

image.png

现在版本的CGO代码中C语言代码的比例已经很少了,但是我们依然可以进一步以Go语言的思维来提炼我们的CGO代码。通过分析可以发现SayHello()函数的参数如果可以直接使用Go字符串是最直接的。在Go 1.10中CGO新增加了一个_GoString_预定义的C语言类型,用来表示Go语言字符串。下面是改进后的代码:

image.png

虽然看起来全部是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++对应的源文件。

举个最简单的例子:

image.png


这个例子展示了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语言字符串:

image.png

现在我们可能会想在其他Go语言包中也使用这个辅助函数:

image.png


这段代码是不能正常工作的,因为当前main包引入的C.cs变量的类型是当前main包的CGO构造的虚拟的C包下的*char类型(具体点是*C.char,更具体点是*main.C.char),它和cgo_helper包引入的*C.char类型(具体点是*cgo_helper.C.char)是不同的。在Go语言中方法是依附于类型存在的,不同Go包中引入的虚拟的C包的类型是不同的(main.Ccgo_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语句设置编译阶段和链接阶段的相关参数。编译阶段的参数主要用于定义相关宏和指定头文件检索路径。链接阶段的参数主要是指定库文件检索路径和要链接的库文件。

image.png


上面的代码中,CFLAGS部分,-D部分定义了宏PNG_DEBUG,值为1-I定义了头文件包含的检索目录。LDFLAGS部分,-L指定了链接时库文件检索目录,-l指定了链接时需要链接png库。

由于C/C++遗留的问题,C头文件检索目录可以是相对路径,但是库文件检索目录则需要绝对路径。在库文件的检索目录中可以通过${SRCDIR}变量表示当前包目录的绝对路径:

image.png

上面的代码在链接时将被展开为:

image.png


#cgo语句主要影响CFLAGSCPPFLAGSCXXFLAGSFFLAGSLDFLAGS几个编译器环境变量。LDFLAGS用于设置链接时的参数,除此之外的几个变量用于改变编译阶段的构建参数(CFLAGS用于针对C语言代码设置编译参数)。

对于在CGO环境混合使用C和C++的用户来说,可能有3种不同的编译选项:CFLAGS对应C语言特有的编译选项,CXXFLAGS对应C++特有的编译选项,CPPFLAGS则对应C和C++共有的编译选项。但是在链接阶段,C和C++的链接选项是通用的,因此这个时候已经不再有C和C++语言的区别,它们的目标文件的类型是相同的。

#cgo语句还支持条件选择,当满足某个操作系统或某个CPU架构类型时,后面的编译或链接选项生效。例如下面是分别针对Windows和非Windows平台下的编译和链接选项:

image.png其中在Windows平台下,编译前会预定义X86宏为1;在非Windows平台下,在链接阶段会要求链接math数学库。这种用法对于在不同平台下只有少数编译选项差异的场景比较适用。

如果在不同的系统下CGO对应着不同的C代码,那么我们可以先使用#cgo语句定义不同的C语言的宏,然后通过宏来区分不同的代码:

image.png

这样就可以用C语言中常用的技术来处理不同平台之间的差异代码。

2.2.3 build标志条件编译

build标志是在Go或CGO环境下的C/C++文件开头的一种特殊的注释。条件编译类似于前面通过#cgo语句针对不同平台定义的宏,只有在对应平台的宏被定义之后才会构建对应的代码。但是,通过#cgo语句定义宏有个限制,即它只能是基于Go语言支持的Windows、Darwin和Linux等已经支持的操作系统,如果希望定义一个DEBUG标志的宏,#cgo语句就无能为力了。而Go语言提供的build标志条件编译特性则容易做到。

例如,下面的源文件只有在设置debug构建标志时才会被构建:

image.png

可以用以下命令构建:

image.png


可以通过-tags命令行参数同时指定多个build标志,它们之间用空格分隔。

当有多个build标志时,可以通过逻辑操作的规则来组合使用多个标志。例如,以下构建标志表示只有在“Linux/386”或“Darwin平台下非CGO环境”才进行构建:

image.png


其中linux,386linux386用逗号连接表示“与”的意思;而linux,386darwin,!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语言高级编程》

1.jpg

作者:柴树杉 曹春晖

  • 一本能满足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


【版权声明】本文为华为云社区用户转载文章,如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。