第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〜x30と、x0・w0の対応

x0、x8などは64ビットの汎用レジスタ名である。x29はフレームポインタとして使用されるときにfp、x30はリンクレジスタとしてlrと呼ばれる。fpはスタックフレームの基準アドレス、lrは戻りアドレスの保持に使われる。

w0は別のレジスタではなく、x0の下位32ビットを参照する名前である。w1〜w30も同様に対応する。この環境のintは4バイトなのでwレジスタ、ポインタやlongは8バイトなのでxレジスタを使う。wレジスタへの書き込みは、対応するxレジスタの上位32ビットをゼロにする。

まず実物を見る。識別しやすい値を設定し、関数呼び出し時点で停止する:

c
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つの事実が読める。

  1. レジスタは実在し、中身はただの64ビットの値。x0 の 0x1000004c4 は「"%lx %lx\n" へのアドレス」という意味を持つ — 値の意味は、その値を使う命令によって決まる
  2. レジスタは使い回される。この呼び出しでは、x1/x2はa/bを渡すために使用されていない。変数ごとに専用のレジスタがあるわけではない
  3. なお a/b の値そのものはこのとき [sp] と [sp+8](spが指すスタック領域)にいた — Mac(Darwin)のarm64では可変引数(...)をスタック渡しするABIの規則のため。本稿のmake_nodeやmallocでは、第1引数を汎用レジスタで渡す。第4節で実測する
Q1. arm64の汎用レジスタについて正しい説明はどれか。
Q2.

w0 とは何か。

2. メモリとレジスタ間のロード・ストア

ldrはメモリからレジスタへ読み込み、strはレジスタからメモリへ書き込む

メモリ上の値を整数演算に使う場合、ロード命令でレジスタへ読み込む。演算結果をメモリへ保存する場合はストア命令を使う。コンパイラは命令列に応じてレジスタを割り当てるため、変数とレジスタの対応は固定ではない。

では、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/ldp2レジスタ分をストア・ロード(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] が行うことはどれか。

Q4.

ldr x8, [sp, #0x18] の意味はどれか。

3. p = p->next を分解する

次は、リストを走査するmainのループと、それに対応する命令列である。

c
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)
sizeof1616

実測でも:

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か。

Q6. 8バイトのメンバーを「先頭に」置くとき、パディングは要らない。なぜか。

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)が指すアドレスへジャンプ」

make_node呼び出しの各処理と、x0・lr・実行位置の対応

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と戻ってきたアドレスはそれぞれどのレジスタで渡されたか。

5. アクセス幅とオフセットから型・配置を推定する

ここまではCの操作と命令列を対応付けた。逆アセンブリの読解では、命令列からデータ型や操作を推定する。次の実測例で、アクセス幅とメンバーのオフセットを確認する。

実測1: 3タイプのメンバー

c
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 が出る

c
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の文はどれか。

Q9. 逆に

n->value = 0(x9はnを指す)は機械語でどう書かれるか。

Q10.

it->count = 42(struct item{char tag; int count; struct item *next;})に対応する行はどれか。

Q11.

struct s { short a; short b; int c; } のレイアウトは。

まとめ

本日の要素内容
汎用レジスタ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の操作をロード・ストア、整数演算、分岐、関数呼び出しの命令列と対応付けた。逆アセンブリを読むときは、レジスタの値とメモリ配置を追い、どの値をどこから読み、どこへ書くかを確認する。

練習問題

  1. この記事の next_asm.c 相当を自分で書き、lldb の disassemble -n main で mov/ldr/str/bl の個数を数える
  2. struct { char a; char b; short c; int d; } のレイアウトを予測し、offsetof で検証する(予測: a@0, b@1, c@2, d@4, sizeof8)
  3. 自分の関数(引数2つ・戻り値あり)を書き、lldbで呼び出し前後に register read x0 x1 を観察する
  4. struct item に double price; を追加し、配置を予測し、offsetofとsizeofで確認する。逆アセンブリに 浮動小数点・SIMDレジスタを使う命令が現れるか確認する。d0〜d31はこれらのレジスタの下位64ビットを参照する名前である
  5. (挑戦) 同じソースを -O2 でビルドし、-O0 との逆アセンブリを比べる — メモリとの往復(ldr/str)が激減し、レジスタに値を保持したり、不要な処理を除去したりすることがある

次回

次回は仮想メモリを扱う。OSが設定するページテーブルと、CPUのMMUによる仮想アドレスから物理アドレスへの変換を説明する。