Files
Pluto-SDR/README.md
Maxim 7f508b6fd7
All checks were successful
01-smoke / smoke (push) Successful in 0s
02-guardrails / guardrails (push) Successful in 0s
03-gftest / gftest (push) Successful in 6s
04-cross-build / cross-build (push) Successful in 3s
Доработка документации
2026-07-17 10:16:10 +03:00

695 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Pluto-link — OFDM-радиолиния между двумя PlutoSDR
Цифровая радиолиния точка-точка на базе двух ADALM-Pluto+ (клон, Zynq-7020).
PHY: OFDM (liquid-dsp) + RS(255,223) FEC. Весь сигнальный тракт исполняется
на ARM Cortex-A9 **внутри** Pluto; хост-ПК нужен только для сборки, заливки
бинарей и снятия логов. Прототип в рамках ОКР «R-Link» (двухранговая
самоорганизующаяся сеть); данный репозиторий покрывает уровень PHY/линка.
**Быстрый старт (clone → сборка → приём/передача) — [docs/USAGE.md](docs/USAGE.md).**
---
## 1. Аппаратная платформа (фактическая, проверено)
| Параметр | Значение | Как проверено |
|---|---|---|
| Плата | Pluto+ (клон, Zynq-**7020**, AD9361) | `cat /proc/cpuinfo` — 2 ядра |
| CPU | 2 × Cortex-A9 @ 667 МГц, NEON | `Features: ... neon vfpv3` |
| RAM | 1 ГБ (доступно ~1000 МБ) | `free` |
| Прошивка | v0.38-4-g95aad-dirty | баннер SSH |
| Доступ | SSH root@192.168.2.1 (пароль `analog`), USB-Ethernet | — |
⚠️ Стоковый ADALM-Pluto (7010, 512 МБ, 1 ядро) — **другая плата**; все
бюджеты CPU в этом документе рассчитаны для Pluto+. Не путать.
⚠️ В прошивке v0.38 нет sftp-server → заливать файлы только `scp -O`
(legacy-протокол).
## 2. Бюджет CPU — главное ограничение проекта
Замерено офлайн-бенчмарком `src/ofdm_bench.c` прямо на ARM Pluto
(одно ядро, static, `-O3 -mcpu=cortex-a9 -mfpu=neon -mfloat-abi=hard
-ffast-math`, liquid-dsp 1.8.0 **с FFTW/NEON** — проверено по
`HAVE_LIBFFTW3F` в config.log):
| Режим ofdmflexframesync | Пропускная способность | Вывод |
|---|---|---|
| Поиск преамбулы (шум) | **5.32 Msps** | дежурный режим, не лимитирует |
| Приём кадров (duty ~57%) | **2.34 Msps** | узкое место |
Следствия (зафиксированы как проектные решения):
- **Рабочий sample rate = 1.92 MSPS.** Запас 1.22× на ядре синхронизатора;
второе ядро — под IIO, RS-декод, ввод-вывод.
- **3.84 MSPS запрещён**: система работает на редких пакетах и лавинообразно
разваливается под нагрузкой (0.61× от realtime). Это худший вид отказа —
«на демо работало».
- Полезная скорость ≈ **1.31.5 Мбит/с** (~160190 КБ/с). Файл 10 МБ ≈ 1 мин.
- Параметры замера: M=64, CP=16, taper=4, QPSK, CRC32, payload 1275 Б.
Полный протокол — `docs/BENCHMARK.md`. При любом изменении PHY-параметров
бенчмарк перегоняется **до** изменения кода тракта.
## 3. Структура репозитория
```
pluto-link/
├── README.md # этот файл
├── CLAUDE.md # краткие правила для Claude Code
├── makefile # цели: all, tx, rx, bench, gftest, deploy, clean
├── toolchain.env # CROSS=arm-linux-gnueabihf-, SYSROOT=$HOME/xarm
├── .gitea/workflows/ # offline CI (см. §4.5, docs/CI.md)
│ ├── 01-smoke.yml # runner жив, что есть в образе
│ ├── 02-guardrails.yml # grep-страж жёстких правил CLAUDE.md
│ ├── 03-gftest.yml # юнит-тест group-FEC (774 400 кейсов)
│ └── 04-cross-build.yml # приёмка ARM-тулчейна + сборка tx/rx/bench
├── src/
│ ├── common.h # параметры OFDM/RS, дефолты, прототипы
│ ├── common.c # pluto_init (local:), pluto_configure
│ ├── transmitter.c # stdin → OFDM → AD9361 TX
│ ├── receiver.c # AD9361 RX → OFDM sync → stdout
│ ├── group_fec.c # GF(256) 16+2 erasure FEC + offline self-test
│ └── ofdm_bench.c # бенчмарк CPU (не удалять!)
├── scripts/
│ ├── build_deps.sh # кросс-сборка FFTW + liquid-dsp → $HOME/xarm
│ ├── deploy.sh # scp -O бинарей в /tmp обеих плат
│ ├── test_file_link.sh # e2e: файл → эфир → md5-сверка
│ └── ci_guardrails.sh # логика 02-guardrails.yml, гоняется и локально
└── docs/
├── BENCHMARK.md # методика и результаты замеров CPU
├── CI.md # два runner'а, что тестируется, нюансы
├── COMPARISON.md # сравнение с коммерческими MANET-радио (Silvus, Sinosun)
├── NOTES.md # FDD, регуляторика, известные грабли
└── USAGE.md # руководство пользователя pluto-link
```
## 4. Среда разработки и сборка
### 4.1 Хост
Любой Linux; проверено на Raspberry Pi OS 64-bit (aarch64). Кросс-тулчейн:
```bash
sudo apt install -y build-essential autoconf automake libtool git wget \
gcc-arm-linux-gnueabihf
```
### 4.2 Зависимости (один раз, ~20 мин)
`scripts/build_deps.sh` собирает в `$HOME/xarm` статические armhf-библиотеки.
**Порядок важен**: liquid зависит от FFTW и libfec, поэтому они собираются до него.
1. **FFTW 3.3.10**: `--enable-single --enable-neon --enable-static`
2. **libfec** (jgaeddert): Reed-Solomon для liquid. liquid **не реализует RS
сам** — оборачивает libfec. Без неё `fec_create(LIQUID_FEC_RS_M8)` вернёт
NULL и `-c rs8` молча пойдёт БЕЗ FEC. Собирать строго ДО liquid.
3. **liquid-dsp 1.8.0**: с `CPPFLAGS=-I$HOME/xarm/include
LDFLAGS=-L$HOME/xarm/lib` и обходом autoconf-кросс-проблемы:
`ac_cv_func_malloc_0_nonnull=yes ac_cv_func_realloc_0_nonnull=yes`
4. **libiio v0.25**: только local backend (network/usb/xml/iiod отключены).
5. **libad9361-iio**: FIR-фильтр для sample rate < 2.083 MSPS
(`ad9361_set_bb_rate`). Без него AD9361 отвергает 1.92 MSPS (EINVAL).
После сборки liquid **обязательно** проверить, что подхвачены и FFTW, и libfec:
`grep -E "HAVE_LIBFFTW3F 1|HAVE_LIBFEC 1" config.log` → обе строки. Без FFTW
liquid молча падает на встроенный FFT (бюджет CPU невалиден); без libfec нет RS.
⚠️ При обновлении зависимостей liquid надо пересобирать через `make clean`:
его makefile не отслеживает зависимость от `config.h`, и `configure`+`make`
без очистки лишь переустановят старую `libliquid.a` (без FEC). Проверка перед
деплоем: `arm-linux-gnueabihf-nm $XARM/lib/libliquid.a | grep -c init_rs_char`
→ ненулевое = RS-символы libfec действительно связаны.
### 4.3 Ключевые решения по сборке
- **Только статическая линковка** (`-static`). Причина: не зависим от libc
прошивки Pluto, не таскаем .so, деплой = один файл. Цена +2 МБ — ничто
при 1 ГБ RAM.
- **Флаги обязательны**: `-O3 -mcpu=cortex-a9 -mfpu=neon -mfloat-abi=hard
-ffast-math`. Без NEON бюджет CPU из раздела 2 не выполняется.
- Одной командой:
```bash
arm-linux-gnueabihf-gcc -O3 -mcpu=cortex-a9 -mfpu=neon -mfloat-abi=hard \
-ffast-math -static src/receiver.c src/common.c -o receiver \
-I$HOME/xarm/include -L$HOME/xarm/lib \
-Wl,--start-group -lad9361 -liio -lliquid -lfec -lfftw3f -lm -lpthread -Wl,--end-group
```
Библиотеки завёрнуты в `-Wl,--start-group ... -Wl,--end-group`: у
`libad9361.a`/`libiio.a` есть циклическая зависимость символов друг на друга,
и однопроходный порядок слева направо её не всегда разрешает — компоновщику
нужно несколько проходов по кругу. Баг был не виден на бумаге и всплыл
только на реальной кросс-сборке в CI (`04-cross-build.yml`, см. §4.5); без
`--start-group`/`--end-group` `make all` изредка падает на undefined
reference в зависимости от версии тулчейна.
### 4.4 Деплой
```bash
scp -O transmitter receiver root@192.168.2.1:/tmp/ # Pluto A
scp -O transmitter receiver root@<pluto_B>:/tmp/ # Pluto B
```
rootfs Pluto живёт в RAM: `/tmp` очищается при ребуте — это нормально,
`deploy.sh` заливает заново.
### 4.5 CI / автотесты (offline Gitea Actions)
На каждый push гоняются 4 workflow на self-hosted-runner'ах сервера, без
доступа в интернет и без `actions/checkout` (Marketplace офлайн недоступен).
Два независимых runner'а: `host` (Alpine, только grep/bash-проверки) и
`armhf` (нативный x86-64 Debian-контейнер с ARM-кросс-тулчейном — **не**
эмуляция ARM, обычная кросс-компиляция).
| Workflow | Runner | Проверяет |
|---|---|---|
| `01-smoke` | `host` | runner жив, какие инструменты есть в образе |
| `02-guardrails` | `host` | grep-страж жёстких правил CLAUDE.md (§5): rate, `header_len=12`, `local:`, static-флаги |
| `03-gftest` | `armhf` | `make gftest` — 774 400 кейсов GF(256) group-FEC |
| `04-cross-build` | `armhf` | `make check-env && make all` — реальная кросс-сборка tx/rx/bench |
Локально без runner'а: `scripts/ci_guardrails.sh`, `make gftest`, `make
check-env && make all`. Полное описание архитектуры двух runner'ов,
почему это не эмуляция ARM, и разбор реальных ловушек (musl vs glibc,
group-linking из §4.3, параллелизм между runner'ами) — **`docs/CI.md`**.
Зелёные 0104 подтверждают компилируемость и статические инварианты. Они
**не** подтверждают передачу данных по эфиру — это по-прежнему ручной
чек-лист: `scripts/test_file_link.sh`, деплой, бенчмарк на плате (§8.2).
## 5. Правила кода (конституция)
1. Язык — **C**. Python/GNU Radio в тракте данных запрещены.
2. Железо — только **libiio, контекст `local:`** (код исполняется на Pluto).
Сетевые контексты `ip:` — только для отладочных утилит с хоста.
3. Бюджет: считай, что у тебя **одно ядро 667 МГц** на DSP; второе занято
IIO/FEC/IO. Любая новая нагрузка в тракте — сначала через ofdm_bench.
4. Тяжёлые зависимости (FFmpeg, cJSON и т.п.) — запрещены. JSON руками,
видео — raw UDP.
5. Порядок отладки: **кабель+аттенюатор → антенны на столе → дистанция**.
Никогда не отлаживать новый код сразу «в воздухе».
6. Изменил PHY-параметры (M, CP, модуляция, rate) — перегони бенчмарк и
обнови `docs/BENCHMARK.md` в том же коммите.
## 6. Параметры радиотракта
| Параметр | Значение | Примечание |
|---|---|---|
| Sample rate | **1 920 000** | см. раздел 2; 3.84 MSPS запрещён |
| Полоса | 1 500 000 | ~0.78 × rate |
| Частота (симплекс-тест) | 915 МГц | обе платы на одной частоте |
| FDD (этап 2) | A: TX 915/RX 868; B: TX 868/RX 915 | разнос 47 МГц |
| TX gain (кабель) | 40 дБ | + аттенюатор 3040 дБ обязателен |
| TX gain (антенны) | 20…0 дБ | начинать с минимума |
| RX gain | 50 дБ, manual | фиксированный |
| OFDM | M=64, CP=16, taper=4, QPSK | liquid defaults + CRC32 |
| FEC | RS(255,223), payload ≤ 1024 Б | 5 блоков на кадр |
⚠️ **FDD без дуплексеров**: собственный TX 915 МГц глушит свой RX 868 МГц
широкополосным шумом. Симптом: loopback работает, дуплекс в эфире — нет.
Меры: max tx_attenuation, разнос/кросс-поляризация антенн, SAW-фильтр
868 МГц на RX. Подробнее — `docs/NOTES.md`.
⚠️ **Регуляторика**: 868 МГц — SRD-диапазон (ограничения полосы/мощности/
duty cycle), 915 МГц в регионе ETSI занят GSM-900 uplink. Работа — кабель
или минимальная мощность на столе, без внешних усилителей.
## 7. Формат кадра
```
OFDM-кадр liquid: [преамбула][заголовок 12 Б][payload ≤ 1275 Б][CRC32]
Заголовок (12 байт):
0-1 0xF0 0xAA сигнатура
2-5 seq (uint32 BE) порядковый номер кадра
6-7 len (uint16 BE) длина исходных данных до FEC
8-9 nblocks число RS-блоков
10 last_block_bytes хвост последнего блока (0 = полный)
11 резерв
```
⚠️ **Критично**: у liquid заголовок пользователя по умолчанию **8 байт**.
Байты 811 передаются только после явного вызова на обеих сторонах:
```c
ofdmflexframegen_set_header_len(fg, 12); // TX
ofdmflexframesync_set_header_len(fs, 12); // RX
```
Без этого RX читает `hdr[10]` за пределами буфера (маскируется обрезкой
по `original_len`, но это мина).
## 8. Запуск
### 8.1 Передача файла Pluto→Pluto
```bash
scp -O test.bin root@192.168.2.1:/tmp/
# Pluto B — приёмник
ssh root@<pluto_B> '/tmp/receiver -f 915000000 -r 1920000 -b 1500000 -c rs8 \
> /tmp/out.bin'
# Pluto A — передатчик (кабель: -g -40 + аттенюатор!)
ssh root@192.168.2.1 'cat /tmp/test.bin | /tmp/transmitter -f 915000000 \
-r 1920000 -b 1500000 -g -40 -p 0 -c rs8'
# сверка
md5sum test.bin && ssh root@<pluto_B> 'md5sum /tmp/out.bin'
```
### 8.2 Бенчмарк CPU (перед любым изменением PHY)
```bash
scp -O ofdm_bench root@192.168.2.1:/tmp/
ssh root@192.168.2.1 '/tmp/ofdm_bench 1.92'
# критерий: "сигнал+кадры" ≥ 1.2× цели на одном ядре
```
## 9. Известные проблемы и техдолг
| # | Проблема | Статус |
|---|---|---|
| 1 | Заголовок 12 Б vs 8 Б по умолчанию у liquid (раздел 7) | ✅ исправлено: `set_header_len(12)` на обеих сторонах |
| 2 | `recovered[4096]` в receiver при лимите nblocks≤100 (22 КБ) → переполнение стека на битом заголовке. Лимит должен быть `4096/RS_DATA = 18` | ✅ исправлено: лимит `nblocks ≤ 4096/RS_DATA = 18` |
| 3 | `fec_decode()` liquid не сообщает о неисправимых RS-блоках → счётчик `rs_saved` фиктивен. Достоверный критерий — только CRC/md5 поверх данных | ✅ исправлено: CRC32 поверх данных до RS на TX, сверка после декода на RX; `rs_saved`/`rs_fail` достоверны (подтверждено loopback'ом, §12.2) |
| 4 | Внешний NCO-CFO в receiver: `set_phase(0)` на границах буферов рвёт фазу; ofdmflexframesync и так компенсирует CFO сам | ✅ исправлено: внешний NCO удалён, CFO оставлен только как телеметрия |
| 5 | TX пушит весь буфер 16384 сэмпла при кадре ~10300 → ~35% эфира впустую + паузы `-p` | ✅ исправлено: `iio_buffer_push_partial(idx)` + дренаж хвоста DMA перед закрытием |
| 6 | Нет ARQ: потерянный кадр = молчаливая дыра в файле. seq в заголовке есть, но RX его игнорирует | ✅ исправлено на FDD (§12.9, §10 п.7): детектор пропусков seq → NACK в STATUS → селективный ретрансмит из окна TX 2048 кадров + кольцо переупорядочивания на RX + хендшейк END/COMPLETE (хвостовые потери). 10 МБ байт-в-байт, A возвращает 0 только по подтверждённому COMPLETE. **На симплексе (без `-F`) ARQ невозможен** — там остаются детектор `[ПОТЕРЯ]` и групповой FEC 16+2 (≤2 потери/группу, §12.7, п.6) |
## 10. Дорожная карта
1. ✅ Бенчмарк CPU на ARM Pluto → выбран rate 1.92 MSPS
2. ✅ Перенос TX/RX на Pluto (`local:`), фиксы техдолга #1#7 (см. §9)
3. ⚙️ **текущий этап** — файл через эфир (OTA). Ввод в строй строго OTA;
кабельный этап осознанно пропущен.
✅ 14-07-2026: цифровой BIST-loopback подтвердил DSP-тракт (§12.2)
✅ первый OTA-приём — 10 КБ байт-в-байт, EVM 22.7 дБ (§12.3)
✅ пауза `-p 6000` убрала throughput-потери (100 КБ — 100/100);
на 1 МБ остаточный PER ≈ 0.2% (2 кадра). Побайтовый md5 на объёме отложен
на ARQ/дуплекс (п.7) — решение §12.3; веха симплекса = PHY-линия + PER
4. ✅ антенны на столе — PER 10 МБ ≈ 0.36%, свип TX gain (§12.4),
рабочая точка 20 дБ; линия SNR-ограничена, целостность 100%
5. ✅ **3-стадийный RX-конвейер** (§12.6): ядро 0 — только
`ofdmflexframesync_execute`; ядро 1 — refill+конвертация **и RS-декод**.
Рабочая пауза `-p 6000` → `-p 3000`, throughput 90 → 120 КБ/с (**×1.36**),
FQ drops 0 (RS не узкое место). `-p 0` на этом PHY недостижим — осознанное
ограничение; побайтовый лосслесс на симплексе — групповым FEC (п.6, §12.7).
6. ✅ **Групповой стирающий FEC 16+2** (§12.7): восстановление молчаливых
потерь кадров БЕЗ обратного канала (RAID-6-стиль P+Q над GF(256): 16 кадров
данных + 2 паритета, закрывает ≤2 стёртых кадра/группу). Соук 10 МБ
байт-в-байт на рабочей точке `-p 4000`. Защита информации (лёгкий AEAD,
+16 Б/кадр, <1% CPU) — отложена, требования ОКР «R-Link» нет; формат
с резервом под неё.
7. ✅ **FDD 868/915 + обратный канал + ARQ** (§12.8, §12.9) — техдолг #6 закрыт.
Этап 1: две несущие, A TX915/RX868, B зеркально. Этап 2: обратный канал
STATUS (кумулятивный снимок RX: highest_seq + NACK-список), gate пройден —
PER форварда не деградирует от обратного TX. Этап 3: EOF-хендшейк END/COMPLETE
(хвостовые потери молчаливы — END несёт итог передачи) + селективный
ретрансмит по NACK (окно TX 2048 кадров, повтор не чаще 400 мс) + кольцо
переупорядочивания на RX (1024 кадра). **Побайтовый лосслесс на 10 МБ:
44 потери вытянуты 45 ретрансмитами, md5 ==.** ARQ и group-FEC работают
вместе: паритет гасит одиночные потери без задержки, ARQ добирает остальное.
8. ✅ **UDP-туннель поверх линка** (§12.10) — `udp_gw` инкапсулирует UDP-датаграммы
в самосинхронизирующиеся рекорды поверх байтового потока transmitter/receiver,
без единой правки ядра тракта. Радио-смоук на антеннах пройден дважды подряд:
md5 == байт-в-байт, 0 дропов/ресинков/дыр. Web-настройка (libmicrohttpd) —
осталась нереализованной, следующий шаг п.8
9. Видео (raw UDP, пакеты ≤1472 Б) — при устойчивом PER
## 11. Диагностика (шпаргалка)
| Симптом | Первое, что проверить |
|---|---|
| scp: `sftp-server not found` | использовать `scp -O` |
| RX молчит | частоты TX/RX, `-g` TX, антенны/кабель, RX gain |
| CRC бьётся, кадры находятся | SNR: снизить полосу, амплитуду TX (`-a 0.15`) |
| md5 не сходится, CRC ок | потерянные кадры → смотреть разрывы seq (#6) |
| Работает на малом трафике, падает под нагрузкой | CPU-лимит: rate > 1.92 MSPS? второй процесс на ядре 0? |
| Loopback ок, дуплекс в эфире нет | самоглушение FDD (раздел 6) |
## 12. Журнал ввода в строй (commissioning)
Ввод в строй ведётся строго **OTA** (антенны с самого начала). Кабельный
этап регламента отладки (§5.5) осознанно пропущен — фиксируется как отклонение.
### 12.1 Программные фиксы перед первым включением (2026-07-14)
Перед первой передачей закрыты DSP-техдолги, способные испортить приём даже
при идеальном канале (детали и статусы — §9): удалён вредный внешний
NCO-CFO-контур (#4), добавлена достоверная проверка целостности CRC32 поверх
RS (#3), детектор пропусков seq (#6); TX переведён на partial-push + дренаж
хвоста DMA (#5). PHY-параметры не менялись → бенчмарк §2 остаётся в силе.
### 12.2 Цифровой BIST-loopback (2026-07-14) — DSP-тракт без эфира
Цель: изолировать код/протокол от RF **до** первого выхода в эфир. Внутренний
цифровой шлейф AD9361 (`/sys/kernel/debug/iio/iio:device0/loopback = 1`), TX и
RX на одной плате (192.168.2.1), файл 100 КБ случайных данных, режим `-c rs8`.
| Пауза TX (`-p`) | Поймано/послано | RS испр | RS битых | Потери seq | md5 | EVM |
|---|---|---|---|---|---|---|
| 2 000 мкс | 76/100 | 2 | 17 | 24 | ✗ (дыры) | 56 дБ |
| 20 000 мкс | 100/100 | 0 | 0 | 0 | ✅ совпал | 60 дБ |
**Вывод: DSP-пайплайн полностью исправен** — при разгруженном RX 100% кадров
приняты байт-в-байт, md5 совпал. Потери на паузе 2 мс — не баг фрейминга, а
**конкуренция за CPU: TX+RX+iiod на одной 2-ядерной плате**; RX не успевает за
эфиром и kernel роняет сэмплы (согласуется с бюджетом 1.22× из §2). В штатном
двухплатном OTA у RX выделенное ядро — ожидается кратное снижение потерь.
Проверка CRC-поверх-RS подтверждена в бою: 17 неисправимых кадров корректно
отброшены (не ушли мусором в файл), 2 кадра реально восстановлены RS — счётчики
`rs_saved`/`rs_fail` теперь достоверны (техдолг #3 закрыт по факту).
### 12.3 Первый двухплатный OTA (2026-07-14) — приём по воздуху
Антенны, симплекс 915 МГц, дистанция «на столе». RX на плате B (192.168.3.1),
TX на A (192.168.2.1), TX gain 30 дБ. Опорники плат сведены достаточно:
|CFO| ≈ 0.0005, синхронизатор ловит **без** ручного свипа частоты.
| Тест | Размер | FEC | `-p` | Поймано | RS испр | RS бит | Потери | Вывод/послано | md5 | RSSI | EVM |
|---|---|---|---|---|---|---|---|---|---|---|---|
| A | 10 КБ | none | 5000 мкс | 10 | — | — | 0 | 10/10 | ✅ **совпал** | 31 дБ | 22.7 дБ |
| B | 100 КБ | rs8 | 2000 мкс | 77 | 4 | 13 | 26 | 61/100 | ✗ (дыры) | 31 дБ | 25.0 дБ |
| B | 100 КБ | rs8 | 6000 мкс | 100 | 0 | 0 | 0 | 100/100 | ✅ **совпал** | 34 дБ | 16.0 дБ |
| C | 1 МБ | rs8 | 6000 мкс | 1022 | 0 | 0 | 2 | 1022/1024 | ✗ (2 кадра) | 30 дБ | 26.8 дБ |
| D | 10 МБ | rs8 | 6000 мкс | 10240 | 2 | 0 | 37 | 10203/10240 | ✗ (37 кадров) | 30 дБ | 25.9 дБ |
**Главное: первый файл прошёл по воздуху байт-в-байт (тест A)** — OTA-линия
работоспособна. Радиоканал качественный: EVM 16…27 дБ, RSSI 30…34 дБ, CFO ≈ 0,
пойманные кадры почти все декодируются.
**Пропускная способность (тесты B→B): пауза 2 мс теряет ~39% кадров, 6 мс — 0%.**
Причём на двух платах с выделенными ядрами, значит это **не** CPU-contention из
§12.2. Причина конструктивная: **receiver однопоточный** — RS-декод выполняется
inline в callback синхронизатора и отнимает его пропускную способность. Запас
1.22× (§2) не покрывает добавленную стоимость RS при сплошном потоке; троттлинг
TX паузой `-p 6000` возвращает систематическую потерю в ноль (100 КБ — 100/100).
**Остаточный PER (тесты C, D): 0.2% на 1 МБ (2/1024) и 0.36% на 10 МБ (37/10240).**
Это уже не throughput (кадры не накапливаются, `RS битых: 0` на всех объёмах), а
спорадические одиночные пропуски. `RS битых: 0` на 10 МБ — важно: проверка CRC-
поверх-RS ни разу не пропустила мусор в файл, а RS дважды реально исправил ошибки
(тест D). При симплексе без ARQ пропущенный кадр — молчаливая дыра (техдолг #6),
поэтому md5 на объёме не сходится, хотя PHY-линия по факту надёжна (**PER ≈ 0.3%,
целостность 100%**). CPU RX справляется: PER не растёт с объёмом → отставание не
накапливается.
Дальнейшие рычаги: (1) **устойчивая передача файла** требует избыточности —
на симплексе это forward-redundancy (TX гонит файл N раз, RX пишет кадры по
`seq` в нужное смещение, дыры заполняются за проходы), полноценный ARQ — на этапе
дуплекса (§10 п.7); (2) двухпоточный конвейер RX (BENCHMARK §«резервы» п.2) —
поднимет throughput и позволит убрать паузу. `test_file_link.sh` теперь с
параметрами `PAUSE`/`FEC`/`TXGAIN`. **Рычаг (2) реализован и опровергнут —
см. §12.5.**
**Решение (2026-07-14):** побайтовый лосслесс на объёме отложен на этап дуплекса —
там ставится полноценный ARQ с обратным каналом (§10 п.7). На текущем симплекс-
этапе forward-redundancy не внедряем; веха этапа — работоспособная PHY-линия и
измеренный PER.
### 12.4 Свип TX gain — карта PER/EVM (2026-07-14)
1 МБ, rs8, `-p 6000`, дистанция «на столе», симплекс 915 МГц.
| TX gain | RSSI | EVM | Потери seq | RS испр | RS бит | PER (потери) | Примечание |
|---|---|---|---|---|---|---|---|
| 40 дБ | 45 дБ | 15.0 дБ | 33 | 27 | 41 | ~3.2% (+41 неиспр) | SNR-floor: RS активно спасает, но 41 блок неисправим |
| 30 дБ | 31 дБ | 24.8 дБ | 5 | 0 | 1 | 0.49% | рабочая зона |
| 20 дБ | 22 дБ | 32.9 дБ | 3 | 0 | 0 | 0.29% | **рабочая точка** (запас в обе стороны) |
| 10 дБ | 11 дБ | 39.7 дБ | 1 | 0 | 0 | 0.10% | лучший PER; вход RX ещё не насыщен |
Выводы:
- **Линия SNR-ограничена, а не насыщением**: EVM монотонно улучшается 15 → 40 дБ
с ростом мощности; насыщения входа RX (усиление 50 дБ фикс.) не видно даже при
RSSI 11 дБ. Потолок по gain на этой геометрии не достигнут.
- **PER падает с мощностью**: 3.2% (40) → 0.1% (10). Плато 20…10 дБ даёт
PER 0.10.3% при EVM 33…40 дБ, RS даже не требуется (0 неисправимых).
- **RS реально работает под нагрузкой**: на 40 дБ спас 27 кадров, забракованных
liquid-CRC. До фикса #3 они ушли бы мусором или фиктивно «спасёнными» — это
боевое подтверждение всей FEC-цепочки (libfec + CRC-поверх-RS).
- **Рабочая точка симплекса: TX gain 20 дБ** (PER 0.29%, EVM 33 дБ, запас по SNR
вниз и до насыщения вверх); для максимальной надёжности на этой дистанции 10 дБ.
**Итог симплекс-этапа:** PHY OTA-линия работает и охарактеризована по мощности,
целостность 100% (мусор в файл не попадает), PER ≈ 0.10.3% в рабочей зоне.
Остаток к «надёжной побайтовой» передаче на симплексе закрыт групповым FEC
(§12.7, §10 п.6); полный ARQ — на этапе дуплекса (§10 п.7).
### 12.5 Двухпоточный RX и диагностика паузы (2026-07-14)
Реализован двухпоточный RX (поток A: refill+convert на ядре 1; поток B:
sync+RS на ядре 0, кольцо 16×32768). Цель — убрать TX-паузу `-p 6000` —
**НЕ достигнута**: на 1 МБ чисто только при `-p 8000` (хуже базы §12.4),
на низких паузах кольцо переполняется (столбец `Overrun`).
Диагностика через расширенный `ofdm_bench` (разбивка стадий, см.
docs/BENCHMARK.md «Разбивка стоимости по стадиям»):
| Замер (плата B, ядро 0) | Результат | Вывод |
|---|---|---|
| sync-only, сплошные кадры (`-g 0`) | **1.65 Msps < 1.92** | синхронизатор сам не realtime при duty→1; `-p 0` недостижим |
| «запас 1.22×» из §2 | артефакт duty 0.571 | под сплошным потоком запаса нет |
| только конвертация | 85 Msps (~2% нагрузки B) | вынос конвертации (что и дал двухпоток) почти бесполезен |
| RS-декод | 275 мкс/блок = **26% эфира кадра** | крупнейшая переносимая стоимость; «RS мал» (§2) опровергнуто |
| контеншн L2/DDR (`-H`) | 0.2% | кэш-гипотеза отвергнута; ядро 1 свободно под RS |
**Диагноз:** узкое место — не захват (конвертация бесплатна) и не кэш, а
сам `sync + RS` на одном ядре. Двухпоток вынес не то (конвертацию, ~2%),
оставив RS (26%) на ядре синхронизатора.
**План:** 3-стадийный конвейер — ядро 0: только sync; ядро 1: refill+conv+**RS**
+запись. Модель p_min sync-only ~1.3 мс. Абсолютный `-p 0` на этом PHY недостижим
(нужен M=32/CP=8 или ARQ). Реализовано и измерено в §12.6.
### 12.6 3-стадийный RX-конвейер — результат (2026-07-14)
Реализован (receiver.c): поток A (ядро 1) refill+convert → кольцо сэмплов;
поток B (ядро 0) **только** `ofdmflexframesync_execute`, callback кладёт сырой
кадр в очередь fq; поток C (ядро 1) из fq → seq + RS + CRC-поверх-RS + запись.
Статистику печатает main. Два новых счётчика: `Overrun` (кольцо A→B) и
`FQ drops` (очередь B→C).
Свип паузы, 1 МБ, TXGAIN 20:
| `-p` | Кадров | Потери seq | Overrun | FQ drops | Оценка |
|---|---|---|---|---|---|
| 6000 | 1024 | 6 | 0 | 0 | чисто (SNR-пол) |
| 3000 | 1024 | 3 | 0 | 0 | **чисто — рабочая точка** |
| 2500 | 1024 | 0 | 0 | 0 | чисто (измеренный край) |
| 2000 | 1006 | 20 | 7 | 0 | колено (sync не успевает) |
| 1000 | 971 | 56 | 15 | 0 | перегруз sync ядра 0 |
| 0 | 970 | 52 | 15 | 0 | перегруз sync ядра 0 |
Соук 10 МБ на `-p 3000`: Потери 27/10240 = **PER 0.26%**, Overrun 0, FQ drops 0 —
чисто по throughput, остаток чисто SNR (как §12.4).
**Выводы:**
- **`FQ drops = 0` на всех паузах, включая `-p 0`** — поток C (RS на ядре 1) ни
разу не отстал. Тезис «RS на второе ядро» подтверждён: RS больше не узкое место.
- Единственный лимит — sync на ядре 0 (`Overrun`): чисто до `-p 2500`, колено
на `-p 2000`. Реальное колено ~2500 выше нуль-запасной модели p_min 1333 мкс
из-за джиттера/маржи планировщика (запас ~2×).
- **Рабочая точка: `-p 3000`** (вдвое меньше прежних `-p 6000`), соук-подтверждена,
запас ~1000 мкс до overrun. Throughput 1024 Б/(5.33+3 мс) ≈ **120 КБ/с** против
~90 КБ/с на `-p 6000` = **×1.36** (не ×45 — эфир кадра 5.33 мс доминирует над
паузой; прежняя оценка исправлена).
- `-p 0` по-прежнему недостижим (sync сам 0.86× realtime, §12.5) — осознанное
ограничение PHY, лосслесс на объёме — групповым FEC (§12.7) или ARQ (§10 п.7).
### 12.7 Групповой стирающий FEC 16+2 (§10 п.6) — 2026-07-15
Симплекс закрывает молчаливые потери кадров БЕЗ обратного канала (ARQ требует
дуплекса, п.7). Потери одиночные и с известной позицией (из seq) — это
*стирания*, поэтому оптимально стирающее кодирование, а не дублирование.
**Метод (по расчёту).** Группа = 16 кадров данных + 2 паритета над GF(256)
(полином 0x11d, генератор g=2, RAID-6-стиль): P = ⊕dᵢ, Q = ⊕gⁱ·dᵢ. Позиции
стираний известны → 2 паритета закрывают ≤2 стёртых кадра на группу.
| Вариант | Overhead | Остаток на 10 МБ (PER 0.26%) |
|---|---|---|
| Дублирование 2× | 100% | ~0.07 кадра |
| XOR 16+1 | 6.25% | ~0.6 группы — НЕ лосслесс |
| **16+2 (выбрано)** | **12.5%** | **~0.009 группы** → ~99% передач байт-в-байт |
Формат: hdr[11] (был резерв) = флаги `[bit7 GF_MODE][bit6 GF_PARITY][bit5 GF_Q]
[bits4-0 idx 0..17]`; hdr[11]=0 = legacy (RX автодетект по первому кадру, старый
путь не тронут). Паритетный кадр несёт 1026 Б (2 Б меты `k`/`tail_len`
+ 1024 Б паритета) → те же 5 RS-блоков (1275 Б в эфире), что и данные →
геометрия PHY не меняется, бенчмарк §2 в силе (правило №6 не срабатывает).
GF-математика самопроверена офлайн: `make gftest` — **774400 кейсов** (все
`k=1..16`, все пары стираний, все комбинации наличия P/Q), **0 ошибок**.
Стоимость GF-кодека < 0.2% ядра, ложится в поток C (RS).
**Прогоны OTA (2026-07-15, TXGAIN 20, симплекс 915 МГц):**
| Тест | Размер | `-p` | Потери seq | Overrun | GF восст | GF провал | md5 |
|---|---|---|---|---|---|---|---|
| Регрессия legacy (`GROUPFEC=0`) | 1 МБ | 3000 | 0 | 0 | — | — | ✅ |
| Group | 1 МБ | 3000 | 4 | 0 | 4 | 0 | ✅ |
| Group стресс (29 дБ) | 1 МБ | 3000 | 3 | 0 | 3 | 0 | ✅ |
| **Соук** | 10 МБ | 3000 | 208 | **62** | 38 | **44** | ✗ |
| Диагностика legacy (`GROUPFEC=0`) | 10 МБ | 3000 | 213 | 63 | — | — | ✗ |
| **Соук** | 10 МБ | 4000 | 33 | **0** | 31 | **0** | ✅ **байт-в-байт** |
**Ключевая находка — overrun рвёт стирающий FEC на длинной дистанции.**
Соук на `-p 3000` дал **44 провала группы** против ~6 по независимой модели
(λ=0.42 стирания/группу, Пуассон) = **×7 сверх ожидания** → потери
**кластеризуются в пачки**, а не рассыпаны. Источник — 62 overrun ядра 0:
каждый роняет подряд идущие seq = подряд идущие idx одной группы, пробивая
бюджет ≤2. Диагностика (`GROUPFEC=0`, тот же 10 МБ) дала те же 63 overrun и
2.7% потерь → **срыв ядра 0 присущ sustained-соуку сам по себе, group-FEC ни
при чём**; рабочая точка `-p 3000` из §12.6 снята на коротких прогонах и на
10 МБ-соук не переносится (поток B не держит realtime на непрерывном потоке
дольше).
**`-p 4000` даёт ядру 0 запас → Overrun 0 → чистый floor** (33 стирания на
640 групп = λ=0.05, независимые одиночные) → 16+2 закрыл всё с запасом
(GF провал 0), **10 МБ байт-в-байт**. Интерливер (разнос кадров группы против
пачек) оказался **не нужен**: убрав overrun паузой, получили редкие
независимые стирания, где у 16+2 колоссальный margin.
**Рабочая точка group-FEC / длинных передач: `-p 4000`** (~95 КБ/с; цена
против `-p 3000` — ~12 КБ/с за Overrun 0 и лосслесс). Короткие передачи
(≤1 МБ) остаются рабочими на `-p 3000` (§12.6). Защита информации (лёгкий
AEAD, +16 Б/кадр, <1% CPU) — отложена, требования ОКР «R-Link» нет; формат
кадра с резервом под неё.
### 12.8 FDD 868/915 + обратный канал STATUS — gate-замер (§10 п.7, этапы 12) — 2026-07-16
Подготовка к ARQ (техдолг #6). Обе платы в FDD (ENSM `fdd`, оба cf-девайса,
LO по ридбэку с допуском 100 Гц — PLL округляет к сетке ~2 Гц): данные A→B
на 915, приёмник B шлёт кумулятивный STATUS (кадр `F0 55`, 255 Б = 1 RS-блок,
10 Гц, поток C) на 868; передатчик A слушает 868 потоком fb_rx (ядро 1).
Стоимость framegen STATUS — 570.6 мкс/кадр ≈ 0.6% ядра (BENCHMARK.md).
**Gate самоглушения** (кабель+аттенюатор, `TXGAIN 30`, `-p 4000`,
GROUPFEC=0 — сырой PER, 1 МБ = 1024 кадра на прогон):
| Режим | FBGAIN, дБ | Потери seq /1024 | Доставка STATUS B→A |
|---|---|---|---|
| симплекс (PER₀, 3×) | — | 3 / 5 / 14 | — |
| FDD + STATUS | 80 / 60 / 40 | 5 / 5 / 5 | 0% |
| FDD + STATUS | 20 | 4 | 47% |
| FDD + STATUS | 10 | 1 | 52% |
| FDD + STATUS | 0 (3×) | 4* / 11 / 7 | 52 / 50 / 51% |
\* плюс первые «RS битых: 3» — лёгкий самодесенс RX платы B на максимуме
её fb-TX; рабочее окно fb-TX на кабеле 20…10.
**Выводы.** (1) PER₂ ≈ PER₀ при ЛЮБОМ усилении обратного канала — излучение
STATUS форвард-линк не деградирует (среднее 0.51% против 0.72% базы; PER₁≈PER₀
снято этапом 1). (2) Доставка STATUS упирается в плато ~50% независимо от
усиления — структурный эффект: fb-RX платы A глохнет на время её собственных
TX-бёрстов (duty 57% при `-p 4000`), STATUS выживает в паузах. Для ARQ это
приемлемо: STATUS кумулятивен, 50% потерь = эффективные ~5 Гц обратной связи;
в фазах ретрансмита/END духота эфира падает и доставка растёт там, где нужна.
**Gate пройден** (критерий уточнён: доставка стабильна И PER₂PER₀ ≤ +0.3 п.п.
И PER₁≈PER₀), fallback TDD не требуется. Разбор — NOTES §2.2. Дальше — этап 3:
селективный ретрансмит по NACK + END/COMPLETE-хендшейк.
### 12.9 ARQ: END/COMPLETE + селективный ретрансмит (§10 п.7, этап 3) — 2026-07-16
Закрытие техдолга #6. Три подэтапа, каждый принят на железе отдельно.
**Механизм.** (а) **END** (кадр `F0 E7`, payload 12 Б: total_seq + total_bytes):
хвостовые потери молчаливы — у последних кадров нет следующего seq, по которому
детектор увидел бы дыру. A шлёт END каждые 100 мс после EOF, пока не услышит
COMPLETE (таймаут 10 с → **exit 2**). (б) **Окно TX** `txwin[2048]` на A хранит
сырой payload: `phy_tx_frame` детерминирован, повтор уходит байт-в-байт.
(в) **NACK** едут в кумулятивном STATUS (50 старейших seq); ретрансмит
per-seq не чаще **400 мс** — расчёт петли потеря→STATUS→повтор→подтверждение при
50% доставки STATUS (§12.8). (г) **Кольцо переупорядочивания** `rb[1024]` на B:
ретрансмит приезжает вне очереди, в stdout уходит только непрерывный префикс
(курсор `next_out`). COMPLETE ⇔ `next_out == total_seq`.
**Результаты** (кабель+аттенюатор, `TXGAIN 30`, `FBGAIN 15`, `-p 4000`):
| Прогон | Потери seq | Ретрансмитов | Итог |
|---|---|---|---|
| 1 МБ, GROUPFEC=0 | 4 / 1024 | 4 | md5 ==, COMPLETE за 88 мс, END × 1 |
| 10 МБ, GROUPFEC=0 | 44 / 10240 | 45 | md5 ==, `NACK вне окна: 0`, STATUS 93% |
| 1 МБ, GROUPFEC=1 | 3 / 1153 (GF восст.) | **0** | md5 ==, COMPLETE за 88 мс |
| 10 МБ, GROUPFEC=1 | 51 / 11520 (45 GF восст.) | **6** | md5 ==, COMPLETE за 185 мс |
| негатив, FBGAIN 89 | — | — | A честно **exit 2** за 10 с (раньше рапортовал успех) |
Окно TX на 10 МБ провернулось ~5 раз без единого «NACK вне окна», кольцо RX не
переполнялось ни разу — оба расчётных риска закрыты замером. Доставка STATUS в
END-фазе растёт до ~93% (эфир пуст, самоглушение A прекращается) — плато §12.8
на хендшейк не влияет, как и предполагалось.
**Гибрид закрывает почти всё паритетом.** На GROUPFEC=1 паритет гасит 45 из 51
потери на 10 МБ — ретрансмитов нужно всего **6** (против 55 на том же прогоне
без group-FEC), то есть на порядок меньше эфирного времени на дозакрытие тех же
потерь. Это и есть цель гибрида: GF закрывает большинство дыр немедленно, ARQ —
только то, что паритету не по силам (>2 стираний/группу). Полная приёмочная
матрица 3c (7 команд из плана, включая обе регрессии симплекса) пройдена
2026-07-16 — все результаты совпали с ожиданием, включая штатный FAIL симплекса
без group-FEC (нет ARQ на симплексе, техдолг #6 закрыт только на FDD).
**Клифф канала.** Стресс `TXGAIN 38/40/45` бесполезен: канала там нет вовсе —
`Заголовков 3322, CRC 0` (заголовок BPSK живёт, payload QPSK+RS падает первым).
Переход 30 (PER 0.4%) → 38 (100%) резкий, промежуточной точки на кабеле почти
нет. Поведение в обрыве корректное: A упёрся в расчётный потолок rate-limit
(2246 ретрансмитов ≈ 50 × 18 с / 0.4 с — эфир не залит дублями), B не поднял
COMPLETE, A вышел с 2. Настоящий PER-стресс — свипом в §12.4, не здесь.
**Симплекс идёт мимо кольца.** Без `-F` ретрансмитов нет, seq только растёт —
переупорядочивать нечего, а дыру закрыть некому: кольцо лишь задержало бы вывод
до EOF. Отсюда ветка `g_fb_fg ? rb : stdout` в `process_frame`/`group_flush`.
### 12.10 UDP-туннель — радио-смоук (§10 п.8) — 2026-07-17
**Реализация.** `udp_gw` — самостоятельный бинарь без зависимостей от
iio/liquid/fec, два режима: `-l <port>` (UDP → рекорды в stdout) и
`-d ip:port` (рекорды из stdin → UDP). Рекорд — 8 Б оверхеда (magic `D5 5D`,
len, id, flags, hcrc8) поверх уже существующего байтового потока
transmitter/receiver — ядро тракта не тронуто ни строкой. Парсер
самосинхронизируется по magic+hcrc8 после дыры (симплекс рвёт границы
рекордов произвольно). `stdout` шлюза в режиме `-l` — `O_NONBLOCK`: вход
быстрее линка дропается с счётчиком, а не блокирует приём (семантика UDP).
`udp_probe` (send/recv по UDP, свой мини-инструмент — busybox без `nc`/`socat`,
правило №1 запрещает Python и на хосте) закрывает бринг-ап без внешних
зависимостей. Пиннинг `udp_gw` на отдельное ядро не сделан — по факту
замера не нужен (Overrun/FQdrp = 0 при работающем шлюзе, ниже).
**Loopback на одной плате** (без радио, сокет↔сокет через localhost):
66/66 датаграмм, md5 ==, все счётчики (дропы/ресинк/bad_crc/дыры) — 0.
**Радио-смоук** (антенны на столе, симплекс 915 МГц, `TXGAIN 20`, `-G -p 3000`,
64 КБ / 66 датаграмм по 1000 Б, темп генератора `PACE_MS=15` ≈65 КБ/сс
запасом под потолок линка ~107 КБ/с): два прогона подряд — md5 == байт-в-байт,
`отправлено 66 | дропов 0` на входе, `доставлено 66 | ресинк 0 Б | bad_crc 0 |
дыр(id) 0` на выходе, `Потери seq 0` на радио, EVM 18.5…26.2 дБ.
Правило №7 (кабель+аттенюатор до антенн) сознательно не применялось: `udp_gw`
не меняет PHY ни на байт (слой поверх уже принятого байтового потока), его
собственная логика парсера закрыта юнит-тестом (`make gwtest`) и loopback-ом
на плате — радио-прогон здесь равносилен штатной приёмке линка.
**Отладка теста, не туннеля.** Первый радио-прогон (ручные команды в терминале)
дал пустой файл на приёме: у `udp_probe recv` сторожевой таймер простоя тикает
с момента ЗАПУСКА процесса, а не с первой датаграммы — интерактивная вставка
команд заняла больше отведённых 10 с, и приёмная цепочка на B самоликвидировалась
до начала передачи на A. После автоматизации в `scripts/test_udp_tunnel.sh`
всплыли ещё два эффекта хореографии: (а) единовременный `killall` терял хвост
последней group-FEC-группы, потому что `receiver` флашит его только в момент
собственной смерти (`receiver.c:798`, после основного цикла) — гашение
переписано на строгий порядок «receiver → пауза на прохождение хвоста через
`udp_gw -d` → `udp_probe»; (б) старт генератора без ожидания готовности
transmitter ронял 12 первые датаграммы в EAGAIN, пока iio ещё поднимался —
заменено на ожидание строки «Ожидание данных» в логе TX. Оба фикса —
в `scripts/test_udp_tunnel.sh`, радиотракт и `udp_gw` не менялись.
**Гибрид с group-FEC.** Паритет гасит одиночные потери без задержки (RX снимает
восстановленные seq с NACK — ретрансмит не нужен), ARQ добирает то, что паритету
не по силам (>2 стираний/группу). `grp_failed > 0` при целом md5 — норма: группа
не собралась, но ARQ её дозакрыл.
---
*Ввод в строй: 1415-07-2026. Замеры CPU и базовые решения — 10-07-2026.
Прошивка v0.38, liquid-dsp 1.8.0.*