1.4 数组、结构体
将数组和结构体放在一起讨论,主要是因为它们都是复合数据类型。
1.4.1 数组
数组在C语言中是比较简单的结构,谈它之前我们先从一个问题开始。笔者学习C时就感到很奇怪,为什么数组的第一个元素的索引要从0开始,而不是1。后来看到Pascal语言可用索引1开始访问第一个元素,就非常鄙视C语言的“非人性做法”。当打开这个黑匣子分析清楚后,才明白自己的无知,一切选择都有原因。
让我们先猜测数组的访问方式。一个最基本的想法是,要访问一个内存只要拿到其地址即可。如何获取到第i个元素的地址?最直接的想法是,如果能拿到数组首部地址,加上相对偏移就能计算出第i个元素的地址。这个偏移比较好计算,因为每个元素大小一样,用元素个数乘以元素大小就能获得偏移量。图1.48给出了数组的内存结构。
图1.48
第1个元素的地址 = 首地址a
第2个元素的地址 = 首地址a + 1×元素大小
第3个元素的地址 = 首地址a + 2×元素大小
......
第i个元素的地址 =首地址a + (i – 1)×元素大小
这里是用1作为元素第一个编号,在计算地址时总要进行一次减法运算。现在应该发现C语言数组索引编号的奥妙了吧,奇怪的方式一定有其原因。如果从0开始编号,计算地址时就没有了减法,用违背常规的方式换来了计算速度:
第0个元素的地址 = 首地址a
第1个元素的地址 = 首地址a + 1×元素大小
第2个元素的地址 = 首地址a + 2×元素大小
......
第i个元素的地址 =首地址a + i×元素大小
好了,按照我们的习惯,到了实证的阶段。程序代码如下:
int array[5]; void main(){ array[0] = 1; array[1] = 2; array[2] = 3; }
其反汇编如下:
array[0] = 1; mov dword ptr ds:[004174c4h], 1 array[1] = 2; mov dword ptr ds:[004174c8h], 2 array[2] = 3; mov dword ptr ds:[004174cch], 3
可知,数组的首部地址是004174c4h;第2个元素是004174c8h,比前一个增加了4字节;第三个是004174cch,离首部偏移了8字节,用监视器查看array[0]的地址正好是004174c4h。因为int是4字节,所以偏移量是按4的整数倍递增的。
我们再来看数组是局部变量的情况,其代码如下:
int array[5]; array[0] = 1; mov dword ptr [ebp-18h], 1 // mov dword ptr[ebp - 18 h + 0 * 4], 1 array[1] = 2; mov dword ptr [ebp-14h], 2 // mov dword ptr[ebp - 18 h + 1 * 4], 2 array[2] = 3; mov dword ptr [ebp-10h], 3 // mov dword ptr[ebp - 18 h + 2 * 4], 3
每行后添加的注释等价代码清晰地显示了数组是按首部地址+偏移量的做法。这些代码还看不出基于0索引的数组的优势。就用下面一段典型的for循环访问数组来实证这一优势。
for(i = 0; i < 10; i++) ... a[i] = 1; 00412036 mov eax, dword ptr [ebp-8] 00412039 mov dword ptr [ebp+eax*4-38h], 1
其中,最后黑体所示的eax * 4正如前所示为偏移量计算:ebp–38h是a数组的首址,eax存储的是i的值(请自己证实)。
从以上反汇编我们也理解了C语言为什么会发生数组越界错误,因它只是拿到首部地址然后加偏移量,如果索引值超出范围,那么求得的元素地址也就超过了范围。请大家自己实验超越范围的数组元素访问,并查看其反汇编代码。
1.4.2 结构体
相对数组而言,更自由和更复杂的数据结构是结构体。还是老规矩,大家先猜测它在内存中会是什么样子。例如:
struct Person{ int age; int no; }
两个整数成员分配8字节应该就可以了。而且为了不浪费,这两个成员变量应该是连续的。如何访问其中的成员变量呢?与数组的思路相仿,如果拿到结构体首部地址,然后求偏移量即可。偏移量如何计算?因为代码是编译器生成的,它自然知道哪个字段在哪个位置,如Person的no成员是在偏4字节的地方,因为第一个成员占了4字节。
图1.49
下面用真实代码实证:
Person p; p.age = 1; mov dword ptr [ebp-0Ch], 1 p.no = 2; mov dword ptr [ebp-8], 2
从反汇编我们能得出其内存结构,见图1.49。
第一条mov指令对ebp–0ch地址赋值。第二条指令对ebp–8地址赋值,该地址正好比ebp–0ch大4字节,说明赋值给了结构体首部偏4字节的no成员:ebp–8=ebp–0ch(结构体的首部地址)+4。
用监视窗体来证明分析,见图1.50。可知,age的地址&p.age和ebp–0ch相等,为0x0012ff5c;no的地址&p.no与ebp–8相等,为0x0012ff60。
图1.50
我们再来看一段代码的编译结果,并将版本改为Release版本,且关掉优化选项(在“项目属性”中的“配置属性→C/C++→优化→优化”选项设定为禁用)。该效果在VC 6.0中的Debug版本中可以查看到。
int no; int age; age = 1; mov dword ptr [ebp-8], 1 no = 2; mov dword ptr [ebp-4], 2
在该编译状态下,将代码改为如下:
Person p; p.age = 1; mov dword ptr [ebp-8], 1 p.no = 2; mov dword ptr [ebp-4], 2
我们发现,两者的反汇编代码一模一样。换句话说,无法从反汇编确定某块内存到底是结构体还是相互比邻的局部变量。结构体并未在内存中有更多特殊性,没有用一段内存(如标志位之类)来表示这是一个结构体,内存大小看来就是全部成员变量之和(其实未必,请看1.5节),不过是编译器提供给我们的一个方便自动求取偏移量的方法:我们给定要访问的成员,编译器就能自动为我们确定出偏移的位置,如p.no就自动偏移4字节。