0%

语言高阶-内联汇编

一、编译原理

在我一开始接触内联汇编的时候,我已经略懂编译的原理了,而这更让我困惑,比如说像这样的内联汇编:

asm volatile("lw a0, 0(%0)"
    :
    : "r"(addr));

会改写寄存器 a0 的值,那如果 a0 原先就被程序中的某个变量占用了呢?那岂不是会造成原来那个变量所参与的行为发生错误?同时 addr 需要被放到一个寄存器中(我们称之为 rx),而 rx 如果之前也被占用了呢?,为了把原来在内存中的变量搬运到 rx 寄存器中,是不是也会

其实内联汇编并不是生硬地将 lw a0, 0(rx) 直接插入到代码里,因为这样确实会因为上述的原因导致程序错误。在实际情况中,内联汇编在编译角度应该被理解成一个类似于函数调用的处理逻辑。也就是说,如果内联汇编中用到了 a0 ,那么在内联汇编前就会有一个机制保存 a0 ,而在内联汇编后则有一个机制恢复原本的 a0 。也就是和“调用者保存寄存器”的逻辑是一样的。

正因为如此,我们的内联汇编语法复杂晦涩,正是为了确保插入汇编时给编译器提供足够多的信息,避免引起错误。但是正因为内联汇编复杂,所以尽可能不要使用内联汇编。


二、语法

2.1 整体结构

内联汇编(严格意义上说是“拓展内联汇编语法”)的结构如下所示:

asm AsmQualifiers ( AssemblerTemplate
                 : OutputOperands
                 [ : InputOperands
                 [ : Clobbers ] ])

asm AsmQualifiers ( AssemblerTemplate
                      : OutputOperands
                      : InputOperands
                      : Clobbers
                      : GotoLabels)

出现在上面代码块中的 [] 表示可以省略。下面将分章节进行介绍各个部分。

2.2 asm

asm 是 GCC 支持的关键字,但不是 ISO C 中的关键字,如果我们开启了 ‐std=c99 等启用 ISO C 的编译选项,代码将无法成功编译。然而,内联汇编对于许多 ISO C 程序是必须的,GCC 通过 __asm__ 给程序员开了个后门。使用 __asm__ 替代 asm 可以让程序作为 ISO C 程序成功编译(GCC 也可以编译 ISO C)。volatile 和 inline 也有加 __ 的版本。

也就是说,asm 是属于 GCC 特定的关键字,并不是通用的 ISO C 的关键字,所以如果希望兼容性考虑,应该使用 __asm__ 来保证程序通过 ISO 标准。

2.3 AsmQualifiers

AsmQualifiers 包括以下 3 个修饰符:

  • volatile: 指示编译器不要对 asm 代码段进行优化,这个是十分必须的,因为在不考虑性能的前提下,我们使用汇编的目的往往是进行一些对系统寄存器的显式设置,我们要避免可能出现的指令重排和消除。
  • inline: 指示编译器尽可能小的假设 asm 指令的大小,这条只是建议,所以和所有没用的 inline 指令一样,可以选择不加。
  • goto:指示编译器可以在汇编指令中可以使用 C label 进行跳转,如果没有这个需求,可以不加。

这三个似乎是互斥的,我不太确定。

2.4 AssemblerTemplate

汇编模板指的是一系列汇编“模板”的集合,也就是多条类汇编指令,如下所示:

"add %0, %1, %2\n\t"
"str %0, %3"

之所以说他们是模板而非具体的指令,是因为里面有些并非汇编所有的占位符,比如说上面例子中的 %0, %1, %2 。这种东西和 C 语言中的格式化输出的 %d, %s 类似,在汇编中会被相应的 C 语言的输入/输出变量所替代。正是依靠这种机制,内联汇编建立了在C 语言变量汇编语言操作数之间的联系。

占位符主要有两种形式,一种是 %0, %1, %2,... ,这种可以分别对应到后面的第 0 个操作数,第 1 个操作数,第 2 个操作数。如下所示,对于如下代码,有 c => %0, a => %1, b => %2, d => %3

asm volatile (
    "add %0, %1, %2\n\t"
    "str %0, %3"
    : "=r" (c)
    : "r" (a), "r" (b), "m" (d)
    : "cc"    // 添加 clobber,用于标识修改了条件码寄存器
);

另一种是给占位符起名字,如下所示:

__asm__ volatile(
    "msr S3_7_C7_C0_0, %[domain]"
    :
    : [domain] "r"(d));

此时有 d => %[domain] ,注意这里的语法是 %[asmSymbolicName]] ,不仅要在 AssemblerTemplate 中记录,同时需要在 Operands 处记录。

