Embedded HandbookПриложения
Все разделы › XXV. Мини-проекты
STM32U585 · уровень средний · 43 мин чтения
Открыть в интерактивном справочнике

198ф. Мини-проект: USB-флешка на B-U585I-IOT02A — прибор отдаёт журнал как диск

Самописец из 198б отдавал поток через виртуальный COM-порт, и на ПК нужна была своя программа. Здесь прибор проще для того, кто им пользуется: подключил кабель — в проводнике появился диск, на нём файл LOG.CSV, его открывает любая таблица. Никаких драйверов и программ: класс Mass Storage понимают все системы сами (про USB-хост и чтение флешек — 20а.4).

Хитрость в том, что никакого диска в приборе нет. Журнал лежит в ОЗУ кольцом двоичных записей по 8 байт, а загрузочный сектор, таблица FAT, каталог и строки CSV собираются в тот момент, когда хост просит сектор командой READ(10). Диск только для чтения: запись с ПК прибор отклоняет и заранее говорит хосту, что носитель защищён.

Проверено без платы. Генератор диска fatgen.c, обработчик Bulk-Only и SCSI scsi.c и кольцо журнала logring.c собраны gcc на ПК вместе с моделью хоста: она делает то же, что система при подключении флешки, и читает весь диск пакетами по 64 байта. Три прочитанных образа разобраны двумя независимыми путями — библиотекой FatFs и своим разбором на Python — и файл LOG.CSV сверен байт в байт. Прошивочная часть собрана armclang под Cortex-M33; на плате она не запускалась.

Опоры: пример «USB Device Mass Storage» из пакета платы B-U585I-IOT02A 1.1.0 (Keil; файлы USBD_User_MSC_0.c, MemoryDiskImage.c, USBD_Config_MSC_0.h; описание библиотеки — MDK-Middleware 7.13.0, компонент USB 6.15.0); заголовок stm32u585xx.h пакета STM32U5xx_DFP 2.2.1; DS13086 (STM32U585xx, таблицы 88 и 89 — флеш-память); драйвер MX25LM51245G из пакета платы; библиотека ST USB Device (класс MSC, копии для F4, F7 и G4 в github.com/betaflight/betaflight, коммит 744f95f) и там же emfat — диск FAT32 «на лету» для журналов полёта; FatFs R0.15 (ChaN) — как в 198т. Лицензия: пакет платы — Apache-2.0 и BSD-3-Clause, примеры MDK-Middleware — лицензия Arm для MDK, библиотека ST — MCD-ST Liberty V2 и SLA0044, emfat — MIT, FatFs — своя свободная лицензия (уведомление в исходниках). Из чужого кода в разделе только короткие цитаты; FatFs лежит в проверке с уведомлением. Со стандартами USB Mass Storage и SCSI не сверялось: коды команд и ошибок взяты из заголовков MDK (usb_msc.h) и ST (usbd_msc_scsi.h).

Как это называется в документации

Оригинал Перевод
Mass Storage Class (MSC) класс «накопитель»: флешки, карточные читалки
Bulk-Only Transport (BOT) протокол «только массовые передачи»: команда, данные, состояние
Command Block Wrapper (CBW), Command Status Wrapper (CSW) обёртка команды (31 байт) и ответ о выполнении (13 байт)
data residue остаток: сколько байт из обещанных не передано
logical block address (LBA) номер сектора (блока) от начала диска
sense data: sense key, ASC, ASCQ сведения об ошибке: класс ошибки и её уточняющие коды
UNIT ATTENTION «внимание»: носитель мог смениться
write protect (WP) защита от записи
STALL, endpoint halt остановка конечной точки до сброса хостом
BIOS parameter block (BPB) параметры тома в загрузочном секторе
volume label метка тома
eject (START STOP UNIT, LoEj) «Извлечь»: хост отпускает носитель

198ф.1 Что делает прибор и с чего начать

Прибор меряет температуру и влажность раз в 10 секунд и кладёт запись в кольцо. Подключили к ПК — видно диск LOGGER с одним файлом:

seq,time_s,temp_C,rh_pct
00000000,00000000,-015.00,030.00
00000001,00000010,-013.69,030.71
00000002,00000020,-012.38,031.42

— это начало файла из прогона: заголовок и первые три записи. Откуда берутся значения, для этого раздела неважно: в модели они считаются по формуле, на плате их даст датчик (198х).

Путь запроса от ПК до журнала: проводник шлёт READ(10), через USB полной скорости с точками Bulk по 64 байта идут CBW 31 байт, данные и CSW 13 байт; scsi.c ведёт протокол Bulk-Only и команды SCSI, в MODE SENSE ставит защиту от записи и отвечает на запись ошибкой 07/27; fatgen.c по номеру сектора собирает загрузочный сектор, FAT, каталог или строки CSV из снимка кольца журнала 32768 записей по 8 байт в SRAM3; новый снимок берётся только после команды Извлечь и сообщается хосту через UNIT ATTENTION 06/28; внизу раскладка диска 2055 секторов по 512 байт: сектор 0 загрузочный, 1…6 и 7…12 две копии FAT, 13 корневой каталог, 14…2054 данные файла кластерами 2…2042 подряд

Пример пакета платы. В пакете B-U585I-IOT02A есть готовая флешка «Mass Storage» на библиотеке USB из MDK-Middleware. Библиотека сама ведёт протокол и команды SCSI, программе остаются семь обработчиков в USBD_User_MSC_0.c: инициализация и её отмена, буфер, ёмкость, чтение, запись и проверка носителя. «Диск» там — массив в ОЗУ на 8 КБ (16 блоков по 512 байт) и готовый образ FAT12 на 4096 байт во флеш-памяти, который при старте копируется в этот массив. По описанию примера: OTG_FS на разъёме CN1 (USB Type-C), процессор 160 МГц, USB 48 МГц. Конечные точки Bulk — номер 1 в обе стороны, пакет 64 байта, буфер класса 512 байт; строка INQUIRY — «Keil», «Disk 0», «1.0».

Разбор этого образа по полям (проверка делает это сама):

образ примера: 4096 байт, FAT12 «USB_Memory»; BPB: сектор 512 байт, секторов в кластере 1, FAT 1 × 2 сектора, корень 16 мест, всего секторов 64
  README.TXT: кластер 2, 107 байт: «This is a USB Memory Device Demonstration.»
  ёмкость по USBD_MSC0_GetMediaCapacity: sizeof(memory)/512 = 8192/512 = 16 блоков, а в BPB — 64

Том в загрузочном секторе заявлен на 64 сектора, а GetMediaCapacity отдаёт 16. Пока занятые кластеры лежат в первых 16 секторах, это не видно; к этой неувязке вернёмся в граблях.

Чем плох готовый образ для журнала: файл в нём неподвижен. Чтобы хост увидел новые записи, образ пришлось бы переписывать — FAT, каталог и данные — и держать его целиком в памяти. Здесь сделано наоборот: хранится только журнал, а диск вычисляется. Та же мысль у emfat в betaflight: журнал полёта отдаётся как FAT32, собранный по запросу, а STORAGE_IsWriteProtected там всегда возвращает 1.


198ф.2 Mass Storage изнутри: CBW, данные, CSW

Устройство класса MSC объявляет интерфейс с классом 0x08, подклассом 0x06 (команды SCSI) и протоколом 0x50 (Bulk-Only) и две точки Bulk — это же видно со стороны хоста в 20а.4. Всё остальное идёт по этим двум точкам тремя шагами:

  1. хост шлёт в Bulk-OUT CBW — 31 байт: сигнатура «USBC» (0x43425355), метка, сколько байт данных он ждёт, направление (бит 7: 1 — к хосту), номер логического устройства, длина команды и сама команда SCSI до 16 байт;
  2. данные — в одну сторону, пакетами по 64 байта на полной скорости;
  3. хост забирает из Bulk-IN CSW — 13 байт: «USBS» (0x53425355), та же метка, остаток и состояние: 0 — выполнено, 1 — ошибка, 2 — ошибка фазы.

При ошибке состояние 1 не говорит, что случилось. Хост спрашивает отдельной командой REQUEST SENSE и получает три кода: класс ошибки (sense key) и уточнение (ASC, ASCQ).

