针对 Solaris OS, x86 Platform Edition 的汇编语言技术
作者:Paul Lovvik
内容
介绍
很久以前的大学时代我曾学习过汇编语言课程,当时学习的重点是 x86 指令集(当时是 286),所使用的操作系统是 MSDOS。真是时过境迁,现在许多都改变了。处理器的速度越来越快,编译器在生成代码时越来越智能化,软件工程师所编写的程序也越来越大。是软件世界在过去的数年中变化之大,使得应用程序的程序员不需要再考虑汇编语言了吗?答案为是和否。
是,因为公司越来越关注可移植性并寄希望于新的硬件为其提供所需的性能。
否,因为许多企业解决方案性能的判定取决于其性价比,任何的性能优势都将提高利润率。
我发现 Sun 的伙伴仍然在其产品中使用汇编语言以确保热码路径(hot code path)尽可能有效。尽管如今的编译器能够生成高度有效的代码,最后的代码仍常常无法与软件工程师的手写代码相比,因为他知道如何提高每一个微处理器指令的性能。汇编语言仍旧是一个强大的工具,利用它可以进行优化,赋予程序员更大的控制力,明智的使用可以提高性能。汇编语言也可以是一个负担。因为与高级语言相比,汇编语言需要更专业的人才来进行维护,且汇编语言不可移植。
本文将讨论运行于 x86 架构之上的 Solaris 操作系统的汇编语言技术。我不仅要告诉人们如何将汇编语言集成到其项目当中,而且要向人们证明要获得更好的性能,汇编语言并不总是正确的选择。本文将不会介绍 x86 指令集,也不会介绍如何写汇编代码,因为市场上有太多的书介绍这两个主题。
本文中所有的示例代码(个别标明的除外)都可以用 Sun Studio 软件或者 GCC 中的编译器进行编译。
Solaris 操作系统的汇编语言启动模板
当编写汇编语言模块时,有一个启动文件(starter file)将十分有用。该文件应该用于提高软件工程师的编程效率,方法是减少记错用于建立和结束函数堆栈的指令的格式或正确顺序所造成的错误。不是简单的提供我自己的这么一个文件,我将向您证明如何创建您自己的含有所需特性的样本文件。这可以通过使用一个编译器和一小段 C 代码来轻松搞定。
一开始我将创建一个非常简单的 C 程序。我已经选择了一个示例用来说明汇编语言模块的内容以及为什么手写汇编代码会更有效。请参阅“用汇编改善性能”一节,该节讨论了使用手写汇编代码的时机。
我的程序是 HelloWorld 的一个简单的变体:
hello.c
#include <stdio.h>
int main(int argc, char **argv) {
int count = argc;
if (count > 1) {
int index;
for (index = 1; index < count; index++) {
printf("Hello, %s\n", argv[index]);
}
} else {
printf("Hello, world!\n");
}
return (0);
}
我已经扩展了公共的 HelloWorld
实现以通过姓名欢迎每一个人,只要在命令行输入其姓名即可。我可以编译该代码,但是相反,我使用编译器来生成汇编代码:
cc -S hello.c
-S 选项可导致生成一个新文件 hello.s,它含有 C 应用程序的汇编版本。也可以用 GCC 来生成汇编代码,尽管最终的代码会有所不同。我已经向该汇编文件中添加了一些注释,目的是增加其可读性,并便于和原始的 C 代码进行比较。
hello.s
.section .text,"ax"
.align 4
.globl main
.type main,@function
.align 16
main:
pushl %ebp
movl %esp,%ebp
subl $12,%esp
.L93:
/ File hello.c:
/ Line 5
movl 8(%ebp), %eax / argc is referenced by
/ 8(%ebp)
/ %eax is a temp register
/ used for loading a
/ local variable (count)
movl %eax, -8(%ebp) / int count = argc
/ count is referenced by
/ -8(%ebp)
/ Line 6
movl -8(%ebp), %eax / set up for comparison
/ moving count into a
/ temp register
cmpl $1,%eax / compare count to 1
jle .L95 / if count <= 1, goto
/ .L95
.L96:
/ Line 8
movl $1, -12(%ebp) / index is a local
/ variable referenced
/ by -12(%ebp)
/ index = 1 (from the
/ for loop)
movl -12(%ebp), %eax / put index into temp
/ register for a compare
cmpl -8(%ebp),%eax / index < count?
jge .L99 / if not, goto .L99
.L100:
.L97:
/ Line 9
movl -12(%ebp), %edx / calculate the address
/ of argv[index]
movl 12(%ebp), %eax / argv is referenced by
/ 12(%ebp)
movl 0(%eax,%edx,4), %eax / set tmp var to argv +
/ (index * 4)
pushl %eax / Set up for printf call.
/ All arguments are
/ pushed onto the stack
/ in reverse order from
/ the C code. Start with
/ argv[index].
pushl $.L101+0 / push "Hello, %s\n"
call printf / print (uses libc)
addl $8,%esp / Fix up the stack after
/ the function call. All
/ the arguments passed to
/ the function must be
/ removed from the stack.
/ Here, we simply
/ manipulate the stack
/ pointer, which is more
/ efficient.
movl -12(%ebp), %eax / this is the last part
/ of the for loop using a
/ temp register to
incl %eax / increment the index
movl %eax, -12(%ebp) / reassign to the index
/ local variable
movl -12(%ebp), %eax / LOOK HERE! The %eax
/ register already holds
/ the -12(%ebp) value
/ from the line above.
/ This is the comparison
/ part of the for loop.
/ Use a temporary
/ variable to hold index
/ for the compare.
cmpl -8(%ebp),%eax / index < count?
jl .L97 / yes, goto .L97
.L102:
.L99:
jmp .L103
.align 4
.L95:
/ Line 12
pushl $.L104+0 / set up for the other
/ printf call push
/ "Hello, world!\n" onto
/ the stack
call printf
addl $4,%esp / fix up the stack-only 1
/ arg this time
.L103:
/ Line 14
movl $0, -4(%ebp)
jmp .L92
.align 4
.L92:
movl -4(%ebp), %eax / the return value goes
/ in %eax
movl %ebp,%esp / reset the stack pointer
popl %ebp
ret
.size main,.-main
.section .bss,"aw"
Bbss.bss:
.zero 0
.type Bbss.bss,@object
.size Bbss.bss,0
.section .data,"aw"
Ddata.data:
.zero 0
.type Ddata.data,@object
.size Ddata.data,0
.section .rodata,"a"
Drodata.rodata:
.zero 0
.type Drodata.rodata,@object
.size Drodata.rodata,0
.section .rodata1,"a"
.align 4
.L101: / H e l l o ,
.byte 0x48,0x65,0x6c,0x6c,0x6f,0x2c,0x20
/ % s \n
.byte 0x25,0x73,0xa,0x0
.set .,.+1
.type .L101,@object
.size .L101,12
.align 4
.L104: / H e l l o ,
.byte 0x48,0x65,0x6c,0x6c,0x6f,0x2c,0x20
/ w o r l d ! \n
.byte 0x77,0x6f,0x72,0x6c,0x64,0x21,0xa,0x0
.type .L104,@object
.size .L104,15
.type printf,@function
阅读由机器生成的汇编代码并找出提高其效率的方法,这是一个有趣的练习。以上的代码中至少存在两处可以改进的地方。例如,在第73 行(或查找 “LOOK HERE!”),索引变量的内容正被装入 %eax,尽管 %eax 已经含有索引内容。这并不是理想的,但尽管在一个循环中有这种无效率的重复也不能对此应用程序的整体性能有明显的影响。
我使用了这个编译器生成的汇编代码来为所有将出现在本文中的汇编模块创建一个起始点。为了创建此模板,我用 Sun Studio 9 和 GCC 中的编译器多次生成了汇编代码。最后的模板代表了一个合理的最小集。
Start.s
/ This is a template for creating assembly language
/ programs or modules for Solaris x86.
/
/ Before coding, replace all instances of "main" with
/ the name of your function.
/
/ Build using cc
.section .text / code section
.globl main
.type main, @function
.align 16
main: / start of the function
preamble: / the preamble sets up the
/ stack frame
pushl %ebp
movl %esp,%ebp
pushl %ebx
pushl %esi
pushl %edi
/ Function code goes here
finale: / the finale restores the
/ stack
popl %edi / and returns to the caller.
popl %esi
popl %ebx
movl %ebp,%esp
popl %ebp
ret
.size main,.-main
.section .data / contains initialized data
Ddata.data:
.type Ddata.data,@object
.size Ddata.data,0
.section .bss / contains uninitialized
/ data
Bbss.bss:
.type Bbss.bss,@object
.size Bbss.bss,0
该模块的第一部分用于汇编代码。main 标签表示汇编函数的起始点。导言建立了堆栈结构,通过保持
%ebp寄存器的内容,然后配置 %ebp 来指向堆栈的顶部。如果将该模块和一个优化的
C 应用程序连接起来,则必须保持 %ebx,%esi 和%edi 的内容。
最后返回堆栈指针%ebp、%edi、%esi和%ebx寄存器的原始值,然后将控制返回给调用程序。
Ddata.data
部分是存放初始化数据的地方。根据惯例,Ddata.data
部分的数据被看做常量。
Bbss.bss
部分是非常量数据。非常量数据可以是所分配的且将在应用程序的执行过程中被初始化和使用的模块变量。
我已经写了这个模板,使得该函数名将是 "main"。要创建一个含有非
main
函数的模块,只需要执行一次全局搜索,并用您的函数名替代
“main”即可。
此时,就可以用 start.s 模板来手工编写一个简单的汇编程序。在本例中,我将保持函数名为“main”,并将用汇编代码编写该经典的
HelloWorld 程序:
HelloWorld.s
.section .text / code Section
.globl main
.type main, @function
.align 16
main: / start of the function
preamble: / the preamble sets up the
/ stack frame
pushl %ebp
movl %esp,%ebp
pushl %ebx
pushl %esi
pushl %edi
/ Function code goes here
pushl $Hello / prepare for a call to
/ printf by pushing the
/ arguments onto the stack
call printf
addl $4, %esp / fix the stack upon return
/ of printf
movl $0, %eax / set up the return value
movl %eax, -4(%ebp)
finale: / the finale restores the
/ stack
popl %edi / and returns to the caller.
popl %esi
popl %ebx
movl %ebp,%esp
popl %ebp
ret
.size main,.-main
.section .data / contains initialized data
Ddata.data:
.type Ddata.data,@object
Hello:
.string "Hello, world!\n"
.size Ddata.data,0
.section .bss / contains uninitialized
/ data
Bbss.bss:
.type Bbss.bss,@object
.size Bbss.bss,0
经典的 2 行 C 程序有效的转换为 5 行汇编体,加上对汇编语言应用程序的标准包装。
在函数代码部分,"Hello, world!\n"
字符串常量的地址被压入堆栈并调用了 printf
函数。返回值被设置到 %eax 中,最后只要改变堆栈指针并重置一些寄存器的值即可清空该堆栈。
用 cc -o HelloWorld HelloWorld.s 命令编译此应用程序。
在上一个示例中,我选择用汇编语言编写该 main
函数。编写一个与一个 C
应用程序相连接的汇编语言模块也同样容易。在这种情况下,必须改变函数名,并应该提供一个
C 头文件,使得 C 编译器可以自动进行参数检查。
C 应用程序的汇编函数
在最后一节,我提供了一个模板,该模板使开始编写汇编程序或函数变得容易。在本节中,我将说明如何编写 Solaris 操作系统的汇编语言函数。本节强调使用堆栈来读取参数、返回数值以及分配函数局部变量。
堆栈在汇编语言中扮演着核心角色。它用于在函数之间传递数值,并可以用来在函数中创建临时变量。
访问函数参数
用于函数调用的参数按倒序压入出现在 C
代码中的堆栈。这样,C
调用中的第一个参数将会是最接近汇编函数中堆栈顶部的值。其他参数在从堆栈中弹出的次序和
C 代码中的相同。
下面的示例说明了这个观点:
int foo(int a, int b, int c);
...
int result = foo(1, 2, 3);
...
foo:
preamble:
...
popl %eax / %eax = 1
popl %ebx / %ebx = 2
popl %ecx / %ecx = 3
这时少有的为什么要写汇编函数,因为堆栈必须在本质上相同的环境下返回到调用程序。也就是说,%esp 寄存器必须指向和调用之前相同的地址。而且,由于从堆栈弹出数值的效率要低于在堆栈中对它们的引用,popl
指令很少用来读取参数。
相反,该值是用于堆栈中的。这也使多次引用参数值成为可能,而不用创建一个临时变量来保存参数值或者使用一个专用寄存器。可以使用一个
movl 指令将数值读入一个寄存器。
这个示例说明了堆栈中的位置:
...
%ebp + 12 / Second parameter (if present)
%ebp + 8 / First parameter (if present)
%ebp + 4
%ebp + 0 / Caller's %ebp value
%ebp - 4 / Caller's %ebx value
%ebp - 8 / Caller's %esi value
%ebp - 12 / Caller's %edi value
%ebp - 16 / temporary storage
%ebp - 20 / temporary storage
...
注意这个示例有与我在我的 start.s
模板中创建堆栈结构的方法相指定的偏移量。如果模板改变了(通过在导言中的堆栈中压入更多或更少的寄存器),堆栈中的偏移量必须分别改变。
这是一个简单的函数,它添加了三个传递给它的参数的值,并打印结果。
add3_a.s
add3: / start of the function
/ void add3(int a, int b,
/ int c);
preamble: / the preamble sets up the
/ stack frame
pushl %ebp
movl %esp,%ebp
pushl %ebx
pushl %esi
pushl %edi
/ Function code goes here
movl 8(%ebp), %ecx / %ecx represents the sum, set
/ to parameter a
addl 12(%ebp), %ecx / add value of parameter b
addl 16(%ebp), %ecx / add value of parameter c
/ set up for print - calling
/ like this:
/ printf("sum=%d\n", sum);
pushl %ecx / push sum (2nd param) first
pushl $sumString / push the address of the
/ format string
call printf
addl $8, %esp / adjust the stack pointer to
/ account for the 2 parameters
/ pushed on to call printf
finale: / the finale restores the stack
popl %edi / and returns to the caller.
popl %esi
popl %ebx
movl %ebp,%esp
popl %ebp
ret
.size add3,.-add3
.section .data / contains initialized data
Ddata.data:
.type Ddata.data,@object
sumString:
.string "sum=%d\n"
.size Ddata.data,0
对 printf 函数的调用使这个如何读取参数和如何调用一个带参数的函数成为以个好的示例。
返回一个值
当一个函数返回时,其返回值是通过 %eax 传递的。要说明这个观点,我将从以上
add3 示例中移除 printf 调用。不是去打印结果,总和将作为一个返回值传递给调用程序。
add3_b.s
add3: / start of the function
/ int add3(int a, int b,
/ int c);
preamble: / the preamble sets up the
/ stack frame
pushl %ebp
movl %esp,%ebp
pushl %ebx
pushl %esi
pushl %edi
/ Function code goes here
movl 8(%ebp), %ecx / %ecx represents the sum, set
/ to parameter a
addl 12(%ebp), %ecx / add value of parameter b
addl 16(%ebp), %ecx / add value of parameter c
movl %ecx, %eax / set the return value
finale: / the finale restores the
/ stack
popl %edi / and returns to the caller
popl %esi
popl %ebx
movl %ebp,%esp
popl %ebp
ret
通过这个示例(和对如何使用堆栈的解释),可以清楚地看到堆栈的参数值也可以被修改,只要需要的返回值多于一个。
创建一个局部变量
为堆栈分配空间以创建局部变量也是可能的。在这个简短的示例中,我用和以上示例代码中所用的相同的方法来添加参数。但不是使用 %ecx作为总和,我使用堆栈上的空间来达成这个目的。此项技术对于那些比现有寄存器需要更多变量的算法而言,十分有价值。
add3_c.s
add3: / start of the function
/ int add3(int a, int b,
/ int c);
preamble: / the preamble sets up the
/ stack frame
pushl %ebp
movl %esp,%ebp
pushl %ebx
pushl %esi
pushl %edi
/ Function code goes here
movl 8(%ebp), %eax
movl %eax, -16(%ebp) / sum = -16(%ebp),
/ set to parameter a
movl 12(%ebp), %eax
addl %eax, -16(%ebp) / add value of
/ parameter b
movl 16(%ebp), %eax
addl %eax, -16(%ebp) / add value of
/ parameter c
movl -16(%ebp), %eax / Set the return value
finale: / the finale restores the
/ stack and returns to the
popl %edi / caller
popl %esi
popl %ebx
movl %ebp,%esp
popl %ebp
ret
将数据直接从堆栈转移到堆栈的其他部分是不可能的,所以我使用
%eax 寄存器来为 movl 和 addl 指令临时保存参数值。
这些简单示例说明如何用汇编语言调用函数,和如何处理参数、返回值和临时变量。
如果需要模块范围的变量,只需要在其中一个数据部分分配变量空间即可。有趣的是,也可以在其中一个数据部分中分配函数的静态变量。静态变量的作用范围并不局限于汇编级的函数内部,但是 C 语言却可以使静态变量的可访问范围局限于创建它们的函数内部。
内联汇编技术
在最后部分我讨论了如何编写汇编语言函数。上一节的所有代码都是用汇编语言所编写的。有时候在
C
函数中使用一小段汇编语言,而不是用一个汇编函数替代整个的
C 函数,这样更能提高 C
函数的性能。本节将探讨如何准确的实现这一点。
在 C 中,要想在函数中包含汇编语言,则可以使用 __asm
函数。要了解如何将汇编语言片段集成到您的 C
代码中,使得您可以利用函数参数和已创建的任何局部变量,请参阅“
C 应用程序的汇编函数”一节。
作为第一个示例,我说明了如何编写一个使用了内联汇编语言的
C
函数来添加三个整数值并返回结果。正因为选择了这个示例,所以汇编模块代码可以直接和内联汇编代码相比较。
add3_inline_a.c
int add3(int a, int b, int c) {
volatile int result = 0;
__asm("\n\
movl 8(%ebp), %ecx / %ecx is the sum, set to a \n\
addl 12(%ebp), %ecx / add b (the second param) \n\
addl 16(%ebp), %ecx / add c \n\
movl %ecx, -8(%ebp) / move the result into the \n\
/ result variable \n\
");
return (result);
}
本来 %ecx
寄存器是用来对三个参数进行求和的。该寄存器的值然后被设置到代表局部变量
result 的位置,于是它将返回给调用程序。结果变量的修改器 volatile
是必要的,于是编译器不会只是简单的返回 0。volatile
关键字是向编译器的一个暗示,它必须实际读取变量内容,而不依赖于寄存器中缓冲值。
那个示例看上去很直接,看上去可能很像它的此代码比起上面的一些示例更易编写和维护。这是不正确的。该示例只有在非最优化编译的情况下才可正常工作。当编译器生成优化代码时,stackframe
就会用不同的方式创建,当用优化标签对其进行编译时,它将不会工作。问题是依据函数的编译方式,结果变量会放入堆栈的不同偏移量。
经过一些研究,我就可以用 Sun Studio
软件中的编译器对代码进行编译,而不考虑是否启动优化。然而,同样的代码当用
GCC 进行优化编译时,它不能工作。这里就是该新代码:
add3_inline_b.c
int add3(int a, int b, int c) {
volatile int result = 0;
__asm("\n\
movl 8(%ebp), %ecx / %ecx is the sum, set it a \n\
addl 12(%ebp), %ecx / add b (the second param) \n\
addl 16(%ebp), %ecx / add c \n\
movl %ecx, (%esp) / move the result into the \n\
/ result variable \n\
movl %ecx, %eax / also set the return value \n\
/ just in case \n\
");
return (result);
}
不同之处是,现在我是用堆栈指针而不是 %ebp
来引用局部变量“result”。一般来说,用这种方式使用内联汇编的模块有非常特定的关于其编译方式的需求。
在这样一个函数中使用内联汇编确实没有什么意义。在这种情况下,C
代码只建立堆栈结构并返回。用 C
或汇编来编写整个函数将更加有益,避免被绑到单一编译器或者本模块的特定编译器。
在本节的其余部分,我提出了三个汇编方案来解决同一个问题。目的是让人们对多种方案进行了解和比较。如果您的项目需要调用内联汇编的话,我想这有助于您选择最合适的方案。
在本例中,我采用了一组字符串,并返回对应的一组整数,每个整数都表示对应字符串的长度。
getStringLengths.c
int *getStringLengths(int size, char **strings) {
int *lengths = calloc(size, sizeof (int));
int index;
for (index = 0; index < size; index++) {
lengths[index] = strlen(strings[index]);
}
return (lengths);
}
因为我使用了 strlen 函数,我想我可以使用汇编语言改善函数性能,前提是我不必忍受相同的函数调用开销,也不必执行需要内存访问的
jump。这说明我不能只是编写我自己的 strlen 函数的汇编版,反而我需要具有
strlen 函数功能的内联版。
在下面一节中,我介绍了三个不同的方法来实现这个函数。
第一个方法类似于最后一个内联示例,且有与引用局部变量类似的问题。我只是使用
__asm() 来将汇编指令直接包含在代码中。
getStringLengths_inline_a.c
int *asmGetStringLengths(int size, char **strings) {
int *lengths = calloc(size, sizeof (int));
int index;
for (index = 0; index < size; index++) {
__asm("\n\
movl 0(%esp), %edx / index \n\
movl 12(%ebp), %ecx / string array \n\
/address \n\
movl 0(%ecx, %edx, 4), %ecx / string element \n\
/address \n\
\n\
xor %eax, %eax / %eax = 0 (this is \n\
/ the counter) \n\
jmp asm_charCompare \n\
asm_loopStart: \n\
addl $1, %eax \n\
asm_charCompare: \n\
movsbl (%ecx, %eax), %edx \n\
cmpl $0, %edx \n\
jne asm_loopStart \n\
asm_loopEnd: / at this point, \n\
/ %eax contains the \n\
/ length \n\
movl %eax, %ecx / perform the \n\
/ assignment back to \n\
movl 0(%esp), %edx / lengths[index] \n\
movl 4(%esp), %eax \n\
movl %ecx, 0(%eax,%edx,4) \n\
");
}
return (lengths);
}
使用 Sun Studio 软件或 GCC (前提是不使用优化标记)中的编译器,本代码可正确工作。
对于第二个方法,我使用了内联过程调用扩展器。只有 Sun Studio 软件的编译器有此扩展器,所以我这里展示的代码是指定编译器的。
_strlen.il
.inline _strlen,4 / inline function name, size
/ of args
xor %eax, %eax / %eax = 0 (this is the
/ counter)
movl (%esp), %ecx / %ecx = the string to
/ determine the length of
jmp charCompare
loopStart:
addl $1, %eax
charCompare:
movsbl (%ecx, %eax), %edx
cmpl $0, %edx
jne loopStart
loopEnd: / at this point, %eax contains
/ the length
.end
getStringLengths_inline_b.c
extern int _strlen(char *string);
int *getStringLengths(int size, char **strings) {
int *lengths = calloc(size, sizeof (int));
int index;
for (index = 0; index < size; index++) {
lengths[index] = _strlen(strings[index]);
}
return (lengths);
}
注意在 C 代码中,我已经为我的内联函数提供了一个函数原型,且我调用汇编代码如同它是一个函数。实际上,它将不会被看做一个函数调用;汇编代码被插入函数体中。
内联调用扩展器和 asm 方案之间的差别是局部变量的访问方式。利用内联调用控制器,汇编代码所需的局部变量会在汇编代码插入之前被压入堆栈。这就提供了一致的变量访问。仍然可以在汇编代码中访问函数参数,正如上例所示。
_strlen.il
文件并不代表一个完整的汇编模块,所以它不需要数据部分和堆栈结构创建代码。
当使用内联调用扩展器时,保持除 %eax、 %ecx、
和 %edx 外所有寄存器的值是很重要的。返回值被传递给寄存器
%eax 中的函数体,和用于函数返回值的惯例相同。要了解更多有关内联调用扩展器的信息,请参阅
manpage:inline(1)。
在内联调用扩展器方案中您可能会遇到的一个问题是,如果内联代码有标签(label),则相同的内联调用不可像我的示例那样用于一个模块中的多个地方。内联代码无论在何时调用它的时候都会插入最后的汇编代码中,当出现标签重复的错误时,标签可以使汇编程序退出。要处理这个问题的方法是创建额外的模块,使得内联代码在每个模块中最多调用一次,或者只有在您的代码不含标签时使用本方案。
我所提供的最后一个内联汇编的示例使用了扩展的 Asm 和
GCC 编译器。又一次,本方案是指定编译器型的。GCC
已经扩展了 __asm 命令以解决将 C
局部变量和堆栈偏移量相匹配的问题。本接口指的是 "Extended Asm"。
getStringLengths_inline_c.c
int *extasmGetStringLengths(int size, char **strings) {
int *lengths = calloc(size, sizeof (int));
int index;
for (index = 0; index < size; index++) {
__asm__ __volatile__ (" \n\
xor %%eax, %%eax / %%eax = 0 (this is the \n\
/ counter) \n\
jmp extasm_charCompare \n\
extasm_loopStart: \n\
addl $1, %%eax \n\
extasm_charCompare: \n\
movsbl (%1, %%eax), %%edx \n\
cmpl $0, %%edx \n\
jne extasm_loopStart \n\
extasm_loopEnd: / at this point, %%eax \n\
/ contains the length \n\
movl %%eax, %0 \n\
"
: "=r" (lengths[index])
: "c" (strings[index])
: "eax", "edx", "cc");
}
return (lengths);
}
__asm 命令后的指令分别表示输出寄存器、输入寄存器和
clobbered 寄存器。
输出寄存器是一个字母,标明应配置哪个寄存器来接收输出,且前面必须加上“=”号。在本例中,我选择 "r"
代表输出寄存器,它表明该寄存器应该被动态分配。在汇编代码的最后,我必须用以下的代码将结果移入输出寄存器:
movl %%eax, %0
汇编代码引用的第一个分配的寄存器为 %0,第二个为 %1,依此类推。注意在汇编中,我现在必须使用
%% 来引用寄存器。
我所选择的输入寄存器是 %ecx。之所以选用这个寄存器,是因为我可以重用以上两个示例的大多数代码,而几乎不用做什么修改。该寄存器在我的汇编代码执行之前会装载 strings[index]
的值。
clobber list 告诉编译器在执行汇编代码和之后的恢复之前需要保存什么寄存器。在本例中,我使用了 %eax
和 %edx, 我也改变了标记寄存器的状态(指的是条件代码)。GCC 将使用此信息来保存前一个状态,并为指定为动态分配的这些选择合适的非冲突寄存器。
再一次,扩展的 asm 专用于 GCC。我在这里包含它是为了完整性,我的个人方针是在任何可能的时候使我的代码可以在编译器间移植。
使用汇编改善性能
再回到裸机是很令人兴奋的,不是吗?确实,适合于手写汇编代码的情况很少出现。所有的代码都用汇编语言来写,甚至在每一个应用程序中都加入汇编模块,这都是不负责任的。
汇编代码让代码维护人员很难理解。这将增加维护成本和提高故障率。在您的产品中加入大量的汇编例程意味着您需要一个更加专业的维护工程师。否则,他们要么丢下那部分代码不管,要么以性能的损失为代价来对其进行维护。
性能的改善往往是以牺牲代码的可移植性为代价的。对于每一个汇编函数而言,每一个支持的平台都需要有一个独立的实现。这也提高了代码的维护成本。
汇编语言并不是改善应用程序之性能的首选。用手写汇编代码编写一个效率低下的算法并不会有什么明显的帮助。选择解决问题的最快算法,然后如果需要或者想要提高性能的话,用汇编语言对该算法进行重新编码,这都是很重要的。
在本节中,我将说明改善性能的一个流程,根据这个流程可确保要求性能的实现,而在每一种情况下都不使用汇编语言。该流程有如下步骤:
- 用 C 编写代码。
- 对 C 代码进行性能测试,确定它是否足够。
- 尝试其他算法,并再次测试。
- 如果不能获得想要的性能,请使用编译器从您最快的算法生成汇编代码。
- 进行手工优化,或者重写该汇编,并重测试。
- 选择有最佳测试性能的更改。
为了说明整个流程,我使用了一个相对简单的算法,它可能会从汇编中获益。
问题是简单的字符串逆序函数。该函数应该和 memcpy 类似,不同是它以逆序拷贝内存。我的目标是要将该新函数的性能和 memcpy
函数的性能能够不相上下。
性能测试是由我所专门创建的一个简单的测试套件进行的。结果是以
HTML
格式写成,目的是快速判断出最佳性能。该测试套件的代码见附录
A。
一开始,我用简单的 C 代码来实现该字符串逆序函数:
rcopy_c1.c
/*
* Copy the source onto the destination in reverse
* order.
*/
void *
rcopy_c1(void *dest, const void *src, size_t size) {
char *destChar = (char *)dest;
char *srcChar = (char *)src;
int index;
for (index = 0; index < size; index++) {
destChar[size - 1 - index] = srcChar[index];
}
return (dest);
}
该代码相当直接。我使用了一个简单的循环来按顺序来左右移动源内存块,并将其转变到目的内存块上。由于源内存是按照顺序左右移动的,所以目的内存是用逆序编写的。现在我运行该测试套件查看其性能和
memcpy 的性能的比较情况。
下表显示的是第一次测试的结果。
| FUNCTION |
16 KB |
64 KB |
128 KB |
256 KB |
512 KB |
1 MB |
2 MB |
| rcopy_c1 |
227.4 MB/sec |
233.5 MB/sec |
233.6 MB/sec |
228.7 MB/sec |
220.9 MB/sec |
223.9 MB/sec |
224.2 MB/sec |
| Baseline (memcpy) |
3062.2 MB/sec |
1279.4 MB/sec |
1294.7 MB/sec |
999.3 MB/sec |
330.3 MB/sec |
338.5 MB/sec |
341.6 MB/sec |
该代码编译时使用了 cc testharness.c rcopy_c1.c 命令。
这次我感觉没有达到我的目标。这并不意外,因为在大多数操作系统上,memcpy
是一个用汇编语言编写的函数,目的是要从基本的硬件中尽可能的挖掘出所有的性能。
这确实是一个简单的算法,但我仍可以对其进行些许的改进。在我目前只使用了一个索引的地方,我可以反过来使用两个。额外的变量也简化了循环中的算法(要么自增,要么自减,而不是变量相减)。
我也可以不使用含有对源和目的内存块的 char *
的引用的额外的临时变量。和这类似的一些事情是必要的,于是指针算法可以通过使用适当的尺寸来实现。相反,我可以改变该代码以使用
casting,于是临时变量可以被移除。不管那种方法,我不得不给编译器一个暗示以使用
movb 指令而不是 movl。
rcopy_c2.c
void *
rcopy_c2(void *dest, const void *src, size_t size) {
int srcIndex = 0;
int destIndex = size - 1;
while (srcIndex < size) {
((char *)(dest))[destIndex--] =
((char *)(src))[srcIndex++];
}
return (dest);
}
此表显示了第二次测试的结果。
| FUNCTION |
16 KB |
64 KB |
128 KB |
256 KB |
512 KB |
1 MB |
2 MB |
| rcopy_c1 |
226.8 MB/sec |
233.9 MB/sec |
233.9 MB/sec |
228.6 MB/sec |
224.3 MB/sec |
224.0 MB/sec |
223.4 MB/sec |
| rcopy_c2 |
227.1 MB/sec |
234.3 MB/sec |
233.6 MB/sec |
228.8 MB/sec |
223.7 MB/sec |
223.9 MB/sec |
223.6 MB/sec |
| Baseline (memcpy) |
3028.6 MB/sec |
1500.1 MB/sec |
1492.0 MB/sec |
1083.7 MB/sec |
344.1 MB/sec |
352.6 MB/sec |
354.6 MB/sec |
又一次,我这个 C 版本的 rcopy 仍无法和 memcpy
相比。只是为了好玩,请查看函数 rcopy_c2 中的汇编代码。我这里只是给出了与函数相关的代码,并没有生成其他部分。又一次,我添加了一些注释,使人们更容易理解代码的意图。
rcopy_c2.s
rcopy_c2:
pushl %ebp
movl %esp,%ebp
subl $12,%esp
.L109:
/ File rcopy_c2.c:
/ Line 7
movl $0, -8(%ebp) / -8(%ebp) refers to srcIndex
/ Line 8
movl 16(%ebp), %eax / %eax = size
decl %eax / size - 1
movl %eax, -12(%ebp) / -12(%ebp) refers to destIndex
/ Line 10
movl -8(%ebp), %eax / need to use %eax for the
/ compare
cmpl 16(%ebp),%eax / srcIndex < size?
jae .L113 / if not, goto .L113
.L114:
.L111:
/ Line 11
movl 12(%ebp), %eax / %eax = *src
addl -8(%ebp), %eax / %eax = += srcIndex
movl 8(%ebp), %edx / %edx = *dest
addl -12(%ebp), %edx / %edx += destIndex
movsbl 0(%eax), %eax / %eax = contents of address
/ (%eax)
movb %al,0(%edx) / dest[destIndex] = lower bits
/ of %eax
movl -8(%ebp), %eax / prepare for increment
incl %eax / increment the srcIndex
movl %eax, -8(%ebp) / update the incremented value
movl -12(%ebp), %eax / prepare to decrement
/ destIndex
decl %eax / decrement destIndex
movl %eax, -12(%ebp) / update the destIndex variable
movl -8(%ebp), %eax / prepare to compare srcIndex
/ to size
cmpl 16(%ebp),%eax / srcIndex < size?
jb .L111 / if so, goto .L111
.L115:
.L113:
/ Line 13
movl 8(%ebp), %eax / prepare the return value
movl %eax, -4(%ebp)
jmp .L108
.align 4
.L108:
movl -4(%ebp), %eax / return value goes in %eax
/ (it's already there)
movl %ebp,%esp / reset the stack pointer
popl %ebp
ret / return to the caller
我注意到了用于简单计算(如比较,递增和递减)中的许多可将数据转移到寄存器中的指令。我可以用手写汇编代码对此进行改进。下面是逆序拷贝的第三次尝试,它用汇编语言写成。又一次,所有的样板文件都省略了。
rcopy_a3.s
rcopy_a3: / start of the function
preamble: / the preamble sets up the
/ stack frame
pushl %ebp
movl %esp,%ebp
pushl %ebx
pushl %esi
pushl %edi
/ Function code goes here
movl 12(%ebp), %esi / %esi = *src. %esi will
/ *always* be *src.
movl 16(%ebp), %ebx / %ebx will *always* be
/ srcIndex. Move the size
/ into %ebx. We will
/ decrement the srcIndex to
/ traverse the source
/ instead of increment it.
/ This avoids additional
/ register manipulation
/ within the loop.
movl 8(%ebp), %edi / %edi = *dest, %edi will
/ *always* be dest
xor %ecx, %ecx / clear %ecx - this will be
/ destIndex
cmpl $0, %ebx / srcIndex >= 0?
jb endLoop / if not, jump past the loop
beginLoop:
movb -1(%esi, %ebx), %al / move one byte
movb %al, (%edi, %ecx) / dest[destIndex] =
/ src[srcIndex]
decl %ebx / srcIndex--
incl %ecx / destIndex++
cmpl $0, %ebx / srcIndex >= 0?
ja beginLoop / if so, goto beginLoop
endLoop:
movl 8(%ebp), %eax / prepare the return value
finale: / the finale restores the
/ stack and returns to the
popl %edi / caller.
popl %esi
popl %ebx
movl %ebp,%esp
popl %ebp
ret
我已经对代码进行了显著的整理。我已经将重要的索引和地址“粘”到了特定的且在函数执行过程中没有改变的寄存器中。下表显示的是新的结果。
| FUNCTION |
16 KB |
64 KB |
128 KB |
256 KB |
512 KB |
1 MB |
2 MB |
| rcopy_c1 |
227.1 MB/sec |
227.4 MB/sec |
234.0 MB/sec |
228.8 MB/sec |
224.3 MB/sec |
223.5 MB/sec |
224.4 MB/sec |
| rcopy_c2 |
225.7 MB/sec |
234.7 MB/sec |
234.6 MB/sec |
228.9 MB/sec |
224.0 MB/sec |
222.7 MB/sec |
224.1 MB/sec |
| rcopy_a3 |
645.9 MB/sec |
654.0 MB/sec |
650.4 MB/sec |
620.6 MB/sec |
391.4 MB/sec |
386.9 MB/sec |
383.3 MB/sec |
| Baseline (memcpy) |
3026.9 MB/sec |
1391.4 MB/sec |
1340.6 MB/sec |
1044.7 MB/sec |
358.7 MB/sec |
362.3 MB/sec |
360.7 MB/sec |
这些结果就好了许多。对一些拷贝尺寸而言, rcopy_a3 函数的性能甚至击败了
memcpy 函数的性能!
我已经编写了一个在某些尺寸上,性能优于 memcpy 函数的一个
rcopy 函数的版本,并使原始的 C
算法相形见绌。此汇编语言一事已经有了回报。就是这样,对吗?
并不完全。截至目前,我还没有完全利用工具箱中的最强大的工具。编译器并不能自动优化
C
代码。我可以使用编译器的最优化来查看我是否已经用汇编创建了可能的最快算法:
cc -fast testharness.c rcopy_c1.c rcopy_c2.c rcopy_a3.s
再次运行测试套件之后,我得到的结果显示如下:
| FUNCTION |
16 KB |
64 KB |
128 KB |
256 KB |
512 KB |
1 MB |
2 MB |
| rcopy_c1 |
643.2 MB/sec |
546.6 MB/sec |
647.8 MB/sec |
609.9 MB/sec |
389.0 MB/sec |
389.8 MB/sec |
388.4 MB/sec |
| rcopy_c2 |
849.7 MB/sec |
859.5 MB/sec |
864.1 MB/sec |
793.6 MB/sec |
383.5 MB/sec |
390.9 MB/sec |
386.8 MB/sec |
| rcopy_a3 |
619.0 MB/sec |
655.6 MB/sec |
649.8 MB/sec |
613.4 MB/sec |
395.6 MB/sec |
394.1 MB/sec |
391.0 MB/sec |
| Baseline (memcpy) |
2995.7 MB/sec |
1435.0 MB/sec |
1339.5 MB/sec |
1028.8 MB/sec |
352.5 MB/sec |
352.3 MB/sec |
349.5 MB/sec |
在较大的拷贝方面,我的汇编语言函数仍然在很利索的挫败着其对应的
C 函数,但其着眼于较小的拷贝。
对于我的逆序拷贝函数而言,在可证明的最常见的应用程序尺寸方面,经过优化的
C
版本更有效。如果在这些较小的拷贝方面,我的汇编函数不能打败
C 函数,则我不认为值得使用汇编。
进一步的研究正在按顺序进行。如果您将 rcopy_c2 的性能比作其他
rcopy 变体,您会发现在小拷贝尺寸方面,它确实有很好的性能。我将使用该函数作为一个指南来帮助修改我的汇编函数,使其运行速度更快。又一次,我生成了
rcopy_c2 的汇编代码,但这次我使用了最优化。
cc -fast -S rcopy_c2.c
最优代码比起那些手写或者非最优汇编代码而言,阅读起来更困难,但在这种情况下,它并不是不能理解的。
rcopy_c2_optimized.s
rcopy_c2:
push %ebx
push %esi
push %edi
mov 24(%esp),%edi / %edi is size
lea -1(%edi),%edx / %edx is destIndex, set to
/ size -1
xor %ecx,%ecx / srcIndex = 0
test %edi,%edi
jbe .LE0.29
.LP0.30:
mov 20(%esp),%ebx / %ebx is src
mov 16(%esp),%esi / %esi is dest
.LB0.31:
movb (%ebx,%ecx),%al
movb %al,(%esi,%edx)
add $0x1,%ecx / srcIndex++
add $0xffffffff,%edx / destIndex--
.LC0.32:
cmp %edi,%ecx / srcIndex < size?
jb .LB0.31 / if so, repeat the loop
.LX0.33:
.LE0.29:
.CG3.24:
mov 16(%esp),%eax
pop %edi
pop %esi
pop %ebx
ret
完成这些之后,我发现最优代码使用的技巧之一是总是加,而不是递增或递减。我将要将此技巧用于我的汇编代码,对
rcopy_a3 进行如下的改动得到 rcopy_a4。
addl $0xffffffff, %ebx / srcIndex--
addl $0x1, %ecx / destIndex++
再次测试,结果见下表。
| FUNCTION |
16 KB |
64 KB |
128 KB |
256 KB |
512 KB |
1 MB |
2 MB |
| rcopy_c1 |
642.9 MB/sec |
648.4 MB/sec |
651.1 MB/sec |
610.5 MB/sec |
384.4 MB/sec |
375.9 MB/sec |
376.2 MB/sec |
| rcopy_c2 |
839.6 MB/sec |
861.7 MB/sec |
858.7 MB/sec |
780.9 MB/sec |
386.0 MB/sec |
381.1 MB/sec |
379.4 MB/sec |
| rcopy_a3 |
638.9 MB/sec |
656.8 MB/sec |
645.7 MB/sec |
615.6 MB/sec |
377.2 MB/sec |
376.1 MB/sec |
372.2 MB/sec |
| rcopy_a4 |
853.4 MB/sec |
870.6 MB/sec |
863.3 MB/sec |
792.4 MB/sec |
375.5 MB/sec |
374.4 MB/sec |
369.6 MB/sec |
| Baseline (memcpy) |
3078.1 MB/sec |
1519.5 MB/sec |
1500.1 MB/sec |
1061.3 MB/sec |
353.3 MB/sec |
353.5 MB/sec |
352.7 MB/sec |
对于较小的拷贝而言,我的汇编代码较以前速度更快,但慢于所有其他尺寸在 512K 和更大的拷贝。我的代码和最优化的 rcopy_c2
函数之间的其他主要差别是我的汇编按逆序读取源缓冲区,而编译过的版本按顺序读取,按逆序写。如果我在汇编代码中进行该项改变,则两个汇编实现可能会精确的相同。看一下最优化的汇编,我找不出明显的可担保手写汇编方案的低效率。
给定该结果,我认为编写一个我的汇编算法的 C
版本,然后查看最优化的 C
性能是如何比作我的手写汇编函数的,这将十分有趣。
rcopy_c5.c
void *
rcopy_c5(void *dest, const void *src, size_t size) {
int srcIndex = size - 1;
int destIndex = 0;
while (srcIndex >= 0) {
((char *)(dest))[destIndex++] =
((char *)(src))[srcIndex--];
}
return (dest);
}
它和 rcopy_c2.c 之间的惟一不同之处在于,我用逆序读取源,按顺序写入目的。您可以在下表中查看我的新实现的性能。
| FUNCTION |
16 KB |
64 KB |
128 KB |
256 KB |
512 KB |
1 MB |
2 MB |
| rcopy_c1 |
644.6 MB/sec |
595.9 MB/sec |
649.7 MB/sec |
608.5 MB/sec |
377.6 MB/sec |
378.5 MB/sec |
379.4 MB/sec |
| rcopy_c2 |
855.3 MB/sec |
820.9 MB/sec |
850.7 MB/sec |
778.0 MB/sec |
373.9 MB/sec |
369.2 MB/sec |
373.3 MB/sec |
| rcopy_a3 |
638.1 MB/sec |
652.7 MB/sec |
644.3 MB/sec |
608.0 MB/sec |
382.1 MB/sec |
379.1 MB/sec |
380.3 MB/sec |
| rcopy_a4 |
853.3 MB/sec |
867.5 MB/sec |
856.5 MB/sec |
782.4 MB/sec |
382.5 MB/sec |
379.0 MB/sec |
380.0 MB/sec |
| rcopy_c5 |
854.1 MB/sec |
871.3 MB/sec |
861.8 MB/sec |
786.3 MB/sec |
386.1 MB/sec |
378.0 MB/sec |
382.4 MB/sec |
| Baseline (memcpy) |
3116.5 MB/sec |
1363.2 MB/sec |
1379.3 MB/sec |
1026.8 MB/sec |
354.8 MB/sec |
355.3 MB/sec |
357.0 MB/sec |
我也将在我的基于双 CPU AMD Opteron 的工作站(Colfax
国际高端工作站,型号 #691)上进行此项测试以了解性能数字的变化(见下表)。
| FUNCTION |
16 KB |
64 KB |
128 KB |
256 KB |
512 KB |
1 MB |
2 MB |
| rcopy_c1 |
775.3 MB/sec |
746.8 MB/sec |
751.1 MB/sec |
751.6 MB/sec |
746.4 MB/sec |
648.7 MB/sec |
647.4 MB/sec |
| rcopy_c2 |
972.0 MB/sec |
896.8 MB/sec |
898.6 MB/sec |
899.4 MB/sec |
891.1 MB/sec |
705.0 MB/sec |
703.8 MB/sec |
| rcopy_a3 |
690.2 MB/sec |
689.4 MB/sec |
690.2 MB/sec |
690.9 MB/sec |
684.0 MB/sec |
580.1 MB/sec |
569.8 MB/sec |
| rcopy_a4 |
690.3 MB/sec |
692.7 MB/sec |
694.4 MB/sec |
694.4 MB/sec |
687.3 MB/sec |
606.1 MB/sec |
603.9 MB/sec |
| rcopy_c5 |
979.9 MB/sec |
926.8 MB/sec |
928.0 MB/sec |
927.8 MB/sec |
915.8 MB/sec |
710.6 MB/sec |
722.6 MB/sec |
| Baseline (memcpy) |
5201.6 MB/sec |
2398.8 MB/sec |
2400.3 MB/sec |
2407.1 MB/sec |
2159.4 MB/sec |
750.9 MB/sec |
756.6 MB/sec |
在较快的机器上,我的汇编语言的性能实际上是仅有的一个性能糟糕的。实际上,在
128K 情况下,与我的手提电脑和我的 Opteron 工作站间的 rcopy_a4
算法相比,它耗费了的时间增加了 20%。当和 memcpy 性能相比时,它非常有趣,在相同的情况下,其拷贝速度增加了
75%。
将所有这些都加以考虑,我将不得不选择 rcopy_c5
算法。它是最合适的实现,由于其功能的可移植性(其相对性能可能不能移植到所有的架构之上),其易于维护性,和它击败了所有其他算法的事实。在
Opteron 工作站上,性能最低提高 1%,最高提高 3%。Pentium 4
的性能提高范围是,负的 1% 和正的 1%。
所以假设我最后选择了 C
算法,那些写两个汇编语言函数是不是浪费时间呢?不是。要牢记和我的原始代码相比,Pentium
上的性能提高了 46%,Opteron 上的性能提高了
26%。无论我如何达到那里,都是很显著的。
关键点
本练习说明了汇编在应用程序性能中如何扮演一个角色,而不论最终您是否使用您的汇编语言。如果不了解流程中的汇编语言,我就不可能获得这样一个快速的算法。在您追求最终的性能时,请牢记这个练习,请不要在写应用程序时一遇到困难就考虑在其中使用汇编语言。
结束语
对于那些考虑在自己的项目中应用汇编语言的人来说,我已经介绍了许多有趣的信息。如果您需要详细了解汇编语言并对其进行最优化的使用,则我介绍的并不全面,但许多导致关键
takeaway 的练习应该帮助您组织想法,这些想法包括汇编如何可以集成到您自己工作中,以及如何接近该集成以确保您获得您问题的最佳性能的胜利。
我将用一些思想进行总结:
对于编译器可移植性,最好避免使用内联汇编。如果必须使用汇编语言,一定要进行全面的测试以确保该代码支持您所关心的编译器,和支持所需要的编译器标记。注意当最优化被打开时,许多编译器用一个不同的方法建立堆栈结构,该方法可干扰局部变量。
编译器最优化对您的手写汇编代码没有性能的影响。而且,确保您的汇编代码的性能被比作一个最优的
C 语言对等物。
如果汇编用于提高性能,要确保它用于起作用的领域。在用任何一种语言编写最优化函数之前,应该对代码进行概要的勾勒以确保您了解实际的软件瓶颈。不要为一个仅仅是猜测的想法投入太多的精力。
附录 A
源代码清单
关于作者
Paul Lovvik 已经在 Sun 工作六年了,他是某个小组的首席工程师,该小组负责
Market Development Engineering 组织的 Solaris OS, x86 Platform Edition 合作伙伴计划。在过去的几年中,Paul
及其工程小组已经帮助许多合作伙伴在其产品中增加了 Solaris x86 支持。
|