C 语言内联汇编

目录

1. 简介

本文介绍了 GCC/Clang 提供的 C 语言内联汇编特性的用途和用法。 对于 C 语言编程来说,内联汇编Inline Assembler 并不是一个新特性,它可以帮助我们充分利用计算机的性能。 大多数情况下很少有机会实际使用该特性。事实上,内联汇编只为特定的要求提供服务,特别是在涉及底层的系统编程时。

本文大量参考了 GCC-Inline-Assembly-HOWTO 以及 Linux 中国 的译文 GCC 内联汇编 HOWTO , 在此感谢他们的辛苦劳动。

2. 内联函数与内联汇编

C 语言标准1 提供的函数声明的关键字 inline 使我们可以要求编译器将一个函数的代码直接插入到函数被实际调用的地方。 这样的函数被称作内联函数Inline Function(又称在线函数编译时期展开函数),类似于 macro, 不同之处在于内联函数是在编译期完成,而宏是在预处理阶段直接修改。

内联函数的有什么优点呢?

最主要的优点是可以减少函数调用开销。 同时,如果所有实参的值为常量,它们的已知值可以在编译期被简化,因此并非所有的内联函数代码都会被包含进去。 内联函数对于代码大小的影响是不可预测的,这取决于特定的情况。

而内联汇编是将一些 汇编语言Assembly Language 的指令以内联函数的形式写入 C 语言代码中, 并且可以通过 C 语言的变量输入/输出,实现了汇编语言和 C 语言语言的联合编程。 为了使用内联汇编,GCC2 和 Clang3 均提供了 asm 关键词。

内联汇编既有内联函数的内联特性,又可以充分利用计算机性能。 但是,内联汇编但缺点也很明显,受限于汇编语言的硬件相关性,包含内联汇编的代码通常不具备良好的跨平台特性。4

3. 汇编语法

当前版本的 GCC/GNU 编译器和 Clang/LLVM 编译器默认使用 AT&T/UNIX 汇编语法, GCC 可以通过加参数 -masm=intel 来使用 Intel 汇编语法,该参数并不适用于 Clang。

3.1 AT&T/UNIX 和 Intel 汇编语法的主要区别

  1. 源操作数Source目的操作数Destination 的顺序

    AT&T/UNIX 汇编语法的操作数顺序和 Intel 语法的正好相反。 在 AT&T/UNIX 语法中,第一操作数为源操作数,第二操作数为目的操作数。 然而在 Intel 语法中,第一操作数为目的操作数,第二操作数为源操作数。 也就是说,AT&T/UNIX 语法中的 Op-code src dst 在 Intel 语法中变为 Op-code dst src

  2. 寄存器Register 命名不同

    在 AT&T/UNIX 语法中,寄存器名称有 % 前缀,即如果要使用 eax 寄存器,它应该写作 %eax。 然而在 Intel 语法中,寄存器名称没有 % 前缀。

  3. $ 前缀和 立即数Immediate Operand 格式

    在 AT&T/UNIX 语法中,C 语言的变量和立即数都以 $ 作为前缀,同时常量立即数可以添加前缀 0x 表示十六进制。 然而在 Intel 语法中,C 语言的变量和常量立即数都没有 $ 前缀,常量立即数以 h 为后缀来表示十六进制。

  4. 存储器操作数Memory Operand 大小的表示方法

    在 AT&T/UNIX 语法中,存储器操作数的大小取决于操作数名字的后缀。 后缀 bwl 分别指明了 字节byte(8位)、word(16位)、长型long(32位)的存储器引用。 然而在 Intel 语法中,通过给存储器操作数添加 byte ptrword ptrdword ptr 的前缀来对应实现这一功能。 因此,AT&T/UNIX 语法中的 movb foo, %al 在 Intel 语法中变为 mov al, byte ptr foo

  5. 其它关于操作数

    在 AT&T/UNIX 语法中,基址寄存器包含在 () 中,然而在 Intel 语法中,它们变为 []。 另外,在 AT&T/UNIX 语法中,间接内存引用表示为 section:disp(base, index, scale),而在 Intel 语法中变为 section:[base + index*scale + disp]。 需要牢记的一点是,在 AT&T/UNIX 语法中,当一个立即数用于 dispscale 时,不能添加 $ 前缀。

以上是关于 AT&T/UNIX 语法和 Intel 语法的一些主要区别,为了更好理解,下面列举一些示例代码。

Intel CodeAT&T/UNIX Code
mov eax, 1movl $1, %eax
mov ebx, 0ffhmovl $0xff, %ebx
int 80hint $0x80
mov ebx, eaxmovl %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

关于更完整的汇编语法,请参考 GNU 汇编部分文档。 由于 AT&T/UNIX 语法使用更广泛,本文以下关于内联汇编的介绍均以 AT&T/UNIX 语法作为示例。

4. C 语言内联汇编的语法

GCC/Clang 提供的关键字 asm 可以直接用来使用内联汇编代码,__asm__ 是它的别名,可以避免 asm 与其它标识符冲突。

GCC/Clang 提供了两种内联汇编的使用方法,分别是 基本汇编Basic Asm扩展汇编Extended Asm

4.1 基本汇编

基本汇编的语法直接了当。它的基本格式为

1
asm ("汇编代码");

示例代码:

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

如果指令多于一条,可以一行一条,并用双引号圈起,同时为每条指令添加 \n\t 后缀。 这是因为 GCC 将每一条当作字符串发送给 GNU 汇编器GAS,并且通过使用换行符/制表符发送正确格式化后的行给汇编器。

示例代码:

1
2
3
4
asm ("movl %eax, %ebx\n\t"
     "movl $56, %esi\n\t"
     "movl %ecx, $label(%edx,%ebx,$4)\n\t"
     "movb %ah, (%ebx)");

如果在汇编代码中涉及到一些寄存器,即改变其内容,在没有恢复这些变化的情况下从汇编中返回,会导致一些意想不到的事情。 这是因为 GCC 并不知道寄存器内容的变化,这会导致不可预料的问题,特别是当编译器做了某些优化时。 因此在基本汇编中通常只能使用没有副作用的指令。如果想要使用改变寄存器的指令,则需要在内联汇编退出时恢复这些寄存器。 扩展汇编给我们提供了这个功能。

4.2 扩展汇编

在扩展汇编中,我们可以同时指定操作数。它允许我们指定输入寄存器、输出寄存器以及修饰寄存器列表。 GCC 不强制用户必须指定使用的寄存器。可以把头疼的事留给 GCC,这可能可以更好地适应 GCC 的优化。

扩展汇编的基本格式为:

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

汇编程序模板由汇编指令组成。每一个操作数由一个操作数约束字符串所描述,其后紧接一个括弧括起的 C 语言表达式。 冒号用于将汇编程序模板和第一个输出操作数分开,另一个(冒号)用于将最后一个输出操作数和第一个输入操作数分开(如果存在的话)。 逗号用于分离每一个组内的操作数。总操作数的数目限制在 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 所指位置 (每执行 stosl 一次,寄存器 edi 的值会递增或递减,这取决于是否设置了 direction 标志,因此以上代码实则初始化一个内存块)。 它也告诉 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 的值。其中:

  • b 为输出操作数,用 %0 引用,a 为输入操作数,用 %1 引用。
  • "r""=r" 为操作数约束字符串。之后会更详细地介绍约束。 这里 r 告诉 GCC 可以使用任一寄存器存储操作数。输出操作数约束应该有一个约束修饰符 =,表明它是一个只读的输出操作数。
  • 寄存器名字以两个 % 为前缀。这有利于 GCC 区分操作数和寄存器。操作数以一个 % 为前缀。
  • 第三个冒号之后的修饰寄存器 %eax 用于告诉 GCC %eax 的值将会在 asm 内部被修改,所以 GCC 将不会使用此寄存器存储任何其他值。

以下是关于扩展汇编的更详细的介绍。

4.3 汇编程序模板

汇编程序模板包含了被插入到 C 语言程序的汇编指令集。 其格式为:每条指令用双引号圈起,或者整个指令组用双引号圈起。 同时每条指令应以分界符结尾。有效的分界符有换行符 \n 和分号 ;\n 可以紧随一个制表符 \t

4.4 输入操作数和输出操作数

扩展汇编的每个操作数是一个用括弧圈起的 C 语言表达式,前面是以双引号圈起的操作数约束字符串。约束字符串主要用于决定操作数的寻址方式,同时也用于指定使用的寄存器。

当操作数多于一个时,用逗号隔开。

每个操作数在汇编程序模板中用数字引用。编号方式索引初始值为 0。操作数的最大个数在前一节介绍过。

输出操作数表达式必须为左值。输入操作数的要求不像这样严格。它们可以为表达式。 扩展汇编特性常常用于编译器所不知道的机器指令。如果输出表达式无法直接寻址(即它是一个位域),我们的约束字符串必须给定一个寄存器。 在这种情况下,GCC 将会使用该寄存器作为汇编的输出,然后存储该寄存器的内容到输出。

正如前面所陈述的一样,普通的输出操作数必须为只写的; GCC 将会假设指令前的操作数值是死的,并且不需要被(提前)生成。扩展汇编也支持输入-输出或者读-写操作数。

以下为另一个示例代码。目的是求一个数的5次方结果。为了计算该值,使用了 lea 指令。

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

这里输入为 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,它就不用被当作修饰的(寄存器)了。

4.5 修饰寄存器列表

一些指令会修改一些硬件寄存器内容。我们不得不在修饰寄存器列表中列出这些寄存器,即扩展汇编内第三个 : 之后的部分。 这可以告知 GCC 汇编指令将会使用和修改这些寄存器,这样 GCC 就不会假设存入这些寄存器的值是有效的。 不用在这个列表里列出输入、输出寄存器。因为它们被显式地指定了约束,GCC 可以推断 asm 使用了它们。 如果指令隐式或显式地使用了任何除此之外的其他寄存器,那么就需要在修饰寄存器列表中指定这些寄存器。

如果指令会修改 条件码寄存器Condition Code Register (又称 状态寄存器Status Register标志寄存器Flag Register), 则必须将 %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"
    );

4.6 关于 volatile

如果汇编语句必须在我们放置它的地方执行(例如,不能为了优化而被移出循环语句),将 C 语言标准的关键词 volatile 放置在 asm 后面、() 的前面。 以避免编译器不可预知的优化,防止它被移动、删除或者其他操作。内核源码经常会有这种写法。

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

类似于 __asm____volatile__volatile 的别名,可以避免 volatile 与其它标识符冲突。

4.7 关于操作数约束字符串

约束和内联汇编有很大的关联。但以上对约束的介绍还不多。约束用于表明

  • 操作数是否可以位于寄存器和位于哪种寄存器
  • 操作数是否可以是一个内存引用和哪种地址
  • 操作数是否可以是一个立即数和可能的取值范围,等等

4.7.1 常用约束

  1. 寄存器操作数约束 r

    当使用这种约束指定操作数时,它们存储在 通用寄存器General Purpose Register 中。示例代码:

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

    变量 myval 保存在寄存器中,寄存器 eax 的值被复制到该寄存器中,然后 myval 的值从寄存器更新到了内存。

    还可以指定其它特定的寄存器。它们为:

    rRegister(s)
    a%eax %ax %al
    b%ebx %bx %bl
    c%ecx %cx %cl
    d%edx %dx %dl
    S%esi %si
    D%edi %di
  2. 内存操作数约束 m

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

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

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

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

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

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

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

  4. 一些其它通用约束

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

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

4.7.2 约束修饰符

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

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

上述的约束列表和解释并不完整。下一节的示例可以让我们对内联汇编的用途和用法更好的理解。

5. 一些实用的示例

Linux 内核代码里看到许多内联汇编。将内联汇编函数写成宏的形式总是非常方便的。 (usr/src/linux/include/asm/*.h)

5.1 两数相加

 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;
}

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

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

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

5.2 比较值

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 的值,我们可以使用 incl %0 代替 decl %0

这里需要注意的地方是

  • my_var 是一个存储于内存的变量
  • cond 位于寄存器 eaxebxecxedx 中的任何一个,约束 =q 保证了这一点
  • memory 位于修饰寄存器列表中,也就是说,代码将改变内存中的内容

5.3 将寄存器中的一个比特位置 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(x86 相关约束)。 也就是说,可以设置/清除 ADDR 变量上第 0 到 31 位的任一比特位。因为条件码会被改变,所以我们将 cc 添加进修饰寄存器列表。

5.4 字符串拷贝

 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 表明寄存器 esiedieax 早期修饰寄存器。 也就是说,它们的内容在函数完成前会被改变。这里很明显可以知道为什么 memory 会放在修饰寄存器列表。

以下还有一个类似的函数,能移动双字块数据。函数被声明为一个宏:

1
2
3
4
5
6
7
8
#define mov_blk(src, dest, numwords)                     \
__asm__ __volatile__ ("cld\n\t"                          \
                      "rep\n\t"                          \
                      "movsl"                            \
                     :                                   \
                     :"S"(src), "D"(dest), "c"(numwords) \
                     :"%ecx", "%esi", "%edi"             \
                     )

这里没有输出,寄存器 ecxesiedi 的内容发生了改变,这是块移动的副作用。因此必须将它们添加进修饰寄存器列表。

5.5 Linux 中的系统调用

Linux 中的所有的系统调用都被写成宏(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 中,每个参数位于 ebxecxedx 中。最后 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)

6. 其它

C 语言内联汇编是一个极大的主题,本文的介绍是不完整的。 GCC 和 Clang 的官方文档均详细介绍了其所支持的内联汇编语法,关于以上讨论过的语法细节可以在它们的文档上获取。

Linux 内核大量地使用了 GCC 内联汇编,强烈推荐在 Bootlin 上阅读 Linux 源码。


  1. C 参考手册 ↩︎

  2. GCC online documentation ↩︎

  3. Clang Compiler User’s Manual ↩︎

  4. 内联汇编 - 从头开始 ↩︎