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

198б. Мини-проект: USB-самописец и программа на ПК

Прибор этого проекта — самописец: F407 меряет два канала АЦП тысячу раз в секунду и непрерывно шлёт поток на компьютер, где программа принимает, проверяет и пишет файл с графиком. Компьютеру прибор виден обычным последовательным портом — класс CDC из раздела 20. Новое здесь не USB (его механика уже разобрана), а всё, что между измерением и файлом: двоичный кадр с контролем целостности, очередь на случай пауз приёмника и программа на ПК, которая честно считает потери.

QEMU здесь не помощник: USB OTG он не моделирует — ни перечисления, ни конечных точек. Поэтому проект разрезан по шву, проверяемому без платы (приём разделов 184 и 196): кадр и очередь — общий код logger.c без единого регистра, собранный и для Cortex-M4, и обычным gcc; поток кадров генератор гонит через канал в приёмник. Файлы — _verify/usb198b/: logger.h, logger.c, sim_queue.c, gen_frames.py, logger_rx.py; прогоны записаны в _verify/usb198b/*_host.txt, сверяет _verify/usb198b_check.py.

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

Оригинал Перевод
data logger самописец: прибор, пишущий поток измерений
payload полезная нагрузка: данные кадра без заголовка и CRC
magic number сигнатура: приметные байты начала кадра
sequence number порядковый номер кадра: по дырам в нём видны потери
throughput пропускная способность: сколько байтов в секунду проходит
goodput полезная её часть — без заголовков, CRC и повторов
drop-oldest / drop-newest вытеснять старое / отбрасывать новое при переполнении

198б.1 Что делает прибор и кто за что отвечает

Функция Как
мерить два канала АЦП (раздел 14), 1 кГц, время — от SysTick, 1 мс
упаковывать кадр 20 байт: сигнатура, номер, время, каналы, счётчик вытеснений, CRC-32
переживать паузы ПК очередь на 32 кадра; полна — новое вытесняет старое
отдавать класс CDC (рецепт 20.7): пакеты до 64 байт, хост забирает сам
принимать на ПК Python: поиск сигнатуры → CRC → номера → CSV и график
считать потери дыры в номерах сверяются со счётчиком вытеснений из кадров

Путь измерения: АЦП, кадры, очередь и CDC в прошивке, затем COM-порт, приёмник и файл на компьютере

Два решения определяют всю архитектуру. Время метит прошивка: кадры доезжают до программы пачками и с задержкой — хост опрашивает устройство по своему расписанию (раздел 20), приёмник читает порт, когда его пустит система. Поэтому в каждом кадре лежит t_ms от SysTick, и графики строятся только по нему, а не по часам ПК. И буферизует прошивка: обратного канала нет, попросить «повтори» некому — паузу приёмника переживает только очередь в ОЗУ прибора (198б.3).

Конечных точек хватает с запасом: CDC требует три сверх нулевой — массовые IN и OUT для данных и прерывающую IN для уведомлений. OTG FS в F407 даёт четыре IN и четыре OUT, включая нулевую (заголовок stm32f407xx.h из пака: USB_OTG_FS_MAX_IN_ENDPOINTS 4). Буферы точек — общие 1280 байт FIFO (USB_OTG_FS_TOTAL_FIFO_SIZE), то есть 320 слов, которые прошивка делит между приёмным FIFO и передающими; кадрам по 20 байт хватает любой разумной разбивки.


198б.2 Кадр: двадцать байт, байт за байтом

Текстовый формат («t=1000 ch0=-123…») был бы вдвое-втрое толще и требовал разбора строк; двоичный кадр фиксированного размера проще и прибору, и программе. Поля — младшим байтом вперёд: так их пишет в память Cortex-M, и ПК почти всюду читает так же (порядок байтов — раздел 3). Сборка кадра в logger.c — буквально байт за байтом, без структур с выравниванием: формат виден глазами и не зависит от компилятора.

Кадр самописца: двадцать байт с сигнатурой, номером, временем, двумя каналами, счётчиком вытеснений и CRC-32

Байты Поле Зачем
0–1 сигнатура 0xA5 0x5A найти начало кадра в потоке без границ
2 тип 0x01 задел: калибровка, журнал, ответы — другие типы
3 флаги бит 0: перед этим кадром очередь вытесняла
4–5 порядковый номер дыры в номерах = потерянные кадры
6–9 t_ms время измерения по часам прибора
10–11, 12–13 канал 0, канал 1 сырые знаковые коды АЦП, без пересчёта
14–15 вытеснено всего младшие 16 бит счётчика вытеснений
16–19 CRC-32 по первым 16 байтам — ровно 4 слова

Счётчик вытеснений — сквозной, а не «с прошлого кадра»: вытесненный кадр уносит своё поле с собой, и раздельные счётчики потерялись бы вместе с ним. Разность сквозного счётчика в первом и последнем принятых кадрах всегда говорит, сколько вытеснила очередь, — сколько бы кадров между ними ни пропало.

CRC замыкает первые 16 байт — ровно четыре слова, как их ест аппаратный блок STM32 (раздел 43): слова из памяти, без переворота байтов. На компьютере то же считает десяток строк:

uint32_t lg_crc32_words(const uint8_t *p, uint32_t n)
{
    uint32_t crc = 0xFFFFFFFFu;
    for (uint32_t i = 0; i < n; i += 4) {
        /* слово так, как его прочитал бы процессор из памяти (младшим вперёд) */
        crc ^= (uint32_t)p[i] | (uint32_t)p[i + 1] << 8 |
               (uint32_t)p[i + 2] << 16 | (uint32_t)p[i + 3] << 24;
        for (int b = 0; b < 32; b++)
            crc = (crc & 0x80000000u) ? (crc << 1) ^ 0x04C11DB7u : crc << 1;
    }
    return crc;
}

