Размер страницы памяти windows

Трансляция адресов

Трансляция виртуального адреса – это определение реального (физического) расположение ячейки памяти с данным виртуальным адресом, т. е. преобразование виртуального адреса в физический. Принцип трансляции показан на рис.11.1, здесь мы рассмотрим подробности трансляции и детали реализации в WRK.

Из рис.11.1 видно, что информация о соответствии виртуальных адресов физическим хранится в таблицах страниц. В системе для каждого процесса поддерживается множество записей о страницах: если размер страницы 4 КБ, то чтобы хранить информацию обо всех виртуальных страницах в 32 разрядной системе требуется более миллиона записей (4 ГБ / 4 КБ = 1 048 576). Эти записи о страницах сгруппированы в таблицы страниц (Page Table), запись называется PTE (Page Table Entry). В каждой таблице содержится 1024 записи, таким образом, максимальное количество таблиц страниц для процесса – 1024 (1 048 576 / 1024 = 1024). Половина от общего количества – 512 таблиц – отвечают за пользовательское ВАП, другая половина – за системное ВАП.

Таблицы страниц хранятся в виртуальной памяти (см. рис.11.2). Информация о расположении каждой из таблиц страниц находится в каталоге страниц (Page Directory), единственном для процесса. Записи этого каталога называются PDE (Page Directory Entry). Таким образом, процесс трансляции является двухступенчатым: сначала по виртуальному адресу определяется запись PDE в каталоге страниц, затем по этой записи находится соответствующая таблица страниц, запись PTE которой указывает на требуемую страницу в физической памяти.

Откуда процесс знает, где в памяти хранится каталог страниц? За это отвечает поле DirectoryTableBase структуры KPROCESS (файл base\ntos\inc\ke.h, строка 958, первый элемент массива).
Схема трансляции адресов показана на рис.11.3.

Трансляция адресов

Рис.
11.3.
Трансляция адресов

Записи PDE и PTE представлены структурой MMPTE_HARDWARE (base\ntos\mm\i386\mi386.h, строка 2508), содержащей следующие основные поля:

  • флаг (однобитовое поле) Valid: если виртуальная страница расположена в физической памяти, Valid = 1;
  • флаг Accessed: если к странице были обращения для чтения, Accessed = 1;
  • флаг Dirty: если содержимое страницы было изменено (была произведена операция записи), Dirty = 1;
  • флаг LargePage: если страница является большой (4 МБ), LargePage = 1;
  • флаг Owner: если страница доступна из пользовательского режима, Owner = 1;
  • 20 битовое поле PageFrameNumber: указывает номер страничного фрейма (PFN, Page Frame Number).

В поле PageFrameNumber хранится номер записи в базе данных PFN – системной структуре, отвечающей за информацию о страницах физической памяти. Запись PFN представлена структурой MMPFN (файл base\ntos\mm\mi.h, строка 1710) и подробно описана в [5, стр. 502].

Ошибки страниц

Страница может находиться либо в физической памяти (ОЗУ), либо на диске в файле подкачки.

Если в записи PTE флаг Valid = 1, то страница находится в физической памяти и к ней можно обращаться. Иначе (Valid = 0) – страница недоступна процессу. При попытке доступа к такой странице возникает страничная ошибка (page fault) и вызывается функция MmAccessFault (файл base\ntos\mm\mmfault.c, строка 101).

Причин страничных ошибок существует множество (см. [Руссинович и др., 2008, стр. 463]), мы рассмотрим только одну – страница выгружена в страничный файл (файл подкачки). В этом случае запись PTE имеет тип MMPTE_SOFTWARE (файл base\ntos\mm\i386\mi386.h, строка 2446) и вместо поля PageFrameNumber имеет 20 разрядное поле PageFileHigh, отвечающее за расположение страницы в страничном файле.

Страничные файлы описываются структурой MMPAGING_FILE (base\ntos\mm\mi.h, строка 4239), имеющей следующие поля:

  • Size – текущий размер файла (в страницах);
  • MaximumSize, MinimumSize – максимальный и минимальный размеры файла (в страницах);
  • FreeSpace, CurrentUsage – число свободных и занятых страниц;
  • PageFileName – имя файла;
  • PageFileNumber – номер файла;
  • FileHandle – дескриптор файла.

В 32 разрядных Windows поддерживается до 16 файлов подкачки размером до 4095 МБ каждый. Список файлов подкачки находится в ключе реестра HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management\PagingFiles. Соответствующий системный массив MmPagingFile[MAX_PAGE_FILES] типа PMMPAGING_FILE описывается в файле base\ntos\mm\mi.h (строка 8045).

Пределы памяти

В таблицах 8.1, 8.2 и 8.3 приведены ограничения на виртуальную и физическую память в 32 разрядных и 64 разрядных операционных системах Windows.

Таблица
11.1.
Ограничения на виртуальную память

Тип памяти 32-разрядные Windows 64-разрядные Windows
Виртуальное адресное пространство 4 ГБ 16 ТБ (16 000 ГБ)
Пользовательское ВАП 2 ГБ; до 3 ГБ в случае использования специальных ключей при загрузке 8 ТБ
Системное ВАП 2 ГБ; от 1 до 2 ГБ в случае использования специальных ключей при загрузке 8 ТБ

Таблица
11.2.
Ограничения на физическую память в клиентских версиях

Версия Windows 32-разрядные 64-разрядные
Windows XP От 512 МБ (Starter)
до 4 ГБ (Professional)
128 ГБ (Professional)
Windows Vista от 1 ГБ (Starter)
до 4 ГБ (Ultimate)
от 8 ГБ (Home Basic)
до 128 ГБ (Ultimate)
Windows 7 от 2 ГБ (Starter)
до 4 ГБ (Ultimate)
от 8 ГБ (Home Basic)
до 192 ГБ (Ultimate)

Таблица
11.3.
Ограничения на физическую память в серверных версиях

Версия Windows 32-разрядные 64-разрядные
Windows Server 2003 R2 От 4 ГБ (Standard)
до 64 ГБ (Datacenter)
От 32 ГБ (Standard)
до 1 ТБ (Datacenter)
Windows Server 2008 От 4 ГБ (Web Server)
до 64 ГБ (Datacenter)
От 32 ГБ (Web Server)
до 1 ТБ (Datacenter)
Windows Server 2008 R2 нет 32 разрядных версий от 8 ГБ (Foundation)
до 2 ТБ (Datacenter)

Резюме

В лекции изучаются такие важные понятия как виртуальная и физическая память, виртуальное адресное пространство, рабочий набор процесса, файл подкачки. Рассматриваются структура виртуального адресного пространства процесса, способы выделения памяти процессам, дескрипторы виртуальных адресов, ошибки страниц. Описывается процесс трансляции виртуальных адресов в физические. Приводятся ограничения на размер виртуальной и физической памяти в различных версиях Windows.

Следующая лекция посвящена принципам обеспечения безопасности в Windows.

Контрольные вопросы

  • Дайте определение «виртуальной памяти». Чем виртуальная память отличается от физической?
  • Нарисуйте структуру виртуального адресного пространства в 32 разрядной системе Windows.
  • Что такое страница памяти? Какие страницы используются в Windows?
  • Опишите достоинства и недостатки различных способов выделения памяти.
  • Опишите процесс трансляции адресов. Какую роль в этом процессе играют таблицы страниц? Каталоги страниц?
  • Возможно ли в 32 разрядной системе Windows наличие большего объема физической памяти, чем виртуальной памяти процесса?

Уровень сложностиПростой

Время на прочтение15 мин

Количество просмотров13K

В этом посте я буду говорить о страничной организации только в контексте PML4 (Page Map Level 4), потому что на данный момент это доминирующая схема страничной организации x86_64 и, вероятно, останется таковой какое-то время.

Окружение

Это необязательно, но я рекомендую подготовить систему для отладки ядра Linux с QEMU + gdb. Если вы никогда этого не делали, то попробуйте такой репозиторий: easylkb (сам я им никогда не пользовался, но слышал о нём много хорошего), а если не хотите настраивать окружение самостоятельно, то подойдёт режим практики в любом из заданий по Kernel Security на pwn.college (вам нужно знать команды vm connect и vm debug).

Я рекомендую вам так поступить, потому что считаю, что самостоятельное выполнение команд вместе со мной и возможность просмотра страниц (page walk) на основании увиденного в gdb — хорошая проверка понимания.

Что такое страница

В x86_64 страница — это срез памяти размером 0x1000 байтов, выровненный по 0x1000 байтам.

Именно поэтому при изучении /proc/<pid>/maps видно, что все диапазоны адресов начинаются и заканчиваются адресами, заканчивающимися на 0x000, ведь минимальный размер распределения памяти в x86_64 равен размеру страницы (0x1000 байтов), а страницы должны быть «выровнены по страницам» (последние 12 битов должны быть равны нулю).

При помощи MMU виртуальную страницу (Virtual Page) можно резолвить в единую физическую страницу (Physical Page) (она же «блок страницы», Page Frame), хотя и многие виртуальные страницы могут ссылаться на одну физическую.

Что такое виртуальный адрес

Как можно догадаться, PML4 имеет четыре уровня структур страничной организации памяти; эти структуры называются таблицами страниц (Page Table). Таблица страниц — это область памяти размером со страницу, содержащая 512 8-байтных элементов таблицы страниц. Каждый элемент таблицы страниц ссылается или на таблицу страниц следующего уровня или на конечный физический адрес, в который резолвится виртуальный адрес.

Элемент таблицы страниц, используемый для трансляции адресов, основан на виртуальном адресе доступа к памяти. Так как на каждый уровень используется 512 элементов, 9 битов виртуального адреса применяются на каждом уровне для индексации в соответствующей таблице страниц.

Допустим, у нас есть такой адрес:

0x7ffe1c9c9000

Последние 12 битов адреса обозначают смещение внутри физической страницы:

0x7ffe1c9c9000 & 0xfff = 0x0

Это значит, что определив физический адрес страницы, в которую резолвится этот виртуальный адрес, мы добавим ноль к результату, чтобы получить конечный физический адрес.

После последних 12 битов, которые являются смещением внутри конечной страницы, виртуальный адрес состоит из индексов таблиц страниц. Как говорилось выше, каждый уровень страничной организации памяти использует 9 битов виртуального адреса, поэтому самый нижний уровень структур страничной организации, то есть таблица страниц, индексируется по следующим 9 битам адреса (благодаря битовому маскированию при помощи & 0x1ff для сдвинутого значения). На следующих уровнях нам просто нужно каждый раз выполнять сдвиг вправо ещё на девять битов и снова маскировать нижние девять битов в качестве нашего индекса. Выполнение этой операции для показанного выше адреса даёт нам следующие индексы:

Level 1, Page Table (PT):
Index = (0x7ffe1c9c9000 >> 12) & 0x1ff = 0x1c9

Level 2, Page Middle Directory (PMD):
Index = (0x7ffe1c9c9000 >> 21) & 0x1ff = 0x0e4

Level 3, Page Upper Directory (PUD):
Index = (0x7ffe1c9c9000 >> 30) & 0x1ff = 0x1f8

Level 4, Page Global Directory (PGD):
Index = (0x7ffe1c9c9000 >> 39) & 0x1ff = 0x0ff

База

Разобравшись, как выполнять индексацию в таблицах страниц, и поняв, что они приблизительно содержат, нужно узнать, где они находятся конкретно.

У каждого потока CPU есть базовый регистр таблицы страниц под названием cr3.

cr3 содержит физический адрес самого верхнего уровня структуры страничной организации, называемого Page Global Directory (PGD).

При отладке ядра через gdb содержимое cr3 можно считать следующим образом:

gef➤  p/x $cr3
$1 = 0x10d664000

В зависимости от используемых функций процессора в регистре cr3 наряду с адресом PGD может храниться и дополнительная информация, поэтому более универсальный способ получения физического адреса PGD из регистра cr3 заключается в маскировании нижних 12 битов его содержимого:

gef➤  p/x $cr3 & ~0xfff
$2 = 0x10d664000

Элементы таблиц страниц

Давайте рассмотрим в gdb физический адрес, полученный нами из cr3. Команда monitor xp/... раскрытая gdb благодаря QEMU Monitor, позволяет нам выводить физическую память vm, а команда monitor xp/512gx ... печатает всё содержимое (512 элементов) PGD, на который ссылается cr3:

gef➤  monitor xp/512gx 0x10d664000
...
000000010d664f50: 0x0000000123fca067 0x0000000123fc9067
000000010d664f60: 0x0000000123fc8067 0x0000000123fc7067
000000010d664f70: 0x0000000123fc6067 0x0000000123fc5067
000000010d664f80: 0x0000000123fc4067 0x0000000123fc3067
000000010d664f90: 0x0000000123fc2067 0x000000000b550067
000000010d664fa0: 0x000000000b550067 0x000000000b550067
000000010d664fb0: 0x000000000b550067 0x0000000123fc1067
000000010d664fc0: 0x0000000000000000 0x0000000000000000
000000010d664fd0: 0x0000000000000000 0x0000000000000000
000000010d664fe0: 0x0000000123eab067 0x0000000000000000
000000010d664ff0: 0x000000000b54c067 0x0000000008c33067

Вывод получается большим и почти весь он состоит из нулей, поэтому я привожу здесь только самый конец.

Вероятно, этот вывод пока для вас не имеет особого смысла, но мы можем заметить в данных определённые паттерны, например, многие 8-байтные элементы заканчиваются на 0x67.

Расшифровка записей PGD

Возьмём из показанного выше вывода PGD в качестве примера запись PGD по адресу 0x000000010d664f50 со значением 0x0000000123fca067, чтобы понять, как расшифровать запись.

И давайте сделаем это с двоичной формой значения этой записи:

gef➤  p/t 0x0000000123fca067
$6 = 100100011111111001010000001100111

Вот небольшая схема с объяснениями, что означает каждый бит записи:

~ PGD Entry ~                                                   Present ──────┐
                                                            Read/Write ──────┐|
                                                      User/Supervisor ──────┐||
                                                  Page Write Through ──────┐|||
                                               Page Cache Disabled ──────┐ ||||
                                                         Accessed ──────┐| ||||
                                                         Ignored ──────┐|| ||||
                                                       Reserved ──────┐||| ||||
┌─ NX          ┌─ Reserved                             Ignored ──┬──┐ |||| ||||
|┌───────────┐ |┌──────────────────────────────────────────────┐ |  | |||| ||||
||  Ignored  | ||               PUD Physical Address           | |  | |||| ||||
||           | ||                                              | |  | |||| ||||
0000 0000 0000 0000 0000 0000 0000 0001 0010 0011 1111 1100 1010 0000 0110 0111
       56        48        40        32        24        16         8         0

Вот что означает каждая из этих меток:

  • NX (неисполняемый) — если этот бит установлен, никакое из отображений памяти, являющихся потомком этого PGD, не будет исполняемым.

  • Reserved — эти значения должны быть равны нулю.

  • PUD Physical Address — физический адрес PUD, связанного с этой записью PGD.

  • Accessed — если эта запись или её потомки ссылаются на какую‑то страницу, то этот бит устанавливается MMU и может быть сброшен операционной системой.

  • Page Cache Disabled (PCD) — страницы‑потомки этой записи PGD не должны попадать в иерархию кэшей CPU; иногда этот бит также называют Uncacheable (UC).

  • Page Write Through (WT) — записи в страницы‑потомки этой записи PGD должны сразу же выполнять запись в ОЗУ, а не буферизировать записи в кэш CPU перед обновлением ОЗУ.

  • User/Supervisor — если этот бит сброшен, к страницам‑потомкам этой PGD невозможно выполнить доступ ни из какого режима, за исключением supervisor.

  • Read/Write — если этот бит сброшен, в страницы‑потомки этой PGD нельзя выполнять запись.

  • Present — если этот бит сброшен, то процессор не будет использовать эту запись для трансляции адресов и ни один из остальных битов не будет применяться.

Здесь нас волнует бит Present, биты, определяющие физический адрес следующего уровня структур страничной организации, биты PUD Physical Address и биты разрешений: NX, User/Supervisor, and Read/Write.

  • Бит Present очень важен, потому что без него вся остальная часть записи игнорируется.

  • PUD Physical Address позволяет нам продолжить просмотр страниц (page walk), сообщая, где находится физический адрес следующего уровня структур страничной организации памяти.

  • Биты Permission применяются к страницам, являющимся наследниками записи PGD; они определяют, как можно выполнять доступ к этим страницам.

Остальные биты для наших целей не так важны:

  • Бит Accessed устанавливается, если запись используется при трансляции доступа к памяти, он не важен для просмотра страниц.

  • Page Cache Disabled и Page Write Through не используются для обычного отображения страниц и не влияют на трансляцию страниц и разрешения, так что не будем обращать на них внимания.

Итак, декодировав эту запись, мы получим:

PUD является Present:

gef➤  p/x 0x0000000123fca067 & 0b0001
$18 = 0x1

Отображения в PUD и ниже могут быть Writable:

gef➤  p/x 0x0000000123fca067 & 0b0010
$19 = 0x2

Отображения в PUD и ниже могут быть доступны для User:

gef➤  p/x 0x0000000123fca067 & 0b0100
$20 = 0x4

Физический адрес PUD (биты (51:12] ) — 0x123fca000:

gef➤  p/x 0x0000000123fca067 & ~((1ull<<12)-1) & ((1ull<<51)-1)
$21 = 0x123fca000

Отображения в PUD и ниже могут быть Executable:

gef➤  p/x 0x0000000123fca067 & (1ull<<63)
$22 = 0x0

Декодирование записей для всех уровней

Разобравшись, как декодировать запись PGD, нам будет легко декодировать остальные уровни, по крайней мере, в общем случае.

На всех этих диаграммах X означает, что бит может быть и нулём, и единицей; в противном случае, если биту присвоено конкретное значение, то оно требуется или для архитектуры или для конкретной кодировки, показанной на диаграмме.

PGD

~ PGD Entry ~                                                   Present ──────┐
                                                            Read/Write ──────┐|
                                                      User/Supervisor ──────┐||
                                                  Page Write Through ──────┐|||
                                               Page Cache Disabled ──────┐ ||||
                                                         Accessed ──────┐| ||||
                                                         Ignored ──────┐|| ||||
                                                       Reserved ──────┐||| ||||
┌─ NX          ┌─ Reserved                             Ignored ──┬──┐ |||| ||||
|┌───────────┐ |┌──────────────────────────────────────────────┐ |  | |||| ||||
||  Ignored  | ||               PUD Physical Address           | |  | |||| ||||
||           | ||                                              | |  | |||| ||||
XXXX XXXX XXXX 0XXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX 0XXX XXXX
       56        48        40        32        24        16         8         0

Эту диаграмму мы уже видели, я подробно описал её в предыдущем разделе, но здесь у неё не указана конкретная запись PGD.

PUD

~ PUD Entry, Page Size unset ~                                  Present ──────┐
                                                            Read/Write ──────┐|
                                                      User/Supervisor ──────┐||
                                                  Page Write Through ──────┐|||
                                               Page Cache Disabled ──────┐ ||||
                                                         Accessed ──────┐| ||||
                                                         Ignored ──────┐|| ||||
                                                      Page Size ──────┐||| ||||
┌─ NX          ┌─ Reserved                             Ignored ──┬──┐ |||| ||||
|┌───────────┐ |┌──────────────────────────────────────────────┐ |  | |||| ||||
||  Ignored  | ||               PMD Physical Address           | |  | |||| ||||
||           | ||                                              | |  | |||| ||||
XXXX XXXX XXXX 0XXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX 0XXX XXXX
       56        48        40        32        24        16         8         0

Как видите, показанная выше диаграмма PUD очень похожа на диаграмму PGD, единственное различие заключается в появлении бита Page Size. Установленный бит Page Size сильно меняет интерпретацию записи PUD. В этой диаграмме мы предполагаем, что он сброшен, как и бывает чаще всего.

PMD

~ PMD Entry, Page Size unset ~                                  Present ──────┐
                                                            Read/Write ──────┐|
                                                      User/Supervisor ──────┐||
                                                  Page Write Through ──────┐|||
                                               Page Cache Disabled ──────┐ ||||
                                                         Accessed ──────┐| ||||
                                                         Ignored ──────┐|| ||||
                                                      Page Size ──────┐||| ||||
┌─ NX          ┌─ Reserved                             Ignored ──┬──┐ |||| ||||
|┌───────────┐ |┌──────────────────────────────────────────────┐ |  | |||| ||||
||  Ignored  | ||                PT Physical Address           | |  | |||| ||||
||           | ||                                              | |  | |||| ||||
XXXX XXXX XXXX 0XXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX 0XXX XXXX
       56        48        40        32        24        16         8         0

Диаграмма PMD тоже очень похожа на предыдущую, и, как и в случае с записью PUD, мы игнорируем бит Page Size.

PT

~ PT Entry ~                                                    Present ──────┐
                                                            Read/Write ──────┐|
                                                      User/Supervisor ──────┐||
                                                  Page Write Through ──────┐|||
                                               Page Cache Disabled ──────┐ ||||
                                                         Accessed ──────┐| ||||
┌─── NX                                                    Dirty ──────┐|| ||||
|┌───┬─ Memory Protection Key              Page Attribute Table ──────┐||| ||||
||   |┌──────┬─── Ignored                               Global ─────┐ |||| ||||
||   ||      | ┌─── Reserved                          Ignored ───┬─┐| |||| ||||
||   ||      | |┌──────────────────────────────────────────────┐ | || |||| ||||
||   ||      | ||            4KB Page Physical Address         | | || |||| ||||
||   ||      | ||                                              | | || |||| ||||
XXXX XXXX XXXX 0XXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX
       56        48        40        32        24        16         8         0

В записи Page Table всё становится интереснее: мы видим несколько новых полей/атрибутов, которых не было на предыдущих уровнях.

Вот какие это поля/атрибуты:

  • Memory Protection Key (MPK или PK): это расширение x86_64, позволяющее назначать страницам 4-битные ключи, что можно использовать, чтобы конфигурировать разрешения памяти для всех страниц с этим ключом.

  • Global: этот атрибут связан с тем, как TLB (Translation Lookaside Buffer — кэш MMU для трансляции виртуальных адресов в физические) кэширует трансляцию для страницы; если этот бит установлен, то страница не будет удалена из TLB при переключении контекста; обычно он включён для страниц ядра, чтобы снизить количество промахов TLB.

  • Page Attribute Table (PAT): если значение установлено, то MMU должно обратиться к Page Attribute Table MSR, чтобы определить Memory Type страницы, например, является ли эта страница Uncacheable, Write Through или имеет один из нескольких других типов памяти.

  • Dirty: этот бит похож на бит Accessed, он устанавливается MMU, если в эту страницу выполнена запись, и должен быть сброшен операционной системой.

Ничто из этого не влияет на саму трансляцию адреса, однако конфигурация Memory Protection Key может означать, что ожидаемые разрешения доступа к памяти для страницы, на которую ссылается эта запись, могут быть строже, чем закодированные в самой записи.

В отличие от предыдущих уровней, поскольку это последний, запись хранит последний физический адрес страницы, связанной с виртуальным адресом, который мы транслируем. Применив битовую маску для получения байтов физического адреса и добавив последние 12 байтов исходного виртуального адреса (смещение внутри страницы), мы получим физический адрес!

В общем случае просмотр страниц выполняется всего за несколько этапов:

  • Преобразуем виртуальный адрес в индексы и смещение страницы, сдвинув адрес и применив битовые маски

  • Считываем cr3, чтобы получить физический адрес PGD

  • Чтобы достичь каждого уровня до последнего:

    • Используем индексы, вычисленные из виртуального адреса, чтобы узнать, какую запись из таблицы страниц использовать

    • Применяем битовую маску к записи, чтобы получить физический адрес следующего уровня

  • На последнем уровне снова находим запись, соответствующую индексу из виртуального адреса

  • Применяем битовую маску, чтобы получить физический адрес страницы, связанный с виртуальным адресом

  • Добавляем смещение внутри страницы от виртуального адреса до физического адреса страницы

  • Готово!

Увеличиваем масштаб

Как говорилось выше, диаграммы PUD и PMD рассчитаны на общий случай, когда не установлен бит Page Size.

А что происходит, если он установлен?

Если он установлен, то он, по сути, говорит MMU: сворачивайся, мы здесь закончили, не храни просмотр страниц, текущая запись содержит физический адрес страницы, которую мы ищем.

