指令系统

机器指令

简称指令,是指示计算机执行某种操作的命令。

是计算机运行的最小功能单位。

指令集体系结构 ISA

ISA 完整定义了软件和硬件之间的接口。

ISA 是软件和硬件之间接口的一个完整定义,包含了基本数据类型、指令集、寄存器、寻址模式、存储体系、中断和异常处理及外部 I/O。

ISA 规定了执行每条指令时所需要的操作码、操作数、寻址方式等信息,以及指令的功能和效果。

ISA 规定的内容主要包括:

  • 指令格式,指令寻址方式,操作类型,以及每种操作对应的操作数的相应规定。
  • 操作数的类型,操作数寻址方式,以及是按大端方式还是按小端方式存放。
  • 程序可访问的寄存器编号、个数和位数,存储空间的大小和编址方式。
  • 指令执行过程的控制方式等,包括程序计数器、条件码定义等。

ISA 规定了机器级程序的格式,机器语言或汇编语言程序员必须对机器的 ISA 非常熟悉。

高级语言抽象层太高,隐藏了许多机器级程序的细节,使得高级语言程序员不能很好地利用与机器结构相关的一些优化方法来提升程序的性能。若程序员对 ISA 和底层硬件实现细节有充分的了解,则可以更好地编址高性能程序。

指令系统 / 指令集

一台计算机的所有指令的集合。

指令系统是指令集体系结构 ISA 中最核心的部分。

是计算机硬件的语言系统。

位于硬件和软件的交界面上。

是计算机的主要属性,是表征一台计算机性能的重要因素。

它的格式与功能不仅直接影响到机器的硬件结构,而且也直接影响到系统软件,影响到机器的适用范围。

引入指令系统的目的

避免用户与二进制代码直接接触,使得用户编写程序更为方便。

指令的基本格式

一条指令通常包括:操作码字段(OP) + 地址码字段(A)。

操作码

指出该指令应执行什么操作以及具有何种功能。

是识别指令、了解指令功能及区分操作数地址内容等的关键信息。

地址码

给出被操作信息(指令或数据)的地址。

包括:参与运算的一个或多个操作数所在的地址、运算结果的保存地址、程序的转移地址、被调用的子程序的入口地址等。

定长指令字结构

一个指令系统中所有指令的长度都是相等的。

定字长指令的执行速度快,控制简单。

变长指令字结构

一个指令系统中各种指令的长度随指令功能而异。

定长操作码

指令系统中所有指令的操作码长度都相同。

定长操作码指令在指令字的最高位部分分配固定的若干位(定长)表示操作码。

一般 $n$ 位操作码字段的指令系统最大能够表示 $2^n$ 条指令。

控制器的译码电路设计简单,有利于简化计算机硬件设计、提高指令译码和识别速度,但灵活性较低。

当计算机字长为 32 位或更长时,这是常规用法。

可变长操作码

指令系统中各指令的操作码长度可变,且分散地放在指令字的不同位置上。

增加了指令译码和分析的难度,使控制器的译码电路设计复杂,但灵活性较高,在指令字长有限的前提下仍保持比较丰富的指令种类。

最常见的变长操作码方法是扩展操作码:定长指令字结构 + 可变长操作码。它使操作码的长度随地址码的减少而增加,不同地址数的指令可具有不同长度的操作码,从而在满足需要的前提下,有效地缩短指令字长。


根据指令中操作数地址码的数目不同,可将指令分为以下几种格式:

零地址指令

只给出操作码 OP,没有显式地址。这种指令有两种可能:

  1. 不需要操作数的指令,如空操作指令、停机指令、关中断指令等。

  2. 堆栈计算机中的零地址运算类指令,两个操作数隐含存放在栈顶和次栈顶,弹出送到运算器进行运算,运算结果再隐式地压回栈顶。

    堆栈指令的访存次数取决于采用的是软堆栈(由内存实现)还是硬堆栈(由寄存器实现)。

    软堆栈:对于双目运算需要访存 4 次(取指 → 取 2 个操作数 → 存结果)。

    硬堆栈:只需在取指时访存 1 次。

一地址指令

这种指令也有两种常见的形态,要根据操作码的含义确定究竟是哪一种:

  1. 只有目的操作数的单操作数指令,如加 1、减 1、取反、求补、移位等。

    指令含义:OP(A1) → A1。

    完成一条指令需要 3 次访存:取指 → 读 A1 → 写 A1。

  2. 隐含约定目的地址的双操作数指令,按指令地址 A1 可读取源操作数,指令可隐含约定另一个操作数由 ACC 提供,运算结果也将存放在 ACC 中。

    指令含义: (ACC)OP(A1) → ACC。

    完成一条指令需要 2 次访存:取指 → 读 A1。

二地址指令

对于常用的算术和逻辑运算指令,往往要求使用两个操作数,需分别给出目的操作数和源操作数的地址,其中目的操作数地址还用于保存本次的运算结果。

指令含义:(A1)OP(A2) → A1。

完成一条指令需要访存 4 次:取指 → 读 A1 + 读 A2 → 写 A1。

三地址指令

常用于需要两个操作数的算术运算、逻辑运算相关指令。

指令含义:(A1)OP(A2) → A3。

完成一条指令需要访存 4 次:取指 → 读 A1 + 读 A2 → 写 A3。

四地址指令

指令含义:(A1)OP(A2) → A3,A4 = 下一条将要执行指令的地址。

完成一条指令需要访存 4 次:取指 → 读 A1 + 读 A2 → 写 A3。

扩展操作码指令格式

扩展操作码是最常见的变长操作码方法(定长指令字结构 + 可变长操作码)。

使操作码的长度随地址码的减少而增加,不同地址数的指令可具有不同长度的操作码,从而在满足需要的前提下,有效地缩短指令字长。

目的

保持指令字长度不变而增加指令的数量。

设计原则

扩展操作码指令格式时,必须注意:

  1. 各指令的操作码一定不能重复。
  2. 不允许短码是长码的前缀。

通常情况下,对使用频率较高的指令,分配较短的操作码;对使用频率较低的指令,分配较长的操作码,从而尽可能减少指令译码和分析的时间。

扩展操作码示例

例一:

例二:

设单个地址码长度为 $n$,上一层留出 $m$ 种状态,下一层可扩展出 $m × 2^n$ 种状态。

指令的操作类型

数据传送

LOAD:从内存单元读取数据到 CPU 寄存器。

STORE:从 CPU 寄存器写数据到内存单元。

MOV:寄存器之间的传送。

PUSH:进栈操作。

POP:出栈操作。

……

算术和逻辑运算

算术:加(ADD)、减(SUB)、乘(MUL)、除(DIV)、增 1(INC)、减 1(DEC)、求补、浮点运算、十进制运算等。

逻辑:与(AND)、或(OR)、非(NOT)、异或(XOR)、位操作、位测试、位清除、位求反等。

移位操作

算术移位、逻辑移位、循环移位等。

算术和逻辑运算、移位操作可归为运算类指令。

转移操作

无条件转移(JMP)、条件转移(BRANCH)、调用(CALL)、返回(RET)、陷阱(TRAP)等。

转移操作指令也可称为程序控制类指令,用于改变程序执行的顺序,并使程序具有测试、分析、判断和循环执行的能力。

调用指令 vs 转移指令

执行调用指令时必须保存下一条指令的地址(返回地址),当子程序执行结束时,根据返回地址返回到主程序继续执行;而转移指令则不返回执行。

无条件转移指令 vs 条件转移指令

无条件转移指令在任何情况下都执行转移操作,而条件转移指令仅在特定条件满足时才执行转移操作,转移条件一般是某个标志位的值,或几个标志位的值。

输入输出操作

用于完成 CPU 与外部设备交换数据或传送控制命令及状态信息。

指令的寻址方式

寻 “址”

寻找指令或操作数的有效地址。

