メモリ管理1(メモリマップ)

数値や文字などのデータは,プログラムの実行中には, コンピュータのメモリの内部に記録されている. 今回は,データがメモリ内のどこに/どのように記録されるのか? メモリマップ(memory map)について理解しよう.

教科書の該当範囲:第7章

高品質なプログラムを作成できるようになるには, コードの書き方だけでなく, コンピュータの仕組みを理解しておく必要がある.

これまでのような表面的な理解 「こう書けばこうなる」とか「動いたからこれでいいのだ」 だけでは満足しないこと. これからは内面的な仕組みまで推理すること. そうすれば「この書き方だとなぜ動かないのか?」 自力で間違いを発見・解決できるようになる.

メモリマップの基礎

データの形式

まず,メモリ内にデータがどんな形式で記録されるのか? 概要を理解しよう.

プログラムが取り扱うすべての種類のデータは, メモリ(RAM: random access memory)内に 整数値(2進数,非負)として記録される. 各データ型のメモリ内での記録形式は次の通りである:

メモリ内でのデータの実体は,型によらず,すべて2進整数である. ただし,人間は2進数を取り扱うのが苦手なので, Cではそれを 10進数や 16進数や文字として表現し, ソースファイルに記述したり,画面に表示できるようになっている.

ここで,2種類の表現方法... 記録形式(メモリ内部でのデータの「実体」)と 表示形式(画面上でのデータの「外見」) との間の違いに注意しよう.

たとえば,型変換(double)123 では, 整数 123 から実数 123.0 へデータの実体を変更しており, それに伴い,外見も "123.0" や "1.23e+02" 等に変わることになる.

この例では,記録と表示の間に,何か関連があるように見えるかもしれないが...

一方,表示処理printf("%c", 65) では, 整数 65 を画面上で文字 'A' に見せかけるだけであり, メモリ内の整数 65 を文字 'A' に書き換えているわけではない. 実体は変わらず 65 のままであり,外見だけが A に変わる.

とにかく,見かけ(画面上の情報の表現)にダマされるな!! 中身(メモリ内の情報の実体)を想像するんだ.
そもそも,コンピュータの内部では,すべてのデータは数値として取り扱われる. しかし,そのままでは,ユーザにとって理解しづらい. そこで,端末が要る訳だ. 端末が,数値に応じた文字を画像や音声に変換して外部へ出力してくれているんですよ. ユーザは端末がないとコンピュータ上で何もできませんよね? この素晴らしき端末との連携処理についても後日説明予定.
変数の配置

さて,大抵のプログラムでは,複数個の変数を利用することになるだろう. そこで,どの変数のデータがメモリ内のどの位置に記録されるのか? 具体的に理解しよう.

なお,ここからは,利用可能なメモリの全体をメモリ空間, その内の一部分をメモリ領域と呼んで区別しよう. メモリ空間には, 1 byte 毎に番号(メモリアドレス;memory address)が 連続的に割り振られている. メモリ領域は, アドレス範囲(先頭アドレスとデータサイズ, または,先頭アドレスと末尾アドレス)により特定されることになる.

想像してみよう. 同サイズのダンボール箱がたくさん,倉庫に収納されている. また,各箱には通し番号が付けられ,番号順に並べられている. これをメモリに例えると...

変数データについては, アドレス演算子&」で先頭アドレスを調べられる. 次のプログラムでは, さまざまな変数のアドレスを 16 進数として表示している.

なぜ,わざわざ 16 進数で? 各データのメモリ使用量が 4バイトや 8バイトなので, 2n を単位として数える方が好都合だからだ. (もちろん,この n は正の整数ね.)

ソース map-1.c:(メモリマップの確認用プログラム)

#include <stdio.h>

int main(void)
{
	char	a;
	int	b;
	double	c;

	printf("(アドレス) : (変数名)\n");
	printf("%p : a\n", &a);	// 変数a のアドレス&a を表示
	printf("%p : b\n", &b);	// 変数b のアドレス...
	printf("%p : c\n", &c);	// 変数c のアドレス...

	return (0);
}
えーと,「%p」とか「&」については, 以前にも使いましたよ.

コンパイル・実行例:

$  cc  map-1.c  -o  map-1

$  ./map-1
(アドレス) : (変数名)
0x7fffc690c52f : a
0x7fffc690c528 : b
0x7fffc690c520 : c

ここで,アドレス値は時と場合と処理系によって異なる. (異なる結果になっていても気にしなくてよい.)

ちなみに,複数回実行すると,アドレスが微妙に変化する. (変化しない場合もある.) これで,アドレスの決定は,プログラム側ではなく, 処理系(OS)側によってなされていることが確認できる. (この授業では,アドレス変化の規則性などについては,気にしなくてよい.)

なお,アドレスの数値はゴミではない. 勘違いしないこと. (「意味不明な値=ゴミ」とは限らない.)

ゴミとは初期化されていない変数の値のことなんだが, ここでは,変数の「値」ではなく,変数の「アドレス」を表示しているし.

次に,アドレスの順序を整えてみよう:

$  ./map-1 | sort
(アドレス) : (変数名)
0x7fffc690c520 : c
0x7fffc690c528 : b
0x7fffc690c52f : a

ここでは,プログラムmap-1 の実行結果を,画面表示する前に, unixコマンドsort で昇順(小→大)に並べ替えてみた.

なお,宣言の順序とアドレスの順序とが異なっても気にしなくてよい. 上述の通り,処理系依存なので.

この実行結果は,次の表のようなメモリマップの状態を意味している.

map-1 のメモリマップ表
  アドレス範囲    変数名  
0x 7fff c690 c520 〜 c527c
0x 7fff c690 c528 〜 c52bb
0x 7fff c690 c52c 〜 c52e空き
0x 7fff c690 c52fa

なお,アドレス範囲の末尾は,各変数のデータサイズから算出される. データサイズは,sizeof演算子を利用し, sizeof(型) とか sizeof(データ) によって確認できる. char型は 1 byte, int型は 4 byte, double型は 8 byte となる. ソースコードを変更して確かめてみると良いだろう.

メモリマップでは,空き領域の存在にも注意しよう. 「メモリが隙間なく利用される」とは限らない. また,空き領域と思っていたら, 実は何か他の意外なデータに利用されているかも知れない.

ところで,今回のプログラムでは,アドレスを表示しているが, これは,メモリマップについて学習するためだけの措置だ. 実用的なプログラミングでは, アドレスを表示するようなことは,ほとんどない. 具体的なアドレスは,ユーザが知らなくても, コンピュータさえ知っていれば充分だ.

ユーザに通知するのは, 異常動作通知「0xXXXX 番地でメモリ保護例外が発生しました」等, 非常に特殊な場合だけ. しかも,普通のユーザにとっては,そんなこと通知されても, 対処のしようがない.

しかし,C言語のプログラマたる者は, 常に正常動作するプログラムを作成するために, 脳内にメモリマップをイメージする必要がある. 後で試すように,メモリ内容は簡単に破壊可能だからだ.

書き換えたつもりのないデータが, プログラムの不具合(== プログラマの不注意)によって, 簡単に書き換えられてしまう.
配列の配置

メモリマップのイメージが特に重要になるのは, 配列を使う場合だ. 次のプログラムを実行してみよう.

ソース map-2.c:(配列のメモリマップの確認用プログラム)

#include <stdio.h>

#define N 3

int main(void)
{
	char	a[N];
	int	b[N];
	double	c[N];
	int	i;

//	printf("(アドレス) : (変数名)\n");	// sort の邪魔になるので削除 or 無効化
	for (i = 0; i < N; i++) {
		printf("%p : a[%d]\n", &a[i], i);
		printf("%p : b[%d]\n", &b[i], i);
		printf("%p : c[%d]\n", &c[i], i);
	}
	printf("%p : i\n", &i);

	return (0);
}

実行例:

$  ./map-2 | sort
0x7ffd2ef27a0c : i
0x7ffd2ef27a10 : c[0]
0x7ffd2ef27a18 : c[1]
0x7ffd2ef27a20 : c[2]
0x7ffd2ef27a30 : b[0]
0x7ffd2ef27a34 : b[1]
0x7ffd2ef27a38 : b[2]
0x7ffd2ef27a40 : a[0]
0x7ffd2ef27a41 : a[1]
0x7ffd2ef27a42 : a[2]

実行結果をよく観察し,メモリマップ表を作成してみよう. 同じ配列の配列要素は要素番号の順に隙間なく連続的に 配置されている,ということがわかるハズだ.

異なる配列・変数のメモリ領域の間には隙間ができる場合もある.

複数回実行してみると, アドレスの絶対値は変化するかもしれないが, 相対値は変化しない. つまり,配列要素のメモリ領域の連続性は常に保たれる.

関数呼出による配置変化

ローカル変数(関数内で宣言されている変数)は, その関数が呼び出されるたびに生成され, その関数から戻るたびに消滅する. つまり,使用されるメモリの範囲は, プログラムの実行中に伸縮することになる. このメモリスタックについて確認してみよう.

ソース stack.c:(メモリスタックの確認プログラム)

#include <stdio.h>

void sub1(void)
{
	int	x;

	printf("%p : x@sub1\n", &x);
}

void sub2(void)
{
	int	y;

	sub1();
	printf("%p : y@sub2\n", &y);
}

void sub3(void)
{
	int	z;

	sub2();
	printf("%p : z@sub3\n", &z);
}