Но это ещё не всё: физический адрес в записях, где установлен бит Page Size, предназначен не для обычной страницы на 4 КБ (0x1000 байтов), это Huge Page, имеющая два варианта: Huge Page на 1 ГБ и Huge Page на 2 МБ.

Когда бит Page Size установлен у записи PUD, то он ссылается на 1-гигабайтную Huge Page, а когда бит Page Size установлен у записи PMD, он ссылается на 2-мегабайтную Huge Page.

Но откуда берутся числа 1 ГБ и 2 МБ?

Каждый уровень таблиц страниц хранит до 512 записей, то есть PT может ссылаться на не более чем 512 страниц и 512 * 4 КБ = 2 МБ. То есть Huge Page на уровне PMD, по сути, означает, что запись ссылается на страницу, имеющую тот же размер, что и полный PT.

Расширяя это на уровень PUD, мы просто снова умножаем на 512 и получаем размер полного PMD, содержащего полные PT: 512 * 512 * 4 КБ = 1 ГБ.

PUD страницы Huge Page

~ PUD Entry, Page Size set ~                                     Present ─────┐
                                                             Read/Write ─────┐|
                                                       User/Supervisor ─────┐||
                                                   Page Write Through ─────┐|||
                                                Page Cache Disabled ─────┐ ||||
                                                          Accessed ─────┐| ||||
                                                            Dirty ─────┐|| ||||
┌─── NX                                                Page Size ─────┐||| ||||
|┌───┬─── Memory Protection Key                         Global ─────┐ |||| ||||
||   |┌──────┬─── Ignored                             Ignored ───┬─┐| |||| ||||
||   ||      | ┌─── Reserved           Page Attribute Table ───┐ | || |||| ||||
||   ||      | |┌────────────────────────┐┌───────────────────┐| | || |||| ||||
||   ||      | || 1GB Page Physical Addr ||      Reserved     || | || |||| ||||
||   ||      | ||                        ||                   || | || |||| ||||
XXXX XXXX XXXX 0XXX XXXX XXXX XXXX XXXX XX00 0000 0000 0000 000X XXXX 1XXX XXXX
       56        48        40        32        24        16         8         0

Когда бит Page Size установлен, можно заметить, что запись PUD выглядит больше похожей на запись PT, чем на обычную запись PUD, что логично, ведь она также ссылается на страницу, а не на таблицу страниц.

Однако существуют некоторые отличия от PT:

  1. Бит Page Size находится там, где находится в PT бит Page Attribute Table (PAT), поэтому бит PAT перенесён в бит 12.

  2. Физический адрес 1-гигабайтной Huge Page должен иметь выравнивание в физической памяти по 1 ГБ; именно поэтому существуют новые биты Reserved и поэтому бит 12 можно задействовать как бит PAT.

В целом здесь нет ничего особо нового, при работе Huge Page с другие различия заключаются в том, что для получения физического адреса страницы к адресу нужно применить другую битовую маску; кроме того, выравнивание по 1 ГБ означает, что при вычислении физического адреса виртуального адреса в странице нам нужно использовать маску, основанную на выравнивании по 1 ГБ, а не по 4 КБ.

PMD страницы Huge Page

~ PMD Entry, Page Size set ~                                     Present ─────┐
                                                             Read/Write ─────┐|
                                                       User/Supervisor ─────┐||
                                                   Page Write Through ─────┐|||
                                                Page Cache Disabled ─────┐ ||||
                                                          Accessed ─────┐| ||||
                                                            Dirty ─────┐|| ||||
┌─── NX                                                Page Size ─────┐||| ||||
|┌───┬─── Memory Protection Key                         Global ─────┐ |||| ||||
||   |┌──────┬─── Ignored                             Ignored ───┬─┐| |||| ||||
||   ||      | ┌─── Reserved         Page Attribute Table ─────┐ | || |||| ||||
||   ||      | |┌───────────────────────────────────┐┌────────┐| | || |||| ||||
||   ||      | ||     2MB Page Physical Address     ||Reserved|| | || |||| ||||
||   ||      | ||                                   ||        || | || |||| ||||
XXXX XXXX XXXX 0XXX XXXX XXXX XXXX XXXX XXXX XXXX XXX0 0000 000X XXXX 1XXX XXXX
       56        48        40        32        24        16         8         0

Здесь ситуация очень похожа на запись PUD с установленным битом Page Size; единственное изменение заключается в том. что поскольку на этом уровне выравнивание для 2-мегабайтных страниц меньше, установлено меньше битов Reserved.

Выравнивание по 2 МБ означает, что смещение внутри huge page должно вычисляться при помощи маски, основанной на выравнивании по 2 МБ.

Просматриваем страницы

В этом разделе давайте посмотрим, как выполнять просмотр страниц вручную в gdb.

Подготовка

Запустив vm и подключив gdb, я сначала выберу адрес для выполнения просмотра страниц; в качестве примера я использую текущий указатель стека при работе ядра:

gef➤  p/x $rsp
$42 = 0xffffffff88c07da8

Итак, у нас есть адрес для просмотра, давайте получим физический адрес PGD из cr3:

gef➤  p/x $cr3 & ~0xfff
$43 = 0x10d664000

Я воспользуюсь этой небольшой функцией на Python, чтобы извлечь смещения таблиц страниц из виртуального адреса:

def get_virt_indicies(addr):
    pageshift = 12
    addr = addr >> pageshift
    pt, pmd, pud, pgd = (((addr >> (i*9)) & 0x1ff) for i in range(4))
    return pgd, pud, pmd, pt

На выходе получаем следующее:

In [2]: get_virt_indicies(0xffffffff88c07da8)
Out[2]: (511, 510, 70, 7)

PGD

Полученный нами индекс для PGD на основании виртуального адреса — это 511. Умножив 511 на 8, мы получим байтовое смещение в PGD, с которого начинается запись PGD для нашего виртуального адреса:

gef➤  p/x 5118
$44 = 0xff8

Добавив это смещение к физическому адресу PGD, мы получим физический адрес записи PGD:

gef➤  p/x 0x10d664000+0xff8
$45 = 0x10d664ff8

А считывание физической памяти по этому адресу даёт нам саму запись PGD:

gef➤  monitor xp/gx 0x10d664ff8
000000010d664ff8: 0x0000000008c33067

Похоже, у записи установлены последние три бита (present, user и writeable), а старший бит (NX) сброшен, то есть пока нет никаких ограничений на разрешения страниц, связанных с этим виртуальным адресом.

Маскирование битов [12, 51) даёт нам физический адрес PUD:

gef➤  p/x 0x0000000008c33067 & ~((1<<12)-1) & ((1ull<<51) - 1)
$46 = 0x8c33000

PUD

Индекс, полученный для PUD на основании виртуального адреса — это 510. Умножив 510 на 8, мы получим байтовое смещение в PUD, с которого начинается запись PUD для нашего виртуального адреса:

gef➤  p/x 5108
$47 = 0xff0

Добавив это смещение к физическому адресу PUD, мы получим физический адрес записи PUD:

gef➤  p/x 0x8c33000+0xff0
$48 = 0x8c33ff0

А считывание физической памяти по этому адресу даёт нам саму запись PUD:

gef➤  monitor xp/gx 0x8c33ff0
0000000008c33ff0: 0x0000000008c34063

На этом этапе нам нужно начать обращать внимание на Size Bit (бит 7), потому что если это 1-гигабайтная страница, мы остановим на этом просмотр страниц.

gef➤  p/x 0x0000000008c34063 & (1<<7)
$49 = 0x0

Похоже, в этой записи от сброшен, так что мы продолжим просмотр страниц.

Также обратим внимание, что запись PUD заканчивается на 0x3, а не на 0x7, как на предыдущем уровне, младшие два бита (present, writeable) по-прежнему установлены, а бит user теперь сброшен. Это значит, что доступ в пользовательском режиме к страницам, принадлежащим к этой записи PUD, приведёт к page fault из-за безуспешной проверки разрешения на доступ.

Бит NX по-прежнему сброшен, поэтому страницы, принадлежащие к этому PUD, по-прежнему могут быть исполняемыми.

Маскирование битов [12, 51) даёт нам физический адрес PMD:

gef➤  p/x 0x0000000008c34063 & ~((1ull<<12)-1) & ((1ull<<51)-1)
$50 = 0x8c34000

PMD

Индекс, полученный нами для PMD на основании виртуального адреса — это 70, поэтому умножение 70 на 8 позволит нам получить байтовое смещение в PMD с которого начинается запись PMD для нашего виртуального адреса:

gef➤  p/x 708
$51 = 0x230

Добавив это смещение к физическому адресу PMD, получим физический адрес записи PMD:

gef➤  p/x 0x8c34000+0x230
$52 = 0x8c34230

А считывание физической памяти по этому адресу даёт нам саму запись PMD:

gef➤  monitor xp/gx 0x8c34230
0000000008c34230: 0x8000000008c001e3

На этом уровне нам тоже нужно обращать внимание на Size Bit, потому что если это страница на 2 МБ, мы остановим на этом просмотр страниц.

gef➤  p/x 0x8000000008c001e3 & (1<<7)
$53 = 0x80

Похоже, наш виртуальный адрес ссылается на 2-мегабайтную Huge Page! Поэтому физический адрес в записи PMD — это физический адрес Huge Page.

Кроме того, судя по битам разрешений, страница по-прежнему Present и Writeable, а бит User по-прежнему сброшен, так что доступ к этой странице есть только из режима supervisor (ring-0).

В отличие от предыдущих уровней, здесь старший бит NX установлен:

gef➤  p/x 0x8000000008c001e3 & (1ull<<63)
$54 = 0x8000000000000000

То есть Huge Page — это не исполняемая память.

Применив битовую маску к битам [21:51), мы получим физический адрес huge page:

gef➤  p/x 0x8000000008c001e3 & ~((1ull<<21)-1) & ((1ull<<51)-1)
$56 = 0x8c00000

Теперь нам нужно применить маску к виртуальному адресу, основанному на 2-мегабайтному выравниванию страниц, чтобы получить смещение в Huge Page.

2 МБ эквивалентно 1<<21, поэтому применив битовую маску (1ull<<21)-1, мы получим смещение:

gef➤  p/x 0xffffffff88c07da8 & ((1ull<<21)-1)
$57 = 0x7da8

Добавив это смещение к базовому адресу 2-мегабайтной Huge Page, мы получим физический адрес, связанный с виртуальным адресом, с которого мы начинали:

gef➤  p/x 0x8c00000 + 0x7da8
$58 = 0x8c07da8

Похоже, виртуальный адрес 0xffffffff88c07da8 имеет физический адрес 0x8c07da8!

Проверка

Есть несколько способов проверки правильного просмотра страниц, легче всего будет просто сдампить память по виртуальному и физическому адресу, а затем сравнить их. Если они похожи, то, вероятно, мы всё сделали правильно:

Физический:

gef➤  monitor xp/10gx 0x8c07da8
0000000008c07da8: 0xffffffff810effb6 0xffffffff88c07dc0
0000000008c07db8: 0xffffffff810f3685 0xffffffff88c07de0
0000000008c07dc8: 0xffffffff8737dce3 0xffffffff88c3ea80
0000000008c07dd8: 0xdffffc0000000000 0xffffffff88c07e98
0000000008c07de8: 0xffffffff8138ab1e 0x0000000000000000

Виртуальный:

gef➤  x/10gx 0xffffffff88c07da8
0xffffffff88c07da8:	0xffffffff810effb6	0xffffffff88c07dc0
0xffffffff88c07db8:	0xffffffff810f3685	0xffffffff88c07de0
0xffffffff88c07dc8:	0xffffffff8737dce3	0xffffffff88c3ea80
0xffffffff88c07dd8:	0xdffffc0000000000	0xffffffff88c07e98
0xffffffff88c07de8:	0xffffffff8138ab1e	0x0000000000000000

На мой взгляд, выглядит неплохо!

Ещё один способ проверки — использовать команду monitor gva2gpa (гостевой виртуальный адрес в гостевой физический адрес), раскрытую gdb благодаря QEMU Monitor:

gef➤  monitor gva2gpa 0xffffffff88c07da8
gpa: 0x8c07da8

Если предположить, что QEMU выполняет трансляцию адресов правильно (вероятно, это справедливое предположение), то у нас есть ещё одно подтверждение успешности просмотра страниц!

Подведём итог

Надеюсь, теперь у вас есть достаточно чёткое понимание того, как работает страничная организация памяти в системах x86_64. Я хотел уместить в пост много информации, поэтому пришлось подумать, как всё это упорядочить, и я всё ещё не уверен, что это идеальный способ.

