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

大綱

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

地址空間

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

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

函數(shù)棧幀

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

棧幀的劃定

有兩個(gè)寄存器的值來劃定一個(gè)函數(shù)棧幀:
rsp :棧寄存器,指向當(dāng)前棧頂位置; rbp :棧幀寄存器,指向函數(shù)棧幀的起始位置;
所以,我們可以認(rèn)為在一個(gè)函數(shù)執(zhí)行的時(shí)候,rsp, rbp 這兩個(gè)寄存器指向的區(qū)域就是當(dāng)前函數(shù)的一個(gè)棧幀。在 golang 的一個(gè)函數(shù)的代碼里,開頭會(huì)先保存 rbp 寄存器的值,保存到棧上,函數(shù)執(zhí)行完之后,需要返回 caller 函數(shù)之前,需要恢復(fù) rbp 寄存器。
舉個(gè)例子:
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)
????//?分配??臻g
????test_call.go:15?????0x1056fe0???4883ec10????????sub?rsp,?0x10
????//?保存上一個(gè)函數(shù)的?;刂?br>????test_call.go:15?????0x1056fe4???48896c2408??????mov?qword?ptr?[rsp+0x8],?rbp
????//?rbp?指向當(dāng)前的?;?br>????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
????//?恢復(fù)?rbp?值(指向上一個(gè)函數(shù)的棧基)
????test_call.go:17?????0x1057009???488b6c2408??????mov?rbp,?qword?ptr?[rsp+0x8]
????//?回收??臻g
????:1???0x105700e???4883c410????????add?rsp,?0x10
????//?返回調(diào)用函數(shù)
????:1???0x1057012???c3??????????ret

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

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

函數(shù)調(diào)用在 golang 里面非常簡單,比如 b1 := C(b) 就是一個(gè)函數(shù)調(diào)用,執(zhí)行函數(shù) C ,傳入的實(shí)參是變量 b ,返回值存入局部變量 b1,對(duì)應(yīng)的匯編指令是 call 。這個(gè)語句經(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
這里我們注意到,一個(gè)簡單的 b1 := C(b) 會(huì)翻譯成多條匯編語句,通過匯編語句我們看到一行函數(shù)調(diào)用主要做兩件事情:
設(shè)置函數(shù)參數(shù); 執(zhí)行 call指令;
函數(shù)調(diào)用最重要的就是 call 指令。call 指令是一條基礎(chǔ)的匯編指令,做兩件事情:
把當(dāng)前所在函數(shù)(caller)的下一行指令壓棧; 會(huì)導(dǎo)致棧頂往下增長 8 字節(jié) 跳轉(zhuǎn)到 C 函數(shù)指令執(zhí)行(pc 的值切換成 C 的入口指令)
什么意思?舉個(gè)例子,假如 b1 := C(b) 下一行的命令是 a :=1 ,如下:
b1?:=?C(b)
a?:=?1
調(diào)用 call $main.C 的時(shí)候,就先把 a := 1 這行語句對(duì)應(yīng)的代碼地址保存到棧上,然后 pc 寄存器加載函數(shù) C 的入口指令。

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

函數(shù)返回

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

舉個(gè)例子

了解完基礎(chǔ)知識(shí),我們以下面的例子,分析一下這個(gè)函數(shù)棧,復(fù)習(xí)一下:
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ù)棧幀如下:

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

總結(jié)

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

思考

為什么深入理解 defer 需要先深入理解函數(shù)調(diào)用呢?
因?yàn)?,這個(gè)關(guān)系到 defer 最本質(zhì)的語義:defer 是在函數(shù)調(diào)用返回的時(shí)候執(zhí)行的。那么這個(gè)執(zhí)行時(shí)機(jī)到底是什么樣子的?是先設(shè)置返回值,還是先執(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
}
這三個(gè)函數(shù)的返回值分別是多少?可以思考下。
答案:f1() -> 1,f2() -> 1,f3() -> 6 。
你全對(duì)了嗎?如果心有疑問,我們?cè)谙乱淮蔚?defer 原理分享里展開進(jìn)一步的剖析。
推薦閱讀
