1 编译器模式(1)
当我第一次学会了C和C++时候,我只是编写短的代码块,然后编译它,查看它在汇编语言中是什么样子。
这对我来说很容易,因为我这样做了很多次。C /C++代码和编译器产生的代码的对应关系印在我的脑海中如此之深,以至于当我看了x86代码的代码使便可以很快还原它的C代码。
也许这项技术对于别人来说是有用的,所以我将尝试描述一些例子。
1.1 Hello, world!
让我们从《C语言程序设计》书中著名的例子开始
1 #include <stdio.h> 2 int main() 3 { 4 printf("hello, world"); 5 return 0; 6 };
1.1.1 x86
MSVC
我们在MSVC 2010中编译它
cl 1.cpp /Fa 1.asm
(/Fa选项意味着生成汇编代码)
Listing 1.1: MSVC 2010
CONST SEGMENT $SG3830 DB ’hello, world’, 00H CONST ENDS PUBLIC _main EXTRN _printf:PROC ; Function compile flags: /Odtp _TEXT SEGMENT _main PROC push ebp mov ebp, esp push OFFSET $SG3830 call _printf add esp, 4 xor eax, eax pop ebp ret 0 _main ENDP _TEXT ENDS
MSVC生成的汇编代码使用Intel语法。Intel语法和AT&T语法的区别将在后续章节讨论。
编译器生成的1.obj文件将被链接为1. exe。
在我们的例子中,该文件包含两个段:CONST(数据常量)和_TEXT(代码)。
字符串“hello,world”在C /C++是const char *类型,但是它没有自己的名字。编译器需要使用该字符串,因此定义了一个内部名称$SG3830。
正如我们看到的,这是个标准的C / C++字符串,字符串是由00作为结束标志。
在代码段,_TEXT,只有一个函数:main()。函数main()由prologue code开始,由epilogue code结束(像几乎所有函数那样)
在我们看到调用了printf()函数:CALL _printf
在调用字符串地址(或者是指针)之前它在指令PUSH的帮助下压入堆栈。当printf()函数返回流控制给main()时,字符串地址(或者是指针)仍然保存在堆栈中。
当我们不再需要它时只需要修正栈指针(ESP寄存器)即可。ADD ESP, 4 意思是让ESP寄存器的值加4。为何是4呢?因为它是32位的代码,我们需要4字节地址出栈。
64位代码的话要加8。“ADD ESP, 4”相当于“POP register”, 但是该命令不需使用任何寄存器。
一些编译器(如Intel c++编译器)在相同的情况下可能使用POP ECX而不是用ADD。(如我们可以在Oracle RDBMS中看到,因为它使用Intel c++编译器)。这条指令
可以达到相同的作用但是ECX寄存器被改写了。
Intel C++ 编译器可能会使用POP ECX是因为它的操作码(Opcode)比ADD ESP, 4要更短。(1字节相对于3字节)
可以在1.2小结阅读更多的堆栈的知识。
调用printf()之后,最初的C/C++代码将会返回0-返回0作为main()函数的结果。在生成的代码中,由指令XOR EAX,EAX实现。XOR实际上是“异或”但是编译器通常用它
代替MOV EAX, 0-用样的道理是因为操作码比较短(2字节相对于5字节)。一些编译器可能会使用SUB EAX, EAX,它的含义是从eax的值中减去eax的值,结果也是0。
最后一个指令RET向调用者返回控制流。通常C/C++运行库(CRT,C runtime library)会将控制权交还给操作系统。
GCC
让我们在linux的GCC 4.4.1中编译相同的C/C++代码:gcc 1.c -o 1
然后在IDA的帮助下我们看看main()函数是怎么生成的。(IDA同MSVC一样使用INTEL语法)
注意 我们也可以用GCC生成intel语言的汇编代码,只需要通过添加-S -masm=intel选项
Listing 1.2: GCC
main proc near var_10 = dword ptr -10h push ebp mov ebp, esp and esp, 0FFFFFFF0h sub esp, 10h mov eax, offset aHelloWorld ; "hello, world" mov [esp+10h+var_10], eax call _printf mov eax, 0 leave retn main endp
结果几乎完全一样。“hello world” 字符串的地址首先存在EAX寄存器中然后入栈。函数的prologue中我们可以看到AND ESP, 0FFFFFFF0h使得ESP的值按16字节边界对齐。
结果使得栈中所有的值对齐。(如果内存定位是按照4或者16字节对齐,CPU能够更好的工作)SUB ESP, 10h在栈中分配了16字节空间,尽管在下面我们看到只需要4个字节。
原因在于在栈中也是16字节对齐的。然后字符串地址(或指针)没有使用PUSH指令直接写入栈空间。var_10 —一个局部变量同时也是printf()的参数。然后printf()函数被调用。
和msvc不同的是,GCC编译器在不选择优化的时候,使用MOV EAX, 0而不是用比较短的操作码。最后一个指令,leave-相当于MOV ESP, EBP和POP EBP指令的组合-换句话说,
这个指令使得栈指针返回并且恢复EBP寄存器的初始状态。这是必要的,因为我们在函数的开始修改了这些寄存器(ESP和EBP)的值(执行了MOV EBP, ESP / AND ESP, ...)
GCC: AT&T 语法
让我们看看这些在的汇编语言的AT&T语法如何表达。这个语法在UNIX世界更受欢迎。
Listing 1.3: let’s compile in GCC 4.7.3
gcc -S 1_1.c
我们可以得到这些:
Listing 1.4: GCC 4.7.3
1 .file "1_1.c" 2 .section .rodata 3 .LC0: 4 .string "hello, world" 5 .text 6 .globl main 7 .type main, @function 8 main: 9 .LFB0: 10 .cfi_startproc 11 pushl %ebp 12 .cfi_def_cfa_offset 8 13 .cfi_offset 5, -8 14 movl %esp, %ebp 15 .cfi_def_cfa_register 5 16 andl $-16, %esp 17 subl $16, %esp 18 movl $.LC0, (%esp) 19 call printf 20 movl $0, %eax 21 leave 22 .cfi_restore 5 23 .cfi_def_cfa 4, 4 24 ret 25 .cfi_endproc 26 .LFE0: 27 .size main, .-main 28 .ident "GCC: (Ubuntu/Linaro 4.7.3-1ubuntu1) 4.7.3" 29 .section .note.GNU-stack,"",@progbits
里面有很多的宏(以点开头)。目前我们对它不感兴趣。现在为了简化我们可以忽略它们。(除了以null结尾的字符编码的.string宏,就像C字符串一样)
然后得到如下代码:
Listing 1.5: GCC 4.7.3
1 .LC0: 2 .string "hello, world" 3 main: 4 pushl %ebp 5 movl %esp, %ebp 6 andl $-16, %esp 7 subl $16, %esp 8 movl $.LC0, (%esp) 9 call printf 10 movl $0, %eax 11 leave 12 ret
其中Intel and AT&T语法的一些区别是:
∙操作数位置相反:
Intel语法: <指令> <目的操作数> <源操作数>.
AT&T语法: <指令> <源操作数> <目的操作数>.
可以这样考虑:使用Intel语法时你可以在脑海中在操作数之间加个(=)等号,而使用AT&T语法时你可以加个向右的箭头(→)
AT&T:寄存器名字前必须加个百分号(%),立即数前面必须加个美元符号($),使用圆括号代替方括号。
AT&T:在每条指令后添加一个特殊的符号来定义数据类型
– l — 长字 (32 bits)
– w — 字 (16 bits)
– b —字节 (8 bits)
让我们回到编译结果:
它和我们在IDA里看到的结果是相同的,只有一个很小的区别:0FFFFFFF0h写成了$-16。它们是一样的:十进制的16在十六进制中是0x10。0x10等于0xfffffff0(32位数据类型)。
另外,使用MOV将返回值设为0,而不是XOR。MOV仅将值加载到寄存器,它的名字不太合适(数据没有移动),这条指令在其他架构下叫“load”或者其他类似的名字。
浙公网安备 33010602011771号