Как бы то ни было, я считаю страничную организацию памяти довольно удобной; мне кажется, это одна из тех вещей, разобравшись в которых, можно их понять. Но чтобы разобраться, нужно время и изучение gdb.

Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку

This tutorial was written by Tom’s Hardware Community member viveknayyar007. You can find a list of all their tutorials here.

Pagefile in Windows 10 is a hidden system file with the .SYS extension that is stored on your computer’s system drive (usually C:\). The Pagefile allows the computer to perform smoothly by reducing the workload of the physical memory, or RAM.

Simply put, every time you open more applications than the RAM on your PC can accommodate, the programs already present in the RAM are automatically transferred to the Pagefile. This process is technically called Paging. Because the Pagefile works as a secondary RAM, many times it is also referred to as Virtual Memory.

The minimum and maximum size of the Pagefile can be up to 1.5 times and 4 times of the physical memory that your computer has, respectively. For example, if your computer has 1GB of RAM, the minimum Pagefile size can be 1.5GB, and the maximum size of the file can be 4GB.

By default, Windows 10 automatically manages the Pagefile according to your computer’s configuration and the RAM present in it. However, if you ever face lagging while working on Windows 10, or you start getting the PAGE_FAULT_IN_NONPAGED_AREA or KERNEL_DATA_INPAGE_ERROR Blue Screen of Death (BSOD), managing the size of the Pagefile manually is the first thing you should look for.

Managing the Pagefile size manually in Windows 10 is simple. Here is how:

  • Use an administrator account to log on to Windows 10.
  • From the desktop screen, right-click the Start button to open its context menu.

  • Click System.
  • From the left pane of the System window, click Advanced system settings.

  • On the System Properties box, ensure that you are on the Advanced tab.
  • Click the Settings button from under the Performance section.

  • On the Performance Options box, go to the Advanced tab.
  • Click the Change button from under the Virtual memory section.
  • On the Virtual Memory box, uncheck the Automatically manage paging file size for all drives checkbox.
  • From the available list, click to select the drive on which Windows 10 is installed. (C: in most cases.)
  • From below the list, click to select the Custom size radio button.
  • In the now-enabled fields, type the minimum and maximum size of the Pagefile in megabytes (MB) according to the physical memory present in your computer.
  • Click Set and then click OK.
  • Restart your computer when you’re done.

Get Tom’s Hardware’s best news and in-depth reviews, straight to your inbox.

The Tom’s Hardware forum community is a powerful source of tech support and discussion on all the topics we cover from 3D printers, single-board computers, SSDs, and GPUs to high-end gaming rigs. Articles written by the Tom’s Hardware Community are either written by the forum staff or one of our moderators.

Страничная память — способ организации виртуальной памяти, при котором виртуальные адреса отображаются на физические постранично. Для 32-битной архитектуры x86 минимальный размер страницы равен 4096 байт.[1]

Поддержка такого режима присутствует в большинстве 32-битных и 64-битных процессоров. Такой режим является классическим для почти всех современных ОС, в том числе Windows и семейства UNIX. Широкое использование такого режима началось с процессора VAX и ОС VMS с конца 1970-х годов (по некоторым сведениям, первая реализация). В семействе x86 поддержка появилась с поколения 386, оно же первое 32-битное поколение.

  • поддержка изоляции процессов и защиты памяти путём создания своего собственного виртуального адресного пространства для каждого процесса
  • поддержка изоляции области ядра от кода пользовательского режима
  • поддержка памяти «только для чтения» и неисполняемой памяти
  • поддержка отгрузки давно не используемых страниц в область подкачки на диске
  • поддержка отображённых в память файлов, в том числе загрузочных модулей
  • поддержка разделяемой между процессами памяти, в том числе с копированием-по-записи для экономии физических страниц
  • поддержка системного вызова fork() в ОС семейства UNIX

Адрес, используемый в машинном коде, то есть значение указателя, называется «виртуальный адрес».

Адрес, выставляемый процессором на шину, называется «линейный адрес» (который позже преобразуется в физический).

Запись таблицы страниц обычно содержит в себе следующую информацию:

  • флаг «страница отображена»
  • физический адрес
  • флаг «страница доступна из режима пользователя». При неустановке данного флага страница доступна только из режима ядра.
  • флаг «страница доступна только на чтение». В некоторых случаях используется только для режима пользователя, то есть в режиме ядра все страницы всегда доступны на запись.
  • флаг «страница недоступна на исполнение».
  • режим использования кэша для страницы. Влияет на тип шинных транзакций, инициируемых процессором при обращении через данную запись. Особенно часто используется для видеопамяти (комбинированная запись) и для отображенных в память регистров устройств (полное отсутствие кэширования).

Число записей в одной таблице ограничено и зависит от размера записи и размера страницы. Используется многоуровневая организация таблиц, часто 2 или 3 уровня, иногда 4 уровня (для 64-разрядных архитектур).

В случае 2 уровней используется «каталог» страниц, в котором хранятся записи, указывающие на физические адреса таблиц страниц. В таблицах содержатся записи, указывающие на страницы данных.

При использовании 3-уровневой организации добавляется надкаталог, хранящий записи, указывающие на несколько каталогов.

Старшие биты виртуального адреса указывают на номер записи в каталоге, средние — номер записи в таблице, младшие (адрес внутри страницы) попадают в физический адрес без трансляции.

Формат записей таблиц, их размер, размер страницы и организация таблиц зависит от типа процессора, а иногда и от режима его работы.

Исторически x86 использует 32-битные PTE, 32-битные виртуальные адреса, 4-КБ страницы, 1024 записи в таблице, двухуровневые таблицы. Старшие 10 битов виртуального адреса — номер записи в каталоге, следующие 10 — номер записи в таблице, младшие 12 — адрес внутри страницы.

Начиная с Pentium Pro, процессор поддерживает страницы размером 4 МБ. Однако, чтобы система и программы, запущенные в ней, могли использовать страницы такого размера, технология 4-МБ страниц (hugepages) должна быть соответствующим образом активирована, а приложение настроено на использование страниц такого размера.

Процессор x86 в режиме PAE (Physical Address Extension) и в режиме x86_64 (long mode) использует 64-битные PTE (из них реально задействованы не все биты физического адреса, от 36 в PAE до 48 в некоторых x86_64), 32-битные виртуальные адреса, 4-КБ страницы, 512 записей в таблице, трёхуровневые таблицы с четырьмя каталогами и четырьмя записями в надкаталоге. Старшие 2 бита виртуального адреса — номер записи в надкаталоге, следующие 9 — в каталоге, следующие 9 — в таблице.
Физический адрес каталога или же надкаталога загружен в один из управляющих регистров процессора.

При использовании PAE вместо 4-МБ больших страниц используются двухмегабайтные. См. также PSE.

В архитектуре x86_64 возможно использовать страницы размером 4 килобайта (4096 байтов), 2 мегабайта, и (в некоторых AMD64) 1 гигабайт.

Если обращение к памяти не может быть оттранслировано через TLB, то микрокод процессора обращается к таблицам страниц и пытается загрузить PTE оттуда в TLB. Если и после такой попытки сохранились проблемы, то процессор исполняет специальное прерывание, называемое «отказ страницы» (page fault). Обработчик этого прерывания находится в подсистеме виртуальной памяти ядра ОС.

Некоторые процессоры (MIPS) не имеют обращающегося к таблице микрокода, и генерируют отказ страницы сразу после неудачи поиска в TLB, обращение к таблице и её интерпретация возлагаются уже на обработчик отказа страницы. Это лишает таблицы страниц требования соответствовать жёстко заданному на уровне аппаратуры формату.

Причины отказа страницы (page fault):

  • не существует таблицы, отображающей данный регион,
  • PTE не имеет взведённого флага «страница отображена»,
  • попытка обратиться из пользовательского режима к странице «только для ядра»,
  • попытка записи в страницу «только для чтения»,
  • попытка исполнения кода из страницы «исполнение запрещено».

Обработчик отказов в ядре может загрузить нужную страницу из файла или же из области подкачки (см. свопинг), может создать доступную на запись копию страницы «только для чтения», а может и возбудить исключительную ситуацию (в терминах UNIX — сигнал SIGSEGV) в данном процессе.

Каждый процесс имеет свой собственный набор таблиц страниц. Регистр «каталог страниц» перегружается при каждом переключении контекста процесса. Также необходимо сбросить ту часть TLB, которая относится к данному процессу.

В большинстве случаев ядро ОС помещается в то же адресное пространство, что и процессы, для него резервируются верхние 1—2 гигабайта 32-битного адресного пространства каждого процесса. Это делается с целью избежать переключения таблиц страниц при входе в ядро и выходе из него. Страницы ядра помечаются как недоступные для кода режима пользователя.

Память региона ядра часто совершенно одинакова для всех процессов, однако некоторые подрегионы региона ядра (например, регион Windows, где находится подсистема графики и драйвер видео) могут быть различными для разных групп процессов (сессий).

Так как память ядра одинакова у всех процессов, соответствующие ей TLB не нужно перегружать после переключения процесса. Для этой оптимизации x86 поддерживает флаг «глобальный» у PTE.

Обработчик отказа страницы в ядре способен прочитать данную страницу из файла.

Это приводит к возможности лёгкой реализации отображенных в память файлов. Концептуально это то же, что выделение памяти и чтение в неё отрезка файла, с той разницей, что чтение осуществляется неявно «по требованию», выраженному отказом страницы при попытке обращения к ней.

Вторым преимуществом такого подхода является — в случае отображения «только для чтения» — разделение одной и той же физической памяти между всеми процессами, отображающими данный файл (выделенная же память своя у каждого процесса).

Третьим преимуществом является возможность «забывания» (discard) некоторых отображенных страниц без выгрузки их в область подкачки, обязательной для выделенной памяти. В случае повторной потребности в странице она может быть быстро загружена из файла снова.

Четвёртым преимуществом является неиспользование дискового кэша в этом режиме, что означает экономию на копировании данных из кэша в запрошенный регион. Преимущества дискового кэша, оптимизирующего операции небольшого размера, а также повторное чтение одних и тех же данных, полностью исчезают при чтениях целых страниц и тем более их групп, недостаток же в виде обязательного лишнего копирования — сохраняется.

Отображаемые в память файлы используется в ОС Windows, а также ОС семейства UNIX, для загрузки исполняемых модулей и динамических библиотек. Они же используются утилитой GNU grep для чтения входящего файла, а также для загрузки шрифтов в ряде графических подсистем.

Огромным достоинством страничной виртуальной памяти по сравнению с сегментной является отсутствие «ближних» и «дальних» указателей.

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

Кроме того, сегментные архитектуры имеют тяжелейшую проблему SS != DS, широко известную в начале 1990-х годов в программировании под 16-битные версии Windows. Эта проблема приводит к затруднениям в реализации динамических библиотек, ибо они имеют свой собственный DS, и SS текущего процесса, что приводит к невозможности использования «ближних» указателей в них. Также наличие своего собственного DS в библиотеках требует устанавливающих правильное значение DS заплаток (MakeProcInstance) для обратных вызовов из библиотеки в вызвавшее приложение.

Основная статья: Страничный кэш[en]

Поддержка файлов, отображенных в память, требует поддержки ядром ОС структуры «совокупность физических страниц, содержащих в себе отрезки данного файла». Отображение файла в память делается путём заполнения входов таблиц ссылками на страницы данной структуры.

Совершенно очевидно, что данная структура является уже готовым дисковым кэшем. Использование её в качестве кэша также решает проблему когерентности файла, доступного через read/write, и его же, отображённого в память.

Таким образом, пути кэшированного ввода-вывода в дисковый файл (FsRtlCopyRead в Windows и аналогичная ей generic_file_read() в Linux) реализуются как копирования данных в физические страницы, отображенные на файл.

Такая организация кэша является единственной в Windows, эта ОС вообще не имеет классического блочного кэша диска. Метаданные файловых систем кэшируются путём создания лже-файлов (IoCreateStreamFileObject) и создания страничного кэша для них.

Первоначально архитектура x86 не имела флага «страница недоступна на исполнение» (NX).

