Еще более низкий уровень (avr-vusb)
USB на регистрах: STM32L1 / STM32F1
USB на регистрах: interrupt endpoint на примере HID
USB на регистрах: isochronous endpoint на примере Audio device

В прошлый раз мы познакомились с общими принципами организации USB и собрали простое устройство, иллюстрирующее работу конечной точки типа Control. Пришло время изучать следующий тип — Bulk. Конечные точки такого типа предназначены для обмена большими объемами информации, причем чувствительной к надежности, но не скорости обмена.

Классические примеры — запоминающие устройства и переходники вроде USB-COM. Но переходники требуют еще наличия конечной точки типа Interrupt, которую мы пока «не проходили», так что остановимся на эмуляции флешки. Точнее, двух флешек одновременно.

Рекомендую параллельно сравнивать написанное с исходным кодом.

Дескриптор


static const uint8_t USB_ConfigDescriptor[] = {
  ARRLEN34(
  ARRLEN1(
    bLENGTH, // bLength: Configuration Descriptor size
    USB_DESCR_CONFIG,    //bDescriptorType: Configuration
    wTOTALLENGTH, //wTotalLength
    1, // bNumInterfaces
    1, // bConfigurationValue: Configuration value
    0, // iConfiguration: Index of string descriptor describing the configuration
    0x80, // bmAttributes: bus powered
    0x32, // MaxPower 100 mA
  )
  ARRLEN1(
    bLENGTH, //bLength
    USB_DESCR_INTERFACE, //bDescriptorType
    0, //bInterfaceNumber
    0, // bAlternateSetting
    2, // bNumEndpoints
    MSDCLASS_MSD, // bInterfaceClass: 
    MSDSUBCLASS_SCSI, // bInterfaceSubClass: 
    MSDPROTOCOL_BULKONLY, // bInterfaceProtocol: 
    0x00, // iInterface
  )
  ARRLEN1(
    bLENGTH, //bLength
    USB_DESCR_ENDPOINT, //bDescriptorType
    ENDP_NUM | 0x80,  //Endpoint address
    USB_ENDP_BULK,   //Bulk endpoint type
    USB_U16(ENDP_SIZE), //endpoint size
    0x00,   //Polling interval in milliseconds (ignored)
  )
  ARRLEN1(
    bLENGTH, //bLength
    USB_DESCR_ENDPOINT, //bDescriptorType
    ENDP_NUM,  //Endpoint address
    USB_ENDP_BULK,   //Bulk endpoint type
    USB_U16(ENDP_SIZE), //endpoint size
    0x00,   //Polling interval in milliseconds (ignored)
  )
  )
};

Здесь мы видим сначала заголовок дескриптора с полной длиной и другими неинтересными параметрами. Потом идет описание единственного интерфейса запоминающего устройства, в котором важно правильно указать поля Class, Subclass и Protocol — именно они отвечают за правильную идентификацию устройства в системе. Также важное поле bNumEndpoints, которое показывает сколько конечных точек нашему интерфейсу принадлежит. В нашем случае их две: на чтение и на запись. И тут же идут их описания, в которых внимание нужно уделить номеру (в номере конечной точки типа IN также выставлен 7-й бит, что прописано в дескрипторе как OR с 0x80) и размеру. Организация конечных точек в STM32 позволяет один номер точки использовать как на передачу, так и на прием. Существует еще альтернативный режим, в котором направление у точки одно, а буфер «комплементарной» точки используется для двойной буферизации. По идее, это может повысить скорость, но мы так делать пока не будем и воспользуемся более простым способом — приемник и передатчик, 0x01 и 0x81. А вот поле частоты опроса роли не играет вообще: пока данные для передачи есть, хост будет нашу точку дергать так часто, как только сможет, а когда данные закончатся — оставит в покое.

