GCC内联汇编

 

目录

GCC汇编器语法

基本内联

扩展汇编

汇编模板

操作数

clobber list

Volatile...?

更多限制

常用限制

限制修饰符

Some Useful Recipes.


GCC汇编器语法

linux的GUN C 编译器,使用的是AT&T汇编语法。

  1. 源 - 目标
    AT&T语法中操作数的方向与intel相反。在 Intel 语法中,第一个操作数是目标,第二个操作数是源,而在 AT&T 语法中,第一个操作数是源,第二个操作数是目标。
    Interl: Op-code dst src
    AT&T: Op-code src ds
  2. 寄存器命名
    在寄存器名字前加%,例如eax写作%eax。
  3. 立即操作数
    AT&T立即操作数以"$"开头。对于静态"C"便来也要在前缀加上"$"。对于十六进制,首先看到一个"$",然后使"0x",之后是常量。"$0x1"。
  4. 操作数大小
    在 AT&T 语法中,内存操作数的大小由操作指令名称的最后一个字符确定。“b”、“w”和“l”的操作指令后缀指定字节(8 位)、字(16 位)和长(32 位)内存引用。
  5. 内存操作数
    在 Intel 语法中,基址寄存器包含在 '[' 和 ']' 中,而在 AT&T 中它们更改为 '(' 和 ')'。此外,在 Intel 语法中,间接内存引用类似于
    section:[base + index*scale + disp],变为
    section:disp(base, index, scale) 在 AT&T 中。

+------------------------------+------------------------------------+
|       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 |
+------------------------------+------------------------------------+

基本内联

基本内联汇编的格式非常简单。它的基本形式是

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将每条指令作为字符串发送给as(GAS),通过换行符/制表符区分来发送正确格式化字符给汇编器。

例子:

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

如果我们的代码里使用了寄存器, 并且在返回的时候没有还原它, 这将有坏的情况发生. 因为GCC并不知道寄存器的值改变了, 特别是编译器对代码进行优化的时候. 编译器会认为,那些存放变量的寄存器,我们并没有改变它,然后继续自己的优化. 为了避免这种情况, 要么, 我们不改变寄存器的值, 要么, 汇编函数返回之前, 还原寄存器使用前的值, 或者 等着代码崩溃(wait for something to crash). 正是由于存在这样的问题,我们需要使用"Extended Asm". 它将提供给我们扩展功能, 解决上边的问题.

扩展汇编

在基本嵌入汇编格式中,我们只使用了指令. 在扩展汇编中, 我们还可以指定更多操作. 它允许我们指定输入寄存器, 输出寄存器和变化表(clobber list). 我们并不一定要指定使用哪些寄存器. 我们可以把这件头痛的事情交给GCC去做. 扩展汇编的格式如下:

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

这个模板由若干条汇编指令组成, 每个操作数(括号里C语言的变量)都有一个限制符(“”中的内容)加以描述. 冒号用来分割输入的操作和输出的操作. 如果每组内有多个操作数,用逗号分割它们. 操作数最多为10个, 或者依照具体机器而异 .

如果没有输出操作, 但是又有输入, 你必须使用连续两个冒号, 两个连续冒号中无内容, 表示没有输出结果的数据操作 .

例子:

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

上面这段代码做了什么? 这段内嵌汇编把 fill_value, count装入寄存器,同时告知GCC,clobber list目录中的寄存器eax,edi,已经改变.

看一个更详细的例子:

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

代码目的是让'b'的值与'a'的值相等.

  • 'b'是要输出的数据,%0也指它。 'a'是输入的数据,%1也指它。
  • 'r' 是对操作数的约束。呆会在详细了解。 暂时这样理解,‘r’告诉GCC选择一个可用的寄存器来保存这个操作数。 输出操作数,应该使用‘=’, 表示这个数据只写。
  • 双%%前缀,指明这是一个寄存器名。 单%指明操作数。 这帮组GCC辨别 操作数和寄存器。
  • 第三个冒号后边, 这个变化表(clobber list)里的寄存器%eax,告诉gcc声明的寄存器值已经改变,这样,GCC不会在其他地方使用这个寄存器了。

当这段汇编代码执行完毕,'b'变量将会存储这个结果,,正如例子里声明这个变量为输出。 换句话说, 'b'用来反映汇编程序里值的变化。

汇编模板

