fix(ir-dma): eliminate isSending latch — free-running TIM, completion watchdog, busy() OR

C: remove racy activeCount_ + shared-timer gating; TIM17 free-running, per-stream DMA arming only (HAL_DMA_Start_IT/Abort_IT). Sets active before arming; never stops TIM on a shared counter.

D: poll() watchdog detects ticksOutput stall (>=stallTimeoutMs_, default 100ms) and forceStop()s the stuck stream under a per-channel NVIC mask; recoveryCount()/setStallTimeoutMs() accessors. dmaErrorCb now checks !active for symmetric single-stop.

E: busy() now returns true if ANY stream active (was AND) — honest contract for irAnyTxBusy()/carrier retune; does not serialize the send path.

Bug: IR TX rarely latched (LED frozen on/off) because stopStream was reachable only from the DMA HT/CPLT IRQ. Hardware bring-up (logic analyzer, recoveryCount telemetry) pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 15:26:52 +03:00
parent 9935ac192a
commit 7176fe5321

View File

@ -85,7 +85,6 @@ public:
}
s_instance = this;
activeCount_ = 0;
for (uint8_t i = 0; i < streamCount_; i++) {
HAL_NVIC_EnableIRQ(streams_[i].dmaIrq);
@ -98,12 +97,19 @@ public:
return s_instance;
}
// Fix E (контракт busy): "занят" = АКТИВЕН ХОТЯ БЫ ОДИН поток (OR).
// Раньше было AND (все потоки сразу) — почти всегда false, не отражало занятости и было
// избыточно (per-stream s.active в startStream + per-encoder isSending уже не дают повторный
// старт того же потока). OR нужен, чтобы irAnyTxBusy()/applyCarrierMultiply реально дожидались
// простоя ВСЕХ эмиттеров перед ретюном несущей.
// ВНИМАНИЕ: это НЕ сериализует независимые front/rear — путь отправки гейтится per-stream;
// внешний busy()-гейт в IR_Encoder::rawSend намеренно нейтрализован в прошивке (txBusy()=false).
// Чтобы ВКЛЮЧИТЬ сериализацию front/rear — вернуть txBusy()=busy() в Car/src/IR/IR.cpp.
bool busy() const {
if (streamCount_ == 0) return false;
for (uint8_t i = 0; i < streamCount_; i++) {
if (!streams_[i].active) return false;
if (streams_[i].active) return true;
}
return true;
return false;
}
IR_SendStatus start(IR_Encoder* enc, const uint8_t* packet, uint8_t len) {
@ -126,6 +132,35 @@ public:
return &streams_[streamIndex].hdma;
}
// Fix D: не-IRQ страховка завершения. Вызывать из главного цикла (IR_Module::tick()).
// Штатно isSending сбрасывает только DMA HT/CPLT IRQ; если он не сработает (пропуск/слипание
// флагов, Transfer-Error, последствие любой гонки) — поток «залипает» навсегда. Здесь ловим
// застой прогресса ticksOutput по стенным часам и форсим stopStream как аварийное
// восстановление. Это recovery (не prevention): фикс C убирает саму гонку, а poll() страхует
// структурный SPOF завершения и делает редкий баг наблюдаемым (recoveryCount()).
void poll() {
const uint32_t now = millis();
for (uint8_t i = 0; i < streamCount_; i++) {
TxStream& s = streams_[i];
if (!s.active) continue; // atomic: bool
const uint32_t t = s.ticksOutput; // atomic: выровненный u32 на Cortex-M
if (t != s.lastTicks) {
s.lastTicks = t;
s.lastProgressMs = now;
continue;
}
if ((uint32_t)(now - s.lastProgressMs) >= stallTimeoutMs_) {
forceStop(s);
}
}
}
/** Порог застоя для watchdog (мс). По умолчанию 100; должен быть заметно больше времени
вывода одной половины DMA-буфера на самой медленной несущей. 0 игнорируется. */
void setStallTimeoutMs(uint32_t ms) { if (ms != 0U) stallTimeoutMs_ = ms; }
/** Сколько раз watchdog аварийно восстановил залипший поток (наблюдаемость/логи). */
uint32_t recoveryCount() const { return recoveries_; }
private:
struct TxStream {
DMA_HandleTypeDef hdma{};
@ -152,6 +187,10 @@ private:
uint32_t totalTicks = 0;
volatile uint32_t ticksOutput = 0;
// Fix D (watchdog): прогресс ticksOutput против стенных часов (контекст потока).
uint32_t lastTicks = 0;
uint32_t lastProgressMs = 0;
bool active = false;
void resetWave() {
@ -186,29 +225,38 @@ private:
TxStream streams_[MaxStreams]{};
uint8_t streamCount_ = 0;
volatile uint8_t activeCount_ = 0;
// Fix C: разделяемый activeCount_ удалён — он и был источником гонки поток↔ISR.
uint32_t stallTimeoutMs_ = 100; // Fix D
uint32_t recoveries_ = 0; // Fix D (только контекст потока)
static uint32_t u32ptr(const volatile void* p) {
return (uint32_t)(uintptr_t)p;
}
void startTimerIfNeeded() {
// Fix C: TIM держим free-running. Включаем ИДЕМПОТЕНТНО при старте потока и НИКОГДА не
// останавливаем по разделяемому счётчику (счётчик убран → гонка поток↔ISR устранена).
// Счётчик TIM НЕ сбрасываем: сброс глитчил бы уже активный поток; для ИК ≤1 период несущей
// фазового сдвига пренебрежим. В простое (ни один DMA-канал не заармлен) update-события TIM
// генерят DMA-запросы «в никуда» — безвредно, в GPIO ничего не пишется, светодиод не дёргается.
void ensureTimerRunning() {
if (htim_ == nullptr) return;
if (activeCount_ != 1) return;
__HAL_TIM_DISABLE_DMA(htim_, TIM_DMA_UPDATE);
__HAL_TIM_CLEAR_FLAG(htim_, TIM_FLAG_UPDATE);
__HAL_TIM_SET_COUNTER(htim_, 0);
__HAL_TIM_ENABLE_DMA(htim_, TIM_DMA_UPDATE);
HAL_TIM_Base_Start(htim_);
if ((htim_->Instance->CR1 & TIM_CR1_CEN) == 0U) {
__HAL_TIM_CLEAR_FLAG(htim_, TIM_FLAG_UPDATE);
HAL_TIM_Base_Start(htim_);
}
}
void stopTimerIfIdle() {
if (htim_ == nullptr) return;
if (activeCount_ != 0) return;
__HAL_TIM_DISABLE_DMA(htim_, TIM_DMA_UPDATE);
HAL_TIM_Base_Stop(htim_);
// Fix D: аварийный стоп залипшего потока из контекста потока (не ISR). Маскируем ИМЕННО IRQ
// этого DMA-канала, чтобы штатный HT/CPLT/Error-обработчик не вытеснил нас посреди stopStream
// (иначе двойной abort/externalFinishSend). Другие потоки и прочие IRQ не трогаем.
void forceStop(TxStream& s) {
HAL_NVIC_DisableIRQ(s.dmaIrq);
if (s.active) {
stopStream(s);
recoveries_++;
}
HAL_NVIC_EnableIRQ(s.dmaIrq);
}
static TxStream* streamFromDma(DMA_HandleTypeDef* hdma) {
@ -241,7 +289,7 @@ private:
static void dmaErrorCb(DMA_HandleTypeDef* hdma) {
auto* s = streamFromDma(hdma);
if (s == nullptr) return;
if (s == nullptr || !s->active) return; // симметрично HT/CPLT: одиночный stop, без двойного finish
s->onError();
if (s_instance != nullptr) {
s_instance->stopStream(*s);
@ -319,14 +367,20 @@ private:
s.port->BSRR = s.resetWord;
// Fix C/D: помечаем active и инициализируем watchdog ДО арминга DMA — иначе ранний
// HT/CPLT IRQ при active==false выйдет из callback'а и пропустит refill → недосчёт
// ticksOutput → латч. TIM уже (или станет) free-running, старт по счётчику не нужен.
s.lastTicks = 0;
s.lastProgressMs = millis();
s.active = true;
const uint32_t dst = u32ptr(&s.port->BSRR);
if (HAL_DMA_Start_IT(&s.hdma, (uint32_t)(uintptr_t)s.dmaBuf, dst, s.bufLen) != HAL_OK) {
s.active = false;
return IR_SendStatus::DmaStartFailed;
}
s.active = true;
activeCount_++;
startTimerIfNeeded();
ensureTimerRunning();
return IR_SendStatus::Success;
}
@ -343,9 +397,7 @@ private:
if (s.enc != nullptr) {
s.enc->externalFinishSend();
}
if (activeCount_ > 0) activeCount_--;
stopTimerIfIdle();
// Fix C: TIM НЕ останавливаем — он free-running, без разделяемого счётчика.
}
};