メモリ管理


Table of Contents

1. メモリ管理
レイアウト
スタック
基本的な動き
参照渡し
alloca()
本当は
ヒープ
基本的な構造
混ぜるな危険!
仮想記憶を使おう
C++ Wizardへの道

List of Figures

1.1. レイアウトと変数
1.2. スタックの動き
1.3. スタックの破壊
1.4. alloca()のスタックの動き

Chapter 1. メモリ管理

レイアウト

プログラムはデータです。データを実際に解析(解釈)してみて始めて命令コー ドか単なるデータかわかります。プログラムを起動するというのは、プログラ ムファイルを解析しながらメモリの中に並べ、並べ終ったら、開始位置(Entry Point)と言いますから、命令を CPU に送り込み始めることです。

CPU は命令をメモリから順に呼んで、それを解釈して動きます。 メモリの中にプログラムがどういう風に並ぶかを理解しましょう。

プログラムの中には命令と変数があります。どちらも単なるビットの並びです。 区別は尽きません。だから分離して並べます。命令が並ぶ区画(セグメント) と変数が並ぶ区画があります。

Figure 1.1. レイアウトと変数

レイアウトと変数

変数には種類があります。これはどの区画に置かれて、どう管理されるかが違 うからです。変数が置かれる区画はさらに小さな区画に区分けされているとい うことです。スタック、ヒープ、グローバル(スタティックも同じ)です。

変数を使うとき、それがこの三つのうちどれなのかを常に忘れないようにしま しょう。変数が直接それをさしているうちは良いですが、ポインタを使い始め ると、ちょっと気を緩めた途端にとんでも無いことが起こります。

変数はラベルだという事を忘れないように。

実際に上記のプログラムでそれぞれの変数がどいうアドレスになるか調べてみました。


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

このプログラムが走るときスタックがどんな感じで動くかは下の図のようにな ります。

Figure 1.2. スタックの動き

スタックの動き

ローカル変数を確保するという事はどういうことなのかと言えば(ここでは 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() が返した構造対は一体どこに確保されているでしょうか?

Figure 1.3. スタックの破壊

スタックの破壊

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);
}

alloca()

スタックに領域を取ります。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みたいです)。これがミソです。

Figure 1.4. alloca()のスタックの動き

alloca()のスタックの動き

本当は

ここまでの説明では引数やローカル変数へのアクセスに 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

ヒープ

ヒープを使う関数はライブラリによって関数名は違うし実装の仕組みも異なっていますが、基本的な構造はみな一緒です。

基本的な構造

...工事中です。しばらくを待ちを。スタックを詳しくやりすぎて疲れますた...

混ぜるな危険!

領域の確保と開放はセットで行う。例えば、new で取った領域を free() で開 放しないこと。malloc() で取った領域を delete で開放しないこと。

領域確保の API に種類がある Win32 API では特に注意すること。

仮想記憶を使おう

Win32 API では FileMapping, UNIX では mmap() というのがあります。ファ イルの内容をそのままメモリに「写す」ことができます。大きなファイルを扱 う時に劇的な性能アップが期待できます。ただし、仮想記憶の基本的な仕組み を理解しておかないとハマリマス。

C++ Wizardへの道

メモリ管理をできる限り自動にしようというのが C++ の目標の一つです。ス マートポインタとか、自前の new とか、奥が深いです。このあたりまで来る と(3, 1)でしょうか。