23 February 2025
I’d like to be able to control some WS2812B addressable RGB LEDs using the CH32V003 chips. I’ve designed a little development board with a CH32V003F4U6 QFN20 device and it has a microscopically tiny WS2812B-compatible LED on it.
When I designed this board, I connected the data pin of the WS2812B LED to PA2, but only because I had written some earlier code that already used that pin to drive the LEDs. In retrospect, I should have used PC6, as it is also the SPI MOSI output, which is ideal for shifting out bits in a serial fashion, which is what the WS2812B type of LEDs want.
But here we are and I already have a stack of these boards here to play with, so play with them I will.
I’m using the new MounRiver Studio 2 (MRS2) IDE for this project. I created a new project called F4-WS2812B because that’s how creatively I name things.
I made a few tweaks to the project settings. Instead of attempting to crank up a 24 MHz quartz crystal attached to PA1 and PA2 that is not there, I set the system clock to 8 MHz. This is coincidentally the default system clock for the CH32V003 chips in their native state, before MRS2 decides these things for you. The chip powers up with the high-speed internal (HSI) oscillator running at ~24 MHz and sets up a prescaler of 3 on the system clock. For the speed crazy fans out there, you can speed up this little chip to 48 MHz, no problem. But for this project, I am currently estimating (i.e., totally guessing) that 8 MHz should be sufficient for driving the serial bitstream out to the LED in accordance with its timing constraints.
I also switched the default compiler from GCC8 to GCC12 and made a handful of other, less signficiant changes to the project settings.
So the first thing to do is to configure PA2 as a push-pull output with modest speed capabilities. The slowest setting is 2 MHz, and the target signal specification is 800 KHz. I’m not 100% clear on what all this affects on the output pin drive circuitry, but I assume it reduces some of the EMI that might otherwise be emitted if driven at the maximum speeds. Here is the code to do that, using the supplied HAL library:
// configure PA2 as push-pull output, 2 MHz max
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure = {0};
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_2MHz;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_Init(GPIOA, &GPIO_InitStructure);
GPIO_WriteBit(GPIOA, GPIO_Pin_2, Bit_RESET); // PA2 low
The next thing to do is to start sending out some pulses and see how close we can get to the WS2812B’s timing requirements. Running at 8 MHz, each instruction cycle lasts 125 nanoseconds, and the RISC-V CPU in this chip executes most instructions in a single cycle.
There are three types of pulses that we need to send to talk to this LED. A short high pulse followed by a longer low pulse counts as a zero. A longer high pulse followed by a shorter low pulse counts as a one. A long low pulse of at least 50 microseconds acts as a ‘reset’ signal, telling the LED to latch in any data that has been shifted into it and using that data to light up the red, green or blue LEDs accordingly.
Now much has been said about the strictness of these timing requirements. The only thing that is really critical is the difference in the “short” and “long” periods of the high portion of the pulses. There can be quite a bit of variability on the low portion of the pulse, as long as it’s not so long as to be interpreted as the reset signal.
My simple adaptation of this timing protocol has pretty good control over the high part of the pulse, but the low parts tend to go on just a bit too long – but it still works just fine. The downside is that the overall bit frequency is only about 250 KHz, much lower than the maximum of 800 KHz. Right now I’m only trying to light up a single addressable LED, so this works fine, but if I was wanting to talk to a lengthy string of LEDs, this would seriously limit the maximum update rate for the entire string.
At the lowest level, I created a function called ws2812b_pulse() that takes a single argument. For a short pulse, you send it a zero. For the longer pulse, send it a 1. To send the reset signal, send it a 2. Here is the code:
void ws2812b_pulse(uint8_t length) { // send out a pulse
// note: system clock assumed to be 8 MHz
// 0 = short pulse
// 1 = long pulse
// 2 = 'reset' signal
switch(length) {
case 0: // short pulse, 250 ns
GPIOA->BSHR = GPIO_Pin_2; // high
GPIOA->BCR = GPIO_Pin_2; // low
break;
case 1: // long pulse, 750 ns
GPIOA->BSHR = GPIO_Pin_2; // high
__asm__("nop"); // extend that pulse by 125 ns
__asm__("nop"); // extend that pulse by 125 ns
__asm__("nop"); // extend that pulse by 125 ns
__asm__("nop"); // extend that pulse by 125 ns
GPIOA->BCR = GPIO_Pin_2; // low
break;
case 2: // reset, > 50 us
GPIOA->BCR = GPIO_Pin_2; // low
Delay_Us(50);
break;
}
}
Next up the chain, I wrote a function that repeatedly calls the ws2812b_pulse() function with the data bits of a single byte, starting with the most significant bit (MSB) and going down to the least significant bit (LSB), as this is how the WS2812B listens for bytes. Here is the code:
void ws2812b_byte(uint8_t byte) { // send a byte one bit at a time, MSB first
uint8_t i; // bit counter
for(i = 0; i < 8; i++) { // loop through all the bits in the byte
if(byte & 0x80) { // send a 1
ws2812b_pulse(1);
} else { // send a 0
ws2812b_pulse(0);
}
byte <<= 1; // shift all the bits
}
}
The actual protocol of the WS2812B is to get three bytes worth of data, in green, red, blue order. If you send in another three bytes, it will shift out the previous bits to the next LED. Once you’re ready to commit, you send the reset signal, and the chips latch all their data and start showing the corresponding colors with their LEDs.
Here is the code to send all three bytes in the proper order:
void ws2812b_rgb(uint8_t red, uint8_t green, uint8_t blue) { // send RGB data to LED
ws2812b_byte(green); // green data
ws2812b_byte(red); // red data
ws2812b_byte(blue); // blue data
}
To latch in that data, I created a macro that sends the reset pulse:
#define ws2812b_reset() ws2812b_pulse(2) // send 'reset' pulse to latch data
As a demonstration of all the possible colors at the lowest possible brightness, I wrote this simple loop that repeats endlessly:
while(true) { // an endless loop
ws2812b_rgb(0, 0, 0); // black
ws2812b_reset(); // reset
Delay_Ms(250);
ws2812b_rgb(1, 0, 0); // red
ws2812b_reset(); // reset
Delay_Ms(250);
ws2812b_rgb(1, 1, 0); // yellow
ws2812b_reset(); // reset
Delay_Ms(250);
ws2812b_rgb(0, 1, 0); // green
ws2812b_reset(); // reset
Delay_Ms(250);
ws2812b_rgb(0, 1, 1); // cyan
ws2812b_reset(); // reset
Delay_Ms(250);
ws2812b_rgb(0, 0, 1); // blue
ws2812b_reset(); // reset
Delay_Ms(250);
ws2812b_rgb(1, 0, 1); // magenta
ws2812b_reset(); // reset
Delay_Ms(250);
ws2812b_rgb(1, 1, 1); // white
ws2812b_reset(); // reset
Delay_Ms(250);
}
You’ll find that these little LEDs are quite bright when told to shine at their utmost capacity. In fact, you need to be careful when you are working with more than just a very few of these in a string, as the current consumption goes way up way quick, and they tend to produce a good amount of heat in the process. But one little LED on my little dev board is going to continue to behave itself and blink merrily along into the night.