计算机系统基础-程序的转换及机器级表示

244 阅读34分钟

计算机系统基础-程序的转换及机器级表示

1. 程序转换概述

  计算机指令有微指令机器指令伪(宏)指令之分。微指令是微程序级指令,俗语硬件范畴;伪指令是由若干机器指令组成的指令序列,属于软件范畴;机器指令介于二者之间,处于硬件和软件的交界面。

1.1 指令集体系结构

  计算机系统是由多个不同的抽象层构成的,每个抽象层的引入,都是为了对它的上层屏蔽或隐藏其下层的实现细节,从而为其上层提供简单的使用接口。
  计算机系统抽象层中,最重要的抽象层就是指令集体系结构(Instructure Set Architecture, ISA),作为计算机硬件之上的抽象层,对使用硬件的软件屏蔽了底层硬件的实现细节,将物理上的计算机硬件抽象成一个逻辑上的虚拟计算机,称为机器语言级虚拟机
  ISA 定义了机器语言级虚拟机的属性和功能特性,主要包括如下信息:
  1. 可执行的指令的集合,包括指令格式、操作种类以及每种操作对应的操作数的相应规定;
  2. 指令可以接受的操作数类型;
  3. 操作数或其地址所能存放的寄存器组的结构,包括每个寄存器的名称、编号、长度和用途;
  4. 操作数所能存放的存储空间的大小和编址方式;
  5. 操作数在存储空间存放是按照大端方式还是小端方式存放;
  6. 指令获取操作数以及下一条指令的方式,即寻址方式;
  7. 指令执行过程的控制方式,包括程序计数器、条件码定义等。
  ISA 规定了机器级程序的格式和行为,也就是说,ISA 属于软件看得见的特性。

2. IA-32 指令系统概述

2.1 寄存器组织和寻址方式

  不考虑 I/O 指令,IA-32 指令的操作数有三类:立即数、寄存器操作数和存储器操作数。立即数就在指令中,无需指定其存放位置。寄存器操作数需要指定操作数所在寄存器的编号,当操作数为存储单元内容时,需要指定操作数所在存储单元的地址。

2.1.1 定点寄存器组

  IA-32 的定点寄存器中共有 8 个通用寄存器(General Purpose Register, GDR)、2 个专用寄存器和 6 个段寄存器。定点通用寄存器时指没有专门用途的可以存放各类定点操作数的寄存器。
  8 个通用寄存器的长度是 32 位,其中 EAX、EBX、ECX 和 EDX 主要用来存放操作数,可根据操作数长度是字节、字还是双字来确定存取寄存器的最低 8 位、最低 16 位还是全部 32 位。ESP、EBP、ESI 和 EDI 主要用来存放变址值或指针,可以作为 16 位或 32 位寄存器使用,其中,ESP 是栈指针寄存器,EBP 是基址指针寄存器。
  两个专用寄存器分别是指令指针寄存器 EIP 和标志寄存器 EFLAGS。EIP 从 16 位的 IP 扩展而来,指令指针寄存器 IP(Instuction Pointer) 与程序计数器 PC 是功能完全一样的寄存器,名称不同而已,这里认为两者通用,都是指用来存放将要执行的下一条指令的地址的寄存器。EFLAGS 从 16 位的 FLAGS 扩展而来。实地址模式下,使用 16 位的 IP 和 FLAGS 寄存器;保护模式下,使用 32 位的 EIP 和 EFLAGS 寄存器。
  EFLAGS 寄存器的第 0-11 位中的 9 个标志位是从最早的 8086 微处理器延续下来的,按功能可以分为 6 个条件标志位和 3 个控制标志位。其中,条件标志用来存放运行的状态信息,由硬件自动设定,条件标志有时也称为条件码;控制标志由软件设定,用于中断响应、串操作和单步执行等控制。
  常用条件标志含义说明如下:
  1. OF(Overflow Flag): 溢出标志,反映带符号数的运算结果是否超过相应的数值范围,溢出时,OF=1;
  2. SF(Sign Flag): 符号标志,反映带符号数运算结果的符号,负数时,SF=1;
  3. ZF(Zero Flag): 零标志,反映结果是否为 0,若结果为 0,则 ZF=1;
  4. CF(Carry Flag): 进/借位标志,反映无符号整数加(减)运算后的进(借)位情况,有进(借)位则 CF=1。
  控制标志含义说明如下:
  1. DF(Direction Flag): 方向标志,用来确定串操作指令执行时变址寄存器 SI(ESI) 和 DI(EDI) 中的内容时自动递增还是自动递减,若 DF=1,则为递减,可用 std 指令和 cld 指令分别将 DF 置 1 和清 0;
  2. IF(Interrupt Flag): 中断允许标志,若 IF=1 ,表示允许响应中断,否则禁止响应中断。IF 对非屏蔽中断和内部异常不起作用,进队外部可屏蔽中断起作用,可用 sti 指令和 cli 指令分别将 IF 置 1 和清 0;
  3. TF(Trap Flag): 陷阱标志,用来控制单步执行操作,TF=1 时,CPU 按照单步方式执行指令,此时,可以控制在每执行完一条指令后,就把该指令执行得到的机器状态显示出来。没有专门的指令修改该标志,但是可用栈操作指令来改变其值。
  EFLAGS 寄存器的第 12~31 位中的其他状态或控制信息是从 80286 以后逐步添加的。包括用于表示当前程序的 I/O 特权级(IOPL)、当前任务是否有嵌套任务(NT)、当前处理器是否处于虚拟 8086 方式(VM) 等一些状态或控制信息。
  6 个段寄存器都是 16 位,CPU 根据段寄存器的内容,与寻址方式确定的有效地址一起,并结合其他用户不可见的内部寄存器,生成操作数所在的存储地址。

