The $0.1 PLD you already have in your drawer.
Turn a CH32V003 microcontroller into a tiny reprogrammable glue chip. Write
low-density logic in Verilog or SystemVerilog, feed the source through
bin/cli.js, and get a runtime-free C firmware that bit-bangs GPIO to
emulate the circuit -- slower than a real PLD, but cheap and flexible.
%% cherepanov pipeline: Verilog/SystemVerilog -> CH32V003 firmware + timing report.
%% bin/cli.js <config.js> drives every node except the final flash/mcu.
flowchart TD
%% --- inputs ---
SRC["top.v / top.sv<br/>(+ optional .vh)"]
CFG["config.js<br/>port to pin map, clock"]
%% --- Verilator lowering (step 1) ---
VLT["Verilator --cc<br/>--pins-uint8 --no-timing"]
HDR["V(top).h<br/>port declarations"]
CPP["obj_dir/*.cpp<br/>___ico_sequent__TOP__0"]
%% --- cli.js transcription + bridge (steps 2-4) ---
CLI["bin/cli.js<br/>parse ports, translate body"]
MAIN["main.c<br/>GPIO bridge + eval()"]
FUN["funconfig.h<br/>clock / PLL"]
MK["Makefile"]
%% --- build (step 5) ---
MAKE["make build<br/>riscv64-elf-gcc -O3"]
BIN["main.bin"]
LST["main.lst<br/>disassembly"]
%% --- timing analysis on the disassembly (step 6) ---
ANA["analyze main.lst<br/>count instrs + MMIO<br/>in the hot loop"]
REP["Tdelay / Tjitter<br/>fast/slow dual-path"]
%% --- flash to target ---
FLASH["make flash<br/>ch32fun bootloader"]
MCU["CH32V003<br/>bit-bangs GPIO"]
SRC --> VLT
VLT --> HDR --> CLI
VLT --> CPP --> CLI
CFG --> CLI
CLI --> MAIN --> MAKE
CLI --> FUN --> MAKE
CLI --> MK --> MAKE
MAKE --> BIN --> FLASH --> MCU
MAKE --> LST --> ANA --> REP
bin/cli.js <config.js> is the only entry point. It:
- Runs Verilator
--ccto lower the Verilog/SystemVerilog into C++. - Extracts the lowered logic body (
___ico_sequent__TOP__0). - Translates that body to plain C (no verilated runtime -- it's hosted-only
and won't build for the bare-metal
rv32ectarget). Designs with acasestatement are lowered to a constant-pool table lookup whose data lives in a separateV<top>__ConstPool_<n>.cpp; those tables are transcribed to plain-Cstatic constarrays so the body links standalone. - Wraps it in a GPIO bridge: reads input pins via
GPIOx->INDR, evaluates, writes output pins viaGPIOx->OUTDR. - Generates
funconfig.handMakefile, builds the firmware, and runs a timing analysis on the disassembly.
- Verilator v5
- ch32fun (CH32V003 toolchain + bootloader)
- RISC-V bare-metal toolchain (
riscv64-elf-gcc) - Node.js (for
bin/cli.js)
cd examples/74x04
../../bin/cli.js config.js
make flash
The config file (.js or .json) is the only argument and fully describes
the build:
module.exports = {
top: 'top', // optional; default = stem of vsrc[0]
vsrc: ['top.sv'], // Verilog/SystemVerilog source files
// include: ['../common.vh'], // optional; Verilog include files
clock: { mhz: 48, use_pll: true }, // optional; default 48 MHz PLL
// ch32fun: '../ch32fun/ch32fun', // optional; path to ch32fun (default: $CH32FUN or ../ch32fun/ch32fun)
ports: { // explicit per-port -> physical pin map
a: { type: 'input', width: 6, pins: ['d0','d1','d2','d3','d4','d5'] },
y: { type: 'output', width: 6, pins: ['c0','c1','c2','c3','d2','d3'] },
}
};Each pin string (e.g. 'd4') maps to a physical GPIO pin (PD4). A Verilog
port of arbitrary width maps to an arbitrary list of pins, which may span
several GPIO ports; the generator reads each touched GPIO port's INDR once
and writes one OUTDR word per touched output port.
Non-output pins are configured as floating inputs (no pull-up/down), which
disables their output driver. This makes a blind full-port GPIOx->OUTDR
write safe: output pins get driven, input pins' latch bits update but are
not driven externally.
| example | function | source | flash |
|---|---|---|---|
7400 |
quad 2-input NAND | top.v |
852 B |
74x02 |
quad 2-input NOR | top.v |
828 B |
74x04 |
hex inverter (6 gates) | top.sv |
748 B |
74x47 |
BCD-to-7-segment decoder | ic_74x47.v |
~1100 B |
74x47 is the first example exercising a case statement: Verilator
lowers it to a constant-pool table lookup, which cli.js transcribes to a
static const array in main.c (see step 3 above).
Each example directory has a config.js, a Verilog source, and (after
running cli.js) a generated main.c + funconfig.h + Makefile. Flash
sizes are indicative -- they vary with the ch32fun/toolchain version.
Oscillogram of one gate of the 74x04 inverter. Blue line is the input, yellow
line is the output. With change-detection the loop polls at the fast-path rate
while inputs are idle and runs the slow path only on the iteration that detects
an edge; the input→output delay is Tslow + Tfast/2 with ±Tfast/2 aperture
jitter (see below).
The loop has two execution paths because of change-detection:
- fast path -- no input changed: read each input GPIO port's
INDR, compare against the saved previous value,continueback to the top. Skips eval +OUTDRwrites. Cost is oneINDRread + compare per input port, so it is estimated from the number of input GPIO ports (P):Tfast ≈ (3·P + 1)/f + P·0.05 µs. The idle poll loop's one branch is always taken the same way (no mispredict bubble, no +1.6), and it only doesINDRreads, which are cheaper thanOUTDRwrites (~0.05 µs each). - slow path -- some input changed: read
INDR, detect the change, runeval, writeOUTDR, loop back.Tslow = (instrs + 1.6)/f + mmio·0.1 µsis counted from the full loop body inmain.lst(the +1.6 is the taken back-edge bubble; OUTDR writes pay the full AHB→APB bridge floor).
While inputs are idle, every iteration runs the fast path, so the loop polls
at 1/Tfast. An input edge arriving at a random phase waits for the next
INDR read (uniform in [0, Tfast], mean Tfast/2), then the detecting
slow-path iteration spends its read→write span Tslow to drive OUTDR. This
is exactly a clocked-FF + combinational-logic timing model:
Tdelay = Tslow + Tfast/2 (mean input→output delay)
Tjitter = ±Tfast/2 (aperture jitter — the only variable term)
The jitter is entirely sampling (aperture) jitter -- not interrupt latency
-- and the only lever on it is shortening the fast path (the poll rate). With
change-detection off, Tfast = Tslow = T and this reduces to the
oscilloscope-validated Tdelay = 1.5T, Tjitter = ±0.5T.
Two oscilloscope data points pin the slow-path model (Tslow = (instrs+1.6)/f + mmio·0.1 µs):
| clock | Tslow (measured) | Tslow (model) |
|---|---|---|
| 48 MHz (PLL) | 0.4 µs | 0.40 µs |
| 24 MHz (HSI) | 0.6 µs | 0.60 µs |
Solving: c = 9.6 cycles, m = 0.2 µs (2 MMIO). The fast path
(Tfast = instrs/f + mmio·0.05 µs, no branch bubble, cheaper INDR reads) is
pinned by the 74x04 jitter measurement: 3 input ports → Tfast = 0.36 µs →
Tjitter = ±0.18 µs. At 48 MHz the slow path is 50% CPU-bound, 50%
MMIO-bound -- the clock is only a half-lever because the AHB→APB bridge sets
a floor the PLL can't touch; the fast path (and thus the jitter) is dominated
by the INDR read cost, so the clock is only a weak lever on jitter -- the
real lever is reading fewer input ports.
For low-density logic the gate eval itself (~a, ~(a & b), etc.) is a
handful of ALU instructions and is not the bottleneck. The dominant cost
is port remuxing -- scattering input bits across GPIO ports and gathering
output bits back. With a linear mapping (all inputs on one port, all outputs
on another, identity bit order) the compiler folds the scatter/gather into
two mask instructions and the loop hits the 0.4 µs floor. Pin mappings that
span multiple GPIO ports cost ~3×.
cli.js reports the timing analysis automatically after building:
Timing analysis
----------------
clock: 48 MHz (PLL)
fast path: 10 instrs, 3 INDR reads -> Tfast = 0.36 us
slow path: 58 instrs +1.6 = 59.6 cyc, 7 MMIO -> Tslow = 1.94 us
Tdelay: 2.12 us (Tslow + Tfast/2)
Tjitter: +-0.18 us (Tfast/2 aperture)
sudo zypper install verilator cross-riscv64-elf-gcc16 cross-riscv64-newlib-devel \
cross-riscv64-binutils libudev-devel libusb-1_0-devel nodejs
See LICENSE.

