VC11.0编译器对C/C++代码的原始实现原理(部分)
本文主要讲的是VC编译器如何按照“我们所表达的意思”编译,VC11.0的Release配置版本默认会对源码进行优化,这样的话无法还原源码和机器指令的对应关系,所以我关闭了编译器所有的优化,这也是为什么本文叫“代码的原始实现原理”的原因。本文难免有错误,欢迎指正!
为了方便查看汇编代码的函数调用关系,所以采取动态链接VC运行库,以方便加载VC库函数的符号。
这里声明一个约定:文中提到的内存地址均是16进制的。
第一部分:变量的内存分配
在介绍之前,先说明一下CPU的寄存器,我的计算机是64位的,但是为了方便,所以编译的程序是32位的,而且使用的32位调试器来进行分析的。
这里简单介绍一下常用的寄存器。 32位平台常用的CPU寄存器如下:
EAX
ECX
EDX
EBX //前面四个寄存器通常是存放临时数据
ESP //ESP是非常关键的一个寄存器,它的作用是记录栈顶的内存地址
EBP //在VC编译器编译出来的程序中,EBP的值通常作为局部变量寻址的基址
ESI
EDI
EIP //EIP用于记录程序当前执行指令所在的内存地址
上述寄存器的长度都是32位,即4字节长度,本部分着重需要弄明白ESP,EBP的用途,下面举实例做讲解:
一.局部非静态变量的内存分配
实例1
C源码:
#include <stdio.h> int main() { char ch='a'; return 0; }
反汇编结果:
00011000 >/$ 55 push ebp ;将原来寄存器ebp的值入栈 00011001 |. 8BEC mov ebp,esp ;将寄存器esp的值传送到ebp 00011003 |. 51 push ecx ;将寄存器ecx的值入栈 00011004 |. C645 FF 61 mov byte ptr ss:[ebp-0x1],0x61 ;将字符'a'的ASCII码0x61传送到内存地址为ebp-0x1的内存空间里, ;这个内存空间就是变量ch的内存空间,注意到这个内存空间是怎么 ;来的,后面详细解释 00011008 |. 33C0 xor eax,eax ;VC的编译器通常将寄存器EAX作为返回值,对eax自身进行异或运算 ;结果为0,于是就对应源码里面的return 0; 0001100A |. 8BE5 mov esp,ebp 0001100C |. 5D pop ebp ;上面两句与函数开头的两句指令相对应,作用是恢复函数调用前寄 ;存器的值,清理现场。 0001100D . C3 retn ;返回
大部分指令都好理解,唯一不太好理解的是“ch变量的内存空间是怎么来的?”。
下面做一个假设来模拟运行上诉指令:
假设刚进入main函数的时候,esp的值为0025F9E8,即此时栈顶的地址为0025F9E8
push ebp
这时esp的值减去4变为0025F9E4,因为寄存器ebp是4个字节长度
这里要说明一下实际压栈的原理,CPU实际的压栈操作并不是真的把数据往下面“压”,而是在栈顶的“上面”添加数据,再把esp寄存器减去相应的长度数值(栈是中的数据是越靠近栈顶,内存地址越低,所以减少esp的值就相当于“升高”栈顶),这样就“变相”地完成了“压栈”操作了。这是一种灵活的处理方法,毕竟如果真是“压”的话,要将后面的数据全部往下面移动,性能开销太大了。
mov ebp,esp
将esp现在的值传送给ebp,所以ebp保存着当前esp的值0025F9E4
push ecx
将ecx的值“压”到栈顶,此时esp-4,所以esp的值是0025F9E0,ebp的值仍然是0025F9E4
mov byte ptr ss:[ebp-0x1],0x61
此时实际上就是把'a'的ASCII码0x61保存到0025F9E4-1=0025F9E3。这个0025F9E3就是变量ch的内存地址。
好了,到此为止,看明白了么? 那句push ecx就是分配ch变量的内存的关键,其实ecx的值是无关数据,压栈的目的不是为了临时保存ecx的值,而是将栈“空”四个字节出来,即给ch分配内存空间,共分配了4个字节(0025F9E0-0025F9E3),但实际上变量ch只用了1个字节,但区区浪费3个字节无所谓啦,毕竟只是短时间占用,没什么影响的。 接下来看另一个例子。
实例2
C源码:
#include <stdio.h> int main() { int a=100; int b=200; int c=300; int d=400; return 0; }
反汇编结果:
00EE1000 > 55 push ebp 00EE1001 8BEC mov ebp,esp 00EE1003 83EC 10 sub esp,0x10 ;将esp减去0x10,即减去16 ;在实例1的时候就讲过CPU压栈的原理,这里 ;将esp减去16,就是将栈顶“向上移动”16个字节 ;就相当于在栈顶预留16个字节 ;这16个字节就是给a、b、c、d这四个int变量分配的内存空间 00EE1006 C745 FC 6400000>mov dword ptr ss:[ebp-0x4],0x64 ;a=100 ;将0x64,也就是十进制的100保存到内存地址为ebp-0x4的内存空间 ;这和实例1的处理方法相同,请参照实例1后面的说明 ;都是用ebp的值作为基址来寻址变量内存空间的 00EE100D C745 F8 C800000>mov dword ptr ss:[ebp-0x8],0xC8 ;b=200 00EE1014 C745 F4 2C01000>mov dword ptr ss:[ebp-0xC],0x12C ;c=300 00EE101B C745 F0 9001000>mov dword ptr ss:[ebp-0x10],0x190 ;d=400 ;这四句指令类似,只是通过不同的偏移来寻址到各自的内存空间 00EE1022 33C0 xor eax,eax ;将eax清零,作为返回值 00EE1024 8BE5 mov esp,ebp 00EE1026 5D pop ebp ;这两句指令用于还原寄存器的值,使其值恢复到调用函数之前, 00EE1027 C3 retn ;返回
实例2和实例1不同的地方在于:实例1是通过对ecx压栈来分配内存,实例2是直接通过减掉寄存器esp的值来移动栈顶来分配内存。
相同的地方在于:局部非静态变量都是在栈上分配的内存空间,函数执行完以后,esp的值被还原成执行函数之前的值,就相当于释放了函数运行过程中占用的栈,这也是为什么局部非静态变量在函数执行结束后会数据会丢失的原因。
经过试验,
当函数里的局部非静态变量总大小小于等于4字节的时候,编译器会采取push ecx的方法分配这四个字节;
当函数里的局部非静态变量总大小大于4字节的时候,编译器会采取sub esp,0xXX的方法来分配这些变量的内存。
这样做的目的是减少指令长度,因为push ecx对应的机器指令只有1个字节长度,而sub esp,0xXX对应的机器指令则有3个字节。
有人又会问,局部非静态变量总大小为8字节的时候,为什么不采取连续两次push ecx的方法分配8字节内存呢?两次push ecx需要2字节,但也比3字节少啊?我个人认为这里有个平衡问题。
push ecx
CPU执行的时候,实际上将它分为两步
sub esp,0x4 mov [esp],ecx
本来这句mov [esp],ecx指令就是没有什么用处的东西,我们根本不需要保存ecx的值,我们现在需要的仅仅只是将栈顶“上移”也就是sub esp,0x4,如果两次push ecx就会做两次无用功。而且相对于访问CPU寄存器而言,访问内存效率要低得多(CPU访问内存必须经过总线),如果把这些因素考虑在内,为了达到性能和大小的平衡,两次push ecx还不如用sub esp,0x8
二.局部静态变量的内存分配
实例1
C源码:
#include <stdio.h> int main() { static int a; static char b; a=1994; b='X'; return 0; }
反汇编结果:
001A1000 > 55 push ebp 001A1001 8BEC mov ebp,esp 001A1003 C705 20301A00 C>mov dword ptr ds:[0x1A3020],0x7CA ;将1994赋值给a 001A100D C605 24301A00 5>mov byte ptr ds:[0x1A3024],0x58 ;将'X'赋值给b 001A1014 33C0 xor eax,eax 001A1016 5D pop ebp 001A1017 C3 retn
很明显,变量a和b的内存地址分别是0x1A3020和0x1A3024。由此看出局部静态变量所占内存空间的内存地址是固定的。
实例2
C源码:
#include <stdio.h> int main() { static int a=1994; static char b='X'; return 0; }
反汇编结果:
00961000 > 55 push ebp 00961001 8BEC mov ebp,esp 00961003 33C0 xor eax,eax 00961005 5D pop ebp 00961006 C3 retn
奇怪!怎么没有赋值过程啊?从反汇编结果来看在main函数里面似乎什么都没有做。确实,什么都没有做!这也是局部静态变量和局部非静态变量初始化值的方式的差异。为了继续探究,我们在原来的代码中加入一句printf()来找到这两个变量的内存地址。
修改后的C源码如下:
#include <stdio.h> int main() { static int a=1994; static char b='X'; printf("%d %d",&a,&b); return 0; }
反汇编结果:
011F1000 > 55 push ebp 011F1001 8BEC mov ebp,esp 011F1003 68 04301F01 push 0x11F3004 011F1008 68 00301F01 push 0x11F3000 011F100D 68 F8201F01 push 0x11F20F8 ; ASCII "%d %d" 011F1012 > FF15 90201F01 call dword ptr ds:[0x11F2090] ; msvcr110.printf 011F1018 > 83C4 0C add esp,0xC 011F101B 33C0 xor eax,eax 011F101D 5D pop ebp 011F101E C3 retn
现在我们能够看到变量a和b的内存地址分别是0x11F3000和0x11F3004,我们在查看一下相应的内存(内存数据是用十六进制表示的)。
0x11F3000 >CA 07 00 00 58 00 00 00 01 00 00 00 00 00 00 00 ?..X..........
在0x11F3000处的“CA070000”就是整数1994的十六进制的“小端方式(Little-endian)”存储值,在0x11F3004处的“58”就是'X'的ASCII码。
事实上,局部静态变量的初始值是由编译器硬编码到程序中的,随着程序的启动,这些值就随即映射到相应的内存空间里面,也就是说其初始值在程序启动的时候就已经有了。
由上面两个实例也可以看出,
TYPE VAR; VAR = VALUE;
和
TYPE VAR = VALUE;
所表达的意思其实并不相同。
前者是声明一个变量,然后再给它赋值;后者是声明一个初始值为多少的变量。对于局部非静态变量,两者的意义虽不同但是实现方法方法是一样的,因为局部非静态变量的初始值不能像静态变量那样,编译的时候就硬编码到程序.data段里面,局部非静态变量必须临时分配含有未知数据的内存空间,然后再赋值才能实现初始值,但是对静态变量而言,就看得出来这两种代码的差异了。
(当然,在打开编译器的优化选项以后,编译器会对源代码进行灵活处理,那样的话编译器对这两种代码的处理可能是相同的。对于开启优化选项的情况本文暂且不提,正如本文开头所说的那样,本文只是探究编译器是如何“按照我们的意思”编译程序的。开启优化选项的情况,以后会专门写一篇博文来探究)
三.全局(静态)变量的内存分配
“全局变量”本身其实也是“静态”的,但有些地方喜欢用全称——全局静态变量,为了简单,这里再声明一个约定:下文中均用“全局变量”这个术语。
实例1
C源码:
#include <stdio.h> int a=1994; int main() { a=820; return 0; }
反汇编结果:
01031000 > 55 push ebp 01031001 8BEC mov ebp,esp 01031003 C705 00300301 3>mov dword ptr ds:[0x1033000],0x334 ;将820赋值给变量a 0103100D 33C0 xor eax,eax 0103100F 5D pop ebp 01031010 C3 retn
我们很容易就看出,变量a的内存地址为0x1033000
联想第二节所分析的,全局变量和局部静态变量一样,内存空间是“硬分配”的,其初始值也是“硬编码”的。从底层上看全局变量和局部静态变量确实是一样的,只是编译器在检查代码的时候限制了局部变量的静态访问范围而已,但他们的实现方式和工作方式都相同。
有第二节和第三节可看出,静态变量(包括全局变量和局部静态变量)所占的内存空间的内存地址是固定的,它们是由编译器硬编码到程序中的,且静态变量所占用的内存空间从程序开始运行就一直被占用。另外,局部静态变量的初始值也是由编译器硬编码到程序中的相应数据段上的,随着程序的运行,这些初始值也随即映射到相应的内存空间里面供程序使用。
事实上:
1.编译器对没有显式指定初始值的静态变量,默认是按初始值为0来处理的(这一点大多数C/C++的书都有提到)。
2.硬编码到程序数据段上的静态变量的初始值,随着程序的运行数据段上的数据被映射到内存中,他们所占用的内存空间正是这些全局变量所用的内存空间,这一切都是编译器事先“计算和设计”好的,所以静态变量的内存地址是不变的。
四.数组的内存分配
数组也是一种重要的数据结构,他就是“在一块的多个变量”,类似数学中“集合”的概念。类似但不同,不同点在于,数组的元素没有要求“互异性”,数组的元素可以是相同的值,而且数组所包含的元素的内存地址是连续的。下面我们来看一下VC是如何分配数组所占内存的。
实例1
C源码:
int main() { int a[50]; return 0; }
反汇编结果:
01331000 >/$ 55 push ebp 01331001 |. 8BEC mov ebp,esp 01331003 |. 33C0 xor eax,eax 01331005 |. 5D pop ebp 01331006 \. C3 retn
怎么回事?从反汇编结果来看似乎main函数什么都没做啊?联想到第二节的实例2,因为那个地方也出现了这种情况。进而产生疑问,是不是数组也是静态分配的?为此我们也和之前一样加入一句printf()函数调用,并且向其传入数组的地址。
C源码:
#include <stdio.h> int main() { int a[50]; printf("%d",a); return 0; }
反汇编结果:
00981000 > 55 push ebp 00981001 8BEC mov ebp,esp 00981003 81EC CC000000 sub esp,0xCC ;分配204个字节,一个int型是4个字节,数组是由50个int组成的,也就是200字节, ;而多分配的4个字节是用于下面的安全检查,这个安全检查是防止缓冲区越界,这不 ;在本篇文章的讨论范围之内。 00981009 A1 00309800 mov eax,dword ptr ds:[0x983000] 0098100E > 33C5 xor eax,ebp 00981010 8945 FC mov dword ptr ss:[ebp-0x4],eax ;ebp-0x4用于下面安全检查,本文不讨论 00981013 8D85 34FFFFFF lea eax,dword ptr ss:[ebp-0xCC] ;将ebp-0xCC传入eax 00981019 50 push eax ;将eax压栈,作为printf()的第二个参数。也就是说ebp-0xCC就是数组的内存地址 ;长度为200字节 0098101A > 68 F8209800 push 0x9820F8 ; ASCII "%d" 0098101F > FF15 90209800 call dword ptr ds:[0x982090] ; msvcr110.printf ;调用printf()函数 00981025 83C4 08 add esp,0x8 00981028 > 33C0 xor eax,eax 0098102A 8B4D FC mov ecx,dword ptr ss:[ebp-0x4] 0098102D 33CD xor ecx,ebp 0098102F E8 04000000 call 00981038 ; 反汇编分.__security_check_cookie ;基于cookie的安全检查 00981034 8BE5 mov esp,ebp 00981036 5D pop ebp 00981037 C3 retn
事实证明,刚刚的代码编译出来的程序中,局部非静态数组不是静态分配的,而是动态分配的。
虽然我们关闭了编译器的优化,但是从实际情况看,如果程序中没有用到这个数组,那么编译器会省略掉对这个数组的内存分配。但从本实例可以看出,编译器对数组的内存分配也很简单:
TYPE VAR[N];
就是分配N个TYPE型的变量而已。由于数组是按一个整体来分配的,所以其成员的内存地址是连续的。
五.结构与对象的内存分配
首先,我说明一下,为什么我将结构和对象放在一起,原因是,在C++中,结构体已经被扩展为类了,什么?没搞错吧?类和结构是一样的?是的,至少在底层来看,他们是一样的。如果你还不相信,你可以试试下面的代码:
C源码:
#include <iostream> #include <cstdlib> struct structa { public: void set(int x,int y); int add(); private: int a; int b; }; void structa::set(int x,int y) { a=x; b=y; } int structa::add() { return a+b; } using namespace std; int main() { structa as; as.set(2,3); cout<<as.add()<<endl; system("pause"); }
是不是发现编译顺利通过了?对的。
我刚刚说了C++编译器在实现对象和结构的时候,在底层上,两者没有区别,但没有说“在编译阶段他们没有区别”,其实在编译阶段的区别很简单:类的成员默认属性是private,而结构的成员默认属性是public.读者可以自己去尝试。
正是因为从底层上,C++的编译器对类(对象)和结构的处理没有区别,所以下面的分析以类(对象)为准,好了,步入正题。
实例一
C源码:
#include <cstdlib> class classA { public: int a; int b; private: int c; int d; }; int main() { classA var; var.a=1; var.b=2; system("pause"); }
反汇编结果:
000F1F50 /$ 55 push ebp 000F1F51 |. 8BEC mov ebp,esp 000F1F53 |. 83EC 10 sub esp,0x10 ;分配16字节的内存 000F1F56 |. 8D4D F0 lea ecx,dword ptr ss:[ebp-0x10] 000F1F59 |. E8 32F3FFFF call 000F1290 ; 反汇编分.000F1290 000F1F5E |. C745 F0 01000>mov dword ptr ss:[ebp-0x10],0x1 ;将1赋值给成员a 000F1F65 |. C745 F4 02000>mov dword ptr ss:[ebp-0xC],0x2 ;将2赋值给成员b 000F1F6C |. 68 08310F00 push 0xF3108 ; /command = "pause" 000F1F71 |. FF15 BC300F00 call dword ptr ds:[0xF30BC] ; \system 000F1F77 |. 83C4 04 add esp,0x4 000F1F7A |. 8D4D F0 lea ecx,dword ptr ss:[ebp-0x10] 000F1F7D |. E8 AEF3FFFF call 000F1330 ; 反汇编分.000F1330 000F1F82 |. 33C0 xor eax,eax 000F1F84 |. 8BE5 mov esp,ebp 000F1F86 |. 5D pop ebp 000F1F87 \. C3 retn
从上面结果看,进入main函数以后,首先移动栈顶分配16个字节的内存空间用来存放var对象的a、b、c、d四个整数型成员,这和分配数组的内存空间的方法差不多。还有就是public和private属性在底层根本没有体现出来,原因是这些属性只是在编译阶段检查和约束访问权限,也就是说这只是编译器在编译的时候对代码进行检查,如果发现“违规”访问,然后就报告错误并且终止编译,而编译后,在底层是没有这个约束的。
但是我们知道,数组成员的类型必须相同,结构和类成员的类型可以不同,这就意味着结构和类(对象)的成员长度可能“参差不齐”,那么这会导致什么现象呢?
实例二
C源码:
#include <cstdlib>
class classA
{
public:
short a;
int b;
short c;
int d;
};
int main()
{
classA var;
var.a=1;
var.b=2;
var.c=3;
var.d=4;
system("pause");
}
反汇编结果:
01141000 /$ 55 push ebp 01141001 |. 8BEC mov ebp,esp 01141003 |. 83EC 14 sub esp,0x14 ;分配20字节内存空间 01141006 |. A1 00301401 mov eax,dword ptr ds:[0x1143000] 0114100B |. 33C5 xor eax,ebp 0114100D |. 8945 FC mov dword ptr ss:[ebp-0x4],eax ;前面4个字节用于安全检查 01141010 |. B8 01000000 mov eax,0x1 01141015 |. 66:8945 EC mov word ptr ss:[ebp-0x14],ax ;var.a=1; 01141019 |. C745 F0 02000>mov dword ptr ss:[ebp-0x10],0x2 ;var.b=2; 01141020 |. B9 03000000 mov ecx,0x3 01141025 |. 66:894D F4 mov word ptr ss:[ebp-0xC],cx ;var.c=3; 01141029 |. C745 F8 04000>mov dword ptr ss:[ebp-0x8],0x4 ;var.d=4; 01141030 |. 68 B8201401 push 0x11420B8 ; /command = "pause" 01141035 |. FF15 90201401 call dword ptr ds:[0x1142090] ; \system 0114103B |. 83C4 04 add esp,0x4 0114103E |. 33C0 xor eax,eax 01141040 |. 8B4D FC mov ecx,dword ptr ss:[ebp-0x4] 01141043 |. 33CD xor ecx,ebp 01141045 |. E8 04000000 call 0114104E ; 反汇编分.0114104E ;安全检查 0114104A |. 8BE5 mov esp,ebp 0114104C |. 5D pop ebp 0114104D \. C3 retn
(由于之前误删了分析报告,我又懒得重新写一份了,所以本文不再更新)