go通过swig封装、调用c++共享库的技术总结

go通过swig封装、调用c++共享库的技术总结

@(知识记录)

1 简介

最近在研究golang,希望能对目前既有的python服务做一些优化,这些服务目前已经占用了6-7台机器。选择golang的原因,是看上其在并发方面更简单的支持,比c++更高的开发效率,以及比python更高的运行效率。

由于现实的原因,我们不太可能将所有模块都用golang重写一遍,有一些公司通用的模块是用C++编译成为.so的方式提供的。因此,如果想要用golang重构服务,调用C++共享库是不可能绕过的问题,也是首要解决的问题。

本文是对golang调用、封装c++共享库的技术总结,共分为四部分。第一部分介绍golang调用c语言接口的基本方法并介绍cgo;第二部分介绍swig的用法;第三部分是一个示例工程,完整模拟现实环境的调用和封装;第四部分对实际问题中的一个.so模块进行封装。

2 go调用c及cgo简介

最初遇到本文问题(go封装c++共享库)时,我在网上搜索到最多的文章,就是go如何调用c代码中的函数。当时的感觉是有点失望,因为都没能一步一步手把手完整解决我的问题。但是现在看来,本节的主题(go调用c代码)是后面所有工具的基础。

2.1 示例代码

首先,放上一段golang示例代码,这段代码来自cgo官方文档

package main

// #include <stdio.h>
// #include <stdlib.h>
//
// static void myprint(char* s) {
//   printf("%s\n", s);
// }
import "C"
import "unsafe"

func main() {
	cs := C.CString("Hello from stdio")
	C.myprint(cs)
	C.free(unsafe.Pointer(cs))
}

首先,这是一段golang代码,从package定义、到import包、到函数定义,都是我们熟悉的golang元素。main函数内部是一个变量初始化和两个函数调用,且变量和函数的定义都来自名为C的package。在目录下运行go build -o test命令,可以得到一个可执行文件test,再运行./test命令,可以看到如下输出。

>./test
Hello from stdio

2.2 代码解析

这段代码的关键部分在于import "C"及其之前的注释部分。

"C"在这里是一个pseudo-package,并不是一个实际存在的go package。对C语言部分的所有引用,都通过这个pseudo-package来进行。在import "C"之前的注释,可以是任何合法的c语言代码,go代码可以引用这些C语言定义的函数、变量等,仿佛它们就是定义在名为C的package中。可以是定义,也可以通过extern声明其他C文件中的定义。具体到上面的代码,在注释部分定义了一个C函数myprint,然后在go的main函数中调用了它。

2.3 cgo指示

