利きx64アセンブラ
初めに
今月初め (2024/11/9) のKernel/VM探検隊@北陸 Part 7で、satoru_takeuchiさんによる「利きプロセススケジューラ」という発表がありました。そこで二番煎じではありますが「利きx64アセンブラ」というネタを紹介します(ネタ自体は前から構想はあったけど形にはしてなかった - 言い訳)。
アセンブラ
アセンブラとはアセンブリ言語で書かれたプログラムを機械語のバイト列に変換するツールで、ここではx86-64アーキテクチャ用アセンブラを対象とします。
アセンブラなんてCPUの仕様書にしたがってバイト列を生成するだけなのでアセンブラごとの違いなんてあるの?と思われるかもしれませんが、よく探してみると意外と個性が出ることがあります。
筆者は長年JITアセンブラXbyakというものを開発していたので、アセンブラが生成したバイト列を眺めることが多く、その違いにはまることもしばしばありました。
というわけで、その経験の一部をまとめてみました。
対象アセンブラ
今回ターゲットとするアセンブラは次の4種類です。
- MASM 14.42.34433.0 (Visual Studio付属)
- GAS GNU assembler (GNU Binutils for Ubuntu) 2.42
- NASM 2.16.03
- YASM 1.3.0
ターゲットアセンブリ言語は
add eax, ecx
fsubp
vpextrw rax, xmm0, 3
です。なお、gasの標準フォーマットはIntel形式ではなくAT&T形式なので、それに合わせてレジスタの順序を入れ換え、レジスタ名に%をつけ、即値に$をつけた
add %ecx, %eax
fsubp
vpextrw $3, %xmm0, %rax
を利用しました。
生成バイト列
4種類のアセンブラに順不同でA, B, C, Dとラベルをつけてアセンブルした出力バイト列は次のようになりました。
| アセンブラ\命令 | add eax, ecx | fsubp | vpextrw rax, xmm0, 3 |
|---|---|---|---|
| A | 01 C8 | DE E9 | C5 F9 C5 C0 03 |
| B | 01 C8 | DE E9 | C4 E1 F9 C5 C0 03 |
| C | 03 C1 | DE E9 | C5 F9 C5 C0 03 |
| D | 01 C8 | DE E1 | C5 F9 C5 C0 03 |
それぞれの命令で、他と結果が異なるものが一つずつあります。よって4種類のアセンブラを区別できます。さて、みなさんはA~Dがどのアセンブラの出力か判別できるでしょうか。
空白
答え合わせ
| 命令 | add eax, ecx | fsubp | vpextrw rax, xmm0, 3 |
|---|---|---|---|
| NASM | 01 C8 | DE E9 | C5 F9 C5 C0 03 |
| YASM | 01 C8 | DE E9 | C4 E1 F9 C5 C0 03 |
| MASM | 03 C1 | DE E9 | C5 F9 C5 C0 03 |
| GAS | 01 C8 | DE E1 | C5 F9 C5 C0 03 |
です。
解説
add eax, ecx
まず、add eax, ecxのような頻出する命令で異なるバイト列になることがあるというのに驚かれた方もいらっしゃるかもしれません。addにはオペランドの種類によって様々なオペコードがあるのですが、その中で
| オペコード | 命令 |
|---|---|
| 01 /r | ADD r/m32, r32 |
| 03 /r | ADD r32, r/m32 |
というのがあります。r/m32は「32ビットレジスタまたは32ビットメモリ」、r32は32ビットレジスタを意味します。そのため、ADD r32, r32は二種類のオペコードがあるためどちらを使うかでバイト列が変わるのです。
詳しいことは知りませんが、最初MASMが登場し、その後GASがx86に対応したときに逆のエンコードを採用し、NASMやYASMがGASに追従したのかなと想像します。
多くの基本的な命令でこのような冗長性があり、それを利用してバイト列に1ビットの情報を埋め込むテクニックがあります。
fsubp
fsubpはFPU命令で、FPUレジスタの「st(1)からst(0)を引き、結果をst(1)に格納してレジスタをポップ」します。命令の最後のpはpopの意味です。
fsubp st(i), st(0)のi=1の省略形で、頻繁に使われていたため専用命令が用意されています。GASの出力DE E1は「st(0)からst(1)を引き、結果をst(1)に格納してレジスタをポップする」命令fsubrp(引き算の順序が逆 - reverseのr)を表す別の命令です(挙動が異なる)。逆にfsubrpはGASだけfsubpになってしまい非常に混乱し、バグの原因となります。
なお、ポップしないfsubはfsubrと入れ代わりません。「AT&T形式はIntel形式のレジスタ順序を入れ換えるだけ」と機械的に処理してると間違ってしまう極めてレアなケースです(大昔、非常にはまった記憶が)。今はFPUを使うことはほとんどないので気にすることはないでしょうが、なんでこんなことしてしまったんでしょうね。
vpextrw rax, xmm0, 3
vpextrw reg, xmm, immはxmmレジスタのimm番目の値を16ビット分だけregにコピーして上位ビットを0で埋める命令です。
上位ビットはクリアされるのでディスティネーションレジスタが64ビットであるvpextrw rax, xmm0, 3でも32ビットであるvpextrw eax, xmm0, 3でも同じ挙動です。それならバイト長の短いvpextrw eax, xmm0, 3を選択するのがありがたいのですが、YASMは3バイトREXプレフィクスC4を使っているため他のアセンブラの出力より長くなっています(バイトコード自体が冗長なだけで有効です)。
おまけ
実はGASもアセンブリ言語ソースの先頭に.intel_syntax noprefixをつけるとIntel形式で受け付けてくれ、その場合fsubpとfsubrpも他のアセンブラと同じ挙動になります。そうすると、今回の問題だけではGASとNASMの区別はできません。
nasm 2.16.01まではxchg命令だけMASMと同じ形のバイトコードを出力していたので区別できていたのですが、2.16.02からGASと同じ挙動になってしまいました。
判別できるパターンが無いかご存じの方がいらしたら教えてください。
Discussion