CPU 如何执行指令

CPU 结构以及计算机指令的执行过程十分负载,但对于程序员来说只需要知道写好的代码编程指令之后,是一条一条顺序执行的就可以了。

我们先不管几百亿的晶体管的背后是怎么通过电路运转起来的,逻辑上,我们可以认为,CPU 其实就是由一堆寄存器组成的。而寄存器就是 CPU 内部,由多个触发器(Flip-Flop)或者锁存器(Latches)组成的简单电路。

N 个触发器或者锁存器,就可以组成一个 N 位(Bit)的寄存器,能够保存 N 位的数据。cdba5c17a04f0dd5ef05b70368b9a96f.webp
CPU 中有多种寄存器,这里介绍三种比较特殊的:

一个是 PC 寄存器(Program Counter Register),我们也叫指令地址寄存器(Instruction Address Register)。顾名思义,它就是用来存放下一条需要执行的计算机指令的内存地址。

第二个是指令寄存器(Instruction Register),用来存放当前正在执行的指令。

第三个是条件码寄存器(Status Register),用里面的一个一个标记位(Flag),存放 CPU 进行算术或者逻辑计算的结果。ad91b005e97959d571bbd2a0fa30b48a.webp
实际上,一个程序执行的时候,CPU 会根据 PC 寄存器里的地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。可以看到,一个程序的一条条指令,在内存里面是连续保存的,也会一条条顺序加载。

而有些特殊指令,比如上一讲我们讲到 J 类指令,也就是跳转指令,会修改 PC 寄存器里面的地址值。这样,下一条要执行的指令就不是从内存里面顺序加载的了。事实上,这些跳转指令的存在,也是我们可以在写程序的时候,使用 if…else 条件语句和 while/for 循环语句的原因。

if else 与程序的执行和跳转

#include <time.h>
#include <stdlib.h>
int main()
{
  srand(time(NULL));
  int r = rand() % 2;
  int a = 10;
  if (r == 0)
  {
    a = 1;
  } else {
    a = 2;
  } 

$ gcc -g -c test.c
$ objdump -d -M intel -S test.o 

        if (r == 0)   {
  37:   83 7d f8 00             cmp    DWORD PTR [rbp-0x8],0x0
  3b:   75 09                   jne    46 <main+0x46>
                a = 1;
  3d:   c7 45 fc 01 00 00 00    mov    DWORD PTR [rbp-0x4],0x1
  44:   eb 07                   jmp    4d <main+0x4d>
        } else {
                a = 2;
  46:   c7 45 fc 02 00 00 00    mov    DWORD PTR [rbp-0x4],0x2
  4d:   b8 00 00 00 00          mov    eax,0x0
        }
}
  52:   c9                      leave
  53:   c3                      ret

19 行即比较 r 与 0 是否相等,cmp 指令的比较结果,会存入到条件码寄存器当中去。在这里,如果比较的结果是 True,也就是 r == 0,就把零标志条件码(对应的条件码是 ZF,Zero Flag)设置为 1。除了零标志之外,Intel 的 CPU 下还有进位标志(CF,Carry Flag)、符号标志(SF,Sign Flag)以及溢出标志(OF,Overflow Flag),用在不同的判断条件下。

cmp 指令执行完成之后,PC 寄存器会自动自增,开始执行下一条 jne 的指令。跟着的 jne 指令,是 jump if not equal 的意思,它会查看对应的零标志位。如果 ZF 为 1,说明上面的比较结果是 TRUE,如果是 ZF 是 0,也就是上面的比较结果是 False,会跳转到后面跟着的操作数 46 的位置。

27 行这条 mov 指令的第一个操作数 eax,代表累加寄存器,第二个操作数 0x0 则是 16 进制的 0 的表示。这条指令其实没有实际的作用,它的作用是一个占位符。main 函数没有设定返回值,而 mov eax, 0x0 其实就是给 main 函数生成了一个默认的为 0 的返回值到累加器里面。if 条件里面的内容执行完成之后也会跳转到这里,和 else 里的内容结束之后的位置是一样的。b439cebb2d85496ad6eef2f61071aefa.webp 讲打孔卡的时候说到,读取打孔卡的机器会顺序地一段一段地读取指令,然后执行。执行完一条指令,它会自动地顺序读取下一条指令。如果执行的当前指令带有跳转的地址,比如往后跳 10 个指令,那么机器会自动将卡片带往后移动 10 个指令的位置,再来执行指令。同样的,机器也能向前移动,去读取之前已经执行过的指令。这也就是我们的 while/for 循环实现的原理。

if else 和 goto 来实现 for 循环

int main()
{
    int a = 0;
    for (int i = 0; i < 3; i++)
    {
        a += i;
    }
}

for (int i = 0;i < 3; i++)
   f:   c7 45 fc 00 00 00 00    mov    DWORD PTR [rbp-0x4],0x0
  16:   eb 0a                   jmp    22 <main+0x22>
        {
                a += i;
  18:   8b 45 fc                mov    eax,DWORD PTR [rbp-0x4]
  1b:   01 45 f8                add    DWORD PTR [rbp-0x8],eax
        for (int i = 0;i < 3; i++)
  1e:   83 45 fc 01             add    DWORD PTR [rbp-0x4],0x1
  22:   83 7d fc 02             cmp    DWORD PTR [rbp-0x4],0x2
  26:   7e f0                   jle    18 <main+0x18>
  28:   b8 00 00 00 00          mov    eax,0x0
        }
}
  2d:   5d                      pop    rbp
  2e:   c3                      ret

fb50fe39181abb0f70fcfec53cf12317.webp jle 和 jmp 指令,就像程序语言里面的 goto 命令,直接指定了一个特定条件下的跳转位置。虽然我们在用高级语言开发程序的时候反对使用 goto,但是实际在机器指令层面,无论是 if…else…也好,还是 for/while 也好,都是用和 goto 相同的跳转到特定指令位置的方式来实现的。

总结延伸

这一节,在单条指令的基础上,学习了程序里的多条指令,究竟是怎么样一条一条被执行的。除了简单地通过 PC 寄存器自增的方式顺序执行外,条件码寄存器会记录下当前执行指令的条件判断状态,然后通过跳转指令读取对应的条件码,修改 PC 寄存器内的下一条指令的地址,最终实现 if…else 以及 for/while 这样的程序控制流程。

高级语言的流程控制,回归到计算机可以识别的机器指令级别,其实都只是一个简单的地址跳转而已,也就是一个类似于 goto 的语句。

想要在硬件层面实现这个 goto 语句,除了本身需要用来保存下一条指令地址,以及当前正要执行指令的 PC 寄存器、指令寄存器外,我们只需要再增加一个条件码寄存器,来保留条件判断的状态。这样简简单单的三个寄存器,就可以实现条件判断和循环重复执行代码的功能。