Подключение байт за байтом. Так начинает модель хоста — INQUIRY, потом ёмкость, потом MODE SENSE:

  CBW: 55 53 42 43 00 00 8F 19 24 00 00 00 80 00 06 12 00 00 00 24 00 00 00 00 00 00 00 00 00 00 00
  CSW: 55 53 42 53 00 00 8F 19 00 00 00 00 00
INQUIRY: 36 байт, статус 0: «HANDBOOK» «U585 LOGGER     » «1.0 », съёмный 1
  CBW: 55 53 42 43 02 00 8F 19 08 00 00 00 80 00 0A 25 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  CSW: 55 53 42 53 02 00 8F 19 00 00 00 00 00
  данные: 00 00 08 06 00 00 02 00
READ CAPACITY(10): последний сектор 2054, размер 512 → 2055 секторов
  CBW: 55 53 42 43 04 00 8F 19 C0 00 00 00 80 00 06 1A 00 3F 00 C0 00 00 00 00 00 00 00 00 00 00 00
  CSW: 55 53 42 53 04 00 8F 19 BC 00 00 00 00
MODE SENSE(6): 4 байт, остаток 188, байт 2 = 80 → защита от записи 1

Как читать первый CBW: 55 53 42 43 — «USBC»; метка 00 00 8F 19; ждёт 0x24 = 36 байт; 80 — данные к хосту; устройство 0; команда 6 байт: 12 00 00 00 24 00 — INQUIRY на 36 байт. В CSW та же метка, остаток 0, состояние 0. У MODE SENSE хост просил 192 байта (0xC0), прибор отдал 4, и в CSW остаток BC 00 00 00 = 188.

Команды, которые понимает прибор. Коды — из usb_msc.h MDK и usbd_msc_scsi.h ST:

Команда Код Ответ прибора
TEST UNIT READY 0x00 готов; после «Извлечь» — ошибка 02/3A (носителя нет)
REQUEST SENSE 0x03 18 байт: 0x70, класс ошибки в байте 2, ASC в байте 12
INQUIRY 0x12 36 байт: диск, съёмный, «HANDBOOK», «U585 LOGGER», «1.0»
MODE SENSE(6) / (10) 0x1A / 0x5A заголовок 4 / 8 байт, бит WP = 0x80 — защищён
START STOP UNIT 0x1B LoEj = 1, Start = 0 — «Извлечь»
PREVENT ALLOW MEDIUM REMOVAL, VERIFY(10), SYNCHRONIZE CACHE(10) 0x1E, 0x2F, 0x35 ничего делать не нужно — выполнено
READ FORMAT CAPACITIES 0x23 12 байт: 2055 блоков по 512, «размечен»
READ CAPACITY(10) 0x25 номер последнего блока 2054 и размер 512
READ(10) 0x28 сектора из fatgen
WRITE(10) 0x2A ошибка 07/27 — защищён от записи
остальные — ошибка 05/20 — нет такой команды

Разбор команды целиком:

static void scsi_command(const uint8_t *cb, bool in) {
    uint8_t op = cb[0];
    if (!in && m.dlen) {                    /* данные от хоста: WRITE(10), MODE SELECT … */
        if (op == 0x2A) fail(SK_DATA_PROTECT, 0x27, false);   /* WRITE PROTECTED */
        else fail(SK_ILLEGAL, 0x20, false);
        return;
    }
    if (m.ua && op != 0x12 && op != 0x03) { /* INQUIRY и REQUEST SENSE проходят всегда */
        m.ua = 0;
        fail(SK_UNIT_ATTENTION, 0x28, in);  /* MEDIUM MAY HAVE CHANGED */
        return;
    }
    switch (op) {
    case 0x00:                              /* TEST UNIT READY */
        if (m.ejected) fail(SK_NOT_READY, 0x3A, in);          /* MEDIUM NOT PRESENT */
        else m.state = ST_CSW;
        break;
    case 0x03: {                            /* REQUEST SENSE: фиксированный формат, 18 байт */
        uint8_t s[18] = {0x70, 0, m.key, 0, 0, 0, 0, 10, 0, 0, 0, 0, m.asc, 0, 0, 0, 0, 0};
        m.key = m.asc = 0;
        reply(s, sizeof s, cb[4]);
        break;
    }
    case 0x12:                              /* INQUIRY */
        reply(INQUIRY, sizeof INQUIRY, be16(cb + 3));
        break;
    case 0x1A: {                            /* MODE SENSE(6): заголовок 4 байта, бит WP = 0x80 */
        static const uint8_t s[4] = {3, 0, 0x80, 0};
        reply(s, sizeof s, cb[4]);
        break;
    }
    case 0x5A: {                            /* MODE SENSE(10): заголовок 8 байт */
        static const uint8_t s[8] = {0, 6, 0, 0x80, 0, 0, 0, 0};
        reply(s, sizeof s, be16(cb + 7));
        break;
    }
    case 0x1B:                              /* START STOP UNIT: LoEj = 1, Start = 0 — «Извлечь» */
        if ((cb[4] & 0x03u) == 0x02u) m.ejected = 1;
        m.state = ST_CSW;
        break;
    case 0x1E:                              /* PREVENT ALLOW MEDIUM REMOVAL */
    case 0x2F:                              /* VERIFY(10) */
    case 0x35:                              /* SYNCHRONIZE CACHE(10) */
        m.state = ST_CSW;
        break;
    case 0x23: {                            /* READ FORMAT CAPACITIES */
        uint8_t s[12] = {0, 0, 0, 8, 0, 0, 0, 0, 0x02, 0, SECTOR >> 8, SECTOR & 0xFF};
        put_be32(s + 4, TOTAL_SECTORS);     /* 0x02 — носитель размечен */
        reply(s, sizeof s, be16(cb + 7));
        break;
    }
    case 0x25: {                            /* READ CAPACITY(10): НОМЕР последнего сектора и размер */
        uint8_t s[8];
        put_be32(s, TOTAL_SECTORS - 1u);
        put_be32(s + 4, SECTOR);
        reply(s, sizeof s, sizeof s);
        break;
    }
    case 0x28: {                            /* READ(10) */
        uint32_t lba = be32(cb + 2), cnt = be16(cb + 7);
        if (m.ejected) {
            fail(SK_NOT_READY, 0x3A, in);
        } else if (lba >= TOTAL_SECTORS || cnt > TOTAL_SECTORS - lba) {
            fail(SK_ILLEGAL, 0x21, in);     /* LOGICAL BLOCK ADDRESS OUT OF RANGE */
        } else if (m.dlen != cnt * SECTOR) {
            m.status = 2;                   /* хост и команда не сошлись в длине: ошибка фазы */
            if (m.dlen) m.in_halt = 1;
            m.state = ST_CSW;
        } else {
            m.lba = lba;
            m.left = cnt;
            m.pos = m.len = 0;
            m.state = cnt ? ST_DATA : ST_CSW;
        }
        break;
    }
    default:
        fail(SK_ILLEGAL, 0x20, in);         /* INVALID COMMAND OPERATION CODE */
    }
}

Две вспомогательные функции решают, что уйдёт хосту. reply не отдаёт больше, чем просила команда и чем обещал CBW; fail запоминает ошибку для REQUEST SENSE и останавливает (STALL) ту точку, по которой шли бы данные, — так хост понимает, что данных не будет, снимает остановку и забирает CSW:

/* Короткий ответ: не больше, чем просил хост в команде и чем он ждёт по CBW */
static void reply(const uint8_t *d, uint32_t n, uint32_t alloc) {
    if (n > alloc) n = alloc;
    if (n > m.dlen) n = m.dlen;
    memcpy(m.buf, d, n);
    m.pos = 0;
    m.len = n;
    m.left = 0;
    m.state = n ? ST_DATA : ST_CSW;
}

/* Ошибка: запомнить причину, остановить точку, по которой шли бы данные, ответить CSW = 1 */
static void fail(uint8_t key, uint8_t asc, bool in) {
    m.key = key;
    m.asc = asc;
    m.status = 1;
    if (m.dlen) {
        if (in) m.in_halt = 1;
        else m.out_halt = 1;
    }
    m.state = ST_CSW;
}

Приём CBW и выдача пакетов в Bulk-IN. Сектор для READ(10) собирается в буфер на 512 байт, когда предыдущий отдан, и уходит восемью пакетами:

int msc_out(const uint8_t *p, uint32_t n) {
    if (m.out_halt) return -1;
    if (m.state != ST_CBW) return 0;
    if (n != 31u || get32(p) != CBW_SIG) {  /* не CBW: обе точки стоят до сброса BOT */
        m.in_halt = m.out_halt = 1;
        m.state = ST_DEAD;
        return 0;
    }
    m.tag = get32(p + 4);
    m.dlen = get32(p + 8);
    m.sent = 0;
    m.status = 0;
    scsi_command(p + 15, (p[12] & 0x80u) != 0);
    return 0;
}

int msc_in(uint8_t *pkt) {
    if (m.in_halt) return -1;
    if (m.state == ST_DATA) {
        if (m.pos == m.len) {               /* буфер отдан: считать следующий сектор */
            disk_read(m.lba++, m.buf);
            m.left--;
            m.pos = 0;
            m.len = SECTOR;
        }
        uint32_t n = m.len - m.pos;
        if (n > MSC_PACKET) n = MSC_PACKET;
        memcpy(pkt, m.buf + m.pos, n);
        m.pos += n;
        m.sent += n;
        if (m.pos == m.len && m.left == 0) m.state = ST_CSW;
        return (int)n;
    }
    if (m.state == ST_CSW) {
        put32(pkt, CSW_SIG);
        put32(pkt + 4, m.tag);              /* dCSWTag — как в CBW */
        put32(pkt + 8, m.dlen - m.sent);    /* dCSWDataResidue: сколько не дослали */
        pkt[12] = m.status;
        m.state = ST_CBW;
        return 13;
    }
    return 0;
}

void msc_clear_halt(uint8_t ep) {
    if (m.state == ST_DEAD) return;         /* после плохого CBW помогает только сброс BOT */
    if (ep & 0x80u) m.in_halt = 0;
    else m.out_halt = 0;
}

Модуль не знает ни регистров OTG, ни библиотеки: драйвер точек отдаёт ему пакеты Bulk-OUT и берёт пакеты Bulk-IN. Поэтому он и проверяется на ПК.


198ф.3 FAT12 «на лету»: раскладка секторов

Сколько места нужно. Строка CSV — 34 байта, заголовок — 26:

  последняя: 00001499,00014990,-001.31,054.29

В файле не больше 30 720 записей (почему не все 32 768 — в 198ф.4). Остальное считается подряд, и так же это делают макросы fatgen.h:

Что Расчёт Итог
наибольший файл 26 + 34 × 30 720 1 044 506 байт
кластеров по 512 байт 1 044 506 / 512 = 2040,05 → вверх 2041
FAT12: 12 бит на кластер, плюс два служебных (2041 + 2) × 1,5 = 3064,5 → 3065 байт 6 секторов
загрузочный сектор сектор 0
две копии FAT 2 × 6 сектора 1…12
корневой каталог: 16 мест по 32 байта 16 × 32 = 512 сектор 13
данные: кластер 2 = сектор 14 14 + 2041 2055 секторов = 1 052 160 байт

2041 кластер — меньше 4085, поэтому это FAT12: тип тома определяется числом кластеров, а не надписью в загрузочном секторе. В FatFs та же граница — MAX_FAT12 0xFF5, с пометкой, что она выбрана по поведению DOS и Windows. Кластер — один сектор: так строка файла и сектор диска считаются одной формулой.

#define SECTOR        512u                              /* байт в секторе и в кластере */
#define HDR_LEN       26u                               /* "seq,time_s,temp_C,rh_pct\r\n" */
#define LINE_LEN      34u                               /* "00000001,00000010,+023.45,045.10\r\n" */
#define SNAP_GUARD    2048u                             /* запас кольца на время чтения хостом */
#define SNAP_MAX      (LOG_CAP - SNAP_GUARD)            /* записей в файле не больше */
#define FILE_MAX      (HDR_LEN + LINE_LEN * SNAP_MAX)
#define CLUSTERS      ((FILE_MAX + SECTOR - 1u) / SECTOR)
#define FAT_BYTES     ((3u * (CLUSTERS + 2u) + 1u) / 2u) /* 12 бит на кластер, два первых — служебные */
#define FAT_SECTORS   ((FAT_BYTES + SECTOR - 1u) / SECTOR)
#define ROOT_ENTRIES  16u                               /* корневой каталог — ровно один сектор */
#define FAT_LBA       1u                                /* сектор 0 — загрузочный */
#define ROOT_LBA      (FAT_LBA + 2u * FAT_SECTORS)      /* две копии FAT */
#define DATA_LBA      (ROOT_LBA + 1u)                   /* кластер 2 */
#define TOTAL_SECTORS (DATA_LBA + CLUSTERS)

Загрузочный сектор. Только BPB и подпись 0x55AA; кода загрузки нет:

static void boot_sector(uint8_t *b) {
    static const uint8_t jmp_oem[11] = {0xEB, 0x3C, 0x90, 'M', 'S', 'D', 'O', 'S', '5', '.', '0'};
    memcpy(b, jmp_oem, sizeof jmp_oem);     /* без EB xx 90 том не признают за FAT */
    put16(b + 11, SECTOR);                  /* BPB_BytsPerSec */
    b[13] = 1;                              /* BPB_SecPerClus */
    put16(b + 14, FAT_LBA);                 /* BPB_RsvdSecCnt: только загрузочный сектор */
    b[16] = 2;                              /* BPB_NumFATs */
    put16(b + 17, ROOT_ENTRIES);            /* BPB_RootEntCnt */
    put16(b + 19, TOTAL_SECTORS);           /* BPB_TotSec16 — то же число, что в READ CAPACITY */
    b[21] = 0xF8;                           /* BPB_Media: 0xF8, как в образе примера пакета */
    put16(b + 22, FAT_SECTORS);             /* BPB_FATSz16 */
    put16(b + 24, 63);                      /* BPB_SecPerTrk и BPB_NumHeads — для старых систем */
    put16(b + 26, 255);
    b[36] = 0x80;                           /* BS_DrvNum */
    b[38] = 0x29;                           /* BS_BootSig: дальше идут номер и метка тома */
    put32(b + 39, VOLUME_ID);
    memcpy(b + 43, VOLUME_LABEL, 11);
    memcpy(b + 54, "FAT12   ", 8);
    b[510] = 0x55;
    b[511] = 0xAA;
}

Как он выглядит в прочитанном образе:

  загрузочный, байты 0…35: EB 3C 90 4D 53 44 4F 53 35 2E 30 00 02 01 01 00 02 10 00 07 08 F8 06 00 3F 00 FF 00 00 00 00 00 00 00 00 00
  загрузочный, байты 36…61: 80 00 29 29 09 26 20 4C 4F 47 47 45 52 20 20 20 20 20 46 41 54 31 32 20 20 20; 510…511: 55 AA

00 02 — 512 байт в секторе, 01 — сектор в кластере, 01 00 — один служебный сектор, 02 — две FAT, 10 00 — 16 мест в корне, 07 08 = 2055 секторов, F8 — тип носителя, 06 00 — 6 секторов на FAT. Дальше 29 — за ним номер тома 29 09 26 20 и метка LOGGER.

FAT12. Файл лежит кластерами подряд, так что таблица — это просто «следующий = текущий + 1», а у последнего — конец цепочки 0xFFF. Хитрость FAT12 в упаковке: два 12-битных элемента занимают три байта. Функция считает любой байт таблицы по его смещению, не собирая таблицу в памяти:

/* Цепочка файла: кластеры 2, 3, … подряд, у последнего — конец цепочки 0xFFF */
static uint32_t fat_entry(uint32_t c) {
    if (c == 0) return 0xFF8;               /* 0xF00 | BPB_Media */
    if (c == 1) return 0xFFF;
    if (c >= 2u + snap_clusters) return 0;  /* свободный кластер */
    return c == 1u + snap_clusters ? 0xFFF : c + 1u;
}

/* Байт FAT12 по смещению: три байта хранят два 12-битных элемента */
static uint8_t fat_byte(uint32_t off) {
    uint32_t c = off / 3u * 2u;
    switch (off % 3u) {
    case 0:
        return (uint8_t)fat_entry(c);
    case 1:
        return (uint8_t)((fat_entry(c) >> 8) | (fat_entry(c + 1u) << 4));
    default:
        return (uint8_t)(fat_entry(c + 1u) >> 4);
    }
}
  FAT, байты 0…11: F8 FF FF 03 40 00 05 60 00 07 80 00
  элемент 341 на стыке секторов 1 и 2: байты 511 = 61 и 512 = 15 -> 156 (следующий кластер 342)

Первые три байта F8 FF FF — служебные элементы 0xFF8 и 0xFFF. Дальше 03 40 00 — это кластеры 2 → 3 и 3 → 4. Элемент 341 начинается в последнем байте первого сектора FAT и кончается в первом байте второго: кто собирает FAT посекторно по номерам элементов, тот здесь ошибается. Счёт по смещению байта этой ошибки не знает.

Каталог и данные. В корне два места: метка тома и файл с атрибутами «только чтение» и «архивный». Дату без часов реального времени пришлось зашить: 29.09.2026, 12:00.

static void root_dir(uint8_t *b) {
    memcpy(b, VOLUME_LABEL, 11);
    b[11] = 0x08;                           /* метка тома */
    uint8_t *e = b + 32;
    memcpy(e, FILE_NAME, 11);
    e[11] = 0x21;                           /* только чтение + архивный */
    put16(e + 14, FAT_TIME);                /* создан */
    put16(e + 16, FAT_DATE);
    put16(e + 18, FAT_DATE);                /* последний доступ */
    put16(e + 22, FAT_TIME);                /* изменён */
    put16(e + 24, FAT_DATE);
    put16(e + 26, 2);                       /* первый кластер */
    put32(e + 28, snap_size);
}
  каталог, LOG.CSV: 4C 4F 47 20 20 20 20 20 43 53 56 21 00 00 00 60 3D 5D 3D 5D 00 00 00 60 3D 5D 02 00 52 C7 00 00

21 — атрибуты, 3D 5D — дата 0x5D3D, 02 00 — первый кластер, 52 C7 00 00 = 51 026 байт. Байты файла считаются по смещению: сначала заголовок, потом строка номер (смещение − 26) / 34; за концом файла — нули. Сборка сектора целиком:

/* Байты файла [off, off + len): заголовок, потом строки по 34 байта; за концом файла — нули */
static void csv_fill(uint32_t off, uint8_t *dst, uint32_t len) {
    char ln[LINE_LEN];
    uint32_t cur = UINT32_MAX;
    for (uint32_t i = 0; i < len; i++, off++) {
        if (off >= snap_size) {
            dst[i] = 0;
        } else if (off < HDR_LEN) {
            dst[i] = (uint8_t)HDR[off];
        } else {
            uint32_t k = (off - HDR_LEN) / LINE_LEN;
            if (k != cur) {
                make_line(snap_first + k, ln);
                cur = k;
            }
            dst[i] = (uint8_t)ln[(off - HDR_LEN) % LINE_LEN];
        }
    }
}

void disk_read(uint32_t lba, uint8_t *b) {
    memset(b, 0, SECTOR);
    if (lba == 0) {
        boot_sector(b);
    } else if (lba < ROOT_LBA) {            /* обе копии FAT одинаковы */
        uint32_t off = ((lba - FAT_LBA) % FAT_SECTORS) * SECTOR;
        for (uint32_t i = 0; i < SECTOR; i++) b[i] = fat_byte(off + i);
    } else if (lba == ROOT_LBA) {
        root_dir(b);
    } else if (lba < TOTAL_SECTORS) {
        csv_fill((lba - DATA_LBA) * SECTOR, b, SECTOR);
    }
}

198ф.4 Где хранить журнал: память U585

Диску места не нужно — нужно журналу. Что есть у STM32U585 по stm32u585xx.h и DS13086:

Память Сколько Для журнала
SRAM1 192 КБ переменные программы и стек
SRAM2 64 КБ
SRAM3 512 КБ кольцо 32 768 × 8 байт = 256 КБ
SRAM4 16 КБ
флеш-память 2 МБ, два банка по 1 МБ, страница 8 КБ запись по 128 бит (16 байт), 118 мкс; 10 000 циклов стирания (таблицы 88 и 89)
MX25LM51245G на плате 64 МБ, страница 256 байт по драйверу пакета платы

Запись в ОЗУ — 8 байт: время, температура и влажность в сотых. Номер записи не хранится, он следует из положения в кольце. В файле та же запись занимает 34 байта — в 4,25 раза больше: CSV выгоднее считать, чем хранить. Кольцо на 256 КБ при записи раз в 10 секунд — это 32 768 × 10 с = 91 час.

Журнал в ОЗУ пропадёт при отключении питания. Если это важно, кольцо переезжает во флеш-память: 16 байт на запись — ровно одна запись по 128 бит; банк 1 МБ — 128 страниц и 65 536 записей; страница стирается раз за оборот кольца, и 10 000 циклов хватит на 655 360 000 записей. Генератор диска при этом не меняется — меняется только log_get. Этот вариант не собирался.

Кольцо. Пишет главный цикл, читает обработчик USB в своём потоке. Счётчик растёт только после того, как запись целиком легла в ячейку, а чтение проверяет, что ячейку не переписали, пока её копировали:

void log_append(uint32_t t_s, int16_t temp_c100, uint16_t rh_c100) {
    log_rec_t *r = &ring[count % LOG_CAP];
    r->t_s = t_s;
    r->temp_c100 = temp_c100;
    r->rh_c100 = rh_c100;
    BARRIER();                              /* сначала запись, потом счётчик */
    count = count + 1u;                     /* 32-битная запись на Cortex-M33 неделима */
}

uint32_t log_count(void) {
    return count;
}

bool log_get(uint32_t seq, log_rec_t *r) {
    uint32_t c = count;
    if (seq >= c || c - seq >= LOG_CAP) return false;   /* самую старую ячейку сейчас могут переписывать */
    *r = ring[seq % LOG_CAP];
    BARRIER();
    return count - seq < LOG_CAP;           /* пока копировали, ячейку не тронули */
}

Самая старая ячейка считается уже потерянной: в неё сейчас пишет log_append.

Снимок. Хост читает FAT и каталог один раз, а файл — потом, кусками. Если бы размер файла рос прямо во время чтения, каталог и данные разошлись бы. Поэтому при подключении прибор берёт снимок — какие записи видны — и не меняет его, пока хост не отпустит диск:

void disk_snapshot(void) {
    uint32_t cnt = log_count();
    snap_n = cnt < SNAP_MAX ? cnt : SNAP_MAX;
    snap_first = cnt - snap_n;
    snap_size = HDR_LEN + LINE_LEN * snap_n;
    snap_clusters = (snap_size + SECTOR - 1u) / SECTOR;
}

Снимок — не больше 30 720 записей: оставшиеся 2048 ячеек — запас на то время, пока хост читает, а журнал пишет дальше. При записи раз в 10 секунд это 2048 × 10 с = 5,7 часа. Если запас кончился, старые строки файла не врут, а честно заменяются прочерками той же длины.

Сборка под Cortex-M33. Кольцо помещено в SRAM3 атрибутом секции и файлом раскладки:

; Раздел 198ф: STM32U585AI — флеш-память 2 МБ с 0x08000000, SRAM1 192 КБ с 0x20000000,
; SRAM3 512 КБ с 0x20040000 (stm32u585xx.h пакета STM32U5xx_DFP 2.2.1). Кольцо журнала — в SRAM3.
LR_FLASH 0x08000000 0x00200000 {
  ER_FLASH 0x08000000 0x00200000 {
    *(+RO)
  }
  RW_SRAM1 0x20000000 0x00030000 {
    *(+RW +ZI)
  }
  RW_SRAM3 0x20040000 0x00080000 {
    *(.bss.sram3)
  }
}

armclang 6.24, -Os, два варианта (без драйвера USB и без библиотеки):

== вариант mdk: ER_FLASH с 0x08000000 — 1556 байт, RW_SRAM1 с 0x20000000 — 528 байт, RW_SRAM3 с 0x20040000 — 262144 байт
== вариант bot: ER_FLASH с 0x08000000 — 2332 байт, RW_SRAM1 с 0x20000000 — 568 байт, RW_SRAM3 с 0x20040000 — 262144 байт

