本文由zengl.com站长对
http://pan.baidu.com/share/link?shareid=3717576860&uk=940392313 汇编教程英文版相应章节进行翻译得来。
另外再附加一个英特尔英文手册的共享链接地址:
http://pan.baidu.com/share/link?shareid=2345340326&uk=940392313 (在某些例子中会用到)
本篇翻译对应汇编教程英文原著的第461页到第473页,对应原著第15章 (注意这里的页数不是页脚的页数,而是pdf电子档顶部,分页输入框中的页数,也就是包含了目录,前言部分的总页数,pdf电子档总页数是577,当前已翻译完的页数为473 / 577)。
Optimization Tricks 优化技巧:
上一篇文章里介绍了编译器的很多优化方案,每种优化方案都代表着一种汇编指令的优化技巧,其中,最常用的优化技巧如下:
-
Optimizing calculations 优化表达式的计算
-
Optimizing variables 优化变量
-
Optimizing loops 优化循环体
-
Optimizing conditional branches 优化条件分支
-
Optimizing common subexpressions 优化公共子表达式
要演示这些优化技巧,下面还是采用上一篇文章里的方法,即先生成非优化版的汇编代码,再用gcc的-O3选项生成优化版的汇编代码,通过比较优化版和非优化版的汇编代码,从而理解这些优化技巧。
Optimizing calculations 优化表达式的计算:
当程序需要进行方程运算时,如果计算表达式过于臃肿的话,就会影响程序的执行性能,编译器在对这些计算表达式进行优化时,可以将不必要的步骤给简化掉,从而提高程序的性能。
Calculations without optimization 生成计算程式的非优化的汇编代码:
下面的calctest.c是一个简单的计算程式,它会对a,b,c三个局部变量的值进行简单的计算,并将计算的结果显示出来:
/* calctest.c - An example of pre-calculating values */
#include <stdio.h>
int main()
{
int a = 10;
int b, c;
a = a + 15;
b = a + 200;
c = a + b;
printf("The result is %d\n", c);
return 0;
}
|
我们先用gcc -S命令来生成非优化版的汇编代码:
$ gcc -S calctest.c
$ cat calctest.s
.file "calctest.c"
.section .rodata
.LC0:
.string "The result is %d\n"
.text
.globl main
.type main, @function
main:
leal 4(%esp), %ecx
andl $-16, %esp
pushl -4(%ecx)
pushl %ebp
movl %esp, %ebp
pushl %ecx
subl $20, %esp
movl $10, -12(%ebp)
addl $15, -12(%ebp)
movl -12(%ebp), %eax
addl $200, %eax
movl %eax, -16(%ebp)
movl -16(%ebp), %eax
movl -12(%ebp), %edx
leal (%edx,%eax), %eax
movl %eax, -20(%ebp)
movl $.LC0, %eax
subl $8, %esp
pushl -20(%ebp)
pushl %eax
call printf
addl $16, %esp
movl $0, %eax
movl -4(%ebp), %ecx
leave
leal -4(%ecx), %esp
ret
.size main, .-main
.ident "GCC: (GNU) 4.5.2"
.section .note.GNU-stack,"",@progbits
$
|
上面的输出显示,一开始subl $20, %esp指令将ESP减去20,从而在栈里为局部变量预留出20个字节的空间,a、b、c三个局部变量与对应的栈位置如下表所示:
Program Variable
局部变量 |
Stack Storage Location
栈中对应的存储位置 |
a |
-12(%ebp) |
b |
-16(%ebp) |
c |
-20(%ebp) |
|
上面的汇编代码会依次计算出a,b,c三个变量的值,然后将计算的值存储到对应的栈位置,这里在计算c变量的值时,用的是leal (%edx,%eax), %eax指令,lea指令会将源操作数的内存地址赋值给目标操作数,这里源操作数的内存地址是采用索引的内存寻址方式,可以参考之前
"Moving Data 汇编数据移动 (二)"文章里的
"Using indexed memory locations (使用索引的内存寻址方式)"部分的内容。
索引的寻址方式的汇编表达式格式如下:
base_address(offset_address, index, size) |
要计算出该格式对应的内存地址,可以使用下面的方法:
base_address + offset_address + index * size |
可能有人会说,leal (%edx,%eax), %eax指令的源操作数(%edx,%eax)和上面的base_address(offset_address, index, size)不太一样,其实,(%edx,%eax)里的%edx对应的就是offset_address,%eax对应的就是index,而base_address和size则留空了,base_address留空时,base_address就用0代替,size留空时,size就用1来代替,这样(%edx,%eax)对应的内存地址就是 0 + %edx + %eax * 1 也就是%edx + %eax,这样leal (%edx,%eax), %eax指令执行时,就可以把%edx + %eax的值当作内存地址赋值给%eax,从而计算出a + b的值(a的值已经赋值给了%edx,b的值赋值给了%eax),最后再将计算结果通过movl %eax, -20(%ebp)指令设置到c变量对应的栈位置处。
另外,base_address(offset_address, index, size)格式中,还可以将index给留空,index留空时就用0来代替,所以 (%eax) 对应的内存地址就是:0 + %eax + 0 * 1 即 %eax,因此 (%eax) 就表示EAX寄存器的值所引用的内存位置。同理,-4(%ecx) 对应的内存地址就为:-4 + %ecx + 0 * 1 即 %ecx - 4的内存地址。所有括号引用的内存地址都可以这么来换算出所引用的地址值。
base_address(offset_address, index, size)格式里的offset_address也可以留空,当offset_address留空时就用0来代替,但是,这里要注意的是,如果offset_address留空,则offset_address后面的逗号不能省略,因为如果省略的话,index就会被误认为offset_address了,例如:values(,%edi,4)对应的内存地址就是 values + 0 + %edi * 4 ,其中的values是假设定义过的标签名,标签名会被汇编器自动替换为对应的内存地址值。
之前的
"Moving Data 汇编数据移动 (二)"文章里有关索引的寻址方式部分,没有讲的很详细,对上面提到的省略格式并没有进行详细的介绍,所以在这里进行补充说明。
从上面非优化的汇编代码中,可以看到,每次执行程序时,都会将a、b、c的值重头计算一遍,下面我们看下优化的汇编代码会产生怎样的结果。
Viewing the optimized calculations 查看优化过的汇编代码:
下面我们就用gcc编译器的-O3选项来查看优化时生成的汇编代码:
$ gcc -O3 -S -o calctest2.s calctest.c
$ cat calctest2.s
.file "calctest.c"
.section .rodata.str1.1,"aMS",@progbits,1
.LC0:
.string "The result is %d\n"
.text
.p2align 4,,15
.globl main
.type main, @function
main:
leal 4(%esp), %ecx
andl $-16, %esp
pushl -4(%ecx)
pushl %ebp
movl %esp, %ebp
pushl %ecx
subl $12, %esp
pushl $250
pushl $.LC0
call printf
xorl %eax, %eax
movl -4(%ebp), %ecx
leave
leal -4(%ecx), %esp
ret
.size main, .-main
.ident "GCC: (GNU) 4.5.2"
.section .note.GNU-stack,"",@progbits
$
|
从上面的输出可以看到,经过-O3优化后,生成的汇编代码省略掉了所有的计算过程,直接将结果250压入栈,作为printf函数的参数,将其显示出来。这是因为编译器检测到无论执行多少次,得到的结果都是一个固定的值,也就没必要执行那些繁琐的计算步骤了,从而大大的简化了汇编指令,提高了程序的执行性能,同时又不影响最终的结果,但是只有当a,b,c都为局部变量时才可以被简化掉,如果a,b,c是全局变量则不会被简化掉,下面的优化变量部分会有进一步的说明。由此可以看出,GCC编译器可以将不必要的计算步骤给简化掉,从而达到优化程序性能的目的。
Optimizing variables 优化变量:
在汇编程式里,可以将变量的值存储在以下几个地方:
-
Define variables in memory using the .data or .bss sections.
将变量定义在.data或.bss段,从而可以将变量定义为一个全局变量。
-
Define local variables on the stack using the EBP base pointer.
将变量存储在栈里,即将变量定义为一个局部变量,通过EBP之类的指针寄存器进行访问。
-
Use available registers to hold variable values.
使用寄存器来存储变量的值
下面就通过具体的例子来说明,当变量存储在这几个地方时,对优化的影响。
Using global and local variables without optimizing 使用全局变量与局部变量的非优化版本:
很多C及C++的程序员并不关心全局变量和局部变量对程式的影响,他们只关心如何使用这些变量,但是实际上,变量的定义方式不同,所生成的汇编指令也会有很大的不同,定义不当则有可能会影响到程式的执行性能。
我们通过下面的vartest.c程式来进行说明:
/* vartest.c - An example of defining global and local C variables */
#include <stdio.h>
int global1 = 10;
float global2 = 20.25;
int main()
{
int local1 = 100;
float local2 = 200.25;
int result1 = global1 + local1;
float result2 = global2 + local2;
printf("The results are %d and %f\n", result1, result2);
return 0;
}
|
上面的代码中,定义了2个全局变量(一个整数类型,一个单精度浮点类型),同时定义了4个局部变量(2个整数类型,2个单精度浮点类型),同样先用gcc的-S选项来生成非优化版的汇编代码:
$ gcc -S vartest.c
$ cat vartest.s
.file "vartest.c"
.globl global1
.data
.align 4
.type global1, @object
.size global1, 4
global1:
.long 10
.globl global2
.align 4
.type global2, @object
.size global2, 4
global2:
.long 1101135872
.section .rodata
.LC1:
.string "The results are %d and %f\n"
.text
.globl main
.type main, @function
main:
leal 4(%esp), %ecx
andl $-16, %esp
pushl -4(%ecx)
pushl %ebp
movl %esp, %ebp
pushl %ecx
subl $20, %esp
movl $100, -12(%ebp)
movl $0x43484000, %eax
movl %eax, -16(%ebp)
movl global1, %eax
addl -12(%ebp), %eax
movl %eax, -20(%ebp)
flds global2
fadds -16(%ebp)
fstps -24(%ebp)
flds -24(%ebp)
movl $.LC1, %eax
leal -8(%esp), %esp
fstpl (%esp)
pushl -20(%ebp)
pushl %eax
call printf
addl $16, %esp
movl $0, %eax
movl -4(%ebp), %ecx
leave
leal -4(%ecx), %esp
ret
.size main, .-main
.ident "GCC: (GNU) 4.5.2"
.section .note.GNU-stack,"",@progbits
$
|
由于单精度浮点数20.25在内存里的二进制格式对应的十进制值为1101135872,因此,global2标签处对应的值就为long类型的1101135872,有关浮点数在内存中的二进制格式请参考之前的
"汇编数据处理 (三) 浮点数"文章里的内容。同样的,上面movl $0x43484000, %eax指令中的0x43484000就是200.25在内存里的十六进制值。
上面的汇编代码同样也是按部就班的先给local1和local2两个局部变量赋值,接着用global1与local1相加,结果存储到result1,global2与local2使用fadds浮点运算指令计算出global2 + local2的值,结果存储到-24(%ebp)即result2局部变量里。可以看到,与C程式描述的过程差不多,每步计算过程都没简化。
Global and local variables with optimization 查看全局变量与局部变量优化后的版本:
同样的,我们可以添加-O3选项来生成优化版的汇编代码:
$ gcc -O3 -S -o vartest2.s vartest.c
$ cat vartest2.s
.file "vartest.c"
.section .rodata.str1.1,"aMS",@progbits,1
.LC1:
.string "The results are %d and %f\n"
.text
.p2align 4,,15
.globl main
.type main, @function
main:
leal 4(%esp), %ecx
andl $-16, %esp
pushl -4(%ecx)
pushl %ebp
movl %esp, %ebp
pushl %ecx
subl $12, %esp
flds .LC0
fadds global2
fstpl (%esp)
movl global1, %eax
addl $100, %eax
pushl %eax
pushl $.LC1
call printf
xorl %eax, %eax
movl -4(%ebp), %ecx
leave
leal -4(%ecx), %esp
ret
.size main, .-main
.globl global1
.data
.align 4
.type global1, @object
.size global1, 4
global1:
.long 10
.globl global2
.align 4
.type global2, @object
.size global2, 4
global2:
.long 1101135872
.section .rodata.cst4,"aM",@progbits,4
.align 4
.LC0:
.long 1128808448
.ident "GCC: (GNU) 4.5.2"
.section .note.GNU-stack,"",@progbits
$
|
上面的输出显示,生成的汇编代码简化掉了local2局部变量,直接通过flds .LC0指令将LC0处定义的1128808448即200.25浮点数,加载到ST0浮点寄存器,然后通过fadds global2指令,将ST0里的200.25与global2里的20.25相加,结果存储在ST0里,最后通过fstpl (%esp)指令直接将相加的结果从ST0弹出到ESP栈顶位置,作为稍候的printf函数的第二个参数(越靠后的参数越先压入栈),这样就同时将result2局部变量也给简化掉了。
同理,直接通过movl global1, %eax与addl $100, %eax两条指令计算出printf函数的第一个参数,这里,直接用常量100进行计算,从而简化掉了local1局部变量,同时,addl指令加法运算的结果,也直接通过pushl %eax压入栈,从而为printf函数准备好第一个参数,也就是将result1局部变量也给简化掉了。
所以,可以看到,编译器将local1,local2,result1,result2四个局部变量的相关操作都优化掉了,汇编代码里不再包含局部变量的相关访问操作,取而代之的是,直接将整数常量或浮点数与全局变量进行运算,运算结果也直接压入栈作为printf函数的参数,将结果显示出来。从而简化了汇编指令,提高了程序的执行速度。
同时,还可以看到,编译器并没有将全局变量给优化掉(因为全局变量的值有可能会在别的地方被修改掉,所以每次都要重新读取全局变量的值,当然本例由于在main主函数里,所以不存在这种情况),只优化掉了局部变量,之前的calctest.c例子,由于calctest.c程式里的a、b、c都是局部变量,所以这些变量都被优化掉了,因此,在编写C或C++程式时,应尽量使用局部变量,非必要的全局变量应尽量用局部变量来代替,以便于编译器的优化处理。
Optimizing loops 优化循环体:
循环可以说是一个应用程式里最耗时的部分了,编译器同样可以对循环体的汇编指令进行优化,以提高循环操作的执行速度。
Normal for-next loop code 常规的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=1;i<=3;i++)
{
sum += i;
} |
可以直接展开为:
sum += 1;
sum += 2;
sum += 3; |
这样就避免了循环体的跳转指令,不过仅限于循环次数比较少的情况,以及循环体内的代码量不多的情况。
下面,我们通过具体的例子来看看编译器是如何优化循环体的代码的。
Viewing loop code 查看循环体非优化版的汇编代码:
我们通过下面的sums.c程式来进行说明:
/* sums.c - An example of optimizing for-next loops */
#include <stdio.h>
int sums(int i)
{
int j, sum = 0;
for(j = 1; j <= i; j++)
sum = sum + j;
return sum;
}
int main()
{
int i = 10;
printf("Value: %d Sum: %d\n", i, sums(i));
return 0;
}
|
上面程式里定义了一个sums函数,该函数可以计算出1到给定参数i的和,例如,上面的main主函数将i设为10,然后调用sums(i),则sums(i)就会将1+2+3+4+....+10的总和给计算出来,在计算这个总和时,就用到了for循环体,我们同样先用gcc的-S选项来生成非优化版的汇编代码:
$ gcc -S sums.c
$ cat sums.s
.file "sums.c"
.text
.globl sums
.type sums, @function
sums:
pushl %ebp
movl %esp, %ebp
subl $16, %esp
movl $0, -8(%ebp)
movl $1, -4(%ebp)
jmp .L2
.L3:
movl -4(%ebp), %eax
addl %eax, -8(%ebp)
incl -4(%ebp)
.L2:
movl -4(%ebp), %eax
cmpl 8(%ebp), %eax
jle .L3
movl -8(%ebp), %eax
leave
ret
.size sums, .-sums
.section .rodata
.LC0:
.string "Value: %d Sum: %d\n"
.text
.globl main
.type main, @function
main:
leal 4(%esp), %ecx
andl $-16, %esp
pushl -4(%ecx)
pushl %ebp
movl %esp, %ebp
pushl %ecx
subl $20, %esp
movl $10, -12(%ebp)
pushl -12(%ebp)
call sums
addl $4, %esp
movl $.LC0, %edx
subl $4, %esp
pushl %eax
pushl -12(%ebp)
pushl %edx
call printf
addl $16, %esp
movl $0, %eax
movl -4(%ebp), %ecx
leave
leal -4(%ecx), %esp
ret
.size main, .-main
.ident "GCC: (GNU) 4.5.2"
.section .note.GNU-stack,"",@progbits
$
|
从上面的输出可以看到,sums函数里和循环体相关的汇编代码如下:
movl $0, -8(%ebp)
movl $1, -4(%ebp)
jmp .L2
.L3:
movl -4(%ebp), %eax
addl %eax, -8(%ebp)
incl -4(%ebp)
.L2:
movl -4(%ebp), %eax
cmpl 8(%ebp), %eax
jle .L3
movl -8(%ebp), %eax
leave
ret
|
上面代码中,-4(%ebp)对应局部变量j,-8(%ebp)对应局部变量sum,8(%ebp)对应参数i,可以看到,上面的汇编指令都是对局部变量的栈内存进行的相关操作,例如:addl %eax -8(%ebp)是指将EAX的值和-8(%ebp)即sum的值相加,结果存储到-8(%ebp)里,由于需要对内存进行操作,所以循环体在执行时,存在一个内存访问延时问题,从而会在一定程度上影响程序的执行性能。
下面再看下经过编译器优化后,所生成的汇编代码。
Optimizing the for-next loop 查看for循环体优化版的汇编代码:
我们同样通过-O3选项来进行优化:
$ gcc -O3 -S -o sums2.s sums.c
$ cat sums2.s
.file "sums.c"
.text
.p2align 4,,15
.globl sums
.type sums, @function
sums:
pushl %ebp
movl %esp, %ebp
movl 8(%ebp), %ecx
testl %ecx, %ecx
jle .L4
xorl %eax, %eax
movl $1, %edx
.p2align 4,,15
.L3:
addl %edx, %eax
incl %edx
cmpl %edx, %ecx
jge .L3
popl %ebp
ret
.L4:
xorl %eax, %eax
popl %ebp
ret
.size sums, .-sums
.section .rodata.str1.1,"aMS",@progbits,1
.LC0:
.string "Value: %d Sum: %d\n"
.text
.p2align 4,,15
.globl main
.type main, @function
main:
leal 4(%esp), %ecx
andl $-16, %esp
pushl -4(%ecx)
pushl %ebp
movl %esp, %ebp
pushl %ecx
subl $8, %esp
pushl $55
pushl $10
pushl $.LC0
call printf
xorl %eax, %eax
movl -4(%ebp), %ecx
leave
leal -4(%ecx), %esp
ret
.size main, .-main
.ident "GCC: (GNU) 4.5.2"
.section .note.GNU-stack,"",@progbits
$
|
从上面的输出可以看到,sums函数里和循环体相关的汇编代码如下:
.p2align 4,,15
.L3:
addl %edx, %eax
incl %edx
cmpl %edx, %ecx
jge .L3
popl %ebp
ret
|
.p2align伪指令的含义可以参考:
https://sourceware.org/binutils/docs/as/P2align.html#P2align 该链接里的文章,.p2align后面的第一个参数表示后面的内存地址的对齐字节数,例如上面的.p2align 4,,15第一个参数为4,表示下面的.L3标签处对应的内存地址,需要按照2的4次方即16个字节或16个字节的倍数进行对齐,这样.L3可以位于0,16,32,48 .... 的内存地址。第二个参数为跳过的字节需要被填充的值,该参数可以省略,如果省略则用0填充,有的系统中,如果是代码段,则会用no-op(没有op操作码)的指令来进行填充,如果上面例子里.L3之前的代码的内存地址为5的话,那么.L3从6移动到16的对齐位置时,需要跳过10个字节,那么这10个字节就会被0或no-op指令填充。
.p2align的最后一个参数表示当进行对齐时,如果跳过多少个字节则停止对齐操作,例如上面的.p2align 4,,15的最后一个参数为15,表示当.L3移到到对齐位置时,如果需要跳过的字节数超过15个字节时,则不执行该对齐操作。
另外,上面优化过的汇编代码里,直接将局部变量sum用%eax来代替,将局部变量j用%edx来代替,并将函数参数i的值设置到%ecx里,这样.L3处的循环体在执行时,就直接对EAX,EDX和ECX的寄存器进行操作,不需要进行内存的访问操作,从而有效的提高了循环体的执行效率。
限于篇幅,本章就到这里,下一篇继续介绍其他的优化技术。
OK,就到这里,休息,休息一下 o(∩_∩)o~~