gcc内联汇编

Gcc-inline-assembly-HOWTO

1.Gcc汇编语法

GNU,Linux的C编译器gcc,使用AT&T / UNIX汇编语法。在这里,我们将使用AT&T汇编语法。不要担心如果你不熟悉AT&T语法,我会教你。这是完全不同于英特尔语法。我会给出主要的不同。

  • 目的操作数和源操作数顺序

    AT&T语法和intel语法操作数顺序相反。在intel语法中,第一个操作数是目的操作数,第二个操作数是源操作数,而在AT&T中刚好相反,例如:

"Op-code dst src" intel 语法

"Op-code src dst" AT&T 语法
  • 寄存器命名

    寄存器前缀是%,例如使用eax,应该写作%eax。

  • 立即操作数

    AT&T立即操作数前面有个 C ”。intel语法中,十六进制常数的后缀是h,而AT&T中我们用0x做前缀表示16进制数。所以,对于16进制数,应该表示为$0x加上一个常数。

  • 操作数大小

    在AT&T语法中,内存操作数的大小取决于运算代码名称的最后一个字符是什么。操作码后缀’b’,’w’、’l’分别确定字节(8位)、字(16位)、双字(32位)的内存引用。英特尔语法通过前缀内存操作数(不是操作码)。前缀有以下几种’BYTE PTR’,’字PTR”,和’DWORD PTR’。

    因此,英特尔“MOV AL,BYTE PTR foo”在AT&T语法中对应是“movb foo,%al。

  • 寻址操作数。

    在英特尔语法的基址寄存器是被’[‘和’]’括起来的,而在AT&T是被’(’和’)’括起来的。此外,在英特尔语法的间接内存引用像下面这样

    节:[基地+段址*比例+显式数]

而在AT&T中

    节:显式数(基址、段址、比例)在AT&T

有一点要记住的是,当一个常数是用来显式数/比例数,不能前缀’$’。

现在我们看到了一些英特尔语法和语法的主要区别。我只写了其中几个。想看完整的信息,请参阅GNU汇编文件。现在我们将看一些例子来帮助理解。

Intel CodeAT&T Code
mov eax,1movl $1,%eax
mov ebx,0ffhmovl $0xff,%ebx
int 80hint $0x80
mov ebx, eaxmovl %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

2.基础内联语法

基本的内联程序集的格式是非常易懂的。它的基本形式是

asm("汇编代码");

例子:

asm("movl %ecx %eax");/*移动ECX的内容到EAX* /  

__asm__"movb % bh (%eax)");/*将字节从bh的内容移动到由EAX指向的位置 * /

你可能已经注意到了,在这里我使用asm和__asm__。两者都是有效的。如果我们的程序中一些关键字和asm冲突,我们可以用__asm__。如果我们有超过一个指令,我们每行写一个双引号,并且后缀’\n’,’\t’.这是因为GCC将每个指令作为字符串发送,通过使用换行/TAB格式可以得到正确的行格式的汇编代码中。

例子:


__asm__("movl %eax,%ebx \n\t"

    "movl $56,%esi\n\t"

    "movl %ecx,$label(%edx,%ebx,$4)\n \t"

    "movb %ah,(%ebx)");

      如果在我们的代码中我们使用(比如改变寄存器内容)一些寄存器返回ASM的时候没有修正确定一些变化,不好的事情就会发生。这是因为GCC所不知道的寄存器内容的变化,尤其是当编译器对代码进行优化的时候。它会假设一些寄存器包含一些变量,我们可能没有通知GCC他们发生了变化,它继续干它的好像什么都没发生.
      我们可以做的是使用没有副作用的指令,或者当我们退出时确定和修正变化,或者等待程序崩溃。这是我们要扩展功能的地方。扩展ASM提供给我们了一些那样的功能。

5.扩展汇编

      在基本的内联汇编里,我们只拥有指令。在扩展汇编中,我们还可以在操作数做一些手脚。它允许我们指定输入寄存器,输出寄存器和一个改动寄存器的列表。不需要强制指定要使用哪个寄存器,我们可以把事情都交给gcc,可能更适合GCC的优化方案。基本格式是:

asm(汇编程序模板

  :输出操作数/ * 可选* /

  :输入操作数/ * 可选* /

  :表改动寄存器/ * 可选* /

);

      汇编程序模板包括汇编指令。每一个操作数都是由一个操作数特定的字符串来描述的,跟在C语言的表达式的括号中。一个冒号分开了汇编模板和第一个输出操作数,另一个冒号分离了最后一个输出操作数和第一个输入操作数,如果有的话。逗号分隔每个组内的操作数。在机器描述中,操作数的总数被限制在十个或者机器指令描述的模式中更大的数。
      如果没有输出操作数,但是有输入操作数,你必须将两个连续的冒号放在输出操作数本应该出现的地方。

例子:

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

      这个代码做了什么?上述代码将fill_value复制count次然后填充到edi寄存器所指向的内存空间。它告诉GCC,寄存器ecx和edi的内容不再有效。让我们再看一个例子:

int a = 10,b;
asm("movl %1,%%eax;
  movl %%eax,%0;"
  :"=r"(b)/ *输出* /
  :"r"(a)/ *输*/
  :"%eax"/ *修饰寄存器* /
);

      在这里,我们将a赋值给了b。这个代码有一些有趣的的地方:
