Перейти к содержанию

Лабораторная 11.30 — Оценщик грубого CFO на реальном двухплатном канале

Цель

Оценщик грубого CFO Блока 05 (qpsk_coarse_cfo.v, моделируемый coarse_cfo_ref.py) был проверен только на синтетических сдвигах: модель с плавающей точкой, fixed-point модель, побитово с ней совпадающая, и RTL-testbench, питаемый сгенерированным захватом. У всех этих проверок общее допущение: что реальная пара AD9361 выглядит как модель.

Одна плата этого не проверит. TX_LO и RX_LO берутся от одного опорного генератора, поэтому CFO ≈ 0, и оценивать нечего. Эта работа использует две платы с независимыми опорными генераторами (единственную конфигурацию, где существует реальный сдвиг несущей) и заново прогоняет те же самые оценщики float и fixed-point по восстановленным символам. Fixed-point тракт это арифметика RTL, поэтому согласие здесь: свидетельство о поставляемом HDL, а не о симуляции.

Установка

плата A  ──  циклический QPSK из ЦАП через DMA (RRC курса, SPS=8, 480 кСимв/с, 3,84 МГц)
   │
   │   TX1 ──▶ аттенюатор 30 дБ ──▶ RX1     (закрытый кабель SMA, 915 МГц)
   ▼
плата B  ──  захват сырых IQ ──▶ SSH ──▶ хост: оценщик, EVM, SER

Плата B: вторая плата AD9361 в своей подсети, поэтому обе могут делить один коммутатор без конфликта адресов (ZYNQ_SSH_HOST_B, по умолчанию 192.168.20.1). Передаваемая форма сигнала строится из тех же коэффициентов RRC Q15, что использует передатчик PL, а RRC применяется циклически: DMA бесконечно воспроизводит буфер от конца к началу, и линейная свёртка оставила бы шов, размазывающий спектр.

Запуск

# ВСЕГДА сначала: цепочка анализа на сгенерированной форме сигнала, без радио в тракте.
python blocks/block_11_integrated_sdr_project/python/lab_11_30_two_board_cfo_validation.py --self-test

# затем реальный канал
python blocks/block_11_integrated_sdr_project/python/lab_11_30_two_board_cfo_validation.py

Полезные флаги: --carrier (по умолчанию 915e6), --tx-gain (по умолчанию −30 дБ, кондуктивно), --rx-gain, --symbols, --capture, --window, --host-a/--host-b.

Результат (2026-07-17, 915 МГц, TX −30 дБ на аттенюатор 30 дБ, усиление RX 50 дБ)

Дословно из скрипта:

board A transmitting: TX=-30.000000 dB at 915.000 MHz, DAC source=0x00000002 (DMA), LO_powerdown=0
receiver alive: capture rms = 253.5 counts
contiguity check: |autocorr| at the 32768-sample cyclic period = 0.9989

timing mu = 1.625 samples, |corr| = 35.6x
EVM       = 6.62 %
CFO       = -173.2 Hz (std 13.4 Hz) -> -0.189 ppm at 915 MHz
estimator float vs fixed(RTL): mean |diff| = 0.14 Hz, max 0.34 Hz
SER       = 0 / 15360 = 0.000000
Метрика Значение
Непрерывность \|autocorr\| на циклическом периоде 0,9989
Пик корреляции в 35,6× выше среднего
EVM 6,62 %
Межплатный CFO −173,2 Гц, СКО 13,4 Гц (−0,189 ppm на 915 МГц)
Оценщик float против fixed (RTL) среднее |diff| = 0,14 Гц, максимум 0,34 Гц
SER 0 / 15 360 символов (30 720 бит)

