1. 程式人生 > 其它 >CGO入門剖析與實踐

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* 的型別轉換函式

  1. 使用 _cgo_cmalloc 在 C 空間內申請記憶體(即不受 Go GC 控制的記憶體)
  2. 使用該段 C 記憶體初始化一個[]byte 物件
  3. 將 string 拷貝到[]byte 物件
  4. 將該段 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 的轉換。