Windbg로 알아보는 Prototype PTE
지금까지 PTE를 설명하면서 Hard, Soft, Trans 유형을 알아봤는데요,
Windbg로 알아보는 PTE (_MMPTE)
; https://www.sysnet.pe.kr/2/0/13845
Windbg로 알아보는 Trans/Soft PTE와 2가지 Page Fault 유형
; https://www.sysnet.pe.kr/2/0/13846
이번에는 Prototype PTE에 대해 다음의 글을 기반으로
베껴 정리해 보겠습니다. ^^
Prototype PTEs
; https://codemachine.com/articles/prototype_ptes.html
공유 메모리인 경우, 예를 들어 2개의 프로세스가 memory mapped file을 열어 같은 메모리를 바라보게 되면 각각의 프로세스에 있는 PTE는 동일한 PFN을 가리키게 됩니다.
[A Process PTE] [PFN a9d33]
+------------------+ +-------------------------------+
| Valid | | |
| PFN = a9d33 | -----> | Active Shared == 2 |
+------------------+ | |
| |
[B Process PTE] | |
+------------------+ | |
| Valid | | |
| PFN = a9d33 | -----> | |
+------------------+ +-------------------------------+
물론, 저렇게만 구성하고 있으면 문제가 발생할 수 있는 시나리오가 있습니다.
1. A 프로세스가 Working set trimmer에 의해 PTE 연결이 PFN과 끊긴 경우,
2. B 프로세스도 Working set trimmer에 의해 PTE 연결이 PFN과 끊긴 경우,
3. 이후 A 프로세스가 해당 메모리를 참조해 달라진 PFN이 로드되었다면,
4. B 프로세스는 이후 어떻게 그 달라진 PFN을 찾아서 참조해야 할까?
개인적인 생각으로는, 위와 같은 상황에서 윈도우 커널은 PFN을 참조하는 PTE에 대한 연결 리스트를 유지하고 있을 것이라고 생각했습니다. 하지만 ^^ 역시 머리 좋은 개발자들이 작성한 거라 전혀 다른 방식으로 해결하고 있었는데요, 이를 위해 "Prototype PTE"라는 것이 등장합니다.
방법 자체는 단순합니다. 윈도우 커널에서 공유 메모리는 Memory Manager 측에서 "
Section Object"를 생성해 관리를 합니다. 이때 Section Object는 가상 메모리 페이징에 사용되는 Hard PTE 항목에 준하는 필드를 Paged Pool 영역에 할당하고 연결시켜 두는데요, 바로 그것을 가리켜 Prototype PTE라고 합니다. (다시 말해, 가상 메모리의 페이징에 사용되는 페이지 테이블과는 무관하게 커널이 임의로 만든 PTE라고 보면 됩니다.)
실제로 이 Prototype PTE가 하는 역할은 Hard PTE처럼 PFN을 가리키게 됩니다. 그래서, 공유 메모리를 사용하는 경우의 구성은 다음과 같이 이뤄집니다.
[A Process PTE] [PFN a9d33]
+------------------+ +-------------------------------+
| Valid | | PteAddress == (Prototype PTE) |
| PFN = a9d33 | -----> | Active Shared == 2 |
+------------------+ | |
| |
| |
[Prototype PTE] | |
+------------------+ | |
| Valid | ------> | |
| PFN = a9d33 | <------ | |
+------------------+ | |
| |
| |
[B Process PTE] | |
+------------------+ | |
| Valid | | |
| PFN = a9d33 | -----> | |
+------------------+ +-------------------------------+
게다가 PFN 측에서도 실제 페이징에 연관된 프로세스들의 PTE를 가리키지 않고 Memory Manager가 만든 Section Object의 Prototype PTE를 가리키게 됩니다.
자, 그럼 저 상태에서 A 프로세스의 PTE가 PFN과의 연결이 끊기면 이제 해당 PTE는 Prototype PTE를 가리키도록 바뀝니다.
[A Process PTE] [PFN a9d33]
+-------------------------+ +-------------------------------+
| Not Valid | | PteAddress == (Prototype PTE) |
| Proto = (addr of proto) | | Active Shared == 1 |
+-------------------------+ | |
| | |
| | |
| | |
[Prototype PTE] v | |
+------------------+ | |
| Valid | ------> | |
| PFN = a9d33 | <------ | |
+------------------+ | |
| |
| |
| |
[B Process PTE] | |
+------------------+ | |
| Valid | | |
| PFN = a9d33 | -----> | |
+------------------+ +-------------------------------+
마찬가지로 B 프로세스의 PTE마저 PFN과의 연결이 끊기면 이제 PFN a9d33의 Shared == 0이 되므로 해당 PFN은 Standby 또는 Modified 연결 리스트로 반환되고 다른 프로세스에 의해 사용 가능한 상태가 됩니다.
이후, A 또는 B가 다시 공유 메모리를 참조하게 되면 Prototype PTE부터 PFN 연결을 하게 되고, 프로세스 측의 PTE는 Prototype PTE가 가리키는 PFN을 다시 바라보는 식으로 동작합니다. 오호~~~ 저것만으로도 아무런 문제 없이 공유 메모리가 유지되는군요. ^^
그런데, 위의 시나리오는 커널에서 공유 메모리를 생성해 사용하는 경우에 해당합니다. User Mode에서 공유 메모리를 사용하는 경우에는 좀 더 복잡한 구조가 나오는데요, 이유는 알 수 없지만 사용자 응용 프로그램의 경우에는 EPROCESS가 가지고 있는 VAD를 거쳐서 Prototype PTE를 참조하는 식으로 바뀝니다.
따라서, 위에서 "A Process PTE"가 끊긴 경우라고 설명하면서 그린 다이어그램은, 실제로는 A Process가 사용자 모드 프로그램이므로 다음과 같은 식으로 바뀝니다.
[A Process PTE] [PFN a9d33]
+-------------------------+ +-------------------------------+
| Not Valid | | PteAddress == (Prototype PTE) |
| Status: Proto | | Active Shared == 1 |
+-------------------------+ | |
| |
| |
[A Process VAD] | |
+-------------------------+ | |
| VAD | | |
| Proto = (addr of proto) | | |
+-------------------------+ | |
| | |
| | |
| | |
[Prototype PTE] v | |
+------------------+ | |
| Valid | ------> | |
| PFN = a9d33 | <------ | |
+------------------+ | |
| |
| |
| |
[B Process PTE] | |
+------------------+ | |
| Valid | | |
| PFN = a9d33 | -----> | |
+------------------+ +-------------------------------+
자, 그럼 실제로 Windbg를 통해 확인해 볼까요? ^^
확인 방법은 1) 커널에서 생성한 공유 메모리의 Prototype PTE, 2) 사용자 모드에서 생성한 공유 메모리의 Prototype PTE를 확인하는 것으로 나뉘는데요, 아쉽게도 1번 방법은
Examining Prototype PTEs (Case #1) 내용을 참고하시고, 2번 방법에 대해서만 실습을 해보겠습니다.
이를 위해 공유 메모리를 사용하는 C# 프로그램을 하나 준비하고,
internal class Program
{
[DllImport("kernel32.dll")]
static extern bool SetProcessWorkingSetSize(IntPtr hProcess, nint dwMinimumWorkingSetSize, nint dwMaximumWorkingSetSize);
static unsafe void Main(string[] args)
{
int pid = Environment.ProcessId;
Console.WriteLine($"Process ID: {pid} (0x{pid:x})");
long size = 1024 * 1024 * 20; // 20MB 크기
{
MemoryMappedFile? mmf = null;
try
{
mmf = MemoryMappedFile.OpenExisting("my_map", MemoryMappedFileRights.ReadWrite);
}
catch (FileNotFoundException)
{
}
if (mmf == null)
{
mmf = MemoryMappedFile.CreateFromFile("c:\\temp\\test.txt",
FileMode.OpenOrCreate, "my_map", size,
MemoryMappedFileAccess.ReadWrite);
}
if (mmf == null)
{
return;
}
using (MemoryMappedViewAccessor mmva = mmf.CreateViewAccessor(0, size, MemoryMappedFileAccess.ReadWrite))
{
for (long i = 0; i < size / 2; i++)
{
char ch = (char)(0x30 + (i % (0x7e - 0x30)));
mmva.Write(i, ch);
}
IntPtr ptrAddress = GetBufferAddress(mmva);
Console.WriteLine($"mm-virtual address: 0x{ptrAddress:x}");
while (true)
{
string? text = Console.ReadLine();
if (text == "q")
{
break;
}
if (text == "m")
{
SetProcessWorkingSetSize(System.Diagnostics.Process.GetCurrentProcess().Handle, -1, -1);
}
}
}
}
}
private static unsafe IntPtr GetBufferAddress(MemoryMappedViewAccessor mmva)
{
SafeMemoryMappedViewHandle handle = mmva.SafeMemoryMappedViewHandle;
byte* ptrAddress = null;
handle.AcquirePointer(ref ptrAddress);
return new nint(ptrAddress);
}
}
두 번 프로세스를 실행하면 이런 결과를 얻게 됩니다.
// 같은 물리 메모리를 가리키는 서로 다른 프로세스의 가상 주소
Process ID: 12996 (0x32c4)
mm-virtual address: 0x20d05130000
Process ID: 106836 (0x1a154)
mm-virtual address: 0x1d483620000
이 상태에서 라이브 커널 디버깅을 windbg로 연결해 각각의 PTE를 확인해 봅니다.
7: kd> !process 0 0 mem_map.exe
PROCESS ffff868231f8f0c0
SessionId: 2 Cid: 32c4 Peb: f5c9a45000 ParentCid: 2278
DirBase: 1700f0000 ObjectTable: ffff978b10b52b00 HandleCount: 177.
Image: mem_map.exe
PROCESS ffff868231a290c0
SessionId: 2 Cid: 1a154 Peb: 942874c000 ParentCid: 2278
DirBase: 07309000 ObjectTable: ffff978ae0044b00 HandleCount: 171.
Image: mem_map.exe
// [생략: ffff868231f8f0c0 프로세스 문맥 전환]
4: kd> !pte 0x20d05130000
VA 0000020d05130000
PXE at FFFFFA7D3E9F4020 PPE at FFFFFA7D3E8041A0 PDE at FFFFFA7D00834140 PTE at FFFFFA0106828980
contains 0A000001D520F867 contains 0A000001DF210867 contains 0A00000164E50867 contains C0000001CC012867
pfn 1d520f ---DA--UWEV pfn 1df210 ---DA--UWEV pfn 164e50 ---DA--UWEV pfn 1cc012 ---DA--UW-V
// [생략: ffff868231f8f0c0 프로세스 문맥 전환]
1: kd> !pte 0x1d483620000
VA 000001d483620000
PXE at FFFFFA7D3E9F4018 PPE at FFFFFA7D3E803A90 PDE at FFFFFA7D007520D8 PTE at FFFFFA00EA41B100
contains 0A000001F3418867 contains 0A000001E1619867 contains 0A0000016B271867 contains C0000001CC012867
pfn 1f3418 ---DA--UWEV pfn 1e1619 ---DA--UWEV pfn 16b271 ---DA--UWEV pfn 1cc012 ---DA--UW-V
보는 바와 같이 동일한 PFN 1cc012를 가리키고 있지만,
4: kd> !pfn 1cc012
PFN 001CC012 at address FFFFC40005640360
flink 00000001 blink / share count 00000002 pteaddress FFFF978AE23F4000
reference count 0001 used entry count 0150 Cached color 0 Priority 5
restore pte 8682312ACD5004C0 containing page 04B81D Active P
Shared
정작 PFN 측에서는 "Prototype PTE"를 가리키고, 이때의 Shared Count는 2라고 나옵니다. 해당 Prototype PTE는 페이징 테이블에 없어 !pte 명령의 출력 과정에는 없지만 직접 _MMPTE 구조체로 값을 덤프해 보면,
1: kd> dt _MMPTE FFFF978AE23F4000 u.Hard.
nt!_MMPTE
+0x000 u :
+0x000 Hard :
+0x000 Valid : 0y1
+0x000 Dirty1 : 0y0
+0x000 Owner : 0y0
+0x000 WriteThrough : 0y0
+0x000 CacheDisable : 0y0
+0x000 Accessed : 0y1
+0x000 Dirty : 0y0
+0x000 LargePage : 0y0
+0x000 Global : 0y1
+0x000 CopyOnWrite : 0y0
+0x000 Unused : 0y0
+0x000 Write : 0y1
+0x000 PageFrameNumber : 0y0000000000000000000111001100000000010010 (0x1cc012)
+0x000 ReservedForSoftware : 0y0000
+0x000 WsleAge : 0y1010
+0x000 WsleProtection : 0y000
+0x000 NoExecute : 0y0
이렇게 Prototype PTE가 (마치 Hard PTE처럼) 공유 메모리에 해당하는 PFN을 가리키고 있음을 확인할 수 있습니다. 또한, "
Prototype PTEs" 글에서 설명했듯이 Prototype PTE가 paged pool에 위치하고 있다는 것과 "MmSt" 태그를 달고 있다는 것도 볼 수 있습니다.
1: kd> !pool FFFF978AE23F4000
Pool page ffff978ae23f4000 region is Paged pool
*ffff978ae23f4000 : large page allocation, tag is MmSt, size is 0xa000 bytes
Pooltag MmSt : Mm section object prototype ptes, Binary : nt!mm
자, 그럼 이 상태에서 ffff868231f8f0c0 프로세스의 working set을 줄이면, 해당 공유 메모리의 PFN을 참조하는 share count가 1로 줄어듭니다.
2: kd> !pfn 1cc012
PFN 001CC012 at address FFFFC40005640360
flink 00000001 blink / share count 00000001 pteaddress FFFF978AE23F4000
reference count 0001 used entry count 0150 Cached color 0 Priority 5
restore pte 8682312ACD5004C0 containing page 04B81D Active MP
Modified Shared
그리고 프로세스의 PTE를 확인해 보면,
// [생략: ffff868231f8f0c0 프로세스 문맥 전환]
2: kd> !pte 0x20d05130000
VA 0000020d05130000
PXE at FFFFFA7D3E9F4020 PPE at FFFFFA7D3E8041A0 PDE at FFFFFA7D00834140 PTE at FFFFFA0106828980
contains 0A000001D520F867 contains 0A000001DF210867 contains 0000000164E50880
pfn 1d520f ---DA--UWEV pfn 1df210 ---DA--UWEV contains 0000000164E50880
not valid
Transition: 164e50
Protect: 4 - ReadWrite
하필 ^^; PTE를 담고 있는 164e50 페이지까지 Working set trimmer에 의해 Not Valid 상태로 바뀌어 PTE 내용 자체가 출력이 안 됩니다. 그 와중에 다행인 점은, 해당 영역이 아직 메모리에는 그대로 남아 있어 PTE 주소를 직접 덤프해 "Prototype" 필드가 1로 바뀐 것까지는 확인이 됩니다.
2: kd> dt _MMPTE FFFFFA0106828980 u.Proto.
nt!_MMPTE
+0x000 u :
+0x000 Proto :
+0x000 Valid : 0y0
+0x000 DemandFillProto : 0y0
+0x000 HiberVerifyConverted : 0y0
+0x000 ReadOnly : 0y0
+0x000 SwizzleBit : 0y0
+0x000 Protection : 0y00100 (0x4)
+0x000 Prototype : 0y1
+0x000 Combined : 0y0
+0x000 Unused1 : 0y0000
+0x000 ProtoAddress : 0y111111111111111111111111111111110000000000000000 (0xffffffff0000)
여기서 혼동하지 말아야 할 것이 있는데요, _MMPTE의 Proto 구조체가 Prototype PTE의 내부를 나타내는 것이 아니고, 해당 PTE 자체가 Prototype PTE의 도움을 받아야 한다는 것만을 알려주는 것에 불과하다는 점입니다.
그리고 위에서 ProtoAddress는 커널에서 생성한 공유 메모리라면 Prototype PTE의 주소를 가리키겠지만, 사용자 모드 프로그램의 경우에는 VAD를 거쳐서 Prototype PTE를 참조하게 되므로 아무런 값도 가지지 않습니다.
어쩌면 위의 경우는 운이 좋아 Trans 상태의 PTE를 본 것이었을 수도 있습니다. 만약 아예 페이징이 돼 아래와 같은 식으로 Soft PTE로 바뀌면 메모리에 대한 덤프도 불가능해집니다.
6: kd> !pte 0x20d05130000
VA 0000020d05130000
PXE at FFFFFA7D3E9F4020 PPE at FFFFFA7D3E8041A0 PDE at FFFFFA7D00834140 PTE at FFFFFA0106828980
contains 0A000001D520F867 contains 0A000001DF210867 contains 0026941D00D02084
pfn 1d520f ---DA--UWEV pfn 1df210 ---DA--UWEV contains 0026941D00D02084
not valid
PageFile: 2
Offset: 26941d
Protect: 4 - ReadWrite
6: kd> dq FFFFFA0106828980 L1
fffffa01`06828980 ????????`????????
어쨌든 뭐 중요한 것은 프로세스 측의 PTE는 그냥 내려가도 상관이 없다는 것만 기억해 두시면 되겠습니다. ^^
그나저나 위의 실습까지 하셨다면, 혹시나 저 상황에서 어떻게 Prototype PTE를 찾을 수 있는지 궁금하지 않으신가요? ^^
왜냐하면 공유 메모리를 가리키던 프로세스의 PTE 자체에는 "Prototype : 0y1"이라는 정보를 제외하고는 "Prototype PTE"의 위치에 대해서는 아무런 정보도 없기 때문에 이상할 수 있습니다.
물론, 그래도 괜찮으니까 ^^ 그런 식으로 만들었을 텐데요, 다행히 User 모드 프로그램의 경우 사용자의 가상 주소가 Prototype PTE를 찾은 Key로서 동작하게 됩니다. 정말 그런지 windbg로 확인을 해볼 텐데요, 이를 위해 우선 대상 프로세스의 VAD 주소를 먼저 알아야 합니다.
2: kd> !process ffff868231f8f0c0 1
PROCESS ffff868231f8f0c0
SessionId: 2 Cid: 32c4 Peb: f5c9a45000 ParentCid: 2278
DirBase: 1700f0000 ObjectTable: ffff978b10b52b00 HandleCount: 192.
Image: mem_map.exe
VadRoot ffff868231956660 Vads 133 Clone 0 Private 13071. Modified 13874. Locked 2.
...[생략]...
그리고 그 VAD 내부에 공유 메모리를 가리키던 가상 주소에 해당하는 항목을 찾을 수 있고,
// mm-virtual address: 0x20d05130000
2: kd> !vad ffff868231956660
VAD Level Start End Commit
ffff868215b8bdb0 6 7ffe0 7ffe0 1 Private READONLY
ffff868215b8f230 5 7ffed 7ffed 1 Private READONLY
...[생략]...
ffff8682190f1980 4 20d00400 20d005ff 176 Private READWRITE
ffff86821e4e0220 6 20d00600 20d03605 12289 Private READWRITE
ffff868227020440 5 20d03610 20d0361f 0 Mapped READWRITE Pagefile section, shared commit 0x2b3
ffff8682270206c0 2 20d03620 20d05121 0 Mapped READONLY \Windows\Globalization\ICU\icudtl.dat
ffff868226328f00 5 20d05130 20d0652f 0 Mapped READWRITE \temp\test.txt
...[생략]...
0x20d05130000 주소에 해당하는 VAD 항목의 주소는 ffff868226328f00인데요,
ffff868226328f00 5 20d05130 20d0652f 0 Mapped READWRITE \temp\test.txt
해당 VAD를 덤프해 보면,
2: kd> dt _MMVAD ffff868226328f00
nt!_MMVAD
+0x000 Core : _MMVAD_SHORT
+0x040 u2 : <unnamed-tag>
+0x048 Subsection : 0xffff8682`312acd50 _SUBSECTION
+0x050 FirstPrototypePte : 0xffff978a`e23f4000 _MMPTE
+0x058 LastContiguousPte : 0xffff978a`e23fdff8 _MMPTE
+0x060 ViewLinks : _LIST_ENTRY [ 0xffff8682`312accd8 - 0xffff8682`31969720 ]
+0x070 VadsProcess : 0xffff8682`31f8f0c1 _EPROCESS
+0x078 u4 : <unnamed-tag>
+0x080 FileObject : (null)
저렇게 FirstPrototypePte 필드로 Prototype PTE의 가상 주소를 찾을 수 있습니다. 결국, Page fault handler는 저런 식으로 Prototype PTE를 찾아 공유 메모리를 각각의 프로세스에 연결하는 작업을 수행하게 될 것입니다.
자, 그럼 이 상태에서 다른 프로세스 1개도 working set을 줄여볼까요? ^^ 그 상태가 되면 이제 공유 메모리로 할당돼 있던 PFN은 Standby (또는 Modified) 상태로 바뀌게 되고,
7: kd> !pfn 1cc012
PFN 001CC012 at address FFFFC40005640360
flink 001B5711 blink / share count 00052AC2 pteaddress FFFF978AE23F4000
reference count 0000 used entry count 0150 Cached color 0 Priority 5
restore pte 8682312ACD5004C0 containing page 04B81D Standby P
Shared
해당 프로세스의 PTE는 (이전 프로세스와는 달리) PTE 영역이 해제되지 않아 "Proto: VAD"라고 나오는 출력을 볼 수 있게 됐습니다.
7: kd> !pte 0x1d483620000
VA 000001d483620000
PXE at FFFFFA7D3E9F4018 PPE at FFFFFA7D3E803A90 PDE at FFFFFA7D007520D8 PTE at FFFFFA00EA41B100
contains 0A000001F3418867 contains 0A000001E1619867 contains 0A0000016B271867 contains FFFFFFFF00000480
pfn 1f3418 ---DA--UWEV pfn 1e1619 ---DA--UWEV pfn 16b271 ---DA--UWEV not valid
Proto: VAD
Protect: 4 - ReadWrite
그렇다고 해서 별다른 정보를 더 갖고 있는 것은 아니고, 그냥 "Prototype" 필드가 1을 담고 있다는 정도에 불과합니다.
7: kd> dt _MMPTE FFFFFA00EA41B100 u.Proto.
nt!_MMPTE
+0x000 u :
+0x000 Proto :
+0x000 Valid : 0y0
+0x000 DemandFillProto : 0y0
+0x000 HiberVerifyConverted : 0y0
+0x000 ReadOnly : 0y0
+0x000 SwizzleBit : 0y0
+0x000 Protection : 0y00100 (0x4)
+0x000 Prototype : 0y1
+0x000 Combined : 0y0
+0x000 Unused1 : 0y0000
+0x000 ProtoAddress : 0y111111111111111111111111111111110000000000000000 (0xffffffff0000)
기타, Prototype PTE의 주소를 찾는 방법은 이전과 동일하게 VAD를 찾아서 FirstPrototypePte 필드를 찾아가 보는 실습을 반복하시면 됩니다.
가상 메모리 관리에서의 페이징 시스템은 커널 측에서 소프트웨어로도 구현을 해야 하지만, CPU도 이와 직접적으로 연동이 되는 구조입니다. 엄밀히 말하면, CPU 제조사가 규칙을 만들어 놓으면 운영체제 개발자들은 그에 맞춰서 소프트웨어를 구현하는 식이라고 봐야 합니다. (아마도 현실적으로는, 서로 협의를 통해 만들지 않을까 싶습니다.)
Prototype PTE가 재미있는 것은, CPU 제조사 입장에서 봤을 때 이것은 전혀 고려하지 않았던 기능이라고 봐야 할 것입니다. 즉, 순전히 소프트웨어만으로 운영의 묘미를 살린 사례인데요, 공유 메모리를 이런 식으로 구현했다는 발상 자체가 재미있는 것 같습니다. ^^ 제가 리눅스는 잘 몰라서 확신할 수는 없지만 아마도 리눅스의 경우에는 공유 메모리를 또 다른 방식으로 구현하지 않았을까... 예상해 봅니다.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]