Embedded HandbookApps
All sections › XXV. Mini-projects
STM32F103 · level intermediate · 23 min read
Open in the interactive handbook

198д. Mini-project: a logic analyzer made from a BluePill

The last mini-project is a measuring instrument. Section 36а lists a logic analyzer among the instruments on your desk; until you have one, the BluePill itself can play the role of an eight-channel pod: a timer sets the sample rate, DMA moves the port into a ring buffer, UART sends the record to the computer, and Python parses it and turns it into a file for a viewer program. Along the way there is honest arithmetic: what sample-rate ceiling follows from the bus architecture, how many milliseconds fit into 20 KB of RAM and how many seconds the upload takes.

This is an instrument "until you have the instrument": the store-bought pod from section 36а.6 records 8 channels at 24 MS/s for hours on end, while our home-made one captures 16384 samples and no faster than 4.5 MS/s. In return, it shows how a logic analyzer works inside, down to the pre-trigger record and the trigger mark.

The files are in _verify/la198d/: la_f103.c (firmware), start_f103.c, la_host.py (capture model, parser and VCD converter). The firmware is built for Cortex-M3, and the model was run on the computer with a self-test. The check is _verify/la198d_check.py.

Terms used in the documentation

Term Meaning
memory depth, capture depth how many samples fit into the instrument's memory
armed recording is already running, the instrument waits for the trigger condition
post-trigger the part of the buffer after the trigger moment
metastability a latch "stuck" between 0 and 1
synchronizer two latches in a row on an asynchronous input
value change dump (VCD) a text format for signal records: the moments of transitions
run-length encoding (RLE) storing the durations of levels
bus matrix the arbiter between the CPU and DMA

198д.1 What the instrument does and what it cannot do

Function How
eight channels PB0…PB7: the low byte of GPIOB->IDR, one sample = one byte
sample rate a grid of 100 kS/s … 4.5 MS/s from TIM2 (crystal ×9 = 72 MHz)
capture depth a ring of 16384 samples, 16.4 ms at 1 MS/s
trigger a rising or falling edge on any of the channels, via EXTI
pre-trigger a quarter of the ring: the history before the edge, for free
upload USART1: 115200 or 921600 baud, our own "LA1" format
on the computer timing diagram, UART decoding, VCD converter for PulseView

Analyzer wiring: eight channels PB0–PB7 to the board under test, USB-UART on PA9 and PA10, channels 0, 1 and 5 are 3.3 V only

The channels are the whole low byte of port B. Two pins have quirks: PB2 is BOOT1 (on the BluePill it goes to a jumper through a resistor; this weak divider does not disturb the signal), and PB3 and PB4 belong to JTAG after reset. The firmware disables JTAG in the very first line of setup (SWJ_CFG = 010 in AFIO_MAPR: "JTAG-DP disabled, SW-DP enabled"), so you can still flash over SWD as before, and PB3 and PB4 become ordinary inputs.

⚠️ Three channels out of eight do not tolerate 5 V. In the pin table of the F103 datasheet, PB0, PB1 and PB5 have no FT mark: their limit is VDD + 0.3 V. The FT inputs (the other five channels) withstand up to 5.5 V. Connect a five-volt bus to channels 2, 3, 4, 6, 7, or through a divider (section 62). This also applies to I²C with pull-ups to 5 V.

What you can examine is decided by the rule from section 36а.6: no fewer than four to five samples per bit. At 1 MS/s that is UART 115200 (8.7 samples per bit, with margin), at 4.5 MS/s it is SPI and I²C up to hundreds of kilohertz. For buses running at megahertz and for hunting nanosecond glitches you need a real instrument or an oscilloscope (section 36а).


198д.2 The sample-rate ceiling: counting by the buses

The path of one sample: TIM2 reaches the end of its count, an update event, a DMA request, DMA reads GPIOB->IDR, writes a byte to RAM. Before promising megahertz, let us count what this path costs.

In total a sample takes at least five clocks at 72 MHz, a ceiling of about 14 MS/s, and only if the DMA is alone on the bus. It is not alone: the bus matrix shares access round-robin and guarantees the CPU at least half of the bandwidth, which means that in the worst case the DMA gets only half too. That is why the top step of the grid is 4.5 MS/s: 16 clocks per sample, a threefold margin over the calculated five. We do not promise anything faster: the DMA will not report a missed sample in any way (198д.9), and "an analyzer that sometimes lies" is worse than none. During the capture the CPU stays out of the way: it only reads the DMA counter.

Memory is simpler to count. One sample is a byte, the ring is 16 KB out of 20 KB of RAM (the rest is the stack and variables; giving it all 20 KB = 20.5 ms will not work):

Sample rate Ring of 16384 samples Clocks per sample
4.5 MS/s 3.6 ms 16
1 MS/s 16.4 ms 72
500 kS/s 32.8 ms 144
100 kS/s 164 ms 720

198д.3 Capture: the timer sets the step, DMA carries the bytes

The sample rate is set by TIM2: ARR = 72 MHz / f − 1, and every update event issues a DMA request (the UDE bit in DIER; timers themselves are section 13, the map of "which request is on which channel" is section 9: TIM2_UP is served by channel 2 of DMA1). The channel is configured like this:

    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;

Two non-obvious widths. GPIO registers on the F1 must be read only as 32-bit words, so the source is declared 32-bit (PSIZE_1), while the destination is byte-wide: according to the width table in the reference manual, DMA in this case writes the low byte of the word it read to memory, which is exactly PB0…PB7. One request, one byte of the ring, and the CPU takes no part.

Then the capture itself starts:

    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;                      /* выборки пошли */

