一、编译原理
在我一开始接触内联汇编的时候,我已经略懂编译的原理了,而这更让我困惑,比如说像这样的内联汇编:
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
会读取、修改寄存器 rsi
和 rdi
的值,如果我们不告诉 GCC 我们写的汇编代码有“修改 rsi
和 rdi
”的副作用,GCC 会认为 rsi
和 rdi
没有被修改,生成错误的代码。
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
、`b
、c
、d
,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 块中使用伪指令和宏。