Как стать автором
Обновить

Пишем драйвер для самодельного USB устройства

Время на прочтение13 мин
Количество просмотров177K
Целью этой статьи является пошаговая демонстрация процесса разработки всего набора программного обеспечения необходимого для организации связи самодельного устройства с компьютером посредством USB.

На данный момент, большинство радиолюбителей реализуют такой тип подключения используя чипы переходники USB в RS232 таким образом организуя связь со своим устройством посредством драйвера виртуального COM порта поставляемого с чипом переходником. Минусы такого подхода думаю понятны. Это как минимум лишний чип на плате и ограничения накладываемые этим чипом и его драйвером.
Мне же хочется осветить весь процесс организации такого взаимодействия так как оно и должно быть сделано, и как делается во всех серьезных устройствах.
В конце концов, сейчас 21-й век, модуль USB есть почти во всех микроконтроллерах. Именно о том, как наиболее быстро воспользоваться этим модулем и будет эта статья.

Так как для демонстрации процесса написания драйвера USB устройства нам необходимо собственно само устройство, то выберем одну из распространенных отладочных плат доступных в России. У меня это плата производства компании OLIMEX модель LPC-P2148. Основой платы является микроконтроллер LPC2148 архитектуры ARM7TDMI производства компании NXP. Всю информацию по плате можно получить на сайте производителя по следующей ссылке. Вот как она выглядит.

image

Выбор контроллера и отладочной платы абсолютно не принципиален т.к. процесс разработки взаимодействия между ОС на персональном компьютере и самой платой от этого не зависит. Среду разработки прошивки микроконтроллера будем использовать KEIL версии 4.23, что так же не принципиально. В итоге, планируется реализовать только BULK тип передачи. Будем считывать массив данных из устройства в компьютер, а передавать на устройство будем состояние светодиодов, чтобы было видно, что плата реагирует на наши команды.

Для удобства понимания разделим дальнейшие действия на стадии и будем проходить их по-порядку.

1. Адаптация готового примера USB устройства под нашу плату с целью убедиться, что плата работает и USB канал так же работоспособен. Это будет как бы наша стартовая точка.
2. Изменение прошивки платы, чтобы она стала для Windows неизвестным устройством, требующее драйвер производителя.
3. Адаптация базового шаблона, пустого драйвера, чтобы Windows могла его корректно установить, для обслуживания нашего устройства.
4. Реализация взаимодействия драйвера с пользовательским приложением.
5. Написание консольного приложения Windows для работы с нашим драйвером, а следовательно и подключенным USB устройством.
6. Наполнение всей системы необходимыми функциями.

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

1. Адаптация примера RTX_Memory под плату OLIMEX LPC-P2148

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

Проект находится в папке ARM\Boards\Keil\MCB2140\RL\USB\. Пути здесь и далее я буду указывать относительно основной папки, куда установлена среда KEIL.

Скопируем проект в отдельное место, загрузим его в KEIL и соберем. Собраться должен без ошибок. В итоге мы получили HEX файл, который можем прошить с помощью утилиты FlashMagic.
Правда можно пока его не прошивать так как очевидно, что он работать на нашей плате не будет.
Если сравнить схему нашей платы и платы для которой написан пример, а это модель MCB2140 производства KEIL, то видно различия в подключении подтяжки линии D+.
На плате MCB2140 она всегда подтянута к 3.3В, а на LPC-P2148 этой подтяжкой управляет микроконтроллер через транзистор.

Схемы обеих плат доступны на сайтах www.olimex.com и www.keil.com соответственно.

Для простоты, мы немного изменим код инициализации, чтобы наша плата всегда при включении включала подтяжку линии D+, о чем будет сообщать светодиод USB_LINK.
В процедуре USB_Init() отключим линию CONNECT от модуля USB и будем ею управлять сами. А так как на этом же транзисторе есть еще и светодиод USB_LINK то получится, когда мы его включим, автоматически вулючится и подтяжка линии D+.

