[earlgrey] add QEMU support Wire the lowRISC QEMU ot-earlgrey machine into target/earlgrey as a target (parallel to verilator). QEMU runs ~50x faster than verilator (2.6s vs minutes) and is part of the earlgrey_qemu_tests workflow within ci so the tests run on every PR. Active QEMU tests: - //target/earlgrey/tests/ipc/user:ipc_runner_qemu_test PASS 2.6s - //target/earlgrey/tests/threads/kernel:threads_runner_qemu_test PASS 12.5s 50/50 stability runs, zero flakes. Build changes: - third_party/qemu/: lowRISC QEMU release archive pinned to v10.2.0-2026-01-15; hermetic pip-parse for hjson + pyelftools (@openprot_python_deps, Bazel-managed Python 3.11). - third_party/opentitan_rtl/: vendored RTL constants for cfggen. RMA OTP vmem consumed from @opentitan_devbundle (no local copy). - target/earlgrey/tooling/qemu.bzl: qemu_cfg / qemu_otp / qemu_flash rules + pure-function helpers callable from opentitan_runner.bzl. - target/earlgrey/tooling/qemu_runner.py + qemu_start.sh: Python harness with HMP cont, UART Unix-socket reader, pw_tokenizer detokenization, SIGTERM-safe cleanup. Target glue: - target_type=qemu added; entry.rs .fake_rom_ext cfg widened from `verilator` to `any(verilator, qemu)`. Silicon ROM delta verified at 0 bytes. - config.rs gets qemu SYSTEM_CLOCK_HZ = 24_000_000 (matches opentitan/sw/device/lib/arch/device_sim_qemu.c). - opentitan_runner.bzl gains interface="qemu" branch with _QEMU_ATTRS separated from _BASE_ATTRS to avoid testonly contamination of non-test opentitan_runner targets. interface attr gained values=[...] constraint. CI: new earlgrey_qemu_tests workflow added to workflows.json, wired into ci group; ci_tests filters now exclude -qemu to prevent double-counting. Signed-off-by: Miguel Osorio <miguelosorio@google.com>
diff --git a/.gitignore b/.gitignore index e90698a..1195f04 100644 --- a/.gitignore +++ b/.gitignore
@@ -127,3 +127,7 @@ # Build scripts build.rs.bk + +# Python bytecode +__pycache__/ +*.pyc
diff --git a/MODULE.bazel b/MODULE.bazel index 065bb35..332ce62 100644 --- a/MODULE.bazel +++ b/MODULE.bazel
@@ -130,3 +130,17 @@ hsm = use_extension("//third_party/hsm:extensions.bzl", "hsm") use_repo(hsm, "cloud_kms_hsm") + +# ── lowRISC QEMU (ot-earlgrey machine for earlgrey QEMU tests) ── +# Tag v10.2.0-2026-01-15, sha256 9e97f93b... (locked in third_party/qemu/extensions.bzl). +qemu = use_extension("//third_party/qemu:extensions.bzl", "qemu") +use_repo(qemu, "qemu_opentitan", "qemu_opentitan_src") + +# ── Python deps for QEMU tooling scripts (cfggen, flashgen) ── +pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip") +pip.parse( + hub_name = "openprot_python_deps", + python_version = "3.11", + requirements_lock = "//third_party/qemu:requirements.txt", +) +use_repo(pip, "openprot_python_deps")
diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index a3a6cfa..8a91f25 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock
@@ -552,6 +552,41 @@ ] } }, + "//third_party/qemu:extensions.bzl%qemu": { + "general": { + "bzlTransitiveDigest": "vYbS6zbBSUEYrJr8ZFwTQQ36P+gfjwYHsC6c3Of1Mbw=", + "usagesDigest": "sd4NrZaNp1CVFJ9pAaTO/LvX7gn0a5KpGnmBklHnLK4=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "qemu_opentitan_src": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://github.com/lowRISC/qemu/releases/download/v10.2.0-2026-01-15/qemu-ot-earlgrey-v10.2.0-2026-01-15-x86_64-unknown-linux-gnu.tar.gz", + "build_file": "@@//third_party/qemu:BUILD.qemu_opentitan.bazel", + "sha256": "9e97f93b09912c904e84f06571e7b49023ccb405dd3caa232ad1e82a3f7b381c", + "patch_cmds": [ + "touch .this.is.the.archive" + ] + } + }, + "qemu_opentitan": { + "repoRuleId": "@@//third_party/qemu:extensions.bzl%qemu_bazel_build_or_forward", + "attributes": { + "qemu_src": "@@+qemu+qemu_opentitan_src//:qemu_src" + } + } + }, + "recordedRepoMappingEntries": [ + [ + "", + "bazel_tools", + "bazel_tools" + ] + ] + } + }, "@@aspect_rules_esbuild+//esbuild:extensions.bzl%esbuild": { "general": { "bzlTransitiveDigest": "j6sp9ShWeG247Tw+aD0AaskiS6b/Dz94ci6OQrXvF5o=", @@ -2149,7 +2184,7 @@ "@@rules_libusb+//:extensions.bzl%libusb": { "general": { "bzlTransitiveDigest": "6s4LH70dpiebZQRs79OFjy6WpIaYps0B2+QpeixbTao=", - "usagesDigest": "mS8IemUWNRRKkBagACH+EOS/mM9aBoJISPKaD2aTfbI=", + "usagesDigest": "lbrZG+wyaTNaFb643SLJ9csKroS3S04dyqjShKEgymY=", "recordedFileInputs": {}, "recordedDirentsInputs": {}, "envVariables": {}, @@ -16617,8 +16652,8 @@ }, "@@rules_rust+//crate_universe/private:internal_extensions.bzl%cu_nr": { "general": { - "bzlTransitiveDigest": "C5Hj0oKc853CHNTtSUTsu5NL9s0l9VNnFPRu5dUvN2o=", - "usagesDigest": "v4We18mWSPeKV4GPp9Gne78W+jZOgP2pC1i4UN9br1g=", + "bzlTransitiveDigest": "KrH+YrNOYgkslLKDNwjzG0KdYsMKdr/q6Ogs0S7tx3Q=", + "usagesDigest": "ZmL90WEq2B6/NJ8rtHAqdnDPn+/9xG/GWR5K4UU4tyo=", "recordedFileInputs": {}, "recordedDirentsInputs": {}, "envVariables": {}, @@ -16682,7 +16717,7 @@ "binary": "cargo-bazel", "cargo_lockfile": "@@rules_rust+//crate_universe:Cargo.lock", "cargo_toml": "@@rules_rust+//crate_universe:Cargo.toml", - "version": "1.86.0", + "version": "1.95.0", "timeout": 900, "rust_toolchain_cargo_template": "@rust_host_tools//:bin/{tool}", "rust_toolchain_rustc_template": "@rust_host_tools//:bin/{tool}",
diff --git a/presubmit/license.py b/presubmit/license.py index cfc9ce9..055d4fe 100644 --- a/presubmit/license.py +++ b/presubmit/license.py
@@ -83,6 +83,7 @@ # keep-sorted: start r"\bdocs/src/third_party/.*", r"\bthird_party/.*\.bazelrc$", + r"\bthird_party/qemu/.*", # keep-sorted: end # Diff/Patch files # keep-sorted: start
diff --git a/target/earlgrey/BUILD.bazel b/target/earlgrey/BUILD.bazel index 4dd3c83..43cafb5 100644 --- a/target/earlgrey/BUILD.bazel +++ b/target/earlgrey/BUILD.bazel
@@ -38,6 +38,7 @@ values = [ "silicon", "fpga", + "qemu", "verilator", ], ) @@ -53,6 +54,11 @@ ) config_setting( + name = "qemu", + flag_values = {":target_type": "qemu"}, +) + +config_setting( name = "verilator", flag_values = {":target_type": "verilator"}, ) @@ -71,6 +77,7 @@ ], crate_features = select({ ":fpga": ["fpga"], + ":qemu": ["qemu"], ":silicon": ["silicon"], ":verilator": ["verilator"], "//conditions:default": [], @@ -136,6 +143,7 @@ srcs = ["config.rs"], crate_features = select({ ":fpga": ["fpga"], + ":qemu": ["qemu"], ":silicon": ["silicon"], ":verilator": ["verilator"], "//conditions:default": [],
diff --git a/target/earlgrey/README.md b/target/earlgrey/README.md index 64312f2..38deb0a 100644 --- a/target/earlgrey/README.md +++ b/target/earlgrey/README.md
@@ -31,6 +31,11 @@ bazelisk run //target/earlgrey/ipc/user:ipc_runner_verilator + .. tab-item:: QEMU + .. code-block:: console + + bazelisk run //target/earlgrey/tests/ipc/user:ipc_runner_qemu_test + ## Testing To run the unittests, run @@ -47,6 +52,41 @@ bazelisk test --test_output=all --cache_test_results=no //target/earlgrey/unittest_runner:hyper340_test + .. tab-item:: QEMU + .. code-block:: console + + bazelisk test //target/earlgrey/tests/ipc/user:ipc_runner_qemu_test + + This test runs in approximately 2.6 seconds under QEMU. + It is included in the ``earlgrey_qemu_tests`` workflow and runs on every PR via the ``ci`` group. + +## Adding a QEMU lane to a new earlgrey test + +1. **Confirm an existing verilator target** — find the ``opentitan_test`` (or ``opentitan_runner``) rule + with ``interface = "verilator"`` in the target's ``BUILD.bazel``. + +2. **Add a sibling QEMU test target** in the same ``BUILD.bazel``: + + .. code-block:: python + + opentitan_test( + name = "<test>_qemu_test", + interface = "qemu", + tags = ["qemu"], + target = ":<image_target>", + timeout = "moderate", + ) + +3. **Omit** ``ecdsa_key``, ``spx_key``, and ``nightly_test`` — those are not needed for QEMU tests. + +4. **No workflow wiring needed.** The ``earlgrey_qemu_tests`` build uses + ``--build_tag_filters=+qemu --test_tag_filters=+qemu`` and targets ``//target/earlgrey/...``, + so any ``opentitan_test`` target tagged ``qemu`` under ``target/earlgrey/`` is automatically + picked up and run on every PR via the ``ci`` group. + +5. **Canonical example:** ``target/earlgrey/tests/ipc/user/BUILD.bazel`` — see the + ``ipc_runner_qemu_test`` target. + ## VS Code setup .. _rust-analyzer: https://rust-analyzer.github.io/
diff --git a/target/earlgrey/config.rs b/target/earlgrey/config.rs index 0125590..80f9944 100644 --- a/target/earlgrey/config.rs +++ b/target/earlgrey/config.rs
@@ -33,6 +33,8 @@ const SYSTEM_CLOCK_HZ: u64 = 6_000_000; #[cfg(feature = "verilator")] const SYSTEM_CLOCK_HZ: u64 = 125_000; + #[cfg(feature = "qemu")] + const SYSTEM_CLOCK_HZ: u64 = 24_000_000; } impl RiscVKernelConfigInterface for KernelConfig {
diff --git a/target/earlgrey/entry.rs b/target/earlgrey/entry.rs index 99ec235..d7b39f3 100644 --- a/target/earlgrey/entry.rs +++ b/target/earlgrey/entry.rs
@@ -73,20 +73,25 @@ " ); -#[cfg(feature = "verilator")] +// SECURITY: This fake ROM_EXT trampoline bypasses ROM_EXT signature validation. +// It must never run on silicon. It is gated behind the non-default "verilator" +// and "qemu" features, which are only active for simulation targets. +#[cfg(any(feature = "verilator", feature = "qemu"))] global_asm!( r#" /* + * Fake ROM_EXT trampoline shared by verilator and QEMU simulation targets. + * * We don't want to have separate build targets or linker script templating - * to run under verilator (e.g. to set the origin to 0x2000_0000 instead of - * the normal value of 0x2001_0000). + * to run under verilator or QEMU (e.g. to set the origin to 0x2000_0000 + * instead of the normal value of 0x2001_0000). * - * Instead, when building for verilator, we add a ".fake_rom_ext" section that - * the linker script locates at 0x2000_0000. In this section, we construct - * the most trivial of ROM_EXT manifests and set the entrypoint to the start - * offset of the kernel. + * Instead, when building for verilator or QEMU, we add a ".fake_rom_ext" + * section that the linker script locates at 0x2000_0000. In this section, + * we construct the most trivial of ROM_EXT manifests and set the entrypoint + * to the start offset of the kernel. * - * The verilator test ROM doesn't do any validation of the manifest header. + * The test ROM doesn't do any validation of the manifest header. * We don't _need_ to set any version numbers or magic identifier words. * * We set the following:
diff --git a/target/earlgrey/syscall_latency/BUILD.bazel b/target/earlgrey/syscall_latency/BUILD.bazel index edf8573..1db2c52 100644 --- a/target/earlgrey/syscall_latency/BUILD.bazel +++ b/target/earlgrey/syscall_latency/BUILD.bazel
@@ -18,6 +18,7 @@ codegen_crate_name = "syscall_latency_codegen", crate_features = select({ "//target/earlgrey:fpga": ["fpga"], + "//target/earlgrey:qemu": ["qemu"], "//target/earlgrey:silicon": ["silicon"], "//target/earlgrey:verilator": ["verilator"], "//conditions:default": [],
diff --git a/target/earlgrey/syscall_latency/main.rs b/target/earlgrey/syscall_latency/main.rs index d989989..f96026d 100644 --- a/target/earlgrey/syscall_latency/main.rs +++ b/target/earlgrey/syscall_latency/main.rs
@@ -14,6 +14,8 @@ const IO_CLOCK_HZ: u64 = 6_000_000; #[cfg(feature = "verilator")] const IO_CLOCK_HZ: u64 = 125_000; +#[cfg(feature = "qemu")] +const IO_CLOCK_HZ: u64 = 24_000_000; #[inline(always)] fn rv_timer_value(rv_timer: &RvTimer) -> u64 {
diff --git a/target/earlgrey/tests/drivers/gpio/BUILD.bazel b/target/earlgrey/tests/drivers/gpio/BUILD.bazel index 12f26dc..d1e737b 100644 --- a/target/earlgrey/tests/drivers/gpio/BUILD.bazel +++ b/target/earlgrey/tests/drivers/gpio/BUILD.bazel
@@ -90,3 +90,6 @@ ], target = ":gpio", ) + +# TODO: QEMU ot-earlgrey machine has no ot_gpio device model — DIRECT_OUT reads +# return 0. Re-enable when upstream lowRISC QEMU lands ot_gpio emulation.
diff --git a/target/earlgrey/tests/ipc/user/BUILD.bazel b/target/earlgrey/tests/ipc/user/BUILD.bazel index c2f2ac8..0d7946e 100644 --- a/target/earlgrey/tests/ipc/user/BUILD.bazel +++ b/target/earlgrey/tests/ipc/user/BUILD.bazel
@@ -111,3 +111,11 @@ ], target = ":ipc", ) + +opentitan_test( + name = "ipc_runner_qemu_test", + timeout = "moderate", + interface = "qemu", + tags = ["qemu"], + target = ":ipc", +)
diff --git a/target/earlgrey/tests/threads/kernel/BUILD.bazel b/target/earlgrey/tests/threads/kernel/BUILD.bazel index e8d84e2..63ba187 100644 --- a/target/earlgrey/tests/threads/kernel/BUILD.bazel +++ b/target/earlgrey/tests/threads/kernel/BUILD.bazel
@@ -78,6 +78,14 @@ target = ":threads", ) +opentitan_test( + name = "threads_runner_qemu_test", + timeout = "moderate", + interface = "qemu", + tags = ["qemu"], + target = ":threads", +) + filegroup( name = "system_config", srcs = ["system.json5"],
diff --git a/target/earlgrey/tests/uart/BUILD.bazel b/target/earlgrey/tests/uart/BUILD.bazel index 8b07ea4..ae5b0a4 100644 --- a/target/earlgrey/tests/uart/BUILD.bazel +++ b/target/earlgrey/tests/uart/BUILD.bazel
@@ -141,3 +141,7 @@ ], target = ":uart", ) + +# TODO: QEMU ot-earlgrey machine doesn't fire UART1 RX-watermark interrupts — +# the loopback test hangs waiting for an interrupt that never arrives. +# Re-enable when lowRISC QEMU validates interrupt-driven UART under ot-earlgrey.
diff --git a/target/earlgrey/tooling/BUILD.bazel b/target/earlgrey/tooling/BUILD.bazel index 10a2f11..f2bfe64 100644 --- a/target/earlgrey/tooling/BUILD.bazel +++ b/target/earlgrey/tooling/BUILD.bazel
@@ -1,8 +1,10 @@ # Licensed under the Apache-2.0 license # SPDX-License-Identifier: Apache-2.0 +load("@openprot_python_deps//:requirements.bzl", "requirement") load("@pigweed//pw_build:pw_py_importable_runfile.bzl", "pw_py_importable_runfile") load("@rules_python//python:defs.bzl", "py_binary") +load("//target/earlgrey/tooling:qemu.bzl", "qemu_cfg", "qemu_otp") pw_py_importable_runfile( name = "opentitantool-runfiles", @@ -61,6 +63,18 @@ import_location = "opentitan.fake_keys.app_prod_ecdsa", ) +# Canonical per-package cfg/otp targets; also serve as smoke tests for the +# qemu_cfg and qemu_otp rules (all defaults). +qemu_cfg( + name = "qemu_earlgrey_cfg", + testonly = True, +) + +qemu_otp( + name = "qemu_rma_otp", + testonly = True, +) + py_binary( name = "opentitan_runner", srcs = [ @@ -81,3 +95,31 @@ "@rules_python//python/runfiles", ], ) + +exports_files(["qemu_start.sh"]) + +py_binary( + name = "qemu_runner", + testonly = True, + srcs = ["qemu_runner.py"], + data = [":qemu_start.sh"], + main = "qemu_runner.py", + deps = [ + "@pigweed//pw_tokenizer/py:detokenize", + ], +) + +py_binary( + name = "qemu_cfg_gen", + srcs = ["qemu_cfg_gen.py"], + main = "qemu_cfg_gen.py", +) + +py_binary( + name = "qemu_constants_dumper", + srcs = ["qemu_constants_dumper.py"], + deps = [ + "//third_party/qemu:ot", + requirement("hjson"), + ], +)
diff --git a/target/earlgrey/tooling/opentitan_runner.bzl b/target/earlgrey/tooling/opentitan_runner.bzl index fcde8db..81042bb 100644 --- a/target/earlgrey/tooling/opentitan_runner.bzl +++ b/target/earlgrey/tooling/opentitan_runner.bzl
@@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 load("@pigweed//pw_kernel/tooling:system_image.bzl", "SystemImageInfo") +load("//target/earlgrey/tooling:qemu.bzl", "gen_flash") load("//target/earlgrey/tooling/signing:defs.bzl", "KeySetInfo", "sign_binary") def _target_type_transition_impl(_, attr): @@ -9,6 +10,8 @@ return {"//target/earlgrey:target_type": "fpga"} if attr.interface == "verilator": return {"//target/earlgrey:target_type": "verilator"} + if attr.interface == "qemu": + return {"//target/earlgrey:target_type": "qemu"} return {"//target/earlgrey:target_type": "silicon"} @@ -39,6 +42,60 @@ if hasattr(ctx.attr, "exit_failure") and ctx.attr.exit_failure: optional_args += " --exit-failure='{}'".format(ctx.attr.exit_failure) + if ctx.attr.interface == "qemu": + flash_file = gen_flash( + ctx, + flashgen = ctx.attr._flashgen, + firmware_bin = bin_file, + firmware_elf = elf_file, + ) + + cfg_file = ctx.attr._qemu_cfg[DefaultInfo].files.to_list()[0] + otp_file = ctx.attr._qemu_otp[DefaultInfo].files.to_list()[0] + qemu_bin = ctx.file._qemu_bin + qemu_rom = ctx.file._qemu_rom + qemu_start = ctx.file._qemu_start + qemu_runner_exe = ctx.executable._qemu_runner + + run_script = ctx.actions.declare_file(ctx.attr.name + ".sh") + ctx.actions.write( + output = run_script, + is_executable = True, + content = """#!/bin/bash +{runner} \ + --qemu-start {qemu_start} \ + --qemu-bin {qemu_bin} \ + --qemu-config {cfg} \ + --qemu-rom {rom} \ + --qemu-otp {otp} \ + --qemu-flash {flash} \ + --firmware-elf {elf} \ + --icount 6 \ + --timeout-seconds 120 \ + {optional_args} +""".format( + runner = qemu_runner_exe.short_path, + qemu_start = qemu_start.short_path, + qemu_bin = qemu_bin.short_path, + cfg = cfg_file.short_path, + rom = qemu_rom.short_path, + otp = otp_file.short_path, + flash = flash_file.short_path, + elf = elf_file.short_path, + optional_args = optional_args, + ), + ) + + qemu_runner_runfiles = ctx.attr._qemu_runner[DefaultInfo].default_runfiles.files + + return [DefaultInfo( + runfiles = ctx.runfiles( + files = [elf_file, bin_file, qemu_bin, qemu_rom, qemu_start, cfg_file, otp_file, flash_file], + transitive_files = qemu_runner_runfiles, + ), + executable = run_script, + )] + if ctx.attr.interface == "hyper310" or ctx.attr.interface == "hyper340": load_bitstream = "--load-bitstream" mechanism = "--mechanism=bootstrap" @@ -83,7 +140,7 @@ doc = "ECDSA public key to validate this image", ), "interface": attr.string( - doc = "The interface to use.", + values = ["hyper310", "hyper340", "qemu", "teacup", "verilator"], mandatory = True, ), "manifest": attr.label( @@ -115,6 +172,43 @@ ), } +# Hidden attrs for the qemu interface. Kept separate from _BASE_ATTRS because +# several of these deps are testonly=True; merging them into _BASE_ATTRS would +# prevent non-test opentitan_runner targets from loading without errors. +_QEMU_ATTRS = { + "_flashgen": attr.label( + executable = True, + cfg = "exec", + default = "//third_party/qemu:flashgen", + ), + "_qemu_bin": attr.label( + allow_single_file = True, + cfg = "exec", + default = "//third_party/qemu:qemu-system-riscv32", + ), + "_qemu_cfg": attr.label( + default = "//target/earlgrey/tooling:qemu_earlgrey_cfg", + ), + "_qemu_otp": attr.label( + default = "//target/earlgrey/tooling:qemu_rma_otp", + ), + "_qemu_rom": attr.label( + allow_single_file = True, + cfg = "exec", + default = "@opentitan_devbundle//:earlgrey/test_rom/test_rom_sim_verilator.elf", + ), + "_qemu_runner": attr.label( + executable = True, + cfg = "exec", + default = "//target/earlgrey/tooling:qemu_runner", + ), + "_qemu_start": attr.label( + allow_single_file = True, + cfg = "exec", + default = "//target/earlgrey/tooling:qemu_start.sh", + ), +} + opentitan_runner = rule( implementation = _opentitan_runner_impl, executable = True, @@ -124,7 +218,7 @@ opentitan_test = rule( implementation = _opentitan_runner_impl, test = True, - attrs = _BASE_ATTRS | { + attrs = _BASE_ATTRS | _QEMU_ATTRS | { "exit_failure": attr.string( default = "FAIL: .+\\n", doc = "The regex to look for in the output to determine failure.",
diff --git a/target/earlgrey/tooling/qemu.bzl b/target/earlgrey/tooling/qemu.bzl new file mode 100644 index 0000000..b411b05 --- /dev/null +++ b/target/earlgrey/tooling/qemu.bzl
@@ -0,0 +1,211 @@ +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 +# +# Three pure-function Bazel rules for generating QEMU backing files: +# qemu_cfg — QEMU readconfig INI (from RTL secrets via cfggen.py) +# qemu_otp — QEMU OTP raw image (from a .vmem via otptool.py) +# qemu_flash — QEMU flash image (from a firmware .bin via flashgen.py) +# +# Ported from opentitan/rules/opentitan/qemu.bzl. Stripped: ExecEnvInfo, +# sim_qemu, qemu_params, _test_dispatch, _transform. Those belong to +# opentitan's exec-env system which openprot does not use (see plan §"exec_env +# is unused"). +# +# Security note (build-side): all file paths flow through ctx.actions.run() +# argument lists, never through shell interpolation. Attack surface here is +# Bazel rule invocation by BUILD authors, not external input. No runtime +# user-controlled data crosses this boundary. + +# --------------------------------------------------------------------------- +# Helper functions — call these from within a rule's own ctx to generate a +# single backing file. Each takes explicit arguments rather than ctx +# attribute lookup, so callers (including opentitan_runner.bzl) can supply +# values from their own ctx without attribute-name collisions. +# --------------------------------------------------------------------------- + +def gen_cfg(ctx, cfggen, qemu_constants): + """Generate a QEMU readconfig INI file containing OpenTitan RTL secrets from JSON. + + Args: + ctx: Rule context. + cfggen: Target providing qemu_cfg_gen.py executable (DefaultInfo). + qemu_constants: File — qemu_constants.json. + + Returns: + The declared output File (.ini). + """ + out = ctx.actions.declare_file(ctx.label.name + ".ini") + ctx.actions.run( + inputs = [qemu_constants], + outputs = [out], + executable = cfggen[DefaultInfo].files_to_run, + arguments = [ + "--json", + qemu_constants.path, + "--out", + out.path, + ], + mnemonic = "QemuCfgGen", + ) + return out + +def gen_otp(ctx, otptool, vmem): + """Generate a QEMU-compatible raw OTP image from a .vmem file. + + Args: + ctx: Rule context. + otptool: Target providing otptool.py executable (DefaultInfo). + vmem: File — OTP .vmem image (e.g. img_rma.vmem). + + Returns: + The declared output File (.raw). + """ + out = ctx.actions.declare_file(ctx.label.name + ".raw") + ctx.actions.run( + inputs = [vmem], + outputs = [out], + executable = otptool[DefaultInfo].files_to_run, + arguments = [ + "-m", + vmem.path, + "-r", + out.path, + "-k", + "otp", + ], + mnemonic = "QemuOtpGen", + ) + return out + +def gen_flash(ctx, flashgen, firmware_bin, firmware_elf = None): + """Generate a QEMU-compatible flash backing image from a firmware binary. + + NOTE: only single-bank flash images are supported (mirrors opentitan). + The firmware binary is placed at offset 0x0 of the flash image. + + Args: + ctx: Rule context. + flashgen: Target providing flashgen.py executable (DefaultInfo). + firmware_bin: File — flat binary to splice into the flash image. + firmware_elf: File or None — ELF for mtime sanity checks (unused by + QEMU itself; pass None to skip checks via --unsafe-elf). + + Returns: + The declared output File (.qemu_bin). + """ + out = ctx.actions.declare_file(ctx.label.name + ".qemu_bin") + args = [ + "-t", + "{}@0x0".format(firmware_bin.path), + ] + + # When an ELF is present, pass --ignore-time so Bazel's mtime rewriting + # does not cause spurious "binary older than ELF" failures. When absent, + # pass --unsafe-elf to skip ELF size validation entirely. + if firmware_elf: + args += ["--ignore-time"] + else: + args += ["--unsafe-elf"] + + args += [out.path] + + inputs = [firmware_bin] + if firmware_elf: + inputs.append(firmware_elf) + + ctx.actions.run( + inputs = inputs, + outputs = [out], + executable = flashgen[DefaultInfo].files_to_run, + arguments = args, + mnemonic = "QemuFlashGen", + ) + return out + +# --------------------------------------------------------------------------- +# Standalone rules — invoke these directly from a BUILD file to materialize +# the backing files as named Bazel targets. +# --------------------------------------------------------------------------- + +def _qemu_cfg_impl(ctx): + out = gen_cfg( + ctx, + cfggen = ctx.attr.cfggen, + qemu_constants = ctx.file.qemu_constants, + ) + return [DefaultInfo(files = depset([out]))] + +qemu_cfg = rule( + implementation = _qemu_cfg_impl, + attrs = { + "cfggen": attr.label( + executable = True, + cfg = "exec", + allow_files = True, + default = Label("//target/earlgrey/tooling:qemu_cfg_gen"), + doc = "qemu_cfg_gen.py py_binary target.", + ), + "qemu_constants": attr.label( + allow_single_file = True, + default = Label("//third_party/opentitan_rtl:qemu_constants"), + doc = "qemu_constants.json — QEMU constants in JSON format.", + ), + }, +) + +def _qemu_otp_impl(ctx): + out = gen_otp( + ctx, + otptool = ctx.attr.otptool, + vmem = ctx.file.vmem, + ) + return [DefaultInfo(files = depset([out]))] + +qemu_otp = rule( + implementation = _qemu_otp_impl, + attrs = { + "otptool": attr.label( + executable = True, + cfg = "exec", + allow_files = True, + default = Label("//third_party/qemu:otptool"), + doc = "otptool.py py_binary target.", + ), + "vmem": attr.label( + allow_single_file = True, + default = Label("@opentitan_devbundle//:earlgrey/otp/img_rma.24.vmem"), + doc = "OTP .vmem image to convert (defaults to RMA image).", + ), + }, +) + +def _qemu_flash_impl(ctx): + out = gen_flash( + ctx, + flashgen = ctx.attr.flashgen, + firmware_bin = ctx.file.firmware_bin, + firmware_elf = ctx.file.firmware_elf, + ) + return [DefaultInfo(files = depset([out]))] + +qemu_flash = rule( + implementation = _qemu_flash_impl, + attrs = { + "firmware_bin": attr.label( + allow_single_file = True, + mandatory = True, + doc = "Flat firmware binary to splice into the flash image at offset 0x0.", + ), + "firmware_elf": attr.label( + allow_single_file = True, + doc = "ELF counterpart for mtime sanity checks (optional; omit for unsigned builds).", + ), + "flashgen": attr.label( + executable = True, + cfg = "exec", + allow_files = True, + default = Label("//third_party/qemu:flashgen"), + doc = "flashgen.py py_binary target.", + ), + }, +)
diff --git a/target/earlgrey/tooling/qemu_cfg_gen.py b/target/earlgrey/tooling/qemu_cfg_gen.py new file mode 100755 index 0000000..b3927be5 --- /dev/null +++ b/target/earlgrey/tooling/qemu_cfg_gen.py
@@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 + +import argparse +import json +import sys +from configparser import ConfigParser +from typing import TextIO, Optional + +# Helper to match cfggen's alphanumeric-ish sorting if needed. +# cfggen uses 'alphanum_key' from 'ot.util.misc'. +# Let's see if standard sorting is enough. +# The INI file had: +# digest_const = ... +# digest_iv = ... +# flash_addr_const = ... +# flash_addr_iv = ... +# flash_data_const = ... +# flash_data_iv = ... +# inv_default_part_5 = ... +# inv_default_part_6 = ... +# inv_default_part_7 = ... +# inv_default_part_8 = ... +# inv_default_part_9 = ... +# inv_default_part_10 = ... +# scrmbl_key = ... +# secret0_scramble_key = ... +# Standard sorting of these keys: +# ['digest_const', 'digest_iv', 'flash_addr_const', 'flash_addr_iv', 'flash_data_const', 'flash_data_iv', 'inv_default_part_10', 'inv_default_part_5', 'inv_default_part_6', 'inv_default_part_7', 'inv_default_part_8', 'inv_default_part_9', 'scrmbl_key', 'secret0_scramble_key'] +# Wait! Standard sorting puts 'inv_default_part_10' BEFORE 'inv_default_part_5'! +# But in the generated INI, 'inv_default_part_10' was AFTER 'inv_default_part_9': +# inv_default_part_5 = ... +# inv_default_part_6 = ... +# ... +# inv_default_part_10 = ... +# This is because cfggen uses 'alphanum_key' which does natural sorting (numeric sorting for numbers). +# We should implement natural sorting to match exactly! + + +def try_int(s: str) -> getattr(str, "__class__", object): # type: ignore + try: + return int(s) + except ValueError: + return s + + +def alphanum_key(s: str) -> list: + import re + + return [try_int(c) for c in re.split(r"(\d+)", s)] + + +def add_pair(cfg: ConfigParser, devname: str, kname: str, value: str) -> None: + if value: + if f'ot_device "{devname}"' not in cfg: + cfg[f'ot_device "{devname}"'] = {} + cfg[f'ot_device "{devname}"'][f" {kname}"] = f'"{value}"' + + +def generate_roms( + cfg: ConfigParser, rom_ctrl: dict, socid: Optional[str] = None, count: int = 1 +) -> None: + for cnt in range(count): + for rom, data in rom_ctrl.items(): + nameargs = ["ot-rom_ctrl"] + if socid: + if count > 1: + nameargs.append(f"{socid}{cnt}") + else: + nameargs.append(socid) + if rom != "null": + nameargs.append(f"rom{rom}") + romname = ".".join(nameargs) + for kname in sorted(data.keys(), key=alphanum_key): + val = data[kname] + add_pair(cfg, romname, kname, val) + + +def generate_otp( + cfg: ConfigParser, otp_ctrl: dict, variant: str, socid: Optional[str] = None +) -> None: + nameargs = [f"ot-otp-{variant}"] + if socid: + nameargs.append(socid) + otpname = ".".join(nameargs) + for kname in sorted(otp_ctrl.keys(), key=alphanum_key): + val = otp_ctrl[kname] + add_pair(cfg, otpname, kname, val) + + +def generate_lc_ctrl( + cfg: ConfigParser, lc_ctrl: dict, lc_states: dict, socid: Optional[str] = None +) -> None: + nameargs = ["ot-lc_ctrl"] + if socid: + nameargs.append(socid) + lcname = ".".join(nameargs) + + lcdata = {} + # In cfggen, states are added first, then other constants, then sorted. + # But ConfigParser preserves insertion order in Python 3.7+. + # If we want to match exact sorting in output, we should collect all pairs first, then sort them. + # Actually, cfggen did: + # for name, states in self._lcconst.states.items(): + # self.add_pair(lcname, lcdata, f'{name}_first', states[0]) ... + # for kname, value in lc_ctrl.items(): + # self.add_pair(lcname, lcdata, kname, value) + # lcdata = dict(sorted(lcdata.items())) + # So the final keys are sorted. + + pairs = {} + for name, states in lc_states.items(): + pairs[f"{name}_first"] = states[0] + pairs[f"{name}_last"] = states[1] + for kname, value in lc_ctrl.items(): + pairs[kname] = value + + for kname in sorted(pairs.keys(), key=alphanum_key): + add_pair(cfg, lcname, kname, pairs[kname]) + + +def generate_key_mgr( + cfg: ConfigParser, keymgr: dict, socid: Optional[str] = None +) -> None: + if not keymgr: + return + keymgr_name = keymgr.get("name", "keymgr") + values = keymgr.get("values", {}) + nameargs = [f"ot-{keymgr_name}"] + if socid: + nameargs.append(socid) + kmname = ".".join(nameargs) + for kname in sorted(values.keys(), key=alphanum_key): + value = values[kname] + add_pair(cfg, kmname, kname, value) + + +def generate_ast( + cfg: ConfigParser, top_clocks: dict, variant: str, socid: Optional[str] = None +) -> None: + nameargs = [f"ot-ast-{variant}"] + if socid: + nameargs.append(socid) + astname = ".".join(nameargs) + + topclockstr = ",".join(f'{name}:{c["frequency"]}' for name, c in top_clocks.items()) + aonclockstr = ",".join(name for name, c in top_clocks.items() if c["aon"]) + add_pair(cfg, astname, "topclocks", topclockstr) + add_pair(cfg, astname, "aonclocks", aonclockstr) + + +def generate_clkmgr( + cfg: ConfigParser, + top_clocks: dict, + sub_clocks: dict, + clock_groups: dict, + socid: Optional[str] = None, +) -> None: + nameargs = ["ot-clkmgr"] + if socid: + nameargs.append(socid) + clkname = ".".join(nameargs) + + refclocks = [name for name, c in top_clocks.items() if c["ref"]] + if len(refclocks) > 1: + raise ValueError(f'Multiple reference clocks detected: {", ".join(refclocks)}') + + clkrefname = refclocks[0] if refclocks else None + clfrefval = top_clocks.get(clkrefname) if clkrefname else None + + topclockdefs = [] + # Sort clocks to match cfggen output (which seems to match input order, but let's sort to be deterministic if cfggen sorted them? + # cfggen: `for ckname, ckval in self._top_clocks.items():` + # In cfggen, self._top_clocks is populated in load_config, preserving insertion order from HJSON. + # Our JSON also preserves insertion order (Python 3.7+ dict). + # Let's see if sorting is needed. The INI had: "main:500,io:480,usb:240,aon:1". + # In HJSON, clocks are defined in some order. + # If we want to match exactly, we should preserve order. + # In Python, dict preserves insertion order, so iterating over top_clocks.items() preserves order. + for ckname, ckval in top_clocks.items(): + if clfrefval: + clkratio = ckval["frequency"] // clfrefval["frequency"] + else: + clkratio = 1 + topclockdefs.append(f"{ckname}:{clkratio}") + topclockstr = ",".join(topclockdefs) + + # subclocks in INI: "io_div2:io:2,io_div4:io:4,aes:main:1,hmac:main:1,kmac:main:1,otbn:main:1" + # This also preserves insertion order from HJSON. + subclockstr = ",".join( + f'{name}:{c["source"]}:{c["div"]}' for name, c in sub_clocks.items() + ) + + # groups in INI: "powerup:aon+io+io_div2+io_div4+main+usb,trans:aes+hmac+kmac+otbn,..." + # The group sources are sorted: `"+".join(sorted(g.sources))` + # But the groups themselves preserve insertion order. + groupstr = ",".join( + f'{name}:{"+".join(sorted(g["sources"], key=alphanum_key))}' + for name, g in clock_groups.items() + ) + + swcgstr = ",".join(name for name, g in clock_groups.items() if g["sw_cg"]) + hintstr = ",".join(name for name, g in clock_groups.items() if g["hint"]) + + add_pair(cfg, clkname, "topclocks", topclockstr) + if clkrefname: + add_pair(cfg, clkname, "refclock", clkrefname) + add_pair(cfg, clkname, "subclocks", subclockstr) + add_pair(cfg, clkname, "groups", groupstr) + add_pair(cfg, clkname, "swcg", swcgstr) + add_pair(cfg, clkname, "hint", hintstr) + + +def generate_pwrmgr( + cfg: ConfigParser, top_clocks: dict, socid: Optional[str] = None +) -> None: + nameargs = ["ot-pwrmgr"] + if socid: + nameargs.append(socid) + pwrname = ".".join(nameargs) + + clockstr = ",".join(name for name, c in top_clocks.items() if not c["aon"]) + add_pair(cfg, pwrname, "clocks", clockstr) + + +def main(): + parser = argparse.ArgumentParser( + description="Regenerate QEMU config from JSON constants." + ) + parser.add_argument("--json", required=True, help="Input JSON file with constants") + parser.add_argument("--out", required=True, help="Output INI file") + args = parser.parse_args() + + with open(args.json, "r") as f: + data = json.load(f) + + cfg = ConfigParser() + + variant = data["variant"] + + rom_ctrl = data.get("rom_ctrl", {}) + generate_roms(cfg, rom_ctrl) + + otp_ctrl = data.get("otp_ctrl", {}) + generate_otp(cfg, otp_ctrl, variant) + + lc_ctrl = data.get("lc_ctrl", {}) + lc_states = data.get("lc_states", {}) + generate_lc_ctrl(cfg, lc_ctrl, lc_states) + + keymgr = data.get("keymgr", {}) + generate_key_mgr(cfg, keymgr) + + top_clocks = data.get("top_clocks", {}) + generate_ast(cfg, top_clocks, variant) + + sub_clocks = data.get("sub_clocks", {}) + clock_groups = data.get("clock_groups", {}) + generate_clkmgr(cfg, top_clocks, sub_clocks, clock_groups) + + generate_pwrmgr(cfg, top_clocks) + + with open(args.out, "w") as f: + if data.get("git_version"): + f.write(f'# Generated from OpenTitan commit: {data["git_version"]}\n\n') + cfg.write(f) + + +if __name__ == "__main__": + main()
diff --git a/target/earlgrey/tooling/qemu_constants_dumper.py b/target/earlgrey/tooling/qemu_constants_dumper.py new file mode 100755 index 0000000..e33fe2f --- /dev/null +++ b/target/earlgrey/tooling/qemu_constants_dumper.py
@@ -0,0 +1,564 @@ +#!/usr/bin/env python3 +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 + +"""OpenTitan QEMU configuration dumper to JSON. + + Based on cfggen.py from lowRISC QEMU. +""" + +from argparse import ArgumentParser +from configparser import ConfigParser +from logging import getLogger +from os.path import ( + abspath, + basename, + dirname, + isdir, + isfile, + join as joinpath, + normpath, +) +from traceback import format_exc +from typing import NamedTuple, Optional, TextIO +import sys +import json +import os + +# Robust runfiles path resolution for Bazel +runfiles_dir = os.environ.get("RUNFILES_DIR") +if runfiles_dir: + found = False + for root, dirs, files in os.walk(runfiles_dir): + if root.endswith(os.path.join("python", "qemu")) and "ot" in dirs: + sys.path.append(root) + found = True + break + if not found: + print("Warning: Could not find qemu python path in runfiles", file=sys.stderr) +else: + # Fallback + QEMU_PYPATH = joinpath( + dirname(dirname(dirname(normpath(__file__)))), "python", "qemu" + ) + sys.path.append(QEMU_PYPATH) + +try: + _HJSON_ERROR = None + from hjson import load as hjload +except ImportError as hjson_exc: + _HJSON_ERROR = str(hjson_exc) + + def hjload(*_, **__): # noqa: E301 + """dummy func if HJSON module is not available""" + return {} + + +from ot.lc_ctrl.const import LcCtrlConstants +from ot.otp.const import OtpConstants +from ot.otp.secret import OtpSecretConstants +from ot.top import OpenTitanTop +from ot.util.arg import ArgError +from ot.util.log import configure_loggers +from ot.util.misc import alphanum_key, retrieve_git_version, to_bool + + +OtParamRegex = str +"""Definition of a parameter to seek and how to shorten it.""" + + +class OtClock(NamedTuple): + """Clock definition.""" + + name: str + """Clock signal name.""" + + frequency: int + """Clock frequency in Hz.""" + + aon: bool + """Whether the clock is always on.""" + + ref: bool + """Whether the clock is a reference clock.""" + + +class OtDerivedClock(NamedTuple): + """Clock derived from a top level clock definition.""" + + name: str + """Clock signal name.""" + + source: str + """Clock source signal name.""" + + div: int + """Divider.""" + + +class OtClockGroup(NamedTuple): + """Clock logicial group definition.""" + + name: str + """Group name.""" + + sources: list[str] + """Clock source signal names.""" + + sw_cg: bool + """Whether clock group can be managed by SW.""" + + hint: bool + """Whether clock group can be hinted by SW.""" + + +class OtConfiguration: + """QEMU configuration file generator.""" + + MODULES = { + "rom_ctrl": (True, r"RndCnstScr(.*)"), + "otp_ctrl": (False, r"RndCnst(.*)Init"), + "lc_ctrl": (False, r"RndCnstLcKeymgrDiv(.*)"), + "keymgr": ( + False, + r"RndCnst((?:.*)Seed)", + # The CDI keymgr seed does not match 'RndCnst.*Seed' + r"RndCnst(Cdi)", + ), + "keymgr_dpe": (False, r"RndCnst((?:.*)Seed)"), + } + + TRANSLATIONS = {"keymgr": {"cdi": "cdi_seed"}} + + def __init__(self): + self._log = getLogger("cfggen.cfg") + self._otpconst = OtpConstants() + self._lcconst = LcCtrlConstants() + self._constants: dict[str, dict[Optional[int], dict[str, str]]] = {} + self._top_clocks: dict[str, OtClock] = {} + self._sub_clocks: dict[str, OtDerivedClock] = {} + self._clock_groups: dict[str, OtClockGroup] = {} + self._mod_clocks: dict[str, list[str]] = {} + self._top_name: Optional[str] = None + self._git_version: Optional[str] = None + self._exclusions: dict[str, set[str]] = {} + + @property + def top_name(self) -> Optional[str]: + """Return the name of the top as defined in a configuration file.""" + return self._top_name + + def load_config(self, toppath: str) -> None: + """Load data from HJSON configuration file.""" + assert not _HJSON_ERROR + with open(toppath, "rt") as tfp: + cfg = hjload(tfp, object_pairs_hook=dict) + self._top_name = cfg.get("name") + topbase = basename(toppath) + + self._git_version = retrieve_git_version(toppath) + + for module in cfg.get("module") or []: + modtype = module.get("type") + moddefs = self.MODULES.get(modtype) + if not moddefs: + continue + multi, regexes = moddefs[0], moddefs[1:] + consts = {} + OtpSecretConstants.load_values(module, consts, multi, *regexes) + if not consts: + continue + for cname, tname in self.TRANSLATIONS.get(modtype, {}).items(): + if cname in consts: + consts[tname] = consts.pop(cname) + self._log.debug("Constants for %s loaded from %s", modtype, topbase) + exist = modtype in self._constants + if multi: + if not exist: + self._constants[modtype] = consts + else: + self._constants[modtype].update(consts) + else: + if exist: + raise ValueError(f"Redefinition of {modtype}") + self._constants[modtype] = {None: consts} + + clocks = cfg.get("clocks", {}) + for clock in clocks.get("srcs", []): + name = clock["name"] + aon = to_bool(clock["aon"], False) + ref = to_bool(clock["ref"], False) + freq = int(clock["freq"]) + self._top_clocks[name] = OtClock(name, freq, aon, ref) + for clock in clocks.get("derived_srcs", []): + name = clock["name"] + src = clock["src"] + aon = to_bool(clock["aon"], False) + freq = int(clock["freq"]) + div = int(clock["div"]) + src_clock = self._top_clocks.get(src) + if not src_clock: + raise ValueError(f"Invalid top clock {src} " f"referenced from {name}") + if src_clock.frequency // div != freq: + raise ValueError( + f"Incoherent derived clock {name} frequency: " + f"{src_clock.frequency}/{div} != {freq}" + ) + if aon and not src_clock.aon: + raise ValueError(f"Incoherent derived clock {name} AON") + self._sub_clocks[name] = OtDerivedClock(name, src, div) + clock_names = set(self._top_clocks.keys()) + clock_names.update(set(self._sub_clocks.keys())) + for group in clocks.get("groups", []): + ext = group["src"] == "ext" + if ext: + continue + name = group["name"] + hint = group["sw_cg"] == "hint" + sw_cg = not hint and to_bool(group["sw_cg"], False) + clk_srcs = [] + for clk_name, clk_src in group.get("clocks", {}).items(): + if not hint: + exp_name = f"clk_{clk_src}_{name}" + if clk_name != exp_name: + raise ValueError( + f"Unexpected clock {clk_name} in group" + f" {name} (exp: {exp_name})" + ) + clk_srcs.append(clk_src) + else: + exp_prefix = f"clk_{clk_src}_" + if not clk_name.startswith(exp_prefix): + raise ValueError( + f"Unexpected clock {clk_name} in group" f" {name}" + ) + src_name = clk_name.removeprefix(exp_prefix) + clk_srcs.append(src_name) + if src_name in self._sub_clocks: + raise ValueError(f"Refinition of clock {src_name}") + self._sub_clocks[src_name] = OtDerivedClock(src_name, clk_src, 1) + self._clock_groups[name] = OtClockGroup(name, clk_srcs, sw_cg, hint) + modules = cfg.get("module", []) + mod_clocks = {} + for module in modules: + type_ = module["type"] + if type_ in ("ast", "clkmgr"): + continue + name = module["name"] + clk_srcs = module.get("clock_srcs", {}) + clk_grp = module.get("clock_group", "") + clocks = [] + for clk in clk_srcs.values(): + if isinstance(clk, dict): + clocks.append(f'{clk["group"]}.{clk["clock"]}') + else: + clocks.append(f"{clk_grp}.{clk}") + mod_clocks[name] = clocks + self._mod_clocks = mod_clocks + + def load_lifecycle(self, svpath: str) -> None: + """Load LifeCycle data from RTL file.""" + with open(svpath, "rt") as cfp: + self._log.debug("Loading LC constants from %s", svpath) + self._lcconst.load_sv(cfp) + + def load_otp_constants(self, svpath: str) -> None: + """Load OTP data from RTL file.""" + with open(svpath, "rt") as cfp: + self._log.debug("Loading OTP constants from %s", svpath) + self._otpconst.load_sv(cfp) + + def load_constants(self, hjpath: Optional[str]) -> None: + """Load definitions from HJSON file.""" + if not hjpath: + return + assert not _HJSON_ERROR + self._log.debug("Loading secrets from %s", hjpath) + hjbase = basename(hjpath) + with open(hjpath, "rt") as tfp: + cfg = hjload(tfp, object_pairs_hook=dict) + for module in cfg.get("module") or []: + modtype = module.get("type") + moddefs = self.MODULES.get(modtype) + if not moddefs: + continue + multi, regexes = moddefs[0], moddefs[1:] + consts = {} + OtpSecretConstants.load_values(module, consts, multi, *regexes) + if not consts: + continue + for cname, tname in self.TRANSLATIONS.get(modtype, {}).items(): + if cname in consts: + consts[tname] = consts.pop(cname) + self._log.debug("Constants for %s loaded from %s", modtype, hjbase) + exist = modtype in self._constants + if multi: + if not exist: + self._constants[modtype] = consts + else: + self._constants[modtype].update(consts) + else: + if exist: + raise ValueError(f"Redefinition of {modtype}") + self._constants[modtype] = {None: consts} + self._otpconst.load_secrets(cfg) + + def prepare(self) -> None: + """Prepare generation of data, aggregating several sources.""" + digests = { + "cnsty_digest": "digest", + "flash_data_key": "flash_data", + "flash_addr_key": "flash_addr", + "sram_data_key": "sram", + } + avail_digests = self._otpconst.get_digests() + otp_ctrl = self._constants["otp_ctrl"][None] + for digest, prefix in digests.items(): + if digest not in avail_digests: + continue + pair = self._otpconst.get_digest_pair(digest, prefix) + otp_ctrl.update(pair) + for key in self._otpconst.get_scrambling_keys(): + key_value = self._otpconst.get_scrambling_key(key) + key = key.removesuffix("key") + "_scramble_key" + otp_ctrl[key] = key_value + idx = 0 + while True: + try: + defaults = self._otpconst.get_partition_inv_defaults(idx) + if defaults: + otp_ctrl[f"inv_default_part_{idx}"] = defaults + idx += 1 + except ValueError: + break + lc_ctrl = self._constants["lc_ctrl"][None] + lc_ctrl.update(self._lcconst.tokens) + + def exclude(self, exclusions: list[str]) -> None: + """Add property exclusions.""" + for exclude in exclusions: + try: + dev, prop = exclude.split(".") + except ValueError as exc: + raise ArgError(f"Invalid exclusion format: {exclude}") from exc + if dev not in self._exclusions: + self._exclusions[dev] = set() + self._exclusions[dev].add(prop) + + def dump_json(self, variant: str, ofp: TextIO) -> None: + """Dump all configuration constants to JSON format.""" + data = { + "variant": variant, + "git_version": self._git_version, + "rom_ctrl": self._constants.get("rom_ctrl", {}), + "otp_ctrl": self._constants.get("otp_ctrl", {}).get(None, {}), + "lc_ctrl": self._constants.get("lc_ctrl", {}).get(None, {}), + "keymgr": {}, + "lc_states": { + name: list(states) for name, states in self._lcconst.states.items() + }, + "top_clocks": { + c.name: {"frequency": c.frequency, "aon": c.aon, "ref": c.ref} + for c in self._top_clocks.values() + }, + "sub_clocks": { + c.name: {"source": c.source, "div": c.div} + for c in self._sub_clocks.values() + }, + "clock_groups": { + g.name: {"sources": g.sources, "sw_cg": g.sw_cg, "hint": g.hint} + for g in self._clock_groups.values() + }, + } + + for keymgr_name in ("keymgr", "keymgr_dpe"): + if keymgr_name in self._constants: + data["keymgr"] = { + "name": keymgr_name, + "values": self._constants[keymgr_name].get(None, {}), + } + break + + json.dump(data, ofp, indent=2) + + +def main(): + """Main routine""" + debug = True + try: + argparser = ArgumentParser(description="Dump OpenTitan constants to JSON.") + files = argparser.add_argument_group(title="Files") + files.add_argument( + "opentitan", nargs="?", metavar="OTDIR", help="OpenTitan root directory" + ) + files.add_argument( + "-T", "--top", choices=OpenTitanTop.names, help="OpenTitan top name" + ) + files.add_argument( + "-o", + "--out", + metavar="JSON", + required=True, + help="Filename of the JSON file to generate", + ) + files.add_argument( + "-c", + "--otpconst", + metavar="SV", + help="OTP Constant SV file (default: auto)", + ) + files.add_argument( + "-l", "--lifecycle", metavar="SV", help="LifeCycle SV file (default: auto)" + ) + files.add_argument( + "-S", "--secrets", metavar="HJSON", help="Secret HJSON file (default: auto)" + ) + files.add_argument( + "-t", + "--topcfg", + metavar="HJSON", + help="OpenTitan top HJSON config file " "(default: auto)", + ) + mods = argparser.add_argument_group(title="Modifiers") + mods.add_argument( + "-x", + "--exclude", + action="append", + metavar="DEVICE.NAME", + default=[], + help="Discard any property from DEVICE that starts " + "with NAME (may be repeated)", + ) + extra = argparser.add_argument_group(title="Extras") + extra.add_argument("-v", "--verbose", action="count", help="increase verbosity") + extra.add_argument( + "-d", "--debug", action="store_true", help="enable debug mode" + ) + args = argparser.parse_args() + debug = args.debug + + log = configure_loggers(args.verbose, "cfggen", "lc", "otp")[0] + + if _HJSON_ERROR: + argparser.error(f"Missing HJSON module: {_HJSON_ERROR}") + + cfg = OtConfiguration() + + topcfg = args.topcfg + ot_dir = args.opentitan + if not topcfg: + if not args.opentitan: + argparser.error("OTDIR is required is no top file is specified") + if not isdir(ot_dir): + argparser.error("Invalid OpenTitan root directory") + ot_dir = abspath(ot_dir) + if not args.top: + argparser.error("Top name is required if no top file is " "specified") + top = f"top_{args.top}" + topvar = OpenTitanTop.short_name(args.top) + topcfg = joinpath(ot_dir, f"hw/{top}/data/autogen/{top}.gen.hjson") + if not isfile(topcfg): + argparser.error(f"No such file '{topcfg}'") + log.info("Top config: '%s'", topcfg) + cfg.load_config(topcfg) + else: + if not isfile(topcfg): + argparser.error(f"No such top file: {topcfg}") + cfg.load_config(topcfg) + ltop = cfg.top_name + if not ltop: + argparser.error("Unknown top name") + log.info("Top: '%s'", ltop) + ltop = ltop.lower() + topvar = OpenTitanTop.short_name(cfg.top_name) + if not topvar: + argparser.error(f"Unsupported top name: {cfg.top_name}") + top = f"top_{ltop}" + if not ot_dir: + check_dir = f"hw/{top}/data" + cur_dir = dirname(topcfg) + while cur_dir: + check_path = joinpath(cur_dir, check_dir) + if isdir(check_path): + ot_dir = cur_dir + break + cur_dir = dirname(cur_dir) + if not ot_dir: + argparser.error("Cannot find OT root directory") + elif not isdir(ot_dir): + argparser.error("Invalid OpenTitan root directory") + ot_dir = abspath(ot_dir) + log.info("OT directory: '%s'", ot_dir) + log.info("Variant: '%s'", topvar) + top_dir = joinpath(ot_dir, "hw", top) + + lcfilename = "lc_ctrl_state_pkg.sv" + lcpath = args.lifecycle + if not lcpath: + lc_constant_locations = [ + joinpath(top_dir, f"rtl/autogen/testing/{lcfilename}"), + joinpath(top_dir, f"rtl/autogen/dev/{lcfilename}"), + joinpath(top_dir, f"rtl/autogen/{lcfilename}"), + joinpath(ot_dir, f"hw/ip/lc_ctrl/rtl/{lcfilename}"), + ] + for maybe_lcpath in lc_constant_locations: + if isfile(maybe_lcpath): + lcpath = maybe_lcpath + break + if not lcpath: + argparser.error(f"Unknown location for '{lcfilename}'") + if not isfile(lcpath): + argparser.error(f"No such file '{lcpath}'") + log.debug(f"'{lcfilename}' location: '%s'", lcpath) + + ocfilename = "otp_ctrl_part_pkg.sv" + ocpath = args.otpconst + if not ocpath: + otp_constant_locations = [ + joinpath(top_dir, f"ip_autogen/otp_ctrl/rtl/{ocfilename}"), + joinpath(ot_dir, f"hw/ip/otp_ctrl/rtl/{ocfilename}"), + ] + for maybe_ocpath in otp_constant_locations: + if isfile(maybe_ocpath): + ocpath = maybe_ocpath + break + if not ocpath: + argparser.error(f"Unknown location for '{ocfilename}'") + if not isfile(ocpath): + argparser.error(f"No such file '{ocpath}'") + log.debug(f"'{ocfilename}' location: '%s'", ocpath) + + secpath = args.secrets + if secpath: + if not isfile(secpath): + argparser.error("No such secret file: {secpath}") + else: + sec_constant_locations = [ + joinpath(top_dir, f"data/autogen/{top}.secrets.testing.gen.hjson"), + joinpath(top_dir, f"data/autogen/{top}.secrets.dev.gen.hjson"), + ] + for maybe_secpath in sec_constant_locations: + if isfile(maybe_secpath): + secpath = maybe_secpath + break + + cfg.load_lifecycle(lcpath) + cfg.load_otp_constants(ocpath) + cfg.load_constants(secpath) + cfg.prepare() + cfg.exclude(args.exclude) + + with open(args.out, "wt") as ofp: + cfg.dump_json(topvar, ofp) + + except (ArgError, IOError, ValueError, ImportError) as exc: + print(f"\nError: {exc}", file=sys.stderr) + if debug: + print(format_exc(chain=False), file=sys.stderr) + sys.exit(1) + except KeyboardInterrupt: + sys.exit(2) + + +if __name__ == "__main__": + main()
diff --git a/target/earlgrey/tooling/qemu_runner.py b/target/earlgrey/tooling/qemu_runner.py new file mode 100644 index 0000000..1efdf7d --- /dev/null +++ b/target/earlgrey/tooling/qemu_runner.py
@@ -0,0 +1,416 @@ +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 +"""QEMU runner for the ot-earlgrey machine. + +Launches firmware under the QEMU ot-earlgrey machine, pipes UART output through +pw_tokenizer detokenization, and exits with a structured code: + + 0 success-regex matched + 1 failure-regex matched + 2 timeout (no match before --timeout-seconds elapsed) + 3 QEMU process exited unexpectedly before any match +""" + +import atexit +import argparse +import io +import logging +import os +import re +import shutil +import signal +import socket +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +from pw_tokenizer import detokenize + +_LOG = logging.getLogger(__name__) + +_SPIFLASH_SIZE = 32 * 1024 * 1024 # W25Q256 = 32 MiB +_SCRIPT_DIR = Path(__file__).resolve().parent + +EXIT_SUCCESS = 0 +EXIT_FAILURE = 1 +EXIT_TIMEOUT = 2 +EXIT_QEMU_CRASH = 3 + +# Shared state for signal handlers — mutable list used as a ref cell. +_active_pid: list[int] = [] + + +def _parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p.add_argument( + "--qemu-start", + type=Path, + default=_SCRIPT_DIR / "qemu_start.sh", + help="path to qemu_start.sh (default: sibling of this script)", + ) + p.add_argument( + "--qemu-bin", required=True, type=Path, help="path to qemu-system-riscv32" + ) + p.add_argument( + "--qemu-config", + required=True, + type=Path, + help="QEMU readconfig INI from qemu_cfg rule", + ) + p.add_argument("--qemu-rom", required=True, type=Path, help="test ROM ELF") + p.add_argument( + "--qemu-otp", + required=True, + type=Path, + help="read-only OTP image; will be copied to a writable temp path", + ) + p.add_argument( + "--qemu-flash", + required=True, + type=Path, + help="read-only flash image; will be copied to a writable temp path", + ) + p.add_argument( + "--firmware-elf", + required=True, + type=Path, + help="firmware ELF for pw_tokenizer detokenization", + ) + p.add_argument( + "--exit-success", + default=None, + type=str, + help="regex matched against detokenized UART output to signal pass", + ) + p.add_argument( + "--exit-failure", + default=None, + type=str, + help="regex matched against detokenized UART output to signal fail", + ) + p.add_argument( + "--timeout-seconds", + default=120, + type=int, + help="seconds before EXIT_TIMEOUT (0 = no timeout)", + ) + p.add_argument( + "--icount", default=6, type=int, help="QEMU icount shift value (default 6)" + ) + return p.parse_args() + + +def _wait_for_pid(pidfile: Path, deadline: float) -> int: + while time.monotonic() < deadline: + try: + text = pidfile.read_text().strip() + pid = int(text) + if pid > 0: + return pid + except (FileNotFoundError, ValueError): + pass + time.sleep(0.05) + raise TimeoutError(f"QEMU PID file never appeared: {pidfile}") + + +def _connect_unix(path: Path, deadline: float) -> socket.socket: + while True: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + sock.connect(str(path)) + return sock + except OSError: + sock.close() + if time.monotonic() >= deadline: + raise TimeoutError(f"timed out connecting to {path}") + time.sleep(0.05) + + +def _alive(pid: int) -> bool: + return Path(f"/proc/{pid}").exists() + + +def _kill_qemu(pid: int) -> None: + """SIGTERM → wait 5 s → SIGKILL.""" + if not _alive(pid): + return + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + return + deadline = time.monotonic() + 5.0 + while _alive(pid) and time.monotonic() < deadline: + time.sleep(0.1) + if _alive(pid): + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + + +def _signal_handler(signum: int, _frame) -> None: + # sys.exit raises SystemExit, unwinding try/finally and TemporaryDirectory. + if _active_pid: + _kill_qemu(_active_pid[0]) + sys.exit(EXIT_QEMU_CRASH) + + +class _Watcher: + """Thread-safe regex watcher operating on detokenized text.""" + + def __init__(self, success: str | None, failure: str | None): + self._success = re.compile(success) if success else None + self._failure = re.compile(failure) if failure else None + self.result: int | None = None + self._lock = threading.Lock() + + def feed(self, text: str) -> bool: + """Return True and record result on first pattern match.""" + with self._lock: + if self.result is not None: + return True + if self._success and self._success.search(text): + self.result = EXIT_SUCCESS + return True + if self._failure and self._failure.search(text): + self.result = EXIT_FAILURE + return True + return False + + +def _emit_line(det: detokenize.Detokenizer | None, line: str) -> str: + """Detokenize one line, write to stdout, return the decoded text.""" + if det is None: + sys.stdout.write(line) + sys.stdout.flush() + return line + try: + buf = io.BytesIO() + det.detokenize_text_to_file(line, buf) + decoded = buf.getvalue().decode("utf-8", errors="replace") + sys.stdout.write(decoded) + sys.stdout.flush() + return decoded + except Exception as e: + _LOG.debug("detokenize error: %s", e) + sys.stdout.write(line) + sys.stdout.flush() + return line + + +def _uart_thread( + sock: socket.socket, + watcher: _Watcher, + elf: Path, + done: threading.Event, +) -> None: + try: + det: detokenize.Detokenizer | None = detokenize.Detokenizer(elf) + except Exception as e: + _LOG.warning("detokenizer init failed (%s); raw output only", e) + det = None + + line_buf = b"" + try: + while not done.is_set(): + try: + chunk = sock.recv(4096) + except OSError: + break + if not chunk: + break + + line_buf += chunk + while b"\n" in line_buf: + idx = line_buf.index(b"\n") + 1 + line = line_buf[:idx] + line_buf = line_buf[idx:] + decoded = _emit_line(det, line.decode("utf-8", errors="replace")) + if watcher.feed(decoded): + done.set() + return + + # Flush any partial line after the connection closes. + if line_buf: + decoded = _emit_line(det, line_buf.decode("utf-8", errors="replace")) + watcher.feed(decoded) + except Exception as e: + _LOG.warning("UART reader error: %s", e) + done.set() + + +def _run(args: argparse.Namespace) -> int: + # Validate regex patterns before touching the filesystem. + for flag, val in ( + ("--exit-success", args.exit_success), + ("--exit-failure", args.exit_failure), + ): + if val is not None: + try: + re.compile(val) + except re.error as e: + _LOG.error("invalid %s pattern: %s", flag, e) + return EXIT_FAILURE + + signal.signal(signal.SIGINT, _signal_handler) + signal.signal(signal.SIGTERM, _signal_handler) + + with tempfile.TemporaryDirectory() as _tmpdir: + tmp = Path(_tmpdir) + + # Step 1: writable copies of read-only Bazel-built backing files. + # QEMU pflash/mtd drivers write back to the backing file on close. + # Use copyfile (content-only) not copy2, so Bazel's read-only mode bits + # are not propagated to the writable temp copy. + otp_rw = tmp / "otp.raw" + flash_rw = tmp / "flash.qemu_bin" + shutil.copyfile(args.qemu_otp, otp_rw) + shutil.copyfile(args.qemu_flash, flash_rw) + + # Step 2: 32 MiB SPI flash backing store (sparse — W25Q256). + spiflash = tmp / "spiflash.bin" + with open(spiflash, "wb") as fh: + fh.seek(_SPIFLASH_SIZE - 1) + fh.write(b"\x00") + + pidfile = tmp / "qemu.pid" + logfile = tmp / "qemu.log" + monitor_path = tmp / "monitor.sock" + uart_path = tmp / "uart0.sock" + + # Step 3: launch qemu_start.sh. -daemonize causes QEMU to fork to + # background; the script (and thus subprocess.run) returns once the + # child has written its PID file. + env = dict(os.environ) + env.update( + { + "QEMU_BIN": str(args.qemu_bin), + "QEMU_CONFIG": str(args.qemu_config), + "QEMU_ROM": str(args.qemu_rom), + "QEMU_OTP": str(otp_rw), + "QEMU_FLASH": str(flash_rw), + "QEMU_SPIFLASH": str(spiflash), + "QEMU_PIDFILE": str(pidfile), + "QEMU_LOG": str(logfile), + "QEMU_ICOUNT": str(args.icount), + "QEMU_MONITOR": str(monitor_path), + "QEMU_UART_SOCKET": str(uart_path), + } + ) + + cmd = [str(args.qemu_start)] + _LOG.info("starting QEMU via %s", args.qemu_start) + launch = subprocess.run(cmd, env=env, check=False) + if launch.returncode != 0: + _LOG.error("qemu_start.sh exited %d", launch.returncode) + return EXIT_QEMU_CRASH + + deadline = ( + time.monotonic() + args.timeout_seconds + if args.timeout_seconds > 0 + else float("inf") + ) + + # Step 4a: poll for QEMU's PID file. + try: + qemu_pid = _wait_for_pid(pidfile, deadline) + except TimeoutError as e: + _LOG.error("%s", e) + return EXIT_TIMEOUT + _LOG.info("QEMU daemonized, pid=%d", qemu_pid) + + # Register PID for signal-handler cleanup. + _active_pid.clear() + _active_pid.append(qemu_pid) + atexit.register(_kill_qemu, qemu_pid) + + # Step 4b: connect to HMP monitor socket and resume the CPU. + # The -S flag holds the CPU paused; without `cont` the UART is silent. + try: + mon = _connect_unix(monitor_path, deadline) + try: + # Wait for the HMP `(qemu) ` prompt before sending cont. + mon.settimeout(5.0) + banner = b"" + try: + while b"(qemu)" not in banner: + chunk = mon.recv(64) + if not chunk: + break + banner += chunk + except socket.timeout: + _LOG.warning( + "monitor: no (qemu) prompt within 5s, sending cont anyway" + ) + mon.settimeout(None) + mon.sendall(b"cont\n") + finally: + mon.close() + except (TimeoutError, OSError) as e: + _LOG.error("monitor: %s", e) + _kill_qemu(qemu_pid) + return EXIT_QEMU_CRASH + + # Step 5: connect to UART0 socket and watch for exit patterns. + try: + uart_sock = _connect_unix(uart_path, deadline) + except TimeoutError as e: + _LOG.error("%s", e) + _kill_qemu(qemu_pid) + return EXIT_QEMU_CRASH + + watcher = _Watcher(args.exit_success, args.exit_failure) + done = threading.Event() + reader = threading.Thread( + target=_uart_thread, + args=(uart_sock, watcher, args.firmware_elf, done), + daemon=True, + ) + reader.start() + + timed_out = False + crashed = False + try: + while not done.is_set(): + if args.timeout_seconds > 0 and time.monotonic() >= deadline: + timed_out = True + _LOG.error("test timed out after %ds", args.timeout_seconds) + break + if not _alive(qemu_pid): + crashed = True + _LOG.error("QEMU pid=%d exited unexpectedly", qemu_pid) + if logfile.exists(): + tail = logfile.read_text(errors="replace")[-2000:] + _LOG.info("QEMU log tail:\n%s", tail) + break + done.wait(timeout=0.1) + finally: + # Step 6: cleanup on all exit paths. + done.set() + try: + uart_sock.close() + except OSError: + pass + reader.join(timeout=2) + _kill_qemu(qemu_pid) + _active_pid.clear() + + if watcher.result is not None: + return watcher.result + if timed_out: + return EXIT_TIMEOUT + if crashed: + return EXIT_QEMU_CRASH + # Reader set done without a watcher match (clean EOF, no sentinel). + return EXIT_TIMEOUT + + +if __name__ == "__main__": + logging.basicConfig(format="%(levelname)s: %(message)s") + sys.exit(_run(_parse_args()))
diff --git a/target/earlgrey/tooling/qemu_start.sh b/target/earlgrey/tooling/qemu_start.sh new file mode 100755 index 0000000..6d8b8e9 --- /dev/null +++ b/target/earlgrey/tooling/qemu_start.sh
@@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 +# +# Launch qemu-system-riscv32 for the ot-earlgrey machine and daemonize it. +# The CPU starts paused (-S); the runner must send `cont` to $QEMU_MONITOR +# before any firmware output appears. +# +# Ported from opentitan/hw/top_earlgrey/sw/util/qemu.sh. +# Stripped for v1: JTAG sockets, GPIO socket, I2C, USB, extra UARTs. +# UART0 uses a Unix socket (not PTY) for Bazel sandbox hermeticity. +# +# Required environment variables: +# QEMU_BIN path to qemu-system-riscv32 +# QEMU_CONFIG path to QEMU readconfig INI (from cfggen.py) +# QEMU_ROM path to test ROM ELF +# QEMU_OTP path to mutable raw OTP image (from otptool.py) +# QEMU_FLASH path to mutable flash image (from flashgen.py) +# QEMU_SPIFLASH path to mutable 32 MiB SPI-flash backing store +# QEMU_PIDFILE path where QEMU writes its PID after daemonizing +# QEMU_LOG path for QEMU's -D log output +# QEMU_ICOUNT icount shift value (default: 6) +# QEMU_MONITOR path for the QEMU monitor Unix socket (HMP mode) +# QEMU_UART_SOCKET path for the UART0 Unix socket + +set -e + +_fail() { + echo "qemu_start.sh: $1" >&2 + exit 1 +} + +# Mandatory checks — all must be non-empty strings pointing at real files. +[ -n "$QEMU_BIN" ] || _fail "QEMU_BIN is unset" +[ -f "$QEMU_BIN" ] || _fail "QEMU_BIN not found: $QEMU_BIN" +[ -n "$QEMU_CONFIG" ] || _fail "QEMU_CONFIG is unset" +[ -f "$QEMU_CONFIG" ] || _fail "QEMU_CONFIG not found: $QEMU_CONFIG" +[ -n "$QEMU_ROM" ] || _fail "QEMU_ROM is unset" +[ -f "$QEMU_ROM" ] || _fail "QEMU_ROM not found: $QEMU_ROM" +[ -n "$QEMU_OTP" ] || _fail "QEMU_OTP is unset" +[ -f "$QEMU_OTP" ] || _fail "QEMU_OTP not found: $QEMU_OTP" +[ -n "$QEMU_FLASH" ] || _fail "QEMU_FLASH is unset" +[ -f "$QEMU_FLASH" ] || _fail "QEMU_FLASH not found: $QEMU_FLASH" +[ -n "$QEMU_SPIFLASH" ] || _fail "QEMU_SPIFLASH is unset" +[ -f "$QEMU_SPIFLASH" ] || _fail "QEMU_SPIFLASH not found: $QEMU_SPIFLASH" +[ -n "$QEMU_PIDFILE" ] || _fail "QEMU_PIDFILE is unset" +[ -n "$QEMU_LOG" ] || _fail "QEMU_LOG is unset" +[ -n "$QEMU_MONITOR" ] || _fail "QEMU_MONITOR is unset" +[ -n "$QEMU_UART_SOCKET" ] || _fail "QEMU_UART_SOCKET is unset" + +QEMU_ICOUNT="${QEMU_ICOUNT:-6}" + +qemu_args=( + # No GUI. + "-display" "none" + + # Earlgrey 1.0.0 machine. + "-M" "ot-earlgrey" + + # RTL constants from cfggen. + "-readconfig" "$QEMU_CONFIG" + + # Daemonize after initialization; write PID to file. + "-daemonize" + "-pidfile" "$QEMU_PIDFILE" + + # Start CPU paused — runner sends `cont` via monitor before tailing UART. + "-S" + + # Log guest errors and unimplemented peripheral access (invaluable on failures). + "-D" "$QEMU_LOG" + "-d" "guest_errors,unimp" + + # ROM image. + "-object" "ot-rom_img,id=rom,file=${QEMU_ROM}" + + # OTP backing store (pflash). + "-drive" "if=pflash,file=${QEMU_OTP},format=raw" + + # Firmware flash (mtd bus 2 = eflash). + "-drive" "if=mtd,id=eflash,bus=2,file=${QEMU_FLASH},format=raw" + + # SPI Host 0 backing store — ot-earlgrey machine requires this drive even + # when the test does not exercise SPI. W25Q256 = 32 MiB, matches the dd + # image created by the runner. + "-global" "ot-earlgrey-board.spiflash0=w25q256" + "-drive" "if=mtd,file=${QEMU_SPIFLASH},format=raw,bus=0" + + # Virtual-time pacing: 1 GHz >> icount ns/insn ≈ wall-clock alignment. + "-icount" "shift=${QEMU_ICOUNT},align=on,sleep=on" + + # Disable fatal-reset so tests that trigger a reset don't kill QEMU. + "-global" "ot-rstmgr.fatal_reset=0" + + # Disable keymgr flash-seed check (info pages not spliced; would error). + "-global" "ot-keymgr.disable-flash-seed-check=true" + + # Suppress test-status-register exit so multiple resets are possible. + "-global" "ot-ibex_wrapper.dv-sim-status-exit=off" + + # Monitor socket in HMP (readline) mode — runner writes plain `cont\n`. + "-chardev" "socket,id=monitor,path=${QEMU_MONITOR},server=on,wait=off" + "-mon" "chardev=monitor,mode=readline" + + # UART0 as a Unix socket (not PTY) for hermeticity inside Bazel sandboxes. + "-chardev" "socket,path=${QEMU_UART_SOCKET},server=on,wait=off,id=uart0" + "-serial" "chardev:uart0" + + # UART1 — no external endpoint needed for software-loopback tests. + "-serial" "null" +) + +echo "qemu_start.sh: launching ${QEMU_BIN} with ${#qemu_args[@]} args" >&2 +exec "$QEMU_BIN" "${qemu_args[@]}"
diff --git a/third_party/opentitan_rtl/BUILD.bazel b/third_party/opentitan_rtl/BUILD.bazel new file mode 100644 index 0000000..86712aa --- /dev/null +++ b/third_party/opentitan_rtl/BUILD.bazel
@@ -0,0 +1,9 @@ +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 + +package(default_visibility = ["//target/earlgrey/tooling:__pkg__"]) + +filegroup( + name = "qemu_constants", + srcs = ["qemu_constants.json"], +)
diff --git a/third_party/opentitan_rtl/VENDORED_FROM.md b/third_party/opentitan_rtl/VENDORED_FROM.md new file mode 100644 index 0000000..c26945b --- /dev/null +++ b/third_party/opentitan_rtl/VENDORED_FROM.md
@@ -0,0 +1,61 @@ +# Vendored RTL Constants + +These constants are used to generate QEMU input files. To avoid bringing in large SystemVerilog (`.sv`) and HJSON files from the OpenTitan repository into the build flow, the necessary constants have been extracted into a single JSON file: `qemu_constants.json`. + +This JSON file is a **static copy** of constants extracted from a specific upstream commit of OpenTitan. OpenTitan is not a Bazel dependency of openprot. + +## Upstream Source + +| Repository | URL | +|---|---| +| lowRISC/opentitan | https://github.com/lowRISC/opentitan | + +**Upstream commit:** `76d47da22f2310207ae72224f3835969f5a6d274` + +## File Provenance + +| File in this directory | Extracted From Upstream Paths | Notes | +|---|---|---| +| `qemu_constants.json` | `hw/ip/otp_ctrl/rtl/otp_ctrl_part_pkg.sv`<br>`hw/ip/lc_ctrl/rtl/lc_ctrl_state_pkg.sv`<br>`hw/top_earlgrey/data/autogen/top_earlgrey.gen.hjson` | Extracted using `qemu_constants_dumper.py` tool. | + +> **RMA OTP image:** `img_rma.vmem` is consumed directly from the devbundle as `@opentitan_devbundle//:earlgrey/otp/img_rma.24.vmem`. The devbundle pin in `MODULE.bazel` is the single source of truth. + +## Uprev and Regeneration Procedure + +The constants in `qemu_constants.json` are silicon-locked. Regenerate them only if the upstream RTL constants have meaningfully changed — typically they won't. + +When regeneration is genuinely needed: + +1. Temporarily copy the upstream files verbatim to their historical locations (only needed for the dumper tool to find them, or you can pass their paths explicitly to the dumper tool): + * `hw/ip/otp_ctrl/rtl/otp_ctrl_part_pkg.sv` + * `hw/ip/lc_ctrl/rtl/lc_ctrl_state_pkg.sv` + * `hw/top_earlgrey/data/autogen/top_earlgrey.gen.hjson` + (You can copy them anywhere, e.g. to a temporary `tmp/` directory). + +2. Run the dumper tool `//target/earlgrey/tooling:qemu_constants_dumper` to generate the new JSON file. Pass the temporary paths explicitly: + ```bash + bazel run //target/earlgrey/tooling:qemu_constants_dumper -- \ + --json \ + --out $PWD/third_party/opentitan_rtl/qemu_constants.json \ + --top earlgrey \ + --topcfg /path/to/temporary/top_earlgrey.gen.hjson \ + --otpconst /path/to/temporary/otp_ctrl_part_pkg.sv \ + --lifecycle /path/to/temporary/lc_ctrl_state_pkg.sv \ + /path/to/temporary/opentitan_root_if_needed + ``` + *(Note: If you stage the files in a mock OpenTitan directory structure, you can omit the explicit paths and just pass the root directory path as positional argument).* + +3. Delete the temporary `.sv` and `.hjson` files. Do **NOT** commit them to the repository. + +4. Update the **Upstream commit** hash in this file (`VENDORED_FROM.md`) to capture the commit from which the new constants were obtained. + +5. Commit the updated `qemu_constants.json` and `VENDORED_FROM.md`. + +## Why JSON Constants, Not RTL Files + +Previously, `cfggen.py` (from the QEMU release archive) consumed the raw `.sv` and `.hjson` files at build time to generate QEMU readconfig files. This required bringing large RTL files into the repository and parsing them during the build, which introduced a build-time dependency on python's `hjson` module and complicated the sandbox environment. + +By extracting these constants into `qemu_constants.json` once, we: +1. Avoid cluttering the repository with large, unused RTL files. +2. Simplify the target build flow, which now only needs to run a simple, dependency-free Python script (`qemu_cfg_gen.py`) to generate the QEMU INI file from the JSON. +3. Remove the build-time dependency on python's `hjson` module for target runs (it is only a tool dependency for the dumper now).
diff --git a/third_party/opentitan_rtl/qemu_constants.json b/third_party/opentitan_rtl/qemu_constants.json new file mode 100644 index 0000000..5e003bd --- /dev/null +++ b/third_party/opentitan_rtl/qemu_constants.json
@@ -0,0 +1,184 @@ +{ + "variant": "eg", + "git_version": "d6ec7c9-dirty", + "rom_ctrl": { + "null": { + "nonce": "fee457dee82b6e06", + "key": "663c291739ff0e7d644758fee1c58564" + } + }, + "otp_ctrl": { + "scrmbl_key": "55d70063277642b5309a163990b966cd494444c3bcdf8087b7facd65e9654cd8", + "digest_const": "0e95f517cb98955b4d5a89aa9109294a", + "digest_iv": "bead91d5fa4e0915", + "flash_data_const": "277195fc471e4b26b6641214b61d1b43", + "flash_data_iv": "e048b657396b4b83", + "flash_addr_const": "d60822e1faec5c7290c7f21f6224f027", + "flash_addr_iv": "0b7474d640f8a7f5", + "sram_const": "4a22d4b78fe0266fbee3958332f2939b", + "sram_iv": "f98c48b1f9377284", + "secret0_scramble_key": "3ba121c5e097ddeb7768b4c666e9c3da", + "secret1_scramble_key": "effa6d736c5eff49ae7b70f9c46e5a62", + "secret2_scramble_key": "85a9e830bc059ba9286d6e2856a05cc3", + "inv_default_part_5": "7edc64361898f6ff7023461f42b4a5b3f7f71ea9a9507acf17c456385a48b963a629408e450e8458f3cd636e3c9a1140ae2905da9b31bb7ac60dd16b888838df2737bacf95ed7bf8", + "inv_default_part_6": "6969690000000000f254e78568a7f4bb", + "inv_default_part_7": "024c5fee0f5c8bb2c3080ad0531facb5370ad4f5b61d71b62203a5595f131d71a060cae9543819be", + "inv_default_part_8": "31dc5b08ced196d3b50ff2274384b9dbdd0d4e5d6bd0177ec2dce29f8327c8dbca99e674d3996d4260d2a0790332d0552ab6973f142947235a0c88a3ea33571096c8ddc72428751c29709bbd80960ee0a80ddce593c569c4", + "inv_default_part_9": "8a1cd4f5518b4d2a1978b594ed3dcd94671685f41086d5d3f6f8a097aad0585e4d857adfdebd0d2c0ece8ed011c5bad01988d871b8d25f316e627ccc51fc79029f45333ca4988068edfed1b3f0968cd628a94cbb02adbb8c", + "inv_default_part_10": "be6f2b4a67801b852a4529345af1cdbf37e97ab260e717e8e3c3363a666c130154d936fd1163e401fadd9f8c0ee9d1a056a2719fc2c345a4873c594f465e723e64ba4e17c79665cccb7943e751f0059633fbb917e41db693" + }, + "lc_ctrl": { + "invalid": "5bc66d10208c4fb51b97bbf4d12c378c", + "test_unlocked": "cfd6a5a5bf3032db51fa1eb92f6ce10e", + "dev": "90f9c5c06f733dc911a321dd4c0ac240", + "production": "6d467215dab3f2b54a52e6728678af07", + "rma": "18068e344d2eae4d73c8fa54de8aa4a4", + "raw_unlock_token": "ea2b3f32cbe77554e43c8ea7ebf197c2" + }, + "keymgr": { + "name": "keymgr", + "values": { + "lfsr_seed": "6ca8a8225c8e1705", + "revision_seed": "20bdaf59fe2ae209b8325d2c8c35fc960679b106a87e59e6aeb1b5302d334010", + "creator_identity_seed": "70251696fbb4ba169a6cd82fad46a0a5c04009bb934c7ef7b83b6e40610b4309", + "owner_int_identity_seed": "fcb9dc54d4276137f9901d09a76aeaa19de5c579fd38bdf38f0c21d6a52226b6", + "owner_identity_seed": "a4a7bdb05fe921615bf2ff984540f7d43ece76b4eb13363774ed2ed45545e927", + "soft_output_seed": "f6d9e4abac398d42c745eef646c1464dca86dafd7c7c71e6058ddfd871c51cac", + "hard_output_seed": "baf4410f06dcc036fcd16fcde97d171891105dd895e3a1d0a19a16d6dbd8b20f", + "aes_seed": "c9e662e1e1b4982b3e8eff63890dbeae926fd468a77efde3de5b4caf4776a247", + "kmac_seed": "baba4c9908ed16bc5415ec16d28c53551312fcedcf2832a66ceacf8ed4d5b616", + "otbn_seed": "177dd43b56fa754e1f53ad658193b563d9bdc6d2aee84852f0cc7371ed3a6faa", + "none_seed": "4b2276bec9fc1a7a5722a9aaca4db84a32e5c6985a1a6435118b5f5f3fd3b931", + "cdi_seed": "516c144b34c96dfabb6f6e79208a74f35c79f397c4e4c7e22b7581848a90a125" + } + }, + "lc_states": { + "lc_state": [ + "ee75b407d2314d2ef84185ac8c990f536071632c086d4c924070be92d2948d6228b2711e9b2d8c4d", + "ee75fe0ffe7b6f3ffc5f9ffd9ff96fdb7f736f6c9e6fdcd35277fef2d3bdcd6ffbb2f59fdf3fbedd" + ], + "lc_trscnt": [ + "dfb6c45a241f85ce9f42229e8627462fdb02c6701242f14b41891180045c09c26c52744267c04aa055921b9461bb07da", + "dfb6f4fabf1fefcebf5ba2ffc677c6afdbabcefeb672f36b4fbdb3988dfe1be67e7e77ca77c76af7ddde3b9e7fbfe7de" + ], + "soc_dbg": [ + "440af80d", + "6c9ef9cf" + ], + "ownership": [ + "3c61398d626885931da660ed2ab18a0f", + "bc757dbdeaf9b7d35de7e9efabfbbecf" + ] + }, + "top_clocks": { + "main": { + "frequency": 100000000, + "aon": false, + "ref": false + }, + "io": { + "frequency": 96000000, + "aon": false, + "ref": false + }, + "usb": { + "frequency": 48000000, + "aon": false, + "ref": false + }, + "aon": { + "frequency": 200000, + "aon": true, + "ref": true + } + }, + "sub_clocks": { + "io_div2": { + "source": "io", + "div": 2 + }, + "io_div4": { + "source": "io", + "div": 4 + }, + "aes": { + "source": "main", + "div": 1 + }, + "hmac": { + "source": "main", + "div": 1 + }, + "kmac": { + "source": "main", + "div": 1 + }, + "otbn": { + "source": "main", + "div": 1 + } + }, + "clock_groups": { + "powerup": { + "sources": [ + "io_div4", + "aon", + "main", + "io", + "usb", + "io_div2" + ], + "sw_cg": false, + "hint": false + }, + "trans": { + "sources": [ + "aes", + "hmac", + "kmac", + "otbn" + ], + "sw_cg": false, + "hint": true + }, + "infra": { + "sources": [ + "io_div4", + "main", + "usb", + "io", + "io_div2" + ], + "sw_cg": false, + "hint": false + }, + "secure": { + "sources": [ + "io_div4", + "main", + "aon" + ], + "sw_cg": false, + "hint": false + }, + "peri": { + "sources": [ + "io_div4", + "io_div2", + "io", + "usb", + "aon" + ], + "sw_cg": true, + "hint": false + }, + "timers": { + "sources": [ + "io_div4", + "aon" + ], + "sw_cg": false, + "hint": false + } + } +}
diff --git a/third_party/qemu/BUILD b/third_party/qemu/BUILD new file mode 100644 index 0000000..ff21735 --- /dev/null +++ b/third_party/qemu/BUILD
@@ -0,0 +1,52 @@ +# Copyright lowRISC contributors. +# Licensed under the Apache License, Version 2.0, see LICENSE for details. +# SPDX-License-Identifier: Apache-2.0 +# +# Ported from opentitan/third_party/qemu/BUILD. +# Uses @openprot_python_deps instead of @ot_python_deps. + +load("@openprot_python_deps//:requirements.bzl", "requirement") +load("@rules_python//python:defs.bzl", "py_binary", "py_library") + +package(default_visibility = ["//visibility:public"]) + +py_binary( + name = "cfggen", + srcs = ["@qemu_opentitan//:scripts/opentitan/cfggen.py"], + deps = [ + ":ot", + requirement("hjson"), + ], +) + +py_binary( + name = "flashgen", + srcs = ["@qemu_opentitan//:scripts/opentitan/flashgen.py"], + deps = [ + ":ot", + requirement("pyelftools"), + ], +) + +py_binary( + name = "otptool", + srcs = ["@qemu_opentitan//:scripts/opentitan/otptool.py"], + deps = [":ot"], +) + +py_library( + name = "ot", + srcs = ["@qemu_opentitan//:ot"], +) + +alias( + name = "qemu-system-riscv32", + actual = "@qemu_opentitan//:build/qemu-system-riscv32", +) + +alias( + name = "qemu-img", + actual = "@qemu_opentitan//:build/qemu-img", +) + +exports_files(["build_qemu.sh"])
diff --git a/third_party/qemu/BUILD.qemu_opentitan.bazel b/third_party/qemu/BUILD.qemu_opentitan.bazel new file mode 100644 index 0000000..a55f9a4 --- /dev/null +++ b/third_party/qemu/BUILD.qemu_opentitan.bazel
@@ -0,0 +1,19 @@ +# Copyright lowRISC contributors (OpenTitan project). +# Licensed under the Apache License, Version 2.0, see LICENSE for details. +# SPDX-License-Identifier: Apache-2.0 + +package(default_visibility = ["//visibility:public"]) + +exports_files(glob(["**"])) + +filegroup( + name = "qemu_src", + srcs = glob(["**"]), +) + +filegroup( + name = "ot", + srcs = glob([ + "python/qemu/ot/**", + ]), +)
diff --git a/third_party/qemu/README.md b/third_party/qemu/README.md new file mode 100644 index 0000000..bb5c76c --- /dev/null +++ b/third_party/qemu/README.md
@@ -0,0 +1,71 @@ +# QEMU + +For instructions on setting up QEMU for local development and troubleshooting steps, see the [setup guide](./setup.md). + +## Introduction + +[QEMU](https://www.qemu.org/) is an open-source project providing fast, functional full-system emulation. +OpenPRoT uses the lowRISC QEMU fork's `ot-earlgrey` machine to run earlgrey firmware tests without requiring Verilator or physical hardware. + +The QEMU binary is downloaded from the lowRISC GitHub releases as a pre-built archive: + +| Item | Value | +|---|---| +| Tag | `v10.2.0-2026-01-15` | +| Archive | `qemu-ot-earlgrey-v10.2.0-2026-01-15-x86_64-unknown-linux-gnu.tar.gz` | +| SHA-256 | `9e97f93b09912c904e84f06571e7b49023ccb405dd3caa232ad1e82a3f7b381c` | + +The Bazel targets `//third_party/qemu:cfggen`, `//third_party/qemu:flashgen`, and `//third_party/qemu:otptool` wrap the Python scripts bundled in the release archive. + +## Scope + +Only the Earlgrey machine (`ot-earlgrey`) is supported. +Emulation is ongoing and incomplete — many peripherals are not yet fully emulated. +Before relying on QEMU results, verify that the peripherals your test exercises are supported. + +## Key limitations + +- **Not cycle-accurate.** `mcycle` and hardware timers are approximate. + OpenPRoT uses `icount shift=6` to pace virtual time to wall-clock time. +- **PMP granularity.** Misaligned PMP regions disable TLB caching, causing slowdowns. + Fine-grained PMP regions in earlgrey ROM/ROM_EXT can produce significant slowdowns. +- **UART CharDev.** UART0 is wired via a Unix socket chardev; the runner opens it for output. + UART oversampling is not emulated. +- **QEMU starts paused** (`-S` flag). The runner must send `cont\n` to the monitor socket + before any firmware output appears. Forgetting this produces silent hangs. + +## Files + +| File | Purpose | +|---|---| +| `extensions.bzl` | Bazel module extension — fetches the pinned QEMU release archive or builds from a local source override | +| `BUILD.qemu_opentitan.bazel` | BUILD file placed inside the fetched QEMU repo | +| `build_qemu.sh` | Shell script that configures + builds QEMU from a local source checkout | +| `BUILD` | Bazel targets for `cfggen`, `flashgen`, `otptool`, `qemu-system-riscv32` | +| `requirements.in` | Unlocked Python deps for cfggen/flashgen | +| `requirements.txt` | Locked + hashed Python deps (input to `rules_python` pip.parse) | + +## Local development override + +To iterate on QEMU itself without waiting for a new release archive, pass an `--override_repository` +flag pointing at your local source checkout: + +```sh +bazelisk build \ + --override_repository=qemu_opentitan_src=/path/to/your/qemu \ + @qemu_opentitan//:build/qemu-system-riscv32 +``` + +The `extensions.bzl` module extension checks for this override; when set it runs `build_qemu.sh` +against the local tree instead of fetching the pinned archive. +The resulting binary is used by all `//third_party/qemu:qemu-system-riscv32` consumers in the +same build, including `ipc_runner_qemu_test`. + +For a full walkthrough (configuring build deps, setting `QEMU_CFLAGS`, etc.) see [setup.md](./setup.md). + +## Ported from + +These files were ported from `opentitan/third_party/qemu/` at the same upstream revision +as the pinned QEMU tag above. When upgrading the QEMU version, re-port `extensions.bzl` +(update the tag + sha256), update `requirements.txt` if script deps changed, and re-verify +`cfggen`/`flashgen`/`otptool` still work against the new scripts.
diff --git a/third_party/qemu/build_qemu.sh b/third_party/qemu/build_qemu.sh new file mode 100755 index 0000000..16c1234 --- /dev/null +++ b/third_party/qemu/build_qemu.sh
@@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# +# Copyright lowRISC contributors (OpenTitan project). +# Licensed under the Apache License, Version 2.0, see LICENSE for details. +# SPDX-License-Identifier: Apache-2.0 +# +set -e + +if [ "$#" -ne 2 ]; then + echo "usage: build_qemu.sh <path to QEMU source> <output archive path>" + exit 1 +fi + +QEMU_ROOT="$1" +OUT_ARCHIVE="$(realpath "$2")" + +# Go to the QEMU source directory. +cd "$QEMU_ROOT" +# Create the build directory if needed. +mkdir -p build +cd build +# Run configure if necessary. +if [ -f config-host.mak ]; +then + echo "Skipping configure, delete $PWD/config-host.mak to reconfigure" +else + config_log="$PWD/qemu_config.log" + echo "Configuring QEMU (output log in $config_log)..." + if ! "$QEMU_ROOT/configure" \ + --target-list=riscv32-softmmu \ + --without-default-features \ + --enable-tcg \ + --enable-tools \ + --enable-trace-backends=log \ + &> "$config_log" ; + then + echo "Failed (see $config_log)" + exit 1 + fi +fi + +# Build QEMU +build_log="$PWD/qemu_build.log" +echo "Building QEMU (output log in $build_log)..." +if ! ninja qemu-system-riscv32 &> "$build_log" ; then + echo "Failed (see $build_log)" + exit 1 +fi +if ! ninja qemu-img &>> "$build_log" ; then + echo "Failed (see $build_log)" + exit 1 +fi + +# Run the make release script to create the requested archive. +# Some versions have a bug and except PWD to be the source directory. +archive_log="$PWD/qemu_archive.log" +echo "Creating archive (output log in $archive_log)..." +cd "$QEMU_ROOT" +if ! "./scripts/opentitan/make_release.sh" \ + "$OUT_ARCHIVE" . "build" &> "$archive_log" ; +then + echo "Failed (see $archive_log)" + exit 1 +fi
diff --git a/third_party/qemu/extensions.bzl b/third_party/qemu/extensions.bzl new file mode 100644 index 0000000..464119f --- /dev/null +++ b/third_party/qemu/extensions.bzl
@@ -0,0 +1,110 @@ +# Copyright lowRISC contributors. +# Licensed under the Apache License, Version 2.0, see LICENSE for details. +# SPDX-License-Identifier: Apache-2.0 +# +# Ported from opentitan/third_party/qemu/extensions.bzl. +# Pinned to QEMU tag v10.2.0-2026-01-15. + +load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") + +_ARCHIVE_MARKER_FILE = ".this.is.the.archive" + +def _qemu_bazel_build_or_forward_impl(rctx): + qemu_root = rctx.path(rctx.attr.qemu_src).dirname + + # Determine whether the source repo was overridden with a local source directory. + if qemu_root.get_child(_ARCHIVE_MARKER_FILE).exists: + # Symlink everything to the release directory. Also watch everything. + for child in qemu_root.readdir(): + rctx.symlink(child, child.basename) + if not child.is_dir: + rctx.watch(child) + elif child.is_dir: + rctx.watch_tree(child) + else: + print( + "QEMU source archive override detected:", + "will be using {} as the source directory".format(qemu_root.realpath), + ) + + # Sanity checks. + configure_script = qemu_root.get_child("configure") + if not configure_script.exists: + fail("There is no configure script in the source directory, did you override the QEMU source correctly?") + + # Run the build script which will produce a release archive + release_archive = "build_archive.tar.gz" + rctx.watch(rctx.attr._build_qemu) + res = rctx.execute( + [ + rctx.attr._build_qemu, + qemu_root.realpath, + release_archive, + ], + quiet = False, + ) + if res.return_code != 0: + fail("Failed to build QEMU") + + # Extract archive here. + rctx.extract(rctx.path(release_archive)) + + # Watch everything in the source directory. + for child in qemu_root.readdir(): + # Skip the build directory because it can cause weird bazel behaviour: + # this directory has symlinks and cycles. + if not child.is_dir: + rctx.watch(child) + elif child.is_dir and child.basename != "build": + rctx.watch_tree(child) + + # Create a single BUILD file that exports everything. + rctx.file("BUILD", rctx.read(rctx.path(rctx.attr._build_file))) + +qemu_bazel_build_or_forward = repository_rule( + implementation = _qemu_bazel_build_or_forward_impl, + attrs = { + # This attribute is used to locate the source repository so that we don't have to guess + # its name. + "qemu_src": attr.label(doc = "This is a label to any file at the root of the QEMU source repository."), + "_build_file": attr.label( + default = ":BUILD.qemu_opentitan.bazel", + ), + "_build_qemu": attr.label( + default = ":build_qemu.sh", + executable = True, + cfg = "exec", + ), + }, + # Allow the repository to be refetched by running + # bazel configure fetch --force + configure = True, + # Make sure to refetch on restart. + local = True, +) + +def _qemu_opentitan_repos(): + QEMU_VERSION = "v10.2.0-2026-01-15" + + url = "/".join([ + "https://github.com/lowRISC/qemu/releases/download", + QEMU_VERSION, + "qemu-ot-earlgrey-{}-x86_64-unknown-linux-gnu.tar.gz".format(QEMU_VERSION), + ]) + + http_archive( + name = "qemu_opentitan_src", + url = url, + build_file = Label(":BUILD.qemu_opentitan.bazel"), + sha256 = "9e97f93b09912c904e84f06571e7b49023ccb405dd3caa232ad1e82a3f7b381c", + patch_cmds = ["touch {}".format(_ARCHIVE_MARKER_FILE)], + ) + + qemu_bazel_build_or_forward( + name = "qemu_opentitan", + qemu_src = "@qemu_opentitan_src//:qemu_src", + ) + +qemu = module_extension( + implementation = lambda _: _qemu_opentitan_repos(), +)
diff --git a/third_party/qemu/requirements.in b/third_party/qemu/requirements.in new file mode 100644 index 0000000..ab59f3e --- /dev/null +++ b/third_party/qemu/requirements.in
@@ -0,0 +1,4 @@ +# Python dependencies for QEMU tooling scripts (cfggen, flashgen, otptool). +# cfggen.py requires hjson; flashgen.py requires pyelftools; otptool.py has no deps. +hjson +pyelftools==0.29
diff --git a/third_party/qemu/requirements.txt b/third_party/qemu/requirements.txt new file mode 100644 index 0000000..b29322d --- /dev/null +++ b/third_party/qemu/requirements.txt
@@ -0,0 +1,14 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --generate-hashes --output-file=requirements.txt requirements.in +# +hjson==3.1.0 \ + --hash=sha256:55af475a27cf83a7969c808399d7bccdec8fb836a07ddbd574587593b9cdcf75 \ + --hash=sha256:65713cdcf13214fb554eb8b4ef803419733f4f5e551047c9b711098ab7186b89 + # via -r requirements.in +pyelftools==0.29 \ + --hash=sha256:519f38cf412f073b2d7393aa4682b0190fa901f7c3fa0bff2b82d537690c7fc1 \ + --hash=sha256:ec761596aafa16e282a31de188737e5485552469ac63b60cfcccf22263fd24ff + # via -r requirements.in
diff --git a/third_party/qemu/setup.md b/third_party/qemu/setup.md new file mode 100644 index 0000000..97ba1f2 --- /dev/null +++ b/third_party/qemu/setup.md
@@ -0,0 +1,65 @@ +# Setting up QEMU for local OpenPRoT development + +By default, the build system downloads a pre-built release of the [lowRISC QEMU fork](https://github.com/lowRISC/qemu/). +For local development and debugging, you can build QEMU from source instead. + +## Building from source using Bazel + +Check out the lowRISC QEMU fork and switch to the correct branch: + +```bash +git clone https://github.com/lowRISC/qemu +cd qemu +git checkout ot-10.2.0 # branch corresponding to the pinned release +``` + +Perform this setup step once at the root of your QEMU checkout: + +```bash +touch REPO.bazel +ln -s "/path/to/openprot/third_party/qemu/BUILD.qemu_opentitan.bazel" "BUILD.bazel" +``` + +Then tell Bazel to use your local checkout instead of the downloaded archive by passing +`--override_repository` on every Bazel invocation: + +```bash +bazelisk build --override_repository="+qemu+qemu_opentitan_src=/path/to/qemu" \ + //third_party/qemu:qemu-system-riscv32 +``` + +To avoid repeating this flag, add it to your `.bazelrc-site` file at the repo root: + +``` +common --override_repository=+qemu+qemu_opentitan_src=/path/to/qemu +``` + +## Troubleshooting + +### Finding the canonical repository name + +If Bazel reports that `+qemu+qemu_opentitan_src` is not a valid repository name, run: + +```bash +bazelisk mod dump_repo_mapping "" | jq .qemu_opentitan_src +``` + +Use the reported canonical name in `--override_repository`. + +### How the override detection works + +`extensions.bzl`'s `qemu_bazel_build_or_forward` rule checks for a marker file +(`.this.is.the.archive`) that is injected into the pre-built release archive. +If the marker is absent (local checkout), the rule runs `build_qemu.sh` inside your +source directory, which configures with: + +``` +--target-list=riscv32-softmmu +--without-default-features +--enable-tcg +--enable-tools +--enable-trace-backends=log +``` + +These flags are the minimum needed for the `ot-earlgrey` machine. +Do not add other flags — they either bloat the build or break OT-specific TCG plugins.
diff --git a/workflows.json b/workflows.json index 5d9ca2a..261827c 100644 --- a/workflows.json +++ b/workflows.json
@@ -63,8 +63,8 @@ "build_type": "bazel", "args": [ "--keep_going", - "--build_tag_filters=-hardware,-disabled,-verilator,-emulator", - "--test_tag_filters=-hardware,-disabled,-verilator,-emulator", + "--build_tag_filters=-hardware,-disabled,-verilator,-emulator,-qemu", + "--test_tag_filters=-hardware,-disabled,-verilator,-emulator,-qemu", "--test_output=streamed", "--" ], @@ -121,6 +121,27 @@ ] }, { + "name": "earlgrey_qemu_tests", + "build_config": { + "name": "earlgrey_qemu_tests_config", + "description": "Run Earlgrey QEMU-based tests", + "build_type": "bazel", + "args": [ + "--keep_going", + "--build_tag_filters=+qemu", + "--test_tag_filters=+qemu", + "--test_output=streamed" + ], + "driver_options": { + "@type": "pw.build.proto.BazelDriverOptions", + "no_test": false + } + }, + "targets": [ + "//target/earlgrey/..." + ] + }, + { "name": "ast10x0_qemu_tests", "build_config": { "name": "ast10x0_qemu_tests_config", @@ -181,10 +202,11 @@ }, { "name": "ci", - "description": "Execute CI tests plus dedicated AST10x0 QEMU coverage", + "description": "Execute CI tests plus dedicated AST10x0 and Earlgrey QEMU coverage", "builds": [ "ci_tests", - "ast10x0_qemu_tests" + "ast10x0_qemu_tests", + "earlgrey_qemu_tests" ] }, {