Verified on the bench that the modified master bitstream excites in every stage and the RF switch routes: 16 traces per sweep, 62 dB between the two switch positions of one master port, 9.9k measurements/s. Adds the built master image, two run profiles, a verification script and librevna_protocol.md, and drops the stale prototype driver copy that the firmware tree still carried.
400 lines
26 KiB
Markdown
400 lines
26 KiB
Markdown
# Переключение стимула внутри частотной точки
|
||
|
||
Режим, в котором одна развёртка даёт матрицу 4×4 вместо 2×4: каждый порт мастера
|
||
измеряется на **каждой** частоте дважды — с двумя положениями выходного ключа.
|
||
|
||
```text
|
||
4 выхода = 2 порта мастера × 2 положения ключа
|
||
4 входа = 2 слейва × 2 порта (входной ключ не участвует)
|
||
```
|
||
|
||
Выигрыш не в скорости (стадий столько же, сколько было развёрток), а в
|
||
когерентности: все 16 комбинаций снимаются в пределах ~0.4 мс на одной частоте
|
||
вместо разброса в сотни миллисекунд между развёртками.
|
||
|
||
Базовое устройство протокола, поверх которого всё это работает (кадр, типы
|
||
пакетов, `SweepSettings`, разбор `VNADatapoint`) — в
|
||
[librevna_protocol.md](librevna_protocol.md).
|
||
|
||
## Раскладка стадий
|
||
|
||
| Стадия | Порт мастера | Ключ | Выход |
|
||
|---|---|---|---|
|
||
| 0 | 1 | A | 0 |
|
||
| 1 | 1 | B | 2 |
|
||
| 2 | 2 | A | 1 |
|
||
| 3 | 2 | B | 3 |
|
||
|
||
Стадии нумеруются **порт-мажорно**: старшие биты счётчика стадий выбирают порт,
|
||
младший — положение ключа. Выходы нумеруются **позиция-мажорно**, поэтому выходы
|
||
0 и 1 сохраняют тот же смысл, что и без переключения.
|
||
|
||
Из этого следует главное свойство: **положение ключа — это младший бит номера
|
||
стадии**, а период 2 нацело делит 4 стадии. Значит через границу точки чередование
|
||
продолжается без разрыва, и контроллеру достаточно инвертировать ногу на каждый
|
||
цикл триггера — абсолютный счётчик стадий не нужен, нужна только правильная
|
||
чётность, а её контроллер восстанавливает на каждой точке по длине паузы.
|
||
|
||
## Контракт по проводу
|
||
|
||
Хост отправляет это в `build_sweep_settings_payload`, прошивка читает — обе
|
||
стороны реализованы. Слово стадий в `SweepSettings` (16 бит, смещение 25 в
|
||
payload из 31 байта; раскладку payload целиком см.
|
||
[librevna_protocol.md](librevna_protocol.md)):
|
||
|
||
| Биты | Поле | Значение в этом режиме |
|
||
|---|---|---|
|
||
| 0..2 | `stages` | 3 (то есть 4 стадии) |
|
||
| 3..5 | `port1Stage` | 0 — **первая стадия блока** порта 1 |
|
||
| 6..8 | `port2Stage` | 2 — первая стадия блока порта 2 |
|
||
| 9..11 | `port3Stage` → ширина под-стадии | `log2(положений ключа)` = 1 |
|
||
| 12..14 | `port4Stage` | не используется |
|
||
| 15 | `unused2` | не используется |
|
||
|
||
Порт-стадия — это **первая стадия блока** порта, а не его индекс. Без под-стадий
|
||
блок вырождается в одну стадию, и значение совпадает с индексом, то есть со
|
||
стоковой раскладкой.
|
||
|
||
Поле `port3Stage` переиспользовано под ширину блока под-стадий: двухпортовая
|
||
прошивка его игнорирует, поэтому формат пакета не меняется. **Ноль там —
|
||
стоковая раскладка**, так что хост с `stimulus_switch_positions = 1` остаётся
|
||
полностью совместим с немодифицированным прибором.
|
||
|
||
Слейвам обе порт-стадии выставляются в **7** — зарезервированное значение
|
||
«порт не излучает». Стоковый приём «одна за последней стадией» здесь не работает:
|
||
при 4 стадиях старшие биты счётчика принимают только значения 0 и 1, свободного
|
||
значения не остаётся, и слейв начал бы излучать.
|
||
|
||
## FPGA мастера
|
||
|
||
Сделано и собрано в
|
||
[`multi_libreVNA_firmware/reproduce/LibreVNA/FPGA/VNA/`](multi_libreVNA_firmware/reproduce/LibreVNA/FPGA/VNA/).
|
||
|
||
### `Sweep.vhd`
|
||
|
||
Порт включался по точному совпадению, поэтому излучал ровно в одной стадии, и во
|
||
второй под-стадии зондирующего сигнала просто не было. Сравнение вынесено в
|
||
функцию `port_excites`: младшие `SUBSTAGE_BITS` бит счётчика стадий — положение
|
||
ключа, и они **маскируются** из сравнения, а старшие биты выбирают порт.
|
||
|
||
```vhdl
|
||
if port_stage = PORT_STAGE_DISABLED then -- "111", порт не излучает
|
||
return false;
|
||
end if;
|
||
return (std_logic_vector(stage) and compare_mask) = (port_stage and compare_mask);
|
||
```
|
||
|
||
Маска декодируется из `SUBSTAGE_BITS` тремя вентилями, причём нераспознанная
|
||
ширина даёт все единицы — чтобы неконфигурированный прибор никогда не выродился
|
||
в «каждый порт излучает в каждой стадии».
|
||
|
||
Совместимость со стоком **строгая, а не приблизительная**. При `SUBSTAGE_BITS = 0`
|
||
маска все единицы, и выражение становится буквально стоковым
|
||
`stage_cnt = port_stage`. Значит для всех существующих вызовов поведение
|
||
идентично — включая ручной режим и анализатор спектра, где `STAGES = 0`.
|
||
|
||
Значение 7 зарезервировано под «порт выключен»: так хост глушит излучение
|
||
слейвов. Стоковый хост слал «одну за последней стадией», и это тоже не совпадает
|
||
ни с одной реальной стадией, так что новый битстрим работает и со старым хостом.
|
||
|
||
### `SPIConfig.vhd` и `top.vhd`
|
||
|
||
В регистре `SweepSetup` (адрес 6) заняты биты 15:13, 12, 5:3 и 2:0 — **биты 11:6
|
||
свободны**. Заняты три из них:
|
||
|
||
```vhdl
|
||
when 6 => STAGES <= spi_buf_out(15 downto 13);
|
||
SYNC_ENABLED <= spi_buf_out(12);
|
||
SUBSTAGE_BITS <= spi_buf_out(8 downto 6); -- новое
|
||
PORT1_STAGE <= spi_buf_out(5 downto 3);
|
||
PORT2_STAGE <= spi_buf_out(2 downto 0);
|
||
```
|
||
|
||
В `top.vhd` добавлен сигнал `sweep_substage_bits` и проброшен от `SPIConfig` к
|
||
`Sweep`.
|
||
|
||
### Почему маска, а не сдвиг
|
||
|
||
Первая версия сравнивала `shift_right(stage_cnt, substage_bits)` с номером порта.
|
||
Функционально верно, но переменный сдвиг синтезируется в barrel shifter, а
|
||
вызывался он четырежды (по два на порт): **+142 LUT** при всего +3 регистрах.
|
||
Разводка поехала, и контрольная сборка показала нарушение hold на входе
|
||
`MCU_MOSI` — на шине, по которой MCU пишет регистры FPGA. Сбой там означал бы
|
||
неверную конфигурацию развёртки, то есть крайне неприятный в поиске отказ.
|
||
|
||
Маска обходится несколькими вентилями и такого эффекта не даёт.
|
||
|
||
### Cost table размещателя — обязательно
|
||
|
||
Ограничение `MCU_MOSI OFFSET = IN ... BEFORE MCU_SCK` маргинально **уже в стоке**:
|
||
запас по hold всего +0.458 нс. Поэтому любое изменение нетлиста переставляет
|
||
размещение и может его опрокинуть. Проверено прямым A/B: сток даёт Timing Score 0,
|
||
первая версия правки — 1880, версия на маске при cost table 1 — 5480.
|
||
|
||
Решается штатно, подбором cost table. В `VNA.xise` зафиксировано:
|
||
|
||
```xml
|
||
<property xil_pn:name="Starting Placer Cost Table (1-100) Map spartan6" xil_pn:value="2" .../>
|
||
```
|
||
|
||
С этим значением **Timing Score 0**, а запас по hold на `MCU_MOSI` даже больше
|
||
стокового: +0.642 нс против +0.458. Ресурсы: 2302 LUT против 2189 у стока (40%
|
||
против 38% ёмкости xc6slx9).
|
||
|
||
**Не сбрасывайте это свойство в 1.** Если после правок в VHDL тайминг снова
|
||
поедет — перебирайте cost table дальше (3, 4, 5…) и проверяйте `Timing Score` в
|
||
`top.par`; сборка каждого варианта около шести минут, синтез при этом кэширован.
|
||
|
||
### Проверка
|
||
|
||
Рядом лежит самопроверяющийся тестбенч `Test_Sweep.vhd` на четыре сценария: сток,
|
||
два положения ключа, приёмник без излучения, четыре положения. Он гоняет автомат
|
||
без триггерной петли (`SYNC_ENABLED = '0'`) и сверяет `PORT1_ACTIVE`/`PORT2_ACTIVE`
|
||
в момент старта каждой оцифровки.
|
||
|
||
```bash
|
||
ghdl -a --std=93 Sweep.vhd Test_Sweep.vhd && ghdl -r --std=93 Test_Sweep
|
||
```
|
||
|
||
Тест проверен мутацией: если выбросить маску из сравнения, сценарии с
|
||
под-стадиями падают (8 сработавших проверок), а стоковый продолжает проходить.
|
||
|
||
VHDL держите в чистом ASCII: GHDL отвергает не-ASCII символы даже в комментариях.
|
||
|
||
### Проверено на железе 2026-09-11
|
||
|
||
Мастер прошит `combined_per_point_switching.vnafw`, контроллер врезан в петлю,
|
||
ВЧ-ключ **не** подключён. Скрипт
|
||
[`per_point_switching_check.py`](../python_app/scripts/per_point_switching_check.py)
|
||
снял 8 коллекций по 16 трасс:
|
||
|
||
```text
|
||
выход 0: -71.5 дБ выходы 0 и 1: расхождение 0.5 дБ
|
||
выход 1: -72.0 дБ выходы 2 и 3: расхождение 1.3 дБ
|
||
выход 2: -70.6 дБ
|
||
выход 3: -71.9 дБ
|
||
```
|
||
|
||
Что именно этим доказано:
|
||
|
||
- **Битстрим в приборе доработанный.** Со стоковым сравнением «стадия равна
|
||
порт-стадии» излучали бы только стадии 0 и 2, а выходы 1 и 3 провалились бы в
|
||
шум. Здесь все четыре на одном уровне.
|
||
- **Петля триггера с контроллером замкнута.** При `syncMode = 3` мастер семплирует
|
||
по вернувшемуся триггеру, так что без исправного зеркалирования в STM32 данных
|
||
не было бы вовсе.
|
||
- **Выходы попарно совпадают**, как и должно быть при отключённом ключе: обе его
|
||
позиции — физически один путь.
|
||
|
||
Считать фронты для этой проверки **бессмысленно**: поле `stages` стоковое, сток
|
||
тоже отработает четыре стадии на точку, просто без стимула в 1 и 3.
|
||
|
||
Межстадийная пауза, измеренная контроллером, — **62 мкс**, ровно `dwell_us = 2`
|
||
плюс 60 мкс собственного успокоения PLL прибора. Но распознавание границ точек по
|
||
длине паузы работает плохо, коэффициент 1.5 оказался мал — подробности и цифры в
|
||
[README контроллера](switch_controller/README.md). На переключение это не влияет.
|
||
|
||
### Проверено с подключённым ВЧ-ключом, 30–200 МГц
|
||
|
||
Профиль `run_config_examples/run_config_librevna_multi_per_point_lowband.example.json`:
|
||
30–200 МГц, IFBW 50 кГц, 201 точка. Диапазон выбран выше
|
||
`HW::BandSwitchFrequency = 25 МГц` — ниже неё прибор уходит на путь Si5351 через
|
||
I2C, останавливает развёртку и даёт нерегулярные длинные паузы. Верхняя граница
|
||
200 МГц лежит в полосе осциллографа, поэтому стимул видно напрямую.
|
||
|
||
Медиана `|S21|` по трём повторам, дБ:
|
||
|
||
| вход | выход 0 (A) | выход 1 (B) | выход 2 (A) | выход 3 (B) |
|
||
|---|---|---|---|---|
|
||
| 0 | **−5.4** | −77.1 | −69.8 | −78.0 |
|
||
| 1 | −69.2 | −68.9 | −69.0 | −69.1 |
|
||
| 2 | −77.1 | −77.7 | −76.4 | −77.9 |
|
||
| 3 | −73.6 | −73.8 | −72.8 | −73.5 |
|
||
|
||
Подключён был один тракт — вход 0 в выход 0. Разница между двумя положениями
|
||
одного и того же порта мастера (выходы 0 и 1) составила **72 дБ**: в положении A
|
||
сигнал идёт в подключённый кабель, в положении B — в открытый порт. Это и есть
|
||
прямое доказательство, что ключ переключается по стадиям. Повторяемость
|
||
0.0–1.8 дБ, на сигнальном тракте 0.0 дБ.
|
||
|
||
**Ограничение по IFBW.** При `if_bandwidth_hz = 1000` прибор воспроизводимо
|
||
падает: `LIBUSB_ERROR_IO`, затем устройства отваливаются с шины
|
||
(`LIBUSB_ERROR_NO_DEVICE`). Ограничения по числу отсчётов тут нет — 800 отсчётов
|
||
против предела 8191, — так что причина, вероятно, в возросшей скважности
|
||
источника (при 1 кГц он включён 94% времени против 24% при 50 кГц) и, значит, в
|
||
среднем токе. IFBW 5, 10, 20 и 50 кГц собираются нормально. **Штатным считать
|
||
50 кГц**, ниже 5 кГц не опускаться без проверки питания приборов.
|
||
|
||
## Прошивка STM32 LibreVNA
|
||
|
||
Сделано, собирается. Для самих стадий менять было нечего: `stageCnt` уже считается
|
||
до `settings.stages`, значения тегируются номером стадии, `VNADatapoint<32>`
|
||
вмещает 4 стадии × 3 значения. Протащено только новое поле:
|
||
|
||
```c
|
||
// FPGA.cpp — параметр со значением по умолчанию, чтобы не трогать вызовы из
|
||
// ручного режима и анализатора спектра
|
||
void FPGA::SetupSweep(uint8_t stages, uint8_t port1_stage, uint8_t port2_stage,
|
||
bool synchronize, bool syncMaster, uint8_t substage_bits) {
|
||
...
|
||
value |= (uint16_t) (substage_bits & 0x07) << 6;
|
||
|
||
// VNA.cpp — ширина под-стадии приезжает в port3Stage: порта 3 у этого железа нет,
|
||
// поле свободно, формат пакета не меняется
|
||
FPGA::SetupSweep(s.stages, s.port1Stage, s.port2Stage, s.syncMode != 0,
|
||
s.syncMaster, s.port3Stage);
|
||
```
|
||
|
||
Собирать так же, как описано в [`reproduce/README.md`](multi_libreVNA_firmware/reproduce/README.md).
|
||
|
||
## Контроллер ключа (отдельный STM32)
|
||
|
||
Реализован: [`switch_controller/`](switch_controller/) — WeAct STM32G431CB,
|
||
PlatformIO, тот же тулчейн, что у пульта тележки. Подробности подключения и
|
||
диагностики в [его README](switch_controller/README.md), здесь только суть.
|
||
|
||
Врезается в триггерную петлю между `TRIGGER_OUT` мастера и `TRIGGER_IN` первого
|
||
слейва. Электрически чисто: в режиме `Mode::Trigger` прошивка LibreVNA уводит
|
||
STM32 прибора в Hi-Z на обеих линиях (`GPIO_MODE_INPUT`, `NOPULL`, EXTI снят).
|
||
|
||
Логика сводится к трём правилам:
|
||
|
||
1. **Зеркалирование обоих фронтов.** Задержка не влияет на измерения: мастер
|
||
стартует оцифровку по своему `TRIGGER_IN`, который приходит с конца кольца,
|
||
то есть тоже после контроллера. Она общая для всех трёх приборов и в
|
||
отношении «приёмник/опорник» сокращается — только чуть удлиняет развёртку.
|
||
2. **Инверсия ноги по СПАДУ триггера.** Положение ключа — младший бит номера
|
||
стадии, а период 2 нацело делит 4 стадии, поэтому чередование не рвётся и на
|
||
границе точки. Спад означает «оцифровка закончилась», дальше идёт окно
|
||
успокоения следующей стадии (~62 мкс при `dwell_us = 2`), которого ключу
|
||
хватает с огромным запасом.
|
||
3. **По ноге на каждый ключ, но ходят они синхронно.** Нужное положение зависит
|
||
только от номера стадии, а не от того, какой порт излучает. PB2 управляет
|
||
ключом выходного порта 1, PB6 — порта 2; оба вывода на порту B, поэтому
|
||
переключаются одной записью в `BSRR`, то есть строго одновременно.
|
||
|
||
Страховка от сбоя чётности: один потерянный фронт инвертирует чередование
|
||
навсегда и молча меняет антенны местами. Контроллер распознаёт границу точки по
|
||
длине паузы (между точками к времени успокоения добавляется перезагрузка
|
||
регистров PLL, между развёртками — ещё и работа хоста) и принудительно ставит
|
||
положение A. Так чётность чинится на **каждой** точке. Порог берётся как минимум
|
||
паузы по скользящему окну с запасом, то есть подстраивается сам под любые
|
||
настройки развёртки; отдельный провод для маркера начала развёртки не нужен.
|
||
|
||
## Порядок ввода в строй
|
||
|
||
### Кого перепрошивать
|
||
|
||
**Только мастер.** Слейвы остаются на текущей прошивке, и вот почему: хост
|
||
выставляет им обе порт-стадии в 7, а стоковое сравнение `stage_cnt = PORTn_STAGE`
|
||
при счётчике 0..3 с семёркой не совпадает никогда — то есть слейв не излучает и
|
||
без доработки. Число стадий он берёт из того же поля `STAGES`, что и раньше, так
|
||
что четыре стадии на точку отработает как есть, а тегирование значений номером
|
||
стадии в стоковой прошивке уже общее.
|
||
|
||
Это заметно экономит силы: slave-битстрим пересобирать не надо, а он, напомню,
|
||
побайтово невоспроизводим.
|
||
|
||
### 1. Битстрим FPGA мастера (Xilinx ISE 14.7)
|
||
|
||
**Уже собран.** Лежит в `FPGA/VNA/top_master_per_point_switching.bin`,
|
||
sha256 `e184eb92bef1c698849511845fa8d754…`, Timing Score 0. Пересобирать нужно
|
||
только после правок в VHDL.
|
||
|
||
Имя отдельное намеренно: `top_master.bin` оставлен нетронутым — это одна из двух
|
||
копий невосстановимого битстрима, из которого собран текущий `combined.vnafw`
|
||
(вторая — `extracted/master_fpga.bin`). Заменить его новым стоит только после
|
||
проверки на железе.
|
||
|
||
Как собрать заново — в GUI:
|
||
|
||
1. Скопировать [`multi_libreVNA_firmware/reproduce/LibreVNA/FPGA`](multi_libreVNA_firmware/reproduce/LibreVNA/FPGA)
|
||
в общую папку виртуалки (`C:\shared\LibreVNA\FPGA`).
|
||
2. Открыть **`VNA.xise`** — проект мастера. Не `VNA_external_lo_slave.xise`.
|
||
3. Process → **Generate Programming File**.
|
||
4. Забрать `top.bin` **сразу** и переименовать в
|
||
`top_master_per_point_switching.bin`: оба проекта
|
||
пишут в один и тот же файл, и следующая сборка его затрёт. Именно так был
|
||
потерян исходный мастер-битстрим.
|
||
5. Проверить `Timing Score` в `top.par` — должен быть 0 (см. про cost table выше).
|
||
|
||
Или из командной строки в госте, без GUI:
|
||
|
||
```bash
|
||
cd ~/shared/LibreVNA/FPGA/VNA # лучше скопировать на локальный диск: vboxsf медленная
|
||
source /opt/Xilinx/14.7/ISE_DS/settings64.sh
|
||
printf 'project open VNA.xise\nprocess run "Generate Programming File"\nproject close\n' > /tmp/bit.tcl
|
||
xtclsh /tmp/bit.tcl
|
||
grep "Timing Score" top.par
|
||
```
|
||
|
||
Битстрим невоспроизводим побайтово (place & route недетерминирован плюс
|
||
timestamp внутри), так что новый файл будет отличаться от старого при любом
|
||
исходе — это нормально.
|
||
|
||
### 2. Прошивка MCU мастера
|
||
|
||
```bash
|
||
cd multi_libreVNA_firmware/reproduce/LibreVNA/Software/VNA_embedded
|
||
make BINPATH="<путь к arm-none-eabi из CubeIDE>/tools/bin" -j4
|
||
```
|
||
|
||
Без `EXTERNAL_MASTER_LO_SLAVE` — это и есть сборка мастера. Проверено: с этими
|
||
правками собирается без ошибок.
|
||
|
||
### 3. Упаковка .vnafw
|
||
|
||
Готовый образ уже лежит в репозитории:
|
||
`multi_libreVNA_firmware/combined_per_point_switching.vnafw`, хеш в
|
||
`reproduce/SHA256SUMS`. Пересобрать:
|
||
|
||
```bash
|
||
cd multi_libreVNA_firmware/reproduce/LibreVNA/Software/VNA_embedded
|
||
make BUILD_DIR=build_per_point_switching # НЕ в build/: там эталон
|
||
cd ../..
|
||
python3 AssembleFirmware.py \
|
||
--bitstream FPGA/VNA/top_master_per_point_switching.bin \
|
||
--mcu Software/VNA_embedded/build_per_point_switching/VNA_embedded.bin \
|
||
--output ../combined_per_point_switching.vnafw
|
||
```
|
||
|
||
Имя намеренно новое: **не перезаписывайте `combined.vnafw`, пока не проверите
|
||
всё на железе** — это ваш путь отката. После успешной проверки замените им
|
||
`combined.vnafw`, обновите `extracted/`, `build/`, `SHA256SUMS` и уберите
|
||
предупреждение в начале [`reproduce/README.md`](multi_libreVNA_firmware/reproduce/README.md).
|
||
|
||
Учтите: `./build.sh` сверяет результат со старым эталоном и теперь сообщит о
|
||
расхождении. Это ожидаемо, а не поломка.
|
||
|
||
### 4. Заливка
|
||
|
||
Штатным LibreVNA-GUI: **Device → Firmware Update**, скормить `.vnafw` целиком —
|
||
GUI сам разложит битстрим и прошивку MCU.
|
||
|
||
### 5. Включение режима и проверка
|
||
|
||
Только после того, как мастер поднялся на новой прошивке и обычный режим
|
||
(`stimulus_switch_positions: 1`) работает как раньше — включать переключение.
|
||
Порядок проверки на стенде:
|
||
|
||
1. Осциллографом на PB2, PB6 и PB5 контроллера: на одной частоте четыре стадии и
|
||
два периода `A B A B`, PB2 и PB6 меняются одновременно и в одну сторону, PB5
|
||
молчит.
|
||
2. По USB CDC контроллера (развёртку остановить): `parity corrections : 0` и
|
||
`stages per point : 4`.
|
||
3. Сверить трейсы: выходы 0 и 1 должны совпасть с тем, что даёт старый режим —
|
||
это те же порт 1 и порт 2 в положении A.
|
||
|
||
## Хост
|
||
|
||
Уже сделано. Включается одним полем в `run_config.json`:
|
||
|
||
```json
|
||
"multi_device": { "stimulus_switch_positions": 2 },
|
||
"sweep": { "dwell_us": 2 }
|
||
```
|
||
|
||
При этом расбери **перестаёт трогать ключ**: `SwitchedMatrixRadarService` уходит
|
||
из тракта, матрица 4×4 собирается за одну развёртку. `stimulus_switch_positions:
|
||
1` возвращает всё в прежний режим.
|