Поддержка данного флага появилась в архитектуре x86 как часть режима PAE (Physical Address Extension) в поколении Pentium 4, под большим давлением со стороны специалистов по безопасности (см. архивы NTBugTraq). Установка данного флага на страницах стека и кучи (heap) позволяет реализовать аппаратно защиту от исполнения данных, что делает невозможной работу многих разновидностей вредоносного ПО, в том числе, например, злонамеренную эксплуатацию многих брешей в Internet Explorer (брешь декабря 2008 года, см. MS knowledge base, не может быть задействована в случае включенной DEP).

Поддержка PAE в Windows, дающая возможность включения защиты от исполнения данных, появилась в Windows 2000, она включена по умолчанию в серверных версиях Windows и отключена в клиентских.

Устройства PCI, в том числе память видеоплаты, обычно поддерживают только 32-битные адреса. Следовательно, им должны быть выданы физические адреса ниже отметки 4 ГБ. Эта «апертура» уменьшает объём видимой физической памяти ниже отметки 4 ГБ до примерно 3,2 ГБ. Остальная часть физической памяти переотображается контроллером выше отметки 4 ГБ.

Для любого обращения к памяти свыше отметки 4 ГБ (то есть более чем примерно 3,2 ГБ) требуется поддержка контроллером (то есть северным мостом чипсета) такой конфигурации. Современные чипсеты (например, Intel G33) такую поддержку имеют.

Также требуется настройка BIOS под названием memory remapping, отображающая регион [3,2…4] на [4…4,8].

Процессор x86 вне режима PAE использует 32-битные PTE и физические адреса, то есть ему не доступно ничего, находящееся выше отметки 4 ГБ (см. также PSE-36 об одном из вариантов обхода данного ограничения). Таким образом, для использования памяти более, чем примерно 3,2 ГБ в ОС она должна поддерживать PAE. Для Windows — это опция загрузки, для Linux — опция построения ядра.

Кроме того, Microsoft принудительно отключила поддержку физических адресов выше 4 ГБ по политико-маркетинговым соображениям в следующих ОС:

  • 32-битная Windows XP,
  • 32-битный Windows Server 2003 Web Edition,
  • 32-битная Windows Vista (подключение поддержки требует набора команд в командной строке: «BCDEdit /set PAE forceenable», «BCDEdit /set nolowmem on»).

Поддержка физических адресов выше 4 ГБ имеется в следующих версиях:

  • всe 64-битные версии,
  • 32-битная Windows Vista SP1 (поддержка включена по умолчанию, но её подключение нередко может требовать набора команд в командной строке),
  • 32-битный Windows Server 2003, отличный от Web Edition,
  • 32-битный Windows Server 2008.

Таким образом, для того, чтобы использовать память выше 3,2 ГБ в Windows, нужны:

  • поддержка чипсетом,
  • правильные настройки BIOS,
  • правильная версия Windows,
  • правильная опция загрузки (с поддержкой PAE),
  • поддержка 36-битного адресного пространства драйверами устройств.

Тем не менее, даже в «урезанной» версии Windows, не поддерживающей адреса выше 4 ГБ, имеет смысл всегда использовать PAE, ибо (см. выше) защита от исполнения данных (DEP) тоже требует PAE. При включении PAE может перестать работать небольшая часть ПО, например, эмулятор Windows Mobile. Согласно официальной версии Microsoft, введение 4 ГБ ограничения адресного пространства связано с отсутствующей или плохой поддержкой 36-битного адресного пространства некоторыми драйверами устройств, это следует иметь в виду, по причине аппаратных ограничений или неподходящих драйверов невозможно подключить PAE на версиях, имеющих поддержку физических адресов выше 4 ГБ. Возможность включения или выключения PAE не зависит от драйверов, но, если драйвер какого-то старого PCI оборудования не поддерживает правильно физические адреса, не умещающиеся в 32 бита, то данное устройство будет работать неверно и может привести к зависанию всего компьютера.

  • Защищённый режим
  1. Intel® 64 and IA-32 Architectures Software Developer’s Manual Volume 3 (3A, 3B, 3C & 3D): System Programming Guide (декабрь 2016). Дата обращения: 21 декабря 2019. Архивировано 19 мая 2020 года. (англ.)
  • How It Works: Virtual Memory / David Chisnall, May 21, 2008; Prentice Hall Professional. (англ.)
  • E.2. Страничная организация памяти. Виртуальная память. / Серия: «Основы информационных систем» Выпуск 2 (VII). Память. Юрий А. Денисов
  • Глава 9. Виртуальная память. Архитектурные средства поддержки виртуальной памяти; 9.2.1 Страничная память / Курс Введение в операционные системы, В. Е. Карпов, К. А. Коньков, В. П. Иванников. 2001—2003
  • Лекция 8: Организация памяти компьютера. Простейшие схемы управления памятью. Страничная память / Основы операционных систем, 2004, ISBN 978-5-9556-0044-4

Этот пост — несколько необычное ответвление (spin-off) предыдущего.

Слова «звучит как сюжет для разрушителей легенд» из него, сказанные случайно, красного словца ради, сильно запали мне в голову, и я решил, что было бы неплохо самому «разрушить» несколько таких мифов. И вот появился этот пост — возможно, недостаточно точный технически, но рассказывающий различные факты о памяти в, как я надеюсь, занимательной форме.

Мне кажется, что будет особенно интересно, если вы попробуете при чтении угадывать результаты экспериментов (и статус мифов) до того, как они будут изложены.

Содержание

  1. Программа не может выделить больше памяти, чем установлено ОЗУ
  2. Суммарный размер памяти для всех программ не может превышать 2 Гб
  3. 32-х разрядное приложение не может выделить 1.5 Гб памяти за раз
  4. 32-х разрядное приложение не может использовать более 2 Гб памяти
  5. Ключ /3GB расширяет пользовательское адресное пространство для всех программ
  6. Режим /3GB позволит мне выделить 1 гигантский блок памяти в 3 Гб
  7. 32-х разрядная программа не может выделить более 3 Гб в своём адресном пространстве
  8. 32-х разрядная операционная система не может использовать все 4 Гб оперативной памяти
  9. Вам нужно включать режим /3GB, если у вас есть больше 2 Гб физической памяти
  10. Большой .exe файл — это плохо, потому что он тратит память
  11. Delphi приложение занимает много памяти
  12. Доступ к невыделенной памяти приводит к возбуждению Access Violation
  13. Освобождение памяти уменьшает показатели использования памяти программы
  14. Obj.Free не приводит к Obj = nil
  15. Если программа не освободит память, то в системе останется мусор и она замедлится

Миф №1: программа не может выделить больше памяти, чем установлено ОЗУ

Миф происходит от того, что люди не понимают, что адресное пространство программы теперь виртуально. Оно более не связано с оперативной памятью (вот уже более пятнадцати лет).

Этот миф легко разрушить непосредственным экспериментом. Я установил количество ОЗУ для виртуальной машины в 256 Мб, запустил её и выполнил такой код:

procedure TForm1.Button1Click(Sender: TObject);
begin
  AllocMem(512 * 1024 * 1024); // выделить 512 Мб памяти
end;

Эта операция будет успешна (хотя и достаточно медленна). Операция была бы мгновенной, если бы мы использовали только резервирование (RESERVE), вместо полноценного выделения (COMMIT), но, возможно, тогда эксперимент не был бы таким зрелищным.

Итак, вот снимок экрана с запущенной программой до выделения:

и после (я нажал на кнопку аж два раза):

А вот и общая статистика системы:

Как вы видите, на машине установлено 261'616 Кб оперативной памяти. До выделения памяти наша программа занимала 31'980 Кб виртуальной памяти и 3'764 Кб оперативной. После выделения памяти программа стала занимать 1'080'752 Кб виртуальной памяти и 1'748 Кб физической. Вы также можете увидеть, что суммарное количество выделенной памяти в системе равно 1'313'300 Кб.

Итак, легенда разрушена прямым экспериментом.

Как известно, классический сюжет Mythbusters состоит из двух частей. Когда легенда разрушена, разрушители легенд подбирают такие условия, при которых происходило бы событие, упоминающееся в легенде.

Этим мы сейчас и займёмся: мы заходим в свойства системы и уменьшаем размер файла подкачки до 128 Мб. Таким образом, суммарный объём памяти, доступный системе и всем программам, будет равен 256 + 128 = 384 Мб.

Перезагрузка, запускаем тестовый пример снова и вот результат:

На этот раз наш вызов AllocMem проваливается с выбросом исключения EOutOfMemory. И Process Explorer показывает нам причину:

Статус мифа: busted.

Миф №2: суммарный размер памяти для всех программ не может превышать 2 Гб

Выше мы увидели, что программа может выделить сколько угодно памяти, пока у неё есть место в виртуальном адресном пространстве. Т.е. 32-х разрядная программа может выделить 512 Мб, но не 2 Гб — потому что это размер пользовательской части адресного пространства по умолчанию. Некоторые люди считают, что все запущенные программы в системе не могут выделить более двух гигабайт памяти.

Этот миф происходит от того, что люди не понимают, что адресное пространство программы теперь своё у каждой программы (вот уже более пятнадцати лет).

Посмотрим, так ли это.

В этот раз я запустил пять копий программы-примера из предыдущего пункта и вот что получилось:

А вот и статус системы в целом:

Как видите, никаких проблем нет: все запущенные приложения в системе смогли выделить 2'902'204 Кб памяти (и да, я поднял кол-во ОЗУ до 1 Гб, чтобы система поменьше тормозила).

Что касается части два, то она выглядит так же, как ранее: нельзя выделить памяти больше, чем у вас есть оперативной памяти + файла подкачки.

Статус мифа: busted.

Миф №3: 32-х разрядное приложение не может выделить 1.5 Гб памяти за раз

Несмотря на то, что приложению доступно по умолчанию около 2 Гб виртуального адресного пространства, утверждается, что приложение не может выделить 1.5 Гб памяти одним куском.

Давайте проверим. Изменим код с AllocMem в нашем тестовом приложении на выделение 1.5 Гб и запустим программу. Получаем:

Легенда разрушена?

Не так быстро. Попробуем сделать это на другой машине:

(сообщения «Мало виртуальной памяти» нет)

Гм, в этот раз нам не удаётся выделить 1.5 Гб памяти.

Мы получили противоречивые результаты. В чём же дело?

Хотя нам действительно доступно около 2 Гб одним куском (только в самом начале и в самом конце этого региона откушено по 64 Кб на спец. области), но нужно вспомнить, что в этом адресном пространстве лежат не только ваши данные, но и ваш код, библиотеки (DLL), их код и так далее. Даже если вы не загружали библиотек явно в вашем коде — они всё равно будут загружены. Как минимум это kernel32.dll и user32.dll. И дальше всё зависит от того, как именно они загружены. Обычно системные библиотеки загружаются одним большим компактным регионом, расположенном по старшим адресам — поскольку они загружаются с краю адресного пространства, то в центре у вас получается большой кусок для вашей работы. Но если какая-то DLL загружается в середину адресного пространства, то оно оказывается разбито пополам, и вы уже не сможете выделить память одним куском (но всё ещё можете выделить её в два или три куска).

К примеру, вот снимок загруженных DLL в адресном пространстве первого примера (который успешно выделил 1.5 Гб памяти) до выделения памяти:

Как видим, в центре у нас есть большой свободный кусок — от $2D40000 до $648B0000, т.е. $648B0000 - $2D40000 = 1'563 Мб (примечание: это не значит, что в этом промежутке нет вообще ничего — там могут быть не DLL, а данные). Т.е. у нас есть свободное место.

А вот этот же снимок DLL на машине, где выделить память не удалось:

Как видите, в этом случае в середине большого свободного промежутка у нас разместилась DLL от FileBox Extender — это небольшая утилита, которая добавляет полезные кнопки в заголовки окон. Поскольку она меняет поведение каждого окна, то она должна быть загружена в каждую программу. Но из-за того, что она оказалась неграмотно спроектированной, её базовый адрес оказался в неудачном месте. Такая ситуация называется фрагментацией адресного пространства.

Мораль истории: либо ставьте поменьше «расширителей оболочки», либо следите, чтобы они были грамотно спроектированы.

Статус мифа: plausible.

Миф №4: 32-х разрядное приложение не может использовать более 2 Гб памяти

