- 老“碼”識(shí)途
- 韓宏 李林
- 15021字
- 2018-12-30 16:52:33
1.3 函數(shù)調(diào)用和局部變量
1.3.1 計(jì)算指令中的跳轉(zhuǎn)地址
函數(shù)是C語言為我們提供的一份大禮,它封裝了許多細(xì)節(jié)。掌握一個(gè)函數(shù)的調(diào)用細(xì)節(jié),對培養(yǎng)計(jì)算機(jī)的思維方式有莫大幫助,現(xiàn)在就開始這一探索之旅吧。
首先,給出DM1-10所示的代碼。然后在main()函數(shù)的“z=Add(1,2);”行設(shè)置斷點(diǎn),按F5鍵,調(diào)試運(yùn)行并停止在該斷點(diǎn)處。反匯編代碼如下:
DM1-10 int Add(int x, int y){ int sum; sum=x+y; return sum; } void main(){ int z; z = Add(1, 2); printf("z=%d\n", z); } z = Add(1, 2); 00413762 6a 02 push 2 00413764 6a 01 push 1 00413766 e8 7a da ff ff call 004111e5 0041376B 83 c4 08 add esp, 8 0041376E 89 45 f8 mov dword ptr [ebp-8], eax
執(zhí)行了call指令后,程序就跳到Add()函數(shù)。可知,call指令完成了函數(shù)調(diào)用,而且執(zhí)行call指令后,函數(shù)確實(shí)跳轉(zhuǎn)到了0x004111e5地址。(還是從機(jī)器碼開始分析來尋找這個(gè)跳轉(zhuǎn)信息。)對于機(jī)器碼e8 7a da ff ff,先來猜測它的含義。習(xí)慣上,e8似乎代表call指令,后面4字節(jié)似乎與跳轉(zhuǎn)地址相關(guān)。怎樣證明?一時(shí)也看不出后4字節(jié)是否與跳轉(zhuǎn)地址相關(guān),那么再加一個(gè)新的函數(shù),來看是否第1字節(jié)代表call指令。添加一個(gè)sub()函數(shù),其調(diào)用反匯編如下:
sub(2, 1); 004137BC 6a 01 push 1 004137BE 6a 02 push 2 004137C0 e8 25 da ff ff call 004111ea
可看到新的call指令的機(jī)器碼是e8 25 da ff ff。e8依然在第1字節(jié),后面4字節(jié)則變化了,因此可推定e8代表了call指令。下面看后4字節(jié)是否與跳轉(zhuǎn)地址相關(guān)。
00413766 e8 7a da ff ff call 004111e5
要跳轉(zhuǎn)去的地址是0x004111E5,而機(jī)器碼后4字節(jié)是7a da ff ff。這個(gè)數(shù)也與地址0x004111E5無關(guān)。根據(jù)對mov指令的經(jīng)驗(yàn),小端機(jī)內(nèi)存中的7a da ff ff代表的值就是0xffffda7a。(但這個(gè)似乎也不對。)我們需要分析可能的跳轉(zhuǎn)機(jī)制。生活中的例子能給我們最好的提示,程序不過是自然和社會(huì)中各種機(jī)制的一種模擬罷了。生活中要找一個(gè)地方有兩種方法:一是用經(jīng)緯度這種方式絕對定位出位置,二是用一種指路方式“從這里左拐,然后右拐(右轉(zhuǎn)彎),再左拐……”。這是用指路人位置作為坐標(biāo)進(jìn)行相對偏移。其實(shí),計(jì)算機(jī)尋址(定位某個(gè)地址)也有這兩種方式。絕對定位就是直接給出地址值,如mov指令中用全局變量地址直接賦值,相對定位就是用偏移量。那么,call機(jī)器碼中后4字節(jié)代表的0xffffda7a是否是偏移量呢?
從上面可知,call指令在0x00413766地址處,加上0xffffda7a應(yīng)該是一個(gè)非常大的數(shù),從“call 004111e5”指令給出的提示可知是要跳轉(zhuǎn)到0x4111e5,這個(gè)似乎也不對。偏移量是區(qū)分正負(fù)的(與往前還是往后跳轉(zhuǎn)相關(guān)),這個(gè)看上去很大的偏移量0xffffda7a是否是一個(gè)負(fù)數(shù)?這里需要用到一個(gè)基礎(chǔ)知識(shí):計(jì)算機(jī)中負(fù)整數(shù)的表示。
負(fù)整數(shù)的表示
補(bǔ)碼表示:即其正整數(shù)值求反加1。比如,-1就是1求反,它只有最后一位是0,其他位均為1,再加1,那么所有位均為1,即0xffffffff。對有符號(hào)數(shù),最高bit為1,則說明這它是一個(gè)負(fù)數(shù)。
如果對一個(gè)補(bǔ)碼形式的負(fù)數(shù)求其正數(shù)值,就是求反加1。比如,對-1求其正數(shù)值,就是對0xffffffff求反,即0,再加1,就是1。
0xffffda7a最高位為1,是負(fù)數(shù)。試將其求反加1:該值的二進(jìn)制表示是1111 1111 1111 1111 1101 1010 0111 1010;求反,為0000 0000 0000 0000 0010 0101 1000 0101;再加1,其十六進(jìn)制表示為0x2586。來看call指令,打開計(jì)算器做一個(gè)減法。

這個(gè)減法的結(jié)果是0x4111E0,理論上應(yīng)該是跳轉(zhuǎn)到的地址004111E5,如上面虛線箭頭指示的地址。可是兩者不相等。(還好,這兩個(gè)值已經(jīng)非常接近了,它們只差5字節(jié)。)這5字節(jié)是怎么回事?(我們需要思考、分析,不要放過任何一個(gè)理所當(dāng)然的要素。很多時(shí)候,分析程序有點(diǎn)像腦筋急轉(zhuǎn)彎,你忽略掉的理所當(dāng)然的因素恰恰是關(guān)鍵點(diǎn)。)在用相對偏移量尋址時(shí)是怎么計(jì)算的?是基址+偏移量。基址的選擇必須是call指令的地址嗎?是的,但并非必須。call指令有5字節(jié),而剛才的計(jì)算恰好差了5字節(jié)。這是巧合嗎?
如果我們嘗試將基址從call的起始處放到結(jié)束處來看,即call的下一條指令的起始地址來看新計(jì)算結(jié)果,那么call指令轉(zhuǎn)跳的地址是0x00413766+call指令長度5–偏移量0x2585 = 0x4111e5。0x4111e5就是call指令指示出來的跳轉(zhuǎn)地址。正確了。
call指令的偏移量尋址
x86系列CPU的call指令的尋址方式為:用與call指令相關(guān)的偏移量定位轉(zhuǎn)跳到的地址。
偏移量計(jì)算如下:偏移量 = 轉(zhuǎn)跳到的地址 -call指令后一條指令的起始地址。
1.3.2 返回故鄉(xiāng)的準(zhǔn)備
函數(shù)的特點(diǎn)是調(diào)用后會(huì)返回,那么如何得知這個(gè)返回地址?它是存儲(chǔ)在函數(shù)代碼中嗎?就像mov指令中的地址那樣?如果這樣,豈非只能返回到一個(gè)固定地址?因?yàn)榇a在運(yùn)行期是不變的,但函數(shù)可在不同地方被調(diào)用,這意味著可返回到不同地址。那么,函數(shù)返回地址是否可以像參數(shù)一樣傳遞給函數(shù)?
我們來看call指令除了跳轉(zhuǎn)外,還對系統(tǒng)施加了什么影響。首先,call指令如下:
00413796 e8 4a da ff ff call 004111e5 0041379b 83 c4 08 add esp, 8
call的返回地址是0x0041379b。call執(zhí)行后是否存儲(chǔ)了該地址?如果是,該地址存儲(chǔ)在何處?一般而言,數(shù)據(jù)或者存儲(chǔ)在內(nèi)存中,或者存儲(chǔ)在寄存器中。
如果返回地址存儲(chǔ)在內(nèi)存中,那么從哪里獲取該內(nèi)存地址?不外乎指令、內(nèi)存和寄存器。放在指令中已不可能,因?yàn)閏all指令的5字節(jié)只能放下偏移量。放到內(nèi)存更不行,要訪問該內(nèi)存,其地址又從何獲取?這回到了本欲解決的問題。只剩下從寄存器獲取。
如果返回地址存儲(chǔ)在寄存器中,那么call執(zhí)行后某個(gè)寄存器中就是返回地址。
綜合以上兩點(diǎn),某個(gè)寄存器中的值或者是返回地址,或者指向某內(nèi)存地址,它存儲(chǔ)了返回地址。(那就觀察寄存器的變化來實(shí)證吧。)在call指令處設(shè)置斷點(diǎn),按F11鍵,單步執(zhí)行一次,這時(shí)call執(zhí)行后變化的寄存器值在圖1.23中標(biāo)注出來。變化的寄存器有兩個(gè),它們是我們的關(guān)注點(diǎn)。EIP中存儲(chǔ)的是call跳轉(zhuǎn)到的指令的地址,從反匯編代碼窗體中可證明(見圖1.24),箭頭所指的位置就是EIP指示的地址0x004111e5。因此,EIP寄存器與我們的分析無關(guān)。

圖1.23

圖1.24
現(xiàn)在關(guān)注另一個(gè)變化的寄存器ESP,其值為0x00116e60。它或者是返回地址,或者是存放返回地址內(nèi)存的地址。顯然,它并非call指令的返回地址0x0041379b。那么,它指向內(nèi)存中存儲(chǔ)的值是什么?在內(nèi)存窗體中輸入地址0x00116e60,結(jié)果見圖1.25。根據(jù)小端機(jī)原則,內(nèi)存中9b 37 41 00代表的值是0x0041379b,這正是call指令返回地址。可知,call指令將返回地址保存在內(nèi)存中,而且ESP寄存器指向了該內(nèi)存。1.3.6節(jié)將討論該返回地址如何被使用。

圖1.25
棧(stack)
棧是一個(gè)先進(jìn)后出的數(shù)據(jù)結(jié)構(gòu),先壓棧的數(shù)據(jù)后出棧。出棧操作總是將當(dāng)前棧頂?shù)臄?shù)據(jù)彈出。如果當(dāng)前棧為空,壓棧2后,棧頂存儲(chǔ)的就是2,再壓棧4,那么棧頂存儲(chǔ)的就是4。執(zhí)行出棧操作,就會(huì)將棧頂?shù)?彈出,此時(shí)當(dāng)前棧頂存儲(chǔ)的就是2,如果再次執(zhí)行出棧操作,2將出棧。這就是先進(jìn)后出(詳細(xì)可參閱數(shù)據(jù)結(jié)構(gòu)的書籍)。
ESP寄存器(Extended Stack Pointer)存儲(chǔ)棧頂內(nèi)存地址。我們可在調(diào)試環(huán)境中查看寄存器窗體,獲取當(dāng)前的ESP值,然后將該值輸入到內(nèi)存窗體中(用十六進(jìn)制形式),即可查看棧中數(shù)據(jù)。
壓棧的指令是push,如“push 1”將1壓棧,這時(shí)ESP指向存儲(chǔ)1的內(nèi)存地址。再執(zhí)行“push 0c”,則0c將存入當(dāng)前棧頂,ESP將指向存儲(chǔ)0c的內(nèi)存地址。每次壓棧,32位機(jī)上都將用4字節(jié)存儲(chǔ)壓棧值。在x86中,push指令使ESP的值減4。換句話說,越壓棧,棧頂?shù)刂吩叫 S械腃PU中,壓棧是增加ESP的值。
出棧指令為“pop寄存器”。比如,“pop EAX”就是將當(dāng)前ESP指向的棧頂中存儲(chǔ)的值賦值給EAX,并且ESP的值加4。
call指令功能
call指令相當(dāng)于以下兩條指令的組合:
push 返回地址 jmp 函數(shù)入口地址
其中,返回地址通過第一步保存在棧上,函數(shù)返回時(shí)會(huì)用到。
1.3.3 給函數(shù)傳遞參數(shù)
函數(shù)需要參數(shù),現(xiàn)在來看參數(shù)是如何傳遞的。
還是將之前的語句“z = Add(1, 2);”進(jìn)行反匯編:
004139a2 6a 02 push 2 004139a4 6a 01 push 1 004139a6 e8 3a d8 ff ff call p (4111e5h) 004139ab 83 c4 08 add esp, 8
兩條push指令應(yīng)該是傳遞2和1兩個(gè)參數(shù),通過棧來傳遞。我們還是應(yīng)該實(shí)證。最簡單的方法就是將該語句改成“z=Add(2,1);”,調(diào)換參數(shù)的順序。再次反匯編,則先壓棧1后壓棧2。基本可證明這個(gè)猜測,甚至還推斷出C語言的參數(shù)傳遞是從右往左壓棧傳遞參數(shù)。
下面單步分析執(zhí)行這三條指令時(shí)棧的變化。在“push 2”處設(shè)置斷點(diǎn),在該斷點(diǎn)中斷,此時(shí)ESP的值是0x00116e6c,將該值輸入內(nèi)存窗體,然后用滾動(dòng)條往上滾動(dòng)一行,我們查看整個(gè)棧的情況,其結(jié)果見圖1.26。

圖1.26
單步執(zhí)行“push 2”,查看ESP的值為0x00116e68,比執(zhí)行前的0x00116e6c少了4字節(jié)(x86棧頂?shù)刂沸∮跅5祝瑝簵t使棧頂?shù)刂窚p小)。內(nèi)存窗體中顯示棧布局,見圖1.27。此時(shí)ESP指向的棧頂所存值如圖中黑線所示為2(小端機(jī)內(nèi)存值02 00 00 00代表2)。

圖1.27
再次單步執(zhí)行“push 1”指令,查看ESP中的值為0x00116e64,比執(zhí)行前的值0x00116e68也少了4字節(jié)。而內(nèi)存窗體中顯示棧布局見圖1.28。此時(shí)ESP指向的棧頂所存值如圖中黑線所示為1 (小端機(jī)內(nèi)存值01 00 00 00代表1)。

圖1.28
單步執(zhí)行call指令后,棧內(nèi)存見圖1.29,ESP又減少了4字節(jié),為0x00116e60,其指向的內(nèi)容ab 39 41 00正好是call指令的返回地址0x004139ab。(該地址見本節(jié)開始的反匯編,是call指令后的指令“004139ab add esp,8”的地址。)

圖1.29
由此我們驗(yàn)證了參數(shù)傳遞時(shí),棧頂?shù)刂凡粩鄿p小的過程,并查看到參數(shù)壓棧到棧內(nèi)存的布局。
1.3.4 函數(shù)獲取參數(shù)
被調(diào)函數(shù)如何獲取棧上1和2這兩個(gè)參數(shù)?我們先來看執(zhí)行了call指令后的棧映像,見圖1.30,反映了圖1.26~圖1.29的結(jié)果。

圖1.30
指令“push 2”執(zhí)行后,ESP指向存儲(chǔ)2的地址;指令“push 1”執(zhí)行后,ESP減4指向存儲(chǔ)1的地址,然后執(zhí)行call指令,返回地址被壓棧,ESP內(nèi)容再次減4,指向保存返回地址的內(nèi)存。
要獲取到棧上存儲(chǔ)的參數(shù)1和2,必須得到存儲(chǔ)1和2內(nèi)存的地址。如何得到它們?是將它們再次壓棧傳遞嗎?肯定不行。存儲(chǔ)地址的內(nèi)存的地址又怎樣獲取?我們觀察,1和2是連續(xù)存放的,只要獲取一個(gè)基點(diǎn),就能通過偏移量計(jì)算出1和2存儲(chǔ)的地址。從哪里獲取基點(diǎn)的地址?它似乎不應(yīng)該是保存在內(nèi)存中,否則該內(nèi)存的地址又無法獲取。在計(jì)算機(jī)中,除了內(nèi)存,就是寄存器用來存儲(chǔ)信息,如在圖1.30中,ESP寄存器指向當(dāng)前棧頂。圖中一槽內(nèi)存是4字節(jié),那么1的存儲(chǔ)地址就是esp+4,2的存儲(chǔ)地址就是esp+8。由于ESP將隨棧的變化而變化,所以為了計(jì)算簡便,可以將該值存儲(chǔ)到另一個(gè)寄存器,如EBP(Extended Base Pointer,擴(kuò)展基址指針寄存器)。如果將ESP存入EBP,那么剛才的兩個(gè)地址就是ebp+4和ebp+8。
不過由于EBP本身存儲(chǔ)了值,如果直接賦值修改它,當(dāng)EBP原值要使用時(shí),就沒有辦法了。比如,函數(shù)f1()調(diào)用函數(shù)f2(),如果f1()和f2()都用EBP存放基點(diǎn)地址,f2()后執(zhí)行,修改了之前f1()設(shè)定的EBP值。當(dāng)f2()返回時(shí),如不還原EBP,f1()用EBP做基點(diǎn)就要出錯(cuò)。因此,必須有一個(gè)保存和恢復(fù)EBP值的過程。下面代碼完成了這一過程,將EBP壓到棧上,最后用“pop ebp”指令將棧頂存儲(chǔ)的EBP值彈出來,賦值給EBP,從而還原。
push ebp ... pop ebp
因此可以猜想,在被調(diào)用的函數(shù)開始處存在如下代碼:
push ebp mov ebp, esp
它保存了EBP的值,并將ESP的值賦值給EBP。壓棧EBP后,ESP將再次減4字節(jié),見圖1.31。