这个汇编模板包含一套完整的汇编指令,帮助在c语言内嵌入汇编语言。具体格式如下:每条指令应该加上双括号,或者给整套汇编指令加上双括号(如,最后一个例子)。每条指令结尾都应加上结束符,合法的结束符有(\n)和(;),或许还应该在 \n后边加上一个 \t,我们应该了解原因吧? 括号里的若干操作数,依次对应%0,%1...等。

操作数

c表达式作为asm中汇编指令的操作数,每个操作数首先加上双引号。对于输出操作数,首先会有一个限制符,然后跟上C变量,运算结果将存入这个变量。

双引号内的“限制符”是一个规定的格式。在输出操作中,这个限制符会额外多一个符号(=)。限制符主要用来决定操作数的寻址方式。同时还可指定使用某一个寄存器。

如果我们使用多个操作数,它们之间用逗号分隔。

在汇编器模板中,每个操作数都由数字与之对应。编号如下进行。如果总共有 n 个操作数(包括输入和输出),则第一个输出操作数编号为 0,按递增顺序继续,最后一个输入操作数编号为 n-1。操作数的最大数量与我们在上一节中看到的一样。

输出操作数表达式必须是左值。输入操作数不受这样的限制。它们可能是表达式。扩展汇编常常用于实现机器平台自身特殊的指令,编译器可能并不能识别他们:-)。如果输出表达式不能直接寻址(例如,它是一个位字段),我们的约束必须允许一个寄存器。在这种情况下,GCC 将使用寄存器作为 asm 的输出,然后将该寄存器内容存储到输出中。

我们现在分析几个例子。我们想给一个数乘以5。因此,我们使用lea指令: (汇编语句leal(r1,r2,4),r3语句表示r1+r2*4→r3。这个例子可以非常快地将x乘5。)

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

这里,输入一个变量x,我们并没指定特定的寄存器来存储它,GCC会选择一个(“r”表示gcc选择)。如我们所要求的,gcc会自动选择两个寄存器,一个给input,一个给output。如果我们想给input和output指定同一个寄存器,我们可以要求GCC这样做(通过更改“限制符”内容)。

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

上例,我们就让input和output使用同一个寄存器,但是不知道具体哪一个。(如果输入操作的限制符为0或为空,则说明使用与相应输出一样的寄存器。)如果,我们想指定使用具体一个寄存器,可以看看如下代码:“c”表示使用寄存器ecx。

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

上面三个例子,我们没有把任何寄存器放入clobber list,为什么?前两个例子,由GCC选择寄存器,所以它知道那些寄存器值改变了。最后一个例子,我们没有把ecx寄存器放入clobber list,GCC知道它的值变成x了。因此,既然GCC知道ecx寄存器的值,就没必要加入到clobber list。

clobber list

一些指令改变了硬件寄存器的值。这是需要在clobber list中列举出这些寄存器,位置在asm函数中第三个':'后。这是为了告知GCC,我们将使用和更改列举出的寄存器。那么,GCC就知道之前装载到寄存器里的值已经无效了,不会使用寄存器的旧值进行错误操作。我们不必把input,output所使用的寄存器列入clobber list,因为GCC知道汇编代码已经使用和改变了那些寄存器。

如果汇编代码将改变条件码寄存器,我们需要在clobber list中加入“cc”。

如果我们的指令以一种不可预知的方式修改了内存,需在clobber list中加入“memory”。这将使GCC在整个汇编指令的寄存器中不保留缓存值。如果受影响的内存没有列在asm的输入或输出中,我们还必须添加volatile关键字。

clobber list中的寄存器可以反复读写。参考下面这个例子,代码子程序__foo用eax,ecx寄存器传递参数。则这俩寄存器的值不再可靠,所以加入到clobber list中。

        asm ("movl %0,%%eax;
              movl %1,%%ecx;
              call _foo"
             : /* no outputs */
             : "g" (from), "g" (to)
             : "eax", "ecx"
             );
             // from和to是任意寄存器

Volatile...?

如果你熟悉内核代码或者像她一样漂亮的代码,你一定见到过许多函数被volatile或__volatile__修饰,通常紧跟在 asm或__asm__后边。我先前提到过asm和__asm__的区别。那volatile呢?

如果你不希望自己编写的汇编代码被gcc优化或移动,你需要使用到volatile这个keyword,将其放在asm和()之间即可。

对volatile关键字的使用应非常谨慎。

如果我汇编代码仅仅做一些简单计算并且没有什么副作用,那么最好不用volatile。不使用它,可以帮助GCC优化代码,让代码更“漂亮”。

