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

Лабораторная 8.14 — OFDM RTL: от mapper до эквализированного loopback

Цель

Провести OFDM мини-линию из Лабораторной 8.5 от модели с плавающей точкой на Python к синтезируемому, самопроверяемому fixed-point тракту: QPSK mapper, распределитель/экстрактор поднесущих, потоковый radix-2 64-точечный IFFT/FFT, вставка/удаление циклического префикса и одноотводный эквалайзер, каждый со своим тестбенчем на Icarus Verilog, собранные в полный цифровой loopback TX-RX и в эквализированный loopback через реальный канал.

Эта лабораторная: программно-RTL половина issue #48. Прочитайте границу доказательности прежде чем считать любой результат здесь аппаратным измерением это не так.

Зачем нужна вторая, RTL-модель той же цепочки

Лабораторная 8.5 уже доказывает, что алгоритм OFDM работает в плавающей точке. Это не то же самое, что «это работает на кристалле FPGA». Каждый этап здесь существует дважды, намеренно:

  • один раз как побитово точная Q1.15 fixed-point модель на Python (tools/ofdm_ifft_fixed.py, tools/ofdm_fft_fixed.py, tools/ofdm_equalizer_fixed.py), которая явно отслеживает каждое решение об округлении и насыщении;
  • один раз как синтезируемый Verilog, который проверяется по тем же самым закоммиченным векторам, что и fixed-point модель, а не только по ожиданиям собственного тестбенча.

Именно вторая копия делает утверждение «RTL корректен» опровержимым, а не предположением. Файлы verification/vectors/block08_ofdm_*.mem это общая точка истины: tests/test_ofdm_ifft_fixed.py, tests/test_ofdm_fft_fixed.py и tests/test_ofdm_tx_fixed.py сверяют Python-модель с ними, а tb_ofdm_ifft64_sequential.sv/tb_ofdm_fft64_sequential.sv сверяют RTL с теми же файлами, так что расхождение между Python и RTL не может остаться незамеченным.

Цепочка TX

flowchart LR
    BITS["48 пар QPSK-бит"] --> MAP[ofdm_qpsk_mapper]
    MAP --> ALLOC[ofdm_subcarrier_allocator]
    ALLOC --> IFFT[ofdm_ifft64_sequential]
    IFFT --> CP[ofdm_cp16_inserter]
    CP --> OUT["80 Q1.15 отсчётов/символ"]
Блок Роль Fixed-point / временной контракт
ofdm_qpsk_mapper.v 2 бита -> один Q1.15 QPSK-символ, знаковое соглашение как в qpsk_from_bits из Lab 8.5 амплитуда round(32767/sqrt(2)) = 23170; латентность 1 такт
ofdm_subcarrier_allocator.v Размещает 48 символов данных в 64-бинный кадр IFFT (естественный порядок) и вставляет четыре пилотных значения (pilot_ref = [+1,+1,+1,-1]) на k = -21,-7,+7,+21, точно как частотное распределение в Lab 8.5 собирает 48 символов, затем выдаёт бины 0..63; новый кадр не принимается до конца выдачи
ofdm_ifft64_sequential.v Потоковый radix-2 DIT IFFT, один экземпляр ofdm_ifft_butterfly, переиспользуемый на все 6 стадий x 32 бабочки каждая бабочка делит на 2 -> нормированный масштаб 1/64; total_saturation_count накапливает насыщенные финальные компоненты по всем 192 бабочкам
ofdm_cp16_inserter.v Добавляет отсчёты [48..63] в качестве циклического префикса выдаёт 80 отсчётов/символ (out_is_cp высок для индексов 0..15); frame_error, если входной символ был неполным

ofdm_tx_mapper_ifft_path.v собирает mapper -> allocator -> IFFT; ofdm_tx_cp16_path.v добавляет этап CP и является полным TX-базисом, используемым в loopback-тестах ниже.

Цепочка RX

flowchart LR
    IN["80 Q1.15 отсчётов/символ"] --> CPR[ofdm_cp16_remover]
    CPR --> FFT[ofdm_fft64_sequential]
    FFT --> CEQ["ofdm_channel_equalizer (по поднесущим, обучающий символ)"]
    CEQ --> EXT[ofdm_subcarrier_extractor]
    EXT -->|данные| BUF["буфер символа на 48 отсчётов"]
    EXT -->|пилоты| PILOT[ofdm_pilot_phase_tracker]
    BUF --> EQ[ofdm_one_tap_equalizer]
    PILOT -->|"коэффициент этого символа"| EQ
    EQ --> DEMAP[ofdm_qpsk_demapper]
    DEMAP --> BITS["восстановленные пары бит"]
Блок Роль Fixed-point / временной контракт
ofdm_cp16_remover.v Отбрасывает 16-отсчётный префикс, передаёт отсчёты 16..79 без изменений полностью backpressure на 64 полезных отсчётах
ofdm_fft64_sequential.v Масштабированное прямое FFT, построено переиспользованием ядра IFFT через тождество FFT(x)/N = conj(IFFT(conj(x))) редкие насыщения на границе Q1.15 при сопряжении -32768 подсчитываются явно, а не скрываются
ofdm_subcarrier_extractor.v Разделяет 64 бина на 48 данных, 4 пилота и 12 нулевых/защитных поднесущих: точная инверсия распределения allocator'а приёмники данных и пилотов backpressure-независимы; застревание одного не может застопорить другой
ofdm_pilot_phase_tracker.v Превращает четыре пилота каждого символа в фазовый корректирующий коэффициент Q2.14 (CORDIC векторизации + поворота) эталоны пилотов учитываются только знаком, 14 итераций CORDIC, pilot_ready опущен на 31 такт на символ; побитово совпадает с tools/ofdm_pilot_phase_tracker_fixed.py
ofdm_channel_equalizer.v Оценивает H[k] на каждой используемой несущей по обучающему символу и снимает её фазу в следующих символах G = Y*conj(sign X) (только сложения); NORMALIZE = 0: Z = Y*conj(G) >> 4, амплитуда не нормируется, конвейер на 3 такта; NORMALIZE = 1: zero-forcing 2^14 * Y/G с CORDIC 1/abs(G)^2 на каждый бин, конвейер на 7 тактов; побитово совпадает с tools/ofdm_channel_equalizer_fixed.py
ofdm_pilot_phase_corrector.v Подключает трекер к эквалайзеру: буферизует данные одного символа и применяет коэффициент этого же символа буфер на 48 отсчётов; пока буфер опустошается, data_ready и pilot_ready опущены, поэтому задержка: один символ плюс 31 такт трекера; арифметика трекера и эквалайзера не меняется
ofdm_one_tap_equalizer.v Умножает каждый символ данных на внешне заданный корректирующий коэффициент Q2.14 Q1.15 x Q2.14 -> Q3.29, округление до Q15 (к ближайшему, half-LSB от нуля), затем насыщение; saturation_count накапливается с момента сброса
ofdm_cfo_corrector.v Коррекция CFO во временной области перед удалением CP (этап 2e) корреляция префикса, накопленная по символам, векторный CORDIC на 20 итераций, 32-битный NCO, конвейерный CORDIC поворота на 16 итераций; побитово совпадает с tools/ofdm_cfo_corrector_fixed.py
ofdm_qam16_demapper.v Решающее устройство 16-QAM с кодом Грея (этап 2e) нужен эквалайзер zero-forcing; порог 2^14/3 = 5461
ofdm_qpsk_demapper.v Демаппер с жёстким решением, обратный mapper'у прозрачный ready/valid, ноль соответствует биту 0: точно как в Python-эталоне

Fixed-point контракт одним взглядом

Каждый отсчёт в этой цепочке: знаковый Q1.15 (±1 представляется как ±32767/-32768, обычная асимметричная граница дополнительного кода). Единственное намеренное исключение: корректирующий коэффициент эквалайзера, Q2.14, чтобы можно было представить коэффициент коррекции почти до 2.0 без незаметного изменения масштаба сигнального тракта. Каждый арифметический этап, способный потерять точность или диапазон это бабочки IFFT/FFT, комплексное умножение эквалайзера это явно округляется (к ближайшему, half-LSB от нуля) и явно насыщается, и каждое насыщение подсчитывается, а не просто ограничивается: total_saturation_count на стороне TX/IFFT, total_saturation_count + conjugation_saturation_count на стороне FFT, saturation_count в эквалайзере. Это прямо удовлетворяет требованию issue #48 «явное масштабирование, насыщение и счётчики переполнения», и именно это делает критерий приёмки ниже проверяемым, а не предполагаемым:

«Никакого недокументированного насыщения при выбранном back-off».

Каждый loopback-тест ниже утверждает, что каждый из этих счётчиков равен ровно нулю, и с шумом провалился бы, если бы какой-то этап тихо клипировал сигнал.

Потоковый контракт