С библиотекой MDK наш код — 1556 байт флеш-памяти и 528 байт SRAM1, из них 512 — буфер сектора для библиотеки. Со своим BOT и SCSI — 2332 байта и 568 байт. Кольцо — 262 144 байта SRAM3 в обоих вариантах.


198ф.5 Запись с ПК: почему опасна и как её запретить

Диск вычисляется, а хост об этом не знает. Запись с ПК — новый файл, правка LOG.CSV, служебная папка системы — меняет FAT и каталог в кэше хоста и отправляет сектора прибору. Прибору их некуда положить: при следующем чтении он снова соберёт свою FAT, и хост увидит том, который сам не писал. Отсюда правило: защищать на всех уровнях сразу.

  1. MODE SENSE: бит 7 байта 2 (в MODE SENSE(10) — байта 3) — носитель защищён. Хост, который спрашивает MODE SENSE при подключении, знает это до первой записи.
  2. WRITE(10): отказ с остановкой точки OUT и ошибкой 07/27 (DATA PROTECT, WRITE PROTECTED) — если хост всё же попробует.
  3. Атрибут файла «только чтение» — для программ на ПК.

Попытка записи в модели:

  CBW: 55 53 42 43 18 00 8F 19 00 02 00 00 00 00 0A 2A 00 00 00 00 0E 00 00 01 00 00 00 00 00 00 00
  CSW: 55 53 42 53 18 00 8F 19 00 02 00 00 01
WRITE(10) сектора 14: принято 0 байт, STALL OUT 1, статус 1, остаток 512
  REQUEST SENSE (после записи): ключ 07, ASC 27, ASCQ 00, статус 0

Хост обещал 512 байт (00 02 00 00, направление 00 — к устройству), прибор не принял ни одного: точка OUT остановлена, в CSW остаток 00 02 00 00 = 512 и состояние 1.

Как это сделано в библиотеках. В MDK защита — бит USBD_MSC_MEDIA_PROTECTED в ответе USBD_MSC0_CheckMedia; что именно библиотека отвечает на MODE SENSE, по исходникам не проверить — она поставляется собранной. В библиотеке ST V2.4.2 (копия для F4 в betaflight) SCSI_ModeSense6 копирует восемь нулевых байт MSC_Mode_Sense6_data и бит WP не ставит, даже если IsWriteProtected вернул 1: хост узнаёт о защите, только когда запись уже отклонена. В копиях для F7 и G4 того же репозитория бит добавлен строкой с пометкой «set bit 7 of the device configuration byte to indicate write protection». Отказ записи ST отмечает ключом 02 (NOT READY) с кодом 0x27; наш обработчик — ключом 07 (DATA PROTECT).

С библиотекой MDK весь «диск» прибора — это обработчики в USBD_User_MSC_0.c. Файл целиком:

/* Раздел 198ф: обработчики класса MSC библиотеки USB из MDK-Middleware (пример пакета платы
 * B-U585I-IOT02A, «Mass Storage»). BOT и SCSI библиотека ведёт сама; «диск» отдаёт fatgen. */
#include "rl_usb.h"
#include "fatgen.h"

static uint32_t block_buf[SECTOR / 4u];     /* буфер библиотеки: один сектор */

void USBD_MSC0_Initialize(void) {
    disk_snapshot();
}

void USBD_MSC0_Uninitialize(void) {
}

bool USBD_MSC0_GetCacheInfo(uint32_t *buffer, uint32_t *size) {
    *buffer = (uint32_t)block_buf;
    *size = sizeof block_buf;
    return true;
}

bool USBD_MSC0_GetMediaCapacity(uint32_t *block_count, uint32_t *block_size) {
    *block_count = TOTAL_SECTORS;           /* то же число, что в BPB_TotSec16 */
    *block_size = SECTOR;
    return true;
}

bool USBD_MSC0_Read(uint32_t lba, uint32_t cnt, uint8_t *buf) {
    if (lba >= TOTAL_SECTORS || cnt > TOTAL_SECTORS - lba) return false;
    for (uint32_t i = 0; i < cnt; i++) disk_read(lba + i, buf + i * SECTOR);
    return true;
}

bool USBD_MSC0_Write(uint32_t lba, uint32_t cnt, const uint8_t *buf) {
    (void)lba;
    (void)cnt;
    (void)buf;
    return false;                           /* диск только для чтения */
}

uint32_t USBD_MSC0_CheckMedia(void) {
    return USBD_MSC_MEDIA_READY | USBD_MSC_MEDIA_PROTECTED;
}

198ф.6 Прогон на ПК: модель хоста, FatFs и разбор на Python

host.c — модель хоста. Она вызывает msc_out и msc_in так, как это делал бы драйвер USB, и снимает остановки точек так, как это делает хост. Сценарий:

  1. в журнале 1500 записей; подключение: INQUIRY, TEST UNIT READY, READ CAPACITY, READ FORMAT CAPACITIES, MODE SENSE, сектор 0;
  2. весь диск по READ(10) кусками по 128 секторов, журнал тем временем пишет по записи на каждую команду;
  3. запись, чтение за концом диска, неизвестная команда;
  4. «Извлечь», прибор добавляет 3000 записей и берёт новый снимок, снова весь диск;
  5. кольцо переполнено: ещё 40 000 записей, новый снимок, и ещё 2052 записи до чтения — больше запаса;
  6. испорченный CBW и сброс протокола.
disk1.img: 2055 секторов прочитано командами READ(10): 17 команд, 16457 пакетов IN
  снимок: первая запись 0, записей 1500, файл 51026 байт; в журнале при конце чтения 1517
каталог при подключённом диске: LOG.CSV 51026 байт (журнал уже 1517 записей)

17 команд READ(10) по 128 секторов и 16 457 пакетов IN: 2055 × 8 пакетов данных и 17 CSW. Пока хост читал, журнал вырос до 1517 записей, а файл в каталоге остался 51 026 байт = 26 + 34 × 1500 — снимок держится.

Извлечь и вставить снова. Хост отпускает диск командой START STOP UNIT, прибор видит это, берёт новый снимок и «вставляет» носитель:

«Извлечь»: PREVENT ALLOW 0, START STOP UNIT LoEj=1 — статус 0, прибор видит извлечение: 1
TEST UNIT READY (после извлечения): статус CSW 1
  REQUEST SENSE (после извлечения): ключ 02, ASC 3A, ASCQ 00, статус 0
прибор: журнал 4517 записей, новый снимок, носитель «вставлен» снова
TEST UNIT READY (первая команда после вставки): статус CSW 1
  REQUEST SENSE (первая команда после вставки): ключ 06, ASC 28, ASCQ 00, статус 0
TEST UNIT READY (вторая): статус CSW 0

02/3A — носителя нет; 06/28 — внимание, носитель мог смениться; дальше диск снова готов, и в нём 4517 записей.

Разбор образов. Первый путь — FatFs R0.15: монтирует образ, читает метку, свободное место и LOG.CSV через f_read кусками по 4096 байт. Писать на диск не пыталась ни разу:

disk1.img: f_mount 0, тип 1 (1 = FAT12), кластер 1 секторов, элементов FAT 2043
  метка «LOGGER», свободных кластеров 1941; LOG.CSV: f_stat 0, 51026 байт, атрибуты 21, дата 5D3D
  f_read 0: 51026 байт, CRC-32 290CD8BE; записей на диск: 0
disk3.img: f_mount 0, тип 1 (1 = FAT12), кластер 1 секторов, элементов FAT 2043
  метка «LOGGER», свободных кластеров 0; LOG.CSV: f_stat 0, 1044506 байт, атрибуты 21, дата 5D3D
  f_read 0: 1044506 байт, CRC-32 9ED3FEDF; записей на диск: 0

