第2回 — C言語の動的メモリ確保と構造体
前回は、アドレスとバイト列の関係から、型・変数・ポインタ・配列・文字列・スタックを説明した。一般的な実装では、関数から戻ると、その呼び出しで使用したスタックフレームが解放される。
関数から戻った後も使用するデータや、必要なサイズが実行時に決まるデータには、動的メモリ確保が適している。本稿では、ヒープと malloc / free を扱い、続いて struct による構造体の定義と配置を説明する。
実測例の環境はmacOSとApple clangである。1バイトは8ビット、int は4バイト、ポインタは8バイト。アドレスの具体的な値は環境や実行ごとに異なる。
出発点: 関数から戻った後も使用するデータ
関数内で作成したデータを、関数から戻った後も呼び出し側で使用する場合を考える。自動記憶域と静的記憶域には、次の違いがある。
| 選択肢 | 問題点 |
|---|---|
| 自動記憶域期間のローカル変数・配列 | 関数が戻るとフレームごと再利用される |
| グローバル配列 | プログラムの実行中は有効だが、サイズはコンパイル時に固定される |
実行時に分かったサイズで複数の領域を確保したい場合、固定長のグローバル配列だけでは対応しにくい。staticなローカル変数も関数から戻った後に有効だが、固定長の配列のサイズや個数は同様に決まっている。
malloc では実行時にサイズを指定し、関数呼び出しとは独立した有効期間を持つ領域を確保できる。
Q1. 動的に確保した領域の性質として正しいものはどれか。
mallocは実行時に指定したサイズの領域を確保する。確保した関数から戻っても領域は有効であり、freeで解放する。自動記憶域期間を持つローカル変数とは有効期間が異なる。
1. malloc: nバイトの領域を確保する
malloc(n) はnバイトの領域を確保し、その先頭アドレスを返す。失敗した場合は NULL を返すので、実務では確認する。確保した領域の中身は初期化されない(0で埋めたい場合は calloc を使う)。
int *p = malloc(4); // 4バイトを確保する。失敗した場合はNULLを返すサイズは普通の数値なので、実行時に決まる値をそのまま書ける。利用可能なメモリの範囲で、複数の領域を確保できる。
関数から戻っても領域は有効である
残る疑問は寿命である。malloc した領域へのポインタを関数から返したら、関数の戻りとともに領域はどうなるか。次は、その動作を確認したコードと出力である。
int *make_answer(void) {
int *p = malloc(sizeof(int));
if (p == NULL) return NULL;
*p = 42;
return p; // malloc領域のアドレスを返す
}main 内: ローカル &local = 0x16f722498 ← スタック
make_answer 内: p = 0x10070d890, *p = 42 ← ヒープ
noise 内: ローカル &a = 0x16f722458 ← スタック(別フレーム)
main 内(戻った後): q = 0x10070d890, *q = 42 ← まだ読めるmake_answer が戻った後、さらに別の関数 noise を呼んでスタックに新しいフレームを積み直しても、*q = 42 はそのまま読める。
この実行例では、ローカル変数のアドレスは 0x16f7...、mallocで確保した領域のアドレスは 0x1007... であった。アドレスの上位桁だけで領域の種類を一般的に判定することはできない。前回のアドレス空間の図にあった「動的に確保した領域」の区画がこれである。mallocの領域は、どの関数のフレームの中でもない。 だからフレームの積み直しの影響を受けない。
これで寿命のルールが確定する。
mallocで確保した領域は、
free(p)を呼ぶまで使い続けられる。関数の呼び出し・戻りとは無関係。
| スタック(フレーム) | ヒープ(malloc) | |
|---|---|---|
| 確保 | 関数を呼ぶと自動 | malloc(n) を明示 |
| 解放 | 関数が戻ると自動 | free(p) を明示 |
| 寿命 | その呼び出しの間 | freeするまで |
| サイズ | ローカル変数や保存するレジスタなどに依存する | 実行時に指定する。確保は失敗することもある |
mallocで確保した領域は、関数から戻るだけでは解放されない。不要になった時点で、プログラムがfreeを呼ぶ必要がある。
Q2.
mallocした領域へのポインタを関数から返し、呼び出し元でその領域を読むとどうなるか。
malloc領域はどの関数のフレームにも属さないため、関数が戻ってもfreeするまで使える。呼び出し元で読めるのはこのためである。
Q3. 関数から戻ると、その呼び出しに伴って解放される領域はどちらか。
この実装では、関数から戻るとそのスタックフレームが解放される。mallocで確保した領域は、その関数から戻っても有効であり、freeで解放する必要がある。
2. freeとメモリ領域の再利用
free(p) は、mallocなどで確保した領域を解放する。メモリアロケータは、解放された領域を後の確保で再利用することがある。管理方法は実装によって異なり、領域を保持する場合もOSへ返す場合もある。
ならば、freeした直後に同じサイズをmallocしたら何が返るか。32バイトを2つ確保し、片方をfreeしてもう一度確保するコードを実測した。
int *a = malloc(32);
int *b = malloc(32);
printf("a = %p\nb = %p\n", (void *)a, (void *)b);
free(a);
int *c = malloc(32);
printf("c = %p\n", (void *)c);a = 0x100afd980
b = 0x100afd830
--- free(a) ---
c = 0x100afd980 ← 解放前のaと同じアドレスこの実行では、aが指していた領域が解放され、次の malloc(32) が同じアドレスを返した。bの領域は確保されたままである。スタックでも動的に確保した領域でも、使用を終えたメモリが再利用されることがある。
| 使い終わり | 再利用 | |
|---|---|---|
| スタック | 関数が戻る(pop) | 次の呼び出しのフレーム |
| ヒープ | free(p) | 次の malloc |
C標準は、後のmallocが同じアドレスを返すことを保証しない。freeした時点で、その領域へのアクセスは無効になる。同じアドレスが再び確保されても、解放前のポインタを再利用してはいけない。新しい確保で得たポインタを使用する。
なお free(NULL) は「何もしない」と標準で定められているので、NULLに対して呼んで問題はない。
Q4. この実測例で、free直後に同じサイズをmallocした結果はどれか。
この実行では、解放した領域が再利用され、同じアドレスが返された。確保の順序やアドレスは実装と実行状況に依存し、C標準の保証ではない。
Q5.
free(a)した直後、a が指していた32バイトの領域はどうなっているか。
free後は、その領域をプログラムから読み書きできない。アロケータは後の確保で再利用することも、OSへ返すこともある。直後のmallocで再利用されるとは限らない。
実装への注意: 解放後のメモリ内容は、アロケータや設定によって変わる。元のデータが残る場合も、管理情報などで上書きされる場合もある。C標準は内容を保証しない。free後の領域を読み書きしてはいけない。
3. リークと解放責任
確保に成功した領域ごとに、不要になった時点で一度だけ解放する必要がある。解放を担当する関数や呼び出し側を明確にする。不要な領域を解放せず保持し続ける問題を、メモリリークと呼ぶ。
確保した領域への有効なポインタをすべて失うと、その領域をfreeできなくなる。これはメモリリークの典型例である。
freeを書き忘れた関数を作り、macOSの leaks ツールに検出させた。
$ MallocStackLogging=1 leaks -atExit -- ./leak_demo
Process 52822: 1 leak for 160 total leaked bytes.
STACK OF 1 INSTANCE OF 'ROOT LEAK: <malloc in make_leak>':
2 leak_demo main + 28 leak_demo.c:14
1 leak_demo make_leak + 20 leak_demo.c:5 ← 確保されたままのmalloc
0 libsystem_malloc.dylib _malloc_zone_malloc...
1 (160 bytes) ROOT LEAK: <malloc in make_leak 0x8d3028000>確保されたままの128バイトが、確保した行番号つきで報告される。要求サイズとツールの報告サイズが異なる理由は、アロケータの管理方式やツールの集計方法に依存する。この出力だけから、差の32バイトの内訳を断定することはできない。
プロセスが終了すれば、OSがそのプロセスのメモリを回収する。しかし、終了までに使用可能なメモリを圧迫することはある。特に長時間実行されるプロセスでは、リークの累積が問題になる。1リクエストごとに128バイトずつ漏れるサーバーは、100万リクエストで120MB超を返せずに抱え続ける。リークが致命的になるのは、サーバー・ブラウザ・ゲームループといった長命プロセスにおいてである。
Q6. リークした領域は、プロセス実行中、なぜもう取り返せないのか。
freeには、確保で得た有効なポインタを渡す必要がある。そのポインタをすべて失うと、領域は確保されたままになり、正しく解放できなくなる。
Q7. 1リクエストごとに1回リークする、ずっと動き続けるサーバーでは何が起きるか。
プロセスが実行されている間はリークは積み重なる。ヒープに自動返却はないので、プロセス終了まで回収できない。最終的に確保が失敗したり、OSがプロセスを終了させたりする可能性がある。
4. 動的メモリに関する未定義動作
二重解放、解放後使用、確保した領域の境界外へのアクセスは、いずれも未定義動作である。
二重free
int *p = malloc(32);
free(p);
free(p); // 解放済みの領域を再度解放する。未定義動作である同じ領域を二度解放すると、アロケータの管理情報が破壊される可能性がある。実測すると、この環境では2回目のfreeでプロセスが即座に異常終了した(exit code 134)。
ただし正確に言うと、C標準は二重freeを未定義動作と定め、何が起きても保証しない。このMacのallocatorが検出して止めたのは実装固有の検出機能で、検出されない環境では黙って管理情報が壊れ、後のmallocが奇妙なアドレスを返し始める。
解放後使用(use-after-free)
freeはポインタ変数へNULLを代入する操作ではない。しかし、領域の解放後、その領域を指していたポインタは無効になる。次は、解放後使用を含む不正なコードと、その実測結果である。結果はC標準で保証されない。
int *a = malloc(sizeof(int));
free(a);
int *c = malloc(sizeof(int)); // この実行では、解放前のaと同じアドレスが返された
*c = 77;
*a = 99; // 解放済みポインタを使った書き込み。未定義動作である
printf("*c = %d\n", *c);c = 0x102ef19c0, a と同じアドレスか: 同じ
*c = 77 と書いた
*c = 99 ← 77のはずが?この実行では *c が99になった。機械語レベルでは、解放前のaと新しいcに同じ数値のアドレスが使われ、同じメモリ位置への書き込みが観測された。ただし、*a = 99 は解放後使用による未定義動作である。同じアドレスの再確保によって、古いポインタが有効になるわけではない。
このように、解放後使用は異常終了せず、別のデータを破壊する場合がある。異常終了しないことは、コードが正しいことを意味しない。検出にはAddressSanitizer(clang -fsanitize=address でビルド)が有効で、この環境でも「どの行の書き込みが・いつfreeされた領域に・いつ確保されたものか」を報告してくれる。
境界越え(ヒープバッファオーバーフロー)
malloc(8) で使ってよいのは8バイトだけ。9バイト目以降への書き込みは、隣の領域やallocatorの管理情報を壊す。通常のCのポインタアクセスには、境界検査が自動では付かない — 配列の場合と同じである。
ここで混同しやすいのが添字の単位である。p[i] の i はバイトではなく要素を数える。int *p では p[1] は「2番目のint」、つまり4バイト分の読み書きになる。
実測でも、int *p の &p[1] は &p[0] の4バイト先、char *c に読み替えると &c[1] は1バイト先を指す。「1個分のサイズ」はポインタの型が決めている。だから malloc(4) に対する p[1] = 2 は、byte 4〜7 への書き込み — 領域の外である。
Q8. 二重freeは、C標準の上でどう定義されるか。
標準は未定義動作とし、何も保証しない。このmacOSではallocatorが検出して異常終了することが多いが、それは実装のありがたい副作用であって保証ではない。
Q9. freeしたaと同じ数値のアドレスがcに返された。その後の
*a = 99はC標準上どう扱われるか。
解放した領域を指していたaは使用できない。実測ではcの値が99になったが、これは未定義動作の一例であり、常に同じ結果になるとは限らない。
Q10. 有効なint変数xに対して
int *a = &x; int *c = &x;とした。*c = 5;の後に*aを読むと何が得られるか。
aとcは、どちらも有効な変数xを指す。*cへの代入はxを更新するので、*aからも5を読み出せる。解放済みポインタを使う例とは異なり、この操作は有効である。
Q11.
int *pが領域の先頭(byte 0)を指しているとき、p[2] = 9;はどこに書き込むか。
p[2] は「3番目のint」。2×4=8バイト先から4バイト、つまりbyte 8〜11に書く。添字は要素を数え、charの連番として数えたければ char * に読み替える。
Q12. 次のうち、未定義動作にならないものはどれか(
pはint *、intは4バイトで、mallocは成功したものとする)。
未定義動作は前三つ: 二重free、4バイト領域への8バイト書き込み(p[1]はbyte 4〜7で領域外)、free後の書き込み。malloc(4)にintを1つ書いてfreeするのは正当な1:1の使い方である。
5. struct: 自分で型を作る
「1人分のデータ」のように、異なる型を1つのまとまりとして扱いたいとき、そのための型を自分で定義できる。
struct person {
char name[32];
int age;
double height;
}; // 新しい型の定義では、この構造体はメモリ上でどれだけのバイトを使うのか。最小の例 struct { char c; int i; } について、隙間なく詰めれば バイトのはずである。実測すると:
sizeof(struct A) = 8 バイト ← 予想は5だった5ではない。中身を1バイトずつダンプすると、差の正体が見える(事前に 0xAA で埋めてから値を入れている)。
byte 0: 58 ← a.c ('X')
byte 1: aa ← 誰も書いていない
byte 2: aa ← 誰も書いていない
byte 3: aa ← 誰も書いていない
byte 4: 44 ← a.i = 0x11223344 の一部(下位バイトから 44 33 22 11)
byte 5: 33
byte 6: 22
byte 7: 11byte 1〜3 の AA は、どのメンバーにも割り当てられていないパディング(padding) である。c は byte 0 だけ、i は byte 4〜7 だけを使い、byte 1〜3 は空きのまま残る。intのバイトが「下位から」並んで見えるのは、このCPUがリトルエンディアン(低位バイトを小さいアドレスに置く)だからである。
Q13.
struct { char c; int i; }の sizeof はいくつか(この環境)。
charは1バイト、intは4バイトだが、intは「4の倍数アドレス」に置かなければならないためbyte 4へ。byte 1〜3がパディングになり、計8バイトになる。
Q14. ダンプの byte 1〜3 に 0xAA が残っていたのはなぜか。
cはbyte 0だけ、iはbyte 4〜7だけを使う。byte 1〜3に書き込む者は誰もいないので、埋めた0xAAがそのまま残った。この使われない領域がpaddingである。
6. アラインメントと構造体のメンバー配置
なぜコンパイラは byte 1〜3 を空け、i を byte 4 に置いたのか。プラットフォームの**配置ルール(アラインメント)**があるためだ。
各メンバーは、その型のアラインメント要件を満たす位置に配置される。構造体全体のサイズは、構造体のアラインメントの倍数になる。
サイズとアラインメントは別の概念である。以下の基本型は、この環境では両者が一致する。配列や構造体など、すべての型で一致するわけではない。
| 型 | サイズ | この環境でのアラインメント |
|---|---|---|
char | 1 | すべて(1の倍数) |
short | 2 | 偶数アドレス |
int | 4 | 4の倍数アドレス |
long・ポインタ・double | 8 | 8の倍数アドレス |
アラインメントは、オブジェクトの先頭アドレスに対する配置条件である。通常の構造体では、コンパイラがABIの規則に従って各メンバーのオフセットとパディングを決める。CPUが非整列アクセスに対応していても、通常のCオブジェクトには型のアラインメント要件がある。
末尾のパディングは、構造体を配列にしたときも各要素のアラインメントを維持するために必要になる。この環境の通常の構造体では、構造体のアラインメントはメンバーのアラインメントの最大値になる。
本稿の通常の構造体では、次の手順で配置を計算できる。各メンバーを宣言順に配置し、必要なら次のオフセットをその型のアラインメントの倍数まで切り上げる。最後に全体のサイズを構造体のアラインメントの倍数まで切り上げる。offsetof で実測した表がこちらである。
{char; int} sizeof= 8 c@0 i@4
{int; char} sizeof= 8 i@0 c@4
{char; int; char} sizeof=12 c@0 i@4 d@8
{char; double; char} sizeof=24 c@0 d@8 e@16
{double; char} sizeof=16 d@0 c@8注目すべきは {char; double; char} の24バイトである。メンバーのサイズの合計は10バイトだが、c の後に7バイト、末尾に7バイトのパディングが入る。**メンバーの宣言順によってサイズが変わる。**この例では、アラインメントの大きいメンバーを先に配置するとパディングが減る。ただし、どの並べ替えでもサイズが小さくなるとは限らない。
リバースエンジニアリングでは、アクセスするオフセットやバイト数から構造体の配置を推定する。ただし、ゼロや未使用に見えるバイトだけでパディングと断定することはできない。命令列や複数のメモリダンプも確認する必要がある。
Q15.
struct { char c; short s; char d; }の sizeof はいくつか(short は2バイト)。
c@0。s(2バイト)は2の倍数アドレスが必要なのでbyte 1をパディングにしてs@2〜3。d@4。メンバーはオフセット4まで使い、全体を構造体のアラインメント2の倍数に切り上げて6バイトになる。shortのアラインメントは2、構造体のアラインメントも2なので、末尾に1バイトのパディングを含む。
7. 構造体の動的メモリ確保
最後に、構造体と動的メモリ確保を組み合わせる。structは型、mallocは実行時の領域。合わせると、自分で作った型のデータを、実行時に必要な数だけ、好きな期間だけ持てる。リスト・木・ハッシュ表など、動的データ構造の実装に使用する。
struct point { int x; int y; }; // 8バイトの型
struct point *pp = malloc(sizeof(struct point)); // point 1個分を確保
if (pp == NULL) return 1; // 確保失敗を確認する
pp->x = 3; // (*pp).x と完全に同じ
pp->y = 4;
/* 必要な処理が終わったら解放する。 */
free(pp);pp->x は (*pp).x と同じ意味である。ppが指す構造体のメンバーxを参照する。サイズは sizeof(struct point) と書けばよく、パディングが入っていても sizeof が正確な値を返すので手計算は不要である。
sizeof(struct point) = 8
pp = 0x100bc18f0 (動的に確保した領域)
pp->x = 3, pp->y = 4
(*pp).x = 3 ← 同じもの
pointのバイトダンプ:
byte 0〜3: 03 00 00 00 ← x
byte 4〜7: 04 00 00 00 ← y構造体の各メンバーには、先頭からのオフセットが決まっている。この環境のstruct pointでは、xはオフセット0、yはオフセット4に配置される。ppが有効な構造体を指している場合、pp->xとpp->yでそれぞれを読み書きできる。
この形の到達点が、自分と同じ型へのポインタをメンバーに持つ構造体である。
struct node { int value; struct node *next; }; // 連結リストのノード型value は byte 0〜3、next は8バイトなので byte 8〜15 — 計16バイト。nextに次のノードへのポインタを格納することで、連結リストを構成できる。次回はその操作を扱う。
Q16.
struct node { int value; struct node *next; }の sizeof はいくつか(この環境)。
value(int)@0〜3。nextは8バイトのポインタなので8の倍数アドレスへ: byte 4〜7がパディング、next@8〜15。計16バイト(構造体のアラインメント8の倍数なので、追加の切り上げは不要)。nextは、次のノードへのポインタを保持する。
まとめ
本稿では、動的に確保した領域の有効期間と、構造体のメンバー配置を説明した。
| ルール | 内容 |
|---|---|
| mallocの寿命 | freeするまで使える。関数の呼び出し・戻りとは無関係 |
| 領域の再利用 | 解放した領域は後のmallocで再利用される場合がある。同じアドレスになる保証はない |
| 解放責任 | 確保に成功した領域は、不要になった時点で一度だけ解放する |
| 事故3種 | リーク(free忘れ)・二重free・解放後使用。境界越えは検査なし |
| 添字の単位 | p[i] の i は要素を数える。1個分のサイズはポインタの型が決める |
| アラインメント | 各型の配置要件に従う。サイズとは区別する。構造体のサイズも配置要件を満たす |
| 構造体 | 宣言順にメンバーを配置し、必要に応じてパディングを含む |
動的に確保した構造体は、複数の関数から利用できる。必要なメンバーを初期化し、不要になったら領域を解放する。次回は、この操作を連結リストで確認する。
練習問題
int *p = malloc(5 * sizeof(int))の&p[0]〜&p[4]を表示し、何バイトずつ増えるか予測してから実測と照合する- 自作
my_strdup— 引数の文字列をmallocで複製して返す(終端\0の分も確保する。返した領域は呼び出し側がfreeする) struct person { char name[16]; int age; }の sizeofと各メンバーのオフセットを紙で予測してからoffsetofで照合し、バイトダンプでパディングを確認する- 意図的にリークするプログラムを書き、
MallocStackLogging=1 leaks -atExit -- ./a.outで自分のリークを見つける
次回
リスト構造 — struct node を next で連結し、辿り、解放する。続いて lldb でフレームとヒープを確認する。その先はアセンブリ読解、仮想メモリの仕組みへ進む。