本日Qiitaのお知らせメールで紹介された(?)こちらの記事。
https://qiita.com/fujitanozomu/items/8b7d9e4e51baf0764dfc
面白かったので、何故そうなるのか細かく考えてみました。
以下のコードは、上記の記事より拝借させて頂きました。
#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);
}
main関数内のprintfがないと、fizzbuzz関数が100まで出力しなくなる、というもの。
この動作を理解するためには、「呼び出し規約」「スタック」等の知識が必要となります。
詳細は省いてとりあえず答え。
main関数実行直後のsp(スタックポインタ、スタック領域の現在のメモリ上のアドレスを指し示す)が1000であるとすれば、printf実行時のスタックは、以下のようになります。
そして、fizzbuzz(100)実行時のスタック(厳密には、fizzbuzz関数のdoの位置)は以下のようになります。
詳細は後述しますが、printfを実行した時点で1008番地にはprintfで使用された値が入り、それはprintf実行後に特にクリアされたりはしません。つまり、fizzbuzz関数実行時、next変数は同じ1008番地を見ているため、printf実行時のゴミを参照することになります。
これ故に、printfが実行されるとnextが0以外の値となり、fizzbuzz関数がうまく動作する、というスンポーです。
※もしprintf実行時に、1008番地が0になったとしたら、当然ですがfizzbuzz関数はうまく動作しません。
なんでそうなるの?
これを理解するためには、CPUの仕組みやC言語の仕組みを少し知る必要があります。
※なお、専門家ではないので多少正確でない表現があるかも知れません。突っ込む際は優し目にお願いします。
スタック領域
まずスタック領域について軽く説明させてください。
スタックという名前だけあって、pushで積んでpopで取得(除去)するアレです。
CPUにはメモリの一区画をスタック的に使うための仕組みがあります。SP(スタックポインタ)やPUSH命令、POP命令等です。
※これは設計次第なので、もしかしたらCPUによってはないかもです。ですが、普段我々が使用するパソコン等のCPUにはあるはずです。
スタック領域はあくまで「スタック的に使う」だけなので、「スタックのてっぺん - 4のアドレス」といったアクセスが可能です。
ちなみに、こちらもコンパイラの設計次第ではあるのですが、C言語では通常ローカル変数はスタックに積まれます。
なので、関数内で
int a;
int b;
とかやると、スタックのてっぺんにa用の領域、b用の領域が積まれます。
この時点でSPはb用の領域を指しているので、SP - 4とすることでa用の領域にアクセスできるわけです。
※32bitの場合、intは4バイトなので、「てっぺんの一個前」という意味で-4です。
なお、スタックに積まれたローカル変数は、関数終了時にスタックから除去されます。
※もっと厳密な言い方すると、「スコープを外れたら」ですかね。
しかも除去はPOPではなく、SPの値を関数呼び出し前の値で書き換える、みたいなことをすることが多いようです。
機械語に関数呼び出し等ない
みなさん馴染みの言語は、多分殆ど「関数」があると思います。
しかし、CPUが理解できる唯一の言語「機械語」には関数はありません。
代わりに(?)CALL命令という物があり、サブルーチンを実行することができます。
プログラムは通常、メモリにロードされ、IP(インストラクションポインタ)が指し示すアドレスにある命令を順に実行していきます。
このIPの値を書き換えれば、別の場所のプログラムを実行することができます。この「別の場所のプログラム」がサブルーチンです。
ただ別のアドレスのプログラムに制御をうつしたいだけならJMP命令というものを使えばよいのですが、飛び先のプログラムが完了したら元の場所に戻ってきたい!なんて場合はCALL命令を使います。JMP命令はIPの値を書き換えるだけですが、CALL命令はIPの値をスタックに積んだ後JMPします。
この仕組みにより、関数を実行できる!ようにも思えるのですが、実は引数の受け渡しと、戻り値の取得は、自前でなんとかしないといけません。
呼び出規約
さて、「引数をどうやって渡す」「戻り値をどうやって受け取る」は、実は色々な方法があります。
この辺りのやり方をルールとして定めたのが「呼び出し規約」です。
実際は、引数・戻り値の他にも、スタックを使用した場合、それを元の状態に戻すのは、関数の呼び出し元?先?というのもあります。
c言語の標準はcdeclというやつですが、ウィンドウズではstdcallという規約を使っていたりします。
cdeclでは、引数はスタックに積む、というルールになっています。
スタックに積んだ後CALL命令を発行するので、先ほどのprintfの例では1000番地に引数、1004番地にreturn addressが格納された訳です。
※なお、1000番地の次が1004番地なのは、32bit命令を想定しているためです。64bit命令ならば、1008になるかと思います。多分。
ちなみに、何ですが、異なる言語のライブラリを使うケースでは、呼び出し規約をあわせないとうまく動作しません。
JavaからCで書かれたプログラムを呼ぶときや、VB.NETからCで書かれたプログラムを呼ぶとき、等です。
結局printfは?
先ほど、printf実行時の1008番地が?のままでした。
printfの引数が二つあれば何事もなく説明完了とできたんですが、実際にメモリの内容を(手で)書き出してみると、それだけでは説明できず、軽く愕然としてこの記事書くの辞めようかと思いました。
しかし、ネットでprintfのコードを見つける事ができたので掲載します。
※printfのコードは、処理系によって異なります。なので、以下はあくまで一例です。
int
__printf (const char *format, ...)
{
va_list arg;
int done;
va_start (arg, format);
done = vfprintf (stdout, format, arg);
va_end (arg);
return done;
}
関数名が__で始まっているのは目をつぶってください。話が長くなる(というか私には説明できない)ので。
このコードではva_list arg;というコードがあるので、これが1008番地に来るはずです。
なので、このargが0以外の値になれば、「このprintfがあると何故か動くんだ」という状態になります。
おしまい
ということで、何とか説明できたんじゃないかな?と思うんですが、如何でしょうか?
この辺り詳しく知りたい方は、「はじめて読む8086」「30日でできる! OS自作入門」等の本で勉強されるとよろしいかと思います。
※ちなみに、SE歴15年程のアプリ屋さんですが、上記知識が役に立ったのは1回だけです。
しかも、VB.NETからC言語のプログラムを呼ぶ、というケースで担当者が「なんかちゃんと実行されてるんですけどエラーになるんですよね」と言ってきて、「呼び出し規約あってないんじゃないすか」とアドバイスした、という、その程度です…。(実際呼び出し規約の問題でした)