C函數原理

C語言做爲面向過程的語言,函數是其中最重要的部分,同時函數也是C種的一個難點,這篇文章但願經過彙編的方式說明函數的實現原理。css

棧結構與相關的寄存器

在計算中,棧是十分重要的一種數據結構,同時也是CPU直接支持的一種數據結構,棧採用先進後出的方式。CPU中分別用兩個寄存器ebp和esp來保存棧底地址和棧頂地址,在CPU層面只須要ebp的值大於ESP的值兩個寄存器所指向的內存的中間的部分就構成了一個棧。彙編中採用push和pop兩個指令來表示入棧和出棧,這兩個指令後面直接跟寄存器或者內存地址,表示將相應的值放入棧中,好比push eax至關於指令sub esp, 4; mov [esp], eax而pop eax至關於mov [esp], eax; add esp, 4。
另外CPU中有一個專門記錄下一條指令的寄存器eip,這樣每當執行一條指令,eip寄存器加上相應指令的長度,這樣每一條指令執行完成後,eip都執向下一條指令的地址。只要可以保存函數調用前,下一句代碼的地址,這樣在函數執行完成後將這個地址賦值給eip寄存器,就可以回到調用者的位置,這是函數實現的基本依據。markdown

函數的調用

咱們經過這樣一段代碼來講明函數的調用過程數據結構

int add(int a, int b)
{
    int c = a + b;
    return c;
}
int main(int argc, char* argv[])
{
    add(1, 2);
    return 0;
}

它對應的反彙編代碼以下:函數

;這是調用函數以前所作的準備,代碼在main函數中
004012A8   push        2
004012AA   push        1
004012AC   call        @ILT+0(add) (00401005)
004012B1   add         esp,8

;函數中的彙編代碼
00401250   push        ebp
00401251   mov         ebp,esp
00401253   sub         esp,44h
00401256   push        ebx
00401257   push        esi
00401258   push        edi
00401259   lea         edi,[ebp-44h]
0040125C   mov         ecx,11h
00401261   mov         eax,0CCCCCCCCh
00401266   rep stos    dword ptr [edi]
;後面的就是函數中的實現代碼

首先在調用函數以前進行參數壓棧,首先將參數列表中的參數從右至左,依次壓棧,而後調用一句call指令,跳轉到函數代碼處,call指令主要有兩個做用,一個是eip的值壓入棧中,而後使用jmp指令,跳轉到對應函數的實現位置,此時棧中的值以下圖:
圖1
在函數實現的位置,首先將ebp壓棧,這個時候的ebp保存的是調用者的棧幀的棧底地址。而後將ESP賦值給ebp,這些指令執行後棧中的內容以下圖所示:
圖2
此時ebp與ESP相等,ebp上面的部分都是該函數的函數棧幀,用於保存該函數的局部變量。接下來將ESP的值減去44h,並對ESP和ebp之間的內存進行初始化爲0xcc,而0xcc轉化爲字符串就是一系列的「燙」,還記得之前在vc6.0中寫程序時常常出現的「燙燙燙」嗎。這些指令就是初始化一個棧空間,這個空間大小爲48h,之後在函數中定義變量時是利用ebp來作偏移,ESP由於是棧頂指針會一直變化,因此採用了一個不變的棧底指針做爲偏移的基址。好比下面是add函數的語句對應的代碼:測試

10: int c = a + b;
00401268   mov         eax,dword ptr [ebp+8]
0040126B   add         eax,dword ptr [ebp+0Ch]
0040126E   mov         dword ptr [ebp-4],eax

初始化變量C的時候,變量的地址是ebp - 4,而從上面的圖中能夠看出ebp + 8指向的是第一個參數,ebp + 4指向的是保存的EIP的值。如今咱們來證明一下,經過VC6.0的調試功能,查看寄存器的值,此時咱們獲得以下的圖:
這裏寫圖片描述
在圖中明顯的看出此時ebp的值爲0x0012FEEC,而ebp + 4則是0x0012FEF0,這個地址對應的位置存儲的值爲0x004012B1,看到了嗎,這個地址對應的代碼是否是add esp, 8;這句話是否是在call以後。
當函數返回時執行下面的語句:ui