Каждый блок использует один тактовый домен, синхронный активный низким сброс и однорегистровое рукопожатие ready/valid: источник обязан удерживать valid и данные стабильными до момента, пока ready тоже не станет высоким, а ready потребителя может комбинационно зависеть от собственной занятости, но не создавать комбинационную петлю обратно через valid. Это намеренно та же дисциплина, что уже используется в цепочке QPSK-модема в других частях курса. Внутри цепочки сигналы по-прежнему называются valid/ready/re/im/index/last; ofdm_axi_modem.v это упаковка AXI вокруг всей цепочки TX и RX:

Интерфейс Формат
s_axis_tx tdata[1:0]: одна пара битов QPSK (при MODULATION = 1: tdata[3:0], четыре бита); 48 на символ (tlast не используется, кадр задаётся счётом)
m_axis_tx tdata = {Q, I}, Q1.15; 80 отсчётов на символ, сначала CP, tlast на 80-м
s_axis_rx tdata = {Q, I}, Q1.15; 80 отсчётов на символ, tlast на 80-м (tlast не на своём месте выставляет флаг ошибки кадра RX)
m_axis_rx QPSK: tdata[7:0] = {data_index, bits}; 16-QAM: tdata[15:0] = {2'b00, data_index, 4'b0000, bits}, чтобы индекс оставался выровненным по байту. 48 на символ в порядке бинов FFT (индексы данных 24..47, затем 0..23), tlast на 48-м
s_axi (AXI4-Lite) 0x00 ID "OFDM", 0x04 версия (0x00030100), 0x08 управление (бит 0: сброс тракта, бит 1: переобучение, бит 8: сброс липких ошибок), 0x0C статус (бит 6: канал обучен), 0x10-0x18 счётчики насыщения TX/FFT/EQ, 0x1C/0x20 счётчики символов TX/RX, 0x24 фаза по пилотам, 0x28 корректирующий коэффициент, 0x2C насыщения эквалайзера канала, 0x30 число пройденных обучений (одно на CE_TRAIN_SYMBOLS обучающих символов), 0x34 оценка CFO theta = -2^24 * 64 * CFO (CFO в периодах на отсчёт, 24 бита со знаковым расширением), 0x38 конфигурация сборки (бит 0 MODULATION, 1 CHANNEL_EQ, 2 NORMALIZE, 3 CFO_CORR, 4 BRAM_MEMORY, 8:5 CE_TRAIN_SYMBOLS)

RX-цепочка содержит корректор CFO во временной области из этапа 2e (CFO_CORR = 1) и эквалайзер по поднесущим ofdm_channel_equalizer.v из этапа 2d (CHANNEL_EQ = 1, по умолчанию zero-forcing NORMALIZE = 1). MODULATION = 1 собирает вариант 16-QAM; ему нужен эквалайзер zero-forcing, потому что только он ставит внутренний и внешний уровни на одну сетку. Первым символом после сброса и после записи CONTROL[1] должен прийти обучающий символ: хост передаёт шаблон train_bits() эквалайзера как 48 пар битов на s_axis_tx (для 16-QAM это те же знаки на внешних точках, {b1, 0, b0, 0}). Переобучение заново запускает и оценку CFO. Этот символ не даёт выхода на m_axis_rx, а STATUS[6] поднимается, когда коэффициенты готовы. tb_ofdm_axi_modem.sv передаёт обучающий символ и три символа данных из s_axis_tx через канал с поворотом в s_axis_rx со случайными остановками на линии и на m_axis_rx_tready, затем сбрасывает тракт и переобучает через CONTROL[1], каждый раз с ещё одним символом данных. Проверяются BER=0, карта регистров, то, что регистр фазы по пилотам после выравнивания канала показывает около 0, то, что CFO_THETA совпадает с заданным смещением с точностью 1 %, и липкий флаг ошибки кадра RX. С параметром CFO_PPM стенд вдобавок поворачивает фазу канала на смещение на каждом отсчёте; CI проверяет QPSK при 120 и -150 градусах и 16-QAM при 120 градусах и 5000 ppm:

PASS: ofdm_axi_modem (MODULATION=0, NORMALIZE=1, CFO 0 ppm) 120-degree channel recovered 480/480 bits in 5 symbols after 1 training symbol(s), BER=0; reset, retrain, registers and sticky errors checked
PASS: ofdm_axi_modem (MODULATION=1, NORMALIZE=1, CFO 5000 ppm) 120-degree channel recovered 960/960 bits in 5 symbols after 1 training symbol(s), BER=0; reset, retrain, registers and sticky errors checked

Этап 3 ниже помещает её в block design с PS и AXI DMA и запускает на плате; этап 4 подключает её к AD9361 через поиск кадра, в симуляции и в собранном образе, который на плате ещё не запускался.

Пилоты: что уже есть, а чего пока нет

Пара allocator/extractor выполняет настоящую вставку и извлечение пилотов: allocator записывает четыре фиксированных пилотных значения в их бины на TX, а extractor выдаёт отдельный поток пилотов на RX (pilot_re/pilot_im/pilot_slot/pilot_ref_re) параллельно с потоком данных, точно отражая pilot_k/pilot_ref из Lab 8.5.

ofdm_pilot_phase_tracker.v потребляет этот поток пилотов. Для каждого OFDM-символа он формирует P = sum(sign(pilot_ref) * pilot) (эталоны равны +-1, поэтому умножители не нужны), находит angle(P) векторизующим CORDIC на 14 итераций и превращает его поворотным CORDIC в корректирующий коэффициент 16384 * exp(-j * angle(P)) в Q2.14: входной формат ofdm_one_tap_equalizer. Это форма с фиксированной точкой для pilot_phase = angle(vdot(pilot_ref, y_eq[pilots])) из Lab 8.5. На 31 такт вычисления pilot_ready опускается, а при нулевой сумме пилотов выдаётся единичный коэффициент с флагом zero_energy.

Проверка устроена так же, как для остальной цепочки: tools/ofdm_pilot_phase_tracker_fixed.py это побитово точная модель, tools/generate_ofdm_pilot_tracker_vectors.py записывает 49 тестовых символов (через каждые 15 градусов, границы +-90 и +-180 градусов, амплитуды от 300 до 32000, нулевая энергия и зашумлённые символы, у которых четыре пилота не совпадают), а tb_ofdm_pilot_phase_tracker.sv требует побитового совпадения, удерживая pilot_valid высоким все такты занятости:

python -m pytest tests/test_ofdm_pilot_phase_tracker_fixed.py
python -m tools.generate_ofdm_pilot_tracker_vectors   # пересоздать закоммиченные векторы
python tools/run_ofdm_rtl.py                          # все OFDM-стенды, включая трекер
PASS: ofdm_pilot_phase_tracker matched the fixed-point model on 49 symbols (backpressure cycles 1490)

Относительно идеального exp(-j * theta) коэффициент отличается не более чем на 7 LSB от 16384 (0,04 %), а фаза: не более чем на 3 единицы pi/2^15 (около 3e-4 рад) на каждом целом градусе.

Коэффициент относится к символу, по пилотам которого он получен, но данные этого символа уже прошли к моменту прихода последнего пилота (бин 57). Поэтому ofdm_pilot_phase_corrector.v держит данные символа в буфере на 48 отсчётов и выпускает их через эквалайзер, когда коэффициент готов: коррекция того же символа, как в лабораторной 8.5, ценой задержки на один символ. Применение коэффициента к следующему символу обошлось бы без буфера, но давало бы слежение с запаздыванием на символ. Эта коррекция: одна общая фаза на символ; частотно-селективному каналу нужна ещё оценка по поднесущим из этапа 2d (ofdm_channel_equalizer.v).

Этап проверки 1: модель с плавающей точкой vs fixed-point модель vs RTL

python -m pytest tests/test_ofdm_ifft_fixed.py tests/test_ofdm_fft_fixed.py \
  tests/test_ofdm_equalizer_fixed.py tests/test_ofdm_tx_fixed.py -q

Эти тесты сверяют арифметику Q1.15 Python-модели (включая счётчики насыщения) с закоммиченными файлами verification/vectors/block08_ofdm_*.mem. Тестбенчи RTL ниже сверяются с теми же файлами, так что успех с обеих сторон это побитовое совпадение в три стороны: замысел с плавающей точкой -> fixed-point эталон -> синтезируемый RTL.

Этап проверки 2: самопроверяющий цифровой RTL loopback

У каждого блока свой тестбенч; запустите два сквозных напрямую через Icarus Verilog:

RTL=blocks/block_08_modulation_and_synchronization/rtl
TB=blocks/block_08_modulation_and_synchronization/tb

iverilog -g2012 -o /tmp/tb_ofdm_loop.vvp \
  $RTL/ofdm_qpsk_mapper.v $RTL/ofdm_subcarrier_allocator.v \
  $RTL/ofdm_ifft_butterfly.v $RTL/ofdm_iq_bank_ram.v $RTL/ofdm_ifft64_sequential.v \
  $RTL/ofdm_tx_mapper_ifft_path.v $RTL/ofdm_cp16_inserter.v \
  $RTL/ofdm_tx_cp16_path.v $RTL/ofdm_cp16_remover.v \
  $RTL/ofdm_fft64_sequential.v $RTL/ofdm_subcarrier_extractor.v \
  $RTL/ofdm_qpsk_demapper.v \
  $TB/tb_ofdm_tx_rx_digital_loopback.sv
