译:GCC内联汇编入门

原文: GCC-Inline-Assembly-HOWTO

1. 简介(Introduction.)

1.1 Copyright and License.

Copyright (C)2017 桂糊涂
Copyright (C)2003 Sandeep S.

This document is free; you can redistribute and/or modify this under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version.

This document is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.

1.2 反馈(略)

1.3 背景(略)

希望将Windows项目BWAPI移植mac/linux时,遇到Visual C内联汇编迁移到GCC的问题,于是研习此文并译之。

2. 概览(Overview of the whole thing.)

  • 我们在此学习GCC内联汇编。内联是什么?

我们可以指导编译器将函数的代码直接插入调用的位置,这类函数叫做内联函数。听起来像是宏?事实上还真挺像。

  • 内联函数有什么好处?

内联的方法降低了函数调用的问题。而且如果任何参数是常量的话,在编译器将得到明显优化,而不是所有的内联函数代码都被包含。代码量会更少,取决于具体的情况。为了定义内联函数,我们使用关键字inline声明。

  • 什么是内联汇编?

内联汇编是写在内联函数中的汇编过程(assembly routines)。它非常方便、快速,在系统编程中非常有用。我们主要关注学习GCC内联汇编函数的基础格式和用法。要声明内联汇编函数,我们使用关键字asm

内联汇编很重要,因为有能力操作并输出到C变量中。因为这些能力,asm作为了C和汇编指令间的接口。

3、GCC汇编语法(GCC Assembler Syntax.)

GCC使用AT&T/UNIX汇编语法。其与Intel语法区别较大,主要区别有:

3.1. 源-目标顺序(Source-Destination Ordering)

Intel:Op-code dst src

AT&T:Op-code src dst

3.2. 寄存次命名(Registry Naming)

%为前缀,如:使用eax写作%eax

3.3. 立即操作数(Immediate Operands)

AT&T立即操作数以$开头,对staic “C”变量也前置$。16进制常量,Intel语法后缀h,AT&T前缀0x。所以对于16进制数,我们会先看到$,然后是0x,最后是常量。

3.4. 操作数大小(Operand Size)

译注:操作数(operand),很多情况下指操作对象,即寄存器或内存地址。

AT&T语法中操作数大小取决于操作码最后一个字符。操作码后缀b,w,l 对应 byte(8-bit), word(16-bit), 和 long(32-bit)。Intel语法中,通过在操作数(非操作码)前缀 byte ptr, word ptr, 和 dword ptr 实现该功能。

因此, Intel 之 mov al, byte ptr foomovb foo, %al 于 AT&T.

3.5. 内存操作数(Memory Operands)

Intel语法中基址寄存器(The base register)内于[]之间,而AT&T于() 之间。此外,间接内存引用(indirect memory reference)Intel风格为

section:[base + index*scale + disp] ,改变为

section:disp(base, index, scale)于 AT&T.

需指出,当常量使用disp/scale,$ 无需前置。

以上是Intel于AT&T语法的主要区别,完整信息请参加GNU Assembler documentations。以下一些例子有助于我们更好的理解:

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

4. 内联基础(Basic Inline.)

内联汇编的基本形式

asm("assembly code");

例:

asm("movl %ecx %eax"); /* moves the contents of ecx to eax */
__asm__("movb %bh (%eax)"); /*moves the byte from bh to the memory pointed by eax */

asm__asm__都是合法的,如asm与你的程序冲突,你可以使用__asm__。如果有多行代码,我们将每一个使用"包含,并后缀\n\t。因gcc将每行作为一个stringas(GAS),通过换行/tab我们可以发送正确的格式给汇编器(assembler)。

例:

__asm__ ("movl %eax, %ebx\n\t"
         "movl $56, %esi\n\t"
         "movl %ecx, $label(%edx,%ebx,$4)\n\t"
         "movb %ah, (%ebx)");

