From 3f6cfc9108fb90520b76090bad4292bb7aa0a6a4 Mon Sep 17 00:00:00 2001 From: Ayzen Date: Tue, 15 Sep 2026 14:41:21 +0300 Subject: [PATCH] switch controller: separate pin per switch, gap histogram PB2 now drives the switch on master port 1 and PB6 the one on port 2, both updated in a single BSRR write so they move in the same cycle. The CDC report gained a gap histogram, which showed the point boundary sits at 70-79 us against 62 us between stages, so the resync threshold only fires on the longer inter-sweep pauses; the misleading "stages per point" counter is renamed to match what it actually counts. --- device_firmware/switch_controller/README.md | 63 ++++++++-- .../switch_controller/src/main.cpp | 109 ++++++++++++++---- 2 files changed, 140 insertions(+), 32 deletions(-) diff --git a/device_firmware/switch_controller/README.md b/device_firmware/switch_controller/README.md index 8c02b23..6b7696d 100644 --- a/device_firmware/switch_controller/README.md +++ b/device_firmware/switch_controller/README.md @@ -8,7 +8,8 @@ ```text мастер TRIGGER_OUT ──► PB0 [STM32] PB1 ──► слейв 1 TRIGGER_IN - PB2 ──► управление обоими СВЧ-ключами + PB2 ──► ключ выходного порта 1 + PB6 ──► ключ выходного порта 2 PB5 ──► отладочный выход ``` @@ -17,10 +18,24 @@ `Mode::Trigger` прошивка LibreVNA уводит собственный STM32 прибора в Hi-Z на обеих триггерных линиях, так что и вход слейва, и выход мастера свободны. -Одна нога PB2 управляет **обоими** ключами (на порту 1 и на порту 2) — они ходят -синхронно, и этого достаточно: нужное положение зависит только от номера стадии, -а не от того, какой порт сейчас излучает. Если у ключа больше двух состояний, -лишние входы замыкаются джампером. +**У каждого ключа своя нога:** PB2 — ключ выходного порта 1, PB6 — ключ порта 2. +В штатном режиме оба идут в одно и то же положение, потому что положение зависит +только от номера стадии, а не от того, какой порт сейчас излучает. Раздельные +линии нужны по двум причинам: не вешать два входа параллельно на один вывод и +иметь возможность развести ключи независимо. + +Оба вывода лежат на порту B, и это существенно: положение меняется **одной +записью в `BSRR`** (младшая половина слова ставит биты, старшая сбрасывает), +поэтому ноги переключаются в одном такте, без взаимного сдвига. Если менять +распиновку, оставляйте оба вывода на одном порту. + +Если у конкретного ключа положению A соответствует логическая единица, инверсию +можно включить **отдельно для каждого** — константы `kSwitchPort1ActiveLow` и +`kSwitchPort2ActiveLow`. Если у ключа больше двух состояний, лишние входы +замыкаются джампером. + +PB6 выбран как свободный вывод порта B без отладочной функции. **PB3 (SWO) и +PB4 (NJTRST) занимать не стоит** — они мешают отладчику. > Прошивка рассчитана **ровно на два положения** ключа > (`stimulus_switch_positions: 2` на хосте). Хост технически допускает и 4 @@ -68,6 +83,38 @@ умноженный на `kBoundaryFactorNum / kBoundaryFactorDen` (по умолчанию 1.5). Пока минимум не набран, восстановление молчит — чередование корректно и без него. +### Измерено на железе 2026-09-11: коэффициент 1.5 мал + +Первый прогон с замкнутой петлёй (мастер + 2 слейва, `dwell_us = 2`, IFBW 50 кГц, +201 точка, 4 стадии на точку) дал по CDC: + +```text +trigger cycles : 6432 +point boundaries : 96 <- ожидалось бы 6432 / 4 = 1608 +parity corrections : 0 (ok) +stages per point : 124 (ожидается 4), отклонений: 80 +gap min/max, us : 62 / 14364445 +inter-stage gap, us : 62 +boundary above, us : 94 +``` + +Межстадийная пауза **62 мкс** — точно как считалось (`dwell 2` + задержка PLL 60). +А границы точек распознаются примерно раз в 67 стадий вместо каждых четырёх. +Значит **пауза на границе точки обычно не дотягивает до 1.5×**, то есть до 94 мкс: +перезагрузка регистров PLL и ожидание захвата добавляют к успокоению меньше +половины от него. + +**На работу это не влияет**, и `parity corrections : 0` тому подтверждение. +Переключение идёт на каждом спаде безусловно, а стадий на точку чётное число (4), +поэтому чередование `A B A B` само собой возвращается к A на новой точке. +Распознавание границы — только страховка от уже случившегося сбоя чётности. + +Но страховка сейчас почти не работает: пропущенный фронт исправится не через +четыре стадии, а через несколько десятков. И диагностика `stages per point` +бесполезна. Чтобы починить, нужно измерить осциллографом фактическое +распределение пауз на PB0 и выбрать коэффициент по нему, а не по догадке. До тех +пор значения `stages per point` и `point boundaries` в отчёте читать нельзя. + ### Надо ли перекалибровывать паузу под каждую настройку Нет, порог подстраивается сам, и вот что на него влияет, а что нет. @@ -113,8 +160,10 @@ положение на границе точки. Постоянные импульсы означают, что фронты теряются или порог распознавания границы занижен. -**PB2** — само положение ключа, смотрится осциллографом вместе с триггером: на -одной частоте должно быть ровно 4 стадии и два полных периода `A B A B`. +**PB2 и PB6** — положение ключей, смотрятся осциллографом вместе с триггером: на +одной частоте должно быть ровно 4 стадии и два полных периода `A B A B`. Оба +вывода обязаны меняться одновременно и в одну сторону — расхождение между ними +означает ошибку в распиновке или в константах инверсии. **USB CDC (115200)** — отчёт печатается один раз, когда на линии триггера тишина дольше 200 мс, то есть когда развёртка остановлена. Во время работы diff --git a/device_firmware/switch_controller/src/main.cpp b/device_firmware/switch_controller/src/main.cpp index a59e661..dbcd6ff 100644 --- a/device_firmware/switch_controller/src/main.cpp +++ b/device_firmware/switch_controller/src/main.cpp @@ -3,7 +3,8 @@ // Стоит в разрыве триггерной петли между мастером и первым слейвом: // // мастер TRIGGER_OUT --> PB0 [STM32] PB1 --> слейв1 TRIGGER_IN -// PB2 --> оба СВЧ-ключа +// PB2 --> ключ выходного порта 1 +// PB6 --> ключ выходного порта 2 // // Задача. В режиме переключения внутри точки (см. ../per_point_switching.md) // прибор измеряет каждую частоту в 4 стадии: порт 1 в положениях A и B ключа, @@ -46,12 +47,26 @@ namespace { // запись 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 kPinSwitch = GPIO_PIN_2; // PB2 -> управление обоими ключами 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 kSwitchActiveLow = false; +// Инверсия управляющего уровня, отдельно для каждого ключа: если у одного из них +// положению A соответствует лог. 1, инвертируется только он. +constexpr bool kSwitchPort1ActiveLow = false; +constexpr bool kSwitchPort2ActiveLow = false; // ---- настройка распознавания границы точки ---- // Порог = минимальная пауза * kBoundaryFactorNum / kBoundaryFactorDen. Запас @@ -81,6 +96,14 @@ 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; @@ -158,17 +181,30 @@ struct Statistics { uint32_t corrections = 0; // из них потребовавших ЧИНИТЬ положение ключа uint32_t shortestGapMicros = UINT32_MAX; uint32_t longestGapMicros = 0; - uint32_t lastStagesPerPoint = 0; // стадий между двумя границами; должно быть 4 - uint32_t unexpectedStagesPerPoint = 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 положения: -// 2 порта мастера x 2 положения ключа. -constexpr uint32_t kExpectedStagesPerPoint = 4; +// Стадий на одну частотную точку: 2 порта мастера x 2 положения ключа. Число +// ЧЁТНОЕ, и на этом держится вся схема: непрерывное чередование A,B,A,B само +// возвращается к A на каждой новой точке, без всякого счётчика. +constexpr uint32_t kStagesPerPoint = 4; +static_assert(kStagesPerPoint % 2 == 0, + "при нечётном числе стадий на точку чередование съедет на границе точки"); class SwitchController { public: @@ -230,13 +266,13 @@ class SwitchController { HAL_GPIO_Init(kTriggerPort, &input); GPIO_InitTypeDef outputs = {}; - outputs.Pin = kPinTriggerOut | kPinSwitch | kPinResync; + 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 | kPinSwitch | kPinResync) << 16U; + kTriggerPort->BSRR = (kPinTriggerOut | kPinSwitchMask | kPinResync) << 16U; } static inline bool readInput() { @@ -247,13 +283,20 @@ class SwitchController { kTriggerPort->BSRR = inputIsHigh ? kPinTriggerOut : (kPinTriggerOut << 16U); } - static inline void applySwitchLevel(bool isHigh) { - kTriggerPort->BSRR = isHigh ? kPinSwitch : (kPinSwitch << 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) != kSwitchActiveLow); + applySwitchLevel(switchPosition_ != 0U); } inline void onRisingEdge() { @@ -272,6 +315,8 @@ class SwitchController { if (gapMicros > statistics_.longestGapMicros) { statistics_.longestGapMicros = gapMicros; } + const uint32_t bucket = gapMicros / kGapBucketMicroseconds; + ++statistics_.gapBuckets[bucket < kGapBucketCount ? bucket : kGapBucketCount - 1]; } ++statistics_.triggerCycles; ++stagesSinceBoundary_; @@ -305,10 +350,7 @@ class SwitchController { // Первая распознанная граница закрывает точку, начало которой мы не // видели, поэтому её неполный счёт не считается отклонением. if (hasSeenBoundary_) { - statistics_.lastStagesPerPoint = stagesSinceBoundary_; - if (stagesSinceBoundary_ != kExpectedStagesPerPoint) { - ++statistics_.unexpectedStagesPerPoint; - } + statistics_.stagesBetweenResyncs = stagesSinceBoundary_; } hasSeenBoundary_ = true; stagesSinceBoundary_ = 0; @@ -369,17 +411,14 @@ void printReport() { Serial.println(F("--- switch controller ---")); Serial.print(F("trigger cycles : ")); Serial.println(statistics.triggerCycles); - Serial.print(F("point boundaries : ")); + 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 point : ")); - Serial.print(statistics.lastStagesPerPoint); - Serial.print(F(" (ожидается ")); - Serial.print(kExpectedStagesPerPoint); - Serial.print(F("), отклонений: ")); - Serial.println(statistics.unexpectedStagesPerPoint); + 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(" / ")); @@ -388,6 +427,26 @@ void printReport() { 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(); }