NeoPixel LED Lights with STM32 HAL, Timer & DMA

Links marked with a star (*) are Amazon affiliate links that help covering the costs of running the TheVFDCollective.com and keeping this blog ad free!

chipsncoffee.jpg

Hey, finally, a tech tutorial! Indeed, and it’s all about my favorite LED: In this tutorial I’d like to show you how to hook up the gone viral digital LED WS2812B/SK6812, made famous by Adafruit as NeoPixel to an STM32 microcontroller with the STM32 HAL.

So why STM32 and not Arduino? Why not! There are a whole bunch of libraries for Arduino, but I’ve seen few people controlling NeoPixel using the STM32 HAL, so guess what, it’s our turn to do so. It is actually not so much of a bad idea to try your project using an STM32 either: The STM32F042 we gonna use in this project boasts with built in peripherals, has USB and is very reasonably priced.

Step 1: Things you’ll need

The first step couldn’t be easier and consists of gathering the things you’ll need to get this little project done:

  • An STM32 board. I’m using a NUCLEO-F042K6, which is the size and pinout of an Arduino Nano*

  • An LED strip of NeoPixel* LEDs. I’ll be using the SK6812, which is the WS2812B RGB LED plus an additional warm white LED. How cool is that? Since I don’t have an LED strip, I simply chained a few breakout boards with LED together, same thing

  • A breadboard and some jumper wires

    Simply connect the 5 V terminal to the 5 V terminal on the LED strip, any ground pin to the GND terminal of the LED ground, and pin A0 of the Nucleo to DIN of the LED strip.

Wait a second. The microcontroller operates at 3.3 V and the LED uses 5 V, we need to make sure the LED ‘understands’ 3.3 volts. How do we do that? I cheated a little bit by adding a 0.6 V diode to the power line to create 4.4 V. It’s not the end of the world for just 8 LEDs, but you want to use a more elegant and power efficient solution when you want to control more LEDs. You can build a simple level shifter using an NMOS and a PMOS transistor, or an out of the box solution buffer, 74HC125 for example to overcome this issue.

Instead of an LED strip, I've soldered eight breakout boards, with NeoPixel soldered to it, together

Instead of an LED strip, I've soldered eight breakout boards, with NeoPixel soldered to it, together

Step 2: STM32CubeMX

Now it’s time to setup our microcontroller. The most comfortable way to do so is to use STM32CubeMX, where you just click together how your microcontroller should be set up, and the cube picks all the drivers needed and generates nasty configuration stuff for you. Here are the things you want to set up after picking NUCLEO-F042K6* by starting a new project:

Project setup: Picking the NUCLEO-F042K6

Agree to initialize all peripherals to default. There are several ways how you can control the NeoPixel LED, I’ll show you a very resource saving way of using one circular buffered DMA (direct memory access) and one timer set up as PWM output. Let’s do the project setup first, and talk about exactly how it works in a second.

Pinout & Configuration

In Timers go to TIM2. You want to select the following in Mode:

  • Clock Source to Internal Clock

  • Channel1 to PWM Generation CH1

Leave everything else just like how it is and continue with Configuration below:

  • Counter Settings - Counter Period to 60 - 1

Pin diagram of the STM32F042 microcontroller

Pin diagram of the STM32F042 microcontroller

And keep everything else untouched. Next in System Core, go to DMA, and click on Add. You should be able to pick TIM2_CH1 from the drop down menu. Set it to:

  • Direction: Memory to Peripheral

  • Mode: Circular

  • Increment Address: Memory

  • Peripheral Data Width: Word

  • Memory Data Width: Byte

Clock Configuration

In the System Clock Multiplexer, select HSI48 as SYSCLK clock source. If performance doesn’t matter and you want to reduce the CO2 footprint of your microcontroller and its effect on global warming , leave the HSI 8 MHz clock to save some energy.

Project generation

We will be using SystemWorkbench as IDE. So in the Project Manager tab, select SW4STM32, aka. System Workbench as IDE and smash the blue Generate Code button!

Step 3: How does the NeoPixel work?

Lights, chips and coffee*

Lights, chips and coffee*

A very obvious reason why the NeoPixel took over the maker world is what we just saw in step one: It requires exactly one data wire to operate, power aside. Back in the days, you would set up a multiplexed RGB LED matrix, controlling each row and column with a transistor drivers and current limiting resistors. With NeoPixel instead, we’re lazy and we speak to a tiny controller inside the NeoPixel, and simply tell it to turn on the LEDs for us. Through this single wire we say how bright we want to turn the green, red, blue and warm white component of each LED in the chain.

