Глава 3. Основы программирования ядра

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

В этой главе:

  • Общие руководства программирования ядра

  • Сопоставление сборок отладки и выпуска

  • API ядра

  • Функции и коды ошибок

  • Строки

  • Динамическое выделение памяти

  • Связанные списки

  • Атрибуты объекта

  • Собственно объект драйвера

  • Объекты устройств

Общие руководящие правила программирования ядра

Разработка драйверов ядра требует Windows Driver Kit (WDK), где размещаются все необходимые заголовки и библиотеки. API самого ядра состоит из функций C, по существу очень похожи на API режима пользователя. Однако имеется ряд отличий. В Таблице 3-1 суммируются все важные различия между программированием в режиме пользователя и программированием в режиме ядра.

Таблица 3-1. Отличия разработки между режимом пользователя и режимом ядра
  Режим пользователя Режим ядра

Не обрабатываемые исключительные ситуации

Не обрабатываемые исключительные ситуации приводят к крушению процесса

Не обрабатываемые исключительные ситуации приводят к крушению всей системы

Прекращение

При прекращении процесса все частная память и ресурсы освобождаются автоматически

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

Возвращаемые значения

Ошибки API иногда игнорируются

Не следует (почти) никогда игнорировать ошибки

IRQL

Всегда PASSIVE_LEVEL (0)

Может быть DISPATCH_LEVEL (2) или выше

Плохое кодирование

Обычно локализовано в самом процессе

Может оказывать воздействие на всю систему

Тестирование и отладка

Обычно тестирование и отладка выполняются в определённой машине разработчика

Отладка должна проводиться при помощи другой машины

Библиотеки

Могут применяться почти все библиотеки C/C++ (например, STL, boost)

Большинство стандартных библиотек не могут применяться

Обработка исключительной ситуации

Может применять исключительные ситуации C++ или Структурную обработку исключительных ситуаций (SEH, Structured Exception Handling)

Могут применяться только SEH

Применение C++

Доступен весь C++ времени исполнения

Никакого C++ времени исполнения

Необработанные исключительные ситуации

Происходящие в режиме пользователя исключительные ситуации, которые не перехватываются самой программой вызывают преждевременное прекращение такого процесса. Код режима ядра, с другой стороны, который обладает неявным доверием, не способен восстановиться после исключительной ситуации без обработки. Такая исключительная ситуация приводит к крушению всей системы с печально известным Синим окном смерти (BSOD - Blue screen of death, более новые версии Windows обладают более разнообразными цветами для экрана крушения). На первый взгляд, BSOD может показаться неким видом кары, но по существу, это защитный механизм. Обоснованием этого является то, что продолжение исполнения кода может приводить к необратимому повреждению Windows (например, к удалению важных файлов или повреждению реестра), что влечёт за собой отказ запуска этой системы. А потому лучше сразу всё остановить, чтобы предотвратить потенциальный ущерб. Более подробно BSOD мы обсудим в Главе 6.

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

Прекращение

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

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

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

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

Это подчёркивает ответственность драйвера ядра за надлежащую очистку после себя; никто иной этого не сделает.

Возвращаемые значения функции

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

Игнорирование возвращаемых значений из API ядра намного опаснее (вспомните предыдущий раздел Прекращение)и, как правило, этого следует избегать. Даже кажущиеся "невинными" функции способны отказывать по неожиданным причинам, поэтому здесь золотое правило - всегда проверять возвращаемые из API ядра значения состояния.

IRQL

Уровень запроса прерывания (IRQL, Interrupt Request Level) это важное понятие ядра, которое в дальнейшем будет обсуждаться в Главе 6. На данный момент достаточно будет сказать, что обычно IRQL процессора равен нулю и, в частности, он всегда равен нулю при исполнении кода режима пользователя. В режиме ядра он в большинстве случаев также равен нулю, но не всегда. Некоторые ограничения на выполнение кода имеются при IRQL 2 или выше, а это означает, что сам автор драйвера обязан быть осторожным и применять лишь допустимые для столь высокого IRQL API. Воздействие IRQL со значением выше нуля обсуждается в Главе 6.

Применение C++

В программировании режима пользователя C++ применялся на протяжении многих лет и он хорошо работал при сочетании с API Windows режима пользователя. Для режима ядра Microsoft начал официальное сопровождение C++ начиная с Visual Studio 2012 и WDK 8. C++, естественно, не обязателен, однако он обладает некоторыми важными преимуществами относительно очистки ресурсов с идиомой C++ под названием Приобретения ресурсов инициализацией (RAII, Resource Acquisition Is Initialization). Мы слегка будем пользоваться этой идиомой для гарантии того, что у нас нет утечки ресурсов.

C++ в качестве языка программирования почти полностью поддерживается кодом ядра. Однако в самом ядре отсутствует время исполнения C++, а потому некоторые функциональные возможности C++ просто не могут применяться:

  • Операторы new и delete не поддерживаются и будут приводить к отказу при компиляции. Это обусловлено тем, что их обычное действие состоит в выделении из кучи режима пользователя, что неуместно внутри ядра. API самого ядра обладает функциями "замещения", которые наиболее точно моделируются вследствие функций malloc и free. Позднее в этой главе мы обсудим эти функции. Тем не менее, имеется возможность перекрывать операторы new и delete аналогично тому как это иногда делается в режиме пользователя и активировать функции выделения и освобождения памяти самого ядра. Позднее в этой главе мы рассмотрим как это осуществлять.

  • Обладающие не определёнными по умолчанию конструкторами глобальные переменные не будут вызываться - для вызова таких конструкторов нет никакого C/C++ времени исполнения. Таких ситуаций следует избегать, однако имеются некоторые обходные пути:

    • Избегайте в своём конструкторе любой код и вместо создавайте некую подлежащую в явном виде вызову функцию Init (то есть из DriverEntry).

    • Выделяйте указатель исключительно как глобальный (или статический) и создавайте реальный экземпляр динамически. Сам компилятор сгенерирует необходимый верный код для активации такого конструктора. Это работает при условии перекрытия операторов new и delete, как это описывается позднее в данной главе.

  • Ключевые слова обработки исключительной ситуации C++ (try, catch, throw) не компилируются. Это происходит по той причине, что механизм обработки исключительных ситуаций C++ требует своего собственной среды времени исполнения, что не предоставляется в ядре. Обработка исключительной ситуации может выполняться только при помощи Структурной обработки исключительных ситуаций (SEH, Structured Exception Handling) - механизма ядра для обработки исключительных ситуаций. Более подробно мы рассмотрим SEH в Главе 6.

  • В самом ядре недоступны стандартные библиотеки C++. Хотя большинство из них основаны на шаблонах, они не компилируются, поскольку могут зависеть от библиотек и семантики режима пользователя. Тем не менее, шаблоны C++ в качестве функциональной возможности языка программирования работают просто отлично. Одним из пригодных способов применения шаблонов является создание альтернатив для типов библиотек режима ядра на основе аналогичных типов из стандартной библиотеки C++ режима пользователя, таких как std::vector<>, std::wstring и тому подобных.