即确定本条指令的数据地址及下一条将要执行的指令的地址。

形式地址 A

指令中的地址码字段并不代表操作数的真实地址,这种地址称为形式地址 A。

符号 (A) 表示 位于 A 地址的数值,A 既可以是寄存器的标号,也可以是内存地址。

  • 若为立即寻址,则形式地址的位数决定了操作数的范围。
  • 若为直接寻址,则形式地址的位数决定了可寻址的范围。
  • 若为寄存器寻址,则形式地址的位数决定了通用寄存器的最大数量。
  • 若为寄存器间接寻址,则寄存器的位数决定了可寻址的范围。

有效地址 EA (Effective Address)

形式地址结合寻址方式,可以计算出操作数在存储器中的真实地址,这种地址称为有效地址 EA。

如:EA = (A) 表示 有效地址 = 位于 A 地址的数值

PC 中存放的是有效地址。

不分段不分页:有效地址 = 物理地址。

段式/段页式:有效地址 = 段内偏移。

页式:有效地址 = 线性地址/虚拟地址。

寻址方式分类

  1. 指令寻址

    寻找下一条要执行的指令的地址(始终由 PC 给出)。

  2. 数据寻址

    寻找操作数的地址(把操作数的形式地址转换成操作数的有效地址的过程)。

    如何在指令中表示一个操作数的地址、如何用这种表示得到操作数或怎样计算出操作数的地址。

    指令格式:

为区别各种寻址方式,通常在指令中设一个寻址特征字段,用来指明属于哪种寻址方式(其位数决定了寻址方式的种类)。

操作数存放位置

操作数通常位于:

  1. 指令代码
  2. CPU 内部的寄存器
  3. 主存
  4. I/O 端口

指令系统中采用不同寻址方式的目的

缩短指令字长,扩大寻址空间,提高编程的灵活性。

但这也提高了指令译码的难度,多重寻址方式会造成 CPU 结构的复杂化,也不利于指令流水线的运行。而寻址方式少虽然能够提高 CPU 的效率,但对于用户而言会使编程变得复杂,很难满足需求。

指令寻址

顺序寻址

程序的指令序列在主存中顺序存放,程序执行时从第一条指令开始,逐条取出并逐条执行。

依靠程序计数器 PC 实现。

(PC) + “1” → PC。这里的 1 理解为 1 个指令字长,实际加的值会因指令长度、编址方式而不同。

现代计算机通常是按字节编址的,若指令字长为 16 位,则 PC 自增为 (PC) + 2;若指令字长为 32 位,则 PC 自增为 (PC) + 4。

取指后一定会 PC + “1”。

跳跃寻址

下一条指令的地址不是由 PC 的当前值所给出,而由本条指令给出下条指令地址的计算方式。

通过转移类指令实现。

是否跳跃可能受到状态寄存器的控制。

跳跃的地址分为绝对转移(地址码直接指出转移目标地址)和相对转移(地址码指出转移目标地址相对于当前 PC 值的偏移量)。

由于 CPU 总是根据 PC 的内容去主存取指令的,因此转移指令执行的结果是修改 PC 值(已经 + “1”),下一条指令仍然通过 PC 给出。

数据寻址

采用不同寻址方式的目的是为了缩短指令字长,扩大寻址空间,提高编程的灵活性,但这也提高了指令译码的复杂度。

隐含寻址

指令不显式给出操作数的地址,而在指令中隐含操作数的地址。

如单地址指令,隐含约定第二个操作数由累加器 ACC 提供,指令中只明显指出第一个操作数的地址。

优点:有利于缩短指令字长,可简化地址结构,是获取操作数最快的方式

缺点:需要增加存储操作数或隐含地址的硬件。

立即(数)寻址

指令字中的地址字段是操作数本身,也称立即数。

数据采用补码的方式存放。

优点:取出指令即可同时获得操作数,执行阶段不访存,提高了指令的执行速度,指令执行时间最短

缺点:对于定长指令格式,A 的位数限制了立即数的取值范围;操作数作为指令的一部分,不能被修改。

通常用于给某一寄存器或者主存单元赋值。

直接寻址

形式地址 A 就是操作数的有效地址 EA,即 EA = A。

优点:寻找操作数比较简单,无需专门计算操作数地址,执行阶段只访存一次。

缺点:A 的位数限制了操作数的寻址范围,并且操作数的地址不易修改。

在早期的计算机中常常作为主要的寻址方式。

间接寻址

形式地址 A 指出操作数有效地址 EA 所在的存储单元的地址,即 EA = (A),有效地址由形式地址间接提供。

可以是一次间接寻址,也可以是多次间接寻址:

主存字第一位为 1 时,表示取出的仍不是操作数的地址,即多次间址;主存字第一位为 0 时,表示取得的是操作数的地址。

优点:

  1. 可扩大操作数的寻址范围(EA 的位数大于 A 的位数)。
  2. 便于编程,可方便地完成子程序返回:调用子程序前,将返回地址存入子程序最末条指令的形式地址的存储单元,便可准确返回原程序。

缺点:指令在执行阶段需要多次访存(一次间接寻址就需要两次访存。多次间接寻址需根据存储字的最高位确定访存次数),指令执行时间变长。

由于执行速度较慢,一般为了扩大寻址范围时,通常采用寄存器间接寻址。​

寄存器寻址

操作数不在内存中,而是放在寄存器中。

指令的地址字段给出的是操作数所在的寄存器编号。

形式地址 A 表示的是寄存器的编号 R,即 EA = R。

优点:

  1. 指令在执行阶段不访存,只访问寄存器,执行速度快。
  2. 因寄存器编号较短(寄存器数量远小于内存单元数),对应地址码位数较少,指令字长较短,故可压缩指令字,节省存储空间。
  3. 支持向量/矩阵运算。

缺点:寄存器价格昂贵,CPU 的寄存器数量有限。

寄存器间接寻址

形式地址 A 所表示的寄存器中,存放的是操作数在内存中的地址,即 EA = (R)。

这种方式综合了间接寻址和寄存器寻址各自的特点。

寄存器一次间接寻址执行阶段仅需访存 1 次。

相比于间接寻址,既扩大了寻址范围,又减少了访存次数,故比一般间接寻址相比执行速度更快。

提到扩大寻址范围时,通常指的是寄存器间接寻址而不是间接寻址。

偏移寻址

EA = (R) + A,可以说偏移寻址结合了直接寻址和寄存器间接寻址。

一般有两个地址字段:A—内存形式地址,直接使用;R—某寄存器编号,间接使用。

根据使用的寄存器是专用还是通用寄存器,可分为隐式偏移(使用专门的寄存器,不需在指令中指明寄存器编号)和显式偏移。

常用的三种形式:相对寻址、基址寻址、变址寻址。

相对寻址

EA = (PC) + A,使用程序计数器 PC 提供主存基准地址,指令字中的形式地址 A 给出相对于当前 PC 值的偏移量。

此处 A 称为偏移量,可正可负,通常用补码表示。该偏移量实质上是以下条指令在内存中的首地址为基准的位置偏移量。

基于程序的局部性原理。

用处:

  1. 代码模块可采用浮动地址,程序在内存中可任意放置。

    编程只需确定程序内部操作数与指令之间的相对距离,而无需确定操作数在主存储器中的绝对地址,这样,程序可以安排在主存的任意位置而不会影响其正确性。

  2. 广泛应用于转移类指令,转移地址随 PC 值变化(注意 PC 先自增)。

    例如,对于转移指令 JMP A,若指令的地址为 X,且占 2B,则在取出该指令后,PC 的值会增 2,即 (PC) = X + 2,这样在执行完该指令后,会自动跳转到 X + 2 + A 的地址继续执行。

基址寻址

EA = (BR) + A,使用基址寄存器提供主存基准地址,指令字中的形式地址 A 给出相对该基准地址的偏移量。