What one little controller inside NeoPixel does is that from all the data shuttled to it, it simply eats one chunk of color data, and passes the remaining chunks to the next LED. To explore exactly how to speak the NeoPixels language (protocol), we can simply take a look into the datasheet.

Figure 1: Actual oscilloscope recording of waveform setting eight RGBW NeoPixel LED colors. That’s NeoPixelese. It’s time to learn it together!

It says that data pulses are transferred, really fast, at a fixed frequency of 800 kHz. For each color component, we have 8 bits, which correspond to 256 different steps, with 0 being turned off (0% brightness) and 255 being maximum bright (100% brightness). First green, then red, then blue and finally white. Here are some questions to get you warmed up. We’ll work with these numbers in a minute:

  • We have 4 color components. (R)ed, (G)reen, (B)lue and (W)hite. How many bits does it take to set one NeoPixel color? #BITS PER PIXEL = 8 Bit * 4 colors = 32 Bit

  • How many bytes is that? #BYTES PER PIXEL = 32 Bit / 8 = 4 Bytes

  • How much time does it take to send one bit? T_Bit = 1 / f_Bit = 1 / 800 kHz = 1.25 µs

  • How much time does it take to send one byte? T_Byte = 8 Bit * T_Bit = 10 µs

  • How much time does it take to send a NeoPixel color? T_RGBW = T_Byte * 4 colors = 40 µs

  • How much time does it take to send 8 NeoPixel colors? T_refresh = 8 * T_RGBW = 320 µs. Looking at the waveform duration in figure 1, that’s pretty close, huh? BTW, the datasheet calls this a data refresh cycle

  • How does the NeoPixel know whether the bit I’m sending is a 0 or 1? Let’s figure out together!

Figure 2: Timing waveform of the SK6812 ‘NeoPixel’ LED

Figure 2: Timing waveform of the SK6812 ‘NeoPixel’ LED

Take a look at figure 2. As you can see, bits are sent as rectangular shaped pulses, one after another, MSB (most significant bit) first. We tell zeros and ones apart by looking at how long the pulse is switched on (5 V), and how long it is switched off (0 V) during the the pulse length T_Bit we just calculated. In uRbAN geEktiONaRy, you’ll find the term pulse width modulation, and we’re just varying the duty cycle to send 0 and 1. By looking at the pulses in figure 3, which is just figure 1 zoomed in a bit, can you tell why it is a dim color? How would a bright color look like?

Figure 3: Zoomed waveform of a NeoPixel color information transfer

Figure 3: Zoomed waveform of a NeoPixel color information transfer

To me a question arises at this time. When do you know that a data refresh cycle has ended? Here, the reset pulse comes in. As soon as we’ve finished sending each color to its corresponding NeoPixel, we tell it that we’re done by flagging reset, which is pulling the data line low, 0 V, for at least 50 µs. Let’s do a final, hypothetical calculation just for fun to get the idea of what the limits of the NeoPixel LED are:

Assume you want to build a display using a matrix of NeoPixel, say for a huge billboard at Times Square. In order to make the human eye think a picture on a screen is continuous, it needs to be refreshed at at least 30 frames per second. How many SK6812 RGBW LEDs can you use, assuming a transmission delay of 0.5 µs between each LED? How many WS2812B RGB LEDs can you use?

WS2812 & 3528 white LEDs. SK6812 merges them into one package!

WS2812 & 3528 white LEDs. SK6812 merges them into one package!

  • SK6812: #LEDs ≈ (1 / 30 Hz - 50 µs) / (40 µs + 0.5 µs) = 821

  • WS2812: #LEDs ≈ (1 / 30 Hz - 50 µs) / (30 µs + 0.5 µs) = 1091

You will actually find numbers close to these inside the datasheet. To control more than 800 LEDs, simply have more than one transfer channel. If you really wish to try this out in the wild, there are a more things to be aware of, power (supply) wise in particular.

Step 4: Let’s hatch a plan

Now comes the part where we think of how we want to program the STM32F0, so that it can speak with the NeoPixel. Just as I’ve said before, we want to look for a resource saving approach, and one way to do so is to use a Timer configured in PWM output mode, that gets it data from a DMA buffer.

Setting the Timer

First, let’s see how we need to configure our timer to get pulses shooting at 800 kHz. This is how the timer frequency is defined as:

  • f_TIM = f_CLK / (Timer Prescaler + 1) / (Counter Period + 1)

