06 | 指令跳转:原来if...else就是goto_寄存器


06 | 指令跳转:原来if...else就是goto_自增_02

06 | 指令跳转:原来if...else就是goto_计算机原理_03

06 | 指令跳转:原来if...else就是goto_自增_04

06 | 指令跳转:原来if...else就是goto_操作数_05



00:00



讲述:徐文浩 大小:11.99M 时长:13:05


我们总是要用到 if…else 这样的条件判断语句、while 和 for 这样的循环语句,还有函数或者过程调用。


对应的,CPU 执行的也不只是一条指令,一般一个程序包含很多条指令。因为有 if…else、for 这样的条件和循环存在,这些指令也不会一路平铺直叙地执行下去。


今天我们就在上一节的基础上来看看,一个计算机程序是怎么被分解成一条条指令来执行的。


CPU 是如何执行指令的?


是一条一条 顺序 执行的就可以了。


逻辑上,我们可以认为,CPU 其实就是由一堆寄存器组成的。而寄存器就是 CPU 内部,由多个触发器(Flip-Flop)或者锁存器(Latches)组成的简单电路。


触发器和锁存器,其实就是两种不同原理的数字电路组成的逻辑门。这块内容并不是我们这节课的重点,所以你只要了解就好。如果想要深入学习的话,你可以学习数字电路的相关课程,这里我们不深入探讨。


N 个触发器或者锁存器,就可以组成一个 N 位(Bit)的寄存器,能够保存 N 位的数据。比方说,我们用的 64 位 Intel 服务器,寄存器就是 64 位的。



06 | 指令跳转:原来if...else就是goto_操作数_06


一个 CPU 里面会有很多种不同功能的寄存器。我这里给你介绍三种比较特殊的。


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


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


条件码寄存器 (Status Register),用里面的一个一个标记位(Flag),存放 CPU 进行算术或者逻辑计算的结果。


有些寄存器既可以存放数据,又能存放地址,我们就叫它通用寄存器。



06 | 指令跳转:原来if...else就是goto_寄存器_07


一个程序执行的时候,CPU 会根据 PC 寄存器里的地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。可以看到,一个程序的一条条指令,在内存里面是连续保存的,也会一条条顺序加载。


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


从 if…else 来看程序的执行和跳转


我们现在就来看一个包含 if…else 的简单程序。

// test.c
# 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 ;
}
————————————————


我们用 rand 生成了一个随机数 r,r 要么是 0,要么是 1。当 r 是 0 的时候,我们把之前定义的变量 a 设成 1,不然就设成 2。





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




我们把这个程序编译成汇编代码。你可以忽略前后无关的代码,只关注于这里的 if…else 条件判断语句。对应的汇编代码是这样的:



if
              
fc
              

                3f: 75 09 jne 4a <main+0x4a>
              
              

                {
              
              

                a = 1;
              
              

                41: c7 45 f8 01 00 00 00 mov DWORD PTR [rbp-0x8],0x1
              
              

                48: eb 07 jmp 51 <main+0x51>
              
              

                }
              
              
else
              

                {
              
              

                a = 2;
              
              

                4a: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
              
              
 51: b8 00 00 00 00 mov eax,0x0
              

                }




可以看到,这里对于 r == 0 的条件判断,被编译成了 cmp 和 jne 这两条指令。


cmp 指令比较了前后两个操作数的值,这里的 DWORD PTR 代表操作的数据类型是 32 位的整数,而[rbp-0x4]则是变量 r 的内存地址。所以,第一个操作数就是从内存里拿到的变量 r 的值。第二个操作数 0x0 就是我们设定的常量 0 的 16 进制表示。cmp 指令的比较结果,会存入到 条件码寄存器 当中去。


零标志条件码 (对应的条件码是 ZF,Zero Flag)设置为 1。除了零标志之外,Intel 的 CPU 下还有 进位标志 (CF,Carry Flag)、 符号标志 (SF,Sign Flag)以及 溢出标志


cmp 指令执行完成之后,PC 寄存器会自动自增,开始执行下一条 jne 的指令。