* b是输出操作数,表示为%0;a是输入,表示为%1.
* r是一个对操作数的约束。我们可以在后面看到关于操作数限制的细节。同时,r还告诉gcc用一个寄存器存储操作数。输出操作数应该有一个约束符号’=’。这个’=’表示这个操作数只读的输出操数。
* 这里有两个%前缀在寄存器前面。他们帮助gcc区分操作数和寄存器。操作数只有一个%前缀。
*第三个冒号后的修饰寄存器%eax告诉gcc,asm汇编代码中%eax被改变了,于是gcc不会用这个寄存器存储其他的变量。

      当asm执行完毕,b会具有更新后的值,因为它是输出操作数。换句话说,asm中对b的改变在asm外同样可见。

5.1 汇编模板

      汇编模板包括插入到C程序汇编指令集。格式就像:双引号括起来的指令,或者整个指令组被双引号括起来。每一个指令后都应该有一个分割符。有效的分隔符是换行’\n’和分号’;’.’\n’可能后面有一个’\t’。我们应该知道为什么放’\n’/’\t’,对吗?和C表达式有关的操作数表示为%0,%1…

5.2 操作数

      C表达式在asm的汇编指令中作为操作数使用。每一个操作数前面都有约束的双引号。输出操作符中还可能有一个约束修饰符’=’,然后才跟着一个表示操作数的C表达式。
      ”约束”(C表达式) 是最通用的形式。对于输出操作符,会有多出一个修饰符。约束首先用来确定操作数的地址模式,他们也被用来确定使用哪个寄存器。
      如果我们使用多于一个操作数,那么他们就被逗号分隔。
      在汇编模板中,每一个操作数都通过数字来引用。索引方式是,如果一共有n个操作数,同时包括输入和输出,那么第一个输出操作数被称为第0个,接下来是递增的顺序,最后一个输入操作数被称为第n-1个。操作数最多有几个,我们在前面已经见过了。
      输出操作是必须是左值。输入操作数并不需要严格遵守,他们可以是表达式。用于机器指令的扩展汇编的特性并不被编译器察觉到它的存在。如果输出表达式不能被直接寻址(例如,位寻址),我们的约束必须允许一个寄存器。在这种情况下,gcc会使用寄存器作为asm的输出,并且将寄存器的内容存储到输出中。
      正如上面叙述的,普通的输出操作符必须是只写的;gcc会假设在执行这个指令之前操作数中的值不会在起作用并且也不需要产生新的。扩展汇编也支持输入输出或者读写操作数。
      现在我们来看一些例子。我们想要将一个数乘以5。为了这个目的,我们使用指令lea(加载有效地址)。

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

      这里我们的输入是’x’。我们并不确定要使用哪个寄存器,gcc会选择某个寄存器作为输入,某个寄存器作为输出并且按我们期望它做的那样做。如果我们想要输入和输出使用同一个寄存器,我们可以指示gcc这样做。这里我们使用那些读写操作数的类型。通过确定合适的约束,我们可以这样写:

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

      现在我们输入和输出操作数在同一个寄存器了。但是我们不知道是哪一个寄存器。现在我们想确定这个寄存器,这里有一个办法:

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

      在所有这三个例子中,我们没有把任何寄存器放在修改列表(clobber list)里.为什么?在前面的两个例子里,gcc决定寄存器并且知道发生了什么变化。在最后一个例子,我们没有将ecx放在修改列表中,gcc知道它变成了x。因为它可以知道ecx的值,所以它就没有考虑被‘痛打一顿’放进修改表中。

