インラインアセンブリ
Rust は asm! マクロによってインラインアセンブリをサポートしています。 これは、コンパイラが生成するアセンブリ出力に手書きのアセンブリを埋め込むために使用できます。 通常これは必要ないはずですが、必要な性能やタイミングを他の方法では達成できない場合には必要になることがあります。たとえばカーネルコードで低レベルのハードウェアプリミティブにアクセスする場合にも、この機能が要求されることがあります。
注: ここでの例は x86/x86-64 アセンブリで示していますが、他のアーキテクチャもサポートされています。
インラインアセンブリは現在、以下のアーキテクチャでサポートされています。
- x86 および x86-64
- ARM
- AArch64
- RISC-V
基本的な使い方
可能な限り最も単純な例から始めましょう。
#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; unsafe { asm!("nop"); } } }
これは、コンパイラが生成するアセンブリに NOP(no operation)命令を挿入します。 すべての asm! 呼び出しは unsafe ブロック内に置く必要があることに注意してください。これは、任意の命令を挿入してさまざまな不変条件を破る可能性があるためです。挿入される命令は、asm! マクロの第 1 引数に文字列リテラルとして列挙されます。
入力と出力
何もしない命令を挿入するだけではかなり退屈です。実際にデータに作用することをしてみましょう。
#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; let x: u64; unsafe { asm!("mov {}, 5", out(reg) x); } assert_eq!(x, 5); } }
これは値 5 を u64 変数 x に書き込みます。 命令を指定するために使用している文字列リテラルが、実際にはテンプレート文字列であることがわかります。 これは Rust のフォーマット文字列と同じ規則に従います。 ただし、テンプレートに挿入される引数は、見慣れているものとは少し異なります。まず、変数がインラインアセンブリの入力なのか出力なのかを指定する必要があります。この場合は出力です。これは out と書くことで宣言しました。 また、アセンブリがその変数をどの種類のレジスタで想定しているかも指定する必要があります。 この場合は reg を指定することで、任意の汎用レジスタに入れています。 コンパイラはテンプレートに挿入する適切なレジスタを選択し、インラインアセンブリの実行が終了した後にそこから変数を読み取ります。
入力も使用する別の例を見てみましょう。
#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { 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 に書き込みます。 このアセンブリがこれを行う具体的な方法は、まず値を i から出力へコピーし、その後それに 5 を加算するというものです。
この例はいくつかのことを示しています。
第一に、asm! では複数のテンプレート文字列引数を使用できることがわかります。それぞれは、互いの間に改行を挟んで結合されたかのように、別々のアセンブリコード行として扱われます。これにより、アセンブリコードを整形しやすくなります。
第二に、入力は out ではなく in と書くことで宣言されることがわかります。
第三に、任意のフォーマット文字列と同様に、引数番号や名前を指定できることがわかります。 インラインアセンブリテンプレートでは、引数が複数回使用されることが多いため、これは特に有用です。 より複雑なインラインアセンブリでは、一般にこの機能を使用することが推奨されます。可読性が向上し、引数の順序を変更せずに命令を並べ替えられるためです。
上記の例をさらに洗練して、mov 命令を避けることができます。
#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; let mut x: u64 = 3; unsafe { asm!("add {0}, 5", inout(reg) x); } assert_eq!(x, 8); } }
inout は、入力でもあり出力でもある引数を指定するために使用されることがわかります。 これは、入力と出力を別々に指定する場合とは異なり、両方が同じレジスタに割り当てられることが保証されます。
inout オペランドの入力部分と出力部分に異なる変数を指定することも可能です。
#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; let x: u64 = 3; let y: u64; unsafe { asm!("add {0}, 5", inout(reg) x => y); } assert_eq!(y, 8); } }
遅延出力オペランド
Rust コンパイラは、オペランドの割り当てに関して保守的です。out はいつでも書き込まれる可能性があると仮定されるため、他のどの引数とも場所を共有できません。 しかし、最適な性能を保証するには、使用するレジスタをできるだけ少なくすることが重要です。そうすれば、インラインアセンブリブロックの前後でそれらを保存して再読み込みする必要がなくなります。 これを実現するために、Rust は lateout 指定子を提供しています。これは、すべての入力が消費された後にのみ書き込まれる任意の出力に使用できます。この指定子には inlateout バリアントもあります。
以下は、release モードまたはその他の最適化された場合に inlateout を使用_できない_例です。
#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; let mut 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); } }
最適化されていない場合(例: Debug モード)では、上記の例で inout(reg) a を inlateout(reg) a に置き換えても、期待される結果が得られ続けることがあります。しかし、release モードまたはその他の最適化された場合には、inlateout(reg) a を使用すると、代わりに最終値が a = 16 となり、アサーションが失敗する可能性があります。
これは、最適化された場合には、コンパイラが入力 b と c に同じレジスタを自由に割り当てられるためです。コンパイラは、それらが同じ値を持つことを知っているからです。さらに、inlateout が使用されると、a と c が同じレジスタに割り当てられる可能性があり、その場合、最初の add 命令が変数 c からの初期ロードを上書きしてしまいます。これは、inout(reg) a を使用すると a 用に別個のレジスタが割り当てられることが保証されるのとは対照的です。
しかし、以下の例では、すべての入力レジスタが読み取られた後にのみ出力が変更されるため、inlateout を使用できます。
#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; let mut a: u64 = 4; let b: u64 = 4; unsafe { asm!("add {0}, {1}", inlateout(reg) a, in(reg) b); } assert_eq!(a, 8); } }
ご覧のとおり、このアセンブリ断片は a と b が同じレジスタに割り当てられていても正しく動作します。
明示的なレジスタオペランド
一部の命令では、オペランドが特定のレジスタにあることが必要です。 そのため、Rust のインラインアセンブリは、より具体的な制約指定子をいくつか提供しています。 reg は一般にどのアーキテクチャでも利用できますが、明示的なレジスタはアーキテクチャに強く依存します。たとえば x86 では、汎用レジスタ eax、ebx、ecx、edx、ebp、esi、edi などを名前で指定できます。
#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { 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)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; fn mul(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 as u128) << 64) + lo as u128 } } }
これは mul 命令を使用して、2 つの 64 ビット入力を乗算し、128 ビットの結果を得ます。 唯一の明示的なオペランドはレジスターであり、これは変数 a から埋めます。 2 番目のオペランドは暗黙的であり、rax レジスターでなければならず、これは変数 b から埋めます。 結果の下位 64 ビットは rax に格納され、そこから変数 lo を埋めます。 上位 64 ビットは rdx に格納され、そこから変数 hi を埋めます。
クロバーされるレジスター
多くの場合、インラインアセンブリは出力としては不要な状態を変更します。 通常、これはアセンブリ内でスクラッチレジスターを使用する必要があるため、または命令がそれ以上調べる必要のない状態を変更するためです。 この状態は一般に「クロバーされる」と呼ばれます。 コンパイラーがインラインアセンブリブロックの前後でこの状態を保存および復元する必要がある場合があるため、このことをコンパイラーに伝える必要があります。
use std::arch::asm; #[cfg(target_arch = "x86_64")] fn main() { // それぞれ 4 バイトのエントリーが 3 つ let mut name_buf = [0_u8; 12]; // 文字列は ebx、edx、ecx の順に ascii として格納されます // ebx は予約されているため、asm はその値を保持する必要があります。 // そのため、メインの asm の前後でそれを push および pop します。 // 64 ビットプロセッサー上の 64 ビットモードでは、 // 32 ビットレジスター(ebx など)の push/pop は許可されないため、代わりに拡張 rbx レジスターを使用する必要があります。 unsafe { asm!( "push rbx", "cpuid", "mov [rdi], ebx", "mov [rdi + 4], edx", "mov [rdi + 8], ecx", "pop rbx", // 値を格納するために配列へのポインターを使用し、 // Rust コードを簡潔にします。その代償として asm 命令がいくつか増えます // ただし、これは `out("ecx") val` のような明示的なレジスター出力とは対照的に、 // asm がどのように動作するかをより明示的に示します // *ポインター自体* は、その背後に書き込まれる場合でも入力にすぎません in("rdi") 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); } #[cfg(not(target_arch = "x86_64"))] fn main() {}
上の例では、cpuid 命令を使用して CPU メーカー ID を読み取っています。 この命令は、サポートされている最大の cpuid 引数を eax に書き込み、CPU メーカー ID を ASCII バイトとして ebx、edx、ecx にその順序で書き込みます。
eax は読み取られることがありませんが、それでもコンパイラーが asm の前にこれらのレジスターに入っていた値を保存できるように、そのレジスターが変更されたことをコンパイラーに伝える必要があります。これは、変数名の代わりに _ を指定して出力として宣言することで行います。これは、出力値が破棄されることを示します。
このコードは、ebx が LLVM による予約済みレジスターであるという制限にも対処しています。つまり、LLVM はそのレジスターを完全に制御していると想定しており、asm ブロックを抜ける前に元の状態へ復元されなければならないため、コンパイラーが一般レジスタークラス(例: in(reg))を満たすために使用する場合を除いて、入力または出力として使用できません。これにより、予約済みレジスターを使用する場合の reg オペランドは危険になります。同じレジスターを共有しているため、入力や出力を知らないうちに破損させる可能性があるからです。
これに対処するため、rdi を使用して出力配列へのポインターを格納し、push によって ebx を保存し、asm ブロック内で ebx から配列へ読み取り、その後 pop によって ebx を元の状態へ復元します。push と pop は、レジスター全体が保存されることを保証するために、完全な 64 ビット版である rbx レジスターを使用します。32 ビットターゲットでは、コードは代わりに push/pop で ebx を使用します。
これは、asm コード内で使用するスクラッチレジスターを取得するために、一般レジスタークラスとともに使用することもできます。
#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; // シフトと加算を使用して x に 6 を掛けます let mut 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)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; extern "C" fn foo(arg: i32) -> i32 { println!("arg = {}", arg); arg * 2 } fn call_foo(arg: i32) -> i32 { unsafe { let result; asm!( "call {}", // 呼び出す関数ポインター in(reg) foo, // rdi の第 1 引数 in("rdi") arg, // rax の戻り値 out("rax") result, // "C" 呼び出し規約によって保持されないすべてのレジスターを // クロバーされるものとしてマークします。 clobber_abi("C"), ); result } } } }
レジスターテンプレート修飾子
場合によっては、テンプレート文字列に挿入されるときにレジスター名がどのようにフォーマットされるかを細かく制御する必要があります。これは、あるアーキテクチャのアセンブリ言語に同じレジスターに対する複数の名前があり、それぞれが通常はそのレジスターのサブセットに対する「ビュー」である場合に必要です(例: 64 ビットレジスターの下位 32 ビット)。
デフォルトでは、コンパイラーは常にレジスター全体のサイズを指す名前を選択します(例: x86-64 では rax、x86 では eax など)。
このデフォルトは、フォーマット文字列の場合と同様に、テンプレート文字列オペランドに修飾子を使用することで上書きできます。
```rust
# #[cfg(target_arch = "x86_64")] {
use std::arch::asm;
let mut x: u16 = 0xab;
unsafe {
asm!("mov {0:h}, {0:l}", inout(reg_abcd) x);
}
assert_eq!(x, 0xabab);
# }
この例では、reg_abcd レジスタクラスを使用して、レジスタ割り当て器が、最初の 2 バイトを独立してアドレス指定できる 4 つのレガシー x86 レジスタ(ax、bx、cx、dx)に制限されるようにしています。
レジスタ割り当て器が x を ax レジスタに割り当てることを選択したと仮定しましょう。 h 修飾子はそのレジスタの上位バイトのレジスタ名を出力し、l 修飾子は下位バイトのレジスタ名を出力します。したがって、asm コードは mov ah, al として展開され、値の下位バイトを上位バイトにコピーします。
オペランドでより小さいデータ型(例: u16)を使用し、テンプレート修飾子の使用を忘れた場合、コンパイラは警告を出力し、使用すべき正しい修飾子を提案します。
メモリアドレスオペランド
アセンブリ命令では、メモリアドレス/メモリ位置を介して渡されるオペランドが必要になることがあります。 ターゲットアーキテクチャで指定されたメモリアドレス構文を手動で使用する必要があります。 たとえば、x86/x86_64 で Intel アセンブリ構文を使用する場合、入力/出力を [] で囲んで、それらがメモリオペランドであることを示すべきです。
#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; fn load_fpu_control_word(control: u16) { unsafe { asm!("fldcw [{}]", in(reg) &control, options(nostack)); } } } }
ラベル
名前付きラベルの再利用は、それがローカルであるかどうかにかかわらず、アセンブラまたはリンカエラーを引き起こしたり、その他の奇妙な動作を引き起こしたりする可能性があります。名前付きラベルの再利用は、次のようなさまざまな方法で発生する可能性があります。
- 明示的に: 1 つの
asm!ブロック内でラベルを複数回使用する、または複数のブロックにわたって複数回使用する。 - インライン化を介して暗黙的に: コンパイラは、たとえばそれを含む関数が複数の場所でインライン化される場合に、
asm!ブロックの複数のコピーをインスタンス化することが許可されています。 - LTO を介して暗黙的に: LTO によって、他のクレート のコードが同じコード生成ユニットに配置されることがあり、その結果、任意のラベルが持ち込まれる可能性があります。
そのため、インラインアセンブリコード内では GNU アセンブラの数値 ローカルラベル のみを使用すべきです。アセンブリコードでシンボルを定義すると、シンボル定義の重複により、アセンブラおよび/またはリンカエラーにつながる可能性があります。
さらに、x86 でデフォルトの Intel 構文を使用している場合、LLVM のバグ により、0 や 1 の数字だけで構成されるラベル(例: 0、11、101010)は、バイナリ値として解釈されてしまう可能性があるため、使用すべきではありません。options(att_syntax) を使用すると曖昧さは避けられますが、それは asm! ブロック_全体_の構文に影響します。(options の詳細については、下記の オプション を参照してください。)
#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; let mut 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”(“backward”)または ”f”(“forward”)を数値ラベルに追加すべきであること。そうすると、その方向にある、この数値で定義された最も近いラベルを参照します。
オプション
デフォルトでは、インラインアセンブリブロックは、カスタム呼び出し規約を持つ外部 FFI 関数呼び出しと同じように扱われます。つまり、メモリを読み書きする可能性があり、観測可能な副作用を持つ可能性がある、というように扱われます。しかし、多くの場合、アセンブリコードが実際に何をしているのかについて、より多くの情報をコンパイラに与え、よりよく最適化できるようにすることが望ましいです。
前の例の add 命令を取り上げましょう。
#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; let mut 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! マクロの省略可能な最後の引数として指定できます。ここでは 3 つのオプションを指定しています。
pureは、asm コードに観測可能な副作用がなく、その出力が入力のみに依存することを意味します。これにより、コンパイラのオプティマイザはインライン asm の呼び出し回数を減らしたり、完全に削除したりすることさえ可能になります。nomemは、asm コードがメモリを読み書きしないことを意味します。デフォルトでは、コンパイラはインラインアセンブリが、それにアクセス可能な任意のメモリアドレス(例: オペランドとして渡されたポインタ経由、またはグローバル)を読み書きできると仮定します。nostackは、asm コードがスタックにデータをプッシュしないことを意味します。これにより、コンパイラは x86-64 のスタックレッドゾーンなどの最適化を使用して、スタックポインタの調整を避けることができます。
これらにより、コンパイラは asm! を使用するコードをよりよく最適化できます。たとえば、出力が不要な純粋な asm! ブロックを削除できます。
利用可能なオプションの完全な一覧とその効果については、リファレンスを参照してください。