Глава 2. Приступаем к разработке ядра

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

В этой главе:

  • Установим необходимые инструменты

  • Создадим проект драйвера

  • Собственно процедуры DriverEntry и выгрузки

  • Развёртывание нашего драйвера

  • Простая трассировка

Установка необходимых инструментов

В стародавние времена (до 2012), весь процесс разработки и сборки драйверов заключался в применении посвящённого этому инструментария построения из Device Driver Kit (DDK, Набора инструментальных средств для разработки драйверов устройств), причём без наличия интегрированной практики разработки, к которой разработчики привыкли при создании приложений режима пользователя. Имелись некоторые обходные пути, но ни один из них не был идеальным и официально не поддерживался со стороны Microsoft.

К счастью, начиная с Visual Studio 2012 и Windows Driver Kit 8, Microsoft официально сопровождает сборку драйверов при помощи Visual Studio (через msbuild), причём без необходимости применения компилятора и инструментов сборки по- отдельности.

Чтобы приступить к разработке драйвера в вашей машине разработки должны быть установлены следующие инструменты (и именно в этом порядке):

  • Visual Studio 2019 с самыми последним обновлениями. Убедитесь что в процессе установки выбран рабочий поток C++. Обратите внимание будут выполнены все SKU, включая бесплатную редакцию Сообщества.

  • Windows 11 SDK (обычно рекомендуется самый последний). Убедитесь, что в процессе установки выбран, по крайней мере, элемент Debugging Tools for Windows.

  • Windows 11 Driver Kit (WDK) - он поддерживает сборку драйверов для Windows 7 и последующих версий Windows. Убедитесь, что ваш мастер установил необходимые шаблоны проектов для Visual Studio в самом конце установки.

  • Инструментарий Sysinternals, который бесценен при любой "внутренней" работе, может быть выгружен с http://www.sysinternals.com. Кликните по Sysinternals Suite в левой части этой веб страницы и выгрузите zip файл Sysinternals Suite. Распакуйте в любой папке и эти инструменты готовы к применению.

[Совет]Совет

Необходимо соответствие версий SDK и DDK. Для загрузки подходящих к выбранному DDK SDK следуйте руководствам в странице выгрузки WDK.

Быстрый способ убедиться что подходящие шаблоны WDK установлены верно, состоит в открытии Visual Studio и выборе New Project с поиском проектов драйверов, например "Empty WDM Driver".

Создаём проект драйвера

Когда указанная выше установка выполнена, может быть создан проект нового драйвера. Тот шаблон, которым мы воспользуемся в этом разделе, является "WDM Empty Driver". Рисунок 2-1 показывает как выглядит наш диалог New Project для данного типа драйвера в Visual Studio 2019. Рисунок 2-2 отображает тот же самый начальный мастер с Visual Studio 2019, когда установлен и включён Classic Project Dialog. Наш проект на обоих рисунках носит название "Sample".

 

Рисунок 2-1


Новый проект WDM Driver в Visual Studio 2019

 

Рисунок 2-2


Новый проект WDM Driver в Visual Studio 2019 с расширением Classic Project Dialog

После создания данного проекта, наш Solution Explorer показывает внутри своего фильтра Driver Files единственный файл - Sample.inf. Если вам не требуется в данном примере это файл, просто удалите его (клик павой кнопкой и выбор Remove, либо нажмите клавишу Del).

Теперь самое время добавить исходный файл. Кликните правой кнопкой по узлу Source Files в Solution Explorer и выберите из появившегося меню File Add / New Item…. Выберите исходный файл C++ и назовите его Sample.cpp. Для его создания кликните OK.

Процедуры DriverEntry и Unload

Всякий драйвер обладает некой точкой входа с названием по умолчанию DriverEntry. Её можно рассматривать в качестве функции "main" данного драйвера, сопоставимой с нашей классической main для приложения режима пользователя. Данная функция вызывается потоком системы при IRQL PASSIVE_LEVEL (0) (IRQL подробно обсуждаются в Главе 8.)

DriverEntry обладает предварительно определённым прототипом, показанном здесь:


NTSTATUS
DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath\
);
 	   

Указанные аннотации _In_ являются частью Source (Code) Annotation Language (SAL, Языка аннотации исходного кода). Такие аннотации прозрачны для самого компилятора, однако предоставляют метаданные, полезные для чтения людьми и инструментами статического анализа. Я могу удалить эти аннотации в примерах кода для более быстрого их чтения, однако вам надлежит пользоваться аннотациями SAL везде где возможно.

Минимальная процедура DriverEntry способна просто возвращать успешное состояние, например так:


NTSTATUS
DriverEntry(
    _In_ PDRIVER_OBJECT DriverObject, 
    _In_ PUNICODE_STRING RegistryPath) {
    return STATUS_SUCCESS;
}
 	   

Этот код пока не компилируется. Прежде всего вам необходимо включить некий заголовок, который обладает необходимыми определениями для тех типов, которые представлены в DriverEntry. Вот одна из возможностей:


#include 
 	   

Теперь наш код имеет лучшие шансы для компиляции, однако всё ещё завершается неудачно. Одна из причин состоит в том, что по умолчанию, наш компилятор настроен на трактовку предупреждений как ошибок и сама функция не пользуется приданными ей параметрами. Не рекомендуется удалять treat warnings as errors из параметров компиляции, поскольку некоторые предостережения способны маскировать ошибки. Эти предостережения могут быть разрешены посредством удаления всех имён параметров (или сокрытия их комментарием), что подходит для файлов C++. Это иной, более "классический" способ решения такой проблемы, которая состоит в применении макроса UNREFERENCED_PARAMETER:


NTSTATUS
DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
    UNREFERENCED_PARAMETER(DriverObject);
    UNREFERENCED_PARAMETER(RegistryPath);

    return STATUS_SUCCESS;
}
 	   

Как оказывается, этот макрос на самом деле ссылается на переданный параметр, просто записывая его значение как есть, что отключает компиляцию, превращая данный параметр в технически "ссылочный".

Сборка этого проекта теперь проходит, однако вызывает ошибку привязки. Наша функция DriverEntry должна обладать C- привязкой, которая не установлена по умолчанию при компиляции C++. Вот окончательная версия успешной сборки драйвера, состоящего лишь из функции DriverEntry:


extern "C" NTSTATUS
DriverEntry(PDRIVER_OBJECT DriverObject, 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(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
    UNREFERENCED_PARAMETER(RegistryPath);

    DriverObject->DriverUnload = SampleUnload;

    return STATUS_SUCCESS;
}
 	   

Развёртывание драйвера

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

Установка программного драйвера, так же в точности, как и установка службы режима пользователя, требует вызова API CreateService с надлежащими параметрами, либо применения сопоставимого инструмента. Одним из наиболее известных инструментов для этой цели выступает Sc.exe (сокращение от Service Control) - Управление службой), встроенного инструмента 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 отображает снимок экрана для нашего редактора реестра после предыдущей команды.

 

Рисунок 2-3


Реестр для установленного драйвера

Для загрузки своего драйвера мы можем снова воспользоваться своим инструментом Sc.exe, причём на этот раз с параметром start, который для загрузки нашего драйвера пользуется API StartService (тот же самый API применяется для загрузки служб). Тем не менее, в 64 битных системах драйверы должны подписываться, а потому, наша следующая команда завершится неудачно:


sc start sample
		

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

В командном окне с повышенными полномочиями проверочную подпись можно включит подобным образом:


bcdedit /set testsigning on
		

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

[Замечание]Замечание

Если вы проводите проверку в системе Windows 10 (или последующей) с разрешённым безопасным запуском, такой режим проверки подписи завершится неудачей. Именно это является настройкой Безопасного запуска (Securing Boot, локальная отладка ядра также защищена им). Если у вас нет возможности отключения Безопасного запуска через настройку BIOS, по причине политики ИТ, или по какой- либо ещё, наилучшим вариантом будет проверка в виртуальной машине. {Прим. пер.: подробнее о Безопасном запуске в нашем переводе Практика загрузки. Изучение процесса загрузки Linux, Windows и Unix Йогеша Бабара, (с) 2020, Apress и в переводе глав из Руткиты и буткиты. Противодействие современному вредоносному ПО и угрозам следующего поколения Алекс Матросов, Евгений Родионов и Сергей Братус, (с) 2019, No Starch Press, Inc.}

Имеется ещё один параметр, который вам может потребоваться указывать когда вы намерены проверять драйвер в компьютере с предустановленной Windows 10. В этом случае вам надлежит установить целевую версию ОС в диалоговом окне свойств своего проекта, как это показано на Рисунке 2-4. Обратите внимание, что я выбрал все конфигурации и все платформы чтобы при переключении конфигураций (Debug/ Release) или платформ ((x86/x64/ARM/ARM64), поддерживалась данная установка.

 

Рисунок 2-4


Настройка Target OS Platform в свойствах нашего проекта

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


c:/>sc start sample
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


Загруженный в пространство системы образ драйвера sample

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


sc stop sample
		

За сценой этого, sc.exe вызывает соответствующий API ControlService со значением SERVICE_CONTROL_STOP. Выгрузка данного драйвера является причиной для вызова процедуры Unload, которая в данном случае не делает ничего. Вы можете убедиться что этот драйвер в конечном счёте выгружен, снова просмотрев Process Explorer ;там совсем не должно быть более никакой записи образа этого драйвера.

Простая трассировка

Как мы можем быть уверены, что наши процедуры DriverEntry и Unload действительно исполняются? Давайте добавим к этим функциям базовую трассировку. Драйверы обладают возможностью применения функции DbgPrint для вывода текста в стиле printf, который можно просматривать при помощи отладчика своего ядра или иного инструмента.

Вот обновлённая версия для процедур DriverEntry и Unload, которые пользуются KdPrint для отладки того факта, что их код выполнен:


void SampleUnload(PDRIVER_OBJECT DriverObject) {
    UNREFERENCED_PARAMETER(DriverObject);

    DbgPrint("Sample driver Unload called\n");
}

extern "C" NTSTATUS
DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
    UNREFERENCED_PARAMETER(RegistryPath);

    DriverObject->DriverUnload = SampleUnload;

    DbgPrint("Sample driver initialized successfully\n");

    return STATUS_SUCCESS;
}
 	   

