How Charlieplexing works: an interactive demo
Eleven pins, 104 LEDs, and a scan you can slow down until the trick falls apart. An interactive walkthrough of the Arduino UNO Q's charlieplexed matrix, built from the board's own devicetree.
Named after Charlie Allen, who wrote it up in a Maxim application note in the early 2000s. It was a pun on multiplexing, not a formal name. You will also see it called complementary drive or tri-state multiplexing. It is not really an invention. Every ingredient was already on the first page of the LED's datasheet; the contribution was noticing what they added up to.
The idea underneath it
- An LED is a light source and a switch in the same package. Charlieplexing puts the second job to work: the diode does the address decoding, so the panel needs no decoder, no shift register and no driver IC. Just GPIOs.
- That is also why the trick does not generalise. Buzzers, relays, heaters, motors – none of them are polarity-sensitive with a conduction threshold, so there is nothing to decode with. Button matrices have the same problem from the other side, which is why you end up adding a diode per key.
- The cost is that the display is never really finished. A latched row/column matrix can be set and forgotten; a charlieplexed one must be redrawn continuously, forever, in the background, or it goes dark.
Video 1. A demo of Charlieplexing on the Arduino Uno Q. This demo is a companion to that blog article.
When it earns its place
- Pins are the scarcest resource on a small microcontroller, and a matrix like this uses a lot of them. On a board whose LEDs are the main application, that is justified. On a board with a job to do, it usually is not.
- Suited to onboard status displays, badges and dev-board decoration – small LEDs, modest brightness, an MCU with spare timer and interrupt budget.
- Poorly suited to anything large, bright, or CPU-hungry. Past a certain size a dedicated driver chip is cheaper in every currency except BOM lines.
- Single LED type only, in practice. Mixing colours on the same pins breaks the assumption the whole scheme rests on.
Why it is worth a demo
This is one of the few places in embedded work where firmware timing is directly visible to the naked eye. Flicker, banding, uneven greys and dim rows are all scheduling faults you can see, with no logic analyser required.
104 LEDs from 11 pins
Charlieplexing exploits one thing an ordinary matrix throws away: a GPIO has three states, not two. Driven high, driven low, or high-impedance. With n pins you can address every ordered pair of pins, and each pair carries two LEDs wired back to back, so n(n−1) LEDs are individually reachable. Eleven pins gives 110; this board populates 104 of them.
Every number in the binding below is a design consequence of that. Walk through the tabs in order, or jump to the scan for the animation.
led_matrix: charlieplex-led-matrix {
compatible = "charlieplex-led-matrix";
gpios = <&gpiof 0 0>, ... <&gpiof 10 0>; // 11 pins → 110 addressable LEDs
counter = <&counter_matrix>; // hardware timebase, not a thread
refresh-frequency = <100>; // 10 ms per whole frame
width = <13>; height = <8>; // 104 pixels, row-major
grayscale-bits = <3>; // 8 levels via binary code modulation
pixel-pairs = <0x0001 0x0100 ...>; // pixel n → 0x<source><sink>
};
- pixel-pairs is the whole wiring map in one property: entry n is framebuffer pixel n, high byte = the pin that sources current, low byte = the pin that sinks it.
- counter matters because the shortest interval the driver must hit is 130 µs. That is a timer compare channel's job, not a scheduler's.
- 0x0001 and 0x0100 are the same two wires with the polarity swapped — the two anti-parallel LEDs on one pin pair.
Convention check: this page reads the high byte as the source. If your driver reverses it, every LED simply trades places with its anti-parallel twin and the image mirrors within each pair — the mechanism is unchanged.
Two pins, two LEDs, one resistor per pin. The LEDs are anti-parallel, so polarity alone selects which one conducts — and setting both pins to high-impedance turns the pair off without changing anything else in the circuit.
In the binding these two LEDs are 0x0001 (PF0 sources, PF1 sinks) and 0x0100 (PF1 sources, PF0 sinks) — pixels 0 and 1 of the framebuffer.
Add a third pin and you get three pairs, so six LEDs. Exactly one lights: one pin sources, one sinks, and every pin not involved must be an input. Slow the cycle down and watch what happens to the other five — each one is blocked for a different reason, and the reasons are the whole design.
Or drive it yourself: click any pin pad in the diagram to cycle it HIGH → LOW → high-Z. All 27 combinations are legal to try, including the ones a real driver never uses.
- reverse biased — the anti-parallel twin of the lit LED. Its cathode is the pin sitting at 3.3 V, so the junction is held off. This is the free half of charlieplexing: polarity alone separates two LEDs on one pair of wires.
- two-LED series path — real current has a route from the high pin to the low pin through the idle pin, crossing two junctions in series. The idle pin floats, the 3.3 V divides across both junctions, and neither reaches its ~1.9 V forward drop. Nothing lights.
- no forward drive — one end is driven, the other is floating, and there is no completed route onward. Charge has nowhere to go.
That series inequality — 2·VF ≈ 3.8 V against a 3.3 V rail — is the real constraint of the technique, and it is a margin, not a law.
Raise the supply to 5 V, or fit low-VF red parts, and those paths conduct faintly: the classic charlieplex ghost. It is also why you cannot mix LED colours with different forward voltages on the same pins — a 3.2 V blue and a 1.8 V red on one pair, and the pair's behaviour stops being symmetric. On the UNO Q all 104 are the same part, driven from 3.3 V, which is what makes the 11-pin trick safe here.
Every ordered pair of distinct pins is one LED. The grid below is that idea drawn out: rows are the sourcing pin, columns the sinking pin. The diagonal is empty (a pin cannot drive itself) and six cells are simply not populated on this board.
The six gaps are pin 10 against pins 7, 8 and 9, in both directions — the tail of the sequence, dropped once 104 pixels were reached. A conventional row/column matrix would need 21 pins for 104 LEDs; a shift-register solution needs external parts.
Now the part that surprises people. A pixel's position on the panel says nothing about which pins reach it. Pick any LED: its anti-parallel twin is the pixel next door, and the LEDs that light together with it are scattered all over the panel.
Because 13 is odd, the twin pairing shifts by one on every row, so pairs straddle the row ends. This is exactly why the driver needs the pixel-pairs table: there is no arithmetic that maps (x, y) to a pin pair.
Eleven phases per frame. In each phase one pin sources and up to ten pins sink, so up to ten LEDs light at once. Slow it down to watch the phases step; run it at 1× and persistence of vision does the rest. The default image is the comet from main.c — a full-brightness head on the two centre rows and a tail that fades one duty level per 60 ms frame.
/* one 60 ms frame of main.c */
for (i = 0; i < 104; i++)
if (fb[i] > 0) fb[i]--; /* fade one level */
fb[3 * 13 + x] = 7; /* head, two rows */
fb[4 * 13 + x] = 7;
x += dir;
if (x == 12 || x == 0) dir = -dir;
fb is display_get_framebuffer() — 104 bytes, row-major, in exactly the order of pixel-pairs. The tail is six cells long because the fade is one level per frame and grayscale-bits = 3 only has seven non-zero levels to give away.
The two head pixels look like one bright bar, but they are two unrelated pin pairs: at twelve of the thirteen columns they fall in different scan phases, so the head is never drawn in one go. Row 3 alone spans eight of the eleven phases.
Brightness here is integrated over real time with a ~55 ms eye response, then given 11× exposure gain — the same trick the hardware plays, driving each LED hard for 1/11 of the time. At 1 : 200 you are seeing individual grey ticks; the picture looks wrong because your eye is no longer doing the averaging.
Three greyscale bits means eight levels, and there is no analogue control here — only time. Each 909 µs phase is cut into seven ticks, and the bits of the pixel value select runs of 1, 2 and 4 ticks. Binary code modulation: one compare interrupt per bit, not one per tick.
Timing budget
- 129.87 µs is the tightest deadline in the whole driver. Miss it and you get visible banding, which is why the binding takes a counter phandle: three compare events per phase, 33 per frame, 3 300 per second.
- Why stop at 3 bits? The LSB tick is phase ÷ (2bits−1). Four bits gives 60 µs, five gives 29 µs. Holding 100 Hz refresh, the interrupt cost rises and the LSB tick approaches GPIO and ISR overhead — where the LSB stops being linear and the greys stop being trustworthy.
- Peak vs average. A given LED can only ever be on for 1/11 of the frame, so full white is 9 % duty. Average current stays modest but peak current is 11× the apparent brightness — that sets the series resistor, and only one pin sources at a time so the supply sees one phase's worth.
- Eight linear steps, non-linear eye. Duty is linear in the pixel value while perception is roughly logarithmic, so levels 6 and 7 look nearly identical and 0→1 is a big jump. With 3 bits there is no room for a gamma table; you choose your eight duties and live with them.