Хабрахабр

Изобретаем библиотеку vusb

Введение

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

В отличие от распространенной литературы, мы пойдем не от теории к практике, от самого нижнего уровня к верхнему, от логических напряжений на выводах, и закончим «изобретением» той самой vusb, после каждого шага проверяя, работает ли код как ожидалось. С этой целью попробуем изготовить на основе контроллера ATmega8 устройство, прикидывающееся USB-HID'ом. Впрочем, привычный для меня стиль написания кода отличается от стиля авторов vusb. Отдельно отмечу, что не изобретаю альтернативу этой библиотеке, а напротив, последовательно воспроизвожу ее исходный код, максимально сохраняя оригинальную структуру и названия, поясняя, для чего служит тот или иной участок. Отсюда же следует, что какой-то важный момент может быть упущен, или какая-то тема не до конца раскрыта. Сразу же честно признаюсь, что помимо альтруистического интереса (рассказать другим сложную тему) имею и корыстный — изучить тему самостоятельно и через объяснение выловить для себя максимум тонких моментов.

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

Также отмечу, что рассматривается только low-speed USB, даже без упоминания, чем отличаются более скоростные разновидности.

Шаг 0. Железо и прочая подготовка

Схему приводить не буду, она вполне стандартна (см. В качестве подопытного возьмем самодельную отладочную платку на основе ATmega8 с кварцем 12 МГц. В моем случае выводу D+ соответствует PD2, выводу D- PD3, а подтяжка висит на PD4. официальный сайт vusb), единственное что стоит упомянуть, так это используемые выводы. В принципе, подтягивающий резистор можно было соединить и с питанием, но ручное управление им кажется чуть более соответствующим стандарту.

6 В (зачем так было сделано для меня загадка). С разъема USB подается питание 5 В, однако на сигнальных линиях ожидается не более 3. Я выбрал второй вариант, но по большому счету это не принципиально. Значит нужно либо понизить питание контроллера, либо поставить стабилитроны на сигнальные линии.

В моем случае это два светодиода на PD6, PD7 и, самое главное, UART на PD0, PD1, настроенный на 115200, так что болтовню контроллера можно будет слушать через обычный screen или другую программу для работы с COM-портом: Раз уж мы «изобретаем» реализацию, было бы неплохо видеть, что же происходит в мозгах контроллера, то есть необходима хоть какая-то отладочная информация.

$ screen /dev/ttyUSB0 115200

Также полезной утилитой при отладке USB окажется wireshark с соответствующим модулем (он, правда, не всегда запускается с «из коробки», но решение подобных проблем вполне успешно находится в интернете и не является задачей данной статьи).

Точно так же не буду акцентировать внимание на настройках периферии, не связанных с USB. Здесь можно было бы потратить еще килобайт текста на описание программатора, makefile`ов и прочего, но вряд ли это имеет смысл. Если кто-то не может разобраться даже с этим, тем, быть может, и в недра программного USB лезть рано?

Исходный код всех шагов доступен на Github: https://github.com/COKPOWEHEU/vusb_dev/tree/master/vusb_dev

Шаг 1. Принимаем хоть что-то

5 мегабита в секунду. Согласно документации, USB поддерживает несколько фиксированных скоростей, из которых AVR потянет только самую низкую: 1. Для выбранной нами частоты резистор должен соединять D- с питанием 3. Она определяется по подтягивающему резистору и последующему общению. 5 кОм, но на практике можно соединять и с +5 В, и номинал немного варьировать. 3 В и иметь номинал 1. Понятно, что такая точность и скорость достижимы только на ассемблере, так что заведем файл drvasm. При частоте контроллера 12 МГц на один бит приходится всего 8 тактов. Отсюда же следует необходимость использования прерывания для отлова начала приема байта. S. В результате с момента начала байта до его конца проходит всего 64 такта контроллера (на самом деле запас еще меньше), так что использовать другие прерывания, не связанные с USB не стоит. Радует, что первый байт, передаваемый по USB, всегда одинаковый, SYNC, так что если попадем не в начало, ничего страшного.

Именно там будут заданы выводы, отвечающие за USB, а также используемые биты, константы и регистры. Сразу же вынесем конфигурацию в отдельный файл usbconfig.h.

Первым байтом всегда идет байт синхронизации SYNC, равный 0b10000000, вторым — байт-идентификатор пакета PID. Теоретическая вставка
Передача по протоколу USB осуществляется пакетами по нескольку байт в каждом. Этот способ заключается в том, что логический ноль передается изменением логического уровня на противоположный, а логическая единица — не-изменением. Передача каждого байта идет от младшего бита к старшему (это не совсем так, но в vusb эту тонкость игнорируют, учитывая в другом месте) при помощи кодирования NRZI. Таким образом размер байта может составлять 8 или 9 бит.
Еще стоит упомянуть о том, что линии данных в USB дифференциальные, то есть когда на D+ высокий уровень, на D- он низкий (это называется K-состояние) и наоборот (J-состояние). Кроме того, вводится защита от рассинхронизации (которой мы пользоваться не будем, но учитывать должны) источника и приемника сигнала: если в передаваемой последовательности есть шесть единиц подряд, то есть шесть тактов подряд состояние выводов не меняется, в передачу добавляется принудительная инверсия, как будто передается ноль. Правда, есть и исключение: сигнал конца пакета (он называется SE0) передается притягиванием обеих сигнальных линий к земле (D+ = D- = 0). Это сделано для лучшей помехозащищенности на высокой частоте. Если время небольшое (длина одного байта или чуть больше) то это Idle, пауза между пакетами, а если большое — сигнал сброса. Есть еще два сигнала, передаваемые удерживанием на линии D+ низкого напряжения, а на D- высокого, в течение различного времени.

Значит для определения состояния шины USB нам достаточно одной линии, D+ или D-. Итак, передача идет по дифференциальной паре, не считая экзотического случая SE0, но его пока рассматривать не будем. По большому счету, разницы какую выбрать, нет, но для определенности пусть будет D-.

Состоянию Idle соответствует лог. Начало пакета можно определить по приему байта SYNC после длительного Idle`а. Значит последовательность состояний D- будет следующей: 1 на линии D- (оно же J-состояние), а байт SYNC равен 0b100000, но он передается от младшего бита к старшему, да еще закодирован в NRZI, то есть каждый ноль означает инверсию сигнала, а единица — сохранение прежнего уровня.

Но вдруг во время начала приема контроллер будет занят и не сможет войти в прерывание немедленно? Начало пакета проще всего детектировать по спадающему фронту, на него и настроим прерывание. Он сплошь состоит из фронтов на границах битов, так что мы можем подождать один из них, потом еще пол-бита, и попадем прямо в середину следующего. Чтобы в такой ситуации не сбиться со счета тактов, воспользуемся байтом SYNC по прямому назначению. И для этого SYNC также подходит: у него в конце идут два нулевых бита подряд (они же K-состояния). Впрочем, ждать «какого-нибудь» фронта — не лучшая идея, нам ведь надо не просто попасть в середину бита, но и знать в какой по счету бит мы попали. Итак, в файле drvasm. Вот их и будем ловить. Причем за счет времени на проверку состояния порта, на безусловный переход и прочее, в метку мы попадаем далеко не в начале бита, а как раз к середине. S появляется кусок кода от входа в прерывание до foundK. Поэтому ждем 8 тактов (пока что пустыми nop'ами) и проверяем следующий бит. Но проверять этот же бит бессмысленно, мы ведь и так знаем его значение. Если он тоже нулевой, то мы нашли конец SYNC'а и можем переходить к приему значащих битов.

Ну и ожидание состояния SE0 чтобы не попасть случайно в следующий пакет. Собственно, весь дальнейший код предназначен для считывания еще двух байтов с последующим выводом на UART.

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

4E 55 00 00 4E 55 00 00 4E 55 00 00 4E 55 00 00 4E 55 00 00

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

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

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

Шаг 2. Демо-версия NRZI

Чтобы не перекодировать вручную, можно поручить это самому контроллеру: операция XOR делает именно то, что нужно, правда результат у нее получается инвертированный, поэтому после нее добавим еще одну инверсию:

mov temp, shift
lsl shift
eor temp, shift
com temp
rcall uart_hex

Результат вполне ожидаем:

2D 00 FF FF 2D 00 FF FF 2D 00 FF FF 2D 00 FF FF 2D 00 FF FF

Шаг 3. Избавляемся от цикла приема байта

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

Результат от предыдущего варианта не отличается.

Шаг 4. Читаем в буфер

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

И наконец признак конца пакета (EOP) — два бита паузы, но это уже не данные. Структура пакетов в USB стандартизована и состоит из следующих частей: байт SYNC, байт PID+CHECK (2 поля по 4 бита), поле данных (иногда 11 бит, но чаще произвольное количество 8-битных байтов) и контрольная CRC сумма размером либо 5 (для 11-битного поля данных), либо 16 (для остальных) бит.

Поэтому придется вынести чтение двух первых битов в линейный участок кода, между командами которого вставим код инициализации, а потом прыгнем в середину цикла чтения, на метку rxbit2. Перед работой с массивом надо еще настроить регистры, а свободных nop`ов перед первым битом для этого недостаточно. Согласно документации, в одном пакете нельзя передавать больше 8 байт данных. Кстати о размере буфера. Байт SYNC и состояние EOP записывать не будем. Добавляем служебные байты PID и CRC16, получаем размер буфера 11 байт. Пока что мы не будем использовать буфер целиком, но чтобы не возвращаться в будущем, лучше сразу выделить нужный объем. Контролировать интервал запросов от хоста мы не сможем, но и терять их не хочется, поэтому для чтения возьмем двойной запас.

Шаг 5. Работаем с буфером по-человечески

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

>03 2D 00 10 >01 FF >03 2D 00 10 >01 FF >03 2D 00 10 >01 FF >03 2D 00 10 >01 FF >03 2D 00 10 >01 FF

Шаг 6. Добавляем добавку добавочных нулей

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

unstuff0: ;1 (за счет breq) andi x3, ~(1<<0) ;1 [15] стираем 0-й бит маски. Он инвертирован не будет mov x1, x2 ;1 [16] подменяем предыдущий принятый бит текущим (добавочным) in x2, USBIN ;1 [17] <-- 1-й бит оказался добавочным. Считываем вместо него настоящий ori shift, (1<<0) ;1 [18] выставляем 0-й бит в лог.1 чтобы добавка не срабатывала повторно rjmp didUnstuff0 ;2 [20]
;<---//--->
rxLoop: eor shift, x3 ;1 [0] in x1, USBIN ;1 [1] st y+, shift ;2 [3] ldi x3, 0xFF ;1 [4] nop ;1 [5] eor x2, x1 ;1 [6] bst x2, USBMINUS ;1 [7] записываем считанный бит в 0-й бит регистра shift bld shift, 0 ;1 [8] in x2, USBIN ;1 [9] <-- считываем 1-й бит(возможно, добавочный) andi x2, USBMASK ;1 [10] breq se0 ;1 [11] andi shift, 0xF9 ;1 [12]
didUnstuff0: breq unstuff0 ;1 [13] eor x1, x2 ;1 [14]; bst x1, USBMINUS ;1 [15] записываем считанный бит в 1-й бит регистра shift bld shift, 1 ;1 [16]
rxbit2: in x1, USBIN ;1 [17] <-- считываем 2-й бит (возможно, добавочный) andi shift, 0xF3 ;1 [18] breq unstuff1 ;1 [19]
didUnstuff1:

Обратите внимание, что вводились они для отсчета тактов контроллера, поэтому идут не по порядку. Для удобства навигации адреса описываемых команд буду отсчитывать по меткам справа. Далее на метке [1] считывается состояние линии D-, по XOR'у с предыдущим принятым состоянием декодируем NRZI (напоминаю, что обычный XOR добавляет свою инверсию, для исправления чего мы вводим регистр маски x3, инициализируемый единицами 0xFF) и записываем в 0-й бит регистра shift [7,8]. Считывание очередного байта начинается на метке rxLoop, проводится инверсия предыдущего байта и запись в буфер [0, 3]. Неизменному биту, принятому с D- соответствует запись нуля (а не единицы! Дальше начинается самое интересное — проверяем не был ли принятый бит шестым неизменным. Поэтому нужно проверить не являются ли биты 0, 7, 6, 5, 4, 3 нулями. Менять на единицу будем в конце, XOR'ом) в регистр. Чтобы от них избавиться, обрежем регистр по маске [12], где выставлены в 1 все интересующие нас биты: 0b11111001 = 0xF9. Остальные два бита значения не имеют, они остались от предыдущего байта и были проверены раньше. Там считывается еще один бит [17] взамен считанного ранее, в промежутке между другими операциями, лишнего [9]. Если после наложения маски все биты оказались нулями, ситуация добавления бита зафиксирована и идет переход на метку unstuff0. Дело в том, что на каждом бите значение считывается в один регистр, а потом XOR`ится с другим, после чего регистры меняются местами. Также меняем местами регистры текущего и предыдущего значений x1, x2. Но самое интересное, что в регистр данных shift записываем не ноль, который получили честно, а единицу, которую пытался передать хост [18]. Соответственно, при чтении добавочного регистра эту операцию тоже нужно произвести. Таким образом, в регистре shift все биты инвертированы (относительно передаваемого хостом), а нулевой — нет. Связано это с тем, что при приеме следующих битов значение нулевого тоже придется учитывать, и если бы мы записали ноль, проверка по маске не могла бы узнать что добавочный бит уже учтен. Для этого на отсчете [15] и сбрасываем нулевой бит. Чтобы такая каша не была записана в буфер, обратную инверсию будем проводить по XOR'у не с 0xFF [0], а с 0xFE, то есть регистром, в котором соответствующий бит будет сброшен в 0 и, соответственно, не приведет к инверсии.

Скажем, 1-й бит соответствует проверке 1, 0, 7, 6, 5, 4, тогда как биты 2, 3 игнорируются. Аналогичная ситуация происходит с битами 1-5. Этому соответствует маска 0xF3.
А вот обработка 6 и 7 битов отличается:

didUnstuff5: andi shift, 0x3F ;1 [45] проверка битов 5-0 breq unstuff5 ;1 [46]
;<---//---> bld shift, 6 ;1 [52]
didUnstuff6: cpi shift, 0x02 ;1 [53] проверка битов 6-1 brlo unstuff6 ;1 [54]
;<---//---> bld shift, 7 ;1 [60]
didUnstuff7: cpi shift, 0x04 ;1 [61] проверка битов 7-2 brsh rxLoop ;3 [63]
unstuff7:

Кроме того, на отсчете [45] уже была наложена маска, обнулившая 7 бит. Маской для 6-го бита является число 0b01111110 (0x7E), но накладывать ее на регистр shift нельзя, поскольку она сбросит 0-й бит, который должен быть записан в массив. То есть значение регистра должно быть 0 или 1, что прекрасно проверяется сравнением «меньше, чем 2» [53, 54].
Тот же принцип использован для 7-го бита: вместо наложения маски 0xFC идет проверка на «меньше, чем 4» [61, 63]. Значит, обрабатывать добавочный бит надо, если биты 1-6 равны нулю, а 0-й не имеет значения.

Шаг 7. Сортируем пакеты

Кстати, почему это я назвал пакет 0x2D SETUP'ом, когда он вроде бы ACK? Раз уж мы можем принимать пакет с первым байтом (PID), равным 0x2D (SETUP), попробуем рассортировать принятое. Первое значащее поле, PID, занимает всего 4 бита, после чего идет еще 4 бита CHECK, представляющее побитовую инверсию поля PID. Дело в том, что передача по USB от младшего бита к старшему осуществляется в пределах каждого поля, а не байта, тогда как мы принимаем побайтно. Впрочем, особой разницы нет, поскольку все значения известны заранее, и переставить полубайты местами несложно. Таким образом, первым принятым байтом будет не PID+CHECK, а наоборот, CHECK+PID. Поэтому впоследствии этот участок будет вынесен в файл asmcommon.inc, который будет содержать ассемблерный код, не привязанный к частоте. Вот сразу и запишем в файле usbconfig.h основные коды, которые могут нам пригодиться.
Пока не начали дописывать код обработки PID'а, отметим, что он должен быть быстрым (то есть на ассемблере), но вот выравнивание по тактам не требуется, ведь пакет мы уже приняли. А пока просто выделим комментарием.
Теперь перейдем к сортировке принятых пакетов

Каждая транзакция начинается с посылки хостом специального маркер-пакета, несущего информацию о том, что же хост хочет делать с устройством: конфигурировать (SETUP), передавать данные (OUT) или принимать их (IN). Теоретическая вставка
Пакеты данных на шине USB объединяются в транзакции. Дальше следует пакет данных (DATA0 или DATA1), который может быть послан как хостом, так и устройством, в зависимости от маркер-пакета. После передачи маркер-пакета следует пауза длиной в два бита. Далее еще одна пауза в два бита длиной и ответ — HANDSHAKE, пакет подтверждения (ACK, NAK, STALL, их рассмотрим в другой раз).

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

Добавляем проверку принятого байта PID на соответствие каждому. Итак, мы знаем все типы пакетов, необходимые для обмена. Поэтому и команд типа IN ожидать не приходится. В настоящий момент устройство еще не умеет писать в шину даже такие примитивные пакеты как ACK, а значит неспособно рассказать хосту что же оно такое. Так что проверим только прием команд SETUP и OUT, для чего в соответствующих ветках пропишем включение соответствующих светодиодов.

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

2D|80|06|00|01|00|00|40|00 C3|80|06|00|01|00|00|40|00 2D|80|06|00|01|00|00|40|00 C3|80|06|00|01|00|00|40|00

Значит, и SETUP и OUT мы поймали. А кроме того — оба горящих светодиода.

Шаг 8. Читаем адрес на конверте

Конечные точки нужны для того, чтобы функционально выделить конкретную подфункцию устройства. Теоретическая вставка
Маркер-пакеты (SETUP, IN, OUT) служат не только для того, чтобы показать устройству, что от него хотят, но и чтобы адресоваться к конкретному устройству на шине и к конкретной конечной точке внутри него. Скажем, если устройство представляется переходником USB-COM, его основная задача — принимать данные с шины и передавать их в порт (первая конечная точка) и принимать данные с порта и отдавать в шину (вторая). Они могут различаться по частоте опроса, скорости обмена и прочим параметрам. Но помимо этого, время от времени устройство должно обмениваться с хостом состоянием управляющих линий (всякие RTS, DTR и прочие) и настройками обмена (скорость, проверки четности). По смыслу эти точки предназначены для большого потока неструктурированных данных. Кроме того, удобно, когда служебная информация не смешивается с данными. А вот тут больших объемов данных не предполагается. На практике, конечно, бывает по-разному…
Не менее интересный вопрос, зачем устройству передается его адрес, ведь кроме него в данный конкретный порт все равно ничего не воткнуть. Вот и получается, что для переходника USB-COM удобно использовать как минимум 3 конечные точки. Они могут быть достаточно «тупыми» и просто транслировать сигналы от хоста всем устройствам, не заботясь о сортировке. Это сделано для упрощения разработки USB-хабов. Структура таких пакетов приведена ниже:
поле
А уж устройство само разберется, обрабатывать пакет или проигнорировать.
Так вот, и адрес устройства, и адрес конечной точки содержатся в маркер-пакетах.

Из приведенной ранее схемы обмена видно, что сразу после маркер-пакета идет прием (если маркер-пакет содержал PID = SETUP или OUT) или передача (IN) пакета данных, за которым должно следовать подтверждение.

В глобальном смысле это означает, что мы должны от начала транзакции (маркер-пакет) и до самого подтверждения (Handshake) хранить:

  • адрес устройства: если адрес не соответствует нашему, надо не мешать общаться другим, даже NAK не слать
  • тип маркер-пакета: если это SETUP или OUT, идет прием, если IN — передача, причем
  • адрес конечной точки. Пока что неактуально, ведь точка будет всего одна и не будет сильно отличаться от устройства в целом, но в общем случае все же надо знать, с какой именно точкой желает общаться хост

Для второго параметра корректными значениями являются только три PID'а, к которым мы можем добавить четвертый, обозначающий как раз флажок игнора. Первый параметр можно оформить как флажок «отвечать — не отвечать» и совместить со вторым. Для хранения этого значения заведем переменную usbCurrentTok. Красивым значением для такого «PID» будет ноль. Отдельный вопрос что делать с данными, адресованными другому устройству? А вот PID'ы начала данных (DATA0, DATA1) маркерами не являются, соответственно и сохранять их не будем. Выйти из прерывания до окончания транзакции (событие SE0) мы не можем в любом случае, поскольку хост-то данные передает, и уровни на линиях D+, D- скачут. Варианта два: можно честно их считать, но потом проигнорировать (значение 0 в переменной usbCurrentTok позволит это сделать без проблем), либо даже не считывать, а просто подождать окончания транзакции. Теоретически, можно было бы завести отдельный таймер на ожидание окончания обмена по неверному адресу, а после его срабатывания запустить прерывание ожидания начала обмена снова. Значит мы тут же попадем в прерывание снова, но уже не в начало байта SYNC, а неизвестно куда. Да и занимать целый таймер под такое дело не хочется. Но на практике «тупые» хабы встречаются уже редко, и подавляющее большинство посылок будет адресовано именно нам.

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

В результате во втором байте, помимо адреса, оказался младший бит конечной точки, а к остальным битам этой точки примешалась контрольная сумма CRC (которую мы все равно не будем считать). Как уже упоминалось ранее, передача по USB осуществляется с младшего бита, но в пределах каждого отдельного поля, тогда как наша функция приема работает с байтами и знать не знает про поля. При этом в бит переноса улетает 0-й бит адреса конечной точки. Чтобы правильно считать из пакета адрес, просто побитово сдвинем его влево [21]. Правда, при этом в бездну улетает старший бит CRC, но мы все равно не собирались его использовать. Чтобы его приклеить на законное место, воспользуемся циклическим сдвигом [26].

Шаг 9. Безотказный прием

Но для универсальности стоит предусмотреть также передачу и NAK'а, и любого другого байта (храниться он будет в cnt — здесь это не счетчик а просто буфер для данных). Пришло время научиться не только принимать данные, но и передавать хосту отчет о приеме, пока что только «пакет принял», то есть ACK. Указателем на начало буфера назначим регистр Y, а количество элементов будем хранить в переменной с оригинальным названием cnt (не путать с передачей произвольного байта через тот же регистр). Поскольку в USB байты по одному не ходят, сначала сформируем буфер, а потом будем передавать его вместе со всеми обязательными байтами вроде SYNC и PID. Для этого положим его в регистр x3 и сообщим функции что он — буфер размером в 1 байт, ведь к регистрам общего назначения можно обратиться как к обычной памяти. Это все далеко идущие планы, а пока надо передать всего один байт — ACK. В нашем случае регистр x3 (он же r20) расположен по адресу 20.

Впрочем, к такому поведению хост готов и просто отключит наш порт после нескольких неудачных попыток. Поскольку адрес менять мы пока не умеем (это делается специальным запросом в SETUP, да еще и с подтверждением), но передавать ACK'и уже готовы, наступает немного опасный момент, когда устройство потенциально может мешать конфигурировать другие устройства, которым тоже пока не назначили адрес.

Проще всего это сделать операцией XOR по маске, чтобы не задеть остальные выводы порта, которые, скорее всего, будут использованы под какие-то другие задачи. Как мы помним, передача единицы осуществляется сохранением состояния линий D+, D- (что вообще не требует усилий от контроллера), а передача нуля — изменением.

Но, по сравнению с приемом, во время передачи бита свободного времени чуть больше, так что нет нужды проверять добавочные нули на каждом бите независимо. Передача байтов, так же как и прием, завязана на точный подсчет тактов, поэтому располагаться будет в части файла, зависящей от частоты. Поэтому авторы vusb применили хитрый трюк: цикл txBitloop обрабатывает по 2 бита за раз ([00], [08]). Но и завернуть все в цикл не выйдет. Остальные два бита передаются в линейном участке, чтобы можно было разместить код подготовки переменных к передаче следующего байта между строк. Прокрутив этот цикл 3 раза, передается 6 бит. Первые два раза результат получается отрицательным (если счетчик изначально меньше 171, а так как размер буфера не более 11 байт, это условие выполняется всегда), а на третий — неотрицательным, на единицу меньше исходного. Чтобы одновременно уменьшить счетчик на 1 и прокрутить цикл 3 раза применен хитрый способ: от переменной счетчика отнимается число 171. Столько в байт и влезает, бит переноса не выставляется
то же самое можно доказать и математически, но здесь этого делать не будем. Поясню на примере cnt=4:
4 — 171 = -167 = (сколько влезает в байт) 89 (+бит переноса)
89 — 171 = -82 = (сколько влезает в байт) 174 (+бит переноса)
174 — 171 = 3.

Добавление добавочных нулей после каждых 6 идущих подряд единиц осуществляется не по маскам, как в случае приема, а просто подсчетом в регистре x4. Таким образом, цикл прокручивается 3 раза, после чего счетчик уменьшается на 1. После передачи всего буфера не забываем переключить D+, D- обратно на вход, восстановить биты прерывания и т. п.
После всех манипуляций наблюдаем зажигание зеленого светодиода и следующий вывод:

2D|80|06|00|01|00|00|40|00 69|00|10|00|01|00|00|40|00

Скорее всего, основной цикл просто не успевает его поймать, ведь весь этот вывод по UART штука довольно медленная. Куда потерялся токен C3 неизвестно. Значит, мы на верном пути. Впрочем, уже то, что к нам начал приходить токен IN означает, что хост подозревает устройство не совсем безнадежным.

Шаг 10. Посылаем хоста NAK

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

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

Факт окончания обработки данных будем отслеживать по равенству нулю счетчика длины (usbRxLen), обнулять который будем в Си-шном коде. Собственно посылка NAK осуществляется из метки handleData в случае если пришел новый пакет, а из предыдущего данные еще не забрали [22]. Другая особая ситуация: хост может послать пакет с пустым полем данных, в котором обрабатывать вообще нечего, поэтому шлем ACK сразу. А если данных пока не было (задел на будущее — если данные уже обработаны), выставляем количество принятых байт в переменную usbRxLen, а токен, в честь которого данные посланы — в usbRxToken, поскольку разделить SETUP и OUT все-таки надо.

Дело в том, что обработка пакета штука долгая, особенно если это не какой-то из стандартных пакетов запроса, а что-то, связанное с юзерской задачей. Во всех же остальных случаях просто сохраняем принятый пакет в буфер и ждем пока основной цикл его обработает. Лучше дадим хосту знать, что пакет приняли, но ответа пока нет, пусть спросит попозже, а там авось Си-шный код придумает что ответить.
Как следствие, хост посылает запросы вроде Зачем же задерживать выполнение программы длительным нахождением в прерывании?

2D|80|06|00|01|00|00|40|00

Но устройство принимает только первый, отвечая на остальные NAK`ом, поскольку буфер уже занят, а чистить его пока некому.

Шаг 11. Обрабатываем запросы

Пора переходить на более высокий уровень — анализировать запросы хоста и пытаться на них адекватно реагировать. С приемом и передачей битов, байтов, пакетов и даже транзакций по большому счету закончили. Именно для этого мы выносили буфер и тому подобное в глобальные переменные. Положительный момент заключается в том, что хост не требует немедленной реакции, так что мы можем перестать, наконец, мучить прерывание, и отдать обработку основному коду. Вот только в цикле могут быть и другие задачи, помимо работы с USB, так что лучше сразу вынести эту задачу в отдельную функцию usbPoll. Теперь мы можем их читать из основного цикла. И если пришли — передать их в соответствующую функцию. Главное — надо проверить пришли ли вообще данные, или это был пустой пакет. В результате у нас целых 3 «мусорных» байта. Как мы помним на примере SETUP пакета, к данным примешиваются еще PID и CRC, но если в SETUP использовалась 5-битная контрольная сумма, то данные защищают аж 16-битной. И просто передадим полезные данные в функцию usbProcessRx, попутно указав правильное начало буфера приема, с учетом двойной буферизации и игнорирования токена. «Мусорных» потому что PID мы уже сохранили в переменной usbRxToken, а CRC проверять не обязательно, вот и не будем отъедать ресурсы контроллера такими глупостями, как надежность.

Если его обнаружили, надо сбросить состояние модуля USB как будто его выключали по питанию. Второй момент, который можно обработать, это упомянутое в самом начале особое состояние — сброс шины, то есть длительное удерживание SE0.

Пока что нас интересуют только пакеты типа SETUP, поскольку устройство еще не сумело рассказать хосту что же оно такое. Вернемся к обработке принятого пакета. Структура SETUP пакета описана в usbRequest_t и занимает всегда 8 байт. Так что других пакетов ждать все равно приходится. В перспективе отдадим обработку нестандартных запросов на реализацию юзеру, а сами будем обрабатывать стандартные. Запросы делятся на два основных класса: стандартные (которые должно обрабатывать любое USB-устройство) и нестандартные, то есть специфичные для каких-то конкретных. Но пока что просто будем выводить символы чего же нам прилетело.
А прилетает нам куча стандартных запросов, что, в принципе, не удивительно.

Шаг 12. подробности SETUP'а

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

Шаг 13. Отправляем ответ

После этого хост делает запрос на чтение IN и ждет от устройства пакета DATAx, на который готов выслать подтверждение. Как было показано раньше, хост начинает общение с пакетов SETUP + DATAx, причем в пакете DATAx хранится всегда 8 байт. Собственно функция отправки нескольких байт у нас есть, мы использовали ее чтобы послать ACK или NAK. До настоящего момента ответный пакет мы отправлять не умели, исправлением чего сейчас и займемся. Самое главное — посылка всего буфера usbTxBuf, точнее не всего, а только usbTxLen байт из него. Но посылка именно одного из них годится только для простых случаев, которые могут быть обнаружены ассемблерным куском кода. Сравним это с допустимыми значениями PID, с учетом того, что старший полубайт является инверсией младшего. Стандартом low-speed USB задан максимальный размер буфера на прием и передачу в 8 байт (плюс PID, плюс два байта CRC), значит и переменная usbTxLen никак не может быть больше 11. Так что значения PID с размером массива никак не спутать, чем и воспользуемся дабы не плодить переменные. Получается, что возможных значений всего 16, из которых наименьшее, 0x0F, вообще считается зарезервированным. Отправлять этот байт будем после приема пакета IN, как единственного места, где устройству вообще разрешено слать данные (handshake пакеты не в счет, они не для данных).

Устройство, точнее, функция usbPoll, как дойдут руки, анализирует принятый пакет и заполняет буфер ответа (пока что все заполнение заключается в записи PID=DATA1 (чем отличаются DATA0 и DATA1 расскажу чуть позже, пока что методом тыка выясняем, что хосту нравится только DATA1). То есть обмен данными выглядит так:
Хост шлет SETUP + DATAx, на что получает ACK или NAK в зависимости от состояния буфера приема. Послать мы их обязаны в любом случае, но раз данных нет, писать туда что-то не имеет смысла. Два байта CRC не заполняем. Обратите внимание, что формируем мы 3 байта, а посылаем 4. Ну и в конце концов задаем размер буфера для передачи — 4 байта. Во время подготовки ответа хост может периодически попинывать нас «ты там уже IN или еще NAK?» на что следует ответ NAK. Дело в том, что байт SYNC также будет послан и учитывается при подсчете. Но вот когда данные наконец готовы, ответ меняется на более осмысленный, DATA1 с содержимым буфера.

При помощи него хост пытается назначить нам адрес на шине. Попутно обработаем единственный интересующий нас запрос, не требующий развернутого ответа — USBRQ_SET_ADDRESS (остальные либо не интересны, либо требуют подробного ответа). S, псевдометка make SE0). Но фактическая запись произойдет только при отправке пустого пакета (drvsdm. Правда, он не понимает, почему вместо ответа на остальные запросы ему выдается не осмысленная информация, а такие же заглушки, так что делает несколько попыток выяснить класс устройства и прочие параметры, а после получения нереалистичных ответов признает устройство безнадежным. Раньше этого делать не стоило, поскольку хост бы не поверил что мы приняли адрес и готовы на него отзываться, а вот теперь, когда мы послали ему целый DATA1 с пустым полем данных, он понимает, что перед ним полноценное устройство и соглашается назначить ему адрес. Исправлением этого недостатка и займемся в дальнейшем, а пока можно прошить устройство данным кодом и полюбоваться, как ему назначают адрес на шине.

Шаг 14. Сортируем стандартные запросы

Как мы помним, типичными запросами были USBRQ_GET_DESCRIPTOR и USBRQ_SET_ADDRESS, впрочем, второй мы уже обработали. Прежде чем писать ответы, проанализируем что же именно спрашивает у нас хост. Находится интересующая нас информация в следующем байте запроса, после собственно USBRQ_GET_DESCRIPTOR. Теперь напишем функцию usbDriverDescriptor, выясняющую интересующий хоста дескриптор. 1 в нашем случае), тип устройства, производитель, номер устройства и т. п.
USBDESCR_CONFIG — конфигурация, то есть типы конечных точек, потребляемый ток и т. п.
USBDESCR_STRING — строковые описания устройства, производителя и версии.
Прошиваем, запускаем и видим, что хост много раз пытается выяснить USBDESCR_DEVICE, терпит неудачу, после чего даже не пытается задать остальные вопросы. Основные дескрипторы, которые мы будем обрабатывать, это:
USBDESCR_DEVICE — общее описание девайса: тип протокола USB (1.

Шаг 15. Заполняем анкетные данные

Во-первых, надо заполнить структуру описания устройства. Здесь придется единоразово внести большое количество изменений. Среди прочего надо указать Vendor ID и Product ID, которые выдаются только разработчиками стандарта USB, причем за весьма солидную плату. Описывать все ее поля не буду, лучше возьмем какую-то более-менее стандартную, например, HID, благо для использования не потребуется драйверов, да и сам протокол обмена не слишком сложный. К счастью, авторы vusb приобрели несколько штук и разрешили пользоваться всем желающим при соблюдении соответствующих условий.

Как было сказано выше, у одного устройства может быть несколько дескрипторов, плюс еще строковые значения, так что указатель именно на интересующий нас (точнее, хоста) кусок памяти запишем в переменную usbMsgPtr, а размер — в переменную len, которую впоследствии передадим в usbMsgLen. Местом для хранения структуры возьмем, разумеется, флеш-память контроллера. Значит, будем передавать по частям, в 3 этапа. Размер дескриптора устройства у нас (да и у всех остальных устройств) составляет 18 байт, тогда как в буфер помещается только 8. Если же во время чтения произошла какая-то ошибка, пошлем STALL.

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

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

Эти PID'ы при нормальной передаче должны чередоваться, так что если встречается два нулевых или два первых подряд, значит что-то пошло не так и надо это исправлять. Теоретическая вставка
Отдельные PID'ы для DATA0 и DATA1 используются для дополнительной защиты от ошибок передачи.

Для этого применяется операция XOR сначала с одним PID'ом, потом с другим. Когда мы передавали единственный пустой пакет данных, чередование DATA0 / DATA1 нас не интересовало (как не интересовало и не будет интересовать при приеме), но теперь первая же посылка, дескриптор, занимает аж 3 пакета, значит придется добавить чередование. Если первый пакет имел PID равный DATA1, то наложение XOR с тем же PID приведет к обнулению, а наложение XOR с DATA0 доведет изменение до конца.
Прошивка устройства этим кодом дает понять, что хоста ответ удовлетворил, и теперь он запрашивает USBDESCR_CONFIG. Как можно понять из определения операции, ее двойное применение равнозначно возвращению в исходное состояние, причем несколько XOR'ов друг другу не мешают.

Шаг 16. Наконец-то устройство!

Просто добавляем новый дескриптор (он уже менее стандартный, но описывать его я все равно не буду) и отсылаем. Обработка запроса USBDESCR_CONFIG ничем принципиально не отличается от USBDESCR_DEVICE. И наше устройство, наконец-то определяется системой именно как USB-устройство, а не просто непонятная кривулька, дрыгающая линиями D+, D-.

Строки различаются по номерам, указанным в трех предпоследних байтах дескриптора устройства (нулевой номер соответствует списку языков, который не совсем строка). Раз уж добавление дескрипторов оказалось такой простой задачей, добавим еще и все строковые описания: устройства, производителя, версию и язык. Что помешало разработчикам стандарта USB использовать стандартную и удобную UTF-8 мне неизвестно. Стоит отметить, что для строк используется кодировка UTF-16, то есть каждый символ кодируется двумя байтами.

Доступных свободно пар VID, PID у нас не так уж много, поэтому отличить устройство именно по ним не выйдет. В случае vusb наличие этих строк не просто приятная особенность, позволяющая просто по выводу lsusb определить что же подключено. А вот ограничений на формат строк описания мне неизвестно, поэтому искать нужное устройство будем по паре VID, PID, а потом уточнять наше — не наше именно по строковым описаниям.

Об этом говорит хост при передаче SETUP: как мы помним, он передает не только адрес устройства на шине, но и адрес конечной точки. Кстати, часть данных отправляется уже не через устройство в целом, а через первую конечную точку (других у нас и нет). Собственно, с момента запроса дескриптора конфигурации, общение в основном будет идти через точку, указанную там.
Вот теперь устройство отображается в системе как положено. Если это 0, то обращение идет к устройству в целом, а если нет — то к точке с соответствующим номером.

Шаг 17. Устройство становится человечнее (HID)

То есть для HID важнее скорость реакции, но не объем передаваемых данных или скорость. Теоретическая вставка
HID — human interface device, специальный тип устройств, предназначенный прежде всего для взаимодействия с человеком, с учетом его технических ограничений. Ну и упрощение «мозга» устройств тоже было важным моментом. Кроме того, упор делался на стандартизацию стандартных устройств, чтобы при подключении, скажем, мышки, не пришлось устанавливать специальные драйвера от производителя. В результате при разработке стандарта HID решили пожертвовать скоростью обмена (для low-speed она составляет всего лишь около 800 байт в секунду), но отсутствие необходимости в специальных драйверах компенсирует многое.

Поскольку реализация конкретного устройства не привязана к ядру библиотеки vusb, ее стоит отдать на откуп пользователю. Для общения по протоколу HID придется завести еще один дескриптор, передавая его по запросу USBDESCR_HID_REPORT. Кроме того, раньше мы использовали только пакеты SETUP, но теперь надо сделать отдельный обработчик для пакетов OUT. Так, для функции usbDriverSetup (стандартные запросы) появляется параллельная usbFunctionSetup (юзерские запросы). Чтобы код знал, какую из альтернатив запускать, в функции usbFunctionSetup взводится (или не взводится, если не нужен) специальный флажок USB_FLG_USE_USER_RW, а в usbDriverSetup он безоговорочно сбрасывается. Системные данные через них не передаются, так что принятое будет отправлено напрямую в юзерский код, точнее, в функцию usbFunctionWrite.
Доработать функцию чтения тоже придется, дополнив usbDeviceRead альтернативой usbFunctionRead, которая вместо дескрипторов и тому подобного заполняет буфер передачи юзерскими данными.

Просто анализируем пришедшие данные и работаем с кнопкой и светодиодами. Полезный функционал устройства — мигалка диодами и опрос кнопки — осуществляются именно в usbFunctionWrite и usbFunctionRead. Единственная сложность — данные могут прийти в несколько этапов, так что их придется склеивать из кусочков.
Напоследок подчистим прочие стандартные запросы в usbDriverSetup.

Шаг 18. Общаемся с железкой

Поскольку устройство у нас HID, писать драйвер не придется, можно ограничиться программой, запускаемой с правами обычного пользователя (но вот права доступа к самому устройству в udev прописать все-таки придется). Устройство готово, но чтобы его проверить до конца, надо научиться посылать ему запросы и получать ответы. Подробно расписывать ее работу особого смысла не вижу, кому будет интересно, разберется по коду, либо воспользуется без подробного изучения.
Теперь устройство успешно распознается операционной системой и реагирует на посылаемые байты. К сожалению, кроссплатформенной библиотеки под эту задачу я не нашел, пришлось писать свою.

Шаг 19. Сравниваем с vusb

S делится на частото-независимые usbdrvasm. Чтобы получить оригинальный код vusb осталось разделить наши три файла на части по смыслу и густо обмазать механизмами переносимости, защиты и универсальности.
drvasm. S и asmcommon.inc, а также частото-зависимый, один на выбор, usbdrvasm12.inc — usbdrvasm20.inc.
main.c делится на собственно main.c (юзерский код) и usbdrv.c (ядро библиотеки vusb)
usbconfig.h тонким слоем размазывается между предыдущими (некоторые объявления нужны только конкретному файлу), но основная суть, настройка конфигурации, остается в usbconfig.h.

Заключение

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

Использованная литература и полезные ссылки

Интерфейсы USB: Практика использования и программирования
https://radiohlam.ru/tag/usb/
http://we.easyelectronics.ru/electro-and-pc/usb-dlya-avr-chast-1-vvodnaya.html
http://usb.fober.net/cat/teoriya/ https://www.obdev.at/products/vusb/index.html (оф сайт vusb)
http://microsin.net/programming/arm-working-with-usb/usb-in-a-nutshell-part1.html
Агуров П.В.

S. P. Из-за особенностей местного редактора таблиц (наверное, это я с ним плохо разобрался) они выглядят гораздо менее информативно, чем хотелось

Теги
Показать больше

Похожие статьи

Добавить комментарий

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

Кнопка «Наверх»
Закрыть