Небольшая предыстория
Совсем недавно я перешёл на 3 курс технического университета, где на нашу группу вновь навалилась целая куча новых предметов. Одним из них являлся ИиУВМ(Интерфейсы и устройства вычислительных машин), на котором нам предстояло изучать, как те или иные части компьютера взаимодействуют между собой. Уже на 2 лабораторной работе нам выдали задание, суть которого заключалась в переборе всех устройств на PCI шине и получению их Vendor и Device ID через порты ввода-вывода. Для упрощения задачи преподаватель выдал ссылку на специальный драйвер и dll библиотеку под Windows XP и заявил, что это оптимальный вариант выполнения работы, так как по другому сделать её невозможно. Перспектива писать код под устаревшую OS меня не радовала, а слова про "невозможность" другой реализации лишь разожгли интерес. После недолгих поисков я выяснил, что цель может быть достигнута с помощью самописного драйвера.
В этой статье я хочу поделиться своим опытом, полученным в ходе длительных блужданий по документации Microsoft и попыток добиться от ChatGPT вменяемого ответа. Если вам интересно системное программирование под Windows - добро пожаловать под кат.
Важно знать
Современные драйвера под Windows могут быть основаны на одном из двух фреймворков: KMDF и UMDF (Kernel Mode Driver Framework и User Mode Driver Framework). В данной статье будет рассматриваться разработка KMDF драйвера, так как на него наложено меньше ограничений в отношении доступных возможностей. До UMDF драйверов я пока не добрался, как только поэкспериментирую с ними, обязательно напишу статью!
Разработка драйверов обычно происходит с помощью 2 компьютеров (или компьютера и виртуальной машины), на одном вы пишите код и отлаживаете программу (хост-компьютер), на другом вы запускаете драйвер и молитесь, чтобы система не легла после ваших манипуляций (целевой компьютер).
Перед началом работы с драйверами обязательно создайте точку восстановления, иначе вы рискуете положить свою систему так, что она перестанет запускаться. В таком случае выходом из ситуации станет откат Windows в начальное состояние (то есть в состояние при установке), что уничтожит не сохранённые заранее файлы на системном диске. Автор данной статьи наступил на эти грабли и совсем забыл скопировать свои игровые сохранения в безопасное место...
Первый запуск драйвера после добавления обработки IOCTL запросов на моём компьютере приводит к падению системы с необходимостью откатываться на точку восстановления. После этого драйвер запускается и работает без проблем. Самое странное, что если перенести тот же код в новый проект и запустить этот драйвер, то падения не происходит. Причины этого найти мне не удалось, так что прошу помощи в комментариях.
Я не профессиональный разработчик и данная статья лишь способ проложить дорогу для тех, кому так же, как и мне, стало интересно выйти за рамки привычных user mode приложений и попробовать что-то новое. Критика и дополнения приветствуются, постараюсь по возможности оперативно исправлять все ошибки. Теперь точно всё :)
Установка компонентов
-
В данной статье я не буду рассматривать вопрос о создании Hello world драйвера, этот вопрос полностью разобран тут. Рекомендую чётко следовать всем указаниям этого руководства, для того чтобы собрать и запустить свой первый драйвер. Это может занять некоторое время, но как можно достигнуть цели не пройдя никакого пути? Если у вас возникнут проблемы с этим процессом, я с радостью помогу вам в комментариях.
-
Полезно будет установить утилиту WinObj, которая позволяет вам просматривать имена файлов устройств и находить символические ссылки, которые на них указывают. Качаем тут.
-
Также неплохой утилитой является DebugView. Она позволяет вам просматривать отладочные сообщения, которые отправляются из ядра. Для этого необходимо включить опцию Kernel Capture на верхней панели.
Теперь с полным баком всяких утилит и библиотек переходим к самому интересному.
Начинаем веселье
В результате выполнения всех пунктов руководства Microsoft вы должны были сформировать файл драйвера cо следующим содержимым (комментарии добавлены от меня):
#include <ntddk.h>
#include <wdf.h>
// Объявление прототипа функции входа в драйвер, аналогично main() у обычных программ
DRIVER_INITIALIZE DriverEntry;
// Объявление прототипа функции для создания экземпляра устройства
// которым будет управлять наш драйвер
EVT_WDF_DRIVER_DEVICE_ADD KmdfHelloWorldEvtDeviceAdd;
// Пометки __In__ сделаны для удобства восприятия, на выполнение кода они не влияют.
// Обычно функции в пространстве ядра не возвращают данные через return
// (return возвращает статус операции)
// так что при большом числе аргументов такие пометки могут быть полезны
// чтобы не запутаться
NTSTATUS
DriverEntry(
// Фреймворк передаёт нам этот объект, никаких настроек мы для него не применяем
_In_ PDRIVER_OBJECT DriverObject,
// Путь, куда наш драйвер будет помещён
_In_ PUNICODE_STRING RegistryPath
)
{
// NTSTATUS переменная обычно используется для возвращения
// статуса операции из функции
NTSTATUS status = STATUS_SUCCESS;
// Создаём объект конфигурации драйвера
// в данный момент нас не интерсует его функциональность
WDF_DRIVER_CONFIG config;
// Макрос, который выводит сообщения. Они могут быть просмотрены с помощью DbgView
KdPrintEx((DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL, "HelloWorld: DriverEntryn"));
// Записываем в конфиг функцию-инициализатор устройства
WDF_DRIVER_CONFIG_INIT(&config,
KmdfHelloWorldEvtDeviceAdd
);
// Создаём объект драйвера
status = WdfDriverCreate(DriverObject,
RegistryPath,
WDF_NO_OBJECT_ATTRIBUTES,
&config,
WDF_NO_HANDLE
);
return status;
}
NTSTATUS
KmdfHelloWorldEvtDeviceAdd(
_In_ WDFDRIVER Driver, // Объект драйвера
_Inout_ PWDFDEVICE_INIT DeviceInit // Структура-иницализатор устройства
)
{
// Компилятор ругается, если мы не используем какие-либо параметры функции
// (мы не используем параметр Driver)
// Это наиболее корректный способ избежать этого предупреждения
UNREFERENCED_PARAMETER(Driver);
NTSTATUS status;
// Объявляем объект устройства
WDFDEVICE hDevice;
// Снова вывод сообщения
KdPrintEx((DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL, "KmdfHelloWorld: DeviceAddn"));
// Создаём объект устройства
status = WdfDeviceCreate(&DeviceInit,
WDF_NO_OBJECT_ATTRIBUTES,
&hDevice
);
// Утрированный пример того, как можно проверить результат выполнения операции
if (!NT_SUCCESS(status)) {
return STATUS_ERROR_PROCESS_NOT_IN_JOB;
}
return status;
}
Данный драйвер не делает ничего интересного, кроме 2-х отладочных сообщений и создания экземпляра устройства. Пока мы не можем общаться с устройством из пространства пользователя. Нужно это исправить!
Весь дальнейший код будет добавляться в функциюKmdfHelloWorldEvtDeviceAdd
Для достижения цели необходимо создать файл устройства и символическую ссылку на него в пространстве ядра. С этим нам помогут функции WdfDeviceInitAssignName
и WdfDeviceCreateSymbolicLink
Однако просто вызвать их, передав имена файлов, не получится, нужна подготовка.
Начнём с тех самых имён. Они представляют собой строки в кодировке UTF-8. Следующий пример показывает способ инициализации строки в пространстве ядра.
UNICODE_STRING symLinkName = { 0 };
UNICODE_STRING deviceFileName = { 0 };
RtlInitUnicodeString(&symLinkName, L"\DosDevices\PCI_Habr_Link");
RtlInitUnicodeString(&deviceFileName, L"\Device\PCI_Habr_Dev");
Желательно придерживаться показанного в примере стиля именования файла устройства, то есть начинаться имя должно с префикса Device
Следующим шагом становится установление разрешения на доступ к устройству. Оно может быть доступно или из пространства ядра, или из системных или запущенных администратором программ. Внимание на код.
UNICODE_STRING securitySetting = { 0 };
RtlInitUnicodeString(&securitySetting, L"D:P(A;;GA;;;SY)(A;;GA;;;BA)");
// SDDL_DEVOBJ_SYS_ALL_ADM_ALL
WdfDeviceInitAssignSDDLString(DeviceInit, &securitySetting);
Комментарий капсом - это пометка какой тип разрешения на устройство здесь выставлен. В документации Microsoft описаны константы, аналогичные комментарию, однако у меня компилятор их не видел, и мне пришлось вставлять строку в сыром виде. Ссылка на типы разрешений тут.
Далее необходимо настроить дескриптор безопасности для устройства. Если коротко, то это реакция устройства на обращения к своему файлу.
// FILE_DEVICE_SECURE_OPEN означает, что устройство будет воспринимать обращения
// к файлу устройства как к себе
WdfDeviceInitSetCharacteristics(DeviceInit, FILE_DEVICE_SECURE_OPEN, FALSE);
Наконец-то мы можем создать файл устройства:
status = WdfDeviceInitAssignName(
DeviceInit,
&deviceFileName
);
// Напоминание о том, что результат критичных для драйвера функций нужно проверять
if (!NT_SUCCESS(status)) {
WdfDeviceInitFree(DeviceInit);
return status;
}
Переходим к символической ссылке, следующий код должен быть вставлен после функции WdfDeviceCreate
status = WdfDeviceCreateSymbolicLink(
hDevice,
&symLinkName
);
Итоговый код функции KmdfHelloWorldEvtDeviceAdd
должен иметь следующий вид:
NTSTATUS
KmdfHelloWorldEvtDeviceAdd(
_In_ WDFDRIVER Driver, // Объект драйвера
_Inout_ PWDFDEVICE_INIT DeviceInit // Структура-иницализатор устройства
)
{
// Компилятор ругается, если мы не используем какие-либо параметры функции
// (мы не используем параметр Driver)
// Это наиболее корректный способ избежать этого предупреждения
UNREFERENCED_PARAMETER(Driver);
NTSTATUS status;
UNICODE_STRING symLinkName = { 0 };
UNICODE_STRING deviceFileName = { 0 };
UNICODE_STRING securitySetting = { 0 };
RtlInitUnicodeString(&symLinkName, L"\DosDevices\PCI_Habr_Link");
RtlInitUnicodeString(&deviceFileName, L"\Device\PCI_Habr_Dev");
RtlInitUnicodeString(&securitySetting, L"D:P(A;;GA;;;SY)(A;;GA;;;BA)");
// SDDL_DEVOBJ_SYS_ALL_ADM_ALL
WdfDeviceInitAssignSDDLString(DeviceInit, &securitySetting);
WdfDeviceInitSetCharacteristics(DeviceInit, FILE_DEVICE_SECURE_OPEN, FALSE);
status = WdfDeviceInitAssignName(
DeviceInit,
&deviceFileName
);
// Hезультат критичных для драйвера функций нужно проверять
if (!NT_SUCCESS(status)) {
WdfDeviceInitFree(DeviceInit);
return status;
}
// Объявляем объект устройства
WDFDEVICE hDevice;
// Снова вывод сообщения
KdPrintEx((DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL,"HelloWorld: EvtDeviceAddn"));
// Создаём объект устройства
status = WdfDeviceCreate(&DeviceInit,
WDF_NO_OBJECT_ATTRIBUTES,
&hDevice
);
status = WdfDeviceCreateSymbolicLink(
hDevice,
&symLinkName
);
// Утрированный пример того, как можно проверить результат выполнения операции
if (!NT_SUCCESS(status)) {
return STATUS_ERROR_PROCESS_NOT_IN_JOB;
}
return status;
}
После сборки и установки драйвера, в утилите WinObj по пути "GLOBAL??" вы сможете увидеть следующее:

Общаемся с устройством
Взаимодействие с устройством происходит через IOCTL запросы. В режиме пользователя есть специальная функция, которая позволяет отправлять их устройству и получать данные в ответ. Пока сконцентрируемся на обработке этих запросов на стороне драйвера.
С чего начнётся этот этап? Правильно! С инициализации необходимых компонентов. Следите за руками:
WDF_IO_QUEUE_CONFIG ioQueueConfig;
WDFQUEUE hQueue;
// Инициализируем настройки очереди, в которую будут помещаться запросы
// Параметр WdfIoQueueDispatchSequential говорит то, что запросы будут обрабатываться
// по одному в порядке очереди
WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(
&ioQueueConfig,
WdfIoQueueDispatchSequential
);
// Обработчик HandleIOCTL будет вызываться в ответ на функцию DeiviceIOControl
// Уже скоро мы создадим его
ioQueueConfig.EvtIoDeviceControl = HandleIOCTL;
// Создаём очередь
status = WdfIoQueueCreate(
hDevice, // Объект устройства уже должен существовать
&ioQueueConfig,
WDF_NO_OBJECT_ATTRIBUTES,
&hQueue
);
if (!NT_SUCCESS(status)) {
return status;
}
Очередь есть, но нет обработчика. Работаем:
// Выглядит страшно, но по сути код может быть любым числом, этот макрос использован
// для более подробного описания возможностей IOCTL кода для программиста
#define IOCTL_CODE CTL_CODE(FILE_DEVICE_UNKNOWN, 0x3000, METHOD_BUFFERED, GENERIC_READ | GENERIC_WRITE)
VOID HandleIOCTL(
_In_ WDFQUEUE Queue, // Объект очереди, применения ему я пока не нашёл
_In_ WDFREQUEST Request, // Из этого объекта мы извлекаем входной и выходной буферы
_In_ size_t OutputBufferLength,
_In_ size_t InputBufferLength,
_In_ ULONG IoControlCode // IOCTL код, с которым к устройству обратились
)
{
NTSTATUS status = STATUS_SUCCESS;
UNREFERENCED_PARAMETER(Queue);
UNREFERENCED_PARAMETER(InputBufferLength);
UNREFERENCED_PARAMETER(OutputBufferLength);
UNREFERENCED_PARAMETER(Request);
switch (IoControlCode)
{
case IOCTL_CODE:
{
// Обрабатываем тут
break;
}
}
// По сути своей return из обработчика
// Используется, если запрос не возваращает никаких данных
WdfRequestComplete(Request, status);
}
Вот мы уже и на финишной прямой, у нас есть очередь, есть обработчик, но последний не возвращает и не принимает никаких данных. Сделаем так, чтобы обработчик возвращал нам сумму переданных чисел.
Объявим 2 структуры, из их названий будет понятно для чего они будут использоваться. В режиме ядра лучше использовать системные типы данных, такие как USHORT, UCHAR и другие.
struct DeviceRequest
{
USHORT a;
USHORT b;
};
struct DeviceResponse
{
USHORT result;
};
Обновлённая функция обработки IOCTL запроса:
VOID HandleIOCTL(
_In_ WDFQUEUE Queue, // Объект очереди, применения ему я пока не нашёл
_In_ WDFREQUEST Request, // Из этого объекта мы извлекаем входной и выходной буферы
_In_ size_t OutputBufferLength,
_In_ size_t InputBufferLength,
_In_ ULONG IoControlCode // IOCTL код, с которым к устройству обратились
)
{
NTSTATUS status = STATUS_SUCCESS;
UNREFERENCED_PARAMETER(Queue);
UNREFERENCED_PARAMETER(InputBufferLength);
UNREFERENCED_PARAMETER(OutputBufferLength);
size_t returnBytes = 0;
switch (IoControlCode)
{
case IOCTL_CODE:
{
struct DeviceRequest request_data = { 0 };
struct DeviceResponse *response_data = { 0 };
PVOID buffer = NULL;
PVOID outputBuffer = NULL;
size_t length = 0;
// Получаем указатель на буфер с входными данными
status = WdfRequestRetrieveInputBuffer(Request,
sizeof(struct DeviceRequest),
&buffer,
&length);
// Проверка на то, что мы получили буфер и он соотвествует ожидаемому размеру
// Очень важно делать такие проверки, чтобы не положить систему :)
if (length != sizeof(struct DeviceRequest) || !buffer)
{
status = STATUS_INVALID_DEVICE_REQUEST;
break;
}
request_data = *((struct DeviceRequest*)buffer);
// Получаем указатель на выходной буфер
status = WdfRequestRetrieveOutputBuffer(Request,
sizeof(struct DeviceResponse),
&outputBuffer,
&length);
if (length != sizeof(struct DeviceResponse) || !outputBuffer)
{
status = STATUS_INVALID_DEVICE_REQUEST;
break;
}
response_data = (struct DeviceResponse*)buffer;
// Записываем в выходной буфер результат
response_data->result = request_data.a + request_data.b;
// Вычисляем сколько байт будет возвращено в ответ на данный запрос
returnBytes = sizeof(struct DeviceResponse);
break;
}
}
// Функция-return изменилась, так как теперь мы возвращаем данные
WdfRequestCompleteWithInformation(Request, status, returnBytes);
}
Последний шаг - программа в режиме пользователя. Cоздаём обычный С или C++ проект и пишем примерно следующее:
#include <windows.h>
#include <iostream>
//Эта часть аналогична тем же объявлениям в драйвере
//================
#define IOCTL_CODE CTL_CODE(FILE_DEVICE_UNKNOWN, 0x3000, METHOD_BUFFERED, GENERIC_READ | GENERIC_WRITE)
struct DeviceRequest
{
USHORT a;
USHORT b;
};
struct DeviceResponse
{
USHORT result;
};
// ===========================
int main()
{
HANDLE hDevice = CreateFileW(L"\??\PCI_Habr_Link",
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL);
DeviceRequest request = { 0 };
request.a = 10;
request.b = 15;
LPVOID input = (LPVOID)&request;
DeviceResponse response = { 0};
LPVOID answer = (LPVOID)&response;
DWORD bytes = 0;
bool res = DeviceIoControl(hDevice, IOCTL_CODE, input, sizeof(DeviceRequest),
answer, sizeof(DeviceResponse), &bytes, NULL);
response = *((DeviceResponse*)answer);
std::cout << "Sum : " << response.result << std::endl;
char ch;
std::cin >> ch;
CloseHandle(hDevice);
}
При запуске вы должны получить такой результат:

Заключение
Собственно, на этом всё. В конечном итоге мы получили драйвер, способный принимать и отвечать на запросы пользовательских программ. Дальнейшие модификации проекта ограничиваются лишь вашей фантазией.
Автор: Константин