プログラムのメモリ構造と実行ファイルの仕組み

プログラムを実行すると、実行ファイルの内容がメモリ上に展開されます。この記事では、実行ファイルの構造と、メモリ上での各領域の配置について解説します。

プログラムを書いていると「ヒープ」や「スタック」という用語を聞くことがあるでしょう。これらは具体的にメモリのどこに配置され、どのような役割を持つのでしょうか。また、実行ファイルにリソースを埋め込むとは、どこに埋め込まれるのでしょうか。

この記事では、これらの疑問に答えながら、プログラムのメモリ構造について説明します。

実行ファイルの構造

実行ファイルには、プログラムの実行に必要な情報が含まれています。ファイルフォーマットはOSやアーキテクチャによって異なりますが、基本的な構造は共通しています。

ヘッダ

実行ファイルの先頭には、ヘッダと呼ばれるメタデータが配置されています。

**UNIX/Linux系(ELF形式)**の場合、ファイルの先頭に「ELF」という文字列(16進数で 7f 45 4c 46)が含まれています。このヘッダには、対象アーキテクチャ、エントリポイント、セクションの配置情報などが記録されています。

**Windows系(PE形式)**の場合、ファイルの先頭に「MZ」という署名があり、その後にPE(Portable Executable)ヘッダが続きます。テキストエディタで実行ファイルを開くと「This program cannot be run in DOS mode」というメッセージが見つかることがあります。これはDOS互換性のための古い仕組みの名残です。

これらのヘッダは、OSがプログラムをメモリに読み込む際に参照されますが、プログラム実行中にメモリ上に保持される必要はありません。

テキスト領域(コード部)

プログラムの機械語命令が格納されている領域です。.textセクションとも呼ばれます。

この領域はメモリ上では読み取り専用かつ実行可能な属性が設定されます。これにより、プログラムコードの不正な書き換えを防ぎつつ、CPUが命令を実行できるようになっています。

複数のプロセスが同じプログラムを実行している場合、この領域は共有されることがあります(メモリの節約のため)。

データ領域

プログラムで使用する定数や初期化済みのグローバル変数が格納される領域です。実行ファイル内では、主に以下のセクションに分かれています。

.rodata(読み取り専用データ)

文字列リテラルやconstで宣言された定数など、読み取り専用のデータが配置されます。

const char* message = "Hello, World!";  // "Hello, World!"は.rodataに配置

メモリ上では読み取り専用の属性が設定され、書き込もうとするとセグメンテーション違反が発生します。

.data(初期化済みデータ)

初期値が設定されたグローバル変数や静的変数が配置されます。

int global_var = 42;  // .dataセクションに配置
static int static_var = 100;  // .dataセクションに配置

この領域は読み書き可能です。

.bss(未初期化データ)

初期値が設定されていないグローバル変数や静的変数が配置される領域です。BSSは「Block Started by Symbol」の略です。

int uninitialized_var;  // .bssセクションに配置(0で初期化される)
static char buffer[1024];  // .bssセクションに配置

実行ファイル内では、この領域は実際のデータを持たず、サイズ情報のみが記録されます。プログラム起動時にOSが必要なサイズのメモリを確保し、0で初期化します。これにより、実行ファイルのサイズを削減できます。

リソースの埋め込み

アイコンやダイアログの定義などのリソースは、実行ファイルの専用セクション(Windowsの場合は.rsrcセクション)に格納されます。バイナリエディタでnotepad.exeを開くと、ファイルの後方にXMLなどのリソースデータが埋め込まれているのを確認できます。

メモリ上の配置

プログラムが実行されると、実行ファイルの内容がメモリ上に展開されます。ただし、実行ファイルの内容がそのまま配置されるわけではなく、プログラムの実行に必要な追加の領域も確保されます。

典型的なメモリレイアウトは以下のようになります。

低位アドレス(小さいアドレス)
機械語の命令(テキスト領域)
読み取り専用データ(.rodata)
初期化済みグローバル変数(.data)
未初期化グローバル変数(.bss)
ヒープ領域
スタック領域
高位アドレス(大きいアドレス)

ヒープ領域

動的にメモリを確保する際に使用される領域です。C言語のmalloc()free()、C++のnewdelete、Pythonのオブジェクト作成などで使用されます。

ヒープ領域は低位アドレスから高位アドレス方向へ成長します。プログラムの実行中に必要に応じて拡張されます。

使用後に解放(free)しないとメモリリークが発生し、利用可能なメモリが減少します。

スタック領域

関数呼び出しやローカル変数の保存に使用される領域です。高位アドレスから低位アドレス方向へ成長します。

関数が呼び出されるたびに、以下の情報がスタックに積まれます(これを「スタックフレーム」と呼びます)。

  • 戻りアドレス(呼び出し元に戻るためのアドレス)
  • 関数の引数
  • ローカル変数
  • 保存が必要なレジスタの値

関数から戻ると、対応するスタックフレームが破棄され、スタックポインタが元の位置に戻ります。

void function() {
    int local_var = 10;  // スタック上に確保される
    // 関数終了時に自動的に解放される
}

ヒープとスタックの衝突

ヒープは下方向(高位アドレス方向)へ、スタックは上方向(低位アドレス方向)へ成長します。両者が成長しすぎて衝突すると、メモリ不足エラーが発生します。

  • ヒープオーバーフロー: ヒープ領域で過度にメモリを確保し、スタック領域に達してしまう状態
  • スタックオーバーフロー: 再帰呼び出しが深すぎる、あるいは大きなローカル変数を確保しすぎて、スタックがヒープ領域に達してしまう状態

現代のOSでは仮想メモリを使用しており、プロセスごとに独立したアドレス空間が与えられます。また、ASLR(Address Space Layout Randomization)などのセキュリティ機能により、各領域の配置はプログラム実行ごとにランダム化されることがあります。

メモリレイアウトの確認方法

Linuxでは、実行中のプロセスのメモリマップを確認できます。

# プロセスIDが1234の場合
cat /proc/1234/maps

また、objdumpreadelfコマンドで実行ファイルのセクション情報を確認できます。

# セクション一覧の表示
objdump -h ./a.out

# 詳細なセクション情報
readelf -S ./a.out

まとめ

プログラムのメモリ構造を理解することは、メモリ効率の良いプログラムを書くため、また、セグメンテーション違反やメモリリークなどの問題をデバッグするために重要です。

  • テキスト領域: 機械語命令が格納される(読み取り専用、実行可能)
  • .rodata: 読み取り専用データ(文字列リテラルなど)
  • .data: 初期化済みグローバル変数
  • .bss: 未初期化グローバル変数(実行時に0初期化)
  • ヒープ: 動的メモリ確保に使用(下方向に成長)
  • スタック: 関数呼び出しとローカル変数に使用(上方向に成長)

OSやアーキテクチャによって詳細は異なりますが、基本的な構造は共通しています。


参考: