【一文通】C/C++与Go语言混合编程入门级教程(Windows平台完成)

在这里插入图片描述

一、概述

Go语言可以通过自带的 cgo 工具进行 C+GO 混合编程,这个工具放在go安装目录的 pkg\tool 下,其源代码则在 src\runtime\cgo 里面,当然作为入门教程本文不打算对cgo的实现原理进行深入研究,仅从 Hello World 的角度来实际体验一下 cgo(文末有我收集的各种资源可供深入学习)。


二、从最简单开始 (内联C代码)

默认的 Go 编译器是关闭交叉编译功能的,因为开启了 cgo 会让 Go 程序的移植性变差且部署变得很麻烦。纯粹的 Go 代码固然更好,但有时候当需要用到的软件库找不到 Go 版本的时候则只能采用这种方式来应对。正面而言,没有cgo,Go 就不会有今天的地位,因为通过它可以继承 C/C++ 将近半个世纪的软件遗产,此外 cgo 也是在 Android 和 iOS 上运行 Go 程序的关键。

打开cgo很简单,将环境变量 CGO_ENABLED 设为 1 就可以了:
Windows:set CGO_ENABLED=1
类Linux平台: export CGO_ENABLED=1

输入这段程序:

main.go:

package main

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

import "fmt"

func main() {
    
    
	a := C.int(10)
	b := C.int(20)
	c := C.Add(a, b)
	fmt.Println(c) // 30
}

上面这段代码,最前面有一段 “注释”,下一行是import "C",注意这段注释并非真正的注释,而是C语言代码,在Go语言的规范中,在 import "C" 之前的若干注释行叫做序文(preamble),而实际上Go语言也没有“C”这个包,这是一个虚拟包,它的作用可以理解为一条分割线,让C和Go代码分离,在 import "C"之上的注释部分是C代码,之下则是普通的 Go 代码。注意C代码和 import "C" 之间不可存在空行,“C”这个包也不能被常规 import 语句所引用,只能以独占一行的方式存在。此后在普通Go代码部分,我们就可以通过 C.Add 来引用这个C语言函数了。这种通过内联方式来引用C代码的方法很简单,在编译环节和普通纯Go相比没有差异,直接输入go build main.go 或者 go run main.go 就可以了。


三、引用C语言编写的库

大多数情况下,我们的目的是通过cgo来引用第三方软件库,无论开源闭源基本上都是以库的方式存在的,下面就来讲一下在Go代码中如何引用C语言编译好的静态链接库里的函数。为了简单起见我们先模拟一个C语言静态链接库,创建以下2个文件:

hello.c:

#include <stdio.h>
#include "hello.h"

void SayHello()
{
    
    
    printf("Hello, world!\n");
}

hello.h:

void SayHello();

用gcc将他们编译为静态链接库(后缀为.a):
$ gcc -c hello.c
$ ar -crv libhello.a hello.o
输入以上两行命令后,我们得到了一个 libhello.a 的静态链接库。

PS:关于gcc和ar命令,是包含在MinGW软件里的,关于它的安装这里不展开讨论,可以翻阅我之前的博文,过程也很简单。

再创建1个Go文件:
main.go:

package main

/*
#cgo CFLAGS: -I${SRCDIR}
#cgo LDFLAGS: -L${SRCDIR} -lhello
#include "hello.h"
*/
import "C"

import (
	"fmt"
)

func main() {
    
    
	C.SayHello()
	fmt.Println("Succeed!")
}

CFLAGS和LDFLAGS 是两个C语言的编译和链接开关,在这里CFLAGS指明了头文件的路径,而LDFLAGS则指定了库文件路径和库文件名。

PS: 头文件就是指跟在 #include 后面的后缀为 .h的文件,库文件则是指以 .a 或 .so 结尾的文件,在Windows下则是 .lib 或 .dll 方式存在。${SRCDIR} 则表示当前目录,也就是我们通常使用的 “.” 。库文件不能使用相对路径是C/C++的历史遗留问题,通过${SRCDIR}则可以变相地采用相对路径,例如假设有一个绝对路径c:\test\hello,则${SRCDIR}\lib会自动展开为 c:\test\hello\lib。

CFLAGS 通过 -I 将在当前目录设为头文件(.h)检索路径。LDFLAGS 则通过 -L${SRCDIR} 在当前目录设为库文件(.a)检索路径,-lhello 表示具体链接的是 libhello.a 这个库。

注意:-lhello 表示链接 libhello.a这个库,其实是将 “lib" 和 后缀 “.a” 去掉之后简写为hello的,这是C语言的套路。此外,动态链接库(.so文件)的链接方式是一样的,假设现在我们提供的库文件是一个动态链接库 libhello.so ,在这里的设置是完全一样的都是 -lhello。

