C言語をしっかりと学びたい! (その3 メモリ②)(途中)

テーマ

メモリの使われ方を知る。ついでに関数ポインタも分かるかも

前回の記事により、連続したメモリを確実に用意してもらえるようになりました。そこで今回は、そのメモリが実際にどのように使われるのかを見ていきたいと思います。なお、この記事に出てくるアドレスはすべて仮想アドレスです。(前の記事を飛ばした人は特に気にしないで大丈夫です。)

4つに分かれている!

与えられた一連のメモリは4つの領域に分かれており、それぞれ名前がついています。

プログラム領域

静的領域

ヒープ領域

スタック領域

①のプログラム領域は、その名の通り、ロードしたプログラムを置く場所です。コンパイルされたバイナリーが置かれる、というイメージで大丈夫です。CPUはここを読み取っていってプログラムを実行します。

②の静的領域は、グローバル変数を置くための領域です。グローバル変数はプログラム実行中、最初から最後までずっと維持しておく必要がありますよね?なので、とにかくここはいじるな(勝手に開放したりするな)!そっとしておいてやれって意味で静的です。多分きっと。(いじらないって言ってももちろん読み取りや代入はできます。)

③のヒープ領域は、①②④のどれでもないところです。とだけ先に言っておきますが、その本質はスタック領域の説明をしないと分からないので、その後で。

④のスタック領域は、ちょっと(かなり)説明が大変。

スタック領域

一言で言えば

「関数」を実装するために用いる。

のですが、これだけ言っても何もわからないですよね。というわけでちゃんと説明するのですが、これの説明がなかなかとってもかなり大変、、、

なのでいくつかのパートに分けて解説していきます。なお、今回は「アセンブラを01に置き換えたもの」も「アセンブラ」と表現します。他にも全く厳密でない表現を(説明に問題ない範囲で)しまくります。なぜならそうしないと、くどくなるから。

まずはスタックとはなんぞやというところから。

スタックとは

スタックは一言で言えば

「後入れ、先出し」

のデータ構造です。よく言われる例で言えば、お皿を積み重ねることをイメージしてください。

お皿を積み上げていき、それを今度は取り除くとき、最後に積んだやつを最初に取り除きますよね?そのお皿を情報に置き換えてあげればオッケーです。

関数はスタック!?

今説明したスタックというデータ構造ですが、プログラミングにでてくるあるものに似ているんですが、それは何でしょう!?(ヒントは見出し)

そう、関数ですね。

スタックでは、例えば A の後 B が来て 、次にC が来たあとCを取り除いて D が来る、というのを考えます。これはまさしく、関数A を呼んでその中で関数B を呼んで、関数B の中で関数C を呼んで、関数C が終わったら関数D を呼ぶ、というのと同じです。

これで関数はスタックのデータ構造と同じである、ということが納得してもらえたでしょうか?

では今度は関数がどのようにして呼ばれ、あるいはリターンしたときにどのようにして元の行に戻るのかを見ていきましょう。

アセンブラに関数は出て来ない!

そもそも、すべてのプログラムは機械語で、つまりアセンブリ言語の単位で実行されるのでしたね。逆に言えばアセンブラにできないことはいかなるプログラムにもできない

しかし、アセンブリ言語には関数という概念は出てきません!

これは大変です。なんせ関数という機能がない中で関数を実装しないといけないのですから。では今度は、どのようにしてアセンブラで関数を実装しているのかを見ていきましょう。(いつになったらスタック領域の話になるんだと思っているかもしれませんが、大丈夫です。僕も同じことを思いながら書いています。)

前提知識

「その1」のおさらいとして。アセンブラ言語は1byte単位で書かれていました。つまり1つのアドレスに1つのアセンブラというイメージですね。(例えば「Aのメモリの値をBにコピー」の場合はコピーで1byte, A、Bもそれぞれ1byte)

ここからはわかりやすさ重視のためにC言語の1行は全て、アセンブラ言語1byteに変換されるとします。本当は違いますがここでの解説には支障ありません。

実際に実行するときにはアセンブラがプログラム領域にコピーされるのですが、ここで当たり前っぽいけど大事なポイントが1つ。それは、
メモリにはアセンブラが連続して順番に置かれる
ということです。

例えば次のような関数のとき

void f(int a, int b)
{
   a = a + b;
   b = a - b;
   g(a, b);
   a = a + b + 1;
}

アドレス n に a = a + b;があったとすれば、n + 1には b = a - b;が、n + 2 には g(a, b);が、n + 3 には a = a + b + 1;が絶対に来るという感じです。

プログラムカウンター

ところでプログラムの実行にあたり、(あまりにも当たり前のことですが)次は何行目を実行するのかをCPUは分かっている必要があります。

なので、それを覚えるための専用のレジスタがあります。そのレジスタのことを「プログラムカウンター(PC)」と呼びます。(レジスタとは、CPU内にあるむちゃくちゃ高速なメモリのことです。CPUを動かすために使われるため、主記憶装置とは用途が違います。CPUは用途別のいくつかのレジスタを持ちますが、一つ一つの大きさ(容量?)はむちゃくちゃ小さいです。なぜなら高額だから)