vvp /tmp/tb_ofdm_loop.vvp

Loopback через канал с поворотом +90 градусов и одноотводный эквалайзер, отдельный testbench:

iverilog -g2012 -o /tmp/tb_ofdm_eq_loop.vvp \
  $RTL/ofdm_qpsk_mapper.v $RTL/ofdm_subcarrier_allocator.v \
  $RTL/ofdm_ifft_butterfly.v $RTL/ofdm_iq_bank_ram.v $RTL/ofdm_ifft64_sequential.v \
  $RTL/ofdm_tx_mapper_ifft_path.v $RTL/ofdm_cp16_inserter.v \
  $RTL/ofdm_tx_cp16_path.v $RTL/ofdm_cp16_remover.v \
  $RTL/ofdm_fft64_sequential.v $RTL/ofdm_subcarrier_extractor.v \
  $RTL/ofdm_one_tap_equalizer.v $RTL/ofdm_qpsk_demapper.v \
  $TB/tb_ofdm_tx_rx_equalized_loopback.sv
vvp /tmp/tb_ofdm_eq_loop.vvp

Или запустите все OFDM-стенды (их 23) так же, как CI; --fabric и --baseline выбирают другие сборки IFFT/FFT:

python tools/run_ofdm_rtl.py

Измеренные результаты (воспроизводятся командами выше на текущем RTL этого репозитория):

PASS: OFDM TX->RX digital loopback recovered 96/96 bits with zero errors
PASS: OFDM +90deg channel -> -90deg equalizer recovered 96/96 bits, BER=0

Второй результат это не тривиальный passthrough: тестбенч применяет настоящий поворот на +90 градусов ко всем переданным отсчётам во временной области (channel_re = -im, channel_im = re), а эквалайзер получает точный обратный коэффициент (W = -j в Q2.14), чтобы его снять. Каждый счётчик насыщения (tx_saturation_count, fft_saturation_count, eq_saturation_count) в этом же тесте проверяется на строгий ноль, так что «BER=0» не маскирует переполнение, которое случайно сократилось численно.

Этап проверки 2b: loopback с коррекцией по пилотам

Здесь коэффициент эквалайзеру никто не задаёт. ofdm_pilot_phase_corrector.v буферизует 48 отсчётов данных символа, даёт трекеру измерить четыре пилота этого символа и затем пропускает буферизованные данные через эквалайзер с коэффициентом того же символа. Тестбенч поворачивает канал на ANGLE_DEG (по умолчанию 120 градусов, далеко за пределами ±45 градусов, которые выдерживает решающее устройство QPSK) и передаёт два символа подряд:

for angle in 120 -150; do
  iverilog -g2012 -s tb_ofdm_tx_rx_pilot_corrected_loopback \
    -Ptb_ofdm_tx_rx_pilot_corrected_loopback.ANGLE_DEG=$angle \
    -o /tmp/tb_ofdm_pc_loop.vvp $RTL/ofdm_*.v $TB/tb_ofdm_tx_rx_pilot_corrected_loopback.sv
  vvp /tmp/tb_ofdm_pc_loop.vvp
done
PASS: OFDM 120-degree channel -> pilot-corrected RX recovered 192/192 bits in 2 symbols, BER=0 (phase 21851, coeff=(-8201,-14183))
PASS: OFDM -150-degree channel -> pilot-corrected RX recovered 192/192 bits in 2 symbols, BER=0 (phase -27301, coeff=(-14186,8200))

Измеренная фаза: угол канала в единицах pi/2^15 (120 градусов = 21845), коэффициент равен 16384 * exp(-j * phase) в Q2.14. Если заставить корректор применять единичный коэффициент, тот же тест падает с 96/192 битовыми ошибками.

Этап проверки 2c: смещение несущей частоты

Статический поворот это простой случай: каждому символу нужен один и тот же коэффициент. Смещение несущей частоты (CFO) заставляет фазу расти от символа к символу и внутри символа. Тот же тестбенч принимает CFO_PPM (смещение в 1e-6 циклов на отсчёт) и SYMBOLS и печатает строку RESULT перед PASS/FAIL; tools/run_ofdm_cfo_sweep.py прогоняет его по списку смещений:

python tools/run_ofdm_cfo_sweep.py --angle-deg 30 --symbols 8

Измерено в Icarus Verilog 12.0 (768 бит на точку):

CFO, ppm CFO / расстояние между поднесущими Набег фазы за символ Битовых ошибок Фаза RTL (последний символ) Фаза float-модели
0 0,000 0 град 0 5467 5461,3
1000 0,064 28,8 град 0 -19767 -19764,1
2000 0,128 57,6 град 0 20491 20487,2
4000 0,256 115,2 град 0 -30221 -30220,4
5000 0,320 144,0 град 0 9907 9904,7
6000 0,384 172,8 град 13 -15547 -15540,8
7000 0,448 201,6 град 71 24527 24514,3
8000 0,512 230,4 град 104 -997 -1006,0

Из таблицы видно три вещи:

  • Коррекция того же символа отслеживает большие набеги фазы. До 144 градусов за символ корректор по-прежнему даёт BER = 0, потому что каждый символ корректируется фазой, измеренной по его собственным пилотам. Приёмник, применяющий коэффициент предыдущего символа, ошибался бы на весь набег, а решающее устройство QPSK выдерживает только 45 градусов.
  • Ломается модель общей фазы, а не слежение. Внутри одного символа фаза всё равно уходит на 2*pi*CFO*64, и поднесущие перестают быть ортогональными: энергия каждой несущей просачивается в соседние (межканальная интерференция, ICI). Примерно с 0,38 расстояния между поднесущими одна только ICI переворачивает биты. Один коэффициент на символ этого не исправит; нужна оценка и коррекция CFO во временной области, до FFT.
  • Оценка по пилотам смещена, и это смещение: не шум. Фаза трекера отличается от средней фазы канала на символе на величину, растущую с CFO. Float-модель tools/ofdm_cfo_pilot_bias.py строит те же символы и воспроизводит фазу RTL с точностью примерно 13 единиц (0,07 градуса); если поднесущие данных оставить пустыми, разница равна нулю. Смещение это ICI от поднесущих данных, просочившаяся в четыре бина пилотов, и оно зависит от данных:
python tools/ofdm_cfo_pilot_bias.py --angle-deg 30 --symbols 8 --ppm 0 500 2000 4000
   CFO ppm   mean phase   pilot estimate   bias (units, deg)   bias with pilots only
         0       5461.3           5461.3       0.0    0.00                   0.0
       500      25367.9          25624.8     256.9    1.41                  -0.0
      2000      19551.6          20487.2     935.6    5.14                  -0.0
      4000     -31894.2         -30220.4    1673.8    9.19                  -0.0

Поэтому при CFO тестбенч проверяет BER, а фазу только сообщает. В CI прогоняется случай 2000 ppm, 8 символов.

Этап проверки 2d: частотно-селективный канал

Одного коэффициента на символ достаточно, пока все поднесущие видят один и тот же канал. Многолучевой канал не таков: его частотная характеристика H[k] имеет своё усиление и свою фазу на каждой несущей. ofdm_channel_equalizer.v стоит между FFT и экстрактором и оценивает H[k] по обучающему символу, первому символу после сброса, на 48 поднесущих данных которого известная фиксированная последовательность битов (train_bits()), а пилоты обычные:

  • обучение: G[k] = Y[k] * conj(sign(X[k])), только сложения, потому что знаки эталона это ±1;
  • каждый следующий символ: Z[k] = Y[k] * conj(G[k]) >> 4 с округлением и насыщением.

Z[k] это переданный символ, умноженный на |H[k]|^2: фаза канала снята на каждой несущей, поэтому жёсткие решения QPSK верны без деления. Амплитуда не нормируется (несущие в замирании выходят маленькими) для QPSK это нормально, для 16-QAM и для измерения EVM: нет. Пилоты тоже выравниваются, поэтому ofdm_pilot_phase_corrector.v дальше по тракту измеряет только то, что изменилось после обучающего символа, например остаточный CFO. Лабораторная 8.5 использует другую преамбулу (известные значения на чётных несущих, интерполяция между ними, деление на H); обучающий символ здесь ближе к длинному обучающему полю 802.11.

Блок побитово совпадает с tools/ofdm_channel_equalizer_fixed.py на 6 кадрах (единичный канал, многолучевость лабораторной 8.5, сильная многолучевость с шумом, глубокое замирание, насыщение эквалайзера, бины у полной шкалы):

python -m pytest tests/test_ofdm_channel_equalizer_fixed.py
python -m tools.generate_ofdm_channel_eq_vectors      # перегенерировать закоммиченные векторы
PASS: ofdm_channel_equalizer matched the fixed-point model on 6 frames, 768 equalized bins, saturation counts included

Сквозной стенд tb_ofdm_tx_rx_multipath_loopback.sv передаёт обучающий символ, а затем данные через h = 0.5 + 0.25 e^{j1.2} z^-3 + 0.15 e^{-j2.0} z^-7 (в пределах циклического префикса):

