第4回 — ARM64のレジスタとC言語の逆アセンブリ
前回、リストのノードをlldbでメモリ内容として見た。本稿では視点をメモリからCPUの中へ移す。Cの1行 p = p->next が、CPUが実際に実行する命令の列にどう割れるかを追い、最後に逆方向 — 機械語だけからCに対応する操作を推定する — まで行く。リバースエンジニアリングの実技である。
実測環境はmacOS・Apple Silicon(arm64)・Apple clang。clang -Wall -g -O0 でビルドし、lldbで観察する。
1. 汎用レジスタとレジスタ名
レジスタ(register)は、CPUが命令の実行に使用する記憶領域である。arm64には64ビットの汎用レジスタx0〜x30がある。これらはメモリアドレスではなく、命令中のレジスタ名で指定する。ほかにスタックポインタsp、プログラムカウンタpc、浮動小数点・SIMDレジスタなどがある。
x0、x8などは64ビットの汎用レジスタ名である。x29はフレームポインタとして使用されるときにfp、x30はリンクレジスタとしてlrと呼ばれる。fpはスタックフレームの基準アドレス、lrは戻りアドレスの保持に使われる。
w0は別のレジスタではなく、x0の下位32ビットを参照する名前である。w1〜w30も同様に対応する。この環境のintは4バイトなのでwレジスタ、ポインタやlongは8バイトなのでxレジスタを使う。wレジスタへの書き込みは、対応するxレジスタの上位32ビットをゼロにする。
まず実物を見る。識別しやすい値を設定し、関数呼び出し時点で停止する:
int main(void) {
long a = 0x1111111111111111L; /* 観察用の定数 */
long b = 0x2222222222222222L; /* 観察用の定数 */
printf("%lx %lx\n", (unsigned long)a, (unsigned long)b);
return 0;
}lldb で printf にブレークポイントを置いて実行する:
(lldb) b printf
(lldb) run
(lldb) register read x0 x1 x2
x0 = 0x00000001000004c4 "%lx %lx\n" ← 文字列のアドレス。lldbが中身まで解決
x1 = 0x000000016fdfe998 ← この呼び出しの引数としては使用されない値
x2 = 0x000000016fdfe9a8 ← 同上ここから3つの事実が読める。
- レジスタは実在し、中身はただの64ビットの値。
x0の0x1000004c4は「"%lx %lx\n"へのアドレス」という意味を持つ — 値の意味は、その値を使う命令によって決まる - レジスタは使い回される。この呼び出しでは、x1/x2はa/bを渡すために使用されていない。変数ごとに専用のレジスタがあるわけではない
- なお
a/bの値そのものはこのとき[sp]と[sp+8](spが指すスタック領域)にいた — Mac(Darwin)のarm64では可変引数(...)をスタック渡しするABIの規則のため。本稿のmake_nodeやmallocでは、第1引数を汎用レジスタで渡す。第4節で実測する
Q1. arm64の汎用レジスタについて正しい説明はどれか。
arm64には31個の汎用レジスタx0〜x30がある。命令ではレジスタ名で指定する。メモリを使う整数の加算では、値をロードしてからレジスタ同士を加算する。
Q2.
w0とは何か。
w0はx0の下位32ビットを参照する。この環境ではintの操作にw名、ポインタの操作にx名を使う。独立した2つのレジスタではない。
2. メモリとレジスタ間のロード・ストア
メモリ上の値を整数演算に使う場合、ロード命令でレジスタへ読み込む。演算結果をメモリへ保存する場合はストア命令を使う。コンパイラは命令列に応じてレジスタを割り当てるため、変数とレジスタの対応は固定ではない。
では、long a = 0x1111…; が実際どう実行されたか。main の逆アセンブリだ:
<+24>: mov x8, #0x1111111111111111 ; 定数をx8へ設定する
<+28>: stur x8, [x29, #-0x10] ; x8の中身をメモリへ置く
... (printf の準備) ...
<+40>: ldur x10, [x29, #-0x10] ; a をメモリからレジスタx10へ
<+44>: ldr x8, [sp, #0x18] ; b をメモリからレジスタx8へ
<+48>: mov x9, sp
<+52>: str x10, [x9] ; a をspが指すスタック領域へ(Darwinの可変引数)
<+56>: str x8, [x9, #0x8] ; b をその隣へ
<+60>: adrp x0, 0 ; fmt文字列のアドレスをx0へ(2命令がかり)
<+64>: add x0, x0, #0x4c4 ; → "%lx %lx\n"
<+68>: bl printf ; 呼び出し(後述)命令のニーモニックとオペランド
stur x8, [x29, #-0x10]
└命令 └レジスタ └─メモリオペランド(ベース+オフセット)| 命令 | 意味 | 向き |
|---|---|---|
mov | レジスタ←定数/別レジスタ | レジスタへの値の設定 |
ldr | メモリ→レジスタ(ロード) | 読み込み |
str | レジスタ→メモリ(ストア) | 書き込み |
add/sub | 足し算・引き算 | 演算(レジスタ内だけ) |
stp/ldp | 2レジスタ分をストア・ロード(p = pair) | 2レジスタとメモリ間の転送 |
bl/ret | 関数呼び出し・戻り | 制御の移動 |
ロード・ストア命令は、ニーモニック、データを読み書きするレジスタ、メモリオペランドを指定する。転送方向はニーモニックで決まる。ほかの命令は異なる形式のオペランドを取る。
stur x8, [x29, #-16]は、x29の値から16を引いたアドレスへ、x8の8バイトを書き込む。ldur x8, [x29, #-16]は、同じアドレスから8バイトを読み、x8に格納する。
この -O0 の実測では、ローカル変数をスタックへ格納し、使用時にロードする命令が多く見られた。ただし、最適化なしでも全変数が必ずメモリに配置されるとは限らない。-O2 では、値をレジスタに保持したり、不要な変数や命令を除去したりすることがある。
目で見る: store の瞬間
4行目(long a = …)の実行の前後で、aの格納先([x29−16])を確認すると:
実行前: [x29−16] = 0x00000001f8918100 ← 初期値の書き込み前のメモリ内容
実行後: [x29−16] = 0x1111111111111111 ← aの初期値を書き込んだ
x8 = 0x1111111111111111 ← 書き込み元のx8も同じ値を保持するmovが定数をx8に設定し、sturがx8の値をメモリへ書き込む。sturはx8自体の値を変えない。
[x9, #0x4] の「+4」はアドレスの計算で、値への演算ではない
str w8, [x9, #0x4] に「加算命令」は存在しない。「アドレス = x9の中身 + 4」という計算は str の実行時にCPUが内部で済ませる(この「ベースレジスタ+オフセット」でアドレスを指定する方式をアドレッシングモードと呼ぶ)。x9 も w8 も書き換わらない — 変わるのはメモリ [it+4] の中身だけ。もし本当に足し算したければ別命令 add が要る。
Q3.
stur x8, [x29, #-0x10]が行うことはどれか。
str(store)は「レジスタ→メモリ」。x8の中身をアドレスx29−16の8バイトへ書く。逆向きはldr(load)。sturは符号付きの非スケール即値オフセットを取るストア命令であり、対応するロード命令はldurである。
Q4.
ldr x8, [sp, #0x18]の意味はどれか。
ldr=取る(メモリ→レジスタ)。#0x18=24。アドレスsp+24の中身をx8へ。「+24」はアドレスの計算で、x8の値が加算されるわけではない。
3. p = p->next を分解する
次は、リストを走査するmainのループと、それに対応する命令列である。
for (struct node *p = a; p != NULL; p = p->next)
total += p->value;─── ループ本体(for文の3つの処理がそのまま現れる) ───
<+68>: ldr x8, [sp, #0x8] ; ② p をメモリからレジスタへ
<+72>: cbz x8, <+120> ; ② p == NULL(=0)なら脱出
<+80>: ldr x8, [sp, #0x8] ; ┐ total += p->value
<+84>: ldr w9, [x8] ; │ p->value = [p+0] ← オフセット0、w9=int幅!
<+88>: ldr w8, [sp, #0x14] ; │ total をメモリからレジスタへ
<+92>: add w8, w8, w9 ; │ 足し算(← 演算はレジスタの中だけ!)
<+96>: str w8, [sp, #0x14] ; ┘ total をメモリへ戻す
<+104>: ldr x8, [sp, #0x8] ; ┐ ③ p = p->next:
<+108>: ldr x8, [x8, #0x8] ; │ ★next = [p+8] ← 前回の byte 8
<+112>: str x8, [sp, #0x8] ; ┘ p をメモリへ戻す
<+116>: b <+68> ; ループ先頭へ前回のfor文の3つの処理(①初期化 ②NULLで停止 ③nextで進む)が、そのまま機械語のブロックになっている。そして:
p->value=ldr w9, [x8]— オフセット0、w(4バイト)p->next=ldr x8, [x8, #8]— オフセット8、x(8バイト)p != NULL=cbz(ゼロなら分岐 — NULL は 0 だから)
#8 の正体
ldr x8, [x8, #8] は、実行前のx8の値に8を加えたアドレスから8バイトを読み込む。x8にノードの先頭アドレスが入っていれば、これはnextの読み取りになる。前回のメモリダンプで確認したメンバーのオフセット8が、命令の即値として使われている。
以下の [p+8] は、ノードの先頭アドレスから8バイト先という意味の表記であり、Cのポインタ式 p + 8 ではない。
p(=mallocが返したアドレス)
+0 ┌ value (4バイト)
+4 ├ パディング (4バイト) ← nextを8の倍数アドレスに置くためのパディング
+8 └ next (8バイト) ← 命令のオフセットは#8確保した領域の先頭アドレスと、メンバーのオフセットは別の値である。このmacOS環境のmallocは16バイト境界に整列したアドレスを返す。一方、#8は構造体の先頭からnextまでの距離である。nextのアドレスは、構造体の先頭アドレスに8を加えて求める。
そしてオフセットは宣言順で決まる。並びを変えれば変わる:
{int value; node *next} | {node *next; int value} | |
|---|---|---|
| value | [p+0] | [p+8] |
| next | [p+8] | [p+0] |
| パディング | 真ん中4バイト(+4) | 末尾4バイト(+12) |
| sizeof | 16 | 16 |
実測でも:
A: value@0 next@8 sizeof=16 ← {int value; struct A *next;}
B: next@0 value@8 sizeof=16 ← {struct B *next; int value;}宣言順に、各メンバーのアラインメントを満たす最小のオフセットへ配置すれば計算できる。構造体自体が必要なアラインメントを満たしているため、最初のメンバーはオフセット0に配置される。先頭メンバーの前にパディングは入らない。
Q5.
ldr x8, [x8, #8](=p->next)の#8はなぜ8か。
next(サイズ・アラインメントが8バイトの型)は8の倍数アドレスへ置かれ、前にパディング4バイトが入るのでオフセット8。sizeof=16、value@0。mallocが返す先頭アドレスのアラインメントと、メンバーのオフセットは区別する。
Q6. 8バイトのメンバーを「先頭に」置くとき、パディングは要らない。なぜか。
最初のメンバーのオフセットは0である。構造体のアラインメントがそのメンバーの要件を満たすので、先頭にパディングは不要である。後続のメンバーの前や末尾にはパディングが入ることがある。
4. 関数呼び出しと引数・戻り値のレジスタ
make_node の機械語を通読する:
make_node(v):
<+0>: sub sp, sp, #0x20 ; プロローグ: スタックフレームを確保する(32バイト)
<+4>: stp x29, x30, [sp, #0x10] ; fp・lr退避
<+8>: add x29, sp, #0x10 ; 基準設定
<+12>: stur w0, [x29, #-0x4] ; ★第1引数vはw0で渡される → メモリへ
<+16>: mov x0, #0x10 ; ★malloc(16)の第1引数16をx0へ
<+20>: bl malloc ; 呼び出し(戻りアドレスをlr=x30に記録)
<+24>: str x0, [sp] ; ★malloc の戻り値はx0で返る → n へ
<+28>: ldur w8, [x29, #-0x4] ; v をメモリから
<+32>: ldr x9, [sp] ; n をメモリから
<+36>: str w8, [x9] ; n->value = v → [n+0]
<+40>: ldr x8, [sp] ; n
<+44>: str xzr, [x8, #0x8] ; n->next = NULL → [n+8] にゼロレジスタxzrから置く
<+48>: ldr x0, [sp] ; ★戻り値 n をx0へ
<+52>: ldp x29, x30, [sp, #0x10] ; fp・lr復元
<+56>: add sp, sp, #0x20 ; エピローグ: スタックフレームを解放する
<+60>: ret ; lr のアドレスへ帰る呼び出し規約(calling convention)は、引数・戻り値・レジスタ保存などの取り決めである。arm64の基本はAAPCS64であり、Darwinには一部異なる規則がある。
本稿で扱う単純な整数・ポインタ引数はx0〜x7、32ビットの値は対応するw名を使って渡す。make_node(int v)の第1引数はw0、ポインタの戻り値はx0を使う。mallocの第1引数と戻り値もx0を使う。浮動小数点や構造体などには別の規則があり、すべての引数・戻り値がx0を使うわけではない。
そして bl/ret が制御を担当する:
bl= 「lr(x30)に戻りアドレスを書いてからジャンプ」ret= 「lr(x30)が指すアドレスへジャンプ」
lldbでこの一連の流れを連続観察できる。make_node(1) の呼び出しの前後で:
【呼び出し前】main:
w0 = 0x0000000000000001 ← 呼び出し側がw0に第1引数を設定する(mov w0, #1)
【make_node 入口】(bl の直後):
w0 = 0x0000000000000001 ← 呼び出し時のw0の値が保持されている
lr = 0x00000001000004b8 ← 戻りアドレス = blの次の命令(main+24)を自動記録
pc = 0x0000000100000460 ← 今いる場所 = make_node の先頭
【make_node から戻る】(ret の直後):
x0 = 0x000000010062c6a0 ← 戻り値 = ノードのアドレス
pc = 0x00000001000004b8 ← ちょうど lr が指していたアドレスへ帰ったまとめると:
呼び出し準備: movなどで引数をw0/x0へ設定する
呼び出し: blがlrへ次の命令のアドレスを書き、関数へ分岐する
復帰準備: 戻り値をx0へ設定し、保存したlrを復元する
復帰: retがlrの示すアドレスへ分岐するこの関数は stp x29, x30, [sp, #0x10] でfpとlrを保存し、復帰前にldpで復元する。bl mallocはlrを上書きするため、mainへの戻りアドレスを保存・復元する必要がある。ret自体は戻り値の設定もlrの復元も行わない。
デバッガのバックトレースは、フレームポインタやアンワインド情報などを利用する。すべての関数が同じフレームを作るとは限らない。
第1節のprintfでは、固定引数の書式文字列へのポインタをx0で渡し、可変引数のa/bをスタックで渡した。これはDarwin arm64のABIの規則である。本稿のmake_nodeとmallocの第1引数は、汎用レジスタで渡す。
Q7.
make_node(1)の呼び出しで、値1と戻ってきたアドレスはそれぞれどのレジスタで渡されたか。
make_nodeのint型の第1引数はw0、ポインタ型の戻り値はx0を使う。引数と戻り値の設定はmovやldrなどが行い、blとretは制御の移動を担当する。
5. アクセス幅とオフセットから型・配置を推定する
ここまではCの操作と命令列を対応付けた。逆アセンブリの読解では、命令列からデータ型や操作を推定する。次の実測例で、アクセス幅とメンバーのオフセットを確認する。
実測1: 3タイプのメンバー
struct item { char tag; int count; struct item *next; };
/* メンバー配置の予測: tag@0, count@4, next@8, sizeof=16 */
void fill(struct item *it) {
it->tag = 'A';
it->count = 42;
it->next = NULL;
}offsetof確認: tag@0 count@4 next@8 size=16
fill の機械語:
<+16>: strb w8, [x9] ← it->tag = 'A' b = byte(1バイト)!
<+28>: str w8, [x9, #0x4] ← it->count = 42 w名(4バイト幅)
<+36>: str xzr, [x8, #0x8] ← it->next = NULL x名(8バイト幅)実測2: short では strh が出る
struct s { short a; short b; int c; }; /* a@0, b@2, c@4, sizeof=8 */ <+16>: strh w8, [x9] ← a = 1 h = halfword(2バイト)
<+28>: strh w8, [x9, #0x2] ← b = 2 2バイト、オフセット2
<+40>: str w8, [x9, #0x4] ← c = 3 4バイト、オフセット4アクセス幅と型の候補
| 命令 | 幅 | この環境での型の候補 |
|---|---|---|
strb(b = byte) | 1バイト | char |
strh(h = halfword) | 2バイト | short |
str + w名 | 4バイト | int |
str + x名 | 8バイト | ポインタ・long |
読み方はこうなる:
アクセス幅は読み書きするバイト数、オフセットは基準アドレスからの距離を示す。転送方向はニーモニックで分かる。 これらは型とデータ配置を推定する手掛かりになる。ただし、アクセス幅だけでは型は一意に決まらず、元のCの文をそのまま復元できるとは限らない。
例: str xzr, [x8, #0x8](x8 が struct node を指す)— 8バイト幅(x名)のゼロ(xzrはゼロレジスタ)をオフセット8に置く。next はオフセット8の8バイトメンバーだから、これは n->next = NULL。もし n->value = 0(int=4バイト、オフセット0)なら str wzr, [x9] と書かれるはずだ。
型情報やシンボルがない実行ファイルでも、「どのオフセットに何バイトを読み書きしているか」を集めれば、構造体のメンバー配置を推定できる。8バイトのアクセスがポインタか整数かは、後続の命令や参照先も確認して判断する。
Q8.
str xzr, [x8, #0x8](x8はstruct node{int value; struct node *next;}を指す)に対応するCの文はどれか。
xzr=8バイト幅のゼロレジスタ、#0x8=nextのオフセット。「8バイトのゼロをオフセット8に置く」= n->next = NULL。value(4バイト、オフセット0)への0なら str wzr, [x9] になる。
Q9. 逆に
n->value = 0(x9はnを指す)は機械語でどう書かれるか。
valueはint(4バイト)でオフセット0。4バイトのゼロはw名のゼロレジスタwzr、場所は[n+0]。つまりstr wzr, [x9]。型と配置が既知なので、4バイト幅・オフセット0のストアを選べる。
Q10.
it->count = 42(struct item{char tag; int count; struct item *next;})に対応する行はどれか。
countはint(4バイト)でオフセット4。w名のstrで[n+4]。strb+オフセット0はtag、xzr+オフセット8はnext。この既知の構造体では、アクセス幅とオフセットからcountへの代入と判断できる。
Q11.
struct s { short a; short b; int c; }のレイアウトは。
aのオフセットは0、bは2で、いずれもshortのアラインメント2を満たす。cはオフセット4でintのアラインメント4を満たす。末尾は8なので全体のサイズも8バイトになる。この実測ではshortへの代入にstrhを使う。
まとめ
| 本日の要素 | 内容 |
|---|---|
| 汎用レジスタ | x0〜x30。w0〜w30はそれぞれの下位32ビットを参照する。fp=x29、lr=x30 |
| メモリとレジスタ | ロード・ストアで値を転送する。変数とレジスタの対応は固定ではない |
| ロード・ストア | ldrはメモリからレジスタへの読み込み、strはレジスタからメモリへの書き込み |
| アドレッシング | [x9, #4] の+4はアドレスの計算。x9もw8も書き換わらない |
p = p->next | この実測ではpをロードし、[p+8]のnextを読み、pへストアする3命令 |
| 呼び出し規約 | make_nodeの第1引数はw0、戻り値はx0。blはlrへ戻りアドレスを記録し、retはlrへ分岐する |
| 幅の辞書 | strb=1バイト(char)、strh=2(short)、str+w名=4(int)、str+x名=8(ポインタ/long) |
| 逆アセンブリの読解 | アクセス幅とオフセット、値の使われ方から、型やメンバー配置を推定する |
本稿では、Cの操作をロード・ストア、整数演算、分岐、関数呼び出しの命令列と対応付けた。逆アセンブリを読むときは、レジスタの値とメモリ配置を追い、どの値をどこから読み、どこへ書くかを確認する。
練習問題
- この記事の
next_asm.c相当を自分で書き、lldbのdisassemble -n mainでmov/ldr/str/blの個数を数える struct { char a; char b; short c; int d; }のレイアウトを予測し、offsetofで検証する(予測: a@0, b@1, c@2, d@4, sizeof8)- 自分の関数(引数2つ・戻り値あり)を書き、lldbで呼び出し前後に
register read x0 x1を観察する struct itemにdouble price;を追加し、配置を予測し、offsetofとsizeofで確認する。逆アセンブリに 浮動小数点・SIMDレジスタを使う命令が現れるか確認する。d0〜d31はこれらのレジスタの下位64ビットを参照する名前である- (挑戦) 同じソースを
-O2でビルドし、-O0との逆アセンブリを比べる — メモリとの往復(ldr/str)が激減し、レジスタに値を保持したり、不要な処理を除去したりすることがある
次回
次回は仮想メモリを扱う。OSが設定するページテーブルと、CPUのMMUによる仮想アドレスから物理アドレスへの変換を説明する。