久しぶりにプログラミング関連の話題です。
つい先日友人からC言語で質問がありまして、なぜかprintf関数を入れると正しく動くけどコメントアウトするとバグるというかなり不思議な不具合でした。
スタックの問題だろうなと思ったものの、久しぶりにC言語を触ったのでなかなか理由が説明できませんでした。
いろいろと検証したところ自分なりに原因がわかったものの、高級言語が主流な現代では割と起こりやすい問題だと思ったので備忘録がてら記事を書くことにしました。
私自身メモリの扱いは正しく理解できていないのでおかしなところがあれば指摘していただけると幸いです。
検証環境
項目 | 内容 |
---|---|
OS | Windows 10 Pro x64 / macOS |
CPU | Core i5 4590 / Core i7 5650U |
コンパイラ | gcc 7.3.0 |
具体的な問題
友人から貰ったプログラムは再帰呼び出しを頻繁に行うもので例にはあまりならなさそうだったので検証で作成したプログラムを載せます。
func1関数のやりたいことは負数なら正数に戻して返すという簡単なプログラムです。
ただ、バグを再現するためにfunc2や文字で表現したり無駄な処理を多く入れています。
#include <stdio.h> int func2() { char c = '-'; return c; } int func1(int value) { char c; // cの初期値は'-'以外を期待する、通常は0を期待すると思う //printf("%p: %d\n", &c, c); // これを外すと1回目も2回目もアドレスが同じことがわかる if (value < 0) c = func2(); if (c == '-') return value * -1; return value; } int main() { int i1 = func1(-2); // printf("%d\n", i1); // ① int i2 = func1(2); printf("%d\n", i1); // 期待値: 2 printf("%d\n", i2); // 期待値: 2 }
これを実行すると結果はどちらも2となるはずですが、最後だけ-2になると思います。
原因
初期化忘れ
表面的な原因だと初期化忘れが原因です。
func1ではchar cを宣言していますが、初期化されていません。
C言語ではよく言われる話ですが、初期化しない場合はその変数に何の値が入るかわかりません。
なので偶然cには'-'が入っていて最後だけ正数なのにマイナスをかけてしまったのです。
なのでchar c = 0;のように初期化すればこの問題は解決します。
ローカル変数の割当方法とスタック領域
この問題は初期化すれば解決するのですが、本題はなぜ'-'が入ってしまったのか、です。
偶然入っているだけなら毎回'-'が入るのはちょっと謎です。
ちなみに①のコメントアウトを外すと正しい結果に変わります。
実はこの問題の根本はスタック領域の動きとローカル変数の表現方法にあります。
C言語ではローカル変数の表現にスタック領域を使用するというのは有名な話だと思いますが、コンパイルされた後のアセンブリではC言語のようなローカル変数というものは存在しません。
どのように表現されているかというと、スタックの特定の領域に型のサイズだけ値を書き込み、そこから型のサイズ分読み込むことで変数を表現しています。
_func2: LFB12: .file 1 "source.c" .loc 1 4 0 .cfi_startproc push ebp .cfi_def_cfa_offset 8 .cfi_offset 5, -8 mov ebp, esp .cfi_def_cfa_register 5 sub esp, 16 ; ここから .loc 1 5 0 mov BYTE PTR [ebp-1], 45 ; char c = '-' .loc 1 6 0 movsx eax, BYTE PTR [ebp-1] ; return c .loc 1 7 0 leave ; return c .cfi_restore 5 .cfi_def_cfa 4, 4 ret .cfi_endproc
func2の部分だけアセンブリを抜粋しました。
func2では初期化しているのでわかりやすいのですが、「mov BYTE PTR [ebp-1], 45」で、'-'(45)をebpから-1の位置に書き込んでいます。
これがローカル変数の正体です。
スタックのとある領域をローカル変数として扱っているだけで名前のついた変数は存在しません。
あとは戻り値としてebp-1の値をレジスタeaxに書き込んでfunc2関数の処理は終了です。
次にfunc1のアセンブリです。
_func1: LFB13: .loc 1 9 0 .cfi_startproc push ebp .cfi_def_cfa_offset 8 .cfi_offset 5, -8 mov ebp, esp .cfi_def_cfa_register 5 sub esp, 16 ; ここから .loc 1 14 0 cmp DWORD PTR [ebp+8], 0 ; if (value < 0) jns L4 .loc 1 15 0 call _func2 ; func2() mov BYTE PTR [ebp-1], al ; c = func2() L4: .loc 1 17 0 cmp BYTE PTR [ebp-1], 45 ; if (c == '-') jne L5 .loc 1 18 0 mov eax, DWORD PTR [ebp+8] neg eax ; value * -1 jmp L6 L5: .loc 1 19 0 mov eax, DWORD PTR [ebp+8] ; value L6: .loc 1 20 0 leave ; return .cfi_restore 5 .cfi_def_cfa 4, 4 ret .cfi_endproc
ちょっと長いですが、注目するところはL4の「cmp BYTE PTR [ebp-1], 45」です。
ここはcの値が'-'であるかどうかの条件分岐なんですが、ebp-1から1バイト分の値と定数を比較しています。(注 func2のebpではない)
ここで問題となるのがそれ以前にebp-1の値が一切触れられていないんですよね。
なのでこの位置に何か値が入っていればそれがcの値となって条件分岐の材料となるわけです。
ちなみに、初期化した場合は「mov BYTE PTR [ebp-1], 0」が実行されるので少なくとも0という確定した値になります。
func1呼び出しで2回目にバグった理由としては、1回目の呼び出しの時は大抵ebp-1の場所には0が入っています。
しかしながら、1回目にc=func2()で'-'を入れてしまっているためにメモリ上にこれが残ってしまいます。
この状態で2回目のfunc1が呼び出されると先の理由からその値がcとなってしまいます。
そのままその値を条件分岐の材料とするのですから'-'でtrueとなり、思ってもみないバグが発生します。
func1との間にprintf関数を挟むと正しくなるのも同様の理由です。
printf関数はコンソールに出力するだけですが、内部では複雑なことをしていて、結構スタックを利用します。
スタックを利用した分だけメモリ上の値が書き換わるので見かけ上正しく動いてしまいます。
また、呼び方の違いでスタックの積まれ方が変わると発生しないので非常に厄介。
ただ、これはコンパイラや実行環境に依存するので一概にこの問題が起きるとは言えません。
少なくともgccでは変数の自動初期化は行われないのでこの問題が起きます。
clangは試してませんが、VC++はエラーを出す(C++は初期化必須が定義されていました)ので初期化忘れを防止する仕組みが採用されているものも多いのでしょうか。
〆
初め聞いた時はポインタ絡みの初歩的なミスだろうと思っていましたが、思った以上に厄介な問題でした。
ただの初期化忘れですが、こういうこともあるんですね。
また、printfデバッグ法なんてのもありますが、こういう場合にはさらに複雑化するだけなので素直にデバッガつかいましょう。
そういえば初めXcodeで検証してたのでclangも通っちゃうのかな?警告は出てた気がするけど。
アセンブリ含め、スタックの動きを改めて勉強できたので良かったです。
○追記 高級言語でどうのこうの言ってますが、C#などはそもそもスタック未初期化変数があるとエラー出すのでそもそも起きないですね。
おまけ
高級言語でもプリミティブ型やスタックに置かれるようなローカル変数だと初期化しろって割と怒られるので構造体でやってみました。
#include <stdio.h> typedef struct { char c; } TEST; TEST func2() { TEST t; t.c = '-'; return t; } int func1(int value) { TEST t; // t.cの初期値は0を期待する、通常は0を期待すると思う //printf("%p: %d\n", &t.c, t.c); if (value < 0) t = func2(); if (t.c == '-') return value * -1; return value; } int main() { int i1 = func1(-2); int i2 = func1(2); printf("%d\n", i1); // 2 printf("%d\n", i2); // 2 }
結局スタックに乗るので同じ結果になるのですが、構造体だと高級言語のクラスみたいなイメージなのでやらかしてしまいそう。