标签:做了 编程 引号 传递 mos 通过 top 内存 scale
原文作者 Sandeep.S
英文原文 [https://www.ibiblio.org/gferg/ldp/GCC-Inline-Assembly-HOWTO.html]
本文将介绍GCC编译环境下,在C语言代码中嵌入汇编代码的基本方法。阅读本文需要您具备80X86汇编语言和C语言的基础知识。为了使中文描述更加清楚自然,翻译过程中加入了稍许解释和意译部分。
版权/反馈/勘误/感谢等信息。[^ 1]
[^ 1]:这里信息价值不大,没有翻译。具体参加原文:https://www.ibiblio.org/gferg/ldp/GCC-Inline-Assembly-HOWTO.html#s1
在讨论GCC内联汇编之前,我们先来搞搞清楚,到底什么是内联汇编?
先看在C语言中,我们可以指定编译器将一个函数代码直接复制到调用其代码的地方执行。这种函数调用方式和默认压栈调用方式不同,我们称这种函数为内联函数。内联函数看起来很像宏?两者确实有许多共同之处。
那么,内联函数有哪些优点呢?
很明显,内联函数降低了函数的调用开销:如果多次被调用的某个函数实参相同,那么它的返回值一定是相同的,这就给编译器留下了优化空间。此时编译器完全可以直接用这个返回值替代这个函数,而不必把该函数的代码插入到调用者的代码中再去计算结果了。如此一来,不但减少了代码量,还节省了计算资源。指定编译器将一个函数处理为内联函数,我们只要在函数申明前加上inline关键字即可。
基于对上述内联函数的认知,我们大概可以想象出内联汇编到底是怎么一回事了。内联汇编相当于用汇编语句写成的内联函数。它方便,快速,对系统编程有着举足轻重的作用。本文主要就GCC内联函数的格式和使用方法展开讨论。在GCC中声明一个内联汇编函数,我们要用asm这个关键字。
之所以内联汇编如此有用,主要是因为它可以操作C语言变量,比如可以输出值到C语言变量。这个特性使内联汇编成为汇编代码和调用其C程序之间的桥梁。
GCC (GNU Compiler for Linux) 使用AT&T/UNIX汇编语法。所以这篇文章将会用AT&T汇编格式来写汇编代码。如果你不熟悉AT&T汇编语法也没有关系,下面会有些简单的介绍。AT&T和Intel汇编语法差别比较大,二者主要不同之处如下:
OP-code dst src //Intel语法
Op-code src dst //AT&T语法
寄存器命名
在AT&T汇编中, 寄存器名前有%前缀。例如,如果要使用eax,得写作: %eax。
立即数 (Immediate Operand)
在AT&T语法中,立即数(Immediate Operand)都有‘$‘前缀。引用的C语言静态变量 (static C variables) 也必须放上‘$‘前缀;
此外,在Intel语法中, 16进制的常数是以’h’作为后缀的,但是在AT&T语法中, 是以‘0x’作为前缀的。因此,在AT&T语法中,一个16进制常数的写法是:首先以$开头接着是0x,最后是常数本身。
操作数大小
在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
用AT&T语法写就是:movb foo, %al
内存操作数
在Intel语法中,基址寄存器是放在方括号‘[]’中的,但AT&T是放在小括弧’()’内的。
因此,在Intel语法中,一个间接内存寻址是这么写的:section:[base + index * scale + disp]。
而在AT&T中则应该写成这样:section:disp(base, index, scale)
此外对于AT&T汇编,当一个常数被用作disp或者scale时,不需要‘$‘前缀。这点需要记住。
以上就是AT&T和Intel汇编语法的一些主要不同点。这只是一小部分,具体内容需要参考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"); /* 把 ecx 内容移动到 eax */ __asm__("movb %bh , (%eax)"); /* 把bh中一个字节的内容移动到eax指向的内存 */
你可能注意到了这里使用了两个不同的关键字 asm 和 __asm__。这两个关键字都可以使用。不过当遇到asm关键字与程序其他变量有冲突的时候就必须用__asm__了。如果内联汇编有多条指令,则每行要加上双引号,并且该行要以\n\t结尾。这是因为GCC会将每行指令作为一个字符串传给as(GAS),使用换行和TAB可以将正确且格式良好的代码行传递给汇编器。
举个例子:
__asm__ ( "movl %eax, %ebx\n\t"
"movl $56, %esi\n\t"
"movl %ecx, $label(%edx,%ebx,$4)\n\t"
"movb %ah, (%ebx)");
如果在内联代码中操作了一些寄存器,比如你修改了寄存器内容(而之后也没有进行还原操作),程序很可能会产生一些难以预料的情况。因为此时GCC并不知道你已经将寄存器内容修改了。这点尤其是在编译器对代码进行了一些优化的情况下而导致问题。因为编译器注意不到寄存器内容已经被改掉,程序将当作它没有被修改过而继续执行。所以此时我们尽量不要使用这些会产生附加影响的操作,或者当我们退出的时候还原这些操作。否则很可能会造成程序崩溃。可是如果我们必须要这样操作该怎么办呢?我们可以通过下面的讨论的扩展内联汇编进行。
前面讨论的基本内联汇编只涉及到嵌入汇编指令,而在扩展形式中,我们还可以指定操作数,并且可以选择输入输出寄存器,以及指明要修改的寄存器列表。对于要访问的寄存器,并不一定要要显式指明,也可以留给GCC自己去选择,这可能让GCC更好去优化代码。扩展内联汇编格式如下:
asm ( assembler template
: output operands /* optional */
: input operands /* optional */
: list of clobbered registers /* optional */
);
其中assembler template为汇编指令部分。括号内的操作数都是C语言表达式中常量字符串。不同部分之间使用冒号分隔。相同部分语句中的每个小部分用逗号分隔。最多可以指定10个操作数,不过可能有的计算机平台有额外的文档说明可以使用超过10个操作数。
此外,如果没有输出部分但是有输入部分,我们还得保留输出部分前面的冒号。就像下面这样:
asm ( "cld\n\t"
"rep\n\t"
"stosl"
: /* no output registers */
: "c" (count), "a" (fill_value), "D" (dest)
: "%ecx", "%edi"
);
上述代码做了些什么呢?它主要是循环count次把fill_value的值到填充到edi寄存器指定的内存位置。并且告诉GCC,寄存器ecx[^ 2]和edi中的内容可能已经被改变了。 为了有一个更清晰的理解,我们再来看一个例子:
[^ 2]:原文有误,原文是这里是eax。
int a=10, b;
asm ( "movl %1, %%eax;
movl %%eax, %0;"
:"=r"(b) /* output */
:"r"(a) /* input */
:"%eax" /* clobbered register */
);
上面代码实现的功能就是用汇编代码把a的值赋给b。值得注意的几点有:
当这段代码执行结束后,变量”b”的值将会被改写,因为它是被指定作为输出操作数的。这里可以看出在“asm”内部对b的改动将影响到asm外了,正如之前所说的内联汇编起到桥梁作用。
下面我们将对扩展内联汇编各个部分分别进行详细的讨论。
汇编模板部分就是嵌入在C程序中的汇编指令,格式如下:
”asm”内部使用C语言字符串作为操作数。操作数都要放在双引号中。对于输出操作数,还要用“=”修饰。constraint和修饰都放在双引号内。之后是C表达式了。就像下面这样:"constraint" (C expression) //"=r"(result)
对于输出操作数一定要用 “=“修饰。 constraint主要用来指定操作数的寻址类型 (内存寻址或寄存器寻址),也用来指明使用哪个寄存器。
如果有多个操作数,使用逗号隔开。
在汇编模板部分,我们按顺序用数字去引用操作数,引用规则如下:
如果总共有n个操作数(包括输入输出操作数),那么第一个输出操作引用数字为0,依次递增,然后最后一个操作数是n-1。关于操作数数量限制参见前面的章节。
输出操作数表达式必须是左值,输入操作数没有这个限制。注意这里可以使表达式,不仅仅指一个变量。当编译器不知道有这个机器指令的时候(比如新CPU指令出来的时候,编译器还没有支持该指令),扩展汇编形式就能发挥其用武之地了。如果输出表达式不能直接寻址(比如是[bit-field]), constraint就必须指定一个寄存器。这种情况下,GCC将使用寄存器作为asm的输出。然后保存这个寄存器的值到输出表达式中。
如前文所描述,一般输出操作数必须是只写 (write only)的;GCC将认为在这条指令之前,保存在这种操作数中的值已经过期和不再需要了。当然也支持输入输出类型或者可读可写类型的操作数。
现在我们来看一些例子:
要求把一个数字乘以5,我们可以使用汇编指令lea来实现,具体方法如下:
asm ( "leal (%1,%1,4), %0"
: "=r" (five_times_x)
: "r" (x)
);
这里输入操作数是 ‘x’,因为没有指定具体要使用那个寄存器,GCC会自己选择合适的输入输出寄存器。我们也可以修改constraint部分内容,让GCC固定使用同一个寄存器,具体方法如下:
asm( "lea (%0,%0,4),%0"
: "=r" (five_times_x)
: "0" (x)
);
上面例子中指定GCC始终使用在相同的寄存器来处理输入输出操作数。当然这时我们也不知道GCC具体使那个寄存器,如果需要的话我们也可以像这样指定一个:
asm ( "leal (%%ecx,%%ecx,4), %%ecx"
: "=c" (x)
: "c" (x)
);
上面的三个例子中,我都没有在clobber list部分指定何寄存器。为什么?前两个例子中,因为指定GCC自己选择合适的寄存器,并且GCC知道会改写什么。第三个例子中我们也没有必要把ecx放在clobber list中是因为GCC知道x将存入其中,GCC完全知道ecx的值。所以我们也不用写在clobber list中。
如果某个指令改变了某个寄存器的值,我们就必须在asm中第三个冒号后的Clobber List中标示出该寄存器。为的是通知GCC,让其不再假定之前存入这些寄存器中的值依然合法。输入输出寄存器不用放Clobber List中(看上面就是个例子),因为GCC能知道asm将使用这些寄存器。(因为它们已经显式被指定输入输出标出在输入输出部分) 。其他使用到的寄存器,无论是显示还是隐式的使用,必须在clobbered list中标明。
如果指令中以无法预料的形式修改了内存值,需要在clobbered list中加上”memory”。从而使得GCC不去缓存在这些内存值。此外,如果要改变没有被列在输入和出部分的内存内容时,需要加上volatile关键字说明。clobbered list中列出的寄存器可以被多次读写。
来看一个内联汇编实现乘法的例子,这里内联汇编调用函数_foo,并且接受存在eax和ecx值作为参数:
asm( "movl %0,%%eax;
movl %1,%%ecx;
call _foo"
: /*no outputs*/
: "g" (from), "g" (to)
: "eax", "ecx"
);
如果你熟悉内核代码或者一些类似优秀的代码,你一定见过很多在asm或者asm后的函数声明前加了volatile 或者volatile。前面已经讨论了asm和asm的用途,那volatile有什么用途呢?
如果我们要求汇编代码必须在被放置的位置执行(例如不能被循环优化而移出循环),我们就要在asm之后的“()”前,放一个volatile关键字。 这样可以禁止这些代码被移动或删除,像这样声明:asm volatile ( ... : ... : ... : ...);
同样,如果担心volatile有变量冲突,可以使用__volatile__关键字。
如果汇编代码只是做一些运算而没有什么附加影响的时候最好不要使用volatile修饰。不用volatile能给GCC留下优化代码的空间。
在“常用技巧”章节中的代码示例里有更多的关于volatile的使用详情。[^ 3]
[^ 3]:原文有误: 原文是clobber-list,这样应该是volatile
你可能已经感到我们之前经常提到的constraint是个很重要的内容了。不过之前我们并没有过多的讨论。constraint中可以指明一个操作数是否在寄存器中,在哪个寄存器中;可以指明操作数是否是内存引用,如何寻址;可以说明操作数是否是立即数常量,和其可能是的值(或值范围)。
虽然constraints有很多,但常用的并不多。下面我们就来看看这些常用的constraints。
寄存器操作数constraints: r
如果操作数指定了这个constraints,操作数将被存储在通用寄存器中。看下面的例子:asm ( "movl %%eax, %0" : "=r" (myval));
上面变量myval会被被保存在一个由GCC自己选择的寄存器中,eax中的值被拷贝到这个寄存器中去,并且在内存中的myval的值也会按这个寄存器值被更新。当constraints ”r” 被指定时,GCC可能会在任何一个可用的通用寄存器中保存这个值。当然,你也可以指定具体使用那个寄存器,用下表所列出的constraints:
r | Register(s) |
---|---|
a | %eax, %ax, %al |
b | %ebx, %bx, %bl |
c | %ecx, %cx, %cl |
d | %edx, %dx, %adl |
S | %esi, %si |
D | %edi, %di |
内存操作数constraint: m
当操作数在内存中时,任何对其操作会直接在内存中进行。与寄存器constraint不同的是:指定寄存器constraint时,内存操作时先把值存在一个寄存器中,修改后再将该值写回到该内存中去。寄存器constraint通常只用于必要的汇编指令,或者用于能明显加快操作速度的情况,因为内存constraint能提升C语言变量更新效率,完全没必要通过一个寄存器来中转。下面这个例子中,sidt的值会被直接存储到loc所指向的内存:asm (“sidt” %0” : : “m”(loc) );
匹配constraint
在某些情况下,一个变量可能被用来传递输入也用来保存输出。这种情况下我们需要用到匹配constraint。asm (“incl %0” :”=a”(var) : “0”(var) );
在之前章节中我们已经看过类似的例子。上面的例子中,%eax被用来传递输入也用来保存输出。输入变量先被读入eax中,incl执行之后,%eax被更新并且保存到变量var中。这里的constraint ”0”就是指定使用和第一个输出相同的寄存器,即输入变量指定放在eax中。这种constraint可以使用在如下场景:
其他可能用到的constraint有:
这里是一些x86特有的constraint:
在使用constraint的时候,为了更精确的控制约束,GCC提供了一些修饰符,常用的修饰符有:
对于cosntraint的解释还远远没完。代码本身是理解内联汇编最好的老师。下一小结中我们就来看一些代码示例。通过这些示例我们能学到更多关于clobber-list和constraint使用情况。
到目前为止,GCC内联汇编基础知识就已经讲完了。接下来让我们通过一些简单的例子来巩固我们所学到到知识。内联汇编函数可以很方便的用宏的形式来编写,linux内核代码中有很多这样的实例(在/usr/src/linux/asm/*.h)。
int main(void)
{
int foo = 10, bar = 15;
__asm__ __volatile__ ( "addl %%ebx, %%eax"
: ”=a”(foo)
: ”a”(foo), “b”(bar)
);
prinft(“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是一个整型数并且要保存到一个寄存器中(可以参考上面关于constraint的列表)。这里clobber list中没有指定任何寄存器。
__asm__ __volatile__ ( "decl %0; sete %1"
: "=m" (my_var), "=q" (cond)
: "m" (my_var)
: "memory"
);
上面的程序将~my_var~减一并且如果减一的最终结果为零就将cond置位。我们可以在汇编语句之前加上~”lock;\n\t”~让其变成原子操作。
类似的,我们可以用”incl %0”替换”decl %0”来增加~my_var~的值。
这里值得注意的几点有:
- my_var是存在内存中的变量;
- cond存在通用寄存器中(eax,ebx,ecx,edx),因为有限制条件”=q”;
- clobber list中指定了“memory”,说明代码将改变内存值。
3. 如何设置和清除寄存器中的某一位?来看看下面这个技巧。
```
__asm__ __volatile__( “btsl %1, %0”
: “=m” (ADDR)
: “Ir” (pos)
: “cc”
);
上面例子中变量ADDR(一个内存变量)的’pos’位置值被设置成了1。我们可以使用btrl指令来清除由btsl设置的位。pos变量的限定符constraint为”Ir”说明pos放在寄存器中,并且取值范围是0-31(I是一个x86相关constraint)。因此我们可以设置或者清除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结束。约束符constraint 为”&S”,”&D”,”&a”,指定了使用的寄存器为esi,edi和eax。很明显这些寄存器是clobber寄存器,因为它们的内容会在函数执行后被改变。此外我们也能看出为什么memory被放在clobber list中,因为d0, d1, d2被更新了。
我们再来看一个类似的函数。该函数用来移动一块双字(double word)。注意这个函数是用宏来定义的。
`#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 list中。
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中。
Linux中所有的系统调用都是用上面类似的方式实现的。比如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,所以我们把1放到eax中并且把0放到ebx中,最后通过调用int $0x80,exit(0)就被执行了。这就是exit函数的全部。
这篇文章讲述了GCC内联汇编的基础知识。一旦你理解了这些基础内容,自己再一步步的看下去就没有什么困难了。通过这些例子可以更好的帮助我们理解内联汇编的常用特性。
GCC内联汇编是一个很大的主题,这片文章的讨论还远远不够。本篇文章我们提到的大多数语法都可以在官方文档GNU Assembler中看到。完整的constraint也可以在GCC官方文档中找到。
Linux内核大范围内使用了GCC内联汇编,我们可以从中找到各种各样的例子来学习。这对我们也很有帮助。
如果你找到任何低级的排版打字错误或者过期的内容,请联系我。
标签:做了 编程 引号 传递 mos 通过 top 内存 scale
原文地址:https://www.cnblogs.com/xphh/p/11491489.html