圖1.31
此時(shí)用EBP來計(jì)算參數(shù)1和2的存儲(chǔ)地址。存儲(chǔ)1的地址是ebp+8,因?yàn)檫@時(shí)距離1存放的地方有EBP和返回地址,共8字節(jié)。存儲(chǔ)2的地址是ebp+0ch(即12)。現(xiàn)在反匯編Add()函數(shù),查看是否與我們分析的一樣。
驗(yàn)證1:見DM1-11。
DM1-11 int Add(int x, int y) { 00411430 push ebp 00411431 mov ebp, esp 00411433 sub esp, 0cch 00411439 push ebx 0041143a push esi 0041143b push edi 0041143c lea edi, [ebp+ffffff34h] 00411442 mov ecx, 33h 00411447 mov eax, 0cccccccch 0041144c rep stos dword ptr es:[edi] int sum; sum=x+y; 0041144e mov eax, dword ptr [ebp+8] 00411451 add eax, dword ptr [ebp+0ch] }
反匯編代碼中,黑體標(biāo)注的前2行正如我們分析的那樣。最后兩條指令(黑體標(biāo)注的)是“sum=x+y;”語句的對應(yīng)指令。mov指令將地址為ebp+8的內(nèi)存中存儲(chǔ)的值賦給EAX,從圖1.31看,ebp+8正好是存儲(chǔ)1的地址,符合之前分析,執(zhí)行后,EAX的值為1。其后的add指令將內(nèi)存地址為ebp+0ch中存的值,就是2,與EAX相加并將其和存回EAX。執(zhí)行完,該指令后EAX就是參數(shù)1與2的和3。一切如猜測。
驗(yàn)證2:在單步狀況下停在最后兩條指令處,用監(jiān)視窗體查看&x和&y,獲取兩個(gè)參數(shù)的地址;然后通過寄存器,將ebp+8和ebp+0ch的值求出,與監(jiān)視窗體中的&x和&y值比較,發(fā)現(xiàn)ebp+8、ebp+0ch確實(shí)是x和y的地址。
1.3.5 局部變量
局部變量在哪里分配?其特點(diǎn)與參數(shù)一樣,當(dāng)函數(shù)調(diào)用完畢就不再使用,所以仿效參數(shù),就是將其分配在棧上。棧上方已經(jīng)被參數(shù)等使用,我們只有使用棧更低地址的空間,也就是繼續(xù)壓棧分配局部變量,見圖1.32,即ebp–4指向的內(nèi)存。
反匯編,來看這個(gè)假設(shè)是否正確。
sum=x+y; 0041144e mov eax, dword ptr [ebp+8] 00411451 add eax, dword ptr [ebp+0ch] 00411454 mov dword ptr [ebp-8], eax
從前面分析已知,第1、2兩條指令將x和y求和,并放入EAX中。最后的指令將EAX的值存入ebp–8指向的內(nèi)存中(斷點(diǎn)后單步執(zhí)行到第3條指令,用監(jiān)視器查看&sum的值確實(shí)是ebp–8),并非ebp–4,見圖1.33。(請用內(nèi)存窗體和寄存器窗體單步跟蹤,查看這些相關(guān)內(nèi)存的變化。)奇怪的是,與猜測一致,在Visual C++ 6.0中,反匯編代碼如下:
00401038 mov eax, dword ptr [ebp+8] //獲取參數(shù)x 0040103b add eax, dword ptr [ebp+0ch] //獲取參數(shù)y,將x、y求和放入EAX 0040103e mov dword ptr [ebp-4], eax //將和存到局部變量sum,其地址是ebp-4
在VS 2008中,為了防止溢出攻擊,所以產(chǎn)生了該現(xiàn)象。詳細(xì)見習(xí)題4。
不過,至此我們應(yīng)該有一個(gè)簡單的結(jié)論:在使用了EBP尋址的函數(shù)中,ebp+偏移量就是參數(shù)的地址,ebp–偏移量就是局部變量的地址。
1.3.6 返回故鄉(xiāng)
函數(shù)執(zhí)行完畢就要返回調(diào)用的地方,從1.3.2節(jié)知道,call指令將其后指令的地址壓棧保存,我們要查看它如何用該地址返回。首先介紹返回指令ret。

圖1.32

圖1.33
ret指令:將棧頂保存的地址彈入指令寄存器EIP,相當(dāng)于“pop eip”,從而讓程序跳轉(zhuǎn)到該地址。執(zhí)行ret指令后,寄存器EIP(存儲(chǔ)了棧頂中保存的那個(gè)地址)和ESP(在32位x86中加4)的值有變化。
從ret的功能看,編譯器要保證執(zhí)行該命令時(shí),ESP正好指向call指令壓棧保存返回地址的那段內(nèi)存,我們跟蹤的代碼見DM1-13。push指令的壓棧順序是ebx → esi → edi,pop指令的出棧順序是edi → esi → ebx。正好體現(xiàn)了棧的先進(jìn)后出原則。push與pop之間的指令沒有影響ESP的值(可在0041143c指令處觀察ESP的值,并在0041145a處觀察ESP的值,兩者相同),所以這兩組指令完成了對EBX、ESI、EDI的值的保存和恢復(fù)。
DM1-13 { 00411430 push ebp 00411431 mov ebp, esp 00411433 sub esp, 0cch 00411439 push ebx 0041143a push esi 0041143b push edi 0041143c lea edi, [ebp+ffffff34h] 00411442 mov ecx, 33h 00411447 mov eax, 0cccccccch 0041144c rep stos dword ptr es:[edi] int sum; sum=x+y; 0041144e mov eax, dword ptr [ebp+8] 00411451 add eax, dword ptr [ebp+0ch] 00411454 mov dword ptr [ebp-8], eax return sum; 00411457 mov eax, dword ptr [ebp-8] } 0041145a pop edi 0041145b pop esi 0041145c pop ebx 0041145d mov esp,ebp 0041145f pop ebp 00411460 ret
最后3條斜體標(biāo)注的指令中,0041145d處的指令將EBP賦值給ESP。注意,函數(shù)執(zhí)行00411431處指令后,棧映像見圖1.33。當(dāng)執(zhí)行0041145d處指令時(shí),EBP未改變。圖1.34給出了執(zhí)行其前后的棧映像。
在執(zhí)行“mov esp, ebp”指令后,ESP正好指向保存EBP值的棧頂,因此其后的“pop ebp”指令彈出保存的EBP舊值給EBP,恢復(fù)EBP,使得ESP加4,指向保存返回地址的內(nèi)存,見圖1.35。這時(shí)執(zhí)行ret指令,正好將棧頂保存的返回地址彈給EIP,完成了返回。
我們用調(diào)試器來跟蹤觀察以上分析,完成實(shí)證過程。首先,call指令執(zhí)行完后,進(jìn)入Add()函數(shù)時(shí)程序的狀態(tài)見圖1.36,包括代碼窗體、內(nèi)存窗體和寄存器狀態(tài),call壓棧的返回地址為0x004139ab。
執(zhí)行“push edi”指令后的ESP的值見圖1.37。然后,在執(zhí)行“pop edi”前觀察此時(shí)ESP的值,與圖1.37中的0x00116D84相同,見圖1.38。
讀者可以自己跟蹤之前3條push指令壓棧到內(nèi)存中的值,然后配合ESP查看指向的內(nèi)存,查看pop指令恢復(fù)這些寄存器的過程,體驗(yàn)先進(jìn)后出的棧結(jié)構(gòu)在保存和恢復(fù)寄存器的值所起的作用。
下面省略“mov esp, ebp”和“pop ebp”兩條指令的觀察(讀者可仿效上面過程自己查看),直接在ret指令前停下,查看狀態(tài),見圖1.39。ESP恢復(fù)到了0x00116E60,與圖1.36執(zhí)行call指令后ESP的值一樣,說明這時(shí)棧已經(jīng)還原到剛剛進(jìn)入Add()函數(shù)時(shí)的狀態(tài)了,觀察ESP指向的內(nèi)存,圖1.39中正好是0x004139ab,即call指令壓棧的返回地址。這時(shí)執(zhí)行ret,就會(huì)將當(dāng)前棧頂(esp指向的內(nèi)存)保存的地址0x004139ab壓入EIP,并將esp加4,見圖1.40。

