メモリ管理1:メモリマップ

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

教科書・参考書の該当範囲:なし

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

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

データの形式

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

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

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

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

たとえば,型変換(double)65 では, 型を整数から実数へ変えているので, 記録形式も表示形式も変わることになる.65.0 等に. この例では,記録と表示の間に,何か関連があるように見えるかもしれないが...

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

とにかく,見かけ(画面上の情報)にダマされるな!! 中身(メモリ内の情報)を想像するんだ.

データの配置

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

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

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

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

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

ソース 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>

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

//	printf("(アドレス) : (変数名)\n");
	for (i = 0; i < 3; 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]

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

異なる配列の間には隙間ができる場合もある.

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


メモリ破壊

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

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

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

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

メモリ関連のバグは,特に,配列を使用している場合に遭遇しやすい. map-3 を実行してみよう.

ソース 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] = 99999b[5] = 99999 とか, b[-2] = 99999b[10000] = 99999 等についても.

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


関数呼出とメモリマップ

関数内で宣言される変数のメモリ領域のアドレスは, その関数が呼び出されるたびに, 自動的に更新される. これを確認してみよう.

ソース 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 とは, 一見,異なる変数かと思いきや, 実は,同じアドレスに割り当てられている.


本日の課題

  1. map-2 の実行結果について,メモリマップ表を作成せよ.
  2. 注意事項:

    • アドレス範囲(先頭〜末尾)については昇順(小 → 大)に隙間なく示すこと.
    • すべての配列要素(a[0]a[1],等) それぞれのメモリ領域を明示すること.
    • 空き領域およびカウンタ変数 i についても明示すること.
    • 表の罫線は不要.

    なお,当たり前だが,このページに載っている実行結果ではなく, 自分の実行結果を書くこと.

  3. (上級者用問題)map-3 の不具合について,原因を推理・実証せよ.
  4. なぜ,b[4] に書き込んだデータが他の変数に書き込まれてしまうのか? また,そもそもなぜ,存在しないハズの変数 b[4] に書き込めてしまうのか? さらに,他の配列要素,たとえば b[5] や b[-2] 等へ書き込むとどうなるか? etc.

    アドバイス:メモリマップを使って考えよう. 面倒がらずに map-3.c を改造し,各変数のメモリアドレスも表示してみるとよい. そして,b[4] のアドレスも予想すること.

提出:


(c) 2018, yanagawa@kushiro-ct.ac.jp