如果我们的代码触及(touch)(如,改变内容)一些寄存器,而后不修复这些改变直接从asm返回的话,一些不好的事就会发生。这是因为GCC不知道对寄存器内容的改变,而这将我们带向问题,又起当编译器进行了某些优化的时候。它将假设一些寄存器包含了一些变量的值,而我们已经改变了没有告知GCC, 然后它继续执行就像什么也没发生一样。我们可以做的是使用一些没有副作用的指令,或者在我们退出前修复问题,或者等待崩溃。这就是我们想要一些扩展功能性(functionality)的地方。扩展asm(Extended asm)提供了我们这种功能性。

5. 扩展Asm(Extended Asm.)

基本汇编中我们只有指令。在扩展汇编中,我们可以指定操作对象(operand)。它允许我们指定输入寄存器,输出寄存器及一列受影响(clobbered)寄存器。它不是mandatory to指定寄存器使用,我们可以将麻烦留给GCC而GCC有可能(probably)更好的适配GCC的优化机制。反正(Anyway)基本形式如下:

asm ( assembler template
    : output operands /* optional */
    : input operands /* optional */
    : list of clobbered registers /* optional */
    );

汇编模板(assembler template)由汇编指令构成。每个操作数(operand)
描述为一个操作限制符(operand-constraint string),followed by the C expression in 括号。冒号分割汇编模板、输出操作数组、输入操作数组、clobbered寄存器组。逗号分割每个组内的操作数。操作数总数限制在10个或the maximum number of operands in any instruction pattern in the machine description,whichever is greater.

如果没有输出操作数但有输入操作数,你必须放两个连续冒号。

例如:

asm ("cld\n\t"
     "rep\n\t"
     "stosl"
     : /* no output registers */
     : "c" (count), "a" (fill_value), "D" (dest)
     : "%ecx", "%edi"
     );

以上代码是什么作用? The above inline fills the fill_value count times to the location pointed to by the register edi. 它也同时告诉gcc, 寄存器 eax and edi 的内容不再有效. 让我们看看另一个例子来更好的理解:

int a=10, b;
asm ("movl %1, %%eax; movl %%eax, %0;"
    :"=r"(b) /* output */
    :"r"(a) /* input */
    :"%eax" /* clobbered register */
    );

这里我们使用汇编指令让b的值等于a的值。有趣的点是:

b 是 output operand, referred to by %0a 是 input operand, referred to by %1.
r is 限制(constraints)对于 operands. 我们后面会详细讨论“限制”. 此时, r 告诉 GCC 使用任意register来储存操作数。输出操作数限制应该有一个限时修饰符=。这个修饰符意味着它是一个输出操作数且是只写的(write-only)。

在寄存器名称前出现了两个%。这帮助GCC来区分操作数和寄存器。操作数有一个单独的%作为前缀。

受影响(clobbered)寄存器%eax在第三个冒号之后,告诉GCC %eax的值已在asm内被修改,所以GCC不会使用这个寄存器去保存其他的值。

asm执行结束后,b将反射更新后的值,因为它被指定为一个输出操作数。另一方面,asm内部对b的改变应该(is supposed to)在asm外部被反射.

现在我们详细的看一下每一个区域。

5.1 汇编模板(Assembler Template).

汇编模板包含一组嵌入到C程序中的指令。格式类似:或者每个指令包围在双引号中,或整组指令包含在双引号中。每个指令也应该以一个分隔符结束。合法的分隔符可以是\n;\n可以跟随一个\t。C表达式的操作数呈现为 %0, %1 ...等。

5.2 操作数(Operands).

C expressions serve as operands for the assembly instructions inside "asm". 每个操作数首先写作一个双引号内的操作数限制符(operand constraint)。 对于输出操作数, 引号内还有一个限制修饰符, 然后跟随操作数对应的 C 表达式 。 即,