跟着的 jne 指令,是 jump if not equal 的意思,它会查看对应的零标志位。如果 ZF 为 1,说明上面的比较结果是 TRUE,如果是 ZF 是 0,也就是上面的比较结果是 False,会跳转到后面跟着的操作数 4a 的位置。这个 4a,对应这里汇编代码的行号,也就是上面设置的 else 条件里的第一条指令。当跳转发生的时候,PC 寄存器就不再是自增变成下一条指令的地址,而是被直接设置成这里的 4a 这个地址。这个时候,CPU 再把 4a 地址里的指令加载到指令寄存器中来执行。


跳转到执行地址为 4a 的指令,实际是一条 mov 指令,第一个操作数和前面的 cmp 指令一样,是另一个 32 位整型的内存地址,以及 2 的对应的 16 进制值 0x2。mov 指令把 2 设置到对应的内存里去,相当于一个赋值操作。然后,PC 寄存器里的值继续自增,执行下一条 mov 指令。


我们的 main 函数没有设定返回值,而 mov eax, 0x0 其实就是给 main 函数生成了一个默认的为 0 的返回值到累加器里面。if 条件里面的内容执行完成之后也会跳转到这里,和 else 里的内容结束之后的位置是一样的。



06 | 指令跳转:原来if...else就是goto_操作数_08


上一讲我们讲打孔卡的时候说到,读取打孔卡的机器会顺序地一段一段地读取指令,然后执行。执行完一条指令,它会自动地顺序读取下一条指令。如果执行的当前指令带有跳转的地址,比如往后跳 10 个指令,那么机器会自动将卡片带往后移动 10 个指令的位置,再来执行指令。同样的,机器也能向前移动,去读取之前已经执行过的指令。这也就是我们的 while/for 循环实现的原理。


如何通过 if…else 和 goto 来实现循环?

int                               main               ()
              

               {
              
              
int                               a                               =
                
               0
              
for                (               int                               i
                
               =
                
               0
               ; i < 
               3
              

                {
              
              

                a += i;
              
              

                }
              
              

               }





我们再看一段简单的利用 for 循环的程序。我们循环自增变量 i 三次,三次之后,i>=3,就会跳出循环。整个程序,对应的 Intel 汇编代码就是这样的:



for                (               int                i =                0
               ; i <= 
               2
              
45                f8                00                               00
                
               00
                
               00
                mov DWORD PTR [rbp-
               0x4
               ],
               0x0
              
12
              

                {
              
              

                a += i;
              
              
14               : 8b                45                f8 mov eax,DWORD PTR [rbp-               0x4
              
17               : 01                45                fc add DWORD PTR [rbp-               0x8
              
              
83                               45                f8 01 add DWORD PTR [rbp-               0x4
               ],
               0x1
              
83                7d f8 02 cmp DWORD PTR [rbp-               0x4               ],               0x2
              
22               : 7e f0 jle                14
              
24               : b8                00                               00
                
               00
                
               00
                mov eax,
               0x0
              

                }





可以看到,对应的循环也是用 1e 这个地址上的 cmp 比较指令,和紧接着的 jle 条件跳转指令来实现的。主要的差别在于,这里的 jle 跳转的地址,在这条指令之前的地址 14,而非 if…else 编译出来的跳转指令之后。往前跳转使得条件满足的时候,PC 寄存器会把指令地址设置到之前执行过的指令位置,重新执行之前执行过的指令,直到条件不满足,顺序往下执行 jle 之后的指令,整个循环才结束。



06 | 指令跳转:原来if...else就是goto_计算机原理_09


如果你看一长条打孔卡的话,就会看到卡片往后移动一段,执行了之后,又反向移动,去重新执行前面的指令。


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


总结延伸


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


你会发现,虽然我们可以用高级语言,可以用不同的语法,比如 if…else 这样的条件分支,或者 while/for 这样的循环方式,来实现不同的程序运行流程,但是回归到计算机可以识别的机器指令级别,其实都只是一个简单的地址跳转而已,也就是一个类似于 goto 的语句。


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


下一节,我们会进一步讲解,如果程序中出现函数或者过程这样可以复用的代码模块,对应的指令是怎么样执行的,会和我们这里的 if…else 有什么不同。