Ten slices to five: what a WS2812 bit really is, and how an ESP32 builds one

Our LED driver's changelog has a one-line entry: "custom 250/500/500 ns timing." Behind that line is a wire protocol with no clock, a peripheral that was designed for audio and cannot vary a pulse width, and a piece of integer arithmetic that decides how much memory bandwidth your LEDs cost. Getting that arithmetic wrong cost us 32 MB/s of DMA reads and, in the worst capture we took, two or three corrupted pixels in every frame. This post explains the protocol from scratch, then shows exactly what the ESP32 does with it, then hands you an interactive model of the thing so you can check any LED chip yourself.
Why write this one down
This is the companion to the 60 fps logic-analyzer post, which told the story of the bug hunt. This post is the mechanism: the same fix, explained rather than narrated, for a reader who has never met a WS2812.
It also corrects our own shorthand. Around the shop this change got remembered as "going from six timing buckets to three." The committed numbers are 10 DMA slices per bit down to 5. The "3" is real but narrower: it is how many of those slices are HIGH for a '1' bit under the new timing, down from 7.
Part 1: what a WS2812-family pixel actually is
Every LED in our poles is an addressable RGB pixel: a red, green, and blue die plus a small controller chip, sharing one package. The chip family is WS2812 / WS2813 / SK6812 and their many clones. Ours are SK6813-mini, driven inside WS2813 timing spec.
Three properties matter.
One wire, no clock. There is no clock line. The receiver recovers timing from the data signal itself, which means the transmitter has to hit real nanosecond targets or the data is simply wrong. This is the whole reason the rest of this post exists.
Bits are encoded as pulse widths. Every bit occupies a fixed period, about 1.25 µs. The line goes high at the start of the period and low again partway through. A short high is a '0', a long high is a '1', and that is the entire protocol.
All six captured LED lanes carry different data and still begin on the same 125 ns sample. The zoom shows individual 250 ns and 750 ns HIGH pulses inside 1.25 µs bits, with all six lanes reading a different eleven-bit word.
Fig 1: real captured data off our own strips, not a drawing. The frame on the wire was built to give every strip its own byte signature, so the six lanes have nothing in common except the clock. Top panel: all six at one frame start, every one rising on the same 125 ns sample, which is direct wire proof that a single peripheral drives all of them. That fact matters in Part 2. Bottom panel: eleven bits from later in the same frame, six different words, 250 ns highs for '0' and 750 ns highs for '1', each inside its own 1.25 µs period.
FastLED describes any such chip with three numbers, T1, T2, T3:
- a '1' is HIGH for T1 + T2, then LOW for T3
- a '0' is HIGH for T1, then LOW for T2 + T3
So T1 is the '0' high time (datasheets call it T0H), T1 + T2 is the '1' high
time (T1H), and T1 + T2 + T3 is the bit period. Every chip in
components/FastLED-idf/chipsets.h is one line of these three constants:
| chip | T1 / T2 / T3 | T0H | T1H | bit period |
|---|---|---|---|---|
| WS2813, WS2811 800k | 320 / 320 / 640 ns | 320 ns | 640 ns | 1.28 µs |
| WS2812 800k | 250 / 625 / 375 ns | 250 ns | 875 ns | 1.25 µs |
| SK6812 | 300 / 300 / 600 ns | 300 ns | 600 ns | 1.20 µs |
| SM16703 | 300 / 600 / 300 ns | 300 ns | 900 ns | 1.20 µs |
| UCS2903 | 250 / 750 / 250 ns | 250 ns | 1000 ns | 1.25 µs |
| ours: 250 / 500 / 500 ns | 250 ns | 750 ns | 1.25 µs |
Notice how much they disagree, and notice that they are all describing roughly the same idea: short pulse zero, long pulse one, about 800 kbit/s. The chips accept a window around their nominal numbers, which is why one controller can usually drive several of these families. That tolerance is the room we later spend.
Pixels are a shift register made of chips. The strip is a daisy chain. Each LED reads the first 24 bits it sees (8 bits each of green, red, blue), keeps them, and repeats everything after that out its own data pin to the next LED. So the controller sends one long burst: pixel 0's 24 bits, then pixel 1's, and so on. To end a frame it holds the line low. A low longer than the chip's reset time latches every pixel to the values it is holding and re-arms the chain for the next frame.
For one of our lanes that is 480 pixels x 24 bits = 11,520 bits per frame, and at 1.25 µs per bit, 14.4 ms of continuous transmission. Against a 60 fps budget of 16.67 ms, the wire alone is 86% of the frame. That number is worth sitting with: on this product, the LED protocol is not a detail at the edge of the system, it is the dominant consumer of the frame.
A note on names
Through the rest of this post, "the stock constants" means FastLED's built-in WS2812 timing, 250 / 625 / 375 ns, which is what we were running. "Our timing" means 250 / 500 / 500 ns, which is what we run now.
Calling the second one "ours" overstates it, and the class in our source being
named PoleFXController overstates it further. There is nothing proprietary
or clever in those numbers. A 750 ns '1' is simply the WS2813 nominal, and the
250 ns '0' sits inside the same part's window. All we did was pick, out of the
range the LED already accepts, the combination whose three numbers share a
large common divisor. Anyone driving these chips off an ESP32 can pick the
same ones, and after reading Part 5 you will be able to find the equivalent
for whatever part you have.
Part 2: how the ESP32 generates it
There are three ways to make this waveform on an ESP32.
- Bit-bang it. Toggle a GPIO with the CPU and count cycles. Works, blocks a core for 14.4 ms, and any interrupt corrupts the frame.
- The RMT peripheral. Purpose-built for exactly this: it emits (level, duration) pairs, so a '1' and a '0' are just different durations. Clean, but it has a small number of channels (8 on the classic ESP32), and channels are not sample-locked to each other.
- The I2S peripheral in parallel / LCD mode. This is what we use, and it is a hack, in the good sense.
We drive six lanes of 480 pixels. We want them to start on the same nanosecond, because the six lanes are six slices of one image on one physical pole. I2S parallel mode gives us that for free: one peripheral shifts up to 24 output pins simultaneously. That is the top panel of Fig 1.
The catch, and the trick
The I2S peripheral cannot vary a pulse width. It is a shift-out engine: it takes a 32-bit word and puts each bit on a pin, then takes the next word, at a fixed clock rate. Bit N of the word goes to lane N.
So the driver approximates. Quote from the top of
components/FastLED-idf/platforms/esp/32/clockless_i2s_esp32.h:
Unlike the RMT peripheral the I2S system cannot send bits of different lengths. Instead, we set the I2S data clock fairly high and then encode a signal as a series of bits.
Each LED bit gets chopped into slices, and each slice is one 32-bit DMA word carrying that instant's level for all lanes at once. A '1' is "high for the first several slices, then low"; a '0' is "high for one slice, then low."
How wide is a slice? It has to divide T1, T2, and T3 evenly, so the driver takes their greatest common divisor. That single line of arithmetic is the subject of this post:
gPulsesPerBit = T1/pgc_ + T2/pgc_ + T3/pgc_; // slices per LED bit
ones_for_one = T1/pgc_ + T2/pgc_; // HIGH slices for a '1'
ones_for_zero = T1/pgc_; // HIGH slices for a '0'
The consequence is not intuitive, so here it is as a thing you can operate. Drag the three constants around, or load a chipset, and watch the slice count and the resulting memory-bus load move. The waveform the LED sees is the thick line; the shaded columns behind it are the DMA words the ESP32 has to fetch to draw it.
Work it by hand for FastLED's stock WS2812 constants, 250 / 625 / 375 ns. The GCD is 125 ns, so each 1.25 µs bit is 10 slices, of which 7 are high for a '1' and 2 are high for a '0'. Now our timing, 250 / 500 / 500 ns. The GCD is 250 ns, so the same 1.25 µs bit is 5 slices, 3 high for a '1' and 1 for a '0'.
The old encoding uses ten 125 ns DMA slices per bit; the new encoding uses five 250 ns slices per bit while preserving the same 1.25 µs wire period.
Fig 3: the same comparison as a static picture. The '1' high shrinks from 875 ns to 750 ns and the '0' does not move at all. On the wire this change is close to invisible.
Inside the ESP32 it is not invisible at all.
What a slice costs
The I2S clock has to run at (slices per bit) x (bit rate). The peripheral divides the 80 MHz APB clock by N + b/a, and the driver solves for those:
| slices/bit | slice width | I2S clock | divider N | DMA words/s | DMA read rate | |
|---|---|---|---|---|---|---|
| stock WS2812 | 10 | 125 ns | 8 MHz | 10 | 8 M/s | 32 MB/s |
| ours | 5 | 250 ns | 4 MHz | 20 | 4 M/s | 16 MB/s |
Every slice is a 4-byte read from RAM, sustained for the entire 14.4 ms transmission, every frame, forever. Halving the slice count halves that.
It also halves the buffers. Each DMA buffer holds one pixel's worth of
expanded slices for all 24 possible lanes:
32 * NUM_COLOR_CHANNELS * gPulsesPerBit bytes, which is 960 B at 10 slices
and 480 B at 5. We keep 4 of them in a circular chain, refilled by an
end-of-transmission interrupt while the DMA plays the others.
And there is real work happening in that refill. The pixel data arrives
lane-serially (all of lane 0's bytes, then lane 1's) but the hardware wants it
lane-parallel (one word per instant, one bit per lane), so fillBuffer()
transposes a 3 x 24 x 8 bit array into 3 x 8 x 32 and then expands each
resulting group of 24 parallel bits into the slice pattern. Fewer slices means
less expansion per pixel, on an interrupt that has to complete inside one
pixel-time.
Part 3: why it mattered
The ESP32's memory bus is shared. On our boards, the same bus is serving Ethernet DMA, and six UDP packets land in the middle of every single LED transmission, because the Pi is streaming frames at 60 fps while the previous frame is still going out.
Our reading is that at 32 MB/s sustained the I2S DMA intermittently lost that arbitration. When the I2S FIFO underruns, the peripheral does not stop or flag an error to the driver: it holds its last output level until data arrives. If the last level was high, the pulse just keeps going.
On the analyzer, that is a 0.5 to 2.5 µs stuck-high pulse where the legal maximum is 1 µs. To the LED, a stretched high inside a bit period is a '1' where a '0' was intended. On the strip it is a single pixel, one color channel, one frame, suddenly bright: an RGB sparkle.
The silence is also why the mechanism stayed inferred. There was no underrun flag for us to read, we ran no Ethernet-off control, and we never held the wire timing fixed while sweeping bus load. What we have is a mechanism that predicts the exact waveform signature we were seeing, and a fix derived from it that removed the signature. Strong, but not a unique identification, and Part 4 has a measurement that does not fit it cleanly.
The measured before and after, over 8 s captures on the bench
(commit a3c0109):
pulses stretched past 1 µs: 607 -> 0
sparkle pixels: 2.523/frame -> 0.000/frame
frame pacing: 60.08 fps median, 1 late interval in 477
The analyzer's summary line for the after capture says "9 anomalous pulses" rather than zero, and that is worth unpacking instead of rounding away. Its legality test is a fixed set of four width bins, so it flags anything a sample off one of them. The recovered histogram accounts for eight: four at 125 ns and four at 500 ns, none more than one sample from a legal width, none of them decoding to the wrong bit. The ninth is the stray 60 to 110 µs high that shows up about once per capture in the post-frame dead zone past pixel 480, where the DMA replays stale data until the task-level stop runs. That is why nine residual anomalies and zero sparkles are the same result and not a contradiction. The population that mattered, highs stretched past 1 µs, went to none.
Then a soak to make sure it was not luck: 24 captures of about 11 s each over roughly 18 minutes, probes on five lanes, about 70% wire-time coverage. 0 sparkle flips in about 74,000 lane-frames, roughly 15,000 frames per lane. That is 23 of the 24 iterations. The one we set aside had its lanes disagreeing by two orders of magnitude in anomalous-pulse count, which a single shared 32-bit DMA word cannot do, so it reads as probe handling rather than as the LED path; counted inclusively the raw log is 78,480 lane-frames, and every flip in it lives inside that one iteration.
The unit deserves a second look too, because it flatters the result. Lane-frames are not independent trials. All five lanes leave one I2S engine on that same shared 32-bit word, so the soak watched about 15,000 transmissions, each five times over: five correlated views of fifteen thousand events, not seventy-four thousand events.
Eight days later, on main-line firmware several merges downstream, a fresh capture showed 479 frames, 59.99 fps median, 0 late intervals of 478, and zero out-of-spec pulses inside the real 480 pixels of every frame.
The pacing improvement in that list is worth noting, because it was a surprise. Our reading is that the underruns had also been stalling the DMA, so they were eating frame time as well as corrupting pixels: one root cause, two symptoms that looked unrelated. The duller candidate is sitting right there too, though. Half the slices is also half the expansion work in the refill interrupt described above, which runs on the CPU inside the DMA window. We never separated the two.
The whole fix is one line, main/leds.cpp:23:
class PoleFXController : public ClocklessController<DATA_PIN,
C_NS(250), C_NS(500), C_NS(500), RGB_ORDER> {};
Part 4: what "out of spec" actually looks like
"Out of spec" sounds like a binary pass or fail. In practice each way of missing the window has its own signature, and knowing them is what makes a capture readable.
High too long, within one bit period. A '0' reads as a '1'. This was our bug, and it has a very legible fingerprint on a histogram of pulse widths:
The before capture contains a 1.1–2.6 µs stuck-high tail of 607 pulses. The after capture contains nine near-boundary pulses and no tail.
Fig 4: every HIGH pulse on one lane over an 8 s capture, before and after. Legal widths cluster in two narrow bands. The tail out to 2.6 µs in the top panel is the stuck-high signature, and it is not a measurement artifact: no legal encoding, and no amount of sample rounding on top of one, produces a pulse there.
The pixel-level forensics fit the mechanism: pure single-channel, single-bit 0-to-1 flips, with the flipped bit positions concentrated in the top three bits (255, 384, and 398 occurrences), which is exactly why they read as bright sparkles rather than invisible ones. Corruption did not track content: the linear correlation between frame luminance and flip count was -0.056, splitting the capture at the median luminance gave 2.62 flips per frame in the dim half against 2.40 in the bright half, flips were uniform across all 480 pixel positions, and they arrived at a steady 125 to 165 per second while the pattern moved. That is four ways of failing to find a content dependence, not a proof there is none; a flip needs a '0' bit in one of the top three positions to land on, so the opportunity is content-shaped even if the rate is not.
High too short. A '1' reads as a '0'. Same class of error, dimmer symptom, which is a good reason not to trust your eyes: a bug that turns bits off is much harder to see than one that turns them on.
Low too long. Every chip treats a sufficiently long low as the frame latch. A stall in the middle of a frame can therefore end the frame early, and everything after it starts filling the strip from pixel 0 again. The symptom is a strip that looks shifted or half-updated rather than sparkly.
Not enough bits. If the controller sends fewer than 24 bits per pixel, the tail pixels keep their previous values and nothing looks obviously broken. We shipped exactly this bug for part of one afternoon (frames 40 bits short after a DMA buffer change) and it was invisible on the strip. Counting bits is the cheapest high-value check there is: a frame that is not 11,520 bits per lane is the number one red flag in any capture.
The honest caveat
The rule that came out of this work is "prefer a coarse GCD," and it is a good rule. It is not the whole story.
Our morning baseline that day ran stock WS2813 timing (320 / 320 / 640 ns). Running that through the driver's own arithmetic gives a GCD of 320 ns, 4 slices per bit, and 12.5 MB/s: less bus pressure than the 16 MB/s we ended up with. It still sparkled, at 2.22 flips per frame. It also ran only 2 DMA buffers, which leaves the refill interrupt about one pixel-time of slack instead of three.
So there are at least two candidate ways to starve that FIFO: not enough bandwidth headroom, and not enough time headroom. Our fix addressed the first because that was the one the measurements pointed at, on a build that already had 4 buffers. (Note the provenance difference: the 4-slice / 12.5 MB/s figure for the baseline is derived from the driver source, not measured on the bench. The 2.22 flips per frame is measured.)
Related, and also worth not over-fitting: the first hypothesis for the sparkles was interrupt latency, and it was tested with more DMA slack and an EOF resync. The corruption barely moved: 1.714 flips per frame against 2.523. Its 4,097 anomalous pulses are the easiest number in the whole session to misquote as the "before" of the real fix, because the analyzer's own summary line for the after capture prints them that way; they belong to this test capture, not to the build the fix replaced. Scored the same way, that build logged 3,057, so the resync came out marginally worse on pulses while looking better on flips. Plausible mechanism, wrong one.
Part 5: checking a chip before you commit to it
Two checks, one at design time and one on the bench.
Design time: run the GCD
Before adopting a new LED part, run its FastLED timing constants through the
driver's own pgcd() and look at what it costs. That is what the interactive
model in Part 2 does, and here is every chipset in our tree run through it at
once:
Computed sustained I2S DMA read rates range from 11.7 MB/s for PL9823 to 61.5 MB/s for UCS1903B. The PoleFX timing is 16.0 MB/s; stock WS2812 timing is 32.0 MB/s.
Fig 5: computed, not measured. The spread across parts that all look interchangeable on a product page is a factor of five.
UCS1903B is the cautionary entry: 20 slices per bit, 61.5 MB/s, and a
15.4 MHz I2S clock against the driver's own I2S_MAX_CLK comment that "more
than a certain speed and the I2S loses some bits." That part would be a bad
citizen on a board that also does Ethernet. Note also that PL9823 and SK6822
sit at opposite ends of the chart despite having nearly identical wire
timing, which is the whole point: the wire does not tell you the cost.
Why not WS2813 timing, then?
Fair question, and the chart invites it. Stock WS2813 timing (320 / 320 / 640 ns) is 4 slices per bit and 12.5 MB/s, which is cheaper on the bus than the 16 MB/s we chose. We ran it, and we moved off it on purpose.
The catch is the bit period. WS2813 timing is 320 + 320 + 640 = 1.28 µs per bit against our 1.25 µs. Over 11,520 bits that is 0.35 ms more per frame, every frame. When the transmission alone is already 14.4 ms of a 16.67 ms budget, 0.35 ms is a fifth of everything left over. Moving to a 1.25 µs bit was the first change we made that morning, and the build log records it as 0.45 ms per frame recovered.
The second reason is that we could not measure any benefit from going lower. We have exactly two points on this board: 16 MB/s ran clean and 32 MB/s did not. Nothing was ever measured in between, so we cannot put a threshold anywhere in that gap, only bracket it. What we can say is that the extra 3.5 MB/s bought nothing we could see, while the 0.35 ms costs something we were fighting for all day. Cheaper on a chart is not the same as better in the budget.
The third reason is that WS2813 timing is not the clean option it looks like there. It is exactly what our morning baseline ran, and that build sparkled at 2.22 flips per frame. It also had 2 DMA buffers rather than 4, so it is not a controlled comparison, but it does mean a low slice count is not on its own a clean bill of health. (Provenance, again: the 4-slice / 12.5 MB/s figure is computed, the 2.22 flips per frame is measured.)
So the rule is not "minimize slices." It is "get the slice rate down until the bus stops fighting you, then spend everything else on the wire clock." Where exactly it stops fighting you is a per-board number, and on this board we only bracketed it between 16 and 32 MB/s.
One caveat on reading that chart. The driver's pgcd() is not a true GCD:
when the exact divisor would need more than I2S_MAX_PULSE_PER_BIT = 20
slices, it relaxes a precision tolerance until the count fits, which is why
350 / 660 / 350 comes out at 330 ns rather than 10 ns. The relaxed divisor
means the generated pulse is a few nanoseconds off nominal, which is fine
inside a chip's window and is exactly the tradeoff we are making deliberately.
The useful move, and the one we made, is to stop treating the chipset's nominal constants as sacred. The LED decodes a window, not a point. Pick numbers inside that window whose GCD is coarse.
Bench: look at the wire
The design-time check tells you what the driver intends. Only the wire tells you what happened. A $6 FX2 logic-analyzer clone at 8 MHz is enough:
sigrok-cli --driver fx2lafw --config samplerate=8m --samples 100m -o cap.sr
Then decode with the rules in pfx-firmware/docs/logic-analyzer.md: 1.25 µs
per bit, a high longer than 500 ns is a '1', a low longer than 50 µs is a
frame gap, a frame is 11,520 bits per lane. At 8 MHz sampling a legal high
lands in one of two adjacent 125 ns bins (250 or 375 ns for a '0', 750 or
875 ns for a '1'), because the analyzer's clock and the ESP32's are
free-running against each other and an individual pulse reads one sample long
or short. tools/analyzer/analyze_capture.py does all of it.
Do not read "outside the bins" as "generation glitch" mechanically. Probe loading alone biases a lane's rounding, consistently and in either direction: in one six-lane capture four strips rounded long while two rounded short on 0.3 to 0.7% of their pulses, which puts perfectly legal bits at 125 and 625 ns. The test that survives contact with a real bench is whether one sample of rounding can explain the width. Ours could not: a 2.6 µs high is sixteen samples from anything legal.
Three things to check in that order: bits per frame (is it exactly 11,520), pulse widths (are they all in the legal bins), then intervals (is the pacing what you asked for). Our whole story is in that order: the truncation bug was a bit count, the sparkles were pulse widths, the 60 fps work was intervals.
What we changed
- 250 / 500 / 500 ns instead of FastLED's stock WS2812 250 / 625 / 375 ns
(
main/leds.cpp, commita3c0109). Same 1.25 µs bit on the wire, T1H 875 ns to 750 ns, both inside WS2813 tolerance. - Consequence, which is the actual point: GCD 125 ns to 250 ns, 10 DMA slices per bit to 5, 32 MB/s to 16 MB/s of sustained DMA reads, 960 B to 480 B per DMA buffer.
- Result: highs stretched past 1 µs 607 to none per 8 s capture, sparkles 2.52 to 0.00 per frame, and 0 flips in about 74,000 lane-frames of soak (roughly 15,000 transmissions, watched on five correlated lanes).
Rules we keep
- On I2S parallel, the GCD of your timing constants is a bandwidth decision. It sets the DMA slice rate. Finer granularity buys nothing the LED can see and doubles memory-bus pressure. But cheaper is not automatically better: below the point where the bus stops fighting you, slices are free and bit-period microseconds are not.
- The chipset constants are a window, not a point. Choosing custom timing inside the datasheet window is a legitimate and cheap tool.
- Count the bits first. A frame that is not 11,520 bits per lane outranks every other anomaly.
- A peripheral that underruns may not tell you. I2S holds its last level and keeps going. Silence from the hardware is not evidence of health.
- Measure before believing a mechanism. The interrupt-latency theory for the sparkles was plausible, cheap to believe, and disproven in one capture.
Measured numbers in this post come from the 2026-07-09 bench session and its
recovered dataset (pfx-labs/signal-lab/) or the 2026-07-17 recapture. Fig 1
is newer than both: it is a single 8 MHz capture taken on 2026-07-31, once all
six data lines were finally on probes at the same time. Fig 5 and the
interactive model in Fig 2 are computed directly from
clockless_i2s_esp32.h, and say so where it matters.