Remember we set f_CLK to 48 MHz? Now for f_TIM to be 800 kHz, we need to either increase the prescaler value, or increase the counter period. Increasing the prescaler means fewer steps for the counter period, so we’ll keep the prescaler 0 and maximize the counter period. Turns out that for

  • Counter period = 60 - 1 = 59

we exactly get 800 kHz for f_TIM. Yay! So, when we output a pulse, 59 corresponds to a 100% on pulse at 5V, and 0 corresponds to 0% on pulse at 5V. By looking at figure 2, what’s the counter value we need for a logical 0, and a logical 1 pulse?

  • For a logical 0, the pulse is switched on 32% of the time. That’s 59 * 32% ≈ 19

  • For a logical 1, the pulse is switched on 64% of the time. That’s 59 * 64% ≈ 38, which is just twice of 0’s time!

From NeoPixel Color Array to DMA Buffer

Every time we update our DMA buffer, we need to copy and translate NeoPixel color data to it. A simple way to store the color data is to use an array, which we’ll call rgb_arr. When setting a color, we write into rgb_arr.

DSC_7460.jpg

Sending PWM data to the timer

In the second step we want to think about how we send our counter comparison values to the timer, and we’ll use DMA for that. Do you know what’s cool about DMA? Once you pass your chunk of data in your memory to the DMA, it takes care of sending it to the peripheral (timer in this case) all by itself. So it’s not taking up any computation time doing it, and you can do whatever you want in that time.

Now how large do we want our chunk of data (buffer, from now on) to be? Let’s do a calculation. We have 8 NeoPixel. That’s 4 * 8 = 32 different colors. Remember that each color is made up of 8 bits? If we want to represent them in terms of the counter comparison value, which is a 8 bit (1 byte) number itself, it’s 32 * 8 = 256 bytes just for 8 LEDs. We’re cool with that because our STM32F042 microcontroller has 6144 KB of RAM. How about 100 NeoPixel then? That’s 4 * 100 = 400 different colors, and 3200 bytes occupied just for the DMA data. That’s an awful lot and clearly we’d like to find a less wasteful solution. Here, figure 4 comes in. Have a look at it!

Figure 4: Flowchart of one NeoPixel data refresh cycle implemented with timer PWM and double buffered DMA

Figure 4: Flowchart of one NeoPixel data refresh cycle implemented with timer PWM and double buffered DMA

Instead of keeping the PWM data for all NeoPixel at any time, we just keep PWM data for the two upcoming NeoPixel in a circular buffer. So we have a buffer which keeps just 2 * 4 color components * 8 bytes = 64 bytes. Why does it make sense? While the upper buffer is being transferred, we update the lower buffer, and vice versa, that’s the idea. At the very beginning of our refresh cycle (start), left chart, we fill our buffer with the 0th and 1st NeoPixel color, and pass the buffer onto our DMA controller. While we’re busy doing some other stuff, DMA will call us back some time, telling us we’re half done (transfer half done). At this time, the DMA has sent the first 32 bytes (0th pixel), so it still has another 32 to go (1st pixel). Since we don’t need the upper half of the buffer any more, we can replace it with the data of the 2nd NeoPixel. After a while, the DMA gets back at us once again, this time telling us it has done sending all of the 64 bytes (transfer done). We know the 0th and 1st pixel are sent by now.

Figure 5: Hand simulation of double buffered DMA color refresh cycle for 8 LEDs

Time to place the lower half with the 3rd NeoPixel data. Since our DMA is in circular mode, it will instantly resume transferring data of the 2nd NeoPixel and call us back again when it’s half done (transfer half done), so we can fill the upper half with the 4th LED data, you get the idea. In case you’re the visual learner type, let’s do a little simulation by hand in figure 5 with all of our 8 LEDs. Blue stands for updated, new data which overwrites what has previously been ther

But what is happening when we hit the last, 7th LED? Now we’ve sent all our data, but remember a color refresh cycle is closed by sending reset pulse that is longer than 50 µs? One way to do so is to fake fill and send two more NeoPixel colors, but now not with a logical ‘0’, but with the number 0. How long is sending the 1/2 and 2/2 reset pulse? Yes, 80 µs it is. That’s more than enough!

 

Step 5: Finaly, the code!

Code from Fluorescence firmware version 3.0. It uses the exact same technique talking to NeoPixel!

Code from Fluorescence firmware version 3.0. It uses the exact same technique talking to NeoPixel!

