欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

linux gcc 内联汇编入门

程序员文章站 2024-03-23 14:20:40
...

目录

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

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

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

3.2. 寄存次命名(Registry Naming)

3.3. 立即操作数(Immediate Operands)

3.4. 操作数大小(Operand Size)

3.5. 内存操作数(Memory Operands)

4. 内联基础(Basic Inline.)

5. 扩展Asm(Extended Asm.)

5.1 汇编模板(Assembler Template).

5.2 操作数(Operands).

5.3 受影响列表(Clobber List.)

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

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

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

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

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

8. 总结(Concluding Remarks.)


原文: GCC-Inline-Assembly-HOWTO

希望将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内联汇编。所以我们可以在其中找到很多的不同类型的例子。对我们非常有帮助。