浅析 CPU 是如何执行二进制机器码的
《图解 Google V8》里面有门课来讲 CPU 是如何执行二进制机器码的, 遥想好几年前大学应该也学过这玩意儿, 不过早就忘没了. 这篇文章权当补补课, 也是纪念逝去的青春 💔.
从源码, 汇编码到机器码
int main()
{
int x = 1;
int y = 2;
int z = x + y;
return z;
}
以上面一段 C 代码为例, 通过 GCC 编译器将这段 C 代码编译成二进制文件, 在通过 objdump 将二进制文件反编译成汇编代码, 得到上图. 通常我们将汇编语言编写的程序转换为机器语言的过程称为“汇编”; 反之,机器语言转化为汇编语言的过程称为“反汇编”.
gcc -O0 -o code_prog code.c # 编译成二进制机器码 objdump -d code_prog # 二进制机器码反汇编成汇编码
图片中最左边是编译生成的机器码, 使用十六进制展示, 机器码每一行都是一个指令,这一大堆指令按照顺序集合在一起就组成了程序,所以程序的执行,本质上就是 CPU 按照顺序执行这一大堆指令的过程.
中间的部分是汇编代码,汇编代码采用**助记符(memonic)**来编写程序,例如原本是二进制表示的指令,在汇编代码中可以使用单词来表示,比如 mov、add 就分别表示数据的存储和相加。汇编语言和机器语言是一一对应的.
CPU 是如何执行程序的
在了解 CPU 是如何执行程序之前, 我们看一下典型的计算机系统的硬件组织结构有哪些, 从下图可以看出, 它主要是由 CPU、主存储器、各种 IO 总线,还有一些外部设备,诸如硬盘、显示器、USB 等设备组成的。
内存
首先说一下内存, 在程序执行之前,我们的程序需要被装进内存. 比如在 Windows 系统, 当双击一个可执行文件时,系统中的程序加载器会将该文件加载到内存中。
你可以把内存看成是一个快递柜,比如当你需要寄件的时候,你可以打开快递柜中的第 100 号单元格,并存放你的物品,有时候你会收到快递,提示你在快递柜的 105 号单元格中,你就可以打开 105 号单元格取出的你的快递。这里的快递柜、快递柜中的每个单元格的编号、操作快递柜的人,你可以把它们对比成计算机中的内存、内存地址和 CPU.
也就是说,CPU 可以通过指定内存地址,从内存中读取数据,或者往内存中写入数据,有了内存地址,CPU 和内存就可以有序地交互.
此外, 内存还是一个临时存储数据的设备,之所以是临时的存储器,是因为断电之后,内存中的数据都会消失。
在内存中,每个存放字节的空间都有其唯一的地址,而且地址是按照顺序排放的. 开头的那段 C 代码,这段代码会被编译成可执行文件,可执行文件中包含了二进制的机器码,当二进制代码被加载进了内存后,那么内存中的每条二进制代码便都有了自己对应的地址. 有时候一条指令只需要一个字节就可以了,但是有时候一条指令却需要多个字节。在下图中,对于同一条指令,我们使用了相同的颜色来标记.
CPU 寄存器
一旦二进制代码被装载进内存,CPU 便可以从内存中取出一条指令,然后分析该指令,最后执行该指令。我们把取出指令、分析指令、执行指令这三个过程称为一个 CPU 时钟周期。CPU 是永不停歇的,当它执行完成一条指令之后,会立即从内存中取出下一条指令,接着分析该指令,执行该指令,CPU 一直重复执行该过程,直至所有的指令执行完成。
CPU 之所以可以从上到下依次取出每条指令来分析执行, 得利于 CPU 中的 PC 寄存器. 它保存了将要执行的指令地址,当二进制代码被装载进了内存之后,系统会将二进制代码中的第一条指令的地址写入到 PC 寄存器中,到了下一个时钟周期时,CPU 便会根据 PC 寄存器中的地址,从内存中取出指令。
因此, PC 寄存器中的指令取出来之后,系统要做两件事:
- 将下一条指令的地址更新到 PC 寄存器中
- 分析该指令,并识别出不同的类型的指令,以及各种获取操作数的方法. 在指令分析完成之后,就要执行指令了.
在了解 CPU 是如何执行指令之前,我们还需要了解 CPU 中的一个重要部件: 通用寄存器. 通用寄存器是 CPU 中用来存放数据的设备,不同处理器中寄存器的个数也是不一样的,之所以要通用寄存器,是因为 CPU 访问内存的速度很慢,所以 CPU 就在内部添加了一些存储设备,这些设备就是通用寄存器。
你可以把通用寄存器比喻成是你身上的口袋,内存就是你的背包,而硬盘则是你的行李箱,要从背包里面拿物品会比较不方便,所以你会将常用的物品放进口袋。你身上口袋的个数通常不会太多,容量也不会太大,而背包就不同了,它的容量会非常大。
通用寄存器通常用来存放数据或者内存中某块数据的地址,我们把这个地址又称为指针,通常情况下寄存器对存放的数据是没有特别的限制的,比如某个通用寄存器既可以存储数据,也可以存储指针。不过一般来讲, 每种数据都有自己独特的寄存器, 比如 rbp 寄存器通常是用来存放栈帧指针的,rsp 寄存器用来存放栈顶指针的,PC 寄存器用来存放下一条要执行的指令等。
CPU 执行指令
我们先来了解下几种常用的指令类型:
第一种是加载的指令,其作用是从内存中复制指定长度的内容到通用寄存器中,并覆盖寄存器中原来的内容。比如下图使用了 movl 指令,指令后面跟着的第一个参数是要拷贝数据的内存的位置,第二个参数是要拷贝到 ecx 这个寄存器。
第二种存储的指令,和加载类型的指令相反,其作用是将寄存器中的内容复制内存某个位置,并覆盖掉内存中的这个位置上原来的内容. 比如下图使用 movl 指令,movl 指令后面的 %ecx 就是寄存器地址,-8(%rbp) 是内存中的地址,这条指令的作用是将寄存器中的值拷贝到内存中。
第三种是更新指令,其作用是复制两个寄存器中的内容到 ALU 中,也可以是一块寄存器和一块内存中的内容到 ALU 中,ALU 将两个字相加,并将结果存放在其中的一个寄存器中,并覆盖该寄存器中的内容。下图 addl 指令,将寄存器 eax 和 ecx 中的值传给 ALU,ALU 对它们进行相加操纵,并将计算的结果写回 ecx。
第四种是跳转指令,从指令本身抽取出一个字,这个字是下一条要执行的指令的地址,并将该字复制到 PC 寄存器中,并覆盖掉 PC 寄存器中原来的值。那么当执行下一条指令时,便会跳转到对应的指令了。下图是通过 jmp 来实现的,jmp 后面跟着要跳转的内存中的指令地址。
除了以上指令之外,还有 IO 读 / 写指令,这些指令可以从一个 IO 设备中复制指定长度的数据到寄存器中,也可以将一个寄存器中的数据复制到指定的 IO 设备。
看一个实例
以最上面的 C 代码为例, CPU 会首先执行调用 main 函数,在调用 main 函数时,CPU 会保存上个栈帧上下文信息和创建当前栈帧的上下文信息,主要是通过下面这两条指令实现的. 第一条指令 pushq %rbp,是将 rbp 寄存器中的值写到内存中的栈区域。第二条指令是将 rsp 寄存器中的值写到 rbp 寄存器中。
pushq %rbp movq %rsp, %rbp
然后将 0 写到栈帧的第一个位置,对应的汇编代码如下:
movl $0, -4(%rbp)
接下来给 x 和 y 赋值,对应的代码是下面两行, 第一行指令是将常数值 1 压入到栈中,然后再将常数值 2 压入到栈中,这两个值分别对应着 x 和 y。
movl $1, -8(%rbp) movl $2, -12(%rbp)
接下来,x 的值从栈中复制到 eax 寄存器中,对应的指令如下所示:
movl -8(%rbp), %eax
现在 eax 寄存器中保存了 x 的值,那么接下来,再将内存中的 y 和 eax 中的 x 相加,相加的结果再保存在 eax 中,对应的指令如下所示:
addl -12(%rbp), %eax
现在 x+y 的结果保存在了 eax 中了,接下来 CPU 会将结果保存中内存中,执行如下指令:
movl %eax, -16(%rbp)
最后又将结果 z 加载到 eax 寄存器中,代码如下所示:
movl -16(%rbp), %eax
注意这里的 eax 寄存器中的内容就被默认作为返回值了,执行到这里函数基本就执行结束了,然后需要继续执行一些恢复现场的操作,代码如下所示:
popq % rbp; retq;
PREVIOUS POST
[HTTP 系列] 第 5 篇 —— 网络安全
NEXT POST
谈 Generator 与 async / await