Еще пару слов надо сказать про размер конечных точек. Согласно стандарту [1], он должен быть равен 8, 16, 32 или 64 байта. Правда, покупные флешки как-то умудряются использовать и 512-байтные… В любом случае, делать полноценную флешку на контроллере общего назначения не самая удачная идея, так что оставим 64 байта. Да и места под буферы у нас немного.

Приведенные в этом примере константы для Class / Subclass / Protocol не являются единственно возможными. Скажем, можно попытаться эмулировать флоппик (Subclass = 0x04 вместо нашего 0x06). И оно при подключении даже показывает красивую иконку дисекты. Правда, не в винде — очевидно, она использует какие-то специфичные запросы и не верит, что бывают флоппики, их не поддерживающие. Но до специфичных запросов мы еще доберемся. Еще, если поменять Protocol, можно воспользоваться для обмена не только Bulk-точками, но и Interrupt. Но опять же, Interrupt мы не проходили, да и реальные флешки таким тоже не пользуются.

Помните, у нас в DeviceDescriptor (который почти ни за что не отвечает, поэтому не меняется и поэтому же здесь не приведен) есть поле iSerialNumber? Так вот, на этом поле растут грабли! Стандарт предписывает последние 12 символов использовать для идентификации экземпляра устройства. Соответственно, «хвост» этой строки должен представлять собой последовательность шестнадцатеричных цифр ('0'-'9', 'A'-'F'), закодированных в 16-битной кодировке. Есть подозрение, что перед ними можно оставить осмысленный текст. А практика показала, что и количество «цифр» может быть меньше 12-и.

Скажем, в моем примере вся строка состоит из единственного символа u''1'' и, кажется, работает. Но вот подставлять туда не-шестнадцатеричные символы все же не стоит: некоторые версии Windows такого пугаются и не хотят с устройством работать.

SETUP запросы


Несмотря на то, что обмен данными идет только через Bulk-точки, кое-какая информация передается и через ep0 по соответствующим запросам. Нам понадобится всего два таких запроса — USBCLASS_MSC_RESET и USBCLASS_MSC_GET_MAX_LUN, причем первый (ресет) мы пока проигнорируем. А вот второй стоит рассмотреть подробнее. Дело в том, что запоминающее устройство по логике авторов стандарта состоит из независимых логических блоков (адресуемых по logical unit number, LUN), с каждым из которых можно общаться независимо. Дальше мы увидим, что в протокол обмена всегда входит поле bLUN, именно за это отвечающее. Всего в одном устройстве их может быть до 15 штук. Правда, никто не запрещает сделать составное устройство, где по 15 «носителей» будет в каждом. В общем, важная это штука, обрабатываем обязательно. Тем более что в качестве ответа на этот запрос достаточно вернуть всего один байт с номером последнего unit'а. Важно! Не количество, а именно номер. То есть если устройство у нас всего одно с lun=0, то и вернуть надо 0, а не 1.

Принцип обмена bbb


BBB (bulk/bulk/bulk) или, что тоже самое, BOT (bulk only transport) — протокол обмена [1], при котором используется единственный тип конечной точки. Через нее передаются команды, через нее же передаются данные и через нее же успешность команд контролируется. Собственно всю логику обмена я сейчас и описал. Перейдем к подробностям:

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

struct usb_msc_cbw{
  uint32_t dSignature;
  uint32_t dTag;
  uint32_t dDataLength;
  uint8_t  bmFlags;
  uint8_t  bLUN;
  uint8_t  bCBLength;
  uint8_t  CB[16];
}__attribute__((packed));

поле dSignature — волшебное чиселко, равное 0x43425355 (или же 4 символа ''USBC''), передающееся хостом для синхронизации. Благодаря им устройство могло более-менее достоверно отличить начало команды от простого потока данных. Дальше идет dTag — порядковый номер команды чтобы если хост не дождался завершения команды и послал другую, смог отличить ответы. Это число надо будет куда-нибудь сохранить, а потом хосту вернуть.

