在GO中呼叫C原始碼#基礎篇1

神王攻大人 發表於 2022-05-07
Go

開坑說明

最近在編寫客戶端程式或與其他部門做功能整合時多次碰到了跨語言的sdk整合,雖說方案很多諸如rpc啊,管道啊,檔案io啊,unix socket啊之類的不要太多,順便研究了下在go呼叫標準c介面的種種方法與坑,內容不少,有空便慢慢更新了。

內嵌形式

先讓我們來看一個最簡單的cgo例項

package main

//#include <stdio.h>
import "C"

func main() {
	C.puts(C.CString("Hello World"))
}

輸出

Hello World

通過"C包"呼叫了c中常見的puts函式同時傳入通過C.Cstring把go 中string轉化為的c string(相當於char *)。其實“C”這個並不是一個包,而是通過import "C"語句啟用了go編譯器cgo相關的功能讓gcc也參與到了編譯中。這種方式通過緊貼在import "C"語句上面的註釋中編寫c程式碼並在後續程式碼中使用C物件呼叫。當然也可以通過這種方式呼叫自定義的c函式。

package main
import "C"

/*#include <stdio.h>

void say_hello_with_name(char * name){
	printf("hello %s\n", name);
}
 */
import "C"

func main() {
	C.say_hello_with_name(C.CString("oscar"))
}

輸出

hello oscar

外接的C程式碼

內建的C程式碼固然很方便,但用到cgo大多數的使用場景是我有一個需要複用的c程式碼庫,像是c++的stl庫亦或者是linux c中的什麼已經封裝好的第三方依賴。這些時候便需要外接一些c的檔案.h .c .cpp之類與.go檔案混編。先看一個最簡單的例子(呼叫linux的系統賬戶認證)。

// auth.h
int auth(char *user, char *passwd);
// auth.c
#include <shadow.h>
#include <stdio.h>
#include <unistd.h>

int auth(char *user, char *passwd){
    char *obtpwd;
    struct spwd *spasswd;

    spasswd = getspnam(user);
    obtpwd = crypt(passwd, spasswd->sp_pwdp);
    if(strcmp(spasswd->sp_pwdp, obtpwd) == 0)
    return 0;
    else return 1;
}

// main.go
package main

/*
#cgo LDFLAGS: -lcrypt

#include "auth.h"
*/
import "C"
import "fmt"

func main() {
	var username, password string

	fmt.Println("Please enter your username and password: ")
    _, _ = fmt.Scanln(&username, &password)

	rst := C.auth(C.CString(username), C.CString(password))
	fmt.Println(rst)
}

保證上述三個檔案在同一個go工程目錄下執行 go build -o main 構建工程。#cgo LDFLAGS: -lcrypt 這個一行是cgo給gcc的編譯引數,相關的編譯引數與連線引數有空了在後面的文章裡說明,-lcrypt 表示編譯時需要去連線libcrypt這個庫。

呼叫C的靜態庫

注意,這種c go 混編的方式個人是不建議的,cgo對外接c程式碼片構建支援非常差,我無法在cgo中通過編譯引數指定c程式碼片的搜尋路徑(標頭檔案倒是沒啥問題),這也就意味著當專案被呼叫的c程式碼片都得在專案根目錄下,這可太糟糕了。個人覺得如果有大量的外部依賴c語言的庫請分開編譯,c庫使用gcc編譯成靜態或動態庫在讓go在編譯時連線為好,寫個makefile分開分步編譯也不是什麼麻煩事,還是上面的例子,讓我們把編譯的過程稍加修改。

1. 構建libauth.a靜態庫

gcc -c -o auth.o -lcrypt auth.c
ar rcs libauth.a auth.o

得到libauth.a

2. 對main.go稍加修改

package main

/*
#cgo CFLAGS: -I./
#cgo LDFLAGS: -L. -lauth -lcrypt

#include "auth.h"
*/
import "C"
import "fmt"

func main() {
	var username, password string

	fmt.Println("Please enter your username and password: ")
    _, _ = fmt.Scanln(&username, &password)

	rst := C.auth(C.CString(username), C.CString(password))
	fmt.Println(rst)
}

此處修改主要是新增了libauth.a靜態庫的連結引數

3. 編譯

go build -o main main.go

可以把上述的步驟整下寫個簡單的makefile

