Table of Contents
List of Figures
プログラムはデータです。データを実際に解析(解釈)してみて始めて命令コー ドか単なるデータかわかります。プログラムを起動するというのは、プログラ ムファイルを解析しながらメモリの中に並べ、並べ終ったら、開始位置(Entry Point)と言いますから、命令を CPU に送り込み始めることです。
CPU は命令をメモリから順に呼んで、それを解釈して動きます。 メモリの中にプログラムがどういう風に並ぶかを理解しましょう。
プログラムの中には命令と変数があります。どちらも単なるビットの並びです。 区別は尽きません。だから分離して並べます。命令が並ぶ区画(セグメント) と変数が並ぶ区画があります。
変数には種類があります。これはどの区画に置かれて、どう管理されるかが違 うからです。変数が置かれる区画はさらに小さな区画に区分けされているとい うことです。スタック、ヒープ、グローバル(スタティックも同じ)です。
変数を使うとき、それがこの三つのうちどれなのかを常に忘れないようにしま しょう。変数が直接それをさしているうちは良いですが、ポインタを使い始め ると、ちょっと気を緩めた途端にとんでも無いことが起こります。
変数はラベルだという事を忘れないように。
実際に上記のプログラムでそれぞれの変数がどいうアドレスになるか調べてみました。
08048554: main 08049850: &gii 08049854: &sii 08049940: &siu 08049948: &giu 0804B000: buf BFBFEABC: &buf BFBFEAC0: &j BFBFEAC4: &i BFBFEAD0: &argc BFBFEAD4: &argv BFBFEAF0: argv
スタックは関数が呼び出されると動きます。というか、関数の仕組みの基礎で す。関数を呼び出す時、当然、必要な変数を渡します。このとき、メモリがど うなっているか理解しましょう。大きな落とし穴が空いています。また、関数 から値を返す時にも当然注意をしましょう。アセンブラ(機械語)レベルでどう なっているのか感覚的に理解しておくことは大事です。この理解はデバッグ能 力に直結しています。
main() { int x = 10; x = foo(x); } int foo(int x) { return x * 10; }
うえのCのコードはコンパイルされると、以下のような機械語に翻訳されます。 ここで使う機械語(アセンブラ)はintel風ですが、大分省略しています。実際 にコンパイラが吐き出す機械語は分け分からんです。
main: C10 sub sp, 1 C11 mov *sp, 10 C12 push *sp C13 call foo C14 add sp, 1 C15 mov *sp, ax C16 add sp, 1 C17 ret foo: C20 mov ax, *(sp + 1) C21 mul ax, 10; C22 ret
このプログラムが走るときスタックがどんな感じで動くかは下の図のようにな ります。
ローカル変数を確保するという事はどういうことなのかと言えば(ここでは main() の x)、その領域の分だけ SP を減らすという事です。引数を渡すとい うのはスタックに積むという事です。
引数や返値には構造体を渡す事はできません。どうしてもポインタを渡すことになります。こんなコードかいてません?
#include <stdio.h> struct sts_t { int m_int; char* m_str; }; static struct sts_t* foo() { struct sts_t st; char message[80]; st.m_int = 10; sprintf(message, "value is %d", st.m_int); st.m_str = message; printf("%s\n", st.m_str); return &st; } main() { struct sts_t* s = foo(); printf("hello world\n"); printf("%d = %s\n", s->m_int, s->m_str); }
そのままコンパイルして実行すると
value is 10 hello world -1077941508 = ランダムな文字列
なぜだか分かりますか? foo() が返した構造対は一体どこに確保されているでしょうか?
foo()のリターン直前、スタックの中は意図した通りになっています。リター ン直後も大丈夫です。構造体へのアドレスををちゃんと s に保存できます。
しかし、そのあと printf() を呼び出すと、"hello world" へのポインタを積 み、次に、リターンアドレスが積まれます。リターンアドレスを積んだ所はな んと s->m_str が入っている所です。更には、printf() は複雑な関数ですか らローカル変数を沢山使いますし関数もどんどん呼び出します。s->m_int の あたりはもちろん、messages が入っていた所もメチャクチャにされてしまい ます。
printf() から処理が戻って来ても s は printf() に蹂躙されたアドレス空間 を指しています。
思った通り動くようにするには、関数のスタックの中に確保し たデータを参照しないようにするしかありません。グローバル変数をつかうか ヒープから領域を確保します。ヒープから確保したばあいはそれを開放するの を忘れてはいけません。
#include <stdio.h> #include <stdlib.h> struct sts_t { int m_int; char* m_str; }; static struct sts_t* foo() { struct sts_t* s = (struct sts_t*)malloc(sizeof(struct sts_t)); char* message = (char*)malloc(80); s->m_int = 10; sprintf(message, "value is %d", s->m_int); s->m_str = message; printf("%s\n", s->m_str); return s; } main() { struct sts_t* s = foo(); printf("hello world\n"); printf("%d = %s\n", s->m_int, s->m_str); free(s->m_str); free(s); }
スタックに領域を取ります。malloc() のように手動回収ではなくて自動回収 してくれますし、malloc()より構造が簡単なので速いです。
ただし、注意がいりますし、たまに自動回収してくれるといこ とに過度の期待をしてこんなコードを書いてしまいます。
void foo() { /* 次の行の長さ データ というデータ構造のファイルを読んで処理。 */ while (!feof(stdin)) { char num_str[16]; gets(num_str); char* buf = (char*)alloca(atoi(num_str)); gets(buf); proc(buf); } }
alloca() は関数を出る時に自動回収されます。自動変数と違ってスコープを 出るときはに回収されません。なぜなら、alloca() はライブラリで実装(コン パイラが実装しているケースもあるようですが)。自動変数はコンパイラが実 装しているかららです。
void foo() { /* 次の行の長さ データ というデータ構造のファイルを読んで処理。 */ while (!feof(stdin)) { char num_str[16]; gets(num_str); bar(atoi(num_str)); } } void bar(const int size) { char* buf = (char*)alloca(size); gets(buf); proc(buf); } }
これなら大丈夫です。bar() を出るときに確保した領域は綺麗になくなります。
alloca() は何をしているのかと言えば、呼ぶ前と呼んだ後で SP の位置をず らしてしまうのです。ローカル変数を取るとき、SP を動かす(減らす)と書き ました。ローカル変数と alloca() の違いは、ローカル変数の確保はコンパイ ル時に決められてコンパイラがコードを生成する(SP を減らす命令)のに対し て、alloca() は実行時に関数を呼び出す事で SP を減らすということです。
ちなみに、FreeBSD での alloca() の実装は以下のようになっています。
ENTRY(alloca) popl %edx /* pop return addr */ popl %eax /* pop amount to allocate */ movl %esp,%ecx addl $3,%eax /* round up to next word */ andl $0xfffffffc,%eax subl %eax,%esp movl %esp,%eax /* base of newly allocated space */ pushl 8(%ecx) /* copy possible saved registers */ pushl 4(%ecx) pushl 0(%ecx) pushl %eax /* dummy to pop at callsite */ jmp *%edx /* "return" */
生のコードは分かりにくいの疑似アセンブラに省略すると
pop dx /* リターンアドレスを dx に取り出す。 これで、もう ret 命令は使えない。 */ pop ax /* 指定量を ax に取り出す。 この時点で sp は呼び出し前の位置に戻ってしまっている */ sub sp, ax /* 無理矢理 sp を動かす(減らす、上に上げる) */ mov ax, sp /* sp の所からは使えるのでそれをリターン値として ax 入れる */ jmp dx /* ret は使えないので jmp で飛ぶ */
普通は関数から返るときには ret 命令を使ってスタックの位置を戻しますが、 alloca() は jmp 命令で戻ります(FORTRANみたいです)。これがミソです。
ここまでの説明では引数やローカル変数へのアクセスに SP からの相対アドレ スを使いました。これは説明を簡単にするためです。実際には関数に入った所 と出るところには次のようなコードが入り、ローカル変数には BP からの相対 アドレスが使われます。この仕組みが無いと alloca() は実際には使えません。
foo: push bp /* BP を保存 */ mov bp, sp /* SP を BP にコピー */ mov ax, *(bp + 2) /* 引数へのアクセスは BP 相対 */ .... mov sp, bp /* SP を復帰 */ pop bp /* BP を復帰 */ ret
ヒープを使う関数はライブラリによって関数名は違うし実装の仕組みも異なっていますが、基本的な構造はみな一緒です。
Win32 API では FileMapping, UNIX では mmap() というのがあります。ファ イルの内容をそのままメモリに「写す」ことができます。大きなファイルを扱 う時に劇的な性能アップが期待できます。ただし、仮想記憶の基本的な仕組み を理解しておかないとハマリマス。