Controlling WS2812(B) leds using STM32 HAL SPI

And optionally using DMA

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:

  1. Encode each WS2812 bit as one SPI byte.
  2. Use 0x80 (10000000) for a logical 0.
  3. Use 0xFC (11111100) for a logical 1.
  4. Send 24 encoded bits per LED in green, red, blue order.
  5. Append enough zero bytes to keep the line low for at least 50 µs after the last bit.
  6. 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:

WS2812B protocol and timing
WS2812B protocol and timing

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 1 keeps MOSI high for that slice
  • an SPI 0 keeps MOSI low for that slice

So the real problem becomes:

  1. How many SPI bits should represent one WS2812 bit?
  2. Which pattern should represent LED 0 and LED 1?
  3. 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 0 can 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 1 can 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:

  1. total bit time must be at least 1.25 µs
  2. total bit time must stay below the reset threshold, roughly 9 µs
  3. logical 0 high time must be at least 62.5 ns
  4. logical 0 high time must stay below 0.50 µs
  5. logical 1 high 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 0 high time = number of leading 1s in the 0 pattern × T
  • logical 1 high time = number of leading 1s in the 1 pattern × T

The question is how large N needs to be.

Option 1: 3 SPI pulses per WS2812 bit

This is the smallest practical encoding:

  • 0100
  • 1110

That gives:

  1. 3T ≥ 1.25 µsT ≥ 417 ns
  2. 3T < 9 µsT < 3000 ns
  3. T ≥ 62.5 ns
  4. T < 500 ns
  5. 2T ≥ 625 nsT ≥ 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:

  • 01000
  • 11110

Now the limits become:

  1. 4T ≥ 1.25 µsT ≥ 313 ns
  2. 4T < 9 µsT < 2250 ns
  3. T ≥ 62.5 ns
  4. T < 500 ns
  5. 3T ≥ 625 nsT ≥ 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:

  • 010000000
  • 111111100

The limits become:

  1. 8T ≥ 1.25 µsT ≥ 157 ns
  2. 8T < 9 µsT < 1125 ns
  3. T ≥ 62.5 ns
  4. T < 500 ns
  5. 6T ≥ 625 nsT ≥ 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
  • 0 maps to 0x80
  • 1 maps to 0xFC
  • 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 bitratemax bitratereset pulses (50 µs) @ max bitrate
31001102.00 Mb/s2.39 Mb/s120
4100011102.00 Mb/s3.19 Mb/s160
810000000111111002.00 Mb/s6.36 Mb/s318

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:

  1. Call ws2812_init() once during startup.
  2. Update one LED with ws2812_pixel() or all LEDs with ws2812_pixel_all().
  3. 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:

typemin bitratemax bitrate
WS28122.25 Mb/s10 Mb/s
WS2812B2.25 Mb/s8.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.