Второй путь — fatread.py, свой разбор без кода fatgen.c: поля BPB, элементы FAT по 12 бит, каталог, цепочка кластеров. Ожидаемый файл скрипт строит сам по той же формуле записей, что и модель:

  LOG.CSV: 51026 байт, атрибуты 21, дата 5D3D, время 6000, кластеры 2…101 (100 подряд: True), свободных 1941
  совпал байт в байт с ожидаемым: True; строк 1501, затёртых 0; CRC-32 290CD8BE
  LOG.CSV: 1044506 байт, атрибуты 21, дата 5D3D, время 6000, кластеры 2…2042 (2041 подряд: True), свободных 0
  совпал байт в байт с ожидаемым: True; строк 30721, затёртых 5; CRC-32 9ED3FEDF
  первые строки: seq,time_s,temp_C,rh_pct | --------,--------,-------,------ | 00013819,00138190,-012.11,041.49

CRC-32 файла у FatFs и у Python совпали во всех трёх образах. В третьем образе снимок — последние 30 720 записей из 44 534, первая — 13 814. Пока хост не читал, журнал добавил 2052 записи, на 4 больше запаса, и 5 самых старых строк стали прочерками (четыре сверх запаса и самая старая ячейка, в которую пишут). Остальные 30 715 строк — точно те, что в кольце.

Испорченный CBW. Не та сигнатура — обе точки остановлены, и простое снятие остановки не помогает; помогает только сброс протокола (запрос класса 0xFF по нулевой точке):

после мусора: IN -1, OUT -1
CLEAR_FEATURE без сброса: IN -1
TEST UNIT READY (после сброса BOT): статус CSW 0

198ф.7 Грабли

Новые записи не появляются, пока диск подключён. Система читает FAT и каталог и держит их у себя. Менять их под подключённым хостом нельзя — получится файл, у которого каталог и данные из разного времени. Новый снимок — только после «Извлечь», и сразу UNIT ATTENTION 06/28. Как именно конкретная система перечитает диск после 06/28, на модели не проверить.

«Извлечь» не отключает кабель. После START STOP UNIT с LoEj хост ещё на шине и спрашивает TEST UNIT READY. Ответ «готов» после извлечения выглядит как новый диск без предупреждения — нужно 02/3A, пока прибор не «вставит» носитель снова. Выдернуть кабель без «Извлечь» диску только для чтения не страшно: несохранённого нет.

Размер из READ CAPACITY и из BPB разошлись. В образе примера пакета том заявлен на 64 сектора, а ёмкость — 16. Хост, который начнёт писать в свободные кластеры по FAT, уйдёт за конец носителя. Здесь одно число 2055 стоит и в BPB, и в READ CAPACITY, и в READ FORMAT CAPACITIES.

READ CAPACITY отдаёт номер последнего блока, а не их число. 2054, не 2055. Ошибка на единицу — и хост считает последний сектор лишним или недостающим.

Размер блока — 512. Сектор диска не обязан совпадать со страницей флеш-памяти (8 КБ у U585, 256 байт у MX25LM51245G). Хосту отдаются сектора по 512 байт, а как они лежат в памяти — забота прибора; у вычисляемого диска этого вопроса нет совсем.

Элемент FAT12 на стыке секторов. 12 бит — полтора байта, и элемент 341 разрезан между первым и вторым сектором FAT. Генератор, который строит сектор FAT по номерам элементов, теряет половину. Считать по смещению байта.

Больше 4084 кластеров — уже не FAT12. Тип тома определяется числом кластеров. Раздули журнал — либо кластер больше, либо FAT16; надпись FAT12 в загрузочном секторе ничего не решает.

Хост ждёт данных, а их нет. Если команда не удалась, а CBW обещал данные, одного CSW с ошибкой мало: точку данных нужно остановить, иначе хост будет ждать обещанные байты. В модели так отработаны WRITE(10) (точка OUT), чтение за концом и неизвестная команда (точка IN).

Защита от записи только в WRITE(10). Если MODE SENSE молчит о защите, хост узнаёт о ней по отказу посреди работы. Бит WP — в MODE SENSE с самого подключения; в библиотеке ST V2.4.2 его нет.


198ф.8 Итоги

Полные исходники

fatgen.h — раскладка диска FAT12 «на лету».

/* Раздел 198ф: диск FAT12 только для чтения, который собирается «на лету» из журнала.
 * В памяти нет ни одного сектора: каждый считается в момент запроса READ(10). */
#ifndef FATGEN_H
#define FATGEN_H
#include <stdint.h>
#include "logring.h"

#define SECTOR        512u                              /* байт в секторе и в кластере */
#define HDR_LEN       26u                               /* "seq,time_s,temp_C,rh_pct\r\n" */
#define LINE_LEN      34u                               /* "00000001,00000010,+023.45,045.10\r\n" */
#define SNAP_GUARD    2048u                             /* запас кольца на время чтения хостом */
#define SNAP_MAX      (LOG_CAP - SNAP_GUARD)            /* записей в файле не больше */
#define FILE_MAX      (HDR_LEN + LINE_LEN * SNAP_MAX)
#define CLUSTERS      ((FILE_MAX + SECTOR - 1u) / SECTOR)
#define FAT_BYTES     ((3u * (CLUSTERS + 2u) + 1u) / 2u) /* 12 бит на кластер, два первых — служебные */
#define FAT_SECTORS   ((FAT_BYTES + SECTOR - 1u) / SECTOR)
#define ROOT_ENTRIES  16u                               /* корневой каталог — ровно один сектор */
#define FAT_LBA       1u                                /* сектор 0 — загрузочный */
#define ROOT_LBA      (FAT_LBA + 2u * FAT_SECTORS)      /* две копии FAT */
#define DATA_LBA      (ROOT_LBA + 1u)                   /* кластер 2 */
#define TOTAL_SECTORS (DATA_LBA + CLUSTERS)

void     disk_snapshot(void);                           /* зафиксировать, какие записи видит хост */
uint32_t disk_file_size(void);
uint32_t disk_first_seq(void);
void     disk_read(uint32_t lba, uint8_t *buf);         /* один сектор, 512 байт */

#endif

fatgen.c — генератор секторов FAT12.

/* Раздел 198ф: генератор FAT12. Сектор 0 — загрузочный, 1…12 — две копии FAT, 13 — корневой
 * каталог, с 14 — данные файла LOG.CSV кластерами по одному сектору подряд. */
#include <string.h>
#include "fatgen.h"

#define VOLUME_ID    0x20260929u
#define VOLUME_LABEL "LOGGER     "
#define FILE_NAME    "LOG     CSV"
#define FAT_DATE     ((uint16_t)(((2026u - 1980u) << 9) | (9u << 5) | 29u))   /* 29.09.2026 */
#define FAT_TIME     ((uint16_t)(12u << 11))                                  /* 12:00:00 */

static const char HDR[HDR_LEN + 1u] = "seq,time_s,temp_C,rh_pct\r\n";
static const char LOST[LINE_LEN + 1u] = "--------,--------,-------,------\r\n";

static uint32_t snap_first, snap_n, snap_size, snap_clusters;

static void put16(uint8_t *p, uint32_t v) {
    p[0] = (uint8_t)v;
    p[1] = (uint8_t)(v >> 8);
}
static void put32(uint8_t *p, uint32_t v) {
    put16(p, v);
    put16(p + 2, v >> 16);
}

void disk_snapshot(void) {
    uint32_t cnt = log_count();
    snap_n = cnt < SNAP_MAX ? cnt : SNAP_MAX;
    snap_first = cnt - snap_n;
    snap_size = HDR_LEN + LINE_LEN * snap_n;
    snap_clusters = (snap_size + SECTOR - 1u) / SECTOR;
}
uint32_t disk_file_size(void) {
    return snap_size;
}
uint32_t disk_first_seq(void) {
    return snap_first;
}

static void boot_sector(uint8_t *b) {
    static const uint8_t jmp_oem[11] = {0xEB, 0x3C, 0x90, 'M', 'S', 'D', 'O', 'S', '5', '.', '0'};
    memcpy(b, jmp_oem, sizeof jmp_oem);     /* без EB xx 90 том не признают за FAT */
    put16(b + 11, SECTOR);                  /* BPB_BytsPerSec */
    b[13] = 1;                              /* BPB_SecPerClus */
    put16(b + 14, FAT_LBA);                 /* BPB_RsvdSecCnt: только загрузочный сектор */
    b[16] = 2;                              /* BPB_NumFATs */
    put16(b + 17, ROOT_ENTRIES);            /* BPB_RootEntCnt */
    put16(b + 19, TOTAL_SECTORS);           /* BPB_TotSec16 — то же число, что в READ CAPACITY */
    b[21] = 0xF8;                           /* BPB_Media: 0xF8, как в образе примера пакета */
    put16(b + 22, FAT_SECTORS);             /* BPB_FATSz16 */
    put16(b + 24, 63);                      /* BPB_SecPerTrk и BPB_NumHeads — для старых систем */
    put16(b + 26, 255);
    b[36] = 0x80;                           /* BS_DrvNum */
    b[38] = 0x29;                           /* BS_BootSig: дальше идут номер и метка тома */
    put32(b + 39, VOLUME_ID);
    memcpy(b + 43, VOLUME_LABEL, 11);
    memcpy(b + 54, "FAT12   ", 8);
    b[510] = 0x55;
    b[511] = 0xAA;
}

/* Цепочка файла: кластеры 2, 3, … подряд, у последнего — конец цепочки 0xFFF */
static uint32_t fat_entry(uint32_t c) {
    if (c == 0) return 0xFF8;               /* 0xF00 | BPB_Media */
    if (c == 1) return 0xFFF;
    if (c >= 2u + snap_clusters) return 0;  /* свободный кластер */
    return c == 1u + snap_clusters ? 0xFFF : c + 1u;
}

/* Байт FAT12 по смещению: три байта хранят два 12-битных элемента */
static uint8_t fat_byte(uint32_t off) {
    uint32_t c = off / 3u * 2u;
    switch (off % 3u) {
    case 0:
        return (uint8_t)fat_entry(c);
    case 1:
        return (uint8_t)((fat_entry(c) >> 8) | (fat_entry(c + 1u) << 4));
    default:
        return (uint8_t)(fat_entry(c + 1u) >> 4);
    }
}

static void root_dir(uint8_t *b) {
    memcpy(b, VOLUME_LABEL, 11);
    b[11] = 0x08;                           /* метка тома */
    uint8_t *e = b + 32;
    memcpy(e, FILE_NAME, 11);
    e[11] = 0x21;                           /* только чтение + архивный */
    put16(e + 14, FAT_TIME);                /* создан */
    put16(e + 16, FAT_DATE);
    put16(e + 18, FAT_DATE);                /* последний доступ */
    put16(e + 22, FAT_TIME);                /* изменён */
    put16(e + 24, FAT_DATE);
    put16(e + 26, 2);                       /* первый кластер */
    put32(e + 28, snap_size);
}

static void dec(char *p, uint32_t v, int n) {   /* n цифр с ведущими нулями */
    while (n--) {
        p[n] = (char)('0' + v % 10u);
        v /= 10u;
    }
}

static void make_line(uint32_t seq, char *p) {
    log_rec_t r;
    if (!log_get(seq, &r)) {                /* запись затёрли, пока хост читал */
        memcpy(p, LOST, LINE_LEN);
        return;
    }
    int32_t t = r.temp_c100;
    dec(p, seq, 8);
    p[8] = ',';
    dec(p + 9, r.t_s, 8);
    p[17] = ',';
    p[18] = t < 0 ? '-' : '+';
    if (t < 0) t = -t;
    dec(p + 19, (uint32_t)t / 100u, 3);
    p[22] = '.';
    dec(p + 23, (uint32_t)t % 100u, 2);
    p[25] = ',';
    dec(p + 26, r.rh_c100 / 100u, 3);
    p[29] = '.';
    dec(p + 30, r.rh_c100 % 100u, 2);
    p[32] = '\r';
    p[33] = '\n';
}

/* Байты файла [off, off + len): заголовок, потом строки по 34 байта; за концом файла — нули */
static void csv_fill(uint32_t off, uint8_t *dst, uint32_t len) {
    char ln[LINE_LEN];
    uint32_t cur = UINT32_MAX;
    for (uint32_t i = 0; i < len; i++, off++) {
        if (off >= snap_size) {
            dst[i] = 0;
        } else if (off < HDR_LEN) {
            dst[i] = (uint8_t)HDR[off];
        } else {
            uint32_t k = (off - HDR_LEN) / LINE_LEN;
            if (k != cur) {
                make_line(snap_first + k, ln);
                cur = k;
            }
            dst[i] = (uint8_t)ln[(off - HDR_LEN) % LINE_LEN];
        }
    }
}

void disk_read(uint32_t lba, uint8_t *b) {
    memset(b, 0, SECTOR);
    if (lba == 0) {
        boot_sector(b);
    } else if (lba < ROOT_LBA) {            /* обе копии FAT одинаковы */
        uint32_t off = ((lba - FAT_LBA) % FAT_SECTORS) * SECTOR;
        for (uint32_t i = 0; i < SECTOR; i++) b[i] = fat_byte(off + i);
    } else if (lba == ROOT_LBA) {
        root_dir(b);
    } else if (lba < TOTAL_SECTORS) {
        csv_fill((lba - DATA_LBA) * SECTOR, b, SECTOR);
    }
}

scsi.c — Bulk-Only Transport и команды SCSI.

/* Раздел 198ф: Bulk-Only Transport. Хост шлёт CBW (31 байт), потом данные в одну сторону,
 * потом забирает CSW (13 байт). Все команды с данными от хоста отклоняются: диск только для чтения. */
#include <string.h>
#include "fatgen.h"
#include "scsi.h"

#define CBW_SIG 0x43425355u                 /* "USBC" */
#define CSW_SIG 0x53425355u                 /* "USBS" */

enum { ST_CBW, ST_DATA, ST_CSW, ST_DEAD };
enum { SK_NOT_READY = 2, SK_ILLEGAL = 5, SK_UNIT_ATTENTION = 6, SK_DATA_PROTECT = 7 };

static const uint8_t INQUIRY[36] = {
    0x00,                                   /* прямой доступ: диск */
    0x80,                                   /* RMB: съёмный носитель */
    0x02, 0x02, 31, 0, 0, 0,                /* версия, формат ответа, ещё 31 байт */
    'H', 'A', 'N', 'D', 'B', 'O', 'O', 'K', /* производитель, 8 байт */
    'U', '5', '8', '5', ' ', 'L', 'O', 'G', 'G', 'E', 'R', ' ', ' ', ' ', ' ', ' ',   /* изделие, 16 */
    '1', '.', '0', ' ',                     /* версия, 4 байта */
};

static struct {
    uint8_t  state, in_halt, out_halt;
    uint32_t tag, dlen, sent;               /* dCBWDataTransferLength и сколько уже ушло */
    uint8_t  status;                        /* bCSWStatus: 0 — успех, 1 — ошибка, 2 — ошибка фазы */
    uint32_t lba, left;                     /* READ(10): следующий сектор и сколько осталось */
    uint8_t  buf[SECTOR];
    uint32_t pos, len;
    uint8_t  key, asc;                      /* ошибка для REQUEST SENSE */
    uint8_t  ua, ejected;
} m;

static uint32_t get32(const uint8_t *p) {
    return p[0] | (uint32_t)p[1] << 8 | (uint32_t)p[2] << 16 | (uint32_t)p[3] << 24;
}
static void put32(uint8_t *p, uint32_t v) {
    for (int i = 0; i < 4; i++) p[i] = (uint8_t)(v >> (8 * i));
}
static uint32_t be32(const uint8_t *p) {
    return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16 | (uint32_t)p[2] << 8 | p[3];
}
static uint32_t be16(const uint8_t *p) {
    return (uint32_t)p[0] << 8 | p[1];
}
static void put_be32(uint8_t *p, uint32_t v) {
    for (int i = 0; i < 4; i++) p[i] = (uint8_t)(v >> (24 - 8 * i));
}

void msc_reset(void) {
    uint8_t ej = m.ejected, ua = m.ua;
    memset(&m, 0, sizeof m);
    m.ejected = ej;                         /* сброс протокола не вставляет носитель обратно */
    m.ua = ua;
}
void msc_media_changed(void) {
    m.ejected = 0;
    m.ua = 1;                               /* следующая команда получит UNIT ATTENTION 06/28 */
}
bool msc_ejected(void) {
    return m.ejected;
}

