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:
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
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.
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:
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:
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¶
- You photograph the panel at 1/4000 s and see one lit dot. Is the board broken?
- The panel's LEDs are only on or off. How does a bar show half brightness?
bars.cis tested without any hardware. What property of that code makes that possible, and what would break it?- 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.