深入理解计算机系统(第3章 程序的机器级表示④)
3.8 数组分配和访问
C语言的一个不同寻常的特点是可以产生指向数组中元素的指针,并对这些指针进行运算。在机器代码中,这些指针会被翻译成地址计算。
3.8.1 基本原则
对于数据类型\(T\)和整型常数\(N\),声明为\(T\) \(A[N]\)。(起始位置表示为\(x_A\))
这个声明有两个效果
- 在内存中分配一个\(L·N\)字节的连续区域,这里\(L\)是数据类型T的大小(单位为字节)。
- 引入了标识符\(A\),可以用\(A\)来作为指向数组开头的指针,这个指针的值就是\(x_A\)。
可以用\(0\)~\(N - 1\)的整数索引来访问该数组元素。数组元素\(i\)会被存放在地址为\(x_A + L·i\)的地方。
3.8.2 指针运算
C语言允许对指针进行运算,如果\(p\)是一个指向类型为\(T\)的数据的指针,\(p\)的值为\(x_p\),那么表达式\(p + i\)的值为\(x_p + L·i\),这里的\(L\)是数据类型\(T\)的大小。(\(L\)被隐式地添加在索引\(i\)之前)
单操作数操作符‘&’和‘*’可以产生指针和间接引用指针——对于一个表示某个对象的表达式\(Expr\),\(\&Expr\)是给出该对象地址的一个指针;对于一个表示地址的表达式\(AExpr\),*\(AExper\)给出该地址处的值。(eg:\(A[i]\)等同于表达式*\((A + i)\))
3.8.3 嵌套的数组
声明一个多维数组为\(T\) \(D[R][C]\)(数组元素在内存中按照“行优先”的顺序排列)。
要访问多维数组的元素,编译器会以数组起始为基地址,偏移量为索引,产生计算期望的元素的偏移量,然后使用某种MOV指令。
数组元素\(D[i][j]\)的内存地址为:\(\&D[i][j] = x_D + L( C·i + j )\)。
3.8.4 定长数组
C语言编译器能够优化定长多维数组上的操作代码。
以如下16×16的整型数组为例:
#define N 16
typedef int fix_matrix[N][N];
3.8.5 变长数组
历史上,C语言只支持大小在编译时就能确定的多维数组。程序员需要变长数组时不得不用malloc或calloc这样的函数分配空间,并且不得不显式地编码,用行优先索引将多维数组映射到一维数组。
ISO C99引入了一种功能,允许数组的维度是表达式,在数组被分配的时候才计算出来。(eg:\(int\) \(A[expr1][expr2]\))
3.9 异质的数据结构
C语言提供了两种将不同类型的对象组合到一起创建数据类型的机制:
- 结构(struct)将多个对象集合到一个单位中。
- 联合(union)允许用几种不同的类型来引用一个对象。
3.9.1 结构
C语言的struct声明创建一个数据类型,将可能不同类型的对象聚合到一个对象中。用名字来引用结构的各个部分。
类似于数组的实现,结构的所有组成部分都存放在内存中一段连续的区域内,而指向结构的指针就是结构第一个字节的地址。编译器维护关于每个结构类型的信息,指示每个字段(field)的字节偏移。它以这些偏移作为内存引用指令中的位移,从而产生对结构元素的引用。
3.9.2 联合
联合提供了一种方式,能够规避C语言的类型系统,允许以多种类型来引用一个对象。用不同的字段来引用相同的内存块
3.9.3 数据对齐
计算机系统对基本数据类型的合法地址做出了一些限制,要求某种类型对象的地址必须是某个值\(K\)(2、4、8)的倍数。这种对齐限制简化了形成处理器和内存系统之间接口的硬件设计。
例如,3.6.8节开始的跳转表的汇编代码声明在第二行有命令.align 8,这保证了它后面的数据的起始地址是8的倍数,遵循8字节对齐的限制。
对于包含结构的代码,编译器可能需要在字段的分配中插入间隙,以保证每个结构元素都满足它的对齐要求:
3.10 在机器级程序中将控制于数据结合起来
3.10.1 理解指针
-
每个指针都对应一个类型
-
每个指针都有一个值
-
指针用‘&’运算符创建
-
*操作符用于间接引用指针
-
数组与指针紧密联系
-
将指针从一种类型强制转换成另一种类型,只改变它的类型,而不改变它的值。
-
指针也可以指向函数
3.10.2 应用:使用GDB调试器
linux> gdb prog
3.10.3 内存越界引用和缓冲区溢出
C对于数组引用不进行任何边界检查,而且局部变量和状态信息都存放在栈中。这两种情况结合到一起就能导致严重的程序错误,对越界的数组元素的写操作会破坏存储在栈中的状态信息。当程序使用这被破坏的状态,试图重新加载寄存器或指向ret指令时,就会出现很严重的错误。
一种特别常见的状态破坏称为缓冲区溢出(buffer overflow)——在栈中分配某个字符数组来保存一个字符串,但是字符串的长度超出了为数组分配的空间。
缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数。这是一种最常见的通过计算机网络攻击系统安全的方法。
通常,输入给程序一个字符串,这个字符串包含一些可执行代码的字节编码,称为攻击代码(exploit code),另外,还有一些字节会用一个指向攻击代码的指针覆盖返回地址。那么,执行ret指令的效果就是跳转到攻击代码。
在一种攻击形式中,攻击代码会使用系统调用启动一个shell程序,给攻击者提供一组操作系统函数。在另一种攻击形式中,攻击代码会执行一些未授权的任务,修复对栈的破坏,然后第二次执行ret指令,(表面上)正常返回到调用者。
3.10.4 对抗缓冲区溢出攻击
-
栈随机化
-
栈破坏检测
-
限制可执行代码区域