/* Короткий ответ: не больше, чем просил хост в команде и чем он ждёт по CBW */
static void reply(const uint8_t *d, uint32_t n, uint32_t alloc) {
    if (n > alloc) n = alloc;
    if (n > m.dlen) n = m.dlen;
    memcpy(m.buf, d, n);
    m.pos = 0;
    m.len = n;
    m.left = 0;
    m.state = n ? ST_DATA : ST_CSW;
}

/* Ошибка: запомнить причину, остановить точку, по которой шли бы данные, ответить CSW = 1 */
static void fail(uint8_t key, uint8_t asc, bool in) {
    m.key = key;
    m.asc = asc;
    m.status = 1;
    if (m.dlen) {
        if (in) m.in_halt = 1;
        else m.out_halt = 1;
    }
    m.state = ST_CSW;
}

static void scsi_command(const uint8_t *cb, bool in) {
    uint8_t op = cb[0];
    if (!in && m.dlen) {                    /* данные от хоста: WRITE(10), MODE SELECT … */
        if (op == 0x2A) fail(SK_DATA_PROTECT, 0x27, false);   /* WRITE PROTECTED */
        else fail(SK_ILLEGAL, 0x20, false);
        return;
    }
    if (m.ua && op != 0x12 && op != 0x03) { /* INQUIRY и REQUEST SENSE проходят всегда */
        m.ua = 0;
        fail(SK_UNIT_ATTENTION, 0x28, in);  /* MEDIUM MAY HAVE CHANGED */
        return;
    }
    switch (op) {
    case 0x00:                              /* TEST UNIT READY */
        if (m.ejected) fail(SK_NOT_READY, 0x3A, in);          /* MEDIUM NOT PRESENT */
        else m.state = ST_CSW;
        break;
    case 0x03: {                            /* REQUEST SENSE: фиксированный формат, 18 байт */
        uint8_t s[18] = {0x70, 0, m.key, 0, 0, 0, 0, 10, 0, 0, 0, 0, m.asc, 0, 0, 0, 0, 0};
        m.key = m.asc = 0;
        reply(s, sizeof s, cb[4]);
        break;
    }
    case 0x12:                              /* INQUIRY */
        reply(INQUIRY, sizeof INQUIRY, be16(cb + 3));
        break;
    case 0x1A: {                            /* MODE SENSE(6): заголовок 4 байта, бит WP = 0x80 */
        static const uint8_t s[4] = {3, 0, 0x80, 0};
        reply(s, sizeof s, cb[4]);
        break;
    }
    case 0x5A: {                            /* MODE SENSE(10): заголовок 8 байт */
        static const uint8_t s[8] = {0, 6, 0, 0x80, 0, 0, 0, 0};
        reply(s, sizeof s, be16(cb + 7));
        break;
    }
    case 0x1B:                              /* START STOP UNIT: LoEj = 1, Start = 0 — «Извлечь» */
        if ((cb[4] & 0x03u) == 0x02u) m.ejected = 1;
        m.state = ST_CSW;
        break;
    case 0x1E:                              /* PREVENT ALLOW MEDIUM REMOVAL */
    case 0x2F:                              /* VERIFY(10) */
    case 0x35:                              /* SYNCHRONIZE CACHE(10) */
        m.state = ST_CSW;
        break;
    case 0x23: {                            /* READ FORMAT CAPACITIES */
        uint8_t s[12] = {0, 0, 0, 8, 0, 0, 0, 0, 0x02, 0, SECTOR >> 8, SECTOR & 0xFF};
        put_be32(s + 4, TOTAL_SECTORS);     /* 0x02 — носитель размечен */
        reply(s, sizeof s, be16(cb + 7));
        break;
    }
    case 0x25: {                            /* READ CAPACITY(10): НОМЕР последнего сектора и размер */
        uint8_t s[8];
        put_be32(s, TOTAL_SECTORS - 1u);
        put_be32(s + 4, SECTOR);
        reply(s, sizeof s, sizeof s);
        break;
    }
    case 0x28: {                            /* READ(10) */
        uint32_t lba = be32(cb + 2), cnt = be16(cb + 7);
        if (m.ejected) {
            fail(SK_NOT_READY, 0x3A, in);
        } else if (lba >= TOTAL_SECTORS || cnt > TOTAL_SECTORS - lba) {
            fail(SK_ILLEGAL, 0x21, in);     /* LOGICAL BLOCK ADDRESS OUT OF RANGE */
        } else if (m.dlen != cnt * SECTOR) {
            m.status = 2;                   /* хост и команда не сошлись в длине: ошибка фазы */
            if (m.dlen) m.in_halt = 1;
            m.state = ST_CSW;
        } else {
            m.lba = lba;
            m.left = cnt;
            m.pos = m.len = 0;
            m.state = cnt ? ST_DATA : ST_CSW;
        }
        break;
    }
    default:
        fail(SK_ILLEGAL, 0x20, in);         /* INVALID COMMAND OPERATION CODE */
    }
}

int msc_out(const uint8_t *p, uint32_t n) {
    if (m.out_halt) return -1;
    if (m.state != ST_CBW) return 0;
    if (n != 31u || get32(p) != CBW_SIG) {  /* не CBW: обе точки стоят до сброса BOT */
        m.in_halt = m.out_halt = 1;
        m.state = ST_DEAD;
        return 0;
    }
    m.tag = get32(p + 4);
    m.dlen = get32(p + 8);
    m.sent = 0;
    m.status = 0;
    scsi_command(p + 15, (p[12] & 0x80u) != 0);
    return 0;
}

int msc_in(uint8_t *pkt) {
    if (m.in_halt) return -1;
    if (m.state == ST_DATA) {
        if (m.pos == m.len) {               /* буфер отдан: считать следующий сектор */
            disk_read(m.lba++, m.buf);
            m.left--;
            m.pos = 0;
            m.len = SECTOR;
        }
        uint32_t n = m.len - m.pos;
        if (n > MSC_PACKET) n = MSC_PACKET;
        memcpy(pkt, m.buf + m.pos, n);
        m.pos += n;
        m.sent += n;
        if (m.pos == m.len && m.left == 0) m.state = ST_CSW;
        return (int)n;
    }
    if (m.state == ST_CSW) {
        put32(pkt, CSW_SIG);
        put32(pkt + 4, m.tag);              /* dCSWTag — как в CBW */
        put32(pkt + 8, m.dlen - m.sent);    /* dCSWDataResidue: сколько не дослали */
        pkt[12] = m.status;
        m.state = ST_CBW;
        return 13;
    }
    return 0;
}

void msc_clear_halt(uint8_t ep) {
    if (m.state == ST_DEAD) return;         /* после плохого CBW помогает только сброс BOT */
    if (ep & 0x80u) m.in_halt = 0;
    else m.out_halt = 0;
}

logring.c — кольцо журнала.

/* Раздел 198ф: кольцо журнала. Пишет главный цикл, читает обработчик USB: счётчик растёт
 * только после того, как запись целиком легла в ячейку. */
#include "logring.h"

#ifndef LOG_RAM_ATTR
#define LOG_RAM_ATTR                        /* на U585: __attribute__((section(".bss.sram3"))) */
#endif
#define BARRIER() __asm volatile("" ::: "memory")   /* компилятор не переставляет доступы через неё */

LOG_RAM_ATTR static log_rec_t ring[LOG_CAP];
static volatile uint32_t count;

void log_append(uint32_t t_s, int16_t temp_c100, uint16_t rh_c100) {
    log_rec_t *r = &ring[count % LOG_CAP];
    r->t_s = t_s;
    r->temp_c100 = temp_c100;
    r->rh_c100 = rh_c100;
    BARRIER();                              /* сначала запись, потом счётчик */
    count = count + 1u;                     /* 32-битная запись на Cortex-M33 неделима */
}

uint32_t log_count(void) {
    return count;
}

bool log_get(uint32_t seq, log_rec_t *r) {
    uint32_t c = count;
    if (seq >= c || c - seq >= LOG_CAP) return false;   /* самую старую ячейку сейчас могут переписывать */
    *r = ring[seq % LOG_CAP];
    BARRIER();
    return count - seq < LOG_CAP;           /* пока копировали, ячейку не тронули */
}

Дальше: раздел 198х «Мини-проект: датчики платы B-U585I-IOT02A — климат, движение и свет» — откуда в журнале берутся температура и влажность: датчики на той же плате.

Открыть в интерактивном справочнике