指令跳转
指令跳转:原来if…else就是goto
用到 if…else 这样的条件判断语句、while 和 for 这样的循环语句,还有函数或者过程调用。对应的,CPU 执行也不只是一条指令,一般一个程序包含很多条指令,因为有 if…else、for 这样的条件和循环存在,这些指令也不会一路平铺直叙地执行下去。
CPU 是如何执行指令的?
对于做软件开发来说,写好的代码变成指令之后,是一条一条顺序执行的。
先不管几百亿的晶体管的背后是怎么通过电路运转起来的,逻辑上,我们可以认为,CPU 其实就是由一堆寄存器组成的。而寄存器就是CPU 内部,由多个触发器(Flip-Flop)或者锁存器(Latches)组成的简单电路。
N 个触发器或者锁存器,就可以组成一个 N 位(Bit)的寄存器,能够保存 N 位的数据。比方说,用的64位 Intel服务器,寄存器就是64位的。
一个CPU 里会有多种不同功能的寄存器,主要介绍以下三种较为特殊的:
PC寄存器,也称为指令地址寄存器(Instruction Address Register)。是用来存放下一条需要执行的计算机指令的内存地址。
指令寄存器,用来存放当前正在执行的指令。
条件寄存器,用里面的一个一个标记位(Flag),存放CPU进行算数或者逻辑计算的结果。
- 除了这些特殊的寄存器,CPU 里还有更多用来存储数据和内存地址的寄存器。这样的寄存器通常一类里面不止一个。通常根据存放的数据内存来给他们取名,比如整数寄存器、浮点寄存器、向量寄存器和地址寄存器等。有些既可以存数据,也能存地址的就称之为通用寄存器。
实际上,一个程序执行的时候,CPU 会根据PC寄存器里的地址,从内存里把需要执行的指令读取到指令寄存器里执行,然后根据指令长度递增,开始顺序读取下一条指令。可以看到,一个程序的一条条指令,在内存里是连续保存的,也是一条条顺序加载的。
而一些特殊指令,比如 J指令,会修改PC寄存器里的地址值。这样,下一条要执行的指令就不是从内存里顺序加载的了。事实上,这些跳转指令的存在,也是我们在写程序的时候,使用 if…else 条件语句和 while/for 循环语句的原因。
从 if…else 来看程序的执行和跳转
现在就来看一个包含 if…else 的简单程序。
1 |
|
我们用 rand 生成了一个随机数 r,r 要么是 0,要么是 1。当 r 是 0 的时候,我们把之前定义的变量 a 设成 1,不然就设成 2。使用 gcc 和 objdump 把这个程序编译成汇编代码。只关注于这里的 if…else 条件判断语句。对应的汇编代码是这样的:
1 |
|
可以看到,这里对于 r == 0 的条件判断,被编译成了 cmp 和 jne 这两条指令。cmp 指令比较了前后两个操作数的值,这里的 DWORD PTR 代表操作的数据类型是 32 位的整数,而[rbp-0x4]则是变量 r 的内存地址。第一个操作数就是从内存里拿到的变量 r 的值。第二个操作数 0x0 就是我们设定的常量 0 的 16 进制表示。cmp 指令的比较结果,会存入到条件码寄存器当中去。
在这里,如果比较的结果是 True,也就是 r == 0,就把零标志条件码(对应的条件码是 ZF,Zero Flag)设置为 1。除了零标志之外,Intel 的 CPU 下还有进位标志(CF,Carry Flag)、符号标志(SF,Sign Flag)以及溢出标志(OF,Overflow Flag),用在不同的判断条件下。
cop 指令执行完成之后,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 指令。
这条 mov 指令的第一个操作数 eax,代表累加寄存器,第二个操作数 0x0 则是 16 进制的 0 的表示。这条指令其实没有实际的作用,它的作用是一个占位符。前面的 if 条件,如果满足的话,在赋值的 mov 指令执行完成之后,有一个 jmp 的无条件跳转指令。跳转的地址就是这一行的地址 51。我们的main 函数没有设定返回值,而move eax,0x0 其实就是给函数生成了一个默认的为0 的返回值的累加器里面。if 条件里面的内容执行完成之后也会跳转到这里,和 else 里的内容结束之后的位置是一样的。
如何通过 if…else 和 goto 来实现循环?
1 |
|
从上面这一段简单的利用 for 循环的程序。我们循环自增变量 i 三次,三次之后,i>=3,就会跳出循环。整个程序,对应的 Intel 汇编代码就是这样的:
1 |
|
可以看到,对应的循环也是用 1e 这个地址上的 cmp 比较指令,和紧接着的 jle条件跳转指令来实现的。主要的差别在于,这里的 jle 跳转的地址,在这条指令之前的地址 14。往前跳转使得条件满足的时候,PC寄存器会把指令地址设置到之前执行过的指令位置,重新执行之前执行过的指令,直到条件不满足,顺序往下执行jle之后的指令,整个循环才结束。
其实,jle 和 jmp 指令,有点像程序语言里面的 goto 命令,直接指定了一个特定条件下的跳转位置。虽然我们在用高级语言开发程序的时候反对使用 goto,但是实际在机器指令层面,无论是 if…else…也好,还是 for/while 也好,都是用和 goto 相同的跳转到特定指令位置的方式来实现的。
总结延伸:
程序里的多条指令,除了通过PC寄存器自增的方式顺序执行外,条件寄存器会记录下当前执行指令的条件判断状态,然后通过跳转指令读取对应的条件码,修改PC寄存器内的下一条指令的地址,最终实现if…else 以及 for/while 这样的程序控制流程。
想要在硬件层面实现这个 goto 语句,除了本身需要用来保存下一条指令地址,以及当前正要执行指令的 PC 寄存器、指令寄存器外,我们只需要再增加一个条件码寄存器,来保留条件判断的状态。这样简简单单的三个寄存器,就可以实现条件判断和循环重复执行代码的功能。