35
27

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[入門] C/C++ malloc(0)の謎 メモリ確保の実装と挙動

Last updated at Posted at 2025-06-28

image.png

皆さんは、C言語でmalloc(0)1を呼び出したらどうなるか知っていますか?一見すると無意味に思える「0バイト2のメモリ確保」ですが、実はC標準では実装依存3として定義されており、システムによって異なる動作をします。

この記事では、malloc(0)の謎に迫り、各種実装での挙動を実際に検証しながら、なぜこのような仕様になっているのか、そしてプログラムにどのような影響を与えるのかを徹底的に解説します。

malloc(0)とは何か - そしてなぜ問題なのか

C標準の定義

C標準(C17 7.22.3)では、malloc(0)の動作について以下のように定義されています。

要求されたサイズが0の場合、動作は実装依存である」「NULLポインタを返すか、サイズが0でないかのように動作する

つまり、標準は2つの選択肢を提供しています。

  1. NULLを返す4 - メモリ確保失敗として扱う
  2. 有効なポインタを返す5 - 実際には使えないが、free()6可能なポインタ

なぜこれが重要なのか

一見些細に思えるこの違いが、以下のような場面で重要になります。

  • 動的配列の初期化 - 要素数0の配列を作成する場合
  • エラーハンドリング - NULLチェックの必要性
  • メモリリーク7 - free()を忘れた場合の影響
  • 移植性8 - 異なるシステム間でのコードの動作

歴史的背景 - なぜ実装によって動作が異なるのか

主要実装の登場時期と設計思想

malloc(0)の動作が実装依存となった背景を理解するために、主要なCライブラリとコンパイラの歴史を振り返ってみましょう。

ライブラリ / コンパイラ 開発者・企業 0バイト動作 背景
1978 Whitesmiths C Whitesmiths Ltd(P. J. Plauger) 非NULL(切り上げ確保) Unix以外で最古級の商用C
1982 Manx Aztec C Manx Software Systems NULL(古い版) 8bit/MSX向け、組込み系
1987-88 glibc 0.x Roland McGrath(FSF) 非NULL(dlmalloc系) GNU Cライブラリの誕生
1992 dmalloc Gray Watson ポリシーで選択可 デバッグ専用malloc
1993 MSVC 1.0 Microsoft DevDiv 非NULL Windows向け32bit開発

glibcの設計思想と参照元

Roland McGrathが1987年にGNU C Library(glibc)を書き始めたとき、以下の既存実装を参考にしました。

  1. BSD/SunOS系socket/selectなどのBerkeley系拡張APIの互換性
  2. fdlibm(Sun Microsystems)、IEEE 754準拠の数学関数実装
  3. dlmalloc(Doug Lea)、効率的なヒープ管理アルゴリズム

特にmalloc()実装については、Doug LeaのdlmallocをWolfram Glogerがスレッド対応に拡張したptmallocを採用し、これが「非NULLポインタを返す」設計選択の基礎となりました。

MSVCの組織的背景

一方、Microsoft Visual C++(MSVC)は、NT開発チームではなくDeveloper Divisionが主導して開発しました。

  • 1983-1992、MS-DOS向けMicrosoft C(16bit)
  • 1993、Visual C++ 1.0 32bit版(Windows NT専用)
  • 現在、Julia Liuson率いるDevDivが継続開発

MSVCも「非NULLポインタを返す」選択をしましたが、これはWindows Heap APIとの整合性を重視した結果です。

なぜ「実装依存」になったのか

1980年代から90年代初頭にかけて、異なる設計思想を持つ実装が並存していました。

  • 組込み系(Aztec Cなど)、メモリが貴重なため、0バイト要求はNULLを返してエラー扱い。現代の組み込み開発でもこの思想は継承され、malloc(0)は設計ミスとしてassertで停止させることが多い
  • Unix系(BSD、dlmalloc系)、一貫性を重視し、どんなサイズでもユニークなポインタを返す。サイズ0も「特別扱いしない」という哲学で、realloc()との組み合わせも自然に動作する設計
  • デバッグ用(dmalloc)、用途に応じて動作を切り替え可能。開発時は異なる実装の動作をエミュレートでき、移植性のテストに有用

