Duplicating High-Level Conditional Branches (用汇编模拟高级语言的条件分支结构): 如果你使用C , C++ , JAVA之类的高级语言进行编程的话,你应该经常会用到这些语言里的条件控制语句,这些...

    本文由zengl.com站长对
    http://pan.baidu.com/share/link?shareid=3717576860&uk=940392313 汇编教程英文版相应章节进行翻译得来。
    另外再附加一个英特尔英文手册的共享链接地址:
    http://pan.baidu.com/share/link?shareid=2345340326&uk=940392313 (在某些例子中会用到)

    本篇翻译对应汇编教程英文原著的第177页到第191页,目前翻译完前6章 (注意这里的页数不是页脚的页数,而是pdf电子档顶部,分页输入框中的页数,也就是包含了目录,前言部分的总页数,pdf电子档总页数是577,当前已翻译完的页数为191/ 577)。

Duplicating High-Level Conditional Branches (用汇编模拟高级语言的条件分支结构):

    如果你使用C , C++ , JAVA之类的高级语言进行编程的话,你应该经常会用到这些语言里的条件控制语句,这些语句看上去和汇编语言完全不同,通过下面的内容,你可以学会如何使用汇编代码来模拟出高级语言中的这些功能。

    要学会如何用汇编来实现高级语言中的那些功能的最好方法就是:反汇编这些高级语言编写的程序,也就是逆向工程。

if statements (if语句):

    高级语言中最常见的条件控制语句就是if语句,下面的ifthen.c例子演示了C语言中if语句的用法:

/* ifthen.c – A sample C if-then program */
#include <stdio.h>
int main()
{
    int a = 100;
    int b = 25;
    if(a > b)
    {
        printf("The higher value is %d\n", a);
    }
    else
        printf("The higher value is %d\n", b);
    return 0;
}

    上面这个C程序通过if....else...结构实现了a与b两个变量的比较操作,如果a大于b则打印a的值,否则就打印b的值,下面通过给gcc编译器提供一个-S的参数就可以得到程序的反汇编代码:

$ gcc -S ifthen.c
$ cat ifthen.s

    .file "ifthen.c"
    .section .rodata
.LC0:
    .string "The higher value is %d\n"
    .text
.globl main
    .type main, @function
main:
    pushl %ebp
    movl %esp, %ebp
    subl $24, %esp
    andl $-16, %esp
    movl $0, %eax
    subl %eax, %esp
    movl $100, -4(%ebp)
    movl $25, -8(%ebp)
    movl -4(%ebp), %eax
    cmpl -8(%ebp), %eax
    jle .L2
    movl -4(%ebp), %eax
    movl %eax, 4(%esp)
    movl $.LC0, (%esp)
    call printf
    jmp .L3
.L2:
    movl -8(%ebp), %eax
    movl %eax, 4(%esp)
    movl $.LC0, (%esp)
    call printf
.L3:
    movl $0, (%esp)
    call exit
    .size main, .-main
    .section .note.GNU-stack,"",@progbits
    .ident "GCC: (GNU) 3.3.2 (Debian)"

    gcc根据-S参数,会将反汇编后的代码输出到ifthen.s文件中,接着就可以通过cat命令将ifthen.s的文件内容给输出显示出来,从上面的输出可以看出来,一段简单的C程序对应这么多的汇编代码,所以演示的C程序例子写的很短,如果写长点,那汇编代码就有的看了,下面我们一步步的分析这些汇编代码。

    下面是代码的第一部分:

pushl %ebp
movl %esp, %ebp
subl $24, %esp
andl $-16, %esp
movl $0, %eax
subl %eax, %esp

    上面的汇编代码中,先将ESP指针传递给EBP寄存器,这样EBP就会作为指针指向程序的局部栈区域,通过EBP加偏移值就可以访问到函数的局部变量和参数,接着通过sub之类的指令手动调整ESP的值,这样就在栈中为函数的局部变量预留出一段空间。

    函数的栈空间准备好后,就通过下面的代码对两个局部变量进行赋值:

movl $100, -4(%ebp)
movl $25, -8(%ebp)

    第一条代码中EBP减4的偏移值对应的栈位置就是C程序中变量a所在的内存位置,通过mov指令将100设置到变量a的内存中,同理第二条代码就是将25设置到变量b的栈内存中,两个变量的内存偏移相差4,是因为a , b变量都是整数类型,而一般一个整数占用的内存空间是4个字节。这种用EBP加偏移值访问局部变量的方式是函数中常用的方式,这种方式将在后面的章节中做详细介绍。

    现在a , b两个变量都被赋了值,是时候执行if语句的操作了:

movl -4(%ebp), %eax
cmpl -8(%ebp), %eax
jle .L2

    上面代码中,先将变量a在栈中的值赋值给EAX寄存器,然后通过cmp指令对EAX和变量b在栈中的值进行比较,当EAX的值即变量a的值小于等于变量b的值时,jle指令就会跳转到.L2位置处,.L2里的代码就是if...else...结构中else部分的代码:

.L2:
    movl -8(%ebp), %eax
    movl %eax, 4(%esp)
    movl $.LC0, (%esp)
    call printf

    上面的代码就是将变量b的值给打印出来,代码中先将EBP减8位置处的值即变量b的值赋值给EAX,然后将EAX设置到ESP加4的位置,该位置是printf函数的第二个参数的位置,接着将.LC0标签的内存地址即"The higher value is %d\n"字符串对应的内存地址作为printf函数的第一个参数,最后call指令调用printf函数,从而将变量b给打印显示出来。

    如果前面的jle指令没发生跳转,即变量a大于变量b,则会执行下面的代码:

movl -4(%ebp), %eax
movl %eax, 4(%esp)
movl $.LC0, (%esp)
call printf
jmp .L3

    上面的代码就是将变量a的值给打印显示出来。printf执行完后,通过jmp .L3跳转到C程序的退出代码的位置:

.L3:
    movl $0, (%esp)
    call exit
    .size main, .-main
    .section .note.GNU-stack,"",@progbits
    .ident "GCC: (GNU) 3.3.2 (Debian)"

    根据上面的分析,你就可以自己通过汇编语言来实现高级语言的if结构了。至于上面汇编跳转时之所以使用jle指令,即根据a小于等于b进行跳转判断,而不是根据if里的a大于b来进行跳转判断,是为了更好的优化分支指令,这些内容在下面的优化部分会提到。

    下面是用汇编代码实现if语句的模板样式:

if:
    <condition to evaluate>
    jxx else ; jump to the else part if the condition is false
    <code to implement the "then" statements>
    jmp end ;jump to the end
else:
    < code to implement the "else" statements>
end:

    上面模板中当if条件判断为false时,就通过jxx指令(jxx中的xx是通配符,可以是jge , jle 等等)跳转到else标签处执行else代码块,如果条件判断为true,则不跳转,直接执行then部分的语句也就是if代码块。

    如果if条件判断部分有多条语句的话,就需要用到多个cmp和jxx指令,如下面这个pascal语言中的if例子:

if (eax < ebx) || (eax == ecx) then
    < then logic code>
else
    < else logic code >

    对应的汇编代码如下:

if:
    cmpl %eax, %ebx
    jg then
    cmpl %eax, %ecx
    jne else
then:
    < then logic code>
    jmp end
else:
    < else logic code >
end:

    上面的汇编代码中通过两个cmp指令和jg , jne指令对if....then之间的逻辑或运算分别作了判断,当EBX大于EAX时,就直接跳到then标签处,否则继续比较EAX和ECX,若EAX和ECX不相等,则jne就跳转到else部分,如果相等则执行then部分。

for loops 用汇编代码模拟高级语言的循环结构:

    下面先看个C语言的例子:

/* for.c – A sample C for program */
#include <stdio.h>
int main()
{
    int i = 0;
    int j;
    for (i = 0; i < 1000; i++)
    {
        j = i * 5;
        printf("The answer is %d\n", j);
    }
    return 0;
}

    上面是C程序中for循环结构的例子,同样进行反汇编输出如下:

$ gcc -S for.c
$ cat for.s

    .file "for.c"
    .section .rodata
.LC0:
    .string "The answer is %d\n"
    .text
.globl main
    .type main, @function
main:
    pushl %ebp
    movl %esp, %ebp
    subl $24, %esp
    andl $-16, %esp
    movl $0, %eax
    subl %eax, %esp
    movl $0, -4(%ebp)
    movl $0, -4(%ebp)
.L2:
    cmpl $999, -4(%ebp)
    jle .L5
    jmp .L3
.L5:
    movl -4(%ebp), %edx
    movl %edx, %eax
    sall $2, %eax
    addl %edx, %eax
    movl %eax, -8(%ebp)
    movl -8(%ebp), %eax
    movl %eax, 4(%esp)
    movl $.LC0, (%esp)
    call printf
    leal -4(%ebp), %eax
    incl (%eax)
    jmp .L2
