原文: Green threads explained in 200 lines of rust language
地址: https://cfsamson.gitbook.io/green-threads-explained-in-200-lines-of-rust/
作者: Carl Fredrik Samson(cfsamson@Github)
翻译: 耿腾
在这个例子中,我们将创建自己的栈,并使 CPU 从它当前执行的上下文切换到到我们创建的栈。我们将在后面的章节中基于这些概念进行构建(不过我们不会在这些代码的基础上构建)。
译者注:阅读本章时建议读者自己也动手写代码运行一下,很多问题就容易理解了。
创建我们的项目
首先,让我们在名为 green_threads 的文件夹中启动一个新项目。命令行执行:
cargo init
我们需要使用 nightly 版本的 Rust,因为我们将使用一些尚未稳定的功能:
rustup override set nightly
在我们的 main.rs 文件中,我们首先启用一个功能,它允许我们使用 asm! 宏:
#![feature(asm)]
我们在这里设置一个较小的栈尺寸,只有 48 个字节,这样我们可以在切换上下文之前打印并查看这个栈:
const SSIZE: isize = 48;
在 OSX 中使用这么小的栈的好像有些问题。此代码运行的最小值是 624 字节的栈大小。如果你想要遵循这个确切的例子,代码可以在 Rust Playground 上运行(但是由于最终代码中的循环,你需要等待大概 30 秒的超时时间)。
然后,我们添加一个表示 CPU 状态的结构。我们现在只关注存储 “栈指针” 的寄存器,所以只需要添加下面的代码:
#[derive(Debug, Default)]
#[repr(C)]
struct ThreadContext {
rsp: u64,
}
在后面的示例中,我们将使用之前链接中的规范文档中标记为 “callee saved”(由被调用者保存的) 的所有寄存器。这些就是 x86-64 ABI 描述的寄存器中那些用来保存上下文的寄存器,但是现在我们只需要一个寄存器来使 CPU 跳转到我们的栈。
需要注意的是,这个结构定义需要加上 #[repr(C)],因为我们需要按照汇编代码的方式去访问数据。Rust 没有稳定的 ABI,因此我们无法确保它会在内存中以 rsp 作为前 8 个字节来表示。C 具有稳定的 ABI,这一属性正是在告诉编译器使用兼容 C-ABI 的内存布局。当然,我们的结构现在只有一个字段,但我们稍后会添加更多字段。
fn hello() -> ! {
println!("I LOVE WAKING UP ON A NEW STACK!");
loop {}
}
对于这个非常简单的例子,我们将定义一个函数,它只打印一条消息,然后永远循环:
接下来是我们的内联汇编,我们切换到自己的栈。
unsafe fn gt_switch(new: *const ThreadContext) {
asm!("
mov 0x00($0), %rsp
ret
"
:
: "r"(new)
:
: "alignstack" // 目前没有这句也可以工作,不过后面会用到
);
}
我们在这里使用了一个技巧。我们写入要在新栈上运行的函数的地址。然后我们将存储此地址的第一个字节的地址传递给 rsp 寄存器(我们设置给 new.rsp 的地址值将指向 位于我们自己的栈上的地址,该地址将导致上述函数被调用)。我讲清楚了吗?
ret 关键字将程序控制转移到位于栈顶部的返回地址。由于我们将地址推送到 %rsp 寄存器,因此CPU会认为它是当前运行的函数的返回地址,因此当我们传递 ret 指令时,它会直接返回到我们自己的栈中。
CPU 做的第一件事就是读取函数的地址并运行它。
Rust 内联汇编宏的快速入门
如果您之前没有使用内联汇编,可能会看起来很陌生,但我们稍后会使用扩展版本来切换上下文,所以我将逐行解释我们正在做什么:
unsafe 是一个关键字,表示 Rust 无法在我们编写的函数中强制执行安全保证。由于我们直接操作 CPU,这绝对是不安全的。
gt_switch(new: *const ThreadContext)
在这里我们获取一个指向 ThreadContext 实例的指针,我们只读取一个字段。
asm!("
这是 Rust 标准库中的 asm! 宏。它将检查我们的语法,在遇到看起来不像 AT&T(默认情况下)汇编语法的情况时会产生一个错误消息。
这个宏里第一个输入是汇编模板:
mov 0x00($0), %rsp
这是一个简单的指令,它将存储在基地址为 $0 偏移量为 0x00 处的值(这意味着在十六进制中完全没有偏移)移动到 rsp 寄存器。由于 rsp 寄存器存储指向栈上下一个值的指针,因此我们有效地将我们提供的地址压到当前的栈上,覆盖了当前已有的值。
在普通的汇编代码中,你不会看到这样使用的 $0。这是汇编模板的一部分,是第一个参数的占位符。参数编号为 0,1,2 …… 从输出参数开始,然后继续输入参数。我们这里只有一个输入参数,对应于 $0。
如果在普通汇编中遇到 $,它很可能意味着一个立即值(一个整数常量),但这取决于(是的,$可以表示方言之间以及 x86 汇编和 x86-64 汇编之间的不同之处)。
ret
ret 关键字指示 CPU 从栈顶部弹出一个内存位置,然后无条件跳转到该位置。实际上我们已经劫持了我们的 CPU 并使其返回到我们的栈。
:
内联 ASM 与普通 ASM 略有不同。我们在汇编模板后传递了四个附加参数。这是第一个被称为 output(输出) 的,它是我们传递输出参数的地方,这些参数是我们想要在Rust函数中用作返回值的参数。
: "r"(new)
第二个是我们的输入参数。在编写内联汇编时,"r" 被称为一个 constraint(约束)。您可以使用这些约束来有效地指导编译器决定放置输入的位置(例如,在一个寄存器中作为值或将其用作“内存”位置)。 "r" 仅表示将其放入编译器选择的通用寄存器中。内联汇编中的约束本身是一个很大的课题,幸运的是我们的需求很简单。
:
下一个选项是 clobber 列表,您可以在其中指定编译器不应触及的寄存器,并让它知道我们要在汇编代码中管理这些寄存器。如果我们弹出栈的任何值,我们需要在这里指定哪些寄存器并让编译器知道,因此它知道它不能自由地使用这些寄存器。我们不需要它,因为我们返回了一个全新的栈。
: "alignstack"
最后一个是我们的 options(选项)。这些对于 Rust 来说是独一无二的,我们可以设置的选项由三种:alignstack,volatile 和 intel。我会向你介绍文档以了解它们,在这里有具体解释。值得注意的是,我们需要为代码指定 “对齐栈(alignstack)” 才能在 Windows 上运行。
运行我们的例子
fn main(){
let mut ctx = ThreadContext::default();
let mut stack = vec![0_u8; SSIZE as usize];
let stack_ptr = stack.as_mut_ptr();
unsafe {
std::ptr::write(stack_ptr.offset(SSIZE - 16) as * mut u64, hello as u64);
ctx.rsp = stack_ptr.offset(SSIZE - 16) as u64;
gt_switch(&mut ctx);
}
}
所以这实际上是在设计我们的新栈。 hello 已经是一个指针了(一个函数指针),所以我们可以直接把它转换为一个 u64,因为 64 位系统上的所有指针都是 64 位,然后我们将这个指针写入我们的新栈。
我们将在下一章中详细讨论栈,但现在我们需要知道的一件事是栈向下增长。如果我们的 48 字节栈在索引 0处开始,并在索引 47 处结束,则索引 32 将是从栈末尾开始的 16 字节偏移量的第一个索引。
请注意,我们将指针写入距离栈底部16字节的偏移量(还记得我写的关于16字节对齐的内容吗?)。
我们把它作为指向 u64 的指针而不是指向 u8 的指针。我们想要写入位置 32、33、34、35、36、37、38、39,这是我们存储 u64 所需的 8 字节空间。如果我们不进行这个类型转换,我们实际上是在尝试将 u64 写入位置 32(译者注:即将一个 u64 写入到一个 u8 中,显然存不下),这不是我们想要的。
译者注:stack_ptr 的类型为 * mut u8,stack_ptr.offset(SSIZE - 16) 也是 * mut u8。
我们将 rsp(栈指针)设置为 栈中索引为 32 的内存地址,我们传递的不是存储在该位置的 u64 值而是首字节的地址。
译者注:如果传递的是存储在该位置的值,代码就应该是 (stack_ptr.offset(SSIZE - 16) as * mut u64).read(),即 hello 函数指针的值;如果是首字节地址,就是上面那段代码中的 stack_ptr.offset(SSIZE - 16) as u64(这个地址以 u64 类型存储,因为我们要把它赋值给 u64 类型的 ctx.rsp)。
当我们执行 cargo run 命令时,我们将看到如下输出:
Finished dev [unoptimized + debuginfo] target(s) in 0.58s
Running `target\debug\green_thread_start.exe`
I LOVE WAKING UP ON A NEW STACK!
好的,究竟发生了什么?我们在任何时候都没有调用函数 hello,但它仍然运行了。发生的事情是我们实际上让 CPU 跳转到我们自己的栈并在那里执行代码。我们迈出了实现上下文切换的第一步。
在接下来的章节中,我们会在实现绿色线程之前先探讨一点栈相关的内容,这个过程会更加容易,因为目前我们已经涵盖了很多基础知识。
如果要运行它,可以在这里查看完整代码。