输入 go build main.go 或者 go run main.go 运行,显示:

Hello, world!
Succeed!

尽管就只是打印了两行字,但这里的 “Hello, world!” 实际上是调用了 C语言的 libhello.a 库里的 SayHello() 函数的结果,而 “Succeed!” 则是调用普通 Go 标准库 fmt 的结果,两者有着本质区别。


四、引用C++库

相对而言,C++与Go的交叉编码显得要麻烦一些,cgo 是 C 语言和 Go 语言之间的桥梁,但原则上它无法直接支持 C++ 的类,只能增加一组 C 语言函数接口作为 C++ 类和 CGO 之间的桥梁,迂回地让Go和C++对接。这就是我们经常在开源Go项目里面经常看到 “xxx-bridge” 的原因,只要出现这种情况,多半是这个项目引用了C++库。

首先,我们先创造一个C++库,创建一个myLib目录,然后在里面再创建两个C++文件:
$ mkdir myLib
$ cd myLib

hello.cpp:

#include "hello.h"
#include <iostream>

void hello() {
    
    
    std::cout << "Hello, World!\nThis message comes from a CPP function!" << std::endl;
}

hello.h:

void hello();

和上例一样,创建一个静态链接库:
$ g++ -c hello.cpp (注意这次使用的是g++而不是gcc)
$ ar crs libhello.a hello.o
然后退回到项目的根目录,再创建两个作为“桥梁”的C++文件:
$ cd ..

hellobridge.cpp :

#include "hellobridge.h"
#include "mylib/hello.h"

void CallHello()
{
    
    
    hello(); // 调用库中的hello()函数
}

hellobridge.h :

#ifdef __cplusplus
extern "C" {
    
    
#endif

void CallHello();

#ifdef __cplusplus
}
#endif

因为这个文件是用于 CGO,必须采用 C 语言规范的名字修饰规则,在 C++ 源文件包含时需要用 extern "C" 语句说明。

最后创建 Go主程序:

hello.go :

package main

/*
package main

/*
#cgo CXXFLAGS: -std=c++0x
#cgo LDFLAGS: -L${SRCDIR}/mylib -lhello
#cgo CPPFLAGS: -Wno-unused-result
#include "hellobridge.h"
*/
import "C"

func main() {
    
    
	C.CallHello()
}

这次我们设置了 CXXFLAGS 编译开关,告诉 cgo 现是 C++ 代码。

注意:go env 环境变量有 CC 和 CXX 之分,分别对应的是C和C++的可执行编译器(需要放到PATH命令里以在任何地方执行),当我们设置CFLAGS的时候,cgo 会自动开启C语言编译器进行工作(默认是gcc),而当CXXFLAGS开关被设置的时候,cgo 会自动选择使用C++编译器(默认是g++)。而我们不必纠结如何“指挥”cgo 用什么编译器去工作,它的逻辑很简单即通过两个编译开关来判定,我们不需要将 CC 设为 g++ 这种方法来实现强制 cgo 使用C++进行编译, 这将弄巧成拙。

关于这三个开关:
CFLAGS :C语言编译参数
CXXFLAGS: C++独有的编译参数
CPPFLAGS:C和C++共有的编译参数
在本例中我们发现一个参数 -Wno-unused-result ,这个参数是告诉编译器取消“未使用”变量的警告,这个参数是C和C++共有的因此放到了 CPPFLAG 里。

上述文件全部创建完毕后,输入 go build -o hello.exe 或者 go run .

Hello, World!
This message comes from a CPP function!

注意:因为这次我们不只有 hello.go 文件参与编译,另外还有两个作为桥梁的文件:hellobridge.cpp 和 hellobridge.h 也要参与编译,因此就不能像上面的例子那样使用 go build hello.go 单独编译 hello.go文件而是需要编译整个文件夹。


五、使用pkg-config

本节并非本文的重点,但鉴于pkg-config这个工具使用很广泛并且 cgo 与它也有相应的对接参数,因此一并在这里告知。

pkg-config 是原生Linux下的工具(也有For Win版),它的主要作用就是简化库文件的引用操作,因为我们引用任何第三方库首先都要知道它的头文件和库文件到底存放位置,然后再来拼接类似 CFLAGS 或 LDFLAGS 这样的参数,这是一件很麻烦的事情。通过pkg-config 在一定程度上能简化这个操作。 其运行逻辑其实非常简单,可以简单地将它理解为一个自动展开器,比如说现在有一个库 hello,它的头文件在存放在 /usr/local/include ,库文件在/usr/local/lib ,过往在编译它的时候我们需要这么做:
gcc hello.cpp -I/usr/local/include -L/usr/local/lib -lhello -o hello.exe

我们需要通过各种方法(比如说whereisfind这一类指令)去查找这个库的实际存放位置,这显得非常麻烦。而采用了pkg-config后,则变成了这样:

gcc hello.cpp `pkg-config -cflags -libs hello` -o hello.exe 

其中用 ` ` 号包裹起来那段内容,在命令执行的时候会自动展开为

-I/usr/local/include -L/usr/local/lib -lhello

这样我们就只需要知道库的名字,而不需要关心这个库到底存放在哪了,省时又省心。

每一个库,都会预先创建一个 .pc 后缀的文件,里面预设了 CFLAGS 以及 LDFLAGS 等等记录,当 pkg-config 检索到这个文件后,将会自动将匹配到的内容展开,而这些 .pc 文件存放的地方称为 PKG_CONFIG_PATH , 下面就用我们的hello库来演示一遍 pkg-config 的用法。

首先,尽管这是一个Linux工具,实际上我们也不必费尽心思去寻找它的所谓 Windows 版,因为在安装 MSYS2 或者 MinGW 的时候就附带了这个工具。我们只需要进入MSYS2就能直接使用,使用命令:
$ echo $PKG_CONFIG_PATH
得知所有的 .pc 文件都放在这两个目录里:
/mingw64/lib/pkgconfig:/mingw64/share/pkgconfig
输入命令:
$ cd /mingw64/lib/pkgconfig
创建一个文件 hello.pc

Name: Hello
Description: Hello World Cgo Test.
Version: 1.0.0
Libs: -Lc:/test/myLib -lhello
Cflags: -Ic:/test/myLib

Name 和 Description 随便输,Libs 表示我们hello库实际存放位置,Cflags 指明了 hello 库的头文件存放位置。
存盘退出,输入;
# pkg-config --list-all
屏幕会显示一堆 package 名字,仔细找找看,我们的Hello库应该也在里面了。
输入:
# pkg-config --cflags --libs hello
显示:
-Ic:/test/myLib -Lc:/test/myLib -lhello
说明pkg-config已经侦测到我们的hello库并且能够自动展开了。

现在,我们用gcc或者g++命令编译的时候就可以这样:

gcc hello.cpp `pkg-config -cflags -libs hello` -o hello.exe

它会自动展开成这样:

gcc hello.cpp -Ic:/test/myLib -Lc:/test/myLib -lhello -o hello.exe

坑提示:在Windows下的两个shell都不能识别 `pkg-config -cflags -libs hello`这种格式,无论是cmd还是powershell,我还尝试过使用 $(pkg-config -cflags -libs hello) 这样的格式,都不能实现展开。但对于已经安装好MSYS2的系统来说,这也不是什么大问题,只是进哪个Shell的差异而已。

回到我们的 Go 这边,采用了 pkg-config 之后,不再需要指定头文件和库文件路径,修改后代码如下:

package main

/*
#cgo pkg-config: hello
#cgo CXXFLAGS: -std=c++0x
#cgo CPPFLAGS: -Wno-unused-result
#include "hellobridge.h"
*/
import "C"

func main() {
    
    
	C.CallHello()
}

旧的代码是:#cgo LDFLAGS: -L${SRCDIR}/mylib -lhello
而现在我们只需要:pkg-config: hello 就可以了,对我们自己而言倒也没什么,因为库是我们自己写的当然很清楚它保存在哪,但是换一个角度,这个库假设提供给别人使用,那将会省去他很多麻烦。

六、后记
cgo 门道非常深,本文仅作抛砖引玉之用,且只讲了go如何引用c,而未提及c引用go,以及变量、数组、结构体、指针等各种转换问题。我收集了一些学习资料可供大家深入研究:

官方手册:
https://pkg.go.dev/cmd/cgo
https://go.dev/blog/cgo

CGO:
https://chai2010.cn/advanced-go-programming-book/ch2-cgo/ch2-01-hello-cgo.html
https://www.cntofu.com/book/19/0.13.md
https://www.cnblogs.com/lidabo/p/6068448.html
https://bastengao.com/blog/2017/12/go-cgo-cpp.html
https://fasionchan.com/golang/practices/call-c/

C/C++:
https://blog.51cto.com/u_15091053/2652800
https://www.cnblogs.com/52php/p/5681711.html

猜你喜欢

转载自blog.csdn.net/rockage/article/details/131357305
今日推荐