2024-02-19 | 研究与探索 | UNLOCK

在 Rust 中使用内联汇编

本文的知乎链接:https://zhuanlan.zhihu.com/p/615148682

自 Rust 1.59 以降,在 Rust 代码中内联汇编代码的语言特性已然 stable 。参考知乎上一篇文章 ,我用 Rust 的内联汇编实现了有栈协程(仓库地址)。在此过程中学到了一些知识。

本文假设读者对 x86 汇编有基础了解。

局部内联汇编与自动分配寄存器

Rust 的内联汇编一开始是对标 GCC 的内联汇编设计的,长得像这样(来源):

1
2
3
4
5
6
7
8
9
10
asm!("mov $4, %eax
cpuid
mov %eax, $0
mov %ebx, $1
mov %ecx, $2
mov %edx, $3"
: "=r"(a), "=r"(b), "=r"(c), "=r"(d)
: "m"(info)
: "eax", "ebx", "ecx", "edx"
)

后来才变成如今富有 Rust 特色的样子(playground):

1
2
3
4
5
6
7
asm!(
"mov edi, ebx",
"cpuid",
"xchg edi, ebx",
in("eax") info,
lateout("eax") a, out("edi") b, out("ecx") c, out("edx") d,
)

与 GCC 内联汇编语法一样,Rust 希望即使需要手写汇编,程序员也能将一部分工作交给编译器来高效完成,这部分工作就是寄存器分配,毕竟只有编译器了解内联汇编前后的上下文,知道该怎么分配寄存器最合适。

asm 宏的 inoutinoutlateoutinlateout 参数就是为了让编译器帮助分配寄存器的。

  • in 表示将变量的值传给寄存器,编译器生成的汇编代码会使得在内联汇编代码中读取相应的寄存器,就得到了传入的变量的值;
  • out 表示将寄存器的值写到变量中,在内联汇编代码中写入相应寄存器,编译器在内联汇编之后生成的汇编代码会使得相应变量具有写入相应寄存器的值;
  • late 则是代表编译器可以采取进一步的策略来优化寄存器分配:默认的分配策略给每个参数分配不同的寄存器,使用 lateoutinlateout 的参数则允许编译器复用某个 in 参数的寄存器,只要内联汇编代码中先读完所有的 in 寄存器,再输出 lateoutinlateout 寄存器即可。

具体细节以及此处没讲到的 option 可参考 Rust By ExampleRust Reference

全局内联汇编与名称修饰(Name Mangling)

除了需要写在函数体中的 asm 宏,还有需要写在函数之外的 global_asm 宏,其作用与独立的汇编代码相差不大,一切全由程序员掌控,没有上节所述寄存器自动分配之功能,还需要手动管理参数传递,栈对齐等等。

global_asm 我们可以写出源代码完全是汇编代码的函数,函数名就是汇编代码中的标签,函数参数和返回值需要按照 ABI 约定来处理(playground):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use std::arch::global_asm;

extern "C" {
fn my_asm_add(a: i32, b: i32) -> i32;
}

global_asm!{
"my_asm_add:",
"mov eax, edi",
"add eax, esi",
"ret",
}

fn main() {
let a = 114;
let b = 514;
let x = unsafe { my_asm_add(a, b) };
dbg!(x);
}

这段代码在 x86_64-unknown-linux-gnu 的目标,也就是 Rust Playground 的运行环境下会通过编译并输出正确结果 628,但在 64 位 Windows 下则会得到错误的结果,因为 64 位 Windows 所用的 ABI 和 64 位 Linux 不一样,虽然都是通过寄存器传递参数,但 64 位 Windows 的 ABI 的第一二参数是用 RCX 和 RDX 传递,而非示例中的 RDI 和 RSI。

而在 MacOS 上编译 ,结果是编译不过——虽然和 64 位 Linux 一样使用 System V AMD64 ABI,但 MacOS 进行 C 语言函数名名称修饰时会在函数名前加一个下划线,所以编译器会试图寻找 _my_asm_add 符号,结果找不到。在汇编代码中把 my_asm_add: 改成 _my_asm_add: 即可编译通过。

由此可见汇编语言的不可移植性:即使是同一架构,甚至同一 ABI 约定的汇编代码也相当不可移植。

在代码编写过程中,我发现一个技巧可以规避掉名称修饰的影响。asmglobal_asm 宏可以接受格式为 sym SYMBOL 的参数来引用符号,其中 SYMBOL 是函数或者静态变量,这种参数的目的是在汇编语言中直接引用 Rust 函数或静态变量的符号,尽管 Rust 的名称修饰算法尚未 stable,但代码中可以不写出来而由编译器来计算。这个功能也可以用在 extern 符号上,因此可以这样写:

1
2
3
4
5
6
7
8
global_asm!{
".extern {0}",
"{0}:",
"mov eax, edi",
"add eax, esi",
"ret",
sym my_asm_add,
}

这样在编译 x86_64-unknown-linux-gnu 目标时生成的汇编代码中的标签是 my_asm_add ,而对于 x86_64-apple-darwin 目标,生成的标签则是 _my_asm_add

这样的技巧不够方便,更直观的写法是 naked function,这种函数从外部看来就是一个 unsafe 函数,而内部只允许有一个 asm 宏调用,编译器不生成一般函数中会有的各种上下文代码,函数本体完全由该 asm 宏调用生成。

程序重定位与位置无关代码

为了加载动态链接库或者避免被黑客利用固定程序地址攻击,操作系统加载程序时会将其载入到随机的内存地址,这个过程就是程序重定位。

对于 32 位 x86 程序,需要在加载时修改程序中所有的绝对地址,包括函数的和数据的。在汇编语言中可以直接将标号作为常量使用,但最好不要写 mov eax, LABEL 这样的语句,因为这样的语句加载器不会识别和修改。应该写 lea eax, LABEL

x86_64 支持相对 RIP 寻址,Rust 编译器默认将代码编译为使用这项特性的位置无关可执行程序(PIE),因此在 Rust 的内联汇编中取符号地址需要写成 lea rax, [rip+SYMBOL]

示例,x86_64 平台下给 static 变量 X 加 1 的函数用汇编语言实现(playground):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use std::arch::global_asm;

static mut X: usize = 0;

extern "C" {
fn incr_x();
}

global_asm!{
"{0}:",
"add dword ptr [rip+{X}], 1",
"ret",
sym incr_x,
X = sym X,
}

fn main() {
unsafe {
incr_x();
dbg!(X);
incr_x();
dbg!(X);
}
}

32 位 x86 代码下则需要把汇编代码改成:

1
2
3
4
5
6
7
8
global_asm!{
"{0}:",
"lea eax, {X}",
"add dword ptr[eax], 1",
"ret",
sym incr_x,
X = sym X,
}

参考链接