Следующее поле, dDataLength, ограничивает количество байтов ответа. То есть наша посылка не может быть больше, чем dDataLength байт. Поле bmFlags для нас бесполезно, оно по большей части состоит из устаревших настроек. А вот bLUN это то, о чем я говорил раньше — номер «носителя», с которым хочет пообщаться хост. Если носитель у вас единственный, оно всегда будет равно нулю. Но в данном примере мы сделаем их два, так что этот самый LUN придется активно читать. bCBLength — снова бесполезное поле, которое показывает размер дополнительных данных… как будто мы его и так не знаем. И наконец, CB[] — данные, специфичные для конкретного запроса. Их мы будем рассматривать только применительно собственно к запросам. Хотя нет, не совсем так. Поле CB[0] собственно за запрос отвечает, поэтому его мы будем читать и по нему же определять как на данный запрос реагировать.

А реакция может заключаться либо в чтении данных размером dDataLength от хоста, либо запись данных того же объема. Формат этих данных зависит от принятой команды, так что пока не будем углубляться.

И наконец идет подтверждение — специальная структура следующего вида:

struct usb_msc_csw{
  uint32_t dSignature;
  uint32_t dTag;
  uint32_t dDataResidue;
  uint8_t  bStatus;
}__attribute__((packed));

Поле dSignature, как и в случае запроса, является магическим чиселком, но другим: 0x53425355 (оно же строка u''USBS''). А вот поле dTag должно в точности совпадать с полученным нами при начале обмена. Смысл следующего поля, dDataResidue, я не слишком понял. Вроде бы оно содержит количество данных, которое мы хотели передать хосту, но в dDataLength не влезло, но не похоже чтобы значение там на что-то влияло. Пожалуй, самое важное поле здесь — bStatus. Если что-то пошло не так, по нему хост может увидеть, что команда завершилась ошибкой и надо что-то делать.

В результате первая посылка всегда идет от хоста, вторая либо от хоста, либо от нас, и третья — всегда от нас.

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

static void msc_ep1_in(uint8_t epnum);

static void msc_ep1_out(uint8_t epnum){
  int left = sizeof(usb_msc_cbw_t) - msc_cbw_count;
  if(left > 0){ //чтение команды
    int sz = usb_ep_read(ENDP_NUM, (uint16_t*)&(((uint8_t*)&msc_cbw)[msc_cbw_count]) );
    msc_cbw_count += sz;
    if(msc_cbw_count == sizeof(usb_msc_cbw_t)){ //команда прочитана полностью
      scsi_command();
    }else return;
  }else if(bytescount < bytestoread){ //если разнести условие, произойдет повторное чтение буфера EP1_OUT, который был прочитан раньше, но size не сброшен (все равно этим железо занимается)
    uint8_t lun = msc_cbw.bLUN;
    int sz;
    if(lun == 0){
      sz = usb_ep_read(ENDP_NUM, (uint16_t*)&buffer[0]);
    }else{
      sz = usb_ep_read(ENDP_NUM, (uint16_t*)&rambuf[ start_lba*512 + bytescount ]);
      cur_count += sz;
    }
    bytescount += sz;
  }
  if(bytescount < bytestoread)return;
  msc_ep1_in(ENDP_NUM | 0x80);
}

static void msc_ep1_in(uint8_t epnum){
  if(! usb_ep_ready(epnum) )return;
  
  if(bytescount < bytestowrite){
    uint32_t left = bytestowrite - bytescount;
    if(left > ENDP_SIZE)left = ENDP_SIZE;
    if(block_count == 0){
      usb_ep_write(ENDP_NUM, &buffer[bytescount], left);
    }else{
      uint8_t lun = msc_cbw.bLUN;
      usb_ep_write(ENDP_NUM, &storage[lun].buf[start_lba*512 + bytescount], left);
      cur_count += left;
    }
    bytescount += left;
  }else{
    int32_t left = sizeof(msc_csw) - msc_csw_count;
    if(left > 0){
      if(left > ENDP_SIZE)left = ENDP_SIZE;
      usb_ep_write(ENDP_NUM, (uint8_t*)&(((uint8_t*)&msc_csw)[msc_csw_count]), left);
      msc_csw_count += left;
    }else if(left == 0){
      msc_cbw_count = 0;
      msc_csw_count = 0;
      bytestoread = 0;
      bytestowrite = 0;
      bytescount = 0;
      
      block_count = 0;
      cur_count = 0;
    }
  }
}

