C言語のポインタとアドレスをアセンブリコードを用いて理解してみる
本記事はデジクリアドベントカレンダー2019の12/17用に書かれました。
TL;DR
- 「アドレス」はデータが保存される場所
- 「ポインタ」はアドレスを格納するための領域
- ポインタ宣言はアドレスを格納するためのポインタ変数を宣言すること
- 参照外しはポインタに格納されているアドレス先のデータを, 先頭アドレスと型情報を元に参照すること
- アドレスを調整することで他の変数にもアクセスできる
もくじ
目標
本記事の目標は以下の3点です。
- 「アドレス」と「ポインタ」が何を表しているかを理解する
- ポインタ宣言と参照外しの違いを理解する
- キャストと参照外しを用いて別の変数にアクセスする
なお, タイトルにもある通り, 本記事ではC言語およびアセンブリコードを用います。なぜなら, アセンブリコードを読むことで実行時に何が起きているかが直感的に理解できるためです。
なお, アセンブリについては以下の記事を読むことを強くオススメします。 基礎知識やアセンブリの読み方, メモリやレジスタの仕組み等が詳しく書かれており, 非常にわかりやすいです。
1. 「アドレス」と「ポインタ」が何を表しているかを理解する
アドレス
データが保存される場所のことです。正しくは「メモリアドレス」といいます(長いので以後アドレスとします)。値を変数に格納したり, 正しく関数を呼び出したりできるのは, このアドレスがあるためです。
アドレスは1バイト単位でランダムアクセスすることが可能です。1バイトは一般に8ビットで, 10進数で0から255の値を格納することができます。それ以上の数を格納したい場合は, 複数のバイトをまとめて使用することで実現します。
ビットとバイトの違いが分からない方はこちらの記事が参考になるかもしれません。
さて, C言語ではこのアドレスを&変数名
のように, &演算子(アドレス演算子) を用いて表現します。例えば, 変数a
のアドレスが欲しい時は&a
のように書くことで, その変数が格納されている先頭アドレスを取得できます。先述した通り, データが大きい時は複数のバイトに連なってデータが格納されるため, 連続しているうちの先頭のアドレスを変数のアドレスとしています。
では, C言語のコード例を見てみましょう。 コードと実行結果はこちらから確認できます。
#include <stdio.h> int main() { int num = 57; // 変数の値を表示 printf("変数numの値は%d\n", num); // 変数のアドレスを表示 printf("変数numのアドレスは%p\n", &num); return 0; } // 出力 // 変数numの値は57 // 変数numのアドレスは0x7ffd8e972c5c
変数の値とアドレスが正しく出力されていますね。
printf()関数の引数として, 前者はnum
を, 後者は&num
を使うことで, 正しく値が取得できていることがわかります。
では, 先ほどのコードをコンパイルしたアセンブリコードを見てみましょう。 コードはこちらから確認できます。 それぞれのコードの意味はコメントで加えておきました。
.LC0: ;「変数numの値は%d\n」 .string "\345\244\211\346\225\260num\343\201\256\345\200\244\343\201\257%d\n" .LC1: ; 「変数numのアドレスは%p\n」 .string "\345\244\211\346\225\260num\343\201\256\343\202\242\343\203\211\343\203\254\343\202\271\343\201\257%p\n" main: push rbp ; ベースポインタをスタックに積む mov rbp, rsp ; rspをベースポインタの所に持ってくる sub rsp, 16 ; rspをスタックトップに持ってくる mov DWORD PTR [rbp-4], 57 ; rbp-4のアドレスからDWORD PTR(4バイト分)の領域に57という値を格納 mov eax, DWORD PTR [rbp-4] ; rbp-4のアドレスからDWORD PTR(4バイト分)の領域にある値をeaxに格納 mov esi, eax ; esiにeaxの値を格納(printf関数の引数) mov edi, OFFSET FLAT:.LC0 ; ediに「変数numの値は%d\n」という文字列を格納(printf関数の引数) mov eax, 0 ; eaxに0を格納 call printf ; printf関数を呼び出す lea rax, [rbp-4] ; raxにrbp-4のアドレスを格納 mov rsi, rax ; rsiにraxの値を格納(printf関数の引数) mov edi, OFFSET FLAT:.LC1 ; ediに「変数numのアドレスは%p\n」という文字列を格納(printf関数の引数) mov eax, 0 ; eaxに0を格納 call printf ; printf関数を呼び出す mov eax, 0 ; eaxに0を格納 leave ; high-level procedure exit ret ; return
ここで注目していただきたいのは, 変数numの値を表示する時と変数numのアドレスを表示する時に使ったアセンブリ命令の違いです。
前者では以下のようにmovを使用しています。
mov eax, DWORD PTR [rbp-4] ; rbp-4のアドレスからDWORD PTR(4バイト分)の領域にある値をeaxに格納
movはMOVeのことです。Intel SDMの5.1.1 Data Transfer Instructionsによると, 汎用レジスタ間でデータを移動すると書かれています。したがって, 変数の値そのものに働きかけをしていることがわかります。
一方, 後者では以下のようにleaを使用しています。
lea rax, [rbp-4] ; raxにrbp-4のアドレスを格納
leaはLoad Effective Addressの略で, 直訳すると実行アドレスのロードのことです。Intel SDMでは5.1.13 Miscellaneous Instructionsに書かれています。したがって, 変数の実行アドレスを取得するという働きかけをしていることがわかります。
以上より, C言語とアセンブリに以下のような対応付けができます。 なお, Cでの変数名はnumで, [rbp-4]から格納されているとします。
変数の値 | 変数のアドレス | |
---|---|---|
C言語 | num | &num |
アセンブリ | mov rax, DWORD PTR [rbp-4] | lea rax, [rbp-4] |
ポインタ
変数や関数が格納されているアドレスを格納するための領域です。先ほど, アドレスの取得の仕方を紹介しましたが, 一般的な変数にアドレスは格納しません。そこで, このポインタを使用します。
C言語では, ポインタを書く際に*変数名
のように, *
を用いて表現します。この変数名は自由に宣言できますが, 他の変数名と重複してはいけません。
難しく考えることは無いです。ポインタはアドレスを格納するための領域です。
では, C言語のコードを見ていきましょう。 コードと実行結果はこちらから確認できます。
#include <stdio.h> int main() { int num = 57; // ポインタに変数numのアドレスを格納 int* ptr = # printf("変数numのアドレスは%p\n", ptr); return 0; } // 出力 // 変数numのアドレスは0x7ffd5324a174
アドレスが正しく出力されていることがわかります。
では, 先ほどのコードをコンパイルしたアセンブリコードを見てみましょう。 コードはこちらから確認できます。 それぞれのコードの意味はコメントで加えておきました。
.LC0: ; 「変数numのアドレスは%p\n」 .string "\345\244\211\346\225\260num\343\201\256\343\202\242\343\203\211\343\203\254\343\202\271\343\201\257%p\n" main: push rbp ; ベースポインタをスタックに積む mov rbp, rsp ; rspをベースポインタの所に持ってくる sub rsp, 16 ; rspをスタックトップにセットする mov DWORD PTR [rbp-12], 57 ; rbp-12のアドレスからDWORD PTR(4バイト分)の領域に57という値を格納 lea rax, [rbp-12] ; rbp-12のアドレスをraxに格納 mov QWORD PTR [rbp-8], rax ; raxの値をrbp-8からQWORD PTR(8バイト分)の領域に格納 mov rax, QWORD PTR [rbp-8] ; rbp-8からQWORD PTR(8バイト分)の領域の値をraxに格納 mov rsi, rax ; raxの値をrsiに格納 mov edi, OFFSET FLAT:.LC0 ; ediに「変数numのアドレスは%p\n」という文字列を格納 mov eax, 0 ; eaxに0を格納 call printf ; printfを呼び出し mov eax, 0 ; eaxに0を格納 leave ; high-level procedure exit ret ; return
ここで注目していただきたいのは, 以下の通り変数のアドレスを格納する際にDWORD PTR(4バイト分)ではなく, QWORD PTR(8バイト分)の領域を用いていることです。
lea rax, [rbp-12] ; rbp-12のアドレスをraxに格納 mov QWORD PTR [rbp-8], rax ; raxの値をrbp-8からQWORD PTR(8バイト分)の領域に格納
これは当然といえば当然なのですが, x86-64は64ビットのOSなので, アドレスは64ビット(8バイト)です。そのため, アドレスを格納するためには8バイトの領域が必要になり, このような命令を実行しています。
以上より, C言語とアセンブリに以下のような対応付けができます。 なお, C言語での変数名はnumで[rbp-4]から格納されているとします。 そして, C言語でのポインタ名はptrとします。
変数の値 | 変数のアドレス | 変数のポインタ | |
---|---|---|---|
C言語 | num | &num | ptr |
アセンブリ | mov rax, DWORD PTR [rbp-4] | lea rax, [rbp-4] | mov QWORD PTR [rbp-8], rax |
まとめると, 「アドレス」はデータが保存される場所のこと, 「ポインタ」はアドレスを格納するための領域です。
2. ポインタ宣言と参照外しの違いを理解する
新たに「ポインタ宣言」と「参照外し」という言葉が出てきました。ポインタわからんという方はこの2つを区別できていないのではないかと考えています。そのため, ここでそれぞれの内容を述べておきます。
「ポインタ宣言」は, アドレスを格納するため変数を宣言することです。つまり, 先ほどポインタの説明で出てきたポインタの使い方は「ポインタ宣言」にあたります。
一方, 「参照外し」はポインタに格納されているアドレス先のデータを参照することです。 実際のC言語のコード例を見てみましょう。
#include <stdio.h> int main() { int num = 57; // ポインタに変数numのアドレスを格納 int* ptr = # // 参照外しにより, ptrのアドレスの先の値を取得 int referred_num = *ptr; printf("%d\n", referred_num); return 0; } // 出力 // 57
参照外しによりptrが保持しているアドレス先に格納されている値を取得できていることがわかります。
では, このコードをコンパイルしたアセンブリコードを見てみましょう。 コードはこちらから確認できます。
.LC0: .string "%d\n" main: push rbp ; ベースポインタをスタックに積む mov rbp, rsp ; rspをベースポインタの所に持ってくる sub rsp, 16 ; rspをスタックトップにセットする mov DWORD PTR [rbp-16], 57 ; rbp-16のアドレスからDWORD PTR(4バイト分)の領域を確保して, 57を格納する lea rax, [rbp-16] ; rbp-16のアドレスをraxに格納 mov QWORD PTR [rbp-8], rax ; raxの値をrbp-8からQWORD PTR(8バイト分)の領域に格納 mov rax, QWORD PTR [rbp-8] ; rbp-8からQWORD PTR(8バイト分)の領域の値をraxに格納 mov eax, DWORD PTR [rax] ; raxに格納されているアドレスからDWORD PTR(8バイト分)の領域の値をeaxに格納 mov DWORD PTR [rbp-12], eax ; rbp-12からDWORD PTR(4バイト分)の領域にeaxの値を格納 mov eax, DWORD PTR [rbp-12] ; rbp-12からDWORD PTR(4バイト分)の領域にある値をeaxに格納 mov esi, eax ; esiにeaxの値を格納 mov edi, OFFSET FLAT:.LC0 ; ediに「%d\n」を格納 mov eax, 0 ; eaxに0を格納 call printf ; printf関数を呼び出し mov eax, 0 ; eaxに0を格納 leave ; high-level procedure exit ret ; return
今回の参照外しに該当する部分は以下になります。
mov eax, DWORD PTR [rax] ; raxに格納されているアドレスからDWORD PTR(8バイト分)の領域の値をeaxに格納
アセンブリでは[ ]
で囲むと, 囲まれた値をアドレスとして認識し, そのアドレス上の値にアクセスします。したがって, 参照外しが正しく行われていることがアセンブリでも確認できました。
さらに, この参照外しは複数回行うことが可能です。 以下のこちらのコードを見てみましょう。
#include <stdio.h> int main() { int num = 57; int* ptr = # int** ptr2 = &ptr; int*** ptr3 = &ptr2; int**** ptr4 = &ptr3; int***** ptr5 = &ptr4; int referred_num = *****ptr5; printf("%d\n", referred_num); return 0; } // 出力 // 57
なんとも仰々しいですが, 正しく実行できます。 これもコンパイルされたアセンブリを読めば簡単に理解できます。
.LC0: .string "%d\n" main: push rbp ; ベースポインタをスタックに積む mov rbp, rsp ; rspをベースポインタに持ってくる sub rsp, 48 ; rspをスタックトップにセットする mov DWORD PTR [rbp-16], 57 ; rbp-16のアドレスからDWORD PTR(4バイト分)の領域に57を格納する lea rax, [rbp-16] ; raxにrbp-16のアドレスを格納 mov QWORD PTR [rbp-24], rax ; rbp-24のアドレスからQWORD PTR(8バイト分)の領域にraxの値を格納する lea rax, [rbp-24] ; raxにrbp-16のアドレスを格納 mov QWORD PTR [rbp-32], rax ; rbp-32のアドレスからQWORD PTR(8バイト分)の領域にraxの値を格納する lea rax, [rbp-32] ; raxにrbp-32のアドレスを格納 mov QWORD PTR [rbp-40], rax ; rbp-40のアドレスからQWORD PTR(8バイト分)の領域にraxの値を格納する lea rax, [rbp-40] ; raxにrbp-40のアドレスを格納 mov QWORD PTR [rbp-48], rax ; rbp-48のアドレスからQWORD PTR(8バイト分)の領域にraxの値を格納する lea rax, [rbp-48] ; raxにrbp-48のアドレスを格納 mov QWORD PTR [rbp-8], rax ; rbp-8のアドレスからQWORD PTR(8バイト分)の領域にraxの値を格納する mov rax, QWORD PTR [rbp-8] ; rbp-8のアドレス先の値をraxに格納する mov rax, QWORD PTR [rax] ; raxのアドレス先の値をraxに格納する mov rax, QWORD PTR [rax] ; raxのアドレス先の値をraxに格納する mov rax, QWORD PTR [rax] ; raxのアドレス先の値をraxに格納する mov rax, QWORD PTR [rax] ; raxのアドレス先の値をraxに格納する mov eax, DWORD PTR [rax] ; raxのアドレス先の値をeaxに格納する mov DWORD PTR [rbp-12], eax ; eaxの値をrbp-12のアドレスからDWORD PTR(4バイト分)の領域に格納する mov eax, DWORD PTR [rbp-12] ; rbp-12のアドレスからDWORD PTR(4バイト分)の領域にある値をeaxに格納する mov esi, eax ; esiにeaxの値を格納する mov edi, OFFSET FLAT:.LC0 ; ediに"%d\n"を格納する mov eax, 0 ; eaxに0を格納する call printf ; printfを呼び出す mov eax, 0 ; eaxに0を格納する leave ; high-level procedure exit ret ; return
これを見れば,
int referred_num = *****ptr5;
という部分で,
mov rax, QWORD PTR [rax]
が複数回繰り返されて参照外しが行われていることが分かるはずです。
以上より, 「ポインタ宣言」は, アドレスを格納するため変数を宣言することであり,「参照外し」はポインタに格納されているアドレス先のデータを参照することであるとわかります。
3. キャストと参照外しを用いて別の変数にアクセスする
また新しい単語「キャスト」が出てきました。
「キャスト」とは, 変数を何の型で解釈するか指定する操作です。
キャストは(型名)
を先頭に付与することで実現できます。
例えば, num
という変数をlong型にキャストしたい場合は, (long)num
と書けば良いです。
こちらのコードを見てください。
#include <stdio.h> int main() { long num = 1; int num2 = 2; char num3 = '3'; long* ptr = # int* ptr2 = &num2; char* ptr3 = &num3; printf(" ptr : %p\n", ptr); printf(" ptr2: %p\n", ptr2); printf(" ptr3: %p\n", ptr3); char* ptr4 = (char*)(ptr - 1) + 3; printf(" ptr4: %p\n", ptr4); printf("*ptr4: %c\n", *ptr4); return 0; } // 出力 // ptr : 0x7ffce2e87928 // ptr2: 0x7ffce2e87924 // ptr3: 0x7ffce2e87923 // ptr4: 0x7ffce2e87923 // *ptr4: 3
このコードを実行すると, 以下のような結果が得られます。
このコードで注目していただきたいことは, 以下の通りptrを利用してptr3のデータにアクセスできているということです。
char* ptr4 = (char*)(ptr - 1) + 3;
アクセスする方法は単純で, ptrからptr3のアドレスを取得しているだけです。しかし, これコードの意味を理解するには内部で何がおきているのかを理解する必要があるはずです。
そのために, コンパイル後のアセンブリコードを確認します。
ここで, 簡単のためにprintf()
関数を取り除いています。
main: push rbp mov rbp, rsp mov QWORD PTR [rbp-40], 1 mov DWORD PTR [rbp-44], 2 mov BYTE PTR [rbp-45], 51 lea rax, [rbp-40] mov QWORD PTR [rbp-8], rax lea rax, [rbp-44] mov QWORD PTR [rbp-16], rax lea rax, [rbp-45] mov QWORD PTR [rbp-24], rax mov rax, QWORD PTR [rbp-8] sub rax, 8 add rax, 3 mov QWORD PTR [rbp-32], rax mov eax, 0 pop rbp ret
このうち, 注目していただきたいのは以下の4行です。
mov rax, QWORD PTR [rbp-8] ; rbp-8からQWORD PTR(8バイト分)の領域にある値をraxに格納 sub rax, 8 ; raxから8引く add rax, 3 ; raxに3を足す mov QWORD PTR [rbp-32], rax ; rbp-32からQWORD PTR(8バイト分)の領域にraxの値を格納
わざわざとった値に対して加減算を行っています。 これは, 今回の変数が以下のように積まれているためです。
アドレス番号 | 値 | 補足 |
---|---|---|
0x0000fff0 | num | long(8バイト分) |
0x0000ffec | num2 | int(4バイト分) |
0x0000ffeb | num3 | char(1バイト分) |
したがって, numのアドレスを持つポインタに8引いて3を足せばnumからでもnum3にアクセスできるわけです。 C言語のコードでは1しか引いていないのに8引かれているのは, ptrがlong型のポインタであるためです。long型は8バイトなので, -1しただけで8バイト分のアドレスを戻っています。
したがって, 同様に以下の場合でも正しくアクセスできますね。
// char型のポインタとしてキャストした上で5バイト分戻る char* ptr4 = ((char*)ptr - 5);
つまり, ポインタでの参照外しをする際には, 先頭アドレスと型によって情報を読み取っているということです。
仮に先頭アドレスが正しくとも, 型が誤っていると正しくデータを取得できません。そのため, 型付き言語では変なキャストをするとwarningが出る訳です。
したがって, 先ほどの参照外しの説明は不十分です。 参照外しはポインタに格納されているアドレス先のデータを, 先頭アドレスと型情報を元に参照することと言った方がより正しいでしょう。
おわりに
ここまでお読みいただきありがとうございました。 本当はCコンパイラでアドレスとポインタを自作してその上で動かしたかったのですが, 時間が間に合いませんでした(演算子の定義等までは実装できました)。
本記事は参照外しとポインタ宣言が混同している方のために書いたつもりなんですが, 結果的に怪文書ができてしまった気がします。
なにはともあれ, ここまでお読みいただきありがとうございました。 変な部分があればマサカリを投げてください。 ありがとうございました。