第1回 — C言語のメモリ・ポインタ・スタック

C言語では、データをメモリのどこに置き、どのように読み書きするかが重要になる。型やポインタの意味も、メモリの仕組みと結び付けると理解しやすい。

本稿では、メモリのアドレスとバイト単位のデータ配置を説明する。その後、数の表現、型、変数、ポインタ、配列、文字列、スタックを順に扱う。各節の末尾には確認クイズを設けた。

実測例の環境はmacOSとApple clangである。この環境では1バイトは8ビット、int は4バイト、ポインタは8バイトである。以下のサイズや配置の例は、この環境を前提とする。アドレスの具体的な値は、環境や実行ごとに異なる。

出発点: メモリのアドレスとデータ

次の図は、連続したアドレスに格納された各バイトのデータを示す。

アドレス0〜4の各バイトにデータが格納されたメモリの模式図

アドレスはメモリ上の位置を示す値であり、データはその位置に格納された内容である。アドレスとデータは別のものである。

1. メモリとアドレス

CPUがメモリからデータを読むには、読み出す位置を指定する必要がある。書き込む場合も同じである。その位置を示す番号が、アドレスである。

この環境では、メモリはバイト単位でアドレスを指定する。隣接するバイトのアドレスは1ずつ増える。アドレス1000から4バイトのデータを格納する場合、使用するアドレスは1000〜1003である。

メモリとディスクには、役割の違いもある。

  • RAMは、実行中のプログラムが使う作業領域である。通常、電源を切ると内容が失われる。
  • ディスクは、プログラムやファイルを保存する領域である。電源を切っても内容が残る。

プログラムを実行すると、必要なコードやデータがメモリに読み込まれる。CPUは、そのメモリ上の命令を読み出して処理を進める。

Q1. アドレス1つ(例: 1000番)が指す領域は何バイトか。

2. 2進数: ビット列を数として読む

1ビットは、0または1のどちらかを表す。1バイトは8ビットなので、8個の0と1を並べたものとして書ける。

このビット列を整数として読むために使うのが、2進数である。仕組みは、普段使っている10進数と変わらない。

桁ごとの重み

10進数の345は、次のように分解できる。

345=3⋅100+4⋅10+5⋅1345 = 3 \cdot 100 + 4 \cdot 10 + 5 \cdot 1

右から順に、各桁の重みは1、10、100となる。左へ1桁進むたびに重みが10倍になる仕組みである。

2進数では、この倍率を2に変える。重みは右から1、2、4、8、16と続く。

2進数1011の各桁の重みは8、4、2、1であり、値は8+2+1=11になる

各ビットに重みを掛けて足すと、10進数の値が求まる。

1⋅8+0⋅4+1⋅2+1⋅1=111 \cdot 8 + 0 \cdot 4 + 1 \cdot 2 + 1 \cdot 1 = 11

つまり、2進数の1011は10進数の11である。1になっている桁の重みだけを足すと考えてもよい。

8ビットでは 28=2562^8 = 256 通りの組合せがある。符号なし整数として読む場合、その範囲は0〜255となる。

Q2. 2進数 1101 を10進数に直すといくつか。
Q3. 2進数 10110 を10進数に直すといくつか。

3. 16進数: ビット列を短く書く

2進数はビットの状態を直接表せるが、桁数が多くなる。たとえば64ビットの値をすべて書くには、0と1が64文字必要である。

そこで使うのが16進数である。ビット列を4ビットずつ区切り、それぞれを1文字で表す。

4ビットには 24=162^4 = 16 通りの組合せがある。0〜9だけでは10通りしか表せないため、10〜15にはA〜Fを使う。

2進数16進数10進数2進数16進数10進数
000000100088
000111100199
0010221010A10
0011331011B11
0100441100C12
0101551101D13
0110661110E14
0111771111F15

たとえば、2進数の10110111は次のように書き換えられる。

10110111を4ビットずつ区切ると1011と0111になり、16進数のBと7に対応する

0x は、その後ろの数が16進数であることを示す印である。1バイトは8ビットなので、16進数では2桁で表せる。メモリダンプでバイトを2桁ずつ表示するのは、この対応が分かりやすいためである。

16進数を10進数へ直す場合も、桁の重みを使う。右から1、16、256と、左へ進むたびに16倍になる。

