雑記
コンパイルの話5:マシン語に落ちるということ3:変数とスタック
最終更新:
匿名ユーザー
-
view
C言語では、関数外にグロ-バル変数、関数内にローカル変数を宣言して使用することができます。
また、それらの変数の宣言の際に、staticやconstといった修飾子をつけることができます。
また、それらの変数の宣言の際に、staticやconstといった修飾子をつけることができます。
これはC言語にかぎらず、C言語以降の高級言語といわれているものではほとんど実装されている機能です。
これらは、コンパイルされた実行ファイル(マシン語に落とされた状態)ではどのような扱いになっているのでしょうか。
グローバルな変数は、関数にかかわらずそのプログラムが実行されている間はその領域に変更がないため、「ヒープエリア」と呼ばれる領域に確保されます。
コンパイラの実装やOSなどの処理系にも依存しますが、プログラムが実行される時に領域が確保されるわけです。
ローカル変数でstaticをつけて宣言した場合も、グローバル変数と同じ領域に確保されます。この場合もその領域は当該の関数を抜けても介抱されることはなく、再び当該関数に突入したときには以前の値を保持しています。使い方にもよりますが、関数の再突入性を悪くする恐れがあるので多様は避けたほうがいいでしょう。
また、mallocなどで確保したメモリもヒープエリアに確保されます。
これに対して、関数内の(非staticな)ローカル変数はスタック領域に確保されます。
・・・このあたりまでって、大体実はちょっと詳しい解説書には書いてあるんですよね。
でも実際に関数コールの際にスタックがどう振舞うか、とか、そもそも「スタック」ってなんですか、なんて感じの人が多い気がします。
というわけで長い前フリだったわけですが、関数・変数とスタックの関係について。
とりあえず、まずは「スタック」って言うものについて。
直訳すると、「棚」。値を入れたり出したりできる棚をスタックと呼びます。だいたいはCPU命令としてサポートされています。
PUSHでスタックに保存、POPでスタックから取り出します。
PUSHでスタックに保存、POPでスタックから取り出します。
後入れ先だし(first in last out)なので、例えば
PUSH A
PUSH B
PUSH C
とすると取り出す際にはC、B、Aの順で取り出されるわけです。
PUSH A
PUSH B
PUSH C
とすると取り出す際にはC、B、Aの順で取り出されるわけです。
以上がだいたいのスタックについてのお話。
もう少し詳しく説明すると、CPUにはスタックポインタと呼ばれるアドレスを保持するレジスタが居てまして、
PUSH AとCPUに命令するとCPUは
①まずレジスタのアドレスを進める(もしくは戻す)
②Aという値をそのレジスタに格納されているアドレスのメモリに保存する
というわけです。POP命令の場合は、その反対ですね。
PUSH AとCPUに命令するとCPUは
①まずレジスタのアドレスを進める(もしくは戻す)
②Aという値をそのレジスタに格納されているアドレスのメモリに保存する
というわけです。POP命令の場合は、その反対ですね。
スタックの格納先は当然メモリであり、プログラムごとにスタックとして利用できる領域があるわけです。
それが、「スタックエリア」です。
んで、話が長くなっちゃったわけなんですけどこのスタックがローカル変数とどう関係があるのか。
C言語では、結構気軽に関数コールできるわけですが、それがコンパイルされてマシン語に落ちたものを見てみると、関数一個呼ぶのでも結構がんばっているのですよ。
int hoge(int a) { int b; b = a + 1; printf("%d%d\n", a, b); return b; }
なんて、まったく意味のない関数があったとします。
この場合でも、まず
①汎用レジスタなどをPUSH
②現在のプログラムカウンタ(まさに今実行しているマシン語命令の次のアドレス)をスタックにPUSH
③関数に引数として渡された値をPUSH
(上の場合ならaの値をスタックにつむ)
④hogeというサブルーチンへジャンプ
(hogeのマシン語命令のあるアドレスをプログラムカウンタに入れる)
⑤スタックポインタをひとつ進める、あるいは戻す
(この領域をローカル変数bとして使う)
・・・ここまでで、やっと関数の入り口にたどり着きました。
①汎用レジスタなどをPUSH
②現在のプログラムカウンタ(まさに今実行しているマシン語命令の次のアドレス)をスタックにPUSH
③関数に引数として渡された値をPUSH
(上の場合ならaの値をスタックにつむ)
④hogeというサブルーチンへジャンプ
(hogeのマシン語命令のあるアドレスをプログラムカウンタに入れる)
⑤スタックポインタをひとつ進める、あるいは戻す
(この領域をローカル変数bとして使う)
・・・ここまでで、やっと関数の入り口にたどり着きました。
んで、
⑥スタックからaの値を取り出す
⑦プラス1する
⑧bの領域(スタックに確保した)に格納する
⑨再び①~⑤と同じことをやってprintf()コール処理をする
⑩アキュムレータ(処理を行うための特別なレジスタ)にbを格納する
(戻り値とするため)
⑪スタックから元のプログラムカウンタを取り出し、上書き
・・・ここまでが関数内の処理。
⑥スタックからaの値を取り出す
⑦プラス1する
⑧bの領域(スタックに確保した)に格納する
⑨再び①~⑤と同じことをやってprintf()コール処理をする
⑩アキュムレータ(処理を行うための特別なレジスタ)にbを格納する
(戻り値とするため)
⑪スタックから元のプログラムカウンタを取り出し、上書き
・・・ここまでが関数内の処理。
とまあ、スタックを使って引数のやり取りや関数から抜けたときの戻り先、はては関数内のローカル変数の領域までいろいろな情報を保存しているのである。
このスタックはマルチタスクな環境ではプログラムごとに割り当てられているわけだけど、メモリの領域なので当然有限な領域でしかない。
したがって、ローカル変数ででかい配列を持とうとしたり、再帰処理などで終了条件を満たさないでずっと自分自身をコールし続けたりすると当然おかしなことになる。大抵は、別のプログラムのスタック領域などにはみ出して、そこに変な値を書き込んでしまうことになる。
また、関数を抜けるとPUSHした分スタックを戻して別の関数で再びローカル変数として使われる。C言語などで初期化されないローカル変数の値が不定とされるのは、その為だ。
こういった、「実際にマシン語になったときどうなっているのか」という知識って、必要だと思う。
※追記
ここまで読んで、「あれ?」って思ってくれた方はいると思います。
スタックってひとつずつしか格納/取り出しできないと書いたのに、関数の説明でローカル変数として使ってるのは無理があるんじゃないか、と当然思われるはずです。
答えは簡単で、スタックは単なるメモリなので、スタックポインタレジスタに入っているアドレスをまさにC言語のようなポインタとして扱ってやることでその前後の値は通常のメモリアクセスと同様にできるのです。「出し入れがひとつずつ」なのは、あくまでPUSH/POP命令による場合です。
ここまで読んで、「あれ?」って思ってくれた方はいると思います。
スタックってひとつずつしか格納/取り出しできないと書いたのに、関数の説明でローカル変数として使ってるのは無理があるんじゃないか、と当然思われるはずです。
答えは簡単で、スタックは単なるメモリなので、スタックポインタレジスタに入っているアドレスをまさにC言語のようなポインタとして扱ってやることでその前後の値は通常のメモリアクセスと同様にできるのです。「出し入れがひとつずつ」なのは、あくまでPUSH/POP命令による場合です。