AsmWalker ガイド — C のポインタがアセンブラではどう見えるか
C 言語を学ぶ人の多くが「ポインタの壁」に突き当たり、挫折します。
* と & の意味はわかっても、なぜそう動くのかがつかめない——
そのもどかしさを感じたことがある人は少なくないはずです。
ところが、アセンブラを一度見てしまうと景色が一変します。
ポインタは「アドレスという数値を入れたレジスタ」に過ぎず、
*p はそのアドレスのメモリを読み書きする命令に展開される——
たったそれだけの話です。
抽象的に見えたポインタが、メモリという具体的な実体と結びついた瞬間、
理解は一気に深まります。
Python や JavaScript をはじめとする現代の高級言語は、 メモリの管理をランタイムに任せ、開発者から意図的に隠しています。 それ自体は生産性を高める賢い設計ですが、裏を返せば 「プログラムが実際にどう動いているか」を知る機会を奪ってもいます。
C / C++ は今もメモリに直接触れることができる、数少ない言語です。 OS・組み込み・ゲームエンジン・言語処理系—— コンピュータの核心に触れるソフトウェアの多くが今もなお C/C++ で書かれているのは偶然ではありません。 ポインタとメモリの仕組みを理解することは、 使う言語を問わず Computer Science の土台になる知識です。 このガイドがその一歩になれば幸いです。
C 言語のポインタは、他の変数が置かれているメモリアドレスを保持する変数です。 アセンブラ視点で見ると、ポインタは「ただの整数値(アドレス)が入ったレジスタ」です。 特別な構文はなく、通常の数値と同じレジスタに保存されます。
// C言語
int x = 42;
int* p = &x; // p は x のアドレスを保持
これをアセンブラで表現すると、p はアドレス値が入ったレジスタ(またはスタック上の値)です。
C の &x は「x が置かれているアドレスを求める」演算です。
アセンブラでは、スタック変数のアドレスを計算する命令(lea や add)で実現されます。
; int x = 42 → [fp-4] に配置
mov r3, #42
str r3, [fp, #-4] ; x = 42
; int *p = &x
sub r3, fp, #4 ; r3 = fp - 4 (x のアドレス)
str r3, [fp, #-8] ; p = &x をスタックに保存
; int x = 42 → [rbp-4] に配置
mov DWORD PTR [rbp-4], 42 ; x = 42
; int *p = &x
lea rax, [rbp-4] ; rax = rbp - 4 (x のアドレス)
mov QWORD PTR [rbp-16], rax ; p = &x をスタックに保存
lea(Load Effective Address)は「アドレスを計算してレジスタに格納する」命令で、
メモリの中身は読まずにアドレス値そのものを返します。ARM の sub r3, fp, #4 も同じ役割です。
ldr r3, [r3] / str r2, [r3] など)に
進んだとき、ベースレジスタに紫の 「ptr」バッジが表示されます。
アドレスの取得から参照まで手順をたどってみてください。
C の *p は「p が指すアドレスのメモリを読み書きする」間接参照(dereference)です。
アセンブラでは メモリアクセス命令(ARM: LDR/STR、x86: MOV [...], ...)として現れます。
// C言語
int x = 42;
int* p = &x;
int y = *p; // p が指すアドレスの値を読む → y = 42
*p = 100; // p が指すアドレスに 100 を書く → x = 100
; int y = *p
ldr r3, [fp, #-8] ; r3 = p(アドレス値)
ldr r3, [r3] ; r3 = *p(そのアドレスの内容)
str r3, [fp, #-12] ; y = *p
; *p = 100
ldr r3, [fp, #-8] ; r3 = p(アドレス値)
mov r2, #100
str r2, [r3] ; *p = 100(そのアドレスに書き込み)
; int y = *p
mov rax, QWORD PTR [rbp-16] ; rax = p(アドレス値)
mov eax, DWORD PTR [rax] ; eax = *p(そのアドレスの内容)
mov DWORD PTR [rbp-20], eax ; y = *p
; *p = 100
mov rax, QWORD PTR [rbp-16] ; rax = p(アドレス値)
mov DWORD PTR [rax], 100 ; *p = 100(そのアドレスに書き込み)
ldr r3, [r3] や str r2, [r3](x86: mov eax, [rax])のステップで、
汎用レジスタパネル の [...] 内のベースレジスタに紫の「ptr」バッジが表示されます。
「このレジスタがポインタとして使われている」という構文上の事実を示すバッジです。
スタックパネルでも対応するアドレスの値が変化することを確認してください。
C でポインタを関数引数に渡すと、アセンブラでは「アドレス値を引数レジスタにコピーする」命令が生成されます。 関数の中でそのアドレスを使ってメモリを読み書きすると、呼び出し元の変数を直接変更できます。 これがいわゆる「参照渡し」の正体です。
// C言語
void double_it(int* p) {
*p = *p * 2; // 呼び出し元の変数を書き換え
}
int main(void) {
int x = 5;
double_it(&x); // x のアドレスを渡す → x が 10 になる
}
; double_it(&x) の呼び出し
sub r0, fp, #8 ; r0 = &x(アドレスを引数1へ)
bl double_it ; 関数呼び出し
; double_it(&x) の呼び出し
lea rdi, [rbp-4] ; rdi = &x(アドレスを第1引数へ)
call double_it ; 関数呼び出し
; r0 = &x が渡ってきている
ldr r3, [r0] ; r3 = *p(x の値を読む)
mov r2, r3, lsl #1 ; r2 = r3 * 2
str r2, [r0] ; *p = r2(x を書き換え)
; rdi = &x が渡ってきている
mov eax, DWORD PTR [rdi] ; eax = *p
add eax, eax ; eax *= 2
mov DWORD PTR [rdi], eax ; *p = eax(x を書き換え)
x の値が変化することを確認してください。
bl double_it / call double_it 直前ステップでは r0/rdi に青の「引数1」バッジが表示されます。
関数内で ldr r3, [r0] / mov eax, [rdi] を実行するステップでは
r0/rdi に紫の「ptr」バッジが表示され、ポインタ参照が行われていることを確認できます。
C では配列名はその先頭要素へのポインタと同じように扱われます。 アセンブラでは「配列の先頭アドレス + インデックス × 要素サイズ」の計算が明示的に現れます。
// C言語
int arr[3] = {10, 20, 30};
int v = arr[2]; // 先頭から 2 * 4 = 8 バイト先を読む
; arr の先頭アドレスを取得
sub r3, fp, #20 ; r3 = &arr[0]
; arr[2] にアクセス (オフセット = 2 * 4 = 8)
ldr r3, [r3, #8] ; r3 = arr[2]
; arr の先頭アドレスを取得
lea rax, [rbp-16] ; rax = &arr[0]
; arr[2] にアクセス (オフセット = 2 * 4 = 8)
mov eax, DWORD PTR [rax+8] ; eax = arr[2]
ループで配列を走査するとき、インデックス計算(index * 4)がアセンブラ上で
シフト命令(lsl #2)や SIB アドレッシングとして現れることがあります。
; x86-64: arr[i] へのアドレッシング(SIB 形式)
mov eax, DWORD PTR [rax + rcx*4]
; ↑ index × sizeof(int) を CPU が自動計算
ポインタに整数を加算すると、要素サイズ分だけアドレスが移動します。 これがポインタ演算です。アセンブラには要素サイズの乗算が直接現れます。
// C言語
int arr[5] = {1, 2, 3, 4, 5};
int* p = arr; // p → arr[0]
p = p + 2; // p → arr[2](アドレスは + 2*4 = +8)
int v = *p; // v = 3
; p = p + 2 (int* なので +8 バイト)
ldr r3, [fp, #-8] ; r3 = p
add r3, r3, #8 ; r3 += 2 * sizeof(int)
str r3, [fp, #-8] ; p = r3
; p = p + 2 (int* なので +8 バイト)
mov rax, QWORD PTR [rbp-16] ; rax = p
add rax, 8 ; rax += 2 * sizeof(int)
mov QWORD PTR [rbp-16], rax ; p = rax
p + 1 は「アドレス + 1」ではなく「アドレス + sizeof(*p)」です。
アセンブラレベルでは常にバイト単位で計算されるため、C の p + 2 は
int* なら +8、char* なら +2 になります。
二重ポインタ(int**)は「ポインタへのポインタ」、つまりアドレスのアドレスです。
アセンブラでは 3 段階のメモリアクセスとして現れます。
// C言語
int x = 42;
int* p = &x;
int** pp = &p;
int v = **pp; // pp → p → x の値を読む
; v = **pp
mov rax, QWORD PTR [rbp-32] ; rax = pp(アドレスのアドレス)
mov rax, QWORD PTR [rax] ; rax = *pp = p(アドレス)
mov eax, DWORD PTR [rax] ; eax = **pp = x(値)
このように、参照を重ねるたびにメモリアクセスが 1 回追加されます。
実際の C プログラムでは引数の配列(char** argv)や
リンクリストの更新(Node**)などで二重ポインタが頻繁に登場します。
| C の操作 | アセンブラでの対応 |
|---|---|
&x(アドレス取得) | lea rax, [rbp-4] / sub r3, fp, #4 — アドレス計算 |
*p(読み出し) | mov eax, [rax] / ldr r3, [r3] — メモリロード |
*p = v(書き込み) | mov [rax], eax / str r3, [r3] — メモリストア |
p + n(ポインタ演算) | add rax, n * sizeof(*p) — バイト単位加算 |
arr[i](配列アクセス) | [base + i * size] — ベース+オフセット計算 |
**pp(二重逆参照) | メモリアクセス 3 回(pp → p → 値) |
アセンブラで見ると、ポインタは「アドレスが入ったレジスタ」に過ぎません。 間接参照のたびに追加のメモリアクセスが発生するという事実を理解すると、 ポインタのコストとバグの原因がよりクリアに見えてきます。