另外,在import "C"之前的注释,还可以包括cgo指示( #cgo directives),这个特性在上面的简单示例代码中没有涉及。如下代码所示:

// #cgo CFLAGS: -DPNG_DEBUG=1
// #cgo amd64 386 CFLAGS: -DX86=1
// #cgo LDFLAGS: -L${SRCDIR}/libs -lfoo
// #include <png.h>
import "C"

cgo指示(go directives)以#cgo开头,并紧接着一个空格。这部分内容不是C代码,但是用来控制C编译器及link的参数,可以包括CFLAGS, CPPFLAGS, CXXFLAGS, FFLAGS和LDFLAGS 。

  • 一个package中所有CPPFLAGS和CFLAGS cgo指示,都被连在一起,在编译C文件时使用;
  • 一个package中所有CPPFLAGS和CXXFLAGS cgo指示,都被连在一起,在编译C++文件时使用;
  • 一个package中所有CPPFLAGS和FFLAGS cgo指示,都被连在一起,在编译Fortran文件时使用;
  • 一个package中所有LDFLAGS cgo指示,都被连在一起,在链接时使用。

2.4 这一切怎么发生的

摘抄自cgo文档

When the Go tool sees that one or more Go files use the special import "C", it will look for other non-Go files in the directory and compile them as part of the Go package. Any .c, .s, or .S files will be compiled with the C compiler. Any .cc, .cpp, or .cxx files will be compiled with the C++ compiler. Any .f, .F, .for or .f90 files will be compiled with the fortran compiler. Any .h, .hh, .hpp, or .hxx files will not be compiled separately, but, if these header files are changed, the package (including its non-Go source files) will be recompiled. Note that changes to files in other directories do not cause the package to be recompiled, so all non-Go source code for the package should be stored in the package directory, not in subdirectories. The default C and C++ compilers may be changed by the CC and CXX environment variables, respectively; those environment variables may include command line options.

当go tool发现一个或多个文件里包含import "C"时,它会寻找目录内其他非go源码的文件,并且将它们编译为package的一部分。对于.c,.s及.S结尾的文件,会用c编译器编译;对于.cc,.cpp和.cxx文件,会用c++编译器编译;对于.f,.F,.for和.f90文件,会用fortran编译器编译。对于.h,.hh,.hpp和.hxx文件,虽然不会编译,但如果这些头文件发生了变化,package整个会被重新编译。其他目录的文件如果发生变化,不会引起package重新编译。因此,所有所有非go文件都应该放在package的目录下,不要放在任何子目录里。默认的c和c++班一起,会被CC和CXX环境变量影响,这些环境变量可以在命令行参数中包括。

2.5 其他注意事项

2.5.1 指针

Go是有gc的语言,C是没有gc的语言,且两者都是有指针的语言,所以在处理指针时应该格外注意。Go的内存管理模块无法知道C内部发生了什么,C也不知道Go的内存管理。

在使用指针时,应该对某个指针时C指针Go指针有明确的认知。C指针是指向通过C的库分配的内存,Go指针是指向Go代码分配的内存。这个目的是区分这块内存是由谁(C还是Go)来分配的,而不是由指针的类型决定的。

Go code may pass a Go pointer to C provided the Go memory to which it points does not contain any Go pointers. The C code must preserve this property: it must not store any Go pointers in Go memory, even temporarily.

Go代码给C传递一个指针,应该保证这块内存或结构中,不包含其他Go指针。C代码应该保证,不保存任何Go指针,即便临时保存也不行。

回到我们最初的示例代码。

func main() {
	cs := C.CString("Hello from stdio")
	C.myprint(cs)
	C.free(unsafe.Pointer(cs))
}

由于cs是一个C字符串,这块内存是C内存,因此需要我们在用完之后手动释放。即便这是在Go代码中操作的。

2.5.2 封装

对C函数及类型的访问和操作,如上述几段代码中操作C.开头的变量或函数,应该限制在一个package里,这个package的作用就是将C的函数封装为Go的函数。因为使用C类型需要操心的东西比较多,封装起来更容易管理也不容易出问题,例如上例中释放字符串这种操作。一般人应该不会希望在写Go代码时,心理还一直惦记着这些事情。

3 SWIG

有了上一节提到的Cgo支持,所有C库及接口都可以被Go调用,标准C接口也是大多数库的开发包都提供的接口。然而,由于一些历史问题,有些不那么规范的库只提供了C++风格的接口,例如接口用到vector、map、string等,Cgo是不支持C++的这些特性的。遇到这种情况,标准的做法是给这些C++接口再封装一个标准C接口。这个封装工作需要我们再写一份C或C++代码,做一些类型或者接口的转换,通过extern C导出C风格的接口。然后再按照上一节的做法,用Go去调用这个标准C接口。

如果接口比较简单,数量也不多,上述封装可以手工完成。如果接口数量较多,且涉及大量C++特性,上述封装工作可能就不难么容易了。

SWIG就是自动帮你做了这件事。准确地说,SWIG生成了两个文件,一个文件是*_wrapper.cpp文件,一个是*.go文件。*_wrapper.cpp文件将C++接口封装为C接口。*.go文件通过上一节说的import "C"来引用C接口,并把对这些C接口的调用,封装为不涉及任何C特性的Go函数或方法。因此,它实际做了两件事,一是我们上面说的将C++接口封装为C接口,另一件是上一节说的封装问题,在Go代码里把对C接口的使用细节封装起来。

接下来我们就看一下SWIG的使用方法及它对Go的支持。

3.1 SWIG简介

SWIG是一个软件开发工具,用于将C或C++程序与其他高级程序语言连接起来。它支持多种目标语言,包括脚本语言如Javascript、Perl、PHP、Python、Tcl、Ruby,也包括非脚本语言如C#,Common Lisp (CLISP, Allegro CL, CFFI, UFFI)、D、Go 、Java等。SWIG解析C或C++接口,生成“连接代码”,使其他高级语言可以调用C或C++的代码。

使用SWIG需要先定义一个接口文件,这个接口文件说明了需要导出的接口及数据类型,这个接口文件以.i作为后缀。我以一个简单的实例,来说明我用到的一些特性,其他技术细节可以参考SWIG DocSWIGPlus Doc

%module compare_length
%{
#include "compare_length.h"
%}

%include "typemaps.i"
%include "std_vector.i"

%template(VecInt) std::vector<int>;

int compare(const std::vector<int>& vl, const std::vector<int>& vr);

从语法层面看,SWIG文件是一个增强版的C++文件。它支持所有C++语法,它还包括SWIG指令。所有以%开头的行,都是SWIG指令,位于“%{”和“%}”之间的部分不会被处理,会被原封不动地复制到*_wrapper.cpp文件中,这可以使wrapper引用一些头文件。你甚至可以让SWIG来直接处理一个.h文件或.cpp文件,但是并不推荐这么做。

一般使用来说,SWIG接口文件应该包括

  1. 模块声明,位于第一行,以%module指令开头;
  2. 定义需要在_wrapper.cpp文件里包含的头文件,即%{和%}之间的部分。在上面的例子,就是包含int compare函数的那个头文件;
  3. 声明要导出的接口及类型。这就是完全的ANSI C/C++声明语法。
  4. 根据具体需求,其他需要的辅助指令。包括%include指令、%template指令等。具体可以根据需要参考SWIG安装文件的Examples,路径位于SWIG_SOURCE_ROOT/Examples,也可以参考SWIGPlus doc

接口文件写完后。需要运行SWIG命令,对于不同的目标语言,有不同的参数可选。我们这里以python和go为例说明一下。

swig -c++ -python compare_length.i
swig -c++ -go -intgosize 64 -cgo compare_length.i

总结一下。第一,需要根据需要写一个.i接口文件,定义导出的接口;第二,根据目标语言运行swig命令,生成连接C/C++与目标语言的代码。

剩下的部分,就因目标语言而异了。对于Python,需要将swig生成的[module_name]_wrapper.cpp文件与原有的C/C++库或文件编译为一个.so,然后通过生成的[module_name].py在Python中使用。对于Go,需要将[module_name]_wrapper.cpp、[module_name].go,以及原有的C/C++库或文件放到GOPATH下的一个具体路径里然后通过go来build。

3.2 SWIG与Go

这里先说一下版本问题,我们用的是Go 1.8和SWIG 3.0。Go 1.4和Go 1.5的go tool有较大差别,很多方法中用到的6c、6g、8c、8g在1.5以后的版本都去掉了,但1.5以后支持cgo。SWIG 3.0支持-cgo参数。本文用的方法都是基于cgo的。

因为C++与Go语法上存在一些差别,不能完全对应上,因此在将C++的元素导出时会有一些修改。

  1. 所有的Go代码都必须在一个package内,SWIG生成的Go代码,也都位于一个package内,这个package的名称由SWIG接口文件中的%package指令设置;
  2. 由于在Go中只有大写字母开头的名称,才是在package外可用的,因此所有被导出的C++名称(变量、函数、类等),如果是小写字母开头,则会被转换为大写字母开头;
  3. 导出C++的变量,会被封装为Get和Set两个函数。例如一个C++变量名为var,SWIG会为其生成SetVar和GetVar两个方法;
  4. 导出C++变量如果是常亮,则只提供Get方法;
  5. 导出C++的宏,在Go中会变成一个常量;
  6. 导出C++的类,会被Go导出为两个类型。由于Go没有类的概念,因此会为其生成两个类型,一个类型用来持有C++对象的指针,一个与C++类同名的接口类型用来调用方法。所有的C++类的公共变量,都会在这个接口内生成Set和Get两个方法。另外,Go会生成一个NewClassName的函数来初始化对象。

其他细节我这里也没有涉及,有需要可以参考SWIG文档中SWIG and Go这一部分。

现在我们回过头来看一下SWIG所做的事情,就是利用一些trick把C++接口中与Go无法对应的部分,在尽量不影响语义的前提下对应起来。这也正是如果我们如果手工封装C++模块时所要做的。现在SWIG替我们做了这些累活。

4 用SWIG封装C++动态库示例

4.1 项目简介

本节以一个示例来说明,如何使用SWIG连接Go和C++代码。示例项目包括C++编译的动态库.so,C++源码,C++风格的函数接口,C++风格的数据类型。这些特性大部分是Cgo不能直接支持的,也是在实际项目中经常遇到的。

  1. libl2.so,l2.h。一个动态库及其头文件,其中的函数int l2(const std::vector<int>& elements)用于计算一个向量的长度。我们没有该函数的实现代码。
  2. compare_length.cxx,compare_length.h。一个cxx文件和其头文件,这个cxx文件中的函数int compare(const std::vector<int>& vl, const std::vector<int>& vr)调用了在l2.h中定义l2函数,比较两个向量的长度。这个函数是我们要导出给Go的函数。

我们没有l2函数的实现代码,只有动态库,对应于在实际工程中用到第三方动态库。我们有compare函数的源代码和头文件,对应于已有的C++实现的一些功能,这部分功能我们不想在Go中重复实现。

libl2.so位于$GOPATH/compare_length/路径下。

l2.h的内容如下,位于$GOPATH/compare_length/路径下:

#include <vector>

int l2(const std::vector<int>& elements);

compare_length.h的内容如下,位于$GOPATH/compare_length/路径下:

#include<vector>
#include "l2.h"

int compare(const std::vector<int>& vl, const std::vector<int>& vr);

compare_length.cxx的源码如下,位于$GOPATH/compare_length/路径下:

#include <vector>
#include "l2.h"
#include "compare_length.h"

int compare(const std::vector<int>& vl, const std::vector<int>& vr) 
{
    int l2_l = l2(vl);
    int l2_r = l2(vr);
    return l2_l - l2_r;
}

4.2 编译步骤

首先,写一个SWIG接口文件compare_length.i,用于指定导出的函数和数据类型。

%module compare_length
%{
#include "compare_length.h"
%}

%include "typemaps.i"
%include "std_vector.i"

%template(VecInt) std::vector<int>;

int compare(const std::vector<int> vl, const std::vector<int> vr);

导出的模块通过%module指定为compare_length。导出一个函数compare和一个类型VecInt。

第二步,运行以下SWIG命令,生成compare_length_wrapper.cxx文件和compare_length.go文件。

> swig -c++ -go -intgosize 64 -cgo compare_length.i

运行上述命令后,在$GOPATH/compare_length/路径下生成了连个文件compare_length_wrapper.cxx和compare_length.go文件。compare_length_wrapper.cxx文件将compare_length.i中指定的C++类型和接口导出为C风格的接口和类型。compare_length.go文件在Go环境引用C接口,并将其封装为Go风格的接口。通过vim查看compare_length.go的内容,可以看到import "C"。


extern _Bool _wrap_VecInt_isEmpty_compare_length_d0802815884ccdeb(uintptr_t arg1);
extern void _wrap_VecInt_clear_compare_length_d0802815884ccdeb(uintptr_t arg1);
extern void _wrap_VecInt_add_compare_length_d0802815884ccdeb(uintptr_t arg1, swig_intgo arg2);
extern swig_intgo _wrap_VecInt_get_compare_length_d0802815884ccdeb(uintptr_t arg1, swig_intgo arg2);
extern void _wrap_VecInt_set_compare_length_d0802815884ccdeb(uintptr_t arg1, swig_intgo arg2, swig_intgo arg3);
extern void _wrap_delete_VecInt_compare_length_d0802815884ccdeb(uintptr_t arg1);
extern swig_intgo _wrap_compare_compare_length_d0802815884ccdeb(uintptr_t arg1, uintptr_t arg2);
#undef intgo
*/
import "C" 

这里面导出的函数,都位于compare_length_wrapper.cxx文件,而且是SWIG自动生成的。

第三步,修改compare_length.go文件。添加对动态库的链接参数。在import "C"之前的注释部分添加这一句内容,#cgo LDFLAGS: -L${SRCDIR}/ -ll2。修改后的compare_length.go文件内容如下。

extern _Bool _wrap_VecInt_isEmpty_compare_length_d0802815884ccdeb(uintptr_t arg1);
extern void _wrap_VecInt_clear_compare_length_d0802815884ccdeb(uintptr_t arg1);
extern void _wrap_VecInt_add_compare_length_d0802815884ccdeb(uintptr_t arg1, swig_intgo arg2);
extern swig_intgo _wrap_VecInt_get_compare_length_d0802815884ccdeb(uintptr_t arg1, swig_intgo arg2);
extern void _wrap_VecInt_set_compare_length_d0802815884ccdeb(uintptr_t arg1, swig_intgo arg2, swig_intgo arg3);
extern void _wrap_delete_VecInt_compare_length_d0802815884ccdeb(uintptr_t arg1);
extern swig_intgo _wrap_compare_compare_length_d0802815884ccdeb(uintptr_t arg1, uintptr_t arg2);
#undef intgo
#cgo LDFLAGS: -L${SRCDIR}/ -ll2
*/
import "C" 

这是本文第一部分讲到的,添加Cgo指令。由于我们用到了动态库,需要指定链接时的参数。

第四步,在$GOPATH/compare_length/路径下运行go build。或者在任意位置运行go build compare_length。看到没有报错,就是build成功了。

4.3 测试使用

现在,GOPATH/compare_length/中的compare_length模块已经和一般的Go模块一样,可以被其他Go代码调用。我们建立一个测试路径GOPATH/compare_length_test/,在其中添加一个测试文件runme.go。

package main

import (
    "compare_length"
    "fmt"
)

func main() {
    l1 := compare_length.NewVecInt();
    l2 := compare_length.NewVecInt();
    l1.Add(1);
    l1.Add(2);
    l1.Add(3);

    l2.Add(1);
    l2.Add(2);
    l2.Add(4);
    ret := compare_length.Compare(l1, l2);
    fmt.Println(ret);
}

运行go build -o runme,可以看到生成了可执行文件runme。然后在本地运行./runme,遇到报错信息。

./runme: error while loading shared libraries: libl2.so: cannot open shared object file: No such file or directory

通过ldd runme看一下。可以看到libl2.so未找到。

libl2.so => not found

由于我们用到了动态库,因此要指定一下环境变量LD_LIBRARY_PATH。

export LD_LIBRARY_PATH=$GOPATH/src/compare_length/:$LD_LIBRARY_PATH
./rumme

可以看到返回了正确内容。

-1

5 实际问题

我的实际问题,是用Go调用一个已有的NLP模块,该模块是用C++写的。与上一节中的示例项目基本一致,只是链接的动态库更多,导出的函数及类型更多。

之前这个模块已经通过SWIG导出给Python,因此segment.i文件没有做任何修改。

%module segment
%{
    #include "segment.h"
%}
%include "typemaps.i"
%include "std_string.i"
%include "std_vector.i"
%include "segment.h"

%template(VecDouble)    std::vector<double>;
%template(VecInt)       std::vector<int>;
%template(CoreSegmentItemVec) std::vector<CoreSegmentItem>;

int coreSegment(void* INOUT, const std::string& IN, std::vector<CoreSegmentItem>& OUT);
std::string postag2string(int wtype);
std::string t2sgchar(const std::string& IN, bool ifcase = true);
std::string sbc2dbc(const std::string& IN, bool ifcase = true);

运行SWIG命令,生成segment_wrapper.cxx和segment.go两个文件。

swig -c++ -go -gointsize 64 -cgo segment.i

修改segment.go文件,添加链接参数,在import "C"之前的注释里添加。

#cgo LDFLAGS: -L${SRCDIR}/lib -lssplatform -lencoding -lCoreSegmentor

然后尝试go build。没有提示错误就是build成功。然后在$GOPATH下的另一个目录写一段测试代码。

package main

import (
        "github.com/terencezhou/segment"
        "github.com/axgle/mahonia"
        "fmt"
)

func main(){
        test_str := "中华人民共和国国家主席于今年10月对美国进行了访问。";
        encoder_gbk := mahonia.NewEncoder("gbk")
        decoder_gbk := mahonia.NewDecoder("gbk")
        gbk_test_str := encoder_gbk.ConvertString(test_str)
        segment.Init();
        handler := segment.CreateCoreHandle();
        seg_res := segment.NewCoreSegmentItemVec();
        ret := segment.CoreSegment(handler, gbk_test_str, seg_res);
        fmt.Println(test_str);
        fmt.Printf("Segment status : %d\n", ret);
        for idx:=0; int64(idx) < seg_res.Size(); idx++{
                coreItem := seg_res.Get(idx);
                fmt.Println(decoder_gbk.ConvertString(coreItem.GetTxt()));
        }
}

可以看到正确输出了分词。

中华人民共和国国家主席于今年10月对美国进行了访问。
Segment status : 0
中华
人民
共和国
国家
主席
于
今年
10
月
对
美国
进行
了
访问

6 总结及扩展阅读

本文第二部分简述了Go如何通过Cgo调用C接口,其他细节可以参考Cgo文档。本文第二部分简述了SWIG的使用,及SWIG的Go特性,详细内容可以参考SWIG文档SWIG文档中关于Go的部分。第三部分是一个示例工程,用来测试实际使用中用到的特性,l2.cpp的实现在下面给出,可以手动生成libl2.so来进行测试,但测试编译go模块的时候,需要将l2.cpp从$GOPATH/src/compare_length/目录中移出,否侧Cgo会自动编译它。第四部分是在实际工程中的使用。

#include <vector>
#include <math.h>

#include "l2.h"

using namespace std;

int l2(const vector<int>& elements)
{
    int sum = 0;
    for (vector<int>::const_iterator iter = elements.begin();
            iter != elements.end();
            iter++)
    {   
        sum += (*iter) * (*iter);
    }   
    float sq = sqrt(sum);
    int l2 = (int)sq;
    return l2; 
}

通过下面命令可以生成用来测试的libl2.so。

g++ -g -o libl2.so -shared -fPIC -DHAVE_INTTYPES_H -DHAVE_NETINET_IN_H l2.cpp
posted @ 2018-12-03 16:20  苦力笨笨  阅读(9598)  评论(2编辑  收藏  举报