.PHONY : all

all: main

libauth.a: auth.c
	gcc -c -o auth.o -lcrypt auth.c
	ar rcs libauth.a auth.o

main: main.go libauth.a
	go build -o main main.go

clean:
	rm -f auth.o libauth.a main

這樣也讓我們得出產物的過程變得相對簡單快捷

呼叫C的動態庫

比起靜態庫動態庫更加的靈活,它不會在編譯階段和主程式直接打包在一起而類似一個外接函式庫而且對外僅對介面負責,這使得程式的某些功能升級或是外掛整合變得方便(僅修改動態庫即可)。
動態庫的呼叫和靜態庫類似,此處我做如何動態載入相關的說明(因為你動態載入了其實和cgo沒啥大的關係,有關係也不是你處理的,而是動態載入包處理的),僅說明如何在編譯階段連結動態庫。
還是上面的那個例子,構建動態庫。

package main

/*
#cgo CFLAGS: -I.
#cgo LDFLAGS: -L. -lauth

#include "auth.h"
*/
import "C"
import "fmt"

func main() {
	var username, password string

	fmt.Println("Please enter your username and password: ")
    _, _ = fmt.Scanln(&username, &password)

	rst := C.auth(C.CString(username), C.CString(password))
	fmt.Println(rst)
}
gcc -shared -lcrypt -fPIC -o libauth.so auth.c
go build -o main main.go # go編譯器會根據編譯引數自動去找libauth.so這個庫

編譯完成後為動態庫建立軟連線讓系統可以找到它

ln -s /home/tmp/test2/libauth.so /lib64/libauth.so

之後便可正常執行

編譯和連結引數

1. 編譯引數: CFLAGS/CPPFLAGS/CXXFLAGS

這仨只要記住CFLAGS是純C風格的編譯引數,CPPFLAGS是C/C++的編譯引數,CXXFLAGS是純C++使用的編譯引數。為什麼會有這三個區別呢,很好理解c++是c的超集(差不多可以這麼說),但c和c++的編譯引數還是有很大的區別的,此處便做以不同的編譯引數區分。此處列舉部分CFLAGS引數

-c              用於把原始碼檔案編譯成 .o 物件檔案,不進行連結過程
-o              用於連線生成可執行檔案,在其後可以指定輸出檔案的名稱
-g              用於在生成的目標可執行檔案中,新增除錯資訊,可以使用GDB進行除錯
-Idir           用於把新目錄新增到include路徑上,可以使用相對和絕對路徑,“-I.”、“-I./include”、“-I/opt/include”
-Wall           生成常見的所有告警資訊,且停止編譯,具體是哪些告警資訊,請參見GCC手冊,一般用這個足矣!
-w              關閉所有告警資訊
-O              表示編譯優化選項,其後可跟優化等級0\1\2\3,預設是0,不優化
-fPIC           用於生成位置無關的程式碼
-v              (在標準錯誤)顯示執行編譯階段的命令,同時顯示編譯器驅動程式,前處理器,編譯器的版本號

最常見的引數使用便是指定標頭檔案的搜尋路徑,像是這樣

#cgo CFLAGS: : -I./include

2. 連結引數: LDFLAGS

主要是用於指定在編譯階段連線庫的搜尋路徑和連結庫名稱。這個就比較簡單-L後面跟搜尋路徑,-l後面跟要連結的庫名稱。舉個例子。我要連線一個在/home/self_lib/下名字叫做libsuperme.so的庫,那麼我就可以這麼寫

#cgo LDFLAGS: -L/home/self_lib/ -lsuperme

指的一提的是-L後面跟的路徑不能為相對路徑,但你如果寫了./這樣的相對路徑,cgo編譯器還是會給你轉成絕對路徑的,如果連線庫在當前的原始碼目錄(編譯階段)也可以利用{SRCDIR}這個巨集,可以這樣寫

#cgo LDFLAGS: -L{SRCDIR} -lsuperme

pkg-config

為不同 c/c++庫編寫編譯和連結引數是非常繁瑣的,因此cgo提供了pkg-config 工具支援,此工具可以通過pkg-config xxx -cflags命令生成引數。此功能本人未使用到待後續有空了試試,在大量使用第三方c/c++庫時應該可以節約大量的時間成本。