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.
This commit is contained in:
Ayzen
2026-09-15 14:41:21 +03:00
parent caac6561c2
commit 3f6cfc9108
2 changed files with 140 additions and 32 deletions
+56 -7
View File
@@ -8,7 +8,8 @@
```text ```text
мастер TRIGGER_OUT ──► PB0 [STM32] PB1 ──► слейв 1 TRIGGER_IN мастер TRIGGER_OUT ──► PB0 [STM32] PB1 ──► слейв 1 TRIGGER_IN
PB2 ──► управление обоими СВЧ-ключами PB2 ──► ключ выходного порта 1
PB6 ──► ключ выходного порта 2
PB5 ──► отладочный выход PB5 ──► отладочный выход
``` ```
@@ -17,10 +18,24 @@
`Mode::Trigger` прошивка LibreVNA уводит собственный STM32 прибора в Hi-Z на обеих `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 > (`stimulus_switch_positions: 2` на хосте). Хост технически допускает и 4
@@ -68,6 +83,38 @@
умноженный на `kBoundaryFactorNum / kBoundaryFactorDen` (по умолчанию 1.5). Пока умноженный на `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** — само положение ключа, смотрится осциллографом вместе с триггером: на **PB2 и PB6** — положение ключей, смотрятся осциллографом вместе с триггером: на
одной частоте должно быть ровно 4 стадии и два полных периода `A B A B`. одной частоте должно быть ровно 4 стадии и два полных периода `A B A B`. Оба
вывода обязаны меняться одновременно и в одну сторону — расхождение между ними
означает ошибку в распиновке или в константах инверсии.
**USB CDC (115200)** — отчёт печатается один раз, когда на линии триггера **USB CDC (115200)** — отчёт печатается один раз, когда на линии триггера
тишина дольше 200 мс, то есть когда развёртка остановлена. Во время работы тишина дольше 200 мс, то есть когда развёртка остановлена. Во время работы
+84 -25
View File
@@ -3,7 +3,8 @@
// Стоит в разрыве триггерной петли между мастером и первым слейвом: // Стоит в разрыве триггерной петли между мастером и первым слейвом:
// //
// мастер TRIGGER_OUT --> PB0 [STM32] PB1 --> слейв1 TRIGGER_IN // мастер TRIGGER_OUT --> PB0 [STM32] PB1 --> слейв1 TRIGGER_IN
// PB2 --> оба СВЧ-ключа // PB2 --> ключ выходного порта 1
// PB6 --> ключ выходного порта 2
// //
// Задача. В режиме переключения внутри точки (см. ../per_point_switching.md) // Задача. В режиме переключения внутри точки (см. ../per_point_switching.md)
// прибор измеряет каждую частоту в 4 стадии: порт 1 в положениях A и B ключа, // прибор измеряет каждую частоту в 4 стадии: порт 1 в положениях A и B ключа,
@@ -46,12 +47,26 @@ namespace {
// запись BSRR, без обращения к разным периферийным блокам. // запись BSRR, без обращения к разным периферийным блокам.
constexpr uint32_t kPinTriggerIn = GPIO_PIN_0; // PB0 <- TRIGGER_OUT мастера constexpr uint32_t kPinTriggerIn = GPIO_PIN_0; // PB0 <- TRIGGER_OUT мастера
constexpr uint32_t kPinTriggerOut = GPIO_PIN_1; // PB1 -> TRIGGER_IN слейва 1 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 -> отладочный выход (см. ниже) 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; GPIO_TypeDef *const kTriggerPort = GPIOB;
// Инверсия управляющего уровня, если положению A у ключа соответствует лог. 1. // Инверсия управляющего уровня, отдельно для каждого ключа: если у одного из них
constexpr bool kSwitchActiveLow = false; // положению A соответствует лог. 1, инвертируется только он.
constexpr bool kSwitchPort1ActiveLow = false;
constexpr bool kSwitchPort2ActiveLow = false;
// ---- настройка распознавания границы точки ---- // ---- настройка распознавания границы точки ----
// Порог = минимальная пауза * kBoundaryFactorNum / kBoundaryFactorDen. Запас // Порог = минимальная пауза * kBoundaryFactorNum / kBoundaryFactorDen. Запас
@@ -81,6 +96,14 @@ constexpr uint32_t kGapWindowSamples = 16;
// Защита от шума: пауза короче этого не считается настоящей стадией. // Защита от шума: пауза короче этого не считается настоящей стадией.
constexpr uint32_t kMinPlausibleGapMicroseconds = 2; 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 kResyncPulseMicroseconds = 2;
@@ -158,17 +181,30 @@ struct Statistics {
uint32_t corrections = 0; // из них потребовавших ЧИНИТЬ положение ключа uint32_t corrections = 0; // из них потребовавших ЧИНИТЬ положение ключа
uint32_t shortestGapMicros = UINT32_MAX; uint32_t shortestGapMicros = UINT32_MAX;
uint32_t longestGapMicros = 0; uint32_t longestGapMicros = 0;
uint32_t lastStagesPerPoint = 0; // стадий между двумя границами; должно быть 4 // Стадий между двумя срабатываниями восстановления. Измерено 2026-09-14:
uint32_t unexpectedStagesPerPoint = 0; // распознаётся НЕ граница точки, а граница СВИПА, поэтому здесь ожидается
// «число точек x стадий на точку» (804 при 201 точке и 4 стадиях), а не 4.
// Подробности — в README, раздел про длины паузы.
uint32_t stagesBetweenResyncs = 0;
// Гистограмма пауз между стадиями, ведро = kGapBucketMicroseconds мкс.
// Нужна, чтобы подобрать порог распознавания границы точки по ИЗМЕРЕНИЮ, а
// не по догадке: min/max для этого бесполезны, потому что max забирает
// межразвёрточная пауза, а классов пауз внутри развёртки два —
// межстадийная и межточечная, — и надо видеть оба.
uint32_t gapBuckets[kGapBucketCount] = {};
void reset() { void reset() {
*this = Statistics{}; *this = Statistics{};
} }
}; };
// Ожидаемое число стадий между границами точки при переключении на 2 положения: // Стадий на одну частотную точку: 2 порта мастера x 2 положения ключа. Число
// 2 порта мастера x 2 положения ключа. // ЧЁТНОЕ, и на этом держится вся схема: непрерывное чередование A,B,A,B само
constexpr uint32_t kExpectedStagesPerPoint = 4; // возвращается к A на каждой новой точке, без всякого счётчика.
constexpr uint32_t kStagesPerPoint = 4;
static_assert(kStagesPerPoint % 2 == 0,
"при нечётном числе стадий на точку чередование съедет на границе точки");
class SwitchController { class SwitchController {
public: public:
@@ -230,13 +266,13 @@ class SwitchController {
HAL_GPIO_Init(kTriggerPort, &input); HAL_GPIO_Init(kTriggerPort, &input);
GPIO_InitTypeDef outputs = {}; GPIO_InitTypeDef outputs = {};
outputs.Pin = kPinTriggerOut | kPinSwitch | kPinResync; outputs.Pin = kPinTriggerOut | kPinSwitchMask | kPinResync;
outputs.Mode = GPIO_MODE_OUTPUT_PP; outputs.Mode = GPIO_MODE_OUTPUT_PP;
outputs.Pull = GPIO_NOPULL; outputs.Pull = GPIO_NOPULL;
outputs.Speed = GPIO_SPEED_FREQ_VERY_HIGH; outputs.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
HAL_GPIO_Init(kTriggerPort, &outputs); HAL_GPIO_Init(kTriggerPort, &outputs);
kTriggerPort->BSRR = (kPinTriggerOut | kPinSwitch | kPinResync) << 16U; kTriggerPort->BSRR = (kPinTriggerOut | kPinSwitchMask | kPinResync) << 16U;
} }
static inline bool readInput() { static inline bool readInput() {
@@ -247,13 +283,20 @@ class SwitchController {
kTriggerPort->BSRR = inputIsHigh ? kPinTriggerOut : (kPinTriggerOut << 16U); kTriggerPort->BSRR = inputIsHigh ? kPinTriggerOut : (kPinTriggerOut << 16U);
} }
static inline void applySwitchLevel(bool isHigh) { // Оба ключа переводятся ОДНОЙ записью в BSRR: младшая половина слова ставит
kTriggerPort->BSRR = isHigh ? kPinSwitch : (kPinSwitch << 16U); // биты, старшая сбрасывает, поэтому ноги меняются в одном такте, без взаимного
// сдвига. Это и есть причина держать оба вывода на одном порту.
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) { inline void applySwitchPosition(uint32_t position) {
switchPosition_ = position & 1U; switchPosition_ = position & 1U;
applySwitchLevel((switchPosition_ != 0U) != kSwitchActiveLow); applySwitchLevel(switchPosition_ != 0U);
} }
inline void onRisingEdge() { inline void onRisingEdge() {
@@ -272,6 +315,8 @@ class SwitchController {
if (gapMicros > statistics_.longestGapMicros) { if (gapMicros > statistics_.longestGapMicros) {
statistics_.longestGapMicros = gapMicros; statistics_.longestGapMicros = gapMicros;
} }
const uint32_t bucket = gapMicros / kGapBucketMicroseconds;
++statistics_.gapBuckets[bucket < kGapBucketCount ? bucket : kGapBucketCount - 1];
} }
++statistics_.triggerCycles; ++statistics_.triggerCycles;
++stagesSinceBoundary_; ++stagesSinceBoundary_;
@@ -305,10 +350,7 @@ class SwitchController {
// Первая распознанная граница закрывает точку, начало которой мы не // Первая распознанная граница закрывает точку, начало которой мы не
// видели, поэтому её неполный счёт не считается отклонением. // видели, поэтому её неполный счёт не считается отклонением.
if (hasSeenBoundary_) { if (hasSeenBoundary_) {
statistics_.lastStagesPerPoint = stagesSinceBoundary_; statistics_.stagesBetweenResyncs = stagesSinceBoundary_;
if (stagesSinceBoundary_ != kExpectedStagesPerPoint) {
++statistics_.unexpectedStagesPerPoint;
}
} }
hasSeenBoundary_ = true; hasSeenBoundary_ = true;
stagesSinceBoundary_ = 0; stagesSinceBoundary_ = 0;
@@ -369,17 +411,14 @@ void printReport() {
Serial.println(F("--- switch controller ---")); Serial.println(F("--- switch controller ---"));
Serial.print(F("trigger cycles : ")); Serial.print(F("trigger cycles : "));
Serial.println(statistics.triggerCycles); Serial.println(statistics.triggerCycles);
Serial.print(F("point boundaries : ")); Serial.print(F("resyncs (sweep ends): "));
Serial.println(statistics.boundaries); Serial.println(statistics.boundaries);
Serial.print(F("parity corrections : ")); Serial.print(F("parity corrections : "));
Serial.print(statistics.corrections); Serial.print(statistics.corrections);
Serial.println(statistics.corrections == 0 ? F(" (ok)") : F(" <-- ПРОВЕРИТЬ")); Serial.println(statistics.corrections == 0 ? F(" (ok)") : F(" <-- ПРОВЕРИТЬ"));
Serial.print(F("stages per point : ")); Serial.print(F("stages per resync : "));
Serial.print(statistics.lastStagesPerPoint); Serial.print(statistics.stagesBetweenResyncs);
Serial.print(F(" (ожидается ")); Serial.println(F(" (ожидается points x stages, напр. 804)"));
Serial.print(kExpectedStagesPerPoint);
Serial.print(F("), отклонений: "));
Serial.println(statistics.unexpectedStagesPerPoint);
Serial.print(F("gap min/max, us : ")); Serial.print(F("gap min/max, us : "));
Serial.print(statistics.shortestGapMicros == UINT32_MAX ? 0 : statistics.shortestGapMicros); Serial.print(statistics.shortestGapMicros == UINT32_MAX ? 0 : statistics.shortestGapMicros);
Serial.print(F(" / ")); Serial.print(F(" / "));
@@ -388,6 +427,26 @@ void printReport() {
Serial.println(g_controller.typicalGapMicroseconds()); Serial.println(g_controller.typicalGapMicroseconds());
Serial.print(F("boundary above, us : ")); Serial.print(F("boundary above, us : "));
Serial.println(g_controller.boundaryThresholdMicroseconds()); 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(); statistics.reset();
} }