“ 不要小瞧函數(shù)調(diào)用棧哦,它可是理解參數(shù)傳遞、命名匿名返回值、Function Value、defer等面試??偷年P(guān)鍵吶~”我們按照編程語言的語法定義的函數(shù),會被編譯器編譯為一堆堆機器指令,寫入可執(zhí)行文件。程序執(zhí)行時可執(zhí)行文件被加載到內(nèi)存,這些機器指令對應(yīng)到虛擬地址空間中,位于代碼段。如果在一個函數(shù)中調(diào)用另一個函數(shù),編譯器就會對應(yīng)生成一條call指令,程序執(zhí)行到這條指令時,就會跳轉(zhuǎn)到被調(diào)用函數(shù)入口處開始執(zhí)行,而每個函數(shù)的最后都有一條ret指令,負(fù)責(zé)在函數(shù)結(jié)束后跳回到調(diào)用處,繼續(xù)執(zhí)行。函數(shù)棧幀
函數(shù)執(zhí)行時需要有足夠的內(nèi)存空間,供它存放局部變量、參數(shù)等數(shù)據(jù),這段空間對應(yīng)到虛擬地址空間的棧。棧,只有一個 口可供進(jìn)出,先入棧的在底,后入棧的在頂,最后入棧的最早被取出。運行時棧,上面是高地址,向下增長,棧底通常被稱為“?;保瑮m敱环Q為“棧指針”。棧高地址向下增長棧基棧指針
分配給函數(shù)的??臻g被稱為“函數(shù)棧幀”,Go語言中函數(shù)棧幀布局是這樣的,先是調(diào)用者?;刂?,然后是函數(shù)的局部變量,最后是被調(diào)用函數(shù)的返回值和參數(shù)。函數(shù)棧幀BP of calleeSP of calleeBP of caller局部變量返回值參數(shù)
BP of callee和SP of callee標(biāo)識被調(diào)用函數(shù)執(zhí)行時,?;拇嫫骱蜅V羔樇拇嫫髦赶虻奈恢?,但是注意“BP of caller”不一定會存在,有些情況下可能會被優(yōu)化掉,也有可能是平臺不支持。我們只關(guān)注局部變量和參數(shù)、返回值的相對位置就好。舉個例子,函數(shù)A調(diào)用函數(shù)B,函數(shù)A有兩個局部變量,函數(shù)B有兩個參數(shù)和兩個返回值。func A() { var a1, a2, r1, r2 int64 a1, a2 = 1, 2 r1, r2 = B(a1, a2) r1 = C(a1) println(r1, r2)}func B(p1, p2 int64) (int64, int64) { return p2, p1}func C(p1 int64) int64 { return p1}函數(shù)A的棧幀布局如下圖所示,局部變量之后的空間用于存放被調(diào)用函數(shù)的返回值和參數(shù),接下來要調(diào)用函數(shù)B,所以先有兩個int64類型的變量空間用作B的返回值,再有兩個int64類型的變量空間用于存放傳遞給B的參數(shù)。......return1棧a1a2r1r2局部變量BP of ASP of A返回值BP of callerreturn2param1參數(shù)param2
注意觀察參數(shù)的順序,先入棧第二個參數(shù),再入棧第一個參數(shù),返回值也是一樣,上面是第二個返回值的空間,然后才是第一個返回值的空間。其實這也好解釋,因為這些是被調(diào)用函數(shù)的返回值和參數(shù),被調(diào)用函數(shù)是通過棧指針加上偏移值這樣相對尋址的方式來定位到自己的參數(shù)和返回值的,這樣由下至上正好先找到第一個參數(shù),再找到第二個參數(shù)。所以參數(shù)和返回值采用由右至左的入棧順序比較合適?!巴ǔ#覀冋J(rèn)為返回值是通過寄存器傳遞的,但是Go語言支持多返回值,所以在棧上分配返回值空間更合適~”......return1棧a1a2r1r2局部變量BP of ASP of A返回值BP of callerreturn2param1參數(shù)param2SP of B+偏移......SP of B
圖:被調(diào)用函數(shù)相對尋址參數(shù)我們知道對函數(shù)B的調(diào)用會被編譯器編譯為call指令。實際上call指令只做兩件事:第一:將下一條指令的地址入棧,被調(diào)用函數(shù)執(zhí)行結(jié)束后會跳回到這個地址繼續(xù)執(zhí)行,這就是函數(shù)調(diào)用的“返回地址”。第二:跳轉(zhuǎn)到被調(diào)用的函數(shù)B指令入口處執(zhí)行,所以在“返回地址”下面就是函數(shù)B的棧幀了。......return1棧a1a2r1r2局部變量BP of ASP of A返回值BP of callerreturn2param1參數(shù)param2SP of B 返回地址BP of A......
所有函數(shù)的棧幀布局都遵循統(tǒng)一的約定,函數(shù)B結(jié)束后它的棧幀被釋放,回到函數(shù)A中繼續(xù)執(zhí)行。......return1棧a1a2r1r2局部變量BP of ASP of A返回值BP of callerreturn2param1參數(shù)param2SP of B返回地址BP of A......
到了調(diào)用函數(shù)C的時候,它只有一個參數(shù)和一個返回值,它們會占用函數(shù)A棧幀中最下面的一部分空間,所以上面會空出來一塊,這是為了在被調(diào)用函數(shù)中可以用標(biāo)準(zhǔn)的相對地址定位到自己的參數(shù)和返回值,而無需顧慮其它。同樣的,call指令會壓入返回地址,并跳轉(zhuǎn)到函數(shù)C的指令入口處,所以下面就是函數(shù)C的棧幀了。......return1棧a1a2r1r2局部變量BP of ASP of A返回值BP of callerparam1參數(shù)SP of C 返回地址BP of A......
Go語言中,函數(shù)棧幀是一次性分配的,也就是在函數(shù)開始執(zhí)行的時候分配足夠大的棧幀空間。就像上例中函數(shù)A一樣,它要調(diào)用兩個函數(shù),除了調(diào)用者?;刂贰⒕植孔兞恳酝?,再有四個int64的空間用作被調(diào)用函數(shù)的參數(shù)與返回值就足夠了。一次性分配函數(shù)棧幀的主要原因是避免棧訪問越界,如下圖所示,三個goroutine初始分配的棧空間是一樣的,如果g2剩余的棧空間不夠執(zhí)行接下來的函數(shù),若函數(shù)棧幀是逐步擴(kuò)張的,那么執(zhí)行期間就可能發(fā)生棧訪問越界。......棧g1g2g3free越界了
其實,對于棧消耗較大的函數(shù),go語言的編譯器還會在函數(shù)頭部插入檢測代碼,如果發(fā)現(xiàn)需要進(jìn)行“棧增長”,就會另外分配一段足夠大的??臻g,并把原來棧上的數(shù)據(jù)拷過來,原來的這段棧空間就被釋放了。......棧g1g2g3free
了解了函數(shù)棧幀布局,接下來,我們看幾個關(guān)于參數(shù)和返回值常見的問題。傳參
下面有一個swap函數(shù),接收兩個整型參數(shù),main函數(shù)想要通過swap來交換兩個局部變量的值,但是失敗了......func swap(a,b int) { a,b = b,a} func main() { a,b := 1,2 swap(a,b) println(a,b) //1,2}我們通過函數(shù)調(diào)用棧,看看失敗的原因到底在哪兒?main函數(shù)棧幀中,先分配局部變量存儲空間,a=1,b=2。因為例子中調(diào)用的函數(shù)沒有返回值,所以局部變量后面就是給被調(diào)用函數(shù)傳入的參數(shù)。需要傳入兩個整型參數(shù),Go語言中傳參都是值拷貝,參數(shù)是整型,所以拷貝整型變量值。注意參數(shù)入棧順序:由右至左。先入棧第二個參數(shù),再入棧第一個參數(shù)。......棧a=1b=2b=2a=1局部變量SP of main參數(shù)BP of caller......BP of main返回地址SP of swap值拷貝
調(diào)用者棧幀后面是call指令存入的返回地址,swap開始執(zhí)行,再下面分配的就是swap函數(shù)棧幀了。swap函數(shù)要交換兩個參數(shù)的值,但是注意,swap的參數(shù)在哪里?main的局部變量a和b又在哪里?找到它們,交換失敗的原因就找到了,想要交換的局部變量a和b在局部變量空間,但實際上交換的是參數(shù)空間的a和b。......棧a=1b=2b=1a=2局部變量SP of main參數(shù)BP of caller...... BP of main 返回地址SP of swap交換
再來個例子,依然要交換兩個整型變量的值,但是參數(shù)類型改為整型指針。這次交換成功了,同樣通過函數(shù)調(diào)用棧,看看和上次有什么不同。func swap(a,b *int) { *a,*b = *b,*a} func main() { a,b := 1,2 swap(&a,&b) println(a,b) //2,1}main函數(shù)棧幀中,先分配局部變量,然后分配參數(shù)空間,參數(shù)是指針,傳參都是值拷貝,這里拷貝的是a和b的地址。依然由右至左,先入棧b的地址, 再入棧a的地址。再后面是返回地址,以及swap函數(shù)棧幀。......棧a=1b=2&b&a局部變量SP of main參數(shù)BP of caller...... BP of main 返回地址SP of swap值拷貝地址
swap要交換的是這兩個參數(shù)指針指向的數(shù)據(jù),也就是局部變量空間這里的a和b,所以這一次能夠交換成功!......棧a=2b=1&b&a局部變量SP of main參數(shù)BP of caller...... BP of main 返回地址SP of swap交換
返回值
直接看例子,這里main函數(shù)調(diào)用incr函數(shù),然后把返回值賦給局部變量b,下面來看看函數(shù)調(diào)用棧的情況。func incr(a int) int { var b int defer func(){ a++ b++ }() a++ b = a return b}func main(){ var a,b int b = incr(a) println(a,b) //0,1}main函數(shù)棧幀,先是局部變量,a=0,b=0,然后是incr的返回值,初始化為類型零值,再然后是參數(shù)空間。到incr函數(shù)棧幀這里,保存調(diào)用者main的?;刂泛螅跏蓟植孔兞縝。......棧a=0b=00a=0局部變量SP of main參數(shù)BP of callerb=0 BP of main 返回地址SP of incr返回值......
incr函數(shù)會把參數(shù)a自增一,然后賦值給局部變量b,要注意它們的位置。......棧a=0b=00a=1局部變量SP of main參數(shù)BP of caller...... BP of main 返回地址SP of incr返回值b=1
到incr函數(shù)的return這里,必須要明確一個關(guān)鍵問題。incr函數(shù)返回之前要給返回值賦值并執(zhí)行defer函數(shù),那誰先?誰后?答案是:“先賦值”所以incr函數(shù)返回前,會先把局部變量b的值拷貝到返回值空間,然后再執(zhí)行注冊的defer函數(shù)。......棧a=0b=01a=1局部變量SP of main參數(shù)BP of caller...... BP of main 返回地址SP of incr返回值b=1拷貝返回值
在defer函數(shù)里,a再次自增1,局部變量b也自增1。......棧a=0b=01a=2局部變量SP of main參數(shù)BP of caller...... BP of main 返回地址SP of incr返回值b=2
所以,incr結(jié)束后,返回值為1,賦給main函數(shù)局部變量b,最后會輸出0和1。......棧a=0b=11a=2局部變量SP of main參數(shù)BP of caller...... BP of main 返回地址SP of incr返回值b=2b=incr(a)
這是匿名返回值的情況,下面再來個例子,其它都不變,只把這里的局部變量b改成命名返回值,看看有什么不同。func incr(a int) (b int) { defer func(){ a++ b++ }() a++ return a}func main(){ var a,b int b = incr(a) println(a,b) //0,2}main函數(shù)棧幀,與上個例子完全相同,到incr函數(shù)棧幀這里,沒有局部變量,執(zhí)行到a++時,參數(shù)a自增1。返回前,先把參數(shù)a賦給返回值b,要注意返回值的位置。......棧a=0b=0b=1a=1局部變量SP of main參數(shù)BP of caller...... BP of main 返回地址返回值拷貝返回值SP of incr
然后執(zhí)行defer函數(shù),參數(shù)a再次自增1,返回值b也自增1,然后incr結(jié)束,返回值最終為2。......棧a=0b=0b=2a=2局部變量SP of main參數(shù)BP of caller......BP of main返回地址返回值SP of incr
所以, main的局部變量b賦值為2,最后會輸出0和2。......棧a=0b=2b=2a=2局部變量SP of main參數(shù)BP of caller...... BP of main 返回地址SP of incr返回值b=incr(a)
命名返回值和匿名返回值相關(guān)的問題,最關(guān)鍵的還是函數(shù)棧幀布局,以及返回值被賦值的時機。最后,留給感興趣的同學(xué)看看,在匯編指令層面怎么實現(xiàn)函數(shù)跳轉(zhuǎn)與返回。函數(shù)跳轉(zhuǎn)與返回
程序執(zhí)行時 CPU用特定寄存器來存儲運行時?;c棧指針,同時也有指令指針寄存器用于存儲下一條要執(zhí)行的指令地址。......棧寄存器BPSPIP棧基棧指針指令指針指令push 3push 4
如果接下來要執(zhí)行"push 3"這條指令,CPU讀取后,會將指令指針移向下一條指令,然后棧指針向下移動,數(shù)字3入棧。......棧寄存器BPSPIP?;鶙V羔樦噶钪羔樦噶頿ush 3push 43
繼續(xù)執(zhí)行下一條指令,再次移動棧指針入棧數(shù)字4。......棧寄存器BPSPIP?;鶙V羔樦噶钪羔樦噶頿ush 3push 4......34
前面我們提過Go語言中函數(shù)棧幀不是這樣逐步擴(kuò)張的,而是一次性分配,也就是在分配棧幀時,直接將棧指針移動到所需最大??臻g的位置。......棧寄存器BPSPIP?;鶙V羔樦噶钪羔樦噶畎?移動到SP+16處把4移動到SP+8處
然后通過棧指針加上偏移值這種相對尋址方式使用函數(shù)棧幀。例如sp加16字節(jié)處存儲3,加8字節(jié)處存儲4,諸如此類。......棧寄存器BPSPIP?;鶙V羔樦噶钪羔樦噶畎?移動到SP+16處把4移動到SP+8處......34
接下來我們看看call指令和ret指令,是怎樣實現(xiàn)函數(shù)跳轉(zhuǎn)與返回的。func A(){ a,b := 1,2 B(a,b) return}func B(c,d int){ println(c,d) return}調(diào)用函數(shù)B之前函數(shù)A棧幀如下圖所示,注意函數(shù)A和函數(shù)B的指令分布在代碼段,而且函數(shù)A調(diào)用函數(shù)B的call指令在地址a1處,函數(shù)B入口地址在b1處。......棧a=1b=2......代碼段............a1call b1~~~~~~~~~~~~~~~~b1~~~~~~~~......~~~~~~~~~~~~~~~~RETAB寄存器BPSPa1IPs1s2d=2c=1s3s4s5s6
然后到call指令這里,它的作用有兩點:第一,把返回地址a2入棧保存起來;第二,跳轉(zhuǎn)到指令地址b1處。......棧a=1b=2......代碼段............a1call b1~~~~~~~~~~~~~~~~b1~~~~~~~~......~~~~~~~~~~~~~~~~RETAB寄存器BPSPb1IPa2a2d=2c=1s1s2s3s4s5s6
call指令結(jié)束。函數(shù)B開始執(zhí)行,我們先看它最開始的三條指令:第一條指令,把SP向下移動24字節(jié)(從s6挪到s9),為自己分配足夠大的棧幀;第二條指令,要把調(diào)用者?;鵶1存到SP+16字節(jié)的地方(s7那里);第三條指令,把s7(SP+16)存入BP寄存器。棧a=1b=2............代碼段............a1call b1~~~~~~~~~~~~~~~~b1~~~~~~~~......~~~~~~~~~~~~~~~~RETAB寄存器BPSPb4IPa2a2s1b4d=2c=1s1s2s3s4s5s6s7s8s9
圖:執(zhí)行函數(shù)B入口處插入的三條指令接下來就是執(zhí)行函數(shù)B剩下的指令了,沒有局部變量,只有被調(diào)用者的參數(shù)空間。在最后的ret指令之前,編譯器還會插入兩條指令:第1條指令:恢復(fù)調(diào)用者A的棧基地址,它之前被存儲在SP+16字節(jié)(s7)這里,所以BP恢復(fù)到s1;第2條指令:釋放自己的棧幀空間,分配時向下移動多少(從s6到s9)釋放時就向上移動多少(從s9到s6)。棧a=1b=2......代碼段............a1call b1~~~~~~~~~~~~~~~~b1~~~~~~~~......~~~~~~~~~~~~~~~~RETAB寄存器BPSPbnIPa2a2s1bnc=1d=2c=1d=2......s1s2s3s4s5s6s7s8s9
然后就到ret指令了,它的作用也有兩點:第一,彈出call指令壓棧的返回地址a2;第二,跳轉(zhuǎn)到call指令壓棧的返回地址a2處。......代碼段............a1call b1~~~~~~~~~~~~~~~~b1~~~~~~~~......~~~~~~~~~~~~~~~~RETAB寄存器BPSPa2IPa2棧a=1b=2a2s1c=1d=2c=1d=2......s1s2s3s4s5s6s7s8s9
現(xiàn)在可以從a2這里繼續(xù)執(zhí)行了。簡單來說,函數(shù)通過call指令實現(xiàn)跳轉(zhuǎn),而每個函數(shù)開始時會分配棧幀,結(jié)束前又釋放自己的棧幀,ret指令又會把?;謴?fù)到call之前的樣子,通過這些指令的配合最終實現(xiàn)了函數(shù)跳轉(zhuǎn)與返回。