数値や文字などのデータは,プログラムの実行中には, コンピュータのメモリの内部に記録されている. 今回は,データがメモリ内のどこに/どのように記録されるのか? メモリマップ(memory map)について理解しよう.
高品質なプログラムを作成できるようになるには, コードの書き方だけでなく, コンピュータの仕組みを理解しておく必要がある.
まず,メモリ内にデータがどんな形式で記録されるのか? 概要を理解しよう.
プログラムが取り扱うすべての種類のデータは, メモリ(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 進数として表示している.
ソース 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); }
コンパイル・実行例:
$ 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 で昇順(小→大)に並べ替えてみた.
この実行結果は,次の表のようなメモリマップの状態を意味している.
アドレス範囲 | 変数名 |
---|---|
0x 7fff c690 c520 〜 c527 | c |
0x 7fff c690 c528 〜 c52b | b |
0x 7fff c690 c52c 〜 c52e | 空き |
0x 7fff c690 c52f | a |
なお,アドレス範囲の末尾は,各変数のデータサイズから算出される. データサイズは,sizeof演算子を利用し, sizeof(型) とか sizeof(データ) によって確認できる. char型は 1 byte, int型は 4 byte, double型は 8 byte となる. ソースコードを変更して確かめてみると良いだろう.
ところで,今回のプログラムでは,アドレスを表示しているが, これは,メモリマップについて学習するためだけの措置だ. 実用的なプログラミングでは, アドレスを表示するようなことは,ほとんどない. 具体的なアドレスは,ユーザが知らなくても, コンピュータさえ知っていれば充分だ.
しかし,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 に回答し,電子メールで提出せよ.
注意事項:
なお,当たり前だが,このページに載っている実行結果ではなく, 自分の実行結果を報告すること.
何KB or 何MB 程度であるか? どのように推計したか? また,実行結果もコピペすること.
なお,1KB = 1024B,1MB = 1024KB とする.
メールの送信形式を必ず「テキスト形式 or プレーンテキスト」に変更せよ.