00401271   mov         eax,dword ptr [ebp-4]
12:   }
00401274   pop         edi
00401275   pop         esi
00401276   pop         ebx
00401277   mov         esp,ebp
00401279   pop         ebp
0040127A   ret

當咱們執行完了這些代碼,函數棧的環境已經造成了,下面是整個棧幀環境的示意圖:
圖3
首先還原以前保存的寄存器環境,這幾個寄存器沒有太大的做用,只是編譯器判斷之後可能使用到它們,於是將其以前的值保存,可是與函數的實現沒有太大關係,在這並不關心。以前在進入函數時首先將esp指向的位置擡高了44h,可是在這並無看到將esp指向的位置下降44h的操做,可是它有一句mov esp, ebp,這句話就是用來還原棧環境的,還記得以前的圖嗎,ebp指向當前函數棧幀的棧底,經過這一句能夠直接將esp還原,使其指向正確的位置。想一想我當初學8086彙編利用棧操做時,不知道這個寄存器,當時壓入的數據與彈出的數據不匹配結果還原到了錯誤的地址執行代碼,但是花了好多時間調試才發現,甚是苦逼。如今好了,利用一句話直接將esp指向正確的位置,減小了很多工做,沒必要去記你到底壓入了多少內容,也沒必要刻意的去將這些內容彈出。到這,棧環境又回到了當初圖2的情景。而後進行了一句pop ebp將以前存儲的ebp的內容還原,這個時候ebp指向的是調用者的函數棧幀的棧底位置。
在上述的最後有一句ret,至關於先執行pop eip,將以前保存的eip的值還原,這樣CPU執行的下句代碼就是eip指向的內存位置的代碼。spa

函數中的參數傳遞

從上面的代碼中能夠看出,函數的形參與實參並非同一個變量,它們所在的內存地址不一樣,這樣就解釋了爲何形參的改變沒法影響實參,只有經過傳入地址才能改變實參。咱們這傳遞的是具體的變量值,如今咱們不這麼作,當傳遞一個結構體的話會怎麼樣?下面是一段測試代碼:指針

struct NUM
{
    int a;
    int b;
};

int add(NUM num)
{
    int c = num.a + num.b;
    return c;
}

int main(int argc, char* argv[])
{
    NUM num;
    num.a = 1;
    num.b = 2;
    add(num);
    return 0;
}

下面是它的反彙編代碼:調試

23:       num.a = 1;
004012A8   mov         dword ptr [ebp-8],1
24:       num.b = 2;
004012AF   mov         dword ptr [ebp-4],2
25:       add(num);
004012B6   mov         eax,dword ptr [ebp-4]
004012B9   push        eax
004012BA   mov         ecx,dword ptr [ebp-8]
004012BD   push        ecx
004012BE   call        @ILT+0(add) (00401005)

從彙編代碼中能夠看到,結構體在丁一時,它裏面的成員是從低地址到高地址依次定義的。ebp - 8是 成員a的地址,ebp - 4是成員b的地址,在傳參時,首先壓入棧中的是ebp - 4 而後是ebp - 8。這樣在函數棧中仍然保持着定義時候的順序,這麼作與C在底層對結構體的處理有關。其實對於參數大於4個字節的狀況,通常是採用拷貝的方式,將參數所在內存中的內容依次拷貝到函數棧中。只是例子中的結構體只有兩個整造成員,所以採用的是兩次入棧的操做。好比咱們在上面例子的結構體中添加一個char szBuf[255]的成員,這個時候在傳參時會執行這樣的語句:code

004012C2   sub         esp,108h
004012C8   mov         ecx,42h
004012CD   lea         esi,[ebp-108h]
004012D3   mov         edi,esp
004012D5   rep movs    dword ptr [edi],dword ptr [esi]
004012D7   call        @ILT+0(add) (00401005)
004012DC   add         esp,108h

rep movs dword ptr [edi], dword ptr [esi]指令是將esi所指向的內存依次複製到edi所指向的內存中,賦值的大小是ecx個字節,而每次賦值dword也就是4個字節。

函數的返回值

函數能夠返回不一樣的值,通常利用return語句返回,可是在上面的說明中並無這樣的指令,惟一用來返回的ret指令,只是修改棧的內容並作一個跳轉,並無實際的返回什麼,下面咱們就來看看函數是如何返回值的。
咱們用第一段C代碼來講明函數是如何返回的,下面是add函數和main函數的return語句對應的反彙編代碼:

;main函數的反彙編代碼
17:       return 0;
004012B4   xor         eax,eax
;函數add的反彙編代碼
00401268   mov         eax,dword ptr [ebp+8]
0040126B   add         eax,dword ptr [ebp+0Ch]
0040126E   mov         dword ptr [ebp-4],eax
11:       return c;
00401271   mov         eax,dword ptr [ebp-4]
;對於返回值的使用
16:       int c = add(1, 2);
004012A8   push        2
004012AA   push        1
004012AC   call        @ILT+0(add) (00401005)
004012B1   add         esp,8
004012B4   mov         dword ptr [ebp-4],eax
17:       return 0;
004012B7   xor         eax,eax

在main的返回值中,首先執行的是xor eax, eax將eax清零,而後調用ret,在add函數中,將實參相加的結果保存到eax中,而後返回,這樣咱們猜想函數可能經過eax來保存函數的返回值。同時在main函數中咱們將返回值保存到另外一個變量中,int c = add(1, 2)的反彙編代碼能夠看出,最終是執行了mov [ebp - 4], eax。因此從這能夠看出函數若是返回四個字節的內容時會用eax保存這個返回值。若是小於4個呢,下面一段反彙編代碼說明了這一點

16:       short c = add(1, 2);
004012A8   push        2
004012AA   push        1
004012AC   call        @ILT+10(add) (0040100f)
004012B1   add         esp,8
004012B4   mov         word ptr [ebp-4],ax

這段代碼說明當小於4個字節時仍然會使用eax寄存器的低位存儲返回值。若是大於4個字節該如何處理?

struct NUM
{
    int a;
    char szBuf[255];
};

NUM Ret(NUM num)
{
    return num;
}
int main(int argc, char* argv[])
{
    NUM num = {0};
    NUM num1 = Ret(num);
    return 0;
}

對應的反彙編代碼以下:

;main 函數的返回值部分
004012EE   mov         esi,eax
004012F0   mov         ecx,41h
004012F5   lea         edi,[ebp-30Ch]
004012FB   rep movs    dword ptr [edi],dword ptr [esi]
004012FD   mov         ecx,41h
00401302   lea         esi,[ebp-30Ch]
00401308   lea         edi,[ebp-208h]
0040130E   rep movs    dword ptr [edi],dword ptr [esi]
;Ret函數的返回值部分
16: return num;
00401268   mov         ecx,41h
0040126D   lea         esi,[ebp+0Ch]
00401270   mov         edi,dword ptr [ebp+8]
00401273   rep movs    dword ptr [edi],dword ptr [esi]
00401275   mov         eax,dword ptr [ebp+8]
17: } 

當返回值大於4個字節時會採用其餘模式,這個時候再也不採用寄存器做爲中間通道傳遞返回值,而是直接經過內存拷貝的方式來進行參數傳遞,在返回時,進行了內存拷貝將返回值拷貝到ebp + 8的位置,並將這個的首地址賦值給eax,使用這個值時,利用eax找到返回值所在內存的首地址,而後將這段內存的內容拷貝到相關變量所在的內存中,從在還看出了一個問題,就是返回值所在的內存的首地址爲ebp + 8,若是沒有保存這個值,並當即調用下一個函數的話,ebp + 8所在位置就會變成下一個函數的函數棧,這樣這個返回值就丟失了,而且這個eax寄存器也會被下一個函數的返回值給覆蓋,因此在調用函數後,若是不保存這個返回值,返回值就會丟失,也不能被引用。另外從上面能夠看出,當參數或者返回值大於4個字節時,都要經歷內存的拷貝,這樣會大大下降效率,因此在參數或者返回值大於4個字節時通常利用指針或者引用來傳值,若是不想函數改變出入或者傳出的值,可使用const關鍵字。

局部變量的做用域

討論局部變量的做用域,首先來看局部變量在函數中是如何存儲的。仍是來看看上面的例子中的一段彙編代碼

10: int c = a + b;
00401268   mov         eax,dword ptr [ebp+8]
0040126B   add         eax,dword ptr [ebp+0Ch]
0040126E   mov         dword ptr [ebp-4],eax

