A serial receiver recovers the transmitter's clock, so it can capture every symbol. But the rest of the receiving chip runs on its own clock, which differs by a few hundred ppm. Symbols arrive slightly faster (or slower) than the chip consumes them, about one extra symbol every couple of thousand. A FIFO between the two clocks fills up (or runs dry) in a few milliseconds, however deep it is.
How does a receiver hand a continuous stream from one clock to a slightly different one, forever, without losing or inventing data?
The obvious answer is an asynchronous FIFO between the recovered clock and the local clock. It handles the crossing safely, but it cannot change the rate: if the writer is 600 ppm faster than the reader, the FIFO gains a symbol every 1,667 cycles, and any finite depth eventually overflows. Making it deeper only delays the failure. Something must actually remove symbols when the writer is fast and add symbols when it is slow, and it must never be data.
Two pieces work together:
The protocol makes the transmitter send filler symbols at regular intervals: symbols that carry no information, so removing or duplicating one changes nothing. Serial links send them in small groups, often called skip (SKP) symbols.
The receiver has an elastic buffer between the recovered clock and its local clock, and it watches how full the buffer is. When the buffer is filling beyond its midpoint, it deletes a filler symbol from a group as it passes. When it is draining below the midpoint, it inserts one by reading a filler symbol twice. Data symbols are never touched.
The buffer stays near half full, and the stream's real content passes through unchanged. The transmitter does not know or care whether its filler was shortened or stretched.
Each filler group gives the receiver one chance to remove or add a symbol. The clocks gain one symbol on each other every 1/Δ symbols, where Δ is the fractional frequency difference (lesson 1.6). So groups must come at least that often, with margin:
600 ppm difference -> one symbol of drift every 1 / 0.0006 = 1,667 symbols
Filler group every K symbols, one adjustment per group -> need K < 1,667 (with margin)
Buffer depth: enough above and below the midpoint to ride out the drift between groups,
plus the receiver's own pipelining. A filler group every 1,180 symbols (illustrative)
means at most 1,180 x 0.0006 = 0.7 symbols of drift between chances to correct it.The model writes symbols at the far clock's rate and reads one per local cycle. The transmitter inserts a group of three filler symbols (S) after every K data symbols, and the receiver deletes or repeats one filler per group to stay near half of a 16-entry buffer. It checks that the data comes out complete and in order, then runs once more with no filler at all.
"""An elastic buffer between two clocks that differ by a few hundred ppm.
The transmitter's clock writes symbols into the buffer; the receiver's clock reads
one symbol per cycle. Every K data symbols the transmitter sends a group of three
SKP symbols that carry no data. When the buffer is filling up, the receiver deletes
one SKP from a group; when it is draining, it inserts one by reading a SKP twice.
Data symbols are never added or dropped.
"""
from collections import deque
DEPTH, MID = 16, 8
def run(write_ppm, cycles, K):
rate = 1 + write_ppm * 1e-6 # writer symbols per reader cycle
buf = deque(["S"] * MID) # start half full
acc, next_data, since_skp = 0.0, 0, 0
pending = deque() # symbols the writer will send, one per writer cycle
sent, out = [], []
deleted = inserted = 0
fill_trace = []
group_adjusted = False
for cyc in range(cycles):
acc += rate
while acc >= 1: # writer side: 0, 1 or 2 symbols this reader cycle
acc -= 1
if not pending:
if since_skp >= K:
pending.extend(["S", "S", "S"]); since_skp = 0
else:
pending.append(next_data); sent.append(next_data); next_data += 1; since_skp += 1
buf.append(pending.popleft())
if len(buf) > DEPTH:
return dict(failed=f"overflow at cycle {cyc}", trace=fill_trace)
if not buf:
return dict(failed=f"underflow at cycle {cyc}", trace=fill_trace)
head = buf[0]
if head != "S":
group_adjusted = False
if head == "S" and not group_adjusted and len(buf) > MID + 1 and len(buf) > 1:
buf.popleft(); deleted += 1; group_adjusted = True # delete one SKP
head = buf[0]
if head == "S" and not group_adjusted and len(buf) < MID - 1:
out.append("S"); inserted += 1; group_adjusted = True # insert: read it again
else:
out.append(buf.popleft())
if cyc % 2000 == 0:
fill_trace.append(len(buf))
data_out = [x for x in out if x != "S"]
assert data_out == sent[: len(data_out)] # data intact, in order
return dict(failed=None, deleted=deleted, inserted=inserted, trace=fill_trace)
for ppm in (+600, -600):
r = run(ppm, 200_000, K=1180)
assert r["failed"] is None
t = r["trace"]
print(f"writer {ppm:+d} ppm, SKP every 1180 symbols: fill stayed {min(t)}..{max(t)}, "
f"{r['deleted']} SKP deleted, {r['inserted']} inserted")
r = run(+600, 30_001, K=10**9)
print(f"writer +600 ppm, no SKP symbols: {r['failed']}")
print("fill every 2000 cycles without SKP:", r["trace"])
print("fill every 2000 cycles with SKP: ", run(+600, 30_001, K=1180)["trace"])
print("PASS: SKP deletion and insertion keep the buffer near half full; without them it overflows")It prints:
writer +600 ppm, SKP every 1180 symbols: fill stayed 8..9, 119 SKP deleted, 0 inserted
writer -600 ppm, SKP every 1180 symbols: fill stayed 5..7, 0 SKP deleted, 118 inserted
writer +600 ppm, no SKP symbols: overflow at cycle 13333
fill every 2000 cycles without SKP: [8, 9, 10, 11, 12, 14, 15]
fill every 2000 cycles with SKP: [8, 9, 8, 8, 8, 9, 8, 9, 8, 8, 9, 9, 8, 9, 8, 9]
PASS: SKP deletion and insertion keep the buffer near half full; without them it overflowsWith a fast writer the receiver deleted 119 filler symbols in 200,000 cycles, one per slip (200,000 / 1,667 ≈ 120); with a slow writer it inserted 118. Without filler, the buffer overflowed at cycle 13,333: it started 8 symbols below full, and 8 / 0.0006 = 13,333. Depth bought time, not a solution.
Option | What it costs | When it wins |
|---|---|---|
Elastic buffer with filler adjustment (this lesson) | Filler bandwidth; a buffer; the protocol must define filler symbols | Continuous serial streams between plesiochronous clocks |
Shared reference clock on both ends | A clock distributed to both ends | Boards and systems that can route one reference; the ppm difference shrinks (often the buffer still exists for safety) |
Run the receiver logic on the recovered clock | The whole receiver in another clock domain | Simple devices where the data leaves on the same clock it arrived with |
Async FIFO with idle gaps the receiver can drop | Needs gaps in traffic | Packet links with idle time between packets |
Protocol | Its choice |
|---|---|
PCI Express | Receivers compensate for clock differences with an elastic buffer that adds or removes SKP symbols in SKP ordered sets, which transmitters send at regular intervals. The two ends may differ by up to 600 ppm in PCIe 1.0–4.0. |
UART | No continuous stream, so no compensation: each frame resynchronises on its start bit (lesson 1.2). |
A transmitter optimisation suppresses filler groups while a long packet is being sent, to squeeze out a little more bandwidth (illustrative). Long packets now run for more than 1/Δ symbols with no filler, and the receiver's elastic buffer drifts beyond its limit during the packet, dropping or duplicating a data symbol. The link layer's CRC then rejects the packet and it is replayed, so the bug shows up only as a rising replay count under heavy traffic.
Symptom: replays increase with packet length and traffic, not with error-prone channels.
Evidence: elastic buffer overflow or underflow events during long packets.
Fix: send filler at the interval the specification requires, even in the middle of a burst; the filler interval is a promise to the receiver, not an option.
One symbol of drift every 1 / 0.0003 ≈ 3,333 symbols, so groups must come more often than that, with margin for the buffer's pipelining.
Filler carries no information, so removing or duplicating it leaves the stream's content unchanged. Touching a data symbol would corrupt the data.
The buffer would drain by about one symbol every 1,667 cycles and underflow, leaving the reader with nothing valid to read.
A deeper FIFO cannot fix a rate difference; only removing or adding symbols can.
The transmitter sends filler symbols regularly; the receiver's elastic buffer deletes or repeats them to stay half full.
Filler must come more often than once per 1/Δ symbols, where Δ is the clock difference; the interval is a promise to the receiver.