指令集¶
约 3824 个字 70 行代码 1 张图片 预计阅读时间 14 分钟
指令集简介¶
CPU 的运行实际上是在不断的重复“取指-执行周期”(Fetch-Execute Cycle),实现对程序指令的逐条处理。CPU 需要确定输入的指令的内容与执行的操作的对应关系,例如,在 x86-64 平台上指令内容 00000001 11010000
表示将 eax
寄存器中的数据与 edx
寄存器中的数据相加并存储到 eax
寄存器中。由于计算机需要用到的指令非常多,我们需要定义指令集来规范指令的格式、功能和操作方式,增强程序的移植性和通用性。
常见的指令集可分为复杂指令集(CISC)和精简指令集(RISC)两大类。
- CISC 以 x86 架构为代表,广泛应用于个人电脑和服务器,其特点是指令丰富、功能复杂,一条指令可完成多个操作,能有效减少程序的指令条数,但处理器设计相对复杂。
- RISC 则以 ARM、MIPS 为典型,它的指令简洁、功能单一,每条指令仅完成一个基本操作,处理器硬件结构更简单,执行效率更高,因此在移动设备(如手机、平板)、嵌入式系统中占据主导地位。
向量化指令集
随着数据处理需求的增长,向量化指令集逐渐成为提升处理器性能的关键。传统指令集一次只能处理单个数据,而向量化指令集支持一次对多个数据(如 128 位、256 位甚至 512 位的向量数据)进行并行操作,特别适合图像处理、视频编解码、科学计算等大规模数据运算场景。
充分利用向量化指令集将会极大的提升你的程序的性能,详细介绍可参考向量化章节
汇编语言简介¶
机器语言是一系列的二进制数,其可以容易的被 CPU 执行,但其不具有可读性。为了方便人类阅读,在进行指令级别的研究时,我们通常将机器语言翻译为人类可读性更高的汇编语言进行阅读。
汇编语言是一种低级编程语言,它使用助记符(如 MOV、ADD 等)来代替机器语言中的二进制指令,同时保留了与机器语言的直接对应关系。这意味着汇编语言的一条指令通常对应机器语言的一条指令,开发者可以通过汇编语言更直观地控制 CPU 的操作,如寄存器使用、内存访问、运算逻辑等。
下面是一个简单的汇编语言的示例,使用 AT&T 语法1,用于计算 1 到 100 的和并输出二进制结果。
# sum_binary.s
.section .data
result:
.quad 0 # 8字节空间存储结果
.section .text
.globl _start
_start:
# 计算1到100的和
xorq %rax, %rax # 累加器清0
movq $1, %rcx # 计数器初始化为1
loop:
addq %rcx, %rax # sum += i
incq %rcx # i++
cmpq $100, %rcx # 比较i和100
jle loop # 如果i <= 100,继续循环
# 保存结果到内存
movq %rax, result # 将结果存入result变量
# 输出二进制内容
movq $1, %rax # 系统调用号1 (write)
movq $1, %rdi # 文件描述符1 (stdout)
leaq result(%rip), %rsi # 获取result的地址
movq $8, %rdx # 输出8字节
syscall
# 退出程序
movq $60, %rax # 系统调用号60 (exit)
xorq %rdi, %rdi # 退出状态码0
syscall
使用下述方式运行这段汇编:
gcc -static -no-pie -nostartfiles sum_binary.s -o sum_binary
./sum_binary | od -tx1 # 由于输出内容是二进制终端不可见,使用od捕获并转为字符串
预期结果如下:
0000000 ba 13 00 00 00 00 00 00
0000010
内存的小端存储
5050 的十六进制表示为 0x13ba
,程序输出的是使用 8 字节表示的整数,容易计算应该为 00 00 00 00 00 00 13 ba
,但是为什么实际的输出是 ba 13 00 00 00 00 00 00
?请尝试查阅资料或询问 AI 解决这个问题。
可以看出,虽然汇编语言能实现类似 C 语言的功能,但由于其与机器指令一一对应,需要手动的进行寄存器的管理,编写难度远大于 C 语言。因此我们通常不使用汇编语言程序编写,我们只需了解常见的汇编语言的含义即可。
查看你的 C 代码的汇编
以 C 语言为代表的高级语言需要进行编译为机器语言后才能执行。编译器(如 gcc
)负责完成这个工作。在编译器进行编译时会出于性能或安全的原因考虑在保证程序运行结果不变的情况下修改实际的运行逻辑。你可以写一个简单的 C 语言程序并运行
gcc -S <your file>.c exmaple.s
生成你的代码编译后的汇编代码。
(选学)函数和 cdecl
函数式编程是现代编程的通行做法。只要你接触过任何编程语言,你对“函数”这个概念就不会陌生。
cdecl (C declaration) 函数式编程中,要求至少有一个“调用栈”,有一个寄存器作为“栈指针寄存器” (stack pointer, sp)。栈指针初始指向栈的最高地址/栈底;每调用一个函数栈指针就下移,这段空间就成为了函数的栈帧,可以在函数内部自由使用。
下图是一个典型的 x86 栈结构图。一个栈帧里从高向低存了参数、返回地址指针和局部变量。在 arm 和 riscv 中不用栈,而是用“返回地址寄存器” (link register, lr) 储存返回地址。
函数被调用时,首先会设置参数,根据调用约定通常前几个参数放到寄存器里,其他参数放到栈上;接着会执行一个调用指令(call
),它把下一条指令的地址压入栈中(作为返回地址),并把指令指针寄存器(instruction pointer, rip
)设为被调用函数的地址,从而"跳转"到调用函数;然后在函数内部开头(prologue)会修改栈指针(stack pointer, rsp
)为局部变量分配空间。
在函数结尾(epilogue),通常先修改 rsp
归还栈空间,然后通过返回指令(ret
)从栈中弹出返回地址到 rip
,跳转回上层函数。
这过程主要讲究的就是个"栈平衡",即在函数内部用多少申请多少;函数调用前后 rsp
不能改变,否则函数的栈帧就被破坏了!除此之外,根据不同架构和操作系统的调用约定(calling convention),有些寄存器(callee-saved registers)也需要被保护起来(如 x86_64 中的 rbx
, rbp
, r12-r15
);通常一个函数若要使用 callee-saved register 就会把他 push 到栈里,用完后 pop 回来(即先降 rsp
、save,再 load、升 rsp
)(看似多此一举,但是在使用寄存器很多时还是很有用的)。
x86_64 的 System V ABI 调用约定
参数 1~ 参数 6 分别保存到 rdi
, rsi
, rdx
, rcx
, r8
, r9
寄存器中,剩下的参数从右往左依次入栈,调用者实现栈平衡,返回值如果 64 位放得下就存放在 rax
中,更大的值则通过内存返回。rbp
通常用作栈帧指针(可选),rsp
作为栈指针。rip
是指令指针。
例如一个函数:
int square(int x) {
blackbox();
return x*x;
}
可以编译成汇编
square:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl %edi, -4(%rbp)
call blackbox
movl -4(%rbp), %eax
imull %eax, %eax
leave
retq
这段代码首先保存旧的 rbp
(callee-saved),然后把 rbp
设为当前 rsp
,接着把 rsp
减去 16,为函数的局部变量分配了 16 字节栈空间(System V ABI 要求栈空间 16 字节对齐);然后把第一个参数 edi
存到栈中([rbp-4]
),因为它可能会被 blackbox
调用修改;然后 call
指令跳转到 blackbox
函数并将返回地址压入栈中;调用完成后从栈中加载回参数值到 eax
;然后计算 eax = eax * eax
,作为返回值;最后使用 leave
指令(相当于 mov rsp, rbp
然后 pop rbp
)恢复栈指针和基指针,再通过 ret
从栈中弹出返回地址到 rip
实现返回。
栈是怎么初始化的
栈式函数编程强依赖于“栈”。但栈是怎么分配、初始化、赋值给 stack pointer 的呢?
在 Linux 里,通过 fork
创建的子进程会 COW 地复用父进程栈,而 execve
调用会为进程重新分配栈并设置 stack pointer。新线程默认会复用父线程的栈,pthread 库会在新线程创建前通过 mmap
调用分配栈内存,在 clone
调用中传给内核让内核设置 sp。
在一些有栈异步运行时中,会通过 mmap
调用为协程分配栈,并通过上下文切换等用户态代码为 sp 指针赋值。
需要强调,由于 Linux 的内存有懒分配机制,所谓“分配内存”并不意味着这一整块内存都已经加载到了页表中(见计算机组成和操作系统),而可能在访问发生 Page Fault 后动态分配;这样能确保用多少分配多少。因此栈内存的分配是贯穿程序运行的。
向量化指令集的汇编语言¶
常见的向量化指令集有:
- x86 架构:SSE(Streaming SIMD Extensions)、AVX(Advanced Vector Extensions)、AVX2、AVX-512 等。
- ARM 架构:NEON、SVE(Scalable Vector Extensions)。
- RISC-V 架构:RVV(RISC-V Vector Extension)。
考虑到读者的个人电脑 CPU 大概率均支持 AVX2
指令集,本部分我们将以 AVX2
指令集为实例介绍向量化指令集。
如何查看自己的 CPU 支持的指令集
在 Linux 系统下运行
lscpu
即可打印 CPU 的详细信息,在 Flags
行中会列出当前 CPU 支持的所有指令集。
例如可以使用
lscpu | grep avx2
来确认你的 CPU 是否支持 AVX2
指令集
AVX2
指令集常见的指令有:
- vmov*: 用于载入和存储数据,根据数据类型不同有
vmovaps
,vmovdqa
等 - vaddp*/vpadd*: 前者用于浮点加法,后者用于整数运算,如
vaddps
,vaddpd
,vpaddd
,vpaddq
等 - vsubp*/vpsub*:用于减法运算。
- vmulp*/vpmul*:用于乘法运算。
AVX2
指令集在汇编中通常将其寄存器表示为 %ymm<id>
,也可以使用这个特征来区分不同的指令集。
我们不需要记忆具体的指令集语法,在需要写汇编时有很多途径可以查询。但是我们需要区分出不同指令集的语法,已方便我们分析当前编译器对代码的优化。 例如,下面有一个简单的 C 代码,实现了两个浮点数组求和的功能。
// vector_add.c
#include <stddef.h>
void vector_add(const float *a, const float *b, float *result, size_t n) {
for (size_t i = 0; i < n; ++i) {
result[i] = a[i] + b[i];
}
}
在编译时编译器可能会尝试向量化上述函数以提升其性能。但是我们并不知道编译器是否自动向量化了上述代码,编译器是否向量化了上述代码到尽可能高的位宽以获得最佳性能。 例如你可以使用下述代码编译上述函数:
gcc -S vector_add.c -o vector_add.s
然后打开 vector_add.s
查看,在笔者的机器上此时能发现
addss %xmm1, %xmm0
movss %xmm0, (%rax)
这意味着编译器确实尝试将上述代码向量化。但是其向量化到了 SSE
指令集,该指令集位宽仅为 128 位。但是笔者的电脑有支持 256 位位宽的 AVX2
指令集,这表明目前编译器的优化并不理想。
我们可以尝试添加 -O3
或 -Ofast
选项来让编译器进行更多的优化:
gcc -O3 -S vector_add.c -o vector_add.s
gcc -Ofast -S vector_add.c -o vector_add.s
在笔者的机器上上述方法得到的结果均未使用 AVX2
指令集。
我们可以显式的指定希望采用的向量化指令集,将其作为编译选项传递给编译器:
gcc -O3 -mavx2 -S vector_add.c -o vector_add.s
在笔者的机器上的可以在 vector_add.s
上发现:
vmovups (%rdi,%rax), %ymm1
vaddps (%rsi,%rax), %ymm1, %ymm0
vmovups %ymm0, (%rdx,%rax)
上述编译方法成功的启用了 AVX2
指令集。
内存对齐
请搜索 vmovaps
和 vmovups
之间的区别,并思考两者的性能是否存在差异。解释为什么编译器选择了 vmovups
。
(选做)修改源代码使得编译器编译出的结果使用 vmovaps
。
编译器自动向量化的复杂性
现代的编译器在我们设置了合适的编译选项时大部分时间都可以帮助我们自动向量化我们的代码提升性能。但是在大型项目中,我们并不能精确的知道此时我们希望向量化的代码是否被充分的向量化了。此时查看汇编代码给我们提供了一种解决方法。
CPU 的工作流程¶
CPU 的核心工作机制可概括为 “取指 - 执行” 循环,其详细过程如下:
-
取指阶段(Fetch)
取指阶段的核心任务是从内存中读取下一条要执行的指令,具体步骤如下:
- CPU 通过程序计数器(PC,Program Counter) 记录当前要执行指令在内存中的地址。PC 中存储的是下一条指令的内存地址(初始值通常指向程序的第一条指令)。
- CPU 将 PC 中的地址通过地址总线发送给内存,内存根据地址查找对应的指令,并将指令数据通过数据总线传输到 CPU 的指令寄存器(IR,Instruction Register) 中暂存。
- 读取指令后,PC 自动递增(增加的值取决于指令的长度,如 32 位指令通常 + 4),指向内存中的下一条指令地址,为下一次取指做准备。若遇到跳转指令,PC 的修改会在后续阶段进行,而非在此阶段。
-
译码阶段(Decode)
指令被送入 IR 后,CPU 进入译码阶段,解析指令的含义:
指令译码器(ID,Instruction Decoder) 对 IR 中的指令进行解码,确定该指令要执行的操作(如加法、数据传输、逻辑运算等),以及操作所需的数据源(如寄存器地址、内存地址等)。例如,若指令为 “ADD R1, R2, R3”,译码后会明确:操作是 “加法”,操作数来自寄存器 R2 和 R3,结果需存回寄存器 R1。
此阶段还可能涉及到寄存器堆的访问,提前获取操作数到临时寄存器,为执行阶段做好准备。
-
执行阶段(Execute)
根据译码结果,CPU 通过算术逻辑单元(ALU) 或其他功能部件执行具体操作:
- 若为算术运算(如加法、减法)或逻辑运算(如与、或、非),由 ALU 完成计算。
- 若为数据传输指令(如从内存读取数据到寄存器),则通过数据通路完成数据的移动。
- 若为控制类指令(如跳转、中断),则在此阶段确定是否修改 PC 的值。
-
写回阶段(Write Back)
执行完成后,需将结果保存到指定位置(寄存器或内存):
- 若操作结果是数据(如算术运算结果),通常先写入通用寄存器,再根据需求决定是否写入内存。
- 若操作是控制转移(如跳转),则结果体现为 PC 值的修改(指向新的指令地址),该修改会影响下一次取指的地址。
流水线技术
现代 CPU 为提高效率,采用 “流水线” 设计 —— 在一条指令执行 “执行阶段” 时,下一条指令可同时进入 “译码阶段”,再下一条进入 “取指阶段”,使多个指令的不同阶段并行处理,大幅提升吞吐量。不过,流水线可能因数据相关、控制相关等问题产生阻塞,需要通过定向技术、分支预测等机制缓解。
对于不同的指令来说,上述每个阶段所花费的时钟周期数是不一样的。在进行计算性能优化时,我们更关心 CPU 的吞吐,即在单个时钟周期内,CPU 能处理多少个数据。