0x2F=2⋅16+15⋅1=470x2F = 2 \cdot 16 + 15 \cdot 1 = 47

2進数、10進数、16進数は、同じ数を異なる方法で書いたものである。

Q4. 16進数 0x2F を10進数に直すといくつか。
Q5. 1バイトの中身が2進数で 10110111 のとき、16進数で書くといくつか。

4. 型: バイトの読み方を決める

1バイトで表せる符号なし整数は0〜255である。しかし、プログラムではそれより大きな数も扱う。これは、複数のバイトをまとめて1つの値として読むことで実現できる。

たとえば、4バイトをまとめると32ビットになる。符号なし整数として読めば、最大値は次のようになる。

232−1=4,294,967,2952^{32} - 1 = 4,294,967,295

約42.9億まで表せる計算である。ただし、同じ32ビットでも、符号付き整数として読んだ場合は範囲が異なる。

同じバイトでも意味は変わる

バイトの並びだけを見ても、それを数として読むのか、文字として読むのかは決まらない。

たとえば 0x41 は、整数として読むと65になる。ASCII文字コードとして読むと 'A' になる。保存されているビットは同じであり、違うのは読み方である。

C言語の型は、この読み方を指定する。どれだけの領域を使い、その値にどのような操作ができるかを決める役割を持つ。

型この環境でのサイズ用途
char1バイト文字コードや小さな整数を扱う
int4バイト符号付き整数を扱う
ポインタ8バイトデータなどのアドレスを保持する

Cの定義では、sizeof(char) は常に1である。一方、int やポインタのサイズは環境によって異なる。

コンパイラは型をもとに、何バイトを読み書きするか、どの命令で計算するかを決める。通常、データの各バイトに「これはintである」といった型を示す情報が付くわけではない。

そのため、リバースエンジニアリングでは、読み書きの幅や計算方法を手掛かりに、元の型を推定することになる。

Q6. 1バイトの符号なし整数の最大値は255である。約42億の整数はどのように格納するか。
Q7. メモリ上の1バイト 0x41 を「文字’A’」として扱うかどうかは、何によって決まるか。

5. 変数: データに名前を付ける

プログラムを書くたびに長いアドレスを指定するのは扱いにくい。そこで、データに名前を付け、その名前で参照できるようにする。これが変数である。

c
int x = 42;

この宣言は、次のように読める。

部分意味
int整数を扱う型を指定する
x変数の名前を付ける
= 42初期値を42にする

メモリ上に置かれた変数については、&x でアドレスを取得できる。また、sizeof(x) で、その変数のサイズをバイト単位で調べられる。

次は、2つの int 変数を調べた実測例である。

x のアドレス  : 0x16b09a4a8
y のアドレス  : 0x16b09a4a4
sizeof(int)   : 4 バイト

2つのアドレスの差は4である。この例では、4バイトの変数が隣接して置かれている。ただし、別々の変数が必ずこの順序で並ぶとは限らない。配置はコンパイラなどによって変わる。

変数名も、そのままCPUが使うわけではない。コンパイラが、メモリやレジスタへの操作に置き換える。最適化によって、変数がメモリ上に置かれないこともある。

領域の終わりを数える

アドレス AA から nn バイトを使うとき、最後のバイトのアドレスは A+n−1A+n-1 となる。その直後のアドレスは A+nA+n である。

たとえば、0x2000から4バイトを使う場合は次のようになる。

0x2000から4バイトの領域は0x2003までであり、その直後は0x2004である

最後のバイトと、その直後の位置は1つ違う。このように、境界や個数を1つずれて扱う誤りを、オフバイワンエラーと呼ぶ。配列の境界を確認するときにも重要になる。

Q8. double 型(8バイト)をアドレス 0x2000 から置く。使う領域と、その直後のアドレスはどれか。

6. ポインタ: アドレスを保持する変数

変数のアドレスは & で取得できる。そのアドレスを保持するための型がポインタ型である。

c
int x = 42;
int *p = &x;

p に入るのは42ではなく、xのアドレスである。int * は「int型の値を指すポインタ」を意味する。

次は、p と x がメモリに置かれた例である。

ポインタpがxのアドレスを保持しており、p自身のアドレスと参照先のアドレスは異なる

ここでは、p自身のアドレスは 0x16fdca4a0 である。一方、pの中に保存されているアドレスは 0x16fdca4a8 である。ポインタ自身の位置と、ポインタが指す位置を区別する必要がある。