⚠️ Сверьте реализацию по числам раздела 43, прежде чем гонять кадры. Модель печатает три контрольных значения: по байтам от 123456789 выходит 0x0376E6E7 (CRC-32/MPEG-2), по словам от 12345678 — 0xFEFC54F9, и это же число даёт побайтовый счёт строки 43218765 — порядок байтов в слове перевёрнут, как и описано там. Совпали все три — прошивка может писать CRC->DR без __REV, а ПК считать этой функцией.

Счётчики намеренно узкие. Номер кадра — 16 бит: оборот через 65 536 кадров, при 1 кГц — каждые 65,5 с; приёмнику хватает, потому что дыры он считает разностью по модулю 2¹⁶. Время — 32 бита миллисекунд: оборот через 49,7 суток непрерывной записи.


198б.3 Очередь: вытеснять старое или стоять

Очередь — кольцо на 32 кадра (640 байт буферов; вся структура со счётчиками — 652 байта, напечатано моделью). При 1 кГц это подушка на 32 мс молчания приёмника — обычные паузы планировщика и записи на диск она съедает молча. Интересное начинается, когда пауза длиннее. Вариантов два, и оба теряют данные — вопрос лишь, какие:

В ядре обе политики рядом (lg_push и lg_push_stop), чтобы модель могла их столкнуть. Вытеснение — три строки в начале lg_push; заодно взводится флаг и растёт сквозной счётчик:

void lg_push(struct lg_queue *q, uint32_t t_ms, int16_t ch0, int16_t ch1)
{
    uint8_t evicted = 0;
    if (q->count == LG_DEPTH) {                    /* вытеснить самый старый */
        q->head = (uint16_t)((q->head + 1) % LG_DEPTH);
        q->count--;
        q->drops_total++;
        evicted = 1;
    }
    uint16_t tail = (uint16_t)((q->head + q->count) % LG_DEPTH);
    lg_build(q, q->buf[tail], t_ms, ch0, ch1, evicted);
    q->count++;
}

При «стопе» есть тонкость: кадр не создан, но номер расходуется — иначе приёмник не увидел бы дыру, и потеря прошла бы незамеченной.

Модель sim_queue.c прогоняет 10 000 мс: производитель кладёт кадр каждую миллисекунду, потребитель забирает до четырёх кадров за миллисекунду, но на [3000; 3250) мс замолкает — четверть секунды хост занят чем-то своим. Обе политики, один и тот же прогон:

Политика Дошло Потеряно Потерянные номера
новое вытесняет старое 9781 219 2999–3217 — начало паузы
«стоп» 9781 219 3031–3249 — конец паузы

Потерь поровну — за паузу родилось больше кадров, чем влезает, и лишние погибли при любой политике. Различие в том, какие выжили: после «стопа» запись обрывается на номере 3030 — событие, из-за которого хост завис, в журнале отсутствует; при вытеснении дыра лежит в начале паузы, а последние 250 мс — на месте. Самописцу почти всегда нужно второе. И заметьте: потеряно 219, а не 218 кадров — очередь встретила паузу не пустой (кадр 2999 ещё ждал отправки), свободных мест было 31. Бухгалтерия сходится: сумма дыр в номерах принятых кадров равна сквозному счётчику в последнем — 219 и там и там, account=ok.

Ядро собрано и для прошивки: arm-none-eabi-gcc -mcpu=cortex-m4 -Os даёт 504 байта кода на весь logger.c — сборка кадра, CRC и обе политики; своих данных у ядра нет, очередь живёт у владельца. На плате lg_push зовётся из прерывания АЦП, а главный цикл по lg_pop скармливает кадры CDC_Transmit_FS (рецепт 20.7): пока класс отвечает «занят», следующий кадр просто не забирается из очереди.


198б.4 🔧 Приёмник на ПК: сигнатура, CRC, счёт потерь

Со стороны компьютера прибор — последовательный порт, читать его из Python умеет pyserial: serial.Serial("COM5") и port.read(4096) в цикле. Всё интересное — в разборе прочитанного.

⚠️ Порт отдаёт куски произвольного размера. USB возит пакетами по 64 байта, система склеивает их в буфер, read возвращает что накопилось: полтора кадра, треть кадра, десять кадров. Границ кадров не существует нигде, кроме самого формата — поэтому разбор обязан быть потоковым: принял кусок, добавил к хвосту, вынул целые кадры, остаток оставил до следующего куска.

Разбор — метод feed класса Parser в logger_rx.py, ему всё равно, откуда пришёл кусок — из порта, файла или канала:

    def feed(self, chunk):
        self.buf += chunk
        while True:
            i = self.buf.find(MAGIC)
            if i < 0:                          # сигнатуры нет — всё, кроме
                keep = max(0, len(self.buf) - 1)   # последнего байта, мусор
                self.bytes_skipped += keep
                self.buf = self.buf[keep:]
                return
            self.bytes_skipped += i
            self.buf = self.buf[i:]
            if len(self.buf) < FRAME:          # кадр ещё не дошёл целиком
                return
            cand = self.buf[:FRAME]
            if crc32_words(cand[:16]) == struct.unpack("<I", cand[16:])[0]:
                self._accept(cand)
                self.buf = self.buf[FRAME:]
            else:                              # ложная сигнатура или порча
                self.crc_errors += 1
                self.bytes_skipped += 1
                self.buf = self.buf[1:]

Порядок обязателен: сигнатура лишь кандидат на кадр, подтверждает его CRC. Два байта 0xA5 0x5A случаются и внутри полезной нагрузки, и в мусоре; CRC ложного кандидата не сойдётся, разбор сдвинется на один байт и продолжит поиск — это и есть восстановление после сбоя. Хвост короче кадра не трогается: остальное дошлёт следующий read.

Принятые кадры считаются по трём графам: frames — сколько дошло, lost_by_seq — сумма дыр в номерах, drops_accounted — разность сквозного счётчика вытеснений между последним и первым кадром. Разница lost_by_seq − drops_accounted — потери не по вине очереди: порча на линии, ошибки разбора. CSV пишется колонками t_ms,ch0,ch1 — график строится чем угодно, от табличного редактора до matplotlib.


198б.5 Пропускная способность: потолок и запасы

Сколько вообще может провезти CDC на полной скорости? Считается честно, из кадра шины (не путать с кадром самописца): при 12 Мбит/с за миллисекундный кадр шины проходит 1500 «сырых» байт. Массовая транзакция с полем данных 64 байта стоит 77 байт: 13 байт накладных — по байту SYNC и PID на каждый из трёх пакетов (маркерный, данные, подтверждение), 2 байта адреса с CRC5, 2 байта CRC16 поля данных и около трёх байт межпакетных промежутков. В кадр шины помещается 1500 / 77 = 19 транзакций — потолок массовых передач:

