
Driving WS2812 and WS2812B LEDs from an STM32 does not require carefully timed bit-banging. SPI can generate the waveform for you, as long as each LED bit is expanded into a short SPI bit pattern. This post explains the timing math and the full reasoning, but builds toward the practical result: use an 8-bit encoding, send the data in GRB order, and run SPI somewhere around 3 to 6 Mb/s.
Short version
If you want a working starting point before going through the derivation:
- Encode each WS2812 bit as one SPI byte.
- Use
0x80(10000000) for a logical0. - Use
0xFC(11111100) for a logical1. - Send 24 encoded bits per LED in green, red, blue order.
- Append enough zero bytes to keep the line low for at least 50 µs after the last bit.
- Pick an SPI bitrate between 3 and 6 Mb/s.
The rest of the article explains why those values work and how to choose them on STM32.
The protocol in one minute
Each WS2812 or WS2812B LED expects 24 bits: 8 bits of green, 8 bits of red, and 8 bits of blue. The LEDs are daisy-chained, so any extra bits continue to the next LED in the strip.
A single data bit is not represented by a clock edge. Instead, it is represented by how long the data line stays high within a fixed 1.25 µs bit time:
For WS2812B, the datasheet specifies:
- logical
0: high for 0.35 µs, low for 0.90 µs - logical
1: high for 0.90 µs, low for 0.35 µs
After all bits have been sent, the line must stay low for at least 50 µs so the LEDs latch the frame. The datasheet calls this the reset time, but in practice it is simply the end-of-frame gap.
That gives a quick feel for the timing budget:
- one LED:
24 × 1.25 µs + 50 µs = 80 µs - eight LEDs:
8 × 24 × 1.25 µs + 50 µs = 290 µs
The older WS2812 has slightly different nominal numbers, but the method below still works for both parts.
Why SPI works here
SPI produces evenly spaced high and low pulses. If we choose the SPI bitrate carefully, and replace each WS2812 bit with a short SPI bit pattern, the MOSI waveform can satisfy the LED timing.
It helps to think of SPI as a small pulse generator:
- one SPI bit = one small slice of time
- an SPI
1keeps MOSI high for that slice - an SPI
0keeps MOSI low for that slice
So the real problem becomes:
- How many SPI bits should represent one WS2812 bit?
- Which pattern should represent LED
0and LED1? - Which SPI bitrate makes those patterns land inside the timing window?
Actual timing requirements
Tim cpldcpu measured the real timing tolerance of the LEDs and found that the datasheet is conservative. He found:
- A reset is issued as early as at 9 µs (much less that the 50 µs from the data sheet)
- Cycle time of a bit should be at least 1.25 µs [req 1] (value from data sheet) and at most 9 µs [req 2] (time for reset)
- A
0can be encoded with a high pulse as short as 62.5 ns [req 3], but should not be longer than 0.50 µs [req 4] - A
1can be encoded with a high pulse almost as long as the total cycle time, but should not be shorter than 0.625 µs [req 5]
Providing us with these pratical requirements:
- total bit time must be at least 1.25 µs
- total bit time must stay below the reset threshold, roughly 9 µs
- logical
0high time must be at least 62.5 ns - logical
0high time must stay below 0.50 µs - logical
1high time must be at least 0.625 µs
These are the constraints used in the calculations below.
Choosing the optimal SPI encoding
Assume one SPI pulse lasts T. If one WS2812 bit is encoded into N SPI pulses, then:
- total WS2812 bit time =
N × T - logical
0high time = number of leading1s in the0pattern ×T - logical
1high time = number of leading1s in the1pattern ×T
The question is how large N needs to be.
Option 1: 3 SPI pulses per WS2812 bit
This is the smallest practical encoding:
0→1001→110
That gives:
3T ≥ 1.25 µs→T ≥ 417 ns3T < 9 µs→T < 3000 nsT ≥ 62.5 nsT < 500 ns2T ≥ 625 ns→T ≥ 313 ns
The tightest limits are therefore 417 ns ≤ T < 500 ns, which means an SPI bitrate between 2.00 Mb/s and 2.39 Mb/s.
This works, but the allowed range is narrow.
Option 2: 4 SPI pulses per WS2812 bit
A more forgiving encoding is:
0→10001→1110
Now the limits become:
4T ≥ 1.25 µs→T ≥ 313 ns4T < 9 µs→T < 2250 nsT ≥ 62.5 nsT < 500 ns3T ≥ 625 ns→T ≥ 209 ns
So 313 ns ≤ T < 500 ns, or an SPI bitrate between 2.00 Mb/s and 3.19 Mb/s.
This already leaves more room for a practical STM32 clock setup.
Option 3: 8 SPI pulses per WS2812 bit
This is the most convenient encoding:
0→100000001→11111100
The limits become:
8T ≥ 1.25 µs→T ≥ 157 ns8T < 9 µs→T < 1125 nsT ≥ 62.5 nsT < 500 ns6T ≥ 625 ns→T ≥ 105 ns
So 157 ns ≤ T < 500 ns, or an SPI bitrate between 2.00 Mb/s and 6.36 Mb/s.
This gives the widest usable bitrate range and fits naturally into bytes, which is the main reason to prefer it on STM32.
Why 8 SPI bits is the sweet spot
The 8-bit approach is the easiest one to work with in real code:
- one WS2812 bit becomes one byte in memory
0maps to0x801maps to0xFC- no bit packing is needed
It also gives the most freedom when choosing an SPI clock divider. Here is the trade-off for the three encodings:
| SPI bits | “0” | “1” | min bitrate | max bitrate | reset pulses (50 µs) @ max bitrate |
|---|---|---|---|---|---|
| 3 | 100 | 110 | 2.00 Mb/s | 2.39 Mb/s | 120 |
| 4 | 1000 | 1110 | 2.00 Mb/s | 3.19 Mb/s | 160 |
| 8 | 10000000 | 11111100 | 2.00 Mb/s | 6.36 Mb/s | 318 |
On an STM32G474 running at 170 MHz, for example:
- with the 4-bit encoding, the nearest SPI dividers give 1.3 Mb/s and 2.7 Mb/s, so only 2.7 Mb/s works
- with the 8-bit encoding, the nearest SPI dividers give 2.7 Mb/s and 5.3 Mb/s, and both work
So the 8-bit encoding is not just easier to understand in software, it is also much easier to hit with the fixed prescalers that STM32 SPI peripherals provide.
A simple STM32 SPI implementation
With the 8-bit encoding, the buffer layout is straightforward:
- 24 bytes per LED: 8 for green, 8 for red, 8 for blue
- one encoded byte per original color bit
- extra zero bytes at the end to create the latch/reset time
Because 0x80 is 10000000 and 0xFC is 11111100, MOSI stays high for 1 or 6 of the 8 SPI bit times.
ws2812-spi.h
#define WS2812_NUM_LEDS 8
#define WS2812_SPI_HANDLE hspi2
#define WS2812_SPI_0 0x80
#define WS2812_SPI_1 0xFC
#define WS2812_RESET_BYTES 60
#define WS2812_BUFFER_SIZE (WS2812_NUM_LEDS * 24 + WS2812_RESET_BYTES)
extern SPI_HandleTypeDef WS2812_SPI_HANDLE;
extern uint8_t ws2812_buffer[];
void ws2812_init(void);
void ws2812_send_spi(void);
void ws2812_pixel(uint16_t led_no, uint8_t r, uint8_t g, uint8_t b);
void ws2812_pixel_all(uint8_t r, uint8_t g, uint8_t b);
WS2812_RESET_BYTES is intentionally generous. Even at 6.36 Mb/s, 60 zero bytes keep the line low for about 75 µs, which is safely above the required 50 µs.
ws2812-spi.c
#include <string.h>
#include "main.h"
#include "ws2812-spi.h"
uint8_t ws2812_buffer[WS2812_BUFFER_SIZE];
void ws2812_init(void) {
memset(ws2812_buffer, 0, WS2812_BUFFER_SIZE);
ws2812_send_spi();
}
void ws2812_send_spi(void) {
HAL_SPI_Transmit(&WS2812_SPI_HANDLE, ws2812_buffer, WS2812_BUFFER_SIZE, HAL_MAX_DELAY);
}
#define WS2812_FILL_BUFFER(COLOR) \
for( uint8_t mask = 0x80; mask; mask >≥ 1 ) { \
if( COLOR & mask ) { \
*ptr++ = WS2812_SPI_1; \
} else { \
*ptr++ = WS2812_SPI_0; \
} \
}
void ws2812_pixel(uint16_t led_no, uint8_t r, uint8_t g, uint8_t b) {
uint8_t * ptr = &ws2812_buffer[24 * led_no];
WS2812_FILL_BUFFER(g);
WS2812_FILL_BUFFER(r);
WS2812_FILL_BUFFER(b);
}
void ws2812_pixel_all(uint8_t r, uint8_t g, uint8_t b) {
uint8_t * ptr = ws2812_buffer;
for( uint16_t i = 0; i < WS2812_NUM_LEDS; ++i) {
WS2812_FILL_BUFFER(g);
WS2812_FILL_BUFFER(r);
WS2812_FILL_BUFFER(b);
}
}
The only easy-to-miss detail is the color order: the LEDs expect GRB, not RGB.
How to use it
The flow is simple:
- Call
ws2812_init()once during startup. - Update one LED with
ws2812_pixel()or all LEDs withws2812_pixel_all(). - Call
ws2812_send_spi()when the frame is ready.
The HAL_SPI_Transmit() version is blocking, but the transfer is short enough for many small strips. For example, eight LEDs take well under a millisecond.
Testing
I tested the actual bitrate limits with a setup of 8 leds:
| type | min bitrate | max bitrate |
|---|---|---|
| WS2812 | 2.25 Mb/s | 10 Mb/s |
| WS2812B | 2.25 Mb/s | 8.25 Mb/s |
That matches the theory quite well. In practice, I would choose 3 to 6 Mb/s to leave some margin on both sides.
One extra observation from testing: even though the bit timing itself is fairly tolerant, the latch/reset gap still needed to be close to the datasheet value. Using at least 50 µs low time remained the reliable choice.
Using DMA
This code can easily be upgraded using DMA. Just enable DMA with the SPI peripheral in the STM32CubeIDE device configuration. Then change ws2812_init() to start the first DMA transfer.
void ws2812_init(void) {
memset(ws2812_buffer, 0, WS2812_BUFFER_SIZE);
HAL_SPI_Transmit_DMA(&WS2812_SPI_HANDLE, ws2812_buffer, WS2812_BUFFER_SIZE);
}
stm32g4xx_it.c
void DMA1_Channel1_IRQHandler(void)
{
HAL_DMA_IRQHandler(&hdma_spi2_tx);
HAL_SPI_Transmit_DMA(&WS2812_SPI_HANDLE, ws2812_buffer, WS2812_BUFFER_SIZE);
}
That keeps sending complete frames in the background, so any buffer update becomes visible on the next cycle. The exact DMA channel and IRQ name will depend on the STM32 family you use.
If you prefer one-shot updates instead of continuous refresh, call HAL_SPI_Transmit_DMA() only when you have a new frame ready and do not restart it in the interrupt handler.