& と * の役割

  • &x は、xのアドレスを取得する。
  • *p は、pが指す位置の値を参照する。

*p を読むとxの値が得られる。*p に代入すると、xの値が変わる。

c
*p = 99;   // x の値が99になる

pはxの値のコピーを持っているのではない。xのアドレスを保持し、その位置のデータを読み書きするために使われる。

ポインタを追うときは、次の2つの代入を区別するとよい。

c
p = &b;    // pが指す先をbへ変える
*p = 99;   // pが現在指している変数の値を変える

前者はp自身への代入であり、後者はpの参照先への代入である。

Q9.

int x = 42; int *p = &x; のとき、変数 p の中に入っている値は何か。

Q10. 次のコードを実行した後の m, n の値はどれか。
c
int m = 5;
int n = 6;
int *q = &n;
*q = 60;
q = &m;
*q = 50;

7. 配列: 同じ型の要素を連続して置く

配列は、同じ型のデータをメモリ上に連続して並べたものである。各データを要素と呼び、0から始まる番号で指定する。

c
int arr[] = {10, 20, 30, 40};

arr[0] は10、arr[1] は20を表す。この環境ではintは4バイトなので、要素のアドレスも4ずつ増える。

int配列の4要素が連続して並び、各要素のアドレスが4バイトずつ増える

別々に宣言した変数とは異なり、配列の要素は隙間なく連続する。

配列とポインタの関係

多くの式では、配列名 arr は先頭要素へのポインタとして扱われる。つまり、&arr[0] と同じ位置を指す。ただし、sizeof(arr) などではこの変換は起こらない。

先頭へのポインタに1を足すと、次の要素へ進む。

c
arr + 1    // arr[1]の位置
arr + 2    // arr[2]の位置

ここで増えるのは1バイトではなく、要素1個分のバイト数である。intへのポインタなら、この環境では1を足すと4バイト先へ進む。

この関係から、配列の要素へのアクセスは次のようにも書ける。

c
arr[2]       // 30
*(arr + 2)   // 同じく30

一般に、arr[i] は *(arr + i) と同じ操作を表す。

Q11.

int arr[8] が 0x1000 から始まるとき、&arr[4] のアドレスはどれか。intは4バイトとする。

8. 文字列: 終端を示す0を付ける

C言語の文字列は、文字を表すバイトの列を \0 で終わらせたものである。\0 は値が0の文字であり、文字列の終端を示す。

c
char s[] = "cat";

この配列には、次の4バイトが入る。

文字列catはc、a、t、ヌル文字の4バイトで構成され、sizeofは4、strlenは3になる

文字は3個だが、終端の \0 も場所を使うため、配列のサイズは4バイトになる。

sizeof と strlen の違い

この例では、sizeof(s) と strlen(s) は別の値を返す。

sizeof(s) = 4   ← 配列全体のバイト数
strlen(s) = 3   ← 終端までのバイト数。終端そのものは含まない

sizeof(s) は配列のサイズを調べる。strlen(s) は先頭から順に読み、最初の \0 までの長さを数える。ASCII文字だけのこの例では、数えたバイト数と文字数が一致する。

Cの文字列には、長さを示す値が自動で付くわけではない。文字列を扱う関数は、値0のバイトを終端の目印として使う。

終端がない場合の危険

\0 が欠けていると、strlen や printf("%s", s) は配列の終わりを判断できない。配列の外まで読み続ける可能性があり、動作は未定義となる。

また、strcpy などでコピー先の容量を確認せずに書き込むと、配列の外へデータを書いてしまうことがある。これはバッファオーバーフローである。境界外の読み取りと書き込みはどちらも危険だが、区別して考える必要がある。

Q12.

char s[] = "hello"; のとき sizeof(s) はいくつか。

9. 関数呼び出しとスタック

関数が動いている間は、その呼び出しに必要な作業領域が使われる。一般的な実装では、この領域をスタックフレームとして、メモリのスタック領域に確保する。

スタックフレームには、ローカル変数、保存したレジスタ、呼び出し元へ戻るための情報などが置かれる。具体的な内容は、CPUやコンパイラによって異なる。

再帰では複数の呼び出しが同時に残る

関数が自分自身を呼び出すことを再帰と呼ぶ。再帰中は、呼び出し元が戻る前に次の呼び出しが始まるため、複数のフレームが同時に必要になる。

