Этап 3c — гибрид GF+ARQ, пересборка NACK, приёмка. Техдолг #6 закрыт

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.
This commit is contained in:
Maxim
2026-07-16 15:29:25 +03:00
parent 6294a472cd
commit ab1e87a17e
6 changed files with 224 additions and 49 deletions

View File

@@ -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 на плате нет)

View File

@@ -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 её дозакрыл.
---
*Ввод в строй: 1415-07-2026. Замеры CPU и базовые решения — 10-07-2026.
Прошивка v0.38, liquid-dsp 1.8.0.*

View File

@@ -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-диапазон: ограничения на полосу канала (сотни кГц),

View File

@@ -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

View File

@@ -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 стираний либо не хватило паритета
}
}
if (g_fb_fg) {
// FDD (гибрид GF+ARQ, этап 3c): порядок держит кольцо, и принятые кадры
// уже легли в него на приёме (group_route). Отсюда идут ТОЛЬКО кадры,
// поднятые паритетом, — и их seq снимаются с NACK: GF закрыл дыру,
// ретрансмит был бы лишним трафиком. Невосстановимые остаются в miss —
// их дошлёт ARQ, для того он здесь и включён.
for (int i = 0; i < k; i++) {
if (!g.have[i]) continue; // невосстановимая дыра
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);
}
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,22 +511,38 @@ static void process_end(const uint8_t *hdr, uint8_t *pay, unsigned pay_len,
}
end_rx++;
if (end_seen) return;
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), ложных дыр они не породят.
// Хвостовые потери молчаливы: у последних кадров нет следующего 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;
lost += e.total_seq - (seq_init ? highest_seq + 1 : 0);
seq_init = 1;
highest_seq = e.total_seq - 1;
}
}
// Досрочно финализировать последнюю группу (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);
}
@@ -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);

View File

@@ -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");
}
}
@@ -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)",