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

198д. Мини-проект: логический анализатор из BluePill

Последний мини-проект — измерительный. В разделе 36а логический анализатор стоит в списке приборов на столе; пока его там нет, роль приставки на восемь каналов сыграет сама BluePill: таймер задаёт частоту выборки, DMA переносит порт в кольцевой буфер, UART отдаёт запись на компьютер, а Python разбирает её и превращает в файл для программы просмотра. По дороге — честная арифметика: какой потолок частоты выборки следует из устройства шин, сколько миллисекунд влезает в 20 КБ ОЗУ и сколько секунд уходит на выгрузку.

Это прибор «пока прибора нет»: покупная приставка из раздела 36а.6 пишет 8 каналов на 24 Мвыб/с часами напролёт — наша самоделка снимает 16 384 выборки и не быстрее 4,5 Мвыб/с. Зато в ней видно, как логический анализатор устроен изнутри — вплоть до предзаписи и отметки запуска.

Файлы — _verify/la198d/: la_f103.c (прошивка), start_f103.c, la_host.py (модель захвата, разборщик и конвертер в VCD). Прошивка собрана для Cortex-M3, модель прогнана на компьютере с самотестом. Сверяет _verify/la198d_check.py.

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

Оригинал Перевод
memory depth, capture depth глубина записи: сколько выборок помещается в памяти прибора
armed «взведён»: запись уже идёт, прибор ждёт условия запуска
post-trigger послезапись: часть буфера после момента запуска
metastability метастабильность: защёлка, «зависшая» между 0 и 1
synchronizer синхронизатор: две защёлки подряд на асинхронном входе
value change dump (VCD) текстовый формат записи сигналов: моменты переключений
run-length encoding (RLE) кодирование длинами серий: хранить длительности уровней
bus matrix матрица шин: арбитр между процессором и DMA

198д.1 Что делает прибор и что ему не по силам

Функция Как
восемь каналов PB0…PB7: младший байт GPIOB->IDR — одна выборка = один байт
частота выборки сетка 100 квыб/с … 4,5 Мвыб/с от TIM2 (кварц ×9 = 72 МГц)
глубина записи кольцо 16 384 выборки — 16,4 мс при 1 Мвыб/с
запуск фронт вверх или вниз на любом из каналов, через EXTI
предзапись четверть кольца — история до фронта, даром
выгрузка USART1: 115 200 или 921 600 бод, свой формат «LA1»
на компьютере временная диаграмма, разбор UART, конвертер в VCD для PulseView

Подключение анализатора: восемь каналов PB0–PB7 к испытуемой плате, USB-UART на PA9 и PA10, каналы 0, 1 и 5 — только 3,3 В

Каналы — весь нижний байт порта B. Два вывода с особенностями: PB2 — это BOOT1 (на BluePill он заведён на перемычку через резистор — слабый делитель сигналу не мешает), а PB3 и PB4 после сброса принадлежат JTAG. Прошивка отключает JTAG первой же строкой настройки (SWJ_CFG = 010 в AFIO_MAPR — «JTAG-DP выключен, SW-DP включён»), так что прошиваться по SWD можно по-прежнему, а PB3 и PB4 становятся обычными входами.

⚠️ Три канала из восьми не терпят 5 В. В таблице выводов даташита F103 у PB0, PB1 и PB5 нет пометки FT: их предел — VDD + 0,3 В. FT-входы (остальные пять каналов) держат до 5,5 В. Пятивольтовую шину подключают к каналам 2, 3, 4, 6, 7 — или через делитель (раздел 62). Это относится и к I²C с подтяжками к 5 В.

Что можно разглядывать, решает правило из раздела 36а.6 — не меньше четырёх-пяти выборок на бит: при 1 Мвыб/с это UART 115 200 (8,7 выборки на бит — с запасом), при 4,5 Мвыб/с — SPI и I²C до сотен килогерц. Для шин на мегагерцы и поиска наносекундных выбросов нужен настоящий прибор или осциллограф (раздел 36а).


198д.2 Потолок частоты выборки: считаем по шинам