iverilog -g2012 -s tb_ofdm_tx_rx_multipath_loopback -o /tmp/tb_mp.vvp \
  $RTL/ofdm_*.v $TB/tb_ofdm_tx_rx_multipath_loopback.sv
vvp /tmp/tb_mp.vvp
PASS: OFDM 3-path channel -> per-subcarrier equalizer recovered 384/384 data bits in 4 symbols after 1 training symbol, BER=0 (equalizer saturations 0)

С -Ptb_ofdm_tx_rx_multipath_loopback.USE_CHANNEL_EQ=0 эквалайзер по поднесущим обходится, и корректируется только общая фаза по пилотам: FAIL BER nonzero: 24/384 bit errors. Добавление CFO (CFO_PPM, 8 символов данных) показывает обе коррекции вместе: 0 ошибок при 500, 1000 и 2000 ppm, 2 из 768 бит при 3000 ppm, где несущие в самой глубокой части замирания встречаются с межканальной интерференцией этапа 2c.

Zero-forcing: одна сетка амплитуд для 16-QAM

QPSK нужны только знаки, поэтому Z = Y*conj(G) достаточно; решающему устройству 16-QAM нужно ещё, чтобы все несущие лежали на одной сетке амплитуд. С NORMALIZE = 1 эквалайзер делит на |G|^2 без делителя: после обучающего символа он останавливает вход, пока последовательный CORDIC в линейном режиме вычисляет 1/|G[k]|^2 для каждого бина (|G|^2 сдвигается так, чтобы старший бит стал битом 35, затем 17 итераций y -/+= x >> i, z +/-= 2^(16-i)), и бины данных выходят как 2^14 * Y/G: около ±8192 на компоненту на каждой несущей данных и около ±16384 на пилотах. Fixed-point модель и RTL совпадают побитово в обоих режимах (7 кадров, в одном из которых канал после обучения меняется, чтобы пройти путь насыщения):

PASS: ofdm_channel_equalizer (NORMALIZE=1) matched the fixed-point model on 7 frames, 896 equalized bins, saturation counts included

Многолучевой стенд сообщает, насколько решённые компоненты отстоят от одной сетки: rms(c - sign*A)/A, где A это их средний модуль (-Ptb_ofdm_tx_rx_multipath_loopback.NORMALIZE=1, 8 символов данных):

Эквалайзер Битовых ошибок Средняя амплитуда A Разброс относительно сетки
нет (только общая фаза по пилотам) 48/768 189 57,6 %
NORMALIZE = 0 (только фаза, abs(H)^2 остаётся) 0/768 5790 61,7 %
NORMALIZE = 1 (zero-forcing) 0/768 8192 0,4 %
NORMALIZE = 1, CFO 500 / 1000 / 2000 ppm 0/768 8159 / 8113 / 7983 8,9 / 17,5 / 35,8 %

Zero-forcing кладёт все несущие на одну сетку. CFO снова её размывает: внутри символа фаза всё равно уходит на 2*pi*CFO*64, и один коэффициент на символ этого не снимет. QPSK это безразлично (BER остаётся 0), а решающему устройству 16-QAM: нет, поэтому приёмнику QAM нужна коррекция CFO до FFT.

Этап проверки 2e: 16-QAM и коррекция CFO во временной области

Два новых блока превращают цепочку в приёмник 16-QAM:

  • ofdm_qam16_mapper.v / ofdm_qam16_demapper.v: 16-QAM с кодом Грея, четыре бита на несущую. На каждой оси первый бит: знак, второй выбирает внутренний уровень 1/sqrt(10) (10362) или внешний 3/sqrt(10) (31086). ofdm_tx_cp16_path выбирает маппер параметром MODULATION = 1. Обучающий символ использует внешние точки со знаками обучающего символа QPSK, поэтому эквалайзер zero-forcing кладёт внешний уровень на 2^14/2 = 8192, а внутренний на треть от него: порог решающего устройства посередине это 2^14/3 = 5461, он выведен, а не подобран.
  • ofdm_cfo_corrector.v перед удалением CP: буферизует каждый символ из 80 отсчётов и считает корреляцию циклического префикса P = sum x[n] * conj(x[n+64]) по 16 отсчётам префикса (префикс это копия хвоста символа, поэтому angle(P) = -2*pi*64*CFO), накопленную с момента сброса. Векторный CORDIC на 20 итераций даёт угол, 32-битный NCO сдвигается на угол/64 за отсчёт, а конвейерный CORDIC поворота на 16 итераций поворачивает тот же символ обратно, включая обучающий. Побитово совпадает с tools/ofdm_cfo_corrector_fixed.py (6 потоков, 1280 отсчётов):
python -m pytest tests/test_ofdm_qam16_cfo_fixed.py
python -m tools.generate_ofdm_cfo_vectors      # перегенерировать закоммиченные векторы
PASS: ofdm_cfo_corrector matched the fixed-point model on 6 streams, 1280 samples; misplaced in_last flagged
PASS: 16-QAM mapper and slicer match the model (16 codes, thresholds)

tb_ofdm_tx_rx_qam16_loopback.sv прогоняет 16-QAM через всю цепочку (CHANNEL = 1: многолучёвость лабораторной 8.5, 6 символов данных, 1152 бита на точку) и сообщает EVM относительно идеальной сетки после эквалайзера:

CFO, ppm (доля шага поднесущих) Без корректора CFO С корректором CFO
0 0 ошибок, EVM 0,44 % 0 ошибок, EVM 0,44 %
500 (0,032) 0 ошибок, EVM 8,4 % 0 ошибок, EVM 0,44 %
2000 (0,128) 88 ошибок, EVM 34 % 0 ошибок, EVM 0,45 %
4000 (0,256) 281 ошибка, EVM 74 % 0 ошибок, EVM 0,54 %
7000 (0,448) 495 ошибок, EVM 162 % 0 ошибок, EVM 0,50 %
7700 (0,493) 0 ошибок, EVM 0,49 %
8200 (0,525) 550 ошибок, EVM 146 %
iverilog -g2012 -s tb_ofdm_tx_rx_qam16_loopback -Ptb_ofdm_tx_rx_qam16_loopback.CFO_PPM=6000 \
  -o /tmp/tb_qam16.vvp $RTL/ofdm_*.v $TB/tb_ofdm_tx_rx_qam16_loopback.sv
vvp /tmp/tb_qam16.vvp

Что из этого следует:

  • 16-QAM нужна коррекция CFO до FFT. QPSK выдерживал 0,32 шага поднесущих с фазой на символ (этап 2c); 16-QAM теряет биты уже на 0,128, потому что межканальная интерференция размывает решение «внутренний/внешний» задолго до того, как перевернёт знак.
  • У корреляции префикса есть диапазон. Её угол однозначен, пока 64 * CFO не выходит за пол-оборота, то есть abs(CFO) < 1/128 цикла на отсчёт (7812,5 ppm, половина шага поднесущих). На 8200 ppm угол заворачивается (theta меняет знак), и коррекция толкает не в ту сторону.
  • Многолучёвость немного её смещает. Совсем без сдвига корректор всё равно видит небольшой (до примерно 26 ppm на сильном канале): первые отсчёты префикса несут эхо предыдущего символа, так что префикс это не точная копия. Остаток снимает трекер по пилотам; пропуск первых отсчётов префикса в корреляции убрал бы смещение ценой меньшего числа отсчётов.
  • На сильной многолучёвости (CHANNEL = 2) EVM 1,7 % при 0, 6000 и -4000 ppm: остаётся вклад эквалайзера, а не сдвига.

Этап проверки 2f: шум и цена одного обучающего символа

tb_ofdm_tx_rx_qam16_loopback.sv умеет добавлять после канала белый гауссов шум (ESN0_DB10: Es/N0 на используемую поднесущую в десятых долях дБ; MODULATION = 0 пропускает через ту же цепочку QPSK). Стенд выводит уровень шума из масштабирования цепочки: бин равен X/64, поэтому шум с дисперсией 2*sigma^2 на отсчёт превращается в 2*sigma^2/64 на бин и sigma = sqrt(abs(X)^2 / (128 * Es/N0)). tools/run_ofdm_ber_sweep.py перебирает Es/N0 и печатает измеренный BER рядом с кривой AWGN:

python tools/run_ofdm_ber_sweep.py --modulation qpsk --esn0 6 8 10 12 --seeds 4
python tools/run_ofdm_ber_sweep.py --modulation qam16 --esn0 14 16 18 20 22 --seeds 4

Плоский канал без CFO, 100 символов данных на прогон, четыре зерна шума на точку (вся цепочка: корректор CFO, FFT, эквалайзер zero-forcing, обученный по одному зашумлённому символу, фаза по пилотам, решающее устройство):

