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, andcore_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
syet. 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.
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__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—streamingis 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.
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.
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 portPress Ctrl+C in the reader to stop streaming and close the port cleanly.

