ActBa64

actba64 の String GC

文字列専用のハイブリッド GC。アルゴリズム本体は Include、コンパイラはルート・セーフポイント・BSS 領域・極小組込に限定する。言語仕様側の要約は language.md §6 を参照。

役割 ファイル
確保・precise マーク・掃き出し(Basic) src/Include/default/StringGc.abp
GC 定数・武装フラグ src/actba64/AstLower.abp
組込スタブ(Region / InRange / MarkUserBss / MarkStack) src/actba64/StrGcRt.abp
IR Collect 本体(主に -actba32 セーフポイント) src/actba64/StrGcCollect.abp
precise ルート登録 src/actba64/AstLowerApi.abp / AstLowerStrQuery.abp
セーフポイント・LowFindFunc 呼出 src/actba64/AstLowerRt.abp
cmp rcx,rax / gs:[8] / fs:[4] IR.abp / Emitter / CodeGen

C ランタイムや別 DLL は使わない。HeapAlloc / HeapFree(kernel32)だけを呼び、マーク表は PE の BSS(グローバル領域の末尾)に置く。


0. Include 公開 API

default.idx が常時 StringGc.abp を読む。

名前 役割
Function StrHeapAlloc(nbytes As Long) As VoidPtr HeapAlloc + blocks[] 登録。戻りは ヘッダ先頭(呼び出し側が +4)
Sub StrGcMarkPrecise(p As VoidPtr) precise マーク
Sub StrGcMarkCons(p As VoidPtr) conservative マーク(現行実装はタグ必須にしない)
Sub StrCollect() グローバル precise → GcMarkUserBss → GcMarkStack → sweep

コンパイラが注入する組込(AST 無し・LowAddFunc):

名前 役割
Function StrGcRegion() As VoidPtr BSS GC 領域先頭(g_gcBaseOff)
Function StrGcGlobals() As VoidPtr ユーザ BSS 先頭
Function StrGcUserBssSize() As Long ユーザ BSS バイト数(= g_gcBaseOff)
Function StrGcInRange(p, lo, hi As VoidPtr) As Long 符号なし lo <= p <= hi(32bit stdcall は ret 12)
Sub GcMarkUserBss() ユーザ BSS の conservative 走査(IR Cons)
Sub GcMarkStack() RSP〜TEB StackBase の conservative 走査(IR Cons)

コンパイラが残す手助け: precise ルート表、セーフポイント挿入、BSS GC_REGION_SIZE 追記、上記スタブ。

32bit (-actba32): セーフポイントも 64bit と同様に Include の StrCollect を呼ぶ(BSS/スタックの conservative は IR スタブ)。IR StrCollect 本体は Include が無いときのフォールバック。


1. なぜ GC か

ActiveBasic 4.20 互換の String は 代入のたびに新しいヒープ塊を指すポインタ になる。連結・Mid$・Str$ は毎回確保する。参照カウントも、代入時の StrFree も行わない。

代入時に旧値をすぐ HeapFree すると、別名(同じ塊を指す別変数)や、関数の戻り値をまだ rax に載せる前に潰す事故が起きる。そこで 生きているポインタから到達できる塊だけ残し、残りをまとめて解放 する。

コンパクション(塊の引っ越し)はしない。一度確保したアドレスは、解放されるまで変わらない。StrPtr で取った内部ポインタも、親 String がマークされていれば有効なまま。


2. 文字列のメモリ表現

ActiveBasic と同じレイアウト。

  HeapAlloc が返す base
  ┌─────────────┬──────────────────┬────┐
  │ dword ヘッダ  │  バイト列(len)   │ NUL│
  └─────────────┴──────────────────┴────┘
         ▲                    ▲
         │                    └─ ユーザが見る String / StrPtr
         │                       (base + 4)
         └─ bit31 = ヒープ印(0x80000000)
            bit30..0 = 長さ(埋め込み NUL を含む)

ヒープ印の意味:


3. 全体像

  Cat / Mid$ / Str$ / Chr$ / Input / MakeStr
            │
            ▼
     StrHeapAlloc(size)     ← HeapAlloc + blocks[] へ登録
            │
            ▼
     ヘッダに (len | 0x80000000) を書く
     rax = base + 4
            │
            ▼
     代入はポインタ格納のみ(旧値は即解放しない)
            │
            ▼
     セーフポイント
       1. ローカル precise ルートを marks[] に付ける
       2. StrCollect
            グローバル precise ルート
            ユーザ BSS の conservative 走査
            スタックの conservative 走査
            未マーク塊を HeapFree + swap-remove
            marks[] を 0 クリア

GC は String ヒープだけ を見る。malloc したバッファは対象外。UDT や *Byte もルートとしては登録しない。


4. BSS 上の GC 領域

モジュール Dim をグローバルへ割り当てたあと、その末尾に固定サイズの領域を足す。

g_gcBaseOff = g_globDataSize
g_globDataSize = g_globDataSize + GC_REGION_SIZE   ' 38928

