第1回 — C言語のメモリ・ポインタ・スタック
C言語では、データをメモリのどこに置き、どのように読み書きするかが重要になる。型やポインタの意味も、メモリの仕組みと結び付けると理解しやすい。
本稿では、メモリのアドレスとバイト単位のデータ配置を説明する。その後、数の表現、型、変数、ポインタ、配列、文字列、スタックを順に扱う。各節の末尾には確認クイズを設けた。
実測例の環境はmacOSとApple clangである。この環境では1バイトは8ビット、int は4バイト、ポインタは8バイトである。以下のサイズや配置の例は、この環境を前提とする。アドレスの具体的な値は、環境や実行ごとに異なる。
出発点: メモリのアドレスとデータ
次の図は、連続したアドレスに格納された各バイトのデータを示す。
アドレスはメモリ上の位置を示す値であり、データはその位置に格納された内容である。アドレスとデータは別のものである。
1. メモリとアドレス
CPUがメモリからデータを読むには、読み出す位置を指定する必要がある。書き込む場合も同じである。その位置を示す番号が、アドレスである。
この環境では、メモリはバイト単位でアドレスを指定する。隣接するバイトのアドレスは1ずつ増える。アドレス1000から4バイトのデータを格納する場合、使用するアドレスは1000〜1003である。
メモリとディスクには、役割の違いもある。
- RAMは、実行中のプログラムが使う作業領域である。通常、電源を切ると内容が失われる。
- ディスクは、プログラムやファイルを保存する領域である。電源を切っても内容が残る。
プログラムを実行すると、必要なコードやデータがメモリに読み込まれる。CPUは、そのメモリ上の命令を読み出して処理を進める。
Q1. アドレス1つ(例: 1000番)が指す領域は何バイトか。
1つのアドレスが指すのは1バイトである。4バイトのintを1000番から置くと、アドレス1000〜1003の4バイトを使う。型のサイズと、1つのアドレスが指す単位は別である。
2. 2進数: ビット列を数として読む
1ビットは、0または1のどちらかを表す。1バイトは8ビットなので、8個の0と1を並べたものとして書ける。
このビット列を整数として読むために使うのが、2進数である。仕組みは、普段使っている10進数と変わらない。
桁ごとの重み
10進数の345は、次のように分解できる。
右から順に、各桁の重みは1、10、100となる。左へ1桁進むたびに重みが10倍になる仕組みである。
2進数では、この倍率を2に変える。重みは右から1、2、4、8、16と続く。
各ビットに重みを掛けて足すと、10進数の値が求まる。
つまり、2進数の1011は10進数の11である。1になっている桁の重みだけを足すと考えてもよい。
8ビットでは 通りの組合せがある。符号なし整数として読む場合、その範囲は0〜255となる。
Q2. 2進数 1101 を10進数に直すといくつか。
左から重みは8、4、2、1である。1になっている桁の重みを足すと、8 + 4 + 1 = 13 になる。
Q3. 2進数 10110 を10進数に直すといくつか。
左から重みは16、8、4、2、1である。1になっている桁の重みを足すと、16 + 4 + 2 = 22 になる。桁が増えても計算方法は同じである。
3. 16進数: ビット列を短く書く
2進数はビットの状態を直接表せるが、桁数が多くなる。たとえば64ビットの値をすべて書くには、0と1が64文字必要である。
そこで使うのが16進数である。ビット列を4ビットずつ区切り、それぞれを1文字で表す。
4ビットには 通りの組合せがある。0〜9だけでは10通りしか表せないため、10〜15にはA〜Fを使う。
| 2進数 | 16進数 | 10進数 | 2進数 | 16進数 | 10進数 |
|---|---|---|---|---|---|
| 0000 | 0 | 0 | 1000 | 8 | 8 |
| 0001 | 1 | 1 | 1001 | 9 | 9 |
| 0010 | 2 | 2 | 1010 | A | 10 |
| 0011 | 3 | 3 | 1011 | B | 11 |
| 0100 | 4 | 4 | 1100 | C | 12 |
| 0101 | 5 | 5 | 1101 | D | 13 |
| 0110 | 6 | 6 | 1110 | E | 14 |
| 0111 | 7 | 7 | 1111 | F | 15 |
たとえば、2進数の10110111は次のように書き換えられる。
0x は、その後ろの数が16進数であることを示す印である。1バイトは8ビットなので、16進数では2桁で表せる。メモリダンプでバイトを2桁ずつ表示するのは、この対応が分かりやすいためである。
16進数を10進数へ直す場合も、桁の重みを使う。右から1、16、256と、左へ進むたびに16倍になる。
2進数、10進数、16進数は、同じ数を異なる方法で書いたものである。
Q4. 16進数 0x2F を10進数に直すといくつか。
Fは10進数の15に対応する。したがって、2×16 + 15 = 47 となる。
Q5. 1バイトの中身が2進数で 10110111 のとき、16進数で書くといくつか。
左から4ビットずつ区切ると1011と0111になる。それぞれBと7に対応するため、答えは0xB7である。
4. 型: バイトの読み方を決める
1バイトで表せる符号なし整数は0〜255である。しかし、プログラムではそれより大きな数も扱う。これは、複数のバイトをまとめて1つの値として読むことで実現できる。
たとえば、4バイトをまとめると32ビットになる。符号なし整数として読めば、最大値は次のようになる。
約42.9億まで表せる計算である。ただし、同じ32ビットでも、符号付き整数として読んだ場合は範囲が異なる。
同じバイトでも意味は変わる
バイトの並びだけを見ても、それを数として読むのか、文字として読むのかは決まらない。
たとえば 0x41 は、整数として読むと65になる。ASCII文字コードとして読むと 'A' になる。保存されているビットは同じであり、違うのは読み方である。
C言語の型は、この読み方を指定する。どれだけの領域を使い、その値にどのような操作ができるかを決める役割を持つ。
| 型 | この環境でのサイズ | 用途 |
|---|---|---|
char | 1バイト | 文字コードや小さな整数を扱う |
int | 4バイト | 符号付き整数を扱う |
| ポインタ | 8バイト | データなどのアドレスを保持する |
Cの定義では、sizeof(char) は常に1である。一方、int やポインタのサイズは環境によって異なる。
コンパイラは型をもとに、何バイトを読み書きするか、どの命令で計算するかを決める。通常、データの各バイトに「これはintである」といった型を示す情報が付くわけではない。
そのため、リバースエンジニアリングでは、読み書きの幅や計算方法を手掛かりに、元の型を推定することになる。
Q6. 1バイトの符号なし整数の最大値は255である。約42億の整数はどのように格納するか。
4バイトをまとめると32ビットになる。符号なし整数として読めば、約42.9億まで表せる。1バイトの大きさを変えるのではなく、複数のバイトを使う仕組みである。
Q7. メモリ上の1バイト 0x41 を「文字’A’」として扱うかどうかは、何によって決まるか。
0x41は、整数なら65、ASCII文字コードなら'A'である。バイトの値だけでは意味は決まらず、読む側の取り決めによって解釈が変わる。
5. 変数: データに名前を付ける
プログラムを書くたびに長いアドレスを指定するのは扱いにくい。そこで、データに名前を付け、その名前で参照できるようにする。これが変数である。
int x = 42;この宣言は、次のように読める。
| 部分 | 意味 |
|---|---|
int | 整数を扱う型を指定する |
x | 変数の名前を付ける |
= 42 | 初期値を42にする |
メモリ上に置かれた変数については、&x でアドレスを取得できる。また、sizeof(x) で、その変数のサイズをバイト単位で調べられる。
次は、2つの int 変数を調べた実測例である。
x のアドレス : 0x16b09a4a8
y のアドレス : 0x16b09a4a4
sizeof(int) : 4 バイト2つのアドレスの差は4である。この例では、4バイトの変数が隣接して置かれている。ただし、別々の変数が必ずこの順序で並ぶとは限らない。配置はコンパイラなどによって変わる。
変数名も、そのままCPUが使うわけではない。コンパイラが、メモリやレジスタへの操作に置き換える。最適化によって、変数がメモリ上に置かれないこともある。
領域の終わりを数える
アドレス から バイトを使うとき、最後のバイトのアドレスは となる。その直後のアドレスは である。
たとえば、0x2000から4バイトを使う場合は次のようになる。
最後のバイトと、その直後の位置は1つ違う。このように、境界や個数を1つずれて扱う誤りを、オフバイワンエラーと呼ぶ。配列の境界を確認するときにも重要になる。
Q8. double 型(8バイト)をアドレス 0x2000 から置く。使う領域と、その直後のアドレスはどれか。
最後のバイトは0x2000 + 8 − 1 = 0x2007である。その直後は0x2008になる。0x2000〜0x2008まで数えると9バイトになる。
6. ポインタ: アドレスを保持する変数
変数のアドレスは & で取得できる。そのアドレスを保持するための型がポインタ型である。
int x = 42;
int *p = &x;p に入るのは42ではなく、xのアドレスである。int * は「int型の値を指すポインタ」を意味する。
次は、p と x がメモリに置かれた例である。
ここでは、p自身のアドレスは 0x16fdca4a0 である。一方、pの中に保存されているアドレスは 0x16fdca4a8 である。ポインタ自身の位置と、ポインタが指す位置を区別する必要がある。
& と * の役割
&xは、xのアドレスを取得する。*pは、pが指す位置の値を参照する。
*p を読むとxの値が得られる。*p に代入すると、xの値が変わる。
*p = 99; // x の値が99になるpはxの値のコピーを持っているのではない。xのアドレスを保持し、その位置のデータを読み書きするために使われる。
ポインタを追うときは、次の2つの代入を区別するとよい。
p = &b; // pが指す先をbへ変える
*p = 99; // pが現在指している変数の値を変える前者はp自身への代入であり、後者はpの参照先への代入である。
Q9.
int x = 42; int *p = &x;のとき、変数 p の中に入っている値は何か。
pに代入したのは&xなので、pはxのアドレスを保持する。xの値42を読み出すには、*pを使う。
Q10. 次のコードを実行した後の m, n の値はどれか。cint m = 5; int n = 6; int *q = &n; *q = 60; q = &m; *q = 50;
最初、qはnを指すので、*q = 60でnが60になる。q = &mで参照先がmに変わり、*q = 50でmが50になる。
7. 配列: 同じ型の要素を連続して置く
配列は、同じ型のデータをメモリ上に連続して並べたものである。各データを要素と呼び、0から始まる番号で指定する。
int arr[] = {10, 20, 30, 40};arr[0] は10、arr[1] は20を表す。この環境ではintは4バイトなので、要素のアドレスも4ずつ増える。
別々に宣言した変数とは異なり、配列の要素は隙間なく連続する。
配列とポインタの関係
多くの式では、配列名 arr は先頭要素へのポインタとして扱われる。つまり、&arr[0] と同じ位置を指す。ただし、sizeof(arr) などではこの変換は起こらない。
先頭へのポインタに1を足すと、次の要素へ進む。
arr + 1 // arr[1]の位置
arr + 2 // arr[2]の位置ここで増えるのは1バイトではなく、要素1個分のバイト数である。intへのポインタなら、この環境では1を足すと4バイト先へ進む。
この関係から、配列の要素へのアクセスは次のようにも書ける。
arr[2] // 30
*(arr + 2) // 同じく30一般に、arr[i] は *(arr + i) と同じ操作を表す。
Q11.
int arr[8]が 0x1000 から始まるとき、&arr[4]のアドレスはどれか。intは4バイトとする。
arr[4]は先頭から4要素分先にある。1要素は4バイトなので、4×4 = 16バイト進む。16は16進数で0x10であり、0x1000 + 0x10 = 0x1010となる。
8. 文字列: 終端を示す0を付ける
C言語の文字列は、文字を表すバイトの列を \0 で終わらせたものである。\0 は値が0の文字であり、文字列の終端を示す。
char s[] = "cat";この配列には、次の4バイトが入る。
文字は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)はいくつか。
helloの5文字に、終端の\0を加えた6バイトである。この文字列を終端も含めて保存するには、少なくとも6バイトが必要になる。
9. 関数呼び出しとスタック
関数が動いている間は、その呼び出しに必要な作業領域が使われる。一般的な実装では、この領域をスタックフレームとして、メモリのスタック領域に確保する。
スタックフレームには、ローカル変数、保存したレジスタ、呼び出し元へ戻るための情報などが置かれる。具体的な内容は、CPUやコンパイラによって異なる。
再帰では複数の呼び出しが同時に残る
関数が自分自身を呼び出すことを再帰と呼ぶ。再帰中は、呼び出し元が戻る前に次の呼び出しが始まるため、複数のフレームが同時に必要になる。
次は、各呼び出しのローカル変数 local のアドレスを表示した例である。
深さ1: local のアドレス 0x16ced2488
深さ2: local のアドレス 0x16ced2458
深さ3: local のアドレス 0x16ced2428この例では、深さが1つ増えるたびにアドレスが0x30、つまり48バイトずつ小さくなっている。3つの呼び出しがそれぞれ別の作業領域を使っていることが分かる。
スタックポインタで使用位置を管理する
スタックは、最後に確保した領域から先に解放する。この方式を**LIFO(後入れ先出し)**と呼ぶ。
使用中のスタック領域の位置を管理するレジスタを、スタックポインタと呼ぶ。arm64では sp という名前で参照する。
今回の環境では、スタック領域を確保するときにスタックポインタの値を減らし、解放するときに値を元に戻す。確保される領域は小さいアドレス側へ広がる。
解放とは、その領域を再利用できる状態に戻すことである。保存されていたバイトを必ず消去するわけではない。ただし、そこに古い値が残っていても、寿命を終えた変数や未初期化の変数を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のアドレスはどうなるか。
最初の呼び出しが終わると、スタックの位置が戻る。同じ位置から同じ配置で領域を確保するなら、xには再び0x500が使われる。なお、同じアドレスになること自体はC言語の保証ではない。
Q14. 実測例の再帰中、深さ1・2・3の local が異なるアドレスを持つのはなぜか。
呼び出し元が戻る前に次の呼び出しが始まるため、各呼び出しの作業領域が同時に必要になる。一方、staticなローカル変数は呼び出しごとに別の実体を持たない。
Q15. プロセス起動時に、スタック領域の配置を決めるのは誰か。
OSがプロセスのメモリ配置を用意する。ASLRが有効なら、スタックなどの配置は実行ごとに変わる。
次回
次回は、関数が終了した後も使えるデータを扱うために、ヒープと malloc / free を学ぶ。続いて、複数の値をまとめる struct を扱う。
その後は、デバッガでの観察、アセンブリの読解、仮想メモリの仕組みへと進む。