BERGSONNE

USB Raw Streaming

Stream raw binary packets from a Core to a host over USB CDC, with the host driving start/stop over single-byte commands. A Python reader decodes the packets and prints live throughput.

Overview

The firmware targets a Core.ST.H5.1 at the "low" clock setting — USB CDC is available on the L4 and H5 Cores. Three pieces make up the example:

  • Firmware (main.c) — a packed-struct packet, an ISR receive callback for start/stop, and core_usb_write() for output.
  • Host (stream_read.py) — auto-detects the serial port, sends start, parses packets, and prints throughput. Ctrl+C sends stop before closing.

Expected throughput is around 800 KB/s — the practical Full-Speed USB bulk ceiling. The demo’s tiny 6-byte packets run well below that on purpose; see Throughput for how to approach the limit.

Firmware

The complete firmware, then a part-by-part walkthrough:

#include "core.h"
#include "core_usb.h"

/* Packet layout — keep small for this demo.
 * For maximum throughput, pack more samples per packet
 * (up to 512 bytes, must be a multiple of 64 for best efficiency). */
typedef struct __attribute__((packed)) {
    uint32_t seq;
    uint16_t sample;
} stream_packet_t;

static stream_packet_t packet;
static volatile uint8_t streaming = 0;

/* RX callback — runs in USB ISR, checks for start/stop commands */
static void on_rx(const uint8_t *data, uint16_t len, void *ctx)
{
    (void)ctx;
    for (uint16_t i = 0; i < len; i++) {
        if (data[i] == 's') streaming = 1;
        if (data[i] == 'x') streaming = 0;
    }
}

int main(void)
{
    core_init();
    core_usb_init();
    core_pad_output(9);

    core_usb_on_receive(on_rx, NULL);

    uint32_t seq = 0;
    uint16_t sample = 0;

    while (1) {
        if (!core_usb_connected()) {
            streaming = 0;
            core_pad_toggle(9);
            core_delay_ms(500);
            continue;
        }

        if (!streaming) {
            core_pad_write(9, 1);   /* LED on = idle, waiting for 's' */
            core_delay_ms(10);
            continue;
        }

        /* Build packet */
        packet.seq = seq++;
        packet.sample = sample++;   /* Simulated ramp — replace with ADC reads */

        core_usb_write((const uint8_t *)&packet, sizeof(packet));

        if ((seq & 0xFFF) == 0)
            core_pad_toggle(9);
    }
}

Initialization

core_init() sets up clocks and SysTick; core_usb_init() enables USB CDC (the device appears as /dev/tty.usbmodem* on macOS or /dev/ttyACM* on Linux); core_pad_output(9) configures pad 9 as a status LED. core_usb_on_receive(on_rx, NULL) registers the receive callback — the second argument is a user-context pointer.

Main loop

  • Disconnected — no host terminal open. Reset the flag, slow-blink the LED (toggle every 500 ms), loop.
  • Idle — connected but no s yet. Hold the LED on, 10 ms delay.
  • Streaming — build a packet (sequence + ramp sample), send it with core_usb_write(), repeat. The LED toggles every 4096 packets for visual feedback.
Blocking write = flow control
core_usb_write() is blocking — it returns the number of bytes sent (or -1 if USB isn’t configured) and waits on the host before sending more. That gives natural flow control: the firmware never overruns the USB buffer, and a mid-stream disconnect is caught on the next loop by core_usb_connected().

Packet format

The packet is a packed struct, so firmware and host agree on the exact 6-byte layout with no padding surprises — seq in bytes 0–3, sample in bytes 4–5, both little-endian.

typedef struct __attribute__((packed)) {
    uint32_t seq;
    uint16_t sample;
} stream_packet_t;

The host unpacks it with a matching format string:

seq, sample = struct.unpack_from("<IH", buf)
#                                 │ └─ uint16_t (H) = sample
#                                 └─── uint32_t (I) = seq
Why packed
__attribute__((packed)) stops the compiler from inserting padding between fields. Without it, sample could align to a 4-byte boundary and the struct would be 8 bytes — desyncing the host parser, which expects 6.

Start / stop

The host controls streaming with single ASCII bytes — s to start, x to stop — handled in a callback that runs in the USB interrupt.

/* RX callback — runs in USB ISR, checks for start/stop commands */
static void on_rx(const uint8_t *data, uint16_t len, void *ctx)
{
    (void)ctx;
    for (uint16_t i = 0; i < len; i++) {
        if (data[i] == 's') streaming = 1;
        if (data[i] == 'x') streaming = 0;
    }
}

/* In main: */
core_usb_on_receive(on_rx, NULL);
  • volatile streaming is written in the ISR and read in the main loop; without it the compiler could cache the read.
  • Callback, not polling — once a callback is registered, bytes route straight to it and are not buffered for core_usb_read(). The callback runs in the ISR, so keep it short — just set a flag.
  • Scan every byte — a USB packet can carry several bytes, so the loop checks each one.

Python reader

stream_read.py opens the port (globbing /dev/tty.usbmodem* then /dev/ttyACM*, or take an explicit port as an argument), flushes stale data, sends s, then loops reading raw bytes and slicing complete 6-byte packets out of a buffer:

data = ser.read(4096)
if not data:
    continue

total_bytes += len(data)
buf += data

while len(buf) >= PACKET_SIZE:
    seq, sample = struct.unpack_from("<IH", buf)
    total_packets += 1
    buf = buf[PACKET_SIZE:]

Buffering matters: a single read() may return half a packet or several. Stats print once per second, auto-formatting as KB/s or MB/s. On Ctrl+C the script sends x (and waits 100 ms) before closing the port, so the firmware stops cleanly.

Baud rate is cosmetic
USB CDC ignores the line rate — bytes move at the USB bus rate regardless of the 115200 pyserial opens with. It does not cap throughput.

Throughput

Full-Speed USB 2.0 caps bulk transfers at roughly 1.2 MB/s; host overhead and OS scheduling pull the practical ceiling to about 800 KB/s. The real rate is dominated by how full you keep each 64-byte USB packet — the demo’s 6-byte packet wastes most of every transaction.

  • 6 bytes (this demo) — ~270 KB/s; 58 bytes wasted per USB packet.
  • 64 bytes — ~600 KB/s; one full USB packet per write.
  • 512 bytes — ~800 KB/s; eight back-to-back USB packets.
Batch your samples
To approach the ceiling, pack many samples per write — up to 512 bytes, a multiple of 64 for best efficiency (as the main.c comment notes). Replace packet.sample = sample++ with real ADC reads and fill an array before sending.

config.json

The descriptor targets a Core.ST.H5.1 at the "low" clock, with no pads mapped — USB is brought up by core_usb_init(), and the pad 9 status LED is configured at runtime with core_pad_output(9).

{
  "core": "Core.ST.H5.1",
  "clock": "low",
  "pads": {}
}

Build & run

cd examples/h1-usb-stream
make            # build firmware
make flash      # flash via SWD  (or: make flash-dfu)
pip3 install pyserial                  # one-time
python3 stream_read.py                 # auto-detect port
python3 stream_read.py /dev/ttyACM0    # explicit port

Press Ctrl+C in the reader to stop streaming and close the port cleanly.