2.1.2 寻址方式

  根据指令给定信息得到的操作数或操作地址的方式称为寻址方式立即寻址指指令中直接给出操作数;寄存器寻址指指令中给出操作数所存放的寄存器的编号;除了这两种寻址方式,其他寻址方式的操作数都在存储单元中,称为存储器操作数
  实地址模式是为了与 8086/8088 兼容而设置的,在加电或复位的时候处于这一模式。其最大寻址空间位 1MB,32 条地址线中的 A31 ~ A20 不起作用,存储管理采用分段方式,每段的最大地址空间位 64KB,物理地址由段地址乘以 16 加上偏移地址构成,其中段地址位于段寄存器中,偏移地址用来指定段内的一个存储单元。
  保护模式的引入是为了实现在多任务方式下对不同任务使用的虚拟存储空间进行完全的隔离,以保证不同任务之间不会相互破坏各自的代码和数据。系统启动后总是先进入实地址模式,对系统进行初始化,然后转入保护模式进行操作。保护模式下,处理器采用虚拟存储器管理方式。
  IA-32 采用段页式虚拟内存管理方式,CPU 先通过分段方式得到线性地址 LA,再通过分页方式实现从线性地址到物理地址的转换。

2.1.3 浮点寄存器栈和多媒体扩展寄存器组

  IA-32 的浮点处理架构有两种,一种是与 x86 配套的浮点协处理 x87 架构,它是一种栈结构 FPU,x87 中进行运算的浮点数来源于浮点寄存器栈的栈顶;另一种是由 MMX 发展而来的 SSE 架构,采用单指令多数据(Single Instruction Multi Data, SIMD)技术,该技术可实现单条指令同时并行处理多个数据元素的功能,其操作数来源于专门新增的 8 个 128 位寄存器 XMM0 ~ XMM7。

3. IA-32 常用指令类型及其操作

3.1 传送指令

  传送指令用于寄存器、存储单元或 I/O 端口之间传送信息,分为通用数据传送、地址传送、标志传送和 I/O 信息传送等几类,处理部分标志传送外,其他指令均不影响标志位的状态。

3.1.1 通用数据传送指令

  通用数据传送指令传送的是寄存器或存储器中的数据,主要包括:
  MOV:一般传送指令;
  MOVS:符号扩展传送指令,将短的源数据高位符号扩展后传送到目的地址;
  MOVZ:零扩展传送指令,将短的源数据高位零扩展后传送到目的地址;
  XCHG:数据交换指令,将两个寄存器内容互换;
  PUSH:先执行 R[sp] <- R[sp] - 2 或 R[esp] <- R[esp] - 4,再将一个字或双字从指定寄存器送到 SP 或者 ESP 指示的栈单元;
  POP:先将一个字或双字从 SP 或 ESP 指示的栈单元送到指定寄存器中,再执行 R[sp] <- R[sp] + 2 或 R[esp] <- R[esp] + 4。
  栈是一种采用先进后出方式进行访问的一块存储区,在处理过程调用时非常有用,多数情况下,栈从高地址向低地址增长。

3.1.2 地址传送指令

  地址传送回指令传送的是操作数的存储地址,指定的目的寄存器不能是段寄存器,且源操作数必须是存储器寻址方式。注意,这些指令均不影响标志位,主要是加载有效地址(Load Effect Address, LEA)指令,用来将源操作数的存储地址送到目的寄存器中。

3.1.3 输出输出指令

  输入输出指令专门用于在累加器和 I/O 端口之间的数据传送。

3.1.4 标志传送指令

  标志传送指令专门用于对标志寄存器进行操作。

3.2 定点算数运算指令

  定点算数运算指令用于二进制数和无符号十进制数的各种算数运算。

3.2.1 加/减运算指令

  加/减类指令(ADD/SUB) 用于对给定长度的两个位串进行相加或相减,两个操作数中最多只能有一个是存储器操作数,不区分是无符号数还是带符号数,产生的和/差送到目的地,生成的标志信息送标志寄存器 FLAGS/EFLAGS。

3.2.2 增/减运算指令

  增/减类(INC/DEC) 指令对给定长度的一个位串加 1 或减 1,给定操作数即是源操作数又是目的操作数,不区分无符号数还是带符号整数,生成的标志信息送标志寄存器 FLAGS/EFLAGS。

3.2.3 取负指令

  取负类指令 NEG 用于求操作数的负数,也即,将给定长度的位串“各位取反、末尾加 1”,也称为取补指令。

3.2.4 比较指令

  比较类指令 CMP 用于两个寄存器操作数的比较,用目的操作数减去源操作数,结果不送回目的操作数,即两个操作数保持原值不变,指示标志位相应改变,因而功能类似 SUB 指令。通常,该指令后面跟条件转移指令或条件设置指令。

3.2.5 乘/除运算指令

  乘法指令分成 MUL(无符号数乘)和 IMUL(带符号数乘)两类,对于 IMUL 指令,可以明显给出一个、两个或三个操作数,但是对于 MUL 指令,则只能明显给出一个操作数。

3.3 按位运算指令

  按位运算指令用来对不同长度的操作数进行按位操作,立即数只能作为源操作数,不能作为目的操作数,并且最多只能有一个为存储器操作数。按位运算指令主要分为逻辑运算指令和移位指令。

3.3.1 逻辑运算指令

  以下 5 类逻辑运算指令中,仅 NOT 指令不影响条件标志位,其他指令执行后,OF=CF=0,而 ZF 和 SF 则根据运算结果来设置:若结果全为 1,则 ZF=1;若最高位为1,则 SF=1。
  NOT:单操作数取反指令,将操作数每一位取反,然后把结果送回对应位;
  AND:对双作数按位逻辑“与”,主要用来实现掩码操作。
  OR:对双操作数按位逻辑“或”,常用于使目的操作数的特定位置 1;
  XOR:对双操作数按位进行逻辑“异或”,常用于判断两个操作数中哪些位不同或用于改变特定位的值;
  TEST:根据两个操作数相“与”的结果来设置条件标志,常用于需检测某种条件但不能改变原操作数的场合。

3.3.2 移位指令

  移位运算指令将寄存器或存储单元的 8、16 或 32 位二进制数进行算数移位、逻辑移位或循环移位。在移位过程中,把 CF 看做扩展位,用它接受从操作数最左或最右溢出的一个二进制位。只能移动 1~31 位,所移位数可以是立即数或存放在 CL 寄存器的一个数值。

3.4 控制转移指令

  IA-32 中指令执行顺序由 CSEIP 决定。正常情况下,指令按照它们在存储器中的存放顺序一条一条的执行,但是,在有些情况下,程序需要转移到另一端代码中去执行,可以采用改变 CS 和 EIP,或者进改变 EIP 的方式来实现转移。第一种称为段间转移,也叫远转移,转移目标属性为 FAR;第二种称为段内转移,分近转移短转移,转移目标的属性分别为 NEAR 和 SHORT。
  段内转移和段间转移都由直接转移和间接转移之分。直接转移是指转移的目标地址作为立即数直接出现在指令的机器码中;间接转移则指转移的目标地址间接存储在某一寄存器或存储单元中。
  目标转移地址的计算方法有两种,一种是通过将当前 EIP 的值增加或减少某一个值,也就是以当前指令位中心往前或往后移,称为相对转移;另一种是以新得值代替当前 EIP 的值,称为绝对转移在 IA-32 指令系统中,所有段内直接转移都是相对转移,所有段内间接转移和段间转移都是绝对转移
  IA-32 提供了多种控制转移指令,有无条件转移指令、条件转移指令、条件设置指令、调用/返回指令和中断指令等。这些指令中,除中断指令外,其他指令都不影响状态标志位,但有些指令的执行受状态标志的影响。与条件转移指令和条件设置指令相关的还有传送指令。

3.4.1 无条件转移指令

  无条件转移到转移目标地址处执行。

3.4.2 条件转移指令

  条件转移指令以条件标志或者条件标志位的逻辑运算结果作为转移依据。如果满足转移条件,则程序转移到由标号 label 确定的目标地址处执行;否则继续执行下一条指令。这类指令都采用相对转移方式在段内直接转移。

3.4.3 条件设置指令

  条件设置指令用来将条件标志组合得到的条件之设置到一个 8 位通用寄存器中。

3.4.4 条件传送指令

  条件传送指令的功能是,如果符合条件就进行传送操作,否则什么都不做。

3.4.5 调用和返回指令

  为了便于模块化程序设计,往往把程序的某些具有独立功能的部分编写成独立的程序模块,称为子程序。这些子程序可以被主程序调用,并且执行完毕后又返回主程序继续执行原来的程序。子程序的使用有助于提高程序的可读性,并有利于代码重用。子程序的使用主要通过过程调用或函数调用实现,方便起见,这里将过程(调用)和函数(调用)统称为过程(调用)。为了实现这个功能,IA-32 提供了以下指令:
  1. 调用指令:调用指令 CALL 是一种无条件转移指令,跳转方式由 JMP 指令类似。具有两个功能:①将返回地址入栈(相当于 PUSH 操作);②跳转到指定地址处执行。执行时,首先将当前 EIP 或 CS:EIP 的内容(即返回地址,相当于 CALL 指令下面一条指令的地址)入栈,人然后将调用目标地址(即子程序的首地址)装入 EIP 或 CS:EIP,以将控制转移到被调用的子程序执行。显然,CALL 指令会修改栈指针 ESP。
  2. 返回指令:返回指令 RET 也是一种无条件转移指令,通常放在子程序末尾,使子程序执行后返回主程序继续执行。该指令执行过程中,返回地址被从栈顶取出(相当于 POP 指令),并送到 EIP 寄存器(段内或段间调用时)和 CS 寄存器(仅段间使用)。显然,RET 指令也会修改栈指针。若 RET 指令带有一个立即数 n, 则当它完成上述操作后,还会执行 R[esp] <- R[esp] + n 操作,从而实现预定的修改栈指针目的。

3.4.6 中断指令

  中断的概念和过程调用有些类似,两者都是将返回的地址先压栈,然后转到某个程序取执行。它们的主要区别是:①过程调用调跳转到一个用户事先设定好的子程序,而中断跳转则是转向系统事先设定好的中断服务程序;②过程调用可以是 NEAR 或 FAR 类型,能直接或间接跳转,而中断跳转通常是段间间接转移,因为中断处理会从用户态转到内核态执行;③过程调用仅保存返回地址,而中断指令还要使标志寄存器入栈保存。

4. C 语言程序的机器级表示

4.1 过程调用的机器级表示

4.1.1 IA-32 中用于过程调用的指令

  CALL 指令在跳转到被调用过程执行之前先要把返回地址压入栈,RET 指令在返回调用过程之前要从栈中取出返回地址。