В примерах кода этой книги применяется С++. В таких примерах кода в основном применяются следующие свойства:

  • Ключевое слово nullptr, представляющее истинный указатель NULL.

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

  • Везде, где они имеют смысл, будут применяться шаблоны.

  • Перекрытие операторов new и delete.

  • Конструкторы и деструкторы, в особенности для построения типов RAII.

Для разработки ядра можно пользоваться любым стандартом C++. Имеющиеся настройки Visual Studio для новых проектов применяют C++ 14. Однако вы можете изменить стандарт компилятора C++ на любую иную установку, включая C++ 20 (последний стандарт на момент написания этих строк). Некоторые применяемые нами позднее функциональные возможности будут зависеть, как минимум, от C++ 17.

Строго говоря, драйверы ядра можно писать без каких бы то ни было проблем на чистом C. Если вы предпочитаете следовать этим путём, вместо файлов с расширением CPP применяйте файлы с C. Это автоматически активирует для таких файлов компилятор C.

Тестирование и отладка

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

Для кода ядра тестирование обычно выполняется в иной машине, как правило в виртуальной машине, размещаемой в вашей машине разработки. Это обеспечит в случае возникновения BSOD (синего окна смерти) отсутствие воздействия на машину разработки. Отладка кода ядра обязана осуществляться в другой машине, в которой присутствует реальный драйвер. Это обусловлено тем, что достижение точки прерывания в режиме ядра замораживает всю машину целиком, а не просто определённый процесс. Такая машина разработки размещает сам отладчик, в то время как соответствующая вторая машина (и снова, как правило, виртуальная машина) исполняет необходимый код драйвера. Эти две машины должны соединяться через некий механизм с тем, чтобы данные могли протекать между самим хостом (где выполняется сам отладчик) и его целью. Более подробно отладку мы рассмотрим в Главе 5.

Сопоставление Отладки и Выпуска

В точности как и с проектами режима пользователя, сборка драйверов ядра может выполняться в режимах Отладки или Выпуска (Debug или Release). Имеющиеся отличия аналогичны их двойникам режима пользователя - сборки Отладки не применяют по умолчанию оптимизаций компилятора, но более просты для отладки. Сборки Выпуска пользуются полной оптимизацией компилятора по умолчанию, производя самый быстрый и наименьший из возможных код. Тем не менее, имеются и некие отличия.

Применяемыми в ядре терминами выступают Checked (Debug) и Free (Release). Хотя проекты ядра Visual Studio продолжают применять термины Debug/Release, более старая документация пользуется терминами Checked/Free. С точки зрения компиляции, сборка Debug ядра определяет символ DBG и устанавливает для него значение 1 (в противовес определяемому в режиме пользователя символу _DEBUG). Это означает что вы можете применять такой символ DBG в качестве отличия между сборками Debug и Release с условием компиляции. Например, именно это выполняет макрос KdPrint: в сборках Debug он выполняет компиляцию вызывая DbgPrint, в то время как сборки Release не компилируют ничего, что в результате вызовы KdPrint не имеют воздействия в сборках Release. Обычно это именно то что нужно вам, потому как такие вызовы достаточно затратны. Мы обсудим прочие способы ведения журналов в Главе 5.

API ядра

Драйверы ядра пользуются экспортируемыми из компонентов ядра функциями. Эти функции будут носить название API ядра. Большинство функций реализуются внутри самого модуля ядра (NtOskrnl.exe), однако некоторые могут реализовываться прочими модулями ядра, например, HAL (hal.dll).

Такой API Ядра это большой набор функций C. Большинство из них начинаются с некого префикса, предполагающего тот компонент, который реализует эту функцию. Таблица 3-2 отображает некоторые из наиболее распространённых префиксов и их значение:

Таблица 3-2. Распространённые префиксы API ядра
Префикс Значение Образец

Ex

Общие функции супервизора

ExAllocatePoolWithTag

Ke

Общие функции ядра

KeAcquireSpinLock

Mm

Диспетчер памяти

MmProbeAndLockPages

Rtl

Общая библиотека времени выполнения

RtlInitUnicodeString

FsRtl

Библиотека времени выполнения файловой системы

FsRtlGetFileSize

Flt

Библиотека мини- фильтра файловой системы

FltCreateFile

Ob

Диспетчер объектов

ObReferenceObject

Io

Диспетчер ввода/ вывода

IoCompleteRequest

Se

Безопасность

SeAccessCheck

Ps

Диспетчер процессов

PsLookupProcessByProcessId

Po

Диспетчер управления питанием

PoSetSystemState

Wmi

Windows management instrumentation

WmiTraceMessage

Zw

Собственные обёртки API

ZwCreateFile

Hal

Hardware abstraction layer

HalExamineMBR

Cm

Диспетчер конфигурации (реестр)

CmRegisterCallbackEx

Если вы взглянете на список экспортируемых из NtOsKrnl.exe функций, вы обнаружите множество функций, которые не документированы в самом Windows Driver Kit; это просто обусловлено фактом жизни разработчика ядра - не всё документируется.

