- PVSM.RU - https://www.pvsm.ru -

Актуальный гайд по написанию простого Windows-драйвера

Небольшая предыстория

Совсем недавно я перешёл на 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 приложений и попробовать что-то новое. Критика и дополнения приветствуются, постараюсь по возможности оперативно исправлять все ошибки. Теперь точно всё :)

Установка компонентов

  1. В данной статье я не буду рассматривать вопрос о создании Hello world драйвера, этот вопрос полностью разобран тут [1]. Рекомендую чётко следовать всем указаниям этого руководства, для того чтобы собрать и запустить свой первый драйвер. Это может занять некоторое время, но как можно достигнуть цели не пройдя никакого пути? Если у вас возникнут проблемы с этим процессом, я с радостью помогу вам в комментариях.

  2. Полезно будет установить утилиту WinObj, которая позволяет вам просматривать имена файлов устройств и находить символические ссылки, которые на них указывают. Качаем тут [2].

  3. Также неплохой утилитой является DebugView [3]. Она позволяет вам просматривать отладочные сообщения, которые отправляются из ядра. Для этого необходимо включить опцию 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 описаны константы, аналогичные комментарию, однако у меня компилятор их не видел, и мне пришлось вставлять строку в сыром виде. Ссылка на типы разрешений тут [4].

Далее необходимо настроить дескриптор безопасности для устройства. Если коротко, то это реакция устройства на обращения к своему файлу.

// 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);
}

При запуске вы должны получить такой результат:

Актуальный гайд по написанию простого Windows-драйвера - 2

Заключение

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

Автор: Константин

Источник [5]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/news/387143

Ссылки в тексте:

[1] тут: https://learn.microsoft.com/en-us/windows-hardware/drivers/gettingstarted/writing-a-very-small-kmdf--driver

[2] тут: https://learn.microsoft.com/en-us/sysinternals/downloads/winobj

[3] DebugView: https://learn.microsoft.com/en-us/sysinternals/downloads/debugview

[4] тут: https://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/sddl-for-device-objects?redirectedfrom=MSDN

[5] Источник: https://habr.com/ru/articles/761512/?utm_source=habrahabr&utm_medium=rss&utm_campaign=761512