"constraint" (C expression) 乃通用形式。对输出操作数会有一个额外的修饰符。限制符(constraint)主要用于决定操作数的地址模式。他们也被用于指定要使用的寄存器。

如我们使用超过一个操作数,以逗号,分隔。

在汇编模板中,每个操作数按数字被引用。数字按如下规则排列。如果有n个操作数(包括输入、输出),那么第一个输出操作数是数字0,连续增加,最后一个输入操作数是数字n-1。最大操作数数量如上一段所述。

输出操作数表达式必须是lvalues(32-bit)。输入操作数无此限制。他们必须是表达式。扩展汇编功能是最常用于编译器自身不知晓的机器指令;-)。如果输出表达式无法被直接寻址(addressed)(比如,它是一个bit-field),我们限制符必须“允许”(allow)一个寄存器。在那种情况下,GCC将使用该寄存器为asm的输出,然后将寄存器内容存储到输出。

如上所述,原始输出操作数必须是只写的;GCC将假设那个操作对象中的值在指令前已失效且无需生成。扩展汇编也支持“输入-输出”或“读-写”操作数。

我们现在看一些例子。我们希望将一个数乘以5。对此我们使用lea指令。

asm ("leal (%1,%1,4), %0"
    : "=r" (five_times_x)
    : "r" (x)
    );

此处我们的输入是x。我们没有指定使用哪个寄存器。GCC会为输入选择一些寄存器用来输入,一个用来输出,执行我们的要求。如果我们希望输入和输出放在(reside)同一个寄存器中,我们可以让GCC来实现。这里我们使用那种"读-写"操作数,通过指定合适的限制符,这里我们来实现它:

asm ("leal (%0,%0,4), %0"
    : "=r" (five_times_x)
    : "0" (x)
    );

现在输入和输出操作数在同一个寄存器内了。但我们不知道是哪个寄存器。现在如果我们也想要指定,有一个办法:

asm ("leal (%%ecx,%%ecx,4), %%ecx"
    : "=c" (x)
    : "c" (x)
    );

以上三个例子中,我们没有把任何一个寄存器放在受影响列表中。为什么?前两个例子中,GCC决定使用哪个寄存器,因此知道发生了什么改变。在最后一个中,我们不需要将ecx放在受影响列表中,gcc知道它会放入x中。因为它可以知道ecx的值,它不会被视为受影响的。

5.3 受影响列表(Clobber List.)

一些指令会影响一些硬件寄存器。我们必须在受影响列表中列出那些寄存器,即asm函数第三个:后的区域。这用于指示gcc我们将使用并修改它们。所以gcc将补不回假设它加载到这些寄存器中的值是合法的。我们不应该列出输入和输出寄存器。因为gcc知道asm使用它们(因为它们被明确指定为限制符(constraints))。如果指令使用了任何其他寄存器,显式或隐式的(并且这些寄存器没有出现在输入和输出列表上),那么那些寄存器必须在受影响列表中指定。

如果我们的指令可以修改条件码寄存器(the condition code register),我们必须增加cc到受影响寄存器列表。

如果我们的指令用一个不可预期的方法(fashion)修改了内存,添加memory到受影响寄存器。这会使GCC在汇编指令期间不在寄存器内保持内存值的缓存。我们也必须添加volatile关键字,如果内存影响(memory affected)未列在asm的输入和输出中。

我们可以读写受影响寄存器任意多次。注意模板中乘法指令的例子;它假设子过程(subroutine) _foo 接受eaxecx寄存器中的参数。

asm ("movl %0,%%eax; movl %1,%%ecx; call _foo"
    : /* no outputs */
    : "g" (from), "g" (to)
    : "eax", "ecx"
    );

5.4 Volatile ...? (不稳定的...?)

如果你熟悉内核源码或者一些类似的优美代码,你必然已见过很多函数声明为volatile__volatile__,跟随在__asm__之后。我之前提到过关于关键字asm__asm__。所以什么是volatile

