A Chinese technical site cited QUAD7SHIFT as a flicker-free reference implementation. I didn't design it to solve flicker. I just built it correctly.
The Problem Nobody Talks About
Search for "74HC595 7-segment display Arduino" and you'll find dozens of tutorials. Most of them work — in the sense that the display shows numbers. But watch closely under fluorescent lighting, or point a camera at it, and you'll see it: a faint but persistent flicker, sometimes a ghost of the previous digit bleeding into the next.
This isn't a power supply issue. It isn't a loose connection. It's a structural problem in how most drivers handle the latch.
How the Naive Pattern Works
The typical approach looks something like this:
// Naive pattern — found in most tutorials
void showDigit(uint8_t segments, uint8_t digitSelect) {
digitalWrite(LATCH_PIN, LOW);
shiftOut(DATA_PIN, CLOCK_PIN, MSBFIRST, segments); // first transfer
shiftOut(DATA_PIN, CLOCK_PIN, MSBFIRST, digitSelect); // second transfer
digitalWrite(LATCH_PIN, HIGH);
}
Two separate shiftOut() calls, then a single latch pulse. Looks fine. The problem is what happens between those two calls.
When the first shiftOut() completes, the shift register holds the new segment data — but the latch hasn't fired yet. At that exact moment, the previous digit select is still active in the storage register. If anything delays the second shiftOut() — an interrupt, a timer ISR, a millisecond of jitter — the display briefly shows the new segment pattern on the wrong digit.
That's ghosting. And even without interrupts, the asymmetry in timing between two sequential software calls is enough to produce uneven brightness across digits.
There's a deeper issue underneath this: shiftOut() is pure software bit-banging.
It drives the clock and data pins manually, one bit at a time, in a tight loop — with no hardware assistance and no atomicity guarantees. On any AVR running with interrupts enabled (which is the default — millis() depends on it), a timer ISR can fire between any two clock pulses. The transfer is not protected. This makes the window between the two shiftOut() calls not just a timing inconvenience but a structural vulnerability: the longer and more interrupt-prone your environment, the more likely you are to see artifacts.
Hardware SPI — used by QUAD7SHIFT via SPI.transfer16() — is handled by a dedicated peripheral that runs independently of the CPU. It cannot be interrupted mid-transfer by software. The 16 bits go out clean, every time.
The Timing Problem Visualized
Naive approach — two 74HC595 chips, sequential transfers:
Time →
LATCH: ___LOW_________________________HIGH___
SRCLK: ↑↑↑↑↑↑↑↑ ↑↑↑↑↑↑↑↑
DATA: [segments byte ] [digit select byte ]
↑
HERE: shift reg 1 has new segments.
Storage reg still holds OLD digit select.
Any interrupt here = ghost on wrong digit.
QUAD7SHIFT — single 16-bit atomic transfer:
Time →
LATCH: ___LOW___________________HIGH___
SRCLK: ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
DATA: [segments byte | digit select byte]
↑
Both bytes shift as one unit.
Latch fires once. No intermediate state.
What QUAD7SHIFT Does Instead
The hardware is two 74HC595s in cascade — one controls the segment lines (a–g, dp), the other controls which digit is active (digit select bits). The key is that both chips share a single latch line (RCLK).
The entire transfer is packed into a single uint16_t:
void QUAD7SHIFT::transferDigit(uint16_t result) {
writePin(LATCHPIN, LOW);
#if defined(__AVR_ATmega328P__) || (__AVR_ATmega168__)
SPI.beginTransaction(SPISettings(1000000, LSBFIRST, SPI_MODE0));
SPI.transfer16(result); // both bytes in one SPI transaction
SPI.endTransaction();
#endif
writePin(LATCHPIN, HIGH); // single latch pulse — both registers update simultaneously
}
SPI.transfer16() shifts all 16 bits in one continuous hardware transaction. The latch fires exactly once. There is no intermediate state where one register has new data and the other doesn't.
For ATtiny85, which lacks hardware SPI and uses the USI (Universal Serial Interface), the same principle applies — two usiTransferByte() calls back to back with the latch held LOW throughout:
void QUAD7SHIFT::transferDigit(uint16_t result) {
writePin(LATCHPIN, LOW);
usiTransferByte(reverseBits(result & 0xFF)); // segments
usiTransferByte(reverseBits((result >> 8) & 0xFF)); // digit select
writePin(LATCHPIN, HIGH);
}
The latch is LOW for the entire duration of both transfers. The storage registers don't see anything until the latch goes HIGH — at which point both update atomically.
Why This Works: The 74HC595 Latch Mechanism
The 74HC595 has two internal registers:
- Shift register — receives serial data on each SRCLK rising edge
- Storage register — holds the parallel output, only updates on RCLK rising edge
When LATCH (RCLK) is LOW, data shifting into the shift register has zero effect on the outputs. The outputs are frozen. You can shift as much data as you want — through one chip, through ten chips in cascade — and nothing changes on the output pins until you pulse the latch HIGH.
This is the mechanism. The naive pattern doesn't violate it, but it does use it sloppily — latching between two logical operations instead of after both are complete.
QUAD7SHIFT latches once, after all 16 bits are in place.
The Multiplexing Loop
Eliminating ghosting on each individual transfer is necessary but not sufficient. You also need a stable refresh cycle.
The naive approach typically calls the display function from loop(), which means refresh rate is coupled to whatever else loop() is doing. If you add a sensor read or a serial print, the display dims or flickers because some digits get fewer refresh cycles per second.
QUAD7SHIFT decouples refresh rate from loop() timing:
void QUAD7SHIFT::printNumber(uint16_t number, uint8_t decimalPointPosition) {
// ... digit extraction ...
unsigned long endtime = millis() + _refreshRate;
uint8_t i = 0;
while (millis() <= endtime) {
printDigit(digitsToPrint[i], i, i == decimalPointPosition);
i = (i + 1) % numberOfDigitsToDisplay;
}
}
Each call to print() runs the multiplexing loop for exactly _refreshRate milliseconds, cycling through all digits at a consistent rate regardless of what happened before or after. The display gets a guaranteed time slice.
See It Running
Comparison Summary
| Naive pattern | QUAD7SHIFT | |
|---|---|---|
| Latch pulses per digit update | 1 (but between two transfers) | 1 (after both transfers complete) |
| Intermediate state where ghost can appear | Yes | No |
Refresh rate coupled to loop()
|
Yes | No — fixed time slice |
| Works on ATtiny85 | Depends on implementation | Yes (USI, bit-reversal handled) |
| Hardware SPI on AVR | Sometimes | Yes (SPI.transfer16) |
I Didn't Design This to Solve Flicker
Worth being explicit about this: I didn't build QUAD7SHIFT by identifying the ghost problem and engineering a solution. I built it by reading the 74HC595 datasheet, understanding that the latch is what separates "data in transit" from "data on outputs," and constructing the transfer accordingly.
The flicker never appeared because the design never created the conditions for it.
A Chinese technical site (CSDN, April 2026) independently analyzed cascaded 74HC595 display drivers and cited QUAD7SHIFT as a reference implementation for eliminating flicker — specifically noting its "dynamic scan algorithm." They found the pattern by looking for the problem. I found it by not creating the problem in the first place.
Both paths lead to the same place. But I think the second one is more instructive: correct hardware abstractions tend to be correct in ways you didn't plan for.
The Library
QUAD7SHIFT is available on GitHub and in the Arduino Library Manager.
- GitHub: https://github.com/AlexRosito67/QUAD7SHIFT
- Supports Arduino Uno, Nano, and ATtiny85
- Common anode and common cathode displays
- Configurable refresh rate
- String display, float, and integer overloads
If this was useful, there's a Buy Me a Coffee link on the GitHub page.
Alex Rosito — self-taught electronics engineer. ATtiny85 · ESP32 · KiCad · C++
Top comments (0)