次は、各呼び出しのローカル変数 local のアドレスを表示した例である。

深さ1: local のアドレス 0x16ced2488
深さ2: local のアドレス 0x16ced2458
深さ3: local のアドレス 0x16ced2428

この例では、深さが1つ増えるたびにアドレスが0x30、つまり48バイトずつ小さくなっている。3つの呼び出しがそれぞれ別の作業領域を使っていることが分かる。

スタックポインタで使用位置を管理する

スタックは、最後に確保した領域から先に解放する。この方式を**LIFO(後入れ先出し)**と呼ぶ。

使用中のスタック領域の位置を管理するレジスタを、スタックポインタと呼ぶ。arm64では sp という名前で参照する。

今回の環境では、スタック領域を確保するときにスタックポインタの値を減らし、解放するときに値を元に戻す。確保される領域は小さいアドレス側へ広がる。

再帰のフレームは深さ1、2、3の順に積まれ、深さ3、2、1の順に戻る

解放とは、その領域を再利用できる状態に戻すことである。保存されていたバイトを必ず消去するわけではない。ただし、そこに古い値が残っていても、寿命を終えた変数や未初期化の変数をCのコードから安全に読めるわけではない。

同じ領域が再利用される

先ほどの再帰がすべて終了した後、同じ条件で再び呼び出すと、実測では同じアドレスが使われた。

1回目: 0x16ced2488 → 0x16ced2458 → 0x16ced2428
2回目: 0x16ced2488 → 0x16ced2458 → 0x16ced2428

スタックポインタが元の値に戻り、同じ配置で領域を確保したためである。ただし、これは配置が同じだった例であり、C言語が常に同じアドレスになることを保証しているわけではない。

デバッガの bt(backtrace)を使うと、現在どの関数からどの関数が呼ばれているかを確認できる。

スタックの仕組みから分かること

ローカル変数のアドレスを返しても、その変数の寿命は延びない。 関数が終了すると、その領域は再利用される。有効期間が終了したオブジェクトを指していたポインタを、ダングリングポインタと呼ぶ。その参照先を使うのは危険である。

また、ローカル配列の外へ書き込むと、同じフレーム内の別のデータを壊す可能性がある。戻り先の情報まで書き換えられると、プログラムの実行の流れが変わることもある。これが、スタック上のバッファオーバーフローを利用した攻撃の基本的な考え方である。実際に悪用できるかどうかは、配置や保護機構にも左右される。

関数が終了した後もデータを使いたい場合は、別の寿命を持つ領域が必要になる。その選択肢の1つが、次回扱うヒープである。malloc で確保した領域は、free で解放するまで利用できる。

スタックの開始位置とASLR

プロセスのメモリには、スタックのほか、コードやデータ、動的に確保した領域などが置かれる。次は、その関係を単純化した図である。実際の配置はOSや実装によって異なる。

スタック、動的に確保した領域、データとコードを含む仮想アドレス空間の模式図。実際の配置は環境によって異なる

プロセスの起動時、OSはスタックなどの配置を決める。**ASLR(アドレス空間配置のランダム化、Address Space Layout Randomization)**が有効なら、同じプログラムでも実行ごとに配置が変わる。攻撃者がアドレスを事前に予測しにくくするための仕組みである。

実行ごとに開始位置が変わることと、同じ実行内でスタック領域を再利用することは、別の話である。開始位置がランダムでも、そこからの確保と解放はスタックの規則に従う。

なお、プログラムが扱うアドレスは仮想アドレスである。CPUのMMUが、OSの用意した対応情報に従って物理メモリのアドレスへ変換する。この仕組みは後の回で扱う。

Q13. 関数 f 内のローカル変数 x が 0x500 にあった。f が戻り、同じ実行内で再び f を呼ぶ。スタックの位置とフレームの配置が同じなら、xのアドレスはどうなるか。
Q14. 実測例の再帰中、深さ1・2・3の local が異なるアドレスを持つのはなぜか。
Q15. プロセス起動時に、スタック領域の配置を決めるのは誰か。

次回

次回は、関数が終了した後も使えるデータを扱うために、ヒープと malloc / free を学ぶ。続いて、複数の値をまとめる struct を扱う。

その後は、デバッガでの観察、アセンブリの読解、仮想メモリの仕組みへと進む。