第2回 — C言語の動的メモリ確保と構造体

前回は、アドレスとバイト列の関係から、型・変数・ポインタ・配列・文字列・スタックを説明した。一般的な実装では、関数から戻ると、その呼び出しで使用したスタックフレームが解放される。

関数から戻った後も使用するデータや、必要なサイズが実行時に決まるデータには、動的メモリ確保が適している。本稿では、ヒープと malloc / free を扱い、続いて struct による構造体の定義と配置を説明する。

実測例の環境はmacOSとApple clangである。1バイトは8ビット、int は4バイト、ポインタは8バイト。アドレスの具体的な値は環境や実行ごとに異なる。

出発点: 関数から戻った後も使用するデータ

関数内で作成したデータを、関数から戻った後も呼び出し側で使用する場合を考える。自動記憶域と静的記憶域には、次の違いがある。

選択肢問題点
自動記憶域期間のローカル変数・配列関数が戻るとフレームごと再利用される
グローバル配列プログラムの実行中は有効だが、サイズはコンパイル時に固定される

実行時に分かったサイズで複数の領域を確保したい場合、固定長のグローバル配列だけでは対応しにくい。staticなローカル変数も関数から戻った後に有効だが、固定長の配列のサイズや個数は同様に決まっている。

malloc では実行時にサイズを指定し、関数呼び出しとは独立した有効期間を持つ領域を確保できる。

Q1. 動的に確保した領域の性質として正しいものはどれか。

1. malloc: nバイトの領域を確保する

malloc(n) はnバイトの領域を確保し、その先頭アドレスを返す。失敗した場合は NULL を返すので、実務では確認する。確保した領域の中身は初期化されない(0で埋めたい場合は calloc を使う)。

c
int *p = malloc(4);   // 4バイトを確保する。失敗した場合はNULLを返す

サイズは普通の数値なので、実行時に決まる値をそのまま書ける。利用可能なメモリの範囲で、複数の領域を確保できる。

関数から戻っても領域は有効である

残る疑問は寿命である。malloc した領域へのポインタを関数から返したら、関数の戻りとともに領域はどうなるか。次は、その動作を確認したコードと出力である。

c
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の領域は、どの関数のフレームの中でもない。 だからフレームの積み直しの影響を受けない。

スタックのフレームは自動で解放されるのに対し、ヒープ領域はfreeするまで残る

これで寿命のルールが確定する。

mallocで確保した領域は、free(p) を呼ぶまで使い続けられる。関数の呼び出し・戻りとは無関係。

スタック(フレーム)ヒープ(malloc)
確保関数を呼ぶと自動malloc(n) を明示
解放関数が戻ると自動free(p) を明示
寿命その呼び出しの間freeするまで
サイズローカル変数や保存するレジスタなどに依存する実行時に指定する。確保は失敗することもある

mallocで確保した領域は、関数から戻るだけでは解放されない。不要になった時点で、プログラムがfreeを呼ぶ必要がある。

Q2.

malloc した領域へのポインタを関数から返し、呼び出し元でその領域を読むとどうなるか。

Q3. 関数から戻ると、その呼び出しに伴って解放される領域はどちらか。

2. freeとメモリ領域の再利用

free(p) は、mallocなどで確保した領域を解放する。メモリアロケータは、解放された領域を後の確保で再利用することがある。管理方法は実装によって異なり、領域を保持する場合もOSへ返す場合もある。

ならば、freeした直後に同じサイズをmallocしたら何が返るか。32バイトを2つ確保し、片方をfreeしてもう一度確保するコードを実測した。

c
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

freeで解放した領域が後のmallocで再利用された実測例

C標準は、後のmallocが同じアドレスを返すことを保証しない。freeした時点で、その領域へのアクセスは無効になる。同じアドレスが再び確保されても、解放前のポインタを再利用してはいけない。新しい確保で得たポインタを使用する。

なお free(NULL) は「何もしない」と標準で定められているので、NULLに対して呼んで問題はない。

Q4. この実測例で、free直後に同じサイズをmallocした結果はどれか。
Q5.

free(a) した直後、a が指していた32バイトの領域はどうなっているか。

実装への注意: 解放後のメモリ内容は、アロケータや設定によって変わる。元のデータが残る場合も、管理情報などで上書きされる場合もある。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. リークした領域は、プロセス実行中、なぜもう取り返せないのか。
Q7. 1リクエストごとに1回リークする、ずっと動き続けるサーバーでは何が起きるか。

4. 動的メモリに関する未定義動作

二重解放、解放後使用、確保した領域の境界外へのアクセスは、いずれも未定義動作である。

二重free

c
int *p = malloc(32);
free(p);
free(p);      // 解放済みの領域を再度解放する。未定義動作である

同じ領域を二度解放すると、アロケータの管理情報が破壊される可能性がある。実測すると、この環境では2回目のfreeでプロセスが即座に異常終了した(exit code 134)。

ただし正確に言うと、C標準は二重freeを未定義動作と定め、何が起きても保証しない。このMacのallocatorが検出して止めたのは実装固有の検出機能で、検出されない環境では黙って管理情報が壊れ、後のmallocが奇妙なアドレスを返し始める。

解放後使用(use-after-free)

freeはポインタ変数へNULLを代入する操作ではない。しかし、領域の解放後、その領域を指していたポインタは無効になる。次は、解放後使用を含む不正なコードと、その実測結果である。結果はC標準で保証されない。

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ポインタの添字は4バイトごと、charポインタの添字は1バイトごと。malloc(4)の境界をp[1]が越える