在程序执行过程中,基址寄存器内容不变(作为基地址),形式地址 A 可变(作为偏移量)。

基址寄存器既可采用专用寄存器(隐式),又可指定某个通用寄存器作为基址寄存器(显式)。

专用的基址寄存器是面向操作系统的,其内容由操作系统或管理程序确定,主要用于解决程序逻辑空间与存储器物理空间的无关性;采用通用寄存器作为基址寄存器时,可由用户决定哪个寄存器作为基址寄存器,但其内容仍由操作系统确定。

注意图中的指令格式。

优点:

  1. 扩大寻址范围(基址寄存器位数 > 形式地址 A 的位数)。

  2. 用于为程序或数据分配存储空间,实现存储透明性。

    用户不必考虑自己的程序在主存中的位置,由 OS 或管理程序根据主存情况,赋予基址寄存器一个初始基地址。用户在程序执行过程中不可修改基址寄存器的内容。

  3. 实现段寻址:将主存空间分为若干段,每段的首地址存于基址寄存器,段内偏移量由指令字中的形式地址 A 指出。

  4. 有利于多道程序设计,并可用于编制浮动程序。

缺点:偏移量(形式地址 A)的位数较短。

变址寻址

EA = (IX) + A,使用指令字中的形式地址 A 提供基准地址,寄存器中含有相对偏移量。

IX 为变址寄存器(专用),也可用通用寄存器作为变址寄存器。

变址寄存器面向用户,其内容由用户设定,在程序执行过程中可变(作为偏移量),但形式地址 A 的内容不可变(作为基地址)。

优点:

  1. 扩大寻址范围(IX 位数大于形式地址 A 的位数),偏移量足以表示整个存储空间。
  2. 常用于需要频繁修改操作数地址的处理,如数组运算、字符串操作以及循环重复等,特别适合编制循环程序

变址寻址 vs 基址寻址

变址寻址与基址寻址的有效地址形成过程极为相似。但从本质上讲,两者有较大区别。

基址寻址面向系统,主要用于为多道程序或数据分配存储空间,因此基址寄存器的内容通常由操作系统或管理程序确定,在程序的执行过程中其值不可变,而指令字中的 A 是可变的。

变址寻址立足于用户,主要用于处理数组问题,在变址寻址中,变址寄存器的内容由用户设定,在程序执行过程中其值可变,而指令字中的 A 是不可变的。

复合寻址

如基址变址寻址(先基址再变址):EA = (IX) + ((BR) + A)。

堆栈寻址

要求计算机系统中设有堆栈才能实现。

堆栈是存储器(或专用寄存器组)中一块特定的、按后进先出 LIFO 原则管理的存储区(数据存取只在栈顶地址进行)。

该存储区读/写单元(即栈顶)的地址用一个特定的寄存器给出,该寄存器称为堆栈指针 SP。通常情况下,在读/写栈顶前后都伴有自动完成对 SP 内容的加减操作。

堆栈的两种形式:

  1. 硬堆栈/寄存器堆栈:寄存器组。

    成本较高,不适合做大容量的堆栈。

  2. 软堆栈:从主存中划出一段区域。

    最合算且最常用的方法。

可视为是一种隐含寻址:操作数地址隐含为 SP。

可视为是一种寄存器间接寻址:SP 为寄存器,存放操作数有效地址。

在采用堆栈结构的计算机系统中,大部分指令表面上都表现为无操作数指令的形式。

总结

数据寻址方式示意图

基本的数据寻址方式分析

访存次数不算取指,也不考虑有效地址向物理地址转化时访问内存页表的可能。

隐含寻址、立即寻址、寄存器寻址访存次数为 0,获取操作数速度比较快(隐含寻址 > 立即寻址 > 寄存器寻址)。

立即寻址的指令执行时间最短。

寄存器间接一次寻址取操作数速度接近直接寻址。

缩短指令字/地址段位数:隐含寻址、寄存器寻址、寄存器间接寻址。

简化地址结构:隐含寻址。

对于一个指令系统来说,寻址方式多和少有什么影响?

寻址方式的多样化能让用户编程更为方便,但多重寻址方式会造成 CPU 结构的复杂化,也不利于指令流水线的运行。

寻址方式太少虽能提高 CPU 的效率,但对用户而言,少数几种寻址方式会使编程变得复杂,很难满足用户需求。

程序的机器级代码表示

历年统考真题主要考察的时 x86 汇编指令,因此本节主要介绍 x86 汇编指令。

定点寄存器组

IA-32 指令用到的寄存器主要分为定点寄存器组、浮点寄存器栈和多媒体扩展寄存器组。

浮点寄存器栈和多媒体扩展寄存器组属于 IA-32 的浮点处理架构。前者用于浮点运算器 FPU,后者用于 SSE 架构(由多媒体扩展 MMX 技术发展而来,采用单指令多数据 SIMD 技术)。

定点寄存器组中共有 8 个通用寄存器 GPR (General Purpose Register)、2 个专用寄存器、6 个段寄存器。

前缀 E 为 Extend,表示 32 位的寄存器。

通用寄存器

用于存放操作数(包括源操作数、目的操作数、中间结果)和各种地址信息等。

除指针寄存器 EBP 和 ESP 外,其他寄存器的用途比较灵活的。

数据寄存器

  • 累加器 EAX (Accumulator)
  • 基址寄存器 EBX (Base Register)
  • 计数寄存器 ECX (Count Register)
  • 数据寄存器 EDX (Data Register)

EAX、EBX、ECX、EDX 通常用来存放操作数。

为了向后兼容,这 4 个寄存器的高两位字节和低两位字节可以独立使用,而低两位字节又可分别作为两个 8 位寄存器。可根据操作数长度是字节、字还是双字来确定存取寄存器的最低 8 位、最低 16 位还是全部 32 位。

由于 32 或 64 位 x86 体系结构都是由 16 位扩展而来,因此字 word 均为 16 位。

指针寄存器

  • 帧指针寄存器 EBP (Base Pointer)

    指向当前过程的栈帧的底部。

  • 堆栈指针寄存器 ESP (Stack Pointer)

    指向堆栈顶部的下一未使用位置,通常和 SS 寄存器配合使用。

    也即指向当前过程的栈帧的顶部。

    当进程在用户空间运行时,堆栈指针寄存器指向用户栈栈顶,使用用户栈;当进程在内核空间时,堆栈指针寄存器指向内核栈栈顶,使用内核栈。

变址寄存器

变址寄存器 Index Register 用于存放偏移地址。

  • 源变址寄存器 ESI

    存放相对于 DS 段的偏移地址。

    通常与 DS 寄存器一起用作复制/传输指令/操作数的源地址。

  • 目的变址寄存器 EDI

    存放相对于 ES 段的偏移地址。

    通常与 ES 寄存器一起用作复制/传输指令/操作数的目标地址。

🌱调用约定

根据 x86 架构上的 C 语言调用约定,EAX、ECX、EDX 属于调用者保存寄存器,EBX、ESI、EDI 属于被调用者保存寄存器。另外,调用者的 EBP 值会由被调用者的第一个指令压入被调用者的栈帧中(即保存在被调用者栈帧的栈底)。

所谓调用者保存,就是说在调用发生时,被调用方有权利破坏这些寄存器而不通知调用方。所以调用方为了保证调用返回后能顺利执行,就要自己来保存这些值;所谓被调用者保存,是指这些寄存器被调用者可以随便用,但在返回前要把这些寄存器的旧值复原。

专用寄存器

两个专用寄存器分别是指令指针寄存器 EIP (Extend Instruction Point) 和状态标志寄存器 EFLAGS。

实地址模式时,使用 16 位的 IP 和 FLAGS 寄存器;保护模式时,使用 32 位的 EIP 和 EFLAGS 寄存器。

指令指针寄存器 EIP

与程序计数器 PC 功能完全一样,只是名称不同。

标志寄存器 EFLAGS

