автордың кітабын онлайн тегін оқу Работа с ядром Windows
Переводчик Е. Матвеев
Литературный редактор М. Петруненко
Корректоры Н. Викторова, М. Молчанова (Котова)
Павел Йосифович
Работа с ядром Windows. — СПб.: Питер, 2021.
ISBN 978-5-4461-1680-5
© ООО Издательство "Питер", 2021
Все права защищены. Никакая часть данной книги не может быть воспроизведена в какой бы то ни было форме без письменного разрешения владельцев авторских прав.
Глава 1. Обзор внутреннего устройства Windows
Здесь описываются важнейшие концепции внутреннего устройства Windows. Некоторые аспекты будут более подробно описаны позднее в книге, когда они будут тесно связаны с непосредственно рассматриваемой темой. Убедитесь в том, что вы хорошо понимаете концепции этой главы, так как они образуют основу для построения любых драйверов и даже низкоуровневого кода пользовательского режима.
В этой главе:
• Процессы
• Виртуальная память
• Программные потоки
• Системные сервисные функции
• Архитектура системы
• Дескрипторы и объекты
Процессы
Процесс (process) — управляющий объект, который обеспечивает изоляцию адресных пространств и представляет работающий экземпляр программы. Довольно часто встречающееся выражение «процесс выполняется» неточно. Процессы не выполняются — они управляют. Потоки (threads) выполняют код и выполняются с технической точки зрения. На высоком уровне абстракции процессу принадлежит:
• Исполняемая программа, которая содержит код и данные, используемые для выполнения кода в процессе.
• Приватное виртуальное адресное пространство, которое используется для выделения памяти для любых целей, когда память потребуется коду внутри процесса.
• Основной маркер (primary token) — объект для хранения стандартного контекста безопасности процесса. Маркер используется потоками, выполняющими код внутри процесса (если только поток не переключится на использование другого маркера при помощи механизма олицетворения (impersonation)).
• Приватная таблица дескрипторов для объектов исполнительной системы (таких, как события, семафоры и файлы).
• Один или несколько потоков исполнения. Нормальный процесс пользовательского режима создается с одним потоком (в котором выполняется классическая функция main/WinMain). Процесс пользовательского режима без потоков в основном бесполезен, и в обычных обстоятельствах он будет уничтожен ядром.
Компоненты процесса изображены на рис. 1.1.
Рис. 1.1. Важнейшие компоненты процесса
Процесс однозначно определяется своим идентификатором процесса, который остается уникальным на все время существования объекта процесса в ядре. После уничтожения тот же идентификатор может повторно использоваться для новых процессов. Важно понимать, что сам исполняемый файл не обеспечивает однозначной идентификации процесса. Например, в системе могут одновременно работать пять экземпляров notepad.exe. Каждый процесс имеет собственное адресное пространство, собственные потоки, собственную таблицу дескрипторов, собственный уникальный идентификатор процесса и т.д. Все пять процессов используют один и тот же файл образа (notepad.exe), в котором хранится их изначальный код и данные. На рис. 1.2 показан снимок экрана вкладки Details Диспетчера задач с пятью экземплярами Notepad.exe, каждый из которых обладает собственными атрибутами.
Рис. 1.2. Пять экземпляров notepad
Виртуальная память
Каждый процесс обладает собственным виртуальным приватным линейным адресным пространством. Это адресное пространство в исходном состоянии пусто (или почти пусто, потому что сначала в него отображается исполняемый образ и NtDll.Dll, а за ними следуют DLL-библиотеки других подсистем). Как только начинается выполнение основного (первого) потока, в адресном пространстве с большой вероятностью будет выделяться память, загружаться другие DLL-библиотеки и т.д. Это адресное пространство является приватным, то есть другие процессы не могут обращаться к нему напрямую. Диапазон адресов адресного пространства начинается с нуля (точнее, первые 64 Кбайт адресов не могут выделяться или использоваться иным образом) и следует до максимума, который зависит от разрядности (32 или 64 бита) процесса и разрядности операционной системы следующим образом:
• Для 32-разрядных процессов в 32-разрядных системах Windows размер адресного пространства процесса по умолчанию составляет 2 Гбайт.
• Для 32-разрядных процессов в 32-разрядных системах Windows, использующих параметр расширения пользовательского адресного пространства (флаг LARGEADDRESSAWARE в заголовке Portable Executable), размер адресного пространства процесса может достигать 3 Гбайт (в зависимости от конкретного значения параметра). Чтобы получить доступ к расширенному диапазону адресного пространства, исполняемый файл, на основе которого был создан процесс, должен быть помечен флагом компоновщика LARGEADDRESSAWARE в заголовке. При отсутствии такого флага адресное пространство будет ограничено 2 Гбайт.
• Для 64-разрядных процессов (естественно, в 64-разрядных системах Windows) размер адресного пространства процесса составляет 8 Тбайт (Windows 8 и ранее) или 128 Тбайт (Windows 8.1 и далее).
• Для 32-разрядных процессов в 64-разрядных системах Windows размер адресного пространства составляет 4 Гбайт, если исполняемый файл был скомпонован с флагом LARGEADDRESSAWARE. В противном случае размер остается равным 2 Гбайт.
Требование флага LARGEADDRESSAWARE обусловлено тем фактом, что 2-гигабайтное адресное пространство требует только 31 разряда, а старший бит (MSB, Most Significant Bit) остается свободным и может использоваться приложением. Установка флага означает, что программа не использует разряд 311 ни для каких целей, поэтому присваивание 1 этому разряду (что происходит с адресами выше 2 Гбайт) не создаст проблем.
Каждый процесс имеет собственное адресное пространство, из-за чего все адреса в процессе относительны, а не абсолютны. Например, если вы пытаетесь определить, какие данные хранятся по адресу 0x20000, одного адреса недостаточно; необходимо указать, к какому процессу относится этот адрес.
Сама память называется виртуальной; это означает, что между диапазоном адресов и его точным расположением в физической памяти (ОЗУ) существует косвенная связь. Буфер в процессе может быть отображен в физическую память, а может временно храниться в файле (например, в страничном файле). Термин «виртуальный» относится к тому факту, что с точки зрения исполнения нет необходимости знать, хранятся ли данные, к которым вы обращаетесь, в оперативной памяти или нет. Если память процесса действительно отображена в оперативную память, то процессор обратится к данным напрямую. В противном случае процессор инициирует исключение ошибки страницы (page fault), что заставляет обработчика ошибок страниц диспетчера памяти загрузить данные из соответствующего файла, скопировать их в оперативную память, внести необходимые изменения в элементы таблицы страниц, обеспечивающие отображение буфера, и приказать процессору повторить попытку. На рис. 1.3 показано отображение виртуальной памяти в физическую для двух процессов.
Рис. 1.3. Отображение виртуальной памяти
Единица управления памятью называется страницей (page). Каждый атрибут, относящийся к памяти (например, защита), всегда имеет страничную гранулярность. Размер страницы определяется типом процессора (и на некоторых процессорах может настраиваться); в любом случае диспетчер памяти должен использовать именно этот размер. Нормальный размер страницы (иногда называемый малым) — 4 Кбайт во всех архитектурах, поддерживаемых Windows.
Помимо нормального (малого) размера страницы, в системах Windows также поддерживаются большие страницы. Размер большой страницы равен 2 Мбайт (x86/x64/ARM64) и 4 Мбайт (ARM). Для отображения большой страницы без использования таблицы страниц используется элемент PDE (Page Directory Entry). Этот механизм ускоряет преобразование, но что еще важнее, он позволяет более эффективно использовать TLB (Translation Lookaside Buffer) — кэш недавно преобразованных страниц, содержимое которого поддерживается процессором. В случае большой страницы один элемент TLB позволяет отображать объем памяти, значительно превышающий размер малой страницы.
Недостаток больших страниц — необходимость наличия непрерывного участка физической памяти. Такого участка может не быть, если памяти в системе недостаточно или она сильно фрагментирована. Кроме того, большие страницы всегда невыгружаемые (non-pageable), и они должны быть защищены доступом только для чтения/записи. В Windows 10 и Server 2016 поддерживаются огромные страницы с размером 1 Гбайт. Они используются автоматически с большими страницами, если размер выделяемого блока превышает 1 Гбайт, а страница может быть выделена в непрерывном диапазоне физической памяти.
Состояние страниц
Каждая страница в виртуальной памяти может находиться в одном из трех состояний:
• Свободная (free) — память никак не используется; в ней нет ничего полезного. Любая попытка обращения к свободной странице приведет к исключению нарушения прав доступа. Большинство страниц только что созданного процесса свободно.
• Закрепленная (committed) — состояние, обратное свободному; к закрепленной странице возможно успешное обращение (без учета атрибутов защиты — например, попытка записи в страницу, доступную только для чтения, приведет к нарушению прав доступа). Закрепленные страницы обычно отображаются в физическую память или в файл (например, в страничный файл).
• Зарезервированная (reserved) — страница не закреплена, но диапазон адресов зарезервирован для возможного выделения в будущем. С точки зрения процессора зарезервированная страница не отличается от свободной — при любой попытке обращения происходит исключение нарушения прав доступа. Тем не менее новые попытки выделения памяти функцией VirtualAlloc (или NtAllocateVirtualMemory, соответствующей платформенной функцией) без указания конкретного адреса не приведут к выделению памяти в зарезервированном блоке. Классический пример использования зарезервированной памяти для поддержания непрерывного виртуального адресного пространства с экономией памяти описан позднее в этой главе, в разделе «Стеки потоков».
Системная память
Нижняя часть адресного пространства предназначена для использования процессами. Пока некоторый поток выполняется, связанное с ним адресное пространство процесса становится видимым от нулевого адреса до верхнего предела, описанного в предыдущем разделе. Однако операционная система тоже должна где-то находиться, а именно в верхнем диапазоне адресов, поддерживаемом системой:
• В 32-разрядных системах, работающих без параметра расширения пользовательского виртуального адресного пространства, операционная система размещается в верхних 2 гигабайтах виртуального адресного пространства, в диапазоне адресов от 0x8000000 до 0xFFFFFFFF.
• В 32-разрядных системах, настроенных в режиме расширения пользовательского виртуального адресного пространства, операционная система размещается в оставшемся адресном пространстве. Например, если система настроена с 3 Гбайт пользовательского адресного пространства на процесс (максимум), то ОС занимает верхний 1 Гбайт (в диапазоне адресов от 0xC0000000 до 0xFFFFFFFF). От сокращения адресного пространства в наибольшей степени страдает кэш файловой системы.
• В 64-разрядных системах Windows 8, Server 2012 и ранее ОС занимает верхние 8 Тбайт виртуального адресного пространства.
• В 64-разрядных системах Windows 8.1, Server 2012 R2 и далее ОС занимает верхние 128 Тбайт виртуального адресного пространства.
Системное пространство не является относительным по отношению к процессам — в конце концов, все процессы в системе используют одну и ту же «систему», одно ядро и одни драйверы (исключение — часть системной памяти, существующая на уровне сеанса, но для данного обсуждения это несущественно). Отсюда следует, что любой адрес в системном пространстве является абсолютным, а не относительным, потому что он «выглядит» одинаково в контексте каждого процесса. Конечно, попытки обращения из пользовательского режима в системное пространство приводят к исключению нарушения прав доступа.
В системном пространстве находится само ядро, уровень абстрагирования оборудования (HAL, Hardware Abstraction Layer) и загруженные драйверы ядра. Таким образом, драйверы ядра автоматически защищены от прямых обращений из пользовательского режима. Также это означает, что их влияние распространяется на всю систему. Например, если в драйвере ядра происходит утечка памяти, эта память не будет освобождена даже после выгрузки драйверов. С другой стороны, любая утечка в процессах пользовательского режима никогда не может продолжаться за пределами их жизненного срока. Ядро отвечает за закрытие и освобождение всех ресурсов, приватных для уничтожаемого процесса (все дескрипторы закрываются, а вся приватная память освобождается).
Потоки
Фактическое выполнение кода осуществляется потоками (threads). Поток содержится в процессе и использует ресурсы, предоставляемые процессом (например, виртуальную память и дескрипторы объектов ядра), для выполнения работы.
Самая важная информация, принадлежащая потоку:
• Текущий режим доступа (пользовательский режим или режим ядра).
• Контекст выполнения, включающий значения регистров процессора и состояние выполнения.
• Один или два стека, используемые для выделения памяти локальных переменных и управления вызовами.
• Массив локальной памяти потоков (TLS, Thread Local Storage), предоставляющий средства для хранения приватных данных потока с унифицированной семантикой доступа.
• Базовый приоритет и текущий (динамический) приоритет.
• Привязка к процессору, указывающая, на каких процессорах разрешено выполнение потока.
Наиболее распространенные состояния, в которых может находиться поток:
• Выполнение (Running) — поток выполняет код на (логическом) процессоре.
• Готовность (Ready) — поток ожидает планирования на выполнение, потому что все нужные процессоры либо заняты, либо недоступны.
• Ожидание (Waiting) — поток ожидает наступления некоторого события, прежде чем продолжить выполнение. После того как событие произойдет, поток переходит в состояние готовности.
На рис. 1.4 изображена соответствующая диаграмма состояний. Числа в круглых скобках обозначают номера состояний при просмотре в таких программах, как Performance Monitor. Обратите внимание: у состояния готовности (Ready) имеется парное состояние отложенной готовности (Deferred Ready), сходное с ним и существующее в основном для минимизации внутренних блокировок.
Рис. 1.4. Основные состояния потоков
Стеки потоков
У каждого потока имеется стек, используемый им при выполнении. Стек используется для создания локальных переменных, передачи параметров функциям (в некоторых случаях) и хранения адресов возврата при вызове функций. У потока имеется как минимум один стек, находящийся в системном пространстве (пространстве ядра); он относительно мал (по умолчанию 12 Кбайт в 32-разрядных системах и 24 Кбайт в 64-разрядных системах). У потоков пользовательского режима существует второй стек в диапазоне адресов пользовательского режима соответствующего процесса, этот стек имеет намного больший размер (по умолчанию он может увеличиваться до 1 Мбайт). На рис. 1.5 изображен пример с тремя потоками пользовательского режима и их стеками. На рисунке потоки 1 и 2 принадлежат процессу А, а поток 3 — процессу Б.
Стек ядра всегда находится в физической памяти, пока поток находится в состоянии выполнения или готовности. Причина будет рассмотрена позднее в этой главе. С другой стороны, стек пользовательского режима может выгружаться, как и все содержимое памяти пользовательского режима.
Поведение стека пользовательского режима отличается от стека режима ядра из-за своего размера. Он начинается с закрепления небольшого объема памяти (вплоть до одной страницы), при этом остаток адресного пространства стека составляет зарезервированная память (которая не может выделяться ни для каких целей). Идея состоит в том, чтобы иметь возможность расширять стек в том случае, если коду потока потребуется использовать больший объем памяти стека. Для этого следующая страница (иногда несколько страниц) непосредственно после закрепленной части помечается специальным защитным атрибутом PAGE_GUARD — признаком сторожевой страницы. Если потоку понадобится больше памяти, он выполняет запись в сторожевую страницу; возникает исключение, которое обрабатывается диспетчером памяти. Затем диспетчер памяти снимает атрибут сторожевой страницы, закрепляет страницу и помечает следующую страницу как сторожевую. Таким образом, стек растет по мере надобности, а вся память стека не закрепляется заранее. На рис. 1.6 изображен примерный вид стека потока пользовательского режима.
Рис. 1.5. Потоки пользовательского режима и их стеки
Размер стека пользовательского режима для потока определяется следующим образом:
• Величины закрепленной и зарезервированной части стека хранятся в заголовке PE (Portable Executable) исполняемого файла. Они используются по умолчанию, если поток не укажет альтернативные значения.
• При создании потока функцией CreateThread (или аналогичной функцией) вызывающая сторона может указать требуемый размер стека — либо размер изначально закрепленной части, либо размер зарезервированной части (но не оба сразу) в зависимости от флага, переданного функции; при передаче нуля используются значения по умолчанию из предыдущего пункта.
Рис. 1.6. Стек потока в пользовательском пространстве
Интересно, что функции CreateThread и CreateRemoteThread(Ex) позволяют задать только одно значение для размера стека и состояния памяти (закрепленная или зарезервированная). Платформенная (недокументированная) функция NtCreateThreadEx позволяет задать оба значения.
Системные сервисные функции
Приложениям требуется выполнять различные операции, которые не являются чисто вычислительными: выделение памяти, открытие файлов, создание потоков и т.д. Эти операции могут выполняться только кодом, работающим в режиме ядра. Как же выполнять такие операции в коде пользовательского режима? Возьмем классический пример: пользователь, запустивший процесс Notepad, выполняет запрос на открытие файла командой меню File. Код Notepad реагирует вызовом документированной функции Windows API CreateFile. Функция CreateFile документирована в соответствии с реализацией из kernel32.dll — одной из DLL-библиотек подсистем Windows. Эта функция также работает в пользовательском режиме, поэтому она ни при каких условиях не сможет напрямую открыть файл. После проверки ошибок она вызывает функцию NtCreateFile. Реализация этой функции содержится в NTDLL.dll — фундаментальной DLL-библиотеке, реализующей так называемый платформенный API; по сути, это код самого низкого уровня, который все еще работает в пользовательском режиме. Эта (официально недокументированная) функция API осуществляет переход в режим ядра. Непосредственно перед переходом она помещает число, называемое номером системной сервисной функции, в регистр процессора (EAX в архитектурах Intel/AMD). Затем выполняется специальная команда процессора (syscall для x64, sysenter для x86), которая осуществляет фактический переход в режим ядра с переходом в заранее определенную функцию, называемую диспетчером системных функций.
В свою очередь, диспетчер системных функций использует значение из регистра EAX как индекс в таблице SSDT (System Service Dispatch Table). По адресу, содержащемуся в таблице, код осуществляет переход непосредственно к системной функции. Для нашего примера с Notepad элемент SSDT содержит указатель на функцию NtCreateFile диспетчера ввода/вывода. Обратите внимание: имя этой функции совпадает с именем функции из NTDLL.dll; более того, она получает те же аргументы. После того как вызов системной функции будет завершен, поток возвращается в пользовательский режим для выполнения команды, следующей за sysenter/syscall. Эта последовательность событий изображена на рис. 1.7.
Рис. 1.7. Последовательность действий при вызове системных сервисных функций
Общая архитектура системы
На рис. 1.8 изображена общая архитектура Windows, состоящая из компонентов пользовательского режима и режима ядра.
Рис. 1.8. Архитектура системы Windows
Краткая сводка блоков на рис. 1.8:
• Пользовательские процессы. Обычные процессы, созданные на базе файлов образов и выполняемые в системе (например, экземпляры Notepad.exe, cmd.exe, explorer.exe и т.д.).
• DLL-библиотеки подсистем. DLL-библиотеки подсистем представляют собой библиотеки динамической компоновки (DLL, Dynamic Link Libraries), реализующие API подсистем. Подсистема является неким представлением функциональности, предоставляемым ядром. С технической точки зрения, начиная с Windows 8.1 существует только одна подсистема — подсистема Windows. К числу DLL-библиотек подсистем принадлежат такие известные файлы, как kernel32.dll, user32.dll, gdi32.dll, advapi32.dll, combase.dll и др. В основном DLL-библиотеки содержат официально документированный Windows API.
• NTDLL.DLL. DLL-библиотека системного уровня, реализующая платформенный Windows API. Она содержит код самого низкого уровня, выполняемый в пользовательском режиме. Самая важная ее роль — переход в режим ядра для вызова системных функций. NTDLL также реализует диспетчер кучи (Heap Manager), загрузчик образов (Image Loader) и некоторые части пула потоков пользовательского режима.
• Процессы служб. Процессы служб — нормальные процессы Windows, которые взаимодействуют с диспетчером служб (SCM, Service Control Manager — реализуется в services.exe) и позволяют до определенной степени управлять своим сроком жизни. Диспетчер служб может запускать, останавливать, приостанавливать, возобновлять работу служб и отправлять службам другие сообщения. Службы обычно выполняются под одной из специальных учетных записей Windows — локальной системы, сетевых или локальных служб.
• Исполнительная система. Исполнительная система является верхним уровнем NtOskrnl.exe («ядра»). В ней содержится большая часть кода, работающего в режиме ядра. Прежде всего это различные диспетчеры: диспетчер объектов, диспетчер памяти, диспетчер ввода/вывода, диспетчер Plug & Play, диспетчер электропитания, диспетчер конфигурации и т.д. По своим размерам она значительно больше нижнего уровня ядра.
• Ядро. Уровень ядра реализует самые фундаментальные и критичные по времени части кода ОС режима ядра. К их числу относятся планирование потоков, обработка прерываний и диспетчеризация исключений, а также реализация различных примитивов ядра, таких как мьютексы и семафоры. Часть кода ядра написана на машинном языке конкретного процессора для эффективности и для получения прямого доступа к специфическим возможностям процессора.
• Драйверы устройств. Драйверы устройств представляют собой загружаемые модули ядра. Их код выполняется в режиме ядра и может распоряжаться всей мощью ядра. Книга посвящена написанию некоторых разновидностей драйверов ядра.
• Win32k.sys. Компонент режима ядра подсистемы Windows. По сути, это модуль ядра (драйвер), который обеспечивает часть Windows API и классического интерфейса графических устройств GDI (Graphic Device Interface), относящуюся к пользовательскому интерфейсу. Это означает, что все операции оконной системы (CreateWindowEx, GetMessage, PostMessage и т.д.) обеспечиваются этим компонентом. Остальные компоненты системы практически ничего не знают о пользовательском интерфейсе.
• Уровень абстрагирования оборудования (HAL). Уровень HAL располагается над оборудованием в максимальной близости к процессору. Он позволяет драйверам устройств использовать API, не требующие досконального и точного знания всех подробностей, например контроллер прерываний или контроллер DMA. Естественно, этот уровень в основном представляет интерес для драйверов, написанных для управления физическими устройствами.
В Windows 10 версии 1607 появилась поддержка подсистемы Windows для Linux (WSL, Windows Subsystem for Linux). И хотя на первый взгляд это всего лишь очередная подсистема вроде старых подсистем POSIX и OS/2, поддерживаемых Windows, на самом деле это совсем не так. Старые подсистемы могли выполнять приложения POSIX и OS/2, если они были откомпилированы компилятором для Windows. С другой стороны, в WSL такое требование отсутствует. Существующие исполняемые файлы для Linux (в формате ELF) могут запускаться в Windows без перекомпиляции.
Чтобы такая схема работала, был создан новый тип процессов — процесс Pico в сочетании с провайдером Pico. Вкратце процесс Pico представляет собой пустое адресное пространство (минимальный процесс), используемое для процессов WSL, где каждый системный вызов (вызов системной функции Linux) должен быть перехвачен и преобразован в эквивалентный вызов(-ы) системной функции Windows при помощи провайдера Pico. Таким образом, на Windows-машине фактически устанавливается полноценная система Linux (часть пользовательского режима).
• Системные процессы. Общим термином «системные процессы» обозначаются процессы, которые обычно просто «находятся на своем месте» и делают то, что положено; обычно эти процессы не предназначены для прямого взаимодействия. Тем не менее они важны, а некоторые даже необходимы для благополучного существования системы. Завершение некоторых из этих процессов приводит к фатальным последствиям вплоть до полного сбоя системы. Некоторые из системных процессов относятся к платформенным; это означает, что они используют только платформенный API (API, реализуемый NTDLL). Примеры системных процессов — Smss.exe, Lsass.exe, Winlogon.exe, Services.exe и др.
• Процесс подсистемы. Процесс подсистемы Windows, в котором выполняется образ Csrss.exe, может рассматриваться как помощник ядра для управления процессами, работающими в системе Windows. Этот процесс является критическим, то есть в случае его уничтожения происходит полный сбой системы. Обычно создается только один экземпляр Csrss.exe для каждого сеанса, поэтому в стандартной системе существуют два экземпляра — для сеанса 0 и для сеанса текущего пользователя (обычно 1). Хотя Csrss.exe является «диспетчером» подсистемы Windows (единственным из оставшихся в наши дни), его важность выходит далеко за рамки этой роли.
• Гипервизор Hyper-V. Гипервизор Hyper-V существует в Windows 10 и Server 2016 (и последующих системах), если они поддерживают механизм VBS (Virtualization Based Security). VBS предоставляет дополнительный уровень безопасности, где реальная машина в действительности представлена виртуальной машиной, находящейся под управлением Hyper-V. Рассмотрение VBS выходит за рамки книги. За дополнительной информацией обращайтесь к книге «Внутреннее устройство Windows»2.
Дескрипторы и объекты
Ядро Windows предоставляет различные типы объектов, которые могут использоваться процессами пользовательского режима, самим ядром и драйверами режима ядра. Экземпляры этих типов представляют собой структуры данных в системном пространстве, создаваемые диспетчером объектов (часть исполнительной системы) по требованию кода пользовательского режима или режима ядра. Для объектов ведется подсчет ссылок — только после освобождения последней ссылки объект будет уничтожен и удален из памяти.
Так как экземпляры объектов находятся в системном пространстве, код пользовательского режима не может обращаться к ним напрямую. Он должен использовать механизм косвенного обращения — так называемые дескрипторы (handles). Дескриптор представляет собой индекс в таблице, хранимой на уровне отдельных процессов, которая содержит логические указатели на объект ядра, находящийся в системном пространстве. Существуют различные функции Create* и Open* для создания/открытия объектов и получения дескрипторов этих объектов. Например, функция пользовательского режима CreateMutex позволяет создать или открыть мьютекс (в зависимости от того, задано ли имя объекта и существует ли он). В случае успеха функция возвращает дескриптор этого объекта. Возвращаемое значение 0 является признаком недействительного дескриптора (и неудачного вызова функции). С другой стороны, функция OpenMutex пытается открыть дескриптор для именованного мьютекса. Если объект с заданным именем не существует, вызов функции завершается неудачей, и функция возвращает null (0).
Код режима ядра (и драйверов) может использовать как дескриптор, так и прямой указатель на объект. Выбор обычно зависит от функции API, которую код хочет вызвать. В некоторых случаях дескриптор, передаваемый драйверу из пользовательского режима, должен быть преобразован в указатель функцией ObReferenceObjectByHandle. Эти подробности будут рассмотрены в одной из следующих глав.
Значения дескрипторов кратны 4, при этом первый допустимый дескриптор равен 4; нулевое значение дескриптора ни при каких условиях действительным быть не может.
Многие функции возвращают null(0) при неудаче, но есть и исключения. В первую очередь функция CreateFile в случае неудачи возвращает INVALID_HANDLE_VALUE(-1).
Код режима ядра может использовать дескрипторы при создании/открытии объектов, но он также может использовать прямые указатели на объекты ядра. Обычно это делается в тех случаях, когда того требует определенная функция API. Код ядра может получить указатель на объект по действительному дескриптору при помощи функции ObReferenceObjectByHandle. В случае успеха счетчик ссылок объекта увеличивается; это делается для предотвращения риска того, что клиент пользовательского режима, удерживающий дескриптор, решит закрыть его. В этом случае указатель на объект, хранящийся у кода ядра, будет указывать на несуществующий объект (так называемый висячий указатель). К объекту можно безопасно обращаться независимо от того, где именно он удерживается, пока код ядра не вызовет функцию ObDerefenceObject, которая уменьшает счетчик ссылок. Если код ядра пропустит этот вызов, в системе возникнет утечка ресурсов, которая будет устранена только при следующей загрузке системы.
Для всех объектов ведутся счетчики ссылок. Диспетчер объектов хранит для объектов количество дескрипторов и общий счетчик ссылок. После того, как объект станет ненужным, его клиент должен закрыть дескриптор (если он использовался для обращения к объекту) или разыменовать объект (если клиент режима ядра использовал указатель). В дальнейшем код должен считать свой дескриптор/указатель недействительным. Диспетчер объектов уничтожает объект в том случае, если его счетчик ссылок упал до нуля.
Каждый объект содержит указатель на тип объекта, в котором хранится информация о самом типе; это означает, что для каждой разновидности объектов существует один объект типа. Они также предоставляются в форме экспортируемых глобальных переменных ядра; некоторые из них определяются в заголовках ядра и могут пригодиться в определенных ситуациях, как будет показано в следующих главах.
Имена объектов
Некоторые разновидности объектов могут обладать именами. Зная имя объекта, вы можете открыть объект по имени соответствующей функцией Open. Обратите внимание: не все объекты обладают именами; например, у процессов и потоков имен нет — есть только идентификаторы. Именно по этой причине функции OpenProcess и OpenThread должен передаваться идентификатор процесса/потока (числа) вместо строкового имени. Другой довольно странный пример объекта, не обладающего именем, — файл. Имя файла не является именем объекта — это совершенно разные концепции.
В коде пользовательского режима вызов функции Create с передачей имени создает объект с этим именем, если такой объект не существует, но если объект существует, то функция просто открывает существующий объект. В последнем случае вызов GetLastError вернет значение ERROR_ALREADY_EXISTS, которое означает, что новый объект не создается, а функция возвращает просто еще один дескриптор для существующего объекта.
Имя, передаваемое функции Create, на самом деле не является окончательным именем объекта. К нему присоединяется префикс \Sessions\x\BaseNamedObjects\, где x — идентификатор сеанса вызывающей стороны. Если идентификатор сеанса равен 0, то к имени присоединяется префикс \BaseNamedObjects\. Если же вызывающая сторона работает в контейнере AppContainer (обычно это процесс Universal Windows Platform), то присоединяемая строка становится более сложной и содержит уникальный идентификатор SID контейнера \Sessions\x\AppContainerNamedObjects\{AppContainerSID}.
Из всего сказанного следует, что имена объектов относительны по отношению к сеансу (а в случае AppContainer — относительны по отношению к пакету). Если объект должен совместно использоваться разными сеансами, он может быть создан в сеансе 0, для чего к имени объекта присоединяется Global\; например, при создании функцией CreateMutex мьютекса с именем Global\MyMutex объект будет создан в иерархии\BaseNamedObjects. Следует заметить, что контейнеры AppContainer не обладают полномочиями для использования пространства имен объектов сеанса 0. Для просмотра этой иерархии можно воспользоваться программой WinObj из пакета Sysinternals (должна запускаться с повышенными привилегиями), как показано на рис. 1.9.
В окне на рис. 1.9 показано пространство имен диспетчера объекта, которое образует иерархию именованных объектов. Эта структура хранится в памяти, а для манипуляций с ней по мере надобности используется диспетчер объектов (часть исполнительной среды). Область видимости: безымянные объекты не входят в эту структуру; это означает, что в WinObj отображаются не все существующие объекты, а только объекты, созданные с указанием имени.
Каждый процесс содержит приватную таблицу дескрипторов объектов ядра (как именованных, так и безымянных). Для просмотра таблицы можно воспользоваться программами Process Explorer и/или Handles из пакета Sysinternals. На рис. 1.10 показан снимок экрана Process Explorer с дескрипторами некоторого процесса. По умолчанию в окне для каждого дескриптора выводится только тип объекта и имя. При этом также доступны другие столбцы, показанные на рис. 1.10.
Рис. 1.9. Программа WinObj из пакета Sysinternals
Рис. 1.10. Просмотр дескрипторов процессов в программе Process Explorer
По умолчанию Process Explorer отображает только дескрипторы для объектов с именами (в соответствии с определением имен в Process Explorer — см. далее). Чтобы просмотреть все дескрипторы в процессе, выберите команду ShowUnnamedHandlesandMappings в меню View программы Process Explorer.
Различные столбцы в режиме вывода дескрипторов предоставляют дополнительную информацию о каждом дескрипторе. Значение дескриптора и тип объекта не нуждаются в пояснениях. С именем столбца дело обстоит сложнее. В нем приводятся истинные имена объектов для мьютексов (Mutant), семафоров (Semaphore), событий (Event), секций (Section), портов ALPC (ALPCPort), заданий (Job), таймеров (Timer) и других, не столь часто используемых типов. Для других объектов выводится имя, смысл которого отличается от истинного:
• Для объектов процессов (Process) и потоков (Thread) выводится уникальный идентификатор.
• Для объектов файлов (File) выводится имя файла (или имя устройства), на который указывает объект. Это имя не совпадает с именем объекта, так как по имени файла невозможно получить дескриптор для объекта файла — можно только создать новый объект файла, который соответствует тому же файлу или устройству (при условии, что это позволяют сделать настройки совместного доступа для исходного объекта файла).
• Для имен объектов разделов реестра (Key) выводится путь к разделу реестра. Имя раздела не может использоваться по тем же причинам, что и для объектов файлов.
• Для объектов каталогов (Directory) выводится путь вместо истинного имени объекта. Каталог не является объектом файловой системы, это каталоги диспетчера объектов — для их просмотра удобно использовать программу WinObj из пакета Sysinternals.
• Для имен объектов маркеров (Token) выводится имя пользователя, хранящееся в маркере.
Обращение к существующим объектам
В столбце Access в окне режима дескрипторов программы Process Explorer выводится маска доступа, использованная для создания или открытия дескриптора. Маска доступа определяет, какие операции могут выполняться с конкретным дескриптором. Например, если код клиента хочет завершить процесс, он должен сначала вызвать функцию OpenProcess, чтобы получить дескриптор нужного процесса с маской доступа (как минимум) PROCESS_TERMINATE; в противном случае завершить процесс с этим дескриптором не удастся. Если вызов завершится успехом, то вызов TerminateProcess тоже должен быть успешным. Пример кода пользовательского режима для завершения процесса по идентификатору процесса:
bool KillProcess(DWORD pid) {
// Открыть для процесса дескриптор с достаточным уровнем
HANDLE hProcess = OpenProcess(PROCESS_TERMINATE, FALSE, pid);
if (!hProcess)
return false;
// Уничтожить с произвольным кодом завершения
BOOL success = TerminateProcess(hProcess, 1);
// Закрыть дескриптор
CloseHandle(hProcess);
return success != FALSE;
}
В столбце DecodedAccess приводится текстовое описание маски доступа (для некоторых типов объектов), по которому проще определить уровень доступа, разрешенный для конкретного дескриптора.
Если сделать двойной щелчок на элементе дескриптора, программа выводит некоторые свойства объекта. На рис. 1.11 показан снимок экрана со свойствами объекта события.
Рис. 1.11. Свойства объекта в Process Explorer
Свойства на рис. 1.11 включают имя объекта (если есть), его тип, описание, адрес в памяти ядра, количество открытых дескрипторов и информацию об объекте, например состояние и тип для объекта события. Учтите, что в поле References не выводится фактическое количество ссылок на объект. Для просмотра реального значения счетчика ссылок объекта используется команда !trueref отладчика ядра:
lkd> !object 0xFFFFA08F948AC0B0
Object: ffffa08f948ac0b0 Type: (ffffa08f684df140) Event
ObjectHeader: ffffa08f948ac080 (new version)
HandleCount: 2 PointerCount: 65535
Directory Object: ffff90839b63a700 Name: ShellDesktopSwitchEvent
lkd> !trueref ffffa08f948ac0b0
ffffa08f948ac0b0: HandleCount: 2 PointerCount: 65535 RealPointerCount: 3
Атрибуты объектов и отладчик ядра более подробно рассматриваются в следующих главах.
А теперь займемся написанием очень простого драйвера для демонстрации многих инструментов, которые понадобятся нам позднее.
• Гипервизор Hyper-V. Гипервизор Hyper-V существует в Windows 10 и Server 2016 (и последующих системах), если они поддерживают механизм VBS (Virtualization Based Security). VBS предоставляет дополнительный уровень безопасности, где реальная машина в действительности представлена виртуальной машиной, находящейся под управлением Hyper-V. Рассмотрение VBS выходит за рамки книги. За дополнительной информацией обращайтесь к книге «Внутреннее устройство Windows»2.
Требование флага LARGEADDRESSAWARE обусловлено тем фактом, что 2-гигабайтное адресное пространство требует только 31 разряда, а старший бит (MSB, Most Significant Bit) остается свободным и может использоваться приложением. Установка флага означает, что программа не использует разряд 311 ни для каких целей, поэтому присваивание 1 этому разряду (что происходит с адресами выше 2 Гбайт) не создаст проблем.
Имеется в виду, что это не 31-й разряд, а 32-й (нумерация начинается с 0-го). — Примеч. пер.
Руссинович М., Соломон Д., Ионеску А., Йосифович П. Внутреннее устройство Windows. 7-е изд. — СПб.: Питер, 2019. — 944 с.: ил.
Глава 2. Первые шаги в программировании для режима ядра
В этой главе рассматриваются основы, необходимые для того, чтобы приступить к разработке драйвера режима ядра. В этой главе мы установим необходимые инструменты и напишем очень простой драйвер, который можно будет загружать и выгружать.
В этой главе:
• Установка инструментов
• Создание проекта драйвера
• Функция DriverEntry и функция выгрузки
• Развертывание драйвера
• Простая трассировка
Установка инструментов
В прежние времена (до 2012 года) в процессе разработки и построения драйверов приходилось применять специальные средства построения из пакета DDK (Device Driver Kit), без удобных интегрированных сред, к которым привыкли разработчики при написании приложений пользовательского режима. Существовали некоторые обходные решения, но все они были не идеальны и не поддерживались официально. К счастью, начиная с Visual Studio 2012 и Windows Driver Kit 8, компания Microsoft официально поддерживает построение драйверов в Visual Studio (и msbuild) без необходимости использовать отдельный компилятор и средства сборки.
Чтобы приступить к разработке драйвера, необходимо установить следующие инструменты (в указанном порядке):
• Visual Studio 2017 или 2019 с новейшими обновлениями. Проследите за тем, чтобы во время установки была выбрана поддержка C++. На момент написания книги среда Visual Studio 2019 была только что выпущена и могла использоваться для разработки драйверов. Вам подойдет любое издание, включая бесплатное издание Community edition.
• Windows 10 SDK (обычно новейшая версия является самой лучшей). Проследите за тем, чтобы во время установки был как минимум выбран пункт DebuggingToolsforWindows.
• Windows 10 Driver Kit (WDK). Новейшая версия должна вам подойти, но также проследите за тем, чтобы в конце стандартной установки были установлены шаблоны проектов для Visual Studio.
• Пакет Sysinternals, оказывающий неоценимую помощь при любой работе с «внутренностями» системы, можно бесплатно загрузить по адресу http://www.sysinternals.com. Щелкните на ссылке SysinternalsSuite в левой части веб-страницы и загрузите Sysinternals в виде zip-архива. Распакуйте архив в любую папку; инструменты готовы к работе.
Чтобы убедиться в том, что шаблоны WDK были установлены правильно, откройте Visual Studio, выберите вариант New Project и проверьте наличие проектов драйверов — например, «Empty WDM Driver».
Создание проекта драйвера
После установки всех перечисленных средств можно создать новый проект драйвера. В этом разделе вам понадобится шаблон пустого драйвера WDM (WDMEmptyDriver). На рис. 2.1 показано, как выглядит диалоговое окно NewProject для этого типа драйверов в Visual Studio 2017. На рис. 2.2 изображена та же программа-мастер в Visual Studio 2019. На обеих иллюстрациях проекту присвоено имя «Sample».
После того как проект будет создан, на панели Solution Explorer находится всего один файл — Sample.inf. В данном примере этот файл не нужен, просто удалите его.
В проект нужно добавить исходный файл. Щелкните правой кнопкой мыши на узле SourceFiles на панели Solution Explorer и выберите команду Add/NewItem… из меню File. Выберите исходный файл C++ и присвойте ему имя Sample.cpp. Щелкните на кнопке OK, чтобы создать файл.
Рис. 2.1. Новый проект драйвера WDM в Visual Studio 2017
Рис. 2.2. Новый проект драйвера WDM в Visual Studio 2019
Функция DriverEntry и функция выгрузки
Каждый драйвер содержит точку входа, которой по умолчанию присваивается имя DriverEntry. Это своего рода аналог классической функции main приложений пользовательского режима. Эта функция вызывается системным потоком на уровне IRQLPASSIVE_LEVEL (0). (IRQL подробно рассматриваются в главе 8).
Функция DriverEntry имеет заранее определенный прототип:
NTSTATUS
DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath);
Аннотации _In_ являются частью языка аннотаций исходного кода SAL (Source (Code) Annotation Language). Эти аннотации, прозрачные для компилятора, предоставляют метаданные для читателя-человека и средств статического анализа кода. Мы будем использовать их по мере возможности, чтобы сделать код более понятным.
Минимальная функция DriverEntry может просто вернуть успешный код статуса:
NTSTATUS
DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) {
return STATUS_SUCCESS;
}
Такой код компилироваться не будет. Сначала необходимо включить заголовочный файл с необходимыми определениями для типов, встречающихся в DriverEntry. Один из возможных вариантов выглядит так:
#include <ntddk.h>
Шансы на успешную компиляцию кода повышаются, но попытка все равно завершится неудачей. Причина заключается в том, что по умолчанию компилятор интерпретирует предупреждения как ошибки, а функция не использует переданные ей аргументы. Отключать интерпретацию предупреждений как ошибок не рекомендуется, потому что некоторые предупреждения могут быть замаскированными ошибками. Чтобы избавиться от таких предупреждений, нужно полностью удалить имена аргументов (или закомментировать их) — это нормально для файлов C++. Существует и другое, более классическое решение — использование макроса UNREFERENCED_PARAMETER:
NTSTATUS
DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) {
UNREFERENCED_PARAMETER(DriverObject);
UNREFERENCED_PARAMETER(RegistryPath);
return STATUS_SUCCESS;
}
Этот макрос ссылается на переданный аргумент, просто записывая его значение; все претензии компилятора снимаются, так как аргумент был «использован».
Теперь проект компилируется нормально, но при компоновке происходит ошибка. Дело в том, что функция DriverEntry должна использовать схему компоновки C, которая не используется по умолчанию в C++. Ниже приведена итоговая версия успешной сборки драйвера, состоящего только из функции DriverEntry:
extern "C"
NTSTATUS
DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) {
UNREFERENCED_PARAMETER(DriverObject);
UNREFERENCED_PARAMETER(RegistryPath);
return STATUS_SUCCESS;
}
В какой-то момент драйвер может быть выгружен. В этот момент все, что было сделано в функции DriverEntry, должно быть отменено. Если этого не сделать, происходит утечка — ядро не перейдет в нормальное состояние до следующей перезагрузки. Драйверы могут содержать функцию выгрузки, которая автоматически вызывается перед выгрузкой драйвера из памяти. Указатель на нее должен был задан в поле DriverUnload объекта драйвера:
DriverObject->DriverUnload = SampleUnload;
Функция выгрузки получает объект драйвера (тот же, который передавался DriverEntry) и возвращает void. Так как наш пример драйвера ничего не делает в отношении выделения ресурсов в DriverEntry, в функции выгрузки ничего делать не придется, поэтому ее пока можно оставить пустой:
void SampleUnload(_In_ PDRIVER_OBJECT DriverObject) {
UNREFERENCED_PARAMETER(DriverObject);
}
Полный исходный код драйвера на текущий момент:
#include <ntddk.h>
void SampleUnload(_In_ PDRIVER_OBJECT DriverObject) {
UNREFERENCED_PARAMETER(DriverObject);
}
extern "C"
NTSTATUS
DriverEntry(_In_ PDRIVER_OBJECT DriverObject,
_In_ PUNICODE_STRING RegistryPath) {
UNREFERENCED_PARAMETER(RegistryPath);
DriverObject->DriverUnload = SampleUnload;
return STATUS_SUCCESS;
}
Установка и загрузка драйвера
Итак, файл драйвера Sample.sys успешно откомпилирован; теперь установим его в системе, а затем загрузим его. Обычно установка и загрузка драйверов осуществляются на виртуальной машине, чтобы избежать риска фатальных ошибок на основной машине. Вы можете либо выбрать этот путь, либо пойти на небольшой риск с этим минималистским драйвером.
Для установки программного драйвера, как и для установки службы пользовательского режима, необходимо вызвать функцию API СreateService c правильными аргументами или же воспользоваться существующими инструментами. Один из самых известных инструментов такого рода — Sc.exe, встроенная Windows-программа для работы со службами. Мы воспользуемся ею для установки и последующей загрузки драйвера. Учтите, что установка и загрузка драйверов является привилегированной операцией, выполнение которой обычно разрешается только администраторам.
Откройте окно командной строки с повышенными привилегиями и введите следующую команду (в последней части следует указать фактический путь к файлу SYS в вашей системе):
sc create sample type= kernel binPath= c:\dev\sample\x64\debug\sample.sys
Обратите внимание: между словом type и знаком равенства нет пробела, а между знаком равенства и словом kernel пробел есть. То же относится и ко второй части.
Если все прошло нормально, в выходных данных должна быть выведена информация об успехе. Чтобы проверить установку, откройте редактор реестра (regedit.exe) и найдите драйвер в разделе HKLM\System\CurrentControlSet\Services\Sample.
На рис. 2.3 показан скриншот редактора реестра после выполнения приведенной выше команды.
Чтобы загрузить драйвер, снова воспользуйтесь программой Sc.exe, но на этот раз с параметром Start; параметр использует функцию API StartService для загрузки драйвера (эта же функция API используется для загрузки служб). Тем не менее в 64-разрядных системах драйверы должны иметь цифровую подпись, поэтому обычно следующая команда завершится неудачей:
sc start sample
Так как подписывать драйвер в ходе разработки может быть неудобно (и даже невозможно, если у вас нет соответствующего сертификата), будет лучше перевести систему в режим тестовой подписи. В этом режиме неподписанные драйверы загружаются без проблем.
Рис. 2.3. Раздел реестра для установленного драйвера
В окне командной строки с повышенными привилегиями режим тестовой подписи включается командой следующего вида:
bcdedit /set testsigning on
К сожалению, команда вступает в силу после перезагрузки системы. После перезагрузки приведенная выше команда start должна работать успешно.
Если вы тестируете драйвер в Windows 10 с включенным режимом безопасной загрузки Secure Boot, попытка изменения режима тестовой подписи завершится неудачей. Это одна из настроек, защищенных режимом Secure Boot (также защищена локальная отладка режима ядра). Если вы не можете отключить режим Secure Boot в настройках BIOS из-за IT-политики в вашей организации или по другой причине, лучше всего проводить тестирование на виртуальной машине.
Есть еще одна настройка, которая может оказаться необходимой, если вы собираетесь тестировать драйвер в системе, предшествующей Windows 10. В этом случае следует задать целевую версию ОС в диалоговом окне свойств проекта (рис. 2.4). Обратите внимание: на иллюстрации я выбрал все конфигурации и все платформы, поэтому при переключении конфигурации (Debug/Release) или платформы (x86/x64/ARM/ARM64) настройка будет сохранена.
Рис. 2.4. Выбор целевой ОС в свойствах проекта
При включенном режиме тестовой подписи после загрузки драйвера должен быть выведен следующий результат:
SERVICE_NAME: sample
TYPE : 1 KERNEL_DRIVER
STATE : 4 RUNNING
(STOPPABLE, NOT_PAUSABLE, IGNORES_SHUTDOWN)
WIN32_EXIT_CODE : 0 (0x0)
SERVICE_EXIT_CODE : 0 (0x0)
CHECKPOINT : 0x0
WAIT_HINT : 0x0
PID : 0
FLAGS :
Это означает, что все хорошо, а драйвер загружен. Чтобы убедиться в этом, откройте Process Explorer и найдите файл образа драйвера Sample.sys. На рис. 2.5 выведена подробная информация об образе драйвера, загруженном в системное пространство.
Рис. 2.5. Образ драйвера, загруженный в системное пространство
В этот момент драйвер можно выгрузить следующей командой:
sc stop sample
Во внутренней реализации sc.exe вызывает функцию API ControlService со значением SERVICE_CONTROL_STOP.
При выгрузке драйвера будет вызвана функция выгрузки, которая на данный момент не делает ничего. Чтобы убедиться в том, что драйвер был действительно выгружен, снова загляните в Process Explorer; на этот раз образ драйвера не должен присутствовать в списке.
Простая трассировка
Как убедиться в том, что функция DriverEntry и функция выгрузки были действительно выполнены? Добавим в эти функции простейшую трассировку. Драйверы могут использовать макрос KdPrint для вывода в стиле printf текста, который можно просматривать в отладчике ядра и других инструментах. Макрос KdPrint компилируется только в отладочных (Debug) сборках; он вызывает функцию API ядра DbgPrint.
Обновленные версии функции DriverEntry и функции выгрузки, использующие макрос KdPrint для трассировки факта выполнения их кода:
void SampleUnload(_In_ PDRIVER_OBJECT DriverObject) {
UNREFERENCED_PARAMETER(DriverObject);
KdPrint(("Sample driver Unload called\n"));
}
extern "C"
NTSTATUS
DriverEntry(_In_ PDRIVER_OBJECT DriverObject,
_In_ PUNICODE_STRING RegistryPath) {
UNREFERENCED_PARAMETER(RegistryPath);
DriverObject->DriverUnload = SampleUnload;
KdPrint(("Sample driver initialized successfully\n"));
return STATUS_SUCCESS;
}
Обратите внимание на двойные круглые скобки при использовании KdPrint. Они необходимы, потому что KdPrint является макросом, но при этом может получать произвольное количество аргументов в стиле printf. Так как макросы не могут получать переменное количество аргументов, для вызова функции DbgPrint используется трюк компилятора.
С этими командами можно загрузить драйвер снова и просмотреть эти сообщения. Отладчик ядра будет использоваться в главе 4, а пока мы воспользуемся удобной программой DebugView из пакета Sysinternals. Прежде чем запускать DebugView, необходимо провести подготовку. Прежде всего, начиная с Windows Vista, вывод DbgPrint генерируется только при наличии определенного значения в реестре. Вы должны добавить в реестр раздел с именем DebugPrintFilter в разделе HKLM\SYSTEM\CurrentControlSet\Control\SessionManager (обычно этот раздел не существует). Добавьте в новый раздел параметр DWORD с именем DEFAULT (не путайте со значением по умолчанию, существующим в любом разделе) и присвойте ему значение 8 (строго говоря, подойдет любое значение с установленным битом 3). На рис. 2.6 показан этот параметр в RegEdit. К сожалению, этот параметр вступит в силу только после перезагрузки системы.
После того как настройка вступит в силу, запустите DebugView (DbgView.exe) с повышенными привилегиями. В меню Options включите режим CaptureKernel (или нажмите Ctrl+K). Режимы CaptureWin32 и CaptureGlobalWin32 можно безопасно отключить, чтобы вывод разных процессов не загромождал экран.
Постройте драйвер, если это не было сделано ранее. Теперь можно загрузить драйвер в окне командной строки с повышенными привилегиями (scstartsample). Вывод в DebugView должен выглядеть так, как показано на рис. 2.7. При выгрузке драйвера появится другое сообщение из-за вызова функции выгрузки. (Третья строка вывода сгенерирована другим драйвером и не имеет отношения к нашему примеру.)
Рис. 2.6. Раздел Debug Print Filter в реестре
Рис. 2.7. Вывод программы DebugView из пакета Sysinternals
Упражнения
1. Добавьте в функцию DriverEntry код для вывода версии ОС Windows: основная версия, дополнительная версия и номер сборки. Используйте функцию RtlGetVersion для получения нужной информации. Проверьте результаты при помощи DebugView.
Итоги
В этой главе мы рассмотрели инструменты, необходимые для разработки ядра, а также написали минимальный драйвер, который доказывает работоспособность этих инструментов. В следующей главе будут рассмотрены фундаментальные структурные элементы функций API, концепций и структур режима ядра.
Глава 3. Основы программирования ядра
В этой главе более подробно рассматриваются функции API, структуры и определения режима ядра. Также будут описаны некоторые механизмы выполнения кода в драйвере. Наконец, вся новая информация будет объединена для построения нашего первого функционального драйвера.
В этой главе:
• Общие рекомендации программирования ядра
• Отладочные и конечные сборки
• API режима ядра
• Функции и коды ошибок
• Строки
• Динамическое выделение памяти
• Списки
• Объект драйвера
• Объекты устройств
Общие рекомендации программирования ядра
Для разработки драйверов ядра необходим пакет Windows Driver Kit (WDK) с соответствующими заголовочными файлами и библиотеками. API режима ядра состоит из функций C, которые имеют много общего с функциями пользовательского режима. Впрочем, есть и некоторые различия. В табл. 3.1 приведена сводка основных различий между программированием пользовательского режима и программированием режима ядра.
Таблица 3.1. Различия в разработке для режима ядра и пользовательского режима
| Пользовательский режим |
Режим ядра |
|
| Необработанное исключение |
Фатальный сбой процесса |
Фатальный сбой системы |
| Завершение |
При завершении процесса вся приватная память и ресурсы освобождаются автоматически |
Если драйвер выгружается без освобождения всех используемых им ресурсов, возникает утечка, которая будет исправлена только при следующей загрузке |
| Возвращаемые значения |
Ошибки API иногда игнорируются |
Ошибки (почти) никогда не должны игнорироваться |
| IRQL |
Всегда PASSIVE_LEVEL(0) |
Может быть DISPATCH_LEVEL(2) и выше |
| Ошибки программирования |
Обычно локализуются в процессе |
Могут иметь общесистемные последствия |
| Тестирование и отладка |
Тестирование и отладка обычно выполняются на машине разработчика |
Отладка должна выполняться на другой машине |
| Библиотеки |
Может использовать практически любые библиотеки C/C++ (например, STL и boost) |
Не может использовать большинство стандартных библиотек |
| Обработка исключений |
Может использовать исключения C++ или структурированную обработку исключений (SEH) |
Может использовать только SEH |
| Использование C++ |
Доступна вся среда времени выполнения C++ |
Среда времени выполнения С++ недоступна |
Необработанные исключения
Исключения, происходящие в пользовательском режиме и не перехваченные программой, приводят к преждевременному завершению программы. С другой стороны, код режима ядра, который неявно считается пользующимся доверием, не может восстановиться из необработанного исключения. Такое исключение приведет к общему сбою системы с печально известным «синим экраном смерти», или BSOD (Blue Screen Of Death) (в новых версиях Windows экраны общего сбоя используют более разнообразные цвета). На первый взгляд BSOD создает неудобства, но по сути это защитный механизм. Дело в том, что возможное продолжение выполнения кода может причинить непоправимый вред Windows (например, удаление важных файлов или повреждение реестра), из-за которого система не сможет загрузиться. А значит, лучше немедленно остановить работу, чтобы предотвратить возможные повреждения. BSOD более подробно рассматривается в главе 6.
Все сказанное ведет к одному простому выводу: код режима ядра следует писать очень внимательно и осторожно, уделяя особое внимание всем мелочам и проверкам ошибок.
Завершение
Когда процесс завершается по любой причине — нормальным образом, из-за необработанного исключения или из внешнего кода, — он никогда не оставляет после себя никаких утечек: вся приватная память освобождается, все дескрипторы закрываются и т.д. Конечно, преждевременное закрытие дескрипторов может привести к потере некоторых данных (например, при закрытии дескриптора файла перед записью части данных на диск), но утечки ресурсов не будет: это гарантируется ядром.
С другой стороны, драйверы ядра такой гарантии не дают. Если драйвер выгружается в тот момент, когда он все еще удерживает выделенную память или открытые дескрипторы режима ядра, такие ресурсы не будут освобождены автоматически. Освобождение произойдет только при следующей загрузке системы.
Почему это происходит? Разве ядро не может отслеживать выделение памяти и использование ресурсов драйверами, чтобы автоматически освобождать их при выгрузке драйвера?
Теоретически это возможно (хотя в настоящее время ядро не следит за использованием ресурсов). Настоящая проблема в том, что такие попытки освобождения ресурсов со стороны ядра были бы слишком опасными. Представьте, что драйвер выделил буфер в памяти, а затем передал его другому драйверу, с которым он взаимодействует. Второй драйвер использует буфер в памяти и в конечном итоге освобождает его. Если ядро попытается освободить буфер при выгрузке первого драйвера, то во втором драйвере попытка обращения к освобожденному буферу приведет к нарушению прав доступа и общему сбою системы.