Путь одной выборки: TIM2 доходит до конца счёта — событие обновления — запрос DMA — DMA читает GPIOB->IDR — пишет байт в ОЗУ. Прежде чем обещать мегагерцы, посчитаем, во что этот путь обходится.

Итого выборка занимает не меньше пяти тактов по 72 МГц — потолок около 14 Мвыб/с, и то если DMA один на шине. Он не один: матрица шин делит доступ по кругу и гарантирует процессору не меньше половины полосы — значит, и DMA в худшем случае достаётся только половина. Поэтому верхняя ступень сетки — 4,5 Мвыб/с: 16 тактов на выборку, трёхкратный запас к расчётным пяти. Быстрее не обещаем: пропуск выборки DMA ничем не сообщит (198д.9), а «анализатор, который иногда врёт» хуже, чем никакой. Процессор на время захвата уходит с дороги: читает только счётчик DMA.

Память считается проще. Одна выборка — байт, кольцо — 16 КБ из 20 КБ ОЗУ (остальное — стек и переменные; отдать все 20 КБ = 20,5 мс не выйдет):

Частота выборки Кольцо 16 384 выборки Такт на выборку
4,5 Мвыб/с 3,6 мс 16
1 Мвыб/с 16,4 мс 72
500 квыб/с 32,8 мс 144
100 квыб/с 164 мс 720

198д.3 Захват: таймер задаёт шаг, DMA носит байты

Частоту выборки задаёт TIM2: ARR = 72 МГц / f − 1, по каждому событию обновления — запрос DMA (бит UDE в DIER; сами таймеры — раздел 13, карта «какой запрос на каком канале» — раздел 9: TIM2_UP обслуживает канал 2 DMA1). Канал настроен так:

    DMA1_Channel2->CPAR = (uint32_t)&GPIOB->IDR;  /* источник: порт B, чтение словом */
    DMA1_Channel2->CMAR = (uint32_t)ring;
    DMA1_Channel2->CNDTR = N;
    DMA1_Channel2->CCR = DMA_CCR2_PSIZE_1 | DMA_CCR2_MINC | DMA_CCR2_CIRC | DMA_CCR2_EN;

Две неочевидные ширины. Регистры GPIO у F1 положено читать только 32-битными словами — поэтому источник объявлен 32-битным (PSIZE_1), а приёмник байтовый: по таблице ширин из справочного руководства DMA в этом случае пишет в память младший байт прочитанного слова — как раз PB0…PB7. Один запрос — один байт кольца, процессор не участвует.

Дальше запускается сам захват:

    TIM2->ARR = (uint16_t)(FCLK / rates[rate_i] - 1u);
    TIM2->EGR = TIM_EGR_UG;                       /* применить ARR сразу */
    TIM2->DIER = TIM_DIER_UDE;                    /* запрос DMA по событию обновления */
    TIM2->CR1 = TIM_CR1_CEN;                      /* выборки пошли */

🔧 Рецепт: проверить сетку выборки меандром. Настроить на PA8 ШИМ 100 кГц со скважностью 50 % (раздел 13.5) и перемычкой завести его на канал 7. В записи при 1 Мвыб/с период обязан быть ровно 10 выборок — если получается 11–12 «через раз», выборки теряются, частоту выборки надо снижать. Такой самопроверкой стоит встречать каждую новую скорость.


198д.4 Запуск: ждать фронт, не теряя начала

Наивный план «увидели фронт — начали писать» теряет самое интересное: то, что было до фронта, — а хочется видеть, например, состояние линий перед стартом обмена. Решение всех настоящих анализаторов — писать всегда: DMA работает в циклическом режиме (раздел 9) и гоняет кольцо по кругу ещё до всякого запуска. Фронт ничего не запускает — он лишь ставит отметку.

Кольцевой буфер на 16 384 выборки: история до фронта, отметка запуска на границе четверти и 12 288 выборок после

Фронт ловит EXTI (раздел 7): линия по номеру канала, порт B, фронт вверх или вниз. Обработчик крошечный:

void EXTI_IRQHandler(void)
{
    trig_ndtr = DMA1_Channel2->CNDTR;   /* где кольцо было в момент фронта */
    EXTI->PR = 0xFFu;                   /* снять запросы */
    EXTI->IMR &= ~0xFFu;                /* второй фронт уже не нужен */
    triggered = 1;
}

Порядок такой: взвели — прошивка сперва даёт кольцу накопить предзапись (четверть кольца, 4096 выборок) и только потом разрешает EXTI. После фронта дописываются оставшиеся 12 288 выборок — и таймер останавливается. В выгруженной записи отметка запуска поэтому всегда стоит ровно на выборке 4096, а перед ней — настоящая история входов.

Отметка не идеальна: вход в прерывание — 12 тактов плюс хвосты (раздел 7), и CNDTR читается с запозданием. На 4,5 Мвыб/с (16 тактов на выборку) отметка может уехать на одну-две выборки — сами данные при этом не искажаются ни на бит: их пишет DMA, которому до прерываний дела нет.


198д.5 Выгрузка: свой формат и сколько ждать

После остановки кольцо разворачивается по времени — от самой старой выборки к самой свежей — и уходит в USART1 с 16-байтовым заголовком:

Байты Что
0…3 LA1 и версия формата (1)
4…7 частота выборки, Гц
8…11 число выборок (16 384)
12…15 номер выборки с отметкой запуска
    uint32_t start = (N - DMA1_Channel2->CNDTR) & (N - 1u);  /* самая старая выборка */
    uint32_t trig = (N - trig_ndtr) & (N - 1u);              /* выборка момента запуска */
    for (uint32_t i = 0; i < N; i++)
        tx(ring[(start + i) & (N - 1u)]);         /* кольцо — по порядку времени */

Выгрузка — (16 + 16 384) байта, по 10 бит на байт — 164 000 бит:

Скорость порта Делитель BRR Фактически Время выгрузки
115 200 бод 625 ровно 115 200 1,42 с
921 600 бод 78 923 077 бод (+0,16 %) 0,18 с

Ровно 921 600 из 72 МГц не делится — ближайший целый делитель 78 даёт 923 077 бод; ошибка 0,16 % для UART незаметна (допуск — около 2 %, раздел 16). Команды прибору — однобайтовые: 0…7 — канал запуска, a/A — взвести по фронту вниз/вверх, g — снять без условия, s — следующая частота выборки, u — переключить скорость порта.

Сборка arm-none-eabi-gcc -Os для Cortex-M3, без предупреждений: 1272 байта Flash и 16 396 байт ОЗУ, из них 16 384 — кольцо. Прошивка — младший брат: почти вся работа лежит на DMA и компьютере.


198д.6 Разбор на компьютере: диаграмма, UART и VCD

Компьютерная половина — la_host.py. Она читает выгрузку, печатает кусок временной диаграммы вокруг отметки запуска:

к0  ▔▔▔▔▔▔▔▔▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
к1  ▁▁▔▔▔▔▔▔▔▔▔▔▁▁▁▁▁▁▁▁▁▁▔▔▔▔▔▔▔▔▔▔
            ^ запуск (выборка 4096)

— и разбирает UART: старт по фронту вниз, каждый бит читается в середине:

        if prev == 1 and cur == 0:                    # старт-бит
            byte = 0
            for bit in range(8):
                p = k + (1.5 + bit) * spb             # середина бита данных

После стоп-бита разборщик прыгает в его середину и ждёт настоящий фронт следующего старта — пересинхронизация на каждом байте, как в живом приёмнике UART (раздел 16). Без неё передатчик со скоростью на 2 % выше уезжал бы на 1,7 выборки за кадр, и к пятому байту разбор рассыпался.

Тот же скрипт — конвертер в VCD: текстовый формат «моменты переключений и новые значения», который PulseView (раздел 36а.6) открывает как импорт, — и запись самодельного прибора попадает в те же разборщики протоколов, что и запись покупного:

$timescale 1 ns $end
$var wire 1 ! k0 $end
...
#4096000
0!