即程序状态字寄存器 PSWR,主要用于记录机器的状态和控制信息。

0~11 位中的 9 个标志位是从最早的 8086 微处理器延续下来,它们按功能可以分为 6 个条件标志和 3 个控制标志。

条件标志用来存放运行的状态信息,由硬件自动设定。条件标志有时也称为条件码。

控制标志由软件设定,用于中断响应、串操作和单步执行等。

EFLAGS 寄存器的第 12~31 位中的其他状态或控制信息是从 80286 以后逐步添加的。包括:

① IOPL:表示当前程序的 I/O 特权级,即限制访问 I/O 端口的最低特权级,通常为 0;

② NT:表示当前任务是否是嵌套任务;

③ VM:当前处理器是否处于虚拟 8086 方式;

等一些状态或控制信息。

常用条件标志含义

  1. 溢出标志 OF (Overflow Flag)

    反映带符号数的运算结果是否超过相应数值范围。

    溢出时 OF = 1,否则 OF = 0。

    $OF=C_{n} \oplus C_{n-1}$,即符号位进位和最高数位进位的异或结果。

    对无符号数运算没有意义。

  2. 符号标志 SF (Sign Flag)

    反映带符号数运算结果的符号。

    负数时 SF = 1,否则 SF = 0。

    对无符号数运算没有意义。

  3. 零标志 ZF (Zero Flag)

    反映运算结果是否为 0。

    结果为 0 时 ZF = 1,否则 ZF = 0。

  4. 进/借位标志 CF (Carry Flag)

    反映无符号整数加(减)法运算后的进(借)位情况。

    有进(借)位时 CF = 1,否则 CF = 0。

    $CF=C_{out} \oplus Sub$($Sub$ 作为 $C_{in}$)。

    对带符号数运算没有意义。

注意:不论是无符号数还是带符号数,都以二进制代码形式无差别存放在机内,加法器不加以区分,条件标志也按照自己的规则生成,只是不一定有意义。

控制标志含义

  1. 方向标志 DF (Direction Flag)

    用来确定串操作指令执行时变址寄存器 SI/ESI 和 DI/EDI 中的内容是自动递增还是递减。

    递减时 DF = 1,递增时 DF = 0。

  2. 中断允许标志 IF (Interrupt Flag)

    IF = 1 允许响应中断,IF = 0 禁止响应中断。

    IF 对非屏蔽中断和内部异常不起作用,即仅对外部可屏蔽中断起作用。

  3. 陷阱标志 TF (Trap Flag)

    用来控制单步执行操作。

    TF = 1 时将开启单步调试模式,每条指令被执行后都将产生一个调试异常,以便于观察指令执行后的情况,此时可控制在每执行完一条指令后就把该指令执行得到的机器状态(包括各寄存器和存储单元的值等)显示出来。

段寄存器

6 个段寄存器都是 16 位,存放段选择子

  • 代码段寄存器 CS:指向程序代码所在的段。
  • 栈段寄存器 SS:指向栈区所在的段。
  • 数据段寄存器 DS:指向程序的全局静态数据区所在的段。

其他 3 个段寄存器可以指向任意的数据段。

CS 寄存器中的 RPL 字段表示正在执行的程序的当前特权级 CPL (Current Privilege Level)。Linux 只使用 0 级(最高级)和 3 级(最低级),分别为内核态和用户态。

汇编指令格式

使用不同的编程工具开发程序时,用到的汇编程序也不同。

基于 x86 架构的处理器所使用的汇编指令一般有两种格式:AT&T 格式和 Intel 格式,二者区别如下:

① AT&T 格式的指令只能用小写字母,Intel 格式的指令对大小写不敏感。

② AT&T 格式中,第一个为源操作数,第二个为目的操作数,方向从左到右;Intel 格式中,第一个为目的操作数,第二个为源操作数,方向从右到左。

③ AT&T 格式中,寄存器需要加前缀 %,立即数需要加前缀 $;Intel 格式中寄存器和立即数都不需要加前缀。

④ 在内存寻址方面, AT&T 格式使用 (),Intel 格式使用 []

⑤ 在处理复杂寻址方式时,例如 AT&T 格式的内存操作数 disp(base, index, scale) 分别表示偏移量、基址寄存器、变址寄存器和比例因子,如 8(%edx, %eax, 2) 表示操作数为 M[R[edx] + R[eax] 2 + 8], 其对应的 Intel 格式的操作数为 `[edx + eax 2 + 8]`。

⑥ 在指定数据长度方面,AT&T 格式指令操作码后面紧跟一个字符,表示操作数大小,b 表示字节 byte,w 表示字 word,l 表示双字 long;Intel 格式也有类似的语法,它在操作码后面显式地注明 byte ptr、word ptr 或 dword ptr。

💁目前历年统考真题采用的均是 Intel 格式,但不排除会使用 AT&T 格式的可能。

`mov` 指令用于在内存和寄存器之间或者寄存器之间移动数据。

lea 指令用于将一个内存地址(非其所指内容)或运算结果加载到目的寄存器。

机器指令格式*

下图是 Intel 64 和 IA-32 体系结构的机器指令格式,包含前缀和指令本身的代码部分:

前缀部分

前缀部分最多占 4B,有 4 种前缀类型,每个前缀占 1B,无先后顺序关系。

  • 指令前缀:包括加锁 LOCK 和重复执行两种。
  • 段前缀:用于指定指令所使用的非默认段寄存器。
  • 操作数长度前缀:用于指定非默认的操作数长度。
  • 地址长度前缀:用于指定非默认的地址长度。

若指令使用默认的段寄存器、操作数长度、地址长度,则无须在指令前加相应的前缀字节。

指令部分

  • 主操作码字段:必需。

  • ModR/M 字段:可再分成 Mod、Reg/OP 和 R/M 三个字段。

    Reg/OP 可能是寄存器编号,用来表示某一个操作数地址;也可能是 3 位拓展操作码。

    Mod 和 R/M 共 5 位,表示另一个操作数的寻址方式,可组合成 32 种情况。当 Mod = 11 时,为寄存器寻址方式,3 位 R/M 表示寄存器编号,其他 24 种情况都是存储器寻址方式。

  • SIB 字段:有比例因子 SS、变址寄存器 Index 和基址寄存器 Base 三个字段。

    是否在 ModR/M 字节后跟一个 SIB 字节,由 Mod 和 R/M 组合确定。

  • 位移字段:如果寻址方式中需要由位移量,则由位移字段给出。

  • 立即数字段:用于给出指令中的一个源操作数。

常用汇编指令(Intel 格式)

常用标记

  • \<reg> 表示任意寄存器,若其后带有数字,则指定其位数。

    \<reg32> 表示 32 位寄存器(eax、ebx、ecx、edx、esi、edi、esp、ebp)。

    \<reg16> 表示 16 位寄存器(ax、bx、cx、dx)。

    \<reg8> 表示 8 位寄存器(ah、al、bh、bl、ch、cl、dh、dl)。

  • \<mem> 表示内存地址

    [eax][var + 4]dword ptr [eax + ebx]

  • \<con> 表示常数

    \<con8> 表示 8 位常数,\<con16> 表示 16 位常数,\<con32> 表示 32 位常数。

汇编指令对应的二进制编码

x86 中的指令机器码长度为 1 字节,对同一指令的不同用途有多种编码方式。

比如 mov 指令就有 28 种机内编码,用于不同操作数类型或用于特定寄存器,例如:

1
2
3
4
5
mov ax, <con16>               #机器码为B8H
mov al, <con8> #机器码为B0H
mov <reg16>, <reg16>/<mem16> #机器码为89H
mov <reg8>/mem8>, <reg8> #机器码为8AH
mov <regl6>/<mem16>, <regl6> #机器码为8BH

数据传送指令

mov 指令

将第二个操作数(寄存器的内容/内存中的内容/常数值)复制到第一个操作数(寄存器/内存)。

