十一、进阶 | Linux 内核中的概念(3)
内联汇编
引言
在阅读Linux内核的源代码时,我经常看到这样的语句:
__asm__("andq %%rsp,%0; ":"=r" (ti) : "0" (CURRENT_MASK));
是的,这是内联汇编,换句话说,是集成在高级编程语言中的汇编代码。在这种情况下,高级编程语言是C。是的,C
编程语言并不是非常高级,但仍然。
如果你熟悉汇编编程语言,你可能会发现内联汇编
与普通汇编并没有太大不同。而且,被称为基本形式
的特殊形式的内联汇编与普通汇编完全相同。例如:
__asm__("movq %rax, %rsp");
或者:
__asm__("hlt");
没有__asm__
前缀的相同代码,你可能在纯汇编代码中看到。是的,这非常相似,但并不像第一眼看上去那么简单。实际上,GCC支持两种形式的内联汇编语句:
基本
;扩展
。
基本形式只包含两件事:__asm__
关键字和包含有效汇编指令的字符串。例如,它可能看起来像这样:
__asm__("movq $3, %rax\t\n"
"movq %rsi, %rdi");
asm
关键字可以代替__asm__
,但是__asm__
是可移植的,而asm
关键字是GNU
扩展。在接下来的示例中,我将只使用__asm__
变体。
如果你知道汇编编程语言,这看起来非常熟悉。主要问题在于内联汇编语句的第二种形式 - 扩展
。这种形式允许我们向汇编语句传递参数,执行跳转等。听起来不难,但需要了解除了汇编语言知识之外的特殊规则。每当我在Linux内核中看到另一段内联汇编代码时,我都需要参考官方的文档,以记住特定的修饰符
如何表现,或者例如=&r
的含义是什么。
我决定写这部分内容,以巩固与内联汇编相关的知识,因为内联汇编语句在Linux内核中非常常见,我们有时在linux-insides部分中也可以看到它们。我认为,如果我们有一个包含内联汇编更重要方面的信息的特殊部分,那将是有用的。当然,你可以在官方文档中找到关于内联汇编的全面信息,但我喜欢把所有内容放在一个地方。
注意:这部分不会提供汇编编程的指南。它不打算教你用汇编器编写程序,或者知道一个或另一个汇编指令的含义。只是一个扩展汇编的小备忘。
扩展内联汇编简介
那么,让我们开始吧。正如我上面提到的,基本
汇编语句由asm
或__asm__
关键字和一组汇编指令组成。这种形式与“普通”汇编没有任何不同。最有趣的部分是带操作数的内联汇编器,或扩展
汇编。扩展汇编语句看起来更复杂,由多个部分组成:
__asm__ [volatile] [goto] (汇编模板
[: 输出操作数]
[: 输入操作数]
[: 破坏列表]
[: 跳转标签]);
所有用方括号标记的参数都是可选的。你可能注意到,如果我们跳过可选参数和修饰符volatile
和goto
,我们得到基本
形式。
让我们按顺序考虑这个问题。第一个可选修饰符
是volatile
。这个说明符告诉编译器,汇编语句可能会产生副作用
。在这种情况下,我们需要防止与给定汇编语句相关的编译器优化。简单来说,volatile
说明符指示编译器不要修改语句,并将其精确放置在原始代码中的位置。例如,让我们看看Linux内核中的以下函数:
static inline void native_load_gdt(const struct desc_ptr *dtr)
{
asm volatile("lgdt %0"::"m" (*dtr));
}
在这里,我们看到native_load_gdt
函数,它使用lgdt
指令将基地址从全局描述符表加载到GDTR
寄存器。这个汇编语句用volatile
修饰符标记。非常重要的是,编译器不会更改此汇编语句在结果代码中的原始位置。否则,GDTR
寄存器可能包含全局描述符表
的错误地址,或者地址可能正确,但结构尚未填充。这可能导致异常生成,阻止内核正确启动。
第二个可选修饰符
是goto
。这个修饰符告诉编译器,给定的汇编语句可能会跳转到GotoLabels
中列出的标签之一。例如:
__asm__ goto("jmp %l[label]" : : : : label);
由于我们已经完成了这两个修饰符,让我们看看汇编语句主体的主要部分。正如我们上面看到的,汇编语句的主要部分由以下四个部分组成:
- 一组汇编指令;
- 输出参数;
- 输入参数;
- 破坏列表。
首先代表包含一组有效汇编指令的字符串,这些指令可以通过\t\n
序列分隔。处理器寄存器的名称必须在扩展
形式中用%%
序列前缀,其他符号如立即数必须以$
符号开始。OutputOperands
和InputOperands
是以逗号分隔的C变量列表,可以提供“约束”,Clobbers
是汇编指令从AssemblerTemplate
修改的寄存器或其他值的列表,超出了OutputOperands
中列出的。在我们深入示例之前,我们需要了解一些关于约束
的知识。约束是一个字符串,指定操作数的放置。例如,操作数的值可以写入处理器寄存器,或从内存中读取等。
考虑以下简单示例:
#include <stdio.h>
int main(void)
{
unsigned long a = 5;
unsigned long b = 10;
unsigned long sum = 0;
__asm__("addq %1,%2" : "=r" (sum) : "r" (a), "0" (b));
printf("a + b = %lu\n", sum);
return 0;
}
让我们编译并运行它,以确保它按预期工作:
$ gcc test.c -o test
./test
a + b = 15
好的,太好了。它有效。现在让我们详细看看这个示例。在这里,我们看到一个简单的C
程序,它计算两个变量的和,将结果放入sum
变量,最后我们打印结果。这个示例由三部分组成。首先是带有add指令的汇编语句。它将源操作数的值与目标操作数的值相加,并将结果存储在目标操作数中。在我们的例子中:
addq %1, %2
将展开为:
addq a, b
在OutputOperands
和InputOperands
中列出的变量和表达式可以在AssemblerTemplate
中匹配。输入/输出操作数被指定为%N
,其中N
是从左到右的操作数编号,从零
开始。我们的汇编语句的第二部分位于第一个:
符号之后,包含输出值的定义:
"=r" (sum)
注意sum
用两个特殊符号标记:=r
。这是我们遇到的第一种约束。实际的约束在这里只是r
本身。=
符号是修饰符
,表示输出值。这告诉编译器之前的值将被丢弃,并被新数据替换。除了=
修饰符,GCC
还支持以下三个修饰符:
+
- 操作数由指令读取和写入;&
- 输出寄存器不应与输入寄存器重叠,并且只用于输出;%
- 告诉编译器操作数可能是交换的。
现在让我们回到r
修饰符。如我上面提到的,修饰符表示操作数的放置。r
符号意味着值将存储在其中一个通用寄存器中。我们汇编语句的最后一部分:
"r" (a), "0" (b)
这些是输入操作数 - 变量a
和b
。我们已经知道r
修饰符的作用。现在我们可以看看变量b
的约束。0
或任何从1
到9
的数字被称为“匹配约束”。用这个,单个操作数可以用于多个角色。约束的值是源操作数索引。在我们的例子中0
将匹配sum
。如果我们看看我们程序的汇编输出:
0000000000400400 <main>:
...
...
...
4004fe: 48 c7 45 f8 05 00 00 movq $0x5,-0x8(%rbp)
400506: 48 c7 45 f0 0a 00 00 movq $0xa,-0x10(%rbp)
400516: 48 8b 55 f8 mov -0x8(%rbp),%rdx
40051a: 48 8b 45 f0 mov -0x10(%rbp),%rax
40051e: 48 01 d0 add %rdx,%rax
首先,我们的值5
和10
将被放置在栈上,然后这些值将被移动到两个通用寄存器:%rdx
和%rax
。
这样,%rax
寄存器用于存储b
的值以及存储计算结果。注意我使用了gcc 6.3.1
版本,所以你编译器的结果可能有所不同。
我们已经看了内联汇编语句的输入和输出参数。在我们继续讨论gcc
支持的其他约束之前,还有内联汇编语句的一个部分我们还没有讨论 - clobbers
。
破坏列表
如上所述,“破坏”部分应包含一个逗号分隔的列表,列出了将被汇编代码修改的寄存器的内容。这在我们汇编表达式需要额外寄存器进行计算时很有用。如果我们在内联汇编语句中添加破坏寄存器,编译器会考虑这一点,并且相关寄存器不会同时被编译器使用。
考虑之前的例子,但我们将添加一个额外的简单汇编指令:
__asm__("movq $100, %%rdx\t\n"
"addq %1,%2" : "=r" (sum) : "r" (a), "0" (b));
如果我们查看汇编输出:
0000000000400400 <main>:
...
...
...
4004fe: 48 c7 45 f8 05 00 00 movq $0x5,-0x8(%rbp)
400506: 48 c7 45 f0 0a 00 00 movq $0xa,-0x10(%rbp)
400516: 48 8b 55 f8 mov -0x8(%rbp),%rdx
40051a: 48 8b 45 f0 mov -0x10(%rbp),%rax
40051e: 48 c7 c2 64 00 00 00 mov $0x64,%rdx
400525: 48 01 d0 add %rdx,%rax
我们将看到%rdx
寄存器被0x64
或100
覆盖,结果将是110
而不是10
。现在如果我们将%rdx
寄存器添加到clobbered
寄存器列表中:
__asm__("movq $100, %%rdx\t\n"
"addq %1,%2" : "=r" (sum) : "r" (a), "0" (b) : "%rdx");
再次查看汇编器输出:
0000000000400400 <main>:
4004fe: 48 c7 45 f8 05 00 00 movq $0x5,-0x8(%rbp)
400506: 48 c7 45 f0 0a 00 00 movq $0xa,-0x10(%rbp)
400516: 48 8b 4d f8 mov -0x8(%rbp),%rcx
40051a: 48 8b 45 f0 mov -0x10(%rbp),%rax
40051e: 48 c7 c2 64 00 00 00 mov $0x64,%rdx
400525: 48 01 c8 add %rcx,%rax
%rcx
寄存器将用于sum
计算,保留程序的预期语义。除了通用寄存器,我们还可以传递两个特殊说明符。它们是:
cc
;memory
。
第一个 - cc
表示汇编代码修改标志寄存器。这通常用于汇编中包含算术或逻辑指令的情况:
__asm__("incq %0" ::""(variable): "cc");
第二个memory
说明符告诉编译器,给定的内联汇编语句执行了在输出列表的操作数中未指定的内存的读写操作。这可以防止编译器将内存值加载并缓存在寄存器中。让我们看看以下示例:
#include <stdio.h>
int main(void)
{
unsigned long a[3] = {10000000000, 0, 1};
unsigned long b = 5;
__asm__ volatile("incq %0" :: "m" (a[0]));
printf("a[0] - b = %lu\n", a[0] - b);
return 0;
}
这个例子可能是人为的,但它说明了主要思想。在这里,我们有一个整数数组和一个整数变量。示例非常简单,我们取a
的第一个元素并增加它的值。之后我们从a
的第一个元素中减去b
的值。最后我们打印结果。如果我们编译并运行这个简单的例子,结果可能会让你惊讶:
~$ gcc -O3 test.c -o test
~$ ./test
a[0] - b = 9999999995
结果是a[0] - b = 9999999995
,但为什么呢?我们增加了a[0]
并减去了b
,所以结果应该是a[0] - b = 9999999996
。
如果我们看看这个例子的汇编输出:
00000000004004f6 <main>:
4004b4: 48 b8 00 e4 0b 54 02 movabs $0x2540be400,%rax
4004be: 48 89 04 24 mov %rax,(%rsp)
...
...
...
40050e: ff 44 24 f0 incq (%rsp)
4004d8: 48 be fb e3 0b 54 02 movabs $0x2540be3fb,%rsi
我们将看到a
的第一个元素包含值0x2540be400
(10000000000
)。代码的最后两行是实际的计算。
我们看到我们的增加指令incq
,但随后只是将0x2540be3fb
(9999999995
)移动到%rsi
寄存器。这看起来很奇怪。
问题是我们将-O3
标志传递给了gcc
,所以编译器做了一些常量折叠和传播,以确定a[0] - 5
的结果在编译时,并且将其减少到一个常量movabs
与常量0x2540be3fb
或9999999995
在运行时。
现在让我们将memory
添加到破坏列表中:
__asm__ volatile("incq %0" :: "m" (a[0]) : "memory");
运行这个的新结果:
~$ gcc -O3 test.c -o test
~$ ./test
a[0] - b = 9999999996
现在结果是正确的。如果我们再次查看汇编输出:
000000
00000000004004f6 <main>:
400404: 48 b8 00 e4 0b 54 02 movabs $0x2540be400,%rax
40040b: 00 00 00
40040e: 48 89 04 24 mov %rax,(%rsp)
400412: 48 c7 44 24 08 00 00 movq $0x0,0x8(%rsp)
400419: 00 00
40041b: 48 c7 44 24 10 01 00 movq $0x1,0x10(%rsp)
400422: 00 00
400424: 48 ff 04 24 incq (%rsp)
400428: 48 8b 04 24 mov (%rsp),%rax
400431: 48 8d 70 fb lea -0x5(%rax),%rsi
在这里我们看到一个区别,那就是在最后两行:
400428: 48 8b 04 24 mov (%rsp),%rax
400431: 48 8d 70 fb lea -0x5(%rax),%rsi
而不是常量折叠,GCC
现在在汇编中保留计算,并将a[0]
的值放在%rax
寄存器中。最后,它只是从%rax
寄存器中减去常量值b
,并将结果放入%rsi
寄存器。
除了memory
说明符,我们还在这里看到了一个新的约束 - m
。这个约束告诉编译器使用a[0]
的地址,而不是它的值。所以,现在我们已经完成了clobbers
部分,我们可以继续看看GCC
支持的其他约束。
约束
现在我们已经完成了内联汇编语句的所有三个部分,让我们回到约束。我们在前面的部分中已经看到了一些约束,比如代表寄存器
操作数的r
,代表内存操作数的m
,以及代表重用、索引操作数的0-9
。除了这些,GCC
还支持其他约束。例如,i
约束代表已知值的立即数
整数操作数:
#include <stdio.h>
int main(void)
{
int a = 0;
__asm__("movl %1, %0" : "=r"(a) : "i"(100));
printf("a = %d\n", a);
return 0;
}
结果是:
~$ gcc test.c -o test
~$ ./test
a = 100
或者例如I
,它代表立即的32位整数。i
和I
之间的区别在于i
是通用的,而I
严格指定为32位整型数据。例如,如果你尝试编译以下代码:
unsigned long test_asm(int nr)
{
unsigned long a = 0;
__asm__("movq %1, %0" : "=r"(a) : "I"(0xffffffffffff));
return a;
}
你将得到一个错误:
$ gcc -O3 test.c -o test
test.c: In function ‘test_asm’:
test.c:7:9: warning: asm operand 1 probably doesn’t match constraints
__asm__("movq %1, %0" : "=r"(a) : "I"(0xffffffffffff));
^
test.c:7:9: error: impossible constraint in ‘asm’
而同时:
unsigned long test_asm(int nr)
{
unsigned long a = 0;
__asm__("movq %1, %0" : "=r"(a) : "i"(0xffffffffffff));
return a;
}
可以完美运行:
~$ gcc -O3 test.c -o test
~$ echo $?
0
GCC
还支持J
、K
、N
约束,分别代表0-63位整数常量、有符号8位整数常量和无符号8位整数常量。o
约束代表具有可偏移
的内存地址的内存操作数。例如:
#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;
}
结果,如预期:
~$ gcc -O3 test.c -o test
~$ ./test
2
所有这些约束可以组合(只要它们不冲突)。在这种情况下,编译器将为特定情况选择最佳选项。例如:
unsigned long a = 10;
unsigned long b = 20;
void main(void)
{
__asm__ ("movq %1,%0" : "=mr"(b) : "rm"(a));
}
将使用内存操作数:
main:
movq a(%rip),b(%rip)
ret
b:
.quad 20
a:
.quad 10
而不是直接使用通用寄存器。
这就是内联汇编语句中常用的约束。你可以在官方文档中找到更多。
架构特定约束
在我们结束之前,让我们看看一组特殊的约束。这些约束是架构特定的,由于这本书专门针对x86_64架构,我们将看看与之相关的约束。首先,a
... d
以及S
和D
约束集代表通用目的寄存器。在这种情况下,a
约束对应于%al
、%ax
、%eax
或%rax
寄存器,具体取决于指令大小。S
和D
约束分别是%si
和%di
寄存器。例如,让我们看看我们之前的示例。我们可以在其汇编输出中看到a
变量的值存储在%eax
寄存器中。现在让我们看看具有其他约束的相同汇编的汇编输出:
#include <stdio.h>
int a = 1;
int main(void)
{
int b;
__asm__ ("movq %1,%0" : "=r"(b) : "d"(a));
return b;
}
现在我们可以看到a
变量的值将存储在%rax
寄存器中:
0000000000400400 <main>:
4004aa: 48 8b 05 6f 0b 20 00 mov 0x200b6f(%rip),%rax # 601020 <a>
f
和t
约束代表任何浮点栈寄存器 - %st
和浮点栈的顶部。u
约束代表浮点栈顶部的第二个值。
就这些了。你可以在官方文档中找到更多关于x86_64和通用约束的细节。
链接
"《Linux嵌入式必考必会》专栏,专为嵌入式开发者量身打造,聚焦Linux环境下嵌入式系统面试高频考点。涵盖基础架构、内核原理、驱动开发、系统优化等核心技能,实战案例与理论解析并重。助你快速掌握面试关键,提升竞争力。