Когда операционная система зависает с синим экраном и кодом IRQL_NOT_LESS_OR_EQUAL, причина почти всегда прячется в одной из двух областей памяти, о которых обычный пользователь никогда не задумывается. Ядро Windows и драйверы устройств хранят свои внутренние структуры не в обычной оперативной памяти процессов, а в специальных зонах, которые менеджер памяти выделяет отдельно от всего остального. Эти зоны называются выгружаемым пулом (paged pool) и невыгружаемым пулом (nonpaged pool), и разница между ними определяет, какую именно информацию система готова временно сбросить на диск, а какую обязана держать в физической памяти при любых обстоятельствах.

Что вообще такое пул памяти и зачем ядру два отдельных типа

Менеджер памяти Windows создаёт оба пула ещё на этапе инициализации системы, задолго до запуска первого пользовательского процесса. Оба пула располагаются в области виртуального адресного пространства, зарезервированной под нужды системы, и отображаются в виртуальное пространство каждого процесса одинаково, независимо от того, какое приложение сейчас активно. Пул работает похожим образом на обычный менеджер кучи, который выделяет память пользовательским программам: минимальный размер выделения кратен размеру страницы (4 килобайта на платформах x86 и x64), а более мелкие запросы дробят крупные регионы на части, чтобы не тратить память впустую.

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

Почему часть данных ядра нельзя выгружать на диск ни при каких условиях

Ответ кроется в механике обработки прерываний и в понятии уровня запроса прерывания IRQL (Interrupt Request Level). Когда процессор работает на уровне DPC/dispatch или выше, обработка отложенных вызовов процедур (DPC) и подпрограмм обслуживания прерываний (ISR) не может позволить себе ждать. Если в этот момент процессору потребуется страница, физически отсутствующая в оперативной памяти, происходит page fault, а обработка page fault сама по себе требует, чтобы поток мог быть приостановлен и переключён, что запрещено на таких высоких уровнях IRQL. Получается замкнутый круг: страничная ошибка на уровне DPC/dispatch и выше система разрешить не способна в принципе, поэтому любой код и любые данные, к которым обращаются на этом уровне или выше, обязаны находиться в памяти, гарантированно присутствующей физически, то есть в невыгружаемом пуле.

Точно та же логика применяется к спин-блокировкам (spin lock), единственному типу блокировок, доступному внутри ISR и DPC. Пока поток удерживает спин-блокировку, он не имеет права допустить страничную ошибку, поэтому все структуры данных, защищённые спин-блокировкой и доступные из прерываний, тоже обязаны лежать в невыгружаемом пуле. Нарушение этого правила драйвером оборачивается одной из самых узнаваемых аварийных остановок Windows с кодом 0x0A, известной как IRQL_NOT_LESS_OR_EQUAL: система обнаруживает, что код попытался обратиться к странице, которой физически нет в памяти, работая на уровне, где page fault недопустим.

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

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

Выгружаемый пул предназначен для данных, чья временная недоступность в физической памяти не создаёт критической проблемы для корректной работы системы. Такие структуры драйверы и компоненты ядра обычно используют на уровнях IRQL ниже DPC/dispatch, то есть в контексте, где страничная ошибка разрешена и обрабатывается штатным образом: система приостанавливает поток, подгружает нужную страницу из файла подкачки, обновляет таблицу страниц и продолжает выполнение с той же точки. Классический пример содержимого выгружаемого пула это дескрипторы (handles) объектов ядра: их количество ограничено доступной памятью именно потому, что они хранятся в выгружаемом, а не невыгружаемом пуле, а значит система способна освобождать под них физическую память по мере необходимости, выгружая менее востребованные страницы.

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

Механизм выделения памяти и роль тегов пула