Как уже было сказано, обмен начинается с того, что в конечную точку OUT приходит посылка от хоста. Проблема в том, что размер посылки составляет 31 байт, а размер конечной точки может быть и 8 байт, так что стоит предусмотреть прием по частям. К счастью, пока мы не ответим на один запрос, другого нам слать не будут (если не вылетим по таймауту, конечно), поэтому для хранения запроса и ответа заведем глобальные переменные

usb_msc_cbw_t msc_cbw; //принимаемый от хоста запрос
uint8_t msc_cbw_count = 0; //сколько байтов уже принято

usb_msc_csw_t msc_csw = { //передаваемый хосту отчет
  .dSignature = 0x53425355,
};
uint8_t msc_csw_count = 0; //сколько байтов уже передано

И до тех пор пока количество принятых байтов не сравняется с размером запроса, читать будем именно туда. Если последний байт принят «в нашу смену», то не спешим выходить, а сразу запускаем обработчик команд scsi_command() и даже пересылку ответа (мы ведь помним, что для начала IN транзакции первый пакет надо передать вручную). Но вот запросы на чтение обрабатывать сразу не выйдет, ведь в буфере приема у нас «хвост» команды, а вовсе не данные.

Поэтому scsi_command() только выставляет количество данных (bytestoread). В частности, может выставить в 0 чтобы показать что чтение не нужно. Таким образом дальнейший прием будет повторяться пока количество реально принятых байтов bytescount не достигнет желаемого. После чего все равно произойдет ручной вызов обработчика конечной точки IN, которая пошлет если не данные, то хотя бы отчет об успешности.

Собственно, устройство точки IN не слишком отличается от OUT. Основная разница, что она сначала пытается передать bytestowrite байтов и только потом структуру msc_csw с использованием ее персонального счетчика msc_csw_count.

Организация памяти и прочие извращения


Раз уж решили реализовать несколько LUN'ов, имеет смысл и внутреннюю организацию им сделать максимально различной (впрочем, максимальной она не получилась, чуть позже объясню почему). Причем желательно обойтись без возни с подключением к контроллеру периферии Допустим, LUN=0 будет отображением части флешки контроллера (а поскольку возиться с записью на нее данных опять же лень, сделаем ее read-only), а LUN=1 — оперативки.

Объем флешки у L151 целых 256 кБ, но ведь нам его еще программировать, а это долго.

Ограничимся объемом 100 кБ: на таком объеме уже можно создать файловую систему FAT и даже место для файлов останется. Оперативки у нас поменьше, всего 32 кБ, от которых мы откусим 29 кБ и заполним первый «сектор» копией из образа флешки. Пусть тоже будет считаться FAT'ом, хотя и корявым. Впрочем, если будете экспериментировать с моим кодом, рекомендую взять образ флешки поменьше, чтобы не ждать минуту пока оно прошьется.

Не уверен, что это нужно, но все же расскажу как можно подготовить этот образ. Первым делом создаем «болванку» dd if=/dev/zero of=fatexample.img bs=1k count=100
Дальнейшие действия, увы, придется производить от рута:
создаем на «болванке» файловую систему mkfs.vfat fatexample.img
куда-нибудь ее монтируем mount fatexample.img /mnt -o user,umask=0
флажки в конце нужны чтобы пользоваться этой файловой системой мог обычный пользователь.

