内联汇编是 Rust 提供的一种机制,允许开发者在编译器生成的汇编代码中嵌入手工编写的汇编指令。虽然一般情况下不需要使用内联汇编,但在某些情况下,例如需要实现特定的性能要求或特定时序,或者访问底层硬件原语(例如内核代码)时,内联汇编就显得尤为必要。
内联汇编的基本用法
内联汇编通过
asm!
宏实现。所有
asm!
调用必须位于
unsafe
代码块中,因为它们可能会插入任意指令,破坏各种不变式。
以下是一个简单的示例,在汇编代码中插入一条 NOP(无操作)指令:
#![allow(unused)]
fnmain() {
use std::arch::asm;
unsafe {
asm!("nop");
}
}
输入和输出操作
内联汇编不仅可以插入指令,还可以操作数据。以下示例将值
5
写入
u64
类型的变量
x
中:
#![allow(unused)]
fnmain() {
use std::arch::asm;
let x: u64;
unsafe {
asm!("mov {}, 5", out(reg) x);
}
assert_eq!(x, 5);
}
在
asm!
宏中,第一个参数是一个模板字符串,用于指定指令。模板字符串遵循 Rust 的格式字符串语法。变量的输入和输出通过
in
和
out
指定,并使用
reg
指定变量在通用寄存器中。编译器会选择合适的寄存器插入模板,并在内联汇编执行完成后从寄存器中读取变量。
以下示例使用了输入和输出:
#![allow(unused)]
fnmain() {
use std::arch::asm;
let i: u64 = 3;
let o: u64;
unsafe {
asm!(
"mov {0}, {1}",
"add {0}, 5",
out(reg) o,
in(reg) i,
);
}
assert_eq!(o, 8);
}
该示例将
i
的值加
5
,并将结果写入
o
。
示例还展示了以下几个方面:
asm!
宏可以接受多个模板字符串参数,每个参数都被视为单独的汇编代码行,并在它们之间插入换行符。
可以使用
inout
指定既是输入又是输出的变量,保证输入和输出使用同一个寄存器。
可以使用数字或名称指定参数,方便代码可读性,并允许在不改变参数顺序的情况下重新排列指令。
以下示例使用
inout
操作符对一个变量进行操作:
#![allow(unused)]
fnmain() {
use std::arch::asm;
letmut x: u64 = 3;
unsafe {
asm!("add {0}, 5", inout(reg) x);
}
assert_eq!(x, 8);
}
延迟输出操作符
Rust 编译器在分配操作符时非常保守。它假设
out
可以在任何时间写入,因此不能与其他参数共享位置。为了保证最佳性能,尽可能减少寄存器使用至关重要,因为它们不需要在内联汇编块周围保存和重新加载。为了实现这一点,Rust 提供了
lateout
指定符。它可以用于任何仅在所有输入都被使用后才被写入的输出。还存在
inlateout
变体。
以下示例展示了
inlateout
不能使用的情况:
#![allow(unused)]
fnmain() {
use std::arch::asm;
letmut a: u64 = 4;
let b: u64 = 4;
let c: u64 = 4;
unsafe {
asm!(
"add {0}, {1}",
"add {0}, {2}",
inout(reg) a,
in(reg) b,
in(reg) c,
);
}
assert_eq!(a, 12);
}
编译器可以为输入
b
和
c
分配同一个寄存器,因为它知道它们的值相同。但是,它必须为
a
分配一个单独的寄存器,因为它使用的是
inout
而不是
inlateout
。如果使用
inlateout
,则
a
和
c
可以分配到同一个寄存器,在这种情况下,第一个覆盖
c
值的指令会导致汇编代码产生错误的结果。
以下示例可以
inlateout
,因为输出只在所有输入寄存器都被读取后才被修改:
#![allow(unused)]
fnmain() {
use std::arch::asm;
letmut a: u64 = 4;
let b: u64 = 4;
unsafe {
asm!("add {0}, {1}", inlateout(reg) a, in(reg) b);
}
assert_eq!(a, 8);
}
显式寄存器操作符
某些指令要求操作数位于特定寄存器中。因此,Rust 内联汇编提供了一些更具体的约束指定符。虽然
reg
通常在任何架构上都可用,但显式寄存器高度依赖于特定架构。例如,对于 x86,通用寄存器
eax
、
ebx
、
ecx
、
edx
、
ebp
、
esi
和
edi
可以通过它们的名称访问。
#![allow(unused)]
fnmain() {
use std::arch::asm;
let cmd = 0xd1;
unsafe {
asm!("out 0x64, eax", in("eax") cmd);
}
}
示例中,使用
out
指令将
cmd
变量的内容输出到端口
0x64
。由于
out
指令只接受
eax
(及其子寄存器)作为操作数,因此必须使用
eax
约束指定符。
需要注意的是,与其他操作数类型不同,显式寄存器操作数不能在模板字符串中使用:不能使用
{}
,而应该直接写寄存器名称。此外,它们必须出现在操作数列表的末尾,位于所有其他操作数类型之后。
以下示例使用 x86 的
mul
指令:
#![allow(unused)]
fnmain() {
use std::arch::asm;
fnmul(a: u64, b: u64) -> u128 {
let lo: u64;
let hi: u64;
unsafe {
asm!(
// x86 的 mul 指令将 rax 作为隐式输入,并将乘法的 128 位结果写入 rax:rdx。
"mul {}",
in(reg) a,
inlateout("rax") b => lo,
lateout("rdx") hi
);
}
((hi asu128) << 64) + lo asu128
}
}
该示例使用
mul
指令将两个 64 位输入相乘,得到一个 128 位结果。唯一的显式操作数是一个寄存器,从变量
a
中填充。第二个操作数是隐式的,必须是
rax
寄存器,从变量
b
中填充。结果的低 64 位存储在
rax
中,从该寄存器中填充变量
lo
。高 64 位存储在
rdx
中,从该寄存器中填充变量
hi
。
覆盖寄存器
在很多情况下,内联汇编会修改不需要作为输出的状态。通常这是因为必须在汇编中使用一个临时寄存器,或者因为指令修改了不需要进一步检查的状态。这种状态通常被称为「覆盖」。需要告诉编译器这一点,因为编译器可能需要在内联汇编块周围保存和恢复这种状态。
use core::arch::asm;
fnmain() {
// 三个四字节的条目
letmut name_buf = [0_u8; 12];
// 字符串以 ASCII 形式存储在 ebx、edx、ecx 中
// 由于 ebx 是保留的,我们获取一个临时寄存器,并将 ebx 中的值移动到该寄存器中。
// 但是,asm 需要保留该寄存器的值,因此它在主 asm 周围被压入和弹出
// (在 64 位处理器上的 64 位模式下,32 位处理器将使用 ebx)
unsafe {
asm!(
"push rbx",
"cpuid",
"mov [{0}], ebx",
"mov [{0} + 4], edx",
"mov [{0} + 8], ecx",
"pop rbx",
// 我们使用指向数组的指针来存储值,以简化 Rust 代码,代价是多执行几条 asm 指令
// 然而,这比显式寄存器输出(如 `out("ecx") val`)更明确地说明了 asm 的工作方式
// *指针本身* 只是输入,即使它是在后面写入的
in(reg) name_buf.as_mut_ptr(),
// 选择 cpuid 0,还指定 eax 作为覆盖
inout("eax") 0 => _,
// cpuid 也覆盖这些寄存器
out("ecx") _,
out("edx") _,
);
}
let name = core::str::from_utf8(&name_buf).unwrap();
println!("CPU Manufacturer ID: {}", name);
}
在示例中,使用
cpuid
指令读取 CPU 制造商 ID。该指令将
eax
写入最大支持的
cpuid
参数,并将
ebx
、
esx
和
ecx
写入 CPU 制造商 ID,以 ASCII 字节形式按顺序存储。
即使
eax
从未被读取,也需要告诉编译器该寄存器已被修改,以便编译器可以保存这些寄存器在 asm 之前存储的任何值。这可以通过将其声明为输出,但使用
_
而不是变量名来完成,这表示输出值将被丢弃。
该代码还绕过了 LLVM 对
ebx
的限制,因为它是一个保留寄存器。这意味着 LLVM 假设它对该寄存器拥有完全控制权,并且必须在退出 asm 块之前将其恢复到其原始状态,因此它不能用作输出。为了解决这个问题,我们使用
push
保存寄存器,在 asm 块中从
ebx
读取到一个使用
out(reg)
分配的临时寄存器,然后使用
pop
将
ebx
恢复到其原始状态。
push
和
pop
使用寄存器的完整 64 位
rbx
版本,以确保整个寄存器都被保存。在 32 位目标上,代码将使用
ebx
来代替
push
/
pop
。
这也可以用于通用寄存器类(例如
reg
),以获取用于 asm 代码的临时寄存器:
#![allow(unused)]
fnmain() {
use std::arch::asm;
// 使用移位和加法将 x 乘以 6
letmut x: u64 = 4;
unsafe {
asm!(
"mov {tmp}, {x}",
"shl {tmp}, 1",
"shl {x}, 2",
"add {x}, {tmp}",
x = inout(reg) x,
tmp = out(reg) _,
);
}
assert_eq!(x, 4 * 6);
}
符号操作数和 ABI 覆盖
默认情况下,
asm!
假设任何未指定为输出的寄存器的内容都会被汇编代码保留。
asm!
宏的
clobber_abi
参数告诉编译器根据给定的调用约定 ABI 自动插入必要的覆盖操作数:在该 ABI 中未完全保留的任何寄存器都将被视为覆盖。可以提供多个
clobber_abi
参数,并且将插入所有指定 ABI 的所有覆盖。
#![allow(unused)]
fnmain() {
use std::arch::asm;
extern"C"fnfoo(arg: i32) -> i32 {
println!("arg = {}", arg);
arg * 2
}
fncall_foo(arg: i32) -> i32 {
unsafe {
let result;
asm!(
"call *{}",
// 要调用的函数指针
in(reg) foo,
// 第一个参数在 rdi 中
in("rdi") arg,
// 返回值在 rax 中
out("rax") result,
// 将「C」调用约定中未保留的所有寄存器标记为覆盖。
clobber_abi("C"),
);
result
}
}
}
寄存器模板修饰符
在某些情况下,需要对寄存器名称在插入模板字符串时的格式方式进行精细控制。当架构的汇编语言对同一个寄存器有几个名称时,这将是必要的,每个名称通常是该寄存器的「视图」,通常是一个寄存器子集(例如 64 位寄存器的低 32 位)。
默认情况下,编译器将始终选择引用完整寄存器大小的名称(例如,x86-64 上的
rax
,x86 上的
eax
等)。
可以使用模板字符串操作数上的修饰符来覆盖此默认值,就像对格式字符串一样:
#![allow(unused)]
fnmain() {
use std::arch::asm;
letmut x: u16 = 0xab;
unsafe {
asm!("mov {0:h}, {0:l}", inout(reg_abcd) x);
}
assert_eq!(x, 0xabab);
}
在该示例中,使用
reg_abcd
寄存器类将寄存器分配器限制为 4 个传统 x86 寄存器(
ax
、
bx
、
cx
、
dx
),其中前两个字节可以独立寻址。
假设寄存器分配器选择将
x
分配到
ax
寄存器。
h
修饰符将发出该寄存器高字节的寄存器名称,
l
修饰符将发出该寄存器低字节的寄存器名称。因此,asm 代码将扩展为
mov ah, al
,它将值的低字节复制到高字节。
如果使用较小的数据类型(例如
u16
)作为操作数,并且忘记使用模板修饰符,编译器将发出警告并建议使用正确的修饰符。
内存地址操作数
有时汇编指令需要通过内存地址/内存位置传递操作数。必须手动使用目标架构指定的内存地址语法。例如,在 x86/x86_64 上使用 Intel 汇编语法,应该将输入/输出包装在
[]
中,以指示它们是内存操作数:
#![allow(unused)]
fnmain() {
use std::arch::asm;
fnload_fpu_control_word(control: u16) {
unsafe {
asm!("fldcw [{}]", in(reg) &control, options(nostack));
}
}
}
标签
任何对命名标签的重复使用,无论局部还是全局,都可能导致汇编器或链接器错误,或者可能导致其他奇怪的行为。对命名标签的重复使用可以通过多种方式发生,包括:
显式:在一个
asm!
块中多次使用标签,或者在多个块中多次使用标签。
通过内联隐式:编译器允许实例化
asm!
块的多个副本,例如当包含它的函数在多个地方内联时。
通过 LTO 隐式:LTO 可能导致来自_其他板条箱_的代码被放置在同一个代码生成单元中,因此可能引入任意标签。
因此,在内联汇编代码中,只应该使用 GNU 汇编器的 数字 局部标签。在汇编代码中定义符号可能会导致汇编器和/或链接器错误,因为符号定义重复。
此外,在 x86 上使用默认的 Intel 语法时,由于LLVM 的一个 bug,不应该使用仅由
0
和
1
数字组成的标签,例如
0
、
11
或
101010
,因为它们最终可能会被解释为二进制值。使用
options(att_syntax)
将避免任何歧义,但这会影响_整个_
asm!
块的语法。(有关
options
的更多信息,请参阅下面的选项)。
#![allow(unused)]
fnmain() {
use std::arch::asm;
letmut a = 0;
unsafe {
asm!(
"mov {0}, 10",
"2:",
"sub {0}, 1",
"cmp {0}, 3",
"jle 2f",
"jmp 2b",
"2:",
"add {0}, 2",
out(reg) a
);
}
assert_eq!(a, 5);
}
该示例将
{0}
寄存器值从 10 减到 3,然后加 2 并将其存储在
a
中。
该示例展示了以下几个方面:
首先,同一个数字可以在同一个内联块中多次用作标签。
其次,当数字标签用作引用(例如,作为指令操作数)时,应该在数字标签后添加后缀「b」(「向后」)或「f」(「向前」)。然后它将引用该数字在该方向上定义的最近标签。
选项
默认情况下,内联汇编块与具有自定义调用约定的外部 FFI 函数调用相同:它可以读取/写入内存,具有可观察的副作用等。但是,在很多情况下,希望向编译器提供更多关于汇编代码实际执行情况的信息,以便它可以更好地进行优化。
以之前的
add
指令示例为例:
#![allow(unused)]
fnmain() {
use std::arch::asm;
letmut a: u64 = 4;
let b: u64 = 4;
unsafe {
asm!(
"add {0}, {1}",
inlateout(reg) a, in(reg) b,
options(pure, nomem, nostack),
);
}
assert_eq!(a, 8);
}
选项可以作为
asm!
宏的可选最终参数提供。在示例中指定了三个选项:
pure
表示 asm 代码没有可观察的副作用,并且其输出仅取决于其输入。这允许编译器优化程序减少对内联 asm 的调用次数,甚至完全消除它。
nomem
表示 asm 代码不读取或写入内存。默认情况下,编译器将假设内联汇编可以读取或写入它可以访问的任何内存地址(例如,通过作为操作数传递的指针,或全局变量)。
nostack
表示 asm 代码不会将任何数据压入堆栈。这允许编译器使用优化,例如 x86-64 上的堆栈红区,以避免堆栈指针调整。
这些选项允许编译器更好地优化使用
asm!
的代码,例如,通过消除输出不需要的纯
asm!
块。
文章精选
点 击 关 注 并 扫 码 添 加 进 交 流 群
领
取
「Rust
语
言
」
学
习
资
料