# 指令系统
## 基本概念
## 指令格式
## 寻址方式
## 数据的对齐和大小端存放方式
## CISC和RISC
## 高级程序语言与机器代码之间的对应
- 编译器、汇编器和链接器
- 选择结构语句
- 循环结构语句
- 过程调用对应的机器级表示
指令系统
1 - 指令格式和寻址方式
指令的格式
指令中主要包含两个部分:操作码(opcode)以及 地址(address)。
这里地址是一个通用含义,指的是操作的对象,可以是一个内存地址,也可以是 CPU 中的一个寄存器。
指令类型
根据操作码分类
指令根据其操作码(opcode)的不同可以分为以下类别:
- 数据传输指令
MOV
:将数据从一个位置传输到另一个位置,可以是寄存器到寄存器、内存到寄存器、寄存器到内存等。PUSH
:将数据(通常是寄存器中的值)推入堆栈。POP
:从堆栈中弹出数据并存储到寄存器中。
- 算术和逻辑运算指令
ADD
、SUB
、MUL
、DIV
:执行算术运算,如加法、减法、乘法和除法。AND
、OR
、XOR
、NOT
:执行逻辑运算,如按位与、按位或、按位异或和按位取反。INC
、DEC
:递增和递减操作数的值。CMP
:用于比较两个值,并根据结果设置标志寄存器的状态。
- 控制转移指令
JMP
:用于无条件跳转到指定的目标地址。Jxx
:条件跳转指令,根据特定的条件(如零标志、进位标志等)来决定是否跳转。CALL
:调用子程序或函数。RET
:从子程序返回。
- 输入/输出指令
IN
:从外部设备或端口读取数据。OUT
:向外部设备或端口发送数据。
- 字符串操作指令(String Instructions):
MOVS
、LODS
、STOS
、CMPS
:用于在内存中执行字符串操作,如移动、加载、存储、比较。
- 陷阱指令(Trap Instructions):
INT
:用于引发中断,通常用于与操作系统进行通信。
- 协处理器指令(Coprocessor Instructions):
CLI
、STI
:用于清除和设置 CPU 的中断标志,通常只能在内核模式下执行。
根据地址个数分类
根据指令中的地址个数,可以将指令划分为以下类型。
指令格式 | 指令格式 | 含义 |
---|---|---|
零地址指令 | op | $op$ |
一地址指令 | op, A1 | $op(A_1) \rightarrow A_1$ |
二地址指令 | op, A1, A2 | $(A_1)op(A_2) \rightarrow A_1$ |
三地址指令 | op, A3, A1, A2 | $(A_1)op(A_2) \rightarrow A_3$ |
“N 地址指令” 中的 “地址” 一词可以是立即数或一个内存地址,也可以是寄存器。
定长和变长指令
定长指令集中所有指令长度相同,变长指令集中会包含不同长度的指令。
使用定长指令集的典型 cpu 架构是 arm(RISC),优点是解码简单、高效,但可能导致指令浪费空间。
使用变长指令集的典型 cpu 架构是 x86(CISC),优点是可以更加紧凑地编码复杂操作,但解码更加复杂。
下面就要抛出两个问题了,这两个点也经常在题目中间接考察。
- 定长指令集中的指令长度都相同,但是其中会包含不同地址个数的指令,如何将这些指令放到一个固定的长度中呢?
- 对于变长指令集,每个指令长度都不同,那么 cpu 如何确定代码段(.text)中不同指令的边界呢?
寻址方式
计算机中的寻址方式(Addressing Modes)是指在指令中如何指定操作数的位置或地址。
立即数寻址
操作数作为指令的一部分而直接写在指令中,这种操作数称为立即数,这种寻址方式也就称为立即数寻址方式。
立即数寻址方式通常用于对通用寄存器或内存单元赋初值,上图是指令 MOV AX, 4567H
存储形式和执行示意图,直接将 指令中的数据 4567H
存储到寄存器中。
直接寻址
间接寻址
基址寻址
8086 的存储器被分成若干段,其中会专门设置一个基址存储器用于存储段的起始地址,段内的位移量由指令给出。操作数的地址由基址寄存器的内容和指令的地址码 A 相加得到,这种情况下地址码 A 常被成为位移量。
变址寻址
指令地址码给出地址 A 和指定的变址寄存器 R,将 A 和 R 的内容相加就是操作数的地址。常利用变址操作与循环执行程序的方法对数组进行运算。
注意
基址和变址寻址的区别?
- 基址寻址主要用于为程序或数据分配存储空间,故基址寄存器的内容通常由操作系统或管理程序确定,在程序运行过程中,值是不可变的,而指令字中的地址码是可变的。
- 变址寻址中,变址寄存器的内容是用户自己设定的,在程序运行过程中是可变的,而指令字中的地址码是不可变的。编制寻址主要用于处理数组等问题,并且特别适合编制循环程序。
相对寻址
堆栈寻址
根据 函数调用时的内存结构,函数栈可以存储函数中的变量。堆栈寻址就是在函数的栈帧中访问通过 PUSH
指令存储的数据。
寻址方式对比
寻址方式 | 描述 | 示例 |
---|---|---|
立即寻址 | 操作数直接包含在指令中。 | MOV R1, #5 (将 5 加载到 R1 寄存器) |
寄存器寻址 | 操作数在寄存器中。 | ADD R1, R2 (R2 加到 R1 中) |
直接寻址 | 操作数的内存地址直接包含在指令中。 | MOV R1, [1000] (从内存地址 1000 取数据) |
间接寻址 | 操作数的地址存储在寄存器中,指令通过寄存器访问内存中的数据。 | MOV R1, [R2] (R2 寄存器中是内存地址) |
基址寻址 | 使用基址寄存器和偏移量计算操作数的实际地址。 | MOV R1, [R2 + 4] (基址 R2 加偏移 4) |
变址寻址 | 通过基址寄存器和索引寄存器的和来确定操作数地址,常用于数组操作。 | MOV R1, [R2 + R3] (R2 与 R3 相加) |
相对寻址 | 操作数地址通过程序计数器(PC)当前值加上指令中的偏移量计算,常用于跳转指令。 | JMP LABEL (跳转到相对地址) |
堆栈寻址 | 通过堆栈顶指针(SP)来访问操作数,常用于函数调用和返回。 | PUSH R1 (将 R1 压入堆栈) |
2 - 数据对齐方式
数据对齐
数据对齐(Data Alignment)是指数据在内存中的存放方式,它要求数据的起始地址必须是某个数(通常是1、2、4、8)的整数倍,这个数被称为对齐因子(Alignment Factor)。数据对齐的目的是为了提高内存访问的效率,因为许多计算机系统都是按照数据的对齐边界来设计内存访问硬件的。
不对齐的数据访问可能会导致性能下降,因为处理器可能需要额外的内存访问来获取不完整的数据。在一些严格要求数据对齐的架构中,不对齐的数据访问甚至会导致硬件异常。
在 C11 中,我们可以通过 _Alignof
来查看不同数据类型的对齐因子:
#include <stdint.h>
#include <stdio.h>
#define EVAL_PRINT(expr) printf("%-20s = %u\n", #expr, (uint8_t)(expr));
int main(void) {
EVAL_PRINT(_Alignof(char));
EVAL_PRINT(_Alignof(uint8_t));
EVAL_PRINT(_Alignof(uint16_t));
EVAL_PRINT(_Alignof(uint32_t));
EVAL_PRINT(_Alignof(int));
EVAL_PRINT(_Alignof(uint64_t));
EVAL_PRINT(_Alignof(void*));
EVAL_PRINT(_Alignof(size_t));
return 0;
}
以上程序运行的结果如下所示,以此我们可以判断每个类型的 aglinment 大小。
$ gcc alignof.c && ./a.out
_Alignof(char) = 1
_Alignof(uint8_t) = 1
_Alignof(uint16_t) = 2
_Alignof(uint32_t) = 4
_Alignof(int) = 4
_Alignof(uint64_t) = 8
_Alignof(void*) = 8
_Alignof(size_t) = 8
以下图中的结构体定义为例,假设我们定义一个变量,变量的类型长度为 K 个字节,那么这个变量在内存中的地址 addr 必须是 K 的整数倍,即 addr % K == 0。
上图中变量 b 和 a 之间增加了 1 个字节的 padding,变量 d 的末尾也增加了 3 个字节 padding,以保证下一个数据的开始是 4 的整数倍。
大小端
大小端(Endianness)是指多字节数据在内存中的字节序,也就是字节的排列顺序。主要有两种存放方式:
- 大端模式(Big-Endian): 数据内部的高位字节存放在低位地址,低位字节存放在高位地址。也就是说,一个整数的第一个字节(最高有效字节)将存放在起始地址处。
- 小端模式(Little-Endian): 数据内部的低位字节存放在低位地址,高位字节存放在高位地址。也就是说,一个整数的最后一个字节(最低有效字节)将存放在起始地址处。
举一个例子,假如定义数组 long a[2] = {0x76543210, 0xFEDCBA98}
,long
类型的大小为8字节,数组 a
在内存中的起始地址为 0x1000
,则数组中两个元素在内存中的字节排列如下图所示:
大小端的选择通常是由计算机的CPU架构决定的,不同的架构有不同的字节序要求。例如,Intel x86和x86-64架构是小端,而网络协议通常是大端,因为大端的格式在字节流中的表示更加直观。
3 - 指令集种类
复杂和精简指令集
CISC(Complex Instruction Set Computer)和 RISC(Reduced Instruction Set Computer)是两种不同的计算机体系结构设计哲学,它们在指令集架构和执行方式上有显著的差异。
CISC
- 指令集复杂:CISC 指令集包含大量复杂的指令,其中一条指令可以执行多种操作,包括内存访问、算术运算、逻辑运算等。
- 指令不定长:CISC 支持多种长度的指令。
- 多寻址模式:CISC 指令通常支持多种寻址模式,允许直接访问内存,因此可以在一条指令中执行复杂的操作。
- 微程序控制:CISC 计算机通常使用微程序控制单元,指令解码和执行过程相对复杂。
- 复杂硬件:CISC 处理器通常包括大量的硬件单元,用于支持复杂的指令集,这使得 CISC 芯片相对较大。
CISC 的典型芯片就是 x86 系列,如 Intel 的 Core i 系列处理器和 AMD 的 Ryzen 系列处理器。
RISC
- 指令集精简:RISC 计算机的指令集更加精简,通常包含较少、更简单的指令。每条指令只执行一种操作。
- 指令定长:RISC 指令集中所有指令长度相同。
- 固定寻址模式:RISC 指令通常只支持一种或者很少种寻址模式,鼓励将数据加载到寄存器中后再执行操作。
- 硬布线控制:RISC 计算机使用硬布线控制单元,指令解码和执行过程较为简单。
- 精简硬件:RISC 处理器通常采用更精简的硬件,以提高性能和降低成本。
上图体现了 CISC 和 RISC 的差别:RISC 寄存器数量比 CISC 更多,CISC 的访存指令比 RISC 更加复杂(CSIC 单条指令完成的工作 RISC 需要多条指令才能完成)。
程序示例
这一节举个实际的例子说明一下 CISC 和 RISC 上的汇编代码区别。
for (i = 0; i < 24; i++)
for (j = 0; j < 24; j++)
...
a[i][j] = 10;
...
对于上述的循环代码段,编译器将其编译为如下的 intel x86 汇编代码(CISC) 和 arm mips 汇编代码(RISC):
for (i = 0; i < 24; i++)
1 00401072 C7 45 F8 00 00 00 00 mov dword ptr [ebp-8], 0 ; i = 0,初始化 i 变量
2 00401079 EB 09 jmp 00401084h ; 跳转到循环条件检查
3 0040107B 8B 55 F8 mov eax, [ebp-8] ; 读取 i 的值到 eax
... ... ...
7 00401088 7D 32 jge 004010BCh ; 若 i >= 24,跳出循环
for (j = 0; j < 64; j++)
8 0040108A C7 45 FC 00 00 00 00 mov dword ptr [ebp-4], 0 ; j = 0,初始化 j 变量
... ... ...
a[i][j] = 10;
... ... ...
19 004010AE C7 84 82 00 20 42 00 0A 00 00 00
mov dword ptr [ecx+edx*4+00422000h], 0Ah
; 存储 10 到 a[i][j],计算地址:
; ecx = i, edx = j
; a[i][j] = 10
20 ... ...
for (i = 0; i < 24; i++)
1 00401000 20020000 addi $v0, $zero, 0 # i = 0
2 00401004 08004004 j 0x00401010 # 跳转到循环条件检查
3 00401008 8C430008 lw $v1, 8($fp) # 读取 i
... ... ...
7 00401010 1C600018 bge $v1, 24, 0x00401040 # if (i >= 24) 跳出循环
for (j = 0; j < 64; j++)
8 00401014 20040000 addi $a0, $zero, 0 # j = 0
... ... ...
a[i][j] = 10;
17 00401028 00031880 sll $v1, $v1, 6 # i × 64
18 0040102C 00641820 add $v1, $v1, $a0 # i × 64 + j
19 00401030 00031880 sll $v1, $v1, 2 # (i × 64 + j) × 4
20 00401034 3C010042 lui $at, 0x0042 # 加载基地址高 16 位
21 00401038 34222000 ori $v0, $at, 0x2000 # 完整基地址 0x00422000
22 0040103C 00621020 add $v0, $v1, $v0 # 计算目标地址
23 00401040 2405000A li $a1, 10 # 10 存入 $a1
24 00401044 AC450000 sw $a1, 0($v0) # a[i][j] = 10
25 ... ...
CISC 和 RISC 的简要对比如下所示:
特性 | CISC | RISC |
---|---|---|
指令集 | 复杂,指令数量多 | 精简,指令数量少 |
指令长度 | 不定长 | 固定长度 |
寻址模式 | 多种寻址模式 | 较少寻址模式 |
控制方式 | 微程序控制 | 硬布线控制 |
硬件复杂度 | 复杂 | 精简 |
优点 | 功能强大,一条指令可完成复杂操作 | 性能高,功耗低 |
缺点 | 硬件复杂,指令执行效率相对较低 | 功能相对简单,复杂操作需要多条指令完成 |
不同位数 cpu 的指令
从 8086(16 位 intel 处理器)再到后来的 32 位以及 64 位的 CPU,差异主要体现在以下几个方面:
- 寄存器位数和命名不同:32 位寄存器有
E
前缀,64 位寄存器有R
前缀。 - 指令集不同:32 位处理器相比 16 位处理器有着更加复杂的指令集,64 位处理器的指令集也比 32 位更加复杂。
下表中给出了不同位数寄存器的对比,注意每个寄存器的开头都与一个单次相对应,在最初 8086 的设计中这是代表一种语义。
寄存器 | 16 位 | 32 位 | 64 位 | 类型 |
---|---|---|---|---|
Accumulator | AX | EAX | RAX | General |
Base | BX | EBX | RBX | General |
Counter | CX | ECX | RCX | General |
Data | DX | EDX | RDX | General |
Source Index | SI | ESI | RSI | Pointer |
Destination Index | DI | EDI | RDI | Pointer |
Base Pointer | BP | EBP | RBP | Pointer |
Stack Pointer | SP | ESP | RSP | Pointer |
Instruction Pointer | IP | EIP | RIP | Pointer |
Code Segment | CS | - | - | Segment |
Data Segment | DS | - | - | Segment |
AT&T 和 Intel 指令
在 x86 汇编指令集中,常有 AT&T 和 Intel 两种格式,两种格式有较大差异。在考试中主要考察的是 Intel 格式,但是 AT&T 也需稍作了解,在遇到指令时能辨认出即可。
Intel 格式
# 寄存器访问
mov eax,1
mov ebx,0ffh
# 内存访问
mov eax,[ebx]
mov eax,[ebx+3]
AT&T 格式
# 寄存器访问
movl $1,%eax
movl $0xff,%ebx
# 内存访问
movl (%ebx),%eax
movl 3(%ebx),%eax
两种指令格式的不同主要在于 Intel 格式的 目的操作数在左,源操作数在右,而 AT&T 格式 目的操作数在右,源操作数在左。
两种指令格式的具体表格如下表所示:
特性 | Intel 格式 | AT&T 格式 |
---|---|---|
操作数顺序 | 目的,源 | 源,目的 |
寄存器 | eax, ebx... | %eax, %ebx... |
常数 | 10, 0x20... | $10, $0x20... |
内存寻址 | [] | () |
4 - 高级语言和机器码
编译过程
一个传统的 C 程序从源代码到可执行二进制程序的过程中,需要经历预处理(Preprocess)、编译(Compile)、汇编(Assemble)、链接(Link)四个步骤,这四个步骤分别由预处理器(Preprocessor)、编译器(Compiler)、汇编器(Assembler)、链接器(Linker)完成。
预处理
预处理(Preprocess)阶段负责对源代码(source code)进行文本的转换和处理,将其转化为扩展代码(expanded code)。具体而言,预处理阶段包含如下工作:
- 头文件包含:将
#include
指令包含的头文件内容插入到源文件中。 - 宏替换: 将源代码中定义的宏
#define
进行替换。 - 删除注释: 将代码中的注释删除。
编译
编译(Compile)阶段将预处理后的源代码(extended code)翻译成汇编代码(assembly code)。编译阶段包含词法分析、语法分析、语义分析、中间代码优化和汇编代码生成等子过程。
注意
注意编译(compilation)这个词一般的含义是将高级语言代码转化为二进制程序,但是如果在整个编译流程中谈到这个词,则需要将其与汇编(assemble)进行区分:
- 编译是将高级语言代码转化为汇编代码
- 汇编是将汇编代码转化为二进制代码
汇编
汇编阶段负责将汇编代码(assembly code)转换为目标文件(object file)。汇编器(assembler)解析汇编指令,将其翻译为对应的机器码。
链接
如上图所示,链接器(linker)的作用是将由编译器生成的一个或多个目标代码文件(object file,通常是汇编器生成的机器代码)合并为一个单一的可执行文件。在这个过程中,链接器主要完成如下任务:
- 符号解析:查找所有未定义的符号(如函数调用、全局变量)并找到对应的定义。
- 重定位:确定目标文件中的符号地址,并更新相关指令或数据。
- 合并代码和数据段:将不同目标文件的代码和数据合并,形成最终的可执行文件。
链接分为静态链接和动态链接两种方式。
- 静态链接:静态链接是在编译时将所有依赖的库代码拷贝到最终的可执行文件中,生成一个完全独立的二进制文件。
- 动态链接:动态链接不会在编译时将库代码合并,而是在运行时加载外部共享库(.so / .dll)。可执行文件只包含对库的引用,而不包含库的代码。
汇编代码
高级语言程序对应的汇编代码 尝尝与存储系统 在大题中进行综合考察,这里需要重点掌握选择、循环、函数调用语句对应的汇编代码。
选择结构语句
选择语句的基本执行思路:选择语句中的变量被保存在寄存器中,通过条件比较指令对寄存器进行比较,然后跳转到不同的分支执行不同的代码。
if (a > b) {
max = a;
} else {
max = b;
}
; 假设a, b的值分别存放在寄存器eax和ebx中
; 比较 a 和 b
cmp eax, ebx
; 如果 a <= b, 跳转到 else_label
jle else_label
; a > b 的分支,无需跳转
mov max, eax ; max = a
jmp endif_label ; 跳转到 endif_label
; a <= b 的分支
else_label:
mov max, ebx ; max = b
; 执行结束
endif_label:
循环结构语句
while (count < 10) {
count++;
}
; 假设count的值存放在寄存器ecx中
start:
cmp ecx, 10 ; 比较count和10
jge end ; 如果count >= 10, 跳出循环
inc ecx ; count增加
jmp start ; 无条件跳回循环开始
end:
函数定义和调用
C 语言中函数对应的汇编代码从逻辑上可以分为三个部分:
- 函数入口
- 保存寄存器:保存调用者(caller)的寄存器,以确保在函数执行完后,寄存器的值不被改变。
- 设置栈帧:保存 caller 的栈帧,设置被调用者(callee)的栈帧。
- 函数体
- 这部分是函数的执行逻辑,会包含各种操作指令,此时局部变量会被保存到栈上。
- 函数返回
- 如果函数有返回值,通常会将结果保存在 eax 寄存器中。
- 恢复栈帧:恢复栈指针,确保栈帧被正确销毁。
- 恢复寄存器:如果函数入口时保存了寄存器,那么在返回之前,需要将它们恢复。
这一节可以结合 函数调用时内存结构 共同理解,下面通过几个简单的例子说明以下函数定义和调用。
int add(int x, int y) {
return x + y;
}
; 假定 'a' 和 'b' 作为参数通过堆栈传递
.globl _add
_add:
; 保存 caller 的 ebp
push ebp
; 设置 callee 的 ebp
mov ebp, esp
; x + y 的汇编表示
mov eax, [ebp+8]
add eax, [ebp+12]
; 恢复 caller 的 ebp
pop ebp
; 函数返回
ret
在 add 函数对应的汇编代码中,首先需要保存 caller(下文中的 func 函数) 的栈帧并设置 callee(即 add 函数自己)的栈帧,这个步骤通过 push ebp
以及 mov ebp, esp
完成。这个例子比较简单,所以寄存器够用,caller 和 callee 的寄存器不会出现竞争的情况,所以无需在 callee 中保存 caller 的寄存器。
add 函数体的工作就是计算 x + y
的结果。
void func(int a, int b) {
int sum = add(a, b);
int var = sum * 2;
// ... 一些使用var的代码 ...
}
.globl _func
_func:
push ebp
mov ebp, esp
; 调用函数 add
sub esp, 8
push dword [ebp+12]
push dword [ebp+8]
call _add
add esp, 8
; 保存返回值
mov [ebp-4], eax
mov eax, [ebp-4]
; 将eax左移1位,相当于乘以2
shl eax, 1
; 将结果存储到 'var'
mov [ebp-8], eax
; ... 更多使用 'var' 的代码 ...
; 函数完成,清理堆栈,并恢复ebp
mov esp, ebp
pop ebp
ret