圖1.34

圖1.35

圖1.36

圖1.37

圖1.38

圖1.39

圖1.40
1.3.7 返回點(diǎn)什么
函數(shù)可有返回值,那么如何返回它們?通過寄存器。
返回小于4字節(jié)的數(shù)
函數(shù)返回值:編譯器習(xí)慣上用eax作為存儲(chǔ)返回值的寄存器,被調(diào)用方在ret前設(shè)定eax,返回后,調(diào)用方從eax獲取到該值。
例如,對于代碼DM1-14,第1條mov指令將ebp–8指向的內(nèi)存(即sum變量的內(nèi)存,見1.3.5節(jié))中存儲(chǔ)的值賦給了EAX,之后的指令不修改EAX的值(在寄存器窗體中查看該狀況)。
DM1-14 return sum; 00411457 mov eax, dword ptr [ebp-8] } 0041145a pop edi 0041145b pop esi 0041145c pop ebx 0041145d mov esp, ebp 0041145f pop ebp 00411460 ret
調(diào)用方代碼如下:
z = Add(1, 2); 004139a2 push 2 004139a4 push 1 004139a6 call 004111e5 004139ab add esp, 8 004139ae mov dword ptr [ebp-8], eax
call指令返回后,最后一條指令將EAX的值賦值給了ebp–8指向的內(nèi)存。之前我們已經(jīng)有這樣的概念,ebp減一個(gè)偏移量就是局部變量的地址,該指令說明它將返回值賦給了一個(gè)局部變量。如果在該指令設(shè)置斷點(diǎn),查看ebp–8的值,并在監(jiān)視窗體中查看&z,就會(huì)發(fā)現(xiàn)兩者的值相同。
返回結(jié)構(gòu)體
對于返回值的傳遞大家還有問題嗎?似乎一切都很好,但仔細(xì)分析,EAX只能返回長度小于等于4字節(jié)的數(shù)據(jù),如果返回值大于4字節(jié)怎么辦?double類型(8字節(jié))、用戶自定義數(shù)據(jù)類型——結(jié)構(gòu)體(有多少成員就有多大)的數(shù)據(jù)大于4字節(jié)。對于這些“過大”的數(shù)據(jù)怎樣返回?(還是先猜測。)再大的數(shù)據(jù),只要拿到地址就能訪問。那么,是傳給函數(shù)這個(gè)“大”數(shù)據(jù)的地址由函數(shù)去填寫其內(nèi)容,還是在函數(shù)中分配這個(gè)“大”數(shù)據(jù)的內(nèi)存并返回其指針呢?如果是后者,會(huì)存在一個(gè)問題:該數(shù)據(jù)在何處分配?因?yàn)檫@個(gè)數(shù)據(jù)與局部變量一樣只在函數(shù)中有效,自然會(huì)在棧上分配。可是當(dāng)函數(shù)返回后,函數(shù)分配的內(nèi)存“邏輯上”無效了,用“無效”指針訪問于理不通,見圖1.41。

圖1.41
如果是調(diào)用者先分配一塊內(nèi)存,然后將其指針傳入給函數(shù)由它寫入返回值,那么被調(diào)函數(shù)返回時(shí)該內(nèi)存并不失效,似乎可行,見圖1.42。其中保存返回值的內(nèi)存在棧的上部分配,其地址為0x12ff58,然后該地址作為一個(gè)參數(shù)被壓棧傳遞給被調(diào)用函數(shù)。

圖1.42
有了猜測,我們需要用實(shí)驗(yàn)來證明,用結(jié)構(gòu)體來驗(yàn)證。需要用到結(jié)構(gòu)體,請大家先參照1.4節(jié)理解結(jié)構(gòu)體的相關(guān)知識(shí)。(在學(xué)習(xí)中經(jīng)常會(huì)遇到這樣的狀態(tài),在研究一個(gè)知識(shí)點(diǎn)時(shí),發(fā)現(xiàn)必須先了解另外一個(gè)知識(shí)點(diǎn),因此需要先去解決它,然后回頭繼續(xù)當(dāng)前問題。某些時(shí)候,這樣的跳轉(zhuǎn)會(huì)發(fā)生多次,只要是在我們能力之內(nèi),跳轉(zhuǎn)下去直到把一條知識(shí)線索都搞通也是一種學(xué)習(xí)方法,并非一定要所謂的“系統(tǒng)化”。)
相關(guān)代碼見DM1-15。在main()函數(shù)的“r=myfunc();”處設(shè)置斷點(diǎn),執(zhí)行并反匯編如下:
DM1-15 struct myrd{ int i1; int i2; }; myrd myfunc(){ myrd r1; r1.i1 = 1; r1.i2 = 2; return r1; }; void main(int count, char** args){ myrd r; r = myfunc(); } myrd r; r = myfunc(); 0040120E call 00401180 ...
在call之前并沒有如想象那樣傳入保存返回值內(nèi)存的地址。(怎么回事?)在call指令處跟蹤進(jìn)函數(shù)(step into):
myrd r1; r1.i1 = 1; 0040119e mov dword ptr [ebp-0ch], 1 r1.i2 = 2; 004011a5 mov dword ptr [ebp-8], 2 return r1; 004011ac mov eax, dword ptr [ebp-0ch] 004011af mov edx, dword ptr [ebp-8] … ret
0040119e和004011a5處的兩條指令將1和2賦值給了r1的兩個(gè)成員變量,而004011ac和004011af處的兩條指令又將r1的兩個(gè)成員變量賦值給EAX和EDX。難道要返回的8字節(jié)的結(jié)構(gòu)體是通過EAX和EDX返回的嗎?如果是,在ret指令執(zhí)行時(shí),這兩個(gè)寄存器應(yīng)該是1和2。我們在之后的ret指令處設(shè)置斷點(diǎn),發(fā)現(xiàn)運(yùn)行到ret時(shí),EAX和EDX的值確實(shí)如此。
下面來檢查調(diào)用方的代碼,見DM1-16。
DM1-16 myrd r; r = myfunc(); 0040120e call 00401180 00401213 mov dword ptr [ebp+ffffff24h], eax 00401219 mov dword ptr [ebp+ffffff28h], edx 0040121f mov eax, dword ptr [ebp+ffffff24h] 00401225 mov dword ptr [ebp-0ch], eax 00401228 mov ecx, dword ptr [ebp+ffffff28h] 0040122e mov dword ptr [ebp-8], ecx
call指令后的兩條指令將EAX和EDX的值分別賦給了地址為ebp+FFFFFF24h和ebp+FFFFFF28h的內(nèi)存,確實(shí)是EAX和EDX用于返回結(jié)構(gòu)體的值。剩下要證明的就是這個(gè)返回值賦給了變量r。我們發(fā)現(xiàn),EAX的值最終賦值給了ebp–0ch,而edx最終賦值給了ebp–8,自然猜想ebp–0ch這個(gè)更小的地址應(yīng)該是結(jié)構(gòu)體myrd中靠前定義的成員變量i1的地址,也就是結(jié)構(gòu)體r的首部地址。在監(jiān)視窗體中輸入如下表達(dá)式驗(yàn)證猜想,見圖1.43,可知r的地址和ebp–0ch是一個(gè)值。(在表達(dá)式中用(void *)強(qiáng)制是為了用十六進(jìn)制顯示。)同理,請讀者自己證明ebp–8就是r.i2的地址。
ebp+ffffff24h和ebp+ffffff28h兩個(gè)地址的內(nèi)存似乎被用來中轉(zhuǎn),EAX和EDX保存的8字節(jié)返回值先賦給這兩個(gè)地址,然后從它們中讀出,賦值給變量r。那么,這塊內(nèi)存在哪里?它們有何用?我們先將上面兩個(gè)地址轉(zhuǎn)換成容易讀懂的東西,最高位為1就是負(fù)數(shù),所以這兩個(gè)地址其實(shí)可以轉(zhuǎn)換為“ebp–xxx”的形式。將ffffff24h和ffffff28h求反加1,獲得其正值,分別為dch和d8h。這兩塊內(nèi)存的地址就是ebp–dch和ebp–d8h。它們明顯小于r的地址ebp–0ch,可知這塊內(nèi)存位于局部變量r的下方,似乎是一個(gè)臨時(shí)分配存儲(chǔ)返回值的緩沖區(qū)。
我們不解的是,為何不直接將EDX和EAX中的返回值直接賦值給r?我們用的是Debug版,代碼沒有優(yōu)化。這恰恰證明了之前的猜想:調(diào)用方分配一塊緩沖存儲(chǔ)返回值。優(yōu)化的Release版本會(huì)用這個(gè)中轉(zhuǎn)緩沖區(qū)嗎?選擇調(diào)試界面中的Release菜單(在VC 6.0中,調(diào)試Release需要專門設(shè)置,VS 2008不需要),見圖1.44,反匯編的代碼見DM1-17。

