8 March 2025
Testing proves testing works. And the testing of the new bare-metal WS2812B LED driver overnight proves that the new bare-metal WS2812B LED driver works, as well. Running just over 12,000 updates per second, it has been running for approximately 16 hours with no unexplainable hang-ups. That’s around 700 million error-free transmissions, which is a lot. So that’s not the problem.
I will take this opportunity to bring some closure to one of the original project design goals, which was to have a nice little demo program showing the LED changing colors, as well as being able to adjust the apparent brightness of the LED in real time.
I had already connected a small potentiometer on the little solderless breadboard, configured as a voltage divider, and attached it to one of the analog-to-digital converter (ADC) input pins. I would like to use that potentiometer as a dial to adjust the LED brightness.
There are several steps required to get the ADC initialized and ready to read analog values. First, we have to configure the ADC prescaler in the RCC’s Clock Configuration Register 0, RCC_CFGR0. Right now, it’s being set to “HBCLK divided by 2”, which is both the default value after reset and the value that I write to the register during initial setup (by omission). As the “HBCLK” is currently 48 MHz, or will be once the PLL locks, the ADC clock will be 24 MHz, which we are cautioned is the maximum rate. So I’ll leave it like that for now.
I should note here that I am assigning new enumerated values with their ‘absolute’ values within their respective register, and not the numeric value they would have if we were accessing them through a C-style bit-field within a structure. For example, the SW field starts at bit position 0 and is two bits wide. It has four possible values, three of which are assigned. Being at bit position 0 within the register, the absolute and relative values are the same. But the very next field, SWS, starts at bit position 2 and is also two bits wide. The relative values are the same, but the absolute values are shifted left two places:
# RCC_CFGR0/SW values
RCC_SW_HSI = 0x00000000 # HSI
RCC_SW_HSE = 0x00000001 # HSE
RCC_SW_PLL = 0x00000002 # PLL
# RCC_CFGR0/SWS values
RCC_SWS_HSI = 0x00000000 # HSI
RCC_SWS_HSE = 0x00000004 # HSE
RCC_SWS_PLL = 0x00000008 # PLL
So I can just use them as they are and not have to worry about shifting them around to be in the right place when I need them. I typically bit-wise ‘or’ all the values together to come up with a single value to write to the register in question. That way I can set several bits or bit fields at once with a single write.
The include file generator script also produces bit masks for each of the register bit fields, e.g.:
RCC_SW = 0b00000000000000000000000000000011 # SW - System clock Switch, pos=0, width=2
RCC_SWS = 0b00000000000000000000000000001100 # SWS - System Clock Switch Status, pos=2, width=2
These are useful for masking out everything but the bit field contents, when you need just that information out of a register.
Next we have to enable the ADC’s peripheral clock. As a PB2 peripheral, I can just add its enable bit to the setup that’s already being done for the other peripherals:
# enable required peripheral clocks
li x4, RCC_USART1EN | RCC_SPI1EN | RCC_ADC1EN | RCC_IOPCEN
sw x4, RCC_APB2PCENR(x3)
Then we should configure the input pin that is to be used as the ADC input. PC2 is analog input 2 (A2). I added a comment to the GPIOC initialization code and changed the configuration word being written to the CFGLR configuration register for GPIOC:
# GPIOC
# PC2/A2 - analog input 2
# PC6 - SPI data out
la x3, GPIOC_BASE
li x4, 0x89888088
sw x4, GPIO_CFGLR(x3)
That very magic-looking number is actually easy to understand once you see my ‘cheat sheet’ of all the possible configuration values for these pins:
0 analog input
1 output push-pull 10 MHz max
2 output push-pull 2 MHz max
3 output push-pull 30 MHz max
4 floating input mode (no pull up or down) - default
5 output open drain 10 MHz max
6 output open drain 2 MHz max
7 output open drain 30 MHz max
8 input with pull up or down
9 output push-pull multiplexed 10 MHz max
A output push-pull multiplexed 2 MHz max
B output push-pull multiplexed 30 MHz max
C reserved
D output open drain multiplexed 10 MHz max
E output open drain multiplexed 2 MHz max
F output open drain multiplexed 30 MHz max
Each hexadecimal digit represents one bit of the GPIO port. On other device families that offer 16 or more pins per port, there are additional configuration registers, but they use the same format. The ‘0’ digit that you see in the third-to-last position corresponds to the ‘analog input’ configuration from the table and sets up PC2 as an analog input.
Then we can start to configure the ADC itself. Now the ADC on this little chip is quite versatile and talented. It’s quite similar to the ADCs available on the STM32 devices, if you are familiar with those. It’s way more complex than, for example, the Atmel (now Microchip) AVR devices.
Most of the initialization process will be to tell the ADC peripheral what we don’t want. Like I said, it’s got a lot of features and I’m not even going to scratch the surface of what all it can do. I just want to take a single reading from a single channel every once in a while.
The first step is to power on the ADC module, using the ADON bit in the ADC Control Register 2 (ADC_CTLR2). Then begins a ‘module stabilization time, t(stab), of typically 7 us. We’ll give it 10 us because we’re generous.
As I mentioned previously, it’s more effort to tell the ADC not to do stuff. Next, we have to tell it that we only want one channel to be converted, and that channel is A2.
After that, there is a calibration routine that mostly runs itself. First, we reset the calibration register by writing a 1 into the RSTCAL bit of the ADC_CRLR2 register, then we wait for it to clear itself, signaling that it has completed the reset. Then we do the exact same thing but with the CAL bit, and then the ADC is done calibrating itself.
After that, the ADC is ready to do its thing. Here is the complete initialization code:
# initialize ADC
la x3, ADC1_BASE
li x4, ADC1_ADON
sw x4, ADC1_CTLR2(x3) # module power on
li a0, 10
call delay_us # module stabilization time (>7 us)
li x4, (1 << 20)
sw x4, ADC1_RSQR1(x3) # total of 1 conversions requested
li x4, 2
sw x4, ADC1_RSQR3(x3) # 1st regular conversion channel is A2
li x4, ADC1_RSTCAL | ADC1_ADON
sw x4, ADC1_CTLR2(x3) # reset calibration register
1: lw x4, ADC1_CTLR2(x3)
andi x4, x4, ADC1_RSTCAL
bnez x4, 1b # wait for RSTCAL to go back to zero when reset is complete
li x4, ADC1_CAL | ADC1_ADON
sw x4, ADC1_CTLR2(x3) # start calibration function
1: lw x4, ADC1_CTLR2(x3)
andi x4, x4, ADC1_CAL
bnez x4, 1b # wait for CAL to go back to zero when calibration is complete
And here is a simple function to start a conversion, wait for it to complete, then return the converted value:
adc_convert: # perform single conversion
# on entry: none
# on exit: a0[9..0] conversion result
# register usage:
# x3: pointer to ADC1_BASE
# x4: read status register
addi sp, sp, -16 # allocate space on stack
sw ra, 12(sp) # preserve return address
sw x3, 8(sp) # preserve x3
sw x4, 4(sp) # preserve x4
la x3, ADC1_BASE
li x4, ADC1_ADON
sw x4, ADC1_CTLR2(x3) # start conversion
1: lw x4, ADC1_STATR(x3)
andi x4, x4, ADC1_EOC
beqz x4, 1b # wait for conversion to complete
lw a0, ADC1_RDATAR(x3) # read conversion result
lw ra, 12(sp) # restore return address
lw x3, 8(sp) # restore x3
lw x4, 4(sp) # restore x4
addi sp, sp, 16 # restore stack pointer
ret # return from function
Bear in mind that this is a 10 bit wide ADC, and we only really want an 8 bit range for the LED brightness. Scaling the value to fit the range just takes a single “shift right logical” instruction. Once we have a scaled value available that represents the position of the “LED brightness” dial, we can use that as the color intensity value that we’re sending to the LED.
So I finally have a blinking, multi-color LED whose brightness I can adjust in real time. Who says dreams don’t come true?