.L3:
    movl $0, (%esp)
    call exit
    .size main, .-main
    .section .note.GNU-stack,"",@progbits
    .ident "GCC: (GNU) 3.3.2 (Debian)"

$

    类似于前面的if例子,这个for例子首先也会调整EBP和ESP寄存器,让EBP指向栈的起始位置,这样就可以用EBP加偏移值来访问栈中的局部变量和参数,并且调整ESP栈顶指针,从而为函数中的局部变量预留出足够的栈内存空间,for结构的代码开始于.L2标签处:

.L2:
    cmpl $999, -4(%ebp)
    jle .L5
    jmp .L3

    上面的代码片段对应 for (i = 0; i < 1000; i++) 中的i < 1000这个条件判断部分,EBP减4对应变量i的栈内存位置,所以cmp $999,-4(%ebp)就是将变量i和999进行比较,如果变量i小于等于999则jle指令就会跳转到.L5标签处执行for里面的循环体代码,否则就直接jmp跳转到.L3标签处退出循环。

    在上面for条件判断通过的情况下,jle指令就会跳转到.L5的循环体中:

.L5:
    movl -4(%ebp), %edx
    movl %edx, %eax
    sall $2, %eax
    addl %edx, %eax
    movl %eax, -8(%ebp)
    movl -8(%ebp), %eax
    movl %eax, 4(%esp)
    movl $.LC0, (%esp)
    call printf

    循环体中先将EBP减4即变量i的值赋值给EDX寄存器,EDX再拷贝一份到EAX中,接着通过sal算术左移指令将EAX里的值以二进制形式左移两位,相当于将EAX的值乘以4,然后将EDX的值加入到EAX中,因为EDX等于i的值,所以最终EAX里的值就相当于i乘以5,最后将EAX赋值给EBP减8即变量j的栈内存处,再将j的值压入栈作为printf函数的参数,从而将j值打印显示出来。(代码中的sal之类的指令将在以后的章节中进行详细的介绍)

    循环体执行完后,又会回到for语句部分:

leal -4(%ebp), %eax
incl (%eax)
jmp .L2

    上面的代码对应 for (i = 0; i < 1000; i++) 中的 i++ 部分,通过lea指令将变量i的内存地址加载到EAX中,这样EAX就可以引用变量i了(引用的时候EAX要用括号括起来),接着通过inc指令将EAX引用的变量i的值加一,即完成了i++,最后jmp .L2跳转到.L2处,上面说过.L2部分会对i值进行判断,如果i小于1000就继续循环,否则就退出循环。

    通过上面的解析就可以知道如何使用汇编代码来构建一个高级语言的for循环结构,下面是汇编代码实现for结构的一个模板样式:

for:
    <condition to evaluate for loop counter value>
    jxx forcode ; jump to the code of the condition is true
    jmp end ; jump to the end if the condition is false
forcode:
    < for loop code to execute>
    <increment for loop counter>
    jmp for ; go back to the start of the For statement
end:

    for模板样式中,会先进行条件判断,如前面例子中会先对i < 1000进行判断,如果符合条件则通过jxx forcode跳转到for循环体中,否则就jmp end跳出循环,在forcode循环体部分,在执行完< for loop code to execute>循环体中的逻辑代码后,会执行一些计数器的递增之类的操作即<increment for loop counter>部分,例如前面例子中的for(...;....; i++)里面的i++部分,调整完计数器的值后,就jmp for跳转回for结构的开头,继续对计数器的值进行条件判断,就这么周而复始一直循环下去直到计数器到了指定的值或者到达指定的条件就跳出循环。

    在高级语言中还有一种while循环结构,这种结构和for结构很相似,可以使用C语言写一个while的例子,然后反汇编分析其代码,同样可以得到while结构的汇编代码模型出来。

Optimizing Branch Instructions 优化分支指令:

    分支指令对程序的执行性能有很大的影响,前面我们提到过现在大部分的处理器都会先将指令缓存到指令预取缓存中,这样就可以不用每次去读内存了,同时out-of-order无序执行引擎会尽可能的将缓存中的还没执行到的代码预执行或预处理一遍,如果你的汇编代码中没有跳转分支指令的话,指令代码就会在这种缓存和预处理机制下飞快的执行,一旦遇到了分支指令尤其是条件分支指令时,因为分支跳转指令可能会跳转到另外一个位置去执行,之前缓存和预处理的指令就有可能全部作废,然后还要从新的跳转后的位置重新缓存和预处理指令,如果你的程序中分支很多的话,那这种类似的开销就会很大了。

    当然现在的处理器也有一定的措施来应对跳转分支,下面来看下处理器是如何处理分支的,同时讨论如何根据这些原理更好的优化汇编代码。

