# 浅析 CPU 是如何执行二进制机器码的

> 《图解 Google V8》里面有门课来讲 CPU 是如何执行二进制机器码的, 遥想好几年前大学应该也学过这玩意儿, 不过早就忘没了. 这篇文章权当补补课, 也是纪念逝去的青春 💔.

## 从源码, 汇编码到机器码

```cpp
int main()
{
    int x = 1;
    int y = 2;
    int z = x + y;
    return z;
}
```

![机器码, 汇编码对照](https://static.yancey.app/lodreupk-1652967183858.webp)

以上面一段 C 代码为例, 通过 GCC 编译器将这段 C 代码编译成二进制文件, 在通过 objdump 将二进制文件反编译成汇编代码, 得到上图. 通常我们将汇编语言编写的程序转换为机器语言的过程称为“汇编”; 反之，机器语言转化为汇编语言的过程称为“反汇编”.

```shell
gcc -O0 -o code_prog code.c # 编译成二进制机器码
objdump -d code_prog # 二进制机器码反汇编成汇编码
```

图片中最左边是编译生成的**机器码**, 使用十六进制展示, 机器码每一行都是一个指令，这一大堆指令按照顺序集合在一起就组成了程序，所以程序的执行，本质上就是 CPU 按照顺序执行这一大堆指令的过程.

中间的部分是汇编代码，汇编代码采用**助记符(memonic)**来编写程序，例如原本是二进制表示的指令，在汇编代码中可以使用单词来表示，比如 mov、add 就分别表示数据的存储和相加。汇编语言和机器语言是一一对应的.

## CPU 是如何执行程序的

在了解 CPU 是如何执行程序之前, 我们看一下典型的计算机系统的硬件组织结构有哪些, 从下图可以看出, 它主要是由 CPU、主存储器、各种 IO 总线，还有一些外部设备，诸如硬盘、显示器、USB 等设备组成的。

![计算机组成](https://static.yancey.app/z30ock3b-1652967910341.webp)

### 内存

首先说一下内存, 在程序执行之前，我们的程序需要被装进内存. 比如在 Windows 系统, 当双击一个可执行文件时，系统中的程序加载器会将该文件加载到内存中。

你可以把内存看成是一个快递柜，比如当你需要寄件的时候，你可以打开快递柜中的第 100 号单元格，并存放你的物品，有时候你会收到快递，提示你在快递柜的 105 号单元格中，你就可以打开 105 号单元格取出的你的快递。这里的快递柜、快递柜中的每个单元格的编号、操作快递柜的人，你可以把它们对比成计算机中的内存、内存地址和 CPU.

也就是说，CPU 可以通过指定内存地址，从内存中读取数据，或者往内存中写入数据，有了内存地址，CPU 和内存就可以有序地交互.

此外, 内存还是一个临时存储数据的设备，之所以是临时的存储器，是因为断电之后，内存中的数据都会消失。

在内存中，每个存放字节的空间都有其唯一的地址，而且地址是按照顺序排放的. 开头的那段 C 代码，这段代码会被编译成可执行文件，可执行文件中包含了二进制的机器码，当二进制代码被加载进了内存后，那么内存中的每条二进制代码便都有了自己对应的地址. 有时候一条指令只需要一个字节就可以了，但是有时候一条指令却需要多个字节。在下图中，对于同一条指令，我们使用了相同的颜色来标记.

![加载到内存中的程序](https://static.yancey.app/i73qyljl-1652968275222.webp)

### CPU 寄存器

一旦二进制代码被装载进内存，CPU 便可以从内存中取出一条指令，然后分析该指令，最后执行该指令。我们把取出指令、分析指令、执行指令这三个过程称为一个 CPU **时钟周期**。CPU 是永不停歇的，当它执行完成一条指令之后，会立即从内存中取出下一条指令，接着分析该指令，执行该指令，CPU 一直重复执行该过程，直至所有的指令执行完成。

CPU 之所以可以从上到下依次取出每条指令来分析执行, 得利于 CPU 中的 **PC 寄存器**. 它保存了将要执行的指令地址，当二进制代码被装载进了内存之后，系统会将二进制代码中的第一条指令的地址写入到 PC 寄存器中，到了下一个时钟周期时，CPU 便会根据 PC 寄存器中的地址，从内存中取出指令。

因此, PC 寄存器中的指令取出来之后，系统要做两件事:

1. 将下一条指令的地址更新到 PC 寄存器中
2. 分析该指令，并识别出不同的类型的指令，以及各种获取操作数的方法. 在指令分析完成之后，就要执行指令了.

在了解 CPU 是如何执行指令之前，我们还需要了解 CPU 中的一个重要部件: **通用寄存器**. 通用寄存器是 CPU 中用来存放数据的设备，不同处理器中寄存器的个数也是不一样的，之所以要通用寄存器，是因为 CPU 访问内存的速度很慢，所以 CPU 就在内部添加了一些存储设备，这些设备就是通用寄存器。

你可以把通用寄存器比喻成是你身上的口袋，内存就是你的背包，而硬盘则是你的行李箱，要从背包里面拿物品会比较不方便，所以你会将常用的物品放进口袋。你身上口袋的个数通常不会太多，容量也不会太大，而背包就不同了，它的容量会非常大。

通用寄存器通常用来存放数据或者内存中某块数据的地址，我们把这个地址又称为**指针**，通常情况下寄存器对存放的数据是没有特别的限制的，比如某个通用寄存器既可以存储数据，也可以存储指针。不过一般来讲, 每种数据都有自己独特的寄存器, 比如 rbp 寄存器通常是用来存放栈帧指针的，rsp 寄存器用来存放栈顶指针的，PC 寄存器用来存放下一条要执行的指令等。

### CPU 执行指令

我们先来了解下几种常用的指令类型:

第一种是**加载的指令**，其作用是从内存中复制指定长度的内容到通用寄存器中，并覆盖寄存器中原来的内容。比如下图使用了 movl 指令，指令后面跟着的第一个参数是要拷贝数据的内存的位置，第二个参数是要拷贝到 ecx 这个寄存器。

![加载的指令](https://static.yancey.app/fxeeyo1n-1652969056899.webp)

第二种**存储的指令**，和加载类型的指令相反，其作用是将寄存器中的内容复制内存某个位置，并覆盖掉内存中的这个位置上原来的内容. 比如下图使用 movl 指令，movl 指令后面的 %ecx 就是寄存器地址，-8(%rbp) 是内存中的地址，这条指令的作用是将寄存器中的值拷贝到内存中。

![存储的指令](https://static.yancey.app/tgpahgxl-1652969159311.webp)

第三种是**更新指令**，其作用是复制两个寄存器中的内容到 ALU 中，也可以是一块寄存器和一块内存中的内容到 ALU 中，ALU 将两个字相加，并将结果存放在其中的一个寄存器中，并覆盖该寄存器中的内容。下图 addl 指令，将寄存器 eax 和 ecx 中的值传给 ALU，ALU 对它们进行相加操纵，并将计算的结果写回 ecx。

![更新指令](https://static.yancey.app/j8zel254-1652969241891.webp)

第四种是**跳转指令**，从指令本身抽取出一个字，这个字是下一条要执行的指令的地址，并将该字复制到 PC 寄存器中，并覆盖掉 PC 寄存器中原来的值。那么当执行下一条指令时，便会跳转到对应的指令了。下图是通过 jmp 来实现的，jmp 后面跟着要跳转的内存中的指令地址。

![跳转指令](https://static.yancey.app/6a6qikyh-1652969688014.webp)

除了以上指令之外，还有 IO 读 / 写指令，这些指令可以从一个 IO 设备中复制指定长度的数据到寄存器中，也可以将一个寄存器中的数据复制到指定的 IO 设备。

## 看一个实例

以最上面的 C 代码为例, CPU 会首先执行调用 main 函数，在调用 main 函数时，CPU 会保存上个栈帧上下文信息和创建当前栈帧的上下文信息，主要是通过下面这两条指令实现的. 第一条指令 pushq %rbp，是将 rbp 寄存器中的值写到内存中的栈区域。第二条指令是将 rsp 寄存器中的值写到 rbp 寄存器中。

```ts
pushq  %rbp
movq   %rsp, %rbp
```

然后将 0 写到栈帧的第一个位置，对应的汇编代码如下：

```ts
movl  $0, -4(%rbp)
```

接下来给 x 和 y 赋值，对应的代码是下面两行, 第一行指令是将常数值 1 压入到栈中，然后再将常数值 2 压入到栈中，这两个值分别对应着 x 和 y。

```ts
movl  $1, -8(%rbp)
movl  $2, -12(%rbp)
```

接下来，x 的值从栈中复制到 eax 寄存器中，对应的指令如下所示：

```ts
movl -8(%rbp), %eax
```

现在 eax 寄存器中保存了 x 的值，那么接下来，再将内存中的 y 和 eax 中的 x 相加，相加的结果再保存在 eax 中，对应的指令如下所示：

```ts
addl  -12(%rbp), %eax
```

现在 x+y 的结果保存在了 eax 中了，接下来 CPU 会将结果保存中内存中，执行如下指令：

```ts
 movl  %eax, -16(%rbp)
```

最后又将结果 z 加载到 eax 寄存器中，代码如下所示：

```ts
movl  -16(%rbp), %eax
```

注意这里的 eax 寄存器中的内容就被默认作为返回值了，执行到这里函数基本就执行结束了，然后需要继续执行一些恢复现场的操作，代码如下所示：

```ts
popq % rbp;
retq;
```


---

Source: https://yanceyleo.com/post/950d248c-3127-4fc9-8dfc-f460c439a38d
Published: 2022-05-19
Tags: 技术杂谈, 计算机组成原理