int main(void)
{
	sub1();
	printf("-----\n");
	sub2();
	printf("-----\n");
	sub3();

	return (0);
}								

実行例:

$  ./stack
0x7ffeb2f847ec : x@sub1
-----
0x7ffeb2f847cc : x@sub1		# あれれ?
0x7ffeb2f847ec : y@sub2		# おやおや?
-----
...

ここで,1〜3回目の x@sub1 は, 一見,すべて同じ変数かと思いきや, 異なるアドレスに割り当てられており, 実は,互いに別物となっている.

一方,x@sub1 と y@sub2 とは, 一見,異なる変数かと思いきや, 実は,同じアドレスに割り当てられている.


メモリマップの注意事項

バッファオーバラン

C言語では,プログラマの権力は絶大であり, メモリ内容を破壊し, プログラムを異常動作させることも簡単にできてしまう... 当たり前だが,わざとやってはいけません. しかし,意図せずとも,わずかな不注意によって, 重大な欠陥が混入してしまうこともよくある.

度々,銀行や空港などのシステム障害が重大ニュースとして報じられている. つまり,プログラムの異常動作は,人の資産や生命に危険を及ぼしかねない. この責任の重大さを理解した上で,学習に打ち込もう.

したがって,一見うまく動作しているように思えても, 本当に正しく動作しているとは限らない. メモリ関連のありがちなバグ(bug;不具合の原因)を体験してみよう.

まさか,いないとは思うが... 「プログラマのミスをコンピュータが自動的に直してくれる」 なんてことは,絶対にありえない.期待しないこと. 異常動作のすべては,プログラマの責任だ. 「プログラムは思い通りには動かない. 書いた通りに動く.」 正しい書き方を学んで行こう. (ただし今回は,まず先に,間違いについて学ぶ.)

バッファオーバランは, メモリ関連のバグの代表例である. 配列要素への不適切な代入によって, 意図しないメモリ領域の内容が変更されてしまう.

バッファオーバランについては,すでに, こことかここでも試しています.

バッファオーバランによるメモリ破壊について実験してみよう.

ソース map-3.c:(バッファオーバランによるメモリ破壊の実験プログラム)

#include <stdio.h>

int main(void)
{
	int  a[3] = { 10, 11, 12 };
	int  b[3] = { 20, 21, 22 };
	int  c[3] = { 30, 31, 32 };
  	int  i;

//	b[4] = 99999;	// バグなコード

	for (i = 0; i < 3; i++) {
		printf("a[%d] = %d\n", i, a[i]);
		printf("b[%d] = %d\n", i, b[i]);
		printf("c[%d] = %d\n", i, c[i]);
	}
//	printf("%p : i\n", &i);

	return (0);
}								

このまま,コンパイルし,実行:

$  ./map-3 | sort
a[0] = 10
a[1] = 11
a[2] = 12
b[0] = 20
b[1] = 21
b[2] = 22
c[0] = 30
c[1] = 31
c[2] = 32

これは,いたって普通の動作だ.

では次に, 8行目のコメント化されているコード(// b[4] = 9999;)を 有効化(行頭のコメント記号「//」を削除)し, コンパイルし,実行:

$  ./map-3 | sort
a[0] = 99999	# えっ!?
a[1] = 11
a[2] = 12
b[0] = 20
b[1] = 21
b[2] = 22
c[0] = 30
c[1] = 31
c[2] = 32

なお,書き換わる変数は必ずしも a[0] とは限らない.

疑問:

原因を推理しよう.

要素番号を変えて実行し, どうなるか試してみるとよい. b[3] = 99999, b[5] = 99999 とか, b[-2] = 99999 や b[10000] = 99999 等についても.

もし,何も表示されない場合,sort を使わずに実行してみよう. 実行時エラー(Bus error や Segmentation fault)で異常終了したことがわかる.

スタックオーバフロー

メモリ空間は有限であるため, 関数呼出に伴うメモリスタックの拡大にも限度がある. 限度を超えてしまうとスタックオーバフローの実行時エラーが発生し, プログラムは強制終了となってしまう.

スタックオーバフローについても, すでに紹介していました.

関数の再帰呼出を利用して, スタックオーバフローを体験してみよう.

ソース stkover.c:(スタックオーバフローの体験プログラム)

#include <stdio.h>

void func(void)
{
	int	a[65536];	// ローカルな配列
		// メモリ使用量:65536*4 byte
		// = 256*256*4 B = 256*1024 B = 256 KB

	printf("%p\n", a);	// 先頭アドレス
	func();	// 終了条件なしで再帰(無限再帰)
		// ...本来なら終了条件が必要
}

int main(void)
{
	func();
	return (0);
}

本日の課題

レポートを提出せよ.

質問 Q1〜Q4 に回答し,電子メールで提出せよ.