Кроме того, на нашей плате меньше светодиодов чем у MCB2140. По-этому их назначение так же нужно переопределить. На данном этапе я их переназначил просто для индикации процессов чтения/записи.
Так как у нас нет индикаторов LED_CFG и LED_SUSP то закоментируем их использование везде по коду проекта.
Теперь можно собрать проект и прошить его в контроллер. Подключив плату к компьютеру, видно, что он ее распознает как внешний накопитель и в системе появляется еще один диск размером всего около 25КБайт и с файлом readme.txt.

На этом первый этап можно считать законченным.

2. Переход от USB накопителя к уникальному устройству.

На данный момент мы имеем устройство, которое на любом компьютере с любой ОС будет распознаваться, как внешний USB накопитель. Но нам требуется, чтобы Windows не знала, ким образом работать с нашим устройством и требовала драйвер. О том, что подключенное устройство относится ко классу накопителей, говорит параметр Interface class находящийся в дескрипторе интерфейса.

Если открыть файл usbdesc.c и найти там этот параметр то будет видно что он имеет значение USB_DEVICE_CLASS_STORAGE.

Заменим его на USB_DEVICE_CLASS_VENDOR_SPECIFIC, и следующие за ним два поля заменим на нули.
Теперь пересобрав проект и прошив плату мы увидим, что Windows больше не знает, что наше устройство является накопителем и требует предоставить подходящий драйвер.

Тут может возникнуть проблема. Дело в том, что Windows запомнив VID и PID нашего устройства в предыдущий раз, как относящиеся к устройству внешнего хранения, может продолжать ставить на него свой драйвер не обращая внимание на то, что класс устройства поменялся. Решение простое. Если плата по-прежнему определяется как накопитель, найдите ее в ветке USB диспетчера устройств и удалите драйвер вручную. После этого ОС должна начать просить драйвер.

3. Создаем базовый драйвер.

Итак, у нас есть рабочее USB устройство для которого требуется предоставить драйвер.
Для начала мы напишим самый простой драйвер, который не будет делать ничего полезного, кроме как загружаться в систему при появлении нашего устройства на шине USB. Драйвер будет иметь минимальный код, чтобы только корректно загрузиться и выгрузиться системой.

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

Для начала, нужно скачать с сайта Microsoft набор для разработки драйвером. Называется он Windows Driver Kit. Я использую версию WDK 7600.16385.1.

После установки, мы получим много примеров, окружение для сборки и документацию. В меню пуск, нужно найти раздел WDK и там Build Environments. Это так называемые окружения для сборки. Фактически они предоставляют нам консоль, которая уже настроина так, чтобы собирать драйверы для нужной системы.

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

Я буду использовать далее окружение «x86 Checked Build Environment» от windows XP. Это даст мне универсальный драйвер корректно работающий на системах от Windows XP и новее.

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

Самым подходящим кандидатом оказался пример к некой плате OSR USB-FX2 learning kit. Что это за плата я абсолютно не имею понятия, но нужный нам пример находится в WDK по пути src\usb\osrusbfx2\. Самое интересное, что это не просто пример, а пошаговое обучение, как сделать драйвер к этой плате. Как раз то, что нам и нужно. Зайдем глубже в директорию kmdf\sys и видим, что там все шаги и лежат по папочкам. Подробнее о них можно почитать в описании примера, находящемся в файле osrusbfx2.htm.

Тут я сделаю небольшое отступление, чтобы немножко сделать более понятней следующие действия.
Дело в том, что с момента появления Windows NT кое что изменилось в процессе написания драйвера. В те времена нам приходилось напрямую использовать функции ядра ОС и часто, просто чтобы сделать пустышку способную правильно загружаться, выгружаться, отвечать на события PNP и т.п. базовые функции, приходилось много чего изучить и не один раз вылететь в BSOD. Потом Microsoft сделала модель, которую назвала Windows Driver Model и которая внесла некоторого рода стандарт что ли, как должен выглядеть драйвер. Особого облегчения, лично я от этого не почувствовал. А следующим шагом был сделан фреймворк, который называется Windows Driver Framework. И вот благодаря этому жить стало намного проще. Теперь фреймворк берет на себя реализацию всех базовых действий необходимых для обслуживания основных событий, а нам останется только правильным образом добавить нужных нам функций. Вот именно эту технологию мы и будем использовать.