19 × 64 = 1216 байт за миллисекунду ≈ 1,2 МБ/с.

Это потолок всей шины: он делится между устройствами на том же контроллере и урезается другим трафиком; живой стек с ним и не сравняется. Но масштаб виден сразу:

Поток Байт/с Хватает?
самописец: 1 кГц × 20 байт 20 000 запас 60,8 раза
потолок CDC FS 1 216 000 —
UART 115 200 бод, 8N1 11 520 нет: меньше потока
UART 230 400 бод, 8N1 23 040 да, запас 15,2 %

Отсюда два вывода. Первый: на этих скоростях USB даже не вспотел — тесно станет на десятках килогерц. Второй: запасной канал UART (раздел 16) на привычных 115 200 бод наш поток не тянет — байт на проводе стоит 10 бит, и 11 520 Б/с меньше 20 000; нужен либо вдвое быстрый порт, либо прореживание. Полезной нагрузки в кадре при этом 4 байта из 20 — накладные 80 %; что с этим делать — в 198б.7.


198б.6 Прогон без платы: генератор гонит поток в приёмник

gen_frames.py собирает те же кадры, что logger.c — модель напечатала опорный кадр A5 5A 01 00 00 00 E8 03 00 00 85 FF C8 01 00 00 90 E0 61 E8, и генератор на тех же данных обязан выдать его байт в байт (проверка сверяет), — и через канал кормит logger_rx.py, как это делал бы порт.

Сценарий первый — честный канал, 10 000 кадров подряд:

frames=10000 crc_errors=0 lost_by_seq=0 drops_accounted=0 lost_unexplained=0 bytes_skipped=0

Ни потерь, ни ошибок CRC, ни выброшенных байтов; CSV — 10 001 строка с заголовком.

Сценарий второй — канал с грязью: 157 байт мусора двумя вставками (внутри одной — нарочно подброшенная ложная сигнатура A5 5A), пять кадров «вытеснены очередью» (номера пропущены, сквозной счётчик уехал на 5), в трёх кадрах после подсчёта CRC испорчен байт полезной нагрузки:

frames=9992 crc_errors=4 lost_by_seq=8 drops_accounted=5 lost_unexplained=3 bytes_skipped=217

Всё сходится до байта. Дошло 9992 = 10 000 − 5 вытесненных − 3 испорченных. Ошибок CRC четыре: три порчи плюс ложная сигнатура из мусора. Дыр в номерах восемь: пять объяснил счётчик вытеснений из самих кадров, три остались «на линии» — ровно испорченные. Выброшено 217 байт: 157 мусора и 60 байт трёх кадров, не прошедших CRC. Приёмник не только пережил грязь — он отчитался, какая потеря чья.


198б.7 Что улучшить


198б.8 Грабли

Скорость, заданная при открытии порта, ни на что не влияет. Так и задумано: у CDC «бод» — просьба из старого мира, до проводов она не доезжает, байты летят пакетами USB. Настоящая арифметика — в 198б.5.

CRC «не сходится с компьютером». Аппаратный блок ест слова младшим байтом вперёд (раздел 43). Либо прошивка кормит CRC->DR через __REV, либо — как здесь — ПК считает по словам; контроль: 12345678 по словам даёт 0xFEFC54F9.

Каждый второй кадр — «испорчен». Приёмник верит, что read вернёт целый кадр. Не вернёт: границ кадров в порту нет, кадр режется на любом байте. Потоковый разбор с хвостом — единственный честный путь.

Запись обрывается за мгновение до события. Политика «стоп»: пока хост молчал, очередь сохранила старые кадры и выбросила свежие — само событие. Самописцу — вытеснение старого (прогон 198б.3).

Потери растут, хотя пауз нет. Если lost_by_seq опережает drops_accounted, очередь ни при чём — кадры гибнут по пути: кабель, питание хаба, гонка записи кадра в прошивке. Глубина очереди не поможет.

