汇编指令之跳转指令
程序员文章站
2022-05-28 12:14:11
...
正常执行的情况下,指令会按照顺序一条条地执行,使用跳转(jump)指令可以改变这种行为。在汇编代码中,这些跳转的目的地通常用一个标号(label)指明。在产生目标代码文件时,汇编器会确定所有带标号指令的地址,并将跳转目标(目的指令的地址)编码为跳转指令的一部分。下表列举了不同的跳转指令。
其中,jmp 指令是无条件跳转,它可以是直接跳转,即跳转目标是作为指令的一部分编码的;也可以是间接跳转,即跳转目标是从寄存器或内存位置中读出的。汇编语言中,直接跳转是给出一个标号(如“.L1”)作为跳转目标的。间接跳转的写法是“*”后面跟一个x86-64 中的寄存器与汇编操作数杂述一节中描述的内存操作数格式中的一种操作数指示符。比如,指令“jmp *%rax”用寄存器 %rax 中的值作为跳转目标,而“jmp *(%rax)”则以 %rax 中的值作为读地址,然后从内存中读出跳转目标。
表中的其他跳转指令都是有条件的——它们根据条件码的某种组合,或者跳转,或者继续执行代码序列中的下一条指令。这些指令的名字和跳转条件与 SET 指令(见汇编指令之条件码)的名字和设置条件是相匹配的,而且有些指令同样拥有同义名。注意,条件跳转只能是直接跳转。
如前所述,汇编器以及后来的链接器,可以根据标号产生跳转目标的适当编码。跳转指令有几种不同的编码,但是最常用的都是 PC 相对的(PC-relative),也就是,它们会将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码。这些地址偏移量可以编码为 1、2 或 4 个字节。第二种编码方式是给出“绝对”地址,即用 4 个字节直接指定目标。
下面代码片段是一个 PC 相对寻址的例子。它包含两个跳转:jmp 指令前向跳转到更高的地址,而 jg 指令后向跳转到较低的地址。
这里需要了解的是,使用 rep 后跟 ret 的指令组合主要是为了避免使 ret 指令成为条件跳转指令的目标。如果没有 rep 指令,当分支不跳转时,jg 指令会继续到 ret 指令。而根据 AMD 的说法,当 ret 指令通过跳转指令到达时,处理器不能正确预测 ret 指令的目的。因此这里的 rep 指令就是一种空操作,将它作为跳转目的插入,除了能使代码在 AMD 上运行得更快之外,不会改变代码的其他行为。
汇编器产生的“.o”格式的反汇编版本如下:
可见,jmp 和 jg 指令的跳转目标分别被指明为 0x8 和 0x5。反观指令的字节编码,会看到 jmp 跳转指令的目标编码为 0x03,把它加上下一条指令的地址 0x5,就得到跳转目标地址 0x8。类似地,jg 跳转指令的目标用单字节、补码表示编码为 0xf8(十进制 -8),将其加上下一条指令的地址 0xd(十进制 13),就得到跳转地址 0x5。
下面是链接后的程序反汇编版本:
可见,尽管这些指令被重定位到不同的地址,但是 jmp 和 jg 指令中的跳转目标的编码并没有变。通过使用与 PC 相对的跳转目标编码,可使指令编码更为简洁(只需要 2 个字节),而且目标代码可以不做改变就移到内存中不同的位置。
类似的例子说明,当执行 PC 相对寻址时,程序计数器的值是跳转指令后面的那条指令的地址,而不是跳转指令本身的地址。这种惯例可以追溯到早期的实现,当时的处理器会将更新程序计数器作为执行一条指令的第一步。
使用控制的条件转移是实现条件操作的传统方法:当条件满足时,程序沿着一条执行路径执行,不满足时则走另一条路径。这种机制简单而通用,但在现代处理器上,它可能会非常低效。一种替代的策略是使用数据的条件转移。这种方法会计算一个条件操作的两种结果,然后再根据条件是否满足从中选取一个。尽管这种策略只有在一些受限制的情况中才可行,但一旦可行,就可以使用一条简单的条件传送指令来实现它。条件传送指令更符合现代处理器的功能特性。
为了理解为什么基于条件数据传送的代码会比基于条件控制转移的代码性能要好,需要了解现代处理器通过使用流水线(pipelining)来获得高性能。在流水线中,一条指令的处理要经过一系列的阶段,每个阶段执行所需操作的一小部分(例如,从内存取指令、确定指令类型、从内存读数据、执行算术运算、向内存写数据,以及更新程序计数器)。这种方法通过重叠连续指令的步骤来获得高性能,例如,在取一条指令的同时,执行它前面一条指令的算术运算。而要做到这一点,就要求能够事先确定要执行的指令序列,这样才能保持流水线中充满待执行的指令。当机器遇到条件跳转时(也称为“分支”)时,只有当分支条件求值完成后,才能决定分支往哪边走。处理器采用非常精密的分支预测逻辑来猜测每条跳转指令是否会执行。只要它的猜测还比较可靠(现代微处理器设计试图达到 90% 以上的成功率),指令流水线中就会充满着指令。另一方面,如果错误预测一个跳转,就要求处理器丢掉它为该跳转指令后所有指令已做的工作,然后再开始用从正确位置处起始的指令去填充流水线。这样一个错误预测通常会招致很严重的惩罚,浪费大约 15~30 个时钟周期,导致程序性能严重下降。而对于编译出来使用条件传送的代码而言,无论测试的数据是什么,所需的时间都是大约 8 个时钟周期。因为控制流不依赖于数据,所以这使得处理器更容易保持流水线是满的。
下表列举了 x86-64 上一些可用的条件传送指令。
每条指令都有两个操作数:源寄存器或者内存地址 S,和目的寄存器 R。与不同的 SET 和跳转指令一样,这些指令的结果取决于条件码的值。源值可以从内存或者源寄存器中读取,但只有在指定的条件满足时,才会被复制到目的寄存器中。源和目的的值可以是 16 位、32 位或 64 位长,但不支持单字节的条件传送。无条件指令的操作数的长度显示地编码在指令名中(如 movw 和 movl),汇编器可以从目标寄存器的名字推断出条件传送指令的操作数长度,所以对所有的操作数长度,都可以使用同一个的指令名字。
不同于条件跳转,处理器无需预测测试的结果就可以执行条件传送。处理器只是读源值,检查条件码,然后要么更新目的寄存器,要么保持不变。考虑下面的条件表达式和赋值的通用形式:
v = test-expr ? then-expr : else-expr;
如果用条件控制转移的标准方法来编译这个表达式会得到如下形式:
if(!test-expr)
goto false;
v = then-expr;
goto done;
false:
v = else-expr;
done:
/* other */
这段代码结合条件跳转和无条件跳转来保证只有一个 then-expr 序列或者 else-expr 序列执行。
而在基于条件传送的代码中,会对 then-expr 和 else-expr 都求值,最终值的选择基于对 test-expr 的求值。代码描述大致如下:
v = then-expr;
ve = else-expr;
if(!test-expr) v = ve;
这里的最后一条语句就是用条件传送实现的。
不过不是所有的条件表达式都可以用条件传送来编译。比如,如果 then-expr 和 else-expr 这两个表达式中的任意一个可能产生错误条件或者副作用,就会导致非法的行为。作为说明,考虑下面这个 C 函数。
乍一看,这段代码似乎很适合被编译成使用条件传送,如下面的汇编代码所示。
不过,这个实现是非法的,因为即使当测试为假时,movq 指令对 xp 的间接引用还是发生了,从而导致一个间接引用空指针的错误,所以必须用分支代码来编译这段代码。
此外,使用条件传送也不总是会提高代码的效率。例如,如果 expr-expr 和 else-expr 的求值需要大量的计算,则当相应的条件不满足时,这些工作就白费了。编译器必须考虑浪费的计算和由于分支预测错误所造成的性能处罚之间的相对性能。对 GCC 编译器来说,通常只有当两个表达式都很容易计算时,比如表达式分别都只是一条加法指令,它才会使用条件传送。
其中,jmp 指令是无条件跳转,它可以是直接跳转,即跳转目标是作为指令的一部分编码的;也可以是间接跳转,即跳转目标是从寄存器或内存位置中读出的。汇编语言中,直接跳转是给出一个标号(如“.L1”)作为跳转目标的。间接跳转的写法是“*”后面跟一个x86-64 中的寄存器与汇编操作数杂述一节中描述的内存操作数格式中的一种操作数指示符。比如,指令“jmp *%rax”用寄存器 %rax 中的值作为跳转目标,而“jmp *(%rax)”则以 %rax 中的值作为读地址,然后从内存中读出跳转目标。
表中的其他跳转指令都是有条件的——它们根据条件码的某种组合,或者跳转,或者继续执行代码序列中的下一条指令。这些指令的名字和跳转条件与 SET 指令(见汇编指令之条件码)的名字和设置条件是相匹配的,而且有些指令同样拥有同义名。注意,条件跳转只能是直接跳转。
如前所述,汇编器以及后来的链接器,可以根据标号产生跳转目标的适当编码。跳转指令有几种不同的编码,但是最常用的都是 PC 相对的(PC-relative),也就是,它们会将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码。这些地址偏移量可以编码为 1、2 或 4 个字节。第二种编码方式是给出“绝对”地址,即用 4 个字节直接指定目标。
下面代码片段是一个 PC 相对寻址的例子。它包含两个跳转:jmp 指令前向跳转到更高的地址,而 jg 指令后向跳转到较低的地址。
movq %rdi, %rax jmp .L2 .L3: sarq %rax .L2: testq %rax, %rax jg .L3 rep; ret
这里需要了解的是,使用 rep 后跟 ret 的指令组合主要是为了避免使 ret 指令成为条件跳转指令的目标。如果没有 rep 指令,当分支不跳转时,jg 指令会继续到 ret 指令。而根据 AMD 的说法,当 ret 指令通过跳转指令到达时,处理器不能正确预测 ret 指令的目的。因此这里的 rep 指令就是一种空操作,将它作为跳转目的插入,除了能使代码在 AMD 上运行得更快之外,不会改变代码的其他行为。
汇编器产生的“.o”格式的反汇编版本如下:
0: 48 89 f8 mov %rdi, %rax 3: eb 03 jmp 8 <loop+0x8> 5: 48 d1 f8 sar %rax 8: 48 85 c0 test %rax, %rax b: 7f f8 jg 5 <loop+0x5> d: f3 c3 repz retq
可见,jmp 和 jg 指令的跳转目标分别被指明为 0x8 和 0x5。反观指令的字节编码,会看到 jmp 跳转指令的目标编码为 0x03,把它加上下一条指令的地址 0x5,就得到跳转目标地址 0x8。类似地,jg 跳转指令的目标用单字节、补码表示编码为 0xf8(十进制 -8),将其加上下一条指令的地址 0xd(十进制 13),就得到跳转地址 0x5。
下面是链接后的程序反汇编版本:
4004d0: 48 89 f8 mov %rdi, %rax 4004d3: eb 03 jmp 4004d8 <loop+0x8> 4004d5: 48 d1 f8 sar %rax 4004d8: 48 85 c0 test %rax, %rax 4004db: 7f f8 jg 4004d5 <loop+0x5> 4004dd: f3 c3 repz retq
可见,尽管这些指令被重定位到不同的地址,但是 jmp 和 jg 指令中的跳转目标的编码并没有变。通过使用与 PC 相对的跳转目标编码,可使指令编码更为简洁(只需要 2 个字节),而且目标代码可以不做改变就移到内存中不同的位置。
类似的例子说明,当执行 PC 相对寻址时,程序计数器的值是跳转指令后面的那条指令的地址,而不是跳转指令本身的地址。这种惯例可以追溯到早期的实现,当时的处理器会将更新程序计数器作为执行一条指令的第一步。
使用控制的条件转移是实现条件操作的传统方法:当条件满足时,程序沿着一条执行路径执行,不满足时则走另一条路径。这种机制简单而通用,但在现代处理器上,它可能会非常低效。一种替代的策略是使用数据的条件转移。这种方法会计算一个条件操作的两种结果,然后再根据条件是否满足从中选取一个。尽管这种策略只有在一些受限制的情况中才可行,但一旦可行,就可以使用一条简单的条件传送指令来实现它。条件传送指令更符合现代处理器的功能特性。
为了理解为什么基于条件数据传送的代码会比基于条件控制转移的代码性能要好,需要了解现代处理器通过使用流水线(pipelining)来获得高性能。在流水线中,一条指令的处理要经过一系列的阶段,每个阶段执行所需操作的一小部分(例如,从内存取指令、确定指令类型、从内存读数据、执行算术运算、向内存写数据,以及更新程序计数器)。这种方法通过重叠连续指令的步骤来获得高性能,例如,在取一条指令的同时,执行它前面一条指令的算术运算。而要做到这一点,就要求能够事先确定要执行的指令序列,这样才能保持流水线中充满待执行的指令。当机器遇到条件跳转时(也称为“分支”)时,只有当分支条件求值完成后,才能决定分支往哪边走。处理器采用非常精密的分支预测逻辑来猜测每条跳转指令是否会执行。只要它的猜测还比较可靠(现代微处理器设计试图达到 90% 以上的成功率),指令流水线中就会充满着指令。另一方面,如果错误预测一个跳转,就要求处理器丢掉它为该跳转指令后所有指令已做的工作,然后再开始用从正确位置处起始的指令去填充流水线。这样一个错误预测通常会招致很严重的惩罚,浪费大约 15~30 个时钟周期,导致程序性能严重下降。而对于编译出来使用条件传送的代码而言,无论测试的数据是什么,所需的时间都是大约 8 个时钟周期。因为控制流不依赖于数据,所以这使得处理器更容易保持流水线是满的。
下表列举了 x86-64 上一些可用的条件传送指令。
每条指令都有两个操作数:源寄存器或者内存地址 S,和目的寄存器 R。与不同的 SET 和跳转指令一样,这些指令的结果取决于条件码的值。源值可以从内存或者源寄存器中读取,但只有在指定的条件满足时,才会被复制到目的寄存器中。源和目的的值可以是 16 位、32 位或 64 位长,但不支持单字节的条件传送。无条件指令的操作数的长度显示地编码在指令名中(如 movw 和 movl),汇编器可以从目标寄存器的名字推断出条件传送指令的操作数长度,所以对所有的操作数长度,都可以使用同一个的指令名字。
不同于条件跳转,处理器无需预测测试的结果就可以执行条件传送。处理器只是读源值,检查条件码,然后要么更新目的寄存器,要么保持不变。考虑下面的条件表达式和赋值的通用形式:
v = test-expr ? then-expr : else-expr;
如果用条件控制转移的标准方法来编译这个表达式会得到如下形式:
if(!test-expr)
goto false;
v = then-expr;
goto done;
false:
v = else-expr;
done:
/* other */
这段代码结合条件跳转和无条件跳转来保证只有一个 then-expr 序列或者 else-expr 序列执行。
而在基于条件传送的代码中,会对 then-expr 和 else-expr 都求值,最终值的选择基于对 test-expr 的求值。代码描述大致如下:
v = then-expr;
ve = else-expr;
if(!test-expr) v = ve;
这里的最后一条语句就是用条件传送实现的。
不过不是所有的条件表达式都可以用条件传送来编译。比如,如果 then-expr 和 else-expr 这两个表达式中的任意一个可能产生错误条件或者副作用,就会导致非法的行为。作为说明,考虑下面这个 C 函数。
long cread(long *xp){ return (xp ? *xp : 0); }
乍一看,这段代码似乎很适合被编译成使用条件传送,如下面的汇编代码所示。
; long cread(long *xp) ; Invalid implementation of function cread ; xp in register %rdi cread: movq (%rdi), %rax ; v = *xp,间接引用 movl $0, %edx ; Set ve = 0 testq %rdi, %rdi ; Test xp cmove %rdx, %rax ; If xp == 0, v = ve ret ; return v
不过,这个实现是非法的,因为即使当测试为假时,movq 指令对 xp 的间接引用还是发生了,从而导致一个间接引用空指针的错误,所以必须用分支代码来编译这段代码。
此外,使用条件传送也不总是会提高代码的效率。例如,如果 expr-expr 和 else-expr 的求值需要大量的计算,则当相应的条件不满足时,这些工作就白费了。编译器必须考虑浪费的计算和由于分支预测错误所造成的性能处罚之间的相对性能。对 GCC 编译器来说,通常只有当两个表达式都很容易计算时,比如表达式分别都只是一条加法指令,它才会使用条件传送。