That’s what we’ve done so far: We learned how and what it takes to talk to NeoPixel and thought about what our program on our STM32 microcontroller side should do to achieve this. Now we’re more than ready for the code, I’m excited and got a little impatient already, so let’s go. First, create a separate source file in SystemWorkbench by clicking on the project and adding a new Header (.h) and Source (.c) file pair. Give it a name for example SK6812.h/c or NeoPixel.h/c. To use the STM32 HAL with our source files, we need to include the HAL. Since we’ll be working with our timer TIM2, we need to ‘import’ that as well. The first lines of the .c source looks like this therefore:

// Peripheral usage
#include "stm32f0xx_hal.h"
extern TIM_HandleTypeDef htim2;
extern DMA_HandleTypeDef hdma_tim2_ch1;

Functions for our NeoPixel driver

This is the place to get creative. You can come up with any fancy LED function you want here, but for the tutorial, we’ll cover the most essential, driver level functions: Setting one NeoPixel at given index, setting all NeoPixel and a function to start the color refresh cycle. Place them into your header file:

#include <stdint.h>

void led_set_RGBW(uint8_t index, uint8_t r, uint8_t g, uint8_t b, uint8_t w);
void led_set_all_RGBW(uint8_t r, uint8_t g, uint8_t b, uint8_t w);
void led_render();

Now head over to the source (.c) file again, we start off with simple constant definitions, and our color array (rgb_arr), write buffer (wr_buf), and write buffer position tracker (wr_buf_p).

Constants

#define PWM_HI (38)
#define PWM_LO (19)

// LED parameters
#define NUM_BPP (4)
#define NUM_PIXELS (8)
#define NUM_BYTES (NUM_BPP * NUM_PIXELS)

color array and DMA buffer

// LED color buffer
uint8_t rgb_arr[NUM_BYTES];

// LED write buffer
#define WRITE_BUF_LEN (NUM_BPP * 8)
uint8_t wr_buf[WRITE_BUF_LEN];
uint_fast8_t wr_buf_p = 0;

Color Setting functions

Continue by implementing the simple functions that do nothing but set the NeoPixel color array at given positions to the color passed to it. Remember we have 4 colors? So to navigate to a pixel, we need to take 4 * index. As you can see, setting all colors is just calling the set function #LED times.

void led_set_RGBW(uint8_t index, uint8_t r, uint8_t g, uint8_t b, uint8_t w) {
  rgb_arr[4 * index    ] = g;
  rgb_arr[4 * index + 1] = r;
  rgb_arr[4 * index + 2] = b;
  rgb_arr[4 * index + 3] = w;
}

void led_set_all_RGBW(uint8_t r, uint8_t g, uint8_t b, uint8_t w) {
  for(uint_fast8_t i = 0; i < NUM_PIXELS; ++i) led_set_RGBW(i, r, g, b, w);
}

Kicking off the color refresh cycle

void led_render() {
  if(wr_buf_p != 0 || hdma_tim2_ch1.State != HAL_DMA_STATE_READY) {
    // Ongoing transfer, cancel!
    for(uint8_t i = 0; i < WR_BUF_LEN; ++i) wr_buf[i] = 0;
    wr_buf_p = 0;
    HAL_TIM_PWM_Stop_DMA(&htim2, TIM_CHANNEL_1);
    return;
  }
  // Ooh boi the first data buffer half (and the second!)
  for(uint_fast8_t i = 0; i < 8; ++i) {
    wr_buf[i     ] = PWM_LO << (((rgb_arr[0] << i) & 0x80) > 0);
    wr_buf[i +  8] = PWM_LO << (((rgb_arr[1] << i) & 0x80) > 0);
    wr_buf[i + 16] = PWM_LO << (((rgb_arr[2] << i) & 0x80) > 0);
    wr_buf[i + 24] = PWM_LO << (((rgb_arr[3] << i) & 0x80) > 0);
    wr_buf[i + 32] = PWM_LO << (((rgb_arr[4] << i) & 0x80) > 0);
    wr_buf[i + 40] = PWM_LO << (((rgb_arr[5] << i) & 0x80) > 0);
    wr_buf[i + 48] = PWM_LO << (((rgb_arr[6] << i) & 0x80) > 0);
    wr_buf[i + 56] = PWM_LO << (((rgb_arr[7] << i) & 0x80) > 0);
  }

  HAL_TIM_PWM_Start_DMA(&htim2, TIM_CHANNEL_1, (uint32_t *)wr_buf, WR_BUF_LEN);
  wr_buf_p = 2; // Since we're ready for the next buffer
}
animated.gif