Модуляция Es/N0, дБ Ошибки / биты BER EVM Теория AWGN Теория на 3 дБ позже
QPSK 6 3613 / 38400 9,4e-2 88,6 % 2,3e-2 7,9e-2
QPSK 8 1793 / 38400 4,7e-2 68,6 % 6,0e-3 3,8e-2
QPSK 10 669 / 38400 1,7e-2 52,7 % 7,8e-4 1,3e-2
QPSK 12 152 / 38400 4,0e-3 40,6 % 3,4e-5 2,4e-3
16-QAM 14 2552 / 76800 3,3e-2 27,5 % 9,4e-3 4,2e-2
16-QAM 16 971 / 76800 1,3e-2 21,6 % 1,8e-3 1,7e-2
16-QAM 18 236 / 76800 3,1e-3 17,1 % 1,4e-4 4,5e-3
16-QAM 20 31 / 76800 4,0e-4 13,5 % 2,9e-6 5,8e-4
16-QAM 22 3 / 76800 3,9e-5 10,7 % 6,8e-9 2,5e-5

Только симуляция, с фиксированными зёрнами; теоретические столбцы: 1/2 erfc(sqrt(Es/N0 / 2)) для QPSK с кодом Грея и приближение ближайших соседей 3/8 erfc(sqrt(Es/N0 / 10)) для 16-QAM с кодом Грея. Что из этого следует:

  • Основная часть разрыва: обучающий символ, а не RTL. Эквалайзер делит на G, оценённый по одному символу с тем же шумом. В первом порядке шум оценки складывается с шумом данных: вдвое большая дисперсия, потеря 3 дБ, если обучающие точки имеют среднюю энергию символа (QPSK). Обучающий символ 16-QAM использует внешние точки с энергией в 1,8 раза выше средней, поэтому его оценка чище: 1 + 1/1,8, около 1,9 дБ. Поэтому 16-QAM ложится между двумя теоретическими кривыми, а QPSK за второй.
  • При малом Es/N0 рассуждение первого порядка слишком оптимистично. 1/G от зашумлённого G имеет тяжёлый хвост: поднесущая, чья оценка случайно вышла малой, усиливает всё, что на ней, на все 100 символов. Идеальная модель с плавающей точкой ровно этого (идеальный тайминг, без пилотов, Z = Y / G_est) даёт для QPSK EVM 131 % при 6 дБ и 49,7 % при 10 дБ против 70,7 % и 44,7 % по правилу «вдвое большей дисперсии».
  • Корректор CFO здесь ничего не стоит. В обход него (--no-cfo-corr) получается 1848 и 707 ошибок при 8 и 10 дБ: то же, что с ним, в пределах разброса зёрен. Оставшийся разрыв до идеальной модели, около 0,5–1 дБ, согласуется с фазой символа по четырём зашумлённым пилотам (в идеальной модели трекера пилотов нет) и с фиксированной точкой.
  • Зёрна важны. Все символы данных одного прогона делят одну оценку канала, поэтому прогон измеряет одну реализацию обучения; удачная или неудачная оценка сдвигает точку в два раза. Усредняйте по зёрнам (--seeds), прежде чем сравнивать с кривой.

Лекарство обычное: усреднить оценку по нескольким обучающим символам или сгладить её по соседним поднесущим. Каждое уменьшение шума оценки вдвое возвращает часть потери.

Усреднение по нескольким обучающим символам. У ofdm_channel_equalizer есть параметр TRAIN_SYMBOLS (1, 2, 4 или 8; AXI-модем передаёт его как CE_TRAIN_SYMBOLS). Первые N символов после сброса или переобучения: обучающие; блок складывает их оценки и сохраняет round(sum / N), поэтому мощность шума в G падает в N раз. CORDIC-проход запускает и trained поднимает только последний из них. N = 1: прежняя оценка бит в бит, а fixed-point модель (average_estimate() в tools/ofdm_channel_equalizer_fixed.py) совпадает с RTL при N = 4 на собственном файле векторов. Тот же перебор, те же зёрна, с четырьмя обучающими символами:

python tools/run_ofdm_ber_sweep.py --modulation qpsk --esn0 6 8 10 12 --seeds 4 --train-symbols 4
python tools/run_ofdm_ber_sweep.py --modulation qam16 --esn0 14 16 18 20 22 --seeds 4 --train-symbols 4
Модуляция Es/N0, дБ Битовые ошибки, 1 / 4 обучающих символа EVM, 1 / 4 Теория, сдвинутая на потерю обучения при N = 4
QPSK 6 3613 / 1723 88,6 / 62,8 % 3,7e-2 (1427 ошибок)
QPSK 8 1793 / 569 68,6 / 48,4 % 1,2e-2 (473)
QPSK 10 669 / 97 52,7 / 37,8 % 2,3e-3 (90)
QPSK 12 152 / 11 40,6 / 29,7 % 1,9e-4 (7)
16-QAM 14 2552 / 1323 27,5 / 22,4 % 1,3e-2 (1028)
16-QAM 16 971 / 337 21,6 / 17,8 % 3,1e-3 (236)
16-QAM 18 236 / 43 17,1 / 14,1 % 3,3e-4 (25)
16-QAM 20 31 / 2 13,5 / 11,2 % 1,0e-5 (1)
16-QAM 22 3 / 0 10,7 / 8,9 % 5,0e-8 (0)

38400 бит на точку QPSK и 76800 на точку 16-QAM, как выше. Последний столбец сдвигает кривую AWGN на потерю обучения в первом порядке 10*log10(1 + 1/(r*N)), где r это отношение энергии обучающего символа к средней (1 для QPSK, 1,8 для 16-QAM): 0,97 и 0,56 дБ при N = 4 вместо 3 и 1,9 дБ.

  • Потеря уменьшается, как предсказано. QPSK при 10 дБ теряет в 7 раз меньше бит и ложится на сдвинутую кривую (97 ошибок против 90). EVM тоже сходится: для идеальной QPSK это 1/sqrt(Es/N0), 31,6 % при 10 дБ, умноженное на sqrt(1 + 1/N) из-за оценки это 35,4 % при N = 4; измерено 37,8 % (было 52,7 %).
  • Вместе с ней уменьшается тяжёлый хвост. При четырёх взглядах на поднесущую оценка, случайно оказавшаяся маленькой, встречается реже. При 6 дБ EVM QPSK в 1,12 раза выше значения первого порядка (62,8 против 56,0 %), а с одним обучающим символом было в 1,25 раза (88,6 против 70,9 %).
  • Остаются пилоты и фиксированная точка. 16-QAM при 16-18 дБ всё ещё в 1,4-1,7 раза выше своей сдвинутой кривой, те же примерно 0,5 дБ, что и раньше: фаза символа по четырём зашумлённым пилотам.
  • Это стоит символов и почти не стоит логики. Четыре обучающих символа на 100 символов данных это 4 % накладных расходов вместо 1 %, и только если канал столько времени не меняется; для меняющегося канала нужно слежение (пока не сделано). Усреднение это два лишних бита в памяти коэффициентов и сумматор; цена в отчёте OOC.

Этап проверки 2g: уровень, PAPR и спектр TX

Issue #48 требует пикового и среднеквадратичного уровня, PAPR и запаса (back-off), числа ограничений и занимаемого спектра передатчика. tb_ofdm_tx_metrics.sv подаёт случайные биты в ofdm_tx_cp16_path и проверяет то, на что опирается измерение (80 отсчётов на символ, CP равен хвосту символа, ноль насыщений IFFT); с +DUMP=<file> он записывает отсчёты в файл. tools/measure_ofdm_tx_metrics.py запускает его и считает величины:

python tools/measure_ofdm_tx_metrics.py --symbols 400

Полная шкала это 32768 на компоненту, комплексный отсчёт считается по модулю, поэтому комплексный тон на полной шкале это 0 dBFS. 400 случайных символов (32000 отсчётов), зерно 1, симуляция RTL:

QPSK 16-QAM
Среднеквадратичный уровень -18,97 dBFS -18,97 dBFS
Пик, по модулю / по компоненте -8,68 / -8,70 dBFS -8,99 / -9,46 dBFS
PAPR: максимум / превышается 1e-2 / 1e-3 отсчётов 10,29 / 6,59 / 8,11 дБ 9,98 / 6,59 / 8,21 дБ
PAPR при 1e-3, интерполяция 4x 8,19 дБ 8,31 дБ
RTL против float-IFFT тех же поднесущих SQNR 71,7 дБ, не более 2,5 LSB SQNR 71,5 дБ, не более 2,4 LSB
Занимаемая полоса 99 % 52,6 разноса поднесущих 52,6
Защитная полоса (abs(f) >= 28 разносов) / край полосы (>= 31) -17,6 / -18,6 дБн -17,6 / -18,6 дБн

Что из этого следует:

  • Уровень задан масштабированием IFFT, а не выбран. Каждая бабочка делит на два, поэтому бин с амплитудой A превращается во времени в A/64; 52 занятые поднесущие по 32767 дают среднеквадратичное значение 32767 * sqrt(52) / 64, -19 dBFS. При PAPR около 10 дБ остаётся 9 дБ запаса, который ничем не используется.
  • Во времени обе модуляции выглядят одинаково. У QPSK и 16-QAM по построению одинаковая средняя энергия поднесущей, а сумма 52 независимых поднесущих близка к гауссовской при любом созвездии: точки CCDF совпадают в пределах 0,1 дБ.
  • Отсчёты 1x уже показывают пики. Из 64 бинов заняты 52, сигнал слегка передискретизирован, и интерполированная в 4 раза копия выше всего на 0,1 дБ при 1e-3.
  • Ошибка фиксированной точки намного ниже всего остального. SQNR 72 дБ это несколько LSB округления в шести каскадах бабочек; спектры RTL и float совпадают с точностью 0,1 дБ.
  • Защитная полоса не пуста, и дело не в RTL. У float-копии ровно те же -17,6 дБн. Каждый символ: прямоугольный отрезок из 80 отсчётов, поэтому у каждой поднесущей медленно спадающие боковые лепестки sinc, а на частоте 1x в защитную полосу попадают ещё и лепестки противоположного края (спектр повторяется через 64 разноса). Чтобы её понизить, нужны оконная обработка символов или фильтр TX, а также интерполирующие фильтры AD9363; ничего из этого в измерение не входит.

Запас перед 12-битным ЦАП. Тот же скрипт масштабирует отсчёты RTL к заданному среднеквадратичному уровню, квантует их до 12 бит с насыщением (разрядность ЦАП AD9363) и измеряет ущерб на занятых поднесущих:

Уровень на ЦАП Ограниченные компоненты, QPSK / 16-QAM EVM, QPSK / 16-QAM Защитная полоса
-6 dBFS 266 / 269 (0,42 %) 2,10 / 2,23 % -17,5 дБн
-9 dBFS 2 / 1 0,25 / 0,11 % -17,6 дБн
-12 dBFS 0 / 0 0,07 / 0,07 % -17,6 дБн
-15 dBFS 0 / 0 0,10 / 0,10 % -17,6 дБн
-18 dBFS 0 / 0 0,14 / 0,14 % -17,6 дБн

Ниже -12 dBFS доминирует шум квантования 12 бит, и EVM растёт по мере снижения уровня (при -18 dBFS теория для 12 бит даёт около 0,15 %); выше начинают сказываться ограничения редких пиков, и при -6 dBFS ограничивается одна компонента из 240. Оптимум это -12 dBFS: пики уровня 1e-3 оказываются примерно на 4 дБ ниже полной шкалы, а сам уровень на 7 дБ выше того, на котором RTL оставляет сигнал. На родных -19 dBFS сам ЦАП стоит около 0,15 % EVM, это безвредно, но передатчик теряет 7 дБ выходной мощности. Вернуть их мог бы фиксированный цифровой коэффициент усиления перед ЦАП; в RTL его пока нет. Только симуляция: что AD9363 и аналоговый тракт делают со спектром, можно узнать только на плате.

Этап проверки 3: block design с PS и AXI DMA, запуск на плате

Этап 3 issue #48 это петля в ПЛИС на Zynq: PS отправляет биты, PL модулирует и демодулирует их, PS читает их обратно. Дизайн собирается без платы:

python tools/build_block8_ofdm_dma_bd.py --build-dir C:/tmp/ofdm_dma_bd
flowchart LR
    DDR[(DDR)] <--> HP0[PS S_AXI_HP0] <--> SMC[AXI SmartConnect] <--> DMA[AXI DMA, простой режим]
    DMA -- "MM2S: байт на пару бит" --> TXS[модем S_AXIS_TX]
    TXS --> LOOP["отсчёты TX -> отсчёты RX<br/>(петля в обёртке)"]
    LOOP --> RXS[модем M_AXIS_RX]
    RXS -- "S2MM: 48 байт на символ" --> DMA
    GP0[PS M_AXI_GP0] --> REGS[регистры модема и DMA]

ofdm_axi_modem_vivado_wrapper.v снабжает порты модема интерфейсными атрибутами Vivado и замыкает его потоки отсчётов внутри ПЛИС: выход TX на вход RX, без канала; tb_ofdm_axi_modem_vivado_wrapper.sv управляет ею так, как будет DMA (поток байт с одним tlast в конце, случайное противодавление на выходе), и проверяет, что все биты вернулись по порядку. Скрипт берёт настройки PS из собственного hardware handoff платы, как mailbox лабораторной 5.12, добавляет S_AXI_HP0, FCLK_CLK0 = 100 МГц и прерывания DMA и доводит сборку до битстрима и XSA. Результат (Vivado 2021.1, xc7z020clg400-2, отчёты в reports/fpga/block8_ofdm_dma_bd_raw):

Адресная карта регистры модема 0x40000000, 4 КБ; регистры AXI DMA 0x40400000, 64 КБ; DMA обращается к DDR через HP0
Тайминг на 100 МГц WNS +0,754 нс, 0 нарушающих конечных точек
Ресурсы 9859 LUT (18,5 %), 8715 FF, 5,5 BRAM, 32 DSP48E1: модем 5914 LUT, SmartConnect 2212, DMA 1200, интерконнект GP0 520
DRC предупреждения о конвейеризации DSP и 2 рекомендации; ошибок нет

Две проблемы проявились только здесь. Первая автоматизация отдала модему всё окно M_AXI_GP0 в 1 ГБ, регистрам DMA не нашлось свободной апертуры, и Vivado исключил их с предупреждением: битстрим собрался, а запрограммировать DMA было бы невозможно. Теперь скрипт сначала сужает окно модема и останавливается, если какого-то блока регистров нет в адресной карте. Вторая: защёлка в IFFT, описанная в отчёте OOC.

Запуск на плате

tools/run_ofdm_dma_board.py загружает битстрим в плату Zynq-7020 с Linux и запускает sw/ofdm_dma_loopback.c, программу на C, собранную arm-linux-gnueabihf-gcc -static из Vitis. Она работает с регистрами простого режима PG021:

  1. Записать обучающие пары бит и пары бит данных (по одной в байт, сначала обучающий шаблон) в буфер в DDR.
  2. Запустить передачу: MM2S_DMACR (0x00) = 1, MM2S_SA (0x18) = адрес источника, MM2S_LENGTH (0x28) = число байт.
  3. Взвести приём: S2MM_DMACR (0x30) = 1, S2MM_DA (0x48) = адрес буфера, S2MM_LENGTH (0x58) = 48. Модем завершает каждый символ сигналом tlast, поэтому одна передача равна одному символу; для следующего приём взводится заново.
  4. Дождаться бита IOC (12) в S2MM_DMASR (0x34): он ставится в конце передачи и при выключенных прерываниях. Затем сравнить байты ({data_index, bits}) с отправленными и прочитать STATUS, CE_TRAINED и счётчики насыщения модема по 0x40000000.

Драйвера ядра нет, поэтому важны две детали:

  • Физические адреса. DMA нужны физические адреса. Программа закрепляет по странице на буфер и берёт её физический номер из /proc/self/pagemap.
  • Кэши. HP0 не когерентен с кэшами CPU: записанное процессором может ещё лежать в кэше, когда DMA читает DDR, а грязная строка кэша, вытесненная позже, может затереть то, что записал DMA. Программа читает и пишет буферы только через некэшируемое отображение /dev/mem. Перед этим она проходит по 4 МБ вспомогательной памяти, чтобы вытеснить грязные строки, оставшиеся после обнуления страницы.

Перед загрузкой сценарий отвязывает драйверы собственных ядер PL платы, чтобы код ядра не обращался к регистрам, которые исчезнут с новым битстримом. После прогона он тёплым перезапуском возвращает плату к её образу.

Результат на плате учебного стенда (отчёт):

  • 4 зерна × 100 прогонов × 80 символов данных: 3 072 000 бит туда и обратно, 0 битовых ошибок, ни одного потерянного байта, ни одной ошибки DMA и ни одного насыщения ни в одном счётчике;
  • в каждом прогоне эквалайзер обучался один раз (81 символ передан, 80 принято);
  • один прогон занимает около 480 мкс (около 16 Мбит/с), в основном это CPU, заново взводящий S2MM на каждый символ.

В битстриме нет тракта AD9361, это петля внутри ПЛИС. Подключение модема к AD9361 требует пересечения тактовых доменов с тактом интерфейса AD9361 и способа найти кадр в непрерывном потоке: это этап 4.

Этап проверки 4: радио

На плате приёмник модема встречает непрерывный поток АЦП: тишина, потом кадр в неизвестный момент, с неизвестным смещением несущей и неизвестным уровнем. Сам приёмник ждёт уже нарезанные символы, по 80 отсчётов с tlast на 80-м, и обучающий символ первым. Этап 4 добавляет то, что стоит между ними, в том порядке, в каком кадр это проходит:

flowchart LR
    ADC[AD9361 RX, 12 бит] --> F1[двухтактовый FIFO] --> SYNC["ofdm_frame_sync<br/>трекер DC, Шмидл и Кокс,<br/>линия задержки"]
    SYNC --> COND["ofdm_frame_conditioner<br/>DC, грубый CFO, уровень"] --> RX[приёмник модема] --> RXB[(биты RX)]
    TXB[(биты TX)] --> TX[ПЗУ преамбулы + передатчик модема] --> F2[двухтактовый FIFO] --> DAC[AD9361 TX]

