上一篇文章里介绍了编译器的很多优化方案,每种优化方案都代表着一种汇编指令的优化技巧,其中,最常用的优化技巧如下...

    本文由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~~
上下篇

下一篇: 优化汇编指令 (三)

上一篇: 优化汇编指令 (一)

相关文章

汇编中使用文件 (三) 使用文件结束篇

调用汇编模块里的函数 (三) 静态库、共享库、本章结束篇

使用内联汇编 (三) 内联汇编结束篇

汇编流程控制 (三) 流程控制结束篇

优化汇编指令 (一)

Moving Data 汇编数据移动 (四) 结束篇 栈操作