Close

MalO SAO - LEDs

A project log for MalO SAO

SCP-1471

parallel-logicParallel Logic 06/21/2026 at 18:150 Comments

Not every peripheral is equal, so I opted to prioritize the most important ones first.  I figure the most  critical parts of this design will be the LEDs, cap touch, and OLED screen, with the IR TxD/RxD close behind.  With that in mind, I focused on the Charlieplexing of the LEDs next.  This constituted a jump up in complexity by an order of magnitude or two since this is now leveraging the advanced features of the RP2350.

The processor is equipped with 3 PIO banks (with 4 state machines each - the 4 state machines all share the same shared instructions, but have their own pointers) and 16 DMAs.  To run each bank of charlieplexed LEDs, I opted to use one PIO state machine and 2 DMAs.  One DMA loads commands into the PIO (which pins to input/output, which pins to drive high/low, and for how long), and one DMA does nothing more than restart the first DMA when it finishes.  The DMAs do have a ring-buffer feature, but that requires the list of transactions be an clean power of 2 (48 LEDs would need some padding to fit in 64 instructions).  But more importantly, to avoid shearing, I want to load in a new series of LED commands only at the exact moment the complete set finishes, and I'd prefer not to have to rely on an IRQ interrupt.

There's a good talk here about driving LEDs with PWM.  My main takeaway from that is the human eye response is logarithmic, and an easy way to implement that on the hardware side is to square the brightness of each LED.  So while the software may command, as an example, an 8-bit brightness of 100 (out of the max 255), under the hood 100*100 = 10'000 is used for the duration that LED is ON for (out of the max 256*256-1= 65535).

If all 48 LEDs in a bank are driven at the max brightness of 255*255, that's 48*255*255 clock cycles, at 150 MHz = 20 ms.  I'm targeting a 60 FPS system (16.6 ms), and rather than possibly skipping every 5th LED bank update (due to timing async updates between the core0 running py at 60 FPS, and the core1 pushing the updates to the LEDs when they're ready), I scaled down the max brightness by 20%.  So in the C code you'll see brightness*brightness*4/5;

It's also important to note that if you drive one LED at brightness 100, that should look different than driving it at brightness 255.  However, if you do nothing but loop around the LED instructions for driving one LED, that becomes: turn LED ON, wait for 100*100*4/5, turn LED OFF, turn LED ON, wait 100*100*4/5..., which for all intents and purposes is identical to the 255 case: turn LED ON, wait 255*255*4/5, turn LED OFF, turn LED ON, wait 255*255*4/5.  What is needed is a pad time so that the 255 LED can remain on continuously, but in the 100 case, the pad fills a period of time with no light, effectively stabilizing the brightness of the entire bank of LEDs.  LED commands to the PIO are 16-bit duration, 8-bit output/input direction, and 8-bit output high/low.  However, if all the pins are disabled in a PIO command, the state machine interprets this as an extra long delay, in effect: 24-bits delay, and 8 bits of 0s.

There is some trickery that can be done with how the pad delay time is calculated.  The simplest approach is to take the number of LEDs and multiple by the max brightness any LED can have: 48*255*255*4/5.  However, this can lead to a dim display if the LEDs aren't being driven at max - ex it's common to fade from one LED color to another.  So for this I compute an "effective led count".  For example I might set an effective LED count of 1, even tough I have two LEDs: one at 100 and one at 155.  The pad time becomes: 1*255*255*4/5 - (100*100*4/5 + 155*155*4/5) = 24800.  The take-away is: count the max number of LEDs you plan to have ON during an animation and set that as the effective LED count.  If the display is too bright, increase the effective LED count.  If too dim, you can try decreasing the LED count, but at a certain point the display may appear to flicker between frames if the delay count reaches zero and the refresh time becomes a function of the brightness of the individual LEDs.

Close... but not quite.  The above demo attempts to drive one LED at a time at max brightness.  However, you will notice some errant green LEDs turning ON dimly elsewhere in the display.

This was a lengthy rabbit hole, but in summary:

- Could this be from a sneak path caused by the shared resistors between on the red LED charliplex lines?  Measuring the voltages across the affected LEDs didn't cleanly line up with this theory (the voltage across the ON LED was in line with the design intent, it was just the other LEDs were also ON).

- The lower bank of LEDs had a behavior where I could drive the GP0 pin LOW, but not HIGH, seemed to point to some kind of configuration problem, especially since I was using identical code to drive both banks, but only GP0 had this problem/behavior.  This was particularly perplexing because I could drive the pins manually with pinMode and digitalWrite, but only when I used the PIO was I unable to drive the pins.  Experimented with longer wait times, and making stand-alone PIO programs that did nothing but drive chosen pins

- Ultimately, it came down to a few pin configuration settings.  Most processors I've worked with float all pins on power-ON.  The RP2350 applies weak pull-downs on all pins on power-ON.  This has the effect of introducing a weak path from the charliplex cathode and multiple other pins as anodes, turning on several other LEDs weakly.  By disabling the pulldowns, this fixed the leakage issue.

- I also extended the logic of the PIO program a bit.  The simplest way to implement a charlieplex program is to enable the target pins, wait the target amount of time (down to and including 0) and then turn OFF the target pins (or skip the clearing step and just have the next chareliplex instruction overwrite all the pin states).  However, it takes non-zero time to run through this logic, and the LEDs, even at zero brightness, will appear faintly ON, which I feel is tacky.  So I put in a few extra lines of code to escape past ever setting the pins at all if the delay is exactly zero (the other option could be to update the command set to remove entries that have 0 brightness, but I'd prefer a static command set where all I have to update is the durations for each pin pair for simplicty).

- The end result is 8-bit brightness control on all LEDs (16 bits under the hood), including true black (OFF):

TODO: Video...

Discussions