На данном этапе следует обсудить один из наборов функций - функции с префиксом Zw. Эти функции отражают собственные интерфейсы API, доступные в качестве шлюзов из NtDll.Dll, причём с реальной представляемой самим Супервизором реализацией. Когда из режима пользователя вызывается некая функция Nt, скажем, NtCreateFile, она достигает своего Супервизора в его реальной реализации NtCreateFile. В данном месте NtCreateFile может выполнять разнообразные проверки на основании того факта, что его первоначальная сторона вызова выполняется в режиме пользователя. Такие сведения вызывающей стороны сохраняются на основе потока- через- поток, причём в соответствующем недокументированном участнике PreviousMode в соответствующей структуре KTHREAD для каждого потока.

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

Вы можете запрашивать предыдущий режим процессора через вызов документированного API ExGetPreviousMode.

С другой стороны, когда драйверу ядра требуется вызвать некую системную службу, он не должен подвергаться тем же проверкам и ограничениям, которые налагаются на вызывающие стороны режима пользователя. Именно здесь и вступают в действие функции Zw. Вызов функции Zw устанавливает предыдущий режим вызывающей стороны в KernelMode (0) и затем вызывает собственную функцию. Например, вызов ZwCreateFile устанавливает предыдущую вызывающую сторону в KernelMode и далее вызывает NtCreateFile, что в результате приводит обход некоторых проверок безопасности и буфера NtCreateFile, которые бы выполнялись в противном случае. Суть в том, что драйверы ядра обязаны вызывать функции Zw, если нет веских причин поступать иначе.

Функции и коды ошибок

Большинство функций API возвращает некое состояние, указывающее на успешность или неудачу операции. Оно обладает типом NTSTATUS, 32- битным целым со знаком. Его значение STATUS_SUCCESS (0) означает успех. Отрицательное значение указывает на некий вид ошибки. Вы можете найти все заданные для NTSTATUS значения в файле <ntstatus.h>.

Большинство путей кода не беспокоятся о точной природе получаемой ошибки, а потому проверки старшего бита вполне достаточно для выяснения того произошла ли ошибка. Это можно осуществить при помощи макроса NT_SUCCESS. Вот пример, который проверяет наличие отказа и регистрирует ошибку когда он имеет место:


NTSTATUS DoWork() {
    NTSTATUS status = CallSomeKernelFunction();
    if(!NT_SUCCESS(Statue)) {
        KdPirnt((L"Error occurred: 0x%08X\n", status));
        return status;
    }

    // продолжить дополнительные действия

    return STATUS_SUCCESS;
}
 	   

В некоторых ситуациях возвращаемые из функций значения NTSTATUS в конечном итоге всплывают вплоть до режима пользователя. В таком случае значение STATUS_xxx транслируется в некоторое значение ERROR_yyy, которое доступно для режима пользователя через функцию GetLastError. Обратите внимание, что это не те же самые числа; например,коды ошибки в режиме пользователя обладают положительными значениями (ноль всё ещё успех). Далее, устанавливаемое соответствие не будет один- к- одному. В любом случае, обычно это не относится в драйверу ядра.

Внутренние функции драйвера ядра также обычно возвращают NTSTATUS чтобы указывать своё состояние успеха/ неудачи. Обычно этого достаточно, поскольку эти функции вызывают API интерфейсы ядра, а потому способны распространять любую ошибку, просто возвращая то же значение состояния, которое они получили от конкретного API. Это к тому же подразумевает, что "настоящие" возвращаемые значения функций драйвера обычно возвращаются указателями или ссылками, предоставляемыми этой функцией в качестве аргументов.

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

Возвращайте NTSTATUS из своих собственных функций. Это упростит и согласует отчёты об ошибках.

Строки

API по мере необходимости применяет строки во многих ситуациях. В некоторых случаях эти строки являются простыми указателями Unicode (wchar_t* или одним из их typedef, например WCHAR*), однако большинство имеющих дело со строками функций ожидают структуры с типом UNICODE_STRING.

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

Применяемый в данной книге термин Unicode в точности эквивалентен UTF-16, что означает 2 байта на символ. Именно так строки хранятся внутренним образом внутри компонентов ядра. В целом, Unicode это некое множество стандартов относительно кодирования символов. Дополнительные сведения вы можете найти на https://unicode.org.

Структура UNICODE_STRING представляет строку с её длиной и максимумом её известной длины. Вот упрощённое определение этой структуры:


typedef struct _UNICODE_STRING {
    USHORT Length;
    USHORT MaximumLength;
    PWCH   Buffer;
} UNICODE_STRING;
typedef UNICODE_STRING *PUNICODE_STRING;
typedef const UNICODE_STRING *PCUNICODE_STRING;
 	   

Значение элемента Length представлено в байтах (не в символах) и не включает в себя ограничитель Unicode-NULL, если он присутствует (ограничитель NULL не является обязательным). Значение элемента MaximumLength это число байтов, до которого может вырастать данная строка без необходимости перераспределения памяти.

Манипуляции структурами UNICODE_STRING обычно выполняются при помощи набора функций Rtl, которые конкретно имеют дело со строками. Таблица 3-3 перечисляет некоторые распространённые функции для манипуляции строками, предоставляемые функциями Rtl.

Таблица 3-3. Распространённые функции UNICODE_STRING
Функция Описание

RtlInitUnicodeString

Инициализирует UNICODE_STRING на основании имеющегося указателя строки C. Устанавливает Buffer, затем вычисляет значение Length и устанавливает MaximumLength в то же самое значение. Обратите внимание, что данная функция не выделяет никакой памяти - она лишь инициализирует необходимые внутренние элементы.

RtlCopyUnicodeString

Копирует UNICODE_STRING в другую строку. Значение указателя строки получателя (Buffer) должно быть выделено перед копированием, а MaximumLength устанавливается надлежащим образом.

RtlCompareUnicodeString

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

RtlEqualUnicodeString

Сопоставляет две строки UNICODE_STRING на равенство, причём с определением чувствительности к значению регистра.

RtlAppendUnicodeStringToString

Добавляет одну строку UNICODE_STRING в конец другой.

RtlAppendUnicodeToString

Добавляет UNICODE_STRING в конец строки в стиле C.

Дополнительно к указанным выше функциям, имеются функции, которые работают с указателями строк C. Более того, внутри самого ядра реализованы хорошо известные функции строк из C Runtime Library, что к тому же удобно: wcscpy_s, wcscat_s, wcslen, wcscpy_s, wcschr, strcpy, strcpy_s и прочие.

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

