物理メモリが共有されコピーオンライトで複製される様子を図示する
Windows で同じプログラムを複数起動すると、それぞれに仮想アドレス空間が割り当てられます。プロセス分離です。しかし、完全に分離しているのではなく、裏では一部の物理メモリが共有されています。
また、この共有されたメモリに書き込みを行おうとすると、ほかのプロセスに影響を与えないようメモリが自動的に複製されます。コピーオンライト(Copy-on-Write)です。
この物理メモリの共有やコピーオンライトの実際の動作を見てみます。
動作確認環境
- Windows 11 Home 24H2
- Visual Studio Community 2022 (Visual C++)
- WinDbg 1.2502.25002.0
データやコードを書き換えるプログラムを作成
テスト用に、次の処理を順番に実行するプログラムを作りました。
- 変数の値 “Hello, Japan!” をメッセージボックスに表示
- 変数の値を “Hello, Anpan!” に書き換えてメッセージボックスを表示
- WinMain 関数の先頭のコードを 0xCC 0xCC 0xCC 0x00 に書き換えてメッセージボックスを表示
ソースコード test.c です。
#include <windows.h>
char *pGlobal = "Hello, Japan!";
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
// メッセージボックスを表示
MessageBox(0, pGlobal, "test #1", MB_OK);
// データを書き換えて、メッセージボックスを表示
*(pGlobal + 7) = 'A';
*(pGlobal + 8) = 'n';
MessageBox(0, pGlobal, "test #2", MB_OK);
// コードを書き換えて、メッセージボックスを表示
HANDLE hProcess = GetCurrentProcess();
LPVOID lpAddress = WinMain;
int newValue = 0xCCCCCC; // 0xCC = int 3 = 'フ'
BOOL bSuccess = WriteProcessMemory(hProcess, lpAddress, &newValue, sizeof(newValue), NULL);
if (! bSuccess)
{
MessageBox(0, "error", "test #3", MB_OK);
return 1;
}
MessageBox(0, (char *)WinMain, "test #3", MB_OK);
return 0;
}
x64 Native Tools Command Prompt for VS 2022 のコマンドプロンプトを開きます。

cl コマンドでビルドします。IDE を使っても構いませんが、軽いからです。オプションの「/Od」は最適化なし、「/Zi」はデバッグを情報を生成、の意味です。
C:\tmp> cl /Od /Zi test.c /link user32.lib
test.exe が生成されました。
メモリレイアウトの確認
test.exe を 2 個起動します。

各プロセスの仮想アドレス空間を調べたところ、レイアウトはまったく同じでした。たとえば、
- メインルーチンである WinMain 関数のコード(正確には WinMain 関数にジャンプするコード)
- グローバル変数にセットしている “Hello, Japan!” のデータ
- OS の user32.dll が提供する MessageBox 関数のコード(正確には ASCII 用の MessageBoxA 関数のコード)
は、今回、以下の場所にロードされていました。

そして、各仮想アドレスに対応する物理アドレスは次のようになっていました。

WinMain (青)や MessageBox (緑)のコードが共有されていることがわかります。コードは基本的に読み取り専用なので共有が可能で、共有すれば物理メモリを節約できます。
一方、”Hello, Japan!” (赤)のデータは、プロセスごとに専用の物理メモリが割り当てられていました。データは書き換えられる可能性が高いので最初から分けておこうという判断でしょう
もともと .exe/.dll ファイルの内部は、ここが読み取り専用のコード領域、ここが読み書き可能なデータ領域などと複数のセクションに分かれています。Visual Studio 付属の dumpbin ツールで見ると確認できます。
C:\tmp> dumpbin /headers test.exe
......
SECTION HEADER #1
.text name ★ テキストセクション
......
Code ★ コード
Execute Read ★ 実行可能, 読み取り専用
......
SECTION HEADER #3
.data name ★ データセクション
......
Initialized Data ★ 初期化済みデータ
Read Write ★ 読み書き可能
......
データ領域やコード領域に書き込みを行うと
プロセス 1 で [OK] ボタンを押して、変数の値を “Hello, Japan!” から “Hello, Anpan!” に書き換えます。

プロセス 1 専用のデータ領域が書き換えられました。もともとプロセス 2 とは実体が異なるため、相手方には干渉していません。

続いて、もう一度プロセス 1 で [OK] ボタンを押し、WinMain の先頭に 0xCC 0xCC 0xCC 0x00(= int 3, int 3, int 3 = ‘フフフ’)を書き込みます。ポインタを使って普通にコード領域に書き込みを行うとメモリアクセス違反で例外が発生するので、Windows API の WriteProcessMemory 関数を利用して書き込みます。

書き込み後のレイアウトは次の通りです。

物理メモリ内の WinMain が OS によって自動的に複製され、複製された側のコード領域が書き換えられました。コピーオンライト(書き込み時コピー)です。この動作により、プロセス 2 への影響が回避できています。
また、プロセス 1 の仮想アドレス空間における WinMain のアドレスは変わらず、裏でこっそり物理アドレスとのマッピングが変更されています。これにより、プロセス 1 はメモリが複製されたことを意識することなく動作を継続できます。
もう一方のプロセスからも書き込み
もう一方のプロセス 2 で [OK] ボタンを押して、変数の値を “Hello, Japan!” から “Hello, Anpan!” に書き換えます。

プロセス 2 専用のデータ領域が書き換えられました。

続いて、もう一度プロセス 2 で [OK] ボタンを押し、WinMain の先頭に 0xCC 0xCC 0xCC 0x00 を書き込んでみます。

書き込み後のレイアウトは次の通りです。

