Linux后端程序員必備技能之函數(shù)棧
大家都知道函數(shù)調(diào)用是通過棧來實(shí)現(xiàn)的,而且知道在棧中存放著該函數(shù)的局部變量。但是對于棧的實(shí)現(xiàn)細(xì)節(jié)可能不一定清楚。本文將介紹一下在Linux平臺下函數(shù)棧是如何實(shí)現(xiàn)的。有些同學(xué)可能覺得沒必要了解這么深入,其實(shí)非也。根據(jù)本號多年的經(jīng)驗(yàn),了解系統(tǒng)深層次的原理對分析疑難問題有很好的幫助。
圖0 函數(shù)棧
就像熟悉抓包是解決網(wǎng)絡(luò)通信問題的高級武器一樣,熟悉函數(shù)調(diào)用棧則是分析程序內(nèi)存問題的高級武器。本文以Linux 64位操作系統(tǒng)下C語言開發(fā)為例,介紹應(yīng)用程序調(diào)用棧的實(shí)現(xiàn)原理,并通過一個(gè)實(shí)例和GDB工具具體分析一下某個(gè)程序的調(diào)用棧內(nèi)容。在介紹具體的調(diào)用棧之前,我們先介紹一些基礎(chǔ)知識,這些知識是理解后續(xù)函數(shù)調(diào)用棧的基礎(chǔ)。
X86 CPU的寄存器
CPU的寄存器是需要了解的基礎(chǔ)知識,這是因?yàn)樵赬64體系中函數(shù)的參數(shù)是通過寄存器傳遞的。如圖1是X86 CPU寄存器的列表及功能簡要說明。
圖1 Intel X86 CPU寄存器用途
我們知道Intel的CPU在設(shè)計(jì)的時(shí)候都是向前兼容的,也就是在新一代的CPU上可以運(yùn)行老一代CPU上的編譯的程序。為了保證兼容性,新一代CPU保留了老一代寄存器的別名。以16位寄存器AX為例,AL表示低8位,AH表示高8位。而32位CPU問世之后,通過名為EAX的寄存器表示32位寄存器,AX仍然保留。以此類推,RAX表示一個(gè)64位寄存器。
圖2 不同的寄存器名稱
應(yīng)用程序的地址空間
操作系統(tǒng)通過虛擬內(nèi)存的方式為所有應(yīng)用程序提供了統(tǒng)一的內(nèi)存映射地址。如圖3所示,從上到下分別是用戶棧、共享庫內(nèi)存、運(yùn)行時(shí)堆和代碼段。當(dāng)然這個(gè)是一個(gè)大概的分段,實(shí)際分段比這個(gè)可能稍微復(fù)雜一些,但整個(gè)格局沒有大變化。
圖3 應(yīng)用程序的地址空間
從圖中可以看出用戶棧是從上往下生長的。也就是用戶棧會先占用高地址的空間,然后占用低地址空間。目前我們可以大體上有個(gè)了解即可,后面我們在詳細(xì)分析用戶棧的細(xì)節(jié)。
函數(shù)調(diào)用及匯編指令
為了理解函數(shù)調(diào)用棧的細(xì)節(jié),有必要了解一下匯編程序中函數(shù)調(diào)用的實(shí)現(xiàn)。函數(shù)的調(diào)用主要分為2部分,一個(gè)是調(diào)用,另外一個(gè)是返回。在匯編語言中函數(shù)調(diào)用是通過call指令完成的,返回則是通過ret指令。
匯編語言的call指令相當(dāng)于執(zhí)行了2步操作,分別是,1)將當(dāng)前的IP或CS和IP壓入棧中; 2)跳轉(zhuǎn),類似與jmp指令。同樣,ret指令也分2步,分別是,1)將棧中的地址彈出到IP寄存器;2)跳轉(zhuǎn)執(zhí)行后續(xù)指令。這個(gè)基本上就是函數(shù)調(diào)用的原理。
除了在代碼間的跳動外,函數(shù)的調(diào)用往往還需要傳遞一個(gè)參數(shù),而處理完成后還可能有返回值。這些數(shù)據(jù)的傳遞都是通過寄存器進(jìn)行的。在函數(shù)調(diào)用之前通過上文介紹的寄存器存儲參數(shù),函數(shù)返回之前通過RAX寄存器(32位系統(tǒng)為EAX)存儲返回結(jié)果。
另外一個(gè)比較重要的知識點(diǎn)是函數(shù)調(diào)用過程中與堆棧相關(guān)的寄存器RSP和RBP,兩個(gè)寄存器主要實(shí)現(xiàn)對棧位置的記錄,具體作用如下:
- RSP:棧指針寄存器(reextended stack pointer),其內(nèi)存放著一個(gè)指針,該指針永遠(yuǎn)指向系統(tǒng)棧最上面一個(gè)棧幀的棧頂。
- RBP:基址指針寄存器(reextended base pointer),其內(nèi)存放著一個(gè)指針,該指針永遠(yuǎn)指向系統(tǒng)棧最上面一個(gè)棧幀的底部。
寄存器的名稱跟體系結(jié)構(gòu)是相關(guān)的,本文是64位系統(tǒng),因此寄存器是RSP和RBP。如果是32位系統(tǒng)則寄存器的名稱為ESP和EBP。
應(yīng)用程序調(diào)用棧
我們先從整體上來看一下函數(shù)調(diào)用棧的主要內(nèi)容,如圖4所示。在函數(shù)棧中主要包括函數(shù)參數(shù)表、局部變量表、棧的基址和函數(shù)返回地址。這里棧的基址是上一個(gè)棧幀的基址,因?yàn)樵诒竞瘮?shù)中需要使用該基址訪問棧中的內(nèi)容,因此需要首先將上一個(gè)棧幀中的基址壓棧。
圖4 函數(shù)調(diào)用棧概覽
為了便于理解,我們以一個(gè)具體的程序作為示例。本程序非常簡單,主要是模擬了多個(gè)函數(shù)的函數(shù)調(diào)用關(guān)系和參數(shù)傳遞。另外,在函數(shù)func_2中定義了2個(gè)形參,以模擬多參數(shù)傳遞的過程。
圖5 函數(shù)棧匯編分析
在本示例中,main函數(shù)調(diào)用func_1函數(shù)。我們從main函數(shù)開始分析,可以先看一下右側(cè)的C語言代碼。首先是函數(shù)參數(shù)的準(zhǔn)備過程。在main函數(shù)調(diào)用func_1時(shí)依次傳入的參數(shù)為1、2、3和4+g,其中***一個(gè)參數(shù)是需要計(jì)算的。按照紅色方框的虛線,我們可以看到對應(yīng)的匯編程序,在匯編程序中首先處理***一個(gè)參數(shù),然后是倒數(shù)第二個(gè),以此類推(函數(shù)參數(shù)的處理順序在日常開發(fā)中是需要注意的內(nèi)容重點(diǎn))。同時(shí),我們看到存儲參數(shù)的寄存器名稱與前文是一致。
當(dāng)準(zhǔn)備完參數(shù)之后,就是調(diào)用func_1函數(shù),這個(gè)在匯編語言中就是call func_1這一行。雖然只是一行匯編指令,但其實(shí)內(nèi)部做了一些事情,這個(gè)我們在前文介紹call指令的時(shí)候有所介紹,大家可以參考一下前文。
之后就進(jìn)入func_1函數(shù)的處理邏輯。最一開始是pushq %rbp匯編程序,這句指令的作用是將RBP壓入函數(shù)棧中。這句壓棧及后面的更新RBP的值(moveq %rsp, %rbp)是構(gòu)建本函數(shù)的棧幀頭,后續(xù)對本棧幀的內(nèi)容的訪問都是通過幀頭(RBP)進(jìn)行的。接下來是對參數(shù)壓棧的過程和局部變量初始化的過程,具體分布參考圖5中的綠色方框和紅色方框。
完成函數(shù)內(nèi)的運(yùn)算后,***將運(yùn)算結(jié)果放入寄存器EAX中,然后調(diào)用指令leave和ret。這里面需要說明的是leave指令,該指令相當(dāng)于下面兩條匯編指令??梢詫Ρ纫幌潞瘮?shù)入口的匯編指令,其實(shí)兩者是對稱的。leave指令將本幀的棧基址賦值給棧指針(圖6中步驟2),然后將其中的內(nèi)容彈出到RBP中(圖6中步驟3)。其實(shí)就是RBP指向上一個(gè)幀(調(diào)用者)的棧幀,也即是一個(gè)復(fù)原的過程。
- movl %ebp %esp
- popl %ebp
圖6 函數(shù)返回示意圖
這樣,函數(shù)返回后寄存器RBP和RSP從被調(diào)用者的棧幀切換到了調(diào)用者的棧幀。
通過GDB分析函數(shù)調(diào)用棧
上面是通過反匯編的方式分析函數(shù)的調(diào)用棧和棧幀情況。我們還可以通過gdb動態(tài)的分析函數(shù)棧和棧幀的使用情況。我們依然通過main函數(shù)調(diào)用func_1函數(shù)為例來分析。我們這里在函數(shù)func_1的入口處設(shè)置一個(gè)單點(diǎn),然后運(yùn)行程序,程序停止在斷點(diǎn)處。如圖7是我們逐步執(zhí)行是函數(shù)棧的變化過程,具體細(xì)節(jié)我們這里就不再贅述,大家可以實(shí)際操作一下。
圖7 函數(shù)棧變化過程
本文的目的是讓大家對函數(shù)調(diào)用棧有個(gè)整體的了解,這樣對以后程序的疑難雜癥就有更多的解決思路。因?yàn)樵趯?shí)際生產(chǎn)環(huán)境中與棧相關(guān)的問題也是比較多的,比如局部變量太多導(dǎo)致的棧溢出,或者踩內(nèi)存問題引起的棧破壞等等。因此,了解了函數(shù)棧的原理,在遇到所謂的莫名其妙問題的時(shí)候就會有新的思路。往往很多問題不是問題本身莫名其妙,而是我們的知識儲備不夠,自己感覺莫名其妙而已。