Recently I re-discovered that I have a Fipsy FPGA Breakout Board. Back in 2018 when I received this board, I made a version of blinky with five LEDs, lighting up red-green-yellow-blue-white, one at a time. Now, I want to play with something more colorful: NeoPixels!
What is a NeoPixel?
The WS2812 Integrated Light Source, also known as NeoPixel, is a simple, scalable, and affordable full-color LED. Neopixels are commonly found on Adafruit-branded development boards such as the MagTag. They are also available standalone in a zillion form factors. The unit I have is Keyes 18-LED ring WS2812 module that contains a string of 18 WS2812 pixels.
Each WS2812 is a surface-mount package that integrates RGB LEDs alongside a driver chip, forming a complete control of a pixel point. The WS2812 is controlled through a single wire, in which "0" and "1" bits are encoded in the timing of high and low voltage states. During a data refresh cycle, a WS2812 chip accepts 24 bits of data for its green, red, and blue color components, and then forwards any subsequent bits to the next WS2812 chip. This forwarding feature enables cascading: when a string of pixels receives a sequence of 24-bit words, the initial 24 bits go to the first pixel, the next 24 bits go to the second pixel, and so on. Theoritically, you could control a zillion pixels through a single wire.
The WS2812 datasheet has detailed information on the control signal format and timing requirements:
Adafruit has a well maintained Adafruit NeoPixel Library that allows controlling WS2812 NeoPixels from various microcontrollers. Using an Arduino example in this library, I tested my light ring with an ESP32 to confirm that the hardware works.
First Pixel on FPGA
Searching GitHub, I managed to find some Verilog code that can control the NeoPixels: Neopixel_TX_Core. It was written for Xilinx Spartan-6 series, so that it would not run directly on the Lattice MachXO2-256 chip on the Fipsy FPGA board. Nevertheless, WS2812 isn't a vastly complicated protocol, so that I'm confident that I can make it work.
I spent some time reading the existing code, and found that it has three modules:
- FIFO_WxD.v provides a First In First Out (FIFO) buffer.
- neopixel_tx_fsm.v implements the WS2812 protocol.
- neopixel_tx.v puts the above two modules together to allow buffered transmission.
I added all three files to a Lattice Diamond project, but couldn't get it to compile. Error message suggests that the design is too large to fit in the tiny MachXO2-256 chip. I knew the resource hog shall be the FIFO module, so that I decided to delete the FIFO as well as the neopixel_tx layer, and deal with neopixel_tx_fsm.v directly.
neopixel_tx_fsm.v is declared with these ports:
module neopixel_tx_fsm(
input wire clk, // 20MHz clock for FSM
input wire rst, // active low reset
input wire mode, // 1 = 800kHz speed, 0 = 400kHz
input wire tx_enable, // xmit enable flag so user can preload fifo
input wire empty_flg, // FIFO empty status indicator
input wire [23:0] neo_dIn, // data order is GRB with G7 (msb) transmitted first
input wire rgb_msgTyp, // 1 = color data message, 0 = reset message (51us low)
output wire rd_next, // read strobe for FSM
output wire neo_tx_out // neopixel data stream output
);
Within the module, the logic is driven by a Finite State Machine (FSM) with four states.
Whenever the module is idle and "FIFO is not empty", it would start sending either a reset message or a color data message.
Unless the reset signal is pulled low, the module would continue sending the current message.
Upon completion, it would return to the idle state, as indicated by rd_next
going high, and then check for what to do next.
The timing required by WS2812 is derived from the 20MHz input clock. For example, the T1H time specified in the WS2812 datasheet, 700±150ns, is generated by counting 12 clock cycles, which is 600ns. Unfortunately, the internal oscillator in the MachXO2 does not support the exact 20MHz frequency. I had to opt for the closest 20.46MHz frequency and hope it works.
It didn't take long to write the glue code that sends one reset message followed by one color data message, which should light up a single NeoPixel. However, it did not work at all: all my pixels are dark. I wired up some LEDs and inserted some code to reveal the state machine, and the LEDs suggest that the FSM is stuck in the idle state all the time, despite the condition should have been met.
I poked around the code but it just didn't work, until I changed this line:
neopixel_tx_fsm np(.rst(1'b1));
To this:
neopixel_tx_fsm np(.rst(PIN10));
The Fipsy FPGA does not have a reset button on the board, so that in my prior experiments, I did not connect the reset signals but simply tied them to VCC (i.e. not in reset). This worked fine for the counters, but apparently caused the neopixel_tx_fsm.v module to remain in an inconsistent state. As soon as the reset signal is connected, the module started showing signs of life: my first pixel lit up.
Static Colors
The next step would be lighting up all 18 pixels.
I made a 5-bit down counter that is decremented whenever rd_next
is high.
Then, I use combinational logic to determine the message type:
- If the counter is above 18, send a reset message.
- If the counter is less than 18, send a color data message.
However, only half of the LEDs would light up.
It turns out that the neopixel_tx_fsm.v module keeps rd_next
high for two clock cycles before reading the next message.
Therefore, I adjusted the counter as follows:
reg [5:0] index = 6'd0; // 6-bit down counter, decremented every clock cycle when rd_next is high
wire [4:0] pos = index[5:1]; // pixel position
wire ticker = index[0]; // "1" then "0" for each pixel position
Then I hooked up the combinational on the pos
value.
All 18 pixels are now glowing green.
The next step would be making them display different colors. While I'd like to have each of 18 LEDs display a different color, there isn't enough FPGA resources to define a 432-bit register for the required bits. Thus, I settled on three alternating colors:
- I have just one 24-bit color register.
- Upon reset, this register is initialized as green.
- For each subsequent pixel, the 24-bit color register is shifted by 8 bits, forming a green-blue-red cycle.
During development, I often found the pixels appearing white instead of the three colors. However, when I press and hold the reset button, each pixel shows one of the three colors. It was caused by the combination of several factors:
- The sequential logic driven by my 6-bit down counter is refreshing the LEDs very rapidly.
- The color shifting logic is executed every time
ticker
is high, for eachpos
. - A
pos
value could refer to either a pixel position or a reset command, but I was not distinguishing them, so that the color is shifted 32 times per counter underflow. - 32 is not divisible by 3 (colors), so that every pixel would be displaying a different color upon each refresh.
- Since the refreshes are happening rapidly, every pixel shows the sum of green, blue, and red, which totals to: white.
To solve this problem, I limited the color shifting logic to only execute when pos
refers to a pixel.
Therefore, the color is shifted exactly 18 times per counter underflow.
18 is divisble by 3, so that each pixel would receive the same color every time.
I uploaded the code of the first iteration, and posted pictures on several forums. Commenters are disappointed that I used so many circuits to turn on a light that does not even have animation.
The Map report from Lattice Diamond shows the FPGA resource usage at 30%:
Number of registers: 51 out of 322 (16%)
PFU registers: 51 out of 256 (20%)
PIO registers: 0 out of 66 (0%)
Number of SLICEs: 38 out of 128 (30%)
SLICEs as Logic/ROM: 38 out of 128 (30%)
SLICEs as RAM: 0 out of 96 (0%)
SLICEs as Carry: 6 out of 128 (5%)
Number of LUT4s: 75 out of 256 (29%)
Number used as logic LUTs: 63
Number used as distributed RAM: 0
Number used as ripple logic: 12
Number used as shift registers: 0
Animation!
When I made an animation in my elevator simulator, I basically wrote a giant truth table of all possible animation frames along with two counters to determine the animation frame. If I try the same on the little Fipsy FPGA board, I would be greeted by no other than:
ERROR - Design doesn't fit into device specified, refer to the Map report for more details.
I must move the color using the limited amount of logic resources I have!
Reading the code from my first iteration, I realized that I am already "moving" the colors, shifting by 8 bits for each pixel. However, the pixel position is also moving, so that the two moves cancel out, and the NeoPixel ring displays static colors. If I want the colors to change positions, all I need to do is to NOT execute the color shifting logic sometimes.
I tried a few conditions, but they mostly result in white pixels. As analyzed earlier, having a pixel appearing as white indicates that its color is changing too rapidly. To solve this problem, I had to slow things down: the color shifting logic may be skipped approximately once a second, which causes the color to "move" by a pixel. After adding a 24-bit counter to convert the 20.46MHz oscillator signal to 1.2Hz, a simple 3-color animation is achieved, code link.
The Map report from Lattice Diamond shows the FPGA resource usage has grown to 44%:
Number of registers: 76 out of 322 (24%)
PFU registers: 76 out of 256 (30%)
PIO registers: 0 out of 66 (0%)
Number of SLICEs: 56 out of 128 (44%)
SLICEs as Logic/ROM: 56 out of 128 (44%)
SLICEs as RAM: 0 out of 96 (0%)
SLICEs as Carry: 19 out of 128 (15%)
Number of LUT4s: 110 out of 256 (43%)
Number used as logic LUTs: 72
Number used as distributed RAM: 0
Number used as ripple logic: 38
Number used as shift registers: 0
More Colors!
The 24-bit counter definitely took up a big chunk of the logic resources, but I can make it smaller: the MachXO2 device includes an Embedded Function Block (EFB) that contains several hardened control functions. This includes a 16-bit timer/counter function. If I can link the EFB counter to my own counter, the 24-bit counter can be replaced with a smaller counter.
I generated the EFB with IPexpress, enabling only a statically configured counter:
It is important to set "Prescale Divider Value" to 1; otherwise, the counter will not work. I set the counter top value to 39960. Given 20.46MHz input clock, the counter would overflow at approximately 512Hz. When combined with a 9-bit counter implemented in FPGA logic, this would give us the 1Hz frequency.
With these resource savings, I still do not have enough room for a 432-bit register, but I do have enough for a 144-bit register that holds 6 colors.
Upon reset, the register is initialized with six customizable color codes.
For each pixel's rd_next
, the least significant 24 bits are sent to the pixel, and then the 144-bit register is shifted by 24 bits to reveal the next pixel's color.
Effectively, the pixels can now display six different colors, repeated three times.
The final result, link to code, has a FPGA resource usage of 80%:
Number of registers: 181 out of 322 (56%)
PFU registers: 181 out of 256 (71%)
PIO registers: 0 out of 66 (0%)
Number of SLICEs: 102 out of 128 (80%)
SLICEs as Logic/ROM: 102 out of 128 (80%)
SLICEs as RAM: 0 out of 96 (0%)
SLICEs as Carry: 11 out of 128 (9%)
Number of LUT4s: 93 out of 256 (36%)
Number used as logic LUTs: 71
Number used as distributed RAM: 0
Number used as ripple logic: 22
Number used as shift registers: 0
I guess this is as far as I can go on this chip.
Conclusion
This article describes my journey in getting WS2812 NeoPixels controlled by Fipsy FPGA board. I started with lighting up a single pixel, to a 3-color static display, and finally arrived at a 6-color animation. This learning experience allowed me to re-discover the capability of MachXO2-256 FPGA devices.