GCC内联汇编

本文主要参考 https://www.linuxprobe.com/gcc-how-to.html

GCC 汇编语法

Linux 上的 GNU C 编译器 GCC ,使用 AT&T/UNIX 汇编语法。在这里,我们将使用 AT&T 语法 进行汇编编码。AT&T 语法和 Intel 语法的差别很大。

源操作数和目的操作数顺序

AT&T 语法的操作数方向和 Intel 语法的刚好相反。

  • Intel 语法中,第一操作数为目的操作数,第二操作数为源操作数。

  • AT&T 语法中,第一操作数为源操作数,第二操作数为目的操作数。

寄存器命名

AT&T 语法寄存器名称有 % 前缀,即如果必须使用 eax,它应该用作 %eax

立即数

AT&T 语法立即数以 $ 为前缀。静态 C 变量也使用 $ 前缀。在 Intel 语法中,十六进制常量以 h 为后缀,然而 AT&T 不使用这种语法,这里我们给常量添加前缀 0x。所以,对于十六进制,我们首先看到一个 $,然后是 0x,最后才是常量。

例如:

1
movl %ecx, $label(%edx,%ebx,$0x4)

操作数大小

AT&T 语法中,存储器操作数的大小取决于操作码名字的最后一个字符。操作码后缀 bwl 分别指明了字节(8位)、字(16位)、长型(32位)存储器引用。

Intel 语法通过给存储器操作数添加 byte ptrword ptrdword ptr 前缀来实现这一功能。

因此,Intelmov al, byte ptr fooAT&T 语法中为 movb foo, %al

存储器操作数

Intel 语法中,基址寄存器包含在 [] 中,然而在 AT&T 中,它们变为 ()

另外,在 Intel 语法中, 间接内存引用为 section:[base + index*scale + disp],在 AT&T 中变为 section:disp(base, index, scale)

需要牢记的一点是,当一个常量用于 dispscale ,不能添加 $ 前缀。

现在我们看到了 Intel 语法和 AT&T 语法之间的一些主要差别。我仅仅写了它们差别的一部分而已。关于更完整的信息,请参考 GNU 汇编文档。现在为了更好地理解,我们可以看一些示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
+------------------------------+------------------------------------+
| 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 |
+------------------------------+------------------------------------+

基本内联

基本内联汇编的格式非常直接了当,它的基本格式为:

1
asm("汇编代码");

示例

1
2
asm("movl %ecx %eax");         /* 将 ecx 寄存器的内容移至 eax  */
__asm__("movb %bh (%eax)"); /* 将 bh 的一个字节数据 移至 eax 寄存器指向的内存 */

asm__asm__ 这两者都是有效的,如果关键词 asm 和我们程序的一些标识符冲突了,我们可以使用 __asm__。如果我们的汇编指令有多条时,可以每一条指令一行,并用双引号圈起,同时为每条指令添加 \n\t 后缀。

例如:

1
2
3
4
5
__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 的优化。基本格式为:

1
2
3
4
5
asm ( 汇编程序模板
: 输出操作数 /* 可选的 */
: 输入操作数 /* 可选的 */
: 修饰寄存器列表 /* 可选的 */
);

汇编程序模板由汇编指令组成。第一个 : 用于将汇编程序模板和第一个输出操作数分开,第二个 : 用于将最后一个输出操作数和第一个输入操作数分开。 , 用于分离每一个组内的操作数,总操作数的数目限制在 10 个,或者机器描述中的任何指令格式中的最大操作数数目,以较大者为准。

如果没有输出操作数但存在输入操作数,你必须将两个连续的冒号放置于输出操作数原本会放置的地方周围。

例如:

1
2
3
4
5
6
7
asm("cld\n\t"
"rep\n\t"
"stosl"
: /* 无输出寄存器 */
: "c" (count), "a" (fill_value), "D" (dest) /* 输入操作数 引号中的内容为约束字符串,括号中的内容为被约束的变量 */
: "%ecx", "%edi" /* 修饰寄存器列表 */
);

以上的内联汇编是将 fill_value 值连续 count 次拷贝到寄存器 edi 所指位置。 它也告诉 gcc 寄存器 ecxedi 将会被修改。

为了更加清晰地说明,让我们再看一个示例。

1
2
3
4
5
6
7
int a=10, b;
asm ("movl %1, %%eax;
movl %%eax, %0;"
:"=r"(b) /* 输出 */
:"r"(a) /* 输入 */
:"%eax" /* 修饰寄存器 */
);

这里我们所做的是使用汇编指令使 b 变量的值等于 a 变量的值。一些有意思的地方是:

  1. b 为输出操作数,用 %0 引用;a 为输入操作数,用 %1 引用。

  2. r 为操作数约束。r 告诉 GCC 可以使用任一寄存器存储操作数。=是一个约束修饰符 ,它表明 b 是一个可写的输出操作数。

  3. 寄存器名字以两个 % 为前缀。这有利于 GCC 区分操作数和寄存器,操作数以一个 % 为前缀。

  4. 第三个冒号之后的修饰寄存器 %eax 用于告诉 GCC %eax 的值将会在 asm 内部被修改,所以 GCC 将不会使用此寄存器存储任何其他值。

asm 执行完毕, b 变量会映射到更新的值,因为它被指定为输出操作数。换句话说, asmb 变量的修改应该会被映射到 asm 外部。

汇编程序模板

汇编程序模板包含了被插入到 C 程序的汇编指令集。其格式为:

每条指令用双引号圈起,或者整个指令组用双引号圈起。同时每条指令应以分界符结尾。有效的分界符有换行符(\n)和分号(;)。一般使用换行符后会添加一个制表符(\t),原因就是为了排版和分隔。 C 变量对应的操作数使用 %0、%1 ... %n-1表示。

操作数

C 变量用作 asm 内的汇编指令操作数。每个操作数前面是以双引号圈起的操作数约束,对于输出操作数,在引号内还有一个额外约束修饰符。约束字符串主要用于决定操作数的寻址方式,同时也用于指定使用的寄存器。

如果我们使用多个操作数,那么每一个操作数用逗号隔开。

在汇编程序模板中,每个操作数用数字引用。编号方式如下,如果总共有 n 个操作数(包括输入和输出操作数),那么第一个输出操作数编号为 0 ,逐项递增,并且最后一个输入操作数编号为 n - 1

输出操作数变量必须为左值。输入操作数的要求不像这样严格。扩展汇编特性常常用于编译器所不知道的机器指令 。

所以现在我们来关注一些示例。我们想要求一个数的5次方结果。为了计算该值,我们使用 lea 指令。

1
2
3
4
asm ("leal (%1,%1,4), %0"
: "=r" (five_times_x)
: "r" (x)
);

如果我们想要输入和输出放在同一个寄存器里,我们也可以要求 GCC 这样做。

1
2
3
4
asm ("leal (%0,%0,4), %0"
: "=r" (five_times_x)
: "0" (x)
);

现在输出和输出操作数位于同一个寄存器。但是我们无法得知是哪一个寄存器。现在假如我们也想要指定操作数所在的寄存器,这里有一种方法。

1
2
3
4
asm ("leal (%%ecx,%%ecx,4), %%ecx"
: "=c" (x)
: "c" (x)
);

在头两个示例, GCC 决定了寄存器并且它知道发生了什么改变。在最后一个示例,我们不必将 ecx 添加到修饰寄存器列表, gcc 知道它表示 x 。因为它可以知道 ecx 的值,它就不被当作修饰的了。

修饰寄存器列表

一些指令会破坏一些硬件寄存器内容。我们不得不在修饰寄存器中列出这些寄存器,即汇编函数内第三个 : 之后的域。这可以通知 gcc 我们将会自己使用和修改这些寄存器,这样 gcc 就不会假设存入这些寄存器的值是有效的。我们不用在这个列表里列出输入、输出寄存器。因为 gcc 知道 asm 使用了它们。如果指令隐式或显式地使用了任何其他寄存器,(并且寄存器没有出现在输出或者输出约束列表里),那么就需要在修饰寄存器列表中指定这些寄存器。

如果我们的指令可以修改条件码寄存器( cc ),我们必须将 cc 添加进修饰寄存器列表。

如果我们的指令以不可预测的方式修改了内存,那么需要将 memory 添加进修饰寄存器列表。这可以使 GCC 不会在汇编指令间保持缓存于寄存器的内存值。如果被影响的内存不在汇编的输入或输出列表中,我们也必须添加 volatile 关键词。

我们可以按我们的需求多次读写修饰寄存器。参考一下模板内的多指令示例;它假设子例程 _foo 接受寄存器 eaxecx 里的参数。

1
2
3
4
5
6
7
asm ("movl %0,%%eax;
movl %1,%%ecx;
call _foo"
: /* no outputs */
: "g" (from), "g" (to)
: "eax", "ecx"
);

Volatile

关键词 volatile 放置在 asm 后面、()的前面,以防止编译器优化将指令移动、删除或者其他操作,我们将其声明为 asm volatile ( ... : ... : ... : ...);