Два независимых TCXO оказываются на расстоянии ~0,2 ppm: пара сотен Гц на 915 МГц, стабильно с точностью ~13 Гц на протяжении записи. Оценщик однозначен в пределах ±60 кГц (±1/8 цикла на символ при 480 кСимв/с), так что железо лежит внутри проектного диапазона с запасом более двух порядков, а fixed-point арифметика, выполняемая RTL, следует за эталоном float с точностью до долей герца на живом сигнале. Более ранний интерактивный прогон той же последовательности дал EVM 6,59 %, CFO −208,0 Гц и float-против-fixed 0,09 Гц: те же числа в пределах дрейфа от прогона к прогону.

Сравнение оценщиков подаёт обеим моделям те же символы того же (не развёрнутого) сегмента. Если дать fixed-модели развёрнутый срез или иное число символов, измеряется собственная дисперсия двух оценщиков, а не арифметика до исправления это раздувало показатель с 0,14 Гц до 66 Гц.

Четыре ловушки, зашитые в эту работу

Каждая из них давала убедительный, но совершенно ложный «аппаратный дефект». Три из четырёх были ошибками измерения, а не радио.

1. iio_readdev по умолчанию использует буфер на 256 отсчётов

Без -b <N> захват склеивается из N/256 фрагментов с пропущенными отсчётами между ними. Каждый фрагмент верен, поэтому спектр и EVM выглядят вполне разумно, но запись не является непрерывным временным рядом. Симптомы: корреляция никогда не захватывается (5–8× вместо 20–70×), CFO как будто «гуляет» на десятки кГц, BER застревает около случайного.

Всегда задавайте размер буфера равным всему захвату и проверяйте:

|autocorr| на периоде циклического буфера:   с разрывами ≈ 0,01      непрерывный ≈ 0,99

CFO эту проверку не ломает (он лишь добавляет постоянную фазу) поэтому это чистый ответ «да/нет» о непрерывности. Работа выполняет её автоматически и предупреждает.

2. Тон DDS не включает синтезатор TX

При adi,tx-lo-powerdown-managed-enable=1 драйвер держит out_altvoltage1_TX_LO_powerdown=1, пока не появится настоящий поток TX. Тон только от DDS потоком не является, поэтому синтезатор остаётся выключенным, и с разъёма SMA ничего не выходит независимо от hardwaregain. Признак безошибочен, когда его знаешь: изменение усиления TX на 40 дБ не сдвигает принятый уровень вообще это сломанный тракт, а не слабый. Не гоняйтесь за кабелями.

Буфер DMA (то, что использует эта работа) является таким потоком и сам поднимает LO. Чтобы разделить цифровую и аналоговую части, когда канал молчит, используйте собственный loopback AD9361 это атрибут debugfs называется loopback, а не bist_loopback:

echo 1 > /sys/kernel/debug/iio/iio:device0/loopback   # цифровой TX→RX внутри микросхемы

Сильный тон там при тишине при loopback=0 относит неисправность в аналоговую область/LO и снимает подозрения с прошивки, ядра DDS и каждого кабеля.

3. Эталон для EVM нужно повернуть на +45°

Для QPSK sym**4 = −1 всегда, поэтому angle(mean(sym**4))/4 = π/4, и коррекция кладёт созвездие на оси, а не на 45°. Если сравнивать его с (±1±j)/√2, вы измерите постоянные ≈54 %, которые не сдвинет ни изменение усиления, ни тайминга, ни LO. Эта неподвижность и есть подсказка: физика сдвигается, а постоянная арифметическая ошибка это нет.

4. Знак корреляции

ifft(fft(a) * conj(fft(b))) имеет пик в off, означающем a[n] ≈ b[n−off], поэтому выровненный эталон: np.roll(tx, +off). Неверный знак даёт ровно 0,75 SER случайного угадывания при том что корреляция по-прежнему сообщает идеальный захват.

Правило, которому учат все четыре: прогоните всю цепочку анализа на известном сигнале без радио в тракте и потребуйте SER = 0 до каких-либо выводов о железе. --self-test делает именно это за несколько секунд.

Повторяемость

Работа идёт подряд без перезагрузок между прогонами. Проверено 25-прогонной серией без присмотра (232 с), без сбоев:

За 25 прогонов
Декодировано 768 000 бит, 0 ошибок → BER = 0 (0 / 384 000 символов)
Межплатный CFO среднее −288,5 Гц, σ 11,0 Гц (мин −311,5, макс −262,0)
EVM среднее 7,35 % (6,65…8,27 %)
Захват корреляции ≥ 35,5× выше среднего; непрерывность \|autocorr\| ≥ 0,994 в каждом прогоне

Это возможно благодаря двум вещам, и обе достались тяжело:

  • Приёмник должен быть платой того же класса, что и передатчик (стоковый ADI/Pluto, Linux 5.15). Первый приёмник (устройство FishBall/ZynqSDR на замороженном Linux 4.0) имел драйвер, у которого RX DMA «зависал» примерно после одного захвата/переконфигурации за загрузку, игнорировал обычный reboot, иногда игнорировал sysrq и порой требовал физического выключения питания. Перепрошивка тем же образом Pluto, что у передатчика, убрала всё это: его RX теперь возвращает чистые данные в каждом прогоне, а reboot работает.
  • Циклический DMA передатчика освобождается в начале каждого прогона функцией reset_tx_dma(). Если оставить как есть, iio_writedev -c застревает в неубиваемом состоянии (uninterruptible-DMA), удерживая буфер ЦАП, и следующий прогон падает с Unable to allocate buffer: Device or resource busy, а pkill -9, SIGTERM и переключение RSTN ядра DDS не помогают. Помогает unbind+rebind драйвера cf_axi_dds. Он заново регистрирует ядро DDS под новым iio:deviceN, поэтому работа обращается к передатчику и приёмнику по ИМЕНИ и читает источник ЦАП по физическому адресу: ничто не зависит от номера устройства DDS.

Рецепт: прошейте обе платы стендовым образом (см. README-bench.txt для каждой платы на её SD-карте), затем просто запускайте работу: многократно, без присмотра. Сначала запустите --self-test, если вы меняли анализ на стороне хоста.

По-прежнему держите -b неизменным в рамках сессии: захват с другим -b заставляет драйвер склеивать следующий из устаревших фрагментов (|autocorr| падает до ~0,02, то есть ловушка 1). Работа использует один размер буфера (--capture) для своего единственного чтения, так что это касается только случая, когда вы вручную опрашиваете приёмник между прогонами: не делайте этого.

Ловушка запуска, о которой стоит знать

ParamikoCommandRunner оборачивает каждую команду в sh -lc '...; rc=$?; printf ...' и читает канал до EOF. Фоновое задание, запущенное через него (даже nohup ... &) уничтожается вместе с обёрткой, молча и с пустым журналом. Измерено бок о бок:

запуск через ParamikoCommandRunner:  DAC source = 0x0 (DDS), writedev исчез
запуск через raw exec_command:       DAC source = 0x2 (DMA), writer жив

Поэтому передатчик запускается в сыром сеансе (start_detached). А поскольку работающий iio_writedev всё же не доказывает передачу, работа считывает выбор источника ЦАП обратно и требует 0x2; канал, оставленный на DDS с нулевым масштабом, иначе выглядит в точности как мёртвый канал: именно так это и обнаружили.

RF-безопасность

Это кондуктивная работа: TX1 платы A попадает на RX1 платы B через кабель и аттенюатор 30 дБ, и ничего не излучается. TX −30 дБ по умолчанию безопасен за этим аттенюатором. По воздуху предел это −50 дБ: не повышайте мощность, чтобы «увидеть сигнал».

Стоковый rc.user платы B при каждой загрузке заново включает передатчик 71 МГц на −10 дБ. Эта работа глушит его и принудительно выставляет −89,75 дБ до какого-либо иного действия и приглушает обе платы при выходе в том числе после сбоя.