Linux GNU 内联汇编

2018-05-30 linux language c/cpp

在通过 C 做上层的开发时,实际上很少会用到汇编语言,不过对于 Linux 内核开发来说,经常会遇到与体系架构相关的功能或优化代码,此时可能需要通过将汇编语言指令插入到 C 语句的中间来执行这些任务。

这里简单介绍下 GNU 中与 Linux 内联汇编相关的用法。

gnu logo

示例

如下是 GNU 内联汇编的基本语法。

asm asm-qualifiers (assembler template
    : output operands               (optional)
    : input operands                (optional)
    : list of clobbered registers   (optional)
);

下面的代码是一个简单的赋值操作,采用的是 AT&T 的语法,而非常见的 Intel 语法。

#include <stdio.h>

int main(void)
{
	int foo = 0; bar = 10;

	__asm__ __volatile__ (
		"movl %1, %%eax;"
		"movl %%eax, %0;"
		:"=r"(foo)  /* %0 output */
		:"r"(bar)   /* %1 input */
		:"%eax");   /* clobbered register */

        printf("foo = bar; foo = %d\n", foo);

        return 0;
}

直接通过 gcc foobar.c -o foobar 进行编译,然后运行即可,简单解析如下:

  • foo 是输出操作数,内联汇编中通过 %0 引用;bar 是输入操作数,在内联中通过 %1 引用。
  • r 是操作数的约束,指明这两个变量存放到通用寄存器中,详见如下关于约束的介绍。
  • 最后的修饰寄存器 %eax 用于告知编译器,在内联汇编中会修改该寄存器的值,这样 GCC 就不会使用该寄存器存储任何其它的值。

那么上述的内联汇编中,通过 movl %1, %%eaxbar 的值移到 %eax 中,movl %%eax, %0 再将 %eax 的内容移到 foo 中。

foo 被指定为输出操作数,那么当上述的内联汇编执行完之后,在 C 中对应的 foo 变量同时会更新,也就是说在内对 foo 的修改会直接在外面体现出来。

注意,内联汇编中使用 %0 %1 来标示变量,任何带有一个 % 的都被看成是输入、输出操作数,而非寄存器,如果要使用寄存器需要两个 % 也就是类似 %%eax 这种方式。

规范

参照上述的语法,简单列举一下使用方法。

关键字

主要是 asmvolatile ,前者显然是用来标示这是一段汇编代码,后者表示不需要 gcc 对下面的汇编代码做任何优化。

为避免 asm 的命名冲突,gcc 还支持 __asm__ 关键字,两者的作用等价;volatile 也同样存在一个 __volatile__ 关键字。

通用规范

其中的每条指令以分界符结尾,分界符可以是换行符 \n 和分号 ; ,为了排版可以使用 \n\t 作为分隔。如下是编写内联汇编时一些 AT&T 语法的常见规范:

  • 寄存器命名。都添加了 % 前缀,例如 %eax
  • 由左到右的顺序为 源操作数 和 目的操作数,这与 Intel 的语法有所区别。
  • 操作数大小。非强制(编译器会根据操作数推测),但是可以增加可读性,指令后添加 b w l 标示 Byte Word Long 类型。
  • 立即操作数。通过 $ 符号指定。
  • 间接内存引用。通过 () 来完成。

简单的示例。

----- 指令后显示指定操作数大小
movb %al, %bl   -- Byte Move 8-bits
movw %ax, %bx   -- Word Move 16-bits
movl %eax, %ebx -- Long Word Move 32-bits
movq %rax, %rbx -- Long Long Word Move 64-bits

----- 寄存器寻址,将eax的数据放到edx中
movl %eax, %edx

----- 操作立即数,将0xffff添加到eax寄存器中
movl $0xffff, %eax

----- 直接寻址,也就是访问的0x123地址处的数据,等价于edx = *(uint32_t *)0x123
movl 0x123, %edx

----- 间接内存引用,将esi所指向内存地址的内容添加到edx寄存器中
movl (%esi), %edx

----- 变址寻址,将ebx所指向内存地址+4的内容添加到edx寄存器中,等价于edx = *(int32_t*)(ebx+4)
movl 4(%ebx), %edx

操作数

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

约束

简单来说,就是告知 gcc 在编译时如何使用一些参数,例如:A) 操作数是否能放到寄存器,应该放到那种寄存器;B) 操作数是否可以为一个内存引用和哪种地址;操作数是否可以为一个立即数和它可能的取值范围(即值的范围),等等。

寄存器约束

用于存储到通用寄存器中,当指定 r 时,gcc 可以将变量保存在任何可用的 GPR 中,除非使用具体的名称用来指定所需的寄存器类型,其中类型如下:

a    %eax, %ax, %al
b    %ebx, %bx, %bl
c    %ecx, %cx, %cl
d    %edx, %dx, %dl
S    %esi, %si
D    %edi, %di

示例代码

加法操作

#include <stdio.h>

int main(void)
{
    int foo = 10, bar = 15;

    __asm__ __volatile__ (
            "addl %%ebx, %%eax;"
            : "=a"(foo)         /* ouput */
            : "a"(foo), "b"(bar)/* input */
    );
    printf("foo + bar = %d\n", foo);

	return 0;
}

内存屏障

__asm__ __volatile__ ("" ::: "memory");

用于防止编译器将读写操作乱序执行,这里再重新讨论下其中的参数:

  • __volatile__ 告诉编译器,严禁将此处的汇编语句与其它的语句重组合优化,防止编译器的乱序执行;
  • memory 强制编译器假设 RAM 所有内存单元均被汇编指令修改,这样寄存器、Cache 中已缓存数据将会重新从内存中加载。

其它

生成汇编

在编写完内联汇编之后,如果想要查看代码是否正确,可以先编译生成汇编代码查看。

可以直接使用 -S 参数生成汇编,默认是 AT&T 风格,当然也可以指定为 Intel 方式。

$ gcc -S -o target.s source.c
$ gcc -masm=intel -S -o target.s source.c

参考