Oh no, a lot of weird shifting and bitwise operators. No worries, let’s break it down in pieces. You might have noticed that led_render() is exactly start in figure 4. We fill the buffer initially with data of our first two (0th and 1st) NeoPixel colors: In total, we have a 64 byte buffer to set to either 19 (logical 0) or 38 (logical 1). So for each bit of the 4 colors, thus 8 times, we ask: Is my bit 0 or 1?

  • If the bit at i is 0, the term ((rgb_arr[0] << i) & 0x80) > 0 is 0. So wr_buf[i] equals
    PWM_LO << 0. A shift by 0 is a multiplication by one, so wr_buf[i] now holds 19

  • If the bit at i is 1, the term ((rgb_arr[0] << i) & 0x80) > 0 is 1. So wr_buf[i] equals
    PWM_LO << 1. A shift by 1 is a multiplication by two, so wr_buf[i] now holds 38, which is equal to PWM_HI

This is precisely what we want our timer counter comparison values to be set to. Finally we just say we can proceed to the 2nd NeoPixel, start the DMA transfer. In the DMA start call, we tell it which timer, which channel, where the write buffer it is, and how long it is.

The Color refresh cycle

STM32 HAL controlled SK6812 NeoPixel up and running!

STM32 HAL controlled SK6812 NeoPixel up and running!

How do we know the DMA transfer is half complete or complete? By pure chance, the STM32 HAL happens to have a function called HAL_TIM_PWM_PulseFinishedHalfCpltCallback that is triggered via interrupt, when a DMA transfer is half done, and HAL_TIM_PWM_PulseFinishedCallback when it’s completely done. Whatever you are doing at the moment, when the transfer is (halfway) done, the interrupt is triggered, and your main program is pushed aside until it has processed the code inside the interrupt. That’s perfect because we just wanted to take advantage of that to update our buffer with the next NeoPixel color, just before the circular buffer starts over!

void HAL_TIM_PWM_PulseFinishedHalfCpltCallback(TIM_HandleTypeDef *htim) {
  // DMA buffer set from LED(wr_buf_p) to LED(wr_buf_p + 1)
  if(wr_buf_p < NUM_PIXELS) {
    // We're in. Fill the even buffer
    for(uint_fast8_t i = 0; i < 8; ++i) {
      wr_buf[i     ] = PWM_LO << (((rgb_arr[4 * wr_buf_p    ] << i) & 0x80) > 0);
      wr_buf[i +  8] = PWM_LO << (((rgb_arr[4 * wr_buf_p + 1] << i) & 0x80) > 0);
      wr_buf[i + 16] = PWM_LO << (((rgb_arr[4 * wr_buf_p + 2] << i) & 0x80) > 0);
      wr_buf[i + 24] = PWM_LO << (((rgb_arr[4 * wr_buf_p + 3] << i) & 0x80) > 0);
    }
    wr_buf_p++;
  } else if (wr_buf_p < NUM_PIXELS + 2) {
    // Last two transfers are resets. 64 * 1.25 us = 80 us == good enough reset
    // First half reset zero fill
    for(uint8_t i = 0; i < WR_BUF_LEN / 2; ++i) wr_buf[i] = 0;
    wr_buf_p++;
  }
}

And that’s the right two diagrams in figure 4. Try to map the flowchart to the code and see how similar it is.

Time to make it shine!

Hey, we made it! Now it’s time to go to the main function and make it shine! While you can do whatever you want, here’s a classical color cross fade, which you’ll also find in Fluorescence! It works by working in the HSL color space and just cycling through the color spectrum wheel. The color difference is added by multiplying the i-th NeoPixel with an angle_difference.

uint8_t angle = 0;
const uint8_t angle_difference = 11;
while (1) {
  for(uint8_t i = 0; i < 8; i++) {
    // Calculate color
    uint32_t rgb_color = hsl_to_rgb(angle + (i * angle_difference), 255, 3);
    // Set color
    led_set_RGB(i, (rgb_color >> 16) & 0xFF, (rgb_color >> 8) & 0xFF, rgb_color & 0xFF);
  }
  // Write to LED
  ++angle;
  led_render();
  // Some delay
  HAL_Delay(30);
}

Hook your STM32 Nucleo board to your computer, compile, hit debug in SystemWorkbench and have a colorful time!

Click on debug and upload your code to your STM32 NUCLEO board!

Click on debug and upload your code to your STM32 NUCLEO board!

GitHub Repository

Wonder where to find the hsl_to_rgb function? As always, the code is provided to you freely on GitHub along with the whole SystemWorkbench project. Enjoy!