AD9361 ADC -> rx_ingress -> ddc_core -> rx_switch -> agc -> matched_filter
-> timing_recovery -> carrier_recovery -> slicer
-> demod (BPSK | QPSK | GMSK) -> deframer -> fec_rx -> DMA (bytes)In PL loopback the input comes from the channel emulator instead of the ADC (the loopback mux
sits in front of the RX chain). The DDC-side blocks live in the window at 0x4001_0000, the
demodulator-side blocks at 0x4002_0000.
rx_ingress#
Packs the AD9361's 12-bit I/Q into the 16-bit datapath with a digital gain.
| Register | Meaning |
|---|---|
rx_ddc.ingress.ctrl | [0] enable (radio.rx_enabled), [1] clear counter, [2] clear status |
rx_ddc.ingress.gain | Q8.8 digital gain (0x0100 = 1.0); radio gain --domain digital |
rx_ddc.ingress.status | [0] ADC overflow (sticky), [1] post-gain saturation (sticky), [2] running |
rx_ddc.ingress.smpl_cnt | Samples received |
ddc_core#
A complex NCO mixer, a decimator by DECIM (a single-stage running-sum CIC), and a compensation
FIR (identity by default).
| Register | Meaning | Set by |
|---|---|---|
rx_ddc.ddc.ctrl | [0] enable | Profile |
rx_ddc.ddc.decim | Decimation 1 to 32, always equal to the TX interpolation | Profile |
rx_ddc.ddc.nco_freq | Q32 phase increment | Tuning (LO offset), radio nco --rx-hz |
rx_ddc.ddc.filter_sel, gain, status | FIR bank, gain, busy | — |
rx_ddc.rx_switch then routes the DDC output to the demodulator and to the monitor taps.
agc#
Estimates |x|², compares it with TARGET, and integrates the gain up (DECAY step) or down
(ATTACK step).
| Register | Meaning | Profile value |
|---|---|---|
rx_demod.agc.ctrl | [0] enable, [1] clear | enabled |
rx_demod.agc.target | Target |A|² = I² + Q² | 0x0409C0C9: 12 dB below a full-scale QPSK |
rx_demod.agc.attack, decay | Step sizes | 0x0100 each |
rx_demod.agc.gain | Current gain, Q4.12 (read-only) | → telemetry agc_gain |
rx_demod.agc.status | [0] locked (error within 12.5 % of target) |
The target sits 12 dB under full scale because the AGC acts before the matched filter, whose DC gain is about 2.8: at full scale the filter clipped most samples and blinded the carrier loop. A gain pinned at its Q4.12 ceiling (just under 16) means the input is too weak: raise the RX gain.
matched_filter#
A 33-tap complex FIR, root-raised-cosine α = 0.35 at 4 samples per symbol, built at synthesis time.
| Register | Meaning |
|---|---|
rx_demod.matched_filter.ctrl | [0] enable, [1] clear shift register |
rx_demod.matched_filter.coeff_bank | Write a tap: index in [15:0], Q15 coefficient in [31:16] |
rx_demod.matched_filter.sps | Inert: the kernel is fixed at 4 sps |
timing_recovery#
Gardner timing recovery with a fractional (Farrow, piecewise-parabolic) interpolator: two interpolants per symbol, on-time and mid-symbol, driven by a PI loop.
| Register | Meaning |
|---|---|
rx_demod.timing_recovery.ctrl | [0] enable, [1] clear |
rx_demod.timing_recovery.loop_bw | [7:0] kp, [15:8] ki (profile: 0x0202) |
rx_demod.timing_recovery.status | [0] locked → telemetry lock |
rx_demod.timing_recovery.phase_err | Normalised Gardner error, signed 16-bit |
rx_demod.timing_recovery.lock_cnt | Symbols spent under the lock threshold |
The lock detector also requires a minimum input magnitude, so silence releases the lock.
carrier_recovery#
A second-order Costas loop, one update per symbol, with acquisition aids.
| Register | Meaning |
|---|---|
rx_demod.carrier_recovery.ctrl | [0] enable, [1] clear, [2] mode (0 QPSK, 1 BPSK), [3] blind sweep, [4] frequency-locked loop, [5] FLL always, [6] x4 frequency estimator, [7] hold the frequency while there is no signal |
rx_demod.carrier_recovery.loop_bw | [7:0] kp, [15:8] ki, [19:16] kp shift, [27:24] ki shift (profile: 0x04080420) |
rx_demod.carrier_recovery.fll | FLL gain: [7:0] kf, [11:8] kf shift |
rx_demod.carrier_recovery.fdet_cfg, fdet_thresh | x4 estimator window and lock threshold |
rx_demod.carrier_recovery.fdet_freq, fdet_status | Residual measured by the estimator, and its verdict |
rx_demod.carrier_recovery.sweep_range, sweep_step | Blind sweep bounds (±3 kHz, sized from the symbol rate) |
rx_demod.carrier_recovery.cfo | freq_q, the loop's frequency: cfo_hz = freq_q · Rs / 2³² |
rx_demod.carrier_recovery.phase_err | Phase detector output (unitless) |
rx_demod.carrier_recovery.status | [0] locked → telemetry carrier_lock |
What a profile arms depends on the path:
| Path | ctrl | Why |
|---|---|---|
PL loopback (radio.loopback: true) | 0x01 | There is no frequency to acquire; aids would only cost accuracy |
| RF | 0xD1: FLL, x4 estimator, silence hold | The FLL pulls the loop onto the offset; the x4 estimator owns the lock verdict above about 12 dB Es/N0 so a rotating constellation is not read as locked; the hold stops the frequency integrator drifting on noise during silences |
The blind sweep (bit 3) is left off: it false-locked on frequency aliases in bench tests.
slicer and demodulators#
The slicer takes a hard, Gray-coded decision on the sign of I and Q and counts symbols
(rx_demod.slicer.sym_cnt → telemetry rx_symbols). The demodulator matching the profile is
enabled; the others are switched off.
| Block | Registers | Notes |
|---|---|---|
demod_bpsk | ctrl, status, evm | Coherent |
demod_qpsk | ctrl, status, evm, sig_pwr, err_pwr, win_cnt | Coherent. Signal and error powers over 1024-symbol windows give snr_db and mer_db |
demod_gmsk | ctrl, bt, status | Non-coherent FM discriminator, hard decision. No EVM |
deframer#
Searches the sync marker, delimits frames and descrambles.
| Register | Meaning |
|---|---|
rx_demod.deframer.ctrl | [0] enable, [1] 2 bits per symbol (QPSK), [2] clear counters |
rx_demod.deframer.sync_word | The marker to search |
rx_demod.deframer.frame_len | Frame length on the coded stream |
rx_demod.deframer.scramble | [0] enable, [15:8] seed (same layout as the TX scrambler) |
rx_demod.deframer.status | [0] locked, [2:1] state, [4:3] rotation of the last matched marker (0/90/180/270°) |
rx_demod.deframer.frame_cnt | Frames emitted → telemetry rx_frames |
rx_demod.deframer.err_cnt | Markers matched with one wrong bit → telemetry rx_frame_errors, ber_asm |
The marker is correlated against every rotation of the constellation the modulation allows (four for QPSK, two for BPSK and GMSK) with at most one bit of error. The matched rotation is undone on the payload before byte assembly and descrambling, so the bytes come out as sent whichever point the carrier loop settled on.
The deframer applies back-pressure: if nothing consumes its output (no RX block submitted), it parks with a byte pending and the jam propagates back to the transmitter. The reader must be armed before the feed; the API and CLI measurement commands do this for you.
fec_rx#
Viterbi decoder (64 states, hard decision) followed by the Reed-Solomon (255,223) decoder (syndromes, Berlekamp-Massey, Chien search, Forney).
| Register | Meaning |
|---|---|
rx_demod.fec_rx.ctrl | [0] enable, [1] bypass Viterbi, [2] bypass RS |
rx_demod.fec_rx.corr_cnt | Bytes corrected by RS |
rx_demod.fec_rx.fail_cnt | Uncorrectable codewords |
rx_demod.fec_rx.rate, status | Rate, busy |
RS corrects up to 16 byte errors per codeword; beyond that it declares the failure rather than returning a wrong word silently, and the codeword is still passed on, errors included. The RS decoder is double-buffered, so a codeword is accepted while the previous one is corrected and the decoder closes its input for only about 20 cycles per codeword.
Reading the chain from the outside#
| Question | Look at |
|---|---|
| Is any signal reaching the receiver? | part_rssi_db, rx_ddc.ingress.smpl_cnt, agc_gain below its ceiling |
| Are symbols being decided? | rx_symbols moving |
| Is the constellation clean? | evm, snr_db/mer_db, sig_pwr/err_pwr |
| Are frames found? | rx_frames moving while tx_frames moves |
| How degraded is the channel? | rx_frame_errors/ber_asm, RS corr_cnt, fail_cnt |
| Are the bytes right? | Only a byte comparison (loopback measurement) or a transfer frame's FECF says |