Linux Kernel 源码学习必备知识之:GCC 内联汇编(AT&T格式)

一、内联汇编简介

1.1 什么是内联汇编

内联汇编称为 inline assembly,GCC 支持在 C 代码中直接嵌入汇编代码,所以称为 GCC inline assembly

内联汇编按格式分为两大类:基本内联汇编扩展内联汇编。基本内联汇编没有操作数,而扩展内联汇编可以有一个或多个操作数。当在 C 函数里混合使用 C 和汇编语言时,首选扩展汇编;当汇编语言出现在模块顶级时(也就是不在模块的函数中,在函数外使用),必须使用基本内联汇编。

1.2 为什么要使用内联汇编

因为一些底层操作,C语言不支持(比如寄存器操作),而汇编语言可以;为了提高 C 语言的能力,因此有了内联汇编。

二、 基本内联汇编

基本内联汇编是最简单的内联形式,其格式为:

asm [qualifiers] ("assembly code")

各关键字之间可以用空格或制表符分隔,也可以紧凑挨在一起不分隔。

关键字 asm 用于声明内联汇编表达式,这是内联汇编固定的部分,不可缺少。asm 和 __asm__ 是同义的,是由 gcc 定义的宏:#define __asm__ asm 。

2.1 限定符(qualifiers)

基本内联汇编有两种限定符,分别为 volatile 和 inline

volatile 限定符:

因为 gcc 有一个优化选项 -O,可以指定优化级别。当用 -O 来编译时,gcc 按照自己的意图优化代码,说不定就会把自己写的代码修改了。关键字 volatile 的作用是告诉 gcc:“不要修改我写的汇编代码,请原样保留”。volatile 和 __volatile__ 是同义的,是由 gcc 定义的宏:#define __volatile__ volatile

在基本内联汇编中,volatile 是可选的,而且对功能没有影响。因为基本内联汇编代码块,默认都是 volatile 的。

inline 限定符:

如果使用 inline 限定符,则出于内联目的,asm 语句的大小将被视为可能的最小大小(参见Size of an asm)。

2.2 汇编代码(assembly code)

"assembly code" 是我们写的汇编代码,它必须位于圆括号内,而且必须用双引号引起来。这是格式要求,只要满足了这个格式 asm [qualifiers] (""),assembly code 甚至可以为空。

assembly code 的规则:

  • 指令必须用双引号 引起来,无论双引号中是一条指令或多条指令;
  • 一对双引号不能跨行,如果跨行需要再结尾用反斜杠 “\” 转义;
  • 指令之间用分号“;”、换行符“\n”或换行符加制表符“\n\t”分隔。

提醒一下,即使是指令分布再多个双引号中,gcc 最终也要把 它们合并到一起来处理,合并之后,指令间要有分隔符。所以,当指令在多个双引号中时,除最后一个双引号外,其余双引号中的代码最后一定要有分隔符,这和其它编程语言中表示代码结束部分的分隔符是一样的,如:

asm("mov $9, %eax;""pushl %eax")    # 正确
asm("mov $9, %eax""pushl %eax")     # 错误

2.3 代码示例

asm("movl %ecx %eax"); /* moves the contents of ecx to eax */
__asm__("movb %bh (%eax)"); /* moves the byte from bh to the memory pointed by eax */
​
__asm__ ("movl %eax, %ebx\n\t"
          "movl $56, %esi\n\t"
          "movl %ecx, $label(%edx,%ebx,$4)\n\t"
          "movb %ah, (%ebx)");

2.4 基本内联汇编的用武之地

使用扩展汇编通常会生成更短、更安全以及更高效的代码;在大部分情况下使用扩展汇编是比基本汇编更好的解决方案。但是,在两种情况下只能使用基本内联汇编:

  • 扩展内联汇编只能在C函数内部使用,所以当需要在文件域(最顶级)及函数外部使用时,只能使用基本内联汇编。函数外部使用的基本内联汇编,不能使用任何限定符
  • 带 nake 属性声明的函数,必须使用基本内联汇编(参见 Function Attributes)。

2.5 基本内联汇编的缺点

如果我们在汇编代码里改变了寄存器内容,但是从汇编返回时没有对其进行恢复,就会产生不良后果。因为 GCC 根本不知道到我们修改了寄存器,而我们修改寄存器时也没有通知 GCC,它会认为寄存器里的值没有被修改而继续使用,这样就会产生问题而导致程序崩溃。扩展汇编提供了解决这些问题的能力。

[内核资料领取,Linux内核源码学习地址。

三、 扩展内联汇编

由于基本内联汇编功能太薄弱了,不能满足使用需求,所以对它进行了扩展,扩展后的内联汇编格式如下:

   asm [qualifiers] ( assembler template 
                    [: output operands]                  /* 可选的 */
                    [: input operands ]                  /* 可选的 */
                    [: list of clobber/modify]           /* 可选的 */
                    );

和基本内联汇编相比,扩展内联汇编在圆括号中变成了 4 部分,多了 output、input 和 clobber/modify 三项。其中的每一部分都可以省略,甚至包括 assembler template。省略的部分要保留冒号分隔符来占位,如果省略的是最靠后的一个或多个连续的部分,分隔符也不用保留,比如省略了 clobber/modify ,不需要保留 input 后面的冒号。

在学习各部分功能之前,我们先来看一个例子,对扩展内联汇编有一个基本的印象:

#include <stdio.h> 
void main() {
    int a = 10, b;
    asm ("movl %1, %%eax;" 
          "movl %%eax, %0;"
          :"=r"(b)        /* output */
          :"r"(a)         /* input */
          :"%eax"         /* clobbered register */
         );
    printf("Now, b is: %d\n", b);
}
 

在这段代码中,我们实现了让 b 等于 a的功能,代码说明如下:

  • "b" 是输出操作数,被 %0 引用;"a" 是输入操作数,被 %1 引用。
  • "r" 是对操作数的约束,它通知 GCC 可以使用任何寄存器来存储操作数。"=" 是 output 的约束描述符,它说明这个输出操作数是只写的。
  • 在寄存器名称前面有 2 个 %,这是因为单个 % 被 GCC 用做操作数占位符,为了区分操作数和寄存器,GCC 在寄存器名称前使用了 2 个 %。
  • %eax 出现在 clobber/modify 里, 它告诉 GCC %eax 的值会被修改,所以不要用它来存储其它值。

代码输出结果如下:

$ gcc -o test inline_assign.c
$ ./test 
Now, b is: 10

3.1 限定符(qualifiers)

扩展汇编包括如下三种限定符:

  • volatile

volite 限定符主要是禁止 GCC做优化,参见 2.1 节。

  • inline

如果使用 inline 限定符,则出于内联目的,asm 语句的大小将被视为可能的最小大小(参见Size of an asm)。

  • goto

此限定符通知编译器,汇编语句可能会执行一个跳转,跳转目的是 goto 标签里的一个(参见 GotoLabels)。

这里把这些限定符列举出来,只是为了文章的完整性,我们不会对这些限定符做深入讨论。如果大家想做深入研究,可以参考 gcc 相关文档。

3.2 汇编模板(assembler template)

汇编模板包含一组汇编代码,格式同 2.2 节。但与基本内联汇编不同的是,扩展汇编的代码中允许存在操作数占位符,如 %0,%1等。

占位符分为序号占位符名称占位符两种。

  • 序号占位符:

序号占位符是对在 output 和 input 中的操作数的引用,按照它们从左到右出现的次序从 0 开始编号,一直到 9,引用它的格式是%0~9,也就是说最多支持 10 个序号占位符。

在操作数自身的序号前面加 1 个百分号 % 便是对相应操作数的引用。一定要切记,占位符指代约束所对应的操作数,也就是在汇编中的操作数,并不是圆括号中的 C 变量。

举个例子:

asm("addl %%rbx, %%rax":"=a"(out_sum):"a"(in_a),"b"(in_b));

等价于:

asm("addl %2, %1":"=a"(out_sum):"a"(in_a),"b"(in_b));

其中:

"=a"(out_sum)序号为0, %0 对应的是 rax。

"a"(in_a) 序号为1,%1对应的是 rax

"b"(in_b) 序号为 2,%2 对应的是 rbx

前文说过,由于扩展内联汇编中的占位符要有前缀 %,为了区别占位符和寄存器,只好在寄存器前用两个 % 做前缀。

我们写一个简单的包含占位符的例子:

#include <stdio.h>
​
int main(void)
{
    __uint64_t ret;
    asm volatile
    (
        "movq $0x1122334455667788, %%rax;"
        "movq %%rax, %0"
        :"=r"(ret)
    );
​
    printf("ret is: %ld\n", ret);
    return 0;
}

编译并运行,其输出结果如下:

$ gcc -o test test.c
$ ./test
ret is: 1234605616436508552

我们知道,指令的操作数大小并不一致,有的指令操作数大小是 64 位,有的是 32 位,有的是 16 位,有的是 8 位。一个 64 位寄存器,我们既可以当做 64 位来使用,也可以当做 32 位、16位、8位来使用。比如,对于寄存器 %rax,当我们使用全部 64 位时,我们称它为 %rax;使用低 32 位时,为 %eax;依次类推,低 16 位为 %ax,低 8 位为 %al,高 8 位为 %ah。有些情况下,编译器能够推断出操作数的位数;但在某些特殊情况下,编译器会报错。比如下面这段代码:

#include <stdio.h>
int main(void)
{
    __uint64_t x = 0x1122334455667788, y = 0;
    asm volatile
    (
        "movl %1, %0;"
        : "=m"(y)
        : "a"(x)
    );
    printf("x’s lower double word is: %ld\n", y);
​
    y = 0;
    asm volatile
    (
        "movw %1, %0;"
        : "=m"(y)
        : "a"(x)
    );
    printf("x’s lower word is: %ld\n", y);
    return 0;
}

在这段代码中,我们想获取变量 x 的低 32 位(双字)和低 16 位(单字)。虽然我们在汇编指令中分别用 movl 和 movw 指定了操作数大小,但是在编译时依然会报错:

$ gcc -o test test.c 
test.c: Assembler messages:
test.c:5: Error: incorrect register `%rax' used with `l' suffix
test.c:14: Error: incorrect register `%rax' used with `w' suffix

从报错信息中可以看到,虽然我们在汇编指令中使用了指定操作数对应的后缀,但是编译器依然使用的是 %rax寄存器,并给我们报了操作数大小不匹配的错误。

为了解决这个问题,GCC 提供了操作数描述符,让我们来手动指定操作数的大小及其它功能,比较常用的操作数描述符有以下几个:

Modifier Description Operand ‘att’
A Print an absolute memory reference. %A0 *%rax
b Print the QImode name of the register. %b0 %al
d print duplicated register operand for AVX instruction. %d5 %xmm0, %xmm0
E Print the address in Double Integer (DImode) mode (8 bytes) when the target is 64-bit. Otherwise mode is unspecified (VOIDmode). %E1 %(rax)
h Print the QImode name for a “high” register. %h0 %ah
H Add 8 bytes to an offsettable memory reference. Useful when accessing the high 8 bytes of SSE values. For a memref in (%rax), it generates %H0 8(%rax)
k Print the SImode name of the register. %k0 %eax
q Print the DImode name of the register. %q0 %rax
w Print the HImode name of the register. %w0 %ax

注:完整的描述符列表可以参考 GCC 官方文档 Extended Asm:x86 operand modifiers

从上表可以看到,如果我们想用寄存器低 32 位如%eax,需要在 % 和 占位序号之间加上 k,即%eax;想用寄存器低 16 位如%ax,需要在 % 和 占位序号之间加上 w,即%w0,等等。

根据描述符要求,我们把代码修改一下:

#include <stdio.h>
int main(void)
{
    __uint64_t x = 0x1122334455667788, y = 0;
    asm volatile
    (
        "movl %k1, %0;"
        : "=m"(y)
        : "a"(x)
    );
    printf("x’s lower double word is: %ld\n", y);
​
    y = 0;
    asm volatile
    (
        "movw %w1, %0;"
        : "=m"(y)
        : "a"(x)
    );
    printf("x’s lower word is: %ld\n", y);
    return 0;
}

再次编译并运行:

$ gcc -o test test.c 
$ ./test
x’s lower double word is: 1432778632
x’s lower word is: 30600

可以看到,这次可以正常编译运行了。

  • 名称占位符

名称占位符与序号占位符不同,序号占位符靠本身出现在 output 和 input 中的位置就能被编译器辨识出来。而名称占位符需要在 output 和 input 中给操作数显式的起个名字,用名字来标识操作数,格式如下:

[名称] " 约束名 " (C变量)

这样,该约束对应的汇编操作数就有了名字,在 assembly template 中应用操作数时,采用 %[名称] 的形式就可以了。

示例如下:

#include <stdio.h>
void main() {
    int a = 18, b = 3, out = 0;
    asm("divb %[divisor]; movb %%al, %[result]" \
            :[result]"=m"(out)                  \
            :"a"(a),[divisor]"m"(b)
       );
    printf("result is %d\n", out);
}

编译并运行:

$ gcc -o test name_placeholder_test.c 
$ ./test 
result is 6

3.3 操作数

3.3.1 输出操作数(output operands)

output 用来指定汇编代码的数据如何输出给 C 代码使用。内嵌的汇编指令运行结束后,如果想将运行结果保存到 c 变量中,就用此项指定输出的位置。output 中每个操作数格式为:

"操作数修饰符约束名" (C变量名)

其中的引号和括号不能少,操作数修饰符通常为等号 “=”。多个操作数之间用逗号 “,” 分隔。

3.3.2 输入操作数(input operands)

input 用来指定 C 中数据如何输入给汇编使用。要想让汇编使用C中的变量作为参数,就要在此是定。input 中每个操作数的格式为:

"[操作数修饰符] 约束名" (C变量名)

其中的引号和圆括号不能少,操作数修饰符为可选项。多个操作数之间用盗号“,”分隔。

单独强调一下,以上的 output() 和 intput() 括号中的是 C 代码中的变量,output(C变量)和input(C变量) 就像 C 语言中的函数,将 C 变量(值或变量地址)转换成汇编代码的操作数。

3.4 破坏列表(list of colbber/modify)

汇编代码执行后会破坏一些内存或寄存器资源,通过此项通知编译器,可能造成寄存器或内存数据的破坏,这样 gcc 就知道那些寄存器或内存需要提前保护起来。

怎样通知 gcc 我们修改了哪些寄存器?

这个很简单,只要在 clobber/modify 部分明确写出来就行了,记得要用双引号把寄存器名称引起来,多个寄存器之间用逗号“,”’分隔,这里的寄存器不用再加两个’%’啦,只写名称即可,如:

asm ("movl %%eax, %0; movl %%eax,%%ebx"
     :"=m"(ret_value )
     :
     : "bx"
     ) 

大家看,虽然修改的是寄存器 ebx ,但只要在 elobber/modify 声明 bx 就可以了,甚至可以声明 bl 。原因是即使寄存器只变动一部分,它的整体也会全跟着受影响,所以在 clobber/modiy 句中声明寄存器时,可以用低 8 位名称、低 16 位名称或低 32 位或全 64 位名称,如"al"、"ax"、"eax"、"rax" 都是指 rax 寄存器,其他通用寄存器也是一样的。

如果我们的内联汇编代码修改了标志寄存器 eflags 中的标志位,同样需要在 clobber/modiy 命中用”cc”声明。

如果我们修改了内存,我们需要在 clobber/modiy 中用”memory ”声明。

3.5 约束

约束描述了操作数是否使用了寄存器,用的是哪种寄存器;是否引用了内存,用的是哪种地址;是否是立即数,是哪种类型的立即数等等。它所起的作用就是把 C 代码中的操作数(变量、立即数)映射为汇编中所使用的操作数,实际就是描述 C 中的操作数如何变成汇编操作数。这些约束的作用域是 input 和 output 部分,约束分为以下几类:

3.5.1 寄存器约束

寄存器约束就是要求 gcc 使用哪个寄存器,将 input 或 output 中变量约束在某个寄存器中。常见的寄存器约束有:

a:表示 a 系列寄存器 rax/eax/ax/al

b:表示 b 系列寄存器 rbx/ebx/bx/bl

c:表示 c 系列寄存器 rcx/ecx/cx/cl

d:表示 d 系列寄存器 rdx/edx/dx/dl

D:表示寄存器 rdi/edi/di

S:表示寄存器 rsi/esi/si

q:表示 rax/rbx/rcx/rdx 这 4 个通用寄存器中任意一个

r:表示 rax/rbx/rcx/rdx/rsi/rdi 这 6 个通用寄存器中任意一个

g:表示可以存放到任意地点(寄存器和内存)。相当于除了同 q 一样外,还可以让 gcc 安排在内存中

A:a 和 d 寄存器,用于返回结果保存在a 和 d 寄存器的指令。

f:表示浮点寄存器

t:表示第 1 个浮点寄存器

u:表示第 2 个浮点寄存器

p:表示内存地址,给 "load address" 和 "push address" 指令使用。

值得注意的一点这是,在基本内联汇编中,寄存器表示方法和直接写汇编没什么区别,都是以%开头,后面跟着寄存器名称,比如 %eax;但是在扩展内联汇编中,单个 % 有了新的用途,用来表示占位符,所以在扩展内联汇编中的寄存器名称前要用两个%做前缀。下面对比一下:

基本内联汇编:

#include <stdio.h>
int a = 1, b = 2, sum;
void main() {
    asm("push %rax;                 \
         push %rbx;                 \
         movl a, %eax;              \
         movl b, %ebx;              \
         addl %ebx, %eax;           \
         movl %eax, sum;            \
         pop %rbx;                  \
         pop %rax;");
    printf("sum is %d\n", sum);
}

扩展内联汇编:

#include <stdio.h>
void main(){
    int a = 1, b = 2, sum;
    asm("addl %%ebx, %%eax":"=a"(sum):"a"(a),"b"(b));
    printf("sum is %d\n", sum);
}

可以看到,a 和 b 是在 input 部分中输入的,用约束名 a 为 c 变量 a 指定了用寄存器 eax,用约束名 b 为 c 变量 b 指定了用寄存器 ebx。addl 指令的加过保存到寄存器 eax 中,在 output 中用约束名 a 指定了把寄存器 eax 的值存储到 c 变量 sum 中。output 中的 = 号是操作数类型修饰符,表示只写,其实就是 sum = eax 的意思。

3.5.2 内存约束

内存约束是要求 gcc 直接将位于 input 和 output 中的 C 变量的内存地址作为内联汇编代码的操作数,不需要寄存器做中转,直接进行内存读写,也就是汇编代码的操作数是 C 变量的指针。

m:表示内存操作数,可以是计算机支持的任何类型的地址。

o:表示内存操作数,但访问它是通过偏移量的形式访问,即包含 offset_address 的格式。

代码示例:

#include <stdio.h>
int main(void)
{
        static unsigned long arr[3] = {0, 1, 2};
        static unsigned long element;

        asm volatile("movq 16+%1, %0" : "=r"(element) : "o"(arr));
        printf("%lu\n", element);
        return 0;
}

3.5.3 立即数约束

立即数即常数,此约束要求 gcc 在传值的时候不通过寄存器或内存,直接作为立即数传给汇编代码。由于立即数不是变量,只能作为右值,所以只能放在 input 中。

i:表示操作数为整数立即数

F:表示操作数为浮点数立即数

I(大写的i):表示操作数为0~31之间的立即数

J:表示操作数为0~63之间的立即数

N:表示操作数为0~255之间的立即数

O:表示操作数为0~32之间的立即数

X:表示操作数为任何类型立即数

3.5.4 通用约束

0~9:此约束只用在 input 部分,表示该 input 操作数与 output 中第 n 个操作数用相同的寄存器或内存。

3.5.5 操作数类型修饰符

在约束中还有操作数类型修饰符,用来修饰所约束的操作数。

在 output 中有以下 3 种:

=:表示操作数是只写,相当于为 output 括号中的 C 变量赋值,如 "=a"(c_var),此修饰符相当于 c_var = eax

+:表示操作数是可读写的,告诉 gcc 所约束的寄存器或内存先被读入,再被写入。

&:表示此 output 中的操作数要独占所约束(分配)的寄存器,只供 output 使用,任何 input 中所分配的寄存器不能与此相同。注意,当表达式中有多个修饰符时,& 要与约束名挨着,不能分隔。

在 input中:

%:该操作数可以和下一个输入操作数互换

一般情况下,input 中的 C 变量是只读的,output中的 C 变量是只写的。修饰符 “=” 只用在output中,表示 C 变量是只写的。

修饰符 “+” 也只用在 output 中,但它具备读、写的属性,也就是它既可以作为输入,同时也可以作为输出,所以省去了在 input 中声明约束。示例如下:

#include <stdio.h>
void main(){
    int a = 1, b = 2;
    asm("addl %%ebx, %%eax;":"+a"(in_a):"b"(in_b));
    printf("a is %d\n", a);
}

猜你喜欢

转载自blog.csdn.net/m0_50662680/article/details/130875689