1
2
3
4
5
mov <reg>, <reg>
mov <reg>, <mem>
mov <mem>, <reg>
mov <reg>, <con>
mov <mem>, <con>

举例:

1
2
mov eax, ebx           #将ebx值复制到eax
mov byte ptr [var], 5 #将5保存到var值指示的内存地址的一字节中

双操作数指令的两个操作数不能都是内存,即 mov 指令不能用于直接从内存复制到内存。若需在内存之间复制,可使用一个寄存器做中转。

push 指令

将操作数压入内存的栈,常用于函数调用。

ESP 是栈顶,压栈前先将 ESP 值减 4(栈增长方向与内存地址增长方向相反),然后将操作数压入 ESP 指示的地址。

1
2
3
push <reg32>
push <mem>
push <con32>

举例:

1
2
push eax    #将eax值压栈
push [var] #将var值指示的内存地址的4字节值压栈

注意栈中元素固定为 32 位。

pop 指令

执行出栈操作。

出栈前先将 ESP 指示的地址中的内容出栈,然后将 ESP 值加 4。

1
2
pop edi    #弹出栈顶元素送到edi
pop [ebx] #弹出栈顶元素送到ebx指示的内存地址的4字节中

算术和逻辑运算指令

add / sub 指令

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

1
2
3
4
5
add <reg>, <reg> / sub <reg>, <reg>
add <reg>, <mem> / sub <reg>, <mem>
add <mem>, <reg> / sub <mem>, <reg>
add <reg>, <con> / sub <reg>, <con>
add <mem>, <con> / sub <mem>, <con>

举例:

1
2
sub eax, 10             #eax ← eax - 10
add byte ptr [var], 10 #var值指示的内存地址的一字节值与10相加,并将结果保存在var值指示的内存地址的字节中

AT&T:sub source,destination,destination ← destination - source。

adc 带进位加法,在求和运算时再多加上 CF 标志位;sbb 带借位减法,减法运算时再多减去 CF 标志位。

inc / dec 指令

自加 1 / 自减 1。

给定操作数既是源操作数也是目的操作数,不区分是无符号数还是带符号整数,生成的标志信息送标志寄存器,注意不生成 CF 标志

1
2
inc <reg> / dec <reg>
inc <mem> / dec <mem>

举例:

1
2
dec eax              #eax值自减1
inc dword ptr [var] #var值指示的内存地址的4字节值自加1

imul 指令

带符号整数乘法指令。

有两种格式:

  1. 两个操作数,将两个操作数相乘,将结果保存在第一个操作数中。
  2. 三个操作数,将第二个操作数和第三个操作数相乘,将结果保存在第一个操作数中。

两种格式的第一个操作数都必须为寄存器

1
2
3
4
imul <reg32>, <reg32>
imul <reg32>, <mem>
imul <reg32>, <reg32>, <con>
imul <reg32>, <mem>, <con>

举例:

1
2
imul eax, [var]    #eax ← eax * [var]
imul esi, edi, 25 #esi ← edi * 25

乘法操作结果可能溢出,则编译器置溢出标志 OF = 1,以使 CPU 调出溢出异常处理程序.

idiv 指令

带符号整数除法指令。

只有一个操作数,代表除数,被除数为 edx:eax 中的内容(64 位整数)。

操作结果有两部分:商和余数。商送到 eax,余数送到 edx。

1
2
idiv <reg32>
idiv <mem>

举例:

1
2
idiv ebx
idiv dword ptr [var]

and / or / xor 指令

逻辑与/逻辑或/逻辑异或操作指令,用于操作数的位操作。

操作结果放在第一个操作数中。

1
2
3
4
5
and <reg>, <reg> / or <reg> <reg> / xor <reg>, <reg>
and <reg>, <mem> / or <reg> <mem> / xor <reg>, <mem>
and <mem>, <reg> / or <mem> <reg> / xor <mem>, <reg>
and <reg>, <con> / or <reg> <con> / xor <reg>, <con>
and <mem>, <con> / or <mem> <con> / xor <mem>, <con>

举例:

1
2
and eax, 0fH  #将eax中的前28位置0,最后4位保持不变
xor edx, edx #将edx中的内容全部置0

not 指令

位翻转指令,将操作数中的每一位翻转,即 0→1、1→0。

1
2
not <reg>
not <mem>

举例:

1
not byte ptr [var]  #将var值指示的内存地址的一字节的所有位翻转

neg 指令

取负指令。

也即,将给定长度的一个位串 “各位取反、末位加 1”,也称之为取补指令。给定操作数既是源操作数也是目的操作数,生成的标志信息送标志寄存器。

1
2
neg <reg>
neg <mem>

举例:

1
neg eax  #eax ← -eax

若字节操作数的值为 -128,或字操作数的值为 -32768,或双字操作数的值为 -2147483648,则其操作数无变化,但 OF = 1。若操作数的值为 0,则取补结果仍为0 且 CF 置 0,否则总是使 CF 置 1。

shl / shr 指令

逻辑移位指令,shl 逻辑左移,shr 逻辑右移

第一个操作数表示被操作数,第二个操作数指示移位的位数

1
2
3
4
shl <reg>, <con8> / shr <reg>, <con8>
shl <mem>, <con8> / shr <mem>, <con8>
shl <reg>, <cl> / shr <reg>, <cl>
shl <mem>, <cl> / shr <mem>, <cl>

举例:

1
2
shl eax, 1   #将eax值左移1位
shr ebx, cl #将ebx值右移n位(n为cl中的值)

控制流指令

x86 处理器中的程序计数器为 IP。IP 寄存器不能直接操作,但可以用控制流指令更新。

通常用标签(label)指示程序中的指令地址。在 x86 汇编代码中,可在任何指令之前加入标签,例如:

1
2
3
       mov esi, [ebp+8]
begin: xor ecx, ecx
mov eax, [esi]

这样就用 begin 指示了第二条指令,控制流指令通过标签就可以实现程序指令的跳转。

jmp 指令

控制 IP 转移到 label 所指示的地址(从 label 中取出指令执行)。

1
jmp <label>

举例:

1
jmp begin  #跳转到begin标记的指令执行

jcondition 指令

条件转移指令,依据 CPU 状态字中的一系列条件状态转移。

CPU 状态字中包括指示最后一个算术运算结果是否为 0,运算结果是否为负等。

1
2
3
4
5
6
7
8
9
10
11
12
13
je <label>   #jump when equal
jne <label> #jump when not equal
jz <label> #jump when last result zero
#signed
jg <label> #jump when greater than
jge <label> #jump when greater than or equal to
jl <label> #jump when less than
jle <label> #jump when less than or equal to
#unsigned
ja <label> #jump when above
jae <label> #jump when above or equal to
jb <label> #jump when below
jbe <label> #jump when below or equal to

举例:

1
2
cmp eax, ebx
jle done #如果eax的值小于等于ebx值,跳转到done指示的指令执行,否则执行下一条指令

cmp / test 指令

cmp 指令相当于 sub 指令,用于比较两个操作数的值。

test 指令相当于 and 指令,对两个操作数进行逐位与运算。

suband 指令不同的是,这两类指令都不保存操作结果,仅根据运算结果设置 CPU 状态字中的条件码。

1
2
3
4
cmp <reg>, <reg> / test <reg>, <reg>
cmp <reg>, <mem> / test <reg>, <mem>
cmp <mem>, <reg> / test <mem>, <reg>
cmp <reg>, <con> / test <reg>, <con>

cmptest 指令通常和 jcondition 指令搭配使用,举例:

1
2
3
4
cmp dword ptr [var], 10  #将var指示的主存地址的4字节内容与10比较
jne loop #如果相等则继续顺序执行,否则跳转到loop处执行
test eax, eax #测试eax是否为零
jz xxxx #为零则置标志ZF为1,跳转到xxxx处执行

call / ret 指令