Постойте-ка, разве мы только что не подтвердили эту легенду? Не совсем. Ведь есть разница: «выделить за раз» и «использовать». Да, вы не можете выделить 2 Гб памяти (или более) — что за раз, что за несколько вызовов: ведь обычно размер пользовательской части виртуального адресного пространства равен 2 Гб, но это не ограничивает вас 2 Гб виртуальной памяти. Вы можете выделять память, без проецирования её в ваше виртуальное адресное пространство. Как мы увидели в мифах 1 и 2: виртуальное адресное пространство программы не равно виртуальной памяти в системе. Второе — больше, чем первое.

Обычно размер пользовательской части виртуального адресного пространства равен 2 Гб, но это не ограничивает вас 2 Гб виртуальной памяти. Вы можете выделять память, без проецирования её в ваше виртуальное адресное пространство:

  h := CreateFileMapping(INVALID_HANDLE_VALUE, 0,
                         PAGE_READWRITE, 1, 0, nil);

При условии, что у вас достаточно физической памяти и/или файла подкачки, этот запрос на выделение 4 Гб памяти будет успешен.

Конечно же, вы не сможете спроецировать всю эту память сразу, но вы можете делать это по частям.

Другим вариантом использования большего объёма памяти является AWE.

В общем, мораль в том, что виртуальное адресное пространство — это не то же самое, что виртуальная память. Как мы видели ранее, вы можете проецировать одну и ту же память по нескольким адресам, так что соответствие один-к-одному для виртуальной памяти и виртуального адресного пространства никогда и не выполнялось. Здесь мы продемонстрировали, что выделение памяти вовсе не означает, что она вообще занимает какое-то место в виртуальном адресном пространстве.

Более того: если вы укажете при загрузке системы ключ /3GB, то вы сможете использовать более 2 Гб виртуального адресного пространства (и снова: и ещё больше — виртуальной памяти). Ключ /3GB изменяет способ разбиения полных 4 Гб виртуального адресного пространства. Вместо разбиения на 2 Гб пользовательского виртуального адресного пространства и 2 Гб режима ядра, разделение будет сделано на 3 Гб пользовательского и 1 Гб адресного пространства режима ядра (это граница по умолчанию, а вообще она варьируется).

Так что это неверно даже если вы говорили про адресное пространство, а не про память вообще. В этом мифе есть лишь часть правды про адресное пространство.

Статус мифа: (totally) busted.

Миф №5: ключ /3GB расширяет пользовательское адресное пространство для всех программ

Ну, давайте включим режим /3GB и запустим нашу программу пример, где AllocMem выделяет 100 Мб. Будем нажимать на кнопку, пока не возникнет сообщение о нехватке памяти и посмотрим, сколько же памяти нам удалось выделить:

Как видим, это существенно меньше ожидаемых 3 Гб памяти.

На самом деле, режим /3GB влияет только на программы с флагом IMAGE_FILE_LARGE_ADDRESS_AWARE.

По соображениям совместимости, только программы, которые явно пометили себя, что они умеют обрабатывать виртуальное адресное пространство больше 2 Гб, получат большее адресное пространство. Не помеченные программы получат свои обычные 2 Гб, а адресное пространство между 2 Гб и 3 Гб не будет использоваться вовсе.

Почему?

Потому что слишком много программ предполагают, что старший бит адреса в пользовательском режиме всегда очищен (т.е. равен 0), часто делая это невольно. В MSDN есть страничка, на которой перечислены несколько способов использования такого предположения. Например, вы можете захотеть найти средний адрес между двумя другими — используя для этого формулу (a + b) / 2. Но если a и b будут больше 2 Гб, то их сумма не влезет в 4-х байтное целое — следовательно, вы получите неверный результат (для верного вычисления надо использовать выражение a + (b - a) / 2). Соответственно, вы не можете просто взять программу, которую вы не писали, пометить её флагом IMAGE_FILE_LARGE_ADDRESS_AWARE и объявить, что дело сделано. Вам вместе с авторами программы надо проверить, что код не делает никаких предположений насчёт этих 2 Гб (а тот факт, что программа не была помечена, как совместимая с 3 Гб, означает, что никаких проверок не было сделано. В самом деле — в противном случае она была бы уже помечена флагом IMAGE_FILE_LARGE_ADDRESS_AWARE!).

Пометка вашей программы флагом IMAGE_FILE_LARGE_ADDRESS_AWARE указывает операционной системе: «давай, дай мне доступ к этой дополнительной памяти пользовательского адресного пространства», в результате адреса выше 2-х Гб становятся возможными возвращаемыми значениями в функциях выделения памяти. Если вы установите флаг «Top down» в предпочтениях менеджера памяти, вы можете указать менеджеру памяти выделять память сначала по старшим адресам, таким образом, вы заставите свою программу работать на высоких адресах сразу же, а не когда заполнится остальное место. Это очень удобный режим для проверки вашей программы в конфигурации /3GB, посольку он заставляет скорее, чем в обычном режиме, использовать проблемные адреса.

Итак, давайте включим IMAGE_FILE_LARGE_ADDRESS_AWARE для нашей программы:

project Project1;

uses
  Windows; // для определения IMAGE_FILE_LARGE_ADDRESS_AWARE

{$SetPEFlags IMAGE_FILE_LARGE_ADDRESS_AWARE}

...

end.

Или (IMAGE_FILE_LARGE_ADDRESS_AWARE = $20 или 32):

…и посмотрим, как это изменит ситуацию:

Больше 2 Гб — что и требовалось показать (кстати, это же является примером и к предыдущему мифу).

Статус мифа: busted.

Миф №6: режим /3GB позволит мне выделить 1 гигантский блок памяти в 3 Гб

Просто то, что у вас есть аж 3 Гб виртуального адресного пространства, ещё не означает, что вы можете выделить один гигантский блок памяти размером 3 Гб. Мы уже видели (в мифе №3), что в виртуальное адресное пространство может быть фрагментировано, и вы не сможете выделить большой кусок за раз.

Стандартные дыры в виртуальном адресном пространстве не изменились: это 64 Кб внизу и 64 Кб около границы в 2 Гб.

Более того, системные DLL продолжают загружаться по их предпочтительным базовым адресам, которые лежат ниже границы 2 Гб. Куча процесса и другие типичные данные также откусывают понемногу от вашего виртуального адресного пространства.

В результате то, что пользовательское виртуальное адресное пространство практически равно 3 Гб, ещё не значит, что всё свободное пространство представлено одним блоком. Дыры около границы 2 Гб не дают вам получить непрерывного участка даже в 2 Гб.

Примечание: некоторые люди могут захотеть попробовать переместить системные DLL по другим адресам, чтобы освободить побольше места, но это не сработает по нескольким причинам. Во-первых, конечно же, этим вы не избавитесь от пробела в 64 Кб около 2 Гб-ной границы. Во-вторых, система выделяет и другие данные, такие как блоки с информацией о потоках (thread information blocks) и переменные окружения, до того, как ваша программа получит шанс на выполнение; так что к тому времени, как ваша программа сможет выделять память, адресное пространство уже будет занято.

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

Статус мифа: busted.

Миф №7: 32-х разрядная программа не может выделить более 3 Гб в своём адресном пространстве

Как мы увидели выше (в мифе №5), включение режима /3GB позволяет вам выделить память больше 2 Гб, но в том эксперименте вы могли столкнуться об ограничение в 3 Гб. Утверждается, что 32-х разрядная программа, скомпилированная с IMAGE_FILE_LARGE_ADDRESS_AWARE, не может выделить более 3 Гб памяти.

Кажется, что легенда подтверждена в мифе №5? Но не так быстро!

Создадим такую программу:

program Project1;

{$APPTYPE CONSOLE}

uses
  Windows;

{$SetPEFlags IMAGE_FILE_LARGE_ADDRESS_AWARE}

const
  ReserveSize = 1024;            // 1024 * 64 Kb - резерв для работы по выводу результатов
  IncSize: Cardinal = 64 * 1024; // выделения по 64 Kb
var
  Sz: Cardinal;
  LasrErr: Cardinal;
  Reserve: Pointer;
begin
  // Сохранили резерв
  Reserve := VirtualAlloc(nil, ReserveSize * IncSize, MEM_RESERVE, PAGE_READWRITE);

  // Цикл по определению максимума
  Sz := ReserveSize * IncSize;
  while Assigned(VirtualAlloc(nil, IncSize, MEM_RESERVE, PAGE_READWRITE)) do
    Inc(Sz, IncSize);
  LasrErr := GetLastError;

  // Отпустили резерв, чтобы у нас была память для обработки и вывода результатов
  VirtualFree(Reserve, 0, MEM_RELEASE);

  // Смотрим, что получилось
  if LasrErr = ERROR_NOT_ENOUGH_MEMORY then
    WriteLn('ERROR_NOT_ENOUGH_MEMORY')
  else
    WriteLn(LasrErr);
  WriteLn('Allocated: ', Sz, ' (', Sz div (1024 * 1024), ' MB)');
  ReadLn;
end.

Эта программа пытается исчерпать память кусками по 64 Кб. Кроме того, она держит резерв памяти, чтобы выполнить WriteLn и работу со строками в конце (в самом деле, если вы исчерпаете всю память, то не сможете вывести результат). Программа также помечена флагом IMAGE_FILE_LARGE_ADDRESS_AWARE, что даёт ей доступ к памяти больше 2 Гб.

Теперь запустим эту 32-х разрядную программу на 64-х разрядной системе (никаких дополнительных действий вроде включения спец. режимов не требуется):

Это ж без малого аж 4 Гб для 32-х разрядной программы! Т.е. почти двукратное увеличение по сравнению с обычными 2 Гб. Круто.

Статус мифа: plausible.

Миф №8: 32-х разрядная операционная система не может использовать все 4 Гб оперативной памяти

Максимальный для 32-разрядных систем объем памяти – это 128 Гб, как указано в спецификации на Windows Server 2003 Datacenter Edition.

Такое ограничение связано с тем, что в более мощных системах структуры, применяемые диспетчером памяти для отслеживания физической памяти, потребляли бы слишком большую часть пространства виртуальных адресов. Диспетчер памяти отслеживает страницы памяти при помощи массива, называемого базой данных PFN, и в целях оптимизации производительности отображает все содержимое этой базы в виртуальную память. Так как каждая страница памяти представлена структурой данных объемом 28 байт, в системе с физической памятью емкостью 128 Гб для размещения базы данных PFN потребуется 930 Мб. В 32-разрядных ОС Windows предусмотрено пространство виртуальных адресов объемом 4 Гб, зависящее от оборудования и по умолчанию распределяемое между текущим процессом пользовательского режима (например, блокнотом) и системой. В таких условиях база данных PFN объемом 980 Мб занимает почти половину из доступных 2 Гб системной части пространства виртуальных адресов, а значит, на отображение ядра, драйверов устройств, системного кэша и других структур данных системы остается всего 1 Гб:

По той же причине в таблице ограничений объема памяти указаны пониженные лимиты при загрузке в режиме /3GB. Дело в том, что для этого режима характерна такая схема разделения физической памяти, при которой процессам пользовательского режима достается 3 Гб, а системе – всего 1 Гб. В целях повышения производительности в ОС Windows Server 2008 для системных нужд резервируется более значимая доля адресного пространства. Для этого максимальный объем физической памяти, поддерживаемый в 32-разрядных версиях ОС, сокращается до 64 Гб.

Но разрушители легенд не были бы разрушителями легенд, если бы они верили на слово. Поэтому они должны это проверить.

Берём виртуальную машину, устанавливаем ей количество ОЗУ в 4 Гб и запускаем. Что же мы видим?

Что-то не очень похоже на обещанные 128 Гб. В чём же дело?

Дело в том, что ограничение в 128 Гб — это ограничение серверных ОС. Клиентские ОС (а Windows XP и Windows 7 — это клиентские ОС) имеют ограничения в 4 Гб.

Ну, это ничего не объясняет. Во-первых, почему такая разница? Это маркетинговый ход? Во-вторых: где же наши обещанные 4 Гб? Мы видим всего 3.5 Гб.

Во-первых, в ходе тестирования Windows выяснилось, что если разрешить использование памяти более 4 Гб, то многие системы аварийно завершают работу, зависают и отказываются загружаться. Происходит это из-за того, что некоторые драйверы устройств (в особенности аудио- и видеоустройств) запрограммированы на работу с физическими адресами в пределах 4 Гб. Эти драйверы, оказывается, обрубают адреса свыше 4 Гб, что приводит к повреждению содержимого памяти со всеми вытекающими последствиями. В серверных же системах, которые, как правило, оснащаются менее специфичными устройствами с относительно простыми и надежными драйверами, подобные проблемы обнаружены не были. Выявленные недостатки экосистемы драйверов заставили применительно к клиентским версиям ОС отказаться от работы с памятью в объеме свыше 4 Гб, несмотря на то, что теоретически её адресация возможна (обращаю внимание, что речь идёт о физической памяти, а не о виртуальном адресном пространстве, которое даже теоретически не может быть больше 4 Гб в 32-х разрядной системе).

