Система Plug and Play в Windows давно переросла своё первоначальное назначение "вставил плату - заработало". Сегодня через неё живут не только реальные железки, но и устройства, которых физически не существует: виртуальные сетевые адаптеры, последовательные порты-заглушки, программные камеры, целые контроллеры, порождённые другими драйверами. Разберём, как PnP-менеджер обнаруживает и оживляет устройства, какие пути есть у создателя виртуального железа и где в этом деле подвох.

Как PnP-менеджер видит мир устройств

Внутри системы каждое устройство существует как узел дерева устройств. Узел создаётся, когда кто-то перечисляет новое дитя на шине, и дальше вокруг него начинается знакомая суматоха: читаются идентификаторы, подбирается драйвер, строится стек из функционального и фильтровых драйверов, устройству рассылаются ресурсы и IRP запуска. Вся эта идиллия запускается одним ключевым действием: какой-то драйвер должен заявить менеджеру "у меня на шине появилось новое". Для настоящего железа этим занимается драйвер шины - PCI, USB, ACPI. Для виртуального устройства тем же балом может управлять собственный драйвер шины.

Перечисление делится на два семейства. Аппаратное перечисление происходит через запросы отношений шины (BusRelations), на которые драйвер отвечает списком детей. Программное перечисление устроено проще: установщик создаёт раздел в реестре под веткой перечислителя ROOT, и виртуальная шина ROOT сама порождает узлы для всего, что там описано. Отсюда исторически известный класс "root-enumerated" устройств: простейший способ получить виртуальное устройство без написания собственного драйвера шины, когда программное обеспечение создаёт запись, а PnP-менеджер поднимает по ней стек.

Простейший путь к виртуальному устройству, root-enumerated драйвер через запись в реестре

Когда цель - виртуальное устройство без динамики, без детей и без собственной шины, достаточно объявить его через реестр и inf-файл. Драйвер на KMDF для такого случая пишется в каркасном стиле: нет чтения ресурсов с шины, весь контекст выделяется самим драйвером, а все события приходят по внутренним каналам. Inf у такого драйвера отличается парой приёмов: секция установки опирается на идентификатор вида ROOT\MyDevice, а параметры прописываются вручную:

[Version]
Signature   = "$Windows NT$"
Class       = Ports
ClassGuid   = {4D36E978-E325-11CE-BFC1-08002BE10318}
Provider    = %ProviderName%
DriverVer   = 10/07/2025,1.0.0.0
CatalogFile = myport.cat
PnpLockdown = 1

[DestinationDirs]
DefaultDestDir = 13

[SourceDisksNames]
1 = %DiskName%,,,""

[SourceDisksFiles]
myport.sys = 1,,

[Manufacturer]
%ProviderName% = Standard,NTamd64

[Standard.NTamd64]
%DeviceName% = MyInstall, ROOT\MyVirtualPort

[MyInstall.NT]
CopyFiles = Drivers_Dir

[Drivers_Dir]
myport.sys

[MyInstall.NT.Services]
AddService = myport,%SPSVCINST_ASSOCSERVICE%,MyService_Inst

[MyService_Inst]
DisplayName    = %DeviceName%
ServiceType    = 1
StartType      = 3
ErrorControl   = 1
ServiceBinary  = %13%\myport.sys

[Strings]
ProviderName = "Пример"
DeviceName   = "Виртуальный последовательный порт"
DiskName     = "Установочный диск"
SPSVCINST_ASSOCSERVICE = 0x00000002

Ключевых мест здесь три: идентификатор ROOT\MyVirtualPort в секции производителя, каталожный файл с подписью и значение DriverVer, которое при каждом обновлении обязано расти. Установка из командной строки выполняется штатными утилитами:

pnputil /add-driver myport.inf /install
pnputil /enum-drivers
pnputil /enum-devices /connected /class Ports
devcon install myport.inf ROOT\MyVirtualPort
devcon findall ROOT\*
devcon remove ROOT\MyVirtualPort

Узел root-enumerated устройства после установки виден в реестре, и проверка этой ветки решает половину вопросов из серии "почему устройство не появилось":

reg query "HKLM\SYSTEM\CurrentControlSet\Enum\ROOT\MYVIRTUALPORT" /s

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

HDEVINFO set = SetupDiGetClassDevsW(&GUID_DEVINTERFACE_MYPORT, NULL, NULL,
                                    DIGCF_PRESENT | DIGCF_DEVICEINTERFACE);

SP_DEVICE_INTERFACE_DATA ifdata = { 0 };
ifdata.cbSize = sizeof(ifdata);

SetupDiEnumDeviceInterfaces(set, NULL, &GUID_DEVINTERFACE_MYPORT, 0, &ifdata);

