constはどこへ消えた?:未定義動作によるバグの解析

constはどこへ消えた?:未定義動作によるバグの解析

プログラムのコンパイルや実行の過程を理解できないと、プログラムを書くときに難しいバグに遭遇する可能性があります。 私はとある深夜に、後輩から呼び出されました。

事件はこう始まりました。

怪しいバグ

深夜12時57分32秒、後輩がプログラムの実行結果のスクリーンショットを送ってきました。

寝不足だろうと思ったけど

この配列の最初の要素の0×12が、なぜ0×30になったのだろう

そしてコードも送りつけてきました。

#include <stdio.h>

int getH4(char ch)
{
    ch = ch & 0xf0;
    ch >>= 4;
    return (int) ch;
}

int getL4(char ch)
{
    ch = ch & 0x0f;
    return (int) ch;
}

char hex_table[16] = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'};

int main(int argc, char* argv[])
{
    int len = 5;
    const char  src_str[5] = {0x12, 0x13, 0x15, 0x19, 0x00};
    char  dst_str[9];
    int i, j;

    for(i = 0, j = 0; i < len; i++){
        dst_str[j] = hex_table[ getH4(src_str[i]) ];
        j = j + 1;
        dst_str[j] = hex_table[ getL4(src_str[i]) ];
        j = j + 1;
    }

    for(i = 0; i < len; i++){
        printf("%02X ", src_str[i]);
    }
    printf("\n");

    printf("%s\n", dst_str);

    printf("%02X%02X%02X%02X%02X\n", src_str[0], src_str[1], src_str[2], src_str[3], src_str[4]);

    return 0;
}

ついでに実行時のスクリーンショットも:

$ gcc -o test test.c
$ ./test
30 13 15 19 00 
1213151900
3013151900

パッと見て不思議だけど、少し落ち着いてみたらすごくわかりやすいバグなので、おいらの後輩なら簡単に解決できるでしょうと、寝てしまいました。

その次の日朝10時17分25秒、またスクリーンショットが送られてきました。 今回はgccのバージョンまで変えて、同じ結果が出てました。

コンパイラまで変えたら、もうコードの問題だと判断できるでしょう。しかも今日一日中忙しいので、また放置になりました。

夜17時30分07秒、後輩がgccのバグだと思うと言いました。gdbでデバッグしてみたら、途中の値が変わっていたそうです。

やるしかないか…

DEBUG

こんな分かりやすい間違い、デバッグなんていらないだろう。

プログラム読み

まずこのプログラムを読んでみましょう。

#include <stdio.h>

よく見えてるコード、問題ないでしょう。

int getH4(char ch)
{
	ch = ch & 0xf0;
	ch >>= 4;
	return (int) ch;
}

int getL4(char ch)
{
	ch = ch & 0x0f;
	return (int) ch;
}

これは関数名が示すように、これら2つの関数は、バイトの上位4ビットと下位4ビットを取得するために使用されます。

char hex_table[16] = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'};

この配列は16進数の文字テーブルを保存していて、これから16進数の変換プログラムでもあるだろう。

int main(int argc, char* argv[])
{
	int len = 5;
	const char  src_str[5] = {0x12, 0x13, 0x15, 0x19, 0x00};
	char  dst_str[9];
	int i, j;

ようやくmain関数に到達しました。 まず、lenを定義し、初期化に5を指定します。ここには問題ないはず。 次に長さ5のsrc_str配列で、lenはおそらくこの配列の長さです。 この配列をconstとして明示的に宣言するので、変更されるのを恐れているようですね。 次にdst_str配列があり、長さは9で、16進数に変換された文字列のようです。 最後に、i j変数、おそらくループ用ですね。

	for(i = 0, j = 0; i < len; i++){
		dst_str[j] = hex_table[ getH4(src_str[i]) ];
		j = j + 1;
		dst_str[j] = hex_table[ getL4(src_str[i]) ];
		j = j + 1;
	}

ここはすべてのアルゴリズムだろう、ループでやっているようです。 特に問題がないようですね、元の配列から高い4ビットと低い4ビットを取得し、16進数の文字テーブルに変換し、目的の配列に保存します。

	for(i = 0; i < len; i++){
		printf("%02X ", src_str[i]);
	}
	printf("\n");

	printf("%s\n", dst_str);

	printf("%02X%02X%02X%02X%02X\n", src_str[0], src_str[1], src_str[2], src_str[3], src_str[4]);

問題ありそうもない出力です。

	return 0;
}

