彙編與C/C++的故事

  其實我知道這個標題實在是太廣泛了,但是下面所有東西都是和彙編,C++有關的。

  1. stdcall與cdecl
      首先他們共屬於函數約定(calling convention),詳情參考wiki(https://en.wikipedia.org/wiki/Calling_convention).總結如下:
      a. 兩者都是參數從右至左進棧.
      b. stdcall是winapi標準默認調用方式(也就是WINAPI這個宏),cdecl是默認C調用方式.
      c. stdcall在函數返回前清棧(函數自己清棧),cdecl在函數返回後清棧(由調用者清棧).所以可變參數列表只支持cdecl調用方式.
      以上是瞎bb的理論階段,那麼上面所說的清棧到底是個什麼東西呢?我從C代碼層面明明沒有感覺到區別啊!
      下面用vs2015彙編最簡單的函數比較一下(精華在註釋裏面):
void __stdcall func(int) {}
/*
01201690  push        ebp
01201691  mov         ebp,esp
01201693  sub         esp,0C0h
01201699  push        ebx
0120169A  push        esi
0120169B  push        edi
0120169C  lea         edi,[ebp-0C0h]
012016A2  mov         ecx,30h
012016A7  mov         eax,0CCCCCCCCh
012016AC  rep stos    dword ptr es:[edi]
012016AE  pop         edi
012016AF  pop         esi
012016B0  pop         ebx
012016B1  mov         esp,ebp
012016B3  pop         ebp
// 上面代碼都可忽略,除了push,pop沒做什麼。
// 注意這裏在函數返回前用了棧平衡操作
// ret函數是爲了平衡下面012016E0  call        func(0120123Fh)
// 4 纔是stdcall的精華,函數返回前平衡了012016DE  push        1導致的棧變化。
012016B4  ret         4
*/

int main()
{
    func(1);
    //012016DE  push        1
    //012016E0  call        func(0120123Fh)
    // call函數後面沒有任何對ESP操作的代碼,因爲在函數ret之前就完成了清棧工作,這就是stdcall.
    return 0;
}

同樣的我們比較一下把stdcall改爲cdecl,看下彙編代碼:

void __cdecl func(int) {}
/*
00B71690  push        ebp
00B71691  mov         ebp,esp
00B71693  sub         esp,0C0h
00B71699  push        ebx
00B7169A  push        esi
00B7169B  push        edi
00B7169C  lea         edi,[ebp-0C0h]
00B716A2  mov         ecx,30h
00B716A7  mov         eax,0CCCCCCCCh
00B716AC  rep stos    dword ptr es:[edi]
00B716AE  pop         edi
00B716AF  pop         esi
00B716B0  pop         ebx
00B716B1  mov         esp,ebp
00B716B3  pop         ebp
// 同樣注意這裏,當改爲cdecl調用後,ret指令後面就沒東西了
00B716B4  ret
*/

int main()
{
    func(1);
    /*
    00B716DE  push        1
    00B716E0  call        func (0B71343h)
    // 函數調用結束後,生成的彙編代碼函數ret之後,改變了ESP用以平衡之前的push 1操作
    00B716E5  add         esp,4
    */
    return 0;
}

2 . C++的返回值在彙編的eax寄存器位置
  void返回值類型的函數並不意味着一定不能返回參數.

void __stdcall func1() 
{
    __asm
    {
        mov eax, 10
    }
}

int __stdcall func2()
{
    __asm
    {
        mov eax, 11
    }
}
int main()
{
    int x=0;
    __asm
    {
        call func1
        mov x,eax
    }
    printf_s("x=%d  func2:%d\n", x,func2());
    return 0;
}

  上面兩段函數,內部代碼是一樣的,雖然函數返回值定義一個是void類型,一個是int類型,但都可以傳遞值出來。因爲C/C++的返回值存在eax寄存器上。

  3. 引用與指針
  衆所周知,引用是變量的別名,那麼從彙編層面來看,引用時如何處理的呢?

void __stdcall f(int a,int b,int &c)
{
    // c = a+b;
    __asm
    {
        mov eax, dword ptr[a]
        add eax, dword ptr[b]
        mov ecx, dword ptr[c]  // 取得c的地址,注意這裏用的是mov,不是lea
        mov dword ptr [ecx],eax  
    }
}

int main()
{
    int c=0;
    // f(1,2,c);
    __asm
    {
        // 從右至左壓棧,所以進棧順序時c的地址,2,1
        // 最後通過call進入f函數
        lea  eax, [c]
        push eax
        push 2
        push 1
        call f
    }
    printf_s("%d", c);
    return 0;
}

  有趣的是,將上述代碼中f函數參數類型改爲int*代碼仍然是可以正確運行的。所以從彙編層面看,引用傳遞與指針相差無幾,都是傳遞變量地址。從C語言代碼層面看,引用傳遞時的所有操作之前地址都會被解引用。比如上面的c=a+b。所以這也正是爲什麼引用的變量不能被賦爲空指針的原因。
  
  4. 彙編調用printf
  因爲printf允許接受可變參數,所以肯定是cdecl調用方式。其實還是亙古不變的老道理,參數由右至左壓棧,然後call函數,返回值保存在eax中。

    const char* printf_str = "name:%s age:%d\n";
    char name[] = "zhou";
    int result = -1;
    __asm
    {
        push 20   // age壓棧
        lea eax,dword ptr[name]
        push eax  // name 壓棧
        mov eax,dword ptr[printf_str]
        push eax  // 打印格式字符串壓棧
        call printf
        add esp,12  // cdecl自己清棧
        mov result,eax // printf返回值
    }
    printf_s("last printf result:%d\n", result);

  其中有兩點需要特殊說明:
  a. 同樣的字符串操作,char*壓棧時直接push變量本身就好了(因爲指針保存的就是字符串的地址),在對char數組壓棧時,需要先lea獲取首字母地址再push。
  b. printf其實是有返回值的,返回輸出字符的個數,若出錯,則返回負數。
  

  5. c++調用實例方法
  首先先區分函數(function)和方法(method)的區別: in-c-what-is-the-difference-between-a-method-and-a-function
  那麼method相比於function會隱性傳入一個對象指針作爲this參數,那麼從彙編層次可以更清晰的看到這種傳參過程。(精華在註釋)

class A
{
public:
    int f(int x)
    {
        // 此處省略部分彙編代碼
        // 從ecx取出對象地址賦值給this指針
        //00221850  mov         dword ptr[this], ecx
        return x;
        //00221853  mov         eax, dword ptr[x]
        //00221857  pop         esi
        //00221858  pop         ebx
        //00221859  mov         esp, ebp
        //0022185B  pop         ebp
        // 看起來採用stdcall的調用方式,函數內部返回前平衡棧。
        //0022185C  ret         4
    }
};

int main()
{
    A* a = new A();
    a->f(10);
    //002218C8  push        0Ah
    //002218CA  mov         ecx, dword ptr[a] // 在正式調用實例方法之前將a對象地址放入了ecx中
    //002218CD  call        A::f(02213E8h)
    return 0;
}

  總結:
  1. 在調用實例方法之前將實例對象地址放入ecx寄存器並在函數開始將其取出賦值給this對象,這就是c++在調用method時this對象的隱性傳入方式。
  在實例對象返回前完成了棧平衡操作,所以是calling convention是stdcall。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章