Собственно, от его (своего) имени и кидаем туда файлы. Из хулиганских соображений я предпочел записать туда исходники прошивки. Только надо учитывать, что винда не поймет обычный конец строки '\n', ей надо '\r\n'. То есть открываем каждый скопированный файл и меняем ему формат конца строки. Возможно, это как-то делается и из консоли, но я не искал.
Наконец снова заходим в рута дабы отмонтировать образ umount /mnt
Сразу предупреждаю: я не знаю как подобное делается в Windows. Не исключено, что по-человечески оно там вообще не делается, придется искать и скачивать какие-то сторонние программы. Ну или пользоваться для тестов каким-то из моих образов.

Теперь, когда бинарный образ готов, его надо как-то слинковать с остальным проектом. Для этого преобразуем образ в обычный объектный файл: arm-none-eabi-ld -r -b binary -o fatexample.o fatexample.img
Проблема в том, что линкер попытается разместить его в оперативной памяти и закономерно потерпит неудачу. Поэтому преобразуем его еще раз: arm-none-eabi-objcopy --rename-section .data=.rodata,alloc,load,readonly,data,contents fatexample.o fatexample.o
Вот теперь можно линковать. Чтобы не вводить эти команды каждый раз вручную, я прописал их прямо в makefile.

Обязательные команды


Все команды, с которыми мы будем иметь дело, относятся к семейству SCSI (Small Computer System Interface) — интерфейсу обмена данными с носителями информации и много чем еще. Причем не только по USB.

Обязательными для реализации являются следующие (достаточно подробно описаны тут):

#define SCSI_TEST_UNIT_READY 0x00 //проверка готовности LUN
#define SCSI_REQUEST_SENSE   0x03 //запрос подробностей при ошибках
#define SCSI_INQUIRY         0x12 //запрос подробностей по данному LUN'у
#define SCSI_READ_CAPACITY   0x25 //запрос емкости
#define SCSI_READ_10         0x28 //чтение
#define SCSI_WRITE_10        0x2A //запись

Также еще несколько запросов, которые формально не обязательны, но лучше бы их реализовать

#define SCSI_MODE_SENSE_6              0x1A //еще немного подробностей по LUN'у
#define SCSI_MMC_START_STOP_UNIT       0x1B //запрос на отключение
#define SCSI_MMC_PREVENT_ALLOW_REMOVAL 0x1E //еще один запрос на отключение
#define SCSI_MMC_READ_FORMAT_CAPACITY  0x23 //определение емкости (windows-specific)

Первым делом хост запрашивает у носителей данных подробности их внутреннего устройства, посылая для этого запрос SCSI_INQUIRY. В ответ он ожидает очередную волшебную структуру, подробно рассматривать которую я не хочу. Для нашего случая достаточно скопировать готовую и немного поиграться с константами. Скажем, поменять строку вендора. Или, например, нулевой байт изменить с 0x00 на 0x05 чтобы данный LUN считался не просто носителем, а CD/DVD диском. Правда, одного этого недостаточно: необходимо дописать поддержку каких-то специфичных запросы. Поэтому уж настолько извращаться не будем… а жаль

Далее идет запрос емкости (SCSI_READ_CAPACITY), на который надо ответить двумя 32-битными числами (суммарно 8 байт, очевидно): 0-3 байты это номер последнего блока, а 4-7 это размер одного блока. Дело в том, что носители данных не обеспечивают доступ к отдельному байту — только к блоку размером обычно 512 байт. На такие же блоки устройство поделим и мы.

Обратите внимание, что передается не количество блоков, а, как и в случае LUN, номер последнего. То есть uint32_t last_lba = capacity / 512 — 1;

Внимание, грабли! Винда почему-то не полагается на запрос SCSI_READ_CAPACITY, а отправляет SCSI_MMC_READ_FORMAT_CAPACITY с немного другим форматом. И ее не волнует что этот запрос не обязателен для реализации, так что после получения ошибки «запрос не поддерживается» она еще долго отключает-подключает устройство в надежде что то образумится. Если не хотите полчаса ждать пока винда таки смирится что неподдерживаемый запрос не поддерживается, лучше этот запрос реализовать. Как несложно догадаться, линукс ведет себя адекватно и понимает с первого раза.

Еще один запрос, который имеет смысл обработать — SCSI_MODE_SENSE_6. Точнее, опять забить вместо ответа чей-то готовый кусок. 1-й байт в нем отвечает за размер посылки и будет равен 3, второй байт описывает тип носителя (что-то вендор-специфичное, оставим ноль).

Третий байт самый для нас интересный. Его 7-й бит означает защиту от записи, его мы и взведем для 0-го LUN чтобы хост даже не пытался писать в нашу флеш-память. Вот в LUN=1 (оперативка) — другое дело, там этот бит будет нулевым и пусть пишет на здоровье.

Потом хост проверяет готов ли вообще носитель с нужным номером к обмену (SCSI_TEST_UNIT_READY), причем пакет данных ему для ответа не нужен — достаточно кода ошибки: готов — не готов.

Далее по хронологии должен идти рассказ про чтение и запись, но их мы оставим на конец.

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

Если что-то пошло не так (на этапе подтверждения устройство вернуло ошибку), хост запрашивает по этой ошибке подробности (SCSI_REQUEST_SENSE) — массив из трех байтов, кратко поясняющих что же именно случилось. Чаще всего у нас будет случаться SBC_SENSE_KEY_ILLEGAL_REQUEST (неподдерживаемый запрос), но в принципе таким же способом сообщают о неготовности носителя данных и многом другом. Кстати, раз запрос подробностей ошибки — отдельный запрос, то и хранить результат предыдущего надо в отдельной переменной, msc_sense.

READ / WRITE


Ну и наконец самое интересное — чтение и запись данных. На самом деле это не один запрос, а целое семейство, отличающихся размером. Скажем, бывают READ(6), READ(10), READ(12), даже READ(16), которые занимают в msc_cbw.CB соответственно 6, 10, 12 и 16 байтов. Мы будем пользоваться READ(10). И WRITE(10), естественно. Формат у них одинаковый, отличается только направление передачи: от хоста к устройству или от устройства к хосту. Структура запроса такова:

typedef struct{
  uint8_t opcode; //READ(10) / WRITE(10)
  uint8_t cdb_info1;
  uint32_t block_address;
  uint8_t cdb_info2;
  uint16_t length;
  uint8_t control;
}scsi_cbw_10_t;

Из нее нас интересуют только поля block_address и length — номер первого блока, который предстоит прочитать и количество этих блоков. Напоминаю, что размер блока мы сообщили хосту раньше, по запросу SCSI_READ_CAPACITY и что составляет он у нас 512 байт.

Следовательно, адрес первого байта, с которого начнем чтение, равен block_address * 512, а их суммарное количество length * 512.

Из соображений простоты демонстрационного кода (ну и из лени, конечно) посекторной работы с внешней памятью вы здесь не увидите. В самом деле, что к оперативке, что к флешке контроллера доступ побайтный. Но вот при взаимодействии с более сложной периферией вроде SD-карточек уже придется лавировать между прерываниями USB и этой периферии. Возможно, кстати, работу с USB будет проще осуществлять опросом, чем прерываниями. По той же причине выбор нужного буфера на чтение или запись остался в обработчике IN и OUT, а не в scsi_command.

Заключение


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

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

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

[1] Universal Serial Bus Mass Storage Class Bulk-Only Transport копия
[2] Universal Serial Bus Mass Storage Class Specification Overview копия
[3] SCSI Multimedia Commands – 2 (MMC-2) копия
[4] SCSI Primary Commands-3 (SPC-3) копия
[5] SCSI Commands Reference Manual (от Seagate) копия