深入理解C語言的函數調用過程


 

    本文主要從進程棧空間的層面復習一下C語言中函數調用的具體過程,以加深對一些基礎知識的理解。
    先看一個最簡單的程序:

點擊(此處)折疊或打開 

  1. /*test.c*/
  2. #include <stdio.h>
  3. int foo1(int m,int n,int p)
  4. {
  5.         int x = m + n + p;
  6.         return x;
  7. }
  8. int main(int argc,char** argv)
  9. {
  10.         int x,y,z,result;
  11.         x=11;
  12.         y=22;
  13.         z=33;
  14.         result = foo1(x,y,z);
  15.         printf("result=%d\n",result);
  16.         return 0;
  17. }
    主函數main里定義了4個局部變量,然后調用同文件里的foo1()函數。4個局部變量毫無疑問都在進程的棧空間上,當進程運行起來后我們逐步了解一下main函數里是如何基於棧實現了對foo1()的調用過程,而foo1()又是怎么返回到main函數里的。為了便於觀察的粒度更細致一些,我們對test.c生成的匯編代碼進行調試。如下:

點擊(此處)折疊或打開 

  1. .file "test.c"
  2.         .text
  3. .globl foo1
  4.         .type foo1, @function
  5. foo1:
  6.         pushl %ebp
  7.         movl %esp, %ebp
  8.         subl $16, %esp
  9.         movl 12(%ebp), %eax
  10.         movl 8(%ebp), %edx
  11.         leal (%edx,%eax), %eax
  12.         addl 16(%ebp), %eax
  13.         movl %eax, -4(%ebp)
  14.         movl -4(%ebp), %eax
  15.         leave
  16.         ret
  17.         .size foo1, .-foo1
  18.         .section .rodata
  19. .LC0:
  20.         .string "result=%d\n"
  21.         .text
  22. .globl main
  23.         .type main, @function
  24. main:
  25.         pushl %ebp
  26.         movl %esp, %ebp
  27.         andl $-16, %esp
  28.         subl $32, %esp
  29.         movl $11, 16(%esp)
  30.         movl $22, 20(%esp)
  31.         movl $33, 24(%esp)
  32.         movl 24(%esp), %eax
  33.         movl %eax, 8(%esp)
  34.         movl 20(%esp), %eax
  35.         movl %eax, 4(%esp)
  36.         movl 16(%esp), %eax
  37.         movl %eax, (%esp)
  38.         call foo1
  39.         movl %eax, 28(%esp)
  40.         movl $.LC0, %eax
  41.         movl 28(%esp), %edx
  42.         movl %edx, 4(%esp)
  43.         movl %eax, (%esp)
  44.         call printf
  45.         movl $0, %eax
  46.         leave
  47.         ret
  48.         .size main, .-main
  49.         .ident "GCC: (GNU) 4.4.4 20100726 (Red Hat 4.4.4-13)"
  50.         .section .note.GNU-stack,"",@progbits
    上面的匯編源代碼和最終生成的可執行程序主體結構上已經非常類似了:

[root@maple 1]# gcc -g -o test test.s 

[root@maple 1]# objdump -D test >testbin 

[root@maple 1]# vi testbin 

 //… 省略部分不相關代碼 

80483c0:       ff d0                               call   *%eax 

 80483c2:      c9                                   leave 

 80483c3:      c3                                   ret 

  

080483c4 :

 80483c4:       55                                  push  %ebp 

 80483c5:      89 e5                               mov   %esp,%ebp 

 80483c7:      83 ec 10                          sub    $0x10,%esp 

 80483ca:      8b 45 0c                          mov    0xc(%ebp),%eax 

 80483cd:      8b 55 08                         mov   0x8(%ebp),%edx 

 80483d0:      8d 04 02                         lea    (%edx,%eax,1),%eax 

 80483d3:      03 45 10                         add    0x10(%ebp),%eax 

 80483d6:      89 45 fc                          mov    %eax,-0x4(%ebp) 

 80483d9:      8b 45 fc                          mov    -0x4(%ebp),%eax 

 80483dc:      c9                                   leave 

 80483dd:      c3                                   ret 

  

