SSE指令集优化心得(二)
1. 背景
内联函数
编译器将一个函数的代码插入到调用者代码中函数被实际调用的地方,这样的函数就是内联函数。内联函数的目的是减少函数调用开销。内联函数一般是内部没有循环、开关语句的小型函数。
函数是一种更高级的抽象。它的引入使得编程者只关心函数的功能和使用方法,而不必关心函数功能的具体实现;函数的引入可以减少程序的目标代码,实现程序代码和数据的共享。但是,函数调用也会带来降低效率的问题,因为调用函数实际上将程序执行顺序转移到函数所存放在内存中某个地址,将函数的程序内容执行完后,再返回到转去执行该函数前的地方。这种转移操作要求在转去前要保护现场并记忆执行的地址,转回后先要恢复现场,并按原来保存地址继续执行。因此,函数调用要有一定的时间和空间方面的开销,于是将影响其效率。特别是对于一些函数体代码不是很大,但又频繁地被调用的函数来讲,解决其效率问题更为重要。引入内联函数实际上就是为了解决这一问题。
在程序编译时,编译器将程序中出现的内联函数的调用表达式用内联函数的函数体来进行替换。显然,这种做法不会产生转去转回的问题,但是由于在编译时将函数体中的代码被替代到程序中,因此会增加目标程序代码量,进而增加空间开销,而在时间代销上不象函数调用时那么大,可见它是牺牲空间来节省函数调用的开销。
内联函数的定义方法(在函数声明的中使用“ inline”关键字,内联函数的定义方法与一般函数一样),如:
//例:定义内联函数
inline int add_int (int x, int y, int z) {
return x+y+z;
}
使用内联函数应注意的事项
• 在内联函数内不允许用循环语句和开关语句。
宏定义与内联函数的比较
• 宏定义不是函数;内联函数本质上是一个函数(一般函数体较简单,没有循环和控制语句,并且内联函数本身不能直接调用自身)。
1/17
• 宏定义和内联函数使用的时候都是进行代码展开。宏定义是在预编译的时候把所有的宏名用宏体来替换(简单的说就是字符串替换);内联函数则是在编译的时候进行代码插入,编译器会在每处调用内联函数的地方直接把内联函数的内容展开,这样可以省去函数的调用的开销,提高效率。
• 宏定义是没有类型检查的(无论对还是错都是直接替换);内联函数在编译的时候会进行类型的检查,内联函数满足函数的性质,比如有返回值、参数列表等
• 函数调用方式不同:普通函数在被调用的时候,系统首先要到函数的入口地址去执行函数体,执行完成之后再回到函数调用的地方继续执行;内联函数不需要寻址,编译器会在每处调用内联函数的地方将调用表达式用内联函数体来替换,这样既避免了函数调用的开销又没有宏机制的缺陷。如果程序中有N次调用了内联函数则会有N次展开函数代码。
• 内联函数有一定的限制,内联函数体要简单,不能包含复杂的结构控制语句。如果内联函数函数体过于复杂,编译器将自动把内联函数当成普通函数来执行。
• 调用时间不同:内联函数在编译时进行替换,普通函数在运行时被调用。
X86的寄存器
x86的通用寄存器有eax、ebx、ecx、edx、edi、esi。这些寄存器在大多数指令中是可以任意使用的。但有些指令限制只能用其中某些寄存器做某种用途,例如除法指令idivl规定被除数在eax寄存器中,edx寄存器必须是0,而除数可以是任何寄存器中。计算结果的商数保存在eax寄存器中(覆盖被除数),余数保存在edx寄存器。
x86的特殊寄存器有ebp、esp、eip、eflags。eip是程序计数器。eflags保存计算过程中产生的标志位,包括进位、溢出、零、负数四个标志位,在x86的文档中这几个标志位分别称为CF、OF、ZF、SF。ebp和esp用于维护函数调用的栈帧。
esp为栈指针,用于指向栈的栈顶(下一个压入栈的活动记录的顶部),而ebp为帧指针,指向当前活动记录的底部。每个函数的每次调用,都有它自己独立的一个栈帧,这个栈帧中维持着所需要的各种信息。寄存器ebp指向当前的栈帧的底部(高地址),寄存器esp指向当前的栈帧的顶部(低地址)。
【注意】ebp指向当前位于系统栈最上边一个栈帧的底部,而不是系统栈的底部。严格说来,“栈帧底部”和“栈底”是不同的概念;esp所指的栈帧顶部和系统栈的顶部是同一个位置。
1. GCC内联汇编语法
【参考】http://www.ibiblio.org/gferg/ldp/GCC-Inline-Assembly-HOWTO.html#s3
https://www.linuxprobe.com/gcc-how-to.html
2/17
https://www.cnblogs.com/orlion/p/5765339.html
什么是内联汇编?
直观来讲,内联汇编就是写成内联函数的汇编程序。为了声明内联汇编函数,我们使用"asm" 关键词,"asm" 可以用作汇编指令和包含它的C程序之间的接口。
X86两种汇编语法
x86汇编的两种语法:intel语法和AT&T语法,在intel的官方文档中使用intel语法,Windows也使用intel语法,而UNIX平台的汇编器一直使用AT&T语法。
1.1AT&T与Intel汇编语法的区别
GCC是用于Linux的GNU C编译器, 它使用的是AT&T/UNIX程序集语法。在这里, 我们将使用AT&T语法的程序集编码,与Intel语法的主要区别如下:
+------------------------------+------------------------------------+
| Intel Code | AT&T Code |
+------------------------------+------------------------------------+
| mov eax,1 | movl $1,%eax |
| mov ebx,0ffh | movl $0xff,%ebx |
| int 80h | int $0x80 |
| mov ebx, eax | movl %eax, %ebx |
| mov eax,[ecx] | movl (%ecx),%eax |
| mov eax,[ebx+3] | movl 3(%ebx),%eax |
| mov eax,[ebx+20h] | movl 0x20(%ebx),%eax |
| add eax,[ebx+ecx*2h] | addl (%ebx,%ecx,0x2),%eax |
| lea eax,[ebx+ecx] | leal (%ebx,%ecx),%eax |
| sub eax,[ebx+ecx*4h-20h] | subl -0x20(%ebx,%ecx,0x4),%eax |
+------------------------------+------------------------------------+
1. 源/目的操作数顺序
Intel 汇编语言的指令与 AT&T的指令操作数的方向上正好相反:在 Intel 语法中,第一个操作数是目的操作数,第二个操作数源操作数。而在 AT&T 中,第一个数是源操作数,第二个数是目的操作数。
2. 寄存器命名
寄存器名称有 "%" 前缀,即如果必须使用"eax",它应该用作 "%eax"。
3. 立即数
AT&T 立即数以 "$"为前缀。静态"C" 变量也使用 "$" 前缀。在 Intel 语法中,十六进制常量以 "h" 为后缀,然而 AT&T 不使用这种语法,这里我们给常量添加前缀 "0x"。所以,对于十六进制,我们首先看到一个"$",然后是 "0x",最后才是常量。
4. 存储器操作数
在 Intel 语法中, 间接内存引用为"section:[base+ index*scale + disp]",在 AT&T中变为 "section:disp(base, index, scale)"。其中index/scale/disp/segreg全部是可选的, 完全可以简化掉。如果没有指定 scale 而指定了 index,则 scale 的缺省值为 1。 segreg 段
3/17
寄存器依赖于指令以及应用程序是运行在实模式还是保护模式下,在实模式下,它依赖于指令,而在保护模式下, segreg 是多余的。【注意】在 AT&T 中,当立即数用在 scale/disp 中时, 不应当在其前冠以“$”前缀, 而且 scale,disp 不需要加前缀“&”。
在 Intel 中基地址使用“[”和“]”,而在 AT&T 中则使用“(”和“)。
+-------------------------------------------+--------------------------------------------+
| Intel Code | AT&T Code |
+-------------------------------------------+--------------------------------------------+
| Instr foo,segreg: [base+index*scale+disp]|instr %segreg: disp(base,index,scale),foo |
| [eax] | (%eax) |
| [eax + _variable] | _variable(%eax) |
| [eax*4 + _array] | _array(,%eax,4) |
| [ebx + eax*8 +_array] |_array(%ebx,%eax,8) |
+-------------------------------------------+--------------------------------------------+
1. 标识长度的操作码前缀和后缀
在 AT&T 汇编中远程跳转指令和子过程调用指令的操作码使用前缀“l”,分别为 ljmp,lcall,与之相应的返回指令伪 lret。例如:
+---------------------------------------+----------------------------------------+
| Intel Code | AT&T Code |
+---------------------------------------+----------------------------------------+
| LL SECTION:OFFSET | lcall secion: offset |
| FAR SECTION:OFFSET | ljmp secion: offset |
| FAR STACK_ADJUST | lret $stack_adjust |
+---------------------------------------+----------------------------------------+
在 AT&T 语法中,存储器操作数的大小取决于操作码名字的最后一个字符。操作码后缀 ’b’ 、’w’、’l’ 分别指明了字节(8位)、字(16位)、长型(32位)存储器引用。Intel 语法通过给存储器操作数添加 "byteptr"、 "word ptr" 和 "dword ptr" 前缀来实现这一功能。
+---------------------------------------+----------------------------------------+
| Intel Code | AT&T Code |
+---------------------------------------+----------------------------------------+
| Mov al,bl | movb %bl,%al |
| Mov ax,bx | movw%bx,%ax |
| Mov eax,ebx | movl%ebx,%eax |
| Mov eax, dword ptr[ebx] | movl(%ebx),%eax |
| Mov al, byte ptrfoo | movbfoo, %al |
+---------------------------------------+----------------------------------------+
2.2 基本内联汇编
基本内联汇编的格式非常直接了当。它的基本格式为:
asm(" 汇编代码 ");
4/17
示例:
asm("movl %ecx %eax"); /* 将 ecx 寄存器的内容移至 eax */
__asm__("movb %bh (%eax)"); /* 将 bh 的一个字节数据 移至 eax 寄存器指向的内存 */
使用 "asm" 和 "__asm__"都是有效的。如果关键词 "asm" 和我们程序的一些标识符冲突了,我们可以使用 "__asm__"。如果我们的指令多于一条,我们可以每个一行,并用双引号圈起,同时为每条指令添加‘/n‘ 和 ‘/t‘ 后缀。这是因为 gcc 将每一条当作字符串发送给as(GAS)(LCTT译注: GAS 即 GNU 汇编器),并且通过使用换行符/制表符发送正确格式化后的行给汇编器。
示例:
__asm__ ("movl %eax, %ebx/n/t"
"movl $56, %esi/n/t"
"movl %ecx, $label(%edx,%ebx,$4)/n/t"
"movb %ah, (%ebx)");
如果在代码中,我们改变了一些寄存器的值,但在没有恢复这些值的情况下从汇编中返回,这将会导致一些问题,特别是当编译器做了某些优化,因为 GCC 并不知道寄存器内容的变化,当退出改变了寄存器值的内联汇编后,寄存器的值不会保存到相应的变量或内存空间。这是为什么我们需要一些扩展功能,扩展汇编给我们提供了那些功能。
2.3 扩展汇编
在基本内联汇编中,我们只有指令。然而在扩展汇编中,我们可以同时指定操作数。它允许我们指定输入寄存器、输出寄存器以及修饰寄存器列表。GCC 不强制用户必须指定使用的寄存器。我们可以把头疼的事留给 GCC ,这可以更好地适应 GCC 的优化。基本格式为:
asm ( 汇编程序模板
: 输出操作数 /* 可选的 */
: 输入操作数 /* 可选的 */
: 修饰寄存器列表 /* 可选的 */
);
汇编程序模板由汇编指令组成。每一个操作数由一个操作数约束字符串所描述,其后紧接一个括弧括起的 C 表达式。冒号用于将汇编程序模板和第一个输出操作数分开,另一个(冒号)用于将最后一个输出操作数和第一个输入操作数分开(如果存在的话)。逗号用于分离每一个组内的操作数。总操作数的数目限制在 10 个,或者机器描述中的任何指令格式中的最大操作数数目,以较大者为准。如果没有输出操作数但存在输入操作数,你必须将两个连续的冒号放置于输出操作数原本会放置的地方周围。
示例:
asm ("cld/n/t"
"rep/n/t"
"stosl"
: /* 无输出寄存器 */
: "c" (count), "a" (fill_value), "D" (dest)
: "%ecx", "%edi"
);
5/17