31.1

举个简单的例子。

在程序内部,C++类的表示基本和结构体一样。让我们试试这个有2个变量,2个构造函数和1个方法的类。

  1. #include <stdio.h>
  2. class c
  3. {
  4. private:
  5. int v1;
  6. int v2;
  7. public:
  8. c() // default ctor
  9. {
  10. v1=667;
  11. v2=999;
  12. };
  13. c(int a, int b) // ctor
  14. {
  15. v1=a;
  16. v2=b;
  17. };
  18. void dump()
  19. {
  20. printf ("%d; %d", v1, v2);
  21. };
  22. };
  23. int main()
  24. {
  25. class c c1;
  26. class c c2(5,6);
  27. c1.dump();
  28. c2.dump();
  29. return 0;
  30. };

31.1.1 MSVC-X86

这里可以看到main()函数是如何被翻译成汇编代码的:

  1. _c2$ = -16 ; size = 8
  2. _c1$ = -8 ; size = 8
  3. _main PROC
  4. push ebp
  5. mov ebp, esp
  6. sub esp, 16 ; 00000010H
  7. lea ecx, DWORD PTR _c1$[ebp]
  8. call ??0c@@QAE@XZ ; c::c
  9. push 6
  10. push 5
  11. lea ecx, DWORD PTR _c2$[ebp]
  12. call ??0c@@QAE@HH@Z ; c::c
  13. lea ecx, DWORD PTR _c1$[ebp]
  14. call ?dump@c@@QAEXXZ ; c::dump
  15. lea ecx, DWORD PTR _c2$[ebp]
  16. call ?dump@c@@QAEXXZ ; c::dump
  17. xor eax, eax
  18. mov esp, ebp
  19. pop ebp
  20. ret 0
  21. _main ENDP

所以,发生什么了。对每个对象来说(而不是类c),会分配8个字节。这正好是2个变量存储所需的大小。 对c1来说一个默认的无参数构造函数??0c@@QAE@XZ会被调用。对c2来说另一个??0c@@QAE@HH@Z会被调用,有两个数字会被作为参数传递。 指向对象的指针(C++术语的“this”)会被通过ECX寄存器传递。这被叫做thiscall(31.1.1)–这是一个指向对象的指针传递方式。 MSVC使用ECX来传递它。无需说明的是,它并不是一个标准化的方法,其他编译器可能用其他方法,例如通过第一个函数参数,比如GCC就是这么做的。 为什么函数的名字这么奇怪?这是因为名字打碎方式的缘故。 C++类可能有多个同名的重载函数,因此,不同的类也可能有相同的函数名。 名字打碎可以把类的类名+函数名+参数类型编码到一个字符串里面,然后它就会被用作内部名称。这完全是因为编译器和DLL OS加载器都 不知道C++或者面向对象的缘故。 Dump()函数在之后被调用了2次。 让我们看看构造函数的代码。

  1. _this$ = -4 ; size = 4
  2. ??0c@@QAE@XZ PROC ; c::c, COMDAT
  3. ; _this$ = ecx
  4. push ebp
  5. mov ebp, esp
  6. push ecx
  7. mov DWORD PTR _this$[ebp], ecx
  8. mov eax, DWORD PTR _this$[ebp]
  9. mov DWORD PTR [eax], 667 ; 0000029bH
  10. mov ecx, DWORD PTR _this$[ebp]
  11. mov DWORD PTR [ecx+4], 999 ; 000003e7H
  12. mov eax, DWORD PTR _this$[ebp]
  13. mov esp, ebp
  14. pop ebp
  15. ret 0
  16. ??0c@@QAE@XZ ENDP ; c::c
  17. _this$ = -4 ; size = 4
  18. _a$ = 8 ; size = 4
  19. _b$ = 12 ; size = 4
  20. ??0c@@QAE@HH@Z PROC ; c::c, COMDAT
  21. ; _this$ = ecx
  22. push ebp
  23. mov ebp, esp
  24. push ecx
  25. mov DWORD PTR _this$[ebp], ecx
  26. mov eax, DWORD PTR _this$[ebp]
  27. mov ecx, DWORD PTR _a$[ebp]
  28. mov DWORD PTR [eax], ecx
  29. mov edx, DWORD PTR _this$[ebp]
  30. mov eax, DWORD PTR _b$[ebp]
  31. mov DWORD PTR [edx+4], eax
  32. mov eax, DWORD PTR _this$[ebp]
  33. mov esp, ebp
  34. pop ebp
  35. ret 8
  36. ??0c@@QAE@HH@Z ENDP ; c::c

