A sub-€1 USB logic analyzer & protocol driver for 1- and 2-wire buses, built on the CH32X035 PIOC.
Taps a logic-side bus signal and decodes it live in the browser. Tested on CAN, DMX & MDIO. Throughput supports ~1 Mbps (non-clocked) / ~3 MHz (clocked). A separate MDIO master build target can actively drive Clause-22 and MMD register reads/writes over the same USB-CDC plumbing.
![]() CAN live ID view |
![]() CAN trace view |
![]() MDIO register/MMD view |
![]() DMX512 universe view |
Disclaimer: this is an experimental / research project. The goal is to be genuinely useable, but treat it as such.
The whole thing rides on one cheap part: the WCH CH32X035, a 48 MHz RISC-V MCU:
- Dirt cheap (≤ €0.25)
- Needs almost no external components (built-in oscillator)
- Tiny QFN 3x3 package
- Native USB 2.0 FS (12 Mbps)
- And unusually at this price, a PIOC: a minimal coprocessor that can follow a bus edge-by-edge while the CPU does the housekeeping and data transmission.
It started as a need: an ultra low-cost embedded USB↔MDIO bridge to monitor/configure the Ethernet PHYs in an SPE media converter (two PHYs back-to-back). The PIOC turned out to be the perfect peripheral to passively follow an external clock, drive one, and capture/send data frames with tight timing, so the question became: can it be turned into a general-purpose logic tool?
The PIOC is comparable to the RPi Pico's PIO, but:
- it's a full little RISC8 core (more capable per instruction)…
- …and also more constrained: 48 MHz, a single core (no parallel state machines), only 2 IO pins, and ~30 bytes of register file shared with the CPU.
- it's programmed in its own assembly, with sparse docs and a Windows-only vendor toolchain (this repo ships a small Python assembler so you don't strictly need it).
So this is mostly a real-time engineering exercise: how much useful capture can we wring out of that?
Reference dev board used for testing: WeAct Studio CH32X035 Core Board
(< €2). The project builds with PlatformIO using the ch32v platform / noneos-sdk framework.
Related project: ch32-tapioca-probe, using the same approach to build an embedded debug/flashing probe (JTAG, ARM & CH32).
bus line
│
▼
PIOC sample the line at a high, repeatable rate;
(coproc) pack the result into a shared FIFO
│
▼
CPU drain the FIFO into a RAM ring → stream out
│
▼
USB-CDC one binary stream (up to ~500 KB/s)
│
▼
host browser decodes live; Python decodes saved
captures, recovering the clock from the data
Two capture modes, picked at runtime (the host sends !mode rle|clocked):
| mode | for | what the PIOC streams |
|---|---|---|
clocked |
buses with a clock line (MDIO, SPI…) | the data line sampled on each clock edge → logic levels |
rle |
unclocked / asynchronous NRZ buses (CAN, DMX, LIN, UART…) | RLE = run-length encoding: how many ticks the line stayed at a level (host recovers the clock from the data) |
All protocol decoding happens host-side, so the same capture is protocol-agnostic. There's no kernel driver, decoding is a userspace lib in JS (browser) and Python, plus the shared C++ codec headers.
Numbers:
- Host throughput caps at ~500 KB/s (CPU-bound on the drain↔USB path).
- Holds classic CAN @ 1 Mbps (real bus): ~8000 frames/s of typical traffic, and 6000 frames/s worst case (a
0101…payload : transition every bit, shortest bit duration). - Timing resolution ±100 ns.
- Clocked capture is clean end-to-end to ~3 MHz; a real MDIO bus runs ~1.5 MHz. (The clocked PIOC blob has passed isolated SPI-loopback tests at 6 MHz; ~3 MHz is the conservative product ceiling once CPU drain, USB framing and host decoding are included.)
- Board:
genericCH32X035F8U6(QFN20). CH32X035 = 48 MHz RISC-V, USB-FS device. - Tap pins (logic-level side of the bus, not the differential CAN/RS485 pair):
- data →
PC19(PIOC IO1, used in both modes) - clock →
PC18(PIOC IO0,clockedmode only) - common ground
- for CAN/DMX, put the usual transceiver in front and tap the RXD/RO logic output
- data →
- USB-CDC is on
PC16/PC17. LED onPB12(heartbeat). - Flashing: USB bootloader only. The PIOC uses
PC18/PC19, which are also the chip's SWD/SDI debug pins (DIO/CLK), so SWD debug/flash are unavailable. Flash over the USB bootloader instead:- plug the board in while holding the BOOT button, and it enumerates as the WCH bootloader
pio run -t upload(upload_protocol = isp) detects and flashes it, no debug probe
Firmware (PlatformIO):
pio run -e sniffer -t upload # the product: both modes + runtime !mode switchThen decode it in the browser, Chrome/Edge (Web Serial). The dashboard handles both capture modes and auto-detects the active one:
open app/web-sniffer/index.html # then click "Connect" and pick the serial portscripts/ holds offline Python decoders (capture a file, then decode it) + a throughput probe, for CLI/CI, not live (the browser app is the realtime decoder).
Main build environments (platformio.ini):
| env | what |
|---|---|
sniffer (default) |
the product: both datapaths + runtime !mode rle|clocked |
rle_sniffer |
RLE datapath only, with DIAG telemetry (re-validate a bus) |
clocked_sniffer |
clocked datapath only, with DIAG telemetry |
Host tests: bash app/test/run_all.sh - the codec/framing/mode tests need Node + a C++ compiler; the dashboard smoke test additionally needs Chrome (Web Serial / CDP). No hardware required.
| protocol | kind | notes / test setup |
|---|---|---|
| SPI | clocked | the dev loopback: SPI1 generates known waveforms (PA5/PA7 jumpered to the tap pins) to sanity-check capture without an external bus |
| MDIO sniffing | clocked | the original use case: watch Linux monitor/control Ethernet PHYs. Sniffed on a USB↔SPE (10BASE-T1L) dongle, full Clause-22 + MMD decode |
| CAN (classic, 1 Mbps) | RLE | a very cheap CAN analyzer. Tested against an ESP32 (TWAI) and a real bus with QDD motors through an SN65HVD230 CAN PHY |
| DMX (250 kbps) | RLE | handy DMX-line debugger. Tested against an ESP32 (EZDMX) and real lighting drivers over an RS485 transceiver |
Tapioca uses one USB-CDC link for both control and capture data. The host sends
plain-text control commands such as !mode rle / !mode clocked; the device
streams a compact binary format back to the host.
The short version:
| mode | stream shape | used for |
|---|---|---|
rle |
raw run-length bytes plus sentinels | CAN, DMX, LIN/UART-style asynchronous buses |
clocked |
COBS-0xFF framed sampled-burst records |
MDIO, SPI-style externally clocked buses |
The firmware deliberately keeps protocol decode host-side. The device captures edges/samples, preserves timing, reports loss boundaries, and lets the browser or scripts decode CAN/DMX/MDIO/etc.
For the full byte-level format, examples, diagnostics, and the internal PIOC → RAM ring → USB pipeline, see docs/sniffer-usb-wire-api.md.
Bring-up notes, real-time constraints and the less obvious PIOC/USB traps are collected in docs/sniffer-tips-and-gotchas.md.
This project also features a separate MDIO master driver build target gated behind -D RUN_MDIO_MASTER.
This is not a mode of the generic sniffer and it does not use the browser dashboard.
The split is deliberate: MDIO requires the firmware to drive the preamble/address, release data line exactly at turnaround, keep clock running, sample the PHY response at the right phase, and detect a missing PHY from the TA bit. That makes a protocol-specific driver the right shape.
The user interface is mdioctl, a small Python CLI modelled after
phytool: same read / write / print style, without the Linux interface field
because the selected USB-CDC device itself is the interface. But the driver talks plaintext
ASCII so it's also useable standalone.
PlatformIO environment : mdio_master (mdio_master_stub allows testing the ASCII path without a PIOC)
The same phytool-style syntax supports Clause-22 (phy/reg) and MMD indirect
access (phy:mmd/reg), including mdioctl print --mmd <mmd>.
Details: docs/mdio-master.md.
| path | what |
|---|---|
src/ |
firmware, one main_*.cpp per use case: main_sniffer, main_mdio, main_tick_test (each gated on its env's RUN_* flag, so exactly one defines main() per build) |
src/common/ |
shared board layer: USB-CDC + LED instances, clock/timebase bring-up, printf-over-CDC (board.*) |
src/sniffer/ |
the two datapaths (RleSniffer, ClockedSniffer) + wire helpers (mode_command, record_framer) |
src/mdio/ |
active MDIO master (Mdio::Master) + USB ASCII bridge (Mdio::UsbBridge) |
src/{usb,hal,util}/ |
USB-CDC, SDK + ISRs, ring/cobs/led/spi-gen |
pioc/ |
PIOC assembly files & binary blobs + assemble.py (a small native assembler) |
ldscript/ |
custom linker (reserves the top 4 KB of RAM for the PIOC program ROM) |
app/web-sniffer/ |
the Web Serial browser dashboard (single-file index.html + its inline.mjs builder) |
app/mdioctl/ |
phytool-style CLI for the MDIO master firmware |
app/test/ |
the shared Node/C++ host test suite + codec sources of truth |
scripts/ |
offline Python decoders (CLI/CI) + throughput probe + diag_monitor |
docs/ |
detailed notes and screenshots used by the README |



