官术网_书友最值得收藏!

  • 老“碼”識(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)的靈活性,模塊化編程的支撐力量!

主站蜘蛛池模板: 崇义县| 涟源市| 长葛市| 当涂县| 修文县| 拉萨市| 宽城| 财经| 英吉沙县| 临澧县| 永昌县| 改则县| 聂荣县| 龙山县| 颍上县| 永济市| 赤峰市| 玉树县| 府谷县| 荆门市| 濮阳市| 临泉县| 塔城市| 绵竹市| 曲阜市| 鄂州市| 抚州市| 河西区| 资阳市| 鹤峰县| 道真| 鹿邑县| 龙口市| 咸宁市| 永福县| 宣化县| 铜梁县| 桃园县| 福鼎市| 威宁| 南安市|