Skip to content

The LED matrix

104 LEDs, in 8 rows and 13 columns. This page is about how they are lit, because the answer explains why the job belongs on the microcontroller at all — and it is the clearest example in the whole project of a timing problem masquerading as a display problem.

The problem: not enough pins

The obvious way to control 104 LEDs is 104 wires. The chip does not have 104 spare pins, and even if it did, the board would be a mess.

The trick is that you do not have to light them all at once. Light one, very briefly, then the next, and so on, fast enough that your eye cannot follow. This is multiplexing, and it works because of persistence of vision: your visual system blurs anything faster than roughly 50 times per second into a steady image.

So the matrix is never actually showing a picture. It is showing one LED at a time, extremely fast, and your eye assembles it. Photograph one with a fast shutter and you catch a single dot.

This board goes further and uses charlieplexing, which exploits the fact that LEDs conduct in one direction only. With n pins you can address n × (n − 1) LEDs — 11 pins gets you 110, enough for 104 — by driving one pin high, one low, and leaving all the others floating so no current flows through them.

The cost is that only one LED can be lit at any instant, so the refresh loop has to be quick and utterly regular.

Why this cannot be Linux's job

Here is the number that makes the argument:

/* mcu/app/include/app_proto.h */
#define APP_MATRIX_SWEEPS_PER_S 962

The microcontroller sweeps the whole panel about a thousand times a second (962 is the figure the timing was designed around; measured, it is nearer 1100). Each sweep visits 104 LEDs, so it is switching pins roughly 100,000 times a second — one every 10 microseconds.

Recall from page one what happens when Linux is asked to hit an exact moment: it usually does, and occasionally it is 2 milliseconds late because the kernel had something else to do. Two milliseconds is 200 slots. The display would not glitch subtly; a visible chunk of the panel would go dark, at random, forever.

The microcontroller is not faster than the four Linux cores. It is reliable, and that is the whole requirement.

Try it — measure the sweeps yourself:

sudo systemctl stop unoq-cpu-bars
cd ~/two-computers-one-board
./.venv/bin/python -c "
from unoq import MCU
import time

def rate(mcu, label):
    a = int(mcu.status()['sweeps']); time.sleep(2)
    b = int(mcu.status()['sweeps'])
    print(f'{label}: {(b - a) / 2:.0f} sweeps per second')

with MCU() as mcu:
    mcu.matrix_off()
    rate(mcu, 'panel dark ')
    mcu.bars([50, 50, 50, 50])
    rate(mcu, 'panel lit  ')
    mcu.matrix_off()
"
sudo systemctl start unoq-cpu-bars
panel dark :    0 sweeps per second
panel lit  : 1102 sweeps per second

Both numbers are informative.

A dark panel sweeps zero times. The driver does not scan a display with nothing on it — there is no point burning 100,000 pin operations a second to show darkness, and on a board this power-conscious that restraint is deliberate. So if you ever measure this and get 0, the microcontroller is not stuck; it has simply been given nothing to draw.

And the lit figure is ~1100, not the 962 in the header. That constant is the design figure the timing was calculated from; the measured rate is what the hardware actually manages, which depends on how long each slot really takes. Both are true, and the gap between a nominal constant and a measured value is worth being suspicious about in general — here it is benign, but the only reason we know that is that somebody measured.

Grey, from a panel that has no grey

The LEDs are single-colour blue and can only be on or off. Yet the bars fade.

The trick is the same one again, applied to brightness: within each sweep, an LED that should be at level 3 of 7 is lit during three of the seven available slots, and dark for four. Too fast to see, so your eye reports "less bright". This is pulse-width modulation — brightness as a fraction of time on.

#define APP_MATRIX_MAX_LEVEL 7      /* 0 = off, 7 = fully on */

Eight levels from a panel with two states, bought entirely with timing.

Splitting the work

Now the design decision this whole project exists to demonstrate. Drawing a bar chart involves two very different jobs:

Job Where Why
How busy is each core? Linux It is arithmetic on /proc/stat
How tall is each bar? Linux Arithmetic again — percentage to pixels
Which LED is lit right now? MCU 10 μs of slack, forever
flowchart LR
  A["/proc/stat<br/><small>counters</small>"] --> B["cpu.py<br/><small>percentages</small>"]
  B -->|"4 numbers,<br/>twice a second"| C["bars.c<br/><small>rasteriser</small>"]
  C --> D["104 brightness<br/>values"]
  D --> E["matrix.c<br/><small>ISR, every 10 µs</small>"]
  E --> F(["the panel"])

The message between them is tiny: a few numbers saying how tall each bar is. Everything expensive happens on Linux; everything time-critical happens on the microcontroller; the wire between carries almost nothing.

The rasteriser — the code that turns "core 2 is at 47%" into which pixels are lit — lives in mcu/app/src/bars.c. It is pure arithmetic with no hardware in it at all, which is why it can be tested on your laptop:

~/two-computers-one-board/mcu/ztest.sh        # 26 test cases, no board required

Those tests build for native_sim — a fake Zephyr "board" that is really just a Linux program. The rasteriser cannot tell the difference, because it never touches a pin. Separating the calculation from the hardware is what made it testable, and that is a habit worth taking to every embedded project you touch.

Two shapes of frame

The Zephyr LED matrix library takes frames two different ways, and mixing them up is a classic mistake:

Form Type Use
Per-pixel uint8_t[104] One brightness (0–7) per LED — what greys need
Bit-packed uint32_t[4] One bit per LED, on or off — compact, no grey

The bars use the first, because a bar chart without brightness levels is a much worse bar chart.

Try it: drive it yourself

Stop the demo and put your own pattern up:

sudo systemctl stop unoq-cpu-bars
cd ~/two-computers-one-board
./.venv/bin/python -c "
from unoq import MCU
with MCU() as mcu:
    for pct in (0, 25, 50, 75, 100):
        mcu.bars([pct] * 4)
        print('bars at', pct, '%')
        __import__('time').sleep(1)
"
sudo systemctl start unoq-cpu-bars

Then load a core and watch the real thing respond:

yes > /dev/null & sleep 6; kill %1

Go deeper. The panel wiring — which pin pair lights which LED — is a table taken from Arduino's own core, credited in THIRD-PARTY.md. The driver is mcu/app/src/matrix.c; the rasteriser is bars.c.

Check yourself

  1. You photograph the panel at 1/4000 s and see one lit dot. Is the board broken?
  2. The panel's LEDs are only on or off. How does a bar show half brightness?
  3. bars.c is tested without any hardware. What property of that code makes that possible, and what would break it?
  4. You read the sweep counter twice and it has not moved. Give two very different explanations, and say how you would tell them apart.

Next: replacing the firmware on a running board without being able to visit it.