UNIX-C 88 - GCC Inline Assembly (unfinished)
# 前置知识
本文内容涉及汇编语言,在阅读本文档前,请确保你已掌握汇编语言的基础知识,详见 x86 汇编的知识板块。
本文内容相对较为深入,在阅读本文档前,建议你先阅读我的其它所有非进阶文档,详见 C 语言栏目。
请注意:本文是面向进阶读者的,在阅读本文档前,请确保你已经具备了较为完整的 C 语言基础。
# 前言
内联汇编 (inline assembly) 指的是在 C 程序代码中嵌入的汇编指令,也就是对高级语言和汇编语言的混合使用。使用内联汇编可以读写指定的寄存器,例如将 C 语言变量 的值存入指定的寄存器 RDI ,又或是直接操作栈指针,这在底层系统编程中具有重要作用。
- 在 Linux 内核代码中(例如
/usr/src/linux/include/asm/*.h
)可以看到很多内联汇编的调用和宏定义。
内联汇编的代码容易出错、难以维护且不可移植(我们甚至需要在文中专门用一节来解释这件事情),并且实际上可能比 C 语言更慢,因此如果打算在程序中使用内联汇编,最好先考虑是否存在不使用内联汇编解决问题的方法。
GCC 编译器提供 asm
关键字来支持内联汇编,包括两种形式的内联汇编语句:
basic asm statement :不含操作数的内联汇编语句。
extended asm statement :包含一个或多个操作数的内联汇编语句。
值得注意的是,GCC 官方文档中有明文建议,如非必要应尽量使用 extended asm 而非 basic asm ,具体原因详见后文。换句话说,如果读者只是希望简单使用内联汇编而不关心其背后的故事,可直接跳过 basic asm 去学习 extended asm 。
注意在 C 语言中 asm
关键字属于 GNU 扩展(在 C++ 语言中属于标准关键字),使用诸如 -ansi
或 -std=c99
的编译选项会禁用该关键字。此时可以使用 __asm__
作为替代,也可以使用如下代码来解决兼容性问题:
#ifndef __GNUC__
#define __asm__ asm
#endif
在 x86 环境下,可以使用 -masm=<dialect>
参数指定 GCC 输出的汇编程序是使用 AT&T 语法还是 Intel 语法,对应的 <dialect>
分别为 att
和 intel
,未使用该参数指定时默认为 att
。该选项同时还会指定内联汇编所用的语法。
除非另外声明,本文所有代码示例中的汇编代码均使用 AT&T 语法。
考虑到内联汇编的语法较为复杂,建议读者先粗读本文,再详细阅读以完全学习内联汇编的基础语法。如果确有实际需求,再通过 GCC 官方文档深入学习有关内联汇编的更多细节。
# Basic Asm
# 语法规则
basic asm 的语法规则很简单:
asm [qualifiers] ("assembler instructions")
其中:
qualifiers
是可选的限定符,可以包含 volatile
和 inline
。
volatile
:在 basic asm 语句中,volatile
是可选但无效的,因为所有的 basic asm 块都是隐式 volatile 的。inline
:TBD
"assembler instructions"
包含了将被内嵌的汇编指令,该字符串可以包含的任何能被汇编器识别的指令,包括 directives 。
- 该字符串可以包含多条汇编指令,需要用系统汇编代码所用的分隔字符将指令分开。常用的是
\n\t
或;
。
一些简单的 basic asm 的代码示例如下:
// moves the contents of ecx to eax
asm ("movl %ecx %eax");
// debug break
asm ("int $3");
// a multi-instruction example
asm (
"movl %eax, %ebx\n\t"
"movl $56, %esi\n\t"
"movl %ecx, $label(%edx,%ebx,$4)\n\t"
"movb %ah, (%ebx)"
);
和参数丰富的 extended asm 不同,basic asm 不以任何 C 符号作为参数,而是纯粹地直接嵌入汇编指令,因而难以与 C 程序进行交互。
# 注意事项
编译器优化可能会移动 asm 语句的位置,因此如果我们期望某些指令在目标文件中仍然保持连续,应将它们放在单个 asm 语句中。
GCC 不解析 basic asm 的 AssemblerInstructions,这意味着无法将它们内部发生的事情传达给编译器。GCC 在 asm 中看不到符号,并且可能将它们作为未引用的符号丢弃。它也不知道汇编指令的副作用,例如对内存或寄存器的修改。与某些编译器不同,GCC 假定通用寄存器不会发生变化。
编译器将 basic asm 中的汇编程序指令逐字复制到汇编语言输出文件中,而不处理方言或任何可用于 extended asm 的 %
运算符。这导致 basic asm 字符串和 extended asm 模板之间的细微差别。例如,要引用寄存器,您可以在 basic asm 中使用 %eax
,在 extended asm 中使用 %%eax
。
对于具有非空汇编器字符串的基本汇编,GCC 假定汇编器块不会更改任何通用寄存器,但它可以读取或写入任何全局可访问的变量。
# Extended Asm
# 基本语法
extended asm 易于访问 C 语言的符号,包括读写 C 变量、跳转到 C 标签以及调用 C 函数,其功能性要胜于 basic asm ,但是语法也要复杂得多。
extended asm 的基本语法规则如下,其使用冒号 :
来分隔汇编器模板之后的参数,并且各参数可以为空:
asm [qualifiers] (
"assembler template"
: output operands
[ : input operands
[ : clobbers
[ : goto labels ] ] ]
);
这看起来有些复杂,所以我们提供一个解析的版本——实际上该规则表示 extended asm 语句可以书写成下列形式之一:
asm [qua] ("ass" : out);
asm [qua] ("ass" : out : in);
asm [qua] ("ass" : out : in : clob);
asm [qua] ("ass" : out : in : clob : goto);
qualifiers :
qualifiers
是可选的限定符,可以包含 volatile
,inline
和 goto
。
volatile
:extended asm 语句通常用于操纵 input operands 以产生 output operands ,但是该语句也可能产生副作用,因此可能需要使用volatile
限定符来禁用编译器的某些优化。inline
:TBDgoto
:仅当使用了goto labels
时才可选择goto
限定符,这将告知编译器该 extended asm 语句可以跳转到goto labels
中列出的标签之一。为便于描述,我们将使用了goto
限定符的 asm 语句记为 asm goto 语句。此外,asm goto 语句是隐式 volatile 的。
"assembler template" :
"assembler template"
包含了将被内嵌的汇编指令的模板,该字符串是固定文本和代表各参数的符号的组合。
与 basic asm 中的汇编器指令串不同,extended asm 中的汇编器模板串会使用一些符号来表示对应的操作数,具体语法详见后文。
该字符串可以包含多条汇编指令,需要用系统汇编代码所用的分隔字符将指令分开。常用的是
\n\t
或;
。
operands, clobbers, and goto labels :
output operands
和 input operands
分别是该 asm 语句将写入和读取的所有 C 变量的标识符列表。
clobbers
是会被该 asm 语句破坏的(除了输入和输出操作数之外的)所有寄存器的列表,例如可能有寄存器被用于存储中间计算结果。有关 clobbers
的具体含义和作用详见后文。
goto labels
是该 asm 语句可能跳转到的所有 C 标签的列表。
需要注意的是:
output operands
,input operands
和clobbers
可以为空,但是如果在其后还使用了其它参数,则需要保留对应的冒号,详见【输入输出参数】一小节中的代码示例部分。output operands
,input operands
和goto labels
中的符号总数不能超过 。
另外需要强调的是,在 extended asm 中,编写正确的 output operands
,input operands
,clobbers
和 goto labels
是非常重要的。由于编译器不会去理解内联汇编指令的语义,这三个参数就是与汇编器沟通的唯一桥梁,编译器在生成目标代码时将据此确保其行为的正确性。参数的错误或缺漏将导致编译器有概率给出不符合预期的结果,其危害程度不亚于未定义行为。
# 初步了解
由于 extended asm 的语法规则较为复杂,本文首先通过一些简单的代码示例对语法的具体运用进行简单的介绍,以便于读者的理解。
下面是一个简单的 extended asm 的代码示例,其以寄存器 EAX 为媒介将 dst
赋值为 src
:
// assign dst := src
int src = 1;
int dst;
asm (
"movl %1, %%eax\n\t"
"movl %%eax, %0\n\t"
: "=r"(dst) : "r"(src) : "eax"
);
在代码示例中:
dst
是输出操作数,在汇编器模板中由%0
引用。src
是输入操作数,在汇编器模板中由%1
引用。"r"
是对操作数的约束,它告诉 GCC 可以使用任意的通用寄存器来存储该操作数。有关该约束的更多写法详见后文。最后的
"eax"
通知 GCC 寄存器 EAX 的值将被该 asm 语句修改,因此 GCC 会避免将该寄存器用作他用。
更具体地讲:
与 basic asm 不同,extended asm 使用的寄存器以
%%
为前缀,而操作数以%
为前缀,这使 GCC 能够区分寄存器和操作数。输出操作数的约束有一个修饰符
=
,用于指定输出操作数为只写模式。
# 输入和输出操作数
extended asm 的 output operands
和 input operands
参数是两个由逗号 ,
分隔的标识符列表,并且这两个列表可以为空。
# 基本语法
在输入输出参数的列表中,表示每个标识符的基本语法规则如下:
[ [symbolic name] ] "constraint" (c expression)
[symbolic name] :
[symbolic name]
是可选的,用于指定操作数的名称(需包含中括号 []
),需与汇编器模板中的符号名称相对应。例如下面的代码中,asm 语句使用名称 foo
来表示输出操作数 x
:
int x;
asm ("movl %%eax, %[foo]" : [foo] "=r"(x));
[symbolic name]
可以和 C 标识符名称相同,这不会产生冲突:
int foo;
asm ("movl %%eax, %[foo]" : [foo] "=r"(foo));
如果不使用 [symbolic name]
,则所有操作数从左到右依次与汇编器模板中的 %0
, %1
, %2
, ... 相对应。例如对于前文【初步了解】一小节所用的代码示例,操作数 dst
和 src
分别为 %0
和 %1
,对应于它们在操作数列表中的出现顺序。
"constraint" :
"constraint"
是一个字符串,用于指定对操作数的约束。有关操作数约束的具体用法详见【操作数约束】一节。
c expression :
c expression
是作为操作数的 C 表达式。对于输出操作数,该表达式必须是一个左值(例如一个 C 变量)。
# 代码示例
这里给出了一些简单的 extended asm 的代码示例来帮助理解。这些示例是捏造的,读者无需关心它们的语义和实用价值。
// assign x := %eax
int x;
asm ("movl %%eax, %0" : "=r"(x));
// assign %eax := x + y
// no output operands, keep the : present
int x = 1, y = 2;
asm ("movl %0, %%eax" : : "r"(x + y) : "eax");
// nothing changed, and no operands
// still need one : present
// otherwise, it will be thought as a basic asm statement
asm ("movl %%eax, %%eax" : );
// assign %eax := 2 and %ebx = %eax + 1
// no operands, but two clobbers
asm (
"movl $2, %%eax\n\t"
"movl %%eax, %%ebx\n\t"
"addl $1, %%ebx\n\t" : : : "eax", "ebx"
);
// assign dst := src
int src = 1;
int dst;
asm ("mov %1, %0" : "=r"(dst) : "r"(src));
# clobbers
extended asm 的 clobbers
是由逗号 ,
分隔的寄存器名称列表,包含会被该 asm 语句破坏的(除了输入和输出操作数之外的)所有寄存器。
- 在
clobbers
中,寄存器名称可以带%
前缀,也可以不带前缀,即%eax
和eax
都是合法的写法。
clobbers
中列出的寄存器不能以任何方式与输入或输出操作数重复。主要是那些被声明存入特定寄存器中的 C 表达式(详见【操作数约束】一节),这些寄存器不能出现在 clobbers
中。
编译器不会选择 clobbers
中的寄存器来存放任何输入或输出操作数,并且在处理 asm 语句前后的 C 程序时以这些寄存器将被或已被破坏为准,因此这些寄存器可用于内联汇编中的任何用途,而不会导致任何正确性问题。
clobbers
不应包含堆栈指针寄存器(例如 ESP 等),因为 GCC 要求堆栈指针的值在执行 asm 语句前后保持不变。然而低版本的 GCC 并没有强制执行此规则,而是允许堆栈指针出现在列表中,这导致了不明确的语义。目前此行为是 deprecated 的,可能会在未来版本的 GCC 中报告错误。
在下面的代码示例中,执行这一系列的汇编指令会破坏寄存器 EAX 和 EBX 在执行之前的值,因此 extended asm 必须声明它对这两个寄存器的破坏,否则 GCC 不能保证能生成正确的目标代码。
int x = 1;
asm (
"movl %0, %%eax\n\t"
"movl $8, %%eax\n\t"
"addl %%eax, %%ebx\n\t"
: : "r"(x) : "eax", "ebx"
);
除寄存器名称之外,还有如下两种特殊的 clobbers
项:
"cc"
表示 asm 语句修改了标志位寄存器,其具体处理方式取决于所用的指令集,但总是有效的。
"memory"
表示 asm 语句对输入和输出操作数以外的项目执行了内存读写操作(例如某个输入参数是指针,有指令读写了其指向的位置)。
为了确保内存包含正确的值,GCC 可能需要在执行 asm 之前将特定的寄存器值刷新到内存中。此外,编译器不会假设在 asm 之前从内存中读取的任何值在该 asm 之后保持不变。
使用
"memory"
事实上是为编译器构建了一个 memory barrier 。将寄存器刷新到内存会影响性能,最好设法避免使用
"memory"
,而是通过其它途径告知 GCC 被修改的内存位置,例如对于作为输入参数的指针p
,我们可以将*p
作为输出参数。
tbd: find another better example ?
Here is a fictitious sum of squares instruction, that takes two pointers to floating point values in memory and produces a floating point register output. Notice that x, and y both appear twice in the asm parameters, once to specify memory accessed, and once to specify a base register used by the asm. You won’t normally be wasting a register by doing this as GCC can use the same register for both purposes. However, it would be foolish to use both %1 and %3 for x in this asm and expect them to be the same. In fact, %3 may well not be a register. It might be a symbolic memory reference to the object pointed to by x.
asm (
"sumsq %0, %1, %2"
: "+f"(result)
: "r"(x), "r"(y), "m"(*x), "m"(*y)
);
# goto 标签
extended asm 的 goto labels
是由逗号 ,
分隔的 C 标签名称列表,包含该 asm 语句可能跳转到的所有 C 标签。
不在 goto labels
内的跳转不被编译器所知晓,因此如果有被遗忘的跳转,在编译器优化时可能会导致问题。
如果 asm goto 语句修改了任何内容,则需要使用 clobber "memory"
强制编译器将所有寄存器值刷新到内存以保证编译器优化的正确性,并且在 asm goto 语句之后重新加载它们(如果有必要的话)。
asm goto 语句是隐式 volatile 的。
asm goto 语句极易出错,建议读者谨慎使用,并尽量避免使用而采用其它方法。
由于我自己也没有用过,所以对于 asm goto 语句就不多写了,更多用法和注意事项详见 GCC 官方文档。
# 操作数修饰符 (x86)
在 extended asm 的汇编器模板中,可以对操作数的引用(例如 %0
等)使用 x86 修饰符,改变操作数在输出到汇编器的代码中的格式。
下面的代码示例使用了 x86 的 h
和 b
修饰符:
int x;
asm ("xchg %h0, %b0" : "+a"(x));
这将生成如下汇编代码:
xchg %ah, %al
下面列出了部分简单常用的 x86 修饰符,有关完整的 x86 修饰符列表请参阅 GCC 官方文档。
x86 修饰符 | 作用 | 操作数引用(%0 ) | 输出汇编代码(%ax ) |
---|---|---|---|
b | 输出 QImode low part | %b0 | %al |
h | 输出 QImode high part | %h0 | %ah |
w | 输出 HImode | %w0 | %ax |
k | 输出 SImode | %k0 | %eax |
q | 输出 DImode | %q0 | %rax |
A | 输出绝对内存引用 | %A0 | *%rax |
# 操作数约束
操作数约束是在 extended asm 语句中施加于操作数的特殊规则,例如指定一个操作数被存入某个确定的寄存器,指定操作数是否可以是一个立即数、是否可以是内存引用,等等。
操作数约束的基本形式是一个包含若干个字母的字符串,每个字母描述一种对操作数的“许可”,例如 "r"
表示允许使用任意的通用寄存器来存储该操作数,而在 x86 机器上 "ab"
表示允许使用寄存器 A 和 B(包括 AX, EAX, RAX 等)来存储该操作数。
本节将简要介绍一些简单常用的约束,并辅以足够的代码示例来帮助读者理解其基本用法。
本节没有介绍全部有关操作数约束的知识,而是只选取了其中最简单的部分,完整内容请参阅 GCC 官方文档。
# 寄存器约束
r
表示允许使用任意的通用寄存器来存储该操作数。这是最常用的约束,我们已经在前文的各种代码示例中见过多次。
在 x86 机器上,a
, b
, c
, d
, S
, D
分别表示可以使用通用寄存器 A ,B ,C ,D ,SI ,DI 。对于其它指令集则需要使用对应的其它约束。
在非必要的情况下,应当避免随意为操作数指派指定的寄存器,在较弱的约束下(例如 r
)有利于编译器和汇编器的优化,使用最合适的寄存器以提高目标程序的执行速度。
sample tbd。
# 数字匹配约束
0
, 1
, ... , 9
表示将该操作数与指定编号的另一操作数相匹配。
使用数字匹配约束通常是因为同一个 C 变量在 extended asm 语句中充当了多重角色,例如同时作为输入和输出操作数,此时使用数字匹配约束可以让编译器更有效地利用寄存器:
int x = 1;
asm ("incl %0" : "=r"(x) : "0"(x));
数字匹配约束应当放置于操作数约束字符串的末尾,例如希望匹配 %0
且存入寄存器 A ,应当使用 "a0"
而非 "0a"
。
# 内存约束
m
表示允许使用内存操作数,即机器支持的任何类型的地址,此时对操作数执行的任何操作都将直接发生在内存位置。相反地,寄存器约束首先将值存储在要修改的寄存器中,然后将其写回内存位置。
# 其它约束
i
表示允许使用整型立即数作为操作数。这包括值在汇编时或汇编后才可知的符号常量。g
表示允许使用任意的通用寄存器、内存或整型立即数。E
表示允许使用浮点立即数作为操作数。X
表示允许使用一切。
# 修饰符
在基本形式的基础上,操作数约束可以将如下修饰符作为其前缀使用:
=
表示该操作数会被写入,+
表示该操作数会被读取和写入,不含二者的操作数默认只会被读取。&
和%
:TBD.
# Basic Asm VS Extended Asm
使用 basic asm 很难安全地访问 C 语言的符号(包括读写变量和调用函数等),因为其只能访问寄存器,这意味着我们需要预知 C 语言变量在当前上下文对应于哪个寄存器。因此,如果需要访问 C 符号,最好使用 extended asm 。
另一方面,使用 extended asm 通常会生成更短、更安全、更高效的代码,在大多数情况下是比 basic asm 更好的解决方案,但在以下两种情况中只能使用 basic asm :
在 file scope(即函数之外的全局区域)书写内联汇编语言时,必须使用 basic asm 语句,因为 extended asm 语句必须书写在 C 函数内部。
在声明了
naked
属性的函数内部书写内联汇编语言时,必须使用 basic asm 语句。
basic asm 适合用于 directives ,或用于定义汇编语言级的宏,或是用汇编语言编写整个函数。另外,注意在 file scope 书写时不能使用 volatile
和 inline
限定符。
GCC 官方文档中有明文建议,为避免未来对语义的更改引起的复杂问题和编译器之间的兼容性问题,考虑尽可能用 extended asm 替换 basic asm 。
有关为什么要将 basic asm 转换为 extended asm ,以及等价转换的方法,详见:
# 深入理解
在了解内联汇编的基础后,一个自然的问题是,我们在编写底层系统的 C 程序时应当如何看待和使用内联汇编?
对于编译器来说,内联汇编的存在就像是黑盒子一般——编译器不会解析内联汇编指令,不知道它们的语义(甚至不知道它们是否合法),但是知道它的输入和输出,即其读写的内容、执行前后受到影响的内容,等等。
事实上,对于程序员来说内联汇编也是如此,我们完全可以将其视为特殊的黑盒函数,在此基础上谨慎编写其输入和输出的“接口”即可。与 C 函数不同,内联汇编的危险性要大得多,但是从 high-level 的角度看,它们确实有不小的共通之处。
# 参考资料
GCC - Using Assembly Language with C (opens new window)
CodeProject - Using Inline Assembly in C/C++ (opens new window)
ibiblio.org - GCC-Inline-Assembly-HOWTO (opens new window)
- 01
- Reading Papers - Kernel Concurrency06-01
- 02
- Linux Kernel - Source Code Overview05-01
- 03
- Linux Kernel - Per-CPU Storage05-01