🔧 Recipe: check the sample grid with a square wave. Set up a PWM on PA8 at 100 kHz with a 50 % duty cycle (section 13.5) and feed it with a jumper to channel 7. In the record at 1 MS/s the period must be exactly 10 samples; if you get 11–12 "every other time", samples are being lost and the sample rate must be lowered. It is worth greeting every new rate with such a self-check.


198д.4 Trigger: wait for the edge without losing the beginning

The naive plan "we saw the edge, start recording" loses the most interesting part: what happened before the edge, and you often want to see, for example, the state of the lines before a transfer starts. The solution of all real analyzers is to record always: the DMA runs in circular mode (section 9) and spins the ring round and round before any trigger at all. The edge starts nothing; it only sets a mark.

Ring buffer of 16384 samples: the history before the edge, the trigger mark at the quarter boundary and 12288 samples after it

The edge is caught by EXTI (section 7): the line by channel number, port B, rising or falling edge. The handler is tiny:

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

The order is this: after arming, the firmware first lets the ring accumulate the pre-trigger record (a quarter of the ring, 4096 samples) and only then enables EXTI. After the edge the remaining 12288 samples are written, and the timer stops. That is why in the uploaded record the trigger mark always sits exactly at sample 4096, with the real input history before it.

The mark is not perfect: interrupt entry takes 12 clocks plus tails (section 7), and CNDTR is read with a delay. At 4.5 MS/s (16 clocks per sample) the mark can drift by one or two samples, while the data itself is not distorted by a single bit: it is written by the DMA, which does not care about interrupts.


198д.5 Upload: our own format and how long to wait

After the stop the ring is unrolled in time, from the oldest sample to the newest, and goes to USART1 with a 16-byte header:

Bytes What
0…3 LA1 and the format version (1)
4…7 sample rate, Hz
8…11 number of samples (16384)
12…15 index of the sample with the trigger mark
    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)]);         /* кольцо — по порядку времени */

The upload is (16 + 16384) bytes at 10 bits per byte, 164000 bits:

Port speed BRR divider Actual Upload time
115200 baud 625 exactly 115200 1.42 s
921600 baud 78 923077 baud (+0.16 %) 0.18 s

Exactly 921600 cannot be divided out of 72 MHz: the nearest integer divider 78 gives 923077 baud; an error of 0.16 % is unnoticeable for UART (the tolerance is about 2 %, section 16). Commands to the instrument are single bytes: 0…7 is the trigger channel, a/A arms on a falling/rising edge, g captures unconditionally, s selects the next sample rate, u toggles the port speed.

The build with arm-none-eabi-gcc -Os for Cortex-M3 has no warnings: 1272 bytes of Flash and 16396 bytes of RAM, of which 16384 are the ring. The firmware is the junior partner: almost all the work rests on the DMA and the computer.


198д.6 Analysis on the computer: diagram, UART and VCD

The computer half is la_host.py. It reads the upload and prints a piece of the timing diagram around the trigger mark:

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

and decodes UART: start on the falling edge, each bit is read in its middle:

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

After the stop bit the decoder jumps to its middle and waits for the real edge of the next start: resynchronization on every byte, as in a live UART receiver (section 16). Without it, a transmitter running 2 % fast would drift by 1.7 samples per frame, and by the fifth byte the decoding would fall apart.

The same script is a converter to VCD: a text format of "moments of transitions and new values" that PulseView (section 36а.6) opens as an import, and so the record of the home-made instrument goes into the same protocol decoders as the record of a store-bought one:

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

Self-test. The model in la_host.py repeats the firmware algorithm one to one: the ring, the pre-trigger record, the edge, the mark, the "LA1" upload. A generator feeds a UART 115200 message to channel 0 at 1 MS/s in 20 different phases relative to the sample grid, and the record is run through the decoder:

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

All 20 phases are decoded byte for byte, the trigger mark in each record is at sample 4096, and before it there is line idle (the history is intact). With a transmitter running 2 % fast it is also 20 out of 20: the margin of 8.7 samples per bit works.


198д.7 Limitations, honestly


198д.8 What to improve


198д.9 Pitfalls

Channels 3 and 4 are silent or read garbage. PB3 and PB4 belong to JTAG after reset. Disable JTAG (SWJ_CFG = 010); SWD for flashing remains.

Random "activity" on unconnected channels. An input without a pull resistor floats and picks up interference (section 8). This is not a signal; pulling up all eight inputs is not a good idea, since a pull-up loads the line under test.

A 100 kHz square wave "floats" in the record: a period of 10 samples, then 12. The sample rate is higher than the DMA can serve: a timer request arrived while the previous one had not finished, the sample silently vanished, and there is no error flag. Stick to the justified grid and check it with a square wave (recipe in 198д.3).

You armed it, but an edge right after the command was missed. The firmware first accumulates the pre-trigger record (4096 samples: at 100 kS/s that is 41 ms) and only then enables EXTI. This is the price of a guaranteed history before the trigger.

UART in the record decodes only every other time. Too few samples per bit: the rule from section 36а.6 is no fewer than four to five, and if the transmitter is clocked from an RC oscillator (±2 %), with margin. Raise the sample rate.

After a session on a 5 V bus the board started to get hot. PB0, PB1 or PB5 have no FT: 5 V on them is beyond the absolute maximum rating of the datasheet. Five-volt signals go only to FT channels or through a divider (section 62).


198д.10 Summary

Full source code

la_f103.c: the analyzer firmware for the BluePill, capturing eight channels and uploading.

/* Мини-проект «Логический анализатор»: 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: the computer side, with commands, reception and capture analysis.

# -*- 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())

Next: section 198е "Mini-project: a CAN sniffer on STM32 — the SLCAN protocol over USB CDC" — the analyzer caught bits, the sniffer catches CAN bus frames.

Open in the interactive handbook