5.3 修改(痛打)列表

      一些指令痛打一些硬件寄存器。我们要将他们放在修改表中,例如在asm函数中第三个冒号’:’之后的位置。这是为了通知gcc我们会使用和改变他们。所以gcc不会假设它加载进这些寄存器中的值是合法的值。我们不应该在clobber表中列出输入和输出寄存器。因为,gcc知道’asm’使用了他们(因为他们被确定为显示的约束)。如果这些指令使用了任意一个其他的寄存器,显示或者隐式(并且这些寄存器既没出现在输入或者输出约束列表中),然后那些寄存器就应该被放在clobber list中。
      如果我们的指令可以改变条件码寄存器,我们就不得不把cc加到clobber表中。
      如果我们的指令以一种不能预测的方式改变了内存,就将’mm’加到clobber。这会导致在汇编指令的整个过程,gcc不会将内存值缓存在寄存器中。我们也不得不加入volatile关键字,如果被影响的内存没有在asm的输入输出中列出。
      我们可以读写clobber寄存器任意次数。考虑到一个模板中有多个指令;它假设子例程_foo接受eax和ecx中的值.

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

5.4 易失性

      如果你熟悉内核代码或者一些像它一样漂亮的代码,你一定可以看见许多函数被声明为volatile或者__volatile__后面跟着asm或者__asm__.我在之前提过asm和__asm__这两个关键字。那么什么是volatile?
      如果我们的汇编描述必须执行我们输入的(例如,一定不能将一个循环作为优化),把volatile放在asm和()之间。为了避免它移动,删除或者所有的操作,我们这样声明它:

asm volatile ( ... : ... : ... : ...);

      当我们必须非常小心时,使用__volatile__。
      如果我们的汇编只是为了计算并且没有副作用,那就最好不要用这个关键字。避免使用volatile可以,帮助gcc优化代码,使代码运行更快。
      在”一些有用的技巧”这一章中,我提供了许多内联汇编函数的例子。在那里我们可以看到clobber list的很多细节。

6.更多关于”约束”的内容

      此时,你可能已经理解了约束对内联汇编的重要关联。但是我们说了很少关于约束的内容。约束可以表示是否一个操作数是一个寄存器,哪一个类型的寄存器;是否这个操作数可以是一个内存引用,和哪一个类型的地址;是否一个操作数可能是一个立即数常量,和它可能会有哪些值(或者哪些范围的值)……

6.1 通用的约束

      有许多约束很少使用,我们会看一看这些约束。

1.寄存器操作数约束(r)

      当操作数被这个约束确定,它们就被存储在通用寄存器中。请看下面的例子:

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

      这里的变量myval被存储在通用寄存器(GPR)中,eax寄存器的值被拷贝到那个寄存器里,并且myval的值被从这个寄存器更新到内存中。当’r’约束被确定时,gcc可能把变量存储在GPRS中的任意一个寄存器中。为了确定这个寄存器,你必须通过使用寄存器约束直接确定寄存器的名字。他们是:

rRegister(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)

      当操作数在内存中,任意在其上的操作会直接发生在内存地址上,和寄存器约束相反。寄存器约束先将改动的值放在寄存器中再写回到内存中。寄存器约束通常在对于一个指令是绝对必要的或者他们显著的加速了进程的运行的情况下使用,内存约束通常用在一个C变量需要在asm内部和你真的不想用寄存器来存储这个值的情况下。例如,idtr的值存储在内存地址loc上:

asm("sidt %0\n" : :"m"(loc));
3.匹配(数字)约束

      在一些情况下,一个变量可能既是输入也是输出。这些情况可能在asm这样使用.

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

      我们在操作数子部分看到了类似的例子。在这个例子中,寄存器%eax既是输入也是输出。var输入来读取到%eax并且在增加之后又更新%eax到var。这里的”0”确定了和第0个输出变量一样的约束。也就是说,它确定了var的输出的实例应该只能在%eax存储。这个约束可以这样使用:

  • 在输入读自一个变量,变量改变之后又写回同一个变量的情况下;
  • 在输出输入之间的分隔符不是必要的时候.

      匹配约束最重要的作用是他们可以高效利用寄存器资源。

