GNU 扩展:内联汇编

是这样的,我本来是打算去做清华的 ucore OS 实验的, GitBook 上前言里介绍了一下内联汇编,我看了一下发现一堆我不认识的操作,于是就想着复习+预习一波 GNU Compiler Collections 的官方文档

其实之前有写过 GNU 扩展相关的介绍的,由于本人打了一堆一堆之后然后那台 5 岁的古董电脑突然崩溃,而我又忘记保存,心灰意冷,也没有从头再写了。不过话说回来,我浮点数标准那篇文章就是因为研究 GNU 扩展的时候看到并且之后另外研究的。

asm 是 C 语言中的一个关键字,是一个编译器可选实现的关键字。 GNU 编译器实现了最基本的 asm 特性,另外提供了一套扩展。

本文主要介绍的是 GNU 的 asm 扩展,稍微提一下基础 asm 关键字。我使用的架构是 x86-64 ,也只会介绍 x86 家族相关的知识,并且会假设读者熟悉 AT&T 汇编。

大概是个天坑。

基础 ASM

GNU 提供了基础的 ASM 语法,相对于扩展 ASM 语法,更类似一个函数形式:

asm [ volatile ] ( AssemblerInstructions );

举例:

asm volatile ("ret");  // WARNING: This code may leads to stack imbalance.

方括号表示可选,也就是 volatile 是可选。但是有没有在语法上是没有区别的,因为任何基础 ASM 都是隐式 volatile 的。在 ASM 中所谓的 volatile 就是编译器不会对汇编的代码进行分析优化。在基础 ASM 中,编译器不会对汇编内容进行任何检查优化,直接替换到生成的汇编源码中对应的位置。但是有可能在优化外部代码时复制多份、删去 dead code 将代码块重复或删去,因此可能会导致意料外的错误。

因此 GCC 不推荐使用基础 ASM ,但是基础 ASM 仍有其作用。

  • 扩展 ASM 无法在全局作用域声明,此时就必须使用基础 ASM 。此时可以在文件作用域使用汇编指令,定义汇编宏等。
  • 使用 naked 属性的函数必须使用基础 ASM 。

扩展 ASM

利用扩展 ASM ,可以在内联汇编中与 C 语言相交互。但是其语法略复杂,和基础 ASM 的区别就在于多出了至少一个冒号。

语法是

asm [ volatile ] (
  AssemblerTemplate
  : OutputOperands
[
  : InputOperands
[
  : Clobbers
]
]
)

asm [ volatile ] goto (
  AssemblerTemplate
  :
  : InputOperands
  : Clobbers
  : GotoLabels
)

前者是最常用的形式(方括号内为可选项),后者是告诉编译器内联汇编块中可能会有 jmp 到列出在 GotoLabels 中的外部 goto 标签。其中 OutputOperands InputOperands Clobbdrs 或 GotoLabels 均可为空。

编译器会不会解析汇编代码,但是可能会在检查外部 dead code 删除内联汇编块、展开汇编块造成符号重复、缺失,或者对于输入输出进行不恰当的优化,这时候可以加上 volatile 关键字来阻止编译器优化,在解析完后不进行优化直接输出到汇编代码中。

InputOperands 和 OutputOperands 的格式是 [symbolic]"constraints"(variable) , variable 就是对应的变量, constaints 中的符号将在约束中具体讲解。

symbolic 是可选项,它需要是一个 C 标识符名称,声明后可以在 AssemblyTemplate 中使用,相对于 %0 %1 等提供更易读的记号。

输出

在扩展 ASM 中加入了输出之后,就可以进行 C 语言和汇编之间的交互,否则难以使得汇编中的运算结果直接被 C 使用。如 cpuid 指令会将输出存放于 ebx edx ecx 三个寄存器中,但是我们无法直接在 C 语言中存取寄存器内容。即使想方设法将寄存器内容存入栈中,也可能会因为编译器优化等原因,无法保证存取了正确的位置。

这一点利用扩展的 ASM 就很轻松地解决了:

uint32_t genu, ineI, ntel;
asm volatile (
  "xorl %%eax, %%eax\n"
  "cpuid\n"
  : "=b"(genu), "=d"(ineI), "=c"(ntel)
  :
  : "%rax"
);

这样就可以输出到三个变量 genu ineI ntel 中了。

说点关于 cpuid 指令的题外话,这个指令接受一个 eax 为参数,在 eax 为 0 的时候的具体输出值会根据具体的 供应商变化而变化,比如我当前机器的输出分别为 0x756e6547 0x49656e69 0x6c65746e ,每个都是由 4 个可读字符的 ASCII 码组成的,这里是分别是 "genu" "ineI" "ntel" 这三个字符串。每家 CPU 制造商的不同输出可以在 Wikipedia 的 CPUID 条目检索到。 Linux 下 /proc/cpuinfo 里的 vendor_id 就是这个。该指令的所有的对应不同 eax 的返回可以在 Intel 的 Intel® 64 and IA-32 Architectures Software Developer Manual 查到。

xorl %%eax %%eax 是清空 %eax 寄存器,之所以 % 要打两遍,这是由于第一个 % 是作为转义字符存在的。

关于 constraint 的相关知识也会在后面介绍,这里就针对这段代码稍微解释一下:等号 = 表示后面字符表示的寄存器会被覆写,a 表示 raxb 表示 rbxc 表示 rcxd 表示 rdxD 表示 rdiS 表示 rsi

OutputOperands 中的 variable 可以使用任何的 C 左值,InputOperands 中的则可以用任何的 C 右值。这个不需要太多解释。

输入

一个算法,除了需要输出之外,可能还要有输入。在扩展 ASM 中,声明输入的格式和输出类似。还是从一个例子讲起:

int a, b, sum;
scanf("%d%d", &a, &b);
asm volatile (
  "movl %1, %0\n"
  "addl %2, %0\n"
  : "=&r"(sum)
  : "r"(a), "r"(b)
);
printf("%d\n", sum);

这段代码是求和用的,将读入的 ab 相加,结果存入 sum 中。

constaits 中的单个 "r" 指的是任意寄存器,由编译器来分配,与之相对应的是储存到内存中的 "m" 。这里 "=&r"& 先不用理会,后面在约束修饰符中会介绍。代码中 %0 %1 %2 就分别指代在 InputOperands 和 OutputOperands 中的声明的使用的寄存器, 按顺序递增,此处 %0 指代分配给 sum 的寄存器, %1 指代分配给 a%2 指代分配给 b 的寄存器们。

除了编译器直接分配 0 1 2 … ,程序员也可以自己指定名字,格式是在 constaint 前用方括号里声明一个标识符,可以用任何 C 语言标识符允许的名字,如 [ident]"r"(v),可以在 AssemblerTemplate 中使用 %[ident] 来替代,但是仍然占据一个编号,相当于 symbolic 是取了个别名。比如上面的代码改一下可以是:

int a, b, sum;
scanf("%d%d", &a, &b);
asm volatile (
  "movl %[lhs], %[result]\n"
  "addl %[rhs], %[result]\n"
  : [result]"=&r"(sum)
  : [lhs]"r"(a), [rhs]"r"(b)
);
printf("%d\n", sum);

Clobbers

说实话,不知道怎么翻译。

这一块是让我们告诉编译器:除了以上列出来的输入输出可能会在代码中被改变之外,还有哪些东西可能会被改变。除了普通的寄存器如 "%rax" "%rbx" 等等如果在代码中改变需要指出之外,还包括 "cc" ,也就是 Condition Control ,在 x86-64 中是 rflags 寄存器。如果修改了内存,则用 "memory" 来提示编译器。

这里有个比较著名的“内存屏障”就是用这个来实现的,内存屏障的代码是:

asm volatile("" ::: "memory");

它可以阻止编译器将内存屏障上下的内存读写指令优化到一起,也就是对于内存读写操作的优化不可能跨越内存屏障,这在多线程使用 atomic 变量的时候可以防止编译器优化读写打乱内存顺序。

Goto 标签

Goto 的声明多出了 GotoLabels 那一块列出所有用到的标签,在 AssemblerTemplate 中使用 %l + id 来引用标签。关于这一块懒得打太多,举个例子:

asm goto (
    "syscall"
    "test %%rax, %%rax\n"
    "js %l3"
    : // The OutputOperands has to be left empty
    : "a"(1), "D"(STDOUT_FILENO), "S"("Hello, World!\n"), "d"(14)
    : "cc"
    : error
  );
  // do something
error:
  // error handling

%l + id 来指定一个在 GotoLabels 中声明的标签, id 是一个数字,和其他几个未指明变量一样递增,在这里 id 是 3 。除此之外,也可以和 symbolic 类似使用 %[error] 来标识 error 标签。

操作符修饰符

只介绍 x86-64 的。在汇编中,我们可以用 rax eax ax ah al 来指定使用寄存器的哪个部分,与之对应的,在内联汇编中提供了类似方法修饰符来指定字节数:

修饰符 描述 Operand AT&T Intel
z 当前 Operand 对应的汇编后缀 %z0 b / w / l / q
b 最低有效位 %b0 %al al
h 次低有效位 %h0 %ah ah
w 最低有效字 %w0 %ax ax
k 最低有效双字 %k0 %eax eax
q 完整记号 %q0 %rax rax
l 标签名 %l2 .L2 .L2
c constant 无立即数记号 ($) 常量 %c1 233 233

约束

也就是上文中的 constraints 。约束是用来指定某个输入输出放置在内存还是寄存器、如何放置的问题的。对于每种架构都有一套不同的约束,不过还是有最基本的一些约束是各个架构都通用的。本文只介绍 x86-64 的架构,因此只会罗列 x86 家族的机器相关约束。

通用约束

首先是通用约束,在各个机子上都可以使用:

约束名 对应储存单元
X 任何可用方式。
g general ,任何可用寄存器、内存或立即数。
m memory ,任意可用内存。
o offsettable memory ,可偏移寻址内存。
r register ,任意寄存器。
i immediate integer,包括汇编期符号常量。
n immediate integer,不包括符号常量。
E immediate floating point 。
F immediate floating point ,可以为向量值。
s immediate integer ,比 i 更利于优化。
0 1 29 复用之前的标记,可以使用十进制多位数如 "23" 等。
p 一个合法地址,用于 push 或解引用。
其它字母 机器相关。

关于 o ,在 x86 或 x64 的机子上所有都是可偏移寻址 (offsettable addressing) 的,可以认为和 m 等价,而与之相对应的约束记号 V < > 则可以在支持自增寻址或自减寻址的架构使用(如摩托罗拉的 680×0 ),这个表格没有列出。

关于 0 1 29 ,也就是第几个出现在 OutputOperands InputOperands 和 GotoLabels 中的约束的编号。比如 asm("add %0, %0" : "=r"(output) : "0"(input) : "cc"); 就达成了一次乘二,inputoutput 使用的是同一个 r

机器相关约束

然后接下来是机器的专用约束名,十分长,这里只列出 x86 相关的:

约束名 架构(技术) 对应储存单元
R x86, x64 a b c c d si di bp sp
q x86, x64 al bl cl dl (x86) 、任意整型寄存器 LSB (x64)
Q x86, x64 ah bh ch dh
a x86, x64 a
b x86, x64 b
c x86, x64 c
d x86, x64 d
S x86, x64 si
D x86, x64 di
A x86 ad
U x86,x64 调用者保存整数寄存器
f x87 任何 x87 浮点栈寄存器 %st(0)%st(7)
t x87 %st(0)
u x87 %st(1)
y MMX 任何 MMX 向量寄存器 %mm0%mm7
x SSE 任何 SSE 向量寄存器 %xmm0%xmm7
z AVX-512 任何 AVX-512 支持的向量寄存器 %xmm0%xmm31
Yz SSE %xmm0
I x86, x64 031 的整型常量,用于 32 位整型位移
J x86, x64 063 的整形常量,用于 64 位整型位移
K x86, x64 -128127 整型常量
L x86, x64 0xff0xffff ,用于 andi 指令
M x86, x64 0 1 2 3 ,用于 lea 指令位移
N x86, x64 0255 ,用于 in out 指令
G x87 浮点常量
C SSE 浮点常量 0
e x64 32 位有符号整型或符号常量,用于整型提升
We x64 32 位有符号整型或符号常量,用于非 VOIDmode 下的整型提升
Wz x64 32 位无符号整型或符号常量,用于非 VOIDmode 下的整型提升
Wd x64 128 位整型常量,每部分均满足 e
Z x64 32 位无符号整型或符号常量,用于整型提升
Tv AVX2 用于 VSIB 寻址
Ts x86 用于无 ss 偏移的寻址
Ti MPX 用于无索引的 MPX 寻址
Tb MPX 用于无基质的 MPX 寻址

里面有很多我自己都不认识,就不细讲了。具体的其它架构相关的可以去 gcc online doc 里找。

除此之外, gcc 还提供了输出 RFLAGS 里内容的能力,比如满足 nz ,也就是 jnz 会执行 jmp 的时候,可以把一个 OutputOperand 置为 1 。举例而言有 asm ( "test %%rax, %%rax" : "=@ccnz"(not_zero) );

=@ccxx 中的 xx 具体和对应 jxxxx 相同,不再列一张长表了。 gcc 是利用了 setxx 指令来设置 OutputOperand 然后进行无符号扩展的。

约束修饰符

和上面操作符修饰符不同,这里的修饰符是修饰约束的。有四个:

  • (只出现在 OutputOperand 中) = 指的是在该约束对应的储存单元只会被覆写,不会被读取,无论初始值是什么都不会影响结果,对其进行读取是未定义的。
  • (只出现在 OutputOperand 中)与 = 相对应的是 + ,表示既读初始值也会覆写。
  • (只出现在 OutputOperand 中) & 指示 early clobber ,出现在 =+ 之后,表示输出可能会在输入被消耗完就被使用,由于编译器并不保证输入输出就会使用不同的寄存器,因此在消耗完输入前就使用输出可能会导致输入被覆盖得到错误的结果。所以需要用 & 提示编译器区分开输入输出。关于这个的作用推荐一个 StackOverflow 的问题和回答(曾经被这个坑了一会儿,我自己也在 StackOverflow 提了个问题,现在回头看文档觉得自己真蠢)。

多种汇编格式的支持

如果在 AseemblerTemplate 中需要支持多种汇编语言——如 x86 下的 AT&T 格式与 Intel 格式汇编——可以使用大括号来枚举出支持的汇编格式,使用竖线来分隔,举例而言:

"{xorq %%rax, %%rbx | xor rbx, rax }"

就会在汇编的时候选择其中一个输出。当开启编译选项 -masm=att (此项为默认)的时候,会输出左侧的回班,而在开启 -masm=intel 时就会输出右侧的汇编。

上面的代码还有一种更简单的写法:

"xor{q} {%%}r{b|a}x, {%%}r{a|b}x"

{q} 表示在 AT&T 输出 q 而 Intel 不输出,{%%} 同理,在 AT&T 输出 % 而 Intel 不输出,{b|a} 是指 AT&T 输出 b 而 Intel 输出 a ,因为两种汇编的操作数顺序恰好相反。

另外,如果需要在代码中使用 {|} 这三个字符,需要用 % 转义,也就是变成 %{ %| %} 的格式。

发表评论

电子邮件地址不会被公开。 必填项已用*标注