Статья PageFile.sys - подкачка страниц в Windows

Виртуализация системной памяти - это реальная магия, которая поддерживается на аппаратном уровне блоком MMU процессоров х86. В её основе лежит временное хранилище PageFile.sys на внешнем накопителе (диск или флэшка), куда система может сбросить редко используемые страницы при ограниченном объёме памяти ОЗУ. Здесь мы рассмотрим техническую составляющую данного вопроса: - "Как системе удаётся отправить в космос страницу с полезной информацией, и через произвольное время без потерь вернуть её обратно?".

Под катом:
1. Пишем софт для тестов - сбор информации о памяти​
2. Учёт физических фреймов - системная база PFN​
3. Регистр CR3 и каталог страниц процесса​
4. Реализация подкачки страниц​
5. Заключение​



1. Пишем софт для тестов - сбор информации о памяти

Механизм пейджинга в своп и обратно предлагал свою "кислородную маску" старым архитектурам с 128 МБ памяти на борту, но в наше время можно встретить в 1000-раз большие по объёму комбинации плашек 128 ГБ, а файл подкачки Pagefile.sys как был неотъемлемой частью ОС, так и остался. А всё потому, что производители не успевают уже удовлетворять аппетиты разработчиков софта и самих Microsoft, в результате чего спрос всегда идёт на шаг впереди предложений. Остаётся только надеяться, что кто-нибудь остановит этот беспредел с заоблачным требованием к памяти современных систем Win10/11.

В штатных библиотеках Win есть множество api для сбора информации как о физической DDR, так и о вирт.памяти пользовательских процессов. Для наших целей подходит следующая пара функций, которой мы и воспользуемся в тестовом своём приложении.

GetPerformanceInfo() - возвращает блок данных о физ.памяти в структуру PERFORMANCE_INFORMATION с таким содержимым:

C-подобный:
struct PERFORMANCE_INFORMATION
  cb                 dd  sizeof.PERFORMANCE_INFORMATION
  Padding1           dd  0
  CommitTotal        dq  0    ;//  страниц, зафиксированных в данный момент системой
  CommitLimit        dq  0    ;//  макс.кол-во страниц, которое можно зафиксировать без расширения файла подкачки
  CommitPeak         dq  0    ;//  пиковое кол-во зафиксированных страниц с момента последней перезагрузки
  PhysicalTotal      dq  0    ;//  общий объём физ.памяти DDR в страницах
  PhysicalAvailable  dq  0    ;//  объём доступной физ.памяти в страницах
  SystemCache        dq  0    ;//  объём системного кэша в страницах (список ожидания + рабочий набор системы)
  KernelTotal        dq  0    ;//  суммарный объём памяти в пулах ядра (страничном и нестраничном) в страницах
  KernelPaged        dq  0    ;//  объём памяти в выгружаемом пуле ядра
  KernelNonpaged     dq  0    ;//  объём памяти в невыгружаемом пуле ядра
  PageSize           dq  0    ;//  размер страницы в байтах
  HandleCount        dd  0    ;//  текущее кол-во открытых дескрипторов
  ProcessCount       dd  0    ;//  текущее кол-во процессов в системе
  ThreadCount        dd  0    ;//  текущее кол-во потоков в системе
  Padding2           dd  0
ends

GetProcessMemoryInfo() - информация о конкретном процессе:

C-подобный:
struct PROCESS_MEMORY_COUNTERS_EX
  cb                         dd  sizeof.PROCESS_MEMORY_COUNTERS_EX
  PageFaultCount             dd  0
  PeakWorkingSetSize         dq  0   ;//  макс.размер рабочего набора данных в байтах
  WorkingSetSize             dq  0   ;//  текущий размер рабочего набора данных
  QuotaPeakPagedPoolUsage    dq  0   ;//  макс.значение страничного пула
  QuotaPagedPoolUsage        dq  0   ;//  текущее значение страничного пула
  QuotaPeakNonPagedPoolUsage dq  0   ;//  макс.значение невыгружаемого пула памяти
  QuotaNonPagedPoolUsage     dq  0   ;//  текущее значение невыгружаемого пула памяти
  PagefileUsage              dq  0   ;//  параметр Commit Charge для этого процесса (объем частной памяти)
  PeakPagefileUsage          dq  0   ;//
  PrivateUsage               dq  0   ;//  аналогично PagefileUsage для Win7+
