はじめに
先日ツイッターで見かけた呟き
— TOMO (@tomozh) October 14, 2020
そういうこともあるのか的な反応もあるようなので具体例を挙げてみることにする。
例1
所謂FizzBuzz問題。
#include <stdio.h>
void fizzbuzz(int n)
{
int next;
int i = 1;
do {
printf(i % 15 ? i % 5 ? i % 3 ? "%d\n" : "Fizz\n" : "Buzz\n" : "FizzBuzz\n", i);
if (i++ >= n) next = 0;
} while (next);
}
int main(void)
{
printf((char[]){""}); // この位置にprintfが無いとなぜか動かない
fizzbuzz(100);
}
gcc 10.1.0 を使用し、コンパイラが感知したミスには警告を出力してくれる様 -Wall -Wextra を指定し、最適化指示なしでコンパイル、動作させたところ動作するが
Wandboxで実行
main() の中の何も出力していない printf() をコメントアウトすると 100 まで出力しなくなった。
Wandboxで実行
どちらの場合もコンパイル時に警告は出力されなかった。
解説
変数 next が未初期化の状態で参照されており未定義動作を踏んでいる1。
printf() が呼ばれると動作している風なのは偶々の結果でありプログラムが正しい訳ではない。
例2
所謂FizzBuzz問題。
#include <stdio.h>
int main(void)
{
char buf[/*十分な大きさ*/256] = "", *p = buf;
char spec[] = "1から100までの数をプリントするプログラムを書け。\nただし3の倍数のときは数の代わりに「Fizz」と、\n5の倍数のときは「Buzz」とプリントし、\n3と5両方の倍数の場合には「FizzBuzz」とプリントすること。\n";
printf(spec); // この位置にprintfが無いとなぜか動かない
for (int i = 1; i <= 100; i++) {
p += sprintf(p, (const char*[2][2]){{"%d\n", "Fizz\n"}, {"Buzz\n", "FizzBuzz\n"}}[i % 5 == 0][i % 3 == 0], i);
}
printf("%s", buf);
}
gcc 10.1.0 を使用し -Wall -Wextra を指定、最適化指示 -O2 でコンパイル、動作させたところ動作するが
Wandboxで実行
main() の中で本来的に必要ない筈のFizzBuzz問題のルールの出力を行っている printf() をコメントアウトすると異常終了するプログラムとなった。
Wandboxで実行
printf() をコメントアウトした場合に限り
prog.c:6:10: warning: unused variable 'spec' [-Wunused-variable]
spec が使用されていないという警告だけが出力されるが、異常終了の直接の原因を示唆するものではない。
解説
1~100 までの FizzBuzz の結果を格納する buf は/*十分な大きさ*/
と白々しいコメントがあるにも関わらず領域が足りておらずバッファオーバーランを起こしている2。
printf() が呼ばれると動作している風なのは偶々の結果でありプログラムが正しい訳ではない。
結論
この手の不具合で多いのは先の 2例に共通している結論である「printf() が呼ばれると動作している風なのは偶々の結果でありプログラムが正しい訳ではない」という場合が多い。コンパイラの警告機能も万全のものではない。自分の書いたコードを信じてはいけない。
おわりに
おわりです。
-
何が起こっているかの解説の要望があったので追記。例1 は最適化指示なしでコンパイルしており、C言語の仕様には決められていないが gcc のコード生成の特性として fizzbuzz() の中の未初期化変数 next はスタックに割り付けられることを期待している。C言語の仕様には決められていないが動作環境によりプログラム開始時にスタック領域がゼロクリアされていれば未使用のスタック領域に割り当てられた変数は明示的な初期化がなくとも初期値 0 で動作することを期待している。そのため main() 中に printf() が無い場合は fizzbuzz() 中の未初期化の変数 next は偶々初期値が 0 となりループが機能しない。対して、main() 中に printf() があった場合は printf() の呼び出しと printf() 内部でのスタック領域の使用でスタック領域が適当に「汚される」ことを期待している。fizzbuzz() 中での next が「汚された」スタック領域に割り当てられ初期値が 0 以外となることを期待しており、ループは機能することとなる。以上は C言語の仕様に拠るものではなく特定のコンパイラやコンパイルオプション、動作環境に依存したものであり、実際に何が起こっているかはコンパイラが生成したコードや OS のソースにまで確認をしなければ正確なことは断言できるものではないが今回はそこまでは行っていない。C言語は高級アセンブラではなく抽象化された高級言語であり、抽象化された部分の内部を覗こうというのは褒められた使い方ではない。通常は「未初期化の変数により未定義動作を踏んだ」以上に踏み込む必要があるものではない。 ↩
-
例2 では本文で書いた通り buf の領域が足りずバッファオーバーランを起こす。バッファオーバーランで破壊する領域が壊して良い領域であれば一見動作には影響は現れない。例2 の場合 buf はスタック領域に割り当てられており、更にその後ろに spec が配置されるとすると printf(spec) した後であれば spec は無用の領域となるので多少のバッファオーバーランでそこを破壊したところで問題は表面化しない。main() 中から printf(spec) を削除した場合、例2 のプログラムは最適化指示 -O2 でコンパイルしているためそれ以外には参照されていない spec はコンパイラの最適化機能により不要なオブジェクトと判断され削除されるためスタック上に配置されなくなる。C言語では main() も普通の関数でありスタートアップルーチンから呼ばれるのが通常で exit() 等で終了しない限りはスタックに積まれたリターンアドレスに従い呼ばれた元へ復帰動作を行い終了処理を行うのが普通だが、buf の直後に spec が無くスタック上のリターンアドレスがバッファオーバーランにより上書きされた場合、正常な復帰動作が行えずおかしなアドレスへ戻ろうとしてしまう。現代の OS とプロセッサでは安全対策として不正なアドレスでのプログラムの実行には対策が採られており多くの場合実行エラーとなる。 ↩