Files
IR-protocol/docs_analysis/IR_protocol_analysis.md
2026-07-01 15:33:49 +03:00

21 KiB
Raw Blame History

IR-protocol — глубокий анализ (RX-производительность, обработка ошибок, протокол, мины)

Дата: 2026-07-01. Только чтение исходников. Цель: после переноса TX на DMA приём (RX) стал узким местом — разобрать RX-конвейер, обработку ошибок сигнала, дизайн протокола и найти баги/мины. Тайминги: carrierFrec=38000, bitTakts=37carrierPeriod=26µs, bitTime≈962µs (комментарий про 1100 в IR_DecoderRaw.h:107 устарел), riseTime=962, окно ±300µs, IR_timeout≈15144µs, IR_ResponseDelay≈42мс. Числа длительностей — оценки (замеров в коде нет).

0. Резюме (главное)

  1. Найдены реальные баги, триггеримые ШУМОМ (не только валидным трафиком):
    • CRITICAL — OOB-чтение в crcCheck при packSize==1 (IR_DecoderRaw.cpp:887): packSize - crcBytes = 1-2uint8_t len=255crc8 читает dataBuffer[0..254] при массиве 38 → OOB ~217 байт + dataBuffer[255/256]. Мусорный CRC (редко — ложно-валидный кадр) или HardFault у границы SRAM. Нижняя граница packSize нигде не проверяется.
    • HIGH — реальный крэш на Car: null-deref encoder->sendAccept() без проверки (IR_Decoder.cpp:169). У Car декодер создан с encoder==nullptr (Car/src/IR/IR.cpp:31). Приходит валидный кадр IR_MSG_DATA_ACCEPT, адресованный машинке (addrTo==id, addrFrom≠0 и <broadcast) → через acceptDelaysendAccept по nullptr → HardFault. Детерминированно, если кто-то шлёт машинке DATA_ACCEPT; плюс шумом (1/65536 на ложный кадр нужного типа).
    • HIGH — OOB-запись dataBuffer[38] (off-by-one) при packSize==0 (IR_DecoderRaw.cpp:753> вместо >=): при packSize==0 конец кадра не наступает, i_dataBuffer растёт до 304, запись dataBuffer[304/8=38] затирает соседнее поле prevRise.
    • MEDIUM — неинициализированные isWaitingAcceptSend/acceptSendTimer/… (IR_Decoder.cpp:72-79): мусор при старте → возможен спонтанный sendAccept (и крэш по null-deref) без приёма чего-либо.
  2. RX-bottleneck — НЕ ISR и НЕ тяжёлый декод в прерывании (декода в ISR нет, ISR лёгкий ~2-4µs). Узкое место структурное: tick() вынимает РОВНО один сырой фронт за вызов → скорость слива кольца привязана к частоте loop(); при медленном loop 250-элементный буфер (~120мс запаса) переполняется и рвёт кадры. Плюс битовый CRC ×2 на конец кадра и маленький hold-фильтр.
  3. Коррекции ошибок по сути нет — detect-and-drop: 2×CRC8 (~16 бит), FEC/повтор отсутствует, однобитный bruteforce выключен («зависает»). Нет seq/дедупа/ретраев; ACK задуман, но отправитель его игнорирует (мёртвый механизм). 5-битная длина молча обрезает payload >24 байт (send возвращает Success, кадр теряется).

1. RX-конвейер и производительность

Стадии (кто где исполняется)

  • A. EXTI-фронт → ISR (IR_DecoderRaw::isr, .cpp:274-308; attachInterrupt(pin, CHANGE) в IR_Decoder::enable :88-89). Приёмник демодулирующий (TSOP), EXTI по фронтам огибающей. В ISR: micros()noInterrupts/interrupts), dir = port->IDR&mask (пост-фактум чтение уровня — при коротком импульсе возможна неверная dir), mute-гейт (isPairSending → фронт только в edgeTrace и return), subBuffer.push(edge).
  • B. Сырое кольцо RingBuffer<FrontStorage,250> (.h:124, subBufferSize=250, IR_config.h:169), ~8 байт/фронт ≈ 2КБ/декодер. push/pop под своей критсекцией. pop() — один элемент за вызов.
  • C. Фильтр импульсов (tick :417-444) — по умолчанию выключен (IR_INPUT_MIN_PULSE_US=0), сырой фронт идёт прямо в декод. Включённый: hold-массив [6], отсев пар <minUs как глитч.
  • D. FSM преамбулы (preambleProcessEdge :1599-1723): Idle→Candidate→Locked, нужно 2 согласованных периода rise→rise в окне 220-340% bitTime после «тишины» >IR_timeout*2.
  • E. Декод бита (processDecodedFront :466-746): инлайн-отсев глитчей, окна таймингов, реконструкция пропущенных бит через ceil_div.
  • F. Сборка/sync/CRC (writeToBuffer :748-949): биты в dataBuffer[38], sync 3 бита/байт, длина из первого байта, на конце — crcCheck.
  • G. Диспетчеризация (IR_Decoder::_tick :130-172): разбор msgType → gotData/gotBack/gotAccept/gotRequest/gotRaw; авто-ACK для DATA_ACCEPT.

Весь декод (CG) — в tick()/loop, не в ISR. Это осознанная разгрузка прерывания.

Узкое место

  • ISR лёгкий (~2-4µs, оценка), даже при 5-6к фронтов/с ~1.5-2% CPU. Не он предел.
  • Структурный предел: 1 сырой pop/tick (.cpp:409-415) → RX-throughput = частота loop(). Полный кадр ≈ 836 фронтов ≈ 836 tick'ов. Медленный loop (Serial-отладка, моторы, дисплей) < частоты фронтов → кольцо копится → overflow → рваные кадры → CRC/timeout reject.
  • Буфер 250 = ~120мс запаса (250/2080 фр/с). Хватает от коротких стопоров, не от устойчивого отставания.
  • Финальный CRC — битовый crc8 дважды по всей длине (~5-10µs, оценка). BRUTEFORCE_CHECK (выкл, «зависает») при включении — O(len²·64), отдельная мина.

Переполнение/backpressure

  • subBuffer полон → push=false, isSubBufferOverflow=true (транзиентный, сбрасывается в tick), счётчик rxBriefRawOverflowDrops (u16, насыщение), лог QRAW.
  • hold-фильтр(6) переполнение → pulseFilterDropHoldOverflow++ (стойкий), лог HOLD.
  • Битовый буфер кадра → isBufferOverflow, лог BUF, сброс.
  • QFLT/pulseFilterDroppedByFilteredOverflow()мёртвый код, всегда 0.
  • Стойкого счётчика потерянных ПАКЕТОВ нет — потери видны косвенно (CRC/TIMEOUT/SYNC в RX-brief-логе), последний битый кадр — в rejectBuffer.

RX во время своего TX (mute)

  • isPairSending (число busy-энкодеров, пересчёт refreshPairMuteState под критсекцией) → в ISR фронт отбрасывается на входе (:293-299). Глушится любой чужой кадр на всё время своей передачи (isSending). Для DMA-TX снятие mute зависит от externalFinishSend() — задержка удлиняет «глухоту» на хвост.
  • NVIC: библиотека приоритеты почти не трогает; RX-EXTI — дефолт ядра, поднять setReceiveExtiPreemptPriority (IR_Decoder.cpp:44-62). Нужно: preempt RX-EXTI срочнее DMA-TX, иначе чужой DMA/IRQ сдвигает метку фронта → искажение risePeriod (окно всего ±300µs).

Идеи ускорения RX (trade-off)

  1. Таймер input-capture + DMA вместо софт-EXTI — аппаратный штамп фронтов, убирает per-edge ISR и джиттер метки. Дорого (канал таймера, wrap, восстановление dir), но чистейшие тайминги.
  2. Батч-pop сырых фронтов за tick (с лимитом на итерацию) — снимает привязку к частоте loop. Главный дешёвый выигрыш.
  3. subBuffer 250→512/1024 — больше запаса (RAM: 1024≈8КБ).
  4. Табличный CRC вместо битового ×2 (~8× быстрее, +~512Б flash).
  5. Поднять preempt RX-EXTI выше DMA-TX (механизм есть).
  6. Дешёвая метка DWT->CYCCNT вместо micros()+критсекции в ISR.

2. Обработка ошибок сигнала и протокол

CRC — это НЕ CRC16-CCITT, а два сцепленных CRC8

crc8() (IR_config.cpp:17-33) битовый MSB-first, init=0xFF. Полиномы poly1=0x31, poly2=0x8C (=битовое зеркало 0x31, но алгоритм тот же → фактически два разных CRC8). Сборка (crcCheck :951-970): crc = crc8(0..len,poly1)<<8 | crc8(0..len+1,poly2). Асимметрия: байт2 покрывает данные+байт1, байт1 — только данные. Суммарно ~16 бит, вероятность пропуска ~1/65536, но гарантированной хэмминг-дистанции реального CRC16 нет. Мелочь :959: dataBuffer[len] == (crc>>8) & 0xFF парсится как (==)&0xFF — работает случайно (хрупко при смене crc_t).

Фильтры/устойчивость

  • Pulse-filter по длительности — по умолчанию выкл (IR_INPUT_MIN_PULSE_US=0).
  • Инлайн-отсев глитчей в декодере — вкл: IR_SHORT_LOW_GLITCH_REJECT, IR_MICRO_GAP_RISE_REJECT (:484-516), «подтяжка фазы».
  • Преамбула устойчива к случайному шуму (2 согласованных периода в узком окне). Sync 3 бита/байт — ловит расстройку сетки (до 3 ошибок → reject), но это не коррекция.

Коррекции нет

  • FEC отсутствует. Есть эвристическая реконструкция бит по времени (:636-741) — угадывание, финально валидируется CRC.
  • BRUTEFORCE_CHECK (однобитная коррекция перебором) — выключен, //TODO: зависает (IR_config.h:162). При 16-битном контроле ещё и рискует ложной коррекцией.
  • rejectBuffer — не коррекция, а «detect-and-expose» битого кадра приложению.

Подтверждения (Accept) — механизм есть, но мёртвый

Приёмник на IR_MSG_DATA_ACCEPT планирует sendAccept(from, crc8(данные)) через acceptDelay (IR_Decoder::_tick :157-171). Отправитель Accept игнорирует — логика ожидания/ретрая закомментирована (IR_Encoder.cpp:736-741, rawSend:906 «TODO»). Ретраев/дедупа/seq нет. Гарантий доставки нет.

Форматы кадров

Первый байт (msgType<<5)|(packSize&0x1F), packSize — полный размер кадра, длина 5 бит (0..31). Адреса big-endian.

Тип Класс Поля data offset CRC
BACK 0 DataBack msg, addrFrom(1-2) 3 2
ACCEPT 1 Accept msg, addrFrom, customByte(3) 2
REQUEST 2 Request msg, addrFrom, addrTo 2
BACK_TO 4 DataBack msg, addrFrom, addrTo 5 2
DATA_NOACCEPT 6 Data msg, addrFrom, addrTo 5 2
DATA_ACCEPT 7 Data (нужен Accept) как 6 5 2

Адресация (IR_config): Broadcast=65000+; id==0 = promiscuous (принимает всё); машинки 1..31999, КТ 32000..63999, пульты 64000..64999.

Что теряется/портится молча

  1. Переполнение 5-битной длины (серьёзно): накладные Data = 7 байт → payload ≤24, но send разрешает len до bytePerPack=31 (IR_Encoder.cpp:691). При len 25..31 packSize пишется как &0x1F (оборот) → декодер читает короткий кадр → CRC не сойдётся → пакет теряется, а send вернул Success. (Комментарий config:96 сам предупреждает про «31 vs 24».)
  2. Нет seq → дубликаты/пропуски незаметны (любой будущий ретрай = повтор действия).
  3. Коллизии не детектируются (half-duplex без CSMA/CD; isPairSending глушит только свой RX). TODO «отложить TX после приёма» (IR_Encoder.cpp:5) не реализован.
  4. Тайминговая реконструкция может собрать «правдоподобный» буфер, редко проходящий CRC на неверных данных (~1/65536).
  5. Accept-customByte — 1 байт CRC8 (1/256 ложного подтверждения), но т.к. игнорируется — без эффекта.

3. Мины/баги (severity, подтверждено по коду)

# Severity Файл:line Суть Триггер
B1 CRITICAL IR_DecoderRaw.cpp:887,951-970 crcCheck(packSize-crcBytes) при packSize==1 → len=255 → OOB-чтение dataBuffer[0..256] (массив 38) шум: первый байт с младш. 5 бит =1 (0x01,0x21,…) после лока преамбулы
B2 HIGH IR_Decoder.cpp:167-169 encoder->sendAccept() без null-check; у Car encoder==nullptr (IR.cpp:31) → HardFault валидный DATA_ACCEPT машинке (детерминированно) или шум (1/65536)
B3 HIGH IR_DecoderRaw.cpp:753 off-by-one > вместо >= → OOB-запись dataBuffer[38] (затирает prevRise) при packSize==0 шум: первый байт с младш. 5 бит =0, ~38 «дата»-байт подряд
B4 MEDIUM IR_Decoder.cpp:72-79 (конструктор) isWaitingAcceptSend/acceptSendTimer/addrAcceptSendTo/acceptCustomByte не инициализированы → спонтанный sendAccept (+ B2 крэш) мусор в RAM при старте
B5 MEDIUM RingBuffer.h:28-37 + IR_DecoderRaw.cpp:408-415 вложенные критсекции не вкладываются (interrupts() в pop() снимает внешний noInterrupts() из tick()) → торн-рид *rawPtr, слот уже свободен для ISR заполненное кольцо в узком окне
B6 MEDIUM IR_Decoder.cpp:112-115, IR_DecoderRaw.cpp:280-282 джиттер метки фронта: std::function indirect-call + лишний noInterrupts/interrupts вокруг micros() в ISR → разброс risePeriod нагрузка/параллельный DMA
B7 LOW IR_DecoderRaw.cpp:959 приоритет операторов в сравнении CRC — работает случайно смена crc_t
B8 LOW IR_DecoderRaw.cpp:448 isSubBufferOverflow=false без критсекции параллельно ISR =true → потеря флага (диагностика)
B9 LOW IR_DecoderRaw.cpp:1612 sentinel prevRise==0 при micros()==0 (раз в ~71.6 мин) → разовый глитч преамбулы редко
B10 LOW (спит) IR_DecoderRaw.cpp:980-986, :1676 ceil_div /riseTime; при freeFrec==true riseSyncTime может уйти <300 → riseTimeMin overflow / деление на 0 только при freeFrec (по умолч. выкл)