如果担心发生冲突,请使用 __volatile__

如果我们的汇编只是用于一些计算并且没有任何副作用,不使用 volatile 关键词会更好。不使用 volatile 关键字 gcc 将会优化代码。

约束

约束用于表明一个操作数是否可以位于寄存器和位于哪种寄存器;操作数是否可以为一个内存引用和哪种地址;操作数是否可以为一个立即数和它可能的取值范围;等等。

常用约束

在许多约束中,只有小部分是常用的。我们来看看这些约束。

  1. 寄存器操作数约束

当使用这种约束指定操作数时,它们存储在通用寄存器( GPR )中。请看下面示例:

1
asm ("movl %%eax, %0\n" :"=r"(myval));

这里,变量 myval 保存在寄存器中,寄存器 eax 的值被复制到该寄存器中,并且 myval 的值从寄存器更新到了内存。当指定 r 约束时, gcc 可以将变量保存在任何可用的 GPR 中。要指定寄存器,你必须使用特定寄存器约束直接地指定寄存器的名字。它们为:

1
2
3
4
5
6
7
8
9
10
+---+--------------------+
| r | Register(s) |
+---+--------------------+
| a | %eax, %ax, %al |
| b | %ebx, %bx, %bl |
| c | %ecx, %cx, %cl |
| d | %edx, %dx, %dl |
| S | %esi, %si |
| D | %edi, %di |
+---+--------------------+
  1. 内存操作数约束

当操作数位于内存时,任何对它们的操作将直接发生在内存位置,这与寄存器约束相反,后者首先将值存储在要修改的寄存器中,然后将它写回到内存位置。但寄存器约束通常用于一个指令必须使用它们或者它们可以大大提高处理速度的地方。当需要在 asm 内更新一个 C 变量,而又不想使用寄存器去保存它的值,使用内存最为有效。例如, IDTR 寄存器的值存储于内存位置 loc 处:

1
asm("sidt %0\n" : :"m"(loc));
  1. 匹配约束

在某些情况下,一个变量可能既充当输入操作数,也充当输出操作数。可以通过使用匹配约束在 asm 中指定这种情况。

1
asm ("incl %0" :"=a"(var):"0"(var));

在这个匹配约束的示例中,寄存器 %eax 既用作输入变量,也用作输出变量。 var 输入被读进 %eax,并且等递增后更新的 %eax 再次被存储进 var 。这里的 "0" 用于指定与第 0 个输出变量相同的约束。该约束可用于:

  • 在输入从变量读取或变量修改后且修改被写回同一变量的情况。
  • 在不需要将输入操作数实例和输出操作数实例分开的情况。

使用匹配约束最重要的意义在于它们可以有效地使用可用寄存器。

其他一些约束:

  • "m" : 允许一个内存操作数,可以使用机器普遍支持的任一种地址。
  • "o" : 允许一个内存操作数,但只有当地址是可偏移的。即,该地址加上一个小的偏移量可以得到一个有效地址。
  • "V" : 一个不允许偏移的内存操作数。换言之,任何适合 "m" 约束而不适合 "o" 约束的操作数。
  • "i" : 允许一个(带有常量)的立即整形操作数。这包括其值仅在汇编时期知道的符号常量。
  • "n" : 允许一个带有已知数字的立即整形操作数。许多系统不支持汇编时期的常量,因为操作数少于一个字宽。对于此种操作数,约束应该使用 'n' 而不是 'i'
  • "g" : 允许任一寄存器、内存或者立即整形操作数,不包括通用寄存器之外的寄存器。

以下约束为 x86 特有。

  • "r" : 寄存器操作数约束,查看上面给定的表格。
  • "q" : 寄存器 a、b、c 或者 d
  • "I" : 范围从 031 的常量(对于 32 位移位)。
  • "J" : 范围从 063 的常量(对于 64 位移位)。
  • "K" : 0xff
  • "L" : 0xffff
  • "M" : 0、1、23lea 指令的移位)。
  • "N" : 范围从 0255 的常量(对于 out 指令)。
  • "f" : 浮点寄存器
  • "t" : 第一个(栈顶)浮点寄存器
  • "u" : 第二个浮点寄存器
  • "A" : 指定 "a""d" 寄存器。这主要用于想要返回 64 位整形数,使用 "d" 寄存器保存最高有效位和 "a" 寄存器保存最低有效位。

约束修饰符

当使用约束时,对于更精确的控制超过了对约束作用的需求,GCC 给我们提供了约束修饰符。最常用的约束修饰符为:

  • "=" : 意味着对于这条指令,操作数为可写的;旧值会被忽略并被输出数据所替换。
  • "&" : 意味着这个操作数为一个早期改动的操作数,其在该指令完成前通过使用输入操作数被修改了。因此,这个操作数不可以位于一个被用作输出操作数或任何内存地址部分的寄存器。如果在旧值被写入之前它仅用作输入而已,一个输入操作数可以为一个早期改动操作数。

上述只是一些常见的的约束列表和解释。

一些实用的诀窍

现在我们已经介绍了关于 GCC 内联汇编的基础理论,现在我们将专注于一些简单的例子。将内联汇编函数写成宏的形式总是非常方便的。我们可以在 Linux 内核代码里看到许多汇编函数。(usr/src/linux/include/asm/*.h)。

首先我们从一个简单的例子入手。我们将写一个两个数相加的程序。

1
2
3
4
5
6
7
8
9
10
int main(void)
{
int foo = 10, bar = 15;
__asm__ __volatile__("addl %%ebx,%%eax"
:"=a"(foo)
:"a"(foo), "b"(bar)
);
printf("foo+bar=%d\n", foo);
return 0;
}

这里我们要求 GCCfoo 存放于 %eax,将 bar 存放于 %ebx,同时我们也想要在 %eax 中存放结果。"=" 符号表示它是一个输出寄存器。现在我们可以以其他方式将一个整数加到一个变量。

1
2
3
4
5
6
7
__asm__ __volatile__(
" lock ;\n"
" addl %1,%0 ;\n"
: "=m" (my_var)
: "ir" (my_int), "m" (my_var)
: /* 无修饰寄存器列表 */
);

这是一个原子加法。为了移除原子性,我们可以移除指令 lock 。在输出域中,"=m" 表明 myvar 是一个输出且位于内存。类似地,"ir" 表明 myint 是一个整型,并应该存在于其他寄存器。没有寄存器位于修饰寄存器列表中。

现在我们将在一些寄存器/变量上展示一些操作,并比较值。

1
2
3
4
5
__asm__ __volatile__(  "decl %0; sete %1"
: "=m" (my_var), "=q" (cond)
: "m" (my_var)
: "memory"
);

这里,my_var 的值减 1 ,并且如果结果的值为 0,则变量 cond1 。我们可以通过将指令 "lock;\n\t" 添加为汇编模板的第一条指令以增加原子性。

这里需要注意的地方是:

  • my_var 是一个存储于内存的变量。
  • cond 位于寄存器 eax、ebx、ecx、edx 中的任何一个。
  • memory 位于修饰寄存器列表中。也就是说,代码将改变内存中的内容。

如何置 1 或清 0 寄存器中的一个比特位。

1
2
3
4
5
__asm__ __volatile__(   "btsl %1,%0"
: "=m" (ADDR)
: "Ir" (pos)
: "cc"
);

这里 ADDR 变量的 pos 位置上的比特被设置为 1 。我们可以使用 btrl 来清除由 btsl 设置的比特位。 pos 的约束 "Ir" 表明 pos 位于寄存器,并且它的值为 0-31。也就是说,我们可以设置/清除 ADDR 变量上第 031 位的任意位。因为条件码会被改变,所以我们将 "cc" 添加进修饰寄存器列表。

现在我们看看一些更为复杂而有用的函数。字符串拷贝。

1
2
3
4
5
6
7
8
9
10
11
12
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 时,拷贝完成。约束 "&S""&D""&a" 表明寄存器 esi、edieax 早期修饰寄存器,也就是说,它们的内容在函数完成前会被改变。这里很明显可以知道为什么 "memory" 会放在修饰寄存器列表。

我们可以看到一个类似的函数,它能移动双字块数据。注意函数被声明为一个宏。

1
2
3
4
5
6
7
8
9
#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、esiedi 的内容发生了改变,这是块移动的副作用。因此我们必须将它们添加进修饰寄存器列表。

Linux 中,系统调用使用 GCC 内联汇编实现。让我们看看如何实现一个系统调用。所有的系统调用被写成宏(linux/unistd.h)。例如,带有三个参数的系统调用被定义为如下所示的宏。

1
2
3
4
5
6
7
8
9
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 中。

每个系统调用都以类似的方式实现。exit 是一个单一参数的系统调用,让我们看看它的代码看起来会是怎样。它如下所示。

1
2
3
4
5
6
{
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 。因此我们分配 eax 包含 1ebx 包含 0 ,同时通过 "int $0x80" 执行 "exit(0)"。这就是 exit 的工作原理。