Игрища с USB в Windows (отслеживаем и контролируем)

Не так давно появилась задача - каким-то образом отслеживать появление новых USB-флешек в Windows, а также их исчезновение. В идеале также неплохо было бы иметь возможность манипулировать безопасным извлечением. Казалось бы, что все просто - ведь Windows имеет средства для оповещения приложений о вставляемых и вытаскиваемых флешках на уровне пользователя, но на деле оказалось, что тонкостей в этом вопросе очень много.

Итак, в статье я расскажу, как:
[+] отследить появление новой флешки или USB-диска в системе (даже если это хитрожопая флешка, которая монтируется как CD-ROM+Flash, например, или флешка, разбитая на пару дисков)
[+] отслеживать безопасное извлечение флешек и манипулировать им
[+] самому безопасно извлечь любой извлекаемый USB-девайс по букве его диска
[+] отследить прочие события, а именно небезопасное извлечение флешки и отказ в безопасном извлечении

Само-собой, никаких драйверов, только уровень пользователя! Я также поделюсь с вами исходником класса на C++, который реализует все вышеописанные задачи. Давно я не писал годных толстых статей...

Надеюсь, вы приготовились погрузиться в недра WinAPI. Начнем с теории. В Windows существуют специальные события, которые система посылает, когда пользователь вставляет какой-либо USB-девайс в свой компьютер или вытаскивает его. Это DBT_DEVICEARRIVAL и DBT_DEVICEREMOVECOMPLETE. Оба эти ивента шлются через сообщение WM_DEVICECHANGE. Есть и другие полезные события, но о них позже. Казалось бы, все хорошо, и мы с помощью этих событий сразу имеем букву диска, на которую смонтировалось устройство (если это флешка или внешний жесткий диск).

Но на самом деле все не так радужно. Представим такую ситуацию: пользователь вставляет в компьютер новомодную флешку, которая монтируется как CD-ROM, содержащий сопутствующее ПО для нее, и как непосредственно флеш-диск. Через DBT_DEVICEARRIVAL действительно придет сообщение о добавлении двух дисков в систему. Однако, если пользователь вытащит флешку с помощью функции безопасного извлечения устройств, мы узнаем через ивент DBT_DEVICEREMOVECOMPLETE только о том, что был извлечен один из этих двух дисков. Такая ситуация стабильно проявляется как минимум на Windows 7. Я нашел путь, который позволяет стопроцентно определить, какие устройства добавились или удалились из системы - при получении DBT_DEVICEARRIVAL или DBT_DEVICEREMOVECOMPLETE достаточно перечислить все установленные в системе диски, найти среди них USB-девайсы и сравнить полученный список с предыдущим состоянием до прихода события. Возможно, это не очень оптимально, зато мы точно узнаем, какие USB-устройства были примонтированы и удалены из системы.

Что ж, переходим к более сложной части - работа с безопасным извлечением устройств. Представим, что мы читаем какой-то файл с флешки или пишем его. И тут пользователь запросил безопасное извлечение. Если мы тут же не закроем все хендлы и не завершим дисковые операции, система скажет пользователю, что диск занят. Как обработать эту ситуацию корректно? Казалось бы, все просто - есть же событие DBT_DEVICEQUERYREMOVE. Все верно, это то, что нам нужно. Только вот оно не отсылается системой по умолчанию, как я понял. Как быть? На просторах интернета было найдено решение: необходимо открыть замонтированный диск с помощью функции CreateFile (с флагом FILE_FLAG_BACKUP_SEMANTICS) и держать его открытым. Далее необходимо зарегистрировать оповещение о событии безопасного извлечения с помощью RegisterDeviceNotification с типом DBT_DEVTYP_HANDLE (регистрируем по хэндлу устройства, который получили из предыдущего вызова). После этого система начнет слать нам событие DBT_DEVICEQUERYREMOVE с типом (dbch_devicetype) DBT_DEVTYP_HANDLE. Теперь мы сможем определить, какое из подконтрольных нам устройств пользователь хочет безопасно извлечь, и даже вмешаться в этот процесс. Делается это достаточно просто - если мы не хотим позволять системе делать безопасное извлечение устройства, достаточно из обработчика DBT_DEVICEQUERYREMOVE вернуть значение BROADCAST_QUERY_DENY, а если хотим - то TRUE, не забыв при этом снять регистрацию ивента с помощью UnregisterDeviceNotification и закрыть с помощью CloseHandle хендл устройства. Во время обработки ивента DBT_DEVICEQUERYREMOVE мы можем по-быстрому закрыть все прочие хендлы, если наша программа в этот момент использует флешку, завершить все операции записи/чтения.

Но тут есть еще одна тонкость - на эти операции система дает нам ограниченное время. Таймаут примерно 10-15 секунд (в Win 7 меньше, в XP больше). Если мы не успели вернуть из обработчика DBT_DEVICEQUERYREMOVE ничего, то система просто выдаст пользователю сообщение о том, что устройство занято и не может быть извлечено. Даже если мы вернем TRUE после этого, это ни на что не повлияет - устройство так и останется неизвлеченным. Однако, с этой проблемой тоже можно бороться. Ничто не мешает нам определить, прошел ли таймаут ожидания ответа на событие, и если прошел, то принудительно демонтировать устройство после того, как мы реально закончим с ним работать. Это, конечно, не спасет от назойливой таблички с сообщением о занятости флешки, но первоначальное желание пользователя будет выполнено, так как устройство в конечном счете будет извлечено. Разумеется, не стоит слишком много времени тратить на работу с флешкой после того, как пользователь пожелал ее извлечь, иначе он просто выдернет ее из компьютера, а это не то, чего мы хотим.

Вы еще можете спросить, что произойдет, если пользователь захочет безопасно извлечь флешку, разбитую на два или более логических диска. Ответ прост: DBT_DEVICEQUERYREMOVE будет вызван два или более раз, для каждого раздела. Если мы не освободим хотя бы один, то и безопасное извлечение обломится.

Основные моменты я рассмотрел, но есть еще один. Все вышеперечисленные события работают только для приложений, у которых есть окно верхнего уровня. Сделать такое окно для любого приложения, в общем-то, не проблема. Но что, если мы разрабатываем службу Windows? Здесь все просто - необходимо зарегистрировать оповещения о событиях устройств с помощью уже упомянутой функции RegisterDeviceNotification с флагом DEVICE_NOTIFY_SERVICE_HANDLE. В этом случае все ивенты мы сможем обрабатывать внутри своего ServiceCtrlHandler'а (SERVICE_CONTROL_DEVICEEVENT). Код для этого я не писал, но разобраться проблемы не будет, так как никаких отличий в начинке этого кода по сути нет.

Теперь перейдем к самому коду. Если кратко: класс usb_monitor, который я написал в результате всех этих исследований, позволяет отслеживать установку-вытаскивание флешки, нормально работает с любыми флеш-дисками, позволяет манипулировать безопасным отключением устройств. Используется немного буста для реализации коллбеков. Класс еще можно совершенствовать и улучшать, но он уже хорошо справляется со своими задачами. Рассмотрим сначала интерфейс класса (usb_monitor.h):

Необходимые инклюды:

Класс исключений, которые могут быть брошены некоторыми функциями класса usb_monitor:

Далее - описание открытого интерфейса главного класса usb_monitor. Это синглтон, поэтому у него закрытый конструктор.

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

Если тут что-то непонятно, я поясню дальше на примере использования класса.

Здесь, думаю, все очевидно - после создания класса и назначения коллбеков надо вызвать функцию start.

Тут немного поясню. Функция mount_existing_devices необходима для того, чтобы класс смог взять под свой контроль все флешки, которые уже существовали в системе до его создания. Если этого не сделать, мы не сможем манипулировать ими и отслеживать их состояние, так как по умолчанию класс подхватывает только те устройства, которые были вставлены уже после его создания. С unmount_all_devices все ясно - эта функция просто освобождает все ранее взятые под контроль флешки, после чего класс более их не отслеживает и не занимает. Никакие коллбеки при этом не вызовутся.

Эта функция позволяет произвести принудительное безопасное извлечение устройства по букве диска, как будто пользователь кликнул "Безопасное извлечение устройства". Если флешка была подконтрольна классу, он перед отключением ее освободит. Никакие коллбеки при этом вызваны не будут.

Эти функции реализуют тот функционал, который я описал выше - принудительное безопасное извлечение после истечения таймаута ожидания ответа на ивент DBT_DEVICEQUERYREMOVE.

Наконец, парочка вспомогательных функций:

Настройки отслеживания внешних жестких дисков:

Деструктор:

Теперь - немного о закрытых переменных и функциях класса:

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

Данный пример будет мониторить все USB-диски и флешки в системе, позволит манипулировать безопасным извлечением. Работать будет на системах от Windows XP/2003 до Windows 7 (на восьмерке не проверял, но скорее всего тоже будет все нормально). Разумеется, класс usb_test нельзя будет использовать в службах Windows - потребуется небольшая переработка. Но в любых пользовательских приложениях - запросто.

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

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

Далее - пара статических членов класса usb_monitor:

Начнем разбор основного функционала с конструктора:

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

Вот я и рассказал, как работает основная следящая за событиями процедура. В карте existing_notifications_ содержится информация о подконтрольных классу usb_monitor устройствах: их хендлы, хендлы соответствующих им нотификаций и соответствующие буквы дисков. Неопределенным пока что моментом является функция detect_changed_devices, которая в коде выше используется дважды. Она определяет, какие с момента последнего ее вызова устройства были добавлены и удалены. Разберем ее код:

Относительно несложная функция. Однако, она вызывает еще несколько. Сначала разберем, что такое mount_device и unmount_device. Эти функции берут устройство под контроль класса usb_monitor и освобождают его, соответственно. Начнем с mount_device:

С unmount_device все проще. Первый параметр - буква диска, а второй сообщает, следует ли вызывать коллбек пользователя, чтобы сообщить о том, что устройство извлечено небезопасно.

Теперь - еще две коротенькие вспомогательные функции:

Вы, наверное, заметили, что во многих местах тут используется функция get_flash_disks. Смысл ее понятен, осталось разобрать содержание.

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

Последним штрихом остается нерассмотренная функция get_device_info. Этот код был стянут с какого-то сайта, кажется, codeproject, и немного переписан.

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

Про непонятный тип DEVINST можно подробнее прочитать здесь.

Можно для полноты картины рассмотреть деструктор класса usb_monitor:

Оставшиеся функции совсем очевидны и неинтересны, их вы найдете в полном исходном коде класса. Надеюсь, тема работы с USB-устройствами немного приоткрылась для вас. До новых встреч и удачи в кодинге!

Скачать полный исходный код класса (почти без комментариев) и код примера работы с ним: ZIP

