程序員必備高級技術之函數調用棧

大家都知道函數調用是通過棧來實現的,而且知道在棧中存放着該函數的局部變量。但是對於棧的實現細節可能不一定清楚。本文將介紹一下在 Linux 平臺下函數棧是如何實現的。有些同學可能覺得沒必要了解這麼深入,其實非也。根據本號多年的經驗,瞭解系統深層次的原理對分析疑難問題有很好的幫助。

就像熟悉抓包是解決網絡通信問題的高級武器一樣,熟悉函數調用棧則是分析程序內存問題的高級武器。本文以 Linux 64 位操作系統下 C 語言開發爲例,介紹應用程序調用棧的實現原理,並通過一個實例和 GDB 工具具體分析一下某個程序的調用棧內容。在介紹具體的調用棧之前,我們先介紹一些基礎知識,這些知識是理解後續函數調用棧的基礎。

X86 CPU 的寄存器

CPU 的寄存器是需要了解的基礎知識,這是因爲在 X64 體系中函數的參數是通過寄存器傳遞的。如圖 1 是 X86 CPU 寄存器的列表及功能簡要說明。

圖 1 Intel X86 CPU 寄存器用途

我們知道 Intel 的 CPU 在設計的時候都是向前兼容的,也就是在新一代的 CPU 上可以運行老一代 CPU 上的編譯的程序。爲了保證兼容性,新一代 CPU 保留了老一代寄存器的別名。以 16 位寄存器 AX 爲例,AL 表示低 8 位,AH 表示高 8 位。而 32 位 CPU 問世之後,通過名爲 EAX 的寄存器表示 32 位寄存器,AX 仍然保留。以此類推,RAX 表示一個 64 位寄存器。

圖 2 不同的寄存器名稱

應用程序的地址空間

操作系統通過虛擬內存的方式爲所有應用程序提供了統一的內存映射地址。如圖 3 所示,從上到下分別是用戶棧、共享庫內存、運行時堆和代碼段。當然這個是一個大概的分段,實際分段比這個可能稍微複雜一些,但整個格局沒有大變化。

圖 3 應用程序的地址空間

從圖中可以看出用戶棧是從上往下生長的。也就是用戶棧會先佔用高地址的空間,然後佔用低地址空間。目前我們可以大體上有個瞭解即可,後面我們在詳細分析用戶棧的細節。

函數調用及彙編指令

爲了理解函數調用棧的細節,有必要了解一下彙編程序中函數調用的實現。函數的調用主要分爲 2 部分,一個是調用,另外一個是返回。在彙編語言中函數調用是通過 call 指令完成的,返回則是通過 ret 指令。

彙編語言的 call 指令相當於執行了 2 步操作,分別是,1)將當前的 IP 或 CS 和 IP 壓入棧中;2)跳轉,類似與 jmp 指令。同樣,ret 指令也分 2 步,分別是,1)將棧中的地址彈出到 IP 寄存器;2)跳轉執行後續指令。這個基本上就是函數調用的原理。

除了在代碼間的跳動外,函數的調用往往還需要傳遞一個參數,而處理完成後還可能有返回值。這些數據的傳遞都是通過寄存器進行的。在函數調用之前通過上文介紹的寄存器存儲參數,函數返回之前通過 RAX 寄存器(32 位系統爲 EAX)存儲返回結果。

另外一個比較重要的知識點是函數調用過程中與堆棧相關的寄存器 RSP 和 RBP,兩個寄存器主要實現對棧位置的記錄,具體作用如下:

RSP:棧指針寄存器 (reextended stack pointer),其內存放着一個指針,該指針永遠指向系統棧最上面一個棧幀的棧頂。

RBP:基址指針寄存器 (reextended base pointer),其內存放着一個指針,該指針永遠指向系統棧最上面一個棧幀的底部。

寄存器的名稱跟體系結構是相關的,本文是 64 位系統,因此寄存器是 RSP 和 RBP。如果是 32 位系統則寄存器的名稱爲 ESP 和 EBP。