一些其他的约束如下:
1. “m”:允许一个内存操作数,任意类型;
2. “o”:一个内存操作数,并且必须可以加上偏移量;
3. “V”:一个内存操作数,不能加上偏移量,也就是任何符合m但不符合o的约束;
4. “i”:一个立即数整数操作数(常量)。包含符号常量,它的值只在编译时确定;
5. “n”:一个立即整数操作数带有一个已知的数值。许多系统不支持少于一个字长的操作数的编译时符号常量。约束这些操作数应该用’n’而不是’i’;
6. “g”:任意寄存器,内存或者立即整数操作数都允许,除了不是GPRS的寄存器。

      接下来的约束是x86的:
1. “r”:寄存器操作数约束;
2. “q”:寄存器 a,b,c,d;
3. “l”:0-31的常量(32比特的移位量);
4. “J”:0-63的常量(64比特的移位量);
5. “K”:0xff;
6. “L”:0xffff;
7. “M”:0,1,2,or3(lea指令的偏移量);
8. “N”:常量0-255(溢出(外部?)指令);
9. “f”:浮点型指针寄存器;
10. “t”:第一个浮点指针寄存器(栈顶指针);
11. “u”:第二个浮点指针寄存器;
12. “A”:指定’a’或者’d’寄存器。这对于想要返回64bit的整数时让’d’持有最高位和’a’持有最低位有用。

6.2 约束修饰符

      当使用约束时,为了更精确的控制,gcc提供了修饰符,最常用的如下:
1. “=”:意味着操作数对于该指令是只写的;前一个值被抛弃并且被输出数据替换;
2. “&”:意味着这个操作数是一个”提前修改的操作数”,他表示在使用输入的指令结束之前该数就已經修改。因此,这个操作数可能不依赖于作为输入操作数或者任意内存地址的一部分的寄存器。一个输入操作数可以被绑定在一个”提前修改操作数”如果它仅仅在结果被写回之前作为输入使用。

      以上列出的约束和解释绝对不是完整的。例子可以帮助你更好的理解内联汇编。在下一个部分我们会看一些例子,在这里我们可以找到更多的修改列表和约束。

7.一些有用的案例

      现在我们已经了解了gcc内联汇编的基础理论,现在我们应该关注一些实例。把内联汇编函数写成宏是非常便利的。我们在内核代码中可以看到很多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在%eax存储foo,在%ebx存储bar,我们想在%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是一个整数并且属于一些寄存器(重新调用我们在上面看到的表).没有修改寄存器列表。

  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.my_var_是一个属于内存的值。2.cond是eax,ebx,ecx,edx中任意一个寄存器。”=q”约束保证了这一点。3.我们可以看到这里的内存在修改列表中。例如代码在修改内存的内容。

  1. 如何置位/清零一个寄存器里的位?再下一个案例里,我们将看到。
__asm__ __volatile__(   "btsl %1,%0"
          : "=m" (ADDR)
          : "Ir" (pos)
          : "cc"
          );

这里,ADDR地址上变量的pos上的bit被置为1.我们可以用btrl或者btsl来清除位。”Ir”约束表示,pos在一个寄存器中,并且它的值在0-31范围内(x86 依赖的约束).例如,我们可以清/置ADDR上变量的0-31之间的任意位。因为条件码会改变,我们将cc加到修改列表里。
4. 现在我们看一些更复杂但是有用的函数。字符串拷贝。

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会在修改列表中。
我们可以看到个类似的函数,移动一块双字的区域。注意这个函数被声明位宏。

#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的内容中的改变是块移动的副作用。所以我们要将他们加到修改列表。

  1. 在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 */
             );
}

系统调用号是1并且这里,他的参数是0.所以我们分配eax来包含1,ebx包含0,通过int $0x80,exit(0)被调用。这是exit的工作方式。

8. 总结

      这个文档将gcc内联汇编的基础过了一遍。一旦你理解了基本概念,那么接下来就不难开始自己继续的学习。我们看了几个对于使用gcc常用特性的理解很有帮助的内联汇编的例子。
      内联汇编是一个很大的工程并且这个文章绝对不是完整的。更多的语法细节在GNU汇编器的官方文档中可以得到。类似的,为了得到一个完整的约束列表,也请查询官方文档。
      当然,linux内核大规模使用gcc。所以我们可以在linux内核中发现许多不同种类的例子。他们对我们很有帮助。
      如果你已经找到一些印刷排字错误,或者过时的信息,请告诉我们。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值