深入剖析 defer 原理篇 —— 函數(shù)調(diào)用的原理?

大綱

地址空間
函數(shù)棧幀
棧幀的劃定
函數(shù)調(diào)用
函數(shù)返回
舉個例子
總結(jié)
本篇文章是深入剖析 golang 的 defer 的基礎(chǔ)知識準備,如果要完全理解 defer ,避免踩坑,這個章節(jié)的基礎(chǔ)知識必不可少。我們先復習一個最基礎(chǔ)的知識 —— 函數(shù)調(diào)用。這個對理解 defer 在函數(shù)里的行為必不可少。那么,當你看到一個函數(shù)調(diào)用的語句你能回憶起多少知識點呢?

地址空間

下圖是一個典型的操作系統(tǒng)的地址空間示意圖:

最重要的幾點:
內(nèi)核棧在高地址,用戶棧在低地址。如果是 32 位操作系統(tǒng),那么最經(jīng)典的就是,用戶棧區(qū)域為 [0, 3G],內(nèi)核棧區(qū)域為?[3G, 4G]; 棧空間分配是從高地址往下分配的(所以我們經(jīng)常看到棧分配空間,是通過減 rsp 的值來實現(xiàn)就是這個道理); 堆空間分配是從低地址往上分配的;

函數(shù)棧幀

函數(shù)調(diào)用執(zhí)行的時候,需要分配空間存儲數(shù)據(jù),比如函數(shù)的參數(shù),函數(shù)內(nèi)局部變量,寄存器的值(用于上下文切換)。這些數(shù)據(jù)都需要保存在一個地方,這個地方就是棧空間上。因為這些數(shù)據(jù)的聲明周期是和函數(shù)一體的,函數(shù)執(zhí)行的時候存在,函數(shù)執(zhí)行完立馬就可以銷毀。和堆空間不同,堆上用來分配聲明周期由程序員控制的對象。棧的使用規(guī)劃負責人是編譯器,堆空間的使用規(guī)劃負責人是程序員(在有垃圾回收的語言里,堆空間的使用由語言層面支持)。
當函數(shù)調(diào)用的時候,對應產(chǎn)生一個棧幀(stack frame),函數(shù)結(jié)束的時候,釋放棧幀。棧幀主要用來保存:
函數(shù)參數(shù) 局部變量 返回值 寄存器的值(上下文切換)
函數(shù)在執(zhí)行過程中使用一塊棧內(nèi)存來保存上述這些值。當發(fā)生函數(shù)調(diào)用時,因為 caller 還沒執(zhí)行完,caller 的棧幀中保存的數(shù)據(jù)還有用,所以 callee 函數(shù)執(zhí)行的時候不能覆蓋 caller 的棧幀,這種情況需要分配一個 callee 的棧幀。
棧空間的使用方式由編譯器管理,在編譯期間就確定。棧的大小就會隨函數(shù)調(diào)用層級的增加而向低地址增加,隨函數(shù)的返回而縮小,調(diào)用層級越深,消耗的棧空間就越大。所以,在遞歸函數(shù)的場景,經(jīng)常見到有些遞歸太深的函數(shù)會報錯,被操作系統(tǒng)直接拒絕,就是因為考慮到這個棧空間使用的合理性,我們對棧的深度有限制。

棧幀的劃定