実測でも、int *p の &p[1] は &p[0] の4バイト先、char *c に読み替えると &c[1] は1バイト先を指す。「1個分のサイズ」はポインタの型が決めている。だから malloc(4) に対する p[1] = 2 は、byte 4〜7 への書き込み — 領域の外である。

Q8. 二重freeは、C標準の上でどう定義されるか。
Q9. freeしたaと同じ数値のアドレスがcに返された。その後の

*a = 99 はC標準上どう扱われるか。

Q10. 有効なint変数xに対して

int *a = &x; int *c = &x; とした。*c = 5; の後に *a を読むと何が得られるか。

Q11.

int *p が領域の先頭(byte 0)を指しているとき、p[2] = 9; はどこに書き込むか。

Q12. 次のうち、未定義動作に

ならないものはどれか(p は int *、intは4バイトで、mallocは成功したものとする)。

5. struct: 自分で型を作る

「1人分のデータ」のように、異なる型を1つのまとまりとして扱いたいとき、そのための型を自分で定義できる。

c
struct person {
    char   name[32];
    int    age;
    double height;
};   // 新しい型の定義

では、この構造体はメモリ上でどれだけのバイトを使うのか。最小の例 struct { char c; int i; } について、隙間なく詰めれば 1+4=51 + 4 = 5 バイトのはずである。実測すると:

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: 11

byte 1〜3 の AA は、どのメンバーにも割り当てられていないパディング(padding) である。c は byte 0 だけ、i は byte 4〜7 だけを使い、byte 1〜3 は空きのまま残る。intのバイトが「下位から」並んで見えるのは、このCPUがリトルエンディアン(低位バイトを小さいアドレスに置く)だからである。

structの中身。byte1〜3はどのメンバーも使わないパディング

Q13.

struct { char c; int i; } の sizeof はいくつか(この環境)。

Q14. ダンプの byte 1〜3 に 0xAA が残っていたのはなぜか。

6. アラインメントと構造体のメンバー配置

なぜコンパイラは byte 1〜3 を空け、i を byte 4 に置いたのか。プラットフォームの**配置ルール(アラインメント)**があるためだ。

各メンバーは、その型のアラインメント要件を満たす位置に配置される。構造体全体のサイズは、構造体のアラインメントの倍数になる。

サイズとアラインメントは別の概念である。以下の基本型は、この環境では両者が一致する。配列や構造体など、すべての型で一致するわけではない。

型サイズこの環境でのアラインメント
char1すべて(1の倍数)
short2偶数アドレス
int44の倍数アドレス
long・ポインタ・double88の倍数アドレス

アラインメントは、オブジェクトの先頭アドレスに対する配置条件である。通常の構造体では、コンパイラが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バイトのパディングが入る。**メンバーの宣言順によってサイズが変わる。**この例では、アラインメントの大きいメンバーを先に配置するとパディングが減る。ただし、どの並べ替えでもサイズが小さくなるとは限らない。

並び順によってパディングの量が変わり、sizeofが24から16に変わる

リバースエンジニアリングでは、アクセスするオフセットやバイト数から構造体の配置を推定する。ただし、ゼロや未使用に見えるバイトだけでパディングと断定することはできない。命令列や複数のメモリダンプも確認する必要がある。

Q15.

struct { char c; short s; char d; } の sizeof はいくつか(short は2バイト)。

7. 構造体の動的メモリ確保

最後に、構造体と動的メモリ確保を組み合わせる。structは型、mallocは実行時の領域。合わせると、自分で作った型のデータを、実行時に必要な数だけ、好きな期間だけ持てる。リスト・木・ハッシュ表など、動的データ構造の実装に使用する。

c
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

ポインタppが指す構造体と、各メンバーのオフセット

構造体の各メンバーには、先頭からのオフセットが決まっている。この環境のstruct pointでは、xはオフセット0、yはオフセット4に配置される。ppが有効な構造体を指している場合、pp->xとpp->yでそれぞれを読み書きできる。

この形の到達点が、自分と同じ型へのポインタをメンバーに持つ構造体である。

c
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 はいくつか(この環境)。

まとめ

本稿では、動的に確保した領域の有効期間と、構造体のメンバー配置を説明した。

ルール内容
mallocの寿命freeするまで使える。関数の呼び出し・戻りとは無関係
領域の再利用解放した領域は後のmallocで再利用される場合がある。同じアドレスになる保証はない
解放責任確保に成功した領域は、不要になった時点で一度だけ解放する
事故3種リーク(free忘れ)・二重free・解放後使用。境界越えは検査なし
添字の単位p[i] の i は要素を数える。1個分のサイズはポインタの型が決める
アラインメント各型の配置要件に従う。サイズとは区別する。構造体のサイズも配置要件を満たす
構造体宣言順にメンバーを配置し、必要に応じてパディングを含む

動的に確保した構造体は、複数の関数から利用できる。必要なメンバーを初期化し、不要になったら領域を解放する。次回は、この操作を連結リストで確認する。

練習問題

  1. int *p = malloc(5 * sizeof(int)) の &p[0]〜&p[4] を表示し、何バイトずつ増えるか予測してから実測と照合する
  2. 自作 my_strdup — 引数の文字列を malloc で複製して返す(終端 \0 の分も確保する。返した領域は呼び出し側が free する)
  3. struct person { char name[16]; int age; } の sizeofと各メンバーのオフセットを紙で予測してから offsetof で照合し、バイトダンプでパディングを確認する
  4. 意図的にリークするプログラムを書き、MallocStackLogging=1 leaks -atExit -- ./a.out で自分のリークを見つける

次回

リスト構造 — struct node を next で連結し、辿り、解放する。続いて lldb でフレームとヒープを確認する。その先はアセンブリ読解、仮想メモリの仕組みへ進む。