C言語のポインタと malloc:解放後使用・二重解放・リーク
C言語講座3。Apple clang 21 で実行:& と *、malloc/free、解放後使用と二重解放の ASan 報告、macOS の leaks でリーク検出、realloc の伸ばし方を実測。
シリーズ:C言語講座 3/6(中級)。 C で関数とループが書ける人(第2回)で、実際のコードに出てくる * と & に戸惑っている人向けです。読み終えると、ポインタが何を保持しているか説明でき、メモリを正しく確保・解放し、ヒープの代表的な4つのバグについて AddressSanitizer の報告を読み、macOS 付属の leaks でリークを見つけられるようになります。
「C言語はポインタで挫折する」とよく言われます。難しいのは概念より、間違えた場所では落ちないことです。別の場所を壊して後で落ちるか、まったく落ちずに古いデータを返し続けます。組み込み機器、ゲーム、ネイティブライブラリ、Python や PHP の拡張モジュールを触るなら、何日も溶かすのはこの種のバグです。以下はすべて Apple Silicon Mac(M1 Pro)、macOS 26、Apple clang 21.0.0 で実行しました。Valgrind と gdb は macOS に付属しないため使っていません。出力のパスは ~/demo/ に置き換えています。
ポインタとは何か、1本のプログラムで
ポインタは、値としてメモリのアドレスを持つ変数です。&x は x のアドレス、*p は p の中のアドレスをたどった先(間接参照、dereference)です。
#include <stdio.h>
void swap_wrong(int a, int b) { int t = a; a = b; b = t; }
void swap(int *a, int *b) { int t = *a; *a = *b; *b = t; }
int main(void) {
int x = 10;
int *p = &x; /* p holds the address of x */
printf("x=%d &x=%p p=%p *p=%d\n", x, (void *)&x, (void *)p, *p);
*p = 20; /* write through the pointer */
printf("after *p = 20: x=%d\n", x);
printf("sizeof x=%zu sizeof p=%zu\n", sizeof x, sizeof p);
int a = 1, b = 2;
swap_wrong(a, b);
printf("swap_wrong: a=%d b=%d\n", a, b);
swap(&a, &b);
printf("swap: a=%d b=%d\n", a, b);
int arr[4] = {10, 20, 30, 40};
int *q = arr;
printf("*q=%d *(q+2)=%d q[3]=%d (q+1)-q=%td\n", *q, *(q + 2), q[3], (q + 1) - q);
return 0;
}
x=10 &x=0x16dcba578 p=0x16dcba578 *p=10
after *p = 20: x=20
sizeof x=4 sizeof p=8
swap_wrong: a=1 b=2
swap: a=2 b=1
*q=10 *(q+2)=30 q[3]=40 (q+1)-q=1
覚えておくことは3つです。
- C の引数は値渡し。
swap_wrongは自分のコピーを入れ替えただけです。呼び出し元の変数を変えたければアドレスを渡します。scanf("%d", &n)に&が要るのはこのためです。 - ポインタはここでは 8 バイト。 何を指していても同じで、
intは 4 バイトです。 - ポインタ演算はバイトではなく要素単位。
q + 2は int 2 個(8 バイト)進み、q[3]は*(q + 3)と定義されています。第2回の「配列がポインタになる」が成り立つのはこのためです。
メモリはどこにあるか:スタック・ヒープ・静的領域
global 0x104d00000
stack 0x16b1064a8
heap 0x1054bd970
code 0x104cf84f8
グローバル変数・ローカル変数・malloc した領域・関数のアドレスを表示したものです(実行ごとに変わります)。C の変数が住む場所は3つです。
| 領域 | 手に入れ方 | 寿命 | よくある間違い |
|---|---|---|---|
| スタック | ローカル変数 int local; |
関数が戻るまで | &local を返す |
| ヒープ | malloc・calloc・realloc |
free するまで |
free 忘れ、free 後の使用 |
| 静的領域 | グローバル変数・static 変数 |
プログラム全体 | 見えない共有状態 |
スタックは自動で片付くので、ローカル変数へのポインタを返すのは誤りです。
int *make_counter(void) {
int count = 0;
return &count;
}
dangling.c:4:13: warning: address of stack memory associated with local variable 'count' returned [-Wreturn-stack-address]
実行すると 0 と表示されました。そのスタック領域がまだ上書きされていなかっただけです。普通の ASan も黙っていて、ASAN_OPTIONS=detect_stack_use_after_return=1 を付けたときだけ ERROR: AddressSanitizer: stack-use-after-return ... in main dangling.c:8 が出ました。コンパイラの警告を信じましょう。
malloc と free の正しい書き方
int n = 5;
int *a = malloc(n * sizeof *a); /* バイト数 = 個数 × 要素サイズ */
if (a == NULL) { perror("malloc"); return 1; }
for (int i = 0; i < n; i++) a[i] = i * i;
printf("a[4]=%d\n", a[4]); /* a[4]=16 */
free(a);
a = NULL; /* ぶら下がりポインタを残さない */
mallocの引数はバイト数で、初期化されていない領域か、失敗時はNULLを返します。ゼロ埋めが欲しければcalloc(n, sizeof *a)。sizeof *a(aが指す先のサイズ)と書けば、あとでintをlongに変えても正しいままです。sizeof(int)はそうはいきません。- 成功した
malloc1回につきfreeはちょうど1回。free後もポインタは古いアドレスを持ったままで、これをぶら下がりポインタ(dangling pointer)と呼びます。NULLを入れておけば、後の間違いは派手に落ちる(NULL 参照)か無害(free(NULL)は何もしないと規格で決まっている)になります。
ヒープの4つのバグ:ASan なしとあり
4つのバグを1ファイルにまとめ、コマンドライン引数で切り替えられるようにして、普通のビルド(-g)と -fsanitize=address -g の2通りで作りました。
if (mode == 1) { /* forgot sizeof(int) */
int *a = malloc(n);
for (int i = 0; i < n; i++) a[i] = i;
printf("a[4]=%d\n", a[4]);
free(a);
} else if (mode == 2) { /* use after free */
int *a = malloc(n * sizeof *a);
a[0] = 42;
free(a);
printf("a[0]=%d\n", a[0]);
} else if (mode == 3) { /* double free */
int *a = malloc(n * sizeof *a);
free(a);
free(a);
printf("freed twice\n");
}
| バグ | 普通のビルド | ASan 付き |
|---|---|---|
malloc(n * sizeof *a) のつもりで malloc(n) |
a[4]=4、終了コード 0 |
heap-buffer-overflow、11 行目 |
| 解放後使用 | 1回目 a[0]=381779559、以降 a[0]=7、終了コード 0 |
heap-use-after-free、18 行目 |
| 二重解放 | メッセージなし、Trace/BPT trap: 5(133)か Abort trap: 6(134) |
attempting double-free、22 行目 |
NULL の hit に *hit |
Segmentation fault: 11(139) |
SEGV on unknown address 0x000000000000、13 行目 |
危ないのは上の2つです。普通のビルドではもっともらしい数字を出して終了コード 0。malloc(n) のバグは 5 バイトの領域に 20 バイト書いていますが、macOS のアロケータがたまたま後ろに余裕を持っていたので何も起きませんでした。解放後使用の報告(抜粋):
==76039==ERROR: AddressSanitizer: heap-use-after-free on address 0x603000001c30 ...
READ of size 4 at 0x603000001c30 thread T0
#0 0x000100ed4ac4 in main heapbugs.c:18
0x603000001c30 is located 0 bytes inside of 20-byte region [0x603000001c30,0x603000001c44)
freed by thread T0 here:
#0 0x000101971368 in free+0x7c (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x41368)
#1 0x000100ed4a74 in main heapbugs.c:17
previously allocated by thread T0 here:
#0 0x000101971274 in malloc+0x78 (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x41274)
#1 0x000100ed4a08 in main heapbugs.c:15
SUMMARY: AddressSanitizer: heap-use-after-free heapbugs.c:18 in main
スタックトレースが3つ:読んだ場所(18 行目)、解放した場所(17 行目)、確保した場所(15 行目)。実際のプログラムではこの3か所が別々のファイルにあることも多く、この報告が丸1日の調査を省いてくれます。
ほかに確かめたこと。NULL ポインタと未初期化ポインタは落ち方が違います。int *p; *p = 5; はコンパイル時に -Wuninitialized が出て、実行すると segfault ではなく Bus error: 10(終了コード 138)で落ちました。lldb で見ると残っていた値は p = 0x00000001f5578100 で、動的ローダの読み取り専用(r--)ページを指していました。macOS では読み取り専用メモリへの書き込みがバスエラーとして報告されます。残っている値が違えばシグナルも変わります。
また clang の静的解析(clang --analyze、同じツールチェーンに入っています)は実行なしで使えます。この回の8つのバグのうち5つ、未初期化ポインタ、スタックのアドレスを返す関数、2つの解放後使用(Use of memory after it is freed [unix.Malloc])、二重解放(Attempt to free released memory)を指摘しました。malloc(n) のオーバーフロー、find() が返す NULL、次の節のリークは見逃しました。
リーク:macOS でのやり方
リークは、確保したのにポインタを失って二度と free できなくなったメモリです。
char *greet(const char *name) {
char *buf = malloc(64);
if (!buf) return NULL;
snprintf(buf, 64, "Hello, %s!", name);
return buf; /* caller must free */
}
int main(void) {
for (int i = 0; i < 3; i++) {
char *msg = greet("leak");
printf("%s\n", msg);
/* free(msg); <- forgotten */
}
return 0;
}
Linux なら Valgrind か ASan の LeakSanitizer ですが、Apple Silicon の macOS ではどちらも使えませんでした。
$ ASAN_OPTIONS=detect_leaks=1 ./leak_asan
==77114==AddressSanitizer: detect_leaks is not supported on this platform.
$ clang -std=c17 -g -fsanitize=leak -o leak_lsan leak.c
clang: error: unsupported option '-fsanitize=leak' for target 'arm64-apple-darwin25.6.0'
しかも detect_leaks=1 はオプションを無視するのではなくプログラムを異常終了させました(終了コード 134)。チーム共有の ASAN_OPTIONS に入れっぱなしにしないでください。使えるのは /usr/bin の leaks コマンドです(抜粋)。
$ leaks --atExit -- ./leak
Hello, leak!
Hello, leak!
Hello, leak!
Process 77064: 192 nodes malloced for 31 KB
Process 77064: 3 leaks for 240 total leaked bytes.
STACK OF 3 INSTANCES OF 'ROOT LEAK: <malloc in greet>':
2 leak 0x104684508 main + 52 leak.c:14
1 leak 0x104684478 greet + 24 leak.c:6
====
3 (240 bytes) << TOTAL >>
1 (80 bytes) ROOT LEAK: <malloc in greet 0xb14c08050> [80]
malloc(64) なのに 80 bytes なのは、アロケータがサイズクラスに切り上げるためです。leaks は終了コード 1 を返し、free(msg); を足すと 0 leaks for 0 total leaked bytes. で終了コード 0 になりました。テストスクリプトの合否判定に使えます。leak.c:14 のような行番号を出すには -g でビルドします。
どのバグにどの道具か
| 症状・バグ | 最初に使う道具 | 実行してわかったこと |
|---|---|---|
&local を返す、未初期化ポインタ |
コンパイラ警告 -Wall -Wextra |
コンパイル時に捕まる |
| ヒープのはみ出し、解放後使用、二重解放 | -fsanitize=address -g |
読んだ・解放した・確保した行 |
| 関数から戻った後のスタック使用 | ASan+detect_stack_use_after_return=1 |
既定では無効 |
| macOS でのリーク | leaks --atExit -- ./prog |
リークありで終了コード 1。ASan は不可 |
| 実行せずにざっと確認 | clang --analyze |
8 個中 5 個を指摘 |
| 大きなプログラムのクラッシュ | lldb(第6回) |
バックトレースと変数 |
よくある間違い → 実際に起きたこと → 直し方
| 間違い | 実際に見たもの | 直し方 |
|---|---|---|
int n 個に malloc(n) |
黙ってはみ出し、終了コード 0 | malloc(n * sizeof *p) か calloc(n, sizeof *p) |
malloc の戻り値を確認しない |
NULL 参照で終了コード 139 | 毎回 if (p == NULL) |
p = realloc(p, bigger) |
失敗すると元の領域がリーク | tmp = realloc(p, ...); if (!tmp) {...} p = tmp; |
p = p->next で進みながらノードを free |
Abort trap: 6、134(練習問題2) |
free の前に next を保存 |
free 後も p を持ち続ける |
古いデータ、または後でクラッシュ | free(p); p = NULL; |
実測:ヒープ上の配列をどう伸ばすか
realloc(p, size) は領域のサイズを変え、必要なら移動します。問題は何回呼ぶかです。int を 100 万個追加する処理を3通りで、-O2、各5回の中央値、領域が実際に移動した回数(tmp != a)も数えました。
size_t newcap = strategy == 0 ? cap + 1 : (cap ? cap * 2 : 4);
int *tmp = realloc(a, newcap * sizeof *tmp);
if (!tmp) { free(a); exit(1); }
if (tmp != a) moves++;
a = tmp;
| 方式 | realloc 回数 | 移動回数 | 中央値 |
|---|---|---|---|
| 1要素ずつ伸ばす | 1,000,000 | 42 | 20.33 ms |
| 容量を倍々にする | 19 | 14 | 0.76 ms |
最終サイズで1回 malloc |
0 | 0 | 0.61 ms |
意外な点が2つ。1つずつ伸ばす方式は倍々方式の 27 倍「しか」遅くありませんでした。macOS のアロケータは 100 万回のうち 999,958 回はその場で領域を広げ、移動はわずか 42 回だったからです。これを当てにしてはいけません。glibc や Windows、組み込みのヒープではもっと頻繁にコピーが起きる可能性があります(未実行)。もう1つは、倍々方式が「最初からサイズがわかっている場合」と 25% 以内の差だったこと。std::vector や Python のリストなど、多くの可変長配列が倍々(幾何級数的)に伸びるのはこのためです。
正直な限界・バージョンの注意
- 実行は arm64 macOS 26 の Apple clang 21.0.0 のみ。Valgrind・gdb・Linux の LeakSanitizer は説明だけで未実行です。
leaksは macOS の開発者ツールの一部で、出力形式は Apple のものです。ここで動いたのは--atExitの形です。- Linux x86-64 では
-fsanitize=addressに既定でリーク検出が含まれます(未実行)。 - 解放後使用で読めるゴミの値や、二重解放のシグナル(133 か 134)は、同じマシンでも実行ごとに変わりました。この「ばらつき」こそが問題の本質です。
- ポインタの差の
%tdとsizeofの%zuは C99 から。nullptrは C23(-std=c23)で、この回はNULLと C17 を使っています。
練習問題
1. 新しいヒープ配列 {start, start+1, ..., start+n-1} を返す int *make_range(int start, int n) と、それを表示して解放する main を書いてください。ASan と leaks --atExit で確認しましょう。
2. 次のリスト解放処理は Abort trap: 6 で落ちます。理由を説明して直してください。
for (struct node *p = head; p != NULL; p = p->next)
free(p);
解答
1.
int *make_range(int start, int n) {
if (n <= 0) return NULL;
int *a = malloc((size_t)n * sizeof *a);
if (a == NULL) return NULL;
for (int i = 0; i < n; i++)
a[i] = start + i;
return a; /* 呼び出し側が free する */
}
make_range(10, 5) は 10 11 12 13 14 を表示し、ASan・UBSan は無反応、leaks は 0 leaks for 0 total leaked bytes. でした。(size_t) のキャストで、掛け算を malloc が期待する型で行い、大きな n で先に int があふれないようにしています(第1回)。
2. p = p->next は free(p) の後に実行されるので、解放済みのノードを読んでいます。ASan は heap-use-after-free ... READ of size 8 ... ex_free.c:15、freed by ... ex_free.c:16 と報告しました。8 バイトは next ポインタの大きさです。先に保存します。
while (head != NULL) {
struct node *next = head->next; /* free の前に読む */
free(head);
head = next;
}
この版は done と表示し、ASan でも問題なしでした。同じ種類の境界ケースは連結リストの記事(C++、ASan 付き)にもまとめています。
次回
第4回は文字列です。NUL 終端、strcpy だけでなく strncpy も危ない理由、sizeof と offsetof で測る構造体のパディング、そして ASan が捕まえるスタックのバッファオーバーフローを扱います。
- 前へ:C言語講座 2/6:制御文・関数・配列
- 次へ:C言語講座 4/6:文字列・構造体・バッファオーバーフロー
- シリーズの続き:未定義動作と最適化、キャッシュ・make・lldb。シリーズの最初は第1回
- 関連:スタックの C++ 実装と ASan の指摘、プログラミング学習ロードマップ
よくある質問
macOS で C のメモリリークを見つけるには?
macOS 付属の leaks コマンドを使います:leaks --atExit -- ./prog。手元のプログラムでは「3 leaks for 240 total leaked bytes」と、-g 付きなら malloc した行まで表示されました。リークがあると終了コード 1 を返すのでスクリプトにも組み込めます。AddressSanitizer のリーク検出は Apple Silicon の macOS では使えず、ASAN_OPTIONS=detect_leaks=1 は「detect_leaks is not supported on this platform」、-fsanitize=leak は Apple clang 21 がエラーにしました。
free した後のメモリを使うとどうなる?
ツールなしでは多くの場合、目に見える異常は起きません。手元では a[0]=381779559 と出た回も a[0]=7 と出た回もあり、どちらも終了コード 0 でした。そのメモリはアロケータに戻っていて、いつ再利用されてもおかしくありません。-fsanitize=address 付きなら heap-use-after-free で即停止し、読んだ行・free した行・malloc した行を示します。
同じポインタを2回 free するとどうなる?
未定義動作です。macOS 26 ではメッセージもなく、Trace/BPT trap: 5(終了コード 133)のときも Abort trap: 6(134)のときもありました。ASan なら「attempting double-free」と報告します。free の直後にポインタへ NULL を入れておくと、free(NULL) は何もしないと決まっているので安全側に倒れます。