這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
使用 cgo 讓 Go 跟 C 一起工作已經不是啥稀奇的了。有大量的第三方包直接對 C 的庫做了封裝,提供給 Go 使用。從 Go 項目本身的代碼中可以看到,不但有 C 代碼,還有彙編代碼存在。那麼在自己的項目中是否能跟彙編結合呢?這篇文章完整並清晰的解說了如何讓 Go 和彙編協同工作。真得效能敏感?上彙編吧!!
————翻譯分隔線————
Go 和彙編
關於 Go,我最喜歡的部分之一就是它那堅定不移的實用主義線路。有時我們過於強調語言的設計,而忘記了編程所包含的其他內容。例如:
- Go 的編譯器很快
- Go 有著強大的標準庫
- Go 可以工作在多種平台下
- Go 有著可以通過命令列/本地 Web 服務/ 網際網路存取的完整文檔
- 所有 Go 的代碼是靜態編譯的,因此部署的問題微不足道
- 全部 Go 的代碼都以良好的格式發布,可以線上閱讀(就像這個)
- Go 有著良好定義(和文檔)的文法。(不像 C++ 或 Ruby)
- Go 內建包管理工具。
go get X
(例如 go get code.google.com/p/go.net/websocket
)
- 跟其他語言一樣,Go 有編碼樣式指引,有一些是編譯器強制的(例如大寫和小寫),而其他一些僅僅是約定,不過它還是提供了整理代碼的工具:
gofmt name_of_file.go
。
- 還有工具
go fix
可以將 Go 代碼從早版本自動遷移到新的版本
- Go 帶有測試載入器來測試包:
go test /path/to/package
。它還可以進行效能評估。
- 可以調試 和評估 Go 程式。
- 你知道有個遊樂場可以線上嘗試 Go 嗎?
- 通過 cgo Go 可以整合 C 的庫。
這些都已經有一些例子了,不過這裡我想聚焦在一個不怎麼為人所知的話題:Go 可以無縫調用彙編編寫的函數。
如何在 Go 中使用彙編
假設我們需要編寫一個彙編版本的 sum
函數。首先建立一個叫做 sum.go
的檔案,內容如下:
package sumfunc Sum(xs []int64) int64 { var n int64 for _, v := range xs { n += v } return n}
這個函數將一個整型的 slice 相加,並返回結果。為了測試這個函數,建立一個叫做 sum_test.go
的檔案,內容如下:
package sumimport ( "testing")type ( testCase struct { n int64 xs []int64 })var ( cases = []testCase{ { 0, []int64{} }, { 15, []int64{1,2,3,4,5} }, })func TestSum(t *testing.T) { for _, tc := range cases { n := Sum(tc.xs) if tc.n != n { t.Error("Expected", tc.n, "got", n, "for", tc.xs) } }}
為你的代碼編寫測試是個不錯的主意,不但可以檢驗庫的代碼(只要不是 package main
|譯註:package main 中的方法也是可以使用 go test
進行測試的),還是一個用於實驗的好方法。在命令列輸入 go test
就可以運行這個測試。
現在讓我們用彙編來代替這個函數。我們可以來看看 Go 編譯器到底產生了什麼。用命令 go tool 6g -S sum.go
來代替 go test
或者 go build
(對於 64 位元來說)。你會得到下面的內容:
--- prog list "Sum" ---0000 (sum.go:3) TEXT Sum+0(SB),$16-240001 (sum.go:4) MOVQ $0,SI0002 (sum.go:5) MOVQ xs+0(FP),BX0003 (sum.go:5) MOVQ BX,autotmp_0000+-16(SP)0004 (sum.go:5) MOVL xs+8(FP),BX0005 (sum.go:5) MOVL BX,autotmp_0000+-8(SP)0006 (sum.go:5) MOVL xs+12(FP),BX0007 (sum.go:5) MOVL BX,autotmp_0000+-4(SP)0008 (sum.go:5) MOVL $0,AX0009 (sum.go:5) MOVL autotmp_0000+-8(SP),DI0010 (sum.go:5) LEAQ autotmp_0000+-16(SP),BX0011 (sum.go:5) MOVQ (BX),CX0012 (sum.go:5) JMP ,140013 (sum.go:5) INCL ,AX0014 (sum.go:5) CMPL AX,DI0015 (sum.go:5) JGE ,200016 (sum.go:5) MOVQ (CX),BP0017 (sum.go:5) ADDQ $8,CX0018 (sum.go:6) ADDQ BP,SI0019 (sum.go:5) JMP ,130020 (sum.go:8) MOVQ SI,.noname+16(FP)0021 (sum.go:8) RET ,sum.go:3: Sum xs does not escape
彙編是相當難理解的,一會我們會詳細瞭解一下這個部分……不過,首先用這個作為模板接著往下做。在 sum.go
同一目錄建立一個叫做 sum_amd64.s
的檔案,內容如下:
// func Sum(xs []int64) int64TEXT ·Sum(SB),$0 MOVQ $0,SI MOVQ xs+0(FP),BX MOVQ BX,autotmp_0000+-16(SP) MOVL xs+8(FP),BX MOVL BX,autotmp_0000+-8(SP) MOVL xs+12(FP),BX MOVL BX,autotmp_0000+-4(SP) MOVL $0,AX MOVL autotmp_0000+-8(SP),DI LEAQ autotmp_0000+-16(SP),BX MOVQ (BX),CX JMP L2L1: INCL AXL2: CMPL AX,DI JGE L3 MOVQ (CX),BP ADDQ $8,CX ADDQ BP,SI JMP L1L3: MOVQ SI,.noname+16(FP) RET
基本上,我所做的所有處理就是將硬式編碼用於跳轉(JMP,JGE)的行號替換為標籤,並且在函數名前增加了中點符(·)。(確保檔案儲存為 UTF-8 編碼)接下來,從 sum.go
中移除我們的函數定義:
package sumfunc Sum(xs []int64) int64
現在,應當可以用 go test
運行測試,它將使用自訂的彙編版本的函數。
工作原理
這裡對彙編做一些更為詳細的說明。我將簡短的說明一下它做了什麼。
MOVQ $0,SI
首先,將 0 放入 SI(源變址)寄存器,它表示執行的指令的位置。Q 表示四個字,8 位元,下面還會看到 L 表示 4 位元。參數的順序是(源,目標)。
MOVQ xs+0(FP),BXMOVQ BX,autotmp_0000+-16(SP)MOVL xs+8(FP),BXMOVL BX,autotmp_0000+-8(SP)MOVL xs+12(FP),BXMOVL BX,autotmp_0000+-4(SP)
接下來接收傳入的參數,並將其值儲存在棧上。一個 Go 的 slice 有三個部分:指向其所在的記憶體的指標、長度和容量。指標是 8 位元,長度和容量都是 4 位元。因此這段代碼從 BX 寄存器複製了這些值出來。(參閱這裡瞭解更多關於 slice 的細節)
MOVL $0,AXMOVL autotmp_0000+-8(SP),DILEAQ autotmp_0000+-16(SP),BXMOVQ (BX),CX
接下來,將 0 放入 AX,用於迴圈變數。將 slice 的長度放入 DI,並且載入指向 xs 元素的指標到 CX。
JMP L2L1: INCL AXL2: CMPL AX,DI JGE L3
現在到達代碼的主體。首先跳轉到 L2 比較 AX 和 DI。如果相等,說明已經計算了 slice 中的所有元素,因此跳到 L3。(也就是 i == len(xs)
)。
MOVQ (CX),BPADDQ $8,CXADDQ BP,SIJMP L1
這裡進行了求和。首先從 CX 中擷取值儲存到 BP。然後將 CX 向前移動 8 位元組。最後將 BP 加到 SI 並跳轉到 L1。L1 增加 AX 並且再次開始迴圈。
L3: MOVQ SI,.noname+16(FP) RET
結束求和後,將結果儲存在傳遞到函數的所有的參數之後(由於一個 slice 是 16 位元組,所以這裡是 16 位元組)。這時就返回了。
重寫
這裡我重寫了代碼:
// func Sum(xs []int64) int64TEXT ·Sum2(SB),7,$0 MOVQ $0, SI // n MOVQ xs+0(FP), BX // BX = &xs[0] MOVL xs+8(FP), CX // len(xs) MOVLQSX CX, CX // len as int64 INCQ CX // CX++start: DECQ CX // CX-- JZ done // jump if CX = 0 ADDQ (BX), SI // n += *BX ADDQ $8, BX // BX += 8 JMP startdone: MOVQ SI, .noname+16(FP) // return n RET
希望這會更容易理解一些。
忠告
可以這麼做當然很酷,但是不要忽視了這些忠告:
- 彙編很難編寫,特別是很難寫好。通常編譯器會比你寫出更快的代碼(從前文來看,Go 編譯器會做得更好)。
- 彙編僅能運行在一個平台上。在這個例子中,代碼僅能運行在 amd64 上。這個問題有一個解決方案是給 Go 對於 x86 和 arm 不同版本的代碼(像這樣)。
- 彙編讓你和底層綁定在一起,而標準的 Go 不會。例如,slice 的長度當前是 32 位整數。但是也不是不可能為長整型。當發生這些變化時,這些代碼就被破壞了(也可能是編譯器無法檢測到的更噁心的途徑來破壞)
- 當前 Go 編譯器不能將彙編編譯為函數的內聯,但是對於小的 Go 函數是可以的。因此使用彙編可能意味著讓你的程式更慢。
對於下面的兩個原因,這還是很有用的:
有時需要彙編給你帶來一些力量(不論是效能方面的原因,還是一些相當特殊的關於 CPU 的操作)。對於什麼時候應該使用它,Go 源碼包括了若干相當好的例子(可以看看 crypto 和 math)。
由於它非常容易實踐,所以這絕對是個學習彙編的好途徑。