指令指针
在CPU角度来讲,确定下一条指令在何时和何处并不总是容易的任务。随着指令预取缓存技术的发明,很多指令在实际准备好执行之前就被预先载人了处理器缓存。随着乱序引擎技术的发明,很多指令甚至在应用程序中提前执行了,其结果被安排为适当的顺序以便满足应用程序的退役单元的要求。
由于所有这些无秩序的执行方式,确定什么是确切的“下一条指令”可能是困难的。虽然有很多工作在幕后进行,用以提高程序的执行速度,但是处理器仍然需要顺序地单步执行程序逻辑以便生成正确的结果。在这个框架之内,指令指针对于确定程序中执行到了什么位置是至关重要的。
当处理器退役单元执行来自指令的乱序引擎的结果时,指令就被认为执行过了。指令执行之后,指令指针递增到程序代码中的下一条指令。这条指令或许已经由乱序引擎执行过了,或许还没有执行,但是不管是哪一种情况,退役单元都不会处理其结果,直到程序逻辑中应该这样做的时候。
当指令指针在程序指令中移动时,EIP寄存器会递增。记住,指令的长度可能是多个字节,所以指向下一条指令不仅仅是每次使指令指针递增1。
程序不能直接修改指令指针。程序员不具有使用MOV指令直接将EIP寄存器的值改为指向内存中的不同位置的能力。但是,可以利用能够改动指令指针值的指令。这些指令称为分支(branch) 。
分支指令可以改动EIP寄存器的值,要么是无条件改动(无条件分支),要么是按照条件值改动(条件分支)。
无条件分支
程序中遇到无条件分支时,指令指针自动转到另一个位置。可以使用的无条件分支有3种:
- 跳转
- 调用
- 中断
跳转指令使用单一指令码:
jmp location
其中location是要跳转到的内存地址。在汇编语言中,这个位置值被声明为程序代码中的标签。遇到跳转时,指令指针改变为紧跟在标签后面的指令码的内存地址。
JMP指令把指令指针的值改变为JMP指令中指定的内存位置。在幕后,单一汇编跳转指令被汇编为跳转操作码的3种不同类型之一:
- 短跳转
- 近跳转
- 远跳转
这3种跳转类型是由当前指令的内存位置和目的点(“跳转到"的位置)的内存位置之间的距离决定的。依据跳过的字节数目决定使用哪种跳转类型。
当跳转偏移量小于128字节时使用短跳转。
在分段内存模式下,当跳转到另一个段中的指令时使用远跳转。
近跳转用于所有其他跳转。
使用汇编语言助记符指令时,不需要担心跳转的长度。单一跳转指令用于跳转到程序代码中的任何位置。
调用
无条件分支的下一种类型是调用。调用和跳转指令类似,但是它保存发生跳转的位置,井且它具有在需要的时候返回这个位置的能力。在汇编语言程序中实现函数时使用它。
用函数可以编写划分为区域的代码,可以把不同功能分隔为不同的文本段落。如果程序的多个区域使用相同的函数,那么就不需要多次编写相同的代码。可以使用调用语句引用单一函数。
调用指令有两个部分。第一个部分是实际的CALL指令,它需要单一操作数(跳转到的位置的地址):
call address
add ress操作数引用程序中的标签,它被转换为函数中的第一条指令的内存地址。
调用指令的第二部分是返回指令。它使函数可以返回代码的原始部分,就是紧跟在CALL指令后而的位置。返回指令没有操作数,只有助记符R ET 。通过查看堆栈,它知道应该返回到什么位置。
函数如何返回主程序可能是在汇编语言中使用函数时最容易混淆的部分。这个过程不仅仅是在函数的结尾使用RET指令那么简单。实际上,这关系到如何把信息传递给函数以及函数如何读取和存储这些信息。
这些操作是使用堆栈完成的。不仅可以使用PUSH和POP指令引用堆栈中的数据,而且可以使用ESP寄存器直接引用数据,ESP寄存器指向堆栈中的最后一个条目。
函数通常把ESP寄存器复制到EBP寄存器,然后使用EBP寄存器值获得CALL指令之前传递给堆栈的信息,并且为本地数据存储把变量存放在堆栈中。
当执行CALL指令时,返回地址被添加到堆栈中。当被调用的函数开始时,它必须在某个位置存储ESP寄存器,在RET指令试图返回发出调用的程序之前,被调用函数可以从这个存储位置恢复ESP寄存器的原始形式。
因为在函数中也可能对堆栈进行操作,所以EBP经常用作堆栈的基指针。因此,当函数的开始时,通常也把ESP寄存器复制到EBP寄存器。虽然看上去有些棍乱,但是如果创建用于所有函数调用的标准模板,这些操作并不太困难。下面是用于函数的模板形式:
执行CALL指令时,它把EIP寄存器的值存放到堆栈中,然后修改EIP寄存器以指向被调用的函数地址。当被调用的函数完成后,它从堆栈获得过去的EIP寄存器值,并且把控制权返回给原始程序。
function_label:
pushl %ebp
movl %esp, %ebp
< normal function code goes here>
movl %ebp, %esp
popl %ebp
ret
保存了EBP寄存器之后,就可以使用它作为堆栈的基指针,以便在函数中进行对堆栈的所有访问。在返回发出调用的程序之前,ESP寄存器必须被恢复为指向发出调用的内存位置。
中断
无条件分支的第三种类型是中断。中断是处理器”中断”当前指令码路径并且切换到不同路径的方式。中断有两种形式:
- 软件中断
- 硬件中断
硬件设备生成硬件中断。使用硬件中断发出信号,表示硬件层发生的事件(比如I/O端口接收到输入信号时)。程序生成软件中断。它们是把控制交给另一个程序的信号。当一个程序被中断调用时,发出调用的程序暂停,被调用的程序接替它运行。指令指针被转移到被调用的程序,并且从被调用的程序内继续执行。被调用的程序完成时,它可以把控制
返回给发出调用的程序(使用中断返回指令)。
软件中断是操作系统提供的,使应用程序可以使用操作系统内的函数,并且,在某些情况下,甚至可以接触底层的BIOS系统。在Microsoft DOS操作系统中,为很多函数提供了Ox21
软件中断。在Linux领域, Ox80
中断用于提供低级内核函数。
简单地使用带有Ox80值的INT指令把控制转移给Linux系统调用程序。Linux系统调用程序具有很多可以使用的子函数。中断发生时,按照EAX寄存器的值执行子函数。例如,在中断调用Linux系统调用函数exit之前,把值1存放到EAX寄存器中。
条件分支
和无条件分支不同,条件分支不总是被执行。条件分支的结果取决于执行分支时EFLAGS 寄存器的状态。
EFLAGS 寄存器中有很多位,但是条件分支只和其中的5位有关:
- 进位(Carry) 标志(CF) 一—第0位(借位有效位)
- 溢出(Overflow) 标志(OF) -第11 位
- 奇偶校验(Parity) 标志(PF) -第2位
- 符号(Sign) 标志(SF) 一~第7位
- 零(Zero) 标志(ZF) ——第6位
每个条件跳转指令都检查特定的标志位以便确定是否符合进行跳转的条件。使用这5个不同的标志位,可以执行几种跳转组合。
条件跳转指令
条件跳转按照EFLAGS寄存器的当前值来确定是否进行跳转。几种不同的条件跳转指令使用EFLAGS寄存器的不同位。条件跳转指令的格式如下:
jxx address
其中xx是l个到3个字符的条件代码,address是程序要跳转到的位置(通常以标签表示)。
指令 | 描述 | EFLAGS |
---|---|---|
JA | 如果大于(above),则跳转 | CF=0与ZF=0 |
JAE | 如果大于(above) 或等于,则跳转 | CF=O |
JB | 如果小f (below),则跳转 | CF=1 |
JBE | 如果小于(below) 或等于,则跳转 | CF=1或ZF=1 |
JC | 如果进位,则跳转 | CF=1 |
JCXZ | 如果EX寄存器为0,则跳转 | |
JECXZ | 如果ECX寄存器为0,则跳转 | |
JE | 如果相等,则跳转 | ZF=1 |
JG | 如果大于(greater),则跳转 | ZF=0与SF=OF |
JGE | 如果大于(greater) 或等于,则跳转 | SF=OF |
JL | 如果小于(less),则跳转 | SF<>OF |
JLE | 如果小于(less) 或等于,则跳转 | ZF=1或SF<>OF |
JNA | 如果不大于(above),则跳转 | CF=1或ZF=1 |
JNAE | 如果不大于(above) 或等于,则跳转 | CF=1 |
JNB | 如果不小于(below),则跳转 | CF=0 |
JNBE | 如果不小于(below) 或等干,则跳转 | CF=0与ZF=0 |
JNC | 如果无进位,则跳转 | CF=0 |
JNE | 如果不等于,则跳转 | ZF=0 |
JNG | 如果不大于(greater),则跳转 | ZF=1或SF<>OF |
JNGE | 如果不大于(greater) 或等于,则跳转 | SF<>OF |
JNL | 如果不小干(less),则跳转 | SF=OF |
JNLE | 如果不小于(less) 或等于,则跳转 | ZF=0与SF=OF |
JNO | 如果不溢出,则跳转 | OF=0 |
JNP | 如果不奇偶校验,则跳转 | PF=0 |
JNS | 如果无符号,则跳转 | SF=0 |
JNZ | 如果非零,则跳转 | ZF=0 |
JO | 如果溢出,则跳转 | OF=1 |
JP | 如果奇偶校验,则跳转 | PF=1 |
JPE | 如果偶校验,则跳转 | PF=1 |
JPO | 如果奇校验,则跳转 | PF动 |
JS | 如果带符号,则跳转 | SF=1 |
JZ | 如果为零,则跳转 | ZF=1 |
注意到很多条件跳转指令似乎是多余的(比如,如果是above大于,则使用JA进行跳转;如果是greater大于,则使用JG进行跳转)。不同之处在于处理带符号值和无符号值的时候。
对于计算无符号整数值,跳转指令使用above和below关键字。对于带符号整数值,使用greater和less。
在指令码中,条件跳转指令使用单一操作数一一要跳转到的地址。这个操作数常常是汇编语言程序中的一个标签,而在指令码中被转换为偏移地址。条件跳转允许两种跳转类型:
- 短跳转
- 近跳转
短跳转使用8位带符号地址偏移量,而近跳转使用16位或者32位带符号地址偏移量。偏移量值被加到指令指针上。
条件跳转指令不支持分段内存模式下的远跳转。如果在分段内存模式下进行程序设计,就必须使用程序设计逻辑确定条件是否存在,然后实现无条件跳转转移到另一个段中的指令。
为了能够使用条件跳转,在进行跳转之前,必须进行设置EFLAGS寄存器的操作。
比较指令
比较指令是为进行条件跳转而比较两个值的最常见的途径。比较指令的作用就像它的名称表示的,它比较两个值并且相应地设置EFLAGS 寄存器。
CMP指令的格式如下:
cmp operand1 , operand2
CMP指令把第二个操作数和第一个操作数进行比较。在幕后,它对两个操作数执行减法操作(operand2-operand1
) 。
比较指令不会修改这两个操作数,但是如果发生减法操作,就设置
EFLAGS 寄存器。
循环
循环是改变程序内指令路径的另外一种方式。循环可以使用单一循环函数编写重复性任务的代码。循环操作重复地执行,直到满足特定条件。
指令 | 描述 |
---|---|
LOOP | 循环直到ECX寄存器为零 |
LOOPE/LOOPZ | 循环直到ECX寄存器为零,或者没有设置ZF标志 |
LOOPNE/LOOPNZ | 循环直到ECX寄存器为零,或者设置了ZF标志 |
LOOPE/LOOPZ和LOOPNE/LOOPNZ指令提供了监视零标志的附加功能。
这些指令的格式是:
loop address
其中address是要跳转到的程序代码位置的标签名称。不幸的是,循环指令只支持8位偏移量,所以只能进行短跳转。
循环开始之前,必须在ECX寄存器中设置执行迭代的次数值。这通常使用下面这样的代码完成:
< code before the loop>
movl $100, %ecx
loopl:
< code to loop through>
loop loopl
< code after the loop>
要注意循环内部的代码。如果ECX寄存器被修改了,就会影响循环的操作。在循环内实现函数调用时要格外谨慎,因为函数可能很容易地在程序员无意识的情况下破坏ECX寄存器的值。
循环的额外好处在于它们递减ECX寄存器的值,而不影响EFLAGS寄存器的标志位。当ECX寄存器值到达零时,零标志不会被设置。
这里需要注意,如果ECX寄存器的初始值就是0的话,可能会发生灾难性的后果。当执行LOOP指令时,它首先把ECX中的值递减1,然后检查ECX中的值是否为零。使用这个逻辑,如果在LOOP指令之前ECX的值已经为零,LOOP指令会把它递减1,使它成为-1。因为这个值非零,所以LOOP指令继续执行下去,循环回到定义的标签。循环最终会在寄存器溢出时退出,并且显示错误的值。
优化分支指令
分支指令严重地影响了应用程序的性能。大多数现代的处理器(包括IA-32系列的处理器)利用指令预取缓存提高性能。在程序运行时,指令预取缓存被填充上顺序的指令。乱序引擎试图尽可能快地执行指令,即使程序中前面的指令还没有执行。但是,分支指令对乱序引擎有严重的破坏作用。
- 无条件分支
对于无条件分支,不难确定下一条指令,但是根据跳转距离有多远,下一条指令在指令预取缓存中有可能是不存在的。在确定内存中新的指令位置时,乱序引擎必须首先确定指令在预取缓存中是否存在。如果不存在,那么必须清空整个预取缓存,然后从新的位置重新加载指令。这对应用程序的性能而言是代价很高的。 -
条件分支
条件分支给处理器提出了更大的挑战。对于每个条件分支,分支预测单元必须确定是否采用分支。通常,当乱序引擎准备执行条件分支时,没有足够的信息用来确定肯定会采用哪个分支方向。
作为替换的做法,分支预测算法试图猜测特定的条件分支将采用哪条路径。这是使用规则和学习的历史实现的。分支预测算法使用3个主要规则:
- 假设会采用向后分支
- 假设不会采用向前分支
- 以前曾经采用过的分支会再次采用
最后一条规则暗示,执行了多次的分支在多数情况下可能采用相同的路径。分支目标缓冲区(Branch Target Buffer,BTB)跟踪处理器执行的每个分支指令,分支的结果存储在缓冲区区域中。
BTB 信息高于分支的前两个规则。例如,如果第一次遇到分支时,没有采用向后的方向,分支预测单元就会假设任何后续分支都不会采用向后方向,而不是假设会应用向后分支的规则。BTB的问题在于它可能被充满。当BTB 被充满时,查找分支结果会花费更长时间,并且降低执行分支的性能。
优化技巧
- 消除分支(尽可能的消除分支结构,目前几乎所有处理器厂商都提供了特殊的指令来达到这个目的)
-
编写可预测分支的代码
可以利用分支预测单元的规则提高应用程序的性能。把最可能采用的代码安排在向前跳转的顺序执行语句中,会提高需要它时它在指令预取缓存中的可能性。允许跳转指令跳转到使用的可能性低一些的代码段。
对于使用向后分支的代码,要试图使用向后分支路径作为最可能被采用的路径。实现循环时这通常不是问题,但是在某些情况下也许必须改变程序逻辑以便实现这个目的。
- 展开循环
虽然循坏一般都可以通过向后分支规则预测,但是,即使正确地预测了分支,仍然有性能损失。更好的经验规则是尽可能地消除小型循环。
问题出现在循坏的开销上。即使是简单的循环也需要每次迭代时都必须检查的计数器,还有必须计算的跳转指令。根据循环内的程序逻辑指令的数量,这可能是很大的开销。
对于比较小的循环,展开循环能够解决这个问题。展开循环意味着手动地多次编写每条指令的代码,而不是使用循环返回相同的指令。
虽然指令的数量有很大增加,但是处理器能够把所有这些指令都存放到指令预取缓存中,并且顺畅迅速地执行它们。
在展开循环时要小心,因为可能展开过多的指令并且过度地填充预取缓存。这将迫使处理器不断地填充和清空预取缓存。
上述笔记内容学习自 AT&T Professional Assembly Language — Chapter VI