Branch prediction 分支预测:

    当处理器的无序执行引擎遇到分支指令时,它就会利用一个独立的单元即分支预测前端来预测当前的分支是会跳转还是不跳转,分支预测前端使用了几种方法来进行预测。

Unconditional branches 无条件分支指令预测:

    无条件分支指令并不难预测,只是在长跳转的时候可能会产生一些性能上的损耗,如下图所示:


图1

    上图中当进行长距离的跳转时,跳转后的目标位置里的代码如果不在指令预取缓存中的话,整个预取缓存就会被清空,然后要重新从目标位置读取指令到缓存中,如果是短跳转则指令可能已经在缓存中了,就不需要清空缓存了,所以一般是长跳转会带来一些开销。

Conditional branches 条件分支预测:

    条件分支就不像无条件分支那么好预测了,因为无条件分支指令无论如何都会跳转,目标位置很明确,但是条件分支则是根据指令代码的执行情况来决定是否需要进行跳转的。所以对于条件分支就需要一些分支预测算法。

    分支预测算法主要依照下面三条规则:
  • 向后跳转的条件分支指令被假定默认会发生跳转
  • 向前跳转的条件分支指令被假定默认不会发生跳转
  • 在分支跳转的历史记录中,如果之前该条件分支发生过跳转,
    那么再次遇到这个条件分支时假定依然会进行跳转。
    该条规则会覆盖掉前面两条规则,即如果之前发生过跳转行为的,就不会再去考虑什么向前,向后跳转了
    第一条规则中向后跳转的条件分支指令经常出现在循环结构中,例如下面的代码片段:

    movl $100, $ecx
loop1:
    addl %cx, %eax
    decl %ecx
    jns loop1

    上面的代码中,jns会循环100次都是向后跳转到loop1处,只有最后一次当ECX递减到-1时才会不进行跳转,上面是一个简单的循环结构,从这个例子可以看出来在101次执行jns跳转指令中只有一次不会发生跳转,所以向后跳转指令默认会被假定执行。

    对于向前跳转的条件分支,可以先看下面的代码片段:

    movl -4(%ebp), %eax
    cmpl -8(%ebp), %eax
    jle .L2
    movl -4(%ebp), %eax
    movl %eax, 4(%esp)
    movl $.LC0, (%esp)
    call printf
    jmp .L3
.L2:
    movl -8(%ebp), %eax
    movl %eax, 4(%esp)
    movl $.LC0, (%esp)
    call printf
.L3:

    上面的代码片段看起来很眼熟,其实就是前面C语言的if结构反汇编后的一段代码,jle .L2这条指令只有当if条件判断为false时才会跳转到.L2处即if...else...的else部分,因为一般情况下if部分最有可能会执行,而跳转执行else部分的可能性较低,所以jle .L2这种向前跳转的指令默认会被假定不执行,假定不执行的意思就是在无序执行引擎超前执行到jle指令时,它根据预测结果就不会去预取缓存.L2处的代码,但是如果当处理器真正执行到这条指令,并且实际判断的结果是要跳转的话,才会去临时预取缓存,所以分支预测其实就是个概率分析,分析最有可能的情况,然后判断是否需要提前缓存目标位置里的指令。

    第三条规则会根据条件分支的实际跳转历史来进行分析,存储这些历史记录就用到了Branch Target Buffer (BTB)即分支目标缓冲区,该缓冲区会将条件分支的实际跳转情况给记录下来,当第二次遇到相同的条件分支指令时,就会根据历史情况来假定是否会发生跳转,如果第一次实际上确实发生跳转了,那么第二次无序执行引擎超前执行时就会假定这条指令会跳转,然后就会去进行缓存和预处理操作,前面提到过这条规则会覆盖掉前两条规则,例如某个向后跳转指令,如果第一次实际上没发生跳转,那么第二次无序执行引擎超前执行到这个向后跳转指令时,就会假定它不会发生跳转,也就不会进行预取缓存操作,从而忽略掉第一条向后跳转的规则。

    使用BTB分支目标缓冲区来记录跳转历史有个问题,就是当BTB装满记录后,查询历史记录消耗的时间就会变长,从而在一定程度上会降低条件分支指令的执行性能。

