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つの修飾された型が互換性を持つためには、両方が互換性のある型の同じ修飾されたバージョンを持っていなければなりません。指定子または修飾子のリスト内での型修飾子の順序は、指定された型> に影響を与えません。
static
とextern
とは異なり、const
とvolatile
は記憶域クラス指定子ではなく型修飾子です。
const
の意味は、その変数はプログラムによって変更されないを示すことであり、volatile
の意味は、その変数は未知の方法で変更される可能性があることを示すことであります。
仕様書にはこのような例もあります。
extern const volatile int real_time_clock
この宣言では、const
とvolatile
の両方が同時に使用されていますが、両者は矛盾していません。
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]
のアドレスに書き込みます。
以上、本件は解決しました。
ちなみに、このプログラムには他の配列の範囲外アクセスエラーがまだありますが、もっと隠れています。
またちなみに、この同僚のコードを研究室のほかの学生にデバッグさせてみましたが、全員が陣没しました。