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

198б. Mini-project: a USB data logger and a PC program

The device in this project is a data logger: an F407 measures two ADC channels a thousand times a second and continuously streams the data to a computer, where a program receives it, checks it and writes a file with a plot. The computer sees the device as an ordinary serial port — the CDC class from section 20. What is new here is not USB (its mechanics have already been covered) but everything between the measurement and the file: a binary frame with an integrity check, a queue for the receiver's pauses, and a PC program that honestly counts losses.

QEMU is no help here: it does not model USB OTG — neither enumeration nor endpoints. So the project is cut along a seam that can be checked without a board (the technique of sections 184 and 196): the frame and the queue are shared code, logger.c, without a single register, built both for Cortex-M4 and with ordinary gcc; a generator drives the stream of frames through a pipe into the receiver. The files are in _verify/usb198b/: logger.h, logger.c, sim_queue.c, gen_frames.py, logger_rx.py; the runs are recorded in _verify/usb198b/*_host.txt, checked by _verify/usb198b_check.py.

Terms used in the documentation

Term Meaning
data logger a device that records a stream of measurements
payload the frame's data without the header and CRC
magic number distinctive bytes that mark the start of a frame
sequence number the frame's serial number: gaps in it reveal losses
throughput how many bytes per second get through
goodput the useful part of it — without headers, CRC and retries
drop-oldest / drop-newest evict the old / discard the new on overflow

198б.1 What the device does and who is responsible for what

Function How
measure two ADC channels (section 14), 1 kHz, time from SysTick, 1 ms
pack a 20-byte frame: magic, sequence number, time, channels, eviction counter, CRC-32
survive PC pauses a queue of 32 frames; when full, new data evicts the old
send the CDC class (recipe 20.7): packets of up to 64 bytes, the host collects them itself
receive on the PC Python: search for the magic → CRC → sequence numbers → CSV and plot
count losses gaps in the sequence numbers are reconciled with the eviction counter from the frames

Measurement path: ADC, frames, queue and CDC in the firmware, then the COM port, the receiver and the file on the computer

Two decisions define the whole architecture. The firmware stamps the time: frames reach the program in batches and with a delay — the host polls the device on its own schedule (section 20), and the receiver reads the port when the operating system lets it. So every frame carries t_ms from SysTick, and plots are built only from it, not from the PC clock. And the firmware buffers: there is no return channel, nobody to ask to "repeat" — a receiver pause can be survived only by the queue in the device's RAM (198б.3).

There are more than enough endpoints: CDC needs three besides endpoint zero — bulk IN and OUT for data and an interrupt IN for notifications. OTG FS in the F407 provides four IN and four OUT, including endpoint zero (the header stm32f407xx.h from the pack: USB_OTG_FS_MAX_IN_ENDPOINTS 4). The endpoint buffers share 1280 bytes of FIFO (USB_OTG_FS_TOTAL_FIFO_SIZE), that is 320 words, which the firmware divides between the receive FIFO and the transmit FIFOs; any reasonable split is enough for 20-byte frames.


198б.2 The frame: twenty bytes, byte by byte

A text format ("t=1000 ch0=-123…") would be two to three times fatter and would require string parsing; a fixed-size binary frame is simpler for both the device and the program. Fields are little-endian: that is how Cortex-M writes them to memory, and the PC almost everywhere reads them the same way (byte order — section 3). The frame is assembled in logger.c literally byte by byte, without structures with alignment: the format is visible to the eye and does not depend on the compiler.

The logger frame: twenty bytes with the magic, sequence number, time, two channels, eviction counter and CRC-32

Bytes Field Purpose
0–1 magic 0xA5 0x5A find the start of a frame in a stream without boundaries
2 type 0x01 reserve: calibration, log, replies — other types
3 flags bit 0: the queue evicted frames before this one
4–5 sequence number gaps in the numbers = lost frames
6–9 t_ms measurement time by the device's clock
10–11, 12–13 channel 0, channel 1 raw signed ADC codes, no conversion
14–15 total evicted the lower 16 bits of the eviction counter
16–19 CRC-32 over the first 16 bytes — exactly 4 words

The eviction counter is cumulative, not "since the previous frame": an evicted frame takes its own field with it, and per-frame counters would be lost along with it. The difference of the cumulative counter in the first and last received frames always tells how many frames the queue evicted — no matter how many frames went missing in between.

The CRC closes the first 16 bytes — exactly four words, the way the STM32 hardware unit consumes them (section 43): words from memory, without byte reversal. On the computer the same thing takes about ten lines:

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;
}

⚠️ Check the implementation against the numbers of section 43 before running frames. The model prints three check values: bytewise over 123456789 it gives 0x0376E6E7 (CRC-32/MPEG-2), wordwise over 12345678 — 0xFEFC54F9, and the same number comes from a bytewise run over the string 43218765 — the byte order within a word is reversed, exactly as described there. If all three match, the firmware can write CRC->DR without __REV, and the PC can compute with this function.

The counters are deliberately narrow. The frame number is 16 bits: it wraps after 65536 frames, at 1 kHz — every 65.5 s; that is enough for the receiver, because it counts gaps as a difference modulo 2¹⁶. The time is 32 bits of milliseconds: it wraps after 49.7 days of continuous recording.


198б.3 The queue: evict the old or stop

The queue is a ring of 32 frames (640 bytes of buffers; the whole structure with counters is 652 bytes, as printed by the model). At 1 kHz that is a cushion of 32 ms of receiver silence — the usual pauses of the scheduler and of disk writes it absorbs silently. Things get interesting when the pause is longer. There are two options, and both lose data — the only question is which:

Both policies sit side by side in the core (lg_push and lg_push_stop) so that the model can pit them against each other. Eviction is three lines at the start of lg_push; at the same time the flag is set and the cumulative counter grows:

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++;
}

With "stop" there is a subtlety: the frame is not created, but its number is used up — otherwise the receiver would not see a gap, and the loss would go unnoticed.

The model sim_queue.c runs 10000 ms: the producer puts a frame every millisecond, the consumer takes up to four frames per millisecond, but falls silent on [3000; 3250) ms — for a quarter of a second the host is busy with something of its own. Both policies, the very same run:

Policy Delivered Lost Lost numbers
new evicts old 9781 219 2999–3217 — start of the pause
"stop" 9781 219 3031–3249 — end of the pause

The losses are equal — more frames were born during the pause than fit, and the extra ones perished under either policy. The difference is in which survived: after "stop" the record breaks off at number 3030 — the event that made the host hang is missing from the log; with eviction the gap lies at the start of the pause, and the last 250 ms are in place. A data logger almost always needs the second. And note: 219 frames were lost, not 218 — the queue met the pause not empty (frame 2999 was still waiting to be sent), so there were 31 free slots. The books balance: the sum of the gaps in the numbers of the received frames equals the cumulative counter in the last one — 219 in both, account=ok.

The core is also built for the firmware: arm-none-eabi-gcc -mcpu=cortex-m4 -Os gives 504 bytes of code for the whole of logger.c — frame assembly, CRC and both policies; the core has no data of its own, the queue lives with its owner. On the board lg_push is called from the ADC interrupt, and the main loop uses lg_pop to feed frames to CDC_Transmit_FS (recipe 20.7): while the class answers "busy", the next frame is simply not taken from the queue.


198б.4 🔧 The receiver on the PC: magic, CRC, loss accounting

From the computer's side the device is a serial port, and pyserial can read it from Python: serial.Serial("COM5") and port.read(4096) in a loop. Everything interesting is in parsing what has been read.

⚠️ The port returns chunks of arbitrary size. USB carries packets of 64 bytes, the operating system glues them into a buffer, and read returns whatever has accumulated: one and a half frames, a third of a frame, ten frames. Frame boundaries exist nowhere except in the format itself — so the parser must be stream-based: receive a chunk, append it to the tail, take out whole frames, and keep the remainder until the next chunk.

Parsing is the feed method of the Parser class in logger_rx.py; it does not care where the chunk came from — a port, a file or a pipe:

    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:]

The order is mandatory: the magic is only a candidate for a frame, the CRC confirms it. The two bytes 0xA5 0x5A also occur inside the payload and in garbage; the CRC of a false candidate will not match, the parser shifts by one byte and continues the search — that is recovery after a failure. A tail shorter than a frame is left alone: the next read will deliver the rest.

Received frames are counted in three columns: frames — how many arrived, lost_by_seq — the sum of the gaps in the numbers, drops_accounted — the difference of the cumulative eviction counter between the last and the first frame. The difference lost_by_seq − drops_accounted is the losses not caused by the queue: corruption on the line, parsing errors. The CSV is written in columns t_ms,ch0,ch1 — the plot can be built with anything, from a spreadsheet to matplotlib.


198б.5 Throughput: the ceiling and the margins

How much can CDC carry at full speed at all? It is counted honestly, from the bus frame (not to be confused with the logger's frame): at 12 Mbit/s 1500 "raw" bytes pass in a one-millisecond bus frame. A bulk transaction with a 64-byte data field costs 77 bytes: 13 bytes of overhead — one SYNC byte and one PID byte for each of the three packets (token, data, handshake), 2 bytes of address with CRC5, 2 bytes of CRC16 for the data field and about three bytes of inter-packet gaps. A bus frame holds 1500 / 77 = 19 transactions — the ceiling for bulk transfers:

19 × 64 = 1216 bytes per millisecond ≈ 1.2 MB/s.

This is the ceiling for the whole bus: it is shared between devices on the same controller and cut by other traffic; a real stack will not even come close to it. But the scale is clear at once:

Stream Bytes/s Enough?
logger: 1 kHz × 20 bytes 20000 margin of 60.8 times
CDC FS ceiling 1216000 —
UART 115200 baud, 8N1 11520 no: less than the stream
UART 230400 baud, 8N1 23040 yes, margin of 15.2 %

Two conclusions follow. First: at these rates USB does not even break a sweat — it gets tight at tens of kilohertz. Second: the backup UART channel (section 16) at the usual 115200 baud cannot carry our stream — a byte on the wire costs 10 bits, and 11520 B/s is less than 20000; you need either a port twice as fast or decimation. Meanwhile the payload in a frame is 4 bytes out of 20 — 80 % overhead; what to do about that is in 198б.7.


198б.6 A run without a board: the generator drives the stream into the receiver

gen_frames.py assembles the same frames as logger.c — the model printed the reference frame A5 5A 01 00 00 00 E8 03 00 00 85 FF C8 01 00 00 90 E0 61 E8, and on the same data the generator must reproduce it byte for byte (the check compares them) — and feeds them through a pipe to logger_rx.py, as a port would.

Scenario one — a clean channel, 10000 frames in a row:

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

No losses, no CRC errors, no discarded bytes; the CSV is 10001 lines including the header.

Scenario two — a dirty channel: 157 bytes of garbage in two insertions (inside one of them a deliberately planted false magic A5 5A), five frames "evicted by the queue" (numbers skipped, the cumulative counter moved by 5), and in three frames a payload byte corrupted after the CRC was computed:

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

Everything adds up to the byte. Delivered 9992 = 10000 − 5 evicted − 3 corrupted. There are four CRC errors: three corruptions plus the false magic in the garbage. There are eight gaps in the numbers: five are explained by the eviction counter from the frames themselves, three remain "on the line" — exactly the corrupted ones. 217 bytes were discarded: 157 of garbage and 60 bytes of the three frames that failed the CRC. The receiver not only survived the dirt — it reported whose loss was whose.


198б.7 What to improve


198б.8 Pitfalls

The baud rate set when opening the port has no effect. That is by design: in CDC the "baud rate" is a request from the old world; it never reaches any wires, the bytes travel in USB packets. The real arithmetic is in 198б.5.

The CRC "does not match the computer's". The hardware unit consumes words least-significant byte first (section 43). Either the firmware feeds CRC->DR through __REV, or — as here — the PC computes by words; the check: 12345678 by words gives 0xFEFC54F9.

Every other frame is "corrupted". The receiver believes that read returns a whole frame. It does not: there are no frame boundaries in the port, a frame is cut at any byte. Stream parsing with a tail is the only honest way.

The record breaks off a moment before the event. The "stop" policy: while the host was silent, the queue kept the old frames and threw away the fresh ones — the event itself. A data logger needs eviction of the old (the run in 198б.3).

Losses grow although there are no pauses. If lost_by_seq is ahead of drops_accounted, the queue is not to blame — frames die on the way: the cable, the hub's power, a race in writing the frame in the firmware. Queue depth will not help.

The port is "busy" or the device "is not recognized". The port is held by a forgotten terminal — CDC is exclusive; and "not recognized" on a home-made board is almost always the clocking and the surrounding circuitry from section 20, not this project.


198б.9 Summary

Full source code

logger.c — frame assembly and the queue.

/* 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 — the receiver on the computer: magic, CRC, loss accounting, 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()

Next: section 198в "Mini-project: a Modbus RTU I/O module" — the logger talked in one direction; now the device will start answering requests.

Open in the interactive handbook