DWORD needed = 0;
SetupDiGetDeviceInterfaceDetailW(set, &ifdata, NULL, 0, &needed, NULL);

PSP_DEVICE_INTERFACE_DETAIL_DATA_W detail =
    (PSP_DEVICE_INTERFACE_DETAIL_DATA_W)malloc(needed);
detail->cbSize = sizeof(*detail);
SetupDiGetDeviceInterfaceDetailW(set, &ifdata, detail, needed, NULL, NULL);

HANDLE h = CreateFileW(detail->DevicePath, GENERIC_READ | GENERIC_WRITE,
                       0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);

Символическую ссылку из поля DevicePath можно получить и проще, через CM_Get_Device_Interface_List, если не нужен весь набор сведений об устройстве. Единственные ресурсы, которые драйвер получает, - те, что он сам себе задумал.

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

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

Когда виртуальных устройств много и они приходят-уходят, пишется собственный шинный драйвер. Классический образец из комплекта WDK - драйвер toaster bus и его наследники, а более жизненный пример - пары виртуальных последовательных портов, где канал между двумя vCOM живёт целиком в памяти. Архитектура у таких решений выстраивается типовая: драйвер шины реализует bus enumeration, на каждый запрос о детях возвращает актуальный список, для каждого ребёнка создаёт PDO - физический объект устройства - и отдаёт наружу его идентификаторы, возможности и структуру взаимодействия.

Создание PDO в KMDF - операция обдуманная: драйвер шины определяет набор идентификаторов, по которым для ребёнка потом подбирается функциональный стек, и очередь запросов, адресованных непосредственно устройству. Типовой обратный вызов списка детей выглядит так:

NTSTATUS EvtChildListCreateDevice(WDFCHILDLIST ChildList,
                                  PWDF_CHILD_IDENTIFICATION_DESCRIPTION_HEADER Header,
                                  PWDFDEVICE_INIT ChildInit)
{
    WDFDEVICE child;
    WDF_OBJECT_ATTRIBUTES attr;
    PMY_CHILD_INFO info = CONTAINING_RECORD(Header, MY_CHILD_INFO, Header);
    NTSTATUS status;

    DECLARE_CONST_UNICODE_STRING(hwid,     L"ROOT\\MyVirtualPort");
    DECLARE_CONST_UNICODE_STRING(compat,   L"ROOT\\MyVirtualPortCompatible");
    DECLARE_CONST_UNICODE_STRING(instance, L"ROOT\\MyVirtualPortDevice");

    status = WdfPdoInitAssignDeviceID(ChildInit, &instance);
    if (!NT_SUCCESS(status)) { return status; }

    status = WdfPdoInitAddHardwareID(ChildInit, &hwid);
    if (!NT_SUCCESS(status)) { return status; }

    status = WdfPdoInitAddCompatibleID(ChildInit, &compat);
    if (!NT_SUCCESS(status)) { return status; }

    WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attr, MY_CHILD_CONTEXT);
    attr.ParentObject = WdfChildListGetDevice(ChildList);

    status = WdfDeviceCreate(&ChildInit, &attr, &child);
    if (!NT_SUCCESS(status)) { return status; }

    // интерфейс устройства, по которому его найдут приложения
    return WdfDeviceCreateDeviceInterface(child,
               &GUID_DEVINTERFACE_MYPORT, NULL);
}

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

WDF_CHILD_LIST_CONFIG  config;
WDFCHILDLIST           childList;

WDF_CHILD_LIST_CONFIG_INIT(&config, sizeof(MY_CHILD_INFO),
                           EvtChildListCreateDevice);

status = WdfChildListCreate(busDevice, &config,
                            WDF_NO_OBJECT_ATTRIBUTES, &childList);
if (!NT_SUCCESS(status)) {
    return status;
}

// ребёнок появился
status = WdfChildListAddOrUpdateChildDescriptionAsPresent(
             childList, &childInfo, NULL);

// ребёнок исчез
WdfChildListUpdateChildDescriptionAsMissing(childList, childInfo.SerialNo);

На каждое изменение списка фреймворк сам уведомляет PnP-менеджер, а в драйверах без фреймворка ту же роль играет прямой вызов IoInvalidateDeviceRelations. Менеджер на каждую такую отмашку по-новому опрашивает шину, и дерево приходит в соответствие с реальностью.

Здесь же прячется самая важная бытовая истина: виртуальный драйвер обязан уважать право пользователя на внезапное исчезновение. Surprise removal - вполне законное событие: устройство пропало, а открытые дескрипторы остались. Корректный драйвер реализует пару обратных вызовов EvtDeviceSurpriseRemoval и EvtDeviceQueryRemove, честно освобождает ресурсы и не паникует, когда приложение с открытым портом на мгновение оказывается сиротой. Сколько продуктов в поле падало ровно на этом сценарии, лучше не считать.

