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 Doc和SWIGPlus 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接口文件應該包括
- 模塊聲明,位於第一行,以%module指令開頭;
- 定義需要在_wrapper.cpp文件里包含的頭文件,即%{和%}之間的部分。在上面的例子,就是包含int compare函數的那個頭文件;
- 聲明要導出的接口及類型。這就是完全的ANSI C/C++聲明語法。
- 根據具體需求,其他需要的輔助指令。包括%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++的元素導出時會有一些修改。
- 所有的Go代碼都必須在一個package內,SWIG生成的Go代碼,也都位於一個package內,這個package的名稱由SWIG接口文件中的%package指令設置;
- 由於在Go中只有大寫字母開頭的名稱,才是在package外可用的,因此所有被導出的C++名稱(變量、函數、類等),如果是小寫字母開頭,則會被轉換為大寫字母開頭;
- 導出C++的變量,會被封裝為Get和Set兩個函數。例如一個C++變量名為var,SWIG會為其生成SetVar和GetVar兩個方法;
- 導出C++變量如果是常亮,則只提供Get方法;
- 導出C++的宏,在Go中會變成一個常量;
- 導出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不能直接支持的,也是在實際項目中經常遇到的。
- libl2.so,l2.h。一個動態庫及其頭文件,其中的函數
int l2(const std::vector<int>& elements)
用於計算一個向量的長度。我們沒有該函數的實現代碼。- 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