Предисловие

Всем привет! Столкнулся я недавно с одной интересной и не вполне понятной с первого взгляда проблемой в KMDF драйвере, разработкой которого я в данный момент занимаюсь. Опыта в этой сфере у меня не много - это первый проект на KMDF которым я занимаюсь. В деталях описывать проект не могу (всё-таки частная собственность), да это и не нужно, но идея такова: есть 2 драйвера, один из них становится в стек устройств определённого класса и предоставляет интерфейс через который второй драйвер может подписаться на добавление новых и уже подключенных устройств (несколько callback-ов), получать обратные вызовы на определённых операциях и так далее. Таким образом первый драйвер находится в системе постоянно и для своей замены требует перезагрузки и содержит минимальную логику, а второй может свободно обновляться на ходу (без перезагрузки) и принимает решения. Логика этого драйвера подразумевает создание control device для каждого устройства-фильтра, установленного в стек (нужен дополнительный функционал без коллизий с функционалом стека) - и вот тут у меня возникла проблема, на определение причин которой и дальнейшее решение я потратил довольно много времени. Статью об этом решил написать именно сегодня - как-никак это неплохой способ сделать что-то полезное на свой профессиональный юбилей - 10 лет в разработке :-)

Суть проблемы

Система работает следующим образом: драйвер, устанавливающий фильтр в стеки (назовём его фильтр-драйвером) устройств принимает internal ioctl, содержащий адрес на callback функцию AddDevice второго драйвера (назовём его клиент-драйвером) и вызывает её сразу для уже установленных устройств и при добавлении новых при вызове собственного AddDevice PnP менеджером (важное пояснение - AddDevice клиент-драйвера не связана с PnP и не регистрируется в DRIVER_OBJECT, соответственно никем кроме фильтр-драйвера не вызывается, а также имеет кастомный прототип - название такое указано исходя из назначения, а не регистрации или прототипа). С учётом особенностей реализации 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 ). Документация от WdfIoQueueCreate такого статуса возврата не упоминает - что-то пошло не так.

Исследование проблемы

Беглый поиск не дал мне ответа - что ж, не беда. Берём IDA и смотрим что у нас в WdfIoQueueCreate:

Супер, кто-то сбросил флаг инициализации устройства и из-за этого я не могу присоединить к нему очередь. Но кто это сделал? WdfControlFinishInitializing, который этот флаг сбрасывает, вызывается позднее. Отдельный прикол в том, что с помощью отладчика подловить проблему я не смог - изменение времени операций определённо влияло на возникновение проблемы, стало быть мы имеем дело с какой-то проблемой синхронизации. В процессе дальнейшего анализа работы Wdf1000.sys и поиска найденных имён я нашёл то, чего совсем не ожидал - код WDF (KMDF & UMDF) официально доступен на гитхабе MS. Изучение его кода помогло мне понять детали работы фреймворка, но ответа на свой вопрос я там не нашёл.

Ок, хардкор так хардкор. Добавляем к work item-ам автоматическую синхронизацию фреймворком (установкой AutomaticSerialization структуры WDF_WORKITEM_CONFIG в TRUE, подробнее тут) и начинаем смотреть логи. Из них я почерпнул следующее: проблема с подключением очереди возникает только с первым устройством, и то с какой-то вероятностью, и исполнение этого кода начинается либо параллельно с моей DriverEntry, либо сразу после. С помощью расстановки циклов ожидания я создал последовательность, при которой DriverEntry завершается между созданием устройства и созданием очереди к нему в work item-е - и я смог поймать проблему отладчиком. Отлично, я знаю что кто-то сбрасывает поле Flags DEVICE_OBJECT-а - давайте поставим на него breakpoint по доступу на запись (windbg: ba w4 <DEVICE_OBJECT_ADDRESS + FLAGS_OFFSET>) и поймаем нашего плохиша. Смещение поля спросим у отладчика (dt nt!_DEVICE_OBJECT Flags) - в моём случае это 0x30 (x64). Отпускаем отладчик и ловим срабатывание тут:

Давайте взглянем, что это за функция такая:

Проверить смещения полей можно с помощью dt nt!_DEVICE_OBJECT NextDevice и dt nt!_DRIVER_OBJECT DeviceObject . Функция тут простая как грабли и по сути просто выставляет флаги на объект драйвера и весть список его устройств. И тут кроется ответ на мой вопрос "кто инициализировал устройство из соседнего потока?" - виновник найден. По сути эта функция приведёт все мои устройства после DriverEntry в состояние готовности, хочу я этого или нет.

Что делать?

Нам нужно что-то из двух: либо перенести регистрацию AddDevice драйвера-клиента после IopReadyDeviceObjects либо как-то синхронизировать AddDevice c чем-то после функции-плохиша, но в процессе инициализации. Давайте посмотрим код функций в стеке до IopReadyDeviceObjects. IopLoadDriver не содержит чего-то, что можно было бы использовать, а вот IopLoadUnloadDriver - напротив:

Анализ отмеченной функции IopCallDriverReinitializationRoutines дал мне возможность реализовать оба выхода из ситуации - эта функция вызывает так называемые DRIVER_REINITIALIZE callback-и. Таким образом чуть изменив порядок и зарегистрировав установку AddDevice драйвера-клиента из callback функции, установленной с помощью IoRegisterDriverReinitialization, - мы решаем исходную проблему! Забавная вещь: исходники из WRK мало отличаются от современного кода, не смотря на их древность (современный код чуть отрефакторен и содержит генерацию ETW событий на каждый чих - здорово для анализа поведения софта без хуков и прочей чернухи). После внесения этого изменения процесс будет выглядеть так:

Как и ожидалось, испытание подтвердили тот факт, что проблема канула в лету :-)

Вывод

Как минимум не создавать устройств параллельно с выполнением DriverEntry и окрестного кода, если что-то подобно может иметь место - перемещаем это в DRIVER_REINITIALIZE callback. Да и наверное в целом лучше отказаться от кода, который может работать параллельно с DriverEntry. Звучит очевидно, но что имеем - то имеем. Наверное этот же совет можно применить ко множеству ситуаций.

Заключение

Хоть я в разработке и достаточно давно, но драйверами плотно стал заниматься лишь недавно (стартовав и координируя 2 проекта) - и как следствие почти каждый рабочий день открываю для себя что-то новое (например наличие reader-writer spin lock-а, который позволяет получить почти EResource/PushLock на DISPATCH_LEVEL - рекомендую прочесть статью, если ещё не читали). И знаете что? Я кайфую от этого. Недавно читал забавную статью на хабре, которая упоминала три зоны - зону комфорта, зону обучения и зону паники. Так вот: похоже мне нравится находиться на границе зоны обучения и зоны паники :-) Может быть статья и чуть сумбурная, но найди я нечто подобное несколько дней назад - сэкономил бы уйму времени и не получил бы всего кайфа от анализа :-) MS вроде как и упоминает о многих вещах что я нашёл, но информация фрагментирована и по сути моей проблемы не гуглилась - надеюсь сейчас всё будет несколько иначе.

Искренне благодарю за внимание.

Комментарии (4)


  1. emusic
    04.09.2022 17:54
    +1

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

    По-хорошему, при обработке AddDevice в драйвере-перехватчике, Вам нужно дожидаться ее успешного завершения, подтверждающего, что устройство успешно создано, затем где-то запомнить его реквизиты, и вызвать callback основного драйвера независимо (например, через Work Item), чтобы исключить любые гонки.

    Ну и вызывать в качестве callback'а AddDevice основного драйвера имеет смысл лишь в том случае, когда такой вызов неотличим от "честного" вызова из Device Manager, иначе стоило бы предусмотреть для этого собственную функцию.

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


    1. Deamhan Автор
      04.09.2022 19:25

      Вы похоже не совсем меня поняли: AddDevice драйвера-клиента - это просто функция коллбэк, не регистрируемая через DRIVER_OBJECT и c PnP не связанная. Драйвер-клиент с PnP вообще не связна, для него эту задачу выполняет фильтр-драйвер. Тут вышла небольшая путаница наверное - настоящий AddDevice регистрируется только в фильтре, он же отслеживает список подключенных устройств. В конце своей работы "настоящий" AddDevice вызывает AddDevice драйвера-клиента, который и сохраняет те самые "реквизиты" и создаёт связанный с ними control device (это происходит внутри work item-а). Название у них общее только исходя из-за назначения, но регистрация и прототип совсем разные. Если бы они обе были зарегистрированы через DRIVER_OBJECT - это был бы лютый треш, тут Вы несомненно правы - но я не настолько не в себе чтобы такое делать :-) Вообще статья несколько упрощает схему работы - так как она тут больше для общего понимания (манипуляции с фильтр устройствами задействуют ещё и обработку IRP_MN_START_DEVICE). Насчёт KMDF - вопрос спорный, идея применения дерева родителей из коробки мне показалась интересной (для GUI фреймворков это норма), да и упрощение часто используемых вещей вроде автоматической очередизации - тоже полезно, сокращение возможностей отстрелить себе много он тоже сокращает - да и ничто не мешает комбинировать техники, что я и сделал. Ещё забавный момент, который меня удивил в KMDF - это то, что он написан на C++ (ограниченном - оно понятно). Wil даёт RAII классы для хендлов - идёт явное смещение в эту сторону. Хорошо ли всё это - вопрос спорный, но если одно не отменяет другого - я за. Рад увидеть первый коммент, я уж счёл что данной темой тут никто не интересуется Ъ

      P.S. добавил статью этот момент, чтобы не сбивать с толку :-)


      1. emusic
        05.09.2022 06:02

        AddDevice драйвера-клиента - это просто функция коллбэк, не регистрируемая через DRIVER_OBJECT

        Именно поэтому ее и стоило бы назвать по-другому, даже для себя. Привычка к подобным коллизиям в какой-то момент может сработать против Вас.

        упрощение часто используемых вещей вроде автоматической очередизации - тоже полезно

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

        Это как делать на каком-нибудь WTL или MFC приложение, цель которого - перехват и нестандартная обработка оконных сообщений, организация нестандартных связей между окнами и т.п. Сделать-то можно, но риск отстрелить себе что-нибудь в процессе будет приличный.

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

        момент, который меня удивил в KMDF - это то, что он написан на C++

        Что здесь удивительного? Драйверы писались на C++ задолго до его появления - еще с конца 90-х.


        1. Deamhan Автор
          05.09.2022 09:18

          Удивительное то, что у как минимум в моём окружении матёрые разработчики драйверов морщат нос при использовании С++ в дровах - только С. А тут такой удар под дых от MS :-) Да и на форумах жёсткие холивары на эту тему припоминаются.