Начинаем с первого шага. Запускаем «x86 Checked Build Environment» и при помощи команды “cd” перемещаемся в папку WinDDK\7600.16385.1\src\usb\osrusbfx2\kmdf\sys\step1\.

Выплняем команду build -ceZ.

Происходит процесс сборки, и в результате создается папка objchk_wxp_x86( ее название зависит от выбранного окружения ), где мы и находим файл с расширением sys. Это и есть наш драйвер. Чтобы установить его, нам нужен INF файл. Найдем его в папке final этого же проекта. Она называется osrusbfx2.inf. Проблема только в том, что он рассчитан на плату из примера. Чтобы этот файл был способен установить драйвер для нашей платы, просто поменяем в нем везде значения VID и PID на те, которые прописаны в дескрипторе USB устройства в файле usbdesc.c. Просмотрев глазами INF файл, можно заметить, что для установки драйвера еще требуется файл WdfCoInstaller01009.dll. Он тоже находится в поставке WDK.

Итак, копируем в отдельную папку три файла: собранный SYS, INF, WdfCoInstaller01009.dll.

Подключаем нашу плату к компьютеру, и на вопрос Windows о пути к драйверу указываем эту папку.

Наблюдаем обычный процесс копирования файлов драйвера и в диспетчере устройств появляется наше устройство под классом Sample Device. Все, операционная система удовлетворена!

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

В режиме ядра, отладочную информацию выводит функция KdPrint(). Ее использование такое же, как всем известной printf(). Чтобы увидеть ее вывод, нужно установить программу DbgView. Она доступна на сайте Microsoft по ссылке http://technet.microsoft.com/en-us/sysinternals/bb896647. Просто держите ее запущенной и будете видеть вывод всей отладочной информации из режима ядра ОС. Я обычно настраиваю фильтр, чтобы отображались только сообщения нужного мне модуля. В моем варианте Step_1 я добавил вывод в процедуры DeviceEntry() и DeviceAdd() так, что он просто пишет какая функция вызвалась. Подключая и отключая плату, в окне DbgView хорошо видно в каком порядке это происходит.


4. Взаимодействие между режимами ядра и пользователя.

Как известно, драйверы устройств работают в режиме ядра( за некоторым исключением ), а наши приложения в режиме пользователя. Для взаимодействия используется тот же механизм, что и для работы с файлами. Иными словами, для каждого подключенного устройства в системе есть символическое имя, по которому его можно открыть, как обычный файл. Ну а потом использовать обычные процедуры для работы с файлами типа ReadFile() и WriteFile(). В этой части, мы добавим в наш драйвер функционал, позволяющий его открывать, закрывать, писать и читать из него данные.

Записанные данные будем сохранять, чтобы потом отдавать их при операции считывания.

Первое, что нужно сделать, это зарегистрировать свой callback функцию для события EvtDevicePrepareHardware, которую вызовет менеджер PnP после того, как устройство перейдет в неинициализированное состояние D0 и перед тем, как сделать его доступное драйверу. По сути это означает очень простую вещь, устройство мы воткнули, драйвер загрузился, но возможно ваше устройство требует некоторой настройки перед тем, как с ним станет возможно работать. Вот такого рода настройку мы и сделаем в этом событии. В применении к USB, как минимум нужно выбрать нужную конфигурацию. Итак, регистрируем нашу функцию. Для этого добавляем в DriverEntry следующий код:

WDF_PNPPOWER_EVENT_CALLBACKS_INIT(&pnpPowerCallbacks);
pnpPowerCallbacks.EvtDevicePrepareHardware = EvtDevicePrepareHardware;
WdfDeviceInitSetPnpPowerEventCallbacks(DeviceInit, &pnpPowerCallbacks);


Второе. Если обратите внимание на вызов процедуры WdfDeviceCreate из кода драйвера предыдущего параграфа, то можно заметить, что второй параметр этой процедуры передается константа WDF_NO_OBJECT_ATTRIBUTES. Означает это, что объект устройства не имеет никаких атрибутов. Но в реальной жизни нам понадобится как минимум один атрибут. Это так называемый контекст устройства. Упрощенно говоря, это некоторого рода структура, которая относится к конкретному экземпляру устройства поддерживаемого драйвером, и будет далее доступна нам практически в любом месте драйвера. Например она может содержать какой-нибудь буфер. А привязывается она к объекту устройства, а не драйвера т.к. К компьютеру может быть подключено несколько одинаковых устройств, которые будет обслуживать один и тот же драйвер, но все они будут иметь свой собственный объект устройства.

Итак, создадим структуру контекста, и инициализируем ею, параметр атрибутов, передаваемый далее в WdfDeviceCreate:

typedef struct _DEVICE_CONTEXT {
WDFUSBDEVICE UsbDevice;
WDFUSBINTERFACE UsbInterface;
WDFUSBPIPE BulkReadPipe;
WDFUSBPIPE BulkWritePipe;
} DEVICE_CONTEXT, *PDEVICE_CONTEXT;
WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(DEVICE_CONTEXT, GetDeviceContext)

WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, DEVICE_CONTEXT);


Третье. Теперь необходимо создать интерфейс, через который драйвер станет доступный программам пользовательского режима. Раньше, программист должен был сам жестко прописывать имя, по которому мог быть открыт доступ к устройству через процедуру CreateFile. Теперь все стало проще. Нам нужно только создать интерфейс вызвав одну процедуру, а для его идентификации используется сгенерированный GUID. Далее в пользовательском режиме мы будем использовать этот же GUID, чтобы получить имя файла устройства. Итак, вот наш GUID и код связывающий его с интерфейсом:

DEFINE_GUID(GUID_DEVINTERFACE_OSRUSBFX2, // Generated using guidgen.exe
0x573e8c73, 0xcb4, 0x4471, 0xa1, 0xbf, 0xfa, 0xb2, 0x6c, 0x31, 0xd3, 0x84);
// {573E8C73-0CB4-4471-A1BF-FAB26C31D384}

status = WdfDeviceCreateDeviceInterface(device,
(LPGUID) &GUID_DEVINTERFACE_OSRUSBFX2,
NULL);// Reference String


Последнее. В первом пункте мы зарегистрировали процедуру, обрабатывающую событие EvtDevicePrepareHardware. Теперь нужно ее написать. Не буду переписывать ее текст в статью, думаю проще будет глянуть в исходном коде. Скажу только, что в этой процедуре, мы подготавливаем все что нужно для последующей работы драйвера с подключенным устройством. А конкретно создаем объект USB устройства, выбираем нужную конфигурацию, и сохраняем в контексте устройства идентификаторы каналов, относящихся к BULK конечным точкам реализованного в устройстве интерфейса. Нам эти идентификаторы понадобятся позже, для реализации передачи данных. Для наглядности, я добавил вывод параметров каналов в DbgView. Можно заметить, что их параметры — это ни что иное, как те же самые значения, которые мы прописали в дескрипторах конечных точек в файле usbdesc.h прошивки.
Итак, теперь можно опять пересобрать драйвер, и обновить его в системе. На данный момент наш драйвер может уже не просто загрузиться. Он уже умеет настраивать подключенное устройство, и, что самое важное, стал доступен для программ из режима пользователя.


5. Работаем с драйвером из режима пользователя.

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

Работа с устройствами, сводится к открытию их, как обычного файла, и записи и чтения данных при помощи обычных процедур WriteFile и ReadFile. Есть еще очень полезная процедура DeviceIoControl, для организации взаимодействия с драйвером, которое выходит за формат работы с файлами, но мы ее использовать не будем. Открывается файл обычным вызовом CreateFile, вот только нам нужно имя файла. И тут нам пригодится GUID, который мы привязали к интерфейсу драйвера. Я не буду описывать всю процедуру получения имени через GUID, и честно признаюсь, что полностью взял ее из примеров WDK. Процедура GetDevicePath получает GUID и возвращает полный путь ему соответствующий.