物理メモリ内の WinMain が OS によって自動的に複製され、複製された側のコード領域が書き換えられました。
物理メモリ内の旧 WinMain コードを参照しているのはすでにプロセス 2 だけなので、そのまま書き換えても問題ないように思えましたが、コピーオンライトが作動しました。理由はわかりません。
なお、書き込みを行っていない MessageBox 関数は、最初から最後まで共有された状態です。
調査手順について
上記の調査は参考文献 [1] [2] に示した WinDbg のカーネルデバッグ機能を使って行いました。参考までに手順の一部を書き留めておきます。
0: kd> !sym noisy ★ シンボルのロード状況を表示するよう指定。
noisy mode - symbol prompts on
0: kd> .symfix c:\symbols ★ シンボルの格納先を c:\symbols に設定。
......
0: kd> .reload /f ★ シンボル一式のロード。
......
0: kd> !process 0 0 test.exe ★ test プロセスの基本情報を表示。
PROCESS ffff9b076bb34080
SessionId: none Cid: 0880 Peb: ea3aa83000 ParentCid: 13c4
DirBase: 21351d002 ObjectTable: ffffdf09d7951d00 HandleCount: 114.
Image: test.exe
PROCESS ffff9b076cc870c0
SessionId: none Cid: 10f0 Peb: e513481000 ParentCid: 13c4
DirBase: 1e1b4a002 ObjectTable: ffffdf09d716d6c0 HandleCount: 117.
Image: test.exe
0: kd> .process /i /p ffff9b076bb34080 ★ ひとつ目のプロセスに、
You need to continue execution (press 'g' <enter>) for the context
to be switched. When the debugger breaks in again, you will be in
the new process context.
0: kd> g ★ 侵入して、
Break instruction exception - code 80000003 (first chance)
.....
1: kd> .reload /user ★ ユーザーモードのシンボルをロード。
......
1: kd> lm ★ 各モジュールのアドレスを確認。
start end module name
00007ff7`8d740000 00007ff7`8d7ea000 test C (private pdb symbols) C:\tp\checkaddress\test.pdb
00007ffd`99590000 00007ffd`9963b000 TextShaping (deferred)
00007ffd`a8e30000 00007ffd`a8f7d000 textinputframework (deferred)
00007ffd`af7f0000 00007ffd`afad3000 CoreUIComponents (deferred)
00007ffd`b3490000 00007ffd`b35b6000 CoreMessaging (deferred)
00007ffd`b3be0000 00007ffd`b3c8f000 uxtheme (deferred)
00007ffd`b5d70000 00007ffd`b5d8a000 kernel_appcore (deferred)
00007ffd`b6580000 00007ffd`b658c000 CRYPTBASE (deferred)
00007ffd`b6e90000 00007ffd`b6eb7000 win32u (deferred)
00007ffd`b6ec0000 00007ffd`b6ff2000 gdi32full (deferred)
00007ffd`b7090000 00007ffd`b71db000 ucrtbase (deferred)
00007ffd`b71e0000 00007ffd`b7279000 bcryptPrimitives (deferred)
00007ffd`b7400000 00007ffd`b77cc000 KERNELBASE (deferred)
00007ffd`b77d0000 00007ffd`b7944000 wintypes (deferred)
00007ffd`b7a10000 00007ffd`b7ab3000 msvcp_win (deferred)
00007ffd`b8650000 00007ffd`b86f9000 msvcrt (deferred)
00007ffd`b8700000 00007ffd`b8816000 RPCRT4 (deferred)
00007ffd`b8820000 00007ffd`b88c6000 sechost (deferred)
00007ffd`b8de0000 00007ffd`b8f7f000 ole32 (deferred)
00007ffd`b9090000 00007ffd`b9170000 OLEAUT32 (deferred)
00007ffd`b91b0000 00007ffd`b9279000 KERNEL32 (deferred)
00007ffd`b9280000 00007ffd`b92ab000 GDI32 (deferred)
00007ffd`b9360000 00007ffd`b952a000 USER32 (deferred)
00007ffd`b9530000 00007ffd`b9560000 IMM32 (deferred)
00007ffd`b9600000 00007ffd`b975f000 MSCTF (deferred)
00007ffd`b9870000 00007ffd`b9922000 advapi32 (deferred)
00007ffd`b9930000 00007ffd`b9cb4000 combase (deferred)
00007ffd`b9d00000 00007ffd`b9f66000 ntdll (deferred)
......
1: kd> uf test!WinMain ★ WinMain を逆アセンブルして、
test!WinMain [C:\tp\checkaddress\test.c @ 6]:
6 00007ff7`8d747250 44894c2420 mov dword ptr [rsp+20h],r9d
6 00007ff7`8d747255 4c89442418 mov qword ptr [rsp+18h],r8
6 00007ff7`8d74725a 4889542410 mov qword ptr [rsp+10h],rdx
6 00007ff7`8d74725f 48894c2408 mov qword ptr [rsp+8],rcx
6 00007ff7`8d747264 4883ec58 sub rsp,58h
8 00007ff7`8d747268 4533c9 xor r9d,r9d
8 00007ff7`8d74726b 4c8d05a62d0900 lea r8,[test!pGlobal+0x18 (00007ff7`8d7da018)]
8 00007ff7`8d747272 488b15872d0900 mov rdx,qword ptr [test!pGlobal (00007ff7`8d7da000)]
8 00007ff7`8d747279 33c9 xor ecx,ecx
8 00007ff7`8d74727b ff154fd10900 call qword ptr [test!_imp_MessageBoxA (00007ff7`8d7e43d0)]
11 00007ff7`8d747281 488b05782d0900 mov rax,qword ptr [test!pGlobal (00007ff7`8d7da000)]
11 00007ff7`8d747288 c6400741 mov byte ptr [rax+7],41h
12 00007ff7`8d74728c 488b056d2d0900 mov rax,qword ptr [test!pGlobal (00007ff7`8d7da000)] ★ pGlobal 変数の場所と、
12 00007ff7`8d747293 c640086e mov byte ptr [rax+8],6Eh
13 00007ff7`8d747297 4533c9 xor r9d,r9d
13 00007ff7`8d74729a 4c8d057f2d0900 lea r8,[test!pGlobal+0x20 (00007ff7`8d7da020)]
13 00007ff7`8d7472a1 488b15582d0900 mov rdx,qword ptr [test!pGlobal (00007ff7`8d7da000)]
13 00007ff7`8d7472a8 33c9 xor ecx,ecx
13 00007ff7`8d7472aa ff1520d10900 call qword ptr [test!_imp_MessageBoxA (00007ff7`8d7e43d0)]
16 00007ff7`8d7472b0 ff1562cd0900 call qword ptr [test!_imp_GetCurrentProcess (00007ff7`8d7e4018)]
16 00007ff7`8d7472b6 4889442440 mov qword ptr [rsp+40h],rax
17 00007ff7`8d7472bb 488d0555bdffff lea rax,[test!ILT+8210(WinMain) (00007ff7`8d743017)] ★ WinMain 名の書き込み先を特定。
17 00007ff7`8d7472c2 4889442438 mov qword ptr [rsp+38h],rax
18 00007ff7`8d7472c7 c7442430cccccc00 mov dword ptr [rsp+30h],0CCCCCCh
19 00007ff7`8d7472cf 48c744242000000000 mov qword ptr [rsp+20h],0
19 00007ff7`8d7472d8 41b904000000 mov r9d,4
19 00007ff7`8d7472de 4c8d442430 lea r8,[rsp+30h]
19 00007ff7`8d7472e3 488b542438 mov rdx,qword ptr [rsp+38h]
19 00007ff7`8d7472e8 488b4c2440 mov rcx,qword ptr [rsp+40h]
19 00007ff7`8d7472ed ff152dcd0900 call qword ptr [test!_imp_WriteProcessMemory (00007ff7`8d7e4020)]
19 00007ff7`8d7472f3 89442434 mov dword ptr [rsp+34h],eax
20 00007ff7`8d7472f7 837c243400 cmp dword ptr [rsp+34h],0
20 00007ff7`8d7472fc 7520 jne test!WinMain+0xce (00007ff7`8d74731e) Branch
test!WinMain+0xae [C:\tp\checkaddress\test.c @ 22]:
22 00007ff7`8d7472fe 4533c9 xor r9d,r9d
22 00007ff7`8d747301 4c8d05202d0900 lea r8,[test!pGlobal+0x28 (00007ff7`8d7da028)]
22 00007ff7`8d747308 488d15212d0900 lea rdx,[test!pGlobal+0x30 (00007ff7`8d7da030)]
22 00007ff7`8d74730f 33c9 xor ecx,ecx
22 00007ff7`8d747311 ff15b9d00900 call qword ptr [test!_imp_MessageBoxA (00007ff7`8d7e43d0)]
23 00007ff7`8d747317 b801000000 mov eax,1
23 00007ff7`8d74731c eb1b jmp test!WinMain+0xe9 (00007ff7`8d747339) Branch
test!WinMain+0xce [C:\tp\checkaddress\test.c @ 25]:
25 00007ff7`8d74731e 4533c9 xor r9d,r9d
25 00007ff7`8d747321 4c8d05102d0900 lea r8,[test!pGlobal+0x38 (00007ff7`8d7da038)]
25 00007ff7`8d747328 488d15e8bcffff lea rdx,[test!ILT+8210(WinMain) (00007ff7`8d743017)]
25 00007ff7`8d74732f 33c9 xor ecx,ecx
25 00007ff7`8d747331 ff1599d00900 call qword ptr [test!_imp_MessageBoxA (00007ff7`8d7e43d0)]
27 00007ff7`8d747337 33c0 xor eax,eax
test!WinMain+0xe9 [C:\tp\checkaddress\test.c @ 28]:
28 00007ff7`8d747339 4883c458 add rsp,58h
28 00007ff7`8d74733d c3 ret
1: kd> dp 00007ff7`8d7da000 ★ pGlobal ポインタには 00007ff7`8d7da008 が入っており、
00007ff7`8d7da000 00007ff7`8d7da008 4a202c6f`6c6c6548
......
1: kd> da 00007ff7`8d7da008 ★ "Hello, Japan!" を指していることを確認。
00007ff7`8d7da008 "Hello, Japan!"
1: kd> dpa 00007ff7`8d7da000 L1 ★ 別の確認方法。
00007ff7`8d7da000 00007ff7`8d7da008 "Hello, Japan!"
1: kd> u 00007ff7`8d743017 ★ WinMain 名の書き込み先は、真の WinMain にジャンプするコードになっていたが、WinMain として扱う。
test!ILT+8210(WinMain):
00007ff7`8d743017 e934420000 jmp test!WinMain (00007ff7`8d747250)
.....
1: kd> !pte 00007ff7`8d743017 ★ プロセス 1 の WinMain のページテーブルエントリーを取得。
VA 00007ff78d743017
PXE at FFFF964B2592C7F8 PPE at FFFF964B258FFEF0 PDE at FFFF964B1FFDE358 PTE at FFFF963FFBC6BA18
contains 8A00000219429867 contains 0A0000017662A867 contains 0A00000218F2B867 contains 01000001447A2025
pfn 219429 ---DA--UW-V pfn 17662a ---DA--UWEV pfn 218f2b ---DA--UWEV pfn 1447a2 ----A--UREV ★ この最後の pfn が重要。
1: kd> !db (1447a2*1000)+(00007ff7`8d743017&FFF) L10 ★ プロセス 1 の WinMain の物理アドレス。
#1447a2017 e9 34 42 00 00 e9 3f 11-06 00 e9 76 52 04 00 e9 .4B...?....vR...
1: kd> !pte 00007ff7`8d7da008 ★ プロセス 1 の "Hello, Japan!" のページテーブルエントリーを取得。
VA 00007ff78d7da008
PXE at FFFF964B2592C7F8 PPE at FFFF964B258FFEF0 PDE at FFFF964B1FFDE358 PTE at FFFF963FFBC6BED0
contains 8A00000219429867 contains 0A0000017662A867 contains 0A00000218F2B867 contains 8100000144E60847
pfn 219429 ---DA--UW-V pfn 17662a ---DA--UWEV pfn 218f2b ---DA--UWEV pfn 144e60 ---D---UW-V ★ この最後の pfn が重要。
1: kd> !db (144e60*1000)+(00007ff7`8d7da008&FFF) L10 ★ "Hello, Japan!" の物理アドレス。
#144e60008 48 65 6c 6c 6f 2c 20 4a-61 70 61 6e 21 00 00 00 Hello, Japan!...
1: kd> u user32!MessageBoxA ★ MessageBoxA 関数の先頭が適切な命令か確認。
USER32!MessageBoxA:
00007ffd`b93eb2c0 4883ec38 sub rsp,38h
00007ffd`b93eb2c4 4533db xor r11d,r11d
00007ffd`b93eb2c7 44391d6aa50400 cmp dword ptr [USER32!gfEMIEnable (00007ffd`b9435838)],r11d
00007ffd`b93eb2ce 7425 je USER32!MessageBoxA+0x35 (00007ffd`b93eb2f5)
00007ffd`b93eb2d0 65488b042530000000 mov rax,qword ptr gs:[30h]
00007ffd`b93eb2d9 4c8b5048 mov r10,qword ptr [rax+48h]
00007ffd`b93eb2dd 33c0 xor eax,eax
00007ffd`b93eb2df f04c0fb11510ab0400 lock cmpxchg qword ptr [USER32!gdwEMIThreadID (00007ffd`b9435df8)],r10
1: kd> !pte user32!MessageBoxA ★ プロセス 1 の MessageBoxA のページテーブルエントリーを取得。
VA 00007ffdb93eb2c0
PXE at FFFF964B2592C7F8 PPE at FFFF964B258FFFB0 PDE at FFFF964B1FFF6E48 PTE at FFFF963FFEDC9F58
contains 8A00000219429867 contains 0A0000012352C867 contains 0A0000019AC62867 contains 0100000118707025
pfn 219429 ---DA--UW-V pfn 12352c ---DA--UWEV pfn 19ac62 ---DA--UWEV pfn 118707 ----A--UREV
1: kd> !db (118707*1000)+(user32!MessageBoxA&FFF) L10 ★ プロセス 1 の MessageBoxA の物理アドレス。
#1187072c0 48 83 ec 38 45 33 db 44-39 1d 6a a5 04 00 74 25 H..8E3.D9.j...t%
1: kd> .process /i /p ffff9b076cc870c0 ★ ふたつ目のプロセスに、
......
1: kd> g ★ 侵入して、
......
1: kd> .reload /user ★ ユーザーモードのシンボルをロード。
......
1: kd> lm ★ 各モジュールのアドレスを確認。先のプロセスと同じ。
......
1: kd> uf test!WinMain ★ WinMain を逆アセンブル。先のプロセスと同じ。
......
1: kd> !pte 00007ff7`8d743017 ★ プロセス 2 の WinMain のページテーブルエントリーを取得。
VA 00007ff78d743017
PXE at FFFF964B2592C7F8 PPE at FFFF964B258FFEF0 PDE at FFFF964B1FFDE358 PTE at FFFF963FFBC6BA18
contains 8A000001CF656867 contains 0A000001D6657867 contains 0A000001C9E58867 contains 01000001447A2005
pfn 1cf656 ---DA--UW-V pfn 1d6657 ---DA--UWEV pfn 1c9e58 ---DA--UWEV pfn 1447a2 -------UREV
1: kd> !db (1447a2*1000)+(00007ff7`8d743017&FFF) L10 ★ プロセス 2 の WinMain の物理アドレス
#1447a2017 e9 34 42 00 00 e9 3f 11-06 00 e9 76 52 04 00 e9 .4B...?....vR...
1: kd> !pte 00007ff7`8d7da008 ★ プロセス 2 の "Hello, Japan!" のページテーブルエントリーを取得。プロセス 1 とは異なる。
VA 00007ff78d7da008
PXE at FFFF964B2592C7F8 PPE at FFFF964B258FFEF0 PDE at FFFF964B1FFDE358 PTE at FFFF963FFBC6BED0
contains 8A000001CF656867 contains 0A000001D6657867 contains 0A000001C9E58867 contains 810000020CB8D867
pfn 1cf656 ---DA--UW-V pfn 1d6657 ---DA--UWEV pfn 1c9e58 ---DA--UWEV pfn 20cb8d ---DA--UW-V
1: kd> !db (20cb8d*1000)+(00007ff7`8d7da008&FFF) L10 ★ プロセス 2 の "Hello, Japan!" の物理アドレス。
#20cb8d008 48 65 6c 6c 6f 2c 20 4a-61 70 61 6e 21 00 00 00 Hello, Japan!...
1: kd> !pte 00007ffd`b93eb2c0 ★ プロセス 2 の MessageBoxA のページテーブルエントリーを取得。
VA 00007ffdb93eb2c0
PXE at FFFF964B2592C7F8 PPE at FFFF964B258FFFB0 PDE at FFFF964B1FFF6E48 PTE at FFFF963FFEDC9F58
contains 8A000001CF656867 contains 0A000001C9B59867 contains 0A0000020798F867 contains 0100000118707025
pfn 1cf656 ---DA--UW-V pfn 1c9b59 ---DA--UWEV pfn 20798f ---DA--UWEV pfn 118707 ----A--UREV
1: kd> !db (118707*1000)+(00007ffd`b93eb2c0&FFF) L10 ★ プロセス 2 の MessageBoxA の物理アドレス。
#1187072c0 48 83 ec 38 45 33 db 44-39 1d 6a a5 04 00 74 25 H..8E3.D9.j...t%
1: kd> g ★ プログラム再開。test.exe のメッセージボックスの [OK] ボタンを押下。
★ WinDbg 画面上の Break ボタンを押してデバッグ再開。
0: kd> .process /i /p ffff9b076bb34080 ★ プロセス 1 に侵入して、
0: kd> g
6: kd> .reload /user
6: kd> !pte 00007ff7`8d743017 ★ プロセス 1 の WinMain のページテーブルエントリーを取得。
VA 00007ff78d743017
PXE at FFFF964B2592C7F8 PPE at FFFF964B258FFEF0 PDE at FFFF964B1FFDE358 PTE at FFFF963FFBC6BA18
contains 8A00000219429867 contains 0A0000017662A867 contains 0A00000218F2B867 contains 01000001447A2005
pfn 219429 ---DA--UW-V pfn 17662a ---DA--UWEV pfn 218f2b ---DA--UWEV pfn 1447a2 -------UREV
6: kd> !db (1447a2*1000)+(00007ff7`8d743017&FFF) L10 ★ プロセス 1 の WinMain の物理アドレス。
#1447a2017 e9 34 42 00 00 e9 3f 11-06 00 e9 76 52 04 00 e9 .4B...?....vR...
★ 以下同様の手順で調査。
おわりに
複数のプロセスがメモリを共有する様子や、共有メモリへの書き込み時に内容がコピーされる様子を、物理メモリのレベルで確認しました。
参考文献
[1] やや低レイヤー研究所「WinDbg のカーネルデバッグで使える USB 3.0 ケーブルを作る」
https://yaya.lsv.jp/usb-debug-cable/
[2] やや低レイヤー研究所「2 台の PC を USB デバッグケーブルで接続してカーネルデバッグする方法 (Windows)」
https://yaya.lsv.jp/usb_debug_setup/