Пользовательский режим против ядерного и выбор модели

Ещё один рубеж - выбор между ядерным (KMDF) и пользовательским (UMDF) исполнением. UMDF-драйвер живёт в отдельном хост-процессе, имеет ограниченный доступ к аппаратуре и падает без последствий для системы. Для многого виртуального железа это идеальный дом: программная камера, виртуальный последовательный порт, эмулятор человеко-машинного устройства. Ядерный режим неизбежен там, где нужны прямые прерывания, DMA, тесная интеграция с вводом-выводом ядра или участие в стеках хранения и сетей.

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

Из смежного набора практик запоминается и пара вспомогательных правил:

  1. Интерфейс устройства открывать через класс установки, а экземпляры различать по InstanceId, тогда приложение устойчиво к переподключениям;
  2. Не имитировать присутствие там, где его нет, системные инструменты сразу видят разницу между root-enumerated устройством и настоящим железом;
  3. Протоколировать через WPP каждый переход состояния узла, первую неделю полевой эксплуатации именно эти записи спасают расследование;
  4. Тестировать отключение посреди передачи данных, а не в момент покоя, рассинхронизация всплывает именно там.

Идентификаторы, классы и появление устройства перед приложениями

Доля успеха виртуального устройства решается не в ядре, а в том, насколько удобно его находит программный мир. Идентификаторы, которые драйвер возвращает на запросы PnP-менеджера, - это визитная карточка: hardware id подбирает драйвер, compatible id даёт запасной вариант, а device interface class открывает дверь приложениям. Регистрация интерфейса в KMDF сводится к WdfDeviceCreateDeviceInterface с GUID класса, после чего программы находят устройство стандартными связками SetupDiGetClassDevs или CM_Get_Device_Interface_List, открывают символическую ссылку и работают как с обычным файлом. Симлинки создаются в глобальном пространстве имён, оттуда их видно через WinObj, и опытные инженеры периодически проверяют, что ссылка действительно ведёт туда, куда задумано - путаница с путями пространства имён стоила не одной недели отладки.

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

Обновления пакета и сосуществование версий

Виртуальное устройство живёт дольше, чем одна поставка, и тема обновлений требует дисциплины. Пакет драйвера попадает в хранилище Driver Store, версии там различаются строго, и попытка подложить "ту же" версию с другим бинарником кончается отказом подписи или нежданным откатом. Правильный цикл обновления выглядит рутинно: новая версия с новой датой в inf, установка через pnputil, пересоздание узлов, которые держались за старый стек. Для шинных драйверов отдельная петарда: если драйвер шины обновился, а дети уже работают через старый PDO, менеджер устраивает переустановку целого поддерева, и драйвер обязан пережить эту операцию без потери состояния там, где это возможно.

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

reg query "HKLM\SYSTEM\CurrentControlSet\Enum\ROOT" /s /f "MyVirtualPort"
type %windir%\inf\setupapi.dev.log | findstr /I "myport"
pnputil /enum-devices /problem

Последняя команда печатает устройства с кодом проблемы, и код 28 означает неподобранный драйвер, а код 10 - отказ при запуске стека. Зная эти три семейства дурных вестей, диагност перестаёт перебирать всё подряд и идёт напрямик к правильной полке. Именно в этой предсказуемости и заключается взрослость платформы Plug and Play: виртуальное железо подчиняется тем же законам, что и настоящее, и тем, кто их выучил, оно платит безотказной работой.

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

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

Проверка и отладка жизненного цикла виртуального устройства

Наблюдать за жизнью PnP-дерева удобно несколькими инструментами. Диспетчер устройств показывает человеческую картину, а команда pnputil /enum-devices и её родственники дают ту же картину текстом, пригодным для скриптов и регрессионных проверок. Внутри отладчика информативны расширения дерева устройств и стека:

kd> !devnode 0 1
kd> !devstack ffffd10a`12345060
  DevObj           DevNode          DevExt           ObjectName
  ffffd10a`12345060 ffffd10a`123451d0 ffffd10a`12345320
kd> !wdfdevice ffffd10a`12345060
kd> !wdfdriver ffffd10a`0a112000
kd> !devobj ffffd10a`12345060

Включение верификатора для конкретного модуля делается заранее:

verifier /standard /driver myport.sys
verifier /querysettings

Driver Verifier ловит и ошибки управления питанием, и некорректные ответы на запросы отношений, и преждевременный разбор объектов.

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