はじめに
この実験では、asm! マクロを使って Rust でインラインアセンブリの使い方を調べます。インラインアセンブリの基本的な使い方、入出力、遅延出力オペランド、明示的なレジスタオペランド、クロバーされるレジスタ、シンボルオペランドと ABI クロバー、レジスタテンプレート修飾子、メモリアドレスオペランド、ラベル、およびアセンブリコードを最適化するためのオプションについて説明します。
注: 実験でファイル名が指定されていない場合、好きなファイル名を使うことができます。たとえば、
main.rsを使って、rustc main.rs &&./mainでコンパイルして実行することができます。
インラインアセンブリ
Rust は asm! マクロを通じてインラインアセンブリのサポートを提供します。これは、コンパイラによって生成されるアセンブリ出力に手書きのアセンブリを埋め込むために使用できます。一般的にはこれが必要ない場合もありますが、必要なパフォーマンスやタイミングを得ることができない場合には役立ちます。たとえば、カーネルコードで低レベルのハードウェアプリミティブにアクセスする場合も、この機能が必要になることがあります。
注: ここでの例は x86/x86-64 アセンブリで示されていますが、他のアーキテクチャもサポートされています。
インラインアセンブリは現在、次のアーキテクチャでサポートされています。
- x86 および x86-64
- ARM
- AArch64
- RISC-V
基本的な使い方
まずは、可能な限り単純な例から始めましょう。
## #[cfg(target_arch = "x86_64")] {
use std::arch::asm;
unsafe {
asm!("nop");
}
## }
これは、コンパイラによって生成されるアセンブリに NOP(何もしない)命令を挿入します。すべての asm! 呼び出しは unsafe ブロック内にある必要があります。なぜなら、任意の命令を挿入してさまざまな不変条件を破る可能性があるからです。挿入する命令は、asm! マクロの最初の引数に文字列リテラルとしてリストされます。
入出力
さて、何もしない命令を挿入するのはかなり退屈です。では、実際にデータに作用する何かをしてみましょう。
## #[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 を指定します。コンパイラは、テンプレートに挿入する適切なレジスタを選択し、インラインアセンブリの実行が終了した後にそこから変数を読み取ります。
入力も使った別の例を見てみましょう。
## #[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 命令を回避することができます。
## #[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 演算子の入力と出力の部分に異なる変数を指定することも可能です。
## #[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 というバリアントもあります。
ここに、inlateout を release モードやその他の最適化されたケースで使用できない例があります。
## #[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 モード)ではうまく機能するかもしれませんが、最適化されたパフォーマンス(release モードやその他の最適化されたケース)を望む場合は機能しないかもしれません。
それは、最適化されたケースでは、コンパイラは入力 b と c に同じ値があることを知っているため、同じレジスタを割り当てることができます。ただし、a には別のレジスタを割り当てなければなりません。なぜなら、inout を使用しており、inlateout を使用していないからです。inlateout を使用した場合、a と c は同じレジスタに割り当てられる可能性があります。その場合、最初の命令が c の値を上書きして、アセンブリコードが間違った結果を生成する原因になります。
ただし、次の例は inlateout を使用できます。なぜなら、出力はすべての入力レジスタが読み取られた後にのみ変更されるからです。
## #[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 などは、その名前でアクセスできます。
## #[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 命令を使用するこの例を考えてみましょう。
## #[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
}
## }
これは、2 つの 64 ビット入力を乗算して 128 ビットの結果を得るために mul 命令を使用しています。唯一の明示的なオペランドはレジスタであり、変数 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];
// 文字列を ASCII 形式で ebx、edx、ecx に順に格納します
// 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 の前にこれらのレジスタにあった値を保存できます。これは、出力として宣言することで行われますが、変数名の代わりに _ を使用します。これは、出力値が破棄されることを示しています。
このコードはまた、LLVM による ebx が予約済みレジスタである制限に対処しています。つまり、LLVM はこのレジスタを完全に制御していると仮定しており、asm ブロックを抜ける前に元の状態に復元する必要があります。したがって、コンパイラが汎用レジスタクラスを満たすために使用する場合を除き、入力または出力として使用できません。これは、予約済みレジスタを使用する場合、reg オペランドが危険であることを意味します。なぜなら、同じレジスタを共有するため、無意識に入力または出力を破損する可能性があるからです。
これを回避するために、rdi を使用して出力配列へのポインタを格納し、push を使用して ebx を保存し、asm ブロック内で ebx から配列に読み取り、その後 pop を使用して ebx を元の状態に復元します。push と pop は、レジスタの完全な 64 ビット rbx バージョンを使用して、レジスタの全体を保存することを確認します。32 ビットターゲットでは、コードは代わりに push/pop で ebx を使用します。
これはまた、汎用レジスタクラスとともに使用して、asm コード内で使用するためのスクラッチレジスタを取得することもできます。
## #[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 のすべてのクロバーが挿入されます。
## #[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,
// 1 番目の引数は rdi に
in("rdi") arg,
// 戻り値は rax に
out("rax") result,
// "C" 呼び出し規約によって保持されないすべてのレジスタを
// クロバーされたものとしてマークします。
clobber_abi("C"),
);
result
}
}
## }
レジスタテンプレート修飾子
場合によっては、テンプレート文字列に挿入されるときのレジスタ名の書式設定方法を細かく制御する必要があります。アーキテクチャのアセンブリ言語が同じレジスタに対して複数の名前を持ち、それぞれが通常はレジスタのサブセットの「ビュー」である場合にこれが必要になります(たとえば、64 ビットレジスタの下位 32 ビット)。
デフォルトでは、コンパイラは常にレジスタの完全なサイズを指す名前を選択します(たとえば、x86-64 では rax、x86 では eax など)。
このデフォルトは、テンプレート文字列オペランドに修飾子を使用することで上書きできます。フォーマット文字列を使用する場合と同じようにです。
## #[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 レジスタクラスを使用して、レジスタ割り当てを 4 つの古い x86 レジスタ(ax、bx、cx、dx)に制限します。これらのレジスタの最初の 2 バイトは独立してアクセスできます。
レジスタ割り当てが ax レジスタに x を割り当てたと仮定します。h 修飾子は、そのレジスタの上位バイトのレジスタ名を出力し、l 修飾子は下位バイトのレジスタ名を出力します。したがって、asm コードは mov ah, al に展開され、値の下位バイトを上位バイトにコピーします。
オペランドでより小さなデータ型(たとえば、u16)を使用して、テンプレート修飾子を使用するのを忘れた場合、コンパイラは警告を発行し、使用する正しい修飾子を提案します。
メモリアドレスオペランド
時々、アセンブリ命令にはメモリアドレス/メモリ位置を介して渡されるオペランドが必要になります。対象アーキテクチャで指定されたメモリアドレス構文を手動で使用する必要があります。たとえば、x86/x86_64 で Intel アセンブリ構文を使用する場合、入力/出力を [] で囲んで、それがメモリオペランドであることを示す必要があります。
## #[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)を排他的に使用しないでください。なぜなら、それらは最終的に 2 進値として解釈される可能性があるからです。options(att_syntax) を使用すると、あいまいさが回避されますが、これは 全体の asm! ブロックの構文に影響します。([オプション] を参照してください。以下では、options に関する詳細を説明します。)
## #[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"(「後ろ向き」)または "f"(「前向き」)を付ける必要があります。そうすると、この方向にこの数字によって定義される最も近いラベルを参照することになります。
オプション
デフォルトでは、インラインアセンブリブロックは、カスタム呼び出し規約を持つ外部 FFI 関数呼び出しと同じように扱われます。メモリの読み書きが可能で、目に見える副作用がある可能性があります。ただし、多くの場合、アセンブリコードが実際に何をしているかについて、コンパイラにより多くの情報を与えることが望ましいです。そうすることで、より良い最適化が可能になります。
以前の add 命令の例を見てみましょう。
## #[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! ブロックを削除することができます。
利用可能なオプションとその効果の完全なリストについては、[リファレンス](https://doc.rust-lang.org/stable/reference/inline-assembly.html) を参照してください。
まとめ
おめでとうございます!インラインアセンブリの実験を完了しました。技術力を向上させるために、LabEx でさらに多くの実験を行って練習してください。