// Контроллер выходного СВЧ-ключа для multi-device LibreVNA. // // Стоит в разрыве триггерной петли между мастером и первым слейвом: // // мастер TRIGGER_OUT --> PB0 [STM32] PB1 --> слейв1 TRIGGER_IN // PB2 --> ключ выходного порта 1 // PB6 --> ключ выходного порта 2 // // Задача. В режиме переключения внутри точки (см. ../per_point_switching.md) // прибор измеряет каждую частоту в 4 стадии: порт 1 в положениях A и B ключа, // затем порт 2 в тех же двух положениях. Положение ключа — это младший бит // номера стадии, а период 2 нацело делит 4 стадии, поэтому нужная // последовательность это просто непрерывное чередование A,B,A,B,... и // контроллеру достаточно инвертировать ногу на каждый цикл триггера. Абсолютный // счётчик стадий не нужен. // // Переключение делается по СПАДУ триггера: спад означает «оцифровка стадии // закончилась», после него прибор уходит в состояние Settling следующей стадии. // Это окно (dwell_time + собственная задержка PLL прибора, ~62 мкс при // dwell_us = 2) целиком покрывает установление ключа. Переключение по фронту // заставило бы удерживать кольцо на время установления. // // Восстановление чётности. Само по себе чередование самодостаточно, но // единственный пропущенный или лишний фронт инвертирует чётность НАВСЕГДА и // молча меняет антенны местами. Поэтому контроллер дополнительно распознаёт // границу точки по длине паузы: между стадиями пауза равна времени успокоения, // а между точками к ней добавляется перезагрузка регистров PLL, между // развёртками — ещё и работа хоста. Длинная пауза означает, что следующий // триггер начнёт стадию 0, то есть положение A. Так система чинит чётность на // КАЖДОЙ точке, а не один раз на развёртку. // // Порог для «длинной паузы» подстраивается сам: межстадийная пауза — это // минимальная регулярно повторяющаяся пауза, поэтому за порог берётся минимум // по скользящему окну, умноженный на запас. Пока минимум не набран, // восстановление молчит — чередование и без него корректно. // // Задержка зеркалирования (~50 нс) общая для всех приборов: мастер начинает // оцифровку по своему TRIGGER_IN, который приходит с конца кольца, то есть тоже // после этого контроллера. На взаимные сдвиги мастер/слейвы она не влияет и в // отношении «приёмник/опорник» сокращается — она лишь чуть удлиняет развёртку. #include namespace { // ---- распиновка ---- // Все три триггерные линии на GPIOB: зеркалирование это одно чтение IDR и одна // запись BSRR, без обращения к разным периферийным блокам. constexpr uint32_t kPinTriggerIn = GPIO_PIN_0; // PB0 <- TRIGGER_OUT мастера constexpr uint32_t kPinTriggerOut = GPIO_PIN_1; // PB1 -> TRIGGER_IN слейва 1 constexpr uint32_t kPinResync = GPIO_PIN_5; // PB5 -> отладочный выход (см. ниже) // У каждого ключа своя нога. В штатном режиме оба идут в одно и то же положение // (положение задаёт номер стадии, а он общий), но раздельные линии снимают // параллельную нагрузку с одного вывода и позволяют развести ключи независимо. // // PB6 выбран как свободный вывод порта B без отладочной функции. PB3 (SWO) и // PB4 (NJTRST) занимать не стоит — они мешают отладчику. Поменять можно одной // строкой, лишь бы вывод остался на порту B: тогда обе ноги переключаются ОДНОЙ // записью в BSRR, то есть строго одновременно. constexpr uint32_t kPinSwitchPort1 = GPIO_PIN_2; // PB2 -> ключ выходного порта 1 constexpr uint32_t kPinSwitchPort2 = GPIO_PIN_6; // PB6 -> ключ выходного порта 2 constexpr uint32_t kPinSwitchMask = kPinSwitchPort1 | kPinSwitchPort2; GPIO_TypeDef *const kTriggerPort = GPIOB; // Инверсия управляющего уровня, отдельно для каждого ключа: если у одного из них // положению A соответствует лог. 1, инвертируется только он. constexpr bool kSwitchPort1ActiveLow = false; constexpr bool kSwitchPort2ActiveLow = false; // ---- настройка распознавания границы точки ---- // Порог = минимальная пауза * kBoundaryFactorNum / kBoundaryFactorDen. Запас // намеренно щедрый: ложное срабатывание ПОСРЕДИ точки испортит данные, а // несрабатывание всего лишь отключит восстановление, оставив корректное // чередование. Если по осциллографу видно, что восстановление не срабатывает // ни разу, множитель можно снижать — но не ниже реального отношения // межточечной паузы к межстадийной. constexpr uint32_t kBoundaryFactorNum = 3; constexpr uint32_t kBoundaryFactorDen = 2; // Окно, по которому берётся минимальная пауза. Оно намеренно КОРОТКОЕ. // // Межстадийная пауза — самая частая: при четырёх стадиях на точку это три паузы // из четырёх, при двух — одна из двух. Значит минимум даже по десятку отсчётов // уже равен ей, и длинное окно не даёт никакой точности (пауза задаётся // счётчиком в FPGA, она детерминирована). // // Зато длинное окно вредит. Если пауза ВЫРОСЛА — подняли dwell_us или задержку // PLL прибора — устаревший маленький минимум держит порог ниже реальной // межстадийной паузы, и детектор границы начинает срабатывать на каждой стадии, // залипая ключ в положении A. С таким окном это самоустраняется за считанные // точки, а не за развёртку. По той же причине короткое окно быстро вымывает // случайную короткую паузу от помехи. constexpr uint32_t kGapWindowSamples = 16; // Защита от шума: пауза короче этого не считается настоящей стадией. constexpr uint32_t kMinPlausibleGapMicroseconds = 2; // ---- гистограмма пауз ---- // Ведро 10 мкс, 40 вёдер закрывают 0..400 мкс, последнее ведро — «и больше». // Диапазон выбран под ожидаемое: межстадийная пауза 62 мкс при dwell_us = 2, // межточечная — та же плюс перезагрузка регистров PLL. 10 мкс достаточно, чтобы // два класса пауз не слились в одно ведро. constexpr uint32_t kGapBucketMicroseconds = 10; constexpr uint32_t kGapBucketCount = 41; // Длительность импульса на отладочной ноге. constexpr uint32_t kResyncPulseMicroseconds = 2; // Через столько тишины на линии триггера считаем, что развёртка остановлена, и // печатаем отчёт. Порог заведомо больше межразвёрточной паузы, чтобы печать // никогда не наложилась на работающий свип: печать блокирует зеркалирование. constexpr uint32_t kIdleReportMilliseconds = 200; uint32_t g_cpuCyclesPerMicrosecond = 1; // ---- счётчик тактов ядра как источник времени ---- // DWT считает такты ядра свободно и без прерываний, поэтому годится и внутри // горячего цикла. 32 бита при 170 МГц переполняются за ~25 с; все интервалы // считаются вычитанием без знака, поэтому переполнение безопасно. void startCycleCounter() { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; g_cpuCyclesPerMicrosecond = SystemCoreClock / 1000000UL; if (g_cpuCyclesPerMicrosecond == 0) { g_cpuCyclesPerMicrosecond = 1; } } inline uint32_t cycleCount() { return DWT->CYCCNT; } inline uint32_t microsecondsToCycles(uint32_t microseconds) { return microseconds * g_cpuCyclesPerMicrosecond; } inline uint32_t cyclesToMicroseconds(uint32_t cycles) { return cycles / g_cpuCyclesPerMicrosecond; } // Минимум по скользящему окну за O(1) памяти и времени: держим минимум текущего // окна и минимум предыдущего, на границе окна сдвигаем. Эффективная длина окна // плавает между одним и двумя kGapWindowSamples, чего для оценки порога хватает. class SlidingWindowMinimum { public: void add(uint32_t value) { if (value < current_) { current_ = value; } if (++sampleCount_ >= kGapWindowSamples) { previous_ = current_; current_ = UINT32_MAX; sampleCount_ = 0; hasPrevious_ = true; } } bool isValid() const { return hasPrevious_; } // Минимум по обоим окнам. Достоверен только при isValid(). uint32_t value() const { return current_ < previous_ ? current_ : previous_; } private: uint32_t current_ = UINT32_MAX; uint32_t previous_ = UINT32_MAX; uint32_t sampleCount_ = 0; bool hasPrevious_ = false; }; // Диагностика, накапливаемая между отчётами. Читается только в паузе, поэтому // синхронизация не нужна: всё однопоточное. struct Statistics { uint32_t triggerCycles = 0; // сколько стадий отработано uint32_t boundaries = 0; // сколько границ точки распознано uint32_t corrections = 0; // из них потребовавших ЧИНИТЬ положение ключа uint32_t shortestGapMicros = UINT32_MAX; uint32_t longestGapMicros = 0; // Стадий между двумя срабатываниями восстановления. Измерено 2026-09-14: // распознаётся НЕ граница точки, а граница СВИПА, поэтому здесь ожидается // «число точек x стадий на точку» (804 при 201 точке и 4 стадиях), а не 4. // Подробности — в README, раздел про длины паузы. uint32_t stagesBetweenResyncs = 0; // Гистограмма пауз между стадиями, ведро = kGapBucketMicroseconds мкс. // Нужна, чтобы подобрать порог распознавания границы точки по ИЗМЕРЕНИЮ, а // не по догадке: min/max для этого бесполезны, потому что max забирает // межразвёрточная пауза, а классов пауз внутри развёртки два — // межстадийная и межточечная, — и надо видеть оба. uint32_t gapBuckets[kGapBucketCount] = {}; void reset() { *this = Statistics{}; } }; // Стадий на одну частотную точку: 2 порта мастера x 2 положения ключа. Число // ЧЁТНОЕ, и на этом держится вся схема: непрерывное чередование A,B,A,B само // возвращается к A на каждой новой точке, без всякого счётчика. constexpr uint32_t kStagesPerPoint = 4; static_assert(kStagesPerPoint % 2 == 0, "при нечётном числе стадий на точку чередование съедет на границе точки"); class SwitchController { public: void begin() { configurePins(); applySwitchPosition(0); previousInputIsHigh_ = readInput(); mirror(previousInputIsHigh_); lastEdgeCycles_ = cycleCount(); lastFallingEdgeCycles_ = lastEdgeCycles_; } // Один проход горячего цикла. Держится коротким и без ветвлений на // критическом пути: зеркалирование выполняется безусловно, до всей логики. inline void poll() { const bool inputIsHigh = readInput(); mirror(inputIsHigh); if (inputIsHigh != previousInputIsHigh_) { previousInputIsHigh_ = inputIsHigh; if (inputIsHigh) { onRisingEdge(); } else { onFallingEdge(); } return; } endResyncPulseIfElapsed(); detectPointBoundaryIfElapsed(); } bool isIdleFor(uint32_t milliseconds) const { return (cycleCount() - lastEdgeCycles_) > microsecondsToCycles(milliseconds * 1000UL); } Statistics &statistics() { return statistics_; } uint32_t boundaryThresholdMicroseconds() const { return gapMinimum_.isValid() ? cyclesToMicroseconds(boundaryThresholdCycles()) : 0; } uint32_t typicalGapMicroseconds() const { return gapMinimum_.isValid() ? cyclesToMicroseconds(gapMinimum_.value()) : 0; } private: static void configurePins() { __HAL_RCC_GPIOB_CLK_ENABLE(); // Вход с подтяжкой вниз: мастер гонит линию двухтактным выходом, поэтому // подтяжка ему не мешает, но удерживает вход в покое, если провод отвалился. GPIO_InitTypeDef input = {}; input.Pin = kPinTriggerIn; input.Mode = GPIO_MODE_INPUT; input.Pull = GPIO_PULLDOWN; HAL_GPIO_Init(kTriggerPort, &input); GPIO_InitTypeDef outputs = {}; outputs.Pin = kPinTriggerOut | kPinSwitchMask | kPinResync; outputs.Mode = GPIO_MODE_OUTPUT_PP; outputs.Pull = GPIO_NOPULL; outputs.Speed = GPIO_SPEED_FREQ_VERY_HIGH; HAL_GPIO_Init(kTriggerPort, &outputs); kTriggerPort->BSRR = (kPinTriggerOut | kPinSwitchMask | kPinResync) << 16U; } static inline bool readInput() { return (kTriggerPort->IDR & kPinTriggerIn) != 0U; } static inline void mirror(bool inputIsHigh) { kTriggerPort->BSRR = inputIsHigh ? kPinTriggerOut : (kPinTriggerOut << 16U); } // Оба ключа переводятся ОДНОЙ записью в BSRR: младшая половина слова ставит // биты, старшая сбрасывает, поэтому ноги меняются в одном такте, без взаимного // сдвига. Это и есть причина держать оба вывода на одном порту. static inline void applySwitchLevel(bool isPositionB) { const bool port1High = isPositionB != kSwitchPort1ActiveLow; const bool port2High = isPositionB != kSwitchPort2ActiveLow; kTriggerPort->BSRR = (port1High ? kPinSwitchPort1 : (kPinSwitchPort1 << 16U)) | (port2High ? kPinSwitchPort2 : (kPinSwitchPort2 << 16U)); } inline void applySwitchPosition(uint32_t position) { switchPosition_ = position & 1U; applySwitchLevel(switchPosition_ != 0U); } inline void onRisingEdge() { const uint32_t now = cycleCount(); const uint32_t gapCycles = now - lastFallingEdgeCycles_; lastEdgeCycles_ = now; boundaryPending_ = false; // Первый фронт после сброса не с чем сравнивать: паузы до него не было. if (hasCompletedFirstCycle_ && gapCycles >= minPlausibleGapCycles()) { gapMinimum_.add(gapCycles); const uint32_t gapMicros = cyclesToMicroseconds(gapCycles); if (gapMicros < statistics_.shortestGapMicros) { statistics_.shortestGapMicros = gapMicros; } if (gapMicros > statistics_.longestGapMicros) { statistics_.longestGapMicros = gapMicros; } const uint32_t bucket = gapMicros / kGapBucketMicroseconds; ++statistics_.gapBuckets[bucket < kGapBucketCount ? bucket : kGapBucketCount - 1]; } ++statistics_.triggerCycles; ++stagesSinceBoundary_; } inline void onFallingEdge() { const uint32_t now = cycleCount(); lastEdgeCycles_ = now; lastFallingEdgeCycles_ = now; hasCompletedFirstCycle_ = true; // Установившийся режим: следующая стадия всегда в другом положении. // Через границу точки это тоже верно, поэтому предсказание безусловное, // а распознавание границы ниже нужно лишь как страховка от сбоя чётности. applySwitchPosition(switchPosition_ + 1U); boundaryPending_ = true; } // Пауза затянулась сверх межстадийной: следующий триггер начнёт новую точку, // то есть стадию 0. Ставим положение A независимо от накопленной чётности. inline void detectPointBoundaryIfElapsed() { if (!boundaryPending_ || !gapMinimum_.isValid()) { return; } if ((cycleCount() - lastFallingEdgeCycles_) <= boundaryThresholdCycles()) { return; } boundaryPending_ = false; ++statistics_.boundaries; // Первая распознанная граница закрывает точку, начало которой мы не // видели, поэтому её неполный счёт не считается отклонением. if (hasSeenBoundary_) { statistics_.stagesBetweenResyncs = stagesSinceBoundary_; } hasSeenBoundary_ = true; stagesSinceBoundary_ = 0; if (switchPosition_ != 0U) { // Чередование разошлось с реальностью — чиним. В исправной системе // этого не происходит, поэтому счётчик прямо показывает здоровье. applySwitchPosition(0); ++statistics_.corrections; beginResyncPulse(); } } inline void beginResyncPulse() { kTriggerPort->BSRR = kPinResync; resyncPulseStartCycles_ = cycleCount(); resyncPulseActive_ = true; } inline void endResyncPulseIfElapsed() { if (!resyncPulseActive_) { return; } if ((cycleCount() - resyncPulseStartCycles_) < microsecondsToCycles(kResyncPulseMicroseconds)) { return; } kTriggerPort->BSRR = kPinResync << 16U; resyncPulseActive_ = false; } uint32_t boundaryThresholdCycles() const { return (gapMinimum_.value() / kBoundaryFactorDen) * kBoundaryFactorNum; } static uint32_t minPlausibleGapCycles() { return microsecondsToCycles(kMinPlausibleGapMicroseconds); } SlidingWindowMinimum gapMinimum_; Statistics statistics_; uint32_t switchPosition_ = 0; uint32_t stagesSinceBoundary_ = 0; uint32_t lastEdgeCycles_ = 0; uint32_t lastFallingEdgeCycles_ = 0; uint32_t resyncPulseStartCycles_ = 0; bool previousInputIsHigh_ = false; bool boundaryPending_ = false; bool resyncPulseActive_ = false; bool hasCompletedFirstCycle_ = false; bool hasSeenBoundary_ = false; }; SwitchController g_controller; bool g_reportPrinted = false; void printReport() { Statistics &statistics = g_controller.statistics(); Serial.println(F("--- switch controller ---")); Serial.print(F("trigger cycles : ")); Serial.println(statistics.triggerCycles); Serial.print(F("resyncs (sweep ends): ")); Serial.println(statistics.boundaries); Serial.print(F("parity corrections : ")); Serial.print(statistics.corrections); Serial.println(statistics.corrections == 0 ? F(" (ok)") : F(" <-- ПРОВЕРИТЬ")); Serial.print(F("stages per resync : ")); Serial.print(statistics.stagesBetweenResyncs); Serial.println(F(" (ожидается points x stages, напр. 804)")); Serial.print(F("gap min/max, us : ")); Serial.print(statistics.shortestGapMicros == UINT32_MAX ? 0 : statistics.shortestGapMicros); Serial.print(F(" / ")); Serial.println(statistics.longestGapMicros); Serial.print(F("inter-stage gap, us : ")); Serial.println(g_controller.typicalGapMicroseconds()); Serial.print(F("boundary above, us : ")); Serial.println(g_controller.boundaryThresholdMicroseconds()); // Гистограмма: печатаются только непустые вёдра, иначе отчёт разрастётся. // По ней и подбирается коэффициент порога: нужны ДВА скопления — короткое // (межстадийная пауза) и длинное (межточечная), а порог ставится между ними. Serial.println(F("gap histogram (us: count)")); for (uint32_t bucket = 0; bucket < kGapBucketCount; ++bucket) { if (statistics.gapBuckets[bucket] == 0) { continue; } Serial.print(F(" ")); Serial.print(bucket * kGapBucketMicroseconds); if (bucket + 1 == kGapBucketCount) { Serial.print(F("+ : ")); } else { Serial.print(F("-")); Serial.print((bucket + 1) * kGapBucketMicroseconds - 1); Serial.print(F(" : ")); } Serial.println(statistics.gapBuckets[bucket]); } statistics.reset(); } } // namespace void setup() { Serial.begin(115200); startCycleCounter(); g_controller.begin(); } void loop() { g_controller.poll(); // Отчёт печатается только когда развёртка заведомо остановлена: Serial // блокирует зеркалирование, а пропущенные фронты сбили бы чётность (её, // впрочем, восстановит ближайшая граница точки). if (g_controller.isIdleFor(kIdleReportMilliseconds)) { if (!g_reportPrinted) { printReport(); g_reportPrinted = true; } } else { g_reportPrinted = false; } }