Опровергнуто (REFUTED): wrap micros() в таймаутах (wrap-safe, (uint32_t)(a-b)); переполнение subBuffer за границы (push проверяет isFull); packSize>dataByteSizeMax (5 бит → max 31 < 38, опасна только НИЖНЯЯ граница); overflow highCount/lowCount (гейт по IR_timeout); аллокации в ISR (bind SBO на attachInterrupt, в ISR только indirect-call); реентерабельность одной EXTI (M4 не вытесняет сам себя).

Требует железной проверки: джиттер метки фронта под параллельным DMA-TX; NVIC preempt RX>TX; воспроизведение B1/B3 (подать первый байт 0x01 / 0x00 под отладчиком); RAM-бюджет subBuffer (2КБ/декодер) на F4-пульте с несколькими декодерами.


4. Приоритетный список доработок

Сначала — баги (кандидаты на фикс, все в общей библиотеке → blast radius на все проекты)

  1. B1 CRITICAL: валидировать packSize >= crcBytes (и разумную нижнюю границу) ДО crcCheck; отбрасывать кадр иначе. Однострочная защита от OOB.
  2. B2 HIGH: if (encoder) encoder->sendAccept(...) (null-check). Спасает Car от HardFault.
  3. B3 HIGH: >= вместо > в границе writeToBuffer:753 (+ обрабатывать packSize==0 как невалидный).
  4. B4 MEDIUM: инициализировать accept-поля в конструкторе (isWaitingAcceptSend=false и т.д.).
  5. B5 MEDIUM: pop() отдавать по значению (out-параметр) под одной критсекцией, не указателем во внутренний слот.

Затем — производительность RX (эффект/сложность)

  • Батч-pop за tick (высокий эффект / низкая сложность) → снять привязку к частоте loop.
  • Табличный CRC; больше subBuffer; preempt RX>TX; (крупно) input-capture+DMA.

Протокол (эффект/сложность)

  1. Валидация полной длины в send (высокий/низкий): проверять packSize<=31 (Data ≤24, BACK ≤26), возвращать PayloadTooLarge — убирает тихую потерю.
  2. Seq + дедуп (высокий/средний): 1 байт seq, дедуп по (addrFrom, seq). Предпосылка для любого ретрая.
  3. Реальный ACK+ретрай на отправителе (высокий/средне-высокий): механизм генерации Accept уже есть, замкнуть петлю (нужен seq).
  4. Listen-before-talk (средний/низкий): отложить TX пока isReciving() — TODO уже помечен.
  5. Заголовочный CRC/дублирование длины (средний/низкий): битая длина не должна ломать границу кадра.
  6. Кадровый повтор как лёгкий FEC (в паре с seq); CRC16-CCITT вместо 2×CRC8 (маргинально, ломает совместимость); фрагментация >31 байт с ACK (если реально нужны большие payload); починить/включить bruteforce (сомнительно).

Ключевые файлы

IR-protocol/: IR_DecoderRaw.{h,cpp} (ISR, декод, CRC, reject, преамбула, mute), IR_Decoder.{h,cpp} (диспетчеризация, Accept-петля, EXTI/NVIC), IR_Encoder.{h,cpp} (сборка кадра, CRC, send, isSending), PacketTypes.{h,cpp} (форматы, адресация), IR_config.{h,cpp} (тайминги, framing, crc8), RingBuffer.h, IrTxIsrBufferedStorage.h.