Порт «занят» или прибор «не определяется». Порт держит забытый терминал — CDC монопольный; а «не определяется» на самодельной плате — почти всегда тактирование и обвязка из раздела 20, не этот проект.


198б.9 Итоги

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

logger.c — сборка кадра и очередь.

/* logger.c — сборка кадра и очередь. Собирается arm-none-eabi-gcc для
 * Cortex-M4 (прошивка) и обычным gcc (модель на компьютере) без правок. */
#include "logger.h"

uint32_t lg_crc32_words(const uint8_t *p, uint32_t n)
{
    uint32_t crc = 0xFFFFFFFFu;
    for (uint32_t i = 0; i < n; i += 4) {
        /* слово так, как его прочитал бы процессор из памяти (младшим вперёд) */
        crc ^= (uint32_t)p[i] | (uint32_t)p[i + 1] << 8 |
               (uint32_t)p[i + 2] << 16 | (uint32_t)p[i + 3] << 24;
        for (int b = 0; b < 32; b++)
            crc = (crc & 0x80000000u) ? (crc << 1) ^ 0x04C11DB7u : crc << 1;
    }
    return crc;
}

void lg_init(struct lg_queue *q)
{
    q->head = 0; q->count = 0; q->seq = 0; q->drops_total = 0;
}

/* Кадр целиком, байт за байтом; flags — бит 0: перед кадром были вытеснения */
static void lg_build(struct lg_queue *q, uint8_t *f, uint32_t t_ms,
                     int16_t ch0, int16_t ch1, uint8_t flags)
{
    uint16_t drops16 = (uint16_t)q->drops_total;    /* младшие 16 бит */
    f[0]  = LG_MAGIC0;
    f[1]  = LG_MAGIC1;
    f[2]  = LG_TYPE;
    f[3]  = flags;
    f[4]  = (uint8_t)(q->seq);          f[5]  = (uint8_t)(q->seq >> 8);
    f[6]  = (uint8_t)(t_ms);            f[7]  = (uint8_t)(t_ms >> 8);
    f[8]  = (uint8_t)(t_ms >> 16);      f[9]  = (uint8_t)(t_ms >> 24);
    f[10] = (uint8_t)((uint16_t)ch0);   f[11] = (uint8_t)((uint16_t)ch0 >> 8);
    f[12] = (uint8_t)((uint16_t)ch1);   f[13] = (uint8_t)((uint16_t)ch1 >> 8);
    f[14] = (uint8_t)(drops16);         f[15] = (uint8_t)(drops16 >> 8);
    uint32_t crc = lg_crc32_words(f, 16);
    f[16] = (uint8_t)(crc);             f[17] = (uint8_t)(crc >> 8);
    f[18] = (uint8_t)(crc >> 16);       f[19] = (uint8_t)(crc >> 24);
    q->seq++;
}

void lg_push(struct lg_queue *q, uint32_t t_ms, int16_t ch0, int16_t ch1)
{
    uint8_t evicted = 0;
    if (q->count == LG_DEPTH) {                    /* вытеснить самый старый */
        q->head = (uint16_t)((q->head + 1) % LG_DEPTH);
        q->count--;
        q->drops_total++;
        evicted = 1;
    }
    uint16_t tail = (uint16_t)((q->head + q->count) % LG_DEPTH);
    lg_build(q, q->buf[tail], t_ms, ch0, ch1, evicted);
    q->count++;
}

void lg_push_stop(struct lg_queue *q, uint32_t t_ms, int16_t ch0, int16_t ch1)
{
    if (q->count == LG_DEPTH) {                    /* места нет — кадр пропал */
        q->drops_total++;
        q->seq++;                    /* номер расходуется: приёмник увидит дыру */
        return;
    }
    uint16_t tail = (uint16_t)((q->head + q->count) % LG_DEPTH);
    lg_build(q, q->buf[tail], t_ms, ch0, ch1, 0);
    q->count++;
}

int lg_pop(struct lg_queue *q, uint8_t out[LG_FRAME])
{
    if (q->count == 0)
        return 0;
    const uint8_t *f = q->buf[q->head];
    for (uint32_t i = 0; i < LG_FRAME; i++)
        out[i] = f[i];
    q->head = (uint16_t)((q->head + 1) % LG_DEPTH);
    q->count--;
    return 1;
}

