link¶

Overview¶

A packet radio link between two ESP32-S3 boards on the raw Wi-Fi I/Q paths of the esp-sdr module, at the native 80 MS/s, with an iperf style throughput test on the USB shell.

  • PHY (src/qam.c): single carrier QPSK to 4096-QAM, Gray coded per axis, raised cosine pulses (roll-off 0.35) at 4 samples per symbol (20 Mbaud, CONFIG_APP_SPS). A frame is a preamble (twice the same 64 QPSK symbols), a 32 symbol QPSK header with a CRC-16, and a payload of error correction units ending in a CRC-32. Frames are built cyclic and played in a DAC loop, so any capture window holds one whole copy. The receiver finds the preamble (Schmidl-Cox), times it against the known symbols, estimates the carrier offset (and from it the sample clock offset), trains an 11 tap equalizer by least squares and tracks phase and taps decision directed.

  • Error correction per frame: RS(255,231), extended Hamming (128,120), or none. Full frames fill the whole capture window with a short last unit (a shortened RS codeword, fewer Hamming blocks).

  • Pairs: two half length frames looped together. The receiver decodes them on both CPUs and answers with one ACK.

  • MAC (src/mac.c): CSMA/CA. Carrier sense on short captures against the measured noise floor, DIFS and binary exponential backoff in sense slots, stop and wait ARQ with immediate ACKs, retries and duplicate detection. Frames for the next transmission are built on the other CPU while the sender waits for the ACK.

Measured¶

Two XIAO ESP32S3 boards cabled through a 30 dB attenuator, TX step 4, RX gain 32, widest receive filter (bw 0), PHY in its 40 MHz mode (cbw 2), 50 us TX/RX turnaround, 5 s per run. MER is about 31.5 dB.

Mod

FEC

pairs (-w 2)

single (-w 1)

64-QAM

RS

1.29 Mbit/s

1.19 Mbit/s

64-QAM

Hamming

1.41 Mbit/s

1.28 Mbit/s

64-QAM

none

1.54 Mbit/s

1.40 Mbit/s

256-QAM

RS

1.69 Mbit/s

1.47 Mbit/s

256-QAM

Hamming

1.44 Mbit/s

1.35 Mbit/s

256-QAM

none

0.89 Mbit/s

0.60 Mbit/s

The link is bound by the receiver’s decode time (about 2.3 us per symbol per CPU), not by airtime. The MER ceiling of about 31 dB, the same with or without attenuation, rules out 1024-QAM and above on these boards.

OFDM¶

A second PHY (src/ofdm.c) with its link frames (src/ofdm_frame.c), used by the MAC instead of QAM with link set phy 1 on both boards (and the same ofdm set settings on both).

  • Configured by occupied bandwidth, subcarrier count and cyclic prefix; the FFT size follows (the power of two, 16 to 512, that spaces that many subcarriers over that bandwidth at the sample rate). Subcarriers on both sides of DC, DC unused. BPSK to 64-QAM (square, Gray coded per axis), chosen per frame (the client’s -m).

  • The whole PHY runs at 40 MS/s by default (fs 40; the DAC at 40 MS/s costs about 4 dB MER against 80 MS/s, but halves the work on both ends), or at 80 MS/s with the receiver at 40 MS/s (fs 80 rxdiv 2) or 80.

  • A frame: a Schmidl-Cox preamble (known BPSK on the even subcarriers), two pilot symbols, a BPSK header (type, modulation, addresses, sequence number, CRC-16; its bits repeated over the subcarriers and combined soft), the payload with another pilot in its middle, each symbol with a cyclic prefix. The payload is RS(255,231) in equally shortened units, byte interleaved over the frame, with a CRC-32. The frame loops in the DAC and fills half the capture window, so a whole copy always fits.

  • Receiver, on the raw capture words: an integer sliding Schmidl-Cox search (coarse at twice the stride when its peak is clear, then at full resolution), the carrier offset from its phase, the channel from the two pilots (averaged, smoothed over neighbouring subcarriers, their phase step giving the residual offset), fine timing from the pilots’ phase slope (the FFT window moved to the middle of the prefix), then per symbol a derotation table, the FFT and one complex multiply per subcarrier, with a second order phase tracker. The mid pilot measures the phase again, so the payload is demodulated on both CPUs (each half on one), and RS units are decoded on both.

  • ACKs are header-only OFDM frames (18 us), decoded in 0.8 ms.

  • The FFT is the esp-dsp ESP32-S3 kernel (CONFIG_APP_OFDM_ESP_DSP, west project esp-dsp), 1.8 times faster than the same algorithm in C from IRAM. The receive context (tables and buffers, 16 KB) sits in internal RAM above the capture bank.

Measured over the MAC (unique bytes ACKed per second; same setup as above, TX step 4, RX gain 32, cbw 2, ackair 200, 20 s each, no frame lost in any run):

Bandwidth

Subcarriers

Mod

Bytes/frame

Effective

MER

16 MHz

52

QPSK

548

0.60 Mbit/s

27 dB

16 MHz

52

16-QAM

1100

1.14 Mbit/s

27 dB

16 MHz

52

64-QAM

1684

1.63 Mbit/s

27 dB

26 MHz

84

QPSK

906

0.88 Mbit/s

24 dB

26 MHz

84

16-QAM

1850

1.67 Mbit/s

24 dB

26 MHz

84

64-QAM

2791

2.19 Mbit/s

24 dB

30 MHz

96

QPSK

1056

0.99 Mbit/s

23 dB

30 MHz

96

16-QAM

2116

1.84 Mbit/s

23 dB

30 MHz

96

64-QAM

3176

2.24 Mbit/s

23 dB

The QAM link measured 1.62 Mbit/s in the same session. Each exchange is bound by the receiver’s decode (about 9 ms at 30 MHz: search 1.1, channel and header 0.7, payload 3.7, RS 3.1 with its many corrections) and, just as long, the sender’s build of the next frame on the other CPU. 64-QAM at 30 MHz runs close to the code’s limit (RS corrects 80 bytes per frame); 26 MHz is the default. 256-QAM would need about 33 dB MER.

Wide frames need the 40 MHz mode (cbw 2, the default) and amp 90 or less (the default); the forced receive gain is lost now and then and the library re-forces it (link status, engine line). Details in doc/hardware-quirks.md.

Where the time goes¶

The rate is set by the CPUs, not the air (a frame is on air for 0.2 ms of each 10 ms exchange). Per frame at 26 MHz, 64-QAM, RS (both CPUs; the receiver in ms): search 1.1, channel and header 0.85, payload 1.2, Reed-Solomon and CRC 1.8 to 2.3 (13 units at about 0.28 ms with the typical 3 or 4 corrections, more when the MER drops); the sender builds the next frame in 4.6 on the other CPU, ACKs 0.3 each way.

  • 16-bit FFT. The receiver’s payload FFT and the transmitter’s inverse FFT run on esp-dsp’s S3 vector kernel (dsps_fft2r_sc16_aes3_, 7 us for 128 points against 38 for the float one). The input is 10 bit anyway: scaled by 32 (clipping rather than wrapping on a capture with a large offset), the output is X / N and goes back to float through ctx->fscale; the transmitter picks the largest power of two (8, 4, 2, 1) that keeps the preamble in 16 bit and rounds on the way to the DAC. The pilots and the header stay on the float FFT, where two symbols decide the channel estimate (the 16-bit one cost 0.3 dB of MER and ACKs there). OFDM_FFT16 in ofdm.c turns it off.

  • Phase tracking uses the middle half of the band, every second subcarrier (21 of 84), the strongest ones; the payload loop is specialised for 16-QAM and 64-QAM.

  • Vector payload decoder (16-QAM and 64-QAM, veq_kernel() in ofdm.c). The float decisions (46 us per symbol) were bound by the FPU’s latencies and 16 registers. Now, after the common phase is tracked (float, 21 subcarriers), four subcarriers per step on the S3’s vector unit: the FFT output gathered by bin, one complex multiply by the equalizer and one by the phase (ee.cmul.s16, shifted by SAR), then slicing as an add, a clamp and a shift, the squared error summed in the accumulator (MER), the levels packed to bytes through a table. The equalizer (ctx->veq, from the pilots’ channel estimate after every channel()) is scaled so that the spacing of the constellation is exactly 256, which turns the slicer into integer arithmetic; it needs about 10 bits of headroom and falls back to the float decoder for a frame whose weakest subcarrier does not fit. ofdm vec 0|1 switches it; ofdm rx -c decodes every capture twice, vector and float: 10 635 against 10 632 bit errors in 300 frames, the same for 16-QAM and 30 MHz.

  • Per payload symbol (26 MHz, one CPU): derotate 17 us, FFT 11, tracking 10, equalize and decide 10; before: 12 + 41 + 26 + 34. A frame’s payload takes 2.7 ms on one CPU instead of 5.7 (1.1 to 1.2 ms with both).

  • Frame assembly (ofdm_frame.c) used two integer divisions per byte with divisors that vary (units, bytes per unit): about 0.4 ms at each end. Nested loops now, and a byte table for the CRC-32 (the nibble table cost 90 us per frame).

  • OFDM_NFFT_MAX is 256 (512 cost 8 KB of internal RAM that the 16-bit tables need).