构造函数只是函数,它们会使用ECX中存储的指向结构体的指针,然后把指针指向自己的本地变量,但是,这个操作并不是必须的。 对C++标准来说我们知道构造函数不应该返回任何值。事实上,构造函数会返回指向新创建对象的指针,比如“this”。 现在看看dump()函数:

  1. _this$ = -4 ; size = 4
  2. ?dump@c@@QAEXXZ PROC ; c::dump, COMDAT
  3. ; _this$ = ecx
  4. push ebp
  5. mov ebp, esp
  6. push ecx
  7. mov DWORD PTR _this$[ebp], ecx
  8. mov eax, DWORD PTR _this$[ebp]
  9. mov ecx, DWORD PTR [eax+4]
  10. push ecx
  11. mov edx, DWORD PTR _this$[ebp]
  12. mov eax, DWORD PTR [edx]
  13. push eax
  14. push OFFSET ??_C@_07NJBDCIEC@?$CFd?$DL?5?$CFd?6?$AA@
  15. call _printf
  16. add esp, 12 ; 0000000cH
  17. mov esp, ebp
  18. pop ebp
  19. ret 0
  20. ?dump@c@@QAEXXZ ENDP ; c::dump

简单的可以:dump()会把带有2个int的结构体传给ecx,然后从他里面取出2个值,然后传给printf()。 如果使用/Ox优化,代码会更短。

  1. ??0c@@QAE@XZ PROC ; c::c, COMDAT
  2. ; _this$ = ecx
  3. mov eax, ecx
  4. mov DWORD PTR [eax], 667 ; 0000029bH
  5. mov DWORD PTR [eax+4], 999 ; 000003e7H
  6. ret 0
  7. ??0c@@QAE@XZ ENDP ; c::c
  8. _a$ = 8 ; size = 4
  9. _b$ = 12 ; size = 4
  10. ??0c@@QAE@HH@Z PROC ; c::c, COMDAT
  11. ; _this$ = ecx
  12. mov edx, DWORD PTR _b$[esp-4]
  13. mov eax, ecx
  14. mov ecx, DWORD PTR _a$[esp-4]
  15. mov DWORD PTR [eax], ecx
  16. mov DWORD PTR [eax+4], edx
  17. ret 8
  18. ??0c@@QAE@HH@Z ENDP ; c::c
  19. ?dump@c@@QAEXXZ PROC ; c::dump, COMDAT
  20. ; _this$ = ecx
  21. mov eax, DWORD PTR [ecx+4]
  22. mov ecx, DWORD PTR [ecx]
  23. push eax
  24. push ecx
  25. push OFFSET ??_C@_07NJBDCIEC@?$CFd?$DL?5?$CFd?6?$AA@
  26. call _printf
  27. add esp, 12 ; 0000000cH
  28. ret 0
  29. ?dump@c@@QAEXXZ ENDP ; c::dump

还要说的就是栈指针在调用add esp ,x之后并不正确。所以构造函数还需要ret 8来返回,而不是ret。 这是因为这儿调用方式是thiscall(31.1.1),这个方法会使用栈来传递参数,和stdcall对比(47.2)来看,他将为被调用者维护正确的栈,而不是调用者。Ret x指令会额外的给esp加上x,然后会把控制流交还给调用者函数。 调用转换见47章。 还有需要注意的是,编译器会决定什么时候调用构造函数什么时候调用析构函数,但是我们从c++语言基础里面已经知道调用时机了。

31.1.2 MSVC-x86-64

像我们已经知道的那样,x86-64中前4个函数参数是通过RCX/RDX/R8/R9寄存器传递的,剩余的通过栈传递。但是this是用RCX传递的 ,而第一个函数参数是从RDX开始传递的。我们可以通过c(int a, int b)这个函数看出来。

  1. ; void dump()
  2. ?dump@c@@QEAAXXZ PROC ; c::dump
  3. mov r8d, DWORD PTR [rcx+4]
  4. mov edx, DWORD PTR [rcx]
  5. lea rcx, OFFSET FLAT:??_C@_07NJBDCIEC@?$CFd?$DL?5?$CFd?6?$AA@ ; ’%d; %d
  6. jmp printf
  7. ?dump@c@@QEAAXXZ ENDP ; c::dump
  8. ; c(int a, int b)
  9. ??0c@@QEAA@HH@Z PROC ; c::c
  10. mov DWORD PTR [rcx], edx ; 1st argument: a
  11. mov DWORD PTR [rcx+4], r8d ; 2nd argument: b
  12. mov rax, rcx
  13. ret 0
  14. ??0c@@QEAA@HH@Z ENDP ; c::c
  15. ; default ctor
  16. ??0c@@QEAA@XZ PROC ; c::c
  17. mov DWORD PTR [rcx], 667 ; 0000029bH
  18. mov DWORD PTR [rcx+4], 999 ; 000003e7H
  19. mov rax, rcx
  20. ret 0
  21. ??0c@@QEAA@XZ ENDP ; c::c

X64中,Int数据类型依然是32位的。所以这里也使用了32位寄存器部分。 我们还可以看到dump()里的JMP printf,而不是RET,这个技巧我们已经在11.1.1里面见过了。

31.1.3 GCC-x86

