Go語言函數的底層實現

2020-07-16 10:05:08
基於堆疊式的程式執行模型決定了函數是語言的一個核心元素,分析Go語言函數的底層實現,對理解整個程式的執行過程有很大的幫助,研究底層實現有兩種辦法,一種是看語言編譯器原始碼,分析其對函數的各個特性的處理邏輯,另一種是反組合,將可執行程式反組合出來。

本節使用反組合這種短、平、快的方法,首先介紹Go語言的函數呼叫規約,接著介紹Go語言使用組合語言的基本概念,然後通過反組合技術來剖析Go語言函數某些特性的底層實現。

提示:閱讀本節需要有一定的組合基礎,想學習組合的同學,我們這裡準備了一套《組合語言入門教學》供大家學習。

函數呼叫規約

Go語言函數使用的是 caller-save 的模式,即由呼叫者負責儲存暫存器,所以在函數的頭尾不會出現push ebp; mov esp ebp這樣的程式碼,相反其是在主調函數呼叫被調函數的前後有一個儲存現場和恢復現場的動作。

主調函數儲存和恢復現場的通用邏輯如下:
//開闢棧空間,壓棧 BP 儲存現場
    SUBQ $x, SP    //為函數開闢裁空間
    MOVQ BP, y(SP) //儲存當前函數 BP 到 y(SP)位直, y 為相對 SP 的偏移量
    LEAQ y(SP), BP //重直 BP,使其指向剛剛儲存 BP 舊值的位置,這裡主要
                   //是方便後續 BP 的恢復

//彈出棧,恢復 BP
    MOVQ y(SP), BP //恢復 BP 的值為呼叫前的值
    ADDQ $x, SP    //恢復 SP 的值為函數開始時的位

組合基礎

Go 編譯器產生的組合程式碼是一種中間抽象態,它不是對機器碼的對映,而是和平台無關的一個中間態組合描述,所以組合程式碼中有些暫存器是真實的,有些是抽象的,幾個抽象的暫存器如下:
  • SB (Static base pointer):靜態基址暫存器,它和全域性符號一起表示全域性變數的地址。
  • FP (Frame pointer):棧幀暫存器,該暫存器指向當前函數呼叫棧幀的棧底位置。
  • PC (Program counter):程式計數器,存放下一條指令的執行地址,很少直接操作該暫存器,一般是 CALL、RET 等指令隱式的操作。
  • SP (Stack pointer):棧頂暫存器,一般在函數呼叫前由主調函數設定 SP 的值對棧空間進行分配或回收。

Go 組合簡介

1) Go 組合器採用 AT&T 風格的組合,早期的實現來自 plan9 組合器,源運算元在前,目的運算元在後。

2) Go 內嵌組合和反組合產生的程式碼並不是一一對應的,組合編譯器對內嵌組合程式自動做了調整,主要差別就是增加了保護現場,以及函數呼叫前的保持 PC 、SP 偏移地址重定位等邏輯,反組合程式碼更能反映程式的真實執行邏輯。

3) Go 的組合程式碼並不是和具體硬體體系結構的機器碼一一對應的,而是一種半抽象的描述,暫存器可能是抽象的,也可能是具體的。

下面程式碼的分析基於 AMD64 位架構下的 Linux 環境。

多值返回分析

多值返回函數 swap 的原始碼如下:
package main

func swap (a, b int) (x int, y int) {
    x = b
    y = a
    return
}

func main() {
    swap(10, 20)
}

編譯生成組合如下

//- S 產生組合的程式碼
//- N 禁用優化
//- 1 禁用內聯

GOOS=linux GOARCH=amd64 go tool compile -1 -N -S swap.go >swap.s 2>&1

組合程式碼分析

