GCC内联汇编语法

最近由于工作需要,需要编写GCC的内联汇编,在搜索材料后发现GCC’s assembler syntax,此篇文章相比于GCC官方手册要更易理解,也更适合入门学习,此处为个人翻译版。

[toc]

语法概览

它是一条语句(statement)而非表达式(expression)

1
2
3
4
5
6
asm <optional stuff> (
"assembler template"
: outputs
: inputs
: clobbers
: labels)

快速笔记:

  • 开始的asm关键字可以使用__asm__代替,根据个人喜好
  • <optional stuff>(可选内容)可以为空,或者使用volatile或者goto
  • “assembler template”部分就是想要编写的汇编指令
  • 在一个asm语句中,综合起来最多能有30个输入、输出以及标志(label)
  • 输出、输入,破坏列表(clobber)以及标志都是可选的。冒号只需要写到你需要使用的位置就可以,如果你不需要该处的参数,你可以在两个冒号直接留空,比如以下的情况都是合法的
    • asm("movq %0, %0" : "+rm" (foo));
    • asm("addl %0, %1" : "+r" (foo) : "g" (bar));
    • asm("lfence" : /* no output */ : /* no input */ : "memory");

汇编模板(assembler template)语法

汇编模板包含了汇编指令以及操作符的占位符。你可以将其看成是一种格式化字符串。该字符串与编译器生成的汇编文件中的对应的汇编语句相对应。汇编指令使用换行符(“\n”)进行分隔,虽然不是必须的,但是人们习惯于在语句前面添加空格或者Tab字符。我喜欢在每条指令结束后添加“\n ”,这表示在下一条指令前添加一个空格,用于缩进表示。模板的使用是一个很好的例子,用来说明C语言中如何将字符串常量进行连接。

1
2
3
asm("nop\n "
"nop\n "
"nop")

在该格式化字符串中:

  • 使用%N来表示某个变量,其中N用来指示是第几个变量(计数从0开始,输出与输入使用同一个命名空间,输出参数靠前)
  • 可以使用命名变量,格式为%[Name]
  • 针对常量%(字面量,literal)来说,使用%%,如果采用at&t语法来表示寄存器
  • 针对GCC手册中提到的%= %{ %| %},在本文章中并未涉及
  • 编译器还支持的其他格式化描述符(类似于printf支持的%u以及%hu)。这些描述符是依赖于CPU架构的,该信息同样未在该文章中提及。

输入与输出

输入与输出是指C语言中的参数与汇编模板中的“格式化字符串”的关联关系。它们使用逗号分隔,并且其使用语法如下所示:

1
2
        "constraint" (expression)
[Name] "constraint" (expression)

注意,尽管它看上去像是元语法(metasyntatical),它们实际上是你需要使用的字符串字面量:[Name]在使用的时候需要使用中括号括起来,"constraints"必须使用双引号引起来,然后(expression)需要使用括号括起来。

对于"constraints"以及(expression)的使用方法,将在本节后续的Constraints、输出以及输入部分说明。

当你使用可选的“[Name]”字段,可以在汇编模板中使用%[Name]方式来引用输出以及输入变量。下面是一个例子:

1
2
int foo = 1;
asm("inc %[IncrementMe]" : [IncrementMe] "+r" (foo));

即使在语句中使用了命名参数,你仍然可以使用计数语法(%n)。从结论上来说,命名参数仍然影响了参数的计数,asm语句的第二个参数仍然会是%1,不管第一个参数是否是命名参数。

约束(Constraints)

约束字符串用于建立C表达式与汇编语句之间的桥梁。它们是必须的,因为编译器不知道哪种操作数对于哪个指令是合法的。事实上,对于编译器来说,操作数甚至不需要成为任何指令的操作数:你可以在注释或指令名称中使用操作数,也可以根本不使用操作数,只要输出字符串对汇编程序有效即可。
下面是一个例子imul %0, %1, %2

  • 第一个操作数需要是一个寄存器
  • 第二个操作数可以是一个寄存器或者是一个内存地址
  • 最后一个操作数必须是一个整数类型的常数

每个操作数对应的约束字符串一定要将该需要与GCC进行沟通。比如,在上面这个例子中,它一定要确保目标操作数为寄存器。

GCC定义了多种类型的约束方式,但是在2019年的桌面/移动平台中,下面是最常使用的约束类型:

  • r表示操作数是一个通用类型的寄存器
  • m表示操作数是一个内存地址
  • i表示操作数是一个整数类型的常数
  • g表示操作数是一个通用类型的寄存器,或者是一个内存地址或者是一个整数类型的常量(从使用方式来说与rni一致)

就像g约束类型展示的那样,可以对同一个参数使用多种约束类型进行约束。通过使用多种类型的约束,编译器在处理具有多种类型的指令时选择最适合的操作数类型。该方式相比于ARM类型的架构来说,针对x86类型的处理器架构更有作用。因为x86的指令复用了大量不同类型的操作数。

比如:

1
2
3
4
int add(int a, int b) {
asm("addl %1, %0" : "+r" (a) :"rm" (b));
return a;
}

下面是编译器采取的满足约束r的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
add:
// Per x86_64 System V calling convention:
// * a is held in edi.
// * b is held in esi.
// The return value will be held in eax.

// The compiler chooses to move `a` to eax before
// the add (it could arbitrarily do it after).
movl %edi, %eax

// `b` does not need to be moved anywhere, it is
// already in a register.

// The compiler can emit the addition.
addl %esi, %eax

// The result of the addition is returned.
ret

注意对于约束i来说,满足约束的方式可能依赖于采取的优化的模式。如果传入的是整型参数的字面量或者枚举类型的值,那么编译器会将其视为常数,但是如果传入的是变量,这个约束生效的方式将取决于优化常数的能力。比如,在下面的例子中,使用-O1可以正常编译,但是在-O0模式下,将无法正常编译。

1
2
3
4
int x = 3;
asm("int %0" : "i"(x) :"memory");
// -O1 生成“int 3”
// -O0 报错

在GCC文章中,包含了所有的与平台无关的约束以及所有的与平台相关的约束

输出

输出指定了左值,即保存结果的变量将在指令执行结束后需要保存的位置。需要提醒的是,在C语言中,左值的概念是比较模糊的,但是能够被赋值d的通常都可以被认为是左值,比如变量、解引用的指针、数组特定位置、结构体的特定字段等。绝大多数的左值都可以作为操作数,只要满足约束要求。比如可以将一个位域放置到一个寄存器中(使用r),但是不能放到内存中(使用m)。因为在内存中无法针对位域进行寻址。(同样对于clang中的vector类型)。
除此之外,与输出相关的约束需要在头上添加=或者+

  • +表示输出对应的参数是一个可读可写类型。该操作数在初始状态下保有一定的值。在汇编模板的任意一个位置可以从该操作数中读取对应的值。
  • =&表示该输出结果是一个早期修改类型的操作数。它并不包含初始值,在该值被赋值之后,对该值的读取并不会造成错误。
  • =表示该输出结果是一个只写类型的操作数。编译器可以选择将该类型的操作数与输入类型的操作数放到同一个位置:因此在汇编代码最后一条指令执行完成前写入该参数通常是一个错误。
  • =@ccCOND=字符的一个特殊用法,该用法支持你在汇编指令执行完成前查询执行的结果。你无法在汇编模板中引用一个条件输出。

比如,计数所有的x86指令的第一个操作数都是可读可执行的,因此在编写该操作数的约束时需要使用+。例如:

1
asm("addl %1, %0" : "+rm"(foo) : "g"(bar));

在该指令中,foo变量的初始值很重要,因为bar变量会将它的值加到foo变量中。

在另一方面,在ARM指令集中,几乎没有哪个指令会使用目标操作数的初始值。在这种情况下,你只需要使用=就可以了。上面例子的ARM指令实现如下:

1
2
asm("add %0, %1, %2" : "=r"(foo) : "r"(foo), "r"(bar));
// 对于ARM指令集来说,与计算相关的指令必须使用寄存器

当你使用=作为输出时,编译器可能会将输入的操作数与该输出操作数使用同一个地址。你可能会发现这会产生一个错误。比如:

1
2
3
4
asm("movl $123, %0\n\t"
"addl %1, %0"
: "=&r"(foo)
: "r"(bar));

=&类型的约束是必要的,mov指令并非最后一条汇编指令,并且该汇编指令拥有一个输入类型的操作数。如果使用=,那么输入和输出操作数将共享一个地址。如果你的指令仅有一条,那么这个操作是合法的,比如类似于ARM中的操作数。但是对于x86来说,上述指令的结果将不是foo = bar + 123,而是movl $123, %eax;addl %eax, %eax,成了foo = 123 + 123。因此,你需要使用=&约束,让编译器使用另外的寄存器,从而避免输入与输出使用同一个位置。

条件输出操作数

=@ccCOND=类型的一个特例,它允许你在汇编指令结束后获取一个条件判断的状态值。你必须将COND替换成你想查询的与架构无关的条件码名称。对于x86来说,所有可能的条件码列表将会在setcc手册中查询到。比如,=@ccnz将根据setnz指令的结果修改输出类型的操作数(如果结果是非0,则为真,否则为假)。你无法在你的汇编模板中使用条件判断指令,即使它是一个数值操作。例如:

1
2
3
asm("subq %3, %2"
:"=@ccc" (*carry), "=@cco"(*overflow)
:"g"(left), "g"(right));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdbool.h>
#include <inttypes.h>
#include <stdio.h>

__attribute__((always_inline))
void sub_flags(
int64_t left, int64_t right,
bool* carry, bool* overflow) {
asm("subq %3, %2"
: "=@ccc"(*carry), "=@cco"(*overflow)
: "g"(left), "g"(right));
}

void print_flags(bool carry, bool overflow);

void calculate_flags(int64_t left, int64_t right) {
bool carry, overflow;
sub_flags(left, right, &carry, &overflow);
print_flags(carry, overflow);
}

进位标志位(carry)将保存到操作数0上面,而溢出标志位则将保存到操作数1上面,即使它们无法直接引用。

输入

输入操作数可以是任何类型的值,只要它对于这个约束来说是有意义的。比如,对于i约束来说,编译器一定要确保对应的值为常数。输入的操作数的约束前不用加任何的修饰字符。

除了传入约束之外,你可能要传入所需要输出操作数的编号(它是可选的,使用%前缀)。当你使用该字符时,你通知编译器对输入和输出操作数要位于相同的位置。这对于+输出约束符来说是不合规的:编译器将会输出一个错误。你可能将+类型的输出操作数作为一个简写,用于代表"="(foo)的输出以及"0"(foo)的输入。

1
2
asm("xorq %0, %0" : "+r"(foo));
asm("xorq %1, %0" : "=r"(foo) : "0"(foo));

使用volatile关键字

重要

  • 如果编译器发现汇编代码的输入部分没有变化,编译器可能会移动对应的输入汇编指令(比如将其移出循环)
  • 如果编译器发现汇编代码的输出部分没有变化,编译器可能会删除对应的指令

有很多方法来控制编译器优化的程度。最好的方式是确保每一个输出操作数都有合适的约束,这样你就可以在编译器对死代码的优化过程中得到好处(如果这些指令确定是不需要的)。比如你需要使用assert来检查某个使用条件输出赋值的变量,然后在release版本中,由于assert不会执行,那么这个条件判断的汇编指令就会被编译器优化。

给每一个输出操作数赋予对应的约束是最佳的实践方式,但是你无法表达清楚某个值在某条指令中是如何修改的也是一个可能的情况。比如,你可以修改某个由输入指向的值。如果你修改的内存并非与某个输出操作数有着直接关联的话,你可能会使用“memory”破坏参数(关于破坏参数的描述在后续部分)。它将阻止编译器移动到其他内存修改的部分,并且避免其使用寄存器进行优化。

最后,你的汇编指令可有具有无法编码为输出操作数的副作用。并且这些副作用也与内存无关。例如,有很多不会修改内存的系统调用,所以你没有在破坏列表中添加“memory”参数。在这个情况下,你可能要使用volatile关键字,从而阻止编译器将其优化掉,既是发现输出参数并未使用。

1
asm volatile("syscall" : "=a"(ret_val):: "rcx", "r11");

volatile关键字用来说明没有输出的汇编指令。

注意该关键字能够阻止内嵌的汇编指令被永久性的移除,但是它不会阻止其被移动。使用内存破坏符并且产生确切的输入/输出依赖仍然是获取正确结果的必要描述。

破坏列表

破坏列表列举了汇编指令中的一系列可写的位置,这些位置有些可能被修改了,有些可能还未修改。他们可以是:

  • 寄存器名称(在x86架构中register以及%register都是可以的,比如rax或者%rax
  • 特殊名称cc,表示汇编指令修改了状态标志符。在不同状态标志符由不同寄存器进行存储的架构中,比如PowerPC,你还可以指定cr0
  • 特殊名称memory,该描述符表示该汇编指令修改的地址并非与输出操作数直接相关(比如,修改输入操作数解引用的地址内容)。使用memory破坏描述符阻止编译器修改原有汇编指令的顺序

如果你尝试使用一个未知的破坏列表描述符,那么编译器将会产生一个告警错误。

对于一些架构来说在任何的汇编语句都有可能间接的修改了一些寄存器,对于该情况来说,没有办法能够直接约束。一个相关的例子是在x86架构中,标志寄存器是经常被修改的(一个之前的版本的文档推荐x86的所有的指令都要加上cc破坏符)。如果你指定了一个破坏参数,表明该寄存器在执行过程中被修改,但是与此同时,该寄存器被间接的破坏,此时,编译器不会发出警告。似乎没有关于每种架构对clobbers 的调整的文档列表,在本文编写的时间(2019年10月),只有7个架构提供了明确的信息(CRIS, x86, MN103, NDS32, PDP11, IBM RS/6000, Visium)。

对于破坏列表,rdtscp指令是一个经典的例子,它没有任何的操作数,但是raxrdx以及rcx都会被修改。类似于rdtsc,它将处理器的时间标签计数器保存到rax以及rdx中,并且它还会将处理器的ID号写到rcx中。假设你的这条指令在处理器ID之后:你需要将rax以及rdx放到破坏列表中,因为它们被修改了并且没有在输出列表中。

1
2
3
4
5
asm("rdtscp \n\t"
"movq %%rcx, %0\"
: "=rm" (cpu_id)
:
: "rax", "rcx", "rdx");

注意尽可能的不要针对rcx采用x86特有的约束。

标签与goto关键字

在汇编代码编写过程中,你可能需要分支(条件判断)。并且,通过一些其他方式,你可以从汇编代码直接跳到外部的c语言标签。为了达到这个目的,我们可以使用asm goto功能。

当你使用asm goto功能,你就无法指定输出操作数(这个特性是由于 GCC 内部代码表示中的一些相当基本的决策所致),然后你就可以指定对应的标签,代代表着你的asm函数(将asm认为一个函数的话)的第四个参数。标签参数不包含任何的约束,并且无法使用命名参数的方式,你只能使用%N的模式。

1
2
3
4
5
6
7
8
9
10
11
12
int add_overflows(long lhs, long rhs) {
asm goto(
"movq %%rax, %[left]\n "
"addq %[right], %%rax\n "
"jo %2"
: // can't have outputs
: [left] "g" (lhs), [right] "g" (rhs)
: "rax"
: on_overflow);
return 0; // no overflow
on_overflow: return 1; // had an overflow
}

标签只能是C语言中的标签,它们不能是其他的代码地址,比如函数或者一个间接的goto标签变量。

注意一个asm goto语句一直是一个间接的volatile

例子

所有的例子是x86_64架构的。该部分后续会使用x86专用的一些约束,这样可以与一些命名寄存器想绑定:

  • abcd:a、b、c、d寄存器,分别代表rax, rbx, rcx以及rdx(对于每个来说,根据上下文选择al,ax,eax或者rax)
  • D:di寄存器
  • S:si寄存器

处于代码风格以及代码正确性的考量,如果你能够尽可能正确的使用最少的汇编指令,那么你能够获取最佳结果。如果你能够与编译器通信,对所有的mov操作使用合适的约束,那么将会围绕你的汇编指令生成更好的代码。下面所有的例子都采用了一条指令,并且告诉编译器如何安排寄存器从而获取正确的结果。

循环左移

下面是一个针对64位整数的循环左移操作。对rol指令来说,计数操作数需要是一个立即数,或者cl寄存器

1
2
3
4
5
6
int rotate_left(unsigned long long value , unsigned char count) {
asm("rolq %[count], %0"
: "+a"(value)
: [count] "ci" (count));
return value;
}

使用ci约束,编译器可以使用常数,或者将其放入cl寄存器中。

双字乘法

让两个64位的整数相乘将会得到一个128位的结果(根据地址)。在这儿我们能够使用x86架构的a以及d约束,分别代表着使用rax以及rdx寄存器。针对可变类型的寄存器,编译器可以根据情况选择合适的寄存器,比如32位的数使用eax进行保存。

下面这个例子是比较有趣的,因为它是一个间接引用的输出结果。使用=d输出约束来绑定*hi,我们指示编译器将rdx的值保存到*hi,即使它并未直接在汇编指令中指定。

1
2
3
4
5
void imul128(int64_t left, int64_t right, int64_t *lo, int64_t *hi) {
asm("imulq %[rhs]"
: "=a" (*lo), "=d" (*hi)
: [lhs] "0" (left), [rhs] "rm" (right));
}

针对上面的例子,另外一个比较合适的选择是先将*lo = left在C语言中设定好,然后使用+a约束。

使用write系统调用

通过使用x86特有的约束来将输入绑定到特定的寄存器中,我们与编译器通信从而将参数移动到系统调用需要的寄存器当中。在x86_64架构中,对于前四个参数没有移动的必要。

操作系统将系统调用编号放置到rax寄存器中。

1
2
3
4
5
6
7
8
9
10
11
int do_write(int fp, void *ptr, size_t size) {
int rax = SYS_write;
asm volatile(
"syscall"
: "+a"(rax)
: "D" (fp), "S" (ptr), "d" (size)
: "rcx", "r11"
);

return rax;
}

这里使用+a来表示rax,syscall指令会读取该值(操作系统机制要求)。