前言
不管是C語言還是golang語言,都有自己的函數(shù)調(diào)用流程,主要是在函數(shù)調(diào)用過程中,各種寄存器和內(nèi)存堆棧的變化. 理解清楚整個函數(shù)調(diào)用流程,可以加深對golang語言的了解.
編譯源代碼
對下面的簡單函數(shù),通過反匯編和調(diào)試器來看下golang的函數(shù)調(diào)用流程,主要是函數(shù)調(diào)用過程中的參數(shù)傳遞和關(guān)鍵寄存器的變化。
為了避免編譯器的優(yōu)化,加上-gcflags '-l -N'選項,-gcflags是給編譯器的選項,通過go tool compile可以看到選項列表,-l表示禁止內(nèi)聯(lián),-N表示禁止優(yōu)化。一般我們要看一些細(xì)節(jié)的時候,都需要把這兩個選項帶上。
#go build -gcflags '-l -N'
通過如下命令得到反匯編信息
#go tool objdump --gnu -S 0913 > tmp.s
打開tmp.s文件,找到main.test2,能看到main.main和main.test2的反匯編信息,這兒把plan9會把和gnu匯編都顯示。
整個調(diào)用流程如下
下面開始具體分析.
前導(dǎo)和結(jié)尾
分析main.main,發(fā)現(xiàn)golang編譯器給函數(shù)固定插入的前導(dǎo)和結(jié)尾有兩部分.
第一部分如下.其作用是保證當(dāng)前goroutine的棧空間足夠,其方法是通過得到當(dāng)前棧空間接近底部的一個地址0x10(CX)(g.stack.stackguard0)并和當(dāng)前SP比較,如果SP的值小于等于0X10(CX)的值,那么棧的空間已經(jīng)馬上不夠用了,必須進(jìn)行擴容,然后就會jmp到runtime.morestack_noctxt進(jìn)行擴容,完成之后再JMP到main.main,如果還是不夠,就再擴容,直到檢查到夠了。 因為golang中的goroutine使用的棧都是新建的,初始值默認(rèn)為2K,隨著函數(shù)調(diào)用層數(shù)增加,或者有些函數(shù)的局部變量占用空間過大,會導(dǎo)致不夠用,這個時候就需要擴容了. 由于這個處理擴容的代碼是golang編譯器加入的,我們就不用關(guān)心了.
0x45dac0 64488b0c25f8ffffff MOVQ FS:0xfffffff8, CX // mov %fs:0xfffffff8,%rcx
0x45dac9 483b6110 CMPQ 0x10(CX), SP // cmp 0x10(%rcx),%rsp
0x45dacd 0f8687000000 JBE 0x45db5a // jbe 0x45db5a
。。。
0x45db5a e821aeffff CALL runtime.morestack_noctxt(SB) // callq 0x458980
0x45db5f 90 NOPL // nop
0x45db60 e95bffffff JMP main.main(SB) // jmpq 0x45dac0
第二部分如下.如果對C編譯好的代碼進(jìn)行反匯編也能看到基本完全相同的匯編代碼.這部分代碼是對callee進(jìn)行棧空間的分配和回收的.進(jìn)入一個callee的時候,(0)SP是返回地址,也就是callee執(zhí)行完成之后,caller要執(zhí)行的指令地址。
0x45dad3 4883ec48 SUBQ $0x48, SP // sub $0x48,%rsp
0x45dad7 48896c2440 MOVQ BP, 0x40(SP) // mov %rbp,0x40(%rsp)
0x45dadc 488d6c2440 LEAQ 0x40(SP), BP // lea 0x40(%rsp),%rbp
。。。
0x45db50 488b6c2440 MOVQ 0x40(SP), BP // mov 0x40(%rsp),%rbp
0x45db55 4883c448 ADDQ $0x48, SP // add $0x48,%rsp
0x45db59 c3 RET // retq
先將SP的地址下移0x48,這個就是main.main的棧幀大小(不同的函數(shù)需要的棧幀大小不一樣,所以這兒的值不同,但是在編譯的時候是可以計算出每個函數(shù)的局部變量需要的大小,以及調(diào)用其他函數(shù)需要傳參使用的大小的),main.main的局部變量就放在這里面的。注意這兒把main.main棧幀的頂部,也就是(0X40)SP放了老的BP的值,然后把這個地址放到了BP里面。
這樣就BP的值是一個地址,這個地址里面存放著上一個棧幀的BP的值,其也是一個地址,這樣BP的值就弄成了一個鏈表,可以不斷向上串聯(lián)起來。當(dāng)然,如果我們不關(guān)心這個事情,那么BP是不需要的,實際上現(xiàn)在gcc有個選項就可以關(guān)閉對BP寄存器的使用,golang編譯器在優(yōu)化的情況下也會不使用BP寄存器。棧幀里面所有的變量通過SP寄存器進(jìn)行偏移就可以訪問到了。我們看上面的匯編代碼,確實都沒有用到BP寄存器來定位變量。
在函數(shù)調(diào)用完成的時候,通過上面相反的調(diào)用順序?qū)?臻g進(jìn)行回收.
詳細(xì)調(diào)用過程分析
上面已經(jīng)介紹了基本過程.下面再通過分析main.main調(diào)用main.test2的整個過程來加深理解,推薦通過dlv工具來自己一步一步的走,可以加深理解.
進(jìn)入代碼目錄使用dlv debug命令開始調(diào)試,然后使用b main.main設(shè)置斷點,c開始運行,使用disassembly看反匯編代碼,使用si命令來單指令執(zhí)行.
1.main.main CALL main.test2之前的棧幀
也就是上面的0x45daf2執(zhí)行之前的寄存器和棧的情況.現(xiàn)在RBP和RSP是main.main的棧幀,然后main.test2需要的兩個入?yún)⒁呀?jīng)準(zhǔn)備好了.
2.main.main CALL main.test2之后的棧幀
也就是0x45daf2執(zhí)行之后的寄存器和棧的情況. 這個時候SP的值-=8,里面存放main.test2執(zhí)行完成之后返回main.main的執(zhí)行指令的地址,也就是CALL下面那條指令的地址.由于RIP的值被CALL指令修改了,CPU執(zhí)行的下一條指令就是main.test2的第一條指令了.
main.test2(以及其他函數(shù))本身的執(zhí)行流程如下.
首先是棧幀的準(zhǔn)備,然后是返回值的初始值設(shè)置.然后是核心的計算邏輯代碼,然后是根據(jù)計算的結(jié)果設(shè)置返回值.最后銷毀棧幀并返回.
3.main.test2準(zhǔn)備棧幀
通過將RSP的值調(diào)整小(棧幀向下生長),擴展好main.test2函數(shù)的棧幀,然后設(shè)置和RBP的值來標(biāo)記棧幀的開始,同時讓各個棧幀的RBP本身可以形成一個鏈表,方便調(diào)試器查找.
4.初始化返回值,完成計算邏輯,保存返回值.
golang的一個函數(shù)返回值默認(rèn)值需要為0,因為我們可以直接使用不帶參數(shù)的return語句.這兒將返回值設(shè)置為0值.
在通過相應(yīng)的指令完成計算邏輯之后,把返回值保持到main.main的棧幀里面,注意這整個過程中RSP和RBP都不會變化的,局部變量,入?yún)?出參都是通過對RSP進(jìn)行偏移得到的(入?yún)?出參會偏移到main.main的棧幀里面,局部變量偏移到自己的棧幀里面),具體的偏移值編譯器在編譯過程中是可以計算出來的. 注意全f的表示值-1.
5.銷毀棧幀,返回main.main
在恢復(fù)了BP和SP之后,SP處值為main.main中CALL語句壓入的下一條語句的地址.
main.test2的最后一條RET語句執(zhí)行完成之后,RIP的值值變成SP處的值,SP+=8.棧幀完全恢復(fù)到CALL語句之前,唯一變的是main.main棧幀中返回值的兩個地址里面變成了經(jīng)過main.test2執(zhí)行的值.然后SP-0X28這部分的內(nèi)存就無效了,雖然里面的內(nèi)容并沒有被清空為全0.