Embedded C Programming with the PIC18F14K50 - 10. Controlling WS2812B LEDs
Hi there! Today we will try to do something that is nearly impossible - control the WS2812B LEDs using a PIC18F14K50 MCU. Why is it impossible? Because these LEDs require very short timings which are not achievable with our MCU, even if we use the maximum clock rate. But don’t worry - we’ll manage!
WS2812B LEDs Introduction
Before we proceed, let’s first consider what the WS2812B LEDs are. The LED itself looks like a typical 5050 package SMD LED (Figure 1).
But inside it there is a special circuit which allows us to control the LED color and brightness with a one-wire digital protocol. Moreover, these LEDs can be connected in a daisy-chain, which makes it possible to control a line of these LEDs with only one MCU pin.
Thus these LEDs often are available not as standalone devices but as lines (Figure 2), strips (Figure 3), rings (Figure 4), matrices (Figure 5) etc.
And all these circuits are controlled with just one MCU pin!
Let’s now consider the digital protocol and the LEDs connection. I will take the image from the datasheet of the WS2812B LED (Figure 6).
So this protocol is quite simple: to transmit “0” we send the pulse with the width of T0H, and then pause (or go low) the width of T0L. The same with sending “1”. To send the reset state, we need to issue the negative pulse of the Treset width. “Quite simple” - you may say. Yes, that’s true. But let’s look at the timings provided in the same datasheet (Table 1).
Table 1 - WS2812B Timings
Data Transfer Time (TH+TL=1.25µs±600ns)
|T0H||0 code, high voltage time||0.4us||±150ns|
|T1H||1 code, high voltage time||0.8us||±150ns|
|T0L||0 code, low voltage time||0.85us||±150ns|
|T1L||1 code, low voltage time||0.45us||±150ns|
|RES||low voltage time||Above 50µs|
As you see, the pulse widths are very short: 0.4 - 0.85 us (I don’t take into account the Reset pulse, it’s quite long, and there is no problem with it). So the discreteness of the timing cannot be more than 0.05 us which requires the frequency of 1 / 0.05 us = 20 MHz. And currently the PIC18F14K50 has only 1 MHz. Moreover, each instruction in the PIC MCU is implemented during 4 ticks of the CPU clock, so the real operating frequency is just 1 MHz / 4 = 250 kHz. And this is a big problem we need to solve somehow. But let’s put it aside for a while and return to the WS2812B LED control.
To set the color of the LED we need to send the reset pulse followed by a packet of 24 bits. These bits represent the RGB components of the LED color (Figure 7).
So, as you see, the upper 8 bits set the green component, the middle 8 bits set the red component, and the lower 8 bits set the blue component.
As follows from Figure 6 the LEDs can be cascaded by connecting the DOUT pin of the previous LED with the DIN pin of the next LED. To send the data to several LEDs connected in a chain you need to do the following:
- Issue the reset pulse.
- Send a packet of 24 bits to set the color of the first LED.
- Without issuing the next reset pulse send another packet of 24 bits. They will be transparently transmitted to the next LED and set its color.
- Repeat step 3 as many times as how many LEDs you have.
I think this information is enough to start working with the WS2812B LEDs, so we can now formulate the task for the current tutorial: control a line of the WS2812B LEDs using the PIC18F14K50 MCU (the form and number of LEDs depends on what you have: line, ring, strip, matrix or other shape). As you see, the task is not surprising, so let’s try to implement it.
First, we need to consider the schematics diagram that implements the given task (Figure 8).
In this schematics you see the same MCU PIC18F14K50 (DD1) and the programmer PICKit (X1) as well as the set of WS2812B LEDs (LED1, LED2, … LEDn). I skipped the capacitors which are required for normal operation of the LEDs. They should be connected between the VSS and VDD pins of each LED and have a value of 0.1uF. Frankly, I expect you to use some ready board or strip, on which these capacitors are already installed. By the way, even though I didn’t mention this before, it’s a good idea to connect a couple of capacitors between the VDD and VSS pins of the MCU as well, with values of 0.1 uF and 10 uF, this will increase the stability of the MCU operation.
Personally, I have a simple LED strip shown in Figure 3 which consists of just 8 LEDs, so I will use it. But the program code will be expandable to any number of LEDs.
Before we start considering the program code, we need to solve the timings issue. To figure out how to do this, we need to get acquainted with the oscillator module. It provides the clocking of the CPU and the MCU peripheral modules. I already mentioned it in tutorial 2, when I talked about the configuration bits. And I said that by default the CPU is clocked with an external RC oscillator, and we need to switch it to the internal oscillator.
Actually there are several clock sources, both external and internal, but this time we will consider in detail only the internal high-frequency oscillator. We already have it enabled by setting the configuration bits as described in tutorial 2. Now we just need to configure it.
So the high-speed internal oscillator (also called HFINTOSC in the datasheet) runs on different frequencies which are determined by the IRCF (Internal RC oscillator Frequency select) bits of the OSCCON (OSCillator CONtrol) register. There are also other bits in this register but we won’t consider them now. The dependency of the IRCF bits and the oscillator frequency is shown in Table 2.
Table 2 - HFINTOSC Frequency Selection
|3||0||1||1||1 MHz (default)|
So we can overclock the CPU up to 16 MHz using the HFINTOSC module, but as I mentioned in the previous chapter, the real instruction implementation speed will be just 16 / 4 = 4 MHz which is still not enough. So is there any other way to overclock the CPU even more?
The answer is an affirmative. And to do this we need to use the so-called 4x Phase Lock Loop (PLL) Frequency Multiplier module. As follows from its name, it multiplies the input frequency 4 times. And here one could say “Wow! So we can multiply 16 MHz by 4 and get 64 MHz frequency!” Unfortunately, no. The PLL circuit is more adapted to work with an external primary oscillator, and it can multiply its frequency in the range of 4 MHz to 12 MHz, so the maximum CPU frequency can be 12 x 4 = 48 MHz if using an external oscillator. For the HFINTOSC we can only select the 8 MHz frequency to work with the PLL. So the maximum achievable frequency from the internal oscillator with the enabled PLL is 8 x 4 = 32 MHz. So we will use it, as we can’t get more for now.
To enable the PLL, we need to set the bit SPLLEN in the OSCTUNE register. This register is available from the program code. The other way to enable it is to set the PLLEN bit in the configuration register CONFIG1H, which is available only during the MCU programming.
For now that’s all we need to know about the oscillator module to implement the current task, so we can proceed to the programming code.
Program Code Description
#define _XTAL_FREQ 32000000 //CPU clock frequency
#define LED_NUM 8 //Number of LEDs
uint32_t color; //Current color
uint8_t overflow; //Color value overflow flag
void ws2812_color (uint32_t color) //Set the color of the LED
for (uint8_t i = 0; i < 24; i ++) //The loop to set 24 bits (one full LED)
LATBbits.LATB6 = 1; //Set the RB6 pin high to start the pulse
if (color & 0x800000)//If the MSB of the color is 1
__nop(); //Then perform a delay for “nop” 3 commands
LATBbits.LATB6 = 0; //Set the RB6 pin low to finish the pulse
color <<= 1; //Shift the color value
void ws2812_reset (void) //Reset the LED
LATBbits.LATB6 = 0; //Set RB6 pin low to start the reset pulse
__delay_us(50); //Perform a 50us delay
TRISBbits.TRISB6 = 0; //Configure RB6 pin as an output
LATBbits.LATB6 = 1; //Set RB6 pin high
OSCCONbits.IRCF = 6; //Set CPU frequency as 8 MHz
OSCTUNEbits.SPLLEN = 1; //Enable 4xPLL
color = 0x000FFF; //Set the initial color
while (1) //Main loop of the program
ws2812_reset(); //Reset the LED interface
for (uint8_t i = 0; i < LED_NUM; i ++)//Loop to send to all LEDs
ws2812_color(color << i); //Send the specific color to each LED
if (color & 0x800000) //If the MSB of the color is 1
overflow = 1; //Then set the overflow flag as 1
overflow = 0; //Set the overflow flag as 0
color <<= 1; //Shift the color at the 1 bit to the left
color += overflow; //And add the overflow value as an LSB
__delay_ms(100); //Perform the delay between the color change
This program is much shorter than the previous one but it doesn’t have any repeating parts.
In line 1 we define the _XTAL_FREQ macro. But please pay attention that this time its value is 32000000 instead of 1000000 because we will use the 32MHz frequency.
In line 2 we define the macro LED_NUM which defines the number of LEDs that are connected in series to the pin RB6. As I mentioned before, I have a simple line of 8 LEDs, so I defined LED_NUM as 8.
Next we define two variables: color of type uint32_t (line 6) to set the LED color, and overflow of type uint8_t (line 7) which will be used in shifting the LED colors.
In lines 9-23, there is a function ws2812_color which accepts the parameter color and sends 24 bits to the LEDs. This function has some tricks to decrease the execution time to meet the timings requirements.
In line 11, we start the “for” loop from 0 to 24 to send 24 bits to the LED. At the beginning of the loop we set the RB6 pin high (line 13) to start the pulse (Figure 6). Then we check the MSB of the color parameter (line 14). Let’s consider this line in more detail.
The “&” sign means the bitwise AND operation. The constant 0x800000 in binary representation is 0b100000000000000000000000, so only its most significant bit is “1”. When we implement the “&” operation between the color variable and 0x800000, the result will be true only if the MSB of the color variable is “1”, in all other cases it will be 0.
So if MSB of the color is “1” we need to implement the 0.8 us delay (T1H time) and if it is “0” the delay should be 0.4 us (T0H time). As we remember the CPU frequency is 32 MHz, and the instruction execution frequency is 32 / 4 = 8 MHz which means that the instruction execution time is 1 / 8 MHz = 0.125 us. The trick is that the time of execution of lines 13-14 is 0.5us. So we don’t need any extra delay to send the “0” value. And the 0.5 us value fits into the 0.4 ± 0.15 us range, so for “0” we have success! To transmit “1” we need to add at least 0.3 us which we do by calling the three assembly-based functions __nop() (lines 16-18). In this case the overall delay will be 0.5 + 0.125 * 3 = 0.875us which also fits into the range of 0.8 ± 0.15 us. In fact, the real pulse width is 0.75us (I’ll explain why in a moment) which also fits into the desired range. Now we’re good with the positive pulse, and can finish it by setting the RB6 pin low (line 20).
This paragraph is for those readers who want to know more about how it’s done underneath and why we got .75us instead of the expected .875us. If you don’t care, you may just skip it. It’s possible to see the disassembly of the produced code. To do this, you need to select the “Window” in the main menu, then “Target Memory Views” and finally “Program memory”. Then the “Program memory” tab will appear. You may scroll down the addresses and find the part that implements this pulse (Figure 9).
If you read the previous PIC10F200 tutorials series you may see the familiar instructions. In line 8094 we set bit 6 of the LATB register with the BSF instruction. In line 8095, we check the MSB of the memory address 0x03, I think this is the equivalent of the line if (color & 0x800000) in the initial program. The instruction BRA is the unconditional branch (like GOTO), and its implementation requires 2 cycles. This instruction sets the program counter to the address 0x3846 where we clear bit 6 of the LATB register. Let’s count the number of cycles required to send “0”: BSF (1) + BTFSS (1) + BRA (2) = 4, 4 x 0.125 = 0.5 us. And for “1”: BFS (1) + BTFSS (1) + NOP (1) + NOP * 3 (3) = 6, 6 x 0.125 = 0.75 us (The first NOP is called explicitly if the condition of the BTFSS instruction is false). Now, let’s return to the initial program.
In line 21, we shift the color value one bit to the left (if you don’t know, the “<<” operator means shift to the left, and “>>” operator means shift to the right by the specified number of bits). So at the next loop iteration we will check the next bit of the color parameter and send the output pulse according to it.
You can also calculate the number of cycles required to implement the pause between pulses. It consists of lines 20, 21, and 11. I will give you the final result which is 1.375 us for both “0” and “1”. This value is much bigger than the T0L and T1L from table 1. But it turns out that the LED’s logic can accept this value as well. Actually any delay lower than 20us is fine. A longer delay than 20us will be considered as a “reset” pulse.
The transmission of one 24-bit packet taken by the logic analyzer is shown in Figure 10, and the timings for 0 and 1 are shown in Figure 11 and Figure 12, respectively.
OK, finally we have finished with the ws2812_color function, and now can consider the next one, called ws2812_reset which is located in lines 25-29. It’s quite simple and will not take a lot of time. First we set the RB6 pin low to start the reset pulse (line 27), and then perform the delay of 50 us by issuing the function __delay_us (line 28). We don’t return the RB6 line high here because this will distort the first pulse of the data packet, and if the delay is longer than 50 us, it will not cause any problems.
These are all functions that are required to operate with the WS2812B LED. Now we can proceed to the main function of the program (lines 31-52). As usual, we first need to initialize the peripheral modules of the MCU.
In line 33 we configure the RB6 pin as an output and then in line 34 set it high. In lines 35-36 we configure the oscillator module. In line 35, we set the internal oscillator frequency to 8 MHz by setting the IRCF bits of the OSCCON register according to table 2. In line 36, we enable the PLL module by setting the SPLLEN bit in the OSCTUNE register. After that, the CPU frequency will become 32 MHz instead of 1 MHz.
In line 37, we assign the 0x000FFF value of the color variable. According to Figure 7 this value corresponds to the blue color with a slight red hue.
That’s all the initialization required, so we can proceed to the main loop of the program (lines 39-51). Here I implemented a kind of running line where the color gradually changes from one LED to another.
First, we issue the reset state at the LED interface (line 41). Then we send the packets to each available LED with the “for” loop (line 42). In line 43 we shift the color to the right to the number of bits corresponding to the i variable value. This will make an effect of the gradient.
In lines 44-47 we save the MSB of the color variable in the overflow variable. So if the MSB of the color is “1” (line 44) we assign “1” to the overflow (line 45), otherwise (line 46) we assign “0” to it (line 47). Then in line 48 we shift the color variable at the one bit to the left, and finally add the overflow value to the color (line 49). The meaning of lines 44-49 is the following. We want to shift the color value so that in the next loop iteration each LED will have a new color. But this shift operation isn’t rotated, so the MSB of the shifted value doesn’t become its LSB and is lost forever. To prevent this we need to use the special variable and manually move the MSB to LSB.
Finally, we implement the delay to slow down the colors changing (line 50).
And that’s all about the program. As you can see, it’s not that difficult overall, but quite tricky in some places. Now you can assemble the circuit according to Figure 8, compile and download the code into the MCU, run it, and… see nothing, just all white LEDs.
So what is wrong?
Actually, nothing, we just need to change one more setting - optimization level. This is the parameter that tells the compiler how to optimize the code. The thing is the C language is not native for the MCUs, and it’s first translated into Assembly and then to machine codes. And the C operations and commands can be translated in many different ways. The optimization allows the compiler to merge or remove some instructions where it is possible, and thus reduce the code size. There are several optimization levels: 0, 1, 2, 3, and s. The higher the number, the deeper is the optimization, and “s” stands for the “size” which means the optimization that leads to the least code size. The 3’rd level makes the operation speed as the highest priority, so we will use it.
To change the optimization level, we need to open the project properties by right clicking on the project name and selecting the “Properties” point. In the opened window we need to choose the XC8 Compiler point in the left part of the window (Figure 13).
Then in the “Option categories” drop-down list we need to select the “Optimizations” point and then choose the Optimization level “3” (Figure 14).
Then click OK, and compile and run the code one more time. Now everything should work as desired.
And now that’s really all. In this tutorial, we learnt how to configure the oscillator module, and how to control the WS2812B LEDs. Even though the PIC18F14K50 MCU doesn’t have enough speed, the range of the WS2812B timings allows it to successfully work with them.
As homework, I suggest you change the mode of the LEDs operation in any way you want: gradient, changing the brightness, switching the colors etc. But take into account that the operations with the colors should be short enough not to cause the LED reset state.
In the next tutorial I’ll show how to configure the oscillator module using the MCC.
Get the latest tools and tutorials, fresh from the toaster.