Optimizing tips 优化分支指令的建议:

    理解了处理器对分支指令的处理过程,下面就看下分支指令的优化建议:

Eliminate branches 消除分支指令:

    尽量用一些别的指令来代替分支跳转指令,例如前面的汇编数据移动章节中,介绍过一个CMOV指令,该指令可以根据条件来判断是否需要进行赋值操作,如下面的例子:

movl value, %ecx
cmpl %ebx, %ecx
cmova %ecx, %ebx

    上面的代码中,先用cmp指令来比较EBX和ECX的值,当ECX里的无符号值大于EBX里的无符号值时,CMOVA就会将ECX的值传递到EBX寄存器,这个例子也可以用条件分支跳转指令jbe加mov来实现,但是使用CMOV指令就没有条件跳转指令的那些开销,所以能不用分支跳转指令的地方就尽量不用。完整的cmov例子可以查看前面的Moving Data 汇编数据移动 (二)里的cmovtest.s程序。

    另外,还可以通过调整代码布局将不必要的跳转指令给取消掉,一个典型的例子就是在loop循环中出现的分支指令:

loop:
    cmp data(, %edi, 4), %eax
    je part2
    call function1
    jmp looptest
part2:
    call function2
looptest:
    inc %edi
    cmpl $10, %edi
    jnz loop

    上面代码的意思就是循环判断data数组里的元素,如果元素的值和EAX相等就调用function2函数,否则就调用function1函数,代码中loop里的jmp指令完全可以取消掉,只需将代码做如下调整:

loop:
    cmp data(, %edi, 4), %eax
    je part2
    call function1
    inc %edi
    cmp $10, %edi
    jnz loop
    jmp end
part2:
    call function2
    inc %edi
    cmp $10, %edi
    jnz loop
end:

    这段代码就是将inc %edi到jnz loop的代码在第一个函数调用下面多写了一遍,虽然增加了指令数目,但是取消了循环体内部的jmp跳转,这样每次循环时就不需要执行jmp,就少了相关的分支预测和指令预取操作,也就提高了执行性能。

Code predictable branches first 写汇编代码时充分利用分支预测原理:

    在编写汇编程序时,就要有意识的根据分支预测原理来编写汇编代码,人为将最有可能执行的代码放置到向后跳转指令的目标位置处,将不太会执行的代码放置到向前跳转指令的目标位置处,如下图所示:


图2

    上图中more likely表示最可能执行的代码,less likely表示不太会执行的代码。

Unroll loops 将简单的循环结构展开:

    一些简单的循环结构完全可以将其展开,如下面的例子:

    movl values, %ebx
    movl $1, %edi
loop:
    movl values(, %edi, 4), %eax
    cmp %ebx, %eax
    cmova %eax, %ebx
    inc %edi
    cmp $4, %edi
    jne loop

    上面是个简单的循环,通过3次循环将values数组中前4项的最大值找出来并存储到EBX中,因为循环次数不多,我们可以将其展开如下:

movl values, %ebx
movl $values, %ecx
movl (%ecx), %eax
cmp %ebx, %eax
cmova %eax, %ebx
movl 4(%ecx), %eax
cmp %ebx, %eax
cmova %eax, %ebx
movl 8(%ecx), %eax
cmp %ebx, %eax
cmova %eax, %ebx
movl 12(%ecx), %eax
cmp %ebx, %eax
cmova %eax, %ebx

    上面的指令可以完成同样的功能,虽然指令数是变多了,但是因为没有跳转指令,所以所有的指令都会在指令预取缓存和无序执行引擎中飞快的被执行,没有额外的分支预测等开销,所以对于简单的循环结构可以考虑将其展开,以提高程序的执行性能。

    当然如果展开后指令过多的话,会导致预取缓存被填满,这就会迫使处理器经常的填满和清空预取缓存,这样也会带来不小的开销,所以这种方法只适合简单的循环次数不多的循环结构。

    剩下的就是些总结,限于篇幅就不多说了,下一章开始介绍汇编里数字的数据类型等内容。

    OK,到这里,休息,休息一下 o(∩_∩)o~~
上下篇

下一篇: 汇编数据处理 (一)

上一篇: 汇编流程控制 (二) 条件分支及汇编循环

相关文章

什么是汇编语言(一) 汇编底层原理,指令字节码

使用IA-32平台提供的高级功能 (二)

汇编数据处理 (三) 浮点数

汇编字符串操作 (二) 字符串操作结束篇

调用汇编模块里的函数 (一)

汇编里使用Linux系统调用 (二)