ends

На скринах результат этих api с двух моих машин - слева стационар Win7 с 16 ГБ ОЗУ, а справа довольно потрёпанный бук Win10 с макс.возможными 4 ГБ. Разберём подробней эти сведения продвигаясь от чердака к подвалу (исходник с экзе лежит в скрепке):

WinMem.webp

4-КБайтные страницы физ.памяти принято называть фреймами - в первой строке видим их кол-во и сопоставление в гигах. Отметим, что функция GetPerformanceInfo() возвращает счётчик фреймов без учёта уже затенённой памяти, которую нагло отобрали у системы графическое ядро ЦП, менеджер SMM, а так-же служебные таблицы ACPI и PCI-Config-Space. Именно поэтому на скрине видим 15.912, вместо установленной 16.0 ГБ. Во-второй строке лежит текущий размер файла-подкачки, и если сложить 2 эти значения, получим лимит ОЗУ включая внешнюю память на диске.

Здесь важно понять, что процесс сброса страниц в Pagefile начнётся в тот момент, когда значение "Используется" превысит размер физ.памяти в первой строке. Пейджинг будет продолжаться в стандартном режиме пока не закончатся свободные слоты в файле-подкачки, и мы не упрёмся в потолок "Лимит". Тогда система попытается расширить объём подкачки на диске, если в свойствах вирт.памяти установлена опция "Авто, по выбору системы" (Win+Break-->Дополнительно-->Быстродействие). Иначе получим BSOD с ошибкой типа KERNEL_MODE_HEAP_CORRUPTION=0x13A.


1.1. Пару слов про вирт.память пользовательских процессов.

Рабочий набор страниц "Working Set" делится на Shareable и Private. В расшаренный набор входят "Memory Mapped" файлы, например память системных библиотек DLL, которые существуют в единственном экземпляре, и по требованию просто проецируются в каждый процесс. При попытки записи в такие страницы, система создаёт их дубликат, и отправляет в приватную часть памяти процесса. Другой вариант шары - это вызов функции CreateFileMapping() из своего приложения, после чего любой процесс сможет прочитать вашу память.

В свою очередь приватная - это код, данные, стек, куча, PEB/TEB и т.д. процесса. Среди всей приватной памяти есть несколько страниц "NonPaged", которые нельзя выгружать в своп - в них находятся объекты ядра ОС, например таймеры, семафоры и прочие для синхронизации потоков. В окне программы выше есть пимпа "Выделить 10 МБ памяти", нажатие на которую тянет за собой функцию VirtualAlloc(). При этом выделенная память попадает именно в приватный набор.


2. Учёт физических фреймов - системная база PFN

С каждым 4-КБайтным фреймом физ.памяти, системный менеджер связывает 48-байтную структуру MMPFN - это паспорт отдельно взятого страничного фрейма. Все эти структуры собираются в глобальную базу, указатель на которую ядро хранит в своей переменной nt!MmPfnDatabase. По сути адрес базы PFN на Win64 всегда фиксирован и равен 0xFFFFFA80`00000000. Можно запросить значение этой переменной в отладчике WinDbg, и как видим теория подтверждается:

Код:
0: kd> dq nt!MmPfnDatabase L1
       fffff800`024ef280   fffffa80`00000000  <---- адрес базы PFN

Общая структура ядра Win не менялась со времён царя-Гороха, поэтому на всех версиях после базы PFN располагается сразу пул невыгружаемой в своп ядерной памяти, на начало которой указывает переменная nt!MmNonPagedPoolStart. Учитывая эту особенность, размер глобальной базы PFN будет представлять собой разность двух переменных - рассчитать его можно с помощью сл.выражения. Кстати у ядра есть ещё одна переменная nt!MmNumberOfPhysicalPages - это общее число фреймов в ОЗУ. В данном случае видим в ней счётчик 4.165.822, и это-же значение возвращает функция GetPerformanceInfo() на скрине выше:

Код:
0: kd> ? poi(nt!MmNonPagedPoolStart) - poi(nt!MmPfnDatabase)
       Evaluate expression: 207511552 = 00000000`0c5e6000     <---- размер базы = 207.511.552 байта

0: kd> ?(poi(nt!MmNonPagedPoolStart) - poi(nt!MmPfnDatabase)) / @@(sizeof(nt!_MMPFN))
       Evaluate expression: 4323157 = 00000000`0041f755       <---- всего может вместить структур = 4.323.157

0: kd> dq nt!MmNumberOfPhysicalPages L1
       fffff800`024fb080   00000000`003f90be    <---- 4.165.822

Таким образом, вся моя память ОЗУ размером 16 ГБ делится на более 4 млн страничных фрейма, и каждый из них размером по 4 КБ. Чтобы держать под контролем такую армию, нужны довольно сложные алгоритмы. Для решения этой проблемы и была введена структура MMPFN. Пусть на каждые 4 КБ расходуются лишние 48-байт служебной инфы (размер структуры mmpfn), зато в них можно описать теперь состояние физ.фрейма на текущий момент, например: свободный, занятый, расшаренный, и т.д. Вот содержимое этой структуры:

Код:
0: kd> dt _mmpfn -r2
nt!_MMPFN
   +0x000 Flink            : Uint8B          <---- линки на сл.фрейм такого-же типа
   +0x008 Blink            : Uint8B          <---- ^^^^^
   +0x010 PteAddress       : Ptr64 _MMPTE    <---- адрес записи PTE в каталоге страниц процесса-родителя
   +0x018 u3               : <unnamed-tag>

      +0x000 ReferenceCount   : Uint2B          <---- счётчик ссылок на фрейм из вне
      +0x002 e1               : _MMPFNENTRY     <---- характеристики..

         +0x000 PageLocation     : Pos 0, 3 Bits    <---- в каком из шести списков находится
         +0x000 WriteInProgress  : Pos 3, 1 Bit
         +0x000 Modified         : Pos 4, 1 Bit
         +0x000 ReadInProgress   : Pos 5, 1 Bit
         +0x000 CacheAttribute   : Pos 6, 2 Bits
         +0x001 Priority         : Pos 0, 3 Bits
         +0x001 Rom              : Pos 3, 1 Bit
         +0x001 InPageError      : Pos 4, 1 Bit
         +0x001 KernelStack      : Pos 5, 1 Bit
         +0x001 RemovalRequested : Pos 6, 1 Bit
         +0x001 ParityError      : Pos 7, 1 Bit

   +0x01c UsedPTEs         : Uint2B
   +0x01e VaType           : UChar
   +0x01f ViewCount        : UChar
   +0x020 OriginalPte      : _MMPTE          <---- полная копия 8-байтной записи PTE (обсуждается далее)
   +0x028 u4               : <unnamed-tag>

      +0x000 PteFrame         : Pos 0, 52 Bits
      +0x000 Unused           : Pos 52, 3 Bits
      +0x000 PfnImageVerified : Pos 55, 1 Bit
      +0x000 AweAllocation    : Pos 56, 1 Bit
      +0x000 PrototypePte     : Pos 57, 1 Bit     <---- флаг расшаренного фрейма
      +0x000 PageColor        : Pos 58, 6 Bits
0: kd>

Как видим, в основном это битовые флаги, при помощи которых можно закодировать просто огромное кол-во информации. Однако в контексте данной темы нас будет интересовать всего одно поле в этой структуре - это "OriginalPte" по смещению 0х20.

Набор "WorkingSet" любого процесса имеет шесть своих списков, куда сбрасывают фреймы с определённым флагом. На эти списки указывает поле u3.e1.PageLocation в структуре MMPFN - в его трёх битах можно закодировать 8 временных боксов. Указатели на них хранит переменная ядра nt!MmPageLocationList[8].

Код:
0: kd> dqs nt!MmPageLocationList
fffff800`0226b1d0   fffff800`02502500  nt!MmZeroedPageListHead
fffff800`0226b1d8   fffff800`02502a00  nt!MmFreePageListHead
fffff800`0226b1e0   fffff800`02502bc0  nt!MmStandbyPageListHead
fffff800`0226b1e8   fffff800`025022c0  nt!MmModifiedPageListHead
fffff800`0226b1f0   fffff800`024424c8  nt!MmModifiedNoWritePageListHead
fffff800`0226b1f8   fffff800`02443d00  nt!MmBadPageListHead
fffff800`0226b200   00000000`00000000
fffff800`0226b208   00000000`00000000

Таким образом, фреймы могут быть: чистые, обнулённые, свободные, находиться в промежуточном состоянии ожидания какого-либо события, модифицированные, и с ошибками (нельзя использовать). Из всех этих списков, для нас интересен сейчас лист модифицированных, поскольку только грязные фреймы при нехватки памяти сбрасываются в файл-подкачки на диск. Если страничный фрейм не обновлялся записью свежих данных, то сохранять его не имеет смысла - он просто затирается нулями, и переходит в WorkingSet нового владельца.


3. Регистр CR3 и каталог страниц процесса

Когда процесс запускается на исполнение, система создаётся для него личный каталог страниц "PageDirectory", и сохраняет адрес этого каталога в регистре CR3 процесса. Следующий процесс как самостоятельная единица получит уже отдельный свой каталог, и т.д. В общем сколько активных процессов в системе, столько и каталогов PageDir. Не смотря на то, что разрядность системы 64-битная, для адресации памяти процессы используют только 48-бит, в которых можно закодировать пространство в 2^48=256 ТераБайт.

На схеме ниже представлен каталог. По умолчанию, когда система только планирует запуск исполняемого файла, в нём всего 4 таблицы и все они пустые. Теперь загрузчик образов выделяет несколько страниц вирт.памяти, чтобы отобразить в них дисковый файл. Именно в этот момент таблицы каталога начинают заполняться номерами физ.фреймов, которые система предоставила данному процессу.

Записи "Entry" в таблицах размером 8-байт каждая, поэтому одна таблица на любом уровне занимает в памяти 512*8=4096 байт. Поскольку 1 запись PTE адресует 1 фрейм, то 512 записей дают нам доступ к 512*4096=2МБ физической памяти, чего явно не хватит даже обычному приложению "Hello World". Поэтому в реальной ситуации процессы имеют не одну, а несколько сотен таблиц первого уровня "PageTable", и соответственно такое-же число записей PDE в таблице уровня(2) "PageDirectoryTable". Когда заполнится под макушку и таблица(2), то появится новая запись PDPE на более высоком уровне(3), и т.д.

Важно понять, что таблицы в каталоге заполняются не последовательно (сначала полностью первая, потом вторая и т.д.), а в зависимости от адреса, к которому обратился код нашего приложения. Например дефолтная база загрузки исполняемого файла в память равна 0x00400000, что требует 22-битного адреса. Значит появится запись PDE в таблице уровня(2), хотя первая ещё не заполнена под завязку. Но тут программа решила сразу прочитать произвольные данные по адресу выше 32-бит, допустим 0x00002E'0015A4. Как следствие, это потребует записи PDPE в таблице(3), не смотря на то, что во-второй пока только 1 единственная запись.

PageDir.webp

Таким образом, все таблицы заполняются динамически, системным обработчиком исключений "PageFault". Теперь понятно, почему процесс не успел ещё толком вздохнуть, а его счётчик ошибок страниц перевалил уже за 1700. Попробуйте выделить 10 МБ и увидите, что счётчик выстрелил ещё пару раз.

PageFault.webp

Здесь уместно провести небольшой эксперимент в отладчике.
В общем запрашиваем паспорт моей демки выше PerfInfoGui.exe и видим, что каталог её расположен по физ.адресу DirBase=0x10BA9000. Далее переключаем контекст WinDbg на наш процесс и расширением !cmkd смотрим, какую структуру имеет каталог страниц нашего приложения, начиная с базового адреса загрузки 0x00400000:

Код:
0: kd> !process 0 0 PerfInfoGui.exe
PROCESS fffffa800cb46060
   Session: 1         Cid: 0bec    Peb: 7fffffd9000  ParentCid:  09bc
   DirBase: 10ba9000  ObjectTable: fffff8a0035fc630  HandleCount:  69
   Image  : PerfInfoGui.EXE

0: kd> .process /p fffffa800cb46060
   Implicit process is now fffffa80`0cb46060

0: kd> !cmkd.ptelist 400000 403000 -v
VA=0000000000400000
  PXE Idx=000  Va=FFFFF6FB7DBED000  Contents=030000021584A867  Hard Pfn=0021584A  Attr=---DA--UWEV
  PPE Idx=000  Va=FFFFF6FB7DA00000  Contents=031000030724B867  Hard Pfn=0030724B  Attr=---DA--UWEV
  PDE Idx=002  Va=FFFFF6FB40000010  Contents=032000026EACC867  Hard Pfn=0026EACC  Attr=---DA--UWEV
  PTE Idx=000  Va=FFFFF68000002000  Contents=816000011DE06125  Hard Pfn=0011DE06  Attr=-G--A--UR-V
VA=0000000000401000
  PXE Idx=000  Va=FFFFF6FB7DBED000  Contents=030000021584A867  Hard Pfn=0021584A  Attr=---DA--UWEV
  PPE Idx=000  Va=FFFFF6FB7DA00000  Contents=031000030724B867  Hard Pfn=0030724B  Attr=---DA--UWEV
  PDE Idx=002  Va=FFFFF6FB40000010  Contents=032000026EACC867  Hard Pfn=0026EACC  Attr=---DA--UWEV
  PTE Idx=001  Va=FFFFF68000002008  Contents=A2B000005E340967  Hard Pfn=0005E340  Attr=-G-DA--UW-V