几乎和GCC4.4.1一样的结果,除了几个例外。

  1. public main
  2. main proc near ; DATA XREF: _start+17
  3. var_20 = dword ptr -20h
  4. var_1C = dword ptr -1Ch
  5. var_18 = dword ptr -18h
  6. var_10 = dword ptr -10h
  7. var_8 = dword ptr -8
  8. push ebp
  9. mov ebp, esp
  10. and esp, 0FFFFFFF0h
  11. sub esp, 20h
  12. lea eax, [esp+20h+var_8]
  13. mov [esp+20h+var_20], eax
  14. call _ZN1cC1Ev
  15. mov [esp+20h+var_18], 6
  16. mov [esp+20h+var_1C], 5
  17. lea eax, [esp+20h+var_10]
  18. mov [esp+20h+var_20], eax
  19. call _ZN1cC1Eii
  20. lea eax, [esp+20h+var_8]
  21. mov [esp+20h+var_20], eax
  22. call _ZN1c4dumpEv
  23. lea eax, [esp+20h+var_10]
  24. mov [esp+20h+var_20], eax
  25. call _ZN1c4dumpEv
  26. mov eax, 0
  27. leave
  28. retn
  29. main endp

我们可以看到另一个命名破碎模式,这个GNU特殊的模式可以看到指向对象的this时针其实是作为函数的第一个参数被传入的,当然,这个对程序员来说是透明的。 第一个构造函数:

  1. public _ZN1cC1Ev ; weak
  2. _ZN1cC1Ev proc near ; CODE XREF: main+10
  3. arg_0 = dword ptr 8
  4. push ebp
  5. mov ebp, esp
  6. mov eax, [ebp+arg_0]
  7. mov dword ptr [eax], 667
  8. mov eax, [ebp+arg_0]
  9. mov dword ptr [eax+4], 999
  10. pop ebp
  11. retn
  12. _ZN1cC1Ev endp

他所做的无非就是使用第一个传来的参数写入两个数字。 第二个构造函数:

  1. public _ZN1cC1Eii
  2. _ZN1cC1Eii proc near
  3. arg_0 = dword ptr 8
  4. arg_4 = dword ptr 0Ch
  5. arg_8 = dword ptr 10h
  6. push ebp
  7. mov ebp, esp
  8. mov eax, [ebp+arg_0]
  9. mov edx, [ebp+arg_4]
  10. mov [eax], edx
  11. mov eax, [ebp+arg_0]
  12. mov edx, [ebp+arg_8]
  13. mov [eax+4], edx
  14. pop ebp
  15. retn
  16. _ZN1cC1Eii endp

这是个函数,原型类似于:

  1. void ZN1cC1Eii (int *obj, int a, int b)
  2. {
  3. *obj=a;
  4. *(obj+1)=b;
  5. };

这是完全可以预测到的,现在看看dump():

  1. public _ZN1c4dumpEv
  2. _ZN1c4dumpEv proc near
  3. var_18 = dword ptr -18h
  4. var_14 = dword ptr -14h
  5. var_10 = dword ptr -10h
  6. arg_0 = dword ptr 8
  7. push ebp
  8. mov ebp, esp
  9. sub esp, 18h
  10. mov eax, [ebp+arg_0]
  11. mov edx, [eax+4]
  12. mov eax, [ebp+arg_0]
  13. mov eax, [eax]
  14. mov [esp+18h+var_10], edx
  15. mov [esp+18h+var_14], eax
  16. mov [esp+18h+var_18], offset aDD ; "%d; %d
  17. "
  18. call _printf
  19. leave
  20. retn
  21. _ZN1c4dumpEv endp

在这个函数的内部表达中有一个单独的参数,被用作指向当前对象,也即this。 因此,如果从这些简单的例子来看,MSVC和GCC的区别也就只有函数名编码的区别和传入this指针的区别(ECX寄存器或通过第一个参数)。

31.1.14 GCC-X86-64

前6个参数,会通过RDI/RSI/RDX/RCX/R8/R9[21章]的顺序传递,this指针会通过第一个RDI来传递,我们可以接着看到。 Int数据类型也是一个32位的数据,JMP替换RET的技巧这里也用到了。

  1. ; default ctor
  2. _ZN1cC2Ev:
  3. mov DWORD PTR [rdi], 667
  4. mov DWORD PTR [rdi+4], 999
  5. ret
  6. ; c(int a, int b)
  7. _ZN1cC2Eii:
  8. mov DWORD PTR [rdi], esi
  9. mov DWORD PTR [rdi+4], edx
  10. ret
  11. ; dump()
  12. _ZN1c4dumpEv:
  13. mov edx, DWORD PTR [rdi+4]
  14. mov esi, DWORD PTR [rdi]
  15. xor eax, eax
  16. mov edi, OFFSET FLAT:.LC0 ; "%d; %d
  17. "
  18. jmp printf