Во-вторых, фактический лимит поддержки объема памяти ниже. Кроме того, он зависит от набора микросхем и характеристик подключенных устройств. Дело в том, что в таблицу физических адресов включается не только оперативная память, но и память устройств. При этом, для совместимости с 32-разрядными операционными системами, которые не способны обрабатывать адреса свыше 4 Гб, в системах x86 и x64 память устройств отображается ниже границы адресации 4 Гб. Предположим, что в системе установлено 4 Гб оперативной памяти, а окна в память сетевых адаптеров, аудио- и видеоустройств в сумме составляют 500 Мб, тогда 500 Мб из 4 Гб оперативной памяти окажутся за границей адресации — и мы получим доступные только 3.5 Гб физической памяти.

Даже если система оснащена всего 2 Гб физической памяти, может случиться так, что часть её окажется недоступной под управлением 32-разрядной версии Windows. Причиной тому – наборы микросхем, практикующие агрессивное резервирование областей памяти для устройств. Хотя такой сценарий, конечно, достаточно редок.

Статус мифа: plausible.

Миф №9: вам нужно включать режим /3GB, если у вас есть больше 2 Гб физической памяти

Физическая память — это не виртуальная память.

Я не уверен, какой логический процесс привёл к рождению этого мифа. Это не может быть из-за неверной интерпретации соответствия один-к-одному виртуальной и физической памяти, поскольку отображение явно не один-к-одному. Обычно у вас намного больше виртуальной памяти, чем физической. Свободная физическая память не имеет соответствия ни в одном виртуальном адресном пространстве. А разделяемая память обозначена в нескольких виртуальных адресных пространствах, хотя соответствует одним и тем же страницам физической памяти.

Этот миф разрушен в предыдущем расследовании, где никакого режима /3GB мы не включали, но получили 3.5 Гб памяти.

Статус мифа: busted.

Миф №10: большой .exe файл — это плохо, потому что он тратит память

Замечу, что, конечно же, в этом мифе имеется в виду физическая память, а не виртуальная — в том смысле, что большее потребление физической памяти — это нагрузка на систему, т.е. плохо. Очевидно, что виртуальное адресное пространство включает в себя .exe файл, так что тут даже нечего обсуждать.

Итак, я создал два идентичных проекта (пустых VCL приложения). Но во втором приложении я сделал Project / Resources and images и выбрал 110-мегабайтный файл:

Компиляция и мы получаем два файла — в примерно 5 и 110 Мб (включена отладочная информация TD32):

Запускаем обе программы и…

Неожиданно оказывается, что обе программы, несмотря на двадцатикратную разницу в размере, потребляют полностью идентичное количество оперативной памяти! Сюрприз.

Мне кажется, что этот миф получается из-за того, что люди не видят разницы между оперативной памятью и виртуальным адресным пространством.

Смысл в том, что при загрузке программы (или DLL) её файл проецируется на адресное пространство процесса с помощью механизма проецируемых в память файлов. Как и виртуальная память, проецируемые файлы позволяют резервировать регион адресного пространства и передавать ему физическую память. Различие между этими механизмами состоит в том, что в последнем случае физическая память не выделяется из страничного файла (файла подкачки), а берется из файла, уже находящегося на диске. Как только файл спроецирован в память, к нему можно обращаться так, будто он целиком в нее загружен.

Проецируемые файлы применяются для:

  • загрузки и выполнения EXE- и DLL-файлов. Это позволяет существенно экономить как на размере страничного файла, так и на времени, необходимом для подготовки приложения к выполнению;
  • доступа к файлу данных, размещенному на диске. Это позволяет обойтись без операций файлового ввода-вывода и буферизации его содержимого;
  • разделения данных между несколькими процессами, выполняемыми на одной машине (в Windows есть и другие методы для совместного доступа разных процессов к одним данным — но все они так или иначе реализованы на основе проецируемых в память файлов);

Вот почему мы видим увеличение на +110 Мб виртуальной памяти у второго процесса — потому что туда спроецирован больший по размеру .exe файл.

При вызове из потока функции CreateProcess система действует так:

  1. Отыскивает ЕХЕ-файл, указанный при вызове функции CreateProcess;
  2. Создает новый объект ядра «процесс»;
  3. Создает адресное пространство нового процесса;
  4. Резервирует регион адресного пространства — такой, чтобы в него поместил ся данный ЕХЕ-файл. Желательное расположение этого региона указывается внутри самого ЕХЕ-файла. По умолчанию базовый адрес ЕХЕ-файла — $00400000.
  5. Отмечает, что физическая память, связанная с зарезервированным регионом, — ЕХЕ-файл на диске, а не страничный файл.

Спроецировав ЕХЕ-файл на адресное пространство процесса, система обращается к разделу ЕХЕ-файла со списком DLL, содержащих необходимые программе функции. После этого система, вызывая LoadLibrary, поочередно загружает указанные (а при необходимости и дополнительные) DLL-модули. Всякий раз, когда для загрузки DLL вызывается LoadLibrary, система выполняет действия, аналогичные описанным выше в пп. 4 и 5.

После увязки EXE- и DLL-файлов с адресным пространством процесса начинает исполняться стартовый код EXE-файла. Подкачку страниц, буферизацию и кэширование система берет на себя. Например, если код в ЕХЕ-файле переходит к команде, не загруженной в память, возникает ошибка. Обнаружив её, система перекачивает нужную страницу кода из образа файла на страницу оперативной памяти. Затем она отображает страницу оперативной памяти на должный участок адресного пространства процесса, тем самым позволяя потоку продолжить выполнение кода. Все эти операции скрыты от приложения и периодически повторяются при каждой попытке процесса обратиться к коду или данным, отсутствующим в оперативной памяти.

Иными словами, при загрузке процесса, не имеет значения, какого размера будет .exe файл — файл будет лишь спроецирован на адресное пространство, но в физической памяти будет только первая страница кода. Все остальные части будут загружены только по мере обращения кода к ним.

Но наша работа на этом ещё не закончена. Когда же размер файла имеет значение?

Ответ: при упаковке или шифровании.

В Windows загрузчик читает лишь заголовок и таблицу импорта файла, а затем проецирует его на адресное пространство процесса так, будто бы файл является частью виртуальной памяти, хранящейся на диске. Подкачка с диска происходит динамически — по мере обращения к соответствующим страницам памяти, причем загружаются только те из них, которые действительно нужны.

Например, если в текстовом редакторе есть модуль работы с таблицами, он не будет загружен с диска до тех пор, пока пользователь не захочет создать (или отобразить) свою таблицу. Причем неважно — находится ли этот модуль в динамической библиотеке или в основном файле. Загрузка таких «монстров», как Delphi и Word, как бы «размазывается» во времени и к работе с приложением можно приступать практически сразу же после его запуска. А что произойдет, если файл упаковать? Правильно — он будет должен считаться с диска целиком (!) и затем — опять-таки, целиком — распаковаться в оперативную память.

Стоп! Откуда у нас столько оперативной памяти? Ее явно не хватит и распакованные страницы придется вновь скидывать на диск! Как говорится: за что боролись, на то и напоролись. Причем, если при проецировании неупакованного EXE-файла оперативная память не выделяется, (во всяком случае, до тех пор, пока в ней не возникнет необходимость), то уж распаковщику без памяти никак не обойтись. А поскольку оперативной памяти никогда не бывает в избытке, она может быть выделена лишь за счет других приложений! Отметим также, что в силу конструктивных особенностей железа и архитектуры операционной системы, операция записи на диск заметно медленнее операции чтения.

Важно понять: Windows никогда не сбрасывает на диск не модифицированные страницы проецируемого файла. Зачем ей это? Ведь в любой момент их можно вновь считать из оригинального файла. Но при распаковке модифицируются все страницы файла! Значит, система будет вынуждена «гонять» их между диском и памятью, что существенно снизит общую производительность всех приложений в целом.

Еще большие накладные расходы влечет за собой сжатие динамических библиотек. Для экономии памяти страницы, занятые динамической библиотекой совместно используются всеми процессами, загрузившими эту DLL (об этом — в следующем мифе). Но как только один из процессов пытается что-то записать в память, занятую DLL, система мгновенно создает копию модифицируемой страницы и предоставляет ее в «монопольное» распоряжение процесса-писателя. Поскольку распаковка динамической библиотеки происходит в контексте процесса, загрузившего ее, система вынуждена многократно дублировать все страницы памяти, выделенные библиотеке, фактически предоставляя каждому процессору свой собственный экземпляр DLL. Предположим, одна DLL размером в мегабайт, была загружена десятью процессами — посчитайте: сколько памяти напрасно потеряется, если она сжата!

Таким образом, под Windows сжимать исполняемые файлы нецелесообразно — вы платите гораздо больше, чем выручаете.

Статус мифа: busted.

Миф №11: Delphi приложение занимает много памяти

Я уже высказывался на эту тему и даже говорил о типичных ошибках при поиске утечек памяти.

Самое время взяться за эту легенду!

Я взял последнюю версию Delphi на сегодня — Delphi XE (ведь известно, чем старше версия Delphi, тем больший размер она имеет) и создал в ней два пустых приложения — VCL Forms и консольное. Запускаем и видим — VCL Forms:

И консольное:

Вы только посмотрите на эти числа: около 80 и 57 мегабайт! И это — пустые приложения. Просто ужасно.

Кажется, что легенда подтверждена, но так ли это? Давайте посмотрим внимательнее.

Напомню, что это — виртуальная память. Никого не волнует, сколько её вы захапаете. Для экосистемы приложений важно, сколько оперативной (физической) памяти вы занимаете. Почему? Ну, чем плохо, что вы тратите много ресурсов? В абстрактном вакууме — ничем. Если в системе есть ресурсы — тратьте их как угодно. Но на практике машину не покупают для запуска исключительно вашей программы, поэтому здесь важно, что на машине работают и другие программы. Вот почему важно, чтобы вы оставляли как можно больше совместных ресурсов свободными — чтобы ими могло воспользоваться больше программ. Но ведь виртуальное адресное пространство не является общим ресурсом! Оно своё у каждой программы. А общий ресурс — это процессор и физическая память. Вот они делятся между всеми программами, в отличие от виртуального адресного пространства, которое выделяется каждой программе в эксклюзивное пользование.

Мне кажется, что этот миф происходит из неосознавания этой связи.

Но легенда ещё не разрушена — что там у нас с физической памятью?

Каждой программе при её работе выделяется физическая память. Программа не может работать с памятью, выгруженной на диск. Если программа выполняет код, находящийся в файле подкачки, либо обращается к данным, выгруженным на диск, то система автоматически загрузит эти код и данные в оперативную память. Если там есть свободное место. Если его там нет — то часть старых данных (к которым давно не было обращения или к ним обращаются редко) будет выгружена на диск, чтобы освободить место для новых данных. Оперативная память тратится и на такие вещи как дисковый кэш.

Итак, что там с оперативной памятью в нашей программе? Если вы посмотрите на снимки экрана выше, то получите два числа: 10'072 Кб для VCL Forms и 3'548 Кб для консольного (колонка «Working Set Size», это значение также называется «песочницей» программы и показывается Диспетчером Задач в колонке «Память»). Кажется, что это огромные значения — в несколько раз больше размера .exe файлов (который равен 894 Кб для VCL Forms и 22 Кб для консольного).

Кажется, что теперь легенда подтверждена? Но не будем спешить с выводами.

Как мы узнали выше, память в адресном пространстве, хотя и выделяется с кратностью в 64 Кб, но минимальным блоком (для прочих операций с памятью) является страница памяти (она имеет размер в 4 Кб на текущих редакциях Windows). Кроме того, мы узнали, что одна и та же страница памяти может присутствовать (быть ассоциирована) с несколькими программами (адресными пространствами). Вспомните проецируемые в память файлы.

К чему я это говорю?

