IopReadyDeviceObjects: медвежья услуга от ядра и как с ней сосуществовать
Предисловие
Всем привет! Столкнулся я недавно с одной интересной и не вполне понятной с первого взгляда проблемой в KMDF драйвере, разработкой которого я в данный момент занимаюсь. Опыта в этой сфере у меня не много — это первый проект на KMDF которым я занимаюсь. В деталях описывать проект не могу (всё-таки частная собственность), да это и не нужно, но идея такова: есть 2 драйвера, один из них становится в стек устройств определённого класса и предоставляет интерфейс через который второй драйвер может подписаться на добавление новых и уже подключенных устройств (несколько callback-ов), получать обратные вызовы на определённых операциях и так далее. Таким образом первый драйвер находится в системе постоянно и для своей замены требует перезагрузки и содержит минимальную логику, а второй может свободно обновляться на ходу (без перезагрузки) и принимает решения. Логика этого драйвера подразумевает создание control device для каждого устройства-фильтра, установленного в стек (нужен дополнительный функционал без коллизий с функционалом стека) — и вот тут у меня возникла проблема, на определение причин которой и дальнейшее решение я потратил довольно много времени. Статью об этом решил написать именно сегодня — как-никак это неплохой способ сделать что-то полезное на свой профессиональный юбилей — 10 лет в разработке:-)
Суть проблемы
Система работает следующим образом: драйвер, устанавливающий фильтр в стеки (назовём его фильтр-драйвером) устройств принимает internal ioctl, содержащий адрес на callback функцию AddDevice второго драйвера (назовём его клиент-драйвером) и вызывает её сразу для уже установленных устройств и при добавлении новых при вызове собственного AddDevice PnP менеджером. С учётом особенностей реализации AddDevice клиент-драйвера (вызывается на IRQL DISPATCH_LEVEL для уже подключенных устройств) делегирует часть работы на work item для выполнения работы, требующей IRQL PASSIVE_LEVEL (это классика, тут останавливаться не будем). Чтобы это стало часть понятнее добавим чуть графики:
Код work item-а примерно следующий:
NTSTATUS status = WdfDeviceCreate(pInit, pDeviceAttributes, pControlDevice);
if (!NT_SUCCESS(status))
{
// логгирование и т.п.
WdfDeviceInitFree(pInit);
return status;
}
// код настройки дефолтной очереди
status = WdfIoQueueCreate(*pControlDevice, pConfig, pQueueAttributes, pQueue);
if (!NT_SUCCESS(status))
{
// логгирование и т.п.
WdfObjectDelete(*pControlDevice); // устройство без очереди для нас бесполезно
return status;
}
// создание дополнительной очереди для отложенной обработки IRP
WdfControlFinishInitializing(*pControlDevice);
Вроде ничего особенного, верно? Я тоже так думал, но с какой-то вероятностью создание очереди завершалось неудачей со статусом 0xC0000184 (STATUS_INVALID_DEVICE_STATE). Документация от функции (https://docs.microsoft.com/en-us/windows-hardware/drivers/ddi/wdfio/nf-wdfio-wdfioqueuecreate) такого статуса возврата не упоминает — что-то пошло не так.
Исследование проблемы
Беглый поиск не дал мне ответа — что ж, не беда. Берём IDA и смотрим что у нас в WdfIoQueueCreate:
Супер, кто-то сбросил флаг инициализации устройства и из-за этого я не могу присоединить к нему очередь. Но кто это сделал? WdfControlFinishInitializing, который этот флаг сбрасывает, вызывается позднее. Отдельный прикол в том, что с помощью отладчика подловить проблему я не смог — изменение времени операций определённо влияло на возникновение проблемы, стало быть мы имеем дело с какой-то проблемой синхронизации. В процессе дальнейшего анализа работы Wdf1000.sys и поиска найденных имён я нашёл то, чего совсем не ожидал — код WDF (KMDF & UMDF) официально доступен на гитхабе MS (https://github.com/microsoft/Windows-Driver-Frameworks). Изучение его кода помогло мне понять детали работы фреймворка, но ответа на свой вопрос я не нашёл.
Ок, хардкор так хардкор. Добавляем к work item-ам автоматическую синхронизацию фреймворком (установкой AutomaticSerialization структуры WDF_WORKITEM_CONFIG в TRUE, подробнее тут: https://docs.microsoft.com/en-us/windows-hardware/drivers/ddi/wdfworkitem/ns-wdfworkitem-_wdf_workitem_config) и начинаем смотреть логи. Из них я почерпнул следующее: проблема с подключением очереди возникает только с первым устройством, и то с какой-то вероятностью, и исполнение этого кода начинается либо параллельно с моей DriverEntry, либо сразу после. С помощью расстановки циклов ожидания я создал последовательность, при которой DriverEntry завершается между созданием устройства и созданием очереди к нему в work item-е — и я смог поймать проблему отладчиком. Отлично, я знаю что кто-то сбрасывает поле Flags DEVICE_OBJECT-а — давайте поставим на него breakpoint по доступу на запись (windbg: ba w4
Давайте взглянем, что это за функция такая:
Проверить смещения полей можно с помощью dt nt!_DEVICE_OBJECT NextDevice и dt nt!_DRIVER_OBJECT DeviceObject . Функция тут простая как грабли и по сути просто выставляет флаги на объект драйвера и весть список его устройств. И тут кроется ответ на мой вопрос «кто инициализировал устройство из соседнего потока?» — виновник найден. По сути эта функция приведёт все мои устройства после DriverEntry в состояние готовности, хочу я этого или нет.
Что делать?
Нам нужно что-то из двух: либо перенести регистрацию AddDevice драйвера-клиента после IopReadyDeviceObjects либо как-то синхронизировать AddDevice c чем-то после функции-плохиша, но в процессе инициализации. Давайте посмотрим код функций в стеке до IopReadyDeviceObjects. IopLoadDriver не содержит чего-то, что можно было бы использовать, а вот IopLoadUnloadDriver — напротив:
Анализ отмеченной функции IopCallDriverReinitializationRoutines дал мне возможность реализовать оба выхода из ситуации — эта функция вызывает так называемые DRIVER_REINITIALIZE callback-и (https://docs.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/nc-ntddk-driver_reinitialize). Таким образом чуть изменив порядок и зарегистрировав установку AddDevice драйвера-клиента из callback функции, установленной с помощью IoRegisterDriverReinitialization, — мы решаем исходную проблему! Забавная вещь: исходники из WRK мало отличаются от современного кода, не смотря на их древность (современный код чуть отрефакторен и содержит генерацию ETW событий на каждый чих — здорово для анализа без хуков и прочей чернухи). После внесения этого изменения процесс будет выглядеть так:
Как и ожидалось, испытание подтвердили тот факт, что проблема канула в лету :-)
Вывод
Как минимум не создавать устройств параллельно с выполнением DriverEntry и окрестного кода, если что-то подобно может иметь место — перемещаем это в DRIVER_REINITIALIZE callback. Да и наверное в целом лучше отказаться от кода, который может работать параллельно с DriverEntry. Звучит очевидно, но что имеем — то имеем. Наверное этот же совет можно применить ко множеству ситуаций.
Заключение
Хоть я в разработке и достаточно давно, но драйверами плотно стал заниматься лишь недавно (стартовав и координируя 2 проекта) — и как следствие почти каждый рабочий день открываю для себя что-то новое (например наличие read-write spin lock-а, который позволяет получить почти EResource на DISPATCH_LEVEL — рекомендую прочесть статью https://www.osr.com/nt-insider/2015-issue3/the-state-of-synchronization/ если ещё не читали). И знаете что? Я кайфую от этого. Недавно читал забавную статью на хабре, которая упоминала три зоны — зону комфорта, зону обучения и зону паники. Так вот: похоже мне нравится находиться на границе зоны обучения и зоны паники :-) Может быть статья и чуть сумбурная, но найди я нечто подобное несколько дней назад — сэкономил бы уйму времени и не получил бы всего кайфа от анализа :-) MS вроде как и упоминает о многих вещах что я нашёл, но информация фрагментирована и по сути моей проблемы не гуглилась — надеюсь сейчас всё будет несколько иначе.
Искренне благодарю за внимание.