callret 指令分别用于实现子程序(过程、函数等)的调用及返回,都属于无条件转移指令。

1
2
call <label>
ret
  • call 指令先将位于其下条指令的地址入栈(作为返回地址),然后无条件转移到由标签指示的指令。等价于:

    1
    2
    push eip
    jmp <label>

    与其他简单的跳转指令不同,call 指令保存调用之前的地址信息(当 call 指令结束后,返回调用之前的地址)。

  • ret 指令实现子程序的返回机制:ret 指令弹出栈中保存的返回地址(相当于 pop 指令),并送到 EIP 寄存器(段内或段间调用时)和 CS 寄存器(仅段间调用时),即无条件转移到返回地址执行。等价于:

    1
    pop eip

    ret 指令带有一个立即数 n,即 ret n 指令,则当它完成上述操作后,还会执行 R[esp] ← R[esp] + n 操作,实现预定的修改栈指针的目的。等价于:

    1
    2
    pop eip
    add esp, n

    一般 n 为栈中除开 ip 或者 cs 和 ip 数据外的其他数据占用的字节单元数,表示忽略栈中的数据,把栈顶指针移动到栈底。

callret 是程序(函数)调用中最关键的两条指令。

常见语句的机器级表示

本节汇编指令使用 AT&T 格式。

流程控制语句

C 语言有 9 种流程控制语句,分成三类:

选择语句

编译器通过条件码(标志位)设置指令和各类转移指令来实现程序中的选择结构语句。

条件码描述了最近的算术或逻辑运算操作的属性,可以检测这些寄存器来执行条件分支指令,最常用的条件码有 CF、ZF、SF、OF。

条件码设置指令

  • 常见的算术逻辑运算指令(addsubimulorandshlincdecnotsal等)会设置条件码。

  • 只设置条件码而不改变任何其他寄存器的两类指令

    1. cmp 指令:与 sub 指令的行为一样。
    2. test 指令:与 and 指令的行为一样。

    它们只设置条件码,而不更新目的寄存器。

if-else 语句

if-else 语句通用形式:

1
2
3
4
if (cond_expr)
then_statement
else
else_statement

通常,编译后得到的对应汇编代码可以有如下两种不同的结构:

对于下面的 C 语言函数:

1
2
3
4
5
6
int get_lowaddr_content(int *p1, int *p2) {
if (p1 > p2)
return *p2;
else
return *p1;
}

已知形式参数 p1 和 p2 对应的实参已被压入调用函数的栈帧,p1 和 p2 对应实参的存储地址分别为 R[ebp] + 8、R[ebp] + 12。返回结果存放在 EAX 中。

则上述函数体对应的汇编代码为:

1
2
3
4
5
6
7
8
9
1  movl 8(%ebp), %eax   #R[eax]←M[R[ebp]+8],即R[eax]=p1
2 movl 12(%ebp), %edx #R[edx]←M[R[ebp]+12],即R[edx]=p2
3 cmpl %edx, %eax #比较p1和p2,即根据p1-p2的结果置标志(右-左)
4 jbe .L1 #若pl<=p2,则转标记L1处执行
5 movl (%edx), %eax #R[eax]←M[R[edx]],即R[eax]=M[p2]
6 jmp .L2 #无条件跳转到标记L2执行
7 .L1:
8 movl (%eax), %eax #R[eax]←M[R[eax]],即R[eax]=M[p1]
9 .L2:

p1 和 p2 是指针型参数,故在 32 位机中的长度后缀是 l(双字)。

比较指令 cmpl 的两个操作数都应来自寄存器(?),故应先将 p1 和 p2 对应的实参从栈中取到通用寄存器​​。

比较指令执行后得到各个条件码,然后根据各条件码值的组合选择执行不同的指令,因此需要用到条件转移指令。

循环语句

C 语言循环结构有三种:while、for、do ~ while,大多数编译程序将这三种循环结构都转换为 do ~ while 形式来产生机器级代码。

汇编中没有相应的指令存在,可以用条件测试和转跳组合起来实现循环的效果。

在循环结构中,通常使用条件转移指令来判断循环条件的结束。

do ~ while 语句

C 语言形式:

1
2
3
do {
loop_body_statement
} while (cond_expr);

更接近于机器级语言的低级行为描述结构:

1
2
3
4
loop:
loop_body_statement
c = cond_expr;
if (c) goto loop;

上述结构对应的机器级代码中,loop_body_statement 用一个指令序列完成,然后用一个指令序列实现对 cond_expr 的计算,并将计算或比较的结果记录在标志寄存器中,然后用一条条件转移指令来实现 if (c) goto loop; 的功能。

while 语句

C 语言形式:

1
2
while (cond_expr)
loop_body_statement

更接近于机器级语言的低级行为描述结构:

1
2
3
4
5
6
7
c = cond_expr;
if (!c) goto done;
loop:
loop_body_statement
c = cond_expr;
if (c) goto loop;
done:

从上述结构可看出,与 do ~ while 循环结构相比,while 循环仅在开头多了一段计算条件表达式的值并根据条件选择是否跳出循环体执行的指令序列,其余地方与 do ~ while 语句一样。

for 语句

C 语言形式:

1
2
for (begin_expr; cond_expr; update_expr)
loop_body_statement

更接近于机器级语言的低级行为描述结构:

1
2
3
4
5
6
7
8
9
begin_expr;
c = cond_expr;
if (!c) goto done;
loop:
loop_body_statement
update_expr;
c = cond_expr;
if (c) goto loop;
done:

从上述结构可看出,与 while 循环结构相比,for 循环仅在两个地方多了一段指令序列。一个是开头多了一段循环变量赋初值的指令序列,另一个是循环体中多了更新循环变量值的指令序列,其余地方与 while 语句一样。

下面是一个使用 for 循环写的自然数求和的函数:

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

这段代码中的 for 循环的不同组成部分如下:

1
2
3
4
begin_expr           i = 1
cond_expr i <= n
update_expr i++
loop_body_statement result += i

根据前面给出的 for 循环的低级行为描述结构,不难写出上述过程对应的汇编表示:

1
2
3
4
5
6
7
8
9
10
11
 1  movl 8(%ebp), %ecx  #R[ecx]←M[R[ebp]+8],即R[ecx]=n
2 movl $0, eax #R[eax]←0,即result=0
3 movl $1, edx #R[edx]←1,即i=l
4 cmp %ecx, %edx #Compare R[ecx]:R[edx],即比较n:i
5 jg .L2 #If greater(i-n>0),转跳到L2执行(跳出循环)
6 .L1: #1OOP:
7 addl %edx, %eax #R[eax]←R[eax]+R[edx],即result+=i
8 addl $1, %edx #R[edx]←R[edx]+1,即i++
9 cmpl %ecx, %edx #比较%ecx和%edx,即比较n:i
10 jle .L1 #If less or equal(i-n<=0),转跳到L1执行(进入下一轮循环)
11 .L2:

已知 n 对应实参已被压入调用函数的栈帧,其对应的存储地址为 R[ebp] + 8,过程 nsum_for 中非静态局部变量 iresult 被分别分配到寄存器 EDX 和 EAX 中,返回参数在 EAX 中。

过程调用

过程调用的执行步骤

假定过程 P 调用过程 Q:

  1. P 将入口参数(实参)放在 Q 能访问到的地方。
  2. P 将返回地址存到特定的地方,然后将控制转移到 Q(call 指令)。
  3. Q 保存 P 的现场(通用寄存器的内容),并为自己的非静态局部变量分配空间。
  4. 执行过程 Q。
  5. Q 恢复 P 的现场,将返回结果放到 P 能访问到的地方,并释放局部变量所占空间。
  6. Q 取出返回地址,将控制转移到 P(ret 指令)。

需要存放的数据

入口参数、返回地址、调用过程的现场、被调用过程中的非静态局部变量、返回结果等。

调用者保存寄存器

