インラインアセンブリ
Rustはasm!
マクロによってインラインアセンブリをサポートしています。
コンパイラが生成するアセンブリに、手書きのアセンブリを埋め込むことができます。
一般的には必要ありませんが、要求されるパフォーマンスやタイミングを達成するために必要な場合があります。
カーネルコードのような、低レベルなハードウェアの基本要素にアクセスする場合にも、この機能が必要でしょう。
注意: 以下の例はx86/x86-64アセンブリで書かれていますが、他のアーキテクチャもサポートされています。
インラインアセンブリは現在以下のアーキテクチャでサポートされています。
- x86とx86-64
- ARM
- AArch64
- RISC-V
基本的な使い方
最も単純な例から始めましょう。
これは、コンパイラが生成したアセンブリに、NOP (no operation) 命令を挿入します。
すべてのasm!
呼び出しは、unsafe
ブロックの中になければいけません。
インラインアセンブリは任意の命令を挿入でき、不変条件を壊してしまうからです。
挿入される命令は、文字列リテラルとしてasm!
マクロの第一引数に列挙されます。
入力と出力
何もしない命令を挿入しても面白くありません。 実際にデータを操作してみましょう。
これはu64
型の変数x
に5
の値を書き込んでいます。
命令を指定するために利用している文字列リテラルが、実はテンプレート文字列になっています。
これはRustのフォーマット文字列と同じルールに従います。
ですが、テンプレートに挿入される引数は、みなさんがよく知っているものとは少し違っています。
まず、変数がインラインアセンブリの入力なのか出力なのかを指定する必要があります。
上記の例では出力となっています。
out
と書くことで出力であると宣言しています。
また、アセンブリが変数をどの種類のレジスタに格納するかについても指定する必要があります。
上の例では、reg
を指定して任意の汎用レジスタに格納しています。
コンパイラはテンプレートに挿入する適切なレジスタを選び、インラインアセンブリの実行終了後、そのレジスタから変数を読みこみます。
入力を利用する別の例を見てみましょう。
この例では、変数i
の入力に5
を加え、その結果を変数o
に書き込んでいます。
このアセンブリ特有のやり方として、はじめにi
の値を出力にコピーし、それから5
を加えています。
この例はいくつかのことを示します。
まず、asm!
では複数のテンプレート文字列を引数として利用できます。
それぞれの文字列は、改行を挟んで結合されたのと同じように、独立したアセンブリコードとして扱われます。
このおかげで、アセンブリコードを容易にフォーマットできます。
つぎに、入力はout
ではなくin
と書くことで宣言されています。
そして、他のフォーマット文字列と同じように引数を番号や名前で指定できます。 インラインアセンブリのテンプレートでは、引数が2回以上利用されることが多いため、これは特に便利です。 より複雑なインラインアセンブリを書く場合、この機能を使うのが推奨されます。 可読性が向上し、引数の順序を変えることなく命令を並べ替えることができるからです。
上記の例をさらに改善して、mov
命令をやめることもできます。
inout
で入力でもあり出力でもある引数を指定しています。
こうすることで、入力と出力を個別に指定する場合と違って、入出力が同じレジスタに割り当てられることが保証されます。
inout
のオペランドとして、入力と出力それぞれに異なる変数を指定することも可能です。
遅延出力オペランド
Rustコンパイラはオペランドの割り当てに保守的です。
out
はいつでも書き込めるので、他の引数とは場所を共有できません。
しかし、最適なパフォーマンスを保証するためには、できるだけ少ないレジスタを使うことが重要です。
そうすることで、インラインアセンブリブロックの前後でレジスタを保存したり再読み込みしたりする必要がありません。
これを達成するために、Rustはlateout
指定子を提供します。
全ての入力が消費された後でのみ書き込まれる出力に利用できます。
この指定子にはinlateout
という変化形もあります。
以下は、release
モードやその他の最適化された場合に、inlateout
を利用 できない 例です。
上記はDebug
モードなど最適化されていない場合にはうまく動作します。
しかし、release
モードなど最適化されたパフォーマンスが必要な場合、動作しない可能性があります。
というのも、最適化されている場合、コンパイラはb
とc
が同じ値だと知っているので、
b
とc
の入力に同じレジスタを割り当てる場合があります。
しかし、a
についてはinlateout
ではなくinout
を使っているので、独立したレジスタを割り当てる必要があります。
もしinlateout
が使われていたら、a
とc
に同じレジスタが割り当てられたかもしれません。
そうすると、最初の命令によってc
の値が上書きされ、アセンブリコードが間違った結果を引き起こします。
しかし、次の例では、全ての入力レジスタが読み込まれた後でのみ出力が変更されるので、inlateout
を利用できます。
このアセンブリコードは、a
とb
が同じレジスタに割り当てられても、正しく動作します。
明示的なレジスタオペランド
いくつかの命令では、オペランドが特定のレジスタにある必要があります。
したがって、Rustのインラインアセンブリでは、より具体的な制約指定子を提供しています。
reg
は一般的にどのアーキテクチャでも利用可能ですが、明示的レジスタはアーキテクチャに強く依存しています。
たとえば、x86の汎用レジスタであるeax
、ebx
、ecx
、edx
、ebp
、esi
、edi
などは、その名前で指定できます。
この例では、out
命令を呼び出して、cmd
変数の中身を0x64
ポートに出力しています。
out
命令はeax
とそのサブレジスタのみをオペランドとして受け取るため、
eax
の制約指定子を使わなければなりません。
注意: 他のオペランドタイプと異なり、明示的なレジスタオペランドはテンプレート文字列中で利用できません。
{}
を使えないので、レジスタの名前を直接書く必要があります。 また、オペランドのリストの中で他のオペランドタイプの一番最後に置かれなくてはなりません。
x86のmul
命令を使った次の例を考えてみましょう。
mul
命令を使って2つの64ビットの入力を128ビットの結果に出力しています。
唯一の明示的なオペランドはレジスタで、変数a
から入力します。
2つ目のオペランドは暗黙的であり、rax
レジスタである必要があります。変数b
からrax
レジスタに入力します。
計算結果の下位64ビットはrax
レジスタに保存され、そこから変数lo
に出力されます。
上位64ビットはrdx
レジスタに保存され、そこから変数hi
に出力されます。
クロバーレジスタ
多くの場合、インラインアセンブリは出力として必要のない状態を変更することがあります。 これは普通、アセンブリでスクラッチレジスタを利用する必要があったり、 私たちがこれ以上必要としていない状態を命令が変更したりするためです。 この状態を一般的に"クロバー"(訳注:上書き)と呼びます。 私たちはコンパイラにこのことを伝える必要があります。 なぜならコンパイラは、インラインアセンブリブロックの前後で、 この状態を保存して復元しなくてはならない可能性があるからです。
上の例では、cpuid
命令を使い、CPUベンタIDを読み込んでいます。
この命令はeax
にサポートされている最大のcpuid
引数を書き込み、
ebx
、edx
、ecx
の順にCPUベンダIDをASCIIコードとして書き込みます。
eax
は読み込まれることはありません。
しかし、コンパイラがアセンブリ以前にこれらのレジスタにあった値を保存できるように、
レジスタが変更されたことをコンパイラに伝える必要があります。
そのために、変数名の代わりに_
を用いて出力を宣言し、出力の値が破棄されるということを示しています。
このコードはebx
がLLVMによって予約されたレジスタであるという制約を回避しています。
LLVMは、自身がレジスタを完全にコントロールし、
アセンブリブロックを抜ける前に元の状態を復元しなくてはならないと考えています。
そのため、コンパイラがin(reg)
のような汎用レジスタクラスを満たすために使用する場合 を除いて ebx
を入力や出力として利用できません。
つまり、予約されたレジスタを利用する場合に、reg
オペランドは危険なのです。入力と出力が同じレジスタを共有しているので、知らないうちに入力や出力を破壊してしまうかもしれません。
これを回避するために、rdi
を用いて出力の配列へのポインタを保管し、push
でebx
を保存し、アセンブリブロック内でebx
から読み込んで配列に書き込み、pop
でebx
を元の状態に戻しています。
push
とpop
は完全な64ビットのrbx
レジスタを使って、レジスタ全体を確実に保存しています。
32ビットの場合、push
とpop
においてebx
がかわりに利用されるでしょう。
アセンブリコード内部で利用するスクラッチレジスタを獲得するために、 汎用レジスタクラスとともに使用することもできます。
シンボル・オペランドとABIクロバー
デフォルトでは、asm!
は、出力として指定されていないレジスタはアセンブリコードによって中身が維持される、と考えます。
asm!
に渡されるclobber_abi
引数は、与えられた呼び出し規約のABIに従って、
必要なクロバーオペランドを自動的に挿入するようコンパイラに伝えます。
そのABIで完全に保存されていないレジスタは、クロバーとして扱われます。
複数の clobber_abi
引数を指定すると、指定されたすべてのABIのクロバーが挿入されます。
レジスタテンプレート修飾子
テンプレート文字列に挿入されるレジスタの名前のフォーマット方法について、細かい制御が必要な場合があります。 アーキテクチャのアセンブリ言語が、同じレジスタに別名を持っている場合です。 典型的な例としては、レジスタの部分集合に対する"ビュー"があります(例:64ビットレジスタの下位32ビット)。
デフォルトでは、コンパイラは常に完全なレジスタサイズの名前を選択します(例:x86-64ではrax
、x86ではeax
、など)。
この挙動は、フォーマット文字列と同じように、テンプレート文字列のオペランドに修飾子を利用することで上書きできます。
この例では、reg_abcd
レジスタクラスを用いて、レジスタアロケータを4つのレガシーなx86レジスタ (ax
, bx
, cx
, dx
) に制限しています。このうち最初の2バイトは独立して指定できます。
レジスタアロケータがx
をax
レジスタに割り当てることにしたと仮定しましょう。
h
修飾子はそのレジスタの上位バイトのレジスタ名を出力し、l
修飾子は下位バイトのレジスタ名を出力します。
したがって、このアセンブリコードはmov ah, al
に展開され、値の下位バイトを上位バイトにコピーします。
より小さなデータ型(例:u16
)をオペランドに利用し、テンプレート修飾子を使い忘れた場合、
コンパイラは警告を出力し、正しい修飾子を提案してくれます。
メモリアドレスオペランド
アセンブリ命令はオペランドがメモリアドレスやメモリロケーション経由で渡される必要なこともあります。
そのときは手動で、ターゲットのアーキテクチャによって指定されたメモリアドレスのシンタックスを利用しなくてはなりません。
例えば、Intelのアセンブリシンタックスを使うx86/x86_64の場合、入出力を[]
で囲んで、メモリオペランドであることを示さなくてはなりません。
ラベル
名前つきラベルの再利用は、ローカルかそうでないかに関わらず、アセンブラやリンカのエラーを引き起こしたり、変な挙動の原因となります。 名前つきラベルの再利用は以下のようなケースがあります。
- 明示的再利用: 同じラベルを1つの
asm!
ブロック中で、または複数のブロック中で2回以上利用する場合です。 - インライン化による暗黙の再利用: コンパイラは
asm!
ブロックの複数のコピーをインスタンス化する場合があります。例えば、asm!
ブロックを含む関数が複数箇所でインライン化される場合です。 - LTO(訳注: Link Time Optimizationの略)による暗黙の再利用: LTOは 他のクレート のコードを同じコード生成単位に配置するため、同じ名前のラベルを持ち込む場合があります。
そのため、インラインアセンブリコードの中では、GNUアセンブラの 数値型 ローカルラベルのみ使用してください。 アセンブリコード内でシンボルを定義すると、シンボル定義の重複により、アセンブラやリンカのエラーが発生する可能性があります。
さらに、x86でデフォルトのIntel構文を使用する場合、LLVMのバグによって、
0
、11
、101010
といった0
と1
だけで構成されたラベルは、
バイナリ値として解釈されてしまうため、使用してはいけません。
options(att_syntax)
を使うと曖昧さを避けられますが、asm!
ブロック 全体 の構文に影響します。
(options
については、後述のオプションを参照してください。)
このコードは、{0}
のレジスタの値を10から3にデクリメントし、2を加え、a
にその値を保存します。
この例は、以下のことを示しています。
- まず、ラベルとして同じ数字を複数回、同じインラインブロックで利用できます。
- つぎに、数字のラベルが参照として(例えば、命令のオペランドに)利用された場合、"b"("後方")や"f"("前方")の接尾辞が数字のラベルに追加されなくてはなりません。そうすることで、この数字の指定された方向の最も近いラベルを参照できます。
オプション
デフォルトでは、インラインアセンブリブロックは、カスタム呼び出し規約をもつ外部のFFI関数呼び出しと同じように扱われます: メモリを読み込んだり書き込んだり、観測可能な副作用を持っていたりするかもしれません。 しかし、多くの場合、アセンブリコードが実際に何をするかという情報を多く与えて、より最適化できる方が望ましいでしょう。
先ほどのadd
命令の例を見てみましょう。
オプションはasm!
マクロの最後の任意引数として渡されます。
ここでは3つのオプションを利用しました:
pure
は、アセンブリコードが観測可能な副作用を持っておらず、出力は入力のみに依存することを意味します。これにより、コンパイラオプティマイザはインラインアセンブリの呼び出し回数を減らしたり、インラインアセンブリを完全に削除したりできます。nomem
は、アセンブリコードがメモリの読み書きをしないことを意味します。デフォルトでは、インラインアセンブリはアクセス可能なメモリアドレス(例えばオペランドとして渡されたポインタや、グローバルなど)の読み書きを行うとコンパイラは仮定しています。nostack
は、アセンブリコードがスタックにデータをプッシュしないことを意味します。これにより、コンパイラはx86-64のスタックレッドゾーンなどの最適化を利用し、スタックポインタの調整を避けることができます。
これにより、コンパイラは、出力が全く必要とされていない純粋なasm!
ブロックを削除するなどして、asm!
を使ったコードをより最適化できます。
利用可能なオプションとその効果の一覧はリファレンスを参照してください。