Виртуализация системной памяти - это реальная магия, которая поддерживается на аппаратном уровне блоком MMU процессоров х86. В её основе лежит временное хранилище PageFile.sys на внешнем накопителе (диск или флэшка), куда система может сбросить редко используемые страницы при ограниченном объёме памяти ОЗУ. Здесь мы рассмотрим техническую составляющую данного вопроса: - "Как системе удаётся отправить в космос страницу с полезной информацией, и через произвольное время без потерь вернуть её обратно?".
Под катом:
1. Пишем софт для тестов - сбор информации о памяти
Механизм пейджинга в своп и обратно предлагал свою "кислородную маску" старым архитектурам с 128 МБ памяти на борту, но в наше время можно встретить в 1000-раз большие по объёму комбинации плашек 128 ГБ, а файл подкачки Pagefile.sys как был неотъемлемой частью ОС, так и остался. А всё потому, что производители не успевают уже удовлетворять аппетиты разработчиков софта и самих Microsoft, в результате чего спрос всегда идёт на шаг впереди предложений. Остаётся только надеяться, что кто-нибудь остановит этот беспредел с заоблачным требованием к памяти современных систем Win10/11.
В штатных библиотеках Win есть множество api для сбора информации как о физической DDR, так и о вирт.памяти пользовательских процессов. Для наших целей подходит следующая пара функций, которой мы и воспользуемся в тестовом своём приложении.
На скринах результат этих api с двух моих машин - слева стационар Win7 с 16 ГБ ОЗУ, а справа довольно потрёпанный бук Win10 с макс.возможными 4 ГБ. Разберём подробней эти сведения продвигаясь от чердака к подвалу (исходник с экзе лежит в скрепке):
4-КБайтные страницы физ.памяти принято называть фреймами - в первой строке видим их кол-во и сопоставление в гигах. Отметим, что функция
Здесь важно понять, что процесс сброса страниц в Pagefile начнётся в тот момент, когда значение "Используется" превысит размер физ.памяти в первой строке. Пейджинг будет продолжаться в стандартном режиме пока не закончатся свободные слоты в файле-подкачки, и мы не упрёмся в потолок "Лимит". Тогда система попытается расширить объём подкачки на диске, если в свойствах вирт.памяти установлена опция "Авто, по выбору системы" (Win+Break-->Дополнительно-->Быстродействие). Иначе получим BSOD с ошибкой типа KERNEL_MODE_HEAP_CORRUPTION=0x13A.
1.1. Пару слов про вирт.память пользовательских процессов.
Рабочий набор страниц "Working Set" делится на Shareable и Private. В расшаренный набор входят "Memory Mapped" файлы, например память системных библиотек DLL, которые существуют в единственном экземпляре, и по требованию просто проецируются в каждый процесс. При попытки записи в такие страницы, система создаёт их дубликат, и отправляет в приватную часть памяти процесса. Другой вариант шары - это вызов функции
В свою очередь приватная - это код, данные, стек, куча, PEB/TEB и т.д. процесса. Среди всей приватной памяти есть несколько страниц "NonPaged", которые нельзя выгружать в своп - в них находятся объекты ядра ОС, например таймеры, семафоры и прочие для синхронизации потоков. В окне программы выше есть пимпа "Выделить 10 МБ памяти", нажатие на которую тянет за собой функцию
2. Учёт физических фреймов - системная база PFN
С каждым 4-КБайтным фреймом физ.памяти, системный менеджер связывает 48-байтную структуру MMPFN - это паспорт отдельно взятого страничного фрейма. Все эти структуры собираются в глобальную базу, указатель на которую ядро хранит в своей переменной
Общая структура ядра Win не менялась со времён царя-Гороха, поэтому на всех версиях после базы PFN располагается сразу пул невыгружаемой в своп ядерной памяти, на начало которой указывает переменная
Таким образом, вся моя память ОЗУ размером 16 ГБ делится на более 4 млн страничных фрейма, и каждый из них размером по 4 КБ. Чтобы держать под контролем такую армию, нужны довольно сложные алгоритмы. Для решения этой проблемы и была введена структура MMPFN. Пусть на каждые 4 КБ расходуются лишние 48-байт служебной инфы (размер структуры mmpfn), зато в них можно описать теперь состояние физ.фрейма на текущий момент, например: свободный, занятый, расшаренный, и т.д. Вот содержимое этой структуры:
Как видим, в основном это битовые флаги, при помощи которых можно закодировать просто огромное кол-во информации. Однако в контексте данной темы нас будет интересовать всего одно поле в этой структуре - это "OriginalPte" по смещению 0х20.
Набор "WorkingSet" любого процесса имеет шесть своих списков, куда сбрасывают фреймы с определённым флагом. На эти списки указывает поле
Таким образом, фреймы могут быть: чистые, обнулённые, свободные, находиться в промежуточном состоянии ожидания какого-либо события, модифицированные, и с ошибками (нельзя использовать). Из всех этих списков, для нас интересен сейчас лист модифицированных, поскольку только грязные фреймы при нехватки памяти сбрасываются в файл-подкачки на диск. Если страничный фрейм не обновлялся записью свежих данных, то сохранять его не имеет смысла - он просто затирается нулями, и переходит в WorkingSet нового владельца.
3. Регистр CR3 и каталог страниц процесса
Когда процесс запускается на исполнение, система создаётся для него личный каталог страниц "PageDirectory", и сохраняет адрес этого каталога в регистре
На схеме ниже представлен каталог. По умолчанию, когда система только планирует запуск исполняемого файла, в нём всего 4 таблицы и все они пустые. Теперь загрузчик образов выделяет несколько страниц вирт.памяти, чтобы отобразить в них дисковый файл. Именно в этот момент таблицы каталога начинают заполняться номерами физ.фреймов, которые система предоставила данному процессу.
Записи "Entry" в таблицах размером 8-байт каждая, поэтому одна таблица на любом уровне занимает в памяти 512*8=4096 байт. Поскольку 1 запись PTE адресует 1 фрейм, то 512 записей дают нам доступ к 512*4096=2МБ физической памяти, чего явно не хватит даже обычному приложению "Hello World". Поэтому в реальной ситуации процессы имеют не одну, а несколько сотен таблиц первого уровня "PageTable", и соответственно такое-же число записей PDE в таблице уровня(2) "PageDirectoryTable". Когда заполнится под макушку и таблица(2), то появится новая запись PDPE на более высоком уровне(3), и т.д.
Важно понять, что таблицы в каталоге заполняются не последовательно (сначала полностью первая, потом вторая и т.д.), а в зависимости от адреса, к которому обратился код нашего приложения. Например дефолтная база загрузки исполняемого файла в память равна
Таким образом, все таблицы заполняются динамически, системным обработчиком исключений "PageFault". Теперь понятно, почему процесс не успел ещё толком вздохнуть, а его счётчик ошибок страниц перевалил уже за 1700. Попробуйте выделить 10 МБ и увидите, что счётчик выстрелил ещё пару раз.
Здесь уместно провести небольшой эксперимент в отладчике.
В общем запрашиваем паспорт моей демки выше PerfInfoGui.exe и видим, что каталог её расположен по физ.адресу
Как и предполагалось выше, в таблицах верхнего уровня(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).
4. Подкачка страниц как основа вирт.памяти
Теперь (когда у нас есть небольшой багаж фундаментальных основ) рассмотрим, как система реализует сброс и восстановление страничных фремов в Pagefile.sys. Начнём с того, что в Win может существовать не один, а целых 16 файлов-подкачки, хотя на практике используется только 1. Такой огромный резерв предусмотрен для серверных систем, а у нас разговор про клиентские ОС. Переменная ядра
Можно сравнить эти данные с окном свойств вирт.памяти, где должны лежать те же значения. И точно, поле
Для перечисления свободных слотов (страниц) в файле используется механизм битовых карт BitMap. Например если размер Pagefile всего 64 страницы, описать их состояние можно одним 64-битным значением qword. Взведённый бит в нём определяет занятый слот, а сброшенный - свободный. Поскольку у меня файл практически чистый, я получил такую карту:
4.1. Техническая сторона вопроса
На схеме ниже представлен формат и содержимое записи PTE в каталоге страниц процесса. Если нулевой бит под кличкой "Valid" взведён в единицу, значит процесс использует данный пейдж, при этом младшие 12-бит записи перечисляют его атрибуты, т.е. доступна-ли страница для записи "Write", был-ли к ней уже доступ на чтение или запись "Accessed", а так-же изменено или нет её содержимое "Dirty". При необходимости в своп сбрасываются только те страничные фреймы, у которых взведён бит "Dirty". Когда PTE валидна, весь контроль над битами берёт на себя сам ЦП на аппаратном уровне, поэтому такие записи имею тип Hardware-PTE.
А вот следующие 36-бит хранят уже номер фрейма в физ.памяти ОЗУ. Это собственно клиент, которого описывает данная запись. Значение будет совпадать с порядковым номером структуры MMPFN в глобальной базе фреймов, поскольку она характеризует тот-же субъект PFN.
Обратите внимание, что в записях PTE виртуальный адрес не фигурирует от слова вообще! Минимальной порцией является здесь страничный фрейм физ.памяти, в то время как вирт.адрес определяет исключительно индекс в таблицах PDPE\PDE\PTE каталога "PageDirectory" процесса.
Всё идёт гладко до тех пор, пока в какой-то момент система вдруг решает отфутболить наш фрейм в файл-подкачки. У менеджера памяти есть специальный поток, который в непрерывном режиме с определённой периодичностью проверяет каталоги всех активных процессов в системе на предмет занятых.., но давно бездействующих фреймов. Именно они становятся первыми кандидатами на сброс в Pagefile. Тогда система должна проделать следующее с активной записью PTE в каталоге:
Обратите внимание на 4-битное поле "PageFileLow". Выше упоминалось, что в системе может мирно сосуществовать 16 самостоятельных файлов-подкачек типа Pagefile1.sys, Pagefile2.sys, и т.д. Так вот в этих 4-битах кодируется как раз номер одного из 16-ти файлов, но поскольку он у нас 1, то биты[15:12] будут сброшены в нуль, т.е. первый и единственный файл (отсчёт с нуля).
В свою очередь, в 5-битное поле "Protection" дружненько перекочевали атрибуты оригинальной PTE, ведь их нужно будет восстановить при копировании фрейма из свопа обратно в рабочий набор процесса WorkingSet. Вот основные значения этого поля, а дальше есть ещё их комбинации (в 5-битах можно закодировать аж 32 варианта).
4.2. Обратный процесс - восстановление фрейма
Но вот через какое-то время нашему коду понадобились данные из выгруженной в своп страницы. Поскольку она ещё зарегана в каталоге PageDirectory, то менеджер памяти по вирт.адресу находит индекс записи PTE в таблицах каталога, и обнаружив в ней сброшенный бит "Valid" делает вывод, что запрашиваемая страница находится в файле-подкачки, в результате чего возникает ошибка "PageFault". Далее в игру вступает уже её обработчик, который делает следующее:
Таким образом получается, что запись PTE в таблице страниц процесса остаётся по прежнему индексу вирт.адреса, однако в её поле "PageFrameNumber" лежит уже номер совсем другого фрейма PFN, например был
5. Заключение
В рамках одной статьи просто нереально охватить весь материал по данной теме, поэтому я старался сократить его по максимуму. Как результат, получилась "пробежка по макушкам", однако вы можете самостоятельно продолжить плавание, если в наличии имеется отладчик WinDbg. К примеру, можно взять из таблицы диспетчеризации прерываний
Под катом:
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 ГБ. Разберём подробней эти сведения продвигаясь от чердака к подвалу (исходник с экзе лежит в скрепке):
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 единственная запись.Таким образом, все таблицы заполняются динамически, системным обработчиком исключений "PageFault". Теперь понятно, почему процесс не успел ещё толком вздохнуть, а его счётчик ошибок страниц перевалил уже за 1700. Попробуйте выделить 10 МБ и увидите, что счётчик выстрелил ещё пару раз.
Здесь уместно провести небольшой эксперимент в отладчике.
В общем запрашиваем паспорт моей демки выше 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 можно взять его дескриптор.Для перечисления свободных слотов (страниц) в файле используется механизм битовых карт 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.
А вот следующие 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. С этого момента фрейм физ.памяти считается свободным, и его можно отдать другому процессу.
Обратите внимание на 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>