圖1.43

圖1.44
我們驚訝地發(fā)現(xiàn),call之后那些賦值語句全部消失了!為什么將返回值賦值給r的邏輯給“優(yōu)化”掉了?(優(yōu)化的一個(gè)原則是看有用否。)r是main()的局部變量,而main()并未使用該局部變量,當(dāng)然不用賦值。那么,只要讓編譯器認(rèn)為變量r有用,它就不會(huì)被優(yōu)化掉。如果將r定義為全局變量,要判斷r是否有用就必須分析所有代碼。(因?yàn)槿肿兞靠杀凰写a使用)。為了提高編譯速度,編譯器會(huì)“偷懶”,凡是對全局變量的操作會(huì)認(rèn)為有用,不優(yōu)化。
DM1-17 myrd r; r = myfunc(); getchar(); 00401085 call 00401474 } 0040108a xor eax, eax
代碼見DM1-18。果然,在call指令執(zhí)行后,EAX和EDX直接賦值給了變量r。用監(jiān)視窗體證明0041c000h是r的地址即可。
DM1-18 myrd r; void main(){ r = myfunc(); 00401085 call 0040107e 0040108a mov dword ptr ds:[0041c000h], eax 0040108f mov dword ptr ds:[0041c004h], edx getchar();
到此猜想并沒有直接被證實(shí)。如果用EAX、EDX來傳遞返回值,結(jié)構(gòu)體很大,寄存器不夠用時(shí)怎樣處理呢?是否與猜想一致呢?現(xiàn)在我們要做的就是將結(jié)構(gòu)體變大,多定義一些成員變量。
struct myrd { int i1; int i2; int i3; }; myrd myfunc() { myrd r1; r1.i1 = 1; r1.i2 = 2; r1.i3 = 3; return r1; }; void main(int count, char** args) { myrd r; r = myfunc(); getchar(); }
反匯編見DM1-19。本來myfunc沒有參數(shù),但是call指令之前的“push eax”指令向函數(shù)傳遞了一個(gè)參數(shù)。(這似乎印證了我們的猜想。)該參數(shù)是之前保存返回值的那個(gè)臨時(shí)變量的地址嗎?
DM1-19 myrd r; r = myfunc(); 0040121e lea eax, [ebp+ffffff1ch] 00401224 push eax 00401225 call 00401180 0040122a add esp, 4 0040122d mov ecx, dword ptr [eax] 0040122f mov dword ptr [ebp+ffffff08h], ecx 00401235 mov edx, dword ptr [eax+4] 00401238 mov dword ptr [ebp+ffffff0ch], edx 0040123e mov eax, dword ptr [eax+8] 00401241 mov dword ptr [ebp+ffffff10h], eax 00401247 mov ecx, dword ptr [ebp+ffffff08h] 0040124d mov dword ptr [ebp-10h], ecx 00401250 mov edx, dword ptr [ebp+ffffff0ch] 00401256 mov dword ptr [ebp-0ch], edx 00401259 mov eax, dword ptr [ebp+ffffff10h]
lea指令:lea eax, [ebp + 10h]。其邏輯為eax=ebp + 10h。如執(zhí)行該指令時(shí),ebp為3,執(zhí)行后,eax為13h。
我們可以跟蹤進(jìn)函數(shù)。先記錄這個(gè)壓棧的值,即EAX的值0x0012fe88。函數(shù)myfunc()的反匯編如下:
myrd r1; r1.i1 = 1; 0040119e mov dword ptr [ebp-10h], 1 r1.i2 = 2; 004011a5 mov dword ptr [ebp-0ch], 2 r1.i3 = 3; 004011ac mov dword ptr [ebp-8], 3 return r1; 004011b3 mov eax, dword ptr [ebp+8] 004011b6 mov ecx, dword ptr [ebp-10h] 004011b9 mov dword ptr [eax], ecx 004011bb mov edx,dword ptr [ebp-0ch] 004011be mov dword ptr [eax+4], edx 004011c1 mov ecx, dword ptr [ebp-8] 004011c4 mov dword ptr [eax+8], ecx 004011c7 mov eax, dword ptr [ebp+8]
根據(jù)1.3.4節(jié),該參數(shù)的地址應(yīng)該是ebp+8。我們要找到哪條指令用了該地址,004011b3處的語句正是將ebp+8中存的值賦給了EAX。ebp+8中的值正好是0x0012fe88,即call指令前壓棧的EAX的值,見圖1.45。*(void **) (ebp+8)將地址ebp+8強(qiáng)制轉(zhuǎn)為指針的指針(void **),它指向的值被顯示。

圖1.45
“mov eax, dword ptr [ebp+8]”指令使EAX存放了函數(shù)調(diào)用前分配的那個(gè)臨時(shí)變量的地址,那么,我們可將注意力集中在和EAX相關(guān)的后續(xù)指令:
mov dword ptr [eax].. mov dword ptr [eax+4].. mov dword ptr [eax+8]..
這正好是對結(jié)構(gòu)體3個(gè)成員變量的賦值。i1在開始處,所以其地址就是eax,i2離首部4字節(jié)(對應(yīng)eax+4),i3離首部8字節(jié)(對應(yīng)eax+8)。再分析代碼并簡單運(yùn)用調(diào)試工具分析,上面三條賦值指令確實(shí)是將局部變量r1的3個(gè)字段賦值給調(diào)用方為保存返回值分配的臨時(shí)內(nèi)存。代碼中的最后一條指令“004011c7 mov eax, dword ptr [ebp+8]”再次將傳入的保存返回值的內(nèi)存的地址(在內(nèi)存ebp+8中),賦值給EAX。直到ret執(zhí)行前,該EAX的值一直沒有變化,那么,函數(shù)返回后, EAX指向的就是保存返回值的臨時(shí)變量的地址。
下面分析返回后r=myfunc(1)的機(jī)理。call指令返回后的代碼如下:
0040122d mov ecx, dword ptr [eax] 0040122f mov dword ptr [ebp+ffffff08h], ecx //為臨時(shí)內(nèi)存2.i1賦值 00401235 mov edx, dword ptr [eax+4] 00401238 mov dword ptr [ebp+ffffff0ch], edx //為臨時(shí)內(nèi)存2.i2賦值 0040123e mov eax, dword ptr [eax+8]
其中黑體標(biāo)注的3條指令分別從eax偏移0、4、8字節(jié)的地址獲取值,而此時(shí)eax指向保存返回值的結(jié)構(gòu)體的首部,所以這3個(gè)偏移量正好是成員變量i1、i2、i3的地址。可以驗(yàn)證,它們之后的mov指令的目標(biāo)地址(即ebp+ffffff08h等)不是指向變量r的相關(guān)成員。而在這些代碼后還有一堆mov指令,可以證明ebp–10h就是變量r的地址,這些賦值命令將最終為變量r賦值。
0040124d mov dword ptr [ebp-10h], ecx //為r.i1賦值 00401250 mov edx, dword ptr [ebp+ffffff0ch] 00401256 mov dword ptr [ebp-0ch], edx //為r.i2賦值 00401259 mov eax, dword ptr [ebp+ffffff10h] 0040125f mov dword ptr [ebp-8], eax //為r.i3賦值
我們可得到內(nèi)存映像以及與指令的對應(yīng)關(guān)系,見圖1.46。

圖1.46
但是,返回值先賦值給臨時(shí)內(nèi)存2,再從臨時(shí)變量2賦值給r。這樣的中轉(zhuǎn)意義何在?我們可以仿效前面全局變量的Release版本來看它的反匯編。將main()函數(shù)中的r變?yōu)槿肿兞浚磪R編如下:
r = myfunc(); 0040109b lea eax, [esp+4] 0040109f push edi 004010a0 push eax 004010a1 call 0040107e 004010a6 mov esi, eax 004010a8 mov edi, 41c000h 004010ad movs dword ptr es:[edi], dword ptr [esi] 004010ae movs dword ptr es:[edi], dword ptr [esi] 004010af add esp, 4 004010b2 movs dword ptr es:[edi], dword ptr [esi]
從call之前的“push eax”指令來看,應(yīng)該是eax指向保存返回值的內(nèi)存。然后將eax賦值給esi,更可推斷函數(shù)通過eax將該內(nèi)存的地址再次返回。而之后出現(xiàn)了3條奇怪的“movs dword ptr es:[edi], dword ptr[esi]”指令,幾乎可以推斷出是為結(jié)構(gòu)體的3個(gè)成員變量賦值,esi既然指向返回值內(nèi)存的地址,那么edi指向的應(yīng)該是全局變量r的地址。這3條一模一樣的指令完成了3個(gè)不同內(nèi)存的賦值,可以猜想該指令執(zhí)行完后會(huì)將源地址esi和目標(biāo)地址edi的值分別加4。猜測完畢,剩下的就是實(shí)證我們的猜測:edi是否是r的地址,esi是否是返回值的地址,movs指令是否按我們的猜想執(zhí)行?如果這樣,我們在Release版本中看到了更清晰的“大數(shù)據(jù)”返回的算法。實(shí)證的過程留給大家來做。
1.3.8 掃尾工作
在學(xué)習(xí)過程中,我們應(yīng)該關(guān)注每個(gè)細(xì)節(jié),如果不能馬上明白,也要記下來將來分析。在1.3.7節(jié)中,返回代碼如下:
z = Add(1, 2); 004139a2 push 2 004139a4 push 1 004139a6 call 004111e5 004139ab add esp, 8 004139ae mov dword ptr [ebp-8], eax
調(diào)用返回后,在call指令下有一條黑體標(biāo)注的指令,將棧加了8字節(jié)。這條指令與返回值沒有關(guān)系,那它有何用?我們知道,棧是保存參數(shù)、返回地址、局部變量的,那么函數(shù)調(diào)用完畢后,這些參數(shù)、返回值還有用嗎?當(dāng)然無用。如果它們還消耗棧的空間,將發(fā)生什么情況?很明顯,隨著每次函數(shù)調(diào)用,棧空間將不斷減少直至耗盡棧內(nèi)存,即棧溢出(stack overflow)。請大家寫一段簡單的代碼,讓棧溢出(提示:遞歸)。局部變量在被調(diào)用函數(shù)中釋放了,保存返回地址消耗的棧被ret指令彈出也釋放了。那么參數(shù)呢?“push 1”和“push 2”傳參消耗了8字節(jié),我們應(yīng)該恢復(fù)它。棧結(jié)構(gòu)是如何釋放內(nèi)存的?壓棧,棧頂?shù)刂纷冃。瑥棗#瑮m數(shù)刂纷兇蟆_@時(shí),原來壓棧使用的內(nèi)存即可被再次壓棧,存入新內(nèi)容。所以我們只需將壓棧減去的8字節(jié)加回來就可以了。“add esp, 8”指令正好來完成這個(gè)工作。大家還可以參考1.3.7節(jié)的例子,保存返回值的內(nèi)存地址作為參數(shù)傳遞給函數(shù)了,一個(gè)push指令消耗了4字節(jié)的棧,那么call返回后應(yīng)該有“add esp, 4”這樣的代碼來清理平衡棧,如下面黑體標(biāo)注的指令:
r = myfunc(); 0040121e lea eax, [ebp+ffffff1ch] 00401224 push eax 00401225 call 00401180 0040122a add esp, 4
大家不妨將參數(shù)的個(gè)數(shù)增加,來看看“add esp, xxx”指令中xxx的變化。
一切似乎到此就很清楚了,我們來看如下代碼:
Add(1, 2); Add(4, 5); Add(100, 1); …
大家想想其反匯編代碼(見DM1-20),覺得其中有什么問題?
DM1-20 push 2 push 1 call .. add esp, 8 push 5 push 4 call ... add esp, 8 push 1 push 100 call ... add esp, 8 …
每個(gè)Add()函數(shù)調(diào)用后都要執(zhí)行相同的指令“add esp, 8”。這會(huì)帶來什么影響?100次調(diào)用就有100條add指令。而這100條add指令完全是重復(fù)的!什么辦法可防止代碼重復(fù)產(chǎn)生的空間開銷?那就是將重復(fù)代碼變成函數(shù)。如果我們將“add esp, 8”變成Add()函數(shù)的一部分,調(diào)用返回后則不需“add esp, 8”指令(因?yàn)楹瘮?shù)中已經(jīng)執(zhí)行了該指令)。100次調(diào)用可節(jié)省99條add指令。如何來做?是否可將“add esp, 8”直接放入Add()函數(shù)中?我們要養(yǎng)成一個(gè)好習(xí)慣,不要只想到“可放入”就不管了,一定要想如何放入。(在“猜測,實(shí)證”這種方法中,猜測必須到細(xì)節(jié),細(xì)節(jié)決定成敗!這樣你才會(huì)走得更深入,發(fā)現(xiàn)更多問題和有意思的東西。)
不妨做一個(gè)理想實(shí)驗(yàn),我們將指令放在ret之后:
ret add esp, 8
這明顯無意義,執(zhí)行ret后下面指令是無法執(zhí)行的。那么,放在ret前?
add esp, 8 ret
一下也看不出端倪,繼續(xù)關(guān)注細(xì)節(jié)。虛擬出棧布局,分析指令執(zhí)行過程,圖1.47為調(diào)用Add(1, 2)時(shí)的執(zhí)行棧。

