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
мастер 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 мс, то есть когда развёртка остановлена. Во время работы
+84 -25
View File
@@ -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();
}