From 87df13fb4657eb615525efb948594f4017b71079 Mon Sep 17 00:00:00 2001 From: maksyuki Date: Tue, 25 Aug 2026 20:02:58 +0800 Subject: [PATCH] feat: add ECC flow for padless ICS55 core hardening - Introduced a new ECC directory containing scripts and Makefile for the padless ICS55 core hardening flow. - Updated the main Makefile to include the new ECC Makefile and added relevant help commands. - Added dependencies for the ECC CLI in the dependencies.lock.json file. - Created README documentation for the ECC flow detailing usage and requirements. - Implemented various scripts for configuration generation, validation, output checking, and packaging of ECC results. - Added unit tests to validate the ECC configuration and flow behavior. --- Makefile | 5 + dependencies/dependencies.lock.json | 6 + physical/README.md | 2 + physical/ecc/Makefile | 119 +++++++++++++ physical/ecc/README.md | 31 ++++ physical/ecc/scripts/check_outputs.py | 153 ++++++++++++++++ physical/ecc/scripts/doctor.py | 225 ++++++++++++++++++++++++ physical/ecc/scripts/generate_config.py | 107 +++++++++++ physical/ecc/scripts/package.py | 77 ++++++++ physical/ecc/scripts/setup.py | 97 ++++++++++ tests/test_ecc_flow.py | 155 ++++++++++++++++ 11 files changed, 977 insertions(+) create mode 100644 physical/ecc/Makefile create mode 100644 physical/ecc/README.md create mode 100644 physical/ecc/scripts/check_outputs.py create mode 100644 physical/ecc/scripts/doctor.py create mode 100644 physical/ecc/scripts/generate_config.py create mode 100644 physical/ecc/scripts/package.py create mode 100644 physical/ecc/scripts/setup.py create mode 100644 tests/test_ecc_flow.py diff --git a/Makefile b/Makefile index db57dda2..e40889a7 100644 --- a/Makefile +++ b/Makefile @@ -277,6 +277,7 @@ ifeq ($(STA), OPENSTA) endif include physical/librelane/Makefile +include physical/ecc/Makefile .PHONY: help config doctor setup setup-regression setup-mpw setup-clusterip setup-ip setup-pdk setup-app \ clean-all purge-cache manifest check-warnings metrics check-metrics package commercial-package \ @@ -304,6 +305,10 @@ help: ' librelane-openroad open the current Chip run in OpenROAD' \ ' librelane-klayout open the current Chip run in KLayout' \ ' librelane-package package full-chip views and evidence' \ + ' ecc-setup install the pinned ECC CLI and ICS55 inputs' \ + ' ecc-doctor validate the padless ICS55 ECC hardening flow' \ + ' ecc-core run the padless ICS55 ECC hardening flow' \ + ' ecc-package package ECC core views and evidence' \ ' setup install pinned external dependencies' \ ' setup-regression install pinned dependencies for all PR PDK profiles' \ ' doctor check tools, paths, and selected configuration' \ diff --git a/dependencies/dependencies.lock.json b/dependencies/dependencies.lock.json index d3b5ac9a..5cca7210 100644 --- a/dependencies/dependencies.lock.json +++ b/dependencies/dependencies.lock.json @@ -70,6 +70,12 @@ "cluster_wdg": {"url": "https://github.com/retroSoC/wdg.git", "revision": "f5d39fa246054d678d34195824d943367dfcce44", "destination": "rtl/managed/clusterip/wdg", "license": "MulanPSL-2.0"} }, "archives": { + "ecc_cli_linux_x86_64": { + "url": "https://github.com/openecos-projects/ecc/releases/download/v0.1.0-alpha.10/ecc-cli-linux-x86_64.tar.gz", + "sha256": "fc3daaca24dddb04ba3490329042f52da05190da03c3831042293dd0cbffdca6", + "destination": ".cache/retrosoc/downloads/ecc/ecc-cli-linux-x86_64-v0.1.0-alpha.10.tar.gz", + "license": "Apache-2.0" + }, "fatfs": { "url": "https://github.com/retroSoC/artifact/releases/download/fatfs-r0.16/fatfs-r0.16.zip", "sha256": "99f7dc1f7e095356e4a9e3dbe29959090d8b948afe2bbc5441e52fdf4b85449e", diff --git a/physical/README.md b/physical/README.md index d3fb75a7..f5f85a29 100644 --- a/physical/README.md +++ b/physical/README.md @@ -8,6 +8,8 @@ drivers. - `smoke/syn/` contains Yosys synthesis and source-export integration. - `smoke/sta/` contains OpenSTA constraints and timing integration. - `librelane/` contains the open-source IHP130 core-hardening and full-chip pad-ring flows. +- `ecc/` contains the open-source ICS55 padless-core hardening flow backed by + the locked ECOS Chip Compiler release. - `commercial/` contains licensed-tool orchestration and flow logic. PDKs, foundry decks, commercial libraries, site configuration, and results remain outside Git. diff --git a/physical/ecc/Makefile b/physical/ecc/Makefile new file mode 100644 index 00000000..d77f7bb7 --- /dev/null +++ b/physical/ecc/Makefile @@ -0,0 +1,119 @@ +ECC_FLOW_ROOT := $(ROOT_PATH)/physical/ecc +ECC_TARGET ?= core +ECC_RUN_ROOT := $(VARIANT_ROOT)/physical/ecc/$(ECC_TARGET) +ECC_INPUT_DIR := $(ECC_RUN_ROOT)/input +ECC_PROJECT_DIR := $(ECC_RUN_ROOT)/project +ECC_RTL := $(ECC_INPUT_DIR)/retrosoc_asic_sources.sv +ECC_SDC := $(ECC_INPUT_DIR)/retrosoc_core.sdc +ECC_CONFIG := $(ECC_PROJECT_DIR)/ecc.toml +ECC_LOG := $(ECC_RUN_ROOT)/ecc.log +ECC_RESULT := $(ECC_RUN_ROOT)/result.json +ECC_VERIFY := $(ECC_RUN_ROOT)/verification.json +ECC_STATUS := $(ECC_RUN_ROOT)/status.json +ECC_META_RESULT := $(META_DIR)/ecc-$(ECC_TARGET).json +ECC_DOCTOR_RESULT := $(META_DIR)/ecc-$(ECC_TARGET)-doctor.json +ECC_ARCHIVE := $(ECC_RUN_ROOT)/retrosoc-ics55-$(ECC_TARGET).tar.gz +ECC_CACHE_ROOT := $(CACHE_ROOT)/tools/ecc/v0.1.0-alpha.10 +ECC_BIN := $(ECC_CACHE_ROOT)/ecc +ECC_DOWNLOAD := $(ROOT_PATH)/.cache/retrosoc/downloads/ecc/ecc-cli-linux-x86_64-v0.1.0-alpha.10.tar.gz +ECC_PDK_ROOT := $(ROOT_PATH)/physical/pdk/icsprout55-pdk +ECC_H7CR_LIBERTY := $(CACHE_ROOT)/pdk/ics55/ics55_h7cr_ss.lib +ECC_CLOCK_DOMAINS := $(ROOT_PATH)/rtl/mini/integration/clock_reset_domains.json +ECC_PIN_MAP := $(ROOT_PATH)/rtl/mini/pin_map/pin_map.json +ECC_SOURCE_PYTHON ?= /usr/bin/python3 +ECC_ENV := XDG_CACHE_HOME=$(CACHE_ROOT)/fontconfig +ECC_TRACKED_RTL := $(shell git -C $(ROOT_PATH) ls-files \ + 'rtl/**/*.sv' 'rtl/**/*.svh' 'rtl/**/*.v' 'rtl/**/*.vh') + +ifneq ($(ECC_TARGET),core) +$(error ECC_TARGET must be core, got '$(ECC_TARGET)') +endif + +ifneq ($(filter ecc-setup ecc-input ecc-doctor ecc-check ecc-core ecc-status ecc-log ecc-package,$(MAKECMDGOALS)),) +FLOW_PYTHON := $(ECC_SOURCE_PYTHON) +endif + +.PHONY: ecc-check-profile ecc-setup ecc-input ecc-doctor ecc-check ecc-core \ + ecc-status ecc-log ecc-package + +ecc-check-profile: + @test '$(PDK)' = ICS55 || { \ + echo 'physical/ecc requires PDK=ICS55' >&2; exit 2; } + @test '$(HAVE_PLL)' = NO || { \ + echo 'physical/ecc has no qualified PLL hardening configuration' >&2; exit 2; } + @test '$(HAVE_SRAM_IF)' = NO || { \ + echo 'physical/ecc currently hardens the committed padless ICS55 profile only' >&2; exit 2; } + @test '$(HAVE_SRAM_MACRO)' = NO || { \ + echo 'physical/ecc currently has no qualified ICS55 SRAM macro configuration' >&2; exit 2; } + +$(ECC_BIN): $(LOCK_FILE) $(ECC_FLOW_ROOT)/scripts/setup.py | ecc-check-profile + $(FLOW_PYTHON) $(ECC_FLOW_ROOT)/scripts/setup.py --root $(ROOT_PATH) \ + --lock $(LOCK_FILE) --output-dir $(ECC_CACHE_ROOT) + @test -x $@ + +ecc-setup: setup-pdk $(ECC_BIN) + +$(ECC_H7CR_LIBERTY): + +$(MAKE) setup-pdk + @test -f $@ + +$(ECC_RTL): $(MPW_VARIANT_STAMP) $(FILELIST_STAMP) $(ECC_TRACKED_RTL) \ + $(ROOT_PATH)/physical/smoke/syn/tools/export_soc_sources.py | ecc-check-profile + @mkdir -p $(@D) + $(FLOW_PYTHON) $(ROOT_PATH)/physical/smoke/syn/tools/export_soc_sources.py sv \ + --output-dir $(@D) --pdk ICS55 --simu $(SIMU) \ + --jtag-idcode $(JTAG_IDCODE) --ext-clk-hz $(EXT_CLK_HZ) \ + --aud-clk-hz $(AUD_CLK_HZ) --clint-timebase-hz $(CLINT_TIMEBASE_HZ) \ + --dynamic-core-filelist $(MPW_OUTPUT_DIR)/core/core.fl \ + --dynamic-ip-filelist $(MPW_OUTPUT_DIR)/ip/ip.fl \ + --memory-map-filelist $(MEMORY_MAP_FILELIST) \ + --soc-topology-filelist $(SOC_TOPOLOGY_FILELIST) \ + --user-extensions-filelist $(USER_EXTENSIONS_FILELIST) \ + --pin-map-filelist $(PIN_MAP_FILELIST) \ + --archinfo-incdir $(ARCHINFO_METADATA_DIR) --librelane-safe + +$(ECC_SDC): $(ROOT_PATH)/physical/smoke/sta/opensta/generate_sdc.py \ + $(ECC_CLOCK_DOMAINS) $(ECC_PIN_MAP) | ecc-check-profile + @mkdir -p $(@D) + $(FLOW_PYTHON) $(ROOT_PATH)/physical/smoke/sta/opensta/generate_sdc.py \ + --domains $(ECC_CLOCK_DOMAINS) --pin-map $(ECC_PIN_MAP) --output $@ + +$(ECC_CONFIG): $(ECC_RTL) $(ECC_SDC) $(ECC_H7CR_LIBERTY) \ + $(ECC_FLOW_ROOT)/scripts/generate_config.py | ecc-check-profile + @mkdir -p $(@D) + $(FLOW_PYTHON) $(ECC_FLOW_ROOT)/scripts/generate_config.py \ + --rtl $(ECC_RTL) --sdc $(ECC_SDC) --pdk-root $(ECC_PDK_ROOT) \ + --h7cr-liberty $(ECC_H7CR_LIBERTY) --ext-clk-hz $(EXT_CLK_HZ) --output $@ + +ecc-input: ecc-setup $(ECC_CONFIG) + +$(ECC_DOCTOR_RESULT): $(ECC_CONFIG) $(ECC_BIN) $(ECC_FLOW_ROOT)/scripts/doctor.py + @mkdir -p $(@D) + $(FLOW_PYTHON) $(ECC_FLOW_ROOT)/scripts/doctor.py --root $(ROOT_PATH) \ + --lock $(LOCK_FILE) --ecc $(ECC_BIN) --project $(ECC_PROJECT_DIR) \ + --pdk-root $(ECC_PDK_ROOT) --sdc $(ECC_SDC) --output $@ + +ecc-doctor: ecc-setup $(ECC_DOCTOR_RESULT) + +ecc-check: ecc-doctor + +ecc-core: ecc-doctor manifest + $(FLOW_PYTHON) $(ROOT_PATH)/scripts/run_flow.py --tool ecc --log $(ECC_LOG) \ + --result $(ECC_RESULT) --cwd $(ECC_PROJECT_DIR) -- \ + env $(ECC_ENV) $(ECC_BIN) run --project $(ECC_PROJECT_DIR) --overwrite --json + $(FLOW_PYTHON) $(ECC_FLOW_ROOT)/scripts/check_outputs.py --ecc $(ECC_BIN) \ + --project $(ECC_PROJECT_DIR) --sdc $(ECC_SDC) --output $(ECC_VERIFY) + @mkdir -p $(META_DIR) + @cp $(ECC_RESULT) $(ECC_META_RESULT) + +ecc-status: ecc-doctor + @mkdir -p $(ECC_RUN_ROOT) + env $(ECC_ENV) $(ECC_BIN) status --project $(ECC_PROJECT_DIR) --json > $(ECC_STATUS) + @cat $(ECC_STATUS) + +ecc-log: ecc-doctor + env $(ECC_ENV) $(ECC_BIN) log --project $(ECC_PROJECT_DIR) + +ecc-package: ecc-core + $(FLOW_PYTHON) $(ECC_FLOW_ROOT)/scripts/package.py --root $(ROOT_PATH) \ + --variant-root $(VARIANT_ROOT) --run-root $(ECC_RUN_ROOT) --output $(ECC_ARCHIVE) diff --git a/physical/ecc/README.md b/physical/ecc/README.md new file mode 100644 index 00000000..f292fe78 --- /dev/null +++ b/physical/ecc/README.md @@ -0,0 +1,31 @@ +# ECC ICS55 Core Hardening + +This directory owns the on-demand ECOS Chip Compiler (ECC) hardening adapter +for the padless `retrosoc_core` macro on the committed +`configs/ci/ics55.mk` profile. It consumes the locked ECC +`v0.1.0-alpha.10` Linux CLI release, the locked ICS55 PDK checkout, the +cached H7CR slow Liberty view, and the canonical generated SoC sources and +clock-domain constraints. + +The flow deliberately does not use `retrosoc_asic`, IO LEFs/Liberties, pad +cells, a pad ring, bondpads, package constraints, or board timing. Logical +ports remain macro pins. The generated SDC constrains the external, generated +system, audio, JTAG, DVP, and ULPI domains; ECC must accept that SDC through +its PDK overrides. A single-clock fallback is not permitted. + +Run the flow through the root Makefile: + +```sh +make CONFIG=configs/ci/ics55.mk ecc-setup +make CONFIG=configs/ci/ics55.mk ecc-doctor +make CONFIG=configs/ci/ics55.mk ecc-core +make CONFIG=configs/ci/ics55.mk ecc-package +``` + +Inputs, logs, the ECC workspace, reports, and packaged views live below +`build//physical/ecc/core/`; the CLI archive and extracted tool live +below `.cache/retrosoc/`. They are generated evidence, not tracked source. + +ECC hardening is development evidence only. The ICS55 preview PDK and this +padless flow do not establish foundry DRC/LVS, IR/EM, ESD, package, board, or +production-signoff closure. diff --git a/physical/ecc/scripts/check_outputs.py b/physical/ecc/scripts/check_outputs.py new file mode 100644 index 00000000..9782566a --- /dev/null +++ b/physical/ecc/scripts/check_outputs.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Check that an ECC harden run produced complete padless-core evidence.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + + +REQUIRED_STEPS = { + "SYNTHESIS", + "FLOORPLAN", + "NETLIST_OPT", + "PLACEMENT", + "CTS", + "LEGALIZATION", + "ROUTING", + "DRC", + "FILLER", + "RCX", + "STA", + "HARDEN", +} +STEP_ALIASES = { + "FIXFANOUT": "NETLIST_OPT", + "PLACE": "PLACEMENT", + "ROUTE": "ROUTING", +} +ARTIFACT_GROUPS = { + "gds": ("*.gds", "*.gds.gz"), + "def": ("*.def", "*.def.gz"), + "netlist": ("*.v", "*.v.gz"), + "spef": ("*.spef", "*.spef.gz"), + "sta_report": ("*sta*.rpt", "*STA*.rpt", "*timing*.rpt"), + "drc_report": ("*drc*.rpt", "*DRC*.rpt", "*drc*.json", "*DRC*.json"), +} + + +def digest(path: Path) -> str: + hasher = hashlib.sha256() + with path.open("rb") as source: + for block in iter(lambda: source.read(1024 * 1024), b""): + hasher.update(block) + return hasher.hexdigest() + + +def collect_step_names(value: Any) -> set[str]: + names: set[str] = set() + if isinstance(value, dict): + for key, item in value.items(): + if key.lower() in {"step", "name", "type"} and isinstance(item, str): + names.add(item.upper()) + names.update(collect_step_names(item)) + elif isinstance(value, list): + for item in value: + names.update(collect_step_names(item)) + return names + + +def canonical_steps(steps: set[str]) -> set[str]: + return {STEP_ALIASES.get(step, step) for step in steps} + + +def find_artifacts(workspace: Path, patterns: tuple[str, ...]) -> list[Path]: + found: set[Path] = set() + for pattern in patterns: + found.update(path for path in workspace.rglob(pattern) if path.is_file()) + return sorted(found) + + +def command_environment(project: Path) -> dict[str, str]: + cache = project / ".fontconfig-cache" + cache.mkdir(parents=True, exist_ok=True) + environment = os.environ.copy() + environment["XDG_CACHE_HOME"] = str(cache) + return environment + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--ecc", type=Path, required=True) + parser.add_argument("--project", type=Path, required=True) + parser.add_argument("--sdc", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + project = args.project.resolve() + workspace = project / "runs/default" + errors: list[str] = [] + details: dict[str, object] = {"workspace": str(workspace)} + + flow_path = workspace / "home/flow.json" + if not flow_path.is_file(): + errors.append(f"ECC flow state is missing: {flow_path}") + else: + try: + flow = json.loads(flow_path.read_text(encoding="utf-8")) + steps = canonical_steps(collect_step_names(flow)) + details["steps"] = sorted(steps) + missing_steps = sorted(REQUIRED_STEPS - steps) + if missing_steps: + errors.append("ECC harden flow is missing step(s): " + ", ".join(missing_steps)) + except (OSError, json.JSONDecodeError) as error: + errors.append(f"cannot read ECC flow state: {error}") + + copied_sdc = workspace / "origin" / args.sdc.name + if not copied_sdc.is_file(): + errors.append(f"ECC workspace did not retain the generated SDC: {copied_sdc}") + elif digest(copied_sdc) != digest(args.sdc): + errors.append("ECC workspace SDC does not match the generated multi-clock SDC") + + for label, patterns in ARTIFACT_GROUPS.items(): + artifacts = find_artifacts(workspace, patterns) + details[f"{label}_artifacts"] = [str(path.relative_to(workspace)) for path in artifacts] + if not artifacts: + errors.append(f"ECC harden output is missing {label} evidence") + + try: + status = subprocess.run( + [str(args.ecc), "status", "--project", str(project), "--json"], + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=command_environment(project), + ) + details["status"] = json.loads(status.stdout) + except (OSError, subprocess.CalledProcessError, json.JSONDecodeError) as error: + errors.append(f"cannot obtain ECC status: {error}") + + result = { + "schema_version": 1, + "status": "passed" if not errors else "failed", + "errors": errors, + "details": details, + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8") + if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + print(f"ECC output verification passed: {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/physical/ecc/scripts/doctor.py b/physical/ecc/scripts/doctor.py new file mode 100644 index 00000000..502167c1 --- /dev/null +++ b/physical/ecc/scripts/doctor.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Validate the reproducible padless ICS55 ECC hardening environment.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib + + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT)) +from scripts.dependency_lock import load_lock # noqa: E402 +from scripts.setup_helpers import atomic_write # noqa: E402 + + +EXPECTED_ECC_VERSION = "ecc 0.1.0a10" +EXPECTED_CLOCKS = ( + "clk_external", + "clk_system", + "clk_audio", + "clk_jtag", + "clk_dvp", + "clk_usb2_ulpi", +) + + +def command_environment(project: Path) -> dict[str, str]: + cache = project / ".fontconfig-cache" + cache.mkdir(parents=True, exist_ok=True) + environment = os.environ.copy() + environment["XDG_CACHE_HOME"] = str(cache) + return environment + + +def command_output(command: list[str], project: Path) -> str: + return subprocess.check_output( + command, + text=True, + stderr=subprocess.STDOUT, + env=command_environment(project), + ).strip() + + +def ecc_version(binary: Path, project: Path) -> str: + output = command_output([str(binary), "--version"], project) + lines = [line.strip() for line in output.splitlines() if line.strip()] + for line in reversed(lines): + if line.startswith("ecc "): + return line + return output + + +def read_config(path: Path) -> dict[str, Any]: + with path.open("rb") as source: + data = tomllib.load(source) + if not isinstance(data, dict): + raise ValueError("ECC configuration must be a TOML object") + return data + + +def validate_config(config: dict[str, Any], sdc: Path) -> tuple[list[str], dict[str, object]]: + errors: list[str] = [] + details: dict[str, object] = {} + design = config.get("design") + pdk = config.get("pdk") + flow = config.get("flow") + if not isinstance(design, dict): + errors.append("ecc.toml [design] table is missing") + design = {} + if not isinstance(pdk, dict): + errors.append("ecc.toml [pdk] table is missing") + pdk = {} + if not isinstance(flow, dict): + errors.append("ecc.toml [flow] table is missing") + flow = {} + details["top"] = design.get("top") + details["pdk"] = pdk.get("name") + details["preset"] = flow.get("preset") + if design.get("name") != "retrosoc_core" or design.get("top") != "retrosoc_core": + errors.append("ECC design must target the padless retrosoc_core macro") + if design.get("clock_port") != "extclk_i_pad": + errors.append("ECC primary clock must be the logical extclk_i_pad port") + if pdk.get("name") != "ics55": + errors.append("ECC PDK must be ics55") + if flow.get("preset") != "harden": + errors.append("ECC flow preset must be harden") + overrides = pdk.get("overrides") + if not isinstance(overrides, dict): + errors.append("ECC PDK overrides are required for padless H7CR hardening") + overrides = {} + for field in ("tech", "lefs", "libs", "sdc", "site_core", "tap_cell", "end_cap", "fillers"): + if field not in overrides: + errors.append(f"ECC PDK override is missing: {field}") + lefs = overrides.get("lefs", []) + libs = overrides.get("libs", []) + if not isinstance(lefs, list) or len(lefs) != 1 or "H7CR" not in str(lefs[0]): + errors.append("ECC must use exactly one H7CR standard-cell LEF") + if not isinstance(libs, list) or len(libs) != 1 or "h7cr" not in str(libs[0]).lower(): + errors.append("ECC must use exactly one H7CR standard-cell Liberty") + no_pad_markers = ("IO", "PAD", "bondpad") + lef_values = lefs if isinstance(lefs, list) else [] + lib_values = libs if isinstance(libs, list) else [] + flattened = " ".join(str(value) for value in [*lef_values, *lib_values]) + if any(marker.lower() in flattened.lower() for marker in no_pad_markers): + errors.append("ECC padless flow may not reference IO, PAD, or bondpad collateral") + if not sdc.is_file(): + errors.append(f"ECC SDC is missing: {sdc}") + else: + text = sdc.read_text(encoding="utf-8") + missing = [clock for clock in EXPECTED_CLOCKS if f"-name {clock}" not in text] + if missing: + errors.append("ECC SDC is missing clock(s): " + ", ".join(missing)) + if "set_clock_groups -name retrosoc_async -asynchronous" not in text: + errors.append("ECC SDC must preserve asynchronous clock groups") + return errors, details + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, required=True) + parser.add_argument("--lock", type=Path, required=True) + parser.add_argument("--ecc", type=Path, required=True) + parser.add_argument("--project", type=Path, required=True) + parser.add_argument("--pdk-root", type=Path, required=True) + parser.add_argument("--sdc", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + + errors: list[str] = [] + details: dict[str, object] = {} + project = args.project.resolve() + config_path = project / "ecc.toml" + lock = load_lock(args.lock) + details["ecc_archive_sha256"] = lock["archives"]["ecc_cli_linux_x86_64"]["sha256"] + details["locked_pdk_revision"] = lock["sources"]["pdk_ics55"]["revision"] + + if not args.ecc.is_file() or not args.ecc.stat().st_mode & 0o111: + errors.append(f"ECC executable is missing or not executable: {args.ecc}") + else: + try: + version = ecc_version(args.ecc, project) + details["ecc_version"] = version + if version != EXPECTED_ECC_VERSION: + errors.append(f"expected {EXPECTED_ECC_VERSION}, found {version or ''}") + except (OSError, subprocess.CalledProcessError) as error: + errors.append(f"cannot execute ECC: {error}") + + if not (args.pdk_root / ".git").is_dir(): + errors.append(f"ICS55 PDK checkout is missing: {args.pdk_root}") + else: + try: + revision = command_output( + ["git", "-C", str(args.pdk_root), "rev-parse", "HEAD"], project + ) + details["pdk_revision"] = revision + if revision != details["locked_pdk_revision"]: + errors.append( + f"expected ICS55 PDK {details['locked_pdk_revision']}, found {revision}" + ) + except (OSError, subprocess.CalledProcessError) as error: + errors.append(f"cannot inspect ICS55 PDK revision: {error}") + + required = ( + args.pdk_root / "prtech/techLEF/N551P6M_ecos.lef", + args.pdk_root + / "IP/STD_cell/ics55_LLSC_H7C_V1p10C100/ics55_LLSC_H7CR/lef/" + "ics55_LLSC_H7CR_ecos.lef", + ) + for path in required: + if not path.is_file(): + errors.append(f"required padless ICS55 PDK input is missing: {path}") + + if not config_path.is_file(): + errors.append(f"ECC project configuration is missing: {config_path}") + else: + try: + config_errors, config_details = validate_config(read_config(config_path), args.sdc.resolve()) + errors.extend(config_errors) + details.update(config_details) + except (OSError, tomllib.TOMLDecodeError, ValueError) as error: + errors.append(f"cannot validate ECC project configuration: {error}") + + if not errors and args.ecc.is_file(): + try: + check = subprocess.run( + [str(args.ecc), "check", "--project", str(project), "--json"], + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=command_environment(project), + ) + details["ecc_check"] = json.loads(check.stdout) + except subprocess.CalledProcessError as error: + errors.append(f"ECC project check failed: {error.stdout.strip()}") + except (OSError, json.JSONDecodeError) as error: + errors.append(f"ECC project check failed: {error}") + + result = { + "schema_version": 1, + "status": "passed" if not errors else "failed", + "errors": errors, + "details": details, + } + atomic_write(args.output, json.dumps(result, indent=2, sort_keys=True) + "\n") + if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + print(f"ECC doctor passed: {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/physical/ecc/scripts/generate_config.py b/physical/ecc/scripts/generate_config.py new file mode 100644 index 00000000..aafd3a54 --- /dev/null +++ b/physical/ecc/scripts/generate_config.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Generate a padless ICS55 ECC project configuration from canonical inputs.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path + + +EXPECTED_CLOCKS = ( + "clk_external", + "clk_system", + "clk_audio", + "clk_jtag", + "clk_dvp", + "clk_usb2_ulpi", +) + + +def relative(path: Path, base: Path) -> str: + return os.path.relpath(path.resolve(), base.resolve()) + + +def render(args: argparse.Namespace) -> str: + project_dir = args.output.resolve().parent + pdk_root = args.pdk_root.resolve() + sdc = args.sdc.resolve() + liberty = args.h7cr_liberty.resolve() + tech = pdk_root / "prtech/techLEF/N551P6M_ecos.lef" + lef = ( + pdk_root + / "IP/STD_cell/ics55_LLSC_H7C_V1p10C100/ics55_LLSC_H7CR/lef/" + "ics55_LLSC_H7CR_ecos.lef" + ) + required = (args.rtl.resolve(), sdc, pdk_root, tech, lef, liberty) + missing = [str(path) for path in required if not path.exists()] + if missing: + raise FileNotFoundError("missing ECC configuration input(s): " + ", ".join(missing)) + sdc_text = sdc.read_text(encoding="utf-8") + missing_clocks = [clock for clock in EXPECTED_CLOCKS if f"-name {clock}" not in sdc_text] + if missing_clocks: + raise ValueError("ECC SDC is missing clock(s): " + ", ".join(missing_clocks)) + + return "\n".join( + ( + "# Generated by physical/ecc/scripts/generate_config.py; do not edit.", + "[design]", + 'name = "retrosoc_core"', + 'top = "retrosoc_core"', + f'rtl = ["{relative(args.rtl, project_dir)}"]', + 'clock_port = "extclk_i_pad"', + f"frequency_mhz = {args.ext_clk_hz / 1_000_000:.9g}", + "", + "[pdk]", + 'name = "ics55"', + f'root = "{relative(pdk_root, project_dir)}"', + "", + "# ECC v0.1.0-alpha.10 resolves PDK content paths relative to pdk.root.", + "# Supply only the padless H7CR core library and the generated multi-clock SDC.", + "[pdk.overrides]", + f'tech = "{relative(tech, pdk_root)}"', + f'lefs = ["{relative(lef, pdk_root)}"]', + f'libs = ["{relative(liberty, pdk_root)}"]', + f'sdc = "{relative(sdc, project_dir)}"', + 'site_core = "core7"', + 'tap_cell = "FILLTAPH7R"', + 'end_cap = "FILLTAPH7R"', + 'fillers = ["FILLER64H7R", "FILLER32H7R", "FILLER16H7R", "FILLER8H7R", "FILLER4H7R", "FILLER2H7R", "FILLER1H7R"]', + "", + "[flow]", + 'preset = "harden"', + 'run = "default"', + "", + "[params.floorplan]", + "core_util = 0.60", + "aspect_ratio = 1.0", + "", + "[params.place]", + "target_density = 0.55", + "", + ) + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--rtl", type=Path, required=True) + parser.add_argument("--sdc", type=Path, required=True) + parser.add_argument("--pdk-root", type=Path, required=True) + parser.add_argument("--h7cr-liberty", type=Path, required=True) + parser.add_argument("--ext-clk-hz", type=int, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if args.ext_clk_hz <= 0: + parser.error("--ext-clk-hz must be positive") + try: + content = render(args) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(content, encoding="utf-8") + except (OSError, ValueError) as error: + parser.error(str(error)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/physical/ecc/scripts/package.py b/physical/ecc/scripts/package.py new file mode 100644 index 00000000..8d9993b3 --- /dev/null +++ b/physical/ecc/scripts/package.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Create a checksummed development delivery from an ECC core harden run.""" + +from __future__ import annotations + +import argparse +import hashlib +import shutil +import tarfile +import tempfile +from pathlib import Path + + +def hash_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for block in iter(lambda: source.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, required=True) + parser.add_argument("--variant-root", type=Path, required=True) + parser.add_argument("--run-root", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + root = args.root.resolve() + variant_root = args.variant_root.resolve() + run_root = args.run_root.resolve() + project = run_root / "project" + workspace = project / "runs/default" + required = ( + project / "ecc.toml", + workspace, + run_root / "result.json", + run_root / "verification.json", + variant_root / "meta/manifest.json", + variant_root / "meta/ecc-core-doctor.json", + variant_root / "meta/ecc-core.json", + root / "dependencies/dependencies.lock.json", + ) + missing = [str(path) for path in required if not path.exists()] + if missing: + parser.error("missing ECC delivery input(s): " + ", ".join(missing)) + + args.output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="retrosoc-ecc-package-") as temporary: + staging = Path(temporary) / "retrosoc-ics55-core" + shutil.copytree(run_root / "input", staging / "input") + shutil.copy2(project / "ecc.toml", staging / "evidence/ecc.toml") + shutil.copytree(workspace, staging / "evidence/workspace") + for source, destination in ( + (run_root / "result.json", "ecc-result.json"), + (run_root / "verification.json", "verification.json"), + (variant_root / "meta/manifest.json", "manifest.json"), + (variant_root / "meta/ecc-core-doctor.json", "ecc-doctor.json"), + (variant_root / "meta/ecc-core.json", "ecc-core.json"), + (root / "dependencies/dependencies.lock.json", "dependencies.lock.json"), + ): + target = staging / "evidence" / destination + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + files = sorted(path for path in staging.rglob("*") if path.is_file()) + checksums = "\n".join( + f"{hash_file(path)} {path.relative_to(staging)}" for path in files + ) + (staging / "SHA256SUMS").write_text(checksums + "\n", encoding="utf-8") + with tarfile.open(args.output, "w:gz") as archive: + archive.add(staging, arcname=staging.name) + print(f"created ECC delivery: {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/physical/ecc/scripts/setup.py b/physical/ecc/scripts/setup.py new file mode 100644 index 00000000..670344f1 --- /dev/null +++ b/physical/ecc/scripts/setup.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Install the checksum-locked ECOS Chip Compiler CLI into the local cache.""" + +from __future__ import annotations + +import argparse +import os +import shutil +import stat +import subprocess +import sys +import tempfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT)) +from scripts.dependency_lock import archive # noqa: E402 +from scripts.install_toolchain import safe_extract # noqa: E402 +from scripts.setup_helpers import atomic_write, download_file # noqa: E402 + + +ARCHIVE_NAME = "ecc_cli_linux_x86_64" +EXPECTED_VERSION = "0.1.0a10" + + +def ecc_version(binary: Path) -> str: + result = subprocess.run( + [str(binary), "--version"], + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + lines = [line.strip() for line in result.stdout.splitlines() if line.strip()] + for line in reversed(lines): + if line.startswith("ecc "): + return line + return result.stdout.strip() + + +def install(root: Path, lock_path: Path, output_dir: Path, update: bool) -> Path: + spec = archive(ARCHIVE_NAME, lock_path) + archive_path = root / spec["destination"] + download_file(spec["url"], archive_path, spec["sha256"], update=update, timeout=120) + + output_dir = output_dir.resolve() + marker = output_dir / ".complete" + binary = output_dir / "ecc" + marker_text = f"{EXPECTED_VERSION}\n{spec['sha256']}\n" + if binary.is_file() and marker.is_file() and marker.read_text(encoding="utf-8") == marker_text: + return binary + + output_dir.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=f".{output_dir.name}.", dir=output_dir.parent) as temp: + extracted = Path(temp) / "extracted" + extracted.mkdir() + safe_extract(archive_path, extracted) + candidates = sorted( + path for path in extracted.rglob("ecc") if path.is_file() and os.access(path, os.X_OK) + ) + if len(candidates) != 1: + raise RuntimeError( + "expected exactly one executable named ecc in the ECC release archive, " + f"found {len(candidates)}" + ) + bundle_root = candidates[0].parent + candidate = Path(temp) / "content" + shutil.copytree(bundle_root, candidate) + installed_binary = candidate / "ecc" + installed_binary.chmod(installed_binary.stat().st_mode | stat.S_IXUSR) + version = ecc_version(installed_binary) + if version != f"ecc {EXPECTED_VERSION}": + raise RuntimeError( + f"expected ECC version ecc {EXPECTED_VERSION}, found {version or ''}" + ) + atomic_write(candidate / ".complete", marker_text) + if output_dir.exists(): + shutil.rmtree(output_dir) + os.replace(candidate, output_dir) + return binary + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=ROOT) + parser.add_argument("--lock", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--update", action="store_true") + args = parser.parse_args() + binary = install(args.root.resolve(), args.lock.resolve(), args.output_dir, args.update) + print(f"ECC is ready: {binary}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ecc_flow.py b/tests/test_ecc_flow.py new file mode 100644 index 00000000..06ae7480 --- /dev/null +++ b/tests/test_ecc_flow.py @@ -0,0 +1,155 @@ +"""Tests for the padless ICS55 ECC hardening adapter.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +from types import SimpleNamespace + +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib + + +ROOT = Path(__file__).resolve().parents[1] +ECC_ROOT = ROOT / "physical/ecc" + + +def load_module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def write_pdk(root: Path) -> None: + tech = root / "prtech/techLEF/N551P6M_ecos.lef" + lef = ( + root + / "IP/STD_cell/ics55_LLSC_H7C_V1p10C100/ics55_LLSC_H7CR/lef/" + "ics55_LLSC_H7CR_ecos.lef" + ) + tech.parent.mkdir(parents=True) + lef.parent.mkdir(parents=True) + tech.write_text("VERSION 5.8 ;\n", encoding="utf-8") + lef.write_text("VERSION 5.8 ;\n", encoding="utf-8") + + +def write_sdc(path: Path) -> None: + path.write_text( + "\n".join( + [ + f"create_clock -name {name} -period 10 [get_ports clk]" + for name in ( + "clk_external", + "clk_system", + "clk_audio", + "clk_jtag", + "clk_dvp", + "clk_usb2_ulpi", + ) + ] + + ["set_clock_groups -name retrosoc_async -asynchronous"] + ) + + "\n", + encoding="utf-8", + ) + + +def test_ecc_config_is_padless_and_uses_the_full_clock_inventory(tmp_path: Path) -> None: + module = load_module("retrosoc_ecc_config", ECC_ROOT / "scripts/generate_config.py") + pdk = tmp_path / "pdk" + write_pdk(pdk) + rtl = tmp_path / "input/retrosoc_asic_sources.sv" + rtl.parent.mkdir() + rtl.write_text("module retrosoc_core; endmodule\n", encoding="utf-8") + sdc = tmp_path / "input/retrosoc_core.sdc" + write_sdc(sdc) + liberty = tmp_path / "cache/ics55_h7cr_ss.lib" + liberty.parent.mkdir() + liberty.write_text("library (h7cr) {}\n", encoding="utf-8") + output = tmp_path / "project/ecc.toml" + args = SimpleNamespace( + rtl=rtl, + sdc=sdc, + pdk_root=pdk, + h7cr_liberty=liberty, + ext_clk_hz=72_000_000, + output=output, + ) + + config = module.render(args) + output.parent.mkdir() + output.write_text(config, encoding="utf-8") + document = tomllib.loads(config) + + assert document["design"]["name"] == "retrosoc_core" + assert document["design"]["top"] == "retrosoc_core" + assert document["design"]["clock_port"] == "extclk_i_pad" + assert document["design"]["frequency_mhz"] == 72.0 + assert document["pdk"]["name"] == "ics55" + assert document["flow"]["preset"] == "harden" + assert document["params"]["floorplan"]["core_util"] == 0.60 + assert document["params"]["place"]["target_density"] == 0.55 + overrides = document["pdk"]["overrides"] + assert len(overrides["lefs"]) == 1 + assert "H7CR" in overrides["lefs"][0] + assert len(overrides["libs"]) == 1 + assert "h7cr" in overrides["libs"][0].lower() + assert "IO" not in " ".join(overrides["lefs"] + overrides["libs"]) + assert "PAD" not in config + assert "bondpad" not in config + + +def test_ecc_doctor_rejects_single_clock_or_pad_collateral(tmp_path: Path) -> None: + module = load_module("retrosoc_ecc_doctor", ECC_ROOT / "scripts/doctor.py") + sdc = tmp_path / "retrosoc_core.sdc" + write_sdc(sdc) + config = { + "design": { + "name": "retrosoc_core", + "top": "retrosoc_core", + "clock_port": "extclk_i_pad", + }, + "pdk": { + "name": "ics55", + "overrides": { + "tech": "prtech/techLEF/N551P6M_ecos.lef", + "lefs": ["IP/STD_cell/H7CR/lef/ics55_LLSC_H7CR_ecos.lef"], + "libs": ["cache/ics55_h7cr_ss.lib"], + "sdc": "retrosoc_core.sdc", + "site_core": "core7", + "tap_cell": "FILLTAPH7R", + "end_cap": "FILLTAPH7R", + "fillers": ["FILLER1H7R"], + }, + }, + "flow": {"preset": "harden"}, + } + + errors, details = module.validate_config(config, sdc) + assert not errors + assert details["top"] == "retrosoc_core" + config["pdk"]["overrides"]["lefs"] = ["IP/IO/ics55_pad.lef"] + errors, _ = module.validate_config(config, sdc) + assert any("exactly one H7CR" in error for error in errors) + assert any("IO, PAD, or bondpad" in error for error in errors) + + +def test_ecc_flow_is_on_demand_and_locked() -> None: + makefile = (ECC_ROOT / "Makefile").read_text(encoding="utf-8") + root_makefile = (ROOT / "Makefile").read_text(encoding="utf-8") + lock = json.loads((ROOT / "dependencies/dependencies.lock.json").read_text(encoding="utf-8")) + + assert "ecc-core:" in makefile + assert "ecc-package:" in makefile + assert "ecc-all" not in makefile + assert "--librelane-safe" in makefile + assert "include physical/ecc/Makefile" in root_makefile + assert "ecc-core" not in (ROOT / "scripts/regress.py").read_text(encoding="utf-8") + archive = lock["archives"]["ecc_cli_linux_x86_64"] + assert archive["url"].endswith("v0.1.0-alpha.10/ecc-cli-linux-x86_64.tar.gz") + assert archive["sha256"] == "fc3daaca24dddb04ba3490329042f52da05190da03c3831042293dd0cbffdca6"