EAX、ECX、EDX 保存和恢复的任务由调用者负责。

当 P 调用 Q 时,Q 就可以直接使用这三个寄存器(这意味着 P 应在转到 Q 之前先保存它们的值,并在从 Q 返回之后先恢复它们的值再使用)。

被调用者保存寄存器

EBX、ESI、EDI 保存和恢复的任务由被调用者负责。

Q 必须先将它们的原值保存在栈中才能使用它们,并且在返回 P 之前恢复。

另外调用过程 P 的 EBP 亦由被调用过程保存(push %ebp)和恢复(leavemov %ebp, %esp + pop %ebp)。

调用者保存寄存器和被调用者保存寄存器是根据需要才进行保存和恢复,而非必须。

栈与栈帧

每个过程都有自己的栈区,称为栈帧,一个栈由若干栈帧组成。

IA-32 使用栈来支持过程的嵌套调用,过程的入口参数、返回地址、被保存寄存器的值、被调用过程中的非静态局部变量等都会被压入栈中,IA-32 中可通过执行 movpushpop 指令存取栈中元素。

帧指针寄存器 EBP 指示当前过程栈帧的栈底,栈指针寄存器 ESP 指示(当前过程栈帧的)栈顶。

栈从高地址向低地址增长。

过程执行时,ESP 会随着数据的出入栈而动态变化,而 EBP 固定不变。当前栈帧的范围在 EBP 和 ESP 指向的区域之间。

过程调用过程中栈和栈帧的变化

在过程 P 中遇到一个函数调用:

  1. 首先,P 确定是否需要将某些调用者保存寄存器(如 EAX、ECX 和 EDX)保存到自己的栈中。
  2. 然后,将入口参数按序保存到 P 的栈中,参数压栈的顺序是先右后左
  3. 最后执行 call 指令,先将返回地址保存到 P 的栈中,然后转去执行被调用过程 Q。

在执行被调用函数 Q 的准备阶段:

  1. 首先,Q 将 EBP 的旧值保存到自己的栈中并设置 EBP 指向它。

  2. 然后,根据需要确定是否将被调用者寄存器(如 EBX、ESI、EDI)保存到栈帧中。

  3. 最后在栈中为非静态局部变量分配空间。

    通常,如果非静态局部变量为简单变量且有空闲的通用寄存器,则编译器会将通用寄存器分配给局部变量,但是,对于非静态局部变量是数组或结构等复杂数据类型的情况,则只能在栈中为其分配空间。

在 Q 过程体执行后的结束阶段,会恢复被调用者保存寄存器和 EBP 寄存器的值,并使 ESP 指向返回地址,这样栈中的状态又回到了调用 Q 前的状态。这时执行 RET 指令便能取出返回地址,以回到过程 P 继续执行。

从图 b 可看出,在 Q 的过程体执行时,入口参数 1 的地址总是 R[ebp] + 8,入口参数 2 的地址总是 R[ebp] + 12,入口参数 3 的地址总是 R[ebp] + 16,依此类推。

简单的 C 语言程序对应的过程调用的机器级实现

1
2
3
4
5
6
7
8
9
10
int add(int x, int y) {
return x + y;
}

int caller() {
int temp1 = 125;
int temp2 = 80;
int sum = add(temp1, temp2);
return sum;
}

经 GCC 编译后,caller 过程(不包含 add 过程)对应的代码如下(设 P 调用 caller):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 1 caller:
2 pushl %ebp #旧帧指针值压栈
3 movl %esp, %ebp #当前栈顶作为caller栈底
4 subl $24, %esp #移动栈顶指针,为caller参数分配24字节空间(6个双字)
5 movl $125, -12(%ebp) #125→M[R[ebp]-12],保存局部变量temp1值到栈帧
6 movl $80, -8(%ebp) #80→M[R[ebp]-8],保存局部变量temp2值到栈帧
7 movl -8(%ebp), %eax #M[R[ebp]-8]→R[eax],add入口参数temp2值复制到eax
8 mov %eax, 4(%esp) #R[eax]→M[R[esp]+4],保存add入口参数temp2值到栈帧(从eax)
9 movl -12(%ebp), %eax #M[R[ebp]-12]→R[eax],add入口参数temp1值复制到eax
10 movl %eax, (%esp) #R[eax]→M[R[esp]],保存add入口参数temp1值到栈帧(从eax)
11 call add #把返回地址(即第12行指令地址)压栈,调用add,并将add返回值保存在eax中
12 movl %eax, -4(%ebp) #R[eax]→M[R[ebp]-4],add返回值送局部变量sum并保存到栈帧
13 movl -4(%ebp), %eax #M[R[ebp]-4]→R[eax],sum作为caller返回值保存到eax
14 leave
15 ret

执行第 4 行后 ESP 所指位置如下图所示:

caller 栈帧用到的空间占 4 (P 的 EBP 值) + 12 (3 个局部变量) + 8 (add 的 2 个入口参数) + 4 (call 指令设置的 add 调用结束后的返回地址) = 28 字节,但栈帧共有 4 + 24 + 4 = 32 字节(一开始为 caller 参数分配的 24 字节空间只用了 20 字节),有 4 字节空间被浪费。这是因为 GCC 为保证数据的严格对齐而规定每个函数的栈帧大小必须是 16 字节的倍数。

需要说明的是,i386 System V ABI 规范规定,栈中数据按 4 字节对齐,因此,若栈中存放的参数的类型是 char、unsigned char 或 short、unsigned short,也都分配 4 个字节。因而,在被调用函数的执行过程中,可以使用 R[ebp]+8、R[ebp]+12、R[ebp]+16、…… 作为有效地址来访问函数的入口参数。

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

执行 ret 指令之前,应将当前栈帧释放,并恢复旧 ESP 的值。leave 指令实现了这个功能,它相当于以下两条指令的功能:

1
2
movl %ebp, %esp  #使esp指向当前ebp的位置
popl %ebp #弹出当前esp所指位置的内容(P的栈底)并保存到ebp,同时esp也指向了P的栈顶(P设置的返回地址)

编译器不一定要使用 leave 指令,也可通过 pop 指令和对 ESP 的内容做加法来实现退栈操作。

add 过程经 GCC 编译并进行链接后,对应的代码如下:

1
2
3
4
5
6
7
1  8048469:55         push %ebp
2 804846a:89 e5 mov %esp, %ebp
3 804846c:8b 45 0c mov 0xc(%ebp), %eax
4 804846f:8b 55 08 mov 0x8(%ebp), %edx
5 8048472:8d 04 02 lea (%edx, %eax, 1), %eax #R[edx]+R[eax]*1→eax
6 8048475:5d pop %ebp
7 8048476:c3 ret

add 过程没有用到任何被调用者保存寄存器,没有局部变量,此外,add 是一个被调用过程,并且不再调用其他过程,即它是一个叶子过程,因而也没有入口参数和返回地址要保存,因此,add 的栈帧中除了要保存 caller 的 EBP 以外,无须保留任何信息。

通常一个过程对应的机器级代码都有三个部分:

  1. 准备阶段(最简单的准备阶段代码段)

    1
    2
    push %ebp
    mov %esp, %ebp

    通过将当前栈指针 ESP 传送到 EBP 来完成将 EBP 指向当前栈帧底部的任务。

  2. 过程体

    可以很方便地通过 EBP 获取入口参数(从 R[ebp] + 8 开始往上)。

    add 过程中的 lea 指令执行的是加法运算 R[edx] + R[eax] = x + y。

    过程体结束时将返回值放在 EAX。

  3. 结束阶段

    1
    2
    leave
    ret

    1
    2
    3
    mov %ebp, %esp  #EBP和ESP已重合时可省,比如这里的add过程,整个过程体阶段没有压栈操作
    pop %ebp
    ret

    add 的 EBP 和 ESP 都指向保存有 caller EBP 值的单元,因此通过 pop 并将出栈值保存到 EBP 可以实现恢复 EBP 在 caller 过程中的值,并在栈中退出 add 过程的栈帧,同时使得执行到 ret 指令时 ESP 已指向返回地址。

CISC 和 RISC 的基本概念

指令系统朝两个截然不同的方向发展:

  1. 增强原有指令的功能,设置更为复杂的新指令实现软件功能的硬化。

    这类机器称为复杂指令系统计算机 CISC,典型的有采用 x86 架构的计算机。

  2. 减少指令种类和简化指令功能,提高指令的执行速度。

    这类机器称为精简指令系统计算机 RISC,典型的有 ARM、MIPS、RISC-V 架构的计算机。

复杂指令系统计算机 CISC

复杂指令集计算机 CISC (Complex Instruction Set Computer)。

背景

随着超大规模集成电路 VLSI 技术发展,硬件成本不断下降,软件成本不断上升,促使人们在指令系统中增加更多、更复杂的指令,以适应不同的应用领域。

中心思想

每个指令可执行若干低端操作,诸如从存储器读取、存储和计算操作,全部集于单一指令之中。

主要特点

  1. 指令系统复杂庞大,指令数目一般为 200 条以上。
  2. 指令长度不固定,指令格式多,寻址方式多。
  3. 可以访存的指令不受限制。
  4. 各种指令使用频度相差很大。
  5. 各种指令执行时间相差很大,大多数指令需多个时钟周期才能完成。
  6. 控制器大多数采用微程序控制。有些指令非常复杂,以至于无法采用硬连线控制。
  7. 难以用优化编译生成高效的目标代码程序。

80/20 规律

典型程序的 80% 的语句仅仅使用处理机中 20% 的指令,而且这些指令还都是简单指令。这就告诫人们,就算付出很大代价增加复杂指令,也只有 20% 的使用概率,而且还增加了执行指令的开销。

精简指令系统计算机 RISC

精简指令集计算机 RISC (Reduced Instruction Set Computer)。

背景

CISC 指令系统庞大,指令设计要求极高,研制周期变得很长,后来人们发现一味地追求指令系统的复杂和完备程度不是提高计算机性能的唯一途径。

从这一事实出发,人们开始用最常用的 20% 的简单指令,重组实现不常用的 80% 的指令功能,RISC 随之诞生。

中心思想

简化指令系统,系统一条指令完成一个基本 “动作”,多条指令组合完成一个复杂的基本功能。

尽量使用寄存器-寄存器操作指令(减少访存),指令格式力求一致。

RISC 选择一些常用的寄存器型指令,并不是为了兼容 CISC,RISC 也不可能兼容 CISC。

主要特点

  1. 选取使用频率最高的一些简单指令(以及很有用但不复杂的指令),复杂指令的功能由简单指令的组合来实现。
  2. 指令长度固定,指令格式种类少,寻址方式种类少。
  3. 只有 Load/Store (取数/存数) 指令可以访存,其余指令的操作都在寄存器之间进行。
  4. CPU 中通用寄存器的数量相当多。
  5. 一定采用指令流水线技术,大部分指令在一个时钟周期内完成。
  6. 以硬布线控制为主,不用或少用微程序控制。
  7. 特别重视编译优化工作,以减少程序执行时间。

装入/存储(Load/Store)型指令

装入/存储型指令是用在规整型指令系统中的一种通用寄存器型指令风格。这种指令风格在 RISC 指令系统中较为常见。

为了规整指令格式,使指令具有相同的长度,规定只有 Load/Store 指令才能访问内存。而运算指令不能直接访问内存,只能从寄存器取数进行运算,运算的结果也只能送到寄存器。

因为寄存器编号较短,而主存地址位数较长,通过某种方式可使运算指令和访存指令的长度一致。

这种装入/存储型风格的指令系统的最大特点是,指令格式规整,指令长度一致,一般为 32 位。由于只有 Load/Store 指令才能访问内存,程序中可能会包含许多装入指令和存储指令,与ー般通用寄存器型指令风格相比,其程序长度会更长。

CISC vs RISC

RISC 必然采用流水线技术;CISC 无此强制要求,但为了提高指令执行速度,往往也采用流水线技术。

RISC 主要优点(和 CISC 相比):

  1. 更能充分利用 VLSI 芯片的面积。

    CISC 的控制器大多采用微程序控制,其控制存储器在 CPU 芯片所占的面积达 50% 以上;

    RISC 的控制器采用组合逻辑控制,其硬布线逻辑只占 CPU 面积的 10% 左右。

  2. 更能提高运算速度。

    RISC 指令数、寻址方式和指令格式少,又设有多个通用存储器,采用流水线技术,所以运算速度更快,大多数指令在一个时钟周期内完成。

  3. RISC 便于设计,可降低成本,提高可靠性。

    RISC 指令系统简单,因此机器设计周期短。

    其逻辑简单,出错概率低,有错也易发现,因此可靠性高。

  4. 有利于编译程序代码优化。

    RISC 指令类型少,寻址方式少,使编译程序容易选择更有效的指令和寻址方式,并适当地调整指令顺序,使得代码执行更高效化。

    CISC 指令功能强大,寻址方式多,便于汇编程序员编程。

👯兼容性:虽说 RISC 具有更强的实用性,应是未来处理器的发展方向,但事实上,当今 Intel 几乎一统江湖,且早期很多软件都是根据 CISC 设计的,CISC 大多能实现向后兼容,并可加以而扩充;RISC 简化了指令系统,指令条数少,格式也不同于老机器,因此大多数 RISC 机不能与老机器兼容。

现代 CISC 结构的 CPU 已经融合了很多 RISC 的成分,二者性能差距越来越小。

刷题笔记

  • ISA 规定了执行每条指令时所包含的控制信号

    控制信号是由控制单元根据 ISA 生成的,属于微架构层面的实现细节,而不是 ISA 层面的抽象定义。

  • 单地址指令是固定长度的指令

    指令的地址个数与指令的长度是否固定没有必然联系,即使是单地址指令,也可能由于单地址的寻址方式不同而导致指令长度不同。

  • 求指令长度时,注意题目有没有要求是字节的整数倍。

  • 看清题目要求的是操作数有效地址还是操作数本身!!!

  • 相对寻址相关考题给出的 PC 值一般是没有取该转移指令前的值,所以计算操作数/指令地址时记得 + “1”。

    跳转指令(例如 jmp 指令、jle 指令等)后面跟的是偏移量时,转移地址为该条跳转指令的下条指令的地址基础上加上偏移量。

    一定注意细节

  • 内存地址是无符号数,不能为负。

  • 偏移寻址,将偏移量(补码)符号扩展之后直接加到基准地址(无符号数)即可。

  • 条件转移指令 bgt(无符号整数比较大于时转移)的转移条件是:$\overline{CF+ZF}=1$,即进位/借位标志 CF 和零标志 ZF 均为 0。

  • sizeof():返回一个数据类型或变量所占内存大小(字节为单位),属于编译时操作符。

    strlen():返回以 ‘\0’ 结尾的 C 风格字符串的长度(字符数),属于库函数(运行时计算)。

  • Imm8 是 8 位立即数,而不是立即数 8。

  • 嵌套调用时返回地址通常保存在栈中,非嵌套调用时可保存在特定寄存器中。

  • 选择结构语句 if (cond_expr) then_statement; else else_statement; 对应的机器级代码表示的叙述中:

    1)一定包含一条无条件转移指令。

    2)一定包含一条条件转移指令(分支指令)。

    3)计算 cond_expr 的代码段一定在条件转移指令之前。

    4)then_statementelse_statement 的先后顺序不是一定的。

  • 循环结构语句对应的机器级代码表示的叙述中:

    1)不一定包含无条件转移指令。

    2)一定包含至少一条条件转移指令。

    3)循环结束条件可以用一条比较指令 cmp 来实现。