當前位置: 妍妍網 > 碼農

探索 Rust 中的行內組譯

2024-07-19碼農

行內組譯是 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_u812];
    // 字串以 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