Значение префикса wcs указывает на работу со строками Unicode C, в то время как префикс str указывает на работу со строками Ansi C. Значение суффикса _s в некоторых функциях указывает на safe (безопасность) функции, в которой дополнительный параметр указывает максимальную длину такой строки, которая должна быть предоставлена с тем, чтобы эта функция не передавала данных больше такого размера.

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

Никогда не пользуйтесь не безопасными функциями. Вы можете вставить <dontuse.h> для получения ошибок для устаревших функций, если вы применяете их в коде.

Динамическое выделение памяти

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

Само ядро предоставляет два общих пула памяти для применения драйверами (само по себе ядро также пользуется ими).

  • Пул с подкачкой страниц - пул памяти, который в случае необходимости может выделяться постранично.

  • Невыгружаемый пул - пул памяти, который никогда не выделяется постранично т гарантированно остаётся в оперативной памяти.

Очевидно, что невыгружаемый пул является "лучшим" пулом, поскольку он никогда не способен вызывать ошибку страницы. Позднее в этой книге мы обнаружим, что в некоторых случаях требуется выделение памяти из невыгружаемого пула. Драйверам надлежит пользоваться этим пулом экономно, причём исключительно в случае необходимости. Во всех прочих случаях драйверы обязаны применять выгружаемый пул (с подкачкой страниц). Имеющееся перечисление POOL_TYPE представляет значения типов пулов. Данное перечисление содержит множество "типов" пулов, однако драйверами должны применяться лишь три: PagedPool, NonPagedPool, NonPagedPoolNx (невыгружаемый пул без полномочий исполнения).

Таблица 3-4 суммирует наиболее распространённые функции, применяемые для работы с допустимыми пулами памяти ядра:

Таблица 3-4. Функции для выделения из пулов памяти ядра
Функция Описание

ExAllocatePool

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

ExAllocatePoolWithTag

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

ExAllocatePoolZero

То же, что и ExAllocatePoolWithTag, но с заполнением нулями своего блока памяти.

ExFreePool

Высвобождает выделение. Данная функция знает из какого пула было выполнено данное выделение.

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

ExAllocatePool вызывает ExAllocatePoolWithTag при помощи тега enoN (слово "None" в обратном порядке). Более ранние версии Windows применяли mdW (WDM наоборот). Вам надлежит избегать применения этой функции и пользоваться ExAllocatePoolWithTag вместо неё.

ExAllocatePoolZero встроена в wdm.h путём вызова ExAllocatePoolWithTag с добавлением к значению типа пула соответствующего флага POOL_ZERO_ALLOCATION (=1024).

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

Прочие функции управления памятью рассматриваются в Главе 8, Современные технологии программирования.

Параметр значения тега позволяет "маркировать" некое выделение значением в 4- байта. Обычно это значение составляется из символов ASCII общим числом до 4, логически идентифицирующих свой драйвер или некую часть своего драйвера. Этот тег может применяться в помощь выявлению утечек памяти - когда какие- бы то ни было помеченные тегом выделения с обозначенным тегом драйвера остаются после выгрузки своего драйвера. Такие выделения пула (со своими тегами) могут просматриваться при помощи инструмента WDM Poolmon или моего собственного инструмента PoolMonXv2 (выгружаемого с http://www.github.com/zodiacon/AllTools). Рисунок 3-1 отображает снимок экрана для PoolMonXv2.

 

Рисунок 3-1


PoolMonXv2

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

Вам надлежит применять теги из выводимых на печать символов ACSII. В противном случае запуск вашего драйвера под управлением Driver Verifier (Описанного в Главе 11) повлечёт выражение недовольства со стороны Driver Verifier.

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


// определение тега (по причине младшего следования байт, представляемого как 'abcd')

#define DRIVER_TAG 'dcba'

UNICODE_STRING g_RegistryPath;

extern "C" NTSTATUS
DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
    UNREFERENCED_PARAMETER(DriverObject);
    DriverObject->DriverUnload = SampleUnload;

    g_RegistryPath.Buffer = (WCHAR*)ExAllocatePoolWithTag(PagedPool,
        RegistryPath->Length, DRIVER_TAG);
    if (g_RegistryPath.Buffer == nullptr) {
        KdPrint(("Failed to allocate memory\n"));
        return STATUS_INSUFFICIENT_RESOURCES;
    }

    g_RegistryPath.MaximumLength = RegistryPath->Length;
    RtlCopyUnicodeString(&g_RegistryPath, 
        (PCUNICODE_STRING)RegistryPath);

    // %wZ is for UNICODE_STRING objects
    KdPrint(("Original registry path: %wZ\n", RegistryPath));
    KdPrint(("Copied registry path: %wZ\n", &g_RegistryPath));
    //...
    return STATUS_SUCCESS;
}

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

    ExFreePool(g_RegistryPath.Buffer);
    KdPrint(("Sample driver Unload called\n"));
}
 	   

Связные списки

Во многих своих внутренних структурах данных ядро применяет циклические двусвязные списки. Например, все процессы в системе управляются структурами EPROCESS, связанными в циклический двусвязный список, где в его заголовке хранится переменная ядра PsActiveProcessHead.

Все такие списки строятся одинаково вокруг структуры LIST_ENTRY, определяемой следующим образом:


typedef struct _LIST_ENTRY {
   struct _LIST_ENTRY *Flink;
   struct _LIST_ENTRY *Blink;
} LIST_ENTRY, *PLIST_ENTRY;
 	   

Рисунок 3-2 отображает некий образец такого списка, содержащего заголовок и три экземпляра.

 

Рисунок 3-2


Циклический связанный список

Одна такая структура встроена в представляющую интерес реальную структуру. Например, в нашей структуре EPROCESS, её элемент ActiveProcessLinks обладает типом LIST_ENTRY, указывающим на свои следующий и предыдущий объекты LIST_ENTRY в других структурах EPROCESS. Сам заголовок списка хранится обособленно; в случае данного процесса это PsActiveProcessHead.

Для получения указателя на фактическую представляющую интерес структуру, адрес LIST_ENTRY можно добыть при помощи макроса CONTAINING_RECORD.