VA=0000000000402000
  PXE Idx=000  Va=FFFFF6FB7DBED000  Contents=030000021584A867  Hard Pfn=0021584A  Attr=---DA--UWEV
  PPE Idx=000  Va=FFFFF6FB7DA00000  Contents=031000030724B867  Hard Pfn=0030724B  Attr=---DA--UWEV
  PDE Idx=002  Va=FFFFF6FB40000010  Contents=032000026EACC867  Hard Pfn=0026EACC  Attr=---DA--UWEV
  PTE Idx=002  Va=FFFFF68000002010  Contents=5AC00001EE646125  Hard Pfn=001EE646  Attr=-G--A--UREV
VA=0000000000403000
  PXE Idx=000  Va=FFFFF6FB7DBED000  Contents=030000021584A867  Hard Pfn=0021584A  Attr=---DA--UWEV
  PPE Idx=000  Va=FFFFF6FB7DA00000  Contents=031000030724B867  Hard Pfn=0030724B  Attr=---DA--UWEV
  PDE Idx=002  Va=FFFFF6FB40000010  Contents=032000026EACC867  Hard Pfn=0026EACC  Attr=---DA--UWEV
  PTE Idx=003  Va=FFFFF68000002018  Contents=815000012D6F9125  Hard Pfn=0012D6F9  Attr=-G--A--UR-V

0: kd>

Как и предполагалось выше, в таблицах верхнего уровня(4:3) по одной дефолной записи с индексом(0). Во-второй таблице уже 2 - дефолтная и по индексу(2). И наконец в последней PageTable уже 4 записи с индексами(0:3) для каждого страничного фрейма ОЗУ (разница 0x1000).

Вирт.адрес в столбце "VA" нам не интересен, т.к. он принадлежит менеджеру памяти ОС. А вот значения в поле "Contents" представляют собой реальные записи PDPE\PDE\PTE размером 8-байт, из которых два последних столбца "Pfn" и "Attributes" крадут себе значения. Вот расшифровка атрибутов слева-направо: Global, Dirty, Accessed, User, Writeable, Execute, Valid. Отладчик может показать битовую маску из указанных здесь записей PTE, хотя мы и без того уже с ними разобрались. Вот пример последней с индексом(3).

Код:
0: kd> dt _mmpte_hardware FFFFF68000002018
nt!_MMPTE_HARDWARE
   +0x000 Valid            : 1
   +0x000 Write            : 0
   +0x000 Owner            : 1
   +0x000 WriteThrough     : 0
   +0x000 CacheDisable     : 0
   +0x000 Accessed         : 1
   +0x000 Dirty            : 0
   +0x000 LargePage        : 0
   +0x000 Global           : 1
   +0x000 CopyOnWrite      : 0
   +0x000 Reserved1        : 00
   +0x000 PageFrameNumber  : 000000000000000100101101011011111001 (0x12d6f9)
   +0x000 Reserved2        : 0000
   +0x000 SoftwareWsIndex  : 00000010101 (0x15)
   +0x000 NoExecute        : 1
0: kd>


4. Подкачка страниц как основа вирт.памяти

Теперь (когда у нас есть небольшой багаж фундаментальных основ) рассмотрим, как система реализует сброс и восстановление страничных фремов в Pagefile.sys. Начнём с того, что в Win может существовать не один, а целых 16 файлов-подкачки, хотя на практике используется только 1. Такой огромный резерв предусмотрен для серверных систем, а у нас разговор про клиентские ОС. Переменная ядра nt!MmPagingFile смотрит на массив из 16-ти структур MMPAGING_FILE, но здесь всего один указатель:

Код:
0: kd> dq nt!MmPagingFile
fffff800`024852a0  fffffa80`1030e6f0 00000000`00000000
fffff800`024852b0  00000000`00000000 00000000`00000000
fffff800`024852c0  00000000`00000000 00000000`00000000
fffff800`024852d0  00000000`00000000 00000000`00000000
fffff800`024852e0  00000000`00000000 00000000`00000000
fffff800`024852f0  00000000`00000000 00000000`00000000
fffff800`02485300  00000000`00000000 00000000`00000000
fffff800`02485310  00000000`00000000 00000000`00000000

0: kd> dt _mmpaging_file fffffa80`1030e6f0
nt!_MMPAGING_FILE
   +0x000 Size               : 0x32000
   +0x008 MaximumSize        : 0x100000
   +0x010 MinimumSize        : 0x32000
   +0x018 FreeSpace          : 0x31fff
   +0x020 PeakUsage          : 0
   +0x028 HighestPage        : 0
   +0x030 File               : 0xfffffa80`0f99f9f0  _FILE_OBJECT
   +0x038 Entry              : 0xfffffa80`1033d760
   +0x048 PageFileName       : _UNICODE_STRING       "D:\pagefile.sys"
   +0x058 Bitmap             : 0xfffffa80`0fd40000  _RTL_BITMAP
   +0x060 EvictStoreBitmap   : (null)
   +0x068 BitmapHint         : 1
   +0x06c LastAllocationSize : 0
   +0x070 ToBeEvictedCount   : 0
   +0x074 PageFileNumber     : 0
   +0x076 AdriftMdls         : 1
   +0x078 FileHandle         : 0xffffffff`80000214
   +0x080 Lock               : 0
   +0x088 LockOwner          : (null)

Можно сравнить эти данные с окном свойств вирт.памяти, где должны лежать те же значения. И точно, поле Size=0x32000 в 4К страницах, и это исходный размер 800 МБ, как и поле MaxSize=0x100000 равно 4096 МБ. В структуре есть даже размер свободного пространства FreeSpace, и судя по всему мой файл девственно чист. В поле File лежит линк на файловый объект системы, через который осуществляется обмен с Pagefile.sys, а с поля FileHandle можно взять его дескриптор.

Pg.webp

Для перечисления свободных слотов (страниц) в файле используется механизм битовых карт BitMap. Например если размер Pagefile всего 64 страницы, описать их состояние можно одним 64-битным значением qword. Взведённый бит в нём определяет занятый слот, а сброшенный - свободный. Поскольку у меня файл практически чистый, я получил такую карту:

Код:
0: kd> dt 0xfffffa80`0fd40000 _RTL_BITMAP
ntdll!_RTL_BITMAP
   +0x000 SizeOfBitMap : 0x100000
   +0x008 Buffer       : 0xfffffa80`0fd40010 -> 1

0: kd> db 0xfffffa80`0fd40010
fffffa80`0fd40010  01 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
fffffa80`0fd40020  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
fffffa80`0fd40030  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
fffffa80`0fd40040  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
fffffa80`0fd40050  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
fffffa80`0fd40060  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
fffffa80`0fd40070  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
fffffa80`0fd40080  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................

0: kd>


4.1. Техническая сторона вопроса

На схеме ниже представлен формат и содержимое записи PTE в каталоге страниц процесса. Если нулевой бит под кличкой "Valid" взведён в единицу, значит процесс использует данный пейдж, при этом младшие 12-бит записи перечисляют его атрибуты, т.е. доступна-ли страница для записи "Write", был-ли к ней уже доступ на чтение или запись "Accessed", а так-же изменено или нет её содержимое "Dirty". При необходимости в своп сбрасываются только те страничные фреймы, у которых взведён бит "Dirty". Когда PTE валидна, весь контроль над битами берёт на себя сам ЦП на аппаратном уровне, поэтому такие записи имею тип Hardware-PTE.

PTE_Hard.webp

А вот следующие 36-бит хранят уже номер фрейма в физ.памяти ОЗУ. Это собственно клиент, которого описывает данная запись. Значение будет совпадать с порядковым номером структуры MMPFN в глобальной базе фреймов, поскольку она характеризует тот-же субъект PFN.

Обратите внимание, что в записях PTE виртуальный адрес не фигурирует от слова вообще! Минимальной порцией является здесь страничный фрейм физ.памяти, в то время как вирт.адрес определяет исключительно индекс в таблицах PDPE\PDE\PTE каталога "PageDirectory" процесса.