在函數中定義了一個局部變量C,在反彙編代碼中,能夠看出C變量所在的地址爲ebp -4 的位置,根據上面的圖3,能夠看到,這個變量是在函數棧中,在函數中使用ebp間接尋址的方式來訪問,在上面的分析中編譯器預留了44h的空間用來保存局部變量。在編譯時編譯器會計算在函數中定義的局部變量所佔內存的大小,根據這個大小來爲函數分配合適的棧也就是說這個時候不在是sub esp, 44h了而是根據具體須要多大的空間來擡高esp,這個就不用例子演示了,感興趣的朋友能夠寫一個簡單的例子來驗證一下。當函數調用完成後,ebp還原到調用者的棧底部,這個時候不可能再使用ebp間接尋址的方式來找到在上一個函數中定義的局部變量了,及時咱們及時保存了這個變量的地址,也有可能在調用下一個函數時,這個地址所在的內存變成了下一個函數的函數棧,被下一個函數的內容所替代。因此C中局部變量只在本函數中使用。至於在複合語句塊中定義的局部變量出了這個複合語句塊就不能使用,這個純粹是語法上面的限制,其實這個時候仍是能夠利用ebp間接尋址的方式來訪問。

函數的三種調用約定

咱們知道函數中十分重要的一個部分是對棧空間的使用和最後棧空間的回收,不一樣的函數類型有不一樣的參數壓棧與棧空間還原的方式,具體使用哪種方式,須要事先與編譯器約定好,以便生成對應的機器碼來處理。下面咱們來探究這三種調用方式。

stdcall方式

void _stdcall Print(int i, int k)
{
    int j = 0;
    printf("i = %d\n, k = %d\n", i, k);
}

int main(int argc, char* argv[])
{
    Print(10, 20);
    return 0;
}

下面是對應的反彙編代碼

;main函數中的反彙編代碼
16:       Print(10, 20);
004012C8   push        14h
004012CA   push        0Ah
004012CC   call        @ILT+0(Print) (00401005)
;Print函數中反彙編代碼
00401268   mov         dword ptr [ebp-4],0
11:       printf("i = %d\n, k = %d\n", i, k);
0040126F   mov         eax,dword ptr [ebp+0Ch]
00401272   push        eax
00401273   mov         ecx,dword ptr [ebp+8]
00401276   push        ecx
00401277   push        offset string "i = %d\n, k = %d\n" (0042f01c)
0040127C   call        printf (00401570)
00401281   add         esp,0Ch
12:   }
00401284   pop         edi
00401285   pop         esi
00401286   pop         ebx
00401287   add         esp,44h
0040128A   cmp         ebp,esp
0040128C   call        __chkesp (00401410)
00401291   mov         esp,ebp
00401293   pop         ebp
00401294   ret         8

從上面的代碼中能夠看出在調用函數Print函數時,首先壓入棧中的參數是0x14,而後是0x0A,這兩個值對應的是20和10,也就是說這種調用方式參數採用的是從右至左壓棧,而後咱們看到在函數棧環境的初始化中,與以前所說的基本相同,在返回時有一句ret 8這句話是至關於先執行了ret,而後執行了add esp , 8的操做,在調用這句話以前,esp保存的是該函數棧底的指針,esp + 8 正好跳過了以前爲形參準備的棧空間,也就是說這種調用方式是由被調函數自己來完成最後棧空間的回收工做。

cdecl方式

這種方式是C/C++默認的函數調用方式。咱們將上述代碼中的_stdcall改成 _cdecl,下面是函數的部分反彙編代碼:

;main部分
16:       Print(10, 20);
004012C8   push        14h
004012CA   push        0Ah
004012CC   call        @ILT+0(Print) (00401005)
004012D1   add         esp,8
;print函數部分
0040128C   call        __chkesp (00401410)
00401291   mov         esp,ebp
00401293   pop         ebp
00401294   ret

函數棧的初始化工做的代碼基本相同,這裏就再也不粘貼這段代碼了。首先在調用這個函數時壓棧方式也是從右至左一次壓棧,可是函數調用完畢,返回時只要一句ret,而在main函數中多了一句add esp, 8從這個地方能夠很明顯的看出,最後參數所在空間的釋放是由main函數釋放,也就是函數棧的釋放是由調用方來完成。還記得在Windows SDK程序中的WinMain函數前面的WINAPI嗎,其實它是一個宏,表示的正式這種調用方式。

