From ab1e87a17e3e3477fa60fc93615fd35264b7de33 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 16 Jul 2026 15:29:25 +0300 Subject: [PATCH] =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF=203c=20=E2=80=94=20?= =?UTF-8?q?=D0=B3=D0=B8=D0=B1=D1=80=D0=B8=D0=B4=20GF+ARQ,=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B0=20NACK,=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D1=91=D0=BC=D0=BA=D0=B0.=20=D0=A2=D0=B5?= =?UTF-8?q?=D1=85=D0=B4=D0=BE=D0=BB=D0=B3=20#6=20=D0=B7=D0=B0=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ARQ включён и для group-FEC. Паритет и ретрансмит перестали быть альтернативами: GF гасит одиночные потери без задержки, ARQ добирает то, что паритету не по силам (>2 стираний/группу). Дыру, закрытую паритетом, RX снимает с NACK сам — лишнего трафика нет. receiver.c: - group_route: кадр ложится в кольцо ДО любого bypass. Кольцо — единственный источник порядка и живёт независимо от сборки групп; иначе ретрансмит закрытой группы не занял бы свой слот и next_out встал бы на нём навсегда. Паритет получает слот нулевой длины: тратит seq, но в файл не идёт. - group_route: bypass для кадра уже закрытой группы (base < g.base, а также base == g.base при !active — группа финализирована по END). Без него ретрансмит преждевременно flush-ал активную группу, сбрасывая её стирания, либо «воскрешал» закрытую с одним элементом. - group_flush: под FDD пишет в кольцо ТОЛЬКО восстановленные паритетом кадры (g.fixed) и снимает их seq с NACK — принятые уже в кольце. Симплекс сохраняет прямой вывод группы: ретрансмитов там нет, переупорядочивать нечего. - process_end: досрочный group_flush последней группы. Штатно группа закрывается первым кадром следующей — для последней его не будет, и её восстановление случилось бы только при выходе потока C, то есть уже после COMPLETE. - process_end/miss_rebuild: NACK пересобирается из кольца на каждом END. Находка стресса 3b: miss_add при переполнении MISS_CAP молча терял seq, и вернуть его было некому (детектор дыр по нему второй раз не срабатывает) — приём заклинивало насмерть. Кольцо знает точно, каких кадров нет. - arq_check_complete: критерий единый (next_out == total_seq), временный gf_seen убран. transmitter.c: g_arq_on = 1 безусловно при -F; arq_service в GF-цикле как в legacy. --- CLAUDE.md | 5 +- README.md | 56 ++++++++++++++++- docs/NOTES.md | 38 ++++++++++++ scripts/test_arq.sh | 7 ++- src/receiver.c | 146 +++++++++++++++++++++++++++++++++----------- src/transmitter.c | 21 ++++--- 6 files changed, 224 insertions(+), 49 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e4e2bcf..6f3d962 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,8 @@ PHY: OFDM через liquid-dsp 1.8.0 + FFTW/NEON, FEC RS(255,223). bit5 GF_Q (Q иначе P), bits4-0 idx кадра в группе (0..17). 0 = legacy-кадр. Group-FEC 16+2: 16 кадров данных + P(⊕) + Q(Σgⁱ) над GF(256), закрывает ≤2 стёртых кадра/группу без обратного канала (src/group_fec.c). RX автодетект. +Сигнатура F0 E7 — END (этап 3): payload 12 Б, total_seq u32 + total_bytes u64, +маркер конца передачи для хендшейка END/COMPLETE (feedback.h, §12.9). КРИТИЧНО: liquid по умолчанию передаёт 8 Б заголовка. Обязательно `ofdmflexframegen_set_header_len(fg,12)` и `ofdmflexframesync_set_header_len(fs,12)` на обеих сторонах. @@ -55,7 +57,8 @@ TX LO: канал `altvoltage1` (output); RX LO: `altvoltage0` (output). #1 header 12Б (см. выше) · #2 лимит nblocks ≤ 18, не 100 (стек recovered[4096]) · #3 fec_decode() liquid не сообщает об ошибках — rs_saved фиктивен · #4 внешний NCO в receiver вреден, убрать · #5 TX пушит лишние сэмплы -· #6 нет детектора потерь по seq · #7 reciever.c → receiver.c +· #6 ✅ закрыт на FDD: ARQ (NACK→ретрансмит) + END/COMPLETE, §12.9. На симплексе + ARQ невозможен — там детектор [ПОТЕРЯ] + group-FEC · #7 reciever.c → receiver.c ## Команды Сборка: `make` · Деплой: `make deploy` (scp -O! sftp на плате нет) diff --git a/README.md b/README.md index c1e6c80..6a2d420 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ ssh root@192.168.2.1 '/tmp/ofdm_bench 1.92' | 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 его игнорирует | ⚙️ частично: детектор пропусков seq (лог `[ПОТЕРЯ]` + счётчик) + **групповой стирающий FEC 16+2 закрывает ≤2 потери/группу на симплексе без обратного канала (§12.7, п.6)**; полный ARQ — на дуплексе (п.7) | +| 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. Дорожная карта @@ -303,7 +303,15 @@ ssh root@192.168.2.1 '/tmp/ofdm_bench 1.92' байт-в-байт на рабочей точке `-p 4000`. Защита информации (лёгкий AEAD, +16 Б/кадр, <1% CPU) — отложена, требования ОКР «R-Link» нет; формат с резервом под неё. -7. FDD 868/915 двусторонняя + ARQ с обратным каналом + меры из docs/NOTES.md +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-туннель поверх линка; затем web-настройка (libmicrohttpd) 9. Видео (raw UDP, пакеты ≤1472 Б) — при устойчивом PER @@ -580,6 +588,50 @@ TX-бёрстов (duty 57% при `-p 4000`), STATUS выживает в пау И 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% | +| негатив, FBGAIN −89 | — | — | A честно **exit 2** за 10 с (раньше рапортовал успех) | + +Окно TX на 10 МБ провернулось ~5 раз без единого «NACK вне окна», кольцо RX не +переполнялось ни разу — оба расчётных риска закрыты замером. Доставка STATUS в +END-фазе растёт до ~93% (эфир пуст, самоглушение A прекращается) — плато §12.8 +на хендшейк не влияет, как и предполагалось. + +**Клифф канала.** Стресс `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`. + +**Гибрид с group-FEC.** Паритет гасит одиночные потери без задержки (RX снимает +восстановленные seq с NACK — ретрансмит не нужен), ARQ добирает то, что паритету +не по силам (>2 стираний/группу). `grp_failed > 0` при целом md5 — норма: группа +не собралась, но ARQ её дозакрыл. + --- *Ввод в строй: 14–15-07-2026. Замеры CPU и базовые решения — 10-07-2026. Прошивка v0.38, liquid-dsp 1.8.0.* \ No newline at end of file diff --git a/docs/NOTES.md b/docs/NOTES.md index 9111480..0bbe55e 100644 --- a/docs/NOTES.md +++ b/docs/NOTES.md @@ -109,6 +109,44 @@ PER₁≈PER₀ — **выполнен, этап 5 (fallback TDD, цена ~8% t FBGAIN=0 (они и дали три точки плато). Гипотеза компрессии входа при RSSI −33…−35 (см. §2.1) остаётся непроверенной, но gate она не блокирует. +### 2.3. Находки этапа 3 (ARQ, 2026-07-16) + +**Клифф канала на кабеле — свипу PER здесь не место.** `TXGAIN −30` даёт +PER 0.4%, `−38` — уже `CRC: 0` при живых `Заголовках: 3322`. Порог заголовка и +порога payload разнесены: заголовок ofdmflexframe идёт BPSK с собственным FEC, +payload — QPSK+RS(255,223), и падает он первым. Практический смысл: **живой +счётчик «Заголовков» при нулевом CRC — это не «плохой канал», а его отсутствие**; +диагностику по такому прогону строить нельзя, ARQ там нечего вытягивать. +Аттенюатор даёт слишком грубый шаг — карта PER снята свипом в README §12.4. + +**Направления несимметричны.** На одном и том же кабеле A→B даёт `RS битых: 0`, +B→A — `RS битых: 20…28` на 1024 кадрах. Разброс устойчив между прогонами, то +есть это не статистика, а свойство пары плат/тракта. Для ARQ безразлично +(ретрансмит закроет), но при выборе направления для чувствительного трафика и +при трактовке PER-замеров это надо помнить: цифры одного направления не +переносятся на другое. + +**Ловушка «битый кадр выпадает из NACK».** `miss_ack(seq)` по факту приёма кадра +(до декода) — тихо ломает ARQ: кадр, не прошедший RS/CRC, снимался с NACK +навсегда, A его не досылал, а RX считал файл собранным → **A рапортовал успех +с битым md5**. Учёт NACK обязан идти ПОСЛЕ декода: `ok → miss_ack`, +`!ok → miss_add_sorted`. Битый кадр — такая же дыра, как непришедший, просто +обнаруженная не детектором seq. Не выстрелило на приёмке 3a лишь потому, что +там случились `RS битых: 0`. + +**Ловушка «COMPLETE съеден снимком».** STATUS на A забирается снимком +(latest-wins, `fb_take` гасит флаг свежести). Если бы COMPLETE читался из +снимка, `arq_service` съел бы тот самый STATUS, в котором он приехал, и A ушёл +бы в 10-секундный таймаут на ЦЕЛОМ файле. COMPLETE поэтому — отдельная +монотонная защёлка, взводимая прямо в `fb_callback`. + +**Переполнение MISS_CAP — тупик без пересборки.** `miss_add` при переполнении +молча теряет seq, и вернуть его в NACK уже некому: детектор дыр по нему второй +раз не сработает (`highest_seq` ушёл вперёд). В тяжёлом канале это заклинивало +приём насмерть. Лечится тем, что кольцо reorder знает точно, каких кадров нет: +`miss_rebuild()` пересобирает NACK из кольца на каждом END (A шлёт их 10 Гц до +самого COMPLETE, скан ≤1024 — сотые доли ядра). + ## 3. Регуляторика (868/915 МГц в регионе ETSI) - **868 МГц** — SRD-диапазон: ограничения на полосу канала (сотни кГц), diff --git a/scripts/test_arq.sh b/scripts/test_arq.sh index 26b3797..a580069 100755 --- a/scripts/test_arq.sh +++ b/scripts/test_arq.sh @@ -10,7 +10,9 @@ # # Использование: scripts/test_arq.sh [ipA] [ipB] [размер_КБ] # SIZE_KB=10240 — длинный прогон (или третьим позиционным аргументом) -# TXGAIN=-45 — стресс: высокий PER, ARQ обязан вытянуть md5 +# TXGAIN=-33 — стресс: высокий PER, ARQ обязан вытянуть md5. Клифф на +# кабеле резкий: −30 ≈ PER 0.4%, −38 и глубже — CRC 0 +# (канала нет вовсе, проверять там нечего) # NEGATIVE=1 FBGAIN=-89 — обратный канал заглушён: успех = A НЕ подтвердил # FBGAIN (деф. −15) — середина рабочего окна −20…−10 (README §12.8) # FBFREQ, PAUSE, TXGAIN, FEC, GROUPFEC — как в test_fdd_fwd.sh @@ -134,7 +136,8 @@ else echo " - md5 разошёлся при коде A=0 → COMPLETE поднят рано (проверь miss/next_out)." echo " - код A=2 при целом md5 → COMPLETE не долетел: подними FBGAIN к −10." echo " - размер 0 → RX не декодит: остаточный процесс на B или самоглушение." - echo " - GROUPFEC=1: ARQ там пока выключен (этап 3c) → потеря кадра даёт код A=2." + echo " - CRC: 0 при живых 'Заголовках' → канал за клиффом (TXGAIN ≤ −38 на кабеле):" + echo " заголовок BPSK ещё держится, payload QPSK — нет. Это не стресс ARQ, а обрыв." echo " - 'NACK вне окна' > 0 на B → дыра пережила TX-окно (2048 кадров ≈ 21 с)." echo "--- хвост rx_arq.log (B) ---" "${SSH[@]}" "root@$B" "tail -8 /tmp/rx_arq.log" || true diff --git a/src/receiver.c b/src/receiver.c index fed5607..cd4633f 100644 --- a/src/receiver.c +++ b/src/receiver.c @@ -77,7 +77,7 @@ static uint32_t highest_seq = 0; // максимальный увиденн static int seq_init = 0; static uint32_t next_out = 0; // курсор вывода: следующий seq в stdout (этап 3b) static uint64_t rb_over = 0; // кадров отброшено переполнением окна reorder -static int gf_seen = 0; // в эфире group-FEC (автодетект по hdr[11]) +static uint64_t miss_rebuilt = 0; // пересборок NACK из кольца (этап 3c) // Разделяемых на запись счётчиков нет. main читает их раз в секунду для // телеметрии без синхронизации: на 32-бит ARM возможен косметический «рваный» // показ 64-бит значения — на итоговую строку (печатается после join) не влияет. @@ -164,18 +164,16 @@ static int64_t now_ms(void) { return (int64_t)t.tv_sec * 1000 + t.tv_nsec / 1000000; } -// Файл собран? Legacy: курсор вывода дошёл до конца — это сильнее, чем «дыр -// нет»: next_out двигается только по непрерывному префиксу реально записанных -// кадров. Group-FEC: ARQ выключен (этап 3c), вывод идёт мимо кольца — критерий -// прежний, по фронту; ветка уйдёт вместе с гибридом GF+ARQ. +// Файл собран? Критерий один для обоих режимов (этап 3c): курсор вывода дошёл до +// конца. Это сильнее, чем «дыр нет»: next_out двигается только по непрерывному +// префиксу реально записанных кадров. Паритетные кадры group-FEC занимают слот +// нулевой длины — они тратят seq, но в файл не идут, и курсор их проходит. // Держим флаг ARQ_COMPLETE_HOLD_MS, чтобы A успел услышать хотя бы один STATUS, // затем завершаемся штатным путём через stop (main → join A→B→C). static void arq_check_complete(void) { if (!g_fb_fg || !end_seen) return; // симплекс либо передача ещё идёт int done = (end_total_seq == 0) || - (miss_cnt == 0 && - (gf_seen ? (seq_init && highest_seq == end_total_seq - 1) - : next_out == end_total_seq)); + (miss_cnt == 0 && next_out == end_total_seq); if (!done) { complete_since_ms = 0; return; } int64_t t = now_ms(); @@ -342,12 +340,36 @@ static void rb_flush_all(void) { rb_force_to(last); } +// Пересобрать NACK-набор из кольца (этап 3c, только FDD, только по END). +// Зачем: miss_add при переполнении MISS_CAP молча теряет seq, и вернуть его туда +// уже некому — детектор дыр по нему не сработает второй раз (highest_seq ушёл +// вперёд). В тяжёлом канале это тупик: A не знает, что дослать, next_out стоит, +// COMPLETE не наступает, B досиживает до сторожа. Кольцо же знает точно, каких +// кадров нет: слот либо занят своим seq, либо это дыра. +// Скан ограничен окном кольца: seq дальше next_out+RB_CAP всё равно принять +// некуда (rb_insert их отбросит), просить их у A рано. +static void miss_rebuild(void) { + if (!g_fb_fg || !end_seen) return; + if (end_total_seq <= next_out) { miss_cnt = 0; return; } // всё выведено; заодно + // страховка от переполнения вычитания ниже + uint32_t to = end_total_seq; + if (to - next_out > RB_CAP) to = next_out + RB_CAP; + miss_cnt = 0; + for (uint32_t s = next_out; s < to && miss_cnt < MISS_CAP; s++) { + rbslot_t *sl = &rb[s % RB_CAP]; + if (sl->filled && sl->seq == s) continue; // кадр уже принят + miss[miss_cnt++] = s; // возрастает → сортировка цела + } + miss_rebuilt++; +} + // --- Групповой стирающий FEC (поток C, README §12.7) -------------------- // Собирает группу из 16 кадров данных + 2 паритетных (P,Q). Пропавший на эфире // или битый по CRC кадр = стирание; ≤2 стираний на группу закрываются gf_recover. // Всё состояние — только поток C (как остальные C-счётчики), синхронизации нет. static struct { int active; + int seen; // хоть одна группа была: g.base осмыслен (этап 3c) uint32_t base; // seq первого кадра группы (= seq − idx) int k; // число кадров данных (из меты паритета) int k_known; // k получен из паритета @@ -355,6 +377,7 @@ static struct { uint8_t data[GF_K][GF_CHUNK]; uint16_t dlen[GF_K]; // фактическая длина каждого кадра данных uint8_t have[GF_K]; // 1 = кадр idx получен и валиден + uint8_t fixed[GF_K]; // 1 = кадр восстановлен паритетом, а не принят uint8_t P[GF_CHUNK], Q[GF_CHUNK]; int haveP, haveQ; uint16_t tail_len; // длина последнего кадра данных (из меты) @@ -362,8 +385,9 @@ static struct { static void group_reset(uint32_t base) { memset(g.have, 0, sizeof(g.have)); + memset(g.fixed, 0, sizeof(g.fixed)); memset(g.dlen, 0, sizeof(g.dlen)); - g.active = 1; g.base = base; g.k = 0; g.k_known = 0; + g.active = 1; g.seen = 1; g.base = base; g.k = 0; g.k_known = 0; g.maxidx = -1; g.haveP = g.haveQ = 0; g.tail_len = 0; } @@ -383,20 +407,38 @@ static void group_flush(void) { // прочие кадры полные 1024, последний — tail_len g.dlen[i] = (i == k - 1 && g.tail_len) ? g.tail_len : GF_CHUNK; g.have[i] = 1; + g.fixed[i] = 1; // закрыт паритетом — досылать по ARQ не нужно } } else { grp_failed++; // >2 стираний либо не хватило паритета } } - for (int i = 0; i < k; i++) { - if (!g.have[i]) continue; // невосстановимая дыра - uint16_t len = g.dlen[i] > GF_CHUNK ? GF_CHUNK : g.dlen[i]; - fwrite(g.data[i], 1, len, stdout); - total_bytes += len; - crc_final++; + if (g_fb_fg) { + // FDD (гибрид GF+ARQ, этап 3c): порядок держит кольцо, и принятые кадры + // уже легли в него на приёме (group_route). Отсюда идут ТОЛЬКО кадры, + // поднятые паритетом, — и их seq снимаются с NACK: GF закрыл дыру, + // ретрансмит был бы лишним трафиком. Невосстановимые остаются в miss — + // их дошлёт ARQ, для того он здесь и включён. + for (int i = 0; i < k; i++) { + if (!g.fixed[i]) continue; + rb_insert(g.base + i, g.data[i], g.dlen[i]); + miss_ack(g.base + i); + } + rb_drain(); + } else { + // Симплекс: кольца нет (ретрансмитов не будет — переупорядочивать нечего), + // группа сама себе буфер. Пишем её целиком в порядке idx, восстановленные + // встают на свои места. Невосстановимая дыра = сдвиг, прежняя семантика. + for (int i = 0; i < k; i++) { + if (!g.have[i]) continue; + uint16_t len = g.dlen[i] > GF_CHUNK ? GF_CHUNK : g.dlen[i]; + fwrite(g.data[i], 1, len, stdout); + total_bytes += len; + crc_final++; + } + fflush(stdout); } - fflush(stdout); g.active = 0; } @@ -407,6 +449,24 @@ static void group_route(uint8_t flags, uint32_t seq, int is_par = flags & GF_FLAG_PAR; uint32_t base = seq - idx; // idx=k для P, k+1 для Q → та же база + // FDD: кольцо — единственный источник порядка, и оно живёт независимо от + // сборки групп. Кладём кадр в слот ДО всякого bypass: даже ретрансмит давно + // закрытой группы обязан занять своё место, иначе next_out встанет на нём + // навсегда и COMPLETE не наступит. Паритет тратит seq, но данных не несёт — + // ему слот нулевой длины, курсор его проходит насквозь. + if (ok && g_fb_fg) { + rb_insert(seq, is_par ? NULL : dec, is_par ? 0 : dec_len); + rb_drain(); + } + + // Ретрансмит кадра уже закрытой группы (этап 3c). Пересобирать нечего: в FDD + // кадр только что лёг в кольцо, а группа нужна лишь ради паритета. Ловим два + // случая: старая группа (base < g.base) — иначе она преждевременно flush-нула + // бы активную и сбросила её стирания; и текущая, уже финализированная по END + // (base == g.base && !g.active) — иначе кадр «воскресил» бы группу с одним + // элементом, наврав в grp_failed. + if (g.seen && (base < g.base || (base == g.base && !g.active))) return; + if (!g.active || base != g.base) { // новая группа → финализировать прежнюю group_flush(); group_reset(base); @@ -451,24 +511,40 @@ static void process_end(const uint8_t *hdr, uint8_t *pay, unsigned pay_len, } end_rx++; - if (end_seen) return; - end_seen = 1; - end_total_seq = e.total_seq; - end_total_bytes = e.total_bytes; + if (!end_seen) { + end_seen = 1; + end_total_seq = e.total_seq; + end_total_bytes = e.total_bytes; - // Backfill: всё от текущего фронта до total_seq-1 в эфире было, но до нас не - // дошло. Массив miss остаётся отсортированным — добавляем seq строго больше - // всех имеющихся. Фронт двигаем на конец файла: дальше приходить могут только - // ретрансмиты (seq ≤ highest_seq), ложных дыр они не породят. - if (e.total_seq > 0 && (!seq_init || highest_seq < e.total_seq - 1)) { - uint32_t from = seq_init ? highest_seq + 1 : 0; - for (uint32_t m = from; m < e.total_seq; m++) miss_add(m); - lost += e.total_seq - from; - seq_init = 1; - highest_seq = e.total_seq - 1; + // Хвостовые потери молчаливы: у последних кадров нет следующего seq, по + // которому детектор увидел бы разрыв. Двигаем фронт на конец файла — + // дальше приходить могут только ретрансмиты (seq ≤ highest_seq), ложных + // дыр они не породят. + if (e.total_seq > 0 && (!seq_init || highest_seq < e.total_seq - 1)) { + lost += e.total_seq - (seq_init ? highest_seq + 1 : 0); + seq_init = 1; + highest_seq = e.total_seq - 1; + } } - fprintf(stderr, "\n[ARQ] END принят: всего %u кадров, %llu байт; не хватает %d\n", - e.total_seq, (unsigned long long)e.total_bytes, miss_cnt); + + // Досрочно финализировать последнюю группу (group-FEC + FDD). Штатно группа + // закрывается приходом первого кадра СЛЕДУЮЩЕЙ группы — а для последней его + // не будет, и без этого её восстановление паритетом случилось бы только при + // выходе потока C, то есть уже после COMPLETE. Дыры пришлось бы закрывать + // ретрансмитом вместо готового паритета. END значит «новых данных не будет» + // (дальше только повторы), поэтому финализировать безопасно; повторные END + // попадают в no-op (g.active уже 0). + group_flush(); + + // NACK пересобираем на КАЖДОМ END, а не только на первом (этап 3c). A шлёт + // END каждые 100 мс до самого COMPLETE, так что это ещё и штатный способ + // вернуть в запрос seq, выпавшие из miss[] по MISS_CAP: пока хвост пуст, + // дыры видит только кольцо. Скан ≤1024 при 10 Гц — сотые доли ядра. + miss_rebuild(); + + if (end_rx == 1) + fprintf(stderr, "\n[ARQ] END принят: всего %u кадров, %llu байт; не хватает %d\n", + e.total_seq, (unsigned long long)e.total_bytes, miss_cnt); } // Разбор одного кадра в потоке C: seq-детектор + RS + CRC-поверх-RS, затем @@ -584,7 +660,6 @@ route: else miss_add_sorted(seq); if (flags & GF_FLAG_MODE) { - gf_seen = 1; group_route(flags, seq, dec, dec_len, ok); } else if (ok) { if (g.active) group_flush(); // TX не мешает режимы, но подстрахуемся @@ -899,8 +974,9 @@ int main(int argc, char *argv[]) { fprintf(stderr, "ARQ: END принято: %llu | ожидалось кадров: %u | выведено seq: %u | не хватает: %d | COMPLETE: %s\n", (unsigned long long)end_rx, end_total_seq, next_out, miss_cnt, complete_since_ms ? "да" : "НЕТ"); - fprintf(stderr, "ARQ: окно reorder переполнено: %llu | NACK переполнен: %llu\n", - (unsigned long long)rb_over, (unsigned long long)miss_over); + fprintf(stderr, "ARQ: окно reorder переполнено: %llu | NACK переполнен: %llu | пересборок NACK: %llu\n", + (unsigned long long)rb_over, (unsigned long long)miss_over, + (unsigned long long)miss_rebuilt); } if (decoder) fec_destroy(decoder); diff --git a/src/transmitter.c b/src/transmitter.c index c111e1a..c557f3c 100644 --- a/src/transmitter.c +++ b/src/transmitter.c @@ -151,7 +151,8 @@ typedef struct { } txslot_t; static txslot_t txwin[TXWIN]; // ~2.1 МБ .bss (из 1 ГБ платы) -static int g_arq_on = 0; // ARQ включён (FDD + legacy; GF — этап 3c) +static int g_arq_on = 0; // ARQ включён (требует -F: без обратного канала + // NACK взять неоткуда). Работает и с group-FEC. static uint64_t g_retx = 0; // отправлено ретрансмитов static uint64_t g_retx_old = 0; // NACK на seq, вышедший из окна (≈21 с эфира) @@ -353,16 +354,16 @@ int main(int argc, char *argv[]) { pin_to_cpu(0); // TX-данные — ядро 0 pthread_create(&fb_tid, NULL, fb_rx_thread, NULL); fb_started = 1; - // ARQ пока только на legacy-пути: ретрансмит кадра старой группы - // ломает сборку group_route на RX (правило base < g.base — этап 3c). - g_arq_on = !group_fec; + // Этап 3c: ARQ работает и с group-FEC. Ретрансмит кадра старой группы + // больше не ломает сборку на RX (правило base < g.base в group_route), + // а дыры, закрытые паритетом, RX снимает с NACK сам — досылать их не + // придётся. GF и ARQ дополняют друг друга: GF гасит одиночные потери + // без задержки, ARQ добирает то, что паритету не по силам. + g_arq_on = 1; fprintf(stderr, "Обратный канал: слушаю STATUS на %.1f МГц (ядро 1)\n", fb_freq / 1e6); - if (g_arq_on) - fprintf(stderr, "ARQ: вкл (окно %d кадров, повтор не чаще %d мс)\n", - TXWIN, ARQ_RETX_MS); - else - fprintf(stderr, "ARQ: выкл (group-FEC — этап 3c)\n"); + fprintf(stderr, "ARQ: вкл (окно %d кадров, повтор не чаще %d мс)\n", + TXWIN, ARQ_RETX_MS); } } @@ -419,6 +420,7 @@ int main(int argc, char *argv[]) { tx_send_data(fg, buf, enc, seq++, GF_FLAG_MODE | i, gdata[i], glen[i], amp, pause_us); frames++; bytes_tx += glen[i]; + arq_service(fg, buf, enc, amp, pause_us, 2); // как в legacy } // паритеты P (idx=k) и Q (idx=k+1); мета = k′ + длина хвоста uint8_t P[GF_CHUNK], Q[GF_CHUNK], par[GF_PAR_PAY]; @@ -432,6 +434,7 @@ int main(int argc, char *argv[]) { tx_send_data(fg, buf, enc, seq++, GF_FLAG_MODE | GF_FLAG_PAR | GF_FLAG_Q | (k + 1), par, GF_PAR_PAY, amp, pause_us); frames += 2; + arq_service(fg, buf, enc, amp, pause_us, 2); k = 0; if (time(NULL) > last) { fprintf(stderr, "\r📤 Передано: %llu кадров, %llu байт (group-FEC)",