Подключи ISE, Vivado и Quartus к общему итератору версии и HDL-паспорту. Проверяй новый bit/sof до создания manifest, SHA-256 и архива выпуска. Добавь установку локальных команд ISE, инструкции переноса и регрессионные проверки успешных и прерванных сборок.
186 lines
30 KiB
Python
186 lines
30 KiB
Python
"""Установка локальных 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) # Возвращает ошибку конфигурации, не скрывая её успешным завершением.
|