Streaming test and SNR¶

ofdm stx plays frames one after the other from both DAC banks without leaving TX (the next is built while the DAC loops the current one, a bank switch is one retrigger): frame n carries n in its header and bits that depend on n. ofdm srx regenerates them and counts frames seen, missed, clean and bit errors, and the clean rate. ofdm snr prints the SNR per subcarrier of a looped frame.

ofdm set bw 26 ch 84 mod 16qam    # on both
ofdm stx -t 5 -p 6000             # sending board: a frame every 6000 us
ofdm srx -t 6                     # receiving board

Results (two boards, 30 dB, 26 MHz, txgain 4, rxgain 32):

  • Uncoded 16-QAM is clean (BER 0 apart from captures that straddle a bank switch, 2 to 3 % of them): 2.0 Mbit/s of 17 kbit frames with the receiver on one CPU. Uncoded 64-QAM is not (BER 5e-3 in every frame): the MER is 24 dB and the band edges are at 19 to 20 dB; 64-QAM needs about 26 dB. Reed-Solomon (about 4.7 corrections per 255-byte unit on average, with a tail) is what makes it work.

  • ofdm snr: 28 dB in the middle of the band falling smoothly to 19 dB at both edges (the analog filters), so the MER is a property of the band, not of the frame: 27 dB at 16 MHz, 24 at 26, 23 at 30.

  • A broadcast (link_perf.py run --dst 0xff: no ACK, no retry) gains little: the ACK costs 0.34 ms of a 10 ms exchange.

Blind stream¶

link client 0xff -s <us> (link_perf.py run --dst 0xff --stream <us>, OFDM only) sends a stream without carrier sense or ACKs: the radio stays in TX and plays one frame after the other, each from its own DAC bank for <us>, the next built on the other CPU meanwhile (a late build keeps the old frame on air and counts as dropped). The server decodes what it catches, skips a frame it already has after the header (dup), and reports as usual; the client’s “acked” counts frames sent. Frame n carries bytes that depend on n, and the RS and CRC-32 check them.

64-QAM, RS, 26 MHz, 30 dB (received by the server, 8 s, vector decoder):

Period

Offered

Received

Lost

Dups

7000 us

3.19

3.00

0

236

6500 us

3.43

3.10

3.7 %

8

6000 us

3.72

3.04

12.5 %

0

5500 us

4.05

3.01

20 %

0

(Mbit/s.) The receiver’s cycle is about 6 ms (capture 0.4, the stages above, and the captures that straddle a bank switch, about 8 %): a shorter period only loses frames, a longer one repeats them (the repeats are skipped after the header). With the float decoder the same boards reached 2.66 Mbit/s with 10 % lost at 7 ms, over the MAC (stop and wait, CSMA) 2.3 to 2.4. The MER of the link drifts by a dB or more during a session (22.3 to 23.7 dB); more errors per RS unit make the FEC stage slower (1.8 to 2.8 ms).

Parity (CONFIG_APP_RS_PARITY, default 24 bytes per unit, RS(255,231), 12 corrections): the unit needs 4.7 corrections on average, so the 16 of the earlier RS(255,223) were far more than needed. Fewer parity bytes carry 3.6 % more payload per frame and cut the FEC stage to 1.3 to 1.9 ms. Blind stream on the same bench (64-QAM, 26 MHz, received Mbit/s and lost; the RS(255,223) column was measured earlier the same day, within the 22.3 to 23.7 dB MER drift):

Period

RS(255,223)

RS(255,231)

7000 us

3.00, 0

3.11, 0

6500 us

3.10, 3.7 %

3.34, 0.2 %

6000 us

3.04, 12.5 %

3.46, 4.1 %

RS(255,239) (16 parity bytes) is too weak here: 21 % of the frames fail. The edge carriers (19 to 20 dB) produce most of the errors; a static 16-QAM zone on the outer carriers would trade rate for fewer errors, but a model on the measured SNR shows no net gain over simply lowering the parity. Over the MAC (ACKs, 50 us turnaround) the client sends 2.78 Mbit/s, up from 2.3 to 2.4.

Building and running¶

Build with sysbuild (MCUboot, USB DFU) and flash each board once:

west build --sysbuild -b xiao_esp32s3/esp32s3/procpu esp-sdr-zephyr/apps/link
west flash

Later updates go over USB DFU with west dfu -d <build dir> [-s <usb serial>] (see apps/osmosdr/README.rst).

On the shell (/dev/serial/by-id/usb-Zephyr_Project_ESP-SDR_Link_*):

link status                       # address, settings, counters, timing
link set txgain 4 rxgain 32       # several at once; also bw, cbw, amp, air, turn, ...
link server                       # on the receiving board
link client 0x00 -t 10 -m 256qam -f rs -w 2

or from the host, for both boards at once:

tools/link_perf.py run --server <serial> --client <serial> -t 10 -m 256qam \
        -f rs -w 2 --set txgain=4 --set rxgain=32     # --dst 0xff: broadcast, no ACKs

tools/link_perf.py dump saves a capture for tools/qam_sim.c, the host simulator of the PHY (see its header for the build line).

OFDM over the MAC, both boards set alike:

tools/link_perf.py run --server <serial> --client <serial> -t 20 -m 64qam \
        --set phy=1 --set txgain=4 --set rxgain=32 --set ackair=200

ofdm set changes the layout (several keys at once: bw, ch, cp, fs, rxdiv, pilots, smooth, amp, syms, mod for the raw test). The raw PHY test outside the MAC, with known test bits:

ofdm set bw 26 ch 84 mod 64qam
ofdm tx -t 10                     # on the sending board
ofdm rx -n 100 -v                 # on the receiving one; -v: channel per subcarrier,
                                  # -d: dump the first capture with errors
ofdm bench                        # FFT and RS decode times

tools/link_perf.py ofdm --rx <serial> --tx <serial> -n 100 \
        --set txgain=4 --set rxgain=32 --set bw=30 --set ch=96

tools/ofdm_sim.c is its host simulator (PHY, link frames with FRAME=1, ACKs with ACK=1, the two-CPU split with SPLIT=1); it also decodes a capture saved with dump.

esp-sdr-zephyr

Navigation

Overview

  • ESP-SDR on Zephyr

Apps

  • capture
  • stream
  • osmosdr
  • osmosdr over a serial link
  • link
  • wpan
  • rfprobe
  • multiapp

Guides

  • ESP32-S3 SDR: hardware quirks and how they are handled
  • ESP32-S3: undocumented radio registers
  • ESP32-C6: undocumented radio registers
  • stream RX and TX signal path: antenna pin to/from host sample
  • IEEE 802.15.4 on esp-sdr-zephyr
  • Sending ACKs from the software radio
  • Testing
  • multiapp: one firmware that runs the others
  • ZigBit reference radio

Related Topics

  • Documentation overview
    • Previous: osmosdr over a serial link
    • Next: wpan
©The esp-sdr-zephyr contributors. | Powered by Sphinx 9.1.0 & Alabaster 1.0.0 | Page source