Более типичным подходом является применение таких выходных данных только в отладочных сборках. Это связано с тем, что Dbgprint обладает некоторыми накладными расходами, которых вы бы хотели избежать в сборках Release. KdPrint это макрос, который компилируется только в сборках Debug и вызывает лежащий в его основе API ядра DbgPrint. Вот пересмотренная версия, которая применяет KdPrint:


void SampleUnload(PDRIVER_OBJECT DriverObject) {
    UNREFERENCED_PARAMETER(DriverObject);

    KdPrint(("Sample driver Unload called\n"));
}

extern "C" NTSTATUS
DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
    UNREFERENCED_PARAMETER(RegistryPath);

    DriverObject->DriverUnload = SampleUnload;

    KdPrint(("Sample driver initialized successfully\n"));

    return STATUS_SUCCESS;
}
 	   

Обратите внимание на двойные скобки при применении KdPrint. Они необходимы по той причине, что KdPrint это макрос, однако, по- видимому, принимает любое число параметров подобно printf. Поскольку макросы не способны получать переменное число параметров, компьютерный трюк состоит в применении вызова функции DbgPrint, которая принимает на самом деле переменное число параметров.

Разместив эти операторы, мы бы хотели снова загрузить свой драйвер и посмотреть на эти сообщения. Мы будем применять отладчик ядра в Главе 4, однако в данный момент мы воспользуемся полезным инструментом Sysinternals с названием DebugView. Перед применением DebugView вам надлежит выполнить некоторые приготовления. Прежде всего, начиная с Windows Vista, вывод DbgPrint не вырабатывается в действительности, пока в вашем реестре нет определённого значения. Вам придётся добавить некий ключ с названием Debug Print Filter в HKLM\SYSTEM\CurrentControlSet\Control\Session Manager (обычно этот ключ отсутствует). Внутри этого ключа добавьте значение DWORD с названием DEFAULT (это не значение по умолчанию, присутствующее в любом ключе) и установите его значение равным 8 (с технической точки зрения, это сделает любое значение с установленным битом 3). Рисунок 2-6 отображает эту установку в RegEdit. К сожалению, для вступления её в действие вам придётся перезапустить свою систему.

 

Рисунок 2-6


Ключ Debug Print Filter в реестре

После применения этой настройки запустите DebugView (DbgView.exe) с повышенными полномочиями. В его меню Options убедитесь что выбрано Capture Kernel (либо нажмите Ctrl+K). Вы можете безопасно снимать выбор Capture Win32 и Capture Global Win32, поскольку этот вывод режима пользователя из различных процессов не вносит хаос в ваш дисплей.

[Совет]Совет

DebugView способен отображать вывод отладки ядра даже без показанного на Рисунке 2-6 значения Реестра если вы выберете в его меню Capture вариант Enable Verbose Kernel Output. Однако, имеется подозрение, что этот вариант не работает в Windows 11 и такая настройка Реестра всё же необходима.

Соберите свой драйвер, если вы ещё этого не сделали. Теперь вы можете снова загрузить этот драйвер из окна команд с повышенными правами (sc start sample). В DebugView вы должны наблюдать вывод, отображаемый на Рисунке 2-7. Когда вы выгрузите этот драйвер, вы обнаружите возникающим другое сообщение по причине вызова процедуры Unload. (Третья строка вывода происходит из другого драйвера и не имеет никакого отношения к нашему образцу драйвера).

 

Рисунок 2-7


Вывод DebugView Sysinternals

[Замечание]Замечание

Добавьте в вывод своего образца DriverEntry версию ОС Windows: главную, второстепенную и номер сборки. Для выборки этих сведений воспользуйтесь функцией RtlGetVersion. Проверьте получаемые результаты при помощи DebugView.

Выводы

Мы рассмотрели те инструменты, которые необходимо иметь вам для разработки ядра и написания очень минималистского драйвера доказательства работы этих базовых инструментов. В своей следующей главе мы рассмотрим основы построения блоков API ядра, понятий и фундаментальных структур.