task4233のめも

書きたいことをつらつらと

C言語のポインタとアドレスをアセンブリコードを用いて理解してみる

本記事はデジクリアドベントカレンダー2019の12/17用に書かれました。

TL;DR

  • 「アドレス」はデータが保存される場所
  • 「ポインタ」はアドレスを格納するための領域
  • ポインタ宣言はアドレスを格納するためのポインタ変数を宣言すること
  • 参照外しはポインタに格納されているアドレス先のデータを, 先頭アドレスと型情報を元に参照すること
  • アドレスを調整することで他の変数にもアクセスできる

もくじ

目標

本記事の目標は以下の3点です。

  1. 「アドレス」と「ポインタ」が何を表しているかを理解する
  2. ポインタ宣言と参照外しの違いを理解する
  3. キャストと参照外しを用いて別の変数にアクセスする

なお, タイトルにもある通り, 本記事ではC言語およびアセンブリコードを用います。なぜなら, アセンブリコードを読むことで実行時に何が起きているかが直感的に理解できるためです。

なお, アセンブリについては以下の記事を読むことを強くオススメします。 基礎知識やアセンブリの読み方, メモリやレジスタの仕組み等が詳しく書かれており, 非常にわかりやすいです。

qiita.com

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 = &num;

  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 = &num;

  // 参照外しにより, 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 = &num;
  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 = &num;
  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コンパイラでアドレスとポインタを自作してその上で動かしたかったのですが, 時間が間に合いませんでした(演算子の定義等までは実装できました)。

本記事は参照外しとポインタ宣言が混同している方のために書いたつもりなんですが, 結果的に怪文書ができてしまった気がします。

なにはともあれ, ここまでお読みいただきありがとうございました。 変な部分があればマサカリを投げてください。 ありがとうございました。