應用程序調用棧

我們先從整體上來看一下函數調用棧的主要內容,如圖 4 所示。在函數棧中主要包括函數參數表、局部變量表、棧的基址和函數返回地址。這裏棧的基址是上一個棧幀的基址,因爲在本函數中需要使用該基址訪問棧中的內容,因此需要首先將上一個棧幀中的基址壓棧。

圖 4 函數調用棧概覽

爲了便於理解,我們以一個具體的程序作爲示例。本程序非常簡單,主要是模擬了多個函數的函數調用關係和參數傳遞。另外,在函數 func_2 中定義了 2 個形參,以模擬多參數傳遞的過程。

圖 5 函數棧彙編分析

在本示例中,main 函數調用 func_1 函數。我們從 main 函數開始分析,可以先看一下右側的 C 語言代碼。首先是函數參數的準備過程。在 main 函數調用 func_1 時依次傳入的參數爲 1、2、3 和 4+g,其中最後一個參數是需要計算的。按照紅色方框的虛線,我們可以看到對應的彙編程序,在彙編程序中首先處理最後一個參數,然後是倒數第二個,以此類推(函數參數的處理順序在日常開發中是需要注意的內容重點)。同時,我們看到存儲參數的寄存器名稱與前文是一致。

當準備完參數之後,就是調用 func_1 函數,這個在彙編語言中就是 call func_1 這一行。雖然只是一行彙編指令,但其實內部做了一些事情,這個我們在前文介紹 call 指令的時候有所介紹,大家可以參考一下前文。

之後就進入 func_1 函數的處理邏輯。最一開始是 pushq %rbp 彙編程序,這句指令的作用是將 RBP 壓入函數棧中。這句壓棧及後面的更新 RBP 的值(moveq %rsp, %rbp)是構建本函數的棧幀頭,後續對本棧幀的內容的訪問都是通過幀頭(RBP)進行的。接下來是對參數壓棧的過程和局部變量初始化的過程,具體分佈參考圖 5 中的綠色方框和紅色方框。

完成函數內的運算後,最後將運算結果放入寄存器 EAX 中,然後調用指令 leave 和 ret。這裏面需要說明的是 leave 指令,該指令相當於下面兩條彙編指令。可以對比一下函數入口的彙編指令,其實兩者是對稱的。leave 指令將本幀的棧基址賦值給棧指針(圖 6 中步驟 2),然後將其中的內容彈出到 RBP 中(圖 6 中步驟 3)。其實就是 RBP 指向上一個幀(調用者)的棧幀,也即是一個復原的過程。

movl %ebp %esp
popl %ebp

圖 6 函數返回示意圖

這樣,函數返回後寄存器 RBP 和 RSP 從被調用者的棧幀切換到了調用者的棧幀。

通過 GDB 分析函數調用棧

上面是通過反彙編的方式分析函數的調用棧和棧幀情況。我們還可以通過 gdb 動態的分析函數棧和棧幀的使用情況。我們依然通過 main 函數調用 func_1 函數爲例來分析。我們這裏在函數 func_1 的入口處設置一個單點,然後運行程序,程序停止在斷點處。如圖 7 是我們逐步執行是函數棧的變化過程,具體細節我們這裏就不再贅述,大家可以實際操作一下。

圖 7 函數棧變化過程

本文的目的是讓大家對函數調用棧有個整體的瞭解,這樣對以後程序的疑難雜症就有更多的解決思路。因爲在實際生產環境中與棧相關的問題也是比較多的,比如局部變量太多導致的棧溢出,或者踩內存問題引起的棧破壞等等。因此,瞭解了函數棧的原理,在遇到所謂的莫名其妙問題的時候就會有新的思路。往往很多問題不是問題本身莫名其妙,而是我們的知識儲備不夠,自己感覺莫名其妙而已。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/zN0qxvOIwAHFL0kVIfusiA