Компоненты ядра и драйверы запрашивают память из пула через функцию ExAllocatePoolWithTag, которой передаётся требуемый тип пула (PagedPool для выгружаемой памяти, NonPagedPool для невыгружаемой), размер блока в байтах и четырёхбуквенный тег. Тег служит идентификатором, по которому позже можно установить, какой именно компонент выделил конкретный блок памяти, что критически важно при диагностике утечек. Значение тега в дампе пула и в отладчике отображается в обратном порядке байт: если драйвер передал тег "Fred", в выводе он появится как "derF". Освобождение выделенной памяти выполняется функциями ExFreePool или ExFreePoolWithTag, а вызывать ExAllocatePoolWithTag разрешено только на уровне IRQL не выше DISPATCH_LEVEL, что логично согласуется с уже описанным правилом о недопустимости страничных ошибок на более высоких уровнях.

Начиная с Windows 8 добавился отдельный вид невыгружаемой памяти без права на исполнение инструкций, обозначаемый как NX nonpaged pool. Драйверам рекомендовано выделять большую часть невыгружаемой памяти именно из этого пула: поскольку подавляющему большинству структур данных драйвера исполнение кода не требуется, отметка страниц как непригодных для выполнения инструкций мешает вредоносному коду использовать такую память для запуска произвольных команд, даже если злоумышленник сумеет туда что то записать. В Windows 7 и более старых системах вся память невыгружаемого пула по умолчанию оставалась исполняемой, что создавало дополнительную поверхность атаки, закрытую только с переходом на NX-пул.

Как выглядит структура блока памяти внутри пула

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

Понимание этой внутренней структуры особенно значимо для специалистов по безопасности, изучающих технику kernel pool spraying, при которой атакующий пытается предсказуемо разместить контролируемые данные в памяти ядра рядом с уязвимым объектом, эксплуатируя предсказуемость поведения менеджера пула при массовых однотипных выделениях.

Диагностика утечек памяти пула через PoolMon и связанные инструменты

Когда объём выгружаемого или невыгружаемого пула на сервере растёт без остановки, это почти всегда указывает на утечку памяти в каком то драйвере, который выделяет блоки, но не освобождает их вовремя. Штатным средством диагностики служит утилита PoolMon (poolmon.exe), входящая в состав комплекта драйверов Windows (WDK). Она отображает данные, которые операционная система собирает о выделениях памяти из системных выгружаемого и невыгружаемого пулов ядра, а также о пулах, используемых сеансами служб терминалов, и группирует эту статистику по тегу выделения.

Прежде чем PoolMon сможет работать, в системе должно быть включено тегирование пула. Начиная с Windows Server 2003 эта функция включена постоянно и принудительно, а на более старых версиях её приходится активировать вручную через утилиту GFlags, установив флаг "Enable pool tagging" в реестре и перезагрузив систему. Без включённого тегирования PoolMon завершает работу с ошибкой "Сбой запроса тэгов пула c0000002" и отказывается показывать статистику.

Порядок действий при поиске утечки через PoolMon выглядит следующим образом:

  1. Запустить PoolMon и определить, какой именно тип пула проверять: для невыгружаемого пула однократно нажать клавишу P, для выгружаемого нажать P дважды подряд, а для одновременного отслеживания обоих типов оставить фильтр по умолчанию, нажав Enter;
  2. Дать системе поработать некоторое время под обычной или тестовой нагрузкой и периодически повторно запускать снимок состояния, отслеживая, какие теги пула стабильно растут в размере, а не колеблются вокруг постоянного значения;
  3. Сопоставить обнаруженный тег с конкретным компонентом или драйвером через справочный файл pooltag.txt, который поставляется вместе с отладочными инструментами Windows и содержит расшифровку самых распространённых тегов;
  4. При необходимости более глубокого анализа подключить GFlags для включения особого пула (special pool) под конкретный тег, что заставляет систему выделять для него отдельные страницы с защитными зонами по краям и мгновенно ловить выход за границы блока;
  5. Использовать WinDbg с командой anализа пула для просмотра детальной статистики по каждому тегу и для получения дампа памяти в момент падения системы, если утечка привела к исчерпанию пула и последующему краху.

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

Практические последствия исчерпания пула для стабильности сервера

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

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

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