极客时间已完结课程限时免费阅读

06 | 指令跳转:原来if...else就是goto

06 | 指令跳转:原来if...else就是goto-极客时间

06 | 指令跳转:原来if...else就是goto

讲述:徐文浩

时长13:05大小11.99M

上一讲,我们讲解了一行代码是怎么变成计算机指令的。你平时写的程序中,肯定不只有 int a = 1 这样最最简单的代码或者指令。我们总是要用到 if…else 这样的条件判断语句、while 和 for 这样的循环语句,还有函数或者过程调用。
对应的,CPU 执行的也不只是一条指令,一般一个程序包含很多条指令。因为有 if…else、for 这样的条件和循环存在,这些指令也不会一路平铺直叙地执行下去。
今天我们就在上一节的基础上来看看,一个计算机程序是怎么被分解成一条条指令来执行的。

CPU 是如何执行指令的?

拿我们用的 Intel CPU 来说,里面差不多有几百亿个晶体管。实际上,一条条计算机指令执行起来非常复杂。好在 CPU 在软件层面已经为我们做好了封装。对于我们这些做软件的程序员来说,我们只要知道,写好的代码变成了指令之后,是一条一条顺序执行的就可以了。
我们先不管几百亿的晶体管的背后是怎么通过电路运转起来的,逻辑上,我们可以认为,CPU 其实就是由一堆寄存器组成的。而寄存器就是 CPU 内部,由多个触发器(Flip-Flop)或者锁存器(Latches)组成的简单电路。
触发器和锁存器,其实就是两种不同原理的数字电路组成的逻辑门。这块内容并不是我们这节课的重点,所以你只要了解就好。如果想要深入学习的话,你可以学习数字电路的相关课程,这里我们不深入探讨。
好了,现在我们接着前面说。N 个触发器或者锁存器,就可以组成一个 N 位(Bit)的寄存器,能够保存 N 位的数据。比方说,我们用的 64 位 Intel 服务器,寄存器就是 64 位的。
一个 CPU 里面会有很多种不同功能的寄存器。我这里给你介绍三种比较特殊的。
一个是 PC 寄存器(Program Counter Register),我们也叫指令地址寄存器(Instruction Address Register)。顾名思义,它就是用来存放下一条需要执行的计算机指令的内存地址。
第二个是指令寄存器(Instruction Register),用来存放当前正在执行的指令。
第三个是条件码寄存器(Status Register),用里面的一个一个标记位(Flag),存放 CPU 进行算术或者逻辑计算的结果。
除了这些特殊的寄存器,CPU 里面还有更多用来存储数据和内存地址的寄存器。这样的寄存器通常一类里面不止一个。我们通常根据存放的数据内容来给它们取名字,比如整数寄存器、浮点数寄存器、向量寄存器和地址寄存器等等。有些寄存器既可以存放数据,又能存放地址,我们就叫它通用寄存器。
实际上,一个程序执行的时候,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 (r == 0)
3b: 83 7d fc 00 cmp DWORD PTR [rbp-0x4],0x0
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 指令的比较结果,会存入到条件码寄存器当中去。
在这里,如果比较的结果是 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,会跳转到后面跟着的操作数 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 函数没有设定返回值,而 mov eax, 0x0 其实就是给 main 函数生成了一个默认的为 0 的返回值到累加器里面。if 条件里面的内容执行完成之后也会跳转到这里,和 else 里的内容结束之后的位置是一样的。
上一讲我们讲打孔卡的时候说到,读取打孔卡的机器会顺序地一段一段地读取指令,然后执行。执行完一条指令,它会自动地顺序读取下一条指令。如果执行的当前指令带有跳转的地址,比如往后跳 10 个指令,那么机器会自动将卡片带往后移动 10 个指令的位置,再来执行指令。同样的,机器也能向前移动,去读取之前已经执行过的指令。这也就是我们的 while/for 循环实现的原理。

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

