(上)文中我们讨论了如何实现程序的控制,本文我们会看看如何实现不同的数据结构,比如数组、结构体等。由于C语言缺乏边界检查,因此会使程序出现缓冲区溢出的问题,我们也会介绍系统为了应对该问题而提供的一些安全保护机制。
数组
C语言中的数组是一种聚集标量数据的方式。
T A[N]
声明了数据类型为T
,长度为N
的数组A
。假设数组 A 的起始地址为,则数组元素 A[i] 的地址为
。也可以用
A
作为指向数组开头的指针,这个指针的值就是。
C语言允许对指针进行运算。比如E
是一个指向类型为 T 的数据的指针,E
的值为,那么表达式
E+i
的值就是,
是数据类型 T 以字节为单位的大小。
数组中的每个元素其实都有2个属性,一个是它的存储地址,另一个是它存储的内容。对于元素的存储地址,可以通过取地址运算符&来获得,得到一个指向该位置的指针(指针其实就是地址的抽象表述);对于元素存储的内容,可以通过指针运算符*从该地址处取数据。

多维数组(嵌套数组)
二维数组int A[5][3]
在内存中的存储方式如下:

在计算机系统中,我们通常把内存抽象为一个巨大的数组。对于二维数组A, 可以看出,数组元素在内存中按照行优先的顺序存储。
变长数组
在C89标准中,C语言只支持大小在编译时就能确定的多维数组,因此在需要变长数组时不得不使用malloc
这样的函数为数组分配空间。而ISO C99标准引入了变长数组的功能,允许数组的维度是表达式。

变长数组可以作为一个局部变量;也可以作为函数参数,这里参数n
必须在参数A[n][n]
之前,这样函数就可以在遇到这个数组时计算出数组的维度。
异质的数据结构
C语言提供了将不同类型的对象组合到一起的数据类型——结构体。类似数组,结构体的所有组成部分都存放在内存中一段连续的区域内,而指向结构体的指针就是结构体第一个字节的地址。
我们通过起始地址加偏移量的方式来访问结构体中的字段。

假设struct rec*
类型的变量r
存在寄存器%rdi
中,上图两条汇编指令则将元素r->i
复制到r->j
:
- 由于字段
i
相对于结构体起始地址的偏移量为0,因此字段i
的地址就是r
的值,而字段j
的偏移量为4,因此需要将r
加上偏移量4。
数据对齐
此外,为了提供内存系统的性能,许多计算机系统对于数据存储的合法地址做了一些对齐限制,即要求任何K字节的基本对象的地址必须是K的倍数。

在上图结构体中,字段j
是int类型,占4个字节,它的起始地址必须为4的倍数。因此编译器会在字段c
和j
之间插入一个3字节的间隙,使得字段j
的偏移量为8,满足4字节对齐限制。从而使得整个结构体的大小变成12字节。
联合体union
与结构体不同,联合体中的所有字段共享同一存储区域,因此联合体的大小取决于它的最大字段的大小。

联合体一般应用在当我们知道两个不同字段的使用是互斥的,那么将这两个字段声明为联合体,可以减小占用的内存空间。
机器级程序中数据与控制的交互
理解指针
指针是C语言的一个核心特色,以一种统一方式,对不同数据结构中的元素产生引用。指针有以下特点:
- 每个指针都对应一个类型。这个类型表明该指针指向的是哪一类对象。
- 每个指针都有一个值。这个值是某个指定类型对象的地址。特殊的空指针没有指向任何地方。
- 指针用&运算符创建。
- *操作符用于间接引用指针,其结果是一个 值,该值的类型与指针的类型一致。间接引用是用内存引用来实现的,要么是存储到一个指定的地址,要么是从指定的地址读取。
- 数组与指针与紧密相连。一个数组的名字可以像一个指针变量一样引用(但是不能修改)。
内存越界引用(缓冲区溢出)
C语言对于数组引用不进行任何边界检查,并且局部变量和状态信息都存放在栈中。因此,对越界的数组元素的写操作会破坏存储在栈中的状态信息。比如当存储的返回地址的值被破坏了,那么ret
指令会导致程序跳到一个完全意向不到的地方。
对抗缓冲区溢出攻击
许多计算机病毒利用缓冲区溢出的方式对计算机系统进行攻击,因此现代的编译器和操作系统实现了很多机制,来避免入侵者通过这种攻击方式获得系统控制权:
- 栈随机化。栈的位置在程序每次运行时都有变化。因此,即使许多机器都运行同样的代码,它们的栈地址也是不同的。
- 栈破坏检测。虽然C中没有可靠的方法阻止对数组的越界写,但是我们可以在发生了越界写的时候,尝试及时检测到它。GCC编译器在产生的汇编代码中加入了栈保护者机制来检测缓冲区越界。就是在缓冲区与栈保存的状态值之间存一个金丝雀值,该值是随机产生的,因此攻击者没有简单方式知道它是什么。在函数返回之前,程序检测金丝雀值是否改变来判断是否被攻击。

- 限制可执行代码区域。消除攻击者向系统中插入可执行代码的能力,一种方法是限制哪些内存区域能够存放可执行代码。
许多系统允许三种访问形式:读(从内存读数据)、写(存储数据到内存)、执行(将内存的内容看作机器代码)。现在处理器的内存保护引入了不可执行位,将读和执行访问模式分开。有了这个特性,栈可以被标记为可读和可写,但是不可执行,而检查页是否可执行由硬件来完成,效率上没有损失。
浮点代码
处理器的浮点体系结构影响对浮点数据操作的程序如何被映射到机器上。其中AVX浮点体系结构允许数据存储在16个YMM寄存器中,每个寄存器都是256位(32字节)。当对标量数据操作时,这些寄存器只保存浮点数,而且只使用低32位(对于float)或64位(对于double)。
当函数包含指针、整数和浮点数混合的参数时,指针和整数通过通用寄存器传递,而浮点值通过YMM寄存器传递。
用AVX为浮点数上的操作产生的机器代码风格类似于为整数上的操作产生的代码风格。它们都使用一组寄存器来保存和操作数据值,以及传递函数参数。
总结
本章我们只分析了C语言到x86-64处理器的代码映射,C++的编译和C非常相似,但是Java的实现方式却完全不同。Java的目标代码是一种特殊的二进制表示,称为Jave字节代码,该代码可以看成是虚拟机的机器级程序。还有一种称为及时编译的方法,动态地将字节代码序列翻译成机器指令,当代码要执行多次时(比如在循环中),该方法执行起来更快。
用字节代码作为程序的低级表示,优点是相同的代码可以在许多不同的机器上执行,而本章讨论的机器代码只能在x86-64机器上运行。
网友评论