Всё идёт гладко до тех пор, пока в какой-то момент система вдруг решает отфутболить наш фрейм в файл-подкачки. У менеджера памяти есть специальный поток, который в непрерывном режиме с определённой периодичностью проверяет каталоги всех активных процессов в системе на предмет занятых.., но давно бездействующих фреймов. Именно они становятся первыми кандидатами на сброс в Pagefile. Тогда система должна проделать следующее с активной записью PTE в каталоге:

1. Сбросить бит Valid в нуль, в результате чего остальные 11-бит атрибутов меняют своё назначение.​
2. В старшие 32-бита записать номер свободного слота в файле-подкачки.​
3. Первые 2 пункта переводят тип PTE с Hardware на Software, и теперь всеми битами оперирует программно ОС, а не ЦП.​
4. Скопировать всё содержимое фрейма в файл-подкачки на диск.​
5. Исправить структуру MMPFN в глобальной базе, поскольку в ней имеется копия оригинальной записи PTE.​
6. С этого момента фрейм физ.памяти считается свободным, и его можно отдать другому процессу.​

PTE_Soft.webp

Обратите внимание на 4-битное поле "PageFileLow". Выше упоминалось, что в системе может мирно сосуществовать 16 самостоятельных файлов-подкачек типа Pagefile1.sys, Pagefile2.sys, и т.д. Так вот в этих 4-битах кодируется как раз номер одного из 16-ти файлов, но поскольку он у нас 1, то биты[15:12] будут сброшены в нуль, т.е. первый и единственный файл (отсчёт с нуля).

В свою очередь, в 5-битное поле "Protection" дружненько перекочевали атрибуты оригинальной PTE, ведь их нужно будет восстановить при копировании фрейма из свопа обратно в рабочий набор процесса WorkingSet. Вот основные значения этого поля, а дальше есть ещё их комбинации (в 5-битах можно закодировать аж 32 варианта).

Код:
#define  MM_ZERO_ACCESS         0x00  // Нет доступа
#define  MM_READONLY            0x01  // Только чтение
#define  MM_EXECUTE             0x02  // Исполнение
#define  MM_EXECUTE_READ        0x03  // Исполнение + чтение
#define  MM_READWRITE           0x04  // Чтение/запись
#define  MM_WRITECOPY           0x05  // Copy-on-write
#define  MM_EXECUTE_READWRITE   0x06  // Полный доступ
#define  MM_EXECUTE_WRITECOPY   0x07  // Execute + Copy-on-write
#define  MM_GUARD               0x10  // Сторожевая страница
#define  MM_NOCACHE             0x20  // Не кэшировать

4.2. Обратный процесс - восстановление фрейма

Но вот через какое-то время нашему коду понадобились данные из выгруженной в своп страницы. Поскольку она ещё зарегана в каталоге PageDirectory, то менеджер памяти по вирт.адресу находит индекс записи PTE в таблицах каталога, и обнаружив в ней сброшенный бит "Valid" делает вывод, что запрашиваемая страница находится в файле-подкачки, в результате чего возникает ошибка "PageFault". Далее в игру вступает уже её обработчик, который делает следующее:

1. Находит свободный фрейм в глобальной базе PFN.​
2. Читает старшие 32-бита из записи Software-PTE, чтобы найти нужный фрейм в файле-подкачки.​
3. Взводит в PTE бит "Valid", чтобы сменить тип записи опять на Hardware-PTE.​
4. Сохраняет номер найденного на этапе(1) фрейма PFN, в 36-битах 47:12 записи PTE.​
5. Восстанавливает атрибуты оригинальной записи.​
6. Копирует страницу из файла-подкачки в свободный фрейм.​
7. Модифицирует должным образом соответствующую структуру MMPFN в глобальной базе.​

Таким образом получается, что запись PTE в таблице страниц процесса остаётся по прежнему индексу вирт.адреса, однако в её поле "PageFrameNumber" лежит уже номер совсем другого фрейма PFN, например был 12345, а стал ABCDE. В следующий раз можно будет опять изменить номер фрейма по тому-же индексу в таблице, теперь ABCDE-->F9053, и так до бесконечности. Именно такой алгоритм работы менеджера памяти позволяет при физической ОЗУ 4 ГБ, эмулировать наличие 256 ТБ. Главное чтобы в таблицах каталога хватило индексов, тогда можно виртуально представить хоть Петабайты памяти.


