学习 GCC 内联汇编又多了一个好处。现在让我们深入内核,看看一些事情是如何实际工作的。
GNU C 编译器允许您将汇编语言代码嵌入到 C 程序中。 本教程解释了如何在 ARM 架构上做到这一点(译注:因此,若要测试本文档中使用的例子,需采用针对gcc制作的交叉编译器才可,文中我的交叉编译器gcc命名为arm-linux-gnueabihf-gcc)。 由于 GNU 汇编器对于不同的体系结构是相似的,包括汇编器语法和大多数汇编器指令,因此内联汇编的一般概念对于其他体系结构也保持相同。
为什么要将汇编代码嵌入到 C 中?至少有两个原因:
性能优化:除非另有说明,否则编译器倾向于优化。然而,对于某些应用程序,手写汇编取代了对性能最敏感的部分(译注:即对于汇编高手而言,手写汇编运行效率比编译器所做的优化更高)。由于内联汇编器不需要单独的汇编和链接,因此它比单独编写的汇编模块更方便。内联汇编代码可以使用范围内的任何 C 变量或函数名称,因此可以轻松地将其与 C 代码集成。
访问特定处理器指令:C代码不支持饱和数学运算(saturated math operation)(译注: 就是当运算结果大于一个上限或小于一个下限时,结果就等于上限或是下限),协处理器指令或访问当前程序状态寄存器(CPSR)。C代码也不支持ARM 架构LDREX/STREX 指令。ARM架构使用LDREX/STREX指令实现其原子操作(atomic operations)并锁定原语(primitives)。内联汇编是访问这些不受C编译器支持的指令的最容易的方式。
示例代码:
#include
{
int result;
asm volatile("add %[Rd], %[Rm], %[Rn]" : [Rd] "=r" (result):[Rm] "r" (x), [Rn] "r" (y));
return result;
}
int main(void)
{
int ret;
ret = add(5, 7);
printf("the result is = %d\n", ret);
return 0;
}
内联汇编解释:asm volatile("add %[Rd], %[Rm], %[Rn]" : [Rd] "=r" (result) : [Rm] "r" (x), [Rn] "r" (y) );
在解释这段代码之前,我们补充一点基础知识。“asm”关键字允许你在C代码中嵌入汇编语言代码,它是GNU的一种扩展。GCC有两种形式的“asm”内联汇编语句:基础asm和扩展asm。基础asm没有操作数,而扩展asm包括一个或多个操作数。基础asm使你可以包含函数之外的汇编代码。扩展asm倾向于在函数中混合使用C和汇编代码。
asm asm修饰符 (汇编模板)
(1) 关键字“asm ”
“asm”关键字是GNU的扩展,当您的代码使用 -ansi 或 -std 选项编译时,请使用 __asm__ 代替 asm。为了兼容,Linux内核二者兼用。
(2) asm修饰符有两种:
“volatile”修饰符
这里的“volatile”修饰符是可选的,所有基础asm都隐式地使用修饰符“volatile”。(译注:volatile(易失性的)是一个 ANSI C 类型修饰符,在作为信号/中断处理程序、线程代码和其他内核代码(包括设备驱动程序)一部分的 C 代码中经常需要。一般来说,任何可能被异步更新的数据都应声明为易失性的。 顺便说一句,这个问题与 CPU 缓存无关,只是将变量重新加载到寄存器中可能涉及缓存命中或未命中。)
“inline”修饰符
如果你使用“inline”修饰符,则出于内联的目的,asm语句的大小会被视为可能的最小的大小。某些目标要求 GCC 跟踪所使用的每条指令的大小,以便生成正确的代码。 由于 asm 语句生成的代码的最终长度只有汇编程序知道,因此 GCC 必须估计它有多大。它通过计算 asm 模式中的指令数量并将其乘以该处理器支持的最长指令的长度来实现此目的。 (在计算指令数时,它假设汇编器支持任何换行符或任何语句分隔符的出现——通常是“;” ——表示指令的结束。) 通常,GCC 的估计足以确保生成正确的代码,但是如果您使用伪指令或扩展为多个实际指令的汇编器宏,或者如果您使用扩展为更多空间的汇编器指令,则可能会使编译器感到困惑。目标文件比单个指令所需的要多。如果发生这种情况,汇编器可能会生成一个诊断信息,指出标签无法访问。该大小也用于内联决策。 如果您使用 “asm line”替代仅使用“asm”,则出于内联目的,asm 的大小将被视为最小大小,而忽略掉 GCC 认为的它的指令数。
(3) 汇编模板(编译时会用真正的汇编代码替换)
汇编模板是可以被GNU编译器识别的可以包含任意汇编指令的字符串(包括伪指令(directives)(译注:即,伪指令是告诉汇编器如何编译指令的指示符,它本身不是汇编语言的组成部分))。一个C编译器不会解析或检验汇编指令的有效性。汇编模板的解析和语法检查是在汇编阶段完成的。单条asm字符串可以包含多条汇编指令。你可以使用一个tab(\n\t,\n表示换行,\t表示空四个字符)来中断本行并换到下一行且缩进代码。(一些汇编器允许使用分号作为行分隔符。 但是,请注意,某些汇编语言使用分号来开始注释。)
下面一行代码是内核中的(arch/arm/include/asm/barrier.h)基础asm代码:
#define nop() __asm__ __volatile__("mov\t r0,r0\t @ nop\n\t");
这个语句很简单:
asm volatile("mov r0,r0");
上面内联汇编语句将r0寄存器的值复制到其自身。结束的nop()指令仅起延时作用。
使用扩展 asm(请参阅扩展 Asm——使用 C 表达式操作数的汇编器指令)通常会生成更小、更安全且更高效的代码,并且在大多数情况下,它是比基础 asm 更好的解决方案。不过有两种情况只能使用基础asm: 扩展 asm 语句必须位于 C 函数内部,因此要在 C 函数之外的文件范围(“顶级”)编写内联汇编语言,必须使用基本asm。 您可以使用此技术发出汇编程序指令,定义可在文件中其他位置调用的汇编语言宏,或用汇编语言编写整个函数。函数之外的基础 asm 语句不得使用任何修饰符 使用 bare 属性声明的函数也要求使用基础asm(请参阅声明函数的属性)。
安全地访问 C 数据并从基础asm 调用函数比看起来更复杂。要访问C数据,最好使用扩展asm。
不要期望一系列 asm 语句在编译后保持完全连续。如果某些指令需要在输出中保持连续,请将它们放在单个多指令 asm 语句中。请注意,相对于其他代码而言,GCC 的优化器可以移动 asm 语句,包括跨跳转。
asm 语句不可以执行跳进其它asm 语句的代码,GCC并不知道这些跳转,因此,当决定优化的时候不会考虑它们(译注:可能被优化掉)。仅扩展asm支持从汇编代码跳到c语言标签。
在某些情况下,GCC 在优化时可能会复制(或删除重复的)汇编代码。如果您的汇编代码定义了符号或标签,这可能会导致编译期间出现意外的重复符号错误。
C 标准没有指定 asm 的语义,这使其成为编译器之间不兼容的潜在根源。这些不兼容性可能不会产生编译器警告/错误。
GCC 不解析基础 asm 的汇编语句,这意味着无法向编译器传达其中发生的情况。 GCC 在 asm 中没有符号的可见性,并且可能将它们作为未引用而丢弃。它也不知道汇编代码的副作用,例如对内存或寄存器的修改。 与某些编译器不同,GCC 假定通用寄存器不会发生任何更改。 这一假设可能会在未来的版本中发生变化。
为了避免将来语义更改和编译器之间的兼容性问题带来的复杂性,请考虑用扩展 asm 替换基础asm。有关如何执行此转换的信息,请参阅如何从基础 asm 转换为扩展 asm。
编译器将基本 asm 中的汇编指令逐字复制到汇编语言输出文件,而不处理方言(dialects)或扩展 asm 中可用的任何“%”运算符。这导致基础 asm 字符串和扩展 asm 模板之间存在细微差别。例如,要引用寄存器,您可以在基础 asm 中使用“%eax”,在扩展 asm 中使用“%%eax”。
在支持多种汇编器方言的目标(例如 x86)上,所有基础 asm 块都使用 -masm 命令行选项指定的汇编器方言(请参阅 x86 选项)。基础 asm 没有提供为不同方言提供不同汇编字符串的机制。
对于具有非空汇编器字符串的基础 asm,GCC 假定汇编器块不会更改任何通用寄存器,但它可以读取或写入任何全局可访问的变量。
下面是 i386 的基础asm示例:
/* Note that this code will not compile with -masm=intel */
#define DebugBreak() asm("int $3")
asm [volatile] (Assembler Template : OutputOperands /* optional */ : InputOperands /* optional */ :
Clobbers /* optional */)
和
asm [volatile] (Assembly Template : OutputOperands /* optional */ : InputOperands /* optional */ :
Clobbers /* optional */ :
GotoLabels /* optional */)
(1) 关键字“asm ”
意义同基础asm。
(2) asm修饰符有三种:
“volatile”修饰符
扩展 asm 语句的典型用途是操作输入值以产生输出值。然而,您的 asm 语句也可能会产生副作用。 如果是这样,您可能需要使用 “volatile” 限定符来禁用某些优化。见易失性(volatile)。同基础asm的“volatile”关键字介绍。
“inline”修饰符
如果你使用“inline”修饰符,则出于内联的目的,asm语句的大小会被视为可能的最小的大小。同基础asm的“inline”关键字介绍。
“goto”修饰符
此修饰符通知编译器 asm 语句可以执行跳转到 GotoLabels 中列出的标签之一。 请参阅转到标签。
(3) 汇编模板
汇编模板是一个文字字符串,它是固定文本和涉及输入和输出参数的标记的组合。 OutputOperands 和 InputOperands 是以逗号分隔的可选的 C 变量列表。Clobbers (重写文件或内存)也是可选的以逗号分隔的寄存器列表或其他特殊值。 请继续阅读以了解有关这些的更多信息。
当您使用 asm 的 goto 形式时,此部分包含汇编代码中的代码可能跳转到的所有 C 标签的列表。请参阅转到标签。
asm 语句不能执行跳转进其他 asm 语句的操作,只能跳转到所列出的 GotoLabels。 GCC 的优化器不知道其他跳转;因此,他们在决定如何优化时无法考虑这些因素。
asm 语句允许您直接在 C 代码中包含汇编指令。 这可以帮助您最大限度地提高时间敏感代码的性能或访问 C 程序不易使用的汇编指令。
请注意,扩展 asm 语句必须位于函数内部。只有基础asm可以是外部函数(请参阅基础汇编——无操作数的嵌入汇编指令)。 使用 bare 属性声明的函数也需要基础asm(请参阅声明函数的属性)。
虽然 asm 的用途多种多样,但将 asm 语句视为一系列将输入参数转换为输出参数的低级指令可能会有所帮助。因此,使用 asm 的 i386 的简单(如果不是特别有用)示例可能如下所示:
int src = 1;
int dst;
asm ("mov %1, %0\n\t"
"add $1, %0"
: "=r" (dst)
: "r" (src));
printf("%d\n", dst);
此代码将 src 复制到 dst 并向 dst 加 1。
(译注:
编译后生成的汇编列表如下所示(其中,#APP 表示其后的代码由用户实现,而并不是编译器产生,#NO_APP 则表示其后的代码由编译器生成):
#APP
# 16 "testasm.c" 1
mov %eax, %eax
add $1, %eax
# 0 "" 2
#NO_APP
)
例子包含一个asm扩展语句,在汇编代码之后,用冒号(:)分隔每个操作数参数。
(1) 语句
"add %[Rd], %[Rm], %[Rn]"
是一个包含汇编译代友的文字字符串(寄存器Rn的值加上寄存器Rm的值并将结果存入寄存器Rd)。
(2) 语句
[Rd] "=r" (result)
表示由括在方括号中的符号名称组成的输出操作数,后接一个约束字符串和一个括号括起来的C 变量名。
(3) 语句
[Rm] "r" (x), [Rn] "r" (y)
是输入操作数列表,输入操作数列表使用与输出操作数类似的语法。
输出操作数具有下列格式:
[asmSymbolicName] constraint (cvariablename)
[asm符号名] 约束 (C变量名)
一个asm语句有零个或多个由汇编代码所修饰的表示C变量名的输出操作数。asmSymbolicName为操作数指定了一个符号名,方括号([])用于引用内部的asm语句。这个名字的范围是包含这个定义的asm语句。
您还可以使用汇编程序模板中操作数的位置(例如,如果有3个操作数,0% 表示第一个,1% 表示第二个,2% 表示第三个,如此,等等),你可以将这个例子重写为:
asm volatile("add %0, %1, %2" : "=r" (result) : "r" (x), "r" (y) )。即,你可以在内联汇编中使用c语言的变量,即输入输出参数,特别注意,GCC的编号是将输出输入参数个数合并在一起计算的,从输出参数操作数到输入参数开始从0开始编号,例如: uint64_t a = 10, b = 20,c = 30,d = 0; __asm__ __volatile__("movq %2, %%rax \n\t movq %%rax, %0 \n\t" :"=r"(b) /* output */ :"r"(a),"r"(c) /* input */ : /* clobbered register */ ); printf("a=%d,b=%d:\n",a,b);
以上程序段中,GCC操作数有3个,%2表示输出参数c, %0表示输出参数b,%1表示输入参数a。因此,程序输出 a = 10,b = 30 。
各种约束说明如下:
r——从任意可获得的寄存器输出到变量。
如果使用多个输出参数,使用这种方式输出参数值,只会保留最后一个值,是否还有别的原因?
g——让编译器决定使用哪个寄存器输出到变量。
m——使用内存作为输出参数。
uint64_t i1 = 10, i2 = 20,o1 = 0,o2 = 0;
__asm__ __volatile__("movq %2, %%rax \n\t movq %%rax, %0\n\t movq %3, %%rcx \n\t movq %%rcx, %1"
:"=m"(o1),"=m"(o2) /* output */
:"r"(i1),"r"(i2) /* input */
: /* clobbered register */
);
printf("o1=%d,o2=%d:\n",o1,o2);
输出结果: o1 = 10,o2 = 20 。
说明:因为输入输出参数有4个,因此,操作数编号从0-3, %2表示输入参数i1,%3表示输入参数i2,%0表示输出操作数o1, %1表示输出操作数o2。movq %2, %%rax 将输入操作数i1值传给寄存器rax, movq %%rax, %0 再将值传给输出变量o1,后两句功能相同。
a——使用寄存器rax的值输出到变量。
例如:
uint64_tb = 0;
__asm__ __volatile__("movq $111, %%rax" :"=a"(b) /* output */);
printf("b=%d\n", b);
输出 b = 111
b——使用寄存器rbx的值输出到变量。
例如:
__asm__ __volatile__(" movq $111, %%rbx" :"=b"(b) /* output */);
c——使用寄存器rcx的值输出到变量。
例如:
__asm__ __volatile__(" movq $111, %%rcx" :"=c"(b) /* output */);
d——使用寄存器rdx的值输出到变量。
例如:
__asm__ __volatile__(" movq $111, %%rdx" :"=d"(b) /* output */);
f——使用浮点寄存器的值输出到变量。
D——使用寄存器rdi的值输出到变量。
例如:
__asm__ __volatile__(" movq $111, %%rdi" :"=D"(b) /* output */);
S——使用寄存器rsi的值输出到变量。
例如:
__asm__ __volatile__(" movq $111, %%rsi" :"=S"(b) /* output */);
约束是一个字符串常量,它指定对操作数放置的限制。 有关 ARM 和其他体系结构支持的约束的完整列表,请参阅 GCC 文档。 最常用的约束是“r”(译注:即,“register”的首字母),用作通用寄存器(r0 至 r15); “m”表示任何有效的内存位置,“I”表示立即整数(译注:即,我们常说的常数,硬编码在处理器指令中的常数)。
各种约束说明如下:
约束是一个字符串常量,它指定对操作数放置的限制。 有关 ARM 和其他体系结构支持的约束的完整列表,请参阅 GCC 文档。 最常用的约束是“r”(译注:即,“register”的首字母),用作通用寄存器(r0 至 r15); “m”表示任何有效的内存位置,“I”表示立即整数(译注:即,我们常说的常数,硬编码在处理器指令中的常数)。约束字符可以使用约束修饰符作为前缀:
= ——只写操作数,用作输出操作数(译注:可以理解为给变量赋值)。
+ ——读写操作数,必须列为输出操作数。
& ——仅用于输出寄存器(译注:即,输出值到寄存器)。
输出操作数必须只写,输入操作数必须只读。没有任何修饰符的约束只读。因此,现在清楚了,为什么例子程序中的输出操作数有“=r”,而输入操作数有“r”。但是,如果你的输入操作数和输出操作数是同一个量,又是什么情况呢?在这种情况下,则必须使用“+r”约束且必需列为输出操作数:
asm volatile("mov %[Rd], %[Rd], lsl #2" : [Rd] "+r"(x));
(译注:编译后生成汇编代码的编译命令:
arm-linux-gnueabihf-gcc -S inline_shift.c -o file.s)
上面的汇编代码将会产生类似如下的汇编代码信息:
#APP @ 5 "inline_shift.c" 1 mov r3, r3, lsl #2 @ 0 "" 2
(译注:我测试的输出汇编文件信息如下:
@ 6 " inline_shift.c" 1
mov r3, r3, lsl #2
@ 0 "" 2
。)
有时候,即使你没有用指令指示编译器选择同一个寄存器作为输入和输出,它也会这么。如果你要显式地要求处理器使用不同的寄存器作为输入和输出,请使用“=&”约束修饰符。
输出操作数约束应池后接一个必须为输出操作数左值表达式的C变量名(译注:即,必须为可写的表达式)。
输入操作数的语法与输出操作数的语法类似。但是,其语法不应以“=”或“+”起始。输入操作数对寄存的约束不用任何修饰符,因为它们是只读的操作数。你永远不应试图修饰只读输入操作数的内容。如上所述,当输入和输出相同的时候,使用“+r”修饰符。
有时,除了输出操作数中列出的寄存器之外,内联汇编可能还会修改其他寄存器(副作用)。为了让编译器意识到这个额外的改变,你需要将它们列在一个clobber重写列表中。Clobber 列表项可以是寄存器名称,也可以是特殊的 Clobber。 每个 clobber 列表项都是一个字符串常量,并以逗号分隔。当编译器为输入和输出操作数分配寄存器时,它不会使用任何被标识为clobber的寄存器。被标为clobber的寄存器可用于汇编代码中的任何用途。让我们仔细看看没有clobber列表的内联汇编译程序。内联汇编代码可能如下所示:
#APP @ 6 "inline_add.c" 1 add r3, r3, r2 @ 0 "" 2
这里使用了r2和 r3 寄存器。现在我们修改它,在clobber列表中列出这两个寄存器:
asm volatile("add %[Rd], %[Rm], %[Rn]" : [Rd] "=r" (result) : [Rm] "r" (x), [Rn] "r" (y) : "r2", "r3" );
编译产生的汇编代码如下:
#APP @ 6 "inline_add2.c" 1 add r4, r1, r0 @ 0 "" 2
(译注:寄存器分配可能有所差异常,我的测试程序语句是:add r1, r1, r0 。)
注意到,编译器未使用 r2和 r3 寄存器,因为它们被列入clobber列表。在汇编代码中,处理器使用r2和 r3 寄存器来处理任何其它工作。
除了寄存器之外,还有两个特殊的重写可用:“cc”和“memory(内存)”。 “cc” clobber 表示汇编代码修改 CPSR(当前程序状态寄存器)标志寄存器。“内存”clobber告诉编译器内联汇编代码对除输入和输出操作数之外的项执行内存读写入操作。 编译器将寄存器内容刷新到内存,以便在执行内联汇编之前内存包含正确的值。此外,编译器会在内联 asm 语句之后重新加载所有可访问内存,以便获得新值。 这样,“内存”重写器就形成了跨内联 asm 语句的读写编译器屏障(barrier)(或“壁垒”):
#define barrier() __asm__ __volatile__("": : :"memory") 。
当你的代码使用 -ansi和各种-std 编译选项的时候,请使用__asm__关键字替换asm 。
基础和扩展asm的区别在于,后者有使用冒号分隔的可选输出、输入、以及clobber列表。
扩展asm必须内嵌于函数,只有基础asm语句可以位于函数体外。
扩展asm内嵌于函数体内,其典型的优势在于可产生更有效且更健壮的代码。
有时候,如果 GCC 的优化器确定不需要输出变量,则会丢弃 asm 语句。此外,如果优化器认为代码将始终返回相同的结果(即,其输入值在调用之间不会发生变化),则优化器可能会将代码移出循环。使用volatile 修饰符会禁用这些优化(译注:即标为易失性的,编译器就不会对这些部分做优化)。没有输出操作数的 asm 语句和 asm goto 语句被隐式标为volatile。
下面的 i386 代码演示了不使用(或不需要) volatile修饰符的情况。 如果正在执行断言检查,则此代码使用 asm 来执行验证。 否则,任何代码都不会引用 dwRes。 因此,优化器可以丢弃 asm 语句,从而删除整个 DoCheck 例程同,认为其是多余的。通过在不需要时省略 volatile 限定符,您可以让优化器生成尽可能最有效的代码。
#include
#include
#include
void DoCheck(uint32_t dwSomeValue)
{
uint32_t dwRes;
// Assumes dwSomeValue is not zero.
asm ("bsfl %1,%0"
: "=r" (dwRes)
: "r" (dwSomeValue)
: "cc");
assert(dwRes > 3);
}
下一个示例显示优化器可以识别输入 (dwSomeValue) 在函数执行期间永远不会改变的情况,因此可以将 asm 移出循环以生成更高效的代码。 同样,使用 volatile 限定符会禁用这种类型的优化。
void do_print(uint32_t dwSomeValue)
{
uint32_t dwRes;
for (uint32_t x=0; x < 5; x++)
{
// Assumes dwSomeValue is not zero.
asm ("bsfl %1,%0"
: "=r" (dwRes)
: "r" (dwSomeValue)
: "cc");
printf("%u: %u %u\n", x, dwSomeValue, dwRes);
}
}
(译注:下面是do_print函数生成的32位汇编文件列表:
.globl do_print
.type do_print, @function
do_print:
.LFB0: ;Local Function Beginning(局部函数体开始)
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $32, %rsp
movl %edi, -20(%rbp)
movl $0, -4(%rbp)
jmp .L2
.L3:
movl -20(%rbp), %eax
#APP
# 13 "inlineasm.c" 1
bsfl %eax,%eax ;这是嵌入汇编
# 0 "" 2
#NO_APP
movl %eax, -8(%rbp)
movl -8(%rbp), %ecx
movl -20(%rbp), %edx
movl -4(%rbp), %eax
movl %eax, %esi
movl $.LC0, %edi
movl $0, %eax
call printf
addl $1, -4(%rbp) ;计数器加1
.L2:
cmpl $4, -4(%rbp) ;是否继续循环
jbe .L3 ;小于等于4跳转,继续循环
nop
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0: ; Local Function Ending(局部函数体结束)
.size do_print, .-do_print
)
以下示例演示了需要使用 volatile 修饰符的情况。它使用 x86 rdtsc 指令,读取计算机的时间戳计数器。如果没有 volatile 修饰符,优化器可能会假设 asm 块将始终返回相同的值,因此优化掉第二次调用。
uint64_t msr;
asm volatile ( "rdtsc\n\t" // Returns the time in EDX:EAX.
"shl $32, %%rdx\n\t" // Shift the upper bits left.
"or %%rdx, %0" // 'Or' in the lower bits.
: "=a" (msr)
:
: "rdx");
printf("msr: %llx\n", msr);
// Do other work...
// Reprint the timestamp
asm volatile ( "rdtsc\n\t" // Returns the time in EDX:EAX.
"shl $32, %%rdx\n\t" // Shift the upper bits left.
"or %%rdx, %0" // 'Or' in the lower bits.
: "=a" (msr)
:
: "rdx");
printf("msr: %llx\n", msr);
(译注:下面是生成汇编语言列表:
.globl do_something
.type do_something, @function
do_something:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
#APP ;以下是嵌入的代码
# 10 "inlineasm.c" 1
rdtsc
shl $32, %rdx
or %rdx, %rax
# 0 "" 2
#NO_APP
movq %rax, -8(%rbp)
movq -8(%rbp), %rax
movq %rax, %rsi
movl $.LC0, %edi
movl $0, %eax
call printf
#APP ;以下是嵌入的汇编代码,由于加了volatile,编译器没有优化掉,与上面一样
# 22 "inlineasm.c" 1
rdtsc
shl $32, %rdx
or %rdx, %rax
# 0 "" 2
#NO_APP
movq %rax, -8(%rbp)
movq -8(%rbp), %rax
movq %rax, %rsi
movl $.LC0, %edi
movl $0, %eax
call printf
nop
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size do_something, .-do_something
)
GCC 的优化器不会像前面示例中的非易失性代码那样对待此代码。他们不会将其移出循环或忽略它,因为假设先前调用的结果仍然有效。
请注意,编译器甚至可以相对于其他代码移动易失性 asm 指令,包括跨跳转指令(译注:视具体编译器而定,不能抛弃易失性代码,但可以移动它的位置)。 例如,在许多目标上都有一个系统寄存器来控制浮点运算的舍入模式。使用易失性 asm 语句设置它(如以下 PowerPC 示例所示)并不能可靠地工作。
asm volatile("mtfsf 255, %0" : : "f" (fpenv));
sum = x + y;
编译器可能会将加法运算移回到易失性 asm 语句之前。为了使其按预期工作,请通过在后续代码中引用变量来向 asm人为添加依赖项,例如:
asm volatile ("mtfsf 255,%1" : "=X" (sum) : "f" (fpenv));
sum = x + y;
在某些情况下,GCC 在优化时可能会重复(或删除重复的)汇编代码。如果您的 asm 代码定义了符号或标签,这可能会导致编译期间出现意外的重复符号错误。 使用“%=”(请参阅 AssemblerTemplate或汇编译代码部分)可能有助于解决此问题。
汇编器模板(assembler template)是包含汇编器指令的文字字符串。编译器替换模板中引用输入、输出和 goto 标签的标记,然后将生成的字符串输出到汇编器。该字符串可以包含汇编器识别的任何指令,包括伪指令。 GCC 本身并不解析汇编指令,也不知道它们的含义,甚至不知道它们是否是有效的汇编输入。但是,它确实对语句进行计数(请参阅 asm 的大小)。
您可以将多个汇编指令放在一个 asm 字符串中,并用系统汇编代码中通常使用的字符分隔。在大多数地方有效的组合是用于换行的换行符,以及用于移动到指令字段的制表符(写为“\n\t”)。一些汇编器允许使用分号作为行分隔符。但是,请注意,某些汇编语言使用分号来开始注释。
不要指望一系列 asm 语句在编译后还能完全保持编写时的连续性,即使您使用 volatile 修饰符也是如此。如果某些指令需要在输出中保持连续,请将它们放在单个多指令 asm 语句中。
如果不使用输入/输出操作数(例如直接使用来自汇编器模板的全局符号)从 C 程序访问数据,可能无法按预期工作。同样,直接从汇编器模板调用函数需要详细了解目标汇编器和 ABI(译注:即,Application Binary Interface(应用程序二进制接口规范))。
由于 GCC 不解析汇编器模板,因此它所引用的任何符号都是不可见的。这可能会导致 GCC 将这些符号视为未引用而丢弃,除非它们也被列为输入、输出或 goto 操作数(译注:这些符号需显式在输入输出或goto操作数中列出)。
除了输入、输出和 goto 操作数描述的标记之外,这些特殊的标记在汇编器模板中还有特殊含义:
asm模板中的多汇编方言:
在 x86 等目标上,GCC 支持多种汇编语言。 -masm 选项控制 GCC 使用哪种方言作为内联汇编器的默认方言。 -masm 选项的特定于目标的文档包含受支持的方言列表,以及默认方言(如果未指定该选项)。 理解此信息可能很重要,因为使用一种方言编译咎可以正常工作的汇编程序代码,如果使用另一种方言编译则可能会失败。请参阅 x86 选项。
如果您的代码需要支持多种汇编语言(例如,如果您正在编写需要支持各种编译选项的公共标头),请使用以下形式的构造:
{ dialect0 | dialect1 | dialect2... }
当使用方言 #0 编译代码时,此构造输出 dialect0,使用方言 #1 编译代码时输出 dialect1,等等。如果大括号内的替代项少于编译器支持的方言数量,则该构造不输出任何内容。例如,如果 x86 编译器支持两种方言(‘att’、‘intel’),则汇编器模板如下所示:
"bt{l %[Offset],%[Base] | %[Base],%[Offset]}; jc %l2"
相当于下列语句之一:
"btl %[Offset],%[Base] ; jc %l2" /* att dialect */
"bt %[Base],%[Offset]; jc %l2" /* intel dialect */
使用相同的编译器,此代码
"xchg{l}\t{%%}ebx, %1"
对应以下任一句:
"xchgl\t%%ebx, %1" /* att dialect */
"xchg\tebx, %1" /* intel dialect */
不支持嵌套方言替代方案。
现在我已经了解了 GCC 内联汇编的基础知识,让我们继续讨论一个更有趣的主题—— 它在 Linux 内核中的用法。 本文的其余部分与体系结构相关,并针对 ARMv7-A 进行讨论。 ARM 和汇编语言的基础知识将有助于理解此处介绍的其余材料。
在多任务计算机中,共享资源访问必须一次仅限于一个修饰符。该共享资源可以是共享内存位置或外围设备。互斥是并发控制的一个属性,可以保护此类共享资源。在单处理器系统中,禁用中断可能是在临界区内部实现互斥的一种方法(尽管用户模式无法禁用中断),但这种解决方案在SMP系统(译注:即,对称多处理系统)中失败,因为在一个处理器上禁用中断不会阻止其他处理器进入临界区。使用原子操作和锁进行强制互斥。
互斥强制执行原子性操作。首先,我们考虑原子性的定义。如果任何操作整体完全成功并且其结果对系统中的所有 CPU 即时可见,或者整体根本不成功,则该操作是原子性的。 原子性是所有互斥方法的基础。
所有现代计算机体系结构(包括 ARM)都提供用于按原子性修改内存位置的硬件机制。
ARMv6 架构引入了对内存位置进行独占访问的概念,以原子方式更新内存。ARM 架构提供了支持独占访问的指令。
LDREX(独占式加载)将指定内存位置的值加载到寄存器中,并将该内存位置标记为保留。
STREX(独占式存储)将更新后的值从寄存器回写到指定的内存位置,前提是自上次加载以来没有其他处理器修改过物理地址。它向寄存器返回 0 表示成功,否则返回 1,以表示存储操作是否成功。通过检查此返回值,您可以确认是否有任何其他处理器在其间更新了同一内存位置。
这些指令需要硬件支持才能将物理地址标记为该特定处理器的“独占”。
注意:arm称:
如果上下文切换调度例程在进程执行 Load-Exclusive 之后但在执行 Store-Exclusive 之前调度该进程,则当进程恢复时,Store-Exclusive 将返回错误的负值结果,并且内存不会更新。这不会影响程序功能,因为进程可以立即重试该操作。
独占访问的概念还与本地和全局监视器、存储器类型、存储器访问排序规则和屏障指令的概念相关。请参阅本文的参考资料部分以获取更多信息。
实现计数器通常需要原子整数运算。 由于使用复杂的加锁方案保护计数器显得很沉重,所以atomic_inc()和atomic_dec()是更好的选择。Linux内核中的所有原子函数都是使用LDREX和STREX实现的(译注:即,由硬件提供的单条处理器指实实现,而不是长久地锁住地址总结进行耗时的操作)。
看一下 include/linux/types.h 中定义的atomic_t,如下所示:
typedef struct { int counter; } atomic_t;
简化宏定义后,kernel-4.6.2 (arch/arm/include/asm/atomic.h) 中的atomic_add() 函数定义如下所示:
static inline void atomic_add(int i, atomic_t *v)
{
unsigned long tmp;
int result; prefetchw(&v->counter);
}
我们进一步察看上述代码。下面的函数使用 PLD(预加载数据)、PLDW(预加载数据用于写入)指令,这些指令是典型的内存系统提示,它们将数据放入缓存中以实现更快的访问:
prefetchw(&v->counter);
ldrex 将“counter”值加载到“result”并将该内存位置标记为保留:
ldrex %0, [%3] 。
下面的语句将将i加到“result”并将和的结果存储到“result”:
add %0, %0, %4 。
这里可能有两种情况:
strex %1, %0, [%3]
在第一种情况下,strex 成功将“result”的值存储到内存位置,并在“tmp”处返回 0。 仅当没有其他处理器修改当前处理器上次加载和存储之间的位置时,才会发生这种情况。 但是,如果任何其他处理器在其间修改了相同的物理内存,则当前处理器的存储将失败。 在这种情况下,它在“tmp”处返回 1。
该指令测试等效性,如果“tmp”为 0,则设置 CPSR 的 Z(零)标志;如果“tmp”为 1,则清除它:
teq %1, #0
对于成功的存储场景,需要设置 Z 标志。 所以,分支条件不满足。 但是,如果存储失败,则会发生分支并从 ldrex 指令重新开始执行。循环继续直到存储成功:
bne 1b
所有其他原子操作都是类似的,并使用 LDREX 和 STREX。
如果内存操作序列是独立的,则编译器或 CPU 以随机方式执行它以实现优化,例如
a = 1; b = 5 。
但是,为了与其他 CPU 或硬件设备同步,有时需要按照程序代码中指定的顺序发出内存读取(加载)和内存写入(存储)。为了按这个指定顺序执行,你需要使用屏障。屏障通常包含在内核锁定、调度原语和设备驱动程序实现中。
编译器屏障不允许编译器对指令的任何内存访问进行重新排序。 如前所述,barrier() 宏在 Linux 中用作编译器屏障:
#define barrier() __asm__ __volatile__("": : :"memory") 。
处理器优化(例如高速缓存、写入缓冲区和无序执行)可能会导致内存操作以与程序顺序不同的顺序发生。处理器屏障也是隐含的编译器屏障。ARM 有 3 个硬件屏障指令:
(1) 数据内存屏障 (Data Memory Barrier) 确保在屏障之后的任何显式内存访问发生之前,屏障之前的所有内存访问(按程序顺序)在系统中可见。它不会影响指令预取或下一次非内存数据访问的执行。
(2) 数据同步屏障(Data Synchronization Barrier)确保所有挂起的显式数据访问在屏障之后执行任何其他指令之前完成(译注:即挂起的代码在屏障之后第一时间执行)。它不影响指令的预取。
(3) 指令同步屏障(Instruction Synchronization Barrier,简记为ISB) 会刷新管道和预取缓冲区,以便一旦 ISB 完成,处理器就可以从高速缓存或内存中获取下一条指令。
1个内存屏障的实现:
#define dmb(option) __asm__ __volatile__ ("dmb " #option : : : "memory"
#define dsb(option) __asm__ __volatile__ ("dsb " #option : : : "memory")
#define isb(option) __asm__ __volatile__ ("isb " #option : : :
"memory")
SY 是默认值。它适用于整个系统,包括所有处理器和外设。 其他选项请参阅 ARM 手册。 Linux 提供了各种映射到 ARM 硬件屏障指令的内存屏障宏:读内存屏障rmb(); 写内存屏障wmb(); 和完整的内存屏障mb()。 还有相应的 SMP 版本:smp_rmb()、smp_wmb() 和 smp_mb()。当内核在没有 CONFIG_SMP 的情况下编译时,smp_* 只是 Barrier() 宏。
为了原子性地执行任何临界区代码(critical section code),你必须确定不会有两个线程同时执行临界区代码。正如Robert Love在<>一书中所说,“术语执行线程意味着任何执行代码实体。”例如,包括内核任务、中断句柄、中断下半部、或者内核线程。
对于单处理器系统,自旋锁的实现归结为禁用抢占或本地中断。spin_lock() 禁用抢占。 spin_lock_irq() 和 spin_lock_irqsave() 禁用本地中断。但是,这对于SMP(译注:对称多处理机)来说还不够,因为其他处理器可以自由地同时执行临界区代码。
自旋锁实现:
static inline void arch_spin_lock(arch_spinlock_t *lock)
{
unsigned long tmp;
u32 newval;
arch_spinlock_t lockval;
prefetchw(&lock->slock);
__asm__ __volatile__( "1: ldrex %0, [%3]\n" " add %1, %0, %4\n" " strex %2, %1, [%3]\n" " teq %2, #0\n" " bne 1b" : "=&r" (lockval), "=&r" (newval), "=&r" (tmp) : "r" (&lock->slock), "I" (1 << TICKET_SHIFT) : "cc");
while (lockval.tickets.next != lockval.tickets.owner)
{
wfe();
lockval.tickets.owner = ACCESS_ONCE(lock->tickets.owner);
}
smp_mb();
}
static inline void arch_spin_unlock(arch_spinlock_t *lock)
{
smp_mb();
lock->tickets.owner++;
dsb_sev();
}
#define wfe() __asm__ __volatile__ ("wfe" : : : "memory")
#define sev() __asm__ __volatile__ ("sev" : : : "memory")
Linux使用票证锁(ticket lock)算法的改进版本来实现自旋锁。与原子指令一样,自旋锁的实现使用了 LDREX/STREX指令。
这里需要对wfe(等待事件)和sev(发送事件)ARM 指令进行一些介绍。wfe 将 ARM 处理器置于低功耗状态,直到发生唤醒事件。 wfe 的唤醒事件包括在 SMP 系统上的任何处理器上执行 sev 指令、中断、异步中止或调试事件。 在争夺自旋锁时,处理器进入低功耗状态而不是忙于等待,从而节省功耗。 ACCESS_ONCE 宏阻止编译器进行优化,强制编译器每次通过循环获取 lock->tickets.owner 值。在获得锁之后和释放锁之前需要内存屏障 smp_mb() ,以便其他处理器可以根据当前处理器上发生的情况及时更新。
注意:获取和释放锁应该是原子的。否则,多个执行线程可能会并行获取同一锁,从而导致竞争条件。
与自旋锁不同,信号量和互斥体可以休眠。 当一个任务持有信号量并且另一个任务尝试获取它时,信号量会将竞争的任务放入等待队列并将其置于睡眠状态。当信号量可用时,调度程序唤醒等待队列上的任务之一以获取信号量。正如您在清单 5 中看到的,信号量实现使用 raw_spin_lock_irqsave() 和 raw_spin_unlock_irqrestore()来获取锁。如果另一个任务持有信号量,则当前任务释放自旋锁并进入睡眠状态(因为在持有自旋锁时无法选择睡眠),并且在唤醒后,它重新获取自旋锁。 up() 用于释放也使用自旋锁的信号量。与互斥锁不同,up() 可以从任何上下文调用,甚至可以由从未调用过 down() 的任务调用。
信号量的实现:
int down_interruptable(struct semaphore *sem)
{
unsigned long flags;
int result = 0;
raw_spin_lock_irqsave(&sem->lock, flags);
if (likely(sem->count > 0))
sem->count--;
else
result = __down_interruptable(sem); raw_spin_unlock_irqrestore(&sem->lock, flags);
return result;
}
对互斥体的调用可能采用两种不同的路径。 首先,它调用__mutex_fastpath_lock() 来获取互斥锁。 如果无法获取锁,则返回到 __mutex_lock_slowpath()。在后一种情况下,任务被添加到等待队列中并休眠,直到被解锁路径唤醒。
互斥体的实现:
void __sched mutex_lock(struct mutex *lock)
{
might_sleep(); /* * The locking fastpath is the 1->0 transition from * 'unlocked' into 'locked' state. */ __mutex_fastpath_lock(&lock->count, __mutex_lock_slowpath); mutex_set_owner(lock);
}
_mutex_fastpath_lock 是对atomic_sub_return_relaxed() 的调用,这是一个原子操作——以原子方式从 v 中减去 i 并返回结果。 类似地,mutex_unlock() 使用atomic_add_return_relaxed 以原子方式递增计数器。
内容来源: GCC Inline Assembly and Its Usage in the Linux Kernel https://gcc.gnu.org/onlinedocs/gcc/Extended-Asm.html Output Variables in Inline Assembler - GNAT User's Guide https://www.cs.utexas.edu/~dahlin/Classes/UGOS/reading/inline.html