Игрища с USB в Windows (отслеживаем и контролируем): 16 комментариев

  1. Паттерны, ооп, красивый код...Последний код, который я разбирал от dx'a был про стеганографию. Как будто два разных человека писали.
    P.S. Dx, а ты безопасно извлекаешь флешки?

    1. Когда что-то записываю на флешку - обычно пользуюсь безопасным извлечением, да.
      В искусстве программирования совершенствуюсь, незачем стоять на месте :)
      Хотя в этом классе можно было бы еще улучшать.

  2. А если в систему был добавлен виртуальный или реальный физический диск то класс тоже будет пытаться его безопасно извлечь?

    1. Класс определяет только виндовые события, которые относятся к plug-and-play устройствам. Класс определяет, является ли устройство флешкой или внешним жестким диском (эта опция отключается, как в статье указано). Кроме того, извлечь он ничего не пытается, просто отслеживает и все, а перехват безопасного извлечения - это уже желание программиста, класс предоставляет для этого возможности.

  3. Мисье, у меня VC++ 6.0 - я создал Win32 пустой проект добавил ваши классы и main.cpp
    компилятор спрашивает - где взять cfgmgr32.h и boost/function.hpp

    З.Ы - прошу ногами больно не пинать, просто мне больше нравится вначале запускать приложение - а после вникать в код (благо коментариев много спасибо)
    А так я немного по программированию знаю (работал от физических регистров на ATmega16 и ком порта до поточных приложений и сетевой протокол тоже мучал)

    1. Товарищ, советую избавиться от старого говна мамонта программного обеспечения. В нем Вы уже ничего не скомпилируете. Поставьте как минимум 2008 студию, а лучше 2010 или 2012. boost/function берется из буста, который нужно также сначала скачать и собрать. cfgmgr32.h - стандартный заголовочный файл, который в новых студиях присутствует.

  4. Доброй ночи! Хотел бы попросить Вас допилить слегка этот код в плане работы с таким устройством как смартфон. Чтобы при подключении его к компьютеру, был виден раздельно как телефон так и, если присутствует, sd карта. Спасибо

  5. Добро всем времени суток! Автору большой респект за статью! Довольно подробно, доходчиво и красиво описано)
    Однако обращаюсь ко всем, коллеги! Пытаюсь решить проблему, подобную одной части этой статьи, но с несколько другими нюансами.
    В программе, которую я пишу, имеется список USB устройств и устройств класса HID, которые у меня выводятся в древо treeview (как в диспетчере устройств короче). Этот список сразу заполняется в дерево при запуске программы (что как бы естественно очевидно). Сразу оговорю, что проект делаю не WINAPI, а на Windows Forms. И вот никак не могу сообразить как привинтить возможность автоматического обновления списка устройств в моем treeview, если в процессе работы программы будет вставлено или извлечено какое-либо устройство. По примеру из этой и подобным статьям на эту тему, пробовал у себя организовать проверку на получение системного сообщения WM_DEVICECHANGE и обновлять выводимый в дерево список устройств. Однако Ничего не происходит. Кто-то может подсказать, помочь разобраться в этой задаче? Если кто отзовется, исходники проекта скину на почту посмотреть.

    1. "Ничего не происходит" - в чем именно-то проблема? Сообщение WM_DEVICECHANGE не приходит? А точно ли правильно организовали кастомный обработчик сообщений (я не в курсе, как это делается в WinForms), другие-то сообщения приходят?

      1. В общем на основе множества подобных примеров с разных источников, сообразил вот такую функцию (пока для теста с меседжбоксами, чтоб хотя бы увидеть, что выборка сообщений работает):

        void ApplicationEventsMessage(MSG &msg){
        BOOL bRet;

        while ((bRet = GetMessage(&msg, NULL, 0, 0)) != 0)
        {
        if (bRet == -1)
        {
        // Обработка ошибки и возможный выход из программы
        GetLastError();
        }
        else
        {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
        }

        if (msg.message == WM_DEVICECHANGE)
        {
        switch (msg.wParam)
        {
        case DBT_DEVICEARRIVAL:
        MessageBox::Show("Устройство подключено");
        break;
        case DBT_DEVICEREMOVECOMPLETE:
        MessageBox::Show("Устройство извлечено");
        }
        }
        }
        }

        Однако теперь не пойму собственно, а где мне эту функцию вызвать то? Если в главной, то форма даже не загрузится, т.к. while будет крутить бесконечный цикл... Вот думаю, может в отдельном потоке её запустить?

  6. Доброго всем времени суток! Возвращаясь к своему вопросу...
    В общем переделал я обработку сообщений, использовав совсем другой способ, на который наткнулся сам случайно)) Опишу метод.
    Есть такая функция Application::AddMessageFilter(IMessageFilter^ value) (возможно она есть только в .NET разновидностях Си, пока сам точно не знаю, но не суть). Она и работает как фоновый обработчик сообщений, реализаций которого является соответствующий класс IMessageFilter, имя которого ей подаётся в качестве аргумента.
    Сам класс с обработкой сообщений выглядит так:
    public ref class MessageFilter : public System::Windows::Forms::IMessageFilter
    {
    public:
    virtual bool PreFilterMessage(Message % m)
    {
    if (m.Msg == WM_LBUTTONDOWN)
    {
    MessageBox::Show("WM_LBUTTONDOWN is: " + m.Msg + m.WParam.ToInt32());
    return true;
    }
    else return false;
    }
    };

    В таком виде всё работает корректно. Однако, если вместо WM_LBUTTONDOWN пробую обработать WM_DEVICECHANGE, то во время работающей программы, если вытащу, или вставлю любое USB устройство, то "отладочные" мэссэджбоксы не появляются, значит сообщение не видит. Dbt.h в проекте у меня подключен. А почему тогда не видит WM_DEVICECHANGE, не пойму... Есть у кого-нибудь какие мысли на этот счет?

    1. О таком фильтре для .net forms я говорил раньше. И говорил, что не знаю точно, как он работает, поэтому подсказать, как в .NET перехватить это сообщение, не смогу, лучше погуглить или попытаться как-то отладить код. Кстати, зачем код на WinForms писать на C++/CLI...

  7. В функции поиска устройства по physical drive number следует возвращать counter, а не DeviceNumber:
    //info.dev_number = DeviceNumber; /*было*/
    info.dev_number = index; /*стало*/
    Потому что номер physical drive не всегда совпадает с порядковым номером диска в классе устройств, который (класс) возвращает функция SetupDiGetClassDevsW.

    Пример вывода wmic для двух ssd и одной флешки:
    DeviceID MediaType Model
    \\.\PHYSICALDRIVE0 Fixed hard disk media WDC WDS100T1B0A
    \\.\PHYSICALDRIVE2 Removable Media Kingston DataTraveler 3.0 USB Device
    \\.\PHYSICALDRIVE1 Fixed hard disk media WDC WDS100T1B0B

    Для диска WDC WDS100T1B0B DeviceNumber = 1 (потому что physical drive number = 1), но при этом его индекс в классе устройств = 2. Если возвращать в info.dev_number = DeviceNumber = 1, то функция SetupDiEnumDeviceInfo вернёт инфу по диску с индексом 1, т.е. PHYDICALDRIVE2, то есть для флешки (хотя ожидался ssd диск). Как следствие, ssd диск будет считаться REMOVABLE устройством)

Добавить комментарий для ok Отменить ответ

Ваш адрес email не будет опубликован. Обязательные поля помечены *