Файл открыт. Добавим пару вызовов, которые запишут и считают из файла десяток байт.

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

Логика тут такая же, как и с EvtDevicePrepareHardware. Нам нужно зарегистрировать callback функции, которые вызовутся, когда произойдут процедуры чтения из драйвера или записи в него. Делается это в EvtDeviceAdd. Необходимо инициализировать очередь ввода/вывода, заполнить ее поля указателями на наши callback функции и создать ее, прицепив к объекту устройства. Поехали:

WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(&ioQueueConfig,
WdfIoQueueDispatchParallel);

ioQueueConfig.EvtIoRead = EvtIoRead;
ioQueueConfig.EvtIoWrite = EvtIoWrite;

status = WdfIoQueueCreate(device,
&ioQueueConfig,
WDF_NO_OBJECT_ATTRIBUTES,
WDF_NO_HANDLE);


Кроме объявления процедур чтения и записи, нужно не забыть их реализовать. На данном этапе я просто поставил заглушки, которые выводят переданные данные в DbgView и отдают массив из 10 байт при чтении. Код их вы можете посмотреть в исходниках. Там ничего интересного, только советую обратить внимание на работу с памятью. Необходимо по определенным правилам получать буферы т.к. Данные у нас перемещаются между режимами ядра и пользователя. На скриншоте хорошо видно, как мы посылаем данные в драйвер и они появляются в окне DbgView. Потом мы читаем пакет из драйвера и получаем его в выводе консольного приложения.


6. Делаем драйвер полезным.

Вот и настало время, сделать наш драйвер полезным. На данный момент он производит коммуникацию с режимом пользователя но никак не работает с реальным устройством. А все что нам осталось сделать, это в процедуре записиси добавить код, передающий данные на устройство, а в процедуре чтения — код принимающий данные с устройства. В исходниках вы видите, как совсем незначительно изменились процедуры обслуживающие ввод/вывод в драйвере. Мы всего лишь передаем наши буферы далее подсистеме USB ядра, а она уже все сделает, как нужно.
Перед началой реальной передачи данных между PC и устройством, нам еще нужно изменить прошивку устройства, чтобы она как-то реагировала на наши данные.

Изменим немного код в обработке события приема данных таким образом, чтобы если первый принятый байт 0x01 то включим LED_1, а если он 0x02 то включим LED_2. А т.к. После записи в устройство мы из него сразу читаем 10 байт, то добавим этот код тоже. Обратите внимание, что мы отправляем пакет на передачу в событии обработки входящего пакета. Это такая особенность работы модуля USB. Нам нужно заранее отдать ему данные для передачи, чтобы он мог исполнить IN транзакцию. А для наглядности, будем передавать два разных массива. Меняем содержимое MSC_BulkOut() следующим образом:

void MSC_BulkOut (void) {

BulkLen = USB_ReadEP(MSC_EP_OUT, BulkBuf);

LED_Off( LED_RD | LED_WR );
if( BulkBuf[ 0 ] == 0x01 )
{
USB_WriteEP( MSC_EP_IN, (unsigned char*)aBuff_1, sizeof( aBuff_1 ) );
LED_On( LED_RD );
}
else
if( BulkBuf[ 0 ] == 0x02 )
{
USB_WriteEP( MSC_EP_IN, (unsigned char*)aBuff_2, sizeof( aBuff_1 ) );
LED_On( LED_WR );
}
}


А в процедуре MSC_BulkIn() закоментируем весь код, оставив ее полностью пустой.

Результат работы всей связки вы видете на скриншоте.
При этом сама плата моргает двумя светодиодами.


Вот собственно и все. Мы написали прошивку и полноценный драйвер для собственного устройства USB. Если запустить передачу блоками по 4кб, можно добиться скорости 800 Кбайт/сек.
Как видите текст драйвера довольно прост и содержит всего около 250-ти строк.

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

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

Надеюсь статья получилась непохожей на руководство «как нарисовать сову», и кому-нибудь окажется полезной.
Теги:
Хабы:
Всего голосов 152: ↑149 и ↓3+146
Комментарии29

Публикации

Истории

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань