Недавно я ковырялся с подключением своего устройства на микроконтроллере STM32F103 как USB Mass Storage Device, или по русски — как флешку. Вроде бы как все относительно несложно: в графическом конфигураторе STM32CubeMX в пару кликов сгенерировал код, добавил драйвер SD карты, и вуаля — все работает. Только очень медленно — 200кбайт/с при том, что пропускная способность шины USB в режиме Full Speed гораздо выше – 12 мБит/с (грубо 1.2 Мбайт/с). Более того, время старта моей флешки в операционной системе составляет около 50 секунд, что попросту некомфортно в работе. Раз уж я нырнул в эту область, то почему бы и не зачинить скорость передачи.
Вообще-то я уже писал свой драйвер для SD карты (точнее драйвер SPI), который работал через DMA и обеспечивал скорость до 500кб/с. К сожалению в контексте USB этот драйвер не заработал. Причиной всему сама модель общения USB — там все делается на прерываниях, тогда как мой драйвер был заточен под работу в обычном потоке. Да еще и припудрен примитивами синхронизации FreeRTOS.
В этой статье я сделал парочку финтов, которые позволили выжать максимум из связки USB и SD карточки подключенной к микроконтроллеру STM32F103 по SPI. Также тут будет про FreeRTOS, объекты синхронизации и общие подходы к передаче данных через DMA. Так что, думаю, статья будет полезна и тем кто только разбирается в контроллерах STM32, и инструментах вроде DMA, и подходах при работе с FreeRTOS. Код построен на основе библиотек HAL и USB Middleware из пакета STM32Cube, а также SdFat для работы с SD картой.
Обзор архитектуры
Если не вдаваться в подробности отдельных компонентов, то реализация Mass Storage Device (он же Mass Storage Class — MSC) на стороне микроконтроллера — штука сравнительно простая.
С одной стороны находится библиотека USB Core. Она занимается общением с хостом, обеспечивается регистрацию устройства и реализует всякие низкоуровневые штуки USB.
Драйвер Mass Storage (с помощью ядра USB) может принимать и отправлять хосту данные. Примерно как COM порт, только данные передаются блоками. Тут важно смысловое наполнение этих данных: передаются SCSI команды и данные к ним. Причем команд бегает всего несколько видов: прочитать данные, записать данные, узнать размер запоминающего устройства, узнать готовность устройства.
Задача драйвера MSC интерпретировать SCSI команды и перенаправлять вызовы в драйвер запоминающего устройства. Это может быть любое запоминающее устройство с блочным доступом (RAM диск, флешка, сетевое хранилище, компакт диск и др.). В моем случае запоминающее устройство это карточка MicroSD, подключенная через SPI. Набор функций, которые требуются от драйвера примерно такой же: читать, писать, отдавать размер и состояние готовности.
И вот тут появляется один важный нюанс, из-за которого собственно весь сыр-бор. Дело в том, что протокол USB — хост ориентированный. Только хост может стартовать транзакции, отправлять или забирать данные. С точки зрения микроконтроллера это означает что вся активность связанная с USB будет проходить в контексте прерывания. При этом у драйвера MSC будет вызван соответствующий обработчик.
Что касается отправки данных от микроконтроллера в сторону хоста. Микроконтроллер не может самостоятельно инициировать передачу данных. Максимум что может микроконтроллер это сигнализировать ядру USB, что есть данные, которые хост может забрать.
С самой SD картой тоже не все так просто. Дело в том, что карта является сложным устройством (по всей видимости там свой микроконтроллер стоит), а протокол общения весьма нетривиальный. Т.е. это не просто отправил/принял данные по определенному адресу (как в случае с каким нибудь I2C EEPROM модулем). Протокол общения с картой предусматривает целый набор различных команд и подтверждений, проверок контрольных сумм и соблюдений всяких таймаутов.
Я использую библиотеку SdFat. Она реализует работу с SD картой на уровне файловой системы FAT, что я активно использую в своем устройстве. В случае подключения по USB все что связано с файловой системой отключается (эта роль переходит хосту). Но что важно, библиотека отдельно выделяет драйвер карты с интерфейсом, практически таким как хочет того драйвер MSC — прочитать, записать, узнать размер.
Драйвер карты реализует протокол общения с картой через SPI. Он знает какие именно команды слать карте, в какой последовательности и какие ждать ответы. Но сам драйвер не занимается общением с железом. Для этого предусмотрен еще один уровень абстракции — драйвер SPI, который транслирует запросы чтения/записи отдельных блоков в собственно передачу данных по шине SPI. Вот именно в этом месте мне удалось организовать пересылку данных через DMA, что увеличило скорость передачи данных в обычном режиме, но поломало всю малину в случае USB (DMA в итоге пришлось отключить)
Но обо всем по порядку.
Какую проблему мы решаем?
Этот вопрос часто задает мой коллега, чем очень озадачивает собеседников во время технических споров.
Со всей этой кухней есть 2 проблемы:
- Низкая линейная скорость при работе из-под USB. В основном из-за и использования синхронных операций чтения/записи
- Высокая загрузка процессора (до 100%) — устройством становится невозможно пользоваться. Причина в отключенном DMA и необходимости гонять данные средствами процессора.
Но это со стороны контроллера, а есть еще аспекты протокола USB Mass Storage. Я поставил USB сниффер Wireshark и посмотрел какие именно пакеты бегают по шине и вижу еще как минимум 3 причины низкой скорости
- Хост шлет слишком много транзакций
- Транзакции растянуты во времени
- Сами операции чтения/записи происходят синхронно, дожидаясь окончания
Проблему количества транзакций решить довольно просто. Оказалось, что при подключении моего устройства операционка вычитывает всю таблицу FAT и делает еще много разных мелких чтений каталога и MBR. Флешка у меня на 8 гигов, форматирована в FAT32 с размером кластера 4кб. Получается что таблица FAT занимает порядка 8 Мб. При линейной скорости передачи в 200кб/с и получается почти 40 сек.
Самый простой способ сократить количество операций чтения при подключении устройства – уменьшить таблицу FAT. Достаточно просто переформатировать флешку и увеличить размер кластера (тем самым уменьшить их количество и размер таблицы). Я отформатировал карту установив размер кластера в 16кб – размер таблицы FAT стал чуть менее 2 Мб, а время инициализации сократилось до 20 секунд.
В любом случае переформатирование флешки не решает проблему линейной скорости (скорости с которой последовательно читаются большие файлики). Она по прежнему остается на уровне 200кб/с и грузит процессор по самое не хочу. Посмотрим что можно с этим сделать.
Что не так с DMA из-под USB?
Перейдем наконец к коду и посмотрим как у меня устроены чтение/запись на флеш карту (драйвер SPI)
В своем проекте я использую FreeRTOS. Это просто офигенный инструмент, который позволил мне каждую функцию моего устройства обрабатывать в отдельном потоке (задаче). Мне удалось выкинуть огромные машины состояний на все случаи жизни, а код стал существенно проще и понятнее. Все задачи работают одновременно, уступая друг дружке и синхронизируясь если нужно. Ну а если все потоки уснули в ожидании некоторого события, то можно использовать режимы энергосбережения микроконтроллера.
Код, который работает с SD картой так же работает в отдельном потоке. Это позволило написать функции чтения/записи весьма элегантно.
uint8_t SdFatSPIDriver::receive(uint8_t* buf, size_t n)
{
// Start data transfer
memset(buf, 0xff, n);
HAL_SPI_TransmitReceive_DMA(&spiHandle, buf, buf, n);
// Wait until transfer is completed
xSemaphoreTake(xSema, 100);
return 0; // Ok status
}
void SdFatSPIDriver::send(const uint8_t* buf, size_t n)
{
// Start data transfer
HAL_SPI_Transmit_DMA(&spiHandle, (uint8_t*)buf, n);
// Wait until transfer is completed
xSemaphoreTake(xSema, 100);
}
void SdFatSPIDriver::dmaTransferCompletedCB()
{
// Resume SD thread
xSemaphoreGiveFromISR(xSema, NULL);
}
Вся прелесть тут в том, что когда нам нужно прочитать или записать большой блок данных этот код не ждет завершения. Вместо этого запускается передача данных через DMA, а сам поток засыпает. В этом случае процессор может заниматься своими делами, а передача управления переходит другим потокам. Когда передача закончится вызовется прерывание от DMA и разбудит поток, который ждал пересылки данных.
Проблема в том, что такой подход сложно натянуть на модель USB где вся логика работы происходит в прерываниях, а не в обычном потоке выполнения. Т.е. получается, что запрос на чтение/запись мы получим в прерывании, и завершение передачи данных также придется ждать в этом же прерывании.
Пересылку через DMA в контексте прерывания мы, конечно, организовать сможем, но толку от этого будет мало. DMA хорошо работает там где можно запустить передачу и переключить процессор на какую нибудь другую полезную работу, пока передача данных не закончится. Но запустив передачу из прерывания мы не сможем прервать прерывание (извините за тавтологию) и пойти по своим делам. Придется там и висеть в ожидании окончания передачи. Т.е. операция получится синхронной и суммарное время окажется таким же как и в случае без DMA.
Тут гораздо интереснее было бы по запросу от хоста начать передачу данных по DMA и выйти из прерывания. А потом как нибудь на следующем прерывании отчитаться о проделанной работе.
Но это еще не вся картина. Если бы чтение с карты заключалось только в пересылке блока данных, то такой подход было бы не сложно реализовать. Но ведь передача по SPI это, безусловно, самая важная часть, но не единственная. Если посмотреть на чтение/запись блока данных на уровне драйвера карты, то процесс выглядит примерно так.
- Отправить карте команду, дождаться и проверить отклик
- Дождаться готовности карты
- Переслать данные (вот той самой функцией, которую я привел выше)
- Подсчитать контрольную сумму и сравнить ее мнением карты
- Завершить передачу
Если учесть, что этот по виду линейный алгоритм реализован серией вложенных вызовов функций, то рубить его посередине будет не очень разумно. Придется хорошенько перетрусить всю библиотеку. А если учесть, что в некоторых случаях передача может осуществляться не одним куском а в цикле серией маленьких блоков, то задача и вовсе становится невозможной.
Но не все так плохо. Если посмотреть еще выше — на уровень драйвера MSC — то ему вообще по барабану как именно будет происходить передача данных — одним блоком или несколькими, с DMA или без. Главное передать данные и отрапортовать о статусе.
Идеальным местом для экспериментов будет прослойка между драйвером MSC и драйвером карты. Перед всеми издевательствами этот компонент выглядел весьма тривиально — по сути это адаптер между интерфейсом, который хочет видеть драйвер MSC и тем что выдает драйвер карты.
int8_t SD_MSC_Read (uint8_t lun,
uint8_t *buf,
uint32_t blk_addr,
uint16_t blk_len)
{
(void)lun; // Not used
if(!card.readBlocks(blk_addr, buf, blk_len))
return USBD_FAIL;
return (USBD_OK);
}
int8_t SD_MSC_Write (uint8_t lun,
uint8_t *buf,
uint32_t blk_addr,
uint16_t blk_len)
{
(void)lun; // Not used
if(!card.writeBlocks(blk_addr, buf, blk_len))
return USBD_FAIL;
return (USBD_OK);
}
Как я уже говорил, драйвер карты не работает если его вызывать из-под прерывания. Но ведь он хорошо работает в обычном потоке. Так вот и запустим ему отдельный поток.
Этот поток будет получать запросы на чтение и запись через очередь. Каждый запрос включает информацию о типе операции (чтение/запись), номер блока, который нужно прочитать или записать, количество блоков и указатель на буфер данных. Еще я завел указатель на контекст операции — он нам понадобится чуть позже.
enum IOOperation
{
IO_Read,
IO_Write
};
struct IOMsg
{
IOOperation op;
uint32_t lba;
uint8_t * buf;
uint16_t len;
void * context;
};
// A queue of IO commands to execute in a separate thread
QueueHandle_t sdCmdQueue = NULL;
// Initialize thread responsible for communication with SD card
bool initSDIOThread()
{
// Initialize synchronisation
sdCmdQueue = xQueueCreate(1, sizeof(IOMsg));
bool res = card.begin(&spiDriver, PA4, SPI_FULL_SPEED);
return res;
}
Сам поток спит в ожидании команд. Если пришла команда, то выполняется нужная операция, причем синхронно. По окончании операции вызываем коллбек, который в зависимости от реализации сделает то, что нужно по окончании операции чтения/записи.
extern "C" void cardReadCompletedCB(uint8_t res, void * context);
extern "C" void cardWriteCompletedCB(uint8_t res, void * context);
void xSDIOThread(void *pvParameters)
{
while(true)
{
IOMsg msg;
if(xQueueReceive(sdCmdQueue, &msg, portMAX_DELAY))
{
switch(msg.op)
{
case IO_Read:
{
bool res = card.readBlocks(msg.lba, msg.buf, msg.len);
cardReadCompletedCB(res ? 0 : 0xff, msg.context);
break;
}
case IO_Write:
{
bool res = card.writeBlocks(msg.lba, msg.buf, msg.len);
cardWriteCompletedCB(res? 0 : 0xff, msg.context);
break;
}
default:
break;
}
}
}
}
Поскольку все это выполняется в рамках обычного потока, то драйвер карты внутри может использовать DMA и синхронизацию FreeRTOS.
Функции MSC стали чуть сложнее, но ненамного. Теперь вместо непосредственного чтения или записи этот код отправляет запрос в соответствующий поток.
int8_t SD_MSC_Read (uint8_t lun,
uint8_t *buf,
uint32_t blk_addr,
uint16_t blk_len,
void * context)
{
// Send read command to IO executor thread
IOMsg msg;
msg.op = IO_Read;
msg.lba = blk_addr;
msg.len = blk_len;
msg.buf = buf;
msg.context = context;
if(xQueueSendFromISR(sdCmdQueue, &msg, NULL) != pdPASS)
return USBD_FAIL;
return (USBD_OK);
}
int8_t SD_MSC_Write (uint8_t lun,
uint8_t *buf,
uint32_t blk_addr,
uint16_t blk_len,
void * context)
{
// Send read command to IO executor thread
IOMsg msg;
msg.op = IO_Write;
msg.lba = blk_addr;
msg.len = blk_len;
msg.buf = buf;
msg.context = context;
if(xQueueSendFromISR(sdCmdQueue, &msg, NULL) != pdPASS)
return USBD_FAIL;
return (USBD_OK);
}
Тут есть важный момент — изменилась семантика этих функций. Теперь они асинхронные, т.е. не ждут реального окончания операции. Так что нужно будет еще подправить код, который их вызывает, но этим мы займемся чуть позже.
А пока, чтобы проверить эти функции сделаем еще один тестовый поток. Он будет эмулировать USB ядро и посылать запросы на чтение.
uint8_t io_buf[1024];
static TaskHandle_t xTestTask = NULL;
void cardReadCompletedCB(bool res, void * context)
{
xTaskNotifyGive(xTestTask);
}
void cardWriteCompletedCB(bool res, void * context)
{
xTaskNotifyGive(xTestTask);
}
void xSDTestThread(void *pvParameters)
{
xTestTask = xTaskGetCurrentTaskHandle();
uint32_t prev = HAL_GetTick();
uint32_t opsPer1s = 0;
uint32_t cardSize = card.cardSize();
for(uint32_t i=0; i<cardSize; i++)
{
opsPer1s++;
if(SD_MSC_Read(0, io_buf, i, 2, NULL) != 0)
usbDebugWrite("Failed to read block %d\r\n", i);
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
if(HAL_GetTick() - prev > 1000)
{
prev = HAL_GetTick();
usbDebugWrite("Reading speed: %d kbytes/s\r\n", opsPer1s);
opsPer1s = 0;
}
}
while(true)
;
}
Этот код считывает всю карту от начала до конца блоками по 1кб и измеряет скорость чтения. Каждая операция чтения отправляет запрос в поток SD карты. Там синхронно происходит чтение и рапортует об окончании через обратный вызов. Я подставил свою реализацию этого коллбека, которая просто сигнализирует тестовому потоку, что можно продолжать (тестовый поток все это время спит в функции ulTaskNotifyTake() ).
Но самое главное, скорость чтения в таком варианте составляет около 450кб/с, а процессор загружен всего на 3-4%. По моему неплохо.
Прокачиваем драйвер MSC
Итак, драйвер карты мы победили, включив DMA. Но семантика чтения/записи поменялась с синхронной на асинхронную. Теперь нужно подправить реализацию MSC и научить ее работать с асинхронными вызовами. Т.е. на первый запрос от хоста нам нужно начать передачу через DMA, а на все последующие как-то отвечать, мол “предыдущая операция еще не закончилась, загляни позже”.
Вообще-то протокол USB предоставляет такой механизм прямо из коробки. Приемная сторона подтверждает пересылку данных неким статусом. Если данные приняты и обработаны успешно, то приемник подтверждает транзакцию статусом ACK. Если устройство не может обработать транзакцию (не инициализировано, находится в состоянии ошибки или не работает по какой либо другой причине), то ответом будет статус STALL.
А вот если устройство распознало транзакцию, находится в работоспособном состоянии, но данные еще не готовы, то устройство может ответить NAK. В этом случае хост обязан обратиться к устройству с точно таким же запросом чуть позже. Этот статус мы могли бы использовать для отложенного чтения/записи – на первый вызов хоста начинаем передачу данных через DMA, но отвечаем на транзакцию NAK. Когда хост приходит с повторной транзакцией и пересылка через DMA уже закончилась – отвечаем ACK.
К сожалению я не нашел в библиотеке USB от ST хорошего способа отправлять сигнал NAK. Коды возврата функций либо не проверяются, либо могут обрабатывать только 2 состояния – все хорошо, либо ошибка. Во втором случае все конечные точки закрываются, везде выставляется статус STALL.
Я подозреваю, что на уровне самом низком уровне USB драйвера подтверждение NAK используется довольно активно, но как правильно воткнуться с NAK на уровне драйвера класса я не разобрался.
По всей видимости создатели библиотек от ST вместо различных подтверждений предоставили более человечный интерфейс. Если устройству есть что отправить хосту оно вызывает функцию USBD_LL_Transmit() — хост сам заберет предоставленные данные. А если функция не была вызвана, то устройство будет автоматически отвечать NAK ответами. Примерно такая же ситуация с приемом данных. Если устройство готово к приему, то оно вызывает функцию USBD_LL_PrepareReceive(). В противном случае устройство будет отвечать NAK если хост попытается передать данные. Воспользуемся этим знанием для реализации нашего MSC драйвера.
Давайте посмотрим какие транзакции бегают по шине USB (анализ производился до изменений в драйвере карты).
Тут интересно даже не сами транзакции, а их временнЫе отметки. Транзакции на этой картинке я выбрал «легкие» — такие, которые не требуют обработки. Микроконтроллер на такие запросы отвечает захардкоженными ответами, особо не размышляя. Важно тут то, что хост не пуляет транзакциями сплошным потоком. Транзакции идут не чаще чем раз в 1 мс. Даже если ответ готов сразу, хост заберет его только на следующей транзакции через 1мс.
А вот так выглядит чтение одного блока данных в терминах транзакций на шине USB.
Сначала хост отправляет SCSI команду на чтение, а потом отдельными транзакциями читает данные (вторая строка) и статус (третья). Первая транзакция – самая длинная. Во время обработки этой транзакции микроконтроллер как раз и занимается вычиткой с карты. И, опять же, между транзакциями хост выдерживает паузу в 1мс.
Алгоритм драйвера MSC на стороне микроконтроллера выглядит примерно так
- Транзакция SCSI: Read(10) LUN: 0x00 (LBA: 0x00000000, Len: 1)
- Хост отправляет команду на чтение. Со стороны микроконтроллера вызывается функция MSC_BOT_DataOut()
- Команда обрабатывается по цепочке функций MSC_BOT_DataOut() -> MSC_BOT_CBW_Decode() -> SCSI_ProcessCmd() -> SCSI_Read10()
- Поскольку драйвер находится в состоянии hmsc->bot_state == USBD_BOT_IDLE, то готовится процедура чтения: проверяются параметры команды, запоминается сколько всего блоков нужно прочитать, после чего передается управление функции SCSI_ProcessRead() с просьбой прочитать первый блок
- Функция SCSI_ProcessRead() читает данные в синхронном режиме. Именно тут микроконтроллер занят бОльшую часть времени.
- Когда данные получены они перекладываются (с помощью функции USBD_LL_Transmit() ) в выходной буфер конечной точки MSC_IN, чтобы хост мог их забрать
- Драйвер переходит в состояние hmsc->bot_state = USBD_BOT_DATA_IN
- Транзакция SCSI: Data In
- Хост забирает данные из выходного буфера микроконтроллера пакетами по 64 байта (максимальный рекомендованный размер пакета для USB Full Speed устройств). Все это происходит на самом низком уровне в ядре USB, драйвер MSC в этом не участвует
- Когда хост забрал все данные возникает событие Data In. Управление передается в функцию MSC_BOT_DataIn(). Акцентирую Ваше внимание, что эта функция вызывается после реальной отправки данных.
- Драйвер находится в состоянии hmsc->bot_state == USBD_BOT_DATA_IN, что означает мы все еще в режиме чтения данных.
- Если еще не все заказанные блоки прочитаны – стартуем чтение очередного кусочка и ждем завершения, перекладываем в выходной буфер и ждем пока хост заберет данные. Алгоритм повторяется
- Если все блоки прочитаны, то драйвер переключается в состояние USBD_BOT_LAST_DATA_IN для отправки финального статуса команды
- Транзакция SCSI: Response
- К этому моменту данные посылки уже отправлены
- драйвер лишь получает об этом уведомление в переходит в состояние USBD_BOT_IDLE
Самая длинная операция в этой схеме это собственно чтение с карты. По моим замерам чтение занимает порядка 2-3мс в синхронном режиме. Причем передача происходит средствами процессора и все это происходит в прерывании USB. Для сравнения, вычитка одного блока длиной в 512 через DMA занимает чуть более 1мс.
У меня не получилось существенно (скажем до 1Мб/с) ускорить чтение данных – видимо такова пропускная способность карты подключенной по SPI. Но мы можем попробовать поставить к себе на службу 1мс паузы между транзакциями.
Я это вижу так (слегка упрощенно)
- Транзакция SCSI: Read(10) LUN: 0x00 (LBA: 0x00000000, Len: 1)
- Микроконтроллер получает команду на чтение, проверяет все параметры, запоминает количество блоков, которые нужно прочитать
- Микроконтроллер стартует чтение первого блока в асинхронном режиме
- Выходим из прерывания не дожидаясь окончания чтения
- Когда чтение закончилось вызывается коллбек
- Прочитанные данные отправляются в выходной буфер
- Хост их вычитывает без участия драйвера MSC
- Транзакция SCSI: Data In
- Вызывается коллбек функция DataIn(), которая сигнализирует о том, что хост забрал данные и можно делать следующее чтение
- Запускаем чтение следующего блока. Алгоритм повторяется начиная с обратного вызова о завершении чтения
- Если все блоки прочитаны – отправляем пакет статуса
- Транзакция SCSI: Response
- К этому моменту данные посылки уже отправлены
- Готовимся к следующей транзакции
Попробуем реализовать такой подход, благо функция SCSI_ProcessRead() легко разделяется на “до” и “после”. Т.е код который запускает чтение будет выполнятся в контексте прерывания, а оставшийся код переедет в коллбек. Задача этого обратного вызова затолкать прочитанные данные в выходной буфер (хост потом как нибудь заберет эти данные соответствующими запросами)
/**
* @brief SCSI_ProcessRead
* Handle Read Process
* @param lun: Logical unit number
* @retval status
*/
static int8_t SCSI_ProcessRead (USBD_HandleTypeDef *pdev, uint8_t lun)
{
USBD_MSC_BOT_HandleTypeDef *hmsc = pdev->pClassDataMSC;
uint32_t len;
len = MIN(hmsc->scsi_blk_len , MSC_MEDIA_PACKET);
if( pdev->pClassSpecificInterfaceMSC->Read(lun ,
hmsc->bot_data,
hmsc->scsi_blk_addr / hmsc->scsi_blk_size,
len / hmsc->scsi_blk_size,
pdev) < 0)
{
SCSI_SenseCode(pdev,
lun,
HARDWARE_ERROR,
UNRECOVERED_READ_ERROR);
return -1;
}
hmsc->bot_state = USBD_BOT_DATA_IN;
return 0;
}
void cardReadCompletedCB(uint8_t res, void * context)
{
USBD_HandleTypeDef * pdev = (USBD_HandleTypeDef *)context;
USBD_MSC_BOT_HandleTypeDef *hmsc = pdev->pClassDataMSC;
uint8_t lun = hmsc->cbw.bLUN;
uint32_t len = MIN(hmsc->scsi_blk_len , MSC_MEDIA_PACKET);
if(res != 0)
{
SCSI_SenseCode(pdev,
lun,
HARDWARE_ERROR,
UNRECOVERED_READ_ERROR);
return;
}
USBD_LL_Transmit (pdev,
MSC_IN_EP,
hmsc->bot_data,
len);
hmsc->scsi_blk_addr += len;
hmsc->scsi_blk_len -= len;
/* case 6 : Hi = Di */
hmsc->csw.dDataResidue -= len;
if (hmsc->scsi_blk_len == 0)
{
hmsc->bot_state = USBD_BOT_LAST_DATA_IN;
}
}
В коллбеке нужно обращаться к нескольким переменным, которые определялись в функции SCSI_ProcessRead() — указатель на хендл USB, длину передаваемого блока, LUN. Вот тут как раз и пригодился параметр context. Я, правда, не все передавал, а только pdev, а все остальное можно выудить из него. Как по мне такой подход проще чем тягание целой структуры с нужными полями. И, во всяком случае, это лучше чем заводить несколько глобальных переменных.
Добавим двойной буфер
Подход, в целом, заработал, но скорость была по прежнему чуть больше 200кб/с (хотя загрузка процессора починилась и стала около 2-3%). Давайте разбираться что же мешает работать быстрее.
По советам в комментариях к одной из моих статей я таки обзавелся осциллографом (пускай и дешевеньким). Он оказался очень кстати для понимания что вообще там происходит. Я взял неиспользуемый пин и выставлял на нем единицу перед началом чтения и ноль после того как чтение закончилось. На осциллографе процесс чтения выглядел так.
Т.е. само чтение 512 байт занимает чуточку больше 1мс. Когда чтение с карты заканчивается данные передаются в выходной буфер, откуда в течении следующих 1мс хост их забирает. Т.е. тут либо происходит чтение с карты, либо передача по шине USB, но не одновременно.
Обычно такая ситуация решается с помощью двойной буферизации. Более того, USB периферия микроконтроллеров STM32F103 уже предлагает механизмы для двойной буферизации. Только они нам не подойдут по двум причинам:
- Для использования двойной буферизации, которую предлагает сам микроконтроллер, возможно, придется перекроить USB ядро и реализацию MSC
- Размер буфера всего 64 байта, тогда как SD карта блоками меньше чем 512 байт работать не умеет.
Так что нам придется изобрести свою реализацию. Впрочем, это не должно быть сложно. Во-первых зарезервируем место под второй буфер. Я не стал заводить ему отдельную переменную, а просто увеличил существующий буфер в 2 раза. Еще пришлось завести переменную bot_data_idx, которая будет указывать какая половина этого двойного буфера сейчас используется: 0 — первая половина, 1 — вторая.
typedef struct _USBD_MSC_BOT_HandleTypeDef
{
...
USBD_MSC_BOT_CBWTypeDef cbw;
USBD_MSC_BOT_CSWTypeDef csw;
uint16_t bot_data_length;
uint8_t bot_data[2 * MSC_MEDIA_PACKET];
uint8_t bot_data_idx;
...
}
USBD_MSC_BOT_HandleTypeDef;
К слову, структуры cbw и csw весьма чувствительны к выравниванию. Некоторые значения неверно записывались или читались из полей этих структур. Поэтому пришлось перенести их выше чем буферы данных.
Оригинальная реализация работала на прерывании DataIn — сигнале о том, что данные отправились. Т.е. по команде от хоста запускалось чтение, после чего данные прекладывались в выходной буфер. Чтение очередной порции данных “перезаряжалось” по прерыванию DataIn. Нам такой вариант не подходит. Мы будем начинать чтение сразу после того как предыдущее чтение закончилось.
void cardReadCompletedCB(uint8_t res, void * context)
{
USBD_HandleTypeDef * pdev = (USBD_HandleTypeDef *)context;
USBD_MSC_BOT_HandleTypeDef *hmsc = pdev->pClassDataMSC;
uint8_t lun = hmsc->cbw.bLUN;
uint32_t len = MIN(hmsc->scsi_blk_len , MSC_MEDIA_PACKET);
if(res != 0)
{
SCSI_SenseCode(pdev,
lun,
HARDWARE_ERROR,
UNRECOVERED_READ_ERROR);
return;
}
// Synchronization to avoid several transmits at a time
// This must be located here as it waits finishing previous USB transfer
// while the code below prepares next one
pdev->pClassSpecificInterfaceMSC->OnFinishOp();
// Save these values for transmitting data
uint8_t * txBuf = hmsc->bot_data + hmsc->bot_data_idx * MSC_MEDIA_PACKET;
uint16_t txSize = len;
// But before transmitting set the correct state
// Note: we are in context of SD thread, not the USB interrupt
// So values have to be correct when DataIn interrupt occurrs
hmsc->scsi_blk_addr += len;
hmsc->scsi_blk_len -= len;
/* case 6 : Hi = Di */
hmsc->csw.dDataResidue -= len;
if (hmsc->scsi_blk_len == 0)
{
hmsc->bot_state = USBD_BOT_LAST_DATA_IN;
}
else
{
hmsc->bot_data_idx ^= 1;
hmsc->bot_data_length = MSC_MEDIA_PACKET;
SCSI_ProcessRead(pdev, lun); // Not checking error code - SCSI_ProcessRead() already enters error state in case of read failure
}
// Now we can transmit data read from SD
USBD_LL_Transmit (pdev,
MSC_IN_EP,
txBuf,
txSize);
}
Эта функция немного поменяла структуру. Во-первых, именно тут реализована поддержка двойной буферизации. Поскольку эта функция вызывается когда чтение с карты закончено, то мы сразу можем запустить следующее чтение вызовом SCSI_ProcessRead(). Чтобы новое чтение не затерло только что прочитанные данные как раз и используется второй буфер. За переключение буферов отвечает переменная bot_data_idx.
Но это еще не все. Во-вторых изменилась последовательность действий. Теперь сначала заряжается чтение очередного блока данных и только потом вызывается USBD_LL_Transmit(). Так сделано потому, что функция cardReadCompletedCB() вызывается в контексте обычного потока. Если вызвать USBD_LL_Transmit() вначале, а потом менять значения полей hmsc, то потенциально в этот момент может вызваться прерывание от USB, которое также захочет менять эти поля.
В-третьих пришлось прикрутить дополнительную синхронизацию. Дело в том, что обычно чтение с карты занимает чуточку больше времени чем передача по USB. Но иногда бывает наоборот и тогда вызов USBD_LL_Transmit() для очередного блока случается раньше чем предыдущий блок был полностью отправлен. USB ядро от такой наглости дуреет и данные отправляются неверно.
Отправка данных (Transmit) подтверждается событием Data In, но иногда несколько Transmit'ов происходят подряд. Для таких случаем нужна синхронизация.
Решается это очень просто добавлением небольшой синхронизации. Я добавил в интерфейс USBD_StorageTypeDef парочку функций с довольно простой реализацией (хотя, возможно, названия не очень удачные). В реализации используется обычный семафор в режиме signal-wait. OnFinishOp(), которая вызывается, в коллбеке cardReadCompletedCB() будет спать и ждать пока предыдущий пакет данных отправится.
Факт отправки подтверждается событием DataIn, которое обрабатывается функцией SCSI_Read10(), которая вызовет OnStartOp(), которая разблокирует OnFinishOp(), которая отправит очередной пакет данных
void SD_MSC_OnStartOp()
{
xSemaphoreGiveFromISR(usbTransmitSema, NULL);
}
void SD_MSC_OnFinishOp()
{
xSemaphoreTake(usbTransmitSema, portMAX_DELAY);
}
С такой синхронизацией картинка приобретает следующий вид.
Красными стрелками показана синхронизация. Последний Transmit ждет предыдущий Data In
Последний кусочек паззла — функция SCSI_Read10().
/**
* @brief SCSI_Read10
* Process Read10 command
* @param lun: Logical unit number
* @param params: Command parameters
* @retval status
*/
static int8_t SCSI_Read10(USBD_HandleTypeDef *pdev, uint8_t lun , uint8_t *params)
{
USBD_MSC_BOT_HandleTypeDef *hmsc = pdev->pClassDataMSC;
// Synchronization to avoid several transmits at a time
pdev->pClassSpecificInterfaceMSC->OnStartOp();
if(hmsc->bot_state == USBD_BOT_IDLE) /* Idle */
{
// Params checking
…
hmsc->scsi_blk_addr = ...
hmsc->scsi_blk_len = ...
hmsc->bot_state = USBD_BOT_DATA_IN;
...
hmsc->bot_data_idx = 0;
hmsc->bot_data_length = MSC_MEDIA_PACKET;
return SCSI_ProcessRead(pdev, lun);
}
return 0;
}
В оригинальной реализации SCSI_Read10() на первый вызов функции проверялись параметры и запускался процесс чтения первого блока. Эта же функция вызывается позже по прерыванию DataIn когда предыдущий пакет уже отправлен и нужно запускать чтение следующего. Обе ветки запускали чтение с помощью функции SCSI_ProcessRead().
В новой реализации вызов SCSI_ProcessRead() переехал внутрь if’а и вызывается только для чтения первого блока (bot_state == USBD_BOT_IDLE), тогда как чтение последующих блоков запускается из cardReadCompletedCB().
Давайте посмотрим что из этого получилось. Я специально добавил небольшие задержки между чтениями блоков, чтобы на осциллографе увидеть вот такие зазубрины. На самом деле между операциями чтения проходит так мало времени, что мой осциллограф этого не видит.
Как видно из этой картинке затея удалась. Новая операция чтения стартует сразу как только предыдущая закончилась. Паузы между чтениями довольно маленькие и диктуются, в основном, хостом (та самая задержка в 1мс между транзакциями). Средняя скорость чтения больших файлов достигает 400-440кб/с, что весьма неплохо. И, наконец, загрузка процессора составляет около 2%.
А как же запись?
Пока я тактично обходил тему записи на карту. Но теперь с полученными знаниями и пониманием работы драйвера MSC реализация функции записи не должна быть сложной.
Оригинальная реализация работает примерно так.
- Транзакция SCSI Write
- Команда обрабатывается по цепочке функций MSC_BOT_DataOut() -> MSC_BOT_CBW_Decode() -> SCSI_ProcessCmd() -> SCSI_Write10()
- Поскольку драйвер находится в состоянии hmsc->bot_state == USBD_BOT_IDLE, то готовится процедура записи: проверяются параметры команды, запоминается сколько всего блоков нужно будет записать
- Вызывается функция USBD_LL_PrepareReceive() которая готовит периферию USB к приему блока данных.
- Драйвер переходит в состояние hmsc->bot_state = USBD_BOT_DATA_OUT
- Транзакция SCSI: Data Out
- Устройство принимает данные пакетами по 64 байта и укладывает эти данные в предоставленный буфер. Все это происходит на самом низком уровне в ядре USB, драйвер MSC в этом не участвует
- Когда данные приняты возникает событие Data Out и опять вызывается функция SCSI_Write10()
- Поскольку драйвер находится в состоянии hmsc->bot_state == USBD_BOT_DATA_OUT, то управление переходит функции SCSI_ProcessWrite()
- Там происходит запись на карту в синхронном режиме
- Если еще не все данные приняты, то прием “перезаряжается” вызовом USBD_LL_PrepareReceive()
- Если все блоки записаны, то вызывается функция MSC_BOT_SendCSW() которая отправляет хосту подтверждение (Control Status Word — CSW), а драйвер переключается в состояние USBD_BOT_IDLE
- Транзакция SCSI: Response
- К этому моменту пакет статуса уже отправлен. Никаких действий не требуется
Для начала адаптируем оригинальную реализацию к асинхронности функции Write(). Нужно просто разделить функцию SCSI_ProcessWrite() и вызывать вторую половину в коллбеке.
/**
* @brief SCSI_ProcessWrite
* Handle Write Process
* @param lun: Logical unit number
* @retval status
*/
static int8_t SCSI_ProcessWrite (USBD_HandleTypeDef *pdev, uint8_t lun)
{
uint32_t len;
USBD_MSC_BOT_HandleTypeDef *hmsc = pdev->pClassDataMSC;
len = MIN(hmsc->scsi_blk_len , MSC_MEDIA_PACKET);
if(pdev->pClassSpecificInterfaceMSC->Write(lun ,
hmsc->bot_data,
hmsc->scsi_blk_addr / hmsc->scsi_blk_size,
len / hmsc->scsi_blk_size,
pdev) < 0)
{
SCSI_SenseCode(pdev,
lun,
HARDWARE_ERROR,
WRITE_FAULT);
return -1;
}
return 0;
}
return 0;
}
void cardWriteCompletedCB(uint8_t res, void * context)
{
USBD_HandleTypeDef * pdev = (USBD_HandleTypeDef *)context;
USBD_MSC_BOT_HandleTypeDef *hmsc = pdev->pClassDataMSC;
uint8_t lun = hmsc->cbw.bLUN;
uint32_t len = MIN(hmsc->scsi_blk_len , MSC_MEDIA_PACKET);
// Check error code first
if(res != 0)
{
SCSI_SenseCode(pdev,
lun,
HARDWARE_ERROR,
WRITE_FAULT);
return;
}
hmsc->scsi_blk_addr += len;
hmsc->scsi_blk_len -= len;
/* case 12 : Ho = Do */
hmsc->csw.dDataResidue -= len;
if (hmsc->scsi_blk_len == 0)
{
MSC_BOT_SendCSW (pdev, USBD_CSW_CMD_PASSED);
}
else
{
/* Prepare EP to Receive next packet */
USBD_LL_PrepareReceive (pdev,
MSC_OUT_EP,
hmsc->bot_data,
MIN (hmsc->scsi_blk_len, MSC_MEDIA_PACKET));
}
}
Точно также как и в случае чтения нужно как то доставить некоторые переменные из первой функции во вторую. И для этого я использую параметр context и передаю хендл USB устройства (из него можно выудить все необходимые данные).
Скорость записи в таком режиме составляет порядка 90кб/с и в основном ограничена скоростью записи на карту. Это подтверждается осциллограммой — каждый пик это запись одного блока. Судя по картинке, запись 512 байт занимает от 3 до 6мс (каждый раз по разному).
Более того, запись иногда может залипать от 100мс до 0.5с — видимо где то в карте возникает необходимость в различных внутренних активностях — ремаппинг блоков, стирание страниц, или что нибудь в таком духе.
Исходя из этого допиливание двойного буфера вряд ли кардинально улучшит ситуацию. Впрочем все равно попробуем это сделать чисто из спортивного интереса.
Итак, суть упражнения в том, чтобы принимать следующий блок от хоста в то время как предыдущий пишется на карту. На ум сразу приходит вариант запустить запись и прием следующего блока одновременно где нибудь в функции SCSI_Write10(), т.е. по событию DataOut (завершен прием очередного блока). Только работать ничего не будет. т.к. прием идет гораздо быстрее, чем запись и может быть принято больше данных, чем карта успевает писать. Т.е. следующие данные перезатирают ранее принятые, но еще не обработанные.
В такой схеме несколько пакетов могут быть приняты подряд, но не все из них успеют быть записаны на SD карту. Скорее всего часть данных пререзатрется следующим блоком.
Нужно делать синхронизацию. Только где? В случае операции чтения двойную буферизацию и синхронизацию мы организовывали в месте где заканчивается чтение с карты и данные перебрасываются в USB. Этим местом была функция cardReadCompletedCB(). В случае операции записи таким центральным местом будет функция SCSI_Write10() — именно в ней мы окажемся, когда будет принят очередной блок данных, и именно отсюда мы будем стартовать запись на карту.
Но между функциями cardReadCompletedCB() и SCSI_Write10() есть одна принципиальная разница — первая работает в потоке SD карты, а вторая в прерывании USB. Обычный поток может быть приостановлен в ожидании некоторого события или объекта синхронизации. С прерыванием такой фокус не пройдет — все функции FreeRTOS с суффиксом FromISR неблокирующие. Они либо работают как надо (захватывают ресурс, если он свободен, отправляют/получают сообщения через очередь если там есть место или необходимое сообщение), либо эти функции возвращают ошибку. Но они никогда не ждут.
Но если нельзя организовать ожидание в прерывании, то можно попробовать сделать так, чтобы прерывание вообще не вызывалось лишний раз. Точнее даже так: чтобы прерывание возникало ровно столько раз и в такие моменты когда нам нужно.
Давайте рассмотрим несколько случаев, которые могут возникнуть в процессе приема/записи.
Случай №1: прием первого блока. Как только принят первый блок, то можно начинать запись этого блока. Одновременно с этим можно начать прием второго блока. Это избавит от паузы, когда мы не принимаем следующий блок, пока предыдущий пишется на карту.
Случай №2: прием блока в середине транзакции. Скорее всего оба буфера уже будут заполнены. Где нибудь в потоке SD карты идет запись блока данных из первого блока, тогда как второй блок мы только получили от хоста. В принципе ничего не мешает зарядить запись второго блока — там на входе стоит очередь (см функцию SD_MSC_Read() выше), которая регулирует входные запросы и будет писать блоки по очереди. Нужно только убедится, что в этой очереди есть место на 2 запроса.
Но как регулировать прием? У нас всего 2 приемных буфера. Если сразу после приема второго блока начать прием следующего, то это перезатрет данные в первом буфере, откуда в данный момент идет запись на карту. В таком случае правильнее будет начинать прием очередного блока данных когда буфер освободится — когда закончится запись (т.е. в коллбеке функции записи).
Наконец, случай №3: нужно уметь правильно завершить процедуру приема/записи. С последним блоком все понятно — нужно вместо приема очередного блока отправить хосту CSW, что данные приняты и транзакцию можно закрывать. Но нужно помнить, что вначале транзакции мы уже организовали лишний прием, поэтому предпоследний блок не должен заказывать прием лишнего блока.
Вот картинка которая описывает эти случаи.
Случай 1: на первый DataOut сразу же начинаем прием второго блока. Случай 2: начинаем прием очередного блока только после того как запись закончена и буфер свободен. Случай 3: на предпоследней записи прием не начинаем, на последней — отправляем CSW
Интересное наблюдение: если запись на карту идет из первого буфера, то по окончании записи следующий блок будет принят в тот же первый буфер. Точно так же со вторым буфером. Я бы хотел воспользоваться этим фактом в своей реализации.
Попробуем реализовать задуманное. Для реализации первого случая (прием дополнительного блока) нам понадобится специальное состояние
#define USBD_BOT_DATA_OUT_1ST 6 /* Data Out state for the first receiving block */
/**
* @brief MSC_BOT_DataOut
* Process MSC OUT data
* @param pdev: device instance
* @param epnum: endpoint index
* @retval None
*/
void MSC_BOT_DataOut (USBD_HandleTypeDef *pdev,
uint8_t epnum)
{
USBD_MSC_BOT_HandleTypeDef *hmsc = pdev->pClassDataMSC;
switch (hmsc->bot_state)
{
case USBD_BOT_IDLE:
MSC_BOT_CBW_Decode(pdev);
break;
case USBD_BOT_DATA_OUT:
case USBD_BOT_DATA_OUT_1ST:
if(SCSI_ProcessCmd(pdev,
hmsc->cbw.bLUN,
&hmsc->cbw.CB[0]) < 0)
{
MSC_BOT_SendCSW (pdev, USBD_CSW_CMD_FAILED);
}
break;
default:
break;
}
}
Для реализации второго случая (прием блока по завершению записи) нужно каким-то образом передать в коллбек некоторое количество информации. Для этого я завел структуру с контекстом записи, и объявил 2 экземпляра этой структуры в хендле USB.
typedef struct
{
uint32_t next_write_len;
uint8_t * buf;
USBD_HandleTypeDef * pdev;
} USBD_WriteBlockContext;
typedef struct _USBD_MSC_BOT_HandleTypeDef
{
…
USBD_WriteBlockContext write_ctxt[2];
...
}
USBD_MSC_BOT_HandleTypeDef;
Нужно не забыть изменить размер очереди записи в потоке SD карты
// Initialize thread responsible for communication with SD card
bool initSDIOThread()
{
// Initialize synchronisation
sdCmdQueue = xQueueCreate(2, sizeof(IOMsg));
…
}
Функция SCSI_Write10() изменилась мало, добавилась только инициализация индекса двойного буфера и переход в состояние USBD_BOT_DATA_OUT_1ST
/**
* @brief SCSI_Write10
* Process Write10 command
* @param lun: Logical unit number
* @param params: Command parameters
* @retval status
*/
static int8_t SCSI_Write10 (USBD_HandleTypeDef *pdev, uint8_t lun , uint8_t *params)
{
USBD_MSC_BOT_HandleTypeDef *hmsc = pdev->pClassDataMSC;
if (hmsc->bot_state == USBD_BOT_IDLE) /* Idle */
{
// Checking params
…
hmsc->scsi_blk_addr = ...
hmsc->scsi_blk_len = ...
/* Prepare EP to receive first data packet */
hmsc->bot_state = USBD_BOT_DATA_OUT_1ST;
hmsc->bot_data_idx = 0;
USBD_LL_PrepareReceive (pdev,
MSC_OUT_EP,
hmsc->bot_data,
MIN (hmsc->scsi_blk_len, MSC_MEDIA_PACKET));
}
else /* Write Process ongoing */
{
return SCSI_ProcessWrite(pdev, lun);
}
return 0;
}
Вся самая интересная логика будет сосредоточена в функции SCSI_ProcessWrite() — именно там будут распределятся буфера и строится вся цепочка чтений и записей.
/**
* @brief SCSI_ProcessWrite
* Handle Write Process
* @param lun: Logical unit number
* @retval status
*/
static int8_t SCSI_ProcessWrite (USBD_HandleTypeDef *pdev, uint8_t lun)
{
USBD_MSC_BOT_HandleTypeDef *hmsc = pdev->pClassDataMSC;
uint32_t len = MIN(hmsc->scsi_blk_len , MSC_MEDIA_PACKET);
USBD_WriteBlockContext * ctxt = hmsc->write_ctxt + hmsc->bot_data_idx;
// Figure out what to do after writing the block
if(hmsc->scsi_blk_len == len)
{
ctxt->next_write_len = 0xffffffff;
}
else if(hmsc->scsi_blk_len == len + MSC_MEDIA_PACKET)
{
ctxt->next_write_len = 0;
}
else
{
ctxt->next_write_len = MIN(hmsc->scsi_blk_len - 2 * MSC_MEDIA_PACKET, MSC_MEDIA_PACKET);
}
// Prepare other fields of the context
ctxt->buf = hmsc->bot_data + hmsc->bot_data_idx * MSC_MEDIA_PACKET;
ctxt->pdev = pdev;
// Do not allow several receives at a time
if(hmsc->bot_state != USBD_BOT_DATA_OUT_1ST)
pdev->pClassSpecificInterfaceMSC->OnStartOp();
// Write received data
if(pdev->pClassSpecificInterfaceMSC->Write(lun ,
ctxt->buf,
hmsc->scsi_blk_addr / hmsc->scsi_blk_size,
len / hmsc->scsi_blk_size,
ctxt) < 0)
{
SCSI_SenseCode(pdev,
lun,
HARDWARE_ERROR,
WRITE_FAULT);
return -1;
}
// Switching blocks
hmsc->bot_data_idx ^= 1;
hmsc->scsi_blk_addr += len;
hmsc->scsi_blk_len -= len;
/* case 12 : Ho = Do */
hmsc->csw.dDataResidue -= len;
// Performing one extra receive for the first time in order to run receive and write operations in parallel
if(hmsc->bot_state == USBD_BOT_DATA_OUT_1ST && hmsc->scsi_blk_len != 0)
{
hmsc->bot_state = USBD_BOT_DATA_OUT;
USBD_LL_PrepareReceive (pdev,
MSC_OUT_EP,
hmsc->bot_data + hmsc->bot_data_idx * MSC_MEDIA_PACKET, // Second buffer
MIN (hmsc->scsi_blk_len, MSC_MEDIA_PACKET));
}
return 0;
}
Во-первых, тут готовится контекст записи — информация, которая будет передаваться в коллбек. В частности тут решается что будем делать, когда запись этого блока закончится:
- в обычном случае будем начинать прием следующего блока в тот же самый буфер (случай №2 из описанных выше)
- В случае предпоследнего блока ничего не будем делать (случай №3)
- В случае последнего блока будем отправлять Control Status Word (CSW) — отчет хосту о статусе операции
После того как блок данных отправлен в очередь записи на карту индекс буфера (bot_data_idx) переключается на альтернативный. Т.е. следующий пакет будет принят в другой буфер.
Наконец, специальный случай (случай №1) — организуем дополнительный прием данных в случае первого блока (состояние USBD_BOT_DATA_OUT_1ST)
Ответная часть этого кода — коллбек о завершении записи на карту. В зависимости от того какой блок был записан либо организовывается прием следующего блока, либо отправляется CSW, либо ничего не происходит.
void cardWriteCompletedCB(uint8_t res, void * context)
{
USBD_WriteBlockContext * ctxt = (USBD_WriteBlockContext*)context;
USBD_HandleTypeDef * pdev = ctxt->pdev;
USBD_MSC_BOT_HandleTypeDef *hmsc = pdev->pClassDataMSC;
uint8_t lun = hmsc->cbw.bLUN;
// Check error code first
if(res != 0)
{
SCSI_SenseCode(pdev,
lun,
HARDWARE_ERROR,
WRITE_FAULT);
return;
}
if (ctxt->next_write_len == 0xffffffff)
{
MSC_BOT_SendCSW (pdev, USBD_CSW_CMD_PASSED);
}
else
{
pdev->pClassSpecificInterfaceMSC->OnFinishOp();
if(ctxt->next_write_len != 0)
{
/* Prepare EP to Receive next packet */
USBD_LL_PrepareReceive (pdev,
MSC_OUT_EP,
ctxt->buf,
ctxt->next_write_len);
}
}
}
Финальный аккорд это синхронизация, суть работы которой проще показать на картинке.
Очень редко, но все же иногда возникает ситуация, когда запись на карту заканчивается раньше, чем принят следующий пакет. В итоге код (если бы не было синхронизации) мог бы запросить прием еще одного пакета, хотя текущий ещё не до конца принят. Чтобы такого не происходило пришлось добавить синхронизацию. Теперь прежде чем запросить прием следующего блока код будет ждать пока закончится прием предыдущего. Средства синхронизации, которые использовались при чтении (OnStartOp()/OnFinishOp()) вполне подойдут.
Условия при которых нужно синхронизироваться достаточно хитрые. За счет приема дополнительного блока в начале транзакции синхронизация идет со сдвигом в один блок. Поэтому коллбек записи N-того блока ждет приема N+1 блока. Это в свою очередь означает, что прием первого блока (происходит в контексте прерывания от USB) и запись последнего (происходит в контексте потока SD карты) в синхронизации не нуждаются.
Может показаться что красная стрелка дублирует черную, которая стартует запись следующего блока. Но если посмотреть на код, то видно, что это не так. Красная (синхронизация) синхронизирует код в драйвере MSC (синий квадратик), тогда как очередь обрабатывается в драйвере карты (там где основной цикл потока SD карты). Мне не очень хотелось мешать код разных компонентов.
Я расставил немного дебажного логирования, запись 4кб данных выглядит примерно так
Starting write operation for LBA=0041C600, len=4096
Receiving first block into buf=1
Writing block of data for LBA=0041C600, len=512, buf=0
This will be regular block
Receiving an extra block into buf=1
Writing block of data for LBA=0041C800, len=512, buf=1
This will be regular block
Write completed callback with status 0 (buf=0)
Preparing next receive into buf=0
Writing block of data for LBA=0041CA00, len=512, buf=0
This will be regular block
Write completed callback with status 0 (buf=1)
Preparing next receive into buf=1
Writing block of data for LBA=0041CC00, len=512, buf=1
This will be regular block
Write completed callback with status 0 (buf=0)
Preparing next receive into buf=0
Writing block of data for LBA=0041CE00, len=512, buf=0
This will be regular block
Write completed callback with status 0 (buf=1)
Preparing next receive into buf=1
Writing block of data for LBA=0041D000, len=512, buf=1
This will be regular block
Write completed callback with status 0 (buf=0)
Preparing next receive into buf=0
Writing block of data for LBA=0041D200, len=512, buf=0
This will be one before the last block
Write completed callback with status 0 (buf=1)
Preparing next receive into buf=1
Writing block of data for LBA=0041D400, len=512, buf=1
This will be the last block
Write completed callback with status 0 (buf=0)
Write completed callback with status 0 (buf=1)
Write finished. Sending CSW
Как и ожидалось, существенного прироста к скорости это не добавило. После переделки скорость составила 95-100 кб/с. Но как я говорил, делалось это все из спортивного интереса.
А еще быстрее можно?
Давайте попробуем. Где-то в середине работы я случайно обратил внимание, что чтение одного блока и чтение последовательности блоков — это разные команды SD карты. Они даже представлены разными методами драйвера карты — readBlock() и readBlocks(). Точно так же различаются команды записи одного блока и записи серии блоков.
Поскольку драйвер MSC по умолчанию заточен на работу с одним блоком в единицу времени, то был смысл заменить readBlocks() на readBlock(). К моему удивлению скорость чтения даже выросла и стала на уровне 480-500кб/с! Аналогичный трюк с функциями записи, к сожалению, прироста скорости не дал.
Но меня с самого начала мучал один вопрос. Давайте еще разок взглянем на картину чтения. Между зазубринами (чтение одного блока) — около 2мс.
Тактирование SPI у меня настроено на 18МГц (используется делитель частоты ядра 72МГц на 4). Теоретически передача 512 байт должна занимать 512 байт * 8 бит /18 МГц = 228мкс. Да, тут будет определенный оверхед на синхронизацию нескольких потоков, обслуживание очереди и прочие штуки, но это никак не объясняет разницу почти в 10 раз!
С помощью осциллографа я измерил сколько реально времени занимают различные части операции чтения
Операция | Время |
Передача запроса от драйвера MSC до драйвера карты (с использованием очереди запросов) | <100мкс |
Отправка карте команды на чтение | 70мкс |
Ожидание готовности карты | 500-1000 мкс |
Чтение одного блока с карты | 280 мкс |
Передача ответа назад в драйвер MSC | <100 мкс |
К моему удивлению оказалось, что самой долгой операцией является вовсе не чтение данных, а интервал между командой на чтение и подтверждением от карты, что карта готова и можно читать данные. Причем этот интервал весьма сильно плавает в зависимости от различных параметров — частоты запросов, размера читаемых данных, а также адреса читаемого блока. Последний момент очень интересный — чем дальше от начала карты находится блок, который нужно прочитать — тем быстрее он читается (во всяком случае это было так для моей подопытной карты)
Аналогичная (но более грустная) картина наблюдается и при записи на карту. Мне не удалось достаточно хорошо измерить все тайминги, т.к. они плавали в достаточно широких пределах, но выглядит это примерно так.
Операция | Время |
Отправка карте команды на запись | 70мкс |
Ожидание готовности карты | 1-5мс |
Запись одного блока на карту | 0.4-1.2мс |
Все это усугубляется достаточно большой загрузкой ЦП — около 75%. Сама запись теоретически должна занимать те же самые 228мкс, как и чтение — они же тактируются теми же самыми 18МГц. Только в данном случае еще фигурирует синхронизация потоков FreeRTOS. Видимо из-за большой загрузки ЦП и необходимости переключаться на другие (более приоритетные) потоки суммарное время получается значительно больше.
Но самая большая печаль — ожидание готовности карты. Оно во много раз больше чем в случае чтения. Более того, именно тут карта может залипнуть на 100 и даже 500 мс. К тому же в драйвере карты эта часть реализована активным ожиданием, что и приводит к той самой высокой загрузке процессора
// wait for card to go not busy
bool SdSpiCard::waitNotBusy(uint16_t timeoutMS) {
uint16_t t0 = curTimeMS();
while (spiReceive() != 0XFF) {
if (isTimedOut(t0, timeoutMS)) {
return false;
}
}
return true;
}
Тут есть ветки в коде, которые добавят вызов SysCall::yield() внутри цикла, но, боюсь, ситуацию это не исправит. Этот вызов всего лишь рекомендуют планировщику задач переключиться на другой поток. Но поскольку другие потоки у меня в основном спят, то ситуацию это кардинально не улучшит — карта ведь тупить не перестанет.
Еще один забавный момент. В FreeRTOS контексты переключаются по прерыванию SysTick, который по умолчанию настроен на 1мс. Из-за этого многие операции на осциллографе дружненько выравниваются по сетке с шагом кратным 1мс. Если карта не тупит и чтение одного блока вместе с ожиданием занимает меньше 1мс, то тогда включая все потоки, синхронизации и очереди можно обернуться за один тик. Отсюда теоретическая максимальная скорость чтения в такой модели составляет ровно 500 кб/с (0.5кб за 1мс). Что радует — она достигается!
Но эту штуку можно обойти. Выравнивание по 1мс происходит по следующей причине. Прерывание от USB или от DMA ни к чему не привязано и может произойти где нибудь в середине тика. Если прерывание изменило состояние объекта синхронизации (например разблокировало семафор, или добавило сообщение в очередь), то FreeRTOS об этом мгновенно не узнает. Когда прерывание сделает свои дела, то управление передается в тот поток, который работал до прерывания. Когда тик закончится вызовется планировщик, и в зависимости от состояния объекта синхронизации может переключить на соответствующий поток.
Но как раз для таких случаев у FreeRTOS предусмотрен механизм принудительного вызова планировщика. Как я уже говорил, нельзя прервать прерывание. Зато можно маякнуть о необходимости вызова планировщика (акцентирую: не вызвать планировщик, а маякнуть о необходимости вызова). Именно это и делает функция portYIELD_FROM_ISR()
void SdFatSPIDriver::dmaTransferCompletedCB()
{
// Resume SD thread
BaseType_t xHigherPriorityTaskWoken;
xSemaphoreGiveFromISR(xSema, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
Теперь когда закончится обработка прерывания (скажем, от DMA) будет автоматически вызвано прерывание PendSV, в обработчике которого и вызывается планировщик. Тот в свою очередь принудительно переключит контекст и передаст управление тому потоку, который ждал семафора. Т.о. время реакции на прерывание можно существенно сократить, и в итоге такой трюк позволяет разогнать чтение на тестовой карте аж до 600кб/с!
Но это если нет длительного ожидания готовности карты. К сожалению если карта долго думает, то чтение растягивается на 2 тика (а запись на 4-6) и скорость оказывается существенно ниже. Более того, если код активного ожидания постоянно долбится в карту, а карта долго не отвечает, то так может пройти и целый тик. В этом случае планировщик ОС может решить, что этот поток слишком долго работает и вообще переключить управление на другие потоки. Из-за этого может возникнуть дополнительная задержка.
Кстати, тестировал я все это на карте 8Гб класса 6. Я попробовал также несколько других карт, которые у меня были под рукой. Еще одна карта также на 8Гб но 10 класса почему то выдала только 300-350 кб/с на чтение, зато 120 кб/с на запись. Я даже рискнул поставить самую большую и быструю карту, которая у меня была — 32Гб. С ней удалось достичь максимальных скоростей — 650кб/с на чтение и 120кб/с на запись. Кстати, скорости, которые я привожу — средние. Мне нечем было измерить мгновенную скорость.
Какие выводы можно сделать из этого анализа?
- Во-первых, SPI это явно не родной интерфейс для SD карт. Даже крутые карты тупят на самых обычных операциях. Тут есть смысл смотреть в сторону SDIO (я уже забрал на почте пакетик с STM32F103RCT6 — там есть поддержка SDIO из коробки)
- Во-вторых, карта карте рознь. Нужно будет искать ту единственную. Хотя при подключении через SDIO это будет не так критично
- В-третьих, при наличии достаточного количества памяти можно будет переключиться на чтение блоками бОльшего размера (скажем 4к). Тогда длинная задержка вначале чтения/записи будет нивелироваться большой скоростью передачи. Пока 20кб памяти моего контроллера (STM32F103C8T6) забиты почти под завязку и даже 512 байт для двойной буферизации я выкроил с трудом
Заключение
В этой статье я рассказал как мне удалось прокачать реализацию USB MSC от STMicroelectronics. В отличии от других серий микроконтроллеров STM32, серия F103 не имеет встроенной поддержки DMA для USB. Но при помощи FreeRTOS мне удалось прикрутить чтение/запись SD карты через DMA. Ну а что бы максимально эффективно использовать пропускную способность шины USB мне удалось прикрутить двойную буферизацию.
Результат превзошел мои ожидания. Изначально я целился на скорость порядка 400кб/с, а удалось выжать аж 650кб/с. Но для меня важно даже не абсолютные показатели скорости, а то, что эта скорость достигается с минимальным вмешательством процессора. Так данные передаются с помощью DMA и периферии USB, а процессор подключается только чтобы зарядить следующую операцию.
С записью, правда, супер скоростей получить не удалось — всего 100-120кб/с. Виной всему огромные таймауты самой SD карты. Ну а поскольку карта подключена по SPI другого способа узнать о готовности карты (кроме как постоянно ее опрашивать) вроде как и нету. Из-за этого наблюдается довольно высокая загрузка процессора на операциях записи. У меня есть тайная надежда, что подключив карту по SDIO можно достичь гораздо бОльших скоростей.
Я постарался не просто привести код, но и рассказать как он устроен и почему он устроен именно так. Возможно это поможет сделать что нибудь аналогичное для других контроллеров или библиотек. Я не выделял это в отдельную библиотеку, т.к. этот код зависит от других частей моего проекта и библиотеки FreeRTOS. Более того, свой код я строил на базе весьма пропатченой реализации MSC. Так что если вы хотите использовать мой вариант его придется бекпортить на оригинальную библиотеку.
Ссылка на мой репозиторий: github.com/grafalex82/GPSLogger
Буду рад конструктивным коментариям и другим идеям как можно ускорить работу с SD картой.
Комментарии (17)
erley
06.09.2017 15:00+1В вашем случае нужно принимать во внимание что контроллер встроенный в SD карточку реализует FTL прозрачно для внешнего мира. То есть в реальности адресуемые сектора флэш-памяти мапятся в их реальные сектора на чипе NAND флэш внутри карточки.
Раелизация маппинга сильно зависит от производителя, да и сам маппинг усложняется с постепенным износом кристалла памяти со временем. Отсюда и задержки.
Если есть цель попытаться оптимизировать скорость работы вашего кода с флэшем, то тогда нужно использовать NAND флэш чип без FTL, бывают шилдики которые можно подцепить через GPIO и настроить пины.
Ещё один момент — вы используете SPI, однако по моему личному опыту скорость и надёжность работы с SD картами гораздо надёжнее при работе по штатному SD протоколу (настоятельно советую почитать официальные спецификации, там много интересного). Кроме того, SD протокол умеет работать с шириной шины данных 2 и 4 бита, т.е. скорость будет сразу в разы больше.grafalex Автор
06.09.2017 15:03да, спасибо. Я упомянул в статье, что уже купил контроллер с поддержкой SDIO. Изучаю даташиты, скоро буду плату разводить
hddmasters
06.09.2017 15:54+1С записью, правда, супер скоростей получить не удалось — всего 100-120кб/с. Виной всему огромные таймауты самой SD карты. Ну а поскольку карта подключена по SPI другого способа узнать о готовности карты (кроме как постоянно ее опрашивать) вроде как и нету. Из-за этого наблюдается довольно высокая загрузка процессора на операциях записи. У меня есть тайная надежда, что подключив карту по SDIO можно достичь гораздо бОльших скоростей.
К сожалению на запись все SD карты будут достаточно тормознутыми, особенно если писать на них маленькие блоки. Тут уже сам принцип работы NAND накопителя играет роль.
1. Запись идет блоками, которыми оперирует сам NAND контроллер. (неважно, что размер записываемых вами данных значительно меньше блока).
2. Постоянные записи в служебку из-за перестроения транслятора.
Качественная оптимизация на запись возможна, если будет известен используемый NAND контроллер и NAND микросхемы, а также нюансы алгоритма работы.
небольшой пример поверхностного анализа достаточно простого NAND контроллера habrahabr.ru/post/329596
debounce
06.09.2017 19:10Слегка увлекаюсь osdev'ом и usb стек висит todo-мечтах уже два года. Открываю статью про usb, вижу осциллограф — закрываю. Вот не просто с программирования перескочить на физический уровень — а неиллюзорный юзб(для системного программиста) это и есть долгое и вдумчивое сидение в том числе и с осциллографом, или у страха глаза велики?
Посоветуйте как начать, книги, инструменты и, может, вам попадались интересные учебные реализации кода для usb стека?grafalex Автор
07.09.2017 21:12Осциллограф штука полезная, но совершенно не обязательная. Большую часть своего устройства я дебажил обычными логами (через USB и UART). Еще есть внутрисхемная отладка — там вообще по инструкциям кода шагать можно, значения переменных смотреть и такое прочее.
Для гурманов (типа меня) на отладочных платах светодиод предусмотрен. Например Mass Storage я прикручивал во время отпуска, а взял с собой я только отладочную плату без UART переходника. Пришлось отлаживаться ориентируясь на различное моргание светодиода. Что-то в духе «ага, тире-точка-тире — значит мы в функции A()». Даже рудименты в коде остались
Впрочем цена вопроса на осциль типа DSO150 (как у меня) всего $20-25, можно и прикупить при случае. А пользоваться им гораздо проще чем, например, разобраться в какой нибудь периферии микроконтроллера.
Что касается USB стека то лично мне хватило следующего:
— вдумчивого и многократного прочтения USB In A Nutshell (Говорят USB Made Simple тоже стОит почитать)
— код HAL (в части USB) и USB Core от STMicroelectronics довольно прост, хотя его много и листать туда-сюда пришлось многократно
Разумеется чужие примеры также будут полезны.
Так что у страха глаза действительно велики. Хотя тут гораздо уместнее будет сказать «глаза боятся а руки делают». Ничего супер сложного в этом USB нет
LampTester
08.09.2017 22:31+1Если хочется написать именно свой USB-драйвер, работающий непосредственно с железом, то нужен даже не просто осциллограф, а аппаратный USB-анализатор, или крутой осциллограф с соответствующим программным модулем (чтобы декодировать пакеты на шине). Я убедился в этом на практике, пытаясь написать нормальный USB-стек для STM32. Почти написал, кстати. «Почти» — потому что остались странные плавающие глюки в части передачи дескрипторов. Просто так отловить это невозможно, потому что программные снифферы начинают работать только после энумерации, а саму энумерацию можно посмотреть только аппаратно.
Если же для основного функционала брать готовый отлаженный код, то да, все обстоит так, как вам описал grafalex. В этом случае осциллограф используется только как более удобная и информативная замена светодиоду.
А чем вас пугает осциллограф? Ценой? На рынке есть недорогие модели. Более-менее приличные осциллографы начинаются примерно от $400. Недешево, но и не космос, в общем, учитывая, что такие вещи покупаются достаточно надолго. За $20, конечно, можно купить только совсем игрушку, но для использования в качестве показометра пойдет.
И если будете покупать, не берите осциллографы-приставки. Отдельностоящий прибор всегда удобнее для ручной отладки. Приставки хороши тогда, когда надо что-то автоматизировать (например, собрать автоматизированную лабораторную установку).
Jef239
А что будет, если во время вызова USBD_LL_Transmit произойдет прерывание по USB?
grafalex Автор
Мне кажется ничего плохого. Во всяком случае мне хочется думать, что инженеры и программисты STMicroelectronics об этом позаботились.
На самом деле прерывания там бегают постоянно. Например периферия и обслуживающий ее код могут качать данные через другие конечные точки, пока USBD_LL_Transmit() заправляет нужную.
Программист заботится о том, что бы вызывать USBD_LL_Transmit() только если конечная точка неактивна (именно для этого мне пришлось городить синхронизацию). Эта функция асинхронная, она не занимается собственно передачей. Ее роль только выставить переменные и загрузить данные в Packet Memory буфер. А поскольку конечная точка не активна, то USBD_LL_Transmit() может менять переменные и регистры, связанные с этой конечной точкой не опасаясь всяких последствий.
Даже если хост попытается обратиться к этой конечной точке, то получит NAK прямо на уровне железа. Прерывание даже не будет вызвано. Последней строкой в этой функции (точнее USB_EPStartXfer(), которая собственно делает всю магию) является выставление статуса конечной точки
Именно с этого момента конечная точка готова принимать запросы, отправлять данные (которые ей уже загрузили в Packet Memory), а по отправке вызывать прерывание, которое приготовит следующую порцию данных. В последнем случае железо автоматически переведет конечную точку в состояние NAK и, опять же, ничего плохого не произойдет, если именно в этот момент хост обратится к устройству.
Вся эта кухня происходит достаточно глубоко в недрах USB периферии, HAL и USB Core. На уровне драйвера MSC, где я ковырялся в этой статье, этого даже не видно.
Jef239
Вот и я об этом. Как бы не получить редкий и плохоотлавливаемый сбой раз в пару суток.
У нас чуть другая задача — хотим сделать CDC, то есть высокоскоростной ком-порт.
grafalex Автор
Так ведь синхронизация ж есть. Ну и логи можно делать и ловить себе на здоровье.
Баги я, конечно, не исключаю. Но если учесть, что я буду устройство изредка подключать, сливать файло и отключать, то редкими багами я могу и пренебречь. Ну а если кому понадобится в качестве внешнего диска такую штуку использовать — ну тогда да, нужно будет поганять и потестить.
Насколько быстрый? чем стандартный не устраивает?
Jef239
Тяжело ловить черную кошку в темной комнате. Особенно когда непонятно, есть она или нет. Причин для сбоев связи по USB много. И самая первая из них — плохой контакт, вторая — помехи по питанию, третья — ВЧ-наводки… Так что очень легко свалить на другие причины. А потом — генеральский эффект — и не работает от слова совсем. Потому что звезды так сложились, что редкий баг стал проявляться постоянно.
Ну в общем плавали — знаем. Потому и хотим изначально правильно сделать.
Что вы имеете ввиду под стандартным CDC? То, что есть у ST — обрывается на вызове обработчиков из прерываний. А нам надо к нашему приложению подключить. То есть через очереди работать.
У нас наружу смотрят 3 COM-порта. Максимальная скорость — 230400, обусловлена тем, что обычные USB-COM больше не тянут. Есть Moxa, есть FTDI, но это не массовые продукты. Поэтому формирователи RS232 на плате тянут до 250 килобит. А для некоторых вещей (прежде всего отладочных) хорошо бы мегабит. Прошивку залить, лог в реалтайме принять…
Да и неудобно, когда для связи ноута с железкой нужен USB-COM. Удобнее прямо на USB. Меньше сбоев, меньше мест, где может выпасть разъем…
Так что изначально, на этапе разработки платы заложились, что быстрый интерфейс будет по USB. Теперь вот думаем, как реализовывать. А у вас очень похожее решение.
grafalex Автор
Понятно.
Один из способов борьбы с редкими багами заключается в том, что бы сделать их постоянными. Чем выше процент случаев когда баг воспроизводится — тем легче его дебажить и понимать что к чему.
А можно подробнее? А то пока не замечал
Вообще я всячески поддерживаю идею отхода от UART в сторону USB. Вот недавно прикручивал себе (см раздел «USB с двойной буферизацией»). Получалось прокачать порядка мегабита. Тут стандартная реализация CDC от ST. Но, если честно, где то там таки бага. Изредка оно глючит, но я не разбирался почему.
Jef239
Отлаживать хорошо на рабочем месте. А когда генеральский эффект возникает у заказчика или того хуже — на полевых испытания, а в запасе лишь час до демонстрации…
Имеется ввиду обрыв dataflow. То естьК сожалению, генеральский эффект в том и состоит, что пушной зверек приходит в наиболее неудобный момент. У меня был случай, когда сбой произошел прямо на глазах начальства за сутки до установки системы на стан. Повезло — решили ставить, основываясь на результатов тестов, а не генеральского эффекта. Через полгода багу нашел — оказалось вероятность возникновения — раз в 3 года, длительность — несколько секунд.
CDC_Receive_FS
вызывается из прерывания. Обрабатывать входящие данные прямо в прерывании мы не можем, нам нужно, чтобы из прерывания они шли в очередь, а приложение (threadы) асинхронно бы их читало. Аналогично сCDC_Transmit_FS
лишь стартует передачу, а об её окончании мы узнаем лишь в прерывании (ну или опросом). Хочется отправлять данные в очередь и чтобы USB их передавало сразу же, как они появились в очереди. В идеале — отправка данных сразу из нескольких threads.Вот-вот, а хочется чтобы работало без ошибок. И дай бог, чтобы глючило в вашем коде, а не у ST. Потому что у них багов тоже хватает.
Надо бы сюда моего коллегу, который c этим самым USB разбирался. Уж не знаю, уговорю ли его высунуться.
Mirn
вот-вот что-то такое у меня и было с стандартным примером CDC из CubeMX stm32.
просто работало работало, порой долго, и потом внезапно висло.
причём неважно на какой скорости. 16кбит в сек или один мегабит в секунду.
Jef239
Гляньте вот сюда. Возможно оно?