如果我们的汇编语句必须在我们放置它的地方执行,(即,必须不被作为一个优化而移出循环),则将volatile放在asm之后。所以防止它被移动、删除和任何改变,我们如此声明asm volatile(... : ... : ... : ...); 当我们必须非常小心时,使用__volatile__

如果我们的汇编只是做一些计算而没有任何副作用,最好不要使用volatile关键字。忽略它将帮助GCC优化代码使其更优美。

在“一些有用的代码”小节,我已经提供了很多内联汇编函数的例子。我们可以详细了解受影响列表。

6. 详解限制符(More about constraints.)

此时,你可能已经理解限制符必须要做很多的事。但关于限制符我们说的很少。限制符可以说出操作数是否可能是一个寄存器,及哪类寄存器;操作数是否可以是一个内存引用,及哪一类地址;操作数是否可能是一个立即常量,及它可以有哪些可能的值(即值的范围)...等。

6.1 常用限制符(Commonly used constraints.)

有许多限制符,只有一部分是常用的。我们看一看这些限制符。

1. 寄存器操作数限制符(Register operand constraint)(r)
当操作数指定使用此限制符时,它们会存储在常规寄存器中(General Purpose Registers(GPR))。如:

asm ("movl %%eax, %0\n"
    :"=r"(myval)
    );

此处myval变量保存在一个寄存器中,eax的值会复制到那个寄存器,而myval的值会从这个寄存器中更新到内存。当"r"限制符被指定后,gcc可以在任何可用的GPR中保存这个变量。要指定该寄存器,你必须使用特定寄存器限制符指定寄存器名称。它们是:

r Register (s)
a %eax, %ax, %al
b %ebx, %bx, %bl
c %ecx, %cx, %cl
d %edx, %dx, %dl
S %esi, %si
D %edi, %di

2. 内存操作数限制符(Memory operand constraint)(m)

当操作数是在内存中时,任何在它上的操作将直接在内存位置进行,而寄存器限制符,则优先存于寄存器而后修改再写回内存。但寄存器限制符通常只在指令必需或者明显提升性能时使用。当C变量需在asm中修改且无需寄存器保持其值时,内存限制符可最大化性能。如,将idtr的值存储于loc的内存位置中:

asm("sidt %0\n" : :"m"(loc));

3. 匹配(数字)限制符(Matching(Digit) constraints)
有时,一个单独变量既是输入也是输出操作符,这时可使用匹配限制符。

asm ("incl %0" :"=a"(var):"0"(var));

我们在操作数一节看到了类似的例子,在这个例子中寄存器%eax既是输入也是输出变量。var输入读入%eax并更新到%eax最后在自增后存入var。这里的"0"指定了和输出变量一样的第0个限制符。也就是说,它指定了var的输出过程应该只存于%eax中。这类限制符可用于:

  • 输入输出是统一变量,或变量被修改并被写会同一变量时。
  • 将输入和输出操作符分开是不必要的时候。

使用匹配限制符最重要的效果是使可用寄存器的使用更有效。

一些其他的限制符有:

  • m: 接受内存操作数,任意的机器支持的地址。
  • o: 接受内存操作数,只接受偏移地址(offsettable)。即对某个合法地址添加一个微小的偏移量。
  • V: 非偏移内存操作数。换句话说,任何符合"m"但不符合"o"限制符的地址。
  • i: 立即整型操作数,允许在编译期(assembly-time)可知常量符号。
  • n: 立即整型操作数,允许已知数字值。许多系统不支持小于16-bit的(word wide)编译期(assembly-time)常量作为操作数。这些操作数应该使用n而不是i
  • g: 任何寄存器,内存或立即整型操作数都可用,要求寄存器不是常规寄存器(general registers)。

以下限制符为x86限定:

  • r : Register operand constraint, look table given above.
  • q : Registers a, b, c or d.
  • I : Constant in range 0 to 31 (for 32-bit shifts).
  • J : Constant in range 0 to 63 (for 64-bit shifts).
  • K : 0xff.
  • L : 0xffff.
  • M : 0, 1, 2, or 3 (shifts for lea instruction).
  • N : Constant in range 0 to 255 (for out instruction).
  • f : Floating point register
  • t : First (top of stack) floating point register
  • u : Second floating point register
  • A : Specifies the a’ ord’ registers. This is primarily useful for 64-bit integer values intended to be returned with the d’ register holding the most significant bits and thea’ register holding the least significant bits.

6.2 限制符修饰符(Constraint Modifiers.)

当使用限制符时,若要精确控制其效果,GCC提供了修饰符。常用当有:

  • = : 意味着操作数对该指令是只写的;前一个值将被忽略并替换为输出数据。
  • &: 意味着操作数是一个早期受影响的操作数,也就是在指令结束前已被修改。因此,该操作数不可停留在输入寄存器中或任何内存中。在被写入前仅用于输入的输入操作数可设为一个早期受影响操作数 (An input operand can be tied to an earlyclobber operand if its only use as an input occurs before the early result is written)。

关于限制符的描述并不意味结束。例子可以帮助我们更好地理解内联汇编。下一节我们会看一些例子,我们会发现更多关于受影响列表和限制符的使用。

7. 一些有用的代码(Some Useful Recipes.)

现在我们已经基本涵盖了GCC内联汇编内容,我们应该关注一些简单的例子。使用宏来定义内联汇编总是方便的。我们可以看到很多内核(kernel)代码的asm函数例子。(/usr/src/linux/include/asm/*.h).

  1. 首先我们从一个简单的例子开始。我们写一个程序,将两个数字相加:
int main(void)
{
        int foo = 10, bar = 15;
        __asm__ __volatile__("addl  %%ebx,%%eax"
                             :"=a"(foo)
                             :"a"(foo), "b"(bar)
                             );
        printf("foo+bar=%d\n", foo);
        return 0;
}

此处我们让GCC将foo存入%eax,将bar存入%ebx,然后我们希望结果也存在%eax中。=符号表示那是一个输出寄存器。现在我们可以用另一种方式让变量加整数。

 __asm__ __volatile__(
                      "   lock       ;\n"
                      "   addl %1,%0 ;\n"
                      : "=m"  (my_var)
                      : "ir"  (my_int), "m" (my_var)
                      :                                 /* no clobber-list */
                      );

这是一个原子加法。我们可以移除lock指令来移除原子性。输出段=m意为my_var是一个输出操作数且在内存中。类似的ir说明my_int是一个整数并应该载入(reside)到寄存器中。没有受影响寄存器列表。

  1. 现在我们会执行一些动作在寄存器/变量上并比较它们到值。
__asm__ __volatile__(  "decl %0; sete %1"
                      : "=m" (my_var), "=q" (cond)
                      : "m" (my_var) 
                      : "memory"
                      );

此处,my_var的值减1,如果结果为0则cond变量被设置。我们同样可以添加lock;\n\t在第一句来实现原子性。类似的,我们可以用incl %0代替decl %0来实现my_var的加1。

此处需要指出
(i) my_var 是一个位于(residing in)内存中的变量。 (ii)cond可以在eax,ebx,ecxedx中的任意一个。=q限制符确保了这一点。 (iii) 受影响列表中包含memory`,即代码将改变内存中的值。

  1. 如何设置/清除寄存器中的一个位?
__asm__ __volatile__(   "btsl %1,%0"
                      : "=m" (ADDR)
                      : "Ir" (pos)
                      : "cc"
                      );

此处,`ADDR(一个内存变量)中的pos`变量对应的比特位将设为1.

我们可以用btrl替代btsl来清除一个位。限制符Ir指出,pos是一个寄存器,且它的值介于0-31(x86限制符)。即我们可以设置/清除ADDR变量中任意0~31位值。因为条件码将被改变,我们增加cc到受影响列表。

  1. 现在我们看一些复杂但有用的函数。字符串复制。
static inline char * strcpy(char * dest,const char *src)
{
int d0, d1, d2;
__asm__ __volatile__(  "1:\tlodsb\n\t"
                       "stosb\n\t"
                       "testb %%al,%%al\n\t"
                       "jne 1b"
                     : "=&S" (d0), "=&D" (d1), "=&a" (d2)
                     : "0" (src),"1" (dest) 
                     : "memory");
return dest;
}

源地址保存在esi中,目标地址在edi中,然后开始复制,当我们到达0时,复制结束。限制符&S,&D,&a说明寄存器esi, edi, eax是早期受影响寄存器。即,它们的内容将在函数完成前被改变。此处明显memory也在受影响之列。

我们看一个类似的函数,移动一块双字(double words)。注意函数声明为一个宏。

#define mov_blk(src, dest, numwords) \
__asm__ __volatile__ (                                          \
                       "cld\n\t"                                \
                       "rep\n\t"                                \
                       "movsl"                                  \
                       :                                        \
                       : "S" (src), "D" (dest), "c" (numwords)  \
                       : "%ecx", "%esi", "%edi"                 \
                       )

这里我们没有输出,所以改变发生在寄存器ecx, esiedi上,是块移动的副作用。所以我们将它们加在受影响列表上。

Linux中,系统调用是由GCC内联汇编实现的。让我们看一些一个系统调用是如何实现的。所有的系统调用被写作一个宏(linux/unistd.h)。如,一个有3个参数的系统调用被写作如下的宏:

#define _syscall3(type,name,type1,arg1,type2,arg2,type3,arg3) \
type name(type1 arg1,type2 arg2,type3 arg3) \
{ \
long __res; \
__asm__ volatile (  "int $0x80" \
                  : "=a" (__res) \
                  : "0" (__NR_##name),"b" ((long)(arg1)),"c" ((long)(arg2)), \
                    "d" ((long)(arg3))); \
__syscall_return(type,__res); \
}

无论任何的3个参数的系统调用,都使用以上宏进行。syscall数字放在eax中,然后每个参数放在ebx, ecx, edx。最终int 0x80指令真正的执行调用。返回码可以在eax中获得。

每个系统调用实现方法类似。退出是一个单参数系统调用,让我们看看它的代码是什么样的,如下:

{
        asm("movl $1,%%eax;         /* SYS_exit is 1 */
             xorl %%ebx,%%ebx;      /* Argument is in ebx, it is 0 */
             int  $0x80"            /* Enter kernel mode */
             );
}

退出数字是1,参数(parameter)是0。所以我们安排eax包含1,ebx包含0,通过int $0x80执行exit(0)。这就是exit的工作原理。

8. 总结(Concluding Remarks.)

本文档简要叙述了GCC内联汇编的基础。一旦你理解了这些基础概念,自己尝试下一步就不再困难了。我们已经看了一些对理解常用GCC内联汇编功能很有帮助的例子。

GCC内联是一个很大的主题,而这篇文章只是一个的开始。更多语法细节可以在官方GNU汇编文档中查阅。同样的,完整的限制符说明也在GCC官方文档中列出。

当然,Linux内核大规模使用了GCC内联汇编。所以我们可以在其中找到很多的不同类型的例子。对我们非常有帮助。

如果你发现任何文字错误,或本文中的内容已经过期,请告知。

9. 引用(References.)

  1. Brennan’s Guide to Inline Assembly
  2. Using Assembly Language in Linux
  3. Using as, The GNU Assembler
  4. Using and Porting the GNU Compiler Collection (GCC)
  5. Linux Kernel Source

你可能感兴趣的:(译:GCC内联汇编入门)