Кадр. Перед обучающим символом модема идёт преамбула Шмидла и Кокса: QPSK только на чётных используемых поднесущих, поэтому её тело из 64 отсчётов состоит из двух одинаковых половин (CP 16 + 64). Она нужна только для поиска кадра и до модема не доходит.

Поиск (rtl/ofdm_frame_sync.v, бит-точная модель tools/ofdm_frame_sync_fixed.py). Метрика M = |P|^2 / R^2, где P означает корреляцию двух половин, а R среднюю энергию обеих половин. Учебниковая форма нормирует только по второй половине; на первом же смоделированном кадре её пик (594) пришёлся на конец пачки, где эта половина уходит в тишину. В ПЛИС деление не нужно: 4 |P|^2 > 3 R^2 (M > 0,75). Не меньше 8 таких отсчётов подряд образуют плато; его середина даёт первый символ, а окно ставится на 4 отсчёта раньше, внутрь циклического префикса (раннее окно добавляет только наклон фазы, который убирает эквалайзер, позднее смешивает два символа). Линия задержки на 128 отсчётов держит этот символ, пока плато не кончится.

По дороге трижды что-то шло не так, и каждый случай теперь в комментарии и в тесте:

  • Постоянная составляющая идеально «периодична»: пачка, за которой тишина с DC 3 %, давала второй, ложный кадр. Его убирает трекер DC перед метрикой (m += (x - m) >> 10).
  • Сначала у трекера была постоянная 256 отсчётов. При смещении несущей -28 кГц поднесущая +1 оказывается на +4,5 кГц, трекер частично шёл за ней, и оставшаяся после пачки затухающая оценка снова выглядела периодической. Помогли 1024 отсчёта и трекер, который работает всё время, а не начинает заново с каждым RX_ARM.
  • Приёмник модема не готов около 107 тактов после каждого символа (FFT последовательный), а в среднем ему нужно 9-12 тактов на отсчёт. Выходной FIFO на 16 отсчётов покрывает паузу, но не среднее: в симуляции при 8 тактах на отсчёт он переполнялся, при 12 нет. Плата даёт 48.

В варианте «один такт на отсчёт» поиск был глубиной 34 уровня логики (WNS -16 нс на 100 МГц). Теперь это конвейер на 10 тактов; его кольца не сбрасываются (чтобы лечь в распределённую RAM) и читаются как ноль, пока не заполнены один раз, ровно как суммы модели по ещё неполному окну.

Подготовка кадра (rtl/ofdm_frame_conditioner.v). Плато ещё и измеряет кадр. Угол P равен 2 pi 32 eps при смещении несущей eps циклов на отсчёт, поэтому он даёт смещение до +-1/64: +-32,5 кГц на 2,083 MS/s, вдвое больше диапазона собственного корректора модема по CP. CORDIC на 16 итераций даёт угол, NCO поворачивает каждый отсчёт кадра обратно, остаток исправляет модем. Поворот отсчётов поворачивает и их DC, превращая его в тон на -CFO между поднесущими данных (ошибки в битах при -28 кГц и -20 дБ); поэтому сначала вычитается оценка DC из трекера. R даёт уровень, и усиление 2^(k/2) (шаг 3 дБ) приводит любой кадр к RMS около 4096, каким бы ни было усиление радио.

Вокруг (rtl/ofdm_radio_core.v): ядро AXI4-Lite с буферами бит TX и RX, ПЗУ преамбулы, модемом и двухтактовыми FIFO в домен отсчётов, где передатчик отдаёт DAC FIFO по отсчёту на запрос, а приёмник берёт по отсчёту АЦП на строб. Собственные регистры модема (оценка CFO, CE_TRAINED, липкие ошибки) видны по смещению 0x100 ядра.

Образ. Рядом с мостом BPSK/QPSK ядро не помещается: образ моста занимает 176 из 220 DSP48E1 и 75 % LUT, а ядру OFDM нужно ещё около 46 DSP. Поэтому у него свой образ, COURSE_OVERLAY_MODE=ofdm_txrx: штатная оболочка, мультиплексор DAC FIFO (с третьим входом) и ядро по адресу 0x79060000.

Только поиск кадра (OOC, 100 МГц) 889 LUT, 1115 FF, 12 DSP48E1, 1 BRAM, WNS +1,948 нс
Только подготовка кадра (OOC, 100 МГц) 1097 LUT, 467 FF, 7 DSP48E1, WNS +1,354 нс
Образ OFDM (Vivado 2021.1) 22 479 LUT, 26 290 FF, 9,5 BRAM, 81 DSP48E1, WNS +0,203 нс, WHS +0,035 нс, все цепи разведены, защёлок нет (артефакт bitstreams/ofdm-txrx-48-v2)

Симуляция ядра целиком (tb_ofdm_radio_core.sv, по два кадра, проверяется каждый бит) с доменом отсчётов как на плате (62,5 МГц, запрос DAC и строб АЦП раз в 30 тактов, со сдвигом 17 тактов):

канал оценка CFO по преамбуле усиление результат
CFO 3 кГц 3012 / 3012 Гц 2^(-2/2) все биты верны
CFO 20 кГц 20005 / 20011 Гц 2^(-2/2) все биты верны
CFO -28 кГц -27992 / -27978 Гц 2^(-2/2) все биты верны
CFO 3 кГц, -20 дБ 3014 / 3026 Гц 2^(5/2) все биты верны
CFO 3 кГц, +5,6 дБ 3012 / 3008 Гц 2^(-4/2) все биты верны
CFO -28 кГц, -20 дБ (трекер DC сначала успевает сойтись) -27961 / -27927 Гц 2^(5/2) все биты верны
CFO 20 кГц, грубая коррекция выключена кадр найден, биты неверны: за пределом +-16,3 кГц модема

Запуск на плате

sw/zofdm.c (Vitis arm-linux-gnueabihf-gcc -static) управляет ядром: loop на одной плате с кабелем TX1 → RX1, tx и rx на двух платах, regs, stop. Слово 0 каждого кадра содержит его номер, остальное выдаёт генератор, заданный этим номером, поэтому приёмник считает битовые ошибки без всякого обратного канала. tools/run_ofdm_radio_board.py запускает это с ПК: загружает образ на плату A во время работы (AD9361 платы B не переживает перезагрузку PL на ходу), проходит лестницу усилений TX, следит за температурой платы B, собирает все строки результата и тёплой перезагрузкой возвращает плату A к её образу:

python tools/run_ofdm_radio_board.py loop --dry-run --tx-gains -30 -20 -10
python tools/run_ofdm_radio_board.py loop --host 192.168.40.1 --tx-gains -30 -20 -10 --frames 100 \
    --symbols 10 --json-out tmp/ofdm_loop.json

На плате всё это ещё не запускалось.

Что эта лабораторная доказывает и чего не доказывает

Это закрывает, только в симуляции, «Initial RTL scope» issue #48 (mapper, распределитель/экстрактор поднесущих, потоковый 64-точечный IFFT/FFT, вставка/удаление CP, одноотводный эквалайзер, явное масштабирование/насыщение/счётчики переполнения) и этапы проверки 1-2 (плавающая точка vs fixed-point, самопроверяющий цифровой loopback) с реальным, воспроизводимым, измеренным доказательством: 96/96 бит при BER=0 как для чистого цифрового loopback, так и для loopback через настоящий поворот комплексного канала, который эквалайзер реально корректирует.

Это не заявляет:

  • интеграцию в систему Zynq на плате: block design с PS, AXI DMA и модемом в петле внутри ПЛИС собирается, укладывается в тайминг и работает на плате без ошибок на 3 072 000 бит (этап 3 выше); образ, соединяющий модем с AD9361 (этап 4), собран, но на плате не запускался;
  • законченный приёмник 16-QAM: AXI-модем передаёт 16-QAM с корректором CFO (MODULATION = 1), есть прогоны с шумом (этап 2f), но канал оценивается один раз на обучение (с усреднением по числу обучающих символов до восьми, что при четырёх снижает потерю относительно теории примерно с 2–3 дБ до 1 дБ), не отслеживается и не сглаживается по поднесущим, и все прогоны это симуляция;
  • этапы проверки 4-5 на железе (безопасный ослабленный кабельный loopback через AD9361/AD9363, независимый захват SDR собственного радиосигнала OFDM-модема): этап 4 существует только в симуляции, с доменом отсчётов как на плате, смещениями несущей и уровнями, и как собранный образ;
  • план тактирования на уровне платы. Out-of-context реализация в Vivado 2021.1 на xc7z020clg400-2 (отчёт) разводит каждый OFDM-блок, корректор по пилотам и AXI-модем без ошибок DRC, причём пути от портов тоже проверяются (задержка входа/выхода 0 нс). Все тактируемые блоки, кроме однотактного эквалайзера, укладываются в 100 МГц: TX-путь и FFT64 с WNS +1,64 / +1,73 нс (около 120 / 121 МГц), корректор +0,57 нс, эквалайзер канала zero-forcing +2,50 нс (+0,55 нс при усреднении по четырём обучающим символам) и полный AXI-модем с ним и корректором CFO +1,42 нс (5917 LUT, 3195 FF, 32 DSP48E1, 3,5 BRAM; сборка 16-QAM +0,69 нс, 5985 LUT). Для этого понадобилось конвейерное расписание IFFT/FFT, которое теперь используется по умолчанию (PIPELINED = 1): бабочка занимает четыре такта вместо одного, но принимает по бабочке за такт, поэтому преобразование вычисляется за 222 такта вместо 384. Однотактный учебный вариант (PIPELINED = 0 или +define+OFDM_IFFT_PIPELINED=0) не укладывался в 100 МГц примерно на 10 нс (28 уровней логики за один такт). Однотактный эквалайзер (PIPELINED = 0, режим автономного блока по умолчанию) не укладывается на 1,08 нс, когда проверяются пути от его входных портов; корректор и модем используют трёхтактную форму PIPELINED = 1. Рабочая память преобразований по умолчанию в блочной RAM (BRAM_MEMORY = 1: два банка по 32 слова, выбираемые по чётности адреса, так что две точки бабочки никогда не делят банк): 881 / 836 LUT вместо 8151 / 7307 с памятью в логике, при 229 тактах вычисления вместо 222. --fabric и +define+OFDM_IFFT_BRAM=0 выбирают память в логике, а однотактный базовый вариант всегда использует её.