fastcall

fastcall是採用一種特殊的方式調用,通常函數的作法是將參數壓入函數棧中,採用的是內存拷貝的方式,而這種方式爲了體現fast的特性,部分參數是用寄存器來傳值,咱們知道寄存器的存取速度是大於內存的,因此這種方式也就能夠提升程序的運行效率,可是寄存器數量是有限的,所以這種方式是採用寄存器與內存混合使用的方式來傳遞參數。

void  _fastcall Print(int i, int k, int a, int b)
{
    int j = 0;
    printf("i = %d\n, k = %d, a = %d, b = %d\n", i, k, a, b);
}

int main(int argc, char* argv[])
{
    Print(10, 20, 30, 40);
    return 0;
}

對應的反彙編代碼以下:

;main函數的部分
16:       Print(10, 20, 30, 40);
004012D8   push        28h;棧內存傳參
004012DA   push        1Eh
004012DC   mov         edx,14h;寄存器傳參
004012E1   mov         ecx,0Ah
004012E6   call        @ILT+0(Print) (00401005)
17:       return 0;
;print函數返回部分
00401294   pop         edi
00401295   pop         esi
00401296   pop         ebx
00401297   add         esp,4Ch
0040129A   cmp         ebp,esp
0040129C   call        __chkesp (00401420)
004012A1   mov         esp,ebp
004012A3   pop         ebp
004012A4   ret         8 ;平衡函數棧幀

從上面的反彙編代碼能夠看出,這種調用方式是採用寄存器與函數棧混合傳參的方式,在返回時,由函數自己平衡棧幀。

不定參函數

在函數中,可使用這樣一種技術:傳入的參數個數可變,,好比像printf和scan,這種函數至少須要一個參數,而且須要知道參數個數,和各個參數類型,好比printf傳入一個格式字符串來表示參數個數和參數的類型。從上面所說的函數的原理來看,參數是從右至左壓棧,這樣只須要知道第一個參數的地址,就能夠依次向下尋找到各個參數的地址,經過各個參數的類型向下尋址,好比當前參數類型是int型,那麼它的下一個參數的地址就是這個地址加4的位置,同時爲了防止越界訪問,給出了參數個數。下面咱們用一個簡單的例子來講明如何使用這種方式尋址。

//規定函數的第一個參數表示後續參數的個數,後面的參數全爲int
void  Print(int nCout,...)
{
    int *p = &nCout;
    for (int i = 0; i < nCout;i++)
    {
        p++;
        printf("%d\t", *p);
    }
}

int main(int argc, char* argv[])
{
    Print(3, 20, 30, 40);
    return 0;
}

咱們知道參數列表中的參數都是從右至左依次壓入函數棧中,因此這些參數確定是依次存放,且第一個參數所在的地址應該是最小的,之後只須要依次根據將指針向下偏移便可尋址到不一樣的參數,C語言爲了簡化這個操做,定義了一組宏va_list va_start va_arg va_end。這組宏的實現原理其實與上面咱們寫的代碼差很少。因爲傳遞的參數個數不肯定,因此這個函數自己並不知道有多少個參數會傳入,因此但願函數自己來平衡函數棧是不可能的,只有在調用之時才知道這個參數的個數,因此平衡棧的工做只能是由調用者來作,因此上述三種方式只有_cdecl這種方式可使用不定參函數。 最後咱們來總結一下函數的調用通常通過以下步驟: 1. 首先從右至左將參數壓入棧中 2. 而後調用call指令保存eip寄存器的值,而後跳轉到函數代碼 3. 將上一個函數的棧底地址ebp的值壓入棧中 4. 將此時esp的值保存到ebp中,做爲該函數的函數棧的棧底地址 5. 根據函數中局部變量的個數擡高esp的值並初始化這段棧空間 6. 將其他寄存器的值壓棧 7. 執行函數代碼 8. 經過eax或者內存拷貝的方式保存返回值 9. 將上面保存的寄存器的值出棧 10. 執行esp = ebp,時esp指向函數棧的棧底 11. pop ebp 還原以前保存的值,使ebp指向調用者的函數棧棧底 12. ret 返回或者ret n(n爲整數)指令返回到調用者的下一句代碼 13. 平衡堆棧(根據約定方式決定是否有這步)

相關文章
相關標籤/搜索