Самотест. Модель в la_host.py повторяет алгоритм прошивки один в один: кольцо, предзапись, фронт, отметка, выгрузка «LA1». Генератор подаёт на канал 0 посылку UART 115 200 при 1 Мвыб/с в 20 разных фазах относительно сетки выборки, запись прогоняется через разборщик:

phases 20/20 байт-в-байт, посылка b'la198d' (6 байт)
mark=[4096] (предзапись ровно PRE)
tx+2%: 20/20

Все 20 фаз разобраны байт-в-байт, отметка запуска в каждой записи — на выборке 4096, до неё — тишина линии (история цела). И с передатчиком, завышенным на 2 %, — тоже 20 из 20: запас 8,7 выборки на бит работает.


198д.7 Ограничения — честно


198д.8 Что улучшить


198д.9 Грабли

Каналы 3 и 4 молчат или читаются мусором. PB3 и PB4 после сброса принадлежат JTAG. Отключить JTAG (SWJ_CFG = 010) — SWD для прошивки останется.

На неподключённых каналах — случайная «активность». Вход без подтяжки висит и ловит наводки (раздел 8). Это не сигнал; подтягивать все восемь входов не стоит — подтяжка грузит испытуемую линию.

Меандр 100 кГц в записи «плавает»: то 10 выборок период, то 12. Частота выборки выше, чем DMA успевает обслуживать: запрос таймера пришёл, пока прежний не завершён, — выборка молча пропала, флага ошибки нет. Держаться обоснованной сетки и проверять её меандром (рецепт в 198д.3).

Взвели — а фронт, пришедший сразу после команды, пропущен. Прошивка сначала копит предзапись (4096 выборок: при 100 квыб/с это 41 мс) и лишь потом разрешает EXTI. Это цена гарантированной истории до запуска.

UART в записи разбирается через раз. Мало выборок на бит: правило из раздела 36а.6 — не меньше четырёх-пяти, а если передатчик тактируется от RC-генератора (±2 %), — с запасом. Поднять частоту выборки.

После сеанса на 5-вольтовой шине плата стала греться. PB0, PB1 или PB5 — без FT: 5 В на них — за пределом абсолютного максимума даташита. Пятивольтовое — только на FT-каналы или через делитель (раздел 62).


198д.10 Итоги

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

la_f103.c — прошивка анализатора на BluePill: захват восьми каналов и выгрузка.

/* Мини-проект «Логический анализатор»: BluePill (STM32F103C8), регистры через CMSIS.
 *   PB0…PB7 — восемь каналов (PB0, PB1, PB5 — не FT: только до 3,3 В);
 *   PA9/PA10 — USART1: команды и выгрузка, 115 200 или 921 600 бод.
 * Такт: кварц 8 МГц × 9 = 72 МГц. TIM2 задаёт частоту выборки (событие
 * обновления), DMA1 канал 2 по каждому событию переносит GPIOB->IDR
 * (младший байт = PB0…PB7) в кольцевой буфер на 16 384 выборки.
 * Запуск — фронт на выбранном канале через EXTI; кольцо к этому моменту
 * уже пишется, поэтому в записи есть и то, что было ДО фронта. */
#include "stm32f10x.h"

#define N        16384u                 /* выборок в кольце; степень двойки */
#define PRE      (N / 4u)               /* предзапись: четверть кольца — история */
#define FCLK     72000000u

static uint8_t ring[N];
static volatile uint32_t trig_ndtr;     /* CNDTR в момент запуска */
static volatile uint8_t triggered;

static const uint32_t rates[] = { 4500000u, 2000000u, 1000000u, 500000u, 100000u };
static uint8_t rate_i = 2;              /* 1 Мвыб/с по умолчанию */
static uint8_t trig_ch = 0;             /* канал запуска: PB0 */
static uint16_t brr = 625;              /* 72 МГц / 625 = 115 200 бод */

/* один обработчик на все линии EXTI0…EXTI9_5 — в таблице векторов
 * шесть ячеек указывают сюда */