logger_rx.py — приёмник на компьютере: сигнатура, CRC, счёт потерь, CSV.

# -*- coding: utf-8 -*-
"""logger_rx.py — приёмник самописца: сигнатура -> CRC -> счёт потерь -> CSV.

python logger_rx.py [--csv файл]        поток кадров ждёт на stdin

С прибором вместо stdin открывается порт (pyserial):
    port = serial.Serial("COM5")        # скорость у CDC ни на что не влияет
и вместо read() у файла зовётся port.read(4096) в том же цикле.
"""
import struct
import sys

FRAME = 20
MAGIC = b"\xA5\x5A"


def crc32_words(data):
    """Тот же CRC, что считает блок STM32 и logger.c: по словам, без переворота."""
    crc = 0xFFFFFFFF
    for i in range(0, len(data), 4):
        crc ^= int.from_bytes(data[i:i + 4], "little")
        for _ in range(32):
            crc = ((crc << 1) ^ 0x04C11DB7 if crc & 0x80000000 else crc << 1) & 0xFFFFFFFF
    return crc


class Parser:
    """Потоковый разбор: feed() принимает куски любого размера."""

    def __init__(self, on_frame):
        self.buf = b""
        self.on_frame = on_frame
        self.frames = 0
        self.crc_errors = 0
        self.bytes_skipped = 0
        self.lost_by_seq = 0
        self.prev_seq = None
        self.first_field = None
        self.last_field = 0

    def feed(self, chunk):
        self.buf += chunk
        while True:
            i = self.buf.find(MAGIC)
            if i < 0:                          # сигнатуры нет — всё, кроме
                keep = max(0, len(self.buf) - 1)   # последнего байта, мусор
                self.bytes_skipped += keep
                self.buf = self.buf[keep:]
                return
            self.bytes_skipped += i
            self.buf = self.buf[i:]
            if len(self.buf) < FRAME:          # кадр ещё не дошёл целиком
                return
            cand = self.buf[:FRAME]
            if crc32_words(cand[:16]) == struct.unpack("<I", cand[16:])[0]:
                self._accept(cand)
                self.buf = self.buf[FRAME:]
            else:                              # ложная сигнатура или порча
                self.crc_errors += 1
                self.bytes_skipped += 1
                self.buf = self.buf[1:]

    def _accept(self, f):
        (_, _, _, _, seq, t_ms, ch0, ch1, field) = struct.unpack("<BBBBHIhhH", f[:16])
        if self.prev_seq is not None:
            self.lost_by_seq += (seq - self.prev_seq - 1) & 0xFFFF
        self.prev_seq = seq
        if self.first_field is None:
            self.first_field = field
        self.last_field = field
        self.frames += 1
        self.on_frame(t_ms, ch0, ch1)

    def report(self):
        accounted = 0
        if self.first_field is not None:
            accounted = (self.last_field - self.first_field) & 0xFFFF
        return ("frames=%d crc_errors=%d lost_by_seq=%d drops_accounted=%d "
                "lost_unexplained=%d bytes_skipped=%d"
                % (self.frames, self.crc_errors, self.lost_by_seq, accounted,
                   self.lost_by_seq - accounted, self.bytes_skipped))


def main():
    csv = None
    if len(sys.argv) >= 3 and sys.argv[1] == "--csv":
        csv = open(sys.argv[2], "w", encoding="utf-8")
        csv.write("t_ms,ch0,ch1\n")

    def on_frame(t_ms, ch0, ch1):
        if csv:
            csv.write("%d,%d,%d\n" % (t_ms, ch0, ch1))

    p = Parser(on_frame)
    src = sys.stdin.buffer
    while True:
        chunk = src.read(4096)      # порция произвольная: границ кадров нет
        if not chunk:
            break
        p.feed(chunk)
    if csv:
        csv.close()
    print(p.report())


if __name__ == "__main__":
    main()

Дальше: раздел 198в «Мини-проект: модуль ввода-вывода на Modbus RTU» — самописец говорил в одну сторону, теперь прибор станет отвечать на запросы.

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