Skip to content

fix(bitstream): drive SysTick off the core cycle counter#58

Merged
begeistert merged 1 commit into
masterfrom
fix/bitstream-timing
Jul 17, 2026
Merged

fix(bitstream): drive SysTick off the core cycle counter#58
begeistert merged 1 commit into
masterfrom
fix/bitstream-timing

Conversation

@begeistert

Copy link
Copy Markdown
Collaborator

machine.bitstream() — the transport under MicroPython's neopixel module —
produced constant-width pulses, so every NeoPixel painted (0,0,0): a bit-0 and a
bit-1 left the pin with the same high time.

The rp2 port times each bit against SysTick: write CVR, raise the pin,
busy-wait on (start - CVR) < width, lower it. PpbPeripheral advanced SysTick
only from the peripheral tick, so CVR was frozen for the whole busy-wait and both
delays expired at the same instant. This drives SysTick off the core cycle counter
instead, which resolves per instruction.

Includes a regression test over a real 3-pixel WS2812 frame asserting the
pulse-width histogram splits into two clusters (T0H/T1H). Companion to the same fix
in RP2350Sharp.

Tests: 533 unit, integration green (incl. the bitstream regression).

machine.bitstream() produced degenerate pulses: every high pulse lasted
exactly the same time for a bit 0 and a bit 1, so a WS2812 frame left the
chip carrying no information and neopixel always painted (0,0,0).

The rp2 port times bitstream against SysTick. For each bit it writes CVR,
raises the pin, and busy-waits on `(start_ticks - systick_hw->cvr) < t[0]`
before lowering it (ports/rp2/machine_bitstream.c, PICO_ARM path). SysTick
was only advanced from ITickable.Tick, which RP2040Machine.Run calls once
per batch of instructions — 500 K in the test harness. Inside a batch CVR
was therefore frozen, so the very first comparison already read a distance
that cleared both t[0] and t[1] and each wait fell straight through. Both
delays collapsed to the same handful of instructions.

SysTick counts off the core clock, so its value is a pure function of the
owning core's cycle counter and must be derived from it on demand rather
than integrated at the tick quantum. Anchor CVR to CortexM0Plus.Cycles and
resolve it lazily on every SysTick register access; Tick() no longer needs
the shared delta the router passes, which was also wrong for core 1 (each
core's private SysTick advances with its own cycles).

The CVR write path now seeds the reload instead of parking the counter at
zero: ARMv6-M says a CVR write must not raise a SysTick exception, but a
counter left at zero rolls over on the next cycle. Only observable now that
the counter resolves per-instruction.

Pulse-width histogram for `np.write()` over 3 pixels on GP2 (72 bits, of
which 48 zeros and 24 ones), MicroPython 1.28.0:

  before   15:72x                 (one cluster — bits unrecoverable)
  after    51:48x, 99:24x         (two clusters, ratio 1.94 ~ T1H/T0H)

The new integration test boots real MicroPython, runs the neopixel script
and decodes the captured widths back into the GRB bytes the firmware wrote.
@begeistert
begeistert requested a review from lmSeryi as a code owner July 17, 2026 15:24
@begeistert
begeistert merged commit 4cc4ec9 into master Jul 17, 2026
4 checks passed
@begeistert
begeistert deleted the fix/bitstream-timing branch July 17, 2026 15:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant