はじめに
Windowsのメモリ管理は、APIレベルの抽象化(VirtualAlloc等)の裏側で、PTE(Page Table Entry)やPFN(Page Frame Number)といったハードウェアに近いレイヤーが複雑に連動しています。
本記事では、メモリの確保、属性変更、プロセッサ間共有、そしてOSのコミット限界における挙動を、WinDbgを用いたカーネルデバッグを通じて検証した5つのステップをまとめます。
この記事は前半2つのステップとなります。
1. VirtualAllocにおけるWSSとPrivate Usageの解離
まずは基本となる VirtualAlloc の挙動です。MEM_COMMIT と実際の物理メモリ割り当てのタイミングを検証しました。
コード(抜粋)
int main() {
const size_t allocationSize = 4096 * 100;
LPVOID p1 = VirtualAlloc(NULL, allocationSize, MEM_RESERVE, PAGE_NOACCESS);
LPVOID p2 = VirtualAlloc(p1, allocationSize, MEM_COMMIT, PAGE_READWRITE);
VirtualAlloc(reset_target_page, 4096, MEM_RESET, PAGE_READWRITE);
VirtualProtect(guard_target_page, 4096, PAGE_READWRITE | PAGE_GUARD, &old);
VirtualAlloc(read_only_page, 4096, MEM_COMMIT, PAGE_READONLY);
VirtualAlloc(noaccess_page, 4096, MEM_COMMIT, PAGE_NOACCESS);
// ワーキングセットから削除(物理メモリから解放)する
SetProcessWorkingSetSize(GetCurrentProcess(), (SIZE_T)-1, (SIZE_T)-1);
for(int i = 0; i < 5; ++i) {
__try {
p[i * 4096] = (BYTE)i;
}
__except (GetExceptionCode() == STATUS_GUARD_PAGE_VIOLATION ?
EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH) {
DbgLog("[SUCCESS] STATUS_GUARD_PAGE_VIOLATION caught!\n");
DbgLog("Exception Code: 0x%x.\n", GetExceptionCode());
}
DbgLog("--- After Data Write (Page %d) ---\n", i);
PrintMemoryUsage();
DebugBreak();
}
}
検証結果
| ステップ | Private Usage | Working Set (WSS) | PTE状態 |
|---|---|---|---|
| MEM_RESERVE | 変化なし | 変化なし | 未割当 |
| MEM_COMMIT | +400KB | 変化なし |
not valid (Demand Zero) |
| データ書き込み | 維持 | +400KB | Valid (PFN割当済) |
考察
MEM_COMMIT は単なる「予約の確定」であり、物理メモリ(WSS)を消費しません。CPUがメモリにアクセスし、ページフォールトが発生して初めてOSは物理ページを割り当てます。
また、PAGE_GUARD や PAGE_NOACCESS を設定すると、PTEが not valid に書き換わり、そのページは即座にWSSから除外されます。
PAGE_GUARD設定後のログ
前回のPrivate Working Setがこれに対し
Private Working Set: 676 KB
PAGE_GUARD設定すると設定した1ページ分減ります
Private Working Set: 672 KB
これはPAGE_NOACCESSも同様の動きとなります。
この後、1ページずつ再アクセス(Write)すると、再び4KBずつWSSが増加しますが、最初のアクセスでは書き込めないPAGE_GUARDの場合はどうなるか。
PAGE_GUARD への接触時
[SUCCESS] STATUS_GUARD_PAGE_VIOLATION caught!
Working Set Size: 548 KB (+48KB)
ガードページに触れて例外が発生した際、WSSが想定(4KB)よりも大きく(ここでは48KB)増加します。
これは例外ハンドラ(__try/__except)の呼び出しやコンテキストスイッチに伴い、スタックやライブラリのページがWorking Setに引き込まれたためと考えられます。リアルタイム性が求められるシビアな環境では、こうした「付随的なWSS増加」にも注意が必要です。
2. Working Set TrimとTransitionリストの挙動
ではそもそもPAGE_NOACCESSやPAGE_GUARDで解放されたメモリはどこに行くのか。仮説を立てました。
仮説: 「これらは物理メモリの所有権を失って Transition リスト(Standby/Modified)に落ちているはず。だとしたら、これを2GB分作ってメモリを溢れさせたらどうなるのか?」
まずは素直に2GBを確保して0埋めした上で全ページをNOACCESS、PAGE_GUARDで交互に分割してみました。
Standby、Modifiedはそこまで増加しませんでした。
実行前
Standby: 168885 ( 675540 kb)
Modified: 16860 ( 67440 kb)
実行後
Standby: 136441 ( 545764 kb)
Modified: 18514 ( 74056 kb)
PTEを確認したところ以下のようになってました。
1: kd> !pte 0x000001C3C79D3000
VA 000001c3c79d3000
PXE at FFFF82C160B05018 PPE at FFFF82C160A03878 PDE at FFFF82C14070F1E0 PTE at FFFF8280E1E3CE98
contains 0A000000577A8867 contains 0A000000C66A9867 contains 0A000000A00C5867 contains 0000000000000280
pfn 577a8 ---DA--UWEV pfn c66a9 ---DA--UWEV pfn a00c5 ---DA--UWEV not valid
DemandZero
Protect: 14 - ReadWrite G
であるならば、属性を変える前に0埋めで書き込んでいるためにメモリ使用量が上がらなかったのかもしれません。では値を入れてみます。
実行前
Standby: 287325 ( 1149300 kb)
Modified: 13829 ( 55316 kb)
実行後
Standby: 189080 ( 756320 kb)
Modified: 456768 ( 1827072 kb)
Modified が 456,768ページ(約1.8GB)と良い感じに増加。
そしてPTEにTransitionビットが出現しPFNではPFNrestore pte 83F200002284 containing page 0BF5BA Standbyと設定されている。
1: kd> !pte 0x0000026C4D571000
VA 0000026c4d571000
PXE at FFFFCEE773B9D020 PPE at FFFFCEE773A04D88 PDE at FFFFCEE7409B1350 PTE at FFFFCE813626AB88
contains 0A0000011469B867 contains 0A0000011469C867 contains 0A000000BF5BA867 contains 00000000B93C2A80
pfn 11469b ---DA--UWEV pfn 11469c ---DA--UWEV pfn bf5ba ---DA--UWEV not valid
Transition: b93c2
Protect: 14 - ReadWrite G
1: kd> !pfn b93c2
PFN 000B93C2 at address FFFF9D80022BB460
flink 000982C3 blink / share count 000346EB pteaddress FFFFCE813626AB88
reference count 0000 used entry count 0000 Cached color 0 Priority 0
restore pte 83F200002284 containing page 0BF5BA Standby
またPAGE_NOACCESSは以下の通りページファイル行きが予定される事態に。
1: kd> !pte 0x0000026C4D570000
VA 0000026c4d570000
PXE at FFFFCEE773B9D020 PPE at FFFFCEE773A04D88 PDE at FFFFCEE7409B1350 PTE at FFFFCE813626AB80
contains 0A0000011469B867 contains 0A0000011469C867 contains 0A000000BF5BA867 contains 0001113100000302
pfn 11469b ---DA--UWEV pfn 11469c ---DA--UWEV pfn bf5ba ---DA--UWEV not valid
PageFile: 0
Offset: 11131
Protect: 18 - No Access
前述のPFNの結果からどうやら検証プロセスが優先度を低くされていることがわかった。(Priority 0)
そのため本プログラムをHIGH_PRIORITY_CLASSに変更。ついでにページファイルに逃がさないようページファイルも無効化。ついでに最強のプログラムであるNotepadに大量の文字を書き込み未保存の状態で実行。
この状態でもNotepadは落ちることなく起動したままとなる。
次はPAGE_NOACCESSとPAGE_GUARDを交互に設定した後、もう一周次はPAGE_GUARDとPAGE_NOACCESSに設定を書き換えてみる。予想としてはフラグを書き換えて終わりだと思うが、念のため。
実行したコードはこちら。
最終形コード(抜粋)
int main() {
DbgLog("Process ID: %d", GetCurrentProcessId());
const size_t allocationSize = 4096 * 500000;
const size_t PAGE_SIZE = 4096;
const size_t NUM_PAGES = allocationSize / PAGE_SIZE;
SetPriorityClass(GetCurrentProcess(), HIGH_PRIORITY_CLASS);
LPVOID p1 = VirtualAlloc(NULL, allocationSize, MEM_RESERVE, PAGE_READONLY);
if (!p1) return 1;
LPVOID p2 = VirtualAlloc(p1, allocationSize, MEM_COMMIT, PAGE_READWRITE);
for (size_t i = 0; i < NUM_PAGES; ++i) {
((unsigned char*)p1)[i * PAGE_SIZE] = (unsigned char)(i & 0xFF);
}
for (size_t i = 0; i < NUM_PAGES; ++i) {
// ページごとに属性をバラバラにする
DWORD old;
DWORD targetProtect;
switch (i % 2) {
case 0: targetProtect = PAGE_NOACCESS; break;
case 1: targetProtect = PAGE_READWRITE | PAGE_GUARD; break;
}
VirtualProtect((LPVOID)((uintptr_t)p1 + (i * PAGE_SIZE)), PAGE_SIZE, targetProtect, &old);
// 10000ページごとに進捗を出力(出しすぎるとVMが止まるため)
if (i % 10000 == 0) DbgLog("Allocated %zu pages...\n", i);
}
SetProcessWorkingSetSize(GetCurrentProcess(), (SIZE_T)-1, (SIZE_T)-1);
//次は逆にしてもう一周
for (size_t i = 0; i < NUM_PAGES; ++i) {
// ページごとに属性をバラバラにする
DWORD old;
DWORD targetProtect;
switch (i % 2) {
case 0: targetProtect = PAGE_READWRITE | PAGE_GUARD; break;
case 1: targetProtect = PAGE_NOACCESS; break;
}
VirtualProtect((LPVOID)((uintptr_t)p1 + (i * PAGE_SIZE)), PAGE_SIZE, targetProtect, &old);
// 10000ページごとに進捗を出力(出しすぎるとVMが止まるため)
if (i % 10000 == 0) DbgLog("Allocated %zu pages...\n", i);
}
SetProcessWorkingSetSize(GetCurrentProcess(), (SIZE_T)-1, (SIZE_T)-1);
}
そうすると奇妙なことが発生。
実行前
Standby: 67750 ( 271000 kb)
Modified: 527406 ( 2109624 kb)
実行後
Standby: 70932 ( 283728 kb)
Modified: 523688 ( 2094752 kb)
明らかに増加量が減少している。原因を調べてみると
0: kd> !pte 0x000001AA1AD60000
VA 000001aa1ad60000
PXE at FFFFA9D4EA753018 PPE at FFFFA9D4EA603540 PDE at FFFFA9D4C06A86B0 PTE at FFFFA980D50D6B00
contains 0A000000A0908867 contains 0A000000A0909867 contains 0A0000005A74B867 contains 0000000000000280
pfn a0908 ---DA--UWEV pfn a0909 ---DA--UWEV pfn 5a74b ---DA--UWEV not valid
DemandZero
Protect: 14 - ReadWrite G
一部のページがDemandZeroになっている。これはよろしくないと思うので無理やりワーキングセットに引き戻してみた。後から考えれば完全なる逆恨み。
PTEを書き換えて別のpfnと接続し「有効なページ」にする
0: kd> !pte 0x000001AA1AD60000
VA 000001aa1ad60000
PXE at FFFFA9D4EA753018 PPE at FFFFA9D4EA603540 PDE at FFFFA9D4C06A86B0 PTE at FFFFA980D50D6B00
contains 0A000000A0908867 contains 0A000000A0909867 contains 0A0000005A74B867 contains 000000006EB2F867
pfn a0908 ---DA--UWEV pfn a0909 ---DA--UWEV pfn 5a74b ---DA--UWEV pfn 6eb2f ---DA--UWEV
0: kd> !pfn 6eb2f
PFN 0006EB2F at address FFFFD380014C18D0
flink 000D1430 blink / share count 000C9801 pteaddress FFFFA980D50D6B08
reference count 0001 used entry count 0000 NonCached color 0 Priority 5
restore pte 300000300 containing page 05A74B Free
個人的には正しい状態になったのでそのまま進めてみたところBSOD発生。OSにとっては誤った形という判断。
KDTARGET: Refreshing KD connection
*** Fatal System Error: 0x0000001a
(0x0000000000000404,0xFFFFA980D50D6B00,0x000000006EB2F867,0xFFFFA980D50D6B08)
11 00007ff6`893414e2 : 00000000`00000000 000001aa`1ad60000 000001aa`1ab86a80 000001aa`1ab8d540 : KERNELBASE!VirtualFree+0x4a
12 00000000`00000000 : 000001aa`1ad60000 000001aa`1ab86a80 000001aa`1ab8d540 00000000`0007a120 : transitionList_Limit+0x14e2
2章のまとめ:勘違いから見えた「OSの執念」
当初、VirtualProtect で属性を変えれば即座に物理メモリの所有権がリスト(Modified/Standby)へ移ると予想していましたが、実際には 「属性変更(論理)」と「Trim(物理)」は完全に別物 でした。
しかし、あえて値を書き込み、ページファイルを殺し、優先度を上げ、最後にはPTEを力ずくで書き換えてBSODに至るまでの過程で、以下の「Windowsの深層」が浮き彫りになりました。
- ゼロページの冷遇: 0で埋められたページは、OSにとって「保存する価値なし」と判断され、即座に再利用リストへ回される。
-
Transition状態の粘り: 有意なデータを持つページは、プロセスから切り離されてもなお、PTEに
Transitionビットを残し、物理メモリの片隅で「飼い主」が戻ってくるのを待っている。 - カーネルの鉄槌: PFNとPTEの不整合(PFNデータベースを無視した勝手な接続)を、Windowsカーネルは一瞬で見抜き、システムの整合性を守るために自ら停止(BSOD)を選ぶ。
「なぜBSODまで行ったのか?」——それは、OSという名の管理者が、いかに厳格に物理メモリの整合性を管理しているかを証明するため、避けては通れない道だったのです(ということにしましょう)。
※余談ですが、AIにPFNの書き換えを実施したかどうか判断させたところChatGPTは理論のみ、Geminiは実際に書き換えているとの回答でした。今回必要なところを書き換えていないのでどっちが正しいかは読めば分かります。
おわりに
OSが『保存価値なし』と判断する基準を理解することは、スワップが許されないミッションクリティカルな環境でのメモリ・レジリエンス(回復力)を高める鍵となります。
本当はこの後、共有メモリの検証結果も載せようと思ったのですが、恐らく消化不良になるので前半後半に分けます。