int main()
{
int a = 0;
for (int i = 0; i < 3; i++)
{
a += i;
}
}
我们再看一段简单的利用 for 循环的程序。我们循环自增变量 i 三次,三次之后,i>=3,就会跳出循环。整个程序,对应的 Intel 汇编代码就是这样的:
for (int i = 0; i <= 2; i++)
b: c7 45 f8 00 00 00 00 mov DWORD PTR [rbp-0x4],0x0
12: eb 0a jmp 1e
{
a += i;
14: 8b 45 f8 mov eax,DWORD PTR [rbp-0x4]
17: 01 45 fc add DWORD PTR [rbp-0x8],eax
1a: 83 45 f8 01 add DWORD PTR [rbp-0x4],0x1
1e: 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 之后的指令,整个循环才结束。
如果你看一长条打孔卡的话,就会看到卡片往后移动一段,执行了之后,又反向移动,去重新执行前面的指令。
其实,你有没有觉得,jle 和 jmp 指令,有点像程序语言里面的 goto 命令,直接指定了一个特定条件下的跳转位置。虽然我们在用高级语言开发程序的时候反对使用 goto,但是实际在机器指令层面,无论是 if…else…也好,还是 for/while 也好,都是用和 goto 相同的跳转到特定指令位置的方式来实现的。

总结延伸

这一节,我们在单条指令的基础上,学习了程序里的多条指令,究竟是怎么样一条一条被执行的。除了简单地通过 PC 寄存器自增的方式顺序执行外,条件码寄存器会记录下当前执行指令的条件判断状态,然后通过跳转指令读取对应的条件码,修改 PC 寄存器内的下一条指令的地址,最终实现 if…else 以及 for/while 这样的程序控制流程。
你会发现,虽然我们可以用高级语言,可以用不同的语法,比如 if…else 这样的条件分支,或者 while/for 这样的循环方式,来实现不同的程序运行流程,但是回归到计算机可以识别的机器指令级别,其实都只是一个简单的地址跳转而已,也就是一个类似于 goto 的语句。
想要在硬件层面实现这个 goto 语句,除了本身需要用来保存下一条指令地址,以及当前正要执行指令的 PC 寄存器、指令寄存器外,我们只需要再增加一个条件码寄存器,来保留条件判断的状态。这样简简单单的三个寄存器,就可以实现条件判断和循环重复执行代码的功能。
下一节,我们会进一步讲解,如果程序中出现函数或者过程这样可以复用的代码模块,对应的指令是怎么样执行的,会和我们这里的 if…else 有什么不同。

推荐阅读

《深入理解计算机系统》的第 3 章,详细讲解了 C 语言和 Intel CPU 的汇编语言以及指令的对应关系,以及 Intel CPU 的各种寄存器和指令集。
Intel 指令集相对于之前的 MIPS 指令集要复杂一些,一方面,所有的指令是变长的,从 1 个字节到 15 个字节不等;另一方面,即使是汇编代码,还有很多针对操作数据的长度不同有不同的后缀。我在这里没有详细解释各个指令的含义,如果你对用 C/C++ 做 Linux 系统层面开发感兴趣,建议你一定好好读一读这一章节。

课后思考

除了 if…else 的条件语句和 for/while 的循环之外,大部分编程语言还有 switch…case 这样的条件跳转语句。switch…case 编译出来的汇编代码也是这样使用 jne 指令进行跳转吗?对应的汇编代码的性能和写很多 if…else 有什么区别呢?你可以试着写一个简单的 C 语言程序,编译成汇编代码看一看。
欢迎留言和我分享你的思考和疑惑,你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
分享给需要的人,Ta购买本课程,你将得20
生成海报并分享

赞 75

提建议

上一篇
05 | 计算机指令:让我们试试用纸带编程
下一篇
07 | 函数调用:为什么会发生stack overflow?
unpreview
 写留言

精选留言(139)

  • 宋不肥
    2019-05-08
    个人理解:这一讲的核心在于理解几个寄存器的作用,从而理解cpu运行程序的过程:cpu从PC寄存器中取地址,找到地址对应的内存位子,取出其中指令送入指令寄存器执行,然后指令自增,重复操作。所以只要程序在内存中是连续存储的,就会顺序执行这也是冯诺依曼体系的理念吧。而实际上跳转指令就是当前指令修改了当前PC寄存器中所保存的下一条指令的地址,从而实现了跳转。当然各个寄存器实际上是由数电中的一个一个门电路组合出来的,而各个门电路的具体电路形式也是属于模电的东西。对于我们来说,有个具体概念就行,实在需要的时候再回去翻翻课本捡起来就行。
    展开

    作者回复: 👍完全正确。

    共 23 条评论
    206
  • L
    2019-05-08
    非计算机专业 表示看到这一章已经很懵逼了

    作者回复: 那要加油搞清楚啊。

    共 7 条评论
    71
  • 免费的人
    2019-05-09
    switch case 要看编译器有没有生成跳表,没有的话跟if else效率应该是一样的,比如case个数比较少的情况

    作者回复: 👍

    共 3 条评论
    47
  • Out
    2019-05-09
    老师您好,在文中您提到:“在这里,如果比较的结果是False,也就是0,就把零标志码设置为1” 这个地方是不是有问题,根据我查到结果,cmp will ZF to 1 when two operands are equal. 所以如果比较的结果是True,才会把零标志码设置为1。

    作者回复: 是得,笔误了。应该是 “如果比较得结果是True,也就是 r == 0,就把零标志码设置为1” 不然后面得jne跳转和这里也对不上。

    共 2 条评论
    45
  • Linuxer
    2019-05-08
    int main() { 0: 55 push rbp 1: 48 89 e5 mov rbp,rsp int i = 0; 4: c7 45 fc 00 00 00 00 mov DWORD PTR [rbp-0x4],0x0 int a = 0; b: c7 45 f8 00 00 00 00 mov DWORD PTR [rbp-0x8],0x0 switch(i) 12: 8b 45 fc mov eax,DWORD PTR [rbp-0x4] 15: 83 f8 01 cmp eax,0x1 18: 74 07 je 21 <main+0x21> 1a: 83 f8 02 cmp eax,0x2 1d: 74 0b je 2a <main+0x2a> 1f: eb 12 jmp 33 <main+0x33> { case 1: a = 1; 21: c7 45 f8 01 00 00 00 mov DWORD PTR [rbp-0x8],0x1 break; 28: eb 11 jmp 3b <main+0x3b> case 2: a = 2; 2a: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2 break; 31: eb 08 jmp 3b <main+0x3b> default: a = 3; 33: c7 45 f8 03 00 00 00 mov DWORD PTR [rbp-0x8],0x3 break; 3a: 90 nop } return 1; 3b: b8 01 00 00 00 mov eax,0x1 } 40: 5d pop rbp 41: c3 ret 课后问题验证,这么看如果是单纯的两个分支采用if else更有利,另外 mov eax,0x1从这儿看象是main的返回值
    展开

    作者回复: 是的,如果没有提供返回值,很多版本的编译器会隐式地生成一个return 0;的返回值,就会生成 mov eax, 0x0 的多出来的指令。我修改一下让文章更准确一点。

    共 2 条评论
    27
  • 不记年
    2019-05-10
    cpu的在执行指令时还要有个转码的电路来将指令转换成不同的电信号,这些电信号可以控制各个寄存器的动作~

    作者回复: 👍这个关于CPU的控制器的译码器的部分我会在后续讲解CPU的部分讲到。

    19
  • 大熊
    2019-05-22
    接testswitch1的那条 如果条件比较多,3个以上,反汇编出来的代码就是先经过一系列计算,最后跳转。 -----------testswitch2.c #include <stdio.h> int main(int argc, char const *argv[]) { int m = 3; int a = 0; switch (m) { case 5: a = 5; break; case 4: a = 4; break; case 3: a = 3; break; case 2: a = 2; break; case 1: a = 1; break; default: a = 9; } return 0; } -----------testswitch2.c 上面的代码进行的反汇编: -----------testswitch2 #include <stdio.h> int main() { 0: 55 push rbp 1: 48 89 e5 mov rbp,rsp 4: 89 7d ec mov DWORD PTR [rbp-0x14],edi 7: 48 89 75 e0 mov QWORD PTR [rbp-0x20],rsi int m = 3; b: c7 45 f8 03 00 00 00 mov DWORD PTR [rbp-0x8],0x3 # 把3,放在地址[rbp-0x8] int a = 0; 12: c7 45 fc 00 00 00 00 mov DWORD PTR [rbp-0x4],0x0 # 把0,放在地址[rbp-0x4] switch (m) 19: 83 7d f8 05 cmp DWORD PTR [rbp-0x8],0x5 # 把[rbp-0x8]的值(即3)和5比较 1d: 77 51 ja 70 <main+0x70> # 如果大于5,则跳转到70(即default)执行 a = 9 1f: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8] # 把3,放入eax # 从这里开始不做过多解释,这就是经过一系列操作和计算的部分 22: 48 8d 14 85 00 00 00 lea rdx,[rax*4+0x0] 29: 00 2a: 48 8d 05 00 00 00 00 lea rax,[rip+0x0] # 31 <main+0x31> 31: 8b 04 02 mov eax,DWORD PTR [rdx+rax*1] # 34: 48 63 d0 movsxd rdx,eax 37: 48 8d 05 00 00 00 00 lea rax,[rip+0x0] # 3e <main+0x3e> 3e: 48 01 d0 add rax,rdx # 到这里结束 41: ff e0 jmp rax # 直接执行的是jmp指令,直接跳转到要执行的语句位置 /*后面代码省略*/ -----------testswitch2 所以,switch判断条件多,且最好case之间的差值不要过大的时候最好使用switch
    展开

    作者回复: 👍多动手自己体会是王道。

    15
  • Amark
    2019-06-15
    上一节讲的是: 高级语言的代码到汇编语言再到机器指令的过程,而这节其实是: 计算机如何执行机器指令的,分为顺序执行与跳转执行的方式,当然这两种执行方式是通过指令寄存器,PC寄存器,条形码寄存器实现的。
    共 1 条评论
    14
  • Linuxer
    2019-05-08
    51: b8 00 00 00 00 mov eax,0x0 这个会不会是main的返回值呢?

    作者回复: Linuxer同学你说得对,这个就是main的返回值。

    11
  • rookie
    2019-05-10
    程序如下: int main(){ int i = 0; int a = 0; switch(i){ case 1: a = 1; break; case 2: a = 2; break; default: a = 3; break;} return 1; } 下面是机器码和汇编代码: 0000000000000000 <main>: 0: 55 push rbp 1: 48 89 e5 mov rbp,rsp 4: c7 45 f8 00 00 00 00 mov DWORD PTR [rbp-0x8],0x0 #将0复制给[rbp-0x8] 这个内存地址,即 i = 0 b: c7 45 fc 00 00 00 00 mov DWORD PTR [rbp-0x4],0x0 #将0复制给[rbp-0x4] 这个内存地址,即 a = 0 12: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8] #将i的值1复制给寄存器eax 15: 83 f8 01 cmp eax,0x1 #将eax与1进行比较,如果true则执行下面的je指令,跳转到21 18: 74 07 je 21 <main+0x21> 1a: 83 f8 02 cmp eax,0x2 #将eax与2进行比较,如果true则执行下面的je指令,跳转到2a 1d: 74 0b je 2a <main+0x2a> 1f: eb 12 jmp 33 <main+0x33> #跳转到33 21: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1 #a = 1 28: eb 11 jmp 3b <main+0x3b> 2a: c7 45 fc 02 00 00 00 mov DWORD PTR [rbp-0x4],0x2 #a = 2 31: eb 08 jmp 3b <main+0x3b> 33: c7 45 fc 03 00 00 00 mov DWORD PTR [rbp-0x4],0x3 #a = 3 3a: 90 nop 3b: b8 01 00 00 00 mov eax,0x1 # eax = 1 40: 5d pop rbp 41: c3 ret # 返回eax
    展开

    作者回复: 👍

    10
  • aiter
    2019-05-09
    徐老师好~ C语言我不会,。,努力看了半天,算是懂了大部分,但是for循环那里还是有点问题~汇编语言里,jmp 1e 之后,应该是做比较cmp,但是为什么不是0和3比较,而是和16进制的2(0x2)比较? ————- 因为后面用的jle(jump if less or equal) <=2.如果是使用jl(jump if less) <3.应该是编译器的优化行为?可以自己写汇编代码,使用jl 0x3试试效果是否一样
    展开

    作者回复: aiter同学谢谢。我回复了,不过你这里的理解不太对,jle指令并不是和2做比较,而是判断标志位的,jle 和 jl 用的是不同的标志位,具体可以看看这个reference http://www.unixwiz.net/techtips/x86-jumps.html

    9
  • Paul Shan
    2020-02-14
    PC寄存器像在一条磁带上滑动磁头,指令寄存器则是这个磁头读取的内容,条件码寄存器存储的是计算的中间过程,跳转指令则是让这个磁头向前或者向后跳转到预先指定的位置。判断寄存器内容并往前跳转是实现条件的关键,往回跳转是循环的关键,在一个线性结构中实现条件和循环真不容易。

    作者回复: 👍,这个描述和比喻也很恰当。其实计算机是一个依靠非常简单的基础电路组建起来的一个极其精巧的设备。就好像用砖头砌起了米兰大教堂一样。

    7
  • 大马猴
    2019-11-24
    [rbp-0x4] 则是一个寄存器的地址,这里讲错了,rbp寄存器保存的是栈基址
    共 3 条评论
    7
  • 晴天~
    2019-08-19
    徐老师好,有一个问题想请教一下: 执行cmp这样的指令会改变条件码寄存器部分状态码的值,我想请教一下,cpu的条件码寄存器肯定不止一个,后面的jne,jle需要判断条件码的值执行跳转,是不是有一个机制来保证jne这样的指令找到正确的条件码寄存器呢?

    作者回复: 晴天~同学 你好,为什么不能只有一个呢?x86里一个CPU只有一个条件吗寄存器

    7
  • -W.LI-
    2019-05-26
    switch case 我猜是用jump if equal写的,所有判断顺序写一起,所有处理逻辑顺序写一起,满足条件就跳到对应的处理逻辑,遇见break就跳转到switch块的外面,如果没有就会顺序执行剩下的处理逻辑(case穿透)。

    作者回复: 可以写一些带switch...case的程序试一下,你会发现编译器是很聪明的

    4
  • HopeYoung.Lee
    2019-05-24
    请问,Windows命令行可以编写C语言,然后查看汇编代码么?要怎么操作呢?Linux系统我也不会,尴尬。

    作者回复: 可以安装WSL和GCC来跑对应的Linux命令和程序。

    共 2 条评论
    4
  • hello
    2019-05-24
    老师,您上一讲讲道指令都是32位,为什么这次jne 指令是16位,mov指令是56位?

    作者回复: hello同学你好, 上一讲里面,我们是拿MIPS这样比较简单的指令集来举例子的,里面的指令都是等长的。 这一讲里面,实际是在一台Intel CPU的Linux机器上的程序,Intel的指令集里面的每条指令的长度并不是都相等的。

    5
  • 越努力,越幸运
    2019-05-15
    `而 [rbp-0x4] 则是一个寄存器的地址` 这个不应该是栈地址吗,rbp是栈基址,rbp - 0x4是第一个local var的内存地址
    共 5 条评论
    4
  • 北北、Pepper
    2019-05-08
    今年大四,正在实习。在实际工作慢慢发现自己基础知识的薄弱,所以现在也是抓紧时间在补习这些知识。听老师这一讲,又想起了汇编的知识,比起以前,又有了更深的理解。十分期待老师更新专栏~

    作者回复: 👍加油

    4
  • 起飞的鸭子
    2019-05-08
    看这些都有些 吃力我是没救了

    作者回复: sunyunjian同学,千万不要气馁,可以顺序多看几遍,学习不是个着急的事儿。

    4