【連載】兩百行Rust程式碼解析綠色執行緒原理(二)一個能跑通的例子
- 2020 年 2 月 12 日
- 筆記
原文: 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 `targetdebuggreen_thread_start.exe` I LOVE WAKING UP ON A NEW STACK!
好的,究竟發生了什麼?我們在任何時候都沒有調用函數 hello,但它仍然運行了。發生的事情是我們實際上讓 CPU 跳轉到我們自己的棧並在那裡執行程式碼。我們邁出了實現上下文切換的第一步。
在接下來的章節中,我們會在實現綠色執行緒之前先探討一點棧相關的內容,這個過程會更加容易,因為目前我們已經涵蓋了很多基礎知識。
如果要運行它,可以在這裡查看完整程式碼。