Skip to content

Interleaving — spreading a burst across codewords

An interleaver adds no redundancy, detects nothing, and returns exactly as many bits as it was given. What it does is change where errors land, and that is worth a design page because the reasoning around it is easy to get wrong in three specific ways — each of which was got wrong at least once while building this, and each of which produces something that still round-trips against itself.

The transform

Write the input by rows into a rows × cols matrix; read it back by columns.

write ->   c0  c1  c2  c3          read |
row0 [  0   1   2   3 ]                 v
row1 [  4   5   6   7 ]      0 4 8 1 5 9 2 6 10 3 7 11
row2 [  8   9  10  11 ]

That is the whole of it. dp_interleave.h is the permutation as an index map and two typed appliers; coding.Interleaver holds a geometry and calls it.

De-interleaving is the same function with the two arguments exchanged — reading a rows × cols matrix by columns is writing a cols × rows one by rows — so the inverse is not a second implementation. A square block is therefore its own inverse, which is a fact worth knowing before you rely on it accidentally.

What it is for

Write one codeword per row. Reading by columns then transmits one symbol from each codeword in turn, so a burst of up to rows consecutive symbols on the wire costs each codeword at most one. An outer code correcting t symbols per codeword survives a burst of t × rows.

The two numbers are a link budget rather than a tuning pair: rows is the longest burst fully spread, cols is the codeword length.

Three ways to get this wrong

1. Interleaving a single codeword buys nothing

Reed-Solomon corrects any E symbol errors wherever they fall. Permuting them inside one codeword changes nothing a decoder can see. The gain exists only when there are codewords to spread a burst across.

This is not a subtlety — it is the whole mechanism, and a validation built on a single codeword measures a flat zero and reads like a broken interleaver. The first draft of validate_interleave_burst_gain did exactly that.

2. It is not the outer code's own depth

ccsds_tm_rs_encode_block(depth) interleaves depth codewords and gets this property for free — its header says so. Depth-I interleaving is intrinsic to the Reed-Solomon codeblock layout (131.0-B-6 §4.4.1), fused into encode and decode, and it is not a permutation applied afterwards.

So the two share a name and no implementation, and neither can be written in terms of the other. A block interleaver is the general form: it works over whatever span it is given, including many codeblocks and codes with no interleaving of their own.

3. The unit must match the code's symbol

An RS code over GF(256) is protected by permuting octets. Permuting bits inside such a code spreads a burst within symbols that are already wrong.

The measured difference is smaller than the argument suggests, and the reason is worth stating precisely: each row still receives its share of a burst as a contiguous run of bits, so the error count is about the same either way. What differs is alignment — at unit_bits = 8 each codeword gets exactly ceil(B/rows) whole symbols, and at unit_bits = 1 the run is not octet-aligned so it can touch one more symbol at each end. That is invisible until the burst is near the bound, and decisive there.

unit_bits is also an 8× throughput parameter, because an octet unit is one memcpy per eight bits rather than one per bit: 515 Mbit/s against 4133 (bench_interleaver_core).

The measured gain

native/validation/interleave_burst_gain.c, 5 × RS(255,223), E = 16, one codeword per interleaver row:

burst (octets) FER, no interleaver FER, unit_bits=8 FER, unit_bits=1
16 0.000 0.000 0.000
17 0.917 0.000 0.000
80 1.000 0.000 0.792
81 1.000 1.000 1.000

The corrigible burst goes from E = 16 octets to E × rows = 80 — and stops there. An interleaver multiplies the corrigible burst by the depth; it does not remove the bound, and 81 fails for every configuration.

0.917 rather than 1.000 at burst 17 is physical: a burst of E+1 straddling a codeword boundary splits into two runs of at most E, and both correct.

Where it sits in a frame

WFM_STAGE_INTERLEAVE covers the data group — never the sync word or a marker, which a receiver finds by correlation and which must therefore look the same in every frame. It is applied after the outer code and the randomiser and before the inner code, because an interleaver exists so a burst on the channel arrives spread across the outer code's codewords: anything between it and the wire would undo the point.

The column count is derived from the span the stage covers, since a stage's cover is what says how much there is to permute. A span that is not a whole number of depth × unit units is refused rather than padded — padding changes the length, and a receiver de-interleaving the padded block recovers different bits.

The soft path

DsssBurstReceiver.llrs spans the whole frame, and an outer decoder wants those LLRs de-interleaved before it runs. Slicing to hard decisions first and de-interleaving those throws away the confidence the soft output exists to carry, which is most of what an outer code is for.

This is why dp_interleave.h permutes indices rather than bytes: one kernel serves uint8 and float32 alike. There is no interleave_soft, because a transmitter has bits, not LLRs.

Not a convolutional interleaver

A Forney interleaver is a different structure with different latency and memory behaviour. It is deliberately absent rather than pending — see #1031.

Where the code is

what where
the permutation native/inc/dp_interleave.h (header-only)
the object native/inc/interleaver/interleaver_core.h, coding.Interleaver / coding.Deinterleaver
the frame stage WFM_STAGE_INTERLEAVE in native/src/wfm/wfm_frame.c
the CLI wfmgen --interleave R [--interleave-unit N]
the measured gain native/validation/interleave_burst_gain.c