更多限制

看到这里,你应该知道汇编里的限制符(constraint)做了很多的事。但,我们只花了很少的篇幅叙述限制符。比如,限制符可以指定一个寄存器,限制符可以指向一块内存空间,限制符可以是一个立即数...等。

常用限制

有大量的限制符,我们常用使用其中很少一部份,现在来看看:

  1. 寄存器操作数限制(r)
    当操作指定了“r”限制符,那么操作数将会被存储在通用寄存器内。看下例:
    asm ("movl %%eax, %0\n" :"=r"(myval));
    这里的变量myval被存储在一个寄存器内,代码将eax寄存器的值拷贝到myval占用的寄存器内,然后myval寄存器的值将更新myval的内存值。当“r”限制符被指定,GCC可能分配任意一个通用寄存器来存储操作数。如果要确切使用某个寄存器,你应该指定这个寄存器名称,通过下表的格式:
    +---+--------------------+
    | 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. 内存操作数限制(m)
    如果限制符“m”后的操作数在内存中,任何对它们的操作都会直接更改内存值。与“r”限制符不同,“r”首先将操作数保存在寄存器内,然后在寄存器里进行数据操作,接着把数据写回内存区域。使用“r”限制符,通常是由于某些指令必须使用,或者为了加快程序运行,所以占用寄存器。“m”限制符运用更频繁,当我们希望在汇编执行过程中就更新内存,或者不希望额外占用一个宝贵的寄存器来装载变量值,就使用“m”限制符。如下:idtr的值就被保存在loc那块内存。
    asm("sidt %0\n", : :"m"(loc));
  3. Matching(Digit) constraints
    在某些情况下,单个变量可以同时作为输入操作数和输出操作数。这种情况可以通过使用匹配约束在"asm中。
    asm("incl %0" : "=a"(var)":"0"(var));
    上边见到过类似的例子,此例“0”使用了匹配限制符,寄存器eax同时供input,output使用。输入变量var被读入到eax,运算结束后,再被存储到eax。“0”这个限制符表示:与第0个操作数使用相同的寄存器。这样,就指明了输出输入使用同一个寄存器。这个限制符在如下地方可能用到:
    读取输入变量或修改变量,并将变量修改写回同一变量的情况下。
    没有必要使用更多的寄存器时。
    使用匹配限制符最重要的作用是:使得对有限寄存器资源使用更高效。

其他一些限制:

  1. "m":内存操作被允许,机器通常支持的任何合法地址。
  2. "o":内存操作被允许,但前提是支持地址偏移值。即,地址加上一个小的偏移量给出一个有效地址。
  3. "V":内存操作不支持偏移量。也就是说,支持“m”限制符,但不支持“o”的那些地址。
  4. "i":立即数操作被允许。包括在汇编时才知道的常量。
  5. "n":立即数为已经数值的操作被允许。许多系统不支持汇编中的操作数小于一个字宽。对于这些操作数的限制应该使用"n"而不是"i"。
  6. "g":任意寄存器,内存,立即数都被允许。除了非通用寄存器。


x86特定限制符:

  1. "r":寄存器约束,查看上面。
  2. "q":寄存器们 a,b,c或d
  3. "I":常量范围0到31位
  4. "J":常量范围0到63位
  5. "K":oxff
  6. "L":0xffff
  7. "M":0,1,2或3(适用于lea指令的移位)
  8. "N":常量范围0到255(对于输出指令)
  9. "f":浮点寄存器
  10. "t":第一个(栈顶)浮点寄存器
  11. "u":第二个浮点寄存器
  12. "A":指定'a'或'd'寄存器。这主要用于要返回的 64 位整数值,“d”寄存器保存最高有效位,“a”寄存器保存最低有效位。

限制修饰符

在使用限制符,为了更精确的控制限制的影响,GCC提供给我们一些限制语句修饰符。最常用的修饰符有:"=","&"。

  1. "=":表示该指令的操作数类型是只写。之前的值会被丢弃,并将输出数据写入。
  2. "&":表示一个操作数是一个早期会变(earlyclobber)的操作数,在指令完成之前使用输入操作进行修改。因此,此操作数可能不会用作输入操作数或用作任何内存地址的一部分的寄存器中。如果输入操作数仅用作输入操作且在写入早期结果之前,则可以将输入操作数绑定到 earlyclobber 操作数。

Some Useful Recipes.

现在我们已经接触GCC内联汇编的基本理论,我应该专注于几个简单的例子。使用内联汇编来定义宏是非常精妙的,我们可以在内核代码中看到许多asm函数。

1.首先以一个简单的开始,两个数相加。

int main(void)
{
  int foo = 10, bar = 15;
  __asm__ __volatile__("addl %%ebx, %%eax"
                        :"=a"(foo)					// output
                        :"a"(foo), "b"(bar)	// input
                        );
  printf("foo+bar=%d\n",foo);
  return 0;
}

这里我们指定gcc让eax寄存器存储foo,bar存放在ebx,并且将结果放在eax寄存器中。这里的"="表示是存放输出结果的寄存器。

接下来,我们可以用一些其他方式来添加一个整数。

__asm__ __volatile__(
                      "	lock	;\n"
                      "	addl %1,%0	;\n"
                      :"=m"	(my_var)
                      :	"ir"	(my_int), "m"	(my_var)
                      :
                      );

这是一个原子加法。可以去除"lock"指令来去除原子性。在输出域,"=m"的意思是my_var是输出操作数,并且是在内存中。相似的,"ir"的意思是my_int是一个整数,可以选择一个通用寄存器来存放。没有寄存器在clobber list中。

2.现在我们执行一些操作在寄存器/变量上,并且比较它们的值。

__asm__ __volatile__(	"decl %0; sete %1"
                      : "=m"(my_var), "=q" (cond)
                      : "m" (my_var)
                      : "memory"
                      );

这里,my_var的值自减一,如果结果为0,则cond置1。可以通过增加指令"lock;\n\t"来确保原子性。

类似的方式,我们可以使用“incl %0”而不是“decl %0”,这个是增加 my_var。

要点:1.my_var值的变化是直接在内存中。2.cond的限制是"=q",所以它使用的寄存器是eax,ebx,ecx,edx中的一个。3.可以看到"memory"在clobber list中,即,代码改变的内容都在内存中。

3.如何设置/清除寄存器的一位?请看下面的例子:

__asm__ __volatile__(	"btsl %1, %0"
                      :	"=m" (ADDR)
                      : "Ir"	(pos)
                      : "cc"
                      );

将'pos'的ADDR(内存变量)变量的比特位置1。我们可以使用'blrl'代替'btsl'来清理这个比特位。pos的限制"Ir"是说pos位于寄存器中,值的范围0-31位。即,我们可以设置/清除任何0-31比特位的值在ADDR中。因为条件代码将会改变,所以我们增加"cc"到clobberlist。

4.现在我看几个复杂但是有用的函数,字符串拷贝。

static inline char *strcpy(char *dest, const char *src)
{
  int d0, d1, d2;
  __asm__ volatile__(	"1:\tlodsb\n\t"		     		//加载DS:[esi]处1字节->al,并更新esi
                      "stosb\n\t"								//存储字节al->ES:[edi],并更新edi
                      "testb %%al, %%al\n\t"		//刚存储的字节是0?
                      "jne 1b"
                      : "=&S" (d0), "=&D" (d1), "=&a" (d2)	//output "S":esi "D":edi
                      : "0"(src), "1" (dest)		// input
                      :"memory");
   return dest;
}

源地址存放在esi中,目标在edi中,然后开始拷贝,当到达0,拷贝完成。限制符"&S","&D","&a"是说寄存器esi,edi和eax,并且它们是早期改变的寄存器,即,它们的内容在函数结束之前会有改变。所以clobberlist同样为"memory"。

接下来看一个相似的函数,移动2个字节的块。注意,它以宏的方式实现。

#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,esi和edi的内容发生变化是因为块的移动的原因。所以需要将它们添加到colbberlist中。

5.在Linux中,系统调用是使用GCC内联汇编实现的。接下来我们看看系统调用是如何实现的。所有的系统都写成宏(linux/unistd.h)。举例,具有三个参数的系统调用被定义为如下所示的宏。

#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); \
}

在每次进行带有三个参数的系统调用时,都会使用上面显示的宏进行调用。系统调用名称放在 eax 中,参数放在 ebx、ecx、edx 中。 最后“int 0x80”是使系统调用工作的指令。 返回值存入 eax 。

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

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

exit的编号是1,参数是0。所以将eax存放1,ebx存放0,然后执行指令"int $0x80"。这就是exit的工作方式。

你可能感兴趣的:(操作系统设计与实现,linux)