1) swap 函數和 main 函數組合程式碼分析。例如:
"".swap STEXT nosplit size=39 args=0x20 locals=0x0
    0x0000 00000 (swap.go:4) TEXT  "".swap(SB), NOSPLIT, $0 - 32
    0x0000 00000 (swap.go:4) FUNCDATA  $0, gclocals.ff19ed39bdde8a01a800918ac3ef0ec7(SB)
    0x0000 00000 (swap.go:4) FUNCDATA  $1, gclocals.33cdeccccebe80329flfdbee7f5874cb(SB)
    0x0000 00000 (swap.go:4)  MOVQ  $0, "".x+24(SP)
    0x0009 00009 (swap.go:4)  MOVQ  $0, "".y+32(SP)
    0x0012 00018 (swap.go:5)  MOVQ  "".b+16(SP), AX
    0x0017 00023 (swap.go:5)  MOVQ  AX, "".x+24(SP)
    0xOO1c 00028 (swap.go:6)  MOVQ  "".a+8(SP), AX
    0x0021 00033 (swap.go:6)  MOVQ  AX, "".y+32(SP)
    0x0026 00038 (swap.go:7)  RET



"".main STEXT size=68 args=0x0 locals=0x28
    0x0000 00000 (swap.go:10) TEXT "".main(SB), $40 - 0
    0x0000 00000 (swap.go:10) MOVQ (TLS), CX
    0x0009 00009 (swap.go:10) CMPQ SP, 16(CX)
    0x000d 00013 (swap.go:10) JLS 61
    0x000f 00015 (swap.go:10) SUBQ $40, SP
    0x0013 00019 (swap.go:10) MOVQ BP, 32 (SP)
    0x0018 00024 (swap.go:10) LEAQ 32(SP), BP
    0x001d 00029 (swap.go:10) FUNCDATA $0, gclocals ·33cdeccccebe80329flfdbee7f5874cb(SB)
    0x001d 00029 (swap.go:10) FUNCDATA $1, gclocals ·33cdeccccebe80329flfdbee7f5874cb(SB)
    0x001d 00029 (swap.go:11) MOVQ $10, (SP)
    0x0025 00037 (swap.go:11) MOVQ $20 , 8 (SP)
    0x002e 00046 (swap.go:11) PCDATA $0 , $0
    0x002e 00046 (swap.go:11) CALL "". swap(SB)
    0x0033 00051 (swap.go:12) MOVQ 32(SP), BP
    0x0038 00056 (swap.go:12) ADDQ $40, SP
    0x003c 00060 (swap.go:12) RET
    0x003d 00061 (swap.go:12) NOP
    0x003d 00061 (swap.go:10) PCDATA $0, $ - 1
  • 第 5 行初始化返回值 x 為 0。
  • 第 6 行初始化返回值 y 為 0。
  • 第 7~8 行取第 2 個引數賦值給返回值 x。
  • 第 9~10 行取第 1 個引數賦值給返回值 y。
  • 第 11 行函數返回,同時進行棧回收,FUNCDATA 和垃圾收集可以忽略。
  • 第 15~24 行 main 函數堆疊初始化:開闢棧空間,儲存 BP 暫存器。
  • 第 25 行初始化 add 函數的呼叫引數 1 的值為 10。
  • 第 26 行初始化 add 函數的呼叫引數 2 的值為 20。
  • 第 28 行呼叫 swap 函數,注意 call 隱含一個將 swap 下一條指令地址壓棧的動作,即 sp=sp+8。
  • 所以可以看到在 swap 裡面的所有變數的相對位置都發生了變化,都在原來的地址上 +8。
  • 第 29~30 行恢復措空間。

從組合的程式碼得知:
  • 函數的呼叫者負責環境準備,包括為引數和返回值開闢棧空間。
  • 暫存器的儲存和恢復也由呼叫方負責。
  • 函數呼叫後回收棧空間,恢復 BP 也由主調函數負責。

函數的多值返回實質上是在棧上開闢多個地址分別存放返回值,這個並沒有什麼特別的地方,如果返回值是存放到堆上的,則多了一個複製的動作。

main 呼叫 swap 函數棧的結構如下圖所示。

Go函數棧
圖:Go函數棧