05 月 31 日(火)3-4h

文字配列のメモリマップ

文字配列は,文字変数の配列であるとともに, 文字列を記録するための変数でもある. 文字列データがどのようにメモリに格納されるのかを理解しよう.


文字列のメモリマップ

まず,List 1 を利用して,文字列のメモリマップを確認してみよう. このプログラムの実験では, 異なる文字列を同じ文字配列に2回入力し, メモリ内容の変化を観察する.

List 1. 文字列のメモリマップの確認 str-1.c
/* 文字配列のメモリマップを表示
 p:文字配列の先頭アドレスへのポインタ
 n:文字配列の要素数
*/
void PrintStrMem(char *p, int n)
{
	int  i;

	printf("文字列 = \"%s\"\n\n", p);
	printf("  アドレス    変数名   文字   数値\n");
	for (i = 0; i < n; i++) {
		printf("0x%0X : 要素[%d] = '%c' = %4d\n", p, i, *p, *p);
		p++;
	}
}

main()
{
	char  str[10];

	printf("\n[1 回目の入力]\n文字列(9 文字以内,長めに) > ");
	scanf("%s", str);
	PrintStrMem(str, 10);

	printf("\n[2 回目の入力]\n文字列(9 文字以内,短めに) > ");
	scanf("%s", str);
	PrintStrMem(str, 10);
}

実行例:

$ ./str-1

[1 回目の入力]
文字列(9 文字以内,長めに) > program

文字列 = "program"

  アドレス    変数名   文字   数値
0xBF9ADECE : 要素[0] = 'p' =  112	# 入力文字列
0xBF9ADECF : 要素[1] = 'r' =  114
0xBF9ADED0 : 要素[2] = 'o' =  111
0xBF9ADED1 : 要素[3] = 'g' =  103
0xBF9ADED2 : 要素[4] = 'r' =  114
0xBF9ADED3 : 要素[5] = 'a' =   97
0xBF9ADED4 : 要素[6] = 'm' =  109
0xBF9ADED5 : 要素[7] = '' =    0	# 終端記号
0xBF9ADED6 : 要素[8] = '�' =  -99	# 以下,ゴミ
0xBF9ADED7 : 要素[9] = '�' =  -65

[2 回目の入力]
文字列(9 文字以内,短めに) > C

文字列 = "C"

  アドレス    変数名   文字   数値
0xBF9ADECE : 要素[0] = 'C' =   67	# 変化部分
0xBF9ADECF : 要素[1] = '' =    0	# 終端記号
0xBF9ADED0 : 要素[2] = 'o' =  111	# 以下,1回目から変化なし
0xBF9ADED1 : 要素[3] = 'g' =  103
0xBF9ADED2 : 要素[4] = 'r' =  114
0xBF9ADED3 : 要素[5] = 'a' =   97
0xBF9ADED4 : 要素[6] = 'm' =  109
0xBF9ADED5 : 要素[7] = '' =    0
0xBF9ADED6 : 要素[8] = '�' =  -99	# ゴミも変化なし
0xBF9ADED7 : 要素[9] = '�' =  -65

この結果から次のことがわかる:

ここで,文字列末尾の数値 0終端記号と呼ばれ, 文字列とゴミとの境界を知るために重要なデータである. この記号1文字が配列要素1個分のスペースを占有することになるので, 文字配列には用意した要素数よりも短い文字列しか記録できないことになるわけだ. (逆に,ある文字列を記録するには, それよりも1文字分以上広いメモリ領域が必要になる.)

このため,char str[10]; と宣言しても, 配列 str には,10 文字ではなく,9文字しか代入できない, ということになるんだ.

なお,終端記号の数値 0 は,文字として「表示」しても, 実行結果の画面上では「非表示」'' であった. 一方,これをソースコードに文字として「記述」したい場合には, エスケープ系列 '\0' を使えばよい. (後で実験する.)

文字列」と「文字」との違いに注意せよ.

文字列データの場合,たとえば,char str[] = "abc"; とすると, str[0] は 'a',str[1] は 'b',str[2] は 'c' となり, str[3] は自動的に '\0' になる. このように文字列では,終端記号は自動的に追加される.

一方,同じことを文字データで書こうとすると, str[] = {'a', 'b', 'c', '\0'}; のように, 終端記号を手動で明記する必要がある.

詳しくは,次のセクションで確かめる.


文字配列の初期化・書き換え

List 1 では,文字列データを実行時に入力した. 今度は,文字列データをソースコードに記述して, 文字配列を初期化してみよう. List 2 では,文字配列 str[10] を使い, 文字列 "program" を 別の文字列 "info" に書き換えようとしている.

List 2. 文字配列の初期化・書き換えのテスト str-2.c
main()
{
	char  str[10] = "program";	// 宣言と同時に初期化
	printf("str = \"%s\"\n", str);

	str[0] = 'i';		// 1文字ずつ書き換え
	str[1] = 'n';
	str[2] = 'f';
	str[3] = 'o';
//	str[4] = '\0';		// 終端記号の必要性を確認

//	str = "info";		// これは無理

	printf("str = \"%s\"\n", str);
}

実行例:

str = "program"
str = "inforam"		# 何か変だ

List 2 のままでは,文字列 "info" の末尾に終端記号が不足している. 変更して再実行しよう:

str = "program"
str = "info"
二重引用符「"」で囲まれている文字列データの場合, その末尾には(見えないが)自動的に終端記号が追加されている. 一方,文字を組み合わせて文字列を作る場合, 手動で終端してやる必要がある. (上のセクションでも説明済み... でも重要なことなので2回書きました.)

ちなみに,List 2 の配列の宣言では, 次の書き方でも良さそうに思えるが, これは間違い:

char  str[10];
str = "program";	// エラー

このエラーの理由については,後日解説予定.


文字列長計算プログラム

前回の練習問題では, 文字列の長さを調べるために ライブラリ関数 strlen( ) を利用した. ここまでの知識を動員すれば, strlen() の利用方法だけでなく, その内部の仕組みまでも理解できるハズだ. このライブラリ関数のクローンを作ってみよう. List 3 を実行し,ソースコードを解読してみよう.

List 3. 文字列長計算プログラム strlen.c
/* ライブラリ関数 strlen() のクローン
  引数 p:文字列の先頭アドレス(文字列へのポインタ)
  戻り値:文字数
*/
int mystrlen(char *p)
{
	int n = 0;		// 文字数

	while (*p != '\0') {	// 終端記号まで繰り返す
		n++;			// 文字数をカウント
		p++;			// 次の文字へ移行
	}
	return (n);		// 文字数を返す
}

/* メイン関数 */
main()
{
	char str[256];

	// 文字列の入力
	printf("文字列(255 文字以内) > ");
	scanf("%s", str);

	// 文字数の計算・表示
	printf("%d 文字\n", mystrlen(str));	// mystrlen() と strlen() の結果は...
	printf("%d 文字\n", strlen(str));	// 同値のハズ
}

文字列入力とメモリ破壊

前々回にも実験した通り, C言語ではプログラマの不注意によって, メモリ破壊とそれに伴うシステム異常とを簡単に実現できてしまう. 今回は文字列データについて,これを再び体験する.

今回も,注意を喚起するために,わざと失敗を経験してみるテストだ. 本来ならば,メモリ破壊を引き起こすようなコードを書いてはいけない.

文字配列に配列要素数よりも長い文字列を入力するとどうなるかを List 4 のプログラムで試してみよう.

List 4. メモリ破壊の実験 mm-4.c
main()
{
	char a[4] = "ana";
	char b[4] = "jal";
	char c[4] = "hac";

	// 配列 a, b, c の表示
	printf("\n[初期値]\n");
	printf("a = %s\n", a);
	printf("b = %s\n", b);
	printf("c = %s\n", c);

	// 配列 b だけの入力
	printf("\n[入力]\n");
	printf("文字列 b(3 文字以内)> ");
	scanf("%s", b);

	// 配列 a, b, c の表示
	printf("\n[入力後]\n");
	printf("a = %s\n", a);
	printf("b = %s\n", b);
	printf("c = %s\n", c);
}

入力文字数を3文字以内から3文字以上へと順次増加しながら, 何度も実行し,結果を観察しよう. 文字配列 b に記録されるハズの入力データが他の配列に記録されたり, プログラムが実行時エラー(segmentation fault や bus error) によって強制終了されたりする. この実験結果を理解するには,メモリマップを考える必要がある. (各自で考えよう.)

なお,この例のような配列要素数以上のデータ入力によるメモリ破壊は, バッファオーバーフロー(buffer overflow)とか バッファオーバーラン(buffer overrun) と呼ばれている.

バッファオーバーフロー等の不具合の原因となり得ることから, 世間では「scanf を使うのは良くない」と考えられている. 最近の実際的なプログラムでは,他の安全な入力方法を利用しようとしている.

しかし,scanf は簡単なので, この授業では「必要悪」として,使い続けることにする. なお,不完全だが簡単な予防策として, 文字配列を使う場合,要素数を大きめに (例:256 文字とか 1024 文字とかに) 設定しておこう. 必要最小限ギリギリの設定だと,オーバーフローの危険性が高い. または,scanf("%9s", str); とかやれば, 入力文字数を9文字に制限できる.

上級者向け参考文献:


本日の課題

文字配列 str[] の先頭から文字数 n の部分文字列を残し, n 文字以降を破棄する関数 left(char str[], int n) を定義せよ.

ソースコード断片:lett.c

left(char str[], int n)
{
	...		// 定義せよ.
}

// テスト用メイン関数:変更禁止
main()
{
	char  str[256];
	int   n;

	printf("文字列 > "); scanf("%s", str);
	printf("文字数 > "); scanf("%d", &n);

	printf("元の文字列:%s\n", str);

	left(str, n);

	printf("部分文字列:%s\n", str);
}

実行例:

$  ./left
文字列 > information
文字数 > 4
元の文字列:information
部分文字列:info

ヒント:

安全対策なしの事故発生例:

$  ./left
文字列 > information
文字数 > -123456			# 負の大きな値
元の文字列:information
Segmentation fault (コアダンプ)	# 失敗

$  ./left
文字列 > information
文字数 > 123456			# 正の大きな値
元の文字列:information
Segmentation fault (コアダンプ)	# 失敗

安全対策例:

$  ./left
文字列 > information
文字数 > -123456			# 変な値の場合
元の文字列:information
部分文字列:information				# 何もしない
レポート提出 注意事項: 以下の点についても厳しくチェックする:


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