Когда вы запускаете одну и ту же программу второй раз, система просто открывает другое проецируемое в память представление объекта «проекция файла», идентифицирующего образ исполняемого файла. С помощью проецируемых в память файлов несколько одновременно выполняемых экземпляров программы могут совместно использовать один и тот же код, загруженный в оперативную память. Т.е. система просто-напросто проецирует страницы виртуальной памяти, содержащие код и данные .exe файла, второй программы на адресное пространство первого экземпляра программы.

Если один экземпляр программы модифицирует какие-либо данные, размещенные на общей (разделяемой)странице данных, система перехватывает эту попытку, выделяет новый блок памяти, копирует в него нужную программе страницу и после этого разрешает запись в новый блок памяти. Благодаря этому механизму (называемому copy-on-write — копирование при записи), работа остальных экземпляров программы не нарушается. Аналогичная цепочка событий происходит и при отладке приложения. Например, запустив несколько экземпляров программы, вы хотите отладить только один из них. Вызвав отладчик, вы ставите в строке исходного кода точку прерывания. Отладчик модифицирует ваш код, заменяя одну из команд на языке ассемблера другой — заставляющей активизировать сам отладчик. И снова система использует копирование при записи. Обнаружив попытку отладчика изменить код, она выделяет новый блок памяти, копирует туда нужную страницу и позволяет отладчику модифицировать код на этой копии.

Иными словами, то, что вашей программе выделено 10'072 Кб оперативной памяти, — ещё не означает, что это «её вина». Т.е. эти 10'072 Кб — не лично ваша собственность, они совместно используются ещё и другими программами. Можно ли узнать, сколько в этих 10 Мб ваших данных? Да, можно. Это значения в колонке «WS Private» (private working set). Для VCL Forms мы получаем 1'604 Кб, а для консольного — 876 Кб. Это и есть те реальные значения, на которые ваша программа загружает систему. Ради сравнения — эти же программы на Delphi 3 дают 692 Кб и 332 Кб соответственно. Достаточно мало и намного меньше тех значений, о которых обычно думает тот, кто кричит: «ай как много занимает памяти Delphi приложение». И это в системе, где куча свободной ОЗУ и нет давления на память — т.е. это почти максимум. В условиях давления на память эти значения были бы ещё ниже. Посмотрите, как в мифе №1 размер потребляемой Total Commander-ом оперативной памяти снизился с 1'080 Кб до 136 Кб в условиях нехватки памяти (выделения 2x512 Мб на системе с 256 Мб ОЗУ). И заметьте, что даже при выделении 1 Гб памяти, песочница вашей программы осталась очень компактной — менее 2 Мб: потому что к этой памяти мы не обращались. Мы её только выделили.

Статус мифа: busted.

Миф №12: доступ к невыделенной памяти приводит к возбуждению Access Violation

Гм, разве каждый ребёнок не знает про то, что прежде чем использовать память, её надо выделить? Попытка доступа к невыделенной памяти неизменно закончится ошибкой доступа к памяти. Звучит разумно и миф кажется правдоподобным. Но давайте посмотрим, так ли это на самом деле:

program Project1;

{$APPTYPE CONSOLE}

uses
  Windows;

var
  P: Pointer;
begin
  P := VirtualAlloc(nil, 1024, MEM_RESERVE or MEM_COMMIT, PAGE_READWRITE);
  FillChar(P^, 2 * 1024, 0);
  ReadLn;
end.

Чтобы исключить влияние менеджера памяти Delphi, мы выделяем память не через GetMem / AllocMem, а прося её напрямую у системы — через VirtualAlloc. Суть примера в том, что мы выделяем 1 Кб памяти (1024 байт), а потом записываем в них 2 Кб. Казалось бы, это должно привести к возбуждению Access Violation, но при запуске программы мы обнаруживаем, что она успешно выполняется до конца.

В чём же дело? Как мы помним, выделение памяти происходит с гранулярностью в 64 Кб, а размер выделяемых блоков кратен размеру страницы — т.е. 4 Кб. Да, это странное поведение (почему бы не выделять память с гранулярностью в 4 Кб?), но у него есть причины. Но это означает, что если вы просите у системы 1 Кб, то будет выделено все 4 Кб, а 60 Кб, следующие за этой страницей, останутся неиспользуемыми (ведь следующий блок памяти может начинаться лишь на границе +64 Кб от текущего).

Вот и причина для успешного выполнения этого кода — на самом деле код программы выделяет не 1 Кб, а 4 Кб. Это легко можно подтвердить, если заменить множитель 2 в FillChar на 5: 5 Кб больше 4 Кб, поэтому теперь программа вылетит.

Несмотря на это, миф нельзя назвать полностью разрушенным, ведь технически эта память выделена — даже хотя вы логически этого не просили. Поэтому я бы назвал его «правдоподобным».

Статус мифа: plausible.

Миф №13: освобождение памяти уменьшает показатели использования памяти программы

Многие ожидают, что освобождая память, вы возвращаете её системе. И снова это выглядит логично, но что будет на практике?

Создадим пустое приложение с двумя кнопками и Edit-ом: первая кнопка будет выделять память, указанную в Edit-е, а вторая — её освобождать:

procedure TForm1.Button1Click(Sender: TObject);
begin
  Tag := Integer(AllocMem(StrToInt(Edit1.Text) * 1024));
end;

procedure TForm21.Button2Click(Sender: TObject);
begin
  FreeMem(Pointer(Tag));
end;

Запустим программу и попробуем щёлкать на кнопках со значением 10240 (10 Мб).

Ну, при выделении памяти потребление виртуальной памяти приложением подскакивает на +10 Мб, а при освобождении — уменьшается.

Миф подтверждён? Мы так легко не сдаёмся: попробуйте повторить этот же эксперимент, указывая значения вроде 1 или 4. Теперь вы можете заметить, что при освобождении памяти, занятая виртуальная память не изменяется. Более того, если вам достаточно повезёт, то вы увидите, что при выделении памяти, потребление виртуальной памяти не увеличивается!

(примечание: это плавающее поведение; возможно, вам придётся поэкспериментировать с выделением/освобождением памяти, прежде чем вы его воспроизведёте)

Неужели мы открыли неизвестный науке принцип возникновения памяти из ничего? Вовсе нет — вспомните, что работа с памятью в Delphi (да и других языках тоже) идёт через менеджер памяти — это прослойка между вами и системой, которая, грубо говоря, упаковывает ваши запросы на память в один пакет. В предыдущем мифе мы уже увидели, что при выделении всего 1 Кб памяти на деле расходуется в несколько раз больше памяти — из-за её гранулярности. Чтобы память не пропадала зря, менеджер памяти располагает в одном блоке памяти сразу несколько ваших запросов на память — вот почему потребление памяти может не изменяться при выделении/освобождении памяти: потому что память будет «выделена» в уже существующем блоке памяти, либо же при освобождении памяти менеджер памяти не сможет освободить блок памяти, потому что там есть и другие занятые регионы (либо он может просто придержать свободный блок, на случай, если вы сейчас захотите заново выделить память).

Заметьте, что это не является какой-то «плохой» вещью, как вам может показаться. Мы уже разрушили такой миф: вспомните, что потребление оперативной памяти программы крайне слабо связано с выделением памяти в ней. Вы можете выделить 1 Гб памяти, но в оперативной памяти система даст вам всего 2 Мб. Так и с этой, временно не используемой памятью: она никак не мешается и лежит в файле подкачки, пока вам не понадобится или пока её не освободят.

Конечно же, сказанное не означает утечку памяти — вся эта память будет в итоге возвращена системе. Либо с течением времени, либо при последующих освобождениях памяти. Так что в целом, на длительном участке, миф выполняется: освобождение памяти в вашем коде уменьшает потребление памяти программой, но это может быть не так локально, на достаточно малых участках кода.

Статус мифа: plausible.

Примечание: предыдущие два мифа приводят к неизбежному заключению: вы не можете судить о том, валиден (т.е. допустим, корректен) ли данный вам указатель (т.е. была ли под него выделена память) или нет. Более того, это нельзя сделать даже при отсутствии факторов, про которые говорится в мифах:

P := AllocMem(1024);
FreeMem(P);
N := AllocMem(1024);

Валиден ли P? Логически — нет, т.к. у нас есть явное освобождение памяти. Но любая проверка покажет вам, что он допустим: потому что память под N выделится ровно на том же самом месте, где были данные от P. Таким образом, P и N будут указывать на одно и то же место в памяти, даже хотя P более не считается допустимым по этому коду. Поскольку вы не можете гарантировать неосуществимость этой операции на практике (за исключением специальных отладочных сборок, конечно же), то вы и не можете судить о допустимости указателя, основываясь на любых его проверках: какую-бы проверку вы ни предложили бы, всегда найдётся ситуация, когда проверка будет давать ложно-положительный результат.

Поэтому, когда вы освобождаете память, всегда присваивайте указателю nil: тогда его проверка на допустимость будет тривиальной if Assigned(P) then.

Миф №14: Obj.Free не приводит к Obj = nil

Если вы работали с объектами, то знаете, что одним из способов удалить объект — это вызывать метод Free, который проверит ссылку объекта и вызовет его деструктор. Правда ли, что после этого ссылка объекта не изменяется и продолжает указывать на бывший объект?

Это очень легко проверить:

procedure TForm1.Button1Click(Sender: TObject);
var
  O: TObject;
begin
  O := TObject.Create;
  O.Free;
  if Assigned(O) then
    ShowMessage('O <> nil');
end;

Запустите её — и вы получите сообщение.

Почему так происходит? Ну, об этом можно догадаться. Free — это метод объекта. Да, в него передаётся указатель на объект, как и в любой другой метод (далее, в методе, этот указатель становится Self) — но передаётся по значению. Иными словами, Self := nil внутри Free не изменит O — ведь любые изменения в параметре, переданном по значению, не влияют на исходное значение параметра. Free не может изменить O даже теоретически.

Если бы это было не так, то вызов конструктора для создания объекта мог бы быть таким:

O.Create;

Если бы изменения в Self влияли бы на исходное значение, то подобный вызов мог бы создать объект и записать ссылку в O. Но вместо этого мы пишем:

O := TObject.Create;

Что означает создание объекта и запись ссылки в переменную.

Так же и с освобождением объекта: если вы хотите об-nil-ить ссылку — передавайте её по ссылке (в FreeAndNil):

FreeAndNil(O);

FreeAndNil освободит объект и присвоит O в nil. Я уже упоминал, что FreeAndNil является самым правильным вариантом освобождения объекта из трёх (вызов деструктора Destroy, вызов прослойки Free и вызов FreeAndNil).

Статус мифа: confirmed.

Миф №15: если программа не освободит память, то в системе останется мусор и она замедлится

За эти 14 мифов мы уже столько раз запускали тестовые программы на выделение огромных количеств памяти (некоторые — даже больше размера установленной памяти в системе), но после закрытия программ система продолжала работать как ни в чём не бывало, что этот миф разрушен ещё до того, как я его озвучил.

Но особо дотошные могут его проверить самостоятельно: это будет домашнее задание для начинающих разрушителей легенд. Как бы вы его проверили?

Суть в том, что при закрытии программы, все ресурсы, которые были с ней ассоциированы (виртуальная память, физическая память, открытые файлы и т.п.), автоматически удаляются/закрываются/очищаются системой. Конечно, в системе есть глобальные ресурсы/объекты, не являющиеся частью состояния программы (и которые, таким образом, не меняются при закрытии программы), но виртуальная память к ним не относится.

Мне кажется, что миф пришёл из времён, когда адресное пространство было тождественно физической памяти: его ошибочно перенесли на современные системы, не разбираясь, как они работают.

Статус мифа: (totally) busted.

P.S. Я не уверен, насколько удачным получился этот пост, ведь он написан достаточно нестандартно. Возможно, стоило добавить парочку взрывов. Шучу.
P.P.S. Интересно, что много мифов говорят про различные ограничения 32-х разрядных процессов и не существуют для 64-х разрядных приложений с их фантастическими 16-ю эксабайтами адресного пространства.

См. также:

  • Серия про ключ /3GB от Реймонда Чена
  • Серия «Преодолевая ограничения Windows» от Марка Руссиновича

Читать далее: Адресное пространство под микроскопом.

Понравилась статья? Поделить с друзьями:
0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
  • Как сменить номер com порта в windows
  • Как называется видеоредактор в windows 10
  • Asus b250 plus установка windows 7
  • Стабильная windows 10 без обновления
  • Как создать сразу несколько папок в windows 10