圖1.47
執(zhí)行“add esp, 8”指令之前,ESP應(yīng)指向存返回地址的內(nèi)存,那么執(zhí)行后ESP將指向存儲(chǔ)參數(shù)2的地址。再執(zhí)行ret命令,2將壓入EIP,程序?qū)?zhí)行地址為2的內(nèi)存中存儲(chǔ)的東西,自然不對。這里的關(guān)鍵是“add esp, 8”必須在ret后執(zhí)行,因?yàn)閞et要彈出的返回地址在參數(shù)2和1的下方,只有彈出了下方的返回地址,才能以“add esp, 8”平衡棧。而之后執(zhí)行已經(jīng)被證明是不行的。所以,我們并沒有辦法像重用代碼那樣,將“add esp, 8”指令加入到被調(diào)用函數(shù)中!平常大家都覺得難以發(fā)現(xiàn)問題,很多時(shí)候老老實(shí)實(shí)地將每個(gè)想法想清楚“怎么做”,就能發(fā)現(xiàn)問題。現(xiàn)在問題出來了,要重用代碼就要將“add esp, 8”的功能加入到被調(diào)用函數(shù)中,而執(zhí)行順序和棧布局又妨礙我們這樣做(需常注意ret和棧的關(guān)系),該怎么辦?
如果一個(gè)東西是合理的,我們就需要有支持它的機(jī)制。這里的關(guān)鍵是ret后執(zhí)行棧頂增加的邏輯,兩個(gè)動(dòng)作無法分開來做,那么就需要新指令,支持返回和增加棧頂二合一的邏輯。
ret x
ret字節(jié)數(shù):該指令將彈出棧頂?shù)闹底鳛榉祷刂担⒃趶棾龊髮sp值加字節(jié)數(shù)。相當(dāng)于以下指令二合一:ret和add esp,字節(jié)數(shù)。
我們來做一個(gè)實(shí)驗(yàn),在Add()函數(shù)的名字前、返回類型后加上關(guān)鍵字_stdcall,代碼如下:
int _stdcall Add(int x, int y){ int sum; sum=x+y; return sum; }
下面來看被調(diào)用函數(shù)和調(diào)用者的相關(guān)反匯編。首先是Add()函數(shù)的,見DM1-21。最后一條指令表明返回后esp要加8字節(jié)。
DM1-21 return sum; 00411457 mov eax, dword ptr[ebp-8] } 0041145a pop edi 0041145b pop esi 0041145c pop ebx 0041145d mov esp, ebp 0041145f pop ebp 00411460 ret 8
再來看調(diào)用者的反匯編:
004139a2 push 2 004139a4 push 1 004139a6 call 004111f4 004139ab mov dword ptr [ebp-8], eax printf("z=%d\n",z); ...
call指令返回后,只有賦值的mov指令,之前的“add esp, 8”指令消失了,因?yàn)闂m斣贏dd()函數(shù)中通過最后的“ret 8”指令得以增加。
現(xiàn)在我們不禁要問:什么原因?qū)е翪語言選擇了調(diào)用方清理?xiàng)_@種耗費(fèi)代碼空間的做法?獲得了什么好處?從性能上分析,不過是時(shí)間和空間開銷。空間上已經(jīng)開銷大了,難道時(shí)間上更快?兩條指令(ret和add esp, 8)會(huì)比一條指令(ret 8)更快?似乎也不像。除性能還有什么?功能。C語言支持一種變參函數(shù),即函數(shù)參數(shù)個(gè)數(shù)不確定,如我們最早接觸過的函數(shù)printf(),根據(jù)字符串參數(shù)中格式化符號(hào)的不同,參數(shù)個(gè)數(shù)可不同(如printf(“%d\n”, i)與printf(“%s is %d”, “this”, i))。ret x方式不支持變參函數(shù)嗎?在編譯器中,在一個(gè)函數(shù)最后生成ret代碼時(shí),根據(jù)參數(shù)的不同,它生成的指令是固定的,如Add(1, 2)生成“ret 8”,sum(1, 2, 3)生成“ret 0c”。代碼一經(jīng)生成就固定了,“ret x”指令運(yùn)行期中不可變,決定了其參數(shù)只能是固定值。否則,如果調(diào)用Add(1, 2, 3),豈非ret指令要從“ret 8”變?yōu)椤皉et 0c”?這是不可能的。世間萬物都是有代價(jià)的,功能、性能、空間、時(shí)間,難以兩全。為了靈活性,C語言舍棄了一點(diǎn)點(diǎn)空間性能。
現(xiàn)在我們知道,掃尾工作(平衡棧/清棧)有兩種方式:① 調(diào)用方清棧,call返回后,執(zhí)行“add esp, x”指令;② 被調(diào)用方清棧,執(zhí)行“ret x”指令。前者用空間代價(jià)換取了變參功能。
1.3.9 調(diào)用慣例
我們來回顧函數(shù)調(diào)用的環(huán)節(jié):傳參、清棧。清棧有兩種選擇,傳參呢?前面參數(shù)通過棧傳遞,其實(shí)從寄存器也可傳遞,就如同返回值通過寄存器傳遞。寄存器傳遞不需要尋址內(nèi)存空間,直接操控CPU中的寄存器速度快。但寄存器數(shù)目有限,特別是可自由使用的非常少,所以難以應(yīng)對大量參數(shù)的傳遞。
我們來看VC中用寄存器傳遞參數(shù)的例子。將Add()函數(shù)申明為_fastcall,其實(shí)從名字上也透露了寄存器傳參的特點(diǎn):fast(快速)。
int _fastcall Add(int x,int y){ int sum; sum=x+y; return sum; }
對應(yīng)的調(diào)用方代碼如下:
z = Add(1, 2); 004139A2 mov edx,2 004139A7 mov ecx,1 004139AC call 004111F9
我們發(fā)現(xiàn),2和1通過寄存器edx和ecx分別傳遞。如果參數(shù)多了,往往編譯器會(huì)將前兩個(gè)參數(shù)用寄存器傳遞,后面的用壓棧傳遞。比如,將Add()函數(shù)變?yōu)橛?個(gè)參數(shù),我們看到,第3個(gè)參數(shù)實(shí)際是push壓棧傳遞的。
z = Add(1, 2, 3); 004139A2 push 3 004139A4 mov edx,2 004139A9 mov ecx,1 004139AE call 004111FE
另外,即使棧傳遞參數(shù),先壓誰后壓誰也是個(gè)問題,通過觀察已知,C語言的參數(shù)是從右往左壓棧。至于從右往左還是左往右,不同語言有自己的選擇,原因見習(xí)題16。函數(shù)調(diào)用的習(xí)慣如下:
⊙ 是寄存器還是棧傳遞參數(shù)?
⊙ 棧傳遞時(shí),參數(shù)是從右往左還是從左往右壓棧?
⊙ 誰來清棧,是調(diào)用方還是被調(diào)用方?
而這幾點(diǎn)的組合就是調(diào)用的習(xí)慣,即調(diào)用慣例(calling convention)。C語言的調(diào)用方式是棧傳參,參數(shù)從右往左壓棧,調(diào)用方清棧。Pascal是棧傳參,參數(shù)從左往右壓棧,被調(diào)用方清棧。_stdcall是微軟系統(tǒng)調(diào)用采用的慣例,除清棧是被調(diào)用方外,其他同C語言方式。_fastcall是寄存器傳遞,不同語言在選擇寄存器時(shí)有所不同,沒有統(tǒng)一的規(guī)定。調(diào)用慣例非常重要,它涉及調(diào)用方和被調(diào)用方的約定,涉及它們能否正常溝通,在第2章中我們可以看到這個(gè)要點(diǎn)在跨語種編程中的重要影響。
1.3.10 函數(shù)指針
這時(shí),我們可接觸C語言中第2種重要指針——函數(shù)指針了。從使用角度出發(fā),仿照數(shù)據(jù)指針的使用邏輯,可推出函數(shù)指針的內(nèi)涵:數(shù)據(jù)指針是用來訪問數(shù)據(jù)的,所以包含指向的內(nèi)存地址;同時(shí)因?yàn)樵L問的長度需要確定,所以指針類型決定了從該地址訪問多少字節(jié)。
函數(shù)指針是用來調(diào)用函數(shù)的,所以要包含函數(shù)代碼的入口地址。函數(shù)指針有類型嗎?調(diào)用函數(shù)并非只需要入口地址,還需要說明函數(shù)的參數(shù)表、返回類型、調(diào)用慣例,這樣編譯器才能生成正確的調(diào)用代碼(如push多少參數(shù)、是否調(diào)用方清棧等),這三點(diǎn)合在一起就是函數(shù)原型。因此指針類型就是函數(shù)原型。所以,函數(shù)指針包含入口地址和函數(shù)原型兩方面的信息。
比如,對于int Add(int i, int b)函數(shù),我們怎樣描述其指針?首先,隨便定義一個(gè)名字如pfunc,然后在其左邊加上“*”代表指針,即“* pfunc;”,接著需要描述其函數(shù)原型。先來描述其參數(shù)表(兩個(gè)整數(shù)參數(shù)):
* pfunc(int, int);
然后描述其返回類型:
int * pfunc(int, int);
問題:如果返回類型是int *怎么辦?難道寫成“int ** pfunc(int, int);”?這樣表示無法與返回值類型int **區(qū)分,因此我們表示函數(shù)Add()的函數(shù)指針類型如下:
int (*pfunc)(int, int);
最后,如果調(diào)用慣例是C方式,則完畢,否則需要將其標(biāo)識(shí)出來,如_stdcall。例如,下面定義了一個(gè)函數(shù)指針變量pfunc:
int (_stdcall *pfunc)(int, int);
然后可將函數(shù)原型匹配該指針類型的函數(shù)的入口地址賦值給它。這時(shí)用函數(shù)的名字代表入口地址,或在其前加“&”:
pfunc = Add; 或 pfunc = &Add;
當(dāng)函數(shù)指針指向一個(gè)函數(shù)后,我們就能用函數(shù)指針訪問該函數(shù):
int sum = pfunc(1, 2);
反匯編代碼見DM1-22。代碼標(biāo)注的第2行、第4行分別是函數(shù)指針賦值的兩種表示法,都是將函數(shù)Add()入口地址401000h賦給變量pfunc。
DM1-22 int (*pfunc)(int, int); int Add(int a, int b){ return a + b; } void main(){ push ebp mov ebp, esp sub esp, 8 1 pfunc = Add; 2 mov dword ptr ds:[00403374h],401000h 3 pfunc = &Add; 4 mov dword ptr ds:[00403374h],401000h 5 pfunc(1, 2); 6 push 2 7 push 1 8 call dword ptr ds:[00403374h] 9 add esp,8 10 Add(1, 2); 11 push 2 12 push 1 13 call 00401000 14 add esp, 8 }
第6~9行和第11~14行分別是用函數(shù)指針和函數(shù)名兩種方式調(diào)用的反匯編,通過比較發(fā)現(xiàn),除了call指令,后者是用偏移量跳轉(zhuǎn),其他相同,執(zhí)行結(jié)果也相同。
函數(shù)指針賦值的原則是:只能將與指針原型匹配的函數(shù)的入口地址賦值給它。
例如,如果將Add()變成int Add(int a, int b, int c),Add()還可賦給pfunc嗎?答案是“否”,因?yàn)閮烧叩暮瘮?shù)原型不同(參數(shù)表不同)。
如果將Add()改成float Add(int a, int b),Add()可賦給pfunc嗎?答案是“否”,因?yàn)閮烧叩暮瘮?shù)原型不同(返回類型不同)。
如果將Add()改成int _stdcall Add(int a, int b),Add()可賦給pfunc嗎?答案是“否”,因?yàn)閮烧叩暮瘮?shù)原型不同(調(diào)用慣例不同)。
我們就最后一種情況,分析為什么不可以賦值,其他情況自行分析。假定能夠賦值將是怎樣的狀況?分析之前的反匯編碼:
5 pfunc(1, 2); 6 push 2 7 push 1 8 call dword ptr ds:[00403374h] 9 add esp, 8
第9行的指令將壓棧傳參的8字節(jié)清棧。如果Add()是_stdcall,那么在函數(shù)中已通過“ret 8”清棧,這里再次清棧8字節(jié),勢必影響棧布局。簡單來說,當(dāng)包含這段代碼的函數(shù)返回時(shí),返回地址必然錯(cuò)誤。
函數(shù)指針變量申明比較麻煩,如果多次在不同的地方寫,很有可能少寫一個(gè)參數(shù)或調(diào)用慣例寫錯(cuò)等導(dǎo)致編程錯(cuò)誤。為了減少這種麻煩,如果能將一種函數(shù)指針聲明為一種類型,然后所有定義函數(shù)指針變量的地方都用該類型定義就好了。例如,typedef int (*funcPtrType)(int, int)聲明了一個(gè)函數(shù)指針變量類型funcPtrType。funcPtrType pfunc了定義一個(gè)函數(shù)指針變量pfunc。之前的代碼可如下修改:
typedef int (*funcPtrType)(int, int); funcPtrType pfunc; int Add(int a, int b){ return a + b; } …
問題:對于數(shù)據(jù)指針,在遵循一定原則下,可安全地進(jìn)行指針強(qiáng)制轉(zhuǎn)換(見1.2節(jié)),是否函數(shù)指針也可遵循某種原則進(jìn)行指針強(qiáng)制轉(zhuǎn)換?在回答這個(gè)問題前,我們用強(qiáng)制轉(zhuǎn)換處理前面分析的調(diào)用慣例不匹配例子,并分析其反匯編,見DM1-23。注意test函數(shù)中的第一行語句,調(diào)用Add()前,用指針強(qiáng)制轉(zhuǎn)換將Add()的指針類型進(jìn)行了強(qiáng)制轉(zhuǎn)換,因?yàn)锳dd()的函數(shù)原型是_stdcall,而funcPtrType定義的函數(shù)指針類型是C方式,如果不強(qiáng)制轉(zhuǎn)換,編譯器會(huì)報(bào)錯(cuò):
DM1-23 typedef int (*funcPtrType)(int, int); funcPtrType pfunc; int _stdcall Add(int a, int b){ return a + b; } void test(){ pfunc = (funcPtrType)Add; pfunc(1, 2); Add(1, 2); } void main(){ test(); } 錯(cuò)誤 1 error C2440: “=”:無法從“int (__stdcall *) (int, int)轉(zhuǎn)換為“funcPtrType” e:\project\cprj\test2\test2\test2.cpp 18 test2
錯(cuò)誤提示無法將Add()的類型轉(zhuǎn)換為pfunc的類型funcPtrType,因?yàn)閮烧叩恼{(diào)用慣例不同。
下面來看強(qiáng)制轉(zhuǎn)換后的反匯編,見DM1-24。
DM1-24 void test(){ push ebp mov ebp, esp 1 pfunc = (funcPtrType)Add; 2 mov dword ptr ds:[00403374h], 401000h 3 pfunc(1, 2); 4 push 2 5 push 1 6 call dword ptr ds:[00403374h] 7 add esp, 8 8 Add(1, 2); 9 push 2 10 push 1 11 call 00401000 }
首先,第2行強(qiáng)制轉(zhuǎn)換沒有多余的邏輯,只是“欺騙編譯器”,將入口地址賦值給了pfunc變量。與數(shù)據(jù)指針的強(qiáng)制轉(zhuǎn)換一樣,強(qiáng)制轉(zhuǎn)換本身并沒有對應(yīng)的指令,僅僅是將地址賦值給指針變量。
如果大家執(zhí)行該程序會(huì)產(chǎn)生錯(cuò)誤。我們看看錯(cuò)誤與正常方式的差別吧。
① 錯(cuò)誤方式:函數(shù)指針發(fā)起的調(diào)用,call后第7行將棧頂上移8字節(jié)(這是正常方式?jīng)]有的),因?yàn)檫@時(shí)pfunc的類型的調(diào)用慣例是C方式,所以是調(diào)用方清棧(第7行)。
② 正常方式:用函數(shù)名調(diào)用,第11行call指令后無清棧指令(因?yàn)锳dd()是_stdcall,被調(diào)用方清棧,自然不需call后再加ESP中的值)。
被調(diào)用的Add()函數(shù)中本已用ret 8清棧了(ESP加了8),而用函數(shù)指針調(diào)用時(shí),第7行自作多情地再次對ESP加8清棧,這必會(huì)導(dǎo)致錯(cuò)誤!
具體來看錯(cuò)誤是什么。先看test()函數(shù)需返回的地址,單步跟蹤進(jìn)test()函數(shù)中,查看ESP的值為0x0012FF70,指向內(nèi)存中的4字節(jié)整數(shù)是0x0040104b(用小端機(jī)順序解讀),即返回地址。因?yàn)榈?行將棧頂多上移了8字節(jié),所以test()函數(shù)執(zhí)行ret指令前,ESP沒指向剛才保存返回地址的內(nèi)存,即0x0012FF70,而是0x0012FF78,這時(shí)執(zhí)行ret命令,將會(huì)把0x0012FF78中保存的值作為返回地址彈出。下面證明該論斷。
在ret指令處設(shè)置斷點(diǎn),按F5鍵執(zhí)行,在該斷點(diǎn)停下,ESP的值確實(shí)是0x0012FF78。而此時(shí)在內(nèi)存窗體中輸入0x0012FF78,可知其中保存的整數(shù)值是0x78542201,那么ret執(zhí)行后,程序?qū)?zhí)行該地址的指令。按F11鍵,單步執(zhí)行一次,我們可以看到代碼確實(shí)飛到這個(gè)地方了:
78542201 add esi, 4
這當(dāng)然是錯(cuò)誤的返回位置,如果執(zhí)行下去,誰也不知道會(huì)有什么結(jié)果,最終必然出現(xiàn)異常。
簡單總結(jié)一下函數(shù)指針強(qiáng)制轉(zhuǎn)換的作用:與數(shù)據(jù)指針一樣,函數(shù)指針在轉(zhuǎn)換賦值的時(shí)候,除了賦值地址沒有多余動(dòng)作。起效時(shí)是在調(diào)用函數(shù)的時(shí)候,編譯器會(huì)按其原型產(chǎn)生不同的代碼。
通過這個(gè)例子,我們能回答之前的問題“函數(shù)指針是否可通過某種規(guī)則強(qiáng)制轉(zhuǎn)換”。答案是:幾乎不可。不能賦值,一定是因?yàn)橹羔樧兞亢秃瘮?shù)兩者的函數(shù)原型不同,函數(shù)原型不同必然是參數(shù)順序、個(gè)數(shù)、清棧等有差異,勢必造成某種錯(cuò)誤。因此,可以說99%的函數(shù)指針強(qiáng)制轉(zhuǎn)換都必然導(dǎo)致錯(cuò)誤發(fā)生,至于那1%,大家也許將來能遇到,這里不再詳談。
最后一個(gè)問題:函數(shù)指針有什么用?用函數(shù)名字調(diào)用就可以了。那么,用函數(shù)指針調(diào)用函數(shù),如果不看指針的值,肯定不知道是哪個(gè)函數(shù),因?yàn)檫@個(gè)指針指向的是什么函數(shù)并不清楚。也就是說,同樣的調(diào)用代碼可能調(diào)用到不同的函數(shù)。這個(gè)好像面向?qū)ο蟮哪硞€(gè)高級(jí)特征:虛函數(shù)調(diào)用。同樣的代碼,調(diào)用到的函數(shù)可能是父類的,也可能是子類的。后面我們將看到函數(shù)指針的威力:超強(qiáng)的靈活性,模塊化編程的支撐力量!
- Instant Node Package Manager
- Deploying Node.js
- 數(shù)據(jù)結(jié)構(gòu)和算法基礎(chǔ)(Java語言實(shí)現(xiàn))
- MySQL數(shù)據(jù)庫應(yīng)用與管理 第2版
- Getting Started with PowerShell
- C#程序設(shè)計(jì)教程
- Mastering C# Concurrency
- Monitoring Elasticsearch
- SQL基礎(chǔ)教程(視頻教學(xué)版)
- SharePoint Development with the SharePoint Framework
- Scratch3.0趣味編程動(dòng)手玩:比賽訓(xùn)練營
- 快速入門與進(jìn)階:Creo 4·0全實(shí)例精講
- Vue.js光速入門及企業(yè)項(xiàng)目開發(fā)實(shí)戰(zhàn)
- MySQL 8從零開始學(xué)(視頻教學(xué)版)
- Node.js實(shí)戰(zhàn):分布式系統(tǒng)中的后端服務(wù)開發(fā)