原文链接:https://www.ibiblio.org/gferg/ldp/GCC-Inline-Assembly-HOWTO.html ,此文为我的中文翻译,转载请标明出处!
本HOWTO解释了GCC提供的内联汇编功能的使用方法。阅读本文只有两个先决条件,是x86汇编语言和C的基本知识。
版权所有(C)2003 Sandeep S.
这份文件是免费的; 您可以根据自由软件基金会发布的GNU通用公共许可证条款重新分发和/或修改此内容; 许可证的第2版,或(根据您的选择)任何更高版本。
本文件的分发是希望它有用,但没有任何担保; 甚至没有适销性或特定用途适用性的暗示保证。有关更多详细信息,请参阅GNU通用公共许可证。
请向Sandeep.S提出反馈和批评 。我将感谢任何指出本文件中的错误和不准确之处的人; 我得到通知后,我会尽快纠正他们。
我衷心感谢GNU人员提供了这样一个伟大的功能。感谢Mr.Pramode CE所做的一切帮助。感谢Govt Engineering College的朋友,Trichur的道义支持与合作,特别是对Nisha Kurur和Sakeeb S.感谢我在Govt Engineering College的亲爱的老师,Trichur的合作。
另外,感谢Phillip,Brennan Underwood和[email protected]; 这里的许多东西都是从他们的作品中无耻地偷走的。
我们在这里了解GCC内联汇编。这个内联代表什么?
我们可以指示编译器将函数的代码插入其调用者的代码中,直到实际调用的位置。这些函数是内联函数。听起来像宏?确实有相似之处。
内联函数有什么好处?
这种内联方法减少了函数调用开销。如果任何实际参数值是常量,则它们的已知值可能允许在编译时进行简化,因此不需要包含所有内联函数的代码。对代码大小的影响不太可预测,这取决于具体情况。要声明内联函数,我们必须inline
在其声明中使用关键字 。
现在我们可以猜猜什么是内联汇编。它只是一些编写为内联函数的汇编程序。它们在系统编程中非常方便,快速且非常有用。我们的主要重点是研究(GCC)内联汇编函数的基本格式和用法。要声明内联汇编函数,我们使用关键字asm
。
内联汇编很重要,主要是因为它能够操作并使其输出在C变量上可见。由于这种能力,“asm”作为汇编指令和包含它的“C”程序之间的接口。
GCC是用于Linux的GNU C编译器,它使用AT&T / UNIX 汇编语法。这里我们将使用AT&T语法进行汇编编码。如果您不熟悉AT&T语法,请不要担心,我会教您。这与Intel语法完全不同。我将给出重大分歧。
AT&T语法中操作数的方向与英特尔的方向相反。在Intel语法中,第一个操作数是目标,第二个操作数是源,而在AT&T语法中,第一个操作数是源,第二个操作数是目标。即
Intel语法中的“Op-code dst src”更改为
AT&T语法中的“Op-code src dst”。
寄存器名称以%为前缀,即,如果要使用eax,则写入%eax。
AT&T的即时操作数前面是'$'。对于静态“C”变量,前缀为'$'。在Intel语法中,对于十六进制常量,'h'是后缀,这里我们将'0x'加到常量前面。因此,对于十六进制,我们首先看到'$',然后是'0x',最后是常量。
在AT&T语法中,存储器操作数的大小由操作码名称的最后一个字符确定。“b”,“w”和“l”的操作码后缀指定字节(8位),字(16位)和长(32位)存储器引用。Intel语法通过在内存操作数(不是操作码)前加上'byte ptr','word ptr'和'dword ptr'来实现这一点。
因此,英特尔“mov al,byte ptr foo”在AT&T语法中是“movb foo,%al”。
在Intel语法中,基址寄存器包含在'['和']'中,而在AT&T中它们更改为'('和')'。此外,在Intel语法中,间接内存引用就像
section:[base + index * scale + disp],改为
section:AT&T中的disp(基数,指数,比例)。
需要记住的一点是,当一个常量用于disp / scale时,'$'不应该是前缀。
现在我们看到了英特尔语法和AT&T语法之间的一些主要差异。我只写了一些。有关完整信息,请参阅GNU汇编程序文档。现在我们将看一些例子以便更好地理解。
+------------------------------+------------------------------------+ | 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将每个指令作为一个字符串发送至汇编器。
例如。
__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为我们提供了该功能。
在基本的内联汇编中,我们只有指令。在扩展汇编中,我们还可以指定操作数。它允许我们指定输入寄存器,输出寄存器和破坏寄存器列表。指定要使用的寄存器并不是强制性的,我们可以将这个问题留给GCC,这可能更适合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
倍填充到edi
寄存器指向的位置。它还告诉gcc的是,寄存器的内容eax
和edi
不再有效。让我们再看一个例子,让事情更清晰。
int a=10, b; asm ("movl %1, %%eax; movl %%eax, %0;" :"=r"(b) /* output */ :"r"(a) /* input */ :"%eax" /* clobbered register */ );
这里我们做的是使用汇编指令使'b'的值等于'a'的值。一些兴趣点是:
当“asm”的执行完成时,“b”将反映更新的值,因为它被指定为输出操作数。换句话说,“asm”中对“b”的改变应该反映在“asm”之外。
现在我们可以详细查看每个字段。
汇编程序模板包含插入C程序内的汇编指令集。格式如下:要么每条指令都用双引号括起来,要么整个指令组都在双引号内。每条指令也应以分隔符结束。有效分隔符是换行符(\ n)和分号(;)。'\ n'后面可能跟一个标签(\ t)。对应于C表达式的操作数由%0,%1 ......等表示。
C表达式用作“asm”中的汇编指令的操作数。每个操作数都被写为双引号中的第一个操作数约束。对于输出操作数,在引号内也会有一个约束修饰符,然后是C表达式,它代表操作数。即
“约束”(C表达式)是一般形式。对于输出操作数,将有一个额外的修饰符。约束主要用于决定操作数的寻址模式。它们还用于指定要使用的寄存器。
如果我们使用多个操作数,则用逗号分隔。
在汇编程序模板中,每个操作数都由数字引用。编号如下进行。如果总共有n个操作数(包括输入和输出),则第一个输出操作数编号为0,按递增顺序继续,最后一个输入操作数编号为n-1。最大操作数是我们在上一节中看到的。
输出操作数表达式必须是左值。输入操作数不受此限制。他们可能是表达式。扩展的asm功能最常用于编译器本身不知道存在的机器指令;-)。如果无法直接寻址输出表达式(例如,它是位字段),则我们的约束必须允许寄存器。在这种情况下,GCC将使用寄存器作为asm的输出,然后将该寄存器内容存储到输出中。
如上所述,普通输出操作数必须是只写的; GCC将假设在指令之前这些操作数中的值已经死亡且无需生成。扩展的asm还支持输入输出或读写操作数。
所以现在我们专注于一些例子。我们想要将数字乘以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列表。为什么?在前两个例子中,GCC决定寄存器,它知道发生了什么变化。在最后一个,我们没有必要把ecx
加入clobber-list,gcc知道它进入了x。因此,因为它可以知道ecx
的值,所以它不被认为是破坏的。
一些指令破坏了一些硬件寄存器。我们必须在clobber-list中列出这些寄存器,即asm函数中第三个' : ' 之后的字段。这是为了告知gcc我们将自己使用和修改它们。所以gcc不会假设它加载到这些寄存器中的值是有效的。我们不应该在此列表中列出输入和输出寄存器。因为,gcc知道“asm”使用它们(因为它们被明确指定为约束)。如果指令隐式或显式地使用任何其他寄存器(并且输入或输出约束列表中不存在寄存器),则必须在破坏列表中指定这些寄存器。
如果我们的指令可以改变条件代码寄存器,我们必须将“cc”添加到破坏寄存器列表中。
如果我们的指令以不可预测的方式修改内存,请将“memory”添加到修饰寄存器列表中。这将导致GCC不在汇编器指令的寄存器中保持缓存的内存值。如果受影响的内存未在asm的输入或输出中列出,我们还必须添加volatile关键字。
我们可以根据需要多次读取和编写被破坏的寄存器。考虑模板中多个指令的示例; 它假定子程序_foo接收在寄存器eax
和ecx
参数的参数。
asm ("movl %0,%%eax; movl %1,%%ecx; call _foo" : /* no outputs */ : "g" (from), "g" (to) : "eax", "ecx" );
如果您熟悉内核源代码或类似的一些漂亮的代码,您必须已经看到许多函数声明为volatile
或 __volatile__
。我之前提到过关键字asm
和__asm__
。那这 volatile
是什么?
如果我们的汇编语句必须在我们放置的地方执行,(即不能作为优化移出循环),请将关键字volatile
放在asm之后和()之前。我们将其声明为
asm volatile ( ... : ... : ... : ...);
使用__volatile__
的时候,我们必须非常小心。
如果我们的程序集只是用于进行一些计算并且没有任何副作用,那么最好不要使用关键字volatile
。避免gcc无法优化代码。
在 一些有用的方法 中,我提供了许多内联asm函数的示例。在那里我们可以看到详细的clobber列表。
到目前为止,您可能已经理解约束与内联汇编有很大关系。但是我们对限制很少说。约束可以说明操作数是否在寄存器中,以及哪种寄存器; 操作数是否可以作为内存引用,以及哪种地址; 操作数是否可以是立即常量,以及它可能具有哪些可能的值(即值的范围)....等等。
存在许多约束,其中仅频繁使用少数约束。我们将看看这些约束。
使用此约束指定操作数时,它们将存储在通用寄存器(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 | +---+--------------------+
当操作数在存储器中时,对它们执行的任何操作将直接发生在存储器位置,而不是寄存器约束,寄存器约束首先将值存储在要修改的寄存器中,然后将其写回存储器位置。但是寄存器约束通常仅在它们对于指令绝对必要时才使用,或者它们显著加速了该过程。在需要在“asm”内更新C变量并且您真的不想使用寄存器来保存其值时,可以最有效地使用内存约束。例如,idtr的值存储在内存位置loc中:
asm("sidt %0\n" : :"m"(loc));
在某些情况下,单个变量可以作为输入和输出操作数。可以通过使用匹配约束在“asm”中指定这种情况。
asm ("incl %0" :"=a"(var):"0"(var));
我们在操作数小节中也看到了类似的例子。在此示例中,匹配约束,寄存器%eax用作输入和输出变量。var输入读取到%eax,更新后%eax在增量后再次存储在var中。这里的“0”指定与第0个输出变量相同的约束。也就是说,它指定var的输出实例应仅存储在%eax中。可以使用此约束:
使用匹配约束的最重要的影响是它们导致有效使用可用寄存器。
使用的一些其他约束是:
以下约束是x86特定的。
在使用约束时,为了更精确地控制约束的影响,GCC为我们提供了约束修饰符。最常用的约束修饰符是
约束的列表和解释绝不是完整的。示例可以更好地理解内联asm的使用和使用。在下一节中,我们将看到一些示例,我们将在其中找到有关clobber-lists和约束的更多信息。
现在我们已经介绍了关于GCC内联汇编的基本理论,现在我们将集中讨论一些简单的例子。将内联asm函数编写为MACRO总是很方便。我们可以在内核代码中看到许多asm函数。(/usr/src/linux/include/asm/*.h)。
首先,我们从一个简单的例子开始。我们将编写一个程序来相加两个数字。
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,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是一个整数,应该驻留在某个寄存器中(回想一下我们上面看到的表)。clobber列表中没有寄存器。
现在我们将对一些寄存器/变量执行一些操作并比较该值。
__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。
这里要注意的是(i)my_var是驻留在内存中的变量。(ii)约束“= q”保证了cond在eax,ebx,ecx和edx寄存器其中之一中。(iii)我们可以看到内存在clobber列表中。即,代码正在改变内存的内容。
如何设置/清除寄存器中的位?作为下一个方法,我们将会看到它。
__asm__ __volatile__( "btsl %1,%0" : "=m" (ADDR) : "Ir" (pos) : "cc" );
这里,ADDR变量位置'pos'处的位(存储器变量)设置为1,
我们可以使用'btrl'代替'btsl'来清除该位。pos的约束“Ir”表示pos位于寄存器中,其值的范围为0-31(x86依赖约束)。也就是说,我们可以在ADDR设置/清除变量的第0到第31位。由于条件代码将被更改,我们将“cc”添加到clobberlist。
现在我们来看一些更复杂但有用的功能。字符串副本。
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是early clobber寄存器,即它们的内容将在函数完成之前改变。这里也很清楚为什么memory在clobberlist中。
我们可以看到一个类似的函数移动一个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,esi和edi的内容发生的变化是块移动的副作用。所以我们必须将它们添加到clobber列表中。
在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收集返回值。
每个系统调用都以类似的方式实现。退出是一个单个参数系统调用,让我们看看它的代码是什么样的。它如下所示。
{ 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和by int $0x80
,exit(0)
执行。这就是退出的方式。
本文档介绍了GCC内联汇编的基础知识。一旦理解了基本概念,就不难采取自己的步骤。我们看到了一些有助于理解GCC内联汇编常用功能的示例。
GCC内联是一个广泛的主题,本文绝不是完整的。关于我们讨论的语法的更多细节可以在GNU Assembler的官方文档中找到。同样,有关约束的完整列表,请参阅GCC的官方文档。
当然,Linux内核大规模使用GCC Inline。所以我们可以在内核源代码中找到各种各样的例子。他们可以帮助我们很多。
如果您在本文档中发现任何明显错别字或过时信息,请告知我们。