Упражнения

Каждое упражнение меняет коэффициент эквалайзера в строке .coeff_re(16'sd0), .coeff_im(-16'sd16384) файла tb_ofdm_tx_rx_equalized_loopback.sv (Q2.14, то есть 16384 = 1,0). Приведённые выводы получены на Icarus Verilog 12.0.

  1. Возьмите неверный знак, W = +j (.coeff_im(16'sd16384)). Канал и эквалайзер теперь дают в сумме 180 градусов: FAIL BER nonzero: 96/96 bit errors. Инвертирован каждый бит.
  2. Отключите эквалайзер, W = 1 (.coeff_re(16'sd16384), .coeff_im(16'sd0)), или возьмите W = -1. Остаточный поворот +90 или -90 градусов: 48/96 bit errors в обоих случаях. В QPSK с кодом Грея четверть оборота меняет ровно один из двух бит каждого символа.
  3. Скомпенсируйте только половину поворота, W = exp(-j pi/4) (.coeff_re(16'sd11585), .coeff_im(-16'sd11585)). Остаток 45 градусов, и точки ложатся на оси (например, индекс данных 22 даёт EQ=(513,1)): 22/96 bit errors. Объясните, почему это худший случай для жёсткого решения и почему число ошибок не ровно 48.
  4. В реальном приёмнике коэффициент не задаётся testbench. Какие OFDM-символы или поднесущие вы бы использовали для его оценки и как это делает Lab 8.5 на Python?
  5. При CFO 2000 ppm на сколько градусов ошибался бы на каждом символе приёмник, применяющий коэффициент предыдущего символа? Сравните с запасом 45 градусов решающего устройства QPSK и с набегом 57,6 градуса из таблицы. При каком смещении такой приёмник начал бы ошибаться?
  6. Запустите python tools/ofdm_cfo_pilot_bias.py --ppm 4000 и сравните смещение с поднесущими данных и без них. Почему известная детерминированная последовательность данных даёт смещение, а не случайный шум, и уменьшило бы его большее число пилотов?
Ответы (проверены запуском скрипта лабораторной)
  1. Наблюдаемый вывод приведён в упражнении: 180 градусов инвертируют оба бита каждого символа.
  2. Наблюдаемый вывод приведён в упражнении: ±90 градусов меняют ровно один бит каждого символа с кодом Грея, то есть 48 из 96.
  3. Остаток 45 градусов ставит каждую точку на ось, то есть точно на одну из двух границ решений; один бит каждого символа решается тем, что осталось по другой оси (EQ=(513,1): компонента Q это один МЗР). В фиксированной точке без шума эти остатки: детерминированные остатки округления, а не случайный шум, поэтому число ошибок: то, что округление даёт для этого шаблона данных (22), а не 48, к которым свелась бы монетка на каждый бит.
  4. Известные символы или известные поднесущие. Обучающий символ, у которого известны все несущие, даёт по коэффициенту на поднесущую (H[k] = Y[k]/X[k]) это этап 2d в RTL; пилоты внутри каждого символа данных отслеживают то, что меняется потом (этап 2b). Лабораторная 8.5 оценивает H[k] по известной преамбуле на чётных поднесущих, линейно интерполирует нечётные и убирает остаточную фазу каждого символа нагрузки по четырём пилотам.
  5. Один OFDM-символ: 80 отсчётов, поэтому фаза уходит на 360° × 2000·10⁻⁶ × 80 = 57,6° за символ: шаг из таблицы. Применение коэффициента предыдущего символа оставляет весь этот шаг ошибкой, больше 45-градусного запаса решающего устройства QPSK, и приём ломается. Без шума предел: 45° / (360° × 80) ≈ 1560 ppm; с шумом отказы начинаются раньше. Поэтому корректор по пилотам оценивает фазу на том же символе.
  6. С несущими данных оценка по пилотам смещена на 9,19° при 4000 ppm; только с пилотами смещение 0. CFO переносит энергию каждой несущей на соседние (межнесущая интерференция), поэтому несущие данных добавляют слагаемое в каждый бин пилота. Шаблон данных в стенде фиксирован, поэтому это слагаемое каждый раз одно и то же комплексное число: смещение, а не шум, который усредняется. Больше пилотов помогает, только если их слагаемые утечки различаются; настоящее решение: убрать CFO до FFT, что и делает корректор во временной области этапа 2e.

Что включить в отчёт

  • Побитовое совпадение плавающей точки и fixed-point (общие закоммиченные векторы).
  • Самопроверяющий цифровой RTL loopback, 0 ошибок на 96 битах, воспроизводимо.
  • Самопроверяющий эквализированный RTL loopback через реальный комплексный канал, 0 ошибок на 96 битах, воспроизводимо.
  • Каждый счётчик насыщения/переполнения равен нулю при проверенном back-off.
  • Упаковка AXI4-Stream/AXI4-Lite (ofdm_axi_modem.v, 0 ошибок на 480 битах QPSK и 960 битах 16-QAM при случайном backpressure, карта регистров проверена).
  • Block design с PS и AXI DMA, модем в петле внутри ПЛИС, собран до битстрима и запущен на плате (0 ошибок на 3 072 000 бит).
  • Интерфейс AD9361 в отдельном образе (COURSE_OVERLAY_MODE=ofdm_txrx; собран, на плате ещё не запускался).
  • Поиск кадра, грубый CFO и уровень по преамбуле в RTL (симуляция: CFO до -28 кГц, уровни от -20 до +5,6 дБ, домен отсчётов как на плате).
  • Трекер фазы по пилотам в RTL, побитово совпадающий со своей моделью с фиксированной точкой (49 символов).
  • Коэффициент трекера подан на эквалайзер (коррекция того же символа, 0 ошибок на 192 битах в каждом из каналов 120 и -150 градусов).
  • Оценка канала по поднесущим по обучающему символу (побитово; 0 ошибок на 384 битах через трёхлучевой канал и на 768 битах на точку при CFO до 2000 ppm).
  • PL/fabric loopback на Zynq.
  • Безопасный ослабленный кабельный loopback AD9361/AD9363 с метаданными затухания/усиления.
  • Отчёт по ресурсам и таймингу по OOC-реализации в Vivado с учётом путей от портов (все блоки разведены; все тактируемые блоки, кроме однотактного эквалайзера, укладываются в 100 МГц).
  • Конвейерная бабочка и расписание: TX/FFT укладываются в 100 МГц, 222 такта вычисления; оба расписания проходят все OFDM-стенды.
  • Рабочая память преобразований в блочной RAM (два банка по чётности; примерно девятая часть LUT, больший запас по таймингу; все стенды проходят в обоих режимах памяти).
  • Эквалайзер канала zero-forcing с CORDIC 1/abs(G)^2 (побитово; разброс относительно сетки 0,4 % через трёхлучевой канал; укладывается в 100 МГц) и эквалайзер канала в AXI-модеме.
  • 16-QAM и коррекция CFO во временной области сквозным образом и в AXI-модеме (0 ошибок на 1152 битах на точку до 7700 ppm; укладывается в 100 МГц).
  • BER от Es/N0 с AWGN в сравнении с теорией (симуляция; около 2–3 дБ теряется на единственном обучающем символе).
  • Оценка канала, усреднённая по нескольким обучающим символам (TRAIN_SYMBOLS, побитово с моделью; при четырёх символах потеря обучения падает примерно с 3 / 1,9 дБ до 1 / 0,6 дБ). Сглаживание по поднесущим и слежение за каналом пока открыты.
  • Уровень, PAPR и спектр защитной полосы TX измерены на выходе RTL, с перебором запаса перед 12-битным ЦАП (симуляция; спектр RF требует платы).