080483de 

:

 80483de:      55                                     push   %ebp 

 80483df:      89 e5                                 mov   %esp,%ebp 

 80483e1:      83 e4 f0                            and    $0xfffffff0,%esp 

 80483e4:      83 ec 20                           sub    $0x20,%esp 

 80483e7:      c7 44 24 10 0b 00 00       movl   $0xb,0x10(%esp) 

 80483ee:      00 

 80483ef:      c7 44 24 14 16 00 00        movl   $0x16,0x14(%esp) 

 80483f6:      00 

 80483f7:      c7 44 24 18 21 00 00        movl   $0x21,0x18(%esp) 

 80483fe:      00 

 80483ff:      8b 44 24 18                      mov    0x18(%esp),%eax 

 8048403:      89 44 24 08                    mov    %eax,0x8(%esp) 

 8048407:      8b 44 24 14                    mov    0x14(%esp),%eax 

 804840b:      89 44 24 04                    mov    %eax,0x4(%esp) 

 804840f:      8b 44 24 10                     mov    0x10(%esp),%eax 

 8048413:      89 04 24                         mov    %eax,(%esp) 

 8048416:       e8 a9 ff ff ff                     call  80483c4

 804841b:      89 44 24 1c                     mov    %eax,0x1c(%esp) 

 804841f:      b8 04 85 04 08                 mov    $0x8048504,%eax 

 8048424:      8b 54 24 1c                     mov    0x1c(%esp),%edx 

 8048428:      89 54 24 04                     mov    %edx,0x4(%esp) 

 804842c:      89 04 24                         mov    %eax,(%esp) 

 804842f:      e8 c0 fe ff ff                     call   80482f4

 8048434:      b8 00 00 00 00              mov    $0x0,%eax 

 8048439:       c9                                  leave 

 804843a:      c3                                  ret 

 804843b:      90                                 nop 

 804843c:      90                                 nop 

 //… 省略部分不相關代碼

    用GDB調試可執行程序test:

    在main函數第一條指令執行前我們看一下進程test的棧空間布局。因為我們最終的可執行程序是通過glibc庫啟動的,在main的第一條指令運行前,其實還有很多故事的,這里就不展開了,以后有時間再細究,這里只要記住一點:main函數執行前,其進程空間的棧里已經有了相當多的數據。我的系統里此時棧頂指針esp的值是0xbffff63c,棧基址指針ebp的值0xbffff6b8,指令寄存器eip的值是0x80483de正好是下一條馬上即將執行的指令,即main函數內的第一條指令“push %ebp”。那么此時,test進程的棧空間布局大致如下:
    然后執行如下三條指令:

點擊(此處)折疊或打開 

  1. 25 pushl %ebp         //將原來ebp的值0xbffff6b8如棧,esp自動增長4字節
  2. 26 movl %esp, %ebp    //用ebp保存當前時刻esp的值
  3. 27 andl $-16, %esp    //內存地址對其,可以忽略不計
   執行完上述三條指令后棧里的數據如上圖所示,從0xbffff630到0xbffff638的8字節是為了實現 地址對齊的填充數據。此時ebp的值0xbffff638,該地址處存放的是ebp原來的值0xbffff6b8。詳細布局如下:
   第28條指令“subl  $32,%esp”是在棧上為函數里的本地局部變量預留空間,這里我們看到main主函數有4個int型的變量,理論上說預留16字節空間就可以了,但這里卻預留了32字節。GCC編譯器在生成匯編代碼時,已經考慮到函數調用時其輸入參數在棧上的空間預留的問題,這一點我們后面會看到。當第28條指令執行完后棧空間里的數據和布局如下:

    然后main函數里的變量x,y,z的值放到棧上,就是接下來的三條指令:


點擊(此處)折疊或打開 

  1. 29 movl $11, 16(%esp)
  2. 30 movl $22, 20(%esp)
  3. 31 movl $33, 24(%esp)

   這是三條寄存器間接尋址指令,將立即數11,22,33分別放到esp寄存器所指向的地址0xbffff610向高位分別偏移16、20、24個字節處的內存單元里,最后結果如下:

   注意:這三條指令並沒有改變esp寄存器的值。

   接下來main函數里就要為了調用foo1函數而做准備了。由於mov指令的兩個操作數不能都是內存地址,所以要將x,y和z的值傳遞給foo1函數,則必須借助通用寄存器來完成,這里我們看到eax承擔了這樣的任務:

點擊(此處)折疊或打開 

  1. 32 movl 24(%esp), %eax
  2. 33 movl %eax, 8(%esp)
  3. 34 movl 20(%esp), %eax
  4. 35 movl %eax, 4(%esp)
  5. 36 movl 16(%esp), %eax
  6. 37 movl %eax, (%esp)

 


    當foo1函數所需要的所有輸入參數都已經按正確的順序入棧后,緊接着就需要調用call指令來執行foo1函數的代碼了。前面的博文說過,call指令執行時分兩步:首先會將call指令的下一條指令(movl  %eax,28(%esp))的地址(0x0804841b)壓入棧,然后跳轉到函數foo1入口處開始執行。當第38條指令“call foo1”執行完后,棧空間布局如下:
   call指令自動將下一條要執行的指令的地址0x0804841b壓入棧,棧頂指針esp自動向低地址處“增長”4字節。所以,我們以前在C語言里所說的函數返回地址,應該理解為:當被調用函數執行完之后要返回到它的調用函數里下一條馬上要執行的代碼的地址。為了便於觀察,我們把foo1函數最后生成指令再列出來:

點擊(此處)折疊或打開 

  1. 3 .globl foo1
  2. 4           .type foo1, @function
  3. 5 foo1:
  4. 6           pushl %ebp
  5. 7           movl %esp, %ebp
  6. 8           subl $16, %esp
  7. 9           movl 12(%ebp), %eax
  8. 10          movl 8(%ebp), %edx
  9. 11          leal (%edx,%eax), %eax
  10. 12          addl 16(%ebp), %eax
  11. 13          movl %eax, -4(%ebp)
  12. 14          movl -4(%ebp), %eax
  13. 15          leave
  14. 16          ret
  15. 17          .size foo1, .-foo1 
    進入到foo1函數里,開始執行該函數里的指令。當執行完第6、7、8條指令后,棧里的數據如下。這三條指令就是匯編層面函數的“序幕”,分別是保存ebp到棧,讓ebp指向當前棧頂,然后為函數里的局部變量預留空間:
   接下來第9和第10條指令,也並沒有改變棧上的任何數據,而是將函數輸入參數列表中的的x和y的值分別轉載到eax和edx寄存器,和main函數剛開始時做的事情一樣。此時eax=22、edx=11。然后用了一條leaf指令完成x和y的加法運算,並將運算結果存在eax里。第12條指令“addl 16(%ebp), %eax”將第三個輸入參數p的值,這里是實參z的值為33,同樣用寄存器間接尋址模式累加到eax里。此時eax=11+22+33=66就是我們最終要得計算結果。

   因為我們foo1()函數的C代碼中,最終計算結果是保存到foo1()里的局部變量x里,最后用return語句將x的值通過eax寄存器返回到mian函數里,所以我們看到接下來的第13、14條指令有些“多此一舉”。這足以說明gcc人家還是相當嚴謹的,C源代碼的函數里如果有給局部變量賦值的語句,生成匯編代碼時確實會在棧上為本地變量預留的空間里的正確位置為其賦值。當然gcc還有不同級別的優化技術來提高程序的執行效率,這個不屬於本文所討論的東西。讓我們繼續,當第13、14條指令執行完后,棧布局如下:
   將ebp-4的地址處0xbffff604(其實就是foo1()里的第一個局部參數x的地址)的值設置為66,然后再將該值復制到eax寄存器里,等會兒在main函數里就可以通過eax寄存器來獲取最終的計算結果。當第15條指令leave執行完后,棧空間的數據和布局如下:

    我們發現,雖然棧頂從0xbffff5f8移動到0xbffff60c了,但棧上的數據依然存在。也就是說,此時你通過esp-8依舊可以訪問foo1函數里的局部變量x的值。當然,這也是說得通的,因為函數此時還沒有返回。我們看棧布局可以知道當前的棧頂0xbffff60c處存放的是下一條即將執行的指令的地址,對照反匯編結果可以看到這正是main函數里的第18條指令(在整個匯編源文件test.s里的行號是39)“movl  %eax, 28(%esp)”。leave指令其實完成了兩個任務:
   1、將棧上為函數預留的空間“收回”;
   2、恢復ebp;

   也就是說leave指令等價於下面兩條指令,你將leave替換成它們編譯運行,結果還是對的: 

 

點擊(此處)折疊或打開 

  1. movl %ebp,%esp
  2. popl %ebp 

 


   前面我們也說過,ret指令會自動到棧上去pop數據,相當於執行了“popl %eip”,會使esp增大4字節。所以當執行完第16條指令ret后,esp從0xbffff60c增長到0xbffff610處,棧空間結構如下:

   現在已經從foo1里返回了,但是由於還沒執行任何push操作,棧頂“上部”的數據依舊還是可以訪問到了,即esp-12的值就是foo1里的局部變量x的值、esp-4的值就是函數的返回地址,當執行第39條指令“movl %eax,28(%esp)”后棧布局變成下面的樣子:
   第39條指令就相當於給main里的result變量賦值66,如上紅線標注的地方。接下來main函數里要執行printf("result=%d\n",result)語句了,而printf又是C庫的一個常用的輸出函數,這里就又會像前面調用foo1那樣,初始化棧,然后用“call printf的地址”來調用C函數。當40~43這4條指令執行完后,棧里的數據如下:

點擊(此處)折疊或打開 

  1. 40 movl $.LC0, %eax
  2. 41 movl 28(%esp), %edx
  3. 42 movl %edx, 4(%esp)
  4. 43 movl %eax, (%esp)
   上圖為了方便理解,將棧頂的0x08048504替換了成字符串“result=%d\n”,但進程實際運行時此時棧頂esp的值是字符串所在的內存地址。當第44條指令執行完后,棧布局如下:
   由於此時棧已經用來調用printf了,所以棧頂0xbffff610“以上”部分的空間里就找不到foo1的任何影子了。最后在main函數里,當第46、47條指令執行完后棧的布局分別是:
    當main函數里的ret執行完,其實是返回到了C庫里繼續執行剩下的清理工作。
   所以,最后關於C的函數調用,我們可以總結一下:
    1 、函數輸入參數的入棧順序是函數原型中形參從右至左的原則;
    2 、匯編語言里調用函數通常情況下 都用 call指令來完成
   
3、匯編語言里的函數大部分情況下都符合以下的 函數模板:

點擊(此處)折疊或打開 

  1. .globl fun_name
  2. .type fun_name, @function
  3. fun_name:
  4.         pushl %ebp
  5.         movl %esp, %ebp
  6.         <函數主體代碼> 
  7.         leave
  8.         ret 

   如果我們有個函數原型:int funtest(int x,int y int z char* ptr),在匯編層面,當調用它時棧的布局結構一般是下面這個樣子:

    而有些資料上將ebp指向函數返回地址的地方,這是不對的。正常情況下應該是ebp指向old ebp才對,這樣函數末尾的leave和ret指令才可以正常工作。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM