Files
radar_system/device_firmware/switch_controller/src/main.cpp
T
Ayzen 3f6cfc9108 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.
2026-09-15 14:41:21 +03:00

476 lines
25 KiB
C++

// Контроллер выходного СВЧ-ключа для 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 <Arduino.h>
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;
}
}