オフセットは AstLower.abp 先頭の定数。塊ポインタ枠は 32/64 どちらも 8 バイト刻み(SHL_ECX_3)。32bit では下位 4 バイトだけ使う。

オフセット 定数 内容
0 GC_HDR_COUNT 登録数(ポインタ幅ストア、値は 0..4096)
8 GC_HDR_BLOCKS blocks[4096] — HeapAlloc の base
32776 GC_HDR_MARKS marks[4096] — 1 バイト/塊
36872 GC_HDR_PRECISE_COUNT グローバル precise ルート数
36880 GC_HDR_PRECISE_ROOTS ルートの BSS オフセット(各 4 バイト、最大 512)
38928 GC_REGION_SIZE 領域全体

blocks[i] が指すのはユーザポインタではなく ヘッダ先頭。マーク時に user = base+4、end = user+len を計算する。

上限 GC_MAX_BLOCKS = 4096 を超えると、StrHeapAlloc は確保したポインタを返すだけで 表に載せない。その塊は二度と回収されない(リーク)。解放済みポインタを使う UAF にはならない。


5. 確保: StrHeapAlloc

StringGc.abp の StrHeapAlloc。コンパイラ組込経路(Cat / Mid$ / …)は LowFindFunc("StrHeapAlloc") で呼ぶ。

  1. GetProcessHeap → HeapAlloc(heap, 0, nbytes)。
  2. 失敗なら 0 を返す。
  3. count < 4096 なら blocks[count] = base、count++。
  4. 戻りは base(ユーザポインタへの +4 は Cat 等が行う)。

6. マーク

6.1 共通本体

StringGc.abp の StrGcMarkOne。ポインタ範囲判定は組込 StrGcInRange(符号なし)。

入力は候補ポインタ p。

if ptr == 0: return
for i in 0 .. count-1:
    base = blocks[i]
    if base == 0: continue
    len  = [base] & 0x7FFFFFFF
    user = base + 4
    end  = user + len
    if not StrGcInRange(ptr, user, end): continue
    marks[i] = 1
    return

6.2 Precise: グローバル

MAIN 先頭で LowEmitGcPreciseRootsInit が、コンパイル時に集めた BSS オフセットを precise_roots[] へ書き、個数を precise_count に入れる。

StrCollect は各オフセットについて:

p = *(globals + precise_roots[i])   ' LOAD_PTR
StrGcMarkPrecise(p)

対象はモジュールの Dim s As String と Dim a(n) As String の各要素。UDT メンバの String は型表で区別できないため入れない。

6.3 Precise: ローカル

関数ごとに g_fnPreciseRoots[](フレームオフセット)を持つ。セーフポイント直前の LowEmitPreciseLocalMarks が、Collect の 前 に:

LOAD_LOCAL off
CALL StrGcMarkPrecise

をルート数だけ出す。ここが marks[] を付ける唯一の「今のフレーム」経路。Collect 側はローカルオフセットを知らない。

登録タイミング:

x64 の第 5 引数以降は LowAddSym 時点の負オフセットから、実在の [rbp+0x30+(i-4)*8] へ LowRetargetFnPreciseRoot で付け替える。付け替えないと、存在しないスタックスロットをマークして戻り String を落とす。

非引数ローカルは関数入口で 0 初期化。未初期化のゴミを String ポインタとしてマークしないため。

6.4 Conservative: ユーザ BSS

offset = 0 から g_gcBaseOff 直前まで、ポインタ幅刻みで LOAD_PTR → StrGcMarkConservative。GC 領域自身は走査しない。

ここに UDT 内 String、*Byte に残ったユーザポインタ、誤って載った整数が混ざる。ヒープ印と範囲判定で、登録塊に当たらない値は無視される。

6.5 Conservative: スタック

Collect フレーム内の RSP から、TEB の StackBase(x64: gs:[8]、OP_MOV_RAX_GS8)の直前まで、ポインタ幅刻み。


7. 掃き出しと marks クリア

StringGc.abp の StrCollect 後半。i = count-1 から 0 方向へ swap-remove。marks クリアは掃き出しの後。


8. セーフポイント(いつ集めるか)

LowEmitStrCollectSafepoint:

LowEmitPreciseLocalMarks(ctx)   ' CALL StrGcMarkPrecise(Include。無ければ IR Precise)
CALL StrCollect                 ' Include(無ければ IR Collect)

g_strGcArmed = 0 なら何もしない。LowEmitCallStrHeapAlloc が初めて成功すると武装する。

MAIN ループは例外: セーフポイントは本体 lower 前に出すため、その時点ではまだ武装していないことがある。LowMaybeLoopStrCollect が AST 上で確保(Cat / Mid$ 等)を見つけたら、先に武装してから Collect を出す(出さないとループ内の一時 String が blocks[] 上限まで溜まる)。

挿入箇所:

地点 理由
For / While / Do のループ先頭(MAIN のみ) メッセージループ等で回収。関数内は出さない(下記)
Print / Input のあと 一時 String を回収
モジュール MAIN 終了直前 プロセス終了前の掃除(その後すぐ ExitProcess)

関数エピローグでは Collect しない。Mid$ や Hl_Lower$ のような入れ子の終了時に、呼び出し元の String が conservative 走査から漏れて HeapFree されるのを避けるため。GUI ではメッセージループ(MAIN の Do)の先頭で回収する。関数内の一時値は、MAIN に戻ったあとで回収する。

関数内ループでも Collect しない(2026-09 修正)。TrimCode$(Mid$(s, 10)) のように ByVal String 引数へ渡した Mid$ 一時値が、関数内 While/For の Collect で unmarked のまま掃き出され、"ja" → "a" / "j" のように壊れる事例があった。呼び出し元のホーム領域や precise ルートがあっても再現するため、関数内セーフポイントは出さず MAIN に任せる。

-actba32 の MAIN ループも Collect しない(2026-10)。junk の Cat/Mid$ と Mid$(t, 10) を同じ For で回すと、Include StrCollect で回収は進むが UAF(AV)になる。IR Collect にすると回収が進まず blocks[] が上限まで伸びる。64bit の MAIN ループ Collect は有効。32bit は Print 後など従来のセーフポイントのみ。


9. コンパイラ側のルート表

コンパイル中のメモリ(実行ファイルには出ない):

表 内容
g_preciseRoots[] / g_preciseRootCount グローバル String の BSS オフセット。MAIN で BSS へコピー
g_fnPreciseRoots[] / g_fnPreciseRootCount 現在の関数のフレームオフセット。関数ごとにリセット

上限はどちらも GC_MAX_PRECISE = 512。溢れた分は precise に乗らず、conservative 頼みになる。


10. ランタイムの出る順

AstToIr 末尾で文字列ランタイム本体のあと、GC 組込スタブだけを出す(アルゴリズム本体は Include の通常 Function):

Cat / Mid / Str$ / Print / Input / Val / ...
MakeStr / StrCmp / StrFree(呼ばれたとき)
StrHeapAlloc / Precise / Cons / Collect(IR・フォールバックおよび 32bit Collect)
StrGcRegion / Globals / UserBssSize / InRange / GcMarkUserBss / GcMarkStack

11. 制約と既知の落とし穴

生きたポインタが次のどれにも無いと、次のセーフポイントで塊は解放される。

  1. レジスタ専用の一時値
    ループ先頭の Collect の直前に、結果が rax だけ、スタックにも precise スロットにも無い。

  2. UDT の String メンバ
    precise 未登録。その UDT がグローバルか、スタック上のワードとして conservative に見える必要がある。

  3. ByRef String
    実体スロットは呼び出し元側。呼び出し先のフレームオフセットはルートにならない。

  4. ヒープ印の無い塊
    conservative はスキップ。precise ならマークする。確保とヘッダ書き込みの間にセーフポイントは挿入していない。

  5. malloc と String の混同
    MakeStr は複製する。元バッファは GC 対象外。free は手動。

  6. 4096 塊を超える確保
    リーク。エディタのように一時文字列が多いループでは、ループ先頭の Collect が効いている前提。

  7. 関数ラベル ID と戻り型の衝突(修正済)
    AST_FUNC.extra をラベルで上書きすると TY_STRING と衝突し、戻りが precise から外れた。extra は Parser の戻り型のまま。

これらが重なると、解放済みポインタを TextOut / lstrlenA に渡し、画面のゴミ・白画面・アクセス違反になりうる。x64 では故障アドレスがヒープの下位 32bit だけに見えることがある(昔の符号付き 32bit 比較の名残、または既に unmap された下位アドレス)。


12. テスト

src/actba64/test/ のうち GC を直接狙うもの:

ファイル 見ていること
t_str_gc.abp For 内の大量 Cat のあと、先に入れたグローバル keep が残る
t_gc_while.abp While 内 Cat でも同様
t_gc_nested.abp While+Mid$ の内側で確保しない For が走っても元の行が残る
t_gc_trim_mid.abp TrimCode$(Mid$(t, 10)) + Chr$ 圧力でも "ja" が残る
t_gc_param.abp ByVal String 引数が callee 内ループ後も残る
t_gc_udt.abp UDT の String メンバ(precise 外)がグローバル/ローカルとも残る
t_gc_blocks.abp 3000 回 Cat 後も StrGcGetCount が小さく、掃き出しが効いている(Skip32: 32bit MAIN ループ Collect は未)
t_str_ret.abp String 戻りが、先行する Cat で有効になった Collect に潰されない
t_malloc_makestr.abp UDT の *Byte + malloc + MakeStr。ヒープが高位でも複製が残る

CUI で Expect: 0。GUI の描画ループまではカバーしない。


13. 実装を追うときのチェックリスト

コードを変えるとき、次が同時に成り立っているか。