有兩個寄存器的值來劃定一個函數(shù)棧幀:
rsp :棧寄存器,指向當前棧頂位置; rbp :棧幀寄存器,指向函數(shù)棧幀的起始位置;
所以,我們可以認為在一個函數(shù)執(zhí)行的時候,rsp, rbp 這兩個寄存器指向的區(qū)域就是當前函數(shù)的一個棧幀。在 golang 的一個函數(shù)的代碼里,開頭會先保存 rbp 寄存器的值,保存到棧上,函數(shù)執(zhí)行完之后,需要返回 caller 函數(shù)之前,需要恢復 rbp 寄存器。
舉個例子:
func?C(c?int)?(r?int)?{
?c1?:=?c?+?3
?return?c1
}
匯編出來的指令如下,用 dlv 調(diào)試看下:
????15:?func?C(c?int)?(r?int)?{
????16:?????c1?:=?c?+?3
=>??17:?????return?c1
????18:?}
????
(dlv)?disassemble
TEXT?main.C(SB)
????//?分配棧空間
????test_call.go:15?????0x1056fe0???4883ec10????????sub?rsp,?0x10
????//?保存上一個函數(shù)的棧基地址
????test_call.go:15?????0x1056fe4???48896c2408??????mov?qword?ptr?[rsp+0x8],?rbp
????//?rbp?指向當前的棧基
????test_call.go:15?????0x1056fe9???488d6c2408??????lea?rbp,?ptr?[rsp+0x8]
????test_call.go:15?????0x1056fee???48c744242000000000??mov?qword?ptr?[rsp+0x20],?0x0
????//?執(zhí)行?a?+?3
????test_call.go:16?????0x1056ff7???488b442418??????mov?rax,?qword?ptr?[rsp+0x18]
????test_call.go:16?????0x1056ffc???4883c003????????add?rax,?0x3
????//?保存到?c1?變量
????test_call.go:16?????0x1057000???48890424????????mov?qword?ptr?[rsp],?rax
????//?保存到返回值到棧變量
=>??test_call.go:17?????0x1057004???4889442420??????mov?qword?ptr?[rsp+0x20],?rax
????//?恢復?rbp?值(指向上一個函數(shù)的棧基)
????test_call.go:17?????0x1057009???488b6c2408??????mov?rbp,?qword?ptr?[rsp+0x8]
????//?回收棧空間
????:1???0x105700e???4883c410????????add?rsp,?0x10
????//?返回調(diào)用函數(shù)
????:1???0x1057012???c3??????????ret

dlv 調(diào)試到這個 C 函數(shù)的時候,rsp 和 rbp 寄存器的值分別是 0x000000c00002e6f8,0x000000c00002e700,相隔 8 個字節(jié),所以可以說這個函數(shù)的棧幀就只有 8 個字節(jié),不過有上面有 16 個字節(jié)要注意,就是 caller 函數(shù) rbp 的保存值和 caller 下一行要執(zhí)行的指令地址。另外要提一點的是,rbp 這個寄存器其實就函數(shù)執(zhí)行的功能上來說,并不需要,rbp 基本上就是給用來調(diào)試的,標明一個個棧幀,這樣 gdb 或者 dlv 執(zhí)行 bt 命令的時候,就能看到堆棧了。

函數(shù)調(diào)用

函數(shù)調(diào)用在 golang 里面非常簡單,比如 b1 := C(b) 就是一個函數(shù)調(diào)用,執(zhí)行函數(shù) C ,傳入的實參是變量 b ,返回值存入局部變量 b1,對應的匯編指令是 call 。這個語句經(jīng)過編譯器的翻譯,如下:
//?傳入?yún)?shù)
test_call.go:10??0x1056faf?4889442428??mov?qword?ptr?[rsp+0x28],?rax
test_call.go:11??0x1056fb4?48890424??mov?qword?ptr?[rsp],?rax
//?跳轉(zhuǎn)到函數(shù)?C?執(zhí)行指令
test_call.go:11??0x1056fb8?e823000000??call?$main.C
這里我們注意到,一個簡單的 b1 := C(b) 會翻譯成多條匯編語句,通過匯編語句我們看到一行函數(shù)調(diào)用主要做兩件事情:
設置函數(shù)參數(shù); 執(zhí)行 call指令;
函數(shù)調(diào)用最重要的就是 call 指令。call 指令是一條基礎(chǔ)的匯編指令,做兩件事情:
把當前所在函數(shù)(caller)的下一行指令壓棧; 會導致棧頂往下增長 8 字節(jié) 跳轉(zhuǎn)到 C 函數(shù)指令執(zhí)行(pc 的值切換成 C 的入口指令)
什么意思?舉個例子,假如 b1 := C(b) 下一行的命令是 a :=1 ,如下:
b1?:=?C(b)
a?:=?1
調(diào)用 call $main.C 的時候,就先把 a := 1 這行語句對應的代碼地址保存到棧上,然后 pc 寄存器加載函數(shù) C 的入口指令。

進入函數(shù)里面,第一件做的事情就是保存 rbp 的值,后面從函數(shù)中退出的時候,用于恢復上下文。

函數(shù)返回

golang 語言層面函數(shù)返回對應了 return 關(guān)鍵字,這個有必要深入理解下。函數(shù) C 的語句如下:
func?C(c?int)?(r?int)?{
?c1?:=?c?+?3
?return?c1
}
和函數(shù)調(diào)用一樣,函數(shù)返回(return)的調(diào)用也是多個步驟的。看起來就調(diào)用了一行 return c1,但其實這一行語句包含了多行指令:
設置返回值(函數(shù)調(diào)用是 b1 := C(b),這里說的設置返回值也就是設置 b1);所以,設置返回值是在 callee 函數(shù)里; 執(zhí)行 ret指令
函數(shù)返回最重要的就是 ret 指令了,這個指令和 call 是配套的,動作是相反的,匯編指令 ret 主要做兩件事情:
從當前棧頂處取出 [$rsp] 的值,恢復到 pc 寄存器,跳轉(zhuǎn)到這個地址準備執(zhí)行命令; 彈棧,棧頂往上縮減 8 字節(jié)
回想上面說的函數(shù)調(diào)用時候 call 時候的壓棧,ret 取出來的地址就是 a :=1 指令,這樣就剛好對上了,函數(shù) C 調(diào)用完回到原函數(shù)繼續(xù)執(zhí)行下一行命令。

舉個例子

了解完基礎(chǔ)知識,我們以下面的例子,分析一下這個函數(shù)棧,復習一下:
package?main
func?A(a?int)?int?{
?a?=?a?+?1
?a1?:=?B(a)
?return?a1
}
func?B(b?int)?int?{
?b?=?b?+?2
?b1?:=?C(b)
?return?b1
}
func?C(c?int)?(r?int)?{
?c1?:=?c?+?3
?return?c1
}
func?main()?{
?a?:=?A(7)
?_?=?a
}
函數(shù)棧幀如下:

這個地方的棧幀區(qū)域標注都是以 rsp,rpb 寄存器界定的,所以每個棧幀中間有 16 個字節(jié)的間隔,分別是函數(shù)壓棧的地址,還有 rbp 的保存值。

總結(jié)

go 的一行函數(shù)調(diào)用語句其實非原子操作,對應多行匯編指令,包括 1)參數(shù)設置,2) call指令執(zhí)行;其中 call匯編指令的內(nèi)容也有兩個:返回地址壓棧(會導致 rsp 值往下增長,rsp-0x8),callee 函數(shù)地址加載到 pc 寄存器;go 的一行函數(shù)返回 return語句其實也非原子操作,對應多行匯編指令,包括 1)返回值設置 和 2) ret指令執(zhí)行;其中 ret 匯編指令的內(nèi)容是兩個,指令pc 寄存器恢復為 rsp 棧頂保存的地址,rsp 往上縮減,rsp+0x8; 參數(shù)設置在 caller 函數(shù)里,返回值設置在 callee 函數(shù)里; rsp, rbp 兩個寄存器是棧幀的最重要的兩個寄存器,這兩個值劃定了棧幀; rbp 寄存器的常見的作用棧基寄存器,但其實再深入了解下你會知道 rbp 在當今體系里其實可以作為通用寄存器了。而最常見的用來用棧基寄存器還是為了調(diào)試,比較方便的劃定棧幀;

思考

為什么深入理解 defer 需要先深入理解函數(shù)調(diào)用呢?
因為,這個關(guān)系到 defer 最本質(zhì)的語義:defer 是在函數(shù)調(diào)用返回的時候執(zhí)行的。那么這個執(zhí)行時機到底是什么樣子的?是先設置返回值,還是先執(zhí)行 defer 函數(shù)呢?
比如下面的例子:
func?f1?()?(r?int)?{
?t?:=?1
?defer?func()?{
??t?=?t?+5
?}()
?return?t
}
func?f2()?(r?int)?{
?defer?func(r?int)?{
??r?=?r?+?5
?}(r)
?return?1
}
func?f3()?(r?int)?{
?defer?func?()?{
??r?=?r?+?5
?}?()
?return?1
}
這三個函數(shù)的返回值分別是多少?可以思考下。
答案:f1() -> 1,f2() -> 1,f3() -> 6 。
你全對了嗎?如果心有疑問,我們在下一次的 defer 原理分享里展開進一步的剖析。
推薦閱讀
