26 February 2025
After thinking about the WS2812B driver (if you can call it that) for the CH32V003 chip that I described a few days ago, I determined to make a couple of small improvements:
1. Use the hardware SPI to deliver a full-speed bit stream to the addressable LED
2. Be able to adjust the overall brightness of the demo program in real time
I created a new MounRiver Studio 2 (MRS2) project called, imaginatively, “F4-WS2812B-SPI”. This time I adjusted the system clock to the full 48 MHz, but using the internal HSI oscillator as the base instead of the external quartz crystal that is still not there.
In the MRS2-supplied file, system_ch32v00x.c, I un-commented the desired setting, like this:
//#define SYSCLK_FREQ_8MHz_HSI 8000000
//#define SYSCLK_FREQ_24MHZ_HSI HSI_VALUE
#define SYSCLK_FREQ_48MHZ_HSI 48000000
//#define SYSCLK_FREQ_8MHz_HSE 8000000
//#define SYSCLK_FREQ_24MHz_HSE HSE_VALUE
//#define SYSCLK_FREQ_48MHz_HSE 48000000
I find the best test of your system operating frequency is a serial terminal. If your USART is setting the baud rate based on the assumed clock frequency, you’re going to find out quickly if it is right or not. The generic, boiler-plate code created by the MRS2 new project wizard for this chip family (-003) sets up USART1 to be able to use the printf() family of console output functions. It also prints out the “System Clock” value and the unique Chip ID before entering the main loop of the application. I added the program name announcement to this list just so I can keep track of which program is actually running on the terminal. So I normally get this output every time the chip is either re-programmed or reset:
SystemClk:48000000
ChipID:00310510
F4-WS2812B-SPI
So I have confirmation that the system clock is somewhere in the neighborhood of 48 MHz. First, it told me itself. Second, I can actually read what it wrote, so that’s another Good Sign.
Now I’m now having some curiosity spring up around exactly how “unique” this “ChipID” really is. But perhaps I can follow up on that in the near future. It’s not looking altogether unique at this very moment.
So to talk to the WS2812B addressable LED with a ‘serial peripheral interface’ (SPI), um, peripheral, I should warn you that we are going not going to use the SPI as it was originally intended. You already know that the WS2812B uses its own proprietary bit stream protocol, which I vaguely described in a very hand-wavy manner in the previous article. It’s certainly not SPI-compliant, on the face of it.
But since SPI is a protocol of Very Little Brain, we can use it more as a ‘waveform generator’ than strictly a data transmission protocol. Any eight bit byte that you transmit through the SPI emerges as a sequence of bits from a single pin, along with a synchronized clock signal on another pin. We will not be using the clock pin at all, just the data line.
Now the SPI is a versatile beastie with ever so many options for configuring the data stream. This works out well because there are ever so many different SPI-enabled devices and every one of them has its own idea of what is a right and proper configuration.
As a peripheral of the first rank on this chip, it gets an entire chapter (Chapter 14) in the Reference Manual (RM). And here we see again the lingering legacy of “master” and “slave” devices. I’ve described my opinion on this topic in the past, so I will be referring to these two roles as “coordinator” and “participant” from now on. Our chip will coordinate the data flow and the LED will participate in this activity.
The SPI peripheral, which is unfortunately but irrevocably redundant, has access to up to four (4) input and output pins, depending on the required configuration. As previously stated, we will only need one, which is the output data line, called “MOSI” which translates to “coordinator out, participant in”. Other chips from other manufacturer’s sometimes refer to this pin simply as “SDO”, for ‘serial data out’. This pin is routed to PC6 (GPIO port C, pin 6), which is pinned out on the CH32V003F4U6 package on physical pin 13.
Now while the CH32V device is housed in a tiny (3x3mm) square plastic package with teensy weensy pads on the bottom of it, I had the foresight to route all the signals to the correspondingly numbered pins of a 20 pin DIP package, which is the form factor of the little development board I’m using on this project. So pin 13 on the QFN20 (quad flat no leads, 20 pins) maps directly to pin 13 on the dual in-line (DIP) footprint of the board.
Of course, before I go too far on congratulating myself on what a great job I did on laying out this board, let’s consider that I routed the output to the LED on the wrong pin entirely. I picked PA2 only because I had used that pin in the past as an output in a similar project. Now I need to figure out how to “correct” this error and get the signal from the SPI output to the LED.
Well, it’s not at all hard to do. Since the default state of most of the device pins is a high-impedance input, there should be no conflict if I just short PC6 to PA2 using a short jumper wire. I might mention at this point that I have installed the little DIP prototype development board onto a small solderless breadboard. Adding more components and attaching them to the device becomes very easy. Also, I don’t have to do any micro-circuit-surgery on the little board.
The down side is that I won’t be able to use PA2 for anything else.
So now let’s configure the SPI for our purposes. This begins with setting up PC6 as an “alternate function, push-pull output”, i.e., an output driven by one of the internal peripherals and not by the GPIO port. Then configure the SPI port to blast out those bits. Here is the configuration code:
// configure SPI
RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1 | RCC_APB2Periph_GPIOC, ENABLE); // enable GPIOC peripheral clock
GPIO_InitTypeDef GPIO_init_struct = { 0 }; // GPIO initialization parameter structure
GPIO_StructInit(&GPIO_init_struct); // set default values
GPIO_init_struct.GPIO_Pin = GPIO_Pin_6; // PC6 is SDO
GPIO_init_struct.GPIO_Speed = GPIO_Speed_10MHz; // need 6 MHz
GPIO_init_struct.GPIO_Mode = GPIO_Mode_AF_PP; // alternate function, push-pull output
GPIO_Init(GPIOC, &GPIO_init_struct); // initialize PC6
GPIO_WriteBit(GPIOC, GPIO_Pin_6, Bit_RESET); // clear PC6
SPI_InitTypeDef SPI_init_struct = { 0 }; // SPI initialization parameter structure
SPI_I2S_DeInit(SPI1); // reset peripheral
SPI_StructInit(&SPI_init_struct); // set default values
SPI_init_struct.SPI_Direction = SPI_Direction_1Line_Tx; // one line for output only
SPI_init_struct.SPI_Mode = SPI_Mode_Master; // or 'coordinator', if you prefer
SPI_init_struct.SPI_DataSize = SPI_DataSize_8b; // 8 bits
SPI_init_struct.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_8; // 48 MHz / 8 = 6 MHz SPI clock
SPI_init_struct.SPI_FirstBit = SPI_FirstBit_MSB; // MSB first
SPI_Init(SPI1, &SPI_init_struct); // initialize SPI
SPI_Cmd(SPI1, ENABLE); // enable SPI
So to send the individual ‘wave forms’ that make up the binary ones and zeros that the WS2812B understand, we’ll shift out a few ones as a zero and a few more ones as a one. Yes? Yes!
For example, to send the code for a zero, we send a shorter high-level pulse, followed by a longer low-level pulse. I use a 0x60 byte value, or 01100000 in binary. To send a one, I use the value 0xFC, or 11111100 in binary, instead.
I wrote a simple function that sends the data byte out the SPI port, while waiting for any previously-transmitted bytes to clear first. It looks like this:
void spi_send(uint8_t data) { // send 8-bit data out via SPI
while((SPI1->STATR & SPI_I2S_FLAG_TXE) == 0) {
// wait for transmit register to be empty
}
SPI1->DATAR = data;
}
Now if you’ve done the math, and I know you’ve done the math, you’ll quickly figure out that the timing is still not exactly right on these transmissions. This is due to the limited number of SPI clock prescalers available. The system is running at 48 MHz, and we are only provided with powers-of-two for clock divisors. For our purposes, we use “/8” so that we get a 6 MHz clock running the SPI. This means that each “bit” in the eight bit byte that gets sent out occupies ~167 ns, and eight of them adds up to 1.33 us, which is longer than the 1.25 us minimum bit cell duration. So we’re getting 750 KHz instead of 800 KHz. Not perfect, and not 100% of what is possible, but much better than before.
So that’s the first of my two goals accomplished. Now to “adjust” the apparent brightness of the LEDs in real time for demonstration purposes.