原文链接:GCC-Inline-Assembly-HOWTO
参考链接:How to Use Inline Assembly Language in C Code
虽然在其他地方看到过该文章的翻译GCC Inline Assembly HOWTO[译],GCC 内联汇编 HOWTO,但感觉翻的不够好,大多地方都是机翻。
引言
版权与协议
Copyright ©2003 Sandeep S.
本文是免费的,你可以根据自由软件基金会发布的 GNU 通用公共许可证(GPL v2.0 或由你选择的后续版本)的条款重新发布或修改它。
分发此文档是希望它有用,但不作任何保证;甚至没有对适销性或针对特定目的的适用性的暗示保证。有关更多详细信息,请参阅 GNU 通用公共许可证。
反馈与更正
请将反馈意见和批评意见发给 Sandeep.S。对于指出这份文档中的错误和不准确之处的人我会心怀感激之情;一旦接到通知我将马上改正。
鸣谢
我真诚地感谢 GNU 的人们提供了如此伟大的特性。感谢 Pramode C E 先生的帮助。感谢政府工程学院的朋友们,感谢他们道义上的支持和合作,特别是 Nisha Kurur 和 Sakeeb s。感谢我亲爱的政府工程学院老师们的合作。
此外,感谢 Phillip, Brennan Underwood 和 colin@nyx.net,这里的许多东西都是从他们的作品中偷来的。
概述
我们在这里学习 GCC 内联汇编,那么什么是内联汇编?
我们可以指示编译器,在调用一个函数时,让被调用的整个函数代码插入到调用它的函数中,这种(被调用的)函数称为内联函数。听起来和 C 语言中的宏定义类似。
内联函数有什么优点呢?
这种内联方法减少了函数调用的开销。如果任何实参是常量,它们的已知值可能允许在编译时进行简化,因此不需要包含所有内联函数的代码。对代码大小的影响是未知的,这要具体情况具体分析。要声明内联函数,我们必须在声明中使用关键字 inline
。
现在我们可以猜测什么是内联汇编了。它只是一些编写为内联函数的汇编程序。它们方便,快速,在系统编程时非常有用。我们关注的重点是(GCC)内联汇编函数的基本格式和用法。
内联汇编之所以重要,主要是因为它操作 C 语言变量并使其可见的能力。由于这种能力,asm
用作汇编指令和包含它的 C 语言程序之间的接口。
GCC 汇编语法
GCC 是 Linux 下的 GNU C 语言编译器,使用 AT&T/UNIX 汇编语法。我们也将使用该语法来编写代码。如果你对 AT&T 语法不熟悉也别担心,我会教你。我将给出它与 Intel 语法不同之处。
- 源操作数-目的操作数赋值方向
AT&T 语法中操作数的方向与 Intel 语法相反。在 Intel 语法中,第一个是目的操作数,第二个是源操作数,而在 AT&T 语法中,情况相反。也就是说
操作 目的 源
是 Intel 的语法,而
操作 源 目的
是 AT&T 语法。
- 寄存器名称
寄存器名称需要添加前缀 %
。例如,如果使用 eax
寄存器需要写成 %eax
。
- 操作立即数
AT&T 对立即数的操作需要添加 $
。对于 C 语言的静态变量,使用的也是 $
作前缀(译者注:不清楚这是GCC的什么语法)。在 Intel 语法里,十六进制数用 h
作为后缀,AT&T 使用 0x
前缀来表示十六进制数。因此对于十六进制数,我们首先看到的是 $
,然后是 0x
,最后是数字本身。
- 操作数大小
在 AT&T 语法中,操作数的大小由操作码的最后一个字符决定。操作码的后缀 b
,w
和l
分别代表一个字节(8 bit),一个字(16 bit)和一个双字(32 bit)(译者注:一个四字(64 bit)用q
表示)。Intel 语法通过在操作数(不是操作码)前面加上 byte ptr
, word ptr
和 dword ptr
来实现这一点。
因此,对于 Intel 语法下的指令 mov al, byte ptr foo
在 AT&T 下为 movb foo, %al
。
- 内存操作数
在 Intel 语法中,寄存器使用左右中括号括起来的,而在 AT&T 语法中,变成了左右小括号,此外,在 Intel 语法中间接内存引用是这样的
1 | section: |
在 AT&T 中变成
1 | section: |
需要记住的一点是,当 disp/scale
使用一个常量时,不应该加 $
前缀。
现在我们看到了 Intel 语法和 AT&T 语法之间的一些主要差异。我只写了其中的一些。要获得完整的信息,请参考 GNU Assembler 文档。为了更好地理解,现在我们来看一些例子。
1 | +------------------------------+------------------------------------+ |
译者注:田宇的《一个64位操作系统的设计与实现》中将两者之间的主要差异总结成了下面的表格:
基本内联
基本内联的格式非常直接,形如:
1 | asm("assembly code"); |
例如:
1 | asm("movl %ecx %eax"); /* moves the contents of ecx to eax */ |
可能你注意到了我这里用了 asm
和 __asm__
,二者是等价的。如果我们的程序与关键字 asm
与冲突了可以用 __asm__
来代替。如果有多个指令,则每行写一条,用双引号引起来,并在指令后面加上 \t
和 \n
后缀。这是因为 GCC 将每个指令作为字符串发送给 as(GAS),通过使用换行符或制表符,我们将正确格式化的行发送给汇编器。
举例:
1 | __asm__ ("movl %eax, %ebx\n\t" |
在我们的代码中,如果我们影响了(例如,更改内容)一些寄存器的值,而没有在汇编返回前修复这些更改,那么就会发生不好的事情。这是因为 GCC 不知道寄存器内容的变化,这就给我们带来了麻烦,特别是当编译器对代码进行优化的时候。它将假设某个寄存器包含某个变量的值,而我们可能在没有通知 GCC 的情况下更改了变量的值,GCC 将无视这些变化。我们所能做的是,要么使用那么没有副作用的指令,要么在我们退出的时候修复这些更改,要么,我们就等它崩溃好了。这就是我们想扩展一些功能的地方。扩展汇编为我们提供了这种功能。
扩展内联汇编
基本内联汇编中,我们只有指令。在扩展汇编中,我们还可以指定操作数。这允许我们指定输入的寄存器,输出的寄存器和被破坏的寄存器列表(clobber 列表:如果内联汇编代码使用了没有被初始化地声明为输入值或者输出值的任何其他寄存器,则要通知编译器,编译器必须知道这些寄存器,避免使用它们)。指定要使用的寄存器并不是强制要求的,我们可以把这个难题留给 GCC 来解决,这可能更适合 GCC 的优化方案。扩展内联汇编的格式如下:
1 | asm ( assembler template |
assembler template 由汇编指令组成。每个操作数都由一个操作数约束字符来描述,后面跟 C 语言表达式(圆括号括起来)。第一个冒号将 assembler template 与第一个输出操作数分隔开,第二个冒号将最后一个输出操作数与第一个输入操作数(如果有的话)分开。逗号分隔每个组内的操作数。操作数的总数限制为 10,或者汇编描述中的任何指令格式中的最大操作数数目,以较大者为准。
如果没有输出操作数但有输入操作数,那么也需要有一个冒号放在输出操作数的位置。
例如:
1 | asm ( "cld\n\t" |
上面的代码做了什么事呢?上面的内联代码把 fill_value
乘以 count
的值填充到 %edi
寄存器里。它还告诉 GCC,寄存器 %eax
和 %edi
的内容发生了变化。再来看一个更清楚的例子:
1 | int a=10, b; |
这段代码所做的就是使 ‘b’ 变量的值等于 ‘a’ 变量的值。一些有意思的地方是:
- “b” 作为输出操作数,用 %0 引用,“a” 作为输入操作数,用 %1 引用
- “r” 为操作数约束。之后我们会更详细地了解约束。目前,“r” 告诉 GCC 可以使用任意寄存器存储操作数。输出操作数约束有一个约束修饰符 “=”,这表明它是一个只读的输出操作数。
- 寄存器名字用两个 “%” 作前缀,这有利于 GCC 区分操作数和寄存器。操作数用一个 “%” 作前缀。
- 第三个冒号之后的 clobbered 寄存器
%eax
用于告诉 GCC它的值将会在内联汇编阶段被修改,所以 GCC 将不会使用此寄存器存储任何其他值。
当内联汇编执行完毕后,“b” 变量将会映射到新的值,因为它被指定为输出操作数。换句话说,内联汇编内部 “b” 变量的修改会被映射到内联汇编的外部。
现在让我们详细地看看每个部分的细节。
汇编程序模板
汇编程序模板包含了被插入到 C 程序的汇编指令集。其格式为:每条指令用双引号引起,或者整个指令组用双引号引起。同时每条指令应以分界符结尾。有效的分界符有换行符(\n
)和分号(;
)。\n
可以紧随一个制表符(\t
)。我们应该都明白使用换行符或制表符的原因了吧(译注:前面已有说明)。和C语言表达式对应的操作数用 %0、%1 … 等等表示。
操作数
C 语言表达式用作内联汇编指令里的操作数。每个操作数的前面以双引号引起的是操作数约束。对于输出操作数,在引号内还有一个约束修饰符。其后紧随一个用于表示操作数的 C 表达式。
即,“操作数约束”(C表达式)
是一个通用格式。对于输出操作数,还有一个额外的修饰符。约束字符串主要用于决定操作数的寻址方式,同时也用于指定使用的寄存器。
如果我们使用多个操作数,它们之间应该用逗号隔开。
在汇编程序模板中,每个操作数用数字来引用。编号方式如下:如果总共有 n 个操作数(包括输入和输出操作数),那么第一个输出操作数编号为 0,逐项递增,并且最后一个输入操作数编号为 n-1。操作数的最大数目我们前一节已经讲过。
输出操作数表达式必须为左值。输入操作数的要求不这么严格,它们可以为表达式。扩展汇编的特性常常用于编译器所不知道的机器指令。如果输出的表达式无法直接寻址(例如,它是一个位域),我们的约束字符串必须给定一个寄存器。在这种情况下,GCC 将会使用该寄存器存储最终的结果,然后把该寄存器的值输出。
如前文所述,普通的输出操作数必须为“只写”的。GCC 将会假设指令前的操作数值是死的,不需要(提前)生成。扩展汇编也支持输入-输出,或者读-写操作数。
所以现在我们来关注一些实例。我们想要求一个数乘以5的结果,为了计算该值,我们使用 lea
指令
1 | asm ( "leal (%1,%1,4), %0;" |
这里,我们的输入为 x
。我们不指定使用的寄存器。GCC 将会选择一些寄存器作为输入和输出的寄存器。如果我们想要输入和输出放在同一个寄存器里,我们可以指定寄存器这样做。这里我们使用读-写操作,通过合适的约束来实现它。
1 | asm ( "leal (%0,%0,4), %0;" |
现在输出和输入操作数位于同一个寄存器。但是我们无法得知是哪一个寄存器。现在假如我们也想指定操作数所在的寄存器,这里有一种方法。
1 | asm ( "leal (%%ecx,%%ecx,4), %%ecx;" |
在以上三个示例中,我们并没有往 clobber 列表中添加任何寄存器,为什么呢?前两个示例,GCC 自己决定使用哪个寄存器,所以它知道哪些寄存器会发生改变。在最后一个示例中,我们不必将 ecx
添加到 clobber 列表中,GCC 知道它表示 x。因此,因为它可以知道 ecx
的值,他就不被当作 clobber 了。
Clobber 列表
一些指令会破坏一些寄存器的内容。我们不得不在 clobber 列表中指明这些被修改的寄存器,即扩展内联汇编中第三个 :
之后的区域。这可以通知 GCC “我们自己使用和修改了这些寄存器”,这样GCC 就不会假设存入这些寄存器的值是有效的了。我们不应在这个列表里列出输入、输出寄存器,因为 GCC 知道这些汇编使用了它们(因为它们被约束显示地指定了)。如果指令隐式或显式地使用了任何其他寄存器(并且寄存器没有出现在输如或者输出的约束列表里),那么就需要在 clobber 列表中指定这些寄存器。
如果我们的指令可以修改条件码寄存器(cc),我们必须将 cc
添加到 clobber 列表。
如果我们的指令以不可预测的方式修改了内存,那么需要将 memory
添加到 clobber 列表。这可以使 GCC 在整个汇编指令中不保存缓存在寄存器中的内存值。如果汇编的输入或输出中没有列出受影响的内存,我们还必须添加 volatile
关键字。
我们可以按我们的需求多次读写 clobber 列表中的寄存器。参考一下模板内多条指令的示例,它假设子函数 _foo 把寄存器 eax 和 ecx 中的的值当作自己的参数。
1 | asm ("movl %0,%%eax; |
5.4 Volatile … ?
如果你对内核源码比较熟悉,或者看过类似的很棒的代码,你一定见过许多声明为 volatile
或者 __volatile__
的函数,其后跟着一个 asm
或者 __asm__
。我前面提到过关键字 asm
和 __asm__
,那什么是 volatile
呢?
如果我们的汇编语句必须再我们放置它的地方执行(例如,不能为了优化而把它移出循环),那么把关键字 volatile
放在 asm
之后,()
之前,以防止它被移动、被删除或者其他操作。我们将其声明为 asm volatile (... : ... : ... : ...);
。
如果担心关键字冲突,可以使用 __volatile__
。
如果我们的汇编语句只是用于一些简单的计算,那就最好不要使用 volatile
。不使用的话 GCC 会把代码优化得更漂亮。
在第7节中,我提供了多个内联汇编的例子。在那里可以了解到 clobber 列表的细节。
关于约束
到目前为止,你可能已经知道了约束与内联汇编之间有很大的联系。但是我们对约束讲的不多。约束用于表明一个操作数是否在寄存器中,以及寄存器的类型;操作数是否是一个内存引用以及地址的类型;操作数是否可能是一个立即数,它可能有哪些值(即取值的范围),等等。
常用约束
在许多的约束中,只有一小部分是比较常用的,我们来看看这些约束。
- 寄存器操作数约束(r)
当使用这种约束指定操作数时,它们存储在通用寄存器中(GPR)。请看下面的示例:
1 | asm ("movl %%eax,%0;" :"=r"(myval)); |
这里,变量 myval
保存在寄存器中,寄存器 eax
里的值被复制到该寄存器中,并且 myval
的值从寄存器更新到了内存。当指定为 “r” 约束时,GCC 可以将变量保存在任何可用的 GPR 中。要指定寄存器,你必须使用特定的寄存器约束直接指定寄存器名。它们是:
1 | +---+--------------------+ |
- 内存操作数约束(m)
当操作数位于内存时,对它们执行的任何操作都将直接发生在内存位置,这与寄存器约束不同,寄存器约束首先将值存储在要修改的寄存器中,然后将其写回内存位置。但是寄存器约束通常用于指令必须使用它们,或者使用它们可以显著提高处理速度的情况下使用。当需要在 asm 内更新一个 C 变量,而又不想使用寄存器去保存它的值,使用内存约束是最有效的。例如,idtr 的值存储在内存 loc 处:
1 | asm("sidt %0;" : :"m"(loc)); # 译者注: sidt 指令用于保存中断描述符 |
- 匹配(数字)约束
在某些情况下,一个变量可能既是输入操作数,也是输出操作数。这种情况可以通过匹配约束在 asm 中指定。
1 | 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”:允许任何寄存器、内存或者立即数操作数,但不包括通用寄存器之外的寄存器。
以下约束为 x86 特有:
1.) “r”: 寄存器操作数约束,查阅上面给的表格
2.) “q”:寄存器 a、b、c 或 d
3.) “I”:范围从 0 到 31 的常量(对于32位移位)
4.) “J”:范围从 0 到 63 的常量(对于64位移位)
5.) “K”:0xff
6.) “L”:0xffff
7.) “M”:0、1、2 或 3 (lea 指令的移位)
8.) “N”:范围从 0 到 255 的常量(对于 out 指令)
9.) “f”:浮点寄存器
10.) “t”:第一个(栈顶)浮点寄存器
11.) “u”:第二个浮点寄存器
12.) “A”:指定 a
或 d
寄存器。这主要用于想要返回 64 位整数值,d
寄存器包含最高的有效位,a
寄存器包含最低的有效位
约束修饰符
当使用约束时,对于更精确地控制约束的效果,GCC 为我们提供了约束修饰符。最常用的约束修饰符为:
- “=”: 这个操作数对这条指令来说是只写的,之前的值将被丢弃,并被输出的数据替换
- “&”:这个操作数是一个早期改动的操作数,它在使用输入操作数完成指令之前被修改。因此,这个操作数不能位于一个被用作输入操作数或任何内存地址部分的寄存器中。如果在旧值被写入之前它仅用作输入,那么它可以是一个早期改动的操作数。
上述的约束列表和解释并不完整。接下来的示例可以让我们更好的理解内联汇编的用途和用法。在下一节中,我们会看到一些示例,在那里我们会发现更多关于 clobber-list 和约束的东西。
一些实用的诀窍
我们已经介绍了关于 GCC 内联汇编的基础理论,现在我们专注于一些简单的例子。将内联汇编函数写成宏的形式总是很方便的。我们可以在 Linux 内核代码里看到许多汇编函数(/usr/src/linux/include/asm/*.h)。
- 首先我们从一个简单的例子入手。看下面这个两个数相加的程序。
1 | int main(){ |
这里我们要求 GCC 将 foo 存放于 %eax
,将 bar 存放于 %ebx
,同时我们也想要在 %eax
中存放结果。 ‘=’ 符号表示它是一个输出寄存器。现在我们可以以其他方式给变量添加一个整数。
1 | __asm__ __volatile__("lock;" |
这是一个原子加法。我们可以通过移除指令 “lock” 来移除其原子性。在输出字段中,“=m” 表明 my_var 是一个输出,且位于内存。类似地,“ir” 表明 my_int 是一个整型,应该保存在某个寄存器中(回想下我们上边列的表)。这个里面没有寄存器位于 clobber-list 中。
- 现在我们将在一些寄存器或变量上展示一些操作,并比较值
1 | __asm__ __volatile__("decl %0;" |
这里 my_var 的值减 1,并且如果结果的值为 0,则变量 cond 置 1。我们可以通过将指令 lock;\n\t
添加到内联汇编模板的第一条指令以增加其原子性。
同样地,为了使 my_var 的值加1,我们可以使用 incl %0
来代替 decl %0
。
这里需要注意的是,(1) my_var 是一个存储于内存的变量;(2) cond 位于寄存器 %eax、%ebx、%ecx、%edx 中的任意一个(约束 “=q” 保证了这一点);(3) memory 位于 clobber-list 中,也就是说,这段代码将改变内存中的内容。
- 如何置 1 或清 0 寄存器中的一个比特位?
1 | __asm__ __volatile__("btsl %1, %0;" |
这里,ADDR 变量(一个内存变量)的第 pos 位上被置为 1。我们可以使用 btrl
来清除由 btsl
设置的比特位。pos 的约束 “Ir” 表明 pos 位于寄存器,并且它的值为 0-31 (x86 相关约束)。也就是说,我们可以设置或清除 ADDR 变量上第 0-31 位的任意位。由于条件码会被改变,所以我们将 “cc” 添加到 clobber-list。
- 现在我们看一些更为复杂但有用的函数。字符串拷贝
1 | static inline char* strcpy(char* dest, const char* src){ |
dest 存放于 edi,src 存放于 esi。当到达字符串末尾(“0”)时,拷贝完成。约束 “&S”,“&D”,“&a”表明 esi, edi 和 eax 是早期 clobber 的寄存器。也就是说,它们的内容在函数完成前会被改变。这里很明显 memory 被放进了 clobber-list里。
我们可以看到一个类似的函数,它能移动一个双字的数据。注意,函数被声明成一个宏。
1 |
这里我们没有输出,寄存器 ecx、esi 和 edi 的内容发生了改变,这是块移动的副作用。因此我们必须将它们添加进 clobber-list。、
- 在 Linux 中,系统调用使用 GCC 内联汇编实现。让我们看看如何实现一个系统调用。所有的系统调用被写成宏(linux/unistd.h)。例如,带有三个参数的系统调用被定义为如下所示的宏。
1 |
每当进行带有三个参数的系统调用时,都会使用上面所示的宏进行系统调用。系统调用编号放在 eax 中,参数分别位于 ebx、ecx、edx 中。int 0x80
是一条用于执行系统调用的指令。返回值存储在 eax 中。
每个系统调用都以类似的方式实现。exit 是仅有一个参数的系统调用,它的代码如下所示:
1 | { |
exit 的系统调用编号是 “1”,同时它的参数是 “0”。因此我们把 eax 的值设置为 1,ebx 的值设置为 0,通过 int $0x80
指令就可以执行 exit(0)
。这就是 exit 的工作原理。
结束语
本文档介绍了 GCC 内联汇编的基本知识。一旦你理解了这个基本概念,你就可以按照自己的需求去使用它了。我们看到了一些示例,它们有助于理解 GCC 内联汇编的常用特性。
GCC 内联是一个庞大的主题,本文是不完整的。我们所讨论的语法的更多细节可以在 GNU Assembler 的官方文档中找到。类似地,要获得约束的完整列表,请参阅 GCC 的官方文档。
当然,Linux 内核大规模地使用 GCC 内联。因此,我们可以在内核源代码中找到各种各样的例子。他们可以帮助我们很多。
如果您在本文档中发现任何明显的错别字或过时的信息,请告诉我们。