4.1.2 过程调用的执行步骤

  假定过程 P 调用过程 Q,则 P 称为调用者(caller),Q 称为被调用者(callee)。过程调用的执行步骤如下:
  1. P 将入口参数(实参)放到 Q 能访问的地方;
  2. P 将返回地址存到特定的地方,然后将控制转移到 Q;
  3. Q 保存 P 的现场,并为自己的非静态局部变量分配空间;
  4. 执行 Q 的过程体;
  5. Q 回复 P 的现场,并释放局部变量所占的空间;
  6. Q 取出返回地址,将控制转移到 P。
  因为每个处理器只有一套通用寄存器,所以通用寄存器是每个过程共享的资源,在被调用过程使用这些寄存器之前,在准备阶段(上述①②步骤)先将寄存器的值保存到栈中,用完以后,在结束阶段(上述⑤步骤)再从栈中将这些值重新写会寄存器中,这样,回到调用过程后,寄存器中存放的还是调用过程的值。通常将通用寄存器中的值称为现场。不过并不是所有的通用寄存器的值都由被调用过程保存,通常是调用过程保存一部分,被调用过程保存一部分。

4.1.3 过程调用使用的栈

 &emsp用户课件寄存器的数量有限,且它们是所有过程共享的,某时刻只能被一个过程使用;此外,对于过程中使用的一些复杂类型的非静态局部变量(如数组和结构体等)也不可能保存在寄存器中。因此,除了寄存器之外,还需要有一个专门的存储区域来保存这些数据,这个存储区域称为(stack)。

4.1.4 IA-32 的寄存器使用约定

 &emspi386 System V ABI(Application Binary Inteface) 规范规定,寄存器 EAX、ECX 和 EDX 是调用者保存寄存器。当过程 P 调用过程 Q 时,Q 可以直接使用这三个寄存器,不用将它们的值保存到栈中,也就意味着,如果 P 在从 Q 返回后还要用这三个寄存器的话,P 应在转到 Q 之前先保存它们的值,并在从 Q 返回后先恢复它们的值再使用。寄存器 EBX、ESI 和 EDI 是被调用者保存寄存器,Q 必须现将它们的值保存到栈中再使用它们,并在返回 P 之前恢复它们的值。另外两个寄存器 EBP 和 ESP 分别是帧指针寄存器栈指针寄存器,分别用来只想当前栈帧的底部和顶部。

4.1.5 IA-32 的栈、栈帧机器结构

  IA-32 使用栈来支持过程的嵌套调用,过程的入口参数返回地址被保存寄存器的值被调用过程中的非静态局部变量等都会被压入栈中。IA-32 可以通过执行 MOVPUSHPOP 指令存取栈中元素,用 ESP 寄存器指示栈顶,栈从高地址向低地址增长。
  每个过程都由自己的栈区,称为栈帧(stack frame),因此,一个栈由若干个栈帧组成,每个栈帧用专门的帧指针寄存器 EBP 指定起始位置。因而,当前栈帧的范围在帧指针 EBP 和栈指针 ESP 指向区域之间。过程执行时,由于不断由数据入栈,所以栈指针会动态移动,而帧指针可以固定不变。因此,对程序来说,用固定的帧指针来访问变量要比用变化的栈指针方便得多,也不易出错。因此,一个过程内对栈中信息的访问大多通过帧指针 EBP 进行。
  在调用过程 P 遇到一个函数调用(被调用函数为 Q)时:
  1. P 确定是否将某些调用者保存寄存器(如 EAX、ECX 和 EDX)保存到自己的栈帧中;
  2. 将入口参数按需保存到 P 的栈帧中,参数压栈的顺序是先右后左;
  3. 执行 CALL 指令,现将返回地址保存到 P 的栈帧中,然后执行 Q;
  在 Q 的准备阶段:
  1. Q 将 EBP 的值保存到自己的栈帧中,并设置 EBP 指向它,即 EBP 指向当前栈帧的底部;
  2. 根据需要确定是否将被调用者寄存器(如 EBX、ESI 和 EDI)保存到 Q 的栈帧中;
  3. 在栈中为 Q 的非静态局部变量分配空间。通常,如果非静态局部变量为简单变量且有空闲的通用寄存器,则编译器会将通用寄存器分配给局部变量,但是,对于非静态局部变量为数组等情况,只能在栈中为其分配空间   在 Q 的执行体结束阶段:
  1. Q 恢复被调用者保存寄存器和 EBP 寄存器的值,并使 ESP 指向返回地址,栈中状态回到开始执行 Q 的状态;
  2. 执行 RET 指令便能取回返回地址,返回过程 P 执行。

4.1.6 变量的作用域

  在当前过程 Q 的栈帧中保存的 Q 内部非静态局部变量只在 Q 执行过程中有效,当从 Q 返回到 P 后,这些变量所占的空间全部被释放,因此,在 Q 过程之外,这些变量是无效的。

4.1.7 按值传递参数按地址传递参数

  编译器并不为形式参数分配存储空间,而是给形式参数对应的实参分配空间,形式参数实际上只是被调用函数使用实参的一个名称而已。不管是按值传递参数还是按地址传递参数,在调用过程用 CALL 指令调用被调用过程时,对应的实参应该都已有具体的值,并已将实参的值存放到调用过程的栈帧中作为入口参数,以等待被调用过程中的指令所用。

4.1.8 递归过程调用

  以下是一个计算自然数之和的递归函数。

1 int nn_sum(int n)
2 {
3     int result;
4     if (n <= 0)
5         result = 0;
6     else
7         result = n + nn_sum(n-1);
8     return result;
9 }

  递归过程 nn_sum 对应的汇编代码中,用到了一个被调用者保存寄存器 EBX,所以在其栈帧中除了保存常规的 EBP 之外,还要保存 EBX。在递归调用过程中,应该每次都回到同样的地方执行,因此,递归过程中的返回地址应该是相同的,当然,第一次被调用的返回地址应该是不同的。
  递归调用过程的执行要一直等到满足跳出过程的条件时才结束,虽然占用的栈空间都是临时的,过程执行结束后其所占的所有栈空间都会被释放,但是,如果递归深度非常大的时候,栈空间的开销还是比较大的。操作系统位程序分配的栈会有默认大小的限制。若栈大小为 2MB,则在不考虑其他所调用过程所用栈帧的情况下,当递归深度 n 达到大约 2MB/16B=131072 时,发生栈溢出(stack overflow)。
  此外,过程调用的时间开销也要考虑,虽然过程的功能由过程体中的指令来体现,但是,为了支持过程调用,每个过程还包括了准备阶段和结束阶段。没增加一次过程调用,就要增加许多条包含在准备阶段和结合阶段的额外指令,这些额外指令的执行时间开销对于程序的性能可能会影响很大。因此,应该尽量避免不必要的过程调用,特别是递归调用。

4.1.9 非静态局部变量的存储分配

  编译器在给非静态局部变量分配空间时,通常将其所占用的空间分配在本过程的栈帧中。有些编译器在编译优化的情况下,也可能会把基本的简单数据类型的非静态局部变量分配在通用寄存器中,但是,对于复杂的数据变量,如数组等,则一定分配在栈中。
  C 语言和 ABI 规范都没有定义按何种顺序分配变量的空间。相反,C 语言标准明确指出,对不同变量的地址进行除 ==!= 之外的关系运算都属于未定义行为。因此,不可以依赖变量所分配的顺序来确定程序的行为

4.2 循环结构的机器级表示

  计算自然数之和的非递归函数如下:

1 int nn_sum(int n)
2 {
3     int i = 0;    
4     int result = 0;
5     for (i = 1; i <= n; i++)
6         result += i;
7     return result;
8 }

  根据汇编之后的代码来看,递归方式比非递归方式至少多执行了 16n 条指令,由此可以看出,为了提高程序的性能,可能的话最好用非递归方式(个人认为也不绝对,递归代码相对简洁,在性能允许的情况下,使用递归也是可以的)。

5. 复杂数据类型的分配访问

  在机器级代码中,基本类型对应的数据通常是通过单条指令就可以访问和处理,这些数据在指令中或者是以立即数的方式出现或者以寄存器数据的形式出现,或者以存储器数据的形式出现。而对于构造类型的数据,由于其包含多个基本类型数据,因而不能直接用单条指令来访问和运算,通常需要特定的代码结构和寻址方式对其进行处理。

5.1 数组的分配和访问

  数组是一个数据集合,因而不可能放在一个寄存器或者作为立即数存放在指令中,它一定被分配在存储器中,数组中的每个元素在存储器中连续存放,可以用一个索引值来访问数据元素。对于数组的访问和处理,编译器最重要的是要找到一种简便的数组元素地址的计算方法。
  数组可以定义为静态型数组(static)、外部存储型(extern)、自动存储型(auto),或者定义为全局静态区数组,其中,只有 auto 型数组被分配在栈中,其他存储型数组都分配在静态数据区。
  因为在编译、链接时就可以确定在静态区中的数组的地址,所以在编译、链接阶段就可以将数组首地址和数组变量建立关联。对于分配在静态区的已初始化的数组,机器级指令中可通过数组首地址和数组元素的下标来访问相应的数组元素。
  对于 auto 型数组,由于被分配在栈中,因此数组首地址通过 ESP 和 EBP 来定位,机器级代码在数组元素地址由首地址与数组元素的下标值进行计算得到。
  C 语言指针和数组之间关系十分紧密,它们均用于处理存储器中连续存放的一组数据,因而在访问存储器时两者的地址计算方法是统一的,数组元素的引用可以用指针来实现。在指针变量的目标数据类型和数组元素的数据类型相同的前提下,指针变量可以指向数组或者数组中的任意元素。

5.2 结构体数据的分配和访问

  结构体中的数据成员放在存储器中一段连续的存储区中,指向结构的指针就是其中第一个字节的地址。编译器在处理数据结构型数据的时候,根据每个成员的数据类型获得相应的字节偏移量,然后通过每个成员的字节偏移量来访问结构成员。
  与数组一样,分在栈中的 auto 型结类型变量的首地址由 EBP 或者 ESP 来定位,分配在静态存储区的静态和外部型结构体变量首地址是一个确定的静态存储区地址。
  当结构体变量需要作为一个函数的形式参数时,形式参数和调用函数中的实参应该具有相同的结构。和普通变量传递参数的方式一样,它也有按值传递和按地址传递两种方式。如果采用按值传递方式,则结构的每个成员变量都要被复制到栈中参数区,既增加时间开销,也增加空间开销,因而对于结构体变量来说,常采用按地址传递的方式。
  可以把存储器看作由连续的位(cell)构成,每 8 位为一个字节,每个字节有一个地址编号,称为按字节编址。假定计算机系统访存机制限制每次访存最多只能读写 64 位,即 8 个字节,那么,第 0-7 字节可以同时读写,第 8-15 字节可以同时读写,以此类推,这种称为 8 字节宽的存储器机制。因此,如果一条指令要访问的数据不再地址 8i-8i+7 之间的存储单元内,那么就需要多次访问,因而延长了指令的执行时间。因此,数据在存储器中存放时需要进行对齐,避免多次访存带来指令执行效率变低。
  对于底层机器级代码来说,它应该能支持按任意地址访问存储器数据的功能,因此,无论数据对齐与否,IA-32 都能正确工作,只是在对齐方式下程序的执行效率更高。因此,操作系统通常按照对齐方式分配管理内存,编译器也按照对齐方式转换代码。
  最简单的对齐策略就是要求不同基本类型按照其数据长度进行对齐。对于由基本数据类型构成的结构体数据,为了保证其中每个成员都满足对齐要求,i386 System V ABI 对结构体对齐方式有如下几条规则:

  1. 整个结构体变量的对齐方式与其中对齐方式最严格的成员相同;
  2. 每个成员在满足对齐方式前提下,取最小可用位置作为成员在结构体中的偏移量,这可能导致内部插空;
  3. 结构体大小应为对齐边界长度的整数倍,这可能导致尾部插空。

6. 越界访问和缓冲区溢出

  C 语言标准规定,数组越界访问属于未定义行为,访问结果不可预知:可能访问了一个空闲的内存位置;可能访问了一个不该访问的变量;也可能访问了非法地址而导致程序异常终止。在这种情况下,就有可能存在一些安全漏洞,导致恶意攻击。

6.1 缓冲区溢出

  在 C 语言程序执行过程中,当前在被执行的过程(函数)在栈中会形成本过程的栈帧,一个过程的栈帧除了保存 EBP 和被调用者保存寄存器的值外,还会保存本过程的非静态局部变量和过程调用的返回地址。如果在非静态局部变量中定义了数组变量,那么,有可能在对数组元素访问时发生超越数组存储区的越界访问。通常把这种数组存储区看成一个缓冲区,这种超越数组存储区的访问称为缓冲区溢出,
  例如,对于一个有 10 个元素的 char 数组,其定义的缓冲区有 10 字节,如果写一个字符串到缓冲区,那么,只要写入的字符串多余 9 个字符(结束符 \0 占一个字节),则这个缓冲区就会发生“写溢出”。缓冲区溢出会带来程序执行结果错误,甚至存在相当危险的安全漏洞。

6.2 缓冲区溢出攻击

  缓冲区溢出在各种操作系统、应用软件中广泛存在,缓冲区溢出攻击是利用缓冲区溢出漏洞进行的攻击行为。造成缓冲区溢出的原因是程序没有对栈中作为缓冲区的数组进行越界检查,文件 test.c 代码如下:

#include<stdio.h>
#include "string.h"

void outputs(char *str)
{
    char buffer[6];
    strcpy(buffer, str);
    printf("%s \n", buffer);
}

void hacker(void)
{
    printf("being hacked\n");
}

int main(int argc, char *argv[])
{
    outputs(argv[1]);
    return 0;
}

  上述函数 outputs 是一个有漏洞的程序,当命令行给定的字符串超过 25 个字符时,使用 strcpy 函数就会发生缓冲区溢出。假定 hacker 代码首地址 0x8048411,则可以编写如下代码进行攻击:

#include "stdio.h"

char code[]=
"0123456789ABCDEFXXXX"
"\x11\x84\x04\x08"
"\x00"
int main(void)
{
    char *arg[3]
    arg[0] = "./test";
    arg[1] = code;
    arg[2] = NULL;
    execve(arg[0], arg, NULL);
    return 0;
}

  当执行上述程序时,通过系统调用 execve() 可装入 test 可执行文件,并将 code 中的字符串作为命令行参数来启动执行 test。因此,字符串中前 16 个字符被复制到 buffer 中,4 个字符 X 覆盖掉 EBP 的旧值(省略了栈帧状态图),地址 0x08048411 覆盖掉返回地址。执行上述攻击程序后的输出结果为:

0123456789ABCDEFXXXX口口口口
being hacked
Segmentation fault

  输出结果中第一行为执行 outputs 函数后的结果,其中后面 4 个为不可显示字符(对应 ASCII 码的 11H84Hx04H08H);执行完 outputs 后程序被恶意跳转到 hacker 函数执行,因此会显示第二行字符串;最后一行显示 ”Segmentation fault(段错误)“,其原因是在调用 hacker 函数时并没有保存其调用函数的返回地址,所以在执行到 hacker 过程的 RET 指令时渠道的返回地址是一个不可预知的值,因而可能跳转到数据区或系统区或其他非法访问的存储区取执行,造成段错误。
  上面的错误主要是 strcpy() 函数没有进行缓冲区边界检查而直接把 str 所指向的内容复制到 buffer 造成的。存在向 strcpy() 这样的标准函数还有 strcat()sprintf() 等。

6.3 缓冲区溢出攻击防范

  对于缓冲区溢出攻击,主要可以从两方面来防范,一方面是从程序员角度,另一方面是从编译器和操作系统方面。对于程序员来说,应该尽量编写没有漏洞的正确代码。而对于编译器和操作系统来说,应该尽量生成没有漏洞的安全代码。现代操作系统和编译器采用了多个机制来保护缓冲区,包括:
  1. 地址空间随机化:基于缓冲区溢出漏洞的攻击者必须了解缓冲区的起始地址,以便将一个溢出的字符串以及指向攻击代码的指针植入具有漏洞的程序的栈中。基本思路是:将加载程序时生成的代码段、静态数据段、堆区、动态库和栈区各部分的首地址进行随机化处理,使得每次启动执行时,程序各段都被加载到不同的地址起始处。由此可见,在不同机器上运行相同的程序时,程序加载地址空间是不同的,因此,对于一个随机生成的栈起始地址,基于缓冲区溢出漏洞的攻击者不太容易确定栈的起始位置。不过,如果攻击者使用蛮力多次反复使用不同的栈地址进行试探性攻击,还是可能被攻破的。
  2. 栈破坏检测:主要思想是,在函数准备阶段,在其栈帧中的缓冲区底部与保存的寄存器状态之间加入一个随机生成的特定值,称为金丝雀(哨兵)值;在函数恢复阶段,在恢复寄存器并返回调用函数前,先检查该值是否被改变。若发生改变,则程序异常终止。因为插入在栈帧中的特定值是随机生成的,所以攻击者很难猜测它是什么。
  3. 可执行代码区域限制:通过将程序的数据段地址空间设置为不可执行,从而使得攻击者不能执行被植入在输入缓冲区的代码,但是,栈的不可执行保护对于将攻击代码植入堆或者静态数据段的攻击无效,通过引入一个驻留程序的指针,就可以跳过这种保护措施。