void EXTI_IRQHandler(void)
{
    trig_ndtr = DMA1_Channel2->CNDTR;   /* где кольцо было в момент фронта */
    EXTI->PR = 0xFFu;                   /* снять запросы */
    EXTI->IMR &= ~0xFFu;                /* второй фронт уже не нужен */
    triggered = 1;
}

static void clock_72mhz(void)
{
    RCC->CR |= RCC_CR_HSEON;
    while (!(RCC->CR & RCC_CR_HSERDY)) {
    }
    FLASH->ACR = FLASH_ACR_PRFTBE | FLASH_ACR_LATENCY_2;
    RCC->CFGR = RCC_CFGR_PLLSRC | RCC_CFGR_PLLMULL9 | RCC_CFGR_PPRE1_DIV2;
    RCC->CR |= RCC_CR_PLLON;
    while (!(RCC->CR & RCC_CR_PLLRDY)) {
    }
    RCC->CFGR |= RCC_CFGR_SW_PLL;
    while ((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL) {
    }
}

static void tx(uint8_t b)
{
    while (!(USART1->SR & USART_SR_TXE)) {
    }
    USART1->DR = b;
}

static void tx_str(const char *s)
{
    for (; *s; s++)
        tx((uint8_t)*s);
}

static void tx_u32(uint32_t v)          /* десятичное число в консоль */
{
    char b[11];
    int i = 10;
    b[i] = 0;
    do {
        b[--i] = (char)('0' + v % 10u);
        v /= 10u;
    } while (v);
    tx_str(b + i);
}

/* сколько выборок кольцо накрутило с прошлого взгляда на CNDTR */
static uint32_t advanced(uint32_t *prev)
{
    uint32_t cur = DMA1_Channel2->CNDTR;
    uint32_t d = (*prev - cur) & (N - 1u);   /* CNDTR идёт вниз и перезаряжается */
    *prev = cur;
    return d;
}

static void capture_start(void)
{
    DMA1_Channel2->CCR = 0;                       /* стоп и настройка с нуля */
    DMA1_Channel2->CPAR = (uint32_t)&GPIOB->IDR;  /* источник: порт B, чтение словом */
    DMA1_Channel2->CMAR = (uint32_t)ring;
    DMA1_Channel2->CNDTR = N;
    DMA1_Channel2->CCR = DMA_CCR2_PSIZE_1 | DMA_CCR2_MINC | DMA_CCR2_CIRC | DMA_CCR2_EN;
        /* источник 32 бита, приёмник 8 бит: в память идёт младший байт — PB0…PB7 */
    TIM2->PSC = 0;
    TIM2->ARR = (uint16_t)(FCLK / rates[rate_i] - 1u);
    TIM2->EGR = TIM_EGR_UG;                       /* применить ARR сразу */
    TIM2->DIER = TIM_DIER_UDE;                    /* запрос DMA по событию обновления */
    TIM2->CR1 = TIM_CR1_CEN;                      /* выборки пошли */
}

static void wait_samples(uint32_t need, uint32_t *prev)
{
    uint32_t done = 0;
    while (done < need)
        done += advanced(prev);
}

static void dump(void)
{
    uint32_t start = (N - DMA1_Channel2->CNDTR) & (N - 1u);  /* самая старая выборка */
    uint32_t trig = (N - trig_ndtr) & (N - 1u);              /* выборка момента запуска */
    tx('L');
    tx('A');
    tx('1');
    tx(1);                                        /* версия формата */
    uint32_t rate = rates[rate_i];
    uint32_t mark = (trig - start) & (N - 1u);    /* запуск в координатах выгрузки */
    for (int i = 0; i < 4; i++)
        tx((uint8_t)(rate >> 8 * i));             /* всё — младшим байтом вперёд */
    for (int i = 0; i < 4; i++)
        tx((uint8_t)(N >> 8 * i));
    for (int i = 0; i < 4; i++)
        tx((uint8_t)(mark >> 8 * i));
    for (uint32_t i = 0; i < N; i++)
        tx(ring[(start + i) & (N - 1u)]);         /* кольцо — по порядку времени */
}

/* edge: -1 — фронт вниз, +1 — вверх, 0 — снять сразу */
static void capture(int edge)
{
    uint32_t prev = N;
    capture_start();
    wait_samples(PRE, &prev);                     /* сначала накопить историю */
    if (edge) {
        uint32_t line = 1u << trig_ch;
        AFIO->EXTICR[trig_ch >> 2] =
            (AFIO->EXTICR[trig_ch >> 2] & ~(0xFu << 4 * (trig_ch & 3u)))
            | (1u << 4 * (trig_ch & 3u));         /* линия — к порту B */
        EXTI->RTSR = (EXTI->RTSR & ~0xFFu) | (edge > 0 ? line : 0);
        EXTI->FTSR = (EXTI->FTSR & ~0xFFu) | (edge < 0 ? line : 0);
        EXTI->PR = line;
        triggered = 0;
        EXTI->IMR |= line;
        while (!triggered) {                      /* кольцо пишется, процессор ждёт */
        }
        prev = trig_ndtr;
    } else {
        trig_ndtr = DMA1_Channel2->CNDTR;
        prev = trig_ndtr;
    }
    wait_samples(N - PRE, &prev);                 /* дописать три четверти после */
    TIM2->CR1 = 0;                                /* стоп: кольцо замерло */
    DMA1_Channel2->CCR &= ~DMA_CCR2_EN;
    dump();
}

int main(void)
{
    clock_72mhz();
    RCC->AHBENR |= RCC_AHBENR_DMA1EN;
    RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_IOPBEN
                  | RCC_APB2ENR_USART1EN | RCC_APB2ENR_AFIOEN;
    RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
    AFIO->MAPR = AFIO_MAPR_SWJ_CFG_JTAGDISABLE;   /* JTAG выключен, SWD остался: PB3, PB4 — наши */
    GPIOB->CRL = 0x44444444u;                     /* PB0…PB7 — входы без подтяжки, как у прибора */
    GPIOA->CRH = (GPIOA->CRH & ~0xFF0u) | 0x4A0u; /* PA9 — альтернативный выход, PA10 — вход */
    USART1->BRR = brr;
    USART1->CR1 = USART_CR1_UE | USART_CR1_TE | USART_CR1_RE;
    NVIC_EnableIRQ(EXTI0_IRQn);
    NVIC_EnableIRQ(EXTI1_IRQn);
    NVIC_EnableIRQ(EXTI2_IRQn);
    NVIC_EnableIRQ(EXTI3_IRQn);
    NVIC_EnableIRQ(EXTI4_IRQn);
    NVIC_EnableIRQ(EXTI9_5_IRQn);
    for (;;) {
        while (!(USART1->SR & USART_SR_RXNE)) {
        }
        uint8_t c = (uint8_t)USART1->DR;
        if (c >= '0' && c <= '7') {               /* канал запуска */
            trig_ch = (uint8_t)(c - '0');
            tx_str("c=");
            tx_u32(trig_ch);
            tx_str("\r\n");
        } else if (c == 's') {                    /* следующая скорость по кругу */
            rate_i = (uint8_t)((rate_i + 1u) % (sizeof rates / sizeof rates[0]));
            tx_str("s=");
            tx_u32(rates[rate_i]);
            tx_str("\r\n");
        } else if (c == 'u') {                    /* скорость порта туда-обратно */
            brr = (brr == 625) ? 78 : 625;        /* 115 200 или 72 МГц / 78 = 923 077 */
            while (!(USART1->SR & USART_SR_TC)) {
            }
            USART1->BRR = brr;
            tx_str(brr == 625 ? "u=115200\r\n" : "u=921600\r\n");
        } else if (c == 'a') {
            capture(-1);                          /* взвести: фронт вниз (старт-бит UART) */
        } else if (c == 'A') {
            capture(+1);                          /* взвести: фронт вверх */
        } else if (c == 'g') {
            capture(0);                           /* снять сразу, без условия */
        }
    }
}

la_host.py — компьютерная часть: команды, приём и разбор захвата.

# -*- coding: utf-8 -*-
"""Мини-проект «Логический анализатор из BluePill»: компьютерная часть.

python la_host.py selftest   — модель захвата + разбор, 20 фаз, байт-в-байт
python la_host.py vcd dump.la out.vcd — конвертер записи в VCD для PulseView

Модель повторяет алгоритм прошивки la_f103.c один в один: кольцо на
N выборок, предзапись PRE, запуск по спадающему фронту канала 0,
затем N−PRE выборок после и выгрузка в формате LA1.
"""
import struct
import sys

N = 16384          # выборок в кольце (= прошивке)
PRE = N // 4       # предзапись: 4096 выборок истории
RATE = 1_000_000   # выборок в секунду
BAUD = 115200
MSG = b"la198d"    # что «передатчик» шлёт по каналу 0

sys.stdout.reconfigure(encoding="utf-8", errors="replace")


# ── сигнал ───────────────────────────────────────────────────────────────
def uart_level(t_bits):
    """Уровень линии UART в момент t (в битах от начала посылки): кадры 8N1 подряд."""
    if t_bits < 0:
        return 1
    frame, pos = divmod(t_bits, 10.0)
    frame = int(frame)
    if frame >= len(MSG):
        return 1
    bit = int(pos)
    if bit == 0:
        return 0                        # старт
    if bit <= 8:
        return (MSG[frame] >> (bit - 1)) & 1
    return 1                            # стоп


def signal_byte(k, s0, spb):
    """Восемь каналов в момент выборки k: к0 — UART, к1 — меандр 50 кГц."""
    ch0 = uart_level((k - s0) / spb)
    ch1 = (k // 10) & 1                 # период 20 выборок = 50 кГц при 1 Мвыб/с
    return ch0 | (ch1 << 1)


# ── модель захвата: алгоритм прошивки ────────────────────────────────────
def capture(s0, spb, rate=RATE):
    """Кольцо + предзапись + запуск по фронту вниз на к0. Возвращает выгрузку LA1."""
    ring = bytearray(N)
    k = 0
    prev_ch0 = 1
    trig_k = None
    while True:
        b = signal_byte(k, s0, spb)
        ring[k % N] = b
        if k >= PRE and trig_k is None and prev_ch0 == 1 and (b & 1) == 0:
            trig_k = k                  # EXTI: фронт вниз после взведения
        prev_ch0 = b & 1
        k += 1
        if trig_k is not None and k == trig_k + (N - PRE):
            break                       # дописаны три четверти после запуска
    start = k % N                       # самая старая выборка
    mark = (trig_k - (k - N)) % N       # запуск в координатах выгрузки
    out = b"LA1" + bytes([1]) + struct.pack("<III", rate, N, mark)
    out += bytes(ring[(start + i) % N] for i in range(N))
    return out


# ── разбор выгрузки ──────────────────────────────────────────────────────
def parse(dump):
    if dump[:3] != b"LA1" or dump[3] != 1:
        raise ValueError("не формат LA1")
    rate, n, mark = struct.unpack("<III", dump[4:16])
    return rate, mark, dump[16:16 + n]


def decode_uart(samples, ch, rate, baud):
    """Старт по фронту вниз, выборка в середине каждого бита. Возвращает байты."""
    spb = rate / baud
    out, k, prev = [], 0, 1
    while k < len(samples):
        cur = (samples[k] >> ch) & 1
        if prev == 1 and cur == 0:                    # старт-бит
            byte = 0
            for bit in range(8):
                p = k + (1.5 + bit) * spb             # середина бита данных
                if p >= len(samples):
                    return out
                byte |= ((samples[int(p)] >> ch) & 1) << bit
            stop = k + 9.5 * spb
            if stop < len(samples) and (samples[int(stop)] >> ch) & 1:
                out.append(byte)
            k = int(k + 9.5 * spb)                    # в середину стоп-бита:
            prev = 1                                  # следующий старт ищем по фронту
        else:
            prev = cur
        k += 1
    return out


def ascii_diagram(samples, mark, channels=(0, 1), span=32):
    """Кусок временной диаграммы вокруг запуска: ▔ — единица, ▁ — ноль."""
    lines = []
    for ch in channels:
        row = "".join("▔" if (samples[k] >> ch) & 1 else "▁"
                      for k in range(mark - 8, mark - 8 + span))
        lines.append("к%d  %s" % (ch, row))
    lines.append("            ^ запуск (выборка %d)" % mark)
    return "\n".join(lines)


def to_vcd(dump, path):
    """Конвертер LA1 → VCD: PulseView открывает такой файл сам (раздел 36а)."""
    rate, mark, samples = parse(dump)
    ids = "!\"#$%&'("
    with open(path, "w", encoding="ascii") as f:
        f.write("$timescale 1 ns $end\n$scope module la $end\n")
        for ch in range(8):
            f.write("$var wire 1 %s k%d $end\n" % (ids[ch], ch))
        f.write("$upscope $end\n$enddefinitions $end\n")
        last = None
        n_edges = 0
        for k, b in enumerate(samples):
            if last is None or b != last:
                f.write("#%d\n" % round(k * 1e9 / rate))
                for ch in range(8):
                    v = (b >> ch) & 1
                    if last is None or v != ((last >> ch) & 1):
                        f.write("%d%s\n" % (v, ids[ch]))
                        n_edges += 1
                last = b
    return n_edges


# ── самотест ─────────────────────────────────────────────────────────────
def selftest():
    ok = True
    spb = RATE / BAUD
    print("rate=%d baud=%d spb=%.2f" % (RATE, BAUD, spb))
    print("N=%d PRE=%d post=%d" % (N, PRE, N - PRE))

    good = 0
    marks = set()
    for phase in range(20):
        s0 = PRE + 500 + phase / 20.0          # посылка на полбита позже с каждым шагом
        dump = capture(s0, spb)
        rate, mark, samples = parse(dump)
        marks.add(mark)
        got = bytes(decode_uart(samples, 0, rate, BAUD))
        idle_before = all((samples[k] & 1) == 1 for k in range(mark - 400, mark))
        if got == MSG and idle_before:
            good += 1
        else:
            ok = False
            print("phase %02d: FAIL got=%r" % (phase, got))
    print("phases %d/20 байт-в-байт, посылка %r (%d байт)" % (good, MSG, len(MSG)))
    print("mark=%s (предзапись ровно PRE)" % sorted(marks))

    # передатчик со скоростью +2 % — разбор всё ещё честной серединой бита
    good2 = 0
    for phase in range(20):
        s0 = PRE + 500 + phase / 20.0
        dump = capture(s0, RATE / (BAUD * 1.02))
        rate, mark, samples = parse(dump)
        if bytes(decode_uart(samples, 0, rate, BAUD)) == MSG:
            good2 += 1
    print("tx+2%%: %d/20" % good2)
    ok &= good2 == 20

    dump = capture(PRE + 500.0, spb)
    rate, mark, samples = parse(dump)
    print(ascii_diagram(samples, mark))
    edges = to_vcd(dump, "la_test.vcd")
    print("vcd: %d переключений, файл la_test.vcd" % edges)

    hdr_bits = (16 + N) * 10
    print("выгрузка: (16+%d) байт = %d бит" % (N, hdr_bits))
    print("  115200 бод: %.2f с" % (hdr_bits / 115200))
    print("  921600 бод (фактических 923077): %.2f с" % (hdr_bits / (72e6 / 78)))
    print("память: %d выборок = %.1f мс при 1 Мвыб/с; все 20 КБ дали бы %.1f мс"
          % (N, N / 1e3, 20480 / 1e3))
    print("SELFTEST OK" if ok else "SELFTEST FAIL")
    return 0 if ok else 1


if __name__ == "__main__":
    if len(sys.argv) >= 2 and sys.argv[1] == "vcd":
        with open(sys.argv[2], "rb") as f:
            n = to_vcd(f.read(), sys.argv[3])
        print("vcd: %d переключений → %s" % (n, sys.argv[3]))
        sys.exit(0)
    sys.exit(selftest())

Дальше: раздел 198е «Мини-проект: CAN-сниффер на STM32 — протокол SLCAN поверх USB CDC» — анализатор ловил биты, сниффер ловит кадры шины CAN.

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