原文: 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 foo
即 movb 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将每行作为一个string
到as(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 %0
而 a
是 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
接受eax
、ecx
寄存器中的参数。
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 thea’ or
d’ registers. This is primarily useful for 64-bit integer values intended to be returned with thed’ register holding the most significant bits and the
a’ 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).
- 首先我们从一个简单的例子开始。我们写一个程序,将两个数字相加:
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)到寄存器中。没有受影响寄存器列表。
- 现在我们会执行一些动作在寄存器/变量上并比较它们到值。
__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,
ecx和
edx中的任意一个。
=q限制符确保了这一点。 (iii) 受影响列表中包含
memory`,即代码将改变内存中的值。
- 如何设置/清除寄存器中的一个位?
__asm__ __volatile__( "btsl %1,%0"
: "=m" (ADDR)
: "Ir" (pos)
: "cc"
);
此处,`ADDR(一个内存变量)中的
pos`变量对应的比特位将设为1.
我们可以用btrl
替代btsl
来清除一个位。限制符Ir
指出,pos
是一个寄存器,且它的值介于0-31(x86限制符)。即我们可以设置/清除ADDR
变量中任意0~31位值。因为条件码将被改变,我们增加cc
到受影响列表。
- 现在我们看一些复杂但有用的函数。字符串复制。
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
, esi
和 edi
上,是块移动的副作用。所以我们将它们加在受影响列表上。
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.)
- Brennan’s Guide to Inline Assembly
- Using Assembly Language in Linux
- Using as, The GNU Assembler
- Using and Porting the GNU Compiler Collection (GCC)
- Linux Kernel Source