要は、このプログラムカウンターに「次に実行する(正確には、CPUが読み込むべき)アセンブラが置いてあるメモリのアドレス」を覚えさせるということです。そしてその命令が実行されたらプログラムカウンターの値はインクリメントされます。

これによってプログラムを1行1行順番に実行することができるわけです。なぜなら

メモリにはアセンブラが連続して順番に置かれる

から!

いよいよ関数の解説!

基本は1つ次の行、また1つ次の行、、、といきますが、例えば while や if だったり、それこそ関数だったりは必ずしも次の行を実行するわけではありませんよね?

(仮に単純な100回のループだけなら同じアセンブラを100回繰り返し書くことでも実現できますがそういうわけにも行きません。バイナリーがすごく長くなるし break とか実装できないし、まぁ色々と無理ですね)

そこでどのようにしているかというと、単純に、プログラムカウンターの値を変えるんです。アセンブラには jmp という、プログラムカウンターの値を任意に変えるための命令があるのでそれを使います。

例えばif~ else~ なら、条件が真の場合は次のアドレスに変更。偽なら10個先のアドレスに変更、みたいな感じです。こんな感じにすれば無事 while や if といったものが実装できそうです。
さらに言えば関数ポインタも完全に理解できたはず。(関数のアドレスは何ぞやという疑問も解決できたはず)

しかし、これでめでたしめでたし、というわけには行きません。というのも if や while というのは、別にジャンプ前のところに戻る必要はないのでただ飛べばよかったのですが、関数は元の場所に戻ってくる必要があるからです(不謹慎な例を上げるなら飛び降り自殺とバンジージャンプの違いですかね)。10行目で関数B を呼んだのに、関数B が終わったら30行目から再開された、なんてなったら困りますよね。ちゃんと10行目に帰ってきてほしいです。

ではどうするか、答えは簡単。今現在何行目を実行しているのかをどこかに覚えさせておくのです。

ただ、関数が入れ子になってたりしなければ、レジスタのどれかに覚えさせておけばいいのですが、前述の通りレジスタは容量がかなり小さいです。

なので、関数の中でまた関数を呼んで、、、とやっていくと容量が足りなくなってしまいます。

そのため、もとのアドレスの情報をメモリに記憶させる必要があるのです。

やっとです。やっと言えます!!

「関数が終わった時戻るべき場所の情報」を置いておくのがスタックメモリー(の用途の一部)です!!

スタック領域には、もとの場所に戻るためのアドレスの値がスタックのデータ構造で積み上げられていきます。

新しい関数を呼んだらアドレスを一つ積み上げて、関数が終わったら一つ取り除く。こうすることによって関数の基本的な部分を実現していたのです。

ですが、まだこれでは関数及びスタック領域の全てを解説できたわけではありません。そして、解説のためには再び話を脱線させる必要があります、、、orz

多くの人の認識

ここで一旦、スタックやヒープに関する皆さんの認識を書いてみます。おそらく多くの人の認識としては

①普通に関数内で宣言した変数(以下「自動変数」)はスタック領域で、 malloc した場合はヒープ領域。②スタック領域と違ってヒープ領域は開放しないといけない。

の2つだと思います。ただ、実はみなさんが気がついてないだけでスタック領域も開放されまくってます。

例えば先程説明した「もとの場所に戻るためのアドレス」。これは関数が終わったら1つ取り除くと言いましたが、この取り除くってのは具体的にはそのメモリを開放するということなんです。

と、ここで一つ質問です。

rnitta「ね〜ね〜、今これ読んでる人〜。よく『変数の宣言ってのはスタック領域にメモリを確保することだ』って言うじゃない?」

今これ読んでる人「はい、言いますね」

rnitta「それって何?」

rnitta「『スタック領域にメモリを確保する』ってどういうこと?」

メモリの確保とは

まあ茶番は置いといて、実際メモリの確保ってどういう事なんでしょうか?

これまたチコちゃん風に先に答えだけ言うと、「スタック領域にメモリを確保するというのは、スタックポインタの値を小さくすること」です。とは言えこれだけではわからないのでちゃんと説明。

多くの人は、「変数の宣言とは箱を用意することだ」と思っているかもしれません。実際そのイメージでも問題ないのですが、本当はどちらかというと「たくさんある箱の中から自分専用の箱を指定する」というイメージです。分かりやすく言えば「家を建てる」のではなく「ホテルの部屋をとる」というイメージです。

ここのメモリはこの変数専用にするから他の人は使わないでね(勝手に値を変えないでね)、ということですね。

ではどのようにしてそういったことが行われているのかを見ていきましょう。

変数を宣言するときに、メモリはスタック領域に確保されます。そして、スタック領域の特徴は積み重ねる、ということでしたよね?つまり、今使用されているメモリの一つ上のメモリ(今使用されているメモリのアドレス - 1のアドレスが)が新しい変数用のメモリとして用意されます。

とはいえ、今どこのメモリまで使用されているのか分からないとどうしようもないですよね。なので、やはり再びレジスタの出番です。この、「今現在どこのメモリまで使用されているのかを指し示す値」を保持するのがスタックポインタ(SP)というレジスタなのです。

この記事が気に入ったらサポートをしてみませんか?