文字列専用のハイブリッド 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(グローバル領域の末尾)に置く。
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 が無いときのフォールバック。
ActiveBasic 4.20 互換の String は 代入のたびに新しいヒープ塊を指すポインタ になる。連結・Mid$・Str$ は毎回確保する。参照カウントも、代入時の StrFree も行わない。
代入時に旧値をすぐ HeapFree すると、別名(同じ塊を指す別変数)や、関数の戻り値をまだ rax に載せる前に潰す事故が起きる。そこで 生きているポインタから到達できる塊だけ残し、残りをまとめて解放 する。
コンパクション(塊の引っ越し)はしない。一度確保したアドレスは、解放されるまで変わらない。StrPtr で取った内部ポインタも、親 String がマークされていれば有効なまま。
ActiveBasic と同じレイアウト。
HeapAlloc が返す base
┌─────────────┬──────────────────┬────┐
│ dword ヘッダ │ バイト列(len) │ NUL│
└─────────────┴──────────────────┴────┘
▲ ▲
│ └─ ユーザが見る String / StrPtr
│ (base + 4)
└─ bit31 = ヒープ印(0x80000000)
bit30..0 = 長さ(埋め込み NUL を含む)
len + 5(ヘッダ 4 + データ + 終端 NUL)。Len(s) は [ptr-4] & 0x7FFFFFFF。lstrlenA ではない。OP_LEA_RAX_STR)も直前 4 バイトに長さを持つが、ヒープ印は付けない。GC の blocks[] にも載らない。MakeStr(p) は lstrlenA で長さを取り、上記レイアウトへ複製する。ヒープ印の意味:
StrFree も印がある塊だけ HeapFree(base) していた。代入経路からは呼ばれなくなっている(本体生成は g_rtStrFreeLab が確保されたときだけ)。 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 もルートとしては登録しない。
モジュール 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 にはならない。
StrHeapAllocStringGc.abp の StrHeapAlloc。コンパイラ組込経路(Cat / Mid$ / …)は LowFindFunc("StrHeapAlloc") で呼ぶ。
GetProcessHeap → HeapAlloc(heap, 0, nbytes)。count < 4096 なら blocks[count] = base、count++。+4 は Cat 等が行う)。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
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 は型表で区別できないため入れない。
関数ごとに g_fnPreciseRoots[](フレームオフセット)を持つ。セーフポイント直前の LowEmitPreciseLocalMarks が、Collect の 前 に:
LOAD_LOCAL off
CALL StrGcMarkPrecise
をルート数だけ出す。ここが marks[] を付ける唯一の「今のフレーム」経路。Collect 側はローカルオフセットを知らない。
登録タイミング:
LowAddSym で ty = TY_STRING かつ ByVal(isByRef = 0)TY_STRING_ARR は要素ごとにオフセットを足すx64 の第 5 引数以降は LowAddSym 時点の負オフセットから、実在の [rbp+0x30+(i-4)*8] へ LowRetargetFnPreciseRoot で付け替える。付け替えないと、存在しないスタックスロットをマークして戻り String を落とす。
非引数ローカルは関数入口で 0 初期化。未初期化のゴミを String ポインタとしてマークしないため。
offset = 0 から g_gcBaseOff 直前まで、ポインタ幅刻みで LOAD_PTR → StrGcMarkConservative。GC 領域自身は走査しない。
ここに UDT 内 String、*Byte に残ったユーザポインタ、誤って載った整数が混ざる。ヒープ印と範囲判定で、登録塊に当たらない値は無視される。
Collect フレーム内の RSP から、TEB の StackBase(x64: gs:[8]、OP_MOV_RAX_GS8)の直前まで、ポインタ幅刻み。
StringGc.abp の StrCollect 後半。i = count-1 から 0 方向へ swap-remove。marks クリアは掃き出しの後。
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 後など従来のセーフポイントのみ。
コンパイル中のメモリ(実行ファイルには出ない):
| 表 | 内容 |
|---|---|
g_preciseRoots[] / g_preciseRootCount |
グローバル String の BSS オフセット。MAIN で BSS へコピー |
g_fnPreciseRoots[] / g_fnPreciseRootCount |
現在の関数のフレームオフセット。関数ごとにリセット |
上限はどちらも GC_MAX_PRECISE = 512。溢れた分は precise に乗らず、conservative 頼みになる。
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
生きたポインタが次のどれにも無いと、次のセーフポイントで塊は解放される。
レジスタ専用の一時値
ループ先頭の Collect の直前に、結果が rax だけ、スタックにも precise スロットにも無い。
UDT の String メンバ
precise 未登録。その UDT がグローバルか、スタック上のワードとして conservative に見える必要がある。
ByRef String
実体スロットは呼び出し元側。呼び出し先のフレームオフセットはルートにならない。
ヒープ印の無い塊
conservative はスキップ。precise ならマークする。確保とヘッダ書き込みの間にセーフポイントは挿入していない。
malloc と String の混同
MakeStr は複製する。元バッファは GC 対象外。free は手動。
4096 塊を超える確保
リーク。エディタのように一時文字列が多いループでは、ループ先頭の Collect が効いている前提。
関数ラベル ID と戻り型の衝突(修正済)
AST_FUNC.extra をラベルで上書きすると TY_STRING と衝突し、戻りが precise から外れた。extra は Parser の戻り型のまま。
これらが重なると、解放済みポインタを TextOut / lstrlenA に渡し、画面のゴミ・白画面・アクセス違反になりうる。x64 では故障アドレスがヒープの下位 32bit だけに見えることがある(昔の符号付き 32bit 比較の名残、または既に unmap された下位アドレス)。
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 の描画ループまではカバーしない。
コードを変えるとき、次が同時に成り立っているか。
CMP_RCX_RAX + JB/JA/JAE)。count は 32bit のままでよい。blocks[] の添字は常に i * 8。HeapFree 前後でスタックの PUSH/POP が対になる。i を進めない。blocks[] に載せない、ヒープ印も付けない。