从汇编角度看变量的分配及数组名与指针的关系
在应聘的笔试中,这个考的似乎很多,以前只知道sizeof()的结论,但是也不清楚数组名到底是怎么回事,只知道可以隐式退化成指针。于是闲着没事干看了下编译器的实现方式。
下面从汇编代码下观察两者的区别。
首先可以先理解一下函数体内的局部变量的分配。进入函数体,会将参数和局部变量都压入堆栈段,在函数体内对局部变量和参数的寻址基本都是基于ebp这个堆栈基址的寄存器,函数的入口点为main,但是其实前面会先执行调用main的函数作初始化,所以进入main函数的时候会移动ebp,使它等于前面函数体的esp,即栈顶指针来形成属于自己的堆栈寻址区,也叫堆栈帧。当然进入时候会保存调用了这个函数的那个函数的ebp,用来恢复调用者的堆栈帧,而调用者的esp即等于调用函数的ebp,这样调用完成,在堆栈段的恢复就完成了。
C中的变量可以有两种分配方式,在栈中和在堆中。栈中即我们常说到的局部变量,因为自动释放的特性也称之为自动变量;在堆中分配的变量由程序员自己管理,分配和释放。二者的分配速度区别较大,栈区速度很快,但是有大小限制,且进入函数体的时候需要预先计算出函数体的所需的栈内存的大小,灵活度不够,也就是说不能分配变长的数组;堆由库函数调用系统API来分配内存,速度较慢,但是灵活,可以自由分配空间。
任何语言都要被解释成汇编代码来执行,所以对于变量的识别,仅仅只能靠地址。
如下的一个小函数
[cpp]
int _tmain(int argc, _TCHAR* argv[])
{
int size=0; return 0;
}
那么size这个变量,是如何引用或者绑定到这块内存的呢?
最终的引用肯定是地址,即编译器将其和一个内存给绑定起来了,那分配的大概流程是怎么样的?
我的猜想:编译器编译时候有这一张符号表,记录了符号名字及类型和它的地址及所占的字节数。
编译成汇编代码,就这么一句:
[plain]
mov dword ptr [ebp-8],0
那么上面猜测的符号表,至少有这么几个信息。 符号名:size(可能修饰成其它东西了) 类型 int(无所谓,只需要知道引用的内存大小即可) 所占字节数(4),后两个信息都存在于汇编后的dword ptr中,在这句话里,寻找到的内存是以ebp-8为首地址,引用了以首地址为起始点的4字节内存,这样就寻找到了这块内存。在函数体内所有用到这个自动变量的地方都会成为[ebp-8],即理解成为size这个变量引用了ebp-8开始的4字节。这个概念和C++中的引用非常相似,声明了后用于绑定于某个内存,中途不能改变绑定对象。
下面来看下数组的实现情况:
[cpp]
int _tmain(int argc, _TCHAR* argv[])
{
char a[2][3]={1,2,3,7,8,9};
//char (*p)[3]=a;
/*int size=0;*/
return 0;
}
对应的汇编代码为:
[plain]
00412BCE mov byte ptr [ebp-0Ch],1
00412BD2 mov byte ptr [ebp-0Bh],2
00412BD6 mov byte ptr [ebp-0Ah],3
00412BDA mov byte ptr [ebp-9],7
00412BDE mov byte ptr [ebp-8],8
00412BE2 mov byte ptr [ebp-7],9
我们已经知道在函数体内对局部变量的寻址都是依靠ebp来进行的。在二维数组的实现方式中,因分配在堆栈段,故分配是连续的,就是一个2*3的一维数组。像动态分配的二维数组,可能行指针是不连续的。数组名被替换成了首元素的地址。
[cpp]
char (*p)[3]=a;
[plain]
00412BEA lea eax,[ebp-0Ch]
00412BED mov dword ptr [ebp-18h],eax
p存的是第一个元素的地址,取有效地址给了p。所以理论上是数组名a的符号表中记录着数组的首地址,即a[0][0]的地址,因为C是一个强类型的语言,需要对这个指针解2次引用才能访问到第一个元素,其实数组名a,a[0],a[0][0]的值是一样的。当然第一维是对二维数组做一下偏移。
[plain]
*((char*)a+3)
a[1][0]
这两种方式是一样的,所以我们知道了对于机器码这一层是没有所谓的二维数组的,在C语言这一层是存在的,实现方式依旧是一维数组(分配在堆栈段的数组)。
从上面我们可以得出这么一个结论,分配在堆栈段的数组,无论是几维的,它都记录下了首元素的地址,理论上我们可以将其转为类型* 来解一次引用来访问每一个元素。在C语言层面上,对于易做图数组,每一次的解引用即为偏移相应的当前维数,比如有一个char[19][20][3][4]的数组,每对这个数组。
当然符号表还会记录其它信息,首元素的类型,即整个数组的大小。我们试着用sizeof操作符来获取数组长度。
[cpp]
int size=sizeof(a);
对应的汇编码:
[plain]
mov dword ptr [ebp-18h],6
dword ptr [ebp-18h]引用到的就是size这个自动变量,可以看出sizeof直接替换成了6,即对于数组名的sizeof,直接取的是符号表中记录下来的数组长度。
我们换个方式,用指针来寻找a[1[0]。
[plain]
char (*p)[3]=a;
00411A9A lea eax,[ebp-0Ch]
00411A9D mov dword ptr [ebp-18h],eax
p[1][0]=1;
00411AA0 mov eax,dword ptr [ebp-18h]
00411AA3 mov byte ptr [eax+3],1
可见在寻址过程中,首先依据符号表得到p引用的地址,保存入寄存器,然后再将1给该引用的地址引用的地址,而不是数组名情况下直接可以算出偏移量的情况了。
同时,我们也明白了为什么声明一个指向了数组的指针时为什么要指定剩下的几维了。对于一个指针变量p,它的符号表必须得指明指向的类型信息,就比如上面的p[1][0],假设不指明第二维的长度为3,编译器如何能实现 byte ptr [eax+3]?
由此我得出了这些结论:
1.无论数组的类型是什么,数组名都保存着首元素的地址,用数组名来引用元素,编译器能够通过符号表保存着的数组长度信息及类型信息来直接替换成元素的地址,而把数组名赋值给指针变量,将会失去数组长度信息,所以必须指定剩下的维数信息。当然在C语言层面上更加强调类型,假设是二维数组,声明了一个指向了数组的指针后,需对其解2次引用才能访问到元素。当然你也能通过强转为类型*,并偏移相应的字节数来访问到任何元素,但是编译器帮你搞定的事情,为什么要自己去做呢?
例如:
[plain]
char (*p)[c]
p[a][b];
==
byte ptr[ebp-a*c*1+b*1]
你也可以这样:
[plain]
*((char*)p+a*c+b);
后面加的a*c+b这个偏移,即为编译器替我们算好的偏移。
2.数组名不占用任何内存,但是它与自动变量是一样,也会引用到内存,例如上面的
[cpp]
char (*p)[3]=a;
00411A9A lea eax,[ebp-0Ch]
00411A9D mov dword ptr [ebp-18h],eax
p引用到了[ebp-18h]开始的4字节内存,数组也一样,只不过它引用的内存是数组元素的首元素。当我们直接使用数组名来寻址的时候,可以依靠符号表来直接得到首元素地址来进行寻址。而用p则不同了,用p进行寻址的时候,首先找到p符号引用的内存,进行一次寻址,找到p引用的内存,再对该内存做相应的读写。
所以任何的自动变量的符号都是不占内存的,最终都替换成了地址。但是假设以int a;a占用了4字节的理解的话,那么int a[4];a也是占用内存的,它占了4*4字节。
题外话:
定义数组的时候,int a[5],我认为存在着类似于int[5],char[2][10]之类的类型,而正是这种类型可以得出数组的大小。
[cpp]
char (*p)[3];
printf("%d",sizeof(*p));
这种类型也就是数组名的类型,该类型记录着维数信
补充:软件开发 , 其他 ,