函數,相信許多人也知道其重要性;一個檔案往往由一個或者多個函數構成的。然而可能許多人還不知道函數調用的一些深層問題,所以我寫的這篇文章一來是應了一個好朋友的要求而寫,二來希望一些朋友能夠從我這篇文章瞭解函數調用的機制。但是並不是每個人都可以完全讀懂這文章,想完全讀懂此文,我想必須具備三個條件:
一、對於C語言有一定的瞭解,最起碼有一個整體的初步瞭解;
二、能夠讀懂UNIX/LINUX下的AT&T文法的彙編;AT&T彙編與Intel彙編的差別還是挺大的;這個條件可能一些人就不具備了,但是你通過閱讀此文相信也能對函數調用機制有一個大概的瞭解;
三、看到這麼長的文章,一定要有耐心,用心看相信應該多少有點協助;
好了,不講廢話了,進入主題吧。
一、基本知識架構瞭解:
這部分主要講一些基本的東西,主要是關於堆棧的知識。只有瞭解了堆棧的基礎內容,才可以繼續往下讀。
1.概念性的知識:
所謂堆棧,其實也就是程式使用的一種記憶體元素;它是記憶體中用來存放一些資料的地區。我曾經寫過一篇文章發表在這個論壇上裡面也談到了堆和棧的區別;平常經常說的堆棧,其實也是棧,而不是堆,所以這裡也一樣。注意這和資料結構說的棧其實還是有區別的,不要混在一起。
2.堆棧的工作方式:
平常我們所說的資料是怎麼存放在記憶體的?是從低地址開始,然後按照資料佔用位元組大小往高地址逐個存放的。但堆棧就不一樣了。堆棧的工作方式是資料插入堆棧地區然後從堆棧地區刪除資料。這是概括的說法。具體是這樣的:
在UNIX/LINUX中,堆棧是從高地址向低地址衍生的。這裡得說一個重要的東東,那就是堆棧指標ESP。堆棧指標是什嗎?它永遠指向堆棧中的頂部(但如果按照地址值來說卻是底部),是不是對頂部這個詞的理解感覺有點模糊?就是說,比如說你壓棧,就壓進一個4位元組的資料元素,那麼ESP就向下移動了4個位元組,注意這裡是向下移動,所以ESP該指向了更低的地址,所以說它是指向了底部。你可以把堆棧想象成一個杯子,倒進水了水平線是不是上升了(這裡把杯子最底端假設成高地址,把頂端設為低地址),倒出水了水平線是不是下降了?就和壓棧和進棧的道理一樣的。如果還沒有理解也沒關係,自己畫個圖仔細比較就可以了。這裡讓我偷懶一下就不畫圖了。
3.壓棧和進棧指令簡介:
壓棧指令 : pushx source
其中, 'x'可以是 'w'(表示字), 或者是'l'(表示長字);source可以是數值或者寄存器值或者記憶體位址;
出棧指令 : popx des
同樣,'x'可以是 'w'(表示字), 或者是'l'(表示長字);des可以是寄存器值或記憶體值;
關於最最基本的東西已經講得差不多了,當然還有其他一些基本東西,留給大家去查資料了,這部分講的都和本文有密切關係的東西。
二、函數如何通過堆棧來解決問題:
這部分是對函數如何通過堆棧解決函數調用以及參數傳遞的理論性理解,相當重要,只有瞭解之後才可以進行執行個體的分析,這一大部分同樣分成幾個小部分:
1.通過堆棧操作實現參數的傳遞:
前面說過,堆棧的基本操作可以是壓棧和出棧,而參數的傳遞就是通過這種方式來實現的。ESP永遠指向了堆棧頂部,如果這時候壓進一個int型的資料元素,那麼ESP向下移動了4個位元組,這時候它還是指向了堆棧的頂部(注意了,頂部的地址比移動前的地址低,不要亂了)。假如把一個int型資料元素出棧,那麼ESP向上移動4個位元組,這時候它還是指向了堆棧的頂部,只是現在地址是增加了4個位元組。所以,如果一個函數需要傳遞參數過去那麼就得在調用函數之前先把參數壓進棧,然後再調用。關於這點後面我會詳細說一下,現在你如果沒理解也沒關係。
2.函數調用的一般彙編指令:
函數調用的一般彙編指令都是那麼幾條,下面我把他們按一般順序羅列出來: #Asm Code
function:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
#...
movl %ebp, %esp
popl %ebp
ret
下面先簡單分析這幾句一般彙編指令的意思和目的。
pushl %ebp #這句把寄存器%ebp壓棧,目的是什麼呢?看下一條指令:
movl %esp, %ebp
#把寄存器%esp的值給了寄存器%ebp;想想前面說到的%esp寄存器是幹什麼的?用於指向堆棧的頂部,現在通過這條指令,%ebp都是指向了堆棧的頂部了;所以看看第一條指令,其實就是為了保護原來在%ebp寄存器中的內容#那麼這裡為什麼又要把%esp的值賦給%ebp呢?這裡的巧妙就來了。在函數的處理過程中,可能一些資料會被壓進棧,那麼這時候就會破壞棧裡面原有的內容了,如果棧的內容被破壞了,指向棧頂的指標%esp指向的地址不準確了(不知道能不能用“不準確”這個詞來形容,可能不太合適),那麼到時候要清棧就會發生更多的意外問題了。清棧?先別管這個詞,下面也會給出解釋。所以第二條指令是為了保證有一個寄存器永遠指向了棧頂而不必擔心會出現剛才所說的問題。現在是寄存器%ebp永遠指向棧頂了,而%esp可以移動而不必害怕資料會被破壞了。
subl $8, %esp
#看這條指令,為什麼無故要把%esp的值減去8呢?也就是說%esp向下移動8個位元組,而這8個位元組的空間到底用來幹什麼呢?這8個位元組空間其實是為臨時變數留出來的。注意,它會根據臨時變數佔用的位元組大小而留出不同的空間大小,所以不一定是8個位元組,可能是24或者36甚至更大的空間;不過臨時變數太多不是好事情,這點注意。
movl %ebp, %esp #這條指令把%ebp複製到%esp了,理由是什嗎?讓%esp重新指向棧頂,這樣就可以方便函數調用完畢後的清棧了。
ret #函數調用完畢的返回指令,這句指令其實同時把函數調用剛剛開始壓進的IP地址彈出棧。在下面會有詳細分析。
關於函數如何通過堆棧來解決問題的基本理論大概就說到這裡,假如你對上面的內容不理解也沒關係,下面第3個部分通過執行個體來分析可以讓你有個比較深刻的理解。
三、函數調用和參數傳遞機制的執行個體分析:
這是本文的實戰分析部分了,通過例子來加深一下理解。我會先列出C代碼出來,然後列出反組譯碼的彙編代碼,結合C代碼來分析彙編代碼。我會儘可能對各種類型的函數調用或參數類型作一個分析,可能會顯得比較累贅一點,不介意吧?準備好的話就開始吧:
1. 函數原型:void function(void);
// C code
void function(void)
{
return;
}
int main(void)
{
function();
return 0;
}
反組譯碼一下看看彙編代碼,下面是Linux 下的gcc反組譯碼後的代碼(注意:是在我的機子上的反組譯碼代碼):
function:
pushl %ebp
movl %esp, %ebp
popl %ebp
ret
看看,因為函數function什麼也沒有做,所以直接就返回了,上面的指令和第2部分的代碼基本上一樣,甚至更簡單,參照一下前面的分析:
下面看看main函數的反組譯碼代碼了,相對來說複雜一點,看好了:
main:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
andl $-16, %esp
movl $0, %eax
addl $15, %eax
addl $15, %eax
shrl $4, %eax
sall $4, %eax
subl %eax, %esp
call function #函數調用指令
movl $0, %eax
leave
ret
看看函數調用指令 : call function,前面居然還有那麼多據指令,那些指令到底幹什麼用?我一句一句分析吧:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
這三句不分析,和前面第2部分的一樣,忘記的回頭看一下,其實這也反映了一件事:其實main函數也很普通,它跟其他函數其實差不多,只是地位稍微高一點而已。
andl $-16, %esp
這句可能嚇倒一些人了。 andl 是邏輯與指令,而-16其實補碼形式是0xfffffff0。為什麼要把%esp的值和-16進行邏輯與運算呢?不要小看這條指令,它的作用不容忽視,%esp指向堆棧頂部。這條指令其實是為了強制讓%esp的值是16的倍數。為什麼要16的倍數?這裡必須懂得一個常識:Linux下的編譯器GCC預設的堆棧是16位元組對齊的,可能有些人要問為什麼要對齊,對齊其實為了加快CPU的訪問效率,這裡你記住這點就可以了。
movl $0, %eax
addl $15, %eax
addl $15, %eax
shrl $4, %eax
sall $4, %eax
看到這幾句,又有更多人可能被嚇到了,幹嘛對%eax寄存器進行那麼多的操作啊?的確,我也覺得沒什麼多大的必要,因為仔細看看這幾條指令無非就是為了讓%eax的值是0而已。看看剛開始 %eax = 0,經過兩次addl之後,%eax的值變成30了,30其實就是0x11110,再下面兩條指令是為了保證%eax最低5位的值全部為0。注意,這隻是在我的機子上的反組譯碼指令,不同機器對此處理可能不一樣,但有一點一樣就是保證%eax的值是0。看看下面這條指令:
subl %eax, %esp
看,%esp值減去%eax值後把結果送到%esp,所以經過這條指令後%esp值仍然是16的倍數,這就是保證%eax值是16的倍數的原因了。
call function
movl $0, %eax
這個簡單了,調用函數function,最後又把%eax寄存器的值清0,結束整個main函數了。這就是最簡單的函數調用分析了,沒有涉及到參數的傳遞,所以非常簡單,下面就要開始講到參數的傳遞了,事實上有了這個例子的分析,下面的簡單多了。
2.函數原型: int function(int i)
現在有了參數了,也有了傳回值了,相對來說更比較複雜了。這裡就得引入%esp寄存器值的變化了,不然就難以把問題分析清楚了,如果想形象一點地描述那就畫圖,自己畫個圖根據我的資料變化一起分析吧。看看一段簡單的C代碼:
// C Code
int function(int i)
{
return 2 * i;
}
int main(void)
{
int j = function(10);
return 0;
}
之所以些這麼簡單只是為了我們分析問題的方便,懂得個原理就算是複雜的其實稍微再分析一下也就懂了。我們從main開始分析吧:
main:
pushl %ebp
movl %esp, %ebp
subl $24, %esp
andl $-16, %esp
movl $0, %eax
addl $15, %eax
addl $15, %eax
shrl $4, %eax
sall $4, %eax
subl %eax, %esp #到這裡其實和前面的例子基本一樣,就不分析了
movl $10, (%esp)
call function
movl %eax, -4(%ebp)
movl $0, %eax
leave
ret
看看上面的彙編代碼,和前面一樣的不分析。但是其中有句不一樣:subl $24, %esp ; 因為主函數裡有兩個臨時變數i, j;這裡為了有足夠的空間留給臨時變數所以乾脆在堆棧裡騰出24個位元組空間。在看看下面的代碼:
movl $10, (%esp) #====> %esp = 800, (800) = 10
,其中800是我們假設的地址值,(800)表示地址800的內容這裡的(%esp)指的是%esp地址裡的內容,
剛才我們假設這時候%esp的值是800, 那麼地址為800的內容就是10了。執行函數調用了,注意在調用函數前其實是先把函數調用指令call之後的地址壓棧,也就是call之後那條指令的IP值壓棧,所以這時候 %esp =
796;這裡要弄明白為什麼要把下條指令地址壓棧,假設如果不把IP值壓棧,那麼當函數調用完畢後怎麼能找到函數調用時的地址呢?也就是說如果沒把IP壓棧,那麼函數調用完之後就回不到原來的執行地址了,就會造成程式執行順序的錯誤!
下面列出函數function的彙編代碼:
function:
pushl %ebp
movl %esp, %ebp
movl 8(%ebp), %eax
addl %eax, %eax
popl %ebp
ret
pushl %ebp; 經過這條指令後 %esp值減4,所以這時候%esp值是792。下面這句:
movl %esp, %ebp #==============> %ebp = 792, %esp = 792, (792) = %ebp ;其中(792)表示地址792的內容
movl 8(%ebp), %eax #========> %eax = 10
上面這句很多人可能不明白了,8(%ebp)指的是什嗎?8(%ebp)等於 : (%ebp + 8) ,這裡注意,%ebp + 8
是表示一個地址值,加上括弧表示儲存在該地址上的內容。所以8(%ebp)其實就是地址為800的值,看前面地址800的值剛好是10!所以這句其實是把10複製給%eax寄存器.
addl %eax, %eax #======> %eax = 20
相當於2 * %eax, %eax這時候等於20了,剛好是實現了C代碼中的 (2 * i);
popl %ebp #=========> 恢複%ebp寄存器的值, %esp這時候等於796
ret #=========> 函數調用完畢返回,這句其實是把剛才壓棧的IP值彈出棧,執行這條指令後 %esp = 800
# 800!想想我們在調用函數的時候%esp也是800啊!這就是實現了“清棧”了,就是把調用函數所在的棧清除了!
好了,函數 function的彙編程式碼分析完了,現在回頭繼續看看main函數裡的下一條指令了。接下來是這句:
movl %eax, -4(%ebp)
%eax寄存器存放的是什嗎?看function函數的代碼,可以知道其實就是(2 *i)的值,所以傳回值其實是通過%eax來傳遞的!傳遞到-4(%ebp)裡去了,-4(%ebp) = (%ebp - 4);-4(%ebp)到底是什麼呢?看看C代碼,傳回值傳給變數j,那麼-4(%ebp)會不會就是j呢?答案是肯定的!我們先看看%ebp的值是什麼。看看
main函數的彙編代碼,可以得出,%ebp其實指向了main函數的棧底部,但記不記得前面說的subl $24,
%esp是為臨時變數而留出的空間?沒錯,-4(%ebp) 就是儲存在臨時變數地區!也就是變數 j 了。3. 函數原型:int function(int i, int j);
現在參數是兩個,不是一個了,兩個到底該怎麼處理呢?同樣看C程式和相應的彙編代碼:
// C code
int function(int i, int j)
{
return (i + j);
}
int main(void)
{
function(1, 2);
}
下面列出主要的彙編代碼,沒有全部列出來,因為一些和前面一樣的代碼已經分析過,不再羅嗦了。
main:
# ... ...
movl $2, 4(%esp)
movl $1, (%esp)
call function
看到沒有?先把2送進棧裡,再把1壓棧,我們看看函數調用的C代碼:function(1, 2); 2在右邊,而1在左邊,所以,當存在多參數的時候參數壓棧其實是按從右向左的順序壓棧的。當參數都壓棧後,就調用函數了。
function:
pushl %ebp
movl %esp, %ebp
movl 12(%ebp), %eax
addl 8(%ebp), %eax
popl %ebp
ret
看函數的彙編代碼:movl 12(%ebp), %eax ; 知道12是哪裡來的嗎?自己畫圖看看,結合前面的分析!一調用函數先壓IP進棧,再壓%ebp進棧,那麼%esp值就被減去8了,再把%esp值複製到%ebp,就是這樣而已!
4.函數原型:char *function(char *s);
作為字串函數其實道理也差不多,而且我覺得反而更簡單,怎麼個簡單法,看代碼吧:
// C code
char *function(char *s)
{
return s;
}
int main(void)
{
char *p = function("abcd");
}
列出並簡單分析一下彙編代碼:
main:
# ... ...
movl $.LC0, (%esp)
call function
這裡你可能會問 $.LC0 是什麼。看看下面的定義:
.LC0:
.string "abcd"
.LC0隻是一個標誌,就是字串"abcd";所以說白了其實非常簡單。movl $.LC0, (%esp) ;就是把字串送到地址為%esp的空間裡。
下面看調用的函數的彙編代碼了:
function:
#... ...
movl 8(%ebp), %eax
#.. ...
8(%ebp)其實就是字串了,依然建議你們自己畫圖看看,只要圖一畫,自然變得十分清晰。
5.函數原型:struct text function(int n);
現在函數傳回值類型換成是結構體了,差別就來了,不過其實道理還是那個樣,本質的東西還是一樣的。到結構體這裡我就簡單說幾句而已。
//C code
struct text {
int a;
};
struct text function(int n)
{
struct text s;
s.a = n;
return s;
}
int main(void)
{
struct text t = function(10);
return 0; }
這裡舉的例子都非常簡單,我們的目的只是為了分析不同函數調用和參數傳遞方式是怎樣進行的。看彙編代碼:
main:
# ... ...
leal -20(%ebp), %eax
movl $10, 4(%esp)
movl %eax, (%esp)
call function
到這個例子就只分析最重要的指令了,其他的大家可以嘗試分析看看。在main函數開頭,我的機子上GCC居然留出了40個位元組給臨時結構體變數!
開頭有這句:subl $40, %esp ; 有點驚訝,我也不知道為什麼留這麼大的空間。但這些資料我們先不管了,畢竟不是最重要的。
leal -20(%ebp), %eax
看看上面這句,這條指令非常重要。把(%ebp - 20)的地址複製給%eax,%eax 的值其實是結構體中成員的地址。自己分析後面的代碼證實一下。
下面是function函數的彙編代碼:
function:
pushl %ebp
movl %esp, %ebp
subl $16, %esp
movl 8(%ebp), %eax
movl 12(%ebp), %edx
movl %edx, -4(%ebp)
movl -4(%ebp), %edx
movl %edx, (%eax)
subl $16, %esp #為函數中臨時結構體變數保留空間.
在上面和條指令後面那幾條指令都是按變數所在的地址賦給結構體變數成員了,有興趣的朋友自己分析下,讓我偷個懶吧。
5. 其他說明
其實還有很多的類型都沒講,其中就有傳回值為浮點數或者參數是浮點數,浮點數不講了,因為這就比較複雜了,為了支援浮點數以及其他功能(比如說數學運算功能)就需要附加的指令和寄存器了,這些東西全部結合就稱作“浮點單位”(Floating-point unit ---> FPU)。看看浮點數以及運算有自己的指令和寄存器,這些東西說起來就得一篇文章了。
遞迴就不也用講了,道理和前面一樣,只是重複調用自己而已,就按照棧的順序衍生過去而已然後最後一層層清棧了。另外還有可變參數函數,其實有了上面的基礎來分析可變參數函數挺簡單的,入棧順序還是那樣從右向左,只是你需要瞭解一下可變參數函數的內部到底怎麼實現的,這裡存在很強的技巧性,有興趣的朋友可以去看看在標頭檔<stdarg.h>裡面關於可變參數的幾個宏,技巧性超強!
四、後記
寫到這裡我的頭都有點漲了,花了好幾個鐘頭寫的。本來後面還想再把結構體啊可變參數函數啊之類的說一下,但沒什麼激情了。抱歉一下。也不想重審文章了。
因為寫得匆忙,文章肯定有不少地方表達不清楚甚至觀點錯誤,希望大家指正。最後希望我寫的這文章能夠協助一些朋友理解函數調用和參數傳遞的機制。