К примеру, допустим, вы желаете управлять списком структур с типом MyDataItem, определённым подобным образом:


struct MyDataItem {
    // некоторые элементы данных
    LIST_ENTRY Link;
    // ещё элементы данных
};
 	   

При работе с такими связанными списками, у нас имеется некий заголовок для самого списка, хранимый в переменной. Это означает, что естественный обход выполняется при помощи значения элемента Flink данного списка для указания на следующий LIST_ENTRY в данном списке. Обладая указателем на LIST_ENTRY, нам в действительности требуется MyDataItem, содержащий данный элемент записи списка. Именно здесь появляется CONTAINING_RECORD:


MyDataItem* GetItem(LIST_ENTRY* pEntry) {
    return CONTAINING_RECORD(pEntry, MyDataItem, Link);
}
 	   

Данный макрос выполняет правильный расчёт смещения и осуществляет приведение к фактическому типу данных (в моём примере MyDataItem).

Таблица 3-5 показывает распространённые функции для работы с такими связанными списками. Все операции применяют постоянное время.

Таблица 3-5. Функции для работы с циклическими связанными списками
Функция Описание

InitializeListHead

Инициализирует заголовок списка для создания пустого списка. Прямой и обратный указатели указывают на значение прямого указателя.

InsertHeadList

Вставляет элемент в голову данного списка.

InsertTailList

Вставляет элемент в хвост данного списка.

IsListEmpty

Проверяет является ли список пустым.

RemoveHeadList

Удаляет элемент из головы данного списка.

RemoveTailList

Удаляет элемент из хвоста данного списка.

ExInterlockedInsertHeadList

Атомарно вставляет элемент в голову списка применяя соответствующую заданную взаимную блокировку.

ExInterlockedInsertTailList

Атомарно вставляет элемент в хвост списка применяя соответствующую заданную взаимную блокировку.

ExInterlockedRemoveHeadList

Атомарно удаляет элемент из головы списка применяя соответствующую заданную взаимную блокировку.

Последние три функции из таблицы 3-4 осуществляют операцию атомарно при помощи примитива синхронизации с названием spin lock (взаимной блокировки). Взаимные блокировки обсуждаются в Главе 6.

Объект драйвера

Мы уже видели, что наша функция DriverEntry получает два параметра, первым выступает объект драйвера некоторого вида. Это частично документированная структура с названием DRIVER_OBJECT, определяемая в заголовках WDK. "Частичная документированность" означает что некоторые её элементы документированы для применения в драйвере, а некоторые нет. Эта структура выделяется самим ядром и частично инициализируется. Затем она предоставляется DriverEntry (и вплоть до того, пока сам драйвер не выгружает её в своей процедуре Unload). Основная роль самого драйвера в данный момент состоит в дальнейшей инициализации этой структуры для указания того, какие операции поддерживаются этим драйвером.

Одну из таких "операций" мы видели в своей Главе 2 - процедуру Unload. Другим важным набором операций для инициализации выступают операции с названием Dispatch Routines (Процедур организации). Это некий массив указателей на функции, хранимых в элементе DRIVER_OBJECT MajorFunction. Данный набор определяет какие операции поддерживает его драйвер, например, Create, Read, Write и так далее. Эти индексы определяются префиксом IRP_MJ_. Таблица 3-6 показывает некоторые употребимые коды основных функций и их значения.

Таблица 3-6. Коды основных распространённых функций
Основная функция Описание

IRP_MJ_CREATE (0)

Операция создания. Обычно активируется для вызовов CreateFile или ZwCreateFile.

IRP_MJ_CLOSE (2)

Операция закрытия. Обычно активируется для вызовов CloseHandle или ZwClose.

IRP_MJ_READ (3)

Операция считывания. Обычно активируется для ReadFile, ZwReadFile и аналогичных API чтения.

IRP_MJ_WRITE (4)

Операция записи. Обычно активируется для WriteFile, ZwWriteFile и аналогичных API записи.

IRP_MJ_DEVICE_CONTROL (14)

Общий вызов драйвера, активируется по причине вызовов DeviceIoControl или ZwDeviceIoControlFile.

IRP_MJ_INTERNAL_DEVICE_CONTROL (15)

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

IRP_MJ_SHUTDOWN (16)

Вызывается при останове самой системы когда этот драйвер зарегистрирован для уведомлений об останове при помощи IoRegisterShutdownNotification.

IRP_MJ_CLEANUP (18)

Активируется при закрытии самого последнего дескриптора объекта файла, но когда значение счётчика ссылок не ноль.

IRP_MJ_PNP (31)

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

IRP_MJ_POWER (22)

Активируемый Диспетчером управления питания обратный вызов Управления питанием. Обычно представляет интерес для драйверов обслуживания аппаратных средств или для фильтрации таких драйверов.

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

Например, наш драйвер Sample на данный момент не поддерживает никаких Процедур организации, что означает, что нет никакого способа взаимодействия с эти драйвером. По крайней мере, драйвер обязан поддерживать операции IRP_MJ_CREATE и IRP_MJ_CLOSE, чтобы допускать открытие дескриптора к одному из объектов устройств для этого драйвера. В своей следующей главе мы воплотим эти мысли на практике.

Атрибуты объекта

Одной из распространённых структур, встречающихся во многих API ядра, является OBJECT_ATTRIBUTES, определяемая следующим образом:


typedef struct _OBJECT_ATTRIBUTES {
    ULONG Length;
    HANDLE RootDirectory;
    PUNICODE_STRING ObjectName;
    ULONG Attributes;
    PVOID SecurityDescriptor;        // SECURITY_DESCRIPTOR
    PVOID SecurityQualityOfService;  // SECURITY_QUALITY_OF_SERVICE
} OBJECT_ATTRIBUTES;
typedef OBJECT_ATTRIBUTES *POBJECT_ATTRIBUTES;
typedef CONST OBJECT_ATTRIBUTES *PCOBJECT_ATTRIBUTES;
 	   

