自前 malloc — ヒープを自分で管理する
今日のゴール
malloc を自分で書く。バンプ割り当て(切り出すだけ)から始め、
free した領域を再利用できるところまで作る。
コマ12 で作った連結リストが、自前のアロケータの上で動くことを確認する。
この回の位置づけ
| 項目 | 内容 |
|---|---|
| 必須の前提 | コマ12(malloc と連結リスト)まで、およびコマ15。check.py が完成した final/mycc.py で mymalloc.c と tests/*.c をコンパイルするので、コマ15 が無いと1件も実行できない |
| 推奨の前提 | — |
| 改変しない | mycc.py、scaffold/ |
| 編集する | mymalloc.c |
| 完了条件 | check.py の全 Step が PASS になる(このトピックに golden.py は無い) |
| コマ数 | 1 |
| 備考 | ランタイム発展シリーズの第3回。R1・R2 とは独立している(libc は使ってよい) |
コマ12 の資料で「malloc の仕組みは実装しない」と言った、その中身を作る。
malloc は何をしているのか
コマ12 では、こう説明した。
mallocは、指定されたバイト数ぶんのメモリ領域をヒープから確保し、 その先頭アドレスを返すライブラリ関数である。
ではその「確保」とは何か。大きな領域を、要求されたぶんずつ切り分けて配ることである。 配った場所を覚えておかないと、次の要求で同じ場所を二重に配ってしまう。
この回では、ヒープの元になる領域(プール)を呼び出し側から渡してもらう。
#include "lib.h"
char *pool;
pool = malloc(1024);
heap_init(pool, 1024);プール自体は libc の malloc から借り、その中の切り分け(自作アロケータ)を
自分で書く、という位置づけである。
本物の malloc は OS から brk / mmap で領域をもらうが、
そこは R1 のシステムコールの話で、割り当ての本質ではない。
段階1: バンプ割り当て — ただ切り出す
いちばん単純な方式は、「次に配る位置」を1つ覚えておき、要求されたぶんだけ進めることである。
pool ┌──────┬──────┬────────────────────────┐
│ 16B │ 32B │ 未使用 │
└──────┴──────┴────────────────────────┘
^ heap_brk(次はここから配る)
heap_brk を進めるだけなので非常に速い。しかし free ができない
(真ん中を返されても、heap_brk を戻せない)。
それでもコンパイラのような「起動して、確保しまくって、終わったら全部捨てる」
プログラムには十分で、実際に発展課題 P1(C 移植・セルフホスト)の
C 実装の規約でも「free は不要」としている。
段階2: ヘッダを付けて再利用する
free を作るには、返された領域がどれだけの大きさだったかを知る必要がある。
my_free(p) に渡されるのはアドレスだけだからである。
そこで、配る領域の手前に管理用のヘッダを置く。
┌────────────┬──────────────────┐
│ Header │ ユーザ領域 │
└────────────┴──────────────────┘
^ h ^ my_malloc が返すアドレス p
p = h + sizeof(Header)
h = p - sizeof(Header)
ヘッダには、大きさ・解放済みフラグ・次のブロックへのポインタを持たせる。 コマ12 で作った自己参照構造体そのものである。
struct Header {
int size;
int free;
struct Header *next;
};これで、my_free(p) は「p の手前のヘッダを見て free = 1 にする」だけでよい。
my_malloc は、まず管理リストから「解放済みで十分な大きさ」のブロックを探し
(first fit)、あればそれを再利用する。なければ未使用領域から切り出す。
アドレスの計算にキャストが使えない。
本物の C なら (char *)h + sizeof(Header) と書くところだが、
Core プロファイルにキャストはない(language_spec.md の除外一覧)。
代わりに、いったん char * の変数に代入してからポインタ演算する。
char * の演算は1バイト単位なので、sizeof(Header) を足せば期待どおりに進む
(コマ9 のポインタ演算の型サイズの話)。
8バイト境界に揃える
要求サイズは 8 の倍数に切り上げる(align8)。
コマ8・コマ12 で見たように、int は4バイト境界、ポインタは8バイト境界に
置かれる必要があり、揃っていないアドレスを返すと Bus error や
無音の誤動作になるからである。
「アラインメントを揃える」のは、フレームサイズのときと同じ考え方である。
実装
| Step | 実装対象 | 内容 |
|---|---|---|
| 1 | align8 / heap_init / my_malloc(切り出し) |
バンプ割り当て |
| 2 | find_free / my_free / my_malloc(再利用) |
ヘッダとフリーリスト |
| 3 | heap_used |
使用量の観察 |
Step 1 だけでも tests/list_build.c は通る。
Step 2 まで進むと tests/reuse.c(解放した跡地が再利用される)が通る。
テスト
python3 check.py| テスト | 確認内容 |
|---|---|
list_build.c |
自前 malloc で連結リストが作れる(コマ12 の再現) |
reuse.c |
解放したブロックが同じアドレスで再利用される |
exhaust.c |
プールを使い切ったら 0(NULL)を返して止まる |
exhaust.c は「無限に確保できてしまう」バグ(終端チェック忘れ)を検出する。
この回の編集対象は C なので、未実装は NotImplementedError ではなく
mymalloc.c の TODO(...) コメントで表してある。check.py は TODO が残っている間、
テストを回さず [SKIP] 未実装: … と報告する。SKIP は未達なので、完了条件は満たしていない。
大きなローカル変数には注意。
プール自体は malloc で確保するので配列サフィックスの問題はないが、
大きな struct をスタック上のローカル変数として大量に持つと、
スタックフレームのオフセットが RV64 の addi 命令の即値の範囲
(12bit = -2048〜2047)を超え、アセンブラがエラーを出すことがある。
これは自作コンパイラの実際の制限で、発展課題で直せる。
発展課題
- 12bit の壁を越える: フィールドの多い
structのローカル変数を いくつも並べてフレームを膨らませ、実際に壁を越えさせてみる。そのうえで、 オフセットが範囲外のときはliで一度レジスタに入れてからaddする コード生成に直す(コマ3・コマ6 のプロローグとローカル変数アクセス) - 分割(split): 100 バイトの空きブロックに 16 バイトの要求が来たとき、 いまは 100 バイト丸ごと使ってしまう。余りを新しい空きブロックに分ける
- 併合(coalesce): 隣り合う空きブロックを1つにまとめる。 さもないと、小さな空きだらけになって大きな要求が通らなくなる(断片化)
- best fit にする:
find_freeを「最初に見つかったもの」ではなく 「要求に最も近い大きさのもの」を返す方式に変え、断片化の減り方を比べる - libc の malloc と差し替えて計測: 確保・解放を何万回も繰り返すプログラムを書き、 自前版と libc 版で実行時間を比べる
コラム: アロケータは奥が深い。
実用の malloc は、サイズ別のフリーリスト(bins)、スレッドごとのアリーナ、
小さい要求専用の高速path、OS への返却など、多層の工夫でできている。
それでも中心にあるのは、いま書いた「ヘッダで大きさを覚え、空きを探して配る」
という考え方である。
ガベージコレクションを持つ言語(Java や Go)では、この「空きを探す」部分に
「もう誰も指していない領域を見つける」処理が加わる。