跳转至

指令集

约 2739 个字 56 行代码 预计阅读时间 10 分钟

指令集简介

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到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

生成你的代码编译后的汇编代码。

向量化指令集的汇编语言

常见的向量化指令集有:

  • 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*: 用于载入和存储数据,根据数据类型不同有vmovapsvmovdqa
  • 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指令集。

内存对齐

请搜索vmovapsvmovups之间的区别,并思考两者的性能是否存在差异。解释为什么编译器选择了vmovups

(选做)修改源代码使得编译器编译出的结果使用vmovaps

编译器自动向量化的复杂性

现代的编译器在我们设置了合适的编译选项时大部分时间都可以帮助我们自动向量化我们的代码提升性能。但是在大型项目中,我们并不能精确的知道此时我们希望向量化的代码是否被充分的向量化了。此时查看汇编代码给我们提供了一种解决方法。

CPU的工作流程

CPU 的核心工作机制可概括为 “取指 - 执行” 循环,其详细过程如下:​

  1. 取指阶段(Fetch)​

    取指阶段的核心任务是从内存中读取下一条要执行的指令,具体步骤如下:​

    • CPU 通过程序计数器(PC,Program Counter) 记录当前要执行指令在内存中的地址。PC 中存储的是下一条指令的内存地址(初始值通常指向程序的第一条指令)。​
    • CPU 将 PC 中的地址通过地址总线发送给内存,内存根据地址查找对应的指令,并将指令数据通过数据总线传输到 CPU 的指令寄存器(IR,Instruction Register) 中暂存。​
    • 读取指令后,PC 自动递增(增加的值取决于指令的长度,如 32 位指令通常 + 4),指向内存中的下一条指令地址,为下一次取指做准备。若遇到跳转指令,PC 的修改会在后续阶段进行,而非在此阶段。​
  2. 译码阶段(Decode)​

    指令被送入 IR 后,CPU 进入译码阶段,解析指令的含义:​

    指令译码器(ID,Instruction Decoder) 对 IR 中的指令进行解码,确定该指令要执行的操作(如加法、数据传输、逻辑运算等),以及操作所需的数据源(如寄存器地址、内存地址等)。例如,若指令为 “ADD R1, R2, R3”,译码后会明确:操作是 “加法”,操作数来自寄存器 R2 和 R3,结果需存回寄存器 R1。​

    此阶段还可能涉及到寄存器堆的访问,提前获取操作数到临时寄存器,为执行阶段做好准备。​

  3. 执行阶段(Execute)​

    根据译码结果,CPU 通过算术逻辑单元(ALU) 或其他功能部件执行具体操作:​

    • 若为算术运算(如加法、减法)或逻辑运算(如与、或、非),由 ALU 完成计算。​
    • 若为数据传输指令(如从内存读取数据到寄存器),则通过数据通路完成数据的移动。​
    • 若为控制类指令(如跳转、中断),则在此阶段确定是否修改 PC 的值。​
  4. 写回阶段(Write Back)

    执行完成后,需将结果保存到指定位置(寄存器或内存):​

    • 若操作结果是数据(如算术运算结果),通常先写入通用寄存器,再根据需求决定是否写入内存。​
    • 若操作是控制转移(如跳转),则结果体现为 PC 值的修改(指向新的指令地址),该修改会影响下一次取指的地址。​

流水线技术

现代 CPU 为提高效率,采用 “流水线” 设计 —— 在一条指令执行 “执行阶段” 时,下一条指令可同时进入 “译码阶段”,再下一条进入 “取指阶段”,使多个指令的不同阶段并行处理,大幅提升吞吐量。不过,流水线可能因数据相关、控制相关等问题产生阻塞,需要通过定向技术、分支预测等机制缓解。​

对于不同的指令来说,上述每个阶段所花费的时钟周期数是不一样的。在进行计算性能优化时,我们更关心 CPU 的吞吐,即在单个时钟周期内,CPU 能处理多少个数据。