CGO入門剖析與實踐
作者:panhuili,騰訊 IEG 後臺開發工程師
一、CGO 快速入門
1.1、啟用 CGO 特性
在 golang 程式碼中加入 import “C” 語句就可以啟動 CGO 特性。這樣在進行 go build 命令時,就會在編譯和連線階段啟動 gcc 編譯器。
// go.1.15// test1.go
package main
import "C" // import "C"更像是一個關鍵字,CGO工具在預處理時會刪掉這一行
func main() {
}
使用 -x 選項可以檢視 go 程式編譯過程中執行的所有指令。可以看到 golang 編譯器已經為 test1.go 建立了 CGO 編譯選項
[root@VM-centos ~/cgo_test/golink2]# go build -x test1.go WORK=/tmp/go-build330287398 mkdir -p $WORK/b001/ cd /root/cgo_test/golink2 CGO_LDFLAGS='"-g" "-O2"' /usr/lib/golang/pkg/tool/linux_amd64/cgo -objdir $WORK/b001/ -importpath command-line-arguments -- -I $WORK/b001/ -g -O2 ./test1.go # CGO編譯選項 cd $WORK gcc -fno-caret-diagnostics -c -x c - -o /dev/null || true gcc -Qunused-arguments -c -x c - -o /dev/null || true gcc -fdebug-prefix-map=a=b -c -x c - -o /dev/null || true gcc -gno-record-gcc-switches -c -x c - -o /dev/null || true .......
1.2 、Hello Cgo
通過 import “C” 語句啟用 CGO 特性後,CGO 會將上一行程式碼所處註釋塊的內容視為 C 程式碼塊,被稱為序文(preamble)。
// test2.go
package main
//#include <stdio.h> // 序文中可以連結標準C程式庫
import "C"
func main() {
C.puts(C.CString("Hello, Cgo\n"))
}
在序文中可以使用 C.func 的方式呼叫 C 程式碼塊中的函式,包括庫檔案中的函式。對於 C 程式碼塊的變數,型別也可以使用相同方法進行呼叫。
test2.go 通過 CGO 提供的 C.CString 函式將 Go 語言字串轉化為 C 語言字串,最後再通過 C.puts 呼叫 <stdio.h>中的 puts 函式向標準輸出列印字串。
1.3 cgo 工具
當你在包中引用 import "C",go build 就會做很多額外的工作來構建你的程式碼,構建就不僅僅是向 go tool compile 傳遞一堆 .go 檔案了,而是要先進行以下步驟:
-
1)cgo 工具就會被呼叫,在 C 轉換 Go、Go 轉換 C 的之間生成各種檔案。
-
2)系統的 C 編譯器會被呼叫來處理包中所有的 C 檔案。
-
3)所有獨立的編譯單元會被組合到一個 .o 檔案。
-
4)生成的 .o 檔案會在系統的聯結器中對它的引用進行一次檢查修復。
cgo 是一個 Go 語言自帶的特殊工具,可以使用命令 go tool cgo 來執行。它可以生成能夠呼叫 C 語言程式碼的 Go 語言原始檔,也就是說所有啟用了 CGO 特性的 Go 程式碼,都會首先經過 cgo 的"預處理"。
對 test2.go,cgo 工具會在同目錄生成以下檔案
_obj--|
|--_cgo.o // C程式碼編譯出的連結庫
|--_cgo_main.c // C程式碼部分的main函式
|--_cgo_flags // C程式碼的編譯和連結選項
|--_cgo_export.c //
|--_cgo_export.h // 匯出到C語言的Go型別
|--_cgo_gotypes.go // 匯出到Go語言的C型別
|--test1.cgo1.go // 經過“預處理”的Go程式碼
|--test1.cgo2.c // 經過“預處理”的C程式碼
二、CGO 的 N 種用法
CGO 作為 Go 語言和 C 語言之間的橋樑,其使用場景可以分為兩種:Go 呼叫 C 程式 和 C 呼叫 Go 程式。
2.1、Go 呼叫自定義 C 程式
// test3.go
package main
/*
#cgo LDFLAGS: -L/usr/local/lib
#include <stdio.h>
#include <stdlib.h>
#define REPEAT_LIMIT 3 // CGO會保留C程式碼塊中的巨集定義
typedef struct{ // 自定義結構體
int repeat_time;
char* str;
}blob;
int SayHello(blob* pblob) { // 自定義函式
for ( ;pblob->repeat_time < REPEAT_LIMIT; pblob->repeat_time++){
puts(pblob->str);
}
return 0;
}
*/
import "C"
import (
"fmt"
"unsafe"
)
func main() {
cblob := C.blob{} // 在GO程式中建立的C物件,儲存在Go的記憶體空間
cblob.repeat_time = 0
cblob.str = C.CString("Hello, World\n") // C.CString 會在C的記憶體空間申請一個C語言字串物件,再將Go字串拷貝到C字串
ret := C.SayHello(&cblob) // &cblob 取C語言物件cblob的地址
fmt.Println("ret", ret)
fmt.Println("repeat_time", cblob.repeat_time)
C.free(unsafe.Pointer(cblob.str)) // C.CString 申請的C空間記憶體不會自動釋放,需要顯示呼叫C中的free釋放
}
CGO 會保留序文中的巨集定義,但是並不會保留註釋,也不支援#program,C 程式碼塊中的#program 語句極可能產生未知錯誤。
CGO 中使用 #cgo 關鍵字可以設定編譯階段和連結階段的相關引數,可以使用 ${SRCDIR} 來表示 Go 包當前目錄的絕對路徑。
使用 C.結構名 或 C.struct_結構名 可以在 Go 程式碼段中定義 C 物件,並通過成員名訪問結構體成員。
test3.go 中使用 C.CString 將 Go 字串物件轉化為 C 字串物件,並將其傳入 C 程式空間進行使用,由於 C 的記憶體空間不受 Go 的 GC 管理,因此需要顯示的呼叫 C 語言的 free 來進行回收。詳情見第三章。
2.2、Go 呼叫 C/C++模組
2.2.1、簡單 Go 調 C
直接將完整的 C 程式碼放在 Go 原始檔中,這種編排方式便於開發人員快速在 C 程式碼和 Go 程式碼間進行切換。
// demo/test4.go
package main
/*
#include <stdio.h>
int SayHello() {
puts("Hello World");
return 0;
}
*/
import "C"
import (
"fmt"
)
func main() {
ret := C.SayHello()
fmt.Println(ret)
}
但是當 CGO 中使用了大量的 C 語言程式碼時,將所有的程式碼放在同一個 go 檔案中即不利於程式碼複用,也會影響程式碼的可讀性。此時可以將 C 程式碼抽象成模組,再將 C 模組整合入 Go 程式中。
2.2.2、Go 呼叫 C 模組
將 C 程式碼進行抽象,放到相同目錄下的 C 語言原始檔 hello.c 中
// demo/hello.c
#include <stdio.h>
int SayHello() {
puts("Hello World");
return 0;
}
在 Go 程式碼中,宣告 SayHello() 函式,再引用 hello.c 原始檔,就可以調起外部 C 原始檔中的函數了。同理也可以將C 原始碼編譯打包為靜態庫或動態庫進行使用。
// demo/test5.go
package main
/*
#include "hello.c"
int SayHello();
*/
import "C"
import (
"fmt"
)
func main() {
ret := C.SayHello()
fmt.Println(ret)
}
test5.go 中只對 SayHello 函式進行了宣告,然後再通過連結 C 程式庫的方式載入函式的實現。那麼同樣的,也可以通過連結 C++程式庫的方式,來實現 Go 呼叫 C++程式。
2.2.3、Go 呼叫 C++模組
基於 test4。可以抽象出一個 hello 模組,將模組的介面函式在 hello.h 標頭檔案進行定義
// demo/hello.h
int SayHello();
再使用 C++來重新實現這個 C 函式
// demo/hello.cpp
#include <iostream>
extern "C" {
#include "hello.h"
}
int SayHello() {
std::cout<<"Hello World";
return 0;
}
最後再在 Go 程式碼中,引用 hello.h 標頭檔案,就可以呼叫 C++實現的 SayHello 函數了
// demo/test6.go
package main
/*
#include "hello.h"
*/
import "C"
import (
"fmt"
)
func main() {
ret := C.SayHello()
fmt.Println(ret)
}
CGO 提供的這種面向 C 語言介面的程式設計方式,使得開發者可以使用是任何程式語言來對介面進行實現,只要最終滿足 C 語言介面即可。
2.3、C 呼叫 Go 模組
C 呼叫 Go 相對於 Go 調 C 來說要複雜多,可以分為兩種情況。一是原生 Go 程序呼叫 C,C 中再反調 Go 程式。另一種是原生 C 程序直接呼叫 Go。
2.3.1、Go 實現的 C 函式
如前述,開發者可以用任何程式語言來編寫程式,只要支援 CGO 的 C 介面標準,就可以被 CGO 接入。那麼同樣可以用 Go 實現 C 函式介面。
在 test6.go 中,已經定義了 C 介面模組 hello.h
// demo/hello.h
void SayHello(char* s);
可以建立一個 hello.go 檔案,來用 Go 語言實現 SayHello 函式
// demo/hello.go
package main
//#include <hello.h>
import "C"
import "fmt"
//export SayHello
func SayHello(str *C.char) {
fmt.Println(C.GoString(str))
}
CGO 的//export SayHello 指令將 Go 語言實現的 SayHello 函式匯出為 C 語言函式。這樣再 Go 中呼叫 C.SayHello 時,最終呼叫的是 hello.go 中定義的 Go 函式 SayHello
// demo/test7.go
// go run ../demo
package main
//#include "hello.h"
import "C"
func main() {
C.SayHello(C.CString("Hello World"))
}
Go 程式先呼叫 C 的 SayHello 介面,由於 SayHello 介面連結在 Go 的實現上,又調到 Go。
看起來調起方和實現方都是 Go,但實際執行順序是 Go 的 main 函式,調到 CGO 生成的 C 橋接函式,最後 C 橋接函式再調到 Go 的 SayHello。這部分會在第四章進行分析。
2.3.2、原生 C 呼叫 Go
C 呼叫到 Go 這種情況比較複雜,Go 一般是便以為 c-shared/c-archive 的庫給 C 呼叫。
// demo/hello.go
package main
import "C"
//export hello
func hello(value string)*C.char { // 如果函式有返回值,則要將返回值轉換為C語言對應的型別
return C.CString("hello" + value)
}
func main(){
// 此處一定要有main函式,有main函式才能讓cgo編譯器去把包編譯成C的庫
}
如果 Go 函式有多個返回值,會生成一個 C 結構體進行返回,結構體定義參考生成的.h 檔案
生成 c-shared 檔案 命令
go build -buildmode=c-shared -o hello.so hello.go
在 C 程式碼中,只需要引用 go build 生成的.h 檔案,並在編譯時連結對應的.so 程式庫,即可從 C 呼叫 Go 程式
// demo/test8.c
#include <stdio.h>
#include <string.h>
#include "hello.h" //此處為上一步生成的.h檔案
int main(){
char c1[] = "did";
GoString s1 = {c1,strlen(c1)}; //構建Go語言的字串型別
char *c = hello(s1);
printf("r:%s",c);
return 0;
}
編譯命令
gcc -o c_go main.c hello.so
C 函式調入進 Go,必須按照 Go 的規則執行,當主程式是 C 呼叫 Go 時,也同樣有一個 Go 的 runtime 與 C 程式並行執行。這個 runtime 的初始化在對應的 c-shared 的庫載入時就會執行。因此,在程序啟動時就有兩個執行緒執行,一個 C 的,一 (多)個是 Go 的。
三、型別轉換
想要更好的使用 CGO 必須瞭解 Go 和 C 之間型別轉換的規則
3.1、數值型別
在 Go 語言中訪問 C 語言的符號時,一般都通過虛擬的“C”包進行。比如 C.int,C.char 就對應與 C 語言中的 int 和 char,對應於 Go 語言中的 int 和 byte。
C 語言和 Go 語言的數值型別對應如下:
Go 語言的 int 和 uint 在 32 位和 64 位系統下分別是 4 個位元組和 8 個位元組大小。它在 C 語言中的匯出型別 GoInt 和 GoUint 在不同位數系統下記憶體大小也不同。
如下是 64 位系統中,Go 數值型別在 C 語言的匯出列表
// _cgo_export.h
typedef signed char GoInt8;
typedef unsigned char GoUint8;
typedef short GoInt16;
typedef unsigned short GoUint16;
typedef int GoInt32;
typedef unsigned int GoUint32;
typedef long long GoInt64;
typedef unsigned long long GoUint64;
typedef GoInt64 GoInt;
typedef GoUint64 GoUint;
typedef __SIZE_TYPE__ GoUintptr;
typedef float GoFloat32;
typedef double GoFloat64;
typedef float _Complex GoComplex64;
typedef double _Complex GoComplex128;
需要注意的是在 C 語言符號名前加上 Ctype, 便是其在 Go 中的匯出名,因此在啟用 CGO 特性後,Go 語言中禁止出現以Ctype 開頭的自定義符號名,類似的還有Cfunc等。
可以在序文中引入_obj/_cgo_export.h 來顯式使用 cgo 在 C 中的匯出型別
// test9.go
package main
/*
#include "_obj/_cgo_export.h" // _cgo_export.h由cgo工具動態生成
GoInt32 Add(GoInt32 param1, GoInt32 param2) { // GoInt32即為cgo在C語言的匯出型別
return param1 + param2;
}
*/
import "C"
import "fmt"
func main() {
// _Ctype_ // _Ctype_ 會在cgo預處理階段觸發異常,
fmt.Println(C.Add(1, 2))
}
如下是 64 位系統中,C 數值型別在 Go 語言的匯出列表
// _cgo_gotypes.go
type _Ctype_char int8
type _Ctype_double float64
type _Ctype_float float32
type _Ctype_int int32
type _Ctype_long int64
type _Ctype_longlong int64
type _Ctype_schar int8
type _Ctype_short int16
type _Ctype_size_t = _Ctype_ulong
type _Ctype_uchar uint8
type _Ctype_uint uint32
type _Ctype_ulong uint64
type _Ctype_ulonglong uint64
type _Ctype_void [0]byte
為了提高 C 語言的可移植性,更好的做法是通過 C 語言的 C99 標準引入的標頭檔案,不但每個數值型別都提供了明確記憶體大小,而且和 Go 語言的型別命名更加一致。
3.2、切片
Go 中切片的使用方法類似 C 中的陣列,但是記憶體結構並不一樣。C 中的陣列實際上指的是一段連續的記憶體,而 Go 的切片在儲存資料的連續記憶體基礎上,還有一個頭結構體,其記憶體結構如下
因此 Go 的切片不能直接傳遞給 C 使用,而是需要取切片的內部緩衝區的首地址(即首個元素的地址)來傳遞給 C 使用。使用這種方式把 Go 的記憶體空間暴露給 C 使用,可以大大減少 Go 和 C 之間引數傳遞時記憶體拷貝的消耗。
// test10.go
package main
/*
int SayHello(char* buff, int len) {
char hello[] = "Hello Cgo!";
int movnum = len < sizeof(hello) ? len:sizeof(hello);
memcpy(buff, hello, movnum); // go字串沒有'\0',所以直接記憶體拷貝
return movnum;
}
*/
import "C"
import (
"fmt"
"unsafe"
)
func main() {
buff := make([]byte, 8)
C.SayHello((*C.char)(unsafe.Pointer(&buff[0])), C.int(len(buff)))
a := string(buff)
fmt.Println(a)
}
3.3 字串
Go 的字串與 C 的字串在底層的記憶體模型也不一樣:
Go 的字串並沒有以'\0' 結尾,因此使用類似切片的方式,直接將 Go 字串的首元素地址傳遞給 C 是不可行的。
3.3.1、Go 與 C 的字串傳遞
cgo 給出的解決方案是標準庫函式 C.CString(),它會在 C 記憶體空間內申請足夠的空間,並將 Go 字串拷貝到 C 空間中。因此 C.CString 申請的記憶體在 C 空間中,因此需要顯式的呼叫 C.free 來釋放空間,如 test3。
如下是 C.CString()的底層實現
func _Cfunc_CString(s string) *_Ctype_char { // 從Go string 到 C char* 型別轉換
p := _cgo_cmalloc(uint64(len(s)+1))
pp := (*[1<<30]byte)(p)
copy(pp[:], s)
pp[len(s)] = 0
return (*_Ctype_char)(p)
}
//go:cgo_unsafe_args
func _cgo_cmalloc(p0 uint64) (r1 unsafe.Pointer) {
_cgo_runtime_cgocall(_cgo_bb7421b6328a_Cfunc__Cmalloc, uintptr(unsafe.Pointer(&p0)))
if r1 == nil {
runtime_throw("runtime: C malloc failed")
}
return
}
_Cfunc_CString
_Cfunc_CString 是 cgo 定義的從 Go string 到 C char* 的型別轉換函式
- 使用 _cgo_cmalloc 在 C 空間內申請記憶體(即不受 Go GC 控制的記憶體)
- 使用該段 C 記憶體初始化一個[]byte 物件
- 將 string 拷貝到[]byte 物件
- 將該段 C 空間記憶體的地址返回
它的實現方式類似前述,切片的型別轉換。不同在於切片的型別轉換,是將 Go 空間記憶體暴露給 C 函式使用。而_Cfunc_CString 是將 C 空間記憶體暴露給 Go 使用。
_cgo_cmalloc
定義了一個暴露給 Go 的 C 函式,用於在 C 空間申請記憶體
與 C.CString()對應的是從 C 字串轉 Go 字串的轉換函式 C.GoString()。C.GoString()函式的實現較為簡單,檢索 C 字串長度,然後申請相同長度的 Go-string 物件,最後記憶體拷貝。
如下是 C.GoString()的底層實現
//go:linkname _cgo_runtime_gostring runtime.gostring
func _cgo_runtime_gostring(*_Ctype_char) string
func _Cfunc_GoString(p *_Ctype_char) string { // 從C char* 到 Go string 型別轉換
return _cgo_runtime_gostring(p)
}
//go:linkname gostring
func gostring(p *byte) string { // 底層實現
l := findnull(p)
if l == 0 {
return ""
}
s, b := rawstring(l)
memmove(unsafe.Pointer(&b[0]), unsafe.Pointer(p), uintptr(l))
return s
}
3.3.2、更高效的字串傳遞方法
C.CString 簡單安全,但是它涉及了一次從 Go 到 C 空間的記憶體拷貝,對於長字串而言這會是難以忽視的開銷。
Go 官方文件中聲稱 string 型別是”不可改變的“,但是在實操中可以發現,除了常量字串會在編譯期被分配到只讀段,其他的動態生成的字串實際上都是在堆上。
因此如果能夠獲得 string 的記憶體快取區地址,那麼就可以使用類似切片傳遞的方式將字串指標和長度直接傳遞給 C 使用。
查閱原始碼,可知 String 實際上是由緩衝區首地址 和 長度構成的。這樣就可以通過一些方式拿到快取區地址。
type stringStruct struct {
str unsafe.Pointer //str首地址
len int //str長度
}
test11.go 將 fmt 動態生成的 string 轉為自定義型別 MyString 便可以獲得緩衝區首地址,將地址傳入 C 函式,這樣就可以在 C 空間直接操作 Go-String 的記憶體空間了,這樣可以免去記憶體拷貝的消耗。
// test11.go
package main
/*
#include <string.h>
int SayHello(char* buff, int len) {
char hello[] = "Hello Cgo!";
int movnum = len < sizeof(hello) ? len:sizeof(hello);
memcpy(buff, hello, movnum);
return movnum;
}
*/
import "C"
import (
"fmt"
"unsafe"
)
type MyString struct {
Str *C.char
Len int
}
func main() {
s := fmt.Sprintf(" ")
C.SayHello((*MyString)(unsafe.Pointer(&s)).Str, C.int((*MyString)(unsafe.Pointer(&s)).Len))
fmt.Print(s)
}
這種方法背離了 Go 語言的設計理念,如非必要,不要把這種程式碼帶入你的工程,這裡只是作為一種“黑科技”進行分享。
3.4、結構體,聯合,列舉
cgo 中結構體,聯合,列舉的使用方式類似,可以通過 C.struct_XXX 來訪問 C 語言中 struct XXX 型別。union,enum 也類似。
3.4.1、結構體
如果結構體的成員名字中碰巧是 Go 語言的關鍵字,可以通過在成員名開頭新增下劃線來訪問
如果有 2 個成員:一個是以 Go 語言關鍵字命名,另一個剛好是以下劃線和 Go 語言關鍵字命名,那麼以 Go 語言關鍵字命名的成員將無法訪問(被遮蔽)
C 語言結構體中位欄位對應的成員無法在 Go 語言中訪問,如果需要操作位欄位成員,需要通過在 C 語言中定義輔助函式來完成。對應零長陣列的成員(C 中經典的變長陣列),無法在 Go 語言中直接訪問陣列的元素,但同樣可以通過在 C 中定義輔助函式來訪問。
結構體的記憶體佈局按照 C 語言的通用對齊規則,在 32 位 Go 語言環境 C 語言結構體也按照 32 位對齊規則,在 64 位 Go 語言環境按照 64 位的對齊規則。對於指定了特殊對齊規則的結構體,無法在 CGO 中訪問。
// test11.go
package main
/*
struct Test {
int a;
float b;
double type;
int size:10;
int arr1[10];
int arr2[];
};
int Test_arr2_helper(struct Test * tm ,int pos){
return tm->arr2[pos];
}
#pragma pack(1)
struct Test2 {
float a;
char b;
int c;
};
*/
import "C"
import "fmt"
func main() {
test := C.struct_Test{}
fmt.Println(test.a)
fmt.Println(test.b)
fmt.Println(test._type)
//fmt.Println(test.size) // 位資料
fmt.Println(test.arr1[0])
//fmt.Println(test.arr) // 零長陣列無法直接訪問
//Test_arr2_helper(&test, 1)
test2 := C.struct_Test2{}
fmt.Println(test2.c)
//fmt.Println(test2.c) // 由於記憶體對齊,該結構體部分欄位Go無法訪問
}
3.4.2、聯合
Go 語言中並不支援 C 語言聯合型別,它們會被轉為對應大小的位元組陣列。
如果需要操作 C 語言的聯合型別變數,一般有三種方法:第一種是在 C 語言中定義輔助函式;第二種是通過 Go 語言的"encoding/binary"手工解碼成員(需要注意大端小端問題);第三種是使用 unsafe
包強制轉型為對應型別(這是效能最好的方式)。
test12 給出了 union 的三種訪問方式
// test12.go
package main
/*
#include <stdint.h>
union SayHello {
int Say;
float Hello;
};
union SayHello init_sayhello(){
union SayHello us;
us.Say = 100;
return us;
}
int SayHello_Say_helper(union SayHello * us){
return us->Say;
}
*/
import "C"
import (
"fmt"
"unsafe"
"encoding/binary"
)
func main() {
SayHello := C.init_sayhello()
fmt.Println("C-helper ",C.SayHello_Say_helper(&SayHello)) // 通過C輔助函式
buff := C.GoBytes(unsafe.Pointer(&SayHello), 4)
Say2 := binary.LittleEndian.Uint32(buff)
fmt.Println("binary ",Say2) // 從記憶體直接解碼一個int32
fmt.Println("unsafe modify ", *(*C.int)(unsafe.Pointer(&SayHello))) // 強制型別轉換
}
3.4.3、列舉
對於列舉型別,可以通過C.enum_xxx來訪問 C 語言中定義的enum xxx結構體型別。
使用方式和 C 相同,這裡就不列例子了
3.5、指標
在 Go 語言中兩個指標的型別完全一致則不需要轉換可以直接通用。如果一個指標型別是用 type 命令在另一個指標型別基礎之上構建的,換言之兩個指標底層是相同完全結構的指標,那麼也可以通過直接強制轉換語法進行指標間的轉換。
但是 C 語言中,不同型別的指標是可以顯式或隱式轉換。cgo 經常要面對的是 2 個完全不同型別的指標間的轉換,實現這一轉換的關鍵就是 unsafe.Pointer,類似於 C 語言中的 Void*型別指標。
使用這種方式就可以實現不同型別間的轉換,如下是從 Go - int32 到 *C.char 的轉換。