diff --git a/firmware-version-porting.md b/firmware-version-porting.md index ece253c..9844ffb 100644 --- a/firmware-version-porting.md +++ b/firmware-version-porting.md @@ -4,6 +4,7 @@ - [Пошаговое подключение модуля версии](c/firmware-info/PORTING.md). - [BAT-файлы precompile и postcompile для CCS и Keil](tools/firmware-build/README.md). +- [Шаблон контроля версий ПЛИС: Xilinx ISE, Vivado и Altera/Intel Quartus](tools/firmware-build/fpga/README.md). - [Настройка подготовки и публикации образов](tools/firmware-publish/PORTING.md). В режиме CCS `--identity` precompile создаёт заголовок версии и Git-метаданные, а postcompile преобразует OUT в SCI8 HEX/BIN и создаёт manifest и SHA-256. В прежнем режиме postcompile запускает SETGUI preflight готового образа. Для каждого проекта задаются явные пути; алгоритмы остаются в templates. diff --git a/tools/firmware-build/fpga/.gitattributes b/tools/firmware-build/fpga/.gitattributes new file mode 100644 index 0000000..22f70a5 --- /dev/null +++ b/tools/firmware-build/fpga/.gitattributes @@ -0,0 +1,2 @@ +# CMD требует CRLF для надёжного чтения BAT с русскими комментариями в UTF-8. +*.bat text eol=crlf diff --git a/tools/firmware-build/fpga/INSTALL_ISE.bat b/tools/firmware-build/fpga/INSTALL_ISE.bat new file mode 100644 index 0000000..0186b94 --- /dev/null +++ b/tools/firmware-build/fpga/INSTALL_ISE.bat @@ -0,0 +1,29 @@ +@chcp 65001 >nul +@rem Переключение на UTF-8 нужно для русских комментариев и путей; установщик создаёт локальные файлы ISE. +@echo off +rem Сохраняет окружение и не раскрывает восклицательные знаки в путях проекта. +setlocal EnableExtensions DisableDelayedExpansion +rem Предпочитает штатный Windows launcher Python 3, если он установлен. +where py >nul 2>nul +rem При отсутствии launcher использует Python из PATH. +if errorlevel 1 goto use_python +rem Передаёт путь проекта или дополнительные параметры без изменения их порядка. +py -3 "%~dp0ise_project.py" install %* +rem Запоминает код установки до вывода подсказок и ожидания клавиши. +set "FW_EXIT=%errorlevel%" +rem Не запускает установку повторно через другой интерпретатор. +goto done +rem Начинает вариант для компьютеров без Windows launcher. +:use_python +rem При двойном щелчке Python запросит путь к проекту интерактивно. +python "%~dp0ise_project.py" install %* +rem Сохраняет исходный результат установщика. +set "FW_EXIT=%errorlevel%" +rem Завершает оба варианта запуска одинаково. +:done +rem Автоматические вызовы могут отключить ожидание переменной окружения. +if defined FW_NO_PAUSE exit /b %FW_EXIT% +rem Оставляет результат видимым при запуске двойным щелчком или перетаскиванием .xise. +pause +rem Передаёт вызывающей программе результат установки, а не команды pause. +exit /b %FW_EXIT% diff --git a/tools/firmware-build/fpga/ISE_PORTING.md b/tools/firmware-build/fpga/ISE_PORTING.md new file mode 100644 index 0000000..b40604d --- /dev/null +++ b/tools/firmware-build/fpga/ISE_PORTING.md @@ -0,0 +1,42 @@ +# Простое подключение контроля прошивки к ISE + +1. Запустите **`INSTALL_ISE.bat` из этого каталога** и вставьте путь к вашему + `.xise`. Можно просто перетащить `.xise` на этот BAT. +2. Установщик сам прочитает кристалл, язык, имя `.bit` и рабочий каталог + из проекта, скопирует локальные команды и создаст настройки. +3. Один раз подключите паспорт к шине проекта по созданному файлу + `firmware_identity_instance.txt`: адрес слова — `word_addr[3:0]`, + данные — `word_data[15:0]`. Без использования выхода паспорт удалит синтезатор. +4. Для сборки запускайте **`build_firmware.bat` рядом с `.xise`**. + Для проверки настройки без сборки — `build_firmware.bat --check`. + +Автоматически создаются: + +| Файл | Назначение | +|---|---| +| `build_firmware.bat`, `firmware_build.py` | Локальная команда сборки | +| `firmware_build.json` | Найденные пути проекта, библиотеки templates, `.bit` и окружения ISE | +| `firmware_identity.json` | Изделие, кристалл и сохраняемый счётчик версии | +| `firmware_identity_instance.txt` | Заготовка подключения для Verilog или VHDL | +| `FIRMWARE_SETUP.md` | Памятка внутри проекта | +| `build/ise/generated/` | Предварительно созданный HDL-паспорт; обновляется перед каждой сборкой | + +Нужен Python 3.9+ через `py -3` или `python`. Окружение ISE ищется в PATH +и стандартных каталогах Xilinx. Для нестандартной установки один раз задайте +`ise_settings` в `firmware_build.json` — полный путь к `settings64.bat` +или `settings32.bat`. + +Общие инструменты используются из вашей библиотеки templates; вручную +копировать её внутрь проекта не требуется. Если библиотека переехала, +исправьте поле `templates` в `firmware_build.json`. + +Первая сборка получает **0.1.1**. Установка и `--check` номер не расходуют; +неудачная попытка синтеза расходует. Повторная установка сохраняет существующие +JSON и версию; чужие BAT с тем же именем не перезаписывает. + +Готовый `.bit`, manifest, SHA-256, HDL и метаданные сохраняются в +`build/ise/firmware/HASH_ДАТА_ВРЕМЯ_UTC+3/`. После загрузки на плату сравните +12 слов паспорта с `fpga.register_words` в manifest. + +Установщик предназначен для FPGA-проектов с результатом `.bit`; CPLD с `.jed` +требует отдельного шаблона. [Подробности паспорта и прямая Tcl-сборка](README.md). diff --git a/tools/firmware-build/fpga/README.md b/tools/firmware-build/fpga/README.md new file mode 100644 index 0000000..2a1a542 --- /dev/null +++ b/tools/firmware-build/fpga/README.md @@ -0,0 +1,241 @@ +# Контроль прошивок ПЛИС: Xilinx ISE, Vivado и Altera Quartus + +Для ISE: запустите `INSTALL_ISE.bat`, укажите `.xise`, затем используйте +созданный в проекте `build_firmware.bat`. [Краткая инструкция](ISE_PORTING.md). + +Шаблон подключает к существующему проекту ПЛИС общий механизм +[`firmware-info`](../../../c/firmware-info/README.md): сохраняемый итератор +SemVer, время сборки в UTC, Git build ID, manifest, SHA-256 и архив выпуска. +Параметры кристалла, выводы, частоты, ограничения и транспорт чтения регистров +задаёт проект платы. + +Цепочка: проект → Tcl своей САПР → `identity.py` из этого каталога → общий +`c/firmware-info/tools/identity.py`. Итератор, правила Git и архивирования +используются те же, что для CCS и Keil. + +## Файлы и зависимости + +| Файл | Назначение | Зависимости | +|---|---|---| +| `firmware_identity.example.json` | Начальная конфигурация изделия и счётчика | Копируется в проект | +| `INSTALL_ISE.bat`, `ise_project.py` | Автоматическая установка локальных BAT, JSON, HDL и памятки из `.xise` | Windows, Python 3.9+ | +| `ise-build.bat`, `ise_bootstrap.py`, `ise-project.md` | Шаблоны локальной команды сборки и памятки | Копируются установщиком в проект | +| `identity.py` | Генерация Verilog-2001/VHDL-93 и контроль готового файла | Python 3.9+, stdlib, общий `identity.py` из templates | +| `common.tcl` | Пути, язык, вызовы Python, обработка отказов | Tcl 8.5+ в САПР | +| `ise.tcl` | Полная сборка `.xise` до `.bit` | Xilinx ISE / `xtclsh` | +| `vivado.tcl` | Полная сборка `.xpr` до `.bit` | Vivado Project Mode | +| `quartus.tcl` | Полная сборка `.qpf` до `.sof` | Altera/Intel Quartus II или Prime / `quartus_sh` | + +Python 3 должен быть доступен как `python` из окружения САПР. Если ISE или +Quartus подставляет старый Python, передайте путь к `python.exe` шестым +аргументом. Git в PATH нужен для хешей; при недоступном Git получается +явный `NOGIT000`, локальная сборка продолжается. + +## 1. Конфигурация проекта + +Скопируйте `firmware_identity.example.json` рядом с `.xise`, `.xpr` или `.qpf` +под именем `firmware_identity.json`. Пример намеренно требует заменить имя +кристалла; задайте своё изделие и точный part number. + +| Поле | Смысл | +|---|---| +| `product` | Имя изделия, например `MY_FPGA_BOARD` | +| `target` | Точное обозначение кристалла; информационное поле, настройки part в проекте не меняет | +| `version` | Последний занятый номер `[major, minor, patch]`; в примере `[0, 1, 0]` | +| `increment_version` | `true` включает общий сохраняемый итератор; `false` оставляет ручную версию | + +Первая подготовка примера выдаёт **0.1.1**. Следующий pre-build выдаёт 0.1.2, +включая попытки после ошибки синтеза. Переносы: 0.1.999 → 0.2.0, +0.255.999 → 1.0.0. После 255.255.999 сборка останавливается. Post-build номер +не увеличивает. Clean не сбрасывает конфигурацию. + +Храните этот JSON в Git. Каталог `build/` добавьте в `.gitignore` проекта; +файлы результатов САПР также должны игнорироваться, иначе они пометят сборку +как dirty. Одна конфигурация JSON обслуживает только одну сборку одновременно. +Для независимых ревизий платы используйте отдельные JSON и выходные каталоги. + +## 2. Подключение паспорта к логике + +Генератор создаёт `generated/firmware_identity.v` и +`generated/firmware_identity.vhd`. Tcl добавляет в проект только выбранный +язык. Паспорт — комбинационный ROM из 12 слов по 16 бит: + +| Относительный адрес слова | Значение | +|---|---| +| 0 | Версия контракта: 1 | +| 1, 2, 3 | Major, minor, patch | +| 4 | Год, 2000..2199 | +| 5 | Месяц в битах 15..8, день в 7..0 | +| 6 | Час UTC в битах 15..8, минута в 7..0 | +| 7 | Секунда UTC | +| 8..11 | Восемь ASCII-символов build ID, первый символ пары в старшем октете | +| 12..15 | Ноль | + +Адреса относительные, без привязки к CPU, AXI, Avalon, SPI, Modbus или SETCAN. +Базовый адрес в системе назначает проект. Это тот же порядок слов, что в +`firmware_info_to_registers()`. Порядок байтов на конкретной шине задаёт +адаптер проекта. + +В верхний модуль Verilog добавьте экземпляр: + +```verilog +wire [15:0] identity_data; // 16-битное слово паспорта для мультиплексора чтения шины проекта. +firmware_identity u_identity ( // Экземпляр генерируемого до синтеза паспорта текущей сборки. + .word_addr(identity_word_offset), // Четыре младших бита относительного адреса слова в выделенном окне регистров. + .word_data(identity_data) // Выход должен участвовать в ответе шины, чтобы синтезатор сохранил паспорт. +); // Выбор окна и тактовое согласование выполняет логика проекта. +``` + +Для VHDL объявите component в declarative part архитектуры и подключите его +в теле архитектуры; component не требует заранее анализировать generated entity: + +```vhdl +component firmware_identity is -- Объявление размещается до begin архитектуры верхнего уровня. + port ( -- Интерфейс совпадает с генерируемой entity firmware_identity. + word_addr : in std_logic_vector(3 downto 0); -- Относительный адрес одного из 12 слов паспорта. + word_data : out std_logic_vector(15 downto 0) -- Данные для ответа шины проекта. + ); -- Закрывает список портов компонента. +end component; -- Привязка к entity из work выполняется при elaboration. +signal identity_data : std_logic_vector(15 downto 0); -- Объявляется до begin и подключается к чтению регистров. +-- Следующий экземпляр размещается после begin архитектуры верхнего уровня. +u_identity : firmware_identity -- Создаёт экземпляр паспорта без примитивов производителя. + port map ( -- Согласует имена сигналов проекта с интерфейсом компонента. + word_addr => identity_word_offset, -- Четырёхбитный сигнал относительного адреса слова задаёт проект. + word_data => identity_data -- Выход включается в мультиплексор ответа хосту. + ); -- Завершает подключение экземпляра. +``` + +`identity_word_offset`, декодер окна и ответ шины реализуйте в проекте. +Паспорт не имеет clock/reset, чтение комбинационное; при необходимости +зарегистрируйте ответ в тактовом домене шины. Без подключения выхода синтезатор +удалит паспорт. Смена языка существующего проекта требует удалить прежний +generated-файл из списка исходников, чтобы не получить два одноимённых модуля. + +## 3. Полная сборка + +Команды запускаются **из корня проекта** в командной строке с настроенным +окружением соответствующей САПР. В примерах templates подключён как +`lib/templates`. Проект должен уже содержать свой top, part и UCF/XDC/QSF. +Сценарии регистрируют generated-исходник в существующем проекте и выполняют +полную цепочку до файла программирования. + +Аргументы общие: `project config output image verilog|vhdl [python]`. +Пути разрешаются относительно начального рабочего каталога. `image` — точный +путь результата вашей САПР, а `output` — отдельный каталог паспорта и архивов; +передача `output` не меняет настройки выходного каталога САПР. + +**ISE:** + +```bat +rem Собирает существующий ISE-проект, в котором BitGen создаёт top.bit рядом с .xise. +xtclsh "lib/templates/tools/firmware-build/fpga/ise.tcl" "board.xise" "firmware_identity.json" "build/ise" "top.bit" verilog +``` + +Используется `Generate Programming File -force rerun_all`: XST, реализация +и BitGen выполняются заново. Логический `false` от процесса считается ошибкой. + +**Vivado:** + +```bat +rem Замените board.runs/impl_1/top.bit реальным путём .bit выбранного implementation run. +vivado -mode batch -source "lib/templates/tools/firmware-build/fpga/vivado.tcl" -tclargs "board.xpr" "firmware_identity.json" "build/vivado" "board.runs/impl_1/top.bit" verilog +``` + +Берётся активный implementation run и его родительский synthesis run; +нестандартные имена runs поддерживаются. Оба запуска сбрасываются, затем +выполняются синтез и реализация до `write_bitstream`. После `wait_on_run` +отдельно проверяется `PROGRESS`, поэтому завершение с ошибкой не вызывает упаковку. + +**Altera / Intel Quartus:** + +```bat +rem Собирает текущую revision из .qpf; image должен совпадать с результатом Assembler. +quartus_sh -t "lib/templates/tools/firmware-build/fpga/quartus.tcl" "board.qpf" "firmware_identity.json" "build/quartus" "output_files/board.sof" vhdl +``` + +`execute_flow -compile` запускает полную компиляцию с Assembler. +Для другой revision передайте седьмой аргумент после пути к Python: + +```bat +rem Укажите реальный Python 3 и отдельный JSON счётчика ревизии B. +quartus_sh -t "lib/templates/tools/firmware-build/fpga/quartus.tcl" "board.qpf" "identity_B.json" "build/quartus_B" "output_files/board_B.sof" vhdl "C:/Python313/python.exe" "board_B" +``` + +Не добавляйте второе увеличение версии в существующие pre-flow/pre-synthesis hooks. +Эти Tcl-файлы предназначены для запуска полной сборки из командной строки; +их нельзя подключать как вложенные hooks этапов синтеза. Для выпуска через +данную схему запускайте указанный сценарий, в том числе после работы в GUI. + +## 4. Результаты и проверка + +```text +build/vivado/ + generated/ + firmware_identity.h + firmware_identity.v + firmware_identity.vhd + firmware_identity.json + firmware/ + HASH_YYYY-MM-DD_HH-MM-SS_UTC+3/ + top.bit + top.bit.manifest.json + top.bit.sha256 + firmware_identity.v + firmware_identity.json +``` + +Для VHDL архив содержит выбранный `.vhd`, для Quartus — `.sof` вместо `.bit`. +Manifest и `.sha256` также создаются рядом с исходным файлом программирования. +Хеш и дата архивной папки берутся из pre-build, дата имени переводится в UTC+3; +в самом паспорте остаётся UTC. Знак `+` у build ID означает изменённые исходники +или templates. `firmware_identity.h` — сопутствующий результат общего генератора; +FPGA-сценарии используют HDL. + +Упаковка отклоняет отсутствующий, пустой или не обновившийся после prepare +файл. Проверяется содержимое и время записи: повторная запись тех же байтов +допускается. Это защита от старого результата; она не проверяет наличие паспорта +внутри vendor bitstream. Правильность подключения подтверждается чтением всех +12 регистров с работающей ПЛИС и сравнением с `fpga.register_words` в manifest. +SHA-256 относится к файлу программатора целиком и хранится снаружи образа. + +Проверить сохранённый файл можно общим инструментом: + +```bat +rem Проверяет размер и SHA-256 указанного файла по созданному рядом manifest. +python "lib/templates/c/firmware-info/tools/identity.py" verify --image "board.runs/impl_1/top.bit" --manifest "board.runs/impl_1/top.bit.manifest.json" +``` + +Для локального экспорта папки выпуска повторите только finish с `--export`; +номер версии при этом не меняется: + +```bat +rem Копирует текущую архивную папку и проверяет SHA-256 экспортной копии. +python "lib/templates/tools/firmware-build/fpga/identity.py" finish --metadata "build/vivado/generated/firmware_identity.json" --output "build/vivado/firmware" --export "D:/Firmware/FPGA" +``` + +Шаблон выпускает `.bit` и `.sof`. Преобразование в `.mcs`, `.bin`, `.pof` или +`.rbf`, загрузка на плату и публикация в сетевую базу требуют отдельного +проектного этапа. Контрольная сумма нативного образа не переносится на +преобразованный файл: для него создайте отдельные seal/verify общим инструментом. + +## Проверки и состояние + +Хостовые тесты проверяют эталон всех 12 слов, совпадение Verilog/VHDL, +итератор, свежесть результата, повреждение SHA-256, архив и экспорт. Реальные +Tcl-сценарии исполняются в Tcl из Python tkinter с заменой API производителей; +Python-генератор и упаковка выполняются полностью. Нужен Python с tkinter/Tcl, +графическое окно не открывается. + +```bat +rem Запускает проверки адаптера и управляющих сценариев без лицензии САПР. +python -m unittest discover -s tools/firmware-build/fpga/tests -v +``` + +Шаблон пока не подключён к конкретной плате. Полный синтез и чтение паспорта +на железе требуют установленной САПР, проекта и платы; хостовые тесты эти +проверки не заменяют. + +Команды сверены с руководствами производителей: +[ISE Command Line Tools UG628](https://docs.amd.com/v/u/en-US/devref), +[Vivado: запуск синтеза и реализации UG939](https://docs.amd.com/r/2023.1-English/ug939-vivado-designing-with-ip-tutorial/Step-8-Launch-Synthesis-and-Implementation), +[Quartus: полный Tcl flow](https://www.intel.com/content/www/us/en/docs/programmable/683432/22-1/the-tcl-shell-in-interactive-mode-example.html). diff --git a/tools/firmware-build/fpga/common.tcl b/tools/firmware-build/fpga/common.tcl new file mode 100644 index 0000000..d72e948 --- /dev/null +++ b/tools/firmware-build/fpga/common.tcl @@ -0,0 +1,76 @@ +# Общие процедуры совместимы с Tcl 8.5 старых ISE; пути передаются списками без eval. +namespace eval fw { + # Определяет Python-инструмент относительно поставки, а не текущего каталога САПР. + variable helper [file normalize [file join [file dirname [info script]] identity.py]] +# Закрывает пространство имён, не меняя глобальные команды САПР. +} +# Проверяет общие аргументы до открытия проекта и резервирования версии. +proc fw::init {arguments toolchain} { + # Объявляет настройки сценария в общем пространстве имён. + variable settings + # Пять обязательных аргументов и необязательный путь к python.exe. + if {[llength $arguments] < 5 || [llength $arguments] > 6} { + # Неверная команда не должна расходовать номер версии. + error "Usage: project config output image verilog|vhdl ?python?" + # Завершает проверку количества аргументов. + } + # Имена полей определяют одинаковый порядок аргументов всех трёх сценариев. + foreach name {project config output image} value [lrange $arguments 0 3] { + # Нормализует пути до перехода в каталог проекта. + set settings($name) [file normalize $value] + # Заканчивает обработку четырёх путей. + } + # Сохраняет выбранный HDL, чтобы в проект не попали две реализации одного модуля. + set settings(language) [lindex $arguments 4] + # Принимает только языки, поддержанные генератором паспорта. + if {[lsearch -exact {verilog vhdl} $settings(language)] < 0} { + # Останавливает сборку при опечатке в языке. + error "Expected verilog or vhdl" + # Заканчивает проверку выбранного языка. + } + # По умолчанию использует Python 3 из PATH, без аргументов командной оболочки. + set settings(python) python + # Полный путь позволяет обойти Python старой версии, встроенный в САПР. + if {[llength $arguments] == 6} { + # Делает путь python.exe абсолютным без раскрытия Windows junction до последующего cd. + set settings(python) [file join [pwd] [lindex $arguments 5]] + # Завершает выбор интерпретатора. + } + # Передаёт генератору имя формата нативного результата. + set settings(toolchain) $toolchain + # Разделяет генерируемые исходники и архивы внутри каталога конфигурации. + set settings(generated) [file join $settings(output) generated] + # Выбирает расширение только одного подключаемого HDL-файла. + set extension [expr {$settings(language) eq "verilog" ? "v" : "vhd"}] + # Имя модуля совпадает в Verilog и VHDL и известно верхнему уровню проекта. + set settings(hdl) [file join $settings(generated) firmware_identity.$extension] + # Проверяет наличие проектного файла до запуска САПР. + if {![file isfile $settings(project)] || ![file isfile $settings(config)]} { + # JSON создаётся пользователем из примера, чтобы не выпустить изделие с чужим именем. + error "Project or firmware identity config does not exist" + # Завершает проверку обязательных файлов. + } + # Зависимые исходники и ограничения проекта разрешаются относительно его каталога. + cd [file dirname $settings(project)] +# Заканчивает инициализацию путей и языка. +} +# Резервирует версию один раз до любых шагов синтеза. +proc fw::prepare {} { + # Получает проверенные аргументы текущего запуска. + variable settings + # Получает абсолютный путь общего генератора. + variable helper + # exec передаёт каждый путь отдельно; stderr остаётся в журнале САПР, ненулевой код вызывает Tcl error. + puts [exec $settings(python) $helper prepare --repository [file dirname $settings(project)] --config $settings(config) --output $settings(generated) --image $settings(image) --toolchain $settings(toolchain) --language $settings(language) 2>@stderr] +# Завершает подготовку паспорта. +} +# Упаковывает результат только после успешного возврата полного flow. +proc fw::finish {} { + # Использует те же пути, что и pre-build данного запуска. + variable settings + # Выбирает тот же Python-инструмент, который создавал паспорт. + variable helper + # Генератор проверяет обновление файла, SHA-256 и архивную копию; ошибка блокирует успешное завершение Tcl. + puts [exec $settings(python) $helper finish --metadata [file join $settings(generated) firmware_identity.json] --output [file join $settings(output) firmware] 2>@stderr] +# Завершает контроль готового образа. +} diff --git a/tools/firmware-build/fpga/firmware_identity.example.json b/tools/firmware-build/fpga/firmware_identity.example.json new file mode 100644 index 0000000..d483e0e --- /dev/null +++ b/tools/firmware-build/fpga/firmware_identity.example.json @@ -0,0 +1,6 @@ +{ + "product": "MY_FPGA_BOARD", + "target": "REPLACE_WITH_EXACT_FPGA_PART", + "version": [0, 1, 0], + "increment_version": true +} diff --git a/tools/firmware-build/fpga/identity.py b/tools/firmware-build/fpga/identity.py new file mode 100644 index 0000000..d1f7e8b --- /dev/null +++ b/tools/firmware-build/fpga/identity.py @@ -0,0 +1,92 @@ +"""Паспорт ПЛИС и контроль готового образа через общий итератор firmware-info.""" # CLI используется из Tcl трёх систем сборки. +from __future__ import annotations # Откладывает вычисление аннотаций для Python 3.9+. +import argparse # Разбирает явные пути, чтобы рабочий каталог IDE не менял назначение файлов. +from datetime import datetime # Разбирает UTC-время, зафиксированное общим pre-build. +import hashlib # Сравнивает исходный и новый образы при защите от устаревшего результата. +import importlib.util # Подключает существующий генератор без второй реализации итератора. +import json # Читает и сохраняет переносимые метаданные сборки в UTF-8. +from pathlib import Path # Нормализует пути проектов, результатов и общей библиотеки. +TEMPLATES = Path(__file__).resolve().parents[3] # От fpga/ поднимается к корню templates. +SPEC = importlib.util.spec_from_file_location("firmware_identity_shared", TEMPLATES / "c/firmware-info/tools/identity.py") # Выбирает общий модуль из этой же поставки. +shared = importlib.util.module_from_spec(SPEC) # Создаёт пространство имён общего генератора без изменения sys.path. +SPEC.loader.exec_module(shared) # Загружает prepare, seal, verify и archive_build без запуска общего CLI. +FORMATS = {"ise": {".bit"}, "vivado": {".bit"}, "quartus": {".sof"}} # Полные сценарии выпускают нативный файл конфигурации своей САПР. + + +def registers(metadata): # Переносит публичный 12-словный контракт firmware-info v1 в константную HDL-память. + stamp = datetime.fromisoformat(metadata["built_at_utc"]) # Общий prepare всегда сохраняет дату с часовым поясом UTC. + words = [1, *metadata["version"], stamp.year, (stamp.month << 8) | stamp.day, (stamp.hour << 8) | stamp.minute, stamp.second] # Слова 0..7: контракт, SemVer и календарные поля; сдвиг на 8 размещает старший октет. + build_id = metadata["build_id"].encode("ascii") # Контракт использует ровно восемь печатных ASCII-символов. + words.extend((build_id[index] << 8) | build_id[index + 1] for index in range(0, 8, 2)) # Слова 8..11 содержат пары символов, первый в старших восьми битах. + return words # Порядок совпадает с firmware_info_to_registers(), включая дату в UTC. + + +def verilog(words): # Генерирует Verilog-2001 без примитивов конкретного производителя. + lines = ["module firmware_identity ( // Комбинационный паспорт сборки с адресацией 16-битными словами.", " input wire [3:0] word_addr, // Четыре бита адресуют слова 0..11; остальные адреса возвращают ноль.", " output reg [15:0] word_data // Данные выбранного слова; синхронизацию с шиной выполняет проект.", "); // Интерфейс не требует тактового сигнала и сброса.", "always @* begin // Полная комбинационная чувствительность исключает устаревшее значение при смене адреса.", " case (word_addr) // Каждому адресу соответствует константа, вычисленная до синтеза."] # Использует синтаксис, доступный старым XST и Quartus II. + lines.extend(f" 4'd{index}: word_data = 16'h{word:04X}; // Слово {index} общего контракта firmware-info v1, значение без знака." for index, word in enumerate(words)) # Явные размеры исключают расширение констант и зависимость от порядка байтов. + lines.extend([" default: word_data = 16'h0000; // Неиспользуемые адреса не создают защёлок и возвращают ноль.", " endcase // Все входные комбинации определяют выход.", "end // Завершает комбинационное чтение паспорта.", "endmodule // Проект должен подключить выход к доступной хосту карте регистров."]) # Выход обязательно используется верхним уровнем, иначе синтезатор удалит паспорт. + return "\n".join(lines) + "\n" # Сохраняет единый формат строк UTF-8 с завершающим переводом строки. + + +def vhdl(words): # Генерирует VHDL-93 с тем же интерфейсом для проектов без Verilog. + lines = ["library ieee; -- Подключает стандартную библиотеку логических типов.", "use ieee.std_logic_1164.all; -- Определяет std_logic_vector для адреса и 16-битного слова.", "entity firmware_identity is -- Объявляет паспорт, подключаемый к карте регистров проекта.", " port ( -- Описывает комбинационный интерфейс без тактового домена.", " word_addr : in std_logic_vector(3 downto 0); -- Адрес слова 0..11; четыре бита покрывают весь контракт.", " word_data : out std_logic_vector(15 downto 0) -- 16-битное значение общего регистра версии.", " ); -- Завершает описание портов.", "end firmware_identity; -- Закрывает внешний интерфейс паспорта.", "architecture rtl of firmware_identity is -- Реализует паспорт только константами без памяти производителя.", "begin -- Начинает комбинационную архитектуру.", " with word_addr select word_data <= -- При изменении адреса немедленно выбирает соответствующее слово."] # Выбор с others определяет выход для всех состояний входа. + lines.extend(f' x"{word:04X}" when "{index:04b}", -- Слово {index} контракта firmware-info v1, шестнадцатеричная константа 16 бит.' for index, word in enumerate(words)) # Двоичные селекторы не требуют numeric_std и преобразования адреса. + lines.extend([' x"0000" when others; -- Неиспользуемые и неопределённые адреса возвращают нулевое слово.', "end rtl; -- Завершает синтезируемый паспорт сборки."]) # Полный выбор исключает защёлки. + return "\n".join(lines) + "\n" # Возвращает текст для атомарной записи в UTF-8. + + +def fingerprint(image): # Фиксирует состояние результата перед сборкой для отказа при повторной упаковке старого файла. + if not image.exists(): # При первой сборке ожидаемого образа ещё нет. + return None # Любой непустой файл после успешной сборки будет новым результатом. + return {"mtime_ns": image.stat().st_mtime_ns, "sha256": hashlib.sha256(image.read_bytes()).hexdigest()} # Изменение времени допускает побайтно воспроизводимую пересборку. + + +def prepare(repository, config, output, image, toolchain, language): # Резервирует единственный номер до синтеза и создаёт оба варианта HDL. + repository, config, output, image = (path.resolve() for path in (repository, config, output, image)) # Все пути в метаданных независимы от последующих cd в САПР. + if image.suffix.lower() not in FORMATS[toolchain]: # Не выдаёт произвольный или служебный файл за нативный результат данного flow. + raise ValueError(f"{toolchain}: expected {sorted(FORMATS[toolchain])}") # Неверный путь останавливает подготовку до увеличения версии. + before = fingerprint(image) # Читает старый результат до резервирования номера, чтобы отказ чтения не занимал версию. + metadata = shared.prepare(repository, TEMPLATES, config, output) # Использует общий атомарный итератор, Git, SOURCE_DATE_EPOCH и UTC. + words = registers(metadata) # Упаковывает уже зарезервированную версию в общий контракт регистров. + metadata["fpga"] = {"toolchain": toolchain, "language": language, "image_path": str(image), "image_before": before, "register_words": words} # Связывает post-build с конкретным файлом и снимком pre-build. + shared.write_atomic(output / "firmware_identity.v", verilog(words)) # Даёт старым XST/Quartus синтезируемый Verilog-2001. + shared.write_atomic(output / "firmware_identity.vhd", vhdl(words)) # Даёт VHDL-проекту тот же паспорт без смешения языков. + shared.write_atomic(output / "firmware_identity.json", json.dumps(metadata, ensure_ascii=False, indent=2) + "\n") # Сохраняет FPGA-привязку в тех же метаданных, которые попадут в manifest. + return metadata # Вызывающий сценарий добавляет в проект только один выбранный язык. + + +def finish(metadata_path, output, export=None): # Вызывается только после успешной полной сборки нативного образа. + metadata_path = metadata_path.resolve() # Исключает зависимость пути от рабочего каталога post-build. + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) # Использует сохранённый pre-build, не запрашивая Git и время повторно. + image = Path(metadata["fpga"]["image_path"]) # Упаковывает исключительно тот файл, который был указан перед синтезом. + if not image.is_file() or not image.stat().st_size: # Отсутствующий или пустой результат не может стать выпуском. + raise ValueError(f"Missing or empty FPGA image: {image}") # Ошибка блокирует создание manifest и архива. + if fingerprint(image) == metadata["fpga"]["image_before"]: # Старый файл с прежним временем и содержимым означает отсутствие нового результата. + raise ValueError("FPGA image was not updated after prepare") # Не связывает новый номер версии с прежней прошивкой после неудачного запуска. + shared.seal(image, metadata_path) # Считает SHA-256 точных байтов .bit/.sof, передаваемых программатору. + shared.verify(image, image.with_suffix(image.suffix + ".manifest.json")) # Проверяет размер и контрольную сумму до архивирования. + extension = "v" if metadata["fpga"]["language"] == "verilog" else "vhd" # Выбирает точный HDL-паспорт, зарегистрированный в проекте перед синтезом. + hdl = metadata_path.parent / ("firmware_identity." + extension) # Хранит исходник констант рядом с pre-build JSON. + return shared.archive_build(image, metadata_path, output, extra_files=(hdl,), export=export) # Сохраняет HDL с образом; общий архиватор проверяет локальную и экспортную копии. + + +def main(): # Разделяет подготовку и упаковку, чтобы Tcl мог остановиться между ними при ошибке САПР. + parser = argparse.ArgumentParser(description=__doc__) # Печатает назначение общего FPGA-инструмента в --help. + commands = parser.add_subparsers(dest="command", required=True) # Требует явный этап сборки. + before = commands.add_parser("prepare") # Создаёт интерфейс pre-build без запуска САПР из Python. + for name in ("repository", "config", "output", "image"): # Все входные и выходные пути передаются отдельными аргументами. + before.add_argument("--" + name, type=Path, required=True) # Path сохраняет пробелы и кириллицу без shell-подстановки. + before.add_argument("--toolchain", choices=FORMATS, required=True) # Ограничивает форматы результата выбранной САПР. + before.add_argument("--language", choices=("verilog", "vhdl"), default="verilog") # Позволяет выбрать один HDL для регистрации в проекте. + after = commands.add_parser("finish") # Создаёт интерфейс post-build только для готового результата. + after.add_argument("--metadata", dest="metadata_path", type=Path, required=True) # Требует тот же паспорт, что был создан перед синтезом. + after.add_argument("--output", type=Path, required=True) # Размещает архивы отдельно от файлов синтеза. + after.add_argument("--export", type=Path) # По явному запросу копирует проверенную архивную папку в локальный каталог. + arguments = vars(parser.parse_args()) # Преобразует именованные аргументы в параметры выбранной функции. + command = arguments.pop("command") # Удаляет служебное имя команды из аргументов функции. + result = {"prepare": prepare, "finish": finish}[command](**arguments) # Ошибки прерывают процесс с ненулевым кодом для Tcl catch. + print(json.dumps(result, ensure_ascii=False)) # Даёт журналу САПР путь архива и использованные метаданные. + + +if __name__ == "__main__": # Импорт в тестах не должен изменять проектную версию. + main() # Запускает CLI только при прямом вызове Python. diff --git a/tools/firmware-build/fpga/ise-build.bat b/tools/firmware-build/fpga/ise-build.bat new file mode 100644 index 0000000..3c15bc3 --- /dev/null +++ b/tools/firmware-build/fpga/ise-build.bat @@ -0,0 +1,19 @@ +@chcp 65001 >nul +@rem Переключение на UTF-8 сохраняет русские пути и комментарии локальной команды сборки ISE. +@echo off +rem Не допускает изменения окружения родительской консоли и раскрытия символа ! в путях. +setlocal EnableExtensions DisableDelayedExpansion +rem Находит штатный launcher Python 3 без привязки к каталогу его установки. +where py >nul 2>nul +rem При отсутствии launcher переключается на Python из PATH. +if errorlevel 1 goto use_python +rem Запускает локальную обвязку независимо от текущего рабочего каталога. +py -3 "%~dp0firmware_build.py" %* +rem Возвращает точный код сборки или проверки параметров. +exit /b %errorlevel% +rem Обслуживает компьютеры, где установлен только python.exe. +:use_python +rem Передаёт необязательный --check для проверки настройки без увеличения версии. +python "%~dp0firmware_build.py" %* +rem Не скрывает ошибку отсутствующего Python, окружения ISE или самой сборки. +exit /b %errorlevel% diff --git a/tools/firmware-build/fpga/ise-env.bat b/tools/firmware-build/fpga/ise-env.bat new file mode 100644 index 0000000..b1fa478 --- /dev/null +++ b/tools/firmware-build/fpga/ise-env.bat @@ -0,0 +1,8 @@ +@rem Получает окружение ISE в дочернем CMD; вывод SET захватывается Python и не попадает в журнал. +@echo off +rem Активирует штатную установку, путь передан через окружение без вставки в shell-код. +call "%FW_ISE_SETTINGS%" >nul +rem Не возвращает частично настроенное окружение после отказа settings.bat. +if errorlevel 1 exit /b %errorlevel% +rem CMD /u выдаёт переменные в UTF-16LE независимо от текущей кодовой страницы. +set diff --git a/tools/firmware-build/fpga/ise-project.md b/tools/firmware-build/fpga/ise-project.md new file mode 100644 index 0000000..a07c3fa --- /dev/null +++ b/tools/firmware-build/fpga/ise-project.md @@ -0,0 +1,27 @@ +# Сборка прошивки ISE + +Для сборки запускайте `build_firmware.bat`. Версия увеличивается перед +синтезом; результат и архив сохраняются в `build/ise/firmware/`. +`build_firmware.bat --check` проверяет настройки и наличие ISE без сборки +и увеличения версии. + +Один раз подключите экземпляр `firmware_identity` к верхнему модулю: +адрес слова — `word_addr[3:0]`, выход — `word_data[15:0]`. +Заготовка подключения находится в `firmware_identity_instance.txt`. +Выход включите в чтение вашей шины, иначе синтезатор удалит паспорт. +Сценарий сам добавит generated HDL к проекту при сборке. + +В `firmware_identity.json` меняются изделие и номер версии. +В `firmware_build.json` находятся пути проекта, `.bit`, templates, +каталог результатов, язык и необязательный путь `ise_settings` к +`settings64.bat` или `settings32.bat` вашей установки ISE. +Если окружение ISE не найдено автоматически, задайте этот путь один раз. + +Общие инструменты используются из каталога templates, указанного в настройках. +Храните локальные BAT, Python, JSON и заготовку подключения в Git. +Каталог сборки добавлен установщиком в `.gitignore`; остальные результаты +ISE также добавьте в игнорируемые файлы проекта. + +После загрузки `.bit` сравните 12 слов паспорта с `fpga.register_words` +в manifest из архива. Повторная установка сохраняет существующие JSON +и занятый номер версии. Одновременные сборки с одним JSON не поддерживаются. diff --git a/tools/firmware-build/fpga/ise.tcl b/tools/firmware-build/fpga/ise.tcl new file mode 100644 index 0000000..2336371 --- /dev/null +++ b/tools/firmware-build/fpga/ise.tcl @@ -0,0 +1,32 @@ +# Подключает общие процедуры из того же каталога поставки templates. +source [file join [file dirname [info script]] common.tcl] +# Любая ошибка подготовки, XST, реализации или упаковки возвращает ненулевой код xtclsh. +if {[catch { + # Принимает project.xise, config.json, output, top.bit, язык и необязательный python.exe. + fw::init $argv ise + # Открывает существующий проект с выбранной платой, top и ограничениями UCF. + project open $fw::settings(project) + # Создаёт HDL-паспорт до синтеза XST и увеличивает только общий счётчик. + fw::prepare + # Добавляет выбранный HDL-паспорт; повторное добавление существующего источника поддерживает ISE. + xfile add $fw::settings(hdl) + # Принудительно повторяет всю цепочку до BitGen, чтобы паспорт вошёл в новый bitstream. + set result [process run "Generate Programming File" -force rerun_all] + # ISE может вернуть false без Tcl-исключения при отказе дочернего процесса. + if {![string is true -strict $result]} { + # Не позволяет упаковать оставшийся от предыдущей сборки файл .bit. + error "ISE Generate Programming File failed: $result" + # Заканчивает проверку результата процесса. + } + # Записывает manifest, SHA-256 и архив только после успешного BitGen. + fw::finish + # Сохраняет регистрацию HDL-паспорта и закрывает проект. + project close +# catch сохраняет текст ошибки, не продолжая цепочку сборки. +} message]} { + # Выводит причину отказа в поток ошибок командной строки. + puts stderr $message + # Ненулевой код позволяет внешнему BAT или CI остановить выпуск. + exit 1 +# Закрывает обработчик ошибок. +} diff --git a/tools/firmware-build/fpga/ise_bootstrap.py b/tools/firmware-build/fpga/ise_bootstrap.py new file mode 100644 index 0000000..148cea7 --- /dev/null +++ b/tools/firmware-build/fpga/ise_bootstrap.py @@ -0,0 +1,23 @@ +"""Локальная обвязка ISE: настройки проекта и запуск общего инструмента templates.""" # Установщик копирует файл как firmware_build.py. +import json # Читает только настройки путей, оставляя номер версии отдельному JSON. +from pathlib import Path # Разрешает пути относительно установленной обвязки. +import subprocess # Передаёт аргументы отдельным списком без сборки shell-команды. +import sys # Сохраняет выбранный Python 3 и код возврата общей сборки. + + +def main(): # Одна локальная команда обращается к алгоритмам общей библиотеки. + config = Path(__file__).resolve().with_name("firmware_build.json") # Не зависит от того, из какой папки запущен BAT. + settings = json.loads(config.read_text(encoding="utf-8-sig")) # Принимает UTF-8, включая JSON, сохранённый редактором с BOM. + templates = (config.parent / settings["templates"]).resolve() # Поддерживает относительное подключение или абсолютный путь библиотеки. + helper = templates / "tools/firmware-build/fpga/ise_project.py" # Хранит реализацию сборки в одном месте для всех проектов. + if not helper.is_file(): # Перемещение библиотеки должно дать понятную причину отказа. + raise ValueError(f"templates not found; edit templates in {config}") # Не запускает сборку с неподходящим инструментом из PATH. + return subprocess.call([sys.executable, str(helper), "build", "--settings", str(config), *sys.argv[1:]]) # Сохраняет пробелы и кириллицу в каждом аргументе. + + +if __name__ == "__main__": # Импорт для проверки не должен запускать сборку проекта. + try: # Превращает ошибку настройки в короткое сообщение для консоли. + sys.exit(main()) # Сохраняет точный код завершения общего Tcl-flow. + except (OSError, ValueError, KeyError) as error: # Обрабатывает отсутствующие файлы и неверный JSON до запуска САПР. + print(f"[firmware-build] {error}", file=sys.stderr) # Не скрывает путь настройки, которую требуется исправить. + sys.exit(2) # Отличает ошибку настройки от успешной сборки. diff --git a/tools/firmware-build/fpga/ise_project.py b/tools/firmware-build/fpga/ise_project.py new file mode 100644 index 0000000..bf250e6 --- /dev/null +++ b/tools/firmware-build/fpga/ise_project.py @@ -0,0 +1,185 @@ +"""Установка локальных BAT и автоматический запуск контроля прошивки проекта ISE.""" # Общие алгоритмы версии остаются в identity.py. +import argparse # Поддерживает интерактивную установку и явные параметры для автоматизации. +import importlib.util # Подключает существующий FPGA-генератор без повторения его алгоритмов. +import json # Сохраняет отдельно параметры сборки и сохраняемый номер версии. +import os # Разрешает относительные пути библиотеки и окружение Windows. +from pathlib import Path # Работает с путями проекта независимо от текущего каталога. +import shutil # Находит xtclsh в PATH настроенного окружения ISE. +import subprocess # Запускает окружение ISE и готовый Tcl-flow с контролем кодов возврата. +import sys # Передаёт Tcl тот же Python 3, которым запущена обвязка. +import tempfile # Подготавливает предварительный HDL без увеличения рабочего счётчика. +import xml.etree.ElementTree as ET # Читает стандартный XML .xise, не меняя исходный проект. +HERE = Path(__file__).resolve().parent # Находит шаблоны рядом с установщиком. +TEMPLATES = HERE.parents[2] # Поднимается от tools/firmware-build/fpga к корню библиотеки. + + +def relative(path, root): # Сохраняет переносимые пути при расположении проекта и templates на одном диске. + try: # Windows не позволяет вычислить относительный путь между разными дисками. + return Path(os.path.relpath(path, root)).as_posix() # JSON использует прямые слеши без двойного экранирования. + except ValueError: # Разные диски требуют абсолютной привязки. + return path.as_posix() # Абсолютный путь можно заменить в firmware_build.json после переноса. + + +def project_info(project, language=None, image=None): # Извлекает параметры из существующего .xise без пользовательского копирования команд. + project = project.resolve() # Нормализует путь до чтения XML и выбора корня проекта. + if project.is_dir(): # Разрешает перетащить папку проекта вместо отдельного .xise. + choices = list(project.glob("*.xise")) # Не выбирает вложенные архивы и другие платы рекурсивным поиском. + if len(choices) != 1: # Несколько проектов требуют явного выбора .xise пользователем. + raise ValueError("Select one .xise file; the directory must contain exactly one project") # Не угадывает плату по времени изменения. + project = choices[0] # Единственный проект определён однозначно. + if project.suffix.lower() != ".xise" or not project.is_file(): # Не принимает произвольный каталог как проект ISE. + raise ValueError(f"ISE project not found: {project}") # Останавливает установку до записи локальных файлов. + tree = ET.parse(project) # Использует XML-парсер stdlib с поддержкой пространства имён Xilinx. + properties = {} # Собирает только свойства проекта, необходимые для запуска сборки. + for element in tree.iter(): # Поддерживает как namespaced .xise, так и минимальные тестовые проекты. + if element.tag.rsplit("}", 1)[-1] == "property": # Игнорирует список исходников и настройки других XML-элементов. + attributes = {key.rsplit("}", 1)[-1]: value for key, value in element.attrib.items()} # Снимает namespace с атрибутов name и value. + properties[attributes.get("name", "")] = attributes.get("value", "") # Читает сохранённое значение свойства без изменения .xise. + device = properties.get("Device", "") # Точное устройство берётся из настроек исходного проекта. + if not device or "cpld" in properties.get("Device Family", "").lower() or device.lower().startswith(("xc95", "xc2c")): # Данный flow выпускает FPGA .bit, а CPLD требует отдельной цепочки .jed. + raise ValueError("A configured FPGA project producing .bit is required") # Не устанавливает неподходящий шаблон на CPLD. + top = properties.get("Implementation Top", "").split("|") # ISE хранит тип и имя top через вертикальный разделитель. + name = properties.get("Output File Name") or (top[1] if len(top) > 1 else "") # Явное имя BitGen имеет приоритет над именем верхнего модуля. + if not name and image is None: # Отсутствующие настройки не должны приводить к выбору случайного .bit. + raise ValueError("Set Implementation Top in ISE or pass --image path/to/top.bit") # Позволяет явно указать нестандартный результат BitGen. + working = project.parent / properties.get("Working Directory", ".") # Учитывает отдельный рабочий каталог реализации ISE. + image = (project.parent / image if image is not None else working / (name if name.lower().endswith(".bit") else name + ".bit")).resolve() # Путь CLI разрешается от проекта, штатный результат — от Working Directory. + if image.suffix.lower() != ".bit": # Только .bit поддерживается исходным ise.tcl. + raise ValueError("ISE image must have .bit extension") # Ошибка пути обнаруживается до создания настройки. + top_file = properties.get("Implementation Top File", "").lower() # Расширение реального верхнего исходника точнее общего предпочтения языка. + detected = "vhdl" if top_file.endswith((".vhd", ".vhdl")) else "verilog" if top_file.endswith((".v", ".sv")) else properties.get("Preferred Language", "Verilog").lower() # Поддерживает оба языка генерируемого паспорта. + target = device + properties.get("Speed Grade", "") + properties.get("Package", "") # Сохраняет устройство, скоростную группу и корпус из .xise в информационном поле target. + return project, image, language or detected, target # Возвращает только параметры, требуемые общему Tcl-flow. + + +def find_settings(): # Ищет стандартную установку ISE без рекурсивного сканирования дисков. + candidates = [] # Порядок поиска предпочитает уже активное окружение. + xilinx = os.environ.get("XILINX") # Штатная переменная ISE обычно указывает на ISE_DS/ISE. + if xilinx: # Активное окружение даёт наиболее точную подсказку расположения. + candidates.extend(Path(xilinx).parent / name for name in ("settings64.bat", "settings32.bat")) # Проверяет оба варианта разрядности установки. + for drive in "CDEFGHIJKLMNOPQRSTUVWXYZ": # Проверяет стандартный корень Xilinx только на обычных дисках Windows. + base = Path(drive + ":/Xilinx") # Не просматривает содержимое пользовательских проектов. + if base.is_dir(): # Отсутствующий диск или каталог пропускается без ошибки установки. + candidates.extend(sorted(base.glob("*/ISE_DS/settings64.bat"), reverse=True)) # Сначала предлагает 64-битную установку из стандартной структуры. + candidates.extend(sorted(base.glob("*/ISE_DS/settings32.bat"), reverse=True)) # Поддерживает старые 32-битные установки при отсутствии 64-битной. + return next((str(path.resolve()) for path in candidates if path.is_file()), "") # Пустое значение разрешает настроить путь позже. + + +def install(project, language=None, image=None, ise_settings=None): # Создаёт локальные команды и настройки без увеличения версии. + project, image, language, target = project_info(project, language, image) # Проверяет все входные параметры до записи файлов. + if language not in ("verilog", "vhdl"): # Не переносит неизвестные предпочтения языка в общий flow. + raise ValueError("Expected verilog or vhdl") # Ошибка языка требует явного --language при установке. + root = project.parent # Все локальные файлы лежат рядом с .xise для простого запуска. + build_path = root / "firmware_build.json" # Настройки путей отделены от сохраняемого итератора версии. + identity_path = root / "firmware_identity.json" # Существующая рабочая версия никогда не заменяется примером. + settings = {"schema": 1, "project": project.name, "templates": relative(TEMPLATES, root), "image": relative(image, root), "language": language, "output": "build/ise", "ise_settings": str(ise_settings.resolve()) if ise_settings else find_settings()} # По умолчанию пользователь не редактирует пути вручную. + if build_path.exists(): # Повторная установка должна сохранить индивидуальные настройки сборки. + settings = json.loads(build_path.read_text(encoding="utf-8-sig")) # Использует прежний output и язык при создании предварительного HDL. + if (root / settings["project"]).resolve() != project: # Один набор локальных BAT не должен незаметно переключиться на другую плату. + raise ValueError("firmware_build.json belongs to another .xise project") # Требует отдельную папку настройки для другого проекта. + identity = json.loads(identity_path.read_text(encoding="utf-8-sig")) if identity_path.exists() else {"product": project.stem, "target": target, "version": [0, 1, 0], "increment_version": True} # Сохраняет прежний счётчик побайтно, а новый заполняет из .xise. + if type(identity.get("increment_version", False)) is not bool: # Предварительная генерация не должна скрыть неверный тип рабочего флага итератора. + raise ValueError("increment_version must be true or false") # Останавливает установку до создания предварительного HDL. + files = {root / "build_firmware.bat": (HERE / "ise-build.bat").read_bytes(), root / "firmware_build.py": (HERE / "ise_bootstrap.py").read_bytes(), root / "FIRMWARE_SETUP.md": (HERE / "ise-project.md").read_bytes()} # Копирует только локальные обвязки; алгоритмы остаются в указанной библиотеке templates. + for path, data in files.items(): # Проверяет коллизии всех управляемых файлов до первой записи. + if path.exists() and path.read_bytes() != data: # Не перезаписывает чужой BAT или правки пользователя при повторном запуске. + raise ValueError(f"Existing file differs; preserve or rename it before installation: {path}") # Существующие пользовательские файлы требуют явного разрешения конфликта. + spec = importlib.util.spec_from_file_location("ise_install_identity", HERE / "identity.py") # Использует тот же генератор, что последующая сборка. + fpga = importlib.util.module_from_spec(spec) # Создаёт изолированный модуль подготовки предварительного HDL. + spec.loader.exec_module(fpga) # Загружает общий итератор и FPGA-адаптер без запуска CLI. + output = (root / settings["output"]).resolve() # Учитывает выходной каталог, сохранённый предыдущей установкой. + output.mkdir(parents=True, exist_ok=True) # Создаёт место предварительного паспорта и временной конфигурации. + with tempfile.TemporaryDirectory(prefix=".ise-setup-", dir=output) as temporary: # Не редактирует рабочую версию ради предварительной генерации исходников. + preview = Path(temporary) / "identity.json" # Временный JSON существует только во время установки. + preview.write_text(json.dumps({**identity, "increment_version": False}, ensure_ascii=False), encoding="utf-8") # Явно отключает итератор только в копии для предварительного HDL. + fpga.prepare(root, preview, output / "generated", (root / settings["image"]).resolve(), "ise", settings["language"]) # Создаёт HDL до первого запуска ISE, не занимая номер. + for path, data in files.items(): # Записывает заранее проверенные локальные обвязки. + if not path.exists(): # Повторная установка не меняет время и содержимое прежних файлов. + path.write_bytes(data) # Сохраняет исходные UTF-8 файлы из templates без изменения комментариев. + for path, data in ((build_path, settings), (identity_path, identity)): # Устанавливает две настройки с разными назначениями. + if not path.exists(): # Существующие настройки и рабочая версия остаются побайтно прежними. + path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") # Новые JSON доступны для обычного ручного редактирования. + snippet = root / "firmware_identity_instance.txt" # Отдельная заготовка показывает единственную проектную часть подключения. + if not snippet.exists(): # Не меняет уже адаптированный пользователем экземпляр паспорта. + snippet.write_text(instance(settings["language"]), encoding="utf-8") # Генерирует пример именно для языка проекта. + ignore = root / ".gitignore" # Исключение результатов предотвращает ложный dirty от generated-файлов. + existing = ignore.read_text(encoding="utf-8-sig") if ignore.exists() else "" # Сохраняет все существующие правила игнорирования проекта. + ignored = relative(output, root) + "/" # Привязывает исключение к установленному выходному каталогу. + if output.is_relative_to(root) and ignored not in existing.splitlines(): # Внешние каталоги не добавляет в .gitignore данного репозитория. + with ignore.open("a", encoding="utf-8", newline="\n") as stream: # Дописывает одно правило, не заменяя пользовательский файл. + stream.write(("\n" if existing and not existing.endswith("\n") else "") + "# Результаты контроля прошивки ISE.\n" + ignored + "\n") # Отделяет новое правило и объясняет его назначение. + print(f"[firmware-build] Installed: {root / 'build_firmware.bat'}") # Показывает готовую команду для обычной сборки. + print(f"[firmware-build] Connect identity ports using: {snippet}") # Оставляет пользователю только подключение к шине конкретного проекта. + return settings # Позволяет тестам проверить автоматически извлечённые параметры. + + +def instance(language): # Возвращает короткую заготовку подключения без изменения верхней схемы пользователя. + if language == "verilog": # Verilog допускает прямое создание экземпляра с именованными портами. + return "wire [15:0] identity_data; // Слово паспорта подключается к мультиплексору чтения вашей шины.\nfirmware_identity u_identity ( // Генерируемый модуль добавляется сценарием сборки.\n .word_addr(identity_word_offset), // Четырёхбитный относительный адрес слова задаёт декодер проекта.\n .word_data(identity_data) // Выход должен использоваться схемой, иначе синтезатор удалит паспорт.\n); // Тактовое согласование чтения выполняет проект.\n" # Каждый элемент интерфейса снабжён пояснением для адаптации. + return "component firmware_identity is -- Объявляется до begin архитектуры верхнего уровня.\n port ( -- Описывает интерфейс генерируемого паспорта.\n word_addr : in std_logic_vector(3 downto 0); -- Относительный адрес слова 0..11 задаёт декодер проекта.\n word_data : out std_logic_vector(15 downto 0) -- 16-битное слово для ответа шины.\n ); -- Закрывает список портов.\nend component; -- Завершает декларацию компонента.\nsignal identity_data : std_logic_vector(15 downto 0); -- Объявляется до begin и используется при чтении шины.\n-- Следующий экземпляр размещается после begin архитектуры.\nu_identity : firmware_identity -- Создаёт паспорт текущей сборки.\n port map ( -- Сопоставляет интерфейс паспорта с сигналами проекта.\n word_addr => identity_word_offset, -- Четырёхбитный относительный адрес слова формирует проект.\n word_data => identity_data -- Выход включается в ответ шины, чтобы сохранить паспорт при синтезе.\n ); -- Завершает подключение компонента.\n" # Декларация component позволяет создать entity только перед фактической сборкой. + + +def ise_environment(settings): # Подготавливает окружение автоматически, если xtclsh ещё отсутствует в PATH. + environment = {name.upper(): value for name, value in os.environ.items()} # В Windows имена переменных нечувствительны к регистру; единый регистр исключает два разных PATH. + if shutil.which("xtclsh", path=environment.get("PATH")): # Запуск из уже настроенной консоли не требует повторной настройки. + return environment # Сохраняет окружение выбранной пользователем установки. + startup = settings.get("ise_settings") or find_settings() # Использует явный путь либо стандартную найденную установку. + if not startup or not Path(startup).is_file(): # Не запускает pre-build, пока САПР не найдена. + raise ValueError("ISE not found; set ise_settings in firmware_build.json to settings64.bat") # Даёт одно место для исправления нестандартной установки. + if any(character in startup for character in '%!"\r\n'): # CALL повторно раскрывает переменные, поэтому специальные подстановки в пути запрещены. + raise ValueError("ISE settings path contains unsupported command expansion characters") # Не допускает интерпретацию пути как дополнительной команды. + environment["FW_ISE_SETTINGS"] = startup # Путь передаётся через окружение, без вставки в shell-код. + capture = HERE / "ise-env.bat" # Статический BAT исключает вложенное экранирование команды CALL с пробелами. + result = subprocess.run([environment.get("COMSPEC", "cmd.exe"), "/d", "/u", "/v:off", "/c", "call", str(capture)], env=environment, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) # Ключ /u выдаёт SET в UTF-16LE независимо от кодовой страницы; окружение не выводится в журнал. + for line in result.stdout.decode("utf-16-le").splitlines(): # Декодирование Unicode сохраняет русские пути после запуска UTF-8 BAT. + name, separator, value = line.partition("=") # Значения переменных могут сами содержать знак равенства. + if separator and name: # Служебные записи текущих дисков вида =C: не передаются в subprocess. + environment[name.upper()] = value # Получает PATH, XILINX и библиотечные пути без дублирования переменных Windows. + return environment # Настройка применяется к последующему запуску Tcl. + + +def build(settings_path, check=False): # Читает локальную настройку и запускает существующий полный Tcl-flow. + settings_path = settings_path.resolve() # Разрешает пути относительно каталога локальной обвязки. + root = settings_path.parent # Рабочий каталог .xise задаётся настройкой данного проекта. + settings = json.loads(settings_path.read_text(encoding="utf-8-sig")) # Не читает номер версии: он изменяется только внутри общего pre-build. + paths = {name: (root / settings[name]).resolve() for name in ("project", "templates", "image", "output")} # Преобразует все сохранённые пути до запуска САПР. + script = paths["templates"] / "tools/firmware-build/fpga/ise.tcl" # Общий Tcl выполняет генерацию паспорта, синтез и контроль результата. + if not script.is_file() or not paths["project"].is_file() or not (root / "firmware_identity.json").is_file(): # Проверяет обязательные файлы до запуска итератора. + raise ValueError("Project, templates or firmware_identity.json not found; check firmware_build.json") # Не создаёт пустую конфигурацию вместо потерянной пользовательской. + environment = ise_environment(settings) # Находит и активирует штатную установку ISE перед pre-build. + executable = shutil.which("xtclsh", path=environment.get("PATH", environment.get("Path"))) # Использует исполняемый файл именно подготовленного окружения. + if not executable: # Неподходящий settings.bat не должен запускать случайную программу. + raise ValueError("xtclsh was not found after loading ISE settings") # Останавливает сборку до увеличения версии. + print(f"[firmware-build] Project: {paths['project']}", flush=True) # Показывает выбранный проект при запуске BAT двойным щелчком. + if check: # Проверка установки не должна создавать новый паспорт или занимать номер. + print(f"[firmware-build] Ready: {executable}") # Подтверждает наличие зависимостей для обычного запуска. + return 0 # Завершает проверку без Tcl-flow. + return subprocess.call([executable, str(script), str(paths["project"]), str(root / "firmware_identity.json"), str(paths["output"]), str(paths["image"]), settings["language"], sys.executable], cwd=root, env=environment) # Передаёт параметры отдельными аргументами и сохраняет исходный код ошибки САПР. + + +def main(): # Разделяет установку проекта и последующие сборки одной локальной командой. + parser = argparse.ArgumentParser(description=__doc__) # Предоставляет справку для нестандартных путей проекта. + commands = parser.add_subparsers(dest="command", required=True) # Явно выбирает действие без скрытого запуска сборки. + setup = commands.add_parser("install") # Установка создаёт обвязки и предварительный паспорт. + setup.add_argument("project", type=Path, nargs="?") # Двойной щелчок без аргументов включает запрос пути. + setup.add_argument("--language", choices=("verilog", "vhdl")) # Позволяет переопределить язык для смешанного проекта. + setup.add_argument("--image", type=Path) # Поддерживает нестандартное имя результата BitGen. + setup.add_argument("--ise-settings", type=Path) # Указывает settings.bat при нестандартной установке ISE. + run = commands.add_parser("build") # Этот режим вызывает установленная локальная Python-обвязка. + run.add_argument("--settings", dest="settings_path", type=Path, required=True) # Требует явный путь параметров проекта. + run.add_argument("--check", action="store_true") # Проверяет настройку без увеличения версии. + arguments = vars(parser.parse_args()) # Передаёт выбранной функции только её именованные параметры. + command = arguments.pop("command") # Удаляет служебное имя режима из параметров функции. + if command == "install": # Интерактивный запрос нужен только при запуске установщика без пути. + arguments["project"] = arguments["project"] or Path(input("ISE project (.xise or directory): ").strip().strip('"')) # Принимает вставленный путь из Проводника с внешними кавычками. + install(**arguments) # Заполняет локальную настройку и показывает готовую команду сборки. + return 0 # Успешная установка не запускает синтез автоматически. + return build(**arguments) # Обычная команда возвращает результат полного Tcl-flow. + + +if __name__ == "__main__": # Импорт для тестов не должен устанавливать файлы в рабочий проект. + try: # Сохраняет понятную причину отказа без длинной трассировки в BAT-окне. + sys.exit(main()) # Передаёт точный код сборки внешнему BAT или CI. + except (OSError, ValueError, KeyError, ET.ParseError, subprocess.CalledProcessError) as error: # Обрабатывает неверную настройку и отказ штатного settings.bat. + print(f"[firmware-build] {error}", file=sys.stderr) # Печатает исправимую причину остановки. + sys.exit(2) # Возвращает ошибку конфигурации, не скрывая её успешным завершением. diff --git a/tools/firmware-build/fpga/quartus.tcl b/tools/firmware-build/fpga/quartus.tcl new file mode 100644 index 0000000..daadf87 --- /dev/null +++ b/tools/firmware-build/fpga/quartus.tcl @@ -0,0 +1,60 @@ +# Подключает общие пути и Python-этапы, совместимые с Quartus II и Prime. +source [file join [file dirname [info script]] common.tcl] +# Ошибка execute_flow или упаковки не должна выглядеть успешным выпуском. +if {[catch { + # Берёт аргументы quartus_sh из штатного массива Quartus, включая необязательную revision. + set arguments $quartus(args) + # По умолчанию project_open выбирает текущую revision из .qpf. + set revision "" + # Седьмой аргумент разрешён только после явного пути python.exe. + if {[llength $arguments] == 7} { + # Позволяет явно собрать ревизию платы вместо текущей из .qpf. + set revision [lindex $arguments 6] + # Передаёт общему разбору только его шесть аргументов. + set arguments [lrange $arguments 0 5] + # Завершает извлечение необязательной revision. + } + # Принимает project.qpf, config.json, output, revision.sof, язык и необязательный python.exe. + fw::init $arguments quartus + # Подключает штатные команды открытия проекта. + package require ::quartus::project + # Загружает полную цепочку Analysis/Synthesis, Fitter, Assembler и Timing Analyzer. + load_package flow + # Quartus принимает имя проекта без расширения .qpf. + set project_name [file rootname $fw::settings(project)] + # Явная revision нужна для нескольких аппаратных конфигураций одного проекта. + if {$revision eq ""} { + # Использует текущую ревизию проекта, сохранённую в .qpf. + project_open $project_name + # При наличии имени выбирает заданную ревизию. + } else { + # Раздельный аргумент сохраняет пробелы в имени ревизии. + project_open -revision $revision $project_name + # Завершает открытие проекта. + } + # Генерирует новый паспорт перед Analysis/Synthesis, используя общий итератор. + fw::prepare + # По умолчанию регистрирует Verilog-2001 для старых Quartus II. + set assignment VERILOG_FILE + # VHDL-проект получает только альтернативный .vhd файл. + if {$fw::settings(language) eq "vhdl"} { + # Штатное назначение Quartus определяет язык нового исходника. + set assignment VHDL_FILE + # Завершает выбор языка. + } + # Добавляет паспорт к исходникам выбранной ревизии проекта. + set_global_assignment -name $assignment $fw::settings(hdl) + # Полная компиляция экспортирует назначения перед запуском модулей и создаёт новый .sof. + execute_flow -compile + # Контролирует свежесть .sof, формирует SHA-256, manifest и архив. + fw::finish + # Закрывает проект после успешной упаковки результата. + project_close +# Перехватывает отказ САПР и сохраняет причину для внешней сборки. +} message]} { + # Пишет сообщение в стандартный поток ошибок. + puts stderr $message + # Не допускает успешный exit при ошибке компиляции или проверки образа. + exit 1 +# Завершает общий обработчик ошибок. +} diff --git a/tools/firmware-build/fpga/tests/test_flows.py b/tools/firmware-build/fpga/tests/test_flows.py new file mode 100644 index 0000000..58367e1 --- /dev/null +++ b/tools/firmware-build/fpga/tests/test_flows.py @@ -0,0 +1,96 @@ +"""Исполняет реальные Tcl-сценарии с заменой только команд производителей САПР.""" # Проверяет порядок, пути с пробелами и блокирование post-build. +import json # Проверяет, сколько номеров занял каждый запуск сценария. +import os # Временно задаёт путь минимальной инициализации Tcl без GUI. +from pathlib import Path # Находит сценарии относительно тестов. +import sys # Передаёт Tcl путь к тому же Python 3, которым выполняется тест. +import tempfile # Изолирует проекты и готовые образы каждого запуска. +import unittest # Организует успешные и ошибочные сценарии трёх САПР. +import tkinter # Предоставляет настоящий Tcl-интерпретатор без открытия окна Tk. +SCRIPTS = Path(__file__).resolve().parents[1] # Находит поставку FPGA-сценариев. + + +class FlowTests(unittest.TestCase): # Проверяет Tcl-управление без подмены генерации паспорта и архивирования. + def run_flow(self, toolchain, language="verilog", failure="", revision=""): # Исполняет один полный flow во временном проекте. + scratch = SCRIPTS.parents[2] / ".codex-build" # Каталог внутри репозитория исключает внешние junction AppData при проверке Tcl на Windows. + scratch.mkdir(exist_ok=True) # Хранит временные проекты в уже игнорируемой области сборки. + with tempfile.TemporaryDirectory(prefix="FPGA flow пробел ", dir=scratch) as temporary: # Кириллица и пробелы должны пережить Tcl exec и Python CLI. + root = Path(temporary) # Не использует настоящие проекты или настройки САПР. + project = root / {"ise": "board.xise", "vivado": "board.xpr", "quartus": "board.qpf"}[toolchain] # Создаёт только входной маркер нужного типа проекта. + project.touch() # Открытие моделируется командами производителя, наличие проверяет реальный common.tcl. + config = root / "identity.json" # Хранит реальный сохраняемый счётчик. + config.write_text(json.dumps({"product": "Плата", "target": "test", "version": [0, 1, 0], "increment_version": "true" if failure == "prepare" else True}), encoding="utf-8") # Ошибка типа флага должна остановить prepare до резервирования; штатный flow занимает 0.1.1. + image = root / ("board.sof" if toolchain == "quartus" else "board.bit") # Совпадает с нативным форматом сценария. + image.write_bytes(b"old image") # Позволяет проверить отказ упаковки старого файла после сбоя сборки. + previous = Path.cwd() # Настоящий Tcl cd меняет текущий каталог процесса Python. + script = os.path.relpath(SCRIPTS / (toolchain + ".tcl"), previous) # Относительный путь проверяет нахождение Python-helper после перехода в другой каталог проекта. + interpreter = tkinter.Tcl() # Выполняет установленный Tcl без окна и без запуска xtclsh/Vivado/Quartus. + calls = [] # Собирает последовательность только вендорных команд. + def vendor(name, *arguments): # Моделирует интерфейс САПР, сохраняя настоящий exec Python-инструмента. + calls.append((name, *arguments)) # Позволяет проверить порядок подготовки и запуска дочерних шагов. + if name == "current_run": # В Vivado выбирается активная реализация с нестандартным именем. + return "impl_board" # Проверяет отсутствие жёсткой привязки к impl_1. + if name == "get_runs": # Возвращает имя объекта run без лишних преобразований. + return arguments[-1] # Достаточно для чтения PARENT, SRCSET и PROGRESS. + if name == "get_property": # Моделирует свойства выбранного синтеза и реализации. + field, target = arguments # Сохраняет различие родительского синтеза и активного в GUI. + if field == "PARENT": # Реализация может зависеть от другого синтеза. + return "synth_board" # Сценарий должен выбрать этого родителя. + if field == "SRCSET": # Новый модуль должен попасть именно в исходники родителя реализации. + return "sources_board" # Проверяет выбор набора вместо предположения sources_1. + return "0%" if failure == target else "100%" # Ожидание завершения при ошибке не считается успехом. + finished = name == "execute_flow" or name == "process" or (name == "wait_on_run" and arguments[0] == "impl_board") # Определяет место записи результата компиляции. + if finished and not failure: # Только успешный flow создаёт новый файл. + image.write_bytes(b"new image from " + toolchain.encode("ascii")) # Имитирует точные байты нового .bit/.sof. + if name == "process": # ISE сообщает отказ логическим результатом без Tcl error. + return "false" if failure else "true" # Проверяет особую обработку boolean в ise.tcl. + if name == "execute_flow" and failure: # Quartus сообщает ошибку компиляции Tcl-исключением. + raise RuntimeError("test compiler failure") # Вызывает catch в настоящем quartus.tcl. + return "" # Остальные команды производителя успешно завершаются без значения. + for name in ("project", "xfile", "process", "open_project", "current_run", "get_property", "get_runs", "add_files", "update_compile_order", "reset_run", "launch_runs", "wait_on_run", "close_project", "load_package", "project_open", "set_global_assignment", "execute_flow", "project_close"): # Подменяет только API производителя, не команды ядра Tcl. + interpreter.createcommand(name, lambda *arguments, name=name: vendor(name, *arguments)) # Привязывает имя при создании каждой callback-функции. + interpreter.eval('proc exit {code} {error "flow-exit:$code"}') # Не позволяет Tcl exit завершить процесс тестов. + interpreter.call("package", "provide", "::quartus::project", "1.0") # Удовлетворяет require пакета, чьи команды заменены заглушками. + arguments = [str(project), str(config), str(root / "build"), str(image), language, sys.executable] # Передаёт пути списком, без shell-экранирования. + if revision: # Quartus допускает дополнительное имя аппаратной ревизии. + arguments.append(revision) # Проверяет передачу значения с пробелами одним аргументом. + interpreter.call("set", "quartus(args)" if toolchain == "quartus" else "argv", tuple(arguments)) # Создаёт штатный источник аргументов соответствующей САПР. + try: # Гарантирует восстановление cwd даже при ожидаемом exit 1. + if failure: # Неудачная компиляция должна прервать сценарий до упаковки. + with self.assertRaisesRegex(tkinter.TclError, "flow-exit:1"): # Проверяет внешнее состояние отказа. + interpreter.call("source", script) # Исполняет настоящий сценарий со всеми проверками. + else: # Успешный сценарий обязан дойти до архива. + interpreter.call("source", script) # Реальный exec создаёт HDL, manifest и архив. + self.assertEqual(json.loads(config.read_text(encoding="utf-8"))["version"], [0, 1, 0] if failure == "prepare" else [0, 1, 1]) # Ошибка конфигурации не занимает номер; после начала сборки номер резервируется ровно один раз. + manifests = list((root / "build/firmware").glob("*/*.manifest.json")) # Ищет только выпущенные архивы текущего запуска. + self.assertEqual(len(manifests), 0 if failure else 1) # Ошибка синтеза/реализации не должна выпустить старый образ. + if failure == "prepare": # Ошибка общего Python pre-build должна блокировать запуск САПР. + self.assertFalse(any(call[0] in ("process", "launch_runs", "execute_flow") for call in calls)) # Не допускает синтез с прежним паспортом после ошибки настроек. + if not failure: # Дополнительно проверяет выбранный HDL после реального prepare. + suffix = "v" if language == "verilog" else "vhd" # В проект регистрируется только выбранный вариант. + self.assertTrue(any(str(root / "build/generated" / ("firmware_identity." + suffix)).replace("\\", "/") in str(call).replace("\\\\", "/") for call in calls)) # Учитывает Windows-разделители в строковом представлении кортежей. + finally: # Tcl cd не должен влиять на следующий тест или временную очистку. + os.chdir(previous) # Возвращает исходный каталог процесса до удаления проекта. + return calls # Отдаёт последовательность команд для специфических проверок САПР. + + def test_all_toolchains_and_languages(self): # Каждый сценарий должен выпустить файл для обеих HDL-реализаций. + for toolchain in ("ise", "vivado", "quartus"): # Покрывает три независимых набора Tcl-команд. + for language in ("verilog", "vhdl"): # Проверяет правильное расширение и тип назначения исходника. + with self.subTest(toolchain=toolchain, language=language): # Показывает точную конфигурацию возможного отказа. + self.run_flow(toolchain, language) # Создаёт реальный паспорт и архив без лицензии производителя. + + def test_failures_do_not_archive_old_images(self): # Проверяет false ISE, ошибку Quartus и неуспешные runs Vivado. + for toolchain, failure in (("ise", "compile"), ("quartus", "compile"), ("vivado", "synth_board"), ("vivado", "impl_board"), ("ise", "prepare"), ("vivado", "prepare"), ("quartus", "prepare")): # Отдельно покрывает обе стадии Vivado и отказ Python pre-build во всех САПР. + with self.subTest(toolchain=toolchain, failure=failure): # Сохраняет контекст ожидаемой ошибки. + self.run_flow(toolchain, failure=failure) # Старый файл существует, но архив не должен появиться. + + def test_revision_and_parent_synthesis(self): # Проверяет нестандартные имена конфигураций проектов. + quartus = self.run_flow("quartus", revision="board revision B") # Явная ревизия содержит пробелы и передаётся единым аргументом. + self.assertTrue(any(call[:3] == ("project_open", "-revision", "board revision B") for call in quartus)) # Не допускает случайной сборки default revision. + vivado = self.run_flow("vivado") # Активная реализация зависит от синтеза с нестандартным именем. + self.assertIn(("launch_runs", "synth_board"), vivado) # Собирается именно родитель выбранной реализации. + self.assertIn(("update_compile_order", "-fileset", "sources_board"), vivado) # Паспорт относится к набору исходников этого синтеза. + self.assertLess(vivado.index(("wait_on_run", "synth_board")), vivado.index(("launch_runs", "impl_board", "-to_step", "write_bitstream"))) # Реализация начинается только после ожидания синтеза. + + +if __name__ == "__main__": # Поддерживает прямой запуск файла проверки. + unittest.main() # Возвращает ненулевой код при нарушении порядка сборки. diff --git a/tools/firmware-build/fpga/tests/test_identity.py b/tools/firmware-build/fpga/tests/test_identity.py new file mode 100644 index 0000000..9faaad7 --- /dev/null +++ b/tools/firmware-build/fpga/tests/test_identity.py @@ -0,0 +1,94 @@ +"""Проверяет FPGA-паспорт, общий итератор и точные байты архивов без лицензии САПР.""" # Запуск через unittest discover из корня репозитория. +import importlib.util # Импортирует реальный FPGA-адаптер без установки пакета. +import json # Сравнивает конфигурацию, метаданные и manifest готового образа. +import os # Управляет меткой времени файла в тесте воспроизводимой пересборки. +from pathlib import Path # Ограничивает изменения собственными временными каталогами. +import re # Извлекает значения из HDL для проверки общего регистрационного контракта. +import tempfile # Изолирует результаты от рабочих проектов пользователя. +import unittest # Проверяет успешные сборки и отказы при устаревших файлах. +from unittest.mock import patch # Делает Git и время независимыми от машины запуска. +TOOL = Path(__file__).resolve().parents[1] / "identity.py" # Находит инструмент рядом с тестами. +SPEC = importlib.util.spec_from_file_location("fpga_identity", TOOL) # Создаёт отдельное имя для адаптера. +fpga = importlib.util.module_from_spec(SPEC) # Подготавливает импорт без вызова CLI. +SPEC.loader.exec_module(fpga) # Загружает код, используемый всеми тремя сценариями САПР. + + +class IdentityTests(unittest.TestCase): # Проверяет границу между общим генератором и HDL. + def setUp(self): # Создаёт проект с кириллицей и пробелами в пути. + temporary = tempfile.TemporaryDirectory(prefix="ПЛИС project ") # Проверяет UTF-8 и корректную передачу путей. + self.addCleanup(temporary.cleanup) # Очищает только файлы данного теста. + self.root = Path(temporary.name) # Хранит все входы и выходы внутри временного проекта. + self.config = self.root / "firmware_identity.json" # Служит единственным источником ручного номера версии. + self.generated = self.root / "build/generated" # Имитирует каталог текущей конфигурации сборки. + self.image = self.root / "board.bit" # Нативный результат ISE или Vivado. + self.config.write_text(json.dumps({"product": "Плата", "target": "test-part", "version": [1, 2, 3], "increment_version": True}), encoding="utf-8") # Не изменяет пример или настройки реальных проектов. + self.git = patch.object(fpga.shared, "git_state", return_value={"commit": None, "dirty": None}) # Без Git должен получаться явный NOGIT000. + self.git.start() # Фиксирует отсутствие Git для всех вызовов данного теста. + self.addCleanup(self.git.stop) # Восстанавливает функцию после проверки. + self.epoch = patch.dict(os.environ, {"SOURCE_DATE_EPOCH": "1792413296"}) # UTC 2026-10-19 12:34:56 позволяет проверить точные календарные слова. + self.epoch.start() # Делает дату HDL детерминированной. + self.addCleanup(self.epoch.stop) # Не меняет окружение остальных тестов. + + def prepare(self, toolchain="vivado", language="verilog"): # Вызывает публичную функцию адаптера теми же аргументами, что Tcl. + return fpga.prepare(self.root, self.config, self.generated, self.image, toolchain, language) # Делегирует резервирование версии общему prepare. + + def test_hdl_matches_contract_and_iterator(self): # Одним вектором сравнивает обе HDL-реализации с контрактом C. + metadata = self.prepare() # Первый запуск должен увеличить patch ровно один раз. + expected = [1, 1, 2, 4, 2026, 0x0A13, 0x0C22, 56, 0x4E4F, 0x4749, 0x5430, 0x3030] # 12 слов: контракт, 1.2.4, UTC и ASCII NOGIT000. + self.assertEqual(metadata["fpga"]["register_words"], expected) # Фиксирует независимый эталон адресов и старшинства октетов. + verilog = (self.generated / "firmware_identity.v").read_text(encoding="utf-8") # Читает фактически записанный файл для синтеза XST. + vhdl = (self.generated / "firmware_identity.vhd").read_text(encoding="utf-8") # Читает альтернативный VHDL-93 паспорт. + self.assertEqual([int(value, 16) for value in re.findall(r"word_data = 16'h([0-9A-F]{4})", verilog)][:12], expected) # Проверяет константы без зависимости от пробелов и комментариев. + self.assertEqual([int(value, 16) for value in re.findall(r'x"([0-9A-F]{4})" when "[01]{4}"', vhdl)], expected) # Проверяет то же расположение слов в VHDL. + self.assertEqual(json.loads(self.config.read_text(encoding="utf-8"))["version"], [1, 2, 4]) # HDL и сохраняемый счётчик обязаны совпадать. + self.assertEqual(self.prepare()["version"], [1, 2, 5]) # Следующая попытка использует следующий номер общего итератора. + + def test_seal_archive_export_and_tampering(self): # Проверяет реальную упаковку точных байтов и отказ при повреждении. + self.prepare() # Фиксирует паспорт до появления готового .bit. + data = b"test-only FPGA bitstream\x00\xff" # Произвольные байты моделируют файл программатора без разбора вендорного контейнера. + self.image.write_bytes(data) # Имитирует успешный выход полной сборки САПР. + result = fpga.finish(self.generated / "firmware_identity.json", self.root / "firmware", self.root / "export") # Запускает общий seal, verify, archive и проверку экспорта. + for folder in (Path(result["directory"]), Path(result["exported_directory"])): # Проверяет обе сохраняемые копии текущего выпуска. + self.assertEqual((folder / self.image.name).read_bytes(), data) # Архивы должны содержать точные исходные байты. + self.assertEqual((folder / "firmware_identity.v").read_bytes(), (self.generated / "firmware_identity.v").read_bytes()) # Вместе с образом сохраняется точный исходник паспорта, переданный синтезатору. + manifest = fpga.shared.verify(folder / self.image.name, folder / (self.image.name + ".manifest.json")) # Повторно считает размер и SHA-256 независимо от результата finish. + self.assertEqual(manifest["version"], [1, 2, 4]) # Post-build не увеличивает версию второй раз. + self.image.write_bytes(b"damaged") # Имитирует изменение файла после выпуска manifest. + with self.assertRaises(ValueError): # Проверка обязана обнаружить повреждение до программирования. + fpga.shared.verify(self.image, self.image.with_suffix(".bit.manifest.json")) # Использует публичную общую проверку образа. + + def test_stale_missing_and_empty_images_are_rejected(self): # Отказы сборки не должны связывать новый паспорт со старым результатом. + self.image.write_bytes(b"old image") # Оставляет нативный файл от предыдущего выпуска. + self.prepare() # Запоминает его содержимое и время до синтеза. + with self.assertRaisesRegex(ValueError, "not updated"): # Неизменившийся старый файл не считается новым выпуском. + fpga.finish(self.generated / "firmware_identity.json", self.root / "firmware") # Отказывается до создания manifest. + self.image.unlink() # Моделирует отсутствие результата после сбоя САПР. + with self.assertRaisesRegex(ValueError, "Missing or empty"): # Нет образа — нет архива. + fpga.finish(self.generated / "firmware_identity.json", self.root / "firmware") # Проверяет отсутствие файла. + self.image.write_bytes(b"") # Моделирует оборванную запись выходного файла. + with self.assertRaisesRegex(ValueError, "Missing or empty"): # Пустой файл также блокирует выпуск. + fpga.finish(self.generated / "firmware_identity.json", self.root / "firmware") # Не допускает SHA-256 пустого файла как прошивки. + self.assertFalse((self.root / "firmware").exists()) # Ни один из отказов не должен создать архив. + + def test_reproducible_image_with_new_mtime_is_accepted(self): # Ручная версия и воспроизводимый BitGen могут дать прежние байты. + self.image.write_bytes(b"same image") # Готовит существующий результат предыдущей сборки. + metadata = self.prepare() # Запоминает прежнее время записи. + stamp = metadata["fpga"]["image_before"]["mtime_ns"] + 2_000_000_000 # Две секунды покрывают даже грубую точность времени старых файловых систем. + os.utime(self.image, ns=(stamp, stamp)) # Имитирует реальную повторную запись тех же байтов. + fpga.finish(self.generated / "firmware_identity.json", self.root / "firmware") # Обновлённый файл допускается без требования отличающегося хеша. + + def test_quartus_and_wrong_extension(self): # Проверяет нативный .sof и отказы до резервирования номера. + original = self.config.read_bytes() # Неверная команда не должна увеличивать счётчик. + with self.assertRaises(ValueError): # .bit не является нативным результатом Quartus. + self.prepare(toolchain="quartus") # Проверяет соответствие расширения выбранной САПР. + self.assertEqual(self.config.read_bytes(), original) # Конфигурация остаётся прежней после отказа. + self.image = self.root / "revision.sof" # Выбирает штатный выход Assembler. + metadata = self.prepare(toolchain="quartus", language="vhdl") # Использует тот же итератор для Altera и VHDL. + self.image.write_bytes(b"test SOF") # Моделирует успешную компиляцию Quartus. + result = fpga.finish(self.generated / "firmware_identity.json", self.root / "firmware") # Архивирует .sof через общий механизм. + self.assertEqual(result["file"], "revision.sof") # Сохраняет имя ревизии программатора. + self.assertEqual(metadata["fpga"]["language"], "vhdl") # Фиксирует язык для последующего аудита. + + +if __name__ == "__main__": # Позволяет запускать проверку отдельным файлом. + unittest.main() # Возвращает ненулевой код при нарушении контракта. diff --git a/tools/firmware-build/fpga/tests/test_ise_project.py b/tools/firmware-build/fpga/tests/test_ise_project.py new file mode 100644 index 0000000..ebd7f75 --- /dev/null +++ b/tools/firmware-build/fpga/tests/test_ise_project.py @@ -0,0 +1,138 @@ +"""Проверяет автоматическую установку ISE, сохранение версии и локальные BAT.""" # Тесты не изменяют реальные .xise пользователя. +import importlib.util # Загружает установщик без вызова его интерактивного CLI. +import json # Проверяет автоматически извлечённые и сохраняемые настройки проекта. +import os # Подготавливает окружение Windows для реального запуска BAT. +from pathlib import Path # Изолирует проекты в игнорируемом каталоге репозитория. +import subprocess # Проверяет установочный BAT и установленную команду --check. +import tempfile # Создаёт независимый проект для каждой проверки. +import unittest # Проверяет успешную установку, повторные вызовы и отказы до сборки. +from unittest.mock import patch # Заменяет только внешние команды ISE в тестах запуска. +HERE = Path(__file__).resolve().parents[1] # Находит общий установщик рядом с остальными FPGA-инструментами. +SPEC = importlib.util.spec_from_file_location("ise_project_setup", HERE / "ise_project.py") # Выделяет установщику отдельное пространство имён. +ise = importlib.util.module_from_spec(SPEC) # Создаёт модуль для проверки публичных функций. +SPEC.loader.exec_module(ise) # Загружает код без запросов к пользователю и без установки в чужие каталоги. +PROJECT = ''' + + + + + + + + + + + + + +''' # Фикстура воспроизводит namespace и свойства настоящего ISE 14.7 без исходников платы. + + +class SetupTests(unittest.TestCase): # Проверяет автоматическое подключение одного проекта без ручного копирования. + def setUp(self): # Каждый сценарий получает свой рабочий каталог с пробелами и кириллицей. + scratch = HERE.parents[2] / ".codex-build" # Держит тестовые проекты внутри разрешённой области и вне индекса Git. + scratch.mkdir(exist_ok=True) # Поддерживает запуск тестов из свежей копии библиотеки. + temporary = tempfile.TemporaryDirectory(prefix="ISE setup плата & ", dir=scratch) # Амперсанд проверяет передачу путей без вставки shell-команд. + self.addCleanup(temporary.cleanup) # Удаляет только собственный тестовый проект. + self.root = Path(temporary.name) # Хранит весь созданный комплект рядом с тестовым .xise. + self.project = self.root / "board.xise" # Отличается от имени top и результата BitGen для проверки автоподбора. + self.project.write_text(PROJECT, encoding="utf-8") # Не изменяет существующие проекты на диске. + + def install(self, **arguments): # Вызывает установщик без поиска реальной САПР на каждом тесте. + with patch.object(ise, "find_settings", return_value=""): # Автоматическая установка допускается и до установки ISE. + return ise.install(self.project, **arguments) # Проверяет настоящую запись обвязок и предварительного HDL. + + def test_install_detects_project_and_does_not_consume_version(self): # Установка должна завершиться готовым локальным BAT и исходниками паспорта. + before = self.project.read_bytes() # Автоматическое подключение не должно менять XML проекта без ISE. + settings = self.install() # Извлекает параметры и создаёт локальные файлы. + self.assertEqual(settings["image"], "impl/programmer_top.bit") # Output File Name и Working Directory важнее имени .xise. + self.assertEqual(settings["language"], "verilog") # Определяет язык по реальному верхнему исходнику. + identity = json.loads((self.root / "firmware_identity.json").read_text(encoding="utf-8")) # Читает рабочий, а не временный счётчик. + self.assertEqual(identity["target"], "xc6slx16-3ftg256") # Паспорт получает реальный кристалл с корпусом и скоростной группой. + self.assertEqual(identity["version"], [0, 1, 0]) # Установка не является попыткой синтеза и не расходует номер. + self.assertEqual(self.project.read_bytes(), before) # Исходный .xise сохранён побайтно. + for name in ("build_firmware.bat", "firmware_build.py", "firmware_build.json", "FIRMWARE_SETUP.md", "firmware_identity_instance.txt", "build/ise/generated/firmware_identity.v"): # Все ранее ручные файлы создаются автоматически. + self.assertTrue((self.root / name).is_file(), name) # Проверяет полноту установленного комплекта. + self.assertIn("build/ise/", (self.root / ".gitignore").read_text(encoding="utf-8")) # Сгенерированные исходники исключаются из статуса Git проекта. + + def test_reinstall_preserves_configs_version_and_ignore(self): # Повторное подключение не должно сбросить уже выпущенный номер. + self.install() # Сначала создаёт штатный набор локальных файлов. + identity = self.root / "firmware_identity.json" # Рабочий счётчик изменяется только как часть тестового сценария. + identity.write_text('{"product":"Моя плата","target":"xc6slx16","version":[3,4,5],"increment_version":true}', encoding="utf-8") # Имитирует существующую версию и пользовательское форматирование. + config = self.root / "firmware_build.json" # Параметры установки также сохраняются при повторном запуске. + settings = json.loads(config.read_text(encoding="utf-8")) # Сохраняет автоматически определённые значения остальных полей. + settings["output"] = "build/custom" # Проект может поменять каталог архивов после установки. + config.write_text(json.dumps(settings), encoding="utf-8") # Создаёт пользовательский формат настройки. + originals = {path: path.read_bytes() for path in (identity, config)} # Проверяет побайтную сохранность обоих JSON. + self.install() # Повторный вызов должен использовать выбранный пользователем output. + for path, data in originals.items(): # Ни версия, ни пути не должны замениться значениями из .xise. + self.assertEqual(path.read_bytes(), data) # Не допускает переформатирования сохранённых настроек. + ignore = (self.root / ".gitignore").read_bytes() # Запоминает результат после добавления custom output. + self.install() # Третий вызов проверяет отсутствие повторяющихся правил. + self.assertEqual((self.root / ".gitignore").read_bytes(), ignore) # .gitignore не растёт при каждом запуске. + self.assertTrue((self.root / "build/custom/generated/firmware_identity.v").is_file()) # Предварительный HDL соответствует сохранённой настройке. + + def test_existing_user_bat_is_preserved_without_partial_installation(self): # Установщик не должен заменять существующие проектные команды. + custom = self.root / "build_firmware.bat" # Создаёт пользовательскую команду с занятым именем. + custom.write_bytes(b"@echo custom build\n") # Содержимое отличается от управляемого шаблона. + with self.assertRaisesRegex(ValueError, "Existing file differs"): # Коллизия должна быть обнаружена до записи JSON и generated. + self.install() # Выполняет настоящую предварительную проверку устанавливаемых файлов. + self.assertEqual(custom.read_bytes(), b"@echo custom build\n") # Пользовательский файл остаётся прежним. + self.assertFalse((self.root / "firmware_identity.json").exists()) # Частичная установка не создаёт рабочий счётчик. + self.assertFalse((self.root / "build").exists()) # Отказ коллизии не оставляет предварительный паспорт. + + def test_vhdl_override_and_ambiguous_or_cpld_project(self): # Поддерживает VHDL и останавливает неподходящие проекты до записи файлов. + self.project.write_text(PROJECT.replace("logic_top.v", "logic_top.vhd"), encoding="utf-8") # Расширение top имеет приоритет над Preferred Language. + project, image, language, target = ise.project_info(self.root) # Единственный .xise выбирается из переданной папки. + self.assertEqual(language, "vhdl") # Выбирает VHDL автоматически без ручного параметра. + settings = self.install(image=Path("custom/result.bit")) # Разрешает явно переопределить нестандартный путь BitGen. + self.assertEqual(settings["image"], "custom/result.bit") # Не теряет ручное уточнение при автоматическом заполнении. + self.assertIn("component firmware_identity", (self.root / "firmware_identity_instance.txt").read_text(encoding="utf-8")) # Выдаёт VHDL-заготовку для подключения до первого синтеза. + (self.root / "other.xise").write_text(PROJECT, encoding="utf-8") # Моделирует две платы в одной папке. + with self.assertRaisesRegex(ValueError, "exactly one"): # Установщик не должен угадывать нужную плату. + ise.project_info(self.root) # Требует явный файл .xise. + self.project.write_text(PROJECT.replace("xc6slx16", "xc9572xl"), encoding="utf-8") # CPLD требует .jed вместо .bit. + with self.assertRaisesRegex(ValueError, "FPGA project"): # Неподходящий FPGA-шаблон не устанавливается на CPLD. + ise.project_info(self.project) # Проверяет устройство до любых изменений проекта. + + def test_build_preserves_arguments_exit_code_and_check_version(self): # Локальная настройка должна доходить до общего Tcl отдельными аргументами. + self.install() # Готовит настоящий набор настроек и исходную версию. + config = self.root / "firmware_build.json" # Выбирает установленную локальную настройку. + version = (self.root / "firmware_identity.json").read_bytes() # --check не имеет права занимать следующий номер. + with patch.object(ise, "ise_environment", return_value={"PATH": "test"}), patch.object(ise.shutil, "which", return_value="C:/ISE path/xtclsh.exe"), patch.object(ise.subprocess, "call", return_value=7) as invoke: # Не запускает лицензируемую САПР в тесте аргументов. + self.assertEqual(ise.build(config, check=True), 0) # Проверка должна завершиться до запуска Tcl-flow. + invoke.assert_not_called() # Подтверждает отсутствие скрытой сборки в --check. + self.assertEqual(ise.build(config), 7) # Код ошибки САПР передаётся пользователю без подмены. + arguments = invoke.call_args.args[0] # Каждый элемент списка соответствует одному аргументу процесса. + self.assertEqual(arguments[2], str(self.project)) # Пробелы, кириллица и амперсанд сохраняются в пути .xise. + self.assertEqual(arguments[-2], "verilog") # Общий Tcl получает выбранный при установке язык. + self.assertEqual(invoke.call_args.kwargs["cwd"], self.root) # Относительные исходники .xise разрешаются из папки проекта. + self.assertEqual((self.root / "firmware_identity.json").read_bytes(), version) # До настоящего pre-build номер не изменяется. + + @unittest.skipUnless(os.name == "nt", "Windows BAT execution") # Реальные BAT проверяются только на Windows, остальные тесты переносимы. + def test_native_settings_environment_preserves_unicode(self): # Автоматическое окружение должно сохранить пути при любой кодовой странице консоли. + settings = self.root / "settings64.bat" # Путь содержит пробелы, кириллицу и амперсанд. + settings.write_bytes('@echo off\r\n@rem Передаёт переменную с русским путём из исходного окружения.\r\n@set "ISE_TEST_RESULT=%FW_TEST_UNICODE%"\r\n'.encode("utf-8")) # Моделирует штатный settings.bat без настоящей установки ISE. + expected = str(self.root / "русская папка") # Результат не должен зависеть от OEM-кодировки Windows. + with patch.dict(os.environ, {"FW_TEST_UNICODE": expected}), patch.object(ise.shutil, "which", return_value=None): # Принуждает настройку окружения через settings.bat. + environment = ise.ise_environment({"ise_settings": str(settings)}) # Исполняет настоящий CMD /u с захватом SET. + self.assertEqual(environment["ISE_TEST_RESULT"], expected) # Декодирование UTF-16LE должно сохранить каждый символ пути. + + @unittest.skipUnless(os.name == "nt", "Windows BAT execution") # Реальный запуск обвязок требует командного процессора Windows. + def test_real_installer_and_local_bat_check(self): # Проверяет запуск пользователем без ручного копирования файлов или длинной Tcl-команды. + environment = os.environ.copy() # Наследует Python launcher и стандартные системные программы. + environment["FW_NO_PAUSE"] = "1" # Автотест не должен ожидать клавиши установщика. + setup = subprocess.run([environment.get("COMSPEC", "cmd.exe"), "/d", "/c", "call", str(HERE / "INSTALL_ISE.bat"), str(self.project)], env=environment, stdout=subprocess.PIPE, stderr=subprocess.PIPE) # CALL сохраняет кавычки имени BAT; аргумент проекта содержит пробелы и амперсанд. + self.assertEqual(setup.returncode, 0, setup.stderr.decode(errors="replace")) # Файлы должен создать сам установщик. + stub = self.root / "stub bin" # Создаёт отдельный PATH для проверки наличия xtclsh. + stub.mkdir() # Не подменяет системную установку ISE. + (stub / "xtclsh.bat").write_text("@rem Только маркер наличия ISE для --check.\n@exit /b 99\n", encoding="utf-8") # Проверка не должна выполнять эту заглушку. + environment["PATH"] = str(stub) + os.pathsep + environment.get("PATH", "") # Добавляет тестовую САПР перед остальными программами. + before = (self.root / "firmware_identity.json").read_bytes() # Сохраняет номер перед запуском установленного BAT. + run = subprocess.run([environment.get("COMSPEC", "cmd.exe"), "/d", "/c", "call", str(self.root / "build_firmware.bat"), "--check"], cwd=HERE, env=environment, stdout=subprocess.PIPE, stderr=subprocess.PIPE) # Рабочий каталог специально отличается от каталога проекта; CALL сохраняет кавычки пути BAT. + self.assertEqual(run.returncode, 0, run.stderr.decode(errors="replace")) # Локальная Python-обвязка должна найти настройку и библиотеку самостоятельно. + self.assertEqual((self.root / "firmware_identity.json").read_bytes(), before) # Проверка установленного комплекта не увеличивает версию. + + +if __name__ == "__main__": # Поддерживает прямой запуск тестов установщика. + unittest.main() # Возвращает ненулевой код при ошибке установки или потере настроек. diff --git a/tools/firmware-build/fpga/vivado.tcl b/tools/firmware-build/fpga/vivado.tcl new file mode 100644 index 0000000..91d403f --- /dev/null +++ b/tools/firmware-build/fpga/vivado.tcl @@ -0,0 +1,56 @@ +# Находит общий код независимо от рабочего каталога Vivado. +source [file join [file dirname [info script]] common.tcl] +# Объединяет запуск и контроль результата в одну прерываемую при ошибке операцию. +if {[catch { + # Принимает project.xpr, config.json, output, top.bit, язык и необязательный python.exe. + fw::init $argv vivado + # Открывает проект с его part, top, XDC и настройками реализации. + open_project $fw::settings(project) + # Берёт активную реализацию, а не предполагает стандартное имя impl_1. + set implementation [current_run -implementation] + # Синтез выбирается по родителю реализации, даже если в GUI активен другой synth run. + set synthesis [get_property PARENT $implementation] + # Файловый набор принадлежит именно синтезу выбранной реализации. + set sources [get_property SRCSET [get_runs $synthesis]] + # Генерирует паспорт до запуска синтеза; повторное увеличение в hooks проекта не требуется. + fw::prepare + # Регистрирует только один вариант HDL в правильном наборе исходников. + add_files -fileset $sources -norecurse $fw::settings(hdl) + # Обновляет порядок компиляции для нового модуля или VHDL entity. + update_compile_order -fileset $sources + # Сбрасывает реализацию перед синтезом, чтобы не использовать прежнюю размещённую схему. + reset_run $implementation + # Полный синтез обязателен при новом паспорте даже в ручном режиме номера версии. + reset_run $synthesis + # Запускает синтез с настройками проекта. + launch_runs $synthesis + # Ожидает завершения дочернего процесса до проверки результата. + wait_on_run $synthesis + # Ожидание само по себе не означает успех; 100% проверяется отдельно. + if {[get_property PROGRESS [get_runs $synthesis]] ne "100%"} { + # Не запускает реализацию при отказе синтеза. + error "Vivado synthesis failed" + # Заканчивает проверку синтеза. + } + # Включает write_bitstream, который обычная реализация по умолчанию не выполняет. + launch_runs $implementation -to_step write_bitstream + # Ждёт записи .bit перед вызовом общего post-build. + wait_on_run $implementation + # Проверяет завершение полной цепочки генерации файла программирования. + if {[get_property PROGRESS $implementation] ne "100%"} { + # Отказ маршрутизации, DRC или BitGen блокирует архивирование. + error "Vivado implementation/bitstream failed" + # Завершает проверку реализации. + } + # Упаковывает именно ожидаемый .bit после успешной сборки. + fw::finish + # Завершает работу с сохранёнными настройками проекта. + close_project +# Ошибка любого шага передаётся внешнему вызывающему процессу. +} message]} { + # Сохраняет исходное сообщение САПР или общего генератора в журнале. + puts stderr $message + # Возвращает отказ внешней сборке или CI. + exit 1 +# Заканчивает обработку ошибок. +}