1. 程式人生 > >CPU阿甘:函式呼叫的祕密

CPU阿甘:函式呼叫的祕密

我是CPU阿甘, 上次我給大家承諾過,要講一講函式呼叫的祕密, 這個確實有點複雜, 想透徹的理解機器程式碼層面的函式呼叫不容易。

我也是從無數的指令中悟出這個函式呼叫的祕密的, 所以慢慢來,不要急。 放鬆心情, 慢慢的品味, 你可能需要多看幾遍才能明白。

但是你一旦理解了,絕對物超所值,因為你會了解到彙編,暫存器,指標,以及他們在一起到底是怎麼工作的。

首先, 一個程式一條一條的指令都的老老實實的放在記憶體的一個地方,這個地方是Linux老大分配的, 我干涉不了, 但是這些指令都是我打電話給硬碟, 讓他給運輸到記憶體的。

然後Linux老大就會告訴我程式的入口點, 其實就是第一條指令的存放地址, 我就打電話問記憶體要這個指令, 取到指令以後就開始執行。
這些指令當中無非有這麼幾類:

  1. 把資料從記憶體載入我的暫存器裡(什麼? 你不知道啥是暫存器? 暫存器就是我內部的一個臨時的資料儲存空間了)
  2. 對暫存器的資料進行運算, 例如把兩個暫存器的數加起來
  3. 把我暫存器的資料再寫到記憶體裡

但是我一旦遇到像這樣的指令。
“把暫存器ebp的值壓到棧裡去“
我就知道好戲要上場了, 函式呼叫就會開始。

我們這些x86體系的機器有個特點,就是每個函式呼叫都會建立一個所謂的“幀”

哈哈, 不要被這些術語嚇壞, 其實幀也就是我哥們記憶體中的一段連續的空間而已。
像這樣:

這裡寫圖片描述

多個函式幀在記憶體裡排起來, 就像一個先進後出的棧一樣, 不過,這個棧不像我們常見的棧, 棧底在下面。
相反,這個棧的棧底在上面, 是從上往下生長的 (或者說是從高地址向低地址生長的)

記憶體經常向我抱怨: “阿甘,你知道嗎, 每次我看到這個棧, 都有一種真氣逆行的感覺, 半天都調整不過來 ”

但記憶體不知道, 我有一個叫ebp的特殊暫存器, 一直會指向當前函式在一個棧的開始地址。
我還有另外一個特殊暫存器,叫做esp , 他會隨著指令的執行,指向函式幀的最後的地址, 像這樣:

這裡寫圖片描述

現在這個指令來了:
“把暫存器ebp的值壓到棧裡去“
“把esp的值賦給ebp”

這裡寫圖片描述

你看看, 是不是新的函式幀生成了?
只不過現在只有一行資料。 ebp和esp指向同一地址。
函式幀的第一行的地址是800, 裡邊的內容是1000, 也就是上個函式幀的地址

注意, 我們每次操作的是4個位元組,所以原來esp 的地址是804, 現在變成了800
我又問記憶體要下一條指令:
“把esp 的值減去24”

這裡寫圖片描述

下面幾條指令是這樣的:
“把10放到ebp 減去4的地址” (其實就是796嘛)
“把20放到ebp減去8的地址” (其實就是792嘛)

這裡寫圖片描述

你們知道這是幹什麼嗎?
我想了好久才明白這是幹嘛, 這其實就是在分配函式的區域性變數啊
我猜原始碼應該是這樣的:

int x = 10;
int y = 20;

在我看來, x, y 只是變數, 他們叫什麼根本不重要, 重要的是他們的值和地址!
下面幾條指令很有意思:
” 把地址796作為資料放到 esp指向的地址“ (其實就是776嘛)
” 把地址792作為資料放到 esp+4指向的地址” (其實就是780嘛)

這裡寫圖片描述