定番のリターンですね。

こんな簡単なプログラム、どう見ても問題がないでしょう。 でもホントに問題がないのでしょう?

問題:const変数はホントに変えられないのだろう

被害者の供述によると、何もやってないのになぜかconst変数が変わってしまったそうです。 const変数は変更できないので、これは不可能なことだと被害者が言っていました。

でもconstはホントに変えられないっていう意味でしょうか?

まずC言語の仕様書を見てみましょう。

6.5.3 Type qualifiers

Syntax

type-qualifier := const | volatile

Constraints

The same type qualifier shall not appear more than once in the same specifier list or qualifier list, either directly or via one or more typedefs.

Semantics

The properties associated with qualified types are meaningful only for expressions that are lvalues.

If an attempt is made to modify an object defined with a const-qualified type through use of an lvalue with non-const-qualified type, the behavior is undefined. If an attempt is made to refer to an object defined with a volatile-qualified type through use of an lvalue with non-volatilequalified type, the behavior is undefined.

An object that has volatile-qualified type may be modified in ways unknown to the implementation or have other unknown side effects. Therefore any expression referring to such an object shall be evaluated strictly according to the rules of the abstract machine. as described in 5.1.2.3. Furthermore. at every sequence point the value last stored in the object shall agree with that prescribed by the abstract machine, except as modified by the unknown factors mentioned previously. What constitutes an access to an object that has volatile-qualified type is implementation-defined.

If the specification of an array type includes any type qualifiers, the element type is soqualified, not the array type. If the specification of a function type includes any type qualifiers, the behavior is undefined.

For two qualified types to be compatible. both shall have the identically qualified version of a compatible type: the order of type qualifiers within a list of specifiers or qualifiers does not affect the specified type.

6.5.3 型修飾子

構文

type-qualifier := const | volatile

制約事項

同じ型修飾子は、同じ指定子リストまたは修飾子リスト内で、直接または1つ以上のtypedefを介して、1度以上現れてはなりません。

意味論

修飾された型に関連付けられたプロパティは、左辺値として使用される式に対してのみ意味があります。

const修飾型で定義されたオブジェクトを、非const修飾型の左辺値を使用して変更しようとすると、動作は未定義です。volatile修飾型で定義されたオブジェクトにアクセスしようとするために非volatile修飾型の左辺値を使用しようとすると、動作は未定義です。

volatile修飾型のオブジェクトは、実装には未知の方法で変更されたり、他の未知の副作用を持つ可能性があります。したがって、このようなオブジェクトを参照する任意の式は、5.1.2.3で説明され> ている抽象マシンの規則に厳密に従って評価されなければなりません。さらに、各シーケンスポイントで、オブジェクトに最後に格納された値は、前述の未知の要因によって変更されない限り、抽象マシンによって指定されたものと一致しなければなりません。volatile修飾型のオブジェクトへのアクセスを構成するものは、実装に依存します。

配列型の仕様に型修飾子が含まれている場合、要素型がそのように修飾され、配列型は修飾されません。関数型の仕様に型修飾子が含まれている場合、動作は未定義です。

2つの修飾された型が互換性を持つためには、両方が互換性のある型の同じ修飾されたバージョンを持っていなければなりません。指定子または修飾子のリスト内での型修飾子の順序は、指定された型> に影響を与えません。

staticexternとは異なり、constvolatileは記憶域クラス指定子ではなく型修飾子です。 constの意味は、その変数はプログラムによって変更されないを示すことであり、volatileの意味は、その変数は未知の方法で変更される可能性があることを示すことであります。

仕様書にはこのような例もあります。

extern const volatile int real_time_clock

この宣言では、constvolatileの両方が同時に使用されていますが、両者は矛盾していません。 constは、その変数はプログラムによって変更されないことを示しています(等号の左側には置かれません)、volatileは、その変数は未知の方法で変更される可能性があることを示しています。 つまり、この変数は外部デバイスによって変更される可能性がありますが、プログラムは変更してはいけません。

この被害者が遭遇した問題に戻って、たしかに彼は配列をconst charと宣言しましたが、これは単にプログラムがこの変数を等号の左側に置いたり、インクリメントデクリメント演算子を使用して変更したりしないことを意味します。 しかし、文法仕様では次のようにも述べています。

const修飾型で定義されたオブジェクトを、非const修飾型の左辺値を使用して変更しようとすると、動作は未定義です。

つまり、もし変数(ポインタ)がこのconst変数のアドレスを参照している場合、上書きされる可能性があります。

この考えに沿って、プログラム中でこのconst変数のメモリアドレスを指すポインタが見つかり、その内容が変更されたと考えられます。 だから、このポインタを見つけるだけでいいはず…

なぜかプログラムにポインタがないような気がします🤔

配列の範囲外アクセス

コードを確認したところ、このconst変数を参照したポインタはありませんでした。 そして、もしあっても、おそらく最初に警告が出るはずです。

端緒がなくなったようです。

戻って、他の手がかりを探してみましょう。なぜ0x30になったのですか? この数字には何か意味がありますか?

ASCIIテーブルを調べると、この数字は0という文字のASCIIコードです。 では、いつこの文字0を書き込んだのでしょうか?

待って、生成された目的の文字列には、この文字0が含まれているようです。

この考えに沿って、C言語では文字列とポインタには言い表せないような関係があるようです。 では、目的の文字列に書き込んだコードを見てみましょう。

for (i = 0, j = 0; i < len; i++) {
	dst_str[j] = hex_table[getH4(src_str[i])];
	j = j + 1;
	dst_str[j] = hex_table[getL4(src_str[i])];
	j = j + 1;
}

パッと見て問題なさそうです。iは0からlenまで、jは0から始まり、2ずつ増え、5回ループした後、9に増えるはずです。 そして、dstの定義を見てみましょう。

char dst_str[9];

問題はなさ…そ…う

待って、この配列の長さは9ですが、dst_str[0]からdst_str[9]までアクセスしています。 これは10個の要素を書いているのではありませんか? しかし、このdstはどうしてsrc_str[0]に書き込んだのですか?

さて、これらのローカル変数のアドレスを出力してみましょう。

変数名 アドレス サイズ
len 0x7fff5bad2c9c 4
src_str 0x7fff5bad2c97 5
dst_str 0x7fff5bad2c8e 9
i 0x7fff5bad2c88 4
j 0x7fff5bad2c84 4
argc 0x7fff5bad2c7c 4
argv 0x7fff5bad2c70 8

これで、src_strのアドレスがdst_strのアドレスよりも高いことがわかります。 したがって、dst_str[9]にアクセスすると、実際にはsrc_str[0]のアドレスにアクセスします。

そしてこれが今回のバグの原因となります。

ケースクローズ

これは配列の範囲外アクセスによるエラーです。 偶然でもあって、使用されているコンパイラは、src_strのアドレスをdst_strの後ろに置いたため、dst_str[9]に書き込むと、つまりsrc_str[0]のアドレスに書き込みます。

以上、本件は解決しました。

ちなみに、このプログラムには他の配列の範囲外アクセスエラーがまだありますが、もっと隠れています。

またちなみに、この同僚のコードを研究室のほかの学生にデバッグさせてみましたが、全員が陣没しました。