天天看點

重學計算機組成原理(六)- 函數調用怎麼突然Stack Overflow了!(下)2 構造Stack Overflow3 利用函數内聯實作性能優化3 總結4 推薦閱讀參考

2 構造Stack Overflow

通過引入棧,我們可以看到,無論有多少層的函數調用,或者在函數A裡調用函數B,再在函數B裡調用A

這樣的遞歸調用,我們都隻需要通過維持rbp和rsp,這兩個維護棧頂所在位址的寄存器,就能管理好不同函數之間的跳轉

不過,棧的大小也是有限的。如果函數調用層數太多,我們往棧裡壓入它存不下的内容,程式在執行的過程中就會遇到棧溢出的錯誤,這就是stack overflow

構造一個棧溢出的錯誤

并不困難,最簡單的辦法,就是我們上面說的Infiinite Mirror Effect的方式,讓函數A調用自己,并且不設任何終止條件

這樣一個無限遞歸的程式,在不斷地壓棧過程中,将整個棧空間填滿,并最終遇上stack overflow。

int a()
{
  return a();
}


int main()
{
  a();
  return 0;
}
      

除了無限遞歸,遞歸層數過深,在棧空間裡面建立非常占記憶體的變量(比如一個巨大的數組),這些情況都很可能給你帶來stack overflow

相信你了解了棧在程式運作的過程裡面是怎麼回事,未來在遇到stackoverflow這個錯誤的時候,不會完全沒有方向了。

3 利用函數内聯實作性能優化

上面我們提到一個方法,把一個實際調用的函數産生的指令,直接插入到的位置,來替換對應的函數調用指令。盡管這個通用的函數調用方案,被我們否決了,但是如果被調用的函數裡,沒有調用其他函數,這個方法還是可以行得通的。

事實上,這就是一個常見的編譯器進行自動優化的場景,我們通常叫函數内聯(Inline)

隻要在GCC編譯的時候,加上對應的一個讓編譯器自動優化的參數-O,編譯器就會在可行的情況下,進行這樣的指令替換。

案例

重學計算機組成原理(六)- 函數調用怎麼突然Stack Overflow了!(下)2 構造Stack Overflow3 利用函數内聯實作性能優化3 總結4 推薦閱讀參考

為了避免編譯器優化掉太多代碼,小小修改了一下function.c,讓參數x和y都變成了,通過随機數生成,并在代碼的最後加上将u通過printf列印

[hadoop@JavaEdge Documents]$ vim function.c
[hadoop@JavaEdge Documents]$ gcc -g -c -O function.c 
[hadoop@JavaEdge Documents]$ objdump -d -M intel -S function.o

function.o:     file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <main>:
{
    return a+b;
}

int main()
{
   0:   53                      push   rbx
   1:   bf 00 00 00 00          mov    edi,0x0
   6:   e8 00 00 00 00          call   b <main+0xb>
   b:   89 c7                   mov    edi,eax
   d:   e8 00 00 00 00          call   12 <main+0x12>
  12:   e8 00 00 00 00          call   17 <main+0x17>
  17:   89 c3                   mov    ebx,eax
  19:   e8 00 00 00 00          call   1e <main+0x1e>
  1e:   89 c1                   mov    ecx,eax
  20:   bf 67 66 66 66          mov    edi,0x66666667
  25:   89 d8                   mov    eax,ebx
  27:   f7 ef                   imul   edi
  29:   d1 fa                   sar    edx,1
  2b:   89 d8                   mov    eax,ebx
  2d:   c1 f8 1f                sar    eax,0x1f
  30:   29 c2                   sub    edx,eax
  32:   8d 04 92                lea    eax,[rdx+rdx*4]
  35:   29 c3                   sub    ebx,eax
  37:   89 c8                   mov    eax,ecx
  39:   f7 ef                   imul   edi
  3b:   c1 fa 02                sar    edx,0x2
  3e:   89 d7                   mov    edi,edx
  40:   89 c8                   mov    eax,ecx
  42:   c1 f8 1f                sar    eax,0x1f
  45:   29 c7                   sub    edi,eax
  47:   8d 04 bf                lea    eax,[rdi+rdi*4]
  4a:   01 c0                   add    eax,eax
  4c:   29 c1                   sub    ecx,eax
#include <time.h>
#include <stdlib.h>

int static add(int a, int b)
{
    return a+b;
  4e:   8d 34 0b                lea    esi,[rbx+rcx*1]
{
    srand(time(NULL));
    int x = rand() % 5;
    int y = rand() % 10;
    int u = add(x, y);
    printf("u = %d\n", u);
  51:   bf 00 00 00 00          mov    edi,0x0
  56:   b8 00 00 00 00          mov    eax,0x0
  5b:   e8 00 00 00 00          call   60 <main+0x60>
  60:   b8 00 00 00 00          mov    eax,0x0
  65:   5b                      pop    rbx
  66:   c3                      ret    
      

上面的function.c的編譯出來的彙編代碼,沒有把add函數單獨編譯成一段指令順序,而是在調用u = add(x, y)的時候,直接替換成了一個add指令。

除了依靠編譯器的自動優化,你還可以在定義函數的地方,加上inline的關鍵字,來提示編譯器對函數進行内聯。

内聯帶來的優化是,CPU需要執行的指令數變少了,根據位址跳轉的過程不需要了,壓棧和出棧的過程也不用了。

不過内聯并不是沒有代價,内聯意味着,我們把可以複用的程式指令在調用它的地方完全展開了。如果一個函數在很多地方都被調用了,那麼就會展開很多次,整個程式占用的空間就會變大了。

這樣沒有調用其他函數,隻會被調用的函數,我們一般稱之為葉子函數(或葉子過程)

重學計算機組成原理(六)- 函數調用怎麼突然Stack Overflow了!(下)2 構造Stack Overflow3 利用函數内聯實作性能優化3 總結4 推薦閱讀參考

3 總結

這一節,我們講了一個程式的函數間調用,在CPU指令層面是怎麼執行的。其中一定需要你牢記的,就是程式棧這個新概念。

我們可以友善地通過壓棧和出棧操作,使得程式在不同的函數調用過程中進行轉移。而函數内聯和棧溢出,一個是我們常常可以選擇的優化方案,另一個則是我們會常遇到的程式Bug。

通過加入了程式棧,我們相當于在指令跳轉的過程種,加入了一個“記憶”的功能,能在跳轉去運作新的指令之後,再回到跳出去的位置,能夠實作更加豐富和靈活的指令執行流程。這個也為我們在程式開發的過程中,提供了“函數”這樣一個抽象,使得我們在軟體開發的過程中,可以複用代碼和指令,而不是隻能簡單粗暴地複制、粘貼代碼和指令。

4 推薦閱讀

可以仔細讀一下《深入了解計算機系統(第三版)》的3.7小節《過程》,進一步了解函數調用是怎麼回事。

另外,我推薦你花一點時間,通過搜尋引擎搞清楚function.c每一行彙編代碼的含義,這個能夠幫你進一步深入了解程式棧、棧幀、寄存器以及Intel CPU的指令集。

參考

深入淺出計算機組成原理

繼續閱讀