此外,可以注意到每条指令后面都以 \n\t 结尾,这是因为 AssemblerTemplate 会用于生成汇编字符串并被汇编器读取,增加空白符可以提高兼容性。

2.5 InputOperands

之所以先介绍输入操作数,是因为输入操作数比输出操作数简单,更容易理解 C 变量和汇编操作数之间的联系。

此外还需要强调,内联汇编的许多规范都是针对 X86 指令集的,X86 因为 CISC 的原因,指令集更为复杂,比如说可以直接用内存作为操作数,这种行为在 RISC 中是不可理解的,所以对于有些语法觉得画蛇添足也并不奇怪。

InputOperands 的格式如下:

[ [asmSymbolicName] ] constraint (cvariablename)

其中 asmSymbolicName 已经介绍过了,cvariablename 表示输入给 asm 块的 C 变量,而 constraint 则决定了这个 C 变量会对应成汇编中的什么操作数,通用的有如下几种:

  • r: 通用寄存器
  • i: 在汇编时或运行时可以确定的立即数
  • n: 可以直接确定的立即数,如整形字面量
  • g: 任意通用寄存器、内存、立即数
  • m: 内存

我们可以同时指定多个 constraints ,比如说 "rm"(output) 就表示既可以将 output 对应一个内存操作数,也可以将其对应一个寄存器操作数。

有些指令,如 X86 常用的 mov 指令,两个操作数既可以都是寄存器、也可以一个是寄存器一个是内存地址。这时就有三种组合,我们可以将 constraint 可以分为多个候选组合传递给 GCC,如:

: "m,r,r"(output)
: "r,m,r"(input)

constraint 通过,分组,并且一一对应。上面的代码段相当于以下三个输出/输入列表组合在一起:

: "m" (output)
: "r" (input)
 ------------------
: "r" (output)
: "r" (input)
 ------------------
: "r" (output)
: "m" (input)

2.6 OutputOperands

OutputOperands 的结构如下所示:

[ [asmSymbolicName] ] modifier constraint (cvariablename)

可以看到基本上和 InputOperands 结构类似,多出来的是 modifier ,这个必须且只能用于输出操作数。modifier 常见的有 3 种:

  • =: 操作对象是只写的。也就是最为普通的输出操作符
  • +: 操作对象是读写的。也就是这个既是输出操作符,也是输入操作符
  • &: 指示该输出操作符一定不能和输入操作符重叠。这个说的是如下情况:
"mov [output], $1\n\t"
"mov r1, [input]\n\t"

按理说这个情况应该有 output = 1 但是如果不加上 &output 是可以与 input 分到同一个寄存器的,所以就会导致最终效果是 output = input

之所以会这样,是因为 GCC 默认输出变量应当在输入变量之后,所以输入变量可以和输出变量共享寄存器(简单的编译原理,寄存器生命周期问题),但是当存在多条指令的时候,这个假设就会导致错误。所以需要额外声明。

2.7 Clobbers

在使用内联汇编时,我们写的汇编代码可能会产生一些副作用,GCC 必须清楚地知道这些副作用才能调整自己的行为,生成正确的代码。

举一个可能导致生成错误代码的例子。我们使用字符串复制指令 movsb 将一段内存复制到另一个地址, movsb 会读取、修改寄存器 rsirdi 的值,如果我们不告诉 GCC 我们写的汇编代码有“修改 rsirdi ”的副作用,GCC 会认为 rsirdi 没有被修改,生成错误的代码。

Clobber有以下几个:

  • cc: 条件(标准)寄存器。如 X86 的 EFLAGS 寄存器。
  • memory: 读/写内存。为了确保读取到正确的值,GCC 可能会在进入 asm 块前将某些寄存器写入内存中,也可能在必要的时候将内存中存储的寄存器值重新加载到寄存器中。
  • 寄存器名:如 x86 平台的 rax 等,直接写原名即可。

2.8 goto

GCC 提供了在内联汇编中使用 C Label 的功能,但这个功能有限制,这只能在 asm 块没有输出时使用。C Label 在内联汇编中直接当成汇编的 label 使用即可,唯一要注意的是在内联汇编中 C label 的命名。

示例如下

int foo() {
    asm goto (
        "btl %1, %0\n\t"
        "jc %l2"
        : /* No outputs. */
        : "r" (p1), "r" (p2)
        : "cc"
        : carry);
    return 0;
 carry:
    return 1;
}

可以看到跳转的时候用到的 target 是 %l2 ,之所以用这个,是因为 carry 是第 3 个操作数,所以是 l2


三、平台特异性

3.1 X86

x86 平台有些些专门的 constraint , 如:

  • a: ax 寄存器,在 32 位处理器上是 eax, 在 64 位处理器上是 rax
  • b(bx), c(cx), d(dx), S(si), D(di): 类似与a
  • q: 整数寄存器。32 位上是 a`bcd ,64 位增加了 r8 ~ r15 8 个寄存器

X86 中一个大寄存器可以重叠地分为多个小寄存器,比如 rax 低 32 位可以作为 eax 单独使用,eax 低 16 位又可以作为 ax 单独使用,ax 高 8 位可以做为 ah 单独使用、低 8 位可以作为 al 单独使用。针对这种情况,GCC 在 x86 平台专门提供了一些修饰符来调整生成的汇编代码中寄存器、内存地址等的格式。

uint16_t num;
asm volatile ("xchg %h0, %b0" : "+a" (num) );

3.2 RISC-V

GCC 对 RISC-V 平台提供了以下额外的 constrait :

  • f: 浮点寄存器
  • I: 12 比特立即数
  • J: 整数 0
  • K: 用于 CSR 访问指令的 5 比特的无符号立即数
  • A: 存储在通用寄存器中的地址

需要注意,GCC 没有提供对 RISC-V 特定寄存器的 constrait 。


四、杂项

4.1 基本内联汇编

其实上面介绍的内联汇编语法应该被称为“拓展内联汇编语法”,还有一种语法被称为“基本内联汇编”,其结构与拓展语法相比,缺少了输入输出列表等(也就是没有 : 语法符号了),如下所示:

asm asm_qualifiers ( AssembleInstructions )

这种语法会直接将所有的 AssemblerInstructions 直接插入到代码里,也就是那种有可能会导致程序错误的直接插入。

但是这种方法并非没有用处,比如说可以用于同步,如下所示:

inline void sfenceVma()
{
    asm volatile("sfence.vma");
}

4.2 内联寄存器

GCC 没有提供对 RISC-V 特定寄存器的 constrait ,如果我们需要将变量分配到特定的寄存器(比如说通过 an 传入 sbi 的参数),只能通过分配寄存器变量的方式曲线救国。

寄存器变量是 ISO C 的特性,语法为:

register type cvariable;

ISO C 中的寄存器变量特性只是“建议”将某个变量分配到寄存器中,最终是否分配到寄存器中由编译器决定,并且没有提供指定寄存器的语法,分配到哪个寄存器也由编译器决定。GCC 拓展了 ISO C 中寄存器变量的特性,提供了指定寄存器的语法,只要分配的寄存器合法就会分配成功。

语法结构如下:

register type cvariable asm ("register")

register unsigned long long i asm ("rax"); // x86

寄存器变量仅仅是指示编译器将变量放置在特定的寄存器中,不意味这在该变量的整个生命周期中该变量都独占该寄存器,该寄存器很可能会被分配为别的变量使用。程序员只可以假设在声明时变量在指定的寄存器中,之后的语句中不能假设该变量仍在该寄存器中,生成的任何指令都可能修改该寄存器的值。

当 GCC 没有提供将变量分配到特定寄存器中的 constraint 时,我们将该变量声明为局部寄存器变量,并将其分配到特定的寄存器中。然后紧贴着写内联汇编,分配到寄存器中就使用r constrait。也就是说,这个拓展用法仅仅用于弥补在 riscv 内联汇编无法指定特定寄存器的缺陷。

如下所示:

inline void putcharSbi(char c)
{
    register u64 a0 asm("a0") = (u64)c;
    register u64 a7 asm("a7") = (u64)SBI_CONSOLE_PUTCHAR;
    asm volatile("ecall"
                 : "+r"(a0)
                 : "r"(a7)
                 : "memory");
};

4.3 asm 的大小

为了生成正确的代码,一些平台需要 GCC 跟踪每一条指令的长度。但是内联汇编由编译器完成,指令的长度却只有汇编器知道。

GCC 使用比较保守的办法,假设每一条指令的长度都是该平台支持的最长指令长度。asm 块中所有语句分割符(如 ; 等)和换行符都作为指令结束的标准。

通常,这种办法都是正确的,但在遇到汇编器的伪指令和宏时可能会产生意想不到的错误。汇编器的伪指令和宏最终会被汇编器转换为多条指令,但是在编译器眼中它只是一条指令,从而产生误判,生成错误的代码。

因此,尽量不要在 asm 块中使用伪指令和宏。