Данная структура обычно инициализируется при помощи макроса InitializeObjectAttributes, который позволяет определять все элементы этой структуры за исключением Length (настраивается автоматически самим макросом) и SecurityQualityOfService, который обычно не нужен. Вот описание его элементов:

  • ObjectName это значение названия подлежащего созданию/ размещению объекта, предоставленное в виде указателя на UNICODE_STRING. В некоторых случаях может быть достаточным установка значения в NULL. Например, ZwOpenProcess позволяет открывать дескриптор для процесса задавая его PID. Поскольку процессы не обладают названиями, значение ObjectName в таком случае должно инициализироваться в NULL.

  • RootDirectory это не обязательный каталог в пространстве имён Диспетчера объектов, когда такой объект является относительным. Если ObjectName определяет полностью определённое имя, RootDirectory должен быть установлен в NULL.

  • Attributes позволяет определять набор флагов, которые оказывают воздействие на саму операцию в запросе. Таблица 3- отображает определения флагов и их значения.

  • SecurityDescriptor это не обязательный описатель безопасности (SECURITY_DESCRIPTOR) для установки в своём вновь создаваемом объекте. NULL указывает что такой новый объект получает описатель безопасности по умолчанию на основании значения маркера вызывающей стороны.

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

Таблица 3-7. Флаги атрибутов объекта
Flag (OBJ_) Описание

INHERIT (2)

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

PERMANENT (0x10)

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

EXCLUSIVE (0x20)

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

OPENIF (0x80)

Открывать данный объект когда он имеется. В противном случае отказывать в такой операции (не создавать новый объект).

OPENLINK (0x100)

Когда подлежащий открытию объект является объектом символической ссылки, открывать сам объект символической ссылки вместо перехода по этой ссылки к её цели.

KERNEL_HANDLE (0x200)

Возвращаемый дескриптор должен быть дескриптором ядра. Дескрипторы ядра допустимы в любом контексте процесса и не могут применяться кодом режимом пользователя.

FORCE_ACCESS_CHECK (0x400)

Проверка доступа обязана осуществляться даже когда этот объект открыт в режиме доступа KernelMode.

IGNORE_IMPERSONATED_DEVICEMAP (0x800)

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

DONT_REPARSE (0x1000)

Не следуйте точке повторного синтаксического анализа при её возникновении. Вместо этого возвращается ошибка (STATUS_REPARSE_POINT_ENCOUNTERED) Точки повторного синтаксического анализа (reparse point) кратко рассматриваются в Главе 11.

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

Давайте рассмотрим пару образцов применения OBJECT_ATTRIBUTES. Первым выступает функция, которая открывает дескриптор процесса, определяемого идентификатором этого процесса. Для данной цели мы пользуемся API ZwOpenProcess, задаваемого следующим образом:


NTSTATUS ZwOpenProcess (
    _Out_       PHANDLE ProcessHandle,
    _In_        ACCESS_MASK DesiredAccess,
    _In_        POBJECT_ATTRIBUTES ObjectAttributes,
    _In_opt_    PCLIENT_ID ClientId);
 	   

Он пользуется ещё одной общей структурой, CLIENT_ID, которая содержит идентификаторы процесса и/ или потока:


typedef struct _CLIENT_ID {
    HANDLE UniqueProcess;   // PID, not handle
    HANDLE UniqueThread;    // TID, not handle
} CLIENT_ID;
typedef CLIENT_ID *PCLIENT_ID;
 	   

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

Вооружившись этими подробностями, вот функция открытия процесса:


NTSTATUS 
OpenProcess(ACCESS_MASK accessMask, ULONG pid, PHANDLE phProcess) {
    CLIENT_ID cid;
    cid.UniqueProcess = ULongToHandle(pid);
    cid.UniqueThread = nullptr;

    OBJECT_ATTRIBUTES procAttributes = 
        RTL_CONSTANT_OBJECT_ATTRIBUTES(nullptr, OBJ_KERNEL_HANDLE);
    return ZwOpenProcess(phProcess, accessMask, &procAttributes, &cid);
}
 	   

Применяемая функция ULongToHandle выполняет необходимые приведения с тем, чтобы наш компилятор оставался довольным (HANDLE содержит 64- бита в 64- битных системах, однако ULONG всегда 32- битный). Единственным применяемым в приводимом выше коде элементом из OBJECT_ATTRIBUTES являются флаги Attributes.

Вторым примером выступает функция, которая открывает дескриптор на файл для доступа на чтение при помощи API ZwOpenFile, определяемая так:


NTSTATUS ZwOpenFile(
    _Out_   PHANDLE FileHandle,
    _In_    ACCESS_MASK DesiredAccess,
    _In_    POBJECT_ATTRIBUTES ObjectAttributes,
    _Out_   PIO_STATUS_BLOCK IoStatusBlock,
    _In_    ULONG ShareAccess,
    _In_    ULONG OpenOptions);
 	   

Общее обсуждение параметров для ZwOpenFile зарезервировано для Главы 11, однако один момент очевиден: значение названия файла определяется при помощи имеющейся структуры OBJECT_ATTRIBUTES - для этого нет отдельного параметра. Вот вся функция открытия дескриптора для доступа к файлу на чтение целиком:


NTSTATUS OpenFileForRead(PCWSTR path, PHANDLE phFile) {
    UNICODE_STRING name;
    RtlInitUnicodeString(&name, path);

    OBJECT_ATTRIBUTES fileAttributes;
    InitializeObjectAttributes(&fileAttributes, &name,
        OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE, nullptr, nullptr);
    IO_STATUS_BLOCK ioStatus;
    return ZwOpenFile(phFile, FILE_GENERIC_READ, 
        &fileAttributes, &ioStatus, FILE_SHARE_READ, 0);
}
 	   

InitializeObjectAttributes применяется для инициализации имеющейся структуры OBJECT_ATTRIBUTES, хотя также можно воспользоваться и RTL_CONSTANT_OBJECT_ATTRIBUTES, так как мы всего лишь определяем значение имени и атрибутов. Заметьте, что необходимо преобразовывать переданный указатель строки C с окончанием NULL в UNICODE_STRING при помощи RtlInitUnicodeString.

Объекты устройства

Хотя объект драйвера и может выглядеть хорошим претендентом на общение с клиентами, это не так. Фактически, конечными пунктами взаимодействия с клиентами выступают объекты устройств. Объекты устройств являются экземплярами частично документированной структуры DEVICE_OBJECT. Без объектов устройств не с кем общаться. Это означает, что драйвером должен быть создан по крайней мере один объект устройств, а также ему должно быть присвоено название для возможности связи с ним клиентов.

Функция CreateFile (и её вариации) получает первый параметр с названием "имя файла" в документации, однако в действительности это должно быть место название объекта устройства, в котором некий действительный файл файловой системы это всего лишь конкретный представитель. Само название CreateFile нечто вводящее в заблуждение - значение слова "файл" здесь означает "объект файла". Открытие дескриптора для файла или устройства создаёт некий экземпляр структуры своего ядра FILE_OBJECT, другой частично документированной структуры.

Более точно, CreateFile получает Italic символическую ссылку, некий объект ядра, который знает как указывать на иной объект ядра. (Вы можете представлять себе символическую ссылку как аналог в понятии ярлыка файловой системы.) Все имеющиеся символические ссылки, которые могут применяться из вызовов режима пользователя CreateFile или CreateFile2 расположены в соответствующем каталоге Диспетчера объектов с названием ??. Вы можете наблюдать содержимое этого каталога при помощи инструментов Sysinternals WinObj. Рисунок 3-3показывает такой каталог (с названием Global?? в WinObj.

 

Рисунок 3-3


Символические ссылки каталога в WinObj

Некоторые из имён выглядят аналогично представлению в C: Aux, Con и так далее. И в самом деле, это допустимые "имена файлов" для вызовов CreateFile . Иные записи выглядят как длинные загадочные строки, а в действительности они вырабатываются имеющейся системой ввода/ вывода для драйверов на основе аппаратных средств, которые вызывают API IoRegisterDeviceInterface. Такие типы символических ссылок не слишком помогают целям данной книги.

Большинство имеющихся в каталоге \?? символических ссылок указывают на некое название устройства в каталоге \Device. Имеющиеся в этом каталоге названия не доступны напрямую вызывающей стороне режима пользователя. Однако к ним можно получать доступ от вызывающей стороны ядра при помощи API IoGetDeviceObjectPointer.

Канонический пример это драйвер для Process Explorer. Когда процесс Process Explorer запускается с правами администратора, он устанавливает некий драйвер. Такой драйвер придаёт Process Explorer дополнительные возможности, которые могут быть получены вызывающими объектами режима пользователя, даже когда они работают с повышенными правами. К примеру, Process Explorer в своём диалоговом окне Threads для процесса способен отображать полный стек вызовов для потока, включая функции в режиме ядра. Такой вид сведений невозможно получать в режиме пользователя; именно драйвер предоставляет такие недостающие сведения.

Такой устанавливаемый Process Explorer драйвер создаёт отдельный объект устройства с тем, чтобы его Process Explorer обладал возможностью открывать дескриптор к такому устройству и выполнять запросы. Это означает, что его объект устройства должен обладать именем, а также обязан иметь символическую ссылку в каталоге ??; и она там присутствует, с названием PROCEXP152, вероятно, указывающим на версию 15.2 (на момент написания этих строк). Рисунок 3-4 показывает такую символическую ссылку в WinObj.

 

Рисунок 3-4


Символические ссылки Process Explorer в WinObj

Обратите внимание, что эта символическая ссылка для устройства Process Explorer указывает на \Device\PROCEXP152, что является внутренним названием, доступным исключительно для вызывающей стороны ядра (а также, как это будет показано в нашем следующем разделе, для естественных API NtOpenFile и NtCreateFile). Реальный вызов CreateFile, осуществляемый основанным на символической ссылке Process Explorer (либо любого иного клиента) обязан быть присоединённым спереди \\.\.. Это требуется для того, чтобы синтаксический анализатор Диспетчера ввода/ вывода не полагал, что сама строка "PROCEXP152" ссылается на некий файл без расширения в своём текущем каталоге. Именно так Process Explorer открыл бы дескриптор к своему объекту устройства (обращаем внимание на двойной обратный слэш, поскольку обратный слэш выступает экранирующим символом в C/C++):


HANDLE hDevice = CreateFile(L"\\\\.\\PROCEXP152",
    GENERIC_WRITE | GENERIC_READ, 0, nullptr, OPEN_EXISTING, 
    0, nullptr);
 	   
[Совет]Совет

Начиная с C++ и далее, вы можете писать строки без экранирования таким символом обратного слэша. В приводимом выше коде значение пути к устройству может быть записано так: LR"(\\.\PROCEXP152)". L указывает на Unicode (как всегда), тогда как всё между R"( и )" не экранируется.

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

Вы можете испробовать самостоятельно приведённый выше код. Если Process Explorer хотя бы раз запускался с повышенными правами в вашей системе после её запуска, его драйвер должен быть запущен (вы можете убедиться в этом самим данным инструментом), а вызов к CreateFile будет успешным когда сам клиент работает с повышенными правами.

Драйвер создаёт объект драйвера при помощи функции IoCreateDevice. Данная функция выполняет выделение и инициализацию структуры объекта устройства и возвращает указатель на него своей вызывающей стороне. Данный экземпляр объекта устройства сохраняется в элементе соответствующего DeviceObject из структуры DRIVER_OBJECT. Когда создано более одного объекта устройства, они формируют обособленный связанный список, в котором элемент NextDevice из DRIVER_OBJECT указывает на свой следующий объект устройства. Обратите внимание, что все объекты устройств вставляются в голову этого списка, а потому самый первый созданный объект хранится последним; его NextDevice указывает на NULL. Такие зависимости отображены на Рисунке 3-5.

 

Рисунок 3-5


Объекты Драйвера и Устройства

Непосредственное открытие объекта

Наличие символической ссылки упрощает открытие дескриптора к устройству при помощи документированного API режима пользователя CreateFile (или из API ZwOpenFile в самом ядре). Тем не менее, порой полезно обладать возможностью открывать объекты устройств без прохождения через символическую ссылку. Например, объект устройства может не обладать символической ссылкой, поскольку его драйвер (по какой- то из причин) вовсе её не предоставил.

Собственная функция NtOpenFileNtCreateFile) может применяться для прямого открытия объекта устройства. Microsoft никогда не рекомендует применять собственные API, однако эти функции являются чем- то документированным для применения в режиме пользователя. Их определение доступно в файле заголовка <Winternl.h>:


NTAPI NtOpenFile (
    OUT PHANDLE FileHandle,
    IN  ACCESS_MASK DesiredAccess,
    IN  POBJECT_ATTRIBUTES ObjectAttributes,
    OUT PIO_STATUS_BLOCK IoStatusBlock,
    IN  ULONG ShareAccess,
    IN  ULONG OpenOptions);
 	   

Обратите внимание на аналогичность с той ZwOpenFile, которую мы применяли в более ранней секции - это именно тот же самый прототип функции, только активируемый здесь из режима пользователя, причём в конечном итоге для запуска в NtOpenFile внутри Диспетчера ввода, вывода. Данная функция требует применения структуры OBJECT_ATTRIBUTES, описанной ранее в этой главе.

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

Приводимый выше прототип использует такие макросы как IN, OUT и прочие. Они были заменены аннотацией SAL (Язык аннотации исходного кода - Source (Code) Annotation Language). К сожалению, некоторые заголовки файлов ещё не были преобразованы в SAL.

Для демонстрации применения NtOpenFile из режима пользователя мы создадим некое приложение воспроизведения отдельного звука. Обычно, такую службу предоставляет API Windows Beep режима пользователя:


BOOL Beep(
    _In_ DWORD dwFreq,
    _In_ DWORD dwDuration);
 	   

Данная функция принимает значение частоты для воспроизведения (в Герцах), а также значение длительности воспроизведения, в миллисекундах. Данная функция синхронная, что означает что она не выполняет возврат пока не истечёт установленная длительность.

Такой API Beep работает через вызов устройства с названием \Device\Beep (вы можете обнаружить его в WinObj), однако этот драйвер устройства beep не создаёт для него символической ссылки. Тем не менее, мы можем открыть дескриптор к своему устройству beep при помощи NtOpenFile. Затем, для воспроизведения звука мы можем воспользоваться функцией DeviceIoContol с правильными параметрами. Хотя и не слишком сложно повторно спроектировать работу драйвера звукового сигнала, к счастью, нам это не требуется. SDK предоставляет соответствующий файл <ntddbeep.h> с необходимыми определениями, включая значение названия устройства само по себе.

Мы начнём с создания приложения Консоли C++ в Visual Studio. Прежде чем мы получим свою функцию main, нам необходимы некоторые #includes:


#include <Windows.h>
#include <winternl.h>
#include <stdio.h>
#include <ntddbeep.h>
 	   

<winternl.h> предоставляет значение определения для NtOpenFile (и соответствующие структуры данных), в то время как <ntddbeep.h> предоставляет необходимые определения для всего относительно beep.

Поскольку мы будем пользоваться NtOpenFile, нам надлежит также выполнить соединение для NtDll.Dll, что можно сделать путём добавления #pragma в свой код или же добавить эту библиотеку в настройки компоновщика в свойствах своего проекта. Давайте следовать первым способом, поскольку он проще, и не связываться со свойствами своего проекта:


#pragma comment(lib, "ntdll")
 	   
[Замечание]Замечание

Без приведённой выше связи, наш компоновщик испытал бы проблему некой ошибки "unresolved external" (неразрешимой внешней ссылки).

Теперь мы можем приступить к написанию main, где мы принимаем необязательные параметры командной строки, указывающие значения частоты и продолжительности для воспроизведения:


int main(int argc, const char* argv[]) {
    printf("beep [<frequency> <duration_in_msec>]\n");
    int freq = 800, duration = 1000;
    if (argc > 2) {
        freq = atoi(argv[1]);
        duration = atoi(argv[2]);
    }
 	   

Наш следующий шаг состоит в открытии дескриптора этого устройства при помощи NtOpenFile:


HANDLE hFile;
OBJECT_ATTRIBUTES attr;
UNICODE_STRING name;
RtlInitUnicodeString(&name, L"\\Device\\Beep");
InitializeObjectAttributes(&attr, &name, OBJ_CASE_INSENSITIVE, 
    nullptr, nullptr);
IO_STATUS_BLOCK ioStatus;
NTSTATUS status = ::NtOpenFile(&hFile, GENERIC_WRITE, &attr, &ioStatus, 0, 0);
 	   

Строку инициализации названия устройства можно заменить следующей:


RtlInitUnicodeString(&name, DD_BEEP_DEVICE_NAME_U);
 	   

Такой макрос DD_BEEP_DEVICE_NAME_U удобно предоставляется как часть <ntddbeep.h>.

Когда такой код завершается успешно, мы можем воспроизвести необходимый звук.Для этого мы вызываем DeviceIoControl с управляющим кодом, определяемым в <ntddbeep.h> и применяем заданную там структуру, а также заполняем значения частоты и продолжительности:


if (NT_SUCCESS(status)) {
    BEEP_SET_PARAMETERS params;
    params.Frequency = freq;
    params.Duration = duration;
    DWORD bytes;
    //
    // воспроизведение звука
    //
    printf("Playing freq: %u, duration: %u\n", freq, duration);
    ::DeviceIoControl(hFile, IOCTL_BEEP_SET, &params, sizeof(params), 
        nullptr, 0, &bytes, nullptr);

    //
    // звук начинает воспроизводиться и вызов возвращается немедленно
    // Дожидаемся пока само прикладное приложение не закроется
    //
    ::Sleep(duration);
    ::CloseHandle(hFile);
}
 	   

Наш буфер входных данных, передаваемый в DeviceIoControl, обязан быть структурой BEEP_SET_PARAMETERS, которую мы передаём совместно с её размером. Самым последним фрагментом в нашей мозаике выступает применение APISleep для ожидания на основе заданной продолжительности, в противном случае имеющийся дескриптор для нашего устройства был бы закрыт, а само звучание отсечено.

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

Напишите приложение,которое воспроизводит некий массив звуков, воспользовавшись приведённым выше кодом.

Выводы

В данной главе мы рассмотрели некоторые основополагающие структуры данных, понятия и API ядра. В своей следующей главе мы соберём драйвер целиком, а также приложение клиента, расширяя представленные до сих пор сведения.