5. Заключение

В рамках одной статьи просто нереально охватить весь материал по данной теме, поэтому я старался сократить его по максимуму. Как результат, получилась "пробежка по макушкам", однако вы можете самостоятельно продолжить плавание, если в наличии имеется отладчик WinDbg. К примеру, можно взять из таблицы диспетчеризации прерываний IDT адрес обработчика PageFault, и распотрошить его дизасм-листинг. Ну или просто открыть в IDA ядро мастдая Ntoskrnlpa.exe, и найти в нём неэкспортируемую наружу Internal-функцию nt!KiPageFault. В скрепку положил представленный в начале файл для тестов, всем удачи, пока!

Код:
0: kd> !idt

Dumping IDT:

00:  fffff80002433100  nt!KiDivideErrorFaultShadow
01:  fffff80002433180  nt!KiDebugTrapOrFaultShadow
02:  fffff80002433200  nt!KiNmiInterruptShadow

03:  fffff80002433280  nt!KiBreakpointTrapShadow
04:  fffff80002433300  nt!KiOverflowTrapShadow
05:  fffff80002433380  nt!KiBoundFaultShadow
06:  fffff80002433400  nt!KiInvalidOpcodeFaultShadow
07:  fffff80002433480  nt!KiNpxNotAvailableFaultShadow
08:  fffff80002433500  nt!KiDoubleFaultAbortShadow

09:  fffff80002433580  nt!KiNpxSegmentOverrunAbortShadow
0a:  fffff80002433600  nt!KiInvalidTssFaultShadow
0b:  fffff80002433680  nt!KiSegmentNotPresentFaultShadow
0c:  fffff80002433700  nt!KiStackFaultShadow
0d:  fffff80002433780  nt!KiGeneralProtectionFaultShadow

0e:  fffff80002433800  nt!KiPageFaultShadow   <----------- Ошибка (0x0E) ------------//

0f:  fffff80002433df8  nt!KiIsrThunkShadow+0x78
10:  fffff80002433880  nt!KiFloatingErrorFaultShadow
11:  fffff80002433900  nt!KiAlignmentFaultShadow
12:  fffff80002433980  nt!KiMcheckAbortShadow Stack = 0xFFFFF80002C045E0
...........

0: kd> uf /i /c fffff80002433800

    call to nt!KiPageFault+0x214              (fffff800`02301e94)
    call to nt!KiSaveDebugRegisterState       (fffff800`022f6350)
    call to nt!KiUmsTrapEntry                 (fffff800`023047c0)
    call to nt!KiPreprocessKernelAccessFault  (fffff800`0233bd60)
    call to nt!MmAccessFault                  (fffff800`023cd690)
    call to nt!PsWatchWorkingSet              (fffff800`023dd3b0)
    call to nt!KdSetOwedBreakpoints           (fffff800`023805f0)
    call to nt!KiExceptionDispatch            (fffff800`02304300)
    call to nt!KiBugCheckDispatch             (fffff800`02304280)
    call to nt!KiCheckForSListAddress         (fffff800`022b49e0)
    call to nt!KiInitiateUserApc              (fffff800`022fa970)
    call to nt!KiCopyCounters                 (fffff800`0234e9b0)
    call to nt!KiUmsExit                      (fffff800`02304c40)
    call to nt!KiRestoreDebugRegisterState    (fffff800`022f62e0)
0: kd>
 

Вложения

Мы в соцсетях:

Взломай свой первый сервер и прокачай скилл — Начни игру на HackerLab

🚀 Первый раз на Codeby?
Гайд для новичков: что делать в первые 15 минут, ключевые разделы, правила
Начать здесь →
🧭 Навигатор · ИБ 2026
Не знаешь, какой трек твой?
5 направлений ИБ, реальные зарплаты и точка входа для каждого — в одном треде.
JuniorSenior+
100K → 600K+ ₽ /мес
Открыть навигатор →
🔴 Свежие CVE, 0-day и инциденты
То, о чём ChatGPT ещё не знает — обсуждаем в реальном времени
Threat Intel →
💼 Вакансии и заказы в ИБ
Pentest, SOC, DevSecOps, bug bounty — работа и проекты от проверенных компаний
Карьера в ИБ →

HackerLab