首先來段代碼來瞧瞧:
#include <stdio.h> int add(int x,int y){ int z; z=x+y; return z; } int main(){ int r=add(3,4); printf("%d\n",r); return 0; }
一個簡單的函數調用,我們把main函數里的r=add(3,4)反匯編:
可以看到,(這里采用c默認的函數調用慣例,)首先進行參數壓棧,看清楚了,是把參數從右往左壓棧,然后call這個函數。跟蹤,call跟進去后,發現call指令執行后,ESP寄存器減4,也就是說,有往棧里壓了個參數--函數返回地址。看內存變化:
壓進去的是0x00401081,從第一圖可以看到,這就是call指令后的下一句,也就是函數的返回地址,函數調用完后得知道往哪里返回啊。
其實,call指令等價於兩步操作:
push 返回地址
jmp 函數入口地址
我們繼續跟進,看被調函數的反匯編代碼:
首先,再提醒一下,前面知道了,已經壓棧了三次,前兩次是壓棧形參,第三次是壓棧返回地址。看這里的前兩句,又把EBP壓棧了,然后把ESP賦值給了EBP。有沒有覺得奇怪呢?通常情況下,EBP都存儲基址,這里也不例外,由於后面可能出現多次壓棧出棧操作,ESP是變動的,需要一個基址寄存器來加減偏移量去棧上的值,畢竟剛才也看到了,棧里可是有不少重要的東東哦,通過基址加減偏移量就可以訪問了。於是,EBP就暫時擔待了這個重任。后面,ESP做了個減法,為函數內部局部變量等留下一定的棧空間,又壓棧了幾個寄存器,以備使用他們而不至於毀壞原有數據(后面再出棧就恢復了)。看核心代碼,z=x+y后面,把ebp+8地址的值賦給eax,思考一下,ebp+8是哪塊內存?回憶下前面棧里都壓了什么進去?ebp+0存的是ebp原有值,ebp+4存的是返回地址,ebp+8存的是最后一個被壓的參數,ebp+0c存的是......所以這里就是作加法,然后賦值給了ebp-4.這又是什么呢?這是z的地址。在監視窗口里可以看到z的地址就是如此。so,這里可以得出一個結論:ebp+x存的是返回地址、形參等;ebp-x存的是局部變量。
現在看return z后,又把z的值賦給了寄存器EAX,現在可以明白,函數返回值是借用寄存器來實現的。寄存器是個好東西,但是有一個缺點,就是數量少。要是返回的數據比較多,比如結構體,怎么辦?這個后面說。接着看圖,出棧,與前面一個個對應,注意順序蛤。然后,ret,這什么東東?經跟蹤發現,ret執行后,ESP+4,也就是說,有數據出棧,細細看下,是返回地址。ret它給EIP提供了返回地址,即等價於POP EIP。完了嗎?沒有完。別忘了,棧上還有參數呢。先壓的是形參,現在還沒出呢。看圖:
ret后,即執行到call后面的一句。這里ESP+8,這是維持棧平衡,之前形參占據的空間就釋放了,也稱之為調用方清棧。然后后面把寄存器存儲的返回值賦給r,也就是EBP-4,別忘了,r是main函數的局部變量呢。
這里順便提一個匯編知識。看那個call語句,他的16進制編碼與返回值代碼有何端倪?FFFFFF84<>00401005。這是因為call語句不是直接傳絕對地址,而是傳偏移量,計算下0040107c與ffffff84結合能不能得到00401005呢?得出是00401000。結論:偏移量=跳轉到的地址-call指令后一條指令的起始地址。
下面,我把返回值改為一個結構體:
#include <stdio.h> struct node{ int x,y,z; }; struct node f(struct node t){ return t; } int main(){ struct node r; r.x=1; r.y=2; r.z=5; f(r); return 0; }
進行反匯編,先看下main函數里的情況:
看call語句之前都做了什么?壓棧。這里把ESP賦給EAX,然后傳值給EAX+0/4/8等價於壓棧的push操作。注意一個地方,就是push edx。他又壓棧了個參數,這是什么?后面就知道了。看函數里的反匯編代碼:
直接return t。看return后一句,把EBP+8上的值賦給eax,EBP+0是ebp原有值,EBP+4是函數返回地址,EBP+8是main里面call之前那個push EDX操作,so,這里把那個edx賦給了eax。而后面,把ebp+0C/10/14上的內容都賦給了EAX+0/4/8。最后又把EBP+8也就是edx那個地址賦給了寄存器。別忘了,這里是儲存函數返回值的。前面說到,寄存器不夠用,那怎么辦?這里edx傳進來一個地址,然后返回值都依次存進了這個地址,這代表什么?緩沖地帶。既然寄存器使不動了,那就靠內存,這里拿這個緩沖地帶來中轉數據,解決了返回值過多的問題。那么,調用結束后,main函數一定會把這個緩沖地帶的內容取出來賦給某個變量的。我們來看看:
記好了,剛才緩沖地帶的地址放進了EAX里面,這樣它就可以用來做基址了。故而把EAX+0/4/8賦給一塊內存,這里可以看到,賦給了一個匿名局部變量,剛好與前面那個r的地址緊挨着呢。
故而總結下:c默認的調用慣例,函數返回值可以用寄存器或內存來存儲,選擇方式依賴於寄存器是否有能力完成任務。
關於函數的調用慣例,可以參考我的這篇博文:http://www.cnblogs.com/jiu0821/p/4219545.html
下面講解一下一個有趣的案例,先看源碼:
#include <stdio.h> typedef int (*func)(int,int); func pfunc; int _stdcall add(int a,int b){ return a+b; } void test(){ pfunc=(func)add; pfunc(1,2); add(1,2); } int main(){ test(); return 0; }
這里用的函數指針,有不熟悉的可以看我的這篇博文:http://www.cnblogs.com/jiu0821/p/4159487.html
簡單說下源碼,就是定義一個函數指針pfunc,把add強轉賦給pfunc,分別執行pfunc(1,2)和add(1,2),看有沒有什么不一樣的地方。add被_stdcall約束。運行一下會發現,pfunc(1,2)運行出現異常。什么原因呢?反匯編一下就知道了。
對比一下,發現pfunc多了一個出棧操作,而add沒有。那個cmp和call是異常處理,不用管,add也有,下面沒有截出來而已。還記得函數調用之后出棧操作是為了什么來着?維持棧平衡。這里我基本可以估摸出是棧出了問題。跟進去看看:
pfunc與add指向同一塊內存,因為函數入口地址一樣嘛。直接看最后的清棧部分--ret 8。那個8是什么意思呢?ret 8等價於兩條指令:pop eip; add esp,8.
發現問題了嗎?pfunc執行的時候,函數里出棧8字節,外面main那里又出棧8字節,畫蛇添足的結果就是自取滅亡。這里的端倪在於調用慣例不同。c缺省調用慣例是_cdecl,調用方清棧,而這里的_stdcall是被調用方清棧。pfunc使用的是_cdecl,卻執行_stdcall約束的函數,這可能不錯嗎?源碼里的強轉如果去掉,編譯器會報錯的,而強轉就是欺騙編譯器。有時候,欺騙別人,往往到最后,把自己也騙了。
讓我想起來之前在博問里一個博友遇到的問題:http://q.cnblogs.com/q/71965/
const_cast實行強轉,成功地騙過了編譯器,但是程序員自己寫出了未定義行為。我還是直接把我的回復截過來好了。
const對象是不允許修改的,而const_cast的存在是為了有些特殊情況需要表面去除const屬性,比如函數傳參,把const對象傳進非const屬性參數里,表面修改屬性實則不改變其內容。而你這里表面上是修改了,*x=3,這種修改const對象屬於c++標准里未定義行為,針對這樣的,是由編譯器來自行處理的。你可以看到他們地址都相同,但卻值不一樣,這是編譯器的處理效果。我們需要做的是,避免編程里出現這種未定義行為。
c/c++賦給了我們強大的權力,我們不要去胡作非為......
總結一下就是,強轉是很方便,但我們使用的時候,千萬要注意,使用得當。