這又是在幹嘛?
這其實就相當於把 x 的指標 &x和 y 的指標 &y ,放到了特定的地方, 準備著要做什麼事情 , 可能要呼叫函數了。
所以,所謂的指標就是地址而已。
我猜程式設計師寫的程式碼應該是這樣:
int x = 10;
int y = 20;
int sum= add(&x, &y);
接下來的指令是這樣:
“呼叫函式 add”
我看到這樣的函式就需要特別小心, 因為我必須要找到 add函式返回以後的那條指令的地址, 把它也壓到棧裡去。
int x = 10;
int y = 20;
int sum = add(&x, &y);
printf(“the sum is %d\n”,sum); 假設這條指令的地址是100

這裡寫圖片描述

注意啊, 把函式呼叫結束的以後的返回地址100壓入棧以後, esp 也發生變化了, 指向了772的位置
我會找到函式Add 的指令,繼續執行
“把暫存器ebp的值壓到棧裡去“
“把esp的值賦給ebp”
“把暫存器ebx的值壓入棧”
你看每個函式的開始指令都是這樣, 我猜這應該是一種約定吧
這裡額外把ebx這個暫存器壓入棧, 是因為ebx可能被上個函式使用, 但是在add函式中也會用 , 為了不破壞之前的值, 只有先委屈一下暫時放到記憶體裡吧。

這裡寫圖片描述

接下來的指令是:
“把ebp 加8的資料取出來放到 edx 暫存器” (ebp+8 不就是地址776嘛, 其中存放的是&x的地址, 這就是取引數了)

“把ebp 加12的資料取出來放到 ecx 暫存器” (ebp+12 不就是地址780嘛, 其中存放的是&y的地址)

注意啊, 現在edx的值是796, ecx的值是792 , 但他們仍然不是真正的資料, 而是指標(地址)!

“把edx 指向的記憶體地址(796)的資料取出來,放到ebx 暫存器”

“把ecx 指向的記憶體地址(792)的資料取出來,放到eax暫存器”

此時此刻, 終於取到了真正的值, ebx = 10, eax = 20
你暈了沒有?
如果你到此已經暈了, 建議你再讀一遍。
我想原始碼應該非常的簡單,就是這樣:

int add(int *xp , int *yp){
    int x = *xp;
    int y = *yp;
    ....
}

“把ebx 和 eax 的值加起來,放到 eax暫存器中”
這個指令我最擅長做了。
接下來的指令也很關鍵, add 函式已經呼叫完成, 準備返回了
“把esp 指向的資料彈出的ebx暫存器”
“把esp 指向的資料彈出到ebp暫存器”

這裡寫圖片描述

你看add 函式幀已經消失了, 或者換句話說, add 函式幀的資料還在記憶體裡, 只是我們不在關心了!
接下來的指令非常的關鍵:
“返回”
我就會取出那個返回地址, 也就是 100, 去這裡找指令接著執行
其實就是這條語句: printf(“the sum is %d\n”,sum);
問你一個問題, sum的值在那裡儲存著呢?
對, 是在eax暫存器裡 !

搞定了,看著很複雜, 其實看透了也挺簡單吧。 函式呼叫,關鍵就是

  • 把引數和返回地址準備好,
  • 然後大家都遵循約定, 每次新函式都要建立新的函式幀:
    “把暫存器ebp的值壓到棧裡去“
    “把esp的值賦給ebp”
  • 函式呼叫完了, 重置 ebp 和esp ,讓他們重新指向呼叫著的棧幀。

好了,今天就到此為止 , 把我也累壞了, 主人又要關機了, 留一個問題吧:

C語言編譯,連結以後直接就是機器碼, 那函式呼叫的操作都是上面講的。
但是對於Python, Ruby 這樣的解釋型語言, 或者對於java 這樣的有虛擬機器的語言, 他們的函式呼叫是什麼樣的? 和上面講的有什麼關係?



轉自微信公眾賬號:碼農翻身
作者:劉欣 工作15年的前IBM架構師,致力於分享好玩有趣的程式設計知識 !

這裡寫圖片描述