C標準化委員会(ANSI C89/ISO C90)は、これらの既存実装を尊重し、どちらの動作も許容する「実装依存」として標準化しました。

現代の収束

2025年現在、主流の実装(glibc、MSVC、musl、jemalloc)はほぼすべて「非NULLのユニークなポインタを返す」動作に収束しています。これは、

  • 一貫性、サイズ0でも他のサイズと同じ扱い
  • 安全性、free()の呼び出しが常に安全
  • 互換性、realloc()との組み合わせが自然

という利点があるためです。

検証環境と結果

Linux環境での検証

検証環境(Linux/glibc)

項目 詳細
実行環境 GitHub Codespaces9
OS10 Ubuntu (Linux 6.8.0-1027-azure)
コンパイラ11 GCC 13.3.0
Cライブラリ12 glibc 2.35
検証日 2025年6月27日

glibc実装での検証結果

項目 実測結果
返り値 非NULLポインタ(有効なアドレス13
連続呼び出し 毎回異なるアドレスを返す
実際の割り当てサイズ 24バイト
free()の必要性 必要(メモリリーク防止)
realloc()14との互換性 完全互換

実行例(Linux)

$ gcc -o test_malloc test_malloc.c
$ ./test_malloc
malloc(0) 1回目: 0x57efbb7ad2b0
malloc(0) 2回目: 0x57efbb7ad2d0
非NULLポインタが返されました
異なるアドレスが返されました(ユニークなポインタ)
実際の割り当てサイズ: 24 バイト

Linuxでのパフォーマンス測定

=== malloc(0)のベンチマーク ===
malloc(0): 0.101 秒 (10.1 ns/op)
malloc(16): 0.100 秒 (10.0 ns/op)

Windows環境での検証

検証環境(Windows/MSVC)

項目 詳細
実行環境 Windows 11 Pro
コンパイラ Microsoft Visual C++ (cl.exe)15 19.44.35211 for x64
Cランタイム/SDK16 MSVC 14.44.35207, Windows SDK 10.0.26100.0
検証日 2025年6月28日

MSVC実装での検証結果

項目 実測結果
返り値 非NULLポインタ
連続呼び出し 毎回異なるアドレスを返す
free()の必要性 必要(メモリリーク防止)
realloc()との互換性 完全互換

ビルド方法(PowerShell)

cl test_malloc.c `
  /I "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\include" `
  /I "C:\Program Files (x86)\Windows Kits\10\Include\10.0.26100.0\ucrt" `
  /link `
  /LIBPATH:"C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\lib\x64" `
  /LIBPATH:"C:\Program Files (x86)\Windows Kits\10\Lib\10.0.26100.0\ucrt\x64"

実行例(Windows)

=== malloc(0)の検証とアドレス出力 ===
malloc(0) 1回目: 0000026FCB5C78C0
malloc(0) 2回目: 0000026FCB5C78E0
非NULLポインタが返されました
異なるアドレスが返されました(ユニークなポインタ)

Windowsでのパフォーマンス測定

=== malloc(0)のベンチマーク ===
malloc(0): 0.779 秒 (77.9 ns/op)
malloc(16): 0.728 秒 (72.8 ns/op)

重要な発見

LinuxとWindowsの両環境で、malloc(0)は非NULLのユニークなポインタを返すという共通の動作が確認されました。これは両実装とも「サイズが0でないかのように動作する」選択をしていることを示します。

デバッグ用実装 - dmallocの特殊な位置づけ

dmallocとは

dmallocは1992年にGray Watsonが開発したデバッグ専用のmalloc実装です。商用デバッガのPurifyが高価だった時代に、オープンソースの代替として誕生しました。

dmallocの特徴

特徴 説明
目的 メモリリーク、境界破壊の検出
導入方法 LD_PRELOADや静的リンクで既存mallocを置換
0バイト動作 環境変数で選択可能(NULL/非NULL)
ライセンス ISC互換(無償・商用利用可)
# dmallocを使用した実行例
LD_PRELOAD=/usr/lib/libdmalloc.so DMALLOC_OPTIONS=debug=0x41 ./a.out

dmallocは「malloc(0)の動作を実行時に切り替えられる」という特徴があり、デバッグ時に異なる実装の動作をエミュレートできます。

検証コードの詳細

基本的な検証コード

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void test_malloc_zero(void) {
    void *p1 = malloc(0);
    void *p2 = malloc(0);
    
    printf("malloc(0) 1回目: %p\n", p1);
    printf("malloc(0) 2回目: %p\n", p2);
    
    if (p1 != NULL) {
        printf("非NULLポインタが返されました\n");
        if (p1 != p2) {
            printf("異なるアドレスが返されました(ユニークなポインタ)\n");
        }
        
        // メモリ使用量を確認(Linux/glibc限定)
        #ifdef __GLIBC__
        printf("実際の割り当てサイズ: %zu バイト\n", malloc_usable_size(p1));
        #endif
        
        free(p1);
    } else {
        printf("NULLが返されました\n");
    }
    
    if (p2 != NULL) {
        free(p2);
    }
}

このコードでは、まずmalloc(0)を2回連続で呼び出しています。これにより、同じ0バイトの要求に対して、システムがどのような応答をするかを確認できます。もし同じアドレスが返されれば、システムは0バイト要求を特別扱いしている可能性があります。

p1 != NULLのチェックでは、返されたポインタがNULLでないかを確認し、さらにp1 != p2で2つのポインタが異なるアドレスを指しているかを確認しています。異なるアドレスが返される場合、malloc(0)は通常のmalloc()と同様に、呼び出しごとに新しいメモリ領域を確保していることを示します。

malloc_usable_size()はglibc特有の関数で、実際に割り当てられたメモリサイズを返します。0バイトを要求しても、メモリ管理のオーバーヘッドのため、実際にはより多くのメモリが確保されることを確認できます。

musl libcでの結果

$ musl-gcc -o test_malloc test_malloc.c
$ ./test_malloc
malloc(0) 1回目: 0x7f8b4c000020
malloc(0) 2回目: 0x7f8b4c000040
非NULLポインタが返されました
異なるアドレスが返されました(ユニークなポインタ)

メモリオーバーヘッド17の実測(Linux環境)

malloc(0)が実際にどれだけのメモリを消費するかを測定しました。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define NUM_ALLOCS 10000

void measure_overhead(void) {
    void *ptrs[NUM_ALLOCS];
    size_t before, after;
    
    // 初期メモリ使用量を取得
    before = get_memory_usage();  // 実装は省略
    
    // malloc(0)を大量に実行
    for (int i = 0; i < NUM_ALLOCS; i++) {
        ptrs[i] = malloc(0);
    }
    
    // 後のメモリ使用量を取得
    after = get_memory_usage();
    
    printf("malloc(0) %d回の総メモリ使用量: %zu KB\n", 
           NUM_ALLOCS, (after - before) / 1024);
    printf("1回あたりの平均: %.2f バイト\n", 
           (double)(after - before) / NUM_ALLOCS);
    
    // クリーンアップ
    for (int i = 0; i < NUM_ALLOCS; i++) {
        free(ptrs[i]);
    }
}

この関数では、malloc(0)を大量に実行して、実際のメモリ消費量を測定します。10,000個のポインタを配列に格納することで、後でfree()できるようにしています。

ループでmalloc(0)を10,000回実行し、各呼び出しの結果を配列に保存することで、メモリリークを防ぎつつ、実際のメモリ使用量を測定できます。総メモリ使用量を呼び出し回数で割ることで、malloc(0)一回あたりの実際のメモリ消費量を計算します。この値には、メモリ管理用のヘッダーやアラインメントのためのパディングが含まれます。

メモリオーバーヘッド測定結果(glibc)

割り当てサイズ 実際の使用メモリ 備考
malloc(0) 24バイト glibcの最小割り当て単位
malloc(1) 24バイト 同上
malloc(16) 24バイト 同上
malloc(24) 24バイト 同上

GitHub Codespacesのglibc環境では、24バイト以下のすべての割り当てで同じメモリ量が確保されることが確認されました。

free()とrealloc()の挙動

free(malloc(0))の安全性

void test_free_malloc_zero(void) {
    void *p = malloc(0);
    
    if (p != NULL) {
        // これは常に安全
        free(p);
        printf("free(malloc(0))は安全に実行されました\n");
        
        // 二重解放はNG(どんなポインタでも同じ)
        // free(p);  // エラー!
    } else {
        // NULLの場合、free()は何もしない
        free(p);  // 安全
        printf("free(NULL)は安全です\n");
    }
}

realloc()での特殊な挙動

realloc()malloc(0)の組み合わせは特に興味深い動作をします。

void test_realloc_behavior(void) {
    void *p1, *p2, *p3;
    
    // ケース1: realloc(NULL, 0)
    p1 = realloc(NULL, 0);
    printf("realloc(NULL, 0) = %p\n", p1);
    
    // ケース2: malloc(0)してからrealloc
    p2 = malloc(0);
    if (p2 != NULL) {
        p3 = realloc(p2, 10);
        printf("realloc(malloc(0), 10) = %p\n", p3);
        
        if (p3 != NULL) {
            // 10バイトのメモリが使用可能
            strcpy(p3, "Hello");
            printf("データ書き込み成功: %s\n", (char*)p3);
            free(p3);
        }
    }
}

realloc(NULL, 0)は特殊なケースです。第一引数がNULLの場合、realloc()malloc()のように動作するため、これは実質的にmalloc(0)と同じ動作をします。

次に、malloc(0)で取得したポインタをrealloc()で10バイトに拡張しています。これにより、0バイトのメモリ領域が正常なメモリ領域として扱われ、サイズ変更が可能であることを確認します。拡張後のメモリ領域に実際にデータを書き込めることを確認するため、strcpy()で文字列「Hello」(6バイト、終端文字含む)を書き込み、それが正しく読み出せることを検証しています。

実測結果(両環境共通)

=== realloc()との組み合わせテスト ===
realloc(NULL, 0) = [有効なアドレス]
malloc(0) = [有効なアドレス]
realloc(malloc(0), 10) = [有効なアドレス]
データ書き込み成功: Hello

実装例、Pythonでの対応

Pythonでは、内部的にmalloc(1)にフォールバック18します。

import ctypes
import sys

# Pythonの内部実装を模倣
def python_malloc(size):
    if size == 0:
        size = 1  # 0バイトは1バイトとして扱う
    
    # ctypesを使ってCのmallocを呼び出し
    libc = ctypes.CDLL(None)
    libc.malloc.restype = ctypes.c_void_p
    return libc.malloc(size)

# テスト
p1 = python_malloc(0)
p2 = python_malloc(0)
print(f"アドレス1: {hex(p1)}")
print(f"アドレス2: {hex(p2)}")

このPythonコードは、CPythonの内部実装を模倣しています。Pythonは0バイトの要求を1バイトに変換することで、常に有効なポインタが返されることを保証しています。

ctypesモジュールを使用して、PythonからCライブラリのmalloc()関数を直接呼び出しています。CDLL(None)は現在のプロセスにリンクされているCライブラリを参照し、restypeで戻り値の型をvoidポインタに設定しています。

ベストプラクティスと推奨事項

1. 明示的なチェックを行う

void* safe_malloc(size_t size) {
    if (size == 0) {
        // アプリケーションのポリシーに従って処理
        return NULL;  // または malloc(1) を呼ぶ
    }
    return malloc(size);
}

この関数は、malloc(0)の実装依存性を回避するためのラッパー関数です。サイズが0の場合の動作を明示的に定義することで、プログラムの動作を予測可能にします。コメントにあるように、NULLを返す代わりにmalloc(1)を呼ぶ選択肢もあり、これによって常に有効なポインタが返されることを保証できます。

2. 動的配列の初期化パターン

typedef struct {
    size_t size;
    size_t capacity;
    int *data;
} DynamicArray;

DynamicArray* array_create(size_t initial_capacity) {
    DynamicArray *arr = malloc(sizeof(DynamicArray));
    if (!arr) return NULL;
    
    arr->size = 0;
    arr->capacity = initial_capacity;
    
    // 容量0でも最小1バイト確保
    arr->data = malloc(initial_capacity * sizeof(int));
    if (!arr->data && initial_capacity > 0) {
        free(arr);
        return NULL;
    }
    
    return arr;
}

この部分では、動的配列のデータ領域を確保しています。initial_capacityが0の場合、malloc(0)が呼ばれます。重要なのは、エラーチェックでinitial_capacity > 0の条件を追加している点です。これにより、容量0の場合はNULLポインタも許容し、容量が正の値の場合のみメモリ確保失敗として扱います。

3. エラーハンドリングの統一

#define SAFE_FREE(p) do { \
    if ((p) != NULL) { \
        free(p); \
        (p) = NULL; \
    } \
} while(0)

// 使用例
void cleanup(void) {
    void *p = malloc(0);  // NULLかもしれない
    // 処理...
    SAFE_FREE(p);  // 常に安全
}

パフォーマンスベンチマーク19

ベンチマークコード

#include <time.h>

void benchmark_malloc_zero(void) {
    const int iterations = 10000000;
    clock_t start, end;
    double cpu_time_used;
    
    // malloc(0)のベンチマーク
    start = clock();
    for (int i = 0; i < iterations; i++) {
        void *p = malloc(0);
        if (p) free(p);
    }
    end = clock();
    
    cpu_time_used = ((double)(end - start)) / CLOCKS_PER_SEC;
    printf("malloc(0): %.3f 秒 (%.1f ns/op)\n", 
           cpu_time_used, cpu_time_used * 1e9 / iterations);
    
    // 比較: malloc(16)
    start = clock();
    for (int i = 0; i < iterations; i++) {
        void *p = malloc(16);
        if (p) free(p);
    }
    end = clock();
    
    cpu_time_used = ((double)(end - start)) / CLOCKS_PER_SEC;
    printf("malloc(16): %.3f 秒 (%.1f ns/op)\n", 
           cpu_time_used, cpu_time_used * 1e9 / iterations);
}

このベンチマークでは、clock()関数を使用してCPU時間を測定しています。ループ内ではmalloc(0)の呼び出しと、返されたポインタがNULLでない場合のfree()を繰り返します。これにより、メモリの確保と解放のペアの実行時間を測定できます。

測定結果を秒単位に変換し、さらに1回の操作あたりのナノ秒(ns/op)を計算しています。1e9(10億)を掛けることで秒をナノ秒に変換し、反復回数で割ることで1回あたりの時間を求めています。

各環境でのパフォーマンス特性

  • Linux(glibc)malloc(0)malloc(16)の性能差はほぼ無い
  • Windows(MSVC)、同様にmalloc(0)malloc(16)の性能差は小さい

※異なるハードウェア環境での測定のため、環境間の絶対値比較は意味を持ちません
ns/op20、1回の操作あたりのナノ秒(10億分の1秒)

まとめ

本記事では、malloc(0)の挙動について詳細に解析し、LinuxとWindows両環境で実際に動作検証を行いました。

技術的な発見(実測結果)

  1. 共通の動作 - glibcもMSVCも非NULLポインタを返す
  2. ユニークなポインタ - 連続呼び出しで異なるアドレスを返す
  3. メモリオーバーヘッド - glibcでは24バイトの実メモリを確保
  4. free()の必要性 - 両環境とも必須(メモリリーク防止)

歴史的な教訓

  • 1980年代の多様性 - Whitesmiths C、Aztec Cなど異なる設計思想が並存
  • 標準化の妥協 - ANSI C89で「実装依存」として両方の動作を許容
  • 現代の収束 - 主要実装は「非NULLポインタ」で統一

実践的な教訓

  • 明示的な処理 - size == 0のケースは明示的に扱う
  • 移植性の考慮 - 異なる実装での動作を前提にコーディング
  • メモリリークの防止 - 非NULLが返される可能性を常に考慮

推奨アプローチ

// 推奨、アプリケーション層で統一的に扱う
void* app_malloc(size_t size) {
    if (size == 0) {
        // アプリケーションのポリシーに従う
        return malloc(1);  // 常に非NULLを保証
    }
    return malloc(size);
}

malloc(0)は一見些細な問題に見えますが、システムプログラミングにおける実装の多様性標準の柔軟性を理解する良い例です。歴史的な背景を知ることで、なぜこのような仕様になったのかがより深く理解でき、より堅牢で移植性の高いCプログラムの作成に役立つことを願っています。

参考資料

  1. malloc(マロック)、C言語でメモリを動的に確保する関数。「memory allocation(メモリ割り当て)」の略。プログラム実行中に必要なメモリサイズを指定して確保できる。

  2. バイト、コンピュータのメモリやデータの大きさを表す単位。1バイト=8ビット。英数字1文字を格納するのに通常1バイト必要。

  3. 実装依存、プログラミング言語の仕様で、具体的な動作が決められておらず、コンパイラやシステムの実装者に任されていること。同じコードでも環境によって動作が異なる可能性がある。

  4. NULL(ヌル)、「何も指していない」ことを表す特別な値。メモリ確保に失敗した時などに返される。C言語では通常0として定義される。

  5. ポインタ、メモリ上の位置(アドレス)を格納する変数。例えるなら、家の住所を書いたメモのようなもの。

  6. free(フリー)、malloc()で確保したメモリを解放する関数。使い終わったメモリを返却して、他のプログラムが使えるようにする。

  7. メモリリーク、確保したメモリを解放し忘れることで、使われないメモリが徐々に増えていく問題。長時間動作するプログラムでは深刻な問題になる。

  8. 移植性(ポータビリティ)、プログラムが異なる環境(OS、コンパイラなど)でも同じように動作する性質。高い移植性は重要な品質指標。

  9. GitHub Codespaces、GitHubが提供するクラウド上の開発環境。ブラウザから使える仮想的なコンピュータ。

  10. OS(Operating System)、オペレーティングシステムの略。コンピュータを動かす基本ソフトウェア。Windows、macOS、Linuxなどがある。

  11. コンパイラ、人間が書いたプログラム(ソースコード)を、コンピュータが実行できる形式(機械語)に変換するソフトウェア。GCCはLinuxで標準的なCコンパイラ。

  12. glibc(GNU C Library)、Linux系OSで使われる標準的なCライブラリ。malloc()などの基本的な関数を提供する。

  13. アドレス、メモリ上の位置を表す番号。16進数(0x...)で表示されることが多い。

  14. realloc(リアロック)、既に確保したメモリのサイズを変更する関数。「reallocation」の略。

  15. cl.exe、Microsoftが提供するCコンパイラ。Visual Studioに含まれる。

  16. SDK(Software Development Kit)、ソフトウェア開発キット。特定の環境でプログラムを開発するために必要なツールやライブラリのセット。

  17. オーバーヘッド、本来必要な量に加えて、管理などのために追加で必要になるリソース(メモリ、時間など)のこと。

  18. フォールバック、ある処理が実行できない場合に、代替の処理に切り替えること。ここでは0バイトの代わりに1バイトを確保する。

  19. ベンチマーク、性能を測定するためのテスト。同じ処理を何度も繰り返して実行時間を計測する。

  20. ns/op(nanosecond per operation)、1回の操作にかかる時間をナノ秒で表した単位。値が小さいほど高速。

35
27
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
35
27

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?