低レベルプログラミング(No.07:スタック領域とヒープ領域)

使用ツール:Visual Studio Community 2019
使用言語 :C言語(C#も少し)

スタック領域とヒープ領域

変数や配列等は、スタック領域やヒープ領域に配置される事でプログラムから利用する事ができる様になります。

今回はスタック領域とヒープ領域について確認していきたいと思います。

スタック領域

まずは、スタック領域について見て行きたいと思います。

スタック領域に格納される情報

これまでの投稿で扱ってきた様にスタック領域には、下記の様な情報が格納されていきます。

  • 関数内で宣言される変数・配列
  • 呼び出し元BP(ベース・ポインタ)の値(アドレス)
  • 呼び出し元PC(プログラム・カウンタ)の値(アドレス)

プログラム内に複数の関数が宣言されていても、予めスタック領域にそれらの関数内の変数などが配置される訳ではなく、メイン関数から呼び出されたり、他の関数から呼び出された際に、順番にスタック領域に必要な領域が確保され(スタックされ)て行く構造になっていました。

スタック領域は汚い

「汚い」というのは語弊があるかも知れませんが、いつもきれいな状態ではないという意味です。

下記の様なプログラムを考えてみます。

#include <stdio.h>

// func1
void func1()
{
	int a[3] = {1, 2, 3}; // a[0] = 1, arr[1] = 2, arr[2] = 3で配列を初期化

	int sum = 0;
	for(int i = 0; i < 3; i ++)
	{
		sum += a[i];
	}
	
	printf("func1 : sum = %d\n", sum);
}

// func2
void func2()
{
	int c[3] = {5, 4, 3};  // c[0] = 5, c[1] = 4, c[2] = 3で配列を初期化

	int sum = 0;
	for(int i = 0; i < 3; i ++)
	{
		sum += c[i];
	}
	
	printf("func2 : sum = %d\n", sum);
}

// func3
void func3()
{
	int b[3]; // 配列の初期化なし
	
	for(int i = 0; i < 3; i ++)
	{
		printf("func3 : b[%d] = %d\n", i, b[i]);
	}
}

// メイン関数
void main()
{	
	// func1()の呼び出し
	func1();
	
	// func3()の呼び出し
	func3();

	// func2()の呼び出し
	func2();
	
	// func3()の呼び出し
	func3();
}

3~15行目のfunc1関数は関数内の配列要素を{1,2,3}で初期化された配列の総和を求める関数です。

17~29行目のfunc2関数は、func1関数と処理内容は同じですが、配列要素を{5,4,3}で初期化しています。

31~40行目のfunc3関数は、初期化されていない配列要素の値を出力する関数です。

42~56行目のmain関数は、下記の順番で関数を呼び出しています。

  1. func1関数
  2. func3関数
  3. func2関数
  4. func3関数

func1関数を呼び出せば総和「6」(=1+2+3)を表示し、func2関数を呼び出せば総和「12」(=5+4+3)を表示するでしょう。ではfunc3関数を呼び出した時に、初期化されていない配列要素はどのような値が表示されるでしょう?すべての要素が「0」になるでしょうか?

コンパイルして実行してみると以下の様になります。

C:\Test\LLP_007>sample_007_1.exe
func1 : sum = 6
func3 : b[0] = 1
func3 : b[1] = 2
func3 : b[2] = 3
func2 : sum = 12
func3 : b[0] = 5
func3 : b[1] = 4
func3 : b[2] = 3

func1関数を呼び出した後にfunc3関数を呼び出すと初期化されていない配列b[]は要素が{1,2,3}になっています。また、func2関数を呼び出した後にfunc3関数を呼び出すと初期化されていない配列b[]は要素が{5,4,3}になっていて、呼び出すたびに値が異なっています

同じ事を実行させたい関数なのに、呼び出すたびに意図した動作にならず、結果が異なるのはプログラムとしては困ります。(バグの原因になったりします)

なぜ、このような事が起きるのかは、スタック領域の状態を考えれば理解できます。

まず、「/FA」オプションを付けて生成したアセンブリコードの各関数の変数・配列に関する記述を確認すると以下の様になっています。

【func1関数の変数・配列についての記述】

_sum$ = -24						; size = 4
_i$1 = -20						; size = 4
_a$ = -16						; size = 12
__$ArrayPad$ = -4					; size = 4

【func2関数の変数・配列についての記述】

_sum$ = -24						; size = 4
_i$1 = -20						; size = 4
_c$ = -16						; size = 12
__$ArrayPad$ = -4					; size = 4

【func3関数の変数・配列についての記述】

_i$1 = -20						; size = 4
_b$ = -16						; size = 12
__$ArrayPad$ = -4					; size = 4

関数内の変数・配列の格納アドレスははBP(ベース・ポインタ)からの相対位置になりますので、各関数の配列、a[], c[], b[]はBPから「-16」と同一の場所になる事が分かります。

スタック領域の実行時の状態を確認すると下記の様になります。

①func1関数実行時

func1関数が呼ばれた際に、初期化コードも実行されるため配列a[]の要素はコードに記載されている値が格納されます。

func1関数の処理が実行され、main関数へ処理が戻った時のスタック領域は、func1関数実行時の値がそのまま残っています

②func3関数実行時func1関数実行後

func3関数が呼ばれた際には配列b[]の領域は初期化されないため、func1実行時の値が格納された様になっています。

このため、func3関数の配列b[]の要素を出力すると、func1関数の配列a[]の値が出力されます

③func2関数実行時

func2関数が呼ばれた際に、初期化コードも実行されるため配列c[]の要素はコードに記載されている値が格納されます。

そのため、配列c[]の総和を求めるコードは正しく動作します。

func2関数の処理が実行され、main関数へ処理が戻った時のスタック領域は、func2関数実行時の値がそのまま残っています

④func3関数実行時(func2関数実行後)

func3関数が呼ばれた際には配列b[]の領域は初期化されないため、func2実行時の値が格納された様になっています。

このため、func3関数の配列b[]の要素を出力すると、func2関数の配列c[]の値が出力されます


上記の様に、スタック領域は関数が呼び出される度に必要な量が確保され(必要な分だけBPが移動し)、関数の処理が終わると解放されます(呼び出し前のBPが復元される)。しかし、要になった領域のデータはそのまま残されます。

関数内の変数、配列などを初期化しないで利用すると、スタック領域上の古い・意図しないデータ(値)を元に処理が実行され、意図しない結果、動作になってしまう可能性があります。

最近のコンパイラーは変数が初期化されていないと、警告が出たり、コンパイルエラーになる場合がありますが、こういう事が起きない事を防ぐためでしょう。

結論として『変数は必ず初期化して利用しましょう!』となります。

スタック領域のサイズに注意

スタック領域のサイズは有限でMSVCコンパイラでコンパイルした際はデフォルトで1MBです。(参照:https://learn.microsoft.com/ja-jp/cpp/build/reference/f-set-stack-size?view=msvc-170

スタック領域を使い切ると…

「Stack Overflow(スタック・オーバーフロー)」エラー

が発生します。

スタック領域は、関数呼び出しや、関数内の変数などで消費されていくので、関数の中らか関数を呼び出し、更にその中で関数を呼び出して…という様に関数呼び出しが深くなったり、関数内で大きな配列を利用すると、Stack overflowエラーが発生します。

故意にStack Overflowエラーを引き起こすために、下記の様なコードを用意しました。

#include <stdio.h>

// 再帰呼び出し関数
long rcSum(long v)
{
	long tmp[1024];
	tmp[1023] = v;
	
	if(v <= 0)
	{
		return 0;
	}
	
	printf("v = %8ld\n", v);
    // 再起呼び出し
	long sum = v + rcSum(v-1);
	
	return sum;
}

// メイン関数
void main()
{	
	long sum = rcSum(1000);
	
	printf("sum = %ld\n", sum);
}

3~19行目のrcSum関数は1~指定の値までの総和を再帰呼び出しを利用して求める関数です。配列tmp[]はこの関数が呼び出される毎にスタック領域を4KB(1024 x 4)を消費するためのものです。

21~27行目のmain関数はrcSum関数の引数に1000を渡して、1~1000までの総和を求めるものです。

rcSum(1000);を実行すると、rcSum関数が呼び出され、その中からrcSum(999)が呼ばれ、更にその先からrcSum(998)が呼ばれ…とスタック領域にrcSum関数用の領域が積まれていきます。

rcSum関数が一回呼ばれる度に、配列tmp[]分(4KB)のスタック領域が消費されるので、約250回くらいで1MBのサイズのスタック領域が枯渇する予想です。

コンパイルして実行してみると下記の様な結果になります。

C:\Test\LLP_007>sample_007_2.exe
v =     1000
v =      999
v =      998
v =      997
 :
(省略)
 :
v =      754
v =      753
v =      752

C:\Test\LLP_007>

C:\Test\LLP_007>echo %=exitCode:~-8%
C00000FD

248回目(1000 – 752)でプログラム終了してしまいます。プログラムの終了コードを調べると『0xC00000FD』で、これはStack Overflowのエラーコードとなるので、予想通りの動作となりました。

また、関数内のtmp[]配列のサイズを262,144(1M / 4)以上にすれば、一回呼び出しただけでも、Stack Overflowエラーとなります。

コンパイラのオプションでスタック領域のサイズを増やすという解決策もありますが、それは根本的な解決にはなりません…。

結論として『大きなサイズの配列などはヒープ領域に確保!』『再起呼び出しなど呼び出し階層数に注意!』となります。

ヒープ領域

このシリーズの中では、これまであまりヒープ領域は出てきませんでしたが、大きなサイズの配列等を確保する場合などはヒープ領域を利用します。

ヒープ領域でのメモリの確保

ヒープ領域にメモリを確保するには、下記の様にmalloc関数でヒープ領域にメモリを確保し、使い終わったらfree関数でメモリを解放します。

	int* hpArr;
	
	// hpArrのメモリの確保
	hpArr = (int *)malloc(1000 * sizeof(int));
	
	// hpArrの処理
	for(int i = 0; i < 1000; i++)
	{
		hpArr[i] = i;
	}
	
	// hpArrのメモリの解放
	free(hpArr);

ヒープ領域にメモリを確保する事で、スタック領域はそのメモリに対するポインタ変数のみ確保すればよくなるので、スタック領域の枯渇の心配は相当低減されます。

malloc関数の引数は確保するメモリをbyte単位で指定するので、(確保したいサイズx変数の型のサイズ)を指定しています。

また、malloc関数の戻り値はvoid*型なので、利用したい変数の型(のポインタ)に変換(キャスト)します。

ヒープ領域のサイズ

スタック領域のサイズは1MBでしたが、ヒープ領域のサイズはどれくらいになるでしょうか?

動作させるパソコンのCPUやOS、コンパイラの種類によって扱えるメモリ空間が異なります。

コンパイラ種別32bit CPU/OS64bit CPU/OS
32bit向け最大約4GB最大約4GB
64bit向け最大約16EB(エクサバイト)

32bit / 64bitはレジスタのサイズでもありますが、パソコンのCPUとメモリなどを接続するバス幅でもあります。

32bit向けに作成されたプログラムでは、32bit(4byte)で扱える最大の値が0xFFFF_FFFF(=4,294,967,295)となるので、メモリのアドレスで指定できる範囲が0~4,294,967,295(4GB)となり、それがアクセス可能なメモリ空間となります。

64bit向けに作成されたプログラムでは、64bit(8byte)で扱える最大の値が0xFFFF_FFFF_FFFF_FFFF(=18,446,744,073,709,551,615)となるので、メモリのアドレスで指定できる範囲が0~18,446,744,073,709,551,615(16EB = 16,000PT[ペタバイト])となり、それがアクセス可能なメモリ空間となります。

※64bit版Windowsでは、実際には16EBまでは扱えないらしいですが、それでも、通常のプログラムでは使いきれない程広大なメモリ空間になります。

ヒープ領域は、上記のメモリ空間からプログラム領域やスタック領域などを除いた部分になります。

プロセスの仮想メモリ空間とメモリ空間

さて、ここまで書くと『いやいや、自分のパソコンは16GBしかRAMを積んでないから、それ以上メモリは使える訳はない』と言われそうですが、上記のサイズはあくまでもプロセスの理論上のサイズになります。

実際に利用できる全体のメモリのサイズは、パソコンに踏査されている物理メモリ(RAM)のサイズと、スワップ領域としてストレージ(HDD, SSDなど)に確保されているサイズの合計になります。

EXEなどの実行ファイルを起動するとシステムからプロセスとして実行されます。プロセスはプロセスごとに固有のメモリ空間が割り当てられますが、それは仮想的なメモリであるため、プロセスが実際に動作するためには実メモリが必要となります。プロセス自体が扱える仮想メモリ空間は広大ですが、実際に利用するメモリサイズは実際には僅かであり、その利用している部分がシステムにより、物理メモリに確保されます。

WindowsなどのOSでは、複数のプロセスが動作しており、それらが利用するメモリを積み上げていくと物理メモリのサイズを超える場合があります。その場合は、物理メモリの中で現在利用されていない(動作していない)プロセスのメモリをストレージのスワップ領域に退避させます。退避させたメモリ内容が必要になれば、物理メモリを空けて物理メモリ側に移動させます。

(お断り)メモリ管理部分はうろ覚えな部分もあり、間違った記載もあるかも知れませんので、ご了承ください。

物理メモリとストレージ間でメモリ内容を交換する処理をスワッピングなどと呼ばれますが、物理メモリのアクセス速度に対してストレージのアクセス速度は圧倒的に遅いので、スワッピングが発生するほど物理メモリがひっ迫するとパソコンがまともに動作しなくなるほどパフォーマンスが低下するので、いくらでもメモリを確保できるからと言ってもメモリの使いすぎにも注意が必要です。

メモリ不足

スタック領域が枯渇すると「Stack Overflow」エラーが発生しましたが、ヒープ領域が枯渇すると「メモリ不足」エラーとなるでしょうか?

答えとしてはNOです。

ヒープ領域上にメモリを確保するmalloc関数などが、メモリ確保に失敗してNULLを返すだけです。

なので、プログラマがmalloc関数の戻り値のNULLチェックを行い、プログラム内でメモリ不足の判定とその対応を行う必要があります

実際に自分のPCでどれだけのメモリを使用したらメモリ不足になるかを確認するために、以下の様なプログラムを用意しました。(古いOSだと、システムが落ちて再起動するような内容ですが…)

#include <stdio.h>
#include <malloc.h>

#define DELTA (1024 * 1024) // 1MB
// メイン関数
void main()
{
	void * ptr = NULL;
	unsigned int allocSize = DELTA;
	
	// メモリ確保に失敗するまでメモリサイズを変更してメモリを確保し続ける
	do {
		// メモリ確保
		ptr = malloc(allocSize); 
		if(ptr != NULL)
		{
			// 確保したメモリを解放
			free(ptr);
			// 次に確保するメモリサイズの更新
			allocSize += DELTA;
		}
	} while(ptr != NULL);
	
	printf("メモリ確保最大サイズ : %u (byte)\n", allocSize);
}

malloc関数に与える引数(確保メモリサイズ)をmallocに失敗するまで1MBずつ増やしていき、mallocに成功する最大のメモリサイズを表示するという内容になっています。

このプログラムをcl.exeでコンパイルすると32bit環境向けの実行ファイルが作成されますので、理論値4GB付近まで確保できるかを確認します。(malloc関数の引数はsize_t型(unsigned int)なので、関数としても4GBが最大です)

C:\Test\LLP_007>sample_007_4.exe
メモリ確保最大サイズ : 1961885696 (byte)

私のパソコン上では、2GB弱が限界でした。

実際の所はよくわかりませんが、符号有り整数(int)の最大値(= 0x7FFFFFFF)が上限になっているのかも知れません。

メモリ解放に関する注意点

C言語ではヒープ領域に確保したメモリは、プログラマの責任で解放する必要があります。

解放がプログラマの責任になるので、誤った使い方をすると問題が発生するので注意が必要です。

メモリ・リーク

C言語でヒープ領域のメモリを確保して使った後は、プログラマが責任を持ってfree関数で解放してやる必要があります。

malloc時の動作
free時の動作

free関数を忘れると、システムが該当の領域はまだ利用中のメモリ領域として確保し続け、いわゆる「メモリ・リーク」となります。

誰も使わないメモリ領域なのに確保されたままとなり、利用可能なメモリサイズが減少してしまいます。

特に長時間稼働し続けるプログラムの場合、例え数バイトのメモリ・リークでも時間経過とともに積み重なり、いずれプログラムが利用可能なメモリを確保できなくなってしまいます。

メモリ保護違反

free関数で解放したメモリ領域にアクセスしようとするとメモリ保護エラー(エラーコード:0xC0000005)が発生します。

まだ利用するはずのメモリ領域は、誤ってfree関数で解放しない様にしなければなりません。

デバッグの難しさ

メモリリークは長時間実行しないと判明しなかったり、メモリ保護違反は特定の動作をした時だけ発生するなど、原因を調査するのが困難な場合が多いです。

メモリ管理(確保・解放)は一つの関数内で閉じているなど小規模なプログラムでは管理しやすいですが、大規模なプログラムやプロジェクトになると、『呼び出した先で確保したメモリは呼び出し元で解放しなければならない』という様な仕様だったり、利用したりする関数の仕様が見落とされて解放漏れが起きたり、『xxxの関数を呼び出して解放する事。勝手に解放してはならない』という様な仕様が見落とされたりして、メモリ保護違反が起きたりなど、管理が相当煩雑になってしまうため、バグが入り込みやすくなります。

C#言語との違い

Visual C#言語などでは、newでメモリを確保しても、C言語のfree関数に当たるものはありません。

private void method()
{
   // int型配列の確保
   int[] intArr = new int[10];
  
   // int[] intArrの解放??
}

これはC#で作成した実行ファイルが、共通言語ランタイム(CLR)上で動作するプログラムとして作成されるためです。

※共通言語ランタイム(CLR)は、メモリ管理機能、セキュリティ、例外処理など様々な機能を提供します。(JAVA言語に対するJVM(Java Virtual Machine)の様なものになるかと)

共通言語ランタイム(CLR)上で動作するプログラムがメモリを確保しようとした場合、イメージとして以下の様な動きになります。(※細かい部分は間違っているかも知れません)

メモリ確保時の動作

メソッドが終了時など、確保していたメモリを参照する変数が使われなくなった場合、その段階でメモリを解放するのではなく、参照カウンタを減らすという処理を行います。

メモリを参照する変数が使用されなくなった時の動作

実際にメモリが解放されるのは、Gabage Collection(ガベージ・コレクション)が実行される際で、この時、参照カウンタが0のメモリ(誰も参照していない、今後使われる事のないメモリ)が解放対象メモリとなります。

ガベージ・コレクション実行時

上記の様に、C#ではプログラムから明示的にメモリを解放しなくても適切にメモリが解放されるので、メモリ・リークや、解放してしまったメモリへのアクセスによるメモリ保護エラーを気にしなくて良くなります。

(実際には、メモリ解放タイミングが遅くなるので、メモリリークに似た動きになる場合や、「相互参照」などがあると実際のメモリリークは発生したりしますが…)

C#などで、.NET framworkのプログラムを開発していると、「Managed」(管理された)/「Unmanaged」(管理されていない)などの語句に出会う事がありますが、C#で作成されたコードがCLRにより管理された「Managed 」コードとなり、Cで作成されCLR上で動作しないコードがCLRで管理されない「Unmanaged」コードとなります。

今回のまとめ

今回は、スタック領域とヒープ領域について確認しました。(だいぶ、長くなりましたが…)

C#などのCLR上で動作するプログラムを作成する場合は、あまりスタック領域を意識する必要はないかと思いますが、C言語でプログラムを作成する必要がある場合は多少意識する必要があります。

また、大きなサイズの配列などは、ヒープ領域で使うべきという事も確認しました。

ヒープ領域については、利用可能な領域サイズは開発環境や動作させるパソコンのOSによって異なる事や、C言語でのプログラム開発における解放処理に留意する必要がある点を確認しました。

普段プログラミングを行う際に、この辺りを意識する事はないかも知れませんが、不具合発生時には、この辺りの知識があると助かる事もあるかと思います。

次回の予定

次回は、TIPS的なものを予定しています。

前の記事次の記事
No.06:条件分岐No.08:TIPS(C)