From 3b07a2292b6c2954f5d2f1b62c8c06fae8f0cf8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 2 Sep 2026 08:23:02 -0400 Subject: [PATCH 01/40] Added the ZoneX repository foundation, build system and EL2 reference ZoneX started as a repository with four files and no code. This is everything that has to exist before the hypervisor can be written: the build system, the C17 baseline, the verified Armv8-R EL2 register sheet, the recorded design decisions, and CI that runs from the first commit. No hypervisor logic is here, deliberately. Every translation unit is empty of implementation and every one of them compiles, so the change that writes the code opens a tree that already configures, builds and links in five configurations. Writing any of it earlier would have meant writing it against register names nobody had checked, and terminology bleed -- AArch64 spellings on an AArch32 core, an A-profile hypervisor shape on an MPU-only architecture -- is the single easiest way to get this architecture wrong. docs/armv8r-el2-reference.md is the answer to that. Every line in it was read out of the Cortex-R52 TRM or measured on the part, and each carries its source. Two things it settles that matter for the design. There is no HPRBAR.AP encoding that grants a guest access while denying EL2, so isolation between partitions can only come from which regions are enabled while a partition runs, never from the permission bits. And HSCTLR.BR=1 gives EL2 the background map for its own accesses while EL0/EL1 misses still fault regardless of BR, so ZoneX spends no regions on its own code and data -- which matters when the hypervisor's own mapping and every guest's stage-2 mapping share one 20-entry region set. BR grants permission, not attributes, and that distinction is recorded because the model hides it. Table 8-4 fixes the background map as Normal cacheable below 0x60000000, and the S32Z280's console sits at 0x4298_0000 -- inside it. So the hypervisor needs Device-attributed regions for its own console and GIC even with BR set. On the FVP those devices land in the Device band and the background map is accidentally correct, so a green model run cannot show that the regions are missing. It is the same shape as the region-count trap, and both are written down for that reason. It also closes one of two documented ambiguities. The TRM's section 3.3.48 says direct access stops at HPRBAR15; section 8.4 lists HPRBAR16-HPRBAR24 at opc1 = 5 and the register summary agrees. So the EL2 MPU avoids the penalty measured at EL1 for high regions -- 542-604 cycles through PRSELR against 434-470 direct -- and the whole budget is directly addressable. The HPRENR width question stays open: the TRM contradicts itself and only the part can settle whether a bit above 15 is honoured or merely stored. ZoneX is built to C17 with extensions off, the first component of the suite born on that baseline rather than migrated to it, and the strict warning set is in place from here rather than from a later hardening pass. Both are cheaper now than later: retrofitting a codebase to a warning set costs far more than building to one, and the certification back end for ZoneX is funded. CI exists before there is anything to run, for the same reason. The FVP workflow builds and executes; there is no static check for "the partition still runs", and ThreadX added its equivalent eight years in, after a period where nothing in CI ran a single instruction of any port. Every workflow triggers on pull requests against dev and main and on pushes to both -- ci_cortex_m.yml ran on master only while dev was the integration branch, so it gated no pull request anybody opened and the ports drifted for eight months. scripts/check_terminology.sh is the mechanical half of the terminology argument. It rejects the register and concept names that belong to other architectures, and it runs as its own CI job so its answer is unambiguous. Its one exception is deliberate: a suite build option may legitimately end in _EL1 -- TX_R52_BOOT_AT_EL1 is the ThreadX option that builds a guest starting there, and the guest work has to name it repeatedly -- while an AArch64 system register never carries a component prefix, so requiring the absence of one separates the two. CONTRIBUTING.md's "Building and testing" and "Continuous integration" sections described a repository with neither. They now describe what is here. Its C99 line is corrected to C17 for ZoneX, the sentence implying a PowerShell script set is qualified -- Phase 0 is Linux, FVP and silicon, and an untested build_host.ps1 would be a promise the project cannot keep -- and its section on agent-instruction files is replaced by one that does not claim a file this repository does not carry. No other Eclipse ThreadX repository ships agent instructions, and this one does not either; CONTRIBUTING.md is the single authority, and it now says what to point an agent at. Assisted-by: Claude Code (Opus 5) --- .gitattributes | 35 ++ .github/CODEOWNERS | 16 + .github/PULL_REQUEST_TEMPLATE.md | 39 +++ .github/dependabot.yml | 102 ++++++ .github/workflows/clang_check.yml | 136 ++++++++ .github/workflows/gcc_check.yml | 160 +++++++++ .github/workflows/host_tests.yml | 114 +++++++ .github/workflows/zx_fvp.yml | 267 +++++++++++++++ .gitignore | 60 ++++ CMakeLists.txt | 158 +++++++++ CMakePresets.json | 133 ++++++++ CONTRIBUTING.md | 72 ++-- NOTICE | 53 +++ README.md | 102 +++++- cmake/cortex_r52.cmake | 106 ++++++ cmake/cortex_r52_clang.cmake | 95 ++++++ cmake/host.cmake | 47 +++ cmake/zx_warnings.cmake | 55 +++ common/inc/zx_api.h | 133 ++++++++ core/CMakeLists.txt | 44 +++ core/inc/zx_fault.h | 57 ++++ core/inc/zx_manifest.h | 53 +++ core/inc/zx_mm.h | 56 ++++ core/inc/zx_partition.h | 56 ++++ core/inc/zx_schedule.h | 55 +++ core/src/zx_fault.c | 34 ++ core/src/zx_mm_setup.c | 35 ++ core/src/zx_partition_manager.c | 34 ++ core/src/zx_schedule.c | 34 ++ docs/armv8r-el2-reference.md | 326 ++++++++++++++++++ docs/decisions.md | 389 ++++++++++++++++++++++ examples/fvp_baser_aemv8r/CMakeLists.txt | 49 +++ examples/s32z280_evb/CMakeLists.txt | 40 +++ platform/cortex_r52/CMakeLists.txt | 33 ++ platform/cortex_r52/inc/zx_port.h | 82 +++++ platform/cortex_r52/src/zx_console.c | 39 +++ platform/cortex_r52/src/zx_el2_entry.S | 41 +++ platform/cortex_r52/src/zx_gic.c | 39 +++ platform/cortex_r52/src/zx_stage2_mpu.c | 49 +++ platform/cortex_r52/src/zx_timer.c | 38 +++ platform/cortex_r52/src/zx_trap_handler.S | 42 +++ scripts/build_fvp.sh | 21 ++ scripts/build_host.sh | 25 ++ scripts/build_s32z280.sh | 21 ++ scripts/check_terminology.sh | 93 ++++++ scripts/install.sh | 93 ++++++ scripts/test_fvp.sh | 21 ++ scripts/test_host.sh | 25 ++ scripts/test_s32z280.sh | 22 ++ test/fvp/run.sh | 84 +++++ test/host/CMakeLists.txt | 45 +++ test/host/run.sh | 83 +++++ test/host/test_zx_api.c | 91 +++++ test/host/zx_test.h | 82 +++++ test/s32z280/run.sh | 81 +++++ 55 files changed, 4273 insertions(+), 22 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/CODEOWNERS create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/clang_check.yml create mode 100644 .github/workflows/gcc_check.yml create mode 100644 .github/workflows/host_tests.yml create mode 100644 .github/workflows/zx_fvp.yml create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 CMakePresets.json create mode 100644 NOTICE create mode 100644 cmake/cortex_r52.cmake create mode 100644 cmake/cortex_r52_clang.cmake create mode 100644 cmake/host.cmake create mode 100644 cmake/zx_warnings.cmake create mode 100644 common/inc/zx_api.h create mode 100644 core/CMakeLists.txt create mode 100644 core/inc/zx_fault.h create mode 100644 core/inc/zx_manifest.h create mode 100644 core/inc/zx_mm.h create mode 100644 core/inc/zx_partition.h create mode 100644 core/inc/zx_schedule.h create mode 100644 core/src/zx_fault.c create mode 100644 core/src/zx_mm_setup.c create mode 100644 core/src/zx_partition_manager.c create mode 100644 core/src/zx_schedule.c create mode 100644 docs/armv8r-el2-reference.md create mode 100644 docs/decisions.md create mode 100644 examples/fvp_baser_aemv8r/CMakeLists.txt create mode 100644 examples/s32z280_evb/CMakeLists.txt create mode 100644 platform/cortex_r52/CMakeLists.txt create mode 100644 platform/cortex_r52/inc/zx_port.h create mode 100644 platform/cortex_r52/src/zx_console.c create mode 100644 platform/cortex_r52/src/zx_el2_entry.S create mode 100644 platform/cortex_r52/src/zx_gic.c create mode 100644 platform/cortex_r52/src/zx_stage2_mpu.c create mode 100644 platform/cortex_r52/src/zx_timer.c create mode 100644 platform/cortex_r52/src/zx_trap_handler.S create mode 100755 scripts/build_fvp.sh create mode 100755 scripts/build_host.sh create mode 100755 scripts/build_s32z280.sh create mode 100755 scripts/check_terminology.sh create mode 100755 scripts/install.sh create mode 100755 scripts/test_fvp.sh create mode 100755 scripts/test_host.sh create mode 100755 scripts/test_s32z280.sh create mode 100755 test/fvp/run.sh create mode 100644 test/host/CMakeLists.txt create mode 100755 test/host/run.sh create mode 100644 test/host/test_zx_api.c create mode 100644 test/host/zx_test.h create mode 100755 test/s32z280/run.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2752e23 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,35 @@ +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### + +.git* export-ignore + +# Custom attribute marking sources that follow the project's C code style. +[attr]our-c-style whitespace=tab-in-indent eol=lf + +# Line endings. ZoneX is built on Linux and on the Armv8-R AEM FVP; the +# PowerShell entries are here so that a Windows story, should ZoneX ever gain +# one (see docs/decisions.md D13), does not arrive with mixed endings. +*.sh eol=lf +*.py eol=lf +*.ps1 eol=crlf +*.bat eol=crlf + +*.c our-c-style +*.h our-c-style +*.S our-c-style + +*.cmake whitespace=tab-in-indent +*.txt whitespace=tab-in-indent +*.lds whitespace=tab-in-indent diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..3921c04 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,16 @@ +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### + +@eclipse-threadx/admins diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..bade7f0 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,39 @@ + + +## What this changes, and why + + + +## How it was verified + + + +## Checklist + + + +- [ ] Branched from `dev`, and targets `dev`. +- [ ] Commit subject starts with a past-tense verb. +- [ ] AI assistance, if any, is attributed: `Assisted-by:` in the commit and the + AI Disclosure paragraph in every new file header. No `Co-Authored-By:`. +- [ ] New files carry the full Eclipse header with the current year. +- [ ] Regression tests added or updated, or a stated reason why not. +- [ ] Builds clean under the strict warning set (`--preset ci-strict`). +- [ ] No A-profile or RISC-V terminology (`scripts/check_terminology.sh` passes). +- [ ] API or behaviour changes have a matching documentation pull request + against `rtos-docs-asciidoc`. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..be2a764 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,102 @@ +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### + +version: 2 + +# Keeps the pinned action SHAs moving. +# +# Every action reference under .github/workflows is a 40-character commit SHA +# with the version in a trailing comment. A SHA pin *without* this file is +# worse than a floating tag: it freezes CI on whatever was current the day it +# was written. ThreadX discovered that with actions/cache@v1 still sitting in +# a workflow until GitHub began auto-failing every request that used it, and +# with four other actions between two and four majors behind -- none of it +# reported anywhere, because there was no configuration like this one. +# +# Dependabot understands the SHA form and rewrites the trailing version comment +# together with the pin, so the comment cannot drift away from the SHA it +# describes. That is what keeps "which exact code ran in our CI" answerable +# from the repository, which the certification and SBOM work for ZoneX needs on +# its own account. +# +# Two things this does not fix. It reports drift, not silence: a workflow that +# never triggers rots unnoticed no matter what is pinned in it, which is why +# every ZoneX workflow triggers on pull_request against dev and main and on +# push to both. And a new pull_request trigger does not reach a pull request +# that is already open -- only a rebase makes new checks appear on one. +# +# There is no entry for any other ecosystem, and that is a decision rather than +# an oversight: the project forbids external dependencies, there are no +# submodules, and the pinned tools -- gcovr in scripts/install.sh, the Arm GNU +# Toolchain and ATfE versions in the workflows -- live where no Dependabot +# ecosystem can parse them. Those pins move by hand. +updates: + - package-ecosystem: "github-actions" + # "/" is the only accepted value for this ecosystem; it covers + # .github/workflows and .github/actions. + directory: "/" + + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Etc/UTC" + + # Dependabot reads this file from the repository's DEFAULT branch, which is + # main. But main holds the latest release and pull requests belong on dev, + # which is where the checks gate them. target-branch sends the pull + # requests to dev and makes Dependabot read the workflows it is updating + # from dev as well. + # + # The consequence to plan for: landing this file on dev ARMS it, it does not + # fire it. Nothing happens until a release merge carries it to main. + # + # Setting target-branch also opts out of Dependabot *security* updates, + # which only ever run against the default branch. For this ecosystem the + # cost is small -- an action advisory arrives as an ordinary version bump on + # the weekly run -- but it is a real trade and not a detail to rediscover + # later. + target-branch: "dev" + + groups: + # Patch and minor arrive together in one pull request: they are the + # routine traffic, and reviewing them one at a time is how an update + # queue starts being ignored, which is the failure mode this file exists + # to prevent. Majors stay ungrouped, one pull request each, because + # every breaking change ThreadX met in an action set was a major -- + # download-artifact v8 defaulting digest-mismatch to error, and + # upload-artifact v6 requiring runner 2.327.1 or newer. + actions-minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + + # Three distinct actions are in use today (checkout, cache, + # upload-artifact). Ten leaves room for a wave of majors without becoming + # a silent cap, and matches what ThreadX settled on. + open-pull-requests-limit: 10 + + labels: + - "dependencies" + + # Reviewers are not listed here. .github/CODEOWNERS already routes every + # path to @eclipse-threadx/admins and Dependabot honours it. + # + # Commit subjects are left at Dependabot's own "Bump x from a to b" + # wording. The project asks for a past-tense subject and still gets one: + # these pull requests are squash-merged, and the subject is set at that + # point. diff --git a/.github/workflows/clang_check.yml b/.github/workflows/clang_check.yml new file mode 100644 index 0000000..1f46c6d --- /dev/null +++ b/.github/workflows/clang_check.yml @@ -0,0 +1,136 @@ +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### + +name: clang_check + +# Cross-builds ZoneX for the Cortex-R52 with Arm Toolchain for Embedded, which +# is LLVM based and is the successor to Arm Compiler 6. +# +# Why a second toolchain on a repository this small: GNU as accepts several +# non-canonical assembly forms that LLVM's assembler rejects, and ZoneX will be +# substantially assembly by the time that starts to bite. Catching each one as +# it is written costs a minute; finding forty of them the first time somebody +# tries an LLVM build costs a week, which is what ThreadX's ports did before +# clang_check existed there. +# +# It compiles and links and executes nothing. + +on: + push: + branches: [ dev, main ] + paths: + - ".github/workflows/clang_check.yml" + - "CMakeLists.txt" + - "CMakePresets.json" + - "cmake/**" + - "common/**" + - "core/**" + - "platform/**" + - "examples/**" + pull_request: + branches: [ dev, main ] + # Duplicated deliberately; see the note in host_tests.yml. **Edit both.** + paths: + - ".github/workflows/clang_check.yml" + - "CMakeLists.txt" + - "CMakePresets.json" + - "cmake/**" + - "common/**" + - "core/**" + - "platform/**" + - "examples/**" + +jobs: + atfe: + runs-on: ubuntu-24.04 + + env: + # Pinned deliberately, as the runner image is. 22.1.0 is the version + # CONTRIBUTING.md names as the reference. + # Releases: https://github.com/arm/arm-toolchain/releases + ATFE_VERSION: 22.1.0 + + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Cache Arm Toolchain for Embedded + id: cache-atfe + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: atfe + key: atfe-${{ env.ATFE_VERSION }}-linux-x86_64 + + # Arm publishes a plain .sha256 for this one, unlike the GNU toolchain + # whose .sha256 is an MD5. See the note in gcc_check.yml before assuming + # the two are interchangeable. + - name: Install Arm Toolchain for Embedded + if: steps.cache-atfe.outputs.cache-hit != 'true' + run: | + set -eu + base="https://github.com/arm/arm-toolchain/releases/download/release-${ATFE_VERSION}-ATfE" + archive="ATfE-${ATFE_VERSION}-Linux-x86_64.tar.xz" + mkdir -p atfe && cd atfe + curl -fsSLO "$base/$archive" + curl -fsSLO "$base/$archive.sha256" + sha256sum -c "$archive.sha256" + tar xf "$archive" + rm -f "$archive" + + - name: Ensure ninja is available + run: | + set -eu + if command -v ninja >/dev/null 2>&1; then + ninja --version + else + sudo apt-get update + sudo apt-get install -y --no-install-recommends ninja-build + fi + + - name: Build every Cortex-R52 configuration with clang + run: | + set -eu + atfe="$GITHUB_WORKSPACE/atfe/ATfE-${ATFE_VERSION}-Linux-x86_64/bin" + "$atfe/clang" --version | head -1 + + status=0 + + build_configuration() { + name="$1"; shift + echo "" + echo "==============================================================" + echo "== $name" + echo "==============================================================" + if ! cmake -S . -B "build/clang-$name" -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE=cmake/cortex_r52_clang.cmake \ + -DZX_ATFE_TOOLCHAIN_PATH="$atfe" \ + -DCMAKE_COMPILE_WARNING_AS_ERROR=ON "$@"; then + echo "::error::$name: CMake configure failed" + status=1 + return + fi + if ! cmake --build "build/clang-$name"; then + echo "::error::$name: build failed" + status=1 + fi + } + + build_configuration fvp \ + -DZX_BUILD_FVP_EXAMPLE=ON -DZX_BUILD_S32Z280_EXAMPLE=OFF + + build_configuration s32z280 \ + -DZX_BUILD_FVP_EXAMPLE=OFF -DZX_BUILD_S32Z280_EXAMPLE=ON + + exit $status diff --git a/.github/workflows/gcc_check.yml b/.github/workflows/gcc_check.yml new file mode 100644 index 0000000..e629d7a --- /dev/null +++ b/.github/workflows/gcc_check.yml @@ -0,0 +1,160 @@ +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### + +name: gcc_check + +# Cross-builds ZoneX for the Cortex-R52 with the Arm GNU Toolchain, in every +# configuration, with warnings as errors. +# +# GCC is the project's declared default compiler (CONTRIBUTING.md, "Required +# tooling"), and this is the workflow that holds it to that. It compiles and links and +# EXECUTES NOTHING -- zx_fvp.yml is what runs the images. Both exist because +# they fail differently: a change that assembles cleanly and then hangs on the +# first exception passes this workflow and fails that one, and a change that +# only breaks the S32Z280 configuration fails here where no model can catch it. +# +# The S32Z280 lane is build-only and always will be. It needs silicon, and a +# hosted runner has none. + +on: + push: + branches: [ dev, main ] + paths: + - ".github/workflows/gcc_check.yml" + - "CMakeLists.txt" + - "CMakePresets.json" + - "cmake/**" + - "common/**" + - "core/**" + - "platform/**" + - "examples/**" + pull_request: + branches: [ dev, main ] + # Duplicated deliberately; see the note in host_tests.yml. **Edit both.** + paths: + - ".github/workflows/gcc_check.yml" + - "CMakeLists.txt" + - "CMakePresets.json" + - "cmake/**" + - "common/**" + - "core/**" + - "platform/**" + - "examples/**" + +jobs: + gnu: + runs-on: ubuntu-24.04 + + env: + # Pinned deliberately, as the runner image is: a toolchain upgrade should + # be a reviewable commit rather than something that changes underneath + # the hypervisor. 14.3.rel1 is the version CONTRIBUTING.md names as the + # reference, and the version ThreadX pins, so the two repositories are + # comparable. + # Releases: https://developer.arm.com/downloads/-/arm-gnu-toolchain-downloads + GCC_VERSION: 14.3.rel1 + + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # The path and key match zx_fvp.yml's exactly so the two workflows share + # one cache entry rather than each holding its own copy of the same archive. + # Change them together or the sharing silently stops, and the only symptom + # is a slower run. + - name: Cache the AArch32 Arm GNU toolchain + id: cache-arm32 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: toolchain + key: arm-gnu-toolchain-${{ env.GCC_VERSION }}-x86_64-arm-none-eabi + + # The checksum suffix is .sha256asc and not .sha256. Arm publishes both, + # and for arm-none-eabi the .sha256 file contains a 32-character MD5, so + # "sha256sum -c" on it fails with "no properly formatted checksum lines + # found". Do not "simplify" the suffix; ThreadX paid for this one. + - name: Install the AArch32 Arm GNU toolchain + if: steps.cache-arm32.outputs.cache-hit != 'true' + run: | + set -eu + base="https://developer.arm.com/-/media/Files/downloads/gnu/${GCC_VERSION}/binrel" + archive="arm-gnu-toolchain-${GCC_VERSION}-x86_64-arm-none-eabi.tar.xz" + mkdir -p toolchain && cd toolchain + curl -fsSLO "$base/$archive" + curl -fsSLO "$base/$archive.sha256asc" + sha256sum -c "$archive.sha256asc" + tar xf "$archive" + rm -f "$archive" + + # Only reaches apt if the runner image has stopped shipping ninja. ZoneX + # inherits ThreadX's rule about unguarded apt calls: scripts/install.sh + # carries the long version of why, and it comes down to apt-get update + # having stalled for over two hours and taken a whole run with it. Do not + # make this unconditional. + - name: Ensure ninja is available + run: | + set -eu + if command -v ninja >/dev/null 2>&1; then + ninja --version + else + sudo apt-get update + sudo apt-get install -y --no-install-recommends ninja-build + fi + + # Every configuration is configured and built by one loop, so that adding a + # configuration is one line rather than a near-identical block of YAML. + # The status is accumulated rather than allowed to abort, so one broken + # configuration does not hide the state of the others. + - name: Build every Cortex-R52 configuration + run: | + set -eu + export PATH="$GITHUB_WORKSPACE/toolchain/arm-gnu-toolchain-${GCC_VERSION}-x86_64-arm-none-eabi/bin:$PATH" + arm-none-eabi-gcc --version | head -1 + + status=0 + + build_configuration() { + name="$1"; shift + echo "" + echo "==============================================================" + echo "== $name" + echo "==============================================================" + if ! cmake -S . -B "build/$name" -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE=cmake/cortex_r52.cmake \ + -DCMAKE_COMPILE_WARNING_AS_ERROR=ON "$@"; then + echo "::error::$name: CMake configure failed" + status=1 + return + fi + if ! cmake --build "build/$name"; then + echo "::error::$name: build failed" + status=1 + fi + } + + build_configuration fvp \ + -DZX_BUILD_FVP_EXAMPLE=ON -DZX_BUILD_S32Z280_EXAMPLE=OFF + + build_configuration s32z280 \ + -DZX_BUILD_FVP_EXAMPLE=OFF -DZX_BUILD_S32Z280_EXAMPLE=ON + + # Hard float is not a hypothetical lane. ZoneX itself uses no floating + # point at EL2, but a guest built hard-float cannot link against + # soft-float objects, so the ABI has to keep working on both sides. + build_configuration fvp-hardfloat \ + -DZX_BUILD_FVP_EXAMPLE=ON -DZX_BUILD_S32Z280_EXAMPLE=OFF \ + -DZX_R52_FLOAT_ABI=hard + + exit $status diff --git a/.github/workflows/host_tests.yml b/.github/workflows/host_tests.yml new file mode 100644 index 0000000..215c2d0 --- /dev/null +++ b/.github/workflows/host_tests.yml @@ -0,0 +1,114 @@ +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### + +name: host_tests + +# Builds and runs the ZoneX host unit-test suite, under the strict warning set +# with warnings as errors, and reports coverage. Also runs the repository +# checks that need no toolchain. +# +# What this suite covers, and what it deliberately does not: it exercises the +# architecture-independent half of ZoneX -- the manifest and its validator, the +# partition tables, the schedule arithmetic. It cannot cover stage-2 +# programming, the trap path or isolation, and it does not pretend to; those +# are only true on the model and on silicon, and zx_fvp.yml is where they are +# checked. See docs/decisions.md D11, which also records why ZoneX does not +# hold ThreadX's 99/100 coverage threshold over the whole repository. + +on: + # push as well as pull_request. dev needs its own baseline so that a bad + # squash-merge is caught then, rather than by whoever opens the next pull + # request. Both branches are named because both receive merges: dev during + # the quarter, main at release. + # + # Getting these triggers wrong is not hypothetical. ThreadX's + # ci_cortex_m.yml ran on master only while dev was the integration branch, + # so it gated no pull request anybody opened and the ports drifted unnoticed + # for eight months. + push: + branches: [ dev, main ] + paths: + - ".github/workflows/host_tests.yml" + - "CMakeLists.txt" + - "CMakePresets.json" + - "cmake/**" + - "common/**" + - "core/**" + - "scripts/**" + - "test/host/**" + pull_request: + branches: [ dev, main ] + # Duplicated rather than shared through a YAML anchor. GitHub Actions' + # parser does not dependably honour anchors and the failure mode is the + # whole workflow refusing to parse -- a job that dies before it does + # anything, which is worse than the duplication. **Edit both.** + paths: + - ".github/workflows/host_tests.yml" + - "CMakeLists.txt" + - "CMakePresets.json" + - "cmake/**" + - "common/**" + - "core/**" + - "scripts/**" + - "test/host/**" + +jobs: + # Cheap, and independent of every toolchain, so it runs as its own job and + # gives an unambiguous answer rather than being buried in a build log. + terminology: + runs-on: ubuntu-24.04 + steps: + # Actions are pinned to a commit SHA with the version in the trailing + # comment. A tag can be moved; a SHA cannot, which is what makes "which + # code ran in our CI" answerable from the repository -- something the + # certification work needs on its own account. Dependabot moves these pins + # and rewrites the comments with them; see .github/dependabot.yml. + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check for A-profile and RISC-V terminology + run: scripts/check_terminology.sh + + host: + runs-on: ubuntu-24.04 + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install the build and test dependencies + run: scripts/install.sh + + # ci-strict rather than the default preset: CMAKE_COMPILE_WARNING_AS_ERROR + # is on there, and the point of the strict warning set is that CI enforces + # it while a developer build stays warning-tolerant. + - name: Build and test, warnings as errors + run: | + set -eu + cmake --preset ci-strict + cmake --build --preset ci-strict + ctest --preset ci-strict + + # A separate, instrumented build. Coverage instrumentation changes code + # generation, so the run that gates the pull request should not be the + # instrumented one. + - name: Coverage + run: scripts/test_host.sh coverage + + - name: Upload the coverage report + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage_report + path: coverage_report/ + if-no-files-found: error diff --git a/.github/workflows/zx_fvp.yml b/.github/workflows/zx_fvp.yml new file mode 100644 index 0000000..6fe660d --- /dev/null +++ b/.github/workflows/zx_fvp.yml @@ -0,0 +1,267 @@ +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### + +name: zx_fvp + +# Builds every ZoneX Cortex-R52 image and EXECUTES it on the Armv8-R AEM FVP, +# asserting each image's self-reported result. +# +# Why this exists from the first commit rather than from the step that needs +# it: ZoneX is a hypervisor, and there is no static check for "the partition +# still runs" or "the stage-2 fault still fires". A change that assembles +# cleanly, links cleanly and then hangs at EL2 has to be caught by an execution +# test. ThreadX added the equivalent workflow eight years into its life, after +# a period in which nothing in CI ran a single instruction of any port; ZoneX +# gets it while there is one image to run and the shape is cheap to set up. +# +# Modelled directly on threadx's .github/workflows/r52_fvp.yml, including the +# repository-variable arrangement for the model, because Arm has no stable +# unauthenticated download URL for it. +# +# STATUS: the repository contains no images at all yet. The workflow builds the +# hypervisor libraries and says loudly that it executed nothing. Once the first +# image lands this workflow starts doing what its name says. The "no images" +# path is a warning rather than a failure ON PURPOSE and only while that is +# true -- see the comment at the check itself. +# +# The S32Z280 targets are not run here and never will be. They need silicon; +# gcc_check.yml builds them. + +on: + push: + branches: [ dev, main ] + paths: + - ".github/workflows/zx_fvp.yml" + - "CMakeLists.txt" + - "CMakePresets.json" + - "cmake/**" + - "common/**" + - "core/**" + - "platform/**" + - "examples/fvp_baser_aemv8r/**" + - "test/fvp/**" + pull_request: + branches: [ dev, main ] + # Duplicated deliberately; see the note in host_tests.yml. **Edit both.** + paths: + - ".github/workflows/zx_fvp.yml" + - "CMakeLists.txt" + - "CMakePresets.json" + - "cmake/**" + - "common/**" + - "core/**" + - "platform/**" + - "examples/fvp_baser_aemv8r/**" + - "test/fvp/**" + +jobs: + fvp: + runs-on: ubuntu-24.04 + + env: + # Matches gcc_check.yml so the two share one toolchain cache entry. + GCC_VERSION: 14.3.rel1 + + # Where the Armv8-R AEM FVP comes from. + # + # Arm distributes it free of charge but behind a click-through licence, + # with no stable unauthenticated download URL that can be hard-coded -- + # the permalink forms under developer.arm.com/-/cdn-downloads all 404 for + # this model. So the location is a repository variable rather than a + # literal, and a maintainer sets it once to somewhere the runner can + # reach: an Eclipse-hosted mirror, a release asset, or Arm's own URL if a + # durable one ever appears. + # + # Set FVP_AEMV8R_URL to a .tgz/.tar.gz of the model, and optionally + # FVP_AEMV8R_SHA256 to its checksum. With the checksum set the download + # is verified; without it the run says so rather than pretending. + # + # When the variable is unset the build lanes still run and still gate the + # pull request -- only execution is skipped, loudly, in the log and in the + # job summary. A silent green here would recreate the exact hole this + # workflow exists to close. + FVP_URL: ${{ vars.FVP_AEMV8R_URL }} + FVP_SHA256: ${{ vars.FVP_AEMV8R_SHA256 }} + + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Cache the AArch32 Arm GNU toolchain + id: cache-arm32 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: toolchain + key: arm-gnu-toolchain-${{ env.GCC_VERSION }}-x86_64-arm-none-eabi + + # .sha256asc, not .sha256; see gcc_check.yml. + - name: Install the AArch32 Arm GNU toolchain + if: steps.cache-arm32.outputs.cache-hit != 'true' + run: | + set -eu + base="https://developer.arm.com/-/media/Files/downloads/gnu/${GCC_VERSION}/binrel" + archive="arm-gnu-toolchain-${GCC_VERSION}-x86_64-arm-none-eabi.tar.xz" + mkdir -p toolchain && cd toolchain + curl -fsSLO "$base/$archive" + curl -fsSLO "$base/$archive.sha256asc" + sha256sum -c "$archive.sha256asc" + tar xf "$archive" + rm -f "$archive" + + - name: Ensure ninja is available + run: | + set -eu + if command -v ninja >/dev/null 2>&1; then + ninja --version + else + sudo apt-get update + sudo apt-get install -y --no-install-recommends ninja-build + fi + + # Keyed on the URL's own hash rather than a version string, so pointing the + # variable at a different model fetches it instead of serving the old one + # from cache. A raw URL may contain characters a cache key cannot. + - name: Cache the Armv8-R AEM FVP + id: cache-fvp + if: env.FVP_URL != '' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: fvp + key: fvp-aemv8r-${{ hashFiles('.github/workflows/zx_fvp.yml') }}-${{ env.FVP_SHA256 || 'nosum' }} + + - name: Install the Armv8-R AEM FVP + if: env.FVP_URL != '' && steps.cache-fvp.outputs.cache-hit != 'true' + run: | + set -eu + mkdir -p fvp && cd fvp + curl -fsSL -o fvp.tgz "$FVP_URL" + if [ -n "${FVP_SHA256:-}" ]; then + echo "$FVP_SHA256 fvp.tgz" | sha256sum -c - + else + echo "::warning::FVP_AEMV8R_SHA256 is not set; the download was not verified." + fi + tar xf fvp.tgz + rm -f fvp.tgz + + # The model links against libatomic. Everything else it needs is already + # on the runner image, and its own libscxframework/libsystemc ship inside + # the tarball. Guarded for the same reason the ninja step is. + - name: Ensure the FVP's runtime dependencies are present + if: env.FVP_URL != '' + run: | + set -eu + if ldconfig -p | grep -q 'libatomic\.so\.1'; then + echo "libatomic1 present" + else + sudo apt-get update + sudo apt-get install -y --no-install-recommends libatomic1 + fi + + # Resolve the model once and hand its absolute path to CMake below. + # + # examples/fvp_baser_aemv8r/CMakeLists.txt finds it with find_program and a + # hint that is right on a developer's machine and meaningless here, so the + # path is passed explicitly. find_program caches into + # ZX_FVP_BASER_AEMV8R, which means -DZX_FVP_BASER_AEMV8R= overrides + # it with no change to the CMake files. + # + # "present", not a bare "did the variable exist", so that a URL which + # downloads something that is not the model fails here rather than silently + # skipping every test later. + - name: Locate the FVP + id: fvp + run: | + set -eu + if [ -z "${FVP_URL:-}" ]; then + echo "present=false" >>"$GITHUB_OUTPUT" + echo "::warning::FVP_AEMV8R_URL is not set for this repository, so the ZoneX images were built but NOT executed. Set the repository variable to enable the execution lanes." + { + echo "### ZoneX FVP execution: SKIPPED" + echo "" + echo "\`FVP_AEMV8R_URL\` is not set, so this run built the images but executed none of them." + echo "Set the repository variable (Settings -> Secrets and variables -> Actions -> Variables)" + echo "to a download URL for the Armv8-R AEM FVP to turn the execution lanes on." + } >>"$GITHUB_STEP_SUMMARY" + exit 0 + fi + binary="$(find fvp -type f -name FVP_BaseR_AEMv8R -perm -u+x | head -1)" + if [ -z "$binary" ]; then + echo "FVP_AEMV8R_URL was set, but no FVP_BaseR_AEMv8R binary was found in the archive." >&2 + find fvp -maxdepth 3 -type d | sed 's/^/ /' >&2 + exit 1 + fi + binary="$(cd "$(dirname "$binary")" && pwd)/$(basename "$binary")" + echo "present=true" >>"$GITHUB_OUTPUT" + echo "binary=$binary" >>"$GITHUB_OUTPUT" + "$binary" --version + + - name: Build and run the ZoneX images + run: | + set -eu + export PATH="$GITHUB_WORKSPACE/toolchain/arm-gnu-toolchain-${GCC_VERSION}-x86_64-arm-none-eabi/bin:$PATH" + arm-none-eabi-gcc --version | head -1 + + fvp_present='${{ steps.fvp.outputs.present }}' + fvp_binary='${{ steps.fvp.outputs.binary }}' + + fvp_arg="" + if [ "$fvp_present" = "true" ]; then + fvp_arg="-DZX_FVP_BASER_AEMV8R=$fvp_binary" + fi + + cmake -S . -B build/fvp -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE=cmake/cortex_r52.cmake \ + -DCMAKE_COMPILE_WARNING_AS_ERROR=ON \ + -DZX_BUILD_FVP_EXAMPLE=ON \ + -DZX_BUILD_S32Z280_EXAMPLE=OFF \ + $fvp_arg + + cmake --build build/fvp + + # Every image is named from the generated ninja graph rather than from + # a list kept here. Images are EXCLUDE_FROM_ALL, so the build above + # reports "no work to do" for them and would compare nothing; reading + # the graph means an image added to CMakeLists.txt cannot escape this + # check by nobody remembering to list it. + images="$(ninja -C build/fvp -t targets all 2>/dev/null \ + | grep -oE '^[A-Za-z0-9_]+\.elf' \ + | grep -v '^cmake_' | sort -u || true)" + + # A warning, not a failure -- and ONLY while the repository genuinely + # has no images, which is true only until the first one lands. At that + # moment, change this to "::error::" and a non-zero exit: from then on, + # an empty image list means the CMake graph broke, and a workflow that + # shrugs at that is the hole this file exists to close. + if [ -z "$images" ]; then + echo "::warning::ZoneX has no FVP images yet. Nothing was executed." + { + echo "### ZoneX FVP execution: NOTHING TO RUN" + echo "" + echo "The hypervisor libraries built, but the repository contains no images yet." + echo "The first one lands with the EL2 bring-up work." + } >>"$GITHUB_STEP_SUMMARY" + exit 0 + fi + + echo "Building $(echo "$images" | wc -l) images" + # shellcheck disable=SC2086 + cmake --build build/fvp --target $images + + if [ "$fvp_present" != "true" ]; then + echo "Built, not executed (no FVP configured)." + exit 0 + fi + + ctest --test-dir build/fvp --output-on-failure diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..72b2041 --- /dev/null +++ b/.gitignore @@ -0,0 +1,60 @@ +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### + +# Build trees. build/ covers the layout scripts/*.sh and CMakePresets.json use; +# the rest are the directories a hand-run cmake tends to leave behind. +build/ +build_*/ +_deps/ +CMakeFiles/ +CMakeScripts/ +CMakeCache.txt +CMakeLists.txt.user +CMakeUserPresets.json +cmake_install.cmake +install_manifest.txt +compile_commands.json +CTestTestfile.cmake +Testing/ +Makefile + +# Build artifacts. ZoneX links no shared libraries and ships no prebuilt +# objects, so anything matching these in a work tree is output. +*.o +*.obj +*.a +*.elf +*.axf +*.bin +*.map +*.lst +*.dep + +# Coverage. gcovr writes its reports where it is invoked. +*.gcno +*.gcda +*.gcov +coverage_report/ + +# Python, used by the FVP test runner. +__pycache__/ +*.pyc + +# Editor and agent state. +.vscode/ +.settings/ +.metadata/ +.tmp/ +.codex diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..4b6a3b0 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,158 @@ +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# Eclipse ThreadX ZoneX -- root build. +# +# ZoneX is the first component of the suite born on the C17 baseline rather +# than migrated to it, so this file is meant to be the reference implementation +# of the suite-wide C17/CMake plan rather than an exception to it. See +# docs/decisions.md D12. + +cmake_minimum_required(VERSION 3.28...4.2) + +project(zonex + VERSION 0.1.0 + LANGUAGES C + DESCRIPTION "Eclipse ThreadX ZoneX -- a deterministic partitioning hypervisor" +) + +# ZX_ARCH and ZX_TOOLCHAIN are set by the toolchain file, which CMake reads +# during the project() call above -- checking them any earlier would always +# fail. There is no default: a hypervisor silently configured for the wrong +# target is worse than one that refuses to configure. +if(NOT DEFINED ZX_ARCH) + message(FATAL_ERROR + "No ZoneX toolchain file was given, so ZX_ARCH is unset.\n" + " Cortex-R52 cross build: -DCMAKE_TOOLCHAIN_FILE=cmake/cortex_r52.cmake\n" + " Host unit-test build: -DCMAKE_TOOLCHAIN_FILE=cmake/host.cmake") +endif() + +# The host build compiles no assembly and has none to compile. enable_language +# is called here rather than in project() so that a host build does not require +# an assembler it will never invoke. +if(NOT ZX_ARCH STREQUAL "host") + enable_language(ASM) +endif() + +message(STATUS "ZoneX architecture: ${ZX_ARCH} (${ZX_TOOLCHAIN})") + +# C17, strictly. CMAKE_C_EXTENSIONS OFF matters as much as the standard +# itself: it is what makes -Wpedantic meaningful, and what makes the C23 +# attribute question in common/inc/zx_api.h a real constraint rather than +# something GNU mode papers over. +set(CMAKE_C_STANDARD 17) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS OFF) + +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE Debug CACHE STRING "Build type" FORCE) +endif() + +# Enabled at the top level so that tests registered further down the tree are +# discoverable with "ctest --test-dir " from the build root. Without +# this, add_test() in a subdirectory still writes a CTestTestfile.cmake there, +# but nothing references it and ctest reports no tests at all. +enable_testing() + +include(${CMAKE_CURRENT_LIST_DIR}/cmake/zx_warnings.cmake) + +############################################################################### +# Options +############################################################################### + +# Defaults follow the toolchain, because the alternative -- a fixed default -- +# means every invocation of every script has to override one of them. +if(ZX_ARCH STREQUAL "host") + set(ZX_HOST_TESTS_DEFAULT ON) + set(ZX_TARGET_EXAMPLES_DEFAULT OFF) +else() + set(ZX_HOST_TESTS_DEFAULT OFF) + set(ZX_TARGET_EXAMPLES_DEFAULT ON) +endif() + +option(ZX_BUILD_HOST_TESTS + "Build the host-compiled unit tests under test/host" + ${ZX_HOST_TESTS_DEFAULT}) +option(ZX_BUILD_FVP_EXAMPLE + "Build the Armv8-R AEM FVP example under examples/fvp_baser_aemv8r" + ${ZX_TARGET_EXAMPLES_DEFAULT}) +option(ZX_BUILD_S32Z280_EXAMPLE + "Build the NXP S32Z280-594EVB example under examples/s32z280_evb" + ${ZX_TARGET_EXAMPLES_DEFAULT}) + +# The seam to a ThreadX checkout. ZoneX itself does not link ThreadX -- at EL2 +# it is a standalone bare-metal program (docs/decisions.md D9) -- but the +# examples build ThreadX GUEST images, and those need ThreadX sources. The +# variable is declared now and consumed once the examples build guest images; +# declaring it early is what makes the dependency visible rather than +# discovered. +set(ZX_THREADX_SOURCE_DIR "" CACHE PATH + "Path to a ThreadX checkout, used to build the ThreadX guest images under examples/. \ +Leave empty until the examples build guest images.") + +############################################################################### +# The hypervisor +############################################################################### + +# Two libraries rather than one, and the split is not cosmetic: zonex_core is +# the architecture-independent half -- the manifest, the partition tables, the +# schedule -- and is exactly what the host suite can compile and test without a +# model or a board. zonex_port is everything that touches CP15, and exists +# only on a cross build. +add_library(zonex_core STATIC) +add_library(zonex::core ALIAS zonex_core) + +target_include_directories(zonex_core PUBLIC + ${CMAKE_CURRENT_LIST_DIR}/common/inc + ${CMAKE_CURRENT_LIST_DIR}/core/inc +) +target_link_libraries(zonex_core PRIVATE zonex::warnings) + +add_subdirectory(core) + +if(NOT ZX_ARCH STREQUAL "host") + add_library(zonex_port STATIC) + add_library(zonex::port ALIAS zonex_port) + + target_link_libraries(zonex_port PUBLIC zonex_core) + target_link_libraries(zonex_port PRIVATE zonex::warnings) + + add_subdirectory(platform/${ZX_ARCH}) +endif() + +# What a consumer links. An INTERFACE target rather than a third archive, so +# that adding a port library later does not change how anything links. +add_library(zonex INTERFACE) +add_library(eclipse-threadx::zonex ALIAS zonex) +target_link_libraries(zonex INTERFACE zonex_core) +if(TARGET zonex_port) + target_link_libraries(zonex INTERFACE zonex_port) +endif() + +############################################################################### +# Examples and tests +############################################################################### + +if(ZX_BUILD_HOST_TESTS) + add_subdirectory(test/host) +endif() + +if(ZX_BUILD_FVP_EXAMPLE) + add_subdirectory(examples/fvp_baser_aemv8r) +endif() + +if(ZX_BUILD_S32Z280_EXAMPLE) + add_subdirectory(examples/s32z280_evb) +endif() diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..52987ef --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,133 @@ +{ + "version": 6, + "cmakeMinimumRequired": { + "major": 3, + "minor": 28, + "patch": 0 + }, + "vendor": { + "org.eclipse.threadx": { + "copyright": "Copyright (c) 2026 Eclipse ThreadX contributors", + "license": "This program and the accompanying materials are made available under the terms of the MIT License which is available at https://opensource.org/licenses/MIT.", + "aiDisclosure": "This file was largely AI-generated by Claude Code (Opus 5). The AI-generated portions may be considered public domain (CC0-1.0) and not subject to the project's licence. The human contributor has reviewed and verified that the code is correct.", + "spdxLicenseIdentifier": "MIT and CC0-1.0", + "note": "JSON permits no comments, so this vendor block carries the header every other file in the repository has in its own comment syntax. CMake ignores the contents of vendor objects." + } + }, + "configurePresets": [ + { + "name": "default", + "displayName": "Host build", + "description": "The architecture-independent half of ZoneX plus the host unit tests. Warning-tolerant, so a half-finished change can still be compiled and looked at.", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/host", + "toolchainFile": "${sourceDir}/cmake/host.cmake", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "ZX_BUILD_HOST_TESTS": "ON" + } + }, + { + "name": "ci-strict", + "inherits": "default", + "displayName": "Host build, warnings are errors", + "description": "What CI runs. CMAKE_COMPILE_WARNING_AS_ERROR is set here rather than in the default preset deliberately: developer builds stay warning-tolerant and CI enforces strictness.", + "binaryDir": "${sourceDir}/build/ci-strict", + "cacheVariables": { + "CMAKE_COMPILE_WARNING_AS_ERROR": "ON" + } + }, + { + "name": "coverage", + "inherits": "default", + "displayName": "Host build, instrumented", + "description": "Adds --coverage to zonex_core and the test binaries. Report with scripts/test_host.sh coverage.", + "binaryDir": "${sourceDir}/build/coverage", + "cacheVariables": { + "ZX_COVERAGE": "ON" + } + }, + { + "name": "fvp", + "displayName": "Cortex-R52 cross build, Armv8-R AEM FVP", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/fvp", + "toolchainFile": "${sourceDir}/cmake/cortex_r52.cmake", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "ZX_BUILD_FVP_EXAMPLE": "ON", + "ZX_BUILD_S32Z280_EXAMPLE": "OFF" + } + }, + { + "name": "fvp-strict", + "inherits": "fvp", + "displayName": "Cortex-R52 cross build, warnings are errors", + "binaryDir": "${sourceDir}/build/fvp-strict", + "cacheVariables": { + "CMAKE_COMPILE_WARNING_AS_ERROR": "ON" + } + }, + { + "name": "s32z280", + "displayName": "Cortex-R52 cross build, NXP S32Z280-594EVB", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/s32z280", + "toolchainFile": "${sourceDir}/cmake/cortex_r52.cmake", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "ZX_BUILD_FVP_EXAMPLE": "OFF", + "ZX_BUILD_S32Z280_EXAMPLE": "ON" + } + } + ], + "buildPresets": [ + { + "name": "default", + "configurePreset": "default" + }, + { + "name": "ci-strict", + "configurePreset": "ci-strict" + }, + { + "name": "coverage", + "configurePreset": "coverage" + }, + { + "name": "fvp", + "configurePreset": "fvp" + }, + { + "name": "fvp-strict", + "configurePreset": "fvp-strict" + }, + { + "name": "s32z280", + "configurePreset": "s32z280" + } + ], + "testPresets": [ + { + "name": "default", + "configurePreset": "default", + "output": { + "outputOnFailure": true + } + }, + { + "name": "ci-strict", + "configurePreset": "ci-strict", + "output": { + "outputOnFailure": true + } + }, + { + "name": "coverage", + "configurePreset": "coverage", + "output": { + "outputOnFailure": true + } + } + ] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9fb9eb8..0e5d2b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,11 +68,13 @@ For more information, please see the Eclipse Committer Handbook: https://www.ecl ## Required tooling -Eclipse ThreadX components build with CMake and Ninja. There is no dependency on any IDE, and on Windows there is deliberately no dependency on Visual Studio itself - the Visual Studio Build Tools are enough. +Eclipse ThreadX components build with CMake and Ninja. There is no dependency on any IDE. + +**ZoneX is built and tested on Linux only.** Its targets are the Armv8-R AEM FVP and the NXP S32Z280-594EVB, both cross-compiled with the Arm toolchains, and its host unit tests are built with GCC. There is no Windows build and no PowerShell script set; see [`docs/decisions.md`](docs/decisions.md) D13, which also records how cheaply that could change if ZoneX ever acquires a reason for it. Other components of the suite do support Windows through the Visual Studio Build Tools, with deliberately no dependency on Visual Studio itself. | Tool | Requirement | | ---- | ----------- | -| CMake | 3.13 or later | +| CMake | 3.28 or later | | Ninja | any recent release | | Git | any recent release | | Python | 3.x, with `gcovr` 8.6 for coverage reports | @@ -92,17 +94,42 @@ All assembly code targeting Linux toolchains must use GCC syntax. ## Building and testing -ZoneX does not ship a regression suite yet. This section will be completed once the test harness lands. +ZoneX follows the convention the other Eclipse ThreadX repositories use: `scripts/` holds one build script and one test script per test target, each a thin wrapper over a `run.sh` under `test/` where the logic actually lives. + +| Script pair | Target | What it does | +| ----------- | ------ | ------------ | +| `scripts/build_host.sh`, `scripts/test_host.sh` | host | Builds and runs the host unit tests over the architecture-independent code. Pass `coverage` to `test_host.sh` for a gcovr report. | +| `scripts/build_fvp.sh`, `scripts/test_fvp.sh` | Armv8-R AEM FVP | Cross-builds the Cortex-R52 images and executes them on the model. | +| `scripts/build_s32z280.sh`, `scripts/test_s32z280.sh` | NXP S32Z280-594EVB | Cross-builds the same images for silicon. Running them needs the board. | +| `scripts/install.sh` | — | Installs the build and test dependencies on Ubuntu. | +| `scripts/check_terminology.sh` | — | Rejects register and concept names that belong to other architectures. See below. | + +`CMakePresets.json` offers the same builds directly: `--preset default` for a warning-tolerant host build, `--preset ci-strict` for the host build with warnings as errors, `--preset coverage`, and `--preset fvp` / `--preset s32z280` for the cross builds. + +**Which suite does your change belong in?** ZoneX runs two, and the split is deliberate. Architecture-independent logic — the partition manifest and its validator, the partition tables, the schedule arithmetic — is covered by the host suite, which runs anywhere in seconds. Stage-2 MPU programming, the trap path, isolation and every timing claim are only true on the FVP and on silicon and are tested there; a host simulator would be testing a simulation of the mechanism rather than the mechanism. [`docs/decisions.md`](docs/decisions.md) D11 has the full reasoning, including why ZoneX does not hold the suite's usual coverage threshold over the whole repository. + +**A note on terminology.** ZoneX targets Armv8-R AArch32, where both stages of address control are region-based MPUs. Register names from the AArch64 system-register set, the translation-table registers, and RISC-V memory-protection vocabulary are wrong here by construction, and code that uses one was written against the wrong architecture. `scripts/check_terminology.sh` checks this mechanically and runs in CI; [`docs/armv8r-el2-reference.md`](docs/armv8r-el2-reference.md) holds the verified names, encodings and field layouts. Read it before writing anything that touches a register. -The suite will follow the conventions already in use across the Eclipse ThreadX repositories: a `scripts/` directory holding one build script and one test script per test target, shell scripts for Linux and PowerShell scripts for Windows, and CMake presets under `test/`. Each script's role - which target it covers - will be documented here alongside it. +**ZoneX is written to C17**, not C99 — it is the one component of the suite born on that baseline. Extensions are off and `-Wpedantic` is in force, so GNU-only constructs are rejected. -Until then, please describe in your pull request how you verified your change. +Whatever you build, describe in your pull request how you verified your change. "It builds" is not verification. ## Continuous integration -ZoneX has no CI workflows yet. They will be added with the test harness, and this section will describe each check and when it runs. +Four GitHub Actions workflows. **Every one of them triggers on `pull_request` against `dev` and `main`, and on `push` to `dev` and `main`** — a workflow that gates no pull request anybody opens is worse than no workflow, because it looks like coverage. -CI will follow the conventions used across the Eclipse ThreadX repositories: GitHub Actions workflows running on pull requests against `dev` and on pushes to `dev` and `main`, covering compiler checks on the reference toolchains, port build checks, and the regression suite with coverage reporting. Third-party actions are pinned to a commit SHA rather than a tag. +| Workflow | What it checks | +| -------- | -------------- | +| `host_tests.yml` | The host unit tests, built with warnings as errors, plus a coverage report. Runs `check_terminology.sh` as a separate job so its answer is unambiguous. | +| `gcc_check.yml` | Cross-builds every Cortex-R52 configuration — FVP, S32Z280, hard float — with the Arm GNU Toolchain and warnings as errors. Compiles and links; executes nothing. | +| `clang_check.yml` | The same sources with Arm Toolchain for Embedded. GNU `as` accepts non-canonical assembly forms that LLVM's assembler rejects, and ZoneX is going to be substantially assembly. | +| `zx_fvp.yml` | Builds the Cortex-R52 images and **executes** them on the Armv8-R AEM FVP, judging each by its self-reported result. There is no static check for "the partition still runs". | + +Arm distributes the Armv8-R AEM FVP free of charge but behind a click-through licence with no stable unauthenticated URL, so `zx_fvp.yml` takes the download location from the `FVP_AEMV8R_URL` repository variable (with an optional `FVP_AEMV8R_SHA256`). When it is unset the build lanes still run and still gate the pull request; only execution is skipped, and it says so in the log and in the job summary rather than passing quietly. + +Toolchain versions are pinned explicitly, so a bump is a reviewable commit. Third-party actions are pinned to a commit SHA rather than a tag, and `.github/dependabot.yml` keeps those pins moving — a SHA pin without Dependabot freezes CI on whatever was current the day it was written. + +**One thing to know if you have a pull request open already:** a workflow added after your branch was cut does not appear on it. Only a rebase makes new checks show up. ## Pull request acceptance criteria @@ -120,7 +147,7 @@ Before requesting a review, check your contribution against this list. **Code** -* The code is C99-compatible. +* The code is C17-compatible, and compiles clean with extensions off and `-Wpedantic`. (ZoneX differs from the rest of the suite here; see [`docs/decisions.md`](docs/decisions.md) D12.) * It follows the coding style of the surrounding code. * New functions and structures are documented in comments, as in existing code. * MISRA C rules are followed as closely as practical, taking MISRA C 2004, 2012 and 2023 into account. Any deviation is explicit, names the rule being circumvented, and justifies it in a comment. @@ -152,20 +179,25 @@ Two things are non-negotiable: This is consistent with the Eclipse Foundation's [Generative AI Usage Guidelines](https://www.eclipse.org/projects/guidelines/genai/) and the [Eclipse Project Handbook](https://www.eclipse.org/projects/handbook/#genai). Please read them before submitting AI-assisted work. -### AGENTS.md +### Using a coding agent + +There is no `AGENTS.md` in this repository, and no other Eclipse ThreadX repository carries one either. This document is the authority, and it is what to point an agent at. A per-repository restatement of these rules would be one more place for them to drift out of step with the document that governs them. + +Most agents read a file named `AGENTS.md` or `CLAUDE.md` from the working tree automatically. If you keep one, keep it untracked. -The repository root carries an `AGENTS.md` file that coding agents read automatically. It restates the rules in this document in a form agents can follow, and covers: +What an agent working on ZoneX has to be told, all of it either covered below or linked from here: -* **General and style rules** - C99, existing coding style, MISRA C, no `goto`, documented functions and structures, optimise for speed and code size. -* **Compilers** - GCC 14 on Linux, MSVC on Windows, GCC assembly syntax, CMake and Ninja for all builds and tests. -* **Headers** - the copyright and AI-disclosure templates reproduced below. -* **Dependencies** - external dependencies forbidden; never copy code from other implementations of a standard. -* **Regression tests** - 100% coverage, tests ship with the feature. -* **Git** - feature branches based on `dev`, never commit to `main` or `dev` directly, past-tense commit subjects, `Assisted-by` attribution. -* **Security advisories** - draft a GHSA when an agent finds a security issue. -* **Documentation** - documentation changes belong in `rtos-docs-asciidoc`. +* **What is different about ZoneX** - C17 rather than C99, the `zx_` / `ZX_` prefix, and the Armv8-R AArch32 terminology rules that `scripts/check_terminology.sh` enforces in CI. See [`docs/decisions.md`](docs/decisions.md) D12 and D1, and [`docs/armv8r-el2-reference.md`](docs/armv8r-el2-reference.md) for the verified register names. +* **General and style rules** - follow the surrounding code, MISRA C, no `goto`, document new functions and structures, optimise for speed, code size and predictable worst-case timing. +* **Compilers** - Arm GNU Toolchain 14.3.rel1 and ATfE 22.1.0 for cross builds, GCC 14 for the host build, GNU assembly syntax, CMake and Ninja for everything. +* **Headers** - the copyright and AI-disclosure templates below. Every file in this repository is new, so the new-file form is the only one ZoneX needs. +* **Dependencies** - external dependencies are forbidden; never copy code from another implementation of a standard. +* **Regression tests** - 100% coverage, tests ship with the feature, and the change goes in whichever of the two suites can actually exercise it. See "Building and testing" above. +* **Git** - feature branches based on `dev`, never commit to `main` or `dev` directly, past-tense commit subjects, `Assisted-by` attribution and never `Co-Authored-By`. +* **Security advisories** - draft a GHSA rather than describing the issue in a public pull request. +* **Documentation** - manual changes belong in `rtos-docs-asciidoc`; design decisions belong in [`docs/decisions.md`](docs/decisions.md) and verified hardware facts in [`docs/armv8r-el2-reference.md`](docs/armv8r-el2-reference.md). -If you use an agent, point it at `AGENTS.md`. If you contribute a change that alters any of these conventions, update `AGENTS.md` in the same pull request. +Whatever the agent produced, you are the contributor. The checklist under "Pull request acceptance criteria" above applies unchanged. ### Header for new files @@ -252,7 +284,7 @@ Fixed the guest context switch on AArch64 The saved SPSR was restored before the general-purpose registers, so a guest resuming from an exception observed the wrong PSTATE. -Assisted-by: Claude Code (Opus 5) +Assisted-by: Claude Code (Opus 5) ``` ## Release model and support diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..5609250 --- /dev/null +++ b/NOTICE @@ -0,0 +1,53 @@ +# Notices for Eclipse ThreadX + +This content is produced and maintained by the Eclipse ThreadX project. + +* Project home: https://projects.eclipse.org/projects/iot.threadx + +## Trademarks + +Eclipse ThreadX, and Eclipse ThreadX logo are trademarks of the Eclipse +Foundation. Eclipse, and the Eclipse Logo are registered trademarks of the +Eclipse Foundation. + +## Copyright + +All content is the property of the respective authors or their employers. For +more information regarding authorship of content, please consult the listed +source code repository logs. + +## Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the MIT License which is available at https://opensource.org/licenses/MIT. + +SPDX-License-Identifier: MIT + +Portions of this repository were generated with the assistance of generative AI +tools, as recorded in the AI Disclosure paragraph of the affected file headers. +Those portions may be considered public domain (CC0-1.0) and not subject to the +project's licence; the files carrying them declare +`SPDX-License-Identifier: MIT and CC0-1.0`. See CONTRIBUTING.md, "AI-assisted +contributions", and the Eclipse Foundation's Generative AI Usage Guidelines at +https://www.eclipse.org/projects/guidelines/genai/. + +## Source Code + +The project maintains the following source code repositories: + +* https://github.com/eclipse-threadx/zonex + +## Third-party Content + +This project makes no use of third-party content. External dependencies are +forbidden by project policy; see CONTRIBUTING.md, "Pull request acceptance +criteria". + +## Cryptography + +Content may contain encryption software. The country in which you are currently +may have restrictions on the import, possession, and use, and/or re-export to +another country, of encryption software. BEFORE using any encryption software, +please check the country's laws, regulations and policies concerning the import, +possession, or use, and re-export of encryption software, to see if this is +permitted. diff --git a/README.md b/README.md index 521dcfc..e328cdb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,100 @@ -# zonex -Eclipse ThreadX - ZoneX is a deterministic partitioning hypervisor for mixed-criticality embedded systems. + + +# Eclipse ThreadX ZoneX + +ZoneX is a deterministic partitioning hypervisor for mixed-criticality embedded +systems, and a component of the [Eclipse ThreadX](https://threadx.io) suite. + +It runs at EL2 on Armv8-R, gives each partition a statically declared slice of +memory and of time, and treats a partition stepping outside either as a fault to +be reported rather than a condition to be recovered from. + +## Status: Phase 0, under construction + +**There is no working hypervisor in this repository yet.** What is here is the +repository foundation: the build system, the C17 baseline, the verified Armv8-R +EL2 register reference, the recorded design decisions, and the CI seam. The +translation units exist and compile; they are deliberately empty of +implementation. + +Phase 0 is a partitioning demonstrator with a deliberately small scope: + +* two isolated ThreadX guest partitions on a Cortex-R52, +* static memory partitioning through the EL2 (stage-2) MPU, +* time partitioning driven by the hypervisor timer, +* a stage-2 access violation decoded at EL2 and reported, then halt. + +Targets are the Armv8-R AEM FVP and the NXP S32Z280-594EVB. + +### What Phase 0 will prove, and what it will not + +The demonstrator runs on a Cortex-R52 in **lockstep**. On that configuration it +demonstrates **memory isolation and temporal determinism on one logical core**. + +It does **not** demonstrate spatial partitioning across cores; that requires +split-mode SMP and is deferred to a later phase. Interrupt virtualisation with +a certified worst-case execution time, inter-partition communication, the full +time-partition scheduler, TraceX integration, supervised partition restart and +the safety package are likewise later phases, and are not in Phase 0. + +We state this plainly because the audience for this work is safety-savvy, and +because an overclaimed demonstrator is worth less than an honest one. + +## Architecture notes + +ZoneX targets **Armv8-R AArch32**. Two consequences run through the whole +codebase and are worth stating before you read any of it: + +* **There are no page tables, at either stage of translation.** + Stage 2 on + Armv8-R is a region-based MPU (PMSAv8-R), not a translation table. A + hypervisor shaped like an A-profile one — KVM, Xen — is the wrong template. +* **The register names are the AArch32 Hyp-mode ones**: `HVBAR`, `HCR`, + `HSCTLR`, `HPRBAR`/`HPRLAR`/`HPRENR`/`HPRSELR`, `HMPUIR`, `HSR`, + `HDFAR`/`HIFAR`/`HPFAR`, `HMAIR0`/`HMAIR1`, reached through + `MRC`/`MCR p15, 4, …`. + +`docs/armv8r-el2-reference.md` holds the verified register sheet, including the +field layouts and the points that are still open. `docs/decisions.md` records +the Phase-0 design decisions and why each was taken. + +## Building + +ZoneX builds with CMake and Ninja. There is no dependency on any IDE. + +```sh +# Host-compiled unit tests (the architecture-independent logic). +scripts/build_host.sh && scripts/test_host.sh + +# Cortex-R52 cross build, Armv8-R AEM FVP example. +scripts/build_fvp.sh +``` + +ZoneX is written to **C17** — it is the first Eclipse ThreadX component born on +that baseline rather than migrated to it. See `docs/decisions.md` D12. + +`scripts/install.sh` installs the build and test dependencies on Ubuntu. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). Contributions are made against the `dev` +branch, and require a signed Eclipse Contributor Agreement. AI-assisted +contributions are welcome and must be attributed; that file also covers what to +tell a coding agent. + +## Licence + +MIT. See [LICENSE.txt](LICENSE.txt) and [NOTICE](NOTICE). diff --git a/cmake/cortex_r52.cmake b/cmake/cortex_r52.cmake new file mode 100644 index 0000000..8d7d7db --- /dev/null +++ b/cmake/cortex_r52.cmake @@ -0,0 +1,106 @@ +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# CMake toolchain file for Arm Cortex-R52 (Armv8-R, AArch32) with the GNU +# toolchain. Use it as: +# +# cmake -B build -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/cortex_r52.cmake +# +# Derived from threadx's cmake/cortex_r52.cmake and folded into a single file +# rather than shared with it. ZoneX and ThreadX are separate repositories with +# separate release trains; a shared toolchain file would have to be vendored or +# symlinked either way, and a copy that can diverge deliberately is easier to +# reason about than one that diverges by accident. + +set(CMAKE_SYSTEM_NAME Generic) +set(CMAKE_SYSTEM_PROCESSOR cortex-r52) + +# Read by the root CMakeLists.txt to select platform/. It is a normal +# variable rather than a cache entry because a toolchain file is re-read on every +# configure, and because the root project must be able to fail loudly when no +# toolchain file was given at all. +set(ZX_ARCH "cortex_r52") +set(ZX_TOOLCHAIN "gnu") + +# Floating-point ABI. Every Cortex-R52 implements at least a single-precision +# FPU -- GCC rejects "+nofp" for this core and offers only "+nofp.dp" -- so the +# soft-float baseline selects the soft *ABI* rather than removing the FPU. +# +# ZoneX itself uses no floating point at EL2 and does not care. The option +# exists because the guest images built under examples/ do, and because a guest +# built hard-float cannot be linked against soft-float objects. +if(NOT DEFINED ZX_R52_FLOAT_ABI) + set(ZX_R52_FLOAT_ABI "soft" CACHE STRING "R52 float ABI: soft | hard") +endif() +set_property(CACHE ZX_R52_FLOAT_ABI PROPERTY STRINGS soft hard) + +set(ZX_MCPU_FLAGS "-marm -mcpu=cortex-r52") +if(ZX_R52_FLOAT_ABI STREQUAL "hard") + set(ZX_VFP_FLAGS "-mfpu=fpv5-d16 -mfloat-abi=hard") +else() + set(ZX_VFP_FLAGS "-mfloat-abi=soft") +endif() + +set(CMAKE_C_COMPILER arm-none-eabi-gcc) +set(CMAKE_ASM_COMPILER arm-none-eabi-gcc) +set(CMAKE_AR arm-none-eabi-ar) +set(CMAKE_OBJCOPY arm-none-eabi-objcopy) +set(CMAKE_OBJDUMP arm-none-eabi-objdump) +set(CMAKE_SIZE arm-none-eabi-size) + +# Pin the reference cross toolchain (CONTRIBUTING.md, "Required tooling"). +# Absolute paths are used deliberately so the build does not depend on PATH +# ordering. Override with -DZX_ARM_TOOLCHAIN_PATH= +# to build with a different compiler. +if(NOT DEFINED ZX_ARM_TOOLCHAIN_PATH) + set(ZX_ARM_TOOLCHAIN_PATH + "$ENV{HOME}/toolchains/arm-gnu-toolchain-14.3.rel1-x86_64-arm-none-eabi/bin") +endif() +if(EXISTS "${ZX_ARM_TOOLCHAIN_PATH}/arm-none-eabi-gcc") + set(CMAKE_C_COMPILER "${ZX_ARM_TOOLCHAIN_PATH}/arm-none-eabi-gcc") + set(CMAKE_ASM_COMPILER "${ZX_ARM_TOOLCHAIN_PATH}/arm-none-eabi-gcc") + set(CMAKE_AR "${ZX_ARM_TOOLCHAIN_PATH}/arm-none-eabi-ar") + set(CMAKE_OBJCOPY "${ZX_ARM_TOOLCHAIN_PATH}/arm-none-eabi-objcopy") + set(CMAKE_OBJDUMP "${ZX_ARM_TOOLCHAIN_PATH}/arm-none-eabi-objdump") + set(CMAKE_SIZE "${ZX_ARM_TOOLCHAIN_PATH}/arm-none-eabi-size") +endif() + +set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) +set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + +# The compiler check builds a static library rather than an executable, so that +# configuring does not need a linker script. ZoneX has no linker script until +# the first image is produced. +set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) + +# -std= is deliberately absent: the C standard is set once, in the root +# CMakeLists.txt, through CMAKE_C_STANDARD. Spelling it here as well would let +# the two drift apart and the toolchain file would silently win. +set(CMAKE_C_FLAGS + "${ZX_MCPU_FLAGS} ${ZX_VFP_FLAGS} --specs=nosys.specs -fdata-sections -ffunction-sections" + CACHE INTERNAL "c compiler flags") +set(CMAKE_ASM_FLAGS + "${ZX_MCPU_FLAGS} ${ZX_VFP_FLAGS} -x assembler-with-cpp" + CACHE INTERNAL "asm compiler flags") +set(CMAKE_EXE_LINKER_FLAGS + "${ZX_MCPU_FLAGS} ${ZX_VFP_FLAGS} -Wl,--gc-sections" + CACHE INTERNAL "exe link flags") + +set(CMAKE_C_FLAGS_DEBUG "-Og -g -ggdb3" CACHE INTERNAL "c debug flags") +set(CMAKE_ASM_FLAGS_DEBUG "-g -ggdb3" CACHE INTERNAL "asm debug flags") +set(CMAKE_C_FLAGS_RELEASE "-O2 -g" CACHE INTERNAL "c release flags") +set(CMAKE_ASM_FLAGS_RELEASE "-g" CACHE INTERNAL "asm release flags") diff --git a/cmake/cortex_r52_clang.cmake b/cmake/cortex_r52_clang.cmake new file mode 100644 index 0000000..7994fbc --- /dev/null +++ b/cmake/cortex_r52_clang.cmake @@ -0,0 +1,95 @@ +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# CMake toolchain file for Arm Cortex-R52 (Armv8-R, AArch32) with Arm Toolchain +# for Embedded, which is LLVM based and is the successor to Arm Compiler 6. +# +# cmake -B build -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/cortex_r52_clang.cmake +# +# There is no separate clang port: this builds the same sources with a different +# compiler. ZX_TOOLCHAIN therefore stays "gnu" -- it names the port directory, +# not the compiler. +# +# Why ZoneX bothers with a second toolchain at all: GNU as accepts several +# non-canonical assembly forms that LLVM's assembler rejects, and ZoneX will be +# mostly assembly at the point where that starts to matter. Catching those one +# at a time as they are written is far cheaper than finding forty of them the +# first time somebody tries an LLVM build. + +set(CMAKE_SYSTEM_NAME Generic) +set(CMAKE_SYSTEM_PROCESSOR cortex-r52) + +set(ZX_ARCH "cortex_r52") +set(ZX_TOOLCHAIN "gnu") + +if(NOT DEFINED ZX_R52_FLOAT_ABI) + set(ZX_R52_FLOAT_ABI "soft" CACHE STRING "R52 float ABI: soft | hard") +endif() +set_property(CACHE ZX_R52_FLOAT_ABI PROPERTY STRINGS soft hard) + +# Tool names as found on PATH, which is what CI uses. +set(CMAKE_C_COMPILER clang) +set(CMAKE_ASM_COMPILER clang) +set(CMAKE_AR llvm-ar) +set(CMAKE_RANLIB llvm-ranlib) +set(CMAKE_OBJCOPY llvm-objcopy) +set(CMAKE_OBJDUMP llvm-objdump) +set(CMAKE_SIZE llvm-size) + +# Then pin a specific install if one is present, as cmake/cortex_r52.cmake pins +# the GNU toolchain and for the same reason: a local build should not depend on +# PATH ordering. Absent that directory this falls back to the names above +# rather than failing, so the file still works on a machine that keeps clang +# somewhere else. Override with -DZX_ATFE_TOOLCHAIN_PATH=. +if(NOT DEFINED ZX_ATFE_TOOLCHAIN_PATH) + set(ZX_ATFE_TOOLCHAIN_PATH "$ENV{HOME}/toolchains/ATfE-22.1.0-Linux-x86_64/bin") +endif() +if(EXISTS "${ZX_ATFE_TOOLCHAIN_PATH}/clang") + set(CMAKE_C_COMPILER "${ZX_ATFE_TOOLCHAIN_PATH}/clang") + set(CMAKE_ASM_COMPILER "${ZX_ATFE_TOOLCHAIN_PATH}/clang") + set(CMAKE_AR "${ZX_ATFE_TOOLCHAIN_PATH}/llvm-ar") + set(CMAKE_RANLIB "${ZX_ATFE_TOOLCHAIN_PATH}/llvm-ranlib") + set(CMAKE_OBJCOPY "${ZX_ATFE_TOOLCHAIN_PATH}/llvm-objcopy") + set(CMAKE_OBJDUMP "${ZX_ATFE_TOOLCHAIN_PATH}/llvm-objdump") + set(CMAKE_SIZE "${ZX_ATFE_TOOLCHAIN_PATH}/llvm-size") +endif() + +set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) +set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) +set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) + +set(ZX_MCPU_FLAGS "--target=arm-none-eabi -marm -mcpu=cortex-r52") +if(ZX_R52_FLOAT_ABI STREQUAL "hard") + set(ZX_VFP_FLAGS "-mfpu=fpv5-d16 -mfloat-abi=hard") +else() + set(ZX_VFP_FLAGS "-mfloat-abi=soft") +endif() + +set(CMAKE_C_FLAGS + "${ZX_MCPU_FLAGS} ${ZX_VFP_FLAGS} -fdata-sections -ffunction-sections" + CACHE INTERNAL "c compiler flags") +set(CMAKE_ASM_FLAGS + "${ZX_MCPU_FLAGS} ${ZX_VFP_FLAGS} -x assembler-with-cpp" + CACHE INTERNAL "asm compiler flags") +set(CMAKE_EXE_LINKER_FLAGS + "${ZX_MCPU_FLAGS} ${ZX_VFP_FLAGS} -fuse-ld=lld -Wl,--gc-sections" + CACHE INTERNAL "exe link flags") + +set(CMAKE_C_FLAGS_DEBUG "-Og -g" CACHE INTERNAL "c debug flags") +set(CMAKE_ASM_FLAGS_DEBUG "-g" CACHE INTERNAL "asm debug flags") +set(CMAKE_C_FLAGS_RELEASE "-O2 -g" CACHE INTERNAL "c release flags") +set(CMAKE_ASM_FLAGS_RELEASE "-g" CACHE INTERNAL "asm release flags") diff --git a/cmake/host.cmake b/cmake/host.cmake new file mode 100644 index 0000000..ea74da5 --- /dev/null +++ b/cmake/host.cmake @@ -0,0 +1,47 @@ +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# CMake toolchain file for the host build. +# +# The host build compiles the architecture-independent half of ZoneX -- the +# manifest, the partition tables, the scheduling logic -- and the unit tests +# under test/host that exercise it. It links no assembly, programs no MPU and +# never reaches EL2; see docs/decisions.md D11 for what the host suite is +# expected to cover and what it deliberately is not. + +set(CMAKE_SYSTEM_NAME ${CMAKE_HOST_SYSTEM_NAME}) +set(CMAKE_SYSTEM_PROCESSOR ${CMAKE_HOST_SYSTEM_PROCESSOR}) + +set(ZX_ARCH "host") +set(ZX_TOOLCHAIN "gnu") + +# Let the compiler be chosen on the command line or from the environment, for +# example -DCMAKE_C_COMPILER=gcc-14 or CC=gcc-14. CMake reads a toolchain file +# before it consults CC, so setting this unconditionally would quietly override +# the environment and leave no way to build with anything but the +# distribution's default compiler. (Same reasoning, and the same wording, as +# threadx's cmake/linux.cmake, where it was first needed.) +if(NOT DEFINED CMAKE_C_COMPILER) + if(DEFINED ENV{CC}) + set(CMAKE_C_COMPILER $ENV{CC}) + else() + set(CMAKE_C_COMPILER gcc) + endif() +endif() + +# -std= is deliberately absent here too; see the note in cortex_r52.cmake. +set(CMAKE_C_FLAGS "" CACHE INTERNAL "c compiler flags") +set(CMAKE_C_FLAGS_DEBUG "-Og -g -ggdb3" CACHE INTERNAL "c debug flags") +set(CMAKE_C_FLAGS_RELEASE "-O2 -g" CACHE INTERNAL "c release flags") diff --git a/cmake/zx_warnings.cmake b/cmake/zx_warnings.cmake new file mode 100644 index 0000000..81afef2 --- /dev/null +++ b/cmake/zx_warnings.cmake @@ -0,0 +1,55 @@ +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# The strict warning set, as an INTERFACE library every ZoneX target links. +# +# This is upstream ThreadX's kernel warning set -- the one in +# threadx/test/tx/cmake/CMakeLists.txt -- plus -Wmissing-prototypes, which is +# the flag that found the MISRA Rule 8.4 findings in the Cortex-R52 port and +# Modules port work. It is applied +# from the first commit rather than bolted on during the hardening step, +# because retrofitting a codebase to a warning set costs far more than building +# to one, and because ZoneX has a certification back end that will ask. +# +# Warnings become errors under the ci-strict configure preset, through CMake's +# own CMAKE_COMPILE_WARNING_AS_ERROR. A developer build stays warning-tolerant +# so that a half-finished change can still be compiled and looked at. + +add_library(zx_warnings INTERFACE) +add_library(zonex::warnings ALIAS zx_warnings) + +target_compile_options(zx_warnings INTERFACE + -Wall + -Wextra + -Wpedantic + -Wunused + -Wuninitialized + -Wmissing-declarations + -Wmissing-prototypes + -Wconversion + -Wpointer-arith + -Waggregate-return + -Wfloat-equal +) + +# -Wlogical-op is a GNU extension. Clang does not implement it: it has +# -Wlogical-op-parentheses, which is a different check, and rejects the GNU +# spelling as an unknown warning. That is merely noisy until +# CMAKE_COMPILE_WARNING_AS_ERROR turns it into a failed build for every ZoneX +# source under the ATfE lane, so it is selected by compiler rather than +# spelled unconditionally. +target_compile_options(zx_warnings INTERFACE + $<$:-Wlogical-op> +) diff --git a/common/inc/zx_api.h b/common/inc/zx_api.h new file mode 100644 index 0000000..5abd317 --- /dev/null +++ b/common/inc/zx_api.h @@ -0,0 +1,133 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_api.h PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* This file defines the Eclipse ThreadX ZoneX public interface: the */ +/* language capability macros, the version identification, and the */ +/* fundamental types every other ZoneX header builds on. */ +/* */ +/* ZoneX is a partitioning hypervisor. Phase 0 targets Armv8-R */ +/* AArch32, where BOTH stages of address control are region-based */ +/* MPUs (PMSAv8-R). There are no page tables (zx-terminology-allow) */ +/* anywhere on this target, and nothing in this interface should be */ +/* read as implying one. */ +/* */ +/**************************************************************************/ + +#ifndef ZX_API_H +#define ZX_API_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* Language capability macros -- do not use __STDC_VERSION__ elsewhere in this + header. + + NOTE: [[...]] attribute syntax is C23 (202311L). C17 defines NO standard + attributes, so C17 builds deliberately fall through to the GNU/MSVC forms. + ZoneX is built to C17 with CMAKE_C_EXTENSIONS OFF and -Wpedantic in force, + and under those a C17 branch spelling [[nodiscard]] is rejected outright: + "ISO C does not support [[ attributes before C23". + + The macros are the contract, not their spelling. Always write + ZX_NODISCARD; never [[nodiscard]] directly. That way moving ZoneX to C23 + is a change to this one block rather than churn across every header. */ +#if __STDC_VERSION__ >= 202311L /* C23 */ +# define ZX_NODISCARD [[nodiscard]] +# define ZX_DEPRECATED(msg) [[deprecated(msg)]] +# define ZX_MAYBE_UNUSED [[maybe_unused]] +#elif defined(__GNUC__) || defined(__clang__) +# define ZX_NODISCARD __attribute__((warn_unused_result)) +# define ZX_DEPRECATED(msg) __attribute__((deprecated(msg))) +# define ZX_MAYBE_UNUSED __attribute__((unused)) +#elif defined(_MSC_VER) +# define ZX_NODISCARD _Check_return_ +# define ZX_DEPRECATED(msg) __declspec(deprecated(msg)) +# define ZX_MAYBE_UNUSED +#else +# define ZX_NODISCARD +# define ZX_DEPRECATED(msg) +# define ZX_MAYBE_UNUSED +#endif + +/* stdint.h and stddef.h are part of the freestanding C library, which C17 + requires every conforming implementation to provide. They are not an + external dependency in the sense CONTRIBUTING.md forbids. */ +#include +#include + +/* Version identification. ZoneX follows the suite-wide scheme described in + CONTRIBUTING.md, "Release model and support". Phase 0 is pre-release and + carries a 0 major deliberately: the interfaces below are expected to change + until the demonstrator is complete. */ +#define ZX_MAJOR_VERSION 0 +#define ZX_MINOR_VERSION 1 +#define ZX_PATCH_VERSION 0 + +/**************************************************************************/ +/* Fundamental types */ +/**************************************************************************/ + +/* An address in a partition manifest or in an MPU region descriptor. + Deliberately a width-correct typedef rather than a bare unsigned long: the + manifest is a data structure ZoneX means to keep across ports, and Armv8-R + AArch32 is not the last architecture it will describe. See + docs/decisions.md D5. + + uintptr_t rather than a fixed width, so that a 64-bit port -- RISC-V with + the hypervisor extension, or Armv8-R AArch64 (R82) -- widens this with the + pointer instead of needing every declaration revisited. The one case this + does not cover is a machine whose physical address space is wider than its + pointers; ZoneX has no such target, and adding one means revisiting this + typedef rather than working around it at each use. */ +typedef uintptr_t zx_addr_t; + +/* A length, in bytes, of a region of memory described by a manifest. Paired + with zx_addr_t so that a base and a size are always the same width, which is + what makes (base + size - 1) safe to compute as a limit. */ +typedef uintptr_t zx_size_t; + +/* PMSAv8-R programs a region as a base and an inclusive limit, both of which + must be aligned to the 64-byte granule. An under-aligned base does not + fault: its low bits land on the SH, AP and XN fields of HPRBAR and silently + change the region's attributes. An unmasked limit is worse -- (base + size + - 1) ends in 0x3F, which lands on HPRLAR's AttrIndx field and selects an + unwritten MAIR byte. Both traps were paid for at EL1 during the + Cortex-R52 Modules port work, and are repeated here because they apply + unchanged at stage 2. */ +#define ZX_MPU_GRANULE 64U + +/* The C17 baseline is a project decision (docs/decisions.md D12), not an + accident of whichever compiler happens to be installed. Asserting it here + means a build that quietly fell back to an older standard fails at the first + header rather than at whichever construct it first cannot parse. */ +_Static_assert(sizeof(zx_addr_t) >= 4U, + "zx_addr_t must be at least 32 bits wide"); +_Static_assert(sizeof(zx_addr_t) == sizeof(zx_size_t), + "zx_addr_t and zx_size_t must be the same width"); + +#ifdef __cplusplus +} +#endif + +#endif /* ZX_API_H */ diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt new file mode 100644 index 0000000..68f2006 --- /dev/null +++ b/core/CMakeLists.txt @@ -0,0 +1,44 @@ +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# The architecture-independent half of ZoneX. +# +# Nothing here may touch CP15, inline assembly, or a memory-mapped register. +# That is not a style preference: it is what lets the host suite under +# test/host compile and exercise this code on a workstation, which is the only +# part of ZoneX that can be tested without a model or a board. + +target_sources(zonex_core PRIVATE + ${CMAKE_CURRENT_LIST_DIR}/src/zx_fault.c + ${CMAKE_CURRENT_LIST_DIR}/src/zx_mm_setup.c + ${CMAKE_CURRENT_LIST_DIR}/src/zx_partition_manager.c + ${CMAKE_CURRENT_LIST_DIR}/src/zx_schedule.c +) + +# Headers are a first-class build artifact rather than an install(FILES) list, +# so that they propagate correctly when ZoneX is consumed through +# add_subdirectory or FetchContent. +target_sources(zonex_core PUBLIC + FILE_SET zx_headers + TYPE HEADERS + BASE_DIRS ${CMAKE_CURRENT_LIST_DIR}/../common/inc + ${CMAKE_CURRENT_LIST_DIR}/inc + FILES ${CMAKE_CURRENT_LIST_DIR}/../common/inc/zx_api.h + ${CMAKE_CURRENT_LIST_DIR}/inc/zx_fault.h + ${CMAKE_CURRENT_LIST_DIR}/inc/zx_manifest.h + ${CMAKE_CURRENT_LIST_DIR}/inc/zx_mm.h + ${CMAKE_CURRENT_LIST_DIR}/inc/zx_partition.h + ${CMAKE_CURRENT_LIST_DIR}/inc/zx_schedule.h +) diff --git a/core/inc/zx_fault.h b/core/inc/zx_fault.h new file mode 100644 index 0000000..ccaf347 --- /dev/null +++ b/core/inc/zx_fault.h @@ -0,0 +1,57 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_fault.h PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Fault decoding and reporting: reading HSR.EC to classify an */ +/* exception taken to EL2, and reporting a stage-2 access violation */ +/* with the faulting address from HPFAR. */ +/* */ +/* Every exception routed to EL2 from EL0/EL1 arrives at one vector, */ +/* HVBAR + 0x14, so decoding HSR.EC is not an optimisation -- it is */ +/* the only way to know what happened. Two classes must be reported */ +/* differently and never conflated: EC 0x24 is a data abort ROUTED to */ +/* Hyp, which is a guest violating its partition and the event the */ +/* Phase-0 demonstrator exists to show; EC 0x25 is a data abort TAKEN */ +/* FROM Hyp, which is a ZoneX bug. */ +/* */ +/* STATUS */ +/* */ +/* Declared empty. The first stage-2 fault is decoded by the EL2 */ +/* bring-up work. The EC values are tabulated in */ +/* docs/armv8r-el2-reference.md. */ +/* */ +/**************************************************************************/ + +#ifndef ZX_FAULT_H +#define ZX_FAULT_H + +#include "zx_api.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#ifdef __cplusplus +} +#endif + +#endif /* ZX_FAULT_H */ diff --git a/core/inc/zx_manifest.h b/core/inc/zx_manifest.h new file mode 100644 index 0000000..4b25ee5 --- /dev/null +++ b/core/inc/zx_manifest.h @@ -0,0 +1,53 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_manifest.h PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* The partition manifest: the static, build-time declaration of how */ +/* many partitions exist, which memory each one owns, and how much */ +/* time each one is given. A ZoneX system is entirely described by */ +/* its manifest; there is no dynamic partition creation in Phase 0. */ +/* */ +/* The manifest is architecture-independent by construction, which is */ +/* what makes it the part of ZoneX the host test suite can validate */ +/* without a model or a board (docs/decisions.md D11). */ +/* */ +/* STATUS */ +/* */ +/* Declared empty. The manifest types and the validator arrive */ +/* together, and the host suite under test/host grows with them. */ +/* */ +/**************************************************************************/ + +#ifndef ZX_MANIFEST_H +#define ZX_MANIFEST_H + +#include "zx_api.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#ifdef __cplusplus +} +#endif + +#endif /* ZX_MANIFEST_H */ diff --git a/core/inc/zx_mm.h b/core/inc/zx_mm.h new file mode 100644 index 0000000..f80da4a --- /dev/null +++ b/core/inc/zx_mm.h @@ -0,0 +1,56 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_mm.h PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Memory management: turning a validated manifest into stage-2 MPU */ +/* region programming, and switching the enabled region set as */ +/* partitions are scheduled. */ +/* */ +/* "Memory management" here does not mean paging. Armv8-R has no page */ +/* tables at either stage of translation; stage 2 is a region-based */ +/* MPU with a 64-byte granule and no region priority, so two enabled */ +/* regions covering one address is CONSTRAINED UNPREDICTABLE and */ +/* aborts on the S32Z280. A hypervisor cannot map everything and then */ +/* override parts of it -- the regions must be disjoint by */ +/* construction, which is a property the manifest validator enforces. */ +/* */ +/* STATUS */ +/* */ +/* Declared empty. The EL2 bring-up work programs the first region by */ +/* hand; driving the programming from the manifest comes after that. */ +/* */ +/**************************************************************************/ + +#ifndef ZX_MM_H +#define ZX_MM_H + +#include "zx_api.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#ifdef __cplusplus +} +#endif + +#endif /* ZX_MM_H */ diff --git a/core/inc/zx_partition.h b/core/inc/zx_partition.h new file mode 100644 index 0000000..5f92ee6 --- /dev/null +++ b/core/inc/zx_partition.h @@ -0,0 +1,56 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_partition.h PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* The partition: a guest's identity, its lifecycle state, and the */ +/* control block the hypervisor keeps for it. A partition owns EL1 */ +/* and EL0 execution for the whole of its time window, and owns */ +/* nothing outside it. */ +/* */ +/* Isolation between partitions comes from WHICH stage-2 regions are */ +/* enabled while a partition runs, never from the permission bits. */ +/* Armv8-R offers no HPRBAR.AP encoding that grants EL0/EL1 access */ +/* while denying EL2, so a partition's memory is always reachable from */ +/* the hypervisor. See docs/decisions.md D3 and the AP table in */ +/* docs/armv8r-el2-reference.md. */ +/* */ +/* STATUS */ +/* */ +/* Declared empty. The control block is defined alongside the */ +/* manifest, and the first guest partition boots into it after that. */ +/* */ +/**************************************************************************/ + +#ifndef ZX_PARTITION_H +#define ZX_PARTITION_H + +#include "zx_api.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#ifdef __cplusplus +} +#endif + +#endif /* ZX_PARTITION_H */ diff --git a/core/inc/zx_schedule.h b/core/inc/zx_schedule.h new file mode 100644 index 0000000..2e6f87f --- /dev/null +++ b/core/inc/zx_schedule.h @@ -0,0 +1,55 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_schedule.h PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Time partitioning: the fixed, cyclic window schedule that decides */ +/* which partition owns the core, and the hypervisor-timer tick that */ +/* ends each window. */ +/* */ +/* Guests read time through the VIRTUAL timer, with a per-partition */ +/* CNTVOFF, so that a descheduled partition's clock is frozen rather */ +/* than merely unread. The hypervisor keeps the hypervisor timer for */ +/* itself. See docs/decisions.md D7: leaving guests on the physical */ +/* timer is simpler and undermines the determinism claim, which is the */ +/* point of the demonstrator. */ +/* */ +/* STATUS */ +/* */ +/* Declared empty. The second partition brings with it the first */ +/* schedule worth the name; one partition needs no scheduler. */ +/* */ +/**************************************************************************/ + +#ifndef ZX_SCHEDULE_H +#define ZX_SCHEDULE_H + +#include "zx_api.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#ifdef __cplusplus +} +#endif + +#endif /* ZX_SCHEDULE_H */ diff --git a/core/src/zx_fault.c b/core/src/zx_fault.c new file mode 100644 index 0000000..a686a35 --- /dev/null +++ b/core/src/zx_fault.c @@ -0,0 +1,34 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_fault.c PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Fault classification and reporting, driven by HSR.EC and, for a */ +/* stage-2 violation, the faulting address in HPFAR. */ +/* */ +/* This translation unit is deliberately empty of implementation. */ +/* The change that founded this repository builds the repository, not */ +/* the hypervisor; the unit exists so that the change which writes the */ +/* code opens a tree that already configures, compiles and links. */ +/* */ +/**************************************************************************/ + +#include "zx_fault.h" diff --git a/core/src/zx_mm_setup.c b/core/src/zx_mm_setup.c new file mode 100644 index 0000000..89e15fc --- /dev/null +++ b/core/src/zx_mm_setup.c @@ -0,0 +1,35 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_mm_setup.c PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Stage-2 memory setup: translating the manifest's memory */ +/* declarations into MPU region descriptors, and enabling the set */ +/* belonging to the partition about to run. */ +/* */ +/* This translation unit is deliberately empty of implementation. */ +/* The change that founded this repository builds the repository, not */ +/* the hypervisor; the unit exists so that the change which writes the */ +/* code opens a tree that already configures, compiles and links. */ +/* */ +/**************************************************************************/ + +#include "zx_mm.h" diff --git a/core/src/zx_partition_manager.c b/core/src/zx_partition_manager.c new file mode 100644 index 0000000..4c00cc8 --- /dev/null +++ b/core/src/zx_partition_manager.c @@ -0,0 +1,34 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_partition_manager.c PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* The partition manager: partition creation from the manifest, entry */ +/* into a partition, and the halt path taken when one faults. */ +/* */ +/* This translation unit is deliberately empty of implementation. */ +/* The change that founded this repository builds the repository, not */ +/* the hypervisor; the unit exists so that the change which writes the */ +/* code opens a tree that already configures, compiles and links. */ +/* */ +/**************************************************************************/ + +#include "zx_partition.h" diff --git a/core/src/zx_schedule.c b/core/src/zx_schedule.c new file mode 100644 index 0000000..1ce76ec --- /dev/null +++ b/core/src/zx_schedule.c @@ -0,0 +1,34 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_schedule.c PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* The time-partition schedule: the fixed cyclic window table and the */ +/* hypervisor-timer tick that advances it. */ +/* */ +/* This translation unit is deliberately empty of implementation. */ +/* The change that founded this repository builds the repository, not */ +/* the hypervisor; the unit exists so that the change which writes the */ +/* code opens a tree that already configures, compiles and links. */ +/* */ +/**************************************************************************/ + +#include "zx_schedule.h" diff --git a/docs/armv8r-el2-reference.md b/docs/armv8r-el2-reference.md new file mode 100644 index 0000000..57547df --- /dev/null +++ b/docs/armv8r-el2-reference.md @@ -0,0 +1,326 @@ + + +# Armv8-R AArch32 EL2 reference sheet + +*Eclipse ThreadX ZoneX. Verified 2 September 2026.* + +This file exists so that no ZoneX session has to re-derive a register encoding. +Every line below was read out of the Arm Cortex-R52 Technical Reference Manual +(`100026_0103_00_en`, r1p3) or measured on the part, and each carries its +source. Where the TRM contradicts itself, both readings are given and the one +ZoneX relies on is named. + +**Take encodings from here, not from memory and not from an A-profile +hypervisor.** The A-profile shape is the wrong template for this architecture, +and the `AP` field in particular does not mean at EL2 what it means at EL1. + +`scripts/check_terminology.sh` mechanically rejects the register and concept +names that belong to other architectures. It runs in CI. + +--- + +## The architecture, in four facts + +1. **Stage 2 on Armv8-R is an MPU.** TRM §8.3: *"To support virtualization, two + stages of MPU lookup are performed."* Both stages are region-based + (PMSAv8-R). There is no translation structure in memory at either stage. + + +2. **There is exactly one EL2 region set, and it does two jobs.** TRM §8.3: + transactions under the EL2 translation regime are checked against the + EL2-controlled MPU; and when `HCR.VM=1` and `HSCTLR.M=1`, EL0/EL1 + transactions permitted by the EL1 MPU are *also* checked against it. The + hypervisor's own mapping and every guest's stage-2 mapping therefore compete + for the same 16, 20 or 24 regions. + +3. **`HSCTLR.BR` relieves the budget, but only for code and data.** TRM §8.2.2, + quoted verbatim: the background region *"can also be used for EL2 accesses + that do not hit any programmable regions, when the EL2-controlled MPU is + enabled, by setting the background region enable (HSCTLR.BR=1). When the + EL2-controlled MPU is enabled, accesses from the EL0/EL1 translation regime + that do not hit in the EL2 programmable regions generate a translation + fault."* + + Read the second sentence carefully: the EL0/EL1 fault-on-miss is + unconditional and does **not** depend on `BR`. So `BR=1` buys deny-all for + guests, and no region cost for the hypervisor's own code and data. It also + means hypervisor memory is protected from a guest precisely by **not being + covered by any enabled region** — see `docs/decisions.md` D2. + + **`BR` grants permission, not attributes.** The background map's data + attributes are fixed, TRM Table 8-4: + + | Address range | Data attributes (`HSCTLR.C` enabled) | + |---|---| + | `0x00000000`–`0x3FFFFFFF` | Normal, Non-shareable, Write-Back Write-Allocate | + | `0x40000000`–`0x5FFFFFFF` | Normal, Non-shareable, Write-Through cacheable | + | `0x60000000`–`0x7FFFFFFF` | Normal, Shareable, Non-cacheable | + | `0x80000000`–`0xBFFFFFFF` | Device-nGnRE | + | `0xC0000000`–`0xFFFFFFFF` | Device-nGnRnE | + + Anything below `0x60000000` is therefore **cacheable**, which is wrong for a + memory-mapped device. On the S32Z280 that is not hypothetical: LINFlexD_9, + the hypervisor's own console, is at `0x4298_0000` (S32Z27 reference manual), + and the GIC is in the same band. **The hypervisor must program + Device-attributed EL2 regions for its own console and GIC even with `BR=1`.** + + ⚠ **The FVP cannot demonstrate the need.** Its console and GIC are at + `0x9C090000` and `0xAF000000`, inside the Device-nGnRE band, so the + background map is accidentally correct there. Treat this like the region + count: a green model run says nothing about the part. (The GIC base on the + S32Z280 is quoted from bring-up notes rather than read out of the reference + manual — confirm it before relying on the exact address. The band it falls + in, and therefore the conclusion, is not in doubt.) + + One more property to design around, TRM Table 8-3: for instruction access + the background map marks `0x80000000`–`0xFFFFFFFF` execute-never, so + hypervisor code relying on `BR` must live below `0x80000000`. + +4. **Attributes combine, and the stricter of the two stages wins** (TRM + §8.3.1). A guest cannot widen what stage 2 grants it. + +--- + +## Registers + +All are reached with `MRC`/`MCR p15, 4, , …`. `opc1 = 4` is the Hyp bank. + +| Register | `CRn, CRm, opc2` | Notes | +|---|---|---| +| `HVBAR` | `c12, c0, 0` | EL2 vector base, 32-byte aligned. The ThreadX Cortex-R52 port's `entry.S` already installs it. | +| `HCR` | `c1, c1, 0` | `VM[0]` stage-2 enable · `AMO[3]` · `IMO[4]` · `FMO[5]` · `TGE[27]` · `HCD[29]` — keep `HCD` clear to keep `HVC`. The ThreadX Cortex-R52 port clears all six; ZoneX sets `VM`. | +| `HSCTLR` | `c1, c0, 0` | `M[0]` EL2 MPU enable · `BR[17]` background region · `TE` Thumb exceptions · `I` · `C`. | +| `HSR` | `c5, c2, 0` | `EC[31:26]`, `IL[25]`, `ISS[24:0]`. | +| `HDFAR` | `c6, c0, 0` | Data fault address. | +| `HIFAR` | `c6, c0, 2` | Instruction fault address. | +| `HPFAR` | `c6, c0, 4` | **The faulting address for a stage-2 fault** (TRM §3.3.42). | +| `HMPUIR` | `c0, c0, 4` | EL2 region count in **bits [7:0]** — *not* [15:8], where `MPUIR` keeps the EL1 count. TRM Table 3-79: the value is 0, 16, 20 or 24. | +| `HPRSELR` | `c6, c2, 1` | `REGION[3:0]` on a 16-region implementation; **`REGION[4:0]` on 20 or 24** (TRM Tables 3-84, 3-85). Writing a value ≥ the implemented count is UNPREDICTABLE. | +| `HPRENR` | `c6, c1, 1` | One enable bit per region; bulk enable/disable. Width — see the open question below. | +| `HPRBAR` (indirect) | `c6, c3, 0` | Select the region with `HPRSELR` first. | +| `HPRLAR` (indirect) | `c6, c3, 1` | Likewise. | +| `HPRBAR` (direct, n = 0…15) | `c6, c8+n[3:1], 4*n[0]` | TRM §3.3.48. | +| `HPRLAR` (direct, n = 0…15) | `c6, c8+n[3:1], 4*n[0]+1` | TRM §3.3.49. | +| `HPRBAR`/`HPRLAR` (direct, n = 16…24) | `opc1 = 5`, `c6, c8+(n-16)[3:1], …` | **`opc1` is 5, not 4.** TRM §8.4 — see below. | +| `HMAIR0` | `c10, c2, 0` | Memory attribute indirection for EL2 regions. | +| `HMAIR1` | `c10, c2, 1` | Likewise. | +| `CNTHCTL` | `c14, c1, 0` | `PL1PCTEN[0]`, `PL1PCEN[1]`. The ThreadX Cortex-R52 port sets both. | +| `CNTVOFF` | `MCRR p15, 4, , , c14` | 64-bit. Per-guest virtual-time offset. | +| `ICC_HSRE` | `c12, c9, 5` | GICv3 system-register interface enable for EL1. | + +--- + +## Field layouts + +**`HPRBAR`** (TRM Table 3-80): + +| Bits | Field | +|---|---| +| `[31:6]` | `BASE` — bits [31:6] of the lower inclusive limit, zero-extended | +| `[5]` | RES0 | +| `[4:3]` | `SH[1:0]` | +| `[2:1]` | `AP[2:1]` | +| `[0]` | `XN` | + +Identical in shape to the EL1 `PRBAR`. `SH = 0b01` is UNPREDICTABLE for Normal +memory (Table 3-81) — a zeroed shareability field is `0b00`, Non-shareable, and +is the safe default. + +**`HPRLAR`** (TRM Table 3-83): + +| Bits | Field | +|---|---| +| `[31:6]` | `LIMIT` — bits [31:6] of the upper inclusive limit, **postfixed with `0x3F`** | +| `[5:4]` | RES0 | +| `[3:1]` | `AttrIndx[2:0]` — indexes `HMAIR0`/`HMAIR1` | +| `[0]` | `EN` | + +The TRM's own words, *"postfixed with 0x3F"*, are the trap. A limit computed as +`base + length - 1` already ends in `0x3F`; those low bits land on `AttrIndx` +and select an unwritten `HMAIR` byte. **Mask the limit to the 64-byte granule +before ORing the attributes in.** This was paid for at EL1 during the +Cortex-R52 Modules port work, and the field layout is the same here. + +The same granule argument applies to `HPRBAR`: an under-aligned base does not +fault, it silently rewrites `SH`, `AP` and `XN`. + +### `AP` at EL2 is not `AP` at EL1 + +TRM Table 3-82, *AP[2:1] data access permissions for the EL2-controlled MPU*: + +| `AP[2:1]` | From EL2 | From EL0 and EL1 | +|---|---|---| +| `0b00` | Read/write | **None** | +| `0b01` | Read/write | Read/write | +| `0b10` | Read-only | **None** | +| `0b11` | Read-only | Read-only | + +**There is no encoding that grants a guest access while denying EL2.** Two +consequences, and they shape the whole Phase-0 memory model: + +* A partition's memory is always reachable from EL2. That is convenient for the + loader and it is not optional. +* Isolation *between* partitions therefore comes from **which regions are + enabled while that partition runs**, never from the permission bits. See + `docs/decisions.md` D3. + +--- + +## Region counts + +| Target | EL1 regions (`MPUIR[15:8]`) | EL2 regions (`HMPUIR[7:0]`) | +|---|---|---| +| Architectural (Cortex-R52) | 16, 20 or 24 | 0, 16, 20 or 24 | +| **S32Z280-594EVB** | **20**, measured | **20** — `HMPUIR = 0x00000014`, measured on silicon | +| Armv8-R AEM FVP | 32, measured — *not an architecturally legal R52 value* | ⚠ **unknown; measured when EL2 is brought up** | + +The FVP row is the point of the table. The model reported 32 EL1 regions, which +no real Cortex-R52 can have, so **a green FVP run proves nothing about any real +part's region budget.** Expect the same at EL2: read `HMPUIR` on the model +rather than assuming it matches the board. + +--- + +## PMSAv8-R has no region priority + +Two enabled regions matching one address is CONSTRAINED UNPREDICTABLE, and on +the S32Z280 it **aborts**. This is now true at both stages, independently. + +It is the reason a hypervisor cannot be built here in the usual shape — map +everything, then override the parts that differ. The regions must be disjoint +by construction, which is a property the manifest validator has to enforce +before anything is programmed. + +--- + +## Exceptions taken to EL2 + +Every exception routed to EL2 from EL0 or EL1 arrives at **one vector, +`HVBAR + 0x14`**, and must be decoded from `HSR.EC` (TRM Table 3-88): + +| `EC` | Meaning | +|---|---| +| `0x01` | Trapped `WFI`/`WFE` | +| `0x03` | Trapped `MCR`/`MRC`, coproc `0b1111` | +| `0x05` | Trapped `MCR`/`MRC`, coproc `0b1110` | +| `0x11` | `SVC` taken to Hyp | +| `0x12` | **`HVC` executed** — the hypercall seam | +| `0x20` | Prefetch abort **routed to** Hyp | +| `0x21` | Prefetch abort **taken from** Hyp | +| `0x24` | Data abort **routed to** Hyp | +| `0x25` | Data abort **taken from** Hyp | + +`0x24`, with the faulting address in `HPFAR`, is the stage-2 violation the +Phase-0 demonstrator exists to show. `0x25` means the hypervisor faulted on +itself — a ZoneX bug — and must be reported as one rather than folded in with +`0x24`. + +--- + +## Timers and the GIC + +* Three timer interrupts arrive as PPIs (TRM §9): **physical 30, virtual 27, + hypervisor 26**. +* `CNTVOFF` exists, so a guest's virtual time can be frozen while its partition + is descheduled. ZoneX uses the hypervisor timer for its own tick and leaves + the virtual timer to guests — `docs/decisions.md` D7. +* **The Cortex-R52 implements the GICv3 virtual CPU interface**: `ICH_HCR`, + `ICH_VTR`, and **four List Registers, `ICH_LR0`–`ICH_LR3`**. Interrupt + injection is therefore available on this part. + + This is worth recording because it corrects an assumption. The focused + roadmap expected Phase 0 to have to paravirtualise interrupts because NXP's + EL2M does. EL2M's reason is that the GIC **Distributor** is a shared + resource, not that injection is unavailable. Phase 0 still keeps interrupt + handling simple — but that is now a scope decision taken knowingly rather + than a limitation inherited by assumption. + +--- + +## Open questions + +Two, both narrowed while this reference sheet was written and neither yet +proven on hardware. The EL2 bring-up work closes them and updates this section +rather than starting a new note. + +### ⚠ 1. `HPRENR` width — documentary answer known, hardware answer not + +TRM §3.3.46 contradicts itself. The prose says `HPRENR` *"provides direct access +to the region enable (HPRLAR.EN) for regions 0 to 15"*. The bit tables in the +same section say otherwise: + +| Implementation | Table | `ENABLES` field | +|---|---|---| +| 0 regions | 3-75 | none, RAZ | +| 16 regions | 3-76 | `[15:0]` | +| **20 regions** | **3-77** | **`[19:0]`** | +| **24 regions** | **3-78** | **`[23:0]`** | + +The tables are almost certainly right and the prose is a stale sentence from +the 16-region case. **ZoneX assumes `[19:0]` on the S32Z280's 20-region +implementation**, and that must be *proved* on the part: write a pattern with +a bit set above 15, read it back, and confirm that the corresponding region +actually stops matching. A register that reads back the bit while ignoring it +would be the worst outcome and is exactly what a read-back-only test would +miss. + +Why it matters: it decides D4. A single `HPRENR` write is how a partition +switch could cost one instruction instead of a block of region rewrites. + +### ⚠ 2. Direct access above region 15 — **settled by the TRM** + +TRM §3.3.48's prose says direct access is provided to `HPRBAR0`–`HPRBAR15` +only. **§8.4 is more complete and settles it**, listing verbatim: + +``` +HPRBAR16-HPRBAR24 MCR p15, 5, , c6, CRm, op2 +HPRLAR16-HPRLAR24 MCR p15, 5, , c6, CRm, op2 +``` + +with the matching `MRC` forms, and the c6 register summary agrees: +`HPRBAR16-24 (even)` at `opc1 = 5`, `CRm` c8–c12, and `HPRBAR17-23 (odd)` +likewise. The encoding is the `opc1 = 4` one with `opc1` changed to 5 — exactly +as `PRBAR16`–`PRBAR24` use `opc1 = 1` where `PRBAR0`–`PRBAR15` use 0. + +**So the EL2 MPU avoids the penalty measured at EL1 for regions ≥ 16**, where +reaching a high region meant going through `PRSELR` with an extra `ISB` — +542–604 cycles against 434–470 for a direct write. The whole 20-region budget +is directly addressable. + +This is a documentary resolution, not a measured one. Confirm it functionally +the first time a region ≥ 16 is programmed, and change "settled by the TRM" to +"settled on the part" here when that happens. + +--- + +## Sources + +| Document | Identifier | +|---|---| +| Arm Cortex-R52 Technical Reference Manual, r1p3 | `100026_0103_00_en` | +| Arm Cortex-R52 MP040 software developer errata notice, issue 19 | `SDEN-857344` | +| NXP S32Z27 reference manual | `S32Z27RM` | +| NXP S32Z2 mask-set errata, mask 0P91J | `S32Z2_0P91J` | +| NXP S32Z280-594EVB board user guide | `UG10268` | + +Every fact above is cited by section, figure or table number against these +documents, so a reader with the PDFs can check any line without needing the +same local copy anybody else used. + +The **Armv8-R Architecture Reference Manual supplement** is the source that +would settle open question 1 without hardware, and it was not consulted when +this sheet was written. Obtaining it is the cheapest way to close that question +if the hardware run is delayed. diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..d4ed239 --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,389 @@ + + +# ZoneX Phase-0 design decisions + +*Eclipse ThreadX ZoneX. Last updated 2 September 2026.* + +Each entry records a decision and the reason for it, not a survey of the +options. Where an alternative was seriously considered, it is named along with +what made it lose — a decision whose rejected alternative is invisible gets +re-litigated every time somebody new reads the code. + +An entry marked **open** has not been taken yet, and says who takes it and +when. An entry is never deleted; if a decision is reversed, the entry is +amended in place and says so. + +--- + +## D0 — Branch model · **settled 2 Sep 2026** + +**Feature branches are cut from `dev` and pull requests target `dev`**, exactly +as `CONTRIBUTING.md` requires. No exception is +documented for ZoneX because none is needed. + +`eclipse-threadx/zonex` briefly had only `main`, which would have made ZoneX an +exception in the shape `rtos-docs-asciidoc` already is. `dev` was created on 2 +September 2026 instead. The alternative — work off `main` and document the +exception — bought nothing and cost a permanent footnote in every later +contributor's way; creating `dev` while the repository was still empty cost one +command. + +One piece of history worth carrying, because it will recur on the next new +repository in this suite: PR #1 was merged into `main`, which left `dev` a +commit behind with none of the project files on it. A feature branch cut from +`dev` in that window would have carried a tree missing `CONTRIBUTING.md`, +`LICENSE.txt` and `SECURITY.md`, and those would then have surfaced as phantom +additions in the first code pull request's diff. `dev` was fast-forwarded to +`273a2e3`. **For an empty repository, create `dev` before the first pull +request, not after** — or send that pull request to `dev` and let the release +merge carry it to `main`. + +--- + +## D1 — File and symbol prefix · **settled** + +**`zx_` for files and symbols, `ZX_` for macros.** + +Every other component of the suite uses a two-letter prefix — `tx_`, `fx_`, +`nx_`, `ux_`, `gx_`, `lx_` — and the suite-wide C17 plan assigns each component +its own capability-macro prefix for exactly the reason prefixes exist: headers +get included together, and a collision is discovered at the worst moment. + +The alternative was `tx_hv_*`, which is the spelling the focused task list uses +(`tx_hv_mm_setup.c`, `tx_hv_partition_manager.c`, `tx_hv_trap_handler.S`). It +was dropped because it reads as a ThreadX subsystem, and ZoneX is not one — it +does not link ThreadX (D9) and it is released as its own component. + +Decided before any code was written, because every file name in the repository +depends on it. + +--- + +## D2 — How the hypervisor's own memory is protected · **settled, amended** + +**`HSCTLR.BR=1`. ZoneX spends no EL2 MPU regions on its own code and data, and +is protected from a guest by not being covered by any enabled region. It does +spend regions on its own MMIO — see the amendment below.** + +TRM §8.2.2 gives the permission half for free: with `BR=1`, EL2's own accesses +that hit no programmable region fall back to the background map, while +*"accesses from the EL0/EL1 translation regime that do not hit in the EL2 +programmable regions generate a translation fault"* — unconditionally, whatever +`BR` is set to. So the same bit buys deny-all for guests and no region cost for +ordinary hypervisor code and data. + +That matters because of the second architectural fact in +`docs/armv8r-el2-reference.md`: there is exactly one EL2 region set, shared +between the hypervisor's own mapping and every guest's stage-2 mapping. On the +S32Z280 that set has 20 entries, and every one spent on ZoneX comes straight +out of the partition budget. + +Two constraints come with it, and both bind before the first image runs. + +**`BR` gives permission, not attributes.** The background map's data attributes +are fixed by TRM Table 8-4, and below `0x60000000` they are Normal cacheable. +The S32Z27 reference manual puts LINFlexD_9 — the hypervisor's own console on +the S32Z280 — at `0x4298_0000`, inside the Normal Write-Through band, and the +GIC sits in the same band. Reaching a memory-mapped device with cacheable +attributes is not a working console; it is a bug that appears intermittently. +**So the hypervisor must program Device-attributed EL2 regions for its own +console and GIC even with `BR=1`, and those regions have to be budgeted.** + +**The FVP cannot show you this.** Its console and GIC sit at `0x9C090000` and +`0xAF000000`, inside the Device-nGnRE band, where the background map happens to +be correct. A green FVP run therefore proves nothing about whether the +hypervisor's own MMIO regions are right — the same shape as the region-count +trap already recorded in the reference sheet. Verify on silicon. + +**And hypervisor code must live below `0x80000000`**, because the background map +marks `0x80000000`–`0xFFFFFFFF` execute-never for instruction fetch (TRM +Table 8-3). + +*Amended after the background-map attributes were read out of Table 8-4. The +original entry claimed zero region cost outright, which is true of code and +data and false of MMIO.* + +--- + +## D3 — What isolates one partition from another · **settled** + +**Which regions are enabled while a partition runs.** Not the permission bits. + +This is forced, not chosen. TRM Table 3-82 gives four `AP[2:1]` encodings for +the EL2 MPU and **none of them grants EL0/EL1 access while denying EL2**: + +| `AP[2:1]` | From EL2 | From EL0/EL1 | +|---|---|---| +| `0b00` | RW | none | +| `0b01` | RW | RW | +| `0b10` | RO | none | +| `0b11` | RO | RO | + +So a partition's memory is always reachable from the hypervisor — which the +loader needs anyway — and the only mechanism that can separate partition A from +partition B is that A's regions are enabled and B's are not. + +Everything about the memory model follows from this. It is why the manifest has +to produce disjoint regions, why a partition switch is a region-enable +operation, and why "map everything and override" is not available here even +before PMSAv8-R's lack of region priority rules it out. + +--- + +## D4 — How the region set is switched · **open until measured on silicon** + +**Preferred: program every partition's regions once at boot and switch with a +single `HPRENR` write.** Fall back to rewriting the region block directly. + +The preference depends on two things that are not yet proven. `HPRENR` must +really be 20 bits wide on a 20-region implementation — the TRM contradicts +itself and open question 1 in `docs/armv8r-el2-reference.md` tracks it. And the +whole budget must fit: 20 regions, two partitions, plus whatever the console +and the guest images need. + +The fallback is measured, on the S32Z280 and at EL1, during the Cortex-R52 +Modules port work: a direct region write costs 434–470 cycles, against +542–604 through `PRSELR` for a region ≥ 16. ZoneX avoids that second +number entirely — open question 2 established that `HPRBAR16`–`HPRBAR24` are +directly addressable at `opc1 = 5`, so the high regions cost the same as the +low ones. + +**This is a worst-case-execution-time decision, so it is settled with a +measurement on silicon, not with an argument now.** + +--- + +## D5 — How an address is spelled in the manifest · **settled** + +**A width-correct typedef, `zx_addr_t`, defined in `common/inc/zx_api.h`.** Not +a bare `ULONG`. + +The manifest is a data structure ZoneX intends to keep across ports, and +Armv8-R AArch32 will not be the last architecture it describes — RISC-V with +the hypervisor extension and Armv8-R AArch64 (R82) are both 64-bit. A bare +`ULONG` would make every one of those a source-compatibility break. + +It is `uintptr_t` rather than a fixed width, so a 64-bit port widens it with +the pointer instead of needing every declaration revisited. The case that does +*not* cover is a machine whose physical address space is wider than its +pointers; ZoneX has no such target, and acquiring one means revisiting the +typedef rather than working around it at each use. + +--- + +## D6 — How a guest image reaches the hypervisor · **settled** + +**Build each guest as a separate ELF, `objcopy` it to a binary, and `.incbin` +it into the ZoneX image** — exactly as the ThreadX Modules port does. + +Two traps come with it, both already paid for by the ThreadX Modules port, and +both worth re-reading before the first guest lands rather than after: + +* **A linker input pattern must carry the source suffix** (`foo.c.obj`), and + `ALIGN` belongs *after* the colon. A pattern that matches nothing produces an + empty section at whatever address the location counter happened to hold — an + image that builds, links, reports a plausible size, and contains nothing. +* **Code that was copied needs a D-cache clean and then an I-cache + invalidate.** It appears to work without them until an eviction lands + differently. + +--- + +## D7 — What clock a guest reads · **settled** + +**Guests use the virtual timer (PPI 27) with a per-partition `CNTVOFF`. The +hypervisor keeps the hypervisor timer (PPI 26) for the partition tick.** + +`CNTVOFF` is what makes a descheduled partition's clock *frozen* rather than +merely unread, which is the difference between temporal partitioning and time +slicing that a guest can observe. + +The alternative — leave guests on the physical timer and accept that they lose +ticks while descheduled — is simpler and undermines the temporal-determinism +claim, which is the entire point of the demonstrator. Simplicity that costs the +claim is not simplicity worth having here. + +--- + +## D8 — The console · **probe settled; sharing open** + +ZoneX needs a console before it needs anything else, because the Armv8-R AEM +FVP has no debugger seam at all — an Iris server and no GDB stub — so a model +regression can only report through a channel the image carries itself. + +**For the hypervisor's own console, settled: semihosting on the FVP, +LINFlexD_9 on the S32Z280.** Semihosting is chosen for the model because it +needs no peripheral and therefore cannot be broken by a wrong memory map, +which makes it the right thing to bring up *before* the MPU is trusted. +LINFlexD_9 is what the board has, and what the Cortex-R52 bring-up already +uses. Note the consequence recorded in D2: LINFlexD_9 sits at `0x4298_0000`, +in a Normal-cacheable band of the background map, so it needs a +Device-attributed EL2 region — the console is the first thing that proves the +hypervisor's own MMIO regions are programmed correctly. + +**Still open: how two partitions share a console.** That decision arrives when +there are two of them, and the options are: + +* one partition owns the UART and the other has no console; +* both get it mapped, which is honest about there being no device isolation in + Phase 0 and should be said out loud if chosen; +* an `HVC` console hypercall, which costs more and exercises the hypercall + vector as a side effect. + +--- + +## D9 — Does ZoneX link ThreadX? · **settled** + +**No, not in Phase 0.** At EL2 ZoneX is a standalone bare-metal program. The +*guests* are ThreadX. + +Revisit only if a later phase's time-partition scheduler wants kernel services, +and treat that as a significant decision rather than a convenience: linking a +kernel into the hypervisor means the hypervisor inherits the kernel's +certification argument. + +See D14 for the build seam this leaves behind — the examples still need ThreadX +sources, because they build the guest images. + +--- + +## D10 — Eclipse boilerplate and supply-chain posture · **settled** + +**Copy the posture the rest of the suite runs, rather than inventing one.** In +place from the first commit: + +* every GitHub Action pinned to a 40-character commit SHA, with the version in + a trailing comment; +* `.github/dependabot.yml` configured for the `github-actions` ecosystem, so + the pins move; +* `.github/CODEOWNERS` routing every path to `@eclipse-threadx/admins`, which + is also what routes Dependabot's reviewers; +* a pull request template naming the checks a ZoneX change has to pass. + +A SHA pin *without* Dependabot is worse than a floating tag: it freezes CI on +whatever was current the day it was written. ThreadX discovered that with +`actions/cache@v1` still in a workflow until GitHub began auto-failing every +request that used it, and with four other actions between two and four majors +behind, none of it reported anywhere. + +Two things deliberately *not* added. There is no ECA workflow, because the +Eclipse Contributor Agreement check is a Foundation-run GitHub App at the +organisation level and there is nothing repository-side to add — no other +suite repository has one either. And there are no issue templates yet: ZoneX +has no users to triage reports from, and a template set that does not match the +project's actual intake is worse than none. Add them when the first outside +issue arrives. + +--- + +## D11 — What the regression suite runs on · **settled** *(answers upstream issue #2)* + +**Two suites, deliberately.** + +* **Host-compiled unit tests** (`test/host`, `scripts/build_host.sh` / + `scripts/test_host.sh`) cover the architecture-independent logic: the + manifest and its validator, the partition tables, the schedule arithmetic. + Fast, instrumented for coverage, no model and no board. +* **FVP and silicon** cover everything that is only true on the hardware: + stage-2 programming, the trap path, isolation, and the timing claims. The + isolation and determinism cases are meaningless anywhere else. + +Issue #2 also weighed a host *simulator port of ZoneX itself*, mirroring +ThreadX's `win32`/`posix` approach. It was dropped: ZoneX's whole subject is +stage-2 MPU behaviour and trap-and-emulate, so a simulator would be testing a +simulation of the mechanism rather than the mechanism, and a green run would +mean almost nothing. + +The coverage consequence, stated rather than discovered: **the 99/100 threshold +the ThreadX repositories hold in `regression_test.yml` is not achievable for +ZoneX as a whole**, because a large fraction of ZoneX is assembly and CP15 +programming that no host build compiles. Coverage is reported **for the host +suite over `core/` and `common/` only**, and the target-side confidence comes +from the FVP and silicon regressions being positive *and negative*, which is +the isolation and determinism regression's job. A single percentage over the whole repository would be a number that looks +like the ThreadX one and does not mean the same thing. + +--- + +## D12 — C17, and ZoneX as the reference implementation · **settled** + +**ZoneX is built to C17 from its first commit, with `CMAKE_C_EXTENSIONS OFF`, +and it is the reference implementation of the suite-wide C17/CMake plan rather +than an exception to it.** + +Every other component migrates to C17 from C99. ZoneX is the only one *born* +there, which makes it the cheapest place to get the pattern right: the +capability macro block, `cmake_minimum_required(VERSION 3.28...4.2)`, the +`ci-strict` preset with `CMAKE_COMPILE_WARNING_AS_ERROR`, and `FILE_SET +HEADERS` are all in place from commit one. A later component copying the shape +should copy ZoneX's. + +Two details that must survive any later edit of `common/inc/zx_api.h`: + +* **There is no C17 branch in the capability macro block.** `[[nodiscard]]` is + C23. With extensions off and `-Wpedantic` in force, a C17 build spelling + `[[…]]` is rejected outright — *"ISO C does not support `[[` attributes + before C23"*. C17 falls through to `__attribute__((warn_unused_result))` on + GCC and Clang, and `_Check_return_` on MSVC, deliberately. +* **`__STDC_VERSION__` appears in that block and nowhere else in the header.** + +The strict warning set is upstream ThreadX's kernel set plus +`-Wmissing-prototypes`, the flag that found the MISRA Rule 8.4 findings in the +Cortex-R52 port and Modules port work. It is applied from the first commit, in `cmake/zx_warnings.cmake`, +rather than in a later hardening pass — retrofitting a codebase to a warning +set costs far more than building to one. + +Note that `CONTRIBUTING.md` inherited "the code is C99-compatible" from the +suite-wide text. That is corrected for ZoneX; it does not apply here. + +--- + +## D13 — Is there a Windows story? · **settled** + +**No, and `scripts/` ships no PowerShell equivalents.** + +Phase 0 targets the Armv8-R AEM FVP and the S32Z280-594EVB, cross-compiled with +the Arm GNU Toolchain, and the host suite is built with GCC. Nothing in that +chain has a Windows path anybody is going to exercise, and an untested +`build_host.ps1` would be a promise the project cannot keep. + +`CONTRIBUTING.md` inherited a sentence implying PowerShell scripts alongside +the shell ones. That is qualified rather than left standing. + +This is reversible cheaply: `.gitattributes` already pins `*.ps1` to CRLF, and +the `run.sh` entry points hold all the logic, so a PowerShell wrapper set would +be thin whenever ZoneX acquires a reason for one. + +--- + +## D14 — The seam to a ThreadX checkout · **settled; implemented with the first guest image** + +**A single CMake cache variable, `ZX_THREADX_SOURCE_DIR`**, declared in the +root `CMakeLists.txt` now and consumed by the examples once they build guest +images. + +Under D9 ZoneX itself does not need ThreadX at all. But the *examples* build +ThreadX guest images, so the dependency is real and belongs somewhere visible. +Declaring the variable now, empty, is what makes it visible; the alternative — +discovering it when the first guest image is built and inventing a mechanism +then — is how a build grows an undocumented assumption about where a sibling +checkout lives. + +The checkout has to be one carrying a ThreadX-side change ZoneX needs: the +S32Z280 `entry.S` has no `TX_R52_BOOT_AT_EL1` option — the FVP one does — and a +silicon guest cannot start at EL1 without it. That is the reason the seam is a +path to a checkout rather than a released tarball for now, and it will stop +being one as soon as that option is upstream. diff --git a/examples/fvp_baser_aemv8r/CMakeLists.txt b/examples/fvp_baser_aemv8r/CMakeLists.txt new file mode 100644 index 0000000..5e3f80f --- /dev/null +++ b/examples/fvp_baser_aemv8r/CMakeLists.txt @@ -0,0 +1,49 @@ +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# ZoneX on the Armv8-R AEM FVP (FVP_BaseR_AEMv8R). +# +# STATUS: no images yet. The first one -- boot at EL2, program one stage-2 +# region, take a fault and decode it -- lands with the EL2 bring-up work. This +# file exists now so that the option, the model discovery and the CTest +# registration are in place before there is anything to run, rather than being +# invented alongside the first image. +# +# Two things to keep in mind when the first image lands here: +# +# * Images are EXCLUDE_FROM_ALL, as ThreadX's are. That means a bare +# "ninja" builds none of them, and "ctest" then fails with "application +# file not found" rather than with anything that names the real problem. +# Build the image targets explicitly. +# +# * The FVP is not a Cortex-R52. It reported 32 EL1 MPU regions, which is +# not an architecturally legal R52 value -- 16, 20 and 24 are the legal +# ones -- so a green FVP run proves nothing about any real part's region +# budget. Expect the same at EL2, and read HMPUIR on the model rather +# than assuming it matches the S32Z280's 20. + +# The model. find_program caches into ZX_FVP_BASER_AEMV8R, so CI can override +# the hint with -DZX_FVP_BASER_AEMV8R= and no change to this file. +find_program(ZX_FVP_BASER_AEMV8R FVP_BaseR_AEMv8R + HINTS $ENV{HOME}/FVP_Base_AEMv8R_11.32_19/bin +) + +if(ZX_FVP_BASER_AEMV8R) + message(STATUS "ZoneX: Armv8-R AEM FVP found at ${ZX_FVP_BASER_AEMV8R}") +else() + message(STATUS "ZoneX: FVP_BaseR_AEMv8R not found; FVP run targets unavailable.") +endif() + +message(STATUS "ZoneX: the FVP example builds no images yet.") diff --git a/examples/s32z280_evb/CMakeLists.txt b/examples/s32z280_evb/CMakeLists.txt new file mode 100644 index 0000000..3c7f645 --- /dev/null +++ b/examples/s32z280_evb/CMakeLists.txt @@ -0,0 +1,40 @@ +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# ZoneX on the NXP S32Z280-594EVB. +# +# STATUS: no images yet. The first one lands with the EL2 bring-up work, +# alongside the FVP's. +# This target is build-only in CI: it needs silicon, and a hosted runner has +# none. Running it is a bench activity driven by the gdb harness that lands +# with the first image. +# +# What is already known about this board, measured rather than assumed, and +# recorded here so the first image is not written against guesses: +# +# * HMPUIR reads 0x00000014 -- 20 EL2 regions, matching its 20 EL1 regions. +# That is the whole budget the hypervisor's own mapping and every guest's +# stage-2 mapping share. +# +# * SCTLR.TE and HSCTLR.TE reset SET on this part. A32 vectors then never +# run and every fault presents as a silent hang, which is the single most +# expensive way to lose a day on this board. +# +# * Two enabled MPU regions matching one address abort here. PMSAv8-R has +# no region priority, and this part does not tolerate the overlap. +# +# See docs/armv8r-el2-reference.md. + +message(STATUS "ZoneX: the S32Z280 example builds no images yet.") diff --git a/platform/cortex_r52/CMakeLists.txt b/platform/cortex_r52/CMakeLists.txt new file mode 100644 index 0000000..cbdc722 --- /dev/null +++ b/platform/cortex_r52/CMakeLists.txt @@ -0,0 +1,33 @@ +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# The Cortex-R52 port: everything in ZoneX that is specific to Armv8-R AArch32 +# at EL2. + +target_sources(zonex_port PRIVATE + ${CMAKE_CURRENT_LIST_DIR}/src/zx_el2_entry.S + ${CMAKE_CURRENT_LIST_DIR}/src/zx_trap_handler.S + ${CMAKE_CURRENT_LIST_DIR}/src/zx_console.c + ${CMAKE_CURRENT_LIST_DIR}/src/zx_gic.c + ${CMAKE_CURRENT_LIST_DIR}/src/zx_stage2_mpu.c + ${CMAKE_CURRENT_LIST_DIR}/src/zx_timer.c +) + +target_sources(zonex_port PUBLIC + FILE_SET zx_port_headers + TYPE HEADERS + BASE_DIRS ${CMAKE_CURRENT_LIST_DIR}/inc + FILES ${CMAKE_CURRENT_LIST_DIR}/inc/zx_port.h +) diff --git a/platform/cortex_r52/inc/zx_port.h b/platform/cortex_r52/inc/zx_port.h new file mode 100644 index 0000000..aaceed5 --- /dev/null +++ b/platform/cortex_r52/inc/zx_port.h @@ -0,0 +1,82 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_port.h Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* The Cortex-R52 port interface: everything ZoneX needs that is */ +/* specific to Armv8-R AArch32 at EL2. */ +/* */ +/* TERMINOLOGY -- READ THIS BEFORE ADDING ANYTHING BELOW */ +/* */ +/* The Cortex-R52 is AArch32-only and Armv8-R has NO PAGE TABLES at */ +/* either stage of translation. Both stages are region-based MPUs */ +/* (PMSAv8-R). The registers this port uses are therefore the */ +/* AArch32 Hyp-mode ones, reached through MRC/MCR p15, 4, , ... */ +/* (opc1 = 4 is the Hyp bank): */ +/* */ +/* HVBAR EL2 vector base HSR syndrome */ +/* HCR hypervisor configuration HDFAR data fault address */ +/* HSCTLR EL2 system control HIFAR instruction fault */ +/* HMPUIR EL2 region count HPFAR stage-2 fault addr */ +/* HPRSELR region select HMAIR0 memory attributes */ +/* HPRENR region enables HMAIR1 memory attributes */ +/* HPRBAR region base CNTHCTL timer access control */ +/* HPRLAR region limit CNTVOFF virtual time offset */ +/* ICC_HSRE GICv3 sysreg interface */ +/* */ +/* A second family of names is WRONG BY CONSTRUCTION for this target */ +/* and must not appear anywhere in this repository: the AArch64 */ +/* system-register spellings, the translation-table registers, and the */ +/* RISC-V memory-protection vocabulary. Using one of them means the */ +/* code was written against the wrong architecture, and the result */ +/* reads plausibly while being wrong about the hardware. */ +/* */ +/* scripts/check_terminology.sh holds the exact list and enforces it */ +/* in CI. It is the single place that spells those names, so that */ +/* the list cannot drift away from what is actually checked. */ +/* */ +/* The verified encodings, field layouts and exception-class values */ +/* are in docs/armv8r-el2-reference.md, which is the authority. Take */ +/* them from there rather than from memory or from an A-profile */ +/* hypervisor; the AP field in particular does NOT mean at EL2 what it */ +/* means at EL1. */ +/* */ +/* STATUS */ +/* */ +/* Declared empty. The CP15 accessors and the first stage-2 region */ +/* programming arrive with the EL2 bring-up work. */ +/* */ +/**************************************************************************/ + +#ifndef ZX_PORT_H +#define ZX_PORT_H + +#include "zx_api.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#ifdef __cplusplus +} +#endif + +#endif /* ZX_PORT_H */ diff --git a/platform/cortex_r52/src/zx_console.c b/platform/cortex_r52/src/zx_console.c new file mode 100644 index 0000000..4d8c3a9 --- /dev/null +++ b/platform/cortex_r52/src/zx_console.c @@ -0,0 +1,39 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_console.c Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* The hypervisor's own console. ZoneX needs one before it needs */ +/* anything else: on the Armv8-R AEM FVP there is no debugger seam at */ +/* all -- an Iris server and no GDB stub -- so a model regression can */ +/* only report through a channel the image carries itself. */ +/* */ +/* How the console is shared once there are two partitions is an open */ +/* decision, docs/decisions.md D8, deliberately left to the step that */ +/* first has two guests wanting it. */ +/* */ +/* This translation unit is deliberately empty of implementation. */ +/* See docs/armv8r-el2-reference.md for the verified register sheet */ +/* the code that lands here must be written against. */ +/* */ +/**************************************************************************/ + +#include "zx_port.h" diff --git a/platform/cortex_r52/src/zx_el2_entry.S b/platform/cortex_r52/src/zx_el2_entry.S new file mode 100644 index 0000000..3674b8d --- /dev/null +++ b/platform/cortex_r52/src/zx_el2_entry.S @@ -0,0 +1,41 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +@ zx_el2_entry.S -- Cortex-R52 -- the ZoneX EL2 reset and entry path. +@ +@ DESCRIPTION +@ +@ Where ZoneX starts. Establishes the Hyp-mode stack, installs HVBAR, +@ programs CNTFRQ, opens CNTHCTL and ICC_HSRE for EL1, brings up the EL2 +@ MPU with HSCTLR.BR=1, and hands control to the partition manager -- +@ staying at EL2 rather than dropping to EL1, which is what distinguishes +@ this from the ThreadX R52 port's entry.S that it is modelled on. +@ +@ The ThreadX Cortex-R52 port's entry.S is the reference for the EL2 +@ configuration sequence and already does most of this for the ThreadX +@ case; what it does NOT do is leave HCR.VM set, and turning stage 2 on is +@ exactly what ZoneX adds. +@ +@ STATUS +@ +@ Deliberately empty. The change that founded this repository wrote no +@ assembly on purpose: every register name this file will use had to be +@ verified first, and that verification is docs/armv8r-el2-reference.md. +@ +@ NOTE ON SYNTAX +@ +@ GNU assembler syntax, per CONTRIBUTING.md. '@' is the AArch32 comment +@ character; this file is assembled through the C preprocessor (it is +@ .S, not .s), so C-style comments work here as well. diff --git a/platform/cortex_r52/src/zx_gic.c b/platform/cortex_r52/src/zx_gic.c new file mode 100644 index 0000000..5d954b8 --- /dev/null +++ b/platform/cortex_r52/src/zx_gic.c @@ -0,0 +1,39 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_gic.c Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* GICv3 bring-up for EL2, and the routing of guest interrupts. */ +/* */ +/* The Cortex-R52 does implement the GICv3 virtual CPU interface -- */ +/* ICH_HCR, ICH_VTR and four List Registers, ICH_LR0 to ICH_LR3 -- so */ +/* interrupt injection is available. Phase 0 still keeps interrupt */ +/* handling deliberately simple; that is a scope decision rather than */ +/* a hardware limitation, and it is recorded as one so a later phase */ +/* does not rediscover the List Registers from scratch. */ +/* */ +/* This translation unit is deliberately empty of implementation. */ +/* See docs/armv8r-el2-reference.md for the verified register sheet */ +/* the code that lands here must be written against. */ +/* */ +/**************************************************************************/ + +#include "zx_port.h" diff --git a/platform/cortex_r52/src/zx_stage2_mpu.c b/platform/cortex_r52/src/zx_stage2_mpu.c new file mode 100644 index 0000000..8411c12 --- /dev/null +++ b/platform/cortex_r52/src/zx_stage2_mpu.c @@ -0,0 +1,49 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_stage2_mpu.c Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* Stage-2 MPU programming: HPRSELR/HPRBAR/HPRLAR region descriptors, */ +/* HPRENR bulk enable, HMAIR0/HMAIR1 attributes, and the HCR.VM plus */ +/* HSCTLR.M pair that turns stage 2 on. */ +/* */ +/* Three facts shape everything written here. There is exactly ONE */ +/* EL2 region set and it does two jobs -- the hypervisor's own mapping */ +/* and every guest's stage-2 mapping compete for the same 16, 20 or 24 */ +/* regions. HSCTLR.BR=1 relieves most of that pressure: EL2's own */ +/* accesses that hit no region fall back to the background map, while */ +/* EL0/EL1 accesses that hit no region fault regardless of BR -- */ +/* deny-all for guests, and no region spent on ordinary hypervisor */ +/* code and data. But BR grants permission, not attributes. The */ +/* background map is Normal cacheable below 0x60000000, so an MMIO */ +/* device the hypervisor owns -- its console, the GIC -- is reachable */ +/* there with cacheable attributes and still needs a Device-attributed */ +/* EL2 region of its own. On the FVP those devices happen to sit in */ +/* the Device band, so the FVP cannot show you that. See */ +/* docs/decisions.md D2. */ +/* */ +/* This translation unit is deliberately empty of implementation. */ +/* See docs/armv8r-el2-reference.md for the verified register sheet */ +/* the code that lands here must be written against. */ +/* */ +/**************************************************************************/ + +#include "zx_port.h" diff --git a/platform/cortex_r52/src/zx_timer.c b/platform/cortex_r52/src/zx_timer.c new file mode 100644 index 0000000..d74bba4 --- /dev/null +++ b/platform/cortex_r52/src/zx_timer.c @@ -0,0 +1,38 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_timer.c Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* The generic timer at EL2. Three timer interrupts arrive as PPIs: */ +/* physical 30, virtual 27, hypervisor 26. */ +/* */ +/* ZoneX drives the partition tick from the HYPERVISOR timer (PPI 26) */ +/* and leaves the virtual timer (PPI 27) to guests, giving each */ +/* partition its own CNTVOFF so that guest time freezes while the */ +/* partition is descheduled. See docs/decisions.md D7. */ +/* */ +/* This translation unit is deliberately empty of implementation. */ +/* See docs/armv8r-el2-reference.md for the verified register sheet */ +/* the code that lands here must be written against. */ +/* */ +/**************************************************************************/ + +#include "zx_port.h" diff --git a/platform/cortex_r52/src/zx_trap_handler.S b/platform/cortex_r52/src/zx_trap_handler.S new file mode 100644 index 0000000..34c7b0a --- /dev/null +++ b/platform/cortex_r52/src/zx_trap_handler.S @@ -0,0 +1,42 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +@ zx_trap_handler.S -- Cortex-R52 -- the ZoneX EL2 exception vectors. +@ +@ DESCRIPTION +@ +@ The EL2 vector table installed in HVBAR, and the trap entry it points +@ at. Every exception routed to EL2 from EL0 or EL1 arrives at ONE +@ vector, HVBAR + 0x14, so this file's real work is saving the guest +@ context and handing HSR to the C decoder in core/src/zx_fault.c. +@ +@ The exception classes that matter, from HSR.EC: +@ +@ 0x12 HVC executed -- the hypercall seam +@ 0x24 data abort ROUTED to Hyp -- a guest violating its partition, +@ with the faulting address in HPFAR. This is the event the +@ Phase-0 demonstrator exists to show. +@ 0x25 data abort TAKEN FROM Hyp -- a ZoneX bug, and it must be +@ reported differently rather than folded in with 0x24. +@ +@ The full table is in docs/armv8r-el2-reference.md. +@ +@ STATUS +@ +@ Deliberately empty. See zx_el2_entry.S. +@ +@ NOTE ON SYNTAX +@ +@ GNU assembler syntax, per CONTRIBUTING.md. diff --git a/scripts/build_fvp.sh b/scripts/build_fvp.sh new file mode 100755 index 0000000..d4bb6fd --- /dev/null +++ b/scripts/build_fvp.sh @@ -0,0 +1,21 @@ +#!/bin/bash +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# Build the ZoneX images for the Armv8-R AEM FVP. + +set -euo pipefail + +exec "$(dirname "$(realpath "$0")")/../test/fvp/run.sh" build "$@" diff --git a/scripts/build_host.sh b/scripts/build_host.sh new file mode 100755 index 0000000..194c380 --- /dev/null +++ b/scripts/build_host.sh @@ -0,0 +1,25 @@ +#!/bin/bash +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# Build the ZoneX host unit-test target. +# +# A thin wrapper over test/host/run.sh, which is where the build actually +# lives. The pair of wrappers exists because every Eclipse ThreadX repository +# has one per test target and contributors expect to find it. + +set -euo pipefail + +exec "$(dirname "$(realpath "$0")")/../test/host/run.sh" build "$@" diff --git a/scripts/build_s32z280.sh b/scripts/build_s32z280.sh new file mode 100755 index 0000000..d5ca210 --- /dev/null +++ b/scripts/build_s32z280.sh @@ -0,0 +1,21 @@ +#!/bin/bash +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# Build the ZoneX images for the NXP S32Z280-594EVB. + +set -euo pipefail + +exec "$(dirname "$(realpath "$0")")/../test/s32z280/run.sh" build "$@" diff --git a/scripts/check_terminology.sh b/scripts/check_terminology.sh new file mode 100755 index 0000000..9b09722 --- /dev/null +++ b/scripts/check_terminology.sh @@ -0,0 +1,93 @@ +#!/bin/bash +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# Fail the build if A-profile or RISC-V terminology has leaked into ZoneX. +# +# This is not pedantry. ZoneX targets Armv8-R AArch32, where BOTH stages of +# address control are region-based MPUs and there are no page tables anywhere. +# The failure mode this guards against is a real one and it is cheap to fall +# into: a contributor -- or a model -- reaches for the A-profile spelling of a +# register, or shapes stage 2 like a KVM/Xen translation table, and the result +# reads plausibly, compiles on a host, and is wrong about the hardware. +# Terminology bleed is the single easiest way to get this architecture wrong, +# so it is checked mechanically rather than left to review. +# +# The names below are wrong BY CONSTRUCTION for this target: +# +# *_EL2, *_EL1, *_EL0 AArch64 system-register spellings. The R52 is +# AArch32-only; the Hyp-bank names are HVBAR, HCR, +# HSCTLR, HSR, HMAIR0/1 and so on. +# DAIF, DAIFSet, ... AArch64 interrupt-mask spellings. AArch32 masks +# live in CPSR. +# VTTBR, VTCR, TTBR Translation-table base and control registers. There +# is no translation table at either stage here. +# MAIR_EL2 The AArch64 spelling of HMAIR0/HMAIR1. +# NAPOT, PMP RISC-V physical-memory-protection vocabulary. +# page table The concept itself. +# +# A line that must name one of these -- the reference sheet does, to say they +# are wrong -- carries the marker "zx-terminology-allow" and is skipped. The +# marker is deliberately ugly so that using it is a visible decision. + +set -euo pipefail + +readonly ROOT="$(cd "$(dirname "$(realpath "$0")")/.." && pwd)" +readonly MARKER='zx-terminology-allow' + +# Word boundaries matter here. "MAIR_EL2" must be caught while "HMAIR0" must +# not, and a bare "PMP" must be caught while "PMPU" -- were anything ever named +# that -- must not. +# +# The _EL rule carries an exception, and it is not a loophole. What is +# forbidden is an AArch64 SYSTEM REGISTER spelling: MAIR_EL2, SCTLR_EL2, +# HCR_EL2. Suite build-option names end the same way and are perfectly +# legitimate -- TX_R52_BOOT_AT_EL1 is the real ThreadX CMake option that builds +# a guest starting at EL1, and the guest work has to name it repeatedly. A +# system +# register never carries a component prefix, so requiring the absence of one is +# enough to tell them apart. +# +# This needs grep -P for the negative lookahead. GNU grep has it; a build +# environment without it should fail loudly rather than silently checking +# nothing, which is why the invocation below does not fall back to -E. +readonly PROJECT_PREFIXES='TX|ZX|NX|FX|UX|GX|LX' +readonly PATTERN="\\b(?!(?:${PROJECT_PREFIXES})_)[A-Z][A-Z0-9_]*_EL[0-3]\\b|\\bDAIF[A-Za-z]*\\b|\\bVTTBR[A-Z0-9_]*\\b|\\bVTCR[A-Z0-9_]*\\b|\\bTTBR[0-9A-Z_]*\\b|\\bNAPOT\\b|\\bPMP\\b|\\bpage[ -]tables?\\b" + +cd "${ROOT}" + +# Tracked files only. A build tree full of toolchain headers is not ZoneX's +# terminology to police, and scanning one would make this check depend on +# whether somebody had built first. +# +# This script is excluded by name: it has to spell every forbidden term in +# order to look for it. +findings="$(git ls-files -z \ + | grep -zZv '^scripts/check_terminology\.sh$' \ + | xargs -0 grep -nPI "${PATTERN}" 2>/dev/null \ + | grep -v "${MARKER}" || true)" + +if [ -n "${findings}" ]; then + echo "ZoneX terminology check FAILED." >&2 + echo "" >&2 + echo "These lines use a register or concept name that does not exist on" >&2 + echo "Armv8-R AArch32. See platform/cortex_r52/inc/zx_port.h and" >&2 + echo "docs/armv8r-el2-reference.md for the correct spellings." >&2 + echo "" >&2 + echo "${findings}" >&2 + exit 1 +fi + +echo "ZoneX terminology check passed." diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..6c66300 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,93 @@ +#!/bin/bash +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# Install the ZoneX build and test dependencies on Ubuntu. +# +# The retry and timeout handling below is copied from threadx's +# scripts/install.sh rather than written fresh, and the reason is worth keeping +# with it. Everything here reaches the network, and on the hosted runner pool +# that is not dependable: apt-get update stalled seven times in a single day +# there, once for more than two hours, each time with the mirror returning +# nothing and the fallback going silent. Nothing bounded a fetch and nothing +# retried one, so a mirror being down cost a whole run rather than a few +# seconds. +# +# Each command is wrapped in timeout rather than left to bound itself. apt's +# own Acquire timeouts were tried there first and did not help: a run still sat +# inside a single apt-get update for nine and a half minutes without producing +# a line. Whatever apt waits on is not something Acquire::http::Timeout +# covers. timeout does not care where the wait is. +# +# The Acquire options are kept anyway, since they make a slow mirror give up +# sooner. set -e is on -- threadx's copy has no set -e, which let a failed +# update be carried forward into an install from a stale index, and the run +# then failed later somewhere less obvious. + +set -euo pipefail + +APT_OPTIONS=(-o Acquire::Retries=3 + -o Acquire::http::Timeout=20 + -o Acquire::https::Timeout=20) + +# Two minutes per attempt, killed outright if it ignores the first signal. +# Three attempts plus backoff bounds a command at about six and a half minutes. +# +# timeout goes under sudo, not over it, so that it signals apt itself. +# Signalling sudo instead risks the kill landing on sudo while apt carries on +# holding the dpkg lock, which would leave every retry failing for a different +# reason than the one being retried. +TIMEOUT=(timeout --kill-after=10 120) + +retry() { + local attempt + for attempt in 1 2 3; do + if "$@"; then + return 0 + fi + echo "install.sh: '$*' failed or timed out on attempt ${attempt}" + sleep $((attempt * 10)) + done + echo "install.sh: '$*' failed after 3 attempts" + return 1 +} + +retry sudo "${TIMEOUT[@]}" apt-get "${APT_OPTIONS[@]}" update +retry sudo "${TIMEOUT[@]}" apt-get "${APT_OPTIONS[@]}" install -y \ + --no-install-recommends \ + build-essential \ + cmake \ + git \ + ninja-build \ + python3 \ + python3-pip + +# gcovr is pinned exactly rather than floated. The coverage percentage has to +# stay comparable between runs, and the denominator is a property of the tool +# as much as of the tree, so a tool that moves on its own makes every reported +# movement ambiguous. It is moved by hand: this is a shell script, and no +# Dependabot ecosystem parses one. +# +# 8.6 is the version ThreadX pins, deliberately -- the two repositories report +# coverage against the same tool so the numbers can be compared. +retry "${TIMEOUT[@]}" python3 -m pip install --break-system-packages \ + --retries 3 --timeout 30 gcovr==8.6 + +# The Arm GNU Toolchain is NOT installed here. It is fetched, checksum-checked +# and cached by the workflow that needs it, because a 500MB download does not +# belong in the step that installs six apt packages, and because CI and a +# developer machine want it in different places. CONTRIBUTING.md names the +# pinned version; cmake/cortex_r52.cmake expects it under +# $HOME/toolchains/arm-gnu-toolchain--x86_64-arm-none-eabi. diff --git a/scripts/test_fvp.sh b/scripts/test_fvp.sh new file mode 100755 index 0000000..840e93d --- /dev/null +++ b/scripts/test_fvp.sh @@ -0,0 +1,21 @@ +#!/bin/bash +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# Build and execute the ZoneX images on the Armv8-R AEM FVP. + +set -euo pipefail + +exec "$(dirname "$(realpath "$0")")/../test/fvp/run.sh" test "$@" diff --git a/scripts/test_host.sh b/scripts/test_host.sh new file mode 100755 index 0000000..feaf533 --- /dev/null +++ b/scripts/test_host.sh @@ -0,0 +1,25 @@ +#!/bin/bash +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# Run the ZoneX host unit-test target. Pass "coverage" through to run.sh to +# produce a gcovr report as well. + +set -euo pipefail + +# "$@" replaces the default rather than following it, so that +# "scripts/test_host.sh coverage" reaches run.sh as "coverage" and not as +# "test coverage", which run.sh would silently ignore. +exec "$(dirname "$(realpath "$0")")/../test/host/run.sh" "${@:-test}" diff --git a/scripts/test_s32z280.sh b/scripts/test_s32z280.sh new file mode 100755 index 0000000..d22e6c3 --- /dev/null +++ b/scripts/test_s32z280.sh @@ -0,0 +1,22 @@ +#!/bin/bash +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# Build and run the ZoneX images on the NXP S32Z280-594EVB. Needs the board; +# CI builds this target and does not run it. + +set -euo pipefail + +exec "$(dirname "$(realpath "$0")")/../test/s32z280/run.sh" test "$@" diff --git a/test/fvp/run.sh b/test/fvp/run.sh new file mode 100755 index 0000000..f6de308 --- /dev/null +++ b/test/fvp/run.sh @@ -0,0 +1,84 @@ +#!/bin/bash +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# Entry point for the ZoneX Armv8-R AEM FVP target. +# +# run.sh build cross-configure and build every image +# run.sh test build, then execute the images on the FVP through CTest +# +# The model is found by CMake, with $HOME/FVP_Base_AEMv8R_11.32_19/bin as the +# hint. Point ZX_FVP at a different one to override it. +# +# STATUS: there are no images yet. "build" therefore configures and builds the +# hypervisor libraries, and "test" reports that there is nothing to execute -- +# loudly, and with a zero status, because a foundation-only tree failing its own +# CI would say nothing useful. Once the first image lands this script starts +# doing what its name says. + +set -euo pipefail + +readonly ROOT="$(cd "$(dirname "$(realpath "$0")")/../.." && pwd)" +readonly BUILD="${ROOT}/build/fvp" + +command="${1:-test}" + +configure() { + local args=() + if [ -n "${ZX_FVP:-}" ]; then + args+=("-DZX_FVP_BASER_AEMV8R=${ZX_FVP}") + fi + cmake -S "${ROOT}" -B "${BUILD}" -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="${ROOT}/cmake/cortex_r52.cmake" \ + -DCMAKE_BUILD_TYPE=Debug \ + -DZX_BUILD_FVP_EXAMPLE=ON \ + -DZX_BUILD_S32Z280_EXAMPLE=OFF \ + "${args[@]}" +} + +# Read from the generated ninja graph rather than from a list kept in this +# script. Images are EXCLUDE_FROM_ALL, so a bare build reports "no work to +# do", and reading the graph means an image added to CMakeLists.txt cannot +# escape the run by nobody remembering to list it here. +images() { + ninja -C "${BUILD}" -t targets all 2>/dev/null \ + | grep -oE '^[A-Za-z0-9_]+\.elf' \ + | grep -v '^cmake_' | sort -u || true +} + +case "${command}" in + build|test) + configure + cmake --build "${BUILD}" + + image_list="$(images)" + if [ -z "${image_list}" ]; then + echo "ZoneX: no FVP images exist yet." + echo "ZoneX: the hypervisor libraries were built; nothing was executed." + exit 0 + fi + + # shellcheck disable=SC2086 + cmake --build "${BUILD}" --target ${image_list} + + if [ "${command}" = "test" ]; then + ctest --test-dir "${BUILD}" --output-on-failure + fi + ;; + *) + echo "Usage: $0 [build|test]" >&2 + exit 1 + ;; +esac diff --git a/test/host/CMakeLists.txt b/test/host/CMakeLists.txt new file mode 100644 index 0000000..69b32a4 --- /dev/null +++ b/test/host/CMakeLists.txt @@ -0,0 +1,45 @@ +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# The ZoneX host unit-test suite. +# +# What this suite is for, and what it deliberately is not: it covers the +# architecture-independent logic -- the manifest and its validator, the +# partition tables, the schedule arithmetic -- on a workstation, fast, with +# coverage. It does not and cannot cover stage-2 programming, the trap path, +# or isolation; those are only true on the FVP and on silicon, and they are +# tested there. See docs/decisions.md D11. + +option(ZX_COVERAGE "Instrument the host build for gcovr coverage reporting" OFF) + +if(ZX_COVERAGE) + # --coverage on both compile and link. Applied to zonex_core as well as + # the test binaries, because instrumenting only the tests measures the + # tests. + target_compile_options(zonex_core PRIVATE --coverage) + target_link_options(zonex_core PUBLIC --coverage) +endif() + +# One executable per suite, each self-contained through ZX_TEST_MAIN. Separate +# binaries rather than one linked from every test source, so that a test that +# crashes takes down its own suite and not the whole run. +function(zx_add_host_test name) + add_executable(${name} ${CMAKE_CURRENT_LIST_DIR}/${name}.c) + target_include_directories(${name} PRIVATE ${CMAKE_CURRENT_LIST_DIR}) + target_link_libraries(${name} PRIVATE zonex::core zonex::warnings) + add_test(NAME host-${name} COMMAND ${name}) +endfunction() + +zx_add_host_test(test_zx_api) diff --git a/test/host/run.sh b/test/host/run.sh new file mode 100755 index 0000000..9b25e21 --- /dev/null +++ b/test/host/run.sh @@ -0,0 +1,83 @@ +#!/bin/bash +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# Entry point for the ZoneX host unit-test target. +# +# run.sh build configure and build +# run.sh test build, then run through CTest +# run.sh coverage build instrumented, test, and write a gcovr report +# +# scripts/build_host.sh and scripts/test_host.sh are thin wrappers over this, +# following the convention every Eclipse ThreadX repository uses, so that a +# contributor moving between them does not have to relearn where the entry +# point is. + +set -euo pipefail + +readonly ROOT="$(cd "$(dirname "$(realpath "$0")")/../.." && pwd)" +readonly BUILD="${ROOT}/build/host" + +command="${1:-test}" + +coverage=OFF +if [ "${command}" = "coverage" ]; then + coverage=ON +fi + +configure() { + cmake -S "${ROOT}" -B "${BUILD}" -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="${ROOT}/cmake/host.cmake" \ + -DCMAKE_BUILD_TYPE=Debug \ + -DZX_BUILD_HOST_TESTS=ON \ + -DZX_COVERAGE="${coverage}" +} + +case "${command}" in + build) + configure + cmake --build "${BUILD}" + ;; + test) + configure + cmake --build "${BUILD}" + # --output-on-failure, because a host test reports what went wrong on + # its own stdout and CTest hides that by default. A summary line + # saying "1 test failed" and nothing else is not a bug report. + ctest --test-dir "${BUILD}" --output-on-failure + ;; + coverage) + configure + cmake --build "${BUILD}" + ctest --test-dir "${BUILD}" --output-on-failure + # Rooted at the repository so the report names files the way the + # repository does. gcovr writes its intermediate .gcov files into the + # directory it is rooted at, so two of these must never run + # concurrently from the same root -- they delete each other's output, + # and the symptom is a SanityCheckError naming a file that "doesn't + # exist but no error from GCOV detected". ThreadX paid for that one. + mkdir -p "${ROOT}/coverage_report" + gcovr --root "${ROOT}" \ + --filter "${ROOT}/core/" \ + --filter "${ROOT}/common/" \ + --html-details "${ROOT}/coverage_report/index.html" \ + --xml "${ROOT}/coverage_report/coverage.xml" \ + --print-summary + ;; + *) + echo "Usage: $0 [build|test|coverage]" >&2 + exit 1 + ;; +esac diff --git a/test/host/test_zx_api.c b/test/host/test_zx_api.c new file mode 100644 index 0000000..6ec2210 --- /dev/null +++ b/test/host/test_zx_api.c @@ -0,0 +1,91 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* test_zx_api.c HOST TESTS */ +/* */ +/* DESCRIPTION */ +/* */ +/* Covers what the repository foundation actually delivers: the */ +/* contents of common/inc/zx_api.h. */ +/* */ +/* Most of what matters about that header is checked at compile time */ +/* rather than here -- a capability macro that expands to nothing */ +/* usable, or a zx_addr_t of the wrong width, fails the build. This */ +/* file exists for the parts that do not: that the macros can be */ +/* APPLIED to a real declaration, and that the granule constant means */ +/* what the rest of ZoneX will assume it means. */ +/* */ +/* The suite grows when the manifest validator and the first */ +/* substantial body of host-testable logic arrive. */ +/* */ +/**************************************************************************/ + +#include "zx_api.h" +#include "zx_test.h" + +/* The capability macros are exercised by being used, not by being inspected. + ZX_NODISCARD in particular expands to a compiler attribute whose placement + is only validated when it sits on a real declaration; a version of the block + that compiled but produced an attribute the compiler silently ignored would + pass any test that only checked the macro was defined. */ +ZX_NODISCARD static zx_addr_t zx_test_align_down_to_granule(zx_addr_t address); + +static zx_addr_t zx_test_align_down_to_granule(zx_addr_t address) +{ + return address & ~(zx_addr_t)(ZX_MPU_GRANULE - 1U); +} + +static void zx_test_unused_is_accepted(ZX_MAYBE_UNUSED int ignored) +{ +} + +ZX_TEST_MAIN("zx_api", +{ + /* The 64-byte granule is not a tunable. PMSAv8-R lays HPRBAR out as + BASE[31:6] with SH, AP and XN in the low bits, so a base that is not a + multiple of 64 does not fault -- it silently rewrites the region's + attributes. If this constant is ever not 64, every mask in the port is + wrong. */ + ZX_CHECK_EQ(ZX_MPU_GRANULE, 64U); + ZX_CHECK_EQ(ZX_MPU_GRANULE & (ZX_MPU_GRANULE - 1U), 0U); + + /* Alignment behaves as the region programming will need it to: an aligned + base is left alone, and an under-aligned one is brought down rather + than up. Down, because a region must not start before the memory the + manifest declared. */ + ZX_CHECK_EQ(zx_test_align_down_to_granule((zx_addr_t)0x80000000U), + (zx_addr_t)0x80000000U); + ZX_CHECK_EQ(zx_test_align_down_to_granule((zx_addr_t)0x8000003FU), + (zx_addr_t)0x80000000U); + ZX_CHECK_EQ(zx_test_align_down_to_granule((zx_addr_t)0x80000040U), + (zx_addr_t)0x80000040U); + + /* zx_addr_t and zx_size_t must be able to express a limit computed as + base + size - 1 without wrapping for any region the manifest can + describe. The static assertions in zx_api.h cover the widths; this + covers the arithmetic actually being unsigned. */ + ZX_CHECK((zx_addr_t)0U - (zx_addr_t)1U > (zx_addr_t)0U); + + zx_test_unused_is_accepted(0); + + /* The version macros exist and are ordered as a pre-release: Phase 0 is + deliberately 0.x, and something that bumped the major without meaning + to should be visible here rather than only in a release note. */ + ZX_CHECK_EQ(ZX_MAJOR_VERSION, 0); +}) diff --git a/test/host/zx_test.h b/test/host/zx_test.h new file mode 100644 index 0000000..7fadc40 --- /dev/null +++ b/test/host/zx_test.h @@ -0,0 +1,82 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_test.h HOST TESTS */ +/* */ +/* DESCRIPTION */ +/* */ +/* The whole of the ZoneX host unit-test harness. */ +/* */ +/* It is this small on purpose. External dependencies are forbidden */ +/* by project policy, so a third-party framework is not an option, */ +/* and a hand-rolled one large enough to need testing itself would be */ +/* a liability in a repository whose point is a certifiable */ +/* hypervisor. What is here is what CTest actually needs: a way to */ +/* check a condition, a count of failures, and a process exit status */ +/* that is non-zero when any check failed. */ +/* */ +/* A test binary that crashes or exits before printing its summary is */ +/* a failure, never a pass -- the summary line is what the run is */ +/* judged on, the same rule the FVP runner follows on the target side. */ +/* */ +/**************************************************************************/ + +#ifndef ZX_TEST_H +#define ZX_TEST_H + +#include + +/* Not static: the checking macros below update it from any translation unit + linked into a test binary, and ZX_TEST_MAIN defines it exactly once. */ +extern unsigned int zx_test_failures; +extern unsigned int zx_test_checks; + +#define ZX_CHECK(condition) \ + do { \ + zx_test_checks++; \ + if (!(condition)) { \ + zx_test_failures++; \ + (void)printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #condition); \ + } \ + } while (0) + +#define ZX_CHECK_EQ(actual, expected) \ + do { \ + zx_test_checks++; \ + if ((actual) != (expected)) { \ + zx_test_failures++; \ + (void)printf("FAIL %s:%d: %s == %s\n", \ + __FILE__, __LINE__, #actual, #expected); \ + } \ + } while (0) + +/* Defines the counters and a main() that reports and returns a status CTest + can read. Exactly one translation unit per test binary uses it. */ +#define ZX_TEST_MAIN(suite_name, body) \ + unsigned int zx_test_failures = 0U; \ + unsigned int zx_test_checks = 0U; \ + int main(void) \ + { \ + body \ + (void)printf("%s: %u checks, %u failures\n", \ + (suite_name), zx_test_checks, zx_test_failures); \ + return (zx_test_failures == 0U) ? 0 : 1; \ + } + +#endif /* ZX_TEST_H */ diff --git a/test/s32z280/run.sh b/test/s32z280/run.sh new file mode 100755 index 0000000..012b39e --- /dev/null +++ b/test/s32z280/run.sh @@ -0,0 +1,81 @@ +#!/bin/bash +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# Entry point for the ZoneX NXP S32Z280-594EVB target. +# +# run.sh build cross-configure and build every image +# run.sh test build, then run on the board through the gdb harness +# +# "test" needs silicon. A hosted CI runner has none, so this target is +# build-only in CI and "test" is a bench activity. Two things that cost time +# on this board and are worth reading before running anything on it: +# +# * On silicon the Windows-side CCS must be the process listening on 41475. +# ss inside WSL2 cannot see it, so "nothing is listening" from inside WSL +# is not evidence that nothing is. +# +# * Never pipe a run through tail or head. Redirect to a file and read the +# file; the pipe truncates exactly the part that matters when a run hangs. +# +# STATUS: there are no images yet. See test/fvp/run.sh for the same note. + +set -euo pipefail + +readonly ROOT="$(cd "$(dirname "$(realpath "$0")")/../.." && pwd)" +readonly BUILD="${ROOT}/build/s32z280" + +command="${1:-build}" + +configure() { + cmake -S "${ROOT}" -B "${BUILD}" -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="${ROOT}/cmake/cortex_r52.cmake" \ + -DCMAKE_BUILD_TYPE=Debug \ + -DZX_BUILD_FVP_EXAMPLE=OFF \ + -DZX_BUILD_S32Z280_EXAMPLE=ON +} + +images() { + ninja -C "${BUILD}" -t targets all 2>/dev/null \ + | grep -oE '^[A-Za-z0-9_]+\.elf' \ + | grep -v '^cmake_' | sort -u || true +} + +case "${command}" in + build|test) + configure + cmake --build "${BUILD}" + + image_list="$(images)" + if [ -z "${image_list}" ]; then + echo "ZoneX: no S32Z280 images exist yet." + echo "ZoneX: the hypervisor libraries were built; nothing was flashed." + exit 0 + fi + + # shellcheck disable=SC2086 + cmake --build "${BUILD}" --target ${image_list} + + if [ "${command}" = "test" ]; then + echo "ZoneX: running on the S32Z280 needs the board and the gdb harness," >&2 + echo "ZoneX: which arrives with the first image." >&2 + exit 1 + fi + ;; + *) + echo "Usage: $0 [build|test]" >&2 + exit 1 + ;; +esac From 9a3e3babd82ff8e45087785d9844071f76c52975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 2 Sep 2026 10:31:30 -0400 Subject: [PATCH 02/40] Brought stage 2 to first light on the FVP and on S32Z280 silicon An EL2-resident ZoneX now programs stage-2 MPU regions, drops to EL1, and a stage-2 violation by the EL1 payload is taken to EL2, decoded and reported by name -- on the Armv8-R AEM FVP and on S32Z280-594EVB silicon. Nothing else in Phase 0 was worth building until that worked, because everything else assumes it. There is still no ThreadX guest, no partition manifest and no scheduler. The image is one EL2 program, one trivial EL1 payload and one deliberate fault, shared between both targets so that the same program answers the same questions on a model and on a part -- which turned out to matter more than expected. WHAT IT SETTLED HPRENR really is wider than 16 bits. The TRM's prose says regions 0 to 15 and its own bit tables say [19:0] for 20 regions; the tables are right. The S32Z280 implements exactly [19:0], and -- proven functionally rather than by reading the register back -- a bit above 15 genuinely disables its region: an EL1 read of the granule region 16 covers succeeds with the bit set and takes a stage-2 fault with it clear. A register that accepted the bit and ignored it would have passed a read-back test and made a one-write partition switch silently leave the outgoing partition's regions live. Regions above 15 are directly addressable at opc1 = 5, confirmed on both parts by programming region 16 directly and reading it back through the selection register. So the EL2 MPU avoids the 542-604 cycle penalty measured at EL1 during the Cortex-R52 Modules port work for reaching a high region. HPFAR does not mean the same thing on the two targets. The TRM describes it two ways in one section -- FIPA[39:12] at HPFAR[31:4] in its figure, "bits [31:4] of the faulting address" in the table beside it -- and the FVP implements the first while the S32Z280 implements the second. The two differ by a factor of 256 and each is plausible alone, so there is no portable decode of HPFAR here. ZoneX uses HDFAR, which carries the full faulting address on both. The fault report prints HPFAR raw, computes both readings and names which one the target implements, because the divergence is a fact the next person needs. The FVP implements an EL2 MPU and reports 32 regions at both stages, which is not an architecturally legal Cortex-R52 value -- so a green model run still proves nothing about a real part's budget. The image says so in its own output. THE ONE THAT CHANGED THE DESIGN HSCTLR.BR grants permission, not attributes, and on the S32Z280 that bites before the hypervisor can print anything. Reached through the background map, LINFlexD_9 at 0x42980000 is Normal Write-Through memory, and Normal memory reorders and gathers even with caches off -- which corrupts a polled UART's register protocol. The first version printed its whole identity block as legible-but-wrong text, looking exactly like a marginal baud rate. So enabling protection is two steps, not one: the hypervisor's own MMIO regions and HSCTLR.M come up before the console, and HCR.VM only after the partition regions exist. zx_stage2_enable refuses to set HCR.VM while HSCTLR.M is clear, because that combination would run a guest unprotected while every check appeared to pass. HOW A FAULT COMES BACK zx_el2_run_payload saves EL2's context, ERETs to EL1, and does not return through that ERET; the trap handler resumes the saved context with a result code, so a guest fault reaches the hypervisor as a value rather than as a jump into a handler with no context to decide policy in. That is also what lets one run demonstrate several violations instead of only the last one. A fault taken FROM Hyp mode is the exception and reports differently: its own vector, EC 0x25 rather than 0x24, its own message and its own exit code. It has been provoked deliberately on both targets, because a hypervisor bug reported as a partition being stopped at its boundary is a run that passes while proving nothing. EVIDENCE THAT IT CAN FAIL Three builds of the same image, two of which must fail and are registered WILL_FAIL in CTest: the deliberate violation aimed at an address the payload IS granted, and the image told it needs more regions than exist. Both have been seen to fail on both targets, naming what went wrong. The deliberate-EL2- fault build is not registered, because its expected outcome is a failure report and a suite taught to accept that would accept it everywhere. The host suite covers the fault decoder to 100% of its lines, asserting on real syndromes captured from both targets -- including the HPFAR divergence, so a later simplification back to one reading breaks the build. Also fixed along the way: the two example options are now mutually exclusive, because the board's reset state and console are properties of the port library and a PRIVATE definition on an image never reached it -- which had produced an S32Z280 image with an A32 entry point and a semihosting console that built, linked and would have printed nothing. The gcc and clang lanes now build the images rather than only the libraries, since images are EXCLUDE_FROM_ALL and a compile error in one was slipping past both. Assisted-by: Claude Code (Opus 5) --- .github/workflows/clang_check.yml | 24 + .github/workflows/gcc_check.yml | 24 + .github/workflows/zx_fvp.yml | 37 +- CMakeLists.txt | 69 +- README.md | 31 +- common/inc/zx_api.h | 21 + common/inc/zx_console.h | 110 ++ core/CMakeLists.txt | 1 + core/inc/zx_fault.h | 268 ++++- core/src/zx_fault.c | 498 ++++++++- docs/armv8r-el2-reference.md | 316 ++++-- docs/decisions.md | 210 +++- examples/common/zx_payload.S | 295 ++++++ examples/common/zx_probe.h | 139 +++ examples/common/zx_probe_main.c | 1004 +++++++++++++++++++ examples/fvp_baser_aemv8r/CMakeLists.txt | 169 +++- examples/fvp_baser_aemv8r/zx_board.c | 101 ++ examples/fvp_baser_aemv8r/zx_link.lds | 198 ++++ examples/fvp_baser_aemv8r/zx_platform.h | 81 ++ examples/s32z280_evb/CMakeLists.txt | 87 +- examples/s32z280_evb/tools/run_zx_probe.gdb | 309 ++++++ examples/s32z280_evb/tools/run_zx_probe.sh | 162 +++ examples/s32z280_evb/zx_board.c | 345 +++++++ examples/s32z280_evb/zx_link.lds | 206 ++++ examples/s32z280_evb/zx_platform.h | 120 +++ platform/cortex_r52/CMakeLists.txt | 17 + platform/cortex_r52/inc/zx_port.h | 478 ++++++++- platform/cortex_r52/src/zx_console.c | 218 +++- platform/cortex_r52/src/zx_el2_cpu.c | 265 +++++ platform/cortex_r52/src/zx_el2_entry.S | 304 +++++- platform/cortex_r52/src/zx_el2_fault_path.c | 108 ++ platform/cortex_r52/src/zx_stage2_mpu.c | 448 ++++++++- platform/cortex_r52/src/zx_trap_handler.S | 325 +++++- test/fvp/run.sh | 25 +- test/fvp/run_fvp_test.py | 117 +++ test/host/CMakeLists.txt | 10 +- test/host/test_zx_fault.c | 482 +++++++++ test/host/zx_console_capture.c | 160 +++ test/host/zx_console_capture.h | 61 ++ test/host/zx_test.h | 13 +- test/s32z280/run.sh | 23 +- 41 files changed, 7688 insertions(+), 191 deletions(-) create mode 100644 common/inc/zx_console.h create mode 100644 examples/common/zx_payload.S create mode 100644 examples/common/zx_probe.h create mode 100644 examples/common/zx_probe_main.c create mode 100644 examples/fvp_baser_aemv8r/zx_board.c create mode 100644 examples/fvp_baser_aemv8r/zx_link.lds create mode 100644 examples/fvp_baser_aemv8r/zx_platform.h create mode 100644 examples/s32z280_evb/tools/run_zx_probe.gdb create mode 100755 examples/s32z280_evb/tools/run_zx_probe.sh create mode 100644 examples/s32z280_evb/zx_board.c create mode 100644 examples/s32z280_evb/zx_link.lds create mode 100644 examples/s32z280_evb/zx_platform.h create mode 100644 platform/cortex_r52/src/zx_el2_cpu.c create mode 100644 platform/cortex_r52/src/zx_el2_fault_path.c create mode 100755 test/fvp/run_fvp_test.py create mode 100644 test/host/test_zx_fault.c create mode 100644 test/host/zx_console_capture.c create mode 100644 test/host/zx_console_capture.h diff --git a/.github/workflows/clang_check.yml b/.github/workflows/clang_check.yml index 1f46c6d..bc71eb3 100644 --- a/.github/workflows/clang_check.yml +++ b/.github/workflows/clang_check.yml @@ -124,6 +124,30 @@ jobs: if ! cmake --build "build/clang-$name"; then echo "::error::$name: build failed" status=1 + return + fi + + # The IMAGES as well. They are EXCLUDE_FROM_ALL, as ThreadX's are, + # so the build above reports "no work to do" for every one of them + # and a compile error in an image would slip past this whole lane. + # + # Named from the generated ninja graph rather than from a list kept + # here, so an image added to a CMakeLists.txt cannot escape the + # check by nobody remembering to list it. + local images + images="$(ninja -C "build/clang-$name" -t targets all 2>/dev/null \ + | grep -oE '^[A-Za-z0-9_]+\.elf' \ + | grep -v '^cmake_' | sort -u || true)" + if [ -z "$images" ]; then + echo "::error::$name: the CMake graph produced no images at all" + status=1 + return + fi + echo "$name: building $(echo "$images" | wc -l) image(s)" + # shellcheck disable=SC2086 + if ! cmake --build "build/clang-$name" --target $images; then + echo "::error::$name: an image failed to build" + status=1 fi } diff --git a/.github/workflows/gcc_check.yml b/.github/workflows/gcc_check.yml index e629d7a..8ed4627 100644 --- a/.github/workflows/gcc_check.yml +++ b/.github/workflows/gcc_check.yml @@ -141,6 +141,30 @@ jobs: if ! cmake --build "build/$name"; then echo "::error::$name: build failed" status=1 + return + fi + + # The IMAGES as well. They are EXCLUDE_FROM_ALL, as ThreadX's are, + # so the build above reports "no work to do" for every one of them + # and a compile error in an image would slip past this whole lane. + # + # Named from the generated ninja graph rather than from a list kept + # here, so an image added to a CMakeLists.txt cannot escape the + # check by nobody remembering to list it. + local images + images="$(ninja -C "build/$name" -t targets all 2>/dev/null \ + | grep -oE '^[A-Za-z0-9_]+\.elf' \ + | grep -v '^cmake_' | sort -u || true)" + if [ -z "$images" ]; then + echo "::error::$name: the CMake graph produced no images at all" + status=1 + return + fi + echo "$name: building $(echo "$images" | wc -l) image(s)" + # shellcheck disable=SC2086 + if ! cmake --build "build/$name" --target $images; then + echo "::error::$name: an image failed to build" + status=1 fi } diff --git a/.github/workflows/zx_fvp.yml b/.github/workflows/zx_fvp.yml index 6fe660d..16ef256 100644 --- a/.github/workflows/zx_fvp.yml +++ b/.github/workflows/zx_fvp.yml @@ -30,14 +30,19 @@ name: zx_fvp # repository-variable arrangement for the model, because Arm has no stable # unauthenticated download URL for it. # -# STATUS: the repository contains no images at all yet. The workflow builds the -# hypervisor libraries and says loudly that it executed nothing. Once the first -# image lands this workflow starts doing what its name says. The "no images" -# path is a warning rather than a failure ON PURPOSE and only while that is -# true -- see the comment at the check itself. +# WHAT IT RUNS. zx_probe.elf, which must PASS, plus two builds of the same +# image that must FAIL -- one whose deliberate violation is aimed at an address +# the payload is granted, and one told it needs more MPU regions than exist. +# Both are registered WILL_FAIL, because a check that has never been seen to +# fail is not evidence that it can. CTest runs all three. +# +# zx_probe_el2_fault.elf is deliberately NOT registered: its expected outcome +# is a hypervisor-fault report, and a suite taught to accept that as a pass +# would accept it everywhere. It is run by hand. # # The S32Z280 targets are not run here and never will be. They need silicon; -# gcc_check.yml builds them. +# gcc_check.yml builds them, and +# examples/s32z280_evb/tools/run_zx_probe.sh runs them on a bench. on: push: @@ -239,20 +244,20 @@ jobs: | grep -oE '^[A-Za-z0-9_]+\.elf' \ | grep -v '^cmake_' | sort -u || true)" - # A warning, not a failure -- and ONLY while the repository genuinely - # has no images, which is true only until the first one lands. At that - # moment, change this to "::error::" and a non-zero exit: from then on, - # an empty image list means the CMake graph broke, and a workflow that - # shrugs at that is the hole this file exists to close. + # An ERROR, since the EL2 bring-up work landed. This was a warning + # while the repository genuinely had no images; from the moment the + # first one exists, an empty list means the CMake graph broke, and a + # workflow that shrugs at that is the hole this file exists to close. if [ -z "$images" ]; then - echo "::warning::ZoneX has no FVP images yet. Nothing was executed." + echo "::error::No FVP images were found in the CMake graph. ZoneX has images, so this means the graph broke." { - echo "### ZoneX FVP execution: NOTHING TO RUN" + echo "### ZoneX FVP execution: NO IMAGES FOUND" echo "" - echo "The hypervisor libraries built, but the repository contains no images yet." - echo "The first one lands with the EL2 bring-up work." + echo "The hypervisor libraries built, but the ninja graph named no \`*.elf\` targets." + echo "ZoneX does have FVP images, so this is a build-system failure rather than" + echo "a repository that has not got there yet." } >>"$GITHUB_STEP_SUMMARY" - exit 0 + exit 1 fi echo "Building $(echo "$images" | wc -l) images" diff --git a/CMakeLists.txt b/CMakeLists.txt index 4b6a3b0..dce6824 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -74,12 +74,16 @@ include(${CMAKE_CURRENT_LIST_DIR}/cmake/zx_warnings.cmake) # Defaults follow the toolchain, because the alternative -- a fixed default -- # means every invocation of every script has to override one of them. +# +# ONE BOARD PER BUILD TREE. The two example options are MUTUALLY EXCLUSIVE +# and the check below enforces it, so the S32Z280 default is OFF rather than +# following the toolchain the way the FVP's does. if(ZX_ARCH STREQUAL "host") set(ZX_HOST_TESTS_DEFAULT ON) - set(ZX_TARGET_EXAMPLES_DEFAULT OFF) + set(ZX_FVP_EXAMPLE_DEFAULT OFF) else() set(ZX_HOST_TESTS_DEFAULT OFF) - set(ZX_TARGET_EXAMPLES_DEFAULT ON) + set(ZX_FVP_EXAMPLE_DEFAULT ON) endif() option(ZX_BUILD_HOST_TESTS @@ -87,10 +91,65 @@ option(ZX_BUILD_HOST_TESTS ${ZX_HOST_TESTS_DEFAULT}) option(ZX_BUILD_FVP_EXAMPLE "Build the Armv8-R AEM FVP example under examples/fvp_baser_aemv8r" - ${ZX_TARGET_EXAMPLES_DEFAULT}) + ${ZX_FVP_EXAMPLE_DEFAULT}) option(ZX_BUILD_S32Z280_EXAMPLE - "Build the NXP S32Z280-594EVB example under examples/s32z280_evb" - ${ZX_TARGET_EXAMPLES_DEFAULT}) + "Build the NXP S32Z280-594EVB example under examples/s32z280_evb. \ +Mutually exclusive with ZX_BUILD_FVP_EXAMPLE; see the check below." + OFF) + +############################################################################### +# Which board the PORT is built for +############################################################################### + +# The port library is compiled once per build tree, and two facts about it are +# board facts rather than target facts: +# +# ZX_RESET_IN_THUMB whether the core arrives at _start in T32 state. The +# S32Z280 does, because the RTU boot instruction NXP +# plants is a T32 branch; the Armv8-R AEM FVP does not. +# Get this wrong and _start executes the first halfword +# of its own instruction in the other state and goes +# anywhere -- with no output at all. +# +# ZX_CONSOLE_BOARD whether the console is the board's UART or +# semihosting. There is no semihosting on the S32Z280, +# and a semihosting trap there is not a no-op. +# +# Both therefore have to reach platform/cortex_r52/src/*, which +# target_compile_definitions on an EXECUTABLE cannot do: zonex_port is a +# separate static library and a PRIVATE definition on an image never touches +# it. That mistake produces an S32Z280 image with an A32 entry point and a +# semihosting console, which builds and links cleanly and prints nothing. +# +# So the two boards cannot share a build tree, and this refuses rather than +# silently building one of them wrong. Every script and workflow in the +# repository already configures exactly one; the check exists for the +# invocation that does not. +if(ZX_BUILD_FVP_EXAMPLE AND ZX_BUILD_S32Z280_EXAMPLE) + message(FATAL_ERROR + "ZX_BUILD_FVP_EXAMPLE and ZX_BUILD_S32Z280_EXAMPLE are mutually " + "exclusive.\n" + "The two boards need the port library compiled differently -- the " + "S32Z280 resets in T32 state and has no semihosting -- so one build " + "tree can serve only one of them.\n" + "Configure two build trees, or use test/fvp/run.sh and " + "test/s32z280/run.sh, which already do.") +endif() + +set(ZX_PORT_BOARD_DEFINITIONS "") +if(ZX_BUILD_S32Z280_EXAMPLE) + list(APPEND ZX_PORT_BOARD_DEFINITIONS ZX_RESET_IN_THUMB ZX_CONSOLE_BOARD) + message(STATUS "ZoneX board: NXP S32Z280-594EVB " + "(T32 reset, LINFlexD_9 console)") +elseif(ZX_BUILD_FVP_EXAMPLE) + message(STATUS "ZoneX board: Armv8-R AEM FVP " + "(A32 reset, semihosting console)") +elseif(NOT ZX_ARCH STREQUAL "host") + # A cross build with no example is the libraries on their own, which is + # what gcc_check.yml's plain lane does. The port then compiles for the + # FVP's defaults; nothing links it, so nothing depends on that choice. + message(STATUS "ZoneX board: none selected; building the libraries only") +endif() # The seam to a ThreadX checkout. ZoneX itself does not link ThreadX -- at EL2 # it is a standalone bare-metal program (docs/decisions.md D9) -- but the diff --git a/README.md b/README.md index e328cdb..44dd3ca 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,32 @@ be reported rather than a condition to be recovered from. ## Status: Phase 0, under construction -**There is no working hypervisor in this repository yet.** What is here is the -repository foundation: the build system, the C17 baseline, the verified Armv8-R -EL2 register reference, the recorded design decisions, and the CI seam. The -translation units exist and compile; they are deliberately empty of -implementation. +**There is no complete hypervisor in this repository yet — but stage 2 is +alive.** ZoneX now boots at EL2 on both targets, programs stage-2 MPU regions, +drops to EL1, and takes, decodes and reports a stage-2 access violation by +name. There is no ThreadX guest, no partition manifest and no scheduler yet; +each of those assumes what the current image exists to establish. + +`examples/` holds the stage-2 probe: one EL2 program, one trivial EL1 payload +and one deliberate fault. Run it with `scripts/test_fvp.sh` on the Armv8-R AEM +FVP, or `examples/s32z280_evb/tools/run_zx_probe.sh` on the board. + +The suite includes builds that must **fail**, registered as such: one whose +deliberate violation is aimed at an address the payload *is* granted, and one +told it needs more MPU regions than exist. A check that has never been seen to +fail is not evidence that it can. + +Along the way it settled four things about this architecture that the +Cortex-R52 TRM describes ambiguously or contradicts itself about, on both a +model and real silicon. The two most consequential: + +* **`HPRENR` really is wider than 16 bits**, and a bit above 15 really does + disable its region — proven functionally, not by reading the register back. +* **`HPFAR` does not mean the same thing on the two targets.** The TRM + describes it two ways in one section, and the FVP and the S32Z280 each + implement a different one. ZoneX uses `HDFAR`. + +`docs/armv8r-el2-reference.md` carries all of it, with the measured values. Phase 0 is a partitioning demonstrator with a deliberately small scope: diff --git a/common/inc/zx_api.h b/common/inc/zx_api.h index 5abd317..fd57f8a 100644 --- a/common/inc/zx_api.h +++ b/common/inc/zx_api.h @@ -12,6 +12,7 @@ * * SPDX-License-Identifier: MIT and CC0-1.0 **************************************************************************/ +// Some portions generated by Claude Code (Opus 5). /**************************************************************************/ /* */ @@ -49,6 +50,18 @@ extern "C" { and under those a C17 branch spelling [[nodiscard]] is rejected outright: "ISO C does not support [[ attributes before C23". + ZX_NORETURN is the same question with a different answer: C17 does have a + standard spelling, the _Noreturn keyword from C11, and C23 deprecates it in + favour of the [[noreturn]] attribute. So the C17 branch here is a standard + construct rather than a GNU fallback, and it is still reached through the + macro for the same reason as the others. + + ZX_NOINLINE has no standard spelling in any C, which is why it is here + rather than written out: it exists so that a function whose only purpose is + to BE a symbol -- a place for a hardware breakpoint to land, on a target + with no other way to report -- cannot be optimised into its caller and + leave the debugger with nothing to break on. + The macros are the contract, not their spelling. Always write ZX_NODISCARD; never [[nodiscard]] directly. That way moving ZoneX to C23 is a change to this one block rather than churn across every header. */ @@ -56,18 +69,26 @@ extern "C" { # define ZX_NODISCARD [[nodiscard]] # define ZX_DEPRECATED(msg) [[deprecated(msg)]] # define ZX_MAYBE_UNUSED [[maybe_unused]] +# define ZX_NORETURN [[noreturn]] +# define ZX_NOINLINE __attribute__((noinline)) #elif defined(__GNUC__) || defined(__clang__) # define ZX_NODISCARD __attribute__((warn_unused_result)) # define ZX_DEPRECATED(msg) __attribute__((deprecated(msg))) # define ZX_MAYBE_UNUSED __attribute__((unused)) +# define ZX_NORETURN _Noreturn +# define ZX_NOINLINE __attribute__((noinline)) #elif defined(_MSC_VER) # define ZX_NODISCARD _Check_return_ # define ZX_DEPRECATED(msg) __declspec(deprecated(msg)) # define ZX_MAYBE_UNUSED +# define ZX_NORETURN _Noreturn +# define ZX_NOINLINE __declspec(noinline) #else # define ZX_NODISCARD # define ZX_DEPRECATED(msg) # define ZX_MAYBE_UNUSED +# define ZX_NORETURN +# define ZX_NOINLINE #endif /* stdint.h and stddef.h are part of the freestanding C library, which C17 diff --git a/common/inc/zx_console.h b/common/inc/zx_console.h new file mode 100644 index 0000000..f3ffdb2 --- /dev/null +++ b/common/inc/zx_console.h @@ -0,0 +1,110 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_console.h PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* The hypervisor's own console, as an interface rather than a */ +/* driver. It is declared in common/ rather than in the port because */ +/* core/ needs it: a fault record that cannot be printed is not a */ +/* fault report, and the code that formats one is architecture- */ +/* independent. See docs/decisions.md D8. */ +/* */ +/* Three implementations exist, and which one is linked is a build */ +/* decision, never a run-time one: */ +/* */ +/* * semihosting, on the Armv8-R AEM FVP -- no peripheral, so it */ +/* cannot be broken by a wrong memory map, which is what makes it */ +/* usable before the MPU is trusted; */ +/* * a board UART, on silicon, reached through the zx_board_console_ */ +/* hooks below; */ +/* * a capture buffer, in the host unit tests, which is what lets */ +/* the fault report's text be asserted on rather than eyeballed. */ +/* */ +/* Everything here is polled and re-entrant enough to be called from */ +/* a fault handler, because that is where it is needed most. */ +/* */ +/**************************************************************************/ + +#ifndef ZX_CONSOLE_H +#define ZX_CONSOLE_H + +#include "zx_api.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* Write a NUL-terminated string. A null pointer writes nothing rather than + faulting: this is called from the fault handler, and a console that can + itself fault turns one diagnosable problem into none. */ + +void zx_console_puts(const char *string_ptr); + +/* Write value as 0x-prefixed, zero-padded 32-bit hexadecimal. Every register + ZoneX reports is 32 bits wide on this target and is reported at full width, + so that two values can be compared by eye without counting digits. */ + +void zx_console_puthex(uint32_t value); + +/* Write value in decimal, no padding. Used for counts -- region numbers, + fault counts -- where hexadecimal would be harder to read, not easier. */ + +void zx_console_putdec(uint32_t value); + +/* Terminate the run. On the FVP this is the semihosting SYS_EXIT that stops + the model; on silicon there is nothing to stop, so it parks in a loop at a + symbol the gdb harness breaks on. Either way the PASS or FAIL verdict has + already been PRINTED: the AArch32 SYS_EXIT parameter is a reason code and + not an exit status, so the console text is what a run is judged on. */ + +ZX_NORETURN void zx_console_exit(uint32_t failures); + +/* Does nothing, and is the point. A target with no semihosting has nothing + to stop, so a run there ends by parking -- and the only way to read the + result is a debugger. A breakpoint on zx_console_exit lands BEFORE it + stores the verdict, so the harness would read whatever the variable held + beforehand and report that the run never finished, on a run that finished + perfectly. Measured, on the S32Z280, against a console that said ALL + CHECKS PASSED. + + This is called after every global is settled, so it is the symbol a + hardware breakpoint should be placed on. ZX_NOINLINE because a function + that got inlined would leave the debugger with no symbol to break on and + the same silent misreport. */ + +ZX_NOINLINE void zx_console_run_parked(void); + +/* The board's own console, when one is linked instead of semihosting. + Declared here rather than in the port because the port is the Armv8-R + architecture and a LINFlexD is not part of it -- the board example provides + these two functions, and platform/cortex_r52/src/zx_console.c calls them + when ZX_CONSOLE_BOARD is defined. */ + +#ifdef ZX_CONSOLE_BOARD +void zx_board_console_init(void); +void zx_board_console_putc(char character); +#endif + +#ifdef __cplusplus +} +#endif + +#endif /* ZX_CONSOLE_H */ diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index 68f2006..6768f0c 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -36,6 +36,7 @@ target_sources(zonex_core PUBLIC BASE_DIRS ${CMAKE_CURRENT_LIST_DIR}/../common/inc ${CMAKE_CURRENT_LIST_DIR}/inc FILES ${CMAKE_CURRENT_LIST_DIR}/../common/inc/zx_api.h + ${CMAKE_CURRENT_LIST_DIR}/../common/inc/zx_console.h ${CMAKE_CURRENT_LIST_DIR}/inc/zx_fault.h ${CMAKE_CURRENT_LIST_DIR}/inc/zx_manifest.h ${CMAKE_CURRENT_LIST_DIR}/inc/zx_mm.h diff --git a/core/inc/zx_fault.h b/core/inc/zx_fault.h index ccaf347..cac2556 100644 --- a/core/inc/zx_fault.h +++ b/core/inc/zx_fault.h @@ -12,6 +12,7 @@ * * SPDX-License-Identifier: MIT and CC0-1.0 **************************************************************************/ +// Some portions generated by Claude Code (Opus 5). /**************************************************************************/ /* */ @@ -33,11 +34,14 @@ /* Phase-0 demonstrator exists to show; EC 0x25 is a data abort TAKEN */ /* FROM Hyp, which is a ZoneX bug. */ /* */ -/* STATUS */ +/* Nothing here touches a register. The port reads HSR, HPFAR, HDFAR, */ +/* HIFAR, ELR_hyp and SPSR_hyp into a zx_fault_record_t and hands it */ +/* over; everything below is arithmetic on those words, which is what */ +/* lets the host suite exercise the decode without a model or a board. */ /* */ -/* Declared empty. The first stage-2 fault is decoded by the EL2 */ -/* bring-up work. The EC values are tabulated in */ -/* docs/armv8r-el2-reference.md. */ +/* Encodings are from docs/armv8r-el2-reference.md, which is the */ +/* authority. Table 3-88 of the Cortex-R52 TRM (100026_0103_00_en) is */ +/* the source for the exception classes. */ /* */ /**************************************************************************/ @@ -50,6 +54,262 @@ extern "C" { #endif +/**************************************************************************/ +/* HSR layout and field access */ +/**************************************************************************/ + +#define ZX_HSR_EC_SHIFT 26U +#define ZX_HSR_EC_MASK 0x3FU +#define ZX_HSR_IL_MASK 0x02000000U +#define ZX_HSR_ISS_MASK 0x01FFFFFFU + +/* Exception classes, TRM Table 3-88. The whole table is spelled out, not + just the classes Phase 0 handles: an unexpected trap must be reported by + NAME, and a numeric EC in a log is a value somebody then has to look up + while the board is still on the bench. */ + +#define ZX_EC_UNKNOWN 0x00U /* unknown reason */ +#define ZX_EC_WFI_WFE 0x01U /* trapped WFI or WFE */ +#define ZX_EC_MCR_MRC_CP15 0x03U /* trapped MCR/MRC, coproc 15 */ +#define ZX_EC_MCRR_MRRC_CP15 0x04U /* trapped MCRR/MRRC, coproc 15 */ +#define ZX_EC_MCR_MRC_CP14 0x05U /* trapped MCR/MRC, coproc 14 */ +#define ZX_EC_LDC_STC_CP14 0x06U /* trapped LDC/STC, coproc 14 */ +#define ZX_EC_SIMD_FP 0x07U /* SIMD/FP access, from HCPTR */ +#define ZX_EC_VMRS 0x08U /* trapped VMRS, ID group trap */ +#define ZX_EC_MCRR_MRRC_CP14 0x0CU /* trapped MCRR/MRRC, coproc 14 */ +#define ZX_EC_ILLEGAL_STATE 0x0EU /* illegal state exception */ +#define ZX_EC_SVC 0x11U /* SVC taken to Hyp mode */ +#define ZX_EC_HVC 0x12U /* HVC executed */ +#define ZX_EC_PABT_ROUTED 0x20U /* prefetch abort ROUTED to Hyp */ +#define ZX_EC_PABT_FROM_HYP 0x21U /* prefetch abort FROM Hyp */ +#define ZX_EC_PC_ALIGNMENT 0x22U /* PC alignment fault */ +#define ZX_EC_DABT_ROUTED 0x24U /* data abort ROUTED to Hyp */ +#define ZX_EC_DABT_FROM_HYP 0x25U /* data abort FROM Hyp */ + +/* Data-abort ISS fields. The TRM defers the ISS layout to the Armv8-R + architecture supplement, which was not available when this was written, so + these are the AArch32 data-abort ISS positions common to Armv8 and they are + MEASURED rather than trusted: zx_fault_report prints the raw ISS beside the + decoded fields for exactly that reason. See docs/armv8r-el2-reference.md. */ + +#define ZX_ISS_DFSC_MASK 0x3FU /* [5:0] fault status code */ +#define ZX_ISS_WNR_MASK 0x00000040U /* [6] 1 = write */ +#define ZX_ISS_S1PTW_MASK 0x00000080U /* [7] stage-1 walk */ +#define ZX_ISS_CM_MASK 0x00000100U /* [8] cache maintenance */ +#define ZX_ISS_EA_MASK 0x00000200U /* [9] external abort */ +#define ZX_ISS_FNV_MASK 0x00000400U /* [10] FAR not valid */ +#define ZX_ISS_ISV_MASK 0x01000000U /* [24] ISS[23:14] valid */ + +/* HVC immediate: HSR.ISS[15:0]. ZoneX gives the two immediates it uses + names, because "HVC #1" in a trace says nothing about what was asked. + + ZX_HVC_NOP is the empty hypercall vector Phase 0 promises: counted at EL2 + and returned from, which proves the seam is live without giving a guest + anything to call. ZX_HVC_YIELD is how a Phase-0 test payload hands control + back to EL2 on purpose, as opposed to being taken from it by a fault. The + distinction is what lets a run say "the payload survived its violation" + instead of only "no fault was captured". */ + +#define ZX_HSR_HVC_IMM_MASK 0x0000FFFFU +#define ZX_HVC_NOP 0x0000U +#define ZX_HVC_YIELD 0x0001U + +/**************************************************************************/ +/* HPFAR */ +/**************************************************************************/ + +/* HPFAR IS NOT THE SAME REGISTER ON THE TWO ZONEX TARGETS. Measured, on + 2 September 2026, by the stage-2 probe faulting on an address it chose: + + Armv8-R AEM FVP HDFAR 0x000065C0 -> HPFAR 0x00000060 + which is FIPA[39:12] placed at HPFAR[31:4] -- the + shape TRM Figure 3-32 draws, and the A-profile shape. + + S32Z280-594EVB HDFAR 0x31781200 -> HPFAR 0x31781200 + which is the faulting address itself with [3:0] RES0 + -- the shape the TRM's own Table 3-69 row describes, + "Bits [31:4] of the faulting address". + + The TRM contradicts itself between a figure and the table beside it, and + the model and the silicon each implement a different one of the two. So + there is NO PORTABLE DECODE OF HPFAR on this architecture, and a + hypervisor that computed a faulting address from it would be right on one + target and wrong on the other -- by a factor of 256, silently, producing a + plausible address either way. + + HDFAR CARRIES THE FULL FAULTING VIRTUAL ADDRESS ON BOTH, and is what ZoneX + uses. The HPFAR functions below exist to REPORT the register and to say + which reading a target implements, not to be relied on. See + docs/armv8r-el2-reference.md. + + Under the figure's reading there is a further consequence: + + * the faulting address is reported to 4 KB, NOT to the 64-byte MPU + granule. Armv8-R has no translation structure at either stage, so the + 4 KB quantum is inherited from the A-profile register layout rather than + from anything in this architecture -- and it means HPFAR ALONE CANNOT + SAY WHICH GRANULE FAULTED. HDFAR carries the full virtual address and + is what an off-by-one-granule region bug is caught with. + + * FIPA[39:32] does not fit in a 32-bit address. On every ZoneX target + those bits are zero; zx_fault_hpfar_high exists so that a target where + they are not says so rather than reporting a silently truncated + address. + + Both consequences belong to the figure's reading only. Under the table + row's reading -- which is what the S32Z280 does -- HPFAR resolves to 16 + bytes and carries no bits above 32 at all. */ + +#define ZX_HPFAR_FIPA_SHIFT 4U +#define ZX_HPFAR_PAGE_SIZE 0x1000U + +/**************************************************************************/ +/* The fault record */ +/**************************************************************************/ + +/* Statically allocated by the port and filled in by the trap handler before + anything else runs. The earliest evidence is the only trustworthy + evidence: a handler that prints first and captures afterwards can lose the + syndrome to its own console driver taking a second fault. + + zx_fault_captures counts every capture, NOT every fault. A payload + yielding through HVC #1 is captured too, deliberately: "the payload came + back" is evidence, and a regression that can only report the ABSENCE of a + fault cannot tell a working protection failure from a broken test. The + field is named for what it counts so that nobody reads a run's capture + total as a fault total. */ + +typedef struct zx_fault_record_struct +{ + uint32_t zx_fault_captures; /* captures since reset -- see below */ + uint32_t zx_fault_vector; /* HVBAR offset of the vector taken */ + uint32_t zx_fault_hsr; /* HSR */ + uint32_t zx_fault_hpfar; /* HPFAR -- stage-2 faulting address */ + uint32_t zx_fault_hdfar; /* HDFAR -- data fault virtual address */ + uint32_t zx_fault_hifar; /* HIFAR -- instruction fault address */ + uint32_t zx_fault_elr; /* ELR_hyp -- where the guest was */ + uint32_t zx_fault_spsr; /* SPSR_hyp -- the mode it was in */ +} zx_fault_record_t; + +/* The vector offsets in the EL2 table, as captured in zx_fault_vector. A + guest's fault arrives at 0x14 and ZoneX's own at 0x0C or 0x10, so the + vector is an INDEPENDENT witness to what HSR.EC claims. The report + cross-checks the two, because a handler that trusts one register to + describe an event has no way to notice that register being wrong. */ + +#define ZX_VECTOR_RESET 0x00U +#define ZX_VECTOR_UNDEF 0x04U +#define ZX_VECTOR_SVC 0x08U +#define ZX_VECTOR_PABT_HYP 0x0CU +#define ZX_VECTOR_DABT_HYP 0x10U +#define ZX_VECTOR_HYP_TRAP 0x14U +#define ZX_VECTOR_IRQ 0x18U +#define ZX_VECTOR_FIQ 0x1CU + +/* What kind of event a captured record describes. The two that must never be + conflated are the first two. */ + +typedef enum zx_fault_class_enum +{ + ZX_FAULT_GUEST_VIOLATION = 0, /* EC 0x24/0x20 -- a guest overstepped */ + ZX_FAULT_HYPERVISOR_BUG, /* EC 0x25/0x21 -- ZoneX faulted itself */ + ZX_FAULT_HYPERCALL, /* EC 0x12 -- HVC, an intentional entry */ + ZX_FAULT_UNEXPECTED_TRAP /* anything else routed to EL2 */ +} zx_fault_class_t; + +/**************************************************************************/ +/* Decoding */ +/**************************************************************************/ + +ZX_NODISCARD uint32_t zx_fault_ec(uint32_t hsr); +ZX_NODISCARD uint32_t zx_fault_iss(uint32_t hsr); +ZX_NODISCARD zx_fault_class_t zx_fault_classify(uint32_t hsr); +ZX_NODISCARD const char *zx_fault_ec_name(uint32_t ec); +ZX_NODISCARD const char *zx_fault_class_name(zx_fault_class_t fault_class); +ZX_NODISCARD const char *zx_fault_vector_name(uint32_t vector); + +/* True when the record's vector and HSR.EC tell the same story. A guest + fault must arrive at 0x14 and a hypervisor fault at 0x0C or 0x10; anything + else means one of the two registers is not what it is documented to be, and + that is worth a line of its own in the report. */ + +ZX_NODISCARD uint32_t zx_fault_vector_agrees(uint32_t vector, uint32_t hsr); + +/* True when the vector is one for which HSR carries a syndrome at all. + Only +0x0C, +0x10 and +0x14 do. Reset, undefined instruction, SVC from + Hyp, IRQ and FIQ leave HSR holding whatever the last syndrome-bearing + exception left there, so HSR read after one of those is STALE, not wrong. + + This distinction was measured rather than assumed: an ICH_VTR read with + ICC_HSRE.SRE clear took an Undefined Instruction exception at EL2 on the + Armv8-R AEM FVP, and HSR still held EC 0x03 from earlier -- which the + vector-versus-EC cross-check duly reported as a disagreement. It was + right to flag it and wrong to imply one of the registers was broken. + Without this function the report has to accuse the hardware of something + the architecture never promised. */ + +ZX_NODISCARD uint32_t zx_fault_vector_has_syndrome(uint32_t vector); + +/* Data-abort ISS accessors. Valid only when zx_fault_ec is a data abort; + the caller checks, because a decoder that silently returns zero for the + wrong class hides the mistake. */ + +ZX_NODISCARD uint32_t zx_fault_dabt_dfsc(uint32_t hsr); +ZX_NODISCARD uint32_t zx_fault_dabt_is_write(uint32_t hsr); + +/* HVC immediate, valid only when zx_fault_ec(hsr) == ZX_EC_HVC. */ + +ZX_NODISCARD uint32_t zx_fault_hvc_immediate(uint32_t hsr); + +/* The 4 KB page HPFAR names under the FIGURE's reading, as a 32-bit address, + and the FIPA bits above 32 that a 32-bit address cannot carry. */ + +ZX_NODISCARD uint32_t zx_fault_hpfar_page(uint32_t hpfar); +ZX_NODISCARD uint32_t zx_fault_hpfar_high(uint32_t hpfar); + +/* The address HPFAR names under the TABLE ROW's reading: the faulting address + itself, with its low four bits RES0. */ + +ZX_NODISCARD uint32_t zx_fault_hpfar_as_address(uint32_t hpfar); + +/* True when HPFAR and HDFAR agree under each reading, respectively. */ + +ZX_NODISCARD uint32_t zx_fault_hpfar_matches_hdfar(uint32_t hpfar, + uint32_t hdfar); +ZX_NODISCARD uint32_t zx_fault_hpfar_matches_as_address(uint32_t hpfar, + uint32_t hdfar); + +/* Which reading this target implements, decided by comparing HPFAR against a + faulting address the caller already knows. + + THE TWO ZONEX TARGETS DISAGREE, and that is a measured fact rather than a + worry. See the HPFAR note above and docs/armv8r-el2-reference.md. */ + +typedef enum zx_hpfar_reading_enum +{ + ZX_HPFAR_UNRECOGNISED = 0, /* neither reading explains the value */ + ZX_HPFAR_AS_FIPA, /* FIPA[39:12] in HPFAR[31:4] -- the figure */ + ZX_HPFAR_AS_ADDRESS, /* the address itself, [3:0] RES0 -- the row */ + ZX_HPFAR_AMBIGUOUS /* both fit; this address cannot tell them */ + /* apart, so measure with another one */ +} zx_hpfar_reading_t; + +ZX_NODISCARD zx_hpfar_reading_t zx_fault_hpfar_reading(uint32_t hpfar, + uint32_t hdfar); +ZX_NODISCARD const char *zx_fault_hpfar_reading_name( + zx_hpfar_reading_t reading); + +/**************************************************************************/ +/* Reporting */ +/**************************************************************************/ + +void zx_fault_record_reset(zx_fault_record_t *record_ptr); + +/* Print the record. Prints HPFAR raw AND decoded, next to HDFAR, so that a + reader can check the decode rather than take it on trust. */ + +void zx_fault_report(const zx_fault_record_t *record_ptr); + #ifdef __cplusplus } #endif diff --git a/core/src/zx_fault.c b/core/src/zx_fault.c index a686a35..9adc838 100644 --- a/core/src/zx_fault.c +++ b/core/src/zx_fault.c @@ -12,6 +12,7 @@ * * SPDX-License-Identifier: MIT and CC0-1.0 **************************************************************************/ +// Some portions generated by Claude Code (Opus 5). /**************************************************************************/ /* */ @@ -24,11 +25,500 @@ /* Fault classification and reporting, driven by HSR.EC and, for a */ /* stage-2 violation, the faulting address in HPFAR. */ /* */ -/* This translation unit is deliberately empty of implementation. */ -/* The change that founded this repository builds the repository, not */ -/* the hypervisor; the unit exists so that the change which writes the */ -/* code opens a tree that already configures, compiles and links. */ +/* Everything here is arithmetic on words the port captured, so the */ +/* host suite can exercise it without a model or a board -- which */ +/* matters more for this file than for most, because the alternative */ +/* is testing a fault decoder by provoking faults. */ +/* */ +/* MISRA C:2012 deviations (justified) */ +/* */ +/* Rule 15.5 (single point of exit) -- deviated in the two name */ +/* lookups below, which return from inside a switch. The */ +/* alternative is a local pointer assigned in every case and */ +/* returned once, which adds a variable and a store per lookup and */ +/* makes a missing case harder to see, not easier. Rule 16.1's */ +/* requirement that every switch be well formed is observed: each */ +/* clause is a single return and there is a default. */ /* */ /**************************************************************************/ #include "zx_fault.h" +#include "zx_console.h" + +/**************************************************************************/ +/* zx_fault_ec -- HSR.EC, bits [31:26]. */ +/**************************************************************************/ + +uint32_t zx_fault_ec(uint32_t hsr) +{ + return (hsr >> ZX_HSR_EC_SHIFT) & ZX_HSR_EC_MASK; +} + + +/**************************************************************************/ +/* zx_fault_iss -- HSR.ISS, bits [24:0]. */ +/**************************************************************************/ + +uint32_t zx_fault_iss(uint32_t hsr) +{ + return hsr & ZX_HSR_ISS_MASK; +} + + +/**************************************************************************/ +/* zx_fault_classify */ +/* */ +/* Which of the four things that can reach EL2 this is. The routed and */ +/* taken-from pairs differ by exactly one bit of EC, which is precisely */ +/* why they are matched by name here rather than by masking: a mask that */ +/* lost that bit would report a hypervisor bug as a guest violation, and */ +/* a Phase-0 run would then "pass" on ZoneX faulting on itself. */ +/**************************************************************************/ + +zx_fault_class_t zx_fault_classify(uint32_t hsr) +{ + uint32_t ec = zx_fault_ec(hsr); + zx_fault_class_t result; + + if ((ec == ZX_EC_DABT_ROUTED) || (ec == ZX_EC_PABT_ROUTED)) + { + result = ZX_FAULT_GUEST_VIOLATION; + } + else if ((ec == ZX_EC_DABT_FROM_HYP) || (ec == ZX_EC_PABT_FROM_HYP)) + { + result = ZX_FAULT_HYPERVISOR_BUG; + } + else if (ec == ZX_EC_HVC) + { + result = ZX_FAULT_HYPERCALL; + } + else + { + result = ZX_FAULT_UNEXPECTED_TRAP; + } + + return result; +} + + +/**************************************************************************/ +/* zx_fault_ec_name */ +/**************************************************************************/ + +const char *zx_fault_ec_name(uint32_t ec) +{ + switch (ec) + { + case ZX_EC_UNKNOWN: return "unknown reason"; + case ZX_EC_WFI_WFE: return "trapped WFI/WFE"; + case ZX_EC_MCR_MRC_CP15: return "trapped MCR/MRC, coproc 15"; + case ZX_EC_MCRR_MRRC_CP15: return "trapped MCRR/MRRC, coproc 15"; + case ZX_EC_MCR_MRC_CP14: return "trapped MCR/MRC, coproc 14"; + case ZX_EC_LDC_STC_CP14: return "trapped LDC/STC, coproc 14"; + case ZX_EC_SIMD_FP: return "SIMD/floating-point access trapped by HCPTR"; + case ZX_EC_VMRS: return "trapped VMRS"; + case ZX_EC_MCRR_MRRC_CP14: return "trapped MCRR/MRRC, coproc 14"; + case ZX_EC_ILLEGAL_STATE: return "illegal state exception"; + case ZX_EC_SVC: return "SVC taken to Hyp mode"; + case ZX_EC_HVC: return "HVC executed"; + case ZX_EC_PABT_ROUTED: return "prefetch abort ROUTED to Hyp"; + case ZX_EC_PABT_FROM_HYP: return "prefetch abort TAKEN FROM Hyp"; + case ZX_EC_PC_ALIGNMENT: return "PC alignment fault"; + case ZX_EC_DABT_ROUTED: return "data abort ROUTED to Hyp"; + case ZX_EC_DABT_FROM_HYP: return "data abort TAKEN FROM Hyp"; + default: return "not a documented exception class"; + } +} + + +/**************************************************************************/ +/* zx_fault_class_name */ +/**************************************************************************/ + +const char *zx_fault_class_name(zx_fault_class_t fault_class) +{ + switch (fault_class) + { + case ZX_FAULT_GUEST_VIOLATION: + return "GUEST VIOLATION -- a partition overstepped its stage-2 regions"; + case ZX_FAULT_HYPERVISOR_BUG: + return "ZONEX FAULTED AT EL2 -- this is a hypervisor bug, not a guest one"; + case ZX_FAULT_HYPERCALL: + return "HYPERCALL -- an intentional entry to EL2"; + case ZX_FAULT_UNEXPECTED_TRAP: + return "UNEXPECTED TRAP -- routed to EL2 and not handled"; + default: + return "not a ZoneX fault class"; + } +} + + +/**************************************************************************/ +/* zx_fault_vector_name */ +/**************************************************************************/ + +const char *zx_fault_vector_name(uint32_t vector) +{ + switch (vector) + { + case ZX_VECTOR_RESET: return "+0x00 reset"; + case ZX_VECTOR_UNDEF: return "+0x04 undefined instruction, from Hyp"; + case ZX_VECTOR_SVC: return "+0x08 SVC, from Hyp"; + case ZX_VECTOR_PABT_HYP: return "+0x0C prefetch abort, from Hyp"; + case ZX_VECTOR_DABT_HYP: return "+0x10 data abort, from Hyp"; + case ZX_VECTOR_HYP_TRAP: return "+0x14 Hyp trap entry, from EL0/EL1"; + case ZX_VECTOR_IRQ: return "+0x18 IRQ"; + case ZX_VECTOR_FIQ: return "+0x1C FIQ"; + default: return "not an EL2 vector offset"; + } +} + + +/**************************************************************************/ +/* zx_fault_vector_agrees */ +/* */ +/* Cross-check the vector that was taken against what HSR.EC claims. */ +/* Anything routed to EL2 from EL0/EL1 arrives at +0x14; a fault taken */ +/* FROM Hyp arrives at its own vector, +0x0C or +0x10. Disagreement */ +/* means one of the two is not what it is documented to be, which is */ +/* worth reporting rather than quietly preferring one of them. */ +/**************************************************************************/ + +uint32_t zx_fault_vector_agrees(uint32_t vector, uint32_t hsr) +{ + uint32_t ec = zx_fault_ec(hsr); + uint32_t agrees; + + if (ec == ZX_EC_DABT_FROM_HYP) + { + agrees = (vector == ZX_VECTOR_DABT_HYP) ? 1U : 0U; + } + else if (ec == ZX_EC_PABT_FROM_HYP) + { + agrees = (vector == ZX_VECTOR_PABT_HYP) ? 1U : 0U; + } + else + { + agrees = (vector == ZX_VECTOR_HYP_TRAP) ? 1U : 0U; + } + + return agrees; +} + + +/**************************************************************************/ +/* zx_fault_vector_has_syndrome */ +/**************************************************************************/ + +uint32_t zx_fault_vector_has_syndrome(uint32_t vector) +{ + return ((vector == ZX_VECTOR_PABT_HYP) || (vector == ZX_VECTOR_DABT_HYP) + || (vector == ZX_VECTOR_HYP_TRAP)) ? 1U : 0U; +} + + +/**************************************************************************/ +/* zx_fault_dabt_dfsc / zx_fault_dabt_is_write */ +/**************************************************************************/ + +uint32_t zx_fault_dabt_dfsc(uint32_t hsr) +{ + return zx_fault_iss(hsr) & ZX_ISS_DFSC_MASK; +} + + +uint32_t zx_fault_dabt_is_write(uint32_t hsr) +{ + return ((zx_fault_iss(hsr) & ZX_ISS_WNR_MASK) != 0U) ? 1U : 0U; +} + + +/**************************************************************************/ +/* zx_fault_hvc_immediate */ +/**************************************************************************/ + +uint32_t zx_fault_hvc_immediate(uint32_t hsr) +{ + return zx_fault_iss(hsr) & ZX_HSR_HVC_IMM_MASK; +} + + +/**************************************************************************/ +/* zx_fault_hpfar_page */ +/* */ +/* HPFAR[31:4] is FIPA[39:12], so the 4 KB page it names is that field */ +/* shifted up to bit 12 -- a net left shift of 8 -- with the RES0 bits */ +/* that land in [11:8] masked away. Masking is not belt and braces: */ +/* HPFAR[3:0] is RES0 and reads as zero today, but a future part that */ +/* used those bits for anything would otherwise contribute them to the */ +/* address and produce a plausible wrong answer. */ +/**************************************************************************/ + +uint32_t zx_fault_hpfar_page(uint32_t hpfar) +{ + return (hpfar << 8U) & ~(uint32_t)(ZX_HPFAR_PAGE_SIZE - 1U); +} + + +/**************************************************************************/ +/* zx_fault_hpfar_high */ +/* */ +/* FIPA[39:32], which no 32-bit address can carry. Non-zero means the */ +/* faulting address is above 4 GB and zx_fault_hpfar_page has truncated */ +/* it -- impossible on any ZoneX target so far, and the point of */ +/* reporting it is that a target where it is possible says so. */ +/**************************************************************************/ + +uint32_t zx_fault_hpfar_high(uint32_t hpfar) +{ + return hpfar >> 24U; +} + + +/**************************************************************************/ +/* zx_fault_hpfar_as_address */ +/* */ +/* The other reading: the faulting address itself, with the low four */ +/* bits RES0. This is what the S32Z280 implements, and what TRM Table */ +/* 3-69's row -- "Bits [31:4] of the faulting address" -- describes. */ +/**************************************************************************/ + +uint32_t zx_fault_hpfar_as_address(uint32_t hpfar) +{ + return hpfar & 0xFFFFFFF0U; +} + + +/**************************************************************************/ +/* zx_fault_hpfar_matches_hdfar / zx_fault_hpfar_matches_as_address */ +/**************************************************************************/ + +uint32_t zx_fault_hpfar_matches_hdfar(uint32_t hpfar, uint32_t hdfar) +{ + uint32_t hdfar_page = hdfar & ~(uint32_t)(ZX_HPFAR_PAGE_SIZE - 1U); + + return ((zx_fault_hpfar_high(hpfar) == 0U) + && (zx_fault_hpfar_page(hpfar) == hdfar_page)) ? 1U : 0U; +} + + +uint32_t zx_fault_hpfar_matches_as_address(uint32_t hpfar, uint32_t hdfar) +{ + return (zx_fault_hpfar_as_address(hpfar) + == (hdfar & 0xFFFFFFF0U)) ? 1U : 0U; +} + + +/**************************************************************************/ +/* zx_fault_hpfar_reading */ +/* */ +/* Which of the TRM's two mutually exclusive descriptions of HPFAR this */ +/* target implements. Both are tested rather than one being assumed, */ +/* because the two ZoneX targets answer differently -- and reporting */ +/* AMBIGUOUS when a particular faulting address cannot tell them apart is */ +/* the difference between a measurement and a coincidence. An address */ +/* in the first 4 KB, or one whose low twelve bits are zero, satisfies */ +/* both readings and proves nothing. */ +/**************************************************************************/ + +zx_hpfar_reading_t zx_fault_hpfar_reading(uint32_t hpfar, uint32_t hdfar) +{ + uint32_t as_fipa = zx_fault_hpfar_matches_hdfar(hpfar, hdfar); + uint32_t as_address = zx_fault_hpfar_matches_as_address(hpfar, hdfar); + zx_hpfar_reading_t result; + + if ((as_fipa != 0U) && (as_address != 0U)) + { + result = ZX_HPFAR_AMBIGUOUS; + } + else if (as_fipa != 0U) + { + result = ZX_HPFAR_AS_FIPA; + } + else if (as_address != 0U) + { + result = ZX_HPFAR_AS_ADDRESS; + } + else + { + result = ZX_HPFAR_UNRECOGNISED; + } + + return result; +} + + +/**************************************************************************/ +/* zx_fault_hpfar_reading_name */ +/**************************************************************************/ + +const char *zx_fault_hpfar_reading_name(zx_hpfar_reading_t reading) +{ + switch (reading) + { + case ZX_HPFAR_AS_FIPA: + return "FIPA[39:12] at HPFAR[31:4] -- the TRM's Figure 3-32, and the " + "A-profile shape"; + case ZX_HPFAR_AS_ADDRESS: + return "the faulting address itself, [3:0] RES0 -- the TRM's Table " + "3-69 row"; + case ZX_HPFAR_AMBIGUOUS: + return "BOTH readings fit this address, so it cannot tell them apart " + "-- fault on an address whose low 12 bits are not zero"; + case ZX_HPFAR_UNRECOGNISED: + return "NEITHER documented reading explains the value"; + default: + return "not a ZoneX HPFAR reading"; + } +} + + +/**************************************************************************/ +/* zx_fault_record_reset */ +/**************************************************************************/ + +void zx_fault_record_reset(zx_fault_record_t *record_ptr) +{ + if (record_ptr != (zx_fault_record_t *)0) + { + record_ptr->zx_fault_captures = 0U; + record_ptr->zx_fault_vector = 0U; + record_ptr->zx_fault_hsr = 0U; + record_ptr->zx_fault_hpfar = 0U; + record_ptr->zx_fault_hdfar = 0U; + record_ptr->zx_fault_hifar = 0U; + record_ptr->zx_fault_elr = 0U; + record_ptr->zx_fault_spsr = 0U; + } +} + + +/**************************************************************************/ +/* zx_fault_report */ +/* */ +/* Print everything the record holds, and print HPFAR both raw and */ +/* decoded next to HDFAR. Printing the decode without the raw value */ +/* would make the decode unfalsifiable from a log, and the HPFAR */ +/* relationship is one of the two documentary ambiguities the EL2 */ +/* bring-up work exists to close. */ +/**************************************************************************/ + +void zx_fault_report(const zx_fault_record_t *record_ptr) +{ + uint32_t hsr; + uint32_t ec; + zx_fault_class_t fault_class; + + if (record_ptr == (const zx_fault_record_t *)0) + { + zx_console_puts("[FAULT] no fault record was captured -- nothing to report\n"); + return; + } + + hsr = record_ptr->zx_fault_hsr; + ec = zx_fault_ec(hsr); + fault_class = zx_fault_classify(hsr); + + zx_console_puts("\n[FAULT] "); + zx_console_puts(zx_fault_class_name(fault_class)); + zx_console_puts("\n[FAULT] vector "); + zx_console_puts(zx_fault_vector_name(record_ptr->zx_fault_vector)); + + if (zx_fault_vector_has_syndrome(record_ptr->zx_fault_vector) == 0U) + { + zx_console_puts("\n[FAULT] *** THIS VECTOR CARRIES NO SYNDROME. Only +0x0C, +0x10\n" + "[FAULT] *** and +0x14 update HSR, so the HSR below is whatever the\n" + "[FAULT] *** last syndrome-bearing exception left in it -- STALE, not\n" + "[FAULT] *** wrong. The VECTOR is the witness here."); + } + else if (zx_fault_vector_agrees(record_ptr->zx_fault_vector, hsr) == 0U) + { + zx_console_puts("\n[FAULT] *** the vector taken and HSR.EC DISAGREE, and this vector\n" + "[FAULT] *** does carry a syndrome -- so one of the two registers is\n" + "[FAULT] *** not what it is documented to be. Do not trust either\n" + "[FAULT] *** until that is understood."); + } + else + { + /* They agree, which is the ordinary case and needs no line of its + own: the report is read when something went wrong, and noise in + it costs more than the reassurance is worth. */ + } + + zx_console_puts("\n[FAULT] HSR "); + zx_console_puthex(hsr); + zx_console_puts(" EC "); + zx_console_puthex(ec); + zx_console_puts(" = "); + zx_console_puts(zx_fault_ec_name(ec)); + + zx_console_puts("\n[FAULT] HSR.ISS "); + zx_console_puthex(zx_fault_iss(hsr)); + + if ((ec == ZX_EC_DABT_ROUTED) || (ec == ZX_EC_DABT_FROM_HYP)) + { + zx_console_puts(" DFSC "); + zx_console_puthex(zx_fault_dabt_dfsc(hsr)); + zx_console_puts(zx_fault_dabt_is_write(hsr) != 0U ? " on a WRITE" + : " on a READ"); + } + else if (ec == ZX_EC_HVC) + { + zx_console_puts(" HVC immediate "); + zx_console_puthex(zx_fault_hvc_immediate(hsr)); + } + else + { + /* No ISS decode is defined for this class; the raw value above is + all there is to say, and saying more would be inventing it. */ + } + + zx_console_puts("\n[FAULT] HDFAR "); + zx_console_puthex(record_ptr->zx_fault_hdfar); + zx_console_puts(" <- the full faulting virtual address"); + zx_console_puts("\n[FAULT] HIFAR "); + zx_console_puthex(record_ptr->zx_fault_hifar); + + zx_console_puts("\n[FAULT] HPFAR "); + zx_console_puthex(record_ptr->zx_fault_hpfar); + zx_console_puts(" raw"); + zx_console_puts("\n[FAULT] as FIPA "); + zx_console_puthex(zx_fault_hpfar_page(record_ptr->zx_fault_hpfar)); + zx_console_puts(" if HPFAR[31:4] is FIPA[39:12]"); + zx_console_puts("\n[FAULT] as address "); + zx_console_puthex(zx_fault_hpfar_as_address(record_ptr->zx_fault_hpfar)); + zx_console_puts(" if HPFAR[31:4] is the address itself"); + + if (fault_class == ZX_FAULT_GUEST_VIOLATION) + { + zx_console_puts("\n[FAULT] HPFAR reads as\n[FAULT] "); + zx_console_puts(zx_fault_hpfar_reading_name( + zx_fault_hpfar_reading(record_ptr->zx_fault_hpfar, + record_ptr->zx_fault_hdfar))); + zx_console_puts("\n[FAULT] The TRM describes HPFAR both ways and the two ZoneX\n" + "[FAULT] targets implement DIFFERENT ones, so there is no\n" + "[FAULT] portable decode. Use HDFAR."); + + if (zx_fault_hpfar_high(record_ptr->zx_fault_hpfar) != 0U) + { + zx_console_puts("\n[FAULT] *** Under the FIPA reading HPFAR would report\n" + "[FAULT] *** FIPA[39:32] = "); + zx_console_puthex(zx_fault_hpfar_high(record_ptr->zx_fault_hpfar)); + zx_console_puts(", above 4 GB, so the \"as FIPA\"\n" + "[FAULT] *** line above is TRUNCATED."); + } + } + + zx_console_puts("\n[FAULT] ELR_hyp "); + zx_console_puthex(record_ptr->zx_fault_elr); + zx_console_puts(" <- the instruction that faulted"); + zx_console_puts("\n[FAULT] SPSR_hyp "); + zx_console_puthex(record_ptr->zx_fault_spsr); + zx_console_puts(" mode "); + zx_console_puthex(record_ptr->zx_fault_spsr & 0x1FU); + zx_console_puts("\n[FAULT] captures "); + zx_console_putdec(record_ptr->zx_fault_captures); + zx_console_puts(" every entry to EL2 is captured, yields included,\n" + "[FAULT] so this is not a fault count"); + zx_console_puts("\n"); +} diff --git a/docs/armv8r-el2-reference.md b/docs/armv8r-el2-reference.md index 57547df..344e285 100644 --- a/docs/armv8r-el2-reference.md +++ b/docs/armv8r-el2-reference.md @@ -15,7 +15,8 @@ SPDX-License-Identifier: MIT and CC0-1.0 # Armv8-R AArch32 EL2 reference sheet -*Eclipse ThreadX ZoneX. Verified 2 September 2026.* +*Eclipse ThreadX ZoneX. Verified 2 September 2026, and measured on both +targets the same day.* This file exists so that no ZoneX session has to re-derive a register encoding. Every line below was read out of the Arm Cortex-R52 Technical Reference Manual @@ -80,10 +81,16 @@ names that belong to other architectures. It runs in CI. ⚠ **The FVP cannot demonstrate the need.** Its console and GIC are at `0x9C090000` and `0xAF000000`, inside the Device-nGnRE band, so the background map is accidentally correct there. Treat this like the region - count: a green model run says nothing about the part. (The GIC base on the - S32Z280 is quoted from bring-up notes rather than read out of the reference - manual — confirm it before relying on the exact address. The band it falls - in, and therefore the conclusion, is not in doubt.) + count: a green model run says nothing about the part. + + The S32Z280's GIC base is `0x47800000`, and it is not second-hand: the SoC + reference manual gives no GIC address at all, but `IMP_CBAR` reports the + distributor base in bits [31:21] and reads `0x47800000` on the board, which + confirms NXP's map from the hardware itself. + + ZoneX therefore spends **two** of the S32Z280's twenty EL2 regions on its own + MMIO — the console and the GIC — and **none** on the FVP. That difference is + the whole reason both targets have to be run. One more property to design around, TRM Table 8-3: for instruction access the background map marks `0x80000000`–`0xFFFFFFFF` execute-never, so @@ -106,10 +113,10 @@ All are reached with `MRC`/`MCR p15, 4, , …`. `opc1 = 4` is the Hyp bank. | `HSR` | `c5, c2, 0` | `EC[31:26]`, `IL[25]`, `ISS[24:0]`. | | `HDFAR` | `c6, c0, 0` | Data fault address. | | `HIFAR` | `c6, c0, 2` | Instruction fault address. | -| `HPFAR` | `c6, c0, 4` | **The faulting address for a stage-2 fault** (TRM §3.3.42). | -| `HMPUIR` | `c0, c0, 4` | EL2 region count in **bits [7:0]** — *not* [15:8], where `MPUIR` keeps the EL1 count. TRM Table 3-79: the value is 0, 16, 20 or 24. | +| `HPFAR` | `c6, c0, 4` | Stage-2 fault address — **but see the HPFAR warning below: the two ZoneX targets implement two different layouts.** TRM §3.3.44 (not §3.3.42). | +| `HMPUIR` | `c0, c0, 4` | Read-only. EL2 region count in **bits [7:0]** — *not* [15:8], where `MPUIR` keeps the EL1 count. TRM Table 3-79: the value is 0, 16, 20 or 24. | | `HPRSELR` | `c6, c2, 1` | `REGION[3:0]` on a 16-region implementation; **`REGION[4:0]` on 20 or 24** (TRM Tables 3-84, 3-85). Writing a value ≥ the implemented count is UNPREDICTABLE. | -| `HPRENR` | `c6, c1, 1` | One enable bit per region; bulk enable/disable. Width — see the open question below. | +| `HPRENR` | `c6, c1, 1` | One enable bit per region; bulk enable/disable. Width **measured**: `[19:0]` on the S32Z280, 32 bits on the FVP — see below. | | `HPRBAR` (indirect) | `c6, c3, 0` | Select the region with `HPRSELR` first. | | `HPRLAR` (indirect) | `c6, c3, 1` | Likewise. | | `HPRBAR` (direct, n = 0…15) | `c6, c8+n[3:1], 4*n[0]` | TRM §3.3.48. | @@ -119,7 +126,8 @@ All are reached with `MRC`/`MCR p15, 4, , …`. `opc1 = 4` is the Hyp bank. | `HMAIR1` | `c10, c2, 1` | Likewise. | | `CNTHCTL` | `c14, c1, 0` | `PL1PCTEN[0]`, `PL1PCEN[1]`. The ThreadX Cortex-R52 port sets both. | | `CNTVOFF` | `MCRR p15, 4, , , c14` | 64-bit. Per-guest virtual-time offset. | -| `ICC_HSRE` | `c12, c9, 5` | GICv3 system-register interface enable for EL1. | +| `ICC_HSRE` | `c12, c9, 5` | GICv3 system-register interface enable. `SRE[0]`, `Enable[3]`. **Every other `ICC_*`/`ICH_*` register is UNDEFINED until `SRE` is set** — see below. | +| `ICH_VTR` | `c12, c11, 1` | `ListRegs[4:0]`, the List Register count minus one. Needs `ICC_HSRE.SRE`. | --- @@ -184,13 +192,47 @@ consequences, and they shape the whole Phase-0 memory model: | Target | EL1 regions (`MPUIR[15:8]`) | EL2 regions (`HMPUIR[7:0]`) | |---|---|---| | Architectural (Cortex-R52) | 16, 20 or 24 | 0, 16, 20 or 24 | -| **S32Z280-594EVB** | **20**, measured | **20** — `HMPUIR = 0x00000014`, measured on silicon | -| Armv8-R AEM FVP | 32, measured — *not an architecturally legal R52 value* | ⚠ **unknown; measured when EL2 is brought up** | +| **S32Z280-594EVB** | **20** — `MPUIR = 0x00001400` | **20** — `HMPUIR = 0x00000014` | +| **Armv8-R AEM FVP** | **32** — `MPUIR = 0x00002000` | **32** — `HMPUIR = 0x00000020` | + +Both rows measured on 2 September 2026 by the stage-2 probe image, which prints +them and asserts the EL2 count against Table 3-79's permitted set. + +The FVP row is the point of the table, and it is now confirmed at both stages. +**32 is not an architecturally legal Cortex-R52 value at either level**, so a +green FVP run proves nothing about any real part's region budget. The probe +reports the illegality loudly rather than failing on it, because on the model it +is expected. -The FVP row is the point of the table. The model reported 32 EL1 regions, which -no real Cortex-R52 can have, so **a green FVP run proves nothing about any real -part's region budget.** Expect the same at EL2: read `HMPUIR` on the model -rather than assuming it matches the board. +The model is not shy about being a model elsewhere either: + +| | Armv8-R AEM FVP | S32Z280-594EVB | +|---|---|---| +| `MIDR` | `0x410FD0F0`, part **`0xD0F`** — not a Cortex-R52 part number | `0x411FD133`, part **`0xD13`** = Cortex-R52, r1p3 | +| `ICH_VTR` | `0x9008000F` — `ListRegs` 15, so **16** List Registers | `0x90180003` — `ListRegs` 3, so **4** List Registers, matching the TRM's reset value | +| `HSCTLR` at reset | `0x30C50818` — `TE` **clear** | `0x70C50838` — `TE` **SET** | +| `SCTLR` at reset | `0x00C00800` — `TE` clear | `0x70C50838` — `TE` **SET** | +| Execution state at reset | A32 | **T32** | +| `HPRENR` implemented bits | `0xFFFFFFFF` | **`0x000FFFFF`** | + +`TE` set means EL2 exceptions are entered in T32 state, so an A32 vector table +never runs and every fault presents as a silent hang. ZoneX's reset path clears +`HSCTLR.TE` and `SCTLR.TE` before anything can fault, and records their reset +values so that this difference is visible in every run's own output rather than +only in this table. + +### `MRS Rd, CPSR` cannot read `PSTATE.T` + +Worth knowing before anybody tries to detect the execution state from inside an +image. ZoneX's first executed instruction is `MRS r6, CPSR`, and it reads +`0x000001DA` on **both** targets — while the S32Z280 is demonstrably in T32 +state at that moment (the instruction doing the reading is a T32 encoding, and +it executes correctly) and the FVP is in A32. Two execution states, one +reading: the `T` bit is not visible through `MRS`. + +A debugger can see it, which is where the `0x1FA` reading recorded during the +Cortex-R52 port work came from. From inside an image, use `HSCTLR.TE` and +`SCTLR.TE`, and treat the CPSR snapshot as a report of the **mode** field. --- @@ -211,15 +253,27 @@ before anything is programmed. Every exception routed to EL2 from EL0 or EL1 arrives at **one vector, `HVBAR + 0x14`**, and must be decoded from `HSR.EC` (TRM Table 3-88): +The whole of Table 3-88, not only the classes Phase 0 handles: an unexpected +trap has to be reported by **name**, and a numeric `EC` in a log is a value +somebody then has to look up while the board is still on the bench. + | `EC` | Meaning | |---|---| +| `0x00` | Unknown reason | | `0x01` | Trapped `WFI`/`WFE` | | `0x03` | Trapped `MCR`/`MRC`, coproc `0b1111` | +| `0x04` | Trapped `MCRR`/`MRRC`, coproc `0b1111` | | `0x05` | Trapped `MCR`/`MRC`, coproc `0b1110` | +| `0x06` | Trapped `LDC`/`STC`, coproc `0b1110` | +| `0x07` | SIMD/floating-point access trapped by `HCPTR` | +| `0x08` | Trapped `VMRS`, ID group trap | +| `0x0C` | Trapped `MCRR`/`MRRC`, coproc `0b1110` | +| `0x0E` | Illegal state exception | | `0x11` | `SVC` taken to Hyp | | `0x12` | **`HVC` executed** — the hypercall seam | | `0x20` | Prefetch abort **routed to** Hyp | | `0x21` | Prefetch abort **taken from** Hyp | +| `0x22` | PC alignment fault | | `0x24` | Data abort **routed to** Hyp | | `0x25` | Data abort **taken from** Hyp | @@ -250,59 +304,202 @@ itself — a ZoneX bug — and must be reported as one rather than folded in wit --- -## Open questions +## The two open questions, both now CLOSED on both targets -Two, both narrowed while this reference sheet was written and neither yet -proven on hardware. The EL2 bring-up work closes them and updates this section -rather than starting a new note. +Measured on 2 September 2026 by the stage-2 probe image, on the Armv8-R AEM FVP +and on the S32Z280-594EVB. -### ⚠ 1. `HPRENR` width — documentary answer known, hardware answer not +### 1. `HPRENR` width — **settled: the bit tables are right, the prose is stale** TRM §3.3.46 contradicts itself. The prose says `HPRENR` *"provides direct access to the region enable (HPRLAR.EN) for regions 0 to 15"*. The bit tables in the -same section say otherwise: +same section say `[15:0]` for 16 regions, **`[19:0]` for 20** (Table 3-77) and +**`[23:0]` for 24** (Table 3-78). -| Implementation | Table | `ENABLES` field | -|---|---|---| -| 0 regions | 3-75 | none, RAZ | -| 16 regions | 3-76 | `[15:0]` | -| **20 regions** | **3-77** | **`[19:0]`** | -| **24 regions** | **3-78** | **`[23:0]`** | +Two independent measurements, because they answer different questions and only +having both is conclusive: + +**Which bits are implemented.** All ones written to `HPRENR` before any region +existed, then read back: + +| Target | Reads back | Regions | Verdict | +|---|---|---|---| +| S32Z280-594EVB | **`0x000FFFFF`** | 20 | exactly `[19:0]`, exactly Table 3-77 | +| Armv8-R AEM FVP | `0xFFFFFFFF` | 32 | 32 bits for its 32 regions | + +Reset value is `0x00000000` on both, so every region starts disabled and an +unprogrammed region with an UNKNOWN base cannot be live. -The tables are almost certainly right and the prose is a stale sentence from -the 16-region case. **ZoneX assumes `[19:0]` on the S32Z280's 20-region -implementation**, and that must be *proved* on the part: write a pattern with -a bit set above 15, read it back, and confirm that the corresponding region -actually stops matching. A register that reads back the bit while ignoring it -would be the worst outcome and is exactly what a read-back-only test would -miss. +**Which bits WORK.** A read-back alone would not settle it: a register that +accepted a bit above 15 and then *ignored* it would pass that test and is the +worst of the three possible answers, because it would make a partition switch +implemented as one `HPRENR` write silently leave the outgoing partition's +regions live. So the probe also tests it functionally, from EL1: -Why it matters: it decides D4. A single `HPRENR` write is how a partition -switch could cost one instruction instead of a block of region rewrites. +* region 16 programmed over a 64-byte granule holding a known sentinel; +* `HPRENR` bit 16 set — an EL1 read of that granule **succeeds and returns the + sentinel**; +* bit 16 cleared — the same read **takes a stage-2 fault**, `EC 0x24`, + `DFSC 0x04`. -### ⚠ 2. Direct access above region 15 — **settled by the TRM** +Both targets behave identically. `HPRENR` genuinely disables a region above 15. + +**So D4's preferred shape is available**: a partition switch can be a single +`HPRENR` write as far as this register is concerned. What remains unmeasured is +its *cost*, which is what D4 is actually waiting for. + +### 2. Direct access above region 15 — **settled on both parts** TRM §3.3.48's prose says direct access is provided to `HPRBAR0`–`HPRBAR15` -only. **§8.4 is more complete and settles it**, listing verbatim: +only. §8.4 and the `c6` register summary both list `HPRBAR16`–`HPRBAR24` and +`HPRLAR16`–`HPRLAR24` at `opc1 = 5`, `CRm = c8 + (n-16)[3:1]`, with `op2` 0/4 +for an even/odd base and 1/5 for an even/odd limit. + +The probe programs region 16 through the **direct** encoding +(`MCR p15, 5, , c6, c8, 0` and `…, 1`) and reads it back through the +**indirect** path (`HPRSELR` then `HPRBAR`/`HPRLAR`). They agree on both +targets — and region 16 then demonstrably controls access from EL1, so the +encoding reaches the real register rather than a writable hole: + +| | `HPRBAR16` direct / indirect | `HPRLAR16` direct / indirect | +|---|---|---| +| S32Z280-594EVB | `0x31781243` / `0x31781243` | `0x31781241` / `0x31781241` | +| Armv8-R AEM FVP | `0x00006603` / `0x00006603` | `0x00006601` / `0x00006601` | + +**§8.4 is right and §3.3.48's prose is stale.** The whole 20-region budget is +directly addressable, so the EL2 MPU avoids the penalty measured at EL1 during +the Cortex-R52 Modules port work for reaching a region ≥ 16 through the +selection register — 542–604 cycles against 434–470 for a direct write. + +--- + +## ⚠ `HPFAR` IS NOT THE SAME REGISTER ON THE TWO TARGETS + +The most consequential measurement of the EL2 bring-up work, and it is a +divergence rather than an answer. + +TRM §3.3.44 describes `HPFAR` **two ways in the same section**. Figure 3-32 +draws the field as `FIPA[39:12]` occupying `HPFAR[31:4]`, which is the +A-profile shape. Table 3-69's row beside it says the field is *"Bits [31:4] of +the faulting address"*, which is a different value — by a factor of 256. + +The probe faults on an address it chose itself, granule-aligned but deliberately +**not** 4 KB-aligned so that the two readings cannot both fit, and compares +`HPFAR` against `HDFAR`: + +| Target | `HDFAR` (address touched) | `HPFAR` | Reading | +|---|---|---|---| +| Armv8-R AEM FVP | `0x00007040` | `0x00000070` | `FIPA[39:12]` at `[31:4]` — the **figure** | +| S32Z280-594EVB | `0x31781200` | `0x31781200` | the address itself, `[3:0]` RES0 — the **table row** | + +**Each target implements a different one of the TRM's two descriptions.** There +is therefore **no portable decode of `HPFAR` on this architecture**, and code +that computed a faulting address from it would be right on one target and wrong +on the other, silently, producing a plausible address either way. + +**`HDFAR` carries the full faulting virtual address on both, and is what ZoneX +uses.** It is also the register an off-by-one-granule region bug has to be +caught with, because `HPFAR` under the figure's reading resolves only to 4 KB +and cannot say which 64-byte granule faulted. + +Two further properties, both measured: -``` -HPRBAR16-HPRBAR24 MCR p15, 5, , c6, CRm, op2 -HPRLAR16-HPRLAR24 MCR p15, 5, , c6, CRm, op2 -``` +* `HPFAR` is **not updated for a fault taken from Hyp mode**. A deliberate EL2 + permission fault at `0x31781240` left `HPFAR` holding `0x31781200` from the + previous guest fault while `HDFAR` correctly read `0x31781240`. So `HPFAR` is + meaningful only for `EC 0x24`/`0x20`, and ZoneX's fault report omits any + reading of it for `EC 0x25`/`0x21`. +* Under the figure's reading the FVP's `HPFAR` would also carry `FIPA[39:32]`, + which no 32-bit address can hold. On the S32Z280 those bits are part of the + address, so interpreting them as `FIPA[39:32]` yields `0x31` — "above 4 GB" — + which is how the report detects that the figure's reading does not apply. -with the matching `MRC` forms, and the c6 register summary agrees: -`HPRBAR16-24 (even)` at `opc1 = 5`, `CRm` c8–c12, and `HPRBAR17-23 (odd)` -likewise. The encoding is the `opc1 = 4` one with `opc1` changed to 5 — exactly -as `PRBAR16`–`PRBAR24` use `opc1 = 1` where `PRBAR0`–`PRBAR15` use 0. +--- + +## Data-abort fault status codes, measured + +The TRM defers the `ISS` layout to the Armv8-R architecture supplement, which +was not available. These are the AArch32 data-abort `ISS` positions common to +Armv8, confirmed against real syndromes on both targets: + +| `ISS` bits | Field | Measured behaviour | +|---|---|---| +| `[5:0]` | `DFSC` | **`0x04`** when no enabled EL2 region matched · **`0x0C`** when a region matched and `AP` denied the access | +| `[6]` | `WnR` | 1 on the probe's write, 0 on its read — both confirmed | +| `[24]` | `ISV` | set in every syndrome observed | + +Full syndromes, for reference: + +| Event | `HSR` | Vector | Target | +|---|---|---|---| +| Guest write to an ungranted granule | `0x93810044` | `+0x14` | both | +| Guest read of a region disabled via `HPRENR` | `0x93810004` | `+0x14` | both | +| **EL2's own** write to a region marked `AP = 0b10` | `0x9600004C` | `+0x10` | both | -**So the EL2 MPU avoids the penalty measured at EL1 for regions ≥ 16**, where -reaching a high region meant going through `PRSELR` with an extra `ISB` — -542–604 cycles against 434–470 for a direct write. The whole 20-region budget -is directly addressable. +The last row is worth its own note: it confirms TRM Table 3-82 from the other +direction. `AP = 0b10` really is read-only **at EL2**, and an EL2 write to such +a region aborts. -This is a documentary resolution, not a measured one. Confirm it functionally -the first time a region ≥ 16 is programmed, and change "settled by the TRM" to -"settled on the part" here when that happens. +--- + +## Exceptions taken FROM Hyp mode do not all update `HSR` + +Only `HVBAR + 0x0C`, `+0x10` and `+0x14` carry a syndrome. Reset, undefined +instruction, `SVC` from Hyp, IRQ and FIQ do not update `HSR` at all, so `HSR` +read after one of those holds whatever the last syndrome-bearing exception left +in it — **stale, not wrong**. + +Measured: an `ICH_VTR` read taken as an Undefined Instruction exception at EL2 +arrived at `+0x04` with `HSR` still holding `0x0FE33017` (`EC 0x03`) from +earlier. `ELR_hyp` is the useful register in that case — disassemble it. + +ZoneX's fault report distinguishes the two cases rather than accusing the +hardware of contradicting itself, which is what it did before this was +understood. + +--- + +## `ICC_HSRE.SRE` gates every other GIC system register + +`ICH_VTR` — and every other `ICC_*` and `ICH_*` system register — is +**UNDEFINED until `ICC_HSRE.SRE` is set**. This is not a documentation nicety: +reading `ICH_VTR` first takes an Undefined Instruction exception at EL2, which +is how it was found on the FVP. + +ZoneX's reset path therefore sets `ICC_HSRE.SRE` and `ICC_HSRE.Enable` before +the identity block reads anything GIC-shaped. EL1 needs both set anyway to +acknowledge an interrupt at all. + +--- + +## The hypervisor's own console must be Device-attributed before it is used + +The sharpest practical consequence of "`BR` grants permission, not attributes", +and the FVP cannot show it at all. + +On the S32Z280 the console is LINFlexD_9 at `0x42980000`, inside the +`0x40000000`–`0x5FFFFFFF` band that Table 8-4 makes **Normal, Write-Through +cacheable**. With `HSCTLR.BR=1` and the EL2 MPU not yet enabled, the console is +*reachable* — and it does not work correctly. Normal memory permits gathering +and reordering **even with caches off**, which corrupts a polled UART's +register protocol. + +Measured: the whole identity block printed as legible-but-wrong text, +characters intermittently corrupted, looking exactly like a marginal baud rate. +Programming a Device-attributed EL2 region for the console and setting +`HSCTLR.M` **before the first print** fixes it completely — the same run showed +corrupt text before that point and clean text after. + +So the order is: + +1. `HMAIR0`/`HMAIR1`; +2. the hypervisor's own MMIO regions — console, GIC; +3. `HSCTLR.BR` and `HSCTLR.M`; +4. **then** the console, and anything printed; +5. the partition regions; +6. `HCR.VM`. + +See `docs/decisions.md` D2 and D15. --- @@ -320,7 +517,12 @@ Every fact above is cited by section, figure or table number against these documents, so a reader with the PDFs can check any line without needing the same local copy anybody else used. -The **Armv8-R Architecture Reference Manual supplement** is the source that -would settle open question 1 without hardware, and it was not consulted when -this sheet was written. Obtaining it is the cheapest way to close that question -if the hardware run is delayed. +The **Armv8-R Architecture Reference Manual supplement** was not consulted when +this sheet was written, and both of the questions it would have settled have +since been settled on hardware instead — on two targets, which turned out to +matter: they disagree about `HPFAR`, and no document would have predicted that. + +It is still the right source for one thing this sheet does not have: the +authoritative `ISS` layout for a data abort. The fault status codes above are +measured, and measurement can only confirm the encodings the hardware happened +to produce. diff --git a/docs/decisions.md b/docs/decisions.md index d4ed239..0686949 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -116,6 +116,21 @@ Table 8-3). original entry claimed zero region cost outright, which is true of code and data and false of MMIO.* +*Amended again, 2 September 2026, after the first EL2 image ran on both +targets. The prediction above was right, and the reality was worse than +predicted: the console does not merely need Device attributes eventually, it +needs them **before the hypervisor prints its first line**. Reached through the +background map as Normal memory, LINFlexD_9 gave legible-but-CORRUPTED output +— Normal memory permits gathering and reordering even with caches off, and +that breaks a polled UART's register protocol. The whole identity block arrived +looking like a marginal baud rate, which is a far harder failure to recognise +than no output at all. See D15 for the ordering that follows, and +`docs/armv8r-el2-reference.md` for the measurement.* + +*Also now measured rather than budgeted: ZoneX spends exactly **two** of the +S32Z280's twenty EL2 regions on its own MMIO — the console and the GIC — and +**none** of the FVP's.* + --- ## D3 — What isolates one partition from another · **settled** @@ -143,26 +158,34 @@ before PMSAv8-R's lack of region priority rules it out. --- -## D4 — How the region set is switched · **open until measured on silicon** +## D4 — How the region set is switched · **mechanism proven; cost still open** **Preferred: program every partition's regions once at boot and switch with a single `HPRENR` write.** Fall back to rewriting the region block directly. -The preference depends on two things that are not yet proven. `HPRENR` must -really be 20 bits wide on a 20-region implementation — the TRM contradicts -itself and open question 1 in `docs/armv8r-el2-reference.md` tracks it. And the -whole budget must fit: 20 regions, two partitions, plus whatever the console -and the guest images need. - -The fallback is measured, on the S32Z280 and at EL1, during the Cortex-R52 -Modules port work: a direct region write costs 434–470 cycles, against -542–604 through `PRSELR` for a region ≥ 16. ZoneX avoids that second -number entirely — open question 2 established that `HPRBAR16`–`HPRBAR24` are -directly addressable at `opc1 = 5`, so the high regions cost the same as the -low ones. - -**This is a worst-case-execution-time decision, so it is settled with a -measurement on silicon, not with an argument now.** +*Updated 2 September 2026: the two facts this preference depended on are now +measured, and both came out in its favour.* + +**`HPRENR` really is wider than 16 bits**, on both targets — `0x000FFFFF` +implemented on the S32Z280's 20 regions, exactly TRM Table 3-77, against prose +in the same section claiming regions 0 to 15. And it does not merely accept a +bit above 15, it acts on one: with bit 16 set an EL1 read of the granule region +16 covers succeeds, and with it clear the same read takes a stage-2 fault. That +functional test is the one that mattered — a register that accepted the bit and +ignored it would have passed a read-back test and made a one-write partition +switch silently leave the outgoing partition's regions live. + +**And the high regions are directly addressable**, `opc1 = 5`, confirmed on both +parts by programming region 16 directly and reading it back through the +selection register. So the fallback does not carry the penalty measured at EL1 +during the Cortex-R52 Modules port work for a region ≥ 16 — 542–604 cycles +against 434–470 for a direct write. The whole budget costs the same per region. + +**What is still open is the COST of the `HPRENR` write itself**, and the budget +arithmetic: 20 regions on the S32Z280, minus two for the hypervisor's own MMIO +(D2), leaves 18 for every partition and guest. That is a +worst-case-execution-time decision, so it stays open until it is measured on +silicon rather than argued now. --- @@ -229,10 +252,21 @@ LINFlexD_9 on the S32Z280.** Semihosting is chosen for the model because it needs no peripheral and therefore cannot be broken by a wrong memory map, which makes it the right thing to bring up *before* the MPU is trusted. LINFlexD_9 is what the board has, and what the Cortex-R52 bring-up already -uses. Note the consequence recorded in D2: LINFlexD_9 sits at `0x4298_0000`, -in a Normal-cacheable band of the background map, so it needs a -Device-attributed EL2 region — the console is the first thing that proves the -hypervisor's own MMIO regions are programmed correctly. +uses. + +*Confirmed 2 September 2026, and the consequence is sharper than D2 originally +put it.* Semihosting on the model really is immune to the memory map — it is a +debug trap and touches no memory at all. LINFlexD_9 is the opposite: it sits at +`0x4298_0000`, in a Normal-cacheable band of the background map, and reached +that way it produces **corrupted output rather than no output**, which is the +harder failure to recognise. So the console is not merely the first thing that +*proves* the hypervisor's own MMIO regions are right; on silicon it does not +work until they are. D15 records the ordering that forces. + +One thing this bought that was not expected: because the two backends differ, +the SAME image printing cleanly on both targets is itself evidence about the +region programming. A run whose text arrives intact on the board has +demonstrated a correct Device region, without a separate test for it. **Still open: how two partitions share a console.** That decision arrives when there are two of them, and the options are: @@ -387,3 +421,139 @@ S32Z280 `entry.S` has no `TX_R52_BOOT_AT_EL1` option — the FVP one does — an silicon guest cannot start at EL1 without it. That is the reason the seam is a path to a checkout rather than a released tarball for now, and it will stop being one as soon as that option is upstream. + +--- + +## D15 — The order protection is turned on in · **settled 2 Sep 2026** + +**HMAIR, then the hypervisor's own MMIO regions, then `HSCTLR.BR` and +`HSCTLR.M`, then the console, then the partition regions, then `HCR.VM`.** + +Turning protection on is two steps, not one, and they are separated because the +hypervisor needs the first one *before it can trust its own console*. + +`HSCTLR.M` enables the EL2-controlled MPU, which governs EL2's own accesses. +`HCR.VM` makes that same region set apply to EL0/EL1 as stage 2. The first +image wrote them together, in that order, at the point where the partition +regions were ready — which is the obvious arrangement and is wrong on any board +whose console sits below `0x60000000`. There the console is reached through the +background map as **Normal** memory, and Normal memory reorders and gathers +even with caches off, which corrupts a polled UART's register protocol. The +identity block printed as legible-but-wrong text (D2). + +So the console's Device-attributed region has to exist, and `HSCTLR.M` has to +be set, before anything is printed. Three consequences follow and all three are +implemented rather than noted: + +* **`HMPUIR` is read before any region is written**, because it decides whether + there are enough regions at all and writing `HPRSELR` with a value at or + above the implemented count is UNPREDICTABLE. +* **`zx_stage2_enable` refuses to set `HCR.VM` when `HSCTLR.M` is clear.** That + combination would apply stage 2 against a region set the hardware is not + consulting: the guest would run unprotected while every check appeared to + pass, which is the worst outcome available in this file. +* **The board hook that programs those regions prints nothing.** It runs before + the console is trustworthy, so its output arrives corrupted — which the first + version did, dropping two garbled lines into the middle of an otherwise clean + log and reporting its own diagnosis as a defect. + +The order is identical on both targets even though only one of them needs it. +A program that took a different path on the model would be proving something +about a path the silicon never runs. + +--- + +## D16 — A guest fault comes back to the hypervisor as a VALUE · **settled** + +**`zx_el2_run_payload` saves EL2's own SP, LR and callee-saved registers, ERETs +to EL1, and does not return through that ERET. The trap handler restores that +context and resumes it with a result code, so from C the whole excursion looks +like a call returning `ZX_RUN_YIELDED`, `ZX_RUN_FAULTED` or `ZX_RUN_TRAPPED`.** + +The alternative — decide what to do about the fault in the vector — is how a +small hypervisor usually starts and it does not survive contact with policy. A +handler running on the Hyp stack, with the guest's registers gone and nothing +but `HSR` to go on, has no context in which to decide whether a partition +should be reported, restarted, or simply not scheduled again. Turning the fault +into a return value puts that decision in code that can be read, and it costs +about a dozen instructions. + +It also made this step's own regression possible. Every phase of the stage-2 +probe is an ordinary function call whose outcome is checked, including the +phases that are *expected* to fault, so a fault is a result rather than the end +of the run. Without that, the image could only ever demonstrate one violation — +the last one. + +**The exception is a fault taken FROM Hyp mode**, `EC 0x21` or `0x25`. There is +nothing safe to resume there: ZoneX faulted on its own access, so whatever +invariant the saved context assumed may already be false. Those vectors report +and stop, with their own message and their own exit code, and they are never +folded in with a guest violation — a Phase-0 run that reported a hypervisor bug +as a partition being stopped at its boundary would pass while proving nothing. + +Two `HVC` immediates rather than one, for the same reason: `HVC #0` is the empty +Phase-0 hypercall vector and returns to the guest transparently, `HVC #1` is a +guest handing control back. Counting the first and resuming on the second is +what lets a run distinguish "the payload came back on purpose" from "the +payload was taken from". + +--- + +## D17 — One board per build tree · **settled 2 Sep 2026** + +**`ZX_BUILD_FVP_EXAMPLE` and `ZX_BUILD_S32Z280_EXAMPLE` are mutually exclusive, +and the root `CMakeLists.txt` refuses a configuration that sets both.** + +Two facts about the port are board facts, not target facts: whether the core +arrives at `_start` in T32 state, and whether the console is semihosting or the +board's UART. Both have to reach `platform/cortex_r52/src/*`, and +`target_compile_definitions` on an *executable* cannot do that — `zonex_port` +is a separate static library and a `PRIVATE` definition on an image never +touches it. + +This is recorded as a decision because the mistake is silent. The first version +of the S32Z280 example set both definitions on its own targets, and produced an +image with an A32 entry point and a semihosting console. It configured, it +compiled, it linked, and on the board it would have executed the first halfword +of its own first instruction in the wrong state and gone somewhere arbitrary — +with no output of any kind to say so. + +So the definitions come from the build tree, and the two boards cannot share +one. Every script and workflow in the repository already configures exactly +one; the check exists for the invocation that does not. + +--- + +## D18 — `HDFAR`, not `HPFAR` · **settled 2 Sep 2026, by measurement** + +**ZoneX takes the faulting address of a stage-2 violation from `HDFAR`. +`HPFAR` is reported, and not relied on.** + +Not a preference. The Cortex-R52 TRM describes `HPFAR` two ways in the same +section — Figure 3-32 draws `FIPA[39:12]` at `HPFAR[31:4]`, Table 3-69's row +beside it says "Bits [31:4] of the faulting address" — and **the two ZoneX +targets implement different ones**: + +| Target | `HDFAR` | `HPFAR` | Reading | +|---|---|---|---| +| Armv8-R AEM FVP | `0x00007040` | `0x00000070` | the figure | +| S32Z280-594EVB | `0x31781200` | `0x31781200` | the table row | + +Measured, on 2 September 2026, by faulting on an address the image chose to be +granule-aligned but deliberately not 4 KB-aligned, so that the two readings +could not both fit. The two answers differ by a factor of 256, and each is +plausible on its own — so a hypervisor that computed an address from `HPFAR` +would be quietly wrong on one of its two targets. + +`HDFAR` carries the full faulting virtual address on both. It is also the +register an off-by-one-granule region bug has to be caught with: under the +figure's reading `HPFAR` resolves only to 4 KB and cannot say which 64-byte +granule faulted. + +The fault report prints `HPFAR` raw, computes **both** readings, and names +which one the target it is running on implements. That is deliberate: the +divergence is a fact about the architecture's documentation that the next +person needs, and a report that quietly picked one reading would hide it. +`HPFAR` is also not updated at all for a fault taken from Hyp mode, so the +report omits any reading of it there rather than presenting a stale value as an +address. diff --git a/examples/common/zx_payload.S b/examples/common/zx_payload.S new file mode 100644 index 0000000..082b1cd --- /dev/null +++ b/examples/common/zx_payload.S @@ -0,0 +1,295 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +@ zx_payload.S -- the EL1 payload the stage-2 probe runs. +@ +@ WHAT THIS IS +@ +@ A few dozen instructions that run at EL1 under stage 2, with no library, +@ no stack use worth the name and no console. It is not a guest: step 4 +@ brings a real ThreadX guest. It is the smallest thing that can prove +@ stage 2 grants what it should and denies what it should not. +@ +@ WHY IT DOES NOT PRINT +@ +@ Deliberate. A payload that printed would need an MMIO region of its own +@ at stage 2, which would add a variable to every result and would differ +@ between the model (semihosting, no peripheral) and the board (LINFlexD). +@ Instead it records progress bits in its own data region, and EL2 -- which +@ can always reach a partition's memory, because no AP encoding at EL2 can +@ deny it (docs/decisions.md D3) -- reads them out and reports. +@ +@ WHY POINT 4 BELOW IS NOT OPTIONAL +@ +@ zx_payload_violation sets ATTEMPTED before the access and SURVIVED after +@ it, then yields. If stage 2 works, SURVIVED is never written and control +@ never reaches the yield: EL2 takes the fault instead. If stage 2 does +@ NOT work, the payload comes back and says so. +@ +@ That is the difference between a regression that can report "the payload +@ survived its violation -- it is not protected" and one that can only +@ report "no fault was captured". The second cannot tell a working +@ protection failure from a broken test, which is exactly the trap the +@ Cortex-R52 Modules port work fell into once. +@ +@ SECTIONS +@ +@ .zx_payload_text and .zx_payload_data are placed by the linker script on +@ 64-byte boundaries and sized to whole granules, because those are the two +@ stage-2 regions the probe programs. Anything that leaked out of them +@ would be either unreachable or -- worse -- reachable through a region it +@ was not meant to be in. + +#include "zx_port.h" + + .syntax unified + .arm + +/* Progress bits in zx_payload_result. Mirrored in zx_probe_main.c, which + is the only reader; the two lists are checked against each other by eye + because there is no shared header an assembler and a compiler could both + take them from without inventing one for six constants. The names are + spelled identically in both files so a grep finds the pair. */ + + .equ ZX_PAYLOAD_RAN, (1 << 0) + .equ ZX_PAYLOAD_OWN_DATA_OK, (1 << 1) + .equ ZX_PAYLOAD_HVC_RETURNED, (1 << 2) + .equ ZX_PAYLOAD_PROBE_OK, (1 << 3) + .equ ZX_PAYLOAD_ATTEMPTED, (1 << 4) + .equ ZX_PAYLOAD_SURVIVED, (1 << 5) + + .equ ZX_PAYLOAD_SENTINEL, 0xA5A5A5A5 + + .section .zx_payload_text, "ax" + .balign 64 + +/**************************************************************************/ +/* The payload's own EL1 vector table. */ +/* */ +/* Inside the payload's code region on purpose. EL2 installs it in VBAR */ +/* before the first ERET, so an exception taken at EL1 rather than routed */ +/* to EL2 -- an alignment fault, an undefined instruction -- lands */ +/* somewhere that can report itself instead of somewhere random. Placing */ +/* it in EL2's own code region would have cost either a region or a */ +/* prefetch abort at a confusing address. */ +/* */ +/* Each entry issues a distinct HVC immediate, 0x10 + the vector index, */ +/* which reaches EL2 as an unexpected trap carrying the vector number. */ +/* A hang would say nothing; this says which vector. */ +/**************************************************************************/ + + .balign 32 + .global zx_payload_vectors +zx_payload_vectors: + hvc #0x10 /* 0x00 reset */ + hvc #0x11 /* 0x04 undefined instruction */ + hvc #0x12 /* 0x08 SVC */ + hvc #0x13 /* 0x0C prefetch abort */ + hvc #0x14 /* 0x10 data abort */ + hvc #0x15 /* 0x14 reserved */ + hvc #0x16 /* 0x18 IRQ */ + hvc #0x17 /* 0x1C FIQ */ + +/**************************************************************************/ +/* ZX_PAYLOAD_ENTER -- the two instructions every entry point starts with.*/ +/* */ +/* A stack is set even though nothing here pushes anything. SP_svc is */ +/* UNKNOWN out of an ERET, and an unset SP that is never used is one */ +/* edit away from an unset SP that is. */ +/**************************************************************************/ + + .macro ZX_PAYLOAD_ENTER + ldr sp, =__zx_payload_stack_top + ldr r2, =zx_payload_result + ldr r3, [r2] + orr r3, r3, #ZX_PAYLOAD_RAN + str r3, [r2] + .endm + +/**************************************************************************/ +/* zx_payload_grant_check -- does stage 2 GRANT what it should? */ +/* */ +/* Writes a sentinel into its own data region and reads it back. Both */ +/* halves matter: a write that silently went nowhere and a read that */ +/* returned a stale value are different failures, and only the comparison */ +/* distinguishes them from success. */ +/**************************************************************************/ + + .global zx_payload_grant_check + .type zx_payload_grant_check, %function +zx_payload_grant_check: + ZX_PAYLOAD_ENTER + + ldr r0, =zx_payload_scratch + ldr r1, =ZX_PAYLOAD_SENTINEL + str r1, [r0] + dsb + ldr r0, [r0] + cmp r0, r1 + bne zx_payload_grant_done + + ldr r3, [r2] + orr r3, r3, #ZX_PAYLOAD_OWN_DATA_OK + str r3, [r2] + +zx_payload_grant_done: + dsb + hvc #ZX_ASM_HVC_IMM_YIELD + .size zx_payload_grant_check, . - zx_payload_grant_check + +/**************************************************************************/ +/* zx_payload_hvc_check -- is the Phase-0 hypercall vector live? */ +/* */ +/* HVC #0 is the empty vector: EL2 counts it and returns here. Reaching */ +/* the instruction after it is the proof, and it is proof of something */ +/* the fault paths cannot show -- that EL2 can be entered and LEFT again */ +/* without the guest's state being disturbed. */ +/**************************************************************************/ + + .global zx_payload_hvc_check + .type zx_payload_hvc_check, %function +zx_payload_hvc_check: + ZX_PAYLOAD_ENTER + + hvc #ZX_ASM_HVC_IMM_NOP + + ldr r2, =zx_payload_result + ldr r3, [r2] + orr r3, r3, #ZX_PAYLOAD_HVC_RETURNED + str r3, [r2] + dsb + hvc #ZX_ASM_HVC_IMM_YIELD + .size zx_payload_hvc_check, . - zx_payload_hvc_check + +/**************************************************************************/ +/* zx_payload_probe_read -- read the address in r0. */ +/* */ +/* Used twice against the SAME address, once with the covering region */ +/* enabled and once with it disabled, which is how the HPRENR width */ +/* question is answered FUNCTIONALLY rather than by reading a register */ +/* back. A register that accepts a bit above 15 and ignores it would */ +/* pass a read-back test and fail this one, and that is the outcome worth */ +/* catching: it would make a one-write partition switch silently wrong. */ +/* */ +/* PROBE_OK is cleared on entry so that the second run cannot inherit the */ +/* first run's success. */ +/**************************************************************************/ + + .global zx_payload_probe_read + .type zx_payload_probe_read, %function +zx_payload_probe_read: + ZX_PAYLOAD_ENTER + + ldr r3, [r2] + bic r3, r3, #(ZX_PAYLOAD_PROBE_OK) + str r3, [r2] + dsb + + ldr r1, [r0] @ may take a stage-2 fault + + ldr r0, =zx_payload_probe_value + str r1, [r0] + + ldr r3, [r2] + orr r3, r3, #ZX_PAYLOAD_PROBE_OK + str r3, [r2] + dsb + hvc #ZX_ASM_HVC_IMM_YIELD + .size zx_payload_probe_read, . - zx_payload_probe_read + +/**************************************************************************/ +/* zx_payload_violation -- write to the address in r0, which must fault. */ +/* */ +/* ATTEMPTED is set and flushed BEFORE the access, so that a run which */ +/* faults can still prove the payload got as far as trying. SURVIVED is */ +/* set after it, and must never be observed set. See the file header. */ +/**************************************************************************/ + + .global zx_payload_violation + .type zx_payload_violation, %function +zx_payload_violation: + ZX_PAYLOAD_ENTER + + ldr r3, [r2] + orr r3, r3, #ZX_PAYLOAD_ATTEMPTED + str r3, [r2] + dsb + + ldr r1, =ZX_PAYLOAD_SENTINEL + str r1, [r0] @ must take a stage-2 fault + + ldr r3, [r2] + orr r3, r3, #ZX_PAYLOAD_SURVIVED + str r3, [r2] + dsb + hvc #ZX_ASM_HVC_IMM_YIELD + .size zx_payload_violation, . - zx_payload_violation + + .balign 64 + +/**************************************************************************/ +/* The payload's data region. */ +/* */ +/* Loaded rather than NOLOAD, with explicit initialisers, so that nothing */ +/* has to zero it: the reset path zeroes ZoneX's .bss, and a payload area */ +/* that depended on that would depend on the payload living inside the */ +/* hypervisor's own bss -- which is exactly what stage 2 must keep it out */ +/* of. */ +/**************************************************************************/ + + .section .zx_payload_data, "aw" + .balign 64 + + .global zx_payload_result + .type zx_payload_result, %object +zx_payload_result: + .word 0 + .size zx_payload_result, . - zx_payload_result + + .global zx_payload_scratch + .type zx_payload_scratch, %object +zx_payload_scratch: + .word 0 + .size zx_payload_scratch, . - zx_payload_scratch + + .global zx_payload_probe_value + .type zx_payload_probe_value, %object +zx_payload_probe_value: + .word 0 + .size zx_payload_probe_value, . - zx_payload_probe_value + + .balign 8 + .space 256 + .global __zx_payload_stack_top +__zx_payload_stack_top: + +/**************************************************************************/ +/* The granule region 16 covers. */ +/* */ +/* Its own section, so that the linker script can put it on a granule */ +/* boundary well away from anything else: a region that overlapped */ +/* another enabled region would be CONSTRAINED UNPREDICTABLE, and on the */ +/* S32Z280 it aborts. The sentinel is what makes the read a real check */ +/* rather than a liveness test -- a read that succeeded against the wrong */ +/* memory would otherwise look identical to one that succeeded. */ +/**************************************************************************/ + + .section .zx_high_probe, "aw" + .balign 64 + .global zx_high_probe_word + .type zx_high_probe_word, %object +zx_high_probe_word: + .word 0x5A5AC0DE + .size zx_high_probe_word, . - zx_high_probe_word diff --git a/examples/common/zx_probe.h b/examples/common/zx_probe.h new file mode 100644 index 0000000..972cdcf --- /dev/null +++ b/examples/common/zx_probe.h @@ -0,0 +1,139 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* EXAMPLE DESCRIPTION */ +/* */ +/* zx_probe.h SHARED */ +/* */ +/* DESCRIPTION */ +/* */ +/* The seam between the stage-2 probe image and the board it runs on. */ +/* */ +/* The probe itself -- zx_probe_main.c and zx_payload.S -- is shared */ +/* between the Armv8-R AEM FVP and the S32Z280-594EVB deliberately. */ +/* The whole value of running it on both is that the SAME program */ +/* answers the same questions differently, and two copies of it would */ +/* let the two answers diverge for reasons that had nothing to do with */ +/* the hardware. */ +/* */ +/* What each board supplies is below, and it is exactly the set of */ +/* things that genuinely differ. */ +/* */ +/**************************************************************************/ + +#ifndef ZX_PROBE_H +#define ZX_PROBE_H + +#include "zx_port.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* Region indices the probe uses. Fixed rather than allocated, because the + whole point of ZX_REGION_HIGH_PROBE being 16 is that 16 is the first index + where the TRM contradicts itself about how a region can be reached. */ + +#define ZX_REGION_PAYLOAD_CODE 0U +#define ZX_REGION_PAYLOAD_DATA 1U +#define ZX_REGION_BOARD_FIRST 2U +#define ZX_REGION_HIGH_PROBE 16U + +/* Progress bits the payload records in zx_payload_result. Mirrored from + zx_payload.S, spelled identically so that a grep finds both. */ + +#define ZX_PAYLOAD_RAN 0x01U +#define ZX_PAYLOAD_OWN_DATA_OK 0x02U +#define ZX_PAYLOAD_HVC_RETURNED 0x04U +#define ZX_PAYLOAD_PROBE_OK 0x08U +#define ZX_PAYLOAD_ATTEMPTED 0x10U +#define ZX_PAYLOAD_SURVIVED 0x20U + +#define ZX_PAYLOAD_SENTINEL 0xA5A5A5A5U +#define ZX_HIGH_PROBE_SENTINEL 0x5A5AC0DEU + +/**************************************************************************/ +/* What each board must supply */ +/**************************************************************************/ + +/* A name for the report, so a captured log says which target produced it. + Defined by each board's zx_platform.h. */ + +/* Bring up whatever has to exist before the first line is printed. Nothing + on the model, where semihosting needs no peripheral; the console on + silicon. */ + +void zx_board_init(void); + +/* How many EL2 regions the hypervisor's own MMIO needs on this board, and + the code that programs them starting at first_index. + + This is the shape docs/decisions.md D2 forces. HSCTLR.BR=1 costs no + region for ordinary hypervisor code and data, but BR grants PERMISSION, + not ATTRIBUTES: the background map is Normal cacheable below 0x60000000, + so a device the hypervisor owns down there is reachable with the wrong + attributes and still needs a Device-attributed region. On the S32Z280 + that is the console at 0x42980000 and the GIC. On the FVP both sit in the + Device band, so the count is zero and THE MODEL CANNOT SHOW YOU THAT THE + FUNCTION IS NEEDED. It exists so that the model's answer and the board's + answer come out of the same program. */ + +ZX_NODISCARD uint32_t zx_board_mmio_region_count(void); +void zx_board_program_mmio_regions(uint32_t first_index); + +/* Print whatever the board knows about itself that the architecture cannot + report -- which peripheral the console is, which memory the image is in. + Called from the identity block. */ + +void zx_board_report(void); + +/**************************************************************************/ +/* Symbols the linker script defines */ +/**************************************************************************/ + +/* Declared as arrays of unknown size, which is the portable way to name a + linker symbol whose ADDRESS is the value wanted. Declaring one as a + uint32_t and taking &symbol works too and reads as though the symbol had + contents, which these do not. */ + +extern char __zx_payload_code_start[]; +extern char __zx_payload_code_end[]; +extern char __zx_payload_data_start[]; +extern char __zx_payload_data_end[]; +extern char __zx_forbidden_start[]; +extern char __zx_forbidden_end[]; +extern char __zx_high_probe_start[]; +extern char __zx_high_probe_end[]; + +/* Payload entry points and the words it reports through, from zx_payload.S. */ + +extern char zx_payload_vectors[]; +extern char zx_payload_grant_check[]; +extern char zx_payload_hvc_check[]; +extern char zx_payload_probe_read[]; +extern char zx_payload_violation[]; + +extern uint32_t zx_payload_result; +extern uint32_t zx_payload_scratch; +extern uint32_t zx_payload_probe_value; +extern uint32_t zx_high_probe_word; + +#ifdef __cplusplus +} +#endif + +#endif /* ZX_PROBE_H */ diff --git a/examples/common/zx_probe_main.c b/examples/common/zx_probe_main.c new file mode 100644 index 0000000..4b56372 --- /dev/null +++ b/examples/common/zx_probe_main.c @@ -0,0 +1,1004 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* EXAMPLE DESCRIPTION */ +/* */ +/* zx_probe_main.c SHARED */ +/* */ +/* DESCRIPTION */ +/* */ +/* Stage-2 first light. One EL2 program, one trivial EL1 payload, and */ +/* one deliberate fault. */ +/* */ +/* What this image is for, in one sentence: an EL2-resident ZoneX */ +/* programs a stage-2 MPU region, drops to EL1, and a stage-2 violation */ +/* by the EL1 payload is taken to EL2, decoded, and reported by name. */ +/* Nothing else in ZoneX is worth building until that works, because */ +/* everything else assumes it. */ +/* */ +/* It also settles four things that were open before it ran, and each */ +/* of them changes the design of what comes next: */ +/* */ +/* 1. Does the target implement an EL2 MPU at all, and how many */ +/* regions? HMPUIR may legally read 0. */ +/* 2. Is HPRENR really wider than 16 bits? Answered FUNCTIONALLY, */ +/* by disabling a region above 15 and proving the memory it */ +/* covered stops being reachable -- not by reading the register */ +/* back, which a register that accepted the bit and ignored it */ +/* would pass. */ +/* 3. Can a region above 15 be reached DIRECTLY, at opc1 = 5? If it */ +/* can, the EL2 MPU escapes the selection-register penalty */ +/* measured at EL1, which is a worst-case-execution-time fact. */ +/* 4. What exactly is HPFAR's relationship to the faulting address? */ +/* Measured against an address this image chose itself. */ +/* */ +/* WHAT A PASS MEANS, AND WHAT IT DOES NOT */ +/* */ +/* A pass on the Armv8-R AEM FVP proves the code is right. It proves */ +/* NOTHING about the region budget or the MMIO attributes on a real */ +/* part: the model reports 32 EL2 regions, which no Cortex-R52 can */ +/* have, and its console and GIC sit in the Device band of the */ +/* background map where the S32Z280's do not. Both of those are said */ +/* out loud in the image's own output rather than left in a document. */ +/* */ +/* On lockstep silicon this image demonstrates memory isolation on ONE */ +/* logical core. It does not demonstrate spatial multicore */ +/* partitioning. */ +/* */ +/* MISRA C:2012 deviations (justified) */ +/* */ +/* Rule 11.4/11.6 -- casting a linker symbol's address to an integer */ +/* address is inherent to describing memory to an MPU. Confined to */ +/* zx_symbol_address below. */ +/* */ +/**************************************************************************/ + +#include "zx_probe.h" +#include "zx_platform.h" + +/* How many checks failed. Accumulated rather than returned, because the + image reports EVERY check and then its verdict: stopping at the first + failure would hide the identity block that explains it. */ + +static uint32_t zx_failures; + +/* Region 16's descriptor is kept because two phases need it -- the enabled + probe and, when the deliberate EL2 fault is built in, a reprogramming of + the same region with EL2 read-only permissions. */ + +static zx_region_t zx_high_probe_region; + + +/**************************************************************************/ +/* zx_symbol_address -- a linker symbol's address as an MPU address. */ +/**************************************************************************/ + +static zx_addr_t zx_symbol_address(const char *symbol) +{ + return (zx_addr_t)(uintptr_t)symbol; +} + + +/**************************************************************************/ +/* zx_report / zx_check -- one labelled line each. */ +/* */ +/* Fixed-width labels so that a captured log lines up and a missing */ +/* result is visible as a gap rather than having to be counted. */ +/**************************************************************************/ + +static void zx_check(const char *label, uint32_t passed) +{ + zx_console_puts(" ["); + zx_console_puts((passed != 0U) ? "PASS" : "FAIL"); + zx_console_puts("] "); + zx_console_puts(label); + zx_console_puts("\n"); + + if (passed == 0U) + { + zx_failures++; + } +} + + +static void zx_note(const char *label, uint32_t value) +{ + zx_console_puts(" "); + zx_console_puts(label); + zx_console_puts(" = "); + zx_console_puthex(value); + zx_console_puts("\n"); +} + + +/**************************************************************************/ +/* zx_identity_block -- T2. */ +/* */ +/* Reads and prints what the core says about itself, then ASSERTS the */ +/* parts that a later step would otherwise assume. The region count is */ +/* the one that matters: refusing to enable stage 2 when the count is too */ +/* low is the difference between a clear message and a hang, and it is */ +/* exactly what the EL1 MPU initialisation already does in the */ +/* Cortex-R52 port. */ +/**************************************************************************/ + +static uint32_t zx_identity_block(uint32_t regions_required) +{ + uint32_t el2_regions = zx_el2_region_count(); + uint32_t el1_regions = zx_el1_region_count(); + uint32_t ich_vtr = zx_read_ich_vtr(); + uint32_t cpsr = zx_read_cpsr(); + uint32_t midr = zx_read_midr(); + + zx_console_puts("\n--- identity, as the core reports it ---\n"); + + zx_note("MIDR ", midr); + zx_console_puts(" part "); + zx_console_puthex((midr >> 4U) & 0xFFFU); + zx_console_puts(" (0xD13 = Cortex-R52, 0xD16 = Cortex-R52+)\n"); + + zx_note("MPIDR ", zx_read_mpidr()); + zx_note("CPSR (now) ", cpsr); + zx_console_puts(" mode "); + zx_console_puthex(cpsr & ZX_MODE_MASK); + zx_console_puts((( cpsr & ZX_MODE_MASK) == ZX_MODE_HYP) + ? " = Hyp, which is EL2\n" : " = NOT Hyp\n"); + + zx_note("CPSR at reset", zx_reset_cpsr); + zx_console_puts(" read by the FIRST instruction executed. " + "Its mode\n" + " field is the useful part; PSTATE.T is NOT " + "readable\n" + " through MRS and always reads 0 here, on " + "both a\n" + " target that starts in A32 and one that " + "starts in T32.\n" + " Use HSCTLR.TE and SCTLR.TE below, and a " + "debugger.\n"); + zx_note("HSCTLR reset ", zx_reset_hsctlr); + zx_console_puts(" TE at reset was "); + zx_console_puts(((zx_reset_hsctlr & ZX_HSCTLR_TE) != 0U) ? "SET" : "clear"); + zx_console_puts(" -- set means EL2 exceptions are\n" + " entered in T32 and an A32 vector table " + "never runs\n"); + zx_note("SCTLR reset ", zx_reset_sctlr); + zx_note("HSCTLR (now) ", zx_read_hsctlr()); + zx_note("HCR (now) ", zx_read_hcr()); + zx_note("HSR (now) ", zx_read_hsr()); + zx_note("HVBAR ", zx_read_hvbar()); + zx_note("CNTFRQ ", zx_read_cntfrq()); + zx_note("HPRENR reset ", zx_reset_hprenr); + zx_note("HPRENR bits ", zx_hprenr_implemented_bits); + zx_console_puts(" what stuck when all ones were written " + "before any\n" + " region existed, so these are the " + "IMPLEMENTED enable\n" + " bits. Whether one above 15 really " + "disables its\n" + " region is tested functionally further " + "down.\n"); + zx_note("ICH_VTR ", ich_vtr); + zx_console_puts(" ListRegs field "); + zx_console_putdec(ich_vtr & 0x1FU); + zx_console_puts(", so "); + zx_console_putdec((ich_vtr & 0x1FU) + 1U); + zx_console_puts(" List Registers -- interrupt\n" + " injection is available on this part\n"); + + zx_board_report(); + + /* ---------------------------------------------------------------- */ + /* The region budget. Both numbers, side by side, always. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n--- the EL2 region budget ---\n"); + zx_note("MPUIR ", zx_read_mpuir()); + zx_note("HMPUIR ", zx_read_hmpuir()); + + zx_console_puts(" EL1 regions (MPUIR[15:8]) = "); + zx_console_putdec(el1_regions); + zx_console_puts("\n EL2 regions (HMPUIR[7:0]) = "); + zx_console_putdec(el2_regions); + zx_console_puts("\n EL2 regions this image NEEDS = "); + zx_console_putdec(regions_required); + zx_console_puts("\n"); + + if (zx_el2_region_count_is_legal(el2_regions) == 0U) + { + zx_console_puts( + "\n *** HMPUIR reports "); + zx_console_putdec(el2_regions); + zx_console_puts(" EL2 regions. TRM Table 3-79 permits only\n" + " *** 0, 16, 20 or 24, so THIS TARGET IS NOT AN ARCHITECTURALLY\n" + " *** LEGAL Cortex-R52 CONFIGURATION. That is expected of the\n" + " *** Armv8-R AEM FVP, which reported 32 EL1 regions during the\n" + " *** Cortex-R52 port work as well. It is recorded rather than\n" + " *** failed, and it is the reason a green run of this image on\n" + " *** the model says NOTHING about any real part's budget.\n"); + } + else + { + zx_check("HMPUIR reports an architecturally legal region count", 1U); + } + + if (el2_regions == 0U) + { + zx_console_puts( + "\n *** HMPUIR reports ZERO EL2 regions. That is a legal\n" + " *** configuration -- \"no EL2-controlled MPU\" -- and it means\n" + " *** this target cannot host stage 2 at all. Refusing to go on.\n"); + return 0U; + } + + if (el2_regions < regions_required) + { + zx_console_puts( + "\n *** This target has fewer EL2 regions than this image needs.\n" + " *** REFUSING to enable stage 2: programming a region that does\n" + " *** not exist is UNPREDICTABLE, and the failure would present\n" + " *** as a hang rather than as a message.\n"); + return 0U; + } + + return 1U; +} + + +/**************************************************************************/ +/* zx_program_regions -- T4. */ +/* */ +/* The smallest honest region set: the payload's code, the payload's */ +/* data, whatever MMIO the hypervisor itself owns on this board, and the */ +/* granule region 16 covers for the high-region experiments. */ +/* */ +/* What is deliberately NOT here is a region for the granule immediately */ +/* after the payload's data. An address far away from every grant proves */ +/* only that unmapped memory faults; the defect class worth catching is a */ +/* limit or a base off by ONE GRANULE, and only an adjacent hole catches */ +/* it. */ +/**************************************************************************/ + +static void zx_program_regions(uint32_t board_regions) +{ + zx_region_t region; + uint32_t direct_base = 0U; + uint32_t direct_limit = 0U; + uint32_t select_base = 0U; + uint32_t select_limit = 0U; + + zx_console_puts("\n--- programming stage 2 ---\n"); + + /* HMAIR was programmed before the console, along with the hypervisor's + own MMIO regions; the values are reported here, where they can be read + next to the regions that index them. */ + + zx_note("HMAIR0 (already written)", ZX_HMAIR0_VALUE); + zx_note("HMAIR1 (already written)", ZX_HMAIR1_VALUE); + + /* Region 0: the payload's code. Executable, and readable so its own + literal pool works. AP grants EL1 read/write, which is more than + code needs -- but there is no EL2 AP encoding that is read-only to a + guest and writable to EL2, and the loader needs to be able to write + it. Write protection of guest code is a stage-1 job, and stage 1 is + deliberately off in this image. */ + + region.zx_region_base = zx_symbol_address(__zx_payload_code_start); + region.zx_region_limit = zx_symbol_address(__zx_payload_code_end) - 1U; + region.zx_region_ap = ZX_AP_EL2_RW_GUEST_RW; + region.zx_region_xn = ZX_XN_EXECUTABLE; + region.zx_region_sh = ZX_SH_NON_SHAREABLE; + region.zx_region_attrindx = ZX_ATTR_NORMAL_WB; + zx_stage2_region_program(ZX_REGION_PAYLOAD_CODE, ®ion); + + zx_console_puts(" region 0 payload code "); + zx_console_puthex(region.zx_region_base); + zx_console_puts(" .. "); + zx_console_puthex(region.zx_region_limit); + zx_console_puts(" RW, executable\n"); + + /* Region 1: the payload's data. Execute-never. */ + + region.zx_region_base = zx_symbol_address(__zx_payload_data_start); + region.zx_region_limit = zx_symbol_address(__zx_payload_data_end) - 1U; + region.zx_region_ap = ZX_AP_EL2_RW_GUEST_RW; + region.zx_region_xn = ZX_XN_NEVER; + region.zx_region_sh = ZX_SH_NON_SHAREABLE; + region.zx_region_attrindx = ZX_ATTR_NORMAL_WB; + zx_stage2_region_program(ZX_REGION_PAYLOAD_DATA, ®ion); + + zx_console_puts(" region 1 payload data "); + zx_console_puthex(region.zx_region_base); + zx_console_puts(" .. "); + zx_console_puthex(region.zx_region_limit); + zx_console_puts(" RW, execute-never\n"); + + /* The board's own MMIO, already programmed before the console. */ + + if (board_regions > 0U) + { + zx_console_puts(" regions "); + zx_console_putdec(ZX_REGION_BOARD_FIRST); + zx_console_puts(".."); + zx_console_putdec((ZX_REGION_BOARD_FIRST + board_regions) - 1U); + zx_console_puts(" the hypervisor's own MMIO, Device attributes,\n" + " programmed before this console was used\n"); + } + else + { + zx_console_puts(" no hypervisor MMIO regions on this target: its\n" + " console and GIC fall in the Device band of the\n" + " background map, so HSCTLR.BR serves them. A board\n" + " whose devices sit below 0x60000000 needs regions\n" + " here and THIS TARGET CANNOT SHOW YOU THAT.\n"); + } + + /* Region 16: the high-region probe. Programmed through the DIRECT + encoding at opc1 = 5, then read back through HPRSELR/HPRBAR -- the + other path -- so that agreement between them is what settles whether + TRM 8.4 or TRM 3.3.48's prose is right about direct access above + region 15. */ + + zx_high_probe_region.zx_region_base = + zx_symbol_address(__zx_high_probe_start); + zx_high_probe_region.zx_region_limit = + zx_symbol_address(__zx_high_probe_end) - 1U; + zx_high_probe_region.zx_region_ap = ZX_AP_EL2_RW_GUEST_RW; + zx_high_probe_region.zx_region_xn = ZX_XN_NEVER; + zx_high_probe_region.zx_region_sh = ZX_SH_NON_SHAREABLE; + zx_high_probe_region.zx_region_attrindx = ZX_ATTR_NORMAL_WB; + + zx_stage2_region_program_direct16(&zx_high_probe_region); + + zx_console_puts(" region 16 high probe "); + zx_console_puthex(zx_high_probe_region.zx_region_base); + zx_console_puts(" .. "); + zx_console_puthex(zx_high_probe_region.zx_region_limit); + zx_console_puts(" written DIRECTLY, opc1 = 5\n"); + + zx_stage2_region_read_direct16(&direct_base, &direct_limit); + zx_stage2_region_read(ZX_REGION_HIGH_PROBE, &select_base, &select_limit); + + zx_console_puts("\n--- open question: direct access above region 15 ---\n"); + zx_note("HPRBAR16 via opc1 = 5 ", direct_base); + zx_note("HPRBAR16 via HPRSELR ", select_base); + zx_note("HPRLAR16 via opc1 = 5 ", direct_limit); + zx_note("HPRLAR16 via HPRSELR ", select_limit); + + zx_check("HPRBAR16/HPRLAR16 read the same through both access paths, so\n" + " a region above 15 is directly addressable at opc1 = 5\n" + " (TRM 8.4 is right and 3.3.48's prose is stale)", + ((direct_base == select_base) && (direct_limit == select_limit)) + ? 1U : 0U); + + zx_note("HPRENR after programming", zx_hprenr_read()); + + /* The granule that is deliberately NOT covered. Printed so that a + reader of a captured log can see it is adjacent to region 1 rather + than having to take that on trust. */ + + zx_console_puts("\n ungranted granule (no region covers it) "); + zx_console_puthex(zx_symbol_address(__zx_forbidden_start)); + zx_console_puts(" .. "); + zx_console_puthex(zx_symbol_address(__zx_forbidden_end) - 1U); + zx_console_puts("\n it begins at the byte after region 1's limit, which\n" + " is what makes an off-by-one-granule region bug visible\n"); +} + + +/**************************************************************************/ +/* zx_run_phase -- one excursion to EL1, with its outcome named. */ +/**************************************************************************/ + +static uint32_t zx_run_phase(const char *name, char *entry, uint32_t argument) +{ + uint32_t outcome; + + zx_console_puts("\n--- EL1 phase: "); + zx_console_puts(name); + zx_console_puts(" ---\n"); + + outcome = zx_el2_run_payload(zx_symbol_address(entry), argument); + + zx_console_puts(" the payload came back as "); + switch (outcome) + { + case ZX_RUN_YIELDED: + zx_console_puts("ZX_RUN_YIELDED (HVC #1)\n"); + break; + case ZX_RUN_FAULTED: + zx_console_puts("ZX_RUN_FAULTED (a stage-2 violation)\n"); + break; + case ZX_RUN_TRAPPED: + zx_console_puts("ZX_RUN_TRAPPED (something else reached EL2)\n"); + break; + default: + zx_console_puts("an UNKNOWN outcome, which is a ZoneX bug\n"); + break; + } + + zx_note("zx_payload_result", zx_payload_result); + + return outcome; +} + + +/**************************************************************************/ +/* zx_phase_grant -- stage 2 grants what it should. */ +/**************************************************************************/ + +static void zx_phase_grant(void) +{ + uint32_t outcome = zx_run_phase("does stage 2 GRANT what it should?", + zx_payload_grant_check, 0U); + + zx_check("the payload reached EL1 and ran", + ((zx_payload_result & ZX_PAYLOAD_RAN) != 0U) ? 1U : 0U); + zx_check("the payload wrote and read back its own data region", + ((zx_payload_result & ZX_PAYLOAD_OWN_DATA_OK) != 0U) ? 1U : 0U); + zx_check("the payload yielded rather than faulting", + (outcome == ZX_RUN_YIELDED) ? 1U : 0U); + + /* The EXACT value, not just the bits of interest. The progress bits are + spelled in two places -- ZX_PAYLOAD_* here and .equ directives in + zx_payload.S -- because no header can be read by both an assembler and + a compiler without inventing one for six constants. Checking the whole + word after a phase whose outcome is fully known turns that duplication + from a comment asking for care into a test: a bit renumbered on one + side and not the other fails here, on both targets, in the first + phase. */ + + zx_check("zx_payload_result is EXACTLY the two bits this phase sets, which\n" + " is what keeps the progress bits in zx_payload.S and in\n" + " zx_probe.h from drifting apart", + (zx_payload_result + == (ZX_PAYLOAD_RAN | ZX_PAYLOAD_OWN_DATA_OK)) ? 1U : 0U); + + zx_note("zx_payload_scratch, read from EL2", zx_payload_scratch); + zx_check("EL2 can read the partition's memory, which no AP encoding at\n" + " EL2 can deny it (docs/decisions.md D3)", + (zx_payload_scratch == ZX_PAYLOAD_SENTINEL) ? 1U : 0U); +} + + +/**************************************************************************/ +/* zx_phase_hypercall -- the empty Phase-0 hypercall vector. */ +/**************************************************************************/ + +static void zx_phase_hypercall(void) +{ + uint32_t before = zx_el2_hypercall_count(); + uint32_t outcome; + + outcome = zx_run_phase("is the HVC seam live and transparent?", + zx_payload_hvc_check, 0U); + + zx_console_puts(" HVC #0 count before/after = "); + zx_console_putdec(before); + zx_console_puts(" / "); + zx_console_putdec(zx_el2_hypercall_count()); + zx_console_puts("\n"); + + zx_check("HVC #0 was counted at EL2", + (zx_el2_hypercall_count() == (before + 1U)) ? 1U : 0U); + zx_check("HVC #0 RETURNED to the payload, so EL2 can be entered and left\n" + " without disturbing the guest", + ((zx_payload_result & ZX_PAYLOAD_HVC_RETURNED) != 0U) ? 1U : 0U); + zx_check("the payload yielded rather than faulting", + (outcome == ZX_RUN_YIELDED) ? 1U : 0U); +} + + +/**************************************************************************/ +/* zx_phase_hprenr -- T3, the HPRENR width question, answered */ +/* functionally. */ +/* */ +/* The same address is read twice: once with region 16 enabled and once */ +/* with HPRENR bit 16 cleared. If bits above 15 are read/write but */ +/* ignored -- the worst possible outcome, and the one a read-back test */ +/* would miss -- the second read succeeds and this reports it. */ +/**************************************************************************/ + +static void zx_phase_hprenr(uint32_t el2_regions) +{ + zx_addr_t probe_address = zx_symbol_address(__zx_high_probe_start); + uint32_t outcome; + uint32_t enr_before; + uint32_t enr_after; + + zx_console_puts("\n=========================================================\n" + " OPEN QUESTION: is HPRENR wider than 16 bits?\n" + "=========================================================\n"); + + if (el2_regions <= ZX_REGION_HIGH_PROBE) + { + zx_console_puts( + " SKIPPED. This target has "); + zx_console_putdec(el2_regions); + zx_console_puts(" EL2 regions, so index 16 does not\n" + " exist here and the question cannot be asked on it. This is a\n" + " legal configuration, not a failure -- but the question stays\n" + " OPEN, and D4's one-write partition switch stays unproven.\n"); + return; + } + + /* With the region enabled, the read must succeed and must return the + sentinel. Returning the right VALUE is what distinguishes a region + that maps the intended memory from one that merely permits an + access. */ + + enr_before = zx_hprenr_read(); + zx_note("HPRENR with region 16 enabled ", enr_before); + + outcome = zx_run_phase("read the granule region 16 covers, ENABLED", + zx_payload_probe_read, probe_address); + + zx_note("value the payload read", zx_payload_probe_value); + zx_check("with HPRENR bit 16 SET, the payload can read the granule", + ((outcome == ZX_RUN_YIELDED) + && ((zx_payload_result & ZX_PAYLOAD_PROBE_OK) != 0U)) ? 1U : 0U); + zx_check("and it read the SENTINEL, so region 16 maps the memory it was\n" + " programmed for rather than merely permitting an access", + (zx_payload_probe_value == ZX_HIGH_PROBE_SENTINEL) ? 1U : 0U); + + /* Now clear bit 16 and read the same address again. */ + + zx_hprenr_disable(ZX_REGION_HIGH_PROBE); + enr_after = zx_hprenr_read(); + zx_note("HPRENR with bit 16 cleared ", enr_after); + + zx_check("clearing bit 16 of HPRENR changed the register, so the bit is\n" + " at least WRITABLE above 15", + (enr_after != enr_before) ? 1U : 0U); + + outcome = zx_run_phase("read the same granule, region 16 DISABLED", + zx_payload_probe_read, probe_address); + + if ((outcome == ZX_RUN_YIELDED) + && ((zx_payload_result & ZX_PAYLOAD_PROBE_OK) != 0U)) + { + zx_console_puts( + "\n *** THE READ SUCCEEDED WITH THE REGION DISABLED.\n" + " *** HPRENR accepted a bit above 15 and IGNORED it. That is\n" + " *** the worst of the three possible answers: a read-back test\n" + " *** would have passed, and a partition switch implemented as a\n" + " *** single HPRENR write would silently leave the outgoing\n" + " *** partition's regions live. docs/decisions.md D4 must not\n" + " *** take the one-write option on this target.\n"); + zx_check("HPRENR bit 16 actually disables region 16", 0U); + } + else + { + zx_check("HPRENR bit 16 actually DISABLES region 16, so HPRENR is\n" + " wider than 16 bits on this target and TRM 3.3.46's\n" + " prose is stale where its bit tables are right", + (outcome == ZX_RUN_FAULTED) ? 1U : 0U); + zx_fault_report(zx_el2_fault_record()); + } + + /* Put it back, so that later phases start from a known region set. */ + + zx_hprenr_enable(ZX_REGION_HIGH_PROBE); + zx_note("HPRENR restored ", zx_hprenr_read()); +} + + +/**************************************************************************/ +/* zx_phase_violation -- T5, and the point of the whole image. */ +/* */ +/* ZX_PROBE_NEGATIVE_TEST aims the same write at an address the payload */ +/* IS granted. The payload then survives, reports SURVIVED, and this */ +/* image reports FAILED -- which is how the regression is proved able to */ +/* fail. A regression that has never been seen to fail is not evidence, */ +/* and it is kept as a build option rather than as a temporary edit so */ +/* that the demonstration stays reproducible. */ +/**************************************************************************/ + +static void zx_phase_violation(void) +{ +#ifdef ZX_PROBE_NEGATIVE_TEST + + /* zx_payload_scratch, not __zx_payload_data_start. The first word of the + payload's data region IS zx_payload_result, so aiming the write there + overwrote the progress bits with the sentinel and the run then reported + that the payload had never attempted the access -- a true statement + about a word that had been scribbled on, and a misleading one about + what happened. Aiming at the scratch word instead leaves the progress + bits alone, and nothing later in the run reads scratch. */ + + zx_addr_t target = (zx_addr_t)(uintptr_t)&zx_payload_scratch; + uint32_t expect_fault = 0U; +#else + zx_addr_t target = zx_symbol_address(__zx_forbidden_start); + uint32_t expect_fault = 1U; +#endif + uint32_t outcome; + const zx_fault_record_t *record; + + zx_console_puts("\n=========================================================\n" + " THE DEMONSTRANDUM: a stage-2 violation, taken to EL2\n" + "=========================================================\n"); + +#ifdef ZX_PROBE_NEGATIVE_TEST + zx_console_puts( + " NEGATIVE TEST BUILD. The write below is aimed at an address the\n" + " payload IS granted, so it must SUCCEED and this run must report\n" + " FAILED. That is the point: it proves the check can fail.\n" + "\n" + " Expected here: ATTEMPTED set, SURVIVED set, two failing checks,\n" + " no stage-2 fault from this phase, and ZONEX RESULT: FAILED. The\n" + " CTest entry for this build carries WILL_FAIL, so this build\n" + " starting to PASS -- meaning the violation stopped being detected\n" + " -- fails the suite.\n"); +#endif + + zx_console_puts(" target address "); + zx_console_puthex(target); + zx_console_puts("\n"); + + outcome = zx_run_phase("write to the ungranted granule", zx_payload_violation, + target); + record = zx_el2_fault_record(); + + zx_check("the payload got as far as attempting the access", + ((zx_payload_result & ZX_PAYLOAD_ATTEMPTED) != 0U) ? 1U : 0U); + + if ((zx_payload_result & ZX_PAYLOAD_SURVIVED) != 0U) + { + zx_console_puts( + "\n *** THE PAYLOAD SURVIVED ITS VIOLATION. It wrote to memory\n" + " *** stage 2 was meant to deny it and carried on. IT IS NOT\n" + " *** PROTECTED.\n"); + } + + zx_check("the payload did NOT survive the access", + ((zx_payload_result & ZX_PAYLOAD_SURVIVED) == 0U) ? 1U : 0U); + + if (expect_fault != 0U) + { + zx_check("a stage-2 fault was taken and captured", + (outcome == ZX_RUN_FAULTED) ? 1U : 0U); + } + + if (outcome != ZX_RUN_FAULTED) + { + return; + } + + zx_fault_report(record); + + zx_check("the fault is classified as a GUEST violation, not a hypervisor\n" + " one -- EC 0x24 and not EC 0x25", + (zx_fault_classify(record->zx_fault_hsr) + == ZX_FAULT_GUEST_VIOLATION) ? 1U : 0U); + zx_check("HSR.EC is 0x24, a data abort ROUTED to Hyp", + (zx_fault_ec(record->zx_fault_hsr) == ZX_EC_DABT_ROUTED) + ? 1U : 0U); + zx_check("the syndrome says the access was a WRITE, which it was", + (zx_fault_dabt_is_write(record->zx_fault_hsr) != 0U) ? 1U : 0U); + zx_check("the vector taken and HSR.EC agree with each other", + zx_fault_vector_agrees(record->zx_fault_vector, + record->zx_fault_hsr)); + zx_check("HDFAR is EXACTLY the address the payload touched, which is what\n" + " catches a region whose limit is one granule out", + (record->zx_fault_hdfar == target) ? 1U : 0U); + + /* ---------------------------------------------------------------- */ + /* The HPFAR measurement. This is the register whose relationship */ + /* to the faulting address the TRM describes two different ways. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n--- open question: what does HPFAR hold? ---\n" + " The TRM describes HPFAR TWO WAYS: Figure 3-32 draws FIPA[39:12] at\n" + " HPFAR[31:4], while Table 3-69's row beside it says \"Bits [31:4] of\n" + " the faulting address\". Those are different by a factor of 256.\n" + " Both are computed below and compared against an address this image\n" + " chose itself, so the answer is measured rather than picked.\n"); + zx_note("HPFAR raw ", record->zx_fault_hpfar); + zx_note("HDFAR (the address touched) ", record->zx_fault_hdfar); + zx_note("as FIPA[39:12] at [31:4] ", + zx_fault_hpfar_page(record->zx_fault_hpfar)); + zx_note("HDFAR, 4 KB aligned ", + record->zx_fault_hdfar & ~(uint32_t)(ZX_HPFAR_PAGE_SIZE - 1U)); + zx_note("as the address itself ", + zx_fault_hpfar_as_address(record->zx_fault_hpfar)); + zx_note("HDFAR, low 4 bits cleared ", + record->zx_fault_hdfar & 0xFFFFFFF0U); + zx_note("FIPA[39:32] from HPFAR[31:24]", + zx_fault_hpfar_high(record->zx_fault_hpfar)); + + { + zx_hpfar_reading_t reading = + zx_fault_hpfar_reading(record->zx_fault_hpfar, + record->zx_fault_hdfar); + + zx_console_puts("\n ON THIS TARGET, HPFAR holds:\n "); + zx_console_puts(zx_fault_hpfar_reading_name(reading)); + zx_console_puts("\n"); + + zx_check("HPFAR matches HDFAR under one of the TWO documented\n" + " readings, and this run says which", + ((reading == ZX_HPFAR_AS_FIPA) + || (reading == ZX_HPFAR_AS_ADDRESS)) ? 1U : 0U); + + if (reading == ZX_HPFAR_AS_FIPA) + { + zx_console_puts( + "\n NOTE: under this reading HPFAR resolves to 4 KB, not to the\n" + " 64-byte MPU granule, so HPFAR ALONE CANNOT SAY WHICH GRANULE\n" + " FAULTED.\n"); + } + else if (reading == ZX_HPFAR_AS_ADDRESS) + { + zx_console_puts( + "\n NOTE: under this reading HPFAR resolves to 16 bytes, which is\n" + " finer than the 64-byte MPU granule -- but it is NOT the reading\n" + " the Armv8-R AEM FVP implements.\n"); + } + else + { + /* Ambiguous or unrecognised; the name above already said so and + inventing a NOTE for it would be inventing a conclusion. */ + } + + zx_console_puts( + "\n EITHER WAY: THE TWO ZONEX TARGETS DISAGREE ABOUT HPFAR, measured\n" + " on 2 September 2026. There is therefore NO PORTABLE DECODE of it\n" + " on this architecture, and ZoneX uses HDFAR -- which carries the\n" + " full faulting virtual address on both, and is also what catches a\n" + " region whose limit is one granule out.\n"); + } +} + + +/**************************************************************************/ +/* zx_phase_provoke_el2_fault */ +/* */ +/* Deliberately makes ZONEX ITSELF fault, to prove the EC 0x25 path */ +/* exists and reports differently from a guest violation. Region 16 is */ +/* reprogrammed read-only AT EL2 -- AP 0b10 -- and then written from EL2. */ +/* */ +/* There is no way back from this: a fault taken from Hyp mode leaves */ +/* nothing safe to resume, so zx_el2_hypervisor_fault reports and stops. */ +/* It is therefore the LAST thing the image does, it is behind a build */ +/* option, and the build is deliberately not in CTest -- the run's */ +/* expected outcome is a failure report, which is exactly what a suite */ +/* that judges runs by their verdict must not be taught to accept. */ +/**************************************************************************/ + +#ifdef ZX_PROBE_PROVOKE_EL2_FAULT +static void zx_phase_provoke_el2_fault(void) +{ + volatile uint32_t *target = &zx_high_probe_word; + + zx_console_puts("\n=========================================================\n" + " PROVOKING A FAULT AT EL2, DELIBERATELY\n" + "\n" + " Region 16 is about to be reprogrammed read-only at EL2\n" + " (AP 0b10) and then WRITTEN from EL2. The hypervisor\n" + " fault report that follows is EXPECTED, and the point is\n" + " that it is a DIFFERENT report from a guest violation:\n" + " a different vector, EC 0x25 rather than 0x24, and its\n" + " own exit code. This run ends here and ends failing.\n" + "=========================================================\n"); + + zx_high_probe_region.zx_region_ap = ZX_AP_EL2_RO_GUEST_NONE; + zx_stage2_region_program_direct16(&zx_high_probe_region); + + *target = 0xDEADBEEFU; + + /* Not reached. If it is, the region did not deny the write, which is + itself worth reporting rather than passing over in silence. */ + + zx_console_puts("\n *** THE WRITE SUCCEEDED. A region marked read-only at\n" + " *** EL2 did not deny an EL2 write, so AP 0b10 does not\n" + " *** mean what TRM Table 3-82 says it means.\n"); + zx_failures++; +} +#endif + + +/**************************************************************************/ +/* zx_el2_main -- entered from zx_el2_entry.S at EL2. Does not return. */ +/**************************************************************************/ + +ZX_NORETURN void zx_el2_main(void) +{ + uint32_t board_regions; + uint32_t regions_required; + uint32_t el2_regions; + + /* ---------------------------------------------------------------- */ + /* BEFORE THE FIRST PRINT. */ + /* */ + /* On a board whose console sits below 0x60000000 the background */ + /* map reaches it as NORMAL memory, and Normal memory permits the */ + /* gathering and reordering that corrupts a polled UART -- with */ + /* caches off, and regardless of HSCTLR.BR, because BR grants */ + /* permission and not attributes. Measured on the S32Z280: the */ + /* whole identity block printed as legible-but-wrong text, looking */ + /* exactly like a marginal baud rate. */ + /* */ + /* So the hypervisor's own MMIO regions and the EL2 MPU come up */ + /* FIRST, before anything is printed. The order is the same on */ + /* both targets even though only one of them needs it, because a */ + /* program that took a different path on the model would be */ + /* proving something about a path silicon does not run. */ + /* */ + /* The region count is read before any region is written, since */ + /* writing HPRSELR with a value at or above the implemented count */ + /* is UNPREDICTABLE. */ + /* ---------------------------------------------------------------- */ + + el2_regions = zx_el2_region_count(); + board_regions = zx_board_mmio_region_count(); + regions_required = ZX_REGION_BOARD_FIRST + board_regions; + +#ifdef ZX_PROBE_STARVE_REGIONS + + /* Claim to need more regions than any Armv8-R implementation can have, to + exercise the REFUSAL path. Same discipline as ZX_PROBE_NEGATIVE_TEST: + "the image refuses rather than hanging when the region budget is too + small" is a claim, and a claim about a branch nobody has taken is not + evidence. Refusing is the difference between a clear message and a + region programmed at an index the implementation does not have, which + is UNPREDICTABLE and presents as a hang. */ + + regions_required += 64U; +#endif + + if (el2_regions >= board_regions) + { + zx_mair_program(); + + if (board_regions > 0U) + { + zx_board_program_mmio_regions(ZX_REGION_BOARD_FIRST); + } + + zx_el2_mpu_enable(); + } + + zx_board_init(); + + zx_console_puts( + "\n" + "=========================================================\n" + " Eclipse ThreadX ZoneX -- stage-2 first light\n" + " " ZX_PLATFORM_NAME "\n" + " Armv8-R AArch32, EL2, PMSAv8-R at both stages\n" + "=========================================================\n" + "\n" + " ZoneX is resident at EL2. It programs stage-2 MPU regions,\n" + " drops to EL1, and reports what the EL1 payload can and\n" + " cannot reach. There is no ThreadX here, no partition\n" + " manifest and no scheduler: those arrive later, and each\n" + " assumes what this image is here to establish.\n"); + + zx_fault_record_reset(zx_el2_fault_record()); + + zx_console_puts( + "\n--- the EL2 MPU is already on ---\n" + " HSCTLR.BR and HSCTLR.M were set BEFORE this console was used,\n" + " together with any Device-attributed region the hypervisor's own\n" + " MMIO needs. On a board whose console sits below 0x60000000 the\n" + " background map reaches it as NORMAL memory, and Normal memory\n" + " reorders and gathers -- which corrupts a polled UART even with\n" + " caches off. This text arriving intact is itself the evidence.\n"); + zx_note("HSCTLR now", zx_read_hsctlr()); + zx_check("HSCTLR.M is set, so the EL2 MPU is live", + ((zx_read_hsctlr() & ZX_HSCTLR_M) != 0U) ? 1U : 0U); + zx_check("HSCTLR.BR is set, so EL2's own code and data fall back to the\n" + " background map and cost no region", + ((zx_read_hsctlr() & ZX_HSCTLR_BR) != 0U) ? 1U : 0U); + +#ifdef ZX_PROBE_STARVE_REGIONS + zx_console_puts( + "\n REGION-STARVATION BUILD. This image has been told it needs more\n" + " EL2 regions than any Armv8-R implementation can have, so it must\n" + " REFUSE to enable stage 2 and report FAILED. That is the point: it\n" + " proves the refusal happens rather than a region being programmed at\n" + " an index that does not exist -- which is UNPREDICTABLE and presents\n" + " as a hang.\n"); +#endif + + if (zx_identity_block(regions_required) == 0U) + { + zx_console_puts("\nZONEX RESULT: FAILED -- the target cannot host " + "stage 2 as this image needs it\n"); + zx_console_exit(1U); + } + + /* VBAR before the first ERET: the payload's own EL1 vector table lives + inside the payload's code region, so an exception taken AT EL1 rather + than routed to EL2 lands somewhere that can identify itself. */ + + zx_console_puts("\n--- the payload's own EL1 vectors ---\n"); + zx_note("VBAR installed at", zx_symbol_address(zx_payload_vectors)); + zx_console_puts(" they live inside the payload's CODE region, so an\n" + " exception taken at EL1 rather than routed to EL2 lands\n" + " somewhere reachable that can identify itself\n"); + { + uint32_t vbar = (uint32_t)zx_symbol_address(zx_payload_vectors); + + __asm__ volatile("mcr p15, 0, %0, c12, c0, 0" : : "r"(vbar) : "memory"); + __asm__ volatile("isb"); + } + + zx_program_regions(board_regions); + + zx_console_puts("\n--- enabling stage 2 for EL0/EL1 ---\n" + " the EL2 MPU has been on since before the console;\n" + " what is left is HCR.VM. Order: DSB, HCR.VM, ISB.\n"); + zx_stage2_enable(); + zx_note("HSCTLR now", zx_read_hsctlr()); + zx_note("HCR now ", zx_read_hcr()); + zx_check("HCR.VM is set, so stage 2 applies to EL0/EL1", + ((zx_read_hcr() & ZX_HCR_VM) != 0U) ? 1U : 0U); + zx_check("HCR.HCD is clear, so HVC is still available to a guest", + ((zx_read_hcr() & ZX_HCR_HCD) == 0U) ? 1U : 0U); + zx_check("HCR.TGE is clear, so a guest keeps its own exception vectors", + ((zx_read_hcr() & ZX_HCR_TGE) == 0U) ? 1U : 0U); + + zx_phase_grant(); + zx_phase_hypercall(); + zx_phase_hprenr(el2_regions); + zx_phase_violation(); + +#ifdef ZX_PROBE_PROVOKE_EL2_FAULT + zx_phase_provoke_el2_fault(); +#endif + + /* ---------------------------------------------------------------- */ + /* Verdict. The exact strings are what the runners grep for. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n=========================================================\n" + " what this run does and does not prove\n" + "=========================================================\n" + " " ZX_PLATFORM_NAME "\n" + " EL2 regions available: "); + zx_console_putdec(el2_regions); + zx_console_puts(", needed by this image: "); + zx_console_putdec(regions_required); + zx_console_puts("\n" + "\n" + " A green run here proves the CODE is right. It proves nothing\n" + " about the region budget or the MMIO attributes on a different\n" + " part: the Armv8-R AEM FVP reports a region count no Cortex-R52\n" + " can have, and its console and GIC land in the Device band of\n" + " the background map where the S32Z280's do not. Both targets\n" + " have to be run.\n" + "\n" + " On lockstep silicon this demonstrates memory isolation on ONE\n" + " logical core. It does NOT demonstrate spatial multicore\n" + " partitioning.\n"); + + zx_console_puts("\n checks failed: "); + zx_console_putdec(zx_failures); + zx_console_puts("\n"); + + if (zx_failures == 0U) + { + zx_console_puts("\nZONEX RESULT: ALL CHECKS PASSED\n"); + } + else + { + zx_console_puts("\nZONEX RESULT: FAILED\n"); + } + + zx_console_exit(zx_failures); +} diff --git a/examples/fvp_baser_aemv8r/CMakeLists.txt b/examples/fvp_baser_aemv8r/CMakeLists.txt index 5e3f80f..eab1346 100644 --- a/examples/fvp_baser_aemv8r/CMakeLists.txt +++ b/examples/fvp_baser_aemv8r/CMakeLists.txt @@ -15,24 +15,21 @@ # # ZoneX on the Armv8-R AEM FVP (FVP_BaseR_AEMv8R). # -# STATUS: no images yet. The first one -- boot at EL2, program one stage-2 -# region, take a fault and decode it -- lands with the EL2 bring-up work. This -# file exists now so that the option, the model discovery and the CTest -# registration are in place before there is anything to run, rather than being -# invented alongside the first image. +# Two things to keep in mind here: # -# Two things to keep in mind when the first image lands here: +# * Images are EXCLUDE_FROM_ALL, as ThreadX's are. A bare "ninja" builds +# none of them, and "ctest" then fails with "application file not found" +# rather than with anything that names the real problem. test/fvp/run.sh +# reads the image list out of the ninja graph and builds them explicitly. # -# * Images are EXCLUDE_FROM_ALL, as ThreadX's are. That means a bare -# "ninja" builds none of them, and "ctest" then fails with "application -# file not found" rather than with anything that names the real problem. -# Build the image targets explicitly. -# -# * The FVP is not a Cortex-R52. It reported 32 EL1 MPU regions, which is -# not an architecturally legal R52 value -- 16, 20 and 24 are the legal -# ones -- so a green FVP run proves nothing about any real part's region -# budget. Expect the same at EL2, and read HMPUIR on the model rather -# than assuming it matches the S32Z280's 20. +# * The FVP is not a Cortex-R52. It reports 32 EL1 MPU regions and 32 EL2 +# regions, and 32 is not an architecturally legal R52 value -- 16, 20 and +# 24 are. A green FVP run proves the code is right and proves nothing +# about any real part's region budget. zx_probe.elf says so in its own +# output rather than leaving it to this comment. + +set(ZX_FVP_DIR ${CMAKE_CURRENT_LIST_DIR}) +set(ZX_COMMON_DIR ${CMAKE_CURRENT_LIST_DIR}/../common) # The model. find_program caches into ZX_FVP_BASER_AEMV8R, so CI can override # the hint with -DZX_FVP_BASER_AEMV8R= and no change to this file. @@ -46,4 +43,142 @@ else() message(STATUS "ZoneX: FVP_BaseR_AEMv8R not found; FVP run targets unavailable.") endif() -message(STATUS "ZoneX: the FVP example builds no images yet.") +# A bare-metal image has one flat DRAM region and no OS page permissions; +# access control belongs to the MPU, so an RWX segment is expected here rather +# than a mistake. GNU ld has warned about them since binutils 2.39 and takes +# this flag to stay quiet. ld.lld does not warn and rejects the flag outright, +# failing the link with "unknown argument", so the suppression is chosen by +# toolchain rather than spelled into every target. +if(CMAKE_C_COMPILER_ID STREQUAL "GNU") + set(ZX_LINK_QUIET_RWX -Wl,--no-warn-rwx-segments) +else() + set(ZX_LINK_QUIET_RWX) +endif() + +# One function, because the negative-test and provoke-a-fault variants are the +# same image with one definition changed. Building them from a shared function +# is what keeps them honest: a negative test that had drifted from the positive +# one would prove nothing about it. +function(zx_add_fvp_probe target_name) + add_executable(${target_name} EXCLUDE_FROM_ALL + ${ZX_COMMON_DIR}/zx_probe_main.c + ${ZX_COMMON_DIR}/zx_payload.S + ${ZX_FVP_DIR}/zx_board.c + ) + + target_include_directories(${target_name} PRIVATE + ${ZX_FVP_DIR} + ${ZX_COMMON_DIR} + ) + + target_link_libraries(${target_name} PRIVATE + eclipse-threadx::zonex + zonex::warnings + ) + + target_link_options(${target_name} PRIVATE + -T${ZX_FVP_DIR}/zx_link.lds + -nostartfiles + -Wl,-Map=${target_name}.map + ${ZX_LINK_QUIET_RWX} + ) + + # -T is not a dependency CMake knows about, so editing the linker script + # would not trigger a relink and a stale image would be tested against new + # region boundaries. Declare it. + set_target_properties(${target_name} PROPERTIES + LINK_DEPENDS ${ZX_FVP_DIR}/zx_link.lds + ) +endfunction() + +# The image the suite runs. +zx_add_fvp_probe(zx_probe.elf) + +# T7's negative verification: the same image with the deliberate violation +# aimed at an address the payload IS granted. It must report FAILED, and it +# is kept as a build rather than as a temporary edit so that "the check has +# been seen to fail" stays reproducible instead of being a claim in a log. +# +# Registered with CTest as a WILL_FAIL test, which is the only way a suite can +# assert that a check is capable of failing. +zx_add_fvp_probe(zx_probe_negative.elf) +target_compile_definitions(zx_probe_negative.elf PRIVATE ZX_PROBE_NEGATIVE_TEST) + +# Deliberately provokes a fault at EL2 itself, to prove the EC 0x25 path +# exists and reports differently from a guest violation. NOT registered with +# CTest: its expected outcome is a hypervisor-fault report, and a suite taught +# to accept that as a pass would accept it everywhere. Run it by hand, read +# the report, and record it. +zx_add_fvp_probe(zx_probe_el2_fault.elf) +target_compile_definitions(zx_probe_el2_fault.elf PRIVATE + ZX_PROBE_PROVOKE_EL2_FAULT) + +# Claims to need more EL2 regions than any Armv8-R implementation can have, so +# that the REFUSAL path is exercised. Like the negative build, it must FAIL, +# and it is a build rather than a temporary edit so the demonstration stays +# reproducible. +zx_add_fvp_probe(zx_probe_starved.elf) +target_compile_definitions(zx_probe_starved.elf PRIVATE ZX_PROBE_STARVE_REGIONS) + +############################################################################### +# Running on the model +############################################################################### + +if(ZX_FVP_BASER_AEMV8R) + find_package(Python3 COMPONENTS Interpreter) + + if(Python3_FOUND) + set(ZX_FVP_RUNNER ${CMAKE_CURRENT_LIST_DIR}/../../test/fvp/run_fvp_test.py) + + add_test(NAME zx-fvp-probe + COMMAND ${Python3_EXECUTABLE} ${ZX_FVP_RUNNER} + --elf $ + --fvp ${ZX_FVP_BASER_AEMV8R}) + + # The negative build must FAIL. WILL_FAIL rather than a second runner + # flag, so that the assertion lives in the suite definition where it + # can be read, and so that a negative build which started passing -- + # meaning the violation stopped being detected -- fails the suite. + add_test(NAME zx-fvp-probe-negative + COMMAND ${Python3_EXECUTABLE} ${ZX_FVP_RUNNER} + --elf $ + --fvp ${ZX_FVP_BASER_AEMV8R}) + set_tests_properties(zx-fvp-probe-negative PROPERTIES WILL_FAIL TRUE) + + # And the refusal path, for the same reason: a branch that has never + # been taken is not evidence that it works. + add_test(NAME zx-fvp-probe-starved + COMMAND ${Python3_EXECUTABLE} ${ZX_FVP_RUNNER} + --elf $ + --fvp ${ZX_FVP_BASER_AEMV8R}) + set_tests_properties(zx-fvp-probe-starved PROPERTIES WILL_FAIL TRUE) + + add_custom_target(zx-run-probe + COMMAND ${ZX_FVP_BASER_AEMV8R} + -C cluster0.NUM_CORES=1 + -C bp.vis.disable_visualisation=1 + -C bp.terminal_0.start_telnet=0 + -C bp.pl011_uart0.out_file=- + -C bp.pl011_uart0.unbuffered_output=1 + -a $ + DEPENDS zx_probe.elf + USES_TERMINAL + COMMENT "Running the ZoneX stage-2 probe on FVP_BaseR_AEMv8R..." + ) + + add_custom_target(zx-run-probe-el2-fault + COMMAND ${ZX_FVP_BASER_AEMV8R} + -C cluster0.NUM_CORES=1 + -C bp.vis.disable_visualisation=1 + -C bp.terminal_0.start_telnet=0 + -C bp.pl011_uart0.out_file=- + -C bp.pl011_uart0.unbuffered_output=1 + -a $ + DEPENDS zx_probe_el2_fault.elf + USES_TERMINAL + COMMENT "Provoking a deliberate EL2 fault on FVP_BaseR_AEMv8R..." + ) + else() + message(STATUS "ZoneX: Python3 not found; FVP CTest entries unavailable.") + endif() +endif() diff --git a/examples/fvp_baser_aemv8r/zx_board.c b/examples/fvp_baser_aemv8r/zx_board.c new file mode 100644 index 0000000..904bf44 --- /dev/null +++ b/examples/fvp_baser_aemv8r/zx_board.c @@ -0,0 +1,101 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* EXAMPLE DESCRIPTION */ +/* */ +/* zx_board.c Armv8-R AEM FVP */ +/* */ +/* DESCRIPTION */ +/* */ +/* What the Armv8-R AEM FVP needs from the shared stage-2 probe, which */ +/* is almost nothing -- and the almost-nothing is the point. */ +/* */ +/* zx_board_mmio_region_count returns 0 here and 2 on the S32Z280. */ +/* The function exists on both so that the SAME probe program asks the */ +/* question on both targets, rather than the model's build quietly not */ +/* having the code path that silicon needs. */ +/* */ +/**************************************************************************/ + +#include "zx_probe.h" +#include "zx_platform.h" + + +/**************************************************************************/ +/* zx_board_init */ +/* */ +/* Nothing to do. The console is semihosting (docs/decisions.md D8), */ +/* which needs no peripheral and therefore cannot be broken by a wrong */ +/* memory map -- which is exactly why it is the console to bring up */ +/* BEFORE the MPU is trusted. */ +/**************************************************************************/ + +void zx_board_init(void) +{ + /* Intentionally empty. */ +} + + +/**************************************************************************/ +/* zx_board_mmio_region_count */ +/**************************************************************************/ + +uint32_t zx_board_mmio_region_count(void) +{ + return 0U; +} + + +/**************************************************************************/ +/* zx_board_program_mmio_regions */ +/**************************************************************************/ + +void zx_board_program_mmio_regions(uint32_t first_index) +{ + (void) first_index; + + /* Not reached: the count above is zero. Present because the seam is + shared with the board that does need it, and an empty implementation + that says why is better than a link error on one target. */ +} + + +/**************************************************************************/ +/* zx_board_report */ +/**************************************************************************/ + +void zx_board_report(void) +{ + zx_console_puts( + "\n--- what this target is, and is not ---\n" + " console: semihosting (HLT 0xF000). No peripheral, so it cannot\n" + " be broken by a wrong memory map.\n" + " image: linked into low DRAM at 0x00000000. The upper 2 GB half\n" + " of the BaseR map is peripherals and is execute-never in\n" + " the background map.\n" + " NOTE: this model is an Architecture Envelope Model, not a\n" + " Cortex-R52. It reported 32 EL1 MPU regions during the\n" + " Cortex-R52 port work -- not an architecturally legal\n" + " value -- and its MIDR part number is not an R52's. Read\n" + " every count below as the MODEL's, never as a part's.\n" + " NOTE: its UART (0x9C090000) and GIC (0xAF000000) fall in the\n" + " Device-nGnRE band of the background map, so HSCTLR.BR\n" + " gives them the right attributes BY ACCIDENT. On the\n" + " S32Z280 the same devices sit in a Normal cacheable band\n" + " and each costs an EL2 region. This model therefore\n" + " cannot show you that those regions are needed.\n"); +} diff --git a/examples/fvp_baser_aemv8r/zx_link.lds b/examples/fvp_baser_aemv8r/zx_link.lds new file mode 100644 index 0000000..2234391 --- /dev/null +++ b/examples/fvp_baser_aemv8r/zx_link.lds @@ -0,0 +1,198 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/* Link map for the ZoneX stage-2 probe on the Armv8-R AEM FVP (BaseR). + * + * The BaseR memory map is the Base platform map with its two 2 GB halves + * swapped: Base DRAM at 0x80000000 appears at 0x00000000 here, and the + * 0x80000000-0xFFFFFFFF half holds peripherals and is execute-never in the + * background map. Code must therefore be linked into low DRAM; linking at + * 0x80000000 produces a silent fault loop with no output. + * + * WHAT MAKES THIS SCRIPT DIFFERENT FROM AN ORDINARY BARE-METAL ONE + * + * Four areas below are not merely aligned but SIZED to whole 64-byte + * granules, because each one is a stage-2 MPU region and PMSAv8-R has a + * 64-byte granule with an inclusive limit: + * + * .zx_payload_text region 0, the payload's code + * .zx_payload_data region 1, the payload's data + * .zx_forbidden NO REGION AT ALL -- the whole point + * .zx_high_probe region 16, the high-region experiment + * + * .zx_forbidden is placed IMMEDIATELY AFTER .zx_payload_data, adjacent by + * construction. An ungranted address far away from every grant proves only + * that unmapped memory faults; the defect class worth catching is a region + * whose limit or base is out by one granule, and only an adjacent hole + * catches that. Do not move it, and do not let anything land between it and + * the payload's data. + * + * .zx_high_probe is placed after the hole rather than beside the payload, + * because two enabled regions matching one address is CONSTRAINED + * UNPREDICTABLE and aborts on the S32Z280. Keeping region 16's granule well + * clear of regions 0 and 1 makes the disjointness visible in the map file. + * + A NOTE ON ALIGN, because the two spellings are not equivalent + * + * Every ALIGN below is written AFTER the colon, which is the section's + * ALIGNMENT. Written before the colon it would be the section's ADDRESS, + * evaluated from a location counter that starts at zero -- and it silently + * overrides "> DRAM". On this target that happens to be harmless because + * DRAM starts at 0x00000000, which is precisely what makes it a trap: the + * same script shape moved to the S32Z280, whose code SRAM starts at + * 0x79900000, fails the link with "not within region" or, worse, places + * sections somewhere plausible. Keep both scripts in the safe form so that + * neither teaches the wrong one. + * + * ZoneX's OWN code, data, bss and stack are covered by no region at all. + * That is decision D2, not an oversight: with HSCTLR.BR=1 EL2's own accesses + * fall back to the background map, while EL0/EL1 accesses that hit no region + * fault regardless of BR -- so the hypervisor costs no region and is + * protected from the payload precisely by not being mapped. + */ + +ENTRY(_start) + +__zx_hyp_stack_size = 0x1000; + +MEMORY +{ + DRAM (rwx) : ORIGIN = 0x00000000, LENGTH = 0x08000000 /* 128 MB */ +} + +SECTIONS +{ + . = ALIGN(64); + + /* .text.boot first, so _start is at the front of the image. It does not + have to be at a fixed address on this target -- the model takes the + entry point from the ELF -- but keeping the same section order as the + silicon script, where it does, means one layout to reason about. */ + + .boot : + { + KEEP(*(.text.boot)) + } > DRAM + + .vectors_el2 : + { + KEEP(*(.vectors_el2)) + } > DRAM + + .text : + { + *(.text*) + *(.glue_7) + *(.glue_7t) + } > DRAM + + .rodata : + { + . = ALIGN(4); + *(.rodata*) + . = ALIGN(4); + } > DRAM + + .data : ALIGN(64) + { + __zx_data_start = .; + *(.data*) + . = ALIGN(4); + __zx_data_end = .; + } > DRAM + + .bss (NOLOAD) : ALIGN(64) + { + __zx_bss_start = .; + *(.bss*) + *(COMMON) + . = ALIGN(4); + __zx_bss_end = .; + } > DRAM + + /* The hypervisor's Hyp-mode stack. SP must stay 8-byte aligned + (AAPCS), so the area is aligned before its top symbol is taken. */ + + .zx_stacks (NOLOAD) : + { + . = ALIGN(8); + . = . + __zx_hyp_stack_size; + __zx_hyp_stack_top = .; + } > DRAM + + /* ------------------------------------------------------------------ + The partition areas. Everything below is granule-aligned and + granule-sized, and the ORDER of the last three is load-bearing. + ------------------------------------------------------------------ */ + + .zx_payload_text : ALIGN(64) + { + __zx_payload_code_start = .; + KEEP(*(.zx_payload_text)) + . = ALIGN(64); + __zx_payload_code_end = .; + } > DRAM + + .zx_payload_data : ALIGN(64) + { + __zx_payload_data_start = .; + KEEP(*(.zx_payload_data)) + . = ALIGN(64); + __zx_payload_data_end = .; + } > DRAM + + /* One granule, covered by NO region. Adjacent to the payload's data by + construction -- see the header. */ + + .zx_forbidden (NOLOAD) : ALIGN(64) + { + __zx_forbidden_start = .; + . = . + 64; + __zx_forbidden_end = .; + } > DRAM + + .zx_high_probe : ALIGN(64) + { + __zx_high_probe_start = .; + KEEP(*(.zx_high_probe)) + . = ALIGN(64); + __zx_high_probe_end = .; + } > DRAM + + . = ALIGN(8); + _end = .; + PROVIDE(end = .); + + /* Assert the geometry the region programming depends on, so that a + layout mistake fails the LINK rather than the run. A region whose + base is under-aligned does not fault: its low bits land on SH, AP and + XN and silently change the region's attributes, which is the single + hardest failure in this architecture to see. */ + + ASSERT((__zx_payload_code_start & 63) == 0, + "payload code region base is not 64-byte aligned") + ASSERT((__zx_payload_code_end & 63) == 0, + "payload code region is not a whole number of granules") + ASSERT((__zx_payload_data_start & 63) == 0, + "payload data region base is not 64-byte aligned") + ASSERT((__zx_payload_data_end & 63) == 0, + "payload data region is not a whole number of granules") + ASSERT(__zx_forbidden_start == __zx_payload_data_end, + "the ungranted granule is NOT adjacent to the payload's data region") + ASSERT((__zx_high_probe_start & 63) == 0, + "the high-probe region base is not 64-byte aligned") + ASSERT(__zx_high_probe_start >= __zx_forbidden_end, + "the high-probe region overlaps the ungranted granule") +} diff --git a/examples/fvp_baser_aemv8r/zx_platform.h b/examples/fvp_baser_aemv8r/zx_platform.h new file mode 100644 index 0000000..423c2d7 --- /dev/null +++ b/examples/fvp_baser_aemv8r/zx_platform.h @@ -0,0 +1,81 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* EXAMPLE DESCRIPTION */ +/* */ +/* zx_platform.h Armv8-R AEM FVP */ +/* */ +/* DESCRIPTION */ +/* */ +/* Memory map for the Armv8-R AEM FVP (FVP_BaseR_AEMv8R). */ +/* */ +/* Every address here was verified in-model during the Cortex-R52 port */ +/* work rather than taken from documentation. The BaseR map is the */ +/* Base platform map with its two 2 GB halves swapped, so a Base */ +/* peripheral at X below 0x80000000 appears at X + 0x80000000, and */ +/* each base was then confirmed by reading its identification */ +/* register. */ +/* */ +/* WHY THIS TARGET NEEDS NO HYPERVISOR MMIO REGION */ +/* */ +/* ZoneX's console here is semihosting, which is a debug trap and */ +/* touches no memory at all. Even if it used the PL011, the UART at */ +/* 0x9C090000 and the GIC at 0xAF000000 both fall in the Device-nGnRE */ +/* band of the background map, where HSCTLR.BR gives the right */ +/* attributes by accident. */ +/* */ +/* That is a property of this model, not of the architecture, and it */ +/* is the reason the model cannot validate the region budget: on the */ +/* S32Z280 the same two devices sit in a Normal cacheable band and */ +/* each costs a region. See docs/decisions.md D2. */ +/* */ +/**************************************************************************/ + +#ifndef ZX_PLATFORM_H +#define ZX_PLATFORM_H + +#define ZX_PLATFORM_NAME "Armv8-R AEM FVP (FVP_BaseR_AEMv8R), BaseR platform" + +/* PL011 UART0. Unused by ZoneX -- the console is semihosting, per + docs/decisions.md D8 -- and recorded because its ADDRESS is the evidence + for the paragraph above. */ + +#define ZX_FVP_PL011_UART0_BASE 0x9C090000UL + +/* GICv3. A redistributor is two consecutive 64 KB frames per core. */ + +#define ZX_FVP_GICD_BASE 0xAF000000UL +#define ZX_FVP_GICR_RD_BASE 0xAF100000UL +#define ZX_FVP_GICR_SGI_BASE 0xAF110000UL + +/* The model resets CNTFRQ to zero and leaves the system counter stopped, so + the value below is what the Cortex-R52 BSP programs -- CNTFID0 read back + from the counter control frame. The stage-2 probe does not use the timer + and deliberately does not program CNTFRQ: it REPORTS it, so that a zero + here is visible as the model's own state rather than hidden by ZoneX + having written over it. Step 5 is where the timer matters. */ + +#define ZX_FVP_SYSTEM_COUNTER_HZ 100000000UL + +/* Low DRAM, where the image is linked. The 0x80000000-0xFFFFFFFF half holds + peripherals and is execute-never in the background map, so linking code + there produces a silent fault loop with no output. */ + +#define ZX_FVP_DRAM_BASE 0x00000000UL +#define ZX_FVP_DRAM_SIZE 0x08000000UL /* 128 MB */ + +#endif /* ZX_PLATFORM_H */ diff --git a/examples/s32z280_evb/CMakeLists.txt b/examples/s32z280_evb/CMakeLists.txt index 3c7f645..a73804b 100644 --- a/examples/s32z280_evb/CMakeLists.txt +++ b/examples/s32z280_evb/CMakeLists.txt @@ -15,26 +15,91 @@ # # ZoneX on the NXP S32Z280-594EVB. # -# STATUS: no images yet. The first one lands with the EL2 bring-up work, -# alongside the FVP's. -# This target is build-only in CI: it needs silicon, and a hosted runner has -# none. Running it is a bench activity driven by the gdb harness that lands -# with the first image. +# BUILD-ONLY IN CI. Running needs silicon, and a hosted runner has none, so +# this is a bench activity driven by tools/run_zx_probe.sh. # -# What is already known about this board, measured rather than assumed, and -# recorded here so the first image is not written against guesses: +# What is already known about this board, measured rather than assumed: # # * HMPUIR reads 0x00000014 -- 20 EL2 regions, matching its 20 EL1 regions. # That is the whole budget the hypervisor's own mapping and every guest's -# stage-2 mapping share. +# stage-2 mapping share, and TWO of those go to the hypervisor's own MMIO +# (see zx_board.c and docs/decisions.md D2). # # * SCTLR.TE and HSCTLR.TE reset SET on this part. A32 vectors then never # run and every fault presents as a silent hang, which is the single most -# expensive way to lose a day on this board. +# expensive way to lose a day on this board. ZX_RESET_IN_THUMB below is +# the other half of the same fact: the core resets in Thumb STATE too. # # * Two enabled MPU regions matching one address abort here. PMSAv8-R has -# no region priority, and this part does not tolerate the overlap. +# no region priority and this part does not tolerate the overlap. # # See docs/armv8r-el2-reference.md. -message(STATUS "ZoneX: the S32Z280 example builds no images yet.") +set(ZX_S32Z_DIR ${CMAKE_CURRENT_LIST_DIR}) +set(ZX_COMMON_DIR ${CMAKE_CURRENT_LIST_DIR}/../common) + +# See the note in the FVP example: a bare-metal image has one flat memory map +# and access control belongs to the MPU, so an RWX segment is expected. +if(CMAKE_C_COMPILER_ID STREQUAL "GNU") + set(ZX_LINK_QUIET_RWX -Wl,--no-warn-rwx-segments) +else() + set(ZX_LINK_QUIET_RWX) +endif() + +function(zx_add_s32z_probe target_name) + add_executable(${target_name} EXCLUDE_FROM_ALL + ${ZX_COMMON_DIR}/zx_probe_main.c + ${ZX_COMMON_DIR}/zx_payload.S + ${ZX_S32Z_DIR}/zx_board.c + ) + + target_include_directories(${target_name} PRIVATE + ${ZX_S32Z_DIR} + ${ZX_COMMON_DIR} + ) + + # ZX_RESET_IN_THUMB and ZX_CONSOLE_BOARD are NOT set here. They have to + # reach platform/cortex_r52/src/*, which a PRIVATE definition on an + # executable cannot do -- zonex_port is a separate static library. They + # come from the root CMakeLists.txt instead, as a property of the build + # tree, which is also what makes the two boards mutually exclusive in one. + # + # Setting them here was the first version of this file and it produced an + # image with an A32 entry point and a semihosting console. It built, it + # linked, and it would have printed nothing. + + target_link_libraries(${target_name} PRIVATE + eclipse-threadx::zonex + zonex::warnings + ) + + target_link_options(${target_name} PRIVATE + -T${ZX_S32Z_DIR}/zx_link.lds + -nostartfiles + -Wl,-Map=${target_name}.map + ${ZX_LINK_QUIET_RWX} + ) + + set_target_properties(${target_name} PROPERTIES + LINK_DEPENDS ${ZX_S32Z_DIR}/zx_link.lds + ) +endfunction() + +zx_add_s32z_probe(zx_probe.elf) +zx_add_s32z_probe(zx_probe_negative.elf) +target_compile_definitions(zx_probe_negative.elf PRIVATE ZX_PROBE_NEGATIVE_TEST) +zx_add_s32z_probe(zx_probe_el2_fault.elf) +target_compile_definitions(zx_probe_el2_fault.elf PRIVATE + ZX_PROBE_PROVOKE_EL2_FAULT) + +# Claims to need more EL2 regions than any Armv8-R implementation can have, so +# that the REFUSAL path is exercised. Like the negative build, it must FAIL, +# and it is a build rather than a temporary edit so the demonstration stays +# reproducible. +zx_add_s32z_probe(zx_probe_starved.elf) +target_compile_definitions(zx_probe_starved.elf PRIVATE ZX_PROBE_STARVE_REGIONS) + +# No add_test here. A CTest entry that cannot run without a board and a +# Windows-side debug server would fail every hosted run, and a suite that is +# expected to fail teaches everybody to ignore it. tools/run_zx_probe.sh is +# the entry point, and it checks its own preconditions. diff --git a/examples/s32z280_evb/tools/run_zx_probe.gdb b/examples/s32z280_evb/tools/run_zx_probe.gdb new file mode 100644 index 0000000..df745ee --- /dev/null +++ b/examples/s32z280_evb/tools/run_zx_probe.gdb @@ -0,0 +1,309 @@ +# Copyright (c) 2026 Eclipse ThreadX contributors +# SPDX-License-Identifier: MIT +# Some portions generated by Claude Code (Opus 5). +# +# Load the ZoneX stage-2 probe onto the S32Z280-594EVB, run it, and report what +# memory says about the run. +# +# Usage, normally through run_zx_probe.sh which checks the preconditions: +# ZX_S32Z_ELF= \ +# arm-none-eabi-gdb-py -batch -x tools/run_zx_probe.gdb +# +# Requires, and does not do for you: +# * a CCS listening on 41475 -- the *Windows* CCS ("ccs.exe -noportquit"). +# The Linux CCS's pushes to the probe stall after "Sending code ... done", +# and under WSL2 mirrored networking a stray Linux CCS holds the port and +# blocks the Windows one ("pkill -x ccs", not -f: it runs as ./ccs). +# * GTA on 45000: Server/gta/gta -p 45000 -k +# * PYTHONHOME pointing at a source-built Python 3.10.11, which is what +# arm-none-eabi-gdb-py's embedded interpreter needs. +# * The UART already being captured to a file BEFORE this runs. The console +# is the primary evidence and this script is the backup: the console shows +# the ORDER of events, which memory cannot. +# +# ENVIRONMENT. Nothing about anybody's bench is written into this file: an +# installation path and a probe address are properties of a workstation, not of +# the project, and a script carrying somebody's would be wrong for everybody +# else and would tell them where that person's files are. +# +# S32DS_ROOT the S32 Design Studio installation, the directory holding +# S32DS/tools/S32Debugger +# ZX_PROBE the debug probe, in the form S32 Design Studio expects, for +# example "s32dbg:
" +# ZX_S32Z_ELF the image to load +# +# run_zx_probe.sh sets all three and explains each if it is missing. +# +# WHAT A PASS LOOKS LIKE +# +# The probe drops to EL1 four times and deliberately violates its stage-2 +# regions twice -- once with region 16 disabled through HPRENR, once against +# the granule adjacent to its data region. Both must be taken to EL2, decoded +# as EC 0x24, and reported. A run with NO fault is a FAILURE: it means an EL1 +# payload reached memory stage 2 was meant to deny it. +# +# zx_run_failures == 0 the image's own verdict +# zx_payload_result bit 0x20 CLEAR it did not survive the violation +# zx_el2_fault_storage.zx_fault_hsr EC 0x24, not 0x25 +# +# Two silicon quirks are handled below rather than assumed away: the debugger +# holds every RTU core in debug state through MDM_AP CONTROL2, and the core +# resets in Thumb state. + +# NXP's own script reads _PROBE_IP, _SOC_NAME, _CORE_NAME and +# _GDB_SERVER_PORT out of gdb's Python namespace, so they are assigned there +# before it is sourced. The two that vary by bench come from the environment +# and are REQUIRED: a default would be somebody's own installation, and +# guessing wrong here produces a connection error whose message points +# nowhere useful. +python +import gdb, os + +_SOC_NAME = "S32Z280" +_CORE_NAME = "R52_0_0" +_GDB_SERVER_PORT = 45000 + +_S32DS_ROOT = os.environ.get("S32DS_ROOT") +if not _S32DS_ROOT: + raise gdb.GdbError( + "S32DS_ROOT is not set. Point it at your S32 Design Studio " + "installation -- the directory containing S32DS/tools/S32Debugger.") + +_PROBE_IP = os.environ.get("ZX_PROBE") +if not _PROBE_IP: + raise gdb.GdbError( + "ZX_PROBE is not set. Set it to your debug probe as S32 Design " + "Studio names it, for example s32dbg:.") + +gdb.execute("source %s/S32DS/tools/S32Debugger/Debugger/scripts/s32z2e2/" + "s32z2e2_generic_bareboard_all_cores.py" % _S32DS_ROOT) +end + +py board_init() +py s32z2e2_cores.init(_CORE_NAME) +py s32z2e2_cores.start_debug_by_core_name(_CORE_NAME) + +# Select the core context before asking about core state. NXP's core_init() +# omits this and then stop_core() reads the mode of the SoC context, which has +# none, and reports "Core can't be stopped. State is undefined/unknown". +py gta_lib.set_context(context.CORE_CTX[_SOC_NAME + "_" + _CORE_NAME]).unwrap() +py gta_lib.stop_core().unwrap() +py gta_lib.attach().unwrap() + +python +import gdb, os + +# --------------------------------------------------------------------------- +# Release the core from the debugger's external debug request. +# +# _reset_to_first_instruction() asserts MDM_AP CONTROL2[19:16] = +# CR52_RTU0_{3,2,1,0}_EDBGREQ (0x000F0000) and nothing clears them, so every +# RTU0 core is pinned in debug state and executes nothing -- registers and +# memory still respond, which is what makes it look like working hardware. +# --------------------------------------------------------------------------- +CONTROL2 = 0x4DC11044 +EDBGREQ_CORE0 = 0x00010000 + +def rd(addr): + return int(gdb.parse_and_eval("*(unsigned int *)0x%x" % addr)) & 0xFFFFFFFF + +c2 = rd(CONTROL2) +gdb.execute("set *(unsigned int *)0x%x = 0x%x" % (CONTROL2, c2 & ~EDBGREQ_CORE0)) +if rd(CONTROL2) & EDBGREQ_CORE0: + raise gdb.GdbError("CR52_RTU0_0_EDBGREQ is still set; the core will not run") +print("EDBGREQ cleared: CONTROL2 0x%08X -> 0x%08X" % (c2, rd(CONTROL2))) + +elf = os.environ.get("ZX_S32Z_ELF", "zx_probe.elf") +gdb.execute("file %s" % elf) +gdb.execute("load") + +# The core resets in Thumb state and CPSR cannot be written through this +# connection, so entry must be the T32 _start. Mask bit 0: GDB reports a Thumb +# function's address with it set, but PC itself must be even. +start = int(gdb.parse_and_eval("(unsigned int)&_start")) & ~1 +gdb.execute("set $pc = 0x%x" % start) +print("PC set to 0x%08X" % start) + +# zx_console_run_parked, NOT zx_console_exit. A breakpoint on zx_console_exit +# lands before it stores the verdict into zx_run_failures, so the harness reads +# whatever the variable held beforehand -- 0xFFFFFFFF -- and reports that the +# run never finished, on a run whose console said ALL CHECKS PASSED. That +# happened here on 2 September 2026, which is why the symbol exists. +# +# hbreak, not break: a software breakpoint has to write to the code region, and +# this one is in the RTU code SRAM's instruction-fetch window. A hardware +# breakpoint sidesteps the question entirely. +gdb.execute("hbreak zx_console_run_parked") +gdb.execute("continue") +end + +echo \n===== the ZoneX stage-2 probe: what memory says =====\n +python +import gdb + +def rd(addr): + return int(gdb.parse_and_eval("*(unsigned int *)0x%x" % addr)) & 0xFFFFFFFF + +def sym(name): + return int(gdb.parse_and_eval("(unsigned int)&%s" % name)) & 0xFFFFFFFF + +def val(name): + return rd(sym(name)) + +failures = 0 + +# --- the image's own verdict ------------------------------------------------- +# +# Read as well as trusted: zx_console_exit sets it from its argument, so this +# is the same number the console printed. Reading it here is what makes a run +# whose console capture failed still judgeable. +verdict = val("zx_run_failures") +print(" zx_run_failures = %d" % verdict) +if verdict == 0xFFFFFFFF: + print(" *** FAIL: the image never reached its verdict.") + failures += 1 +elif verdict != 0: + print(" *** FAIL: the image reported %d failing check(s)." % verdict) + print(" The console log is where they are named.") + failures += 1 +else: + print(" the image reported every check passing") + +# --- what the payload did ---------------------------------------------------- +RAN, OWN_DATA, HVC_RET, PROBE_OK, ATTEMPTED, SURVIVED = ( + 0x01, 0x02, 0x04, 0x08, 0x10, 0x20) + +progress = val("zx_payload_result") +print("\n zx_payload_result = 0x%08X" % progress) +for bit, name, want_set in ((RAN, "reached EL1 and ran", True), + (OWN_DATA, "wrote and read back its own data", True), + (HVC_RET, "returned from HVC #0", True), + (ATTEMPTED, "attempted the forbidden access", True), + (SURVIVED, "SURVIVED the forbidden access", False)): + got = bool(progress & bit) + ok = (got == want_set) + if not ok: + failures += 1 + print(" 0x%02X %-34s %-5s %s" % (bit, name, "set" if got else "clear", + "ok" if ok else "*** WRONG ***")) +if progress & SURVIVED: + print(" ISOLATION FAILURE: the payload wrote memory it was never granted.") + +# --- what the trap handler captured ------------------------------------------ +print("") +captures = val("zx_el2_fault_storage") # first field +print(" captures into EL2 = %d (yields included, so not a fault count)" + % captures) + +hsr = rd(sym("zx_el2_fault_storage") + 8) +hpfar = rd(sym("zx_el2_fault_storage") + 12) +hdfar = rd(sym("zx_el2_fault_storage") + 16) +elr = rd(sym("zx_el2_fault_storage") + 24) +spsr = rd(sym("zx_el2_fault_storage") + 28) +vector = rd(sym("zx_el2_fault_storage") + 4) +ec = (hsr >> 26) & 0x3F + +if captures == 0: + print(" *** FAIL: nothing was ever captured at EL2. The payload either") + print(" never ran or was never stopped.") + failures += 1 +else: + print(" last capture:") + print(" vector = +0x%02X" % vector) + print(" HSR = 0x%08X EC 0x%02X" % (hsr, ec)) + if ec in (0x24, 0x25): + print(" HSR.ISS = 0x%08X DFSC 0x%02X %s" + % (hsr & 0x01FFFFFF, hsr & 0x3F, + "on a WRITE" if (hsr & 0x40) else "on a READ")) + else: + # No DFSC or WnR is defined outside a data abort, and printing them + # anyway invites somebody to read meaning into ISS bits that have a + # different meaning for this exception class. + print(" HSR.ISS = 0x%08X" % (hsr & 0x01FFFFFF)) + print(" HDFAR = 0x%08X" % hdfar) + + # HPFAR is described TWO ways by the TRM and the two ZoneX targets + # implement DIFFERENT ones, so both are computed and the one that matches + # HDFAR is named. Printing only the A-profile reading -- which is what + # this harness did first -- shows a plausible wrong address on this part. + as_fipa = (hpfar << 8) & 0xFFFFF000 + as_address = hpfar & 0xFFFFFFF0 + print(" HPFAR = 0x%08X" % hpfar) + print(" as FIPA[39:12] = 0x%08X (TRM Figure 3-32)" % as_fipa) + print(" as the address = 0x%08X (TRM Table 3-69 row)" % as_address) + fits_fipa = (as_fipa == (hdfar & 0xFFFFF000)) and ((hpfar >> 24) == 0) + fits_addr = (as_address == (hdfar & 0xFFFFFFF0)) + if fits_fipa and fits_addr: + print(" -> BOTH fit this address; it cannot tell them apart") + elif fits_fipa: + print(" -> this target holds FIPA[39:12] at HPFAR[31:4]") + elif fits_addr: + print(" -> this target holds THE ADDRESS ITSELF, [3:0] RES0") + else: + print(" -> *** NEITHER documented reading explains this value") + failures += 1 + print(" ELR_hyp = 0x%08X" % elr) + print(" SPSR_hyp = 0x%08X mode 0x%02X (%s)" + % (spsr, spsr & 0x1F, + {0x10: "User", 0x13: "Supervisor", 0x1A: "Hyp", + 0x1F: "System"}.get(spsr & 0x1F, "?"))) + + # What the LAST entry to EL2 was. It is not necessarily a fault: the + # payload's final act on a passing run is a fault, but on a run where it + # survived its violation the last entry is the yield -- so demanding + # EC 0x24 here would report a second, invented failure on top of the real + # one and describe the yield as a broken fault. + # + # EC 0x25 is the exception to that leniency and is counted wherever it + # appears: a hypervisor faulting on its own access, reported as a + # partition being stopped at its boundary, is a run that passes while + # proving nothing. + if ec == 0x24: + print(" EC 0x24: a data abort ROUTED to Hyp -- a GUEST violation") + if (spsr & 0x1F) != 0x13: + print(" *** FAIL: the fault did not come from Supervisor mode,") + print(" which is where this payload runs at EL1.") + failures += 1 + elif ec == 0x25: + print(" *** FAIL: EC 0x25 is a data abort TAKEN FROM Hyp. ZoneX") + print(" faulted on its OWN access. That is a hypervisor") + print(" bug, not a partition being stopped.") + failures += 1 + elif ec == 0x12: + print(" EC 0x12: an HVC. The last entry to EL2 was the payload") + print(" yielding, not a fault -- so the DFSC and the") + print(" fault addresses above belong to an EARLIER") + print(" capture and are stale. Read the console.") + else: + print(" EC 0x%02X: neither a fault nor a hypercall. Read the" % ec) + print(" console; the image names what it saw.") + +# --- the numbers this run exists to measure ---------------------------------- +print("") +print(" HVC #0 count = %d" % val("zx_el2_hypercall_counter")) +print(" HSCTLR at reset = 0x%08X TE %s" + % (val("zx_reset_hsctlr"), + "SET" if (val("zx_reset_hsctlr") & (1 << 30)) else "clear")) +print(" SCTLR at reset = 0x%08X TE %s" + % (val("zx_reset_sctlr"), + "SET" if (val("zx_reset_sctlr") & (1 << 30)) else "clear")) +print(" CPSR at reset = 0x%08X mode 0x%02X, T %d" + % (val("zx_reset_cpsr"), val("zx_reset_cpsr") & 0x1F, + 1 if (val("zx_reset_cpsr") & 0x20) else 0)) +print(" HPRENR at reset = 0x%08X" % val("zx_reset_hprenr")) + +print("") +if failures == 0: + print("===== PASS: stage 2 granted what it should and denied what it should not =====") +else: + print("===== FAIL: %d check(s) wrong =====" % failures) + +# Raise, so that gdb -batch exits NON-ZERO and run_zx_probe.sh propagates it. +# Without this the harness printed FAIL and exited 0, which is the one thing a +# regression must never do: the negative build was reported as a passing run by +# the shell around it while its own report said the payload had survived its +# violation. +if failures != 0: + raise gdb.GdbError( + "%d check(s) wrong; see the report above and the console log" % failures) +end diff --git a/examples/s32z280_evb/tools/run_zx_probe.sh b/examples/s32z280_evb/tools/run_zx_probe.sh new file mode 100755 index 0000000..ad6a527 --- /dev/null +++ b/examples/s32z280_evb/tools/run_zx_probe.sh @@ -0,0 +1,162 @@ +#!/bin/bash +# Copyright (c) 2026 Eclipse ThreadX contributors +# SPDX-License-Identifier: MIT +# Some portions generated by Claude Code (Opus 5). +# +# Run the ZoneX stage-2 probe on the S32Z280-594EVB and capture the evidence: +# the console log and the fault registers. +# +# Usage: tools/run_zx_probe.sh [] [] +# +# THE CONSOLE IS THE TEST METHOD HERE, NOT A CONVENIENCE. This captures the +# UART to a FILE before the image runs, and prints the file afterwards. Two +# LINFlexD bugs found during the Cortex-R52 bring-up were invisible from the +# target and showed up only in a byte diff of a captured log, and the gdb +# report at the end can say what the final state was but never the order the +# events happened in. +# +# NEVER PIPE THIS THROUGH tail OR head. Redirect to a file and read the file: +# a pipe truncates exactly the part that matters when a run hangs. +# +# Prerequisites this cannot do for you: +# * the *Windows* CCS listening on 41475, started as "ccs.exe -noportquit". +# The Linux CCS's pushes to the probe stall after "Sending code ... done". +# * board wiring for the console: LIN9 -> daughtercard USB-UART, jumper J248 +# at 1-2, micro-USB at J119 on the daughtercard, activity LED J122. +# +# ENVIRONMENT. Three variables, all REQUIRED, and none of them defaulted -- +# because a default would be whichever workstation this script was written on, +# which is wrong for everybody else and tells them where that person's files +# live: +# +# S32DS_ROOT the S32 Design Studio installation, the directory +# holding S32DS/tools/S32Debugger and S32DS/tools/gdb-arm +# ZX_PROBE the debug probe as S32 Design Studio names it, for +# example s32dbg:
+# ZX_GDB_PYTHONHOME a SOURCE-BUILT Python 3.10 tree. arm-none-eabi-gdb-py's +# embedded interpreter has a minimal builtin set and loads +# _struct, _socket and _ctypes as separate .so files, so a +# distribution Python will not do. +# +# Optional: UART (default /dev/ttyUSB0) and LOG (default /tmp/zx_probe_uart.log). +set -u + +BUILD=${1:-build/s32z280} +IMAGE=${2:-zx_probe.elf} +EVB_BIN="$BUILD/examples/s32z280_evb" +HERE=$(cd "$(dirname "$0")" && pwd) + +require() { + local name=$1 hint=$2 + if [ -z "${!name:-}" ]; then + echo "ERROR: $name is not set. $hint" >&2 + exit 1 + fi +} + +require S32DS_ROOT \ + "Point it at your S32 Design Studio installation -- the directory containing S32DS/tools/S32Debugger." +require ZX_PROBE \ + "Set it to your debug probe as S32 Design Studio names it, for example s32dbg:." +require ZX_GDB_PYTHONHOME \ + "Set it to a source-built Python 3.10 tree; gdb-py's embedded interpreter cannot use a distribution one." + +GDB=$S32DS_ROOT/S32DS/tools/gdb-arm/arm32-eabi/bin/arm-none-eabi-gdb-py +GTA=$S32DS_ROOT/S32DS/tools/S32Debugger/Debugger/Server/gta + +for tool in "$GDB" "$GTA/gta"; do + if [ ! -x "$tool" ]; then + echo "ERROR: $tool is not an executable." >&2 + echo " Is S32DS_ROOT ($S32DS_ROOT) the right installation?" >&2 + exit 1 + fi +done + +ELF=$EVB_BIN/$IMAGE +UART=${UART:-/dev/ttyUSB0} +LOG=${LOG:-/tmp/zx_probe_uart.log} + +if [ ! -f "$ELF" ]; then + echo "ERROR: $ELF not found. Build it first:" >&2 + echo " ./test/s32z280/run.sh build" >&2 + exit 1 +fi + +# A stray Linux CCS holds 41475 under WSL2 mirrored networking and blocks the +# Windows one. pkill -x, not -f: it runs as ./ccs, so a -f pattern misses it. +if pgrep -x ccs > /dev/null; then + echo "ERROR: a Linux CCS is running (pid $(pgrep -x ccs | tr '\n' ' '))." >&2 + echo " It holds 41475 and blocks the Windows CCS. Kill it: pkill -x ccs" >&2 + exit 1 +fi + +# /dev/tcp rather than a Python probe, for two reasons: PYTHONHOME below points +# at the source-built 3.10 for gdb-py and would make system python3 fail, and +# ss/netstat inside WSL2 CANNOT SEE a Windows listener at all -- so "nothing is +# listening" from a listing is not evidence. Test with a connect. +if ! timeout 5 bash -c "exec 3<>/dev/tcp/127.0.0.1/41475" 2>/dev/null; then + echo "ERROR: nothing listening on 41475." >&2 + echo " CCS quits when its client disconnects unless started with" >&2 + echo " -noportquit. Relaunch on Windows as: ccs.exe -noportquit" >&2 + exit 1 +fi + +# --- console capture, started BEFORE the image runs ------------------------- +if [ -c "$UART" ]; then + stty -F "$UART" 115200 cs8 -parenb -cstopb -crtscts raw -echo + : > "$LOG" + cat "$UART" > "$LOG" & + CAT_PID=$! + echo "capturing $UART -> $LOG (pid $CAT_PID)" +else + CAT_PID="" + echo "WARNING: $UART is not a character device; running with no console capture." >&2 + echo " The gdb report at the end still judges the run, but the" >&2 + echo " ORDER of events will be lost." >&2 +fi + +cleanup() { + if [ -n "$CAT_PID" ]; then + kill "$CAT_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +# Computed AFTER every use of the system python3 above: PYTHONHOME pointing at +# the source-built interpreter breaks system tools for the rest of the script, +# which is also why the port check further up uses /dev/tcp rather than Python. +P=$ZX_GDB_PYTHONHOME +export PYTHONHOME=$P +export PYTHONPATH=$P/lib/python3.10:$P/lib/python3.10/lib-dynload:$P/lib/python3.10/site-packages +export ZX_S32Z_ELF=$ELF + +# pkill -x, not -f: GTA runs as ./gta, so a -f pattern like 'gta/gta' never +# matches and a stale server silently serves the next attach. +pkill -x gta 2>/dev/null +sleep 2 +( cd "$GTA" && nohup ./gta -p 45000 -k > /tmp/gta_zx_probe.log 2>&1 & ) +sleep 4 +if ! pgrep -x gta > /dev/null; then + echo "ERROR: GTA failed to start; see /tmp/gta_zx_probe.log" >&2 + exit 1 +fi + +echo "running $ELF" +timeout -k 20 500 "$GDB" -batch -x "$HERE/run_zx_probe.gdb" +rc=$? + +# Let the last of the console output arrive before the capture is torn down. +sleep 2 + +echo "" +echo "===== console, as captured from $UART =====" +if [ -s "$LOG" ]; then + cat "$LOG" +else + echo "(nothing arrived on the console)" + echo "Check: jumper J248 at 1-2, micro-USB at J119 on the daughtercard, and" + echo "LED J122 for board-to-host traffic." +fi +echo "===== end of console =====" +echo "=== gdb exit=$rc ===" +exit $rc diff --git a/examples/s32z280_evb/zx_board.c b/examples/s32z280_evb/zx_board.c new file mode 100644 index 0000000..d7bf006 --- /dev/null +++ b/examples/s32z280_evb/zx_board.c @@ -0,0 +1,345 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* EXAMPLE DESCRIPTION */ +/* */ +/* zx_board.c S32Z280-594EVB */ +/* */ +/* DESCRIPTION */ +/* */ +/* What the S32Z280-594EVB supplies to the shared stage-2 probe: a */ +/* console on LINFlexD_9, and the two Device-attributed EL2 regions */ +/* that the hypervisor's own MMIO needs because HSCTLR.BR grants */ +/* permission and not attributes. */ +/* */ +/* THE ORDER PROBLEM, and it is the interesting thing about this file. */ +/* */ +/* ZoneX prints its identity block BEFORE it programs any region, so */ +/* the console has to work with no region covering it -- which it does */ +/* only because HSCTLR.BR=1 permits the access through the background */ +/* map. The attributes are wrong there (Normal Write-Through, from */ +/* TRM Table 8-4) and the console works anyway while EL2's caches are */ +/* off, which they are in this image. So the Device region programmed */ +/* below is not what makes the console work today; it is what makes it */ +/* keep working when step 5 turns caches on for the timing */ +/* measurements. Programming it now, and reporting that it was */ +/* programmed, is what stops that from being discovered then. */ +/* */ +/* BAUD RATE */ +/* */ +/* LINFlexD_9 is clocked by P5_LIN_BAUD_CLK, driven by MC_CGM_5 MUX 2. */ +/* Read from the board: MUX_2_CSS selects source 2 with the divider at */ +/* 1, and the EVB carries a 40 MHz crystal (UG10268 3.3.1.1): */ +/* */ +/* LFDIV = 40000000 / (16 * 115200) = 21.7014 */ +/* LINIBRR = 21 */ +/* LINFBRR = round(0.7014 * 16) = 11 */ +/* actual = 40000000 / (16 * 21.6875) = 115274 baud, 0.06% error */ +/* */ +/* MISRA C:2012 deviations (justified) */ +/* */ +/* Rule 11.4/11.6 -- casting integer addresses to volatile pointers is */ +/* inherent to memory-mapped device access; confined to ZX_REG32. */ +/* */ +/**************************************************************************/ + +#include "zx_probe.h" +#include "zx_platform.h" + +/* Register offsets, from the S32Z2 Reference Manual section 75.5.1. */ + +#define ZX_LINFLEXD_LINCR1 0x00U +#define ZX_LINFLEXD_LINSR 0x08U +#define ZX_LINFLEXD_UARTCR 0x10U +#define ZX_LINFLEXD_UARTSR 0x14U +#define ZX_LINFLEXD_LINFBRR 0x24U +#define ZX_LINFLEXD_LINIBRR 0x28U +#define ZX_LINFLEXD_BDRL 0x38U + +#define ZX_LINCR1_INIT 0x00000001U /* initialisation mode */ + +/* UARTCR. PCE at bit 2 and TxEn at bit 4 are the two that matter here and + are easy to transpose. */ + +#define ZX_UARTCR_UART 0x00000001U /* UART rather than LIN mode */ +#define ZX_UARTCR_WL0 0x00000002U /* with WL1 clear: 8-bit */ +#define ZX_UARTCR_PCE 0x00000004U /* parity enable */ +#define ZX_UARTCR_TXEN 0x00000010U +#define ZX_UARTCR_RXEN 0x00000020U + +#define ZX_UARTSR_DTF 0x00000002U /* transmit complete, w1c */ + +/* LINSR[15:12] is the LIN state; 0x1 is initialisation mode. */ + +#define ZX_LINSR_LINS_MASK 0x0000F000U +#define ZX_LINSR_LINS_INIT 0x00001000U + +/* Bounded rather than infinite: a console that hangs the boot is worse than + one that reports it could not configure itself. */ + +#define ZX_LINFLEXD_GUARD 100000U + +#define ZX_LINFLEXD_IBRR_115200 21U +#define ZX_LINFLEXD_FBRR_115200 11U + +#define ZX_CONSOLE_BASE ZX_S32Z_LINFLEX_9_BASE + +/* How many MMIO regions this board's hypervisor needs. See the file + header and docs/decisions.md D2. */ + +#define ZX_S32Z_MMIO_REGIONS 2U + +/**************************************************************************/ +/* zx_linflexd_configure_once */ +/* */ +/* One full pass of the configuration sequence. Called twice; see */ +/* zx_board_console_init for why. */ +/**************************************************************************/ + +static void zx_linflexd_configure_once(void) +{ + uint32_t guard; + uint32_t wanted; + + /* Enter initialisation mode and WAIT until the module reports it. Most + of UARTCR is writable only in initialisation mode, and the module does + not enter it in the same cycle as the LINCR1 write. Configuring + without waiting silently HALF-works: bits being set take effect while + bits being cleared do not, so PCE stays enabled and the line runs 8E1 + against a host expecting 8N1 -- which corrupts only those characters + whose parity bit happens to be 0 and leaves the rest readable, looking + for all the world like a marginal baud rate. */ + + ZX_REG32(ZX_CONSOLE_BASE + ZX_LINFLEXD_LINCR1) = ZX_LINCR1_INIT; + + guard = ZX_LINFLEXD_GUARD; + while ((((ZX_REG32(ZX_CONSOLE_BASE + ZX_LINFLEXD_LINSR) + & ZX_LINSR_LINS_MASK) != ZX_LINSR_LINS_INIT)) + && (guard > 0U)) + { + guard--; + } + + /* The UART bit gates writes to the rest of UARTCR, so it has to be set + before the fields that depend on it, not alongside them. */ + + ZX_REG32(ZX_CONSOLE_BASE + ZX_LINFLEXD_UARTCR) = ZX_UARTCR_UART; + + /* 8N1, transmit and receive. PCE deliberately left CLEAR: the BootROM + enables parity for serial boot, and a host on 8N1 would see framing + errors. Inheriting the BootROM's register state would also make this + image's behaviour depend on how the board was last booted. */ + + wanted = ZX_UARTCR_UART | ZX_UARTCR_WL0 | ZX_UARTCR_TXEN | ZX_UARTCR_RXEN; + ZX_REG32(ZX_CONSOLE_BASE + ZX_LINFLEXD_UARTCR) = wanted; + + ZX_REG32(ZX_CONSOLE_BASE + ZX_LINFLEXD_LINFBRR) = ZX_LINFLEXD_FBRR_115200; + ZX_REG32(ZX_CONSOLE_BASE + ZX_LINFLEXD_LINIBRR) = ZX_LINFLEXD_IBRR_115200; + + /* Leave initialisation mode; the module starts operating. */ + + ZX_REG32(ZX_CONSOLE_BASE + ZX_LINFLEXD_LINCR1) = 0U; +} + + +/**************************************************************************/ +/* zx_board_console_init */ +/**************************************************************************/ + +void zx_board_console_init(void) +{ + /* The sequence runs TWICE, and one pass is genuinely not enough. A + single pass gives a working console at -O0 and a corrupted one at -O2: + every character partially wrong, while UARTCR, LINIBRR and LINFBRR all + read back exactly the values written. The registers are right and the + line is wrong, so the failure is invisible to the caller -- the worst + property a console can have, and here the console IS the test method. + + The mechanism is not understood. Tested and rejected during the + Cortex-R52 port work: a bound 100x larger on the initialisation-mode + wait, settling delays before the first LINSR read and after leaving + initialisation mode, a barrier and read-back between the two UARTCR + writes, and waiting for LINSR to report the exit from initialisation + mode. None of those makes a single pass work at -O2. A second pass + does, reliably, at both optimisation levels. Verified on the + S32Z280-594EVB. If the underlying behaviour is ever identified, + revisit this. */ + + zx_linflexd_configure_once(); + zx_linflexd_configure_once(); +} + + +/**************************************************************************/ +/* zx_board_console_putc */ +/**************************************************************************/ + +void zx_board_console_putc(char character) +{ + uint32_t guard; + + if (character == '\n') + { + zx_board_console_putc('\r'); + } + + /* Start the byte, wait for completion, clear the flag, then wait for the + clear to actually take effect. + + The last step is the subtle one. DTF is write-one-to-clear and does + not de-assert in the same cycle as the clearing write, so without it + the NEXT byte's poll can observe this byte's flag, conclude the line is + free while it is still busy, and have its write silently dropped by the + transmitter. That cost exactly one character after every "\r\n" pair + -- the only place two bytes go out back to back -- and showed up as the + first letter of every line going missing. + + Clearing BEFORE the write instead does not fix it and is worse: the + write then lands while the previous byte is still shifting and is + dropped, and the poll afterwards sees the previous byte's completion, + so most of the output disappears. Order matters in both directions. */ + + ZX_REG32(ZX_CONSOLE_BASE + ZX_LINFLEXD_BDRL) = + (uint32_t)(unsigned char)character; + + /* Polled on purpose: this console runs before any interrupt controller + is configured, and it must work inside a fault handler. */ + + while ((ZX_REG32(ZX_CONSOLE_BASE + ZX_LINFLEXD_UARTSR) & ZX_UARTSR_DTF) + == 0U) + { + /* wait for this byte to go out */ + } + + ZX_REG32(ZX_CONSOLE_BASE + ZX_LINFLEXD_UARTSR) = ZX_UARTSR_DTF; + + guard = ZX_LINFLEXD_GUARD; + while (((ZX_REG32(ZX_CONSOLE_BASE + ZX_LINFLEXD_UARTSR) & ZX_UARTSR_DTF) + != 0U) && (guard > 0U)) + { + guard--; + } +} + + +/**************************************************************************/ +/* zx_board_init */ +/**************************************************************************/ + +void zx_board_init(void) +{ + /* Nothing beyond the console, which zx_console_puts brings up on first + use. The TCMs are deliberately left alone: this image places nothing + in them, and a TCM whose region registers have been programmed but + whose locations have not been written is a location that FAULTS on + first read, because ECC is enabled on this part (Cortex-R52 TRM + 6.2.2). Touching them would add a failure mode to an image whose + whole purpose is to isolate one. */ + + zx_board_console_init(); +} + + +/**************************************************************************/ +/* zx_board_mmio_region_count */ +/**************************************************************************/ + +uint32_t zx_board_mmio_region_count(void) +{ + return ZX_S32Z_MMIO_REGIONS; +} + + +/**************************************************************************/ +/* zx_board_program_mmio_regions */ +/* */ +/* AP is ZX_AP_EL2_RW_GUEST_NONE -- 0b00 -- so a guest cannot reach */ +/* either device. That is the one thing AP at EL2 can usefully do: it */ +/* cannot deny EL2, but it can deny EL0/EL1, and denying a partition the */ +/* hypervisor's own console and the GIC is exactly what Phase 0 wants. */ +/**************************************************************************/ + +void zx_board_program_mmio_regions(uint32_t first_index) +{ + zx_region_t region; + uint32_t console_index = first_index; + uint32_t gic_index = first_index + 1U; + + /* LINFlexD_9, the hypervisor's console. */ + + region.zx_region_base = (zx_addr_t)ZX_S32Z_LINFLEX_9_BASE; + region.zx_region_limit = (zx_addr_t)((ZX_S32Z_LINFLEX_9_BASE + + ZX_S32Z_LINFLEX_9_SIZE) - 1UL); + region.zx_region_ap = ZX_AP_EL2_RW_GUEST_NONE; + region.zx_region_xn = ZX_XN_NEVER; + region.zx_region_sh = ZX_SH_NON_SHAREABLE; + region.zx_region_attrindx = ZX_ATTR_DEVICE; + zx_stage2_region_program(console_index, ®ion); + + /* The GIC. Phase 0 configures no interrupts, and this region exists + anyway: with the GIC region Normal, or unmapped with caches on, an + access to it stalls this core outright -- no abort, no handler, and the + debug connection drops. Mapping it correctly from the first image is + cheaper than diagnosing that later. */ + + region.zx_region_base = (zx_addr_t)ZX_S32Z_GIC_BASE; + region.zx_region_limit = (zx_addr_t)((ZX_S32Z_GIC_BASE + + ZX_S32Z_GIC_SIZE) - 1UL); + region.zx_region_ap = ZX_AP_EL2_RW_GUEST_NONE; + region.zx_region_xn = ZX_XN_NEVER; + region.zx_region_sh = ZX_SH_NON_SHAREABLE; + region.zx_region_attrindx = ZX_ATTR_DEVICE; + zx_stage2_region_program(gic_index, ®ion); + + /* NOTHING IS PRINTED HERE, deliberately. This function runs BEFORE the + EL2 MPU is enabled -- it is what makes enabling it useful -- so the + console is still being reached through the background map as Normal + memory, and anything written now comes out corrupted. The first + version of this file printed the two region indices here and they + arrived as garbage in the middle of an otherwise clean log, which is + the same bug reported as a defect in its own diagnosis. + + zx_probe_main.c names the index range once the console is trustworthy, + and zx_board_report gives the two addresses. */ +} + + +/**************************************************************************/ +/* zx_board_report */ +/**************************************************************************/ + +void zx_board_report(void) +{ + zx_console_puts( + "\n--- what this target is ---\n" + " console: LINFlexD_9 at 0x42980000, polled, 115200 8N1. LIN9 is\n" + " the instance wired to the daughtercard USB-UART through\n" + " jumper J248; LINFlexD_0 reaches no connector here.\n" + " code: RTU0 code SRAM at 0x79900000, which is also this core's\n" + " reset address, so _start is linked first.\n" + " data: RTU-local data SRAM at 0x31780000 (DRAM0+DRAM1, both at\n" + " full core speed). RTU-local traffic never leaves the RTU.\n" + " NOTE: this is REAL SILICON, and the numbers below are a part's\n" + " rather than a model's. In particular HSCTLR.TE and\n" + " SCTLR.TE reset SET here, so an A32 vector table would\n" + " never run and every fault would present as a hang.\n" + " NOTE: the console at 0x42980000 and the GIC at 0x47800000 both\n" + " fall in the NORMAL WRITE-THROUGH band of the background\n" + " map (TRM Table 8-4), not the Device band. Each therefore\n" + " costs a Device-attributed EL2 region out of this part's\n" + " 20 -- the constraint the FVP cannot show you.\n"); +} diff --git a/examples/s32z280_evb/zx_link.lds b/examples/s32z280_evb/zx_link.lds new file mode 100644 index 0000000..c8c3e63 --- /dev/null +++ b/examples/s32z280_evb/zx_link.lds @@ -0,0 +1,206 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/* Link map for the ZoneX stage-2 probe on the NXP S32Z280-594EVB, RTU0 core 0. + * + * Code goes to the RTU code SRAM at its instruction-fetch address, + * 0x79900000, which is also where the core resets: MC_ME_PRTN0_CORE0_ADDR + * reads 0x79900000 on this board, so a debugger-loaded image needs no + * relocation to be reached by the reset vector. That window is writable over + * the debug AXI port even though NXP's memory map declares it read-only, so a + * plain "load" works and no LMA alias is required. A flash-booted image would + * have to be written through the data alias at 0x32100000. + * + * Data, bss, the Hyp stack and the payload's areas go to the RTU-local data + * SRAM at 0x31780000. RTU-local traffic does not leave the RTU. + * + * The TCMs are deliberately unused. They are not accessible at reset until + * their region registers are programmed, and ECC is enabled on this part, so a + * TCM location must be WRITTEN before it can be read (Cortex-R52 TRM 6.2.2). + * Nothing needed for early boot can live there, and adding a preload to this + * image would add a failure mode to one whose whole purpose is to isolate one. + * + * A NOTE ON ALIGN, because the two spellings are not equivalent + * + * Every ALIGN below is written AFTER the colon, which is the section's + * ALIGNMENT. Written before the colon it is the section's ADDRESS, evaluated + * from a location counter that starts at zero, and it silently overrides + * "> CODE" -- every section then lands near address 0 and the link fails with + * "not within region". The FVP script gets away with the address form only + * because its region starts at 0x00000000, so both scripts use the safe form. + * + * WHAT MAKES THIS SCRIPT DIFFERENT FROM AN ORDINARY BARE-METAL ONE + * + * Four areas are not merely aligned but SIZED to whole 64-byte granules, + * because each is a stage-2 MPU region and PMSAv8-R has a 64-byte granule + * with an inclusive limit: + * + * .zx_payload_text region 0, the payload's code + * .zx_payload_data region 1, the payload's data + * .zx_forbidden NO REGION AT ALL -- the whole point + * .zx_high_probe region 16, the high-region experiment + * + * .zx_forbidden is placed IMMEDIATELY AFTER .zx_payload_data, adjacent by + * construction. An ungranted address far from every grant proves only that + * unmapped memory faults; the defect class worth catching is a region whose + * limit or base is out by one granule, and only an adjacent hole catches it. + * Do not move it, and do not let anything land between the two. + * + * TWO MORE REGIONS EXIST ON THIS TARGET AND ARE NOT IN THIS SCRIPT: the + * hypervisor's console and the GIC, which are MMIO and have fixed addresses. + * See examples/s32z280_evb/zx_board.c and docs/decisions.md D2. + * + * ZoneX's OWN code, data, bss and stack are covered by no region at all -- + * decision D2, not an oversight. With HSCTLR.BR=1 EL2's own accesses fall + * back to the background map, while EL0/EL1 accesses that hit no region fault + * regardless of BR, so the hypervisor costs no region and is protected from + * the payload precisely by not being mapped. + */ + +ENTRY(_start) + +__zx_hyp_stack_size = 0x1000; + +MEMORY +{ + CODE (rx) : ORIGIN = 0x79900000, LENGTH = 0x00700000 /* 7 MB */ + DATA (rwx) : ORIGIN = 0x31780000, LENGTH = 0x00080000 /* 512 KB */ +} + +SECTIONS +{ + /* .boot MUST come first: MC_ME_PRTN0_CORE0_ADDR is 0x79900000 on this + board, so whatever lands at the start of CODE is what the core executes + out of reset, and that has to be _start. It must also be a T32 + instruction, since the core resets in Thumb state -- which is what + ZX_RESET_IN_THUMB arranges in zx_el2_entry.S. The vector table needs + alignment but not a fixed address, because HVBAR is programmed at run + time, so it follows rather than leads. */ + + .boot : + { + KEEP(*(.text.boot)) + } > CODE + + .vectors_el2 : + { + KEEP(*(.vectors_el2)) + } > CODE + + .text : + { + *(.text*) + *(.glue_7) + *(.glue_7t) + } > CODE + + .rodata : + { + *(.rodata*) + . = ALIGN(4); + } > CODE + + /* The payload's CODE goes in CODE, on a granule boundary, and is the + only part of the image in this memory that a stage-2 region covers. */ + + .zx_payload_text : ALIGN(64) + { + __zx_payload_code_start = .; + KEEP(*(.zx_payload_text)) + . = ALIGN(64); + __zx_payload_code_end = .; + } > CODE + + .data : ALIGN(8) + { + __zx_data_start = .; + *(.data*) + . = ALIGN(8); + __zx_data_end = .; + } > DATA + + .bss (NOLOAD) : ALIGN(8) + { + __zx_bss_start = .; + *(.bss*) + *(COMMON) + . = ALIGN(8); + __zx_bss_end = .; + } > DATA + + /* The hypervisor's Hyp-mode stack. SP must stay 8-byte aligned + (AAPCS), so the area is aligned before its top symbol is taken. */ + + .zx_stacks (NOLOAD) : ALIGN(8) + { + . = . + __zx_hyp_stack_size; + __zx_hyp_stack_top = .; + } > DATA + + /* ------------------------------------------------------------------ + The partition data areas. The ORDER of these three is load-bearing. + ------------------------------------------------------------------ */ + + .zx_payload_data : ALIGN(64) + { + __zx_payload_data_start = .; + KEEP(*(.zx_payload_data)) + . = ALIGN(64); + __zx_payload_data_end = .; + } > DATA + + /* One granule, covered by NO region. Adjacent to the payload's data by + construction -- see the header. */ + + .zx_forbidden (NOLOAD) : ALIGN(64) + { + __zx_forbidden_start = .; + . = . + 64; + __zx_forbidden_end = .; + } > DATA + + .zx_high_probe : ALIGN(64) + { + __zx_high_probe_start = .; + KEEP(*(.zx_high_probe)) + . = ALIGN(64); + __zx_high_probe_end = .; + } > DATA + + . = ALIGN(8); + PROVIDE(end = .); + PROVIDE(_end = .); + + /* Assert the geometry the region programming depends on, so that a layout + mistake fails the LINK rather than the run. An under-aligned base does + not fault: its low bits land on SH, AP and XN and silently change the + region's attributes, which is the hardest failure in this architecture + to see. */ + + ASSERT((__zx_payload_code_start & 63) == 0, + "payload code region base is not 64-byte aligned") + ASSERT((__zx_payload_code_end & 63) == 0, + "payload code region is not a whole number of granules") + ASSERT((__zx_payload_data_start & 63) == 0, + "payload data region base is not 64-byte aligned") + ASSERT((__zx_payload_data_end & 63) == 0, + "payload data region is not a whole number of granules") + ASSERT(__zx_forbidden_start == __zx_payload_data_end, + "the ungranted granule is NOT adjacent to the payload's data region") + ASSERT((__zx_high_probe_start & 63) == 0, + "the high-probe region base is not 64-byte aligned") + ASSERT(__zx_high_probe_start >= __zx_forbidden_end, + "the high-probe region overlaps the ungranted granule") +} diff --git a/examples/s32z280_evb/zx_platform.h b/examples/s32z280_evb/zx_platform.h new file mode 100644 index 0000000..ac54094 --- /dev/null +++ b/examples/s32z280_evb/zx_platform.h @@ -0,0 +1,120 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* EXAMPLE DESCRIPTION */ +/* */ +/* zx_platform.h S32Z280-594EVB */ +/* */ +/* DESCRIPTION */ +/* */ +/* Memory map and peripheral addresses for the NXP S32Z280-594EVB, */ +/* RTU0 core 0 (R52_0_0). */ +/* */ +/* Every address below was either read out of the S32Z2 Reference */ +/* Manual (S32Z27RM Rev. 5.1) or confirmed on the board during the */ +/* Cortex-R52 port work, and the distinction matters: this part */ +/* differs from the Armv8-R AEM FVP in ways that are SILENT rather */ +/* than loud. An address that is merely wrong tends to read back */ +/* zero instead of faulting. */ +/* */ +/* WHY THIS TARGET NEEDS TWO HYPERVISOR MMIO REGIONS AND THE FVP NEEDS */ +/* NONE */ +/* */ +/* HSCTLR.BR=1 lets EL2's own accesses fall back to the background */ +/* map, which costs no region -- but BR grants PERMISSION, not */ +/* ATTRIBUTES. The background map is Normal, Write-Through cacheable */ +/* from 0x40000000 to 0x5FFFFFFF (Cortex-R52 TRM Table 8-4), and both */ +/* of this board's devices that ZoneX owns land in it: */ +/* */ +/* LINFlexD_9, the console 0x42980000 */ +/* GIC 0x47800000 */ +/* */ +/* Reaching a memory-mapped device with cacheable attributes is not a */ +/* working device; it is a bug that appears intermittently. So each */ +/* needs a Device-attributed EL2 region, and each one comes straight */ +/* out of the 20-region budget the hypervisor shares with every */ +/* partition. On the FVP the same two devices sit in the Device band */ +/* and the background map is accidentally correct, which is exactly */ +/* why the model cannot validate this. See docs/decisions.md D2. */ +/* */ +/**************************************************************************/ + +#ifndef ZX_PLATFORM_H +#define ZX_PLATFORM_H + +#define ZX_PLATFORM_NAME "NXP S32Z280-594EVB, RTU0 core 0 (R52_0_0)" + +/* Code SRAM, instruction-fetch view. 7 MB. This is where the core resets + to -- MC_ME_PRTN0_CORE0_ADDR reads 0x79900000 on this board -- so whatever + lands at the start of it is what executes, and _start has to be there. + [verified on board] */ + +#define ZX_S32Z_CODE_SRAM_BASE 0x79900000UL +#define ZX_S32Z_CODE_SRAM_SIZE 0x00700000UL + +/* The same physical SRAM seen as data, 0x32100000. Recorded because a + flash-booted image would have to be written through it; a debugger-loaded + image does not need it, since the fetch window is writable over the debug + AXI port even though NXP's memory map declares it read-only. + [verified on board] */ + +#define ZX_S32Z_CODE_SRAM_DATA_ALIAS 0x32100000UL + +/* RTU-local data SRAM. DRAM0 and DRAM1 are contiguous and both run at full + core speed, which is what makes them the right home for .data, .bss, the + Hyp stack and the payload's areas. DRAM2 at 0x31800000 is half speed and + unused here. [S32Z27RM 6.3.6, confirmed on board] */ + +#define ZX_S32Z_DATA_SRAM_BASE 0x31780000UL +#define ZX_S32Z_DATA_SRAM_SIZE 0x00080000UL /* 512 KB */ + +/* Console. LINFlexD_9, because the EVB user guide (UG10268) dedicates LIN9 + to the daughtercard USB-UART through jumper J248. NOT LINFlexD_0: that is + simply the first instance in the Reference Manual's list and reaches no + connector on this board. [RM + board] */ + +#define ZX_S32Z_LINFLEX_9_BASE 0x42980000UL + +/* The window ZoneX maps for it. 16 KB covers the register block with room + to spare, is a whole number of 64-byte granules, and is small enough that + nothing else the hypervisor might later want to map can collide with it -- + PMSAv8-R has no region priority, and two enabled regions matching one + address abort on this part. */ + +#define ZX_S32Z_LINFLEX_9_SIZE 0x00004000UL + +/* GICv3 for this R52 cluster. IMP_CBAR reports the distributor base in bits + [31:21] and reads 0x47800000 on this board, which confirms NXP's map from + the hardware -- the SoC reference manual gives no GIC address at all. + Frame layout from Cortex-R52 TRM Table 9-1. [verified on board] + + Mapped Device-nGnRnE. With the region Normal, or with the MPU disabled, an + access to it STALLS THE CORE outright on this part: no abort, no fault + handler, and the debug connection drops. That is the most expensive + version of the D2 problem and it is the reason this region exists even + though Phase 0 configures no interrupts. */ + +#define ZX_S32Z_GIC_BASE 0x47800000UL +#define ZX_S32Z_GIC_SIZE 0x00200000UL /* 2 MB */ + +/* Memory-mapped register access. + MISRA C:2012 Rule 11.4/11.6 deviation: casting an integer address to a + volatile pointer is inherent to memory-mapped device access. */ + +#define ZX_REG32(address) (*(volatile uint32_t *)(uintptr_t)(address)) + +#endif /* ZX_PLATFORM_H */ diff --git a/platform/cortex_r52/CMakeLists.txt b/platform/cortex_r52/CMakeLists.txt index cbdc722..0114d86 100644 --- a/platform/cortex_r52/CMakeLists.txt +++ b/platform/cortex_r52/CMakeLists.txt @@ -20,11 +20,28 @@ target_sources(zonex_port PRIVATE ${CMAKE_CURRENT_LIST_DIR}/src/zx_el2_entry.S ${CMAKE_CURRENT_LIST_DIR}/src/zx_trap_handler.S ${CMAKE_CURRENT_LIST_DIR}/src/zx_console.c + ${CMAKE_CURRENT_LIST_DIR}/src/zx_el2_cpu.c + ${CMAKE_CURRENT_LIST_DIR}/src/zx_el2_fault_path.c ${CMAKE_CURRENT_LIST_DIR}/src/zx_gic.c ${CMAKE_CURRENT_LIST_DIR}/src/zx_stage2_mpu.c ${CMAKE_CURRENT_LIST_DIR}/src/zx_timer.c ) +# zx_port.h is read by the assembler as well as the compiler, so the include +# directory has to be on the ASM command line too. target_sources with a +# FILE_SET below publishes it to consumers; this is what makes it visible to +# this target's own .S files, which is a different thing and easy to assume +# has already happened. +target_include_directories(zonex_port PUBLIC + ${CMAKE_CURRENT_LIST_DIR}/inc +) + +# The board facts, from the root CMakeLists.txt. PUBLIC so that the examples +# see them too: examples/s32z280_evb/zx_board.c does not need them today, but +# a board file that disagreed with the port about whether there is semihosting +# would be a very quiet bug. +target_compile_definitions(zonex_port PUBLIC ${ZX_PORT_BOARD_DEFINITIONS}) + target_sources(zonex_port PUBLIC FILE_SET zx_port_headers TYPE HEADERS diff --git a/platform/cortex_r52/inc/zx_port.h b/platform/cortex_r52/inc/zx_port.h index aaceed5..bb4a146 100644 --- a/platform/cortex_r52/inc/zx_port.h +++ b/platform/cortex_r52/inc/zx_port.h @@ -12,6 +12,7 @@ * * SPDX-License-Identifier: MIT and CC0-1.0 **************************************************************************/ +// Some portions generated by Claude Code (Opus 5). /**************************************************************************/ /* */ @@ -24,13 +25,21 @@ /* The Cortex-R52 port interface: everything ZoneX needs that is */ /* specific to Armv8-R AArch32 at EL2. */ /* */ +/* This header is included from BOTH C and preprocessed assembly, so */ +/* that a bit position cannot be spelled one way in zx_el2_entry.S and */ +/* another way in zx_stage2_mpu.c. Everything above the __ASSEMBLER__ */ +/* guard is therefore preprocessor-only; the ZX_BIT and ZX_C32 macros */ +/* exist so that the same definition can carry a U suffix in C, where */ +/* -Wconversion is in force, and no suffix in assembly, where the */ +/* assembler would reject it. */ +/* */ /* TERMINOLOGY -- READ THIS BEFORE ADDING ANYTHING BELOW */ /* */ /* The Cortex-R52 is AArch32-only and Armv8-R has NO PAGE TABLES at */ /* either stage of translation. Both stages are region-based MPUs */ /* (PMSAv8-R). The registers this port uses are therefore the */ /* AArch32 Hyp-mode ones, reached through MRC/MCR p15, 4, , ... */ -/* (opc1 = 4 is the Hyp bank): */ +/* (opc1 = 4 is the Hyp bank; opc1 = 5 reaches HPRBAR16 and above): */ /* */ /* HVBAR EL2 vector base HSR syndrome */ /* HCR hypervisor configuration HDFAR data fault address */ @@ -40,7 +49,7 @@ /* HPRENR region enables HMAIR1 memory attributes */ /* HPRBAR region base CNTHCTL timer access control */ /* HPRLAR region limit CNTVOFF virtual time offset */ -/* ICC_HSRE GICv3 sysreg interface */ +/* ICC_HSRE GICv3 sysreg interface ICH_VTR List Register count */ /* */ /* A second family of names is WRONG BY CONSTRUCTION for this target */ /* and must not appear anywhere in this repository: the AArch64 */ @@ -57,26 +66,479 @@ /* are in docs/armv8r-el2-reference.md, which is the authority. Take */ /* them from there rather than from memory or from an A-profile */ /* hypervisor; the AP field in particular does NOT mean at EL2 what it */ -/* means at EL1. */ -/* */ -/* STATUS */ -/* */ -/* Declared empty. The CP15 accessors and the first stage-2 region */ -/* programming arrive with the EL2 bring-up work. */ +/* means at EL1, which is why the AP encodings below are named for */ +/* what they grant rather than numbered. */ /* */ /**************************************************************************/ #ifndef ZX_PORT_H #define ZX_PORT_H +/* One definition, two languages. The assembler tolerates no integer suffix + and C under -Wconversion wants one on every constant that reaches a + uint32_t, so the suffix is applied by macro rather than by writing each + constant twice. ZX_C32 pastes the suffix, so its argument must be a single + literal token; a composite value is written as (ZX_C32(0x3) << 3). */ + +#ifdef __ASSEMBLER__ +# define ZX_BIT(n) (1 << (n)) +# define ZX_C32(v) v +#else +# define ZX_BIT(n) (1U << (n)) +# define ZX_C32(v) v ## U +#endif + +/**************************************************************************/ +/* AArch32 processor modes and masks */ +/**************************************************************************/ + +#define ZX_MODE_USR ZX_C32(0x10) +#define ZX_MODE_FIQ ZX_C32(0x11) +#define ZX_MODE_IRQ ZX_C32(0x12) +#define ZX_MODE_SVC ZX_C32(0x13) /* an EL1 mode */ +#define ZX_MODE_ABT ZX_C32(0x17) +#define ZX_MODE_HYP ZX_C32(0x1A) /* EL2 */ +#define ZX_MODE_UND ZX_C32(0x1B) +#define ZX_MODE_SYS ZX_C32(0x1F) +#define ZX_MODE_MASK ZX_C32(0x1F) + +#define ZX_PSR_A ZX_BIT(8) /* asynchronous abort mask */ +#define ZX_PSR_I ZX_BIT(7) /* IRQ mask */ +#define ZX_PSR_F ZX_BIT(6) /* FIQ mask */ + +/**************************************************************************/ +/* HCR */ +/**************************************************************************/ + +#define ZX_HCR_VM ZX_BIT(0) /* stage-2 MPU enable */ +#define ZX_HCR_AMO ZX_BIT(3) /* route aborts to EL2 */ +#define ZX_HCR_IMO ZX_BIT(4) /* route IRQ to EL2 */ +#define ZX_HCR_FMO ZX_BIT(5) /* route FIQ to EL2 */ +#define ZX_HCR_DC ZX_BIT(12) /* default cacheable */ +#define ZX_HCR_TGE ZX_BIT(27) /* trap general exceptions */ +#define ZX_HCR_HCD ZX_BIT(29) /* HVC disable -- keep CLEAR */ + +/**************************************************************************/ +/* HSCTLR */ +/**************************************************************************/ + +#define ZX_HSCTLR_M ZX_BIT(0) /* EL2 MPU enable */ +#define ZX_HSCTLR_A ZX_BIT(1) /* alignment check */ +#define ZX_HSCTLR_C ZX_BIT(2) /* data cache */ +#define ZX_HSCTLR_I ZX_BIT(12) /* instruction cache */ +#define ZX_HSCTLR_BR ZX_BIT(17) /* background region enable */ +#define ZX_HSCTLR_TE ZX_BIT(30) /* Thumb exception entry */ + +/**************************************************************************/ +/* SCTLR */ +/**************************************************************************/ + +/* Needed at EL2 because ZoneX must leave the EL1 MPU in a known state before + handing a payload the machine. Step 2 deliberately leaves SCTLR.M CLEAR: + with stage 1 disabled the payload runs on the EL1 background map and the + only thing under test is stage 2. One variable at a time. */ + +#define ZX_SCTLR_M ZX_BIT(0) +#define ZX_SCTLR_C ZX_BIT(2) +#define ZX_SCTLR_I ZX_BIT(12) +#define ZX_SCTLR_BR ZX_BIT(17) +#define ZX_SCTLR_TE ZX_BIT(30) + +/**************************************************************************/ +/* Region descriptor fields */ +/**************************************************************************/ + +/* HPRBAR: BASE[31:6], RES0[5], SH[4:3], AP[2:1], XN[0] (TRM Table 3-80) + HPRLAR: LIMIT[31:6], RES0[5:4], AttrIndx[3:1], EN[0] (TRM Table 3-83) + + Both address fields are bits [31:6] of an address, which is the whole + reason ZX_MPU_GRANULE exists: an under-aligned base does not fault, its low + bits land on SH, AP and XN and silently change the region's attributes. A + limit computed as base + size - 1 already ends in 0x3F, and those bits land + on AttrIndx and select an unwritten HMAIR byte. MASK BOTH before ORing + anything in. */ + +#define ZX_REGION_ADDR_MASK ZX_C32(0xFFFFFFC0) + +#define ZX_HPRBAR_XN ZX_BIT(0) +#define ZX_HPRBAR_AP_SHIFT 1U +#define ZX_HPRBAR_SH_SHIFT 3U + +#define ZX_HPRLAR_EN ZX_BIT(0) +#define ZX_HPRLAR_ATTRINDX_SHIFT 1U + +/* AP[2:1] for the EL2-controlled MPU, TRM Table 3-82. Named rather than + numbered because there is NO encoding that grants a guest access while + denying EL2 -- a partition's memory is always reachable from the + hypervisor, and isolation between partitions comes from which regions are + ENABLED, never from these bits. See docs/decisions.md D3. */ + +#define ZX_AP_EL2_RW_GUEST_NONE ZX_C32(0x0) +#define ZX_AP_EL2_RW_GUEST_RW ZX_C32(0x1) +#define ZX_AP_EL2_RO_GUEST_NONE ZX_C32(0x2) +#define ZX_AP_EL2_RO_GUEST_RO ZX_C32(0x3) + +/* SH[1:0]. 0b01 is UNPREDICTABLE for Normal memory (TRM Table 3-81), so a + zeroed field -- Non-shareable -- is the safe default and the only value + Phase 0 uses. */ + +#define ZX_SH_NON_SHAREABLE ZX_C32(0x0) +#define ZX_SH_OUTER_SHAREABLE ZX_C32(0x2) +#define ZX_SH_INNER_SHAREABLE ZX_C32(0x3) + +/* XN. 1 = execute never. */ + +#define ZX_XN_EXECUTABLE ZX_C32(0x0) +#define ZX_XN_NEVER ZX_C32(0x1) + +/**************************************************************************/ +/* Memory attributes: HMAIR0/1 */ +/**************************************************************************/ + +/* Attribute indices ZoneX programs, and the byte each one holds. + AttrIndx 0-3 index HMAIR0 bytes 0-3; 4-7 index HMAIR1. + + Index 1 is spelled 0x00 EXPLICITLY even though an unwritten HMAIR byte + already reads as zero, which is Device-nGnRnE. Relying on that is how a + masking bug in the limit field survived at EL1: the wrong AttrIndx selected + an unwritten byte, got Device-nGnRnE, and the region still worked well + enough to pass a careless test. Programming every index ZoneX names means + a wrong index produces a WRONG ATTRIBUTE rather than a plausible one. */ + +#define ZX_ATTR_NORMAL_WB ZX_C32(0x0) /* Normal, write-back, RW-alloc */ +#define ZX_ATTR_DEVICE ZX_C32(0x1) /* Device-nGnRnE */ +#define ZX_ATTR_NORMAL_NC ZX_C32(0x2) /* Normal, non-cacheable */ + +#define ZX_MAIR_BYTE_NORMAL_WB ZX_C32(0xFF) +#define ZX_MAIR_BYTE_DEVICE ZX_C32(0x00) +#define ZX_MAIR_BYTE_NORMAL_NC ZX_C32(0x44) + +/* HMAIR0 = byte3:byte2:byte1:byte0 for indices 3, 2, 1, 0. Index 3 is left + zero -- Device-nGnRnE -- because it is unused and Device is the + conservative value for an index nothing should be selecting. */ + +#define ZX_HMAIR0_VALUE ZX_C32(0x004400FF) +#define ZX_HMAIR1_VALUE ZX_C32(0x00000000) + +/**************************************************************************/ +/* Region count limits */ +/**************************************************************************/ + +/* HMPUIR[7:0] must read 0, 16, 20 or 24 (TRM Table 3-79). Anything else + means the target is not an architecturally legal Cortex-R52 configuration, + which the Armv8-R AEM FVP is not: it reported 32 EL1 regions during the + Cortex-R52 port work and reports 32 at EL2 as well. ZoneX therefore + asserts the value LOUDLY and names it rather than trusting it. */ + +#define ZX_EL2_REGIONS_NONE 16U +#define ZX_EL2_REGIONS_MAX 24U + +/**************************************************************************/ +/* GICv3 at EL2 */ +/**************************************************************************/ + +/* ICC_HSRE (p15, 4, c12, c9, 5) enables the GICv3 SYSTEM REGISTER interface. + Until SRE is set, every other ICC_* and ICH_* system register is + UNDEFINED -- which is not a documentation nicety: reading ICH_VTR before + setting this takes an Undefined Instruction exception at EL2, measured on + the Armv8-R AEM FVP on 2 September 2026. So the reset path sets it before + the identity block reads anything GIC-shaped, and EL1 needs it set anyway + to acknowledge an interrupt at all. */ + +#define ZX_ICC_HSRE_SRE ZX_BIT(0) +#define ZX_ICC_HSRE_ENABLE ZX_BIT(3) + +/**************************************************************************/ +/* Semihosting (FVP console and exit) */ +/**************************************************************************/ + +#define ZX_SYS_WRITE0 ZX_C32(0x04) +#define ZX_SYS_EXIT ZX_C32(0x18) + +/* AArch32 SYS_EXIT takes a REASON, not an exit status, which is why every + ZoneX image is judged on its printed verdict rather than on a host exit + code. Both reasons are passed anyway so that a run which stops for the + wrong reason is distinguishable in the model's own trace. */ + +#define ZX_ADP_STOPPED_APPLICATION_EXIT ZX_C32(0x20026) +#define ZX_ADP_STOPPED_RUN_TIME_ERROR ZX_C32(0x20023) + +/**************************************************************************/ +/* Outcome of running an EL1 payload */ +/**************************************************************************/ + +/* What zx_el2_run_payload returns. It is a real return: the trap handler + restores the EL2 context that zx_el2_run_payload saved and resumes it, so a + guest fault comes back to the hypervisor as a VALUE rather than as a jump + into a handler that has to decide policy with no context. Step 3 keeps + this shape; it is how a partition switch will be driven. */ + +#define ZX_RUN_YIELDED ZX_C32(0x0) /* payload returned, HVC #1 */ +#define ZX_RUN_FAULTED ZX_C32(0x1) /* stage-2 fault, EC 0x24/0x20 */ +#define ZX_RUN_TRAPPED ZX_C32(0x2) /* something else reached EL2 */ + +/**************************************************************************/ +/* Constants the assembly needs, mirrored -- and asserted */ +/**************************************************************************/ + +/* zx_el2_entry.S and zx_trap_handler.S need exception classes, vector + offsets and structure offsets that are otherwise declared in core/'s + zx_fault.h -- a portable header that includes and so cannot be + read by the assembler. They are therefore mirrored here, without suffixes, + under a ZX_ASM_ prefix. + + Mirroring a constant is how two definitions drift apart, so each one is + checked against its original by a _Static_assert in + platform/cortex_r52/src/zx_stage2_mpu.c. The structure offsets are checked + against offsetof, which means a field inserted into zx_fault_record_t + breaks the BUILD rather than the fault report -- and a fault report that + quietly writes HDFAR into the HPFAR slot is the worst possible outcome for + a file whose entire job is to be believed. */ + +#define ZX_ASM_EC_SHIFT 26 +#define ZX_ASM_EC_HVC 0x12 +#define ZX_ASM_EC_PABT_ROUTED 0x20 +#define ZX_ASM_EC_DABT_ROUTED 0x24 + +#define ZX_ASM_HVC_IMM_NOP 0x0000 +#define ZX_ASM_HVC_IMM_YIELD 0x0001 + +#define ZX_ASM_VEC_RESET 0x00 +#define ZX_ASM_VEC_UNDEF 0x04 +#define ZX_ASM_VEC_SVC 0x08 +#define ZX_ASM_VEC_PABT_HYP 0x0C +#define ZX_ASM_VEC_DABT_HYP 0x10 +#define ZX_ASM_VEC_HYP_TRAP 0x14 +#define ZX_ASM_VEC_IRQ 0x18 +#define ZX_ASM_VEC_FIQ 0x1C + +#define ZX_ASM_FAULT_OFF_CAPTURES 0 +#define ZX_ASM_FAULT_OFF_VECTOR 4 +#define ZX_ASM_FAULT_OFF_HSR 8 +#define ZX_ASM_FAULT_OFF_HPFAR 12 +#define ZX_ASM_FAULT_OFF_HDFAR 16 +#define ZX_ASM_FAULT_OFF_HIFAR 20 +#define ZX_ASM_FAULT_OFF_ELR 24 +#define ZX_ASM_FAULT_OFF_SPSR 28 + +/* The EL2 context zx_el2_run_payload saves so that the trap handler can + resume it: SP, LR, then the eight callee-saved registers r4-r11. */ + +#define ZX_ASM_RESUME_OFF_SP 0 +#define ZX_ASM_RESUME_OFF_LR 4 +#define ZX_ASM_RESUME_OFF_R4 8 +#define ZX_ASM_RESUME_WORDS 10 + +#ifndef __ASSEMBLER__ + #include "zx_api.h" +#include "zx_console.h" +#include "zx_fault.h" #ifdef __cplusplus extern "C" { #endif +/**************************************************************************/ +/* Region descriptor */ +/**************************************************************************/ + +/* One stage-2 region, as the manifest will eventually describe it and as + zx_stage2_region_program writes it. The limit is INCLUSIVE, matching the + hardware rather than converting at every call site: PMSAv8-R has no size + field, and a length that has to be turned into an inclusive limit in three + places is a length that gets it wrong in one of them. */ + +typedef struct zx_region_struct +{ + zx_addr_t zx_region_base; /* first byte, granule aligned */ + zx_addr_t zx_region_limit; /* LAST byte, inclusive */ + uint32_t zx_region_ap; /* ZX_AP_* */ + uint32_t zx_region_xn; /* ZX_XN_* */ + uint32_t zx_region_sh; /* ZX_SH_* */ + uint32_t zx_region_attrindx; /* ZX_ATTR_* */ +} zx_region_t; + +/**************************************************************************/ +/* Identity and capability */ +/**************************************************************************/ + +ZX_NODISCARD uint32_t zx_read_midr(void); +ZX_NODISCARD uint32_t zx_read_mpidr(void); +ZX_NODISCARD uint32_t zx_read_cntfrq(void); +ZX_NODISCARD uint32_t zx_read_mpuir(void); +ZX_NODISCARD uint32_t zx_read_hmpuir(void); +ZX_NODISCARD uint32_t zx_read_hsctlr(void); +ZX_NODISCARD uint32_t zx_read_hcr(void); +ZX_NODISCARD uint32_t zx_read_hsr(void); +ZX_NODISCARD uint32_t zx_read_hvbar(void); +ZX_NODISCARD uint32_t zx_read_ich_vtr(void); +ZX_NODISCARD uint32_t zx_read_cpsr(void); + +/* Region counts, extracted from the two type registers. They are different + FIELDS in the two registers -- MPUIR keeps the EL1 count in bits [15:8] and + HMPUIR the EL2 count in bits [7:0] -- which is exactly the kind of detail + that is wrong once and then copied. */ + +ZX_NODISCARD uint32_t zx_el1_region_count(void); +ZX_NODISCARD uint32_t zx_el2_region_count(void); + +/* True when count is one of the values TRM Table 3-79 permits. */ + +ZX_NODISCARD uint32_t zx_el2_region_count_is_legal(uint32_t count); + +/**************************************************************************/ +/* Stage-2 MPU programming */ +/**************************************************************************/ + +void zx_mair_program(void); + +/* Program one region through HPRSELR/HPRBAR/HPRLAR and leave it ENABLED. + Masks base and limit to the granule before ORing attributes in, which is + the whole reason this is a function rather than two stores. */ + +void zx_stage2_region_program(uint32_t index, const zx_region_t *region_ptr); + +/* Read one region back through HPRSELR/HPRBAR/HPRLAR. Read-back is not + paranoia here: a region programmed at an index the implementation does not + have is UNPREDICTABLE, and a value that did not stick is the difference + between a wrong region and no region. */ + +void zx_stage2_region_read(uint32_t index, uint32_t *base_ptr, + uint32_t *limit_ptr); + +/* Program region 16 through the DIRECT encoding, MCR p15, 5, , c6, c8, 0 + and 1. Separate from the indirect path on purpose: TRM 3.3.48's prose says + direct access stops at region 15 while 8.4 lists HPRBAR16-HPRBAR24 at + opc1 = 5, and this function is how that contradiction is settled on the + part rather than on paper. Region 16 specifically, because that is the + first index where the two readings differ. Step 3 generalises it once the + answer is known; guessing the generalisation first would be building on the + thing under test. */ + +void zx_stage2_region_program_direct16(const zx_region_t *region_ptr); +void zx_stage2_region_read_direct16(uint32_t *base_ptr, uint32_t *limit_ptr); + +/* HPRENR: one enable bit per region, and the register whose width the TRM + contradicts itself about. */ + +ZX_NODISCARD uint32_t zx_hprenr_read(void); +void zx_hprenr_write(uint32_t value); +void zx_hprenr_enable(uint32_t index); +void zx_hprenr_disable(uint32_t index); + +/* Turning protection on is TWO steps, and separating them is not tidiness. + + zx_el2_mpu_enable sets HSCTLR.BR and HSCTLR.M: the EL2-controlled MPU, + which governs EL2's OWN accesses. zx_stage2_enable then sets HCR.VM, + which is what makes that same region set apply to EL0/EL1 as stage 2. + + They are separate because the hypervisor needs the first one BEFORE it can + trust its own console. On a board whose console sits below 0x60000000 the + background map reaches it as NORMAL memory, which permits the gathering and + reordering that corrupts a polled UART -- measured on the S32Z280, where + the identity block printed as legible-but-wrong text until its Device + region was in place and HSCTLR.M was set. So the order is: HMAIR, the + hypervisor's own MMIO regions, zx_el2_mpu_enable, THEN the console, then + the partition regions, then zx_stage2_enable. + + Enabling HCR.VM with no region covering the payload's code faults at the + ERET target, which presents as "the guest never started" and looks nothing + like a memory-protection problem -- which is why the partition regions are + a precondition of the second step rather than part of it. */ + +void zx_el2_mpu_enable(void); +void zx_stage2_enable(void); +void zx_stage2_disable(void); + +/**************************************************************************/ +/* Running an EL1 payload */ +/**************************************************************************/ + +/* Drop to EL1 at entry_address with argument in r0, and return when the + payload yields (HVC #1) or is taken from by a fault. Returns ZX_RUN_*. + + Implemented in zx_trap_handler.S. It does not return through the ERET it + performed: the trap handler restores the context saved here and resumes it, + so this is a one-way transfer out and a resume back in. */ + +ZX_NODISCARD uint32_t zx_el2_run_payload(zx_addr_t entry_address, + uint32_t argument); + +/* The statically allocated fault record the trap handler fills in. Static, + and filled in before anything else runs, because the earliest evidence is + the only evidence that cannot have been damaged by the handler itself. + + The object is declared here rather than hidden behind the accessor because + zx_trap_handler.S writes it directly, by name, at the ZX_ASM_FAULT_OFF_* + offsets above. A reader of that assembly needs to be able to find the + declaration it is writing through. */ + +extern zx_fault_record_t zx_el2_fault_storage; + +ZX_NODISCARD zx_fault_record_t *zx_el2_fault_record(void); + +/* How many times HVC #0 -- the empty Phase-0 hypercall vector -- has been + taken. Counted rather than ignored so that "the seam is live" is a number + a test can assert on. */ + +extern uint32_t zx_el2_hypercall_counter; + +ZX_NODISCARD uint32_t zx_el2_hypercall_count(void); + +/* Snapshots the reset path takes before it changes anything, so that the + identity block can report what the PART reset to rather than what ZoneX + left behind. HSCTLR.TE and SCTLR.TE reset SET on the S32Z280 and clear on + the Armv8-R AEM FVP; a report that read them after the reset path had + cleared them would show the same value on both targets and hide the single + most expensive difference between them. */ + +extern uint32_t zx_reset_hsctlr; +extern uint32_t zx_reset_sctlr; +extern uint32_t zx_reset_cpsr; +extern uint32_t zx_reset_hprenr; + +/* What sticks in HPRENR when all ones are written, taken by the reset path + before any region exists. This says which bits are IMPLEMENTED; whether a + bit above 15 actually disables its region is a different question and is + answered functionally by the probe image. Both are needed: a register that + accepted the bit and ignored it would pass this test and fail that one, and + that is the outcome that would make a one-write partition switch silently + wrong. */ + +extern uint32_t zx_hprenr_implemented_bits; + +/* The run's verdict, as an object as well as a printed line, so that a + silicon run with no console capture is still judgeable. 0xFFFFFFFF until + zx_console_exit sets it, because zero would be a plausible reading of a + run that never reached its verdict at all. */ + +extern uint32_t zx_run_failures; + +/**************************************************************************/ +/* Entry points the reset path calls */ +/**************************************************************************/ + +/* zx_el2_main is where zx_el2_entry.S hands over, and it does not return. + The board provides zx_board_init, called first: on the FVP it has nothing + to do, and on silicon it brings up the console the rest of the run reports + through. */ + +ZX_NORETURN void zx_el2_main(void); +void zx_board_init(void); + +/* Called from the vectors that mean ZoneX itself faulted, or that mean an + exception nothing was expecting reached EL2. Both report and stop; neither + can safely resume, and pretending otherwise would turn a diagnosable bug + into a corrupted run. */ + +ZX_NORETURN void zx_el2_hypervisor_fault(void); +ZX_NORETURN void zx_el2_unexpected_vector(void); + #ifdef __cplusplus } #endif +#endif /* __ASSEMBLER__ */ + #endif /* ZX_PORT_H */ diff --git a/platform/cortex_r52/src/zx_console.c b/platform/cortex_r52/src/zx_console.c index 4d8c3a9..b02d117 100644 --- a/platform/cortex_r52/src/zx_console.c +++ b/platform/cortex_r52/src/zx_console.c @@ -12,6 +12,7 @@ * * SPDX-License-Identifier: MIT and CC0-1.0 **************************************************************************/ +// Some portions generated by Claude Code (Opus 5). /**************************************************************************/ /* */ @@ -26,14 +27,225 @@ /* all -- an Iris server and no GDB stub -- so a model regression can */ /* only report through a channel the image carries itself. */ /* */ +/* Two backends, chosen at build time and never at run time: */ +/* */ +/* * semihosting, the default. HLT 0xF000 is the Armv8 AArch32 */ +/* semihosting trap, and the model implements it with no */ +/* peripheral configuration at all -- which is exactly why it is */ +/* the right console to bring up BEFORE the MPU is trusted. A */ +/* UART console can be broken by a wrong memory map; this cannot. */ +/* */ +/* * ZX_CONSOLE_BOARD, where the board example supplies */ +/* zx_board_console_init and zx_board_console_putc. The board's */ +/* UART is not part of the Armv8-R architecture, so its driver */ +/* lives with the board rather than in this port. */ +/* */ /* How the console is shared once there are two partitions is an open */ /* decision, docs/decisions.md D8, deliberately left to the step that */ /* first has two guests wanting it. */ /* */ -/* This translation unit is deliberately empty of implementation. */ -/* See docs/armv8r-el2-reference.md for the verified register sheet */ -/* the code that lands here must be written against. */ +/* MISRA C:2012 deviations (justified) */ +/* */ +/* Directive 4.3 -- the single asm statement is alone in */ +/* zx_semihost_call and appears nowhere else in this file. */ +/* Rule 1.1 / 1.2 -- register-asm bindings and inline assembly are */ +/* unavoidable to invoke a semihosting trap; no standard C construct */ +/* expresses it. */ /* */ /**************************************************************************/ #include "zx_port.h" + +/* Number of hexadecimal digits in a 32-bit value. */ + +#define ZX_HEX_DIGITS 8U + +/* The verdict, published as an object as well as printed. On the FVP the + printed line is what the runner judges; on silicon there is no console + capture inside the image's own control, so the gdb harness reads this. + Both, rather than either, because a run whose console was not captured + still has to be judgeable -- and a run whose memory says PASS while its + console says FAIL is a finding in itself. */ + +uint32_t zx_run_failures = 0xFFFFFFFFU; + + +#ifndef ZX_CONSOLE_BOARD + +/**************************************************************************/ +/* zx_semihost_call */ +/* */ +/* One semihosting operation. The only assembly in this file. */ +/**************************************************************************/ + +static uint32_t zx_semihost_call(uint32_t operation, const void *argument_ptr) +{ + register uint32_t result __asm__("r0") = operation; + register const void *argument __asm__("r1") = argument_ptr; + + __asm__ volatile("hlt 0xf000" + : "+r"(result) + : "r"(argument) + : "memory"); + + return result; +} + +#endif /* !ZX_CONSOLE_BOARD */ + + +/**************************************************************************/ +/* zx_console_puts */ +/**************************************************************************/ + +void zx_console_puts(const char *string_ptr) +{ + if (string_ptr == (const char *)0) + { + return; + } + +#ifdef ZX_CONSOLE_BOARD + + { + const char *cursor = string_ptr; + + /* Initialised on first use rather than from zx_board_init, so that a + message emitted before the board hook ran still appears. The + first thing worth printing is often the reason the boot did not + get as far as initialising anything. */ + + static uint32_t initialised = 0U; + + if (initialised == 0U) + { + initialised = 1U; + zx_board_console_init(); + } + + while (*cursor != '\0') + { + zx_board_console_putc(*cursor); + cursor++; + } + } + +#else + + (void) zx_semihost_call(ZX_SYS_WRITE0, string_ptr); + +#endif +} + + +/**************************************************************************/ +/* zx_console_puthex */ +/* */ +/* Always eight digits. Zero padding is not cosmetic here: the report */ +/* puts HPFAR next to HDFAR so that a reader can compare them by eye, and */ +/* two values of different printed widths do not line up. */ +/**************************************************************************/ + +void zx_console_puthex(uint32_t value) +{ + static const char digits[] = "0123456789abcdef"; + char buffer[3U + ZX_HEX_DIGITS]; /* "0x" + digits + NUL */ + uint32_t index; + + buffer[0] = '0'; + buffer[1] = 'x'; + + for (index = 0U; index < ZX_HEX_DIGITS; index++) + { + uint32_t shift = (ZX_HEX_DIGITS - 1U - index) * 4U; + + buffer[2U + index] = digits[(value >> shift) & 0xFU]; + } + + buffer[2U + ZX_HEX_DIGITS] = '\0'; + + zx_console_puts(buffer); +} + + +/**************************************************************************/ +/* zx_console_putdec */ +/**************************************************************************/ + +void zx_console_putdec(uint32_t value) +{ + char buffer[11]; /* 4294967295 plus NUL */ + uint32_t index = sizeof(buffer) - 1U; + uint32_t work = value; + + buffer[index] = '\0'; + + do + { + index--; + buffer[index] = (char)('0' + (char)(work % 10U)); + work /= 10U; + } while ((work != 0U) && (index > 0U)); + + zx_console_puts(&buffer[index]); +} + + +/**************************************************************************/ +/* zx_console_exit */ +/* */ +/* The end of every run, and the symbol the silicon gdb harness breaks */ +/* on. The verdict has already been printed by the caller: on AArch32 */ +/* the SYS_EXIT parameter is a REASON code and not an exit status, so a */ +/* run is judged on its console text and not on what the model returns. */ +/* Both reasons are passed anyway, so that a run stopping for the wrong */ +/* reason is distinguishable in the model's own trace. */ +/**************************************************************************/ + +/**************************************************************************/ +/* zx_console_run_parked */ +/* */ +/* The symbol a silicon debug harness breaks on. See zx_console.h. */ +/**************************************************************************/ + +ZX_NOINLINE void zx_console_run_parked(void) +{ + /* The empty asm keeps the body from being optimised away entirely; the + function has to exist as an address, not merely as a name. */ + + __asm__ volatile("" ::: "memory"); +} + + +ZX_NORETURN void zx_console_exit(uint32_t failures) +{ + zx_run_failures = failures; + + __asm__ volatile("dsb" ::: "memory"); + + /* Everything is settled: the verdict has been printed and stored. A + hardware breakpoint here reads the real result. */ + + zx_console_run_parked(); + +#ifndef ZX_CONSOLE_BOARD + + { + uint32_t reason = (failures == 0U) ? ZX_ADP_STOPPED_APPLICATION_EXIT + : ZX_ADP_STOPPED_RUN_TIME_ERROR; + + (void) zx_semihost_call(ZX_SYS_EXIT, (const void *)reason); + } + +#endif + + /* Reached on silicon always, and on the model only if a host or debug + configuration ignored the semihosting exit. Parking rather than + returning: zx_console_exit is declared not to return, and a run that + carried on past its own verdict would print a second one. */ + + for (;;) + { + /* Intentionally empty. */ + } +} diff --git a/platform/cortex_r52/src/zx_el2_cpu.c b/platform/cortex_r52/src/zx_el2_cpu.c new file mode 100644 index 0000000..bb7178a --- /dev/null +++ b/platform/cortex_r52/src/zx_el2_cpu.c @@ -0,0 +1,265 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_el2_cpu.c Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* The CP15 identity and capability reads, and the objects the EL2 */ +/* assembly and the C code share. */ +/* */ +/* Every accessor here is one MRC and nothing else. They are */ +/* deliberately not macros: a macro that pastes an encoding is a */ +/* macro whose expansion nobody reads, and the encodings are the one */ +/* thing in this port that must be checkable against */ +/* docs/armv8r-el2-reference.md line by line. Each carries its */ +/* CRn, opc1, CRm, opc2 in the comment beside it for exactly that. */ +/* */ +/* Two register-count details are wrong once and then copied for ever, */ +/* so they are separated into named functions rather than left at */ +/* their call sites: the EL1 count lives in MPUIR[15:8] and the EL2 */ +/* count in HMPUIR[7:0]. Different registers, different fields. */ +/* */ +/* MISRA C:2012 deviations (justified) */ +/* */ +/* Directive 4.3 (assembly shall be encapsulated and isolated) -- */ +/* observed rather than violated: every asm statement in this file is */ +/* alone in a function that does nothing else. */ +/* Rule 1.1 / 1.2 (language extensions) -- inline assembly is the only */ +/* way to reach a coprocessor register; no standard C construct */ +/* expresses MRC. */ +/* */ +/**************************************************************************/ + +#include "zx_port.h" + +/**************************************************************************/ +/* Objects shared with the assembly */ +/**************************************************************************/ + +/* Written by zx_trap_handler.S, at the ZX_ASM_FAULT_OFF_* offsets, before + anything else runs after an exception. Read by zx_fault_report. */ + +zx_fault_record_t zx_el2_fault_storage; + +/* Incremented by the HVC #0 path in zx_trap_handler.S. */ + +uint32_t zx_el2_hypercall_counter; + +/* Written once by zx_el2_entry.S, from registers, before .bss is zeroed -- + which is why these live in .data and are not merely uninitialised. Zero + would be a plausible reading of every one of them, so a value that never + arrived must not look like a value that did. */ + +uint32_t zx_reset_hsctlr = 0xFFFFFFFFU; +uint32_t zx_reset_sctlr = 0xFFFFFFFFU; +uint32_t zx_reset_cpsr = 0xFFFFFFFFU; +uint32_t zx_reset_hprenr = 0xFFFFFFFFU; + +/* Not 0xFFFFFFFF as its unwritten value: all-ones is a PLAUSIBLE answer here + -- it is what a 32-region implementation reports -- so a value that never + arrived must not look like one that did. 0xDEADBEEF cannot be mistaken for + a set of implemented enable bits. */ + +uint32_t zx_hprenr_implemented_bits = 0xDEADBEEFU; + +/**************************************************************************/ +/* Identity and capability registers */ +/**************************************************************************/ + +uint32_t zx_read_midr(void) +{ + uint32_t value; + + /* MIDR: p15, 0, c0, c0, 0 */ + __asm__ volatile("mrc p15, 0, %0, c0, c0, 0" : "=r"(value)); + + return value; +} + + +uint32_t zx_read_mpidr(void) +{ + uint32_t value; + + /* MPIDR: p15, 0, c0, c0, 5 */ + __asm__ volatile("mrc p15, 0, %0, c0, c0, 5" : "=r"(value)); + + return value; +} + + +uint32_t zx_read_cntfrq(void) +{ + uint32_t value; + + /* CNTFRQ: p15, 0, c14, c0, 0 */ + __asm__ volatile("mrc p15, 0, %0, c14, c0, 0" : "=r"(value)); + + return value; +} + + +uint32_t zx_read_mpuir(void) +{ + uint32_t value; + + /* MPUIR: p15, 0, c0, c0, 4 */ + __asm__ volatile("mrc p15, 0, %0, c0, c0, 4" : "=r"(value)); + + return value; +} + + +uint32_t zx_read_hmpuir(void) +{ + uint32_t value; + + /* HMPUIR: p15, 4, c0, c0, 4 */ + __asm__ volatile("mrc p15, 4, %0, c0, c0, 4" : "=r"(value)); + + return value; +} + + +uint32_t zx_read_hsctlr(void) +{ + uint32_t value; + + /* HSCTLR: p15, 4, c1, c0, 0 */ + __asm__ volatile("mrc p15, 4, %0, c1, c0, 0" : "=r"(value)); + + return value; +} + + +uint32_t zx_read_hcr(void) +{ + uint32_t value; + + /* HCR: p15, 4, c1, c1, 0 */ + __asm__ volatile("mrc p15, 4, %0, c1, c1, 0" : "=r"(value)); + + return value; +} + + +uint32_t zx_read_hsr(void) +{ + uint32_t value; + + /* HSR: p15, 4, c5, c2, 0 */ + __asm__ volatile("mrc p15, 4, %0, c5, c2, 0" : "=r"(value)); + + return value; +} + + +uint32_t zx_read_hvbar(void) +{ + uint32_t value; + + /* HVBAR: p15, 4, c12, c0, 0 */ + __asm__ volatile("mrc p15, 4, %0, c12, c0, 0" : "=r"(value)); + + return value; +} + + +uint32_t zx_read_ich_vtr(void) +{ + uint32_t value; + + /* ICH_VTR: p15, 4, c12, c11, 1. ListRegs is bits [4:0], and holds the + number of List Registers MINUS ONE -- the Cortex-R52 resets it to + 0x90180003, which is four registers, ICH_LR0 to ICH_LR3. Recorded + because interrupt injection being AVAILABLE on this part corrects an + assumption the roadmap carried; see docs/armv8r-el2-reference.md. */ + __asm__ volatile("mrc p15, 4, %0, c12, c11, 1" : "=r"(value)); + + return value; +} + + +uint32_t zx_read_cpsr(void) +{ + uint32_t value; + + __asm__ volatile("mrs %0, cpsr" : "=r"(value)); + + return value; +} + + +/**************************************************************************/ +/* zx_el1_region_count -- MPUIR[15:8]. */ +/**************************************************************************/ + +uint32_t zx_el1_region_count(void) +{ + return (zx_read_mpuir() >> 8U) & 0xFFU; +} + + +/**************************************************************************/ +/* zx_el2_region_count -- HMPUIR[7:0]. */ +/* */ +/* NOT [15:8]. The two type registers keep their counts in different */ +/* fields, and reading HMPUIR the way MPUIR is read gives zero -- which */ +/* is a LEGAL value meaning "no EL2-controlled MPU regions", so the */ +/* mistake produces a believable answer rather than an obvious one. */ +/**************************************************************************/ + +uint32_t zx_el2_region_count(void) +{ + return zx_read_hmpuir() & 0xFFU; +} + + +/**************************************************************************/ +/* zx_el2_region_count_is_legal */ +/* */ +/* TRM Table 3-79 permits 0, 16, 20 or 24 and nothing else. The Armv8-R */ +/* AEM FVP reports 32, which no Cortex-R52 can have, so this returning */ +/* false is an EXPECTED outcome on the model rather than a broken read. */ +/* What matters is that it is said out loud: a green model run proves */ +/* nothing about any real part's region budget. */ +/**************************************************************************/ + +uint32_t zx_el2_region_count_is_legal(uint32_t count) +{ + return ((count == 0U) || (count == 16U) || (count == 20U) + || (count == 24U)) ? 1U : 0U; +} + + +/**************************************************************************/ +/* zx_el2_fault_record / zx_el2_hypercall_count */ +/**************************************************************************/ + +zx_fault_record_t *zx_el2_fault_record(void) +{ + return &zx_el2_fault_storage; +} + + +uint32_t zx_el2_hypercall_count(void) +{ + return zx_el2_hypercall_counter; +} diff --git a/platform/cortex_r52/src/zx_el2_entry.S b/platform/cortex_r52/src/zx_el2_entry.S index 3674b8d..78dd3e2 100644 --- a/platform/cortex_r52/src/zx_el2_entry.S +++ b/platform/cortex_r52/src/zx_el2_entry.S @@ -12,30 +12,308 @@ * * SPDX-License-Identifier: MIT and CC0-1.0 **************************************************************************/ +@ Some portions generated by Claude Code (Opus 5). @ zx_el2_entry.S -- Cortex-R52 -- the ZoneX EL2 reset and entry path. @ @ DESCRIPTION @ @ Where ZoneX starts. Establishes the Hyp-mode stack, installs HVBAR, -@ programs CNTFRQ, opens CNTHCTL and ICC_HSRE for EL1, brings up the EL2 -@ MPU with HSCTLR.BR=1, and hands control to the partition manager -- -@ staying at EL2 rather than dropping to EL1, which is what distinguishes -@ this from the ThreadX R52 port's entry.S that it is modelled on. +@ puts EL2 in a known configuration, zeroes .bss and hands over to +@ zx_el2_main -- staying at EL2 rather than dropping to EL1, which is what +@ distinguishes this from the ThreadX Cortex-R52 port's entry.S that it is +@ modelled on. In ZoneX, EL2 is the program and EL1 is a payload it +@ launches; see zx_trap_handler.S for that launch. @ -@ The ThreadX Cortex-R52 port's entry.S is the reference for the EL2 -@ configuration sequence and already does most of this for the ThreadX -@ case; what it does NOT do is leave HCR.VM set, and turning stage 2 on is -@ exactly what ZoneX adds. +@ THE ORDER BELOW IS LOAD-BEARING @ -@ STATUS +@ 1. HSCTLR.TE is cleared FIRST, before anything can fault. TE set means +@ EL2 exceptions are entered in T32 state, so an A32 vector table never +@ runs and every fault presents as a silent hang. It resets SET on the +@ S32Z280 -- SCTLR reads 0x70C50838 there, bit 30 high -- and clear on +@ the Armv8-R AEM FVP, which is exactly the shape of difference that +@ makes a green model run worthless as evidence about the part. Its +@ pre-clear value is kept and reported. @ -@ Deliberately empty. The change that founded this repository wrote no -@ assembly on purpose: every register name this file will use had to be -@ verified first, and that verification is docs/armv8r-el2-reference.md. +@ 2. SCTLR.M is cleared, leaving the EL1 MPU DISABLED. That is deliberate +@ for the first stage-2 image: with stage 1 off, the payload runs on the +@ EL1 background map and the only thing under test is stage 2. One +@ variable at a time; stage 1 comes back when a real ThreadX guest does. +@ +@ 3. The Hyp stack exists before anything is pushed at EL2. +@ +@ 4. HVBAR is installed before HCR is touched, so that if anything after it +@ faults, the fault is reported instead of vanishing. +@ +@ 5. HCR.VM stays CLEAR here. Turning stage 2 on is the whole subject of +@ this repository and it happens in C, in zx_stage2_enable, after the +@ regions exist -- enabling HCR.VM with no region covering the payload's +@ code faults at the ERET target and presents as "the guest never +@ started", which looks nothing like a memory-protection problem. +@ +@ HMAIR0/HMAIR1 are programmed in C rather than here, in zx_mair_program, +@ called from zx_el2_main before any region is written. The ordering +@ requirement is only that attributes exist before HSCTLR.M is set, and C is +@ where the byte values are readable next to the AttrIndx names they belong +@ to. @ @ NOTE ON SYNTAX @ @ GNU assembler syntax, per CONTRIBUTING.md. '@' is the AArch32 comment @ character; this file is assembled through the C preprocessor (it is -@ .S, not .s), so C-style comments work here as well. +@ .S, not .s), so it shares every constant with the C sources through +@ zx_port.h and spells none of them itself. + +#include "zx_port.h" + + .syntax unified + +@ No .arch directive. GNU as accepts "armv8-r" and LLVM's integrated +@ assembler accepts no spelling of it at all, failing with "Unknown Arch". +@ The architecture is already selected by -mcpu=cortex-r52 on the command +@ line, which both toolchain files pass, so the directive only restated it. + +/**************************************************************************/ +/* _start -- reset entry, at EL2 (Hyp mode). */ +/* */ +/* Whether the core arrives here in A32 or T32 state is a BOARD fact, not */ +/* an architectural one, so it is a build option rather than a run-time */ +/* test -- there is nothing to test with before the first instruction. */ +/* */ +/* ZX_RESET_IN_THUMB is defined for the S32Z280, where the RTU boot */ +/* instruction NXP plants is a T32 branch and CPSR reads 0x1FA out of */ +/* reset. An A32 entry there executes the first halfword of its own */ +/* instruction as Thumb and goes anywhere. The Armv8-R AEM FVP enters in */ +/* A32 state and needs no trampoline. */ +/* */ +/* .text.boot is linked FIRST on the S32Z280, because the core's reset */ +/* address is the start of its code SRAM: whatever lands there is what */ +/* executes, and it has to be _start. */ +/**************************************************************************/ + +#ifdef ZX_RESET_IN_THUMB + + .section .text.boot, "ax" + .thumb + .balign 2 + .global _start + .type _start, %function +_start: + + @ CPSR as the very first instruction, before the state switch. + @ + @ AND IT STILL CANNOT SEE THE T BIT. Measured: this instruction reads + @ 0x000001DA on BOTH ZoneX targets, while the S32Z280 demonstrably + @ begins execution in T32 state -- the instruction doing the reading is + @ a T32 encoding and executes correctly -- and the Armv8-R AEM FVP + @ begins in A32. Two different execution states, one reading. So + @ MRS Rd, CPSR does not report PSTATE.T, and an image cannot learn its + @ own execution state from it. A DEBUGGER can: gdb shows T set on the + @ board, which is where the 0x1FA reading recorded during the + @ Cortex-R52 port work came from. + @ + @ The snapshot is taken here anyway, because everything else in CPSR -- + @ the mode field above all -- is exactly what wants recording, and this + @ is the earliest instruction there is. r6 carries it into A32; nothing + @ between here and the store touches it. + + mrs r6, cpsr + + @ Leave Thumb. zx_el2_start_a32 is an A32 symbol, so the linker clears + @ bit 0 of its address and BX switches state. + + ldr r0, =zx_el2_start_a32 + bx r0 + .size _start, . - _start + +#endif + + .section .text.boot, "ax" + .arm + .balign 4 + +#ifndef ZX_RESET_IN_THUMB + .global _start + .type _start, %function +_start: +#endif + + .global zx_el2_start_a32 + .type zx_el2_start_a32, %function +zx_el2_start_a32: + + @ ------------------------------------------------------------------ + @ 1. HSCTLR.TE, before anything can fault. r4 keeps the reset value + @ so that the identity block can report what the part reset to. + @ + @ C and I go with it, leaving EL2's caches OFF. That is the simplest + @ memory configuration a bring-up failure can be reproduced in, and + @ it is what the Cortex-R52 port defaults to for the same reason. It + @ also removes one variable from the stage-2 measurements this image + @ exists to make. Caches are a step-5 concern, where the timing + @ claims are, and they will need HMAIR and the region attributes to + @ be right first -- which is what this image establishes. + @ ------------------------------------------------------------------ + + mrc p15, 4, r4, c1, c0, 0 @ HSCTLR, as reset + ldr r0, =(ZX_HSCTLR_TE | ZX_HSCTLR_C | ZX_HSCTLR_I) + bic r0, r4, r0 + mcr p15, 4, r0, c1, c0, 0 + isb + + @ ------------------------------------------------------------------ + @ 2. SCTLR: clear TE for the same reason, and clear M so that the EL1 + @ MPU is OFF. Caches stay off too -- see the note below. + @ ------------------------------------------------------------------ + + mrc p15, 0, r5, c1, c0, 0 @ SCTLR, as reset + ldr r0, =(ZX_SCTLR_TE | ZX_SCTLR_M | ZX_SCTLR_C | ZX_SCTLR_I) + bic r0, r5, r0 + mcr p15, 0, r0, c1, c0, 0 + isb + +#ifndef ZX_RESET_IN_THUMB + + @ CPSR as reset, which is what says which mode and state the core + @ actually arrived in rather than which one this file assumed. On a + @ target that resets in T32 this was already taken in the trampoline + @ above, BEFORE the state switch -- see the note there. + + mrs r6, cpsr + +#endif + + @ ------------------------------------------------------------------ + @ 3. Hyp stack. + @ ------------------------------------------------------------------ + + ldr sp, =__zx_hyp_stack_top + + @ ------------------------------------------------------------------ + @ 4. EL2 vector table. + @ ------------------------------------------------------------------ + + ldr r0, =zx_el2_vectors + mcr p15, 4, r0, c12, c0, 0 @ HVBAR + isb + + @ ------------------------------------------------------------------ + @ 5. HCR, into a known state. + @ + @ Every bit here is cleared, including the ones that reset clear, + @ because "it resets to what we want" is a claim about a part and + @ ZoneX runs on more than one: + @ + @ VM stage 2 stays off until zx_stage2_enable turns it on. + @ TGE set would route EL1's own exceptions differently and stop + @ the guest's vectors being used at all -- not what Phase 0 + @ wants, since a guest must keep its own exception handling. + @ AMO a stage-2 fault is taken to EL2 because it is a fault in + @ IMO the EL2 translation regime, NOT because AMO routes it, so + @ FMO EL1's own aborts and interrupts are left with EL1. + @ HCD clear keeps HVC available, which is the hypercall seam. + @ ------------------------------------------------------------------ + + mrc p15, 4, r0, c1, c1, 0 @ HCR + ldr r1, =(ZX_HCR_TGE | ZX_HCR_AMO | ZX_HCR_IMO | ZX_HCR_FMO | \ + ZX_HCR_VM | ZX_HCR_HCD | ZX_HCR_DC) + bic r0, r0, r1 + mcr p15, 4, r0, c1, c1, 0 + isb + + @ ------------------------------------------------------------------ + @ 5b. GICv3 system-register interface. + @ + @ This is not GIC bring-up -- Phase 0 configures no interrupts at + @ all. It is here because ICC_HSRE.SRE is what makes every other + @ ICC_* and ICH_* system register EXIST: reading ICH_VTR with SRE + @ clear takes an Undefined Instruction exception at EL2. That was + @ measured on the Armv8-R AEM FVP rather than read anywhere, and the + @ identity block reads ICH_VTR to report the List Register count. + @ ------------------------------------------------------------------ + + mrc p15, 4, r0, c12, c9, 5 @ ICC_HSRE + orr r0, r0, #(ZX_ICC_HSRE_SRE | ZX_ICC_HSRE_ENABLE) + mcr p15, 4, r0, c12, c9, 5 + isb + + @ ------------------------------------------------------------------ + @ 5c. HPRENR, and how wide it really is. + @ + @ The TRM contradicts itself about this register: its prose says + @ HPRENR covers "regions 0 to 15" while its own bit tables give + @ [19:0] for a 20-region implementation and [23:0] for a 24-region + @ one. Two things are measured here and both are recorded: + @ + @ r7 the reset value. + @ r8 what sticks when all ones are written -- which says which + @ bits are implemented AT ALL, as opposed to which bits WORK. + @ + @ The read-back is the weaker of the two tests and is taken anyway, + @ because the failure worth catching is a register that accepts a + @ bit above 15 and IGNORES it: that passes a read-back test and + @ fails a functional one, and only having both distinguishes it + @ from a register that is genuinely narrow. The functional test -- + @ disable a region above 15 and prove the memory it covered stops + @ being reachable from EL1 -- is in the probe image. + @ + @ Safe here and nowhere later: HSCTLR.M resets CLEAR on both ZoneX + @ targets, so no region is being consulted and enabling twenty of + @ them with UNKNOWN bases cannot produce the overlapping-region + @ abort that PMSAv8-R's lack of region priority makes possible. + @ HPRENR is put back to zero immediately. + @ ------------------------------------------------------------------ + + mrc p15, 4, r7, c6, c1, 1 @ HPRENR, as reset + mvn r0, #0 + mcr p15, 4, r0, c6, c1, 1 + isb + mrc p15, 4, r8, c6, c1, 1 @ how much of all-ones stuck + mov r0, #0 + mcr p15, 4, r0, c6, c1, 1 + isb + + @ ------------------------------------------------------------------ + @ 6. Zero .bss. Before this, no C object may be read -- which is why + @ the four snapshots above ride in registers rather than being + @ stored as they are taken. + @ ------------------------------------------------------------------ + + ldr r0, =__zx_bss_start + ldr r1, =__zx_bss_end + mov r2, #0 +zx_el2_bss_zero_loop: + cmp r0, r1 + bhs zx_el2_bss_zero_done + str r2, [r0], #4 + b zx_el2_bss_zero_loop +zx_el2_bss_zero_done: + + @ ------------------------------------------------------------------ + @ 7. Publish the snapshots and hand over. + @ ------------------------------------------------------------------ + + ldr r0, =zx_reset_hsctlr + str r4, [r0] + ldr r0, =zx_reset_sctlr + str r5, [r0] + ldr r0, =zx_reset_cpsr + str r6, [r0] + ldr r0, =zx_reset_hprenr + str r7, [r0] + ldr r0, =zx_hprenr_implemented_bits + str r8, [r0] + + dsb + isb + + bl zx_el2_main + + @ zx_el2_main is ZX_NORETURN. If it returns anyway, the image is not in + @ a state anybody should reason about, so it stops here rather than + @ running off the end of .text into whatever follows. + + .global zx_el2_start_hang +zx_el2_start_hang: + b zx_el2_start_hang + .size zx_el2_start_a32, . - zx_el2_start_a32 diff --git a/platform/cortex_r52/src/zx_el2_fault_path.c b/platform/cortex_r52/src/zx_el2_fault_path.c new file mode 100644 index 0000000..53f270a --- /dev/null +++ b/platform/cortex_r52/src/zx_el2_fault_path.c @@ -0,0 +1,108 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_el2_fault_path.c Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* Where the vectors that cannot be resumed end up. */ +/* */ +/* Two of them, and the difference between them is the point: */ +/* */ +/* zx_el2_hypervisor_fault HVBAR + 0x0C or + 0x10. ZoneX faulted */ +/* on its OWN access -- HSR.EC 0x21 or 0x25. This is a hypervisor */ +/* bug. It gets its own message and its own exit code, and it is */ +/* never folded in with a guest violation, because a Phase-0 run */ +/* that reported ZoneX faulting on itself as "the guest was */ +/* stopped at the boundary" would pass while proving nothing. */ +/* */ +/* zx_el2_unexpected_vector reset, undefined instruction, SVC from */ +/* Hyp, IRQ or FIQ at EL2. Each means an assumption in the reset */ +/* path is false: HCR.IMO and HCR.FMO are cleared there, so an IRQ */ +/* arriving at EL2 means something set them. */ +/* */ +/* Neither resumes. There is nothing safe to resume to: whatever */ +/* invariant the saved EL2 context assumed may already be false, and */ +/* carrying on would turn a diagnosable bug into a corrupted run. */ +/* */ +/* Both are reached by BL from the vector, so the fault record has */ +/* already been captured by the time they run. That ordering is not */ +/* an implementation detail: a handler that prints before it captures */ +/* can lose the syndrome to its own console driver faulting. */ +/* */ +/**************************************************************************/ + +#include "zx_port.h" + +/* Distinct exit codes. The FVP runner judges a run on its printed text, so + these matter mostly on silicon, where the gdb harness reads zx_run_failures + -- and there the difference between "a guest was stopped" and "ZoneX broke" + has to survive without a console. */ + +#define ZX_EXIT_HYPERVISOR_FAULT 0x5AU +#define ZX_EXIT_UNEXPECTED_VECTOR 0x5BU + + +/**************************************************************************/ +/* zx_el2_hypervisor_fault */ +/**************************************************************************/ + +ZX_NORETURN void zx_el2_hypervisor_fault(void) +{ + zx_console_puts("\n" + "=========================================================\n" + " ZONEX FAULTED AT EL2. This is a HYPERVISOR bug.\n" + "\n" + " The exception arrived at an EL2 vector reserved for faults\n" + " taken FROM Hyp mode, so the access that failed was ZoneX's\n" + " own -- not a guest's. Nothing below describes a partition\n" + " being stopped at its boundary.\n" + "=========================================================\n"); + + zx_fault_report(zx_el2_fault_record()); + + zx_console_puts("\nZONEX RESULT: FAILED -- ZoneX faulted at EL2\n"); + zx_console_exit(ZX_EXIT_HYPERVISOR_FAULT); +} + + +/**************************************************************************/ +/* zx_el2_unexpected_vector */ +/**************************************************************************/ + +ZX_NORETURN void zx_el2_unexpected_vector(void) +{ + zx_console_puts("\n" + "=========================================================\n" + " UNEXPECTED EL2 VECTOR. An exception nothing was expecting\n" + " reached EL2, which means an assumption in the reset path is\n" + " false. The vector named below says which one.\n" + "\n" + " Read the VECTOR, not HSR. Reset, undefined instruction, SVC\n" + " from Hyp, IRQ and FIQ do not update HSR at all, so the\n" + " syndrome printed below may be left over from an earlier\n" + " exception. ELR_hyp is the address that faulted and is the\n" + " one useful number in that case -- disassemble it.\n" + "=========================================================\n"); + + zx_fault_report(zx_el2_fault_record()); + + zx_console_puts("\nZONEX RESULT: FAILED -- unexpected EL2 vector\n"); + zx_console_exit(ZX_EXIT_UNEXPECTED_VECTOR); +} diff --git a/platform/cortex_r52/src/zx_stage2_mpu.c b/platform/cortex_r52/src/zx_stage2_mpu.c index 8411c12..ae1d46c 100644 --- a/platform/cortex_r52/src/zx_stage2_mpu.c +++ b/platform/cortex_r52/src/zx_stage2_mpu.c @@ -12,6 +12,7 @@ * * SPDX-License-Identifier: MIT and CC0-1.0 **************************************************************************/ +// Some portions generated by Claude Code (Opus 5). /**************************************************************************/ /* */ @@ -40,10 +41,451 @@ /* the Device band, so the FVP cannot show you that. See */ /* docs/decisions.md D2. */ /* */ -/* This translation unit is deliberately empty of implementation. */ -/* See docs/armv8r-el2-reference.md for the verified register sheet */ -/* the code that lands here must be written against. */ +/* Two traps are paid for here once so that no caller pays them again, */ +/* and both were first paid at EL1 during the Cortex-R52 Modules port */ +/* work, where the field layouts are identical: */ +/* */ +/* * an under-aligned BASE does not fault. Its low bits land on SH, */ +/* AP and XN and silently change the region's attributes. */ +/* * an unmasked LIMIT is worse. base + size - 1 ends in 0x3F, and */ +/* those bits land on HPRLAR.AttrIndx and select an unwritten HMAIR */ +/* byte -- which reads as Device-nGnRnE, so the region still */ +/* "works" well enough to pass a careless test. */ +/* */ +/* Both are masked in zx_region_bar/zx_region_lar below, which is why */ +/* programming a region is a function call and not two stores. */ +/* */ +/* MISRA C:2012 deviations (justified) */ +/* */ +/* Directive 4.3 -- every asm statement below is alone in a function */ +/* that does nothing else. */ +/* Rule 1.1 / 1.2 -- inline assembly is the only way to reach a */ +/* coprocessor register. */ /* */ /**************************************************************************/ #include "zx_port.h" + +/**************************************************************************/ +/* The mirrored assembly constants, checked against their originals */ +/**************************************************************************/ + +/* zx_port.h mirrors a handful of constants without integer suffixes so that + the assembler can read them. These assertions are what makes the mirror + safe: change one side and the build stops here, naming the pair. + + The structure offsets matter most. zx_trap_handler.S writes HSR, HPFAR, + HDFAR, HIFAR, ELR_hyp and SPSR_hyp through fixed offsets into + zx_fault_record_t, so a field inserted into that structure would silently + move every subsequent register into the wrong slot -- and the resulting + fault report would be internally consistent, plausible, and wrong. */ + +_Static_assert(ZX_ASM_EC_SHIFT == (int)ZX_HSR_EC_SHIFT, + "ZX_ASM_EC_SHIFT and ZX_HSR_EC_SHIFT have drifted apart"); +_Static_assert(ZX_ASM_EC_HVC == (int)ZX_EC_HVC, + "ZX_ASM_EC_HVC and ZX_EC_HVC have drifted apart"); +_Static_assert(ZX_ASM_EC_PABT_ROUTED == (int)ZX_EC_PABT_ROUTED, + "ZX_ASM_EC_PABT_ROUTED and ZX_EC_PABT_ROUTED have drifted apart"); +_Static_assert(ZX_ASM_EC_DABT_ROUTED == (int)ZX_EC_DABT_ROUTED, + "ZX_ASM_EC_DABT_ROUTED and ZX_EC_DABT_ROUTED have drifted apart"); + +_Static_assert(ZX_ASM_HVC_IMM_NOP == (int)ZX_HVC_NOP, + "ZX_ASM_HVC_IMM_NOP and ZX_HVC_NOP have drifted apart"); +_Static_assert(ZX_ASM_HVC_IMM_YIELD == (int)ZX_HVC_YIELD, + "ZX_ASM_HVC_IMM_YIELD and ZX_HVC_YIELD have drifted apart"); + +_Static_assert(ZX_ASM_VEC_RESET == (int)ZX_VECTOR_RESET, + "ZX_ASM_VEC_RESET and ZX_VECTOR_RESET have drifted apart"); +_Static_assert(ZX_ASM_VEC_UNDEF == (int)ZX_VECTOR_UNDEF, + "ZX_ASM_VEC_UNDEF and ZX_VECTOR_UNDEF have drifted apart"); +_Static_assert(ZX_ASM_VEC_SVC == (int)ZX_VECTOR_SVC, + "ZX_ASM_VEC_SVC and ZX_VECTOR_SVC have drifted apart"); +_Static_assert(ZX_ASM_VEC_PABT_HYP == (int)ZX_VECTOR_PABT_HYP, + "ZX_ASM_VEC_PABT_HYP and ZX_VECTOR_PABT_HYP have drifted apart"); +_Static_assert(ZX_ASM_VEC_DABT_HYP == (int)ZX_VECTOR_DABT_HYP, + "ZX_ASM_VEC_DABT_HYP and ZX_VECTOR_DABT_HYP have drifted apart"); +_Static_assert(ZX_ASM_VEC_HYP_TRAP == (int)ZX_VECTOR_HYP_TRAP, + "ZX_ASM_VEC_HYP_TRAP and ZX_VECTOR_HYP_TRAP have drifted apart"); +_Static_assert(ZX_ASM_VEC_IRQ == (int)ZX_VECTOR_IRQ, + "ZX_ASM_VEC_IRQ and ZX_VECTOR_IRQ have drifted apart"); +_Static_assert(ZX_ASM_VEC_FIQ == (int)ZX_VECTOR_FIQ, + "ZX_ASM_VEC_FIQ and ZX_VECTOR_FIQ have drifted apart"); + +_Static_assert(ZX_ASM_FAULT_OFF_CAPTURES + == (int)offsetof(zx_fault_record_t, zx_fault_captures), + "zx_fault_record_t has moved under zx_trap_handler.S"); +_Static_assert(ZX_ASM_FAULT_OFF_VECTOR + == (int)offsetof(zx_fault_record_t, zx_fault_vector), + "zx_fault_record_t has moved under zx_trap_handler.S"); +_Static_assert(ZX_ASM_FAULT_OFF_HSR + == (int)offsetof(zx_fault_record_t, zx_fault_hsr), + "zx_fault_record_t has moved under zx_trap_handler.S"); +_Static_assert(ZX_ASM_FAULT_OFF_HPFAR + == (int)offsetof(zx_fault_record_t, zx_fault_hpfar), + "zx_fault_record_t has moved under zx_trap_handler.S"); +_Static_assert(ZX_ASM_FAULT_OFF_HDFAR + == (int)offsetof(zx_fault_record_t, zx_fault_hdfar), + "zx_fault_record_t has moved under zx_trap_handler.S"); +_Static_assert(ZX_ASM_FAULT_OFF_HIFAR + == (int)offsetof(zx_fault_record_t, zx_fault_hifar), + "zx_fault_record_t has moved under zx_trap_handler.S"); +_Static_assert(ZX_ASM_FAULT_OFF_ELR + == (int)offsetof(zx_fault_record_t, zx_fault_elr), + "zx_fault_record_t has moved under zx_trap_handler.S"); +_Static_assert(ZX_ASM_FAULT_OFF_SPSR + == (int)offsetof(zx_fault_record_t, zx_fault_spsr), + "zx_fault_record_t has moved under zx_trap_handler.S"); + +/**************************************************************************/ +/* Descriptor words, with the masking done */ +/**************************************************************************/ + +/* HPRBAR: BASE[31:6] | SH[4:3] | AP[2:1] | XN[0]. */ + +static uint32_t zx_region_bar(const zx_region_t *region_ptr) +{ + return (((uint32_t)region_ptr->zx_region_base & ZX_REGION_ADDR_MASK) + | ((region_ptr->zx_region_sh & 0x3U) << ZX_HPRBAR_SH_SHIFT) + | ((region_ptr->zx_region_ap & 0x3U) << ZX_HPRBAR_AP_SHIFT) + | (region_ptr->zx_region_xn & ZX_HPRBAR_XN)); +} + + +/* HPRLAR: LIMIT[31:6] | AttrIndx[3:1] | EN[0]. + + The limit written here is the INCLUSIVE last byte with its low six bits + masked off. The hardware postfixes 0x3F to LIMIT[31:6] (the TRM's own + words), so masking down and letting the hardware fill the low bits back in + reproduces the caller's inclusive limit exactly -- while keeping those bits + away from AttrIndx, which is the trap this masking exists for. */ + +static uint32_t zx_region_lar(const zx_region_t *region_ptr, uint32_t enable) +{ + return (((uint32_t)region_ptr->zx_region_limit & ZX_REGION_ADDR_MASK) + | ((region_ptr->zx_region_attrindx & 0x7U) + << ZX_HPRLAR_ATTRINDX_SHIFT) + | ((enable != 0U) ? ZX_HPRLAR_EN : 0U)); +} + + +/**************************************************************************/ +/* The register writes themselves */ +/**************************************************************************/ + +static void zx_write_hprselr(uint32_t region) +{ + /* HPRSELR: p15, 4, c6, c2, 1. REGION is [3:0] on a 16-region + implementation and [4:0] on 20 or 24 (TRM Tables 3-84, 3-85). Writing + a value at or above the implemented count is UNPREDICTABLE, which is + why every caller checks the count first rather than checking here -- + a check here could only fail after the fact. */ + __asm__ volatile("mcr p15, 4, %0, c6, c2, 1" : : "r"(region) : "memory"); + __asm__ volatile("isb"); +} + + +static void zx_write_hprbar(uint32_t value) +{ + /* HPRBAR, indirect: p15, 4, c6, c3, 0 */ + __asm__ volatile("mcr p15, 4, %0, c6, c3, 0" : : "r"(value) : "memory"); +} + + +static void zx_write_hprlar(uint32_t value) +{ + /* HPRLAR, indirect: p15, 4, c6, c3, 1 */ + __asm__ volatile("mcr p15, 4, %0, c6, c3, 1" : : "r"(value) : "memory"); +} + + +static uint32_t zx_read_hprbar(void) +{ + uint32_t value; + + __asm__ volatile("mrc p15, 4, %0, c6, c3, 0" : "=r"(value)); + + return value; +} + + +static uint32_t zx_read_hprlar(void) +{ + uint32_t value; + + __asm__ volatile("mrc p15, 4, %0, c6, c3, 1" : "=r"(value)); + + return value; +} + + +/**************************************************************************/ +/* zx_mair_program */ +/* */ +/* Must run before HSCTLR.M is set: a region whose AttrIndx selects an */ +/* unprogrammed HMAIR byte gets Device-nGnRnE, which is conservative */ +/* enough to look like it works and wrong enough to matter once caches */ +/* are on. */ +/**************************************************************************/ + +void zx_mair_program(void) +{ + uint32_t mair0 = ZX_HMAIR0_VALUE; + uint32_t mair1 = ZX_HMAIR1_VALUE; + + /* HMAIR0: p15, 4, c10, c2, 0 -- HMAIR1: p15, 4, c10, c2, 1 */ + __asm__ volatile("mcr p15, 4, %0, c10, c2, 0" : : "r"(mair0) : "memory"); + __asm__ volatile("mcr p15, 4, %0, c10, c2, 1" : : "r"(mair1) : "memory"); + __asm__ volatile("isb"); +} + + +/**************************************************************************/ +/* zx_stage2_region_program -- the indirect path, any region index. */ +/* */ +/* BASE is written before LIMIT because LIMIT carries the enable bit. */ +/* Writing LIMIT first would enable the region for the few cycles before */ +/* BASE arrives, against whatever base the register happened to hold -- */ +/* and on a part with no region priority, one stray enabled region */ +/* overlapping another is CONSTRAINED UNPREDICTABLE and aborts. */ +/**************************************************************************/ + +void zx_stage2_region_program(uint32_t index, const zx_region_t *region_ptr) +{ + if (region_ptr == (const zx_region_t *)0) + { + return; + } + + zx_write_hprselr(index); + zx_write_hprbar(zx_region_bar(region_ptr)); + zx_write_hprlar(zx_region_lar(region_ptr, 1U)); + __asm__ volatile("dsb"); + __asm__ volatile("isb"); +} + + +/**************************************************************************/ +/* zx_stage2_region_read -- read one region back, indirect path. */ +/**************************************************************************/ + +void zx_stage2_region_read(uint32_t index, uint32_t *base_ptr, + uint32_t *limit_ptr) +{ + zx_write_hprselr(index); + + if (base_ptr != (uint32_t *)0) + { + *base_ptr = zx_read_hprbar(); + } + + if (limit_ptr != (uint32_t *)0) + { + *limit_ptr = zx_read_hprlar(); + } +} + + +/**************************************************************************/ +/* zx_stage2_region_program_direct16 / zx_stage2_region_read_direct16 */ +/* */ +/* Region 16 through the direct encoding: opc1 = 5, CRm = c8 + rrr where */ +/* rrr = (16 - 16)[3:1] = 0, op2 = 0 for the even base and 1 for the even */ +/* limit (TRM 8.4). */ +/* */ +/* This exists to settle a contradiction in the TRM rather than because */ +/* region 16 is special. Section 3.3.48's prose says direct access is */ +/* provided for HPRBAR0-HPRBAR15 only; section 8.4 and the c6 register */ +/* summary both list HPRBAR16-HPRBAR24 at opc1 = 5. If 8.4 is right the */ +/* whole 20-region budget is directly addressable and the EL2 MPU escapes */ +/* the penalty measured at EL1 for reaching a high region through the */ +/* selection register -- 542-604 cycles against 434-470 for a direct */ +/* write. That is a worst-case-execution-time fact, so it is settled by */ +/* writing the encoding and reading it back through the OTHER path, which */ +/* is what the read-back below is for. */ +/* */ +/* Only region 16 is implemented directly. Generalising to 17-24 before */ +/* the encoding is known to work would be building on the thing under */ +/* test; that is step 3's job, once the answer is in. */ +/**************************************************************************/ + +void zx_stage2_region_program_direct16(const zx_region_t *region_ptr) +{ + uint32_t bar; + uint32_t lar; + + if (region_ptr == (const zx_region_t *)0) + { + return; + } + + bar = zx_region_bar(region_ptr); + lar = zx_region_lar(region_ptr, 1U); + + /* HPRBAR16: p15, 5, c6, c8, 0 -- HPRLAR16: p15, 5, c6, c8, 1 */ + __asm__ volatile("mcr p15, 5, %0, c6, c8, 0" : : "r"(bar) : "memory"); + __asm__ volatile("mcr p15, 5, %0, c6, c8, 1" : : "r"(lar) : "memory"); + __asm__ volatile("dsb"); + __asm__ volatile("isb"); +} + + +void zx_stage2_region_read_direct16(uint32_t *base_ptr, uint32_t *limit_ptr) +{ + uint32_t bar; + uint32_t lar; + + __asm__ volatile("mrc p15, 5, %0, c6, c8, 0" : "=r"(bar)); + __asm__ volatile("mrc p15, 5, %0, c6, c8, 1" : "=r"(lar)); + + if (base_ptr != (uint32_t *)0) + { + *base_ptr = bar; + } + + if (limit_ptr != (uint32_t *)0) + { + *limit_ptr = lar; + } +} + + +/**************************************************************************/ +/* HPRENR */ +/**************************************************************************/ + +uint32_t zx_hprenr_read(void) +{ + uint32_t value; + + /* HPRENR: p15, 4, c6, c1, 1 */ + __asm__ volatile("mrc p15, 4, %0, c6, c1, 1" : "=r"(value)); + + return value; +} + + +void zx_hprenr_write(uint32_t value) +{ + __asm__ volatile("mcr p15, 4, %0, c6, c1, 1" : : "r"(value) : "memory"); + __asm__ volatile("dsb"); + __asm__ volatile("isb"); +} + + +void zx_hprenr_enable(uint32_t index) +{ + zx_hprenr_write(zx_hprenr_read() | (1U << index)); +} + + +void zx_hprenr_disable(uint32_t index) +{ + zx_hprenr_write(zx_hprenr_read() & ~(1U << index)); +} + + +/**************************************************************************/ +/* zx_el2_mpu_enable */ +/* */ +/* HSCTLR.BR and HSCTLR.M: the EL2-controlled MPU, governing EL2's OWN */ +/* accesses. Stage 2 for EL0/EL1 is a separate bit and a separate */ +/* function; see zx_stage2_enable below. */ +/* */ +/* BR is set in the same write as M, deliberately. With M set and BR */ +/* clear, EL2's own accesses to its code, data and stack would hit no */ +/* programmable region and fault -- the hypervisor would abort on its own */ +/* next instruction fetch, which is the least debuggable failure this */ +/* file can produce. BR does NOT relax anything for EL0/EL1, which fault */ +/* on a miss regardless of it; that asymmetry is decision D2. */ +/* */ +/* WHY THIS IS CALLED BEFORE THE CONSOLE. BR grants permission, not */ +/* attributes. The background map is Normal cacheable below 0x60000000, */ +/* and Normal memory permits gathering and reordering even with caches */ +/* off -- which corrupts a polled UART's register protocol. On the */ +/* S32Z280 the console sits at 0x42980000, inside that band, and the */ +/* first version of this port printed its whole identity block as */ +/* legible-but-wrong text: characters intermittently corrupted, looking */ +/* for all the world like a marginal baud rate. Programming the */ +/* console's Device-attributed region and setting M before the first */ +/* print is the fix. The FVP cannot show you any of this -- its console */ +/* is semihosting, and even its UART is in the Device band. */ +/**************************************************************************/ + +void zx_el2_mpu_enable(void) +{ + uint32_t hsctlr; + + __asm__ volatile("dsb"); + + hsctlr = zx_read_hsctlr(); + hsctlr |= (ZX_HSCTLR_BR | ZX_HSCTLR_M); + __asm__ volatile("mcr p15, 4, %0, c1, c0, 0" : : "r"(hsctlr) : "memory"); + __asm__ volatile("isb"); +} + + +/**************************************************************************/ +/* zx_stage2_enable */ +/* */ +/* HCR.VM, which is what makes the EL2 region set apply to EL0/EL1 as */ +/* stage 2. The order is the whole content of this function: */ +/* */ +/* DSB every region write must be complete first. */ +/* HCR.VM stage 2 for EL0/EL1. */ +/* ISB */ +/* */ +/* HSCTLR.M is asserted rather than set here: reaching this with the EL2 */ +/* MPU still disabled would mean HCR.VM took effect against a region set */ +/* the hardware is not consulting, and the payload would run with no */ +/* protection at all while every check appeared to pass. So it refuses. */ +/* */ +/* Setting HCR.VM with no region covering the payload's code faults at */ +/* the ERET target. That presents as "the guest never started" and looks */ +/* nothing like a memory-protection problem, which is why the region */ +/* writes are a precondition of calling this rather than part of it. */ +/**************************************************************************/ + +void zx_stage2_enable(void) +{ + uint32_t hcr; + + if ((zx_read_hsctlr() & ZX_HSCTLR_M) == 0U) + { + zx_console_puts( + "\n *** REFUSING to set HCR.VM: the EL2 MPU (HSCTLR.M) is still\n" + " *** disabled, so stage 2 would apply a region set the hardware\n" + " *** is not consulting. The payload would run UNPROTECTED while\n" + " *** every check appeared to pass. Call zx_el2_mpu_enable first.\n"); + return; + } + + __asm__ volatile("dsb"); + + hcr = zx_read_hcr(); + hcr |= ZX_HCR_VM; + __asm__ volatile("mcr p15, 4, %0, c1, c1, 0" : : "r"(hcr) : "memory"); + __asm__ volatile("isb"); +} + + +/**************************************************************************/ +/* zx_stage2_disable -- the reverse order, for the same reason. */ +/**************************************************************************/ + +void zx_stage2_disable(void) +{ + uint32_t hsctlr; + uint32_t hcr; + + __asm__ volatile("dsb"); + + hcr = zx_read_hcr(); + hcr &= ~(uint32_t)ZX_HCR_VM; + __asm__ volatile("mcr p15, 4, %0, c1, c1, 0" : : "r"(hcr) : "memory"); + __asm__ volatile("isb"); + + hsctlr = zx_read_hsctlr(); + hsctlr &= ~(uint32_t)(ZX_HSCTLR_BR | ZX_HSCTLR_M); + __asm__ volatile("mcr p15, 4, %0, c1, c0, 0" : : "r"(hsctlr) : "memory"); + __asm__ volatile("isb"); +} diff --git a/platform/cortex_r52/src/zx_trap_handler.S b/platform/cortex_r52/src/zx_trap_handler.S index 34c7b0a..d184542 100644 --- a/platform/cortex_r52/src/zx_trap_handler.S +++ b/platform/cortex_r52/src/zx_trap_handler.S @@ -12,17 +12,21 @@ * * SPDX-License-Identifier: MIT and CC0-1.0 **************************************************************************/ +@ Some portions generated by Claude Code (Opus 5). -@ zx_trap_handler.S -- Cortex-R52 -- the ZoneX EL2 exception vectors. +@ zx_trap_handler.S -- Cortex-R52 -- the ZoneX EL2 exception vectors, and the +@ launch and recapture of an EL1 payload. @ @ DESCRIPTION @ -@ The EL2 vector table installed in HVBAR, and the trap entry it points -@ at. Every exception routed to EL2 from EL0 or EL1 arrives at ONE -@ vector, HVBAR + 0x14, so this file's real work is saving the guest -@ context and handing HSR to the C decoder in core/src/zx_fault.c. +@ The EL2 vector table installed in HVBAR, the trap entry it points at, and +@ the pair of routines that hand the machine to EL1 and take it back. @ -@ The exception classes that matter, from HSR.EC: +@ Every exception routed to EL2 from EL0 or EL1 arrives at ONE vector, +@ HVBAR + 0x14, so this file's first job is to decode HSR.EC. A fault +@ taken FROM Hyp mode is different: it arrives at its own vector, +0x0C or +@ +0x10, which makes the vector an INDEPENDENT witness to what HSR claims. +@ Both are captured, and core/src/zx_fault.c reports it when they disagree. @ @ 0x12 HVC executed -- the hypercall seam @ 0x24 data abort ROUTED to Hyp -- a guest violating its partition, @@ -33,10 +37,315 @@ @ @ The full table is in docs/armv8r-el2-reference.md. @ -@ STATUS +@ HOW CONTROL COMES BACK TO EL2 @ -@ Deliberately empty. See zx_el2_entry.S. +@ zx_el2_run_payload saves EL2's own SP, LR and callee-saved registers, +@ then ERETs to EL1. It does not return through that ERET. Instead the +@ trap handler restores that context and resumes it with a result code in +@ r0, so from C the whole excursion looks like an ordinary call that +@ returns ZX_RUN_YIELDED, ZX_RUN_FAULTED or ZX_RUN_TRAPPED. +@ +@ That shape is deliberate and it is not just convenience for a test. A +@ handler that decides policy in the vector has no context to decide it +@ with; a hypervisor that gets its faults back as VALUES can choose what to +@ do about a partition -- report it, restart it, stop scheduling it -- in +@ code that can be read. Step 3 keeps this seam. +@ +@ The exception is a fault taken FROM Hyp mode. There is nothing safe to +@ resume there: ZoneX faulted on its own access, so whatever invariant the +@ resume context assumed may already be false. Those vectors capture and +@ go to C to report and stop. +@ +@ STACK DISCIPLINE +@ +@ The Hyp stack is restored wholesale by the resume path, but every push on +@ every path is still balanced explicitly. Not doing so at EL1 cost a +@ silicon run and leaked bytes of a 1 KB abort stack per fault during the +@ Cortex-R52 Modules port work; a leak that only shows up on the tenth +@ fault is not a leak anybody finds on the bench. @ @ NOTE ON SYNTAX @ @ GNU assembler syntax, per CONTRIBUTING.md. + +#include "zx_port.h" + + .syntax unified + .arm + +/**************************************************************************/ +/* ZX_CAPTURE -- fill the fault record, before anything else runs. */ +/* */ +/* Clobbers r0 and r1. Every path that uses it has already finished with */ +/* the guest's r0/r1, except the HVC #0 path, which does not use it. */ +/* */ +/* The offsets come from zx_port.h and are checked against offsetof by */ +/* _Static_assert in zx_stage2_mpu.c, so a field added to */ +/* zx_fault_record_t breaks the build rather than the report. */ +/**************************************************************************/ + + .macro ZX_CAPTURE vector_offset + ldr r0, =zx_el2_fault_storage + mov r1, #\vector_offset + str r1, [r0, #ZX_ASM_FAULT_OFF_VECTOR] + mrc p15, 4, r1, c5, c2, 0 @ HSR + str r1, [r0, #ZX_ASM_FAULT_OFF_HSR] + mrc p15, 4, r1, c6, c0, 4 @ HPFAR + str r1, [r0, #ZX_ASM_FAULT_OFF_HPFAR] + mrc p15, 4, r1, c6, c0, 0 @ HDFAR + str r1, [r0, #ZX_ASM_FAULT_OFF_HDFAR] + mrc p15, 4, r1, c6, c0, 2 @ HIFAR + str r1, [r0, #ZX_ASM_FAULT_OFF_HIFAR] + mrs r1, ELR_hyp + str r1, [r0, #ZX_ASM_FAULT_OFF_ELR] + mrs r1, SPSR_hyp + str r1, [r0, #ZX_ASM_FAULT_OFF_SPSR] + ldr r1, [r0, #ZX_ASM_FAULT_OFF_CAPTURES] + add r1, r1, #1 + str r1, [r0, #ZX_ASM_FAULT_OFF_CAPTURES] + dsb + .endm + +/**************************************************************************/ +/* The EL2 vector table. 32-byte aligned, as HVBAR requires. */ +/* */ +/* Offsets 0x00-0x10 are exceptions taken FROM Hyp mode itself. Offset */ +/* 0x14 is the Hyp Trap Entry: every exception routed to EL2 from EL1 or */ +/* EL0 funnels through it. 0x18 and 0x1C are unexpected here, because */ +/* HCR.IMO and HCR.FMO are clear and interrupts belong to EL1. */ +/**************************************************************************/ + + .section .vectors_el2, "ax" + .balign 32 + .global zx_el2_vectors +zx_el2_vectors: + b zx_el2_vector_reset /* 0x00 reset, unused at EL2 */ + b zx_el2_vector_undef /* 0x04 undef, from Hyp */ + b zx_el2_vector_svc /* 0x08 SVC, from Hyp */ + b zx_el2_vector_pabt /* 0x0C prefetch abort, Hyp */ + b zx_el2_vector_dabt /* 0x10 data abort, from Hyp */ + b zx_el2_hyp_trap_entry /* 0x14 from EL1/EL0 */ + b zx_el2_vector_irq /* 0x18 IRQ */ + b zx_el2_vector_fiq /* 0x1C FIQ */ + + .text + .balign 4 + +/**************************************************************************/ +/* zx_el2_hyp_trap_entry -- HVBAR + 0x14. */ +/* */ +/* uint32_t is what the caller eventually sees; what arrives here is an */ +/* exception with its cause only in HSR. */ +/**************************************************************************/ + + .global zx_el2_hyp_trap_entry + .type zx_el2_hyp_trap_entry, %function +zx_el2_hyp_trap_entry: + + @ The guest's r0 and r1 are saved because the HVC #0 path below returns + @ to it transparently and must not have changed anything. Every other + @ path discards them, explicitly, before it goes on. + + push {r0, r1} + + mrc p15, 4, r0, c5, c2, 0 @ HSR + lsr r1, r0, #ZX_ASM_EC_SHIFT + cmp r1, #ZX_ASM_EC_HVC + bne zx_el2_trap_not_hypercall + + @ An HVC. Which one is in HSR.ISS[15:0], and the two ZoneX uses mean + @ opposite things: #0 is the empty Phase-0 hypercall vector and returns + @ to the guest, #1 is a payload handing EL2 the machine back. + + ubfx r1, r0, #0, #16 + cmp r1, #ZX_ASM_HVC_IMM_NOP + bne zx_el2_trap_hvc_not_nop + + ldr r0, =zx_el2_hypercall_counter + ldr r1, [r0] + add r1, r1, #1 + str r1, [r0] + dsb + pop {r0, r1} @ balanced with the push + eret + +zx_el2_trap_hvc_not_nop: + cmp r1, #ZX_ASM_HVC_IMM_YIELD + bne zx_el2_trap_not_hypercall + + @ HVC #1. The record is captured for a yield as well as for a fault: + @ "the payload came back" is evidence too, and a run that can only + @ report the absence of a fault cannot tell a working protection + @ failure from a broken test. + + add sp, sp, #8 @ done with the guest's r0/r1 + ZX_CAPTURE ZX_ASM_VEC_HYP_TRAP + mov r0, #ZX_RUN_YIELDED + b zx_el2_resume + +zx_el2_trap_not_hypercall: + add sp, sp, #8 @ done with the guest's r0/r1 + ZX_CAPTURE ZX_ASM_VEC_HYP_TRAP + + @ Re-read EC from the record's own copy rather than from a register + @ that survived the capture, so that what is dispatched on is exactly + @ what will be reported. + + ldr r0, =zx_el2_fault_storage + ldr r1, [r0, #ZX_ASM_FAULT_OFF_HSR] + lsr r1, r1, #ZX_ASM_EC_SHIFT + + cmp r1, #ZX_ASM_EC_DABT_ROUTED + beq zx_el2_trap_guest_violation + cmp r1, #ZX_ASM_EC_PABT_ROUTED + beq zx_el2_trap_guest_violation + + mov r0, #ZX_RUN_TRAPPED + b zx_el2_resume + +zx_el2_trap_guest_violation: + mov r0, #ZX_RUN_FAULTED + b zx_el2_resume + .size zx_el2_hyp_trap_entry, . - zx_el2_hyp_trap_entry + +/**************************************************************************/ +/* zx_el2_run_payload -- drop to EL1 and come back. */ +/* */ +/* uint32_t zx_el2_run_payload(zx_addr_t entry_address, */ +/* uint32_t argument); */ +/* */ +/* r0 = entry address, r1 = argument passed to the payload in r0. */ +/* */ +/* Interrupts are masked in the payload's SPSR (A, I and F all set). */ +/* Phase 0 configures no interrupt controller and routes nothing to EL2, */ +/* so an interrupt arriving while a payload runs would be taken by EL1 */ +/* with no handler behind it. Masking says that out loud rather than */ +/* leaving it to luck; step 4 opens them when a guest has vectors. */ +/**************************************************************************/ + + .global zx_el2_run_payload + .type zx_el2_run_payload, %function +zx_el2_run_payload: + ldr r2, =zx_el2_resume_context + str sp, [r2, #ZX_ASM_RESUME_OFF_SP] + str lr, [r2, #ZX_ASM_RESUME_OFF_LR] + add r3, r2, #ZX_ASM_RESUME_OFF_R4 + stmia r3, {r4-r11} + + @ ELR_hyp is set from r0 BEFORE r0 is reused, and SPSR_hyp from a + @ literal. The payload's argument then moves into r0 last, so it is + @ the value the payload sees. + + msr ELR_hyp, r0 + ldr r0, =(ZX_PSR_A | ZX_PSR_I | ZX_PSR_F | ZX_MODE_SVC) + msr SPSR_hyp, r0 + mov r0, r1 + + dsb + isb + eret + .size zx_el2_run_payload, . - zx_el2_run_payload + +/**************************************************************************/ +/* zx_el2_resume -- restore the context zx_el2_run_payload saved. */ +/* */ +/* r0 already holds the ZX_RUN_* result, which becomes */ +/* zx_el2_run_payload's return value. Not called: branched to, from the */ +/* trap handler, because there is no frame to return through. */ +/**************************************************************************/ + + .type zx_el2_resume, %function +zx_el2_resume: + ldr r2, =zx_el2_resume_context + ldr sp, [r2, #ZX_ASM_RESUME_OFF_SP] + ldr lr, [r2, #ZX_ASM_RESUME_OFF_LR] + add r3, r2, #ZX_ASM_RESUME_OFF_R4 + ldmia r3, {r4-r11} + isb + bx lr + .size zx_el2_resume, . - zx_el2_resume + +/**************************************************************************/ +/* Vectors for exceptions taken FROM Hyp mode. */ +/* */ +/* These mean ZoneX faulted on its own access, so nothing here tries to */ +/* resume: the capture happens and C reports and stops. The distinction */ +/* from a guest violation is the whole point -- a hypervisor bug reported */ +/* as a partition overstepping is a Phase-0 run that "passes" while */ +/* proving nothing. */ +/* */ +/* Each vector records its OWN offset, which is what lets zx_fault_report */ +/* cross-check the vector against HSR.EC instead of trusting one of them. */ +/**************************************************************************/ + + .type zx_el2_vector_pabt, %function +zx_el2_vector_pabt: + ZX_CAPTURE ZX_ASM_VEC_PABT_HYP + bl zx_el2_hypervisor_fault + .size zx_el2_vector_pabt, . - zx_el2_vector_pabt + + .type zx_el2_vector_dabt, %function +zx_el2_vector_dabt: + ZX_CAPTURE ZX_ASM_VEC_DABT_HYP + bl zx_el2_hypervisor_fault + .size zx_el2_vector_dabt, . - zx_el2_vector_dabt + +/**************************************************************************/ +/* Vectors nothing should reach. */ +/* */ +/* Reset is unreachable once running; SVC and undefined instruction from */ +/* Hyp mode mean ZoneX executed something it should not have; IRQ and FIQ */ +/* at EL2 mean HCR.IMO or HCR.FMO is set when this file says they are */ +/* clear. All four report by name and stop, because a vector that falls */ +/* through is how a fault becomes a hang. */ +/**************************************************************************/ + + .type zx_el2_vector_reset, %function +zx_el2_vector_reset: + ZX_CAPTURE ZX_ASM_VEC_RESET + bl zx_el2_unexpected_vector + .size zx_el2_vector_reset, . - zx_el2_vector_reset + + .type zx_el2_vector_undef, %function +zx_el2_vector_undef: + ZX_CAPTURE ZX_ASM_VEC_UNDEF + bl zx_el2_unexpected_vector + .size zx_el2_vector_undef, . - zx_el2_vector_undef + + .type zx_el2_vector_svc, %function +zx_el2_vector_svc: + ZX_CAPTURE ZX_ASM_VEC_SVC + bl zx_el2_unexpected_vector + .size zx_el2_vector_svc, . - zx_el2_vector_svc + + .type zx_el2_vector_irq, %function +zx_el2_vector_irq: + ZX_CAPTURE ZX_ASM_VEC_IRQ + bl zx_el2_unexpected_vector + .size zx_el2_vector_irq, . - zx_el2_vector_irq + + .type zx_el2_vector_fiq, %function +zx_el2_vector_fiq: + ZX_CAPTURE ZX_ASM_VEC_FIQ + bl zx_el2_unexpected_vector + .size zx_el2_vector_fiq, . - zx_el2_vector_fiq + +/**************************************************************************/ +/* The saved EL2 context. In .bss, so the reset path zeroes it before */ +/* anything can read it, and read only by the two routines above. */ +/* */ +/* ONE slot, so zx_el2_run_payload CANNOT NEST. That is a real */ +/* constraint and it is deliberate: a hypervisor that could be inside two */ +/* guest excursions at once would need a stack of these, and Phase 0 has */ +/* no reason to be. A time-partition scheduler calls it once per */ +/* partition, sequentially, and still does not nest. If a later phase */ +/* needs to -- an emulated instruction that itself re-enters a guest -- */ +/* this becomes an array indexed by depth, and the depth needs a bound. */ +/**************************************************************************/ + + .section .bss + .balign 8 + .type zx_el2_resume_context, %object +zx_el2_resume_context: + .space ZX_ASM_RESUME_WORDS * 4 + .size zx_el2_resume_context, . - zx_el2_resume_context diff --git a/test/fvp/run.sh b/test/fvp/run.sh index f6de308..acfec8c 100755 --- a/test/fvp/run.sh +++ b/test/fvp/run.sh @@ -22,11 +22,16 @@ # The model is found by CMake, with $HOME/FVP_Base_AEMv8R_11.32_19/bin as the # hint. Point ZX_FVP at a different one to override it. # -# STATUS: there are no images yet. "build" therefore configures and builds the -# hypervisor libraries, and "test" reports that there is nothing to execute -- -# loudly, and with a zero status, because a foundation-only tree failing its own -# CI would say nothing useful. Once the first image lands this script starts -# doing what its name says. +# The suite is three runs of the same program: zx_probe.elf, which must pass, +# and two builds that must FAIL -- zx_probe_negative.elf, whose deliberate +# violation is aimed at an address the payload IS granted, and +# zx_probe_starved.elf, which is told it needs more MPU regions than exist. +# Both are registered WILL_FAIL in CMake, because a check that has never been +# seen to fail is not evidence that it can. +# +# zx_probe_el2_fault.elf is built but not registered: it makes ZoneX fault at +# EL2 on purpose, so its expected outcome IS a failure report. Run it with +# "ninja -C build/fvp zx-run-probe-el2-fault" and read the report. set -euo pipefail @@ -63,11 +68,15 @@ case "${command}" in configure cmake --build "${BUILD}" + # An empty list is a FAILURE now that ZoneX has images: it means the + # CMake graph broke. It was a zero-status notice while the repository + # genuinely had none. image_list="$(images)" if [ -z "${image_list}" ]; then - echo "ZoneX: no FVP images exist yet." - echo "ZoneX: the hypervisor libraries were built; nothing was executed." - exit 0 + echo "ZoneX: no FVP images were found in the ninja graph." >&2 + echo "ZoneX: ZoneX HAS images, so the build system is broken rather" >&2 + echo "ZoneX: than the tree being incomplete." >&2 + exit 1 fi # shellcheck disable=SC2086 diff --git a/test/fvp/run_fvp_test.py b/test/fvp/run_fvp_test.py new file mode 100755 index 0000000..fd180fa --- /dev/null +++ b/test/fvp/run_fvp_test.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2026 Eclipse ThreadX contributors +# SPDX-License-Identifier: MIT +# Some portions generated by Claude Code (Opus 5). +# +"""Run a ZoneX image on the Armv8-R AEM FVP and assert its self-reported result. + +The runner is deliberately thin, because the platform allows nothing else: the +FVP exposes an Iris server and no GDB stub, so there is nothing to script +through a debugger, and every ZoneX image terminates itself with a semihosting +SYS_EXIT. Launch the model, capture the console, judge the printed verdict. + +A MISSING result line is FAILURE, never success. A silent hang, or a crash +before the first check, must not be able to masquerade as a pass. + +WHY THIS DOES NOT TREAT "[FAULT]" AS A FAILURE + +The Cortex-R52 port's runner does, and it is right to: there, a fault report +means the boot path broke. Here it is the opposite. A ZoneX stage-2 image +exists to PROVOKE a fault and report it, so its expected output contains fault +records -- several of them -- and a runner that failed on the marker would fail +every passing run. The verdict line is the only thing judged, plus the rule +that its absence is a failure. + +ZoneX prints "ZONEX RESULT: ..." rather than "RESULT: ...", so the two suites' +markers cannot be confused if their logs are ever concatenated. +""" + +import argparse +import subprocess +import sys + +PASS_MARK = "ZONEX RESULT: ALL CHECKS PASSED" +FAIL_MARK = "ZONEX RESULT: FAILED" + + +def as_text(stream): + """Normalise a captured stream to str. + + subprocess.TimeoutExpired carries its partial output undecoded even when + the call used text=True, so the timeout path can hand us bytes where the + normal path hands us str. Decoding defensively here keeps the hang report + readable instead of raising TypeError inside the error handler. + """ + if stream is None: + return "" + if isinstance(stream, bytes): + return stream.decode(errors="replace") + return stream + + +def run(elf, fvp, timeout): + command = [ + fvp, + "-C", "cluster0.NUM_CORES=1", + "-C", "bp.vis.disable_visualisation=1", + "-C", "bp.terminal_0.start_telnet=0", + # Route UART0 to stdout so that a PL011-console build is captured too. + # Harmless for the semihosting console ZoneX uses on this target. + "-C", "bp.pl011_uart0.out_file=-", + "-C", "bp.pl011_uart0.unbuffered_output=1", + "-a", elf, + ] + + print("Running:", " ".join(command), flush=True) + + try: + completed = subprocess.run( + command, capture_output=True, text=True, timeout=timeout + ) + output = as_text(completed.stdout) + as_text(completed.stderr) + except subprocess.TimeoutExpired as expired: + print(as_text(expired.stdout) + as_text(expired.stderr)) + print( + f"FAIL: the simulation did not terminate within {timeout}s. " + "Every ZoneX image is expected to exit through semihosting " + "SYS_EXIT, so this is a hang, not a slow run.", + file=sys.stderr, + ) + return 1 + + print(output) + + if FAIL_MARK in output: + print("FAIL: the image reported failing checks.", file=sys.stderr) + for line in output.splitlines(): + if line.startswith(" [FAIL]"): + print(" " + line.strip(), file=sys.stderr) + return 1 + + if PASS_MARK not in output: + print( + "FAIL: no ZoneX verdict line was found. The image neither passed " + "nor reported failure, so it did not reach its verdict.", + file=sys.stderr, + ) + return 1 + + print("PASS") + return 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--elf", required=True, help="image to run") + parser.add_argument("--fvp", default="FVP_BaseR_AEMv8R", help="model binary") + parser.add_argument( + "--timeout", type=int, default=180, help="seconds before declaring a hang" + ) + arguments = parser.parse_args() + + return run(arguments.elf, arguments.fvp, arguments.timeout) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/host/CMakeLists.txt b/test/host/CMakeLists.txt index 69b32a4..8edbcd8 100644 --- a/test/host/CMakeLists.txt +++ b/test/host/CMakeLists.txt @@ -36,10 +36,18 @@ endif() # binaries rather than one linked from every test source, so that a test that # crashes takes down its own suite and not the whole run. function(zx_add_host_test name) - add_executable(${name} ${CMAKE_CURRENT_LIST_DIR}/${name}.c) + add_executable(${name} + ${CMAKE_CURRENT_LIST_DIR}/${name}.c + ${CMAKE_CURRENT_LIST_DIR}/zx_console_capture.c + ) target_include_directories(${name} PRIVATE ${CMAKE_CURRENT_LIST_DIR}) target_link_libraries(${name} PRIVATE zonex::core zonex::warnings) add_test(NAME host-${name} COMMAND ${name}) endfunction() +# The capture console is linked into every suite rather than only the ones +# that print. core/ may call zx_console_* from anywhere -- the fault reporter +# already does -- and a suite that grew a call into one would otherwise fail +# to LINK, which is a confusing way to discover that a test needs a console. zx_add_host_test(test_zx_api) +zx_add_host_test(test_zx_fault) diff --git a/test/host/test_zx_fault.c b/test/host/test_zx_fault.c new file mode 100644 index 0000000..6083068 --- /dev/null +++ b/test/host/test_zx_fault.c @@ -0,0 +1,482 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* test_zx_fault.c HOST TESTS */ +/* */ +/* DESCRIPTION */ +/* */ +/* Covers core/src/zx_fault.c: the HSR decode, the HPFAR arithmetic, */ +/* and the text of the fault report. */ +/* */ +/* This is the part of the trap path that CAN be tested without a */ +/* model or a board, and it is worth testing there because the */ +/* alternative is testing a fault decoder by provoking faults -- which */ +/* can only reach the cases the hardware happens to produce. The */ +/* cases that matter most are the ones nobody wants to provoke: a */ +/* vector that disagrees with its syndrome, an HPFAR reporting an */ +/* address above 4 GB, an exception class the TRM does not define. */ +/* */ +/* WHERE THE REGISTER VALUES BELOW COME FROM */ +/* */ +/* The stage-2 syndromes are not invented. They were MEASURED on the */ +/* Armv8-R AEM FVP on 2 September 2026 by the stage-2 probe image, and */ +/* are quoted here so that a change to the decode has to disagree with */ +/* real hardware output rather than merely with an expectation: */ +/* */ +/* guest violation HSR 0x93810044 HDFAR 0x000065C0 HPFAR 0x60 */ +/* vector +0x14, EC 0x24, DFSC 0x04, on a write */ +/* hypervisor fault HSR 0x9600004C HDFAR 0x00006940 HPFAR 0x00 */ +/* vector +0x10, EC 0x25, DFSC 0x0C, on a write */ +/* */ +/* The DFSC pair is itself a finding: 0x04 is what a stage-2 MISS */ +/* reports and 0x0C what a stage-2 PERMISSION failure reports. */ +/* */ +/**************************************************************************/ + +#include "zx_fault.h" +#include "zx_console_capture.h" +#include "zx_test.h" + +/* Measured on the FVP, 2 September 2026 -- see the header. */ + +#define ZX_MEASURED_GUEST_HSR 0x93810044U +#define ZX_MEASURED_GUEST_HDFAR 0x000065C0U +#define ZX_MEASURED_GUEST_HPFAR 0x00000060U +#define ZX_MEASURED_HYP_HSR 0x9600004CU +#define ZX_MEASURED_HYP_HDFAR 0x00006940U + +/* The same guest violation, measured on S32Z280-594EVB SILICON on + 2 September 2026. HPFAR is NOT the same register here: it holds the + faulting address itself with its low four bits RES0, where the FVP holds + FIPA[39:12] at [31:4]. Both values are kept so that a change to the + decode has to disagree with one of two real parts, and so that the + divergence itself is pinned down by a test rather than only by a note. */ + +#define ZX_SILICON_GUEST_HSR 0x93810044U +#define ZX_SILICON_GUEST_HDFAR 0x31781200U +#define ZX_SILICON_GUEST_HPFAR 0x31781200U + +/* Composes an HSR from an exception class and an ISS, which is what the + negative cases need: the hardware will not produce them on request. */ + +static uint32_t zx_hsr_of(uint32_t ec, uint32_t iss) +{ + return ((ec & ZX_HSR_EC_MASK) << ZX_HSR_EC_SHIFT) | (iss & ZX_HSR_ISS_MASK); +} + + +/* Every exception class TRM Table 3-88 documents. At file scope, not inside + ZX_TEST_MAIN, and that is not a style choice: the preprocessor separates + macro arguments on commas at paren depth zero and BRACES DO NOT PROTECT + THEM, so a braced initialiser list written inside the test body is read as + seventeen extra arguments to a two-argument macro. The error names the + closing brace of the body, dozens of lines away from the cause. */ + +static const uint32_t zx_documented_ec[] = { + ZX_EC_UNKNOWN, ZX_EC_WFI_WFE, ZX_EC_MCR_MRC_CP15, ZX_EC_MCRR_MRRC_CP15, + ZX_EC_MCR_MRC_CP14, ZX_EC_LDC_STC_CP14, ZX_EC_SIMD_FP, ZX_EC_VMRS, + ZX_EC_MCRR_MRRC_CP14, ZX_EC_ILLEGAL_STATE, ZX_EC_SVC, ZX_EC_HVC, + ZX_EC_PABT_ROUTED, ZX_EC_PABT_FROM_HYP, ZX_EC_PC_ALIGNMENT, + ZX_EC_DABT_ROUTED, ZX_EC_DABT_FROM_HYP +}; + +#define ZX_DOCUMENTED_EC_COUNT \ + (sizeof(zx_documented_ec) / sizeof(zx_documented_ec[0])) + + +static void zx_fill_measured_guest_record(zx_fault_record_t *record) +{ + zx_fault_record_reset(record); + record->zx_fault_captures = 5U; + record->zx_fault_vector = ZX_VECTOR_HYP_TRAP; + record->zx_fault_hsr = ZX_MEASURED_GUEST_HSR; + record->zx_fault_hdfar = ZX_MEASURED_GUEST_HDFAR; + record->zx_fault_hpfar = ZX_MEASURED_GUEST_HPFAR; + record->zx_fault_hifar = 0U; + record->zx_fault_elr = 0x00006400U; + record->zx_fault_spsr = 0x000001D3U; /* mode 0x13, Supervisor */ +} + + +ZX_TEST_MAIN("zx_fault", +{ + zx_fault_record_t record; + + /* ---------------------------------------------------------------- */ + /* Field extraction. */ + /* ---------------------------------------------------------------- */ + + ZX_CHECK_EQ(zx_fault_ec(ZX_MEASURED_GUEST_HSR), ZX_EC_DABT_ROUTED); + ZX_CHECK_EQ(zx_fault_ec(ZX_MEASURED_HYP_HSR), ZX_EC_DABT_FROM_HYP); + ZX_CHECK_EQ(zx_fault_ec(0xFFFFFFFFU), 0x3FU); + ZX_CHECK_EQ(zx_fault_ec(0x00000000U), 0x00U); + + /* ISS is [24:0], so bit 25 -- IL -- must NOT leak into it. The + measured guest syndrome has IL set, which is what makes it a useful + value to check this with. */ + ZX_CHECK_EQ(zx_fault_iss(ZX_MEASURED_GUEST_HSR), 0x01810044U); + ZX_CHECK_EQ(zx_fault_iss(ZX_HSR_IL_MASK), 0U); + + /* ---------------------------------------------------------------- */ + /* Classification. The routed and taken-from pairs differ by one */ + /* bit of EC, and conflating them would let a run report a */ + /* hypervisor bug as a partition being stopped at its boundary. */ + /* ---------------------------------------------------------------- */ + + ZX_CHECK_EQ(zx_fault_classify(ZX_MEASURED_GUEST_HSR), + ZX_FAULT_GUEST_VIOLATION); + ZX_CHECK_EQ(zx_fault_classify(ZX_MEASURED_HYP_HSR), + ZX_FAULT_HYPERVISOR_BUG); + ZX_CHECK_EQ(zx_fault_classify(zx_hsr_of(ZX_EC_PABT_ROUTED, 0U)), + ZX_FAULT_GUEST_VIOLATION); + ZX_CHECK_EQ(zx_fault_classify(zx_hsr_of(ZX_EC_PABT_FROM_HYP, 0U)), + ZX_FAULT_HYPERVISOR_BUG); + ZX_CHECK_EQ(zx_fault_classify(zx_hsr_of(ZX_EC_HVC, 0U)), + ZX_FAULT_HYPERCALL); + ZX_CHECK_EQ(zx_fault_classify(zx_hsr_of(ZX_EC_WFI_WFE, 0U)), + ZX_FAULT_UNEXPECTED_TRAP); + ZX_CHECK_EQ(zx_fault_classify(zx_hsr_of(ZX_EC_SVC, 0U)), + ZX_FAULT_UNEXPECTED_TRAP); + ZX_CHECK_EQ(zx_fault_classify(zx_hsr_of(0x3FU, 0U)), + ZX_FAULT_UNEXPECTED_TRAP); + + /* ---------------------------------------------------------------- */ + /* Names. Every documented class must have one, and an */ + /* UNdocumented value must not be silently given a plausible one: */ + /* a report naming EC 0x1F as some real exception would send a */ + /* reader looking for a cause that does not exist. */ + /* ---------------------------------------------------------------- */ + + { + size_t index; + + for (index = 0U; index < ZX_DOCUMENTED_EC_COUNT; index++) + { + const char *name = zx_fault_ec_name(zx_documented_ec[index]); + + ZX_CHECK(name[0] != '\0'); + /* Not the fallback text: every documented class is named. */ + ZX_CHECK(name != zx_fault_ec_name(0x1FU)); + } + } + + ZX_CHECK(zx_fault_ec_name(0x1FU)[0] != '\0'); + ZX_CHECK(zx_fault_ec_name(0x02U)[0] != '\0'); + + ZX_CHECK(zx_fault_class_name(ZX_FAULT_GUEST_VIOLATION)[0] != '\0'); + ZX_CHECK(zx_fault_class_name(ZX_FAULT_HYPERVISOR_BUG)[0] != '\0'); + ZX_CHECK(zx_fault_class_name(ZX_FAULT_HYPERCALL)[0] != '\0'); + ZX_CHECK(zx_fault_class_name(ZX_FAULT_UNEXPECTED_TRAP)[0] != '\0'); + ZX_CHECK(zx_fault_class_name((zx_fault_class_t)99)[0] != '\0'); + + ZX_CHECK(zx_fault_vector_name(ZX_VECTOR_RESET)[0] != '\0'); + ZX_CHECK(zx_fault_vector_name(ZX_VECTOR_UNDEF)[0] != '\0'); + ZX_CHECK(zx_fault_vector_name(ZX_VECTOR_SVC)[0] != '\0'); + ZX_CHECK(zx_fault_vector_name(ZX_VECTOR_PABT_HYP)[0] != '\0'); + ZX_CHECK(zx_fault_vector_name(ZX_VECTOR_DABT_HYP)[0] != '\0'); + ZX_CHECK(zx_fault_vector_name(ZX_VECTOR_HYP_TRAP)[0] != '\0'); + ZX_CHECK(zx_fault_vector_name(ZX_VECTOR_IRQ)[0] != '\0'); + ZX_CHECK(zx_fault_vector_name(ZX_VECTOR_FIQ)[0] != '\0'); + ZX_CHECK(zx_fault_vector_name(0x99U)[0] != '\0'); + + /* ---------------------------------------------------------------- */ + /* Vector versus syndrome. */ + /* ---------------------------------------------------------------- */ + + ZX_CHECK_EQ(zx_fault_vector_has_syndrome(ZX_VECTOR_PABT_HYP), 1U); + ZX_CHECK_EQ(zx_fault_vector_has_syndrome(ZX_VECTOR_DABT_HYP), 1U); + ZX_CHECK_EQ(zx_fault_vector_has_syndrome(ZX_VECTOR_HYP_TRAP), 1U); + ZX_CHECK_EQ(zx_fault_vector_has_syndrome(ZX_VECTOR_RESET), 0U); + ZX_CHECK_EQ(zx_fault_vector_has_syndrome(ZX_VECTOR_UNDEF), 0U); + ZX_CHECK_EQ(zx_fault_vector_has_syndrome(ZX_VECTOR_SVC), 0U); + ZX_CHECK_EQ(zx_fault_vector_has_syndrome(ZX_VECTOR_IRQ), 0U); + ZX_CHECK_EQ(zx_fault_vector_has_syndrome(ZX_VECTOR_FIQ), 0U); + + /* The two measured records agree with themselves. */ + ZX_CHECK_EQ(zx_fault_vector_agrees(ZX_VECTOR_HYP_TRAP, + ZX_MEASURED_GUEST_HSR), 1U); + ZX_CHECK_EQ(zx_fault_vector_agrees(ZX_VECTOR_DABT_HYP, + ZX_MEASURED_HYP_HSR), 1U); + ZX_CHECK_EQ(zx_fault_vector_agrees(ZX_VECTOR_PABT_HYP, + zx_hsr_of(ZX_EC_PABT_FROM_HYP, 0U)), 1U); + + /* And the swaps do not. A guest fault must never be reported as + having arrived at a Hyp-mode fault vector, or vice versa. */ + ZX_CHECK_EQ(zx_fault_vector_agrees(ZX_VECTOR_DABT_HYP, + ZX_MEASURED_GUEST_HSR), 0U); + ZX_CHECK_EQ(zx_fault_vector_agrees(ZX_VECTOR_HYP_TRAP, + ZX_MEASURED_HYP_HSR), 0U); + ZX_CHECK_EQ(zx_fault_vector_agrees(ZX_VECTOR_HYP_TRAP, + zx_hsr_of(ZX_EC_PABT_FROM_HYP, 0U)), 0U); + ZX_CHECK_EQ(zx_fault_vector_agrees(ZX_VECTOR_IRQ, + zx_hsr_of(ZX_EC_HVC, 0U)), 0U); + + /* ---------------------------------------------------------------- */ + /* Data-abort syndrome fields, against the measured values. */ + /* ---------------------------------------------------------------- */ + + ZX_CHECK_EQ(zx_fault_dabt_dfsc(ZX_MEASURED_GUEST_HSR), 0x04U); + ZX_CHECK_EQ(zx_fault_dabt_dfsc(ZX_MEASURED_HYP_HSR), 0x0CU); + ZX_CHECK_EQ(zx_fault_dabt_is_write(ZX_MEASURED_GUEST_HSR), 1U); + ZX_CHECK_EQ(zx_fault_dabt_is_write(ZX_MEASURED_HYP_HSR), 1U); + + /* 0x93810004 is the same guest fault on a READ, also measured. */ + ZX_CHECK_EQ(zx_fault_dabt_is_write(0x93810004U), 0U); + ZX_CHECK_EQ(zx_fault_dabt_dfsc(0x93810004U), 0x04U); + + /* ---------------------------------------------------------------- */ + /* HVC immediates. The two ZoneX uses mean opposite things, so a */ + /* decode that confused them would turn "the payload handed */ + /* control back" into "the payload asked for nothing". */ + /* ---------------------------------------------------------------- */ + + ZX_CHECK_EQ(zx_fault_hvc_immediate(zx_hsr_of(ZX_EC_HVC, ZX_HVC_NOP)), + ZX_HVC_NOP); + ZX_CHECK_EQ(zx_fault_hvc_immediate(zx_hsr_of(ZX_EC_HVC, ZX_HVC_YIELD)), + ZX_HVC_YIELD); + ZX_CHECK_EQ(zx_fault_hvc_immediate(zx_hsr_of(ZX_EC_HVC, 0x0017U)), 0x0017U); + /* Only [15:0]. A bit above the immediate field must not appear in it. */ + ZX_CHECK_EQ(zx_fault_hvc_immediate(zx_hsr_of(ZX_EC_HVC, 0x00FF0001U)), + 0x0001U); + + /* ---------------------------------------------------------------- */ + /* HPFAR. This is the arithmetic the FVP run measured, and the */ + /* reason the measurement was needed: the TRM's figure and the */ + /* table row beside it describe two different shifts. */ + /* ---------------------------------------------------------------- */ + + ZX_CHECK_EQ(zx_fault_hpfar_page(ZX_MEASURED_GUEST_HPFAR), 0x00006000U); + ZX_CHECK_EQ(zx_fault_hpfar_high(ZX_MEASURED_GUEST_HPFAR), 0U); + ZX_CHECK_EQ(zx_fault_hpfar_matches_hdfar(ZX_MEASURED_GUEST_HPFAR, + ZX_MEASURED_GUEST_HDFAR), 1U); + + /* HPFAR[3:0] is RES0. It reads zero today, and masking it away means a + part that ever used those bits cannot contribute them to an address. */ + ZX_CHECK_EQ(zx_fault_hpfar_page(ZX_MEASURED_GUEST_HPFAR | 0xFU), + 0x00006000U); + + /* FIPA[39:32] non-zero: an address above 4 GB, which no ZoneX target + has. The page decode truncates it, and hpfar_high is how a report + can say so instead of printing a plausible wrong address. */ + ZX_CHECK_EQ(zx_fault_hpfar_high(0x12000060U), 0x12U); + ZX_CHECK_EQ(zx_fault_hpfar_matches_hdfar(0x12000060U, 0x00006000U), 0U); + + /* A genuine mismatch, which is what a wrong HPFAR reading would look + like: the same raw value read as HDFAR[31:4] gives 0x60, not + 0x6000. */ + ZX_CHECK_EQ(zx_fault_hpfar_matches_hdfar(ZX_MEASURED_GUEST_HPFAR, + 0x00000060U), 0U); + + /* The whole 4 KB page maps to one HPFAR value, which is the finding that + matters for the region work: HPFAR resolves to 4 KB, NOT to the + 64-byte MPU granule, so it cannot say which granule faulted. */ + ZX_CHECK_EQ(zx_fault_hpfar_matches_hdfar(ZX_MEASURED_GUEST_HPFAR, + 0x00006000U), 1U); + ZX_CHECK_EQ(zx_fault_hpfar_matches_hdfar(ZX_MEASURED_GUEST_HPFAR, + 0x00006FFFU), 1U); + ZX_CHECK_EQ(zx_fault_hpfar_matches_hdfar(ZX_MEASURED_GUEST_HPFAR, + 0x00007000U), 0U); + + /* ---------------------------------------------------------------- */ + /* The two readings, and which one a target implements. This is */ + /* the finding of the whole step: the model and the silicon */ + /* implement DIFFERENT ones of the TRM's two descriptions. */ + /* ---------------------------------------------------------------- */ + + ZX_CHECK_EQ(zx_fault_hpfar_as_address(ZX_SILICON_GUEST_HPFAR), + 0x31781200U); + ZX_CHECK_EQ(zx_fault_hpfar_as_address(0x3178120FU), 0x31781200U); + ZX_CHECK_EQ(zx_fault_hpfar_matches_as_address(ZX_SILICON_GUEST_HPFAR, + ZX_SILICON_GUEST_HDFAR), 1U); + ZX_CHECK_EQ(zx_fault_hpfar_matches_as_address(ZX_MEASURED_GUEST_HPFAR, + ZX_MEASURED_GUEST_HDFAR), 0U); + + /* The FVP's record reads as FIPA and NOT as an address; the S32Z280's + reads as an address and NOT as FIPA. Each excludes the other, which + is what makes each measurement conclusive. */ + ZX_CHECK_EQ(zx_fault_hpfar_reading(ZX_MEASURED_GUEST_HPFAR, + ZX_MEASURED_GUEST_HDFAR), + ZX_HPFAR_AS_FIPA); + ZX_CHECK_EQ(zx_fault_hpfar_reading(ZX_SILICON_GUEST_HPFAR, + ZX_SILICON_GUEST_HDFAR), + ZX_HPFAR_AS_ADDRESS); + + /* A faulting address whose low twelve bits are zero satisfies BOTH + readings, so it proves nothing -- and the classifier must say so + rather than pick one. This is why the probe faults on an address it + chose to be granule-aligned but not page-aligned. */ + ZX_CHECK_EQ(zx_fault_hpfar_reading(0x00000000U, 0x00000000U), + ZX_HPFAR_AMBIGUOUS); + + ZX_CHECK_EQ(zx_fault_hpfar_reading(0xDEADBEE0U, 0x00006000U), + ZX_HPFAR_UNRECOGNISED); + + ZX_CHECK(zx_fault_hpfar_reading_name(ZX_HPFAR_AS_FIPA)[0] != '\0'); + ZX_CHECK(zx_fault_hpfar_reading_name(ZX_HPFAR_AS_ADDRESS)[0] != '\0'); + ZX_CHECK(zx_fault_hpfar_reading_name(ZX_HPFAR_AMBIGUOUS)[0] != '\0'); + ZX_CHECK(zx_fault_hpfar_reading_name(ZX_HPFAR_UNRECOGNISED)[0] != '\0'); + ZX_CHECK(zx_fault_hpfar_reading_name((zx_hpfar_reading_t)99)[0] != '\0'); + + /* ---------------------------------------------------------------- */ + /* The record. */ + /* ---------------------------------------------------------------- */ + + zx_fill_measured_guest_record(&record); + zx_fault_record_reset(&record); + ZX_CHECK_EQ(record.zx_fault_captures, 0U); + ZX_CHECK_EQ(record.zx_fault_vector, 0U); + ZX_CHECK_EQ(record.zx_fault_hsr, 0U); + ZX_CHECK_EQ(record.zx_fault_hpfar, 0U); + ZX_CHECK_EQ(record.zx_fault_hdfar, 0U); + ZX_CHECK_EQ(record.zx_fault_hifar, 0U); + ZX_CHECK_EQ(record.zx_fault_elr, 0U); + ZX_CHECK_EQ(record.zx_fault_spsr, 0U); + + /* A null pointer must be tolerated: this is called from a fault + handler, and a reporter that faults turns one diagnosable problem + into none. */ + zx_fault_record_reset(NULL); + + /* ---------------------------------------------------------------- */ + /* The report's TEXT. What is asserted here is what somebody */ + /* reading a captured log actually needs to find in it. */ + /* ---------------------------------------------------------------- */ + + zx_capture_reset(); + zx_fault_report(NULL); + ZX_CHECK_EQ(zx_capture_contains("no fault record"), 1U); + + zx_capture_reset(); + zx_fill_measured_guest_record(&record); + zx_fault_report(&record); + + ZX_CHECK_EQ(zx_capture_overflowed(), 0U); + ZX_CHECK_EQ(zx_capture_contains("GUEST VIOLATION"), 1U); + ZX_CHECK_EQ(zx_capture_contains("data abort ROUTED to Hyp"), 1U); + ZX_CHECK_EQ(zx_capture_contains("+0x14"), 1U); + ZX_CHECK_EQ(zx_capture_contains("0x93810044"), 1U); + ZX_CHECK_EQ(zx_capture_contains("0x00000024"), 1U); + ZX_CHECK_EQ(zx_capture_contains("on a WRITE"), 1U); + /* HPFAR raw AND decoded. Printing only the decode would make the + decode unfalsifiable from a log. */ + ZX_CHECK_EQ(zx_capture_contains("0x00000060"), 1U); + ZX_CHECK_EQ(zx_capture_contains("0x00006000"), 1U); + ZX_CHECK_EQ(zx_capture_contains("0x000065c0"), 1U); + ZX_CHECK_EQ(zx_capture_contains("HPFAR reads as"), 1U); + ZX_CHECK_EQ(zx_capture_contains("FIPA[39:12] at HPFAR[31:4]"), 1U); + ZX_CHECK_EQ(zx_capture_contains("no\n[FAULT] portable decode"), 1U); + /* No spurious disagreement warning on a self-consistent record. */ + ZX_CHECK_EQ(zx_capture_contains("DISAGREE"), 0U); + ZX_CHECK_EQ(zx_capture_contains("CARRIES NO SYNDROME"), 0U); + + /* The hypervisor fault must read as a DIFFERENT event. */ + zx_capture_reset(); + zx_fault_record_reset(&record); + record.zx_fault_vector = ZX_VECTOR_DABT_HYP; + record.zx_fault_hsr = ZX_MEASURED_HYP_HSR; + record.zx_fault_hdfar = ZX_MEASURED_HYP_HDFAR; + zx_fault_report(&record); + + ZX_CHECK_EQ(zx_capture_contains("ZONEX FAULTED AT EL2"), 1U); + ZX_CHECK_EQ(zx_capture_contains("GUEST VIOLATION"), 0U); + ZX_CHECK_EQ(zx_capture_contains("+0x10"), 1U); + ZX_CHECK_EQ(zx_capture_contains("TAKEN FROM Hyp"), 1U); + /* HPFAR is not updated for a fault taken from Hyp, so the report must + NOT claim any reading of it against HDFAR. */ + ZX_CHECK_EQ(zx_capture_contains("HPFAR reads as"), 0U); + + /* A vector that carries no syndrome: the report must say the HSR is + stale rather than accuse the hardware of disagreeing with itself. + This is the case the FVP produced when ICH_VTR was read with + ICC_HSRE.SRE clear. */ + zx_capture_reset(); + zx_fault_record_reset(&record); + record.zx_fault_vector = ZX_VECTOR_UNDEF; + record.zx_fault_hsr = 0x0FE33017U; /* left over, measured */ + zx_fault_report(&record); + + ZX_CHECK_EQ(zx_capture_contains("CARRIES NO SYNDROME"), 1U); + ZX_CHECK_EQ(zx_capture_contains("DISAGREE"), 0U); + ZX_CHECK_EQ(zx_capture_contains("+0x04"), 1U); + + /* A vector that DOES carry a syndrome, disagreeing with it. Here the + warning is correct and must appear. */ + zx_capture_reset(); + zx_fault_record_reset(&record); + record.zx_fault_vector = ZX_VECTOR_DABT_HYP; + record.zx_fault_hsr = ZX_MEASURED_GUEST_HSR; /* EC 0x24, wrong here */ + zx_fault_report(&record); + + ZX_CHECK_EQ(zx_capture_contains("DISAGREE"), 1U); + ZX_CHECK_EQ(zx_capture_contains("CARRIES NO SYNDROME"), 0U); + + /* A hypercall record, so the HVC branch of the ISS decode is covered. */ + zx_capture_reset(); + zx_fault_record_reset(&record); + record.zx_fault_vector = ZX_VECTOR_HYP_TRAP; + record.zx_fault_hsr = zx_hsr_of(ZX_EC_HVC, ZX_HVC_YIELD); + zx_fault_report(&record); + + ZX_CHECK_EQ(zx_capture_contains("HYPERCALL"), 1U); + ZX_CHECK_EQ(zx_capture_contains("HVC immediate"), 1U); + ZX_CHECK_EQ(zx_capture_contains("0x00000001"), 1U); + + /* A class with no ISS decode at all, so the third arm is covered. */ + zx_capture_reset(); + zx_fault_record_reset(&record); + record.zx_fault_vector = ZX_VECTOR_HYP_TRAP; + record.zx_fault_hsr = zx_hsr_of(ZX_EC_WFI_WFE, 0U); + zx_fault_report(&record); + + ZX_CHECK_EQ(zx_capture_contains("UNEXPECTED TRAP"), 1U); + ZX_CHECK_EQ(zx_capture_contains("trapped WFI/WFE"), 1U); + ZX_CHECK_EQ(zx_capture_contains("DFSC"), 0U); + ZX_CHECK_EQ(zx_capture_contains("HVC immediate"), 0U); + + /* An HPFAR above 4 GB, which the report must flag as truncated. */ + zx_capture_reset(); + zx_fault_record_reset(&record); + record.zx_fault_vector = ZX_VECTOR_HYP_TRAP; + record.zx_fault_hsr = ZX_MEASURED_GUEST_HSR; + record.zx_fault_hpfar = 0x12000060U; + record.zx_fault_hdfar = ZX_MEASURED_GUEST_HDFAR; + zx_fault_report(&record); + + ZX_CHECK_EQ(zx_capture_contains("TRUNCATED"), 1U); + ZX_CHECK_EQ(zx_capture_contains("NEITHER documented reading"), 1U); + + /* The silicon record must read as the OTHER reading, from the same + report code, with no edit. This is the assertion that would catch a + "simplification" of the decode back to one reading. */ + zx_capture_reset(); + zx_fault_record_reset(&record); + record.zx_fault_vector = ZX_VECTOR_HYP_TRAP; + record.zx_fault_hsr = ZX_SILICON_GUEST_HSR; + record.zx_fault_hpfar = ZX_SILICON_GUEST_HPFAR; + record.zx_fault_hdfar = ZX_SILICON_GUEST_HDFAR; + zx_fault_report(&record); + + ZX_CHECK_EQ(zx_capture_contains("the TRM's Table 3-69 row"), 1U); + ZX_CHECK_EQ(zx_capture_contains("FIPA[39:12] at HPFAR[31:4]"), 0U); + + /* The capture console itself: a test that silently lost its output + would assert the ABSENCE of text for the wrong reason. */ + ZX_CHECK_EQ(zx_capture_contains(NULL), 0U); + ZX_CHECK_EQ(zx_capture_exit_status(), 0xFFFFFFFFU); +}) diff --git a/test/host/zx_console_capture.c b/test/host/zx_console_capture.c new file mode 100644 index 0000000..e4bea69 --- /dev/null +++ b/test/host/zx_console_capture.c @@ -0,0 +1,160 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_console_capture.c HOST TESTS */ +/* */ +/* DESCRIPTION */ +/* */ +/* The third implementation of common/inc/zx_console.h. The other */ +/* two are semihosting and a board UART, both in the Cortex-R52 port; */ +/* this one keeps the bytes so a test can look at them. */ +/* */ +/* The formatting is deliberately IDENTICAL to the port's, digit for */ +/* digit -- eight zero-padded hexadecimal digits with an 0x prefix, */ +/* decimal with no padding. A capture that formatted differently */ +/* would let a test assert on text no target ever prints, which is a */ +/* worse outcome than having no test. */ +/* */ +/**************************************************************************/ + +#include "zx_console.h" +#include "zx_console_capture.h" + +#include +#include + +#define ZX_HEX_DIGITS 8U + +static char zx_capture_buffer[ZX_CAPTURE_SIZE]; +static size_t zx_capture_used; +static uint32_t zx_capture_lost; +static uint32_t zx_capture_status = 0xFFFFFFFFU; + + +void zx_capture_reset(void) +{ + zx_capture_buffer[0] = '\0'; + zx_capture_used = 0U; + zx_capture_lost = 0U; + zx_capture_status = 0xFFFFFFFFU; +} + + +const char *zx_capture_text(void) +{ + return zx_capture_buffer; +} + + +uint32_t zx_capture_overflowed(void) +{ + return zx_capture_lost; +} + + +uint32_t zx_capture_contains(const char *needle) +{ + if (needle == NULL) + { + return 0U; + } + + return (strstr(zx_capture_buffer, needle) != NULL) ? 1U : 0U; +} + + +uint32_t zx_capture_exit_status(void) +{ + return zx_capture_status; +} + + +void zx_console_puts(const char *string_ptr) +{ + size_t length; + + if (string_ptr == NULL) + { + return; + } + + length = strlen(string_ptr); + + if ((zx_capture_used + length + 1U) > ZX_CAPTURE_SIZE) + { + zx_capture_lost = 1U; + return; + } + + memcpy(&zx_capture_buffer[zx_capture_used], string_ptr, length + 1U); + zx_capture_used += length; +} + + +void zx_console_puthex(uint32_t value) +{ + static const char digits[] = "0123456789abcdef"; + char buffer[3U + ZX_HEX_DIGITS]; + uint32_t index; + + buffer[0] = '0'; + buffer[1] = 'x'; + + for (index = 0U; index < ZX_HEX_DIGITS; index++) + { + uint32_t shift = (ZX_HEX_DIGITS - 1U - index) * 4U; + + buffer[2U + index] = digits[(value >> shift) & 0xFU]; + } + + buffer[2U + ZX_HEX_DIGITS] = '\0'; + + zx_console_puts(buffer); +} + + +void zx_console_putdec(uint32_t value) +{ + char buffer[11]; + uint32_t index = (uint32_t)sizeof(buffer) - 1U; + uint32_t work = value; + + buffer[index] = '\0'; + + do + { + index--; + buffer[index] = (char)('0' + (char)(work % 10U)); + work /= 10U; + } while ((work != 0U) && (index > 0U)); + + zx_console_puts(&buffer[index]); +} + + +ZX_NORETURN void zx_console_exit(uint32_t failures) +{ + zx_capture_status = failures; + + /* The host suite has no image to stop, and a test that called this would + otherwise take its own process down mid-run. exit is honest about + what the declaration promises; the tests simply never call it. */ + + exit((int)(failures == 0U ? 0U : 1U)); +} diff --git a/test/host/zx_console_capture.h b/test/host/zx_console_capture.h new file mode 100644 index 0000000..5cb3d5a --- /dev/null +++ b/test/host/zx_console_capture.h @@ -0,0 +1,61 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_console_capture.h HOST TESTS */ +/* */ +/* DESCRIPTION */ +/* */ +/* The host suite's console: a buffer instead of a UART. */ +/* */ +/* This is what makes the fault report testable at all. A fault */ +/* report is a body of TEXT that somebody reads at three in the */ +/* morning with a board on the bench, and the properties that matter */ +/* about it -- that it names the exception class, that it prints HPFAR */ +/* raw as well as decoded, that it says so when the vector and the */ +/* syndrome disagree -- are properties of the text. Asserting on them */ +/* on a workstation is far better than provoking real faults and */ +/* reading a log by eye. */ +/* */ +/**************************************************************************/ + +#ifndef ZX_CONSOLE_CAPTURE_H +#define ZX_CONSOLE_CAPTURE_H + +#include "zx_api.h" + +/* Big enough for the longest fault report several times over. A capture + that silently truncated would make an assertion about absent text pass + for the wrong reason, so zx_capture_overflowed reports it instead. */ + +#define ZX_CAPTURE_SIZE 16384U + +void zx_capture_reset(void); +const char *zx_capture_text(void); +uint32_t zx_capture_overflowed(void); + +/* Substring search, because that is the only question the tests ask. */ + +uint32_t zx_capture_contains(const char *needle); + +/* The failure count the last zx_console_exit was given, or 0xFFFFFFFF if it + has not been called. */ + +uint32_t zx_capture_exit_status(void); + +#endif /* ZX_CONSOLE_CAPTURE_H */ diff --git a/test/host/zx_test.h b/test/host/zx_test.h index 7fadc40..c8f2c25 100644 --- a/test/host/zx_test.h +++ b/test/host/zx_test.h @@ -12,6 +12,7 @@ * * SPDX-License-Identifier: MIT and CC0-1.0 **************************************************************************/ +// Some portions generated by Claude Code (Opus 5). /**************************************************************************/ /* */ @@ -67,7 +68,17 @@ extern unsigned int zx_test_checks; } while (0) /* Defines the counters and a main() that reports and returns a status CTest - can read. Exactly one translation unit per test binary uses it. */ + can read. Exactly one translation unit per test binary uses it. + + ONE SHARP EDGE, and it is the preprocessor's rather than this harness's: + the body arrives as a SINGLE macro argument, and macro arguments are + separated on commas at parenthesis depth zero. Braces do not protect a + comma. So a braced initialiser list written directly in the body -- a + table of values to loop over, most naturally -- is read as one extra + argument per element, and the error GCC reports names the closing brace of + the body rather than the line responsible. Put such tables at file scope. + Commas inside ZX_CHECK(...) and any other call are fine; those are inside + parentheses. */ #define ZX_TEST_MAIN(suite_name, body) \ unsigned int zx_test_failures = 0U; \ unsigned int zx_test_checks = 0U; \ diff --git a/test/s32z280/run.sh b/test/s32z280/run.sh index 012b39e..de62e6b 100755 --- a/test/s32z280/run.sh +++ b/test/s32z280/run.sh @@ -30,7 +30,16 @@ # * Never pipe a run through tail or head. Redirect to a file and read the # file; the pipe truncates exactly the part that matters when a run hangs. # -# STATUS: there are no images yet. See test/fvp/run.sh for the same note. +# WHAT "test" RUNS. examples/s32z280_evb/tools/run_zx_probe.sh, which checks +# its own preconditions, captures the console to a file BEFORE the image runs, +# and judges the run from both the console and memory. The negative and +# region-starvation builds are run the same way, by name: +# +# examples/s32z280_evb/tools/run_zx_probe.sh build/s32z280 zx_probe_negative.elf +# examples/s32z280_evb/tools/run_zx_probe.sh build/s32z280 zx_probe_starved.elf +# examples/s32z280_evb/tools/run_zx_probe.sh build/s32z280 zx_probe_el2_fault.elf +# +# All three must FAIL; the last one makes ZoneX fault at EL2 on purpose. set -euo pipefail @@ -60,18 +69,18 @@ case "${command}" in image_list="$(images)" if [ -z "${image_list}" ]; then - echo "ZoneX: no S32Z280 images exist yet." - echo "ZoneX: the hypervisor libraries were built; nothing was flashed." - exit 0 + echo "ZoneX: no S32Z280 images were found in the ninja graph." >&2 + echo "ZoneX: ZoneX HAS images, so the build system is broken rather" >&2 + echo "ZoneX: than the tree being incomplete." >&2 + exit 1 fi # shellcheck disable=SC2086 cmake --build "${BUILD}" --target ${image_list} if [ "${command}" = "test" ]; then - echo "ZoneX: running on the S32Z280 needs the board and the gdb harness," >&2 - echo "ZoneX: which arrives with the first image." >&2 - exit 1 + exec "${ROOT}/examples/s32z280_evb/tools/run_zx_probe.sh" \ + "${BUILD}" zx_probe.elf fi ;; *) From 709a31b5f914bf43ecdfa66b76b72cef955f377b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 2 Sep 2026 11:07:10 -0400 Subject: [PATCH 03/40] Replaced WILL_FAIL with an explicit expected verdict in the FVP runner The two negative FVP tests could pass for the wrong reason. CTest's WILL_FAIL inverts the exit status and nothing else, so it could not distinguish the outcome the suite wants -- the image ran and reported its own failure -- from the ones it does not: the image was never built, the model would not start, or the run hung. All of those exit non-zero, and WILL_FAIL called all of them passes. The images are EXCLUDE_FROM_ALL, so this was reachable from a clean tree: with them unbuilt, `ninja && ctest` reported zx-fvp-probe-negative and zx-fvp-probe-starved as Passed on "application file not found" -- two green lines asserting nothing whatever about stage 2. A negative test that passes because nothing ran is worse than no test, because it is counted. CI was not affected; it names the images as build targets before running the suite. The runner now takes --expect {pass,fail} and judges the verdict itself, and checks the image exists before launching the model. The property WILL_FAIL was chosen for is kept: a negative build that starts PASSING, meaning the violation stopped being detected, still fails the suite. Verified in all three directions: with the images absent all three tests fail; with them built all three pass; and pointing --expect fail at the positive image fails by name. Assisted-by: Claude Code (Opus 5) --- .github/workflows/zx_fvp.yml | 7 +- examples/common/zx_probe_main.c | 2 +- examples/fvp_baser_aemv8r/CMakeLists.txt | 28 +++-- test/fvp/run.sh | 4 +- test/fvp/run_fvp_test.py | 130 +++++++++++++++++++---- 5 files changed, 137 insertions(+), 34 deletions(-) diff --git a/.github/workflows/zx_fvp.yml b/.github/workflows/zx_fvp.yml index 16ef256..0cb11e3 100644 --- a/.github/workflows/zx_fvp.yml +++ b/.github/workflows/zx_fvp.yml @@ -33,8 +33,11 @@ name: zx_fvp # WHAT IT RUNS. zx_probe.elf, which must PASS, plus two builds of the same # image that must FAIL -- one whose deliberate violation is aimed at an address # the payload is granted, and one told it needs more MPU regions than exist. -# Both are registered WILL_FAIL, because a check that has never been seen to -# fail is not evidence that it can. CTest runs all three. +# Both are registered with the runner's "--expect fail", because a check that +# has never been seen to fail is not evidence that it can. CTest runs all +# three. (CTest's WILL_FAIL is deliberately NOT used: it inverts the exit +# status and nothing else, so it cannot tell a reported failure from an image +# that was never built. See test/fvp/run_fvp_test.py.) # # zx_probe_el2_fault.elf is deliberately NOT registered: its expected outcome # is a hypervisor-fault report, and a suite taught to accept that as a pass diff --git a/examples/common/zx_probe_main.c b/examples/common/zx_probe_main.c index 4b56372..ab97928 100644 --- a/examples/common/zx_probe_main.c +++ b/examples/common/zx_probe_main.c @@ -640,7 +640,7 @@ static void zx_phase_violation(void) "\n" " Expected here: ATTEMPTED set, SURVIVED set, two failing checks,\n" " no stage-2 fault from this phase, and ZONEX RESULT: FAILED. The\n" - " CTest entry for this build carries WILL_FAIL, so this build\n" + " CTest entry for this build expects FAILED, so this build\n" " starting to PASS -- meaning the violation stopped being detected\n" " -- fails the suite.\n"); #endif diff --git a/examples/fvp_baser_aemv8r/CMakeLists.txt b/examples/fvp_baser_aemv8r/CMakeLists.txt index eab1346..5c5b457 100644 --- a/examples/fvp_baser_aemv8r/CMakeLists.txt +++ b/examples/fvp_baser_aemv8r/CMakeLists.txt @@ -99,8 +99,8 @@ zx_add_fvp_probe(zx_probe.elf) # is kept as a build rather than as a temporary edit so that "the check has # been seen to fail" stays reproducible instead of being a claim in a log. # -# Registered with CTest as a WILL_FAIL test, which is the only way a suite can -# assert that a check is capable of failing. +# Registered with CTest under "--expect fail", so that the suite asserts the +# check is capable of failing AND that the failure was actually reported. zx_add_fvp_probe(zx_probe_negative.elf) target_compile_definitions(zx_probe_negative.elf PRIVATE ZX_PROBE_NEGATIVE_TEST) @@ -135,23 +135,31 @@ if(ZX_FVP_BASER_AEMV8R) --elf $ --fvp ${ZX_FVP_BASER_AEMV8R}) - # The negative build must FAIL. WILL_FAIL rather than a second runner - # flag, so that the assertion lives in the suite definition where it - # can be read, and so that a negative build which started passing -- - # meaning the violation stopped being detected -- fails the suite. + # The negative build must report FAILED. "--expect fail" rather than + # CTest's WILL_FAIL, which this suite used until it was caught being + # wrong: WILL_FAIL inverts the exit status and nothing else, so it + # cannot distinguish "the image reported its own failure" from "the + # image was never built", "the model would not start" or "it hung". + # All of those exit non-zero, and WILL_FAIL called all of them passes + # -- observed, with these EXCLUDE_FROM_ALL images unbuilt, as two + # green lines asserting nothing about stage 2 at all. + # + # The property WILL_FAIL was chosen for survives the change: a + # negative build that starts PASSING, meaning the violation stopped + # being detected, still fails the suite. See test/fvp/run_fvp_test.py. add_test(NAME zx-fvp-probe-negative COMMAND ${Python3_EXECUTABLE} ${ZX_FVP_RUNNER} --elf $ - --fvp ${ZX_FVP_BASER_AEMV8R}) - set_tests_properties(zx-fvp-probe-negative PROPERTIES WILL_FAIL TRUE) + --fvp ${ZX_FVP_BASER_AEMV8R} + --expect fail) # And the refusal path, for the same reason: a branch that has never # been taken is not evidence that it works. add_test(NAME zx-fvp-probe-starved COMMAND ${Python3_EXECUTABLE} ${ZX_FVP_RUNNER} --elf $ - --fvp ${ZX_FVP_BASER_AEMV8R}) - set_tests_properties(zx-fvp-probe-starved PROPERTIES WILL_FAIL TRUE) + --fvp ${ZX_FVP_BASER_AEMV8R} + --expect fail) add_custom_target(zx-run-probe COMMAND ${ZX_FVP_BASER_AEMV8R} diff --git a/test/fvp/run.sh b/test/fvp/run.sh index acfec8c..b7668cb 100755 --- a/test/fvp/run.sh +++ b/test/fvp/run.sh @@ -26,8 +26,8 @@ # and two builds that must FAIL -- zx_probe_negative.elf, whose deliberate # violation is aimed at an address the payload IS granted, and # zx_probe_starved.elf, which is told it needs more MPU regions than exist. -# Both are registered WILL_FAIL in CMake, because a check that has never been -# seen to fail is not evidence that it can. +# Both are registered with "--expect fail" in CMake, because a check that has +# never been seen to fail is not evidence that it can. # # zx_probe_el2_fault.elf is built but not registered: it makes ZoneX fault at # EL2 on purpose, so its expected outcome IS a failure report. Run it with diff --git a/test/fvp/run_fvp_test.py b/test/fvp/run_fvp_test.py index fd180fa..18e5168 100755 --- a/test/fvp/run_fvp_test.py +++ b/test/fvp/run_fvp_test.py @@ -25,9 +25,38 @@ ZoneX prints "ZONEX RESULT: ..." rather than "RESULT: ...", so the two suites' markers cannot be confused if their logs are ever concatenated. + +WHY --expect EXISTS, AND WHY CTest's WILL_FAIL IS NOT USED INSTEAD + +Some ZoneX images are built to fail: the negative build aims its violation at +an address the payload IS granted, and the starved build claims more regions +than any implementation has. Their whole purpose is to show that a check is +capable of failing, which is the only evidence that the check works. + +The obvious way to assert that is CTest's WILL_FAIL property, and this suite +used it until it was caught being wrong. WILL_FAIL inverts the exit status and +nothing else, so it cannot tell the outcome we want -- "the image ran and +reported its own failure" -- from the outcomes we do not: + + * the image was never built (these targets are EXCLUDE_FROM_ALL, so a plain + `ninja && ctest` leaves them absent), and the runner failed to find it; + * the model was missing, or refused the command line; + * the image hung and hit the timeout. + +Every one of those exits non-zero, so WILL_FAIL called every one of them a +pass. Observed: with the images unbuilt, the two negative tests reported +`Passed` on "application file not found" -- two green lines asserting nothing +whatever about stage 2. A negative test that passes because nothing ran is +worse than no test, because it is counted. + +So the assertion moves in here, where the verdict itself can be read. The +property WILL_FAIL was chosen for is kept: a negative build that starts PASSING +-- meaning the violation stopped being detected -- still fails the suite, under +`--expect fail`, by printing the pass mark it must not print. """ import argparse +import os import subprocess import sys @@ -50,7 +79,74 @@ def as_text(stream): return stream -def run(elf, fvp, timeout): +def judge(output, expect): + """Compare the captured console against what this image was built to do. + + Returns a process exit status. Ordering matters in both branches: the + mark that must NOT appear is tested before the one that must, so an image + printing both is reported as broken rather than as whichever came first. + """ + if expect == "pass": + if FAIL_MARK in output: + print("FAIL: the image reported failing checks.", file=sys.stderr) + for line in output.splitlines(): + if line.startswith(" [FAIL]"): + print(" " + line.strip(), file=sys.stderr) + return 1 + + if PASS_MARK not in output: + print( + "FAIL: no ZoneX verdict line was found. The image neither " + "passed nor reported failure, so it did not reach its " + "verdict.", + file=sys.stderr, + ) + return 1 + + print("PASS") + return 0 + + # expect == "fail": this image was built to violate something, and the + # run is judged on whether the violation was DETECTED and NAMED. + if PASS_MARK in output: + print( + "FAIL: the image reported ALL CHECKS PASSED, but it was built to " + "fail. The check it was built to violate has stopped detecting " + "the violation -- so the corresponding positive test is no " + "longer evidence of anything.", + file=sys.stderr, + ) + return 1 + + if FAIL_MARK not in output: + print( + "FAIL: no ZoneX verdict line was found. A negative build must " + "REPORT its own failure, not merely fail to run: an image that " + "was never built, a model that would not start, or a hang all " + "produce no verdict, and none of them is evidence that the " + "check works.", + file=sys.stderr, + ) + return 1 + + print("PASS (the expected failure was reported)") + return 0 + + +def run(elf, fvp, timeout, expect): + # Checked before the model is launched, and checked for every value of + # --expect. These images are EXCLUDE_FROM_ALL, so "not built yet" is the + # normal state of a fresh tree rather than an exotic error, and it must + # never reach the verdict logic as an absent-verdict run. + if not os.path.isfile(elf): + print( + f"FAIL: no image at {elf}. It is EXCLUDE_FROM_ALL, so `ninja` " + "alone does not build it; name the image as a build target " + "before running the suite.", + file=sys.stderr, + ) + return 1 + command = [ fvp, "-C", "cluster0.NUM_CORES=1", @@ -70,6 +166,12 @@ def run(elf, fvp, timeout): command, capture_output=True, text=True, timeout=timeout ) output = as_text(completed.stdout) + as_text(completed.stderr) + except FileNotFoundError: + print( + f"FAIL: the model binary {fvp} could not be executed.", + file=sys.stderr, + ) + return 1 except subprocess.TimeoutExpired as expired: print(as_text(expired.stdout) + as_text(expired.stderr)) print( @@ -82,23 +184,7 @@ def run(elf, fvp, timeout): print(output) - if FAIL_MARK in output: - print("FAIL: the image reported failing checks.", file=sys.stderr) - for line in output.splitlines(): - if line.startswith(" [FAIL]"): - print(" " + line.strip(), file=sys.stderr) - return 1 - - if PASS_MARK not in output: - print( - "FAIL: no ZoneX verdict line was found. The image neither passed " - "nor reported failure, so it did not reach its verdict.", - file=sys.stderr, - ) - return 1 - - print("PASS") - return 0 + return judge(output, expect) def main(): @@ -108,9 +194,15 @@ def main(): parser.add_argument( "--timeout", type=int, default=180, help="seconds before declaring a hang" ) + parser.add_argument( + "--expect", + choices=("pass", "fail"), + default="pass", + help="the verdict this image was built to print (default: pass)", + ) arguments = parser.parse_args() - return run(arguments.elf, arguments.fvp, arguments.timeout) + return run(arguments.elf, arguments.fvp, arguments.timeout, arguments.expect) if __name__ == "__main__": From ee7b3aecdf687795de29be769114a5e77fa2b88f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 2 Sep 2026 11:18:30 -0400 Subject: [PATCH 04/40] Added a check for local paths and unreachable references Several comments cited a numbered section of a planning document that is not in this repository. The facts were fine; the citations were useless to anyone reading the repository, since that document is not here to read, and they date badly besides. Each one is reworded to keep the fact and drop the reference. The check is the actual fix. These went in one at a time while the work was fresh, and a manual sweep is what let them through -- it looked for absolute paths but not for a citation, and would have missed the hyphenated spelling even if it had. scripts/check_references.sh now rejects local absolute paths, numbered references to documents that are not present, and a tracked agent-instruction file, with the same skip-marker convention as the terminology check. It found the hyphenated one immediately. It runs in CI beside scripts/check_terminology.sh, in the same job and with if:!cancelled() so that one push reports both checks' findings rather than one per re-run. Deliberately narrow: a check that cries wolf gets bypassed. Assisted-by: Claude Code (Opus 5) --- .github/workflows/host_tests.yml | 6 ++ examples/common/zx_payload.S | 4 +- examples/fvp_baser_aemv8r/zx_platform.h | 3 +- platform/cortex_r52/src/zx_el2_entry.S | 6 +- platform/cortex_r52/src/zx_trap_handler.S | 4 +- scripts/check_references.sh | 95 +++++++++++++++++++++++ 6 files changed, 110 insertions(+), 8 deletions(-) create mode 100755 scripts/check_references.sh diff --git a/.github/workflows/host_tests.yml b/.github/workflows/host_tests.yml index 215c2d0..c3176c2 100644 --- a/.github/workflows/host_tests.yml +++ b/.github/workflows/host_tests.yml @@ -81,6 +81,12 @@ jobs: - name: Check for A-profile and RISC-V terminology run: scripts/check_terminology.sh + # Both checks run in the same job, and both run even if the first fails, + # so that one push reports every finding rather than one per re-run. + - name: Check for local paths and unreachable references + if: '!cancelled()' + run: scripts/check_references.sh + host: runs-on: ubuntu-24.04 steps: diff --git a/examples/common/zx_payload.S b/examples/common/zx_payload.S index 082b1cd..4ec2ecf 100644 --- a/examples/common/zx_payload.S +++ b/examples/common/zx_payload.S @@ -18,8 +18,8 @@ @ WHAT THIS IS @ @ A few dozen instructions that run at EL1 under stage 2, with no library, -@ no stack use worth the name and no console. It is not a guest: step 4 -@ brings a real ThreadX guest. It is the smallest thing that can prove +@ no stack use worth the name and no console. It is not a guest: a real +@ ThreadX guest comes later. It is the smallest thing that can prove @ stage 2 grants what it should and denies what it should not. @ @ WHY IT DOES NOT PRINT diff --git a/examples/fvp_baser_aemv8r/zx_platform.h b/examples/fvp_baser_aemv8r/zx_platform.h index 423c2d7..42a7972 100644 --- a/examples/fvp_baser_aemv8r/zx_platform.h +++ b/examples/fvp_baser_aemv8r/zx_platform.h @@ -67,7 +67,8 @@ from the counter control frame. The stage-2 probe does not use the timer and deliberately does not program CNTFRQ: it REPORTS it, so that a zero here is visible as the model's own state rather than hidden by ZoneX - having written over it. Step 5 is where the timer matters. */ + having written over it. The timer matters once time partitioning + arrives. */ #define ZX_FVP_SYSTEM_COUNTER_HZ 100000000UL diff --git a/platform/cortex_r52/src/zx_el2_entry.S b/platform/cortex_r52/src/zx_el2_entry.S index 78dd3e2..70620fa 100644 --- a/platform/cortex_r52/src/zx_el2_entry.S +++ b/platform/cortex_r52/src/zx_el2_entry.S @@ -150,9 +150,9 @@ zx_el2_start_a32: @ memory configuration a bring-up failure can be reproduced in, and @ it is what the Cortex-R52 port defaults to for the same reason. It @ also removes one variable from the stage-2 measurements this image - @ exists to make. Caches are a step-5 concern, where the timing - @ claims are, and they will need HMAIR and the region attributes to - @ be right first -- which is what this image establishes. + @ exists to make. Caches belong with the timing claims, and they + @ will need HMAIR and the region attributes to be right first -- + @ which is what this image establishes. @ ------------------------------------------------------------------ mrc p15, 4, r4, c1, c0, 0 @ HSCTLR, as reset diff --git a/platform/cortex_r52/src/zx_trap_handler.S b/platform/cortex_r52/src/zx_trap_handler.S index d184542..ce93aee 100644 --- a/platform/cortex_r52/src/zx_trap_handler.S +++ b/platform/cortex_r52/src/zx_trap_handler.S @@ -49,7 +49,7 @@ @ handler that decides policy in the vector has no context to decide it @ with; a hypervisor that gets its faults back as VALUES can choose what to @ do about a partition -- report it, restart it, stop scheduling it -- in -@ code that can be read. Step 3 keeps this seam. +@ code that can be read. This seam is kept as partitions arrive. @ @ The exception is a fault taken FROM Hyp mode. There is nothing safe to @ resume there: ZoneX faulted on its own access, so whatever invariant the @@ -220,7 +220,7 @@ zx_el2_trap_guest_violation: /* Phase 0 configures no interrupt controller and routes nothing to EL2, */ /* so an interrupt arriving while a payload runs would be taken by EL1 */ /* with no handler behind it. Masking says that out loud rather than */ -/* leaving it to luck; step 4 opens them when a guest has vectors. */ +/* leaving it to luck; they open when a guest has vectors of its own. */ /**************************************************************************/ .global zx_el2_run_payload diff --git a/scripts/check_references.sh b/scripts/check_references.sh new file mode 100755 index 0000000..1775410 --- /dev/null +++ b/scripts/check_references.sh @@ -0,0 +1,95 @@ +#!/bin/bash +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# Fail the build if the repository cites something a reader of it cannot see. +# +# Three things, all of them ordinary hygiene and all of them easy to write by +# accident while the work is fresh: +# +# 1. A LOCAL ABSOLUTE PATH. "/home/someone/toolchains/..." or "~/work/..." +# means nothing to anybody else, and in a build script it means the +# script only runs on one machine. Paths belong in variables the reader +# can set, which is why the board harness under examples/ takes its +# locations from the environment and refuses to guess. +# +# 2. A NUMBERED STEP OF A DOCUMENT THAT IS NOT IN THE REPOSITORY. A comment +# saying "step 4 brings a real guest" is useless to a contributor who has +# no step 4 to read, and it dates badly besides. The FACT such a comment +# carries is almost always worth keeping -- say "a real guest comes +# later", and the sentence survives both the reader and the schedule. +# +# 3. AN AGENT-INSTRUCTION FILE. No Eclipse ThreadX repository carries one. +# Development-environment instructions are not project documentation, and +# a committed one starts being treated as though it were. +# +# Deliberately narrow. It catches the shapes that have actually gone wrong +# rather than trying to be a general secret scanner, because a check that +# cries wolf gets bypassed and then catches nothing at all. +# +# A line that must contain one of these carries the marker +# "zx-references-allow" and is skipped. As with the terminology check, the +# marker is ugly on purpose: using it should be a visible decision. + +set -euo pipefail + +readonly ROOT="$(cd "$(dirname "$(realpath "$0")")/.." && pwd)" +readonly MARKER='zx-references-allow' + +cd "${ROOT}" + +readonly FORBIDDEN_FILES='^(AGENTS|CLAUDE)\.md$|/(AGENTS|CLAUDE)\.md$' + +# "~/" only when it opens a path, so that prose using a tilde is not caught. +# "step " case-insensitively, because "Step 5" reads as naturally as +# "step 5" and both are the same mistake. +readonly PATTERN='(^|[^A-Za-z0-9_.-])(/home/[A-Za-z0-9._-]+|~/[A-Za-z0-9._-])|\bstep[ -][0-9]+\b' + +status=0 + +# This script is excluded by name: it has to spell the patterns to look for +# them. Tracked files only -- a build tree is not this repository's content. +findings="$(git ls-files -z \ + | grep -zZv '^scripts/check_references\.sh$' \ + | xargs -0 grep -nEiI "${PATTERN}" 2>/dev/null \ + | grep -v "${MARKER}" || true)" + +if [ -n "${findings}" ]; then + echo "ZoneX reference check FAILED." >&2 + echo "" >&2 + echo "These lines name a local path, or a numbered step of a document" >&2 + echo "that is not in this repository. Keep the fact and drop the" >&2 + echo "citation: a reader cannot follow either one." >&2 + echo "" >&2 + echo "${findings}" >&2 + status=1 +fi + +tracked_agent_files="$(git ls-files | grep -E "${FORBIDDEN_FILES}" || true)" + +if [ -n "${tracked_agent_files}" ]; then + echo "" >&2 + echo "ZoneX reference check FAILED: an agent-instruction file is" >&2 + echo "tracked. No Eclipse ThreadX repository carries one." >&2 + echo "" >&2 + echo "${tracked_agent_files}" >&2 + status=1 +fi + +if [ "${status}" -ne 0 ]; then + exit 1 +fi + +echo "ZoneX reference check passed." From ca39876843b5f7cc4b66743ae3ddf2d3ecd2924e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 2 Sep 2026 11:18:48 -0400 Subject: [PATCH 05/40] Added the partition manifest types The manifest is the user-facing contract of a ZoneX system, so this change is mostly about which vocabulary it is written in. ZoneX adopts the suite's own integer type names -- the eight typedefs every ThreadX port carries -- rather than spelling the manifest in terms. The suite-wide C17 upgrade settles the question: it changes no struct layout, no calling convention and no type name, and it regression-tests that a C99 application still compiles against the new headers, which makes those names load-bearing for that promise and therefore permanent. A manifest written in uint8_t would make ZoneX the one component of the suite whose contract used a different vocabulary, for no gain. Addresses stay zx_addr_t: ULONG is not pointer-width on every ABI the suite targets. ZoneX keeps its own copy of the typedefs rather than reaching for tx_port.h, because a manifest has to be readable with no ThreadX checkout in sight; the copy is deliberately unguarded so that a divergence is a hard error at the include rather than a quiet one. The attribute fields are UCHAR and not an enum, and that was measured rather than assumed: a four-value enum is 1 byte under arm-none-eabi-gcc, which uses AAPCS short enums, and 4 bytes under ATfE clang and on the host. ZoneX builds with all three, so an enum field would give the manifest a different layout in each lane. The descriptor's layout is now asserted -- the four attribute bytes consecutive in every lane, and the whole descriptor pinned to 12 bytes on a 32-bit port, so that a change in either target toolchain fails the build instead of producing two images that disagree. The shared-memory type carries the reasoning it needs. Phase 0 shares one read-only granule so the demonstrator has a positive channel to show rather than only proving isolation by what faults, and a single region cannot express it: stage-2 AP belongs to a region, not to a partition, so the publisher covers the granule writably and each reader read-only. Those ranges coincide, which is safe only because one partition runs at a time -- exactly the kind of reasoning that must be written down. What it weakens and what it does not is stated on the type. The region descriptor and the AP/SH/XN encodings move out of the port header into the manifest, because the host-side validator has to build and check the same objects with no Cortex-R52 header in reach. Nothing in assembly used them, which is what made the move free. The port keeps the field positions they shift into and the HMAIR byte layout, which is this port's choice rather than an architectural fact. Also carries three of the reference rewordings from the previous commit, in the three files that changed for both reasons. Verified: both repository checks, the host suite, and the FVP suite under GCC and ATfE clang, plus S32Z280 builds under both toolchains. Assisted-by: Claude Code (Opus 5) --- common/inc/zx_api.h | 42 +++++ core/inc/zx_manifest.h | 240 +++++++++++++++++++++++- examples/common/zx_probe_main.c | 10 +- examples/s32z280_evb/zx_board.c | 8 +- platform/cortex_r52/inc/zx_port.h | 73 +++---- platform/cortex_r52/src/zx_stage2_mpu.c | 16 +- 6 files changed, 317 insertions(+), 72 deletions(-) diff --git a/common/inc/zx_api.h b/common/inc/zx_api.h index fd57f8a..0fedfca 100644 --- a/common/inc/zx_api.h +++ b/common/inc/zx_api.h @@ -109,6 +109,48 @@ extern "C" { /* Fundamental types */ /**************************************************************************/ +/* The suite's integer type names, deliberately identical to the eight + typedefs every ThreadX port carries in its own tx_port.h. + + ZoneX does not link ThreadX (docs/decisions.md D9), so it cannot reach + tx_port.h for them -- and should not: a manifest has to be readable with no + ThreadX checkout in sight, which is also what lets the host suite build + with none present. ZoneX therefore keeps its own copy. + + Copying rather than respelling them in terms is the deliberate + choice, and the suite-wide C17 upgrade is what settles it. That upgrade + changes no struct layout, no calling convention and no type name; it + regression-tests that a C99 application still compiles against the new + headers. These names are load-bearing for that promise and therefore + permanent, so adopting them costs nothing later. A manifest written in + uint8_t would make ZoneX the one component of the suite whose user-facing + contract used a different vocabulary, for no gain. + + Identical typedef redeclaration is legal from C11 onwards, so an + application including both tx_api.h and zx_api.h is well-formed. The + coupling that remains is the real cost: a port that ever respelled one of + these -- ULONG as unsigned long long, say -- would conflict with this copy + at the point of inclusion. That is deliberately left unguarded, because a + hard error at the include is the outcome worth having; an #ifndef around + the block would hide the divergence instead. + + VOID is not copied. ThreadX spells it as a macro rather than a typedef, + ZoneX writes plain void and has no use for it, and an uncopied macro cannot + collide. + + Addresses are NOT spelled with these -- see zx_addr_t below, and D5. ULONG + is unsigned long, which is not pointer-width on every ABI the suite + targets; the suite's own C17 plan adds a static assertion to each + tx_port.h precisely to catch that mismatch. */ +typedef char CHAR; +typedef unsigned char UCHAR; +typedef int INT; +typedef unsigned int UINT; +typedef long LONG; +typedef unsigned long ULONG; +typedef short SHORT; +typedef unsigned short USHORT; + /* An address in a partition manifest or in an MPU region descriptor. Deliberately a width-correct typedef rather than a bare unsigned long: the manifest is a data structure ZoneX means to keep across ports, and Armv8-R diff --git a/core/inc/zx_manifest.h b/core/inc/zx_manifest.h index 4b25ee5..759f52f 100644 --- a/core/inc/zx_manifest.h +++ b/core/inc/zx_manifest.h @@ -30,11 +30,6 @@ /* what makes it the part of ZoneX the host test suite can validate */ /* without a model or a board (docs/decisions.md D11). */ /* */ -/* STATUS */ -/* */ -/* Declared empty. The manifest types and the validator arrive */ -/* together, and the host suite under test/host grows with them. */ -/* */ /**************************************************************************/ #ifndef ZX_MANIFEST_H @@ -46,6 +41,241 @@ extern "C" { #endif +/**************************************************************************/ +/* Build-time capacities */ +/**************************************************************************/ + +/* Fixed capacities rather than dynamic allocation. Phase 0 is static + partitioning: there is no allocator, no filesystem and no partition + creation at run time, so every array below is sized at build time and the + validator can prove the whole system fits the hardware before it runs. + + ZX_MAX_REGIONS_PER_PARTITION is 6 because the region budget, not the + manifest, is the scarce resource: the smallest part ZoneX targets has 20 + EL2 regions, of which the hypervisor's own MMIO takes 2 on silicon. Two + partitions of 6 regions plus a shared granule each fit that with room + left. Raising it is a one-line change the boot-time region check will + immediately hold to account. */ +#define ZX_MAX_PARTITIONS 4U +#define ZX_MAX_REGIONS_PER_PARTITION 6U +#define ZX_MAX_SHARED_RANGES 2U + +/**************************************************************************/ +/* Stage-2 region attributes */ +/**************************************************************************/ + +/* AP[2:1] as stage 2 encodes it. These are NOT the EL1 AP encodings, and + the difference is the one that matters for isolation: there is no encoding + that grants a guest access while denying EL2. A partition is isolated + from another partition by which regions are ENABLED, never by permissions. + See docs/decisions.md D3 and docs/armv8r-el2-reference.md. */ +#define ZX_AP_EL2_RW_GUEST_NONE 0x00U /* 00: RW at EL2 only */ +#define ZX_AP_EL2_RW_GUEST_RW 0x01U /* 01: RW at any level */ +#define ZX_AP_EL2_RO_GUEST_NONE 0x02U /* 10: RO at EL2 only */ +#define ZX_AP_EL2_RO_GUEST_RO 0x03U /* 11: RO at any level */ + +/* XN[0]. A partition needs at least one executable region or it cannot run, + which the validator treats as a build error rather than a runtime + mystery. */ +#define ZX_XN_EXECUTABLE 0x00U +#define ZX_XN_NEVER 0x01U + +/* SH[4:3]. 0b01 is UNPREDICTABLE for Normal memory (TRM Table 3-81), so a + zeroed field -- Non-shareable -- is both the safe default and the only + value Phase 0 uses; it runs one logical core. The other encodings are + named because a later multicore claim would need them, and a magic 3 in a + manifest would not survive review. */ +#define ZX_SH_NON_SHAREABLE 0x00U +#define ZX_SH_OUTER_SHAREABLE 0x02U +#define ZX_SH_INNER_SHAREABLE 0x03U + +/* AttrIndx[3:1] selects one of eight bytes across HMAIR0 and HMAIR1. The + count is the hardware's; which of them the hypervisor has actually WRITTEN + is a software fact, and a region naming an unwritten byte gets + Device-nGnRnE -- memory that works but is slow, with nothing to fault on. + The validator checks the index against a mask of written bytes for exactly + that reason. */ +#define ZX_ATTR_INDEX_COUNT 8U + +/**************************************************************************/ +/* Region descriptor */ +/**************************************************************************/ + +/* One stage-2 region, as a manifest declares it and as the port programs it. + The limit is INCLUSIVE, matching the hardware rather than converting at + every call site: PMSAv8-R has no size field, and a length that has to be + turned into an inclusive limit in three places is a length that gets it + wrong in one of them. + + WHY THE ATTRIBUTE FIELDS ARE UCHAR AND NOT AN ENUM. Each of them holds a + hardware encoding with two, four or eight legal values, so an enum reads + better and would let the compiler check the value. It was measured and + rejected: a four-value enum is 1 byte under arm-none-eabi-gcc (AAPCS short + enums), 4 bytes under ATfE clang, and 4 bytes on the host. ZoneX builds + with all three, so an enum field would give this struct -- the published + manifest contract, whose offsets are already asserted from assembly + elsewhere in the port -- a different layout in each lane. UCHAR is one + byte in all three. Legal-value checking therefore lives in the validator, + which is where it can also name the offender. */ + +typedef struct zx_region_struct +{ + zx_addr_t zx_region_base; /* first byte, granule aligned */ + zx_addr_t zx_region_limit; /* LAST byte, inclusive */ + UCHAR zx_region_ap; /* ZX_AP_* */ + UCHAR zx_region_xn; /* ZX_XN_* */ + UCHAR zx_region_sh; /* ZX_SH_* */ + UCHAR zx_region_attr_index; /* index into HMAIR0/HMAIR1 */ +} ZX_REGION; + +/**************************************************************************/ +/* Shared memory ranges */ +/**************************************************************************/ + +/* An explicitly declared, auditable exception to the no-overlap rule. + * + * WHY THIS TYPE EXISTS AT ALL. Two partitions' regions must not overlap: + * PMSAv8-R has no region priority, so two ENABLED regions matching one + * address is CONSTRAINED UNPREDICTABLE and aborts on the S32Z280. Phase 0 + * nevertheless shares one read-only granule, so that the demonstrator has a + * positive channel to show -- a heartbeat one partition publishes and the + * other reads -- rather than only proving isolation by what faults. + * + * WHY IT CANNOT BE ONE SHARED REGION. Stage-2 AP is a property of a region, + * not of a partition, so a single region covering the granule would have to + * be either writable by both readers or read-only to the publisher. The + * publisher therefore covers the granule with its own writable region and + * each reader with a read-only one. Those ranges coincide, which is safe + * only because one partition runs at a time and the two regions are never + * enabled together -- and that is precisely the kind of reasoning that must + * be written down rather than inferred. + * + * WHAT IT WEAKENS, AND WHAT IT DOES NOT. It does not weaken the claim that + * a partition cannot reach another partition's private memory: every byte + * outside a declared range is still governed by the unconditional overlap + * rule. It does mean the two partitions are not information-theoretically + * isolated -- the publisher can signal the reader, at one granule's + * bandwidth, and a reader cannot be prevented from timing those writes. A + * system that needs no covert channel at all declares no shared ranges, and + * the validator then enforces total disjointness with no exceptions. */ + +typedef struct zx_shared_struct +{ + zx_addr_t zx_shared_base; /* granule aligned */ + zx_addr_t zx_shared_limit; /* LAST byte, inclusive */ + UINT zx_shared_publisher_id; /* the one partition allowed RW */ +} ZX_SHARED; + +/**************************************************************************/ +/* Partition */ +/**************************************************************************/ + +typedef struct zx_partition_struct +{ + UINT zx_partition_id; + const CHAR *zx_partition_name; + + /* The guest image, embedded in the hypervisor's own image with .incbin + (docs/decisions.md D6). These are addresses in the hypervisor's + memory, NOT in the partition's -- the loader copies from here into the + partition's code region. */ + zx_addr_t zx_partition_image_start; + zx_addr_t zx_partition_image_end; + + /* Where the partition starts executing. Must lie inside one of its own + executable regions; a 4-byte error here cost a silicon run during the + Cortex-R52 Modules port work, so the validator checks it. */ + zx_addr_t zx_partition_entry; + + const ZX_REGION *zx_partition_regions; + UINT zx_partition_region_count; + + /* This partition's slot in the major frame. ULONG rather than a + fixed width deliberately: steps to come compare it against a guest + ThreadX's own tick count, which is a ULONG. */ + ULONG zx_partition_window_ticks; +} ZX_PARTITION; + +/**************************************************************************/ +/* The manifest */ +/**************************************************************************/ + +/* The whole system, in one object. + * + * WHERE IT LIVES. In the hypervisor's own .rodata, covered by no enabled + * stage-2 region. That is not a separate mechanism: ZoneX's own memory is + * protected by NOT being covered (docs/decisions.md D2), so a guest cannot + * read the manifest for the same reason it cannot read the hypervisor's + * code. Stated outright here because "the manifest is unreachable from a + * guest" is a property a safety reviewer should not have to derive. */ + +typedef struct zx_manifest_struct +{ + const ZX_PARTITION *zx_manifest_partitions; + UINT zx_manifest_partition_count; + + const ZX_SHARED *zx_manifest_shared; + UINT zx_manifest_shared_count; + + /* The sum of every partition's window. Carried explicitly rather than + computed, so that the validator can disagree with it: a major frame + that does not match its parts is a manifest error, and a computed + field could never be wrong. */ + ULONG zx_manifest_major_frame_ticks; +} ZX_MANIFEST; + +/**************************************************************************/ +/* Compile-time checks */ +/**************************************************************************/ + +/* UCHAR being one byte is what the region descriptor's layout rests on -- + see the note on that struct. */ +_Static_assert(sizeof(UCHAR) == 1U, + "UCHAR must be exactly one byte"); + +/* Every attribute field must be able to hold its whole encoding. Cheap, and + it is the check that would catch a future encoding widened past a byte. */ +_Static_assert(ZX_AP_EL2_RO_GUEST_RO <= 0xFFU, + "the AP encoding no longer fits the region descriptor's field"); +_Static_assert(ZX_ATTR_INDEX_COUNT <= 0xFFU, + "the AttrIndx range no longer fits the region descriptor's field"); + +/* The granule must be a power of two for the validator's alignment tests to + be a mask rather than a division. */ +_Static_assert((ZX_MPU_GRANULE & (ZX_MPU_GRANULE - 1U)) == 0U, + "ZX_MPU_GRANULE must be a power of two"); + +/* The four attribute fields sit in four consecutive bytes, with nothing + between them. Measured to hold in all three of ZoneX's build lanes + (arm-none-eabi-gcc, ATfE clang, host gcc), and asserted because it is the + property the UCHAR-not-enum decision above was made to guarantee: a field + that silently gained padding or a wider type would take this with it. */ +_Static_assert(offsetof(ZX_REGION, zx_region_attr_index) + == offsetof(ZX_REGION, zx_region_ap) + 3U, + "the region descriptor's attribute fields are no longer " + "four consecutive bytes"); + +/* On a 32-bit port -- every port ZoneX has today -- the descriptor is exactly + two addresses plus those four bytes, with no padding anywhere. Pinned to + the measured number so that a layout change in EITHER target toolchain + fails the build rather than producing two images that disagree about the + manifest. The host lane is excluded by the guard, not exempted: there + zx_addr_t is 64-bit and alignment legitimately makes the struct larger, + which is harmless because the host suite builds ZX_REGION objects rather + than reading a target's bytes. */ +#if UINTPTR_MAX == 0xFFFFFFFFU +_Static_assert(sizeof(ZX_REGION) == 12U, + "the region descriptor's size changed on a 32-bit port"); +#endif + +/* A manifest that declared more regions than the smallest supported part has + could not be programmed. This is the compile-time half of the rule; the + half that matters runs at boot against the real HMPUIR, because no header + knows which part it is being built for. */ +_Static_assert((ZX_MAX_PARTITIONS * ZX_MAX_REGIONS_PER_PARTITION) <= 24U, + "the manifest's capacity exceeds the largest Armv8-R EL2 " + "region count (24), so no part could program it"); + #ifdef __cplusplus } #endif diff --git a/examples/common/zx_probe_main.c b/examples/common/zx_probe_main.c index ab97928..fbc228c 100644 --- a/examples/common/zx_probe_main.c +++ b/examples/common/zx_probe_main.c @@ -80,7 +80,7 @@ static uint32_t zx_failures; probe and, when the deliberate EL2 fault is built in, a reprogramming of the same region with EL2 read-only permissions. */ -static zx_region_t zx_high_probe_region; +static ZX_REGION zx_high_probe_region; /**************************************************************************/ @@ -274,7 +274,7 @@ static uint32_t zx_identity_block(uint32_t regions_required) static void zx_program_regions(uint32_t board_regions) { - zx_region_t region; + ZX_REGION region; uint32_t direct_base = 0U; uint32_t direct_limit = 0U; uint32_t select_base = 0U; @@ -301,7 +301,7 @@ static void zx_program_regions(uint32_t board_regions) region.zx_region_ap = ZX_AP_EL2_RW_GUEST_RW; region.zx_region_xn = ZX_XN_EXECUTABLE; region.zx_region_sh = ZX_SH_NON_SHAREABLE; - region.zx_region_attrindx = ZX_ATTR_NORMAL_WB; + region.zx_region_attr_index = ZX_ATTR_NORMAL_WB; zx_stage2_region_program(ZX_REGION_PAYLOAD_CODE, ®ion); zx_console_puts(" region 0 payload code "); @@ -317,7 +317,7 @@ static void zx_program_regions(uint32_t board_regions) region.zx_region_ap = ZX_AP_EL2_RW_GUEST_RW; region.zx_region_xn = ZX_XN_NEVER; region.zx_region_sh = ZX_SH_NON_SHAREABLE; - region.zx_region_attrindx = ZX_ATTR_NORMAL_WB; + region.zx_region_attr_index = ZX_ATTR_NORMAL_WB; zx_stage2_region_program(ZX_REGION_PAYLOAD_DATA, ®ion); zx_console_puts(" region 1 payload data "); @@ -359,7 +359,7 @@ static void zx_program_regions(uint32_t board_regions) zx_high_probe_region.zx_region_ap = ZX_AP_EL2_RW_GUEST_RW; zx_high_probe_region.zx_region_xn = ZX_XN_NEVER; zx_high_probe_region.zx_region_sh = ZX_SH_NON_SHAREABLE; - zx_high_probe_region.zx_region_attrindx = ZX_ATTR_NORMAL_WB; + zx_high_probe_region.zx_region_attr_index = ZX_ATTR_NORMAL_WB; zx_stage2_region_program_direct16(&zx_high_probe_region); diff --git a/examples/s32z280_evb/zx_board.c b/examples/s32z280_evb/zx_board.c index d7bf006..b12a42e 100644 --- a/examples/s32z280_evb/zx_board.c +++ b/examples/s32z280_evb/zx_board.c @@ -35,7 +35,7 @@ /* TRM Table 8-4) and the console works anyway while EL2's caches are */ /* off, which they are in this image. So the Device region programmed */ /* below is not what makes the console work today; it is what makes it */ -/* keep working when step 5 turns caches on for the timing */ +/* keep working once caches are turned on for the timing */ /* measurements. Programming it now, and reporting that it was */ /* programmed, is what stops that from being discovered then. */ /* */ @@ -275,7 +275,7 @@ uint32_t zx_board_mmio_region_count(void) void zx_board_program_mmio_regions(uint32_t first_index) { - zx_region_t region; + ZX_REGION region; uint32_t console_index = first_index; uint32_t gic_index = first_index + 1U; @@ -287,7 +287,7 @@ void zx_board_program_mmio_regions(uint32_t first_index) region.zx_region_ap = ZX_AP_EL2_RW_GUEST_NONE; region.zx_region_xn = ZX_XN_NEVER; region.zx_region_sh = ZX_SH_NON_SHAREABLE; - region.zx_region_attrindx = ZX_ATTR_DEVICE; + region.zx_region_attr_index = ZX_ATTR_DEVICE; zx_stage2_region_program(console_index, ®ion); /* The GIC. Phase 0 configures no interrupts, and this region exists @@ -302,7 +302,7 @@ void zx_board_program_mmio_regions(uint32_t first_index) region.zx_region_ap = ZX_AP_EL2_RW_GUEST_NONE; region.zx_region_xn = ZX_XN_NEVER; region.zx_region_sh = ZX_SH_NON_SHAREABLE; - region.zx_region_attrindx = ZX_ATTR_DEVICE; + region.zx_region_attr_index = ZX_ATTR_DEVICE; zx_stage2_region_program(gic_index, ®ion); /* NOTHING IS PRINTED HERE, deliberately. This function runs BEFORE the diff --git a/platform/cortex_r52/inc/zx_port.h b/platform/cortex_r52/inc/zx_port.h index bb4a146..bb26662 100644 --- a/platform/cortex_r52/inc/zx_port.h +++ b/platform/cortex_r52/inc/zx_port.h @@ -134,7 +134,7 @@ /**************************************************************************/ /* Needed at EL2 because ZoneX must leave the EL1 MPU in a known state before - handing a payload the machine. Step 2 deliberately leaves SCTLR.M CLEAR: + handing a payload the machine. Stage-2 bring-up leaves SCTLR.M CLEAR: with stage 1 disabled the payload runs on the EL1 background map and the only thing under test is stage 2. One variable at a time. */ @@ -167,29 +167,16 @@ #define ZX_HPRLAR_EN ZX_BIT(0) #define ZX_HPRLAR_ATTRINDX_SHIFT 1U -/* AP[2:1] for the EL2-controlled MPU, TRM Table 3-82. Named rather than - numbered because there is NO encoding that grants a guest access while - denying EL2 -- a partition's memory is always reachable from the - hypervisor, and isolation between partitions comes from which regions are - ENABLED, never from these bits. See docs/decisions.md D3. */ +/* The AP, SH and XN ENCODINGS are not here. They live in core/inc/ + zx_manifest.h, with the region descriptor whose fields hold them, because + a manifest author needs them and the host-side validator needs them, and + neither can include a Cortex-R52 port header. Nothing in assembly uses + them, which is what makes the move free. -#define ZX_AP_EL2_RW_GUEST_NONE ZX_C32(0x0) -#define ZX_AP_EL2_RW_GUEST_RW ZX_C32(0x1) -#define ZX_AP_EL2_RO_GUEST_NONE ZX_C32(0x2) -#define ZX_AP_EL2_RO_GUEST_RO ZX_C32(0x3) - -/* SH[1:0]. 0b01 is UNPREDICTABLE for Normal memory (TRM Table 3-81), so a - zeroed field -- Non-shareable -- is the safe default and the only value - Phase 0 uses. */ - -#define ZX_SH_NON_SHAREABLE ZX_C32(0x0) -#define ZX_SH_OUTER_SHAREABLE ZX_C32(0x2) -#define ZX_SH_INNER_SHAREABLE ZX_C32(0x3) - -/* XN. 1 = execute never. */ - -#define ZX_XN_EXECUTABLE ZX_C32(0x0) -#define ZX_XN_NEVER ZX_C32(0x1) + What stays here is everything the encodings are shifted INTO -- the field + positions above -- plus the HMAIR byte layout below, which is this port's + choice of what each attribute index means rather than an architectural + fact. */ /**************************************************************************/ /* Memory attributes: HMAIR0/1 */ @@ -270,8 +257,8 @@ /* What zx_el2_run_payload returns. It is a real return: the trap handler restores the EL2 context that zx_el2_run_payload saved and resumes it, so a guest fault comes back to the hypervisor as a VALUE rather than as a jump - into a handler that has to decide policy with no context. Step 3 keeps - this shape; it is how a partition switch will be driven. */ + into a handler that has to decide policy with no context. This shape is + kept as partitions arrive; it is how a partition switch will be driven. */ #define ZX_RUN_YIELDED ZX_C32(0x0) /* payload returned, HVC #1 */ #define ZX_RUN_FAULTED ZX_C32(0x1) /* stage-2 fault, EC 0x24/0x20 */ @@ -335,30 +322,16 @@ #include "zx_console.h" #include "zx_fault.h" +/* The region descriptor (ZX_REGION) and the AP/SH/XN encodings it holds come + from here. The port programs what a manifest declares, so the descriptor + belongs to the manifest and not to this port: the host-side validator has + to build and check the same objects with no Cortex-R52 header in reach. */ +#include "zx_manifest.h" + #ifdef __cplusplus extern "C" { #endif -/**************************************************************************/ -/* Region descriptor */ -/**************************************************************************/ - -/* One stage-2 region, as the manifest will eventually describe it and as - zx_stage2_region_program writes it. The limit is INCLUSIVE, matching the - hardware rather than converting at every call site: PMSAv8-R has no size - field, and a length that has to be turned into an inclusive limit in three - places is a length that gets it wrong in one of them. */ - -typedef struct zx_region_struct -{ - zx_addr_t zx_region_base; /* first byte, granule aligned */ - zx_addr_t zx_region_limit; /* LAST byte, inclusive */ - uint32_t zx_region_ap; /* ZX_AP_* */ - uint32_t zx_region_xn; /* ZX_XN_* */ - uint32_t zx_region_sh; /* ZX_SH_* */ - uint32_t zx_region_attrindx; /* ZX_ATTR_* */ -} zx_region_t; - /**************************************************************************/ /* Identity and capability */ /**************************************************************************/ @@ -397,7 +370,7 @@ void zx_mair_program(void); Masks base and limit to the granule before ORing attributes in, which is the whole reason this is a function rather than two stores. */ -void zx_stage2_region_program(uint32_t index, const zx_region_t *region_ptr); +void zx_stage2_region_program(uint32_t index, const ZX_REGION *region_ptr); /* Read one region back through HPRSELR/HPRBAR/HPRLAR. Read-back is not paranoia here: a region programmed at an index the implementation does not @@ -412,11 +385,11 @@ void zx_stage2_region_read(uint32_t index, uint32_t *base_ptr, direct access stops at region 15 while 8.4 lists HPRBAR16-HPRBAR24 at opc1 = 5, and this function is how that contradiction is settled on the part rather than on paper. Region 16 specifically, because that is the - first index where the two readings differ. Step 3 generalises it once the - answer is known; guessing the generalisation first would be building on the - thing under test. */ + first index where the two readings differ. The generalisation follows once + the answer is known; guessing it first would be building on the thing + under test. */ -void zx_stage2_region_program_direct16(const zx_region_t *region_ptr); +void zx_stage2_region_program_direct16(const ZX_REGION *region_ptr); void zx_stage2_region_read_direct16(uint32_t *base_ptr, uint32_t *limit_ptr); /* HPRENR: one enable bit per region, and the register whose width the TRM diff --git a/platform/cortex_r52/src/zx_stage2_mpu.c b/platform/cortex_r52/src/zx_stage2_mpu.c index ae1d46c..5da4881 100644 --- a/platform/cortex_r52/src/zx_stage2_mpu.c +++ b/platform/cortex_r52/src/zx_stage2_mpu.c @@ -142,7 +142,7 @@ _Static_assert(ZX_ASM_FAULT_OFF_SPSR /* HPRBAR: BASE[31:6] | SH[4:3] | AP[2:1] | XN[0]. */ -static uint32_t zx_region_bar(const zx_region_t *region_ptr) +static uint32_t zx_region_bar(const ZX_REGION *region_ptr) { return (((uint32_t)region_ptr->zx_region_base & ZX_REGION_ADDR_MASK) | ((region_ptr->zx_region_sh & 0x3U) << ZX_HPRBAR_SH_SHIFT) @@ -159,10 +159,10 @@ static uint32_t zx_region_bar(const zx_region_t *region_ptr) reproduces the caller's inclusive limit exactly -- while keeping those bits away from AttrIndx, which is the trap this masking exists for. */ -static uint32_t zx_region_lar(const zx_region_t *region_ptr, uint32_t enable) +static uint32_t zx_region_lar(const ZX_REGION *region_ptr, uint32_t enable) { return (((uint32_t)region_ptr->zx_region_limit & ZX_REGION_ADDR_MASK) - | ((region_ptr->zx_region_attrindx & 0x7U) + | ((region_ptr->zx_region_attr_index & 0x7U) << ZX_HPRLAR_ATTRINDX_SHIFT) | ((enable != 0U) ? ZX_HPRLAR_EN : 0U)); } @@ -249,9 +249,9 @@ void zx_mair_program(void) /* overlapping another is CONSTRAINED UNPREDICTABLE and aborts. */ /**************************************************************************/ -void zx_stage2_region_program(uint32_t index, const zx_region_t *region_ptr) +void zx_stage2_region_program(uint32_t index, const ZX_REGION *region_ptr) { - if (region_ptr == (const zx_region_t *)0) + if (region_ptr == (const ZX_REGION *)0) { return; } @@ -305,15 +305,15 @@ void zx_stage2_region_read(uint32_t index, uint32_t *base_ptr, /* */ /* Only region 16 is implemented directly. Generalising to 17-24 before */ /* the encoding is known to work would be building on the thing under */ -/* test; that is step 3's job, once the answer is in. */ +/* test; that waits until the answer is in. */ /**************************************************************************/ -void zx_stage2_region_program_direct16(const zx_region_t *region_ptr) +void zx_stage2_region_program_direct16(const ZX_REGION *region_ptr) { uint32_t bar; uint32_t lar; - if (region_ptr == (const zx_region_t *)0) + if (region_ptr == (const ZX_REGION *)0) { return; } From 63812abda8d708c8e3786f4b262950753d554682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 2 Sep 2026 11:23:49 -0400 Subject: [PATCH 06/40] Recorded the manifest's type vocabulary and corrected D12 D19 records why the manifest uses the suite's own type names with zx_addr_t for addresses, including the measurement that settled the attribute fields: a four-value enum is 1 byte under arm-none-eabi-gcc and 4 under ATfE clang and host gcc, so an enum in a published struct would give each build lane a different layout. D12 is corrected rather than extended. It claimed ZoneX "is the reference implementation of the suite-wide C17/CMake plan rather than an exception to it." The plan makes no such claim -- it states it is independent of this roadmap and asks only that ZoneX start directly in C17 with the capability macros -- and ZoneX is in fact an exception on one point: the plan proposes extensions off in the CI preset and on in the default build, while ZoneX has them off in the default build. That is stricter and worth keeping, but it is a deviation, and the entry now says so. The claim mattered: it was load-bearing in an argument for spelling the manifest in terms before anyone checked it. CONTRIBUTING.md and the pull request template gain the new reference check, with a note on what a comment may cite. Assisted-by: Claude Code (Opus 5) --- .github/PULL_REQUEST_TEMPLATE.md | 2 + CONTRIBUTING.md | 5 ++- docs/decisions.md | 72 ++++++++++++++++++++++++++++++-- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index bade7f0..139b948 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -35,5 +35,7 @@ SPDX-License-Identifier: MIT and CC0-1.0 - [ ] Regression tests added or updated, or a stated reason why not. - [ ] Builds clean under the strict warning set (`--preset ci-strict`). - [ ] No A-profile or RISC-V terminology (`scripts/check_terminology.sh` passes). +- [ ] No local paths, and no comment citing a document that is not in the + repository (`scripts/check_references.sh` passes). - [ ] API or behaviour changes have a matching documentation pull request against `rtos-docs-asciidoc`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e5d2b2..b4540cf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -103,6 +103,7 @@ ZoneX follows the convention the other Eclipse ThreadX repositories use: `script | `scripts/build_s32z280.sh`, `scripts/test_s32z280.sh` | NXP S32Z280-594EVB | Cross-builds the same images for silicon. Running them needs the board. | | `scripts/install.sh` | — | Installs the build and test dependencies on Ubuntu. | | `scripts/check_terminology.sh` | — | Rejects register and concept names that belong to other architectures. See below. | +| `scripts/check_references.sh` | — | Rejects local absolute paths, citations of documents that are not in the repository, and a tracked agent-instruction file. | `CMakePresets.json` offers the same builds directly: `--preset default` for a warning-tolerant host build, `--preset ci-strict` for the host build with warnings as errors, `--preset coverage`, and `--preset fvp` / `--preset s32z280` for the cross builds. @@ -110,6 +111,8 @@ ZoneX follows the convention the other Eclipse ThreadX repositories use: `script **A note on terminology.** ZoneX targets Armv8-R AArch32, where both stages of address control are region-based MPUs. Register names from the AArch64 system-register set, the translation-table registers, and RISC-V memory-protection vocabulary are wrong here by construction, and code that uses one was written against the wrong architecture. `scripts/check_terminology.sh` checks this mechanically and runs in CI; [`docs/armv8r-el2-reference.md`](docs/armv8r-el2-reference.md) holds the verified names, encodings and field layouts. Read it before writing anything that touches a register. +**A note on what a comment may cite.** Write for someone who has only this repository. A comment that points at a local path, or at a numbered step of a document that is not here, tells that reader nothing and dates badly besides — and in a build script a local path means the script runs on one machine. Keep the fact and drop the citation. "A real guest comes later" survives both the reader and the schedule; a comment deferring to a numbered stage of an external plan survives neither. `scripts/check_references.sh` checks this mechanically and runs in CI. + **ZoneX is written to C17**, not C99 — it is the one component of the suite born on that baseline. Extensions are off and `-Wpedantic` is in force, so GNU-only constructs are rejected. Whatever you build, describe in your pull request how you verified your change. "It builds" is not verification. @@ -120,7 +123,7 @@ Four GitHub Actions workflows. **Every one of them triggers on `pull_request` ag | Workflow | What it checks | | -------- | -------------- | -| `host_tests.yml` | The host unit tests, built with warnings as errors, plus a coverage report. Runs `check_terminology.sh` as a separate job so its answer is unambiguous. | +| `host_tests.yml` | The host unit tests, built with warnings as errors, plus a coverage report. Runs `check_terminology.sh` and `check_references.sh` in a separate job so their answers are unambiguous. | | `gcc_check.yml` | Cross-builds every Cortex-R52 configuration — FVP, S32Z280, hard float — with the Arm GNU Toolchain and warnings as errors. Compiles and links; executes nothing. | | `clang_check.yml` | The same sources with Arm Toolchain for Embedded. GNU `as` accepts non-canonical assembly forms that LLVM's assembler rejects, and ZoneX is going to be substantially assembly. | | `zx_fvp.yml` | Builds the Cortex-R52 images and **executes** them on the Armv8-R AEM FVP, judging each by its self-reported result. There is no static check for "the partition still runs". | diff --git a/docs/decisions.md b/docs/decisions.md index 0686949..da2ab57 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -352,11 +352,17 @@ like the ThreadX one and does not mean the same thing. --- -## D12 — C17, and ZoneX as the reference implementation · **settled** +## D12 — C17 from the first commit · **settled; heading and one claim corrected 2 Sep 2026** -**ZoneX is built to C17 from its first commit, with `CMAKE_C_EXTENSIONS OFF`, -and it is the reference implementation of the suite-wide C17/CMake plan rather -than an exception to it.** +**ZoneX is built to C17 from its first commit, with `CMAKE_C_EXTENSIONS OFF`.** + +This entry originally said ZoneX "is the reference implementation of the +suite-wide C17/CMake plan rather than an exception to it." That overstated it +in both halves, and the claim was load-bearing in a later argument before +anyone checked it — see D19. The plan makes no such claim: it says it is +independent of the ZoneX roadmap, and asks only that ZoneX be started directly +in C17 with the capability macros. And ZoneX *is* an exception on one point, +recorded below. Every other component migrates to C17 from C99. ZoneX is the only one *born* there, which makes it the cheapest place to get the pattern right: the @@ -365,6 +371,13 @@ capability macro block, `cmake_minimum_required(VERSION 3.28...4.2)`, the HEADERS` are all in place from commit one. A later component copying the shape should copy ZoneX's. +**Where ZoneX deviates.** The plan proposes `CMAKE_C_EXTENSIONS OFF` in the CI +strict preset with extensions **ON** in the default build, so that ports +relying on compiler builtins can be fixed incrementally. ZoneX has it off in +the *default* build. That is stricter, it is worth keeping — a repository with +no legacy ports to migrate pays nothing for it — but it is a deviation and not +a reference implementation of that proposal. + Two details that must survive any later edit of `common/inc/zx_api.h`: * **There is no C17 branch in the capability macro block.** `[[nodiscard]]` is @@ -557,3 +570,54 @@ person needs, and a report that quietly picked one reading would hide it. `HPFAR` is also not updated at all for a fault taken from Hyp mode, so the report omits any reading of it there rather than presenting a stale value as an address. + +--- + +## D19 — What vocabulary the manifest is written in · **settled 2 Sep 2026, partly by measurement** + +**The suite's own type names, with `zx_addr_t` for addresses.** `UCHAR` for the +region attribute fields, `UINT` for identifiers and counts, `ULONG` for tick +counts, and ZoneX's own copy of the eight typedefs every ThreadX port carries. + +The manifest is the user-facing contract, so the cost of getting this wrong is +paid by every later port. Two arguments for spelling it in `` terms +were considered and both fail: + +* *"ZoneX cannot reach those names without linking ThreadX."* It does not need + to. They are eight plain typedefs, and copying them is not a dependency. + ZoneX keeps its own copy so that a manifest is readable with no ThreadX + checkout present — which is also what lets the host suite build without one. + The copy is deliberately **unguarded**: identical typedef redeclaration is + legal from C11, so including both headers is well-formed, and a port that + ever respelled one of these would then fail loudly at the include rather + than diverge quietly. +* *"ZoneX is the reference implementation of the C17 migration, so it should + set the precedent."* The suite-wide plan says the opposite — it is + independent of the ZoneX roadmap and asks only that ZoneX start at C17 with + the capability macros. It also settles the migration risk outright: it + changes no struct layout, no calling convention and no type name, and + regression-tests that a C99 application still compiles against the new + headers. Those names are load-bearing for that promise, and therefore + permanent. Adopting them costs nothing later. See the correction in D12. + +Addresses remain `zx_addr_t` (D5) and that is not an inconsistency: `ULONG` is +`unsigned long`, which is not pointer-width on every ABI the suite targets. + +**The attribute fields are `UCHAR` and not an enum, and that was measured.** An +enum reads better and would let the compiler check the value, so it was tried: + +| Toolchain | `sizeof` a four-value enum | +|---|---| +| `arm-none-eabi-gcc`, Cortex-R52 | **1 byte** (AAPCS short enums) | +| ATfE clang, `armv8r-none-eabi` | **4 bytes** | +| host gcc | **4 bytes** | + +ZoneX builds with all three, so an enum field would give the manifest a +different layout in each lane — for a published contract, in a codebase that +already asserts structure offsets from assembly. `UCHAR` is one byte in all +three. Legal-value checking therefore lives in the validator, which is also +where it can name the offender. The descriptor's layout is asserted rather +than assumed: the four attribute bytes consecutive in every lane, and the +whole descriptor pinned to 12 bytes on a 32-bit port, so that a layout change +in either target toolchain fails the build instead of producing two images +that disagree about the manifest. From 2721dcde928ce642e7e5e6b121d72dc81ef44cd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 2 Sep 2026 11:34:52 -0400 Subject: [PATCH 07/40] Added the manifest validator and its host test suite Almost nothing that can be wrong with a manifest faults, which is the reason this function exists. An under-aligned base does not abort: its low bits land on the SH, AP and XN fields and silently change the region's attributes. An unmasked limit lands on AttrIndx and selects an unwritten MAIR byte, which reads as Device-nGnRnE -- memory that works, slowly, and passes a careless test. Two enabled regions covering one address is CONSTRAINED UNPREDICTABLE. A wrong entry point by four bytes costs a hardware session to find. The defects worth catching are exactly the ones with no symptom, so they have to be caught before the regions are programmed rather than debugged after. One error code per rule, and the fault record carries TWO locations rather than one: the overlap rules have two offenders, and naming only one leaves the reader hunting for the other. Unused index fields hold an explicit sentinel, because zero is a valid partition and region index and a zeroed field would be indistinguishable from a real one. The validator is pure and takes the facts it cannot know -- the hypervisor's MMIO regions, which MAIR bytes were actually written, the region budget -- as an argument. That is what lets one function serve both callers: the host suite hands it values and gets coverage, the boot path hands it the real HMPUIR. The cross-partition overlap rule is waived only for a pair of regions that BOTH exactly cover the same declared shared range. Exact, because waiving it for anything merely touching a declared range would turn one published granule into a licence to overlap around it. The shared declaration and the regions are also checked against each other: a range declared read-only whose reader was given a writable region would be a two-way channel described as one-way, and both partitions would simply work. 150 checks. Every error code has a case, every case asserts the reported indices as well as the status, and the arithmetic has its own: adjacent regions that touch are legal and must not be rejected, a one-granule region is the smallest legal one, and ranges at the top of the address space must not wrap -- which is why the overlap test is two comparisons and computes nothing. 100% line and branch coverage on the validator. Two harness fixes came out of getting there: The coverage report was stale. gcov ACCUMULATES into .gcda files, and nothing deleted them, so a second run over the same build tree reported the union of both and coverage could only ever appear to rise. Measured: with one rule's tests disabled the report still said 100% and the new floor still passed. CI never saw it because every run is a fresh checkout -- the machine getting the wrong answer was the contributor's, asking whether their own change had dropped coverage. The counters are now deleted before the instrumented run. And the floor itself is enforced, scoped to the validator rather than set repository-wide: much of core/ is only true on hardware and the host suite cannot reach it, while a floor pinned to whatever the tree measures today would assert nothing about the code. Verified to fail, at 97.5% with a rule's tests removed, and to name the uncovered lines. Assisted-by: Claude Code (Opus 5) --- core/CMakeLists.txt | 1 + core/inc/zx_manifest.h | 131 +++++ core/src/zx_manifest_verify.c | 969 ++++++++++++++++++++++++++++++++++ test/host/CMakeLists.txt | 1 + test/host/run.sh | 45 ++ test/host/test_zx_manifest.c | 571 ++++++++++++++++++++ 6 files changed, 1718 insertions(+) create mode 100644 core/src/zx_manifest_verify.c create mode 100644 test/host/test_zx_manifest.c diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index 6768f0c..84c5230 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -22,6 +22,7 @@ target_sources(zonex_core PRIVATE ${CMAKE_CURRENT_LIST_DIR}/src/zx_fault.c + ${CMAKE_CURRENT_LIST_DIR}/src/zx_manifest_verify.c ${CMAKE_CURRENT_LIST_DIR}/src/zx_mm_setup.c ${CMAKE_CURRENT_LIST_DIR}/src/zx_partition_manager.c ${CMAKE_CURRENT_LIST_DIR}/src/zx_schedule.c diff --git a/core/inc/zx_manifest.h b/core/inc/zx_manifest.h index 759f52f..cd31838 100644 --- a/core/inc/zx_manifest.h +++ b/core/inc/zx_manifest.h @@ -276,6 +276,137 @@ _Static_assert((ZX_MAX_PARTITIONS * ZX_MAX_REGIONS_PER_PARTITION) <= 24U, "the manifest's capacity exceeds the largest Armv8-R EL2 " "region count (24), so no part could program it"); +/**************************************************************************/ +/* Validator error codes */ +/**************************************************************************/ + +/* One code per rule, and no code shared between two rules. That is what + makes a failure a diagnosis rather than a hint: "the manifest is invalid" + sends a reader back to the manifest, ZX_MANIFEST_LIMIT_UNALIGNED with a + partition and region index sends them to the line. + + The numbering is dense and stable. These reach a boot-time console + message and a host test's expected value, so renumbering them is a + breaking change to both. */ + +#define ZX_MANIFEST_SUCCESS 0x00U +#define ZX_MANIFEST_NULL_POINTER 0x01U +#define ZX_MANIFEST_NO_PARTITIONS 0x02U +#define ZX_MANIFEST_TOO_MANY_PARTITIONS 0x03U +#define ZX_MANIFEST_DUPLICATE_ID 0x04U +#define ZX_MANIFEST_NO_REGIONS 0x05U +#define ZX_MANIFEST_TOO_MANY_REGIONS 0x06U +#define ZX_MANIFEST_BASE_UNALIGNED 0x07U +#define ZX_MANIFEST_LIMIT_UNALIGNED 0x08U +#define ZX_MANIFEST_LIMIT_BELOW_BASE 0x09U +#define ZX_MANIFEST_BAD_AP 0x0AU +#define ZX_MANIFEST_BAD_XN 0x0BU +#define ZX_MANIFEST_BAD_SH 0x0CU +#define ZX_MANIFEST_ATTR_OUT_OF_RANGE 0x0DU +#define ZX_MANIFEST_ATTR_NOT_WRITTEN 0x0EU +#define ZX_MANIFEST_SELF_OVERLAP 0x0FU +#define ZX_MANIFEST_PARTITION_OVERLAP 0x10U +#define ZX_MANIFEST_MMIO_OVERLAP 0x11U +#define ZX_MANIFEST_NO_EXECUTABLE_REGION 0x12U +#define ZX_MANIFEST_NO_WRITABLE_REGION 0x13U +#define ZX_MANIFEST_ENTRY_NOT_EXECUTABLE 0x14U +#define ZX_MANIFEST_IMAGE_RANGE_INVALID 0x15U +#define ZX_MANIFEST_IMAGE_TOO_LARGE 0x16U +#define ZX_MANIFEST_ZERO_WINDOW 0x17U +#define ZX_MANIFEST_FRAME_MISMATCH 0x18U +#define ZX_MANIFEST_TOO_MANY_SHARED 0x19U +#define ZX_MANIFEST_SHARED_NOT_ONE_GRANULE 0x1AU +#define ZX_MANIFEST_SHARED_NO_PUBLISHER 0x1BU +#define ZX_MANIFEST_SHARED_BAD_AP 0x1CU +#define ZX_MANIFEST_REGION_BUDGET 0x1DU + +/**************************************************************************/ +/* What the validator saw */ +/**************************************************************************/ + +/* Where the offending declaration is. Two locations, not one, because the + overlap rules have two offenders and naming only one of them is half a + diagnosis -- a reader told "partition 1 region 2 overlaps something" still + has to find the something. + + Unused fields hold ZX_MANIFEST_NO_INDEX rather than zero. Zero is a valid + partition and region index, so a zeroed field is indistinguishable from a + real one, and that is exactly the sort of ambiguity a fault report must not + have. */ + +#define ZX_MANIFEST_NO_INDEX 0xFFFFFFFFU + +typedef struct zx_manifest_fault_struct +{ + UINT zx_fault_status; /* ZX_MANIFEST_* */ + UINT zx_fault_partition; /* or ZX_MANIFEST_NO_INDEX */ + UINT zx_fault_region; /* or ZX_MANIFEST_NO_INDEX */ + UINT zx_fault_other_partition; /* second offender, overlaps only */ + UINT zx_fault_other_region; /* second offender, overlaps only */ +} ZX_MANIFEST_FAULT; + +/**************************************************************************/ +/* Facts the manifest cannot carry */ +/**************************************************************************/ + +/* Everything the validator needs that is NOT a property of the manifest. + * + * Passed in rather than read from hardware, which is what lets one function + * serve both callers: the host suite hands it made-up values and gets + * coverage, and the boot path hands it the real HMPUIR and the real HMAIR it + * just programmed. A validator that read CP15 itself could only ever run on + * the target, and the rules worth testing exhaustively are the arithmetic + * ones. + * + * zx_env_attr_written_mask is a bitmask of AttrIndx values the hypervisor has + * actually written into HMAIR0/HMAIR1 -- bit n set means index n is + * programmed. It is here because "which indices exist" is architectural + * while "which ones mean anything" is a software fact, and a region naming an + * unwritten index gets Device-nGnRnE: memory that works, slowly, with nothing + * to fault on. That is the failure this mask exists to make impossible. */ + +typedef struct zx_manifest_env_struct +{ + const ZX_REGION *zx_env_mmio_regions; + UINT zx_env_mmio_region_count; + UINT zx_env_attr_written_mask; + UINT zx_env_region_budget; /* HMPUIR count on a target */ +} ZX_MANIFEST_ENV; + +/**************************************************************************/ +/* The validator */ +/**************************************************************************/ + +/* Checks a manifest against every rule and returns the FIRST failure. + * + * Pure: no hardware access, no static state, no allocation, and the same + * inputs always give the same answer. That is not stylistic -- it is what + * makes the function host-testable to a coverage floor, and what lets the + * boot path call it without ordering constraints. + * + * First failure rather than a list, because a manifest with two errors is + * fixed one error at a time anyway, and accumulating them would need storage + * the caller has to size. + * + * fault_ptr may be a null pointer if the caller wants only the status. */ + +ZX_NODISCARD UINT zx_manifest_verify(const ZX_MANIFEST *manifest_ptr, + const ZX_MANIFEST_ENV *env_ptr, + ZX_MANIFEST_FAULT *fault_ptr); + +/* True when two INCLUSIVE ranges share at least one byte. + * + * Exposed because the overlap rules are the ones most worth testing directly, + * and because it is the only piece of arithmetic here that has a wrapping + * trap in it: computing a length, or an exclusive end, from a range that + * reaches the top of the address space wraps to zero and makes a real overlap + * look like none. Written as two comparisons so nothing is computed at all. */ + +ZX_NODISCARD UINT zx_manifest_ranges_overlap(zx_addr_t first_base, + zx_addr_t first_limit, + zx_addr_t second_base, + zx_addr_t second_limit); + #ifdef __cplusplus } #endif diff --git a/core/src/zx_manifest_verify.c b/core/src/zx_manifest_verify.c new file mode 100644 index 0000000..b41ff2c --- /dev/null +++ b/core/src/zx_manifest_verify.c @@ -0,0 +1,969 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_manifest_verify.c PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* The partition manifest validator: one pure function that decides */ +/* whether a manifest describes a system the hardware can actually be */ +/* programmed to enforce. */ +/* */ +/* WHY A VALIDATOR AND NOT JUST CAREFUL MANIFESTS */ +/* */ +/* Almost none of what can be wrong with a manifest FAULTS. PMSAv8-R */ +/* has no region priority, so two enabled regions covering one address */ +/* is CONSTRAINED UNPREDICTABLE -- it aborts on the S32Z280 and might */ +/* not elsewhere. An under-aligned base does not fault at all: its */ +/* low bits land on the SH, AP and XN fields and silently change the */ +/* region's attributes. An unmasked limit lands on AttrIndx and */ +/* selects an unwritten MAIR byte, which reads as Device-nGnRnE -- */ +/* memory that works, slowly, and passes a careless test. A wrong */ +/* entry point by four bytes costs a hardware session to find. */ +/* */ +/* So the defects worth catching are exactly the ones that produce no */ +/* symptom. A validator is the only place they can be caught, and it */ +/* has to run before the regions are programmed rather than after. */ +/* */ +/* SHAPE */ +/* */ +/* Every condition is its own if with a single predicate. That costs */ +/* nothing at run time and it is what makes MC/DC coverage achievable */ +/* later, on a codebase whose certification back end is funded. There */ +/* are no compound predicates and no side effects inside a condition. */ +/* */ +/* There is no goto: project policy forbids it. The function returns */ +/* on first failure instead, which is also the behaviour the callers */ +/* want -- a manifest with two errors gets fixed one at a time. */ +/* */ +/**************************************************************************/ + +#include "zx_manifest.h" + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_manifest_fault_set PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Records where a failure was found, and returns its status so that */ +/* every rule below can be one line: return zx_manifest_fault_set(...) */ +/* */ +/* Tolerates a null fault pointer, because a caller that wants only */ +/* the status should not have to supply storage it will not read. */ +/* */ +/**************************************************************************/ + +static UINT zx_manifest_fault_set(ZX_MANIFEST_FAULT *fault_ptr, + UINT status, + UINT partition_index, + UINT region_index, + UINT other_partition_index, + UINT other_region_index) +{ + if (fault_ptr != (ZX_MANIFEST_FAULT *)0) + { + fault_ptr->zx_fault_status = status; + fault_ptr->zx_fault_partition = partition_index; + fault_ptr->zx_fault_region = region_index; + fault_ptr->zx_fault_other_partition = other_partition_index; + fault_ptr->zx_fault_other_region = other_region_index; + } + + return status; +} + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_manifest_ranges_overlap PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* True when two INCLUSIVE ranges share at least one byte. */ +/* */ +/* Two comparisons, and deliberately no arithmetic. The tempting */ +/* forms all compute something: a length (limit - base + 1), or an */ +/* exclusive end (limit + 1). Both wrap to zero for a range that */ +/* reaches the top of the address space, and a wrapped length makes a */ +/* real overlap look like none -- a hole in the isolation rules at the */ +/* one address where a test is least likely to look. Comparing the */ +/* endpoints directly cannot wrap because it never adds. */ +/* */ +/* Adjacency is NOT overlap: a.limit + 1 == b.base is two regions */ +/* that touch, which is legal and common -- it is how a partition's */ +/* code and data windows sit next to each other. With inclusive */ +/* endpoints that falls out of the comparison rather than needing a */ +/* special case: first_base <= second_limit is false exactly when the */ +/* ranges are disjoint, touching or not. */ +/* */ +/**************************************************************************/ + +UINT zx_manifest_ranges_overlap(zx_addr_t first_base, + zx_addr_t first_limit, + zx_addr_t second_base, + zx_addr_t second_limit) +{ + if (first_base > second_limit) + { + return 0U; + } + + if (second_base > first_limit) + { + return 0U; + } + + return 1U; +} + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_manifest_region_check PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Everything checkable about one region on its own: alignment, the */ +/* ordering of its endpoints, and whether its attribute fields hold */ +/* legal encodings that the hypervisor has actually programmed. */ +/* */ +/* The limit test is written as "all the low bits are SET" rather */ +/* than "limit + 1 is aligned". They are equivalent for every value */ +/* except the top of the address space, where limit + 1 wraps to zero */ +/* -- which happens to be aligned, so that form gets the right answer */ +/* by luck. Relying on luck in an alignment check that guards an */ +/* attribute-corrupting bug is not worth the two characters saved. */ +/* */ +/**************************************************************************/ + +static UINT zx_manifest_region_check(const ZX_REGION *region_ptr, + const ZX_MANIFEST_ENV *env_ptr, + ZX_MANIFEST_FAULT *fault_ptr, + UINT partition_index, + UINT region_index) +{ + if ((region_ptr->zx_region_base & (zx_addr_t)(ZX_MPU_GRANULE - 1U)) != 0U) + { + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_BASE_UNALIGNED, + partition_index, region_index, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + if ((region_ptr->zx_region_limit & (zx_addr_t)(ZX_MPU_GRANULE - 1U)) + != (zx_addr_t)(ZX_MPU_GRANULE - 1U)) + { + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_LIMIT_UNALIGNED, + partition_index, region_index, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + /* Checked after alignment on purpose. A region with base and limit + transposed is far more likely to be a typo than a deliberate empty + region, and reporting the alignment of a transposed pair first would + send the reader to the wrong half of the line. */ + if (region_ptr->zx_region_limit < region_ptr->zx_region_base) + { + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_LIMIT_BELOW_BASE, + partition_index, region_index, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + if (region_ptr->zx_region_ap > ZX_AP_EL2_RO_GUEST_RO) + { + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_BAD_AP, + partition_index, region_index, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + if (region_ptr->zx_region_xn > ZX_XN_NEVER) + { + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_BAD_XN, + partition_index, region_index, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + /* SH 0b01 is UNPREDICTABLE for Normal memory, so it is rejected here + rather than merely range-checked. A manifest asking for it is asking + for behaviour the architecture declines to define. */ + if (region_ptr->zx_region_sh == 0x01U) + { + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_BAD_SH, + partition_index, region_index, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + if (region_ptr->zx_region_sh > ZX_SH_INNER_SHAREABLE) + { + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_BAD_SH, + partition_index, region_index, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + if ((UINT)region_ptr->zx_region_attr_index >= ZX_ATTR_INDEX_COUNT) + { + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_ATTR_OUT_OF_RANGE, + partition_index, region_index, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + /* The check the hardware cannot make. An index inside the architectural + range but outside what the hypervisor programmed selects a zero MAIR + byte, which is Device-nGnRnE -- a plausible-looking attribute nobody + asked for, on memory that still works. */ + if ((env_ptr->zx_env_attr_written_mask + & (1U << (UINT)region_ptr->zx_region_attr_index)) == 0U) + { + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_ATTR_NOT_WRITTEN, + partition_index, region_index, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + return ZX_MANIFEST_SUCCESS; +} + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_manifest_shared_lookup PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Finds the declared shared range a region EXACTLY covers, or returns */ +/* ZX_MANIFEST_NO_INDEX. */ +/* */ +/* Exactly, not merely overlapping, and that is the whole point of the */ +/* function. The cross-partition overlap rule is waived only for */ +/* regions whose endpoints match a declared range, so a region that */ +/* reaches one byte past a shared granule into a neighbour's memory is */ +/* still an overlap and is still rejected. Waiving the rule for */ +/* anything that merely touched a declared range would turn one */ +/* published granule into a licence to overlap around it. */ +/* */ +/**************************************************************************/ + +static UINT zx_manifest_shared_lookup(const ZX_MANIFEST *manifest_ptr, + const ZX_REGION *region_ptr) +{ + UINT index; + + for (index = 0U; index < manifest_ptr->zx_manifest_shared_count; index++) + { + const ZX_SHARED *shared_ptr = &manifest_ptr->zx_manifest_shared[index]; + + if (region_ptr->zx_region_base != shared_ptr->zx_shared_base) + { + continue; + } + + if (region_ptr->zx_region_limit != shared_ptr->zx_shared_limit) + { + continue; + } + + return index; + } + + return ZX_MANIFEST_NO_INDEX; +} + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_manifest_shared_check PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Checks the declared shared ranges themselves, and then checks that */ +/* every region covering one carries the permission its owner should */ +/* have: the publisher writable, everybody else read-only. */ +/* */ +/* That second half is the part with teeth. A shared range declared */ +/* read-only whose reader was given a writable region would be a */ +/* two-way channel described in the manifest as a one-way one, and */ +/* nothing at run time would say so -- both partitions would simply */ +/* work. The declaration and the regions have to be checked against */ +/* each other or the declaration is decoration. */ +/* */ +/**************************************************************************/ + +static UINT zx_manifest_shared_check(const ZX_MANIFEST *manifest_ptr, + ZX_MANIFEST_FAULT *fault_ptr) +{ + UINT shared_index; + + if (manifest_ptr->zx_manifest_shared_count > ZX_MAX_SHARED_RANGES) + { + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_TOO_MANY_SHARED, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + for (shared_index = 0U; + shared_index < manifest_ptr->zx_manifest_shared_count; + shared_index++) + { + const ZX_SHARED *shared_ptr = + &manifest_ptr->zx_manifest_shared[shared_index]; + UINT publisher_found = 0U; + UINT partition_index; + + /* Exactly one granule in Phase 0. Not an arbitrary restriction: the + isolation claim is qualified by how much memory is shared, and a + bound the validator enforces is worth more to a reviewer than a + sentence saying the demonstrator only shares a little. Relaxing + it later is a change to this one condition, in the open. */ + if ((shared_ptr->zx_shared_base + & (zx_addr_t)(ZX_MPU_GRANULE - 1U)) != 0U) + { + return zx_manifest_fault_set(fault_ptr, + ZX_MANIFEST_SHARED_NOT_ONE_GRANULE, + ZX_MANIFEST_NO_INDEX, shared_index, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + if (shared_ptr->zx_shared_limit + != (shared_ptr->zx_shared_base + + (zx_addr_t)(ZX_MPU_GRANULE - 1U))) + { + return zx_manifest_fault_set(fault_ptr, + ZX_MANIFEST_SHARED_NOT_ONE_GRANULE, + ZX_MANIFEST_NO_INDEX, shared_index, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + /* Every region covering the range must match its owner's role. */ + for (partition_index = 0U; + partition_index < manifest_ptr->zx_manifest_partition_count; + partition_index++) + { + const ZX_PARTITION *partition_ptr = + &manifest_ptr->zx_manifest_partitions[partition_index]; + UINT region_index; + + for (region_index = 0U; + region_index < partition_ptr->zx_partition_region_count; + region_index++) + { + const ZX_REGION *region_ptr = + &partition_ptr->zx_partition_regions[region_index]; + + if (zx_manifest_shared_lookup(manifest_ptr, region_ptr) + != shared_index) + { + continue; + } + + if (partition_ptr->zx_partition_id + == shared_ptr->zx_shared_publisher_id) + { + publisher_found = 1U; + + if (region_ptr->zx_region_ap != ZX_AP_EL2_RW_GUEST_RW) + { + return zx_manifest_fault_set( + fault_ptr, ZX_MANIFEST_SHARED_BAD_AP, + partition_index, region_index, + ZX_MANIFEST_NO_INDEX, shared_index); + } + } + else + { + if (region_ptr->zx_region_ap != ZX_AP_EL2_RO_GUEST_RO) + { + return zx_manifest_fault_set( + fault_ptr, ZX_MANIFEST_SHARED_BAD_AP, + partition_index, region_index, + ZX_MANIFEST_NO_INDEX, shared_index); + } + } + } + } + + /* A range nobody publishes is memory two partitions can read and + nothing writes. Harmless, and almost certainly a mistake in the + publisher id, which is worth saying rather than tolerating. */ + if (publisher_found == 0U) + { + return zx_manifest_fault_set(fault_ptr, + ZX_MANIFEST_SHARED_NO_PUBLISHER, + ZX_MANIFEST_NO_INDEX, shared_index, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + } + + return ZX_MANIFEST_SUCCESS; +} + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_manifest_partition_check PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* One partition on its own: its regions individually, its regions */ +/* against each other and against the hypervisor's MMIO, and the */ +/* properties that make it a partition that can actually run. */ +/* */ +/* "Can actually run" is three separate rules, and each of them was a */ +/* real bug somewhere before it was a rule here. A partition with no */ +/* executable region cannot start. One with no writable region cannot */ +/* keep a stack. One whose entry point is outside its executable */ +/* regions faults on its first instruction, and the four-byte version */ +/* of that mistake is invisible in a manifest and costs a hardware */ +/* session to find. None of the three is a runtime mystery worth */ +/* having when they are all decidable here. */ +/* */ +/**************************************************************************/ + +static UINT zx_manifest_partition_check(const ZX_MANIFEST *manifest_ptr, + const ZX_MANIFEST_ENV *env_ptr, + ZX_MANIFEST_FAULT *fault_ptr, + UINT partition_index) +{ + const ZX_PARTITION *partition_ptr = + &manifest_ptr->zx_manifest_partitions[partition_index]; + UINT executable_regions = 0U; + UINT writable_regions = 0U; + UINT entry_is_executable = 0U; + UINT image_fits = 0U; + UINT region_index; + UINT status; + + if (partition_ptr->zx_partition_regions == (const ZX_REGION *)0) + { + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_NULL_POINTER, + partition_index, ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + if (partition_ptr->zx_partition_region_count == 0U) + { + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_NO_REGIONS, + partition_index, ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + if (partition_ptr->zx_partition_region_count + > ZX_MAX_REGIONS_PER_PARTITION) + { + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_TOO_MANY_REGIONS, + partition_index, ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + if (partition_ptr->zx_partition_image_end + < partition_ptr->zx_partition_image_start) + { + return zx_manifest_fault_set(fault_ptr, + ZX_MANIFEST_IMAGE_RANGE_INVALID, + partition_index, ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + if (partition_ptr->zx_partition_window_ticks == 0U) + { + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_ZERO_WINDOW, + partition_index, ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + for (region_index = 0U; + region_index < partition_ptr->zx_partition_region_count; + region_index++) + { + const ZX_REGION *region_ptr = + &partition_ptr->zx_partition_regions[region_index]; + UINT other_index; + UINT mmio_index; + + status = zx_manifest_region_check(region_ptr, env_ptr, fault_ptr, + partition_index, region_index); + + if (status != ZX_MANIFEST_SUCCESS) + { + return status; + } + + /* Against this partition's own other regions. Only the pairs above + this index, so each pair is tested once and the two indices in the + report are always in ascending order. */ + for (other_index = region_index + 1U; + other_index < partition_ptr->zx_partition_region_count; + other_index++) + { + const ZX_REGION *other_ptr = + &partition_ptr->zx_partition_regions[other_index]; + + if (zx_manifest_ranges_overlap(region_ptr->zx_region_base, + region_ptr->zx_region_limit, + other_ptr->zx_region_base, + other_ptr->zx_region_limit) != 0U) + { + return zx_manifest_fault_set(fault_ptr, + ZX_MANIFEST_SELF_OVERLAP, + partition_index, region_index, + partition_index, other_index); + } + } + + /* Against the hypervisor's own MMIO. These regions stay enabled + while a partition runs -- they are the console and the interrupt + controller -- so a partition region overlapping one is two enabled + regions on one address, which is the CONSTRAINED UNPREDICTABLE + case and not a permissions question. */ + for (mmio_index = 0U; + mmio_index < env_ptr->zx_env_mmio_region_count; + mmio_index++) + { + const ZX_REGION *mmio_ptr = + &env_ptr->zx_env_mmio_regions[mmio_index]; + + if (zx_manifest_ranges_overlap(region_ptr->zx_region_base, + region_ptr->zx_region_limit, + mmio_ptr->zx_region_base, + mmio_ptr->zx_region_limit) != 0U) + { + return zx_manifest_fault_set(fault_ptr, + ZX_MANIFEST_MMIO_OVERLAP, + partition_index, region_index, + ZX_MANIFEST_NO_INDEX, + mmio_index); + } + } + + if (region_ptr->zx_region_xn == ZX_XN_EXECUTABLE) + { + executable_regions++; + + if (partition_ptr->zx_partition_entry + >= region_ptr->zx_region_base) + { + if (partition_ptr->zx_partition_entry + <= region_ptr->zx_region_limit) + { + entry_is_executable = 1U; + } + } + + /* The image is copied into a code region, so it has to fit one. + Both subtractions are safe without a guard: image_end >= start + was established before this loop, and limit >= base by the + per-region check just above. No guard is added for them on + purpose -- a condition that cannot be false is dead code, and + dead code in a function with a coverage floor is a branch + nobody can ever cover. */ + { + zx_addr_t image_size = partition_ptr->zx_partition_image_end + - partition_ptr->zx_partition_image_start; + zx_addr_t region_size = region_ptr->zx_region_limit + - region_ptr->zx_region_base; + + if (image_size <= region_size) + { + image_fits = 1U; + } + } + } + + if (region_ptr->zx_region_ap == ZX_AP_EL2_RW_GUEST_RW) + { + writable_regions++; + } + } + + if (executable_regions == 0U) + { + return zx_manifest_fault_set(fault_ptr, + ZX_MANIFEST_NO_EXECUTABLE_REGION, + partition_index, ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + if (writable_regions == 0U) + { + return zx_manifest_fault_set(fault_ptr, + ZX_MANIFEST_NO_WRITABLE_REGION, + partition_index, ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + if (entry_is_executable == 0U) + { + return zx_manifest_fault_set(fault_ptr, + ZX_MANIFEST_ENTRY_NOT_EXECUTABLE, + partition_index, ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + if (image_fits == 0U) + { + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_IMAGE_TOO_LARGE, + partition_index, ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + return ZX_MANIFEST_SUCCESS; +} + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_manifest_cross_check PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Regions of DIFFERENT partitions against each other. */ +/* */ +/* Two partitions' regions are never enabled at the same time, so this */ +/* rule is not about CONSTRAINED UNPREDICTABLE the way the others are. */ +/* It is the isolation claim itself: if two partitions' regions cover */ +/* one address then whichever partition is running can reach the */ +/* other's memory, and the fact that only one region set is enabled at */ +/* a time is exactly what makes that reachable rather than aborting. */ +/* This is the rule a reviewer is really asking about. */ +/* */ +/* Waived only for a pair that BOTH exactly cover the same declared */ +/* shared range. A region matching a declared range while its partner */ +/* merely overlaps it is still a violation -- see */ +/* zx_manifest_shared_lookup. */ +/* */ +/**************************************************************************/ + +static UINT zx_manifest_cross_check(const ZX_MANIFEST *manifest_ptr, + ZX_MANIFEST_FAULT *fault_ptr) +{ + UINT first_partition; + + for (first_partition = 0U; + first_partition < manifest_ptr->zx_manifest_partition_count; + first_partition++) + { + const ZX_PARTITION *first_ptr = + &manifest_ptr->zx_manifest_partitions[first_partition]; + UINT second_partition; + + for (second_partition = first_partition + 1U; + second_partition < manifest_ptr->zx_manifest_partition_count; + second_partition++) + { + const ZX_PARTITION *second_ptr = + &manifest_ptr->zx_manifest_partitions[second_partition]; + UINT first_region; + + for (first_region = 0U; + first_region < first_ptr->zx_partition_region_count; + first_region++) + { + const ZX_REGION *first_region_ptr = + &first_ptr->zx_partition_regions[first_region]; + UINT first_shared = + zx_manifest_shared_lookup(manifest_ptr, first_region_ptr); + UINT second_region; + + for (second_region = 0U; + second_region < second_ptr->zx_partition_region_count; + second_region++) + { + const ZX_REGION *second_region_ptr = + &second_ptr->zx_partition_regions[second_region]; + UINT second_shared; + + if (zx_manifest_ranges_overlap( + first_region_ptr->zx_region_base, + first_region_ptr->zx_region_limit, + second_region_ptr->zx_region_base, + second_region_ptr->zx_region_limit) == 0U) + { + continue; + } + + second_shared = zx_manifest_shared_lookup( + manifest_ptr, second_region_ptr); + + if (first_shared == ZX_MANIFEST_NO_INDEX) + { + return zx_manifest_fault_set( + fault_ptr, ZX_MANIFEST_PARTITION_OVERLAP, + first_partition, first_region, + second_partition, second_region); + } + + if (first_shared != second_shared) + { + return zx_manifest_fault_set( + fault_ptr, ZX_MANIFEST_PARTITION_OVERLAP, + first_partition, first_region, + second_partition, second_region); + } + } + } + } + } + + return ZX_MANIFEST_SUCCESS; +} + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_manifest_verify PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Checks a manifest against every rule and returns the first failure. */ +/* */ +/* The order the rules run in is chosen so that the FIRST message a */ +/* reader gets is the most useful one. Structural checks come before */ +/* content checks, because a null pointer reported as an alignment */ +/* error sends the reader to the wrong place entirely. Per-partition */ +/* checks come before cross-partition ones, because an overlap between */ +/* two partitions is much harder to read than an unaligned base in one */ +/* of them, and fixing the alignment sometimes removes the overlap. */ +/* The region budget is checked last: it is the only rule that depends */ +/* on the hardware, and reporting "this needs more regions than the */ +/* part has" about a manifest that is also malformed is misleading. */ +/* */ +/**************************************************************************/ + +UINT zx_manifest_verify(const ZX_MANIFEST *manifest_ptr, + const ZX_MANIFEST_ENV *env_ptr, + ZX_MANIFEST_FAULT *fault_ptr) +{ + UINT regions_needed; + UINT partition_index; + UINT status; + + if (manifest_ptr == (const ZX_MANIFEST *)0) + { + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_NULL_POINTER, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + if (env_ptr == (const ZX_MANIFEST_ENV *)0) + { + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_NULL_POINTER, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + if (manifest_ptr->zx_manifest_partitions == (const ZX_PARTITION *)0) + { + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_NULL_POINTER, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + /* A shared-range array is optional; a count without one is not. */ + if (manifest_ptr->zx_manifest_shared_count != 0U) + { + if (manifest_ptr->zx_manifest_shared == (const ZX_SHARED *)0) + { + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_NULL_POINTER, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + } + + /* Likewise for the hypervisor's MMIO list: zero regions is the FVP's + honest answer, a count with no array is a caller bug. */ + if (env_ptr->zx_env_mmio_region_count != 0U) + { + if (env_ptr->zx_env_mmio_regions == (const ZX_REGION *)0) + { + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_NULL_POINTER, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + } + + if (manifest_ptr->zx_manifest_partition_count == 0U) + { + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_NO_PARTITIONS, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + if (manifest_ptr->zx_manifest_partition_count > ZX_MAX_PARTITIONS) + { + return zx_manifest_fault_set(fault_ptr, + ZX_MANIFEST_TOO_MANY_PARTITIONS, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + /* Identifiers must be unique, because everything else keys off them -- + the shared ranges name a publisher by id, and a scheduler will name a + partition by id. Two partitions sharing one id makes both of those + silently pick whichever comes first. */ + for (partition_index = 0U; + partition_index < manifest_ptr->zx_manifest_partition_count; + partition_index++) + { + UINT other_index; + + for (other_index = partition_index + 1U; + other_index < manifest_ptr->zx_manifest_partition_count; + other_index++) + { + if (manifest_ptr->zx_manifest_partitions[partition_index] + .zx_partition_id + == manifest_ptr->zx_manifest_partitions[other_index] + .zx_partition_id) + { + return zx_manifest_fault_set(fault_ptr, + ZX_MANIFEST_DUPLICATE_ID, + partition_index, + ZX_MANIFEST_NO_INDEX, + other_index, + ZX_MANIFEST_NO_INDEX); + } + } + } + + for (partition_index = 0U; + partition_index < manifest_ptr->zx_manifest_partition_count; + partition_index++) + { + status = zx_manifest_partition_check(manifest_ptr, env_ptr, fault_ptr, + partition_index); + + if (status != ZX_MANIFEST_SUCCESS) + { + return status; + } + } + + status = zx_manifest_shared_check(manifest_ptr, fault_ptr); + + if (status != ZX_MANIFEST_SUCCESS) + { + return status; + } + + status = zx_manifest_cross_check(manifest_ptr, fault_ptr); + + if (status != ZX_MANIFEST_SUCCESS) + { + return status; + } + + /* The major frame is the sum of the windows. Carried in the manifest + AND checked, rather than computed, so that a manifest whose frame + disagrees with its parts is an error the author gets told about + instead of a number that quietly follows whatever the windows say. */ + { + ULONG frame_total = 0U; + + for (partition_index = 0U; + partition_index < manifest_ptr->zx_manifest_partition_count; + partition_index++) + { + frame_total += manifest_ptr->zx_manifest_partitions[partition_index] + .zx_partition_window_ticks; + } + + if (frame_total != manifest_ptr->zx_manifest_major_frame_ticks) + { + return zx_manifest_fault_set(fault_ptr, + ZX_MANIFEST_FRAME_MISMATCH, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + } + + /* Last, and the only rule that depends on the part. The model reports + 32 EL2 regions, which is not an architecturally legal Cortex-R52 + value, so a manifest that fits the model proves nothing about any real + device -- which is exactly why this is checked against the register + rather than against a constant. */ + regions_needed = env_ptr->zx_env_mmio_region_count; + + for (partition_index = 0U; + partition_index < manifest_ptr->zx_manifest_partition_count; + partition_index++) + { + regions_needed += manifest_ptr->zx_manifest_partitions[partition_index] + .zx_partition_region_count; + } + + if (regions_needed > env_ptr->zx_env_region_budget) + { + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_REGION_BUDGET, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_SUCCESS, + ZX_MANIFEST_NO_INDEX, ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); +} diff --git a/test/host/CMakeLists.txt b/test/host/CMakeLists.txt index 8edbcd8..a92a09a 100644 --- a/test/host/CMakeLists.txt +++ b/test/host/CMakeLists.txt @@ -51,3 +51,4 @@ endfunction() # to LINK, which is a confusing way to discover that a test needs a console. zx_add_host_test(test_zx_api) zx_add_host_test(test_zx_fault) +zx_add_host_test(test_zx_manifest) diff --git a/test/host/run.sh b/test/host/run.sh index 9b25e21..852b755 100755 --- a/test/host/run.sh +++ b/test/host/run.sh @@ -61,6 +61,22 @@ case "${command}" in coverage) configure cmake --build "${BUILD}" + + # DELETE THE COUNTERS FIRST, or the report is a lie. + # + # gcov ACCUMULATES: a .gcda file left from an earlier run is added to + # by the next one, never replaced. So a second coverage run over the + # same build tree reports the union of both, and coverage can only + # ever appear to go up. A line whose only test was just deleted still + # reads as covered, and the floor below cannot fail. + # + # This was measured, not feared: disabling one rule's tests and + # re-running still reported 100%, and the floor passed. CI never sees + # it because every run starts from a fresh checkout, which is exactly + # why it survives -- the machine that gets the wrong answer is the + # contributor's, checking whether their own change dropped coverage. + find "${BUILD}" -name '*.gcda' -delete + ctest --test-dir "${BUILD}" --output-on-failure # Rooted at the repository so the report names files the way the # repository does. gcovr writes its intermediate .gcov files into the @@ -75,6 +91,35 @@ case "${command}" in --html-details "${ROOT}/coverage_report/index.html" \ --xml "${ROOT}/coverage_report/coverage.xml" \ --print-summary + + # THE FLOOR, and it is deliberately narrow. + # + # A repository-wide threshold cannot work here: much of core/ is only + # true on the model or on silicon and the host suite cannot reach it, + # which is why ZoneX does not hold the suite's usual figure over the + # whole tree (docs/decisions.md D11). A floor set to whatever the + # tree happens to measure today would mean nothing either -- it says + # "no worse than when somebody set it" and not one thing about the + # code. + # + # So the floor is set where a real number is defensible: the manifest + # validator, which is a pure function with no hardware in it and no + # excuse for an unreached line. 100% is not aspirational -- it is + # what it measures, on lines and on branches both. A rule added + # without a case that fails it will drop this and fail the build, + # which is the entire point: a validator rule nothing has ever seen + # reject anything is not a rule, it is a comment. + # + # Reported separately from the report above so that the number being + # enforced is visible next to the failure, rather than the build + # dying with a threshold the reader has to go and look up. + echo "" + echo "Enforcing the coverage floor on the manifest validator:" + gcovr --root "${ROOT}" \ + --filter "${ROOT}/core/src/zx_manifest_verify.c" \ + --txt - \ + --fail-under-line 100 \ + --fail-under-branch 100 ;; *) echo "Usage: $0 [build|test|coverage]" >&2 diff --git a/test/host/test_zx_manifest.c b/test/host/test_zx_manifest.c new file mode 100644 index 0000000..f611f3f --- /dev/null +++ b/test/host/test_zx_manifest.c @@ -0,0 +1,571 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* test_zx_manifest.c HOST TESTS */ +/* */ +/* DESCRIPTION */ +/* */ +/* The manifest validator, rule by rule. */ +/* */ +/* Shape: one known-good manifest, rebuilt from scratch before every */ +/* case, and each case breaks exactly one thing. That is what makes a */ +/* failure informative -- if a case fails, the rule it names is the */ +/* rule that changed, because nothing else about the manifest did. */ +/* */ +/* Every case asserts the reported INDICES as well as the status. A */ +/* validator that returns the right code about the wrong region is */ +/* worse than one that returns nothing, because it sends the reader to */ +/* a line that is fine. */ +/* */ +/* The arithmetic cases at the end matter as much as the rule cases. */ +/* Adjacent regions that touch are legal and must not be rejected; a */ +/* region ending at the top of the address space must not wrap; a */ +/* region of exactly one granule is the smallest legal one and is the */ +/* shape every shared range takes. Those are the edges where an */ +/* overlap test written with a subtraction gets the wrong answer. */ +/* */ +/**************************************************************************/ + +#include "zx_manifest.h" +#include "zx_test.h" + +/* File scope, not inside ZX_TEST_MAIN. A braced initialiser in the macro + body is read as one argument per element -- see the note in zx_test.h. */ + +#define P0_ID 1U +#define P1_ID 2U + +#define P0_CODE_BASE 0x00100000U +#define P0_CODE_LIMIT 0x0010FFFFU +#define P0_DATA_BASE 0x00200000U +#define P0_DATA_LIMIT 0x0020FFFFU +#define P1_CODE_BASE 0x00400000U +#define P1_CODE_LIMIT 0x0040FFFFU +#define P1_DATA_BASE 0x00500000U +#define P1_DATA_LIMIT 0x0050FFFFU + +#define SHARED_BASE 0x00300000U +#define SHARED_LIMIT 0x0030003FU + +#define MMIO_BASE 0x42980000U +#define MMIO_LIMIT 0x4298003FU + +#define IMAGE_START 0x80000000U +#define IMAGE_END 0x80001000U + +#define ATTR_NORMAL 0U +#define ATTR_DEVICE 1U +#define ATTR_UNWRITTEN 5U + +static ZX_REGION p0_regions[ZX_MAX_REGIONS_PER_PARTITION]; +static ZX_REGION p1_regions[ZX_MAX_REGIONS_PER_PARTITION]; +static ZX_REGION mmio_regions[1]; +static ZX_SHARED shared_ranges[ZX_MAX_SHARED_RANGES]; +static ZX_PARTITION partitions[ZX_MAX_PARTITIONS]; +static ZX_MANIFEST manifest; +static ZX_MANIFEST_ENV env; +static ZX_MANIFEST_FAULT fault; + +static void set_region(ZX_REGION *region_ptr, zx_addr_t base, zx_addr_t limit, + UCHAR ap, UCHAR xn, UCHAR attr) +{ + region_ptr->zx_region_base = base; + region_ptr->zx_region_limit = limit; + region_ptr->zx_region_ap = ap; + region_ptr->zx_region_xn = xn; + region_ptr->zx_region_sh = ZX_SH_NON_SHAREABLE; + region_ptr->zx_region_attr_index = attr; +} + +/* The known-good system: two partitions, each with its own code and data, + plus one read-only granule published by partition 0. Rebuilt in full + before every case so no case can leak into the next. */ +static void reset_manifest(void) +{ + set_region(&p0_regions[0], P0_CODE_BASE, P0_CODE_LIMIT, + ZX_AP_EL2_RW_GUEST_RW, ZX_XN_EXECUTABLE, ATTR_NORMAL); + set_region(&p0_regions[1], P0_DATA_BASE, P0_DATA_LIMIT, + ZX_AP_EL2_RW_GUEST_RW, ZX_XN_NEVER, ATTR_NORMAL); + set_region(&p0_regions[2], SHARED_BASE, SHARED_LIMIT, + ZX_AP_EL2_RW_GUEST_RW, ZX_XN_NEVER, ATTR_NORMAL); + + set_region(&p1_regions[0], P1_CODE_BASE, P1_CODE_LIMIT, + ZX_AP_EL2_RW_GUEST_RW, ZX_XN_EXECUTABLE, ATTR_NORMAL); + set_region(&p1_regions[1], P1_DATA_BASE, P1_DATA_LIMIT, + ZX_AP_EL2_RW_GUEST_RW, ZX_XN_NEVER, ATTR_NORMAL); + set_region(&p1_regions[2], SHARED_BASE, SHARED_LIMIT, + ZX_AP_EL2_RO_GUEST_RO, ZX_XN_NEVER, ATTR_NORMAL); + + set_region(&mmio_regions[0], MMIO_BASE, MMIO_LIMIT, + ZX_AP_EL2_RW_GUEST_NONE, ZX_XN_NEVER, ATTR_DEVICE); + + shared_ranges[0].zx_shared_base = SHARED_BASE; + shared_ranges[0].zx_shared_limit = SHARED_LIMIT; + shared_ranges[0].zx_shared_publisher_id = P0_ID; + + partitions[0].zx_partition_id = P0_ID; + partitions[0].zx_partition_name = "critical"; + partitions[0].zx_partition_image_start = IMAGE_START; + partitions[0].zx_partition_image_end = IMAGE_END; + partitions[0].zx_partition_entry = P0_CODE_BASE; + partitions[0].zx_partition_regions = p0_regions; + partitions[0].zx_partition_region_count = 3U; + partitions[0].zx_partition_window_ticks = 7U; + + partitions[1].zx_partition_id = P1_ID; + partitions[1].zx_partition_name = "best-effort"; + partitions[1].zx_partition_image_start = IMAGE_START; + partitions[1].zx_partition_image_end = IMAGE_END; + partitions[1].zx_partition_entry = P1_CODE_BASE; + partitions[1].zx_partition_regions = p1_regions; + partitions[1].zx_partition_region_count = 3U; + partitions[1].zx_partition_window_ticks = 3U; + + manifest.zx_manifest_partitions = partitions; + manifest.zx_manifest_partition_count = 2U; + manifest.zx_manifest_shared = shared_ranges; + manifest.zx_manifest_shared_count = 1U; + manifest.zx_manifest_major_frame_ticks = 10U; + + env.zx_env_mmio_regions = mmio_regions; + env.zx_env_mmio_region_count = 1U; + /* Indices 0, 1 and 2 written; 3 upwards are not. */ + env.zx_env_attr_written_mask = 0x07U; + env.zx_env_region_budget = 20U; +} + +/* Every case is "reset, break one thing, expect this status here". */ +#define EXPECT(expected_status, expected_partition, expected_region) \ + do { \ + UINT actual = zx_manifest_verify(&manifest, &env, &fault); \ + ZX_CHECK_EQ(actual, (expected_status)); \ + ZX_CHECK_EQ(fault.zx_fault_status, (expected_status)); \ + ZX_CHECK_EQ(fault.zx_fault_partition, (UINT)(expected_partition)); \ + ZX_CHECK_EQ(fault.zx_fault_region, (UINT)(expected_region)); \ + } while (0) + +#define EXPECT_STATUS(expected_status) \ + do { \ + UINT actual = zx_manifest_verify(&manifest, &env, &fault); \ + ZX_CHECK_EQ(actual, (expected_status)); \ + } while (0) + +static void test_good_manifest(void) +{ + reset_manifest(); + EXPECT(ZX_MANIFEST_SUCCESS, ZX_MANIFEST_NO_INDEX, ZX_MANIFEST_NO_INDEX); + + /* And with no fault storage at all: a caller wanting only the status + must not have to supply somewhere to put the rest. */ + reset_manifest(); + ZX_CHECK_EQ(zx_manifest_verify(&manifest, &env, (ZX_MANIFEST_FAULT *)0), + ZX_MANIFEST_SUCCESS); +} + +static void test_null_pointers(void) +{ + reset_manifest(); + ZX_CHECK_EQ(zx_manifest_verify((const ZX_MANIFEST *)0, &env, &fault), + ZX_MANIFEST_NULL_POINTER); + + reset_manifest(); + ZX_CHECK_EQ(zx_manifest_verify(&manifest, (const ZX_MANIFEST_ENV *)0, + &fault), + ZX_MANIFEST_NULL_POINTER); + + reset_manifest(); + manifest.zx_manifest_partitions = (const ZX_PARTITION *)0; + EXPECT_STATUS(ZX_MANIFEST_NULL_POINTER); + + /* A shared count with no array, and an MMIO count with no array: both + are caller bugs rather than empty lists, and both are reachable by + forgetting to set one field of two. */ + reset_manifest(); + manifest.zx_manifest_shared = (const ZX_SHARED *)0; + EXPECT_STATUS(ZX_MANIFEST_NULL_POINTER); + + reset_manifest(); + env.zx_env_mmio_regions = (const ZX_REGION *)0; + EXPECT_STATUS(ZX_MANIFEST_NULL_POINTER); + + reset_manifest(); + partitions[1].zx_partition_regions = (const ZX_REGION *)0; + EXPECT(ZX_MANIFEST_NULL_POINTER, 1U, ZX_MANIFEST_NO_INDEX); +} + +static void test_partition_structure(void) +{ + reset_manifest(); + manifest.zx_manifest_partition_count = 0U; + EXPECT_STATUS(ZX_MANIFEST_NO_PARTITIONS); + + reset_manifest(); + manifest.zx_manifest_partition_count = ZX_MAX_PARTITIONS + 1U; + EXPECT_STATUS(ZX_MANIFEST_TOO_MANY_PARTITIONS); + + reset_manifest(); + partitions[1].zx_partition_id = P0_ID; + EXPECT(ZX_MANIFEST_DUPLICATE_ID, 0U, ZX_MANIFEST_NO_INDEX); + + reset_manifest(); + partitions[0].zx_partition_region_count = 0U; + EXPECT(ZX_MANIFEST_NO_REGIONS, 0U, ZX_MANIFEST_NO_INDEX); + + reset_manifest(); + partitions[0].zx_partition_region_count = + ZX_MAX_REGIONS_PER_PARTITION + 1U; + EXPECT(ZX_MANIFEST_TOO_MANY_REGIONS, 0U, ZX_MANIFEST_NO_INDEX); +} + +static void test_region_alignment(void) +{ + /* One byte past a granule boundary. This is the bug that does not + fault: the low bits land on SH, AP and XN. */ + reset_manifest(); + p0_regions[1].zx_region_base = P0_DATA_BASE + 1U; + EXPECT(ZX_MANIFEST_BASE_UNALIGNED, 0U, 1U); + + /* A limit that is not the last byte of a granule. This one lands on + AttrIndx and picks an unwritten MAIR byte. */ + reset_manifest(); + p0_regions[1].zx_region_limit = P0_DATA_LIMIT - 1U; + EXPECT(ZX_MANIFEST_LIMIT_UNALIGNED, 0U, 1U); + + /* Transposed endpoints, both correctly aligned for their own field, so + that the alignment rules pass and the ordering rule is what fires. */ + reset_manifest(); + p1_regions[1].zx_region_base = P1_DATA_BASE; + p1_regions[1].zx_region_limit = P0_DATA_BASE + (ZX_MPU_GRANULE - 1U); + EXPECT(ZX_MANIFEST_LIMIT_BELOW_BASE, 1U, 1U); +} + +static void test_region_attributes(void) +{ + reset_manifest(); + p0_regions[0].zx_region_ap = (UCHAR)(ZX_AP_EL2_RO_GUEST_RO + 1U); + EXPECT(ZX_MANIFEST_BAD_AP, 0U, 0U); + + reset_manifest(); + p0_regions[0].zx_region_xn = (UCHAR)(ZX_XN_NEVER + 1U); + EXPECT(ZX_MANIFEST_BAD_XN, 0U, 0U); + + /* SH 0b01 is UNPREDICTABLE for Normal memory, so it is rejected even + though it is inside the field's range. */ + reset_manifest(); + p0_regions[0].zx_region_sh = 0x01U; + EXPECT(ZX_MANIFEST_BAD_SH, 0U, 0U); + + reset_manifest(); + p0_regions[0].zx_region_sh = (UCHAR)(ZX_SH_INNER_SHAREABLE + 1U); + EXPECT(ZX_MANIFEST_BAD_SH, 0U, 0U); + + reset_manifest(); + p0_regions[0].zx_region_attr_index = (UCHAR)ZX_ATTR_INDEX_COUNT; + EXPECT(ZX_MANIFEST_ATTR_OUT_OF_RANGE, 0U, 0U); + + /* In range, but the hypervisor never wrote that MAIR byte. Nothing + faults on this: the region gets Device-nGnRnE and works, slowly. */ + reset_manifest(); + p0_regions[0].zx_region_attr_index = ATTR_UNWRITTEN; + EXPECT(ZX_MANIFEST_ATTR_NOT_WRITTEN, 0U, 0U); +} + +static void test_overlaps(void) +{ + /* Within one partition. */ + reset_manifest(); + p0_regions[1].zx_region_base = P0_CODE_BASE; + EXPECT(ZX_MANIFEST_SELF_OVERLAP, 0U, 0U); + ZX_CHECK_EQ(fault.zx_fault_other_partition, 0U); + ZX_CHECK_EQ(fault.zx_fault_other_region, 1U); + + /* Against the hypervisor's own MMIO, which stays enabled while a + partition runs. */ + reset_manifest(); + p1_regions[1].zx_region_base = MMIO_BASE; + p1_regions[1].zx_region_limit = MMIO_LIMIT; + EXPECT(ZX_MANIFEST_MMIO_OVERLAP, 1U, 1U); + ZX_CHECK_EQ(fault.zx_fault_other_region, 0U); + + /* Between two partitions: the isolation claim itself. */ + reset_manifest(); + p1_regions[1].zx_region_base = P0_DATA_BASE; + p1_regions[1].zx_region_limit = P0_DATA_LIMIT; + EXPECT(ZX_MANIFEST_PARTITION_OVERLAP, 0U, 1U); + ZX_CHECK_EQ(fault.zx_fault_other_partition, 1U); + ZX_CHECK_EQ(fault.zx_fault_other_region, 1U); + + /* A region that COVERS the shared granule but is not exactly it must + still be rejected. This is the case that would turn one published + granule into a licence to overlap around it -- the shared exception + is for an exact match and nothing else. */ + reset_manifest(); + p1_regions[2].zx_region_base = SHARED_BASE; + p1_regions[2].zx_region_limit = SHARED_LIMIT + ZX_MPU_GRANULE; + EXPECT_STATUS(ZX_MANIFEST_PARTITION_OVERLAP); + + /* And the mirror: partition 0's copy is not the declared range while + partition 1's is, so the pair does not match one declaration. */ + reset_manifest(); + p0_regions[2].zx_region_base = SHARED_BASE - ZX_MPU_GRANULE; + p0_regions[2].zx_region_limit = SHARED_LIMIT; + p0_regions[2].zx_region_ap = ZX_AP_EL2_RW_GUEST_RW; + shared_ranges[0].zx_shared_publisher_id = P1_ID; + p1_regions[2].zx_region_ap = ZX_AP_EL2_RW_GUEST_RW; + EXPECT_STATUS(ZX_MANIFEST_PARTITION_OVERLAP); +} + +static void test_runnability(void) +{ + reset_manifest(); + p0_regions[0].zx_region_xn = ZX_XN_NEVER; + EXPECT(ZX_MANIFEST_NO_EXECUTABLE_REGION, 0U, ZX_MANIFEST_NO_INDEX); + + reset_manifest(); + p1_regions[0].zx_region_ap = ZX_AP_EL2_RO_GUEST_RO; + p1_regions[1].zx_region_ap = ZX_AP_EL2_RO_GUEST_RO; + p1_regions[2].zx_region_ap = ZX_AP_EL2_RO_GUEST_RO; + EXPECT(ZX_MANIFEST_NO_WRITABLE_REGION, 1U, ZX_MANIFEST_NO_INDEX); + + /* Four bytes outside the code region. Invisible in a manifest, and it + costs a hardware session to find without this rule. */ + reset_manifest(); + partitions[0].zx_partition_entry = P0_CODE_LIMIT + 1U; + EXPECT(ZX_MANIFEST_ENTRY_NOT_EXECUTABLE, 0U, ZX_MANIFEST_NO_INDEX); + + /* An entry inside a region the partition owns, but a NON-executable + one. The rule is about executable regions, not about any region. */ + reset_manifest(); + partitions[0].zx_partition_entry = P0_DATA_BASE; + EXPECT(ZX_MANIFEST_ENTRY_NOT_EXECUTABLE, 0U, ZX_MANIFEST_NO_INDEX); + + /* BELOW the code region, not above it. Both sides of the containment + test need a case: the two above are both at or past the base, so + without this one the "entry >= base" comparison is never seen to be + false and an entry pointing below a partition's code would be + accepted by a rule nothing had exercised. */ + reset_manifest(); + partitions[0].zx_partition_entry = P0_CODE_BASE - ZX_MPU_GRANULE; + EXPECT(ZX_MANIFEST_ENTRY_NOT_EXECUTABLE, 0U, ZX_MANIFEST_NO_INDEX); + + reset_manifest(); + partitions[1].zx_partition_image_end = IMAGE_START - 1U; + EXPECT(ZX_MANIFEST_IMAGE_RANGE_INVALID, 1U, ZX_MANIFEST_NO_INDEX); + + reset_manifest(); + partitions[1].zx_partition_image_end = IMAGE_START + 0x200000U; + EXPECT(ZX_MANIFEST_IMAGE_TOO_LARGE, 1U, ZX_MANIFEST_NO_INDEX); +} + +static void test_time_partitioning(void) +{ + reset_manifest(); + partitions[1].zx_partition_window_ticks = 0U; + EXPECT(ZX_MANIFEST_ZERO_WINDOW, 1U, ZX_MANIFEST_NO_INDEX); + + /* The frame is carried AND checked, so that it can disagree. */ + reset_manifest(); + manifest.zx_manifest_major_frame_ticks = 11U; + EXPECT_STATUS(ZX_MANIFEST_FRAME_MISMATCH); +} + +static void test_shared_ranges(void) +{ + reset_manifest(); + manifest.zx_manifest_shared_count = ZX_MAX_SHARED_RANGES + 1U; + EXPECT_STATUS(ZX_MANIFEST_TOO_MANY_SHARED); + + reset_manifest(); + shared_ranges[0].zx_shared_base = SHARED_BASE + 1U; + p0_regions[2].zx_region_base = SHARED_BASE + 1U; + p1_regions[2].zx_region_base = SHARED_BASE + 1U; + EXPECT_STATUS(ZX_MANIFEST_BASE_UNALIGNED); + + /* Aligned, but larger than one granule. Phase 0 bounds how much + memory the isolation claim is qualified by, and enforces the bound + rather than describing it. */ + reset_manifest(); + shared_ranges[0].zx_shared_limit = SHARED_LIMIT + ZX_MPU_GRANULE; + p0_regions[2].zx_region_limit = SHARED_LIMIT + ZX_MPU_GRANULE; + p1_regions[2].zx_region_limit = SHARED_LIMIT + ZX_MPU_GRANULE; + EXPECT_STATUS(ZX_MANIFEST_SHARED_NOT_ONE_GRANULE); + + /* An unaligned declaration that no region matches, so the per-region + rules cannot fire first. */ + reset_manifest(); + shared_ranges[0].zx_shared_base = SHARED_BASE + 1U; + shared_ranges[0].zx_shared_limit = SHARED_LIMIT + 1U; + EXPECT_STATUS(ZX_MANIFEST_SHARED_NOT_ONE_GRANULE); + + /* Nobody publishes it: almost certainly a wrong publisher id, and the + result is memory two partitions can read and nothing writes. */ + reset_manifest(); + shared_ranges[0].zx_shared_publisher_id = 99U; + p0_regions[2].zx_region_ap = ZX_AP_EL2_RO_GUEST_RO; + EXPECT_STATUS(ZX_MANIFEST_SHARED_NO_PUBLISHER); + + /* The publisher cannot write what it publishes. */ + reset_manifest(); + p0_regions[2].zx_region_ap = ZX_AP_EL2_RO_GUEST_RO; + EXPECT(ZX_MANIFEST_SHARED_BAD_AP, 0U, 2U); + + /* A reader given write access. The declaration says one-way and the + regions say two-way, and nothing at run time would ever say so: + both partitions would simply work. */ + reset_manifest(); + p1_regions[2].zx_region_ap = ZX_AP_EL2_RW_GUEST_RW; + EXPECT(ZX_MANIFEST_SHARED_BAD_AP, 1U, 2U); + + /* No sharing at all is a legal system, and the commonest one. */ + reset_manifest(); + manifest.zx_manifest_shared_count = 0U; + partitions[0].zx_partition_region_count = 2U; + partitions[1].zx_partition_region_count = 2U; + EXPECT_STATUS(ZX_MANIFEST_SUCCESS); +} + +static void test_region_budget(void) +{ + /* One region short of what the manifest needs: 1 MMIO + 3 + 3. */ + reset_manifest(); + env.zx_env_region_budget = 6U; + EXPECT_STATUS(ZX_MANIFEST_REGION_BUDGET); + + /* Exactly enough must pass. An off-by-one here would either reject a + manifest that fits or accept one that does not, and only one of + those is visible on hardware. */ + reset_manifest(); + env.zx_env_region_budget = 7U; + EXPECT_STATUS(ZX_MANIFEST_SUCCESS); + + /* The hypervisor's own MMIO counts against the budget. On the model + it is zero regions and on silicon it is two, which is why this is + checked against the register and not against a constant. */ + reset_manifest(); + env.zx_env_mmio_region_count = 0U; + env.zx_env_region_budget = 6U; + EXPECT_STATUS(ZX_MANIFEST_SUCCESS); +} + +/**************************************************************************/ +/* The arithmetic. These are the cases an overlap test written with a */ +/* subtraction gets wrong, and they are all legal manifests. */ +/**************************************************************************/ + +static void test_overlap_arithmetic(void) +{ + /* Disjoint, in both argument orders. */ + ZX_CHECK_EQ(zx_manifest_ranges_overlap(0x1000U, 0x103FU, + 0x2000U, 0x203FU), 0U); + ZX_CHECK_EQ(zx_manifest_ranges_overlap(0x2000U, 0x203FU, + 0x1000U, 0x103FU), 0U); + + /* Touching but not overlapping: a.limit + 1 == b.base. This is how a + partition's code and data windows sit next to each other, so a test + that called it an overlap would reject every real manifest. */ + ZX_CHECK_EQ(zx_manifest_ranges_overlap(0x1000U, 0x103FU, + 0x1040U, 0x107FU), 0U); + ZX_CHECK_EQ(zx_manifest_ranges_overlap(0x1040U, 0x107FU, + 0x1000U, 0x103FU), 0U); + + /* Sharing exactly one byte, at each end. */ + ZX_CHECK_EQ(zx_manifest_ranges_overlap(0x1000U, 0x1040U, + 0x1040U, 0x107FU), 1U); + ZX_CHECK_EQ(zx_manifest_ranges_overlap(0x1040U, 0x107FU, + 0x1000U, 0x1040U), 1U); + + /* Identical, and fully nested both ways round. */ + ZX_CHECK_EQ(zx_manifest_ranges_overlap(0x1000U, 0x103FU, + 0x1000U, 0x103FU), 1U); + ZX_CHECK_EQ(zx_manifest_ranges_overlap(0x1000U, 0x1FFFU, + 0x1400U, 0x143FU), 1U); + ZX_CHECK_EQ(zx_manifest_ranges_overlap(0x1400U, 0x143FU, + 0x1000U, 0x1FFFU), 1U); + + /* A single-byte range against itself, which is the degenerate case a + length-based test divides by nothing on. */ + ZX_CHECK_EQ(zx_manifest_ranges_overlap(0x1000U, 0x1000U, + 0x1000U, 0x1000U), 1U); + + /* The top of the address space. A test computing (limit + 1) or + (limit - base + 1) wraps to zero here and reports no overlap for two + ranges that plainly do -- a hole in the isolation rules at the one + address least likely to be tested. Width-correct so this is the real + top on a 32-bit target and on a 64-bit host alike. */ + { + const zx_addr_t top = ~(zx_addr_t)0; + const zx_addr_t last_granule = top - (zx_addr_t)(ZX_MPU_GRANULE - 1U); + + ZX_CHECK_EQ(zx_manifest_ranges_overlap(last_granule, top, + last_granule, top), 1U); + ZX_CHECK_EQ(zx_manifest_ranges_overlap(last_granule, top, + top, top), 1U); + ZX_CHECK_EQ(zx_manifest_ranges_overlap(0U, 0x3FU, + last_granule, top), 0U); + } +} + +static void test_region_edges(void) +{ + /* A region of exactly one granule is legal and is the shape every + shared range takes. */ + reset_manifest(); + p0_regions[1].zx_region_base = P0_DATA_BASE; + p0_regions[1].zx_region_limit = P0_DATA_BASE + (ZX_MPU_GRANULE - 1U); + EXPECT_STATUS(ZX_MANIFEST_SUCCESS); + + /* Adjacent regions that touch: legal, and must not be rejected. */ + reset_manifest(); + p0_regions[1].zx_region_base = P0_CODE_LIMIT + 1U; + p0_regions[1].zx_region_limit = P0_CODE_LIMIT + ZX_MPU_GRANULE; + EXPECT_STATUS(ZX_MANIFEST_SUCCESS); + + /* A region ending at the very top of the address space. The limit + rule is written as "the low bits are all set" rather than "limit + 1 + is aligned" precisely so that this case is decided rather than + arrived at by a wrap that happens to give the right answer. */ + reset_manifest(); + p1_regions[1].zx_region_base = ~(zx_addr_t)0 + - (zx_addr_t)(ZX_MPU_GRANULE - 1U); + p1_regions[1].zx_region_limit = ~(zx_addr_t)0; + EXPECT_STATUS(ZX_MANIFEST_SUCCESS); + + /* One partition, no sharing: the smallest legal system. */ + reset_manifest(); + manifest.zx_manifest_partition_count = 1U; + manifest.zx_manifest_shared_count = 0U; + manifest.zx_manifest_major_frame_ticks = 7U; + partitions[0].zx_partition_region_count = 2U; + EXPECT_STATUS(ZX_MANIFEST_SUCCESS); +} + +ZX_TEST_MAIN("test_zx_manifest", + test_good_manifest(); + test_null_pointers(); + test_partition_structure(); + test_region_alignment(); + test_region_attributes(); + test_overlaps(); + test_runnability(); + test_time_partitioning(); + test_shared_ranges(); + test_region_budget(); + test_overlap_arithmetic(); + test_region_edges(); +) From 82ea6c8944438e67ceb33be7103620eb656148ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 2 Sep 2026 11:41:17 -0400 Subject: [PATCH 08/40] Added the region readback, the switch mask and the layout planner THE READBACK. At stage 2 the alignment bugs fail in the attributes and not in the address: a base one byte past a granule boundary does not fault, because its low bits ARE the SH, AP and XN fields. The region is programmed successfully with permissions nobody asked for, and runs, and nothing ever reports it. zx_stage2_region_readback decomposes a programmed region back into the descriptor a manifest would have declared, and zx_stage2_region_matches compares one against its intent. The EN bit is deliberately not decomposed: a manifest declares what a region IS, while whether it is enabled belongs to the running system. Folding it in would make a region read back unequal to the one written purely because a different partition is current. THE SWITCH. zx_stage2_enable_set replaces the whole HPRENR mask in one write with ONE barrier pair, DSB before ISB. That order is load-bearing: issuing the ISB before the write has retired would let the following fetch be permitted under a mask no longer in force, which is a partition briefly running with its predecessor's memory. The direct region encodings above index 15 are NOT generalised, and that is a decision the measurement enables rather than an omission. The 542-604 against 434-470 cycle figure only applies if regions are reprogrammed per switch; with fixed blocks and mask switching they are programmed once at boot, so unrolling 25 indices -- coprocessor register numbers must be compile-time constants, so it means roughly a hundred inline MCR/MRC statements -- would speed up an operation that happens once. The barrier policy for the switch path now lives in one function, because "how long does a partition switch take" needs an answer that is not assembled from three files. THE PLANNER. zx_mm_plan turns a manifest into a region-index layout and a set of HPRENR images, and touches no hardware: the caller walks the plan and does the writing. The split is what makes the interesting part -- which index each region lands on, which bits each mask carries, whether the total fits the part -- reachable from a workstation. Passing the programmer in as a function pointer would have kept one function at the cost of an indirect call on the switch path. Fixed blocks rather than packing, so a switch is one register write for any manifest instead of a loop whose length depends on the incoming partition. The hypervisor's MMIO occupies the lowest indices and is enabled in every mask, because a partition switch must not blind the hypervisor's own console. The layout is printed once at boot. Every later fault report names a region INDEX, and the same index is a different window on the two targets -- the model spends none of them on hypervisor MMIO and silicon spends two -- so without the printout a diagnostic cannot be mapped back to the manifest. Tests assert the exact mask patterns and then the property behind them: no two partitions' masks share a bit outside the always-on block, and every mask carries that block. The patterns catch an arithmetic slip; the property catches a redesign that is internally consistent and wrong. On hardware the symptom of a wrong mask is that everything works. The coverage floor now covers the planner as well as the validator, both at 100% line and branch, and was re-verified to fail when a rule's tests are removed. Assisted-by: Claude Code (Opus 5) --- core/inc/zx_mm.h | 102 ++++++++- core/src/zx_mm_setup.c | 274 ++++++++++++++++++++++- platform/cortex_r52/inc/zx_port.h | 45 ++++ platform/cortex_r52/src/zx_stage2_mpu.c | 143 ++++++++++++ test/host/CMakeLists.txt | 1 + test/host/run.sh | 23 +- test/host/test_zx_mm.c | 282 ++++++++++++++++++++++++ 7 files changed, 855 insertions(+), 15 deletions(-) create mode 100644 test/host/test_zx_mm.c diff --git a/core/inc/zx_mm.h b/core/inc/zx_mm.h index f80da4a..0ab1993 100644 --- a/core/inc/zx_mm.h +++ b/core/inc/zx_mm.h @@ -33,10 +33,21 @@ /* override parts of it -- the regions must be disjoint by */ /* construction, which is a property the manifest validator enforces. */ /* */ -/* STATUS */ +/* WHERE THE SPLIT IS */ /* */ -/* Declared empty. The EL2 bring-up work programs the first region by */ -/* hand; driving the programming from the manifest comes after that. */ +/* This header PLANS; it does not program. zx_mm_plan turns a */ +/* manifest into a region-index layout and a set of HPRENR masks and */ +/* touches no hardware at all, and the caller -- which does have */ +/* hardware -- walks the plan and writes the registers. */ +/* */ +/* That split is not decoration. The interesting part of this file is */ +/* arithmetic: which index each region lands on, which bits each */ +/* partition's mask carries, and whether the total fits the part. All */ +/* of that is worth testing exhaustively on a workstation, and none of */ +/* it can be if it is interleaved with CP15 writes. The alternative */ +/* -- passing the programmer in as a function pointer -- would keep */ +/* one function but put an indirect call on the partition-switch path */ +/* of a codebase whose certification back end is funded. */ /* */ /**************************************************************************/ @@ -44,11 +55,96 @@ #define ZX_MM_H #include "zx_api.h" +#include "zx_manifest.h" #ifdef __cplusplus extern "C" { #endif +/**************************************************************************/ +/* The region-index layout */ +/**************************************************************************/ + +/* Which MPU region index each thing owns. + * + * FIXED BLOCKS, NOT PACKING. Each partition gets a contiguous run of + * indices reserved for it at plan time, and it keeps them whether it is + * running or not. Packing the regions of whichever partition is current + * into the low indices would use fewer of them, and it would make a + * partition switch a loop whose length depends on the incoming partition -- + * unbounded in the only sense a WCET argument cares about. With fixed + * blocks the switch is one HPRENR write, always, for any manifest. + * + * index 0 .. h-1 hypervisor MMIO: console, interrupt controller. + * Zero of them on the model, two on silicon. + * ALWAYS enabled -- they are the hypervisor's own + * console, and a partition switch must not blind it. + * index h .. h+n0-1 partition 0's block + * index h+n0 .. partition 1's block, and so on + * + * The masks are computed here rather than at each switch for the same + * reason: a value looked up is a value whose cost does not depend on how + * many regions a partition happens to own. */ + +typedef struct zx_mm_layout_struct +{ + UINT zx_layout_partitions; + UINT zx_layout_mmio_count; + UINT zx_layout_regions_used; + + UINT zx_layout_partition_first[ZX_MAX_PARTITIONS]; + UINT zx_layout_partition_count[ZX_MAX_PARTITIONS]; + + /* HPRENR images. uint32_t and not ULONG deliberately: this is a + hardware register value whose width is 32 bits by architecture, and + D19 keeps a fixed width exactly where the width is the point. */ + uint32_t zx_layout_always_mask; + uint32_t zx_layout_partition_mask[ZX_MAX_PARTITIONS]; +} ZX_MM_LAYOUT; + +/**************************************************************************/ +/* The plan */ +/**************************************************************************/ + +/* Assigns region indices and builds the enable masks. Pure: no hardware, + no static state, no allocation. + * + * Returns ZX_MANIFEST_SUCCESS, or the same error codes zx_manifest_verify + * uses -- one vocabulary rather than two, so a boot message means the same + * thing wherever it came from. Callers should verify a manifest before + * planning it; the checks repeated here are the ones whose failure would + * make the plan itself nonsense. + * + * The region budget is rechecked against the real count because this is the + * last point before indices become register writes, and a region programmed + * at an index the implementation does not have is UNPREDICTABLE rather than + * an error -- the model reports 32 EL2 regions, which is not an + * architecturally legal Cortex-R52 value, so a plan that fits the model + * proves nothing about a real part. */ + +ZX_NODISCARD UINT zx_mm_plan(const ZX_MANIFEST *manifest_ptr, + UINT mmio_region_count, + UINT region_budget, + ZX_MM_LAYOUT *layout_ptr); + +/* The HPRENR image that must be in force while a partition runs: its own + block plus the always-on hypervisor MMIO. Returns zero for an index the + plan does not cover, which disables every region -- the conservative + answer, since at EL0/EL1 a miss faults regardless of HSCTLR.BR. */ + +ZX_NODISCARD uint32_t zx_mm_partition_mask(const ZX_MM_LAYOUT *layout_ptr, + UINT partition_index); + +/* Prints the layout once, at boot. + * + * Worth its space in the image: every later fault report names a region + * INDEX, and without this the reader has no way to turn index 7 back into + * "partition 1's data window". A hypervisor whose diagnostics cannot be + * mapped back to the manifest is one debugged by guesswork. */ + +void zx_mm_report(const ZX_MM_LAYOUT *layout_ptr, + const ZX_MANIFEST *manifest_ptr); + #ifdef __cplusplus } #endif diff --git a/core/src/zx_mm_setup.c b/core/src/zx_mm_setup.c index 89e15fc..7662526 100644 --- a/core/src/zx_mm_setup.c +++ b/core/src/zx_mm_setup.c @@ -25,11 +25,277 @@ /* declarations into MPU region descriptors, and enabling the set */ /* belonging to the partition about to run. */ /* */ -/* This translation unit is deliberately empty of implementation. */ -/* The change that founded this repository builds the repository, not */ -/* the hypervisor; the unit exists so that the change which writes the */ -/* code opens a tree that already configures, compiles and links. */ +/* Planning only. Nothing here touches CP15 or a memory-mapped */ +/* register: it decides which MPU region index each thing owns and */ +/* what HPRENR must hold while each partition runs, and the caller */ +/* walks the result and does the writing. See the note in zx_mm.h on */ +/* why the split is there. */ /* */ /**************************************************************************/ #include "zx_mm.h" +#include "zx_console.h" + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_mm_plan PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Assigns a fixed block of region indices to each partition and */ +/* builds the HPRENR image that must be in force while it runs. */ +/* */ +/* The MMIO block comes FIRST, at index 0, and that is deliberate */ +/* rather than arbitrary. Those regions are the hypervisor's own */ +/* console and interrupt controller, they are enabled in every mask, */ +/* and there are zero of them on the model against two on silicon. */ +/* Putting them at the bottom means a partition's block starts at the */ +/* same index on both targets only when the counts agree -- which is */ +/* exactly the difference a boot-time printout should make visible */ +/* rather than hide. */ +/* */ +/**************************************************************************/ + +UINT zx_mm_plan(const ZX_MANIFEST *manifest_ptr, + UINT mmio_region_count, + UINT region_budget, + ZX_MM_LAYOUT *layout_ptr) +{ + UINT next_index; + UINT partition_index; + UINT bit_index; + + if (manifest_ptr == (const ZX_MANIFEST *)0) + { + return ZX_MANIFEST_NULL_POINTER; + } + + if (layout_ptr == (ZX_MM_LAYOUT *)0) + { + return ZX_MANIFEST_NULL_POINTER; + } + + if (manifest_ptr->zx_manifest_partitions == (const ZX_PARTITION *)0) + { + return ZX_MANIFEST_NULL_POINTER; + } + + if (manifest_ptr->zx_manifest_partition_count == 0U) + { + return ZX_MANIFEST_NO_PARTITIONS; + } + + if (manifest_ptr->zx_manifest_partition_count > ZX_MAX_PARTITIONS) + { + return ZX_MANIFEST_TOO_MANY_PARTITIONS; + } + + /* Cleared in full before anything is assigned. A layout is read by a + switch path that indexes it by partition, so a half-written one whose + unused entries hold whatever was on the stack would enable an + arbitrary set of regions -- the single worst failure available here. */ + layout_ptr->zx_layout_partitions = 0U; + layout_ptr->zx_layout_mmio_count = 0U; + layout_ptr->zx_layout_regions_used = 0U; + layout_ptr->zx_layout_always_mask = 0U; + + for (partition_index = 0U; partition_index < ZX_MAX_PARTITIONS; + partition_index++) + { + layout_ptr->zx_layout_partition_first[partition_index] = 0U; + layout_ptr->zx_layout_partition_count[partition_index] = 0U; + layout_ptr->zx_layout_partition_mask[partition_index] = 0U; + } + + /* HPRENR has one bit per region, so an index past bit 31 has no bit to + set. Rejected here rather than silently dropped: a region with no + enable bit is a region that never takes effect, and the partition + would run with a hole in its memory map and no diagnostic. */ + if (mmio_region_count > 32U) + { + return ZX_MANIFEST_REGION_BUDGET; + } + + for (bit_index = 0U; bit_index < mmio_region_count; bit_index++) + { + layout_ptr->zx_layout_always_mask |= (uint32_t)1U << bit_index; + } + + layout_ptr->zx_layout_mmio_count = mmio_region_count; + next_index = mmio_region_count; + + for (partition_index = 0U; + partition_index < manifest_ptr->zx_manifest_partition_count; + partition_index++) + { + const ZX_PARTITION *partition_ptr = + &manifest_ptr->zx_manifest_partitions[partition_index]; + uint32_t mask = layout_ptr->zx_layout_always_mask; + UINT region_index; + + if (partition_ptr->zx_partition_region_count == 0U) + { + return ZX_MANIFEST_NO_REGIONS; + } + + if (partition_ptr->zx_partition_region_count + > ZX_MAX_REGIONS_PER_PARTITION) + { + return ZX_MANIFEST_TOO_MANY_REGIONS; + } + + if ((next_index + partition_ptr->zx_partition_region_count) > 32U) + { + return ZX_MANIFEST_REGION_BUDGET; + } + + for (region_index = 0U; + region_index < partition_ptr->zx_partition_region_count; + region_index++) + { + mask |= (uint32_t)1U << (next_index + region_index); + } + + layout_ptr->zx_layout_partition_first[partition_index] = next_index; + layout_ptr->zx_layout_partition_count[partition_index] = + partition_ptr->zx_partition_region_count; + layout_ptr->zx_layout_partition_mask[partition_index] = mask; + + next_index += partition_ptr->zx_partition_region_count; + } + + layout_ptr->zx_layout_partitions = + manifest_ptr->zx_manifest_partition_count; + layout_ptr->zx_layout_regions_used = next_index; + + /* Last, against the real count. Checked after the layout is built so + that the printout below can say how many were needed as well as how + many exist -- "needs 9, has 8" is a fixable message and "does not + fit" is not. */ + if (next_index > region_budget) + { + return ZX_MANIFEST_REGION_BUDGET; + } + + return ZX_MANIFEST_SUCCESS; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_mm_partition_mask PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* The HPRENR image for one partition: a lookup, not a computation. */ +/* */ +/* Zero for an index the plan does not cover, which disables every */ +/* region. That is the conservative answer and not merely a */ +/* convenient one: at EL0 and EL1 a region miss faults regardless of */ +/* HSCTLR.BR, so an out-of-range partition index stops the guest */ +/* rather than handing it whatever the previous mask allowed. */ +/* */ +/**************************************************************************/ + +uint32_t zx_mm_partition_mask(const ZX_MM_LAYOUT *layout_ptr, + UINT partition_index) +{ + if (layout_ptr == (const ZX_MM_LAYOUT *)0) + { + return 0U; + } + + if (partition_index >= layout_ptr->zx_layout_partitions) + { + return 0U; + } + + return layout_ptr->zx_layout_partition_mask[partition_index]; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_mm_report PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Prints the region-index layout once, at boot. */ +/* */ +/* This is the key to every later diagnostic. A stage-2 fault report */ +/* names a region INDEX, and an index means nothing on its own -- the */ +/* same number is a different window on the model and on silicon, */ +/* because the hypervisor's own MMIO takes none of them on one and two */ +/* on the other. Printing the map once turns every later "region 7" */ +/* into "partition 1's data window" for whoever reads the log. */ +/* */ +/**************************************************************************/ + +void zx_mm_report(const ZX_MM_LAYOUT *layout_ptr, + const ZX_MANIFEST *manifest_ptr) +{ + UINT partition_index; + + if (layout_ptr == (const ZX_MM_LAYOUT *)0) + { + return; + } + + if (manifest_ptr == (const ZX_MANIFEST *)0) + { + return; + } + + zx_console_puts(" region layout:\n"); + + if (layout_ptr->zx_layout_mmio_count == 0U) + { + zx_console_puts(" (no hypervisor MMIO regions on this board)\n"); + } + else + { + zx_console_puts(" 0.."); + zx_console_putdec(layout_ptr->zx_layout_mmio_count - 1U); + zx_console_puts(" hypervisor MMIO, always enabled\n"); + } + + for (partition_index = 0U; + partition_index < layout_ptr->zx_layout_partitions; + partition_index++) + { + UINT first = layout_ptr->zx_layout_partition_first[partition_index]; + UINT count = layout_ptr->zx_layout_partition_count[partition_index]; + + zx_console_puts(" "); + zx_console_putdec(first); + zx_console_puts(".."); + zx_console_putdec((first + count) - 1U); + zx_console_puts(" "); + + if (manifest_ptr->zx_manifest_partitions[partition_index] + .zx_partition_name != (const CHAR *)0) + { + zx_console_puts(manifest_ptr->zx_manifest_partitions[partition_index] + .zx_partition_name); + } + else + { + zx_console_puts("(unnamed)"); + } + + zx_console_puts(", HPRENR "); + zx_console_puthex(layout_ptr->zx_layout_partition_mask[partition_index]); + zx_console_puts("\n"); + } + + zx_console_puts(" regions used "); + zx_console_putdec(layout_ptr->zx_layout_regions_used); + zx_console_puts("\n"); +} diff --git a/platform/cortex_r52/inc/zx_port.h b/platform/cortex_r52/inc/zx_port.h index bb26662..12b7e31 100644 --- a/platform/cortex_r52/inc/zx_port.h +++ b/platform/cortex_r52/inc/zx_port.h @@ -392,6 +392,27 @@ void zx_stage2_region_read(uint32_t index, uint32_t *base_ptr, void zx_stage2_region_program_direct16(const ZX_REGION *region_ptr); void zx_stage2_region_read_direct16(uint32_t *base_ptr, uint32_t *limit_ptr); +/* Read one region back and DECOMPOSE it into the descriptor a manifest would + have declared, so that a caller can compare what the hardware holds against + what it asked for. + + This exists because the whole class of alignment bug at stage 2 fails in the + ATTRIBUTES and not in the address: an under-aligned base does not fault, its + low bits land on SH, AP and XN, and the partition runs with permissions + nobody asked for. Nothing will ever report that. The only defence is to + read the registers back and look, which is what this and + zx_stage2_region_matches are for. */ + +void zx_stage2_region_readback(uint32_t index, ZX_REGION *region_ptr); + +/* Non-zero when a region reads back as the descriptor asked for. Compares + the granule-masked base and limit, because the programmer masks before + writing and a caller comparing raw values would fail on its own rounding + rather than on a real mismatch. */ + +ZX_NODISCARD uint32_t zx_stage2_region_matches(uint32_t index, + const ZX_REGION *region_ptr); + /* HPRENR: one enable bit per region, and the register whose width the TRM contradicts itself about. */ @@ -400,6 +421,30 @@ void zx_hprenr_write(uint32_t value); void zx_hprenr_enable(uint32_t index); void zx_hprenr_disable(uint32_t index); +/* THE PARTITION SWITCH. Replaces the whole enable mask in one write, with + ONE barrier pair at the end rather than one per region. + * + * This is the function a safety customer's question resolves to -- "how long + * does a partition switch take" is mostly this -- so the barrier policy lives + * here and nowhere else. Per-region barriers were measured to be most of a + * per-region cost during the Cortex-R52 Modules port work; a mask write needs + * exactly one DSB to retire and one ISB before the next instruction fetch can + * depend on the new permissions. + * + * WHY A MASK AND NOT A BLOCK REWRITE. Region descriptors are programmed once + * at boot and never again: each partition owns a fixed block of indices, and + * switching partitions changes which blocks are ENABLED. That is what makes + * the switch one register write of bounded cost instead of a loop whose + * length depends on how many regions the incoming partition has. It is also + * why the direct region encodings above index 15, though proven to work, are + * not generalised: they would speed up an operation that happens once. + * + * The cost of a block rewrite is the alternative worth measuring only if two + * partitions ever need different ATTRIBUTES on one address, which no manifest + * the validator accepts can ask for today. See docs/decisions.md D4. */ + +void zx_stage2_enable_set(uint32_t mask); + /* Turning protection on is TWO steps, and separating them is not tidiness. zx_el2_mpu_enable sets HSCTLR.BR and HSCTLR.M: the EL2-controlled MPU, diff --git a/platform/cortex_r52/src/zx_stage2_mpu.c b/platform/cortex_r52/src/zx_stage2_mpu.c index 5da4881..3b8cbdc 100644 --- a/platform/cortex_r52/src/zx_stage2_mpu.c +++ b/platform/cortex_r52/src/zx_stage2_mpu.c @@ -349,6 +349,119 @@ void zx_stage2_region_read_direct16(uint32_t *base_ptr, uint32_t *limit_ptr) } +/**************************************************************************/ +/* zx_stage2_region_readback -- read one region and decompose it. */ +/* */ +/* The inverse of zx_region_bar and zx_region_lar, and it exists for one */ +/* reason: at stage 2 the alignment bugs fail in the ATTRIBUTES, not the */ +/* address. A base one byte past a granule boundary does not fault -- */ +/* its low bits ARE the SH, AP and XN fields, so the region is programmed */ +/* successfully with permissions nobody asked for and runs. Nothing */ +/* reports that, ever. Reading the registers back and decomposing them */ +/* is the only way to see it, which is why this is part of the port's */ +/* interface rather than a debugging aid. */ +/* */ +/* The EN bit is deliberately NOT decomposed into the descriptor. A */ +/* manifest declares what a region IS; whether it is currently enabled is */ +/* a property of the running system and lives in HPRENR. Folding it into */ +/* the descriptor would make a region read back unequal to the one that */ +/* was written purely because a different partition is running. */ +/**************************************************************************/ + +void zx_stage2_region_readback(uint32_t index, ZX_REGION *region_ptr) +{ + uint32_t bar; + uint32_t lar; + + if (region_ptr == (ZX_REGION *)0) + { + return; + } + + zx_stage2_region_read(index, &bar, &lar); + + region_ptr->zx_region_base = (zx_addr_t)(bar & ZX_REGION_ADDR_MASK); + region_ptr->zx_region_limit = (zx_addr_t)((lar & ZX_REGION_ADDR_MASK) + | (ZX_MPU_GRANULE - 1U)); + + region_ptr->zx_region_xn = (UCHAR)(bar & ZX_HPRBAR_XN); + region_ptr->zx_region_ap = (UCHAR)((bar >> ZX_HPRBAR_AP_SHIFT) & 0x3U); + region_ptr->zx_region_sh = (UCHAR)((bar >> ZX_HPRBAR_SH_SHIFT) & 0x3U); + + region_ptr->zx_region_attr_index = + (UCHAR)((lar >> ZX_HPRLAR_ATTRINDX_SHIFT) & 0x7U); +} + + +/**************************************************************************/ +/* zx_stage2_region_matches -- did the region stick, and stick right? */ +/* */ +/* Compares against the granule-MASKED intent rather than the raw */ +/* descriptor. The programmer masks base and limit before writing, so a */ +/* comparison against unmasked values would report a mismatch caused by */ +/* this function's own arithmetic instead of by the hardware -- and a */ +/* readback check that cries wolf is a readback check that gets deleted. */ +/* */ +/* A descriptor whose base was under-aligned therefore does NOT fail here.*/ +/* That is correct and it is not a gap: the validator rejects it before */ +/* anything is programmed, and this function answers a different question */ +/* -- whether the write reached the register the caller believes it did. */ +/* An index the implementation does not have is UNPREDICTABLE and may */ +/* simply not stick, which is exactly what this catches. */ +/**************************************************************************/ + +uint32_t zx_stage2_region_matches(uint32_t index, const ZX_REGION *region_ptr) +{ + ZX_REGION actual; + zx_addr_t expected_base; + zx_addr_t expected_limit; + + if (region_ptr == (const ZX_REGION *)0) + { + return 0U; + } + + zx_stage2_region_readback(index, &actual); + + expected_base = region_ptr->zx_region_base & (zx_addr_t)ZX_REGION_ADDR_MASK; + expected_limit = (region_ptr->zx_region_limit + & (zx_addr_t)ZX_REGION_ADDR_MASK) + | (zx_addr_t)(ZX_MPU_GRANULE - 1U); + + if (actual.zx_region_base != expected_base) + { + return 0U; + } + + if (actual.zx_region_limit != expected_limit) + { + return 0U; + } + + if (actual.zx_region_ap != region_ptr->zx_region_ap) + { + return 0U; + } + + if (actual.zx_region_xn != region_ptr->zx_region_xn) + { + return 0U; + } + + if (actual.zx_region_sh != region_ptr->zx_region_sh) + { + return 0U; + } + + if (actual.zx_region_attr_index != region_ptr->zx_region_attr_index) + { + return 0U; + } + + return 1U; +} + + /**************************************************************************/ /* HPRENR */ /**************************************************************************/ @@ -384,6 +497,36 @@ void zx_hprenr_disable(uint32_t index) } +/**************************************************************************/ +/* zx_stage2_enable_set -- THE PARTITION SWITCH. */ +/* */ +/* One write, one barrier pair. Not one pair per region: barriers were */ +/* measured to be most of a per-region cost during the Cortex-R52 Modules */ +/* port work, and a mask write needs exactly two -- a DSB so the write */ +/* has retired before anything depends on it, and an ISB so the next */ +/* instruction fetch cannot have been permitted under the old mask. */ +/* */ +/* The barrier policy for the whole switch path lives HERE and nowhere */ +/* else, because "how long does a partition switch take" is a question */ +/* ZoneX has to be able to answer with a number, and an answer assembled */ +/* from barriers scattered across three functions is not one. */ +/* */ +/* The DSB comes FIRST and that order is load-bearing. Writing HPRENR */ +/* changes which regions govern EL0/EL1 accesses; issuing the ISB before */ +/* the write has retired would let the fetch that follows be permitted */ +/* under a mask that is no longer in force -- which is a partition */ +/* briefly running with its predecessor's memory, and the one failure */ +/* this function exists to prevent. */ +/**************************************************************************/ + +void zx_stage2_enable_set(uint32_t mask) +{ + zx_hprenr_write(mask); + __asm__ volatile("dsb"); + __asm__ volatile("isb"); +} + + /**************************************************************************/ /* zx_el2_mpu_enable */ /* */ diff --git a/test/host/CMakeLists.txt b/test/host/CMakeLists.txt index a92a09a..8e3b364 100644 --- a/test/host/CMakeLists.txt +++ b/test/host/CMakeLists.txt @@ -52,3 +52,4 @@ endfunction() zx_add_host_test(test_zx_api) zx_add_host_test(test_zx_fault) zx_add_host_test(test_zx_manifest) +zx_add_host_test(test_zx_mm) diff --git a/test/host/run.sh b/test/host/run.sh index 852b755..e778e68 100755 --- a/test/host/run.sh +++ b/test/host/run.sh @@ -102,21 +102,28 @@ case "${command}" in # "no worse than when somebody set it" and not one thing about the # code. # - # So the floor is set where a real number is defensible: the manifest - # validator, which is a pure function with no hardware in it and no - # excuse for an unreached line. 100% is not aspirational -- it is - # what it measures, on lines and on branches both. A rule added - # without a case that fails it will drop this and fail the build, - # which is the entire point: a validator rule nothing has ever seen - # reject anything is not a rule, it is a comment. + # So the floor covers the files where a real number is defensible: + # the manifest validator and the region-layout planner. Both are + # pure functions with no hardware in them, both are reachable in + # full from a workstation, and neither has an excuse for an + # unreached line. 100% is not aspirational -- it is what they + # measure, on lines and on branches both. A rule added without a + # case that fails it drops this and fails the build, which is the + # entire point: a validator rule nothing has ever seen reject + # anything is not a rule, it is a comment. + # + # A file joins this list when it becomes reachable in full, not + # when it is written. Adding one that cannot be is how a floor + # ends up lowered to whatever passes. # # Reported separately from the report above so that the number being # enforced is visible next to the failure, rather than the build # dying with a threshold the reader has to go and look up. echo "" - echo "Enforcing the coverage floor on the manifest validator:" + echo "Enforcing the coverage floor on the fully reachable core:" gcovr --root "${ROOT}" \ --filter "${ROOT}/core/src/zx_manifest_verify.c" \ + --filter "${ROOT}/core/src/zx_mm_setup.c" \ --txt - \ --fail-under-line 100 \ --fail-under-branch 100 diff --git a/test/host/test_zx_mm.c b/test/host/test_zx_mm.c new file mode 100644 index 0000000..f6daa58 --- /dev/null +++ b/test/host/test_zx_mm.c @@ -0,0 +1,282 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* test_zx_mm.c HOST TESTS */ +/* */ +/* DESCRIPTION */ +/* */ +/* The region-index layout and the HPRENR masks it produces. */ +/* */ +/* Worth testing on a workstation to this depth for one reason: the */ +/* masks decide, at run time, which memory a partition can reach. A */ +/* mask with one bit too many is a partition that can read its */ +/* neighbour, and it does not fault, and it does not print anything. */ +/* On hardware the symptom of a wrong mask is that everything works. */ +/* */ +/* So the cases here assert the exact bit patterns, and then assert */ +/* the property behind them -- that no two partitions' masks share a */ +/* bit outside the always-on hypervisor block. The bit patterns catch */ +/* an arithmetic slip; the property catches a redesign that is */ +/* internally consistent and wrong. */ +/* */ +/**************************************************************************/ + +#include "zx_mm.h" +#include "zx_console_capture.h" +#include "zx_test.h" + +static ZX_REGION p_regions[ZX_MAX_PARTITIONS][ZX_MAX_REGIONS_PER_PARTITION]; +static ZX_PARTITION partitions[ZX_MAX_PARTITIONS]; +static ZX_MANIFEST manifest; +static ZX_MM_LAYOUT layout; + +/* Only the fields the planner reads. A plan does not look at bases, limits + or attributes -- it counts regions -- and a fixture that filled them in + would suggest otherwise to the next reader. */ +static void reset_manifest(UINT partition_count, UINT regions_each) +{ + UINT index; + + for (index = 0U; index < ZX_MAX_PARTITIONS; index++) + { + partitions[index].zx_partition_id = index + 1U; + partitions[index].zx_partition_name = "p"; + partitions[index].zx_partition_regions = p_regions[index]; + partitions[index].zx_partition_region_count = regions_each; + partitions[index].zx_partition_window_ticks = 1U; + } + + manifest.zx_manifest_partitions = partitions; + manifest.zx_manifest_partition_count = partition_count; + manifest.zx_manifest_shared = (const ZX_SHARED *)0; + manifest.zx_manifest_shared_count = 0U; + manifest.zx_manifest_major_frame_ticks = partition_count; +} + +static void test_plan_with_mmio(void) +{ + /* Two hypervisor MMIO regions -- the silicon case -- and two + partitions of three regions each. */ + reset_manifest(2U, 3U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, &layout), + ZX_MANIFEST_SUCCESS); + + ZX_CHECK_EQ(layout.zx_layout_mmio_count, 2U); + ZX_CHECK_EQ(layout.zx_layout_always_mask, 0x00000003U); + + /* Partition 0 owns 2, 3, 4 -- and can still reach the console. */ + ZX_CHECK_EQ(layout.zx_layout_partition_first[0], 2U); + ZX_CHECK_EQ(layout.zx_layout_partition_count[0], 3U); + ZX_CHECK_EQ(layout.zx_layout_partition_mask[0], 0x0000001FU); + + /* Partition 1 owns 5, 6, 7. */ + ZX_CHECK_EQ(layout.zx_layout_partition_first[1], 5U); + ZX_CHECK_EQ(layout.zx_layout_partition_mask[1], 0x000000E3U); + + ZX_CHECK_EQ(layout.zx_layout_regions_used, 8U); + ZX_CHECK_EQ(layout.zx_layout_partitions, 2U); +} + +static void test_plan_without_mmio(void) +{ + /* Zero MMIO regions -- the model case. Every partition's block shifts + down, which is exactly why the layout is printed at boot: region 3 + is a different window on the two targets. */ + reset_manifest(2U, 3U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 0U, 32U, &layout), + ZX_MANIFEST_SUCCESS); + + ZX_CHECK_EQ(layout.zx_layout_always_mask, 0U); + ZX_CHECK_EQ(layout.zx_layout_partition_first[0], 0U); + ZX_CHECK_EQ(layout.zx_layout_partition_mask[0], 0x00000007U); + ZX_CHECK_EQ(layout.zx_layout_partition_first[1], 3U); + ZX_CHECK_EQ(layout.zx_layout_partition_mask[1], 0x00000038U); + ZX_CHECK_EQ(layout.zx_layout_regions_used, 6U); +} + +static void test_masks_are_disjoint(void) +{ + UINT first; + UINT second; + + /* The property, not the pattern. Two partitions' masks may share only + the always-on hypervisor bits; a bit shared anywhere else is a + partition that can reach its neighbour's memory while it runs, which + faults nothing and prints nothing. */ + reset_manifest(ZX_MAX_PARTITIONS, 2U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 32U, &layout), + ZX_MANIFEST_SUCCESS); + + for (first = 0U; first < layout.zx_layout_partitions; first++) + { + /* Every mask carries the always-on block. A partition switch that + dropped it would blind the hypervisor's own console, and the + symptom would be a silent board. */ + ZX_CHECK_EQ(layout.zx_layout_partition_mask[first] + & layout.zx_layout_always_mask, + layout.zx_layout_always_mask); + + for (second = first + 1U; second < layout.zx_layout_partitions; + second++) + { + uint32_t shared = layout.zx_layout_partition_mask[first] + & layout.zx_layout_partition_mask[second]; + + ZX_CHECK_EQ(shared, layout.zx_layout_always_mask); + } + } +} + +static void test_plan_rejections(void) +{ + reset_manifest(2U, 3U); + ZX_CHECK_EQ(zx_mm_plan((const ZX_MANIFEST *)0, 2U, 20U, &layout), + ZX_MANIFEST_NULL_POINTER); + + reset_manifest(2U, 3U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, (ZX_MM_LAYOUT *)0), + ZX_MANIFEST_NULL_POINTER); + + reset_manifest(2U, 3U); + manifest.zx_manifest_partitions = (const ZX_PARTITION *)0; + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, &layout), + ZX_MANIFEST_NULL_POINTER); + + reset_manifest(0U, 3U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, &layout), + ZX_MANIFEST_NO_PARTITIONS); + + reset_manifest(ZX_MAX_PARTITIONS + 1U, 3U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, &layout), + ZX_MANIFEST_TOO_MANY_PARTITIONS); + + reset_manifest(2U, 0U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, &layout), + ZX_MANIFEST_NO_REGIONS); + + reset_manifest(2U, ZX_MAX_REGIONS_PER_PARTITION + 1U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, &layout), + ZX_MANIFEST_TOO_MANY_REGIONS); +} + +static void test_budget(void) +{ + /* 2 MMIO + 3 + 3 = 8. Exactly enough must pass and one short must not: + an off-by-one either rejects a manifest that fits or programs a + region at an index the part does not have, and only the first of + those is visible without hardware. */ + reset_manifest(2U, 3U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 8U, &layout), + ZX_MANIFEST_SUCCESS); + + reset_manifest(2U, 3U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 7U, &layout), + ZX_MANIFEST_REGION_BUDGET); + + /* The model's 32 EL2 regions are not an architecturally legal + Cortex-R52 value, so a plan that fits the model can still be too big + for silicon. Same manifest, two budgets, two answers. */ + reset_manifest(ZX_MAX_PARTITIONS, ZX_MAX_REGIONS_PER_PARTITION); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 32U, &layout), + ZX_MANIFEST_SUCCESS); + + reset_manifest(ZX_MAX_PARTITIONS, ZX_MAX_REGIONS_PER_PARTITION); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, &layout), + ZX_MANIFEST_REGION_BUDGET); + + /* HPRENR has 32 bits, so an index with no enable bit is refused even + when a caller claims a larger budget. A region that cannot be + enabled is a hole in the memory map with no diagnostic. */ + reset_manifest(2U, 3U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 33U, 64U, &layout), + ZX_MANIFEST_REGION_BUDGET); + + reset_manifest(2U, 3U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 30U, 64U, &layout), + ZX_MANIFEST_REGION_BUDGET); +} + +static void test_partition_mask_lookup(void) +{ + reset_manifest(2U, 3U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, &layout), + ZX_MANIFEST_SUCCESS); + + ZX_CHECK_EQ(zx_mm_partition_mask(&layout, 0U), 0x0000001FU); + ZX_CHECK_EQ(zx_mm_partition_mask(&layout, 1U), 0x000000E3U); + + /* Out of range and null both give zero, which disables every region. + At EL0 and EL1 a miss faults regardless of HSCTLR.BR, so this stops + the guest rather than leaving the previous partition's mask in + force -- the failure that would be silent. */ + ZX_CHECK_EQ(zx_mm_partition_mask(&layout, 2U), 0U); + ZX_CHECK_EQ(zx_mm_partition_mask(&layout, ZX_MANIFEST_NO_INDEX), 0U); + ZX_CHECK_EQ(zx_mm_partition_mask((const ZX_MM_LAYOUT *)0, 0U), 0U); +} + +static void test_report(void) +{ + reset_manifest(2U, 3U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, &layout), + ZX_MANIFEST_SUCCESS); + + zx_capture_reset(); + zx_mm_report(&layout, &manifest); + + /* The report has to name the indices, or a later fault naming region 7 + cannot be mapped back to a window. */ + ZX_CHECK_EQ(zx_capture_contains("region layout"), 1U); + ZX_CHECK_EQ(zx_capture_contains("hypervisor MMIO"), 1U); + ZX_CHECK_EQ(zx_capture_contains("2..4"), 1U); + ZX_CHECK_EQ(zx_capture_contains("5..7"), 1U); + ZX_CHECK_EQ(zx_capture_contains("regions used"), 1U); + + /* On a board with none, say so rather than printing "0..-1". */ + reset_manifest(2U, 3U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 0U, 20U, &layout), + ZX_MANIFEST_SUCCESS); + + zx_capture_reset(); + zx_mm_report(&layout, &manifest); + ZX_CHECK_EQ(zx_capture_contains("no hypervisor MMIO regions"), 1U); + + /* An unnamed partition must not print a null pointer. */ + reset_manifest(2U, 3U); + partitions[1].zx_partition_name = (const CHAR *)0; + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, &layout), + ZX_MANIFEST_SUCCESS); + + zx_capture_reset(); + zx_mm_report(&layout, &manifest); + ZX_CHECK_EQ(zx_capture_contains("(unnamed)"), 1U); + + /* And neither pointer may be trusted. */ + zx_mm_report((const ZX_MM_LAYOUT *)0, &manifest); + zx_mm_report(&layout, (const ZX_MANIFEST *)0); +} + +ZX_TEST_MAIN("test_zx_mm", + test_plan_with_mmio(); + test_plan_without_mmio(); + test_masks_are_disjoint(); + test_plan_rejections(); + test_budget(); + test_partition_mask_lookup(); + test_report(); +) From 589cccd931dd9f1df763b9b7a4c04e50a3369589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 2 Sep 2026 11:59:16 -0400 Subject: [PATCH 09/40] Proved two partitions deny each other, and measured the switch TWO PARTITIONS, BOTH DIRECTIONS. A run in which partition A reaches its own memory and faults on B's is equally consistent with B's memory being mapped by nobody, which is not isolation but an empty address space with one tenant. So both directions run: B then executes, in its own memory, and faults on A's. The same address is reachable in one region set and unreachable in the other, which is what a partition switch is. Measured on the model: cross-partition probes fault with DFSC 0x04, no enabled region covering the address, and the refused write to the shared granule faults with DFSC 0x0C -- a region matched and its AP said no. Distinguishing those two was the point of checking the status code rather than the fault alone: "read-only" and "not mapped here" are different claims and only one of them describes a shared granule. The adjacent granule carries more weight than the neighbour does. Faulting on the other partition's data proves the sets differ; two sets a kilobyte apart would prove that too. The ungranted granule sits immediately after partition A's data window, by linker-script construction and with an ASSERT to keep it there, so touching it is what says the LIMIT is exact. A RELOCATABLE GUEST BLOB. Two partitions cannot run the same program in different memory while its data addresses live in literal pools, so the blob takes its data window base in a register and reaches everything at a fixed offset from it. Nothing to relocate: branches were already PC-relative, a literal holding a constant survives the copy, and a literal holding an ADDRESS is what the base pointer removes -- there is not one in the section. EL2 copies the blob into each code window, which is the path a real guest image takes, so the manifest's image bounds and the IMAGE_TOO_LARGE rule are load-bearing now rather than hypothetical. Entry points sit at declared offsets because the hypervisor computes them as window_base + constant. .org places them, and .org refuses to move backwards, so a routine that outgrew its slot fails the assembly instead of silently displacing the next entry point. The obvious alternative -- a .space guarded by ".if (. - blob_start) > offset" -- assembles under GNU as and is rejected by LLVM's, which will not treat a label difference as absolute before layout. ZoneX builds with both. THREE NEGATIVE VERIFICATIONS, each its own image because they fail at different points and one image would only show whichever came first: widened a region limit one granule too generous, swallowing the hole. The manifest is VALID -- covering the hole breaks no rule -- and only the runtime probe catches it, which is the clearest statement of why both layers exist. overlap two partitions' data windows on one address. Refused with PARTITION_OVERLAP naming both offenders, before a single region is programmed. badattr a region naming an HMAIR index this image never wrote. Refused with ATTR_NOT_WRITTEN. The hardware would not object: the region gets Device-nGnRnE and works, slowly. THE PMU, so a switch can be measured rather than estimated. The generic timer is not usable -- CNTFRQ reads zero on both targets -- and cycles are the unit a WCET argument is made in anyway. PMCCFILTR is under CRn = c14, not c9 with the rest of the PMU, and it is the register that decides whether Hyp mode is counted at all. Writing it at c9, c14, 7 is UNDEFINED: it took an undefined-instruction exception at EL2 on the first PMU write, reported through the vector because +0x04 carries no syndrome. Encoding corrected against TRM Table 3-15 and recorded where the next reader will look. The counter is also asked whether it is advancing before anything is timed, because a measurement from a stopped counter reports zero cycles for everything. Verified: both repository checks, the host suite, the FVP suite at 6/6 under GCC and ATfE clang, and S32Z280 builds under both toolchains. Assisted-by: Claude Code (Opus 5) --- core/inc/zx_fault.h | 10 + examples/common/zx_guest_abi.h | 143 +++++ examples/common/zx_partitions.c | 718 +++++++++++++++++++++++ examples/common/zx_payload.S | 221 +++++++ examples/common/zx_probe.h | 44 ++ examples/common/zx_probe_main.c | 22 +- examples/fvp_baser_aemv8r/CMakeLists.txt | 43 ++ examples/fvp_baser_aemv8r/zx_board.c | 17 + examples/fvp_baser_aemv8r/zx_link.lds | 112 ++++ examples/fvp_baser_aemv8r/zx_platform.h | 7 + examples/s32z280_evb/CMakeLists.txt | 1 + examples/s32z280_evb/zx_board.c | 36 ++ examples/s32z280_evb/zx_link.lds | 112 ++++ examples/s32z280_evb/zx_platform.h | 5 + platform/cortex_r52/inc/zx_port.h | 28 + platform/cortex_r52/src/zx_stage2_mpu.c | 77 +++ 16 files changed, 1593 insertions(+), 3 deletions(-) create mode 100644 examples/common/zx_guest_abi.h create mode 100644 examples/common/zx_partitions.c diff --git a/core/inc/zx_fault.h b/core/inc/zx_fault.h index cac2556..fb9f16d 100644 --- a/core/inc/zx_fault.h +++ b/core/inc/zx_fault.h @@ -93,6 +93,16 @@ extern "C" { decoded fields for exactly that reason. See docs/armv8r-el2-reference.md. */ #define ZX_ISS_DFSC_MASK 0x3FU /* [5:0] fault status code */ + +/* The two DFSC values a stage-2 MPU produces, both measured on the FVP and + on S32Z280 silicon. Telling them apart is the difference between two + distinct claims: MISS says no enabled region covered the address, while + PERMISSION says one did and its AP refused the access. A check that + accepted either would call a read-only region and an absent one the same + thing, which is exactly the confusion a shared read-only granule has to + be able to rule out. */ +#define ZX_DFSC_MPU_MISS 0x04U +#define ZX_DFSC_MPU_PERMISSION 0x0CU #define ZX_ISS_WNR_MASK 0x00000040U /* [6] 1 = write */ #define ZX_ISS_S1PTW_MASK 0x00000080U /* [7] stage-1 walk */ #define ZX_ISS_CM_MASK 0x00000100U /* [8] cache maintenance */ diff --git a/examples/common/zx_guest_abi.h b/examples/common/zx_guest_abi.h new file mode 100644 index 0000000..81a3e4e --- /dev/null +++ b/examples/common/zx_guest_abi.h @@ -0,0 +1,143 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_guest_abi.h EXAMPLE */ +/* */ +/* DESCRIPTION */ +/* */ +/* The contract between the hypervisor and the RELOCATABLE guest blob: */ +/* where each entry point sits inside the blob, and what each word of */ +/* the partition's data window means. */ +/* */ +/* WHY A RELOCATABLE BLOB AT ALL */ +/* */ +/* Two partitions have to run the same program in DIFFERENT memory, or */ +/* "set A denies B and set B denies A" cannot be demonstrated -- one */ +/* of the two halves would have no code to execute. The blob is */ +/* therefore assembled once, kept in the hypervisor's own memory, and */ +/* COPIED into each partition's code window at boot. That is the same */ +/* path a real guest image takes, which is why the manifest's */ +/* image_start and image_end are load-bearing here rather than */ +/* decorative. */ +/* */ +/* Relocatable, not position-independent-by-arithmetic. The blob */ +/* reaches its data through a BASE POINTER the hypervisor hands it in */ +/* r0, not through PC-relative address computation, so there is no */ +/* relocation to apply and no literal pool holding an absolute address. */ +/* Branches inside the blob are already PC-relative and survive the */ +/* copy untouched; a literal pool holding a CONSTANT survives too. */ +/* The one thing that cannot survive is a literal holding an ADDRESS, */ +/* and that is precisely what the base pointer removes. */ +/* */ +/* WHY THE OFFSETS ARE FIXED CONSTANTS */ +/* */ +/* The hypervisor computes an entry point as window_base + offset, so */ +/* it needs the offsets at compile time. Exporting them as link-time */ +/* symbols would work and would be silently wrong the first time an */ +/* entry grew past its slot; declaring them here and having the */ +/* assembler ASSERT each one fits fails the build instead. See the */ +/* .if/.error guards in zx_payload.S. */ +/* */ +/**************************************************************************/ + +#ifndef ZX_GUEST_ABI_H +#define ZX_GUEST_ABI_H + +/**************************************************************************/ +/* Entry points, as blob offsets */ +/**************************************************************************/ + +/* Offset 0 is the EL1 vector table, so VBAR is simply the window base. + Eight AArch32 vectors of four bytes each need 32; the slot is 64 so the + first entry point starts on a granule boundary and the map file reads + as the region layout rather than as an accident of instruction count. */ +#define ZX_GUEST_OFF_VECTORS 0x000U + +/* Writes a sentinel into its own data window and reads it back. Both + halves matter: a write that went nowhere and a read returning a stale + value are different failures, and only the comparison separates them + from success. */ +#define ZX_GUEST_OFF_GRANT 0x040U + +/* Reads the address the hypervisor left in ZX_GD_TARGET. Used against + the OTHER partition's memory, and against the ungranted granule next + door to a granted one. */ +#define ZX_GUEST_OFF_PROBE 0x080U + +/* Publishes a value into the shared granule. Only the publisher can do + this; a reader attempting it takes a stage-2 permission fault, which is + itself one of the checks. */ +#define ZX_GUEST_OFF_PUBLISH 0x0C0U + +/* Reads the shared granule. Every partition may do this. */ +#define ZX_GUEST_OFF_CONSUME 0x100U + +/* Total size the blob occupies. The assembler checks it. */ +#define ZX_GUEST_BLOB_SIZE 0x140U + +/**************************************************************************/ +/* The partition's data window */ +/**************************************************************************/ + +/* Offsets from the base pointer the hypervisor passes in r0. + * + * This is a MAILBOX and not a parameter list, and the difference matters: + * zx_el2_run_payload passes exactly one argument, while a probe needs both + * somewhere to report and something to aim at. The hypervisor can write + * this window because stage-2 AP cannot deny EL2 (D3) -- so the same + * property that stops AP from isolating partitions is what makes a + * hypervisor-to-guest mailbox free. */ + +#define ZX_GD_PROGRESS 0x00U /* guest writes, EL2 reads */ +#define ZX_GD_SCRATCH 0x04U /* the guest's own sentinel */ +#define ZX_GD_TARGET 0x08U /* EL2 writes: what to probe */ +#define ZX_GD_PROBED 0x0CU /* guest writes: what it read */ +#define ZX_GD_SHARED 0x10U /* EL2 writes: shared granule */ +#define ZX_GD_PUBLISH_VALUE 0x14U /* EL2 writes: what to publish */ + +#define ZX_GD_WINDOW_SIZE 0x40U + +/**************************************************************************/ +/* Progress bits */ +/**************************************************************************/ + +/* Set by the guest as it gets somewhere. Read by EL2 afterwards. + * + * ZX_GP_PROBE_SURVIVED is the one that carries the isolation claim, and it + * carries it NEGATIVELY: the bit being CLEAR after a probe is the evidence + * that stage 2 stopped the access. A test whose pass condition is the + * absence of something must be able to fail, which is why the negative + * builds aim the same probe at an address the guest IS granted. */ + +#define ZX_GP_ENTERED 0x01U +#define ZX_GP_OWN_DATA_OK 0x02U +#define ZX_GP_PROBE_SURVIVED 0x04U +#define ZX_GP_PUBLISHED 0x08U +#define ZX_GP_CONSUMED 0x10U + +/* The sentinel a guest writes into its own scratch word. Distinct per + partition so that EL2 reading both back cannot mistake one for the + other -- which is the failure a single shared sentinel would hide. */ +#define ZX_GUEST_SENTINEL_A 0xA5A50001U +#define ZX_GUEST_SENTINEL_B 0xA5A50002U + +/* What the publisher puts in the shared granule. */ +#define ZX_GUEST_HEARTBEAT 0x48420000U + +#endif /* ZX_GUEST_ABI_H */ diff --git a/examples/common/zx_partitions.c b/examples/common/zx_partitions.c new file mode 100644 index 0000000..ca86dd3 --- /dev/null +++ b/examples/common/zx_partitions.c @@ -0,0 +1,718 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_partitions.c EXAMPLE */ +/* */ +/* DESCRIPTION */ +/* */ +/* TWO partitions, from a manifest, proving each other's memory is */ +/* unreachable. */ +/* */ +/* WHY BOTH DIRECTIONS ARE RUN */ +/* */ +/* A run in which partition A reaches its own memory and faults on B's */ +/* is consistent with isolation. It is equally consistent with B's */ +/* memory simply not being mapped by anybody -- which is not */ +/* isolation, it is an empty address space with one tenant. Only the */ +/* mirror settles it: B must then run, in ITS own memory, and fault on */ +/* A's. The same address has to be reachable in one region set and */ +/* unreachable in the other, and that is what a partition switch is. */ +/* */ +/* WHY THE HOLE MATTERS MORE THAN THE NEIGHBOUR */ +/* */ +/* Faulting on the other partition's data proves the sets differ. It */ +/* does not prove the boundaries are right: two region sets a whole */ +/* kilobyte apart would pass it. The ungranted granule sits */ +/* IMMEDIATELY after partition 0's data window, so touching it catches */ +/* a limit that is one granule too generous -- the defect that */ +/* otherwise reads as a working system. */ +/* */ +/* WHAT THE SHARED GRANULE COSTS */ +/* */ +/* One granule is readable by both partitions and writable by one. */ +/* That is a deliberate, declared exception -- see the note on */ +/* ZX_SHARED in core/inc/zx_manifest.h -- and the run demonstrates */ +/* both halves of it: the reader can read it, and the reader CANNOT */ +/* write it. The second half is checked by fault status code, because */ +/* a permission failure and a region miss are different claims and */ +/* only one of them means "read-only". */ +/* */ +/**************************************************************************/ + +#include "zx_platform.h" +#include "zx_probe.h" +#include "zx_guest_abi.h" +#include "zx_port.h" +#include "zx_manifest.h" +#include "zx_mm.h" + +#define ZX_PARTITION_A 0U +#define ZX_PARTITION_B 1U + +#define ZX_PARTITION_A_ID 1U +#define ZX_PARTITION_B_ID 2U + +/* Region order within a partition's block. Fixed so that a fault naming a + region index can be read back to a window without counting. */ +#define ZX_PART_REGION_CODE 0U +#define ZX_PART_REGION_DATA 1U +#define ZX_PART_REGION_SHARED 2U +#define ZX_PART_REGION_COUNT 3U + +static ZX_REGION zx_regions_a[ZX_PART_REGION_COUNT]; +static ZX_REGION zx_regions_b[ZX_PART_REGION_COUNT]; +static ZX_SHARED zx_shared[1]; +static ZX_PARTITION zx_partitions[2]; +static ZX_MANIFEST zx_manifest; +static ZX_MM_LAYOUT zx_layout; +static ZX_MANIFEST_ENV zx_env; + +/* The hypervisor's own MMIO, as the validator needs to see it. The board + programs these regions itself; this is the same geometry declared so that + a partition window overlapping the console is rejected rather than + discovered as CONSTRAINED UNPREDICTABLE. */ +static ZX_REGION zx_mmio[ZX_BOARD_MAX_MMIO_REGIONS]; + + +/**************************************************************************/ +/* zx_window -- fill in one region descriptor from a linker window. */ +/**************************************************************************/ + +static void zx_window(ZX_REGION *region_ptr, const char *start, + const char *end, UCHAR ap, UCHAR xn) +{ + region_ptr->zx_region_base = zx_symbol_address(start); + region_ptr->zx_region_limit = zx_symbol_address(end) - 1U; + region_ptr->zx_region_ap = ap; + region_ptr->zx_region_xn = xn; + region_ptr->zx_region_sh = ZX_SH_NON_SHAREABLE; + region_ptr->zx_region_attr_index = (UCHAR)ZX_ATTR_NORMAL_WB; +} + + +/**************************************************************************/ +/* zx_build_manifest -- the whole system, declared. */ +/* */ +/* Built at run time from linker symbols rather than written out as a */ +/* initialised constant, because the windows' addresses are the linker's */ +/* to choose and a manifest repeating them by hand would be a second */ +/* source of truth that drifts. A real system's manifest is a constant; */ +/* this one describes a layout the linker script asserts. */ +/**************************************************************************/ + +static void zx_build_manifest(uint32_t board_regions) +{ + /* Partition A: its own code and data, plus the shared granule it + PUBLISHES -- so its copy is writable. */ + zx_window(&zx_regions_a[ZX_PART_REGION_CODE], + __zx_p0_code_start, __zx_p0_code_end, + ZX_AP_EL2_RW_GUEST_RW, ZX_XN_EXECUTABLE); + zx_window(&zx_regions_a[ZX_PART_REGION_DATA], + __zx_p0_data_start, __zx_p0_data_end, + ZX_AP_EL2_RW_GUEST_RW, ZX_XN_NEVER); + zx_window(&zx_regions_a[ZX_PART_REGION_SHARED], + __zx_shared_start, __zx_shared_end, + ZX_AP_EL2_RW_GUEST_RW, ZX_XN_NEVER); + + /* Partition B: the mirror, except that its copy of the shared granule + is READ-ONLY at any level. Same range, different permission -- which + is the whole reason a single shared region cannot express this. */ + zx_window(&zx_regions_b[ZX_PART_REGION_CODE], + __zx_p1_code_start, __zx_p1_code_end, + ZX_AP_EL2_RW_GUEST_RW, ZX_XN_EXECUTABLE); + zx_window(&zx_regions_b[ZX_PART_REGION_DATA], + __zx_p1_data_start, __zx_p1_data_end, + ZX_AP_EL2_RW_GUEST_RW, ZX_XN_NEVER); + zx_window(&zx_regions_b[ZX_PART_REGION_SHARED], + __zx_shared_start, __zx_shared_end, + ZX_AP_EL2_RO_GUEST_RO, ZX_XN_NEVER); + + zx_shared[0].zx_shared_base = zx_symbol_address(__zx_shared_start); + zx_shared[0].zx_shared_limit = zx_symbol_address(__zx_shared_end) - 1U; + zx_shared[0].zx_shared_publisher_id = ZX_PARTITION_A_ID; + + zx_partitions[ZX_PARTITION_A].zx_partition_id = ZX_PARTITION_A_ID; + zx_partitions[ZX_PARTITION_A].zx_partition_name = "partition A"; + zx_partitions[ZX_PARTITION_A].zx_partition_image_start = + zx_symbol_address(__zx_guest_blob_start); + zx_partitions[ZX_PARTITION_A].zx_partition_image_end = + zx_symbol_address(__zx_guest_blob_end); + zx_partitions[ZX_PARTITION_A].zx_partition_entry = + zx_symbol_address(__zx_p0_code_start) + ZX_GUEST_OFF_GRANT; + zx_partitions[ZX_PARTITION_A].zx_partition_regions = zx_regions_a; + zx_partitions[ZX_PARTITION_A].zx_partition_region_count = + ZX_PART_REGION_COUNT; + zx_partitions[ZX_PARTITION_A].zx_partition_window_ticks = 7U; + + zx_partitions[ZX_PARTITION_B].zx_partition_id = ZX_PARTITION_B_ID; + zx_partitions[ZX_PARTITION_B].zx_partition_name = "partition B"; + zx_partitions[ZX_PARTITION_B].zx_partition_image_start = + zx_symbol_address(__zx_guest_blob_start); + zx_partitions[ZX_PARTITION_B].zx_partition_image_end = + zx_symbol_address(__zx_guest_blob_end); + zx_partitions[ZX_PARTITION_B].zx_partition_entry = + zx_symbol_address(__zx_p1_code_start) + ZX_GUEST_OFF_GRANT; + zx_partitions[ZX_PARTITION_B].zx_partition_regions = zx_regions_b; + zx_partitions[ZX_PARTITION_B].zx_partition_region_count = + ZX_PART_REGION_COUNT; + zx_partitions[ZX_PARTITION_B].zx_partition_window_ticks = 3U; + + zx_manifest.zx_manifest_partitions = zx_partitions; + zx_manifest.zx_manifest_partition_count = 2U; + zx_manifest.zx_manifest_shared = zx_shared; + zx_manifest.zx_manifest_shared_count = 1U; + zx_manifest.zx_manifest_major_frame_ticks = 10U; + + /* The environment: what the hypervisor knows and the manifest cannot. */ + zx_board_describe_mmio_regions(zx_mmio); + + zx_env.zx_env_mmio_regions = zx_mmio; + zx_env.zx_env_mmio_region_count = board_regions; + + /* HMAIR indices this image actually programmed -- 0, 1 and 2. A region + naming any other index would get a zero MAIR byte, which is + Device-nGnRnE: memory that works, slowly, with nothing to fault on. */ + zx_env.zx_env_attr_written_mask = 0x07U; +} + + +/**************************************************************************/ +/* zx_program_from_plan -- write every region, then read it back. */ +/* */ +/* The readback is not ceremony. A region whose base was under-aligned */ +/* programs successfully with its low bits landing on SH, AP and XN, and */ +/* a region written at an index the implementation does not have is */ +/* UNPREDICTABLE and may not stick at all. Neither faults. Comparing */ +/* what the register holds against what was asked for is the only way */ +/* either becomes visible, and it costs one read per region once at boot. */ +/**************************************************************************/ + +static uint32_t zx_program_from_plan(void) +{ + uint32_t all_matched = 1U; + UINT partition_index; + + for (partition_index = 0U; + partition_index < zx_layout.zx_layout_partitions; + partition_index++) + { + const ZX_PARTITION *partition_ptr = + &zx_manifest.zx_manifest_partitions[partition_index]; + UINT first = zx_layout.zx_layout_partition_first[partition_index]; + UINT region_index; + + for (region_index = 0U; + region_index < partition_ptr->zx_partition_region_count; + region_index++) + { + const ZX_REGION *region_ptr = + &partition_ptr->zx_partition_regions[region_index]; + uint32_t index = (uint32_t)(first + region_index); + + zx_stage2_region_program(index, region_ptr); + + if (zx_stage2_region_matches(index, region_ptr) == 0U) + { + zx_console_puts(" [FAIL] region "); + zx_console_putdec(index); + zx_console_puts(" did not read back as programmed\n"); + all_matched = 0U; + zx_probe_fail(); + } + } + } + + return all_matched; +} + + +/**************************************************************************/ +/* The mailbox. EL2 can write a partition's data window because stage-2 */ +/* AP cannot deny EL2 -- the same property that stops AP from isolating */ +/* partitions is what makes a hypervisor-to-guest mailbox free. */ +/**************************************************************************/ + +static volatile uint32_t *zx_mailbox(const char *window_start) +{ + return (volatile uint32_t *)(uintptr_t)window_start; +} + + +static void zx_mailbox_reset(const char *window_start, uint32_t sentinel) +{ + volatile uint32_t *box = zx_mailbox(window_start); + + box[ZX_GD_PROGRESS / 4U] = 0U; + box[ZX_GD_SCRATCH / 4U] = 0U; + box[ZX_GD_TARGET / 4U] = 0U; + box[ZX_GD_PROBED / 4U] = 0U; + box[ZX_GD_SHARED / 4U] = (uint32_t)zx_symbol_address(__zx_shared_start); + box[ZX_GD_PUBLISH_VALUE / 4U] = sentinel; +} + + +/**************************************************************************/ +/* zx_enter -- switch to a partition's region set and run one entry. */ +/* */ +/* Three things happen per switch and the ORDER is the interesting part: */ +/* the enable mask, then VBAR, then the ERET. VBAR has to be set while */ +/* the incoming partition's code region is already enabled, because the */ +/* vector table it points at lives inside that region -- installing it */ +/* first would leave a window in which an exception would vector into */ +/* memory the running context cannot reach. */ +/**************************************************************************/ + +static uint32_t zx_enter(UINT partition_index, uint32_t entry_offset, + const char *code_window, const char *data_window, + const char *what) +{ + uint32_t mask = zx_mm_partition_mask(&zx_layout, partition_index); + uint32_t outcome; + + zx_console_puts("\n--- "); + zx_console_puts(zx_manifest.zx_manifest_partitions[partition_index] + .zx_partition_name); + zx_console_puts(": "); + zx_console_puts(what); + zx_console_puts(" ---\n"); + + zx_stage2_enable_set(mask); + zx_note("HPRENR now", zx_hprenr_read()); + + { + uint32_t vbar = (uint32_t)zx_symbol_address(code_window) + + ZX_GUEST_OFF_VECTORS; + + __asm__ volatile("mcr p15, 0, %0, c12, c0, 0" : : "r"(vbar) : "memory"); + __asm__ volatile("isb"); + } + + zx_fault_record_reset(zx_el2_fault_record()); + + outcome = zx_el2_run_payload(zx_symbol_address(code_window) + entry_offset, + (uint32_t)zx_symbol_address(data_window)); + + zx_note("progress", zx_mailbox(data_window)[ZX_GD_PROGRESS / 4U]); + + return outcome; +} + + +/**************************************************************************/ +/* zx_expect_fault -- a check whose PASS condition is an access failing. */ +/* */ +/* Four things have to hold, and dropping any of them weakens the claim: */ +/* the excursion came back as a fault rather than a yield; the guest's */ +/* survival bit is clear, so the load genuinely did not complete; the */ +/* syndrome is a stage-2 data abort routed to EL2 and not something else; */ +/* and HDFAR names the address that was aimed at. */ +/* */ +/* HDFAR and not HPFAR. The two registers do not mean the same thing on */ +/* the two targets -- the model returns a 4 KB page number where silicon */ +/* returns the full address -- so a check written against HPFAR would be */ +/* wrong by a factor of 256 on one of them. HDFAR carries the full */ +/* faulting address on both, and it is also the only one with the */ +/* resolution to identify a 64-byte granule. See docs/decisions.md D18. */ +/**************************************************************************/ + +static void zx_expect_fault(const char *what, uint32_t outcome, + const char *data_window, zx_addr_t target, + uint32_t expected_dfsc) +{ + const zx_fault_record_t *record = zx_el2_fault_record(); + uint32_t progress = zx_mailbox(data_window)[ZX_GD_PROGRESS / 4U]; + uint32_t hsr = record->zx_fault_hsr; + + zx_note("HDFAR", record->zx_fault_hdfar); + zx_note("HSR ", hsr); + + zx_check(what, ((outcome == ZX_RUN_FAULTED) + && ((progress & ZX_GP_PROBE_SURVIVED) == 0U) + && (zx_fault_ec(hsr) == ZX_EC_DABT_ROUTED) + && (record->zx_fault_hdfar == (uint32_t)target)) + ? 1U : 0U); + + zx_check((expected_dfsc == ZX_DFSC_MPU_PERMISSION) + ? " and DFSC says a region MATCHED and its AP refused\n" + " the access -- read-only, not absent" + : " and DFSC says NO enabled region covered it", + (zx_fault_dabt_dfsc(hsr) == expected_dfsc) ? 1U : 0U); +} + + +/**************************************************************************/ +/* zx_phase_two_partitions */ +/**************************************************************************/ + +void zx_phase_two_partitions(uint32_t board_regions, uint32_t el2_regions) +{ + UINT status; + ZX_MANIFEST_FAULT fault; + uint32_t outcome; + volatile uint32_t *shared = + (volatile uint32_t *)(uintptr_t)__zx_shared_start; + + zx_console_puts( + "\n=========================================================\n" + " TWO PARTITIONS, FROM A MANIFEST\n" + "=========================================================\n" + " Everything above ran ONE payload against one region set.\n" + " What follows declares two partitions in a manifest, has the\n" + " validator check it, turns it into a region layout, and runs\n" + " the same guest blob inside each partition's own memory.\n" + "\n" + " A run where A reaches its own memory and faults on B's is\n" + " also consistent with B's memory being mapped by nobody, so\n" + " both directions are run: the same address has to be\n" + " reachable in one region set and unreachable in the other.\n"); + + zx_build_manifest(board_regions); + +#ifdef ZX_PROBE_MANIFEST_OVERLAP + + /* Aim partition B's data window at partition A's. The validator must + refuse BY NAME rather than the run discovering it: two enabled + regions on one address is CONSTRAINED UNPREDICTABLE, and on the + S32Z280 it aborts. */ + zx_console_puts("\n OVERLAP BUILD: partition B's data window has been\n" + " aimed at partition A's, so the validator must refuse.\n"); + zx_regions_b[ZX_PART_REGION_DATA].zx_region_base = + zx_symbol_address(__zx_p0_data_start); + zx_regions_b[ZX_PART_REGION_DATA].zx_region_limit = + zx_symbol_address(__zx_p0_data_end) - 1U; +#endif + +#ifdef ZX_PROBE_BAD_ATTR + + /* An AttrIndx inside the architectural range that this image never + wrote into HMAIR. The hardware will not object: the region gets a + zero MAIR byte, which is Device-nGnRnE, and works slowly. */ + zx_console_puts("\n BAD-ATTRIBUTE BUILD: partition A's data window names\n" + " an HMAIR index this image never programmed, so the\n" + " validator must refuse -- the hardware would not.\n"); + zx_regions_a[ZX_PART_REGION_DATA].zx_region_attr_index = 5U; +#endif + +#ifdef ZX_PROBE_WIDEN_REGION + + /* Widen partition A's data window by exactly one granule, which + swallows the ungranted hole next door. The adjacent-granule probe + must then SURVIVE and the run must report FAILED. This is the + negative verification of the boundary check itself: without it, a + green run proves only that the probe address faults, not that the + boundary is where the manifest says. */ + zx_console_puts("\n WIDENED-REGION BUILD: partition A's data window has\n" + " been extended by one granule over the ungranted hole,\n" + " so the adjacent-granule probe must SURVIVE and this\n" + " run must report FAILED.\n"); + zx_regions_a[ZX_PART_REGION_DATA].zx_region_limit += + (zx_addr_t)ZX_MPU_GRANULE; +#endif + + /* ---------------------------------------------------------------- */ + /* The validator, at boot. */ + /* ---------------------------------------------------------------- */ + + zx_env.zx_env_region_budget = el2_regions; + + status = zx_manifest_verify(&zx_manifest, &zx_env, &fault); + + zx_console_puts("\n--- the manifest, checked before anything is " + "programmed ---\n"); + zx_note("zx_manifest_verify", status); + + if (status != ZX_MANIFEST_SUCCESS) + { + zx_note(" offending partition", fault.zx_fault_partition); + zx_note(" offending region ", fault.zx_fault_region); + zx_note(" second offender ", fault.zx_fault_other_region); + zx_console_puts(" the manifest was REFUSED, so no region was " + "programmed from it\n"); + zx_check("the manifest is valid", 0U); + return; + } + + zx_check("the manifest passes every rule", 1U); + + /* ---------------------------------------------------------------- */ + /* The plan. */ + /* ---------------------------------------------------------------- */ + + status = zx_mm_plan(&zx_manifest, board_regions, el2_regions, &zx_layout); + zx_note("zx_mm_plan", status); + + if (status != ZX_MANIFEST_SUCCESS) + { + zx_check("the layout fits this part's region budget", 0U); + return; + } + + zx_check("the layout fits this part's region budget", 1U); + zx_mm_report(&zx_layout, &zx_manifest); + + /* ---------------------------------------------------------------- */ + /* Copy the guest into each partition, the way a real image goes. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n--- copying the guest blob into both windows ---\n"); + zx_note("blob source", zx_symbol_address(__zx_guest_blob_start)); + zx_note("blob length", (uint32_t)(zx_symbol_address(__zx_guest_blob_end) + - zx_symbol_address(__zx_guest_blob_start))); + + { + const uint32_t *source = (const uint32_t *)(uintptr_t)__zx_guest_blob_start; + uint32_t *dest_a = (uint32_t *)(uintptr_t)__zx_p0_code_start; + uint32_t *dest_b = (uint32_t *)(uintptr_t)__zx_p1_code_start; + uint32_t words = (uint32_t)(ZX_GUEST_BLOB_SIZE / 4U); + uint32_t index; + + for (index = 0U; index < words; index++) + { + dest_a[index] = source[index]; + dest_b[index] = source[index]; + } + } + + /* The copies are data writes and will be FETCHED as instructions, so + the instruction stream has to be told. Caches are off in this image, + which is exactly why this is written down: turning them on later + without this would work on the model and fail on silicon. */ + __asm__ volatile("dsb"); + __asm__ volatile("isb"); + + zx_mailbox_reset(__zx_p0_data_start, ZX_GUEST_SENTINEL_A); + zx_mailbox_reset(__zx_p1_data_start, ZX_GUEST_SENTINEL_B); + *shared = 0U; + + /* ---------------------------------------------------------------- */ + /* Program the regions the plan assigned, and check they stuck. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n--- programming the region set, and reading it back ---\n"); + zx_check("every region reads back as the manifest declared it", + zx_program_from_plan()); + + /* ---------------------------------------------------------------- */ + /* PARTITION A. */ + /* ---------------------------------------------------------------- */ + + outcome = zx_enter(ZX_PARTITION_A, ZX_GUEST_OFF_GRANT, + __zx_p0_code_start, __zx_p0_data_start, + "reaching its OWN data"); + zx_check("partition A ran, in its own copy of the guest, and reached its\n" + " own data window", + ((outcome == ZX_RUN_YIELDED) + && ((zx_mailbox(__zx_p0_data_start)[ZX_GD_PROGRESS / 4U] + & ZX_GP_OWN_DATA_OK) != 0U) + && (zx_mailbox(__zx_p0_data_start)[ZX_GD_SCRATCH / 4U] + == ZX_GUEST_SENTINEL_A)) ? 1U : 0U); + + zx_mailbox(__zx_p0_data_start)[ZX_GD_TARGET / 4U] = + (uint32_t)zx_symbol_address(__zx_p1_data_start); + outcome = zx_enter(ZX_PARTITION_A, ZX_GUEST_OFF_PROBE, + __zx_p0_code_start, __zx_p0_data_start, + "reaching partition B's data"); + zx_expect_fault("partition A CANNOT reach partition B's data window", + outcome, __zx_p0_data_start, + zx_symbol_address(__zx_p1_data_start), + ZX_DFSC_MPU_MISS); + + /* The adjacent granule. This is the check that says the BOUNDARY is + right rather than merely that some far-away address faults. */ + zx_mailbox(__zx_p0_data_start)[ZX_GD_TARGET / 4U] = + (uint32_t)zx_symbol_address(__zx_p0_hole_start); + outcome = zx_enter(ZX_PARTITION_A, ZX_GUEST_OFF_PROBE, + __zx_p0_code_start, __zx_p0_data_start, + "reaching the granule immediately after its own data"); + zx_console_puts(" this granule begins at the byte after partition A's\n" + " data limit, so a limit one granule too generous is\n" + " caught here and nowhere else\n"); + zx_expect_fault("the granule adjacent to partition A's data is NOT\n" + " reachable, so the region's limit is exact", + outcome, __zx_p0_data_start, + zx_symbol_address(__zx_p0_hole_start), + ZX_DFSC_MPU_MISS); + + /* A publishes to the shared granule. */ + zx_mailbox(__zx_p0_data_start)[ZX_GD_PUBLISH_VALUE / 4U] = + ZX_GUEST_HEARTBEAT; + outcome = zx_enter(ZX_PARTITION_A, ZX_GUEST_OFF_PUBLISH, + __zx_p0_code_start, __zx_p0_data_start, + "publishing to the shared granule"); + zx_check("partition A, the declared publisher, can WRITE the shared\n" + " granule", + ((outcome == ZX_RUN_YIELDED) + && ((zx_mailbox(__zx_p0_data_start)[ZX_GD_PROGRESS / 4U] + & ZX_GP_PUBLISHED) != 0U) + && (*shared == ZX_GUEST_HEARTBEAT)) ? 1U : 0U); + + /* ---------------------------------------------------------------- */ + /* PARTITION B -- the mirror. */ + /* ---------------------------------------------------------------- */ + + outcome = zx_enter(ZX_PARTITION_B, ZX_GUEST_OFF_GRANT, + __zx_p1_code_start, __zx_p1_data_start, + "reaching its OWN data"); + zx_check("partition B ran, in its own copy of the guest, and reached its\n" + " own data window", + ((outcome == ZX_RUN_YIELDED) + && ((zx_mailbox(__zx_p1_data_start)[ZX_GD_PROGRESS / 4U] + & ZX_GP_OWN_DATA_OK) != 0U) + && (zx_mailbox(__zx_p1_data_start)[ZX_GD_SCRATCH / 4U] + == ZX_GUEST_SENTINEL_B)) ? 1U : 0U); + + zx_mailbox(__zx_p1_data_start)[ZX_GD_TARGET / 4U] = + (uint32_t)zx_symbol_address(__zx_p0_data_start); + outcome = zx_enter(ZX_PARTITION_B, ZX_GUEST_OFF_PROBE, + __zx_p1_code_start, __zx_p1_data_start, + "reaching partition A's data"); + zx_console_puts(" the SAME address partition A read successfully a few\n" + " lines ago. One region set granted it and the other\n" + " denies it, which is what a partition switch is.\n"); + zx_expect_fault("partition B CANNOT reach partition A's data window", + outcome, __zx_p1_data_start, + zx_symbol_address(__zx_p0_data_start), + ZX_DFSC_MPU_MISS); + + /* B reads what A published: the declared, deliberate exception. */ + outcome = zx_enter(ZX_PARTITION_B, ZX_GUEST_OFF_CONSUME, + __zx_p1_code_start, __zx_p1_data_start, + "reading the shared granule"); + zx_check("partition B can READ the granule partition A published, and\n" + " reads back the value A wrote", + ((outcome == ZX_RUN_YIELDED) + && ((zx_mailbox(__zx_p1_data_start)[ZX_GD_PROGRESS / 4U] + & ZX_GP_CONSUMED) != 0U) + && (zx_mailbox(__zx_p1_data_start)[ZX_GD_PROBED / 4U] + == ZX_GUEST_HEARTBEAT)) ? 1U : 0U); + + /* And cannot write it. Checked by fault status code, because + "read-only" and "not mapped here at all" are different claims. */ + zx_mailbox(__zx_p1_data_start)[ZX_GD_PUBLISH_VALUE / 4U] = 0xDEADBEEFU; + outcome = zx_enter(ZX_PARTITION_B, ZX_GUEST_OFF_PUBLISH, + __zx_p1_code_start, __zx_p1_data_start, + "trying to WRITE the shared granule"); + zx_expect_fault("partition B CANNOT write the shared granule", + outcome, __zx_p1_data_start, + zx_symbol_address(__zx_shared_start), + ZX_DFSC_MPU_PERMISSION); + zx_check("and the granule still holds partition A's value, so the\n" + " refused write changed nothing", + (*shared == ZX_GUEST_HEARTBEAT) ? 1U : 0U); + + /* ---------------------------------------------------------------- */ + /* What EL2 can see, which is everything. */ + /* ---------------------------------------------------------------- */ + + /* ---------------------------------------------------------------- */ + /* What a partition switch COSTS. */ + /* */ + /* Reported in cycles because that is the unit a WCET argument is */ + /* made in, and because there is no alternative here: CNTFRQ reads */ + /* zero on both targets, so nothing can convert to seconds. */ + /* */ + /* The measurement overhead is measured too and subtracted. A */ + /* switch is a single register write and two barriers, so the cost */ + /* of reading the counter twice is not a rounding error against it */ + /* -- it is potentially most of the number. */ + /* */ + /* ON THE MODEL THIS NUMBER MEANS NOTHING. The FVP is a functional */ + /* model, not a timing model; it is printed anyway so that the */ + /* measurement path itself is exercised on every run rather than */ + /* only when a board is attached. The number to quote comes from */ + /* silicon. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n--- what a partition switch costs ---\n"); + + zx_pmu_enable(); + + if (zx_pmu_is_running() == 0U) + { + zx_console_puts(" the PMU cycle counter is not advancing, so no\n" + " timing is reported. This is not a check failure:\n" + " a number from a counter that is not running would\n" + " be a very convincing wrong answer.\n"); + } + else + { + uint32_t mask_a = zx_mm_partition_mask(&zx_layout, ZX_PARTITION_A); + uint32_t mask_b = zx_mm_partition_mask(&zx_layout, ZX_PARTITION_B); + uint32_t overhead; + uint32_t switch_cost; + uint32_t region_cost; + uint32_t start; + uint32_t index; + + /* The cost of asking, with nothing between the two reads. */ + start = zx_pmu_cycles(); + overhead = zx_pmu_cycles() - start; + + /* Eight switches, alternating, so that no run is measuring a + no-op: writing the mask that is already in force could be + optimised by an implementation in a way a real switch is not. */ + start = zx_pmu_cycles(); + for (index = 0U; index < 4U; index++) + { + zx_stage2_enable_set(mask_b); + zx_stage2_enable_set(mask_a); + } + switch_cost = ((zx_pmu_cycles() - start) - overhead) / 8U; + + /* And one region descriptor write, for comparison. This is the + cost a switch would carry PER REGION if the region block were + rewritten instead of the mask being changed, which is the + alternative design and the reason the mask was chosen. */ + start = zx_pmu_cycles(); + for (index = 0U; index < 8U; index++) + { + zx_stage2_region_program( + (uint32_t)zx_layout.zx_layout_partition_first[ZX_PARTITION_A], + &zx_regions_a[ZX_PART_REGION_CODE]); + } + region_cost = ((zx_pmu_cycles() - start) - overhead) / 8U; + + zx_note("counter read overhead, cycles", overhead); + zx_note("HPRENR mask switch, cycles ", switch_cost); + zx_note("one region write, cycles ", region_cost); + + zx_console_puts( + " the mask switch is the whole cost of changing partitions:\n" + " region descriptors are programmed once at boot, so the\n" + " per-region figure is what the alternative design would pay\n" + " for EVERY region on EVERY switch.\n" + " ON THIS TARGET the figures are only meaningful if it is\n" + " silicon. A functional model does not model timing.\n"); + + /* Restore partition A's set, so the state at the end of the phase + is the one the report above describes. */ + zx_stage2_enable_set(mask_a); + } + + zx_console_puts("\n--- what the hypervisor can read, from EL2 ---\n"); + zx_note("partition A scratch", zx_mailbox(__zx_p0_data_start)[ZX_GD_SCRATCH / 4U]); + zx_note("partition B scratch", zx_mailbox(__zx_p1_data_start)[ZX_GD_SCRATCH / 4U]); + zx_check("EL2 reads BOTH partitions' memory, and the two sentinels\n" + " differ -- stage-2 AP cannot deny EL2, so a partition\n" + " is never hidden from the hypervisor that owns it", + ((zx_mailbox(__zx_p0_data_start)[ZX_GD_SCRATCH / 4U] + == ZX_GUEST_SENTINEL_A) + && (zx_mailbox(__zx_p1_data_start)[ZX_GD_SCRATCH / 4U] + == ZX_GUEST_SENTINEL_B)) ? 1U : 0U); +} diff --git a/examples/common/zx_payload.S b/examples/common/zx_payload.S index 4ec2ecf..d4caf8f 100644 --- a/examples/common/zx_payload.S +++ b/examples/common/zx_payload.S @@ -53,6 +53,7 @@ @ was not meant to be in. #include "zx_port.h" +#include "zx_guest_abi.h" .syntax unified .arm @@ -293,3 +294,223 @@ __zx_payload_stack_top: zx_high_probe_word: .word 0x5A5AC0DE .size zx_high_probe_word, . - zx_high_probe_word + +/**************************************************************************/ +/* */ +/* THE RELOCATABLE GUEST BLOB */ +/* */ +/* Everything above runs in ONE place, at the address it was linked for. */ +/* This does not: it is assembled once, kept in the hypervisor's own */ +/* memory, and COPIED into each partition's code window, so that two */ +/* partitions can run the same program in different memory. Without */ +/* that, "set A denies B and set B denies A" has only one half that can */ +/* be demonstrated -- the other partition would have no code to run. */ +/* */ +/* WHAT MAKES IT SURVIVE THE COPY */ +/* */ +/* Not PC-relative arithmetic. The hypervisor passes the partition's */ +/* DATA WINDOW BASE in r0 and every data access is an offset from it, so */ +/* there is nothing to relocate. Three things could have broken and do */ +/* not: */ +/* */ +/* branches already PC-relative; survive untouched */ +/* a literal CONSTANT loaded PC-relatively from a nearby pool, and */ +/* the pool is copied with the code */ +/* a literal ADDRESS would NOT survive -- and there is not one. That */ +/* is the whole discipline of this section: no */ +/* "ldr rN, =symbol" anywhere below. */ +/* */ +/* There is also no stack. SP_svc is UNKNOWN out of an ERET and every */ +/* entry point below is straight-line code that ends in an HVC, so a */ +/* stack pointer would be a thing to get wrong for no benefit. The */ +/* moment anything here pushes, it needs the window to hold a stack and */ +/* this comment needs deleting. */ +/* */ +/* ENTRY POINTS SIT AT DECLARED OFFSETS. The hypervisor computes them as */ +/* window_base + constant, so a routine that outgrew its slot would */ +/* silently become the wrong entry point. Each slot is padded to its */ +/* declared offset and the assembler ERRORS if the previous routine has */ +/* already passed it, which turns that into a build failure. */ +/**************************************************************************/ + + .section .zx_guest_blob, "ax", %progbits + .balign 64 + .global zx_guest_blob_start +zx_guest_blob_start: + +/* Pad to a declared entry offset. + * + * .org and not a computed .space, and that is not a style choice: .org + * takes an offset from the START OF THE SECTION, which is exactly what a + * blob offset is, and it REFUSES to move backwards -- so a routine that + * outgrew its slot fails the assembly instead of quietly displacing the + * next entry point. Both properties for free. + * + * The obvious alternative, guarding a .space with + * ".if (. - zx_guest_blob_start) > offset", assembles under GNU as and is + * rejected by LLVM's assembler, which will not treat the difference of two + * labels as an absolute expression before layout. ZoneX builds with both. + */ + .macro ZX_GUEST_SLOT offset, name + .org \offset + .endm + +/**************************************************************************/ +/* The guest's own EL1 vector table, at offset 0 of the blob. */ +/* */ +/* At offset 0 so that VBAR is simply the partition's code-window base -- */ +/* one fewer computed address in the hypervisor, and one fewer thing to */ +/* get wrong per partition. Each entry issues a distinct HVC immediate */ +/* so that an exception taken AT EL1, rather than routed to EL2, arrives */ +/* at EL2 saying which vector it came from instead of hanging. */ +/**************************************************************************/ + + ZX_GUEST_SLOT ZX_GUEST_OFF_VECTORS, vectors +zx_guest_vectors: + hvc #0x20 @ 0x00 reset + hvc #0x21 @ 0x04 undefined instruction + hvc #0x22 @ 0x08 SVC + hvc #0x23 @ 0x0C prefetch abort + hvc #0x24 @ 0x10 data abort + hvc #0x25 @ 0x14 reserved + hvc #0x26 @ 0x18 IRQ + hvc #0x27 @ 0x1C FIQ + +/**************************************************************************/ +/* grant -- can this partition reach its OWN data? */ +/* */ +/* The positive half of the isolation claim, and it has to come first: a */ +/* run in which every access fails would satisfy every "must fault" check */ +/* in this image and prove nothing at all. */ +/* */ +/* r0 = the partition's data window. The sentinel is passed in rather */ +/* than hard-coded, so that each partition writes a DIFFERENT one and EL2 */ +/* reading both back cannot mistake one partition's success for the */ +/* other's. */ +/**************************************************************************/ + + ZX_GUEST_SLOT ZX_GUEST_OFF_GRANT, grant + .global zx_guest_grant + .type zx_guest_grant, %function +zx_guest_grant: + ldr r3, [r0, #ZX_GD_PROGRESS] + orr r3, r3, #ZX_GP_ENTERED + str r3, [r0, #ZX_GD_PROGRESS] + + ldr r1, [r0, #ZX_GD_PUBLISH_VALUE] @ this partition's sentinel + str r1, [r0, #ZX_GD_SCRATCH] + dsb + ldr r2, [r0, #ZX_GD_SCRATCH] + cmp r2, r1 + bne 1f + + ldr r3, [r0, #ZX_GD_PROGRESS] + orr r3, r3, #ZX_GP_OWN_DATA_OK + str r3, [r0, #ZX_GD_PROGRESS] +1: + dsb + hvc #ZX_ASM_HVC_IMM_YIELD + .size zx_guest_grant, . - zx_guest_grant + +/**************************************************************************/ +/* probe -- read the address EL2 left in the mailbox. */ +/* */ +/* The load may take a stage-2 fault, and when it does this routine never */ +/* reaches its next instruction: the fault goes to EL2, which resumes the */ +/* hypervisor rather than the guest. So SURVIVED being CLEAR afterwards */ +/* is the evidence that the access was stopped. */ +/* */ +/* The bit is cleared on entry rather than assumed clear, because this */ +/* entry point is called more than once per partition -- at the other */ +/* partition's data, and at the ungranted granule next to its own -- and */ +/* a second call inheriting the first call's success would report the */ +/* opposite of the truth. */ +/**************************************************************************/ + + ZX_GUEST_SLOT ZX_GUEST_OFF_PROBE, probe + .global zx_guest_probe + .type zx_guest_probe, %function +zx_guest_probe: + ldr r3, [r0, #ZX_GD_PROGRESS] + orr r3, r3, #ZX_GP_ENTERED + bic r3, r3, #ZX_GP_PROBE_SURVIVED + str r3, [r0, #ZX_GD_PROGRESS] + dsb + + ldr r1, [r0, #ZX_GD_TARGET] + ldr r2, [r1] @ may take a stage-2 fault + + str r2, [r0, #ZX_GD_PROBED] + ldr r3, [r0, #ZX_GD_PROGRESS] + orr r3, r3, #ZX_GP_PROBE_SURVIVED + str r3, [r0, #ZX_GD_PROGRESS] + dsb + hvc #ZX_ASM_HVC_IMM_YIELD + .size zx_guest_probe, . - zx_guest_probe + +/**************************************************************************/ +/* publish -- write the heartbeat into the shared granule. */ +/* */ +/* Only the publishing partition can do this. A reader running the same */ +/* entry point takes a stage-2 PERMISSION fault rather than a region */ +/* miss -- DFSC 0x0C rather than 0x04 -- and telling those two apart is */ +/* how the run shows that the shared granule is read-only to a reader */ +/* rather than simply absent from its map. */ +/**************************************************************************/ + + ZX_GUEST_SLOT ZX_GUEST_OFF_PUBLISH, publish + .global zx_guest_publish + .type zx_guest_publish, %function +zx_guest_publish: + ldr r3, [r0, #ZX_GD_PROGRESS] + orr r3, r3, #ZX_GP_ENTERED + str r3, [r0, #ZX_GD_PROGRESS] + + ldr r1, [r0, #ZX_GD_SHARED] + ldr r2, [r0, #ZX_GD_PUBLISH_VALUE] + str r2, [r1] @ may take a permission fault + dsb + + ldr r3, [r0, #ZX_GD_PROGRESS] + orr r3, r3, #ZX_GP_PUBLISHED + str r3, [r0, #ZX_GD_PROGRESS] + dsb + hvc #ZX_ASM_HVC_IMM_YIELD + .size zx_guest_publish, . - zx_guest_publish + +/**************************************************************************/ +/* consume -- read the shared granule. */ +/* */ +/* Every partition may. What it read is reported through the mailbox so */ +/* that EL2 can check the VALUE, not merely that the read completed: a */ +/* read succeeding against the wrong granule looks identical to one */ +/* succeeding against the right one. */ +/**************************************************************************/ + + ZX_GUEST_SLOT ZX_GUEST_OFF_CONSUME, consume + .global zx_guest_consume + .type zx_guest_consume, %function +zx_guest_consume: + ldr r3, [r0, #ZX_GD_PROGRESS] + orr r3, r3, #ZX_GP_ENTERED + str r3, [r0, #ZX_GD_PROGRESS] + + ldr r1, [r0, #ZX_GD_SHARED] + ldr r2, [r1] @ may take a stage-2 fault + + str r2, [r0, #ZX_GD_PROBED] + ldr r3, [r0, #ZX_GD_PROGRESS] + orr r3, r3, #ZX_GP_CONSUMED + str r3, [r0, #ZX_GD_PROGRESS] + dsb + hvc #ZX_ASM_HVC_IMM_YIELD + .size zx_guest_consume, . - zx_guest_consume + +/* The blob's declared size, checked rather than assumed: the hypervisor + copies exactly this many bytes and the manifest declares an image of + exactly this length, so a blob that outgrew it would be copied + truncated -- which presents as a guest that runs partway. */ + + ZX_GUEST_SLOT ZX_GUEST_BLOB_SIZE, end + .global zx_guest_blob_end +zx_guest_blob_end: diff --git a/examples/common/zx_probe.h b/examples/common/zx_probe.h index 972cdcf..b1619b8 100644 --- a/examples/common/zx_probe.h +++ b/examples/common/zx_probe.h @@ -77,6 +77,22 @@ extern "C" { on the model, where semihosting needs no peripheral; the console on silicon. */ +/* The shared reporting helpers. Not static, because the two-partition + experiment lives in its own translation unit: a single example main + carrying both the stage-2 bring-up and the partition proof would be the + longest file in the repository and the least readable. */ + +void zx_check(const char *label, uint32_t passed); +void zx_note(const char *label, uint32_t value); +ZX_NODISCARD zx_addr_t zx_symbol_address(const char *symbol); +ZX_NODISCARD uint32_t zx_probe_failures(void); +void zx_probe_fail(void); + +/* The two-partition experiment. Runs after the single-payload phases and + reprograms the region set from a manifest, so it must come last. */ + +void zx_phase_two_partitions(uint32_t board_regions, uint32_t el2_regions); + void zx_board_init(void); /* How many EL2 regions the hypervisor's own MMIO needs on this board, and @@ -95,6 +111,15 @@ void zx_board_init(void); ZX_NODISCARD uint32_t zx_board_mmio_region_count(void); void zx_board_program_mmio_regions(uint32_t first_index); +/* The SAME geometry, described rather than programmed, so the manifest + validator can reject a partition window that overlaps the hypervisor's own + console or interrupt controller. Those regions stay enabled while a + partition runs, so an overlap there is two enabled regions on one address + -- CONSTRAINED UNPREDICTABLE, and an abort on the S32Z280. Writes exactly + zx_board_mmio_region_count() entries. */ + +void zx_board_describe_mmio_regions(ZX_REGION *region_ptr); + /* Print whatever the board knows about itself that the architecture cannot report -- which peripheral the console is, which memory the image is in. Called from the identity block. */ @@ -121,6 +146,25 @@ extern char __zx_high_probe_end[]; /* Payload entry points and the words it reports through, from zx_payload.S. */ +/* The two-partition experiment's windows. NOLOAD sections: a partition's + code window has nothing in the ELF to load, because its contents arrive + at run time when the hypervisor copies the guest blob in. */ + +extern char __zx_guest_blob_start[]; +extern char __zx_guest_blob_end[]; +extern char __zx_p0_code_start[]; +extern char __zx_p0_code_end[]; +extern char __zx_p0_data_start[]; +extern char __zx_p0_data_end[]; +extern char __zx_p0_hole_start[]; +extern char __zx_p0_hole_end[]; +extern char __zx_p1_code_start[]; +extern char __zx_p1_code_end[]; +extern char __zx_p1_data_start[]; +extern char __zx_p1_data_end[]; +extern char __zx_shared_start[]; +extern char __zx_shared_end[]; + extern char zx_payload_vectors[]; extern char zx_payload_grant_check[]; extern char zx_payload_hvc_check[]; diff --git a/examples/common/zx_probe_main.c b/examples/common/zx_probe_main.c index fbc228c..804cd92 100644 --- a/examples/common/zx_probe_main.c +++ b/examples/common/zx_probe_main.c @@ -87,7 +87,7 @@ static ZX_REGION zx_high_probe_region; /* zx_symbol_address -- a linker symbol's address as an MPU address. */ /**************************************************************************/ -static zx_addr_t zx_symbol_address(const char *symbol) +zx_addr_t zx_symbol_address(const char *symbol) { return (zx_addr_t)(uintptr_t)symbol; } @@ -100,7 +100,7 @@ static zx_addr_t zx_symbol_address(const char *symbol) /* result is visible as a gap rather than having to be counted. */ /**************************************************************************/ -static void zx_check(const char *label, uint32_t passed) +void zx_check(const char *label, uint32_t passed) { zx_console_puts(" ["); zx_console_puts((passed != 0U) ? "PASS" : "FAIL"); @@ -115,7 +115,19 @@ static void zx_check(const char *label, uint32_t passed) } -static void zx_note(const char *label, uint32_t value) +uint32_t zx_probe_failures(void) +{ + return zx_failures; +} + + +void zx_probe_fail(void) +{ + zx_failures++; +} + + +void zx_note(const char *label, uint32_t value) { zx_console_puts(" "); zx_console_puts(label); @@ -958,6 +970,10 @@ ZX_NORETURN void zx_el2_main(void) zx_phase_hprenr(el2_regions); zx_phase_violation(); + /* Last, because it reprograms the region set from a manifest and the + single-payload phases above depend on the set they were given. */ + zx_phase_two_partitions(board_regions, el2_regions); + #ifdef ZX_PROBE_PROVOKE_EL2_FAULT zx_phase_provoke_el2_fault(); #endif diff --git a/examples/fvp_baser_aemv8r/CMakeLists.txt b/examples/fvp_baser_aemv8r/CMakeLists.txt index 5c5b457..bd3dd8e 100644 --- a/examples/fvp_baser_aemv8r/CMakeLists.txt +++ b/examples/fvp_baser_aemv8r/CMakeLists.txt @@ -62,6 +62,7 @@ endif() function(zx_add_fvp_probe target_name) add_executable(${target_name} EXCLUDE_FROM_ALL ${ZX_COMMON_DIR}/zx_probe_main.c + ${ZX_COMMON_DIR}/zx_partitions.c ${ZX_COMMON_DIR}/zx_payload.S ${ZX_FVP_DIR}/zx_board.c ) @@ -120,6 +121,36 @@ target_compile_definitions(zx_probe_el2_fault.elf PRIVATE zx_add_fvp_probe(zx_probe_starved.elf) target_compile_definitions(zx_probe_starved.elf PRIVATE ZX_PROBE_STARVE_REGIONS) +# The three negative verifications of the two-partition experiment. Each +# breaks exactly one thing, each must report FAILED, and each is a BUILD +# rather than a temporary edit so that "the check has been seen to fail" +# stays reproducible instead of being a sentence in a log. +# +# They are separate images rather than one because they fail at different +# points: two are refused by the validator before a single region is +# programmed, and the third gets all the way to a passing access that should +# have faulted. One image could only demonstrate whichever came first. + +# A region limit one granule too generous, which swallows the ungranted +# granule next door. The adjacent-granule probe must then SURVIVE. This is +# the negative verification of the BOUNDARY check: without it a green run +# shows only that the probe address faults, not that the limit is exact. +zx_add_fvp_probe(zx_probe_widened.elf) +target_compile_definitions(zx_probe_widened.elf PRIVATE ZX_PROBE_WIDEN_REGION) + +# Two partitions whose data windows overlap. The validator must refuse by +# name, before anything is programmed -- two enabled regions on one address +# is CONSTRAINED UNPREDICTABLE and aborts on the S32Z280. +zx_add_fvp_probe(zx_probe_overlap.elf) +target_compile_definitions(zx_probe_overlap.elf PRIVATE ZX_PROBE_MANIFEST_OVERLAP) + +# A region naming an HMAIR index the hypervisor never wrote. The hardware +# will not object: the region gets a zero MAIR byte, which is Device-nGnRnE, +# and works slowly. Only the validator can catch this one, which is exactly +# why it is worth a build of its own. +zx_add_fvp_probe(zx_probe_badattr.elf) +target_compile_definitions(zx_probe_badattr.elf PRIVATE ZX_PROBE_BAD_ATTR) + ############################################################################### # Running on the model ############################################################################### @@ -161,6 +192,18 @@ if(ZX_FVP_BASER_AEMV8R) --fvp ${ZX_FVP_BASER_AEMV8R} --expect fail) + # The two-partition experiment's three negative verifications. Same + # rule as above: each must report FAILED, and each starting to PASS + # means the check it was built to violate has stopped detecting the + # violation. + foreach(zx_negative widened overlap badattr) + add_test(NAME zx-fvp-probe-${zx_negative} + COMMAND ${Python3_EXECUTABLE} ${ZX_FVP_RUNNER} + --elf $ + --fvp ${ZX_FVP_BASER_AEMV8R} + --expect fail) + endforeach() + add_custom_target(zx-run-probe COMMAND ${ZX_FVP_BASER_AEMV8R} -C cluster0.NUM_CORES=1 diff --git a/examples/fvp_baser_aemv8r/zx_board.c b/examples/fvp_baser_aemv8r/zx_board.c index 904bf44..969bc2d 100644 --- a/examples/fvp_baser_aemv8r/zx_board.c +++ b/examples/fvp_baser_aemv8r/zx_board.c @@ -99,3 +99,20 @@ void zx_board_report(void) " and each costs an EL2 region. This model therefore\n" " cannot show you that those regions are needed.\n"); } + + +/**************************************************************************/ +/* zx_board_describe_mmio_regions */ +/* */ +/* Nothing to describe: this model's console and GIC already sit in the */ +/* Device band of the background map, so the hypervisor spends no region */ +/* on them and there is no geometry a partition window could collide */ +/* with. The parameter is unused for exactly that reason, and the */ +/* function exists anyway so that the shared example code does not need */ +/* to know which kind of board it is running on. */ +/**************************************************************************/ + +void zx_board_describe_mmio_regions(ZX_REGION *region_ptr) +{ + (void)region_ptr; +} diff --git a/examples/fvp_baser_aemv8r/zx_link.lds b/examples/fvp_baser_aemv8r/zx_link.lds index 2234391..c21c5fc 100644 --- a/examples/fvp_baser_aemv8r/zx_link.lds +++ b/examples/fvp_baser_aemv8r/zx_link.lds @@ -67,6 +67,13 @@ ENTRY(_start) __zx_hyp_stack_size = 0x1000; +/* One partition window each. The code window has to hold the guest blob + with room to grow; the data window holds the hypervisor-to-guest mailbox + and nothing else, so one granule would do and four give it slack. Both + are whole granules because both are MPU regions. */ +__zx_window_code_size = 0x400; +__zx_window_data_size = 0x100; + MEMORY { DRAM (rwx) : ORIGIN = 0x00000000, LENGTH = 0x08000000 /* 128 MB */ @@ -171,6 +178,81 @@ SECTIONS __zx_high_probe_end = .; } > DRAM + + /* ------------------------------------------------------------------ + THE TWO PARTITION WINDOWS. + + .zx_guest_blob is the copy SOURCE: it lives in the hypervisor's own + memory, covered by no region, and is copied into each partition's + code window at boot. That is the path a real guest image takes, and + it is why a partition's code window is NOLOAD -- there is nothing in + the ELF to load there, the contents arrive at run time. + + The ORDER of what follows is load-bearing in one place: the ungranted + granule sits IMMEDIATELY AFTER partition 0's data window, adjacent by + construction. An ungranted address far from every grant proves only + that unmapped memory faults; the defect worth catching is a region + whose limit is out by one granule, and only an adjacent hole catches + it. The ASSERT below is what keeps a later edit from separating + them. + ------------------------------------------------------------------ */ + + .zx_guest_blob : ALIGN(64) + { + __zx_guest_blob_start = .; + KEEP(*(.zx_guest_blob)) + . = ALIGN(64); + __zx_guest_blob_end = .; + } > DRAM + + .zx_p0_code (NOLOAD) : ALIGN(64) + { + __zx_p0_code_start = .; + . = . + __zx_window_code_size; + __zx_p0_code_end = .; + } > DRAM + + .zx_p0_data (NOLOAD) : ALIGN(64) + { + __zx_p0_data_start = .; + . = . + __zx_window_data_size; + __zx_p0_data_end = .; + } > DRAM + + /* One granule, granted to nobody. Adjacent to partition 0's data. */ + + .zx_p0_hole (NOLOAD) : ALIGN(64) + { + __zx_p0_hole_start = .; + . = . + 64; + __zx_p0_hole_end = .; + } > DRAM + + .zx_p1_code (NOLOAD) : ALIGN(64) + { + __zx_p1_code_start = .; + . = . + __zx_window_code_size; + __zx_p1_code_end = .; + } > DRAM + + .zx_p1_data (NOLOAD) : ALIGN(64) + { + __zx_p1_data_start = .; + . = . + __zx_window_data_size; + __zx_p1_data_end = .; + } > DRAM + + /* The one granule both partitions can see: written by the publisher, + read-only to everyone else. Its own section so that the two regions + covering it -- one per partition -- describe exactly the same range, + which is what the validator's shared-range rule requires. */ + + .zx_shared (NOLOAD) : ALIGN(64) + { + __zx_shared_start = .; + . = . + 64; + __zx_shared_end = .; + } > DRAM . = ALIGN(8); _end = .; PROVIDE(end = .); @@ -195,4 +277,34 @@ SECTIONS "the high-probe region base is not 64-byte aligned") ASSERT(__zx_high_probe_start >= __zx_forbidden_end, "the high-probe region overlaps the ungranted granule") + + /* The two-partition geometry. Each of these would otherwise be a + run-time mystery: an under-aligned window base does not fault, it + lands on SH, AP and XN and silently changes the region's + attributes. */ + + ASSERT((__zx_guest_blob_start & 63) == 0, + "the guest blob is not 64-byte aligned") + ASSERT((__zx_p0_code_start & 63) == 0, + "partition 0's code window base is not 64-byte aligned") + ASSERT((__zx_p0_data_start & 63) == 0, + "partition 0's data window base is not 64-byte aligned") + ASSERT((__zx_p1_code_start & 63) == 0, + "partition 1's code window base is not 64-byte aligned") + ASSERT((__zx_p1_data_start & 63) == 0, + "partition 1's data window base is not 64-byte aligned") + ASSERT((__zx_shared_start & 63) == 0, + "the shared granule is not 64-byte aligned") + ASSERT((__zx_shared_end - __zx_shared_start) == 64, + "the shared area is not exactly one granule") + + ASSERT(__zx_p0_hole_start == __zx_p0_data_end, + "the ungranted granule is NOT adjacent to partition 0's data window") + ASSERT(__zx_p1_code_start == __zx_p0_hole_end, + "something was placed between the ungranted granule and partition 1") + + ASSERT((__zx_guest_blob_end - __zx_guest_blob_start) <= __zx_window_code_size, + "the guest blob does not fit a partition's code window") + ASSERT(__zx_window_data_size >= 64, + "a partition data window must be at least one granule") } diff --git a/examples/fvp_baser_aemv8r/zx_platform.h b/examples/fvp_baser_aemv8r/zx_platform.h index 42a7972..a7f9726 100644 --- a/examples/fvp_baser_aemv8r/zx_platform.h +++ b/examples/fvp_baser_aemv8r/zx_platform.h @@ -79,4 +79,11 @@ #define ZX_FVP_DRAM_BASE 0x00000000UL #define ZX_FVP_DRAM_SIZE 0x08000000UL /* 128 MB */ + +/* How many hypervisor MMIO region descriptors the example has to be able + to hold. This model needs none, and a zero-length array is not valid C -- one + entry that nothing writes costs 12 bytes and keeps the shared example + code free of a board conditional. */ +#define ZX_BOARD_MAX_MMIO_REGIONS 1U + #endif /* ZX_PLATFORM_H */ diff --git a/examples/s32z280_evb/CMakeLists.txt b/examples/s32z280_evb/CMakeLists.txt index a73804b..a6b45f7 100644 --- a/examples/s32z280_evb/CMakeLists.txt +++ b/examples/s32z280_evb/CMakeLists.txt @@ -49,6 +49,7 @@ endif() function(zx_add_s32z_probe target_name) add_executable(${target_name} EXCLUDE_FROM_ALL ${ZX_COMMON_DIR}/zx_probe_main.c + ${ZX_COMMON_DIR}/zx_partitions.c ${ZX_COMMON_DIR}/zx_payload.S ${ZX_S32Z_DIR}/zx_board.c ) diff --git a/examples/s32z280_evb/zx_board.c b/examples/s32z280_evb/zx_board.c index b12a42e..d74f005 100644 --- a/examples/s32z280_evb/zx_board.c +++ b/examples/s32z280_evb/zx_board.c @@ -343,3 +343,39 @@ void zx_board_report(void) " costs a Device-attributed EL2 region out of this part's\n" " 20 -- the constraint the FVP cannot show you.\n"); } + + +/**************************************************************************/ +/* zx_board_describe_mmio_regions */ +/* */ +/* The same two regions zx_board_program_mmio_regions writes, described */ +/* so the validator can see them. The duplication is deliberate and */ +/* bounded: the programmer runs before the console exists and cannot */ +/* report anything, while the validator runs later and needs the */ +/* geometry as data. Both read the same ZX_S32Z_* constants, so the one */ +/* thing that could drift -- the addresses -- cannot. */ +/**************************************************************************/ + +void zx_board_describe_mmio_regions(ZX_REGION *region_ptr) +{ + if (region_ptr == (ZX_REGION *)0) + { + return; + } + + region_ptr[0].zx_region_base = (zx_addr_t)ZX_S32Z_LINFLEX_9_BASE; + region_ptr[0].zx_region_limit = (zx_addr_t)((ZX_S32Z_LINFLEX_9_BASE + + ZX_S32Z_LINFLEX_9_SIZE) - 1UL); + region_ptr[0].zx_region_ap = ZX_AP_EL2_RW_GUEST_NONE; + region_ptr[0].zx_region_xn = ZX_XN_NEVER; + region_ptr[0].zx_region_sh = ZX_SH_NON_SHAREABLE; + region_ptr[0].zx_region_attr_index = (UCHAR)ZX_ATTR_DEVICE; + + region_ptr[1].zx_region_base = (zx_addr_t)ZX_S32Z_GIC_BASE; + region_ptr[1].zx_region_limit = (zx_addr_t)((ZX_S32Z_GIC_BASE + + ZX_S32Z_GIC_SIZE) - 1UL); + region_ptr[1].zx_region_ap = ZX_AP_EL2_RW_GUEST_NONE; + region_ptr[1].zx_region_xn = ZX_XN_NEVER; + region_ptr[1].zx_region_sh = ZX_SH_NON_SHAREABLE; + region_ptr[1].zx_region_attr_index = (UCHAR)ZX_ATTR_DEVICE; +} diff --git a/examples/s32z280_evb/zx_link.lds b/examples/s32z280_evb/zx_link.lds index c8c3e63..a880c85 100644 --- a/examples/s32z280_evb/zx_link.lds +++ b/examples/s32z280_evb/zx_link.lds @@ -73,6 +73,13 @@ ENTRY(_start) __zx_hyp_stack_size = 0x1000; +/* One partition window each. The code window has to hold the guest blob + with room to grow; the data window holds the hypervisor-to-guest mailbox + and nothing else, so one granule would do and four give it slack. Both + are whole granules because both are MPU regions. */ +__zx_window_code_size = 0x400; +__zx_window_data_size = 0x100; + MEMORY { CODE (rx) : ORIGIN = 0x79900000, LENGTH = 0x00700000 /* 7 MB */ @@ -179,6 +186,81 @@ SECTIONS __zx_high_probe_end = .; } > DATA + + /* ------------------------------------------------------------------ + THE TWO PARTITION WINDOWS. + + .zx_guest_blob is the copy SOURCE: it lives in the hypervisor's own + memory, covered by no region, and is copied into each partition's + code window at boot. That is the path a real guest image takes, and + it is why a partition's code window is NOLOAD -- there is nothing in + the ELF to load there, the contents arrive at run time. + + The ORDER of what follows is load-bearing in one place: the ungranted + granule sits IMMEDIATELY AFTER partition 0's data window, adjacent by + construction. An ungranted address far from every grant proves only + that unmapped memory faults; the defect worth catching is a region + whose limit is out by one granule, and only an adjacent hole catches + it. The ASSERT below is what keeps a later edit from separating + them. + ------------------------------------------------------------------ */ + + .zx_guest_blob : ALIGN(64) + { + __zx_guest_blob_start = .; + KEEP(*(.zx_guest_blob)) + . = ALIGN(64); + __zx_guest_blob_end = .; + } > DATA + + .zx_p0_code (NOLOAD) : ALIGN(64) + { + __zx_p0_code_start = .; + . = . + __zx_window_code_size; + __zx_p0_code_end = .; + } > DATA + + .zx_p0_data (NOLOAD) : ALIGN(64) + { + __zx_p0_data_start = .; + . = . + __zx_window_data_size; + __zx_p0_data_end = .; + } > DATA + + /* One granule, granted to nobody. Adjacent to partition 0's data. */ + + .zx_p0_hole (NOLOAD) : ALIGN(64) + { + __zx_p0_hole_start = .; + . = . + 64; + __zx_p0_hole_end = .; + } > DATA + + .zx_p1_code (NOLOAD) : ALIGN(64) + { + __zx_p1_code_start = .; + . = . + __zx_window_code_size; + __zx_p1_code_end = .; + } > DATA + + .zx_p1_data (NOLOAD) : ALIGN(64) + { + __zx_p1_data_start = .; + . = . + __zx_window_data_size; + __zx_p1_data_end = .; + } > DATA + + /* The one granule both partitions can see: written by the publisher, + read-only to everyone else. Its own section so that the two regions + covering it -- one per partition -- describe exactly the same range, + which is what the validator's shared-range rule requires. */ + + .zx_shared (NOLOAD) : ALIGN(64) + { + __zx_shared_start = .; + . = . + 64; + __zx_shared_end = .; + } > DATA . = ALIGN(8); PROVIDE(end = .); PROVIDE(_end = .); @@ -203,4 +285,34 @@ SECTIONS "the high-probe region base is not 64-byte aligned") ASSERT(__zx_high_probe_start >= __zx_forbidden_end, "the high-probe region overlaps the ungranted granule") + + /* The two-partition geometry. Each of these would otherwise be a + run-time mystery: an under-aligned window base does not fault, it + lands on SH, AP and XN and silently changes the region's + attributes. */ + + ASSERT((__zx_guest_blob_start & 63) == 0, + "the guest blob is not 64-byte aligned") + ASSERT((__zx_p0_code_start & 63) == 0, + "partition 0's code window base is not 64-byte aligned") + ASSERT((__zx_p0_data_start & 63) == 0, + "partition 0's data window base is not 64-byte aligned") + ASSERT((__zx_p1_code_start & 63) == 0, + "partition 1's code window base is not 64-byte aligned") + ASSERT((__zx_p1_data_start & 63) == 0, + "partition 1's data window base is not 64-byte aligned") + ASSERT((__zx_shared_start & 63) == 0, + "the shared granule is not 64-byte aligned") + ASSERT((__zx_shared_end - __zx_shared_start) == 64, + "the shared area is not exactly one granule") + + ASSERT(__zx_p0_hole_start == __zx_p0_data_end, + "the ungranted granule is NOT adjacent to partition 0's data window") + ASSERT(__zx_p1_code_start == __zx_p0_hole_end, + "something was placed between the ungranted granule and partition 1") + + ASSERT((__zx_guest_blob_end - __zx_guest_blob_start) <= __zx_window_code_size, + "the guest blob does not fit a partition's code window") + ASSERT(__zx_window_data_size >= 64, + "a partition data window must be at least one granule") } diff --git a/examples/s32z280_evb/zx_platform.h b/examples/s32z280_evb/zx_platform.h index ac54094..7293f28 100644 --- a/examples/s32z280_evb/zx_platform.h +++ b/examples/s32z280_evb/zx_platform.h @@ -117,4 +117,9 @@ #define ZX_REG32(address) (*(volatile uint32_t *)(uintptr_t)(address)) + +/* How many hypervisor MMIO region descriptors the example has to be able + to hold. LINFlexD_9 and the GIC. Must match zx_board_mmio_region_count(). */ +#define ZX_BOARD_MAX_MMIO_REGIONS 2U + #endif /* ZX_PLATFORM_H */ diff --git a/platform/cortex_r52/inc/zx_port.h b/platform/cortex_r52/inc/zx_port.h index 12b7e31..b86cc75 100644 --- a/platform/cortex_r52/inc/zx_port.h +++ b/platform/cortex_r52/inc/zx_port.h @@ -445,6 +445,34 @@ void zx_hprenr_disable(uint32_t index); void zx_stage2_enable_set(uint32_t mask); +/**************************************************************************/ +/* The PMU cycle counter */ +/**************************************************************************/ + +/* Turns on PMCCNTR so that a partition switch can be MEASURED rather than + estimated. + * + * This exists because the generic timer is not usable as a clock here: + * CNTFRQ reads zero on both targets, so there is no frequency to convert + * with and nothing has programmed one. The PMU cycle counter needs no + * frequency -- the unit that matters for a WCET argument is cycles, not + * seconds. + * + * PMCCFILTR matters and is easy to miss: the cycle counter has per-mode + * enables, and code running in Hyp mode is not counted unless the Hyp bit + * is set. A missing one presents as a counter that reads zero or never + * advances, which looks exactly like a PMU that is not implemented. */ + +void zx_pmu_enable(void); + +ZX_NODISCARD uint32_t zx_pmu_cycles(void); + +/* Non-zero when the counter actually advances. Worth asking rather than + assuming: a measurement taken from a counter that is not running reports + zero cycles for everything, which is a very convincing wrong answer. */ + +ZX_NODISCARD uint32_t zx_pmu_is_running(void); + /* Turning protection on is TWO steps, and separating them is not tidiness. zx_el2_mpu_enable sets HSCTLR.BR and HSCTLR.M: the EL2-controlled MPU, diff --git a/platform/cortex_r52/src/zx_stage2_mpu.c b/platform/cortex_r52/src/zx_stage2_mpu.c index 3b8cbdc..ee23f2c 100644 --- a/platform/cortex_r52/src/zx_stage2_mpu.c +++ b/platform/cortex_r52/src/zx_stage2_mpu.c @@ -632,3 +632,80 @@ void zx_stage2_disable(void) __asm__ volatile("mcr p15, 4, %0, c1, c0, 0" : : "r"(hsctlr) : "memory"); __asm__ volatile("isb"); } + + +/**************************************************************************/ +/* The PMU cycle counter */ +/* */ +/* Encodings, AArch32 PMU (not Hyp-banked -- opc1 = 0, reachable from */ +/* any privileged level): */ +/* */ +/* PMCR p15, 0, c9, c12, 0 E[0] enable, C[2] reset cycles, */ +/* D[3] divide-by-64 */ +/* PMCNTENSET p15, 0, c9, c12, 1 bit 31 is the cycle counter */ +/* PMCCNTR p15, 0, c9, c13, 0 the counter itself */ +/* PMCCFILTR p15, 0, c14, c15, 7 which modes are counted */ +/* */ +/* PMCCFILTR is under CRn = c14, NOT c9 with the rest of the PMU. It is */ +/* the one PMU register that moved when the direct event-register */ +/* encodings arrived, and writing it at c9, c14, 7 -- which is PMUSERENR */ +/* territory -- is UNDEFINED. Cost here: an undefined-instruction */ +/* exception at EL2 on the first PMU write, reported through the vector */ +/* rather than HSR because +0x04 carries no syndrome. TRM Table 3-15. */ +/* */ +/* D is left CLEAR so the counter runs 1:1. Divide-by-64 would make a */ +/* partition switch measure as a handful of ticks, and a WCET number */ +/* quantised to 64 cycles is not a WCET number. */ +/**************************************************************************/ + +void zx_pmu_enable(void) +{ + uint32_t value; + + /* PMCCFILTR FIRST, and this is the step that is easy to miss. The + cycle counter has per-mode enables and Hyp mode is NOT counted by + default, so a counter enabled without this reads zero forever -- + indistinguishable from a part with no PMU. Bit 27 (NSH) enables + counting in Non-secure Hyp mode, which is where every instruction + ZoneX measures actually executes. */ + value = ZX_BIT(27); + __asm__ volatile("mcr p15, 0, %0, c14, c15, 7" : : "r"(value) : "memory"); + + /* Enable the cycle counter in PMCNTENSET before PMCR.E, so that + enabling the unit does not briefly run with an unknown counter set. */ + value = ZX_BIT(31); + __asm__ volatile("mcr p15, 0, %0, c9, c12, 1" : : "r"(value) : "memory"); + + /* E and C together: enable, and reset the cycle counter to zero. */ + __asm__ volatile("mrc p15, 0, %0, c9, c12, 0" : "=r"(value)); + value |= (ZX_BIT(0) | ZX_BIT(2)); + value &= ~(uint32_t)ZX_BIT(3); + __asm__ volatile("mcr p15, 0, %0, c9, c12, 0" : : "r"(value) : "memory"); + + __asm__ volatile("isb"); +} + + +uint32_t zx_pmu_cycles(void) +{ + uint32_t value; + + /* ISB before the read, or the read can be reordered ahead of the code + being measured and the interval comes out short -- or negative, which + at least announces itself. */ + __asm__ volatile("isb"); + __asm__ volatile("mrc p15, 0, %0, c9, c13, 0" : "=r"(value)); + return value; +} + + +uint32_t zx_pmu_is_running(void) +{ + uint32_t first; + uint32_t second; + + first = zx_pmu_cycles(); + second = zx_pmu_cycles(); + + return (second != first) ? 1U : 0U; +} From 9c6bc39db5f7020d126e00a8d67bcd63a2002e1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 2 Sep 2026 12:49:43 -0400 Subject: [PATCH 10/40] Recorded the PMU encodings, the mailbox and the switch cost The reference sheet gains the PMU cycle counter, with the encoding trap that cost a run: PMCCFILTR is under CRn = c14, not c9 with the rest of the PMU, and writing it at the plausible c9, c14, 7 is UNDEFINED. It also decides whether Hyp mode is counted at all -- the TRM defers the bit layout to the architecture, and without bit 27 the counter reads zero forever, which is indistinguishable from a part that has no PMU. The hypervisor-to-guest mailbox is documented as a convention rather than left to be read out of the example: it exists because a guest excursion carries one argument while a probe needs both a target and somewhere to report, and it is free because stage-2 AP cannot deny EL2. The same property that stops AP from isolating partitions is what lets the hypervisor write into a partition's memory for nothing. D4 is settled on mechanism. The cost ratio that the preference rested on is measured -- 13 cycles for a mask switch against 54 for one region descriptor write -- and the mask's cost, unlike a block rewrite's, does not grow with the incoming partition's region count, which is the property a WCET argument cannot do without. It also records why the direct encodings above index 15 stay ungeneralised: they would speed up an operation that happens once. Both sets of figures are labelled as the model's. A functional model does not model timing, so the numbers to quote still have to come from the part; the measurement path is in the image and runs on every target, so that closes with a board session rather than with new code. Assisted-by: Claude Code (Opus 5) --- docs/armv8r-el2-reference.md | 71 ++++++++++++++++++++++++++++++++++++ docs/decisions.md | 37 +++++++++++++++---- 2 files changed, 100 insertions(+), 8 deletions(-) diff --git a/docs/armv8r-el2-reference.md b/docs/armv8r-el2-reference.md index 344e285..ac3d584 100644 --- a/docs/armv8r-el2-reference.md +++ b/docs/armv8r-el2-reference.md @@ -503,6 +503,77 @@ See `docs/decisions.md` D2 and D15. --- +## The PMU cycle counter, and the one register that is not where you expect + +`CNTFRQ` reads zero on both targets, so the generic timer cannot be used as a +clock. `PMCCNTR` needs no frequency, and cycles are the unit a WCET argument is +made in anyway. + +| Register | Encoding | Note | +|---|---|---| +| `PMCR` | `p15, 0, c9, c12, 0` | `E[0]` enable, `C[2]` reset cycles, `D[3]` divide-by-64 — leave `D` clear | +| `PMCNTENSET` | `p15, 0, c9, c12, 1` | bit 31 is the cycle counter | +| `PMCCNTR` | `p15, 0, c9, c13, 0` | the counter | +| **`PMCCFILTR`** | **`p15, 0, c14, c15, 7`** | **CRn = c14, not c9** — TRM Table 3-15 | + +⚠ **`PMCCFILTR` is the trap.** Every other PMU register in this list is under +`c9`; `PMCCFILTR` sits under `c14` with the direct event-register encodings. +Writing it at `c9, c14, 7` — the plausible guess, and `PMUSERENR`'s +neighbourhood — is UNDEFINED. Measured cost: an undefined-instruction +exception at EL2 on the first PMU write, arriving at `HVBAR + 0x04`, which +carries no syndrome, so `HSR` was stale and only the vector identified it. + +It also decides whether Hyp mode is counted at all. The TRM defers the bit +layout to the architecture, as it does for the data-abort `ISS`; bit 27 +(`NSH`) is what makes the counter advance in the mode ZoneX actually executes +in. A counter enabled without it reads zero forever, which is +indistinguishable from a part with no PMU — so ZoneX asks whether the counter +is advancing before it times anything. + +**Measured, 2 September 2026, Armv8-R AEM FVP:** + +| | cycles | +|---|---| +| counter-read overhead | 5 | +| `HPRENR` mask switch (a partition switch) | 13 | +| one region descriptor write | 54 | + +**These are model figures and are not timing.** The FVP is a functional model. +They are reported because the *ratio* is the design argument — a mask switch +against a per-region write — and because printing them on every run keeps the +measurement path exercised. The numbers to quote come from silicon. + +--- + +## The hypervisor-to-guest mailbox + +A partition's data window opens with a fixed block of words the hypervisor +writes and the guest reads back, declared in `examples/common/zx_guest_abi.h`. +It exists because a guest excursion carries exactly one argument while a probe +needs both somewhere to report and something to aim at. + +It is free because **stage-2 `AP` cannot deny EL2**: the same property that +stops `AP` from isolating one partition from another is what lets the +hypervisor write into a partition's memory without spending a region or a +permission on it. + +| Offset | Word | Written by | +|---|---|---| +| `0x00` | progress bits | guest | +| `0x04` | the guest's own sentinel | guest | +| `0x08` | address to probe | EL2 | +| `0x0C` | what the guest read | guest | +| `0x10` | address of the shared granule | EL2 | +| `0x14` | value to publish, or the sentinel to use | EL2 | + +The progress word carries the isolation claim **negatively**: the +probe-survived bit being *clear* after an excursion is the evidence that +stage 2 stopped the access. A check whose pass condition is the absence of +something has to be able to fail, which is what the widened-region build +exists to show. + +--- + ## Sources | Document | Identifier | diff --git a/docs/decisions.md b/docs/decisions.md index da2ab57..9e9f61a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -158,10 +158,12 @@ before PMSAv8-R's lack of region priority rules it out. --- -## D4 — How the region set is switched · **mechanism proven; cost still open** +## D4 — How the region set is switched · **settled 2 Sep 2026; cost measured on the model, silicon pending** -**Preferred: program every partition's regions once at boot and switch with a -single `HPRENR` write.** Fall back to rewriting the region block directly. +**One `HPRENR` mask write.** Region descriptors are programmed once at boot, +each partition owns a fixed block of indices, and a switch changes which +blocks are enabled. Rewriting the region block directly remains the fallback +and is no longer expected to be needed. *Updated 2 September 2026: the two facts this preference depended on are now measured, and both came out in its favour.* @@ -181,11 +183,30 @@ selection register. So the fallback does not carry the penalty measured at EL1 during the Cortex-R52 Modules port work for a region ≥ 16 — 542–604 cycles against 434–470 for a direct write. The whole budget costs the same per region. -**What is still open is the COST of the `HPRENR` write itself**, and the budget -arithmetic: 20 regions on the S32Z280, minus two for the hypervisor's own MMIO -(D2), leaves 18 for every partition and guest. That is a -worst-case-execution-time decision, so it stays open until it is measured on -silicon rather than argued now. +**The cost ratio is now measured, and it is what the design rested on.** On +the model a mask switch is 13 cycles against 54 for a single region descriptor +write, so a block rewrite at three regions per partition would cost roughly +twelve times the mask — and unlike the mask, its cost would grow with the +incoming partition's region count, which is the property a WCET argument +cannot have. + +**This is why the direct encodings above index 15 are not generalised.** They +work, proven on both parts, but they would speed up an operation that happens +once at boot. Unrolling them means roughly a hundred inline `MCR`/`MRC` +statements, because coprocessor register numbers must be compile-time +constants. That cost buys nothing while the switch is a mask. + +**Still open: the figures above are the model's**, and a functional model does +not model timing. The number a safety customer is quoted has to come from the +part. The measurement path itself is in the image and runs on every target, so +this closes with a board session rather than with new code. + +The budget arithmetic is unchanged: 20 regions on the S32Z280, minus two for +the hypervisor's own MMIO (D2), leaves 18 for every partition and guest. + +The one case that would reopen the mechanism is two partitions needing +different *attributes* on one address, which no manifest the validator accepts +can ask for today. --- From 1504c7ce33f3a6f41bf6c35c4b7400904856e67f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 2 Sep 2026 16:28:03 -0400 Subject: [PATCH 11/40] Booted a real ThreadX kernel inside a stage-2 partition Everything before this ran a payload written to be run: a few dozen instructions with no kernel, no stack use worth the name and no vectors of its own. This loads a whole ThreadX, which is the difference between "stage 2 works" and "a hypervisor works". A REAL, UNMODIFIED KERNEL. The sources and the Cortex-R52 port are built exactly as they are standalone. What differs is one build option the port has carried since it was written -- for the case its own comment describes, "a vendor EL2 monitor has already dropped privilege to EL1" -- a linker script describing one partition window, and a board file with no devices in it. On the model that is the whole story: the guest builds from unmodified upstream sources, which is why CI can pin a public commit and boot a kernel on every pull request. WHAT THE GUEST PROVES, and each milestone is one the previous cannot fake: it reaches bsp_main, so the ERET landed; tx_application_define runs, so the kernel initialised inside a confined window; two threads take slices, so the ported context switch works underneath stage 2; a queue carries every message CHECKED BY VALUE AND IN ORDER, because four deliveries of the wrong thing would satisfy a count; and a semaphore grants once and refuses the second get. The hypervisor reads all of it out of the guest's own memory afterwards rather than trusting anything the guest printed. THE READBACK IS SEALED, which was the open question and is now a structure rather than a word. A single progress word cannot tell three failures apart: a window nobody wrote reads as zero and zero is plausible; a guest torn off mid-update leaves words individually valid and jointly nonsense; and a report read from the wrong partition's window looks exactly like a report. So the guest writes every field, then a sequence, then a checksum with the HYPERVISOR'S OWN SENTINEL folded in -- and a report can be attributed rather than merely found. AN HVC CONSOLE, settling the open half of D8. One hypercall per character, and the hypervisor tags each line with the partition it SCHEDULED, so a guest cannot claim to be another one. That last part is free and it is what a safety reviewer asks about. The tagging rules are text, so they are asserted against a capture buffer in the host suite: one tag per line and no more, no dangling tag after a final newline, a partial line closed before the hypervisor speaks, and a guest's forged tag appearing as ordinary text inside a correctly attributed line. THE TWO STAGES ARE DISTINGUISHABLE, and by construction rather than by decode. A stage-2 violation is taken to EL2 and reported there; a stage-1 fault is taken to EL1 and reaches the hypervisor only because the guest's own vector records it and hands control back. Different code, different privilege levels. Both are demonstrated, and the demonstration of the first needed a fact worth writing down: both stages are checked and the stricter wins, so an address outside the guest's own EL1 regions is denied by STAGE 1 first. A guest whose own MPU stops at its window boundary cannot show that stage 2 stops anything. It has to grant itself the granule -- its own MPU then claiming memory the manifest never gave it -- and stage 2 refuses anyway. ONE GUEST IMAGE SERVES EVERY CASE. The hypervisor selects between a stage-2 violation, a stage-1 fault and an access that is genuinely permitted by writing an ADDRESS into the mailbox, not by building a different guest. A binary per case is a set of binaries that can drift apart, and the one demonstrating isolation would stop being the one demonstrating that the kernel runs. The permitted-address build must report FAILED, and its failing is what makes the other two evidence. A GUEST DECLARES THE WINDOW IT WAS BUILT FOR, because a guest linked for the wrong one STARTS. Its entry is a PC-relative branch that survives being copied anywhere; every other absolute address in it is baked in. So the failure is not a guest that will not run -- it is one that boots, schedules, and then faults at an address that looks perfectly reasonable in the report, with nothing anywhere saying the image was built for somewhere else. Three words in the image and one comparison in the loader turn that into a refusal. The magic among them earns its four bytes twice: an .incbin whose file is missing, and a linker pattern that matches nothing, both produce an EMPTY section rather than an error. The loader splits the way the region planner already does: core/ decides where the image goes and holds the state machine, and neither copies a byte nor touches a register, so all of it is reachable from a workstation. It joins the coverage floor at 100% on lines and branches, along with the guest console. The manifest validator gains a rule for an image of zero length, and its size rule is tightened to measure the window the ENTRY is in rather than any executable region -- the weaker form passes a manifest whose copy then overruns into a neighbour. MEASURED ON SILICON, and two corrections were needed before the numbers meant anything. Stage 2 costs a warmed guest nothing resolvable: 27,052 cycles against 27,154 with HCR.VM clear, agreeing to 0.4%. Getting there needed the console suppressed -- one trap per character is 5,051,788 cycles against 27,052, so a loud run can only bound the cost from above -- and a warm-up excursion discarded, because measuring only two passes made stage 2 look 28% FASTER. A hypervisor does not give cycles back; the pair differed in ORDER as well as in HCR.VM, and on a real core the first execution of anything is not the second. D4's carried figure also comes off the part: 235 cycles for the HPRENR mask switch against 472 for one region descriptor write. The decision is unchanged and its margin is not -- the model's ratio was 4.2 and silicon's is 2.0, so a per-region cost extrapolated from the model would have been optimistic by half. What the design actually rests on is untouched: the mask does not grow with the incoming partition's region count. Green on both targets: ten FVP images through CTest, six host suites, and all four one-partition images run on a S32Z280-594EVB with the negative one reporting its expected failure. Both toolchains. Assisted-by: Claude Code (Opus 5) --- .github/workflows/zx_fvp.yml | 76 +- core/CMakeLists.txt | 2 + core/inc/zx_fault.h | 22 +- core/inc/zx_guest_console.h | 123 ++ core/inc/zx_manifest.h | 1 + core/inc/zx_partition.h | 185 ++- core/src/zx_guest_console.c | 266 ++++ core/src/zx_manifest_verify.c | 76 +- core/src/zx_partition_manager.c | 459 ++++++- docs/armv8r-el2-reference.md | 201 ++- docs/decisions.md | 281 +++- examples/common/zx_guest_abi.h | 326 ++++- examples/common/zx_guest_image.S | 60 + examples/common/zx_one_partition.c | 1126 +++++++++++++++++ examples/common/zx_probe.h | 12 + examples/common/zx_probe_main.c | 71 +- examples/common/zx_report.c | 107 ++ examples/fvp_baser_aemv8r/CMakeLists.txt | 191 +++ .../fvp_baser_aemv8r/guest_a/CMakeLists.txt | 211 +++ .../guest_a/link_partition_a.lds | 282 +++++ examples/fvp_baser_aemv8r/zx_board.c | 23 + .../fvp_baser_aemv8r/zx_one_partition.lds | 225 ++++ examples/guest_common/zx_guest_app.c | 460 +++++++ examples/guest_common/zx_guest_bsp.c | 610 +++++++++ examples/guest_common/zx_guest_bsp.h | 167 +++ examples/guest_common/zx_guest_head.S | 264 ++++ examples/s32z280_evb/CMakeLists.txt | 114 ++ examples/s32z280_evb/guest_a/CMakeLists.txt | 220 ++++ .../s32z280_evb/guest_a/link_partition_a.lds | 211 +++ examples/s32z280_evb/tools/run_zx_probe.gdb | 144 ++- examples/s32z280_evb/zx_board.c | 14 + examples/s32z280_evb/zx_one_partition.lds | 256 ++++ examples/s32z280_evb/zx_platform.h | 19 + platform/cortex_r52/CMakeLists.txt | 1 + platform/cortex_r52/inc/zx_port.h | 48 + platform/cortex_r52/src/zx_cache.c | 259 ++++ platform/cortex_r52/src/zx_stage2_mpu.c | 2 + platform/cortex_r52/src/zx_timer.c | 77 +- platform/cortex_r52/src/zx_trap_handler.S | 54 +- test/fvp/run.sh | 15 + test/host/CMakeLists.txt | 2 + test/host/run.sh | 27 +- test/host/test_zx_guest_console.c | 221 ++++ test/host/test_zx_manifest.c | 45 + test/host/test_zx_partition.c | 495 ++++++++ test/s32z280/run.sh | 15 +- 46 files changed, 7899 insertions(+), 167 deletions(-) create mode 100644 core/inc/zx_guest_console.h create mode 100644 core/src/zx_guest_console.c create mode 100644 examples/common/zx_guest_image.S create mode 100644 examples/common/zx_one_partition.c create mode 100644 examples/common/zx_report.c create mode 100644 examples/fvp_baser_aemv8r/guest_a/CMakeLists.txt create mode 100644 examples/fvp_baser_aemv8r/guest_a/link_partition_a.lds create mode 100644 examples/fvp_baser_aemv8r/zx_one_partition.lds create mode 100644 examples/guest_common/zx_guest_app.c create mode 100644 examples/guest_common/zx_guest_bsp.c create mode 100644 examples/guest_common/zx_guest_bsp.h create mode 100644 examples/guest_common/zx_guest_head.S create mode 100644 examples/s32z280_evb/guest_a/CMakeLists.txt create mode 100644 examples/s32z280_evb/guest_a/link_partition_a.lds create mode 100644 examples/s32z280_evb/zx_one_partition.lds create mode 100644 platform/cortex_r52/src/zx_cache.c create mode 100644 test/host/test_zx_guest_console.c create mode 100644 test/host/test_zx_partition.c diff --git a/.github/workflows/zx_fvp.yml b/.github/workflows/zx_fvp.yml index 0cb11e3..fa86a10 100644 --- a/.github/workflows/zx_fvp.yml +++ b/.github/workflows/zx_fvp.yml @@ -30,14 +30,31 @@ name: zx_fvp # repository-variable arrangement for the model, because Arm has no stable # unauthenticated download URL for it. # -# WHAT IT RUNS. zx_probe.elf, which must PASS, plus two builds of the same -# image that must FAIL -- one whose deliberate violation is aimed at an address -# the payload is granted, and one told it needs more MPU regions than exist. -# Both are registered with the runner's "--expect fail", because a check that -# has never been seen to fail is not evidence that it can. CTest runs all -# three. (CTest's WILL_FAIL is deliberately NOT used: it inverts the exit -# status and nothing else, so it cannot tell a reported failure from an image -# that was never built. See test/fvp/run_fvp_test.py.) +# WHAT IT RUNS. Two families of image, and every one of them through CTest. +# +# The stage-2 probes: zx_probe.elf, which must PASS, plus builds of the same +# image that must FAIL -- a violation aimed at an address the payload is +# granted, a region budget claimed larger than any implementation has, a window +# widened by one granule, an overlapping manifest, and an unwritten MAIR index. +# +# The one-partition images: a REAL ThreadX kernel booting at EL1 inside a +# stage-2 confined partition, one build that stops it at its stage-2 boundary, +# one that provokes a fault in its OWN stage-1 MPU, and one aimed at an address +# it genuinely owns -- which must FAIL, and whose failing is what makes the +# other two evidence. +# +# The builds that must fail are registered with the runner's "--expect fail", +# because a check that has never been seen to fail is not evidence that it can. +# (CTest's WILL_FAIL is deliberately NOT used: it inverts the exit status and +# nothing else, so it cannot tell a reported failure from an image that was +# never built. See test/fvp/run_fvp_test.py.) +# +# THE GUEST NEEDS A THREADX CHECKOUT, so this workflow makes one. ZoneX links +# no ThreadX -- at EL2 it is a standalone bare-metal program -- but the guest +# IS ThreadX, built as a separate program and embedded as a raw binary. The +# checkout is pinned to a commit rather than to a branch: an unpinned guest +# would mean this suite's results changed when a different repository did, and +# a hypervisor regression is the last place to want that. # # zx_probe_el2_fault.elf is deliberately NOT registered: its expected outcome # is a hypervisor-fault report, and a suite taught to accept that as a pass @@ -58,6 +75,8 @@ on: - "common/**" - "core/**" - "platform/**" + - "examples/common/**" + - "examples/guest_common/**" - "examples/fvp_baser_aemv8r/**" - "test/fvp/**" pull_request: @@ -71,6 +90,8 @@ on: - "common/**" - "core/**" - "platform/**" + - "examples/common/**" + - "examples/guest_common/**" - "examples/fvp_baser_aemv8r/**" - "test/fvp/**" @@ -107,6 +128,26 @@ jobs: - name: Check out the repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # The kernel the GUEST images are built from. + # + # Pinned to a commit, not to a branch. The alternative -- track dev -- + # would make this suite's verdict depend on a second repository's latest + # state, so a ZoneX pull request could go red for a reason that had nothing + # to do with it and everything to do with a kernel change landing that + # morning. Bumping the pin is then a deliberate, reviewable change with + # its own diff, which is what upgrading a dependency should look like. + # + # The Cortex-R52 port has carried TX_R52_BOOT_AT_EL1 since it was written, + # for exactly this case -- "an earlier boot stage or a vendor EL2 monitor + # has already dropped privilege to EL1" -- so the guest needs no change to + # the kernel or to its port. ZoneX is that monitor. + - name: Check out ThreadX, for the guest images + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: eclipse-threadx/threadx + ref: 8c681c188ee748c777185e229024255c054a49d9 + path: threadx + - name: Cache the AArch32 Arm GNU toolchain id: cache-arm32 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -234,6 +275,7 @@ jobs: -DCMAKE_COMPILE_WARNING_AS_ERROR=ON \ -DZX_BUILD_FVP_EXAMPLE=ON \ -DZX_BUILD_S32Z280_EXAMPLE=OFF \ + -DZX_THREADX_SOURCE_DIR="$GITHUB_WORKSPACE/threadx" \ $fvp_arg cmake --build build/fvp @@ -263,6 +305,24 @@ jobs: exit 1 fi + # AND THE GUEST IMAGES SPECIFICALLY. Without a ThreadX checkout the + # example CMakeLists SKIPS them, with a message -- which is right for a + # contributor who has no kernel sources to hand and wrong here, where + # it would mean half this suite quietly stopped running. The stage-2 + # probes would still be green, and nothing would say that a real + # kernel had not been booted at all. + if ! echo "$images" | grep -q '^zx_one_partition\.elf$'; then + echo "::error::The ThreadX guest images were not built. ZX_THREADX_SOURCE_DIR did not reach CMake, so no real kernel was booted and half this suite did not run." + { + echo "### ZoneX FVP execution: NO GUEST IMAGES" + echo "" + echo "The stage-2 probe images built, but the one-partition images -- the ones that" + echo "boot a real ThreadX kernel inside a partition -- were skipped, which happens" + echo "when \`ZX_THREADX_SOURCE_DIR\` is empty. Check the ThreadX checkout step." + } >>"$GITHUB_STEP_SUMMARY" + exit 1 + fi + echo "Building $(echo "$images" | wc -l) images" # shellcheck disable=SC2086 cmake --build build/fvp --target $images diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index 84c5230..2b21493 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -22,6 +22,7 @@ target_sources(zonex_core PRIVATE ${CMAKE_CURRENT_LIST_DIR}/src/zx_fault.c + ${CMAKE_CURRENT_LIST_DIR}/src/zx_guest_console.c ${CMAKE_CURRENT_LIST_DIR}/src/zx_manifest_verify.c ${CMAKE_CURRENT_LIST_DIR}/src/zx_mm_setup.c ${CMAKE_CURRENT_LIST_DIR}/src/zx_partition_manager.c @@ -39,6 +40,7 @@ target_sources(zonex_core PUBLIC FILES ${CMAKE_CURRENT_LIST_DIR}/../common/inc/zx_api.h ${CMAKE_CURRENT_LIST_DIR}/../common/inc/zx_console.h ${CMAKE_CURRENT_LIST_DIR}/inc/zx_fault.h + ${CMAKE_CURRENT_LIST_DIR}/inc/zx_guest_console.h ${CMAKE_CURRENT_LIST_DIR}/inc/zx_manifest.h ${CMAKE_CURRENT_LIST_DIR}/inc/zx_mm.h ${CMAKE_CURRENT_LIST_DIR}/inc/zx_partition.h diff --git a/core/inc/zx_fault.h b/core/inc/zx_fault.h index fb9f16d..5f944dc 100644 --- a/core/inc/zx_fault.h +++ b/core/inc/zx_fault.h @@ -110,19 +110,35 @@ extern "C" { #define ZX_ISS_FNV_MASK 0x00000400U /* [10] FAR not valid */ #define ZX_ISS_ISV_MASK 0x01000000U /* [24] ISS[23:14] valid */ -/* HVC immediate: HSR.ISS[15:0]. ZoneX gives the two immediates it uses - names, because "HVC #1" in a trace says nothing about what was asked. +/* HVC immediate: HSR.ISS[15:0]. ZoneX gives every immediate it uses a name, + because "HVC #1" in a trace says nothing about what was asked. ZX_HVC_NOP is the empty hypercall vector Phase 0 promises: counted at EL2 and returned from, which proves the seam is live without giving a guest anything to call. ZX_HVC_YIELD is how a Phase-0 test payload hands control back to EL2 on purpose, as opposed to being taken from it by a fault. The distinction is what lets a run say "the payload survived its violation" - instead of only "no fault was captured". */ + instead of only "no fault was captured". + + ZX_HVC_PUTC is one character for the hypervisor to print, in r0. + + THE IMMEDIATE IS THE FUNCTION SELECTOR, and a register is not. A general + hypercall ABI usually puts a function id in a register so that the vector + can dispatch through a table, and that is the right shape for one with + dozens of calls. This one has three, the vector already compares + immediates to tell a yield from a transparent return, and the immediate + costs a guest nothing: a console backend receives its character in r0 + under AAPCS and can issue the HVC with no register shuffling at all. An + id in r0 would mean moving the character to r1 and loading a constant into + r0 on every character of a path that is already the slowest thing a guest + does. When a fourth call arrives this stays; when a fortieth does, the + selector moves to a register and this comment is the record of why it was + not there to begin with. */ #define ZX_HSR_HVC_IMM_MASK 0x0000FFFFU #define ZX_HVC_NOP 0x0000U #define ZX_HVC_YIELD 0x0001U +#define ZX_HVC_PUTC 0x0002U /**************************************************************************/ /* HPFAR */ diff --git a/core/inc/zx_guest_console.h b/core/inc/zx_guest_console.h new file mode 100644 index 0000000..dc1f470 --- /dev/null +++ b/core/inc/zx_guest_console.h @@ -0,0 +1,123 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_guest_console.h PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* The hypervisor end of the guest console: one character at a time, */ +/* tagged with the partition that is running. */ +/* */ +/* WHY THE HYPERVISOR OWNS THE CONSOLE */ +/* */ +/* There is one UART and there will be more than one partition. Three */ +/* ways to divide it were available and only one of them survives the */ +/* arrival of the second partition: */ +/* */ +/* * give the device to one partition and leave the other mute -- */ +/* cheapest, and it makes the demonstrator show half of what it */ +/* is for; */ +/* * map the device into both partitions, which is honest about */ +/* there being no device isolation in Phase 0 and puts MMIO into */ +/* every partition's region set, lengthening the isolation claim */ +/* the whole component exists to make; */ +/* * have the guest ASK, and let the hypervisor write. */ +/* */ +/* The third is what this file is. It costs a trap per character, */ +/* which is a real cost and is stated rather than hidden: a partition */ +/* that is printing is not a partition whose timing should be */ +/* measured, and a later phase that needs both will buffer per */ +/* partition and flush at a window boundary. What it buys is that the */ +/* device stays out of every partition's region set, that the */ +/* hypercall seam is exercised by something real rather than by a */ +/* counter, and that two partitions' output can be read side by side. */ +/* */ +/* WHY THE TAG IS APPLIED HERE AND NOT BY THE GUEST */ +/* */ +/* Because a guest must not be able to lie about who it is. A */ +/* partition that prefixed its own lines could claim to be another */ +/* one, and every line of a captured log would then be evidence of */ +/* nothing. The identity comes from the partition the hypervisor */ +/* SCHEDULED, which is the only party that knows it. */ +/* */ +/* This is a small thing that a safety reviewer notices, and it is */ +/* free: the hypervisor is already in the loop for every character. */ +/* */ +/**************************************************************************/ + +#ifndef ZX_GUEST_CONSOLE_H +#define ZX_GUEST_CONSOLE_H + +#include "zx_api.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* Say which partition is about to run, so that its output can be tagged. + Called by whatever drives the partition, immediately before the transfer + to EL1 -- not when the manifest is read, because what matters is which + partition is CURRENT and not which ones exist. + + name_ptr may be a null pointer; the tag then carries the id alone. The + id is what identifies a partition in a manifest, and a name is a + convenience for a reader. */ + +void zx_guest_console_attach(UINT partition_id, const CHAR *name_ptr); + +/* Terminate a partial line and stop tagging. Called after the partition has + given control back, however it gave it back. + + Terminating the line matters more than it looks. A guest that printed + without a trailing newline -- because it faulted mid-line, which is + exactly when a log is read most carefully -- would otherwise have the + hypervisor's next line appended to its own, and the reader would attribute + a hypervisor message to the guest. */ + +void zx_guest_console_detach(void); + +/* One character from the running guest. Called from the hypercall path, so + it must be safe to call with a guest's registers still live and must not + itself be able to fault: it touches no guest memory at all -- the + character arrives in a register. */ + +void zx_guest_console_putc(CHAR character); + +/* How many characters the guests have asked to print since the last reset. + Exists so that "the guest really did print through the hypervisor" is a + number a regression can assert on rather than a shape somebody eyeballs + in a log. */ + +ZX_NODISCARD UINT zx_guest_console_characters(void); + +/* How many characters arrived while NO partition was attached. Never zero + for an innocent reason: an HVC console call reaching EL2 with no current + partition means the run loop and the console disagree about what is + executing, and that is a hypervisor bug rather than a guest one. Counted + separately so it cannot hide inside the total. */ + +ZX_NODISCARD UINT zx_guest_console_orphans(void); + +void zx_guest_console_reset(void); + +#ifdef __cplusplus +} +#endif + +#endif /* ZX_GUEST_CONSOLE_H */ diff --git a/core/inc/zx_manifest.h b/core/inc/zx_manifest.h index cd31838..c1c651e 100644 --- a/core/inc/zx_manifest.h +++ b/core/inc/zx_manifest.h @@ -319,6 +319,7 @@ _Static_assert((ZX_MAX_PARTITIONS * ZX_MAX_REGIONS_PER_PARTITION) <= 24U, #define ZX_MANIFEST_SHARED_NO_PUBLISHER 0x1BU #define ZX_MANIFEST_SHARED_BAD_AP 0x1CU #define ZX_MANIFEST_REGION_BUDGET 0x1DU +#define ZX_MANIFEST_IMAGE_EMPTY 0x1EU /**************************************************************************/ /* What the validator saw */ diff --git a/core/inc/zx_partition.h b/core/inc/zx_partition.h index 5f92ee6..ae5127b 100644 --- a/core/inc/zx_partition.h +++ b/core/inc/zx_partition.h @@ -33,10 +33,19 @@ /* the hypervisor. See docs/decisions.md D3 and the AP table in */ /* docs/armv8r-el2-reference.md. */ /* */ -/* STATUS */ +/* WHERE THE SPLIT IS */ /* */ -/* Declared empty. The control block is defined alongside the */ -/* manifest, and the first guest partition boots into it after that. */ +/* This header DECIDES; it does not touch the machine. Everything */ +/* here is arithmetic over a manifest and a state machine over what */ +/* came back from a partition, and none of it reads a register, copies */ +/* a byte or maintains a cache. The caller -- which does have */ +/* hardware -- performs the copy the load record describes, does the */ +/* cache maintenance, programs the enable mask and transfers to EL1. */ +/* */ +/* That is the same split zx_mm.h makes for region programming, and it */ +/* is made for the same reason: the interesting part is decidable on a */ +/* workstation, and it stops being testable there the moment it is */ +/* interleaved with CP15 writes. */ /* */ /**************************************************************************/ @@ -44,11 +53,181 @@ #define ZX_PARTITION_H #include "zx_api.h" +#include "zx_manifest.h" +#include "zx_fault.h" #ifdef __cplusplus extern "C" { #endif +/**************************************************************************/ +/* Lifecycle states */ +/**************************************************************************/ + +/* A partition's state, as the hypervisor knows it. + * + * The states that matter are the last three, and they must not be conflated. + * YIELDED means a partition handed control back on purpose; FAULTED means it + * was taken from at its boundary; STOPPED means something reached EL2 that + * neither of those describes. A run that reported the third as the second + * would be reporting a hypervisor or configuration problem as a partition + * behaving badly, and would pass while proving nothing. + * + * DECLARED is deliberately zero, so that a zeroed control block reads as + * "in the manifest, nothing done yet" rather than as some more advanced + * state a caller might act on. */ + +#define ZX_PARTITION_DECLARED 0U /* in the manifest, not yet loaded */ +#define ZX_PARTITION_PREPARED 1U /* window located, load record set */ +#define ZX_PARTITION_LOADED 2U /* image copied and caches synced */ +#define ZX_PARTITION_RUNNING 3U /* control is at EL1 right now */ +#define ZX_PARTITION_YIELDED 4U /* handed control back on purpose */ +#define ZX_PARTITION_FAULTED 5U /* taken from at its boundary */ +#define ZX_PARTITION_STOPPED 6U /* something else reached EL2 */ + +/**************************************************************************/ +/* The load record */ +/**************************************************************************/ + +/* What the loader decided, before it did any of it. + * + * WHY THIS IS FILLED IN FIRST AND IN FULL. Every field below is derivable + * from the manifest alone, so all of them are written before the first + * operation that can fail. The alternative -- fill each field as its step + * succeeds -- leaves zeros behind on a failure, and a later consistency + * check then reports a SECOND problem, about a zero window base, pointing at + * the wrong thing entirely. That happened during the Cortex-R52 Modules + * port work and cost more time than the original failure did. + * + * The window is the executable region that CONTAINS THE ENTRY POINT, which + * is the region the image is copied into. It is recorded rather than + * recomputed at each use because a fault report has to be able to say which + * window an address was or was not inside, and recomputing it from a + * manifest at report time is how a report starts disagreeing with the load + * it is describing. */ + +typedef struct zx_partition_load_struct +{ + zx_addr_t zx_load_window_base; /* first byte of the code window */ + zx_addr_t zx_load_window_limit; /* LAST byte, inclusive */ + zx_addr_t zx_load_image_source; /* where the image is now */ + zx_size_t zx_load_image_length; /* how many bytes to copy */ + zx_addr_t zx_load_entry; /* where to start executing */ + UINT zx_load_region_index; /* which of the partition's regions */ +} ZX_PARTITION_LOAD; + +/**************************************************************************/ +/* The control block */ +/**************************************************************************/ + +/* Everything the hypervisor knows about one partition at run time. + * + * It points at its manifest declaration rather than copying it. The + * manifest lives in the hypervisor's own .rodata, covered by no enabled + * stage-2 region, so it is both unreachable from a guest and unchanging -- + * copying it would double the storage and create a second version that could + * disagree with the first. + * + * The counters are here because they answer questions a Phase-0 regression + * has to ask and a printed log cannot: how many times a partition was + * entered, and how many of those ended at its boundary. A guest that + * faulted on every entry and a guest that ran cleanly both produce plausible + * output; the pair of numbers does not. */ + +typedef struct zx_partition_cb_struct +{ + const ZX_PARTITION *zx_partition_declaration; + UINT zx_partition_state; + ZX_PARTITION_LOAD zx_partition_load; + + UINT zx_partition_entries; /* transfers to EL1 */ + UINT zx_partition_faults; /* that ended at a boundary */ + + /* The evidence from the LAST return, kept so that a report written after + several partitions have run still says what happened to this one. A + single global fault record is overwritten by whoever faults next. */ + uint32_t zx_partition_last_hsr; + uint32_t zx_partition_last_address; + uint32_t zx_partition_last_guest_pc; +} ZX_PARTITION_CB; + +/**************************************************************************/ +/* The loader */ +/**************************************************************************/ + +/* Zero a control block and point it at a declaration. Separate from + prepare, so that a caller can build the table before it has decided what + to do with it, and so that "nothing has happened to this partition yet" is + a state rather than the absence of one. */ + +void zx_partition_reset(ZX_PARTITION_CB *cb_ptr, + const ZX_PARTITION *declaration_ptr); + +/* Work out where the image goes, and fill the load record. + * + * Touches nothing: no copy, no cache, no register. On success the state + * becomes ZX_PARTITION_PREPARED and the record describes a copy the caller + * can then perform. + * + * Returns ZX_MANIFEST_SUCCESS or one of the ZX_MANIFEST_* codes -- one + * vocabulary rather than two, so that a boot message means the same thing + * wherever it came from. The two failures it can report, + * ZX_MANIFEST_ENTRY_NOT_EXECUTABLE and ZX_MANIFEST_IMAGE_TOO_LARGE, are the + * SAME rules the validator applies and not a second copy of them: locating + * the window is work only the loader does, and once it is located those two + * conditions are one comparison each. A caller that has already run + * zx_manifest_verify will never see either, which is the point -- the + * validator is what turns them from a run-time failure into a build-time + * one. */ + +ZX_NODISCARD UINT zx_partition_prepare(ZX_PARTITION_CB *cb_ptr); + +/* Say the copy and the cache maintenance are done. The caller does both, + because both are architecture-specific; this records that they happened, + so that a transfer to EL1 from a partition whose image was never copied is + a state error rather than an ERET into whatever the window held. */ + +void zx_partition_loaded(ZX_PARTITION_CB *cb_ptr); + +/**************************************************************************/ +/* Entering and returning */ +/**************************************************************************/ + +/* About to transfer to EL1. Returns non-zero when the partition is in a + state that can be entered -- loaded, or previously yielded -- and zero + when it is not, which is the answer a caller must check rather than a + detail it can assume. */ + +ZX_NODISCARD UINT zx_partition_enter(ZX_PARTITION_CB *cb_ptr); + +/* Control came back. The RECORD decides what happened, not the caller: the + syndrome is the evidence, and a caller that classified the outcome itself + would be a second opinion able to disagree with the fault report printed + beside it. + * + * record_ptr may be a null pointer, which is treated as "something came back + * and nothing was captured" -- ZX_PARTITION_STOPPED. That is the honest + * reading: a return with no syndrome is not a yield. */ + +void zx_partition_returned(ZX_PARTITION_CB *cb_ptr, + const zx_fault_record_t *record_ptr); + +/**************************************************************************/ +/* Reporting */ +/**************************************************************************/ + +ZX_NODISCARD const CHAR *zx_partition_state_name(UINT state); + +/* Print one partition's load record and outcome. + * + * Worth its space in the image for the same reason zx_mm_report is: a fault + * names a guest PC, and a guest PC means nothing without the window base it + * is an offset from. Printing the window, the image extent and the entry + * next to each other is what lets a reader resolve that PC against the + * guest's own map file by hand. */ + +void zx_partition_report(const ZX_PARTITION_CB *cb_ptr); + #ifdef __cplusplus } #endif diff --git a/core/src/zx_guest_console.c b/core/src/zx_guest_console.c new file mode 100644 index 0000000..e500f30 --- /dev/null +++ b/core/src/zx_guest_console.c @@ -0,0 +1,266 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_guest_console.c PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Tags and forwards one guest character at a time. See the header */ +/* for why the hypervisor owns the console and why the tag is applied */ +/* at this end. */ +/* */ +/* Nothing here touches hardware or guest memory: the character */ +/* arrives in a register and leaves through zx_console_puts, which the */ +/* port or the host suite provides. That is what makes the tagging */ +/* rules -- where a prefix goes, what happens to a line nobody */ +/* terminated -- testable on a workstation, which is where text is */ +/* worth testing. */ +/* */ +/* ONE PARTITION AT A TIME */ +/* */ +/* The state below is a single set of variables rather than an array */ +/* per partition, and that is a deliberate match to the rest of */ +/* Phase 0: the excursion to EL1 cannot nest (see the note on */ +/* zx_el2_resume_context in the port's trap handler), so exactly one */ +/* partition can be mid-print. A later phase that buffers each */ +/* partition's output to keep the console off the critical path needs */ +/* an array here, and it needs a bound on it. */ +/* */ +/**************************************************************************/ + +#include "zx_guest_console.h" +#include "zx_console.h" + +/* Which partition's output is being tagged, and whether one is attached at + all. Two variables rather than a sentinel id, because every UINT is a + legal partition id -- including zero -- and a sentinel would make the id + 0xFFFFFFFF mean "nobody" in a system where a manifest may legitimately + use it. */ + +static UINT zx_console_guest_attached; +static UINT zx_console_guest_id; +static const CHAR *zx_console_guest_name; + +/* Whether the next character starts a line. The prefix is emitted lazily, + on the first character of a line rather than after the newline that ended + the previous one, so that a guest which prints nothing more produces no + dangling tag. A trailing "[P1] " with nothing after it reads as output + that was lost. */ + +static UINT zx_console_guest_at_line_start = 1U; + +static UINT zx_console_guest_characters; +static UINT zx_console_guest_orphans; + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_guest_console_tag PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Emits the prefix that says who is speaking. */ +/* */ +/* The id is printed even when a name is available, and in that order. */ +/* A name is what a reader recognises; the id is what the manifest, */ +/* the region layout and every fault report use. Printing only the */ +/* name would leave a reader holding a log with no way back to the */ +/* declaration that produced it. */ +/* */ +/**************************************************************************/ + +static void zx_guest_console_tag(void) +{ + if (zx_console_guest_attached == 0U) + { + /* Not a formatting choice. A character arriving with no partition + attached means the run loop and this file disagree about what is + executing, so the line says so instead of guessing an id. */ + zx_console_puts("[guest, UNATTACHED -- a ZoneX bug] "); + return; + } + + zx_console_puts("[P"); + zx_console_putdec(zx_console_guest_id); + + if (zx_console_guest_name != (const CHAR *)0) + { + zx_console_puts(" "); + zx_console_puts(zx_console_guest_name); + } + + zx_console_puts("] "); +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_guest_console_attach PORTABLE C */ +/* */ +/**************************************************************************/ + +void zx_guest_console_attach(UINT partition_id, const CHAR *name_ptr) +{ + /* Any partial line belongs to whoever was attached before, so it is + closed here as well as in detach. Attaching twice without detaching + is a caller error, and the honest response to it is to keep the two + partitions' text on separate lines rather than to refuse. */ + if (zx_console_guest_at_line_start == 0U) + { + zx_console_puts("\n"); + zx_console_guest_at_line_start = 1U; + } + + zx_console_guest_attached = 1U; + zx_console_guest_id = partition_id; + zx_console_guest_name = name_ptr; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_guest_console_detach PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Closes a partial line and stops tagging. */ +/* */ +/* A guest that faults mid-line is the normal case here rather than an */ +/* exotic one -- it is exactly when a log is read most carefully -- and */ +/* without this the hypervisor's fault report would be appended to the */ +/* guest's unfinished sentence and read as part of it. */ +/* */ +/**************************************************************************/ + +void zx_guest_console_detach(void) +{ + if (zx_console_guest_at_line_start == 0U) + { + zx_console_puts("\n"); + zx_console_guest_at_line_start = 1U; + } + + zx_console_guest_attached = 0U; + zx_console_guest_name = (const CHAR *)0; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_guest_console_putc PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* One character, tagged if it opens a line. */ +/* */ +/* A carriage return is dropped rather than forwarded. A guest that */ +/* ends its lines with CR LF -- which a UART-oriented one reasonably */ +/* does -- would otherwise leave a CR before every tag, and the tag */ +/* would be overprinted on a terminal and invisible in a captured log. */ +/* Dropping it loses nothing: the newline that follows is what ends the */ +/* line here. */ +/* */ +/**************************************************************************/ + +void zx_guest_console_putc(CHAR character) +{ + CHAR text[2]; + + zx_console_guest_characters++; + + if (zx_console_guest_attached == 0U) + { + zx_console_guest_orphans++; + } + + if (character == '\r') + { + return; + } + + if (zx_console_guest_at_line_start != 0U) + { + zx_guest_console_tag(); + zx_console_guest_at_line_start = 0U; + } + + text[0] = character; + text[1] = '\0'; + zx_console_puts(text); + + if (character == '\n') + { + zx_console_guest_at_line_start = 1U; + } +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_guest_console_characters PORTABLE C */ +/* */ +/**************************************************************************/ + +UINT zx_guest_console_characters(void) +{ + return zx_console_guest_characters; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_guest_console_orphans PORTABLE C */ +/* */ +/**************************************************************************/ + +UINT zx_guest_console_orphans(void) +{ + return zx_console_guest_orphans; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_guest_console_reset PORTABLE C */ +/* */ +/**************************************************************************/ + +void zx_guest_console_reset(void) +{ + zx_console_guest_attached = 0U; + zx_console_guest_id = 0U; + zx_console_guest_name = (const CHAR *)0; + zx_console_guest_at_line_start = 1U; + zx_console_guest_characters = 0U; + zx_console_guest_orphans = 0U; +} diff --git a/core/src/zx_manifest_verify.c b/core/src/zx_manifest_verify.c index b41ff2c..703f1f1 100644 --- a/core/src/zx_manifest_verify.c +++ b/core/src/zx_manifest_verify.c @@ -503,6 +503,34 @@ static UINT zx_manifest_partition_check(const ZX_MANIFEST *manifest_ptr, ZX_MANIFEST_NO_INDEX); } + /* An image of ZERO bytes, which is a separate rule from a reversed range + and catches a different mistake. + + A guest image reaches the hypervisor as a section pulled in with + .incbin, and the manifest names that section's start and end symbols. + When the linker script's input pattern matches nothing -- a pattern + missing the object-file suffix does exactly this -- the output section + is created EMPTY at whatever address the location counter happened to + hold, and the two symbols come out equal. The image then builds, + links, reports a plausible size in the map file, and contains no + guest; the run copies nothing and ERETs into whatever the window held. + That failure cost real time during the Cortex-R52 Modules port work, + and it is decidable here in one comparison. + + A partition that genuinely has no image to copy -- code already + resident in ROM, say -- is a case Phase 0 does not have and would need + an explicit declaration rather than an empty range, because an empty + range is indistinguishable from the mistake above. */ + + if (partition_ptr->zx_partition_image_end + == partition_ptr->zx_partition_image_start) + { + return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_IMAGE_EMPTY, + partition_index, ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX, + ZX_MANIFEST_NO_INDEX); + } + if (partition_ptr->zx_partition_window_ticks == 0U) { return zx_manifest_fault_set(fault_ptr, ZX_MANIFEST_ZERO_WINDOW, @@ -586,25 +614,37 @@ static UINT zx_manifest_partition_check(const ZX_MANIFEST *manifest_ptr, <= region_ptr->zx_region_limit) { entry_is_executable = 1U; - } - } - - /* The image is copied into a code region, so it has to fit one. - Both subtractions are safe without a guard: image_end >= start - was established before this loop, and limit >= base by the - per-region check just above. No guard is added for them on - purpose -- a condition that cannot be false is dead code, and - dead code in a function with a coverage floor is a branch - nobody can ever cover. */ - { - zx_addr_t image_size = partition_ptr->zx_partition_image_end - - partition_ptr->zx_partition_image_start; - zx_addr_t region_size = region_ptr->zx_region_limit - - region_ptr->zx_region_base; - if (image_size <= region_size) - { - image_fits = 1U; + /* THE IMAGE IS CHECKED AGAINST THIS REGION AND NO + OTHER, because this is the one it will be loaded + into: the loader copies to the base of the + executable region that holds the entry point. + + The weaker rule -- "the image fits SOME executable + region" -- is the one this replaces, and it passes a + manifest whose entry sits in a small region while a + large one elsewhere absorbs the size. The copy then + overruns the window it actually targets, into + whatever is next in the address space, and the first + symptom is a neighbouring partition behaving oddly. + Naming the window makes the rule mean what a reader + assumes it means. + + Both subtractions are safe without a guard: + image_end > start was established before this loop, + and limit >= base by the per-region check just + above. No guard is added for them on purpose -- a + condition that cannot be false is dead code, and + dead code in a function with a coverage floor is a + branch nobody can ever cover. */ + + if ((partition_ptr->zx_partition_image_end + - partition_ptr->zx_partition_image_start) + <= (region_ptr->zx_region_limit + - region_ptr->zx_region_base)) + { + image_fits = 1U; + } } } } diff --git a/core/src/zx_partition_manager.c b/core/src/zx_partition_manager.c index 4c00cc8..c062ec9 100644 --- a/core/src/zx_partition_manager.c +++ b/core/src/zx_partition_manager.c @@ -21,14 +21,461 @@ /* */ /* DESCRIPTION */ /* */ -/* The partition manager: partition creation from the manifest, entry */ -/* into a partition, and the halt path taken when one faults. */ +/* The partition manager: where a guest image goes, what state a */ +/* partition is in, and what the syndrome says happened to it. */ /* */ -/* This translation unit is deliberately empty of implementation. */ -/* The change that founded this repository builds the repository, not */ -/* the hypervisor; the unit exists so that the change which writes the */ -/* code opens a tree that already configures, compiles and links. */ +/* Nothing here copies a byte, maintains a cache or writes a register. */ +/* See the note on the split in zx_partition.h -- the caller does all */ +/* three, and this decides what the caller should do and records that */ +/* it was done. */ +/* */ +/* MISRA C:2012 deviations (justified) */ +/* */ +/* None. Every function here is straight-line arithmetic over a */ +/* manifest with a single loop and no pointer arithmetic beyond array */ +/* subscripting. */ /* */ /**************************************************************************/ #include "zx_partition.h" +#include "zx_console.h" + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_partition_reset PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Zeroes a control block and points it at its declaration. */ +/* */ +/* Written field by field rather than with a library memset, because */ +/* ZoneX at EL2 links no C library (docs/decisions.md D9) and a */ +/* freestanding implementation is not obliged to provide one. Six */ +/* assignments are also easier to audit than a call whose length */ +/* argument is a sizeof. */ +/* */ +/**************************************************************************/ + +void zx_partition_reset(ZX_PARTITION_CB *cb_ptr, + const ZX_PARTITION *declaration_ptr) +{ + if (cb_ptr == (ZX_PARTITION_CB *)0) + { + return; + } + + cb_ptr->zx_partition_declaration = declaration_ptr; + cb_ptr->zx_partition_state = ZX_PARTITION_DECLARED; + cb_ptr->zx_partition_entries = 0U; + cb_ptr->zx_partition_faults = 0U; + cb_ptr->zx_partition_last_hsr = 0U; + cb_ptr->zx_partition_last_address = 0U; + cb_ptr->zx_partition_last_guest_pc = 0U; + + cb_ptr->zx_partition_load.zx_load_window_base = 0U; + cb_ptr->zx_partition_load.zx_load_window_limit = 0U; + cb_ptr->zx_partition_load.zx_load_image_source = 0U; + cb_ptr->zx_partition_load.zx_load_image_length = 0U; + cb_ptr->zx_partition_load.zx_load_entry = 0U; + cb_ptr->zx_partition_load.zx_load_region_index = ZX_MANIFEST_NO_INDEX; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_partition_prepare PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Locates the window the image is copied into, and fills the load */ +/* record. */ +/* */ +/* THE ORDER OF THIS FUNCTION IS THE POINT. Everything derivable from */ +/* the manifest is written into the record BEFORE the search that can */ +/* fail, so that a failure leaves a record describing what was */ +/* attempted rather than a block of zeros. A zeroed window base is a */ +/* plausible address, and an end-of-run consistency check that met one */ +/* would report a second, invented problem and point the reader at it. */ +/* */ +/**************************************************************************/ + +UINT zx_partition_prepare(ZX_PARTITION_CB *cb_ptr) +{ + const ZX_PARTITION *declaration_ptr; + UINT region_index; + + if (cb_ptr == (ZX_PARTITION_CB *)0) + { + return ZX_MANIFEST_NULL_POINTER; + } + + declaration_ptr = cb_ptr->zx_partition_declaration; + + if (declaration_ptr == (const ZX_PARTITION *)0) + { + return ZX_MANIFEST_NULL_POINTER; + } + + if (declaration_ptr->zx_partition_regions == (const ZX_REGION *)0) + { + return ZX_MANIFEST_NULL_POINTER; + } + + /* Recorded first, unconditionally. See the note above. */ + + cb_ptr->zx_partition_load.zx_load_image_source = + declaration_ptr->zx_partition_image_start; + cb_ptr->zx_partition_load.zx_load_image_length = + (zx_size_t)(declaration_ptr->zx_partition_image_end + - declaration_ptr->zx_partition_image_start); + cb_ptr->zx_partition_load.zx_load_entry = + declaration_ptr->zx_partition_entry; + + if (declaration_ptr->zx_partition_image_end + < declaration_ptr->zx_partition_image_start) + { + return ZX_MANIFEST_IMAGE_RANGE_INVALID; + } + + if (declaration_ptr->zx_partition_image_end + == declaration_ptr->zx_partition_image_start) + { + return ZX_MANIFEST_IMAGE_EMPTY; + } + + /* The window is the EXECUTABLE region that contains the entry point. + Not the first executable region, and not the largest: the image is + copied to the base of the region the guest will start executing in, + so any other choice would load the image somewhere the entry does + not point. */ + + for (region_index = 0U; + region_index < declaration_ptr->zx_partition_region_count; + region_index++) + { + const ZX_REGION *region_ptr = + &declaration_ptr->zx_partition_regions[region_index]; + + if (region_ptr->zx_region_xn != ZX_XN_EXECUTABLE) + { + continue; + } + + if (declaration_ptr->zx_partition_entry < region_ptr->zx_region_base) + { + continue; + } + + if (declaration_ptr->zx_partition_entry > region_ptr->zx_region_limit) + { + continue; + } + + cb_ptr->zx_partition_load.zx_load_window_base = + region_ptr->zx_region_base; + cb_ptr->zx_partition_load.zx_load_window_limit = + region_ptr->zx_region_limit; + cb_ptr->zx_partition_load.zx_load_region_index = region_index; + + /* The limit is INCLUSIVE, so the window holds limit - base + 1 + bytes. Written as a comparison against (limit - base) with the + length reduced by one rather than as base + length - 1, because + the second form overflows for a window that reaches the top of + the address space and then reports a real overrun as a fit. */ + + if ((cb_ptr->zx_partition_load.zx_load_image_length - 1U) + > (zx_size_t)(region_ptr->zx_region_limit + - region_ptr->zx_region_base)) + { + return ZX_MANIFEST_IMAGE_TOO_LARGE; + } + + cb_ptr->zx_partition_state = ZX_PARTITION_PREPARED; + + return ZX_MANIFEST_SUCCESS; + } + + return ZX_MANIFEST_ENTRY_NOT_EXECUTABLE; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_partition_loaded PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Records that the caller has copied the image and synchronised the */ +/* instruction side. */ +/* */ +/* THE CACHE MAINTENANCE THIS ATTESTS TO IS NOT OPTIONAL. A copied */ +/* image arrives through the data side, and on this core the */ +/* instruction side is not coherent with the D-cache; a cold I-cache */ +/* over an address nothing has executed HAPPENS TO WORK until an */ +/* eviction lands differently. The Cortex-R52 Modules port work */ +/* passed a two-address test before the maintenance was added. */ +/* */ +/* Refusing to move a partition that was never prepared is the whole */ +/* reason this is a state machine. Without it, a transfer to EL1 for */ +/* a partition whose image was never copied is an ERET into whatever */ +/* the window happened to hold -- which on a NOLOAD window is */ +/* uninitialised memory, and presents as a guest that started and did */ +/* something arbitrary. */ +/* */ +/**************************************************************************/ + +void zx_partition_loaded(ZX_PARTITION_CB *cb_ptr) +{ + if (cb_ptr == (ZX_PARTITION_CB *)0) + { + return; + } + + if (cb_ptr->zx_partition_state != ZX_PARTITION_PREPARED) + { + return; + } + + cb_ptr->zx_partition_state = ZX_PARTITION_LOADED; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_partition_enter PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* May this partition be entered, and if so, count the entry. */ +/* */ +/* A partition that FAULTED is not re-entered. That is a policy */ +/* choice and it is the conservative one: whatever invariant the guest */ +/* broke to reach its boundary is still broken, and a hypervisor that */ +/* restarted it would be hiding a repeating failure behind a run that */ +/* eventually looked fine. Restart-on-fault is a real requirement for */ +/* some systems and belongs in a scheduler with a policy, not here. */ +/* */ +/**************************************************************************/ + +UINT zx_partition_enter(ZX_PARTITION_CB *cb_ptr) +{ + if (cb_ptr == (ZX_PARTITION_CB *)0) + { + return 0U; + } + + if ((cb_ptr->zx_partition_state != ZX_PARTITION_LOADED) + && (cb_ptr->zx_partition_state != ZX_PARTITION_YIELDED)) + { + return 0U; + } + + cb_ptr->zx_partition_state = ZX_PARTITION_RUNNING; + cb_ptr->zx_partition_entries++; + + return 1U; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_partition_returned PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Classifies what came back, from the syndrome and from nothing else. */ +/* */ +/* The address is taken from HDFAR and not HPFAR. The two registers */ +/* do not mean the same thing on the two ZoneX targets -- the model */ +/* implements the TRM's figure and returns a 4 KB page number where */ +/* the S32Z280 implements the table row and returns the full address */ +/* -- so a manager that recorded HPFAR would be wrong by a factor of */ +/* 256 on one of them. HDFAR carries the full faulting virtual */ +/* address on both. See docs/decisions.md D18. */ +/* */ +/* ELR_hyp is recorded as the guest PC, and it is the field that makes */ +/* a fault report actionable: it resolves against the guest's own map */ +/* file, which is why the guest ELF is kept beside its blob in the */ +/* build tree. */ +/* */ +/**************************************************************************/ + +void zx_partition_returned(ZX_PARTITION_CB *cb_ptr, + const zx_fault_record_t *record_ptr) +{ + if (cb_ptr == (ZX_PARTITION_CB *)0) + { + return; + } + + if (record_ptr == (const zx_fault_record_t *)0) + { + /* A return with no syndrome is not a yield. Nothing is known about + why control came back, and calling that a clean hand-back would + be inventing the one fact the caller failed to supply. */ + cb_ptr->zx_partition_state = ZX_PARTITION_STOPPED; + return; + } + + cb_ptr->zx_partition_last_hsr = record_ptr->zx_fault_hsr; + cb_ptr->zx_partition_last_address = record_ptr->zx_fault_hdfar; + cb_ptr->zx_partition_last_guest_pc = record_ptr->zx_fault_elr; + + switch (zx_fault_classify(record_ptr->zx_fault_hsr)) + { + case ZX_FAULT_HYPERCALL: + cb_ptr->zx_partition_state = ZX_PARTITION_YIELDED; + break; + + case ZX_FAULT_GUEST_VIOLATION: + cb_ptr->zx_partition_state = ZX_PARTITION_FAULTED; + cb_ptr->zx_partition_faults++; + break; + + case ZX_FAULT_HYPERVISOR_BUG: + case ZX_FAULT_UNEXPECTED_TRAP: + default: + /* A hypervisor bug is NOT recorded as a partition fault. The + partition is stopped either way, but counting it as a boundary + violation would let a ZoneX defect be read as the demonstrator + working. */ + cb_ptr->zx_partition_state = ZX_PARTITION_STOPPED; + break; + } +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_partition_state_name PORTABLE C */ +/* */ +/**************************************************************************/ + +const CHAR *zx_partition_state_name(UINT state) +{ + const CHAR *name; + + switch (state) + { + case ZX_PARTITION_DECLARED: + name = "DECLARED (in the manifest, not yet loaded)"; + break; + case ZX_PARTITION_PREPARED: + name = "PREPARED (window located, nothing copied)"; + break; + case ZX_PARTITION_LOADED: + name = "LOADED (image copied, instruction side synchronised)"; + break; + case ZX_PARTITION_RUNNING: + name = "RUNNING (control is at EL1)"; + break; + case ZX_PARTITION_YIELDED: + name = "YIELDED (handed control back on purpose)"; + break; + case ZX_PARTITION_FAULTED: + name = "FAULTED (taken from at its boundary)"; + break; + case ZX_PARTITION_STOPPED: + name = "STOPPED (something else reached EL2)"; + break; + default: + name = "an UNKNOWN state, which is a ZoneX bug"; + break; + } + + return name; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_partition_report PORTABLE C */ +/* */ +/**************************************************************************/ + +void zx_partition_report(const ZX_PARTITION_CB *cb_ptr) +{ + const ZX_PARTITION *declaration_ptr; + + if (cb_ptr == (const ZX_PARTITION_CB *)0) + { + zx_console_puts(" no partition control block to report\n"); + return; + } + + declaration_ptr = cb_ptr->zx_partition_declaration; + + zx_console_puts("\n--- partition "); + + if (declaration_ptr != (const ZX_PARTITION *)0) + { + zx_console_putdec(declaration_ptr->zx_partition_id); + + if (declaration_ptr->zx_partition_name != (const CHAR *)0) + { + zx_console_puts(", "); + zx_console_puts(declaration_ptr->zx_partition_name); + } + } + else + { + zx_console_puts("with NO declaration, which is a ZoneX bug"); + } + + zx_console_puts(" ---\n"); + + zx_console_puts(" state "); + zx_console_puts(zx_partition_state_name(cb_ptr->zx_partition_state)); + zx_console_puts("\n"); + + zx_console_puts(" window "); + zx_console_puthex((uint32_t)cb_ptr->zx_partition_load.zx_load_window_base); + zx_console_puts(" .. "); + zx_console_puthex((uint32_t)cb_ptr->zx_partition_load.zx_load_window_limit); + zx_console_puts(" (region "); + zx_console_putdec(cb_ptr->zx_partition_load.zx_load_region_index); + zx_console_puts(" of this partition's own list)\n"); + + zx_console_puts(" image "); + zx_console_puthex((uint32_t)cb_ptr->zx_partition_load.zx_load_image_source); + zx_console_puts(" + "); + zx_console_puthex((uint32_t)cb_ptr->zx_partition_load.zx_load_image_length); + zx_console_puts(" bytes, copied into the window above\n"); + + zx_console_puts(" entry "); + zx_console_puthex((uint32_t)cb_ptr->zx_partition_load.zx_load_entry); + zx_console_puts("\n"); + + zx_console_puts(" entries "); + zx_console_putdec(cb_ptr->zx_partition_entries); + zx_console_puts(", of which faulted: "); + zx_console_putdec(cb_ptr->zx_partition_faults); + zx_console_puts("\n"); + + if (cb_ptr->zx_partition_entries != 0U) + { + zx_console_puts(" last HSR "); + zx_console_puthex(cb_ptr->zx_partition_last_hsr); + zx_console_puts("\n last address "); + zx_console_puthex(cb_ptr->zx_partition_last_address); + zx_console_puts(" (HDFAR, not HPFAR -- see the decision log)\n"); + zx_console_puts(" guest PC "); + zx_console_puthex(cb_ptr->zx_partition_last_guest_pc); + zx_console_puts(" resolve against the guest's own .map file\n"); + } +} diff --git a/docs/armv8r-el2-reference.md b/docs/armv8r-el2-reference.md index ac3d584..434aadd 100644 --- a/docs/armv8r-el2-reference.md +++ b/docs/armv8r-el2-reference.md @@ -530,18 +530,61 @@ in. A counter enabled without it reads zero forever, which is indistinguishable from a part with no PMU — so ZoneX asks whether the counter is advancing before it times anything. -**Measured, 2 September 2026, Armv8-R AEM FVP:** +**Measured, 2 September 2026, on both targets. The silicon figures are the ones +to quote.** + +| | Armv8-R AEM FVP | S32Z280-594EVB | +|---|---|---| +| counter-read overhead, subtracted from both rows below | 5 | 84 | +| `HPRENR` mask switch (a partition switch) | 13 | **235** | +| one region descriptor write | 54 | **472** | +| ratio, region write : mask | 4.2 | **2.0** | + +**The model's ratio is optimistic by a factor of two.** Both targets agree that +a mask switch is the cheaper operation and that the gap is large enough for the +design to rest on, but a per-region cost extrapolated from the FVP would have +been half of what the part charges. The FVP is a functional model and its +absolute numbers are not timing at all; they are reported because printing them +on every run keeps the measurement path exercised on a machine with no board +attached. + +235 cycles for a single register write also says where a partition switch's +cost actually sits: in the `DSB`/`ISB` pair that has to retire before the next +instruction fetch can depend on the new permissions, not in the write. + +**The silicon figures are reproducible bit for bit** across separate attach, +load and run cycles — the same three hexadecimal values every time. That is +worth knowing before anybody tries to average them: variance here would mean +something else was going on. + +### How to measure an excursion, and the mistake that has to be avoided + +⚠ **The first execution of anything on a real core is not comparable to the +second**, so a measured pair that differs in ORDER as well as in the thing +under test measures neither. + +Measured, on the S32Z280, running one guest twice with the console suppressed: | | cycles | |---|---| -| counter-read overhead | 5 | -| `HPRENR` mask switch (a partition switch) | 13 | -| one region descriptor write | 54 | - -**These are model figures and are not timing.** The FVP is a functional model. -They are reported because the *ratio* is the design argument — a mask switch -against a per-region write — and because printing them on every run keeps the -measurement path exercised. The numbers to quote come from silicon. +| first excursion (cold) | 38,212 | +| second excursion, `HCR.VM` clear | 27,154 | +| third excursion, `HCR.VM` set | 27,052 | + +The first version of that measurement ran only two passes and reported stage 2 +as **28% faster** than no stage 2 — 27,202 cycles against 38,002. A hypervisor +does not give cycles back; the difference was entirely that one pass was the +first and the other was the second. Warming the core with an excursion that is +measured and discarded leaves the two comparable passes agreeing to 102 cycles +in 27,000, which is the honest answer: **with region descriptors programmed +once at boot, stage 2 costs a warmed guest nothing resolvable at this +resolution.** + +⚠ **And a guest's console has to be off to measure anything at all.** With the +`HVC` console enabled the same excursion costs 5,051,788 cycles — one trap per +character through a polled UART, three orders of magnitude above everything +else the guest does. A loud run can bound the cost of stage 2 from above and +can never resolve it. --- @@ -564,7 +607,13 @@ permission on it. | `0x08` | address to probe | EL2 | | `0x0C` | what the guest read | guest | | `0x10` | address of the shared granule | EL2 | -| `0x14` | value to publish, or the sentinel to use | EL2 | +| `0x14` | value to publish, or run options for a kernel guest | EL2 | +| `0x18` | sequence, incremented on every update | guest | +| `0x1C` | checksum over everything reported | guest | +| `0x20` | the guest's own tick count | guest | +| `0x24`-`0x2C` | per-thread counters and queue traffic | guest | +| `0x30` | the guest's own verdict | guest | +| `0x34`-`0x3C` | a stage-1 fault its own vectors saw | guest | The progress word carries the isolation claim **negatively**: the probe-survived bit being *clear* after an excursion is the evidence that @@ -572,6 +621,138 @@ stage 2 stopped the access. A check whose pass condition is the absence of something has to be able to fail, which is what the widened-region build exists to show. +**A real kernel guest cannot take its window base in a register**, which is +why the mailbox sits at a fixed offset in the window rather than being passed +in. A kernel's reset path clobbers `r0` within two instructions of the `ERET`, +long before any of its C runs. The relocatable probe blob is the opposite case +— one program copied into two different windows — and takes a base pointer in +`r0` for exactly that reason. Two guests, two disciplines, and mixing them up +produces a guest that works in one partition and not the other. + +**The sequence and the checksum are what make a report believable**, as opposed +to merely present. Three failures no single word can distinguish: a window +nobody ever wrote reads as zero and zero is a plausible value; a guest torn off +mid-update leaves words that are individually valid and jointly nonsense; and a +report read out of the wrong partition's window looks exactly like a report. +The guest writes every reported field, then the sequence, then a checksum with +the **hypervisor's own sentinel folded into it** — so a torn update fails the +sum, and a report can be *attributed* rather than merely found. It is +deliberately weak arithmetic: a partition can write whatever it likes into its +own window and no checksum changes that. It defends against accidents. + +The three words a stage-1 fault leaves are deliberately OUTSIDE the checksum, +because they are written by the guest's own exception vector — which runs with +no stack, no literal pool beyond the mailbox address, and an obligation to +disturb as little as possible. Computing a checksum there would be absurd. A +torn write of the three leaves the vector word zero, and the hypervisor then +reads "no stage-1 fault" rather than a half-described one. + + +--- + +## `ELR_hyp` after an `HVC` points PAST the instruction + +**Measured, 2 September 2026, on both targets.** A handler that wants to return +to the guest transparently does `ERET` and adjusts nothing. + +This had to be established rather than assumed, because the two plausible +conventions differ by four bytes and the wrong one is an infinite loop rather +than a fault. The measurement is the guest console: it is one `HVC` per +character, and the guest prints several hundred lines and then reaches its own +verdict. Had `ELR_hyp` pointed *at* the `HVC`, the first character would have +re-executed the same instruction forever and the run would have ended in a +timeout with one character of output. + +So it is not a register read that was inspected once; it is a property that +every run of the one-partition image re-establishes, several thousand times, +on both targets. + +--- + +## A stage-1 fault and a stage-2 fault arrive in different places + +This is the fact that makes the two stages distinguishable in a log, and it is +stronger than a decode: **they are reported by different code at different +privilege levels.** + +| | Taken to | Syndrome in | How the hypervisor learns of it | +|---|---|---|---| +| stage-2 violation | **EL2** | `HSR` (`EC 0x24`), `HDFAR` | directly — the fault is its own | +| stage-1 fault | **EL1** | the guest's `DFSR`, `DFAR` | only if the guest tells it | + +A stage-1 fault never reaches EL2 at all. The hypervisor's fault record is +untouched by one, which is why "no stage-2 fault was captured" is a positive +check in the stage-1 demonstration rather than the absence of evidence. + +**Measured, 2 September 2026, on both targets**, by having a guest write to its +own code — permitted by stage 2, because the loader had to be able to write the +window, and refused by the guest's own EL1 MPU: + +| | value | +|---|---| +| guest `DFSR` | `0x00000A0C` — DFSC `0x0C`, a permission fault | +| guest `DFAR` | exactly the address written | +| hypervisor `HSR` | `EC 0x12` — an `HVC`, the guest handing control back | +| hypervisor fault record | no stage-2 capture | + +⚠ **Both stages are checked and the stricter wins** (TRM §8.3.1), which has a +consequence for any test of stage 2 from a real kernel: **an address outside +the guest's own EL1 regions is denied by stage 1 first**, and the abort goes to +EL1. A guest whose own MPU stops at its window boundary therefore cannot +demonstrate that stage 2 stops anything — stage 1 gets there first. To reach a +stage-2 violation the guest has to grant *itself* the granule first, so that +its own MPU says it owns memory the manifest never gave it. That is not a +contrivance; it is the configuration a hypervisor exists to be right about. + +The same rule applies to attributes, not only to permissions: a partition +window with a Device attribute makes every Normal-memory region a guest +programs inside it Device too, which is a spectacular slowdown rather than a +fault. Partition windows get Normal write-back attributes and mean it. + +--- + +## A guest cannot program a TCM, on the S32Z280 + +**`IMP_ATCMREGIONR` and `IMP_BTCMREGIONR` `ENABLEEL2` is silently IGNORED when +written from EL1** — measured on both BTCM and CTCM during the Cortex-R52 +bring-up: the base took and bit 0 took while bit 1 stayed clear. There is no +fault and no indication. + +The consequence for partitioning is that TCM programming is per-core state that +belongs to EL2 now. A guest that places anything in a TCM depends on the +hypervisor having programmed **and ECC-preloaded** it, because ECC is enabled on +this part and a TCM location must be WRITTEN before it can be read (TRM 6.2.2). + +ZoneX's own images place nothing in a TCM and neither does its guest, so +neither programs one — which is why this is recorded here rather than +implemented. The register that has the same shape and does *not* have this +problem is `IMP_PERIPHPREGIONR`: an EL1 write to it traps to EL2 when +`HACTLR.PERIPHPREGIONR` is clear, so it fails loudly instead of quietly. + +--- + +## `CNTFRQ` is EL2-writable only, and reads zero on both targets + +Not a documentation nicety: it is why a guest built to boot at EL1 needs the +hypervisor to program it. `CNTFRQ` is writable only at the highest implemented +exception level, and a guest deriving a tick interval from a zero divides by +zero. + +The value is a **software-declared constant** on both targets — nothing in +either part reports the frequency: + +| Target | `CNTFRQ` at reset | Programmed | Established by | +|---|---|---|---| +| Armv8-R AEM FVP | `0x00000000` | `0x05F5E100` (100 MHz) | `CNTFID0` read back from the counter control frame | +| S32Z280-594EVB | `0x00000000` | `0x007A1200` (8 MHz) | measured at 8.0227 MHz against host wall-clock over 32 s; `CFG_CNTDV` = 4 so the divider is 5; FXOSC 40 MHz, itself confirmed from the boot ROM's LINFlexD baud divisors. 40 / 5 = 8 | + +⚠ **Programming `CNTFRQ` does not start the counter.** On the FVP the system +counter is left stopped (`bp.refcounter.non_arch_start_at_default=0`, +documented as "firmware is expected to enable the timer at boot time"), so a +guest that *waited* on this timer would still wait forever. The cooperative +guest deliberately does not. Starting the counter belongs with interrupt +delivery. + --- ## Sources diff --git a/docs/decisions.md b/docs/decisions.md index 9e9f61a..f2f573d 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -158,7 +158,7 @@ before PMSAv8-R's lack of region priority rules it out. --- -## D4 — How the region set is switched · **settled 2 Sep 2026; cost measured on the model, silicon pending** +## D4 — How the region set is switched · **settled; cost measured on silicon 2 Sep 2026** **One `HPRENR` mask write.** Region descriptors are programmed once at boot, each partition owns a fixed block of indices, and a switch changes which @@ -196,10 +196,41 @@ once at boot. Unrolling them means roughly a hundred inline `MCR`/`MRC` statements, because coprocessor register numbers must be compile-time constants. That cost buys nothing while the switch is a mask. -**Still open: the figures above are the model's**, and a functional model does -not model timing. The number a safety customer is quoted has to come from the -part. The measurement path itself is in the image and runs on every target, so -this closes with a board session rather than with new code. +**The figures are now the part's, and they narrow the margin.** Measured on the +S32Z280-594EVB, reproducible bit for bit across three runs: + +| | Armv8-R AEM FVP | S32Z280-594EVB | +|---|---|---| +| `HPRENR` mask switch | 13 cycles | **235 cycles** | +| one region descriptor write | 54 cycles | **472 cycles** | +| counter read overhead, subtracted | — | 84 cycles | +| ratio, region write : mask | 4.2 | **2.0** | + +The decision does not change and the reasoning for it does. A block rewrite at +three regions per partition costs 1416 cycles against 235 — still nearly six +times the mask — and, unlike the mask, that figure **grows with the incoming +partition's region count**, which is the property a worst-case-execution-time +argument cannot have. That property, not the ratio, is what the design rests +on. + +But the ratio matters to anyone reading the model's numbers as a guide: on +silicon a region write costs twice the mask, not four times. **A per-region +figure taken from the model would have been optimistic by a factor of two.** +And 235 cycles for one register write says plainly where the mask's own cost +sits — in the `DSB`/`ISB` pair that has to retire before the next instruction +fetch can depend on the new permissions, not in the write. Reducing it means +arguing about barriers, not about encodings. + +**A note on how these were measured, because the method is reusable and the +first version of it was wrong.** Both figures average eight operations with the +cost of reading the counter measured separately and subtracted, and the +switches alternate between two masks so that no iteration is writing the value +already in force — an implementation is entitled to make that cheap in a way a +real switch is not. The guest-excursion figures in D8 and D23 needed one +further correction: **the first execution of anything on a real core is not +comparable to the second**, so a measured pair that also differs in order +measures neither. The one-partition image spends a warm-up excursion and +discards it. The budget arithmetic is unchanged: 20 regions on the S32Z280, minus two for the hypervisor's own MMIO (D2), leaves 18 for every partition and guest. @@ -262,7 +293,7 @@ claim is not simplicity worth having here. --- -## D8 — The console · **probe settled; sharing open** +## D8 — The console · **settled; the sharing half settled 2 Sep 2026** ZoneX needs a console before it needs anything else, because the Armv8-R AEM FVP has no debugger seam at all — an Iris server and no GDB stub — so a model @@ -289,14 +320,49 @@ the SAME image printing cleanly on both targets is itself evidence about the region programming. A run whose text arrives intact on the board has demonstrated a correct Device region, without a separate test for it. -**Still open: how two partitions share a console.** That decision arrives when -there are two of them, and the options are: +**How partitions share the console: an `HVC` console, settled 2 September +2026.** A guest's `console_putc` is one hypercall with the character in `r0`; +the hypervisor owns the one device and writes it. Three options were open and +the other two both fail on the arrival of the second partition: + +* *One partition owns the UART and the other is mute.* Cheapest, and it makes + the demonstrator show half of what it exists to show — the whole point of + two partitions is that a reader can watch both. +* *Map the device into both.* Honest about there being no device isolation in + Phase 0, and it puts MMIO into every partition's stage-2 region set, which + lengthens the isolation claim the component exists to make and spends region + budget per partition rather than once. +* *The guest asks and the hypervisor writes.* Chosen. + +What it buys, beyond surviving a second partition: the device stays out of +every partition's region set; the hypercall vector is exercised by something +real rather than by a counter; and the **hypervisor tags each line with the +partition it scheduled**, so a guest cannot claim to be another one. That last +point is free — the hypervisor is already in the loop for every character — +and it is the kind of detail a safety reviewer asks about. + +**The cost is real and is not hidden.** One trap per character through a +polled UART is, on the S32Z280, roughly three orders of magnitude more work +than everything else a small guest does: a guest excursion measured 27,052 +cycles quiet and 5,051,788 cycles printing. Two consequences, both of them +implemented rather than noted: -* one partition owns the UART and the other has no console; -* both get it mapped, which is honest about there being no device isolation in - Phase 0 and should be said out loud if chosen; -* an `HVC` console hypercall, which costs more and exercises the hypercall - vector as a side effect. +* **A partition that is printing is not a partition whose timing should be + measured.** The one-partition image therefore runs its measured passes with + the console suppressed by a mailbox option and its demonstration pass loud — + the same guest image either way, so that the run being measured is the run + being shown. Without that, "what does stage 2 cost" could only be bounded + from above and never resolved. See the measurement note in D4. +* **A later phase that needs both at once buffers per partition and flushes at + a window boundary.** That is not written yet, and the shape it needs is a + per-partition buffer with a bound on it — `core/src/zx_guest_console.c` says + so where the single-partition state is declared. + +The tagging rules are text, and text is worth testing where text can be +tested: the host suite asserts them against a capture buffer — one tag per +line and no more, no dangling tag after a final newline, a partial line closed +before the hypervisor speaks, and a guest's forged tag appearing as ordinary +text inside a correctly attributed line. --- @@ -437,7 +503,7 @@ be thin whenever ZoneX acquires a reason for one. --- -## D14 — The seam to a ThreadX checkout · **settled; implemented with the first guest image** +## D14 — The seam to a ThreadX checkout · **settled; implemented 2 Sep 2026** **A single CMake cache variable, `ZX_THREADX_SOURCE_DIR`**, declared in the root `CMakeLists.txt` now and consumed by the examples once they build guest @@ -456,6 +522,23 @@ silicon guest cannot start at EL1 without it. That is the reason the seam is a path to a checkout rather than a released tarball for now, and it will stop being one as soon as that option is upstream. +*Implemented 2 September 2026, and the split it produced is worth recording.* +The variable is consumed by `examples/*/guest_a`, and **the model's guest needs +no ThreadX-side change at all**: the FVP `entry.S` has carried +`TX_R52_BOOT_AT_EL1` since it was written, for exactly this case — "an earlier +boot stage or a vendor EL2 monitor has already dropped privilege to EL1" — so +the FVP guest builds from unmodified upstream sources. That is what lets the +FVP execution workflow pin a public ThreadX commit and build a guest in CI, +which the silicon half cannot do until the S32Z280 bracket is upstream. Only +the silicon guest waits on a kernel change, and it is one bracket. + +Leaving the variable EMPTY is a supported configuration and not a broken one: +the guest images are skipped with a message from CMake, the stage-2 probe +images still build and run, and a contributor with no kernel sources to hand +can still exercise most of the suite. The FVP workflow nevertheless treats a +guestless build as an ERROR, because there the checkout is arranged and a +silent skip would mean half the suite quietly stopped booting a kernel. + --- ## D15 — The order protection is turned on in · **settled 2 Sep 2026** @@ -642,3 +725,173 @@ than assumed: the four attribute bytes consecutive in every lane, and the whole descriptor pinned to 12 bytes on a 32-bit port, so that a layout change in either target toolchain fails the build instead of producing two images that disagree about the manifest. + +--- + +## D20 — What selects a hypercall · **settled 2 Sep 2026** + +**The `HVC` immediate, not a function id in a register.** `HVC #0` is the empty +Phase-0 vector, `HVC #1` a guest handing control back, `HVC #2` one character +for the hypervisor to print, with the character in `r0`. + +The obvious alternative — a function id in `r0` and arguments from `r1` — is +the right shape for a general hypercall ABI, and it is what an A-profile +hypervisor does. It loses here on three counts, and the third is the one that +decided it. + +* **The vector already decodes immediates.** Telling a yield from a + transparent return was the first thing the trap handler ever had to do (D16), + so the machinery exists and a register-based id would be a second mechanism + beside it rather than instead of it. +* **Phase 0 has three hypercalls.** A dispatch table earns its keep at a few + dozen; at three it is a comparison chain either way. +* **It costs a guest nothing.** A console backend receives its character in + `r0` under AAPCS already, so `hvc #2` is the whole call. An id in `r0` means + moving the character to `r1` and loading a constant into `r0` on **every + character of the slowest path a guest has** — one trap each, through a polled + UART (D8). + +The cost of being wrong is bounded and visible: sixteen bits of immediate is a +small namespace, and when a fourth call arrives this stays while a fortieth +would move the selector into a register. This entry is the record of why it +was not there to begin with. + +**The two spellings are asserted against each other.** A guest is a separate +program with its own toolchain invocation and cannot include the hypervisor's +headers, so it restates the immediates in `examples/common/zx_guest_abi.h` +while the hypervisor decodes them from `core/inc/zx_fault.h`. The one +translation unit that sees both — the example that builds the image — +`_Static_assert`s that they agree, so a renumbered immediate fails the BUILD +rather than making a guest's console output vanish on the model. + +--- + +## D21 — A guest image declares the window it was built for · **settled 2 Sep 2026** + +**Three words at a fixed offset in every guest image: the window base it was +linked for, the window size, and a magic number.** The loader checks all three +against the manifest before it ERETs anywhere, and refuses by name. + +The problem this solves is that **a guest linked for the wrong window starts**. +Its entry point is reached through a PC-relative branch, which survives being +copied anywhere; every other absolute address in it — its literal pools, its +`VBAR`, its own stage-1 MPU bases — is baked in at link time. So the failure is +not a guest that does not run. It is a guest that boots, schedules, and then +reads or writes an address in somebody else's partition, taking a stage-2 fault +whose report names a perfectly reasonable-looking guest PC. Nothing anywhere in +that chain says "this image was built for a different address", and the reader +is sent to the loader when the fault is in the build. + +Three ways to know the linked address were available: + +* *Extract the ELF entry point and load address at build time into a generated + header.* Works, and makes the manifest depend on a build step whose output + nothing checks. +* *Match the boot object's `.text` by linker input pattern so the entry lands + first.* The pattern has to name an object-file suffix, which differs between + toolchains — and a pattern that matches nothing produces an empty section at + whatever address the location counter held, rather than an error. +* *Have the guest say so.* Twelve bytes, no build step, and the guest's own + linker script asserts the values it emits. + +**The magic number earns its four bytes twice over.** A window nobody wrote +reads as zero, and an `.incbin` whose file was absent, or a linker input +pattern that matched nothing, produces an EMPTY section rather than a +diagnostic — so "the header does not carry the magic" catches a missing guest +and a wrong guest with one comparison. It is checked from the image SOURCE and +not from the window, deliberately: a check performed on the copy has already +overwritten whatever was there. + +Two independent checks exist for the empty-image case because it has happened +before in this suite: the linker script asserts the blob is larger than the +header it must contain, and the manifest validator rejects an image of zero +length outright (`ZX_MANIFEST_IMAGE_EMPTY`). + +--- + +## D22 — How a guest is built · **settled 2 Sep 2026** + +**A separate CMake project, configured with THREADX's toolchain file, built as +a superbuild step. Not `add_subdirectory`.** What crosses back is one file: a +raw binary the hypervisor embeds with `.incbin` (D6). + +Three reasons, and the first two are about not contaminating a kernel: + +* **The guest is ThreadX and stays C99**, with ThreadX's own settings. ZoneX is + C17 with `CMAKE_C_EXTENSIONS OFF` and `-Wpedantic` (D12). One build tree + imposes one of those on both, and "the hypervisor's build settings silently + changed how the kernel was compiled" is not a sentence anybody wants in a + defect report. +* **The guest is built the way ThreadX's own CI builds it**, because it is + configured with ThreadX's toolchain file. A guest compiled some other way is + a guest whose failures are not the kernel's. +* **A guest ELF must never enter the hypervisor's link.** Two ThreadX-shaped + images in one link resolve into each other silently, chosen by link order + rather than by intent. Separate projects make that impossible rather than + merely unlikely. + +The guest's toolchain **follows the lane**: ZoneX builds with +`arm-none-eabi-gcc` and with ATfE clang, and the guest support under +`examples/guest_common` is ZoneX's own code even though it is compiled into a +ThreadX image — so it must face both compilers like everything else here. +Pinning the guest to GCC would leave those files compiled by one toolchain, and +that is exactly the gap a GNU-only construct gets through. + +**`BUILD_ALWAYS` is on, and it is not laziness.** The outer build cannot see +into the sub-build's dependency graph, so without it, editing the guest's +application leaves a stale blob embedded in an image that relinks happily — +the same class of quiet failure as a linker pattern that matches nothing. +`OBJECT_DEPENDS` on the blob is the other half: `add_dependencies` orders the +two builds but says nothing about which objects are stale when the blob +changes. + +**CI pins a ThreadX commit rather than tracking a branch.** An unpinned guest +would make this suite's verdict depend on a second repository's latest state, +so a ZoneX pull request could go red for a reason that had nothing to do with +it and everything to do with a kernel change landing that morning. Bumping the +pin is then a deliberate, reviewable change with its own diff — which is what +upgrading a dependency should look like. + +--- + +## D23 — What ZoneX takes over from a guest's boot path · **settled 2 Sep 2026** + +**Two things: `CNTFRQ` and `HCPTR.TCP10/TCP11`. One thing deliberately NOT: +`CNTHCTL.PL1PCTEN` and `PL1PCEN`.** + +A standalone Cortex-R52 kernel resets *into* EL2 and configures it in its own +boot path. Built as a guest it skips that block entirely — which is the whole +point of the port's `TX_R52_BOOT_AT_EL1` option — so the work does not stop +being necessary, it changes owner. This is the list, and keeping it short is +itself a design goal: everything on it is a coupling between the hypervisor and +a guest's expectations. + +* **`CNTFRQ` is programmed** from the board's declared counter frequency. It is + writable only at the highest implemented exception level and reads **zero out + of reset on both ZoneX targets**, so a guest deriving a tick interval from it + divides by zero. The frequency is a software-declared constant in both cases: + `CNTFID0` read back from the counter control frame on the model, and a + measured-and-cross-checked clock tree on the S32Z280. +* **`HCPTR.TCP10/TCP11` are cleared**, so a guest may use its FPU without every + access trapping to EL2. Both reset SET. + +**And the omission is the interesting half.** A standalone kernel opens +`CNTHCTL.PL1PCTEN` and `PL1PCEN`, because it owns the physical timer. A +partition must not have them: **a partition's physical time keeps running while +it is descheduled**, so a guest reading it can observe that it was not running +— and that observation is the temporal-determinism claim, lost. Guests get the +virtual timer with a per-partition `CNTVOFF` instead (D7), which needs no +enable here. This is the first place where "what a kernel does standalone" and +"what a kernel may do as a partition" genuinely diverge, rather than merely +moving. + +**One consequence is recorded now because it becomes a defect later.** Clearing +the FPU traps lets a guest use floating point; nothing in ZoneX saves or +restores `FPEXC`, `FPSCR` or the D-registers across a partition switch. With +one partition that is exactly correct. With two it is two guests sharing a +register bank, which is the kind of thing that is found by a wrong answer +rather than by a fault. + +The same list, written from the kernel's side, is at the `#ifndef` in the +S32Z280 `entry.S` — next to the code it replaces, which is where somebody +adding a third board will be looking. diff --git a/examples/common/zx_guest_abi.h b/examples/common/zx_guest_abi.h index 81a3e4e..ac9fe20 100644 --- a/examples/common/zx_guest_abi.h +++ b/examples/common/zx_guest_abi.h @@ -21,9 +21,26 @@ /* */ /* DESCRIPTION */ /* */ -/* The contract between the hypervisor and the RELOCATABLE guest blob: */ -/* where each entry point sits inside the blob, and what each word of */ -/* the partition's data window means. */ +/* The contract between the hypervisor and a guest image: where each */ +/* entry point sits, and what each word of the readback mailbox means. */ +/* */ +/* TWO KINDS OF GUEST SHARE THIS FILE, and the difference is worth */ +/* stating before either section is read. */ +/* */ +/* * The RELOCATABLE BLOB -- a few dozen instructions, assembled */ +/* once and copied into two partitions, which is what makes "set */ +/* A denies B and set B denies A" demonstrable. Its entry points */ +/* are at fixed offsets and it reaches its data through a base */ +/* pointer handed to it in r0. */ +/* * A REAL KERNEL IMAGE -- a whole ThreadX, linked for one */ +/* partition's window, which owns its own vectors, stacks and */ +/* stage-1 MPU. It cannot take a base pointer in r0, because its */ +/* reset path clobbers r0 long before any C runs, so its mailbox */ +/* is at a FIXED OFFSET IN ITS OWN WINDOW that both sides know. */ +/* */ +/* The mailbox layout is shared between them deliberately: one */ +/* vocabulary for "what a partition reported" means one reader at the */ +/* hypervisor end, whichever kind of guest produced it. */ /* */ /* WHY A RELOCATABLE BLOB AT ALL */ /* */ @@ -111,6 +128,100 @@ #define ZX_GD_SHARED 0x10U /* EL2 writes: shared granule */ #define ZX_GD_PUBLISH_VALUE 0x14U /* EL2 writes: what to publish */ +/* THE SAME WORD, UNDER A SECOND NAME, for a kernel guest. + * + * A kernel guest has no publisher role -- the shared granule and its + * heartbeat belong to the relocatable blob, which is what needs two + * partitions running the same code. So this word is free at that end, and + * it carries run options instead. + * + * Two names for one offset is worth one sentence of explanation and no more: + * each has exactly one reader, and both are in this file where a reader of + * either can see the other. Adding a seventeenth word to a sixteen-word + * granule would have cost a second granule and a second stage-2 region. */ + +#define ZX_GD_OPTIONS 0x14U /* EL2 writes: ZX_GO_* */ + +/* ZX_GO_QUIET exists so that "what does stage 2 cost a guest" can be + measured at all. + * + * The guest's console is one hypercall PER CHARACTER through a polled UART, + * which on silicon is several million cycles for a few hundred lines -- three + * orders of magnitude more than everything else the guest does. A run with + * the console on cannot resolve the cost of stage 2 from noise; it can only + * bound it from above, which is a different and much weaker statement. + * + * So the measured passes run quiet and the demonstration pass runs loud. + * Both are the same guest image: the option is a word in the mailbox, not a + * build, precisely so that the run being measured is the run being + * demonstrated. */ + +#define ZX_GO_QUIET 0x00000001U + +/* THE READBACK CONVENTION, and why it is a structure rather than one word. + * + * A single progress word is enough to say "the guest got somewhere". It is + * not enough for a regression to BELIEVE what it read, and three separate + * failures make that difference concrete: + * + * * A window nobody ever wrote reads as zero, and zero is a perfectly + * plausible progress value. "The guest never started" and "the guest + * started and reported nothing" are different diagnoses. + * * A run that reads a partition's memory AFTER the fact attributes the + * last writer's value to everyone who shared the address. That cost a + * wrong conclusion during the Cortex-R52 Modules port work, where a + * progress word in a shared granule was read back as one module's when + * it was another's. + * * A guest torn off mid-update -- descheduled, or faulted -- leaves a + * half-written set of words that is individually valid and jointly + * nonsense. + * + * So the guest publishes a SEQUENCE it increments on every update and a + * CHECKSUM over everything it reported, and the sentinel that identifies + * which partition wrote it is folded INTO that checksum. A reader then has + * three independent things to check: the sequence advanced, the checksum + * agrees, and the identity inside it is the partition it thinks it is + * reading. Cheap for a guest -- one add per field -- and it is what lets a + * later determinism regression compare two runs rather than merely observe + * one. + * + * The order matters at the guest end and is stated where it is implemented: + * every reported field is written BEFORE the sequence, and the sequence + * before the checksum, so a torn update fails the checksum instead of + * passing with stale content. */ + +#define ZX_GD_SEQUENCE 0x18U /* guest writes: update count */ +#define ZX_GD_CHECKSUM 0x1CU /* guest writes: over the above */ +#define ZX_GD_TICKS 0x20U /* guest writes: its own ticks */ +#define ZX_GD_THREAD_A 0x24U /* guest writes: thread A count */ +#define ZX_GD_THREAD_B 0x28U /* guest writes: thread B count */ +#define ZX_GD_MESSAGES 0x2CU /* guest writes: queue traffic */ +#define ZX_GD_VERDICT 0x30U /* guest writes: ZX_GV_* */ + +/* What the guest's OWN vectors saw, when a stage-1 fault took it. + * + * A stage-1 fault never reaches EL2 -- that is the whole difference between + * the two stages, and it is what makes them distinguishable in a log -- so + * the only way the hypervisor learns of one is if the guest tells it. These + * two words are DFSR and DFAR as the guest's abort handler read them, and + * they are the evidence that the guest's own MPU is live underneath stage 2 + * rather than merely programmed. */ + +#define ZX_GD_FAULT_STATUS 0x34U /* guest writes: its own DFSR */ +#define ZX_GD_FAULT_ADDRESS 0x38U /* guest writes: its own DFAR */ +#define ZX_GD_STAGE1 0x3CU /* guest writes: ZX_GS_* vector */ + +/* THESE THREE WORDS ARE DELIBERATELY OUTSIDE THE CHECKSUM, and the reason is + who writes them. They are written by the guest's own exception vector, + which runs with no stack, no literal pool beyond the mailbox address, and + an obligation to disturb as little as possible -- computing a checksum + there would be absurd. So the sealed snapshot covers what a THREAD + reported, and these carry what a HANDLER saw afterwards. + + That is not a hole. ZX_GD_STAGE1 is a single word naming which vector + fired, so a torn write of the three leaves it zero and the hypervisor + reads "no stage-1 fault" rather than a half-described one. */ + #define ZX_GD_WINDOW_SIZE 0x40U /**************************************************************************/ @@ -131,6 +242,42 @@ #define ZX_GP_PUBLISHED 0x08U #define ZX_GP_CONSUMED 0x10U +/* The bits a REAL kernel guest sets, as it gets further. Each one is a + milestone the one before it cannot fake: reaching bsp_main proves the + ERET landed and the boot path ran, reaching tx_application_define proves + the kernel initialised, and a thread running proves the ported context + switch works underneath stage 2. A run that stops between two of them + says where it stopped, which a single "did it work" bit cannot. */ + +#define ZX_GP_BSP_MAIN 0x0100U /* the boot path reached C */ +#define ZX_GP_KERNEL_ENTERED 0x0200U /* tx_application_define ran */ +#define ZX_GP_THREADS_RAN 0x0400U /* both threads took a slice */ +#define ZX_GP_QUEUE_OK 0x0800U /* the queue carried every item */ +#define ZX_GP_SEMAPHORE_OK 0x1000U /* the semaphore handed off */ +#define ZX_GP_TICKING 0x2000U /* the guest's own tick advanced */ +#define ZX_GP_FINISHED 0x4000U /* the guest reached its verdict */ + +/* WHAT THE GUEST'S OWN VECTORS SAW, in ZX_GD_STAGE1. + * + * A stage-1 fault is taken to EL1 and never reaches the hypervisor, which is + * the whole difference between the two stages -- so this word is the only + * channel by which one becomes visible at EL2, and a report of one is + * evidence that the guest's own MPU is LIVE rather than merely programmed. + * + * One value per vector rather than one "the guest faulted" flag, because a + * data abort, a prefetch abort and an undefined instruction send a reader to + * three different halves of a kernel. Each is a single bit so that an ORR + * with an immediate assembles: the handlers that set them run before + * anything else, with no stack and no literal pool beyond the mailbox + * address. */ + +#define ZX_GS_NONE 0x00000000U /* no stage-1 fault */ +#define ZX_GS_DABT 0x00000001U /* data abort at EL1 */ +#define ZX_GS_PABT 0x00000002U /* prefetch abort at EL1 */ +#define ZX_GS_UNDEF 0x00000004U /* undefined instruction */ +#define ZX_GS_IRQ 0x00000008U /* an IRQ with no handler */ +#define ZX_GS_OTHER 0x00000010U /* a vector nothing expects */ + /* The sentinel a guest writes into its own scratch word. Distinct per partition so that EL2 reading both back cannot mistake one for the other -- which is the failure a single shared sentinel would hide. */ @@ -140,4 +287,177 @@ /* What the publisher puts in the shared granule. */ #define ZX_GUEST_HEARTBEAT 0x48420000U +/**************************************************************************/ +/* A REAL KERNEL IMAGE IN A WINDOW */ +/**************************************************************************/ + +/* Where things sit inside a partition window that holds a whole kernel. + * + * WHY A TRAMPOLINE AT OFFSET ZERO RATHER THAN THE KERNEL'S OWN _start. + * The hypervisor computes the ERET target as window_base + a constant, so it + * needs that constant at compile time. A real kernel's reset symbol is + * wherever its linker put it, after its vector tables, and its address is + * therefore neither zero nor predictable. Three ways out were considered: + * + * * extract the ELF entry point at build time into a generated header -- + * works, and makes the hypervisor's manifest depend on a build step + * whose output nothing checks; + * * match the boot object's .text by input pattern in the linker script so + * that _start lands first -- the pattern has to name an object file + * suffix, which differs between toolchains, and a pattern that matches + * nothing produces an empty section at whatever address the location + * counter held rather than an error; + * * put ONE BRANCH at the window base and let it reach the kernel's own + * reset symbol PC-relatively. + * + * The third is four bytes, needs no build step, and its correctness is + * asserted by the guest's own linker script. It is also the thing a real + * boot ROM does, which is a reasonable sign it is not a trick. + * + * WHY THE MAILBOX IS NOT IN .bss. The kernel's reset path zeroes .bss + * before any C runs. The hypervisor writes the mailbox before it ERETs -- + * it has to, that is how the guest is told which partition it is -- so a + * mailbox in .bss would be handed over and then wiped by its recipient. It + * therefore gets a section of its own, placed before .data, and the guest's + * linker script asserts it is outside the range the reset path clears. */ + +/* THE MAILBOX COMES FIRST, AND THE ORDER IS FORCED. + * + * Two constraints point the same way and a third decides between the + * remaining options. + * + * * The mailbox may not sit INSIDE the guest's own executable region. + * PMSAv8-R has no region priority at either stage, so a writable + * granule overlapping a read-only executable region is two enabled + * regions on one address -- CONSTRAINED UNPREDICTABLE, and an abort on + * the S32Z280. So the mailbox cannot be tucked between the entry + * branch and the vector table. + * * The mailbox may not be in .bss. The kernel's reset path zeroes .bss + * before any C runs, and the hypervisor writes the mailbox BEFORE it + * ERETs -- that is how the guest is told which partition it is -- so a + * mailbox in .bss would be handed over and then wiped by its recipient. + * * It is LOADED rather than NOLOAD, which is what settles its position. + * objcopy -O binary emits only sections with contents, so a NOLOAD + * section at the front of the window would be skipped and the blob's + * first byte would correspond to the SECOND section -- the whole image + * shifted by a granule, copied to the window base, and executed from + * the wrong place. A loaded, zero-filled granule keeps the blob's + * offset zero equal to the window's offset zero, which is the property + * the loader's arithmetic rests on. + * + * Being loaded also means the copy leaves the mailbox ZEROED, which is a + * defined starting state rather than whatever the window held before. The + * hypervisor writes its handover fields after the copy, never before. */ + +#define ZX_GUEST_IMAGE_OFF_MAILBOX 0x00U /* one granule, ZX_GD_* inside */ +#define ZX_GUEST_IMAGE_OFF_ENTRY 0x40U /* the branch the ERET lands on */ + +/* THE IMAGE HEADER, three words after the entry branch. + * + * A guest is LINKED for one window: every absolute address in it is baked + * in. The hypervisor holds a raw blob with no symbol table, so without + * these words it has no way to know which window that was -- and the failure + * is slow rather than immediate, because the entry branch is PC-relative. + * A guest copied into the wrong window STARTS, runs to its first literal + * pool load, and then faults at an address that looks entirely reasonable in + * the report. + * + * The magic is worth its four bytes twice: an unwritten window reads as + * zero, and an .incbin whose linker input pattern matched nothing produces + * an EMPTY section rather than an error, so "the header does not carry the + * magic" catches both a missing guest and a wrong one. */ + +#define ZX_GUEST_IMAGE_OFF_LINK_BASE 0x44U /* the window it was linked for */ +#define ZX_GUEST_IMAGE_OFF_LINK_SIZE 0x48U /* the size it was linked to fit */ +#define ZX_GUEST_IMAGE_OFF_MAGIC 0x4CU /* ZX_GUEST_IMAGE_MAGIC */ + +/* "ZXG" and a version. The version is here so that a later ABI change is a + REFUSAL rather than a guest that starts and misbehaves: the loader can say + "this image was built against a different contract" and name both + numbers. */ + +#define ZX_GUEST_IMAGE_MAGIC 0x5A584731 + +/* The verdict a kernel guest publishes into ZX_GD_VERDICT. Distinct + non-zero values, so that an unwritten word -- zero -- is neither a pass + nor a fail but the absence of a verdict, which is what it actually is. */ + +#define ZX_GV_NONE 0x00000000U +#define ZX_GV_PASSED 0x600DBEEFU +#define ZX_GV_FAILED 0x0BADBEEFU + +/**************************************************************************/ +/* The hypercalls a guest makes */ +/**************************************************************************/ + +/* The HVC immediates, spelled WITHOUT an integer suffix so that the guest's + own assembly can write "hvc #ZX_HVC_GUEST_YIELD" directly. + * + * These are the same three values the hypervisor decodes, where they are + * called ZX_HVC_NOP, ZX_HVC_YIELD and ZX_HVC_PUTC. Two spellings is one + * more than nobody wants, and it is unavoidable: the guest cannot include + * the hypervisor's own headers -- it is a separate program with a separate + * toolchain invocation and its own type vocabulary -- so the contract has to + * be restated at this end. + * + * What makes that safe rather than a latent divergence is that the ZoneX + * example which includes BOTH headers asserts they agree, at compile time. + * A guest built against a renumbered immediate would then fail the + * hypervisor's build rather than fail on the model. */ + +#define ZX_HVC_GUEST_NOP 0x0000 +#define ZX_HVC_GUEST_YIELD 0x0001 +#define ZX_HVC_GUEST_PUTC 0x0002 + +/**************************************************************************/ +/* The readback checksum */ +/**************************************************************************/ + +/* Folded over every field the guest reports, plus the sentinel that says + WHICH partition reported it. Defined here, as one function, so that the + guest computing it and the hypervisor checking it cannot drift: two + implementations of a checksum agree until the day they do not, and the + day they do not looks exactly like a corrupted guest. + + Deliberately additive and deliberately weak. This is not a defence + against a hostile guest -- a partition can write whatever it likes into + its own window, and no checksum changes that. It defends against three + accidents: a window nobody wrote (every field zero, which the magic makes + fail), a torn update (the sequence is folded in and is written last but + one), and a value read out of the WRONG partition's window (the sentinel + is folded in). A stronger function would cost the guest more and catch + nothing further. + + ZX_ASSEMBLER guard: this file is included from zx_payload.S, and an + assembler cannot read a function definition. */ + +#ifndef __ASSEMBLER__ + +#define ZX_GUEST_REPORT_MAGIC 0x5A58F00DU + +static inline unsigned long zx_guest_report_checksum(unsigned long sentinel, + unsigned long progress, + unsigned long sequence, + unsigned long ticks, + unsigned long thread_a, + unsigned long thread_b, + unsigned long messages, + unsigned long verdict) +{ + unsigned long sum = (unsigned long) ZX_GUEST_REPORT_MAGIC; + + sum += sentinel; + sum += progress; + sum += sequence; + sum += ticks; + sum += thread_a; + sum += thread_b; + sum += messages; + sum += verdict; + + return sum & 0xFFFFFFFFUL; +} + +#endif /* __ASSEMBLER__ */ + #endif /* ZX_GUEST_ABI_H */ diff --git a/examples/common/zx_guest_image.S b/examples/common/zx_guest_image.S new file mode 100644 index 0000000..8616119 --- /dev/null +++ b/examples/common/zx_guest_image.S @@ -0,0 +1,60 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +@ zx_guest_image.S -- the ThreadX guest image, embedded in the hypervisor. +@ +@ WHY A BLOB AND NOT A LINKED-IN LIBRARY +@ +@ Because a guest ELF must never enter the hypervisor's link. Two +@ ThreadX-shaped images in one link resolve into each other silently: +@ objects beat static libraries at link time, so the guest's copy of a +@ symbol and the hypervisor's are chosen by link order rather than by +@ intent, and the resulting image builds and reports a plausible size. +@ +@ So the guest is built as a separate program, by a separate toolchain +@ invocation, reduced to a raw binary with objcopy, and pulled in HERE as +@ opaque bytes. The hypervisor's linker sees a lump of data with two +@ symbols around it and no symbols inside it, which is exactly the right +@ amount of coupling. +@ +@ .incbin SEARCHES THE ASSEMBLER'S INCLUDE PATH, NOT THE COMPILER'S +@ +@ -I on the compiler command line does not reach the assembler; it needs +@ -Wa,-I. The example's CMakeLists passes the guest's build directory that +@ way through set_source_files_properties on this file. Getting it wrong +@ fails the assembly with "file not found", which is the good outcome and +@ the reason this is written down: the neighbouring mistake -- a LINKER +@ input pattern that matches nothing -- does not fail at all. It produces +@ an empty section at whatever address the location counter held, and an +@ image that builds, links, reports a plausible size and contains no guest. +@ +@ Two independent checks exist for that, because it has happened before: +@ the linker script asserts the blob is bigger than the header it must +@ contain, and the loader checks the image's own magic number before it +@ ERETs anywhere. + +#include "zx_guest_abi.h" + + .section .zx_guest_a_blob, "a", %progbits + .balign 64 + + .global __zx_guest_a_image_start +__zx_guest_a_image_start: + + .incbin "guest_a.bin" + + .balign 4 + .global __zx_guest_a_image_end +__zx_guest_a_image_end: diff --git a/examples/common/zx_one_partition.c b/examples/common/zx_one_partition.c new file mode 100644 index 0000000..304294e --- /dev/null +++ b/examples/common/zx_one_partition.c @@ -0,0 +1,1126 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* EXAMPLE DESCRIPTION */ +/* */ +/* zx_one_partition.c SHARED */ +/* */ +/* DESCRIPTION */ +/* */ +/* One REAL ThreadX kernel, booting at EL1 inside a stage-2 confined */ +/* partition that ZoneX loaded and launched. */ +/* */ +/* Everything before this image ran a payload written to be run: a few */ +/* dozen instructions with no kernel, no stack use worth the name and */ +/* no vectors of its own. This one loads a whole kernel, which is the */ +/* difference between "stage 2 works" and "a hypervisor works". */ +/* */ +/* THE FIVE THINGS THIS IMAGE PROVES, AND HOW */ +/* */ +/* 1. A real kernel BOOTS in a partition. The guest sets progress */ +/* bits at four milestones, each of which the one before it */ +/* cannot fake, and the hypervisor reads them out of the guest's */ +/* memory afterwards rather than trusting anything the guest */ +/* printed. */ +/* */ +/* 2. It SCHEDULES. Two threads, a queue between them and a */ +/* semaphore -- the ported context switch running underneath a */ +/* stage-2 MPU, with every message checked by value and not by */ +/* count. */ +/* */ +/* 3. It PRINTS through the hypervisor. One hypercall per character, */ +/* tagged with the partition the hypervisor scheduled, so a guest */ +/* cannot claim to be another one. */ +/* */ +/* 4. ITS OWN MPU IS LIVE UNDERNEATH STAGE 2, and the two stages are */ +/* distinguishable. A stage-1 fault is taken to EL1 and reaches */ +/* the hypervisor only because the guest's own vector tells it; a */ +/* stage-2 violation is taken to EL2 and reported there. Two */ +/* different reports from two different privilege levels. */ +/* */ +/* 5. WHAT ZONEX COSTS. The guest is run twice, once with HCR.VM */ +/* clear and once set, and the difference is the first honest */ +/* number for "what does the hypervisor cost" -- which somebody */ +/* will ask. */ +/* */ +/* WHAT A PASS DOES NOT MEAN */ +/* */ +/* On the Armv8-R AEM FVP a pass proves the code is right and says */ +/* NOTHING about timing: the model is functional, not cycle-accurate, */ +/* and it reports a region count no Cortex-R52 can have. Every */ +/* number this image prints has to come from silicon before it is */ +/* quoted. The image says so in its own output rather than leaving it */ +/* to this comment. */ +/* */ +/* On lockstep silicon it demonstrates memory isolation on ONE logical */ +/* core. It does not demonstrate spatial multicore partitioning. */ +/* */ +/**************************************************************************/ + +#include "zx_probe.h" +#include "zx_platform.h" +#include "zx_guest_abi.h" +#include "zx_manifest.h" +#include "zx_mm.h" +#include "zx_partition.h" +#include "zx_guest_console.h" + +/* THE GUEST'S HYPERCALL NUMBERS AND THE HYPERVISOR'S MUST AGREE, and this + is the only translation unit that can see both spellings. The guest is a + separate program with its own toolchain invocation and cannot include + zx_fault.h; it restates the contract in zx_guest_abi.h. Asserting the two + against each other here turns a renumbered immediate into a failed BUILD + rather than a guest whose console output vanishes on the model. */ + +_Static_assert(ZX_HVC_GUEST_NOP == (int)ZX_HVC_NOP, + "the guest and the hypervisor disagree about HVC #0"); +_Static_assert(ZX_HVC_GUEST_YIELD == (int)ZX_HVC_YIELD, + "the guest and the hypervisor disagree about the yield HVC"); +_Static_assert(ZX_HVC_GUEST_PUTC == (int)ZX_HVC_PUTC, + "the guest and the hypervisor disagree about the console HVC"); + +/* The embedded guest image and partition A's window, from the linker + script. */ + +extern char __zx_guest_a_blob_start[]; +extern char __zx_guest_a_blob_end[]; +extern char __zx_partition_a_start[]; +extern char __zx_partition_a_end[]; +extern char __zx_partition_a_hole_start[]; + +#define ZX_PARTITION_A_ID 1U +#define ZX_PARTITION_A_INDEX 0U + +/* The sentinel the hypervisor leaves in the guest's mailbox, and which the + guest folds into every checksum. Distinct from the probe payload's so + that a log from one image cannot be misread as the other's. */ + +#define ZX_GUEST_A_SENTINEL 0xA5A5000AU + +static ZX_REGION zx_regions_a[1]; +static ZX_PARTITION zx_partitions[1]; +static ZX_MANIFEST zx_manifest; +static ZX_MM_LAYOUT zx_layout; +static ZX_MANIFEST_ENV zx_env; +static ZX_PARTITION_CB zx_partition_a; +static ZX_REGION zx_mmio[ZX_BOARD_MAX_MMIO_REGIONS]; + + +/**************************************************************************/ +/* The mailbox, as the hypervisor sees it. */ +/* */ +/* EL2 can read and write a partition's memory because stage-2 AP cannot */ +/* deny EL2 (docs/decisions.md D3) -- the same property that stops AP */ +/* from isolating partitions is what makes a hypervisor-to-guest mailbox */ +/* free. It is at a fixed offset in the window rather than passed in a */ +/* register because a real kernel's reset path clobbers r0 long before */ +/* any of its C runs. */ +/**************************************************************************/ + +static volatile uint32_t *zx_mailbox(void) +{ + return (volatile uint32_t *)(uintptr_t) + (zx_symbol_address(__zx_partition_a_start) + + ZX_GUEST_IMAGE_OFF_MAILBOX); +} + + +static uint32_t zx_mailbox_read(uint32_t offset) +{ + return zx_mailbox()[offset / 4U]; +} + + +static void zx_mailbox_write(uint32_t offset, uint32_t value) +{ + zx_mailbox()[offset / 4U] = value; +} + + +/**************************************************************************/ +/* zx_image_word -- one word out of the embedded guest image. */ +/* */ +/* Read from the SOURCE and not from the window, deliberately: the point */ +/* of the header check is to refuse before anything is copied, and a */ +/* check performed on the copy would already have overwritten whatever */ +/* was there. */ +/**************************************************************************/ + +static uint32_t zx_image_word(uint32_t offset) +{ + const volatile uint32_t *image = (const volatile uint32_t *)(uintptr_t) + zx_symbol_address(__zx_guest_a_blob_start); + + return image[offset / 4U]; +} + + +/**************************************************************************/ +/* zx_build_manifest */ +/* */ +/* ONE PARTITION, ONE REGION, and both of those are worth a sentence. */ +/* */ +/* One partition because two are the next step and adding them here */ +/* would mean debugging a launch path and a time-partition scheduler at */ +/* once. One region because the guest's code and data are CONTIGUOUS in */ +/* a single window: a partition split across two far-apart areas produces */ +/* a blob the size of the gap between them under objcopy -O binary, which */ +/* on the silicon target is 1.2 GB. */ +/* */ +/* The window is therefore both executable and writable at stage 2, and */ +/* that is not a weakness in the isolation claim -- it is where the */ +/* division of labour between the two stages shows. Stage 2 says which */ +/* MEMORY a partition owns; the guest's own stage-1 MPU says which parts */ +/* of it are code. The loader needs the window writable anyway. */ +/**************************************************************************/ + +static void zx_build_manifest(uint32_t board_regions) +{ + zx_regions_a[0].zx_region_base = + zx_symbol_address(__zx_partition_a_start); + zx_regions_a[0].zx_region_limit = + zx_symbol_address(__zx_partition_a_end) - 1U; + zx_regions_a[0].zx_region_ap = ZX_AP_EL2_RW_GUEST_RW; + zx_regions_a[0].zx_region_xn = ZX_XN_EXECUTABLE; + zx_regions_a[0].zx_region_sh = ZX_SH_NON_SHAREABLE; + zx_regions_a[0].zx_region_attr_index = (UCHAR)ZX_ATTR_NORMAL_WB; + + zx_partitions[0].zx_partition_id = ZX_PARTITION_A_ID; + zx_partitions[0].zx_partition_name = "ThreadX guest A"; + zx_partitions[0].zx_partition_image_start = + zx_symbol_address(__zx_guest_a_blob_start); + zx_partitions[0].zx_partition_image_end = + zx_symbol_address(__zx_guest_a_blob_end); + zx_partitions[0].zx_partition_entry = + zx_symbol_address(__zx_partition_a_start) + ZX_GUEST_IMAGE_OFF_ENTRY; + zx_partitions[0].zx_partition_regions = zx_regions_a; + zx_partitions[0].zx_partition_region_count = 1U; + zx_partitions[0].zx_partition_window_ticks = 10U; + + zx_manifest.zx_manifest_partitions = zx_partitions; + zx_manifest.zx_manifest_partition_count = 1U; + zx_manifest.zx_manifest_shared = (const ZX_SHARED *)0; + zx_manifest.zx_manifest_shared_count = 0U; + zx_manifest.zx_manifest_major_frame_ticks = 10U; + + zx_board_describe_mmio_regions(zx_mmio); + + zx_env.zx_env_mmio_regions = zx_mmio; + zx_env.zx_env_mmio_region_count = board_regions; + + /* HMAIR indices this image programmed -- 0, 1 and 2. A region naming + any other index would get a zero MAIR byte, which is Device-nGnRnE: + memory that works, slowly, with nothing to fault on. */ + zx_env.zx_env_attr_written_mask = 0x07U; +} + + +/**************************************************************************/ +/* zx_check_image_header */ +/* */ +/* THE CHECK THAT CATCHES A GUEST BUILT FOR SOMEWHERE ELSE. */ +/* */ +/* A guest is LINKED for one window; every absolute address inside it is */ +/* baked in. The hypervisor holds a raw blob with no symbol table, so */ +/* without the header the guest carries there is no way to know which */ +/* window that was -- and the failure is slow, not immediate: the entry */ +/* branch is PC-relative, so a guest copied into the wrong window STARTS, */ +/* runs to its first literal pool load, and then faults at an address */ +/* that looks entirely reasonable in the report. */ +/* */ +/* The magic earns its four bytes twice over. A window nobody wrote */ +/* reads as zero, and an .incbin whose linker input pattern matched */ +/* nothing produces an EMPTY section rather than an error -- so "the */ +/* header does not carry the magic" catches both a missing guest and a */ +/* wrong one. */ +/**************************************************************************/ + +static uint32_t zx_check_image_header(void) +{ + uint32_t magic = zx_image_word(ZX_GUEST_IMAGE_OFF_MAGIC); + uint32_t link_base = zx_image_word(ZX_GUEST_IMAGE_OFF_LINK_BASE); + uint32_t link_size = zx_image_word(ZX_GUEST_IMAGE_OFF_LINK_SIZE); + uint32_t window_base = (uint32_t)zx_symbol_address(__zx_partition_a_start); + uint32_t window_size = (uint32_t)(zx_symbol_address(__zx_partition_a_end) + - zx_symbol_address(__zx_partition_a_start)); + + zx_console_puts("\n--- the guest image declares what it was built for ---\n"); + zx_note("magic ", magic); + zx_note("linked for base ", link_base); + zx_note("linked for size ", link_size); + zx_note("this window's base ", window_base); + zx_note("this window's size ", window_size); + + zx_check("the embedded image carries the ZoneX guest magic, so a missing\n" + " .incbin or an empty section is caught before anything runs", + (magic == (uint32_t)ZX_GUEST_IMAGE_MAGIC) ? 1U : 0U); + + zx_check("the guest was linked for THIS window's base -- a guest built\n" + " for another address starts and then faults at an\n" + " address that looks perfectly reasonable", + (link_base == window_base) ? 1U : 0U); + + zx_check("and for this window's size, so a guest that expected more room\n" + " than the manifest grants is refused rather than\n" + " discovered at its first stack overflow", + (link_size == window_size) ? 1U : 0U); + + return ((magic == (uint32_t)ZX_GUEST_IMAGE_MAGIC) + && (link_base == window_base) + && (link_size == window_size)) ? 1U : 0U; +} + + +/**************************************************************************/ +/* zx_load_partition -- copy the image and make it executable. */ +/* */ +/* The COPY is here and the DECISION about where to copy is in */ +/* core/src/zx_partition_manager.c, which is the split zx_partition.h */ +/* describes: the arithmetic is testable on a workstation, the copy is */ +/* not. */ +/* */ +/* A word copy, not a byte copy, and both ends are granule aligned by */ +/* construction -- the linker script asserts it at each end -- so the */ +/* alignment the word copy needs is a property of the build rather than */ +/* a run-time hope. The length is rounded UP to a word because a blob */ +/* ending mid-word would otherwise lose its last bytes, and objcopy has */ +/* no obligation to pad. */ +/**************************************************************************/ + +static void zx_load_partition(const ZX_PARTITION_LOAD *load_ptr) +{ + const volatile uint32_t *source = (const volatile uint32_t *)(uintptr_t) + load_ptr->zx_load_image_source; + volatile uint32_t *destination = (volatile uint32_t *)(uintptr_t) + load_ptr->zx_load_window_base; + uint32_t words = (uint32_t)((load_ptr->zx_load_image_length + 3U) / 4U); + uint32_t index; + + for (index = 0U; index < words; index++) + { + destination[index] = source[index]; + } + + /* The bytes just written will be FETCHED as instructions, and on this + core the instruction side is not coherent with the data cache. ZoneX + runs with its caches off today, so this is currently redundant -- and + it is here anyway, because the change that turns caches on will be one + line in the reset path made by somebody with no reason to think about + the loader. A cold instruction cache over an address nothing has + executed HAPPENS TO WORK until an eviction lands differently. */ + + zx_cache_sync_after_load(load_ptr->zx_load_window_base, + load_ptr->zx_load_image_length); +} + + +/**************************************************************************/ +/* zx_hand_over -- what the hypervisor tells the guest before it runs. */ +/* */ +/* AFTER the copy, never before. The mailbox is a LOADED section in the */ +/* guest image -- zeroed by the copy -- which is what keeps the blob's */ +/* offset zero equal to the window's offset zero. Writing the handover */ +/* first would have it copied over. */ +/**************************************************************************/ + +static void zx_hand_over(uint32_t probe_target, uint32_t options) +{ + zx_mailbox_write(ZX_GD_PROGRESS, 0U); + zx_mailbox_write(ZX_GD_SCRATCH, ZX_GUEST_A_SENTINEL); + zx_mailbox_write(ZX_GD_TARGET, probe_target); + zx_mailbox_write(ZX_GD_PROBED, 0U); + zx_mailbox_write(ZX_GD_SHARED, 0U); + zx_mailbox_write(ZX_GD_OPTIONS, options); + zx_mailbox_write(ZX_GD_SEQUENCE, 0U); + zx_mailbox_write(ZX_GD_CHECKSUM, 0U); + zx_mailbox_write(ZX_GD_TICKS, 0U); + zx_mailbox_write(ZX_GD_THREAD_A, 0U); + zx_mailbox_write(ZX_GD_THREAD_B, 0U); + zx_mailbox_write(ZX_GD_MESSAGES, 0U); + zx_mailbox_write(ZX_GD_VERDICT, ZX_GV_NONE); + zx_mailbox_write(ZX_GD_FAULT_STATUS, 0U); + zx_mailbox_write(ZX_GD_FAULT_ADDRESS, 0U); + zx_mailbox_write(ZX_GD_STAGE1, ZX_GS_NONE); + + __asm__ volatile("dsb" ::: "memory"); +} + + +/**************************************************************************/ +/* zx_run_guest -- one excursion, measured. */ +/* */ +/* The PMU counter is read either side rather than a timer, because */ +/* CNTFRQ reads zero on both ZoneX targets and there is nothing to */ +/* convert with. Cycles are also the unit a worst-case-execution-time */ +/* argument is made in, so no conversion is wanted. */ +/**************************************************************************/ + +static uint32_t zx_run_guest(uint32_t *cycles_ptr) +{ + zx_addr_t entry = zx_partition_a.zx_partition_load.zx_load_entry; + uint32_t start; + uint32_t outcome; + + zx_fault_record_reset(zx_el2_fault_record()); + + /* The tag comes from the partition the hypervisor SCHEDULED, which is + the only party that knows it. A guest that prefixed its own lines + could claim to be another partition, and every line of a captured log + would then be evidence of nothing. */ + + zx_guest_console_attach(ZX_PARTITION_A_ID, + zx_partitions[0].zx_partition_name); + + start = zx_pmu_cycles(); + + /* The argument is zero and is not a mailbox pointer. A real kernel's + reset path clobbers r0 within two instructions of the ERET, so + anything passed in it would be gone before any C ran -- which is + exactly why the mailbox is at a fixed offset in the window instead. */ + + outcome = zx_el2_run_payload(entry, 0U); + + *cycles_ptr = zx_pmu_cycles() - start; + + zx_guest_console_detach(); + + zx_partition_returned(&zx_partition_a, zx_el2_fault_record()); + + return outcome; +} + + +/**************************************************************************/ +/* zx_report_mailbox */ +/* */ +/* The checksum is verified before anything the guest reported is */ +/* believed, and that ordering is the point. Three failures it catches: */ +/* a window nobody ever wrote (every field zero, which the magic in the */ +/* checksum makes fail), a guest torn off mid-update, and a value read */ +/* out of the WRONG partition's window -- the sentinel the hypervisor */ +/* itself planted is folded in, so a report can be attributed. */ +/**************************************************************************/ + +static uint32_t zx_report_mailbox(void) +{ + uint32_t progress = zx_mailbox_read(ZX_GD_PROGRESS); + uint32_t sequence = zx_mailbox_read(ZX_GD_SEQUENCE); + uint32_t checksum = zx_mailbox_read(ZX_GD_CHECKSUM); + uint32_t expected = (uint32_t)zx_guest_report_checksum( + zx_mailbox_read(ZX_GD_SCRATCH), progress, sequence, + zx_mailbox_read(ZX_GD_TICKS), zx_mailbox_read(ZX_GD_THREAD_A), + zx_mailbox_read(ZX_GD_THREAD_B), zx_mailbox_read(ZX_GD_MESSAGES), + zx_mailbox_read(ZX_GD_VERDICT)); + + zx_console_puts("\n--- what the guest reported, read out of its own " + "memory ---\n"); + zx_note("progress bits ", progress); + zx_note("sequence ", sequence); + zx_note("checksum ", checksum); + zx_note("checksum expected", expected); + zx_note("sentinel ", zx_mailbox_read(ZX_GD_SCRATCH)); + zx_note("guest ticks ", zx_mailbox_read(ZX_GD_TICKS)); + zx_note("producer slices ", zx_mailbox_read(ZX_GD_THREAD_A)); + zx_note("consumer slices ", zx_mailbox_read(ZX_GD_THREAD_B)); + zx_note("messages carried", zx_mailbox_read(ZX_GD_MESSAGES)); + zx_note("guest verdict ", zx_mailbox_read(ZX_GD_VERDICT)); + zx_note("stage-1 vector ", zx_mailbox_read(ZX_GD_STAGE1)); + zx_note("stage-1 DFSR ", zx_mailbox_read(ZX_GD_FAULT_STATUS)); + zx_note("stage-1 DFAR ", zx_mailbox_read(ZX_GD_FAULT_ADDRESS)); + + zx_check("the guest's report is sealed: the sequence advanced and the\n" + " checksum agrees, with the hypervisor's own sentinel\n" + " folded into it -- so this is THIS partition's report,\n" + " whole, and not a torn or misattributed one", + ((sequence != 0U) && (checksum == expected)) ? 1U : 0U); + + return progress; +} + + +/**************************************************************************/ +/* zx_el2_main -- entered from zx_el2_entry.S at EL2. Does not return. */ +/**************************************************************************/ + +ZX_NORETURN void zx_el2_main(void) +{ + uint32_t board_regions; + uint32_t el2_regions; + uint32_t status; + uint32_t outcome; + uint32_t progress; + uint32_t cycles_without_stage2 = 0U; + uint32_t cycles_with_stage2 = 0U; + uint32_t probe_target = 0U; + ZX_MANIFEST_FAULT fault; + + /* ---------------------------------------------------------------- */ + /* BEFORE THE FIRST PRINT. */ + /* */ + /* On a board whose console sits below 0x60000000 the background */ + /* map reaches it as NORMAL memory, and Normal memory permits the */ + /* gathering and reordering that corrupts a polled UART -- with */ + /* caches off, and regardless of HSCTLR.BR, because BR grants */ + /* permission and not attributes. So HMAIR, the hypervisor's own */ + /* MMIO regions and HSCTLR.M come up FIRST. Measured on the */ + /* S32Z280; see docs/decisions.md D15. */ + /* ---------------------------------------------------------------- */ + + el2_regions = zx_el2_region_count(); + board_regions = zx_board_mmio_region_count(); + + if (el2_regions >= board_regions) + { + zx_mair_program(); + + if (board_regions > 0U) + { + zx_board_program_mmio_regions(0U); + } + + zx_el2_mpu_enable(); + } + + zx_board_init(); + + zx_console_puts( + "\n" + "=========================================================\n" + " Eclipse ThreadX ZoneX -- one ThreadX guest at EL1\n" + " " ZX_PLATFORM_NAME "\n" + " Armv8-R AArch32, EL2, PMSAv8-R at both stages\n" + "=========================================================\n" + "\n" + " ZoneX is resident at EL2. It reads a manifest, validates it,\n" + " turns it into one stage-2 region, copies a REAL ThreadX kernel\n" + " into that region, and ERETs into it. The guest runs threads,\n" + " passes messages, prints through the hypervisor and reports\n" + " back through a mailbox in its own memory.\n" + "\n" + " One partition. Time partitioning and a second guest come\n" + " later, and each of them assumes what this image establishes.\n"); + + zx_fault_record_reset(zx_el2_fault_record()); + zx_guest_console_reset(); + + zx_console_puts("\n--- the EL2 MPU is already on ---\n"); + zx_note("HSCTLR now", zx_read_hsctlr()); + zx_check("HSCTLR.M is set, so the EL2 MPU is live", + ((zx_read_hsctlr() & ZX_HSCTLR_M) != 0U) ? 1U : 0U); + zx_check("HSCTLR.BR is set, so EL2's own code and data fall back to the\n" + " background map and cost no region", + ((zx_read_hsctlr() & ZX_HSCTLR_BR) != 0U) ? 1U : 0U); + + zx_console_puts("\n--- identity, as the core reports it ---\n"); + zx_note("MIDR ", zx_read_midr()); + zx_note("EL2 regions ", el2_regions); + zx_note("EL1 regions ", zx_el1_region_count()); + zx_note("CNTFRQ ", zx_read_cntfrq()); + zx_note("D-cache line ", zx_cache_dcache_line_bytes()); + zx_note("I-cache line ", zx_cache_icache_line_bytes()); + zx_console_puts(" the stride the loader's maintenance loop\n" + " uses. A loop that strode wrongly does not\n" + " fail -- it leaves lines untouched -- so the\n" + " number belongs in the log.\n"); + zx_board_report(); + + if (el2_regions == 0U) + { + zx_console_puts("\n *** HMPUIR reports ZERO EL2 regions, so this " + "target cannot host\n *** stage 2 at all. Refusing " + "to go on.\n" + "\nZONEX RESULT: FAILED -- the target cannot host " + "stage 2\n"); + zx_console_exit(1U); + } + + /* ---------------------------------------------------------------- */ + /* The manifest, and the plan it produces. */ + /* ---------------------------------------------------------------- */ + + zx_build_manifest(board_regions); + zx_env.zx_env_region_budget = el2_regions; + + status = zx_manifest_verify(&zx_manifest, &zx_env, &fault); + + zx_console_puts("\n--- the manifest, checked before anything is " + "programmed ---\n"); + zx_note("zx_manifest_verify", status); + + if (status != ZX_MANIFEST_SUCCESS) + { + zx_note(" offending partition", fault.zx_fault_partition); + zx_note(" offending region ", fault.zx_fault_region); + zx_check("the manifest passes every rule", 0U); + zx_console_puts("\nZONEX RESULT: FAILED\n"); + zx_console_exit(zx_probe_failures()); + } + + zx_check("the manifest passes every rule", 1U); + + status = zx_mm_plan(&zx_manifest, board_regions, el2_regions, &zx_layout); + zx_note("zx_mm_plan", status); + zx_check("the layout fits this part's region budget", + (status == ZX_MANIFEST_SUCCESS) ? 1U : 0U); + + if (status != ZX_MANIFEST_SUCCESS) + { + zx_console_puts("\nZONEX RESULT: FAILED\n"); + zx_console_exit(zx_probe_failures()); + } + + zx_mm_report(&zx_layout, &zx_manifest); + + /* ---------------------------------------------------------------- */ + /* The loader: decide, check, copy. */ + /* ---------------------------------------------------------------- */ + + zx_partition_reset(&zx_partition_a, &zx_partitions[0]); + + status = zx_partition_prepare(&zx_partition_a); + zx_note("zx_partition_prepare", status); + zx_check("the loader located the window the entry point is in, and the\n" + " image fits THAT window rather than merely some\n" + " executable region of the partition", + (status == ZX_MANIFEST_SUCCESS) ? 1U : 0U); + + if (status != ZX_MANIFEST_SUCCESS) + { + zx_partition_report(&zx_partition_a); + zx_console_puts("\nZONEX RESULT: FAILED\n"); + zx_console_exit(zx_probe_failures()); + } + + if (zx_check_image_header() == 0U) + { + zx_console_puts("\n *** REFUSING to launch this image. It is either\n" + " *** absent, truncated, or built for a different\n" + " *** window -- and a guest built for a different\n" + " *** window STARTS before it goes wrong.\n" + "\nZONEX RESULT: FAILED\n"); + zx_console_exit(zx_probe_failures()); + } + + zx_partition_report(&zx_partition_a); + + /* ---------------------------------------------------------------- */ + /* Region programming, and read-back. */ + /* */ + /* The read-back is not ceremony. A region whose base was */ + /* under-aligned programs successfully with its low bits landing on */ + /* SH, AP and XN, and neither faults. Comparing what the register */ + /* holds against what was asked for is the only way either becomes */ + /* visible, and it costs one read per region once at boot. */ + /* ---------------------------------------------------------------- */ + + { + uint32_t index = + (uint32_t)zx_layout.zx_layout_partition_first[ZX_PARTITION_A_INDEX]; + + zx_stage2_region_program(index, &zx_regions_a[0]); + + zx_console_puts("\n--- the region set, programmed and read back ---\n"); + zx_note("partition A region index", index); + zx_check("partition A's window reads back as the manifest declared it", + zx_stage2_region_matches(index, &zx_regions_a[0])); + + zx_stage2_enable_set( + zx_mm_partition_mask(&zx_layout, ZX_PARTITION_A_INDEX)); + zx_note("HPRENR now", zx_hprenr_read()); + } + + zx_console_puts("\n the ungranted granule immediately after the window " + "is at "); + zx_console_puthex(zx_symbol_address(__zx_partition_a_hole_start)); + zx_console_puts("\n it begins at the byte after the window's limit, " + "which is what\n makes a limit one granule too generous " + "visible\n"); + + /* ---------------------------------------------------------------- */ + /* The EL2-only configuration the guest cannot do for itself. */ + /* */ + /* A standalone Cortex-R52 kernel resets INTO EL2 and does this in */ + /* its own boot path. Built as a guest it skips that block, which */ + /* is exactly what the port's TX_R52_BOOT_AT_EL1 option is for, so */ + /* the work changes owner rather than ceasing to be necessary. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n--- what ZoneX takes over from the guest's boot path " + "---\n"); + zx_note("CNTFRQ before", zx_read_cntfrq()); + zx_el2_prepare_guest_el1(zx_board_counter_hz()); + zx_note("CNTFRQ after ", zx_read_cntfrq()); + zx_check("CNTFRQ is programmed, because it is writable only at the\n" + " highest implemented exception level -- a guest booting\n" + " at EL1 cannot write it and would read zero, and any\n" + " tick interval derived from that divides by zero", + (zx_read_cntfrq() == zx_board_counter_hz()) ? 1U : 0U); + zx_console_puts(" HCPTR.TCP10/TCP11 cleared, so a guest may use its FPU\n" + " without every access trapping to EL2. ZoneX does NOT\n" + " yet save or restore floating-point state across a\n" + " partition switch, which is correct with one partition\n" + " and a defect with two.\n" + " CNTHCTL.PL1PCTEN/PL1PCEN left CLEAR on purpose: a\n" + " partition must not reach the PHYSICAL counter, whose\n" + " time keeps running while the partition is descheduled.\n" + " Guests get the virtual timer and a per-partition\n" + " CNTVOFF instead, which is what freezes a descheduled\n" + " partition's clock.\n"); + + zx_pmu_enable(); + + /* ---------------------------------------------------------------- */ + /* PASS ONE: the guest with stage 2 OFF. */ + /* */ + /* This is the "what does ZoneX cost" measurement, and it is run */ + /* FIRST so that the number is taken before the run that may fault. */ + /* No probe target: the cost of running a guest and the cost of */ + /* stopping one are different questions. */ + /* ---------------------------------------------------------------- */ + + /* ---------------------------------------------------------------- */ + /* PASS ZERO: a WARM-UP that is deliberately not measured. */ + /* */ + /* MEASURED, THEN THROWN AWAY, and the reason is a real result */ + /* rather than caution. The first version of this image measured */ + /* two passes and no more, and on silicon the pass WITH stage 2 */ + /* came out 28% FASTER than the pass without it -- 27,202 cycles */ + /* against 38,002. A hypervisor does not give cycles back, so the */ + /* difference was not stage 2 at all: it was that the two passes */ + /* differed in ORDER as well as in HCR.VM, and on a real core the */ + /* first execution of anything is not comparable to the second. */ + /* Branch prediction and the instruction path are warm the second */ + /* time and cold the first. */ + /* */ + /* A pair that differs in two things measures neither. So the */ + /* first excursion is spent warming the core and its result is */ + /* discarded, which leaves the two measured passes differing in */ + /* exactly one bit of hypervisor configuration. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n--- a warm-up excursion, measured and discarded ---\n" + " On a real core the FIRST execution of anything is not\n" + " comparable to the second, so a pair of passes that\n" + " differed in order as well as in HCR.VM would measure\n" + " neither. This pass exists to be thrown away.\n"); + + zx_load_partition(&zx_partition_a.zx_partition_load); + zx_hand_over(0U, ZX_GO_QUIET); + zx_partition_loaded(&zx_partition_a); + + zx_check("the partition may be entered now that its image is loaded", + zx_partition_enter(&zx_partition_a)); + + { + uint32_t warm_cycles = 0U; + + outcome = zx_run_guest(&warm_cycles); + zx_note("warm-up excursion, cycles (discarded)", warm_cycles); + } + + zx_check("the warm-up guest reached its own verdict, which rules out the\n" + " loader, the entry point and the kernel before stage 2\n" + " is added as a variable", + ((zx_mailbox_read(ZX_GD_PROGRESS) & (uint32_t)ZX_GP_FINISHED) + != 0U) ? 1U : 0U); + + zx_console_puts("\n=========================================================\n" + " PASS 1 of 3: the guest with HCR.VM CLEAR, and QUIET\n" + "=========================================================\n" + " Stage 2 does not apply, so the guest is confined only\n" + " by its OWN MPU. This is the baseline the cost of\n" + " stage 2 is measured against; it is not a mode ZoneX\n" + " would ever run a partition in.\n" + "\n" + " THE CONSOLE IS OFF FOR THIS PASS AND THE NEXT, and that\n" + " is what makes the measurement mean anything. The\n" + " guest's console is one hypercall PER CHARACTER through\n" + " a polled UART: on silicon that is three orders of\n" + " magnitude more work than everything else the guest\n" + " does, so a loud run can only BOUND the cost of stage 2\n" + " from above and cannot resolve it. Same image, one word\n" + " in the mailbox -- so the run being measured is the run\n" + " being demonstrated in pass 3.\n"); + + zx_load_partition(&zx_partition_a.zx_partition_load); + zx_hand_over(0U, ZX_GO_QUIET); + + zx_check("the partition may be entered again after yielding", + zx_partition_enter(&zx_partition_a)); + + outcome = zx_run_guest(&cycles_without_stage2); + zx_note("outcome", outcome); + progress = zx_report_mailbox(); + + zx_check("with stage 2 off, the guest reached its own verdict", + (((progress & (uint32_t)ZX_GP_FINISHED) != 0U) + && (outcome == ZX_RUN_YIELDED)) ? 1U : 0U); + zx_check("and nothing has printed yet, so the quiet option really is\n" + " quiet and the cycles above are the guest's own work", + (zx_guest_console_characters() == 0U) ? 1U : 0U); + + /* ---------------------------------------------------------------- */ + /* PASS TWO: stage 2 on, and whatever this build was built to */ + /* demonstrate. */ + /* ---------------------------------------------------------------- */ + +#ifdef ZX_ONE_PROBE_OUTSIDE + probe_target = (uint32_t)zx_symbol_address(__zx_partition_a_hole_start); +#endif +#ifdef ZX_ONE_PROBE_STAGE1 + probe_target = (uint32_t)(zx_symbol_address(__zx_partition_a_start) + + ZX_GUEST_IMAGE_OFF_ENTRY); +#endif +#ifdef ZX_ONE_PROBE_GRANTED + probe_target = (uint32_t)(zx_symbol_address(__zx_partition_a_start) + + ZX_GUEST_IMAGE_OFF_MAILBOX); +#endif + + zx_console_puts("\n=========================================================\n" + " PASS 2 of 3: the same guest with HCR.VM SET, still QUIET\n" + "=========================================================\n" + " The same work as pass 1, with stage 2 in force. The\n" + " difference between the two figures is what stage 2\n" + " costs a guest, and it is the only pair in this run\n" + " that is comparable: identical work, identical console\n" + " traffic (none), one bit of hypervisor configuration\n" + " apart.\n"); + + zx_load_partition(&zx_partition_a.zx_partition_load); + zx_hand_over(0U, ZX_GO_QUIET); + + zx_stage2_enable(); + zx_note("HCR now", zx_read_hcr()); + zx_check("HCR.VM is set, so stage 2 applies to EL0/EL1", + ((zx_read_hcr() & ZX_HCR_VM) != 0U) ? 1U : 0U); + zx_check("HCR.HCD is clear, so HVC is still available -- which the guest's\n" + " console depends on, one hypercall per character", + ((zx_read_hcr() & ZX_HCR_HCD) == 0U) ? 1U : 0U); + zx_check("HCR.TGE is clear, so the guest keeps its own exception vectors\n" + " -- without which a stage-1 fault could not be taken to\n" + " EL1 and the two stages would be indistinguishable", + ((zx_read_hcr() & ZX_HCR_TGE) == 0U) ? 1U : 0U); + + zx_check("the partition may be entered again after yielding", + zx_partition_enter(&zx_partition_a)); + + outcome = zx_run_guest(&cycles_with_stage2); + zx_note("outcome", outcome); + progress = zx_report_mailbox(); + + zx_check("with stage 2 in force, the guest did the same work and reached\n" + " the same verdict", + (((progress & (uint32_t)ZX_GP_FINISHED) != 0U) + && (outcome == ZX_RUN_YIELDED)) ? 1U : 0U); + + /* ---------------------------------------------------------------- */ + /* PASS THREE: loud, and carrying whatever this build was built to */ + /* demonstrate. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n=========================================================\n" + " PASS 3 of 3: the same guest, LOUD\n" + "=========================================================\n" + " Everything below the guest's own output is judged on\n" + " THIS pass. It is the same image the two measured\n" + " passes ran, printing through the hypervisor one\n" + " hypercall per character, with each line tagged by the\n" + " partition ZoneX scheduled rather than by the guest --\n" + " which is why a guest cannot claim to be another one.\n"); + + if (probe_target != 0U) + { + zx_console_puts("\n THIS BUILD ASKS THE GUEST TO PROBE "); + zx_console_puthex(probe_target); + zx_console_puts("\n"); + } + + zx_load_partition(&zx_partition_a.zx_partition_load); + zx_hand_over(probe_target, 0U); + + zx_check("the partition may be entered a third time", + zx_partition_enter(&zx_partition_a)); + + { + uint32_t loud_cycles = 0U; + + outcome = zx_run_guest(&loud_cycles); + zx_note("outcome", outcome); + zx_note("loud excursion, cycles", loud_cycles); + } + + progress = zx_report_mailbox(); + + zx_partition_report(&zx_partition_a); + + if (outcome == ZX_RUN_FAULTED) + { + zx_fault_report(zx_el2_fault_record()); + } + + /* ---------------------------------------------------------------- */ + /* What this build was built to demonstrate. */ + /* ---------------------------------------------------------------- */ + +#if defined(ZX_ONE_PROBE_OUTSIDE) + + zx_console_puts("\n=========================================================\n" + " THE DEMONSTRANDUM: a REAL KERNEL stopped at its boundary\n" + "=========================================================\n" + " The guest granted ITSELF the granule immediately after\n" + " its window -- its own MPU says it owns memory the\n" + " manifest never gave it -- and then wrote to it. Stage 2\n" + " refused anyway, which is what \"the stricter of the two\n" + " stages wins\" means when the two disagree.\n"); + + zx_check("the guest was taken from at its boundary rather than yielding", + (outcome == ZX_RUN_FAULTED) ? 1U : 0U); + zx_check("the probe did NOT survive, so the write genuinely did not\n" + " complete", + ((progress & (uint32_t)ZX_GP_PROBE_SURVIVED) == 0U) ? 1U : 0U); + zx_check("the fault is classified as a GUEST violation, not a hypervisor\n" + " one -- EC 0x24 and not EC 0x25", + (zx_fault_classify(zx_el2_fault_record()->zx_fault_hsr) + == ZX_FAULT_GUEST_VIOLATION) ? 1U : 0U); + zx_check("HSR.EC is 0x24, a data abort ROUTED to Hyp", + (zx_fault_ec(zx_el2_fault_record()->zx_fault_hsr) + == ZX_EC_DABT_ROUTED) ? 1U : 0U); + zx_check("HDFAR is EXACTLY the address the guest touched, which is what\n" + " catches a window whose limit is one granule out", + (zx_el2_fault_record()->zx_fault_hdfar == probe_target) ? 1U : 0U); + zx_check("DFSC says NO enabled region covered it, rather than a region\n" + " matching and refusing -- 0x04 and not 0x0C", + (zx_fault_dabt_dfsc(zx_el2_fault_record()->zx_fault_hsr) + == ZX_DFSC_MPU_MISS) ? 1U : 0U); + zx_check("and the guest's OWN vectors saw nothing, so this was stage 2\n" + " and not stage 1 -- the two are reported by different\n" + " code at different privilege levels", + (zx_mailbox_read(ZX_GD_STAGE1) == ZX_GS_NONE) ? 1U : 0U); + zx_check("the guest PC is inside the partition's window, so it resolves\n" + " against guest_a.map by hand", + ((zx_el2_fault_record()->zx_fault_elr + >= (uint32_t)zx_symbol_address(__zx_partition_a_start)) + && (zx_el2_fault_record()->zx_fault_elr + < (uint32_t)zx_symbol_address(__zx_partition_a_end))) + ? 1U : 0U); + +#elif defined(ZX_ONE_PROBE_STAGE1) + + zx_console_puts("\n=========================================================\n" + " THE DEMONSTRANDUM: a STAGE-1 fault, which EL2 never saw\n" + "=========================================================\n" + " The guest wrote to its OWN CODE. Stage 2 permits that\n" + " -- the loader had to be able to write the window -- and\n" + " the guest's own MPU makes its code read-only, so this is\n" + " the case where the two stages disagree and the STRICTER\n" + " one is stage 1.\n" + "\n" + " The abort was therefore taken to EL1, not to EL2. The\n" + " hypervisor knows about it only because the guest's own\n" + " vector recorded it and handed control back. That is the\n" + " difference between the two stages, and it is why they\n" + " cannot be confused in this output.\n"); + + zx_check("the guest handed control back rather than being taken from at\n" + " its stage-2 boundary", + (outcome == ZX_RUN_YIELDED) ? 1U : 0U); + zx_check("NO stage-2 fault was captured, so EL2 never saw this at all", + (zx_fault_classify(zx_el2_fault_record()->zx_fault_hsr) + == ZX_FAULT_HYPERCALL) ? 1U : 0U); + zx_check("the guest's own vector reports a DATA ABORT at EL1", + (zx_mailbox_read(ZX_GD_STAGE1) == ZX_GS_DABT) ? 1U : 0U); + zx_check("its DFAR is exactly the address it was asked to touch", + (zx_mailbox_read(ZX_GD_FAULT_ADDRESS) == probe_target) ? 1U : 0U); + zx_check("its DFSR is non-zero, so the syndrome was captured rather than\n" + " the word merely being written", + (zx_mailbox_read(ZX_GD_FAULT_STATUS) != 0U) ? 1U : 0U); + zx_check("the probe did NOT survive", + ((progress & (uint32_t)ZX_GP_PROBE_SURVIVED) == 0U) ? 1U : 0U); + +#elif defined(ZX_ONE_PROBE_GRANTED) + + zx_console_puts("\n=========================================================\n" + " NEGATIVE BUILD: the probe is aimed somewhere it IS granted\n" + "=========================================================\n" + " The guest was asked to write to its own mailbox, which\n" + " both stages permit. It must SUCCEED, and this run must\n" + " report FAILED. That is the point: it proves the two\n" + " builds above are capable of failing, and a check whose\n" + " pass condition is the ABSENCE of something has to be.\n" + "\n" + " The CTest entry for this build expects FAILED, so this\n" + " build starting to PASS -- meaning the violation stopped\n" + " being detected -- fails the suite.\n"); + + zx_check("the probe did not survive an access it IS granted (this check\n" + " is EXPECTED TO FAIL, and its failing is the evidence)", + ((progress & (uint32_t)ZX_GP_PROBE_SURVIVED) == 0U) ? 1U : 0U); + zx_check("no stage-2 fault was taken (this one is expected to pass: the\n" + " access was permitted)", + (outcome == ZX_RUN_YIELDED) ? 1U : 0U); + +#else + + zx_console_puts("\n=========================================================\n" + " A REAL THREADX KERNEL, RUNNING UNDER STAGE 2\n" + "=========================================================\n"); + + zx_check("the guest yielded rather than faulting", + (outcome == ZX_RUN_YIELDED) ? 1U : 0U); + zx_check("it reached bsp_main, so the ERET landed and the port's boot\n" + " path ran to C", + ((progress & (uint32_t)ZX_GP_BSP_MAIN) != 0U) ? 1U : 0U); + zx_check("tx_application_define ran, so the kernel initialised inside a\n" + " stage-2 confined window", + ((progress & (uint32_t)ZX_GP_KERNEL_ENTERED) != 0U) ? 1U : 0U); + zx_check("both threads took slices, so the ported context switch works\n" + " underneath stage 2", + ((progress & (uint32_t)ZX_GP_THREADS_RAN) != 0U) ? 1U : 0U); + zx_check("the queue carried every message, checked by VALUE and in order\n" + " -- four deliveries of the wrong thing would satisfy a\n" + " count", + ((progress & (uint32_t)ZX_GP_QUEUE_OK) != 0U) ? 1U : 0U); + zx_check("the semaphore granted once and refused the second get", + ((progress & (uint32_t)ZX_GP_SEMAPHORE_OK) != 0U) ? 1U : 0U); + zx_check("the guest published its own verdict, and it is PASSED", + (zx_mailbox_read(ZX_GD_VERDICT) == ZX_GV_PASSED) ? 1U : 0U); + zx_check("no stage-1 fault was taken, so the guest's own MPU permitted\n" + " everything the kernel legitimately did", + (zx_mailbox_read(ZX_GD_STAGE1) == ZX_GS_NONE) ? 1U : 0U); + + zx_console_puts("\n--- the guest printed through the hypervisor ---\n"); + zx_note("characters forwarded", zx_guest_console_characters()); + zx_note("characters with no partition attached", + zx_guest_console_orphans()); + zx_check("the guest really did print through the hypervisor, one\n" + " hypercall per character", + (zx_guest_console_characters() > 100U) ? 1U : 0U); + zx_check("and no character arrived while NO partition was attached, which\n" + " would mean the run loop and the console disagree about\n" + " what is executing", + (zx_guest_console_orphans() == 0U) ? 1U : 0U); + +#endif + + /* ---------------------------------------------------------------- */ + /* What stage 2 cost. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n--- what stage 2 costs a guest ---\n"); + + if (zx_pmu_is_running() == 0U) + { + zx_console_puts(" the PMU cycle counter is not advancing, so no\n" + " timing is reported. This is not a check failure:\n" + " a number from a counter that is not running would\n" + " be a very convincing wrong answer.\n"); + } + else + { + zx_note("quiet excursion, HCR.VM clear, cycles", cycles_without_stage2); + zx_note("quiet excursion, HCR.VM set, cycles", cycles_with_stage2); + + if (cycles_with_stage2 >= cycles_without_stage2) + { + zx_note("stage 2 cost, cycles ", + cycles_with_stage2 - cycles_without_stage2); + } + else + { + zx_note("stage 2 cost, cycles SAVED ", + cycles_without_stage2 - cycles_with_stage2); + zx_console_puts( + " The run WITH stage 2 was the faster of the two, which is\n" + " not a hypervisor that gives cycles back: it means the\n" + " difference is below this measurement's noise floor. Read\n" + " it as \"no cost resolvable here\" and not as a saving.\n"); + } + + zx_console_puts( + "\n" + " WHAT THE PAIR IS. Two excursions of the same guest image doing\n" + " the same work with its console suppressed, ERET to hand-back,\n" + " differing in one bit of hypervisor configuration: HCR.VM. So\n" + " the difference is attributable, which is the whole reason the\n" + " measured passes are quiet.\n" + "\n" + " WHAT IT IS NOT. It is not \"boot to first thread\": nothing at\n" + " EL1 can read PMCCNTR, so the hypervisor can only measure what\n" + " it can see either side of the transfer. And it is not a\n" + " per-access figure -- stage 2 is checked by hardware on every\n" + " access, so what is being measured is the whole of a real\n" + " kernel's boot and scheduling under it, not one lookup.\n" + "\n" + " WHY IT COMES OUT SMALL. Region descriptors are programmed ONCE\n" + " at boot; stage 2 then costs a guest nothing per access that the\n" + " MPU was not already going to spend. The cost that a partition\n" + " SWITCH carries is a different number, measured by the two-\n" + " partition image, and it is one HPRENR write plus its barriers.\n" + "\n" + " ON A FUNCTIONAL MODEL BOTH NUMBERS MEAN NOTHING. The FVP does\n" + " not model timing; they are printed anyway so that the\n" + " measurement path is exercised on every run rather than only\n" + " when a board is attached. The number to quote comes from\n" + " silicon.\n"); + } + + /* ---------------------------------------------------------------- */ + /* Verdict. The exact strings are what the runners grep for. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n=========================================================\n" + " what this run does and does not prove\n" + "=========================================================\n" + " " ZX_PLATFORM_NAME "\n" + " EL2 regions available: "); + zx_console_putdec(el2_regions); + zx_console_puts("\n" + "\n" + " A green run here proves the CODE is right. It proves nothing\n" + " about the region budget, the MMIO attributes or ANY TIMING on a\n" + " real part: the Armv8-R AEM FVP reports a region count no\n" + " Cortex-R52 can have, its console and GIC land in the Device band\n" + " of the background map where the S32Z280's do not, and it is a\n" + " functional model with no notion of cycles. Both targets have to\n" + " be run.\n" + "\n" + " THE GUEST IS A REAL, UNMODIFIED THREADX. The kernel sources and\n" + " the Cortex-R52 port are built exactly as they are built\n" + " standalone; what differs is one existing build option that skips\n" + " the port's own EL2 configuration, a linker script describing one\n" + " partition window, and a board file with no devices in it.\n" + "\n" + " On lockstep silicon this demonstrates memory isolation on ONE\n" + " logical core. It does NOT demonstrate spatial multicore\n" + " partitioning, and it does not yet demonstrate TIME partitioning\n" + " at all -- this guest takes no interrupts.\n"); + + zx_console_puts("\n checks failed: "); + zx_console_putdec(zx_probe_failures()); + zx_console_puts("\n"); + + if (zx_probe_failures() == 0U) + { + zx_console_puts("\nZONEX RESULT: ALL CHECKS PASSED\n"); + } + else + { + zx_console_puts("\nZONEX RESULT: FAILED\n"); + } + + zx_console_exit(zx_probe_failures()); +} diff --git a/examples/common/zx_probe.h b/examples/common/zx_probe.h index b1619b8..61abcbd 100644 --- a/examples/common/zx_probe.h +++ b/examples/common/zx_probe.h @@ -126,6 +126,18 @@ void zx_board_describe_mmio_regions(ZX_REGION *region_ptr); void zx_board_report(void); +/* What this board's system counter runs at, in Hz. + * + * A SOFTWARE-DECLARED CONSTANT, not something read from the hardware. CNTFRQ + * reads ZERO out of reset on both ZoneX targets and nothing else reports the + * frequency, so the number has to come from the board's own knowledge -- the + * counter control frame on the model, and a measured-and-cross-checked clock + * tree on silicon. ZoneX programs CNTFRQ from it before entering a guest, + * because CNTFRQ is writable only at the highest implemented exception level + * and a guest deriving a tick interval from a zero would divide by zero. */ + +ZX_NODISCARD uint32_t zx_board_counter_hz(void); + /**************************************************************************/ /* Symbols the linker script defines */ /**************************************************************************/ diff --git a/examples/common/zx_probe_main.c b/examples/common/zx_probe_main.c index 804cd92..39d348e 100644 --- a/examples/common/zx_probe_main.c +++ b/examples/common/zx_probe_main.c @@ -63,19 +63,14 @@ /* */ /* Rule 11.4/11.6 -- casting a linker symbol's address to an integer */ /* address is inherent to describing memory to an MPU. Confined to */ -/* zx_symbol_address below. */ +/* zx_symbol_address, which lives in zx_report.c with the other */ +/* shared reporting helpers. */ /* */ /**************************************************************************/ #include "zx_probe.h" #include "zx_platform.h" -/* How many checks failed. Accumulated rather than returned, because the - image reports EVERY check and then its verdict: stopping at the first - failure would hide the identity block that explains it. */ - -static uint32_t zx_failures; - /* Region 16's descriptor is kept because two phases need it -- the enabled probe and, when the deliberate EL2 fault is built in, a reprogramming of the same region with EL2 read-only permissions. */ @@ -83,60 +78,6 @@ static uint32_t zx_failures; static ZX_REGION zx_high_probe_region; -/**************************************************************************/ -/* zx_symbol_address -- a linker symbol's address as an MPU address. */ -/**************************************************************************/ - -zx_addr_t zx_symbol_address(const char *symbol) -{ - return (zx_addr_t)(uintptr_t)symbol; -} - - -/**************************************************************************/ -/* zx_report / zx_check -- one labelled line each. */ -/* */ -/* Fixed-width labels so that a captured log lines up and a missing */ -/* result is visible as a gap rather than having to be counted. */ -/**************************************************************************/ - -void zx_check(const char *label, uint32_t passed) -{ - zx_console_puts(" ["); - zx_console_puts((passed != 0U) ? "PASS" : "FAIL"); - zx_console_puts("] "); - zx_console_puts(label); - zx_console_puts("\n"); - - if (passed == 0U) - { - zx_failures++; - } -} - - -uint32_t zx_probe_failures(void) -{ - return zx_failures; -} - - -void zx_probe_fail(void) -{ - zx_failures++; -} - - -void zx_note(const char *label, uint32_t value) -{ - zx_console_puts(" "); - zx_console_puts(label); - zx_console_puts(" = "); - zx_console_puthex(value); - zx_console_puts("\n"); -} - - /**************************************************************************/ /* zx_identity_block -- T2. */ /* */ @@ -818,7 +759,7 @@ static void zx_phase_provoke_el2_fault(void) zx_console_puts("\n *** THE WRITE SUCCEEDED. A region marked read-only at\n" " *** EL2 did not deny an EL2 write, so AP 0b10 does not\n" " *** mean what TRM Table 3-82 says it means.\n"); - zx_failures++; + zx_probe_fail(); } #endif @@ -1004,10 +945,10 @@ ZX_NORETURN void zx_el2_main(void) " partitioning.\n"); zx_console_puts("\n checks failed: "); - zx_console_putdec(zx_failures); + zx_console_putdec(zx_probe_failures()); zx_console_puts("\n"); - if (zx_failures == 0U) + if (zx_probe_failures() == 0U) { zx_console_puts("\nZONEX RESULT: ALL CHECKS PASSED\n"); } @@ -1016,5 +957,5 @@ ZX_NORETURN void zx_el2_main(void) zx_console_puts("\nZONEX RESULT: FAILED\n"); } - zx_console_exit(zx_failures); + zx_console_exit(zx_probe_failures()); } diff --git a/examples/common/zx_report.c b/examples/common/zx_report.c new file mode 100644 index 0000000..030c5d5 --- /dev/null +++ b/examples/common/zx_report.c @@ -0,0 +1,107 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* EXAMPLE DESCRIPTION */ +/* */ +/* zx_report.c SHARED */ +/* */ +/* DESCRIPTION */ +/* */ +/* How every ZoneX image reports a check, a value and its verdict. */ +/* */ +/* WHY THIS IS ONE FILE AND NOT ONE PER IMAGE */ +/* */ +/* Because the automated runner greps for this text. It judges a run */ +/* on the verdict line and lists the lines beginning " [FAIL]", so */ +/* the FORMAT is an interface between the images and the suite -- and */ +/* two images with their own copies of it would be two images free to */ +/* drift apart, one of them silently stopping being judged. */ +/* */ +/* The failure count lives here for the same reason. It is the number */ +/* the verdict is computed from, and a second image with a second */ +/* counter would be an image whose verdict could disagree with its own */ +/* reported failures. */ +/* */ +/* MISRA C:2012 deviations (justified) */ +/* */ +/* Rule 11.4/11.6 -- casting a linker symbol's address to an integer */ +/* address is inherent to describing memory to an MPU. Confined to */ +/* zx_symbol_address below. */ +/* */ +/**************************************************************************/ + +#include "zx_probe.h" + +/* How many checks failed. Accumulated rather than returned, because an + image reports EVERY check and then its verdict: stopping at the first + failure would hide the identity block that explains it. */ + +static uint32_t zx_failures; + + +/**************************************************************************/ +/* zx_symbol_address -- a linker symbol's address as an MPU address. */ +/**************************************************************************/ + +zx_addr_t zx_symbol_address(const char *symbol) +{ + return (zx_addr_t)(uintptr_t)symbol; +} + + +/**************************************************************************/ +/* zx_report / zx_check -- one labelled line each. */ +/* */ +/* Fixed-width labels so that a captured log lines up and a missing */ +/* result is visible as a gap rather than having to be counted. */ +/**************************************************************************/ + +void zx_check(const char *label, uint32_t passed) +{ + zx_console_puts(" ["); + zx_console_puts((passed != 0U) ? "PASS" : "FAIL"); + zx_console_puts("] "); + zx_console_puts(label); + zx_console_puts("\n"); + + if (passed == 0U) + { + zx_failures++; + } +} + + +uint32_t zx_probe_failures(void) +{ + return zx_failures; +} + + +void zx_probe_fail(void) +{ + zx_failures++; +} + + +void zx_note(const char *label, uint32_t value) +{ + zx_console_puts(" "); + zx_console_puts(label); + zx_console_puts(" = "); + zx_console_puthex(value); + zx_console_puts("\n"); +} diff --git a/examples/fvp_baser_aemv8r/CMakeLists.txt b/examples/fvp_baser_aemv8r/CMakeLists.txt index bd3dd8e..4f45022 100644 --- a/examples/fvp_baser_aemv8r/CMakeLists.txt +++ b/examples/fvp_baser_aemv8r/CMakeLists.txt @@ -63,6 +63,7 @@ function(zx_add_fvp_probe target_name) add_executable(${target_name} EXCLUDE_FROM_ALL ${ZX_COMMON_DIR}/zx_probe_main.c ${ZX_COMMON_DIR}/zx_partitions.c + ${ZX_COMMON_DIR}/zx_report.c ${ZX_COMMON_DIR}/zx_payload.S ${ZX_FVP_DIR}/zx_board.c ) @@ -151,6 +152,158 @@ target_compile_definitions(zx_probe_overlap.elf PRIVATE ZX_PROBE_MANIFEST_OVERLA zx_add_fvp_probe(zx_probe_badattr.elf) target_compile_definitions(zx_probe_badattr.elf PRIVATE ZX_PROBE_BAD_ATTR) +############################################################################### +# The ThreadX guest, and the image that launches it +############################################################################### +# +# THE GUEST IS BUILT BY A SEPARATE CMAKE INVOCATION, as an ExternalProject. +# Not an add_subdirectory, and the reasons are in guest_a/CMakeLists.txt -- +# briefly: the guest is ThreadX and stays C99 with ThreadX's own settings +# while ZoneX is C17 with -Wpedantic, the guest is configured with THREADX's +# toolchain file so that it is built the way ThreadX's own CI builds it, and a +# guest ELF must never be able to enter the hypervisor's link. +# +# What crosses back is one file: guest_a.bin, which zx_guest_image.S pulls in +# with .incbin. guest_a.elf and guest_a.map stay in the guest's own build +# directory beside it, because a fault report names a guest PC and the map +# file is the only way to turn that back into a function. + +if(ZX_THREADX_SOURCE_DIR) + include(ExternalProject) + + set(ZX_GUEST_A_BUILD ${CMAKE_CURRENT_BINARY_DIR}/guest_a) + set(ZX_GUEST_A_BLOB ${ZX_GUEST_A_BUILD}/guest_a.bin) + + # ThreadX's own toolchain file, not ZoneX's. They select the same + # compiler and the same -mcpu, deliberately, but ZoneX's sets ZX_ARCH and + # C17 while ThreadX's sets THREADX_ARCH -- and the guest's build needs the + # second. + # + # AND IT FOLLOWS THE LANE. ZoneX builds with arm-none-eabi-gcc and with + # ATfE clang, and the guest support under examples/guest_common is ZoneX's + # own code even though it is compiled into a ThreadX image -- so it has to + # face both compilers like everything else here. Pinning the guest to GCC + # would leave those files compiled by one toolchain only, which is exactly + # the gap that lets a GNU-only construct through. + if(CMAKE_C_COMPILER_ID STREQUAL "Clang") + set(ZX_GUEST_TOOLCHAIN + ${ZX_THREADX_SOURCE_DIR}/cmake/cortex_r52_clang.cmake) + else() + set(ZX_GUEST_TOOLCHAIN + ${ZX_THREADX_SOURCE_DIR}/cmake/cortex_r52.cmake) + endif() + + ExternalProject_Add(zx_guest_a + SOURCE_DIR ${ZX_FVP_DIR}/guest_a + BINARY_DIR ${ZX_GUEST_A_BUILD} + CMAKE_GENERATOR ${CMAKE_GENERATOR} + CMAKE_ARGS + -DCMAKE_TOOLCHAIN_FILE=${ZX_GUEST_TOOLCHAIN} + -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} + -DZX_THREADX_SOURCE_DIR=${ZX_THREADX_SOURCE_DIR} + -DZX_GUEST_COMMON_DIR=${ZX_COMMON_DIR} + BUILD_BYPRODUCTS ${ZX_GUEST_A_BLOB} + # BUILD_ALWAYS, because the sub-build has its own dependency graph and + # this one cannot see into it. Without it, editing the guest's + # application would leave a stale blob embedded in an image that + # relinked happily -- which is the same class of failure as a linker + # pattern that matches nothing, and just as quiet. + BUILD_ALWAYS TRUE + INSTALL_COMMAND "" + USES_TERMINAL_CONFIGURE FALSE + USES_TERMINAL_BUILD FALSE + ) + + # One function, because the three probe variants are the same image with + # one definition changed. Building them from a shared function is what + # keeps them honest: a negative build that had drifted from the positive + # one would prove nothing about it. + function(zx_add_one_partition target_name) + add_executable(${target_name} EXCLUDE_FROM_ALL + ${ZX_COMMON_DIR}/zx_one_partition.c + ${ZX_COMMON_DIR}/zx_report.c + ${ZX_COMMON_DIR}/zx_guest_image.S + ${ZX_FVP_DIR}/zx_board.c + ) + + add_dependencies(${target_name} zx_guest_a) + + target_include_directories(${target_name} PRIVATE + ${ZX_FVP_DIR} + ${ZX_COMMON_DIR} + ) + + # .incbin SEARCHES THE ASSEMBLER'S INCLUDE PATH, NOT THE COMPILER'S. + # -I on the compiler command line does not reach the assembler; it + # needs -Wa,-I. Getting this wrong fails the assembly with "file not + # found", which is the good outcome -- the neighbouring mistake, a + # LINKER input pattern that matches nothing, produces an empty section + # and an image that builds and contains no guest. + # + # OBJECT_DEPENDS is what makes the blob a dependency of the object. + # add_dependencies orders the guest build before this target but says + # nothing about which of this target's outputs are stale when the blob + # changes, so without it a rebuilt guest would not be re-embedded. + set_source_files_properties(${ZX_COMMON_DIR}/zx_guest_image.S + PROPERTIES + COMPILE_OPTIONS "-Wa,-I${ZX_GUEST_A_BUILD}" + OBJECT_DEPENDS "${ZX_GUEST_A_BLOB}" + ) + + target_link_libraries(${target_name} PRIVATE + eclipse-threadx::zonex + zonex::warnings + ) + + target_link_options(${target_name} PRIVATE + -T${ZX_FVP_DIR}/zx_one_partition.lds + -nostartfiles + -Wl,-Map=${target_name}.map + ${ZX_LINK_QUIET_RWX} + ) + + set_target_properties(${target_name} PROPERTIES + LINK_DEPENDS ${ZX_FVP_DIR}/zx_one_partition.lds + ) + endfunction() + + # The image the suite runs: a real ThreadX kernel booting, scheduling and + # printing inside one stage-2 confined partition. + zx_add_one_partition(zx_one_partition.elf) + + # A REAL KERNEL STOPPED AT ITS BOUNDARY. The guest grants itself the + # granule immediately after its window -- its own MPU then says it owns + # memory the manifest never gave it -- and writes to it. Stage 2 refuses, + # which is what "the stricter of the two stages wins" means when the two + # disagree. Expected to PASS: the violation is detected and named. + zx_add_one_partition(zx_one_partition_outside.elf) + target_compile_definitions(zx_one_partition_outside.elf PRIVATE + ZX_ONE_PROBE_OUTSIDE) + + # A STAGE-1 FAULT, which EL2 never sees. The guest writes to its own + # code: stage 2 permits it and the guest's own MPU does not. The abort is + # taken to EL1 and reaches the hypervisor only because the guest's own + # vector records it -- which is precisely what makes the two stages + # distinguishable. Expected to PASS. + zx_add_one_partition(zx_one_partition_stage1.elf) + target_compile_definitions(zx_one_partition_stage1.elf PRIVATE + ZX_ONE_PROBE_STAGE1) + + # THE NEGATIVE VERIFICATION. The same probe aimed at an address the guest + # IS granted, so it must SUCCEED and this run must report FAILED. A check + # whose pass condition is the absence of something has to be seen to fail, + # and this is kept as a BUILD rather than a temporary edit so that the + # demonstration stays reproducible. + zx_add_one_partition(zx_one_partition_granted.elf) + target_compile_definitions(zx_one_partition_granted.elf PRIVATE + ZX_ONE_PROBE_GRANTED) +else() + message(STATUS + "ZoneX: ZX_THREADX_SOURCE_DIR is empty, so the ThreadX guest images " + "are unavailable. Configure with -DZX_THREADX_SOURCE_DIR= to build them.") +endif() + ############################################################################### # Running on the model ############################################################################### @@ -204,6 +357,44 @@ if(ZX_FVP_BASER_AEMV8R) --expect fail) endforeach() + # THE ONE-PARTITION IMAGES. Three that must pass and one that must + # fail, and the one that must fail is what makes the other three + # evidence: without it, a green run of the boundary test shows only + # that the probe address faults, not that the check can tell the + # difference. + if(TARGET zx_one_partition.elf) + add_test(NAME zx-fvp-one-partition + COMMAND ${Python3_EXECUTABLE} ${ZX_FVP_RUNNER} + --elf $ + --fvp ${ZX_FVP_BASER_AEMV8R}) + + foreach(zx_positive outside stage1) + add_test(NAME zx-fvp-one-partition-${zx_positive} + COMMAND ${Python3_EXECUTABLE} ${ZX_FVP_RUNNER} + --elf $ + --fvp ${ZX_FVP_BASER_AEMV8R}) + endforeach() + + add_test(NAME zx-fvp-one-partition-granted + COMMAND ${Python3_EXECUTABLE} ${ZX_FVP_RUNNER} + --elf $ + --fvp ${ZX_FVP_BASER_AEMV8R} + --expect fail) + + add_custom_target(zx-run-one-partition + COMMAND ${ZX_FVP_BASER_AEMV8R} + -C cluster0.NUM_CORES=1 + -C bp.vis.disable_visualisation=1 + -C bp.terminal_0.start_telnet=0 + -C bp.pl011_uart0.out_file=- + -C bp.pl011_uart0.unbuffered_output=1 + -a $ + DEPENDS zx_one_partition.elf + USES_TERMINAL + COMMENT "Running the ZoneX one-partition image on FVP_BaseR_AEMv8R..." + ) + endif() + add_custom_target(zx-run-probe COMMAND ${ZX_FVP_BASER_AEMV8R} -C cluster0.NUM_CORES=1 diff --git a/examples/fvp_baser_aemv8r/guest_a/CMakeLists.txt b/examples/fvp_baser_aemv8r/guest_a/CMakeLists.txt new file mode 100644 index 0000000..08dceb6 --- /dev/null +++ b/examples/fvp_baser_aemv8r/guest_a/CMakeLists.txt @@ -0,0 +1,211 @@ +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# The ThreadX guest for ZoneX partition A on the Armv8-R AEM FVP. +# +# THIS IS A PROJECT OF ITS OWN, configured and built by a SEPARATE CMake +# invocation from a superbuild step in the parent example. It is not an +# add_subdirectory of the hypervisor's build, and the separation is the +# point rather than an inconvenience: +# +# * The guest is ThreadX and stays C99 with ThreadX's own warning set; +# ZoneX is C17 with CMAKE_C_EXTENSIONS OFF and -Wpedantic. One build +# tree would impose one of those on both, and "the hypervisor's build +# settings silently changed how the kernel was compiled" is not a +# sentence anybody wants to write in a defect report. +# +# * The guest is configured with ThreadX's OWN toolchain file, so it is +# built the way ThreadX's own CI builds it. A guest compiled some other +# way is a guest whose failures are not the kernel's. +# +# * A guest ELF must never enter the hypervisor's link. Two ThreadX-shaped +# images in one link resolve into each other silently. Separate projects +# make that impossible rather than merely unlikely. +# +# What crosses back is one file: guest_a.bin, the raw image, which the +# hypervisor pulls in with .incbin. guest_a.elf and guest_a.map stay here, +# beside it, because a fault report names a guest PC and the only way to turn +# that back into a function is the map file. + +cmake_minimum_required(VERSION 3.28...4.2) + +project(zx_guest_a + VERSION 0.1.0 + LANGUAGES C ASM + DESCRIPTION "A ThreadX guest for ZoneX partition A" +) + +if(NOT ZX_THREADX_SOURCE_DIR) + message(FATAL_ERROR + "ZX_THREADX_SOURCE_DIR is empty, so there is no ThreadX to build a " + "guest from.\n" + "ZoneX itself does not link ThreadX -- at EL2 it is a standalone " + "bare-metal program -- but the examples build ThreadX GUEST images, " + "and those need kernel sources.\n" + "Configure with -DZX_THREADX_SOURCE_DIR=.") +endif() + +if(NOT ZX_GUEST_COMMON_DIR) + message(FATAL_ERROR "ZX_GUEST_COMMON_DIR was not passed to the guest build.") +endif() + +set(GUEST_DIR ${CMAKE_CURRENT_LIST_DIR}) + +# The port's own board support supplies the reset path and the low-level +# initialisation. NOTHING ELSE OF IT IS USED, and the omissions are the +# interesting part: +# +# entry.S with TX_R52_BOOT_AT_EL1, so its EL2 block is +# skipped -- ZoneX is the EL2 monitor that block +# exists to defer to, and it was written that way +# from the start of the Cortex-R52 port. +# tx_initialize_low_level.S publishes the system stack and first free +# address. Built WITHOUT TX_R52_USE_THREADX_IRQ, +# so it creates no tick and calls no board_init: +# this guest takes no interrupts. +# +# console.c NOT used -- the guest has no UART, it hypercalls. +# gicv3.c NOT used -- the distributor is shared between partitions and +# belongs to the hypervisor. +# timer.c NOT used -- a partition's clock is the hypervisor's to hand +# out, so that a descheduled partition's time can be frozen. +# mpu.c NOT used -- its region table describes a whole board, +# including a device window at 0x80000000 this partition does +# not own. The guest programs its own three regions instead. +# +# That list is the honest summary of what changes when a kernel becomes a +# guest, and it is here rather than in a document because this is where +# somebody looking at the build will be. +set(TX_PORT_BSP ${ZX_THREADX_SOURCE_DIR}/ports/cortex_r52/gnu/example_build/fvp_baser_aemv8r) + +if(NOT EXISTS ${TX_PORT_BSP}/entry.S) + message(FATAL_ERROR + "No Cortex-R52 board support at ${TX_PORT_BSP}.\n" + "ZX_THREADX_SOURCE_DIR must point at a ThreadX checkout, not at a " + "release archive of the kernel alone.") +endif() + +############################################################################### +# The kernel +############################################################################### + +# ThreadX's own root CMakeLists, brought in as a subdirectory of THIS project +# rather than of the hypervisor's. It reads THREADX_ARCH and +# THREADX_TOOLCHAIN, which the toolchain file this project was configured +# with has already set. +add_subdirectory(${ZX_THREADX_SOURCE_DIR} threadx EXCLUDE_FROM_ALL) + +############################################################################### +# The guest image +############################################################################### + +if(CMAKE_C_COMPILER_ID STREQUAL "GNU") + set(GUEST_LINK_QUIET_RWX -Wl,--no-warn-rwx-segments) +else() + set(GUEST_LINK_QUIET_RWX) +endif() + +# WHICH objcopy, and why this is three lines rather than one. +# +# CMake detects CMAKE_OBJCOPY for most toolchains, and ThreadX's GNU toolchain +# file leaves it to do so. Its clang toolchain file instead sets a plain +# OBJCOPY variable, following the convention the rest of that repository uses. +# Neither is wrong and this build has to work under both, so it asks in order +# and fails loudly rather than invoking an empty string -- which presents as +# "COMMAND -O binary: not found" and sends the reader looking for a missing +# tool rather than a missing variable. +if(CMAKE_OBJCOPY) + set(ZX_GUEST_OBJCOPY ${CMAKE_OBJCOPY}) +elseif(OBJCOPY) + set(ZX_GUEST_OBJCOPY ${OBJCOPY}) +else() + message(FATAL_ERROR + "No objcopy was found. The guest image has to be reduced to a raw " + "binary before the hypervisor can embed it; set -DCMAKE_OBJCOPY=.") +endif() + +# One function, because the deliberate-stage-1-fault build is the same image +# with one definition changed. Building both from a shared function is what +# keeps them honest: a negative build that had drifted from the positive one +# would prove nothing about it. +function(zx_add_guest target_name) + add_executable(${target_name} + ${GUEST_DIR}/../../guest_common/zx_guest_head.S + ${GUEST_DIR}/../../guest_common/zx_guest_bsp.c + ${GUEST_DIR}/../../guest_common/zx_guest_app.c + ${TX_PORT_BSP}/entry.S + ${TX_PORT_BSP}/tx_initialize_low_level.S + ) + + target_compile_definitions(${target_name} PRIVATE TX_R52_BOOT_AT_EL1) + + target_include_directories(${target_name} PRIVATE + ${GUEST_DIR} + ${ZX_GUEST_COMMON_DIR} + ${TX_PORT_BSP} + ) + + target_link_libraries(${target_name} PRIVATE threadx) + + target_link_options(${target_name} PRIVATE + -T${GUEST_DIR}/link_partition_a.lds + -nostartfiles + -Wl,-Map=${target_name}.map + ${GUEST_LINK_QUIET_RWX} + ) + + # -T is not a dependency CMake knows about, so editing the window layout + # would not trigger a relink and a stale guest would be loaded against new + # region boundaries. + set_target_properties(${target_name} PROPERTIES + LINK_DEPENDS ${GUEST_DIR}/link_partition_a.lds + ) + + # The raw image, which is what the hypervisor embeds. objcopy -O binary + # spans any gap between loaded sections, which is exactly why the window + # is one contiguous area -- see the header of link_partition_a.lds. + # + # The blob drops the .elf suffix, so the ELF and its map file sit beside + # a blob named for the guest rather than for the ELF. That is not tidying: + # the hypervisor's .incbin names this file literally, and "guest_a.bin" + # is a name a reader can match to "guest_a.elf" without thinking. + string(REPLACE ".elf" "" blob_name ${target_name}) + + add_custom_command( + OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/${blob_name}.bin + COMMAND ${ZX_GUEST_OBJCOPY} -O binary + $ + ${CMAKE_CURRENT_BINARY_DIR}/${blob_name}.bin + DEPENDS ${target_name} + COMMENT "Extracting the raw guest image ${blob_name}.bin" + VERBATIM + ) + + add_custom_target(${blob_name}_bin ALL + DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/${blob_name}.bin + ) +endfunction() + +# ONE GUEST IMAGE, and that is a decision rather than an economy. +# +# The hypervisor demonstrates three different outcomes with this guest -- a +# stage-2 violation, a stage-1 fault, and an access that is genuinely +# permitted -- and it selects between them by writing an ADDRESS into the +# guest's mailbox, not by building a different guest. A binary per case is a +# set of binaries that can drift apart, and the one demonstrating isolation +# would then stop being the one demonstrating that the kernel runs. Here +# they are the same bytes, and the run that proves the kernel works is +# literally the run that proves it is confined. +zx_add_guest(guest_a.elf) diff --git a/examples/fvp_baser_aemv8r/guest_a/link_partition_a.lds b/examples/fvp_baser_aemv8r/guest_a/link_partition_a.lds new file mode 100644 index 0000000..47393e1 --- /dev/null +++ b/examples/fvp_baser_aemv8r/guest_a/link_partition_a.lds @@ -0,0 +1,282 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/* Link map for a ThreadX guest inside ZoneX partition A, Armv8-R AEM FVP. + * + * WHAT MAKES THIS DIFFERENT FROM THE PORT'S OWN link.lds + * + * The guest does not own the machine. It owns ONE CONTIGUOUS WINDOW, whose + * base and size are a property of the hypervisor's manifest rather than of + * the board, and everything the guest has -- code, rodata, data, bss, every + * per-mode stack -- has to fit inside it. Three consequences: + * + * * The window base is declared HERE and in the hypervisor's own linker + * script, and the two must agree. They are checked against each other + * at run time: the hypervisor's loader compares the manifest's window + * against the image it was handed, and the image asserts its own base + * below. A guest linked for the wrong address is otherwise a guest + * that runs until its first literal pool load. + * + * * The layout is granule-aligned at three points rather than one, + * because the guest programs its OWN stage-1 MPU over three regions + * inside the window -- the mailbox, the code, and everything writable. + * PMSAv8-R has no region priority at either stage, so those three must + * not overlap, which means each boundary is a 64-byte boundary. + * + * * KEEPING THE WINDOW CONTIGUOUS IS NOT OPTIONAL, and the reason is + * objcopy rather than the MPU. "objcopy -O binary" spans any gap + * between loaded sections and emits the gap as padding, so a guest + * split across two far-apart memory areas produces a blob the size of + * the distance between them. On the S32Z280 that distance is 1.2 GB. + * One window, one blob, no gap. + * + * WHY THE FIRST TWO SECTIONS ARE AT FIXED OFFSETS + * + * The hypervisor computes both as window_base plus a compile-time constant + * from examples/common/zx_guest_abi.h, because it has a raw blob and no + * symbol table to look anything up in: + * + * +0x00 the readback mailbox, one granule + * +0x40 one branch to the kernel's reset symbol -- the ERET target + * + * The ASSERTs at the end are what make those constants true rather than + * hoped for. + * + * A NOTE ON ALIGN, because the two spellings are not equivalent + * + * Every ALIGN below is written AFTER the colon, which is the section's + * ALIGNMENT. Written before the colon it is the section's ADDRESS, + * evaluated from a location counter that starts at zero, and it silently + * overrides "> WINDOW". That is harmless on a target whose memory starts at + * zero and catastrophic on one whose does not -- which is precisely what + * makes it a trap worth stating in every script in this repository rather + * than in one of them. + */ + +ENTRY(_start) + +/* THE WINDOW. These two numbers are the contract with the hypervisor's own + linker script and manifest. Changing either means changing both. + + 256 KB is not a measurement, it is headroom: the guest below links to + roughly a tenth of it, and a window sized to what the image happens to + need today would fail the ASSERT the first time a demo grew a thread. + The stage-2 region costs the same whatever its size. */ + +__zx_guest_window_base = 0x02000000; +__zx_guest_window_size = 0x00040000; + +/* Per-mode stacks. Every AArch32 mode needs one and the port's el1_entry + sets all six; a mode whose stack symbol was missing would fail the link, + which is the good outcome. These are smaller than the standalone board's + because a partition's window is a budget rather than a whole DRAM. */ + +__hyp_stack_size__ = 0x0100; /* unused at EL1, kept so the port links */ +__svc_stack_size__ = 0x0800; +__irq_stack_size__ = 0x0400; +__fiq_stack_size__ = 0x0200; +__abt_stack_size__ = 0x0200; +__und_stack_size__ = 0x0200; +__sys_stack_size__ = 0x0400; + +MEMORY +{ + WINDOW (rwx) : ORIGIN = 0x02000000, LENGTH = 0x00040000 +} + +SECTIONS +{ + /* ---------------------------------------------------------------- */ + /* +0x00 the mailbox. */ + /* */ + /* LOADED, not NOLOAD, and that is what fixes the whole layout. */ + /* objcopy -O binary emits only sections with contents, so a */ + /* NOLOAD section at the front would be skipped and the blob's */ + /* byte zero would be the SECOND section -- the image shifted by a */ + /* granule, copied to the window base, and executed from the wrong */ + /* place. It is also outside .bss on purpose: the port's boot */ + /* path zeroes .bss, and the hypervisor writes the mailbox BEFORE */ + /* the ERET. */ + /* ---------------------------------------------------------------- */ + + .zx_guest_mailbox : ALIGN(64) + { + __zx_guest_mailbox_start = .; + KEEP(*(.zx_guest_mailbox)) + . = ALIGN(64); + __zx_guest_mailbox_end = .; + } > WINDOW + + /* ---------------------------------------------------------------- */ + /* +0x40 the entry branch, then everything executable. */ + /* */ + /* One stage-1 region covers all of it, read-only and executable. */ + /* Read-only at EL1 while stage 2 permits the write is the case */ + /* where the two stages disagree, and it is what the deliberate */ + /* stage-1 fault build provokes. */ + /* ---------------------------------------------------------------- */ + + .zx_guest_head : ALIGN(64) + { + __zx_guest_code_start = .; + KEEP(*(.zx_guest_head)) + } > WINDOW + + .zx_guest_vectors : ALIGN(32) + { + KEEP(*(.zx_guest_vectors)) + } > WINDOW + + .text : + { + KEEP(*(.vectors_el2)) + KEEP(*(.vectors_el1)) + *(.text*) + *(.glue_7) + *(.glue_7t) + } > WINDOW + + .rodata : + { + . = ALIGN(4); + *(.rodata*) + . = ALIGN(4); + } > WINDOW + + /* ---------------------------------------------------------------- */ + /* Everything writable, from a granule boundary. */ + /* */ + /* The alignment belongs on the output section itself. An */ + /* assignment between sections does not advance a MEMORY region's */ + /* allocation pointer, and ". = ALIGN(64);" at the end of .rodata */ + /* does not extend that section when no data follows it -- in both */ + /* cases .data keeps its unaligned address while the symbol */ + /* reports the aligned one, and the first bytes of .data fall */ + /* inside the read-only code region. The Cortex-R52 port's MPU */ + /* caught exactly that, and the variable it broke was the flag the */ + /* abort handler consults -- so the fault turned itself fatal. */ + /* ---------------------------------------------------------------- */ + + .data : ALIGN(64) + { + __zx_guest_code_end = .; + __zx_guest_data_start = .; + __data_start__ = .; + *(.data*) + . = ALIGN(4); + } > WINDOW + + .bss (NOLOAD) : + { + . = ALIGN(4); + __bss_start__ = .; + *(.bss*) + *(COMMON) + . = ALIGN(4); + __bss_end__ = .; + } > WINDOW + + .stacks (NOLOAD) : + { + . = ALIGN(8); + . = . + __hyp_stack_size__; + __hyp_stack_top = .; + + . = ALIGN(8); + . = . + __svc_stack_size__; + __svc_stack_top = .; + + . = ALIGN(8); + . = . + __irq_stack_size__; + __irq_stack_top = .; + + . = ALIGN(8); + . = . + __fiq_stack_size__; + __fiq_stack_top = .; + + . = ALIGN(8); + . = . + __abt_stack_size__; + __abt_stack_top = .; + + . = ALIGN(8); + . = . + __und_stack_size__; + __und_stack_top = .; + + . = ALIGN(8); + . = . + __sys_stack_size__; + __sys_stack_top = .; + } > WINDOW + + . = ALIGN(8); + _end = .; + PROVIDE(end = .); + + /* The writable stage-1 region runs to the end of the window, not to + _end. _tx_initialize_low_level publishes _end as the first free + address and tx_application_define is free to carve from it; this + guest does not, but a region that stopped at _end would make the + next guest that does fault on memory the manifest granted it. */ + + __zx_guest_window_end = __zx_guest_window_base + __zx_guest_window_size; + __zx_guest_data_end = __zx_guest_window_end; + __data_end__ = __zx_guest_window_end; + __code_start__ = __zx_guest_code_start; + __code_end__ = __zx_guest_code_end; + + /* ---------------------------------------------------------------- */ + /* The geometry the hypervisor computes from constants. */ + /* */ + /* Each of these would otherwise be a run-time mystery. An */ + /* under-aligned base does not fault -- its low bits land on SH, */ + /* AP and XN and silently change what a region grants -- and an */ + /* entry point four bytes out of place is invisible in a manifest */ + /* and costs a hardware session to find. */ + /* ---------------------------------------------------------------- */ + + ASSERT(__zx_guest_mailbox_start == __zx_guest_window_base, + "the mailbox is not at the base of the partition window") + ASSERT((__zx_guest_mailbox_end - __zx_guest_mailbox_start) == 64, + "the mailbox is not exactly one granule") + ASSERT(__zx_guest_code_start == (__zx_guest_window_base + 0x40), + "the entry branch is not at the offset the hypervisor ERETs to") + ASSERT(_start >= __zx_guest_code_start, + "the kernel's reset symbol is outside the guest's code region") + ASSERT(_start < __zx_guest_code_end, + "the kernel's reset symbol is outside the guest's code region") + + ASSERT((__zx_guest_code_start & 63) == 0, + "the guest's code region base is not 64-byte aligned") + ASSERT((__zx_guest_code_end & 63) == 0, + "the guest's code region does not end on a granule boundary") + ASSERT((__zx_guest_data_start & 63) == 0, + "the guest's writable region base is not 64-byte aligned") + + /* The mailbox must NOT be inside the range the port's boot path zeroes, + or the hypervisor's handover is wiped by its own recipient before any + C runs -- a guest that reported nothing and a guest that never + started would then be indistinguishable. */ + + ASSERT(__zx_guest_mailbox_end <= __bss_start__, + "the mailbox overlaps .bss, which the boot path zeroes") + + /* And the whole image has to fit the window it was linked for. The + hypervisor checks this too, from the manifest, and it is asserted + here as well because the two checks catch different mistakes: the + link-time one catches a guest that outgrew its window, the run-time + one catches a manifest that disagrees with the guest. */ + + ASSERT(_end <= __zx_guest_window_end, + "the guest image does not fit its partition window") +} diff --git a/examples/fvp_baser_aemv8r/zx_board.c b/examples/fvp_baser_aemv8r/zx_board.c index 969bc2d..1b2955f 100644 --- a/examples/fvp_baser_aemv8r/zx_board.c +++ b/examples/fvp_baser_aemv8r/zx_board.c @@ -101,6 +101,29 @@ void zx_board_report(void) } +/**************************************************************************/ +/* zx_board_counter_hz */ +/* */ +/* The model resets CNTFRQ to zero and leaves the system counter STOPPED */ +/* -- bp.refcounter.non_arch_start_at_default=0, documented as "firmware */ +/* is expected to enable the timer at boot time". The value below is */ +/* CNTFID0 read back from the counter control frame during the */ +/* Cortex-R52 port work, so it is what the counter WOULD run at once */ +/* something starts it. */ +/* */ +/* ZoneX programs CNTFRQ from this so that a guest reading it gets a */ +/* number rather than a zero. Starting the counter itself is a different */ +/* job and belongs with interrupt delivery, which Phase 0 does not have */ +/* yet -- so a guest that tried to WAIT on this timer would still wait */ +/* forever, and the cooperative guest deliberately does not. */ +/**************************************************************************/ + +uint32_t zx_board_counter_hz(void) +{ + return (uint32_t)ZX_FVP_SYSTEM_COUNTER_HZ; +} + + /**************************************************************************/ /* zx_board_describe_mmio_regions */ /* */ diff --git a/examples/fvp_baser_aemv8r/zx_one_partition.lds b/examples/fvp_baser_aemv8r/zx_one_partition.lds new file mode 100644 index 0000000..dc104fc --- /dev/null +++ b/examples/fvp_baser_aemv8r/zx_one_partition.lds @@ -0,0 +1,225 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/* Link map for the ZoneX one-partition image on the Armv8-R AEM FVP. + * + * WHY THIS IS A SECOND SCRIPT AND NOT AN EXTENSION OF zx_link.lds + * + * The two images describe genuinely different memory. The stage-2 probe has + * two small partition windows and a relocatable blob copied into both; this + * one has a single 256 KB window holding a whole ThreadX, and it embeds a + * guest image that the probe image does not have and must not require. + * + * Sharing one script would mean the probe carrying a KEEP for a section it + * never supplies -- which produces an EMPTY section rather than an error, + * silently, at whatever address the location counter held. That is the exact + * failure the loader's magic-number check exists to catch, and the right + * place to avoid it is here. + * + * THE WINDOW ADDRESS IS A CONTRACT, NOT A CHOICE. 0x02000000 and 0x40000 + * are written into the guest's own linker script as well, because the guest + * is LINKED for this window -- every absolute address inside it is baked in. + * Changing either number means changing both, and the guest declares what it + * was built for in its own image header so that a mismatch is REFUSED at + * boot rather than discovered as a fault at a plausible-looking address. + * + * A NOTE ON ALIGN, because the two spellings are not equivalent + * + * Every ALIGN below is written AFTER the colon, which is the section's + * ALIGNMENT. Written before the colon it is the section's ADDRESS, + * evaluated from a location counter that starts at zero, and it silently + * overrides the region assignment. Harmless where memory starts at zero and + * catastrophic where it does not -- which is what makes it worth repeating + * in every script here rather than in one of them. + * + * ZoneX's OWN code, data, bss and stack are covered by no region at all. + * That is decision D2: with HSCTLR.BR=1 EL2's own accesses fall back to the + * background map, while EL0/EL1 accesses that hit no region fault regardless + * of BR -- so the hypervisor costs no region and is protected from the guest + * precisely by not being mapped. + */ + +ENTRY(_start) + +__zx_hyp_stack_size = 0x1000; + +/* THE PARTITION WINDOW. Both numbers are duplicated in + guest_a/link_partition_a.lds and the pair is checked at boot against the + guest's own image header. */ + +__zx_partition_a_base = 0x02000000; +__zx_partition_a_size = 0x00040000; + +MEMORY +{ + /* Two regions rather than one location counter, so that the window's + address is declared rather than arrived at. A script that reached + 0x02000000 by assignment would place the window correctly today and + silently move it the first time the hypervisor's own image grew past + 32 MB -- which is a change nobody would connect to a guest that + stopped working. */ + + HYP (rwx) : ORIGIN = 0x00000000, LENGTH = 0x02000000 /* 32 MB */ + PART_A (rwx) : ORIGIN = 0x02000000, LENGTH = 0x00040040 /* + a hole */ +} + +SECTIONS +{ + . = ALIGN(64); + + .boot : + { + KEEP(*(.text.boot)) + } > HYP + + .vectors_el2 : + { + KEEP(*(.vectors_el2)) + } > HYP + + .text : + { + *(.text*) + *(.glue_7) + *(.glue_7t) + } > HYP + + .rodata : + { + . = ALIGN(4); + *(.rodata*) + . = ALIGN(4); + } > HYP + + /* ------------------------------------------------------------------ + THE GUEST IMAGE, in the hypervisor's own memory. + + Covered by no stage-2 region, exactly like the rest of ZoneX, so a + running partition cannot read the image it was loaded from -- which + matters more than it looks: a partition able to read its own source + image could also read the NEXT partition's, and the isolation claim + would be about the copies rather than about the originals. + ------------------------------------------------------------------ */ + + .zx_guest_a_blob : ALIGN(64) + { + __zx_guest_a_blob_start = .; + KEEP(*(.zx_guest_a_blob)) + . = ALIGN(4); + __zx_guest_a_blob_end = .; + } > HYP + + .data : ALIGN(64) + { + __zx_data_start = .; + *(.data*) + . = ALIGN(4); + __zx_data_end = .; + } > HYP + + .bss (NOLOAD) : ALIGN(64) + { + __zx_bss_start = .; + *(.bss*) + *(COMMON) + . = ALIGN(4); + __zx_bss_end = .; + } > HYP + + /* SP must stay 8-byte aligned (AAPCS), so the area is aligned before + its top symbol is taken. */ + + .zx_stacks (NOLOAD) : + { + . = ALIGN(8); + . = . + __zx_hyp_stack_size; + __zx_hyp_stack_top = .; + } > HYP + + . = ALIGN(8); + _end = .; + PROVIDE(end = .); + + /* ------------------------------------------------------------------ + PARTITION A'S WINDOW, and the ungranted granule after it. + + NOLOAD: there is nothing in this ELF to load here. The contents + arrive at run time, when the loader copies the guest image in, which + is the path a real guest takes and the reason the manifest's + image_start and image_end are load-bearing rather than decorative. + + THE HOLE IS ADJACENT BY CONSTRUCTION and the ASSERT keeps it that + way. An ungranted address far from every grant proves only that + unmapped memory faults; the defect class worth catching is a region + whose limit is out by ONE GRANULE, and only an adjacent hole catches + it. + ------------------------------------------------------------------ */ + + .zx_partition_a (NOLOAD) : ALIGN(64) + { + __zx_partition_a_start = .; + . = . + __zx_partition_a_size; + __zx_partition_a_end = .; + } > PART_A + + .zx_partition_a_hole (NOLOAD) : ALIGN(64) + { + __zx_partition_a_hole_start = .; + . = . + 64; + __zx_partition_a_hole_end = .; + } > PART_A + + /* ------------------------------------------------------------------ + Assert the geometry the region programming and the loader depend on, + so that a layout mistake fails the LINK rather than the run. A + region whose base is under-aligned does not fault: its low bits land + on SH, AP and XN and silently change what it grants, which is the + single hardest failure in this architecture to see. + ------------------------------------------------------------------ */ + + ASSERT((__zx_partition_a_start & 63) == 0, + "partition A's window base is not 64-byte aligned") + ASSERT((__zx_partition_a_end & 63) == 0, + "partition A's window is not a whole number of granules") + ASSERT(__zx_partition_a_start == __zx_partition_a_base, + "partition A's window is not at the address the guest was linked for") + ASSERT(__zx_partition_a_hole_start == __zx_partition_a_end, + "the ungranted granule is NOT adjacent to partition A's window") + + ASSERT((__zx_guest_a_blob_start & 63) == 0, + "the guest image is not 64-byte aligned") + + /* THE BLOB IS NOT EMPTY, and this is the check that catches an .incbin + whose file was missing or a linker input pattern that matched + nothing. Asserted against the size of the image header rather than + against zero, because a blob too small to carry its own header is + just as useless as one of no size at all -- and the loader's magic + check would then be reading past the end of the section. */ + + ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) > 0x50, + "the embedded guest image is empty or too small to carry its header") + + ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) + <= __zx_partition_a_size, + "the embedded guest image does not fit partition A's window") + + /* And the hypervisor's own image must not have reached the window. The + two MEMORY regions make this impossible rather than merely unlikely, + so this asserts the thing the reader would otherwise have to work out + from the region lengths. */ + + ASSERT(_end <= __zx_partition_a_base, + "the hypervisor's own image has grown into partition A's window") +} diff --git a/examples/guest_common/zx_guest_app.c b/examples/guest_common/zx_guest_app.c new file mode 100644 index 0000000..ab98192 --- /dev/null +++ b/examples/guest_common/zx_guest_app.c @@ -0,0 +1,460 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* GUEST APPLICATION DESCRIPTION */ +/* */ +/* zx_guest_app.c GUEST */ +/* */ +/* DESCRIPTION */ +/* */ +/* A real ThreadX kernel, running at EL1 inside a stage-2 confined */ +/* partition that ZoneX loaded and launched. */ +/* */ +/* Two threads, a queue and a semaphore between them, a counter */ +/* published where the hypervisor can read it, and a verdict. That is */ +/* deliberately unambitious: what is being demonstrated is not the */ +/* application, it is that an UNMODIFIED kernel boots and schedules */ +/* under a hypervisor's stage-2 MPU with its own stage-1 MPU live */ +/* underneath. */ +/* */ +/* WHY IT IS COOPERATIVE */ +/* */ +/* There is no tick. This guest takes no interrupts at all, so it */ +/* uses tx_thread_relinquish and tx_queue_send/receive with */ +/* TX_NO_WAIT; tx_thread_sleep would hang and time slicing would do */ +/* nothing. */ +/* */ +/* That is a sub-milestone rather than a limitation of the design. A */ +/* guest with no interrupts fails FAST and CHEAPLY when something is */ +/* wrong with the launch path -- and the launch path is what has never */ +/* been run before. Adding a timer and an interrupt controller at the */ +/* same time would mean a silent guest with three plausible causes. */ +/* The preemptive guest is the next thing, and it needs the hypervisor */ +/* to take the GIC distributor and hand out a virtual timer. */ +/* */ +/* WHAT PROVES WHAT */ +/* */ +/* Each progress bit is a milestone the one before it cannot fake: */ +/* */ +/* BSP_MAIN the ERET landed and the port's boot path ran */ +/* KERNEL_ENTERED tx_kernel_enter initialised and called back */ +/* THREADS_RAN the ported context switch works under stage 2 */ +/* QUEUE_OK every item sent was received, in order */ +/* SEMAPHORE_OK a get/put pair handed off between threads */ +/* FINISHED the guest reached a verdict of its own */ +/* */ +/* The hypervisor reads them out of the mailbox afterwards and does */ +/* not have to trust the guest's own printed claim -- which is the */ +/* point, since a guest that printed "PASSED" and set no bits would */ +/* otherwise pass. */ +/* */ +/**************************************************************************/ + +#include "tx_api.h" + +#include "zx_guest_abi.h" +#include "zx_guest_bsp.h" + +/* Static everything. No byte pool, and therefore no allocator between the + kernel and the thing under test: a guest that failed to start because its + pool did not fit its window would look exactly like a guest that failed to + start because the loader put it in the wrong place. */ + +#define GUEST_STACK_SIZE 1024 +#define GUEST_ITERATIONS 4U +#define GUEST_QUEUE_MESSAGES 4U + +static TX_THREAD thread_producer; +static TX_THREAD thread_consumer; +static TX_QUEUE work_queue; +static TX_SEMAPHORE handover; + +static ULONG producer_stack[GUEST_STACK_SIZE / sizeof(ULONG)]; +static ULONG consumer_stack[GUEST_STACK_SIZE / sizeof(ULONG)]; + +/* One ULONG per message, so TX_1_ULONG. */ +static ULONG queue_storage[GUEST_QUEUE_MESSAGES]; + +static volatile ULONG producer_slices; +static volatile ULONG consumer_slices; +static volatile ULONG messages_carried; +static volatile ULONG queue_faults; +static volatile ULONG semaphore_faults; + + +/**************************************************************************/ +/* publish -- push the counters into the mailbox and reseal it. */ +/* */ +/* Called from a thread rather than at the end, so that a guest which */ +/* stops half-way has still reported how far it got. A report written */ +/* only at the end tells a reader nothing about a run that never reached */ +/* the end -- which is the run worth reading about. */ +/**************************************************************************/ + +static void publish(ULONG progress_bits) +{ + guest_mailbox_write(ZX_GD_THREAD_A, producer_slices); + guest_mailbox_write(ZX_GD_THREAD_B, consumer_slices); + guest_mailbox_write(ZX_GD_MESSAGES, messages_carried); + guest_mailbox_write(ZX_GD_TICKS, tx_time_get()); + + guest_report(progress_bits); +} + + +/**************************************************************************/ +/* producer_entry */ +/**************************************************************************/ + +static void producer_entry(ULONG thread_input) +{ + ULONG index; + + (void) thread_input; + + for (index = 0UL; index < (ULONG) GUEST_ITERATIONS; index++) + { + ULONG message = 0xC0DE0000UL + index; + + producer_slices++; + + console_puts("producer sending "); + console_puthex(message); + console_puts("\n"); + + /* TX_NO_WAIT, because there is no tick: a thread that blocked with a + timeout would wait forever. The queue is sized to hold every + message this loop sends, so a full queue here is a real failure + rather than back-pressure, and it is counted as one. */ + + if (tx_queue_send(&work_queue, &message, TX_NO_WAIT) != TX_SUCCESS) + { + queue_faults++; + } + + /* Hand the CPU over explicitly. With no tick, this is the only + thing that makes the consumer run at all -- which is exactly why + the alternation is worth checking: it proves the ported context + switch really switched rather than returning to the caller. */ + + tx_thread_relinquish(); + } + + publish(ZX_GP_THREADS_RAN); +} + + +/**************************************************************************/ +/* consumer_entry */ +/* */ +/* Drains the queue, checks each message is the one the producer sent in */ +/* that order, exercises the semaphore, and produces the verdict. */ +/* */ +/* CHECKING THE VALUE AND NOT MERELY THE COUNT. A queue that delivered */ +/* four messages in the wrong order, or four copies of one message, */ +/* would satisfy a count and would mean the kernel's queue was broken */ +/* under stage 2. The sequence is what catches that. */ +/**************************************************************************/ + +static void consumer_entry(ULONG thread_input) +{ + ULONG expected = 0xC0DE0000UL; + ULONG progress = 0UL; + + (void) thread_input; + + while (messages_carried < (ULONG) GUEST_ITERATIONS) + { + ULONG message = 0UL; + + consumer_slices++; + + if (tx_queue_receive(&work_queue, &message, TX_NO_WAIT) == TX_SUCCESS) + { + if (message != expected) + { + queue_faults++; + } + + expected++; + messages_carried++; + + console_puts("consumer received "); + console_puthex(message); + console_puts("\n"); + } + else if (producer_slices >= (ULONG) GUEST_ITERATIONS) + { + /* The producer has finished and the queue is empty, so nothing + more will ever arrive. Without this the loop would spin + forever: there is no tick, so nothing would interrupt it and + the run would end in a timeout rather than in a report. + + THE TEST USES THE RESULT OF THE RECEIVE ABOVE and does not + perform one of its own. An earlier version called + tx_queue_receive a second time here to ask whether the queue + was empty -- which CONSUMED a message and threw it away, so + the run reported two messages carried out of four and one of + them out of order. A question that changes the thing it is + asking about is not a question. */ + break; + } + else + { + /* Empty, but the producer is still going: let it run. */ + } + + tx_thread_relinquish(); + } + + if (queue_faults == 0UL) + { + progress |= (ULONG) ZX_GP_QUEUE_OK; + } + + /* The semaphore. Created with a count of one, so the first get + succeeds and the second must fail with TX_NO_WAIT -- both halves are + checked, because a semaphore that granted everything would pass a test + that only took it once. */ + + if (tx_semaphore_get(&handover, TX_NO_WAIT) != TX_SUCCESS) + { + semaphore_faults++; + } + + if (tx_semaphore_get(&handover, TX_NO_WAIT) == TX_SUCCESS) + { + semaphore_faults++; + } + + if (tx_semaphore_put(&handover) != TX_SUCCESS) + { + semaphore_faults++; + } + + if (semaphore_faults == 0UL) + { + progress |= (ULONG) ZX_GP_SEMAPHORE_OK; + } + + console_puts("threads ran, producer/consumer slices = "); + console_putdec(producer_slices); + console_puts(" / "); + console_putdec(consumer_slices); + console_puts("\n"); + console_puts("messages carried = "); + console_putdec(messages_carried); + console_puts(", queue faults = "); + console_putdec(queue_faults); + console_puts(", semaphore faults = "); + console_putdec(semaphore_faults); + console_puts("\n"); + + /* THE PROBE, if the hypervisor asked for one. + * + * The address comes from the mailbox rather than from a build option, so + * ONE guest image serves every case the hypervisor wants to demonstrate: + * an address outside the partition window (a stage-2 violation), the + * guest's own code (permitted by stage 2 and refused by the guest's own + * MPU -- a STAGE-1 fault, which never reaches EL2 at all), and an + * address the guest is genuinely granted (which must SURVIVE, and is how + * the check is proved able to fail). + * + * That matters for more than build time. A separate guest binary per + * case is a set of binaries that can drift apart, and the one that + * demonstrates isolation would then no longer be the one that + * demonstrates the kernel running. Here they are the same bytes. + * + * PROBE_SURVIVED is set only AFTER the write completes, so its absence + * is the evidence that the access was stopped -- by whichever stage + * stopped it. A check whose pass condition is the absence of something + * has to be able to fail, which is what the granted-address build is + * for. */ + + if (guest_mailbox_read(ZX_GD_TARGET) != 0UL) + { + volatile ULONG *target = + (volatile ULONG *)(void *)(unsigned long) + guest_mailbox_read(ZX_GD_TARGET); + + unsigned long address = guest_mailbox_read(ZX_GD_TARGET); + + console_puts("probing "); + console_puthex(address); + console_puts("\n"); + + /* If the guest's own MPU does not already cover the address, grant + it -- otherwise STAGE 1 denies the access and the fault is taken + to EL1, and a stage-2 violation could never be demonstrated from + a real kernel at all. When it IS already covered, granting again + would create an overlap, which is CONSTRAINED UNPREDICTABLE. */ + + if (guest_grant_probe_region(address) != 0U) + { + console_puts("my own MPU did not cover it, so I granted myself\n" + "that granule -- stage 2 has the last word\n"); + } + else + { + console_puts("my own MPU already covers it\n"); + } + + guest_mailbox_write(ZX_GD_PROBED, 0UL); + publish(0UL); + + *target = (ULONG) ZX_GUEST_SENTINEL_A; + + guest_mailbox_write(ZX_GD_PROBED, (unsigned long) *target); + progress |= (ULONG) ZX_GP_PROBE_SURVIVED; + + console_puts("the probe SURVIVED, so nothing denied it\n"); + } + + /* The verdict, written into the mailbox BEFORE it is printed. A guest + that printed a verdict it had not published would let a reader believe + something the hypervisor cannot confirm. */ + + if ((messages_carried == (ULONG) GUEST_ITERATIONS) + && (queue_faults == 0UL) + && (semaphore_faults == 0UL)) + { + guest_mailbox_write(ZX_GD_VERDICT, (unsigned long) ZX_GV_PASSED); + progress |= (ULONG) ZX_GP_FINISHED; + publish(progress); + console_puts("GUEST RESULT: ALL CHECKS PASSED\n"); + } + else + { + guest_mailbox_write(ZX_GD_VERDICT, (unsigned long) ZX_GV_FAILED); + progress |= (ULONG) ZX_GP_FINISHED; + publish(progress); + console_puts("GUEST RESULT: FAILED\n"); + } + + /* Back to the hypervisor, on purpose. A guest that simply returned from + its last thread would leave the kernel idling forever inside a + partition nothing would ever take back, and the run would time out + rather than report. */ + + guest_yield(); +} + + +/**************************************************************************/ +/* tx_application_define */ +/**************************************************************************/ + +void tx_application_define(void *first_unused_memory) +{ + (void) first_unused_memory; + + publish((ULONG) ZX_GP_KERNEL_ENTERED); + + (void) tx_queue_create(&work_queue, "work", TX_1_ULONG, + queue_storage, sizeof(queue_storage)); + + (void) tx_semaphore_create(&handover, "handover", 1UL); + + /* EQUAL PRIORITIES, and that is what makes this a context-switch test. + tx_thread_relinquish yields to the next ready thread OF THE SAME + priority; between threads of different priorities it does nothing + observable, and the higher one simply runs to completion. An earlier + version created the consumer one priority lower and the two never + interleaved at all -- which still delivered every message, and + therefore still would have passed a weaker check than the one here. + + Creation order then decides who runs first, which is what keeps the + message ordering deterministic without a tick to arbitrate. */ + + (void) tx_thread_create(&thread_producer, "producer", producer_entry, 0UL, + producer_stack, sizeof(producer_stack), + 16U, 16U, TX_NO_TIME_SLICE, TX_AUTO_START); + + (void) tx_thread_create(&thread_consumer, "consumer", consumer_entry, 0UL, + consumer_stack, sizeof(consumer_stack), + 16U, 16U, TX_NO_TIME_SLICE, TX_AUTO_START); +} + + +/**************************************************************************/ +/* bsp_main -- entered at EL1 from the port's own boot path. */ +/* */ +/* THE ORDER HERE MATTERS AND IS SHORT ENOUGH TO STATE IN FULL: */ +/* */ +/* REPORT BSP_MAIN, before anything that can fail. If the guest dies */ +/* from here on, the hypervisor still knows the ERET landed and the */ +/* port's boot path completed -- which rules out the entire loader. */ +/* */ +/* INSTALL THE GUEST'S OWN VECTORS, so that a fault from here onwards */ +/* is reported rather than fatal. This comes second and not first on */ +/* purpose: installing vectors before the mailbox write would mean a */ +/* fault during the install itself had nowhere to report to, and the */ +/* mailbox write is the cheaper of the two to get right. */ +/* */ +/* ENABLE THE GUEST'S OWN MPU. After this the guest is confined by */ +/* stage 1 as well as stage 2, and a mistake in either shows up */ +/* immediately -- which is the point of doing it before the kernel */ +/* rather than after. */ +/* */ +/* ENTER THE KERNEL. */ +/**************************************************************************/ + +void bsp_main(void) +{ + unsigned int regions; + + publish((ULONG) ZX_GP_BSP_MAIN); + + /* Before the first print, so that a quiet run really is quiet. See the + note on ZX_GO_QUIET in zx_guest_abi.h: the measured passes cost + nothing on the console and the demonstration pass shows everything, + from the same image. */ + + console_set_quiet(((guest_mailbox_read(ZX_GD_OPTIONS) + & (unsigned long) ZX_GO_QUIET) != 0UL) ? 1U : 0U); + + guest_vectors_install(); + + console_puts("ThreadX guest at EL1, inside a ZoneX partition\n"); + console_puts("mailbox sentinel from the hypervisor = "); + console_puthex(guest_mailbox_read(ZX_GD_SCRATCH)); + console_puts("\n"); + + regions = guest_mpu_init(); + + if (regions == 0U) + { + console_puts("this part has too few EL1 regions for this guest;\n" + "refusing to enable stage 1 rather than programming a\n" + "region index the implementation does not have\n"); + guest_mailbox_write(ZX_GD_VERDICT, (unsigned long) ZX_GV_FAILED); + publish((ULONG) ZX_GP_FINISHED); + guest_yield(); + return; + } + + console_puts("stage-1 MPU on: "); + console_putdec(regions); + console_puts(" EL1 regions, SCTLR.M = "); + console_putdec(guest_mpu_is_enabled()); + console_puts("\n"); + console_puts("every one of them lies inside this partition's stage-2\n" + "window, or the guest would fault on memory its own MPU\n" + "says it owns -- the stricter of the two stages wins\n"); + + tx_kernel_enter(); +} diff --git a/examples/guest_common/zx_guest_bsp.c b/examples/guest_common/zx_guest_bsp.c new file mode 100644 index 0000000..6b9df81 --- /dev/null +++ b/examples/guest_common/zx_guest_bsp.c @@ -0,0 +1,610 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* GUEST SUPPORT DESCRIPTION */ +/* */ +/* zx_guest_bsp.c GUEST */ +/* */ +/* DESCRIPTION */ +/* */ +/* Board support for a ThreadX guest inside a ZoneX partition: a */ +/* console that hypercalls, an EL1 MPU confined to the partition's own */ +/* window, and the readback mailbox. */ +/* */ +/* THREE THINGS A STANDALONE BSP DOES THAT A PARTITION MUST NOT */ +/* */ +/* Each of these is marked where it would have gone, so that whoever */ +/* adds the second partition finds them rather than rediscovers them: */ +/* */ +/* 1. IT OWNS THE CONSOLE DEVICE. Two partitions cannot both drive */ +/* one UART. Resolved here: the guest hypercalls and the */ +/* hypervisor writes. See console_putc below. */ +/* */ +/* 2. IT INITIALISES THE GIC DISTRIBUTOR. The distributor is */ +/* shared across partitions, so two guests configuring it fight. */ +/* NOT resolved here, because this guest takes no interrupts at */ +/* all -- see the note at guest_mpu_init. The hypervisor has to */ +/* take the distributor and hand each partition a virtual */ +/* interface. */ +/* */ +/* 3. IT USES THE PHYSICAL TIMER. A partition's physical time */ +/* keeps running while it is descheduled, so a guest on the */ +/* physical timer can observe that it was not running -- which */ +/* is exactly the temporal-determinism claim, lost. NOT */ +/* resolved here. The virtual timer with a per-partition */ +/* CNTVOFF is what freezes a descheduled partition's clock. */ +/* */ +/* Only the first is answered by this step. The other two are left as */ +/* they are, deliberately and visibly, because a single cooperative */ +/* partition does not need them and inventing an answer before there */ +/* is a second partition to test it against is how a wrong answer gets */ +/* written down. */ +/* */ +/* MISRA C:2012 deviations (justified) */ +/* */ +/* Directive 4.3 -- every asm statement below is one hypercall or one */ +/* coprocessor operation, in a function that does nothing else. */ +/* Rule 11.4 -- casting a linker symbol's address to an integer is */ +/* inherent to describing memory to an MPU. */ +/* */ +/**************************************************************************/ + +#include "zx_guest_abi.h" +#include "zx_guest_bsp.h" + +/* The window geometry, from the guest's own linker script. Declared as + arrays of unknown size, which is the portable way to name a linker symbol + whose ADDRESS is the value wanted. */ + +extern char zx_guest_mailbox[]; +extern char zx_guest_vectors[]; + +extern char __zx_guest_window_base[]; +extern char __zx_guest_window_end[]; +extern char __zx_guest_code_start[]; +extern char __zx_guest_code_end[]; +extern char __zx_guest_data_start[]; + +/* SCTLR bits this file touches. */ + +#define SCTLR_M (1UL << 0) +#define SCTLR_BR (1UL << 17) + +/* PRBAR/PRLAR field positions, EL1. These are the EL1 encodings and NOT the + stage-2 ones: at EL1, AP[2:1] means "read-only" and "unprivileged access", + which is a different question from the one stage 2 asks. Getting the two + confused produces a region that programs cleanly and grants the wrong + thing, so they are spelled out here rather than shared with anything. */ + +/* Named for WHAT THEY GRANT rather than for the level they grant it at, and + the level does not end the name. A macro ending in _EL1 reads as an + AArch64 system-register spelling, which does not exist on this target at + all -- scripts/check_terminology.sh rejects the shape in CI, and it is + right to: the whole class of mistake it guards against is code written + against the wrong architecture that reads plausibly. */ + +#define EL1_AP_PRIV_RW 0x0UL /* 00: RW at EL1, no EL0 access */ +#define EL1_AP_ANY_RW 0x1UL /* 01: RW at EL1 and EL0 */ +#define EL1_AP_PRIV_RO 0x2UL /* 10: RO at EL1, no EL0 access */ +#define EL1_AP_ANY_RO 0x3UL /* 11: RO at EL1 and EL0 */ + +#define EL1_SH_NON 0x0UL + +/* MAIR indices this guest programs. Index 0 is Normal write-back and index + 1 is Device; index 1 is written explicitly even though it is unused, + because an UNWRITTEN MAIR byte reads as zero -- which is Device-nGnRnE, + memory that works slowly with nothing to fault on. A wrong index should + produce a wrong attribute, not a plausible one. */ + +#define ATTR_NORMAL_WB 0UL +#define ATTR_DEVICE 1UL + +#define MAIR0_VALUE 0x000000FFUL /* byte 0 = 0xFF, byte 1 = 0x00 */ +#define MAIR1_VALUE 0x00000000UL + +#define GRANULE_MASK 0xFFFFFFC0UL + +/* HVC takes a literal immediate, not a register, so the constant has to be + pasted into the instruction text. Two levels, because the argument is + itself a macro and one level would stringify its NAME. */ + +#define ZX_GUEST_STRINGIFY_1(value) #value +#define ZX_GUEST_STRINGIFY(value) ZX_GUEST_STRINGIFY_1(value) + +static unsigned int guest_mpu_regions_used; + +/* Whether the console is suppressed for this run. Read ONCE, out of the + mailbox, rather than consulted per character: the whole point of the quiet + run is that a character costs nothing, and a mailbox read per character + would put a load back on the path the measurement is trying to empty. */ + +static unsigned int guest_console_quiet; + + +/**************************************************************************/ +/* read_mpuir / region availability */ +/**************************************************************************/ + +unsigned int guest_mpu_region_count(void) +{ + unsigned long value; + + __asm__ volatile("mrc p15, 0, %0, c0, c0, 4" : "=r"(value)); + + /* MPUIR keeps the EL1 region count in bits [15:8]. HMPUIR keeps the EL2 + count in bits [7:0]. Two different registers with the field in two + different places is exactly the kind of detail that is wrong once and + then copied, so it is written here and nowhere else in the guest. */ + + return (unsigned int)((value >> 8) & 0xFFUL); +} + + +unsigned int guest_mpu_is_enabled(void) +{ + unsigned long sctlr; + + __asm__ volatile("mrc p15, 0, %0, c1, c0, 0" : "=r"(sctlr)); + + return ((sctlr & SCTLR_M) != 0UL) ? 1U : 0U; +} + + +/**************************************************************************/ +/* program_region -- one EL1 region, left enabled. */ +/* */ +/* BOTH ADDRESSES ARE MASKED TO THE GRANULE BEFORE ANYTHING IS ORED IN. */ +/* An under-aligned base does not fault: its low bits land on SH, AP and */ +/* XN and silently change what the region grants. An unmasked limit is */ +/* worse -- a limit computed as base + size - 1 ends in 0x3F, which lands */ +/* on AttrIndx and selects a MAIR byte nobody wrote. Both traps were */ +/* paid for at EL1 during the Cortex-R52 port work; they are repeated */ +/* here because a guest programs its own MPU with exactly the same */ +/* registers. */ +/**************************************************************************/ + +static void program_region(unsigned int index, unsigned long base, + unsigned long limit, unsigned long ap, + unsigned long execute_never, unsigned long attr) +{ + unsigned long prbar = (base & GRANULE_MASK) + | ((EL1_SH_NON & 0x3UL) << 3) + | ((ap & 0x3UL) << 1) + | (execute_never & 0x1UL); + + unsigned long prlar = (limit & GRANULE_MASK) + | ((attr & 0x7UL) << 1) + | 0x1UL; /* EN */ + + __asm__ volatile("mcr p15, 0, %0, c6, c2, 1" : : "r"((unsigned long)index)); + __asm__ volatile("isb"); + __asm__ volatile("mcr p15, 0, %0, c6, c3, 0" : : "r"(prbar)); + __asm__ volatile("mcr p15, 0, %0, c6, c3, 1" : : "r"(prlar)); + __asm__ volatile("isb"); +} + + +/**************************************************************************/ +/* guest_mpu_init */ +/* */ +/* THREE REGIONS, AND THE MAILBOX IS ONE OF THEM ON PURPOSE. */ +/* */ +/* 0 the mailbox RW, execute-never */ +/* 1 code and rodata READ-ONLY, executable */ +/* 2 data, bss and every stack RW, execute-never */ +/* */ +/* The mailbox needs a region of its own precisely because it is WRITABLE */ +/* and sits at the front of a window whose next section is read-only */ +/* code. PMSAv8-R has no region priority, so it cannot be carved out of */ +/* the code region by overlapping it -- two enabled regions on one */ +/* address is CONSTRAINED UNPREDICTABLE and aborts on the S32Z280. */ +/* */ +/* CACHES STAY OFF, and that is a decision rather than an omission. A */ +/* guest with a write-back data cache leaves its mailbox DIRTY IN CACHE */ +/* where the hypervisor reading it from EL2 -- with EL2's own caches off */ +/* -- would see stale words and report a guest that never got anywhere. */ +/* Turning guest caches on therefore needs either a non-cacheable */ +/* attribute on the mailbox granule or an explicit clean before every */ +/* report, and that belongs with the timing work rather than with the */ +/* first launch. One variable at a time. */ +/* */ +/* SCTLR.BR IS LEFT CLEAR, so an EL1 access that hits no region FAULTS */ +/* rather than falling back to a background map. That is the entire */ +/* point of the guest having an MPU at all: without it the three regions */ +/* above would be advisory. */ +/**************************************************************************/ + +unsigned int guest_mpu_init(void) +{ + unsigned int available = guest_mpu_region_count(); + unsigned int index; + unsigned long sctlr; + + guest_mpu_regions_used = 3U; + + if (available < guest_mpu_regions_used) + { + guest_mpu_regions_used = 0U; + return 0U; + } + + /* MAIR before any region, because a region whose AttrIndx selects an + unwritten byte gets Device-nGnRnE and works -- slowly, with nothing to + fault on. */ + + __asm__ volatile("mcr p15, 0, %0, c10, c2, 0" : : "r"(MAIR0_VALUE)); + __asm__ volatile("mcr p15, 0, %0, c10, c2, 1" : : "r"(MAIR1_VALUE)); + __asm__ volatile("isb"); + + program_region(0U, + (unsigned long)(unsigned char *)zx_guest_mailbox, + ((unsigned long)(unsigned char *)zx_guest_mailbox + + ZX_GD_WINDOW_SIZE) - 1UL, + EL1_AP_PRIV_RW, 1UL, ATTR_NORMAL_WB); + + program_region(1U, + (unsigned long)(unsigned char *)__zx_guest_code_start, + (unsigned long)(unsigned char *)__zx_guest_code_end - 1UL, + EL1_AP_PRIV_RO, 0UL, ATTR_NORMAL_WB); + + program_region(2U, + (unsigned long)(unsigned char *)__zx_guest_data_start, + (unsigned long)(unsigned char *)__zx_guest_window_end - 1UL, + EL1_AP_PRIV_RW, 1UL, ATTR_NORMAL_WB); + + /* Every region the implementation has but this guest does not use is + DISABLED explicitly. Their bases and limits are UNKNOWN out of reset, + and an enabled region with an unknown base can overlap one of the + three above -- which is the CONSTRAINED UNPREDICTABLE case again, and + it would present as a guest that faults on its own memory. */ + + for (index = guest_mpu_regions_used; index < available; index++) + { + unsigned long zero = 0UL; + + __asm__ volatile("mcr p15, 0, %0, c6, c2, 1" + : : "r"((unsigned long)index)); + __asm__ volatile("isb"); + __asm__ volatile("mcr p15, 0, %0, c6, c3, 1" : : "r"(zero)); /* EN=0 */ + __asm__ volatile("isb"); + } + + __asm__ volatile("dsb" ::: "memory"); + + /* M without BR: a miss faults. Caches deliberately left alone -- see + the note above. */ + + __asm__ volatile("mrc p15, 0, %0, c1, c0, 0" : "=r"(sctlr)); + sctlr |= SCTLR_M; + sctlr &= ~SCTLR_BR; + __asm__ volatile("mcr p15, 0, %0, c1, c0, 0" : : "r"(sctlr) : "memory"); + + __asm__ volatile("dsb" ::: "memory"); + __asm__ volatile("isb" ::: "memory"); + + return guest_mpu_regions_used; +} + + +/**************************************************************************/ +/* board_irq_handler / board_fiq_handler */ +/* */ +/* THE PORT'S BOARD SUPPORT EXPECTS THESE TO EXIST, and a partition has */ +/* no interrupt controller to service. */ +/* */ +/* They are all but unreachable: the guest replaces VBAR with its own */ +/* vector table before anything can raise an interrupt, so the port's own */ +/* IRQ and FIQ vectors -- which is what calls these -- stop being the */ +/* ones installed. They exist because the port's reset path REFERENCES */ +/* them on one of the two boards, and an unresolved symbol would fail the */ +/* link rather than the run. */ +/* */ +/* Reaching one anyway means an interrupt arrived at a partition that has */ +/* none configured, in the window before its own vectors were installed. */ +/* So each records which vector it was and hands control back, rather than */ +/* returning to a context that has no reason to be sound. A handler that */ +/* silently returned would turn that into a partition that ran on with an */ +/* interrupt still asserted, which on this hardware is a storm rather than */ +/* a glitch. */ +/**************************************************************************/ + +void board_irq_handler(void); +void board_fiq_handler(void); + + +void board_irq_handler(void) +{ + guest_mailbox_write(ZX_GD_STAGE1, + guest_mailbox_read(ZX_GD_STAGE1) | ZX_GS_IRQ); + guest_yield(); +} + + +void board_fiq_handler(void) +{ + guest_mailbox_write(ZX_GD_STAGE1, + guest_mailbox_read(ZX_GD_STAGE1) | ZX_GS_OTHER); + guest_yield(); +} + + +/**************************************************************************/ +/* guest_grant_probe_region */ +/* */ +/* The containment test is done against the three regions this file */ +/* programmed rather than by reading the MPU back, because what matters */ +/* is whether an OVERLAP would be created and the three bases and limits */ +/* are right here. Reading them back would also work and would make the */ +/* test depend on the register decode being right, which is a second */ +/* thing to get wrong for no gain. */ +/**************************************************************************/ + +static unsigned int within(unsigned long address, const char *base, + const char *end) +{ + unsigned long low = (unsigned long)(const unsigned char *)base; + unsigned long high = (unsigned long)(const unsigned char *)end; + + return ((address >= low) && (address < high)) ? 1U : 0U; +} + + +unsigned int guest_grant_probe_region(unsigned long target) +{ + unsigned long granule_base = target & GRANULE_MASK; + + if (within(target, zx_guest_mailbox, + &zx_guest_mailbox[ZX_GD_WINDOW_SIZE]) != 0U) + { + return 0U; + } + + if (within(target, __zx_guest_code_start, __zx_guest_code_end) != 0U) + { + return 0U; + } + + if (within(target, __zx_guest_data_start, __zx_guest_window_end) != 0U) + { + return 0U; + } + + if (guest_mpu_region_count() <= guest_mpu_regions_used) + { + return 0U; + } + + program_region(guest_mpu_regions_used, granule_base, + granule_base + 63UL, + EL1_AP_PRIV_RW, 1UL, ATTR_NORMAL_WB); + + guest_mpu_regions_used++; + + __asm__ volatile("dsb" ::: "memory"); + __asm__ volatile("isb" ::: "memory"); + + return 1U; +} + + +/**************************************************************************/ +/* guest_vectors_install */ +/* */ +/* VBAR is per-guest state a guest owns, which is the point as much as */ +/* the mechanism: the port's own EL1 vectors report through semihosting */ +/* and then halt, which stops the model with no way for the hypervisor to */ +/* learn what happened. A partition replaces them so that a stage-1 */ +/* fault becomes a message instead of a hang. */ +/* */ +/* A partition switch will have to save and restore this. */ +/**************************************************************************/ + +void guest_vectors_install(void) +{ + unsigned long vbar = (unsigned long)(unsigned char *)zx_guest_vectors; + + __asm__ volatile("mcr p15, 0, %0, c12, c0, 0" : : "r"(vbar) : "memory"); + __asm__ volatile("isb" ::: "memory"); +} + + +/**************************************************************************/ +/* console_putc -- one character, through the hypervisor. */ +/* */ +/* The character is in r0 and the HVC immediate says what to do with it, */ +/* which is why there is no function id to load: a console backend */ +/* receives its character in r0 under AAPCS already, so this is the whole */ +/* call. An id in a register would cost two more instructions on the */ +/* slowest path this guest has. */ +/**************************************************************************/ + +void console_set_quiet(unsigned int quiet) +{ + guest_console_quiet = quiet; +} + + +static void console_putc(char character) +{ + /* The register binding is not decoration: the hypervisor reads the + character out of r0 specifically, so the compiler has to be told which + register to put it in rather than left to choose. */ + + register unsigned long value __asm__("r0") = (unsigned long) + (unsigned char)character; + + __asm__ volatile("hvc #" ZX_GUEST_STRINGIFY(ZX_HVC_GUEST_PUTC) + : "+r"(value) + : + : "memory"); +} + + +/**************************************************************************/ +/* console_puts / console_puthex / console_putdec */ +/* */ +/* Deliberately the same three names the Cortex-R52 board support uses, */ +/* so that a demo written against a standalone board builds unchanged */ +/* inside a partition. The BACKEND is what changed, and only the */ +/* backend. */ +/**************************************************************************/ + +void console_puts(const char *string_ptr) +{ + unsigned int index = 0U; + + if ((string_ptr == 0) || (guest_console_quiet != 0U)) + { + return; + } + + while (string_ptr[index] != '\0') + { + console_putc(string_ptr[index]); + index++; + } +} + + +void console_puthex(unsigned long value) +{ + static const char digits[] = "0123456789abcdef"; + unsigned int index; + + if (guest_console_quiet != 0U) + { + return; + } + + console_putc('0'); + console_putc('x'); + + for (index = 0U; index < 8U; index++) + { + unsigned int shift = (7U - index) * 4U; + + console_putc(digits[(value >> shift) & 0xFUL]); + } +} + + +void console_putdec(unsigned long value) +{ + char buffer[11]; + unsigned int index = 0U; + + if (guest_console_quiet != 0U) + { + return; + } + + if (value == 0UL) + { + console_putc('0'); + return; + } + + while ((value != 0UL) && (index < 10U)) + { + buffer[index] = (char)('0' + (char)(value % 10UL)); + value /= 10UL; + index++; + } + + while (index > 0U) + { + index--; + console_putc(buffer[index]); + } +} + + +/**************************************************************************/ +/* guest_yield */ +/**************************************************************************/ + +void guest_yield(void) +{ + __asm__ volatile("dsb" ::: "memory"); + __asm__ volatile("hvc #" ZX_GUEST_STRINGIFY(ZX_HVC_GUEST_YIELD) + : : : "memory"); +} + + +/**************************************************************************/ +/* The mailbox */ +/**************************************************************************/ + +static volatile unsigned long *mailbox_word(unsigned long offset) +{ + return (volatile unsigned long *) + (void *)&zx_guest_mailbox[offset]; +} + + +unsigned long guest_mailbox_read(unsigned long offset) +{ + return *mailbox_word(offset); +} + + +void guest_mailbox_write(unsigned long offset, unsigned long value) +{ + *mailbox_word(offset) = value; +} + + +/**************************************************************************/ +/* guest_report */ +/* */ +/* THE ORDER IS THE WHOLE FUNCTION. Every reported field is written */ +/* before the sequence, and the sequence before the checksum, so that a */ +/* guest torn off mid-update -- descheduled, or faulted -- leaves a set */ +/* of words that FAILS the checksum rather than one that is individually */ +/* valid and jointly nonsense. A reader that saw the new sequence with */ +/* the old fields would have no way to know. */ +/* */ +/* The sentinel the hypervisor left in the mailbox is folded into the */ +/* checksum, which is what makes a report read out of the WRONG */ +/* partition's window detectable rather than merely unlucky. */ +/**************************************************************************/ + +void guest_report(unsigned long progress_bits) +{ + unsigned long sentinel = guest_mailbox_read(ZX_GD_SCRATCH); + unsigned long progress = guest_mailbox_read(ZX_GD_PROGRESS) | progress_bits; + unsigned long sequence = guest_mailbox_read(ZX_GD_SEQUENCE) + 1UL; + unsigned long ticks = guest_mailbox_read(ZX_GD_TICKS); + unsigned long thread_a = guest_mailbox_read(ZX_GD_THREAD_A); + unsigned long thread_b = guest_mailbox_read(ZX_GD_THREAD_B); + unsigned long messages = guest_mailbox_read(ZX_GD_MESSAGES); + unsigned long verdict = guest_mailbox_read(ZX_GD_VERDICT); + + *mailbox_word(ZX_GD_PROGRESS) = progress; + + __asm__ volatile("dsb" ::: "memory"); + + *mailbox_word(ZX_GD_SEQUENCE) = sequence; + + __asm__ volatile("dsb" ::: "memory"); + + *mailbox_word(ZX_GD_CHECKSUM) = + zx_guest_report_checksum(sentinel, progress, sequence, ticks, + thread_a, thread_b, messages, verdict); + + __asm__ volatile("dsb" ::: "memory"); +} diff --git a/examples/guest_common/zx_guest_bsp.h b/examples/guest_common/zx_guest_bsp.h new file mode 100644 index 0000000..fc44b3f --- /dev/null +++ b/examples/guest_common/zx_guest_bsp.h @@ -0,0 +1,167 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* GUEST SUPPORT DESCRIPTION */ +/* */ +/* zx_guest_bsp.h GUEST */ +/* */ +/* DESCRIPTION */ +/* */ +/* What a ThreadX guest running inside a ZoneX partition needs from */ +/* its own board support, which is very little -- and the "very */ +/* little" is the interesting part of this whole step. */ +/* */ +/* A partition owns no device. It has no UART, no interrupt */ +/* controller and no timer of its own; the hypervisor holds all three. */ +/* So the board support here is a console that ASKS, a stage-1 MPU */ +/* confined to the partition's own window, and a mailbox. Everything */ +/* else an ordinary Cortex-R52 board file does is either the */ +/* hypervisor's job now or does not exist. */ +/* */ +/* THIS IS GUEST CODE, NOT ZONEX CODE */ +/* */ +/* It is compiled into the guest image by the guest's own toolchain */ +/* invocation and links against ThreadX, not against ZoneX. It uses */ +/* ThreadX's type names and C99 conventions rather than ZoneX's, which */ +/* is why nothing here spells ZX_NODISCARD or includes zx_api.h. The */ +/* only header shared across the boundary is zx_guest_abi.h, which is */ +/* the contract itself. */ +/* */ +/**************************************************************************/ + +#ifndef ZX_GUEST_BSP_H +#define ZX_GUEST_BSP_H + +/**************************************************************************/ +/* The console */ +/**************************************************************************/ + +/* Write through the hypervisor, one character per hypercall. + * + * WHY THE GUEST DOES NOT OWN A UART. There is one console device and there + * will be more than one partition. Mapping the device into every partition + * would put MMIO in every partition's stage-2 region set and lengthen the + * isolation claim the whole component exists to make; giving it to one + * partition would leave the other mute. So the guest asks and the + * hypervisor writes, and the hypervisor tags the line with the partition it + * scheduled -- which is also why the guest cannot lie about who it is. + * + * THE COST IS REAL AND IS NOT HIDDEN. A trap per character is the slowest + * thing this guest does, and it sits on the critical path of the + * partition's time window. A partition that is printing is not a partition + * whose timing should be measured. */ + +void console_puts(const char *string_ptr); +void console_puthex(unsigned long value); +void console_putdec(unsigned long value); + +/* Suppress the console for this run. + * + * NOT a debugging convenience. One hypercall per character through a polled + * UART is, on silicon, three orders of magnitude more work than everything + * else this guest does -- so a run with the console on can only BOUND the + * cost of stage 2 from above, and cannot resolve it. The hypervisor + * therefore measures quiet passes and demonstrates a loud one, from the same + * guest image, so that the run being measured is the run being shown. */ + +void console_set_quiet(unsigned int quiet); + +/* Hand the machine back to the hypervisor on purpose, as opposed to being + taken from it by a fault. The distinction is what lets a run say "the + guest finished" rather than only "the guest is no longer running". */ + +void guest_yield(void); + +/**************************************************************************/ +/* The stage-1 MPU */ +/**************************************************************************/ + +/* Program the guest's own EL1 regions and enable SCTLR.M. + * + * A partition's own MPU is not decoration. Stage 2 confines the partition + * to its window; stage 1 confines the guest's THREADS within that window, + * which is the protection a kernel is expected to provide and which stage 2 + * cannot express -- stage 2 sees one window, not the code and data inside + * it. + * + * THE STRICTER OF THE TWO STAGES WINS, at both permissions and attributes. + * Every EL1 region programmed here lies inside the partition's stage-2 + * window, or the guest would fault on memory its own MPU says it owns -- + * which reads as a kernel bug and is not one. The linker script asserts + * the geometry that makes that true. + * + * Returns the number of regions programmed, or zero when the part has too + * few to hold them. Refusing is the difference between a clear message and + * a region programmed at an index the implementation does not have, which is + * UNPREDICTABLE and presents as a hang. */ + +unsigned int guest_mpu_init(void); + +unsigned int guest_mpu_region_count(void); +unsigned int guest_mpu_is_enabled(void); + +/* Make one granule reachable at EL1, if the guest's own regions do not + * already cover it. + * + * THIS EXISTS SO THAT A STAGE-2 VIOLATION IS REACHABLE FROM A REAL KERNEL. + * Both stages are checked and the stricter wins, so an address outside the + * guest's own EL1 regions is denied by STAGE 1 -- and a stage-1 fault is + * taken to EL1 and never reaches the hypervisor. A guest whose own MPU + * stops at its window boundary therefore cannot demonstrate that stage 2 + * stops anything: stage 1 gets there first. + * + * So when the hypervisor asks the guest to probe an address its own MPU does + * not cover, the guest GRANTS ITSELF that granule and then probes. Its own + * MPU now says it owns memory the manifest never gave it, which is exactly + * the configuration worth demonstrating: stage 2 refuses anyway. + * + * A target the guest ALREADY covers gets no new region, and that is not an + * optimisation. Two enabled regions matching one address is CONSTRAINED + * UNPREDICTABLE at either stage and aborts on the S32Z280, so overlapping an + * existing region to "make sure" would break the very run it was meant to + * enable. + * + * Returns non-zero when a region was added. */ + +unsigned int guest_grant_probe_region(unsigned long target); + +/* Install the guest's own EL1 vector table. Called before anything can + fault at EL1 with an opinion worth recording. */ + +void guest_vectors_install(void); + +/**************************************************************************/ +/* The mailbox */ +/**************************************************************************/ + +/* Publish everything the guest has to report, in the order that makes a + torn update detectable. See the note on the readback convention in + zx_guest_abi.h. */ + +void guest_report(unsigned long progress_bits); + +/* Read one word the hypervisor left for the guest, and write one the guest + wants reported. + * + * The reported fields are written through here and the SEQUENCE and CHECKSUM + * only by guest_report, which is what keeps the ordering rule in one place: + * a caller that wrote its own checksum would be free to write it first. */ + +unsigned long guest_mailbox_read(unsigned long offset); +void guest_mailbox_write(unsigned long offset, unsigned long value); + +#endif /* ZX_GUEST_BSP_H */ diff --git a/examples/guest_common/zx_guest_head.S b/examples/guest_common/zx_guest_head.S new file mode 100644 index 0000000..a5059ad --- /dev/null +++ b/examples/guest_common/zx_guest_head.S @@ -0,0 +1,264 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +@ zx_guest_head.S -- the front of a ThreadX guest's partition window. +@ +@ WHAT THIS FILE IS FOR +@ +@ Three things live at fixed offsets in a partition window, and all three +@ are here because both sides of the hypervisor boundary have to agree +@ about where they are: +@ +@ +0x00 the readback mailbox, one granule, zero-filled +@ +0x40 one branch to the kernel's own reset symbol +@ +0x80 the guest's OWN EL1 vector table +@ +@ The offsets are declared in examples/common/zx_guest_abi.h and asserted +@ by the guest's linker script, so a section that grew past its slot fails +@ the LINK rather than moving an entry point the hypervisor computes as a +@ constant. +@ +@ WHY THE MAILBOX IS ASSEMBLED HERE AND NOT DECLARED IN C +@ +@ Because it must be a LOADED section outside .bss, and there is no way to +@ ask a C compiler for that which is clearer than sixteen words of zeros in +@ a named section. A C object in .bss would be wiped by the kernel's own +@ reset path AFTER the hypervisor had written the handover into it, and a +@ NOLOAD section would be skipped by objcopy and shift the whole blob by a +@ granule. The reasoning is in zx_guest_abi.h; this is where it lands. +@ +@ WHY THE GUEST INSTALLS ITS OWN VECTORS +@ +@ The port's entry.S gives EL1 a vector table whose handlers report through +@ semihosting and then HALT. That is right for a standalone kernel on a +@ bench and wrong for a partition: a stage-1 fault would stop the model with +@ no way for the hypervisor to learn what happened, and a regression could +@ not tell that from a hang. +@ +@ So the guest replaces VBAR with the table below, in which a data abort +@ RECORDS ITS SYNDROME IN THE MAILBOX AND HANDS CONTROL BACK. That is what +@ makes a stage-1 fault a reported event. It also demonstrates something a +@ later step needs anyway: VBAR is per-guest state, a guest owns it, and a +@ partition switch has to save and restore it. +@ +@ Replacing it is ordinary guest code -- VBAR is writable at EL1 -- and +@ requires no change whatever to ports/cortex_r52. +@ +@ WHY A STAGE-1 FAULT AND A STAGE-2 FAULT CANNOT BE CONFUSED +@ +@ They arrive in different places. A stage-2 violation is a fault in the +@ EL2 translation regime and is taken to EL2, where the hypervisor's own +@ report names it. A stage-1 fault is taken to EL1, HERE, and the +@ hypervisor only ever learns of it because this handler tells it. The two +@ reports are produced by different code at different privilege levels and +@ say so, which is a stronger guarantee than two branches of one report. + +#include "zx_guest_abi.h" + + .syntax unified + .arm + +/**************************************************************************/ +/* The readback mailbox: one granule at the window base. */ +/* */ +/* Sixteen words of zero, in its own loaded section. The hypervisor */ +/* writes the handover fields into it after copying the image and reads */ +/* the reported ones out afterwards; stage-2 AP cannot deny EL2 access to */ +/* a partition's memory, which is what makes the mailbox free. */ +/**************************************************************************/ + + .section .zx_guest_mailbox, "aw", %progbits + .balign 64 + .global zx_guest_mailbox +zx_guest_mailbox: + .space ZX_GD_WINDOW_SIZE, 0 + .size zx_guest_mailbox, . - zx_guest_mailbox + +/**************************************************************************/ +/* The entry branch. */ +/* */ +/* The hypervisor ERETs to the window base plus a compile-time constant, */ +/* so something predictable has to be there. A kernel's reset symbol is */ +/* wherever its linker put it -- after two vector tables, in this port -- */ +/* so this is one PC-relative branch to it and nothing else. */ +/* */ +/* A32, and that is load-bearing rather than incidental: the hypervisor */ +/* sets SPSR_hyp for the ERET, and if SPSR.T disagreed with the state of */ +/* the instruction at the entry the guest would die on its first */ +/* instruction with an undefined-instruction exception that looks exactly */ +/* like a bad entry address. The port's own _start is A32 when it is */ +/* built to boot at EL1, and this matches it. */ +/**************************************************************************/ + + .section .zx_guest_head, "ax", %progbits + .balign 64 + .global zx_guest_entry + .type zx_guest_entry, %function +zx_guest_entry: + b _start + .size zx_guest_entry, . - zx_guest_entry + +/**************************************************************************/ +/* The image header, immediately after the branch. */ +/* */ +/* WHAT PROBLEM THIS SOLVES. A guest is LINKED for one partition's */ +/* window: every absolute address in it -- every literal pool entry, its */ +/* VBAR, its own MPU bases -- is baked in. The hypervisor, holding a raw */ +/* blob, has no symbol table and no way to know which window that was. */ +/* */ +/* And the failure is a slow one. The entry branch is PC-relative, so a */ +/* guest copied into the wrong window STARTS: it runs until its first */ +/* literal pool load, then reads or writes an address in somebody else's */ +/* partition -- or in nobody's -- and takes a stage-2 fault whose report */ +/* points at a guest PC that looks perfectly reasonable. Nothing in that */ +/* chain says "this image was built for a different address". */ +/* */ +/* So the guest declares it. Three words the loader can check before it */ +/* ERETs anywhere: */ +/* */ +/* +0x44 the window base this image was linked for */ +/* +0x48 the window size it was linked to fit */ +/* +0x4C a magic number saying it is a ZoneX guest at all */ +/* */ +/* The magic earns its four bytes twice over: a window that was never */ +/* written reads as zero, and an .incbin whose input pattern matched */ +/* nothing produces an empty section rather than an error, so "the first */ +/* word of the image is not the magic" catches both. */ +/**************************************************************************/ + + .global zx_guest_image_header +zx_guest_image_header: + .word __zx_guest_window_base + .word __zx_guest_window_size + .word ZX_GUEST_IMAGE_MAGIC + .size zx_guest_image_header, . - zx_guest_image_header + +/**************************************************************************/ +/* The guest's own EL1 vector table. */ +/* */ +/* 32-byte aligned, as VBAR requires. Every entry that is not the data */ +/* abort reports itself through the same handler with a different code, */ +/* because a vector that fell through is how a fault becomes a hang. */ +/**************************************************************************/ + + .section .zx_guest_vectors, "ax", %progbits + .balign 32 + .global zx_guest_vectors +zx_guest_vectors: + b zx_guest_trap_reset @ 0x00 reset + b zx_guest_trap_undef @ 0x04 undefined instruction + b zx_guest_trap_svc @ 0x08 supervisor call + b zx_guest_trap_pabt @ 0x0C prefetch abort + b zx_guest_trap_dabt @ 0x10 data abort + b zx_guest_trap_reserved @ 0x14 reserved + b zx_guest_trap_irq @ 0x18 IRQ + b zx_guest_trap_fiq @ 0x1C FIQ + + .text + .balign 4 + +/**************************************************************************/ +/* zx_guest_trap_dabt -- a stage-1 data abort, reported rather than fatal. */ +/* */ +/* DFSR and DFAR are read FIRST, before anything else can disturb them, */ +/* and written into the mailbox with the progress bit that says a stage-1 */ +/* fault happened. Then HVC #1 hands the machine back to the hypervisor, */ +/* which reads the mailbox and reports. */ +/* */ +/* THE MAILBOX ADDRESS IS REACHED THROUGH A LITERAL, WHICH IS SAFE HERE */ +/* AND WOULD NOT BE IN THE RELOCATABLE PROBE BLOB. This image is LINKED */ +/* for its window: every absolute address in it is already correct, and */ +/* the loader copies it to the address it was linked for. The blob in */ +/* examples/common/zx_payload.S is the opposite case -- one program copied */ +/* into two different windows -- and that is why it takes a base pointer */ +/* in r0 instead. Two guests, two disciplines, and mixing them up */ +/* produces a guest that works in one partition and not the other. */ +/* */ +/* No stack is used. SP_abt exists -- the port's el1_entry gave every mode */ +/* a stack -- but a handler that pushes nothing cannot be the thing that */ +/* overflows while reporting why something else went wrong. */ +/**************************************************************************/ + + .macro ZX_GUEST_REPORT_AND_YIELD code + mrc p15, 0, r0, c5, c0, 0 @ DFSR + mrc p15, 0, r1, c6, c0, 0 @ DFAR + ldr r2, =zx_guest_mailbox + str r0, [r2, #ZX_GD_FAULT_STATUS] + str r1, [r2, #ZX_GD_FAULT_ADDRESS] + ldr r0, [r2, #ZX_GD_STAGE1] + orr r0, r0, #\code + str r0, [r2, #ZX_GD_STAGE1] + dsb + hvc #ZX_HVC_GUEST_YIELD + .endm + + .global zx_guest_trap_dabt + .type zx_guest_trap_dabt, %function +zx_guest_trap_dabt: + ZX_GUEST_REPORT_AND_YIELD ZX_GS_DABT +zx_guest_trap_dabt_hang: + b zx_guest_trap_dabt_hang + .size zx_guest_trap_dabt, . - zx_guest_trap_dabt + +/**************************************************************************/ +/* The other seven vectors. */ +/* */ +/* A prefetch abort reads IFSR and IFAR rather than DFSR and DFAR, and */ +/* the rest have no fault registers at all -- so they share the same */ +/* reporting shape with whatever the syndrome registers happen to hold. */ +/* What matters is that each one is DISTINGUISHABLE: a run that reported */ +/* "the guest stopped" without saying which vector took it would send the */ +/* reader to the wrong half of the kernel. */ +/**************************************************************************/ + + .type zx_guest_trap_pabt, %function +zx_guest_trap_pabt: + mrc p15, 0, r0, c5, c0, 1 @ IFSR + mrc p15, 0, r1, c6, c0, 2 @ IFAR + ldr r2, =zx_guest_mailbox + str r0, [r2, #ZX_GD_FAULT_STATUS] + str r1, [r2, #ZX_GD_FAULT_ADDRESS] + ldr r0, [r2, #ZX_GD_STAGE1] + orr r0, r0, #ZX_GS_PABT + str r0, [r2, #ZX_GD_STAGE1] + dsb + hvc #ZX_HVC_GUEST_YIELD +zx_guest_trap_pabt_hang: + b zx_guest_trap_pabt_hang + .size zx_guest_trap_pabt, . - zx_guest_trap_pabt + + .macro ZX_GUEST_TRAP_OTHER name, code + .type \name, %function +\name: + ldr r2, =zx_guest_mailbox + ldr r0, [r2, #ZX_GD_STAGE1] + orr r0, r0, #\code + str r0, [r2, #ZX_GD_STAGE1] + mov r0, #0 + str r0, [r2, #ZX_GD_FAULT_STATUS] + str r0, [r2, #ZX_GD_FAULT_ADDRESS] + dsb + hvc #ZX_HVC_GUEST_YIELD +1: + b 1b + .size \name, . - \name + .endm + + ZX_GUEST_TRAP_OTHER zx_guest_trap_reset, ZX_GS_OTHER + ZX_GUEST_TRAP_OTHER zx_guest_trap_undef, ZX_GS_UNDEF + ZX_GUEST_TRAP_OTHER zx_guest_trap_svc, ZX_GS_OTHER + ZX_GUEST_TRAP_OTHER zx_guest_trap_reserved, ZX_GS_OTHER + ZX_GUEST_TRAP_OTHER zx_guest_trap_irq, ZX_GS_IRQ + ZX_GUEST_TRAP_OTHER zx_guest_trap_fiq, ZX_GS_OTHER diff --git a/examples/s32z280_evb/CMakeLists.txt b/examples/s32z280_evb/CMakeLists.txt index a6b45f7..772678e 100644 --- a/examples/s32z280_evb/CMakeLists.txt +++ b/examples/s32z280_evb/CMakeLists.txt @@ -50,6 +50,7 @@ function(zx_add_s32z_probe target_name) add_executable(${target_name} EXCLUDE_FROM_ALL ${ZX_COMMON_DIR}/zx_probe_main.c ${ZX_COMMON_DIR}/zx_partitions.c + ${ZX_COMMON_DIR}/zx_report.c ${ZX_COMMON_DIR}/zx_payload.S ${ZX_S32Z_DIR}/zx_board.c ) @@ -100,6 +101,119 @@ target_compile_definitions(zx_probe_el2_fault.elf PRIVATE zx_add_s32z_probe(zx_probe_starved.elf) target_compile_definitions(zx_probe_starved.elf PRIVATE ZX_PROBE_STARVE_REGIONS) +############################################################################### +# The ThreadX guest, and the image that launches it +############################################################################### +# +# Built exactly as the model's is -- a separate CMake invocation with +# ThreadX's own toolchain file, producing one raw binary the hypervisor +# embeds with .incbin. The reasoning is in +# examples/fvp_baser_aemv8r/guest_a/CMakeLists.txt and is not repeated. +# +# WHAT DIFFERS ON THIS BOARD is the window: 64 KB in data SRAM rather than +# 256 KB in flat DRAM, because the hypervisor's own data comes out of the +# same 512 KB and because a partition's code and data must be CONTIGUOUS -- +# objcopy -O binary would otherwise pad the blob out to the 1.2 GB between +# this part's two memories. See guest_a/link_partition_a.lds. +# +# AND ONE THING THE MODEL CANNOT SHOW: the guest's reset path here needs the +# port's TX_R52_BOOT_AT_EL1 bracket, which the S32Z280 entry.S gained for +# this work. Its EL2 block is considerably larger than the model's -- TCMs, +# HSCTLR.TE, HMPUIR, the low-latency peripheral port -- and the register list +# that becomes the hypervisor's responsibility is enumerated at the bracket +# itself, in the kernel, next to the code it replaces. + +if(ZX_THREADX_SOURCE_DIR) + include(ExternalProject) + + set(ZX_GUEST_A_BUILD ${CMAKE_CURRENT_BINARY_DIR}/guest_a) + set(ZX_GUEST_A_BLOB ${ZX_GUEST_A_BUILD}/guest_a.bin) + + if(CMAKE_C_COMPILER_ID STREQUAL "Clang") + set(ZX_GUEST_TOOLCHAIN + ${ZX_THREADX_SOURCE_DIR}/cmake/cortex_r52_clang.cmake) + else() + set(ZX_GUEST_TOOLCHAIN + ${ZX_THREADX_SOURCE_DIR}/cmake/cortex_r52.cmake) + endif() + + ExternalProject_Add(zx_guest_a + SOURCE_DIR ${ZX_S32Z_DIR}/guest_a + BINARY_DIR ${ZX_GUEST_A_BUILD} + CMAKE_GENERATOR ${CMAKE_GENERATOR} + CMAKE_ARGS + -DCMAKE_TOOLCHAIN_FILE=${ZX_GUEST_TOOLCHAIN} + -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} + -DZX_THREADX_SOURCE_DIR=${ZX_THREADX_SOURCE_DIR} + -DZX_GUEST_COMMON_DIR=${ZX_COMMON_DIR} + BUILD_BYPRODUCTS ${ZX_GUEST_A_BLOB} + BUILD_ALWAYS TRUE + INSTALL_COMMAND "" + USES_TERMINAL_CONFIGURE FALSE + USES_TERMINAL_BUILD FALSE + ) + + function(zx_add_s32z_one_partition target_name) + add_executable(${target_name} EXCLUDE_FROM_ALL + ${ZX_COMMON_DIR}/zx_one_partition.c + ${ZX_COMMON_DIR}/zx_report.c + ${ZX_COMMON_DIR}/zx_guest_image.S + ${ZX_S32Z_DIR}/zx_board.c + ) + + add_dependencies(${target_name} zx_guest_a) + + target_include_directories(${target_name} PRIVATE + ${ZX_S32Z_DIR} + ${ZX_COMMON_DIR} + ) + + # .incbin searches the ASSEMBLER's include path, which -I on the + # compiler command line does not reach; it needs -Wa,-I. See the + # note in examples/common/zx_guest_image.S. + set_source_files_properties(${ZX_COMMON_DIR}/zx_guest_image.S + PROPERTIES + COMPILE_OPTIONS "-Wa,-I${ZX_GUEST_A_BUILD}" + OBJECT_DEPENDS "${ZX_GUEST_A_BLOB}" + ) + + target_link_libraries(${target_name} PRIVATE + eclipse-threadx::zonex + zonex::warnings + ) + + target_link_options(${target_name} PRIVATE + -T${ZX_S32Z_DIR}/zx_one_partition.lds + -nostartfiles + -Wl,-Map=${target_name}.map + ${ZX_LINK_QUIET_RWX} + ) + + set_target_properties(${target_name} PROPERTIES + LINK_DEPENDS ${ZX_S32Z_DIR}/zx_one_partition.lds + ) + endfunction() + + zx_add_s32z_one_partition(zx_one_partition.elf) + + zx_add_s32z_one_partition(zx_one_partition_outside.elf) + target_compile_definitions(zx_one_partition_outside.elf PRIVATE + ZX_ONE_PROBE_OUTSIDE) + + zx_add_s32z_one_partition(zx_one_partition_stage1.elf) + target_compile_definitions(zx_one_partition_stage1.elf PRIVATE + ZX_ONE_PROBE_STAGE1) + + zx_add_s32z_one_partition(zx_one_partition_granted.elf) + target_compile_definitions(zx_one_partition_granted.elf PRIVATE + ZX_ONE_PROBE_GRANTED) +else() + message(STATUS + "ZoneX: ZX_THREADX_SOURCE_DIR is empty, so the ThreadX guest images " + "are unavailable. Configure with -DZX_THREADX_SOURCE_DIR= to build them.") +endif() + # No add_test here. A CTest entry that cannot run without a board and a # Windows-side debug server would fail every hosted run, and a suite that is # expected to fail teaches everybody to ignore it. tools/run_zx_probe.sh is diff --git a/examples/s32z280_evb/guest_a/CMakeLists.txt b/examples/s32z280_evb/guest_a/CMakeLists.txt new file mode 100644 index 0000000..47a1e71 --- /dev/null +++ b/examples/s32z280_evb/guest_a/CMakeLists.txt @@ -0,0 +1,220 @@ +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### +# +# The ThreadX guest for ZoneX partition A on the NXP S32Z280-594EVB. +# +# THIS IS A PROJECT OF ITS OWN, configured and built by a SEPARATE CMake +# invocation from a superbuild step in the parent example. It is not an +# add_subdirectory of the hypervisor's build, and the separation is the +# point rather than an inconvenience: +# +# * The guest is ThreadX and stays C99 with ThreadX's own warning set; +# ZoneX is C17 with CMAKE_C_EXTENSIONS OFF and -Wpedantic. One build +# tree would impose one of those on both, and "the hypervisor's build +# settings silently changed how the kernel was compiled" is not a +# sentence anybody wants to write in a defect report. +# +# * The guest is configured with ThreadX's OWN toolchain file, so it is +# built the way ThreadX's own CI builds it. A guest compiled some other +# way is a guest whose failures are not the kernel's. +# +# * A guest ELF must never enter the hypervisor's link. Two ThreadX-shaped +# images in one link resolve into each other silently. Separate projects +# make that impossible rather than merely unlikely. +# +# What crosses back is one file: guest_a.bin, the raw image, which the +# hypervisor pulls in with .incbin. guest_a.elf and guest_a.map stay here, +# beside it, because a fault report names a guest PC and the only way to turn +# that back into a function is the map file. + +cmake_minimum_required(VERSION 3.28...4.2) + +project(zx_guest_a_s32z280 + VERSION 0.1.0 + LANGUAGES C ASM + DESCRIPTION "A ThreadX guest for ZoneX partition A on the S32Z280" +) + +if(NOT ZX_THREADX_SOURCE_DIR) + message(FATAL_ERROR + "ZX_THREADX_SOURCE_DIR is empty, so there is no ThreadX to build a " + "guest from.\n" + "ZoneX itself does not link ThreadX -- at EL2 it is a standalone " + "bare-metal program -- but the examples build ThreadX GUEST images, " + "and those need kernel sources.\n" + "Configure with -DZX_THREADX_SOURCE_DIR=.") +endif() + +if(NOT ZX_GUEST_COMMON_DIR) + message(FATAL_ERROR "ZX_GUEST_COMMON_DIR was not passed to the guest build.") +endif() + +set(GUEST_DIR ${CMAKE_CURRENT_LIST_DIR}) + +# The port's own board support supplies the reset path and the low-level +# initialisation. NOTHING ELSE OF IT IS USED, and the omissions are the +# interesting part: +# +# entry.S with TX_R52_BOOT_AT_EL1, so its EL2 block is +# skipped -- ZoneX is the EL2 monitor that block +# exists to defer to, and it was written that way +# from the start of the Cortex-R52 port. +# tx_initialize_low_level.S publishes the system stack and first free +# address. Built WITHOUT TX_R52_USE_THREADX_IRQ, +# so it creates no tick and calls no board_init: +# this guest takes no interrupts. +# +# console.c NOT used -- the guest has no UART, it hypercalls. On this +# board that matters more than on the model: the standalone +# console is LINFlexD_9 at 0x42980000, a device a partition +# would otherwise need a stage-2 region for. +# gicv3.c NOT used -- the distributor is shared between partitions and +# belongs to the hypervisor. +# timer.c NOT used -- a partition's clock is the hypervisor's to hand +# out, so that a descheduled partition's time can be frozen. +# mpu.c NOT used -- its region table describes a whole board. The +# guest programs its own three regions inside its window. +# tcm.c NOT used, and this one is a silicon fact rather than a +# choice: the TCM enables are per-core and ENABLEEL2 is +# silently IGNORED when written from EL1. A guest cannot +# program a TCM. This guest therefore places nothing in one, +# which is why the omission costs it nothing -- a guest that +# DID want TCM would need the hypervisor to program and +# ECC-preload it first. +# +# That list is the honest summary of what changes when a kernel becomes a +# guest, and it is here rather than in a document because this is where +# somebody looking at the build will be. +set(TX_PORT_BSP ${ZX_THREADX_SOURCE_DIR}/ports/cortex_r52/gnu/example_build/s32z280_evb) + +if(NOT EXISTS ${TX_PORT_BSP}/entry.S) + message(FATAL_ERROR + "No Cortex-R52 board support at ${TX_PORT_BSP}.\n" + "ZX_THREADX_SOURCE_DIR must point at a ThreadX checkout, not at a " + "release archive of the kernel alone.") +endif() + +############################################################################### +# The kernel +############################################################################### + +# ThreadX's own root CMakeLists, brought in as a subdirectory of THIS project +# rather than of the hypervisor's. It reads THREADX_ARCH and +# THREADX_TOOLCHAIN, which the toolchain file this project was configured +# with has already set. +add_subdirectory(${ZX_THREADX_SOURCE_DIR} threadx EXCLUDE_FROM_ALL) + +############################################################################### +# The guest image +############################################################################### + +if(CMAKE_C_COMPILER_ID STREQUAL "GNU") + set(GUEST_LINK_QUIET_RWX -Wl,--no-warn-rwx-segments) +else() + set(GUEST_LINK_QUIET_RWX) +endif() + +# WHICH objcopy, and why this is three lines rather than one. +# +# CMake detects CMAKE_OBJCOPY for most toolchains, and ThreadX's GNU toolchain +# file leaves it to do so. Its clang toolchain file instead sets a plain +# OBJCOPY variable, following the convention the rest of that repository uses. +# Neither is wrong and this build has to work under both, so it asks in order +# and fails loudly rather than invoking an empty string -- which presents as +# "COMMAND -O binary: not found" and sends the reader looking for a missing +# tool rather than a missing variable. +if(CMAKE_OBJCOPY) + set(ZX_GUEST_OBJCOPY ${CMAKE_OBJCOPY}) +elseif(OBJCOPY) + set(ZX_GUEST_OBJCOPY ${OBJCOPY}) +else() + message(FATAL_ERROR + "No objcopy was found. The guest image has to be reduced to a raw " + "binary before the hypervisor can embed it; set -DCMAKE_OBJCOPY=.") +endif() + +# One function, because the deliberate-stage-1-fault build is the same image +# with one definition changed. Building both from a shared function is what +# keeps them honest: a negative build that had drifted from the positive one +# would prove nothing about it. +function(zx_add_guest target_name) + add_executable(${target_name} + ${GUEST_DIR}/../../guest_common/zx_guest_head.S + ${GUEST_DIR}/../../guest_common/zx_guest_bsp.c + ${GUEST_DIR}/../../guest_common/zx_guest_app.c + ${TX_PORT_BSP}/entry.S + ${TX_PORT_BSP}/tx_initialize_low_level.S + ) + + target_compile_definitions(${target_name} PRIVATE TX_R52_BOOT_AT_EL1) + + target_include_directories(${target_name} PRIVATE + ${GUEST_DIR} + ${ZX_GUEST_COMMON_DIR} + ${TX_PORT_BSP} + ) + + target_link_libraries(${target_name} PRIVATE threadx) + + target_link_options(${target_name} PRIVATE + -T${GUEST_DIR}/link_partition_a.lds + -nostartfiles + -Wl,-Map=${target_name}.map + ${GUEST_LINK_QUIET_RWX} + ) + + # -T is not a dependency CMake knows about, so editing the window layout + # would not trigger a relink and a stale guest would be loaded against new + # region boundaries. + set_target_properties(${target_name} PROPERTIES + LINK_DEPENDS ${GUEST_DIR}/link_partition_a.lds + ) + + # The raw image, which is what the hypervisor embeds. objcopy -O binary + # spans any gap between loaded sections, which is exactly why the window + # is one contiguous area -- see the header of link_partition_a.lds. + # + # The blob drops the .elf suffix, so the ELF and its map file sit beside + # a blob named for the guest rather than for the ELF. That is not tidying: + # the hypervisor's .incbin names this file literally, and "guest_a.bin" + # is a name a reader can match to "guest_a.elf" without thinking. + string(REPLACE ".elf" "" blob_name ${target_name}) + + add_custom_command( + OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/${blob_name}.bin + COMMAND ${ZX_GUEST_OBJCOPY} -O binary + $ + ${CMAKE_CURRENT_BINARY_DIR}/${blob_name}.bin + DEPENDS ${target_name} + COMMENT "Extracting the raw guest image ${blob_name}.bin" + VERBATIM + ) + + add_custom_target(${blob_name}_bin ALL + DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/${blob_name}.bin + ) +endfunction() + +# ONE GUEST IMAGE, and that is a decision rather than an economy. +# +# The hypervisor demonstrates three different outcomes with this guest -- a +# stage-2 violation, a stage-1 fault, and an access that is genuinely +# permitted -- and it selects between them by writing an ADDRESS into the +# guest's mailbox, not by building a different guest. A binary per case is a +# set of binaries that can drift apart, and the one demonstrating isolation +# would then stop being the one demonstrating that the kernel runs. Here +# they are the same bytes, and the run that proves the kernel works is +# literally the run that proves it is confined. +zx_add_guest(guest_a.elf) diff --git a/examples/s32z280_evb/guest_a/link_partition_a.lds b/examples/s32z280_evb/guest_a/link_partition_a.lds new file mode 100644 index 0000000..1787513 --- /dev/null +++ b/examples/s32z280_evb/guest_a/link_partition_a.lds @@ -0,0 +1,211 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/* Link map for a ThreadX guest inside ZoneX partition A, S32Z280-594EVB. + * + * WHY THE WINDOW IS IN DATA SRAM AND NOT IN CODE SRAM + * + * This board has two memories the RTU reaches at full core speed: 7 MB of + * code SRAM at 0x79900000 and 512 KB of data SRAM at 0x31780000. The + * hypervisor's own text lives in the first and its data in the second. A + * PARTITION needs both in ONE CONTIGUOUS WINDOW, for two independent + * reasons, and data SRAM is the only place that offers it: + * + * * One stage-2 region. A partition split across two areas needs two + * regions and, worse, needs the hypervisor's region budget to grow with + * the number of partitions rather than staying fixed. + * + * * objcopy -O binary SPANS ANY GAP between loaded sections and emits it + * as padding. A guest with code at 0x79900000 and data at 0x31780000 + * produces a blob 1.2 GB long -- the distance between the two areas. + * That is not a theoretical hazard: it is the arithmetic. + * + * The Cortex-R52 Modules port work chose data SRAM for its module area for + * exactly this reason, and the code SRAM it left is mapped read-only. + * + * AND NOT DRAM2, at 0x31800000, which runs at HALF core speed. Nothing here + * would fail if a window were placed there; every timing measurement a later + * step makes would simply be wrong, quietly, and by a factor nobody would + * think to look for. The window below stops well short of it. + * + * The layout, the fixed offsets and the ALIGN discipline are identical to the + * model's script in every other respect; the reasoning for each is written + * out there rather than repeated here, because the two must not drift and one + * copy of the argument is easier to keep true than two. + */ + +ENTRY(_start) + +/* THE WINDOW. These two numbers are the contract with the hypervisor's own + linker script and manifest, and the guest declares them in its own image + header so that a mismatch is REFUSED at boot. + + 64 KB, where the model's guest gets 256 KB. Data SRAM here is 512 KB in + total and the hypervisor's own data, bss and stack come out of the same + memory, so the headroom that costs nothing on a 128 MB model is a real + budget on this board. The image below links to roughly a third of it. */ + +__zx_guest_window_base = 0x317C0000; +__zx_guest_window_size = 0x00010000; + +/* Per-mode stacks. Smaller than the model's for the same reason the window + is: this is a budget rather than a whole DRAM. */ + +__hyp_stack_size__ = 0x0100; /* unused at EL1, kept so the port links */ +__svc_stack_size__ = 0x0600; +__irq_stack_size__ = 0x0300; +__fiq_stack_size__ = 0x0200; +__abt_stack_size__ = 0x0200; +__und_stack_size__ = 0x0200; +__sys_stack_size__ = 0x0300; + +MEMORY +{ + WINDOW (rwx) : ORIGIN = 0x317C0000, LENGTH = 0x00010000 +} + +SECTIONS +{ + /* +0x00 the mailbox: LOADED, outside .bss, at the window base. See + the model's script for why all three of those are forced. */ + + .zx_guest_mailbox : ALIGN(64) + { + __zx_guest_mailbox_start = .; + KEEP(*(.zx_guest_mailbox)) + . = ALIGN(64); + __zx_guest_mailbox_end = .; + } > WINDOW + + /* +0x40 the entry branch, then everything executable. */ + + .zx_guest_head : ALIGN(64) + { + __zx_guest_code_start = .; + KEEP(*(.zx_guest_head)) + } > WINDOW + + .zx_guest_vectors : ALIGN(32) + { + KEEP(*(.zx_guest_vectors)) + } > WINDOW + + .text : + { + KEEP(*(.vectors_el2)) + KEEP(*(.vectors_el1)) + *(.text*) + *(.glue_7) + *(.glue_7t) + } > WINDOW + + .rodata : + { + . = ALIGN(4); + *(.rodata*) + . = ALIGN(4); + } > WINDOW + + .data : ALIGN(64) + { + __zx_guest_code_end = .; + __zx_guest_data_start = .; + __data_start__ = .; + *(.data*) + . = ALIGN(4); + } > WINDOW + + .bss (NOLOAD) : + { + . = ALIGN(4); + __bss_start__ = .; + *(.bss*) + *(COMMON) + . = ALIGN(4); + __bss_end__ = .; + } > WINDOW + + .stacks (NOLOAD) : + { + . = ALIGN(8); + . = . + __hyp_stack_size__; + __hyp_stack_top = .; + + . = ALIGN(8); + . = . + __svc_stack_size__; + __svc_stack_top = .; + + . = ALIGN(8); + . = . + __irq_stack_size__; + __irq_stack_top = .; + + . = ALIGN(8); + . = . + __fiq_stack_size__; + __fiq_stack_top = .; + + . = ALIGN(8); + . = . + __abt_stack_size__; + __abt_stack_top = .; + + . = ALIGN(8); + . = . + __und_stack_size__; + __und_stack_top = .; + + . = ALIGN(8); + . = . + __sys_stack_size__; + __sys_stack_top = .; + } > WINDOW + + . = ALIGN(8); + _end = .; + PROVIDE(end = .); + + __zx_guest_window_end = __zx_guest_window_base + __zx_guest_window_size; + __zx_guest_data_end = __zx_guest_window_end; + __data_end__ = __zx_guest_window_end; + __code_start__ = __zx_guest_code_start; + __code_end__ = __zx_guest_code_end; + + ASSERT(__zx_guest_mailbox_start == __zx_guest_window_base, + "the mailbox is not at the base of the partition window") + ASSERT((__zx_guest_mailbox_end - __zx_guest_mailbox_start) == 64, + "the mailbox is not exactly one granule") + ASSERT(__zx_guest_code_start == (__zx_guest_window_base + 0x40), + "the entry branch is not at the offset the hypervisor ERETs to") + ASSERT(_start >= __zx_guest_code_start, + "the kernel's reset symbol is outside the guest's code region") + ASSERT(_start < __zx_guest_code_end, + "the kernel's reset symbol is outside the guest's code region") + + ASSERT((__zx_guest_code_start & 63) == 0, + "the guest's code region base is not 64-byte aligned") + ASSERT((__zx_guest_code_end & 63) == 0, + "the guest's code region does not end on a granule boundary") + ASSERT((__zx_guest_data_start & 63) == 0, + "the guest's writable region base is not 64-byte aligned") + + ASSERT(__zx_guest_mailbox_end <= __bss_start__, + "the mailbox overlaps .bss, which the boot path zeroes") + + ASSERT(_end <= __zx_guest_window_end, + "the guest image does not fit its partition window") + + /* And it stays out of the half-speed memory. Nothing would fail if it + did not; every timing number a later step produced would simply be + wrong by a factor nobody would think to look for. */ + + ASSERT(__zx_guest_window_end <= 0x31800000, + "the partition window reaches into DRAM2, which runs at half core speed") +} diff --git a/examples/s32z280_evb/tools/run_zx_probe.gdb b/examples/s32z280_evb/tools/run_zx_probe.gdb index df745ee..eed70d5 100644 --- a/examples/s32z280_evb/tools/run_zx_probe.gdb +++ b/examples/s32z280_evb/tools/run_zx_probe.gdb @@ -150,6 +150,27 @@ def sym(name): def val(name): return rd(sym(name)) +def have(name): + """Is this symbol in the image that was loaded? + + ONE HARNESS, TWO FAMILIES OF IMAGE. The stage-2 probe reports through + zx_payload_result; the one-partition image reports through a mailbox in + its guest's own window. Everything else about the run -- attaching, + clearing EDBGREQ, loading, breaking on the parked symbol, the fault + record, the reset snapshots -- is identical, and duplicating two hundred + lines of bench procedure so that the last thirty could differ is how two + harnesses come to disagree about how the board is driven. + + So the report ASKS what the image has. lookup_symbol is scoped and + returns nothing for a file-scope object at this point, which is why this + goes through the symbol table by name instead. + """ + try: + gdb.parse_and_eval("(unsigned int)&%s" % name) + return True + except gdb.error: + return False + failures = 0 # --- the image's own verdict ------------------------------------------------- @@ -169,25 +190,112 @@ elif verdict != 0: else: print(" the image reported every check passing") -# --- what the payload did ---------------------------------------------------- -RAN, OWN_DATA, HVC_RET, PROBE_OK, ATTEMPTED, SURVIVED = ( - 0x01, 0x02, 0x04, 0x08, 0x10, 0x20) - -progress = val("zx_payload_result") -print("\n zx_payload_result = 0x%08X" % progress) -for bit, name, want_set in ((RAN, "reached EL1 and ran", True), - (OWN_DATA, "wrote and read back its own data", True), - (HVC_RET, "returned from HVC #0", True), - (ATTEMPTED, "attempted the forbidden access", True), - (SURVIVED, "SURVIVED the forbidden access", False)): - got = bool(progress & bit) - ok = (got == want_set) - if not ok: +# --- what the payload did, on a stage-2 probe image -------------------------- +if have("zx_payload_result"): + RAN, OWN_DATA, HVC_RET, PROBE_OK, ATTEMPTED, SURVIVED = ( + 0x01, 0x02, 0x04, 0x08, 0x10, 0x20) + + progress = val("zx_payload_result") + print("\n zx_payload_result = 0x%08X" % progress) + for bit, name, want_set in ((RAN, "reached EL1 and ran", True), + (OWN_DATA, "wrote and read back its own data", True), + (HVC_RET, "returned from HVC #0", True), + (ATTEMPTED, "attempted the forbidden access", True), + (SURVIVED, "SURVIVED the forbidden access", False)): + got = bool(progress & bit) + ok = (got == want_set) + if not ok: + failures += 1 + print(" 0x%02X %-34s %-5s %s" % (bit, name, "set" if got else "clear", + "ok" if ok else "*** WRONG ***")) + if progress & SURVIVED: + print(" ISOLATION FAILURE: the payload wrote memory it was never granted.") + +# --- what the GUEST reported, on a one-partition image ----------------------- +# +# Read out of the guest's own window, from EL2's side of the boundary, which +# is possible because stage-2 AP cannot deny EL2 -- the same property that +# stops AP from isolating partitions is what lets a debugger attached to the +# hypervisor read a partition's memory. +# +# The CHECKSUM is verified before any of it is believed. Three things it +# catches and no single field can: a window nobody ever wrote (every word +# zero, which the magic folded into the checksum makes fail), a guest torn off +# mid-update, and a report read out of the wrong partition's window -- the +# sentinel the hypervisor planted is folded in, so a report can be attributed +# rather than merely found. +if have("__zx_partition_a_start"): + GD_PROGRESS, GD_SCRATCH = 0x00, 0x04 + GD_SEQUENCE, GD_CHECKSUM = 0x18, 0x1C + GD_TICKS, GD_THREAD_A = 0x20, 0x24 + GD_THREAD_B, GD_MESSAGES = 0x28, 0x2C + GD_VERDICT = 0x30 + GD_FAULT_STATUS = 0x34 + GD_FAULT_ADDRESS = 0x38 + GD_STAGE1 = 0x3C + + GP_BSP_MAIN, GP_KERNEL = 0x0100, 0x0200 + GP_THREADS, GP_QUEUE = 0x0400, 0x0800 + GP_SEMAPHORE, GP_FINISHED = 0x1000, 0x4000 + GP_PROBE_SURVIVED = 0x0004 + + GV_PASSED = 0x600DBEEF + GV_FAILED = 0x0BADBEEF + + REPORT_MAGIC = 0x5A58F00D + + box = sym("__zx_partition_a_start") + + def mb(offset): + return rd(box + offset) + + print("\n partition A's window = 0x%08X" % box) + print(" the guest's mailbox, read from EL2's side of the boundary:") + print(" progress = 0x%08X" % mb(GD_PROGRESS)) + print(" sequence = %d" % mb(GD_SEQUENCE)) + print(" sentinel = 0x%08X" % mb(GD_SCRATCH)) + print(" guest ticks = %d" % mb(GD_TICKS)) + print(" producer slices = %d" % mb(GD_THREAD_A)) + print(" consumer slices = %d" % mb(GD_THREAD_B)) + print(" messages carried = %d" % mb(GD_MESSAGES)) + print(" verdict = 0x%08X" % mb(GD_VERDICT)) + print(" stage-1 vector = 0x%08X" % mb(GD_STAGE1)) + print(" stage-1 DFSR = 0x%08X" % mb(GD_FAULT_STATUS)) + print(" stage-1 DFAR = 0x%08X" % mb(GD_FAULT_ADDRESS)) + + expected = (REPORT_MAGIC + mb(GD_SCRATCH) + mb(GD_PROGRESS) + + mb(GD_SEQUENCE) + mb(GD_TICKS) + mb(GD_THREAD_A) + + mb(GD_THREAD_B) + mb(GD_MESSAGES) + + mb(GD_VERDICT)) & 0xFFFFFFFF + + print(" checksum = 0x%08X, expected 0x%08X" + % (mb(GD_CHECKSUM), expected)) + + if mb(GD_SEQUENCE) == 0: + print(" *** FAIL: the guest never sealed a report, so it either") + print(" never ran or never reached its first publish.") + failures += 1 + elif mb(GD_CHECKSUM) != expected: + print(" *** FAIL: the checksum disagrees. This report is torn,") + print(" or it was read out of the wrong window.") failures += 1 - print(" 0x%02X %-34s %-5s %s" % (bit, name, "set" if got else "clear", - "ok" if ok else "*** WRONG ***")) -if progress & SURVIVED: - print(" ISOLATION FAILURE: the payload wrote memory it was never granted.") + else: + print(" the report is sealed and attributable") + + for bit, name in ((GP_BSP_MAIN, "reached bsp_main"), + (GP_KERNEL, "tx_application_define ran"), + (GP_THREADS, "both threads took slices"), + (GP_QUEUE, "the queue carried every message"), + (GP_SEMAPHORE, "the semaphore behaved"), + (GP_FINISHED, "reached its own verdict")): + print(" 0x%04X %-34s %s" + % (bit, name, "yes" if (mb(GD_PROGRESS) & bit) else "no")) + + if mb(GD_PROGRESS) & GP_PROBE_SURVIVED: + print(" *** the guest's probe SURVIVED. On the default image and") + print(" on the two fault images that is an ISOLATION FAILURE;") + print(" on the granted-address image it is the expected") + print(" result and the image's own verdict says FAILED for it.") # --- what the trap handler captured ------------------------------------------ print("") diff --git a/examples/s32z280_evb/zx_board.c b/examples/s32z280_evb/zx_board.c index d74f005..e38d268 100644 --- a/examples/s32z280_evb/zx_board.c +++ b/examples/s32z280_evb/zx_board.c @@ -345,6 +345,20 @@ void zx_board_report(void) } +/**************************************************************************/ +/* zx_board_counter_hz */ +/* */ +/* See the note on ZX_S32Z_SYSTEM_COUNTER_HZ in zx_platform.h: measured */ +/* three ways and cross-checked, because CNTFRQ is a software-declared */ +/* constant that reads zero out of reset on this part. */ +/**************************************************************************/ + +uint32_t zx_board_counter_hz(void) +{ + return (uint32_t)ZX_S32Z_SYSTEM_COUNTER_HZ; +} + + /**************************************************************************/ /* zx_board_describe_mmio_regions */ /* */ diff --git a/examples/s32z280_evb/zx_one_partition.lds b/examples/s32z280_evb/zx_one_partition.lds new file mode 100644 index 0000000..b7bf7b8 --- /dev/null +++ b/examples/s32z280_evb/zx_one_partition.lds @@ -0,0 +1,256 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/* Link map for the ZoneX one-partition image on the S32Z280-594EVB. + * + * WHY THIS IS A SECOND SCRIPT AND NOT AN EXTENSION OF zx_link.lds + * + * The two images describe genuinely different memory. The stage-2 probe has + * two small partition windows and a relocatable blob copied into both; this + * one has a single 64 KB window holding a whole ThreadX, and it embeds a + * guest image that the probe image does not have and must not require. + * + * Sharing one script would mean the probe carrying a KEEP for a section it + * never supplies -- which produces an EMPTY section rather than an error, + * silently, at whatever address the location counter held. That is the exact + * failure the loader's magic-number check exists to catch, and the right + * place to avoid it is here. + * + * THE WINDOW ADDRESS IS A CONTRACT, NOT A CHOICE. 0x317C0000 and 0x10000 + * are written into the guest's own linker script as well, because the guest + * is LINKED for this window -- every absolute address inside it is baked in. + * Changing either number means changing both, and the guest declares what it + * was built for in its own image header so that a mismatch is REFUSED at + * boot rather than discovered as a fault at a plausible-looking address. + * + * A NOTE ON ALIGN, because the two spellings are not equivalent + * + * Every ALIGN below is written AFTER the colon, which is the section's + * ALIGNMENT. Written before the colon it is the section's ADDRESS, + * evaluated from a location counter that starts at zero, and it silently + * overrides the region assignment. Harmless where memory starts at zero and + * catastrophic where it does not -- which is what makes it worth repeating + * in every script here rather than in one of them. + * + * ZoneX's OWN code, data, bss and stack are covered by no region at all. + * That is decision D2: with HSCTLR.BR=1 EL2's own accesses fall back to the + * background map, while EL0/EL1 accesses that hit no region fault regardless + * of BR -- so the hypervisor costs no region and is protected from the guest + * precisely by not being mapped. + */ + +ENTRY(_start) + +__zx_hyp_stack_size = 0x1000; + +/* THE PARTITION WINDOW. Both numbers are duplicated in + guest_a/link_partition_a.lds and the pair is checked at boot against the + guest's own image header. */ + +__zx_partition_a_base = 0x317C0000; +__zx_partition_a_size = 0x00010000; + +MEMORY +{ + /* THREE regions here where the model needs two, because this board has + two memories rather than one flat DRAM. + + CODE 7 MB at 0x79900000, which is also where the core resets. + The hypervisor's text and the embedded guest image live + here. + HYP the first 256 KB of the 512 KB data SRAM at 0x31780000: + the hypervisor's own data, bss and stack. + PART_A a 64 KB partition window higher in the same data SRAM, + plus one granule of ungranted memory after it. + + The partition window is in DATA and not in CODE, and that is forced + rather than chosen: a partition needs its code and its data in ONE + CONTIGUOUS window -- one stage-2 region, and a blob that objcopy does + not pad out to the 1.2 GB between the two memories. The Cortex-R52 + Modules port work put its module area in data SRAM for the same + reason. + + Declared as separate regions rather than reached by assignment so that + the window's address is stated. A script that arrived at 0x317C0000 + by advancing a location counter would place it correctly today and + move it silently the first time the hypervisor's own data grew. */ + + CODE (rx) : ORIGIN = 0x79900000, LENGTH = 0x00700000 /* 7 MB */ + HYP (rwx) : ORIGIN = 0x31780000, LENGTH = 0x00040000 /* 256 KB */ + PART_A (rwx) : ORIGIN = 0x317C0000, LENGTH = 0x00010040 /* + hole */ +} + +SECTIONS +{ + . = ALIGN(64); + + /* .boot MUST come first: MC_ME_PRTN0_CORE0_ADDR is 0x79900000 on this + board, so whatever lands at the start of CODE is what the core + executes out of reset, and that has to be _start -- as a T32 + instruction, since the core resets in Thumb state. The EL2 vector + table needs alignment but not a fixed address, because HVBAR is + programmed at run time, so it follows rather than leads. */ + + .boot : + { + KEEP(*(.text.boot)) + } > CODE + + .vectors_el2 : + { + KEEP(*(.vectors_el2)) + } > CODE + + .text : + { + *(.text*) + *(.glue_7) + *(.glue_7t) + } > CODE + + .rodata : + { + . = ALIGN(4); + *(.rodata*) + . = ALIGN(4); + } > CODE + + /* ------------------------------------------------------------------ + THE GUEST IMAGE, in the hypervisor's own memory. + + Covered by no stage-2 region, exactly like the rest of ZoneX, so a + running partition cannot read the image it was loaded from -- which + matters more than it looks: a partition able to read its own source + image could also read the NEXT partition's, and the isolation claim + would be about the copies rather than about the originals. + ------------------------------------------------------------------ */ + + .zx_guest_a_blob : ALIGN(64) + { + __zx_guest_a_blob_start = .; + KEEP(*(.zx_guest_a_blob)) + . = ALIGN(4); + __zx_guest_a_blob_end = .; + } > CODE + + .data : ALIGN(64) + { + __zx_data_start = .; + *(.data*) + . = ALIGN(4); + __zx_data_end = .; + } > HYP + + .bss (NOLOAD) : ALIGN(64) + { + __zx_bss_start = .; + *(.bss*) + *(COMMON) + . = ALIGN(4); + __zx_bss_end = .; + } > HYP + + /* SP must stay 8-byte aligned (AAPCS), so the area is aligned before + its top symbol is taken. */ + + .zx_stacks (NOLOAD) : + { + . = ALIGN(8); + . = . + __zx_hyp_stack_size; + __zx_hyp_stack_top = .; + } > HYP + + . = ALIGN(8); + _end = .; + PROVIDE(end = .); + + /* ------------------------------------------------------------------ + PARTITION A'S WINDOW, and the ungranted granule after it. + + NOLOAD: there is nothing in this ELF to load here. The contents + arrive at run time, when the loader copies the guest image in, which + is the path a real guest takes and the reason the manifest's + image_start and image_end are load-bearing rather than decorative. + + THE HOLE IS ADJACENT BY CONSTRUCTION and the ASSERT keeps it that + way. An ungranted address far from every grant proves only that + unmapped memory faults; the defect class worth catching is a region + whose limit is out by ONE GRANULE, and only an adjacent hole catches + it. + ------------------------------------------------------------------ */ + + .zx_partition_a (NOLOAD) : ALIGN(64) + { + __zx_partition_a_start = .; + . = . + __zx_partition_a_size; + __zx_partition_a_end = .; + } > PART_A + + .zx_partition_a_hole (NOLOAD) : ALIGN(64) + { + __zx_partition_a_hole_start = .; + . = . + 64; + __zx_partition_a_hole_end = .; + } > PART_A + + /* ------------------------------------------------------------------ + Assert the geometry the region programming and the loader depend on, + so that a layout mistake fails the LINK rather than the run. A + region whose base is under-aligned does not fault: its low bits land + on SH, AP and XN and silently change what it grants, which is the + single hardest failure in this architecture to see. + ------------------------------------------------------------------ */ + + ASSERT((__zx_partition_a_start & 63) == 0, + "partition A's window base is not 64-byte aligned") + ASSERT((__zx_partition_a_end & 63) == 0, + "partition A's window is not a whole number of granules") + ASSERT(__zx_partition_a_start == __zx_partition_a_base, + "partition A's window is not at the address the guest was linked for") + ASSERT(__zx_partition_a_hole_start == __zx_partition_a_end, + "the ungranted granule is NOT adjacent to partition A's window") + + ASSERT((__zx_guest_a_blob_start & 63) == 0, + "the guest image is not 64-byte aligned") + + /* THE BLOB IS NOT EMPTY, and this is the check that catches an .incbin + whose file was missing or a linker input pattern that matched + nothing. Asserted against the size of the image header rather than + against zero, because a blob too small to carry its own header is + just as useless as one of no size at all -- and the loader's magic + check would then be reading past the end of the section. */ + + ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) > 0x50, + "the embedded guest image is empty or too small to carry its header") + + ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) + <= __zx_partition_a_size, + "the embedded guest image does not fit partition A's window") + + /* And the hypervisor's own data must not have reached the window. The + separate MEMORY regions make this impossible rather than merely + unlikely, so this asserts the thing the reader would otherwise have to + work out from the region lengths. */ + + ASSERT(_end <= __zx_partition_a_base, + "the hypervisor's own data has grown into partition A's window") + + /* And the window stays out of the half-speed memory. Nothing would fail + if it did not; every timing number a later step produced would simply + be wrong by a factor nobody would think to look for. */ + + ASSERT((__zx_partition_a_base + __zx_partition_a_size) <= 0x31800000, + "the partition window reaches into DRAM2, which runs at half core speed") +} diff --git a/examples/s32z280_evb/zx_platform.h b/examples/s32z280_evb/zx_platform.h index 7293f28..1f639ab 100644 --- a/examples/s32z280_evb/zx_platform.h +++ b/examples/s32z280_evb/zx_platform.h @@ -87,6 +87,25 @@ simply the first instance in the Reference Manual's list and reaches no connector on this board. [RM + board] */ +/* What the system counter runs at, in Hz. + * + * NOT A GUESS, AND NOT READ FROM ANYWHERE. CNTFRQ is a software-declared + * constant: it reads zero out of reset on this silicon exactly as it does on + * the model, and nothing in the part reports the frequency. The number below + * was established three ways during the Cortex-R52 port work and all three + * agree: the counter was measured against host wall-clock time at 8.0227 MHz + * over a 32-second interval; RTU.GPR CFG_CNTDV reads 4, so the divider is + * (4 + 1) = 5; and the board's FXOSC is 40 MHz, itself confirmed + * independently by the LINFlexD baud divisors the boot ROM left behind. + * 40 / 5 = 8. + * + * It is here rather than in the port because it is a BOARD fact. ZoneX + * programs CNTFRQ from it before entering a guest, because CNTFRQ is writable + * only at the highest implemented exception level -- so a guest built to boot + * at EL1 cannot program it and would otherwise read zero. */ + +#define ZX_S32Z_SYSTEM_COUNTER_HZ 8000000UL + #define ZX_S32Z_LINFLEX_9_BASE 0x42980000UL /* The window ZoneX maps for it. 16 KB covers the register block with room diff --git a/platform/cortex_r52/CMakeLists.txt b/platform/cortex_r52/CMakeLists.txt index 0114d86..e5e8655 100644 --- a/platform/cortex_r52/CMakeLists.txt +++ b/platform/cortex_r52/CMakeLists.txt @@ -19,6 +19,7 @@ target_sources(zonex_port PRIVATE ${CMAKE_CURRENT_LIST_DIR}/src/zx_el2_entry.S ${CMAKE_CURRENT_LIST_DIR}/src/zx_trap_handler.S + ${CMAKE_CURRENT_LIST_DIR}/src/zx_cache.c ${CMAKE_CURRENT_LIST_DIR}/src/zx_console.c ${CMAKE_CURRENT_LIST_DIR}/src/zx_el2_cpu.c ${CMAKE_CURRENT_LIST_DIR}/src/zx_el2_fault_path.c diff --git a/platform/cortex_r52/inc/zx_port.h b/platform/cortex_r52/inc/zx_port.h index b86cc75..2898e8a 100644 --- a/platform/cortex_r52/inc/zx_port.h +++ b/platform/cortex_r52/inc/zx_port.h @@ -289,6 +289,7 @@ #define ZX_ASM_HVC_IMM_NOP 0x0000 #define ZX_ASM_HVC_IMM_YIELD 0x0001 +#define ZX_ASM_HVC_IMM_PUTC 0x0002 #define ZX_ASM_VEC_RESET 0x00 #define ZX_ASM_VEC_UNDEF 0x04 @@ -445,6 +446,53 @@ void zx_hprenr_disable(uint32_t index); void zx_stage2_enable_set(uint32_t mask); +/**************************************************************************/ +/* Preparing EL1 for a guest that boots there */ +/**************************************************************************/ + +/* The EL2-only configuration a guest needs and cannot perform itself. + * + * A standalone Cortex-R52 kernel resets into EL2 and does this in its own boot + * path. Built as a guest it skips that block -- which is what the port's + * TX_R52_BOOT_AT_EL1 option is for -- so the work changes owner rather than + * ceasing to be necessary. Clears HCPTR.TCP10/TCP11 and programs CNTFRQ from + * the board's counter frequency, and deliberately does NOT open EL1 access to + * the physical counter. The reasoning for each, including the omission, is in + * platform/cortex_r52/src/zx_timer.c. */ + +void zx_el2_prepare_guest_el1(uint32_t counter_hz); + +/**************************************************************************/ +/* Making a copied image executable */ +/**************************************************************************/ + +/* Clean the bytes of a freshly copied guest image out of the data cache and + invalidate the instruction cache, in that order and with the barriers + between them. + * + * ONE FUNCTION AND NOT TWO, because the failure mode is doing one half. An + * image whose data side was cleaned and whose instruction side was not is + * executable by luck: a cold instruction cache over an address nothing has + * executed misses, fetches from memory and works, until an eviction lands + * differently. Nothing reports that, and the two-address module test during + * the Cortex-R52 Modules port work passed before this maintenance existed. + * + * ZoneX runs with HSCTLR.C and HSCTLR.I clear today, so this is currently + * redundant. It is here anyway: the change that turns caches on will be one + * line in the reset path, made by somebody with no reason to think about the + * loader. See platform/cortex_r52/src/zx_cache.c for why the data side is + * cleaned by range and the instruction side invalidated whole. */ + +void zx_cache_sync_after_load(zx_addr_t base, zx_size_t length); + +/* The smallest cache line on each side, in bytes, across every level. + Reported at boot rather than merely used, because a maintenance loop that + strode wrongly does not fail -- it leaves lines untouched -- so the stride + belongs in a log where it can be compared against the part. */ + +ZX_NODISCARD uint32_t zx_cache_dcache_line_bytes(void); +ZX_NODISCARD uint32_t zx_cache_icache_line_bytes(void); + /**************************************************************************/ /* The PMU cycle counter */ /**************************************************************************/ diff --git a/platform/cortex_r52/src/zx_cache.c b/platform/cortex_r52/src/zx_cache.c new file mode 100644 index 0000000..5d1267b --- /dev/null +++ b/platform/cortex_r52/src/zx_cache.c @@ -0,0 +1,259 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_cache.c Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* Making a copied guest image executable. */ +/* */ +/* WHY THIS FILE EXISTS AT ALL, GIVEN THAT ZONEX RUNS WITH CACHES OFF */ +/* */ +/* Because "it works today" is exactly how this defect survives. */ +/* */ +/* A guest image arrives in its window through DATA writes and is then */ +/* FETCHED as instructions. On this core the instruction side is not */ +/* coherent with the data cache, so an image copied and not */ +/* synchronised is only executable by luck: a cold instruction cache */ +/* over an address nothing has executed happens to miss, fetch from */ +/* memory, and work. It stops working when an eviction lands */ +/* differently -- which is to say, when something unrelated changes. */ +/* The Cortex-R52 Modules port work had a two-address module test */ +/* passing before this maintenance was added to it. */ +/* */ +/* ZoneX at EL2 clears HSCTLR.C and HSCTLR.I in its reset path, so */ +/* right now both sides go to memory and the sequence below is */ +/* genuinely redundant. It is here anyway, and it is here NOW rather */ +/* than when caches are switched on, because the change that switches */ +/* them on will be a one-line change to the reset path made by */ +/* somebody who has no reason to think about the loader. */ +/* */ +/* WHY A RANGE CLEAN AND NOT A CLEAN-ALL */ +/* */ +/* The Cortex-R52 board support cleans the whole data cache by */ +/* set/way, because what it needs is for EVERYTHING this image wrote */ +/* to be visible to a debugger. A loader needs something narrower and */ +/* can say exactly what: the bytes it just wrote. Cleaning by virtual */ +/* address to the point of coherency costs one operation per cache */ +/* line of the IMAGE rather than one per line of the CACHE, and it */ +/* does not write back unrelated dirty lines belonging to the */ +/* hypervisor. */ +/* */ +/* The instruction side goes the other way: ICIALLU invalidates the */ +/* whole instruction cache in ONE operation, which is cheaper than a */ +/* per-line sweep of the image and strictly safer -- an image copied */ +/* into a window that previously held a DIFFERENT guest may have stale */ +/* lines anywhere in it, including addresses the new image does not */ +/* happen to occupy. */ +/* */ +/* WHY THERE IS ONE ENTRY POINT AND NOT TWO */ +/* */ +/* Because the failure mode is doing one half. A caller that cleaned */ +/* the data side and forgot the instruction side gets an image that */ +/* works until it does not, and there is no diagnostic anywhere that */ +/* would say so. Exposing the halves separately would make that */ +/* mistake available for no benefit: nothing in ZoneX wants one */ +/* without the other. */ +/* */ +/* MISRA C:2012 deviations (justified) */ +/* */ +/* Directive 4.3 (assembly shall be encapsulated and isolated) -- */ +/* observed rather than violated: every asm statement below is one */ +/* coprocessor operation inside a one-line static function. */ +/* Rule 1.1 / 1.2 -- inline assembly is unavoidable for CP15; no */ +/* standard C construct reaches a cache maintenance operation. */ +/* */ +/**************************************************************************/ + +#include "zx_port.h" + +/* CTR, the Cache Type Register: p15, 0, c0, c0, 1. + * + * DminLine and IminLine are the LOG2 OF THE NUMBER OF WORDS in the smallest + * line of each side, not of the number of bytes. A loop that strode by + * (1 << DminLine) rather than (4 << DminLine) would step a quarter of a line + * at a time -- four times the work, still correct -- while one that took the + * field as bytes directly would stride four times too far and leave three + * lines in four untouched. The second is the dangerous reading and it is + * the one that looks right. + * + * The minimum across all levels is what these fields report, which is + * exactly what a virtual-address maintenance loop needs: striding by the + * smallest line means no line at any level is skipped. */ + +#define ZX_CTR_IMINLINE_SHIFT 0U +#define ZX_CTR_DMINLINE_SHIFT 16U +#define ZX_CTR_MINLINE_MASK 0xFU + + +static uint32_t zx_read_ctr(void) +{ + uint32_t value; + + __asm__ volatile("mrc p15, 0, %0, c0, c0, 1" : "=r"(value)); + + return value; +} + + +/* DCCMVAC: clean data cache line by virtual address, to the point of + coherency. p15, 0, c7, c10, 1. */ + +static void zx_dccmvac(zx_addr_t address) +{ + __asm__ volatile("mcr p15, 0, %0, c7, c10, 1" + : : "r"((uint32_t)address) : "memory"); +} + + +/* ICIALLU: invalidate all instruction caches to the point of unification. + p15, 0, c7, c5, 0. The operand is ignored and must still be written. */ + +static void zx_iciallu(void) +{ + uint32_t zero = 0U; + + __asm__ volatile("mcr p15, 0, %0, c7, c5, 0" + : : "r"(zero) : "memory"); +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_cache_dcache_line_bytes Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* The smallest data cache line, in bytes, across every level. */ +/* */ +/* Exposed so that the boot report can PRINT the stride the loader */ +/* used. A maintenance loop that strode wrongly does not fail; it */ +/* leaves lines untouched and the image runs until it does not, so the */ +/* stride is worth putting in a log where somebody can compare it */ +/* against the part's documented line size. */ +/* */ +/**************************************************************************/ + +uint32_t zx_cache_dcache_line_bytes(void) +{ + uint32_t ctr = zx_read_ctr(); + uint32_t words_log2 = (ctr >> ZX_CTR_DMINLINE_SHIFT) & ZX_CTR_MINLINE_MASK; + + return 4U << words_log2; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_cache_icache_line_bytes Cortex-R52 */ +/* */ +/**************************************************************************/ + +uint32_t zx_cache_icache_line_bytes(void) +{ + uint32_t ctr = zx_read_ctr(); + uint32_t words_log2 = (ctr >> ZX_CTR_IMINLINE_SHIFT) & ZX_CTR_MINLINE_MASK; + + return 4U << words_log2; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_cache_sync_after_load Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* Makes freshly written bytes fetchable as instructions. */ +/* */ +/* THE ORDER IS THE WHOLE CONTENT OF THIS FUNCTION: */ +/* */ +/* 1. clean every data cache line the image occupies, so the bytes */ +/* reach memory; */ +/* 2. DSB, so those cleans have completed before anything depends on */ +/* them -- an instruction cache invalidate issued before the */ +/* cleans retire can refill from memory that is still stale; */ +/* 3. invalidate the instruction cache, so no stale line for these */ +/* addresses survives; */ +/* 4. DSB, so the invalidate has completed; */ +/* 5. ISB, so the fetch of the next instruction cannot have been */ +/* made before any of it. */ +/* */ +/* Steps 2 and 4 are the two that get dropped, and neither drop fails */ +/* where it is made. */ +/* */ +/* The base is rounded DOWN to a line boundary and the end rounded UP, */ +/* because maintenance is per line and a partly covered line at either */ +/* end would otherwise be left dirty. A guest window is granule */ +/* aligned and a granule is 64 bytes, so on both ZoneX targets the */ +/* rounding changes nothing -- which is precisely why it is written */ +/* rather than assumed: the first target whose line exceeds a granule */ +/* would silently lose the ends of every image. */ +/* */ +/**************************************************************************/ + +void zx_cache_sync_after_load(zx_addr_t base, zx_size_t length) +{ + uint32_t line = zx_cache_dcache_line_bytes(); + zx_addr_t address; + zx_addr_t end; + + if (length == 0U) + { + /* Nothing was copied, so there is nothing to make fetchable. The + caller should not have got here -- an empty image is refused by + the manifest validator and again by the loader -- and this returns + rather than sweeping the whole address space from a length that + underflowed. */ + return; + } + + /* Round the base down and the end up to whole lines. The end is + computed as a LAST BYTE rather than a one-past-the-end address, so + that an image finishing at the top of the address space does not wrap + to zero and turn a five-line clean into a sweep of everything. */ + + address = base & ~(zx_addr_t)(line - 1U); + end = (base + (length - 1U)) | (zx_addr_t)(line - 1U); + + for (;;) + { + zx_dccmvac(address); + + if (address >= (end - (zx_addr_t)(line - 1U))) + { + break; + } + + address += (zx_addr_t)line; + } + + __asm__ volatile("dsb" ::: "memory"); + + zx_iciallu(); + + __asm__ volatile("dsb" ::: "memory"); + __asm__ volatile("isb" ::: "memory"); +} diff --git a/platform/cortex_r52/src/zx_stage2_mpu.c b/platform/cortex_r52/src/zx_stage2_mpu.c index ee23f2c..b76dc72 100644 --- a/platform/cortex_r52/src/zx_stage2_mpu.c +++ b/platform/cortex_r52/src/zx_stage2_mpu.c @@ -93,6 +93,8 @@ _Static_assert(ZX_ASM_HVC_IMM_NOP == (int)ZX_HVC_NOP, "ZX_ASM_HVC_IMM_NOP and ZX_HVC_NOP have drifted apart"); _Static_assert(ZX_ASM_HVC_IMM_YIELD == (int)ZX_HVC_YIELD, "ZX_ASM_HVC_IMM_YIELD and ZX_HVC_YIELD have drifted apart"); +_Static_assert(ZX_ASM_HVC_IMM_PUTC == (int)ZX_HVC_PUTC, + "ZX_ASM_HVC_IMM_PUTC and ZX_HVC_PUTC have drifted apart"); _Static_assert(ZX_ASM_VEC_RESET == (int)ZX_VECTOR_RESET, "ZX_ASM_VEC_RESET and ZX_VECTOR_RESET have drifted apart"); diff --git a/platform/cortex_r52/src/zx_timer.c b/platform/cortex_r52/src/zx_timer.c index d74bba4..175f35a 100644 --- a/platform/cortex_r52/src/zx_timer.c +++ b/platform/cortex_r52/src/zx_timer.c @@ -29,10 +29,81 @@ /* partition its own CNTVOFF so that guest time freezes while the */ /* partition is descheduled. See docs/decisions.md D7. */ /* */ -/* This translation unit is deliberately empty of implementation. */ -/* See docs/armv8r-el2-reference.md for the verified register sheet */ -/* the code that lands here must be written against. */ +/* Only ONE thing is implemented here so far, and it is the thing a */ +/* guest cannot do for itself. Interrupt delivery, the partition tick */ +/* and CNTVOFF arrive with time partitioning; see */ +/* docs/armv8r-el2-reference.md for the verified register sheet the */ +/* code that lands here must be written against. */ +/* */ +/* MISRA C:2012 deviations (justified) */ +/* */ +/* Directive 4.3 -- each asm statement below is one coprocessor */ +/* operation in a function that does nothing else. */ /* */ /**************************************************************************/ #include "zx_port.h" + +/* HCPTR.TCP10 and TCP11 trap EL1 and EL0 access to CP10 and CP11 -- the + floating-point unit -- to EL2. Both reset SET. */ + +#define ZX_HCPTR_TCP (ZX_C32(0x3) << 10) + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_el2_prepare_guest_el1 Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* The EL2-only configuration a guest needs and cannot perform itself. */ +/* */ +/* WHY THIS FUNCTION EXISTS AT ALL. A standalone Cortex-R52 kernel */ +/* resets INTO EL2 and does this work in its own boot path. Built as a */ +/* guest it skips that block entirely -- which is the whole point of */ +/* the port's TX_R52_BOOT_AT_EL1 option -- so the work does not stop */ +/* being necessary, it changes owner. This is that list, and it is */ +/* deliberately short: */ +/* */ +/* HCPTR.TCP10/TCP11 cleared, so a guest may use its FPU without */ +/* every access trapping to EL2. */ +/* CNTFRQ programmed, because it is writable only at the */ +/* highest implemented exception level and reads */ +/* ZERO out of reset on both ZoneX targets. Any */ +/* guest computing a tick interval from it would */ +/* divide by zero. */ +/* */ +/* AND ONE THING DELIBERATELY NOT DONE. CNTHCTL.PL1PCTEN and PL1PCEN */ +/* stay CLEAR, so EL1 cannot reach the physical counter or the physical */ +/* timer. A standalone kernel opens both; a partition must not have */ +/* them, because a partition's PHYSICAL time keeps running while it is */ +/* descheduled and a guest reading it can therefore observe that it was */ +/* not running. That observation is the temporal-determinism claim, */ +/* lost. Guests get the VIRTUAL timer with a per-partition CNTVOFF */ +/* instead, which is what freezes a descheduled partition's clock -- */ +/* see docs/decisions.md D7. The virtual counter needs no enable here. */ +/* */ +/* FLOATING POINT IS NOT YET PARTITION STATE. Clearing the traps lets */ +/* a guest use the FPU; nothing in ZoneX saves or restores FPEXC, FPSCR */ +/* or the D-registers across a partition switch. With one partition */ +/* that is exactly correct and with two it is a defect, so it is */ +/* written down here rather than discovered by two guests sharing a */ +/* register bank. */ +/* */ +/**************************************************************************/ + +void zx_el2_prepare_guest_el1(uint32_t counter_hz) +{ + uint32_t hcptr; + + __asm__ volatile("mrc p15, 4, %0, c1, c1, 2" : "=r"(hcptr)); + hcptr &= ~(uint32_t)ZX_HCPTR_TCP; + __asm__ volatile("mcr p15, 4, %0, c1, c1, 2" : : "r"(hcptr) : "memory"); + __asm__ volatile("isb"); + + __asm__ volatile("mcr p15, 0, %0, c14, c0, 0" + : : "r"(counter_hz) : "memory"); + __asm__ volatile("isb"); +} diff --git a/platform/cortex_r52/src/zx_trap_handler.S b/platform/cortex_r52/src/zx_trap_handler.S index ce93aee..20f42bc 100644 --- a/platform/cortex_r52/src/zx_trap_handler.S +++ b/platform/cortex_r52/src/zx_trap_handler.S @@ -28,7 +28,10 @@ @ +0x10, which makes the vector an INDEPENDENT witness to what HSR claims. @ Both are captured, and core/src/zx_fault.c reports it when they disagree. @ -@ 0x12 HVC executed -- the hypercall seam +@ 0x12 HVC executed -- the hypercall seam. Three immediates, and +@ which one it is decides whether control goes back to the guest +@ transparently (#0, the empty vector, and #2, a console +@ character) or back to the hypervisor (#1, a yield). @ 0x24 data abort ROUTED to Hyp -- a guest violating its partition, @ with the faulting address in HPFAR. This is the event the @ Phase-0 demonstrator exists to show. @@ -142,9 +145,12 @@ zx_el2_vectors: .type zx_el2_hyp_trap_entry, %function zx_el2_hyp_trap_entry: - @ The guest's r0 and r1 are saved because the HVC #0 path below returns - @ to it transparently and must not have changed anything. Every other - @ path discards them, explicitly, before it goes on. + @ The guest's r0 and r1 are saved because the two TRANSPARENT hypercall + @ paths below return to the guest and must not have changed anything -- + @ and the console path needs the guest's r0 after clobbering it, which is + @ why it reads the character back off this stack rather than out of the + @ register. Every other path discards the pair, explicitly, before it + @ goes on. push {r0, r1} @@ -153,9 +159,12 @@ zx_el2_hyp_trap_entry: cmp r1, #ZX_ASM_EC_HVC bne zx_el2_trap_not_hypercall - @ An HVC. Which one is in HSR.ISS[15:0], and the two ZoneX uses mean - @ opposite things: #0 is the empty Phase-0 hypercall vector and returns - @ to the guest, #1 is a payload handing EL2 the machine back. + @ An HVC. Which one is in HSR.ISS[15:0], and the three ZoneX uses do not + @ all mean the same kind of thing: #0 is the empty Phase-0 hypercall + @ vector and #2 is a console character, both of which RETURN to the + @ guest, while #1 is a guest handing EL2 the machine back. The two + @ transparent ones are decoded first because they are the ones taken + @ most often -- a printing guest takes #2 once per character. ubfx r1, r0, #0, #16 cmp r1, #ZX_ASM_HVC_IMM_NOP @@ -170,6 +179,37 @@ zx_el2_hyp_trap_entry: eret zx_el2_trap_hvc_not_nop: + cmp r1, #ZX_ASM_HVC_IMM_PUTC + bne zx_el2_trap_hvc_not_putc + + @ THE GUEST CONSOLE. One character, in the guest's r0, printed by the + @ hypervisor and tagged with the partition it scheduled. Handled HERE, + @ in the vector, rather than by returning a result code the way a fault + @ is: zx_el2_run_payload is a one-way transfer out, so returning through + @ it would end the guest's excursion, and a console write must leave the + @ guest exactly where it was. This is a service and not a policy + @ decision, which is what makes it safe to answer in the vector. + @ + @ EVERY CALLER-SAVED REGISTER IS THE GUEST'S HERE. Hyp mode banks only + @ SP, LR and SPSR; r0-r12 are shared with EL1. So r2, r3 and r12 are + @ saved as well as r0 and r1, and LR with them because BL overwrites + @ LR_hyp. Four registers is 16 bytes, which keeps SP 8-byte aligned as + @ AAPCS requires at the call. Nothing here captures the fault record: + @ a console write must not overwrite the evidence of the last fault. + @ + @ UXTB, because the character is whatever the guest put in r0. A guest + @ cannot be trusted to have narrowed it, and the upper bytes are not + @ part of the contract. + + push {r2, r3, r12, lr} + ldr r0, [sp, #16] @ the guest's r0 + uxtb r0, r0 + bl zx_guest_console_putc + pop {r2, r3, r12, lr} + pop {r0, r1} @ balanced with the push + eret + +zx_el2_trap_hvc_not_putc: cmp r1, #ZX_ASM_HVC_IMM_YIELD bne zx_el2_trap_not_hypercall diff --git a/test/fvp/run.sh b/test/fvp/run.sh index b7668cb..c8709aa 100755 --- a/test/fvp/run.sh +++ b/test/fvp/run.sh @@ -22,6 +22,11 @@ # The model is found by CMake, with $HOME/FVP_Base_AEMv8R_11.32_19/bin as the # hint. Point ZX_FVP at a different one to override it. # +# Point ZX_THREADX at a ThreadX checkout to build the ThreadX GUEST images as +# well. Without it the guest images are skipped -- with a message rather than +# an error -- and the stage-2 probe images still build and run, so a +# contributor with no ThreadX to hand can still exercise most of the suite. +# # The suite is three runs of the same program: zx_probe.elf, which must pass, # and two builds that must FAIL -- zx_probe_negative.elf, whose deliberate # violation is aimed at an address the payload IS granted, and @@ -45,6 +50,16 @@ configure() { if [ -n "${ZX_FVP:-}" ]; then args+=("-DZX_FVP_BASER_AEMV8R=${ZX_FVP}") fi + # The ThreadX checkout the GUEST images are built from. ZoneX itself + # links no ThreadX -- at EL2 it is a standalone bare-metal program -- but + # the examples build ThreadX guests, so the dependency is real and it is + # taken from the environment rather than guessed at. Point ZX_THREADX at + # a checkout; leave it unset and the guest images are simply not built, + # with a message from CMake saying so. + if [ -n "${ZX_THREADX:-}" ]; then + args+=("-DZX_THREADX_SOURCE_DIR=${ZX_THREADX}") + fi + cmake -S "${ROOT}" -B "${BUILD}" -G Ninja \ -DCMAKE_TOOLCHAIN_FILE="${ROOT}/cmake/cortex_r52.cmake" \ -DCMAKE_BUILD_TYPE=Debug \ diff --git a/test/host/CMakeLists.txt b/test/host/CMakeLists.txt index 8e3b364..d454f3f 100644 --- a/test/host/CMakeLists.txt +++ b/test/host/CMakeLists.txt @@ -51,5 +51,7 @@ endfunction() # to LINK, which is a confusing way to discover that a test needs a console. zx_add_host_test(test_zx_api) zx_add_host_test(test_zx_fault) +zx_add_host_test(test_zx_guest_console) zx_add_host_test(test_zx_manifest) zx_add_host_test(test_zx_mm) +zx_add_host_test(test_zx_partition) diff --git a/test/host/run.sh b/test/host/run.sh index e778e68..b550afc 100755 --- a/test/host/run.sh +++ b/test/host/run.sh @@ -103,14 +103,23 @@ case "${command}" in # code. # # So the floor covers the files where a real number is defensible: - # the manifest validator and the region-layout planner. Both are - # pure functions with no hardware in them, both are reachable in - # full from a workstation, and neither has an excuse for an - # unreached line. 100% is not aspirational -- it is what they - # measure, on lines and on branches both. A rule added without a - # case that fails it drops this and fails the build, which is the - # entire point: a validator rule nothing has ever seen reject - # anything is not a rule, it is a comment. + # the manifest validator, the region-layout planner, the partition + # loader and the guest console. All four are pure functions with no + # hardware in them, all four are reachable in full from a + # workstation, and none has an excuse for an unreached line. 100% + # is not aspirational -- it is what they measure, on lines and on + # branches both. A rule added without a case that fails it drops + # this and fails the build, which is the entire point: a validator + # rule nothing has ever seen reject anything is not a rule, it is a + # comment. + # + # The guest console is on this list for a reason worth stating: its + # whole output is TEXT that somebody reads at three in the morning + # with two partitions interleaving, and the properties that matter + # about it -- one tag per line, no dangling tag, a partial line + # closed before the hypervisor speaks -- are properties of the + # characters. There is nowhere better than a workstation to assert + # those, and nowhere worse than a model log read by eye. # # A file joins this list when it becomes reachable in full, not # when it is written. Adding one that cannot be is how a floor @@ -122,8 +131,10 @@ case "${command}" in echo "" echo "Enforcing the coverage floor on the fully reachable core:" gcovr --root "${ROOT}" \ + --filter "${ROOT}/core/src/zx_guest_console.c" \ --filter "${ROOT}/core/src/zx_manifest_verify.c" \ --filter "${ROOT}/core/src/zx_mm_setup.c" \ + --filter "${ROOT}/core/src/zx_partition_manager.c" \ --txt - \ --fail-under-line 100 \ --fail-under-branch 100 diff --git a/test/host/test_zx_guest_console.c b/test/host/test_zx_guest_console.c new file mode 100644 index 0000000..9ad5722 --- /dev/null +++ b/test/host/test_zx_guest_console.c @@ -0,0 +1,221 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* test_zx_guest_console.c HOST TESTS */ +/* */ +/* DESCRIPTION */ +/* */ +/* The guest console's tagging rules, asserted as TEXT. */ +/* */ +/* This is a file whose entire output is read by a person, usually one */ +/* with a board on the bench and two partitions interleaving their */ +/* output, so the properties worth testing are properties of the */ +/* characters: that a tag opens every line and no more than every */ +/* line, that a line nobody terminated does not swallow the */ +/* hypervisor's next message, and that the identity in the tag is the */ +/* one the hypervisor SCHEDULED rather than one a guest supplied. */ +/* */ +/* Asserting that on a workstation is far better than reading a model */ +/* log by eye, and it is the reason the capture console exists. */ +/* */ +/**************************************************************************/ + +#include "zx_guest_console.h" +#include "zx_console_capture.h" +#include "zx_test.h" + +/* Feed a whole string through the one-character-at-a-time interface, which + is what the hypercall path really does: one trap per character. */ +static void guest_says(const char *text) +{ + UINT index = 0U; + + while (text[index] != '\0') + { + zx_guest_console_putc((CHAR)text[index]); + index++; + } +} + + +static void test_one_tag_per_line(void) +{ + zx_guest_console_reset(); + zx_capture_reset(); + + zx_guest_console_attach(1U, "guest A"); + guest_says("hello\nagain\n"); + zx_guest_console_detach(); + + /* Both lines tagged, and the tag carries the id as well as the name -- + the name is what a reader recognises, the id is what the manifest and + every region index are keyed on. */ + ZX_CHECK(zx_capture_contains("[P1 guest A] hello\n") != 0U); + ZX_CHECK(zx_capture_contains("[P1 guest A] again\n") != 0U); + + /* And NOT a third, dangling tag after the final newline. The prefix is + emitted on the first character of a line rather than after the + newline that ended the previous one, precisely so that a guest which + stops printing leaves no orphan tag -- which reads as output lost. */ + ZX_CHECK(zx_capture_contains("[P1 guest A] [") == 0U); + ZX_CHECK_EQ(zx_guest_console_characters(), 12U); + ZX_CHECK_EQ(zx_guest_console_orphans(), 0U); +} + + +static void test_the_id_is_enough(void) +{ + zx_guest_console_reset(); + zx_capture_reset(); + + /* A partition with no name. The id alone identifies it in a manifest, + so the console must not require a name to tag with. */ + zx_guest_console_attach(2U, (const CHAR *)0); + guest_says("terse\n"); + zx_guest_console_detach(); + + ZX_CHECK(zx_capture_contains("[P2] terse\n") != 0U); +} + + +static void test_the_guest_cannot_choose_its_own_tag(void) +{ + zx_guest_console_reset(); + zx_capture_reset(); + + /* A guest printing what LOOKS like another partition's tag. It comes + out tagged with the partition that was scheduled, with the guest's + claim visible inside the line as ordinary text -- which is exactly + what a reader needs to see. A console that let the guest write the + prefix would make every line of a captured log evidence of nothing. */ + zx_guest_console_attach(1U, "guest A"); + guest_says("[P2 guest B] I am partition 2\n"); + zx_guest_console_detach(); + + ZX_CHECK(zx_capture_contains("[P1 guest A] [P2 guest B] I am partition 2\n") + != 0U); + + /* The forged tag never appears at the start of a line. */ + ZX_CHECK(zx_capture_contains("\n[P2 guest B]") == 0U); +} + + +static void test_an_unterminated_line_is_closed(void) +{ + zx_guest_console_reset(); + zx_capture_reset(); + + /* The case this rule exists for: a guest faults mid-sentence, which is + when a log is read most carefully. Without the newline that detach + adds, the hypervisor's fault report would be appended to the guest's + unfinished line and read as part of it. */ + zx_guest_console_attach(1U, "guest A"); + guest_says("about to fau"); + zx_guest_console_detach(); + + ZX_CHECK(zx_capture_contains("[P1 guest A] about to fau\n") != 0U); + + /* Detaching twice must not emit a second newline: a blank line in a + fault report is a reader wondering what was lost. */ + zx_capture_reset(); + zx_guest_console_detach(); + ZX_CHECK_EQ((int)(zx_capture_text()[0]), (int)'\0'); +} + + +static void test_attaching_over_a_partial_line(void) +{ + zx_guest_console_reset(); + zx_capture_reset(); + + /* Attaching a second partition while the first is mid-line is a caller + error, and the honest response is to keep the two partitions' text on + separate lines rather than to refuse: two partitions sharing one + physical line would be a log that attributes text to the wrong + guest. */ + zx_guest_console_attach(1U, "guest A"); + guest_says("unfinished"); + zx_guest_console_attach(2U, "guest B"); + guest_says("mine\n"); + zx_guest_console_detach(); + + ZX_CHECK(zx_capture_contains("[P1 guest A] unfinished\n") != 0U); + ZX_CHECK(zx_capture_contains("[P2 guest B] mine\n") != 0U); +} + + +static void test_carriage_return_is_dropped(void) +{ + zx_guest_console_reset(); + zx_capture_reset(); + + /* A UART-oriented guest ends its lines CR LF, which is entirely + reasonable of it. Forwarded, the CR would sit immediately before the + next line's tag and overprint it on a terminal -- so a captured log + and a live console would disagree about whether the tag was there at + all. Dropping it loses nothing: the newline is what ends a line + here. */ + zx_guest_console_attach(1U, "guest A"); + guest_says("first\r\nsecond\r\n"); + zx_guest_console_detach(); + + ZX_CHECK(zx_capture_contains("[P1 guest A] first\n") != 0U); + ZX_CHECK(zx_capture_contains("[P1 guest A] second\n") != 0U); + ZX_CHECK(zx_capture_contains("\r") == 0U); + + /* Counted anyway. The count answers "did the guest print through the + hypervisor", and a character the console chose not to forward was + still a character the guest asked to print. */ + ZX_CHECK_EQ(zx_guest_console_characters(), 15U); +} + + +static void test_an_orphan_character_is_named(void) +{ + zx_guest_console_reset(); + zx_capture_reset(); + + /* A console call reaching EL2 with no partition attached. There is no + innocent reason for it: the run loop and the console disagree about + what is executing, and that is a ZoneX bug rather than a guest one. + So it is tagged as such and counted SEPARATELY, where it cannot hide + inside the total. */ + guest_says("who am I\n"); + + ZX_CHECK(zx_capture_contains("[guest, UNATTACHED -- a ZoneX bug] who am I\n") + != 0U); + ZX_CHECK_EQ(zx_guest_console_orphans(), 9U); + ZX_CHECK_EQ(zx_guest_console_characters(), 9U); + + /* Reset really resets, so one image can run several experiments and + each one's counts mean only its own. */ + zx_guest_console_reset(); + ZX_CHECK_EQ(zx_guest_console_orphans(), 0U); + ZX_CHECK_EQ(zx_guest_console_characters(), 0U); +} + +ZX_TEST_MAIN("test_zx_guest_console", + test_one_tag_per_line(); + test_the_id_is_enough(); + test_the_guest_cannot_choose_its_own_tag(); + test_an_unterminated_line_is_closed(); + test_attaching_over_a_partial_line(); + test_carriage_return_is_dropped(); + test_an_orphan_character_is_named(); +) diff --git a/test/host/test_zx_manifest.c b/test/host/test_zx_manifest.c index f611f3f..82636a1 100644 --- a/test/host/test_zx_manifest.c +++ b/test/host/test_zx_manifest.c @@ -60,6 +60,14 @@ #define P1_DATA_BASE 0x00500000U #define P1_DATA_LIMIT 0x0050FFFFU +/* A second executable region for partition 1, one granule wide and well + clear of every other range. It exists so that "the image fits the + executable region the ENTRY is in" can be told apart from the weaker + "the image fits SOME executable region": with the entry moved here, a + large image still fits P1_CODE and must still be rejected. */ +#define P1_TINY_CODE_BASE 0x00600000U +#define P1_TINY_CODE_LIMIT 0x0060003FU + #define SHARED_BASE 0x00300000U #define SHARED_LIMIT 0x0030003FU @@ -368,9 +376,46 @@ static void test_runnability(void) partitions[1].zx_partition_image_end = IMAGE_START - 1U; EXPECT(ZX_MANIFEST_IMAGE_RANGE_INVALID, 1U, ZX_MANIFEST_NO_INDEX); + /* An image of ZERO bytes, which is what a linker input pattern that + matched nothing produces: an empty output section whose start and end + symbols come out equal. A separate rule from a reversed range, + because it is a separate mistake and it is the one that builds, links + and reports a plausible size while containing no guest. */ + reset_manifest(); + partitions[1].zx_partition_image_end = IMAGE_START; + EXPECT(ZX_MANIFEST_IMAGE_EMPTY, 1U, ZX_MANIFEST_NO_INDEX); + reset_manifest(); partitions[1].zx_partition_image_end = IMAGE_START + 0x200000U; EXPECT(ZX_MANIFEST_IMAGE_TOO_LARGE, 1U, ZX_MANIFEST_NO_INDEX); + + /* THE CASE THE WEAKER RULE PASSED. Partition 1 gains a second, tiny + executable region and its entry point moves into it, so the image is + far too big for the window it will actually be loaded into while + still fitting the OTHER executable region comfortably. + "The image fits some executable region" accepts this manifest and + the copy then overruns the window it targets; "the image fits the + region holding the entry" rejects it. Without this case the + difference between those two rules is untested, and the tighter one + would be free to rot back into the looser one. */ + reset_manifest(); + set_region(&p1_regions[3], P1_TINY_CODE_BASE, P1_TINY_CODE_LIMIT, + ZX_AP_EL2_RW_GUEST_RW, ZX_XN_EXECUTABLE, ATTR_NORMAL); + partitions[1].zx_partition_region_count = 4U; + partitions[1].zx_partition_entry = P1_TINY_CODE_BASE; + EXPECT(ZX_MANIFEST_IMAGE_TOO_LARGE, 1U, ZX_MANIFEST_NO_INDEX); + + /* And the same geometry with an image that DOES fit the tiny window, + which must be accepted. A rule proved able to reject has to be + proved able to accept as well, or it is indistinguishable from one + that rejects everything. */ + reset_manifest(); + set_region(&p1_regions[3], P1_TINY_CODE_BASE, P1_TINY_CODE_LIMIT, + ZX_AP_EL2_RW_GUEST_RW, ZX_XN_EXECUTABLE, ATTR_NORMAL); + partitions[1].zx_partition_region_count = 4U; + partitions[1].zx_partition_entry = P1_TINY_CODE_BASE; + partitions[1].zx_partition_image_end = IMAGE_START + 0x20U; + EXPECT(ZX_MANIFEST_SUCCESS, ZX_MANIFEST_NO_INDEX, ZX_MANIFEST_NO_INDEX); } static void test_time_partitioning(void) diff --git a/test/host/test_zx_partition.c b/test/host/test_zx_partition.c new file mode 100644 index 0000000..91fde6e --- /dev/null +++ b/test/host/test_zx_partition.c @@ -0,0 +1,495 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* test_zx_partition.c HOST TESTS */ +/* */ +/* DESCRIPTION */ +/* */ +/* The partition manager: where an image is decided to go, and what */ +/* state a partition ends up in. */ +/* */ +/* Two things here are worth more than the rest. */ +/* */ +/* The load record is asserted AFTER A FAILURE as well as after a */ +/* success. The whole reason the record is filled in before the */ +/* search that can fail is so that a failure leaves a description of */ +/* what was attempted; a test that only checked the success path would */ +/* let that ordering be reversed by anyone tidying the function, and */ +/* the cost would land on whoever next read a boot log full of zeros. */ +/* */ +/* The outcome classification is driven from SYNDROME VALUES rather */ +/* than from a caller's opinion, so each case here spells an HSR the */ +/* hardware would really produce. A yield and a stage-2 violation */ +/* must not be able to become each other, and a fault taken from Hyp */ +/* mode -- a ZoneX bug -- must not be counted as a partition */ +/* overstepping its boundary: that would let a hypervisor defect read */ +/* as the demonstrator working. */ +/* */ +/**************************************************************************/ + +#include "zx_partition.h" +#include "zx_console_capture.h" +#include "zx_test.h" + +/* Syndromes as the hardware presents them: HSR.EC is bits [31:26]. */ + +#define HSR_OF(ec) ((uint32_t)(ec) << 26U) + +#define WINDOW_BASE 0x00100000U +#define WINDOW_LIMIT 0x0011FFFFU /* 128 KB, inclusive */ +#define DATA_BASE 0x00200000U +#define DATA_LIMIT 0x0020FFFFU +#define TINY_BASE 0x00300000U +#define TINY_LIMIT 0x0030003FU /* one granule */ + +#define IMAGE_START 0x80000000U +#define IMAGE_END 0x80008000U /* 32 KB */ + +static ZX_REGION regions[4]; +static ZX_PARTITION declaration; +static ZX_PARTITION_CB cb; + +static void set_region(ZX_REGION *region_ptr, zx_addr_t base, zx_addr_t limit, + UCHAR xn) +{ + region_ptr->zx_region_base = base; + region_ptr->zx_region_limit = limit; + region_ptr->zx_region_ap = ZX_AP_EL2_RW_GUEST_RW; + region_ptr->zx_region_xn = xn; + region_ptr->zx_region_sh = ZX_SH_NON_SHAREABLE; + region_ptr->zx_region_attr_index = 0U; +} + +/* The known-good partition, rebuilt before every case. Region 0 is + deliberately NOT the code window: the loader has to SEARCH for the region + holding the entry, and a table whose first region is the answer would let + a loader that ignored the entry point pass every case here. */ +static void reset_declaration(void) +{ + set_region(®ions[0], DATA_BASE, DATA_LIMIT, ZX_XN_NEVER); + set_region(®ions[1], TINY_BASE, TINY_LIMIT, ZX_XN_EXECUTABLE); + set_region(®ions[2], WINDOW_BASE, WINDOW_LIMIT, ZX_XN_EXECUTABLE); + + declaration.zx_partition_id = 3U; + declaration.zx_partition_name = "guest A"; + declaration.zx_partition_image_start = IMAGE_START; + declaration.zx_partition_image_end = IMAGE_END; + declaration.zx_partition_entry = WINDOW_BASE; + declaration.zx_partition_regions = regions; + declaration.zx_partition_region_count = 3U; + declaration.zx_partition_window_ticks = 5U; + + zx_partition_reset(&cb, &declaration); +} + + +static void test_reset(void) +{ + reset_declaration(); + + ZX_CHECK_EQ(cb.zx_partition_state, ZX_PARTITION_DECLARED); + ZX_CHECK_EQ(cb.zx_partition_entries, 0U); + ZX_CHECK_EQ(cb.zx_partition_faults, 0U); + + /* Not zero. Zero is a valid region index, so a zeroed field would be + indistinguishable from "the window is this partition's region 0" -- + the same ambiguity ZX_MANIFEST_NO_INDEX exists to remove. */ + ZX_CHECK_EQ(cb.zx_partition_load.zx_load_region_index, + ZX_MANIFEST_NO_INDEX); + + /* A null control block must be survivable: this is called from a boot + path with no allocator and no way to report. */ + zx_partition_reset((ZX_PARTITION_CB *)0, &declaration); +} + + +static void test_prepare_locates_the_window(void) +{ + reset_declaration(); + + ZX_CHECK_EQ(zx_partition_prepare(&cb), ZX_MANIFEST_SUCCESS); + ZX_CHECK_EQ(cb.zx_partition_state, ZX_PARTITION_PREPARED); + + /* Region 2, not region 1. Region 1 is executable and comes first, and a + loader that stopped at the first executable region would load the + image into a 64-byte window and report success. */ + ZX_CHECK_EQ(cb.zx_partition_load.zx_load_region_index, 2U); + ZX_CHECK_EQ(cb.zx_partition_load.zx_load_window_base, + (zx_addr_t)WINDOW_BASE); + ZX_CHECK_EQ(cb.zx_partition_load.zx_load_window_limit, + (zx_addr_t)WINDOW_LIMIT); + ZX_CHECK_EQ(cb.zx_partition_load.zx_load_image_source, + (zx_addr_t)IMAGE_START); + ZX_CHECK_EQ(cb.zx_partition_load.zx_load_image_length, + (zx_size_t)(IMAGE_END - IMAGE_START)); + ZX_CHECK_EQ(cb.zx_partition_load.zx_load_entry, (zx_addr_t)WINDOW_BASE); + + /* An entry part-way into the window is normal -- a kernel's reset + symbol is not at its window base unless something puts it there -- so + the search must accept it and must still name the window's BASE as + where the image goes. */ + reset_declaration(); + declaration.zx_partition_entry = WINDOW_BASE + 0x40U; + ZX_CHECK_EQ(zx_partition_prepare(&cb), ZX_MANIFEST_SUCCESS); + ZX_CHECK_EQ(cb.zx_partition_load.zx_load_window_base, + (zx_addr_t)WINDOW_BASE); + + /* The last byte of the window is inside it. An inclusive limit + compared with the wrong operator rejects exactly this. */ + reset_declaration(); + declaration.zx_partition_entry = WINDOW_LIMIT; + ZX_CHECK_EQ(zx_partition_prepare(&cb), ZX_MANIFEST_SUCCESS); + ZX_CHECK_EQ(cb.zx_partition_load.zx_load_region_index, 2U); +} + + +static void test_prepare_refuses(void) +{ + /* Null everything. Each of the three is a separate pointer a caller + could get wrong, and none of them may be dereferenced. */ + ZX_CHECK_EQ(zx_partition_prepare((ZX_PARTITION_CB *)0), + ZX_MANIFEST_NULL_POINTER); + + reset_declaration(); + zx_partition_reset(&cb, (const ZX_PARTITION *)0); + ZX_CHECK_EQ(zx_partition_prepare(&cb), ZX_MANIFEST_NULL_POINTER); + + reset_declaration(); + declaration.zx_partition_regions = (const ZX_REGION *)0; + ZX_CHECK_EQ(zx_partition_prepare(&cb), ZX_MANIFEST_NULL_POINTER); + + /* A reversed image range. */ + reset_declaration(); + declaration.zx_partition_image_end = IMAGE_START - 1U; + ZX_CHECK_EQ(zx_partition_prepare(&cb), ZX_MANIFEST_IMAGE_RANGE_INVALID); + ZX_CHECK_EQ(cb.zx_partition_state, ZX_PARTITION_DECLARED); + + /* An EMPTY image: what a linker input pattern that matched nothing + produces. The loader would copy zero bytes and ERET into whatever + the window held, and a NOLOAD window holds uninitialised memory. */ + reset_declaration(); + declaration.zx_partition_image_end = IMAGE_START; + ZX_CHECK_EQ(zx_partition_prepare(&cb), ZX_MANIFEST_IMAGE_EMPTY); + ZX_CHECK_EQ(cb.zx_partition_state, ZX_PARTITION_DECLARED); + + /* An entry in a region the partition owns, but not an executable one. */ + reset_declaration(); + declaration.zx_partition_entry = DATA_BASE; + ZX_CHECK_EQ(zx_partition_prepare(&cb), + ZX_MANIFEST_ENTRY_NOT_EXECUTABLE); + + /* BELOW every executable region, and ABOVE every executable region. + Both sides of the containment test need a case of their own, or one + of the two comparisons is never seen to reject anything. */ + reset_declaration(); + declaration.zx_partition_entry = TINY_BASE - 0x1000U; + ZX_CHECK_EQ(zx_partition_prepare(&cb), + ZX_MANIFEST_ENTRY_NOT_EXECUTABLE); + + reset_declaration(); + declaration.zx_partition_entry = WINDOW_LIMIT + 1U; + ZX_CHECK_EQ(zx_partition_prepare(&cb), + ZX_MANIFEST_ENTRY_NOT_EXECUTABLE); + + /* THE RECORD AFTER A FAILURE. This is the assertion the ordering of + zx_partition_prepare exists for: the fields derivable from the + manifest are there, describing what was attempted, and only the + window -- which is what could not be worked out -- is absent. */ + ZX_CHECK_EQ(cb.zx_partition_load.zx_load_image_source, + (zx_addr_t)IMAGE_START); + ZX_CHECK_EQ(cb.zx_partition_load.zx_load_image_length, + (zx_size_t)(IMAGE_END - IMAGE_START)); + ZX_CHECK_EQ(cb.zx_partition_load.zx_load_entry, + (zx_addr_t)(WINDOW_LIMIT + 1U)); + + /* An image too large for the window the ENTRY is in, while fitting the + other executable region -- which here is the smaller one, so the + geometry is the reverse of the manifest validator's case and catches + a loader that measured the wrong region. */ + reset_declaration(); + declaration.zx_partition_entry = TINY_BASE; + declaration.zx_partition_image_end = IMAGE_START + 0x100U; + ZX_CHECK_EQ(zx_partition_prepare(&cb), ZX_MANIFEST_IMAGE_TOO_LARGE); + ZX_CHECK_EQ(cb.zx_partition_state, ZX_PARTITION_DECLARED); + + /* And the window named in the record is the one that was too small, + which is what a reader needs in order to fix it. */ + ZX_CHECK_EQ(cb.zx_partition_load.zx_load_window_base, + (zx_addr_t)TINY_BASE); +} + + +static void test_image_exactly_fills_the_window(void) +{ + /* The boundary the inclusive limit makes easy to get wrong: a window of + 64 bytes holds an image of exactly 64 bytes and not one of 65. Both + cases, because an off-by-one here is a copy that overruns the window + by a byte -- into the next partition, which is the failure the whole + component exists to prevent. */ + + reset_declaration(); + declaration.zx_partition_entry = TINY_BASE; + declaration.zx_partition_image_end = IMAGE_START + ZX_MPU_GRANULE; + ZX_CHECK_EQ(zx_partition_prepare(&cb), ZX_MANIFEST_SUCCESS); + + reset_declaration(); + declaration.zx_partition_entry = TINY_BASE; + declaration.zx_partition_image_end = IMAGE_START + ZX_MPU_GRANULE + 1U; + ZX_CHECK_EQ(zx_partition_prepare(&cb), ZX_MANIFEST_IMAGE_TOO_LARGE); +} + + +static void test_lifecycle(void) +{ + zx_fault_record_t record; + + /* Nothing may be entered before it is loaded. */ + reset_declaration(); + ZX_CHECK_EQ(zx_partition_enter(&cb), 0U); + + ZX_CHECK_EQ(zx_partition_prepare(&cb), ZX_MANIFEST_SUCCESS); + ZX_CHECK_EQ(zx_partition_enter(&cb), 0U); + + zx_partition_loaded(&cb); + ZX_CHECK_EQ(cb.zx_partition_state, ZX_PARTITION_LOADED); + + ZX_CHECK_EQ(zx_partition_enter(&cb), 1U); + ZX_CHECK_EQ(cb.zx_partition_state, ZX_PARTITION_RUNNING); + ZX_CHECK_EQ(cb.zx_partition_entries, 1U); + + /* A yield: HSR.EC = 0x12, an HVC. */ + record.zx_fault_captures = 1U; + record.zx_fault_vector = ZX_VECTOR_HYP_TRAP; + record.zx_fault_hsr = HSR_OF(ZX_EC_HVC) | 1U; + record.zx_fault_hpfar = 0U; + record.zx_fault_hdfar = 0U; + record.zx_fault_hifar = 0U; + record.zx_fault_elr = WINDOW_BASE + 0x80U; + record.zx_fault_spsr = 0U; + + zx_partition_returned(&cb, &record); + ZX_CHECK_EQ(cb.zx_partition_state, ZX_PARTITION_YIELDED); + ZX_CHECK_EQ(cb.zx_partition_faults, 0U); + ZX_CHECK_EQ(cb.zx_partition_last_guest_pc, WINDOW_BASE + 0x80U); + + /* A yielded partition may be entered again; that is what makes a time + window a window rather than a single shot. */ + ZX_CHECK_EQ(zx_partition_enter(&cb), 1U); + ZX_CHECK_EQ(cb.zx_partition_entries, 2U); + + /* A stage-2 violation. */ + record.zx_fault_hsr = HSR_OF(ZX_EC_DABT_ROUTED); + record.zx_fault_hdfar = DATA_BASE; + zx_partition_returned(&cb, &record); + ZX_CHECK_EQ(cb.zx_partition_state, ZX_PARTITION_FAULTED); + ZX_CHECK_EQ(cb.zx_partition_faults, 1U); + ZX_CHECK_EQ(cb.zx_partition_last_address, DATA_BASE); + + /* And a faulted partition is NOT re-entered. Whatever it broke to get + there is still broken. */ + ZX_CHECK_EQ(zx_partition_enter(&cb), 0U); + ZX_CHECK_EQ(cb.zx_partition_entries, 2U); + + /* zx_partition_loaded refuses to advance a partition that is not + PREPARED, so a second call cannot move a faulted one back. */ + zx_partition_loaded(&cb); + ZX_CHECK_EQ(cb.zx_partition_state, ZX_PARTITION_FAULTED); +} + + +static void test_outcomes_are_not_conflated(void) +{ + zx_fault_record_t record; + + record.zx_fault_captures = 1U; + record.zx_fault_vector = ZX_VECTOR_DABT_HYP; + record.zx_fault_hpfar = 0U; + record.zx_fault_hdfar = 0U; + record.zx_fault_hifar = 0U; + record.zx_fault_elr = 0U; + record.zx_fault_spsr = 0U; + + /* A data abort taken FROM Hyp mode is a ZoneX bug. It stops the + partition, and it must NOT be counted as the partition overstepping: + a run that reported a hypervisor defect as a boundary violation would + pass while proving nothing. */ + reset_declaration(); + ZX_CHECK_EQ(zx_partition_prepare(&cb), ZX_MANIFEST_SUCCESS); + zx_partition_loaded(&cb); + ZX_CHECK_EQ(zx_partition_enter(&cb), 1U); + + record.zx_fault_hsr = HSR_OF(ZX_EC_DABT_FROM_HYP); + zx_partition_returned(&cb, &record); + ZX_CHECK_EQ(cb.zx_partition_state, ZX_PARTITION_STOPPED); + ZX_CHECK_EQ(cb.zx_partition_faults, 0U); + + /* Something nobody expected -- here a trapped WFI. Also STOPPED, also + not a fault. */ + reset_declaration(); + ZX_CHECK_EQ(zx_partition_prepare(&cb), ZX_MANIFEST_SUCCESS); + zx_partition_loaded(&cb); + ZX_CHECK_EQ(zx_partition_enter(&cb), 1U); + + record.zx_fault_hsr = HSR_OF(ZX_EC_WFI_WFE); + zx_partition_returned(&cb, &record); + ZX_CHECK_EQ(cb.zx_partition_state, ZX_PARTITION_STOPPED); + ZX_CHECK_EQ(cb.zx_partition_faults, 0U); + + /* A prefetch abort routed to Hyp IS a guest violation: a partition that + tried to FETCH outside its window overstepped exactly as much as one + that tried to read. */ + reset_declaration(); + ZX_CHECK_EQ(zx_partition_prepare(&cb), ZX_MANIFEST_SUCCESS); + zx_partition_loaded(&cb); + ZX_CHECK_EQ(zx_partition_enter(&cb), 1U); + + record.zx_fault_hsr = HSR_OF(ZX_EC_PABT_ROUTED); + zx_partition_returned(&cb, &record); + ZX_CHECK_EQ(cb.zx_partition_state, ZX_PARTITION_FAULTED); + ZX_CHECK_EQ(cb.zx_partition_faults, 1U); + + /* A return with NO syndrome. Not a yield: nothing is known about why + control came back, and calling it clean would invent the one fact the + caller failed to supply. */ + reset_declaration(); + ZX_CHECK_EQ(zx_partition_prepare(&cb), ZX_MANIFEST_SUCCESS); + zx_partition_loaded(&cb); + ZX_CHECK_EQ(zx_partition_enter(&cb), 1U); + zx_partition_returned(&cb, (const zx_fault_record_t *)0); + ZX_CHECK_EQ(cb.zx_partition_state, ZX_PARTITION_STOPPED); + + /* Null control blocks, on both of the run-time entry points. */ + ZX_CHECK_EQ(zx_partition_enter((ZX_PARTITION_CB *)0), 0U); + zx_partition_returned((ZX_PARTITION_CB *)0, &record); + zx_partition_loaded((ZX_PARTITION_CB *)0); +} + + +static void test_state_names(void) +{ + /* Every state has a name, and the name says what the state MEANS rather + than repeating its spelling: a log line reading "FAULTED" is only + useful to somebody who already knows the difference between that and + STOPPED. */ + ZX_CHECK(zx_capture_contains("") != 0U); + + ZX_CHECK_EQ((int)(zx_partition_state_name(ZX_PARTITION_DECLARED)[0]), + (int)'D'); + ZX_CHECK_EQ((int)(zx_partition_state_name(ZX_PARTITION_PREPARED)[0]), + (int)'P'); + ZX_CHECK_EQ((int)(zx_partition_state_name(ZX_PARTITION_LOADED)[0]), + (int)'L'); + ZX_CHECK_EQ((int)(zx_partition_state_name(ZX_PARTITION_RUNNING)[0]), + (int)'R'); + ZX_CHECK_EQ((int)(zx_partition_state_name(ZX_PARTITION_YIELDED)[0]), + (int)'Y'); + ZX_CHECK_EQ((int)(zx_partition_state_name(ZX_PARTITION_FAULTED)[0]), + (int)'F'); + ZX_CHECK_EQ((int)(zx_partition_state_name(ZX_PARTITION_STOPPED)[0]), + (int)'S'); + + /* A state nobody defined. Named as a ZoneX bug rather than reported as + one of the legal ones, because a report that silently rounded an + impossible value to a plausible one is worse than no report. */ + zx_capture_reset(); + ZX_CHECK(zx_capture_contains("") != 0U); + { + const CHAR *name = zx_partition_state_name(99U); + + ZX_CHECK_EQ((int)(name[0]), (int)'a'); + } +} + + +static void test_report(void) +{ + /* A report with no control block at all says so. It is called from a + failure path, and a null pointer there must not become a second + failure on top of the first. */ + zx_capture_reset(); + zx_partition_report((const ZX_PARTITION_CB *)0); + ZX_CHECK(zx_capture_contains("no partition control block") != 0U); + + /* The full report, after a load and a fault. What a reader needs from + it is the window, the entry, the guest PC and the two counters -- + every one of them is asserted, because this text is the only thing + that turns a guest PC into a line in a map file. */ + reset_declaration(); + ZX_CHECK_EQ(zx_partition_prepare(&cb), ZX_MANIFEST_SUCCESS); + zx_partition_loaded(&cb); + ZX_CHECK_EQ(zx_partition_enter(&cb), 1U); + + { + zx_fault_record_t record; + + record.zx_fault_captures = 1U; + record.zx_fault_vector = ZX_VECTOR_HYP_TRAP; + record.zx_fault_hsr = HSR_OF(ZX_EC_DABT_ROUTED); + record.zx_fault_hpfar = 0U; + record.zx_fault_hdfar = DATA_BASE; + record.zx_fault_hifar = 0U; + record.zx_fault_elr = WINDOW_BASE + 0x1234U; + record.zx_fault_spsr = 0U; + + zx_partition_returned(&cb, &record); + } + + zx_capture_reset(); + zx_partition_report(&cb); + + ZX_CHECK(zx_capture_contains("guest A") != 0U); + ZX_CHECK(zx_capture_contains("FAULTED") != 0U); + ZX_CHECK(zx_capture_contains("0x00100000") != 0U); /* window base */ + ZX_CHECK(zx_capture_contains("0x0011ffff") != 0U); /* window limit */ + ZX_CHECK(zx_capture_contains("0x00200000") != 0U); /* HDFAR */ + ZX_CHECK(zx_capture_contains("0x00101234") != 0U); /* the guest PC */ + ZX_CHECK(zx_capture_contains(".map") != 0U); + ZX_CHECK_EQ(zx_capture_overflowed(), 0U); + + /* An unnamed partition. A name is a convenience; the id is what the + manifest and every region index are keyed on, so the report has to + work without one. */ + reset_declaration(); + declaration.zx_partition_name = (const CHAR *)0; + zx_capture_reset(); + zx_partition_report(&cb); + ZX_CHECK(zx_capture_contains("--- partition 3 ---") != 0U); + + /* A control block with no declaration behind it. Reachable only + through a caller error, and it says which caller error it is. */ + zx_partition_reset(&cb, (const ZX_PARTITION *)0); + zx_capture_reset(); + zx_partition_report(&cb); + ZX_CHECK(zx_capture_contains("NO declaration") != 0U); + + /* And a partition that has never been entered prints no syndrome at + all, rather than printing a zeroed one as though it meant something. */ + ZX_CHECK(zx_capture_contains("last HSR") == 0U); +} + +ZX_TEST_MAIN("test_zx_partition", + test_reset(); + test_prepare_locates_the_window(); + test_prepare_refuses(); + test_image_exactly_fills_the_window(); + test_lifecycle(); + test_outcomes_are_not_conflated(); + test_state_names(); + test_report(); +) diff --git a/test/s32z280/run.sh b/test/s32z280/run.sh index de62e6b..77d1c97 100755 --- a/test/s32z280/run.sh +++ b/test/s32z280/run.sh @@ -49,11 +49,24 @@ readonly BUILD="${ROOT}/build/s32z280" command="${1:-build}" configure() { + local args=() + + # The ThreadX checkout the GUEST images are built from. ZoneX links no + # ThreadX -- at EL2 it is a standalone bare-metal program -- but the + # examples build ThreadX guests, so the dependency is real and it is taken + # from the environment rather than guessed at. Leave ZX_THREADX unset and + # the guest images are simply not built, with a message from CMake saying + # so; the stage-2 probe images still build. + if [ -n "${ZX_THREADX:-}" ]; then + args+=("-DZX_THREADX_SOURCE_DIR=${ZX_THREADX}") + fi + cmake -S "${ROOT}" -B "${BUILD}" -G Ninja \ -DCMAKE_TOOLCHAIN_FILE="${ROOT}/cmake/cortex_r52.cmake" \ -DCMAKE_BUILD_TYPE=Debug \ -DZX_BUILD_FVP_EXAMPLE=OFF \ - -DZX_BUILD_S32Z280_EXAMPLE=ON + -DZX_BUILD_S32Z280_EXAMPLE=ON \ + "${args[@]}" } images() { From 5e59329fcb100e56b2faecf258a69bade6c44455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 2 Sep 2026 17:47:54 -0400 Subject: [PATCH 12/40] Gave a partition a clock, and had its kernel preempted under stage 2 The cooperative guest proved a real ThreadX kernel BOOTS and SCHEDULES inside a stage-2 window. A cooperative kernel is a kernel with the interesting half removed: nothing takes the core from a thread, so nothing can go wrong in the place a partitioning hypervisor most needs nothing to go wrong. This adds the interrupt. WHO OWNS THE GIC, which is the decision the rest follows from. ZoneX owns every byte of memory-mapped GIC state and a partition gets its own CPU interface, which on this part is system registers. The argument is not "shared things belong to EL2", which is true and weak. It is that the redistributor's SGI frame holds the enable bits for all thirty-two SGIs and PPIs of this core -- INCLUDING PPI 26, the hypervisor's own timer, which is what will end a partition's window. A partition able to write that frame could clear that bit and never be descheduled again, and nothing about that is a memory-isolation failure, so no region set would show it. Doing it this way costs the partition nothing: its manifest is unchanged and it is granted no device region of any kind. The alternative -- mapping the redistributor into the partition so it could enable its own PPI -- would have cost two extra EL2 regions on silicon, because PMSAv8-R has no region priority and the window has to be split three ways, AND opened the hole above. Recorded as D24. HCR.IMO STAYS CLEAR, so the timer PPI is a physical interrupt taken straight to EL1: no injection, no List Register, no EL2 work per tick. The cost is written down because it is the next phase's whole problem -- with IMO clear the hypervisor cannot take an interrupt of its own while a partition runs either, so a tick that ENDS a window needs IMO set, and with IMO set every guest interrupt has to be injected. That is a change to the hypervisor and to no guest, which is why this shape is worth having first. ONE GUEST IMAGE, and whether it has a clock is a word in the mailbox rather than a build option. The run that demonstrates isolation and the run that demonstrates preemption are the same bytes, so they cannot drift into two binaries where only one is the one being quoted. The guest is still built from unmodified port sources; the only change is that it now uses the port's own TX_R52_USE_THREADX_IRQ, whose EL1 IRQ vector body it reaches with one branch out of its own vector table -- so the interrupt entry sequence is the port's, unmodified, and VBAR stays the guest's. WHAT IS PROVED, on both targets: a thread that never yields is DISPLACED, two equal-priority threads that never yield are TIME SLICED, the partition receives INTID 27 and no other, and its clock does not advance while it is not running. The last is CNTVOFF, and it is exact: on the S32Z280 the offset was credited with 1,969,637 counts against an interval of 1,969,637. TWO MEASUREMENT MISTAKES ARE IN THE HISTORY BECAUSE BOTH WERE MADE. The gap the freeze is measured across was first taken over a block of console output, on the grounds that a polled UART is millions of cycles. It is -- on silicon. On a model whose console is semihosting it costs no simulated time at all, so the counter advanced by ZERO and the check was green, and so was the build that deliberately breaks the freeze. The gap is now a deliberate dwell, and the image asserts that a real interval happened before it asserts anything about it. Then the check was built on counter reads taken from outside the mechanism, and reported the partition's clock advancing by 64 counts on a run where the freeze was perfect: on the S32Z280 a CNTPCT read crosses into an 8 MHz clock domain, so the handful of reads between the CNTVOFF write and the check cost 11.6 microseconds of real time. It was measuring the cost of measuring. It now compares only instants the mechanism itself took, against the offset read back out of the register, so every term is a hardware read and a CNTVOFF write that had not landed still fails it. TWO NEGATIVE BUILDS, breaking different halves and each failing exactly one check on both targets. One leaves the partition's PPI disabled -- which is the isolation claim showing up as a test result, since the guest cannot enable it itself -- and one does not give the partition back the time it spent descheduled. The second keeps the bookkeeping and drops only the credit, deliberately: skipping the resume outright left the recorded instants stale and its observable check then PASSED, comparing a real interval against the time since boot. The loader is now shared rather than copied. Two images launch the same guest and they differ in what the hypervisor configures and in what they claim, not in how a guest is loaded -- and the mailbox layout, the header check and the seal are a contract with the guest image, which cannot have two implementations. The mailbox grew to two granules for what a preemptive guest has to say, which moves the entry branch to +0x80 and costs nothing at stage 2. FOUR THINGS A REVIEW OF THIS CHANGE FOUND, all of them real. GICR_CTLR.RWP and GICD_CTLR.RWP are now polled. A DSB says a write has left the core and says nothing about whether the GIC has finished acting on it, and bring-up disables all thirty-two of this core's SGIs and PPIs and then enables one of them in the same frame a few instructions later. An in-flight disable retiring after that enable would undo it, leaving a partition arming a timer whose interrupt was disabled -- a guest that never ticks, on some boots. The distributor's group enables are now CLEARED before ARE is set and restored afterwards. Changing ARE while the GIC is enabled is UNPREDICTABLE, so setting ARE first is correct only if the enables were already clear -- a claim about reset state, which this file argues against making everywhere else and then made here anyway. The guest blob's minimum-size ASSERT was still 0x50 after the header moved to 0x8C, so a blob of 0x51 to 0x8F bytes linked cleanly and the magic check read past the end of the section -- the out-of-bounds read the assert exists to prevent. And the build that probes an address it IS granted was aimed at mailbox offset zero, which is ZX_GD_PROGRESS: the probe's sentinel was ORed into the progress word and the seal then certified it. Harmless noise until this change gave two of those bit positions meaning, at which point a cooperative build with no clock reported PREEMPTED and NO_CLOCK both. The probe now has a word of its own that nothing reads. Green on the Armv8-R AEM FVP under both toolchains, and logged ALL CHECKS PASSED on an S32Z280-594EVB, where two identical quiet excursions differed by 90 cycles out of 1,952,856. Assisted-by: Claude Code (Opus 5) --- .github/workflows/zx_fvp.yml | 33 +- docs/armv8r-el2-reference.md | 175 ++- docs/decisions.md | 97 +- examples/common/zx_guest_abi.h | 164 ++- examples/common/zx_guest_launch.c | 367 +++++ examples/common/zx_guest_launch.h | 169 +++ examples/common/zx_one_partition.c | 361 +---- examples/common/zx_preemptive_partition.c | 1192 +++++++++++++++++ examples/common/zx_probe.h | 32 + examples/fvp_baser_aemv8r/CMakeLists.txt | 107 ++ .../fvp_baser_aemv8r/guest_a/CMakeLists.txt | 44 +- .../guest_a/link_partition_a.lds | 20 +- examples/fvp_baser_aemv8r/zx_board.c | 64 +- .../fvp_baser_aemv8r/zx_one_partition.lds | 18 +- examples/fvp_baser_aemv8r/zx_platform.h | 23 + examples/guest_common/zx_guest_app.c | 425 +++++- examples/guest_common/zx_guest_bsp.c | 396 +++++- examples/guest_common/zx_guest_bsp.h | 37 + examples/guest_common/zx_guest_head.S | 92 +- examples/s32z280_evb/CMakeLists.txt | 73 + examples/s32z280_evb/guest_a/CMakeLists.txt | 44 +- .../s32z280_evb/guest_a/link_partition_a.lds | 17 +- examples/s32z280_evb/zx_board.c | 50 + examples/s32z280_evb/zx_one_partition.lds | 18 +- examples/s32z280_evb/zx_platform.h | 16 + platform/cortex_r52/inc/zx_port.h | 209 +++ platform/cortex_r52/src/zx_gic.c | 522 +++++++- platform/cortex_r52/src/zx_timer.c | 467 ++++++- 28 files changed, 4805 insertions(+), 427 deletions(-) create mode 100644 examples/common/zx_guest_launch.c create mode 100644 examples/common/zx_guest_launch.h create mode 100644 examples/common/zx_preemptive_partition.c diff --git a/.github/workflows/zx_fvp.yml b/.github/workflows/zx_fvp.yml index fa86a10..9a8e96a 100644 --- a/.github/workflows/zx_fvp.yml +++ b/.github/workflows/zx_fvp.yml @@ -43,6 +43,12 @@ name: zx_fvp # it genuinely owns -- which must FAIL, and whose failing is what makes the # other two evidence. # +# The preemptive images: the SAME guest, granted the virtual timer's PPI by a +# hypervisor that owns every byte of the GIC, being preempted by its own kernel +# under stage 2 -- plus two builds that must FAIL, one with the partition's +# interrupt left disabled in a frame the guest cannot reach, and one where the +# hypervisor does not give the partition back the time it spent descheduled. +# # The builds that must fail are registered with the runner's "--expect fail", # because a check that has never been seen to fail is not evidence that it can. # (CTest's WILL_FAIL is deliberately NOT used: it inverts the exit status and @@ -311,18 +317,37 @@ jobs: # it would mean half this suite quietly stopped running. The stage-2 # probes would still be green, and nothing would say that a real # kernel had not been booted at all. - if ! echo "$images" | grep -q '^zx_one_partition\.elf$'; then + if ! echo "$images" | grep -qE '^zx_(one_partition|preemptive)\.elf$'; then echo "::error::The ThreadX guest images were not built. ZX_THREADX_SOURCE_DIR did not reach CMake, so no real kernel was booted and half this suite did not run." { echo "### ZoneX FVP execution: NO GUEST IMAGES" echo "" - echo "The stage-2 probe images built, but the one-partition images -- the ones that" - echo "boot a real ThreadX kernel inside a partition -- were skipped, which happens" - echo "when \`ZX_THREADX_SOURCE_DIR\` is empty. Check the ThreadX checkout step." + echo "The stage-2 probe images built, but every image that boots a REAL ThreadX" + echo "kernel inside a partition was skipped -- which happens when" + echo "\`ZX_THREADX_SOURCE_DIR\` is empty. Check the ThreadX checkout step." } >>"$GITHUB_STEP_SUMMARY" exit 1 fi + # BOTH guest families, named separately. The check above catches a + # missing ThreadX checkout, which removes them together; this one + # catches an image being dropped from the CMake graph on its own, + # which would leave the suite green while a whole claim stopped being + # tested. + for required in zx_one_partition.elf zx_preemptive.elf; do + if ! echo "$images" | grep -qx "$required"; then + echo "::error::$required is not in the CMake graph, so the claim it carries is no longer being tested." + { + echo "### ZoneX FVP execution: A GUEST IMAGE IS MISSING" + echo "" + echo "\`$required\` was not found among the targets. The rest of the suite would" + echo "still be green, and nothing in it would say that the claim this image" + echo "carries had stopped being tested." + } >>"$GITHUB_STEP_SUMMARY" + exit 1 + fi + done + echo "Building $(echo "$images" | wc -l) images" # shellcheck disable=SC2086 cmake --build build/fvp --target $images diff --git a/docs/armv8r-el2-reference.md b/docs/armv8r-el2-reference.md index 434aadd..2855936 100644 --- a/docs/armv8r-el2-reference.md +++ b/docs/armv8r-el2-reference.md @@ -302,6 +302,105 @@ itself — a ZoneX bug — and must be reported as one rather than folded in wit handling simple — but that is now a scope decision taken knowingly rather than a limitation inherited by assumption. +### A partition's timer PPI is delivered to EL1 directly — measured on both targets + +With `HCR.IMO` **clear**, a physical interrupt taken while EL1 is running goes +straight to EL1. No injection, no List Register, no EL2 work per tick. A guest +enables only its own CPU interface and acknowledges through `ICC_IAR1`. Verified +end to end on the Armv8-R AEM FVP and on the S32Z280-594EVB: `ICC_IAR1` returns +INTID **27**, and no other INTID reaches the partition. + +The corollary is the one worth writing down, because it is the next phase's +whole problem: with `IMO` clear the hypervisor cannot take an interrupt of its +own while a partition runs **either**, including its own timer on PPI 26. See +`docs/decisions.md` D24. + +### The GIC registers ZoneX writes, and where they are + +Offsets are architectural; only the bases differ between the two targets. + +| Frame | Register | Offset | What it is for | +|---|---|---|---| +| GICD | `GICD_CTLR` | `0x0000` | `ARE` = bit 4, `EnableGrp1` = bit 1, `EnableGrp0` = bit 0. With a single security state `DS` reads 1 and Group 1 **is** bit 1. | +| GICR, RD frame | `GICR_WAKER` | `0x0014` | `ProcessorSleep` = bit 1, `ChildrenAsleep` = bit 2. Clear the first, then **poll** the second; nothing is delivered to the core until it clears. | +| GICR, SGI frame | `GICR_IGROUPR0` | `0x0080` | one bit per INTID 0–31; set = Group 1 (IRQ), clear = Group 0 (FIQ) | +| GICR, SGI frame | `GICR_ISENABLER0` | `0x0100` | write-one-to-**set** | +| GICR, SGI frame | `GICR_ICENABLER0` | `0x0180` | write-one-to-**clear**; all ones disables this core's thirty-two in one write | +| GICR, SGI frame | `GICR_IPRIORITYR` | `0x0400` | one **byte** per INTID, four to a word | +| GICR, SGI frame | `GICR_ICFGR1` | `0x0C04` | two bits per INTID, `00` = **level**. The generic timer asserts a level; configured as edge it is taken once and never again. | + +The redistributor is **two consecutive 64 KB frames per core** — RD then SGI. +Swapping them writes plausible values into the wrong registers and reads back +zero: a GIC that was configured and does nothing, with no fault to point at it. + +| Target | GICD | GICR RD | GICR SGI | +|---|---|---|---| +| Armv8-R AEM FVP | `0xAF000000` | `0xAF100000` | `0xAF110000` | +| S32Z280-594EVB | `0x47800000` | `0x47900000` | `0x47910000` | + +**Both targets implement FIVE priority bits.** Only the top five of a priority +byte survive; the rest read back as zero, so two priorities differing only there +are the *same* priority — and equal priorities do not preempt. Discovered by +writing all ones to one INTID's byte and reading back what stuck, then putting +the byte back. + +### The CPU interface a guest uses, in AArch32 + +Every one of these is a system register, which is why a partition needs no GIC +mapping at all. All are UNDEFINED until `ICC_HSRE.SRE` is set — see below. + +| Register | Encoding | Note | +|---|---|---| +| `ICC_SRE` | `p15, 0, c12, c12, 5` | EL1 access to it is gated by `ICC_HSRE.Enable` | +| `ICC_PMR` | `p15, 0, c4, c6, 0` | **`c4`, not `c12`** — the one encoding in this group that is not where you would look for it | +| `ICC_BPR1` | `p15, 0, c12, c12, 3` | | +| `ICC_IGRPEN1` | `p15, 0, c12, c12, 7` | `ICC_IGRPEN0` is opc2 6, beside it | +| `ICC_IAR1` | `p15, 0, c12, c12, 0` | read; mask to bits [23:0] | +| `ICC_EOIR1` | `p15, 0, c12, c12, 1` | write | + +`ICC_IAR1` returning **1023** means nothing was pending. It must **not** be +given an end-of-interrupt: the running priority was never raised, so dropping it +corrupts the GIC's priority stack rather than merely being redundant. + +### The generic-timer registers, AArch32 + +| Register | Encoding | Level | +|---|---|---| +| `CNTFRQ` | `p15, 0, c14, c0, 0` | readable anywhere, writable only at the highest implemented level | +| `CNTPCT` | `MRRC p15, 0, …, c14` | 64-bit | +| `CNTVCT` | `MRRC p15, 1, …, c14` | 64-bit; `CNTPCT − CNTVOFF` | +| `CNTP_TVAL` / `CNTP_CTL` | `p15, 0, c14, c2, 0` / `…, 1` | physical; **a partition cannot reach these**, `CNTHCTL.PL1PCTEN`/`PL1PCEN` stay clear | +| `CNTV_TVAL` / `CNTV_CTL` | `p15, 0, c14, c3, 0` / `…, 1` | virtual; EL1 access is **not** gated by anything | +| `CNTVOFF` | `MRRC`/`MCRR p15, 4, …, c14` | 64-bit, **EL2 only** | + +`CNTV_CTL`: `ENABLE` = bit 0, `IMASK` = bit 1, `ISTATUS` = bit 2. Stopping a +partition's timer clears **both** ENABLE and IMASK — masking alone leaves the +comparator running with `ISTATUS` set, so a guest re-entered later finds a timer +it never armed already expired. + +`TVAL` rather than `CVAL` for the re-arm: it is a 32-bit down-count loaded +relative to now, so re-arming inside the interrupt handler is one register write +with no 64-bit arithmetic and no counter read. Writing it is also what deasserts +the level. + +### ⚠ Reading `CNTPCT` costs real time on the S32Z280 + +**93 counts — about 11.6 µs — for the handful of counter reads between a +`CNTVOFF` write and a check of it.** Measured on the S32Z280-594EVB; zero on the +Armv8-R AEM FVP. + +The counter runs at 8 MHz, derived from a 40 MHz crystal through a divider of +five, so a `CNTPCT` read crosses into an 8 MHz clock domain and costs a few +counter periods to synchronise. That is not noise to round away: a check of the +`CNTVOFF` freeze that re-read the counter from outside was measuring **the cost +of measuring the freeze**, and reported the partition's clock advancing by 64 +counts on a run where the mechanism was exactly right. + +The fix is to compare only numbers the mechanism itself took — the counter at +suspend and at resume — against the offset read back out of the register. Every +term is then a hardware read, the equality is exact, and a `CNTVOFF` write that +had not landed still fails it. + --- ## The two open questions, both now CLOSED on both targets @@ -746,12 +845,76 @@ either part reports the frequency: | Armv8-R AEM FVP | `0x00000000` | `0x05F5E100` (100 MHz) | `CNTFID0` read back from the counter control frame | | S32Z280-594EVB | `0x00000000` | `0x007A1200` (8 MHz) | measured at 8.0227 MHz against host wall-clock over 32 s; `CFG_CNTDV` = 4 so the divider is 5; FXOSC 40 MHz, itself confirmed from the boot ROM's LINFlexD baud divisors. 40 / 5 = 8 | -⚠ **Programming `CNTFRQ` does not start the counter.** On the FVP the system -counter is left stopped (`bp.refcounter.non_arch_start_at_default=0`, -documented as "firmware is expected to enable the timer at boot time"), so a -guest that *waited* on this timer would still wait forever. The cooperative -guest deliberately does not. Starting the counter belongs with interrupt -delivery. +⚠ **Programming `CNTFRQ` does not start the counter**, and the two are so easily +confused that they are separate hooks in the code. + +| Target | Counter at reset | How it is started | +|---|---|---| +| Armv8-R AEM FVP | **stopped** (`bp.refcounter.non_arch_start_at_default=0`, documented as "firmware is expected to enable the timer at boot time") | `CNTCR.EN` at `0xAA430000`, the counter control frame. In the Device-nGnRE band of the background map, so it needs no EL2 region on this model. | +| S32Z280-594EVB | **running** | nothing to do. `CNTCLKEN` comes from a cluster clock through `RTU.GPR CFG_CNTDV`, whose reset divider is five. There is no control frame for a hypervisor to write. | + +It is EL2's job on both, for the same reason the GIC is: the system counter is +one per system, and a partition able to start or stop it would decide how fast +time ran for every other partition. + +**Whether it worked is a third question**, asked separately and answered the +same way on both boards: read `CNTPCT` twice, bounded, and see whether it moved. +Without it, a guest that arms a timer against a stopped counter blocks for ever +and the run ends in a harness timeout — a failure that names nothing and looks +identical to a broken loader, a broken context switch and a broken GIC. + +### `CNTVOFF` freezes a descheduled partition's clock — measured on silicon + +`CNTVOFF` is a **static** offset written only at EL2, so while a partition is +suspended the physical counter moves and the offset does not: the subtraction +drifts for exactly as long as the partition is away, and what closes the gap is +the write on **resume**, which advances `CNTVOFF` by everything that elapsed. +Measuring the virtual counter anywhere in between measures the drift and not the +correction, which looks exactly like a broken freeze. + +Measured on the S32Z280-594EVB, one partition, a deliberate 20 ms hypervisor +dwell between two excursions: + +| | counts | +|---|---| +| interval the partition was excluded from | 1,969,637 | +| credited to `CNTVOFF` | 1,969,637 — **exact** | +| the partition's virtual clock moved by | 93, and that is the counter-read cost above | + +The same pair of excursions, quiet and identical, differed by **90 cycles out of +1,952,856** — 0.005%. That is the determinism figure one partition on one core +can support; it is repeatability, not the cost of anything. + +--- + +## The state a partition switch must save and restore + +Not a decision — a **list of registers**, kept here because that is what it is, +and because every entry on it was found by needing it rather than by reading a +manual. Nothing switches partitions yet; this is what will have to. + +`—` in the last column means nothing in ZoneX touches it today, which with one +partition is exactly correct and with two is a defect. + +| State | Encoding (AArch32) | Whose | Handled? | +|---|---|---|---| +| **EL1 banked registers** — `SP`, `LR` per mode, `SPSR` per mode | mode-banked | guest | — | +| `VBAR` | `p15, 0, c12, c0, 0` | guest | — · the guest writes it in its own board support, and a switch that did not restore it would send the next partition's faults to the previous one's vector table | +| `SCTLR`, `CONTEXTIDR`, `TPIDRURW`/`TPIDRURO`/`TPIDRPRW` | `p15, 0, c1, c0, 0` / `c13, c0, 1` / `c13, c0, {2,3,4}` | guest | — | +| **The whole EL1 MPU region set** — `PRBAR`/`PRLAR` per region, selected through `PRSELR` | `p15, 0, c6, c3, {0,1}` with `p15, 0, c6, c2, 1` | guest | — · **20 regions × 2 registers on the S32Z280.** This is likely to dominate the switch cost and it is the number the next step has to measure. | +| **FPU** — `FPEXC`, `FPSCR`, `D0`–`D15` | `VMRS`/`VMSR` | guest | — · `HCPTR.TCP10/TCP11` are cleared for a guest (D23), so a guest *may* use the FPU and nothing saves it | +| `CNTV_CTL`, `CNTV_TVAL`/`CNTV_CVAL` | `p15, 0, c14, c3, {1,0}` / `MRRC p15, 3, …, c14` | guest | **stop only.** `zx_el2_guest_timer_stop` disarms the timer when a partition yields, which makes an armed comparator harmless for one partition and does not make it correct for two: a switch that saved neither would hand the next partition the previous one's deadline. | +| `CNTVOFF` | `MRRC`/`MCRR p15, 4, …, c14` | **hypervisor, per partition** | **yes**, for one partition. Becomes an array indexed by partition, along with the suspend/resume instants recorded beside it. | +| **CPU interface** — `ICC_PMR`, `ICC_BPR1`, `ICC_IGRPEN1` | see the table above | guest | — · a partition brings these up itself in `board_init`, so a switch that did not restore them would leave the next partition running with the previous one's priority mask | +| **Which PPIs are enabled** — `GICR_ISENABLER0` | memory-mapped, SGI frame | **hypervisor** | **yes** — every one is disabled at bring-up and only the granted INTID enabled. A switch changes which INTID that is, and it is one register write. | +| The stage-2 region set | `HPRENR` | hypervisor | **yes** — one write, measured; see D4 | + +The two ends of that table are worth contrasting, because they are the whole +shape of the problem. The hypervisor's own per-partition state is *small and +cheap* — one `HPRENR` write, one `CNTVOFF` write, one `GICR_ISENABLER0` write. +The **guest's** state is large, and the EL1 MPU dominates it. A partition switch +is therefore not expensive because the hypervisor does much; it is expensive +because a guest has a lot of registers. --- diff --git a/docs/decisions.md b/docs/decisions.md index f2f573d..c5da774 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -854,10 +854,10 @@ upgrading a dependency should look like. --- -## D23 — What ZoneX takes over from a guest's boot path · **settled 2 Sep 2026** +## D23 — What ZoneX takes over from a guest's boot path · **settled 2 Sep 2026; amended when a partition was first given a clock** -**Two things: `CNTFRQ` and `HCPTR.TCP10/TCP11`. One thing deliberately NOT: -`CNTHCTL.PL1PCTEN` and `PL1PCEN`.** +**Three things: `CNTFRQ`, `HCPTR.TCP10/TCP11`, and starting the system +counter. One thing deliberately NOT: `CNTHCTL.PL1PCTEN` and `PL1PCEN`.** A standalone Cortex-R52 kernel resets *into* EL2 and configures it in its own boot path. Built as a guest it skips that block entirely — which is the whole @@ -874,6 +874,24 @@ a guest's expectations. measured-and-cross-checked clock tree on the S32Z280. * **`HCPTR.TCP10/TCP11` are cleared**, so a guest may use its FPU without every access trapping to EL2. Both reset SET. +* **The system counter is STARTED**, and this was added when a partition was + first granted a timer. It is a separate job from programming `CNTFRQ` and the + two are easy to confuse: `CNTFRQ` is a software-declared constant saying what + the counter *would* run at, and writing it does not make the counter move. + The Armv8-R AEM FVP leaves its counter stopped at reset and documents that + firmware is expected to start it, through a memory-mapped counter control + frame; the S32Z280's runs out of reset, clocked through the RTU's divider, + and has no frame to write. So it is a **board hook** with opposite answers on + the two targets, and it belongs at EL2 on both for the same reason the GIC + does: the system counter is one per system, and a partition able to start or + stop it would be deciding how fast time ran for every other partition. + + Whether it worked is a **third** question, asked separately and answered the + same way on both boards: read `CNTPCT` twice and see whether it moved. That + check earns its place — a guest that arms a timer against a stopped counter + blocks for ever, and the run ends in a harness timeout, which is the least + informative failure this suite can produce and looks identical to a broken + loader, a broken context switch and a broken GIC. **And the omission is the interesting half.** A standalone kernel opens `CNTHCTL.PL1PCTEN` and `PL1PCEN`, because it owns the physical timer. A @@ -895,3 +913,76 @@ rather than by a fault. The same list, written from the kernel's side, is at the `#ifndef` in the S32Z280 `entry.S` — next to the code it replaces, which is where somebody adding a third board will be looking. + +--- + +## D24 — Who owns the GIC, and how a partition's interrupt is delivered · **settled, and measured on both targets** + +**ZoneX owns every byte of memory-mapped GIC state. A partition gets its own +CPU interface, which on this part is system registers, and nothing else. +`HCR.IMO`, `FMO` and `AMO` stay CLEAR, so a physical interrupt taken while a +partition is running is delivered straight to EL1 with no hypervisor +involvement at all.** + +### The division, and why it is sharper than "shared things belong to EL2" + +A GICv3 interrupt reaches a thread through three pieces of state: + +| | scope | reachable by | +|---|---|---| +| distributor | one per system | memory-mapped | +| redistributor | one per **core**, two 64 KB frames | memory-mapped | +| CPU interface | per exception level | **system registers** | + +Only the third is a partition's. The obvious argument for keeping the first two +is that they are shared, which is true and weak. The real argument is specific: +the redistributor's SGI frame holds the enable, group, priority and +edge/level bits for all thirty-two SGIs and PPIs of that core — **including +PPI 26, the hypervisor's own timer**, which is what will end a partition's +window. A partition with a writable mapping of that frame could clear that bit +and never be descheduled again. Nothing about that is a memory-isolation +failure, so no region set would show it; it is the temporal-determinism claim, +gone. + +The sketch this work started from expected the opposite — that a guest would be +given a mapped GIC region and would call the port's own `gicv3_enable_ppi`. +That would have cost two extra EL2 regions on the S32Z280 (the window has to be +split three ways, because PMSAv8-R has no region priority) *and* opened the +hole above. Doing it the other way costs the partition nothing: **its manifest +is unchanged, and it is granted no device region of any kind.** That is the +strongest form the claim could take, and it is why it is worth stating as a +decision rather than as an implementation detail. + +**ZoneX disables all thirty-two before it enables one.** An enable bit is not +reset state a hypervisor may assume: on silicon ZoneX is not the first thing to +run. `GICR_ICENABLER0` is write-one-to-clear, so all ones is a single write and +touches nothing outside this core's SGIs and PPIs. + +### `HCR.IMO` clear, and what it costs + +With `IMO` clear the guest's timer PPI is a **physical** interrupt taken +directly to EL1. No injection, no List Register, no EL2 work per tick. The +Cortex-R52 does implement the virtual CPU interface — `ICH_HCR`, `ICH_VTR` and +four List Registers, confirmed on both targets — so injection is available and +is deliberately not used. + +**The cost is stated here because it becomes the next phase's whole problem:** +with `IMO` clear the hypervisor cannot take an interrupt of its own while a +partition is running either, *including its own timer*. A hypervisor tick that +ENDS a partition's window therefore needs `IMO` SET — and with `IMO` set, every +guest interrupt has to be injected through a List Register. That is a change to +`zx_gic.c` and `zx_trap_handler.S` and to **no guest**, which is exactly why the +shape here is worth having first: it is the version whose correctness can be +established before the delivery mechanism becomes complicated. + +### What a partition ends up touching + +`ICC_SRE`, `ICC_PMR`, `ICC_BPR1`, `ICC_IGRPEN1`, `ICC_IAR1`, `ICC_EOIR1`, +`CNTV_TVAL`, `CNTV_CTL`, `CNTVCT`, `CNTFRQ`. All system registers. `ICC_IGRPEN0` +is deliberately left alone: Group 0 is delivered as FIQ and a partition is +granted no Group 0 interrupt, so a group it has nothing in is a group it has no +business enabling. + +Verified on both targets: the partition receives INTID 27 and no other, and the +hypervisor's own timer PPI reads back disabled. + diff --git a/examples/common/zx_guest_abi.h b/examples/common/zx_guest_abi.h index ac9fe20..0d22a66 100644 --- a/examples/common/zx_guest_abi.h +++ b/examples/common/zx_guest_abi.h @@ -137,8 +137,9 @@ * * Two names for one offset is worth one sentence of explanation and no more: * each has exactly one reader, and both are in this file where a reader of - * either can see the other. Adding a seventeenth word to a sixteen-word - * granule would have cost a second granule and a second stage-2 region. */ + * either can see the other. It is also the LAST alias in this layout: the + * kernel guest's later fields went into a second granule instead, and the + * note there says why one alias is explicable and six would not be. */ #define ZX_GD_OPTIONS 0x14U /* EL2 writes: ZX_GO_* */ @@ -158,6 +159,25 @@ #define ZX_GO_QUIET 0x00000001U +/* ZX_GO_TICK asks the guest to run PREEMPTIVELY: to bring up its own GICv3 + * CPU interface, arm the virtual timer the hypervisor granted it, and run the + * checks that only a kernel with a tick can pass. + * + * IT IS A MAILBOX WORD AND NOT A BUILD OPTION, and that is the whole point. + * The image is built once, with the port's own IRQ path linked in, and the + * hypervisor decides per excursion whether a partition gets a clock. So the + * cooperative run and the preemptive run are literally the same bytes, and + * "the kernel that boots under stage 2" and "the kernel that is preempted + * under stage 2" cannot drift apart into two binaries where only one of them + * is the one being demonstrated. + * + * With the bit CLEAR the guest touches no interrupt controller and arms no + * timer at all -- its IRQ vector is installed and simply never taken -- which + * is what keeps a cooperative excursion exactly as cheap, and exactly as + * measurable, as it was before a tick existed. */ + +#define ZX_GO_TICK 0x00000002U + /* THE READBACK CONVENTION, and why it is a structure rather than one word. * * A single progress word is enough to say "the guest got somewhere". It is @@ -222,7 +242,78 @@ fired, so a torn write of the three leaves it zero and the hypervisor reads "no stage-1 fault" rather than a half-described one. */ -#define ZX_GD_WINDOW_SIZE 0x40U +/**************************************************************************/ +/* THE SECOND GRANULE: what a PREEMPTIVE guest reports */ +/**************************************************************************/ + +/* WHY THE MAILBOX IS TWO GRANULES AND NOT ONE. + * + * The words above fill sixteen exactly, and a guest with a timer of its own + * has six more things to say. Widening the mailbox costs one thing and not + * the thing it looks like it should cost: the guest's stage-1 MPU region for + * the mailbox goes from 64 bytes to 128, and NOTHING at stage 2 changes, + * because the partition window is one region covering the whole of it. It + * does move the entry branch, which is why ZX_GUEST_IMAGE_OFF_ENTRY below is + * 0x80 and the guest's linker script asserts it. + * + * The alternative -- overloading words that a kernel guest happens not to use + * -- was rejected after ZX_GD_OPTIONS. One alias, with one reader at each + * end and both spellings in this file, is explicable. Six would be a layout + * that only makes sense to whoever wrote it. + * + * THESE SIX ARE OUTSIDE THE SEALED SNAPSHOT, and for the same reason the + * stage-1 words are: four of them are written by the guest's TIMER INTERRUPT + * HANDLER, which runs on every tick and must not compute a checksum, and the + * other two are written by threads that never yield and so could not order + * their writes against a seal either. + * + * That is not a hole in the evidence, because the claims do not rest on + * them. "The tick advanced" is ZX_GD_TICKS, which is tx_time_get() and is + * INSIDE the seal; "a thread was preempted" and "two threads were time + * sliced" are progress BITS, and progress is inside the seal too. The six + * words below are the detail that makes a FAILING run diagnosable -- which + * INTID actually arrived, how many times, and how far each spinner got -- + * and a run that fails is exactly the run where a checksum would be + * least informative anyway. */ + +#define ZX_GD_IRQ_COUNT 0x40U /* handler: interrupts serviced */ +#define ZX_GD_TIMER_INTID 0x44U /* handler: the INTID it saw */ +#define ZX_GD_ODD_INTID 0x48U /* handler: any OTHER INTID */ +#define ZX_GD_SPIN_A 0x4CU /* spinner A's loop count */ +#define ZX_GD_SPIN_B 0x50U /* spinner B's loop count */ +#define ZX_GD_WAKES 0x54U /* times the sleeper woke */ + +/* 0x5C to 0x7C are spare, and are zeroed by the handover like everything + else. A word nobody wrote reads as whatever the image was built with, + which for a loaded, zero-filled section is zero -- but only until somebody + changes the section, so the handover writes them rather than relying on + it. */ + +/* A WORD THAT MEANS NOTHING, ON PURPOSE. + * + * One build asks the guest to probe an address it genuinely IS granted, so + * that a check whose pass condition is the absence of something can be seen + * to fail. Somewhere inside the partition is the obvious target and the + * mailbox is the obvious part of it -- but the probe WRITES a sentinel to + * whatever it is aimed at, so aiming it at a word the guest reports through + * corrupts the report. + * + * That was not hypothetical and it was not harmless. The target was mailbox + * offset zero, which is ZX_GD_PROGRESS, so the sentinel 0xA5A50001 was ORed + * into the progress word and the seal then certified it -- and once the + * preemptive bits were added, two of them (ZX_GP_PREEMPTED at 0x010000 and + * ZX_GP_NO_CLOCK at 0x040000) fall on bits the sentinel sets. A cooperative + * build with no clock at all reported both. The corruption had been there + * all along and was invisible until new bits gave those positions meaning, + * which is the worst way for a defect to become visible. + * + * So the probe gets a word of its own, which nothing reads and nothing folds + * into a checksum. */ + +#define ZX_GD_PROBE_SCRATCH 0x58U /* written by the probe, read by + nobody */ + +#define ZX_GD_WINDOW_SIZE 0x80U /**************************************************************************/ /* Progress bits */ @@ -257,6 +348,33 @@ #define ZX_GP_TICKING 0x2000U /* the guest's own tick advanced */ #define ZX_GP_FINISHED 0x4000U /* the guest reached its verdict */ +/* The bits a PREEMPTIVE kernel guest sets, and each of them is a thing a + cooperative guest cannot fake: + + COUNTING the guest's own virtual counter was seen to ADVANCE before + it blocked on anything. This is the pre-flight, and it + exists because the alternative to it is a hang: on a target + whose system counter was never started, a guest that goes + straight to tx_thread_sleep waits for ever and the run ends + in a harness timeout that names nothing. + TICKING tx_time_get() advanced, so _tx_timer_interrupt ran, so the + virtual timer PPI was delivered to EL1 and serviced. + PREEMPTED a thread that never yielded was DISPLACED by a + higher-priority thread waking from a timed sleep. Only an + interrupt can do that. + TIMESLICED two equal-priority threads that never yielded BOTH + advanced, which is the kernel's time slice and not merely + its scheduler. + + TICKING is deliberately kept at the value the cooperative build already + defined for it -- it was declared with the rest of the milestones before + there was a tick to set it. */ + +#define ZX_GP_COUNTING 0x8000U /* the virtual counter moves */ +#define ZX_GP_PREEMPTED 0x010000U /* a spinner was displaced */ +#define ZX_GP_TIMESLICED 0x020000U /* two spinners both advanced */ +#define ZX_GP_NO_CLOCK 0x040000U /* refused to block: no counter */ + /* WHAT THE GUEST'S OWN VECTORS SAW, in ZX_GD_STAGE1. * * A stage-1 fault is taken to EL1 and never reaches the hypervisor, which is @@ -349,8 +467,8 @@ * defined starting state rather than whatever the window held before. The * hypervisor writes its handover fields after the copy, never before. */ -#define ZX_GUEST_IMAGE_OFF_MAILBOX 0x00U /* one granule, ZX_GD_* inside */ -#define ZX_GUEST_IMAGE_OFF_ENTRY 0x40U /* the branch the ERET lands on */ +#define ZX_GUEST_IMAGE_OFF_MAILBOX 0x00U /* TWO granules, ZX_GD_* inside */ +#define ZX_GUEST_IMAGE_OFF_ENTRY 0x80U /* the branch the ERET lands on */ /* THE IMAGE HEADER, three words after the entry branch. * @@ -367,9 +485,9 @@ * an EMPTY section rather than an error, so "the header does not carry the * magic" catches both a missing guest and a wrong one. */ -#define ZX_GUEST_IMAGE_OFF_LINK_BASE 0x44U /* the window it was linked for */ -#define ZX_GUEST_IMAGE_OFF_LINK_SIZE 0x48U /* the size it was linked to fit */ -#define ZX_GUEST_IMAGE_OFF_MAGIC 0x4CU /* ZX_GUEST_IMAGE_MAGIC */ +#define ZX_GUEST_IMAGE_OFF_LINK_BASE 0x84U /* the window it was linked for */ +#define ZX_GUEST_IMAGE_OFF_LINK_SIZE 0x88U /* the size it was linked to fit */ +#define ZX_GUEST_IMAGE_OFF_MAGIC 0x8CU /* ZX_GUEST_IMAGE_MAGIC */ /* "ZXG" and a version. The version is here so that a later ABI change is a REFUSAL rather than a guest that starts and misbehaves: the loader can say @@ -409,6 +527,36 @@ #define ZX_HVC_GUEST_YIELD 0x0001 #define ZX_HVC_GUEST_PUTC 0x0002 +/**************************************************************************/ +/* The interrupt a partition is granted */ +/**************************************************************************/ + +/* THE VIRTUAL TIMER'S PPI, and the two sides have to agree about it for + * different reasons. The hypervisor ENABLES this INTID in the + * redistributor -- which the guest cannot reach -- and the guest + * ACKNOWLEDGES it through its own CPU interface, which is system registers + * and needs no device at all. A disagreement would present as a guest that + * takes an interrupt it does not recognise, records an unexpected INTID and + * never ticks, which is a good failure and still a failure. + * + * Three timer interrupts arrive as PPIs on this core: physical 30, virtual + * 27, hypervisor 26. A partition gets the VIRTUAL one, because its counter + * can be frozen with CNTVOFF while the partition is descheduled and the + * physical one cannot. See docs/decisions.md D7. + * + * Spelled without a suffix so the guest's assembly could use it directly if + * it ever needed to, like the HVC immediates above. The ZoneX side spells + * it ZX_PPI_VIRTUAL_TIMER, and the example that includes both headers + * asserts they agree at compile time. */ + +#define ZX_GUEST_TIMER_INTID 27 + +/* The GIC's spurious INTID: what ICC_IAR1 returns when nothing was actually + pending. It must NOT be given an end-of-interrupt, which is why the guest + checks for it rather than treating every read as an interrupt. */ + +#define ZX_GUEST_SPURIOUS_INTID 1023 + /**************************************************************************/ /* The readback checksum */ /**************************************************************************/ diff --git a/examples/common/zx_guest_launch.c b/examples/common/zx_guest_launch.c new file mode 100644 index 0000000..d31d6ce --- /dev/null +++ b/examples/common/zx_guest_launch.c @@ -0,0 +1,367 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* EXAMPLE DESCRIPTION */ +/* */ +/* zx_guest_launch.c SHARED */ +/* */ +/* DESCRIPTION */ +/* */ +/* The loader half of launching a real ThreadX guest, shared by every */ +/* example image that launches one. See zx_guest_launch.h for why it */ +/* is shared and what is deliberately left with each experiment. */ +/* */ +/* MISRA C:2012 deviations (justified) */ +/* */ +/* Rule 11.4/11.6 -- casting a partition window's address to a */ +/* volatile pointer is inherent to a hypervisor writing a guest's */ +/* memory; confined to the two accessors at the top of this file. */ +/* Directive 4.3 -- the one asm statement below is a barrier in a */ +/* function that does nothing else. */ +/* */ +/**************************************************************************/ + +#include "zx_probe.h" +#include "zx_guest_abi.h" +#include "zx_guest_launch.h" +#include "zx_guest_console.h" + + +/**************************************************************************/ +/* The mailbox */ +/**************************************************************************/ + +static volatile uint32_t *zx_mailbox(const ZX_GUEST_LAUNCH *launch_ptr) +{ + return (volatile uint32_t *)(uintptr_t) + (launch_ptr->zx_launch_window_base + ZX_GUEST_IMAGE_OFF_MAILBOX); +} + + +uint32_t zx_guest_mailbox_read(const ZX_GUEST_LAUNCH *launch_ptr, + uint32_t offset) +{ + return zx_mailbox(launch_ptr)[offset / 4U]; +} + + +void zx_guest_mailbox_write(const ZX_GUEST_LAUNCH *launch_ptr, + uint32_t offset, uint32_t value) +{ + zx_mailbox(launch_ptr)[offset / 4U] = value; +} + + +/**************************************************************************/ +/* zx_image_word -- one word out of the embedded guest image. */ +/* */ +/* Read from the SOURCE and not from the window, deliberately: the point */ +/* of the header check is to refuse before anything is copied, and a */ +/* check performed on the copy would already have overwritten whatever */ +/* was there. */ +/**************************************************************************/ + +static uint32_t zx_image_word(const ZX_GUEST_LAUNCH *launch_ptr, + uint32_t offset) +{ + const volatile uint32_t *image = (const volatile uint32_t *)(uintptr_t) + launch_ptr->zx_launch_image_start; + + return image[offset / 4U]; +} + + +/**************************************************************************/ +/* zx_guest_image_check */ +/* */ +/* THE CHECK THAT CATCHES A GUEST BUILT FOR SOMEWHERE ELSE. */ +/* */ +/* A guest is LINKED for one window; every absolute address inside it is */ +/* baked in. The hypervisor holds a raw blob with no symbol table, so */ +/* without the header the guest carries there is no way to know which */ +/* window that was -- and the failure is slow, not immediate: the entry */ +/* branch is PC-relative, so a guest copied into the wrong window STARTS, */ +/* runs to its first literal pool load, and then faults at an address */ +/* that looks entirely reasonable in the report. */ +/* */ +/* The magic earns its four bytes twice over: an unwritten window */ +/* reads as zero, and an .incbin whose linker input pattern matched */ +/* nothing produces an EMPTY section rather than an error -- so "the */ +/* header does not carry the magic" catches both a missing guest and a */ +/* wrong one. */ +/**************************************************************************/ + +uint32_t zx_guest_image_check(const ZX_GUEST_LAUNCH *launch_ptr) +{ + uint32_t magic = zx_image_word(launch_ptr, ZX_GUEST_IMAGE_OFF_MAGIC); + uint32_t link_base = zx_image_word(launch_ptr, ZX_GUEST_IMAGE_OFF_LINK_BASE); + uint32_t link_size = zx_image_word(launch_ptr, ZX_GUEST_IMAGE_OFF_LINK_SIZE); + uint32_t window_base = (uint32_t)launch_ptr->zx_launch_window_base; + uint32_t window_size = (uint32_t)(launch_ptr->zx_launch_window_end + - launch_ptr->zx_launch_window_base); + + zx_console_puts("\n--- the guest image declares what it was built for ---\n"); + zx_note("magic ", magic); + zx_note("linked for base ", link_base); + zx_note("linked for size ", link_size); + zx_note("this window's base ", window_base); + zx_note("this window's size ", window_size); + + zx_check("the embedded image carries the ZoneX guest magic, so a missing\n" + " .incbin or an empty section is caught before anything runs", + (magic == (uint32_t)ZX_GUEST_IMAGE_MAGIC) ? 1U : 0U); + + zx_check("the guest was linked for THIS window's base -- a guest built\n" + " for another address starts and then faults at an\n" + " address that looks perfectly reasonable", + (link_base == window_base) ? 1U : 0U); + + zx_check("and for this window's size, so a guest that expected more room\n" + " than the manifest grants is refused rather than\n" + " discovered at its first stack overflow", + (link_size == window_size) ? 1U : 0U); + + return ((magic == (uint32_t)ZX_GUEST_IMAGE_MAGIC) + && (link_base == window_base) + && (link_size == window_size)) ? 1U : 0U; +} + + +/**************************************************************************/ +/* zx_guest_image_load -- copy the image and make it executable. */ +/* */ +/* The COPY is here and the DECISION about where to copy is in */ +/* core/src/zx_partition_manager.c, which is the split zx_partition.h */ +/* describes: the arithmetic is testable on a workstation, the copy is */ +/* not. */ +/* */ +/* A word copy, not a byte copy, and both ends are granule aligned by */ +/* construction -- the linker script asserts it at each end -- so the */ +/* alignment the word copy needs is a property of the build rather than */ +/* a run-time hope. The length is rounded UP to a word because a blob */ +/* ending mid-word would otherwise lose its last bytes, and objcopy has */ +/* no obligation to pad. */ +/**************************************************************************/ + +void zx_guest_image_load(const ZX_PARTITION_LOAD *load_ptr) +{ + const volatile uint32_t *source = (const volatile uint32_t *)(uintptr_t) + load_ptr->zx_load_image_source; + volatile uint32_t *destination = (volatile uint32_t *)(uintptr_t) + load_ptr->zx_load_window_base; + uint32_t words = (uint32_t)((load_ptr->zx_load_image_length + 3U) / 4U); + uint32_t index; + + for (index = 0U; index < words; index++) + { + destination[index] = source[index]; + } + + /* The bytes just written will be FETCHED as instructions, and on this + core the instruction side is not coherent with the data cache. ZoneX + runs with its caches off today, so this is currently redundant -- and + it is here anyway, because the change that turns caches on will be one + line in the reset path made by somebody with no reason to think about + the loader. A cold instruction cache over an address nothing has + executed HAPPENS TO WORK until an eviction lands differently. */ + + zx_cache_sync_after_load(load_ptr->zx_load_window_base, + load_ptr->zx_load_image_length); +} + + +/**************************************************************************/ +/* zx_guest_hand_over -- what the hypervisor tells the guest. */ +/* */ +/* AFTER the copy, never before. The mailbox is a LOADED section in the */ +/* guest image -- zeroed by the copy -- which is what keeps the blob's */ +/* offset zero equal to the window's offset zero. Writing the handover */ +/* first would have it copied over. */ +/* */ +/* EVERY WORD IS WRITTEN, including the ones being set to zero. A */ +/* handover that only wrote the fields it cared about would leave the */ +/* rest holding whatever the copy put there, and "the guest reported */ +/* nothing" and "the guest reported what the previous pass left" are */ +/* different diagnoses that would look identical. */ +/**************************************************************************/ + +void zx_guest_hand_over(const ZX_GUEST_LAUNCH *launch_ptr, + uint32_t probe_target, uint32_t options) +{ + zx_guest_mailbox_write(launch_ptr, ZX_GD_PROGRESS, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_SCRATCH, + launch_ptr->zx_launch_sentinel); + zx_guest_mailbox_write(launch_ptr, ZX_GD_TARGET, probe_target); + zx_guest_mailbox_write(launch_ptr, ZX_GD_PROBED, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_SHARED, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_OPTIONS, options); + zx_guest_mailbox_write(launch_ptr, ZX_GD_SEQUENCE, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_CHECKSUM, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_TICKS, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_THREAD_A, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_THREAD_B, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_MESSAGES, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_VERDICT, ZX_GV_NONE); + zx_guest_mailbox_write(launch_ptr, ZX_GD_FAULT_STATUS, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_FAULT_ADDRESS, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_STAGE1, ZX_GS_NONE); + + /* The second granule: what a preemptive guest's timer interrupt + reports. Zeroed here for the same reason as everything above, and it + matters more here than anywhere else -- these words are written by a + HANDLER and are outside the guest's sealed snapshot, so a stale value + has nothing to fail against. */ + + zx_guest_mailbox_write(launch_ptr, ZX_GD_IRQ_COUNT, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_TIMER_INTID, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_ODD_INTID, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_SPIN_A, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_SPIN_B, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_WAKES, 0U); + + __asm__ volatile("dsb" ::: "memory"); +} + + +/**************************************************************************/ +/* zx_guest_run -- one excursion, measured. */ +/* */ +/* The PMU counter is read either side rather than a timer, because */ +/* CNTFRQ reads zero on both ZoneX targets and there is nothing to */ +/* convert with. Cycles are also the unit a worst-case-execution-time */ +/* argument is made in, so no conversion is wanted. */ +/**************************************************************************/ + +uint32_t zx_guest_run(const ZX_GUEST_LAUNCH *launch_ptr, + ZX_PARTITION_CB *partition_ptr, + uint32_t *cycles_ptr) +{ + zx_addr_t entry = partition_ptr->zx_partition_load.zx_load_entry; + uint32_t start; + uint32_t outcome; + + zx_fault_record_reset(zx_el2_fault_record()); + + /* The tag comes from the partition the hypervisor SCHEDULED, which is + the only party that knows it. A guest that prefixed its own lines + could claim to be another partition, and every line of a captured log + would then be evidence of nothing. */ + + zx_guest_console_attach(launch_ptr->zx_launch_partition_id, + launch_ptr->zx_launch_partition_name); + + /* The guest's virtual clock starts running again HERE and is frozen + again below, so that the time a partition can observe is the time it + actually spent on the core. With one partition the gap being closed + is the hypervisor's own printing between passes; with two it is the + other partition's window. See docs/decisions.md D7. */ + + if (launch_ptr->zx_launch_freeze_time != 0U) + { + zx_el2_guest_time_resume(); + } + else + { + zx_el2_guest_time_resume_uncredited(); + } + + start = zx_pmu_cycles(); + + /* The argument is zero and is not a mailbox pointer. A real kernel's + reset path clobbers r0 within two instructions of the ERET, so + anything passed in it would be gone before any C ran -- which is + exactly why the mailbox is at a fixed offset in the window instead. */ + + outcome = zx_el2_run_payload(entry, 0U); + + *cycles_ptr = zx_pmu_cycles() - start; + + /* THE GUEST'S TIMER IS STOPPED BEFORE ANYTHING ELSE RUNS. A partition + that yielded with its virtual timer armed has left a level asserted at + the GIC; the hypervisor runs with interrupts masked so it would not be + taken here, but it would still be pending at the next ERET into a + guest that has not yet reinitialised its own kernel. Stopping it is + one register write and it is per-guest state a partition switch has to + handle anyway. */ + + zx_el2_guest_timer_stop(); + + /* ALWAYS, even in a build whose freeze is switched off. This records + WHEN the partition stopped and what its own clock read then, which is + what the freeze is checked against -- a build that skipped it would + have moved the measurement rather than broken the mechanism. */ + + zx_el2_guest_time_suspend(); + + zx_guest_console_detach(); + + zx_partition_returned(partition_ptr, zx_el2_fault_record()); + + return outcome; +} + + +/**************************************************************************/ +/* zx_guest_report */ +/* */ +/* The checksum is verified before anything the guest reported is */ +/* believed, and that ordering is the point. Three failures it catches: */ +/* a window nobody ever wrote (every field zero, which the magic in the */ +/* checksum makes fail), a guest torn off mid-update, and a value read */ +/* out of the WRONG partition's window -- the sentinel the hypervisor */ +/* itself planted is folded in, so a report can be attributed. */ +/**************************************************************************/ + +uint32_t zx_guest_report(const ZX_GUEST_LAUNCH *launch_ptr) +{ + uint32_t progress = zx_guest_mailbox_read(launch_ptr, ZX_GD_PROGRESS); + uint32_t sequence = zx_guest_mailbox_read(launch_ptr, ZX_GD_SEQUENCE); + uint32_t checksum = zx_guest_mailbox_read(launch_ptr, ZX_GD_CHECKSUM); + uint32_t expected = (uint32_t)zx_guest_report_checksum( + zx_guest_mailbox_read(launch_ptr, ZX_GD_SCRATCH), progress, sequence, + zx_guest_mailbox_read(launch_ptr, ZX_GD_TICKS), + zx_guest_mailbox_read(launch_ptr, ZX_GD_THREAD_A), + zx_guest_mailbox_read(launch_ptr, ZX_GD_THREAD_B), + zx_guest_mailbox_read(launch_ptr, ZX_GD_MESSAGES), + zx_guest_mailbox_read(launch_ptr, ZX_GD_VERDICT)); + + zx_console_puts("\n--- what the guest reported, read out of its own " + "memory ---\n"); + zx_note("progress bits ", progress); + zx_note("sequence ", sequence); + zx_note("checksum ", checksum); + zx_note("checksum expected", expected); + zx_note("sentinel ", zx_guest_mailbox_read(launch_ptr, ZX_GD_SCRATCH)); + zx_note("guest ticks ", zx_guest_mailbox_read(launch_ptr, ZX_GD_TICKS)); + zx_note("producer slices ", zx_guest_mailbox_read(launch_ptr, ZX_GD_THREAD_A)); + zx_note("consumer slices ", zx_guest_mailbox_read(launch_ptr, ZX_GD_THREAD_B)); + zx_note("messages carried", zx_guest_mailbox_read(launch_ptr, ZX_GD_MESSAGES)); + zx_note("guest verdict ", zx_guest_mailbox_read(launch_ptr, ZX_GD_VERDICT)); + zx_note("stage-1 vector ", zx_guest_mailbox_read(launch_ptr, ZX_GD_STAGE1)); + zx_note("stage-1 DFSR ", zx_guest_mailbox_read(launch_ptr, + ZX_GD_FAULT_STATUS)); + zx_note("stage-1 DFAR ", zx_guest_mailbox_read(launch_ptr, + ZX_GD_FAULT_ADDRESS)); + + zx_check("the guest's report is sealed: the sequence advanced and the\n" + " checksum agrees, with the hypervisor's own sentinel\n" + " folded into it -- so this is THIS partition's report,\n" + " whole, and not a torn or misattributed one", + ((sequence != 0U) && (checksum == expected)) ? 1U : 0U); + + return progress; +} diff --git a/examples/common/zx_guest_launch.h b/examples/common/zx_guest_launch.h new file mode 100644 index 0000000..6a082d3 --- /dev/null +++ b/examples/common/zx_guest_launch.h @@ -0,0 +1,169 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* EXAMPLE DESCRIPTION */ +/* */ +/* zx_guest_launch.h SHARED */ +/* */ +/* DESCRIPTION */ +/* */ +/* Everything two example images do IDENTICALLY when they launch a */ +/* real ThreadX guest: check the image's header, copy it into the */ +/* window, write the handover mailbox, run one excursion, and read the */ +/* guest's sealed report back afterwards. */ +/* */ +/* WHY THIS IS A FILE AND NOT A COPY */ +/* */ +/* Two experiments now launch the same guest: one shows a kernel */ +/* confined by stage 2 and stopped at its boundary, the other shows a */ +/* PREEMPTIVE kernel with a timer interrupt of its own. They differ */ +/* in what the hypervisor CONFIGURES and in what they claim -- not in */ +/* how a guest is loaded. */ +/* */ +/* Copying the loader into the second image would have been shorter to */ +/* write and would have created the failure this suite is least able */ +/* to catch: two loaders that agree today, one of them fixed later, */ +/* and an isolation claim still being made by the other. The mailbox */ +/* layout, the header check and the seal are a CONTRACT with the guest */ +/* image, and a contract with two implementations has two versions. */ +/* */ +/* WHAT IS DELIBERATELY *NOT* HERE */ +/* */ +/* The manifest. Each image declares its own, because the manifest is */ +/* the statement of what the experiment grants -- windows, attributes, */ +/* how many partitions -- and that is the experiment rather than its */ +/* plumbing. Sharing it would make the two images agree about the one */ +/* thing they most need to be able to differ about. */ +/* */ +/**************************************************************************/ + +#ifndef ZX_GUEST_LAUNCH_H +#define ZX_GUEST_LAUNCH_H + +#include "zx_api.h" +#include "zx_partition.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/**************************************************************************/ +/* ZX_GUEST_LAUNCH -- where one partition's guest lives. */ +/* */ +/* Four addresses and a sentinel, all of them known to the image from */ +/* its own linker script. Passed as a structure rather than read from */ +/* linker symbols in this file, because these symbols are named by the */ +/* EXAMPLE's linker script and a shared translation unit that referred */ +/* to them by name could only ever serve one example. */ +/**************************************************************************/ + +typedef struct ZX_GUEST_LAUNCH_STRUCT +{ + /* The partition window: where the guest is copied to and runs. */ + zx_addr_t zx_launch_window_base; + zx_addr_t zx_launch_window_end; /* one past the last byte */ + + /* The embedded blob, in the hypervisor's own memory. */ + zx_addr_t zx_launch_image_start; + zx_addr_t zx_launch_image_end; + + /* What the hypervisor plants in the guest's mailbox, and which the + guest folds into every checksum -- so a report read out of the WRONG + window is detectable rather than merely unlucky. */ + uint32_t zx_launch_sentinel; + + /* The partition this guest belongs to, as the hypervisor knows it. + The console tag comes from HERE and never from the guest, so a guest + cannot claim to be another partition. */ + uint32_t zx_launch_partition_id; + const char *zx_launch_partition_name; + + /* WHETHER TO GIVE THIS PARTITION BACK THE TIME IT SPENT DESCHEDULED. + Non-zero is the only setting a hypervisor would ever ship, and the + field exists so that a build can turn the mechanism off and + demonstrate what that costs. + * + * It gates the CORRECTION -- the CNTVOFF write on the way back in -- + * and not the bookkeeping that records when the partition stopped. + * That split is deliberate: the bookkeeping is what the CHECK is + * measured against, so switching it off too would move the measurement + * instead of breaking the mechanism, and a negative build has to break + * the mechanism. + * + * A claim whose pass condition is "the guest could not tell it was + * descheduled" has to be capable of failing, or a green run shows only + * that the arithmetic did not crash. With this clear, the partition's + * clock counts every cycle the hypervisor spent between two excursions + * and the run reports FAILED -- which is what makes the positive run + * evidence. See docs/decisions.md D7. */ + uint32_t zx_launch_freeze_time; + +} ZX_GUEST_LAUNCH; + +/**************************************************************************/ +/* The mailbox, as the hypervisor sees it. */ +/* */ +/* EL2 can read and write a partition's memory because stage-2 AP cannot */ +/* deny EL2 (docs/decisions.md D3) -- the same property that stops AP */ +/* from isolating partitions is what makes a hypervisor-to-guest mailbox */ +/* free. It is at a fixed offset in the window rather than passed in a */ +/* register because a real kernel's reset path clobbers r0 long before */ +/* any of its C runs. */ +/**************************************************************************/ + +ZX_NODISCARD uint32_t zx_guest_mailbox_read(const ZX_GUEST_LAUNCH *launch_ptr, + uint32_t offset); + +void zx_guest_mailbox_write(const ZX_GUEST_LAUNCH *launch_ptr, + uint32_t offset, uint32_t value); + +/* THE CHECK THAT CATCHES A GUEST BUILT FOR SOMEWHERE ELSE. Reads the + header out of the SOURCE blob and not out of the window: the point is to + refuse before anything is copied, and a check performed on the copy would + already have overwritten whatever was there. Reports what it found and + returns non-zero only if every field agrees. */ + +ZX_NODISCARD uint32_t zx_guest_image_check(const ZX_GUEST_LAUNCH *launch_ptr); + +/* Copy the image into the window, then make the instruction side see it. */ + +void zx_guest_image_load(const ZX_PARTITION_LOAD *load_ptr); + +/* What the hypervisor tells the guest before it runs. AFTER the copy, + never before: the mailbox is a LOADED section in the guest image, zeroed + by the copy, so a handover written first would be copied over. */ + +void zx_guest_hand_over(const ZX_GUEST_LAUNCH *launch_ptr, + uint32_t probe_target, uint32_t options); + +/* One excursion, measured in PMU cycles. Returns ZX_RUN_*. */ + +ZX_NODISCARD uint32_t zx_guest_run(const ZX_GUEST_LAUNCH *launch_ptr, + ZX_PARTITION_CB *partition_ptr, + uint32_t *cycles_ptr); + +/* Read the guest's report back and say whether it is SEALED -- the + sequence advanced and the checksum agrees, with the hypervisor's own + sentinel folded into it. Returns the progress bits. */ + +ZX_NODISCARD uint32_t zx_guest_report(const ZX_GUEST_LAUNCH *launch_ptr); + +#ifdef __cplusplus +} +#endif + +#endif /* ZX_GUEST_LAUNCH_H */ diff --git a/examples/common/zx_one_partition.c b/examples/common/zx_one_partition.c index 304294e..77a718e 100644 --- a/examples/common/zx_one_partition.c +++ b/examples/common/zx_one_partition.c @@ -78,6 +78,7 @@ #include "zx_mm.h" #include "zx_partition.h" #include "zx_guest_console.h" +#include "zx_guest_launch.h" /* THE GUEST'S HYPERCALL NUMBERS AND THE HYPERVISOR'S MUST AGREE, and this is the only translation unit that can see both spellings. The guest is a @@ -119,53 +120,32 @@ static ZX_MANIFEST_ENV zx_env; static ZX_PARTITION_CB zx_partition_a; static ZX_REGION zx_mmio[ZX_BOARD_MAX_MMIO_REGIONS]; +/* Where partition A's guest lives, for the shared launcher in + zx_guest_launch.c. Filled at run time rather than being a static + initialiser, because every field is the address of a LINKER symbol and the + address of a linker symbol is not a constant expression. */ -/**************************************************************************/ -/* The mailbox, as the hypervisor sees it. */ -/* */ -/* EL2 can read and write a partition's memory because stage-2 AP cannot */ -/* deny EL2 (docs/decisions.md D3) -- the same property that stops AP */ -/* from isolating partitions is what makes a hypervisor-to-guest mailbox */ -/* free. It is at a fixed offset in the window rather than passed in a */ -/* register because a real kernel's reset path clobbers r0 long before */ -/* any of its C runs. */ -/**************************************************************************/ - -static volatile uint32_t *zx_mailbox(void) -{ - return (volatile uint32_t *)(uintptr_t) - (zx_symbol_address(__zx_partition_a_start) - + ZX_GUEST_IMAGE_OFF_MAILBOX); -} +static ZX_GUEST_LAUNCH zx_launch; -static uint32_t zx_mailbox_read(uint32_t offset) +static void zx_describe_launch(void) { - return zx_mailbox()[offset / 4U]; -} - - -static void zx_mailbox_write(uint32_t offset, uint32_t value) -{ - zx_mailbox()[offset / 4U] = value; -} - - -/**************************************************************************/ -/* zx_image_word -- one word out of the embedded guest image. */ -/* */ -/* Read from the SOURCE and not from the window, deliberately: the point */ -/* of the header check is to refuse before anything is copied, and a */ -/* check performed on the copy would already have overwritten whatever */ -/* was there. */ -/**************************************************************************/ - -static uint32_t zx_image_word(uint32_t offset) -{ - const volatile uint32_t *image = (const volatile uint32_t *)(uintptr_t) + zx_launch.zx_launch_window_base = + zx_symbol_address(__zx_partition_a_start); + zx_launch.zx_launch_window_end = + zx_symbol_address(__zx_partition_a_end); + zx_launch.zx_launch_image_start = zx_symbol_address(__zx_guest_a_blob_start); - - return image[offset / 4U]; + zx_launch.zx_launch_image_end = + zx_symbol_address(__zx_guest_a_blob_end); + zx_launch.zx_launch_sentinel = ZX_GUEST_A_SENTINEL; + zx_launch.zx_launch_partition_id = ZX_PARTITION_A_ID; + zx_launch.zx_launch_partition_name = zx_partitions[0].zx_partition_name; + + /* This guest has no clock and reads none, so the freeze has nothing to + freeze -- and it is switched ON anyway, because the code path that + runs in every image is the code path that stays correct. */ + zx_launch.zx_launch_freeze_time = 1U; } @@ -229,229 +209,6 @@ static void zx_build_manifest(uint32_t board_regions) } -/**************************************************************************/ -/* zx_check_image_header */ -/* */ -/* THE CHECK THAT CATCHES A GUEST BUILT FOR SOMEWHERE ELSE. */ -/* */ -/* A guest is LINKED for one window; every absolute address inside it is */ -/* baked in. The hypervisor holds a raw blob with no symbol table, so */ -/* without the header the guest carries there is no way to know which */ -/* window that was -- and the failure is slow, not immediate: the entry */ -/* branch is PC-relative, so a guest copied into the wrong window STARTS, */ -/* runs to its first literal pool load, and then faults at an address */ -/* that looks entirely reasonable in the report. */ -/* */ -/* The magic earns its four bytes twice over. A window nobody wrote */ -/* reads as zero, and an .incbin whose linker input pattern matched */ -/* nothing produces an EMPTY section rather than an error -- so "the */ -/* header does not carry the magic" catches both a missing guest and a */ -/* wrong one. */ -/**************************************************************************/ - -static uint32_t zx_check_image_header(void) -{ - uint32_t magic = zx_image_word(ZX_GUEST_IMAGE_OFF_MAGIC); - uint32_t link_base = zx_image_word(ZX_GUEST_IMAGE_OFF_LINK_BASE); - uint32_t link_size = zx_image_word(ZX_GUEST_IMAGE_OFF_LINK_SIZE); - uint32_t window_base = (uint32_t)zx_symbol_address(__zx_partition_a_start); - uint32_t window_size = (uint32_t)(zx_symbol_address(__zx_partition_a_end) - - zx_symbol_address(__zx_partition_a_start)); - - zx_console_puts("\n--- the guest image declares what it was built for ---\n"); - zx_note("magic ", magic); - zx_note("linked for base ", link_base); - zx_note("linked for size ", link_size); - zx_note("this window's base ", window_base); - zx_note("this window's size ", window_size); - - zx_check("the embedded image carries the ZoneX guest magic, so a missing\n" - " .incbin or an empty section is caught before anything runs", - (magic == (uint32_t)ZX_GUEST_IMAGE_MAGIC) ? 1U : 0U); - - zx_check("the guest was linked for THIS window's base -- a guest built\n" - " for another address starts and then faults at an\n" - " address that looks perfectly reasonable", - (link_base == window_base) ? 1U : 0U); - - zx_check("and for this window's size, so a guest that expected more room\n" - " than the manifest grants is refused rather than\n" - " discovered at its first stack overflow", - (link_size == window_size) ? 1U : 0U); - - return ((magic == (uint32_t)ZX_GUEST_IMAGE_MAGIC) - && (link_base == window_base) - && (link_size == window_size)) ? 1U : 0U; -} - - -/**************************************************************************/ -/* zx_load_partition -- copy the image and make it executable. */ -/* */ -/* The COPY is here and the DECISION about where to copy is in */ -/* core/src/zx_partition_manager.c, which is the split zx_partition.h */ -/* describes: the arithmetic is testable on a workstation, the copy is */ -/* not. */ -/* */ -/* A word copy, not a byte copy, and both ends are granule aligned by */ -/* construction -- the linker script asserts it at each end -- so the */ -/* alignment the word copy needs is a property of the build rather than */ -/* a run-time hope. The length is rounded UP to a word because a blob */ -/* ending mid-word would otherwise lose its last bytes, and objcopy has */ -/* no obligation to pad. */ -/**************************************************************************/ - -static void zx_load_partition(const ZX_PARTITION_LOAD *load_ptr) -{ - const volatile uint32_t *source = (const volatile uint32_t *)(uintptr_t) - load_ptr->zx_load_image_source; - volatile uint32_t *destination = (volatile uint32_t *)(uintptr_t) - load_ptr->zx_load_window_base; - uint32_t words = (uint32_t)((load_ptr->zx_load_image_length + 3U) / 4U); - uint32_t index; - - for (index = 0U; index < words; index++) - { - destination[index] = source[index]; - } - - /* The bytes just written will be FETCHED as instructions, and on this - core the instruction side is not coherent with the data cache. ZoneX - runs with its caches off today, so this is currently redundant -- and - it is here anyway, because the change that turns caches on will be one - line in the reset path made by somebody with no reason to think about - the loader. A cold instruction cache over an address nothing has - executed HAPPENS TO WORK until an eviction lands differently. */ - - zx_cache_sync_after_load(load_ptr->zx_load_window_base, - load_ptr->zx_load_image_length); -} - - -/**************************************************************************/ -/* zx_hand_over -- what the hypervisor tells the guest before it runs. */ -/* */ -/* AFTER the copy, never before. The mailbox is a LOADED section in the */ -/* guest image -- zeroed by the copy -- which is what keeps the blob's */ -/* offset zero equal to the window's offset zero. Writing the handover */ -/* first would have it copied over. */ -/**************************************************************************/ - -static void zx_hand_over(uint32_t probe_target, uint32_t options) -{ - zx_mailbox_write(ZX_GD_PROGRESS, 0U); - zx_mailbox_write(ZX_GD_SCRATCH, ZX_GUEST_A_SENTINEL); - zx_mailbox_write(ZX_GD_TARGET, probe_target); - zx_mailbox_write(ZX_GD_PROBED, 0U); - zx_mailbox_write(ZX_GD_SHARED, 0U); - zx_mailbox_write(ZX_GD_OPTIONS, options); - zx_mailbox_write(ZX_GD_SEQUENCE, 0U); - zx_mailbox_write(ZX_GD_CHECKSUM, 0U); - zx_mailbox_write(ZX_GD_TICKS, 0U); - zx_mailbox_write(ZX_GD_THREAD_A, 0U); - zx_mailbox_write(ZX_GD_THREAD_B, 0U); - zx_mailbox_write(ZX_GD_MESSAGES, 0U); - zx_mailbox_write(ZX_GD_VERDICT, ZX_GV_NONE); - zx_mailbox_write(ZX_GD_FAULT_STATUS, 0U); - zx_mailbox_write(ZX_GD_FAULT_ADDRESS, 0U); - zx_mailbox_write(ZX_GD_STAGE1, ZX_GS_NONE); - - __asm__ volatile("dsb" ::: "memory"); -} - - -/**************************************************************************/ -/* zx_run_guest -- one excursion, measured. */ -/* */ -/* The PMU counter is read either side rather than a timer, because */ -/* CNTFRQ reads zero on both ZoneX targets and there is nothing to */ -/* convert with. Cycles are also the unit a worst-case-execution-time */ -/* argument is made in, so no conversion is wanted. */ -/**************************************************************************/ - -static uint32_t zx_run_guest(uint32_t *cycles_ptr) -{ - zx_addr_t entry = zx_partition_a.zx_partition_load.zx_load_entry; - uint32_t start; - uint32_t outcome; - - zx_fault_record_reset(zx_el2_fault_record()); - - /* The tag comes from the partition the hypervisor SCHEDULED, which is - the only party that knows it. A guest that prefixed its own lines - could claim to be another partition, and every line of a captured log - would then be evidence of nothing. */ - - zx_guest_console_attach(ZX_PARTITION_A_ID, - zx_partitions[0].zx_partition_name); - - start = zx_pmu_cycles(); - - /* The argument is zero and is not a mailbox pointer. A real kernel's - reset path clobbers r0 within two instructions of the ERET, so - anything passed in it would be gone before any C ran -- which is - exactly why the mailbox is at a fixed offset in the window instead. */ - - outcome = zx_el2_run_payload(entry, 0U); - - *cycles_ptr = zx_pmu_cycles() - start; - - zx_guest_console_detach(); - - zx_partition_returned(&zx_partition_a, zx_el2_fault_record()); - - return outcome; -} - - -/**************************************************************************/ -/* zx_report_mailbox */ -/* */ -/* The checksum is verified before anything the guest reported is */ -/* believed, and that ordering is the point. Three failures it catches: */ -/* a window nobody ever wrote (every field zero, which the magic in the */ -/* checksum makes fail), a guest torn off mid-update, and a value read */ -/* out of the WRONG partition's window -- the sentinel the hypervisor */ -/* itself planted is folded in, so a report can be attributed. */ -/**************************************************************************/ - -static uint32_t zx_report_mailbox(void) -{ - uint32_t progress = zx_mailbox_read(ZX_GD_PROGRESS); - uint32_t sequence = zx_mailbox_read(ZX_GD_SEQUENCE); - uint32_t checksum = zx_mailbox_read(ZX_GD_CHECKSUM); - uint32_t expected = (uint32_t)zx_guest_report_checksum( - zx_mailbox_read(ZX_GD_SCRATCH), progress, sequence, - zx_mailbox_read(ZX_GD_TICKS), zx_mailbox_read(ZX_GD_THREAD_A), - zx_mailbox_read(ZX_GD_THREAD_B), zx_mailbox_read(ZX_GD_MESSAGES), - zx_mailbox_read(ZX_GD_VERDICT)); - - zx_console_puts("\n--- what the guest reported, read out of its own " - "memory ---\n"); - zx_note("progress bits ", progress); - zx_note("sequence ", sequence); - zx_note("checksum ", checksum); - zx_note("checksum expected", expected); - zx_note("sentinel ", zx_mailbox_read(ZX_GD_SCRATCH)); - zx_note("guest ticks ", zx_mailbox_read(ZX_GD_TICKS)); - zx_note("producer slices ", zx_mailbox_read(ZX_GD_THREAD_A)); - zx_note("consumer slices ", zx_mailbox_read(ZX_GD_THREAD_B)); - zx_note("messages carried", zx_mailbox_read(ZX_GD_MESSAGES)); - zx_note("guest verdict ", zx_mailbox_read(ZX_GD_VERDICT)); - zx_note("stage-1 vector ", zx_mailbox_read(ZX_GD_STAGE1)); - zx_note("stage-1 DFSR ", zx_mailbox_read(ZX_GD_FAULT_STATUS)); - zx_note("stage-1 DFAR ", zx_mailbox_read(ZX_GD_FAULT_ADDRESS)); - - zx_check("the guest's report is sealed: the sequence advanced and the\n" - " checksum agrees, with the hypervisor's own sentinel\n" - " folded into it -- so this is THIS partition's report,\n" - " whole, and not a torn or misattributed one", - ((sequence != 0U) && (checksum == expected)) ? 1U : 0U); - - return progress; -} - - /**************************************************************************/ /* zx_el2_main -- entered from zx_el2_entry.S at EL2. Does not return. */ /**************************************************************************/ @@ -553,6 +310,7 @@ ZX_NORETURN void zx_el2_main(void) /* ---------------------------------------------------------------- */ zx_build_manifest(board_regions); + zx_describe_launch(); zx_env.zx_env_region_budget = el2_regions; status = zx_manifest_verify(&zx_manifest, &zx_env, &fault); @@ -605,7 +363,7 @@ ZX_NORETURN void zx_el2_main(void) zx_console_exit(zx_probe_failures()); } - if (zx_check_image_header() == 0U) + if (zx_guest_image_check(&zx_launch) == 0U) { zx_console_puts("\n *** REFUSING to launch this image. It is either\n" " *** absent, truncated, or built for a different\n" @@ -681,6 +439,15 @@ ZX_NORETURN void zx_el2_main(void) " CNTVOFF instead, which is what freezes a descheduled\n" " partition's clock.\n"); + /* CNTVOFF, set so that the partition's own clock reads zero when it + starts. This guest never reads it -- it takes no interrupts and asks + for no tick -- and the offset is programmed anyway, because CNTVOFF is + UNKNOWN out of reset and a register a guest can read must not be left + holding whatever the part came up with. What it is FOR is next door, + in the preemptive image. */ + + zx_el2_guest_time_reset(); + zx_pmu_enable(); /* ---------------------------------------------------------------- */ @@ -718,8 +485,8 @@ ZX_NORETURN void zx_el2_main(void) " differed in order as well as in HCR.VM would measure\n" " neither. This pass exists to be thrown away.\n"); - zx_load_partition(&zx_partition_a.zx_partition_load); - zx_hand_over(0U, ZX_GO_QUIET); + zx_guest_image_load(&zx_partition_a.zx_partition_load); + zx_guest_hand_over(&zx_launch, 0U, ZX_GO_QUIET); zx_partition_loaded(&zx_partition_a); zx_check("the partition may be entered now that its image is loaded", @@ -728,15 +495,15 @@ ZX_NORETURN void zx_el2_main(void) { uint32_t warm_cycles = 0U; - outcome = zx_run_guest(&warm_cycles); + outcome = zx_guest_run(&zx_launch, &zx_partition_a, &warm_cycles); zx_note("warm-up excursion, cycles (discarded)", warm_cycles); } zx_check("the warm-up guest reached its own verdict, which rules out the\n" " loader, the entry point and the kernel before stage 2\n" " is added as a variable", - ((zx_mailbox_read(ZX_GD_PROGRESS) & (uint32_t)ZX_GP_FINISHED) - != 0U) ? 1U : 0U); + ((zx_guest_mailbox_read(&zx_launch, ZX_GD_PROGRESS) + & (uint32_t)ZX_GP_FINISHED) != 0U) ? 1U : 0U); zx_console_puts("\n=========================================================\n" " PASS 1 of 3: the guest with HCR.VM CLEAR, and QUIET\n" @@ -756,15 +523,15 @@ ZX_NORETURN void zx_el2_main(void) " in the mailbox -- so the run being measured is the run\n" " being demonstrated in pass 3.\n"); - zx_load_partition(&zx_partition_a.zx_partition_load); - zx_hand_over(0U, ZX_GO_QUIET); + zx_guest_image_load(&zx_partition_a.zx_partition_load); + zx_guest_hand_over(&zx_launch, 0U, ZX_GO_QUIET); zx_check("the partition may be entered again after yielding", zx_partition_enter(&zx_partition_a)); - outcome = zx_run_guest(&cycles_without_stage2); + outcome = zx_guest_run(&zx_launch, &zx_partition_a, &cycles_without_stage2); zx_note("outcome", outcome); - progress = zx_report_mailbox(); + progress = zx_guest_report(&zx_launch); zx_check("with stage 2 off, the guest reached its own verdict", (((progress & (uint32_t)ZX_GP_FINISHED) != 0U) @@ -786,8 +553,16 @@ ZX_NORETURN void zx_el2_main(void) + ZX_GUEST_IMAGE_OFF_ENTRY); #endif #ifdef ZX_ONE_PROBE_GRANTED + + /* ZX_GD_PROBE_SCRATCH and not offset zero, which is ZX_GD_PROGRESS. The + probe WRITES a sentinel to whatever it is aimed at, so a target inside + the guest's own report corrupts the report -- and the seal then + certifies the corrupted value, which is worse than not checking at + all. See the note on ZX_GD_PROBE_SCRATCH in zx_guest_abi.h. */ + probe_target = (uint32_t)(zx_symbol_address(__zx_partition_a_start) - + ZX_GUEST_IMAGE_OFF_MAILBOX); + + ZX_GUEST_IMAGE_OFF_MAILBOX + + ZX_GD_PROBE_SCRATCH); #endif zx_console_puts("\n=========================================================\n" @@ -800,8 +575,8 @@ ZX_NORETURN void zx_el2_main(void) " traffic (none), one bit of hypervisor configuration\n" " apart.\n"); - zx_load_partition(&zx_partition_a.zx_partition_load); - zx_hand_over(0U, ZX_GO_QUIET); + zx_guest_image_load(&zx_partition_a.zx_partition_load); + zx_guest_hand_over(&zx_launch, 0U, ZX_GO_QUIET); zx_stage2_enable(); zx_note("HCR now", zx_read_hcr()); @@ -818,9 +593,9 @@ ZX_NORETURN void zx_el2_main(void) zx_check("the partition may be entered again after yielding", zx_partition_enter(&zx_partition_a)); - outcome = zx_run_guest(&cycles_with_stage2); + outcome = zx_guest_run(&zx_launch, &zx_partition_a, &cycles_with_stage2); zx_note("outcome", outcome); - progress = zx_report_mailbox(); + progress = zx_guest_report(&zx_launch); zx_check("with stage 2 in force, the guest did the same work and reached\n" " the same verdict", @@ -849,8 +624,8 @@ ZX_NORETURN void zx_el2_main(void) zx_console_puts("\n"); } - zx_load_partition(&zx_partition_a.zx_partition_load); - zx_hand_over(probe_target, 0U); + zx_guest_image_load(&zx_partition_a.zx_partition_load); + zx_guest_hand_over(&zx_launch, probe_target, 0U); zx_check("the partition may be entered a third time", zx_partition_enter(&zx_partition_a)); @@ -858,12 +633,12 @@ ZX_NORETURN void zx_el2_main(void) { uint32_t loud_cycles = 0U; - outcome = zx_run_guest(&loud_cycles); + outcome = zx_guest_run(&zx_launch, &zx_partition_a, &loud_cycles); zx_note("outcome", outcome); zx_note("loud excursion, cycles", loud_cycles); } - progress = zx_report_mailbox(); + progress = zx_guest_report(&zx_launch); zx_partition_report(&zx_partition_a); @@ -909,7 +684,8 @@ ZX_NORETURN void zx_el2_main(void) zx_check("and the guest's OWN vectors saw nothing, so this was stage 2\n" " and not stage 1 -- the two are reported by different\n" " code at different privilege levels", - (zx_mailbox_read(ZX_GD_STAGE1) == ZX_GS_NONE) ? 1U : 0U); + (zx_guest_mailbox_read(&zx_launch, ZX_GD_STAGE1) + == ZX_GS_NONE) ? 1U : 0U); zx_check("the guest PC is inside the partition's window, so it resolves\n" " against guest_a.map by hand", ((zx_el2_fault_record()->zx_fault_elr @@ -942,12 +718,15 @@ ZX_NORETURN void zx_el2_main(void) (zx_fault_classify(zx_el2_fault_record()->zx_fault_hsr) == ZX_FAULT_HYPERCALL) ? 1U : 0U); zx_check("the guest's own vector reports a DATA ABORT at EL1", - (zx_mailbox_read(ZX_GD_STAGE1) == ZX_GS_DABT) ? 1U : 0U); + (zx_guest_mailbox_read(&zx_launch, ZX_GD_STAGE1) + == ZX_GS_DABT) ? 1U : 0U); zx_check("its DFAR is exactly the address it was asked to touch", - (zx_mailbox_read(ZX_GD_FAULT_ADDRESS) == probe_target) ? 1U : 0U); + (zx_guest_mailbox_read(&zx_launch, ZX_GD_FAULT_ADDRESS) + == probe_target) ? 1U : 0U); zx_check("its DFSR is non-zero, so the syndrome was captured rather than\n" " the word merely being written", - (zx_mailbox_read(ZX_GD_FAULT_STATUS) != 0U) ? 1U : 0U); + (zx_guest_mailbox_read(&zx_launch, ZX_GD_FAULT_STATUS) + != 0U) ? 1U : 0U); zx_check("the probe did NOT survive", ((progress & (uint32_t)ZX_GP_PROBE_SURVIVED) == 0U) ? 1U : 0U); @@ -997,10 +776,12 @@ ZX_NORETURN void zx_el2_main(void) zx_check("the semaphore granted once and refused the second get", ((progress & (uint32_t)ZX_GP_SEMAPHORE_OK) != 0U) ? 1U : 0U); zx_check("the guest published its own verdict, and it is PASSED", - (zx_mailbox_read(ZX_GD_VERDICT) == ZX_GV_PASSED) ? 1U : 0U); + (zx_guest_mailbox_read(&zx_launch, ZX_GD_VERDICT) + == ZX_GV_PASSED) ? 1U : 0U); zx_check("no stage-1 fault was taken, so the guest's own MPU permitted\n" " everything the kernel legitimately did", - (zx_mailbox_read(ZX_GD_STAGE1) == ZX_GS_NONE) ? 1U : 0U); + (zx_guest_mailbox_read(&zx_launch, ZX_GD_STAGE1) + == ZX_GS_NONE) ? 1U : 0U); zx_console_puts("\n--- the guest printed through the hypervisor ---\n"); zx_note("characters forwarded", zx_guest_console_characters()); diff --git a/examples/common/zx_preemptive_partition.c b/examples/common/zx_preemptive_partition.c new file mode 100644 index 0000000..350a870 --- /dev/null +++ b/examples/common/zx_preemptive_partition.c @@ -0,0 +1,1192 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* EXAMPLE DESCRIPTION */ +/* */ +/* zx_preemptive_partition.c SHARED */ +/* */ +/* DESCRIPTION */ +/* */ +/* A PREEMPTIVE ThreadX kernel, running at EL1 inside a stage-2 */ +/* confined partition, with a timer interrupt the hypervisor granted */ +/* it and a clock the hypervisor freezes while it is not running. */ +/* */ +/* The image next door showed that a real kernel BOOTS and SCHEDULES */ +/* under stage 2, cooperatively. A cooperative kernel is a kernel */ +/* with the interesting half removed: nothing takes the core from a */ +/* thread, so nothing can go wrong in the place where a partitioning */ +/* hypervisor most needs nothing to go wrong. This image adds the */ +/* interrupt. */ +/* */ +/* THE THREE CLAIMS */ +/* */ +/* 1. A PARTITION RECEIVES AN INTERRUPT IT WAS GRANTED, and only */ +/* that one. ZoneX enables the virtual timer's PPI in a */ +/* redistributor no partition can reach; the guest brings up its */ +/* own CPU interface, which is system registers, and acknowledges. */ +/* */ +/* 2. A REAL KERNEL IS PREEMPTED UNDER STAGE 2. A thread that never */ +/* yields is displaced; two equal-priority threads that never */ +/* yield are time sliced. Neither is possible without an */ +/* interrupt reaching EL1 and running the kernel's own handler. */ +/* */ +/* 3. A DESCHEDULED PARTITION'S CLOCK IS FROZEN. Between two */ +/* excursions the hypervisor does a great deal of work -- on */ +/* silicon, millions of cycles of polled UART -- and the */ +/* partition's virtual counter does not advance by any of it. */ +/* That is CNTVOFF, and it is the difference between temporal */ +/* partitioning and time slicing that a guest can observe. */ +/* */ +/* WHAT THIS IMAGE DELIBERATELY DOES NOT MEASURE */ +/* */ +/* "What a clock costs a partition." The obvious pair -- one */ +/* cooperative excursion against one preemptive one -- differs in the */ +/* thing being measured AND in the fact that the preemptive guest */ +/* spends three ticks deliberately waiting. The difference would be */ +/* dominated by the waiting and would be quoted as the cost of an */ +/* interrupt, which is the same mistake as measuring two passes that */ +/* differ in execution order and calling the difference stage 2. */ +/* */ +/* What IS comparable is two excursions of the SAME preemptive guest */ +/* doing the SAME work with the SAME console traffic, one after the */ +/* other, and that pair is measured: it is a repeatability number, */ +/* which is the honest determinism claim available on one core with */ +/* one partition. */ +/* */ +/* WHAT A PASS DOES NOT MEAN */ +/* */ +/* On the Armv8-R AEM FVP a pass proves the code is right and says */ +/* NOTHING about timing: the model is functional, not cycle-accurate. */ +/* Every number this image prints has to come from silicon before it */ +/* is quoted, and the image says so in its own output. */ +/* */ +/* On lockstep silicon it demonstrates preemption and a frozen clock */ +/* for ONE partition on ONE logical core. Time PARTITIONING -- a */ +/* hypervisor tick that ENDS a partition's window -- is not this, and */ +/* is not here: it needs HCR.IMO set, which changes how every guest */ +/* interrupt is delivered. See docs/decisions.md D24. */ +/* */ +/**************************************************************************/ + +#include "zx_probe.h" +#include "zx_platform.h" +#include "zx_guest_abi.h" +#include "zx_guest_launch.h" +#include "zx_manifest.h" +#include "zx_mm.h" +#include "zx_partition.h" +#include "zx_guest_console.h" + +/* THE GUEST'S CONTRACT AND THE HYPERVISOR'S MUST AGREE, and this is one of + only two translation units that can see both spellings. The guest is a + separate program with its own toolchain invocation and cannot include + zx_port.h; it restates what it needs in zx_guest_abi.h. Asserting the two + against each other here turns a renumbered constant into a failed BUILD + rather than a partition that takes an interrupt it does not recognise. */ + +_Static_assert(ZX_HVC_GUEST_NOP == (int)ZX_HVC_NOP, + "the guest and the hypervisor disagree about HVC #0"); +_Static_assert(ZX_HVC_GUEST_YIELD == (int)ZX_HVC_YIELD, + "the guest and the hypervisor disagree about the yield HVC"); +_Static_assert(ZX_HVC_GUEST_PUTC == (int)ZX_HVC_PUTC, + "the guest and the hypervisor disagree about the console HVC"); +_Static_assert(ZX_GUEST_TIMER_INTID == (int)ZX_PPI_VIRTUAL_TIMER, + "the guest acknowledges a different INTID from the one ZoneX " + "enables for it"); +_Static_assert(ZX_GUEST_SPURIOUS_INTID == (int)ZX_INTID_SPURIOUS, + "the guest and the hypervisor disagree about the spurious " + "INTID, which must never be given an end-of-interrupt"); + +/* The embedded guest image and partition A's window, from the linker + script. */ + +extern char __zx_guest_a_blob_start[]; +extern char __zx_guest_a_blob_end[]; +extern char __zx_partition_a_start[]; +extern char __zx_partition_a_end[]; +extern char __zx_partition_a_hole_start[]; + +#define ZX_PARTITION_A_ID 1U +#define ZX_PARTITION_A_INDEX 0U + +/* The sentinel the hypervisor leaves in the guest's mailbox, and which the + guest folds into every checksum. Distinct from the other images' so that + a log from one cannot be misread as another's. */ + +#define ZX_GUEST_A_SENTINEL 0xA5A5000BU + +/* The priority the partition's timer is given. + * + * A GIC compares priorities NUMERICALLY and lower wins, and only the top + * bits of the byte are implemented -- this image discovers how many and + * reports it, because two values differing only in the vanished bits are + * the SAME priority and cannot preempt one another. + * + * 0xA0 leaves the whole upper half of the range above it, which is where a + * hypervisor tick will have to go once there is one: ZoneX's own timer must + * be able to preempt a partition's, or a partition could delay the end of + * its own window by taking a long interrupt. That is not yet built -- it + * needs HCR.IMO -- and the number is chosen now so that it does not have to + * change then. */ + +#define ZX_GUEST_TIMER_PRIORITY 0xA0U + +/* How long the hypervisor deliberately dwells between two excursions, + * expressed as a divisor of the counter frequency: 20 says a fiftieth of a + * second, which is TWO of the guest's ticks at ThreadX's default rate. + * + * Two rather than one, because the claim is about a clock that a guest can + * READ: a gap shorter than a tick could be frozen or merely rounded away, + * and the difference would not be visible in the guest's own tick count. + * Two ticks is long enough that a partition whose clock was NOT frozen would + * come back to find its own time had jumped, which is exactly the + * observation temporal partitioning exists to prevent. */ + +#define ZX_DWELL_DIVISOR 50U + +static ZX_REGION zx_regions_a[1]; +static ZX_PARTITION zx_partitions[1]; +static ZX_MANIFEST zx_manifest; +static ZX_MM_LAYOUT zx_layout; +static ZX_MANIFEST_ENV zx_env; +static ZX_PARTITION_CB zx_partition_a; +static ZX_REGION zx_mmio[ZX_BOARD_MAX_MMIO_REGIONS]; +static ZX_GIC_LAYOUT zx_gic; +static ZX_GUEST_LAUNCH zx_launch; + + +/**************************************************************************/ +/* zx_describe_launch */ +/* */ +/* Filled at run time rather than being a static initialiser, because */ +/* every field is the address of a LINKER symbol and the address of a */ +/* linker symbol is not a constant expression. */ +/**************************************************************************/ + +static void zx_describe_launch(void) +{ + zx_launch.zx_launch_window_base = + zx_symbol_address(__zx_partition_a_start); + zx_launch.zx_launch_window_end = + zx_symbol_address(__zx_partition_a_end); + zx_launch.zx_launch_image_start = + zx_symbol_address(__zx_guest_a_blob_start); + zx_launch.zx_launch_image_end = + zx_symbol_address(__zx_guest_a_blob_end); + zx_launch.zx_launch_sentinel = ZX_GUEST_A_SENTINEL; + zx_launch.zx_launch_partition_id = ZX_PARTITION_A_ID; + zx_launch.zx_launch_partition_name = zx_partitions[0].zx_partition_name; + +#ifdef ZX_PREEMPT_NO_FREEZE + zx_launch.zx_launch_freeze_time = 0U; +#else + zx_launch.zx_launch_freeze_time = 1U; +#endif +} + + +/**************************************************************************/ +/* zx_build_manifest */ +/* */ +/* ONE PARTITION, ONE REGION, exactly as the cooperative image declares */ +/* them -- and that is worth stating rather than merely doing. Granting */ +/* a partition an interrupt changed NOTHING about the memory it owns: */ +/* no region for a GIC, no region for a timer, no device of any kind. */ +/* The whole of a partition's interrupt capability is system-register */ +/* state, plus one bit the hypervisor set on its behalf in a frame the */ +/* partition cannot address. */ +/* */ +/* That is the strongest form the claim could take. A partition that */ +/* needed the redistributor mapped would need it WRITABLE -- the enable, */ +/* priority and group bits are all in one frame -- and that frame also */ +/* holds the enable for PPI 26, the hypervisor's own timer. The isolation*/ +/* argument would then have a hole in it that no region set could close. */ +/**************************************************************************/ + +static void zx_build_manifest(uint32_t board_regions) +{ + zx_regions_a[0].zx_region_base = + zx_symbol_address(__zx_partition_a_start); + zx_regions_a[0].zx_region_limit = + zx_symbol_address(__zx_partition_a_end) - 1U; + zx_regions_a[0].zx_region_ap = ZX_AP_EL2_RW_GUEST_RW; + zx_regions_a[0].zx_region_xn = ZX_XN_EXECUTABLE; + zx_regions_a[0].zx_region_sh = ZX_SH_NON_SHAREABLE; + zx_regions_a[0].zx_region_attr_index = (UCHAR)ZX_ATTR_NORMAL_WB; + + zx_partitions[0].zx_partition_id = ZX_PARTITION_A_ID; + zx_partitions[0].zx_partition_name = "ThreadX guest A"; + zx_partitions[0].zx_partition_image_start = + zx_symbol_address(__zx_guest_a_blob_start); + zx_partitions[0].zx_partition_image_end = + zx_symbol_address(__zx_guest_a_blob_end); + zx_partitions[0].zx_partition_entry = + zx_symbol_address(__zx_partition_a_start) + ZX_GUEST_IMAGE_OFF_ENTRY; + zx_partitions[0].zx_partition_regions = zx_regions_a; + zx_partitions[0].zx_partition_region_count = 1U; + zx_partitions[0].zx_partition_window_ticks = 10U; + + zx_manifest.zx_manifest_partitions = zx_partitions; + zx_manifest.zx_manifest_partition_count = 1U; + zx_manifest.zx_manifest_shared = (const ZX_SHARED *)0; + zx_manifest.zx_manifest_shared_count = 0U; + zx_manifest.zx_manifest_major_frame_ticks = 10U; + + zx_board_describe_mmio_regions(zx_mmio); + + zx_env.zx_env_mmio_regions = zx_mmio; + zx_env.zx_env_mmio_region_count = board_regions; + zx_env.zx_env_attr_written_mask = 0x07U; +} + + +/**************************************************************************/ +/* zx_grant_the_clock */ +/* */ +/* EVERYTHING THE HYPERVISOR DOES SO THAT A PARTITION CAN HAVE A TICK, */ +/* in one place, because the order matters and every step of it fails */ +/* silently on its own. */ +/* */ +/* THE SYSTEM COUNTER IS STARTED FIRST. Programming CNTFRQ does not */ +/* start it -- CNTFRQ is a software-declared constant that says what */ +/* the counter WOULD run at -- and one of the two ZoneX targets leaves */ +/* its counter stopped at reset. A timer armed against a stopped */ +/* counter never expires, and a guest that blocked on it would end the */ +/* run in a harness timeout rather than a report. */ +/* */ +/* THEN THE GIC. The distributor's affinity routing and group enables, */ +/* then this core's redistributor woken out of ProcessorSleep. Nothing */ +/* can be delivered to this core until the redistributor reports its */ +/* children awake, and a configuration written into a sleeping frame is */ +/* written into a frame that is not listening. */ +/* */ +/* THEN THE PARTITION'S ONE INTERRUPT. Group 1, level-triggered, at a */ +/* priority chosen to leave room above it. This is the only INTID the */ +/* partition is given, and it is given in a frame the partition cannot */ +/* address. */ +/* */ +/* AND EVERY STEP IS READ BACK. A GIC write that landed at the wrong */ +/* offset does not fault -- it does nothing -- and a partition that */ +/* silently never receives its timer is indistinguishable from a kernel */ +/* whose scheduler does not work. Three reads at boot are cheap next */ +/* to that hour. */ +/* */ +/* Returns non-zero when everything the guest depends on is in place. */ +/**************************************************************************/ + +static uint32_t zx_grant_the_clock(void) +{ + uint32_t awake; + uint32_t running; + + zx_console_puts("\n--- the clock and the interrupt ZoneX grants a " + "partition ---\n"); + + zx_board_counter_start(); + running = zx_counter_is_running(); + + zx_note("CNTFRQ ", zx_read_cntfrq()); + zx_check("the system counter is RUNNING, not merely declared. CNTFRQ is\n" + " a software-written constant and says nothing about\n" + " whether the counter moves; this is two reads of\n" + " CNTPCT, and on a target whose counter was never\n" + " started a guest waiting on a timer would wait for ever", + running); + +#ifdef ZX_PREEMPT_NO_PPI + + zx_console_puts("\n THIS BUILD DELIBERATELY DOES NOT ENABLE THE " + "PARTITION'S PPI.\n" + " The GIC is brought up and the interrupt is left\n" + " disabled, so the guest arms a timer whose interrupt\n" + " can never be delivered. It must report that it never\n" + " ticked, and this run must report FAILED.\n"); + + awake = zx_gic_el2_init(&zx_gic); + zx_check("the redistributor woke", awake); + + zx_note("PPI enabled (must be 0)", + zx_gic_ppi_is_enabled(&zx_gic, ZX_PPI_VIRTUAL_TIMER)); + + return (running != 0U) ? awake : 0U; + +#else + + uint32_t priority_bits; + uint32_t enabled; + uint32_t group1; + uint32_t priority; + + awake = zx_gic_el2_init(&zx_gic); + + zx_note("GIC distributor", (uint32_t)zx_gic.zx_gic_dist_base); + zx_note("GIC redistributor, RD frame ", + (uint32_t)zx_gic.zx_gic_rd_base); + zx_note("GIC redistributor, SGI frame", + (uint32_t)zx_gic.zx_gic_sgi_base); + zx_check("the redistributor cleared ProcessorSleep and reports its\n" + " children awake, so this core can be delivered to at\n" + " all. Bounded rather than spun on for ever: a GIC that\n" + " never wakes has to be a report and not a silent boot", + awake); + + priority_bits = zx_gic_priority_bits(&zx_gic, ZX_PPI_VIRTUAL_TIMER); + zx_note("implemented priority bits", priority_bits); + zx_console_puts(" Only the top bits of a priority byte\n" + " exist; the rest read back as zero. Two\n" + " priorities differing only there are the\n" + " SAME priority to the hardware, and equal\n" + " priorities do not preempt -- which is\n" + " what a hypervisor tick will need to do to\n" + " a partition's.\n"); + zx_check("this GIC implements at least three priority bits, so a\n" + " hypervisor tick can be given a priority a partition's\n" + " timer cannot collide with", + (priority_bits >= 3U) ? 1U : 0U); + + zx_gic_enable_guest_ppi(&zx_gic, ZX_PPI_VIRTUAL_TIMER, + ZX_GUEST_TIMER_PRIORITY); + + enabled = zx_gic_ppi_is_enabled(&zx_gic, ZX_PPI_VIRTUAL_TIMER); + group1 = zx_gic_ppi_is_group1(&zx_gic, ZX_PPI_VIRTUAL_TIMER); + priority = zx_gic_ppi_priority(&zx_gic, ZX_PPI_VIRTUAL_TIMER); + + zx_note("granted INTID ", ZX_PPI_VIRTUAL_TIMER); + zx_note(" enabled ", enabled); + zx_note(" group 1 ", group1); + zx_note(" priority ", priority); + + zx_check("the partition's virtual-timer PPI reads back ENABLED, so the\n" + " write reached the register rather than a plausible\n" + " offset next to it", + enabled); + zx_check("and in GROUP 1, which is what the GIC delivers as an IRQ.\n" + " Group 0 arrives as an FIQ and this partition is\n" + " granted none", + group1); + zx_check("and at the priority it was given, with the bits this GIC does\n" + " not implement read back as zero", + (priority == (ZX_GUEST_TIMER_PRIORITY + & (uint32_t)(0xFFU << (8U - priority_bits)))) + ? 1U : 0U); + + zx_check("the HYPERVISOR's own timer PPI is NOT enabled. Every SGI and\n" + " PPI was disabled during bring-up before this one was\n" + " granted, because on silicon ZoneX is not the first\n" + " thing to run and an enable bit is not reset state a\n" + " hypervisor may assume -- with HCR.IMO clear, anything\n" + " left enabled by an earlier stage would be delivered to\n" + " the partition", + (zx_gic_ppi_is_enabled(&zx_gic, ZX_PPI_HYPERVISOR_TIMER) == 0U) + ? 1U : 0U); + zx_check("and neither is the PHYSICAL timer's, which a partition could\n" + " not read anyway: CNTHCTL.PL1PCTEN and PL1PCEN are\n" + " left clear on purpose", + (zx_gic_ppi_is_enabled(&zx_gic, ZX_PPI_PHYSICAL_TIMER) == 0U) + ? 1U : 0U); + + zx_console_puts( + "\n" + " NOT ONE BYTE OF THIS IS MAPPED INTO THE PARTITION. The\n" + " distributor and both redistributor frames stay at\n" + " EL2-read-write, guest-no-access, and the guest needs none of\n" + " them: its CPU interface is system registers. The frame it is\n" + " denied holds the enable bit for the interrupt above AND for the\n" + " hypervisor's own timer, which is what will end a partition's\n" + " window -- a partition able to write it could refuse to be\n" + " descheduled, and no region set would show that.\n"); + + return (running != 0U) ? awake : 0U; + +#endif +} + + +/**************************************************************************/ +/* zx_report_tick */ +/* */ +/* What the partition's interrupt handler recorded. OUTSIDE the guest's */ +/* sealed snapshot, and the report says so: these words are written by a */ +/* handler that runs on every tick and must not stop to compute a */ +/* checksum. The claims do not rest on them -- the tick count inside the */ +/* seal is tx_time_get(), and the preemption bits are progress bits, */ +/* which are sealed. These are the detail that makes a FAILING run */ +/* diagnosable. */ +/**************************************************************************/ + +static void zx_report_tick(void) +{ + zx_console_puts("\n--- what the partition's own interrupt handler " + "recorded ---\n"); + zx_note("interrupts serviced", zx_guest_mailbox_read(&zx_launch, + ZX_GD_IRQ_COUNT)); + zx_note("timer INTID seen ", zx_guest_mailbox_read(&zx_launch, + ZX_GD_TIMER_INTID)); + zx_note("unexpected INTID ", zx_guest_mailbox_read(&zx_launch, + ZX_GD_ODD_INTID)); + zx_note("spinner A loops ", zx_guest_mailbox_read(&zx_launch, + ZX_GD_SPIN_A)); + zx_note("spinner B loops ", zx_guest_mailbox_read(&zx_launch, + ZX_GD_SPIN_B)); + zx_note("sleeps completed ", zx_guest_mailbox_read(&zx_launch, + ZX_GD_WAKES)); + zx_console_puts(" These six are OUTSIDE the guest's sealed\n" + " report, because four of them are written\n" + " by a handler that runs on every tick and\n" + " must not stop to checksum anything. The\n" + " claims rest on the sealed words: the tick\n" + " count is tx_time_get(), and preemption\n" + " and time slicing are progress bits.\n"); +} + + +/**************************************************************************/ +/* zx_el2_main -- entered from zx_el2_entry.S at EL2. Does not return. */ +/**************************************************************************/ + +ZX_NORETURN void zx_el2_main(void) +{ + uint32_t board_regions; + uint32_t el2_regions; + uint32_t status; + uint32_t outcome; + uint32_t progress; + uint32_t granted; + uint32_t cycles_first = 0U; + uint32_t cycles_second = 0U; + uint64_t away_from; + uint64_t virtual_then; + uint64_t offset_before; + uint32_t excluded_for; + uint32_t credited; + uint32_t physical_gap; + uint32_t virtual_gap; + ZX_MANIFEST_FAULT fault; + + /* ---------------------------------------------------------------- */ + /* BEFORE THE FIRST PRINT. On a board whose console sits below */ + /* 0x60000000 the background map reaches it as NORMAL memory, and */ + /* Normal memory permits the gathering and reordering that corrupts */ + /* a polled UART. So HMAIR, the hypervisor's own MMIO regions and */ + /* HSCTLR.M come up FIRST. See docs/decisions.md D15. */ + /* ---------------------------------------------------------------- */ + + el2_regions = zx_el2_region_count(); + board_regions = zx_board_mmio_region_count(); + + if (el2_regions >= board_regions) + { + zx_mair_program(); + + if (board_regions > 0U) + { + zx_board_program_mmio_regions(0U); + } + + zx_el2_mpu_enable(); + } + + zx_board_init(); + + zx_console_puts( + "\n" + "=========================================================\n" + " Eclipse ThreadX ZoneX -- a PREEMPTIVE ThreadX guest\n" + " " ZX_PLATFORM_NAME "\n" + " Armv8-R AArch32, EL2, PMSAv8-R at both stages\n" + "=========================================================\n" + "\n" + " The image next door showed a real kernel BOOT and SCHEDULE\n" + " inside a stage-2 window, cooperatively. This one gives that\n" + " partition a CLOCK.\n" + "\n" + " ZoneX starts the system counter, takes the whole GIC, and\n" + " enables ONE interrupt for the partition -- the virtual timer's\n" + " PPI -- in a redistributor frame the partition cannot address.\n" + " The guest brings up only its own CPU interface, which is system\n" + " registers and touches no device, and is then preempted by its\n" + " own kernel underneath stage 2.\n" + "\n" + " Between excursions the hypervisor moves CNTVOFF, so the time it\n" + " spends here does not happen to the partition.\n"); + + zx_fault_record_reset(zx_el2_fault_record()); + zx_guest_console_reset(); + + zx_console_puts("\n--- the EL2 MPU is already on ---\n"); + zx_note("HSCTLR now", zx_read_hsctlr()); + zx_check("HSCTLR.M is set, so the EL2 MPU is live", + ((zx_read_hsctlr() & ZX_HSCTLR_M) != 0U) ? 1U : 0U); + + zx_console_puts("\n--- identity, as the core reports it ---\n"); + zx_note("MIDR ", zx_read_midr()); + zx_note("EL2 regions ", el2_regions); + zx_note("EL1 regions ", zx_el1_region_count()); + zx_note("ICH_VTR ", zx_read_ich_vtr()); + zx_console_puts(" The virtual CPU interface, which this\n" + " image does NOT use: with HCR.IMO clear a\n" + " physical interrupt taken while a\n" + " partition runs is delivered straight to\n" + " EL1, and the hypervisor is not involved\n" + " in a tick at all. The List Registers are\n" + " reported because the phase that sets IMO\n" + " will need every one of them.\n"); + zx_board_report(); + + if (el2_regions == 0U) + { + zx_console_puts("\n *** HMPUIR reports ZERO EL2 regions, so this " + "target cannot host\n *** stage 2 at all. Refusing " + "to go on.\n" + "\nZONEX RESULT: FAILED -- the target cannot host " + "stage 2\n"); + zx_console_exit(1U); + } + + /* ---------------------------------------------------------------- */ + /* The manifest, the plan, and the loader. */ + /* ---------------------------------------------------------------- */ + + zx_build_manifest(board_regions); + zx_describe_launch(); + zx_board_gic_layout(&zx_gic); + zx_env.zx_env_region_budget = el2_regions; + + status = zx_manifest_verify(&zx_manifest, &zx_env, &fault); + + zx_console_puts("\n--- the manifest, checked before anything is " + "programmed ---\n"); + zx_note("zx_manifest_verify", status); + + if (status != ZX_MANIFEST_SUCCESS) + { + zx_note(" offending partition", fault.zx_fault_partition); + zx_note(" offending region ", fault.zx_fault_region); + zx_check("the manifest passes every rule", 0U); + zx_console_puts("\nZONEX RESULT: FAILED\n"); + zx_console_exit(zx_probe_failures()); + } + + zx_check("the manifest passes every rule", 1U); + zx_check("and it grants the partition NO device region at all, even now\n" + " that the partition has an interrupt -- a partition's\n" + " whole interrupt capability is system-register state", + (zx_partitions[0].zx_partition_region_count == 1U) ? 1U : 0U); + + status = zx_mm_plan(&zx_manifest, board_regions, el2_regions, &zx_layout); + zx_note("zx_mm_plan", status); + zx_check("the layout fits this part's region budget", + (status == ZX_MANIFEST_SUCCESS) ? 1U : 0U); + + if (status != ZX_MANIFEST_SUCCESS) + { + zx_console_puts("\nZONEX RESULT: FAILED\n"); + zx_console_exit(zx_probe_failures()); + } + + zx_mm_report(&zx_layout, &zx_manifest); + + zx_partition_reset(&zx_partition_a, &zx_partitions[0]); + + status = zx_partition_prepare(&zx_partition_a); + zx_note("zx_partition_prepare", status); + zx_check("the loader located the window the entry point is in, and the\n" + " image fits THAT window", + (status == ZX_MANIFEST_SUCCESS) ? 1U : 0U); + + if (status != ZX_MANIFEST_SUCCESS) + { + zx_partition_report(&zx_partition_a); + zx_console_puts("\nZONEX RESULT: FAILED\n"); + zx_console_exit(zx_probe_failures()); + } + + if (zx_guest_image_check(&zx_launch) == 0U) + { + zx_console_puts("\n *** REFUSING to launch this image. It is either\n" + " *** absent, truncated, or built for a different\n" + " *** window.\n" + "\nZONEX RESULT: FAILED\n"); + zx_console_exit(zx_probe_failures()); + } + + zx_partition_report(&zx_partition_a); + + /* ---------------------------------------------------------------- */ + /* The region set, and stage 2 ON for every pass in this image. */ + /* */ + /* What stage 2 COSTS is the other image's question and is measured */ + /* there, by a pair that differs in HCR.VM and nothing else. Here */ + /* stage 2 is simply on, because the claim is about a preemptive */ + /* kernel UNDER it and a pass with it off would prove nothing about */ + /* that. */ + /* ---------------------------------------------------------------- */ + + { + uint32_t index = + (uint32_t)zx_layout.zx_layout_partition_first[ZX_PARTITION_A_INDEX]; + + zx_stage2_region_program(index, &zx_regions_a[0]); + + zx_console_puts("\n--- the region set, programmed and read back ---\n"); + zx_note("partition A region index", index); + zx_check("partition A's window reads back as the manifest declared it", + zx_stage2_region_matches(index, &zx_regions_a[0])); + + zx_stage2_enable_set( + zx_mm_partition_mask(&zx_layout, ZX_PARTITION_A_INDEX)); + zx_note("HPRENR now", zx_hprenr_read()); + } + + zx_console_puts("\n the ungranted granule immediately after the window " + "is at "); + zx_console_puthex(zx_symbol_address(__zx_partition_a_hole_start)); + zx_console_puts("\n"); + + /* ---------------------------------------------------------------- */ + /* What ZoneX takes over from the guest's boot path. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n--- what ZoneX takes over from the guest's boot path " + "---\n"); + zx_el2_prepare_guest_el1(zx_board_counter_hz()); + zx_note("CNTFRQ after ", zx_read_cntfrq()); + zx_check("CNTFRQ is programmed, because it is writable only at the\n" + " highest implemented exception level -- and a guest\n" + " deriving a tick interval from a zero divides by zero,\n" + " which is exactly what this image asks it to do", + (zx_read_cntfrq() == zx_board_counter_hz()) ? 1U : 0U); + + granted = zx_grant_the_clock(); + + zx_el2_guest_time_reset(); + zx_note("CNTVOFF now, low word", + (uint32_t)(zx_el2_guest_time_offset() & 0xFFFFFFFFU)); + zx_console_puts(" The partition's virtual counter now reads\n" + " zero. CNTVOFF is writable only at EL2, so\n" + " where a partition's clock STARTS is the\n" + " hypervisor's to decide -- and so is\n" + " whether it advances while the partition is\n" + " not running.\n"); + + zx_stage2_enable(); + zx_note("HCR now", zx_read_hcr()); + zx_check("HCR.VM is set, so stage 2 applies to EL0/EL1 for every pass\n" + " in this image", + ((zx_read_hcr() & ZX_HCR_VM) != 0U) ? 1U : 0U); + zx_check("HCR.IMO is CLEAR, so a physical interrupt taken while the\n" + " partition runs is delivered STRAIGHT to EL1 -- no\n" + " injection, no List Register, and no EL2 work per tick.\n" + " The cost of that is stated in the verdict below", + ((zx_read_hcr() & ZX_HCR_IMO) == 0U) ? 1U : 0U); + zx_check("HCR.TGE is clear, so the guest keeps its own exception\n" + " vectors -- without which its IRQ vector could not be\n" + " the thing that runs", + ((zx_read_hcr() & ZX_HCR_TGE) == 0U) ? 1U : 0U); + + zx_pmu_enable(); + + /* ---------------------------------------------------------------- */ + /* A WARM-UP EXCURSION, measured and discarded. */ + /* */ + /* On a real core the FIRST execution of anything is not */ + /* comparable to the second: branch prediction and the instruction */ + /* path are cold. Measuring a pair whose members differ in order */ + /* as well as in configuration measures neither -- which is not a */ + /* caution but a result, and it was paid for by the cooperative */ + /* image reporting stage 2 as 28% FASTER than no stage 2 before */ + /* this pass existed. */ + /* */ + /* It is also the first excursion that proves the tick works, so a */ + /* target whose GIC or counter is wrong fails HERE, before any */ + /* number has been printed that somebody might quote. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n--- a warm-up excursion, measured and discarded ---\n"); + + zx_guest_image_load(&zx_partition_a.zx_partition_load); + zx_guest_hand_over(&zx_launch, 0U, ZX_GO_QUIET | ZX_GO_TICK); + zx_partition_loaded(&zx_partition_a); + + zx_check("the partition may be entered now that its image is loaded", + zx_partition_enter(&zx_partition_a)); + + { + uint32_t warm_cycles = 0U; + + outcome = zx_guest_run(&zx_launch, &zx_partition_a, &warm_cycles); + zx_note("warm-up excursion, cycles (discarded)", warm_cycles); + zx_note("outcome", outcome); + } + + progress = zx_guest_report(&zx_launch); + zx_report_tick(); + + zx_check("the warm-up guest reached its own verdict, which rules out the\n" + " loader, the entry point, the GIC and the counter\n" + " before anything is measured", + ((progress & (uint32_t)ZX_GP_FINISHED) != 0U) ? 1U : 0U); + + /* ---------------------------------------------------------------- */ + /* PASS 1 of 3: quiet, preemptive, measured. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n=====================================================" + "====\n" + " PASS 1 of 3: the preemptive guest, QUIET\n" + "=====================================================" + "====\n" + " THE CONSOLE IS OFF FOR THIS PASS AND THE NEXT. A\n" + " guest's console is one hypercall PER CHARACTER through\n" + " a polled UART, which on silicon is three orders of\n" + " magnitude more work than everything else the guest\n" + " does -- so a loud pair could only bound a difference\n" + " from above and never resolve one. Same image, one\n" + " word in the mailbox.\n"); + + zx_guest_image_load(&zx_partition_a.zx_partition_load); + zx_guest_hand_over(&zx_launch, 0U, ZX_GO_QUIET | ZX_GO_TICK); + + zx_check("the partition may be entered again after yielding", + zx_partition_enter(&zx_partition_a)); + + outcome = zx_guest_run(&zx_launch, &zx_partition_a, &cycles_first); + progress = zx_guest_report(&zx_launch); + zx_report_tick(); + + zx_check("the guest yielded rather than faulting", + (outcome == ZX_RUN_YIELDED) ? 1U : 0U); + zx_check("and nothing has printed yet, so the quiet option really is\n" + " quiet and the cycles below are the guest's own work", + (zx_guest_console_characters() == 0U) ? 1U : 0U); + + /* ---------------------------------------------------------------- */ + /* THE GAP. The whole of the frozen-clock claim is measured here. */ + /* */ + /* Between the two excursions the hypervisor does a great deal of */ + /* work. Physical time passes, and a great deal of it on silicon: */ + /* every line below is a polled UART transaction. The partition's */ + /* virtual time must not move by any of it. */ + /* */ + /* Measured as two 64-bit deltas rather than by asking the guest, */ + /* because the guest is not running: the point of the claim is that */ + /* a partition CANNOT observe this interval, so the evidence has to */ + /* come from the side that can. */ + /* ---------------------------------------------------------------- */ + + /* WHERE THE PARTITION STOPPED, taken BEFORE anything below can move it. + The resume further down updates both of these -- that is what a + resume IS -- so reading them afterwards would compare the partition's + restart against itself and report a gap of zero on every build, + including the one that has no freeze at all. */ + + away_from = zx_el2_guest_time_suspended_at(); + virtual_then = zx_el2_guest_virtual_when_suspended(); + offset_before = zx_el2_guest_time_offset(); + + zx_console_puts("\n--- the gap between two excursions ---\n" + " The partition is not running. The hypervisor spends a\n" + " DELIBERATE interval here, measured on the physical\n" + " counter, standing in for the work a real hypervisor\n" + " does between two windows -- and, once there are two\n" + " partitions, for the other partition's window.\n" + "\n" + " IT IS DELIBERATE BECAUSE THE FIRST VERSION WAS NOT.\n" + " That one measured the gap across a block of console\n" + " output, on the grounds that a polled UART is millions\n" + " of cycles. It is -- on silicon. On a model whose\n" + " console is semihosting it costs no simulated time at\n" + " all, so the physical counter advanced by ZERO and the\n" + " virtual counter did too, for the wrong reason. The\n" + " check was green, and so was the build that\n" + " deliberately breaks the freeze.\n"); + + zx_el2_dwell(zx_board_counter_hz() / ZX_DWELL_DIVISOR); + + /* THE FREEZE IS APPLIED HERE, ON THE WAY BACK IN, AND THAT IS WHERE IT + HAS TO BE MEASURED. + * + * CNTVOFF is a static offset: while a partition is suspended the + * physical counter keeps moving and the offset does not, so the + * subtraction drifts for exactly as long as the partition is away. What + * closes the gap is the write on RESUME, which jumps CNTVOFF forward by + * everything that elapsed. Sampling the virtual counter before that + * write therefore measures the drift and not the correction, which is + * what the first version of this check did -- it reported the partition's + * clock advancing by the whole gap on a run where the freeze was working + * perfectly. + * + * So the two samples bracket the instants the PARTITION experiences: the + * moment it stopped, and the moment it is about to start again. Between + * them the hypervisor did a measurable amount of work, and the partition + * is entitled to see none of it. + * + * The build that breaks the freeze skips this exactly as it skips the + * one inside the excursion, so it is the MECHANISM that is switched off + * rather than the measurement being rearranged around it. */ + + if (zx_launch.zx_launch_freeze_time != 0U) + { + zx_el2_guest_time_resume(); + } + else + { + zx_el2_guest_time_resume_uncredited(); + } + + /* The two instants the PARTITION experiences: the moment it stopped, + recorded by the launcher, and now -- the moment it is about to start + again. Physical time separates them; the partition's own clock must + not. */ + + /* THE INTERVAL, as the mechanism itself measured it: the counter at the + instant the partition stopped, and at the instant it was given the + core back. Both taken by the freeze, so the comparison below is not + measuring the cost of measuring. */ + + excluded_for = (uint32_t)((zx_el2_guest_time_resumed_at() - away_from) + & 0xFFFFFFFFU); + credited = (uint32_t)((zx_el2_guest_time_offset() - offset_before) + & 0xFFFFFFFFU); + + /* And the same interval as an OUTSIDE observer measures it afterwards, + which is longer by however much the reads themselves cost. */ + + physical_gap = (uint32_t)((zx_read_cntpct() - away_from) & 0xFFFFFFFFU); + virtual_gap = (uint32_t)((zx_el2_guest_virtual_count() - virtual_then) + & 0xFFFFFFFFU); + + zx_note("counts the partition was excluded from", excluded_for); + zx_note("counts credited to CNTVOFF ", credited); + zx_note("the same interval measured afterwards ", physical_gap); + zx_note("the partition's virtual clock moved by", virtual_gap); + + zx_check("there was a REAL interval for the partition to be excluded\n" + " from. Without this the check below would be satisfied\n" + " by nothing having happened, which is how it was\n" + " satisfied the first time it was written: the gap was\n" + " measured across console output, and on a model whose\n" + " console is semihosting that costs no simulated time", + (excluded_for > 0U) ? 1U : 0U); + + zx_check("CNTVOFF was credited with EXACTLY the interval the partition\n" + " was away for -- not approximately, and not according\n" + " to the arithmetic that computed it: the offset is READ\n" + " BACK out of the register and compared against the two\n" + " counter reads the freeze took, so a CNTVOFF write that\n" + " had silently not landed would fail here", + (credited == excluded_for) ? 1U : 0U); + + zx_check("so the partition's own clock cannot have advanced by anything\n" + " it could observe: the residue below is a fraction of a\n" + " thousandth of the interval, and it is the HYPERVISOR'S\n" + " OWN counter reads between the resume and this check --\n" + " on the S32Z280 a CNTPCT read crosses into an 8 MHz\n" + " clock domain and costs real time. This is why a guest\n" + " gets the VIRTUAL timer: there is no CNTPOFF on this\n" + " architecture, so physical time cannot be given a\n" + " per-partition origin at all. Written as a DIVISION and\n" + " not a multiplication: a residue times a thousand\n" + " overflows a 32-bit word once the gap passes four\n" + " million counts, and it would wrap to a small number and\n" + " pass", + (virtual_gap < (physical_gap / 1000U)) ? 1U : 0U); + + /* ---------------------------------------------------------------- */ + /* PASS 2 of 3: the same work again, and the only honest pair. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n=====================================================" + "====\n" + " PASS 2 of 3: the same guest, the same work, QUIET\n" + "=====================================================" + "====\n" + " Identical to pass 1 in every respect: same image, same\n" + " options, same console traffic (none), stage 2 in force\n" + " for both. The difference between the two figures is\n" + " therefore REPEATABILITY and not the cost of anything,\n" + " which is the determinism claim that one partition on\n" + " one core can actually support.\n"); + + zx_guest_image_load(&zx_partition_a.zx_partition_load); + zx_guest_hand_over(&zx_launch, 0U, ZX_GO_QUIET | ZX_GO_TICK); + + zx_check("the partition may be entered a third time", + zx_partition_enter(&zx_partition_a)); + + outcome = zx_guest_run(&zx_launch, &zx_partition_a, &cycles_second); + progress = zx_guest_report(&zx_launch); + zx_report_tick(); + + zx_check("the guest did the same work again and reached the same verdict", + (((progress & (uint32_t)ZX_GP_FINISHED) != 0U) + && (outcome == ZX_RUN_YIELDED)) ? 1U : 0U); + + /* ---------------------------------------------------------------- */ + /* PASS 3 of 3: loud, and the demonstration. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n=====================================================" + "====\n" + " PASS 3 of 3: the same guest, LOUD\n" + "=====================================================" + "====\n" + " Everything below the guest's own output is judged on\n" + " THIS pass. Each line is tagged by the partition ZoneX\n" + " scheduled rather than by the guest, which is why a\n" + " guest cannot claim to be another one.\n"); + + zx_guest_image_load(&zx_partition_a.zx_partition_load); + zx_guest_hand_over(&zx_launch, 0U, ZX_GO_TICK); + + zx_check("the partition may be entered a fourth time", + zx_partition_enter(&zx_partition_a)); + + { + uint32_t loud_cycles = 0U; + + outcome = zx_guest_run(&zx_launch, &zx_partition_a, &loud_cycles); + zx_note("outcome", outcome); + zx_note("loud excursion, cycles", loud_cycles); + } + + progress = zx_guest_report(&zx_launch); + zx_report_tick(); + zx_partition_report(&zx_partition_a); + + if (outcome == ZX_RUN_FAULTED) + { + zx_fault_report(zx_el2_fault_record()); + } + + /* ---------------------------------------------------------------- */ + /* The demonstrandum. */ + /* ---------------------------------------------------------------- */ + +#ifdef ZX_PREEMPT_NO_PPI + + zx_console_puts("\n=====================================================" + "====\n" + " NEGATIVE BUILD: the partition's PPI was never enabled\n" + "=====================================================" + "====\n" + " The counter runs, the GIC is awake, and the guest arms\n" + " its virtual timer against a comparator that will\n" + " expire -- but the interrupt is disabled in a frame the\n" + " guest cannot reach, so nothing is ever delivered.\n" + "\n" + " It must therefore report that it never ticked, and\n" + " this run must report FAILED. That is what proves the\n" + " positive run's tick checks are capable of failing --\n" + " and it proves something else that matters more: the\n" + " guest could NOT enable its own interrupt. A partition\n" + " that could reach the redistributor would simply have\n" + " turned it on.\n"); + + zx_check("the counter and the GIC both came up, so nothing below is\n" + " explained by either of them failing (this check is\n" + " EXPECTED TO PASS)", + granted); + zx_check("the guest saw its virtual counter advance, so this is not the\n" + " counter and the failure below is the GIC (this check\n" + " is EXPECTED TO PASS)", + ((progress & (uint32_t)ZX_GP_COUNTING) != 0U) ? 1U : 0U); + zx_check("no tick reached the partition (this check is EXPECTED TO FAIL,\n" + " and its failing is the evidence)", + ((progress & (uint32_t)ZX_GP_TICKING) != 0U) ? 1U : 0U); + +#elif defined(ZX_PREEMPT_NO_FREEZE) + + zx_console_puts("\n=====================================================" + "====\n" + " NEGATIVE BUILD: CNTVOFF is left alone across the gap\n" + "=====================================================" + "====\n" + " Everything else is identical. The hypervisor simply\n" + " does not advance CNTVOFF while the partition is not\n" + " running, so the partition's virtual clock counts every\n" + " cycle the hypervisor spent printing between passes.\n" + "\n" + " The frozen-clock check above must therefore FAIL, and\n" + " this run must report FAILED. A claim whose pass\n" + " condition is 'the guest could not tell' has to be seen\n" + " to fail, or a green run shows only that the\n" + " arithmetic did not crash.\n"); + + zx_check("the counter and the GIC both came up, so this build breaks\n" + " nothing they are responsible for (expected to pass)", + granted); + zx_check("the guest still ticked and was still preempted, so this build\n" + " breaks the CLOCK and not the interrupt (expected to\n" + " pass)", + ((progress & (uint32_t)(ZX_GP_TICKING | ZX_GP_PREEMPTED)) + == (uint32_t)(ZX_GP_TICKING | ZX_GP_PREEMPTED)) ? 1U : 0U); + +#else + + zx_console_puts("\n=====================================================" + "====\n" + " A REAL THREADX KERNEL, PREEMPTED UNDER STAGE 2\n" + "=====================================================" + "====\n"); + + zx_check("the guest yielded rather than faulting", + (outcome == ZX_RUN_YIELDED) ? 1U : 0U); + zx_check("it reached bsp_main and entered its kernel, so the loader and\n" + " the entry point are not what is being tested here", + ((progress & (uint32_t)(ZX_GP_BSP_MAIN | ZX_GP_KERNEL_ENTERED)) + == (uint32_t)(ZX_GP_BSP_MAIN | ZX_GP_KERNEL_ENTERED)) + ? 1U : 0U); + zx_check("the partition's own VIRTUAL counter advances, which is a\n" + " different register from the one ZoneX checked: it is\n" + " the physical counter seen past CNTVOFF, and it is the\n" + " one the partition can actually reach", + ((progress & (uint32_t)ZX_GP_COUNTING) != 0U) ? 1U : 0U); + zx_check("A TICK REACHED THE PARTITION. End to end: ZoneX enabled the\n" + " PPI in a redistributor the guest cannot address, the\n" + " guest enabled its own CPU interface, the GIC delivered\n" + " to EL1 and not to EL2, the guest's OWN vector table\n" + " sent it into the port's context save, and\n" + " _tx_timer_interrupt advanced tx_time_get()", + ((progress & (uint32_t)ZX_GP_TICKING) != 0U) ? 1U : 0U); + zx_check("A THREAD THAT NEVER YIELDS WAS DISPLACED. The spinner makes\n" + " no kernel call in its loop, so there is no point at\n" + " which the kernel could have taken the core from it\n" + " cooperatively. Something did anyway", + ((progress & (uint32_t)ZX_GP_PREEMPTED) != 0U) ? 1U : 0U); + zx_check("TWO EQUAL-PRIORITY THREADS WERE TIME SLICED. Neither can\n" + " yield to the other and neither ever does, so the only\n" + " thing that can have interleaved them is the kernel\n" + " counting their slice down on a tick", + ((progress & (uint32_t)ZX_GP_TIMESLICED) != 0U) ? 1U : 0U); + zx_check("the partition received ONLY the interrupt it was granted -- no\n" + " unexpected INTID reached it, so it did not see the\n" + " hypervisor's own timer or anything else", + (zx_guest_mailbox_read(&zx_launch, ZX_GD_ODD_INTID) == 0U) + ? 1U : 0U); + zx_check("the guest's own vectors report no stage-1 fault, so a kernel\n" + " taking interrupts did nothing its own MPU refused", + (zx_guest_mailbox_read(&zx_launch, ZX_GD_STAGE1) + == ZX_GS_NONE) ? 1U : 0U); + zx_check("the guest published its own verdict, and it is PASSED", + (zx_guest_mailbox_read(&zx_launch, ZX_GD_VERDICT) + == ZX_GV_PASSED) ? 1U : 0U); + zx_check("the GIC and the counter were both brought up successfully", + granted); + + zx_console_puts("\n--- the guest printed through the hypervisor ---\n"); + zx_note("characters forwarded", zx_guest_console_characters()); + zx_check("and no character arrived while NO partition was attached", + (zx_guest_console_orphans() == 0U) ? 1U : 0U); + +#endif + + /* ---------------------------------------------------------------- */ + /* Repeatability. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n--- two identical excursions ---\n"); + + if (zx_pmu_is_running() == 0U) + { + zx_console_puts(" the PMU cycle counter is not advancing, so no\n" + " timing is reported. A number from a counter that\n" + " is not running would be a very convincing wrong\n" + " answer.\n"); + } + else + { + uint32_t spread = (cycles_second >= cycles_first) + ? (cycles_second - cycles_first) + : (cycles_first - cycles_second); + + zx_note("first quiet excursion, cycles", cycles_first); + zx_note("second quiet excursion, cycles", cycles_second); + zx_note("spread ", spread); + + zx_console_puts( + "\n" + " WHAT THE PAIR IS. Two excursions of one image doing one\n" + " program's worth of work with its console suppressed, ERET to\n" + " hand-back, with stage 2 in force for both and a tick arriving\n" + " throughout both. They differ in NOTHING, which is what makes\n" + " the spread a measurement rather than a comparison.\n" + "\n" + " WHAT IT IS NOT. It is not the cost of an interrupt, and this\n" + " image deliberately does not offer one. The obvious pair --\n" + " cooperative against preemptive -- differs in the tick AND in\n" + " the fact that the preemptive guest spends three ticks\n" + " deliberately waiting, so its difference would be dominated by\n" + " the waiting and quoted as the cost of an interrupt. That is\n" + " the same error as measuring two passes that differ in\n" + " execution order and calling the difference stage 2, which\n" + " this suite has already made once.\n" + "\n" + " ON A FUNCTIONAL MODEL BOTH NUMBERS MEAN NOTHING. The FVP does\n" + " not model timing; they are printed anyway so that the\n" + " measurement path is exercised on every run. The number to\n" + " quote comes from silicon.\n"); + } + + /* ---------------------------------------------------------------- */ + /* Verdict. The exact strings are what the runners grep for. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n=====================================================" + "====\n" + " what this run does and does not prove\n" + "=====================================================" + "====\n" + " " ZX_PLATFORM_NAME "\n" + "\n" + " PROVED, if the checks above are green: a real ThreadX\n" + " kernel is PREEMPTED inside a stage-2 confined window by\n" + " an interrupt the hypervisor granted it and that it\n" + " could not have granted itself; it receives that one and\n" + " no other; and its clock does not advance while it is\n" + " not running.\n" + "\n" + " NOT PROVED, and the omission is deliberate rather than\n" + " pending: this is not TIME PARTITIONING. ZoneX cannot\n" + " yet END a partition's window, because HCR.IMO is CLEAR\n" + " -- every physical interrupt taken while a partition\n" + " runs goes to EL1, including the hypervisor's own timer.\n" + " Setting IMO routes them all to EL2 instead, and every\n" + " guest interrupt then has to be INJECTED through one of\n" + " this core's four List Registers. That is a change to\n" + " the hypervisor and to no guest, which is why the shape\n" + " here is worth having first.\n" + "\n" + " NOT PROVED: anything about timing on a functional\n" + " model, and anything about more than one partition or\n" + " more than one logical core.\n"); + + zx_console_puts("\n checks failed: "); + zx_console_putdec(zx_probe_failures()); + zx_console_puts("\n"); + + if (zx_probe_failures() == 0U) + { + zx_console_puts("\nZONEX RESULT: ALL CHECKS PASSED\n"); + } + else + { + zx_console_puts("\nZONEX RESULT: FAILED\n"); + } + + zx_console_exit(zx_probe_failures()); +} diff --git a/examples/common/zx_probe.h b/examples/common/zx_probe.h index 61abcbd..89fb6c2 100644 --- a/examples/common/zx_probe.h +++ b/examples/common/zx_probe.h @@ -138,6 +138,38 @@ void zx_board_report(void); ZX_NODISCARD uint32_t zx_board_counter_hz(void); +/* START this board's system counter, if starting it is a thing this board + * needs. + * + * PROGRAMMING CNTFRQ DOES NOT START THE COUNTER, and the two are separate + * hooks because on the two ZoneX targets they have opposite answers. The + * Armv8-R AEM FVP leaves its counter STOPPED at reset and documents that + * firmware is expected to start it, through a memory-mapped counter control + * frame; the S32Z280's runs out of reset, clocked through the RTU's own + * divider, and has no frame for a hypervisor to write. + * + * IT IS EL2'S JOB EITHER WAY. The system counter is one per system: it is + * not a partition's to start, and a partition that could start or stop it + * would be deciding how fast time ran for every other partition. This is the + * same division of labour as the GIC, and it arrives at the same place from a + * different direction. + * + * Whether it worked is NOT this function's answer. zx_counter_is_running + * reads the counter twice and says whether it moved, which is the question + * that matters and is answered identically on both boards. */ + +void zx_board_counter_start(void); + +/* Where this board's GICv3 frames are. + * + * The Cortex-R52 layer knows the register offsets inside a GICv3, which are + * architectural; it does not and must not know where the frames sit, which is + * a board fact. A board that had no GIC at all would leave the structure + * zeroed and zx_gic_el2_init would refuse -- which is a reported failure + * rather than three writes into address zero. */ + +void zx_board_gic_layout(ZX_GIC_LAYOUT *layout_ptr); + /**************************************************************************/ /* Symbols the linker script defines */ /**************************************************************************/ diff --git a/examples/fvp_baser_aemv8r/CMakeLists.txt b/examples/fvp_baser_aemv8r/CMakeLists.txt index 4f45022..6987271 100644 --- a/examples/fvp_baser_aemv8r/CMakeLists.txt +++ b/examples/fvp_baser_aemv8r/CMakeLists.txt @@ -221,6 +221,7 @@ if(ZX_THREADX_SOURCE_DIR) function(zx_add_one_partition target_name) add_executable(${target_name} EXCLUDE_FROM_ALL ${ZX_COMMON_DIR}/zx_one_partition.c + ${ZX_COMMON_DIR}/zx_guest_launch.c ${ZX_COMMON_DIR}/zx_report.c ${ZX_COMMON_DIR}/zx_guest_image.S ${ZX_FVP_DIR}/zx_board.c @@ -297,6 +298,83 @@ if(ZX_THREADX_SOURCE_DIR) zx_add_one_partition(zx_one_partition_granted.elf) target_compile_definitions(zx_one_partition_granted.elf PRIVATE ZX_ONE_PROBE_GRANTED) + + ########################################################################### + # The PREEMPTIVE partition: the same guest, granted a clock + ########################################################################### + # + # A separate image rather than another build of zx_one_partition.c, and + # the reason is what each of them MEASURES. The cooperative image runs a + # pair of passes that differ in HCR.VM and nothing else, so its number is + # the cost of stage 2; adding a tick to it would have put a third variable + # into that pair. This one keeps stage 2 on for every pass and measures + # two identical excursions instead, which is a repeatability number. + # + # They share the loader -- zx_guest_launch.c -- and they share the GUEST, + # byte for byte: whether a partition has a clock is a word in the mailbox, + # not a build option, so the image that demonstrates isolation and the + # image that demonstrates preemption load the same blob. + function(zx_add_preemptive target_name) + add_executable(${target_name} EXCLUDE_FROM_ALL + ${ZX_COMMON_DIR}/zx_preemptive_partition.c + ${ZX_COMMON_DIR}/zx_guest_launch.c + ${ZX_COMMON_DIR}/zx_report.c + ${ZX_COMMON_DIR}/zx_guest_image.S + ${ZX_FVP_DIR}/zx_board.c + ) + + add_dependencies(${target_name} zx_guest_a) + + target_include_directories(${target_name} PRIVATE + ${ZX_FVP_DIR} + ${ZX_COMMON_DIR} + ) + + set_source_files_properties(${ZX_COMMON_DIR}/zx_guest_image.S + PROPERTIES + COMPILE_OPTIONS "-Wa,-I${ZX_GUEST_A_BUILD}" + OBJECT_DEPENDS "${ZX_GUEST_A_BLOB}" + ) + + target_link_libraries(${target_name} PRIVATE + eclipse-threadx::zonex + zonex::warnings + ) + + target_link_options(${target_name} PRIVATE + -T${ZX_FVP_DIR}/zx_one_partition.lds + -nostartfiles + -Wl,-Map=${target_name}.map + ${ZX_LINK_QUIET_RWX} + ) + + set_target_properties(${target_name} PROPERTIES + LINK_DEPENDS ${ZX_FVP_DIR}/zx_one_partition.lds + ) + endfunction() + + # The image the suite runs: a real ThreadX kernel preempted by a timer + # interrupt inside one stage-2 confined partition. + zx_add_preemptive(zx_preemptive.elf) + + # THE TWO NEGATIVE VERIFICATIONS, and they break different halves. + # + # Without the PPI enabled, the guest arms a timer whose interrupt can + # never be delivered -- and cannot enable it itself, which is the isolation + # claim showing up as a test result rather than as a sentence. It must + # report that it never ticked. + zx_add_preemptive(zx_preemptive_no_ppi.elf) + target_compile_definitions(zx_preemptive_no_ppi.elf PRIVATE + ZX_PREEMPT_NO_PPI) + + # Without the CNTVOFF bookkeeping, the partition's clock counts every + # cycle the hypervisor spent between two excursions. A claim whose pass + # condition is "the guest could not tell it was descheduled" has to be + # seen to fail, or a green run shows only that the arithmetic did not + # crash. + zx_add_preemptive(zx_preemptive_no_freeze.elf) + target_compile_definitions(zx_preemptive_no_freeze.elf PRIVATE + ZX_PREEMPT_NO_FREEZE) else() message(STATUS "ZoneX: ZX_THREADX_SOURCE_DIR is empty, so the ThreadX guest images " @@ -381,6 +459,35 @@ if(ZX_FVP_BASER_AEMV8R) --fvp ${ZX_FVP_BASER_AEMV8R} --expect fail) + add_test(NAME zx-fvp-preemptive + COMMAND ${Python3_EXECUTABLE} ${ZX_FVP_RUNNER} + --elf $ + --fvp ${ZX_FVP_BASER_AEMV8R}) + + # Both must report FAILED. Same rule as every other negative + # build here: one that starts PASSING means the check it was + # built to violate has stopped detecting the violation. + foreach(zx_negative no_ppi no_freeze) + add_test(NAME zx-fvp-preemptive-${zx_negative} + COMMAND ${Python3_EXECUTABLE} ${ZX_FVP_RUNNER} + --elf $ + --fvp ${ZX_FVP_BASER_AEMV8R} + --expect fail) + endforeach() + + add_custom_target(zx-run-preemptive + COMMAND ${ZX_FVP_BASER_AEMV8R} + -C cluster0.NUM_CORES=1 + -C bp.vis.disable_visualisation=1 + -C bp.terminal_0.start_telnet=0 + -C bp.pl011_uart0.out_file=- + -C bp.pl011_uart0.unbuffered_output=1 + -a $ + DEPENDS zx_preemptive.elf + USES_TERMINAL + COMMENT "Running the ZoneX preemptive-partition image on FVP_BaseR_AEMv8R..." + ) + add_custom_target(zx-run-one-partition COMMAND ${ZX_FVP_BASER_AEMV8R} -C cluster0.NUM_CORES=1 diff --git a/examples/fvp_baser_aemv8r/guest_a/CMakeLists.txt b/examples/fvp_baser_aemv8r/guest_a/CMakeLists.txt index 08dceb6..cd6a189 100644 --- a/examples/fvp_baser_aemv8r/guest_a/CMakeLists.txt +++ b/examples/fvp_baser_aemv8r/guest_a/CMakeLists.txt @@ -72,15 +72,35 @@ set(GUEST_DIR ${CMAKE_CURRENT_LIST_DIR}) # exists to defer to, and it was written that way # from the start of the Cortex-R52 port. # tx_initialize_low_level.S publishes the system stack and first free -# address. Built WITHOUT TX_R52_USE_THREADX_IRQ, -# so it creates no tick and calls no board_init: -# this guest takes no interrupts. +# address. Built WITH TX_R52_USE_THREADX_IRQ, so +# it calls board_init -- which the ZoneX guest +# support supplies -- and so that entry.S routes +# the EL1 IRQ vector into _tx_thread_context_save. +# The guest's OWN vector table is still what VBAR +# points at; its IRQ slot is one branch into the +# port's vector body, so the interrupt entry +# sequence is the port's own and unmodified. +# WHETHER A TICK ACTUALLY ARRIVES is decided per +# excursion by a word in the mailbox, not by this +# build, so the cooperative run and the preemptive +# run are the same bytes. # # console.c NOT used -- the guest has no UART, it hypercalls. -# gicv3.c NOT used -- the distributor is shared between partitions and -# belongs to the hypervisor. -# timer.c NOT used -- a partition's clock is the hypervisor's to hand -# out, so that a descheduled partition's time can be frozen. +# gicv3.c NOT used, and this is the sharpest of the omissions. Every +# register it touches is MEMORY-MAPPED: the distributor, which +# is one per system, and the redistributor, which is one per +# CORE and holds the enable bits for every partition's PPIs and +# for the hypervisor's own timer. A partition that could write +# that frame could disable the interrupt that ends its window. +# So ZoneX keeps all of it and enables this partition's timer +# PPI on its behalf; the guest brings up only its CPU +# interface, which is system registers and needs no device. +# See guest_interrupts_init in zx_guest_bsp.c. +# timer.c NOT used -- it drives the PHYSICAL timer, whose time keeps +# running while a partition is descheduled, and it starts the +# system counter, which is a system-wide resource. The guest +# arms the VIRTUAL timer instead, in zx_guest_bsp.c, and ZoneX +# starts the counter and moves CNTVOFF. # mpu.c NOT used -- its region table describes a whole board, # including a device window at 0x80000000 this partition does # not own. The guest programs its own three regions instead. @@ -149,7 +169,15 @@ function(zx_add_guest target_name) ${TX_PORT_BSP}/tx_initialize_low_level.S ) - target_compile_definitions(${target_name} PRIVATE TX_R52_BOOT_AT_EL1) + # TX_R52_BOOT_AT_EL1 skips the port's own EL2 configuration block -- ZoneX + # is the EL2 monitor that block exists to defer to. TX_R52_USE_THREADX_IRQ + # gives the port's EL1 IRQ vector its real body and makes the low-level + # initialisation call board_init. Both are the port's OWN options, used as + # the port documents them; neither is a ZoneX patch to ports/cortex_r52. + target_compile_definitions(${target_name} PRIVATE + TX_R52_BOOT_AT_EL1 + TX_R52_USE_THREADX_IRQ + ) target_include_directories(${target_name} PRIVATE ${GUEST_DIR} diff --git a/examples/fvp_baser_aemv8r/guest_a/link_partition_a.lds b/examples/fvp_baser_aemv8r/guest_a/link_partition_a.lds index 47393e1..ba7fdf7 100644 --- a/examples/fvp_baser_aemv8r/guest_a/link_partition_a.lds +++ b/examples/fvp_baser_aemv8r/guest_a/link_partition_a.lds @@ -48,8 +48,8 @@ * from examples/common/zx_guest_abi.h, because it has a raw blob and no * symbol table to look anything up in: * - * +0x00 the readback mailbox, one granule - * +0x40 one branch to the kernel's reset symbol -- the ERET target + * +0x00 the readback mailbox, TWO granules + * +0x80 one branch to the kernel's reset symbol -- the ERET target * * The ASSERTs at the end are what make those constants true rather than * hoped for. @@ -99,7 +99,7 @@ MEMORY SECTIONS { /* ---------------------------------------------------------------- */ - /* +0x00 the mailbox. */ + /* +0x00 the mailbox, two granules. */ /* */ /* LOADED, not NOLOAD, and that is what fixes the whole layout. */ /* objcopy -O binary emits only sections with contents, so a */ @@ -120,7 +120,7 @@ SECTIONS } > WINDOW /* ---------------------------------------------------------------- */ - /* +0x40 the entry branch, then everything executable. */ + /* +0x80 the entry branch, then everything executable. */ /* */ /* One stage-1 region covers all of it, read-only and executable. */ /* Read-only at EL1 while stage 2 permits the write is the case */ @@ -247,9 +247,15 @@ SECTIONS ASSERT(__zx_guest_mailbox_start == __zx_guest_window_base, "the mailbox is not at the base of the partition window") - ASSERT((__zx_guest_mailbox_end - __zx_guest_mailbox_start) == 64, - "the mailbox is not exactly one granule") - ASSERT(__zx_guest_code_start == (__zx_guest_window_base + 0x40), + /* TWO granules, and the number is ZX_GD_WINDOW_SIZE in + examples/common/zx_guest_abi.h. A linker script cannot include a + C header, so the constant is restated here and asserted -- which + is the point: a mailbox that grew past its slot fails the LINK + rather than moving an entry point the hypervisor computes as a + compile-time constant. */ + ASSERT((__zx_guest_mailbox_end - __zx_guest_mailbox_start) == 128, + "the mailbox is not exactly two granules") + ASSERT(__zx_guest_code_start == (__zx_guest_window_base + 0x80), "the entry branch is not at the offset the hypervisor ERETs to") ASSERT(_start >= __zx_guest_code_start, "the kernel's reset symbol is outside the guest's code region") diff --git a/examples/fvp_baser_aemv8r/zx_board.c b/examples/fvp_baser_aemv8r/zx_board.c index 1b2955f..1f1cd9b 100644 --- a/examples/fvp_baser_aemv8r/zx_board.c +++ b/examples/fvp_baser_aemv8r/zx_board.c @@ -112,10 +112,8 @@ void zx_board_report(void) /* something starts it. */ /* */ /* ZoneX programs CNTFRQ from this so that a guest reading it gets a */ -/* number rather than a zero. Starting the counter itself is a different */ -/* job and belongs with interrupt delivery, which Phase 0 does not have */ -/* yet -- so a guest that tried to WAIT on this timer would still wait */ -/* forever, and the cooperative guest deliberately does not. */ +/* number rather than a zero. STARTING the counter is a separate hook, */ +/* zx_board_counter_start below, and on this model it is not optional. */ /**************************************************************************/ uint32_t zx_board_counter_hz(void) @@ -124,6 +122,64 @@ uint32_t zx_board_counter_hz(void) } +/**************************************************************************/ +/* zx_board_counter_start */ +/* */ +/* ONE REGISTER, AND IT IS THE DIFFERENCE BETWEEN A PREEMPTIVE GUEST AND */ +/* A HARNESS TIMEOUT. */ +/* */ +/* This model's system counter is STOPPED at reset and the model says so */ +/* in its own parameters: bp.refcounter.non_arch_start_at_default=0, */ +/* documented as "firmware is expected to enable the timer at boot time". */ +/* So a guest granted the virtual timer here, on a run where nobody wrote */ +/* CNTCR, arms a comparator against a counter that will never reach it */ +/* and blocks for ever. */ +/* */ +/* Read-modify-write rather than a plain store, because CNTCR carries */ +/* more than EN -- HDBG and the frequency-change fields -- and a run that */ +/* had been started under a debugger with any of them set would have them */ +/* quietly cleared by a store. */ +/* */ +/* The frame is reachable with no EL2 region because it sits in the */ +/* Device-nGnRE band of this model's background map. On a board where it */ +/* did not, this function would need a region first -- which is the same */ +/* ordering problem the console has on the S32Z280, and the reason */ +/* zx_board_program_mmio_regions runs before anything is printed. */ +/**************************************************************************/ + +void zx_board_counter_start(void) +{ + ZX_REG32(ZX_FVP_CNT_CONTROL_BASE + ZX_FVP_CNTCR) |= + (uint32_t)ZX_FVP_CNTCR_EN; +} + + +/**************************************************************************/ +/* zx_board_gic_layout */ +/* */ +/* A redistributor is TWO consecutive 64 KB frames per core, and they are */ +/* named separately because swapping them writes plausible values into */ +/* the wrong registers and reads back zero -- a GIC that was configured */ +/* and does nothing, with no fault to point at it. */ +/* */ +/* Both addresses were confirmed in-model by reading GICD_PIDR2 and */ +/* GICR_PIDR2 during the Cortex-R52 port work; both read 0x3B, which is */ +/* GICv3. */ +/**************************************************************************/ + +void zx_board_gic_layout(ZX_GIC_LAYOUT *layout_ptr) +{ + if (layout_ptr == (ZX_GIC_LAYOUT *)0) + { + return; + } + + layout_ptr->zx_gic_dist_base = (zx_addr_t)ZX_FVP_GICD_BASE; + layout_ptr->zx_gic_rd_base = (zx_addr_t)ZX_FVP_GICR_RD_BASE; + layout_ptr->zx_gic_sgi_base = (zx_addr_t)ZX_FVP_GICR_SGI_BASE; +} + + /**************************************************************************/ /* zx_board_describe_mmio_regions */ /* */ diff --git a/examples/fvp_baser_aemv8r/zx_one_partition.lds b/examples/fvp_baser_aemv8r/zx_one_partition.lds index dc104fc..fd6eaec 100644 --- a/examples/fvp_baser_aemv8r/zx_one_partition.lds +++ b/examples/fvp_baser_aemv8r/zx_one_partition.lds @@ -203,12 +203,22 @@ SECTIONS /* THE BLOB IS NOT EMPTY, and this is the check that catches an .incbin whose file was missing or a linker input pattern that matched - nothing. Asserted against the size of the image header rather than + nothing. Asserted against the END OF THE IMAGE HEADER rather than against zero, because a blob too small to carry its own header is just as useless as one of no size at all -- and the loader's magic - check would then be reading past the end of the section. */ - - ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) > 0x50, + check would then be reading past the end of the section, which is + the out-of-bounds read this assert exists to prevent. + + 0x90 is ZX_GUEST_IMAGE_OFF_MAGIC + 4, from + examples/common/zx_guest_abi.h. A linker script cannot include a C + header, so the number is restated here -- and it is restated WRONG + the moment the header moves, which is exactly what happened when the + mailbox grew to two granules and pushed the entry branch and the + header from 0x40 to 0x80. The bound stayed at 0x50, so a blob of + 0x51 to 0x8F bytes linked cleanly and the magic check read past the + section into whatever followed it. */ + + ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) >= 0x90, "the embedded guest image is empty or too small to carry its header") ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) diff --git a/examples/fvp_baser_aemv8r/zx_platform.h b/examples/fvp_baser_aemv8r/zx_platform.h index a7f9726..1b2b051 100644 --- a/examples/fvp_baser_aemv8r/zx_platform.h +++ b/examples/fvp_baser_aemv8r/zx_platform.h @@ -72,6 +72,29 @@ #define ZX_FVP_SYSTEM_COUNTER_HZ 100000000UL +/* The counter control frame (CNTControlBase). THIS MODEL LEAVES ITS SYSTEM + COUNTER STOPPED at reset -- bp.refcounter.non_arch_start_at_default=0, + which the model documents as "firmware is expected to enable the timer at + boot time" -- so CNTCR.EN has to be written before anything can wait on a + timer. ZoneX writes it, because the system counter is one per system and + is not a partition's to start. + + The frame is at 0xAA430000, in the upper half of the BaseR map, where the + background map's Device-nGnRE band gives it the right attributes without + an EL2 region. That is a property of this model and not of the + architecture; the S32Z280 needs no frame at all, and its console and GIC + need regions this one does not. */ + +#define ZX_FVP_CNT_CONTROL_BASE 0xAA430000UL +#define ZX_FVP_CNTCR 0x0000U +#define ZX_FVP_CNTCR_EN 0x00000001UL + +/* A board register accessor, for the one register this board's hypervisor + writes. The S32Z280's zx_platform.h has the same macro for its console + and its GIC; this model needed none until the counter. */ + +#define ZX_REG32(address) (*(volatile uint32_t *)(uintptr_t)(address)) + /* Low DRAM, where the image is linked. The 0x80000000-0xFFFFFFFF half holds peripherals and is execute-never in the background map, so linking code there produces a silent fault loop with no output. */ diff --git a/examples/guest_common/zx_guest_app.c b/examples/guest_common/zx_guest_app.c index ab98192..1cf232c 100644 --- a/examples/guest_common/zx_guest_app.c +++ b/examples/guest_common/zx_guest_app.c @@ -31,20 +31,28 @@ /* under a hypervisor's stage-2 MPU with its own stage-1 MPU live */ /* underneath. */ /* */ -/* WHY IT IS COOPERATIVE */ +/* ONE IMAGE, TWO EXPERIMENTS, AND THE DIFFERENCE IS A WORD IN THE */ +/* MAILBOX */ /* */ -/* There is no tick. This guest takes no interrupts at all, so it */ -/* uses tx_thread_relinquish and tx_queue_send/receive with */ -/* TX_NO_WAIT; tx_thread_sleep would hang and time slicing would do */ -/* nothing. */ +/* With ZX_GO_TICK clear this guest is COOPERATIVE: it takes no */ +/* interrupts at all, arms no timer, and touches no interrupt */ +/* controller, so it uses tx_thread_relinquish and TX_NO_WAIT */ +/* everywhere. With ZX_GO_TICK set the hypervisor has granted it the */ +/* virtual timer, and it goes on to prove the things only a kernel */ +/* with a tick can prove. */ /* */ -/* That is a sub-milestone rather than a limitation of the design. A */ -/* guest with no interrupts fails FAST and CHEAPLY when something is */ -/* wrong with the launch path -- and the launch path is what has never */ -/* been run before. Adding a timer and an interrupt controller at the */ -/* same time would mean a silent guest with three plausible causes. */ -/* The preemptive guest is the next thing, and it needs the hypervisor */ -/* to take the GIC distributor and hand out a virtual timer. */ +/* THE TWO ARE THE SAME BYTES, deliberately. A separate binary for */ +/* the preemptive case would be a binary that can drift from the one */ +/* demonstrating isolation, and the one still being quoted would then */ +/* no longer be the one that was tested. Here the run that shows a */ +/* kernel confined by stage 2 and the run that shows it preempted */ +/* under stage 2 are the same image, loaded twice. */ +/* */ +/* The cooperative case came first for a reason worth keeping: a guest */ +/* with no interrupts fails FAST and CHEAPLY when something is wrong */ +/* with the launch path, and bringing up a timer and an interrupt */ +/* controller at the same time as a loader would have meant a silent */ +/* guest with three plausible causes. */ /* */ /* WHAT PROVES WHAT */ /* */ @@ -57,6 +65,20 @@ /* SEMAPHORE_OK a get/put pair handed off between threads */ /* FINISHED the guest reached a verdict of its own */ /* */ +/* and, when a tick was granted: */ +/* */ +/* COUNTING the partition's own virtual counter ADVANCES. */ +/* Checked before anything blocks, because the */ +/* alternative to checking is a hang: the Armv8-R */ +/* AEM FVP leaves its system counter stopped, and a */ +/* guest that slept on a stopped counter would be */ +/* killed by a harness timeout naming nothing. */ +/* TICKING tx_time_get() advanced, so the virtual timer PPI */ +/* was delivered to EL1 and _tx_timer_interrupt ran */ +/* PREEMPTED a thread that never yields was DISPLACED */ +/* TIMESLICED two equal-priority threads that never yield both */ +/* advanced */ +/* */ /* The hypervisor reads them out of the mailbox afterwards and does */ /* not have to trust the guest's own printed claim -- which is the */ /* point, since a guest that printed "PASSED" and set no bits would */ @@ -78,13 +100,60 @@ #define GUEST_ITERATIONS 4U #define GUEST_QUEUE_MESSAGES 4U +/* THE PRIORITIES ARE THE EXPERIMENT, so they are named rather than pasted. + * + * The producer and the consumer share one priority: tx_thread_relinquish + * yields to the next ready thread OF THE SAME priority, so between threads of + * different priorities it does nothing observable and the higher one simply + * runs to completion. That was found the hard way -- see the note in + * tx_application_define. + * + * The two spinners share a LOWER one, and both halves of that matter. Lower + * than the consumer, so that they run only while the consumer is asleep and + * the cooperative phase is completely unaffected by their existence. Equal + * to each other, so that the only thing that can make both of them advance is + * the kernel's TIME SLICE -- neither ever yields, and at different priorities + * the higher one would simply own the core. */ + +#define GUEST_WORKER_PRIORITY 16U +#define GUEST_SPIN_PRIORITY 20U + +/* Two ticks, so that a slice boundary falls inside the window the consumer + sleeps for. A slice equal to the sleep would leave whether the second + spinner ever ran to the order the two happened to start in. */ + +#define GUEST_SPIN_SLICE 2U + +/* Three ticks of sleep. Long enough to contain at least one slice boundary + for each spinner, short enough that a functional model is not asked to + simulate more time than the claim needs. */ + +#define GUEST_SLEEP_TICKS 3U + +/* How long to wait for the FIRST tick, in iterations of a busy loop. This is + a bound and not a duration: its only job is to be finite, so that a tick + that never arrives becomes a reported failure instead of a hang. */ + +#define GUEST_TICK_GUARD 2000000UL + +/* And the bound on a spinner, for the same reason one level down. It is + never reached on a run where the tick works, which is the only run that + gets this far -- the counter and the first tick are both checked before + anything sleeps. */ + +#define GUEST_SPIN_GUARD 2000000UL + static TX_THREAD thread_producer; static TX_THREAD thread_consumer; +static TX_THREAD thread_spin_a; +static TX_THREAD thread_spin_b; static TX_QUEUE work_queue; static TX_SEMAPHORE handover; static ULONG producer_stack[GUEST_STACK_SIZE / sizeof(ULONG)]; static ULONG consumer_stack[GUEST_STACK_SIZE / sizeof(ULONG)]; +static ULONG spin_a_stack[GUEST_STACK_SIZE / sizeof(ULONG)]; +static ULONG spin_b_stack[GUEST_STACK_SIZE / sizeof(ULONG)]; /* One ULONG per message, so TX_1_ULONG. */ static ULONG queue_storage[GUEST_QUEUE_MESSAGES]; @@ -95,6 +164,17 @@ static volatile ULONG messages_carried; static volatile ULONG queue_faults; static volatile ULONG semaphore_faults; +/* The preemptive phase's state. volatile because it is written by one thread + and read by another with no synchronisation between them -- which is the + whole method here: the spinners never yield and never take a lock, so what + the consumer reads is whatever they had reached at the instant the tick + took the core away from them. */ + +static volatile ULONG spin_a_loops; +static volatile ULONG spin_b_loops; +static volatile ULONG spin_stop; +static volatile ULONG guest_tick_granted; + /**************************************************************************/ /* publish -- push the counters into the mailbox and reseal it. */ @@ -158,6 +238,220 @@ static void producer_entry(ULONG thread_input) } +/**************************************************************************/ +/* spin_entry -- a thread that never yields. */ +/* */ +/* THE POINT OF THIS THREAD IS EVERYTHING IT DOES NOT DO. It makes no */ +/* kernel call in its loop: no relinquish, no sleep, no queue, no */ +/* semaphore. There is therefore no point at which the kernel could */ +/* take the core away from it COOPERATIVELY. If anything else ever runs */ +/* while this thread is between its own iterations, the only thing that */ +/* can have caused it is an interrupt. */ +/* */ +/* Two of these exist and they share a priority, which turns the same */ +/* loop into a second and different claim: neither can yield to the */ +/* other, so the only way BOTH counters can advance is the kernel's time */ +/* slice -- _tx_timer_interrupt counting down tx_thread_time_slice and */ +/* rotating the ready list. */ +/* */ +/* The counter is published into the mailbox on every iteration rather */ +/* than at the end, and that is not an accident of style. A thread that */ +/* was DISPLACED and never resumed -- because the run ended, or because */ +/* the partition was taken by a fault -- has still reported how far it */ +/* got, and how far it got is the evidence. */ +/* */ +/* The bound exists so this cannot be the thing that hangs a run. It is */ +/* unreachable on any run that gets here: the consumer has already */ +/* proved the counter moves and that a tick arrived. */ +/**************************************************************************/ + +static void spin_entry(ULONG thread_input) +{ + volatile ULONG *loops = (thread_input == 0UL) ? &spin_a_loops + : &spin_b_loops; + ULONG offset = (thread_input == 0UL) ? (ULONG) ZX_GD_SPIN_A + : (ULONG) ZX_GD_SPIN_B; + ULONG guard; + + for (guard = 0UL; guard < (ULONG) GUEST_SPIN_GUARD; guard++) + { + if (spin_stop != 0UL) + { + break; + } + + *loops = *loops + 1UL; + guest_mailbox_write(offset, *loops); + } +} + + +/**************************************************************************/ +/* wait_for_first_tick */ +/* */ +/* BUSY, NOT BLOCKING, AND THAT IS THE WHOLE FUNCTION. */ +/* */ +/* tx_thread_sleep is the natural way to wait for a tick and it is the */ +/* one thing that must not be used to find out WHETHER there is one: a */ +/* sleep with no tick behind it never returns, and the run ends in a */ +/* harness timeout -- the single least informative outcome this suite */ +/* can produce, and one that looks identical to a broken loader, a */ +/* broken context switch and a broken interrupt controller. */ +/* */ +/* So the first tick is waited for with interrupts ENABLED and the thread */ +/* RUNNABLE, polling tx_time_get() a bounded number of times. A tick */ +/* that arrives advances it; a tick that never arrives runs the loop out */ +/* and the guest reports that instead. */ +/**************************************************************************/ + +static UINT wait_for_first_tick(void) +{ + ULONG started = tx_time_get(); + ULONG guard; + + for (guard = 0UL; guard < (ULONG) GUEST_TICK_GUARD; guard++) + { + if (tx_time_get() != started) + { + return TX_TRUE; + } + } + + return TX_FALSE; +} + + +/**************************************************************************/ +/* preemptive_phase -- what only a kernel with a tick can demonstrate. */ +/* */ +/* THREE CLAIMS, IN THE ORDER THAT MAKES EACH ONE SAFE TO MAKE. */ +/* */ +/* 1. THE COUNTER MOVES. Read twice, bounded, before anything blocks. */ +/* Programming CNTFRQ does not start a system counter and on one of */ +/* the two targets it is stopped at reset, so this is the check */ +/* that stands between a granted timer and a timeout. It asks */ +/* about the VIRTUAL counter, which is the one this partition can */ +/* see -- the hypervisor's own check, of the physical counter, is a */ +/* different register reached past CNTVOFF. */ +/* */ +/* 2. A TICK ARRIVES. Busy-waited, so that a GIC that was configured */ +/* and delivers nothing is a report rather than a hang. This is */ +/* the first thing in the whole run that proves the interrupt path */ +/* end to end: the hypervisor enabled the PPI in a redistributor */ +/* this guest cannot reach, the guest enabled its own CPU */ +/* interface, the GIC delivered to EL1 rather than to EL2, the */ +/* guest's own vector table sent it into the port's context save, */ +/* and _tx_timer_interrupt ran. */ +/* */ +/* 3. PREEMPTION AND TIME SLICING, measured in one sleep. The */ +/* consumer records both spinners' counters, sleeps, and looks */ +/* again: */ +/* */ +/* the consumer WOKE AT ALL the timeout list ran, so the */ +/* tick reaches the scheduler */ +/* and not merely a counter */ +/* a spinner ADVANCED it was genuinely running -- */ +/* and it never yields, so the */ +/* consumer running again now */ +/* means the tick DISPLACED it */ +/* BOTH spinners advanced neither can yield to the */ +/* other, so the only thing */ +/* that can have interleaved */ +/* them is the time slice */ +/* */ +/* WHY THE TWO ARE SEPARATE BITS. Preemption and time slicing are */ +/* different parts of _tx_timer_interrupt -- the timeout list and the */ +/* slice countdown -- and they fail separately. A kernel whose slice */ +/* counter was wrong would still wake a sleeper, and reporting one bit */ +/* for both would call that a pass. */ +/**************************************************************************/ + +static ULONG preemptive_phase(void) +{ + ULONG progress = 0UL; + ULONG before_a; + ULONG before_b; + ULONG after_a; + ULONG after_b; + + if (guest_counter_is_moving() == 0U) + { + console_puts("this partition's virtual counter is NOT advancing, so\n" + "the system counter was never started. Refusing to\n" + "block on a timer that cannot expire -- a guest that\n" + "slept here would end this run in a timeout naming\n" + "nothing at all\n"); + return (ULONG) ZX_GP_NO_CLOCK; + } + + progress |= (ULONG) ZX_GP_COUNTING; + + console_puts("virtual counter is advancing; waiting for the first tick\n"); + + if (wait_for_first_tick() == TX_FALSE) + { + console_puts("no tick arrived. The counter moves, so the timer was\n" + "armed against a running counter and the interrupt did\n" + "not reach this partition -- which is the GIC or the\n" + "vector table, and not the clock\n"); + return progress | (ULONG) ZX_GP_NO_CLOCK; + } + + progress |= (ULONG) ZX_GP_TICKING; + + console_puts("the tick arrived: INTID "); + console_putdec(guest_mailbox_read(ZX_GD_TIMER_INTID)); + console_puts(", interrupts serviced so far = "); + console_putdec(guest_mailbox_read(ZX_GD_IRQ_COUNT)); + console_puts("\n"); + + /* Both counters are read BEFORE the sleep and both after it. Reading + one, sleeping, and reading the other would compare two different + instants and could report a slice that never happened. */ + + before_a = spin_a_loops; + before_b = spin_b_loops; + + tx_thread_sleep((ULONG) GUEST_SLEEP_TICKS); + + after_a = spin_a_loops; + after_b = spin_b_loops; + + if ((after_a > before_a) || (after_b > before_b)) + { + /* A spinner was running, and it never yields. This thread is + running again. Something took the core from a thread that did not + give it up, and the only thing that can is an interrupt. */ + + progress |= (ULONG) ZX_GP_PREEMPTED; + } + + if ((after_a > before_a) && (after_b > before_b)) + { + progress |= (ULONG) ZX_GP_TIMESLICED; + } + + /* Let the spinners finish, so the kernel is not left with two runnable + threads when this one hands the machine back. */ + + spin_stop = 1UL; + + guest_mailbox_write(ZX_GD_WAKES, guest_mailbox_read(ZX_GD_WAKES) + 1UL); + + console_puts("slept "); + console_putdec((ULONG) GUEST_SLEEP_TICKS); + console_puts(" ticks; spinner loops "); + console_putdec(after_a - before_a); + console_puts(" / "); + console_putdec(after_b - before_b); + console_puts(" -- neither of them yields, so both of those are the\n" + "kernel's own time slice, and this thread running again is\n" + "a lower-priority thread having been DISPLACED\n"); + + return progress; +} + + /**************************************************************************/ /* consumer_entry */ /* */ @@ -251,6 +545,16 @@ static void consumer_entry(ULONG thread_input) progress |= (ULONG) ZX_GP_SEMAPHORE_OK; } + /* THE PREEMPTIVE PHASE, if the hypervisor granted this partition a + clock. It comes BEFORE the probe on purpose: the probe may end the + excursion at a stage-2 boundary, and a claim that was never reached + is indistinguishable from one that failed. */ + + if (guest_tick_granted != 0UL) + { + progress |= preemptive_phase(); + } + console_puts("threads ran, producer/consumer slices = "); console_putdec(producer_slices); console_puts(" / "); @@ -326,11 +630,31 @@ static void consumer_entry(ULONG thread_input) /* The verdict, written into the mailbox BEFORE it is printed. A guest that printed a verdict it had not published would let a reader believe - something the hypervisor cannot confirm. */ - - if ((messages_carried == (ULONG) GUEST_ITERATIONS) - && (queue_faults == 0UL) - && (semaphore_faults == 0UL)) + something the hypervisor cannot confirm. + * + * WHAT A PASS MEANS DEPENDS ON WHAT WAS GRANTED, and the guest is the + * only party that can apply that rule. The hypervisor knows what it + * ASKED for; only the guest knows what it MANAGED. A partition given a + * clock must have used it -- reporting PASSED on a run where the tick + * never arrived would be a guest quietly downgrading its own verdict to + * whatever it happened to achieve, and the hypervisor reading a sealed + * PASSED back has no way to know that is what it means. */ + + if ((guest_tick_granted != 0UL) + && ((progress & (ULONG) (ZX_GP_COUNTING | ZX_GP_TICKING + | ZX_GP_PREEMPTED | ZX_GP_TIMESLICED)) + != (ULONG) (ZX_GP_COUNTING | ZX_GP_TICKING + | ZX_GP_PREEMPTED | ZX_GP_TIMESLICED))) + { + guest_mailbox_write(ZX_GD_VERDICT, (unsigned long) ZX_GV_FAILED); + progress |= (ULONG) ZX_GP_FINISHED; + publish(progress); + console_puts("GUEST RESULT: FAILED -- a clock was granted and this\n" + "partition did not get everything a clock is for\n"); + } + else if ((messages_carried == (ULONG) GUEST_ITERATIONS) + && (queue_faults == 0UL) + && (semaphore_faults == 0UL)) { guest_mailbox_write(ZX_GD_VERDICT, (unsigned long) ZX_GV_PASSED); progress |= (ULONG) ZX_GP_FINISHED; @@ -382,11 +706,39 @@ void tx_application_define(void *first_unused_memory) (void) tx_thread_create(&thread_producer, "producer", producer_entry, 0UL, producer_stack, sizeof(producer_stack), - 16U, 16U, TX_NO_TIME_SLICE, TX_AUTO_START); + GUEST_WORKER_PRIORITY, GUEST_WORKER_PRIORITY, + TX_NO_TIME_SLICE, TX_AUTO_START); (void) tx_thread_create(&thread_consumer, "consumer", consumer_entry, 0UL, consumer_stack, sizeof(consumer_stack), - 16U, 16U, TX_NO_TIME_SLICE, TX_AUTO_START); + GUEST_WORKER_PRIORITY, GUEST_WORKER_PRIORITY, + TX_NO_TIME_SLICE, TX_AUTO_START); + + /* THE TWO SPINNERS EXIST ONLY WHEN A CLOCK WAS GRANTED, and that is + what keeps a cooperative excursion of this image the same program it + was before a tick existed. Created, they would never run -- they sit + below the worker priority and the workers only ever relinquish, which + yields within a priority -- but they would still cost two thread + control blocks, two stacks and two passes of the scheduler's ready + list, and the cooperative pass is one half of a measured pair. + + TX_TIMESLICE and not TX_NO_TIME_SLICE, obviously, and the value is + what the second claim rests on: two threads at ONE priority, neither + of which ever yields, can only interleave because the kernel counts + their slice down on a tick. */ + + if (guest_tick_granted != 0UL) + { + (void) tx_thread_create(&thread_spin_a, "spin A", spin_entry, 0UL, + spin_a_stack, sizeof(spin_a_stack), + GUEST_SPIN_PRIORITY, GUEST_SPIN_PRIORITY, + GUEST_SPIN_SLICE, TX_AUTO_START); + + (void) tx_thread_create(&thread_spin_b, "spin B", spin_entry, 1UL, + spin_b_stack, sizeof(spin_b_stack), + GUEST_SPIN_PRIORITY, GUEST_SPIN_PRIORITY, + GUEST_SPIN_SLICE, TX_AUTO_START); + } } @@ -427,6 +779,21 @@ void bsp_main(void) console_set_quiet(((guest_mailbox_read(ZX_GD_OPTIONS) & (unsigned long) ZX_GO_QUIET) != 0UL) ? 1U : 0U); + /* WHETHER THIS PARTITION HAS A CLOCK, read ONCE and kept. The mailbox + is the hypervisor's to write and a guest that re-read it per decision + could find it changed halfway through a run -- so the answer is taken + at the top, in the same breath as the console option, and everything + downstream asks this variable. + + The port's board support has already read the same word, in board_init + during tx_kernel_enter, and decided whether to arm anything. Reading + it twice is right rather than redundant: the two readers want the same + fact at two moments a partition switch could fall between, and neither + can ask the other. */ + + guest_tick_granted = ((guest_mailbox_read(ZX_GD_OPTIONS) + & (unsigned long) ZX_GO_TICK) != 0UL) ? 1UL : 0UL; + guest_vectors_install(); console_puts("ThreadX guest at EL1, inside a ZoneX partition\n"); @@ -456,5 +823,25 @@ void bsp_main(void) "window, or the guest would fault on memory its own MPU\n" "says it owns -- the stricter of the two stages wins\n"); + if (guest_tick_granted != 0UL) + { + console_puts("this partition was granted a CLOCK: the virtual timer\n" + "on PPI "); + console_putdec((ULONG) ZX_GUEST_TIMER_INTID); + console_puts(", enabled for me in a redistributor I cannot\n" + "reach. CNTFRQ reads "); + console_putdec(guest_counter_frequency()); + console_puts(" Hz, programmed by the\n" + "hypervisor because it is writable only above me. I\n" + "bring up my own CPU interface, which is system\n" + "registers and touches no device at all.\n"); + } + else + { + console_puts("no clock was granted, so this partition takes no\n" + "interrupts: cooperative scheduling only, and\n" + "tx_thread_sleep would never return.\n"); + } + tx_kernel_enter(); } diff --git a/examples/guest_common/zx_guest_bsp.c b/examples/guest_common/zx_guest_bsp.c index 6b9df81..83f0862 100644 --- a/examples/guest_common/zx_guest_bsp.c +++ b/examples/guest_common/zx_guest_bsp.c @@ -34,25 +34,31 @@ /* one UART. Resolved here: the guest hypercalls and the */ /* hypervisor writes. See console_putc below. */ /* */ -/* 2. IT INITIALISES THE GIC DISTRIBUTOR. The distributor is */ -/* shared across partitions, so two guests configuring it fight. */ -/* NOT resolved here, because this guest takes no interrupts at */ -/* all -- see the note at guest_mpu_init. The hypervisor has to */ -/* take the distributor and hand each partition a virtual */ -/* interface. */ +/* 2. IT INITIALISES THE GIC DISTRIBUTOR AND THE REDISTRIBUTOR. */ +/* Both are shared: there is one distributor for the system and */ +/* one redistributor per CORE, and every partition on this core */ +/* shares that redistributor. Resolved here by NOT DOING IT AT */ +/* ALL. The hypervisor owns every byte of GIC memory-mapped */ +/* state and enables a partition's timer PPI on its behalf; the */ +/* guest brings up only its own CPU INTERFACE, which on this */ +/* part is system registers and touches no device. See */ +/* guest_interrupts_init below. */ /* */ /* 3. IT USES THE PHYSICAL TIMER. A partition's physical time */ /* keeps running while it is descheduled, so a guest on the */ /* physical timer can observe that it was not running -- which */ -/* is exactly the temporal-determinism claim, lost. NOT */ -/* resolved here. The virtual timer with a per-partition */ -/* CNTVOFF is what freezes a descheduled partition's clock. */ +/* is exactly the temporal-determinism claim, lost. Resolved */ +/* here: this guest arms the VIRTUAL timer, whose counter the */ +/* hypervisor freezes with CNTVOFF while the partition is not */ +/* running. The guest cannot reach the physical counter at all: */ +/* CNTHCTL.PL1PCTEN and PL1PCEN are left clear at EL2 on */ +/* purpose. See docs/decisions.md D7 and D23. */ /* */ -/* Only the first is answered by this step. The other two are left as */ -/* they are, deliberately and visibly, because a single cooperative */ -/* partition does not need them and inventing an answer before there */ -/* is a second partition to test it against is how a wrong answer gets */ -/* written down. */ +/* All three are now answered, and the shape of each answer is the */ +/* same: the guest keeps what is PER-THREAD or PER-CORE-INTERFACE and */ +/* gives up what is SHARED. A partition may acknowledge an interrupt */ +/* and read a clock; it may not decide which interrupts exist or how */ +/* fast time runs. */ /* */ /* MISRA C:2012 deviations (justified) */ /* */ @@ -63,6 +69,14 @@ /* */ /**************************************************************************/ +/* tx_api.h is here for ONE constant: TX_TIMER_TICKS_PER_SECOND, which the + virtual timer's interval is derived from. The kernel and its board + support have to agree about how long a tick is, and taking the number + from the kernel's own header is the only way that agreement cannot + drift. */ + +#include "tx_api.h" + #include "zx_guest_abi.h" #include "zx_guest_bsp.h" @@ -301,39 +315,359 @@ unsigned int guest_mpu_init(void) /**************************************************************************/ -/* board_irq_handler / board_fiq_handler */ +/* THE PARTITION'S OWN CPU INTERFACE */ +/* */ +/* WHAT A PARTITION IS ALLOWED TO TOUCH, AND WHY IT IS SO LITTLE. */ +/* */ +/* A GICv3 interrupt reaches a thread through three pieces of state, and */ +/* they belong to three different owners: */ +/* */ +/* the DISTRIBUTOR one per system. Decides which interrupts exist, */ +/* where they are routed, and at what priority. */ +/* the REDISTRIBUTOR one per CORE. Holds the enable, group, priority */ +/* and edge/level bits for the 32 SGIs and PPIs of */ +/* that core -- INCLUDING every partition's, and */ +/* including the hypervisor's own. */ +/* the CPU INTERFACE per exception level, and on this part it is */ +/* entirely SYSTEM REGISTERS. Priority mask, group */ +/* enable, acknowledge, end-of-interrupt. */ +/* */ +/* Only the third is a partition's. The first two are memory-mapped and */ +/* the hypervisor keeps them at ZX_AP_EL2_RW_GUEST_NONE, so this file */ +/* could not reach them even if it tried -- and the reason is sharper */ +/* than "they are shared". The redistributor's SGI frame holds the */ +/* enable bit for PPI 26, the hypervisor's OWN timer, which is what ends */ +/* a partition's window. A partition with a writable mapping of that */ +/* frame could clear that bit and never be descheduled again. That is */ +/* not a memory-isolation leak; it is the temporal-determinism claim, */ +/* gone, and no amount of stage-2 region programming would show it. */ +/* */ +/* So the hypervisor enables this partition's timer PPI on its behalf, */ +/* before the ERET, and the guest does the four system-register writes */ +/* below. Nothing here is a device access. */ +/* */ +/* ICC_SRE.SRE IS ALREADY SET. ZoneX's reset path sets ICC_HSRE.SRE and */ +/* ICC_HSRE.Enable, and the second of those is what makes an EL1 access */ +/* to ICC_SRE legal rather than a trap to EL2. Without SRE every other */ +/* ICC_* register is UNDEFINED and reading one takes an undefined- */ +/* instruction exception -- measured at EL2 during this work, and it */ +/* would present at EL1 as a guest dying in its own board support. It is */ +/* read-modify-written here anyway rather than assumed, because "somebody */ +/* else set it" is the kind of dependency that is true until a boot path */ +/* is reordered. */ +/**************************************************************************/ + +#define ICC_SRE_SRE 0x1UL +#define ICC_IGRPEN1_ENABLE 0x1UL +#define ICC_PMR_UNMASK_ALL 0xFFUL + + +static unsigned long read_icc_sre(void) +{ + unsigned long value; + __asm__ volatile("mrc p15, 0, %0, c12, c12, 5" : "=r"(value)); + return value; +} + + +static unsigned long read_icc_iar1(void) +{ + unsigned long value; + __asm__ volatile("mrc p15, 0, %0, c12, c12, 0" : "=r"(value)); + return value; +} + + +static void write_icc_eoir1(unsigned long value) +{ + __asm__ volatile("mcr p15, 0, %0, c12, c12, 1" : : "r"(value) : "memory"); +} + + +static void guest_interrupts_init(void) +{ + unsigned long sre = read_icc_sre() | ICC_SRE_SRE; + + __asm__ volatile("mcr p15, 0, %0, c12, c12, 5" : : "r"(sre) : "memory"); + __asm__ volatile("isb" ::: "memory"); + + /* Priority mask wide open, no subpriority grouping, Group 1 enabled. + The partition's interrupts are all Group 1 -- Group 0 is delivered as + FIQ and this partition is granted none -- so IGRPEN0 is deliberately + left alone rather than enabled "for symmetry": a group a partition has + no interrupt in is a group it has no business enabling. */ + + __asm__ volatile("mcr p15, 0, %0, c4, c6, 0" + : : "r"(ICC_PMR_UNMASK_ALL) : "memory"); + __asm__ volatile("mcr p15, 0, %0, c12, c12, 3" : : "r"(0UL) : "memory"); + __asm__ volatile("mcr p15, 0, %0, c12, c12, 7" + : : "r"(ICC_IGRPEN1_ENABLE) : "memory"); + __asm__ volatile("isb" ::: "memory"); +} + + +/**************************************************************************/ +/* THE PARTITION'S OWN CLOCK */ +/* */ +/* CNTV, not CNTP, and the difference is the whole of temporal */ +/* partitioning. The physical counter keeps running while a partition is */ +/* descheduled, so a guest reading it could observe that it was not */ +/* running. The virtual counter is the physical one minus CNTVOFF, and */ +/* CNTVOFF is the hypervisor's: it advances it by exactly the time the */ +/* partition spent off the core, so from inside, time simply did not pass */ +/* while the partition was not running. See docs/decisions.md D7. */ +/* */ +/* The guest needs no permission for any of this. EL1 access to the */ +/* virtual timer is not gated by anything -- it is the PHYSICAL one that */ +/* CNTHCTL.PL1PCTEN and PL1PCEN gate, and ZoneX leaves both CLEAR, so a */ +/* partition that tried CNTP would trap to EL2 rather than get an answer. */ +/* */ +/* TVAL AND NOT CVAL, deliberately. TVAL is a 32-bit down-count loaded */ +/* relative to now, so re-arming is one register write with no 64-bit */ +/* arithmetic and no read of the counter -- which matters because the */ +/* re-arm happens inside the interrupt handler on every tick. Writing */ +/* TVAL is also what deasserts the timer's LEVEL output; a handler that */ +/* acknowledged the interrupt without re-arming would take it again the */ +/* instant interrupts were re-enabled, for ever. */ +/**************************************************************************/ + +#define CNTV_CTL_ENABLE 0x1UL +#define CNTV_CTL_IMASK 0x2UL + +static unsigned long guest_tick_interval; + + +unsigned long guest_counter_frequency(void) +{ + unsigned long value; + __asm__ volatile("mrc p15, 0, %0, c14, c0, 0" : "=r"(value)); + return value; +} + + +unsigned long guest_virtual_count(void) +{ + unsigned long low; + unsigned long high; + + /* CNTVCT is 64-bit and comes back as a register pair. Only the low + half is returned: this is used to answer "is the counter moving at + all", and a 32-bit answer at 8 MHz wraps in nine minutes, which is + three orders of magnitude longer than the question needs. */ + + __asm__ volatile("mrrc p15, 1, %0, %1, c14" : "=r"(low), "=r"(high)); + (void) high; + + return low; +} + + +void guest_tick_reload(void) +{ + __asm__ volatile("mcr p15, 0, %0, c14, c3, 0" + : : "r"(guest_tick_interval) : "memory"); +} + + +/**************************************************************************/ +/* guest_counter_is_moving */ /* */ -/* THE PORT'S BOARD SUPPORT EXPECTS THESE TO EXIST, and a partition has */ -/* no interrupt controller to service. */ +/* THE PRE-FLIGHT THAT EXISTS BECAUSE THE ALTERNATIVE IS A HANG. */ /* */ -/* They are all but unreachable: the guest replaces VBAR with its own */ -/* vector table before anything can raise an interrupt, so the port's own */ -/* IRQ and FIQ vectors -- which is what calls these -- stop being the */ -/* ones installed. They exist because the port's reset path REFERENCES */ -/* them on one of the two boards, and an unresolved symbol would fail the */ -/* link rather than the run. */ +/* Programming CNTFRQ does not start the system counter, and on the */ +/* Armv8-R AEM FVP the counter is left STOPPED at reset -- the model */ +/* documents that firmware is expected to start it. A guest that armed */ +/* its timer and went straight to tx_thread_sleep on a target whose */ +/* counter never started would wait for ever, and the run would end in a */ +/* harness timeout: the one failure mode that names nothing at all and */ +/* looks identical to a broken loader, a broken context switch and a */ +/* broken GIC. */ /* */ -/* Reaching one anyway means an interrupt arrived at a partition that has */ -/* none configured, in the window before its own vectors were installed. */ -/* So each records which vector it was and hands control back, rather than */ -/* returning to a context that has no reason to be sound. A handler that */ -/* silently returned would turn that into a partition that ran on with an */ -/* interrupt still asserted, which on this hardware is a storm rather than */ -/* a glitch. */ +/* So the guest reads its own counter twice before it blocks on anything. */ +/* The bound is what makes this a check rather than a second hang. A */ +/* counter that is running advances on the first spin at either target's */ +/* frequency; one that is stopped never will, and the guest reports that */ +/* and hands the machine back. */ +/* */ +/* The hypervisor checks the same thing before it hands over, and both */ +/* checks are worth having: ZoneX's says the counter it STARTED is */ +/* running, and this one says the counter the PARTITION can see is -- */ +/* which is a different register, reached through CNTVOFF. */ /**************************************************************************/ +unsigned int guest_counter_is_moving(void) +{ + unsigned long first = guest_virtual_count(); + unsigned long guard; + + for (guard = 0UL; guard < 100000UL; guard++) + { + if (guest_virtual_count() != first) + { + return 1U; + } + } + + return 0U; +} + + +/**************************************************************************/ +/* board_init -- called by the port's _tx_initialize_low_level. */ +/* */ +/* The port calls this because the guest is built with */ +/* TX_R52_USE_THREADX_IRQ, which is also what routes the port's EL1 IRQ */ +/* vector into _tx_thread_context_save. It runs with interrupts still */ +/* MASKED -- _tx_thread_schedule is what opens them -- so arming the */ +/* timer here cannot deliver a tick before the kernel can service one. */ +/* */ +/* WHETHER A PARTITION HAS A CLOCK AT ALL IS THE HYPERVISOR'S DECISION, */ +/* and it arrives as a word in the mailbox rather than as a build option. */ +/* With ZX_GO_TICK clear this function touches nothing: no CPU interface, */ +/* no timer, no GIC. That is what keeps a cooperative excursion of this */ +/* image byte-for-byte the same program as before it had a tick, so the */ +/* two are comparable -- and it means the run that demonstrates isolation */ +/* and the run that demonstrates preemption are the same binary. */ +/**************************************************************************/ + +void board_init(void); + + +void board_init(void) +{ + unsigned long frequency; + + if ((guest_mailbox_read(ZX_GD_OPTIONS) & (unsigned long) ZX_GO_TICK) == 0UL) + { + return; + } + + guest_interrupts_init(); + + /* CNTFRQ is a software-declared constant that the HYPERVISOR programmed: + it is writable only at the highest implemented exception level and + reads zero out of reset on both targets, so a guest booting at EL1 + cannot write it. Dividing by it without checking would be a divide by + zero on any target where ZoneX had not, which is why the guard is here + rather than a comment saying it cannot happen. */ + + frequency = guest_counter_frequency(); + + if (frequency == 0UL) + { + guest_tick_interval = 0UL; + return; + } + + guest_tick_interval = frequency / (unsigned long) TX_TIMER_TICKS_PER_SECOND; + + guest_tick_reload(); + + __asm__ volatile("mcr p15, 0, %0, c14, c3, 1" + : : "r"(CNTV_CTL_ENABLE) : "memory"); + __asm__ volatile("isb" ::: "memory"); +} + + +/**************************************************************************/ +/* board_irq_handler -- the partition's whole interrupt service. */ +/* */ +/* Called from the port's __tx_irq_processing_return, which means */ +/* _tx_thread_context_save has already saved the interrupted context and */ +/* interrupts are still masked. It RETURNS -- the port branches to */ +/* _tx_thread_context_restore afterwards, and that is what turns a tick */ +/* into a thread switch. An earlier version of this function yielded to */ +/* the hypervisor instead, which was right while a partition had no */ +/* interrupts and would now discard a thread's context on every tick. */ +/* */ +/* THE ORDER IS THE WHOLE FUNCTION. */ +/* */ +/* ACKNOWLEDGE FIRST. Reading ICC_IAR1 raises the running priority to */ +/* this interrupt's own, which masks it and everything of equal or */ +/* lower priority. Nothing else may happen before it. */ +/* */ +/* THE SPURIOUS INTID IS NOT AN INTERRUPT. 1023 means nothing was */ +/* actually pending, and it must NOT be given an end-of-interrupt: the */ +/* priority was never raised, so dropping it would corrupt the GIC's */ +/* running-priority stack rather than merely be redundant. */ +/* */ +/* RE-ARM BEFORE SERVICING. Writing CNTV_TVAL restarts the down-count */ +/* and DEASSERTS the timer's level output. The generic timer asserts a */ +/* level, not an edge, so a handler that ended the interrupt without */ +/* re-arming would be re-entered the instant interrupts reopened, and */ +/* again, until the stack was gone. */ +/* */ +/* _tx_timer_interrupt LAST, because it is the part that can change */ +/* which thread runs next. */ +/* */ +/* EVERY COUNTER HERE IS WRITTEN THROUGH THE MAILBOX rather than kept in */ +/* a static and copied out later. A handler that stashed its counts in */ +/* .bss would lose all of them if the guest were taken by a fault, which */ +/* is precisely the run whose interrupt history is worth having. */ +/**************************************************************************/ + +extern void _tx_timer_interrupt(void); + void board_irq_handler(void); void board_fiq_handler(void); void board_irq_handler(void) { + unsigned long intid = read_icc_iar1() & 0xFFFFFFUL; + + if (intid == (unsigned long) ZX_GUEST_SPURIOUS_INTID) + { + return; + } + + if (intid == (unsigned long) ZX_GUEST_TIMER_INTID) + { + guest_mailbox_write(ZX_GD_TIMER_INTID, intid); + guest_mailbox_write(ZX_GD_IRQ_COUNT, + guest_mailbox_read(ZX_GD_IRQ_COUNT) + 1UL); + + guest_tick_reload(); + + write_icc_eoir1(intid); + + _tx_timer_interrupt(); + + return; + } + + /* Something the hypervisor never granted this partition. Recorded by + INTID rather than as a flag, because "an interrupt arrived" sends a + reader nowhere and "INTID 26 arrived" says the hypervisor's own timer + was delivered to a guest. ZX_GS_IRQ is set as well, so a run that + reports nothing else still says an unexpected interrupt happened. */ + + guest_mailbox_write(ZX_GD_ODD_INTID, intid); guest_mailbox_write(ZX_GD_STAGE1, guest_mailbox_read(ZX_GD_STAGE1) | ZX_GS_IRQ); - guest_yield(); + + write_icc_eoir1(intid); } +/**************************************************************************/ +/* board_fiq_handler */ +/* */ +/* A partition is granted no Group 0 interrupt, so nothing can deliver an */ +/* FIQ to it: the guest never enables ICC_IGRPEN0, and the hypervisor */ +/* puts its timer PPI in Group 1. This exists because the port's board */ +/* support references it, and an unresolved symbol would fail the LINK */ +/* rather than the run. */ +/* */ +/* Reaching it anyway means an assumption above is wrong, so it records */ +/* that and hands the machine back rather than returning into a context */ +/* with no reason to be sound. Yielding is right HERE and would be wrong */ +/* in the IRQ handler above, and the difference is that an IRQ is */ +/* expected: an unexpected FIQ has no re-arm, no acknowledge and no */ +/* handler behind it, so returning would leave it asserted and turn one */ +/* interrupt into a storm. */ +/**************************************************************************/ + void board_fiq_handler(void) { guest_mailbox_write(ZX_GD_STAGE1, diff --git a/examples/guest_common/zx_guest_bsp.h b/examples/guest_common/zx_guest_bsp.h index fc44b3f..3c3bba2 100644 --- a/examples/guest_common/zx_guest_bsp.h +++ b/examples/guest_common/zx_guest_bsp.h @@ -86,6 +86,43 @@ void console_set_quiet(unsigned int quiet); void guest_yield(void); +/**************************************************************************/ +/* The clock a partition is granted */ +/* */ +/* A partition owns no timer device. What it has is the VIRTUAL timer */ +/* and the virtual counter, whose zero point the hypervisor moves with */ +/* CNTVOFF so that time does not pass while the partition is not running. */ +/* Every one of these is a system-register access; none of them touches a */ +/* device, and none of them can be denied to EL1 -- it is the PHYSICAL */ +/* counter that ZoneX keeps out of a partition's reach. */ +/* */ +/* board_init below decides whether the timer is armed at all, from a */ +/* word the hypervisor left in the mailbox. These are separate from it */ +/* so that the guest APPLICATION can ask its own questions -- above all */ +/* "is this counter moving?", which is what stands between a target whose */ +/* system counter was never started and a run that ends in a timeout. */ +/**************************************************************************/ + +/* CNTFRQ, which only the hypervisor can have written. Zero means nobody + did, and dividing by it is a divide by zero rather than a slow tick. */ + +unsigned long guest_counter_frequency(void); + +/* The low half of CNTVCT. Enough to answer "is it moving"; deliberately + not enough to be mistaken for a wall clock. */ + +unsigned long guest_virtual_count(void); + +/* Restart the down-count, which also deasserts the timer's level output. + Called on every tick from inside the interrupt handler. */ + +void guest_tick_reload(void); + +/* Read the virtual counter twice, bounded, and say whether it moved. The + bound is what makes this a check rather than a second way to hang. */ + +unsigned int guest_counter_is_moving(void); + /**************************************************************************/ /* The stage-1 MPU */ /**************************************************************************/ diff --git a/examples/guest_common/zx_guest_head.S b/examples/guest_common/zx_guest_head.S index a5059ad..51b82a1 100644 --- a/examples/guest_common/zx_guest_head.S +++ b/examples/guest_common/zx_guest_head.S @@ -21,14 +21,19 @@ @ are here because both sides of the hypervisor boundary have to agree @ about where they are: @ -@ +0x00 the readback mailbox, one granule, zero-filled -@ +0x40 one branch to the kernel's own reset symbol -@ +0x80 the guest's OWN EL1 vector table +@ +0x00 the readback mailbox, two granules, zero-filled +@ +0x80 one branch to the kernel's own reset symbol +@ +0x84 the image header: which window this image was linked for @ -@ The offsets are declared in examples/common/zx_guest_abi.h and asserted -@ by the guest's linker script, so a section that grew past its slot fails -@ the LINK rather than moving an entry point the hypervisor computes as a -@ constant. +@ Those three ARE the contract, and only those three: they are declared in +@ examples/common/zx_guest_abi.h and asserted by the guest's linker script, +@ so a section that grew past its slot fails the LINK rather than moving an +@ entry point the hypervisor computes as a compile-time constant. +@ +@ The guest's own EL1 vector table follows, 32-byte aligned as VBAR +@ requires, and its address is deliberately NOT part of the contract: the +@ guest writes VBAR from the symbol, so the linker may put the table +@ wherever it likes and the hypervisor never needs to know where. @ @ WHY THE MAILBOX IS ASSEMBLED HERE AND NOT DECLARED IN C @ @@ -50,12 +55,40 @@ @ So the guest replaces VBAR with the table below, in which a data abort @ RECORDS ITS SYNDROME IN THE MAILBOX AND HANDS CONTROL BACK. That is what @ makes a stage-1 fault a reported event. It also demonstrates something a -@ later step needs anyway: VBAR is per-guest state, a guest owns it, and a +@ later phase needs anyway: VBAR is per-guest state, a guest owns it, and a @ partition switch has to save and restore it. @ @ Replacing it is ordinary guest code -- VBAR is writable at EL1 -- and @ requires no change whatever to ports/cortex_r52. @ +@ WHICH TABLE WINS FOR AN INTERRUPT, AND WHY IT IS NOT THIS ONE +@ +@ Once a partition has a timer of its own, one vector in this table stops +@ being a diagnostic and becomes the kernel's scheduling path. Two tables +@ then have a claim on it: this one, which the guest installed, and the +@ port's, whose IRQ vector is a branch into _tx_thread_context_save and is +@ the only correct way to enter a ThreadX interrupt handler. +@ +@ The answer is BOTH, in the only order that works. VBAR keeps pointing +@ here -- so a fault is still reported rather than halting the model -- and +@ the IRQ slot is a single branch to the port's own el1_irq_entry. The +@ context save, the handler call and the restore are then the PORT'S code, +@ unmodified and identical to the one a standalone kernel runs. +@ +@ Writing an interrupt entry sequence here instead was the alternative, and +@ it would have been a second implementation of the most delicate assembly +@ in the port: _tx_thread_context_save adjusts LR to find the point of +@ interrupt, and the branch to it must be a plain B rather than a BL for +@ exactly that reason. A guest that gets that subtly wrong does not fail +@ at the vector -- it corrupts one thread's resume address and fails +@ somewhere else entirely. +@ +@ el1_irq_entry exists only when the guest is built with the port's +@ TX_R52_USE_THREADX_IRQ, which is also what makes the port call board_init +@ from its low-level initialisation. The ZoneX guest is always built with +@ it; whether a tick actually arrives is decided per excursion by a word in +@ the mailbox, so the cooperative and preemptive runs are the same bytes. +@ @ WHY A STAGE-1 FAULT AND A STAGE-2 FAULT CANNOT BE CONFUSED @ @ They arrive in different places. A stage-2 violation is a fault in the @@ -71,12 +104,19 @@ .arm /**************************************************************************/ -/* The readback mailbox: one granule at the window base. */ +/* The readback mailbox: two granules at the window base. */ /* */ -/* Sixteen words of zero, in its own loaded section. The hypervisor */ +/* Thirty-two words of zero, in its own loaded section. The hypervisor */ /* writes the handover fields into it after copying the image and reads */ /* the reported ones out afterwards; stage-2 AP cannot deny EL2 access to */ /* a partition's memory, which is what makes the mailbox free. */ +/* */ +/* The second granule is what a guest with a timer of its own has to say, */ +/* and it costs nothing at stage 2 -- the partition window is one region */ +/* covering the whole of it. It does cost the guest's stage-1 region for */ +/* the mailbox 64 more bytes, and it moves the entry branch, which is why */ +/* the offsets in zx_guest_abi.h and the ASSERTs in the guest's linker */ +/* script both name 0x80 rather than 0x40. */ /**************************************************************************/ .section .zx_guest_mailbox, "aw", %progbits @@ -260,5 +300,35 @@ zx_guest_trap_pabt_hang: ZX_GUEST_TRAP_OTHER zx_guest_trap_undef, ZX_GS_UNDEF ZX_GUEST_TRAP_OTHER zx_guest_trap_svc, ZX_GS_OTHER ZX_GUEST_TRAP_OTHER zx_guest_trap_reserved, ZX_GS_OTHER - ZX_GUEST_TRAP_OTHER zx_guest_trap_irq, ZX_GS_IRQ ZX_GUEST_TRAP_OTHER zx_guest_trap_fiq, ZX_GS_OTHER + +/**************************************************************************/ +/* zx_guest_trap_irq -- one branch, into the port's own IRQ vector body. */ +/* */ +/* A PLAIN B AND NOT A BL, and the difference is not stylistic. The */ +/* first thing el1_irq_entry does is branch to _tx_thread_context_save, */ +/* which adjusts LR to recover the point of interrupt. Arriving there */ +/* with LR pointing back into this file would save a resume address */ +/* inside a trampoline, and the thread would return to the wrong place */ +/* the next time it was scheduled -- a corruption that shows up nowhere */ +/* near the vector that caused it. */ +/* */ +/* Nothing else happens here. Recording the interrupt in the mailbox */ +/* would mean touching a register before the port's context save had run, */ +/* which is the one thing that path requires nobody does. The counting */ +/* is done in board_irq_handler, which the port calls once the context is */ +/* safe. */ +/* */ +/* WHEN NO TICK WAS GRANTED this vector is installed and never taken: the */ +/* guest's CPU interface is left disabled, so the GIC has nowhere to */ +/* deliver to. An interrupt arriving anyway would land in the kernel's */ +/* own handler and be counted as an unexpected INTID, which is a better */ +/* outcome than the previous behaviour -- reporting and yielding -- that */ +/* it replaces, because it says WHICH interrupt arrived. */ +/**************************************************************************/ + + .global zx_guest_trap_irq + .type zx_guest_trap_irq, %function +zx_guest_trap_irq: + b el1_irq_entry + .size zx_guest_trap_irq, . - zx_guest_trap_irq diff --git a/examples/s32z280_evb/CMakeLists.txt b/examples/s32z280_evb/CMakeLists.txt index 772678e..69f321a 100644 --- a/examples/s32z280_evb/CMakeLists.txt +++ b/examples/s32z280_evb/CMakeLists.txt @@ -156,6 +156,7 @@ if(ZX_THREADX_SOURCE_DIR) function(zx_add_s32z_one_partition target_name) add_executable(${target_name} EXCLUDE_FROM_ALL ${ZX_COMMON_DIR}/zx_one_partition.c + ${ZX_COMMON_DIR}/zx_guest_launch.c ${ZX_COMMON_DIR}/zx_report.c ${ZX_COMMON_DIR}/zx_guest_image.S ${ZX_S32Z_DIR}/zx_board.c @@ -207,6 +208,78 @@ if(ZX_THREADX_SOURCE_DIR) zx_add_s32z_one_partition(zx_one_partition_granted.elf) target_compile_definitions(zx_one_partition_granted.elf PRIVATE ZX_ONE_PROBE_GRANTED) + + ########################################################################### + # The PREEMPTIVE partition: the same guest, granted a clock + ########################################################################### + # + # A separate image rather than another build of zx_one_partition.c, + # because of what each of them MEASURES: the cooperative one runs a pair + # of passes differing in HCR.VM and nothing else, so its number is the + # cost of stage 2, and a tick would have been a third variable in that + # pair. This one keeps stage 2 on throughout and measures two identical + # excursions instead. + # + # They share the loader and they share the GUEST blob byte for byte: + # whether a partition has a clock is a word in the mailbox rather than a + # build option. + # + # ON THIS BOARD THE GIC IS THE INTERESTING PART. It sits in the Normal + # write-through band of the background map, so it already costs a Device + # EL2 region -- which the model cannot show -- and every frame of it stays + # at guest-no-access. A partition here reaches its timer through system + # registers and through nothing else. + function(zx_add_s32z_preemptive target_name) + add_executable(${target_name} EXCLUDE_FROM_ALL + ${ZX_COMMON_DIR}/zx_preemptive_partition.c + ${ZX_COMMON_DIR}/zx_guest_launch.c + ${ZX_COMMON_DIR}/zx_report.c + ${ZX_COMMON_DIR}/zx_guest_image.S + ${ZX_S32Z_DIR}/zx_board.c + ) + + add_dependencies(${target_name} zx_guest_a) + + target_include_directories(${target_name} PRIVATE + ${ZX_S32Z_DIR} + ${ZX_COMMON_DIR} + ) + + set_source_files_properties(${ZX_COMMON_DIR}/zx_guest_image.S + PROPERTIES + COMPILE_OPTIONS "-Wa,-I${ZX_GUEST_A_BUILD}" + OBJECT_DEPENDS "${ZX_GUEST_A_BLOB}" + ) + + target_link_libraries(${target_name} PRIVATE + eclipse-threadx::zonex + zonex::warnings + ) + + target_link_options(${target_name} PRIVATE + -T${ZX_S32Z_DIR}/zx_one_partition.lds + -nostartfiles + -Wl,-Map=${target_name}.map + ${ZX_LINK_QUIET_RWX} + ) + + set_target_properties(${target_name} PROPERTIES + LINK_DEPENDS ${ZX_S32Z_DIR}/zx_one_partition.lds + ) + endfunction() + + zx_add_s32z_preemptive(zx_preemptive.elf) + + # The two negative verifications, breaking different halves: an interrupt + # the guest cannot enable for itself, and a clock the hypervisor forgets + # to give back. Each must report FAILED. + zx_add_s32z_preemptive(zx_preemptive_no_ppi.elf) + target_compile_definitions(zx_preemptive_no_ppi.elf PRIVATE + ZX_PREEMPT_NO_PPI) + + zx_add_s32z_preemptive(zx_preemptive_no_freeze.elf) + target_compile_definitions(zx_preemptive_no_freeze.elf PRIVATE + ZX_PREEMPT_NO_FREEZE) else() message(STATUS "ZoneX: ZX_THREADX_SOURCE_DIR is empty, so the ThreadX guest images " diff --git a/examples/s32z280_evb/guest_a/CMakeLists.txt b/examples/s32z280_evb/guest_a/CMakeLists.txt index 47a1e71..401f92c 100644 --- a/examples/s32z280_evb/guest_a/CMakeLists.txt +++ b/examples/s32z280_evb/guest_a/CMakeLists.txt @@ -72,18 +72,38 @@ set(GUEST_DIR ${CMAKE_CURRENT_LIST_DIR}) # exists to defer to, and it was written that way # from the start of the Cortex-R52 port. # tx_initialize_low_level.S publishes the system stack and first free -# address. Built WITHOUT TX_R52_USE_THREADX_IRQ, -# so it creates no tick and calls no board_init: -# this guest takes no interrupts. +# address. Built WITH TX_R52_USE_THREADX_IRQ, so +# it calls board_init -- which the ZoneX guest +# support supplies -- and so that entry.S routes +# the EL1 IRQ vector into _tx_thread_context_save. +# The guest's OWN vector table is still what VBAR +# points at; its IRQ slot is one branch into the +# port's vector body, so the interrupt entry +# sequence is the port's own and unmodified. +# WHETHER A TICK ACTUALLY ARRIVES is decided per +# excursion by a word in the mailbox, not by this +# build, so the cooperative run and the preemptive +# run are the same bytes. # # console.c NOT used -- the guest has no UART, it hypercalls. On this # board that matters more than on the model: the standalone # console is LINFlexD_9 at 0x42980000, a device a partition # would otherwise need a stage-2 region for. -# gicv3.c NOT used -- the distributor is shared between partitions and -# belongs to the hypervisor. -# timer.c NOT used -- a partition's clock is the hypervisor's to hand -# out, so that a descheduled partition's time can be frozen. +# gicv3.c NOT used, and this is the sharpest of the omissions. Every +# register it touches is MEMORY-MAPPED: the distributor, which +# is one per system, and the redistributor, which is one per +# CORE and holds the enable bits for every partition's PPIs and +# for the hypervisor's own timer. A partition that could write +# that frame could disable the interrupt that ends its window. +# So ZoneX keeps all of it and enables this partition's timer +# PPI on its behalf; the guest brings up only its CPU +# interface, which is system registers and needs no device. +# See guest_interrupts_init in zx_guest_bsp.c. +# timer.c NOT used -- it drives the PHYSICAL timer, whose time keeps +# running while a partition is descheduled, and it starts the +# system counter, which is a system-wide resource. The guest +# arms the VIRTUAL timer instead, in zx_guest_bsp.c, and ZoneX +# starts the counter and moves CNTVOFF. # mpu.c NOT used -- its region table describes a whole board. The # guest programs its own three regions inside its window. # tcm.c NOT used, and this one is a silicon fact rather than a @@ -158,7 +178,15 @@ function(zx_add_guest target_name) ${TX_PORT_BSP}/tx_initialize_low_level.S ) - target_compile_definitions(${target_name} PRIVATE TX_R52_BOOT_AT_EL1) + # TX_R52_BOOT_AT_EL1 skips the port's own EL2 configuration block -- ZoneX + # is the EL2 monitor that block exists to defer to. TX_R52_USE_THREADX_IRQ + # gives the port's EL1 IRQ vector its real body and makes the low-level + # initialisation call board_init. Both are the port's OWN options, used as + # the port documents them; neither is a ZoneX patch to ports/cortex_r52. + target_compile_definitions(${target_name} PRIVATE + TX_R52_BOOT_AT_EL1 + TX_R52_USE_THREADX_IRQ + ) target_include_directories(${target_name} PRIVATE ${GUEST_DIR} diff --git a/examples/s32z280_evb/guest_a/link_partition_a.lds b/examples/s32z280_evb/guest_a/link_partition_a.lds index 1787513..104f8dc 100644 --- a/examples/s32z280_evb/guest_a/link_partition_a.lds +++ b/examples/s32z280_evb/guest_a/link_partition_a.lds @@ -78,7 +78,8 @@ MEMORY SECTIONS { - /* +0x00 the mailbox: LOADED, outside .bss, at the window base. See + /* +0x00 the mailbox, two granules: LOADED, outside .bss, at the + window base. See the model's script for why all three of those are forced. */ .zx_guest_mailbox : ALIGN(64) @@ -89,7 +90,7 @@ SECTIONS __zx_guest_mailbox_end = .; } > WINDOW - /* +0x40 the entry branch, then everything executable. */ + /* +0x80 the entry branch, then everything executable. */ .zx_guest_head : ALIGN(64) { @@ -180,9 +181,15 @@ SECTIONS ASSERT(__zx_guest_mailbox_start == __zx_guest_window_base, "the mailbox is not at the base of the partition window") - ASSERT((__zx_guest_mailbox_end - __zx_guest_mailbox_start) == 64, - "the mailbox is not exactly one granule") - ASSERT(__zx_guest_code_start == (__zx_guest_window_base + 0x40), + /* TWO granules, and the number is ZX_GD_WINDOW_SIZE in + examples/common/zx_guest_abi.h. A linker script cannot include a + C header, so the constant is restated here and asserted -- which + is the point: a mailbox that grew past its slot fails the LINK + rather than moving an entry point the hypervisor computes as a + compile-time constant. */ + ASSERT((__zx_guest_mailbox_end - __zx_guest_mailbox_start) == 128, + "the mailbox is not exactly two granules") + ASSERT(__zx_guest_code_start == (__zx_guest_window_base + 0x80), "the entry branch is not at the offset the hypervisor ERETs to") ASSERT(_start >= __zx_guest_code_start, "the kernel's reset symbol is outside the guest's code region") diff --git a/examples/s32z280_evb/zx_board.c b/examples/s32z280_evb/zx_board.c index e38d268..d54bb70 100644 --- a/examples/s32z280_evb/zx_board.c +++ b/examples/s32z280_evb/zx_board.c @@ -359,6 +359,56 @@ uint32_t zx_board_counter_hz(void) } +/**************************************************************************/ +/* zx_board_counter_start */ +/* */ +/* NOTHING TO DO, AND THE EMPTINESS IS THE FACT. */ +/* */ +/* This part's generic-timer counter is already running when ZoneX gets */ +/* the core. It is clocked by CNTCLKEN, which the RTU derives from a */ +/* cluster clock through CFG_CNTDV -- a divider whose reset value is 4, */ +/* so the divider is 5 and the 40 MHz crystal gives 8 MHz. There is no */ +/* counter control frame for a hypervisor to enable: the clock tree does */ +/* it, before any software runs. */ +/* */ +/* The Armv8-R AEM FVP is the opposite -- its counter is stopped at reset */ +/* and one register write starts it -- which is exactly why this is a */ +/* board hook rather than something the shared code decides. It is also */ +/* why the CHECK is separate: zx_counter_is_running asks whether the */ +/* counter moved, which is answered the same way on both boards and is */ +/* the question a guest about to block on a timer actually cares about. */ +/**************************************************************************/ + +void zx_board_counter_start(void) +{ + /* Intentionally empty. See above: this part has no counter control + frame, and its counter is running before ZoneX exists. */ +} + + +/**************************************************************************/ +/* zx_board_gic_layout */ +/* */ +/* The three frames inside the 2 MB GIC window this board already maps */ +/* Device-nGnRnE for EL2 only. Naming them here rather than in the */ +/* Cortex-R52 layer is what keeps that layer free of board addresses: */ +/* the offsets inside a GICv3 are architectural and live there, the base */ +/* is a board fact and lives here. */ +/**************************************************************************/ + +void zx_board_gic_layout(ZX_GIC_LAYOUT *layout_ptr) +{ + if (layout_ptr == (ZX_GIC_LAYOUT *)0) + { + return; + } + + layout_ptr->zx_gic_dist_base = (zx_addr_t)ZX_S32Z_GICD_BASE; + layout_ptr->zx_gic_rd_base = (zx_addr_t)ZX_S32Z_GICR_RD_BASE; + layout_ptr->zx_gic_sgi_base = (zx_addr_t)ZX_S32Z_GICR_SGI_BASE; +} + + /**************************************************************************/ /* zx_board_describe_mmio_regions */ /* */ diff --git a/examples/s32z280_evb/zx_one_partition.lds b/examples/s32z280_evb/zx_one_partition.lds index b7bf7b8..c8d2cc5 100644 --- a/examples/s32z280_evb/zx_one_partition.lds +++ b/examples/s32z280_evb/zx_one_partition.lds @@ -227,12 +227,22 @@ SECTIONS /* THE BLOB IS NOT EMPTY, and this is the check that catches an .incbin whose file was missing or a linker input pattern that matched - nothing. Asserted against the size of the image header rather than + nothing. Asserted against the END OF THE IMAGE HEADER rather than against zero, because a blob too small to carry its own header is just as useless as one of no size at all -- and the loader's magic - check would then be reading past the end of the section. */ - - ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) > 0x50, + check would then be reading past the end of the section, which is + the out-of-bounds read this assert exists to prevent. + + 0x90 is ZX_GUEST_IMAGE_OFF_MAGIC + 4, from + examples/common/zx_guest_abi.h. A linker script cannot include a C + header, so the number is restated here -- and it is restated WRONG + the moment the header moves, which is exactly what happened when the + mailbox grew to two granules and pushed the entry branch and the + header from 0x40 to 0x80. The bound stayed at 0x50, so a blob of + 0x51 to 0x8F bytes linked cleanly and the magic check read past the + section into whatever followed it. */ + + ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) >= 0x90, "the embedded guest image is empty or too small to carry its header") ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) diff --git a/examples/s32z280_evb/zx_platform.h b/examples/s32z280_evb/zx_platform.h index 1f639ab..1028caa 100644 --- a/examples/s32z280_evb/zx_platform.h +++ b/examples/s32z280_evb/zx_platform.h @@ -130,6 +130,22 @@ #define ZX_S32Z_GIC_BASE 0x47800000UL #define ZX_S32Z_GIC_SIZE 0x00200000UL /* 2 MB */ +/* The three frames inside it, at the offsets Cortex-R52 TRM Table 9-1 gives: + the distributor at the base, then a redistributor made of TWO consecutive + 64 KB frames per core -- RD, which carries GICR_WAKER, and SGI, which + carries the enable, group, priority and configuration bits for INTIDs 0 to + 31. The same offsets as the Armv8-R AEM FVP's, which is not a coincidence: + they are architectural, and only the base differs. + + ALL THREE STAY AT ZX_AP_EL2_RW_GUEST_NONE. A partition reaches its own + interrupt through system registers and needs none of this mapped -- and it + must not have the SGI frame, which holds the enable bit for the + hypervisor's own timer as well as its own. See docs/decisions.md D24. */ + +#define ZX_S32Z_GICD_BASE (ZX_S32Z_GIC_BASE + 0x000000UL) +#define ZX_S32Z_GICR_RD_BASE (ZX_S32Z_GIC_BASE + 0x100000UL) +#define ZX_S32Z_GICR_SGI_BASE (ZX_S32Z_GIC_BASE + 0x110000UL) + /* Memory-mapped register access. MISRA C:2012 Rule 11.4/11.6 deviation: casting an integer address to a volatile pointer is inherent to memory-mapped device access. */ diff --git a/platform/cortex_r52/inc/zx_port.h b/platform/cortex_r52/inc/zx_port.h index 2898e8a..ad16780 100644 --- a/platform/cortex_r52/inc/zx_port.h +++ b/platform/cortex_r52/inc/zx_port.h @@ -235,6 +235,37 @@ #define ZX_ICC_HSRE_SRE ZX_BIT(0) #define ZX_ICC_HSRE_ENABLE ZX_BIT(3) +/**************************************************************************/ +/* The interrupts ZoneX knows about */ +/**************************************************************************/ + +/* Three timer interrupts arrive as PPIs on this core (TRM section 9), and + which one a partition gets is a design decision rather than a detail: + + 30 the PHYSICAL timer. A partition must not have it -- its time keeps + running while the partition is descheduled, and a guest reading it + can therefore observe that it was not running. + 27 the VIRTUAL timer. What a partition gets, because CNTVOFF freezes + its counter while the partition is off the core. + 26 the HYPERVISOR timer. ZoneX's own, and the reason a partition may + not reach the redistributor: the enable bit for this INTID lives in + the same frame as the enable bit for INTID 27. + + See docs/decisions.md D7. The guest restates INTID 27 as + ZX_GUEST_TIMER_INTID in examples/common/zx_guest_abi.h -- it is a separate + program and cannot include this header -- and the example that includes + both asserts the two agree at compile time. */ + +#define ZX_PPI_PHYSICAL_TIMER 30U +#define ZX_PPI_VIRTUAL_TIMER 27U +#define ZX_PPI_HYPERVISOR_TIMER 26U + +/* What ICC_IAR1 returns when nothing was pending. It must NOT be given an + end-of-interrupt. */ + +#define ZX_INTID_SPURIOUS 1023U + + /**************************************************************************/ /* Semihosting (FVP console and exit) */ /**************************************************************************/ @@ -329,6 +360,31 @@ to build and check the same objects with no Cortex-R52 header in reach. */ #include "zx_manifest.h" +/**************************************************************************/ +/* Where a board's GICv3 frames actually are */ +/* */ +/* PASSED IN RATHER THAN COMPILED IN, because this translation unit is */ +/* the PART's and not a board's. The Cortex-R52 layer knows the register */ +/* offsets inside a GICv3 -- which are architectural -- and knows nothing */ +/* about where the frames sit, which is a board fact and lives in the */ +/* example's zx_platform.h. A #include of a board header here is how a */ +/* port stops being portable between the two targets it already has. */ +/* */ +/* A redistributor is TWO consecutive 64 KB frames per core: the RD frame */ +/* carries GICR_WAKER, and the SGI frame carries the enable, group, */ +/* priority and configuration bits for INTIDs 0 to 31. Both are needed */ +/* and they are named separately because getting them the wrong way round */ +/* writes plausible values into the wrong registers and reads back zero. */ +/**************************************************************************/ + +typedef struct ZX_GIC_LAYOUT_STRUCT +{ + zx_addr_t zx_gic_dist_base; /* GICD */ + zx_addr_t zx_gic_rd_base; /* GICR, the RD frame */ + zx_addr_t zx_gic_sgi_base; /* GICR, the SGI frame */ + +} ZX_GIC_LAYOUT; + #ifdef __cplusplus extern "C" { #endif @@ -462,6 +518,159 @@ void zx_stage2_enable_set(uint32_t mask); void zx_el2_prepare_guest_el1(uint32_t counter_hz); +/**************************************************************************/ +/* The GIC, which EL2 owns whole */ +/* */ +/* Implemented in zx_gic.c. Every function here touches memory-mapped */ +/* GIC state, which is exactly the state no partition is given: the */ +/* distributor is one per system and the redistributor is one per CORE, */ +/* so the frame that holds a partition's timer enable also holds the */ +/* hypervisor's. A guest reaches only its own CPU interface, which is */ +/* system registers. */ +/**************************************************************************/ + +/* Bring the distributor and this core's redistributor up. Returns non-zero + when the redistributor reported its children awake, zero when the bounded + wait ran out -- which is a real outcome and not a hang, because a GIC that + never wakes would otherwise stop the boot with nothing printed. */ + +ZX_NODISCARD uint32_t zx_gic_el2_init(const ZX_GIC_LAYOUT *gic_ptr); + +/* Enable one PPI for delivery to EL1: Group 1, level-triggered, at the given + priority. Level and not edge because the generic timer asserts a level. */ + +void zx_gic_enable_guest_ppi(const ZX_GIC_LAYOUT *gic_ptr, uint32_t intid, + uint32_t priority); + +/* Disable one PPI again. A partition that is not running must not have an + interrupt of its own delivered, and this is how a window ends cleanly. */ + +void zx_gic_disable_guest_ppi(const ZX_GIC_LAYOUT *gic_ptr, uint32_t intid); + +/* READ BACK what was programmed, for the same reason the region set is read + back: a write that went to the wrong offset does not fault, it does + nothing, and a partition that silently never receives its timer looks + exactly like a kernel whose scheduler is broken. */ + +ZX_NODISCARD uint32_t zx_gic_ppi_is_enabled(const ZX_GIC_LAYOUT *gic_ptr, + uint32_t intid); +ZX_NODISCARD uint32_t zx_gic_ppi_is_group1(const ZX_GIC_LAYOUT *gic_ptr, + uint32_t intid); +ZX_NODISCARD uint32_t zx_gic_ppi_priority(const ZX_GIC_LAYOUT *gic_ptr, + uint32_t intid); + +/* How many priority bits this GIC actually implements, discovered by writing + all ones to one INTID's priority byte and reading back what stuck. The low + bits vanish, so two priorities differing only there collapse together -- + which is the difference between an interrupt that can preempt another and + one that quietly cannot. Restores the byte it borrowed. */ + +ZX_NODISCARD uint32_t zx_gic_priority_bits(const ZX_GIC_LAYOUT *gic_ptr, + uint32_t intid); + +/**************************************************************************/ +/* The clock a partition is allowed to see */ +/* */ +/* Implemented in zx_timer.c. A partition reads time through the VIRTUAL */ +/* counter, which is the physical one minus CNTVOFF, and CNTVOFF is the */ +/* hypervisor's. Advancing it by exactly the time a partition spent off */ +/* the core is what makes a descheduled partition's clock FROZEN rather */ +/* than merely unread -- the difference between temporal partitioning and */ +/* time slicing that a guest can observe. See docs/decisions.md D7. */ +/**************************************************************************/ + +/* The physical counter, which no partition can reach. 64-bit, read as a + pair. */ + +ZX_NODISCARD uint64_t zx_read_cntpct(void); + +/* Bounded: read the physical counter twice and say whether it moved. + Programming CNTFRQ does not START the counter, and on one of the two ZoneX + targets it is left stopped at reset -- so this is the check that stands + between a partition granted a timer and a run that ends in a timeout. */ + +ZX_NODISCARD uint32_t zx_counter_is_running(void); + +/* Set this partition's virtual time to zero and start it running. Called + once, when a partition is (re)loaded, so that every excursion of a guest + begins from the same point on its own clock -- which is what makes two + runs of the same guest comparable. */ + +void zx_el2_guest_time_reset(void); + +/* Freeze and unfreeze the partition's virtual counter. suspend() records + the physical count; resume() adds everything that elapsed since to + CNTVOFF, so the virtual counter never advanced. With one partition the + gap being closed is the hypervisor's own work between excursions; with + more than one it is the other partitions' windows. */ + +void zx_el2_guest_time_suspend(void); +void zx_el2_guest_time_resume(void); + +/* A resume that does NOT give the time back. It exists so that the freeze + can be seen to FAIL, and it keeps the bookkeeping so that what breaks is + the mechanism rather than the measurement. */ + +void zx_el2_guest_time_resume_uncredited(void); + +/* CNTVOFF as it stands, for reporting. A number nobody can see is a + mechanism nobody can check. */ + +ZX_NODISCARD uint64_t zx_el2_guest_time_offset(void); + +/* The partition's OWN clock, now: the physical counter seen past CNTVOFF, + which is the number a guest reading CNTVCT would get. */ + +ZX_NODISCARD uint64_t zx_el2_guest_virtual_count(void); + +/* And the two halves of the last freeze point: the physical count when the + partition stopped, and what its own clock read at that instant. + * + * These are what make the freeze CHECKABLE. The invariant is exact -- + * after a resume, zx_el2_guest_virtual_count() equals + * zx_el2_guest_virtual_when_suspended() -- and the physical distance between + * the two instants is the interval the partition was excluded from. + * Measuring either one anywhere else measures the drift that CNTVOFF has not + * corrected yet, which looks exactly like a broken freeze. */ + +ZX_NODISCARD uint64_t zx_el2_guest_time_suspended_at(void); +ZX_NODISCARD uint64_t zx_el2_guest_virtual_when_suspended(void); + +/* And where the counter was when the partition was given the core back. + * + * (suspended_at, resumed_at) is the interval the partition was excluded + * from, as the MECHANISM measured it, which is what CNTVOFF is credited + * with. A check of the freeze compares the offset's actual movement -- read + * back out of the register -- against exactly this interval, and every term + * in that comparison is then a hardware read rather than a restatement of + * the arithmetic being checked. + * + * Reading the counter again from outside does not give the same number: on + * the S32Z280 a CNTPCT read crosses into an 8 MHz clock domain, so the few + * reads between a resume and a check cost 64 counts of real time. Measured, + * on the board. */ + +ZX_NODISCARD uint64_t zx_el2_guest_time_resumed_at(void); + +/* Stop a partition's virtual timer. Per-guest state, and the reason it is + here rather than left to the guest is that a partition can be taken from + at any instant -- including with its timer armed and its level asserted at + the GIC. */ + +void zx_el2_guest_timer_stop(void); + +/* Spend a known interval at EL2, measured in counter counts. + * + * A hypervisor has no reason to wait; this exists so that "a partition's + * clock does not advance while it is not running" can be CHECKED, which + * needs a real interval in which the partition is not running. Measuring + * the gap across whatever work happened to be there is not good enough and + * was tried: the model's console costs it no simulated time, so the gap was + * zero counts long and both the positive and the deliberately-broken build + * reported the clock frozen. */ + +void zx_el2_dwell(uint32_t counts); + /**************************************************************************/ /* Making a copied image executable */ /**************************************************************************/ diff --git a/platform/cortex_r52/src/zx_gic.c b/platform/cortex_r52/src/zx_gic.c index 5d954b8..9cdd22d 100644 --- a/platform/cortex_r52/src/zx_gic.c +++ b/platform/cortex_r52/src/zx_gic.c @@ -21,19 +21,521 @@ /* */ /* DESCRIPTION */ /* */ -/* GICv3 bring-up for EL2, and the routing of guest interrupts. */ +/* GICv3 bring-up for EL2, and the one interrupt a partition is given. */ /* */ -/* The Cortex-R52 does implement the GICv3 virtual CPU interface -- */ -/* ICH_HCR, ICH_VTR and four List Registers, ICH_LR0 to ICH_LR3 -- so */ -/* interrupt injection is available. Phase 0 still keeps interrupt */ -/* handling deliberately simple; that is a scope decision rather than */ -/* a hardware limitation, and it is recorded as one so a later phase */ -/* does not rediscover the List Registers from scratch. */ +/* EVERY REGISTER IN THIS FILE IS MEMORY-MAPPED, AND THAT IS THE POINT. */ /* */ -/* This translation unit is deliberately empty of implementation. */ -/* See docs/armv8r-el2-reference.md for the verified register sheet */ -/* the code that lands here must be written against. */ +/* A GICv3 interrupt reaches a thread through three pieces of state: */ +/* */ +/* the DISTRIBUTOR one per system */ +/* the REDISTRIBUTOR one per CORE -- two 64 KB frames, of which the */ +/* second holds the enable, group, priority and */ +/* edge/level bits for INTIDs 0 to 31 */ +/* the CPU INTERFACE per exception level, and on this part entirely */ +/* SYSTEM REGISTERS */ +/* */ +/* ZoneX keeps the first two and gives a partition the third. The */ +/* division is not "shared things belong to the hypervisor", which is */ +/* true but weak; it is that the redistributor frame holding a */ +/* partition's timer enable is the SAME FRAME that holds the enable for */ +/* PPI 26, the hypervisor's own timer -- the interrupt that ends a */ +/* partition's window. A partition able to write that frame could */ +/* disable it and never be descheduled again. No amount of stage-2 */ +/* region programming would show that, because nothing about it is a */ +/* memory-isolation failure: it is the temporal-determinism claim, */ +/* lost. See docs/decisions.md D24. */ +/* */ +/* The consequence for the guest is small and worth stating: it needs */ +/* NO device mapping at all. Its four writes -- priority mask, binary */ +/* point, group enable, and end-of-interrupt -- are system registers, */ +/* and so is the acknowledge that pairs with them. */ +/* */ +/* WHAT THIS FILE DELIBERATELY DOES NOT DO */ +/* */ +/* It does not INJECT. The Cortex-R52 implements the GICv3 virtual CPU */ +/* interface -- ICH_HCR, ICH_VTR and four List Registers, ICH_LR0 to */ +/* ICH_LR3, confirmed on both targets -- so injection is available. */ +/* ZoneX does not use it, because HCR.IMO is CLEAR: a physical */ +/* interrupt taken while EL1 is running is delivered STRAIGHT to EL1, */ +/* and the hypervisor is not involved in a tick at all. */ +/* */ +/* That is the cheapest possible mechanism and it costs exactly one */ +/* thing, which is written down here rather than discovered later: with */ +/* IMO clear, the hypervisor cannot take an interrupt of its own while */ +/* a partition is running either. A hypervisor tick that ENDS a */ +/* partition's window therefore needs IMO SET, and with IMO set every */ +/* guest interrupt has to be injected through a List Register. That is */ +/* the change time partitioning brings, and it is a change to this */ +/* file and to zx_trap_handler.S rather than to any guest. */ +/* */ +/* MISRA C:2012 deviations (justified) */ +/* */ +/* Rule 11.4/11.6 -- casting an integer address to a volatile pointer */ +/* is inherent to memory-mapped device access; confined to ZX_GIC_REG */ +/* below and appearing nowhere else in this file. */ +/* Directive 4.3 -- the barriers below are single instructions with no */ +/* C equivalent. */ /* */ /**************************************************************************/ #include "zx_port.h" + +/**************************************************************************/ +/* Register offsets. ARCHITECTURAL, which is why they are here and the */ +/* frame addresses are not: a GICv3 has these offsets wherever it sits. */ +/**************************************************************************/ + +/* Distributor. */ + +#define ZX_GICD_CTLR 0x0000U +#define ZX_GICD_CTLR_ENABLE_GRP0 ZX_BIT(0) +#define ZX_GICD_CTLR_ENABLE_GRP1 ZX_BIT(1) +#define ZX_GICD_CTLR_ARE ZX_BIT(4) +#define ZX_GICD_CTLR_RWP ZX_BIT(31) + +/* Redistributor, RD frame. */ + +#define ZX_GICR_CTLR 0x0000U +#define ZX_GICR_CTLR_RWP ZX_BIT(3) +#define ZX_GICR_WAKER 0x0014U +#define ZX_GICR_WAKER_PROC_SLEEP ZX_BIT(1) +#define ZX_GICR_WAKER_CHILD_ASLEEP ZX_BIT(2) + +/* Redistributor, SGI frame: SGIs and PPIs, INTID 0 to 31. */ + +#define ZX_GICR_IGROUPR0 0x0080U +#define ZX_GICR_ISENABLER0 0x0100U +#define ZX_GICR_ICENABLER0 0x0180U +#define ZX_GICR_IPRIORITYR 0x0400U +#define ZX_GICR_ICFGR1 0x0C04U + +/* The lowest INTID that is a PPI. Below it are the sixteen SGIs, which + have no configurable edge or level -- which is why the ICFGR arithmetic + below subtracts this and why an INTID under it is refused. */ + +#define ZX_PPI_FIRST_INTID 16U + +/* How long to wait for the redistributor to report its children awake. + BOUNDED, because a GIC that never wakes must produce a reported failure + and not a boot that stops with nothing printed. */ + +#define ZX_GIC_WAKE_GUARD 100000U + +#define ZX_GIC_REG(address) (*(volatile uint32_t *)(uintptr_t)(address)) + + +static void zx_gic_barrier(void) +{ + __asm__ volatile("dsb" ::: "memory"); + __asm__ volatile("isb" ::: "memory"); +} + + +/**************************************************************************/ +/* Register-write-pending, and why a DSB is not a substitute for it. */ +/* */ +/* SOME GIC WRITES ARE NOT FINISHED WHEN THE INSTRUCTION THAT MADE THEM */ +/* RETIRES. A DSB orders the bus access -- it says the write has left */ +/* this core -- and says nothing about whether the distributor or the */ +/* redistributor has finished acting on it. The two registers below are */ +/* how the GIC reports that, and the writes they track are exactly the */ +/* ones this file makes: */ +/* */ +/* GICD_CTLR.RWP tracks writes to GICD_CTLR, which is where ARE and */ +/* the group enables live */ +/* GICR_CTLR.RWP tracks writes to GICR_ICENABLER0 -- DISABLES */ +/* specifically; an enable through ISENABLER takes */ +/* effect immediately and is not tracked */ +/* */ +/* THE FAILURE THIS PREVENTS IS SPECIFIC AND IT IS INTERMITTENT. Bring- */ +/* up disables all thirty-two of this core's SGIs and PPIs and then */ +/* enables one of them, in the same frame, a few instructions later. */ +/* Without the wait the in-flight disable may retire AFTER the enable and */ +/* undo it, which leaves the partition arming a virtual timer whose */ +/* interrupt is disabled -- a guest that never ticks, on some boots. */ +/* */ +/* Bounded, like every other wait in this file: a GIC that never retires */ +/* a write has to become a reported failure rather than a boot that stops */ +/* with nothing printed. */ +/**************************************************************************/ + +static uint32_t zx_gic_wait_dist_rwp(const ZX_GIC_LAYOUT *gic_ptr) +{ + uint32_t guard; + + for (guard = 0U; guard < ZX_GIC_WAKE_GUARD; guard++) + { + if ((ZX_GIC_REG(gic_ptr->zx_gic_dist_base + ZX_GICD_CTLR) + & (uint32_t)ZX_GICD_CTLR_RWP) == 0U) + { + return 1U; + } + } + + return 0U; +} + + +static uint32_t zx_gic_wait_redist_rwp(const ZX_GIC_LAYOUT *gic_ptr) +{ + uint32_t guard; + + for (guard = 0U; guard < ZX_GIC_WAKE_GUARD; guard++) + { + if ((ZX_GIC_REG(gic_ptr->zx_gic_rd_base + ZX_GICR_CTLR) + & (uint32_t)ZX_GICR_CTLR_RWP) == 0U) + { + return 1U; + } + } + + return 0U; +} + + +/**************************************************************************/ +/* zx_gic_ppi_is_valid -- one guard, used by everything below. */ +/* */ +/* An INTID outside 16 to 31 does not belong in the redistributor's SGI */ +/* frame at all, and the arithmetic that would place it there produces a */ +/* perfectly plausible offset into a neighbouring register. Refusing is */ +/* what turns "the timer never fired" into "the timer was never */ +/* enabled", which are different bugs in different files. */ +/**************************************************************************/ + +static uint32_t zx_gic_ppi_is_valid(uint32_t intid) +{ + return ((intid >= ZX_PPI_FIRST_INTID) && (intid <= 31U)) ? 1U : 0U; +} + + +/**************************************************************************/ +/* zx_gic_el2_init */ +/* */ +/* THE ORDER IS FORCED AND EACH STEP FAILS DIFFERENTLY IF IT IS SKIPPED. */ +/* */ +/* THE GROUP ENABLES ARE CLEARED FIRST, and this is the step that looks */ +/* unnecessary and is not. Changing ARE while the GIC is enabled is */ +/* UNPREDICTABLE, so a sequence that sets ARE and then the enables is */ +/* correct only if the enables were already clear -- which is a claim */ +/* about reset state, and on silicon ZoneX is not the first thing to */ +/* run. This file argues that elsewhere, about the PPI enables, and */ +/* then made the same assumption here until it was pointed out. So the */ +/* GIC is turned OFF, ARE is set, and it is turned back on. */ +/* */ +/* ARE BEFORE THE ENABLES. Affinity routing changes how the */ +/* distributor's own registers behave; configuring under one */ +/* interpretation and reading under another is the failure that */ +/* ordering avoids. This part fixes ARE to one, so the write is a */ +/* formality -- and it is done anyway, because "it resets to what we */ +/* want" is a claim about a part and ZoneX runs on more than one. */ +/* */ +/* THE GROUP ENABLES. With a single security state GICD_CTLR.DS reads */ +/* one and Group 1 is bit 1. Group 0 is enabled too and costs nothing */ +/* while no interrupt is assigned to it: assignment is per interrupt, */ +/* in GICR_IGROUPR0, and every interrupt ZoneX hands a partition is */ +/* Group 1. */ +/* */ +/* EVERY GICD_CTLR WRITE IS FOLLOWED BY A WAIT, because they are not */ +/* finished when the instruction retires. See the note on RWP above. */ +/* */ +/* THE REDISTRIBUTOR WAKES LAST, and nothing can be delivered to this */ +/* core until it has. ProcessorSleep is cleared and then */ +/* ChildrenAsleep is POLLED: the two are separate bits because waking */ +/* is not instantaneous, and a configuration written into a sleeping */ +/* redistributor is written into a frame that is not listening. */ +/* */ +/* Returns zero if the wait ran out, and the caller reports that as a */ +/* failed check rather than continuing into a partition that can never */ +/* receive its timer. */ +/**************************************************************************/ + +uint32_t zx_gic_el2_init(const ZX_GIC_LAYOUT *gic_ptr) +{ + uint32_t control; + uint32_t waker; + uint32_t guard; + + if (gic_ptr == (const ZX_GIC_LAYOUT *)0) + { + return 0U; + } + + control = ZX_GIC_REG(gic_ptr->zx_gic_dist_base + ZX_GICD_CTLR); + control &= ~(uint32_t)(ZX_GICD_CTLR_ENABLE_GRP0 | ZX_GICD_CTLR_ENABLE_GRP1); + ZX_GIC_REG(gic_ptr->zx_gic_dist_base + ZX_GICD_CTLR) = control; + + if (zx_gic_wait_dist_rwp(gic_ptr) == 0U) + { + return 0U; + } + + control |= (uint32_t)ZX_GICD_CTLR_ARE; + ZX_GIC_REG(gic_ptr->zx_gic_dist_base + ZX_GICD_CTLR) = control; + + if (zx_gic_wait_dist_rwp(gic_ptr) == 0U) + { + return 0U; + } + + control |= (uint32_t)(ZX_GICD_CTLR_ENABLE_GRP0 | ZX_GICD_CTLR_ENABLE_GRP1); + ZX_GIC_REG(gic_ptr->zx_gic_dist_base + ZX_GICD_CTLR) = control; + + if (zx_gic_wait_dist_rwp(gic_ptr) == 0U) + { + return 0U; + } + + waker = ZX_GIC_REG(gic_ptr->zx_gic_rd_base + ZX_GICR_WAKER); + waker &= ~(uint32_t)ZX_GICR_WAKER_PROC_SLEEP; + ZX_GIC_REG(gic_ptr->zx_gic_rd_base + ZX_GICR_WAKER) = waker; + + for (guard = 0U; guard < ZX_GIC_WAKE_GUARD; guard++) + { + if ((ZX_GIC_REG(gic_ptr->zx_gic_rd_base + ZX_GICR_WAKER) + & (uint32_t)ZX_GICR_WAKER_CHILD_ASLEEP) == 0U) + { + /* EVERY SGI AND PPI IS DISABLED BEFORE ANY IS GRANTED. + * + * "A partition receives only the interrupt the hypervisor gave + * it" is a claim about what is ENABLED, and enable bits are not + * reset state a hypervisor may assume: on real silicon ZoneX is + * not the first thing to run, and a boot ROM or a previous stage + * may have left any of these thirty-two on. Asserting that they + * are off would then be a check about the last boot rather than + * about this hypervisor. + * + * ICENABLER is write-one-to-CLEAR, so all ones disables all + * thirty-two in one write and touches nothing outside them -- + * SPIs, which live in the distributor, are a different register + * and a different question. + * + * The write is here rather than in the caller because it is part + * of bringing this core's interrupt state to a KNOWN state, which + * is what the rest of this function is doing. A caller that + * forgot it would still get a working timer and would lose the + * claim. */ + + ZX_GIC_REG(gic_ptr->zx_gic_sgi_base + ZX_GICR_ICENABLER0) = + 0xFFFFFFFFU; + + /* AND WAITED FOR. A disable is not finished when the store + retires, and the very next thing this hypervisor does is + ENABLE one of these thirty-two in the same frame. An + in-flight disable that landed after that enable would undo + it, and the partition would arm a virtual timer whose + interrupt was disabled -- a guest that never ticks, on some + boots and not others. */ + + zx_gic_barrier(); + + return zx_gic_wait_redist_rwp(gic_ptr); + } + } + + return 0U; +} + + +/**************************************************************************/ +/* zx_gic_enable_guest_ppi */ +/* */ +/* Group, priority, configuration, THEN enable -- and the order is the */ +/* same one an interrupt controller always wants: everything that */ +/* describes an interrupt before the bit that lets it be delivered. */ +/* Enabling first leaves a window in which the interrupt can arrive with */ +/* whatever group and priority the register happened to hold, and on a */ +/* level-triggered timer that window is however long the rest of this */ +/* function takes. */ +/* */ +/* LEVEL, NOT EDGE. The generic timer asserts a LEVEL: its output stays */ +/* asserted until the comparator is re-armed. Configured as edge it */ +/* would be taken once and then never again, which presents as a kernel */ +/* that ticks exactly one time -- and one tick is enough for a naive */ +/* check to pass. */ +/**************************************************************************/ + +void zx_gic_enable_guest_ppi(const ZX_GIC_LAYOUT *gic_ptr, uint32_t intid, + uint32_t priority) +{ + zx_addr_t priority_word; + uint32_t shift; + + if ((gic_ptr == (const ZX_GIC_LAYOUT *)0) + || (zx_gic_ppi_is_valid(intid) == 0U)) + { + return; + } + + /* Group 1, which is what the GIC delivers as an IRQ. Group 0 arrives as + an FIQ, and a partition is granted none. */ + + ZX_GIC_REG(gic_ptr->zx_gic_sgi_base + ZX_GICR_IGROUPR0) |= + (uint32_t)ZX_BIT(intid); + + /* Priority is a BYTE per INTID inside a word per four INTIDs. The byte + is cleared before it is set, because a read-modify-write that only + ORed would leave whatever the reset value held in the bits the new + priority does not set -- a priority that is numerically lower than + intended, which in a GIC means HIGHER. */ + + priority_word = gic_ptr->zx_gic_sgi_base + ZX_GICR_IPRIORITYR + + (zx_addr_t)(intid & ~3U); + shift = (intid & 3U) * 8U; + + ZX_GIC_REG(priority_word) &= ~(uint32_t)(0xFFU << shift); + ZX_GIC_REG(priority_word) |= (uint32_t)((priority & 0xFFU) << shift); + + /* Two configuration bits per INTID, 00 = level-sensitive. */ + + shift = (intid - ZX_PPI_FIRST_INTID) * 2U; + ZX_GIC_REG(gic_ptr->zx_gic_sgi_base + ZX_GICR_ICFGR1) &= + ~(uint32_t)(0x3U << shift); + + /* ISENABLER is write-one-to-SET: the bits written as zero are left + alone, so this enables one interrupt and does not disturb the rest -- + including the hypervisor's own. A read-modify-write here would be + both unnecessary and wrong. */ + + ZX_GIC_REG(gic_ptr->zx_gic_sgi_base + ZX_GICR_ISENABLER0) = + (uint32_t)ZX_BIT(intid); + + zx_gic_barrier(); +} + + +/**************************************************************************/ +/* zx_gic_disable_guest_ppi */ +/* */ +/* ICENABLER, the write-one-to-CLEAR half of the pair. Same property: */ +/* one interrupt, nothing else touched. */ +/**************************************************************************/ + +void zx_gic_disable_guest_ppi(const ZX_GIC_LAYOUT *gic_ptr, uint32_t intid) +{ + if ((gic_ptr == (const ZX_GIC_LAYOUT *)0) + || (zx_gic_ppi_is_valid(intid) == 0U)) + { + return; + } + + ZX_GIC_REG(gic_ptr->zx_gic_sgi_base + ZX_GICR_ICENABLER0) = + (uint32_t)ZX_BIT(intid); + + /* Waited for, so that a caller can KNOW when the interrupt has stopped + being deliverable. "Disabled shortly" is not a property a partition + switch can be built on: the next thing after a disable is another + partition running. */ + + zx_gic_barrier(); + (void) zx_gic_wait_redist_rwp(gic_ptr); +} + + +/**************************************************************************/ +/* The read-backs */ +/* */ +/* Here for the same reason the stage-2 region set is read back: a write */ +/* that landed at the wrong offset does not fault, it does nothing, and a */ +/* partition that silently never receives its timer looks exactly like a */ +/* kernel whose scheduler does not work. Three reads at boot are cheap */ +/* next to that. */ +/**************************************************************************/ + +uint32_t zx_gic_ppi_is_enabled(const ZX_GIC_LAYOUT *gic_ptr, uint32_t intid) +{ + if ((gic_ptr == (const ZX_GIC_LAYOUT *)0) + || (zx_gic_ppi_is_valid(intid) == 0U)) + { + return 0U; + } + + return ((ZX_GIC_REG(gic_ptr->zx_gic_sgi_base + ZX_GICR_ISENABLER0) + & (uint32_t)ZX_BIT(intid)) != 0U) ? 1U : 0U; +} + + +uint32_t zx_gic_ppi_is_group1(const ZX_GIC_LAYOUT *gic_ptr, uint32_t intid) +{ + if ((gic_ptr == (const ZX_GIC_LAYOUT *)0) + || (zx_gic_ppi_is_valid(intid) == 0U)) + { + return 0U; + } + + return ((ZX_GIC_REG(gic_ptr->zx_gic_sgi_base + ZX_GICR_IGROUPR0) + & (uint32_t)ZX_BIT(intid)) != 0U) ? 1U : 0U; +} + + +uint32_t zx_gic_ppi_priority(const ZX_GIC_LAYOUT *gic_ptr, uint32_t intid) +{ + zx_addr_t priority_word; + + if ((gic_ptr == (const ZX_GIC_LAYOUT *)0) + || (zx_gic_ppi_is_valid(intid) == 0U)) + { + return 0U; + } + + priority_word = gic_ptr->zx_gic_sgi_base + ZX_GICR_IPRIORITYR + + (zx_addr_t)(intid & ~3U); + + return (ZX_GIC_REG(priority_word) >> ((intid & 3U) * 8U)) & 0xFFU; +} + + +/**************************************************************************/ +/* zx_gic_priority_bits */ +/* */ +/* WHY A HYPERVISOR CARES HOW MANY PRIORITY BITS A GIC HAS. */ +/* */ +/* Only the top bits of a priority byte are implemented; the rest read */ +/* back as zero. Two priorities that differ only in the vanished bits */ +/* are therefore the SAME priority to the hardware -- and in a GIC, */ +/* equal priorities do not preempt each other. So a hypervisor that */ +/* intends its own tick to outrank a partition's has to know how much */ +/* of the number it wrote survives. */ +/* */ +/* It is discovered rather than assumed, because it is a property of an */ +/* implementation: the Armv8-R AEM FVP keeps five, and no real part is */ +/* obliged to agree. */ +/* */ +/* The byte is BORROWED and PUT BACK. Leaving all ones in it would set */ +/* that INTID to the lowest possible priority, which is a legal value */ +/* and precisely the wrong one for a timer. */ +/**************************************************************************/ + +uint32_t zx_gic_priority_bits(const ZX_GIC_LAYOUT *gic_ptr, uint32_t intid) +{ + zx_addr_t priority_word; + uint32_t shift; + uint32_t saved; + uint32_t stuck; + uint32_t bits = 0U; + + if ((gic_ptr == (const ZX_GIC_LAYOUT *)0) + || (zx_gic_ppi_is_valid(intid) == 0U)) + { + return 0U; + } + + priority_word = gic_ptr->zx_gic_sgi_base + ZX_GICR_IPRIORITYR + + (zx_addr_t)(intid & ~3U); + shift = (intid & 3U) * 8U; + saved = ZX_GIC_REG(priority_word); + + ZX_GIC_REG(priority_word) = saved | (uint32_t)(0xFFU << shift); + stuck = (ZX_GIC_REG(priority_word) >> shift) & 0xFFU; + ZX_GIC_REG(priority_word) = saved; + + zx_gic_barrier(); + + while ((stuck & 0x80U) != 0U) + { + bits++; + stuck = (stuck << 1) & 0xFFU; + } + + return bits; +} diff --git a/platform/cortex_r52/src/zx_timer.c b/platform/cortex_r52/src/zx_timer.c index 175f35a..9b071b7 100644 --- a/platform/cortex_r52/src/zx_timer.c +++ b/platform/cortex_r52/src/zx_timer.c @@ -29,11 +29,19 @@ /* partition its own CNTVOFF so that guest time freezes while the */ /* partition is descheduled. See docs/decisions.md D7. */ /* */ -/* Only ONE thing is implemented here so far, and it is the thing a */ -/* guest cannot do for itself. Interrupt delivery, the partition tick */ -/* and CNTVOFF arrive with time partitioning; see */ -/* docs/armv8r-el2-reference.md for the verified register sheet the */ -/* code that lands here must be written against. */ +/* Two jobs, and they are not the same kind of thing. */ +/* */ +/* zx_el2_prepare_guest_el1 the EL2-only configuration a guest */ +/* needs and cannot perform itself. */ +/* the CNTVOFF bookkeeping the mechanism that makes a descheduled */ +/* partition's clock FROZEN rather than */ +/* merely unread. */ +/* */ +/* The partition tick -- the hypervisor's OWN timer, on PPI 26, which */ +/* ends a window -- is not here yet, and cannot be until HCR.IMO is */ +/* set: with IMO clear a physical interrupt taken while a partition */ +/* runs is delivered straight to EL1, and the hypervisor would never */ +/* see its own timer. See docs/decisions.md D24. */ /* */ /* MISRA C:2012 deviations (justified) */ /* */ @@ -49,6 +57,66 @@ #define ZX_HCPTR_TCP (ZX_C32(0x3) << 10) +/* CNTV_CTL, which is EL1's register and is written from here only to STOP a + partition's timer. ENABLE and IMASK are both cleared: masking alone would + leave the comparator running and its ISTATUS set, so a guest re-entered + later would find a timer it had not armed already expired. */ + +#define ZX_CNTV_CTL_ENABLE ZX_BIT(0) +#define ZX_CNTV_CTL_IMASK ZX_BIT(1) + +/* How long to spin looking for the physical counter to move. The counter + runs at 8 MHz on one target and 100 MHz on the other, so a counter that is + running advances within a handful of iterations at either; the bound is + generous because its only job is to be FINITE. */ + +#define ZX_COUNTER_MOVE_GUARD 100000U + +/* And the bound on a deliberate dwell. Generous, because its only job is + to be finite: the caller has already been told whether the counter runs, + and a dwell that fell out here would mean it stopped in between. */ + +#define ZX_DWELL_GUARD 100000000U + +/* Where the partition's virtual time was when it was last frozen. One + partition's worth, because ZoneX runs one at a time and + zx_el2_run_payload cannot nest -- the same constraint, in the same place, + and when it becomes an array indexed by partition this does too. */ + +static uint64_t zx_guest_time_frozen_at; + +/* And what the partition's OWN clock read at that instant. + * + * The pair is what makes the freeze checkable rather than merely done. + * CNTVOFF is a STATIC offset, so while a partition is suspended the physical + * counter moves and the offset does not: the subtraction drifts for exactly + * as long as the partition is away, and only the write on RESUME closes it. + * Sampling the virtual counter anywhere in between therefore measures the + * drift and not the correction -- which is how the first version of the + * preemptive image reported a partition's clock advancing by the whole gap + * on a run where the freeze was working perfectly. + * + * So the instant a partition STOPPED is recorded here, in the same breath as + * the physical count, and the invariant a caller can check is exact: the + * partition's virtual count after a resume equals this. */ + +static uint64_t zx_guest_virtual_at_freeze; + +/* And where the physical counter was when the partition was given the core + back. The pair (suspended_at, resumed_at) is the interval the partition + was excluded from, as the MECHANISM measured it -- which is what CNTVOFF + is credited with, and therefore what a check of the freeze has to compare + the offset's movement against. + + Reading the counter again from outside instead does NOT give the same + number, and the difference is not noise to be ignored: on the S32Z280 a + CNTPCT read crosses into an 8 MHz clock domain, so the handful of reads + between the resume and the check cost 64 counts of real time. A check + built on that would be comparing the freeze against the cost of measuring + the freeze. */ + +static uint64_t zx_guest_time_resumed_at; + /**************************************************************************/ /* */ @@ -107,3 +175,392 @@ void zx_el2_prepare_guest_el1(uint32_t counter_hz) : : "r"(counter_hz) : "memory"); __asm__ volatile("isb"); } + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_read_cntpct Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* The PHYSICAL counter, which no partition can reach. */ +/* */ +/* 64-bit, so in AArch32 it is an MRRC into a register pair rather */ +/* than an MRC. The pair is not atomic across the halves on every */ +/* implementation, which is why the low half is read first and the */ +/* high half checked for a carry -- a naive read that caught the low */ +/* half wrapping would produce a value roughly four billion counts in */ +/* the past, and every interval computed from it would be nonsense. */ +/* */ +/**************************************************************************/ + +uint64_t zx_read_cntpct(void) +{ + uint32_t low; + uint32_t high; + uint32_t settled; + + /* Read until two consecutive reads agree about the HIGH half. When they + do, the low half that came with the second one belongs to it. */ + + __asm__ volatile("mrrc p15, 0, %0, %1, c14" : "=r"(low), "=r"(high)); + + for (;;) + { + settled = high; + + __asm__ volatile("mrrc p15, 0, %0, %1, c14" : "=r"(low), "=r"(high)); + + if (high == settled) + { + break; + } + } + + return ((uint64_t)high << 32) | (uint64_t)low; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_counter_is_running Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* Whether the system counter is actually COUNTING. */ +/* */ +/* PROGRAMMING CNTFRQ DOES NOT START THE COUNTER, and the two are so */ +/* easily confused that this function exists mostly to keep them apart. */ +/* CNTFRQ is a software-declared constant: nothing in either ZoneX */ +/* target reports its own counter frequency, so CNTFRQ says only what */ +/* somebody wrote there. Whether the counter moves is a different */ +/* question with a different answer, and on the Armv8-R AEM FVP the */ +/* answer out of reset is NO -- the model leaves its counter stopped */ +/* and documents that firmware is expected to start it. */ +/* */ +/* The consequence of not checking is the worst kind of failure this */ +/* suite can produce: a guest that arms a timer, blocks waiting for it, */ +/* and is killed by a harness timeout that names nothing. So the */ +/* counter is read twice, with a BOUND, and a target whose counter is */ +/* stopped produces a reported check failure instead. */ +/* */ +/**************************************************************************/ + +uint32_t zx_counter_is_running(void) +{ + uint64_t first = zx_read_cntpct(); + uint32_t guard; + + for (guard = 0U; guard < ZX_COUNTER_MOVE_GUARD; guard++) + { + if (zx_read_cntpct() != first) + { + return 1U; + } + } + + return 0U; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_el2_dwell Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* Spend a known interval at EL2, measured on the physical counter. */ +/* */ +/* WHY A HYPERVISOR WOULD EVER DELIBERATELY WAIT. It would not. This */ +/* exists to make one claim MEASURABLE, and the claim is that a */ +/* partition's clock does not advance while the partition is not */ +/* running. Checking that needs an interval in which the partition is */ +/* not running -- and the interval has to be REAL, or the check passes */ +/* for the wrong reason. */ +/* */ +/* That is not hypothetical. The first version of the preemptive */ +/* image measured the gap across a block of console output, on the */ +/* grounds that a polled UART is millions of cycles. It is -- on */ +/* SILICON. On the Armv8-R AEM FVP the console is semihosting, which */ +/* costs the model no simulated time at all, so the physical counter */ +/* advanced by ZERO across the gap and the virtual counter advanced by */ +/* zero for that reason rather than because anything was frozen. The */ +/* check was green and the build that DELIBERATELY BREAKS the freeze */ +/* was green with it. */ +/* */ +/* So the gap is made explicitly, in counter counts, and the image */ +/* also asserts that the physical counter DID advance -- because a */ +/* check whose subject did not happen is not a check. */ +/* */ +/* Bounded twice over: by the counter reaching its target, and by an */ +/* iteration count, so that a stopped counter cannot turn this into a */ +/* hang. The caller has already been told whether the counter runs. */ +/* */ +/**************************************************************************/ + +void zx_el2_dwell(uint32_t counts) +{ + uint64_t target = zx_read_cntpct() + (uint64_t)counts; + uint32_t guard; + + for (guard = 0U; guard < ZX_DWELL_GUARD; guard++) + { + if (zx_read_cntpct() >= target) + { + break; + } + } +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_el2_guest_time_reset Cortex-R52 */ +/* zx_el2_guest_time_suspend */ +/* zx_el2_guest_time_resume */ +/* zx_el2_guest_time_offset */ +/* */ +/* DESCRIPTION */ +/* */ +/* CNTVOFF, which is the whole of temporal partitioning as a partition */ +/* can observe it. */ +/* */ +/* A partition reads time through the VIRTUAL counter, and the virtual */ +/* counter is defined as the physical one MINUS CNTVOFF. CNTVOFF is */ +/* writable only at EL2. So the hypervisor can decide, exactly, what */ +/* time a partition believes it is: */ +/* */ +/* RESET CNTVOFF = the physical count now, so the partition's own */ +/* clock reads zero. Done when a partition is loaded, so */ +/* that every excursion of a guest begins from the same */ +/* point on its own clock -- which is what makes two runs */ +/* of one guest comparable at all. */ +/* SUSPEND record where the physical counter was. The partition's */ +/* clock is now unchanging in the only sense that matters, */ +/* because nothing of the partition is executing to read it. */ +/* RESUME add everything that elapsed since to CNTVOFF. The */ +/* virtual counter therefore reads exactly what it read when */ +/* the partition stopped: from inside, no time passed. */ +/* */ +/* WHY THE PHYSICAL TIMER COULD NOT DO THIS, which is D7 stated as */ +/* mechanism rather than as policy: there is no CNTPOFF on this */ +/* architecture. Physical time is the system's and cannot be given a */ +/* per-partition origin, so a guest on the physical timer would see the */ +/* gaps -- and a partition that can observe that it was not running is */ +/* a partition whose worst-case execution time cannot be argued about */ +/* from the inside. ZoneX therefore leaves CNTHCTL.PL1PCTEN and */ +/* PL1PCEN clear (D23) and hands out the virtual timer instead. */ +/* */ +/* WITH ONE PARTITION THE GAP BEING CLOSED IS THE HYPERVISOR'S OWN */ +/* WORK -- printing a report between two excursions, which on a polled */ +/* UART is millions of cycles. That is a small demonstration of a */ +/* mechanism whose real use is the next one: with two partitions the */ +/* gap is the other partition's window, and closing it is what makes */ +/* the two clocks independent. */ +/* */ +/* MISRA C:2012 deviations (justified) */ +/* */ +/* Directive 4.3 -- CNTVOFF is reachable only through a 64-bit */ +/* coprocessor transfer, and every such access is encapsulated in */ +/* one of the two one-line accessors below. */ +/* */ +/**************************************************************************/ + +static uint64_t zx_read_cntvoff(void) +{ + uint32_t low; + uint32_t high; + + __asm__ volatile("mrrc p15, 4, %0, %1, c14" : "=r"(low), "=r"(high)); + + return ((uint64_t)high << 32) | (uint64_t)low; +} + + +static void zx_write_cntvoff(uint64_t value) +{ + uint32_t low = (uint32_t)(value & 0xFFFFFFFFU); + uint32_t high = (uint32_t)(value >> 32); + + __asm__ volatile("mcrr p15, 4, %0, %1, c14" + : : "r"(low), "r"(high) : "memory"); + __asm__ volatile("isb" ::: "memory"); +} + + +void zx_el2_guest_time_reset(void) +{ + uint64_t now = zx_read_cntpct(); + + zx_write_cntvoff(now); + zx_guest_time_frozen_at = now; + zx_guest_time_resumed_at = now; + zx_guest_virtual_at_freeze = 0U; +} + + +void zx_el2_guest_time_suspend(void) +{ + zx_guest_time_frozen_at = zx_read_cntpct(); + zx_guest_virtual_at_freeze = zx_guest_time_frozen_at - zx_read_cntvoff(); +} + + +static void zx_el2_guest_time_resume_inner(uint32_t credit) +{ + uint64_t now = zx_read_cntpct(); + + /* The elapsed time is ADDED rather than the offset being recomputed from + scratch, because "how much time has this partition been given" is + cumulative and recomputing it would silently reset the partition's + clock on every entry -- which would look like a working freeze and + would in fact be a partition whose time never advances at all. + + A resume that was never preceded by a suspend adds nothing: both were + set together by the reset above, so the subtraction is zero. */ + + if ((credit != 0U) && (now > zx_guest_time_frozen_at)) + { + zx_write_cntvoff(zx_read_cntvoff() + (now - zx_guest_time_frozen_at)); + } + + /* THE BOOKKEEPING HAPPENS EITHER WAY, and that is the whole reason the + uncredited variant exists rather than the caller simply not calling. + These two words are what a check of the freeze is measured against; a + path that left them stale would make the MEASUREMENT undefined instead + of making the MECHANISM wrong, and a negative build has to do the + second. The first version of the broken build skipped the call + outright, and its "the partition could not observe the gap" check then + passed -- comparing a real interval against the time since boot. */ + + zx_guest_time_resumed_at = now; + zx_guest_time_frozen_at = now; +} + + +void zx_el2_guest_time_resume(void) +{ + zx_el2_guest_time_resume_inner(1U); +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_el2_guest_time_resume_uncredited Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* A resume that does NOT give the partition back the time it spent */ +/* descheduled. It exists so that the freeze can be seen to fail. */ +/* */ +/* A claim whose pass condition is "the guest could not tell it was */ +/* descheduled" has to be capable of failing, or a green run shows */ +/* only that the arithmetic did not crash. This is the defect, made */ +/* reproducible: everything else about the partition is identical, and */ +/* its clock counts every cycle the hypervisor spent between two */ +/* windows. */ +/* */ +/* It keeps the BOOKKEEPING, which is what makes it a broken mechanism */ +/* rather than a missing one. Simply not resuming would leave the */ +/* recorded instants stale, and the observable check would then be */ +/* comparing a real interval against the time since boot -- passing */ +/* for a reason that has nothing to do with the freeze. That was */ +/* observed before this function existed. */ +/* */ +/**************************************************************************/ + +void zx_el2_guest_time_resume_uncredited(void) +{ + zx_el2_guest_time_resume_inner(0U); +} + + +uint64_t zx_el2_guest_time_offset(void) +{ + return zx_read_cntvoff(); +} + + +uint64_t zx_el2_guest_virtual_count(void) +{ + return zx_read_cntpct() - zx_read_cntvoff(); +} + + +uint64_t zx_el2_guest_time_suspended_at(void) +{ + return zx_guest_time_frozen_at; +} + + +uint64_t zx_el2_guest_virtual_when_suspended(void) +{ + return zx_guest_virtual_at_freeze; +} + + +uint64_t zx_el2_guest_time_resumed_at(void) +{ + return zx_guest_time_resumed_at; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_el2_guest_timer_stop Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* Disarm a partition's virtual timer, from EL2. */ +/* */ +/* WHY THE HYPERVISOR DOES THIS AND NOT THE GUEST. A partition can be */ +/* taken from at any instant -- by a stage-2 violation, and later by a */ +/* window expiring -- including with its timer armed. The generic */ +/* timer asserts a LEVEL, so an expired comparator holds the PPI */ +/* asserted at the GIC indefinitely. Nothing bad happens while the */ +/* hypervisor is running, because EL2 keeps PSTATE.I set and could not */ +/* take it anyway; the damage is at the NEXT ERET into a guest, which */ +/* would be interrupted before it had reinitialised its own kernel. */ +/* */ +/* BOTH BITS ARE CLEARED, not just ENABLE. Setting IMASK alone leaves */ +/* the comparator running and its ISTATUS set, so a guest re-entered */ +/* later would find a timer it never armed already expired -- which is */ +/* a tick it did not schedule, arriving at a moment it did not choose. */ +/* */ +/* THIS IS PER-GUEST STATE and it is named here as such, because the */ +/* same is true of CNTV_CVAL and CNTV_TVAL: a partition switch that */ +/* saved neither would hand the next partition the previous one's */ +/* deadline. Stopping the timer makes that harmless for one partition */ +/* and does not make it correct for two. */ +/* */ +/**************************************************************************/ + +void zx_el2_guest_timer_stop(void) +{ + uint32_t control; + + /* Read-modify-write rather than a store of zero. ISTATUS is read-only + and the other twenty-nine bits are reserved today, so the two are the + same instruction sequence with the same effect -- and a store would + stop being equivalent the day the architecture defines one of them. + Naming the two bits being cleared is also what makes the paragraph + above checkable against the code. */ + + __asm__ volatile("mrc p15, 0, %0, c14, c3, 1" : "=r"(control)); + control &= ~(uint32_t)(ZX_CNTV_CTL_ENABLE | ZX_CNTV_CTL_IMASK); + __asm__ volatile("mcr p15, 0, %0, c14, c3, 1" + : : "r"(control) : "memory"); + __asm__ volatile("isb" ::: "memory"); +} From 4697e2ba4fba1f9e1317f11f2dff18fd015eae96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 2 Sep 2026 18:57:31 -0400 Subject: [PATCH 13/40] Put ZoneX's guest support under a warnings-as-errors lane The gcc and clang check workflows exist to hold this project to its warning set, and they were not looking at the ZoneX code compiled into a ThreadX image: the guest support and both partition images, several thousand lines. Neither workflow set ZX_THREADX_SOURCE_DIR, so the example CMakeLists skipped the guest images -- correctly, and with a message, which is right for a contributor who has no kernel sources to hand and wrong for the lane whose whole job is strictness. TWO LAYERS HAD TO GIVE WAY, and the second is the one worth reading. The first is a ThreadX checkout in both workflows, pinned to the commit zx_fvp.yml already pins, and the variable passed through to CMake. The second is that fixing only the first would have changed nothing. The guest is an ExternalProject -- a separate CMake invocation with ThreadX's own toolchain file -- and an ExternalProject inherits nothing that is not listed in its CMAKE_ARGS. CMAKE_COMPILE_WARNING_AS_ERROR stops at that boundary, so the guest images would have built in the strict lanes with their warnings merely printed. FORWARDING IT WHOLESALE WOULD HAVE BEEN WORSE THAN NOT FIXING IT. That sub-build compiles ThreadX's kernel as well as ZoneX's guest support, and ZoneX does not get to decide how the kernel is compiled -- which is the argument that makes the guest a separate project in the first place. So the flags are applied PER SOURCE FILE, to the three files that are ZoneX's, and to nothing else. The two files the executable takes from ports/cortex_r52 keep the port's settings, which matters more than it looks: with -Werror a target-wide setting could fail this build on a warning ZoneX neither owns nor can fix here. The list itself comes from cmake/zx_warnings.cmake, which now publishes ZX_WARNING_FLAGS as a variable alongside the interface library it already built. The guest project cannot see the hypervisor's targets, and restating eleven flags over there would have been a second source of truth that agrees until the day it does not. -Wlogical-op is selected by compiler at both ends for the same reason it always was: ATfE clang rejects the GNU spelling outright, which is noise until warnings are errors and a failed build afterwards. ThreadX's own build sets no warning flags at all, so before this those three files were compiled with NONE. The lane found one thing immediately, which is the sort of thing it is for: bsp_main was defined with no prototype -- a MISRA Rule 8.4 finding, and a function whose signature nothing was checking. The four entry points the port calls are now declared in zx_guest_bsp.h rather than forward-declared where they happen to be defined. Not by including the port's board.h, which declares all four: the two boards' copies of that header differ, and the files implementing these are shared between the boards. Verified by running what the workflows run: all three gcc configurations and both clang configurations configure, build and link every image with warnings as errors and the guest images present. The FVP suite is unchanged at 13 of 13. Assisted-by: Claude Code (Opus 5) --- .github/workflows/clang_check.yml | 18 ++++++ .github/workflows/gcc_check.yml | 25 ++++++++ cmake/zx_warnings.cmake | 24 ++++++-- examples/fvp_baser_aemv8r/CMakeLists.txt | 12 ++++ .../fvp_baser_aemv8r/guest_a/CMakeLists.txt | 58 +++++++++++++++++++ examples/guest_common/zx_guest_bsp.c | 7 --- examples/guest_common/zx_guest_bsp.h | 28 +++++++++ examples/s32z280_evb/CMakeLists.txt | 12 ++++ examples/s32z280_evb/guest_a/CMakeLists.txt | 58 +++++++++++++++++++ 9 files changed, 231 insertions(+), 11 deletions(-) diff --git a/.github/workflows/clang_check.yml b/.github/workflows/clang_check.yml index bc71eb3..7605c18 100644 --- a/.github/workflows/clang_check.yml +++ b/.github/workflows/clang_check.yml @@ -66,6 +66,23 @@ jobs: - name: Check out the repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # THE GUEST IMAGES NEED A THREADX CHECKOUT, and without one the example + # CMakeLists SKIPS them -- with a message, which is right for a + # contributor who has no kernel sources to hand and wrong here. Skipped, + # the ZoneX code that is compiled into a ThreadX image never faced this + # workflow at all: the guest support and both partition images are + # thousands of lines that no warnings-as-errors lane was looking at. + # + # Pinned to the same commit zx_fvp.yml pins, and for the same reason: an + # unpinned guest means this workflow's results change when a different + # repository does. Change the two together. + - name: Check out ThreadX, for the guest images + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: eclipse-threadx/threadx + ref: 8c681c188ee748c777185e229024255c054a49d9 + path: threadx + - name: Cache Arm Toolchain for Embedded id: cache-atfe uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -116,6 +133,7 @@ jobs: if ! cmake -S . -B "build/clang-$name" -G Ninja \ -DCMAKE_TOOLCHAIN_FILE=cmake/cortex_r52_clang.cmake \ -DZX_ATFE_TOOLCHAIN_PATH="$atfe" \ + -DZX_THREADX_SOURCE_DIR="$GITHUB_WORKSPACE/threadx" \ -DCMAKE_COMPILE_WARNING_AS_ERROR=ON "$@"; then echo "::error::$name: CMake configure failed" status=1 diff --git a/.github/workflows/gcc_check.yml b/.github/workflows/gcc_check.yml index 8ed4627..4167ef4 100644 --- a/.github/workflows/gcc_check.yml +++ b/.github/workflows/gcc_check.yml @@ -27,6 +27,13 @@ name: gcc_check # # The S32Z280 lane is build-only and always will be. It needs silicon, and a # hosted runner has none. +# +# IT BUILDS THE GUEST IMAGES TOO, which it did not always do. ZoneX links no +# ThreadX -- at EL2 it is a standalone bare-metal program -- but the examples +# build ThreadX GUEST images, and the ZoneX code inside them is ZoneX's own. +# Without a ThreadX checkout those images are skipped, and the workflow that +# exists to hold this project to its warning set was not looking at several +# thousand lines of it. on: push: @@ -70,6 +77,23 @@ jobs: - name: Check out the repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # THE GUEST IMAGES NEED A THREADX CHECKOUT, and without one the example + # CMakeLists SKIPS them -- with a message, which is right for a + # contributor who has no kernel sources to hand and wrong here. Skipped, + # the ZoneX code that is compiled into a ThreadX image never faced this + # workflow at all: the guest support and both partition images are + # thousands of lines that no warnings-as-errors lane was looking at. + # + # Pinned to the same commit zx_fvp.yml pins, and for the same reason: an + # unpinned guest means this workflow's results change when a different + # repository does. Change the two together. + - name: Check out ThreadX, for the guest images + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: eclipse-threadx/threadx + ref: 8c681c188ee748c777185e229024255c054a49d9 + path: threadx + # The path and key match zx_fvp.yml's exactly so the two workflows share # one cache entry rather than each holding its own copy of the same archive. # Change them together or the sharing silently stops, and the only symptom @@ -133,6 +157,7 @@ jobs: echo "==============================================================" if ! cmake -S . -B "build/$name" -G Ninja \ -DCMAKE_TOOLCHAIN_FILE=cmake/cortex_r52.cmake \ + -DZX_THREADX_SOURCE_DIR="$GITHUB_WORKSPACE/threadx" \ -DCMAKE_COMPILE_WARNING_AS_ERROR=ON "$@"; then echo "::error::$name: CMake configure failed" status=1 diff --git a/cmake/zx_warnings.cmake b/cmake/zx_warnings.cmake index 81afef2..74d5e24 100644 --- a/cmake/zx_warnings.cmake +++ b/cmake/zx_warnings.cmake @@ -26,11 +26,22 @@ # Warnings become errors under the ci-strict configure preset, through CMake's # own CMAKE_COMPILE_WARNING_AS_ERROR. A developer build stays warning-tolerant # so that a half-finished change can still be compiled and looked at. +# +# THE FLAGS ARE A VARIABLE AS WELL AS A TARGET, and the second form is not +# redundant. ZoneX's guest support -- examples/guest_common -- is ZoneX's own +# code that is compiled into a THREADX image, by a separate CMake invocation +# with ThreadX's toolchain file, so it cannot see this project's targets at +# all. Restating the list over there would be a second source of truth that +# agrees until the day it does not; including this file and reading the +# variable is one. +# +# It is a variable and not just a target for exactly one consumer, and that +# consumer applies it PER SOURCE FILE rather than per target. The reason is in +# examples/*/guest_a/CMakeLists.txt: the guest executable also contains two +# files from ports/cortex_r52, and ZoneX does not get to decide how the port is +# compiled. -add_library(zx_warnings INTERFACE) -add_library(zonex::warnings ALIAS zx_warnings) - -target_compile_options(zx_warnings INTERFACE +set(ZX_WARNING_FLAGS -Wall -Wextra -Wpedantic @@ -44,6 +55,11 @@ target_compile_options(zx_warnings INTERFACE -Wfloat-equal ) +add_library(zx_warnings INTERFACE) +add_library(zonex::warnings ALIAS zx_warnings) + +target_compile_options(zx_warnings INTERFACE ${ZX_WARNING_FLAGS}) + # -Wlogical-op is a GNU extension. Clang does not implement it: it has # -Wlogical-op-parentheses, which is a different check, and rejects the GNU # spelling as an unknown warning. That is merely noisy until diff --git a/examples/fvp_baser_aemv8r/CMakeLists.txt b/examples/fvp_baser_aemv8r/CMakeLists.txt index 6987271..c3b9763 100644 --- a/examples/fvp_baser_aemv8r/CMakeLists.txt +++ b/examples/fvp_baser_aemv8r/CMakeLists.txt @@ -202,6 +202,18 @@ if(ZX_THREADX_SOURCE_DIR) -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} -DZX_THREADX_SOURCE_DIR=${ZX_THREADX_SOURCE_DIR} -DZX_GUEST_COMMON_DIR=${ZX_COMMON_DIR} + # ZoneX's own warning module, so that ZoneX's guest support faces + # ZoneX's warning set inside a ThreadX build. The sub-project + # applies it to ZoneX's three files and to nothing else; see + # guest_a/CMakeLists.txt for why that distinction is load-bearing. + -DZX_CMAKE_DIR=${CMAKE_SOURCE_DIR}/cmake + # And whether those warnings are fatal, carried across the project + # boundary by hand. CMAKE_COMPILE_WARNING_AS_ERROR does NOT + # propagate into an ExternalProject: the sub-build is a separate + # CMake invocation and inherits nothing that is not listed here. + # That is why the strict lanes were compiling the guest support + # with warnings merely printed -- when they compiled it at all. + -DZX_GUEST_WARNINGS_AS_ERRORS=${CMAKE_COMPILE_WARNING_AS_ERROR} BUILD_BYPRODUCTS ${ZX_GUEST_A_BLOB} # BUILD_ALWAYS, because the sub-build has its own dependency graph and # this one cannot see into it. Without it, editing the guest's diff --git a/examples/fvp_baser_aemv8r/guest_a/CMakeLists.txt b/examples/fvp_baser_aemv8r/guest_a/CMakeLists.txt index cd6a189..3c2a66e 100644 --- a/examples/fvp_baser_aemv8r/guest_a/CMakeLists.txt +++ b/examples/fvp_baser_aemv8r/guest_a/CMakeLists.txt @@ -61,6 +61,39 @@ if(NOT ZX_GUEST_COMMON_DIR) message(FATAL_ERROR "ZX_GUEST_COMMON_DIR was not passed to the guest build.") endif() +# ZONEX'S OWN WARNING SET, READ FROM ZONEX'S OWN MODULE. +# +# This project is configured by a SEPARATE CMake invocation with ThreadX's +# toolchain file, so it cannot see zonex::warnings or any other target of the +# hypervisor's build. What it can do is include the module that defines the +# list, which is why cmake/zx_warnings.cmake publishes ZX_WARNING_FLAGS as a +# variable as well as building an interface library out of it. Restating the +# flags here instead would be a second source of truth that agrees until the +# day it does not. +# +# Optional, and the build says so rather than failing: a contributor +# configuring this project by hand has no reason to have passed ZX_CMAKE_DIR, +# and the guest should still build for them. +if(ZX_CMAKE_DIR AND EXISTS ${ZX_CMAKE_DIR}/zx_warnings.cmake) + include(${ZX_CMAKE_DIR}/zx_warnings.cmake) +else() + set(ZX_WARNING_FLAGS) + message(STATUS + "zx_guest_a: ZX_CMAKE_DIR was not passed, so ZoneX's guest support is " + "compiled with ThreadX's warning settings alone.") +endif() + +# -Wlogical-op is GNU-only; ATfE clang rejects the spelling outright, which is +# merely noisy until warnings are errors. Selected by compiler, exactly as +# cmake/zx_warnings.cmake does it for the hypervisor. +if(CMAKE_C_COMPILER_ID STREQUAL "GNU") + list(APPEND ZX_WARNING_FLAGS -Wlogical-op) +endif() + +if(ZX_GUEST_WARNINGS_AS_ERRORS) + list(APPEND ZX_WARNING_FLAGS -Werror) +endif() + set(GUEST_DIR ${CMAKE_CURRENT_LIST_DIR}) # The port's own board support supplies the reset path and the low-level @@ -179,6 +212,31 @@ function(zx_add_guest target_name) TX_R52_USE_THREADX_IRQ ) + # ZONEX'S WARNING SET, ON ZONEX'S FILES, AND ON NOTHING ELSE. + # + # Applied per SOURCE rather than to the target, and that is the whole + # point of the exercise. This executable is built from five files: three + # of ZoneX's and two of the Cortex-R52 port's. ZoneX does not get to + # decide how the port is compiled -- "the hypervisor's build settings + # silently changed how the kernel was compiled" is not a sentence anybody + # wants in a defect report, and it is the same argument that makes this a + # separate CMake project in the first place. A target-wide setting would + # put entry.S and tx_initialize_low_level.S under a policy they are not + # ZoneX's to set, and with -Werror it could fail this build on a warning + # ZoneX does not own and cannot fix here. + # + # ThreadX's own build sets no warning flags at all, so without this these + # three files are compiled with NONE -- which is what made a + # warnings-as-errors lane over them worth adding: there was nothing for it + # to enforce. + set_source_files_properties( + ${GUEST_DIR}/../../guest_common/zx_guest_head.S + ${GUEST_DIR}/../../guest_common/zx_guest_bsp.c + ${GUEST_DIR}/../../guest_common/zx_guest_app.c + TARGET_DIRECTORY ${target_name} + PROPERTIES COMPILE_OPTIONS "${ZX_WARNING_FLAGS}" + ) + target_include_directories(${target_name} PRIVATE ${GUEST_DIR} ${ZX_GUEST_COMMON_DIR} diff --git a/examples/guest_common/zx_guest_bsp.c b/examples/guest_common/zx_guest_bsp.c index 83f0862..0350369 100644 --- a/examples/guest_common/zx_guest_bsp.c +++ b/examples/guest_common/zx_guest_bsp.c @@ -530,9 +530,6 @@ unsigned int guest_counter_is_moving(void) /* and the run that demonstrates preemption are the same binary. */ /**************************************************************************/ -void board_init(void); - - void board_init(void) { unsigned long frequency; @@ -608,10 +605,6 @@ void board_init(void) extern void _tx_timer_interrupt(void); -void board_irq_handler(void); -void board_fiq_handler(void); - - void board_irq_handler(void) { unsigned long intid = read_icc_iar1() & 0xFFFFFFUL; diff --git a/examples/guest_common/zx_guest_bsp.h b/examples/guest_common/zx_guest_bsp.h index 3c3bba2..6f734e7 100644 --- a/examples/guest_common/zx_guest_bsp.h +++ b/examples/guest_common/zx_guest_bsp.h @@ -86,6 +86,34 @@ void console_set_quiet(unsigned int quiet); void guest_yield(void); +/**************************************************************************/ +/* What the PORT calls, and the guest supplies */ +/* */ +/* These four are not part of ZoneX's design; they are the Cortex-R52 */ +/* port's board-support contract, and the guest implements them because */ +/* it is the board. entry.S calls bsp_main, _tx_initialize_low_level */ +/* calls board_init, and the EL1 IRQ and FIQ vectors call the other two. */ +/* */ +/* DECLARED HERE RATHER THAN BY INCLUDING THE PORT'S board.h, which does */ +/* declare all four. The two boards' copies of that header differ -- */ +/* one carries an identity structure, the other a set of interrupt- */ +/* nesting counters -- and the files that implement these are SHARED */ +/* between the two boards, so including it would make one source depend */ +/* on two different headers depending on which board it was compiled for. */ +/* */ +/* They are declared at all because a definition with no prototype is a */ +/* MISRA Rule 8.4 finding, and because a function nothing declares is a */ +/* function nothing checks the signature of. The caller is assembly, */ +/* which cannot be checked against either -- so the declaration is worth */ +/* no less for that, and the port's own board files carry it for the same */ +/* reason. */ +/**************************************************************************/ + +void bsp_main(void); +void board_init(void); +void board_irq_handler(void); +void board_fiq_handler(void); + /**************************************************************************/ /* The clock a partition is granted */ /* */ diff --git a/examples/s32z280_evb/CMakeLists.txt b/examples/s32z280_evb/CMakeLists.txt index 69f321a..70c3016 100644 --- a/examples/s32z280_evb/CMakeLists.txt +++ b/examples/s32z280_evb/CMakeLists.txt @@ -146,6 +146,18 @@ if(ZX_THREADX_SOURCE_DIR) -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} -DZX_THREADX_SOURCE_DIR=${ZX_THREADX_SOURCE_DIR} -DZX_GUEST_COMMON_DIR=${ZX_COMMON_DIR} + # ZoneX's own warning module, so that ZoneX's guest support faces + # ZoneX's warning set inside a ThreadX build. The sub-project + # applies it to ZoneX's three files and to nothing else; see + # guest_a/CMakeLists.txt for why that distinction is load-bearing. + -DZX_CMAKE_DIR=${CMAKE_SOURCE_DIR}/cmake + # And whether those warnings are fatal, carried across the project + # boundary by hand. CMAKE_COMPILE_WARNING_AS_ERROR does NOT + # propagate into an ExternalProject: the sub-build is a separate + # CMake invocation and inherits nothing that is not listed here. + # That is why the strict lanes were compiling the guest support + # with warnings merely printed -- when they compiled it at all. + -DZX_GUEST_WARNINGS_AS_ERRORS=${CMAKE_COMPILE_WARNING_AS_ERROR} BUILD_BYPRODUCTS ${ZX_GUEST_A_BLOB} BUILD_ALWAYS TRUE INSTALL_COMMAND "" diff --git a/examples/s32z280_evb/guest_a/CMakeLists.txt b/examples/s32z280_evb/guest_a/CMakeLists.txt index 401f92c..d02f02d 100644 --- a/examples/s32z280_evb/guest_a/CMakeLists.txt +++ b/examples/s32z280_evb/guest_a/CMakeLists.txt @@ -61,6 +61,39 @@ if(NOT ZX_GUEST_COMMON_DIR) message(FATAL_ERROR "ZX_GUEST_COMMON_DIR was not passed to the guest build.") endif() +# ZONEX'S OWN WARNING SET, READ FROM ZONEX'S OWN MODULE. +# +# This project is configured by a SEPARATE CMake invocation with ThreadX's +# toolchain file, so it cannot see zonex::warnings or any other target of the +# hypervisor's build. What it can do is include the module that defines the +# list, which is why cmake/zx_warnings.cmake publishes ZX_WARNING_FLAGS as a +# variable as well as building an interface library out of it. Restating the +# flags here instead would be a second source of truth that agrees until the +# day it does not. +# +# Optional, and the build says so rather than failing: a contributor +# configuring this project by hand has no reason to have passed ZX_CMAKE_DIR, +# and the guest should still build for them. +if(ZX_CMAKE_DIR AND EXISTS ${ZX_CMAKE_DIR}/zx_warnings.cmake) + include(${ZX_CMAKE_DIR}/zx_warnings.cmake) +else() + set(ZX_WARNING_FLAGS) + message(STATUS + "zx_guest_a: ZX_CMAKE_DIR was not passed, so ZoneX's guest support is " + "compiled with ThreadX's warning settings alone.") +endif() + +# -Wlogical-op is GNU-only; ATfE clang rejects the spelling outright, which is +# merely noisy until warnings are errors. Selected by compiler, exactly as +# cmake/zx_warnings.cmake does it for the hypervisor. +if(CMAKE_C_COMPILER_ID STREQUAL "GNU") + list(APPEND ZX_WARNING_FLAGS -Wlogical-op) +endif() + +if(ZX_GUEST_WARNINGS_AS_ERRORS) + list(APPEND ZX_WARNING_FLAGS -Werror) +endif() + set(GUEST_DIR ${CMAKE_CURRENT_LIST_DIR}) # The port's own board support supplies the reset path and the low-level @@ -188,6 +221,31 @@ function(zx_add_guest target_name) TX_R52_USE_THREADX_IRQ ) + # ZONEX'S WARNING SET, ON ZONEX'S FILES, AND ON NOTHING ELSE. + # + # Applied per SOURCE rather than to the target, and that is the whole + # point of the exercise. This executable is built from five files: three + # of ZoneX's and two of the Cortex-R52 port's. ZoneX does not get to + # decide how the port is compiled -- "the hypervisor's build settings + # silently changed how the kernel was compiled" is not a sentence anybody + # wants in a defect report, and it is the same argument that makes this a + # separate CMake project in the first place. A target-wide setting would + # put entry.S and tx_initialize_low_level.S under a policy they are not + # ZoneX's to set, and with -Werror it could fail this build on a warning + # ZoneX does not own and cannot fix here. + # + # ThreadX's own build sets no warning flags at all, so without this these + # three files are compiled with NONE -- which is what made a + # warnings-as-errors lane over them worth adding: there was nothing for it + # to enforce. + set_source_files_properties( + ${GUEST_DIR}/../../guest_common/zx_guest_head.S + ${GUEST_DIR}/../../guest_common/zx_guest_bsp.c + ${GUEST_DIR}/../../guest_common/zx_guest_app.c + TARGET_DIRECTORY ${target_name} + PROPERTIES COMPILE_OPTIONS "${ZX_WARNING_FLAGS}" + ) + target_include_directories(${target_name} PRIVATE ${GUEST_DIR} ${ZX_GUEST_COMMON_DIR} From 35635beb2f411b1526ec0820b0f21423e80b90ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 2 Sep 2026 19:25:22 -0400 Subject: [PATCH 14/40] Corrected D24: a partition tick needs FIQ routing, not interrupt injection D24 was written when the single partition was first given a clock, and its closing argument was wrong in a way that made the next step look far larger than it is. It reasoned that because HCR.IMO is clear, the hypervisor cannot take an interrupt of its own while a partition runs, and concluded that a tick which ENDS a partition's window therefore needs IMO set -- and that with IMO set every guest interrupt would have to be injected through a List Register. ROUTING IS BY EXCEPTION TYPE, NOT BY INTID, which is the fact the argument missed. HCR.FMO sends physical FIQ to EL2 while HCR.IMO, left clear, leaves IRQ with EL1. So the hypervisor's own timer goes in GROUP 0 -- which is what the GIC delivers as FIQ -- and arrives at EL2 while every partition interrupt stays Group 1, stays an IRQ, and is still delivered straight to EL1. No injection, no List Register, and no change to any guest. And it is BETTER than injection rather than merely cheaper. With FMO set, PSTATE.F is ignored at EL0 and EL1, so a partition cannot mask the interrupt that ends its own window. A tick delivered as an IRQ to EL1 could be deferred by any guest that disabled interrupts, which is precisely the property time partitioning must not concede. The guest half is already in place and was already right: a partition is granted no Group 0 interrupt and never enables ICC_IGRPEN0, so nothing can deliver an FIQ to it. Only the prose drew the wrong conclusion from what the code does. What is missing is all at EL2 -- FMO, PPI 26 in Group 0, and a body on the FIQ vector -- and none of it has been run. Injection through the List Registers goes back to where it belongs: a later phase, for making interrupt latency a hypervisor-controlled and WCET-bounded quantity, rather than a prerequisite for a partition tick. Comments only; no code changed and no behaviour with it. The three files that repeated the claim -- zx_gic.c, zx_timer.c and the preemptive example's header -- are corrected with it, because a wrong reason repeated in four places is four things to re-derive. Assisted-by: Claude Code (Opus 5) --- docs/decisions.md | 38 ++++++++++++++++++----- examples/common/zx_preemptive_partition.c | 8 +++-- platform/cortex_r52/src/zx_gic.c | 31 ++++++++++++++---- platform/cortex_r52/src/zx_timer.c | 11 ++++--- 4 files changed, 67 insertions(+), 21 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index c5da774..78da24d 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -966,14 +966,36 @@ Cortex-R52 does implement the virtual CPU interface — `ICH_HCR`, `ICH_VTR` and four List Registers, confirmed on both targets — so injection is available and is deliberately not used. -**The cost is stated here because it becomes the next phase's whole problem:** -with `IMO` clear the hypervisor cannot take an interrupt of its own while a -partition is running either, *including its own timer*. A hypervisor tick that -ENDS a partition's window therefore needs `IMO` SET — and with `IMO` set, every -guest interrupt has to be injected through a List Register. That is a change to -`zx_gic.c` and `zx_trap_handler.S` and to **no guest**, which is exactly why the -shape here is worth having first: it is the version whose correctness can be -established before the delivery mechanism becomes complicated. +**The cost, and the way out — which is not the one this entry first claimed.** +With `IMO` clear the hypervisor cannot take an *IRQ* of its own while a +partition is running either. This entry originally concluded that a hypervisor +tick therefore needs `IMO` set, and that every guest interrupt would then have to +be injected through a List Register. **That is wrong, and the correction is +worth more than the original claim was.** + +Routing is by exception **type**, not by INTID. `HCR.FMO` sends physical FIQ to +EL2 while `HCR.IMO`, left clear, leaves IRQ with EL1. So the hypervisor's own +timer on PPI 26 goes in **Group 0** — which is what the GIC delivers as FIQ — +and arrives at EL2, while every partition interrupt stays Group 1, stays an IRQ, +and is still delivered straight to EL1. No injection, no List Register, and no +change to any guest. + +**And it is better than injection rather than merely cheaper.** With `FMO` set, +`PSTATE.F` is *ignored* at EL0 and EL1, so a partition cannot mask the interrupt +that ends its own window. A tick delivered as an IRQ to EL1 could be deferred by +any guest that disabled interrupts — which is precisely the property time +partitioning must not concede. + +**The guest half of that arrangement is already in place**, which is why the +shape here is worth having first: a partition is granted no Group 0 interrupt and +never enables `ICC_IGRPEN0`, so nothing can deliver an FIQ to it. What is missing +is all at EL2 — `FMO`, PPI 26 in Group 0, and a real body on the FIQ vector — and +none of it has been run. + +Injection through the List Registers stays where the roadmap put it: a later +phase, for making interrupt latency a hypervisor-controlled and WCET-bounded +quantity. It is not a prerequisite for a partition tick, and treating it as one +would have made the next step look far larger than it is. ### What a partition ends up touching diff --git a/examples/common/zx_preemptive_partition.c b/examples/common/zx_preemptive_partition.c index 350a870..c7c9879 100644 --- a/examples/common/zx_preemptive_partition.c +++ b/examples/common/zx_preemptive_partition.c @@ -76,9 +76,11 @@ /* */ /* On lockstep silicon it demonstrates preemption and a frozen clock */ /* for ONE partition on ONE logical core. Time PARTITIONING -- a */ -/* hypervisor tick that ENDS a partition's window -- is not this, and */ -/* is not here: it needs HCR.IMO set, which changes how every guest */ -/* interrupt is delivered. See docs/decisions.md D24. */ +/* hypervisor tick that ENDS a partition's window -- is not this and */ +/* is not here. It needs HCR.FMO and the hypervisor's own timer in */ +/* GROUP 0, so that its tick arrives as an FIQ at EL2 while every */ +/* guest interrupt stays an IRQ delivered straight to EL1, exactly as */ +/* it is here. See docs/decisions.md D24. */ /* */ /**************************************************************************/ diff --git a/platform/cortex_r52/src/zx_gic.c b/platform/cortex_r52/src/zx_gic.c index 9cdd22d..e7d8980 100644 --- a/platform/cortex_r52/src/zx_gic.c +++ b/platform/cortex_r52/src/zx_gic.c @@ -61,12 +61,31 @@ /* */ /* That is the cheapest possible mechanism and it costs exactly one */ /* thing, which is written down here rather than discovered later: with */ -/* IMO clear, the hypervisor cannot take an interrupt of its own while */ -/* a partition is running either. A hypervisor tick that ENDS a */ -/* partition's window therefore needs IMO SET, and with IMO set every */ -/* guest interrupt has to be injected through a List Register. That is */ -/* the change time partitioning brings, and it is a change to this */ -/* file and to zx_trap_handler.S rather than to any guest. */ +/* IMO clear, the hypervisor cannot take an IRQ of its own while a */ +/* partition is running either. */ +/* */ +/* THE WAY OUT IS FIQ, NOT INJECTION, and the distinction matters */ +/* because one of them is a rewrite and the other is two register */ +/* writes. Routing is by exception TYPE and not by INTID: HCR.FMO */ +/* sends physical FIQ to EL2 while HCR.IMO, left clear, leaves IRQ with */ +/* EL1. So the hypervisor's own timer on PPI 26 goes in GROUP 0 -- */ +/* which is what the GIC delivers as FIQ -- and arrives at EL2 while */ +/* every partition interrupt stays Group 1, stays an IRQ, and is still */ +/* delivered straight to EL1 with no List Register anywhere. */ +/* */ +/* And it is better than injection rather than merely cheaper. With */ +/* FMO set, PSTATE.F is IGNORED at EL0 and EL1, so a partition cannot */ +/* mask the interrupt that ends its own window. A tick delivered as an */ +/* IRQ to EL1 could be deferred by any guest that disabled interrupts, */ +/* which is the whole property time partitioning must not concede. */ +/* */ +/* The guest half of that arrangement is ALREADY IN PLACE: a partition */ +/* is granted no Group 0 interrupt and never enables ICC_IGRPEN0, so */ +/* nothing can deliver an FIQ to it. What is missing is at EL2 -- */ +/* FMO, PPI 26 in Group 0, and a real body on the FIQ vector -- and */ +/* none of it has been run. Injection through the List Registers stays */ +/* where the roadmap put it: a later phase, for bounding interrupt */ +/* latency, not a prerequisite for a partition tick. */ /* */ /* MISRA C:2012 deviations (justified) */ /* */ diff --git a/platform/cortex_r52/src/zx_timer.c b/platform/cortex_r52/src/zx_timer.c index 9b071b7..801d261 100644 --- a/platform/cortex_r52/src/zx_timer.c +++ b/platform/cortex_r52/src/zx_timer.c @@ -38,10 +38,13 @@ /* merely unread. */ /* */ /* The partition tick -- the hypervisor's OWN timer, on PPI 26, which */ -/* ends a window -- is not here yet, and cannot be until HCR.IMO is */ -/* set: with IMO clear a physical interrupt taken while a partition */ -/* runs is delivered straight to EL1, and the hypervisor would never */ -/* see its own timer. See docs/decisions.md D24. */ +/* ends a window -- is not here yet. It does NOT need HCR.IMO: that */ +/* would route every guest IRQ to EL2 as well. Routing is by */ +/* exception TYPE, so the tick goes in GROUP 0, arrives as an FIQ, and */ +/* HCR.FMO alone brings it to EL2 while guest IRQs stay with EL1. */ +/* With FMO set, PSTATE.F is ignored at EL1, so a partition cannot */ +/* mask the interrupt that ends its own window. */ +/* See docs/decisions.md D24. */ /* */ /* MISRA C:2012 deviations (justified) */ /* */ From d2dde14b695e1ffef82665df133cc62d3bce5456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 2 Sep 2026 19:51:59 -0400 Subject: [PATCH 15/40] Caught a pinned ThreadX too old for the option the guest is built with The workflows pin ThreadX to a commit, and that pin went stale the moment the S32Z280 boot-at-EL1 bracket was upstreamed. The pin still named the dev tip from BEFORE that merge, so every guest image CI built for the S32Z280 was built against a port that had never heard of TX_R52_BOOT_AT_EL1. A DEFINITION NAMING AN OPTION THE SOURCE DOES NOT TEST IS NOT AN ERROR. It is inert. So the build did not fail -- it produced a guest containing the port's entire EL2 reset path, ERET included, which drops to EL1 from EL1 and dies on its first instructions. Confirmed by building one: the guest from the stale pin carries an `eret` and the correct guest carries none, and both link without a single warning. That is the shape of failure this suite exists to refuse. Nothing executes a guest on a hosted runner, so the only symptom was an instruction in a binary nobody ran, and the lane reported success. The warnings-as-errors lane added for the guest support made it worse rather than better: it started building the S32Z280 guest for the first time, against that pin, and called it green. TWO FIXES, BECAUSE THE PIN WILL GO STALE AGAIN. The pin is bumped to the dev commit that carries the bracket, in all three workflows. That is the immediate fix and it has the lifespan of the next upstream option a guest depends on. The lasting one is a guard in the guest's own build: it reads the port's entry.S and REFUSES if TX_R52_BOOT_AT_EL1 does not appear in it, naming what would otherwise happen. Grepping the source is crude, and it is the only check available -- the guest is a separate CMake project, it cannot ask the port which options it supports, and there is no version to compare. Crude and loud beats invisible. Verified both directions: against a checkout without the bracket the build now FAILS with that message, and against one with it the FVP suite is 13 of 13 and both boards build under both toolchains. Assisted-by: Claude Code (Opus 5) --- .github/workflows/clang_check.yml | 2 +- .github/workflows/gcc_check.yml | 2 +- .github/workflows/zx_fvp.yml | 2 +- .../fvp_baser_aemv8r/guest_a/CMakeLists.txt | 34 +++++++++++++++++++ examples/s32z280_evb/guest_a/CMakeLists.txt | 34 +++++++++++++++++++ 5 files changed, 71 insertions(+), 3 deletions(-) diff --git a/.github/workflows/clang_check.yml b/.github/workflows/clang_check.yml index 7605c18..e3029d6 100644 --- a/.github/workflows/clang_check.yml +++ b/.github/workflows/clang_check.yml @@ -80,7 +80,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: eclipse-threadx/threadx - ref: 8c681c188ee748c777185e229024255c054a49d9 + ref: 13c8c768c73b9ce10c9605f6df8b02080440f953 path: threadx - name: Cache Arm Toolchain for Embedded diff --git a/.github/workflows/gcc_check.yml b/.github/workflows/gcc_check.yml index 4167ef4..fd46522 100644 --- a/.github/workflows/gcc_check.yml +++ b/.github/workflows/gcc_check.yml @@ -91,7 +91,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: eclipse-threadx/threadx - ref: 8c681c188ee748c777185e229024255c054a49d9 + ref: 13c8c768c73b9ce10c9605f6df8b02080440f953 path: threadx # The path and key match zx_fvp.yml's exactly so the two workflows share diff --git a/.github/workflows/zx_fvp.yml b/.github/workflows/zx_fvp.yml index 9a8e96a..4717332 100644 --- a/.github/workflows/zx_fvp.yml +++ b/.github/workflows/zx_fvp.yml @@ -151,7 +151,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: eclipse-threadx/threadx - ref: 8c681c188ee748c777185e229024255c054a49d9 + ref: 13c8c768c73b9ce10c9605f6df8b02080440f953 path: threadx - name: Cache the AArch32 Arm GNU toolchain diff --git a/examples/fvp_baser_aemv8r/guest_a/CMakeLists.txt b/examples/fvp_baser_aemv8r/guest_a/CMakeLists.txt index 3c2a66e..147e716 100644 --- a/examples/fvp_baser_aemv8r/guest_a/CMakeLists.txt +++ b/examples/fvp_baser_aemv8r/guest_a/CMakeLists.txt @@ -150,6 +150,40 @@ if(NOT EXISTS ${TX_PORT_BSP}/entry.S) "release archive of the kernel alone.") endif() +# AND THAT THE PORT ACTUALLY HAS THE OPTION THIS GUEST IS BUILT WITH. +# +# TX_R52_BOOT_AT_EL1 is what makes the port's reset path SKIP its EL2 block, and +# a definition that names an option the source does not test is not an error to +# the preprocessor -- it is simply inert. So a ThreadX predating that option +# builds a guest containing the whole EL2 reset path, ERET and all, which drops +# to EL1 from EL1 and dies on its first instructions. +# +# THAT IS NOT HYPOTHETICAL AND IT WAS NOT CAUGHT BY ANYTHING. The workflows pin +# ThreadX to a commit, and the pin went stale the moment the S32Z280 bracket was +# upstreamed: CI kept building this guest against a checkout without it, and +# reported success, because the only symptom is an `eret` in a binary nobody +# executes on a hosted runner. +# +# Grepping the source is crude and it is the only check available here: the guest +# is a separate CMake project, it cannot ask the port what options it supports, +# and there is no version number to compare against. Crude and loud beats +# invisible. +file(READ ${TX_PORT_BSP}/entry.S ZX_PORT_ENTRY_TEXT) +string(FIND "${ZX_PORT_ENTRY_TEXT}" "TX_R52_BOOT_AT_EL1" ZX_PORT_HAS_BOOT_AT_EL1) +unset(ZX_PORT_ENTRY_TEXT) + +if(ZX_PORT_HAS_BOOT_AT_EL1 EQUAL -1) + message(FATAL_ERROR + "This ThreadX checkout's ${TX_PORT_BSP}/entry.S does not know about " + "TX_R52_BOOT_AT_EL1.\n" + "The option would be silently INERT: the guest would be built with the " + "port's whole EL2 reset path in it, including the ERET, and would drop " + "to EL1 from EL1 and die on its first instructions -- while building " + "perfectly cleanly.\n" + "Point ZX_THREADX_SOURCE_DIR at a checkout that carries the boot-at-EL1 " + "bracket for this board.") +endif() + ############################################################################### # The kernel ############################################################################### diff --git a/examples/s32z280_evb/guest_a/CMakeLists.txt b/examples/s32z280_evb/guest_a/CMakeLists.txt index d02f02d..9152b5e 100644 --- a/examples/s32z280_evb/guest_a/CMakeLists.txt +++ b/examples/s32z280_evb/guest_a/CMakeLists.txt @@ -159,6 +159,40 @@ if(NOT EXISTS ${TX_PORT_BSP}/entry.S) "release archive of the kernel alone.") endif() +# AND THAT THE PORT ACTUALLY HAS THE OPTION THIS GUEST IS BUILT WITH. +# +# TX_R52_BOOT_AT_EL1 is what makes the port's reset path SKIP its EL2 block, and +# a definition that names an option the source does not test is not an error to +# the preprocessor -- it is simply inert. So a ThreadX predating that option +# builds a guest containing the whole EL2 reset path, ERET and all, which drops +# to EL1 from EL1 and dies on its first instructions. +# +# THAT IS NOT HYPOTHETICAL AND IT WAS NOT CAUGHT BY ANYTHING. The workflows pin +# ThreadX to a commit, and the pin went stale the moment the S32Z280 bracket was +# upstreamed: CI kept building this guest against a checkout without it, and +# reported success, because the only symptom is an `eret` in a binary nobody +# executes on a hosted runner. +# +# Grepping the source is crude and it is the only check available here: the guest +# is a separate CMake project, it cannot ask the port what options it supports, +# and there is no version number to compare against. Crude and loud beats +# invisible. +file(READ ${TX_PORT_BSP}/entry.S ZX_PORT_ENTRY_TEXT) +string(FIND "${ZX_PORT_ENTRY_TEXT}" "TX_R52_BOOT_AT_EL1" ZX_PORT_HAS_BOOT_AT_EL1) +unset(ZX_PORT_ENTRY_TEXT) + +if(ZX_PORT_HAS_BOOT_AT_EL1 EQUAL -1) + message(FATAL_ERROR + "This ThreadX checkout's ${TX_PORT_BSP}/entry.S does not know about " + "TX_R52_BOOT_AT_EL1.\n" + "The option would be silently INERT: the guest would be built with the " + "port's whole EL2 reset path in it, including the ERET, and would drop " + "to EL1 from EL1 and die on its first instructions -- while building " + "perfectly cleanly.\n" + "Point ZX_THREADX_SOURCE_DIR at a checkout that carries the boot-at-EL1 " + "bracket for this board.") +endif() + ############################################################################### # The kernel ############################################################################### From 1024e6e2919d0707ee89a666e21c3171c32fb98c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 2 Sep 2026 21:41:05 -0400 Subject: [PATCH 16/40] Closed three defects a partition switch would have made visible Carried out of the single-partition work as findings, all outside its scope and all inside this one. THE VALIDATOR AND THE LOADER DISAGREED ABOUT ONE BYTE. zx_manifest_verify compared an image's length against (limit - base) while the limit is INCLUSIVE, so an image that exactly filled its window was refused at boot with a message saying it was too large for a window it fits. zx_partition_prepare has always had the rule right. The two now read identically, and the host suite pins the boundary from both sides -- a one-sided test cannot tell a rule that is off by one from a rule that is right. The loader's own boundary test already existed, which is exactly why nothing caught the disagreement. THE REGION PLANNER BUDGETED AGAINST THE WRONG REGISTER. It checked HMPUIR, which says how many region DESCRIPTORS a part has, and ignored the measured width of HPRENR, which says how many of them a one-write partition switch can turn OFF. The Cortex-R52 TRM disagrees with itself about that width and both ZoneX targets came out twenty bits wide, so it does not bite today; on a part where the two differ, a partition seated past the mask would have its descriptor programmed with its own enable bit set and left there, and the outgoing partition's window would stay live underneath the incoming one. No fault, no diagnostic, and an isolation claim that is simply untrue. zx_mm_plan now takes both budgets and refuses with a code of its own. A NEGATIVE BUILD ANNOUNCED THE VERDICT IT WAS SUPPOSED TO EARN. The probe's negative banner contained the literal fail mark, and the fail mark is what proves a negative run REPORTED its failure rather than crashing -- so every negative run satisfied that condition in its first few lines and a hang afterwards would have been judged a correct result. The banner is reworded and the runner now matches a verdict at the START OF A LINE, which closes it at the reader rather than at the writer. Assisted-by: Claude Code (Opus 5) --- core/inc/zx_manifest.h | 1 + core/inc/zx_mm.h | 31 +++- core/src/zx_manifest_verify.c | 27 ++- core/src/zx_mm_setup.c | 55 +++++- examples/common/zx_one_partition.c | 3 +- examples/common/zx_partitions.c | 3 +- examples/common/zx_probe_main.c | 17 +- .../{guest_a => guest}/CMakeLists.txt | 0 .../link_partition.lds.in} | 0 .../{guest_a => guest}/CMakeLists.txt | 0 .../link_partition.lds.in} | 0 test/fvp/run_fvp_test.py | 37 ++++- test/host/test_zx_manifest.c | 32 ++++ test/host/test_zx_mm.c | 156 +++++++++++++++--- 14 files changed, 324 insertions(+), 38 deletions(-) rename examples/fvp_baser_aemv8r/{guest_a => guest}/CMakeLists.txt (100%) rename examples/fvp_baser_aemv8r/{guest_a/link_partition_a.lds => guest/link_partition.lds.in} (100%) rename examples/s32z280_evb/{guest_a => guest}/CMakeLists.txt (100%) rename examples/s32z280_evb/{guest_a/link_partition_a.lds => guest/link_partition.lds.in} (100%) diff --git a/core/inc/zx_manifest.h b/core/inc/zx_manifest.h index c1c651e..eb77861 100644 --- a/core/inc/zx_manifest.h +++ b/core/inc/zx_manifest.h @@ -320,6 +320,7 @@ _Static_assert((ZX_MAX_PARTITIONS * ZX_MAX_REGIONS_PER_PARTITION) <= 24U, #define ZX_MANIFEST_SHARED_BAD_AP 0x1CU #define ZX_MANIFEST_REGION_BUDGET 0x1DU #define ZX_MANIFEST_IMAGE_EMPTY 0x1EU +#define ZX_MANIFEST_NO_ENABLE_BIT 0x1FU /**************************************************************************/ /* What the validator saw */ diff --git a/core/inc/zx_mm.h b/core/inc/zx_mm.h index 0ab1993..43c40ea 100644 --- a/core/inc/zx_mm.h +++ b/core/inc/zx_mm.h @@ -100,6 +100,13 @@ typedef struct zx_mm_layout_struct D19 keeps a fixed width exactly where the width is the point. */ uint32_t zx_layout_always_mask; uint32_t zx_layout_partition_mask[ZX_MAX_PARTITIONS]; + + /* WHICH HPRENR BITS THE PART ACTUALLY IMPLEMENTS, carried through so + that the report can print it beside the masks built from it. A + number nobody can see is a check nobody can repeat, and this is the + one number that says whether a partition switch can reach every + region the plan assigned. */ + uint32_t zx_layout_enable_bits; } ZX_MM_LAYOUT; /**************************************************************************/ @@ -120,11 +127,33 @@ typedef struct zx_mm_layout_struct * at an index the implementation does not have is UNPREDICTABLE rather than * an error -- the model reports 32 EL2 regions, which is not an * architecturally legal Cortex-R52 value, so a plan that fits the model - * proves nothing about a real part. */ + * proves nothing about a real part. + * + * TWO BUDGETS, NOT ONE, AND THE SECOND IS THE PARTITION SWITCH'S. + * + * region_budget is HMPUIR: how many region DESCRIPTORS the part has, which + * is what decides whether every window can be programmed at all. + * enable_bits is the mask of HPRENR bits the part IMPLEMENTS, measured by + * the reset path writing all ones and reading back what stuck, and it is a + * different question with a different answer: it decides whether a region, + * once programmed, can be turned OFF again. + * + * They can disagree, and the TRM says so twice in incompatible ways -- its + * prose gives HPRENR "regions 0 to 15" while its own bit tables give [19:0] + * on a 20-region part. Both ZoneX targets came out 20 bits wide, so today + * the two budgets agree; on a part where they do not, a plan checked only + * against HMPUIR would put a partition's window at an index with no enable + * bit. Its descriptor would be programmed with HPRLAR.EN set, the + * one-write partition switch would leave that bit alone, and the OUTGOING + * partition's window would stay live underneath the incoming one -- an + * isolation failure with no fault, no diagnostic, and nothing in the log to + * suggest it. That is why the two are separate parameters rather than one + * number the caller reconciles. */ ZX_NODISCARD UINT zx_mm_plan(const ZX_MANIFEST *manifest_ptr, UINT mmio_region_count, UINT region_budget, + uint32_t enable_bits, ZX_MM_LAYOUT *layout_ptr); /* The HPRENR image that must be in force while a partition runs: its own diff --git a/core/src/zx_manifest_verify.c b/core/src/zx_manifest_verify.c index 703f1f1..cf38dd8 100644 --- a/core/src/zx_manifest_verify.c +++ b/core/src/zx_manifest_verify.c @@ -636,10 +636,29 @@ static UINT zx_manifest_partition_check(const ZX_MANIFEST *manifest_ptr, above. No guard is added for them on purpose -- a condition that cannot be false is dead code, and dead code in a function with a coverage floor is a - branch nobody can ever cover. */ - - if ((partition_ptr->zx_partition_image_end - - partition_ptr->zx_partition_image_start) + branch nobody can ever cover. + + THE LENGTH IS REDUCED BY ONE AND THE LIMIT IS NOT, + and getting that backwards is worth a paragraph + because it was wrong here and right in the loader. + The limit is INCLUSIVE, so a window holds + limit - base + 1 bytes; a rule written as + length <= (limit - base) therefore refuses an image + that EXACTLY FILLS its window. That is a manifest + the loader accepts -- zx_partition_prepare asks + whether (length - 1) > (limit - base) -- so the two + disagreed about one byte, and the disagreement had + the validator refusing at boot with a message saying + the image was too large for a window it fits + perfectly. The forms are now identical on purpose: + the subtraction moves to the LENGTH, where it cannot + overflow (length >= 1 above), rather than to the + limit, where base + length - 1 would wrap for a + window reaching the top of the address space and + report a real overrun as a fit. */ + + if (((partition_ptr->zx_partition_image_end + - partition_ptr->zx_partition_image_start) - 1U) <= (region_ptr->zx_region_limit - region_ptr->zx_region_base)) { diff --git a/core/src/zx_mm_setup.c b/core/src/zx_mm_setup.c index 7662526..d79af5f 100644 --- a/core/src/zx_mm_setup.c +++ b/core/src/zx_mm_setup.c @@ -57,16 +57,26 @@ /* exactly the difference a boot-time printout should make visible */ /* rather than hide. */ /* */ +/* AND IT CHECKS TWO BUDGETS, WHICH IS THE HALF THAT WAS MISSING. */ +/* HMPUIR says how many region DESCRIPTORS exist; HPRENR's implemented */ +/* width says how many of them the partition switch can turn OFF. A */ +/* plan checked only against the first can seat a partition's window */ +/* at an index with no enable bit, and a one-write switch then leaves */ +/* the outgoing partition's memory reachable while the incoming one */ +/* runs. Nothing faults and nothing is logged. See zx_mm.h. */ +/* */ /**************************************************************************/ UINT zx_mm_plan(const ZX_MANIFEST *manifest_ptr, UINT mmio_region_count, UINT region_budget, + uint32_t enable_bits, ZX_MM_LAYOUT *layout_ptr) { - UINT next_index; - UINT partition_index; - UINT bit_index; + UINT next_index; + UINT partition_index; + UINT bit_index; + uint32_t used_mask; if (manifest_ptr == (const ZX_MANIFEST *)0) { @@ -101,6 +111,7 @@ UINT zx_mm_plan(const ZX_MANIFEST *manifest_ptr, layout_ptr->zx_layout_mmio_count = 0U; layout_ptr->zx_layout_regions_used = 0U; layout_ptr->zx_layout_always_mask = 0U; + layout_ptr->zx_layout_enable_bits = enable_bits; for (partition_index = 0U; partition_index < ZX_MAX_PARTITIONS; partition_index++) @@ -180,6 +191,34 @@ UINT zx_mm_plan(const ZX_MANIFEST *manifest_ptr, return ZX_MANIFEST_REGION_BUDGET; } + /* AND AGAINST THE OTHER BUDGET: every index assigned above must have an + HPRENR bit, or the partition switch cannot turn its region off. + + The mask is built from next_index rather than ORed together as the + loops ran, because the MMIO block and the partition blocks are + contiguous by construction and one shift is cheaper to read than an + accumulator threaded through two loops. next_index is at most 32 + here -- every path that could make it larger has already returned -- + so the shift is defined; a count of exactly 32 is spelled as the + all-ones constant rather than as a shift by 32, which is undefined + behaviour and the reason this is not written as the obvious + one-liner. + + ZX_MANIFEST_NO_ENABLE_BIT and not ZX_MANIFEST_REGION_BUDGET, because + these are different faults with different fixes: the budget is fixed + by asking for fewer regions, and this one is fixed by moving to a + part whose HPRENR is wide enough. A shared code would send a reader + to shrink a manifest that is already small enough. */ + + used_mask = (next_index >= 32U) + ? 0xFFFFFFFFU + : (uint32_t)(((uint32_t)1U << next_index) - 1U); + + if ((used_mask & ~enable_bits) != 0U) + { + return ZX_MANIFEST_NO_ENABLE_BIT; + } + return ZX_MANIFEST_SUCCESS; } @@ -297,5 +336,15 @@ void zx_mm_report(const ZX_MM_LAYOUT *layout_ptr, zx_console_puts(" regions used "); zx_console_putdec(layout_ptr->zx_layout_regions_used); + zx_console_puts(", HPRENR bits implemented "); + zx_console_puthex(layout_ptr->zx_layout_enable_bits); zx_console_puts("\n"); + zx_console_puts(" Two budgets, not one. The count is how many\n" + " region descriptors the part has; the mask is\n" + " which of them a ONE-WRITE partition switch can\n" + " turn off. A region seated past the mask would be\n" + " programmed with its own enable bit set and left\n" + " there, so the outgoing partition's window would\n" + " stay live under the incoming one, with nothing\n" + " to fault on.\n"); } diff --git a/examples/common/zx_one_partition.c b/examples/common/zx_one_partition.c index 77a718e..a63a37b 100644 --- a/examples/common/zx_one_partition.c +++ b/examples/common/zx_one_partition.c @@ -330,7 +330,8 @@ ZX_NORETURN void zx_el2_main(void) zx_check("the manifest passes every rule", 1U); - status = zx_mm_plan(&zx_manifest, board_regions, el2_regions, &zx_layout); + status = zx_mm_plan(&zx_manifest, board_regions, el2_regions, + zx_hprenr_implemented_bits, &zx_layout); zx_note("zx_mm_plan", status); zx_check("the layout fits this part's region budget", (status == ZX_MANIFEST_SUCCESS) ? 1U : 0U); diff --git a/examples/common/zx_partitions.c b/examples/common/zx_partitions.c index ca86dd3..194056b 100644 --- a/examples/common/zx_partitions.c +++ b/examples/common/zx_partitions.c @@ -454,7 +454,8 @@ void zx_phase_two_partitions(uint32_t board_regions, uint32_t el2_regions) /* The plan. */ /* ---------------------------------------------------------------- */ - status = zx_mm_plan(&zx_manifest, board_regions, el2_regions, &zx_layout); + status = zx_mm_plan(&zx_manifest, board_regions, el2_regions, + zx_hprenr_implemented_bits, &zx_layout); zx_note("zx_mm_plan", status); if (status != ZX_MANIFEST_SUCCESS) diff --git a/examples/common/zx_probe_main.c b/examples/common/zx_probe_main.c index 39d348e..e199a15 100644 --- a/examples/common/zx_probe_main.c +++ b/examples/common/zx_probe_main.c @@ -592,10 +592,19 @@ static void zx_phase_violation(void) " FAILED. That is the point: it proves the check can fail.\n" "\n" " Expected here: ATTEMPTED set, SURVIVED set, two failing checks,\n" - " no stage-2 fault from this phase, and ZONEX RESULT: FAILED. The\n" - " CTest entry for this build expects FAILED, so this build\n" - " starting to PASS -- meaning the violation stopped being detected\n" - " -- fails the suite.\n"); + " no stage-2 fault from this phase, and a FAILED verdict at the end\n" + " of the run. The CTest entry for this build expects that verdict,\n" + " so this build starting to PASS -- meaning the violation stopped\n" + " being detected -- fails the suite.\n" + "\n" + " THIS PARAGRAPH DELIBERATELY DOES NOT SPELL THE VERDICT LINE OUT.\n" + " The runner judges a negative build on TWO things: that the pass\n" + " mark is absent AND that the fail mark is present, and the second\n" + " is what distinguishes a violation that was detected from an image\n" + " that was never built, a model that would not start, or a hang.\n" + " A banner carrying the literal fail mark satisfies that condition\n" + " before the run has done anything, so a crash anywhere below here\n" + " would have been judged a correct negative result.\n"); #endif zx_console_puts(" target address "); diff --git a/examples/fvp_baser_aemv8r/guest_a/CMakeLists.txt b/examples/fvp_baser_aemv8r/guest/CMakeLists.txt similarity index 100% rename from examples/fvp_baser_aemv8r/guest_a/CMakeLists.txt rename to examples/fvp_baser_aemv8r/guest/CMakeLists.txt diff --git a/examples/fvp_baser_aemv8r/guest_a/link_partition_a.lds b/examples/fvp_baser_aemv8r/guest/link_partition.lds.in similarity index 100% rename from examples/fvp_baser_aemv8r/guest_a/link_partition_a.lds rename to examples/fvp_baser_aemv8r/guest/link_partition.lds.in diff --git a/examples/s32z280_evb/guest_a/CMakeLists.txt b/examples/s32z280_evb/guest/CMakeLists.txt similarity index 100% rename from examples/s32z280_evb/guest_a/CMakeLists.txt rename to examples/s32z280_evb/guest/CMakeLists.txt diff --git a/examples/s32z280_evb/guest_a/link_partition_a.lds b/examples/s32z280_evb/guest/link_partition.lds.in similarity index 100% rename from examples/s32z280_evb/guest_a/link_partition_a.lds rename to examples/s32z280_evb/guest/link_partition.lds.in diff --git a/test/fvp/run_fvp_test.py b/test/fvp/run_fvp_test.py index 18e5168..276ba37 100755 --- a/test/fvp/run_fvp_test.py +++ b/test/fvp/run_fvp_test.py @@ -64,6 +64,35 @@ FAIL_MARK = "ZONEX RESULT: FAILED" +def verdict_line(output, mark): + """True when `mark` appears as a VERDICT and not merely as prose. + + A verdict is printed alone on its own line. An image is also free to + talk ABOUT its verdict -- a negative build explains what it expects + before it does anything -- and a substring search cannot tell the two + apart. + + That distinction is not pedantic; it was a hole. The negative probe + build's opening banner contained the literal fail mark inside a + sentence, and the fail mark is exactly what proves a negative run + REPORTED its failure rather than crashing. Every negative run therefore + satisfied that condition in its first few lines, so an image that + hung, faulted, or reset immediately afterwards was judged a correct + negative result. + + Matching at the START OF A LINE closes it at the reader rather than at + the writer, which is the right end: the next explanatory paragraph that + happens to quote a verdict cannot reopen it. + + A prefix and not an exact match, because some verdicts carry a reason + -- "ZONEX RESULT: FAILED -- the target cannot host stage 2 as this + image needs it" is a real one, and it is a verdict rather than prose. + What that still excludes is the case this exists for: a mark quoted + INSIDE a sentence, which never begins the line it is on. + """ + return any(line.strip().startswith(mark) for line in output.splitlines()) + + def as_text(stream): """Normalise a captured stream to str. @@ -87,14 +116,14 @@ def judge(output, expect): printing both is reported as broken rather than as whichever came first. """ if expect == "pass": - if FAIL_MARK in output: + if verdict_line(output, FAIL_MARK): print("FAIL: the image reported failing checks.", file=sys.stderr) for line in output.splitlines(): if line.startswith(" [FAIL]"): print(" " + line.strip(), file=sys.stderr) return 1 - if PASS_MARK not in output: + if not verdict_line(output, PASS_MARK): print( "FAIL: no ZoneX verdict line was found. The image neither " "passed nor reported failure, so it did not reach its " @@ -108,7 +137,7 @@ def judge(output, expect): # expect == "fail": this image was built to violate something, and the # run is judged on whether the violation was DETECTED and NAMED. - if PASS_MARK in output: + if verdict_line(output, PASS_MARK): print( "FAIL: the image reported ALL CHECKS PASSED, but it was built to " "fail. The check it was built to violate has stopped detecting " @@ -118,7 +147,7 @@ def judge(output, expect): ) return 1 - if FAIL_MARK not in output: + if not verdict_line(output, FAIL_MARK): print( "FAIL: no ZoneX verdict line was found. A negative build must " "REPORT its own failure, not merely fail to run: an image that " diff --git a/test/host/test_zx_manifest.c b/test/host/test_zx_manifest.c index 82636a1..ea48890 100644 --- a/test/host/test_zx_manifest.c +++ b/test/host/test_zx_manifest.c @@ -416,6 +416,38 @@ static void test_runnability(void) partitions[1].zx_partition_entry = P1_TINY_CODE_BASE; partitions[1].zx_partition_image_end = IMAGE_START + 0x20U; EXPECT(ZX_MANIFEST_SUCCESS, ZX_MANIFEST_NO_INDEX, ZX_MANIFEST_NO_INDEX); + + /* AN IMAGE THAT EXACTLY FILLS ITS WINDOW, which is the boundary the two + halves of this rule disagreed about. + + The tiny region runs from P1_TINY_CODE_BASE to P1_TINY_CODE_LIMIT + INCLUSIVE, so it holds 0x40 bytes. An image of exactly 0x40 must be + accepted: zx_partition_prepare accepts it, copies it, and leaves not + one byte spare, which is a perfectly good guest. The validator used + to compare the length against (limit - base) -- 0x3F -- and refused + it, so a manifest the loader would have loaded was rejected at boot + with a message saying the image was too large for a window it fits. + + Both sides of the boundary are here on purpose. A one-sided test + cannot tell a rule that is off by one from a rule that is right: it + is the pair -- 0x40 accepted, 0x41 refused -- that pins the + comparison to the exact byte. */ + + reset_manifest(); + set_region(&p1_regions[3], P1_TINY_CODE_BASE, P1_TINY_CODE_LIMIT, + ZX_AP_EL2_RW_GUEST_RW, ZX_XN_EXECUTABLE, ATTR_NORMAL); + partitions[1].zx_partition_region_count = 4U; + partitions[1].zx_partition_entry = P1_TINY_CODE_BASE; + partitions[1].zx_partition_image_end = IMAGE_START + 0x40U; + EXPECT(ZX_MANIFEST_SUCCESS, ZX_MANIFEST_NO_INDEX, ZX_MANIFEST_NO_INDEX); + + reset_manifest(); + set_region(&p1_regions[3], P1_TINY_CODE_BASE, P1_TINY_CODE_LIMIT, + ZX_AP_EL2_RW_GUEST_RW, ZX_XN_EXECUTABLE, ATTR_NORMAL); + partitions[1].zx_partition_region_count = 4U; + partitions[1].zx_partition_entry = P1_TINY_CODE_BASE; + partitions[1].zx_partition_image_end = IMAGE_START + 0x41U; + EXPECT(ZX_MANIFEST_IMAGE_TOO_LARGE, 1U, ZX_MANIFEST_NO_INDEX); } static void test_time_partitioning(void) diff --git a/test/host/test_zx_mm.c b/test/host/test_zx_mm.c index f6daa58..c3ad80d 100644 --- a/test/host/test_zx_mm.c +++ b/test/host/test_zx_mm.c @@ -41,6 +41,15 @@ #include "zx_console_capture.h" #include "zx_test.h" +/* A part whose HPRENR implements every bit, which is what most of the cases + below want: they are about the region BUDGET, and a narrow enable mask + would fail them for a second reason and hide the first. The cases that + are about the enable mask pass their own value, and they are the only + ones that should. Both ZoneX targets measure 0x000FFFFF -- twenty bits + for twenty regions -- so neither extreme is the real hardware, which is + exactly why both are exercised here rather than on a board. */ +#define ZX_HPRENR_ALL 0xFFFFFFFFU + static ZX_REGION p_regions[ZX_MAX_PARTITIONS][ZX_MAX_REGIONS_PER_PARTITION]; static ZX_PARTITION partitions[ZX_MAX_PARTITIONS]; static ZX_MANIFEST manifest; @@ -74,7 +83,7 @@ static void test_plan_with_mmio(void) /* Two hypervisor MMIO regions -- the silicon case -- and two partitions of three regions each. */ reset_manifest(2U, 3U); - ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, &layout), + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, ZX_HPRENR_ALL, &layout), ZX_MANIFEST_SUCCESS); ZX_CHECK_EQ(layout.zx_layout_mmio_count, 2U); @@ -99,7 +108,7 @@ static void test_plan_without_mmio(void) down, which is exactly why the layout is printed at boot: region 3 is a different window on the two targets. */ reset_manifest(2U, 3U); - ZX_CHECK_EQ(zx_mm_plan(&manifest, 0U, 32U, &layout), + ZX_CHECK_EQ(zx_mm_plan(&manifest, 0U, 32U, ZX_HPRENR_ALL, &layout), ZX_MANIFEST_SUCCESS); ZX_CHECK_EQ(layout.zx_layout_always_mask, 0U); @@ -120,7 +129,7 @@ static void test_masks_are_disjoint(void) partition that can reach its neighbour's memory while it runs, which faults nothing and prints nothing. */ reset_manifest(ZX_MAX_PARTITIONS, 2U); - ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 32U, &layout), + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 32U, ZX_HPRENR_ALL, &layout), ZX_MANIFEST_SUCCESS); for (first = 0U; first < layout.zx_layout_partitions; first++) @@ -146,32 +155,34 @@ static void test_masks_are_disjoint(void) static void test_plan_rejections(void) { reset_manifest(2U, 3U); - ZX_CHECK_EQ(zx_mm_plan((const ZX_MANIFEST *)0, 2U, 20U, &layout), + ZX_CHECK_EQ(zx_mm_plan((const ZX_MANIFEST *)0, 2U, 20U, ZX_HPRENR_ALL, + &layout), ZX_MANIFEST_NULL_POINTER); reset_manifest(2U, 3U); - ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, (ZX_MM_LAYOUT *)0), + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, ZX_HPRENR_ALL, + (ZX_MM_LAYOUT *)0), ZX_MANIFEST_NULL_POINTER); reset_manifest(2U, 3U); manifest.zx_manifest_partitions = (const ZX_PARTITION *)0; - ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, &layout), + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, ZX_HPRENR_ALL, &layout), ZX_MANIFEST_NULL_POINTER); reset_manifest(0U, 3U); - ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, &layout), + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, ZX_HPRENR_ALL, &layout), ZX_MANIFEST_NO_PARTITIONS); reset_manifest(ZX_MAX_PARTITIONS + 1U, 3U); - ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, &layout), + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, ZX_HPRENR_ALL, &layout), ZX_MANIFEST_TOO_MANY_PARTITIONS); reset_manifest(2U, 0U); - ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, &layout), + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, ZX_HPRENR_ALL, &layout), ZX_MANIFEST_NO_REGIONS); reset_manifest(2U, ZX_MAX_REGIONS_PER_PARTITION + 1U); - ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, &layout), + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, ZX_HPRENR_ALL, &layout), ZX_MANIFEST_TOO_MANY_REGIONS); } @@ -182,40 +193,143 @@ static void test_budget(void) region at an index the part does not have, and only the first of those is visible without hardware. */ reset_manifest(2U, 3U); - ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 8U, &layout), + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 8U, ZX_HPRENR_ALL, &layout), ZX_MANIFEST_SUCCESS); reset_manifest(2U, 3U); - ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 7U, &layout), + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 7U, ZX_HPRENR_ALL, &layout), ZX_MANIFEST_REGION_BUDGET); /* The model's 32 EL2 regions are not an architecturally legal Cortex-R52 value, so a plan that fits the model can still be too big for silicon. Same manifest, two budgets, two answers. */ reset_manifest(ZX_MAX_PARTITIONS, ZX_MAX_REGIONS_PER_PARTITION); - ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 32U, &layout), + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 32U, ZX_HPRENR_ALL, &layout), ZX_MANIFEST_SUCCESS); reset_manifest(ZX_MAX_PARTITIONS, ZX_MAX_REGIONS_PER_PARTITION); - ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, &layout), + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, ZX_HPRENR_ALL, &layout), ZX_MANIFEST_REGION_BUDGET); /* HPRENR has 32 bits, so an index with no enable bit is refused even when a caller claims a larger budget. A region that cannot be enabled is a hole in the memory map with no diagnostic. */ reset_manifest(2U, 3U); - ZX_CHECK_EQ(zx_mm_plan(&manifest, 33U, 64U, &layout), + ZX_CHECK_EQ(zx_mm_plan(&manifest, 33U, 64U, ZX_HPRENR_ALL, &layout), ZX_MANIFEST_REGION_BUDGET); reset_manifest(2U, 3U); - ZX_CHECK_EQ(zx_mm_plan(&manifest, 30U, 64U, &layout), + ZX_CHECK_EQ(zx_mm_plan(&manifest, 30U, 64U, ZX_HPRENR_ALL, &layout), ZX_MANIFEST_REGION_BUDGET); } +static void test_enable_bit_budget(void) +{ + /* THE SECOND BUDGET, AND WHY IT IS NOT THE FIRST ONE. + * + * A part can have more region DESCRIPTORS than HPRENR has enable BITS. + * The Cortex-R52 TRM says so twice and disagrees with itself: its prose + * gives HPRENR "regions 0 to 15" while its own bit tables give [19:0] + * on a 20-region implementation. Both ZoneX targets measured twenty + * bits, so the two budgets agree on the hardware this suite can reach + * -- which is exactly why the disagreement has to be tested HERE, on a + * workstation, with a mask no board would produce. + * + * What the defect looks like on a part where they differ: a partition + * seated at an index above the mask gets its descriptor programmed with + * HPRLAR.EN set, the one-write partition switch leaves that bit alone + * because HPRENR has nothing there, and the outgoing partition's window + * stays reachable underneath the incoming one. No fault, no + * diagnostic, and an isolation claim that is simply untrue. + */ + + /* 2 MMIO + 3 + 3 = 8 regions on a part with twenty descriptors and a + SIXTEEN-bit HPRENR. Every index used is below 16, so this fits and + must be accepted -- the narrow mask on its own is not an error. */ + reset_manifest(2U, 3U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, 0x0000FFFFU, &layout), + ZX_MANIFEST_SUCCESS); + ZX_CHECK_EQ(layout.zx_layout_enable_bits, 0x0000FFFFU); + + /* The same narrow part with four partitions of four regions each: + 2 + 16 = 18 indices, which the descriptors have room for and the + enable mask does not. Index 16 and 17 would be programmed and could + never be switched off. */ + reset_manifest(4U, 4U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, 0x0000FFFFU, &layout), + ZX_MANIFEST_NO_ENABLE_BIT); + + /* And the SAME manifest on a part whose mask is as wide as its region + count -- which is what both ZoneX targets actually measured -- is + accepted. The pair is the point: a rule that rejects everything is + indistinguishable from one that is right until it is seen to + accept. */ + reset_manifest(4U, 4U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, 0x000FFFFFU, &layout), + ZX_MANIFEST_SUCCESS); + + /* The exact boundary, both sides. Eight indices need bits 0 to 7, so a + mask of 0xFF is exactly enough and 0x7F is one short. An off-by-one + here would either refuse a plan that fits or accept the defect above, + and only a two-sided test can tell those apart. */ + reset_manifest(2U, 3U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, 0x000000FFU, &layout), + ZX_MANIFEST_SUCCESS); + + reset_manifest(2U, 3U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, 0x0000007FU, &layout), + ZX_MANIFEST_NO_ENABLE_BIT); + + /* A HOLE in the middle of the mask, which no real part is likely to + have and which the check must still catch: the rule is "every index + assigned has a bit", not "enough bits exist". Bit 5 missing with + eight indices in use has to be refused, and a count-based check + would pass it. */ + reset_manifest(2U, 3U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, 0xFFFFFFDFU, &layout), + ZX_MANIFEST_NO_ENABLE_BIT); + + /* And a part with no HPRENR at all. Nothing could ever be switched, + so nothing may be planned -- including the hypervisor's own MMIO, + which is in every mask. */ + reset_manifest(2U, 3U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, 0U, &layout), + ZX_MANIFEST_NO_ENABLE_BIT); + + /* ALL THIRTY-TWO INDICES IN USE, which is the one case where the mask + cannot be built by shifting. + * + * 29 hypervisor MMIO regions and one partition of three take indices 0 + * to 31 inclusive, so every bit of HPRENR is needed. Computing that + * mask as ((1 << 32) - 1) is UNDEFINED BEHAVIOUR -- a shift by the + * width of the type -- and on this architecture it is not even reliably + * zero: Arm's shifter takes the count modulo 256 while x86's takes it + * modulo 32, so the same expression yields 0 on the target and 1 on the + * host that tests it. The planner spells the all-ones case out instead, + * and this is the case that reaches that spelling. + * + * No real board looks like this. It is here because the alternative is + * a branch nothing exercises in a file with a 100% floor, and a floor + * with an exception in it is a floor nobody trusts. */ + + reset_manifest(1U, 3U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 29U, 32U, ZX_HPRENR_ALL, &layout), + ZX_MANIFEST_SUCCESS); + ZX_CHECK_EQ(layout.zx_layout_regions_used, 32U); + ZX_CHECK_EQ(layout.zx_layout_partition_mask[0], 0xFFFFFFFFU); + + /* The same geometry one enable bit short, so that the all-ones mask is + seen to REJECT as well as to accept. Bit 31 is the one missing, and + it is partition 0's last region. */ + reset_manifest(1U, 3U); + ZX_CHECK_EQ(zx_mm_plan(&manifest, 29U, 32U, 0x7FFFFFFFU, &layout), + ZX_MANIFEST_NO_ENABLE_BIT); +} + static void test_partition_mask_lookup(void) { reset_manifest(2U, 3U); - ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, &layout), + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, ZX_HPRENR_ALL, &layout), ZX_MANIFEST_SUCCESS); ZX_CHECK_EQ(zx_mm_partition_mask(&layout, 0U), 0x0000001FU); @@ -233,7 +347,7 @@ static void test_partition_mask_lookup(void) static void test_report(void) { reset_manifest(2U, 3U); - ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, &layout), + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, ZX_HPRENR_ALL, &layout), ZX_MANIFEST_SUCCESS); zx_capture_reset(); @@ -246,10 +360,11 @@ static void test_report(void) ZX_CHECK_EQ(zx_capture_contains("2..4"), 1U); ZX_CHECK_EQ(zx_capture_contains("5..7"), 1U); ZX_CHECK_EQ(zx_capture_contains("regions used"), 1U); + ZX_CHECK_EQ(zx_capture_contains("HPRENR bits implemented"), 1U); /* On a board with none, say so rather than printing "0..-1". */ reset_manifest(2U, 3U); - ZX_CHECK_EQ(zx_mm_plan(&manifest, 0U, 20U, &layout), + ZX_CHECK_EQ(zx_mm_plan(&manifest, 0U, 20U, ZX_HPRENR_ALL, &layout), ZX_MANIFEST_SUCCESS); zx_capture_reset(); @@ -259,7 +374,7 @@ static void test_report(void) /* An unnamed partition must not print a null pointer. */ reset_manifest(2U, 3U); partitions[1].zx_partition_name = (const CHAR *)0; - ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, &layout), + ZX_CHECK_EQ(zx_mm_plan(&manifest, 2U, 20U, ZX_HPRENR_ALL, &layout), ZX_MANIFEST_SUCCESS); zx_capture_reset(); @@ -277,6 +392,7 @@ ZX_TEST_MAIN("test_zx_mm", test_masks_are_disjoint(); test_plan_rejections(); test_budget(); + test_enable_bit_budget(); test_partition_mask_lookup(); test_report(); ) From f87705f971ff12a90f5097eab2a78586794e6e4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 2 Sep 2026 21:41:19 -0400 Subject: [PATCH 17/40] Gave ZoneX a static major frame, and put it on the coverage floor core/src/zx_schedule.c was the last empty file in the repository. It now holds the window table, the round-robin advance and the arithmetic that says where each boundary falls on the physical counter. EVERY BOUNDARY IS AN ABSOLUTE COUNT FROM ONE EPOCH, never a countdown re-armed at each tick. Re-arming a relative interval inside the handler adds the handler's own latency to every window, so a frame declared as 10 ms becomes 10 ms plus the switch -- cumulatively, for the life of the run. The schedule still looks fixed and the windows stay in the right proportion; the frame simply stops being the length it was declared to be. It is the most common way a deterministic frame stops being one and it is invisible in any run short enough to read. WHICH IS WHY THIS FILE JOINS THE 100% LINE-AND-BRANCH FLOOR, and the decision was made rather than defaulted either way. Catching drift needs ten thousand frames, which is a millisecond on a workstation and two minutes on the S32Z280 -- and the central host case asserts the ten-thousandth boundary is EXACTLY the epoch plus the tick count times the tick length. The arithmetic is also 64-bit against a counter that has been running since reset, so an epoch just below 0xFFFFFFFF is a fixture here and nine minutes of waiting on a bench. The rest is deliberately absent. One window per partition, in manifest order, lengths fixed at build time; no priorities, no admission control, no yielding into a neighbour's window. An idle partition BURNS its window, which is not a limitation to be fixed later but what a static frame means: handing the remainder to the next partition would make one partition's start time depend on another's behaviour, and the independence of those two things is the entire purchase. Assisted-by: Claude Code (Opus 5) --- core/inc/zx_schedule.h | 240 +++++++++++++++- core/src/zx_schedule.c | 491 +++++++++++++++++++++++++++++++- test/host/CMakeLists.txt | 1 + test/host/run.sh | 30 +- test/host/test_zx_schedule.c | 533 +++++++++++++++++++++++++++++++++++ 5 files changed, 1276 insertions(+), 19 deletions(-) create mode 100644 test/host/test_zx_schedule.c diff --git a/core/inc/zx_schedule.h b/core/inc/zx_schedule.h index 2e6f87f..84b8cd1 100644 --- a/core/inc/zx_schedule.h +++ b/core/inc/zx_schedule.h @@ -22,8 +22,8 @@ /* DESCRIPTION */ /* */ /* Time partitioning: the fixed, cyclic window schedule that decides */ -/* which partition owns the core, and the hypervisor-timer tick that */ -/* ends each window. */ +/* which partition owns the core, and where the boundary that ends */ +/* each window falls on the physical counter. */ /* */ /* Guests read time through the VIRTUAL timer, with a per-partition */ /* CNTVOFF, so that a descheduled partition's clock is frozen rather */ @@ -32,10 +32,57 @@ /* timer is simpler and undermines the determinism claim, which is the */ /* point of the demonstrator. */ /* */ -/* STATUS */ +/* WHERE THE SPLIT IS */ /* */ -/* Declared empty. The second partition brings with it the first */ -/* schedule worth the name; one partition needs no scheduler. */ +/* This header DECIDES; it does not touch the machine. Everything */ +/* here is arithmetic over a manifest and a counter reading: which */ +/* window is current, which one is next, and what absolute count the */ +/* current one ends at. Nothing here reads a timer, writes a */ +/* comparator or switches a region set. The caller -- which does have */ +/* hardware -- reads the counter, programmes CNTHP_CVAL from the */ +/* deadline this file computes, and performs the switch. */ +/* */ +/* That is the same split zx_mm.h and zx_partition.h make, for the */ +/* same reason, and it matters more here than in either of them: the */ +/* property this component exists to guarantee is that a frame does */ +/* not DRIFT, and drift is a property of arithmetic over many */ +/* iterations. A workstation can run ten thousand frames of it in */ +/* a millisecond; a board cannot. */ +/* */ +/* THE ONE PROPERTY EVERYTHING HERE EXISTS FOR */ +/* */ +/* EVERY BOUNDARY IS AN ABSOLUTE COUNT MEASURED FROM ONE EPOCH, never */ +/* a countdown re-armed at each tick. */ +/* */ +/* Re-arming a relative interval inside the handler adds the */ +/* handler's own latency to every window, so a frame that is supposed */ +/* to be 10 ms becomes 10 ms plus however long the switch took -- */ +/* every time, cumulatively. The schedule still looks fixed, the */ +/* windows are still in the right proportion, and the frame slowly */ +/* stops being the length it was declared to be. It is the single */ +/* most common way a "deterministic" frame stops being one, and it is */ +/* invisible in any run short enough to read. */ +/* */ +/* So the boundary is epoch + (ticks so far) x (counts per tick), and */ +/* the switch's own cost comes out of the window it happens in rather */ +/* than being added to the frame. A window too short to hold its own */ +/* switch is then a MISSED DEADLINE, which is a reportable condition */ +/* rather than a schedule that silently slows down. */ +/* */ +/* PHASE 0 IS STATIC, AND THE OMISSIONS ARE DELIBERATE */ +/* */ +/* One window per partition, in manifest order, round robin, with */ +/* lengths fixed at build time. No priorities, no admission control, */ +/* no yielding into another partition's window, no idle detection. */ +/* */ +/* An idle partition BURNS its window, and that is not a limitation */ +/* to be fixed later -- it is what a static frame means. Trapping */ +/* WFI (HCR.TWI) so that a blocked guest hands its remaining time to */ +/* the next partition would raise throughput and would make a */ +/* partition's start time depend on its neighbour's behaviour, which */ +/* is precisely the coupling temporal partitioning is bought to */ +/* remove. It is a later-phase option and it is declined here on */ +/* purpose. */ /* */ /**************************************************************************/ @@ -43,11 +90,194 @@ #define ZX_SCHEDULE_H #include "zx_api.h" +#include "zx_manifest.h" #ifdef __cplusplus extern "C" { #endif +/**************************************************************************/ +/* One window */ +/**************************************************************************/ + +/* A slot in the major frame: which partition owns it and for how long. + * + * A SEPARATE ARRAY FROM THE PARTITION LIST, even though Phase 0 gives every + * partition exactly one window and could therefore index the manifest + * directly. The two are different things and they stop being the same + * length the first time a partition needs two slots in a frame -- a + * high-rate partition interleaved with a slow one is the ordinary shape of + * a real major frame, and it is a change to the BUILDER below rather than + * to anything that consumes a schedule. Collapsing them now would put that + * change in the switch path instead. */ + +typedef struct zx_schedule_window_struct +{ + UINT zx_window_partition; /* index into the manifest */ + ULONG zx_window_ticks; /* how long the slot lasts */ +} ZX_SCHEDULE_WINDOW; + +/**************************************************************************/ +/* The schedule */ +/**************************************************************************/ + +/* The whole time-partitioning state, in one object. + * + * ONE STRUCTURE AND NO STATIC STATE, so that the host suite can run a + * schedule for as many frames as it likes without a fixture, and so that + * two schedules could exist at once if a later phase ever needs to validate + * one while another runs. + * + * The counter fields are 64-bit because the physical counter is. At the + * S32Z280's 8 MHz a 32-bit count wraps in nine minutes, and a demonstrator + * whose determinism claim expires after nine minutes is not one anybody + * should quote. */ + +typedef struct zx_schedule_struct +{ + ZX_SCHEDULE_WINDOW zx_schedule_windows[ZX_MAX_PARTITIONS]; + UINT zx_schedule_window_count; + + /* The major frame, as declared and as it will be spent. */ + ULONG zx_schedule_frame_ticks; + + /* How many counter counts one scheduling tick is. A BOARD fact -- the + counter runs at 100 MHz on the model and 8 MHz on the S32Z280 -- so + it is passed in rather than assumed, and the schedule is expressed in + ticks so that one manifest describes both targets. */ + uint64_t zx_schedule_counts_per_tick; + + /* WHERE THE FRAME STARTED, on the physical counter, once and for all. + Every boundary in the whole run is computed from this one reading, so + there is nothing for a per-tick error to accumulate into. */ + uint64_t zx_schedule_epoch; + + /* Ticks consumed since the epoch, across all frames. The other half of + the absolute-deadline arithmetic, and the reason it is a tick count + rather than a count of counter counts: ticks are exact integers and + counts are the product of one multiplication, so the rounding + happens once per boundary instead of accumulating. */ + uint64_t zx_schedule_ticks_spent; + + UINT zx_schedule_current; /* which window is running */ + ULONG zx_schedule_frames; /* completed major frames */ + + /* Stop after this many frames, or run for ever when zero. + A bounded run is what a ctest can assert on: "it was still going + when the harness timed out" is not a result. See open question 3 of + the step this component was written for -- wrap, but be able to + stop. */ + ULONG zx_schedule_frame_limit; + + /* MISSED DEADLINES: boundaries that had already passed by the time they + were computed, which means a window was too short to contain its own + partition switch. + * + Counted rather than ignored and counted rather than fatal. A missed + deadline is the one failure mode this arithmetic can have that does + not show up as a wrong answer: the schedule keeps running, the + partitions keep taking turns, and the frame is simply longer than it + was declared to be. A number a test can assert is zero is the only + way that becomes visible. */ + ULONG zx_schedule_missed; + + /* Zero once the frame limit has been reached, so that "the schedule is + finished" is a state a caller reads rather than a condition it + recomputes. */ + UINT zx_schedule_running; +} ZX_SCHEDULE; + +/**************************************************************************/ +/* The builder */ +/**************************************************************************/ + +/* Turn a manifest into a round-robin major frame. + * + * Pure: no hardware, no static state, no allocation, same inputs always the + * same answer. Returns ZX_MANIFEST_SUCCESS or one of the ZX_MANIFEST_* + * codes -- one vocabulary rather than two, so a boot message means the same + * thing wherever it came from. + * + * counts_per_tick is what one scheduling tick costs on THIS board's + * counter. Zero is refused rather than accepted as "instant": a schedule + * whose every boundary is the epoch would fire its timer continuously and + * present as a hang inside the hypervisor, which is the least diagnosable + * outcome available here. + * + * frame_limit of zero means run for ever, which is what a demonstration + * wants and what a regression must not have. */ + +ZX_NODISCARD UINT zx_schedule_build(const ZX_MANIFEST *manifest_ptr, + uint64_t counts_per_tick, + ULONG frame_limit, + ZX_SCHEDULE *schedule_ptr); + +/**************************************************************************/ +/* Running the frame */ +/**************************************************************************/ + +/* Start the frame at a counter reading the caller has just taken. Every + boundary for the rest of the run is measured from it. */ + +void zx_schedule_start(ZX_SCHEDULE *schedule_ptr, uint64_t now); + +/* THE ABSOLUTE COUNT AT WHICH THE CURRENT WINDOW ENDS. + * + * This is the value that goes into CNTHP_CVAL, and it is a comparison + * against the physical counter rather than a countdown, which is what makes + * the frame immune to the handler's own latency. One multiply and one add, + * no loop, no branch on guest state. */ + +ZX_NODISCARD uint64_t zx_schedule_deadline(const ZX_SCHEDULE *schedule_ptr); + +/* Which partition owns the core right now, as an index into the manifest. + ZX_MANIFEST_NO_INDEX when the schedule is not running or was never + built -- an answer a caller must check, because the alternative is + entering partition zero by accident. */ + +ZX_NODISCARD UINT zx_schedule_current_partition( + const ZX_SCHEDULE *schedule_ptr); + +/* Move to the next window. Called from the boundary handler and from + nowhere else. + * + * BRANCH-POOR AND LOOP-FREE ON PURPOSE. This runs inside the partition + * switch, so its cost is part of the number a safety customer asks for: + * three adds, one comparison to wrap the window index, and one more to + * decide whether the frame limit has been reached. Nothing here depends on + * anything a guest did. + * + * Returns the partition index now current, or ZX_MANIFEST_NO_INDEX when the + * schedule has just finished -- which is the signal to leave EL1 alone and + * return to the hypervisor rather than entering anything. */ + +ZX_NODISCARD UINT zx_schedule_advance(ZX_SCHEDULE *schedule_ptr); + +/* Record that the boundary just computed had already gone by. Separate + from advance because it is the CALLER that knows what the counter reads: + this file does not read hardware, and a deadline check that took its own + counter reading would be measuring the cost of measuring. See the note + in docs/armv8r-el2-reference.md on what a CNTPCT read costs. */ + +void zx_schedule_note_missed(ZX_SCHEDULE *schedule_ptr); + +ZX_NODISCARD UINT zx_schedule_is_running(const ZX_SCHEDULE *schedule_ptr); + +/**************************************************************************/ +/* Reporting */ +/**************************************************************************/ + +/* Print the frame once, at boot: which partition owns which slot, how long + each is in ticks and in counter counts, and what the whole frame costs. + * + * Worth its space for the same reason zx_mm_report is. Every later claim + * in the run -- "the guest saw half as many ticks as it would standalone" + * -- is a claim about a ratio, and a reader cannot check a ratio against a + * schedule they cannot see. */ + +void zx_schedule_report(const ZX_SCHEDULE *schedule_ptr, + const ZX_MANIFEST *manifest_ptr); + #ifdef __cplusplus } #endif diff --git a/core/src/zx_schedule.c b/core/src/zx_schedule.c index 1ce76ec..e389094 100644 --- a/core/src/zx_schedule.c +++ b/core/src/zx_schedule.c @@ -21,14 +21,493 @@ /* */ /* DESCRIPTION */ /* */ -/* The time-partition schedule: the fixed cyclic window table and the */ -/* hypervisor-timer tick that advances it. */ +/* The time-partition schedule: the fixed cyclic window table, and */ +/* where on the physical counter each window boundary falls. */ /* */ -/* This translation unit is deliberately empty of implementation. */ -/* The change that founded this repository builds the repository, not */ -/* the hypervisor; the unit exists so that the change which writes the */ -/* code opens a tree that already configures, compiles and links. */ +/* Arithmetic only. Nothing here reads a counter, writes a comparator */ +/* or switches a region set; see zx_schedule.h for why the split is */ +/* there and what it buys. */ +/* */ +/* THE WORST-CASE-EXECUTION-TIME ARGUMENT, WHERE THE CODE IS */ +/* */ +/* zx_schedule_advance and zx_schedule_deadline run inside the */ +/* partition switch, so their cost is part of the number this step */ +/* exists to measure. Both are straight-line: a fixed number of */ +/* 64-bit adds, one 64-bit multiply, and two comparisons whose */ +/* operands are the schedule's own fields. */ +/* */ +/* NOTHING IN EITHER OF THEM DEPENDS ON ANYTHING A GUEST DID. There */ +/* is no loop, no search, no allocation, and no data-dependent branch: */ +/* the only branches are the window index wrapping and the frame limit */ +/* being reached, and both are decided by the schedule's own counters. */ +/* That is what makes "how long does a partition switch take" a */ +/* question with one answer rather than a distribution. */ +/* */ +/* The one multiply is deliberate and is the reason the boundary is */ +/* computed rather than accumulated. Adding a window's worth of */ +/* counts to a running total each time would be one instruction */ +/* cheaper and would make the frame's length the sum of everything */ +/* that had happened to it, which is exactly the drift an absolute */ +/* deadline exists to prevent. */ /* */ /**************************************************************************/ #include "zx_schedule.h" +#include "zx_console.h" + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_schedule_build PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Turns a manifest into a round-robin major frame. */ +/* */ +/* ONE WINDOW PER PARTITION, IN MANIFEST ORDER, and the order is worth */ +/* stating because it is the only ordering rule Phase 0 has: a */ +/* schedule whose slots came out in a different order from the */ +/* manifest would be correct and unreadable, and every report in this */ +/* suite names partitions by their manifest index. */ +/* */ +/* THE FRAME IS CHECKED AGAINST ITS PARTS. A manifest carries the */ +/* major frame explicitly rather than computing it, exactly so that it */ +/* can be WRONG -- a computed field could never disagree with anything */ +/* and would check nothing. A frame that does not equal the sum of */ +/* its windows means somebody changed a window and not the total, and */ +/* the consequence is a partition whose slot silently moves. */ +/* */ +/**************************************************************************/ + +UINT zx_schedule_build(const ZX_MANIFEST *manifest_ptr, + uint64_t counts_per_tick, + ULONG frame_limit, + ZX_SCHEDULE *schedule_ptr) +{ + UINT index; + ULONG total = 0UL; + + if ((manifest_ptr == (const ZX_MANIFEST *)0) + || (schedule_ptr == (ZX_SCHEDULE *)0)) + { + return ZX_MANIFEST_NULL_POINTER; + } + + if (manifest_ptr->zx_manifest_partitions == (const ZX_PARTITION *)0) + { + return ZX_MANIFEST_NULL_POINTER; + } + + /* Cleared in full before anything is assigned, for the reason the + region planner is: a schedule is read by a switch path that indexes + it, and a half-written one whose unused slots hold whatever was on + the stack would enter an arbitrary partition. */ + + for (index = 0U; index < ZX_MAX_PARTITIONS; index++) + { + schedule_ptr->zx_schedule_windows[index].zx_window_partition = + ZX_MANIFEST_NO_INDEX; + schedule_ptr->zx_schedule_windows[index].zx_window_ticks = 0UL; + } + + schedule_ptr->zx_schedule_window_count = 0U; + schedule_ptr->zx_schedule_frame_ticks = 0UL; + schedule_ptr->zx_schedule_counts_per_tick = 0U; + schedule_ptr->zx_schedule_epoch = 0U; + schedule_ptr->zx_schedule_ticks_spent = 0U; + schedule_ptr->zx_schedule_current = 0U; + schedule_ptr->zx_schedule_frames = 0UL; + schedule_ptr->zx_schedule_frame_limit = 0UL; + schedule_ptr->zx_schedule_missed = 0UL; + schedule_ptr->zx_schedule_running = 0U; + + if (manifest_ptr->zx_manifest_partition_count == 0U) + { + return ZX_MANIFEST_NO_PARTITIONS; + } + + if (manifest_ptr->zx_manifest_partition_count > ZX_MAX_PARTITIONS) + { + return ZX_MANIFEST_TOO_MANY_PARTITIONS; + } + + /* A tick that costs no counter counts would put every boundary at the + epoch, so the hypervisor timer would fire continuously and the run + would present as a hang INSIDE the hypervisor -- no output, no fault, + and nothing to point at. Refused here, where it can be named. */ + + if (counts_per_tick == 0U) + { + return ZX_MANIFEST_ZERO_WINDOW; + } + + for (index = 0U; index < manifest_ptr->zx_manifest_partition_count; + index++) + { + ULONG ticks = + manifest_ptr->zx_manifest_partitions[index] + .zx_partition_window_ticks; + + /* A window of zero ticks is a partition that is declared and never + runs. That is either a mistake or a partition that should not be + in the manifest, and both are worth refusing rather than + producing a frame with an invisible member. */ + + if (ticks == 0UL) + { + return ZX_MANIFEST_ZERO_WINDOW; + } + + schedule_ptr->zx_schedule_windows[index].zx_window_partition = index; + schedule_ptr->zx_schedule_windows[index].zx_window_ticks = ticks; + + total += ticks; + } + + if (total != manifest_ptr->zx_manifest_major_frame_ticks) + { + return ZX_MANIFEST_FRAME_MISMATCH; + } + + schedule_ptr->zx_schedule_window_count = + manifest_ptr->zx_manifest_partition_count; + schedule_ptr->zx_schedule_frame_ticks = total; + schedule_ptr->zx_schedule_counts_per_tick = counts_per_tick; + schedule_ptr->zx_schedule_frame_limit = frame_limit; + + return ZX_MANIFEST_SUCCESS; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_schedule_start PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Fixes the epoch every later boundary is measured from. */ +/* */ +/* Called ONCE, with a counter reading the caller has just taken, and */ +/* immediately before the first partition is entered. Taking it */ +/* earlier would charge the first window for whatever the hypervisor */ +/* did in between -- which on silicon is a great deal of polled UART */ +/* -- and the first window would then be the only short one in the */ +/* run, which is exactly the kind of asymmetry a determinism claim */ +/* cannot afford to have and not explain. */ +/* */ +/**************************************************************************/ + +void zx_schedule_start(ZX_SCHEDULE *schedule_ptr, uint64_t now) +{ + if (schedule_ptr == (ZX_SCHEDULE *)0) + { + return; + } + + if (schedule_ptr->zx_schedule_window_count == 0U) + { + return; + } + + schedule_ptr->zx_schedule_epoch = now; + schedule_ptr->zx_schedule_ticks_spent = 0U; + schedule_ptr->zx_schedule_current = 0U; + schedule_ptr->zx_schedule_frames = 0UL; + schedule_ptr->zx_schedule_missed = 0UL; + schedule_ptr->zx_schedule_running = 1U; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_schedule_deadline PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* The absolute physical count at which the current window ends. */ +/* */ +/* epoch + (ticks already spent + this window's ticks) x counts. */ +/* */ +/* THE MULTIPLICATION IS THE POINT. Every boundary in the run is one */ +/* exact multiple of the tick away from one epoch, so the errors do */ +/* not compose: a switch that took ten thousand cycles moves nothing, */ +/* because the next boundary was already decided before it started. */ +/* The alternative -- add a window's counts to "now" inside the */ +/* handler -- is one instruction cheaper and makes the frame's length */ +/* the sum of every latency it has ever suffered. */ +/* */ +/* Zero when the schedule is not running, which is a deadline in the */ +/* past and therefore the conservative answer: a caller that armed a */ +/* timer with it would be interrupted at once rather than never. */ +/* */ +/**************************************************************************/ + +uint64_t zx_schedule_deadline(const ZX_SCHEDULE *schedule_ptr) +{ + uint64_t ticks; + + if (schedule_ptr == (const ZX_SCHEDULE *)0) + { + return 0U; + } + + if (schedule_ptr->zx_schedule_running == 0U) + { + return 0U; + } + + ticks = schedule_ptr->zx_schedule_ticks_spent + + (uint64_t)schedule_ptr->zx_schedule_windows + [schedule_ptr->zx_schedule_current].zx_window_ticks; + + return schedule_ptr->zx_schedule_epoch + + (ticks * schedule_ptr->zx_schedule_counts_per_tick); +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_schedule_current_partition PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Which partition owns the core, as a manifest index. */ +/* */ +/* ZX_MANIFEST_NO_INDEX rather than zero when there is no answer. */ +/* Zero is a valid partition, so a caller that received it for "the */ +/* schedule is not running" would enter partition zero -- which is a */ +/* real partition, in the middle of nothing, with its window already */ +/* spent. The distinction is the same one the manifest fault record */ +/* makes and for the same reason. */ +/* */ +/**************************************************************************/ + +UINT zx_schedule_current_partition(const ZX_SCHEDULE *schedule_ptr) +{ + if (schedule_ptr == (const ZX_SCHEDULE *)0) + { + return ZX_MANIFEST_NO_INDEX; + } + + if (schedule_ptr->zx_schedule_running == 0U) + { + return ZX_MANIFEST_NO_INDEX; + } + + return schedule_ptr->zx_schedule_windows + [schedule_ptr->zx_schedule_current].zx_window_partition; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_schedule_advance PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Move to the next window. Called from the boundary handler only. */ +/* */ +/* THE TICKS ARE SPENT BEFORE THE INDEX MOVES, and the order is not */ +/* cosmetic: the deadline for the NEXT window is */ +/* epoch + (spent + next window's ticks) x counts, so a spend that */ +/* happened after the move would charge the outgoing window's time to */ +/* the incoming one and every boundary after it would be early by one */ +/* window. The frame would still be the right length, every window */ +/* would be the wrong one, and a two-partition demonstration with */ +/* equal windows would look perfect. */ +/* */ +/* THE FRAME LIMIT IS CHECKED AFTER THE WRAP, so a limit of one frame */ +/* means every window of frame zero runs and nothing of frame one */ +/* does. A bounded run is what lets a regression assert on a total, */ +/* and "the harness timed out" is not a result. */ +/* */ +/**************************************************************************/ + +UINT zx_schedule_advance(ZX_SCHEDULE *schedule_ptr) +{ + if (schedule_ptr == (ZX_SCHEDULE *)0) + { + return ZX_MANIFEST_NO_INDEX; + } + + if (schedule_ptr->zx_schedule_running == 0U) + { + return ZX_MANIFEST_NO_INDEX; + } + + schedule_ptr->zx_schedule_ticks_spent += + (uint64_t)schedule_ptr->zx_schedule_windows + [schedule_ptr->zx_schedule_current].zx_window_ticks; + + schedule_ptr->zx_schedule_current++; + + if (schedule_ptr->zx_schedule_current + >= schedule_ptr->zx_schedule_window_count) + { + schedule_ptr->zx_schedule_current = 0U; + schedule_ptr->zx_schedule_frames++; + + if ((schedule_ptr->zx_schedule_frame_limit != 0UL) + && (schedule_ptr->zx_schedule_frames + >= schedule_ptr->zx_schedule_frame_limit)) + { + schedule_ptr->zx_schedule_running = 0U; + + return ZX_MANIFEST_NO_INDEX; + } + } + + return schedule_ptr->zx_schedule_windows + [schedule_ptr->zx_schedule_current].zx_window_partition; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_schedule_note_missed PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Records a boundary that had already gone by when it was computed. */ +/* */ +/* WHAT A MISS MEANS. The window was too short to contain its own */ +/* partition switch, so the comparator was armed with a count already */ +/* in the past and the interrupt is pending again the instant the */ +/* hypervisor allows it. The schedule does not break: it keeps */ +/* turning, in the right order, with the right proportions, and every */ +/* frame takes longer than it was declared to. */ +/* */ +/* THAT IS WHY IT IS COUNTED. A missed deadline is the only failure */ +/* this arithmetic can have that produces no wrong answer anywhere -- */ +/* a run full of them looks exactly like a working demonstrator, only */ +/* slower, and nobody times a demonstrator. A number a test asserts */ +/* is zero is what makes it visible. */ +/* */ +/* The CALLER decides that a miss happened, because deciding needs a */ +/* counter reading and this file has no hardware. That division also */ +/* keeps the reading honest: on the S32Z280 a CNTPCT read costs about */ +/* 93 counts of real time, so a check that took its own extra reading */ +/* would be partly measuring itself. */ +/* */ +/**************************************************************************/ + +void zx_schedule_note_missed(ZX_SCHEDULE *schedule_ptr) +{ + if (schedule_ptr == (ZX_SCHEDULE *)0) + { + return; + } + + schedule_ptr->zx_schedule_missed++; +} + + +UINT zx_schedule_is_running(const ZX_SCHEDULE *schedule_ptr) +{ + if (schedule_ptr == (const ZX_SCHEDULE *)0) + { + return 0U; + } + + return schedule_ptr->zx_schedule_running; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_schedule_report PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Prints the major frame once, at boot. */ +/* */ +/* Every temporal claim this suite makes is a claim about a RATIO -- */ +/* "a partition given half the frame sees half the ticks" -- and a */ +/* reader cannot check a ratio against a schedule they cannot see. */ +/* So each window is printed in ticks AND in counter counts, because */ +/* the two targets' counters differ by a factor of twelve and a count */ +/* quoted without its board means nothing. */ +/* */ +/* The counts are printed as a 32-bit value. A window long enough to */ +/* overflow that at either target's frequency would be nine minutes, */ +/* which is not a window, and the alternative is a 64-bit decimal */ +/* printer this console does not have and would not otherwise need. */ +/* */ +/**************************************************************************/ + +void zx_schedule_report(const ZX_SCHEDULE *schedule_ptr, + const ZX_MANIFEST *manifest_ptr) +{ + UINT index; + + if ((schedule_ptr == (const ZX_SCHEDULE *)0) + || (manifest_ptr == (const ZX_MANIFEST *)0)) + { + return; + } + + zx_console_puts(" major frame: "); + zx_console_putdec(schedule_ptr->zx_schedule_frame_ticks); + zx_console_puts(" ticks, "); + zx_console_putdec(schedule_ptr->zx_schedule_window_count); + zx_console_puts(" windows, tick = "); + zx_console_putdec((uint32_t)schedule_ptr->zx_schedule_counts_per_tick); + zx_console_puts(" counter counts\n"); + + for (index = 0U; index < schedule_ptr->zx_schedule_window_count; index++) + { + UINT partition = + schedule_ptr->zx_schedule_windows[index].zx_window_partition; + + zx_console_puts(" window "); + zx_console_putdec(index); + zx_console_puts(": "); + + if ((partition < manifest_ptr->zx_manifest_partition_count) + && (manifest_ptr->zx_manifest_partitions[partition] + .zx_partition_name != (const CHAR *)0)) + { + zx_console_puts(manifest_ptr->zx_manifest_partitions[partition] + .zx_partition_name); + } + else + { + zx_console_puts("(unnamed)"); + } + + zx_console_puts(", "); + zx_console_putdec( + schedule_ptr->zx_schedule_windows[index].zx_window_ticks); + zx_console_puts(" ticks = "); + zx_console_putdec((uint32_t)( + (uint64_t)schedule_ptr->zx_schedule_windows[index].zx_window_ticks + * schedule_ptr->zx_schedule_counts_per_tick)); + zx_console_puts(" counts\n"); + } + + if (schedule_ptr->zx_schedule_frame_limit == 0UL) + { + zx_console_puts(" the frame WRAPS for ever; nothing stops it\n"); + } + else + { + zx_console_puts(" stopping after "); + zx_console_putdec(schedule_ptr->zx_schedule_frame_limit); + zx_console_puts(" frames, so a test can assert on a total rather\n" + " than on a run the harness had to kill\n"); + } +} diff --git a/test/host/CMakeLists.txt b/test/host/CMakeLists.txt index d454f3f..962fc93 100644 --- a/test/host/CMakeLists.txt +++ b/test/host/CMakeLists.txt @@ -55,3 +55,4 @@ zx_add_host_test(test_zx_guest_console) zx_add_host_test(test_zx_manifest) zx_add_host_test(test_zx_mm) zx_add_host_test(test_zx_partition) +zx_add_host_test(test_zx_schedule) diff --git a/test/host/run.sh b/test/host/run.sh index b550afc..3fc84ee 100755 --- a/test/host/run.sh +++ b/test/host/run.sh @@ -104,14 +104,27 @@ case "${command}" in # # So the floor covers the files where a real number is defensible: # the manifest validator, the region-layout planner, the partition - # loader and the guest console. All four are pure functions with no - # hardware in them, all four are reachable in full from a - # workstation, and none has an excuse for an unreached line. 100% - # is not aspirational -- it is what they measure, on lines and on - # branches both. A rule added without a case that fails it drops - # this and fails the build, which is the entire point: a validator - # rule nothing has ever seen reject anything is not a rule, it is a - # comment. + # loader, the guest console and the time-partition schedule. All + # five are pure functions with no hardware in them, all five are + # reachable in full from a workstation, and none has an excuse for an + # unreached line. 100% is not aspirational -- it is what they + # measure, on lines and on branches both. A rule added without a + # case that fails it drops this and fails the build, which is the + # entire point: a validator rule nothing has ever seen reject + # anything is not a rule, it is a comment. + # + # THE SCHEDULE JOINED THIS LIST DELIBERATELY, and the decision was + # worth making rather than defaulting either way. What it computes + # is where each window boundary falls, and its one interesting + # failure -- DRIFT -- is invisible in any run short enough to read: + # a frame that accumulated the handler's own latency would keep + # every partition in order and in proportion and slowly stop being + # the length it was declared to be. Catching that needs ten + # thousand frames, which is a millisecond here and two minutes on + # the S32Z280. It is also the file whose arithmetic is 64-bit + # against a counter that has been running since reset, and an epoch + # near the 32-bit boundary is a fixture on a workstation and nine + # minutes of waiting on a bench. # # The guest console is on this list for a reason worth stating: its # whole output is TEXT that somebody reads at three in the morning @@ -135,6 +148,7 @@ case "${command}" in --filter "${ROOT}/core/src/zx_manifest_verify.c" \ --filter "${ROOT}/core/src/zx_mm_setup.c" \ --filter "${ROOT}/core/src/zx_partition_manager.c" \ + --filter "${ROOT}/core/src/zx_schedule.c" \ --txt - \ --fail-under-line 100 \ --fail-under-branch 100 diff --git a/test/host/test_zx_schedule.c b/test/host/test_zx_schedule.c new file mode 100644 index 0000000..5cfd210 --- /dev/null +++ b/test/host/test_zx_schedule.c @@ -0,0 +1,533 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* test_zx_schedule.c HOST TESTS */ +/* */ +/* DESCRIPTION */ +/* */ +/* The static major frame: which partition owns which slot, and where */ +/* on the physical counter each boundary falls. */ +/* */ +/* WHY THIS BELONGS ON A WORKSTATION AND NOT ON A BOARD */ +/* */ +/* The property the whole component exists to guarantee is that a */ +/* frame does not DRIFT, and drift is invisible in any run short */ +/* enough for a person to read. A schedule that added the handler's */ +/* own latency to every window would look perfect for a second, be */ +/* a millisecond out after a minute, and be wrong by a whole window */ +/* after an hour -- with every partition still taking its turn, in */ +/* order, in the right proportion. */ +/* */ +/* So the central case here runs TEN THOUSAND FRAMES and asserts that */ +/* the last boundary is exactly the epoch plus the total tick count */ +/* times the tick length. That is a millisecond on a workstation and */ +/* it is the assertion no amount of watching a board would make. */ +/* */ +/* The second reason is arithmetic width. The physical counter is */ +/* 64-bit and a boundary is a product, so a schedule that computed it */ +/* in 32 bits would be correct for the first nine minutes on the */ +/* S32Z280 and then wrap. A test can start the epoch just below the */ +/* 32-bit boundary and see that immediately. */ +/* */ +/**************************************************************************/ + +#include "zx_schedule.h" +#include "zx_console_capture.h" +#include "zx_test.h" + +/* The board fact the schedule needs, as both targets report it. The + S32Z280's counter runs at 8 MHz and the model's at 100 MHz, so a tick of + 10 ms -- ThreadX's own default rate -- is 80,000 counts on one and + 1,000,000 on the other. Both appear below, because a schedule that + happened to work at one frequency and not the other would be a defect + visible on exactly one of the two boards. */ +#define S32Z_TICK_COUNTS 80000U +#define FVP_TICK_COUNTS 1000000U + +static ZX_PARTITION partitions[ZX_MAX_PARTITIONS]; +static ZX_MANIFEST manifest; +static ZX_SCHEDULE schedule; + +/* Only the fields the builder reads: an identity, a name for the report, and + a window. A fixture that filled in regions and images would suggest the + schedule looked at them. */ +static void reset_manifest(UINT partition_count, ULONG ticks_each) +{ + UINT index; + ULONG total = 0UL; + + for (index = 0U; index < ZX_MAX_PARTITIONS; index++) + { + partitions[index].zx_partition_id = index + 1U; + partitions[index].zx_partition_name = "p"; + partitions[index].zx_partition_window_ticks = ticks_each; + + if (index < partition_count) + { + total += ticks_each; + } + } + + manifest.zx_manifest_partitions = partitions; + manifest.zx_manifest_partition_count = partition_count; + manifest.zx_manifest_shared = (const ZX_SHARED *)0; + manifest.zx_manifest_shared_count = 0U; + manifest.zx_manifest_major_frame_ticks = total; +} + + +static void test_build(void) +{ + reset_manifest(2U, 5UL); + ZX_CHECK_EQ(zx_schedule_build(&manifest, S32Z_TICK_COUNTS, 0UL, + &schedule), + ZX_MANIFEST_SUCCESS); + + ZX_CHECK_EQ(schedule.zx_schedule_window_count, 2U); + ZX_CHECK_EQ(schedule.zx_schedule_frame_ticks, 10UL); + ZX_CHECK_EQ(schedule.zx_schedule_windows[0].zx_window_partition, 0U); + ZX_CHECK_EQ(schedule.zx_schedule_windows[1].zx_window_partition, 1U); + ZX_CHECK_EQ(schedule.zx_schedule_windows[0].zx_window_ticks, 5UL); + + /* A built schedule is NOT a started one. The epoch is a counter + reading the caller has yet to take, and a schedule that reported + itself running before it had one would put every boundary at zero -- + which is a deadline in the past, and therefore a hypervisor timer + firing continuously. */ + ZX_CHECK_EQ(zx_schedule_is_running(&schedule), 0U); + ZX_CHECK_EQ(zx_schedule_current_partition(&schedule), + ZX_MANIFEST_NO_INDEX); + ZX_CHECK_EQ(zx_schedule_deadline(&schedule), 0U); + + /* Slots of DIFFERENT lengths, which is the asymmetry the whole + demonstration rests on: a partition given a quarter of the frame must + come out with a quarter of the frame, not with a quarter of the + windows. */ + reset_manifest(2U, 0UL); + partitions[0].zx_partition_window_ticks = 7UL; + partitions[1].zx_partition_window_ticks = 3UL; + manifest.zx_manifest_major_frame_ticks = 10UL; + ZX_CHECK_EQ(zx_schedule_build(&manifest, S32Z_TICK_COUNTS, 0UL, + &schedule), + ZX_MANIFEST_SUCCESS); + ZX_CHECK_EQ(schedule.zx_schedule_windows[0].zx_window_ticks, 7UL); + ZX_CHECK_EQ(schedule.zx_schedule_windows[1].zx_window_ticks, 3UL); +} + + +static void test_build_rejections(void) +{ + reset_manifest(2U, 5UL); + ZX_CHECK_EQ(zx_schedule_build((const ZX_MANIFEST *)0, S32Z_TICK_COUNTS, + 0UL, &schedule), + ZX_MANIFEST_NULL_POINTER); + + reset_manifest(2U, 5UL); + ZX_CHECK_EQ(zx_schedule_build(&manifest, S32Z_TICK_COUNTS, 0UL, + (ZX_SCHEDULE *)0), + ZX_MANIFEST_NULL_POINTER); + + reset_manifest(2U, 5UL); + manifest.zx_manifest_partitions = (const ZX_PARTITION *)0; + ZX_CHECK_EQ(zx_schedule_build(&manifest, S32Z_TICK_COUNTS, 0UL, + &schedule), + ZX_MANIFEST_NULL_POINTER); + + reset_manifest(0U, 5UL); + ZX_CHECK_EQ(zx_schedule_build(&manifest, S32Z_TICK_COUNTS, 0UL, + &schedule), + ZX_MANIFEST_NO_PARTITIONS); + + reset_manifest(2U, 5UL); + manifest.zx_manifest_partition_count = ZX_MAX_PARTITIONS + 1U; + ZX_CHECK_EQ(zx_schedule_build(&manifest, S32Z_TICK_COUNTS, 0UL, + &schedule), + ZX_MANIFEST_TOO_MANY_PARTITIONS); + + /* A TICK OF NO LENGTH. Every boundary would then be the epoch, so the + hypervisor timer would be permanently expired and the run would hang + inside EL2 with no output and no fault to point at. Refused where it + can be named. */ + reset_manifest(2U, 5UL); + ZX_CHECK_EQ(zx_schedule_build(&manifest, 0U, 0UL, &schedule), + ZX_MANIFEST_ZERO_WINDOW); + + /* A partition declared with no window is a partition that never runs. + The manifest validator has the same rule; it is repeated here because + the schedule is built from the manifest and a frame with an invisible + member is worse than a refusal. */ + reset_manifest(2U, 5UL); + partitions[1].zx_partition_window_ticks = 0UL; + manifest.zx_manifest_major_frame_ticks = 5UL; + ZX_CHECK_EQ(zx_schedule_build(&manifest, S32Z_TICK_COUNTS, 0UL, + &schedule), + ZX_MANIFEST_ZERO_WINDOW); + + /* THE FRAME MUST EQUAL THE SUM OF ITS PARTS. The manifest carries the + total explicitly precisely so that it can disagree -- a computed + field could never be wrong and would check nothing -- and a + disagreement means somebody changed a window and not the total. */ + reset_manifest(2U, 5UL); + manifest.zx_manifest_major_frame_ticks = 11UL; + ZX_CHECK_EQ(zx_schedule_build(&manifest, S32Z_TICK_COUNTS, 0UL, + &schedule), + ZX_MANIFEST_FRAME_MISMATCH); + + /* A REFUSED BUILD LEAVES NOTHING RUNNABLE. Every rejection above + returns before the window count is set, so a caller that ignored the + status would find a schedule with no windows rather than one holding + whatever the last successful build left -- which is the failure that + would enter the wrong partition. */ + ZX_CHECK_EQ(schedule.zx_schedule_window_count, 0U); + ZX_CHECK_EQ(zx_schedule_is_running(&schedule), 0U); +} + + +static void test_round_robin(void) +{ + UINT index; + + reset_manifest(2U, 0UL); + partitions[0].zx_partition_window_ticks = 7UL; + partitions[1].zx_partition_window_ticks = 3UL; + manifest.zx_manifest_major_frame_ticks = 10UL; + + ZX_CHECK_EQ(zx_schedule_build(&manifest, S32Z_TICK_COUNTS, 0UL, + &schedule), + ZX_MANIFEST_SUCCESS); + + zx_schedule_start(&schedule, 1000U); + + ZX_CHECK_EQ(zx_schedule_is_running(&schedule), 1U); + ZX_CHECK_EQ(zx_schedule_current_partition(&schedule), 0U); + + /* The first boundary is seven ticks past the epoch, not seven ticks past + "now": the epoch IS now, and the distinction only becomes visible at + the second boundary. */ + ZX_CHECK_EQ(zx_schedule_deadline(&schedule), + 1000U + (7U * (uint64_t)S32Z_TICK_COUNTS)); + + ZX_CHECK_EQ(zx_schedule_advance(&schedule), 1U); + ZX_CHECK_EQ(zx_schedule_deadline(&schedule), + 1000U + (10U * (uint64_t)S32Z_TICK_COUNTS)); + ZX_CHECK_EQ(schedule.zx_schedule_frames, 0UL); + + /* The wrap. Back to partition 0, and one frame complete. */ + ZX_CHECK_EQ(zx_schedule_advance(&schedule), 0U); + ZX_CHECK_EQ(schedule.zx_schedule_frames, 1UL); + ZX_CHECK_EQ(zx_schedule_deadline(&schedule), + 1000U + (17U * (uint64_t)S32Z_TICK_COUNTS)); + + /* Four partitions of one tick each, to see the index wrap somewhere + other than at two. A modulus written as a comparison is right for + two windows whichever way it is written. */ + reset_manifest(4U, 1UL); + ZX_CHECK_EQ(zx_schedule_build(&manifest, FVP_TICK_COUNTS, 0UL, + &schedule), + ZX_MANIFEST_SUCCESS); + zx_schedule_start(&schedule, 0U); + + for (index = 0U; index < 4U; index++) + { + ZX_CHECK_EQ(zx_schedule_current_partition(&schedule), index); + ZX_CHECK_EQ(zx_schedule_deadline(&schedule), + (uint64_t)(index + 1U) * FVP_TICK_COUNTS); + (void)zx_schedule_advance(&schedule); + } + + ZX_CHECK_EQ(zx_schedule_current_partition(&schedule), 0U); + ZX_CHECK_EQ(schedule.zx_schedule_frames, 1UL); +} + + +static void test_no_drift_over_many_frames(void) +{ + /* THE CENTRAL CASE, AND THE ONE NO BOARD RUN WOULD MAKE. + * + * Ten thousand frames of a two-window schedule. The last boundary must + * be EXACTLY the epoch plus the total tick count times the tick length + * -- not approximately, and not within a window. + * + * A schedule that re-armed a relative countdown at each boundary would + * pass every other test in this file and fail this one, because its + * error is per-boundary and only shows up multiplied. On the S32Z280 + * ten thousand frames of this schedule is two minutes of wall clock; a + * partition switch of a few thousand cycles added to each of twenty + * thousand boundaries would put the last one nearly a second late, and + * nothing about the run would look wrong. + * + * The epoch is deliberately NOT zero. An arithmetic slip that dropped + * the epoch entirely is invisible when the epoch is zero, and that is + * exactly the sort of test that passes on a fixture and fails on a + * board where the counter has been running since reset. */ + + ULONG frame; + uint64_t expected; + + reset_manifest(2U, 0UL); + partitions[0].zx_partition_window_ticks = 7UL; + partitions[1].zx_partition_window_ticks = 3UL; + manifest.zx_manifest_major_frame_ticks = 10UL; + + ZX_CHECK_EQ(zx_schedule_build(&manifest, S32Z_TICK_COUNTS, 0UL, + &schedule), + ZX_MANIFEST_SUCCESS); + + zx_schedule_start(&schedule, 0x12345678U); + + for (frame = 0UL; frame < 10000UL; frame++) + { + (void)zx_schedule_advance(&schedule); + (void)zx_schedule_advance(&schedule); + } + + ZX_CHECK_EQ(schedule.zx_schedule_frames, 10000UL); + ZX_CHECK_EQ(schedule.zx_schedule_ticks_spent, 100000U); + + expected = 0x12345678U + + (((uint64_t)100000U + 7U) * (uint64_t)S32Z_TICK_COUNTS); + + ZX_CHECK_EQ(zx_schedule_deadline(&schedule), expected); + + /* And nothing about the run reported a missed deadline, because + advancing a schedule cannot miss one -- only a caller comparing a + boundary against a real counter can. */ + ZX_CHECK_EQ(schedule.zx_schedule_missed, 0UL); +} + + +static void test_boundaries_are_64_bit(void) +{ + /* AN EPOCH JUST BELOW THE 32-BIT BOUNDARY. + * + * The physical counter is 64-bit and it has been running since the board + * was reset, so a hypervisor started ten minutes in is already past + * 0xFFFFFFFF at the S32Z280's 8 MHz. A boundary computed in 32 bits + * would wrap to a small number, land in the past, and turn the frame + * into a continuous storm of expired timer interrupts -- which presents + * as a hang inside EL2 rather than as an arithmetic bug. + * + * Nine minutes of uptime is not an exotic condition on a bench. This is + * the case that costs nothing here and a debugging session there. */ + + uint64_t epoch = 0xFFFFFF00U; + + reset_manifest(2U, 5UL); + ZX_CHECK_EQ(zx_schedule_build(&manifest, S32Z_TICK_COUNTS, 0UL, + &schedule), + ZX_MANIFEST_SUCCESS); + + zx_schedule_start(&schedule, epoch); + + ZX_CHECK_EQ(zx_schedule_deadline(&schedule), + epoch + (5U * (uint64_t)S32Z_TICK_COUNTS)); + ZX_CHECK(zx_schedule_deadline(&schedule) > 0xFFFFFFFFU); + + /* And the tick product itself must not be truncated: a frame of 100000 + ticks at 80000 counts is 8x10^9, past 32 bits on its own. */ + reset_manifest(2U, 50000UL); + ZX_CHECK_EQ(zx_schedule_build(&manifest, S32Z_TICK_COUNTS, 0UL, + &schedule), + ZX_MANIFEST_SUCCESS); + zx_schedule_start(&schedule, 0U); + ZX_CHECK_EQ(zx_schedule_deadline(&schedule), + (uint64_t)50000U * (uint64_t)S32Z_TICK_COUNTS); +} + + +static void test_frame_limit(void) +{ + /* A BOUNDED RUN, which is what a ctest can assert on. + * + * The limit is checked after the wrap, so a limit of two frames means + * every window of frames 0 and 1 runs and nothing of frame 2 does. The + * advance that completes the last frame returns NO_INDEX, which is the + * signal to stop entering partitions rather than a partition index a + * caller might enter by mistake. */ + + reset_manifest(2U, 5UL); + ZX_CHECK_EQ(zx_schedule_build(&manifest, S32Z_TICK_COUNTS, 2UL, + &schedule), + ZX_MANIFEST_SUCCESS); + + zx_schedule_start(&schedule, 0U); + + ZX_CHECK_EQ(zx_schedule_advance(&schedule), 1U); /* frame 0, w1 */ + ZX_CHECK_EQ(zx_schedule_advance(&schedule), 0U); /* frame 1, w0 */ + ZX_CHECK_EQ(zx_schedule_advance(&schedule), 1U); /* frame 1, w1 */ + ZX_CHECK_EQ(zx_schedule_is_running(&schedule), 1U); + + ZX_CHECK_EQ(zx_schedule_advance(&schedule), ZX_MANIFEST_NO_INDEX); + ZX_CHECK_EQ(schedule.zx_schedule_frames, 2UL); + ZX_CHECK_EQ(zx_schedule_is_running(&schedule), 0U); + + /* A FINISHED SCHEDULE STAYS FINISHED, and says so in every way a caller + might ask. A stopped schedule that still returned a partition index + would be entered once more, after its own limit, which is the whole + thing a bounded run is for. */ + ZX_CHECK_EQ(zx_schedule_advance(&schedule), ZX_MANIFEST_NO_INDEX); + ZX_CHECK_EQ(zx_schedule_current_partition(&schedule), + ZX_MANIFEST_NO_INDEX); + ZX_CHECK_EQ(zx_schedule_deadline(&schedule), 0U); + + /* An unlimited schedule does not stop, however many frames pass. The + demonstration wants this and a regression must not have it. */ + reset_manifest(2U, 5UL); + ZX_CHECK_EQ(zx_schedule_build(&manifest, S32Z_TICK_COUNTS, 0UL, + &schedule), + ZX_MANIFEST_SUCCESS); + zx_schedule_start(&schedule, 0U); + + { + ULONG step; + + for (step = 0UL; step < 1000UL; step++) + { + (void)zx_schedule_advance(&schedule); + } + } + + ZX_CHECK_EQ(zx_schedule_is_running(&schedule), 1U); + ZX_CHECK_EQ(schedule.zx_schedule_frames, 500UL); +} + + +static void test_null_and_unstarted(void) +{ + /* Every entry point takes a pointer a caller could get wrong, and every + one of them is on the partition-switch path where a fault is a + hypervisor crash rather than a diagnosis. */ + + ZX_CHECK_EQ(zx_schedule_deadline((const ZX_SCHEDULE *)0), 0U); + ZX_CHECK_EQ(zx_schedule_current_partition((const ZX_SCHEDULE *)0), + ZX_MANIFEST_NO_INDEX); + ZX_CHECK_EQ(zx_schedule_advance((ZX_SCHEDULE *)0), ZX_MANIFEST_NO_INDEX); + ZX_CHECK_EQ(zx_schedule_is_running((const ZX_SCHEDULE *)0), 0U); + + zx_schedule_start((ZX_SCHEDULE *)0, 0U); + zx_schedule_note_missed((ZX_SCHEDULE *)0); + zx_schedule_report((const ZX_SCHEDULE *)0, &manifest); + zx_schedule_report(&schedule, (const ZX_MANIFEST *)0); + + /* STARTING A SCHEDULE THAT WAS NEVER BUILT MUST NOT MAKE IT RUN. A + build that was refused leaves no windows, and a start that ignored + that would produce a running schedule whose current window is a + zeroed structure -- so the first ERET would go to partition + NO_INDEX's entry point. */ + reset_manifest(2U, 5UL); + manifest.zx_manifest_major_frame_ticks = 99UL; + ZX_CHECK_EQ(zx_schedule_build(&manifest, S32Z_TICK_COUNTS, 0UL, + &schedule), + ZX_MANIFEST_FRAME_MISMATCH); + zx_schedule_start(&schedule, 1234U); + ZX_CHECK_EQ(zx_schedule_is_running(&schedule), 0U); +} + + +static void test_missed_deadlines(void) +{ + reset_manifest(2U, 5UL); + ZX_CHECK_EQ(zx_schedule_build(&manifest, S32Z_TICK_COUNTS, 0UL, + &schedule), + ZX_MANIFEST_SUCCESS); + zx_schedule_start(&schedule, 0U); + + ZX_CHECK_EQ(schedule.zx_schedule_missed, 0UL); + + zx_schedule_note_missed(&schedule); + zx_schedule_note_missed(&schedule); + + ZX_CHECK_EQ(schedule.zx_schedule_missed, 2UL); + + /* A miss does not stop the schedule, and that is deliberate. The frame + keeps its order and its proportions and simply takes longer than it + was declared to, which is a condition worth REPORTING rather than one + worth halting on: halting would turn a slow demonstrator into a dead + one, and the number is what a test asserts against. */ + ZX_CHECK_EQ(zx_schedule_is_running(&schedule), 1U); + ZX_CHECK_EQ(zx_schedule_advance(&schedule), 1U); +} + + +static void test_report(void) +{ + reset_manifest(2U, 0UL); + partitions[0].zx_partition_name = "critical"; + partitions[1].zx_partition_name = "untrusted"; + partitions[0].zx_partition_window_ticks = 7UL; + partitions[1].zx_partition_window_ticks = 3UL; + manifest.zx_manifest_major_frame_ticks = 10UL; + + ZX_CHECK_EQ(zx_schedule_build(&manifest, S32Z_TICK_COUNTS, 0UL, + &schedule), + ZX_MANIFEST_SUCCESS); + + zx_capture_reset(); + zx_schedule_report(&schedule, &manifest); + + /* The frame, both partitions by name, and each window in BOTH units. + A count without its board means nothing -- the two targets' counters + differ by a factor of twelve -- and a tick without its counts cannot + be checked against a measured run. */ + ZX_CHECK_EQ(zx_capture_contains("major frame"), 1U); + ZX_CHECK_EQ(zx_capture_contains("critical"), 1U); + ZX_CHECK_EQ(zx_capture_contains("untrusted"), 1U); + ZX_CHECK_EQ(zx_capture_contains("560000"), 1U); /* 7 x 80000 */ + ZX_CHECK_EQ(zx_capture_contains("240000"), 1U); /* 3 x 80000 */ + ZX_CHECK_EQ(zx_capture_contains("WRAPS for ever"), 1U); + + /* A bounded run says so, because the difference decides whether a + reader should expect the log to end. */ + ZX_CHECK_EQ(zx_schedule_build(&manifest, S32Z_TICK_COUNTS, 4UL, + &schedule), + ZX_MANIFEST_SUCCESS); + zx_capture_reset(); + zx_schedule_report(&schedule, &manifest); + ZX_CHECK_EQ(zx_capture_contains("stopping after"), 1U); + + /* An unnamed partition must not print a null pointer, and neither must + a window whose partition index is outside the manifest it is being + reported against -- which cannot happen from a successful build and + is exactly why it must not crash the report that would say so. */ + partitions[1].zx_partition_name = (const CHAR *)0; + ZX_CHECK_EQ(zx_schedule_build(&manifest, S32Z_TICK_COUNTS, 0UL, + &schedule), + ZX_MANIFEST_SUCCESS); + zx_capture_reset(); + zx_schedule_report(&schedule, &manifest); + ZX_CHECK_EQ(zx_capture_contains("(unnamed)"), 1U); + + schedule.zx_schedule_windows[1].zx_window_partition = ZX_MAX_PARTITIONS; + zx_capture_reset(); + zx_schedule_report(&schedule, &manifest); + ZX_CHECK_EQ(zx_capture_contains("(unnamed)"), 1U); +} + + +ZX_TEST_MAIN("test_zx_schedule", + test_build(); + test_build_rejections(); + test_round_robin(); + test_no_drift_over_many_frames(); + test_boundaries_are_64_bit(); + test_frame_limit(); + test_null_and_unstarted(); + test_missed_deadlines(); + test_report(); +) From 780022339f6a2959106eaf9bd20afbb67b110e61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 2 Sep 2026 21:41:38 -0400 Subject: [PATCH 18/40] Ended a partition's window with an interrupt it cannot mask The partition switch, and the routing that makes one possible. HCR.FMO SET, IMO AND AMO CLEAR. The hypervisor's own timer goes in GIC Group 0, which the GIC delivers as an FIQ, so it arrives at EL2 -- while every partition interrupt stays Group 1, stays an IRQ, and is still delivered straight to EL1 with no injection and no List Register. Routing is by exception TYPE and not by INTID, which is what makes this two register writes rather than an interrupt-virtualization layer. It is better than injection rather than merely cheaper: with FMO set, PSTATE.F is IGNORED at EL0 and EL1, so a partition cannot mask the interrupt that ends its own window. FMO IS BIT 3 AND AMO IS BIT 5, AND THIS PORT HAD THEM THE OTHER WAY ROUND from its first commit -- the mnemonic order a reader reaches for is the alphabetical one. It survived because IMO is bit 4 either way and because every use of the three until now was a single BIC of all three at once. The first image that SET one of them routed nothing: bit 5 is AMO, so physical FIQ never reached EL2 and no window ever ended, while the comparator expired, the GIC made the interrupt pending and every set-up check printed green. A check written as (HCR & ZX_HCR_FMO) could not have caught it; the positions are now asserted and the image checks the BIT. AND SETTING FMO MOVES THE GUEST'S ICC_PMR TO THE VIRTUAL INTERFACE. TRM 9.3.5: an EL1 access to any register common to both interrupt groups is redirected once FMO or IMO is set, and ICC_PMR is one of them. Every guest writes ICC_PMR = 0xFF at start-up; from that moment the write lands in ICV_PMR while the physical mask -- which resets to zero, masking everything -- is left closed. A partition that was receiving its timer end to end stops receiving anything, with no fault and no message. The hypervisor now owns the physical mask. The same redirection closes D24's last gap: a partition cannot reach the physical Group 0 enable either. THE SWITCH IS FIFTEEN WORDS OF ASSEMBLY AND THE REST IS C. Hyp mode banks only SP, LR and SPSR, so r0-r12, ELR_hyp and SPSR_hyp are all that cannot wait; a guest's banked registers, its EL1 system registers and its whole EL1 MPU are still in the machine and are reached from C. That keeps the twenty- region loop -- which measures at 85% of the switch on both targets -- eight readable lines instead of a hundred and twenty coprocessor moves. The FPU is DENIED to a time-partitioned system rather than saved, because nothing saves the register bank and two guests would share it; the failure mode is a wrong ANSWER and not a fault. The traps bracket the frame exactly, because with them set the S32Z280's debug probe cannot read the core's registers at all. Assisted-by: Claude Code (Opus 5) --- platform/cortex_r52/CMakeLists.txt | 3 + platform/cortex_r52/inc/zx_port.h | 581 ++++++++++++- platform/cortex_r52/src/zx_context.S | 287 +++++++ platform/cortex_r52/src/zx_context.c | 855 +++++++++++++++++++ platform/cortex_r52/src/zx_frame.c | 955 ++++++++++++++++++++++ platform/cortex_r52/src/zx_gic.c | 256 ++++++ platform/cortex_r52/src/zx_timer.c | 344 +++++++- platform/cortex_r52/src/zx_trap_handler.S | 48 +- 8 files changed, 3311 insertions(+), 18 deletions(-) create mode 100644 platform/cortex_r52/src/zx_context.S create mode 100644 platform/cortex_r52/src/zx_context.c create mode 100644 platform/cortex_r52/src/zx_frame.c diff --git a/platform/cortex_r52/CMakeLists.txt b/platform/cortex_r52/CMakeLists.txt index e5e8655..605b786 100644 --- a/platform/cortex_r52/CMakeLists.txt +++ b/platform/cortex_r52/CMakeLists.txt @@ -19,7 +19,10 @@ target_sources(zonex_port PRIVATE ${CMAKE_CURRENT_LIST_DIR}/src/zx_el2_entry.S ${CMAKE_CURRENT_LIST_DIR}/src/zx_trap_handler.S + ${CMAKE_CURRENT_LIST_DIR}/src/zx_context.S ${CMAKE_CURRENT_LIST_DIR}/src/zx_cache.c + ${CMAKE_CURRENT_LIST_DIR}/src/zx_context.c + ${CMAKE_CURRENT_LIST_DIR}/src/zx_frame.c ${CMAKE_CURRENT_LIST_DIR}/src/zx_console.c ${CMAKE_CURRENT_LIST_DIR}/src/zx_el2_cpu.c ${CMAKE_CURRENT_LIST_DIR}/src/zx_el2_fault_path.c diff --git a/platform/cortex_r52/inc/zx_port.h b/platform/cortex_r52/inc/zx_port.h index ad16780..3cbb1ec 100644 --- a/platform/cortex_r52/inc/zx_port.h +++ b/platform/cortex_r52/inc/zx_port.h @@ -111,9 +111,32 @@ /**************************************************************************/ #define ZX_HCR_VM ZX_BIT(0) /* stage-2 MPU enable */ -#define ZX_HCR_AMO ZX_BIT(3) /* route aborts to EL2 */ +#define ZX_HCR_FMO ZX_BIT(3) /* route FIQ to EL2 */ #define ZX_HCR_IMO ZX_BIT(4) /* route IRQ to EL2 */ -#define ZX_HCR_FMO ZX_BIT(5) /* route FIQ to EL2 */ +#define ZX_HCR_AMO ZX_BIT(5) /* route aborts to EL2 */ + +/* ⚠ FMO IS BIT 3 AND AMO IS BIT 5, AND THIS FILE HAD THEM THE OTHER WAY + ROUND until the first image that SET one of them. + * + * The order in the register is FMO, IMO, AMO going up from bit 3 -- TRM + * Table 3-70 and the architecture agree -- and the mnemonic order somebody + * reaches for is the alphabetical one, AMO first. IMO is bit 4 either way, + * which is exactly why the mistake survived: every use of these three until + * now was a single BIC of all three at once, where a swap between two of + * them changes nothing at all. + * + * What it cost when it finally mattered: zx_el2_route_fiq set bit 5, which + * is AMO, so physical FIQ was never routed to EL2 and a partition's window + * never ended. Every other check passed -- the comparator expired, the GIC + * made the interrupt pending, and the guest's PSTATE.F then masked it as an + * ordinary EL1 FIQ. The run printed a perfect setup and then went quiet. + * + * Two things now make it hard to repeat. The positions are asserted, in + * platform/cortex_r52/src/zx_context.c rather than here -- everything above + * the __ASSEMBLER__ guard in this file is read by the assembler as well, and + * an assembler cannot read a _Static_assert. And the image that sets one of + * them now CHECKS THE BIT rather than the name it used to set it, which is + * the only form of that check a swapped definition cannot satisfy. */ #define ZX_HCR_DC ZX_BIT(12) /* default cacheable */ #define ZX_HCR_TGE ZX_BIT(27) /* trap general exceptions */ #define ZX_HCR_HCD ZX_BIT(29) /* HVC disable -- keep CLEAR */ @@ -295,6 +318,25 @@ #define ZX_RUN_FAULTED ZX_C32(0x1) /* stage-2 fault, EC 0x24/0x20 */ #define ZX_RUN_TRAPPED ZX_C32(0x2) /* something else reached EL2 */ +/* AND THE TWO A TIME-PARTITIONED RUN ADDS. A window boundary does NOT come + back here: the boundary handler switches partitions and ERETs into the + next one without ever leaving EL2's exception path, which is what makes + the switch a bounded operation rather than a return through C. These two + are the ways a FRAME ends. + + ZX_RUN_FRAME_DONE the schedule reached its frame limit. A bounded + run, which is what a regression can assert a total + against -- "the harness timed out" is not a result. + ZX_RUN_OVERRUN too many window boundaries had already passed by + the time they were computed, so at least one window + is shorter than its own partition switch. The + schedule would keep turning and every frame would + be longer than declared; stopping and saying so is + the only way that becomes visible. */ + +#define ZX_RUN_FRAME_DONE ZX_C32(0x3) /* the frame limit was reached */ +#define ZX_RUN_OVERRUN ZX_C32(0x4) /* windows shorter than a switch */ + /**************************************************************************/ /* Constants the assembly needs, mirrored -- and asserted */ /**************************************************************************/ @@ -348,6 +390,33 @@ #define ZX_ASM_RESUME_OFF_R4 8 #define ZX_ASM_RESUME_WORDS 10 +/* THE PART OF A PARTITION'S CONTEXT THAT ASSEMBLY TOUCHES, and it is + deliberately the smallest part. + * + * Hyp mode banks only SP, LR and SPSR, so while the hypervisor runs, almost + * all of a guest's state is STILL IN THE MACHINE: its banked SPs and LRs, + * its EL1 system registers, its whole EL1 MPU region set. Every one of + * those can be read and written from C at EL2 exactly as it stands. + * + * What cannot wait is r0-r12, which Hyp mode SHARES with EL1 and which the + * first C instruction would destroy, and ELR_hyp/SPSR_hyp, which say where + * the guest was and in what state. Those fifteen words are captured in the + * vector, before anything else runs, and they are why this block starts + * with them: the offsets below are what the assembly indexes by, so they are + * kept at the front where they are constants a reader can check by counting. + * + * The rest of the structure is C's and has no offsets here. That split is + * the whole reason the largest and most error-prone half of a partition + * switch -- twenty MPU regions, two registers each -- is a readable loop + * rather than a page of MCR/MRC pairs. Every one of these offsets is + * checked against offsetof by a _Static_assert in + * platform/cortex_r52/src/zx_context.c. */ + +#define ZX_ASM_CTX_OFF_R0 0 +#define ZX_ASM_CTX_OFF_R12 48 +#define ZX_ASM_CTX_OFF_ELR 52 +#define ZX_ASM_CTX_OFF_SPSR 56 + #ifndef __ASSEMBLER__ #include "zx_api.h" @@ -359,6 +428,8 @@ belongs to the manifest and not to this port: the host-side validator has to build and check the same objects with no Cortex-R52 header in reach. */ #include "zx_manifest.h" +#include "zx_mm.h" +#include "zx_schedule.h" /**************************************************************************/ /* Where a board's GICv3 frames actually are */ @@ -559,6 +630,16 @@ ZX_NODISCARD uint32_t zx_gic_ppi_is_group1(const ZX_GIC_LAYOUT *gic_ptr, ZX_NODISCARD uint32_t zx_gic_ppi_priority(const ZX_GIC_LAYOUT *gic_ptr, uint32_t intid); +/* And whether the GIC has one PENDING, which is the middle of the three + questions a window that never ends raises: the comparator may not have + expired, the GIC may not have been told, or the core may not have taken + the exception. All three present identically -- a hypervisor that starts + a frame and is never heard from again -- and only this one distinguishes + the first from the third. */ + +ZX_NODISCARD uint32_t zx_gic_ppi_is_pending(const ZX_GIC_LAYOUT *gic_ptr, + uint32_t intid); + /* How many priority bits this GIC actually implements, discovered by writing all ones to one INTID's priority byte and reading back what stuck. The low bits vanish, so two priorities differing only there collapse together -- @@ -818,6 +899,502 @@ extern uint32_t zx_hprenr_implemented_bits; extern uint32_t zx_run_failures; +/**************************************************************************/ +/* A PARTITION'S CONTEXT, AND THE SWITCH */ +/**************************************************************************/ + +/* How many EL1 MPU regions a context block can hold. + * + * TWENTY-FOUR IS THE ARCHITECTURAL MAXIMUM AND THE MODEL REPORTS + * THIRTY-TWO. MPUIR[15:8] gives the EL1 count and TRM Table 3-79 permits + * 0, 16, 20 and 24; the Armv8-R AEM FVP reports 32, which is not a legal + * Cortex-R52 value and is one of the reasons a green model run says nothing + * about a real part's region budget. The array is sized for what the model + * reports rather than for what the architecture permits, because a save + * that stopped at 24 on a part claiming 32 would silently drop the + * outgoing partition's last regions and hand them to its successor. + * + * The count actually saved is read from MPUIR once at boot and clamped to + * this; zx_context_el1_regions reports it, and the two-partition image + * checks that nothing was clamped away. */ + +#define ZX_MAX_EL1_REGIONS 32U + +/* EVERYTHING THAT MAKES ONE PARTITION DIFFERENT FROM ANOTHER, in one + * statically allocated block per partition. No allocation, no list, no + * pointer chasing: a switch indexes an array. + * + * THE ORDER OF THE FIRST FIFTEEN WORDS IS A CONTRACT WITH ASSEMBLY. They + * are what the FIQ vector captures before any C can run, at the + * ZX_ASM_CTX_OFF_* offsets above, and every one of those offsets is + * asserted against offsetof in zx_context.c. Everything after them is + * saved and restored by C, because at EL2 it is all still sitting in the + * machine: Hyp mode banks only SP, LR and SPSR, so a guest's banked + * registers, its EL1 system registers and its entire EL1 MPU can be read + * and written from ordinary C while the hypervisor runs. + * + * That is what keeps the largest and least reviewable part of a partition + * switch -- twenty regions, two registers each, selected through PRSELR -- + * a readable loop instead of a page of coprocessor moves. It costs + * nothing: the registers are the same registers whichever language reaches + * them. + * + * WHAT IS NOT HERE, AND WHY IT IS NOT A HOLE. The FPU. D23 recorded that + * clearing HCPTR.TCP10/TCP11 lets a guest use floating point while nothing + * saves FPEXC, FPSCR or the D-registers -- exactly correct with one + * partition and a shared register bank with two. ZoneX does not save them; + * it DENIES them. zx_el2_deny_guest_fp leaves the traps SET for a + * time-partitioned system, so a guest that touches floating point takes an + * exception to EL2 and is named, rather than quietly reading whatever its + * neighbour left. A later phase either saves the bank or keeps refusing + * it, and both are defensible; sharing it silently is not. */ + +typedef struct ZX_GUEST_CONTEXT_STRUCT +{ + /* ---- captured in the vector, before anything else runs ---- */ + uint32_t zx_ctx_r[13]; /* r0-r12, SHARED with EL1 */ + uint32_t zx_ctx_elr; /* ELR_hyp: where EL1 was */ + uint32_t zx_ctx_spsr; /* SPSR_hyp: mode, flags, masks */ + + /* ---- banked at EL1, still live while the hypervisor runs ---- */ + uint32_t zx_ctx_sp_usr; /* User AND System share these */ + uint32_t zx_ctx_lr_usr; + uint32_t zx_ctx_sp_svc; + uint32_t zx_ctx_lr_svc; + uint32_t zx_ctx_spsr_svc; + uint32_t zx_ctx_sp_irq; + uint32_t zx_ctx_lr_irq; + uint32_t zx_ctx_spsr_irq; + uint32_t zx_ctx_sp_abt; + uint32_t zx_ctx_lr_abt; + uint32_t zx_ctx_spsr_abt; + uint32_t zx_ctx_sp_und; + uint32_t zx_ctx_lr_und; + uint32_t zx_ctx_spsr_und; + uint32_t zx_ctx_sp_fiq; + uint32_t zx_ctx_lr_fiq; + uint32_t zx_ctx_spsr_fiq; + uint32_t zx_ctx_r_fiq[5]; /* r8_fiq to r12_fiq */ + + /* ---- EL1 system registers ---- */ + uint32_t zx_ctx_sctlr; + uint32_t zx_ctx_vbar; + uint32_t zx_ctx_cpacr; + uint32_t zx_ctx_contextidr; + uint32_t zx_ctx_tpidrurw; + uint32_t zx_ctx_tpidruro; + uint32_t zx_ctx_tpidrprw; + uint32_t zx_ctx_mair0; + uint32_t zx_ctx_mair1; + uint32_t zx_ctx_amair0; + uint32_t zx_ctx_amair1; + + /* ---- the guest's own virtual timer ---- */ + uint32_t zx_ctx_cntv_ctl; + uint64_t zx_ctx_cntv_cval; + + /* ---- the hypervisor's per-partition time, which is what freezes ---- + CNTVOFF is EL2's, one value per partition, and the two instants + beside it are what a check of the freeze is measured against. They + are here rather than in a file-scope array in zx_timer.c because + with more than one partition they ARE partition state, and the one + place a partition's state belongs is its context block. */ + uint64_t zx_ctx_cntvoff; + uint64_t zx_ctx_suspended_at; /* physical count when it stopped */ + uint64_t zx_ctx_resumed_at; /* and when it was given the core */ + uint64_t zx_ctx_virtual_at_stop; /* its OWN clock at that instant */ + uint64_t zx_ctx_time_on_core; /* counts it has actually run for */ + + /* ---- the EL1 MPU: the dominant cost of the whole switch ---- */ + uint32_t zx_ctx_prbar[ZX_MAX_EL1_REGIONS]; + uint32_t zx_ctx_prlar[ZX_MAX_EL1_REGIONS]; + uint32_t zx_ctx_prselr; + + /* ---- bookkeeping, not machine state ---- */ + uint32_t zx_ctx_entries; /* times this context was entered */ + uint32_t zx_ctx_preemptions; /* times a boundary took the core */ + uint32_t zx_ctx_started; /* zero until its first entry */ +} ZX_GUEST_CONTEXT; + +/* How many EL1 MPU regions this part actually has, clamped to the array + above, and whether anything had to be clamped away. Read once at boot: + the count is fixed for the life of the run, and reading MPUIR inside the + switch would put a coprocessor read on a path whose cost is the number + this step exists to measure. */ + +void zx_context_probe_el1_regions(void); +ZX_NODISCARD uint32_t zx_context_el1_regions(void); +ZX_NODISCARD uint32_t zx_context_el1_regions_clamped(void); + +/* Prepare a never-run partition: its entry point, and the state a fresh + guest is entered with. Everything else is zero, which is the right + starting value for every banked register a reset path is about to set. */ + +void zx_context_init(ZX_GUEST_CONTEXT *context_ptr, zx_addr_t entry); + +/* SAVE AND RESTORE THE HALF THAT C CAN REACH: banked registers, EL1 system + registers, the guest's virtual timer, and the whole EL1 MPU set. Called + from the boundary handler between the vector's capture and its ERET, and + from nowhere else. + + Both are straight-line apart from one loop whose trip count is the part's + region count, read at boot. Nothing in either depends on anything a + guest did, which is what makes a partition switch one number rather than + a distribution. */ + +void zx_context_save(ZX_GUEST_CONTEXT *context_ptr); +void zx_context_restore(const ZX_GUEST_CONTEXT *context_ptr); + +/* The EL1 MPU half of each, separately, because the measurement needs them + separately: "how much of a partition switch is the guest's MPU set" is + one of the numbers this work exists to produce, and the honest way to + produce it is to time the code the switch actually runs rather than a + copy written for the occasion. A second loop kept beside the first + agrees until the day it does not, and that is the day the published + figure stops describing the shipped switch. The cost of the split -- + one call each way -- is inside every figure this suite prints, because + it is inside every switch it performs. */ + +void zx_context_save_mpu(ZX_GUEST_CONTEXT *context_ptr); +void zx_context_restore_mpu(const ZX_GUEST_CONTEXT *context_ptr); + +/* THE TIME HALF, WHICH IS WHAT THE DETERMINISM CLAIM RESTS ON. + * + * suspend records where the physical counter was and what the partition's + * own clock read there; resume advances that partition's CNTVOFF by + * everything that elapsed and puts it back in the register. Between the + * two the partition's virtual counter does not move, because the offset + * moved by exactly as much as the counter did. + * + * Per CONTEXT rather than per hypervisor, which is the difference between + * one partition and several: with two partitions the interval being + * credited is the OTHER partition's window, and each has its own offset to + * be credited into. See docs/decisions.md D7. */ + +void zx_context_time_suspend(ZX_GUEST_CONTEXT *context_ptr); +void zx_context_time_resume(ZX_GUEST_CONTEXT *context_ptr); +void zx_context_time_reset(ZX_GUEST_CONTEXT *context_ptr); + +/* Print one partition's context: what it cost, where it was, and how much + of its own clock it has been given. */ + +void zx_context_report(const ZX_GUEST_CONTEXT *context_ptr, + const char *name_ptr); + +/**************************************************************************/ +/* Entering a time-partitioned frame */ +/**************************************************************************/ + +/* Hand the core to a partition and do not come back until the FRAME ends. + * + * This is zx_el2_run_payload's shape one level up: it saves EL2's own + * resume context, restores the given partition's, and ERETs. It does not + * return through that ERET. Window boundaries are handled entirely inside + * EL2's exception path -- the FIQ vector saves the outgoing partition, + * restores the incoming one and ERETs again -- so C sees none of them. + * + * It returns, through the same resume path a fault takes, when a guest + * yields or faults, when the schedule reaches its frame limit, or when the + * frame has overrun. The value is one of the ZX_RUN_* codes above. + * + * WHY BOUNDARIES DO NOT COME BACK HERE. A switch that returned to C and + * was re-entered would pay for a return, a dispatch and a call on the path + * whose cost is the whole subject of this step -- and it would make the + * hypervisor's stack depth depend on how a guest left, which is the one + * thing a bounded switch cannot have. */ + +ZX_NODISCARD uint32_t zx_el2_enter_partition(ZX_GUEST_CONTEXT *context_ptr); + +/* The context the boundary handler is currently saving into, and the + schedule it consults. Set by the caller before the frame starts. + * + File-scope because the FIQ vector reaches them with no argument to be + passed one in: an exception arrives with nothing but the machine. They + are declared here rather than hidden because zx_context.S names the + first of them directly. */ + +extern ZX_GUEST_CONTEXT *zx_el2_current_context; + +/**************************************************************************/ +/* THE MAJOR FRAME */ +/**************************************************************************/ + +/* Everything the boundary handler needs, in one object it can reach with no + * argument -- because an exception arrives with nothing but the machine. + * + * The counters are not decoration. A switch cost quoted as a MEAN is a + * number a safety audience will not accept: what they read is the MAXIMUM, + * because that is what a schedule has to be built to survive. Min, max, + * total and count are kept so that the run can print all four and let a + * reader see the spread rather than a summary of it. + * + * PMCCNTR IS 32-BIT AND WRAPS, which is why the per-switch figures are + * uint32_t and the total is not: a switch is a few thousand cycles so one + * difference cannot wrap, and ten thousand of them summed comfortably can. */ + +typedef struct ZX_FRAME_STRUCT +{ + ZX_SCHEDULE *zx_frame_schedule; + ZX_GUEST_CONTEXT *zx_frame_contexts; /* one per partition */ + const ZX_MM_LAYOUT *zx_frame_layout; + + /* WHOSE LINE IS THIS? The manifest, so that the switch can hand the + guest console the identity of the partition it has just SCHEDULED. + The tag on a forwarded character has to come from the hypervisor and + never from the guest, or a partition could claim to be its neighbour + and every line of a captured log would be evidence of nothing. With + one partition that was a single attach around the whole excursion; + with a frame it has to follow the frame, because the partition that + owns the console changes several times a second. + + A null pointer means no console tagging, which is what an image that + runs no guest console wants. */ + const ZX_MANIFEST *zx_frame_manifest; + + UINT zx_frame_partitions; + + /* A bit per partition that has left its window early -- yielded, or + been taken from by a fault. Such a partition is NOT entered again, + and its windows are still SPENT: that is what a static frame means, + and giving its time to a neighbour would make one partition's + schedule depend on another's behaviour. */ + uint32_t zx_frame_stopped_mask; + + /* What ended the frame, left where zx_context.S can pick it up: the + boundary handler is a vector and has no caller to return a value to. */ + uint32_t zx_frame_result; + + uint32_t zx_frame_switches; + + /* How many of those boundaries were TIMED, which is not the same + number. A boundary that had to burn a stopped partition's window is + a switch plus a wait of up to a whole window, and averaging the two + together describes neither -- so it is counted as a boundary and left + out of the cost. In a healthy run the two are equal. */ + uint32_t zx_frame_timed; + + uint32_t zx_frame_switch_min; + uint32_t zx_frame_switch_max; + uint32_t zx_frame_switch_last; + uint64_t zx_frame_switch_total; + + /* An FIQ that arrived at a boundary with the hypervisor's own timer NOT + expired. Nothing else is routed to EL2, so this should be zero for + the life of a run; it is counted because a non-zero value means the + vector was reached by something nobody has identified, and a switch + performed on that basis would be a switch performed for no reason. */ + uint32_t zx_frame_spurious; + + /* WHICH partition left its window early, and what took it. A frame + that ran to its limit with one partition stopped in the middle is a + different system from one where both ran throughout, and "the frame + completed" says the same thing about both. ZX_MANIFEST_NO_INDEX and + ZX_RUN_FRAME_DONE when nobody left early. */ + UINT zx_frame_stopped_index; + uint32_t zx_frame_stop_outcome; +} ZX_FRAME; + +/* Wire the frame up. Nothing is entered and no timer is armed: this only + records what the boundary handler will need, so that a caller can build + the whole system before committing to run it. */ + +void zx_frame_configure(ZX_FRAME *frame_ptr, + ZX_SCHEDULE *schedule_ptr, + ZX_GUEST_CONTEXT *contexts_ptr, + const ZX_MM_LAYOUT *layout_ptr, + const ZX_MANIFEST *manifest_ptr, + UINT partition_count); + +/* RUN THE FRAME. Returns when the schedule reaches its limit, when a guest + leaves its window early and no partition is left to enter, or when the + frame has overrun. One of the ZX_RUN_* codes. + * + * A guest that yields or faults inside its window does NOT end the run: the + * rest of that window is spent where it was declared to be spent, the + * partition is marked stopped, and the frame carries on with its + * neighbours. An idle partition burning its window is not a limitation to + * be optimised away -- it is the property that makes each partition's + * timing independent of the others', which is the whole purchase. */ + +ZX_NODISCARD uint32_t zx_frame_run(ZX_FRAME *frame_ptr); + +/* What the boundary handler calls, from zx_context.S, with the outgoing + partition's fifteen shared words already captured. Returns the context to + resume, or a null pointer when the frame is over. Not called from C. */ + +ZX_NODISCARD ZX_GUEST_CONTEXT *zx_el2_window_boundary(void); + +/* Where the boundary handler leaves the reason a frame ended, because the + vector it runs in has no caller to return one to. */ + +extern uint32_t zx_el2_frame_result; + +/* MEASURE THE SWITCH, BY GROUP, using the same code the switch runs. + * + * Averages `rounds` iterations of each group with the cost of reading the + * cycle counter measured separately and subtracted, and alternates the two + * contexts so that no iteration is restoring the state already in force -- + * an implementation is entitled to make that cheap in a way a real switch + * is not. Both disciplines were established when the region-set switch was + * first measured; they are reused here rather than reinvented because the + * first version of that measurement was wrong in exactly the ways they + * prevent. See docs/decisions.md D4. + * + * Every figure is written into the structure the caller passes, so the + * reporting stays with the image and the measurement stays with the port. */ + +typedef struct ZX_SWITCH_COST_STRUCT +{ + uint32_t zx_cost_counter_read; /* subtracted from every row below */ + uint32_t zx_cost_save_full; + uint32_t zx_cost_restore_full; + uint32_t zx_cost_save_mpu; + uint32_t zx_cost_restore_mpu; + uint32_t zx_cost_region_mask; /* the stage-2 switch, one write */ + uint32_t zx_cost_time_freeze; /* suspend and resume, CNTVOFF */ + uint32_t zx_cost_deadline; /* arming CNTHP_CVAL */ + uint32_t zx_cost_el1_regions; /* how many the loops walked */ +} ZX_SWITCH_COST; + +void zx_frame_measure_switch(ZX_GUEST_CONTEXT *scratch_a, + ZX_GUEST_CONTEXT *scratch_b, + uint32_t mask_a, uint32_t mask_b, + uint32_t rounds, + ZX_SWITCH_COST *cost_ptr); + +/* Spend counter counts at EL2 until an ABSOLUTE deadline, bounded. + * + * The hypervisor's answer to a partition that handed its window back early. + * A static frame does not give that time away, so it is burned here -- and + * burned by watching the same counter the deadline was computed against, + * so that the window a yielding partition leaves behind is exactly as long + * as the one it would have run out. */ + +void zx_el2_dwell_until(uint64_t deadline); + +/* The priority the hypervisor's tick is given, so an image can check what + the GIC read back against what was asked for rather than against a + constant it restated. */ + +ZX_NODISCARD uint32_t zx_frame_hyp_tick_priority(void); + +/**************************************************************************/ +/* The hypervisor's own timer, PPI 26 */ +/**************************************************************************/ + +/* CNTHP: the comparator that ends a partition's window. + * + * AN ABSOLUTE DEADLINE, WRITTEN TO CNTHP_CVAL, and never a countdown into + * CNTHP_TVAL. A countdown re-armed inside the handler adds the handler's + * own latency to every window, so a frame declared as 10 ms becomes 10 ms + * plus the switch -- cumulatively, invisibly, for the life of the run. The + * comparator is 64-bit and compares against the PHYSICAL counter, which is + * the one thing in the system no partition can move. + * + * arm() returns non-zero when the deadline it was given is still in the + * future. A zero return means the window was shorter than its own switch, + * which the schedule counts and the run reports; it is the caller's answer + * to give, so it is returned rather than acted on here. */ + +ZX_NODISCARD uint32_t zx_el2_hyp_timer_arm(uint64_t deadline); +void zx_el2_hyp_timer_stop(void); +ZX_NODISCARD uint32_t zx_el2_hyp_timer_fired(void); +ZX_NODISCARD uint64_t zx_el2_hyp_timer_deadline(void); + +/* Route physical FIQ to EL2, leaving IRQ with EL1. + * + * THE ONE BIT THE WHOLE OF TIME PARTITIONING RESTS ON. With HCR.FMO set, + * PSTATE.F is IGNORED at EL0 and EL1: a partition CANNOT mask the interrupt + * that ends its own window, however it sets its own masks and whatever its + * kernel does with them. A tick delivered as an IRQ could be deferred by + * any guest that disabled interrupts, which is precisely the property time + * partitioning must not concede. + * + * HCR.IMO stays CLEAR, so every guest interrupt is still a physical IRQ + * delivered straight to EL1 with no injection and no List Register -- the + * arrangement the single-partition work already proved end to end. HCR.AMO + * stays clear too, and that omission is deliberate rather than pending: an + * asynchronous abort routed to EL2 arrives at the vector that today means + * "ZoneX faulted on its own access", so setting AMO without reworking that + * vector would report a guest's abort as a hypervisor bug. See + * docs/decisions.md D25. */ + +void zx_el2_route_fiq(void); + +/* Leave HCPTR.TCP10/TCP11 SET, so a guest's floating-point access traps. + * + * The opposite of what zx_el2_prepare_guest_el1 does, and it exists because + * nothing saves the FPU across a partition switch. With one partition an + * open FPU is exactly correct; with two it is two guests sharing a register + * bank, which is found by a wrong ANSWER rather than by a fault. Denying it + * turns that into an exception at EL2 with a syndrome naming the cause. */ + +void zx_el2_deny_guest_fp(void); + +/* And lift them again once the last window has closed. + * + * MEASURED, not stylistic: with the floating-point traps set, the S32Z280's + * debug probe cannot read the core's register file at all, and the harness + * exits non-zero on a run whose own console said ALL CHECKS PASSED. What + * the traps protect is the interval in which PARTITIONS run, and that + * interval ends when the frame does. See zx_timer.c. */ + +void zx_el2_allow_guest_fp(void); + +/**************************************************************************/ +/* The EL2 CPU interface, and the trap FMO sets */ +/**************************************************************************/ + +/* Bring up EL2's own GICv3 CPU interface: the priority mask and the Group 0 + * enable. + * + * THIS IS NOT OPTIONAL HOUSEKEEPING, AND THE REASON IS A REDIRECTION THAT + * IS EASY TO MISS. Setting HCR.FMO does more than route FIQ: it makes EL1 + * accesses to the Group 0 CPU-interface registers, AND to the ones common + * to both groups, go to the VIRTUAL interface instead of the physical one + * (Cortex-R52 TRM 9.3.5). ICC_PMR is one of the common ones. + * + * So the instant FMO is set, the guest's own `ICC_PMR = 0xFF` -- which + * every guest writes at start-up to unmask its interrupts -- lands in + * ICV_PMR and changes nothing about physical delivery. The physical + * priority mask resets to ZERO, which masks everything, and a partition + * that received its timer perfectly well a moment ago stops receiving + * anything at all. Nothing faults; the guest simply never ticks. + * + * The hypervisor therefore owns the physical mask and sets it wide enough + * for both its own tick and the partitions' timers. Priority ORDER still + * does the work it always did: the tick is numerically lower and preempts. + * + * The redirection is also what makes Design A airtight rather than merely + * cheap. ICC_IGRPEN0 is redirected too, so a guest cannot reach the + * physical Group 0 enable to switch off the interrupt that ends its window + * -- it writes ICV_IGRPEN0 and nothing happens. Before FMO was set that + * register was genuinely shared, and D24 could only argue that a partition + * had no Group 0 interrupt worth enabling. */ + +void zx_gic_el2_cpu_interface_init(void); + +/* Acknowledge and complete an FIQ at EL2, through the GROUP 0 registers. + * + * ICC_IAR0 returning ZX_INTID_SPURIOUS means nothing was pending, and it + * must NOT be given an end-of-interrupt: the running priority was never + * raised, so dropping it corrupts the GIC's priority stack rather than + * merely being redundant. */ + +ZX_NODISCARD uint32_t zx_gic_el2_acknowledge(void); +void zx_gic_el2_end_of_interrupt(uint32_t intid); + +ZX_NODISCARD uint32_t zx_gic_el2_priority_mask(void); +ZX_NODISCARD uint32_t zx_gic_el2_group0_enabled(void); + +/* Enable one PPI in GROUP 0, which is what the GIC delivers as an FIQ, at + the given priority. The hypervisor's own timer and nothing else: a + partition is granted no Group 0 interrupt, which is why nothing can + deliver an FIQ to a guest even though FIQ is now routed to EL2. */ + +void zx_gic_enable_hyp_ppi(const ZX_GIC_LAYOUT *gic_ptr, uint32_t intid, + uint32_t priority); + /**************************************************************************/ /* Entry points the reset path calls */ /**************************************************************************/ diff --git a/platform/cortex_r52/src/zx_context.S b/platform/cortex_r52/src/zx_context.S new file mode 100644 index 0000000..1844f17 --- /dev/null +++ b/platform/cortex_r52/src/zx_context.S @@ -0,0 +1,287 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ +@ Some portions generated by Claude Code (Opus 5). + +@ zx_context.S -- Cortex-R52 -- the partition switch, at the exception +@ boundary where it has to be assembly and nowhere else. +@ +@ DESCRIPTION +@ +@ Four routines and one contract. The contract is that r0-r12 are SHARED +@ between Hyp mode and EL1 -- Hyp banks only SP, LR and SPSR -- so a guest's +@ general-purpose registers are destroyed by the first C instruction that +@ runs after an exception. Everything else a partition owns is still in the +@ machine and can wait: its banked SPs and LRs, its EL1 system registers and +@ its whole EL1 MPU are all readable and writable from ordinary C at EL2. +@ +@ So this file is deliberately SMALL. It captures fifteen words, hands +@ over to C, and puts fifteen words back. The twenty MPU regions that +@ dominate the switch's cost are a loop in zx_context.c, where a reader can +@ check them against the manifest they came from. +@ +@ WHY A WINDOW BOUNDARY NEVER RETURNS TO C's CALLER +@ +@ zx_el2_enter_partition is a ONE-WAY transfer, exactly as +@ zx_el2_run_payload is: it saves EL2's own resume context and ERETs. A +@ window boundary is handled entirely inside the exception path -- save the +@ outgoing partition, ask C which one is next, restore it, ERET -- and the +@ hypervisor's C code never sees it. +@ +@ That is not an optimisation. A boundary that returned to C and was +@ re-entered would put a return, a dispatch and a call on the path whose +@ cost is the whole subject of this work, and it would make the +@ hypervisor's stack depth depend on how a guest happened to leave. A +@ bounded switch cannot have either property. +@ +@ WHY lr IS THE SCRATCH REGISTER +@ +@ Every one of r0-r12 belongs to the guest at the instant the FIQ is taken, +@ and there is nowhere to put one before there is somewhere to put them +@ all. LR_hyp is BANKED, and its live value -- EL2's own return address -- +@ was already stored into zx_el2_resume_context by zx_el2_enter_partition +@ before the ERET. So it is the one register that is both reachable and +@ free, and the capture below uses it to hold the context pointer while the +@ thirteen shared registers go out in one STM. +@ +@ STACK DISCIPLINE +@ +@ The boundary path pushes nothing. SP_hyp is whatever EL2 left it at +@ before it ERETed into the guest, which is a valid Hyp stack pointer +@ inside zx_el2_enter_partition's frame, and the BL below runs on it at +@ one fixed depth however many boundaries a run takes. A switch whose +@ stack grew per boundary would survive a demonstration and not a +@ deployment. The one path that does push -- the capture from the trap +@ vector -- balances it explicitly, for the reason AR2 invariant 17 was +@ written: an unbalanced handler leaked bytes of a 1 KB stack per fault +@ during the Cortex-R52 Modules port work and presented as nothing at all +@ until the run got long. +@ +@ NOTE ON SYNTAX +@ +@ GNU assembler syntax, per CONTRIBUTING.md. + +#include "zx_port.h" + + .syntax unified + .arm + + .text + .balign 4 + +/**************************************************************************/ +/* zx_el2_fiq_boundary -- HVBAR + 0x1C, the end of a partition's window. */ +/* */ +/* THE ONE INTERRUPT A PARTITION CANNOT MASK. With HCR.FMO set, PSTATE.F */ +/* is IGNORED at EL0 and EL1, so this vector is reached however a guest */ +/* has set its own masks and whatever its kernel is doing with them. A */ +/* tick delivered as an IRQ could be deferred by any guest that disabled */ +/* interrupts, which is exactly the property time partitioning must not */ +/* concede -- and it is why the hypervisor's own timer goes in GIC Group 0 */ +/* rather than being injected. See docs/decisions.md D24 and D25. */ +/* */ +/* TWO THINGS ARE CHECKED BEFORE ANYTHING IS SAVED, and both are about */ +/* writing a guest's registers into the wrong place: */ +/* */ +/* * that the exception came from EL1 and not from Hyp mode. An FIQ */ +/* taken while the HYPERVISOR runs would otherwise have EL2's own */ +/* r0-r12 and ELR stored into the running partition's context block, */ +/* and that partition would later be resumed inside the hypervisor. */ +/* ZoneX keeps PSTATE.F set at EL2 so it cannot happen; it is checked */ +/* because the check is one comparison and being wrong is */ +/* unrecoverable. */ +/* * that a partition is running at all. Every other ZoneX image */ +/* shares this vector table and leaves FMO clear, so for them an FIQ */ +/* here is an exception nothing expected, and it is reported as one */ +/* through the vector that already exists for it. */ +/**************************************************************************/ + + .global zx_el2_fiq_boundary + .type zx_el2_fiq_boundary, %function +zx_el2_fiq_boundary: + + @ Where was the core when this arrived? SPSR_hyp holds the mode the + @ exception was taken FROM. lr is free here for the reason in the file + @ header, and nothing has been disturbed yet. + + mrs lr, SPSR_hyp + and lr, lr, #ZX_MODE_MASK + cmp lr, #ZX_MODE_HYP + beq zx_el2_vector_fiq @ report and stop + + ldr lr, =zx_el2_current_context + ldr lr, [lr] + cmp lr, #0 + beq zx_el2_vector_fiq @ no frame: unexpected + + @ THE FIFTEEN WORDS THAT CANNOT WAIT. r0-r12 first, in one STM, before + @ any of them is used for anything else; then the two Hyp-banked + @ registers that say where EL1 was and in what state. + + stm lr, {r0-r12} @ ZX_ASM_CTX_OFF_R0, 13 words + mrs r0, ELR_hyp + str r0, [lr, #ZX_ASM_CTX_OFF_ELR] + mrs r0, SPSR_hyp + str r0, [lr, #ZX_ASM_CTX_OFF_SPSR] + + @ C does the rest: the banked registers, the EL1 system registers, the + @ whole EL1 MPU set, the time freeze, the region-set switch and the next + @ deadline. It returns the context to resume, or zero when the frame is + @ over. + + bl zx_el2_window_boundary + + cmp r0, #0 + bne zx_el2_context_resume + + @ The frame ended. Go back to the hypervisor through the same path a + @ fault takes, with the reason picked up from where C left it: this + @ vector was never called, so there is no return value to carry it. + + ldr r0, =zx_el2_frame_result + ldr r0, [r0] + b zx_el2_resume + .size zx_el2_fiq_boundary, . - zx_el2_fiq_boundary + +/**************************************************************************/ +/* zx_el2_context_capture_from_stack */ +/* */ +/* The same capture, for the paths where a guest left through the HYP */ +/* TRAP vector -- a yield, or a fault -- rather than through the FIQ. */ +/* */ +/* WHY THE GUEST'S r0 AND r1 COME OFF THE STACK. The trap vector pushes */ +/* them on entry, because two hypercall paths return to the guest */ +/* transparently and must not have changed anything. By the time control */ +/* reaches here those two registers hold the decoded syndrome instead, */ +/* and the guest's real values are on the Hyp stack. Storing what is in */ +/* the registers would hand a partition a copy of its own exception */ +/* syndrome and resume it with that -- a guest corrupted by the act of */ +/* observing it, which is the hardest class of bug in this file to see. */ +/* */ +/* Called with those two words still on the stack and leaves them there, */ +/* so the caller's own "add sp, sp, #8" stays where it is and stays */ +/* correct. r2-r12 are still the guest's on every path that reaches */ +/* here: the console path, which clobbers more, returns to the guest and */ +/* never comes this way. */ +/* */ +/* Does nothing at all when no partition is running, which is every */ +/* ZoneX image that launches a payload without a schedule. Their yields */ +/* and faults are unchanged by this file existing. */ +/**************************************************************************/ + + .global zx_el2_context_capture_from_stack + .type zx_el2_context_capture_from_stack, %function +zx_el2_context_capture_from_stack: + + @ r0 is saved because it is needed as scratch below; lr because BL put + @ the return address in it and lr is the only register free to hold the + @ context pointer. Two words keeps SP 8-byte aligned, as AAPCS wants + @ it, and the pop below balances this push on BOTH paths out. + + push {r0, lr} + + ldr lr, =zx_el2_current_context + ldr lr, [lr] + cmp lr, #0 + beq zx_el2_capture_none + + stm lr, {r0-r12} @ r0 and r1 are WRONG here + + @ The guest's real r0 and r1, from under this function's own frame: + @ [sp] and [sp, #4] are what was just pushed, so the trap vector's two + @ words are at +8 and +12. + + ldr r0, [sp, #8] + str r0, [lr, #ZX_ASM_CTX_OFF_R0] + ldr r0, [sp, #12] + str r0, [lr, #(ZX_ASM_CTX_OFF_R0 + 4)] + + mrs r0, ELR_hyp + str r0, [lr, #ZX_ASM_CTX_OFF_ELR] + mrs r0, SPSR_hyp + str r0, [lr, #ZX_ASM_CTX_OFF_SPSR] + +zx_el2_capture_none: + pop {r0, lr} + bx lr + .size zx_el2_context_capture_from_stack, . - zx_el2_context_capture_from_stack + +/**************************************************************************/ +/* zx_el2_enter_partition -- hand the core to a partition. */ +/* */ +/* uint32_t zx_el2_enter_partition(ZX_GUEST_CONTEXT *context_ptr); */ +/* */ +/* Saves EL2's own resume context -- the same one zx_el2_run_payload */ +/* uses, and for the same reason -- and then restores the partition and */ +/* ERETs. It does not return through that ERET: control comes back */ +/* through zx_el2_resume when a guest yields or faults, or when the */ +/* boundary handler decides the frame is over. */ +/* */ +/* ONE RESUME SLOT, so this CANNOT NEST, and a time-partition schedule */ +/* has no reason to: it enters one partition at a time, sequentially, */ +/* and window boundaries never come back through here at all. */ +/**************************************************************************/ + + .global zx_el2_enter_partition + .type zx_el2_enter_partition, %function +zx_el2_enter_partition: + ldr r2, =zx_el2_resume_context + str sp, [r2, #ZX_ASM_RESUME_OFF_SP] + str lr, [r2, #ZX_ASM_RESUME_OFF_LR] + add r3, r2, #ZX_ASM_RESUME_OFF_R4 + stmia r3, {r4-r11} + + @ r0 already holds the context. Branched rather than fallen through, + @ so that the two routines can be read, moved or measured separately + @ without a silent dependency on which one the assembler emits first. + + b zx_el2_context_resume + .size zx_el2_enter_partition, . - zx_el2_enter_partition + +/**************************************************************************/ +/* zx_el2_context_resume -- put fifteen words back and go. */ +/* */ +/* void zx_el2_context_resume(ZX_GUEST_CONTEXT *context_ptr); */ +/* */ +/* Does not return. */ +/* */ +/* ELR AND SPSR FIRST, THE THIRTEEN SHARED REGISTERS LAST, and the order */ +/* is forced: writing ELR_hyp or SPSR_hyp needs a register to write from, */ +/* and after the LDM there is no register left that is not the guest's. */ +/* */ +/* SPSR carries the guest's own interrupt masks back with it, exactly as */ +/* they were when the window ended. A switch that re-entered a partition */ +/* with masks of its own choosing would be changing a guest's state */ +/* behind its back. PSTATE.T is in there too, so a guest that was */ +/* executing T32 comes back in T32 without this file having to know */ +/* which -- the invariant that "SPSR.T must match the entry point's */ +/* instruction set" is satisfied here by never having separated them. */ +/**************************************************************************/ + + .global zx_el2_context_resume + .type zx_el2_context_resume, %function +zx_el2_context_resume: + mov lr, r0 + + ldr r0, [lr, #ZX_ASM_CTX_OFF_ELR] + msr ELR_hyp, r0 + ldr r0, [lr, #ZX_ASM_CTX_OFF_SPSR] + msr SPSR_hyp, r0 + + ldm lr, {r0-r12} + + dsb + isb + eret + .size zx_el2_context_resume, . - zx_el2_context_resume diff --git a/platform/cortex_r52/src/zx_context.c b/platform/cortex_r52/src/zx_context.c new file mode 100644 index 0000000..8839c94 --- /dev/null +++ b/platform/cortex_r52/src/zx_context.c @@ -0,0 +1,855 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_context.c Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* The half of a partition switch that does not have to be assembly: */ +/* the banked EL1 registers, the EL1 system registers, the guest's own */ +/* virtual timer, its per-partition CNTVOFF, and the whole EL1 MPU */ +/* region set. */ +/* */ +/* WHY THIS IS C AND NOT A PAGE OF MCR/MRC PAIRS */ +/* */ +/* Because at EL2 it can be. AArch32 Hyp mode banks only SP, LR and */ +/* SPSR, so at the instant the hypervisor takes control, everything */ +/* below is STILL IN THE MACHINE and reachable with the ordinary */ +/* banked-register and coprocessor forms. Only r0-r12, ELR_hyp and */ +/* SPSR_hyp have to be captured before the first C instruction runs, */ +/* and those fifteen words are zx_context.S's whole job. */ +/* */ +/* That split is worth its explanation because the plan this work came */ +/* from expected the opposite -- one large assembly routine doing all */ +/* of it. The dominant cost here is the EL1 MPU: twenty regions on */ +/* the S32Z280, two registers each, selected through a third. As a */ +/* loop it is eight lines that a reader can check against the region */ +/* count the part reported. Unrolled in assembly it is a hundred and */ +/* twenty coprocessor moves in which a single transposed operand is */ +/* invisible and hands one partition another's memory permissions. */ +/* */ +/* Nothing is given up. The loop's trip count is read from MPUIR once */ +/* at boot and does not depend on anything a guest did, so the switch */ +/* is still one number rather than a distribution -- which is the only */ +/* property a worst-case-execution-time argument actually needs. */ +/* */ +/* MISRA C:2012 deviations (justified) */ +/* */ +/* Directive 4.3 -- every banked-register and coprocessor access below */ +/* has no C equivalent. They are confined to this file's accessor */ +/* macros and to the MPU loop, and each names the register it */ +/* touches in a comment beside it. */ +/* */ +/**************************************************************************/ + +#include "zx_port.h" +#include + +/* THE OFFSETS ZX_CONTEXT.S INDEXES BY, checked against the structure they + are meant to describe. A field inserted above zx_ctx_elr would otherwise + move it silently, and the assembly would store a guest's return address + over one of its general-purpose registers -- a partition resumed at a + plausible-looking address in the middle of its own data. Nothing would + fault and nothing would say why. */ + +_Static_assert(offsetof(ZX_GUEST_CONTEXT, zx_ctx_r) == ZX_ASM_CTX_OFF_R0, + "zx_context.S stores r0 at the wrong offset"); +_Static_assert(offsetof(ZX_GUEST_CONTEXT, zx_ctx_r[12]) + == ZX_ASM_CTX_OFF_R12, + "zx_context.S stores r12 at the wrong offset"); +_Static_assert(offsetof(ZX_GUEST_CONTEXT, zx_ctx_elr) == ZX_ASM_CTX_OFF_ELR, + "zx_context.S stores ELR_hyp at the wrong offset"); +_Static_assert(offsetof(ZX_GUEST_CONTEXT, zx_ctx_spsr) == ZX_ASM_CTX_OFF_SPSR, + "zx_context.S stores SPSR_hyp at the wrong offset"); + +/* And that the thirteen shared registers really are contiguous, because + the capture is one STM. */ +_Static_assert(sizeof(((ZX_GUEST_CONTEXT *)0)->zx_ctx_r) == 13U * 4U, + "the shared-register array is no longer thirteen words"); + +/* HCR's three routing bits are FMO, IMO, AMO going UP from bit 3, and this + port had FMO and AMO the other way round until the first image that SET + one of them. The mnemonic order a reader reaches for is alphabetical -- + AMO first -- and IMO is bit 4 either way, which is exactly why the + mistake survived: every use of the three until then was a single BIC of + all three at once, where swapping two of them changes nothing. + + What it cost: routing FIQ to EL2 set bit 5, which is AMO, so a + partition's window never ended. The comparator expired, the GIC made the + interrupt pending, and the guest's own PSTATE.F then masked it as an + ordinary EL1 FIQ. The run printed a flawless set-up and then went quiet. + + Asserted here rather than in the header because everything above the + __ASSEMBLER__ guard in zx_port.h is read by the assembler as well, and an + assembler cannot read a _Static_assert. TRM Table 3-70. */ + +_Static_assert(ZX_HCR_FMO == 0x8U, "HCR.FMO is bit 3 (TRM Table 3-70)"); +_Static_assert(ZX_HCR_IMO == 0x10U, "HCR.IMO is bit 4 (TRM Table 3-70)"); +_Static_assert(ZX_HCR_AMO == 0x20U, "HCR.AMO is bit 5 (TRM Table 3-70)"); + +/* CNTV_CTL, the guest's virtual timer control. Stopping a partition's + timer clears BOTH bits: masking alone leaves the comparator running with + ISTATUS set, so a guest re-entered later finds a timer it never armed + already expired. */ +#define ZX_CNTV_CTL_ENABLE ZX_BIT(0) +#define ZX_CNTV_CTL_IMASK ZX_BIT(1) + +/* MPUIR keeps the EL1 region count in bits [15:8]. HMPUIR keeps the EL2 + count in bits [7:0] -- different registers, different fields, and exactly + the sort of detail that is wrong once and then copied. */ +#define ZX_MPUIR_EL1_COUNT_SHIFT 8U +#define ZX_MPUIR_EL1_COUNT_MASK 0xFFU + +static uint32_t zx_el1_regions; +static uint32_t zx_el1_regions_clamped; + + +/**************************************************************************/ +/* Banked-register accessors */ +/* */ +/* MRS/MSR (banked register) is how Hyp mode reaches another mode's SP, */ +/* LR and SPSR without changing mode. The alternative -- CPS into each */ +/* mode, read the registers, CPS back -- would work at EL1 and cannot be */ +/* used here at all: entering an EL1 mode from Hyp is not a mode change, */ +/* it is an exception return. */ +/* */ +/* User and System SHARE SP and LR, so SP_usr covers both. There is no */ +/* SPSR_usr, because User mode cannot take an exception into itself. */ +/**************************************************************************/ + +#define ZX_SAVE_BANKED(field, reg) \ + __asm__ volatile("mrs %0, " #reg : "=r"(context_ptr->field)) + +#define ZX_LOAD_BANKED(field, reg) \ + __asm__ volatile("msr " #reg ", %0" : : "r"(context_ptr->field)) + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_context_probe_el1_regions Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* Reads how many EL1 MPU regions this part has, ONCE, at boot. */ +/* */ +/* Once, because the count is fixed for the life of the run and a */ +/* coprocessor read inside the switch would put a cost on the path */ +/* this whole step exists to measure -- for an answer that cannot */ +/* change. */ +/* */ +/* CLAMPED, AND THE CLAMPING IS REPORTED. The Armv8-R AEM FVP claims */ +/* thirty-two EL1 regions, which is not an architecturally legal */ +/* Cortex-R52 value; the S32Z280 has twenty. A part claiming more */ +/* than the context block can hold would have its highest regions */ +/* silently left out of every save -- so the outgoing partition's last */ +/* windows would stay programmed and the incoming one would inherit */ +/* them. That is an isolation failure at stage ONE, invisible to */ +/* every stage-2 check in this suite, so the fact that anything was */ +/* clamped is a value a run can assert is zero. */ +/* */ +/**************************************************************************/ + +void zx_context_probe_el1_regions(void) +{ + uint32_t mpuir; + + __asm__ volatile("mrc p15, 0, %0, c0, c0, 4" : "=r"(mpuir)); + + zx_el1_regions = (mpuir >> ZX_MPUIR_EL1_COUNT_SHIFT) + & ZX_MPUIR_EL1_COUNT_MASK; + + if (zx_el1_regions > ZX_MAX_EL1_REGIONS) + { + zx_el1_regions_clamped = zx_el1_regions - ZX_MAX_EL1_REGIONS; + zx_el1_regions = ZX_MAX_EL1_REGIONS; + } + else + { + zx_el1_regions_clamped = 0U; + } +} + + +uint32_t zx_context_el1_regions(void) +{ + return zx_el1_regions; +} + + +uint32_t zx_context_el1_regions_clamped(void) +{ + return zx_el1_regions_clamped; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_context_init Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* The state a partition that has never run is entered with. */ +/* */ +/* IT STARTS BY SAVING THE MACHINE, and that is not a shortcut -- it */ +/* is the only defensible template. A context block is restored */ +/* WHOLESALE: every banked register, every MPU region, every EL1 */ +/* system register. So whatever this function leaves in it is what */ +/* the partition's very first entry programs, and that is the one */ +/* entry where there is nothing to compare the result against. */ +/* */ +/* ZEROING IT ALL WAS THE OBVIOUS ANSWER AND IT IS WRONG. SCTLR has */ +/* RES1 bits on this architecture, and writing zero to a RES1 bit is */ +/* CONSTRAINED UNPREDICTABLE -- so a partition entered from a zeroed */ +/* block starts with a system control register no reset would ever */ +/* produce. The same argument covers CPACR, MAIR and AMAIR: their */ +/* reset values are the part's to choose and are not zero. */ +/* */ +/* What the machine holds when this is called IS the reset state -- */ +/* ZoneX's own reset path has cleared SCTLR.M, .TE, .C and .I and */ +/* touched nothing else at EL1 -- so snapshotting it gives every fresh */ +/* partition exactly the EL1 the single-partition images hand their */ +/* guest. Two partitions then start identically, which is also what */ +/* makes their later divergence attributable to the schedule. */ +/* */ +/* The REGISTERS are zeroed over the top of that snapshot, because a */ +/* guest's banked stack pointers are its own reset path's to set and */ +/* inheriting the hypervisor's idea of them would be worse than */ +/* inheriting nothing. */ +/* */ +/* THE MASKS IN SPSR ARE THE ONES A RESET PATH EXPECTS. A, I and F */ +/* set, SVC mode, exactly as zx_el2_run_payload hands a payload the */ +/* machine -- a kernel opens its own interrupts when it has vectors */ +/* for them. PSTATE.T is left CLEAR, because the entry point is the */ +/* A32 branch at a fixed offset in the window; a guest that switches */ +/* to T32 later carries the T bit back in its own saved SPSR and this */ +/* file never has to know. */ +/* */ +/* F IS SET AND IT NO LONGER MEANS ANYTHING, which is the point of the */ +/* design: with HCR.FMO set, PSTATE.F is IGNORED at EL0 and EL1. It is */ +/* written anyway so that the value a partition starts with is the */ +/* value it would have had, rather than one chosen to depend on a */ +/* hypervisor configuration bit. */ +/* */ +/**************************************************************************/ + +void zx_context_init(ZX_GUEST_CONTEXT *context_ptr, zx_addr_t entry) +{ + uint32_t index; + + if (context_ptr == (ZX_GUEST_CONTEXT *)0) + { + return; + } + + /* The EL1 reset state, as the machine holds it right now. Everything + below overwrites the parts a fresh guest owns and leaves the rest -- + the system registers whose reset values are the part's. */ + + zx_context_save(context_ptr); + + for (index = 0U; index < 13U; index++) + { + context_ptr->zx_ctx_r[index] = 0U; + } + + for (index = 0U; index < 5U; index++) + { + context_ptr->zx_ctx_r_fiq[index] = 0U; + } + + context_ptr->zx_ctx_elr = (uint32_t)entry; + context_ptr->zx_ctx_spsr = ZX_PSR_A | ZX_PSR_I | ZX_PSR_F | ZX_MODE_SVC; + + context_ptr->zx_ctx_sp_usr = 0U; + context_ptr->zx_ctx_lr_usr = 0U; + context_ptr->zx_ctx_sp_svc = 0U; + context_ptr->zx_ctx_lr_svc = 0U; + context_ptr->zx_ctx_spsr_svc = 0U; + context_ptr->zx_ctx_sp_irq = 0U; + context_ptr->zx_ctx_lr_irq = 0U; + context_ptr->zx_ctx_spsr_irq = 0U; + context_ptr->zx_ctx_sp_abt = 0U; + context_ptr->zx_ctx_lr_abt = 0U; + context_ptr->zx_ctx_spsr_abt = 0U; + context_ptr->zx_ctx_sp_und = 0U; + context_ptr->zx_ctx_lr_und = 0U; + context_ptr->zx_ctx_spsr_und = 0U; + context_ptr->zx_ctx_sp_fiq = 0U; + context_ptr->zx_ctx_lr_fiq = 0U; + context_ptr->zx_ctx_spsr_fiq = 0U; + + /* SCTLR, CPACR, MAIR, AMAIR, TPIDR*, CONTEXTIDR, PRSELR and the whole + EL1 MPU come from the snapshot above and are deliberately NOT + overwritten here. See the note on RES1 bits in this function's + description; the short version is that zero is not a reset value. + + The TIMER is zeroed, because it is the one piece of EL1 state a + fresh partition must not inherit: an armed comparator belongs to + whoever armed it, and a guest re-entered with somebody else's + deadline takes a tick it never scheduled as its first act. */ + + context_ptr->zx_ctx_cntv_ctl = 0U; + context_ptr->zx_ctx_cntv_cval = 0U; + + context_ptr->zx_ctx_cntvoff = 0U; + context_ptr->zx_ctx_suspended_at = 0U; + context_ptr->zx_ctx_resumed_at = 0U; + context_ptr->zx_ctx_virtual_at_stop = 0U; + context_ptr->zx_ctx_time_on_core = 0U; + + context_ptr->zx_ctx_entries = 0U; + context_ptr->zx_ctx_preemptions = 0U; + context_ptr->zx_ctx_started = 0U; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_context_save Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* Everything a partition owns that the vector did not have to take. */ +/* */ +/* THE ORDER IS SMALLEST-FIRST AND IT IS NOT ARBITRARY. The banked */ +/* registers and the system registers are a fixed handful; the EL1 MPU */ +/* is twenty regions and dominates. Putting the MPU last means the */ +/* breakdown a measurement wants -- "how much of the switch is the */ +/* region set" -- is the difference between two counter readings taken */ +/* around one contiguous block, rather than a sum of fragments. */ +/* */ +/* THE GUEST'S TIMER IS SAVED AND THEN STOPPED, in that order, and */ +/* both halves matter. Saved, because CNTV_CVAL is a deadline in the */ +/* partition's OWN time and a switch that dropped it would hand the */ +/* next partition this one's deadline. Stopped, because the generic */ +/* timer asserts a LEVEL: a comparator left armed and expired holds the */ +/* PPI asserted at the GIC, and INTID 27 is the same INTID the next */ +/* partition is about to be given. The incoming partition would take */ +/* an interrupt its own kernel never armed, at an instant it did not */ +/* choose, and would service it perfectly correctly. */ +/* */ +/**************************************************************************/ + +void zx_context_save(ZX_GUEST_CONTEXT *context_ptr) +{ + uint32_t control; + uint32_t low; + uint32_t high; + + if (context_ptr == (ZX_GUEST_CONTEXT *)0) + { + return; + } + + /* ---- banked SPs, LRs and SPSRs ---------------------------------- */ + + ZX_SAVE_BANKED(zx_ctx_sp_usr, SP_usr); + ZX_SAVE_BANKED(zx_ctx_lr_usr, LR_usr); + ZX_SAVE_BANKED(zx_ctx_sp_svc, SP_svc); + ZX_SAVE_BANKED(zx_ctx_lr_svc, LR_svc); + ZX_SAVE_BANKED(zx_ctx_spsr_svc, SPSR_svc); + ZX_SAVE_BANKED(zx_ctx_sp_irq, SP_irq); + ZX_SAVE_BANKED(zx_ctx_lr_irq, LR_irq); + ZX_SAVE_BANKED(zx_ctx_spsr_irq, SPSR_irq); + ZX_SAVE_BANKED(zx_ctx_sp_abt, SP_abt); + ZX_SAVE_BANKED(zx_ctx_lr_abt, LR_abt); + ZX_SAVE_BANKED(zx_ctx_spsr_abt, SPSR_abt); + ZX_SAVE_BANKED(zx_ctx_sp_und, SP_und); + ZX_SAVE_BANKED(zx_ctx_lr_und, LR_und); + ZX_SAVE_BANKED(zx_ctx_spsr_und, SPSR_und); + ZX_SAVE_BANKED(zx_ctx_sp_fiq, SP_fiq); + ZX_SAVE_BANKED(zx_ctx_lr_fiq, LR_fiq); + ZX_SAVE_BANKED(zx_ctx_spsr_fiq, SPSR_fiq); + + /* FIQ MODE BANKS r8 TO r12 AS WELL, and they are the registers most + easily forgotten because every other mode shares them. A partition + whose kernel takes an FIQ would be using them -- and a partition here + cannot take one, because Group 0 goes to EL2 and a guest is granted + nothing in it. They are saved anyway: "the guest cannot be using + these" is a claim about a hypervisor configuration bit, and a context + switch that is correct only while one bit stays set is a trap for + whoever changes it. */ + + ZX_SAVE_BANKED(zx_ctx_r_fiq[0], R8_fiq); + ZX_SAVE_BANKED(zx_ctx_r_fiq[1], R9_fiq); + ZX_SAVE_BANKED(zx_ctx_r_fiq[2], R10_fiq); + ZX_SAVE_BANKED(zx_ctx_r_fiq[3], R11_fiq); + ZX_SAVE_BANKED(zx_ctx_r_fiq[4], R12_fiq); + + /* ---- EL1 system registers --------------------------------------- */ + + __asm__ volatile("mrc p15, 0, %0, c1, c0, 0" + : "=r"(context_ptr->zx_ctx_sctlr)); /* SCTLR */ + __asm__ volatile("mrc p15, 0, %0, c12, c0, 0" + : "=r"(context_ptr->zx_ctx_vbar)); /* VBAR */ + __asm__ volatile("mrc p15, 0, %0, c1, c0, 2" + : "=r"(context_ptr->zx_ctx_cpacr)); /* CPACR */ + __asm__ volatile("mrc p15, 0, %0, c13, c0, 1" + : "=r"(context_ptr->zx_ctx_contextidr)); + __asm__ volatile("mrc p15, 0, %0, c13, c0, 2" + : "=r"(context_ptr->zx_ctx_tpidrurw)); + __asm__ volatile("mrc p15, 0, %0, c13, c0, 3" + : "=r"(context_ptr->zx_ctx_tpidruro)); + __asm__ volatile("mrc p15, 0, %0, c13, c0, 4" + : "=r"(context_ptr->zx_ctx_tpidrprw)); + __asm__ volatile("mrc p15, 0, %0, c10, c2, 0" + : "=r"(context_ptr->zx_ctx_mair0)); + __asm__ volatile("mrc p15, 0, %0, c10, c2, 1" + : "=r"(context_ptr->zx_ctx_mair1)); + __asm__ volatile("mrc p15, 0, %0, c10, c3, 0" + : "=r"(context_ptr->zx_ctx_amair0)); + __asm__ volatile("mrc p15, 0, %0, c10, c3, 1" + : "=r"(context_ptr->zx_ctx_amair1)); + + /* ---- the guest's own virtual timer ------------------------------ */ + + __asm__ volatile("mrc p15, 0, %0, c14, c3, 1" : "=r"(control)); + __asm__ volatile("mrrc p15, 3, %0, %1, c14" + : "=r"(low), "=r"(high)); /* CNTV_CVAL */ + + context_ptr->zx_ctx_cntv_ctl = control; + context_ptr->zx_ctx_cntv_cval = ((uint64_t)high << 32) | (uint64_t)low; + + /* And DISARMED, both bits, before the next partition can inherit an + asserted level on the INTID it is about to be given. */ + + control &= ~(uint32_t)(ZX_CNTV_CTL_ENABLE | ZX_CNTV_CTL_IMASK); + __asm__ volatile("mcr p15, 0, %0, c14, c3, 1" + : : "r"(control) : "memory"); + __asm__ volatile("isb" ::: "memory"); + + /* ---- the EL1 MPU: the dominant cost ----------------------------- */ + + zx_context_save_mpu(context_ptr); +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_context_save_mpu Cortex-R52 */ +/* zx_context_restore_mpu */ +/* */ +/* DESCRIPTION */ +/* */ +/* The EL1 MPU region set, on its own. */ +/* */ +/* SEPARATE FUNCTIONS BECAUSE THE MEASUREMENT NEEDS THEM SEPARATE. */ +/* "How much of a partition switch is the guest's MPU" is the question */ +/* this step exists to answer, and the honest way to answer it is to */ +/* time the same code the switch runs rather than a copy of it written */ +/* for the measurement. A second loop maintained beside the first */ +/* would agree until the day it did not, and the day it did not is the */ +/* day the published number stops describing the shipped switch. */ +/* */ +/* The cost of the split is one call each way on the switch path. It */ +/* is included in every figure this suite prints, because it is */ +/* included in every switch it performs. */ +/* */ +/* TWENTY REGIONS ON THE S32Z280, TWO REGISTERS EACH, selected through */ +/* a third. The trip count comes from MPUIR, read once at boot, so it */ +/* depends on the PART and never on anything a guest did. */ +/* */ +/**************************************************************************/ + +void zx_context_save_mpu(ZX_GUEST_CONTEXT *context_ptr) +{ + uint32_t index; + + __asm__ volatile("mrc p15, 0, %0, c6, c2, 1" + : "=r"(context_ptr->zx_ctx_prselr)); /* PRSELR */ + + for (index = 0U; index < zx_el1_regions; index++) + { + __asm__ volatile("mcr p15, 0, %0, c6, c2, 1" + : : "r"(index) : "memory"); + __asm__ volatile("isb"); + __asm__ volatile("mrc p15, 0, %0, c6, c3, 0" + : "=r"(context_ptr->zx_ctx_prbar[index])); + __asm__ volatile("mrc p15, 0, %0, c6, c3, 1" + : "=r"(context_ptr->zx_ctx_prlar[index])); + } +} + + +void zx_context_restore_mpu(const ZX_GUEST_CONTEXT *context_ptr) +{ + uint32_t index; + + for (index = 0U; index < zx_el1_regions; index++) + { + __asm__ volatile("mcr p15, 0, %0, c6, c2, 1" + : : "r"(index) : "memory"); + __asm__ volatile("isb"); + __asm__ volatile("mcr p15, 0, %0, c6, c3, 0" + : : "r"(context_ptr->zx_ctx_prbar[index]) + : "memory"); + __asm__ volatile("mcr p15, 0, %0, c6, c3, 1" + : : "r"(context_ptr->zx_ctx_prlar[index]) + : "memory"); + } + + __asm__ volatile("mcr p15, 0, %0, c6, c2, 1" + : : "r"(context_ptr->zx_ctx_prselr) : "memory"); +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_context_restore Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* The mirror, and the ORDER IS NOT THE MIRROR. */ +/* */ +/* The EL1 MPU regions go back BEFORE SCTLR, so that the bit which */ +/* turns the guest's own MPU on is written when the regions it governs */ +/* are already the incoming partition's. Written the other way round */ +/* there is a window in which stage 1 is enabled over the OUTGOING */ +/* partition's region set -- harmless today, because no EL1 access */ +/* happens between here and the ERET, and exactly the kind of ordering */ +/* that stops being harmless when somebody adds one. */ +/* */ +/* ONE BARRIER PAIR, AT THE END, NOT ONE PER REGION. The barriers */ +/* dominate: AR2 measured 434-470 cycles for a region written directly */ +/* at EL1 with its own barriers, and most of that was the closing */ +/* DSB/ISB rather than the write. Forty region writes with forty */ +/* barrier pairs would be forty times a cost that only has to be paid */ +/* once, because nothing between the first write and the ERET depends */ +/* on the new permissions. The ISB before the ERET is what makes them */ +/* take effect, and there is exactly one. */ +/* */ +/* The PRSELR write inside the loop is different and does need its ISB: */ +/* the very next instruction reads PRBAR THROUGH it, so the selection */ +/* has to be in effect. That is a dependency within the loop, not a */ +/* dependency on the region set, and it is the one barrier that cannot */ +/* be hoisted out. */ +/* */ +/**************************************************************************/ + +void zx_context_restore(const ZX_GUEST_CONTEXT *context_ptr) +{ + uint32_t low; + uint32_t high; + + if (context_ptr == (const ZX_GUEST_CONTEXT *)0) + { + return; + } + + /* ---- the EL1 MPU, first ----------------------------------------- */ + + zx_context_restore_mpu(context_ptr); + + /* ---- EL1 system registers --------------------------------------- */ + + __asm__ volatile("mcr p15, 0, %0, c12, c0, 0" + : : "r"(context_ptr->zx_ctx_vbar) : "memory"); + __asm__ volatile("mcr p15, 0, %0, c1, c0, 2" + : : "r"(context_ptr->zx_ctx_cpacr) : "memory"); + __asm__ volatile("mcr p15, 0, %0, c13, c0, 1" + : : "r"(context_ptr->zx_ctx_contextidr) : "memory"); + __asm__ volatile("mcr p15, 0, %0, c13, c0, 2" + : : "r"(context_ptr->zx_ctx_tpidrurw) : "memory"); + __asm__ volatile("mcr p15, 0, %0, c13, c0, 3" + : : "r"(context_ptr->zx_ctx_tpidruro) : "memory"); + __asm__ volatile("mcr p15, 0, %0, c13, c0, 4" + : : "r"(context_ptr->zx_ctx_tpidrprw) : "memory"); + __asm__ volatile("mcr p15, 0, %0, c10, c2, 0" + : : "r"(context_ptr->zx_ctx_mair0) : "memory"); + __asm__ volatile("mcr p15, 0, %0, c10, c2, 1" + : : "r"(context_ptr->zx_ctx_mair1) : "memory"); + __asm__ volatile("mcr p15, 0, %0, c10, c3, 0" + : : "r"(context_ptr->zx_ctx_amair0) : "memory"); + __asm__ volatile("mcr p15, 0, %0, c10, c3, 1" + : : "r"(context_ptr->zx_ctx_amair1) : "memory"); + + /* SCTLR LAST of the system registers, because it carries M -- the bit + that makes every region written above start being consulted. */ + + __asm__ volatile("mcr p15, 0, %0, c1, c0, 0" + : : "r"(context_ptr->zx_ctx_sctlr) : "memory"); + + /* ---- banked SPs, LRs and SPSRs ---------------------------------- */ + + ZX_LOAD_BANKED(zx_ctx_sp_usr, SP_usr); + ZX_LOAD_BANKED(zx_ctx_lr_usr, LR_usr); + ZX_LOAD_BANKED(zx_ctx_sp_svc, SP_svc); + ZX_LOAD_BANKED(zx_ctx_lr_svc, LR_svc); + ZX_LOAD_BANKED(zx_ctx_spsr_svc, SPSR_svc); + ZX_LOAD_BANKED(zx_ctx_sp_irq, SP_irq); + ZX_LOAD_BANKED(zx_ctx_lr_irq, LR_irq); + ZX_LOAD_BANKED(zx_ctx_spsr_irq, SPSR_irq); + ZX_LOAD_BANKED(zx_ctx_sp_abt, SP_abt); + ZX_LOAD_BANKED(zx_ctx_lr_abt, LR_abt); + ZX_LOAD_BANKED(zx_ctx_spsr_abt, SPSR_abt); + ZX_LOAD_BANKED(zx_ctx_sp_und, SP_und); + ZX_LOAD_BANKED(zx_ctx_lr_und, LR_und); + ZX_LOAD_BANKED(zx_ctx_spsr_und, SPSR_und); + ZX_LOAD_BANKED(zx_ctx_sp_fiq, SP_fiq); + ZX_LOAD_BANKED(zx_ctx_lr_fiq, LR_fiq); + ZX_LOAD_BANKED(zx_ctx_spsr_fiq, SPSR_fiq); + ZX_LOAD_BANKED(zx_ctx_r_fiq[0], R8_fiq); + ZX_LOAD_BANKED(zx_ctx_r_fiq[1], R9_fiq); + ZX_LOAD_BANKED(zx_ctx_r_fiq[2], R10_fiq); + ZX_LOAD_BANKED(zx_ctx_r_fiq[3], R11_fiq); + ZX_LOAD_BANKED(zx_ctx_r_fiq[4], R12_fiq); + + /* ---- and the guest's own virtual timer, LAST -------------------- */ + + /* THE COMPARATOR BEFORE THE CONTROL, because writing CNTV_CTL.ENABLE + is what makes the comparison start being made. Written the other + way round, the timer is enabled for the few cycles it takes to write + CVAL while the comparator still holds the OUTGOING partition's + deadline -- which is in this partition's past, so the level asserts, + and the incoming guest takes a tick before its own deadline as its + first act. */ + + low = (uint32_t)(context_ptr->zx_ctx_cntv_cval & 0xFFFFFFFFU); + high = (uint32_t)(context_ptr->zx_ctx_cntv_cval >> 32); + + __asm__ volatile("mcrr p15, 3, %0, %1, c14" + : : "r"(low), "r"(high) : "memory"); + __asm__ volatile("mcr p15, 0, %0, c14, c3, 1" + : : "r"(context_ptr->zx_ctx_cntv_ctl) : "memory"); + + /* ONE barrier pair, for everything above. See the note in the block + comment: the ISB is what lets the ERET's target be fetched under the + incoming partition's permissions. */ + + __asm__ volatile("dsb" ::: "memory"); + __asm__ volatile("isb" ::: "memory"); +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_context_time_reset Cortex-R52 */ +/* zx_context_time_suspend */ +/* zx_context_time_resume */ +/* */ +/* DESCRIPTION */ +/* */ +/* CNTVOFF, PER PARTITION, which is the whole of temporal partitioning */ +/* as a partition can observe it. */ +/* */ +/* A partition reads time through the virtual counter, which is the */ +/* physical one MINUS CNTVOFF, and CNTVOFF is writable only at EL2. */ +/* With one partition the gap being closed was the hypervisor's own */ +/* work between two excursions. With two it is THE OTHER PARTITION'S */ +/* WINDOW, and that is what makes the two clocks independent rather */ +/* than merely offset: each partition's own time advances by exactly */ +/* the counts it spent on the core and by none of the counts it did */ +/* not. */ +/* */ +/* THE OFFSET IS IN THE CONTEXT BLOCK AND NOT IN A REGISTER BETWEEN */ +/* WINDOWS. There is one CNTVOFF and there are several partitions, so */ +/* the register holds the running partition's value and the others' */ +/* live here. suspend takes it out, resume puts it back with the */ +/* elapsed interval added. */ +/* */ +/* WHERE THE MEASUREMENT HAS TO BE TAKEN, because the obvious place is */ +/* wrong. CNTVOFF is a STATIC offset: while a partition is suspended */ +/* the physical counter moves and the offset does not, so the */ +/* subtraction drifts for exactly as long as the partition is away. */ +/* What closes the gap is the write on RESUME. Sampling the virtual */ +/* counter anywhere in between measures the drift and not the */ +/* correction, and looks precisely like a broken freeze. The two */ +/* instants recorded below are the ones the MECHANISM took, so a check */ +/* built from them is not measuring the cost of measuring -- which on */ +/* the S32Z280 is 93 counts of real time per handful of CNTPCT reads. */ +/* */ +/**************************************************************************/ + +static uint64_t zx_read_cntvoff_reg(void) +{ + uint32_t low; + uint32_t high; + + __asm__ volatile("mrrc p15, 4, %0, %1, c14" : "=r"(low), "=r"(high)); + + return ((uint64_t)high << 32) | (uint64_t)low; +} + + +static void zx_write_cntvoff_reg(uint64_t value) +{ + uint32_t low = (uint32_t)(value & 0xFFFFFFFFU); + uint32_t high = (uint32_t)(value >> 32); + + __asm__ volatile("mcrr p15, 4, %0, %1, c14" + : : "r"(low), "r"(high) : "memory"); + __asm__ volatile("isb" ::: "memory"); +} + + +void zx_context_time_reset(ZX_GUEST_CONTEXT *context_ptr) +{ + uint64_t now; + + if (context_ptr == (ZX_GUEST_CONTEXT *)0) + { + return; + } + + now = zx_read_cntpct(); + + /* Its own clock starts at zero, which is what makes two runs of one + guest comparable at all: a partition whose time began wherever the + board's counter happened to be would report a different number every + power cycle. */ + + context_ptr->zx_ctx_cntvoff = now; + context_ptr->zx_ctx_suspended_at = now; + context_ptr->zx_ctx_resumed_at = now; + context_ptr->zx_ctx_virtual_at_stop = 0U; + context_ptr->zx_ctx_time_on_core = 0U; +} + + +void zx_context_time_suspend(ZX_GUEST_CONTEXT *context_ptr) +{ + uint64_t now; + uint64_t offset; + + if (context_ptr == (ZX_GUEST_CONTEXT *)0) + { + return; + } + + now = zx_read_cntpct(); + offset = zx_read_cntvoff_reg(); + + context_ptr->zx_ctx_cntvoff = offset; + context_ptr->zx_ctx_suspended_at = now; + context_ptr->zx_ctx_virtual_at_stop = now - offset; + + /* HOW LONG THIS PARTITION HAS ACTUALLY HAD THE CORE, accumulated across + every window it has been given. It is the number the whole temporal + claim reduces to: a guest's own clock must equal this and nothing + else, and a guest that can see a larger number has seen its + neighbour's time. Accumulated here because this is the only place + that knows both ends of a window. */ + + if (now > context_ptr->zx_ctx_resumed_at) + { + context_ptr->zx_ctx_time_on_core += + now - context_ptr->zx_ctx_resumed_at; + } +} + + +void zx_context_time_resume(ZX_GUEST_CONTEXT *context_ptr) +{ + uint64_t now; + uint64_t offset; + + if (context_ptr == (ZX_GUEST_CONTEXT *)0) + { + return; + } + + now = zx_read_cntpct(); + offset = context_ptr->zx_ctx_cntvoff; + + /* The elapsed interval is ADDED rather than the offset being recomputed, + because "how much time has this partition been given" is cumulative. + Recomputing it would silently reset the partition's clock on every + entry -- which looks exactly like a working freeze and is in fact a + partition whose time never advances at all. */ + + if (now > context_ptr->zx_ctx_suspended_at) + { + offset += now - context_ptr->zx_ctx_suspended_at; + } + + zx_write_cntvoff_reg(offset); + + context_ptr->zx_ctx_cntvoff = offset; + context_ptr->zx_ctx_resumed_at = now; + context_ptr->zx_ctx_suspended_at = now; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_context_report Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* One partition's context, printed. */ +/* */ +/* The entry and preemption counts are the pair that says what */ +/* happened without a log to read: a partition entered eight times and */ +/* preempted eight times ran out its window every time, and one */ +/* entered eight times and preempted twice yielded six of them. Those */ +/* are different systems and a single "it ran" would call them the */ +/* same. */ +/* */ +/**************************************************************************/ + +void zx_context_report(const ZX_GUEST_CONTEXT *context_ptr, + const char *name_ptr) +{ + if (context_ptr == (const ZX_GUEST_CONTEXT *)0) + { + return; + } + + zx_console_puts(" context: "); + zx_console_puts((name_ptr != (const char *)0) ? name_ptr : "(unnamed)"); + zx_console_puts("\n"); + + zx_console_puts(" entries "); + zx_console_putdec(context_ptr->zx_ctx_entries); + zx_console_puts(", preempted at a boundary "); + zx_console_putdec(context_ptr->zx_ctx_preemptions); + zx_console_puts("\n"); + + zx_console_puts(" resumed at PC "); + zx_console_puthex(context_ptr->zx_ctx_elr); + zx_console_puts(", SPSR "); + zx_console_puthex(context_ptr->zx_ctx_spsr); + zx_console_puts("\n"); + + zx_console_puts(" CNTVOFF "); + zx_console_puthex((uint32_t)(context_ptr->zx_ctx_cntvoff & 0xFFFFFFFFU)); + zx_console_puts(", counts on the core "); + zx_console_putdec((uint32_t)(context_ptr->zx_ctx_time_on_core + & 0xFFFFFFFFU)); + zx_console_puts("\n"); + + zx_console_puts(" its own clock when last stopped "); + zx_console_putdec((uint32_t)(context_ptr->zx_ctx_virtual_at_stop + & 0xFFFFFFFFU)); + zx_console_puts("\n"); +} diff --git a/platform/cortex_r52/src/zx_frame.c b/platform/cortex_r52/src/zx_frame.c new file mode 100644 index 0000000..19ecf08 --- /dev/null +++ b/platform/cortex_r52/src/zx_frame.c @@ -0,0 +1,955 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_frame.c Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* The major frame: the loop that runs a static schedule, and the */ +/* boundary handler that ends one partition's window and begins the */ +/* next one's. */ +/* */ +/* This is the file that makes ZoneX a hypervisor rather than a */ +/* loader. Everything under it was already true with one partition -- */ +/* a guest confined by stage 2, granted an interrupt, with a clock the */ +/* hypervisor freezes. What is here is the thing one partition cannot */ +/* demonstrate: that a partition's window ENDS whether the partition */ +/* agrees or not, and that the next one resumes exactly where it was. */ +/* */ +/* THE TWO PATHS, AND WHY THEY ARE NOT THE SAME PATH */ +/* */ +/* A window can end in two ways and they are genuinely different */ +/* events, so they are handled in different places: */ +/* */ +/* THE BOUNDARY, zx_el2_window_boundary. An FIQ, taken at EL2 */ +/* because HCR.FMO routes it there, regardless of what the partition */ +/* had masked. It never returns to the hypervisor's C: it saves the */ +/* outgoing partition, restores the incoming one, and ERETs from */ +/* inside the exception path. A boundary that came back to C and */ +/* was re-entered would put a return, a dispatch and a call on the */ +/* path whose cost is the subject of this whole step. */ +/* */ +/* A GUEST LEAVING EARLY, in zx_frame_run. A yield or a fault comes */ +/* back through the ordinary resume path, and the hypervisor then */ +/* has a decision to make rather than a switch to perform: this */ +/* partition is done, its window is not, and neither fact may be */ +/* allowed to disturb the schedule. */ +/* */ +/* Both use the same two helpers to leave and to enter, so there is one */ +/* definition of what a partition switch IS and two callers of it. */ +/* */ +/* WHAT AN IDLE PARTITION COSTS, AND WHY IT IS NOT A BUG */ +/* */ +/* A partition that yields burns the rest of its window. The core sits */ +/* at EL2 watching a counter until the boundary it would have been */ +/* preempted at, and then the frame carries on. */ +/* */ +/* That is not a missing optimisation. Handing the remainder to the */ +/* next partition -- which is what trapping WFI would buy -- makes one */ +/* partition's start time depend on its neighbour's behaviour, and the */ +/* independence of those two things is the entire purchase of temporal */ +/* partitioning. A later phase may offer it as an option; Phase 0 */ +/* declines it, and says so where the code is. */ +/* */ +/* MISRA C:2012 deviations (justified) */ +/* */ +/* Rule 8.9 -- the frame pointer below is file-scope because the */ +/* boundary handler is reached from a VECTOR, which is called by */ +/* hardware and can be passed nothing. */ +/* */ +/**************************************************************************/ + +#include "zx_port.h" +#include "zx_guest_console.h" + +/* THE FRAME THE BOUNDARY HANDLER IS RUNNING, and the partition it is + running. Both are file-scope for the same reason: an exception arrives + with nothing but the machine, so a vector reaches its state by name or + not at all. zx_el2_current_context is named directly by zx_context.S -- + it is what the capture stores through -- which is why it is declared in + zx_port.h rather than hidden here. */ + +ZX_GUEST_CONTEXT *zx_el2_current_context; +uint32_t zx_el2_frame_result; + +static ZX_FRAME *zx_frame_active; + +/* The priority the hypervisor's own tick is given. + * + * NUMERICALLY LOWER IS HIGHER PRIORITY, and both targets implement only the + * top FIVE bits of the byte -- so two values differing below bit 3 are the + * same priority to the hardware, and equal priorities do not preempt. + * + * 0x20 against the partitions' 0xA0 leaves four implemented steps between + * them, which is not a margin that needs to be large: it needs to be + * non-zero and to survive the bits the part does not implement. A tick + * that could not preempt a partition's own timer handler would let a guest + * defer the end of its window by taking a long interrupt, which is the + * masking hole in a different disguise. */ + +#define ZX_HYP_TICK_PRIORITY 0x20U + +/* How many boundaries may be missed before the run is stopped and says so. + * + * A missed deadline means a window was shorter than its own partition + * switch: the comparator is armed with a count already in the past, the + * interrupt is pending again immediately, and the frame keeps turning with + * every window longer than declared. Nothing breaks, which is exactly the + * problem -- a run full of them looks like a working demonstrator, only + * slower, and nobody times a demonstrator. + * + * A handful rather than one, because the FIRST boundary of a run legitimately + * competes with a cold instruction path, and a limit of one would turn a + * cold start into a failure. A handful rather than many, because the point + * is to stop rather than to accumulate. */ + +#define ZX_FRAME_MISS_LIMIT 8U + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_frame_configure Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* Records what the boundary handler will need and arms nothing. */ +/* */ +/* Separate from starting the frame so that a caller can build the */ +/* whole system -- manifest, layout, contexts, guests loaded -- and */ +/* then commit to running it in one place. A configure that also */ +/* started would make "everything is ready" and "we are now */ +/* time-partitioned" the same instant, and the second is the one that */ +/* must not happen before the first is checked. */ +/* */ +/**************************************************************************/ + +void zx_frame_configure(ZX_FRAME *frame_ptr, + ZX_SCHEDULE *schedule_ptr, + ZX_GUEST_CONTEXT *contexts_ptr, + const ZX_MM_LAYOUT *layout_ptr, + const ZX_MANIFEST *manifest_ptr, + UINT partition_count) +{ + if ((frame_ptr == (ZX_FRAME *)0) + || (schedule_ptr == (ZX_SCHEDULE *)0) + || (contexts_ptr == (ZX_GUEST_CONTEXT *)0) + || (layout_ptr == (const ZX_MM_LAYOUT *)0)) + { + return; + } + + frame_ptr->zx_frame_schedule = schedule_ptr; + frame_ptr->zx_frame_contexts = contexts_ptr; + frame_ptr->zx_frame_layout = layout_ptr; + frame_ptr->zx_frame_manifest = manifest_ptr; + frame_ptr->zx_frame_partitions = partition_count; + frame_ptr->zx_frame_stopped_mask = 0U; + frame_ptr->zx_frame_result = ZX_RUN_FRAME_DONE; + frame_ptr->zx_frame_switches = 0U; + frame_ptr->zx_frame_timed = 0U; + frame_ptr->zx_frame_switch_min = 0xFFFFFFFFU; + frame_ptr->zx_frame_switch_max = 0U; + frame_ptr->zx_frame_switch_last = 0U; + frame_ptr->zx_frame_switch_total = 0U; + frame_ptr->zx_frame_spurious = 0U; + frame_ptr->zx_frame_stopped_index = ZX_MANIFEST_NO_INDEX; + frame_ptr->zx_frame_stop_outcome = ZX_RUN_FRAME_DONE; + + zx_frame_active = frame_ptr; + zx_el2_current_context = (ZX_GUEST_CONTEXT *)0; + zx_el2_frame_result = ZX_RUN_FRAME_DONE; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_frame_console_release Cortex-R52 */ +/* zx_frame_console_claim */ +/* */ +/* DESCRIPTION */ +/* */ +/* Whose console lines are these? */ +/* */ +/* The tag on a forwarded character comes from the partition ZoneX */ +/* SCHEDULED and never from the guest, or a partition could claim to */ +/* be its neighbour and every line of a captured log would be evidence */ +/* of nothing. With one partition that was one attach around the whole */ +/* excursion; with a frame it has to follow the frame, because the */ +/* partition that owns the console changes several times a second. */ +/* */ +/* THEY ARE CALLED FROM OUTSIDE THE MEASURED SPAN, DELIBERATELY, and */ +/* that is not a way of making the number look better. Releasing the */ +/* console CLOSES A PARTIAL LINE, which on silicon is characters */ +/* through a polled UART -- thousands of cycles, and only on the */ +/* switches that happen to leave a partition mid-sentence. Left inside */ +/* the measurement it made the published switch cost depend on what a */ +/* guest had been printing: min 5,928 cycles against max 15,490 on the */ +/* S32Z280, a spread of 2.6x where the model showed ten cycles. */ +/* */ +/* A partition switch in a product has no console in it at all. The */ +/* tag is a demonstration facility, so it is outside the number that */ +/* describes the switch -- and the fact that it is outside is stated */ +/* here rather than left for a reader to discover from the code. */ +/* */ +/**************************************************************************/ + +static void zx_frame_console_release(const ZX_FRAME *frame_ptr) +{ + if (frame_ptr->zx_frame_manifest != (const ZX_MANIFEST *)0) + { + zx_guest_console_detach(); + } +} + + +static void zx_frame_console_claim(const ZX_FRAME *frame_ptr, UINT index) +{ + if (frame_ptr->zx_frame_manifest != (const ZX_MANIFEST *)0) + { + const ZX_PARTITION *declaration = + &frame_ptr->zx_frame_manifest->zx_manifest_partitions[index]; + + zx_guest_console_attach(declaration->zx_partition_id, + declaration->zx_partition_name); + } +} + + +/**************************************************************************/ +/* zx_frame_stopped -- has this partition left its window for good? */ +/**************************************************************************/ + +static uint32_t zx_frame_stopped(const ZX_FRAME *frame_ptr, UINT index) +{ + return ((frame_ptr->zx_frame_stopped_mask + & ((uint32_t)1U << index)) != 0U) ? 1U : 0U; +} + + +static uint32_t zx_frame_all_stopped(const ZX_FRAME *frame_ptr) +{ + uint32_t all = ((uint32_t)1U << frame_ptr->zx_frame_partitions) - 1U; + + return ((frame_ptr->zx_frame_stopped_mask & all) == all) ? 1U : 0U; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_frame_leave Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* Take the core from whichever partition has it. */ +/* */ +/* The fifteen shared words are already in the context block -- the */ +/* vector put them there, before any C could destroy them -- so this is */ +/* everything else: the banked registers, the EL1 system registers, the */ +/* guest's virtual timer (saved and then disarmed), the whole EL1 MPU */ +/* set, and the freeze. */ +/* */ +/* THE FREEZE COMES LAST, and that ordering is the measurement's. It */ +/* records the physical count at the instant the partition stopped, and */ +/* everything before it is work the partition is still, correctly, */ +/* being charged for: saving a guest's registers happens on the */ +/* outgoing partition's time because it is the outgoing partition that */ +/* made it necessary. Freezing first would charge that work to whoever */ +/* comes next, which is precisely the coupling a static frame removes. */ +/* */ +/**************************************************************************/ + +static void zx_frame_leave(void) +{ + ZX_GUEST_CONTEXT *out = zx_el2_current_context; + + if (out == (ZX_GUEST_CONTEXT *)0) + { + return; + } + + zx_context_save(out); + zx_context_time_suspend(out); + + zx_el2_current_context = (ZX_GUEST_CONTEXT *)0; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_frame_enter Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* Give the core to a partition, up to but not including the ERET. */ +/* */ +/* THE ORDER IS THE WHOLE FUNCTION: */ +/* */ +/* 1 the stage-2 region set, ONE HPRENR write. First, because */ +/* everything after it is a write the incoming partition will */ +/* depend on and nothing after it reads the outgoing partition's */ +/* memory. */ +/* 2 its EL1 state, including its own MPU. */ +/* 3 its clock, unfrozen: CNTVOFF advanced by exactly the interval */ +/* it was away, so from inside no time passed. */ +/* 4 the next boundary, as an ABSOLUTE deadline. */ +/* */ +/* THE DEADLINE IS ARMED LAST AND THAT IS DELIBERATE. Everything above */ +/* it is the switch, and the switch happens on the OUTGOING partition's */ +/* time -- the boundary that ends the incoming window was computed from */ +/* the frame's epoch before any of this ran, so the incoming partition */ +/* gets the window it was promised and not that window minus a switch. */ +/* */ +/* Returns zero when the deadline it armed had already gone by. The */ +/* caller records it and decides; this function does not, because the */ +/* schedule that has to count it belongs to the caller. */ +/* */ +/**************************************************************************/ + +static uint32_t zx_frame_enter(ZX_FRAME *frame_ptr, UINT index) +{ + ZX_GUEST_CONTEXT *in = &frame_ptr->zx_frame_contexts[index]; + + zx_stage2_enable_set(zx_mm_partition_mask(frame_ptr->zx_frame_layout, + index)); + + zx_context_restore(in); + zx_context_time_resume(in); + + in->zx_ctx_entries++; + in->zx_ctx_started = 1U; + + zx_el2_current_context = in; + + return zx_el2_hyp_timer_arm( + zx_schedule_deadline(frame_ptr->zx_frame_schedule)); +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_frame_next Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* Advance the schedule to the next window that has a partition */ +/* willing to run in it, SPENDING every window it passes over. */ +/* */ +/* A partition that has stopped still owns its slot. The core sits at */ +/* EL2 watching the counter until the boundary that window would have */ +/* ended at, and only then does the schedule move on. Giving that time */ +/* to the next partition would be the throughput answer and it would */ +/* make one partition's start time a function of another's behaviour, */ +/* which is the coupling temporal partitioning is bought to remove. */ +/* */ +/* THE LOOP TERMINATES, and it is worth saying how, because a loop */ +/* inside a partition switch is the one thing a WCET argument cannot */ +/* have unbounded. Each iteration either returns or waits out one */ +/* whole window on the physical counter, so it cannot spin: it costs */ +/* real time by construction. And it is cut short by the check below */ +/* -- once every partition has stopped there is nothing left to */ +/* schedule, so the frame ends rather than idling to its limit. */ +/* */ +/* It is also NOT on the hot path. With every partition running, the */ +/* first advance returns and the loop body never executes -- which is */ +/* why the switch measurements in a healthy run are untouched by any of */ +/* this. */ +/* */ +/* Sets *burned_ptr when it waited out at least one window, so that the */ +/* caller can leave that boundary out of its TIMING. A boundary that */ +/* had to spend a dead partition's window is a switch PLUS a wait, and */ +/* a figure averaging the two describes neither: on the cross-partition */ +/* build it turned a switch of a few thousand cycles into one of three */ +/* million and reported the difference as jitter. */ +/* */ +/* Returns ZX_MANIFEST_NO_INDEX when the frame is over. */ +/* */ +/**************************************************************************/ + +static UINT zx_frame_next(ZX_FRAME *frame_ptr, uint32_t *burned_ptr) +{ + UINT index; + + *burned_ptr = 0U; + + if (zx_frame_all_stopped(frame_ptr) != 0U) + { + return ZX_MANIFEST_NO_INDEX; + } + + for (;;) + { + index = zx_schedule_advance(frame_ptr->zx_frame_schedule); + + if (index == ZX_MANIFEST_NO_INDEX) + { + return ZX_MANIFEST_NO_INDEX; + } + + if (zx_frame_stopped(frame_ptr, index) == 0U) + { + return index; + } + + /* Its window, spent where it was declared to be spent. And + REPORTED, because a caller that is timing a partition switch must + not count it: waiting out a whole window is not a switch, and a + figure that averaged the two together would describe neither. */ + + *burned_ptr = 1U; + + zx_el2_dwell_until( + zx_schedule_deadline(frame_ptr->zx_frame_schedule)); + + if (zx_frame_all_stopped(frame_ptr) != 0U) + { + return ZX_MANIFEST_NO_INDEX; + } + } +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_el2_window_boundary Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* THE PARTITION SWITCH. Called from the FIQ vector with the outgoing */ +/* partition's fifteen shared words already captured, and returning the */ +/* context the vector should ERET into. */ +/* */ +/* THE ORDER, AND WHAT EACH STEP IS PROTECTING: */ +/* */ +/* ACKNOWLEDGE FIRST. Reading ICC_IAR0 raises the running priority, */ +/* so nothing can preempt the switch itself. It also identifies */ +/* what arrived: only the hypervisor's own timer should be here, and */ +/* anything else is counted rather than acted on. */ +/* */ +/* SAVE AND FREEZE, on the outgoing partition's time, because it is */ +/* the outgoing partition that made the work necessary. */ +/* */ +/* ADVANCE, AND RE-ARM BEFORE THE END-OF-INTERRUPT. The generic */ +/* timer asserts a LEVEL: until the comparator holds a deadline in */ +/* the future the PPI is still asserted at the GIC, and an EOI given */ +/* before that makes the interrupt pending again immediately. That */ +/* is not a glitch, it is a storm -- the hypervisor would take a */ +/* boundary per instruction and no partition would ever run. */ +/* */ +/* RESTORE LAST, so the incoming partition's permissions and */ +/* registers are in force for as little time as possible before the */ +/* ERET that starts using them. */ +/* */ +/* MEASURED END TO END, in cycles, because "how long does a partition */ +/* switch take" is the question this step exists to answer and the */ +/* honest answer is the one taken from the switch that actually ran. */ +/* Min and max are kept as well as the total: a safety audience reads */ +/* the maximum, and a mean quoted alone hides exactly the excursion */ +/* they are asking about. */ +/* */ +/**************************************************************************/ + +ZX_GUEST_CONTEXT *zx_el2_window_boundary(void) +{ + ZX_FRAME *frame_ptr = zx_frame_active; + uint32_t start; + uint32_t spent; + uint32_t intid; + uint32_t burned; + UINT index; + + if (frame_ptr == (ZX_FRAME *)0) + { + zx_el2_frame_result = ZX_RUN_TRAPPED; + + return (ZX_GUEST_CONTEXT *)0; + } + + intid = zx_gic_el2_acknowledge(); + + /* The console is handed back BEFORE the measurement starts. See + zx_frame_console_release: closing a partial line is a polled-UART + write on silicon, and a switch cost that included it would depend on + what a guest had been printing. */ + + zx_frame_console_release(frame_ptr); + + start = zx_pmu_cycles(); + + if (zx_el2_hyp_timer_fired() == 0U) + { + frame_ptr->zx_frame_spurious++; + } + + if (zx_el2_current_context != (ZX_GUEST_CONTEXT *)0) + { + zx_el2_current_context->zx_ctx_preemptions++; + } + + zx_frame_leave(); + + index = zx_frame_next(frame_ptr, &burned); + + if (index == ZX_MANIFEST_NO_INDEX) + { + zx_el2_hyp_timer_stop(); + + /* Nobody owns the console now, and the hypervisor is about to + speak. Released above, and not claimed again. */ + + if (intid != ZX_INTID_SPURIOUS) + { + zx_gic_el2_end_of_interrupt(intid); + } + + frame_ptr->zx_frame_result = + (zx_schedule_is_running(frame_ptr->zx_frame_schedule) != 0U) + ? ZX_RUN_YIELDED : ZX_RUN_FRAME_DONE; + zx_el2_frame_result = frame_ptr->zx_frame_result; + + return (ZX_GUEST_CONTEXT *)0; + } + + if (zx_frame_enter(frame_ptr, index) == 0U) + { + zx_schedule_note_missed(frame_ptr->zx_frame_schedule); + } + + /* The level is deasserted now that the comparator holds a future + deadline, so the end-of-interrupt cannot re-pend it. A spurious + INTID is NOT given one: the running priority was never raised, and + dropping a priority that was never raised corrupts the GIC's + priority stack rather than merely being redundant. */ + + if (intid != ZX_INTID_SPURIOUS) + { + zx_gic_el2_end_of_interrupt(intid); + } + + spent = zx_pmu_cycles() - start; + + /* And claimed again AFTER it stops, for the same reason. */ + + zx_frame_console_claim(frame_ptr, index); + + /* A BOUNDARY THAT HAD TO BURN A DEAD PARTITION'S WINDOW IS NOT TIMED. + It is a switch plus a wait of up to a whole window, and averaging the + two together describes neither -- on the cross-partition build it + turned a switch of a few thousand cycles into one of three million + and reported the difference as jitter. The boundary still COUNTS, so + "every window ended at a boundary" stays true; only its cost is left + out, and it is left out because it is not the cost of a switch. */ + + frame_ptr->zx_frame_switches++; + + if (burned == 0U) + { + frame_ptr->zx_frame_timed++; + frame_ptr->zx_frame_switch_last = spent; + frame_ptr->zx_frame_switch_total += (uint64_t)spent; + + if (spent < frame_ptr->zx_frame_switch_min) + { + frame_ptr->zx_frame_switch_min = spent; + } + + if (spent > frame_ptr->zx_frame_switch_max) + { + frame_ptr->zx_frame_switch_max = spent; + } + } + + if (frame_ptr->zx_frame_schedule->zx_schedule_missed + >= (ULONG)ZX_FRAME_MISS_LIMIT) + { + zx_el2_hyp_timer_stop(); + frame_ptr->zx_frame_result = ZX_RUN_OVERRUN; + zx_el2_frame_result = ZX_RUN_OVERRUN; + + return (ZX_GUEST_CONTEXT *)0; + } + + return zx_el2_current_context; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_frame_run Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* The frame, from the hypervisor's side. */ +/* */ +/* It looks like a loop that runs partitions and it is not: the loop */ +/* body executes once per partition that LEAVES ITS WINDOW EARLY, which */ +/* in a healthy run is never. Every ordinary window boundary happens */ +/* inside zx_el2_enter_partition and is invisible here. */ +/* */ +/* WHAT A YIELD MEANS IN A STATIC FRAME, and it is worth being precise */ +/* because two reasonable answers are wrong. It does not mean "give */ +/* the rest of my window to somebody else" -- that couples two */ +/* partitions' timing. It does not mean "start me again next window" */ +/* either: a guest that has finished would then be re-entered at */ +/* whatever instruction followed its own hypercall, for ever. It means */ +/* the partition is DONE. Its window is spent, its slot stays in the */ +/* frame, and its neighbours are unaffected -- which is the whole */ +/* claim, demonstrated by a partition doing the least cooperative thing */ +/* available to it. */ +/* */ +/* A FAULT IS THE SAME DECISION FOR A DIFFERENT REASON. Phase 0 does */ +/* not restart a faulted partition: supervised restart is an explicitly */ +/* deferred later phase, and a hypervisor that quietly re-entered a */ +/* partition that had just violated its boundary would be doing the */ +/* easy half of it. The fault record is kept, the partition is */ +/* stopped, and the run reports it. */ +/* */ +/**************************************************************************/ + +uint32_t zx_frame_run(ZX_FRAME *frame_ptr) +{ + UINT index; + uint32_t outcome; + uint32_t burned = 0U; + + if (frame_ptr == (ZX_FRAME *)0) + { + return ZX_RUN_TRAPPED; + } + + /* THE EPOCH, taken here and nowhere else. Every boundary for the rest + of the run is one exact multiple of the tick away from this reading, + which is what makes the frame immune to the switch's own latency. + Taken immediately before the first entry, so the first window is not + the only short one in the run. */ + + zx_schedule_start(frame_ptr->zx_frame_schedule, zx_read_cntpct()); + + index = zx_schedule_current_partition(frame_ptr->zx_frame_schedule); + + if (index == ZX_MANIFEST_NO_INDEX) + { + return ZX_RUN_TRAPPED; + } + + for (;;) + { + if (zx_frame_enter(frame_ptr, index) == 0U) + { + zx_schedule_note_missed(frame_ptr->zx_frame_schedule); + } + + zx_frame_console_claim(frame_ptr, index); + + outcome = zx_el2_enter_partition(zx_el2_current_context); + + zx_frame_console_release(frame_ptr); + + /* Control is back at EL2. Either the frame ended inside the + boundary handler -- in which case it has already stopped the + timer and said why -- or the partition left its window early. */ + + if ((outcome == ZX_RUN_FRAME_DONE) || (outcome == ZX_RUN_OVERRUN)) + { + return outcome; + } + + /* A yield or a fault, inside a window that is not over. */ + + zx_el2_hyp_timer_stop(); + + index = zx_schedule_current_partition(frame_ptr->zx_frame_schedule); + + if (index == ZX_MANIFEST_NO_INDEX) + { + frame_ptr->zx_frame_result = outcome; + + return outcome; + } + + zx_frame_leave(); + + frame_ptr->zx_frame_stopped_mask |= (uint32_t)1U << index; + + /* WHICH partition and WHY, kept for the report. Only the FIRST is + recorded: a frame in which two partitions left early has stopped + being a demonstration of anything and the first departure is what + a reader has to understand before the second makes sense. */ + + if (frame_ptr->zx_frame_stopped_index == ZX_MANIFEST_NO_INDEX) + { + frame_ptr->zx_frame_stopped_index = index; + frame_ptr->zx_frame_stop_outcome = outcome; + } + + /* The rest of this partition's window, spent. */ + + zx_el2_dwell_until( + zx_schedule_deadline(frame_ptr->zx_frame_schedule)); + + index = zx_frame_next(frame_ptr, &burned); + + if (index == ZX_MANIFEST_NO_INDEX) + { + frame_ptr->zx_frame_result = + (zx_schedule_is_running(frame_ptr->zx_frame_schedule) != 0U) + ? outcome : ZX_RUN_FRAME_DONE; + + return frame_ptr->zx_frame_result; + } + } +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_frame_measure_switch Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* The switch, broken down by group, measured with the code the switch */ +/* runs. */ +/* */ +/* THREE DISCIPLINES, ALL OF THEM PAID FOR BY EARLIER MISTAKES IN THIS */ +/* SUITE: */ +/* */ +/* THE COUNTER READ IS MEASURED AND SUBTRACTED. Reading PMCCNTR costs */ +/* cycles, and a group whose real cost is small is otherwise reported */ +/* as mostly the cost of asking. */ +/* */ +/* THE TWO CONTEXTS ALTERNATE. An implementation is entitled to make */ +/* writing the value already in a register cheap, in a way a real */ +/* switch is not. The same rule was needed when the stage-2 region */ +/* mask was first measured, and it is why that figure alternates */ +/* between two masks. */ +/* */ +/* A WARM-UP ROUND IS RUN AND DISCARDED. The first execution of */ +/* anything on a real core is not comparable to the second; a pair */ +/* that differs in ORDER as well as in the thing under test measures */ +/* neither. That is not a caution, it is a result: this suite once */ +/* published stage 2 as 28% FASTER than no stage 2 on exactly that */ +/* error. */ +/* */ +/* WHAT THE NUMBERS ARE NOT. On the Armv8-R AEM FVP they are not */ +/* timing at all -- it is a functional model. They are produced there */ +/* anyway so that the measurement path is exercised on every run, */ +/* including on a machine with no board attached. */ +/* */ +/**************************************************************************/ + +void zx_frame_measure_switch(ZX_GUEST_CONTEXT *scratch_a, + ZX_GUEST_CONTEXT *scratch_b, + uint32_t mask_a, uint32_t mask_b, + uint32_t rounds, + ZX_SWITCH_COST *cost_ptr) +{ + uint32_t round; + uint32_t start; + uint32_t total; + + if ((scratch_a == (ZX_GUEST_CONTEXT *)0) + || (scratch_b == (ZX_GUEST_CONTEXT *)0) + || (cost_ptr == (ZX_SWITCH_COST *)0) + || (rounds == 0U)) + { + return; + } + + cost_ptr->zx_cost_el1_regions = zx_context_el1_regions(); + + /* BOTH SCRATCH BLOCKS ARE FILLED FROM THE MACHINE FIRST, and this is + not tidiness. The loops below alternate between them so that no + iteration restores the state already in force -- and an alternation + whose second block had never been SAVED into would restore a block of + zeros into SCTLR, the EL1 MPU and MAIR. SCTLR has RES1 bits, so zero + is not a value the architecture defines a meaning for; the + measurement would have destroyed the machine it was measuring. */ + + zx_context_save(scratch_a); + zx_context_save(scratch_b); + + /* Warm up, and throw it away. The first execution of anything on a real + core is not comparable to the second. */ + + zx_context_restore(scratch_a); + + /* What it costs to ask. Subtracted from every row below. */ + + start = zx_pmu_cycles(); + + for (round = 0U; round < rounds; round++) + { + total = zx_pmu_cycles(); + } + + total = zx_pmu_cycles() - start; + cost_ptr->zx_cost_counter_read = total / rounds; + + /* ---- the full save ---------------------------------------------- */ + + start = zx_pmu_cycles(); + + for (round = 0U; round < rounds; round++) + { + zx_context_save(((round & 1U) == 0U) ? scratch_a : scratch_b); + } + + total = (zx_pmu_cycles() - start) / rounds; + cost_ptr->zx_cost_save_full = + (total > cost_ptr->zx_cost_counter_read) + ? (total - cost_ptr->zx_cost_counter_read) : 0U; + + /* ---- the full restore ------------------------------------------- */ + + start = zx_pmu_cycles(); + + for (round = 0U; round < rounds; round++) + { + zx_context_restore(((round & 1U) == 0U) ? scratch_a : scratch_b); + } + + total = (zx_pmu_cycles() - start) / rounds; + cost_ptr->zx_cost_restore_full = + (total > cost_ptr->zx_cost_counter_read) + ? (total - cost_ptr->zx_cost_counter_read) : 0U; + + /* ---- the EL1 MPU alone, both directions ------------------------- */ + + start = zx_pmu_cycles(); + + for (round = 0U; round < rounds; round++) + { + zx_context_save_mpu(((round & 1U) == 0U) ? scratch_a : scratch_b); + } + + total = (zx_pmu_cycles() - start) / rounds; + cost_ptr->zx_cost_save_mpu = + (total > cost_ptr->zx_cost_counter_read) + ? (total - cost_ptr->zx_cost_counter_read) : 0U; + + start = zx_pmu_cycles(); + + for (round = 0U; round < rounds; round++) + { + zx_context_restore_mpu(((round & 1U) == 0U) ? scratch_a : scratch_b); + } + + total = (zx_pmu_cycles() - start) / rounds; + cost_ptr->zx_cost_restore_mpu = + (total > cost_ptr->zx_cost_counter_read) + ? (total - cost_ptr->zx_cost_counter_read) : 0U; + + /* ---- the stage-2 region set: one HPRENR write ------------------- */ + + start = zx_pmu_cycles(); + + for (round = 0U; round < rounds; round++) + { + zx_stage2_enable_set(((round & 1U) == 0U) ? mask_a : mask_b); + } + + total = (zx_pmu_cycles() - start) / rounds; + cost_ptr->zx_cost_region_mask = + (total > cost_ptr->zx_cost_counter_read) + ? (total - cost_ptr->zx_cost_counter_read) : 0U; + + /* ---- the freeze: a suspend and a resume, which is CNTVOFF ------- */ + + start = zx_pmu_cycles(); + + for (round = 0U; round < rounds; round++) + { + ZX_GUEST_CONTEXT *which = ((round & 1U) == 0U) ? scratch_a : scratch_b; + + zx_context_time_suspend(which); + zx_context_time_resume(which); + } + + total = (zx_pmu_cycles() - start) / rounds; + cost_ptr->zx_cost_time_freeze = + (total > cost_ptr->zx_cost_counter_read) + ? (total - cost_ptr->zx_cost_counter_read) : 0U; + + /* ---- arming the next boundary ----------------------------------- */ + + { + uint64_t far_away = zx_read_cntpct() + 0x40000000U; + uint32_t armed = 0U; + + start = zx_pmu_cycles(); + + for (round = 0U; round < rounds; round++) + { + armed = zx_el2_hyp_timer_arm(far_away + (uint64_t)round); + } + + total = (zx_pmu_cycles() - start) / rounds; + cost_ptr->zx_cost_deadline = + (total > cost_ptr->zx_cost_counter_read) + ? (total - cost_ptr->zx_cost_counter_read) : 0U; + + /* Consumed rather than discarded: every deadline above is far in + the future, so `armed` is non-zero by construction, and reading + it here is what keeps a ZX_NODISCARD return from being silently + thrown away in the one place where throwing it away is correct. */ + + if (armed == 0U) + { + cost_ptr->zx_cost_deadline = 0U; + } + } + + zx_el2_hyp_timer_stop(); +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_frame_hyp_tick_priority Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* The priority the hypervisor's tick is given, so that an image can */ +/* print it and check it against what the GIC read back. A constant */ +/* an image restated for itself would be a second source of truth for */ +/* the one number that decides whether a window can be ended at all. */ +/* */ +/**************************************************************************/ + +uint32_t zx_frame_hyp_tick_priority(void) +{ + return ZX_HYP_TICK_PRIORITY; +} diff --git a/platform/cortex_r52/src/zx_gic.c b/platform/cortex_r52/src/zx_gic.c index e7d8980..3ef1e0c 100644 --- a/platform/cortex_r52/src/zx_gic.c +++ b/platform/cortex_r52/src/zx_gic.c @@ -125,6 +125,7 @@ #define ZX_GICR_IGROUPR0 0x0080U #define ZX_GICR_ISENABLER0 0x0100U #define ZX_GICR_ICENABLER0 0x0180U +#define ZX_GICR_ISPENDR0 0x0200U #define ZX_GICR_IPRIORITYR 0x0400U #define ZX_GICR_ICFGR1 0x0C04U @@ -558,3 +559,258 @@ uint32_t zx_gic_priority_bits(const ZX_GIC_LAYOUT *gic_ptr, uint32_t intid) return bits; } + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_gic_enable_hyp_ppi Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* Enable one PPI in GROUP 0, which is what the GIC delivers as an FIQ. */ +/* */ +/* THE ONLY DIFFERENCE FROM zx_gic_enable_guest_ppi IS ONE BIT, and it */ +/* is the bit that decides which exception type the interrupt arrives */ +/* as -- and therefore, with HCR.FMO set and HCR.IMO clear, which */ +/* exception LEVEL it arrives at. Group 1 is an IRQ and goes to EL1; */ +/* Group 0 is an FIQ and comes to EL2. Routing is by TYPE, not by */ +/* INTID, which is the whole reason a hypervisor tick costs two */ +/* register writes instead of an interrupt-virtualization layer. */ +/* */ +/* IT IS A SEPARATE FUNCTION AND NOT A FLAG ON THE OTHER ONE. The two */ +/* have different callers with different authority: one hands an */ +/* interrupt to a partition, the other keeps one for the hypervisor, */ +/* and a boolean argument at a call site is the wrong place for that */ +/* distinction to live. A misread flag would put the hypervisor's own */ +/* timer in Group 1 -- delivered as an IRQ, straight to whichever */ +/* partition happened to be running, whose kernel would acknowledge an */ +/* INTID it does not recognise and never end its own window. */ +/* */ +/* LEVEL, NOT EDGE, for the same reason as a guest's timer: the generic */ +/* timer asserts a level until its comparator is re-armed. Configured */ +/* as edge it would end exactly one window and never another, which */ +/* with two partitions looks like a hypervisor that switched once and */ +/* then hung. */ +/* */ +/* PRIORITY MATTERS HERE IN A WAY IT DOES NOT FOR A GUEST. Both */ +/* targets implement five priority bits, so only the top five of the */ +/* byte survive; two values differing only below that are the SAME */ +/* priority to the hardware, and equal priorities do not preempt. The */ +/* hypervisor's tick has to be numerically lower -- higher priority -- */ +/* than any partition's interrupt, or a partition servicing its own */ +/* timer could hold off the end of its window for as long as its */ +/* handler runs. */ +/* */ +/**************************************************************************/ + +void zx_gic_enable_hyp_ppi(const ZX_GIC_LAYOUT *gic_ptr, uint32_t intid, + uint32_t priority) +{ + zx_addr_t priority_word; + uint32_t shift; + + if ((gic_ptr == (const ZX_GIC_LAYOUT *)0) + || (zx_gic_ppi_is_valid(intid) == 0U)) + { + return; + } + + /* GROUP 0. The bit is CLEARED, where the guest path sets it. */ + + ZX_GIC_REG(gic_ptr->zx_gic_sgi_base + ZX_GICR_IGROUPR0) &= + ~(uint32_t)ZX_BIT(intid); + + priority_word = gic_ptr->zx_gic_sgi_base + ZX_GICR_IPRIORITYR + + (zx_addr_t)(intid & ~3U); + shift = (intid & 3U) * 8U; + + ZX_GIC_REG(priority_word) &= ~(uint32_t)(0xFFU << shift); + ZX_GIC_REG(priority_word) |= (uint32_t)((priority & 0xFFU) << shift); + + shift = (intid - ZX_PPI_FIRST_INTID) * 2U; + ZX_GIC_REG(gic_ptr->zx_gic_sgi_base + ZX_GICR_ICFGR1) &= + ~(uint32_t)(0x3U << shift); + + /* Enable LAST, after everything that describes the interrupt. */ + + ZX_GIC_REG(gic_ptr->zx_gic_sgi_base + ZX_GICR_ISENABLER0) = + (uint32_t)ZX_BIT(intid); + + zx_gic_barrier(); +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_gic_el2_cpu_interface_init Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* EL2's own GICv3 CPU interface: the priority mask and Group 0. */ +/* */ +/* THIS IS THE FUNCTION THAT WOULD BE EASIEST TO OMIT AND HARDEST TO */ +/* DEBUG WITHOUT. Setting HCR.FMO does more than route FIQ. It also */ +/* makes EL1 accesses to the GROUP 0 CPU-interface registers, AND to */ +/* the ones COMMON to both groups, go to the VIRTUAL interface instead */ +/* of the physical one -- Cortex-R52 TRM 9.3.5, which lists exactly */ +/* which registers move. ICC_PMR is one of the common ones. */ +/* */ +/* So the instant FMO is set, the guest's own "ICC_PMR = 0xFF" -- which */ +/* every guest writes at start-up to unmask its interrupts, and which */ +/* worked perfectly before -- lands in ICV_PMR and changes nothing */ +/* about physical delivery. The PHYSICAL priority mask resets to ZERO, */ +/* which masks everything. A partition that was receiving its timer */ +/* end to end a moment earlier stops receiving anything at all, and */ +/* nothing faults: the guest simply never ticks, which looks exactly */ +/* like a GIC that was never configured. */ +/* */ +/* The hypervisor therefore owns the physical mask and opens it fully. */ +/* Priority ORDER still does all the work it did before -- the */ +/* hypervisor's tick is numerically lower and preempts a partition's */ +/* timer -- and what changes is only WHO sets the threshold below which */ +/* nothing is delivered at all. That is the right owner: a partition */ +/* able to raise the physical priority mask could refuse the interrupt */ +/* that ends its own window, which is the same hole the redistributor */ +/* was withheld to close. */ +/* */ +/* AND THE REDIRECTION IS WHAT MAKES THE DESIGN AIRTIGHT RATHER THAN */ +/* MERELY CHEAP. ICC_IGRPEN0 is redirected too, so a guest cannot */ +/* reach the physical Group 0 enable to switch off the interrupt that */ +/* ends its window: it writes ICV_IGRPEN0 and nothing happens. Before */ +/* FMO was set that register was genuinely shared between EL1 and EL2, */ +/* and D24 could only argue that a partition had no Group 0 interrupt */ +/* worth enabling. Now it cannot reach the enable at all. */ +/* */ +/**************************************************************************/ + +void zx_gic_el2_cpu_interface_init(void) +{ + uint32_t value; + + /* ICC_PMR is at c4, NOT c12 -- the one encoding in this group that is + not where a reader would look for it. 0xFF is the lowest possible + priority threshold, which permits everything; the ordering between + the hypervisor's tick and a partition's timer is done by their own + priorities and not by this. */ + + value = 0xFFU; + __asm__ volatile("mcr p15, 0, %0, c4, c6, 0" : : "r"(value) : "memory"); + + /* Group 0 enabled at the CPU interface, which is what allows an FIQ to + be signalled to the core at all. */ + + value = 1U; + __asm__ volatile("mcr p15, 0, %0, c12, c12, 6" + : : "r"(value) : "memory"); + __asm__ volatile("isb" ::: "memory"); +} + + +uint32_t zx_gic_el2_priority_mask(void) +{ + uint32_t value; + + __asm__ volatile("mrc p15, 0, %0, c4, c6, 0" : "=r"(value)); + + return value; +} + + +uint32_t zx_gic_el2_group0_enabled(void) +{ + uint32_t value; + + __asm__ volatile("mrc p15, 0, %0, c12, c12, 6" : "=r"(value)); + + return value & 1U; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_gic_el2_acknowledge Cortex-R52 */ +/* zx_gic_el2_end_of_interrupt */ +/* */ +/* DESCRIPTION */ +/* */ +/* The Group 0 acknowledge and end-of-interrupt pair, at EL2. */ +/* */ +/* ICC_IAR0 AND ICC_EOIR0, not the Group 1 registers a guest uses. The */ +/* two pairs are separate hardware with separate active-priority state, */ +/* and acknowledging an FIQ through the Group 1 register would return */ +/* the spurious INTID while leaving the FIQ active for ever -- a */ +/* hypervisor that ends exactly one partition window and then never */ +/* takes another interrupt. */ +/* */ +/* ZX_INTID_SPURIOUS MUST NOT BE GIVEN AN END-OF-INTERRUPT. It means */ +/* nothing was pending, so the running priority was never raised, and */ +/* dropping a priority that was never raised corrupts the GIC's */ +/* priority stack rather than merely being redundant. The caller */ +/* checks; the check is not made here because the caller also has to */ +/* decide what a spurious FIQ at a window boundary MEANS, and that is */ +/* policy. */ +/* */ +/**************************************************************************/ + +uint32_t zx_gic_el2_acknowledge(void) +{ + uint32_t intid; + + __asm__ volatile("mrc p15, 0, %0, c12, c8, 0" : "=r"(intid)); + + return intid & 0xFFFFFFU; +} + + +void zx_gic_el2_end_of_interrupt(uint32_t intid) +{ + __asm__ volatile("mcr p15, 0, %0, c12, c8, 1" + : : "r"(intid) : "memory"); + __asm__ volatile("isb" ::: "memory"); +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_gic_ppi_is_pending Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* Whether the GIC has this PPI pending. */ +/* */ +/* THE MIDDLE OF THREE QUESTIONS, and it exists because the other two */ +/* cannot tell each other's failures apart. A window that never ends */ +/* has three possible causes and they are in three different places: */ +/* */ +/* the comparator never expired CNTHP_CTL.ISTATUS says so */ +/* the GIC was never told THIS register says so */ +/* the core never took the exception routing, or the CPU interface */ +/* */ +/* Without the middle one, the first and the third are indistinguish- */ +/* able from each other and the symptom of all three is identical: a */ +/* hypervisor that starts a frame and is never heard from again, with */ +/* no fault, no message, and a harness timeout that names nothing. */ +/* That is the least informative failure this suite can produce, and */ +/* one memory-mapped read stands between it and a diagnosis. */ +/* */ +/**************************************************************************/ + +uint32_t zx_gic_ppi_is_pending(const ZX_GIC_LAYOUT *gic_ptr, uint32_t intid) +{ + if ((gic_ptr == (const ZX_GIC_LAYOUT *)0) + || (zx_gic_ppi_is_valid(intid) == 0U)) + { + return 0U; + } + + return ((ZX_GIC_REG(gic_ptr->zx_gic_sgi_base + ZX_GICR_ISPENDR0) + & (uint32_t)ZX_BIT(intid)) != 0U) ? 1U : 0U; +} diff --git a/platform/cortex_r52/src/zx_timer.c b/platform/cortex_r52/src/zx_timer.c index 801d261..0549162 100644 --- a/platform/cortex_r52/src/zx_timer.c +++ b/platform/cortex_r52/src/zx_timer.c @@ -37,14 +37,21 @@ /* partition's clock FROZEN rather than */ /* merely unread. */ /* */ -/* The partition tick -- the hypervisor's OWN timer, on PPI 26, which */ -/* ends a window -- is not here yet. It does NOT need HCR.IMO: that */ -/* would route every guest IRQ to EL2 as well. Routing is by */ -/* exception TYPE, so the tick goes in GROUP 0, arrives as an FIQ, and */ -/* HCR.FMO alone brings it to EL2 while guest IRQs stay with EL1. */ -/* With FMO set, PSTATE.F is ignored at EL1, so a partition cannot */ -/* mask the interrupt that ends its own window. */ -/* See docs/decisions.md D24. */ +/* THE PARTITION TICK IS NOW HERE TOO: CNTHP, the hypervisor's own */ +/* timer on PPI 26, armed with an ABSOLUTE deadline in CNTHP_CVAL. */ +/* It does NOT need HCR.IMO, which would route every guest IRQ to EL2 */ +/* as well. Routing is by exception TYPE, so the tick goes in GROUP 0, */ +/* arrives as an FIQ, and HCR.FMO alone brings it to EL2 while guest */ +/* IRQs stay with EL1. With FMO set, PSTATE.F is IGNORED at EL1, so a */ +/* partition cannot mask the interrupt that ends its own window. */ +/* See docs/decisions.md D24 and D25. */ +/* */ +/* THE PER-PARTITION HALF OF THE CNTVOFF BOOKKEEPING MOVED OUT. The */ +/* statics below are one partition's worth and serve the images that */ +/* launch a single guest. Once there is more than one partition, "when */ +/* did this partition stop" is partition state and belongs in its */ +/* context block; zx_context.c holds that version, using the same */ +/* arithmetic against the same register. */ /* */ /* MISRA C:2012 deviations (justified) */ /* */ @@ -68,6 +75,14 @@ #define ZX_CNTV_CTL_ENABLE ZX_BIT(0) #define ZX_CNTV_CTL_IMASK ZX_BIT(1) +/* CNTHP_CTL, the HYPERVISOR timer's control register, whose three bits sit + in the same places as the virtual timer's. ISTATUS is READ-ONLY and says + whether the comparator has been reached: it is the difference between "the + window boundary has arrived" and "some other FIQ did", which is worth + asking rather than assuming when a single vector serves both. */ + +#define ZX_CNTHP_CTL_ISTATUS ZX_BIT(2) + /* How long to spin looking for the physical counter to move. The counter runs at 8 MHz on one target and 100 MHz on the other, so a counter that is running advances within a handful of iterations at either; the bound is @@ -567,3 +582,316 @@ void zx_el2_guest_timer_stop(void) : : "r"(control) : "memory"); __asm__ volatile("isb" ::: "memory"); } + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_el2_hyp_timer_arm Cortex-R52 */ +/* zx_el2_hyp_timer_stop */ +/* zx_el2_hyp_timer_fired */ +/* zx_el2_hyp_timer_deadline */ +/* */ +/* DESCRIPTION */ +/* */ +/* CNTHP, the hypervisor's own timer, and the interrupt that ends a */ +/* partition's window. */ +/* */ +/* CNTHP_CVAL AND NOT CNTHP_TVAL, and the difference is the whole */ +/* determinism claim. TVAL is a 32-bit DOWN-COUNT loaded relative to */ +/* now: re-arming it inside the handler adds the handler's own latency */ +/* to every window, so a frame declared as 10 ms becomes 10 ms plus */ +/* the switch, cumulatively, for the life of the run. The schedule */ +/* still looks fixed, the windows stay in the right proportion, and */ +/* the frame slowly stops being the length it was declared to be. */ +/* CVAL is a 64-bit ABSOLUTE comparison against the physical counter, */ +/* so the switch's own cost comes out of the window it happens in. */ +/* */ +/* The one place TVAL would have been simpler is the reason it is */ +/* worth naming: re-arming a guest's virtual timer inside its own */ +/* handler IS one TVAL write with no counter read and no 64-bit */ +/* arithmetic, which is why the guest side of this suite uses it. The */ +/* hypervisor's frame is the opposite case and needs the opposite */ +/* register. */ +/* */ +/* AGAINST THE PHYSICAL COUNTER, which is what makes the frame */ +/* something no partition can move. A partition can be given any */ +/* virtual time the hypervisor likes; it cannot touch CNTPCT, and */ +/* CNTHCTL.PL1PCTEN is left clear so it cannot even read it. */ +/* */ +/* THE RETURN VALUE IS NOT DECORATION. Arming a comparator with a */ +/* count already in the past leaves the interrupt permanently pending: */ +/* the window was shorter than its own partition switch. Nothing */ +/* breaks -- the schedule keeps turning and every frame takes longer */ +/* than declared -- which is exactly why it has to be reported rather */ +/* than absorbed. The caller decides what to do about it, because the */ +/* caller is the one holding the schedule that has to record it. */ +/* */ +/* THE COUNTER IS READ ONCE, AFTER THE WRITE. On the S32Z280 a CNTPCT */ +/* read crosses into an 8 MHz clock domain and costs about 93 counts of */ +/* real time, so reading it twice to "check" the arming would report a */ +/* miss that the checking itself caused. One read, taken after the */ +/* comparator is set, is both the cheapest and the only honest answer. */ +/* */ +/* MISRA C:2012 deviations (justified) */ +/* */ +/* Directive 4.3 -- CNTHP_CVAL is reachable only through a 64-bit */ +/* coprocessor transfer, encapsulated here. */ +/* */ +/**************************************************************************/ + +static uint64_t zx_hyp_timer_armed_for; + + +uint32_t zx_el2_hyp_timer_arm(uint64_t deadline) +{ + uint32_t low = (uint32_t)(deadline & 0xFFFFFFFFU); + uint32_t high = (uint32_t)(deadline >> 32); + uint32_t control; + + /* The comparator FIRST, then the enable. Written the other way round + the timer is enabled for the few cycles it takes to write CVAL while + the comparator still holds the PREVIOUS deadline -- which has already + passed, so the level asserts and the interrupt is taken again + immediately. On a level-triggered PPI that is a storm rather than a + glitch. */ + + __asm__ volatile("mcrr p15, 6, %0, %1, c14" + : : "r"(low), "r"(high) : "memory"); /* CNTHP_CVAL */ + + control = ZX_CNTV_CTL_ENABLE; + __asm__ volatile("mcr p15, 4, %0, c14, c2, 1" + : : "r"(control) : "memory"); /* CNTHP_CTL */ + __asm__ volatile("isb" ::: "memory"); + + zx_hyp_timer_armed_for = deadline; + + return (zx_read_cntpct() < deadline) ? 1U : 0U; +} + + +void zx_el2_hyp_timer_stop(void) +{ + uint32_t control; + + /* BOTH bits, for the same reason a guest's timer is stopped with both: + masking alone leaves the comparator running with ISTATUS set, so the + next thing to enable it finds a deadline it never chose already + expired. */ + + __asm__ volatile("mrc p15, 4, %0, c14, c2, 1" : "=r"(control)); + control &= ~(uint32_t)(ZX_CNTV_CTL_ENABLE | ZX_CNTV_CTL_IMASK); + __asm__ volatile("mcr p15, 4, %0, c14, c2, 1" + : : "r"(control) : "memory"); + __asm__ volatile("isb" ::: "memory"); +} + + +uint32_t zx_el2_hyp_timer_fired(void) +{ + uint32_t control; + + __asm__ volatile("mrc p15, 4, %0, c14, c2, 1" : "=r"(control)); + + return ((control & (uint32_t)ZX_CNTHP_CTL_ISTATUS) != 0U) ? 1U : 0U; +} + + +uint64_t zx_el2_hyp_timer_deadline(void) +{ + return zx_hyp_timer_armed_for; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_el2_route_fiq Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* HCR.FMO: physical FIQ to EL2, physical IRQ left with EL1. */ +/* */ +/* THE ONE BIT THE WHOLE OF TIME PARTITIONING RESTS ON. With FMO set, */ +/* PSTATE.F is IGNORED at EL0 and EL1 -- a partition cannot mask the */ +/* interrupt that ends its own window, whatever its kernel does with */ +/* its own masks and however tight a loop it is in. A tick delivered */ +/* as an IRQ could be deferred by any guest that disabled interrupts, */ +/* which is precisely the property temporal partitioning is bought to */ +/* have. */ +/* */ +/* ROUTING IS BY EXCEPTION TYPE AND NOT BY INTID, which is what makes */ +/* this two register writes rather than a rewrite. The hypervisor's */ +/* own timer goes in GIC Group 0, which the GIC delivers as an FIQ, and */ +/* arrives here; every partition interrupt stays Group 1, stays an IRQ, */ +/* and is still delivered STRAIGHT to EL1 with no injection and no List */ +/* Register. The guest side of the arrangement needed no change */ +/* whatever. See docs/decisions.md D24 and D25. */ +/* */ +/* HCR.IMO STAYS CLEAR, deliberately, and HCR.AMO with it. AMO would */ +/* route asynchronous aborts to EL2 -- where they arrive at the vector */ +/* that today means "ZoneX faulted on its own access", so setting it */ +/* without reworking that vector would report a guest's abort as a */ +/* hypervisor bug. Neither is needed for a window to end. */ +/* */ +/**************************************************************************/ + +void zx_el2_route_fiq(void) +{ + uint32_t hcr; + + __asm__ volatile("mrc p15, 4, %0, c1, c1, 0" : "=r"(hcr)); + hcr |= (uint32_t)ZX_HCR_FMO; + __asm__ volatile("mcr p15, 4, %0, c1, c1, 0" : : "r"(hcr) : "memory"); + __asm__ volatile("isb" ::: "memory"); +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_el2_deny_guest_fp Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* HCPTR.TCP10/TCP11 SET, so a guest's floating-point access traps. */ +/* */ +/* THE EXACT OPPOSITE OF WHAT zx_el2_prepare_guest_el1 DOES, and the */ +/* reason is that the two functions serve systems with different */ +/* numbers of partitions. */ +/* */ +/* D23 recorded the consequence of opening the FPU before there was a */ +/* second partition to make it matter: nothing in ZoneX saves or */ +/* restores FPEXC, FPSCR or the D-registers across a switch. With one */ +/* partition that is exactly correct -- there is nobody to share the */ +/* bank with. With two it is two guests reading each other's */ +/* registers, and the failure mode is a WRONG ANSWER rather than a */ +/* fault: no exception, no log line, and a number that is somebody */ +/* else's. */ +/* */ +/* ZoneX therefore DENIES the FPU to a time-partitioned system rather */ +/* than saving it. A guest that touches floating point takes an */ +/* exception to EL2 with a syndrome naming the cause, which is a */ +/* diagnosis; sharing the bank silently is not. Saving the bank is a */ +/* later-phase option and it is a real one -- sixteen double registers */ +/* are two instructions -- but it needs the register file's width read */ +/* from MVFR0 rather than assumed, and a partition switch is not the */ +/* place to add an assumption about how many registers a part has. */ +/* */ +/* Both bits, not one. TCP10 and TCP11 gate the two coprocessor */ +/* numbers the VFP and Advanced SIMD are reached through, and a guest */ +/* that found one open would use it. */ +/* */ +/**************************************************************************/ + +void zx_el2_deny_guest_fp(void) +{ + uint32_t hcptr; + + __asm__ volatile("mrc p15, 4, %0, c1, c1, 2" : "=r"(hcptr)); + hcptr |= (uint32_t)ZX_HCPTR_TCP; + __asm__ volatile("mcr p15, 4, %0, c1, c1, 2" : : "r"(hcptr) : "memory"); + __asm__ volatile("isb" ::: "memory"); +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_el2_dwell_until Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* Spend counter counts at EL2 until an ABSOLUTE deadline. */ +/* */ +/* The sibling of zx_el2_dwell, and the difference between them is the */ +/* difference between a measurement and a schedule. zx_el2_dwell waits */ +/* for a DURATION, because a check of the freeze needs a gap of a known */ +/* size. This one waits for an INSTANT, because a window that a */ +/* partition handed back early has to end exactly where it would have */ +/* ended -- at the boundary the frame's epoch already decided, not at */ +/* "however long is left" computed from a counter read taken somewhere */ +/* in the middle of a switch. */ +/* */ +/* THIS IS WHAT AN IDLE PARTITION COSTS, and it is deliberate. A */ +/* static frame does not give a yielding partition's remaining time to */ +/* its neighbour: doing so would make one partition's start time depend */ +/* on another's behaviour, which is the coupling temporal partitioning */ +/* is bought to remove. Trapping WFI to reclaim it is a later-phase */ +/* option that trades determinism for throughput, and Phase 0 declines */ +/* it. */ +/* */ +/* Bounded twice over: by the deadline, and by an iteration count, so */ +/* that a counter which stopped cannot turn a burned window into a */ +/* hang. A deadline already in the past returns immediately, which is */ +/* the right answer -- there is nothing left of that window to spend. */ +/* */ +/**************************************************************************/ + +void zx_el2_dwell_until(uint64_t deadline) +{ + uint32_t guard; + + for (guard = 0U; guard < ZX_DWELL_GUARD; guard++) + { + if (zx_read_cntpct() >= deadline) + { + break; + } + } +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_el2_allow_guest_fp Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* HCPTR.TCP10/TCP11 cleared again, once no partition is running. */ +/* */ +/* THIS EXISTS BECAUSE OF A MEASURED PROPERTY OF THE DEBUG CONNECTION, */ +/* and it is worth the paragraph because the symptom names nothing. */ +/* */ +/* With the floating-point traps SET, the S32Z280's debug probe cannot */ +/* read the core's register file: gdb reports "Could not read */ +/* registers; remote failure reply '01'" and the harness around it */ +/* exits non-zero on a run whose own console said ALL CHECKS PASSED. */ +/* Measured by comparison, on the board: an image that stops BEFORE the */ +/* traps are set produces no such error, and the same image stopping */ +/* AFTER produces two. The debugger reads the VFP registers as part of */ +/* the register file, and a trapped coprocessor is a coprocessor it */ +/* cannot reach. */ +/* */ +/* THE ANSWER IS NOT TO STOP DENYING THE FPU. The traps exist because */ +/* nothing saves the register bank across a partition switch, so two */ +/* guests would share it and the failure mode would be a wrong ANSWER */ +/* rather than a fault. What they protect is the interval in which */ +/* PARTITIONS RUN, and that interval ends when the frame does. So the */ +/* traps are lifted once the last window has closed and before the */ +/* hypervisor parks -- no partition can be sharing anything by then, */ +/* and the machine is handed to whoever is reading it in the state they */ +/* can read. */ +/* */ +/* A harness that reports a passing run as a failure is the one */ +/* outcome a regression must never have, in either direction: the same */ +/* argument that put the verdict check inside the gdb script rather */ +/* than in the shell around it. */ +/* */ +/**************************************************************************/ + +void zx_el2_allow_guest_fp(void) +{ + uint32_t hcptr; + + __asm__ volatile("mrc p15, 4, %0, c1, c1, 2" : "=r"(hcptr)); + hcptr &= ~(uint32_t)ZX_HCPTR_TCP; + __asm__ volatile("mcr p15, 4, %0, c1, c1, 2" : : "r"(hcptr) : "memory"); + __asm__ volatile("isb" ::: "memory"); +} diff --git a/platform/cortex_r52/src/zx_trap_handler.S b/platform/cortex_r52/src/zx_trap_handler.S index 20f42bc..953a196 100644 --- a/platform/cortex_r52/src/zx_trap_handler.S +++ b/platform/cortex_r52/src/zx_trap_handler.S @@ -114,8 +114,19 @@ /* */ /* Offsets 0x00-0x10 are exceptions taken FROM Hyp mode itself. Offset */ /* 0x14 is the Hyp Trap Entry: every exception routed to EL2 from EL1 or */ -/* EL0 funnels through it. 0x18 and 0x1C are unexpected here, because */ -/* HCR.IMO and HCR.FMO are clear and interrupts belong to EL1. */ +/* EL0 funnels through it. */ +/* */ +/* 0x18, IRQ, is unexpected on every ZoneX image: HCR.IMO stays CLEAR, so */ +/* a guest interrupt is delivered straight to EL1 and the hypervisor is */ +/* not involved in a tick at all. */ +/* */ +/* 0x1C, FIQ, IS THE END OF A PARTITION'S WINDOW -- but only in an image */ +/* that sets HCR.FMO and gives itself a schedule. The vector therefore */ +/* goes to zx_el2_fiq_boundary in zx_context.S, which decides between the */ +/* two: an FIQ with a partition running is a boundary, and an FIQ with */ +/* none is the unexpected exception it always was, reported through */ +/* zx_el2_vector_fiq below. One vector table for every image, and the */ +/* images that never route an FIQ are unchanged by the ones that do. */ /**************************************************************************/ .section .vectors_el2, "ax" @@ -129,7 +140,7 @@ zx_el2_vectors: b zx_el2_vector_dabt /* 0x10 data abort, from Hyp */ b zx_el2_hyp_trap_entry /* 0x14 from EL1/EL0 */ b zx_el2_vector_irq /* 0x18 IRQ */ - b zx_el2_vector_fiq /* 0x1C FIQ */ + b zx_el2_fiq_boundary /* 0x1C FIQ -- a window ends */ .text .balign 4 @@ -217,13 +228,23 @@ zx_el2_trap_hvc_not_putc: @ "the payload came back" is evidence too, and a run that can only @ report the absence of a fault cannot tell a working protection @ failure from a broken test. - + @ + @ AND SO IS THE GUEST'S OWN CONTEXT, when a schedule is running. A + @ partition that yields inside its window has not finished its window, + @ and the hypervisor has to be able to say where it was -- to report it, + @ and because the same fifteen words are what a resume would need. The + @ capture reads the guest's r0 and r1 off this stack rather than out of + @ the registers, which by now hold the decoded syndrome; it does nothing + @ at all in an image with no schedule. + + bl zx_el2_context_capture_from_stack add sp, sp, #8 @ done with the guest's r0/r1 ZX_CAPTURE ZX_ASM_VEC_HYP_TRAP mov r0, #ZX_RUN_YIELDED b zx_el2_resume zx_el2_trap_not_hypercall: + bl zx_el2_context_capture_from_stack add sp, sp, #8 @ done with the guest's r0/r1 ZX_CAPTURE ZX_ASM_VEC_HYP_TRAP @@ -294,6 +315,7 @@ zx_el2_run_payload: /* trap handler, because there is no frame to return through. */ /**************************************************************************/ + .global zx_el2_resume .type zx_el2_resume, %function zx_el2_resume: ldr r2, =zx_el2_resume_context @@ -364,6 +386,7 @@ zx_el2_vector_irq: bl zx_el2_unexpected_vector .size zx_el2_vector_irq, . - zx_el2_vector_irq + .global zx_el2_vector_fiq .type zx_el2_vector_fiq, %function zx_el2_vector_fiq: ZX_CAPTURE ZX_ASM_VEC_FIQ @@ -377,14 +400,23 @@ zx_el2_vector_fiq: /* ONE slot, so zx_el2_run_payload CANNOT NEST. That is a real */ /* constraint and it is deliberate: a hypervisor that could be inside two */ /* guest excursions at once would need a stack of these, and Phase 0 has */ -/* no reason to be. A time-partition scheduler calls it once per */ -/* partition, sequentially, and still does not nest. If a later phase */ -/* needs to -- an emulated instruction that itself re-enters a guest -- */ -/* this becomes an array indexed by depth, and the depth needs a bound. */ +/* no reason to be. If a later phase needs to -- an emulated instruction */ +/* that itself re-enters a guest -- this becomes an array indexed by */ +/* depth, and the depth needs a bound. */ +/* */ +/* IT IS SHARED WITH THE TIME-PARTITION FRAME, which is why the symbol */ +/* is global. zx_el2_enter_partition in zx_context.S saves the same */ +/* three things here and comes back through the same zx_el2_resume, and */ +/* it does not nest either: a schedule enters one partition at a time, */ +/* and a WINDOW BOUNDARY never comes back through here at all -- the */ +/* boundary handler ERETs into the next partition from inside the */ +/* exception path. One slot serves both because at most one of them is */ +/* ever inside a guest. */ /**************************************************************************/ .section .bss .balign 8 + .global zx_el2_resume_context .type zx_el2_resume_context, %object zx_el2_resume_context: .space ZX_ASM_RESUME_WORDS * 4 From 1e01c48987712f8315655e713a41947617b29deb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 2 Sep 2026 21:41:56 -0400 Subject: [PATCH 19/40] Ran two ThreadX partitions under one static frame, on both targets The image this repository has been building to. Two ThreadX kernels, each in its own stage-2 window, time-sharing one logical core under a major frame taken from the manifest: partition A holds seven ticks of every ten and partition B three, for twenty frames. MEASURED ON THE S32Z280-594EVB, twenty frames and thirty-nine boundaries, with no missed deadline and no interrupt the hypervisor's own timer had not raised: partition A 11,157,245 counts on the core, 139 of its own ticks partition B 4,822,000 counts on the core, 51 of its own ticks A x 3 = 33,471,735 against B x 7 = 33,754,000, within 0.84% of exact. That equality IS the temporal claim rather than a symptom of it: a partition able to see wall clock would be out by a factor of three, not by a percentage. The 20,755 counts unaccounted for against the frame's 16,000,000 are the thirty-nine switches, charged to neither partition. The switch costs 5,630 / 5,684 / 5,944 cycles min / mean / max, and the guest's own EL1 MPU is 85% of each direction on BOTH targets -- across a 32-region model and a 20-region part. A partition switch is not expensive because the hypervisor does much; its per-partition state is three register writes. It is expensive because a guest has a lot of registers. FOUR BUILDS, AND WHICH ONES MUST PASS IS THE POINT. the frame both partitions run to the limit, clocks in ratio hog B masks IRQ and FIQ at EL1 and spins for ever. It is preempted anyway, and the masking costs it exactly its OWN kernel's tick: its tick count froze at 4 while its liveness counter reached 229,233. A frame driven by an interrupt taken at EL1 would HANG on this build. cross B grants itself a granule inside A's window in its own EL1 MPU and writes to it. Stage 2 refuses, B is stopped, and A runs to the end of the frame with its schedule untouched -- which is the half of isolation a single partition can never demonstrate. no_tick, overlap must FAIL, and now REPORT their failure rather than hanging: a hypervisor that cannot deliver its own tick refuses to start a frame at all, for the same reason a guest is never allowed to arm a timer against a stopped counter. A SECOND PARTITION WAS MEANT TO BE A DATA CHANGE AND IT WAS NOT. It was going to be a second copy of a two-hundred-line build file and a second copy of a linker script, differing in one address each. The guest project is now configured once per partition from a template instead, which is the generality that was missing rather than a special case for partition B. Assisted-by: Claude Code (Opus 5) --- examples/common/zx_guest_abi.h | 72 +- examples/common/zx_guest_image.S | 30 + examples/common/zx_guest_launch.c | 12 + examples/common/zx_preemptive_partition.c | 30 +- examples/common/zx_two_partitions.c | 1756 +++++++++++++++++ examples/fvp_baser_aemv8r/CMakeLists.txt | 262 ++- .../fvp_baser_aemv8r/guest/CMakeLists.txt | 84 +- .../guest/link_partition.lds.in | 31 +- .../fvp_baser_aemv8r/zx_one_partition.lds | 2 +- .../fvp_baser_aemv8r/zx_two_partitions.lds | 314 +++ examples/guest_common/zx_guest_app.c | 101 +- examples/s32z280_evb/CMakeLists.txt | 176 +- examples/s32z280_evb/guest/CMakeLists.txt | 82 +- .../s32z280_evb/guest/link_partition.lds.in | 20 +- examples/s32z280_evb/tools/run_zx_probe.gdb | 44 +- examples/s32z280_evb/zx_one_partition.lds | 2 +- examples/s32z280_evb/zx_two_partitions.lds | 330 ++++ 17 files changed, 3217 insertions(+), 131 deletions(-) create mode 100644 examples/common/zx_two_partitions.c create mode 100644 examples/fvp_baser_aemv8r/zx_two_partitions.lds create mode 100644 examples/s32z280_evb/zx_two_partitions.lds diff --git a/examples/common/zx_guest_abi.h b/examples/common/zx_guest_abi.h index 0d22a66..2d79b8d 100644 --- a/examples/common/zx_guest_abi.h +++ b/examples/common/zx_guest_abi.h @@ -178,6 +178,44 @@ #define ZX_GO_TICK 0x00000002U +/* ZX_GO_FOREVER asks the guest NOT to hand the machine back when it has + * finished its programme, and it is what makes a MAJOR FRAME demonstrable. + * + * A guest that yields is a guest that has finished. With one partition that + * was exactly the right ending -- the hypervisor took the core back, read + * the sealed report and printed it. With a frame it is the wrong one: a + * schedule can only be shown to preempt partitions that are still trying to + * run, and a partition that stopped after its first window would spend every + * later window being politely idled through. + * + * So with this bit set the guest keeps working after its verdict: it stays + * runnable, keeps taking its own ticks, and keeps republishing its tick count + * and its own virtual counter into the mailbox. The hypervisor then reads, + * at the end of the run, what each partition's clock did -- which is the + * evidence for the claim the whole step exists to make. + * + * The verdict is published BEFORE the endless phase begins, so a run that is + * stopped at any point afterwards still carries a sealed report. */ + +#define ZX_GO_FOREVER 0x00000004U + +/* ZX_GO_HOG asks the guest to be as uncooperative as an EL1 program can be: + * after its verdict it masks IRQ and FIQ at EL1 and spins for ever, making + * no kernel call and giving nothing back. + * + * IT IS THE DEMONSTRATION AND NOT A HAZARD, and the distinction is the whole + * design. With HCR.FMO set, PSTATE.F is IGNORED at EL0 and EL1 -- so the + * interrupt that ends this partition's window is delivered to EL2 whatever + * the guest has masked, and there is nothing a partition can do about it. + * Masking its own IRQ costs it only its own kernel's tick. + * + * A run of this build must therefore look exactly like a run without it, + * except that the hogging partition stops ticking. If the frame were driven + * by an interrupt taken at EL1 instead, this build would hang -- which is + * why it exists as a build rather than as a paragraph. */ + +#define ZX_GO_HOG 0x00000008U + /* THE READBACK CONVENTION, and why it is a structure rather than one word. * * A single progress word is enough to say "the guest got somewhere". It is @@ -283,7 +321,39 @@ #define ZX_GD_SPIN_B 0x50U /* spinner B's loop count */ #define ZX_GD_WAKES 0x54U /* times the sleeper woke */ -/* 0x5C to 0x7C are spare, and are zeroed by the handover like everything +/* WHAT A GUEST REPORTS WHILE IT IS STILL RUNNING, which is the other half + * of the temporal claim. + * + * The sealed snapshot says what a partition ACHIEVED. These four say what + * its clock did, and they are refreshed continuously by the endless phase + * ZX_GO_FOREVER asks for -- so the hypervisor can read them at the end of a + * run and compare each partition's own elapsed time against the windows it + * was actually given. + * + * THE COMPARISON IS THE POINT AND IT ONLY WORKS FROM BOTH SIDES. ZoneX + * knows how much of the physical counter each partition spent on the core; + * only the guest can say what its OWN counter did over the same span. A + * partition whose virtual time matched WALL CLOCK rather than its own + * windows would be a partition that could see its neighbour's time, and it + * is precisely the failure that leaves a system running perfectly while + * lying about time. + * + * OUTSIDE THE SEAL, like the rest of this granule, because they are written + * by a loop that must not stop to checksum anything. Nothing rests on them + * that the sealed words do not also carry: ZX_GD_TICKS is tx_time_get() and + * IS sealed, and these are the running version of it plus the counter + * underneath. + * + * LOW HALVES ONLY. At the S32Z280's 8 MHz a 32-bit virtual count wraps in + * nine minutes and a frame demonstration is seconds long, so the low half is + * both sufficient and honest about what it is -- see guest_virtual_count. */ + +#define ZX_GD_LIVE 0x5CU /* the endless loop's own count */ +#define ZX_GD_VCT_START 0x60U /* CNTVCT when the guest began */ +#define ZX_GD_VCT_NOW 0x64U /* CNTVCT, refreshed */ +#define ZX_GD_TICKS_NOW 0x68U /* tx_time_get(), refreshed */ + +/* 0x6C to 0x7C are spare, and are zeroed by the handover like everything else. A word nobody wrote reads as whatever the image was built with, which for a loaded, zero-filled section is zero -- but only until somebody changes the section, so the handover writes them rather than relying on diff --git a/examples/common/zx_guest_image.S b/examples/common/zx_guest_image.S index 8616119..fae71cd 100644 --- a/examples/common/zx_guest_image.S +++ b/examples/common/zx_guest_image.S @@ -45,6 +45,20 @@ @ contain, and the loader checks the image's own magic number before it @ ERETs anywhere. +@ ONE BLOB, OR TWO, AND WHY IT IS A DEFINE RATHER THAN A SECOND FILE +@ +@ An image with two partitions embeds two guests: each one is LINKED for +@ its own window, so they are different bytes and neither can stand in for +@ the other. The obvious arrangement -- a second .S file next to this one +@ -- would carry a second copy of everything above, and the two copies +@ would agree until one of them was fixed. +@ +@ The second blob is therefore conditional, and it has to be: .incbin names +@ a file, and "guest_b.bin" does not exist in a build that has only one +@ partition. An unconditional reference would fail the assembly of every +@ single-partition image with "file not found" -- which is a good failure +@ for a real mistake and a poor one for a file that is legitimately absent. + #include "zx_guest_abi.h" .section .zx_guest_a_blob, "a", %progbits @@ -58,3 +72,19 @@ __zx_guest_a_image_start: .balign 4 .global __zx_guest_a_image_end __zx_guest_a_image_end: + +#ifdef ZX_GUEST_B_BLOB + + .section .zx_guest_b_blob, "a", %progbits + .balign 64 + + .global __zx_guest_b_image_start +__zx_guest_b_image_start: + + .incbin "guest_b.bin" + + .balign 4 + .global __zx_guest_b_image_end +__zx_guest_b_image_end: + +#endif diff --git a/examples/common/zx_guest_launch.c b/examples/common/zx_guest_launch.c index d31d6ce..acab759 100644 --- a/examples/common/zx_guest_launch.c +++ b/examples/common/zx_guest_launch.c @@ -233,6 +233,18 @@ void zx_guest_hand_over(const ZX_GUEST_LAUNCH *launch_ptr, zx_guest_mailbox_write(launch_ptr, ZX_GD_SPIN_B, 0U); zx_guest_mailbox_write(launch_ptr, ZX_GD_WAKES, 0U); + /* And what a guest that keeps running after its verdict reports: its + own liveness counter, and the two clocks it is the only party able to + read. Zeroed for the same reason as everything above, and it matters + most here: "this partition's clock did not move" and "this partition + never wrote its clock" are different findings, and a stale value from + a previous excursion would make the first look like the second. */ + + zx_guest_mailbox_write(launch_ptr, ZX_GD_LIVE, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_VCT_START, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_VCT_NOW, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_TICKS_NOW, 0U); + __asm__ volatile("dsb" ::: "memory"); } diff --git a/examples/common/zx_preemptive_partition.c b/examples/common/zx_preemptive_partition.c index c7c9879..7dd57cc 100644 --- a/examples/common/zx_preemptive_partition.c +++ b/examples/common/zx_preemptive_partition.c @@ -142,8 +142,8 @@ extern char __zx_partition_a_hole_start[]; * hypervisor tick will have to go once there is one: ZoneX's own timer must * be able to preempt a partition's, or a partition could delay the end of * its own window by taking a long interrupt. That is not yet built -- it - * needs HCR.IMO -- and the number is chosen now so that it does not have to - * change then. */ + * needs HCR.FMO and the hypervisor's tick in Group 0 -- and the number is + * chosen now so that it does not have to change then. */ #define ZX_GUEST_TIMER_PRIORITY 0xA0U @@ -589,7 +589,8 @@ ZX_NORETURN void zx_el2_main(void) " whole interrupt capability is system-register state", (zx_partitions[0].zx_partition_region_count == 1U) ? 1U : 0U); - status = zx_mm_plan(&zx_manifest, board_regions, el2_regions, &zx_layout); + status = zx_mm_plan(&zx_manifest, board_regions, el2_regions, + zx_hprenr_implemented_bits, &zx_layout); zx_note("zx_mm_plan", status); zx_check("the layout fits this part's region budget", (status == ZX_MANIFEST_SUCCESS) ? 1U : 0U); @@ -1164,14 +1165,21 @@ ZX_NORETURN void zx_el2_main(void) "\n" " NOT PROVED, and the omission is deliberate rather than\n" " pending: this is not TIME PARTITIONING. ZoneX cannot\n" - " yet END a partition's window, because HCR.IMO is CLEAR\n" - " -- every physical interrupt taken while a partition\n" - " runs goes to EL1, including the hypervisor's own timer.\n" - " Setting IMO routes them all to EL2 instead, and every\n" - " guest interrupt then has to be INJECTED through one of\n" - " this core's four List Registers. That is a change to\n" - " the hypervisor and to no guest, which is why the shape\n" - " here is worth having first.\n" + " yet END a partition's window, because nothing here\n" + " routes an interrupt to EL2 at all -- every physical\n" + " interrupt taken while a partition runs goes to EL1,\n" + " including the hypervisor's own timer.\n" + "\n" + " WHAT IT TAKES IS FIQ, NOT INJECTION, and the difference\n" + " is a rewrite against two register writes. Routing is\n" + " by exception TYPE and not by INTID: HCR.FMO sends\n" + " physical FIQ to EL2 while HCR.IMO, left clear, leaves\n" + " IRQ with EL1. So the hypervisor's tick goes in GROUP 0\n" + " -- which the GIC delivers as an FIQ -- and every guest\n" + " interrupt stays Group 1, stays an IRQ, and is still\n" + " delivered straight to EL1 exactly as it is here, with\n" + " no List Register anywhere. See docs/decisions.md D24,\n" + " whose first version concluded the opposite and says so.\n" "\n" " NOT PROVED: anything about timing on a functional\n" " model, and anything about more than one partition or\n" diff --git a/examples/common/zx_two_partitions.c b/examples/common/zx_two_partitions.c new file mode 100644 index 0000000..1a242a1 --- /dev/null +++ b/examples/common/zx_two_partitions.c @@ -0,0 +1,1756 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* EXAMPLE DESCRIPTION */ +/* */ +/* zx_two_partitions.c SHARED */ +/* */ +/* DESCRIPTION */ +/* */ +/* TWO ThreadX partitions time-sharing one logical core under a static */ +/* major frame, each with its own stage-2 region set, its own frozen */ +/* virtual time, and a bounded, measured switch between them. */ +/* */ +/* This is the image that makes ZoneX a hypervisor rather than a */ +/* loader. Everything under it was already demonstrated with ONE */ +/* partition: a real kernel confined by stage 2, granted an interrupt */ +/* it could not have granted itself, with a clock the hypervisor */ +/* freezes while it is not running. What one partition cannot show is */ +/* the thing that matters -- that a window ENDS whether the partition */ +/* agrees or not, and that the next one resumes exactly where it was. */ +/* */ +/* THE FOUR CLAIMS */ +/* */ +/* 1. TWO PARTITIONS RUN FROM ONE MANIFEST, each in its own window, */ +/* each running its own kernel, neither able to reach the other's */ +/* memory or the ungranted granule between them. */ +/* */ +/* 2. A WINDOW ENDS WHETHER THE PARTITION AGREES OR NOT. The */ +/* hypervisor's own timer is in GIC Group 0, so it arrives as an */ +/* FIQ at EL2 -- and with HCR.FMO set, PSTATE.F is IGNORED at EL0 */ +/* and EL1. A partition cannot mask the interrupt that ends its */ +/* own window. The hogging build proves it by trying. */ +/* */ +/* 3. EACH PARTITION'S CLOCK ADVANCES BY ITS OWN WINDOWS AND BY */ +/* NOTHING ELSE. Measured from both sides, which is the only way */ +/* it means anything: the hypervisor knows how much of the */ +/* physical counter each partition was given, and only the guest */ +/* can say what its own counter did over the same span. A guest */ +/* that saw wall-clock time would be a system that runs perfectly */ +/* and lies about time -- invisible to every other check here. */ +/* */ +/* 4. THE SWITCH IS BOUNDED AND MEASURED, by group, with its jitter */ +/* reported as a MAXIMUM and not only as a mean. A safety */ +/* audience reads the maximum, because that is what a schedule has */ +/* to be built to survive. */ +/* */ +/* THE ASYMMETRY IS DELIBERATE */ +/* */ +/* Partition A is the critical one: the larger window, and a guest */ +/* that keeps working after its verdict so that there is always */ +/* something to preempt. Partition B is the untrusted one: the */ +/* smaller window, chattier, and in one build actively hostile. The */ +/* windows differ so that "a partition given a third of the frame sees */ +/* a third of the ticks" is a claim with two different numbers in it */ +/* rather than a symmetry that would hold by accident. */ +/* */ +/* WHAT A PASS DOES NOT MEAN */ +/* */ +/* On the Armv8-R AEM FVP a pass proves the code is right and says */ +/* NOTHING about timing: the model is functional, not cycle-accurate. */ +/* Every cycle count this image prints has to come from silicon before */ +/* it is quoted, and the image says so in its own output. */ +/* */ +/* On lockstep silicon the two partitions TIME-SHARE ONE LOGICAL CORE. */ +/* What this earns is memory isolation plus temporal determinism on */ +/* that core. It is not spatial multicore partitioning and must never */ +/* be shown as if it were. */ +/* */ +/**************************************************************************/ + +#include "zx_probe.h" +#include "zx_platform.h" +#include "zx_guest_abi.h" +#include "zx_guest_launch.h" +#include "zx_manifest.h" +#include "zx_mm.h" +#include "zx_partition.h" +#include "zx_schedule.h" +#include "zx_guest_console.h" + +/* THE GUEST'S CONTRACT AND THE HYPERVISOR'S MUST AGREE, and this is one of + the few translation units that can see both spellings. The guest is a + separate program with its own toolchain invocation and cannot include + zx_port.h; it restates what it needs in zx_guest_abi.h. Asserting the two + against each other here turns a renumbered constant into a failed BUILD + rather than a partition that takes an interrupt it does not recognise. */ + +_Static_assert(ZX_HVC_GUEST_NOP == (int)ZX_HVC_NOP, + "the guest and the hypervisor disagree about HVC #0"); +_Static_assert(ZX_HVC_GUEST_YIELD == (int)ZX_HVC_YIELD, + "the guest and the hypervisor disagree about the yield HVC"); +_Static_assert(ZX_HVC_GUEST_PUTC == (int)ZX_HVC_PUTC, + "the guest and the hypervisor disagree about the console HVC"); +_Static_assert(ZX_GUEST_TIMER_INTID == (int)ZX_PPI_VIRTUAL_TIMER, + "the guest acknowledges a different INTID from the one ZoneX " + "enables for it"); +_Static_assert(ZX_GUEST_SPURIOUS_INTID == (int)ZX_INTID_SPURIOUS, + "the guest and the hypervisor disagree about the spurious " + "INTID, which must never be given an end-of-interrupt"); + +/* The two windows and the two embedded guests, from the linker script. */ + +extern char __zx_guest_a_blob_start[]; +extern char __zx_guest_a_blob_end[]; +extern char __zx_guest_b_blob_start[]; +extern char __zx_guest_b_blob_end[]; +extern char __zx_partition_a_start[]; +extern char __zx_partition_a_end[]; +extern char __zx_partition_a_hole_start[]; +extern char __zx_partition_b_start[]; +extern char __zx_partition_b_end[]; +extern char __zx_partition_b_hole_start[]; + +#define ZX_PART_A 0U +#define ZX_PART_B 1U +#define ZX_PART_COUNT 2U + +#define ZX_PARTITION_A_ID 1U +#define ZX_PARTITION_B_ID 2U + +/* The sentinels the hypervisor plants, and which each guest folds into every + checksum. DISTINCT, because that is what makes a report attributable: a + value read out of the wrong partition's window looks exactly like a + report, and only the sentinel inside the sum says whose it is. With two + partitions that stops being a precaution and becomes the point. */ + +#define ZX_GUEST_A_SENTINEL 0xA5A50011U +#define ZX_GUEST_B_SENTINEL 0xA5A50022U + +/* THE MAJOR FRAME, in scheduling ticks. + * + * A tick is a hundredth of a second, which is ThreadX's own default rate, so + * a partition's window is a whole number of the ticks its kernel counts. + * That is what makes the tick-budget claim legible: a partition given seven + * of the ten ticks in a frame should see about seven tenths of the ticks a + * standalone kernel would, and both numbers are integers a reader can check. + * + * SEVEN AND THREE RATHER THAN FIVE AND FIVE. Equal windows would make every + * ratio in the run come out as one half, and a great many arithmetic + * mistakes also come out as one half. Unequal windows mean the two + * partitions' numbers cannot be swapped without the run noticing. */ + +#define ZX_TICK_HZ 100U +#define ZX_WINDOW_A_TICKS 7UL +#define ZX_WINDOW_B_TICKS 3UL +#define ZX_FRAME_TICKS (ZX_WINDOW_A_TICKS + ZX_WINDOW_B_TICKS) + +/* How many major frames the run lasts. + * + * BOUNDED, because a regression has to be able to assert on a total, and + * "the harness timed out" is not a result. + * + * TWENTY, AND THE NUMBER IS SET BY THE CHATTY PARTITION ON THE SLOWER + * CONSOLE -- which is one of the more instructive things this demonstration + * shows, so it is written down rather than tuned away. + * + * Partition B prints, and a guest's console is one hypercall PER CHARACTER + * through whatever console the board has. On the Armv8-R AEM FVP that is + * semihosting and costs no simulated time at all; on the S32Z280 it is a + * polled UART and costs about 1,500 counter counts a character -- measured, + * 952 characters in the 1,453,400 counts B was given by a six-frame run. + * B's whole programme is roughly 1,430 characters, so on silicon it spends + * the better part of THREE FRAMES' worth of its own window simply talking. + * + * That is not an artefact to be hidden. IT IS WHAT TIME PARTITIONING DOES: + * a partition that spends its window printing gets its window's worth of + * printing and no more, and its neighbour is completely unaffected -- which + * is visible here as partition A finishing its programme in four ticks + * while B is still mid-sentence. What it costs is that a run has to be + * long enough for the slow partition to reach its verdict, or the + * demonstration ends before its own evidence does. + * + * Twenty frames is two hundred ticks: two seconds of wall clock on silicon, + * giving B sixty ticks of its own time against the roughly thirty-four it + * needs. The margin is deliberate -- a run that only just finishes would + * start failing the first time somebody added a line of guest output. */ + +#define ZX_FRAME_LIMIT 20UL + +/* The priority a partition's timer is given. The same value the + single-partition image uses, and the hypervisor's own tick sits well above + it -- see zx_frame_hyp_tick_priority. */ + +#define ZX_GUEST_TIMER_PRIORITY 0xA0U + +/* How many rounds the switch measurement averages over. Eight, matching the + method the region-set switch was first measured with: enough to average + out a single anomaly, few enough that the whole measurement is a fraction + of one window. */ + +#define ZX_MEASURE_ROUNDS 8U + +static ZX_REGION zx_regions_a[1]; +static ZX_REGION zx_regions_b[1]; +static ZX_PARTITION zx_partitions[ZX_PART_COUNT]; +static ZX_MANIFEST zx_manifest; +static ZX_MM_LAYOUT zx_layout; +static ZX_MANIFEST_ENV zx_env; +static ZX_PARTITION_CB zx_partition_cb[ZX_PART_COUNT]; +static ZX_REGION zx_mmio[ZX_BOARD_MAX_MMIO_REGIONS]; +static ZX_GIC_LAYOUT zx_gic; +static ZX_GUEST_LAUNCH zx_launch[ZX_PART_COUNT]; +static ZX_SCHEDULE zx_schedule; +static ZX_FRAME zx_frame; + +/* ONE CONTEXT BLOCK PER PARTITION, STATICALLY ALLOCATED. No allocation and + no list: a partition switch indexes an array, which is what makes its cost + independent of how many partitions exist and of which one is next. */ + +static ZX_GUEST_CONTEXT zx_context[ZX_PART_COUNT]; + +/* And two more for the measurement, which must not be the running ones. + Timing a save into a partition's own block would overwrite the state that + partition is about to be resumed with -- the measurement destroying its + subject, which is a class of mistake this suite has already paid for + once. */ + +static ZX_GUEST_CONTEXT zx_scratch[2]; +static ZX_SWITCH_COST zx_cost; + + +/**************************************************************************/ +/* zx_describe_launches */ +/* */ +/* Filled at run time rather than as static initialisers, because every */ +/* field is the address of a LINKER symbol and the address of a linker */ +/* symbol is not a constant expression. */ +/* */ +/* zx_launch_freeze_time is set for BOTH partitions in every build. The */ +/* single-partition image has a build that turns it off, to show the */ +/* freeze failing; here the equivalent negative build breaks the */ +/* SCHEDULE instead, because with two partitions the interesting failure */ +/* is not "a clock advanced" but "a clock advanced by its neighbour's */ +/* window". */ +/**************************************************************************/ + +static void zx_describe_launches(void) +{ + zx_launch[ZX_PART_A].zx_launch_window_base = + zx_symbol_address(__zx_partition_a_start); + zx_launch[ZX_PART_A].zx_launch_window_end = + zx_symbol_address(__zx_partition_a_end); + zx_launch[ZX_PART_A].zx_launch_image_start = + zx_symbol_address(__zx_guest_a_blob_start); + zx_launch[ZX_PART_A].zx_launch_image_end = + zx_symbol_address(__zx_guest_a_blob_end); + zx_launch[ZX_PART_A].zx_launch_sentinel = ZX_GUEST_A_SENTINEL; + zx_launch[ZX_PART_A].zx_launch_partition_id = ZX_PARTITION_A_ID; + zx_launch[ZX_PART_A].zx_launch_partition_name = + zx_partitions[ZX_PART_A].zx_partition_name; + zx_launch[ZX_PART_A].zx_launch_freeze_time = 1U; + + zx_launch[ZX_PART_B].zx_launch_window_base = + zx_symbol_address(__zx_partition_b_start); + zx_launch[ZX_PART_B].zx_launch_window_end = + zx_symbol_address(__zx_partition_b_end); + zx_launch[ZX_PART_B].zx_launch_image_start = + zx_symbol_address(__zx_guest_b_blob_start); + zx_launch[ZX_PART_B].zx_launch_image_end = + zx_symbol_address(__zx_guest_b_blob_end); + zx_launch[ZX_PART_B].zx_launch_sentinel = ZX_GUEST_B_SENTINEL; + zx_launch[ZX_PART_B].zx_launch_partition_id = ZX_PARTITION_B_ID; + zx_launch[ZX_PART_B].zx_launch_partition_name = + zx_partitions[ZX_PART_B].zx_partition_name; + zx_launch[ZX_PART_B].zx_launch_freeze_time = 1U; +} + + +/**************************************************************************/ +/* zx_build_manifest */ +/* */ +/* TWO PARTITIONS, ONE REGION EACH, AND NO DEVICE OF ANY KIND. */ +/* */ +/* A second partition costs exactly one more window and one more region. */ +/* It costs no GIC mapping, no timer mapping and no shared page: a */ +/* partition's whole interrupt capability is system-register state, and */ +/* its whole clock is a register only EL2 can write. That is the */ +/* strongest form this claim could take, and it is worth restating with */ +/* two partitions because the obvious design -- give each guest its own */ +/* view of the interrupt controller -- would have cost regions AND */ +/* handed each partition the enable bit for the interrupt that ends its */ +/* own window. */ +/* */ +/* NO SHARED GRANULE EITHER, and that is a decision rather than an */ +/* omission. The manifest can declare one -- the stage-2 probe image */ +/* demonstrates both halves of it -- and this image deliberately does */ +/* not, so that the two partitions are disjoint with no exceptions at */ +/* all. Every temporal number below is then a number about two */ +/* partitions that share nothing but a core. */ +/**************************************************************************/ + +static void zx_build_manifest(uint32_t board_regions) +{ + zx_regions_a[0].zx_region_base = + zx_symbol_address(__zx_partition_a_start); + zx_regions_a[0].zx_region_limit = + zx_symbol_address(__zx_partition_a_end) - 1U; + zx_regions_a[0].zx_region_ap = ZX_AP_EL2_RW_GUEST_RW; + zx_regions_a[0].zx_region_xn = ZX_XN_EXECUTABLE; + zx_regions_a[0].zx_region_sh = ZX_SH_NON_SHAREABLE; + zx_regions_a[0].zx_region_attr_index = (UCHAR)ZX_ATTR_NORMAL_WB; + + zx_regions_b[0].zx_region_base = + zx_symbol_address(__zx_partition_b_start); + zx_regions_b[0].zx_region_limit = + zx_symbol_address(__zx_partition_b_end) - 1U; + zx_regions_b[0].zx_region_ap = ZX_AP_EL2_RW_GUEST_RW; + zx_regions_b[0].zx_region_xn = ZX_XN_EXECUTABLE; + zx_regions_b[0].zx_region_sh = ZX_SH_NON_SHAREABLE; + zx_regions_b[0].zx_region_attr_index = (UCHAR)ZX_ATTR_NORMAL_WB; + +#ifdef ZX_TWO_OVERLAP + + /* THE NEGATIVE BUILD FOR ISOLATION: partition B's window is widened to + swallow the ungranted granule and reach into partition A's. Two + enabled regions on one address is CONSTRAINED UNPREDICTABLE and + aborts on the S32Z280, so the validator must refuse this BY NAME + before a single region is programmed. */ + + zx_regions_b[0].zx_region_base = + zx_symbol_address(__zx_partition_a_start); + +#endif + + zx_partitions[ZX_PART_A].zx_partition_id = ZX_PARTITION_A_ID; + zx_partitions[ZX_PART_A].zx_partition_name = "A (critical)"; + zx_partitions[ZX_PART_A].zx_partition_image_start = + zx_symbol_address(__zx_guest_a_blob_start); + zx_partitions[ZX_PART_A].zx_partition_image_end = + zx_symbol_address(__zx_guest_a_blob_end); + zx_partitions[ZX_PART_A].zx_partition_entry = + zx_symbol_address(__zx_partition_a_start) + ZX_GUEST_IMAGE_OFF_ENTRY; + zx_partitions[ZX_PART_A].zx_partition_regions = zx_regions_a; + zx_partitions[ZX_PART_A].zx_partition_region_count = 1U; + zx_partitions[ZX_PART_A].zx_partition_window_ticks = ZX_WINDOW_A_TICKS; + + zx_partitions[ZX_PART_B].zx_partition_id = ZX_PARTITION_B_ID; + zx_partitions[ZX_PART_B].zx_partition_name = "B (untrusted)"; + zx_partitions[ZX_PART_B].zx_partition_image_start = + zx_symbol_address(__zx_guest_b_blob_start); + zx_partitions[ZX_PART_B].zx_partition_image_end = + zx_symbol_address(__zx_guest_b_blob_end); + zx_partitions[ZX_PART_B].zx_partition_entry = + zx_symbol_address(__zx_partition_b_start) + ZX_GUEST_IMAGE_OFF_ENTRY; + zx_partitions[ZX_PART_B].zx_partition_regions = zx_regions_b; + zx_partitions[ZX_PART_B].zx_partition_region_count = 1U; + zx_partitions[ZX_PART_B].zx_partition_window_ticks = ZX_WINDOW_B_TICKS; + + zx_manifest.zx_manifest_partitions = zx_partitions; + zx_manifest.zx_manifest_partition_count = ZX_PART_COUNT; + zx_manifest.zx_manifest_shared = (const ZX_SHARED *)0; + zx_manifest.zx_manifest_shared_count = 0U; + zx_manifest.zx_manifest_major_frame_ticks = ZX_FRAME_TICKS; + + zx_board_describe_mmio_regions(zx_mmio); + + zx_env.zx_env_mmio_regions = zx_mmio; + zx_env.zx_env_mmio_region_count = board_regions; + zx_env.zx_env_attr_written_mask = 0x07U; +} + + +/**************************************************************************/ +/* zx_grant_the_clocks */ +/* */ +/* EVERYTHING AT EL2 THAT MAKES A TIME-PARTITIONED SYSTEM POSSIBLE, in */ +/* one place, because the order matters and several steps of it fail */ +/* silently on their own. */ +/* */ +/* THE COUNTER, then the GIC, then the two interrupts -- one for the */ +/* partitions and one for the hypervisor -- and then the ROUTING. */ +/* */ +/* THE PARTITIONS' TIMER IS ONE INTID FOR BOTH OF THEM. They never */ +/* run at once, and its meaning is switched with everything else a */ +/* switch switches: the outgoing partition's comparator is saved and */ +/* DISARMED, the incoming one's is restored. Two INTIDs would make */ +/* "which PPIs are enabled" guest state and grow the switch by a */ +/* register write for no gain; one INTID keeps it hypervisor state */ +/* that never changes. This is open question 2 of the step this image */ +/* was written for, answered the way it recommended, and recorded here */ +/* rather than only in the manifest's comments. */ +/* */ +/* THE HYPERVISOR'S TIMER GOES IN GROUP 0, which is what makes the */ +/* whole design work: routing is by exception TYPE, so Group 0 arrives */ +/* as an FIQ and HCR.FMO brings FIQ to EL2, while every partition */ +/* interrupt stays Group 1, stays an IRQ, and is delivered straight to */ +/* EL1 exactly as it was before this image existed. */ +/* */ +/* AND EL2'S OWN CPU INTERFACE COMES UP LAST, AFTER FMO IS SET, */ +/* because that is where the trap is. See zx_gic_el2_cpu_interface_ */ +/* init: setting FMO redirects the guest's ICC_PMR writes to the */ +/* VIRTUAL interface, so the physical priority mask -- which resets to */ +/* zero, masking everything -- becomes the hypervisor's to open. A */ +/* partition that was receiving its timer perfectly well stops */ +/* receiving anything, with no fault and no message. */ +/* */ +/* Returns non-zero when everything both partitions depend on is up. */ +/**************************************************************************/ + +static uint32_t zx_grant_the_clocks(void) +{ + uint32_t running; + uint32_t awake; + uint32_t priority_bits; + uint32_t hyp_priority; + uint32_t tick_deliverable; + + zx_console_puts("\n--- the clocks and the interrupts ZoneX grants ---\n"); + + zx_board_counter_start(); + running = zx_counter_is_running(); + + zx_note("CNTFRQ ", zx_read_cntfrq()); + zx_check("the system counter is RUNNING, not merely declared. Every\n" + " window boundary in this run is an absolute comparison\n" + " against it, so a counter that does not move is a frame\n" + " that never turns", + running); + + awake = zx_gic_el2_init(&zx_gic); + zx_check("the redistributor cleared ProcessorSleep and reports its\n" + " children awake, so this core can be delivered to", + awake); + + priority_bits = zx_gic_priority_bits(&zx_gic, ZX_PPI_VIRTUAL_TIMER); + hyp_priority = zx_frame_hyp_tick_priority(); + + zx_note("implemented priority bits", priority_bits); + zx_note("partition timer priority ", ZX_GUEST_TIMER_PRIORITY); + zx_note("hypervisor tick priority ", hyp_priority); + + /* THE TWO PRIORITIES MUST STILL DIFFER AFTER THE PART HAS THROWN AWAY + THE BITS IT DOES NOT IMPLEMENT. Both targets keep only the top five, + so two values differing below that are the SAME priority -- and equal + priorities do not preempt. A hypervisor tick that could not preempt + a partition's timer handler would let a guest defer the end of its + own window for as long as its handler ran, which is the masking hole + wearing a different hat. */ + + { + uint32_t keep = (uint32_t)(0xFFU << (8U - priority_bits)); + + zx_check("the hypervisor's tick is a HIGHER priority than a\n" + " partition's timer even after this part has\n" + " discarded the priority bits it does not implement --\n" + " numerically lower wins, and equal priorities do not\n" + " preempt at all", + ((hyp_priority & keep) < (ZX_GUEST_TIMER_PRIORITY & keep)) + ? 1U : 0U); + } + + /* ONE INTID FOR BOTH PARTITIONS. Enabled once, here, and never touched + by a switch: the comparator behind it is what changes hands. */ + + zx_gic_enable_guest_ppi(&zx_gic, ZX_PPI_VIRTUAL_TIMER, + ZX_GUEST_TIMER_PRIORITY); + +#ifndef ZX_TWO_NO_TICK + + zx_gic_enable_hyp_ppi(&zx_gic, ZX_PPI_HYPERVISOR_TIMER, hyp_priority); + +#else + + zx_console_puts("\n NEGATIVE BUILD: the HYPERVISOR's own timer PPI is\n" + " deliberately NOT enabled. The comparator is still\n" + " armed at every boundary and still expires; the GIC is\n" + " simply never told to deliver it. No window can then\n" + " end, partition A runs for ever, and partition B never\n" + " runs at all -- so this run must report FAILED.\n"); + +#endif + + zx_check("the partitions' virtual-timer PPI reads back ENABLED and in\n" + " GROUP 1, which is what the GIC delivers as an IRQ,\n" + " straight to EL1 with no injection and no List Register", + (zx_gic_ppi_is_enabled(&zx_gic, ZX_PPI_VIRTUAL_TIMER) + && zx_gic_ppi_is_group1(&zx_gic, ZX_PPI_VIRTUAL_TIMER)) + ? 1U : 0U); + + zx_check("and the HYPERVISOR's own timer PPI is in GROUP 0, which is\n" + " what the GIC delivers as an FIQ. Routing is by\n" + " exception TYPE and not by INTID: that one bit is the\n" + " whole difference between a tick that reaches EL2 and a\n" + " tick that would have to be injected into a List\n" + " Register", + (zx_gic_ppi_is_group1(&zx_gic, ZX_PPI_HYPERVISOR_TIMER) == 0U) + ? 1U : 0U); + + /* AND THAT IT IS ENABLED, which is the check the whole refusal below + rests on. A hypervisor whose own tick is not deliverable cannot end + a window, and a frame started in that state does not fail -- it + HANGS, with the first partition running until something outside the + image stops it. That is the least informative outcome this suite can + produce and it names nothing at all. */ + + tick_deliverable = zx_gic_ppi_is_enabled(&zx_gic, + ZX_PPI_HYPERVISOR_TIMER); + zx_note("hypervisor tick PPI enabled", tick_deliverable); + + zx_check("and the PHYSICAL timer's PPI is enabled for nobody. A\n" + " partition could not read it anyway -- CNTHCTL.PL1PCTEN\n" + " and PL1PCEN are left clear on purpose -- because\n" + " physical time keeps running while a partition is\n" + " descheduled, and a guest that can read it can see that\n" + " it was not running", + (zx_gic_ppi_is_enabled(&zx_gic, ZX_PPI_PHYSICAL_TIMER) == 0U) + ? 1U : 0U); + + /* ROUTING, AND THEN EL2's OWN CPU INTERFACE. In that order, because + the second only matters once the first has changed where the guest's + own writes go. */ + + zx_el2_route_fiq(); + + zx_note("HCR after routing", zx_read_hcr()); + + /* THE BIT, BY POSITION, AND NOT BY NAME. This port had ZX_HCR_FMO and + ZX_HCR_AMO defined the other way round, and a check written as + "(HCR & ZX_HCR_FMO) != 0" passes cheerfully against either + definition -- it is the same symbol on both sides of the comparison. + What it cost was a run in which every set-up check was green, the + comparator expired, the GIC made the interrupt pending, and no window + ever ended: bit 5 is AMO, and physical FIQ had never been routed + anywhere. Bit 3 is FMO on this architecture and on this part, TRM + Table 3-70, and comparing against the NUMBER is the only form of this + check a swapped definition cannot satisfy. */ + + zx_check("HCR bit 3 -- FMO, by position and not by the name this port\n" + " happens to give it -- is SET, so a physical FIQ is\n" + " taken to EL2. And with it set, PSTATE.F is IGNORED at\n" + " EL0 and EL1: a partition cannot mask the interrupt\n" + " that ends its own window, whatever its kernel does\n" + " with its own masks", + ((zx_read_hcr() & 0x8U) != 0U) ? 1U : 0U); + zx_check("and HCR bit 5 -- AMO -- is CLEAR. Routing asynchronous aborts\n" + " to EL2 would send them to the vector that today means\n" + " 'ZoneX faulted on its own access', so a guest's abort\n" + " would be reported as a hypervisor bug. Not needed for\n" + " a window to end, and deliberately deferred", + ((zx_read_hcr() & 0x20U) == 0U) ? 1U : 0U); + zx_check("and HCR bit 4 -- IMO -- is CLEAR, so every partition interrupt\n" + " is still a physical IRQ delivered straight to EL1. The\n" + " guest side of this arrangement needed no change\n" + " whatever, which is the strongest argument the design\n" + " has", + ((zx_read_hcr() & 0x10U) == 0U) ? 1U : 0U); + + zx_gic_el2_cpu_interface_init(); + + zx_note("EL2 ICC_PMR ", zx_gic_el2_priority_mask()); + zx_note("EL2 ICC_IGRPEN0", zx_gic_el2_group0_enabled()); + + zx_check("the PHYSICAL priority mask is open, and it is the\n" + " HYPERVISOR'S to open now. Setting HCR.FMO redirects an\n" + " EL1 write of ICC_PMR to the VIRTUAL interface, so the\n" + " guest's own 'unmask everything' stops affecting\n" + " physical delivery -- and the physical mask resets to\n" + " zero, which masks it all. A partition that received\n" + " its timer end to end a moment ago would stop receiving\n" + " anything, with nothing to fault on", + (zx_gic_el2_priority_mask() != 0U) ? 1U : 0U); + + zx_check("and Group 0 is enabled at EL2's own CPU interface, without\n" + " which an FIQ could not be signalled to the core at all", + zx_gic_el2_group0_enabled()); + + /* ---------------------------------------------------------------- */ + /* A PRE-FLIGHT, BEFORE ANY PARTITION RUNS. */ + /* */ + /* A window that never ends has three possible causes in three */ + /* different places, and they present identically: a hypervisor */ + /* that starts a frame and is never heard from again, with no */ + /* fault, no message and a harness timeout that names nothing. */ + /* That is the least informative failure this suite can produce. */ + /* */ + /* So the comparator is armed for a short interval HERE, with no */ + /* partition running and FIQ still masked at EL2, and two */ + /* questions are asked separately: did it expire, and did the GIC */ + /* make the interrupt pending. A run that fails either of them */ + /* says which half is broken instead of hanging. */ + /* */ + /* The same discipline as zx_counter_is_running, one level up: */ + /* a guest that armed a timer against a stopped counter was the */ + /* failure that check was written for, and this is the */ + /* hypervisor's own version of it. */ + /* ---------------------------------------------------------------- */ + + { + uint64_t soon = zx_read_cntpct() + (uint64_t)(zx_board_counter_hz() + / 1000U); + uint32_t armed = zx_el2_hyp_timer_arm(soon); + + zx_el2_dwell_until(soon + (uint64_t)(zx_board_counter_hz() / 1000U)); + + zx_console_puts("\n--- the hypervisor's own tick, before any " + "partition runs ---\n"); + zx_note("armed for a future deadline", armed); + zx_note("CNTHP expired (ISTATUS) ", zx_el2_hyp_timer_fired()); + zx_note("GIC has PPI 26 pending ", + zx_gic_ppi_is_pending(&zx_gic, ZX_PPI_HYPERVISOR_TIMER)); + + zx_check("the hypervisor's own comparator EXPIRED. This is the\n" + " first of three questions a window that never ends\n" + " raises, and the only one that is about the timer\n" + " rather than about the interrupt controller", + zx_el2_hyp_timer_fired()); + +#ifndef ZX_TWO_NO_TICK + + zx_check("and the GIC made it PENDING, which is the second: a\n" + " comparator that expires into a controller that was\n" + " never told is indistinguishable, from the outside,\n" + " from a core that never took the exception", + zx_gic_ppi_is_pending(&zx_gic, ZX_PPI_HYPERVISOR_TIMER)); + +#endif + + zx_el2_hyp_timer_stop(); + } + + zx_console_puts( + "\n" + " AND THE SAME REDIRECTION IS WHAT MAKES THIS AIRTIGHT RATHER THAN\n" + " MERELY CHEAP. ICC_IGRPEN0 is redirected too, so a partition\n" + " cannot reach the physical Group 0 enable to switch off the\n" + " interrupt that ends its window: it writes the virtual copy and\n" + " nothing happens. Before FMO was set that register was genuinely\n" + " shared, and the argument had to be that a partition was granted\n" + " no Group 0 interrupt worth enabling. Now it cannot reach the\n" + " enable at all.\n"); + + /* THREE THINGS, AND A FRAME NEEDS ALL OF THEM. The counter has to be + moving, the GIC has to be awake, and the hypervisor's own tick has to + be deliverable. Returned as one answer so that the caller has one + decision to make, and the caller's decision is to REFUSE rather than + to start a frame whose windows could never end. */ + + return ((running != 0U) && (awake != 0U) && (tick_deliverable != 0U)) + ? 1U : 0U; +} + + +/**************************************************************************/ +/* zx_prepare_partition -- validate, load and hand over one guest. */ +/* */ +/* Returns non-zero when the partition is ready to be scheduled. */ +/**************************************************************************/ + +static uint32_t zx_prepare_partition(UINT index, uint32_t options, + uint32_t probe_target) +{ + UINT status; + + zx_partition_reset(&zx_partition_cb[index], &zx_partitions[index]); + + status = zx_partition_prepare(&zx_partition_cb[index]); + + if (status != ZX_MANIFEST_SUCCESS) + { + zx_note("zx_partition_prepare", status); + zx_check("the loader located this partition's window and the image\n" + " fits it", 0U); + zx_partition_report(&zx_partition_cb[index]); + + return 0U; + } + + if (zx_guest_image_check(&zx_launch[index]) == 0U) + { + zx_console_puts("\n *** REFUSING to launch this image. It is either\n" + " *** absent, truncated, or built for a different\n" + " *** window.\n"); + + return 0U; + } + + zx_partition_report(&zx_partition_cb[index]); + + zx_guest_image_load(&zx_partition_cb[index].zx_partition_load); + zx_guest_hand_over(&zx_launch[index], probe_target, options); + zx_partition_loaded(&zx_partition_cb[index]); + + zx_check("the partition may be entered now that its image is loaded", + zx_partition_enter(&zx_partition_cb[index])); + + /* ITS OWN CLOCK STARTS AT ZERO, and each partition's starts + independently. Where a partition's time begins is the hypervisor's + to decide, which is the same property that lets it decide the time + does not advance while the partition is not running. */ + + zx_context_init(&zx_context[index], + zx_partition_cb[index].zx_partition_load.zx_load_entry); + zx_context_time_reset(&zx_context[index]); + + return 1U; +} + + +/**************************************************************************/ +/* zx_report_partition -- what one partition did, read at a point where */ +/* neither of them is running. */ +/* */ +/* THE SYNCHRONISATION POINT MATTERS AND IT IS NOT THE OBVIOUS ONE. */ +/* A post-mortem read of a partition's memory attributes the last */ +/* writer's value to everyone who shared the address -- which cost a */ +/* wrong conclusion during the Cortex-R52 Modules port work. Here the */ +/* two windows are DISJOINT, so no address is shared and the hazard */ +/* cannot arise by aliasing; what remains is attribution, and the */ +/* sentinel folded into each guest's checksum is what answers it. Both */ +/* are read after the frame has stopped and before anything else runs, */ +/* so each partition's words are the ones it left behind. */ +/**************************************************************************/ + +static void zx_report_partition(UINT index) +{ + zx_console_puts("\n=========================================================\n" + " PARTITION "); + zx_console_puts(zx_partitions[index].zx_partition_name); + zx_console_puts("\n=========================================================\n"); + + if (zx_guest_report(&zx_launch[index]) == 0U) + { + zx_console_puts(" this partition reported NO progress bits at all,\n" + " which is not the same as reporting failure: it is\n" + " a window nobody wrote.\n"); + } + + zx_console_puts("\n--- and what it kept doing after its verdict ---\n"); + zx_note("liveness counter ", + zx_guest_mailbox_read(&zx_launch[index], ZX_GD_LIVE)); + zx_note("its own ticks, running", + zx_guest_mailbox_read(&zx_launch[index], ZX_GD_TICKS_NOW)); + zx_note("its CNTVCT at the start", + zx_guest_mailbox_read(&zx_launch[index], ZX_GD_VCT_START)); + zx_note("its CNTVCT now ", + zx_guest_mailbox_read(&zx_launch[index], ZX_GD_VCT_NOW)); + zx_note("interrupts it serviced", + zx_guest_mailbox_read(&zx_launch[index], ZX_GD_IRQ_COUNT)); + zx_note("unexpected INTID ", + zx_guest_mailbox_read(&zx_launch[index], ZX_GD_ODD_INTID)); + + zx_context_report(&zx_context[index], + zx_partitions[index].zx_partition_name); +} + + +/**************************************************************************/ +/* zx_report_switch_cost */ +/**************************************************************************/ + +static void zx_report_switch_cost(void) +{ + zx_console_puts("\n--- what a partition switch costs, by group ---\n"); + + if (zx_pmu_is_running() == 0U) + { + zx_console_puts(" the PMU cycle counter is not advancing, so no\n" + " timing is reported. A number from a counter that\n" + " is not running would be a very convincing wrong\n" + " answer.\n"); + + return; + } + + zx_note("EL1 MPU regions walked ", zx_cost.zx_cost_el1_regions); + zx_note("counter read, subtracted ", zx_cost.zx_cost_counter_read); + zx_note("save, everything ", zx_cost.zx_cost_save_full); + zx_note(" of which the EL1 MPU ", zx_cost.zx_cost_save_mpu); + zx_note("restore, everything ", zx_cost.zx_cost_restore_full); + zx_note(" of which the EL1 MPU ", zx_cost.zx_cost_restore_mpu); + zx_note("stage-2 region set, HPRENR", zx_cost.zx_cost_region_mask); + zx_note("the time freeze, CNTVOFF ", zx_cost.zx_cost_time_freeze); + zx_note("arming the next boundary ", zx_cost.zx_cost_deadline); + + zx_console_puts( + "\n" + " THE EL1 MPU DOMINATES, and that is the answer to the question a\n" + " safety customer asks. A partition switch is not expensive\n" + " because the hypervisor does much: its own per-partition state is\n" + " three register writes -- one HPRENR mask, one CNTVOFF, one\n" + " comparator. It is expensive because a GUEST has a lot of\n" + " registers, and most of them are its memory protection unit.\n" + "\n" + " THE WAY TO MAKE IT CHEAPER IS KNOWN AND IS NOT TAKEN HERE. HSTR\n" + " can trap a guest's own CP15 accesses by register group, so a\n" + " hypervisor could shadow the guest's MPU writes and skip the save\n" + " half entirely. That trades a bounded cost at every switch for an\n" + " unbounded number of traps while a guest runs, which is the wrong\n" + " trade for a static frame and the right one for something else.\n" + " The naive version is measured first so that a later phase has a\n" + " number to beat.\n"); +} + + +/**************************************************************************/ +/* zx_report_frame */ +/**************************************************************************/ + +static void zx_report_frame(uint32_t outcome) +{ + zx_console_puts("\n--- the frame, as it ran ---\n"); + + zx_note("outcome ", outcome); + zx_note("major frames completed", zx_schedule.zx_schedule_frames); + zx_note("window boundaries ", zx_frame.zx_frame_switches); + zx_note("missed deadlines ", zx_schedule.zx_schedule_missed); + zx_note("unexplained FIQs ", zx_frame.zx_frame_spurious); + + zx_note("boundaries timed ", zx_frame.zx_frame_timed); + + if ((zx_pmu_is_running() != 0U) && (zx_frame.zx_frame_timed != 0U)) + { + uint32_t mean = (uint32_t)(zx_frame.zx_frame_switch_total + / (uint64_t)zx_frame.zx_frame_timed); + + zx_note("switch cycles, min ", zx_frame.zx_frame_switch_min); + zx_note("switch cycles, mean ", mean); + zx_note("switch cycles, max ", zx_frame.zx_frame_switch_max); + zx_note("switch cycles, spread", + zx_frame.zx_frame_switch_max - zx_frame.zx_frame_switch_min); + + zx_console_puts( + "\n" + " THE MAXIMUM IS THE NUMBER, not the mean. A schedule has to be\n" + " built to survive the worst switch it will ever take, so a mean\n" + " quoted alone hides exactly the excursion a safety reviewer is\n" + " asking about. Both are printed, with the spread between them,\n" + " because the SHAPE is the claim: a switch whose cost depended on\n" + " what a guest had been doing would show it here.\n" + "\n" + " BOUNDARIES THAT HAD TO BURN A STOPPED PARTITION'S WINDOW ARE\n" + " COUNTED AND NOT TIMED. Such a boundary is a switch plus a\n" + " wait of up to a whole window, and averaging the two together\n" + " describes neither -- it turned a switch of a few thousand\n" + " cycles into one of three million and reported the difference\n" + " as jitter. In a run where both partitions live to the end,\n" + " the two counts above are equal.\n"); + } +} + + +/**************************************************************************/ +/* zx_el2_main -- entered from zx_el2_entry.S at EL2. Does not return. */ +/**************************************************************************/ + +ZX_NORETURN void zx_el2_main(void) +{ + uint32_t board_regions; + uint32_t el2_regions; + uint32_t status; + uint32_t granted; + uint32_t outcome; + uint32_t options_a; + uint32_t options_b; + uint32_t probe_b; + uint32_t ticks_a; + uint32_t ticks_b; + ZX_MANIFEST_FAULT fault; + + /* ---------------------------------------------------------------- */ + /* BEFORE THE FIRST PRINT. On a board whose console sits below */ + /* 0x60000000 the background map reaches it as NORMAL memory, and */ + /* Normal memory permits the gathering and reordering that corrupts */ + /* a polled UART. So HMAIR, the hypervisor's own MMIO regions and */ + /* HSCTLR.M come up FIRST. See docs/decisions.md D15. */ + /* ---------------------------------------------------------------- */ + + el2_regions = zx_el2_region_count(); + board_regions = zx_board_mmio_region_count(); + + if (el2_regions >= board_regions) + { + zx_mair_program(); + + if (board_regions > 0U) + { + zx_board_program_mmio_regions(0U); + } + + zx_el2_mpu_enable(); + } + + zx_board_init(); + + zx_console_puts( + "\n" + "=========================================================\n" + " Eclipse ThreadX ZoneX -- TWO PARTITIONS, ONE CORE\n" + " " ZX_PLATFORM_NAME "\n" + " Armv8-R AArch32, EL2, PMSAv8-R at both stages\n" + "=========================================================\n" + "\n" + " Two ThreadX kernels, each in its own stage-2 window, time-\n" + " sharing one logical core under a STATIC MAJOR FRAME taken\n" + " from the manifest.\n" + "\n" + " Partition A gets seven ticks of every ten and partition B\n" + " gets three. Neither can reach the other's memory, neither\n" + " can reach the ungranted granule between them, and neither\n" + " can prevent its own window from ending: the hypervisor's\n" + " timer is in GIC Group 0, so it arrives as an FIQ at EL2 --\n" + " and with HCR.FMO set, PSTATE.F is IGNORED at EL1.\n" + "\n" + " Each partition's clock advances by its own windows and by\n" + " nothing else. That is CNTVOFF, one value per partition,\n" + " and it is the difference between temporal partitioning and\n" + " time slicing a guest can observe.\n"); + + zx_fault_record_reset(zx_el2_fault_record()); + zx_guest_console_reset(); + + zx_console_puts("\n--- identity, as the core reports it ---\n"); + zx_note("MIDR ", zx_read_midr()); + zx_note("EL2 regions ", el2_regions); + zx_note("EL1 regions ", zx_el1_region_count()); + zx_note("HPRENR bits ", zx_hprenr_implemented_bits); + zx_board_report(); + + zx_check("HSCTLR.M is set, so the EL2 MPU is live", + ((zx_read_hsctlr() & ZX_HSCTLR_M) != 0U) ? 1U : 0U); + + if (el2_regions == 0U) + { + zx_console_puts("\n *** HMPUIR reports ZERO EL2 regions, so this " + "target cannot host\n *** stage 2 at all. Refusing " + "to go on.\n" + "\nZONEX RESULT: FAILED -- the target cannot host " + "stage 2\n"); + zx_console_exit(1U); + } + + /* ---------------------------------------------------------------- */ + /* HOW MANY EL1 MPU REGIONS A SWITCH HAS TO CARRY. */ + /* */ + /* Read once, here, because the count is fixed for the run and a */ + /* coprocessor read inside the switch would cost the path this */ + /* image exists to measure -- for an answer that cannot change. */ + /* ---------------------------------------------------------------- */ + + zx_context_probe_el1_regions(); + + zx_note("EL1 regions a switch carries", zx_context_el1_regions()); + zx_check("no EL1 MPU region was left out of the switch. A part with\n" + " more regions than a context block can hold would have\n" + " its highest ones silently dropped from every save --\n" + " so the outgoing partition's last windows would stay\n" + " programmed and the incoming one would inherit them,\n" + " which is an isolation failure at stage ONE that every\n" + " stage-2 check in this suite would miss", + (zx_context_el1_regions_clamped() == 0U) ? 1U : 0U); + + /* ---------------------------------------------------------------- */ + /* The manifest, the plan, the schedule. */ + /* ---------------------------------------------------------------- */ + + zx_build_manifest(board_regions); + zx_describe_launches(); + zx_board_gic_layout(&zx_gic); + zx_env.zx_env_region_budget = el2_regions; + + status = zx_manifest_verify(&zx_manifest, &zx_env, &fault); + + zx_console_puts("\n--- the manifest, checked before anything is " + "programmed ---\n"); + zx_note("zx_manifest_verify", status); + + if (status != ZX_MANIFEST_SUCCESS) + { + zx_note(" offending partition", fault.zx_fault_partition); + zx_note(" offending region ", fault.zx_fault_region); + zx_note(" the other offender ", fault.zx_fault_other_partition); + zx_check("the manifest passes every rule", 0U); + zx_console_puts("\nZONEX RESULT: FAILED\n"); + zx_console_exit(zx_probe_failures()); + } + + zx_check("the manifest passes every rule, including the one that makes\n" + " two partitions possible at all: their windows do not\n" + " overlap. PMSAv8-R has no region priority, so two\n" + " enabled regions on one address is CONSTRAINED\n" + " UNPREDICTABLE and aborts on silicon", + 1U); + + zx_check("and NEITHER partition is granted a device region of any kind,\n" + " even though both have an interrupt and a clock. A\n" + " partition's whole interrupt capability is\n" + " system-register state and its whole clock is a\n" + " register only EL2 can write", + ((zx_partitions[ZX_PART_A].zx_partition_region_count == 1U) + && (zx_partitions[ZX_PART_B].zx_partition_region_count == 1U)) + ? 1U : 0U); + + status = zx_mm_plan(&zx_manifest, board_regions, el2_regions, + zx_hprenr_implemented_bits, &zx_layout); + zx_note("zx_mm_plan", status); + zx_check("the layout fits this part's region budget AND every region it\n" + " assigned has an HPRENR enable bit, so the one-write\n" + " partition switch can reach all of them", + (status == ZX_MANIFEST_SUCCESS) ? 1U : 0U); + + if (status != ZX_MANIFEST_SUCCESS) + { + zx_console_puts("\nZONEX RESULT: FAILED\n"); + zx_console_exit(zx_probe_failures()); + } + + zx_mm_report(&zx_layout, &zx_manifest); + + status = zx_schedule_build(&zx_manifest, + (uint64_t)(zx_board_counter_hz() / ZX_TICK_HZ), + ZX_FRAME_LIMIT, &zx_schedule); + + zx_console_puts("\n--- the major frame ---\n"); + zx_note("zx_schedule_build", status); + zx_check("the schedule was built, and the major frame equals the sum of\n" + " its windows -- a manifest carries the total explicitly\n" + " so that it CAN disagree, and a disagreement means\n" + " somebody changed a window and not the total", + (status == ZX_MANIFEST_SUCCESS) ? 1U : 0U); + + if (status != ZX_MANIFEST_SUCCESS) + { + zx_console_puts("\nZONEX RESULT: FAILED\n"); + zx_console_exit(zx_probe_failures()); + } + + zx_schedule_report(&zx_schedule, &zx_manifest); + + /* ---------------------------------------------------------------- */ + /* The region set, programmed once and never again. */ + /* */ + /* Both partitions' descriptors are written at boot and a switch */ + /* changes which are ENABLED. That is what makes the switch one */ + /* register write of bounded cost instead of a loop whose length */ + /* depends on the incoming partition -- see docs/decisions.md D4. */ + /* ---------------------------------------------------------------- */ + + { + UINT part; + + zx_console_puts("\n--- the region set, programmed and read back ---\n"); + + for (part = 0U; part < ZX_PART_COUNT; part++) + { + uint32_t index = + (uint32_t)zx_layout.zx_layout_partition_first[part]; + const ZX_REGION *region = (part == ZX_PART_A) + ? &zx_regions_a[0] : &zx_regions_b[0]; + + zx_stage2_region_program(index, region); + + zx_note("region index", index); + zx_check("this partition's window reads back as the manifest\n" + " declared it -- an under-aligned base does not\n" + " fault, its low bits land on SH, AP and XN and\n" + " silently change what it grants", + zx_stage2_region_matches(index, region)); + } + } + + zx_console_puts("\n the ungranted granule between the two windows is at "); + zx_console_puthex(zx_symbol_address(__zx_partition_a_hole_start)); + zx_console_puts("\n and the one after partition B is at "); + zx_console_puthex(zx_symbol_address(__zx_partition_b_hole_start)); + zx_console_puts("\n"); + + /* ---------------------------------------------------------------- */ + /* What ZoneX takes over from each guest's boot path. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n--- what ZoneX takes over from a guest's boot path " + "---\n"); + zx_el2_prepare_guest_el1(zx_board_counter_hz()); + zx_note("CNTFRQ after ", zx_read_cntfrq()); + zx_check("CNTFRQ is programmed, because it is writable only at the\n" + " highest implemented exception level and a guest\n" + " deriving a tick interval from a zero divides by zero", + (zx_read_cntfrq() == zx_board_counter_hz()) ? 1U : 0U); + + granted = zx_grant_the_clocks(); + + /* ---------------------------------------------------------------- */ + /* REFUSE TO START A FRAME WHOSE WINDOWS COULD NEVER END. */ + /* */ + /* Everything a partition needs is up, or it is not, and the */ + /* difference decides whether the next few hundred milliseconds are */ + /* a demonstration or a hang. A frame started without a */ + /* deliverable tick does not fail: the first partition simply runs */ + /* until something outside this image stops it, and the log ends */ + /* mid-sentence with no verdict, no fault and nothing to point at. */ + /* */ + /* THIS IS THE SAME RULE AS zx_counter_is_running, ONE LEVEL UP. */ + /* That check exists because a GUEST that arms a timer against a */ + /* stopped counter waits for ever and ends the run in a harness */ + /* timeout naming nothing. This is the hypervisor's own version of */ + /* exactly that failure, and it deserves exactly that answer. */ + /* */ + /* It is also what makes the negative build a TEST rather than a */ + /* hang. zx_two_partitions_no_tick.elf deliberately leaves the */ + /* hypervisor's PPI disabled; without this it would prove its point */ + /* by never finishing, and a negative run that produces no verdict */ + /* is indistinguishable from an image that was never built. */ + /* ---------------------------------------------------------------- */ + + if (granted == 0U) + { + zx_console_puts( + "\n *** REFUSING TO START THE FRAME. Something a window\n" + " *** boundary depends on is not in place: the system counter,\n" + " *** the redistributor, or the hypervisor's own timer PPI.\n" + " *** Starting anyway would run the first partition until\n" + " *** something outside this image stopped it, and the log\n" + " *** would end mid-sentence with no verdict to read.\n"); + + zx_check("everything a window boundary depends on is in place", 0U); + zx_console_puts("\nZONEX RESULT: FAILED -- the hypervisor's own tick " + "cannot be delivered\n"); + zx_console_exit(zx_probe_failures()); + } + + /* ---------------------------------------------------------------- */ + /* Load both guests, and hand each one its options. */ + /* ---------------------------------------------------------------- */ + + zx_stage2_enable(); + zx_note("HCR now", zx_read_hcr()); + zx_check("HCR.VM is set, so stage 2 applies to EL0/EL1 for the whole of\n" + " this run", + ((zx_read_hcr() & ZX_HCR_VM) != 0U) ? 1U : 0U); + zx_check("HCR.TGE is clear, so each guest keeps its own exception\n" + " vectors -- without which its IRQ vector could not be\n" + " the thing that runs", + ((zx_read_hcr() & ZX_HCR_TGE) == 0U) ? 1U : 0U); + + zx_pmu_enable(); + + /* PARTITION A IS QUIET AND PARTITION B IS LOUD, and the asymmetry is + the demonstration rather than a saving. A guest's console is one + hypercall PER CHARACTER through a polled UART, which on silicon is + three orders of magnitude more work than everything else it does -- + so a run with both partitions printing could not resolve anything + about either. One quiet partition and one chatty one is also the + mixed-criticality shape the demonstrator is for: the critical one + does its work, the untrusted one makes noise, and the frame does not + care. + + BOTH RUN FOR EVER, because a schedule can only be shown to preempt + partitions that are still trying to run. */ + + options_a = ZX_GO_TICK | ZX_GO_QUIET | ZX_GO_FOREVER; + options_b = ZX_GO_TICK | ZX_GO_FOREVER; + probe_b = 0U; + +#ifdef ZX_TWO_CROSS + + /* THE ISOLATION CLAIM BETWEEN TWO LIVE PARTITIONS, and it is the one + thing a single partition cannot demonstrate however hard it tries. + * + * Partition B is handed an address inside partition A's window and told + * to write to it. Its guest first GRANTS ITSELF that granule in its own + * EL1 MPU -- so stage 1 permits the access and B genuinely believes it + * owns the memory -- and then writes. + * + * Stage 2 must refuse, because A's region is not in B's enabled set and + * PMSAv8-R gives EL0/EL1 no background map: an access that hits no + * enabled region faults regardless of HSCTLR.BR. That is "the stricter + * of the two stages wins" with the two stages genuinely disagreeing, + * between two partitions that are both alive. + * + * The address is well past A's mailbox, inside its code, so that a run + * in which the write DID succeed would be visibly catastrophic rather + * than quietly wrong. Nothing is written on a correct run. + * + * AND THE REST OF THE RUN IS THE SECOND HALF OF THE CLAIM. B is + * stopped, and partition A carries on to the end of the frame with its + * own schedule untouched -- which is what "a partition cannot harm its + * neighbour" has to mean if it means anything. B's windows are still + * SPENT, because a static frame does not give a dead partition's time + * away, and that path is exercised nowhere else in this suite. */ + + probe_b = (uint32_t)zx_symbol_address(__zx_partition_a_start) + 0x1000U; + options_b = ZX_GO_TICK | ZX_GO_QUIET; + + zx_console_puts("\n=========================================================\n" + " CROSS-PARTITION BUILD: B reaches into A's window\n" + "=========================================================\n" + " Partition B is told to write to "); + zx_console_puthex(probe_b); + zx_console_puts(", which is\n" + " inside partition A's window and inside nothing B was\n" + " granted. B's guest GRANTS ITSELF that granule in its\n" + " own EL1 MPU first, so stage 1 permits it and B\n" + " genuinely believes it owns the memory.\n" + "\n" + " Stage 2 must refuse anyway, and this run must PASS:\n" + " the violation is detected, named, and B is stopped --\n" + " while partition A runs to the end of the frame with\n" + " its own schedule completely untouched. That second\n" + " half is what 'a partition cannot harm its neighbour'\n" + " has to mean if it means anything.\n"); + +#endif + +#ifdef ZX_TWO_HOG + + /* THE DEMONSTRATION THIS DESIGN EXISTS FOR. Partition B masks IRQ and + FIQ at EL1 and spins for ever, making no kernel call and giving + nothing back. It must be preempted anyway, and the only thing its + masking may cost it is its OWN kernel's tick. + + Quiet as well, because a hogging partition that also printed would + be handing the machine back through a hypercall on every character, + which is the opposite of hogging. */ + + options_b = ZX_GO_TICK | ZX_GO_QUIET | ZX_GO_FOREVER | ZX_GO_HOG; + + zx_console_puts("\n=========================================================\n" + " HOSTILE BUILD: partition B masks its interrupts and\n" + " spins for ever\n" + "=========================================================\n" + " It makes no kernel call, asks the hypervisor for\n" + " nothing, and disables both IRQ and FIQ at EL1. That\n" + " is as uncooperative as an EL1 program can be.\n" + "\n" + " IT MUST BE PREEMPTED ANYWAY, and this run must PASS.\n" + " With HCR.FMO set, PSTATE.F is ignored at EL0 and EL1,\n" + " so the FIQ that ends B's window reaches EL2 whatever B\n" + " has masked -- and B cannot reach the bit that would\n" + " change that, because ICC_IGRPEN0 is redirected to the\n" + " virtual interface the moment FMO is set.\n" + "\n" + " What the masking DOES cost B is its own kernel's tick,\n" + " and that shows: B's liveness counter keeps rising while\n" + " its tick count stops. A frame driven by an interrupt\n" + " taken at EL1 would hang here instead, which is why this\n" + " is a build and not a paragraph.\n"); + +#endif + + zx_console_puts("\n--- loading partition A ---\n"); + + if (zx_prepare_partition(ZX_PART_A, options_a, 0U) == 0U) + { + zx_console_puts("\nZONEX RESULT: FAILED\n"); + zx_console_exit(zx_probe_failures()); + } + + zx_console_puts("\n--- loading partition B ---\n"); + + if (zx_prepare_partition(ZX_PART_B, options_b, probe_b) == 0U) + { + zx_console_puts("\nZONEX RESULT: FAILED\n"); + zx_console_exit(zx_probe_failures()); + } + + /* ---------------------------------------------------------------- */ + /* MEASURE THE SWITCH BEFORE RUNNING THE FRAME. */ + /* */ + /* Before, because the measurement writes into scratch contexts */ + /* and reads the machine's current EL1 state -- which after a */ + /* frame would be whichever partition happened to stop last. */ + /* Measuring here means every group is timed against the same, */ + /* known, quiescent state. */ + /* ---------------------------------------------------------------- */ + + zx_frame_measure_switch(&zx_scratch[0], &zx_scratch[1], + zx_mm_partition_mask(&zx_layout, ZX_PART_A), + zx_mm_partition_mask(&zx_layout, ZX_PART_B), + ZX_MEASURE_ROUNDS, &zx_cost); + + /* ---------------------------------------------------------------- */ + /* RUN THE FRAME. */ + /* ---------------------------------------------------------------- */ + + /* THE FLOATING-POINT TRAPS GO ON HERE, AND COME OFF THE MOMENT THE + FRAME ENDS. They bracket exactly the interval they protect, and + nothing else. + * + * zx_el2_prepare_guest_el1 OPENS the FPU, which was exactly right while + * there was one partition: there was nobody to share the register bank + * with. With two it is two guests reading each other's registers, and + * nothing in ZoneX saves FPEXC, FPSCR or the D-registers across a + * switch -- so the failure mode is a WRONG ANSWER and not a fault. + * Closing the traps turns that into an exception at EL2 with a syndrome + * naming the cause. See D23 and D25. + * + * WHY THE BRACKET IS TIGHT RATHER THAN COVERING THE WHOLE RUN. With + * the traps set, the S32Z280's debug probe cannot read the core's + * register file at all: the harness exits non-zero on a run whose own + * console said ALL CHECKS PASSED, and every refusal path that stops + * before a frame starts would fail the same way. Setting them here and + * lifting them the moment zx_frame_run returns means every exit path + * except the one with partitions actually running is unaffected -- + * which is also the clearest statement of what the traps are for. */ + + zx_el2_deny_guest_fp(); + + zx_console_puts("\n the FPU is now DENIED to both partitions, which is\n" + " the opposite of what a one-partition image does.\n" + " Nothing here saves the floating-point register bank\n" + " across a switch, so two guests would share it -- and\n" + " that failure is a wrong number rather than a fault.\n" + " A trap is a diagnosis; a shared bank is not.\n"); + + zx_frame_configure(&zx_frame, &zx_schedule, zx_context, &zx_layout, + &zx_manifest, ZX_PART_COUNT); + + zx_console_puts("\n=========================================================\n" + " THE FRAME STARTS HERE\n" + "=========================================================\n" + " Everything below the guests' own output happened under\n" + " a static schedule. Partition B prints; partition A is\n" + " quiet so that a measured run is measurable. Each line\n" + " is tagged by the partition ZoneX SCHEDULED and never by\n" + " the guest, which is why a guest cannot claim to be its\n" + " neighbour.\n\n"); + + /* THE CONSOLE TAG FOLLOWS THE SCHEDULE and is not set here. With one + partition the image attached once around the whole excursion; with a + frame the partition that owns the console changes several times a + second, so the SWITCH attaches and detaches -- see zx_frame_enter. + The tag therefore always names the partition ZoneX scheduled, which + is the only party that knows it, and a guest cannot claim to be its + neighbour even while their output interleaves. */ + + outcome = zx_frame_run(&zx_frame); + + /* THE LAST WINDOW HAS CLOSED, so the floating-point traps come off. + There are no more switches and nothing left to share. See the note + where they went on: leaving them set costs the silicon harness its + ability to read the core's registers at all. */ + + zx_el2_allow_guest_fp(); + + /* ---------------------------------------------------------------- */ + /* What happened. */ + /* ---------------------------------------------------------------- */ + + zx_report_frame(outcome); + zx_report_switch_cost(); + + zx_report_partition(ZX_PART_A); + zx_report_partition(ZX_PART_B); + + if (outcome == ZX_RUN_FAULTED) + { + zx_fault_report(zx_el2_fault_record()); + } + + ticks_a = zx_guest_mailbox_read(&zx_launch[ZX_PART_A], ZX_GD_TICKS_NOW); + ticks_b = zx_guest_mailbox_read(&zx_launch[ZX_PART_B], ZX_GD_TICKS_NOW); + + /* PRINTED IN EVERY BUILD, INCLUDING THE ONES THAT FAIL. The negative + builds are where these three numbers are most worth having: a run in + which no window ended needs to show that partition A ticked and + partition B did not, and a run refused by the validator needs to show + that the clocks came up before the refusal. A value printed only on + the passing path is a value missing from every log worth reading. */ + + zx_console_puts("\n--- the three numbers every build reports ---\n"); + zx_note("counter and GIC came up", granted); + zx_note("partition A's own ticks ", ticks_a); + zx_note("partition B's own ticks ", ticks_b); + + /* ---------------------------------------------------------------- */ + /* The demonstrandum. */ + /* ---------------------------------------------------------------- */ + +#ifdef ZX_TWO_OVERLAP + + zx_console_puts("\n This build should never have reached here: its\n" + " manifest declares two overlapping windows and the\n" + " validator must refuse it.\n"); + zx_check("the validator refused the overlapping manifest (this check is\n" + " EXPECTED TO FAIL if it is ever reached at all)", 0U); + +#elif defined(ZX_TWO_NO_TICK) + + /* THIS POINT IS UNREACHABLE IN THIS BUILD, and reaching it is itself a + failure worth reporting. + * + The negative build leaves the hypervisor's own PPI disabled, so the + refusal above stops the run before a frame is ever started: the whole + point of that refusal is that a frame which cannot end a window must + not be started, because it would prove its point by never finishing + and a negative run producing no verdict is indistinguishable from an + image that was never built. + * + So the check below exists to catch the refusal having been removed or + weakened. If control gets here, a frame ran without a deliverable + tick -- which means it hung and was interrupted, or the precondition + stopped being checked. */ + + zx_console_puts("\n *** The frame was started in a build whose tick\n" + " *** cannot be delivered. The precondition that was\n" + " *** supposed to refuse it did not.\n"); + zx_check("a frame is never started without a deliverable tick", 0U); + +#else + + zx_console_puts("\n=========================================================\n" + " TWO THREADX PARTITIONS UNDER A STATIC MAJOR FRAME\n" + "=========================================================\n"); + + zx_check("the counter, the GIC and the routing all came up BEFORE the\n" + " frame started -- checked as a precondition and not as\n" + " an observation, because a frame that cannot end a\n" + " window does not fail, it hangs", + granted); + + zx_check("the frame ran to its declared limit rather than stopping\n" + " early. A bounded run is what lets this be asserted on\n" + " at all -- 'the harness timed out' is not a result", + ((outcome == ZX_RUN_FRAME_DONE) + && (zx_schedule.zx_schedule_frames == ZX_FRAME_LIMIT)) + ? 1U : 0U); + +#ifndef ZX_TWO_CROSS + + /* THE CHECKS THAT ONLY MEAN ANYTHING WHEN BOTH PARTITIONS SURVIVE. + * + * The cross-partition build deliberately kills one of them, so a frame + * in which one partition stopped is the RESULT there and would be a + * failure here. Separating them is not bookkeeping: a check skipped in + * the build it cannot hold for is honest, and a check loosened until it + * holds everywhere has stopped saying anything. */ + + zx_check("EVERY WINDOW ENDED AT A BOUNDARY. Two windows per frame for\n" + " the whole run, and each one was taken from a partition\n" + " that had not asked to give it up -- by an FIQ delivered\n" + " to EL2 through a redistributor no partition can address", + (zx_frame.zx_frame_switches + >= ((uint32_t)ZX_FRAME_LIMIT * ZX_PART_COUNT) - 1U) ? 1U : 0U); + + zx_check("no window boundary was MISSED. A missed deadline means a\n" + " window was shorter than its own partition switch: the\n" + " schedule would keep turning, in the right order and the\n" + " right proportions, with every frame longer than it was\n" + " declared to be. Nothing else in this run would show it", + (zx_schedule.zx_schedule_missed == 0UL) ? 1U : 0U); + + zx_check("and no FIQ arrived that the hypervisor's own timer had not\n" + " raised, so every switch above was performed for a\n" + " reason somebody can name", + (zx_frame.zx_frame_spurious == 0U) ? 1U : 0U); + + zx_check("BOTH partitions were entered, more than once each. One\n" + " partition running many times and the other never is a\n" + " perfectly plausible-looking log, and it is not time\n" + " partitioning", + ((zx_context[ZX_PART_A].zx_ctx_entries > 1U) + && (zx_context[ZX_PART_B].zx_ctx_entries > 1U)) ? 1U : 0U); + + zx_check("both reached their own verdicts, so the loader, the entry\n" + " points and both guests' kernels are not what is being\n" + " tested here", + ((zx_guest_mailbox_read(&zx_launch[ZX_PART_A], ZX_GD_VERDICT) + == ZX_GV_PASSED) + && (zx_guest_mailbox_read(&zx_launch[ZX_PART_B], ZX_GD_VERDICT) + == ZX_GV_PASSED)) ? 1U : 0U); + + zx_check("BOTH KEPT RUNNING AFTERWARDS, so every window after their\n" + " verdicts was taken from a partition that was genuinely\n" + " using it. A partition that had finished would be idled\n" + " through its windows and would prove nothing about\n" + " preemption", + ((zx_guest_mailbox_read(&zx_launch[ZX_PART_A], ZX_GD_LIVE) > 0U) + && (zx_guest_mailbox_read(&zx_launch[ZX_PART_B], ZX_GD_LIVE) + > 0U)) ? 1U : 0U); + + zx_check("neither partition saw an INTID it was not granted -- so\n" + " neither received the hypervisor's own timer, nor the\n" + " other's, nor anything else", + ((zx_guest_mailbox_read(&zx_launch[ZX_PART_A], ZX_GD_ODD_INTID) + == 0U) + && (zx_guest_mailbox_read(&zx_launch[ZX_PART_B], ZX_GD_ODD_INTID) + == 0U)) ? 1U : 0U); + + zx_check("and neither guest's own vectors reported a stage-1 fault, so\n" + " neither kernel did anything its OWN MPU refused --\n" + " which is a different claim from stage 2 and is made by\n" + " a different mechanism", + ((zx_guest_mailbox_read(&zx_launch[ZX_PART_A], ZX_GD_STAGE1) + == ZX_GS_NONE) + && (zx_guest_mailbox_read(&zx_launch[ZX_PART_B], ZX_GD_STAGE1) + == ZX_GS_NONE)) ? 1U : 0U); + + /* ---------------------------------------------------------------- */ + /* THE TEMPORAL CLAIM, from both sides. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n--- each partition's own time, against its budget ---\n"); + + zx_note("A: counts on the core", + (uint32_t)(zx_context[ZX_PART_A].zx_ctx_time_on_core + & 0xFFFFFFFFU)); + zx_note("A: its own ticks ", ticks_a); + zx_note("B: counts on the core", + (uint32_t)(zx_context[ZX_PART_B].zx_ctx_time_on_core + & 0xFFFFFFFFU)); + zx_note("B: its own ticks ", ticks_b); + + zx_console_puts( + "\n" + " A HAS SEVEN TICKS OF EVERY TEN AND B HAS THREE, so A's own clock\n" + " must have advanced by more than B's -- and by roughly the ratio\n" + " of their windows rather than by the ratio of anything else. The\n" + " check below is deliberately loose about the exact figure and\n" + " strict about the ORDER: a run in which B's clock had caught A's\n" + " would be a run in which a partition could see time it was not\n" + " given, and that is the failure this whole mechanism exists to\n" + " prevent. It leaves the system running perfectly and lying about\n" + " time, which nothing else here would notice.\n"); + + zx_check("BOTH partitions' clocks advanced, so neither was frozen out\n" + " of its own windows as well as out of its neighbour's", + ((ticks_a > 0U) && (ticks_b > 0U)) ? 1U : 0U); + + zx_check("and PARTITION A'S CLOCK RAN AHEAD OF PARTITION B'S, in the\n" + " direction their window budgets say it must. Each\n" + " partition's virtual counter advanced by the counts it\n" + " spent on the core and by none of the counts it did\n" + " not: that is CNTVOFF, one value per partition, moved\n" + " by exactly the interval each was away", + (ticks_a > ticks_b) ? 1U : 0U); + + /* AND THE RATIO, WHICH IS THE CLAIM ITSELF RATHER THAN A SYMPTOM OF IT. + * + * If each partition's clock advances by exactly the counts it spent on + * the core, then A's total and B's total stand in the ratio of their + * declared windows -- seven to three -- and that is an equality with no + * slack in it: A x 3 must equal B x 7. + * + * IT IS WRITTEN AS TWO PRODUCTS AND NOT AS A DIVISION. Integer division + * of two counts throws away exactly the precision the check needs, and a + * ratio computed as A/B on numbers this size is a small integer whose + * error bars are wider than the defect being looked for. + * + * IN 64-BIT, because each product is a count of counter counts times a + * window length: at the model's 100 MHz a six-frame run already reaches + * 1.3 x 10^8 in each factor, and a longer run or a faster counter passes + * 2^32 without anything appearing to be wrong. + * + * THE TOLERANCE IS 2% AND IT IS NOT ARBITRARY. Two things legitimately + * perturb the ratio and neither is a defect: the FIRST window is + * entered from a cold start rather than from a switch, and the last one + * is cut short by the frame limit rather than by a boundary. Both are + * one window out of twelve. What 2% will not absorb is a partition + * seeing its neighbour's time at all -- that is a factor of three. + * + * A NOTE ON WHAT THIS REPLACED, because it is the mistake this file is + * most at risk of repeating. The check here used to compare A's time + * against A's plus B's and conclude that A had not seen wall clock. + * That is arithmetically true for any positive B: it was a tautology + * wearing the words of a claim, and it would have passed on a run where + * CNTVOFF was never written at all. */ + + { + uint64_t a_scaled = zx_context[ZX_PART_A].zx_ctx_time_on_core + * (uint64_t)ZX_WINDOW_B_TICKS; + uint64_t b_scaled = zx_context[ZX_PART_B].zx_ctx_time_on_core + * (uint64_t)ZX_WINDOW_A_TICKS; + uint64_t apart = (a_scaled > b_scaled) + ? (a_scaled - b_scaled) : (b_scaled - a_scaled); + uint64_t allowed = a_scaled / 50U; /* two per cent */ + + zx_note("A's counts x B's window ticks", (uint32_t)(a_scaled + & 0xFFFFFFFFU)); + zx_note("B's counts x A's window ticks", (uint32_t)(b_scaled + & 0xFFFFFFFFU)); + + zx_check("AND THE TWO CLOCKS STAND IN THE RATIO OF THEIR DECLARED\n" + " WINDOWS, seven to three, to within two per cent.\n" + " This is the temporal claim itself and not a\n" + " symptom of it: each partition's virtual counter\n" + " advanced by the counts it spent on the core and by\n" + " none of the counts it did not. A partition that\n" + " could see wall clock would be out by a FACTOR of\n" + " three here, not by a percentage", + ((a_scaled > 0U) && (apart <= allowed)) ? 1U : 0U); + } + +#endif /* the both-survive block */ + +#ifdef ZX_TWO_CROSS + + /* THE OTHER HALF OF THE CROSS-PARTITION CLAIM, and the half that a + single partition could never make: partition B tried to write inside + partition A's window, stage 2 refused, B was stopped -- AND A RAN TO + THE END OF THE FRAME WITH ITS OWN SCHEDULE UNTOUCHED. + * + * "A partition cannot reach its neighbour's memory" is only half of + * what isolation has to mean. The other half is that being attacked + * costs the neighbour nothing: not a window, not a tick, not a + * boundary. A run in which A's clock had slowed because B misbehaved + * would satisfy every memory check above and would not be temporal + * partitioning. */ + + zx_check("PARTITION B WAS STOPPED AT ITS BOUNDARY, and by a stage-2\n" + " fault rather than by anything it asked for. Its own\n" + " EL1 MPU said it owned the granule -- the guest granted\n" + " itself that region before writing -- and the stricter\n" + " of the two stages won", + ((zx_frame.zx_frame_stopped_index == ZX_PART_B) + && (zx_frame.zx_frame_stop_outcome == ZX_RUN_FAULTED)) + ? 1U : 0U); + + zx_check("and the syndrome is a data abort ROUTED to EL2, which is a\n" + " stage-2 violation and not the guest's own MPU\n" + " complaining -- a stage-1 fault never reaches the\n" + " hypervisor at all, and that difference is what makes\n" + " the two stages distinguishable in a log", + (zx_fault_ec(zx_el2_fault_record()->zx_fault_hsr) + == ZX_EC_DABT_ROUTED) ? 1U : 0U); + + zx_check("and HDFAR names the address B aimed at, inside A's window, so\n" + " the fault is attributable to the access and not merely\n" + " coincident with it", + (zx_el2_fault_record()->zx_fault_hdfar == probe_b) ? 1U : 0U); + + zx_check("PARTITION A WAS UNAFFECTED. It ran every one of its windows,\n" + " to the end of the frame, while its neighbour was\n" + " faulting and then dead. Being attacked cost it\n" + " nothing -- not a window, not a tick, not a boundary", + ((zx_context[ZX_PART_A].zx_ctx_entries >= ZX_FRAME_LIMIT) + && (outcome == ZX_RUN_FRAME_DONE)) ? 1U : 0U); + + zx_check("and B's windows were still SPENT rather than given away. A\n" + " static frame does not hand a dead partition's time to\n" + " its neighbour: doing so would make A's schedule depend\n" + " on B's behaviour, which is the coupling temporal\n" + " partitioning is bought to remove. A's own clock is\n" + " still seven tenths of the frame and not all of it", + (zx_context[ZX_PART_A].zx_ctx_time_on_core + < (uint64_t)ZX_FRAME_LIMIT * (uint64_t)ZX_FRAME_TICKS + * (uint64_t)(zx_board_counter_hz() / ZX_TICK_HZ) + * 4U / 5U) ? 1U : 0U); + +#endif + +#ifdef ZX_TWO_HOG + + zx_check("THE HOGGING PARTITION WAS PREEMPTED ANYWAY. It masked IRQ\n" + " and FIQ at EL1 and never made another kernel call, and\n" + " its window ended on time regardless -- because with\n" + " HCR.FMO set, PSTATE.F is IGNORED at EL0 and EL1 and a\n" + " partition cannot reach the physical Group 0 enable", + (zx_context[ZX_PART_B].zx_ctx_preemptions > 1U) ? 1U : 0U); + + zx_check("and the masking cost it exactly what it should have cost: its\n" + " OWN kernel's tick stopped while its liveness counter\n" + " kept rising. That pair is the evidence that the\n" + " masking really took effect at EL1 and really did\n" + " nothing to the hypervisor", + ((zx_guest_mailbox_read(&zx_launch[ZX_PART_B], ZX_GD_LIVE) > 0U) + && (ticks_b < ticks_a)) ? 1U : 0U); + +#endif + + zx_console_puts("\n--- the guests printed through the hypervisor ---\n"); + zx_note("characters forwarded", zx_guest_console_characters()); + zx_check("and no character arrived while NO partition was attached", + (zx_guest_console_orphans() == 0U) ? 1U : 0U); + +#endif + + /* ---------------------------------------------------------------- */ + /* Verdict. The exact strings are what the runners grep for. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n=========================================================\n" + " what this run does and does not prove\n" + "=========================================================\n" + " " ZX_PLATFORM_NAME "\n" + "\n" + " PROVED, if the checks above are green: two ThreadX\n" + " partitions time-share one logical core under a static\n" + " major frame; each is confined to its own window at\n" + " stage 2; each window ends at a boundary the partition\n" + " cannot defer or mask; each partition's clock advances\n" + " by its own windows and by nothing else; and the switch\n" + " between them is bounded, measured, and broken down by\n" + " group.\n" + "\n" + " NOT PROVED, and the omissions are deliberate rather\n" + " than pending:\n" + "\n" + " * This is TIME partitioning on ONE logical core, not\n" + " spatial partitioning across several. On lockstep\n" + " silicon there is one core to share.\n" + "\n" + " * Interrupt LATENCY is not a hypervisor-controlled\n" + " quantity here. Guest interrupts go straight to\n" + " EL1, which is why they cost what they always did --\n" + " and why bounding them needs the List Registers this\n" + " core has and this phase does not use.\n" + "\n" + " * A faulted partition is STOPPED, not restarted.\n" + " Supervised restart is a later phase and a\n" + " hypervisor that quietly re-entered a partition\n" + " which had just violated its boundary would be doing\n" + " the easy half of it.\n" + "\n" + " * Nothing about timing on a functional model. Every\n" + " cycle count above has to come from silicon before\n" + " it is quoted.\n" + "\n" + " ONE FAILURE MODE IS DELIBERATELY NOT A BUILD, and it is\n" + " worth saying why rather than leaving a gap: a partition\n" + " faulting at the exact instant of a frame boundary. It\n" + " cannot be arranged reproducibly -- it is a race whose\n" + " window is a few cycles wide -- and a test that provoked\n" + " it once would not be a regression, it would be an\n" + " anecdote.\n" + "\n" + " What can be said instead is structural. The two events\n" + " arrive at DIFFERENT VECTORS -- a fault at the Hyp trap\n" + " entry, a boundary at the FIQ vector -- and a core takes\n" + " one exception at a time. Whichever arrives first is\n" + " taken, and the other is still pending afterwards: a\n" + " fault taken first comes back to the hypervisor with the\n" + " boundary's FIQ still asserted at the GIC, and a boundary\n" + " taken first re-enters the faulting partition, which\n" + " faults again on the same instruction. Neither order\n" + " loses an event, because neither event is edge-triggered\n" + " and neither is acknowledged until it is handled.\n"); + + zx_console_puts("\n checks failed: "); + zx_console_putdec(zx_probe_failures()); + zx_console_puts("\n"); + + if (zx_probe_failures() == 0U) + { + zx_console_puts("\nZONEX RESULT: ALL CHECKS PASSED\n"); + } + else + { + zx_console_puts("\nZONEX RESULT: FAILED\n"); + } + + zx_console_exit(zx_probe_failures()); +} diff --git a/examples/fvp_baser_aemv8r/CMakeLists.txt b/examples/fvp_baser_aemv8r/CMakeLists.txt index c3b9763..bab885a 100644 --- a/examples/fvp_baser_aemv8r/CMakeLists.txt +++ b/examples/fvp_baser_aemv8r/CMakeLists.txt @@ -157,7 +157,7 @@ target_compile_definitions(zx_probe_badattr.elf PRIVATE ZX_PROBE_BAD_ATTR) ############################################################################### # # THE GUEST IS BUILT BY A SEPARATE CMAKE INVOCATION, as an ExternalProject. -# Not an add_subdirectory, and the reasons are in guest_a/CMakeLists.txt -- +# Not an add_subdirectory, and the reasons are in guest/CMakeLists.txt -- # briefly: the guest is ThreadX and stays C99 with ThreadX's own settings # while ZoneX is C17 with -Wpedantic, the guest is configured with THREADX's # toolchain file so that it is built the way ThreadX's own CI builds it, and a @@ -171,8 +171,81 @@ target_compile_definitions(zx_probe_badattr.elf PRIVATE ZX_PROBE_BAD_ATTR) if(ZX_THREADX_SOURCE_DIR) include(ExternalProject) + # THE PARTITION WINDOWS, AND THE ONE PLACE THEY ARE WRITTEN DOWN. + # + # These four numbers are the contract between the hypervisor's linker + # scripts, its manifest, and the address each guest is LINKED for. They + # were three separate copies of the same constants until a second + # partition made the cost of that obvious; now the guest builds are + # configured from here and the hypervisor's scripts assert against what + # they produce. + # + # Partition A's window is deliberately UNCHANGED from the one-partition + # image's -- same base, same size -- so that the guest running in it is + # byte for byte the guest those images already run. A second partition + # was supposed to cost one more window and one more blob, and keeping A + # fixed is what makes that literally true. + # + # PARTITION B'S BASE IS NOT ROUND, and the reason is the ungranted + # granule between the two windows. Sixty-four bytes that belong to + # nobody sit immediately after A and immediately before B, so a region + # limit that is one granule too generous at EITHER end is caught by an + # access that should fault. An ungranted address far from every grant + # proves only that unmapped memory faults; adjacency is what makes it a + # boundary test. + set(ZX_PART_A_BASE 0x02000000) + set(ZX_PART_A_SIZE 0x00040000) + set(ZX_PART_B_BASE 0x02040040) + set(ZX_PART_B_SIZE 0x00040000) + + # ONE GUEST PROJECT, CONFIGURED ONCE PER PARTITION. The sub-project + # takes its window and its name as arguments and generates its own linker + # script from a template; see guest/CMakeLists.txt for why that is a + # parameter rather than a second copy of the file. + function(zx_add_guest_project guest_name window_base window_size) + ExternalProject_Add(zx_${guest_name} + SOURCE_DIR ${ZX_FVP_DIR}/guest + BINARY_DIR ${CMAKE_CURRENT_BINARY_DIR}/${guest_name} + CMAKE_GENERATOR ${CMAKE_GENERATOR} + CMAKE_ARGS + -DCMAKE_TOOLCHAIN_FILE=${ZX_GUEST_TOOLCHAIN} + -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} + -DZX_THREADX_SOURCE_DIR=${ZX_THREADX_SOURCE_DIR} + -DZX_GUEST_COMMON_DIR=${ZX_COMMON_DIR} + -DZX_GUEST_NAME=${guest_name} + -DZX_GUEST_WINDOW_BASE=${window_base} + -DZX_GUEST_WINDOW_SIZE=${window_size} + # ZoneX's own warning module, so that ZoneX's guest support + # faces ZoneX's warning set inside a ThreadX build. The + # sub-project applies it to ZoneX's three files and to nothing + # else; see guest/CMakeLists.txt for why that distinction is + # load-bearing. + -DZX_CMAKE_DIR=${CMAKE_SOURCE_DIR}/cmake + # And whether those warnings are fatal, carried across the + # project boundary by hand. CMAKE_COMPILE_WARNING_AS_ERROR + # does NOT propagate into an ExternalProject: the sub-build is + # a separate CMake invocation and inherits nothing that is not + # listed here. That is why the strict lanes were compiling the + # guest support with warnings merely printed -- when they + # compiled it at all. + -DZX_GUEST_WARNINGS_AS_ERRORS=${CMAKE_COMPILE_WARNING_AS_ERROR} + BUILD_BYPRODUCTS ${CMAKE_CURRENT_BINARY_DIR}/${guest_name}/${guest_name}.bin + # BUILD_ALWAYS, because the sub-build has its own dependency graph + # and this one cannot see into it. Without it, editing the guest's + # application would leave a stale blob embedded in an image that + # relinked happily -- the same class of failure as a linker pattern + # that matches nothing, and just as quiet. + BUILD_ALWAYS TRUE + INSTALL_COMMAND "" + USES_TERMINAL_CONFIGURE FALSE + USES_TERMINAL_BUILD FALSE + ) + endfunction() + set(ZX_GUEST_A_BUILD ${CMAKE_CURRENT_BINARY_DIR}/guest_a) set(ZX_GUEST_A_BLOB ${ZX_GUEST_A_BUILD}/guest_a.bin) + set(ZX_GUEST_B_BUILD ${CMAKE_CURRENT_BINARY_DIR}/guest_b) + set(ZX_GUEST_B_BLOB ${ZX_GUEST_B_BUILD}/guest_b.bin) # ThreadX's own toolchain file, not ZoneX's. They select the same # compiler and the same -mcpu, deliberately, but ZoneX's sets ZX_ARCH and @@ -193,38 +266,8 @@ if(ZX_THREADX_SOURCE_DIR) ${ZX_THREADX_SOURCE_DIR}/cmake/cortex_r52.cmake) endif() - ExternalProject_Add(zx_guest_a - SOURCE_DIR ${ZX_FVP_DIR}/guest_a - BINARY_DIR ${ZX_GUEST_A_BUILD} - CMAKE_GENERATOR ${CMAKE_GENERATOR} - CMAKE_ARGS - -DCMAKE_TOOLCHAIN_FILE=${ZX_GUEST_TOOLCHAIN} - -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} - -DZX_THREADX_SOURCE_DIR=${ZX_THREADX_SOURCE_DIR} - -DZX_GUEST_COMMON_DIR=${ZX_COMMON_DIR} - # ZoneX's own warning module, so that ZoneX's guest support faces - # ZoneX's warning set inside a ThreadX build. The sub-project - # applies it to ZoneX's three files and to nothing else; see - # guest_a/CMakeLists.txt for why that distinction is load-bearing. - -DZX_CMAKE_DIR=${CMAKE_SOURCE_DIR}/cmake - # And whether those warnings are fatal, carried across the project - # boundary by hand. CMAKE_COMPILE_WARNING_AS_ERROR does NOT - # propagate into an ExternalProject: the sub-build is a separate - # CMake invocation and inherits nothing that is not listed here. - # That is why the strict lanes were compiling the guest support - # with warnings merely printed -- when they compiled it at all. - -DZX_GUEST_WARNINGS_AS_ERRORS=${CMAKE_COMPILE_WARNING_AS_ERROR} - BUILD_BYPRODUCTS ${ZX_GUEST_A_BLOB} - # BUILD_ALWAYS, because the sub-build has its own dependency graph and - # this one cannot see into it. Without it, editing the guest's - # application would leave a stale blob embedded in an image that - # relinked happily -- which is the same class of failure as a linker - # pattern that matches nothing, and just as quiet. - BUILD_ALWAYS TRUE - INSTALL_COMMAND "" - USES_TERMINAL_CONFIGURE FALSE - USES_TERMINAL_BUILD FALSE - ) + zx_add_guest_project(guest_a ${ZX_PART_A_BASE} ${ZX_PART_A_SIZE}) + zx_add_guest_project(guest_b ${ZX_PART_B_BASE} ${ZX_PART_B_SIZE}) # One function, because the three probe variants are the same image with # one definition changed. Building them from a shared function is what @@ -387,6 +430,114 @@ if(ZX_THREADX_SOURCE_DIR) zx_add_preemptive(zx_preemptive_no_freeze.elf) target_compile_definitions(zx_preemptive_no_freeze.elf PRIVATE ZX_PREEMPT_NO_FREEZE) + ########################################################################### + # TWO PARTITIONS: the image this whole repository has been building to + ########################################################################### + # + # It embeds BOTH guests, and that is the one structural difference from + # every image above. A guest is linked for its window, so two windows are + # two different sets of bytes; there is no arrangement in which one blob + # serves both, and the loader's header check is what would catch an + # attempt. + # + # It shares the loader with the single-partition images -- zx_guest_launch.c + # -- and it shares the GUEST SOURCE with them byte for byte. Whether a + # partition has a clock, prints, keeps running after its verdict or masks + # its own interrupts is a word in the mailbox and not a build, so the image + # that demonstrates isolation and the image that demonstrates time + # partitioning load the same kernel. + function(zx_add_two_partitions target_name) + add_executable(${target_name} EXCLUDE_FROM_ALL + ${ZX_COMMON_DIR}/zx_two_partitions.c + ${ZX_COMMON_DIR}/zx_guest_launch.c + ${ZX_COMMON_DIR}/zx_report.c + ${ZX_COMMON_DIR}/zx_guest_image.S + ${ZX_FVP_DIR}/zx_board.c + ) + + add_dependencies(${target_name} zx_guest_a zx_guest_b) + + target_include_directories(${target_name} PRIVATE + ${ZX_FVP_DIR} + ${ZX_COMMON_DIR} + ) + + # TWO blob directories on the assembler's include path, and the second + # guest's .incbin switched on. ZX_GUEST_B_BLOB is a preprocessor + # define rather than a second .S file because .incbin names a file + # that does not exist in a single-partition build -- an unconditional + # reference would fail the assembly of every other image here. + set_source_files_properties(${ZX_COMMON_DIR}/zx_guest_image.S + PROPERTIES + COMPILE_OPTIONS + "-Wa,-I${ZX_GUEST_A_BUILD};-Wa,-I${ZX_GUEST_B_BUILD};-DZX_GUEST_B_BLOB" + OBJECT_DEPENDS "${ZX_GUEST_A_BLOB};${ZX_GUEST_B_BLOB}" + TARGET_DIRECTORY ${target_name} + ) + + target_link_libraries(${target_name} PRIVATE + eclipse-threadx::zonex + zonex::warnings + ) + + target_link_options(${target_name} PRIVATE + -T${ZX_FVP_DIR}/zx_two_partitions.lds + -nostartfiles + -Wl,-Map=${target_name}.map + ${ZX_LINK_QUIET_RWX} + ) + + set_target_properties(${target_name} PROPERTIES + LINK_DEPENDS ${ZX_FVP_DIR}/zx_two_partitions.lds + ) + endfunction() + + # The image the suite runs. + zx_add_two_partitions(zx_two_partitions.elf) + + # THE HOSTILE BUILD, and it must PASS. Partition B masks IRQ and FIQ at + # EL1 and spins for ever, making no kernel call and asking the hypervisor + # for nothing. It has to be preempted anyway, because with HCR.FMO set + # PSTATE.F is ignored at EL0 and EL1 -- so this is the build that turns + # the central design claim into a test result rather than a paragraph. + # + # A frame driven by an interrupt taken at EL1 would HANG here. + zx_add_two_partitions(zx_two_partitions_hog.elf) + target_compile_definitions(zx_two_partitions_hog.elf PRIVATE ZX_TWO_HOG) + + # THE TWO NEGATIVE VERIFICATIONS, and they break different halves. + # + # Without the hypervisor's own PPI enabled, the comparator still expires + # and the GIC is simply never told to deliver it: no window can end, + # partition A runs until the harness stops it, and partition B never runs + # at all. That is what makes the positive run's preemption checks + # evidence -- a check whose pass condition is "the window ended" has to be + # capable of failing. + zx_add_two_partitions(zx_two_partitions_no_tick.elf) + target_compile_definitions(zx_two_partitions_no_tick.elf PRIVATE + ZX_TWO_NO_TICK) + + # THE CROSS-PARTITION BUILD, and it must PASS. Partition B is told to + # write inside partition A's window; its guest grants itself that granule + # in its OWN EL1 MPU first, so stage 1 permits the access and B genuinely + # believes it owns the memory. Stage 2 refuses, B is stopped, and A runs + # to the end of the frame with its schedule untouched. + # + # It is in the passing group because both halves of it are the claim: the + # violation is DETECTED, and being attacked costs the neighbour nothing. + # A run where it started failing would mean one partition had found a way + # to reach -- or to delay -- the other. + zx_add_two_partitions(zx_two_partitions_cross.elf) + target_compile_definitions(zx_two_partitions_cross.elf PRIVATE ZX_TWO_CROSS) + + # And two overlapping windows, which the validator must refuse BY NAME + # before a single region is programmed. Two enabled regions on one + # address is CONSTRAINED UNPREDICTABLE and aborts on the S32Z280, so + # discovering it at run time is not an option this architecture offers. + zx_add_two_partitions(zx_two_partitions_overlap.elf) + target_compile_definitions(zx_two_partitions_overlap.elf PRIVATE + ZX_TWO_OVERLAP) + else() message(STATUS "ZoneX: ZX_THREADX_SOURCE_DIR is empty, so the ThreadX guest images " @@ -487,6 +638,51 @@ if(ZX_FVP_BASER_AEMV8R) --expect fail) endforeach() + # THE TWO-PARTITION IMAGES. Two that must pass and two that must + # fail, and the pair that must fail is what makes the pair that + # must pass evidence rather than observation. + # + # The HOG build is in the passing group deliberately: its whole + # point is that a partition doing the most uncooperative thing an + # EL1 program can do changes NOTHING about the schedule. A run + # where it started failing would mean a partition had found a way + # to defer the end of its own window. + # + # The timeout is longer than the other images': this one runs six + # major frames of simulated time with two kernels ticking through + # them, and a functional model is not fast. A timeout that had to + # be guessed is one that eventually fires on somebody's slower + # machine and is read as a hang. + foreach(zx_positive "" _hog _cross) + add_test(NAME zx-fvp-two-partitions${zx_positive} + COMMAND ${Python3_EXECUTABLE} ${ZX_FVP_RUNNER} + --elf $ + --fvp ${ZX_FVP_BASER_AEMV8R} + --timeout 600) + endforeach() + + foreach(zx_negative no_tick overlap) + add_test(NAME zx-fvp-two-partitions-${zx_negative} + COMMAND ${Python3_EXECUTABLE} ${ZX_FVP_RUNNER} + --elf $ + --fvp ${ZX_FVP_BASER_AEMV8R} + --timeout 600 + --expect fail) + endforeach() + + add_custom_target(zx-run-two-partitions + COMMAND ${ZX_FVP_BASER_AEMV8R} + -C cluster0.NUM_CORES=1 + -C bp.vis.disable_visualisation=1 + -C bp.terminal_0.start_telnet=0 + -C bp.pl011_uart0.out_file=- + -C bp.pl011_uart0.unbuffered_output=1 + -a $ + DEPENDS zx_two_partitions.elf + USES_TERMINAL + COMMENT "Running the ZoneX two-partition image on FVP_BaseR_AEMv8R..." + ) + add_custom_target(zx-run-preemptive COMMAND ${ZX_FVP_BASER_AEMV8R} -C cluster0.NUM_CORES=1 diff --git a/examples/fvp_baser_aemv8r/guest/CMakeLists.txt b/examples/fvp_baser_aemv8r/guest/CMakeLists.txt index 147e716..d005cce 100644 --- a/examples/fvp_baser_aemv8r/guest/CMakeLists.txt +++ b/examples/fvp_baser_aemv8r/guest/CMakeLists.txt @@ -13,7 +13,14 @@ # SPDX-License-Identifier: MIT and CC0-1.0 ############################################################################### # -# The ThreadX guest for ZoneX partition A on the Armv8-R AEM FVP. +# The ThreadX guest for ONE ZoneX partition on the Armv8-R AEM FVP. +# +# WHICH partition is an argument, not a copy of this file. A second guest +# was meant to be a data change and it was not: it was going to be a second +# copy of everything below, differing in one address. Two copies of an +# argument stay true until the day they do not, so the window base, the +# window size and the image name arrive as CMake variables and this project +# is CONFIGURED ONCE PER PARTITION into a build directory of its own. # # THIS IS A PROJECT OF ITS OWN, configured and built by a SEPARATE CMake # invocation from a superbuild step in the parent example. It is not an @@ -34,17 +41,19 @@ # images in one link resolve into each other silently. Separate projects # make that impossible rather than merely unlikely. # -# What crosses back is one file: guest_a.bin, the raw image, which the -# hypervisor pulls in with .incbin. guest_a.elf and guest_a.map stay here, +# What crosses back is one file: .bin, the raw image, which the +# hypervisor pulls in with .incbin. The ELF and its map file stay here, # beside it, because a fault report names a guest PC and the only way to turn -# that back into a function is the map file. +# that back into a function is the map file -- and with two partitions there +# are two map files, one per window, which is exactly what a report naming an +# address in one of them needs. cmake_minimum_required(VERSION 3.28...4.2) -project(zx_guest_a +project(zx_guest VERSION 0.1.0 LANGUAGES C ASM - DESCRIPTION "A ThreadX guest for ZoneX partition A" + DESCRIPTION "A ThreadX guest for one ZoneX partition" ) if(NOT ZX_THREADX_SOURCE_DIR) @@ -61,6 +70,40 @@ if(NOT ZX_GUEST_COMMON_DIR) message(FATAL_ERROR "ZX_GUEST_COMMON_DIR was not passed to the guest build.") endif() +# WHICH PARTITION THIS GUEST IS FOR. Every one of these is checked rather +# than defaulted: a guest silently linked for somebody else's window is the +# failure the image header exists to catch at boot, and catching it at +# CONFIGURE time costs nothing and names the cause. +# +# ZX_GUEST_NAME becomes the ELF, the map file and the raw blob, and the +# hypervisor's .incbin names that blob literally -- so a guest built under +# the wrong name is a link that fails rather than an image that runs the +# wrong kernel. +if(NOT ZX_GUEST_NAME) + message(FATAL_ERROR + "ZX_GUEST_NAME was not passed to the guest build, so this guest has " + "no identity: the hypervisor embeds its blob by name.") +endif() + +if(NOT ZX_GUEST_WINDOW_BASE OR NOT ZX_GUEST_WINDOW_SIZE) + message(FATAL_ERROR + "ZX_GUEST_WINDOW_BASE and ZX_GUEST_WINDOW_SIZE were not both passed " + "to the guest build.\n" + "A guest is LINKED for one window and every absolute address in it is " + "baked in; there is no defensible default, because a guest linked for " + "the wrong address starts and then faults at its first literal pool " + "load, at an address that looks entirely reasonable in the report.") +endif() + +# The linker script, with this partition's window substituted in. Generated +# into this project's own binary directory, so the two partitions' scripts +# cannot be confused with each other or with the template. +configure_file( + ${CMAKE_CURRENT_LIST_DIR}/link_partition.lds.in + ${CMAKE_CURRENT_BINARY_DIR}/link_partition.lds + @ONLY +) + # ZONEX'S OWN WARNING SET, READ FROM ZONEX'S OWN MODULE. # # This project is configured by a SEPARATE CMake invocation with ThreadX's @@ -280,7 +323,7 @@ function(zx_add_guest target_name) target_link_libraries(${target_name} PRIVATE threadx) target_link_options(${target_name} PRIVATE - -T${GUEST_DIR}/link_partition_a.lds + -T${CMAKE_CURRENT_BINARY_DIR}/link_partition.lds -nostartfiles -Wl,-Map=${target_name}.map ${GUEST_LINK_QUIET_RWX} @@ -290,12 +333,12 @@ function(zx_add_guest target_name) # would not trigger a relink and a stale guest would be loaded against new # region boundaries. set_target_properties(${target_name} PROPERTIES - LINK_DEPENDS ${GUEST_DIR}/link_partition_a.lds + LINK_DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/link_partition.lds ) # The raw image, which is what the hypervisor embeds. objcopy -O binary # spans any gap between loaded sections, which is exactly why the window - # is one contiguous area -- see the header of link_partition_a.lds. + # is one contiguous area -- see the header of link_partition.lds.in. # # The blob drops the .elf suffix, so the ELF and its map file sit beside # a blob named for the guest rather than for the ELF. That is not tidying: @@ -318,14 +361,17 @@ function(zx_add_guest target_name) ) endfunction() -# ONE GUEST IMAGE, and that is a decision rather than an economy. +# ONE GUEST IMAGE PER PARTITION, and that is a decision rather than an +# economy. # -# The hypervisor demonstrates three different outcomes with this guest -- a -# stage-2 violation, a stage-1 fault, and an access that is genuinely -# permitted -- and it selects between them by writing an ADDRESS into the -# guest's mailbox, not by building a different guest. A binary per case is a -# set of binaries that can drift apart, and the one demonstrating isolation -# would then stop being the one demonstrating that the kernel runs. Here -# they are the same bytes, and the run that proves the kernel works is -# literally the run that proves it is confined. -zx_add_guest(guest_a.elf) +# ONE per PARTITION, because a guest is linked for its window and two +# partitions are two windows. ONE per partition, because everything else a +# guest might vary -- whether it has a clock, whether it prints, what it +# probes, whether it hands the machine back or hogs it -- is a WORD IN THE +# MAILBOX and not a build. A binary per behaviour is a set of binaries that +# can drift apart, and the one demonstrating isolation would then stop being +# the one demonstrating that the kernel runs. Here every partition runs the +# same source, compiled the same way, differing only in the address it was +# linked for -- so the run that proves the kernel works is literally the run +# that proves it is confined, in both partitions. +zx_add_guest(${ZX_GUEST_NAME}.elf) diff --git a/examples/fvp_baser_aemv8r/guest/link_partition.lds.in b/examples/fvp_baser_aemv8r/guest/link_partition.lds.in index ba7fdf7..c389254 100644 --- a/examples/fvp_baser_aemv8r/guest/link_partition.lds.in +++ b/examples/fvp_baser_aemv8r/guest/link_partition.lds.in @@ -13,7 +13,24 @@ * SPDX-License-Identifier: MIT and CC0-1.0 **************************************************************************/ -/* Link map for a ThreadX guest inside ZoneX partition A, Armv8-R AEM FVP. +/* Link map for a ThreadX guest inside a ZoneX partition, Armv8-R AEM FVP. + * + * A TEMPLATE, configured once per partition. + * + * The window base and size below are substituted by CMake, and that is the + * answer to a question this repository asked deliberately: adding a second + * partition was meant to be a data change plus one more .incbin, and it was + * not -- it was going to be a second copy of a two-hundred-line build file + * and a second copy of this script, differing in one number each. + * + * Two copies of an argument stay true until the day they do not, and the day + * they do not is a guest linked for a window it is not loaded into: it + * STARTS, because the entry branch is PC-relative, and faults at its first + * literal pool load at an address that looks entirely reasonable. So the + * script is one file and the number is data. The image still declares what + * it was built for in its own header, and the hypervisor still refuses a + * mismatch at boot -- the template removes a way to create one, not the + * check that catches it. * * WHAT MAKES THIS DIFFERENT FROM THE PORT'S OWN link.lds * @@ -73,10 +90,14 @@ ENTRY(_start) 256 KB is not a measurement, it is headroom: the guest below links to roughly a tenth of it, and a window sized to what the image happens to need today would fail the ASSERT the first time a demo grew a thread. - The stage-2 region costs the same whatever its size. */ + The stage-2 region costs the same whatever its size. + + BOTH ARE SUBSTITUTED BY CMAKE, from the values the hypervisor's own + linker script declares, so the two cannot disagree by being edited apart. + Every partition on this board gets the same size and a different base. */ -__zx_guest_window_base = 0x02000000; -__zx_guest_window_size = 0x00040000; +__zx_guest_window_base = @ZX_GUEST_WINDOW_BASE@; +__zx_guest_window_size = @ZX_GUEST_WINDOW_SIZE@; /* Per-mode stacks. Every AArch32 mode needs one and the port's el1_entry sets all six; a mode whose stack symbol was missing would fail the link, @@ -93,7 +114,7 @@ __sys_stack_size__ = 0x0400; MEMORY { - WINDOW (rwx) : ORIGIN = 0x02000000, LENGTH = 0x00040000 + WINDOW (rwx) : ORIGIN = @ZX_GUEST_WINDOW_BASE@, LENGTH = @ZX_GUEST_WINDOW_SIZE@ } SECTIONS diff --git a/examples/fvp_baser_aemv8r/zx_one_partition.lds b/examples/fvp_baser_aemv8r/zx_one_partition.lds index fd6eaec..c83fc3b 100644 --- a/examples/fvp_baser_aemv8r/zx_one_partition.lds +++ b/examples/fvp_baser_aemv8r/zx_one_partition.lds @@ -56,7 +56,7 @@ ENTRY(_start) __zx_hyp_stack_size = 0x1000; /* THE PARTITION WINDOW. Both numbers are duplicated in - guest_a/link_partition_a.lds and the pair is checked at boot against the + guest/link_partition.lds.in and the pair is checked at boot against the guest's own image header. */ __zx_partition_a_base = 0x02000000; diff --git a/examples/fvp_baser_aemv8r/zx_two_partitions.lds b/examples/fvp_baser_aemv8r/zx_two_partitions.lds new file mode 100644 index 0000000..7366804 --- /dev/null +++ b/examples/fvp_baser_aemv8r/zx_two_partitions.lds @@ -0,0 +1,314 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/* Link map for the ZoneX TWO-PARTITION image on the Armv8-R AEM FVP. + * + * WHY THIS IS A THIRD SCRIPT AND NOT AN EXTENSION OF THE OTHER TWO + * + * The three images describe genuinely different memory. The stage-2 probe + * has two small windows and a relocatable blob copied into both; the + * one-partition image has a single 256 KB window and embeds one guest; this + * one has TWO 256 KB windows and embeds two, because a guest is linked for + * its window and two windows are two different sets of bytes. + * + * Sharing one script would mean an image carrying a KEEP for a section it + * never supplies -- which produces an EMPTY section rather than an error, + * silently, at whatever address the location counter held. That is the exact + * failure the loader's magic-number check exists to catch, and the right + * place to avoid it is here. + * + * THE WINDOW ADDRESSES ARE A CONTRACT, NOT A CHOICE. Each is passed to its + * guest's build from examples/fvp_baser_aemv8r/CMakeLists.txt, which is the + * one place they are written down, and each guest declares what it was built + * for in its own image header so that a mismatch is REFUSED at boot rather + * than discovered as a fault at a plausible-looking address. The numbers + * below have to agree with that file, and the ASSERTs at the foot of this + * script are what say so. + * + * THE UNGRANTED GRANULE BETWEEN THE TWO WINDOWS IS LOAD-BEARING. Sixty-four + * bytes belonging to nobody sit immediately after partition A and + * immediately before partition B. A region limit one granule too generous + * at either end reaches into it, and an access there must fault -- which is + * why partition B's base is not a round number. An ungranted address far + * from every grant proves only that unmapped memory faults; adjacency is + * what makes it a boundary test. + * + * A NOTE ON ALIGN, because the two spellings are not equivalent + * + * Every ALIGN below is written AFTER the colon, which is the section's + * ALIGNMENT. Written before the colon it is the section's ADDRESS, + * evaluated from a location counter that starts at zero, and it silently + * overrides the region assignment. Harmless where memory starts at zero and + * catastrophic where it does not -- which is what makes it worth repeating + * in every script here rather than in one of them. + * + * ZoneX's OWN code, data, bss and stack are covered by no region at all. + * That is decision D2: with HSCTLR.BR=1 EL2's own accesses fall back to the + * background map, while EL0/EL1 accesses that hit no region fault regardless + * of BR -- so the hypervisor costs no region and is protected from the guest + * precisely by not being mapped. + */ + +ENTRY(_start) + +__zx_hyp_stack_size = 0x1000; + +/* THE PARTITION WINDOWS. Each pair is passed to its guest's build from this + board's CMakeLists.txt, and checked at boot against the guest's own image + header. */ + +__zx_partition_a_base = 0x02000000; +__zx_partition_a_size = 0x00040000; +__zx_partition_b_base = 0x02040040; +__zx_partition_b_size = 0x00040000; + +MEMORY +{ + /* Two regions rather than one location counter, so that the window's + address is declared rather than arrived at. A script that reached + 0x02000000 by assignment would place the window correctly today and + silently move it the first time the hypervisor's own image grew past + 32 MB -- which is a change nobody would connect to a guest that + stopped working. */ + + HYP (rwx) : ORIGIN = 0x00000000, LENGTH = 0x02000000 /* 32 MB */ + PART_A (rwx) : ORIGIN = 0x02000000, LENGTH = 0x00040040 /* + a hole */ + PART_B (rwx) : ORIGIN = 0x02040040, LENGTH = 0x00040040 /* + a hole */ +} + +SECTIONS +{ + . = ALIGN(64); + + .boot : + { + KEEP(*(.text.boot)) + } > HYP + + .vectors_el2 : + { + KEEP(*(.vectors_el2)) + } > HYP + + .text : + { + *(.text*) + *(.glue_7) + *(.glue_7t) + } > HYP + + .rodata : + { + . = ALIGN(4); + *(.rodata*) + . = ALIGN(4); + } > HYP + + /* ------------------------------------------------------------------ + THE GUEST IMAGE, in the hypervisor's own memory. + + Covered by no stage-2 region, exactly like the rest of ZoneX, so a + running partition cannot read the image it was loaded from -- which + matters more than it looks: a partition able to read its own source + image could also read the NEXT partition's, and the isolation claim + would be about the copies rather than about the originals. + ------------------------------------------------------------------ */ + + .zx_guest_a_blob : ALIGN(64) + { + __zx_guest_a_blob_start = .; + KEEP(*(.zx_guest_a_blob)) + . = ALIGN(4); + __zx_guest_a_blob_end = .; + } > HYP + + .zx_guest_b_blob : ALIGN(64) + { + __zx_guest_b_blob_start = .; + KEEP(*(.zx_guest_b_blob)) + . = ALIGN(4); + __zx_guest_b_blob_end = .; + } > HYP + + .data : ALIGN(64) + { + __zx_data_start = .; + *(.data*) + . = ALIGN(4); + __zx_data_end = .; + } > HYP + + .bss (NOLOAD) : ALIGN(64) + { + __zx_bss_start = .; + *(.bss*) + *(COMMON) + . = ALIGN(4); + __zx_bss_end = .; + } > HYP + + /* SP must stay 8-byte aligned (AAPCS), so the area is aligned before + its top symbol is taken. */ + + .zx_stacks (NOLOAD) : + { + . = ALIGN(8); + . = . + __zx_hyp_stack_size; + __zx_hyp_stack_top = .; + } > HYP + + . = ALIGN(8); + _end = .; + PROVIDE(end = .); + + /* ------------------------------------------------------------------ + PARTITION A'S WINDOW, and the ungranted granule after it. + + NOLOAD: there is nothing in this ELF to load here. The contents + arrive at run time, when the loader copies the guest image in, which + is the path a real guest takes and the reason the manifest's + image_start and image_end are load-bearing rather than decorative. + + THE HOLE IS ADJACENT BY CONSTRUCTION and the ASSERT keeps it that + way. An ungranted address far from every grant proves only that + unmapped memory faults; the defect class worth catching is a region + whose limit is out by ONE GRANULE, and only an adjacent hole catches + it. + ------------------------------------------------------------------ */ + + .zx_partition_a (NOLOAD) : ALIGN(64) + { + __zx_partition_a_start = .; + . = . + __zx_partition_a_size; + __zx_partition_a_end = .; + } > PART_A + + .zx_partition_a_hole (NOLOAD) : ALIGN(64) + { + __zx_partition_a_hole_start = .; + . = . + 64; + __zx_partition_a_hole_end = .; + } > PART_A + + /* ------------------------------------------------------------------ + PARTITION B'S WINDOW, and its own ungranted granule. + + B's base is A's hole's END, so the sixty-four bytes between them + belong to neither partition and are adjacent to both. That single + granule is what catches a limit one granule too generous at EITHER + end -- the defect class that otherwise reads as a working system, + because two region sets a whole kilobyte apart would pass a test + that only checked the neighbour's data. + ------------------------------------------------------------------ */ + + .zx_partition_b (NOLOAD) : ALIGN(64) + { + __zx_partition_b_start = .; + . = . + __zx_partition_b_size; + __zx_partition_b_end = .; + } > PART_B + + .zx_partition_b_hole (NOLOAD) : ALIGN(64) + { + __zx_partition_b_hole_start = .; + . = . + 64; + __zx_partition_b_hole_end = .; + } > PART_B + + /* ------------------------------------------------------------------ + Assert the geometry the region programming and the loader depend on, + so that a layout mistake fails the LINK rather than the run. A + region whose base is under-aligned does not fault: its low bits land + on SH, AP and XN and silently change what it grants, which is the + single hardest failure in this architecture to see. + ------------------------------------------------------------------ */ + + ASSERT((__zx_partition_a_start & 63) == 0, + "partition A's window base is not 64-byte aligned") + ASSERT((__zx_partition_a_end & 63) == 0, + "partition A's window is not a whole number of granules") + ASSERT(__zx_partition_a_start == __zx_partition_a_base, + "partition A's window is not at the address the guest was linked for") + ASSERT(__zx_partition_a_hole_start == __zx_partition_a_end, + "the ungranted granule is NOT adjacent to partition A's window") + + ASSERT((__zx_partition_b_start & 63) == 0, + "partition B's window base is not 64-byte aligned") + ASSERT(((__zx_partition_b_end - __zx_partition_b_start) & 63) == 0, + "partition B's window is not a whole number of granules") + ASSERT(__zx_partition_b_start == __zx_partition_b_base, + "partition B's window is not at the address its guest was linked for") + ASSERT(__zx_partition_b_hole_start == __zx_partition_b_end, + "the ungranted granule is NOT adjacent to partition B's window") + + /* AND THE GRANULE BETWEEN THEM BELONGS TO NOBODY. A's hole ends + exactly where B's window begins, so there is one ungranted granule + and it is adjacent to both windows. Written as an assert rather than + trusted to the MEMORY lengths, because the two are computed from + different numbers and a change to either would break this silently. */ + + ASSERT(__zx_partition_a_hole_end == __zx_partition_b_start, + "the ungranted granule is not adjacent to both partition windows") + + /* And the two windows do not overlap, which PMSAv8-R makes worse than a + logical error: two enabled regions matching one address is CONSTRAINED + UNPREDICTABLE, and it aborts on the S32Z280. The manifest validator + refuses it too; this catches it a whole build earlier. */ + + ASSERT(__zx_partition_a_end <= __zx_partition_b_start, + "the two partition windows overlap") + + ASSERT((__zx_guest_a_blob_start & 63) == 0, + "guest A's image is not 64-byte aligned") + ASSERT((__zx_guest_b_blob_start & 63) == 0, + "guest B's image is not 64-byte aligned") + + /* THE BLOB IS NOT EMPTY, and this is the check that catches an .incbin + whose file was missing or a linker input pattern that matched + nothing. Asserted against the END OF THE IMAGE HEADER rather than + against zero, because a blob too small to carry its own header is + just as useless as one of no size at all -- and the loader's magic + check would then be reading past the end of the section, which is + the out-of-bounds read this assert exists to prevent. + + 0x90 is ZX_GUEST_IMAGE_OFF_MAGIC + 4, from + examples/common/zx_guest_abi.h. A linker script cannot include a C + header, so the number is restated here -- and it is restated WRONG + the moment the header moves, which is exactly what happened when the + mailbox grew to two granules and pushed the entry branch and the + header from 0x40 to 0x80. The bound stayed at 0x50, so a blob of + 0x51 to 0x8F bytes linked cleanly and the magic check read past the + section into whatever followed it. */ + + ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) >= 0x90, + "guest A's embedded image is empty or too small to carry its header") + ASSERT((__zx_guest_b_blob_end - __zx_guest_b_blob_start) >= 0x90, + "guest B's embedded image is empty or too small to carry its header") + + ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) + <= __zx_partition_a_size, + "guest A's embedded image does not fit partition A's window") + ASSERT((__zx_guest_b_blob_end - __zx_guest_b_blob_start) + <= __zx_partition_b_size, + "guest B's embedded image does not fit partition B's window") + + /* And the hypervisor's own image must not have reached the window. The + two MEMORY regions make this impossible rather than merely unlikely, + so this asserts the thing the reader would otherwise have to work out + from the region lengths. */ + + ASSERT(_end <= __zx_partition_a_base, + "the hypervisor's own image has grown into partition A's window") +} diff --git a/examples/guest_common/zx_guest_app.c b/examples/guest_common/zx_guest_app.c index 1cf232c..52d54e7 100644 --- a/examples/guest_common/zx_guest_app.c +++ b/examples/guest_common/zx_guest_app.c @@ -452,6 +452,86 @@ static ULONG preemptive_phase(void) } +/**************************************************************************/ +/* endless_phase -- what a partition does when it has finished but the */ +/* frame has not. */ +/* */ +/* WHY A GUEST WOULD REFUSE TO HAND THE MACHINE BACK. */ +/* */ +/* A guest that yields is a guest that has finished, and with one */ +/* partition that was exactly the right ending. Under a MAJOR FRAME it */ +/* is the wrong one: a schedule can only be shown to preempt partitions */ +/* that are still trying to run, and a partition that stopped after its */ +/* first window would spend every later window being idled through. */ +/* Nothing about the demonstration would be false; it would simply stop */ +/* demonstrating the thing. */ +/* */ +/* So this loop keeps the partition runnable and keeps republishing two */ +/* numbers the hypervisor cannot obtain for itself: this partition's OWN */ +/* tick count, and its OWN virtual counter. Those are what the temporal */ +/* claim is checked against from the inside -- ZoneX knows how much of */ +/* the physical counter each partition was given, and only the guest can */ +/* say what its own clock did over the same span. */ +/* */ +/* THE HOG VARIANT MASKS INTERRUPTS AND THAT IS THE POINT. With */ +/* ZX_GO_HOG the loop first disables IRQ and FIQ at EL1 and then never */ +/* makes another kernel call. A partition doing this is as */ +/* uncooperative as an EL1 program can be: it cannot be descheduled by */ +/* its own kernel, because its own kernel has stopped ticking, and it */ +/* asks the hypervisor for nothing. */ +/* */ +/* It must be preempted anyway. With HCR.FMO set, PSTATE.F is IGNORED */ +/* at EL0 and EL1, so the FIQ that ends this window reaches EL2 whatever */ +/* this thread has masked. All the masking costs is this partition's own */ +/* tick -- which is visible, because ZX_GD_TICKS_NOW stops moving while */ +/* ZX_GD_LIVE keeps going, and that pair is the evidence. */ +/* */ +/* NO SEAL AND NO CHECKSUM IN HERE, deliberately. The verdict was */ +/* published before this was entered, so a run stopped at any point */ +/* below still carries a complete sealed report; resealing on every */ +/* iteration would spend the partition's window computing a checksum */ +/* nobody reads. These words are outside the seal and zx_guest_abi.h */ +/* says so. */ +/**************************************************************************/ + +static void endless_phase(ULONG options) +{ + ULONG live = 0UL; + + guest_mailbox_write(ZX_GD_VCT_START, guest_virtual_count()); + + if ((options & (ULONG) ZX_GO_HOG) != 0UL) + { + console_puts("HOGGING: masking IRQ and FIQ at EL1 and spinning for\n" + "ever. My own kernel will stop ticking. The window\n" + "must end anyway, because PSTATE.F is ignored at EL1\n" + "while the hypervisor routes FIQ to itself -- and I\n" + "cannot reach the bit that would change that.\n"); + + /* Both masks. IRQ is this kernel's own tick; F is the one that + does nothing, and it is set precisely so that the run + demonstrates it doing nothing. */ + + __asm__ volatile("cpsid if" ::: "memory"); + } + else + { + console_puts("finished, and staying runnable so that the frame has\n" + "something to preempt. My tick count and my own\n" + "virtual counter keep going into the mailbox.\n"); + } + + for (;;) + { + live++; + + guest_mailbox_write(ZX_GD_LIVE, live); + guest_mailbox_write(ZX_GD_VCT_NOW, guest_virtual_count()); + guest_mailbox_write(ZX_GD_TICKS_NOW, tx_time_get()); + } +} + + /**************************************************************************/ /* consumer_entry */ /* */ @@ -669,10 +749,23 @@ static void consumer_entry(ULONG thread_input) console_puts("GUEST RESULT: FAILED\n"); } - /* Back to the hypervisor, on purpose. A guest that simply returned from - its last thread would leave the kernel idling forever inside a - partition nothing would ever take back, and the run would time out - rather than report. */ + /* AND THEN ONE OF TWO ENDINGS, chosen by the hypervisor rather than by + the build. + * + Back to the hypervisor, on purpose, is the right ending for an image + that runs one partition: a guest that simply returned from its last + thread would leave the kernel idling inside a partition nothing would + ever take back, and the run would time out rather than report. + * + Under a major frame it is the wrong ending, because a schedule can + only be shown to preempt partitions that are still trying to run. + ZX_GO_FOREVER keeps this one runnable; see endless_phase. */ + + if ((guest_mailbox_read(ZX_GD_OPTIONS) + & (unsigned long) ZX_GO_FOREVER) != 0UL) + { + endless_phase((ULONG) guest_mailbox_read(ZX_GD_OPTIONS)); + } guest_yield(); } diff --git a/examples/s32z280_evb/CMakeLists.txt b/examples/s32z280_evb/CMakeLists.txt index 70c3016..458047a 100644 --- a/examples/s32z280_evb/CMakeLists.txt +++ b/examples/s32z280_evb/CMakeLists.txt @@ -108,13 +108,13 @@ target_compile_definitions(zx_probe_starved.elf PRIVATE ZX_PROBE_STARVE_REGIONS) # Built exactly as the model's is -- a separate CMake invocation with # ThreadX's own toolchain file, producing one raw binary the hypervisor # embeds with .incbin. The reasoning is in -# examples/fvp_baser_aemv8r/guest_a/CMakeLists.txt and is not repeated. +# examples/fvp_baser_aemv8r/guest/CMakeLists.txt and is not repeated. # # WHAT DIFFERS ON THIS BOARD is the window: 64 KB in data SRAM rather than # 256 KB in flat DRAM, because the hypervisor's own data comes out of the # same 512 KB and because a partition's code and data must be CONTIGUOUS -- # objcopy -O binary would otherwise pad the blob out to the 1.2 GB between -# this part's two memories. See guest_a/link_partition_a.lds. +# this part's two memories. See guest/link_partition.lds.in. # # AND ONE THING THE MODEL CANNOT SHOW: the guest's reset path here needs the # port's TX_R52_BOOT_AT_EL1 bracket, which the S32Z280 entry.S gained for @@ -126,8 +126,33 @@ target_compile_definitions(zx_probe_starved.elf PRIVATE ZX_PROBE_STARVE_REGIONS) if(ZX_THREADX_SOURCE_DIR) include(ExternalProject) + # THE PARTITION WINDOWS, AND THE ONE PLACE THEY ARE WRITTEN DOWN. + # + # The contract between this board's linker scripts, the manifest, and the + # address each guest is LINKED for. Partition A's window is deliberately + # UNCHANGED from the one-partition image's, so the guest running in it is + # byte for byte the guest those images already run. + # + # PARTITION B'S BASE IS NOT ROUND because the ungranted granule between + # the two windows is load-bearing: sixty-four bytes belonging to nobody + # sit immediately after A and immediately before B, so a region limit one + # granule too generous at either end is caught by an access that should + # fault. + # + # 64 KB EACH, WHERE THE MODEL GIVES 256 KB. Data SRAM here is 512 KB in + # total and the hypervisor's own data comes out of the same memory; two + # windows and their granules end at 0x317E0080, comfortably short of the + # half-speed DRAM2 at 0x31800000. A third would not fit, and the + # linker script says so with an ASSERT rather than a comment. + set(ZX_PART_A_BASE 0x317C0000) + set(ZX_PART_A_SIZE 0x00010000) + set(ZX_PART_B_BASE 0x317D0040) + set(ZX_PART_B_SIZE 0x00010000) + set(ZX_GUEST_A_BUILD ${CMAKE_CURRENT_BINARY_DIR}/guest_a) set(ZX_GUEST_A_BLOB ${ZX_GUEST_A_BUILD}/guest_a.bin) + set(ZX_GUEST_B_BUILD ${CMAKE_CURRENT_BINARY_DIR}/guest_b) + set(ZX_GUEST_B_BLOB ${ZX_GUEST_B_BUILD}/guest_b.bin) if(CMAKE_C_COMPILER_ID STREQUAL "Clang") set(ZX_GUEST_TOOLCHAIN @@ -137,33 +162,47 @@ if(ZX_THREADX_SOURCE_DIR) ${ZX_THREADX_SOURCE_DIR}/cmake/cortex_r52.cmake) endif() - ExternalProject_Add(zx_guest_a - SOURCE_DIR ${ZX_S32Z_DIR}/guest_a - BINARY_DIR ${ZX_GUEST_A_BUILD} - CMAKE_GENERATOR ${CMAKE_GENERATOR} - CMAKE_ARGS - -DCMAKE_TOOLCHAIN_FILE=${ZX_GUEST_TOOLCHAIN} - -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} - -DZX_THREADX_SOURCE_DIR=${ZX_THREADX_SOURCE_DIR} - -DZX_GUEST_COMMON_DIR=${ZX_COMMON_DIR} - # ZoneX's own warning module, so that ZoneX's guest support faces - # ZoneX's warning set inside a ThreadX build. The sub-project - # applies it to ZoneX's three files and to nothing else; see - # guest_a/CMakeLists.txt for why that distinction is load-bearing. - -DZX_CMAKE_DIR=${CMAKE_SOURCE_DIR}/cmake - # And whether those warnings are fatal, carried across the project - # boundary by hand. CMAKE_COMPILE_WARNING_AS_ERROR does NOT - # propagate into an ExternalProject: the sub-build is a separate - # CMake invocation and inherits nothing that is not listed here. - # That is why the strict lanes were compiling the guest support - # with warnings merely printed -- when they compiled it at all. - -DZX_GUEST_WARNINGS_AS_ERRORS=${CMAKE_COMPILE_WARNING_AS_ERROR} - BUILD_BYPRODUCTS ${ZX_GUEST_A_BLOB} - BUILD_ALWAYS TRUE - INSTALL_COMMAND "" - USES_TERMINAL_CONFIGURE FALSE - USES_TERMINAL_BUILD FALSE - ) + # ONE GUEST PROJECT, CONFIGURED ONCE PER PARTITION. The sub-project + # takes its window and its name as arguments and generates its own linker + # script from a template; see guest/CMakeLists.txt for why that is a + # parameter rather than a second copy of a two-hundred-line file. + function(zx_add_guest_project guest_name window_base window_size) + ExternalProject_Add(zx_${guest_name} + SOURCE_DIR ${ZX_S32Z_DIR}/guest + BINARY_DIR ${CMAKE_CURRENT_BINARY_DIR}/${guest_name} + CMAKE_GENERATOR ${CMAKE_GENERATOR} + CMAKE_ARGS + -DCMAKE_TOOLCHAIN_FILE=${ZX_GUEST_TOOLCHAIN} + -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} + -DZX_THREADX_SOURCE_DIR=${ZX_THREADX_SOURCE_DIR} + -DZX_GUEST_COMMON_DIR=${ZX_COMMON_DIR} + -DZX_GUEST_NAME=${guest_name} + -DZX_GUEST_WINDOW_BASE=${window_base} + -DZX_GUEST_WINDOW_SIZE=${window_size} + # ZoneX's own warning module, so that ZoneX's guest support + # faces ZoneX's warning set inside a ThreadX build. The + # sub-project applies it to ZoneX's three files and to nothing + # else; see guest/CMakeLists.txt for why that distinction is + # load-bearing. + -DZX_CMAKE_DIR=${CMAKE_SOURCE_DIR}/cmake + # And whether those warnings are fatal, carried across the + # project boundary by hand. CMAKE_COMPILE_WARNING_AS_ERROR + # does NOT propagate into an ExternalProject: the sub-build is + # a separate CMake invocation and inherits nothing that is not + # listed here. That is why the strict lanes were compiling the + # guest support with warnings merely printed -- when they + # compiled it at all. + -DZX_GUEST_WARNINGS_AS_ERRORS=${CMAKE_COMPILE_WARNING_AS_ERROR} + BUILD_BYPRODUCTS ${CMAKE_CURRENT_BINARY_DIR}/${guest_name}/${guest_name}.bin + BUILD_ALWAYS TRUE + INSTALL_COMMAND "" + USES_TERMINAL_CONFIGURE FALSE + USES_TERMINAL_BUILD FALSE + ) + endfunction() + + zx_add_guest_project(guest_a ${ZX_PART_A_BASE} ${ZX_PART_A_SIZE}) + zx_add_guest_project(guest_b ${ZX_PART_B_BASE} ${ZX_PART_B_SIZE}) function(zx_add_s32z_one_partition target_name) add_executable(${target_name} EXCLUDE_FROM_ALL @@ -292,6 +331,85 @@ if(ZX_THREADX_SOURCE_DIR) zx_add_s32z_preemptive(zx_preemptive_no_freeze.elf) target_compile_definitions(zx_preemptive_no_freeze.elf PRIVATE ZX_PREEMPT_NO_FREEZE) + ########################################################################### + # TWO PARTITIONS, on silicon + ########################################################################### + # + # The image the whole repository has been building to, and the one whose + # numbers are worth quoting: the model is functional and its cycle counts + # are not timing at all. + # + # It embeds BOTH guests, which is the one structural difference from every + # image above. A guest is linked for its window, so two windows are two + # different sets of bytes and there is no arrangement in which one blob + # serves both. + function(zx_add_s32z_two_partitions target_name) + add_executable(${target_name} EXCLUDE_FROM_ALL + ${ZX_COMMON_DIR}/zx_two_partitions.c + ${ZX_COMMON_DIR}/zx_guest_launch.c + ${ZX_COMMON_DIR}/zx_report.c + ${ZX_COMMON_DIR}/zx_guest_image.S + ${ZX_S32Z_DIR}/zx_board.c + ) + + add_dependencies(${target_name} zx_guest_a zx_guest_b) + + target_include_directories(${target_name} PRIVATE + ${ZX_S32Z_DIR} + ${ZX_COMMON_DIR} + ) + + set_source_files_properties(${ZX_COMMON_DIR}/zx_guest_image.S + PROPERTIES + COMPILE_OPTIONS + "-Wa,-I${ZX_GUEST_A_BUILD};-Wa,-I${ZX_GUEST_B_BUILD};-DZX_GUEST_B_BLOB" + OBJECT_DEPENDS "${ZX_GUEST_A_BLOB};${ZX_GUEST_B_BLOB}" + TARGET_DIRECTORY ${target_name} + ) + + target_link_libraries(${target_name} PRIVATE + eclipse-threadx::zonex + zonex::warnings + ) + + target_link_options(${target_name} PRIVATE + -T${ZX_S32Z_DIR}/zx_two_partitions.lds + -nostartfiles + -Wl,-Map=${target_name}.map + ${ZX_LINK_QUIET_RWX} + ) + + set_target_properties(${target_name} PROPERTIES + LINK_DEPENDS ${ZX_S32Z_DIR}/zx_two_partitions.lds + ) + endfunction() + + zx_add_s32z_two_partitions(zx_two_partitions.elf) + + # THE HOSTILE BUILD, and it must PASS. Partition B masks IRQ and FIQ at + # EL1 and spins for ever. It has to be preempted anyway, because with + # HCR.FMO set PSTATE.F is ignored at EL0 and EL1 -- and this is the board + # where that claim is worth anything, because this is the board with real + # interrupt latency. + zx_add_s32z_two_partitions(zx_two_partitions_hog.elf) + target_compile_definitions(zx_two_partitions_hog.elf PRIVATE ZX_TWO_HOG) + + # The cross-partition build: B reaches into A's window, stage 2 refuses, + # B is stopped, and A runs to the end of the frame untouched. It must + # PASS -- both halves of it are the isolation claim. + zx_add_s32z_two_partitions(zx_two_partitions_cross.elf) + target_compile_definitions(zx_two_partitions_cross.elf PRIVATE + ZX_TWO_CROSS) + + # And the two negative verifications, exactly as on the model. + zx_add_s32z_two_partitions(zx_two_partitions_no_tick.elf) + target_compile_definitions(zx_two_partitions_no_tick.elf PRIVATE + ZX_TWO_NO_TICK) + + zx_add_s32z_two_partitions(zx_two_partitions_overlap.elf) + target_compile_definitions(zx_two_partitions_overlap.elf PRIVATE + ZX_TWO_OVERLAP) + else() message(STATUS "ZoneX: ZX_THREADX_SOURCE_DIR is empty, so the ThreadX guest images " diff --git a/examples/s32z280_evb/guest/CMakeLists.txt b/examples/s32z280_evb/guest/CMakeLists.txt index 9152b5e..75e66c7 100644 --- a/examples/s32z280_evb/guest/CMakeLists.txt +++ b/examples/s32z280_evb/guest/CMakeLists.txt @@ -13,7 +13,12 @@ # SPDX-License-Identifier: MIT and CC0-1.0 ############################################################################### # -# The ThreadX guest for ZoneX partition A on the NXP S32Z280-594EVB. +# The ThreadX guest for ONE ZoneX partition on the NXP S32Z280-594EVB. +# +# WHICH partition is an argument, not a copy of this file: the window base, +# the window size and the image name arrive as CMake variables and this +# project is configured once per partition. The model's copy carries the +# reasoning for that; what is board-specific is below. # # THIS IS A PROJECT OF ITS OWN, configured and built by a SEPARATE CMake # invocation from a superbuild step in the parent example. It is not an @@ -34,17 +39,19 @@ # images in one link resolve into each other silently. Separate projects # make that impossible rather than merely unlikely. # -# What crosses back is one file: guest_a.bin, the raw image, which the -# hypervisor pulls in with .incbin. guest_a.elf and guest_a.map stay here, +# What crosses back is one file: .bin, the raw image, which the +# hypervisor pulls in with .incbin. The ELF and its map file stay here, # beside it, because a fault report names a guest PC and the only way to turn -# that back into a function is the map file. +# that back into a function is the map file -- and with two partitions there +# are two map files, one per window, which is exactly what a report naming an +# address in one of them needs. cmake_minimum_required(VERSION 3.28...4.2) -project(zx_guest_a_s32z280 +project(zx_guest_s32z280 VERSION 0.1.0 LANGUAGES C ASM - DESCRIPTION "A ThreadX guest for ZoneX partition A on the S32Z280" + DESCRIPTION "A ThreadX guest for one ZoneX partition on the S32Z280" ) if(NOT ZX_THREADX_SOURCE_DIR) @@ -61,6 +68,40 @@ if(NOT ZX_GUEST_COMMON_DIR) message(FATAL_ERROR "ZX_GUEST_COMMON_DIR was not passed to the guest build.") endif() +# WHICH PARTITION THIS GUEST IS FOR. Every one of these is checked rather +# than defaulted: a guest silently linked for somebody else's window is the +# failure the image header exists to catch at boot, and catching it at +# CONFIGURE time costs nothing and names the cause. +# +# ZX_GUEST_NAME becomes the ELF, the map file and the raw blob, and the +# hypervisor's .incbin names that blob literally -- so a guest built under +# the wrong name is a link that fails rather than an image that runs the +# wrong kernel. +if(NOT ZX_GUEST_NAME) + message(FATAL_ERROR + "ZX_GUEST_NAME was not passed to the guest build, so this guest has " + "no identity: the hypervisor embeds its blob by name.") +endif() + +if(NOT ZX_GUEST_WINDOW_BASE OR NOT ZX_GUEST_WINDOW_SIZE) + message(FATAL_ERROR + "ZX_GUEST_WINDOW_BASE and ZX_GUEST_WINDOW_SIZE were not both passed " + "to the guest build.\n" + "A guest is LINKED for one window and every absolute address in it is " + "baked in; there is no defensible default, because a guest linked for " + "the wrong address starts and then faults at its first literal pool " + "load, at an address that looks entirely reasonable in the report.") +endif() + +# The linker script, with this partition's window substituted in. Generated +# into this project's own binary directory, so the two partitions' scripts +# cannot be confused with each other or with the template. +configure_file( + ${CMAKE_CURRENT_LIST_DIR}/link_partition.lds.in + ${CMAKE_CURRENT_BINARY_DIR}/link_partition.lds + @ONLY +) + # ZONEX'S OWN WARNING SET, READ FROM ZONEX'S OWN MODULE. # # This project is configured by a SEPARATE CMake invocation with ThreadX's @@ -289,7 +330,7 @@ function(zx_add_guest target_name) target_link_libraries(${target_name} PRIVATE threadx) target_link_options(${target_name} PRIVATE - -T${GUEST_DIR}/link_partition_a.lds + -T${CMAKE_CURRENT_BINARY_DIR}/link_partition.lds -nostartfiles -Wl,-Map=${target_name}.map ${GUEST_LINK_QUIET_RWX} @@ -299,12 +340,12 @@ function(zx_add_guest target_name) # would not trigger a relink and a stale guest would be loaded against new # region boundaries. set_target_properties(${target_name} PROPERTIES - LINK_DEPENDS ${GUEST_DIR}/link_partition_a.lds + LINK_DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/link_partition.lds ) # The raw image, which is what the hypervisor embeds. objcopy -O binary # spans any gap between loaded sections, which is exactly why the window - # is one contiguous area -- see the header of link_partition_a.lds. + # is one contiguous area -- see the header of link_partition.lds.in. # # The blob drops the .elf suffix, so the ELF and its map file sit beside # a blob named for the guest rather than for the ELF. That is not tidying: @@ -327,14 +368,17 @@ function(zx_add_guest target_name) ) endfunction() -# ONE GUEST IMAGE, and that is a decision rather than an economy. +# ONE GUEST IMAGE PER PARTITION, and that is a decision rather than an +# economy. # -# The hypervisor demonstrates three different outcomes with this guest -- a -# stage-2 violation, a stage-1 fault, and an access that is genuinely -# permitted -- and it selects between them by writing an ADDRESS into the -# guest's mailbox, not by building a different guest. A binary per case is a -# set of binaries that can drift apart, and the one demonstrating isolation -# would then stop being the one demonstrating that the kernel runs. Here -# they are the same bytes, and the run that proves the kernel works is -# literally the run that proves it is confined. -zx_add_guest(guest_a.elf) +# ONE per PARTITION, because a guest is linked for its window and two +# partitions are two windows. ONE per partition, because everything else a +# guest might vary -- whether it has a clock, whether it prints, what it +# probes, whether it hands the machine back or hogs it -- is a WORD IN THE +# MAILBOX and not a build. A binary per behaviour is a set of binaries that +# can drift apart, and the one demonstrating isolation would then stop being +# the one demonstrating that the kernel runs. Here every partition runs the +# same source, compiled the same way, differing only in the address it was +# linked for -- so the run that proves the kernel works is literally the run +# that proves it is confined, in both partitions. +zx_add_guest(${ZX_GUEST_NAME}.elf) diff --git a/examples/s32z280_evb/guest/link_partition.lds.in b/examples/s32z280_evb/guest/link_partition.lds.in index 104f8dc..303b410 100644 --- a/examples/s32z280_evb/guest/link_partition.lds.in +++ b/examples/s32z280_evb/guest/link_partition.lds.in @@ -13,7 +13,11 @@ * SPDX-License-Identifier: MIT and CC0-1.0 **************************************************************************/ -/* Link map for a ThreadX guest inside ZoneX partition A, S32Z280-594EVB. +/* Link map for a ThreadX guest inside a ZoneX partition, S32Z280-594EVB. + * + * A TEMPLATE, configured once per partition: the window base and size are + * substituted by CMake. The model's script carries the reasoning for that + * and for everything else the two have in common. * * WHY THE WINDOW IS IN DATA SRAM AND NOT IN CODE SRAM * @@ -55,10 +59,16 @@ ENTRY(_start) 64 KB, where the model's guest gets 256 KB. Data SRAM here is 512 KB in total and the hypervisor's own data, bss and stack come out of the same memory, so the headroom that costs nothing on a 128 MB model is a real - budget on this board. The image below links to roughly a third of it. */ + budget on this board. The image below links to roughly a third of it. + + BOTH ARE SUBSTITUTED BY CMAKE, from the values the hypervisor's own + linker script declares, so the two cannot disagree by being edited apart. + Two 64 KB windows and their ungranted granules fit this memory with room + left; a third would not, and the ASSERT at the foot of the hypervisor's + script is what says so rather than a comment here. */ -__zx_guest_window_base = 0x317C0000; -__zx_guest_window_size = 0x00010000; +__zx_guest_window_base = @ZX_GUEST_WINDOW_BASE@; +__zx_guest_window_size = @ZX_GUEST_WINDOW_SIZE@; /* Per-mode stacks. Smaller than the model's for the same reason the window is: this is a budget rather than a whole DRAM. */ @@ -73,7 +83,7 @@ __sys_stack_size__ = 0x0300; MEMORY { - WINDOW (rwx) : ORIGIN = 0x317C0000, LENGTH = 0x00010000 + WINDOW (rwx) : ORIGIN = @ZX_GUEST_WINDOW_BASE@, LENGTH = @ZX_GUEST_WINDOW_SIZE@ } SECTIONS diff --git a/examples/s32z280_evb/tools/run_zx_probe.gdb b/examples/s32z280_evb/tools/run_zx_probe.gdb index eed70d5..5dcaad8 100644 --- a/examples/s32z280_evb/tools/run_zx_probe.gdb +++ b/examples/s32z280_evb/tools/run_zx_probe.gdb @@ -245,6 +245,7 @@ if have("__zx_partition_a_start"): REPORT_MAGIC = 0x5A58F00D box = sym("__zx_partition_a_start") + guest_sealed = False def mb(offset): return rd(box + offset) @@ -281,6 +282,7 @@ if have("__zx_partition_a_start"): failures += 1 else: print(" the report is sealed and attributable") + guest_sealed = True for bit, name in ((GP_BSP_MAIN, "reached bsp_main"), (GP_KERNEL, "tx_application_define ran"), @@ -298,6 +300,16 @@ if have("__zx_partition_a_start"): print(" result and the image's own verdict says FAILED for it.") # --- what the trap handler captured ------------------------------------------ +# +# Defined here as well, because the guest block above is conditional on the +# image having a partition window at all: the stage-2 probe images do not, and +# a NameError inside a gdb Python block reports as an error in the sourced +# file with no clue as to which name. +try: + guest_sealed +except NameError: + guest_sealed = False + print("") captures = val("zx_el2_fault_storage") # first field print(" captures into EL2 = %d (yields included, so not a fault count)" @@ -311,9 +323,35 @@ spsr = rd(sym("zx_el2_fault_storage") + 28) vector = rd(sym("zx_el2_fault_storage") + 4) ec = (hsr >> 26) & 0x3F -if captures == 0: - print(" *** FAIL: nothing was ever captured at EL2. The payload either") - print(" never ran or was never stopped.") +# ZERO CAPTURES MEANS TWO OPPOSITE THINGS, AND THE GUEST'S OWN REPORT IS WHAT +# SEPARATES THEM. +# +# The fault record is filled by the HYP TRAP vector -- a yield, a fault, or +# anything else routed to EL2 from EL1. For every image that runs a payload +# and takes it back, a count of zero means the payload never ran, and that is +# the failure this check was written for. +# +# A TIME-PARTITIONED RUN IS THE OPPOSITE CASE. Its partitions never yield and +# never fault: every window ends at an FIQ boundary, which is a different +# vector and does not touch the fault record at all. Zero captures there is +# not "nothing ran", it is "nothing ever left its window except at a boundary" +# -- which is the strongest possible outcome and exactly what the run is for. +# +# So the check asks the guest. A sealed, attributable report with progress +# bits in it is proof the payload ran, obtained from the partition's own +# memory and independent of anything the hypervisor recorded. With that in +# hand, zero captures is a RESULT; without it, zero captures is the original +# failure and is still reported as one. +if captures == 0 and guest_sealed: + print(" and that is the RESULT, not a failure: no partition ever left") + print(" its window except at a boundary. A yield or a fault would have") + print(" filled this record; an FIQ boundary is a different vector and") + print(" does not touch it. The guest's own sealed report above is what") + print(" makes this distinguishable from a payload that never ran.") +elif captures == 0: + print(" *** FAIL: nothing was ever captured at EL2, and no guest sealed") + print(" a report either. The payload never ran, or it never") + print(" reached its first publish.") failures += 1 else: print(" last capture:") diff --git a/examples/s32z280_evb/zx_one_partition.lds b/examples/s32z280_evb/zx_one_partition.lds index c8d2cc5..1136224 100644 --- a/examples/s32z280_evb/zx_one_partition.lds +++ b/examples/s32z280_evb/zx_one_partition.lds @@ -56,7 +56,7 @@ ENTRY(_start) __zx_hyp_stack_size = 0x1000; /* THE PARTITION WINDOW. Both numbers are duplicated in - guest_a/link_partition_a.lds and the pair is checked at boot against the + guest/link_partition.lds.in and the pair is checked at boot against the guest's own image header. */ __zx_partition_a_base = 0x317C0000; diff --git a/examples/s32z280_evb/zx_two_partitions.lds b/examples/s32z280_evb/zx_two_partitions.lds new file mode 100644 index 0000000..8356e03 --- /dev/null +++ b/examples/s32z280_evb/zx_two_partitions.lds @@ -0,0 +1,330 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/* Link map for the ZoneX one-partition image on the S32Z280-594EVB. + * + * WHY THIS IS A SECOND SCRIPT AND NOT AN EXTENSION OF zx_link.lds + * + * The two images describe genuinely different memory. The stage-2 probe has + * two small partition windows and a relocatable blob copied into both; this + * one has a single 64 KB window holding a whole ThreadX, and it embeds a + * guest image that the probe image does not have and must not require. + * + * Sharing one script would mean the probe carrying a KEEP for a section it + * never supplies -- which produces an EMPTY section rather than an error, + * silently, at whatever address the location counter held. That is the exact + * failure the loader's magic-number check exists to catch, and the right + * place to avoid it is here. + * + * THE WINDOW ADDRESS IS A CONTRACT, NOT A CHOICE. 0x317C0000 and 0x10000 + * are written into the guest's own linker script as well, because the guest + * is LINKED for this window -- every absolute address inside it is baked in. + * Changing either number means changing both, and the guest declares what it + * was built for in its own image header so that a mismatch is REFUSED at + * boot rather than discovered as a fault at a plausible-looking address. + * + * A NOTE ON ALIGN, because the two spellings are not equivalent + * + * Every ALIGN below is written AFTER the colon, which is the section's + * ALIGNMENT. Written before the colon it is the section's ADDRESS, + * evaluated from a location counter that starts at zero, and it silently + * overrides the region assignment. Harmless where memory starts at zero and + * catastrophic where it does not -- which is what makes it worth repeating + * in every script here rather than in one of them. + * + * ZoneX's OWN code, data, bss and stack are covered by no region at all. + * That is decision D2: with HSCTLR.BR=1 EL2's own accesses fall back to the + * background map, while EL0/EL1 accesses that hit no region fault regardless + * of BR -- so the hypervisor costs no region and is protected from the guest + * precisely by not being mapped. + */ + +ENTRY(_start) + +__zx_hyp_stack_size = 0x1000; + +/* THE PARTITION WINDOWS. Each pair is passed to its guest's build from + this board's CMakeLists.txt, which is the one place they are written + down, and each guest declares what it was built for in its own image + header so that a mismatch is REFUSED at boot. + + PARTITION B'S BASE IS NOT ROUND, and the reason is the ungranted granule + between the two windows. Sixty-four bytes belonging to nobody sit + immediately after A and immediately before B, so a region limit one + granule too generous at EITHER end is caught by an access that should + fault. An ungranted address far from every grant proves only that + unmapped memory faults; adjacency is what makes it a boundary test. + + TWO 64 KB WINDOWS AND THEIR GRANULES FIT THIS MEMORY WITH ROOM LEFT, and + only just: data SRAM ends at 0x31800000, where DRAM2 begins at half core + speed. The last ASSERT below is what says so rather than a comment. */ + +__zx_partition_a_base = 0x317C0000; +__zx_partition_a_size = 0x00010000; +__zx_partition_b_base = 0x317D0040; +__zx_partition_b_size = 0x00010000; + +MEMORY +{ + /* THREE regions here where the model needs two, because this board has + two memories rather than one flat DRAM. + + CODE 7 MB at 0x79900000, which is also where the core resets. + The hypervisor's text and the embedded guest image live + here. + HYP the first 256 KB of the 512 KB data SRAM at 0x31780000: + the hypervisor's own data, bss and stack. + PART_A a 64 KB partition window higher in the same data SRAM, + plus one granule of ungranted memory after it. + PART_B a second, immediately after A's granule and with a granule + of its own. The two windows are 64 KB apart and the sixty- + four bytes between them belong to neither -- which is why + B's base is not a round number. + + The partition window is in DATA and not in CODE, and that is forced + rather than chosen: a partition needs its code and its data in ONE + CONTIGUOUS window -- one stage-2 region, and a blob that objcopy does + not pad out to the 1.2 GB between the two memories. The Cortex-R52 + Modules port work put its module area in data SRAM for the same + reason. + + Declared as separate regions rather than reached by assignment so that + the window's address is stated. A script that arrived at 0x317C0000 + by advancing a location counter would place it correctly today and + move it silently the first time the hypervisor's own data grew. */ + + CODE (rx) : ORIGIN = 0x79900000, LENGTH = 0x00700000 /* 7 MB */ + HYP (rwx) : ORIGIN = 0x31780000, LENGTH = 0x00040000 /* 256 KB */ + PART_A (rwx) : ORIGIN = 0x317C0000, LENGTH = 0x00010040 /* + hole */ + PART_B (rwx) : ORIGIN = 0x317D0040, LENGTH = 0x00010040 /* + hole */ +} + +SECTIONS +{ + . = ALIGN(64); + + /* .boot MUST come first: MC_ME_PRTN0_CORE0_ADDR is 0x79900000 on this + board, so whatever lands at the start of CODE is what the core + executes out of reset, and that has to be _start -- as a T32 + instruction, since the core resets in Thumb state. The EL2 vector + table needs alignment but not a fixed address, because HVBAR is + programmed at run time, so it follows rather than leads. */ + + .boot : + { + KEEP(*(.text.boot)) + } > CODE + + .vectors_el2 : + { + KEEP(*(.vectors_el2)) + } > CODE + + .text : + { + *(.text*) + *(.glue_7) + *(.glue_7t) + } > CODE + + .rodata : + { + . = ALIGN(4); + *(.rodata*) + . = ALIGN(4); + } > CODE + + /* ------------------------------------------------------------------ + THE GUEST IMAGE, in the hypervisor's own memory. + + Covered by no stage-2 region, exactly like the rest of ZoneX, so a + running partition cannot read the image it was loaded from -- which + matters more than it looks: a partition able to read its own source + image could also read the NEXT partition's, and the isolation claim + would be about the copies rather than about the originals. + ------------------------------------------------------------------ */ + + .zx_guest_a_blob : ALIGN(64) + { + __zx_guest_a_blob_start = .; + KEEP(*(.zx_guest_a_blob)) + . = ALIGN(4); + __zx_guest_a_blob_end = .; + } > CODE + + .zx_guest_b_blob : ALIGN(64) + { + __zx_guest_b_blob_start = .; + KEEP(*(.zx_guest_b_blob)) + . = ALIGN(4); + __zx_guest_b_blob_end = .; + } > CODE + + .data : ALIGN(64) + { + __zx_data_start = .; + *(.data*) + . = ALIGN(4); + __zx_data_end = .; + } > HYP + + .bss (NOLOAD) : ALIGN(64) + { + __zx_bss_start = .; + *(.bss*) + *(COMMON) + . = ALIGN(4); + __zx_bss_end = .; + } > HYP + + /* SP must stay 8-byte aligned (AAPCS), so the area is aligned before + its top symbol is taken. */ + + .zx_stacks (NOLOAD) : + { + . = ALIGN(8); + . = . + __zx_hyp_stack_size; + __zx_hyp_stack_top = .; + } > HYP + + . = ALIGN(8); + _end = .; + PROVIDE(end = .); + + /* ------------------------------------------------------------------ + PARTITION A'S WINDOW, and the ungranted granule after it. + + NOLOAD: there is nothing in this ELF to load here. The contents + arrive at run time, when the loader copies the guest image in, which + is the path a real guest takes and the reason the manifest's + image_start and image_end are load-bearing rather than decorative. + + THE HOLE IS ADJACENT BY CONSTRUCTION and the ASSERT keeps it that + way. An ungranted address far from every grant proves only that + unmapped memory faults; the defect class worth catching is a region + whose limit is out by ONE GRANULE, and only an adjacent hole catches + it. + ------------------------------------------------------------------ */ + + .zx_partition_a (NOLOAD) : ALIGN(64) + { + __zx_partition_a_start = .; + . = . + __zx_partition_a_size; + __zx_partition_a_end = .; + } > PART_A + + .zx_partition_a_hole (NOLOAD) : ALIGN(64) + { + __zx_partition_a_hole_start = .; + . = . + 64; + __zx_partition_a_hole_end = .; + } > PART_A + + /* PARTITION B'S WINDOW, beginning where A's ungranted granule ends, so + that the one granule between them is adjacent to BOTH. */ + + .zx_partition_b (NOLOAD) : ALIGN(64) + { + __zx_partition_b_start = .; + . = . + __zx_partition_b_size; + __zx_partition_b_end = .; + } > PART_B + + .zx_partition_b_hole (NOLOAD) : ALIGN(64) + { + __zx_partition_b_hole_start = .; + . = . + 64; + __zx_partition_b_hole_end = .; + } > PART_B + + /* ------------------------------------------------------------------ + Assert the geometry the region programming and the loader depend on, + so that a layout mistake fails the LINK rather than the run. A + region whose base is under-aligned does not fault: its low bits land + on SH, AP and XN and silently change what it grants, which is the + single hardest failure in this architecture to see. + ------------------------------------------------------------------ */ + + ASSERT((__zx_partition_a_start & 63) == 0, + "partition A's window base is not 64-byte aligned") + ASSERT((__zx_partition_a_end & 63) == 0, + "partition A's window is not a whole number of granules") + ASSERT(__zx_partition_a_start == __zx_partition_a_base, + "partition A's window is not at the address the guest was linked for") + ASSERT(__zx_partition_a_hole_start == __zx_partition_a_end, + "the ungranted granule is NOT adjacent to partition A's window") + + ASSERT((__zx_partition_b_start & 63) == 0, + "partition B's window base is not 64-byte aligned") + ASSERT(((__zx_partition_b_end - __zx_partition_b_start) & 63) == 0, + "partition B's window is not a whole number of granules") + ASSERT(__zx_partition_b_start == __zx_partition_b_base, + "partition B's window is not at the address its guest was linked for") + ASSERT(__zx_partition_b_hole_start == __zx_partition_b_end, + "the ungranted granule is NOT adjacent to partition B's window") + ASSERT(__zx_partition_a_hole_end == __zx_partition_b_start, + "the ungranted granule is not adjacent to both partition windows") + ASSERT(__zx_partition_a_end <= __zx_partition_b_start, + "the two partition windows overlap") + + ASSERT((__zx_guest_a_blob_start & 63) == 0, + "guest A's image is not 64-byte aligned") + ASSERT((__zx_guest_b_blob_start & 63) == 0, + "guest B's image is not 64-byte aligned") + + /* THE BLOB IS NOT EMPTY, and this is the check that catches an .incbin + whose file was missing or a linker input pattern that matched + nothing. Asserted against the END OF THE IMAGE HEADER rather than + against zero, because a blob too small to carry its own header is + just as useless as one of no size at all -- and the loader's magic + check would then be reading past the end of the section, which is + the out-of-bounds read this assert exists to prevent. + + 0x90 is ZX_GUEST_IMAGE_OFF_MAGIC + 4, from + examples/common/zx_guest_abi.h. A linker script cannot include a C + header, so the number is restated here -- and it is restated WRONG + the moment the header moves, which is exactly what happened when the + mailbox grew to two granules and pushed the entry branch and the + header from 0x40 to 0x80. The bound stayed at 0x50, so a blob of + 0x51 to 0x8F bytes linked cleanly and the magic check read past the + section into whatever followed it. */ + + ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) >= 0x90, + "guest A's embedded image is empty or too small to carry its header") + ASSERT((__zx_guest_b_blob_end - __zx_guest_b_blob_start) >= 0x90, + "guest B's embedded image is empty or too small to carry its header") + + ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) + <= __zx_partition_a_size, + "guest A's embedded image does not fit partition A's window") + ASSERT((__zx_guest_b_blob_end - __zx_guest_b_blob_start) + <= __zx_partition_b_size, + "guest B's embedded image does not fit partition B's window") + + /* And the hypervisor's own data must not have reached the window. The + separate MEMORY regions make this impossible rather than merely + unlikely, so this asserts the thing the reader would otherwise have to + work out from the region lengths. */ + + ASSERT(_end <= __zx_partition_a_base, + "the hypervisor's own data has grown into partition A's window") + + /* And the window stays out of the half-speed memory. Nothing would fail + if it did not; every timing number a later step produced would simply + be wrong by a factor nobody would think to look for. */ + + ASSERT((__zx_partition_b_base + __zx_partition_b_size + 64) <= 0x31800000, + "the partition windows reach into DRAM2, which runs at half core speed") +} From 72dbb1efcf1589c2af5fea09bb7217d21d2d5ad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 2 Sep 2026 21:42:11 -0400 Subject: [PATCH 20/40] Recorded D25, the switch measurements and the encodings they needed D25: how a partition's window ends. D24's Design A, built, run on both targets, and carrying what it cost that the design did not predict -- the ICC_PMR redirection, the deliberate omission of HCR.AMO, and the FPU denied rather than saved. The reference sheet gains what no session should have to re-derive: HCR's routing bits are FMO[3], IMO[4], AMO[5] -- not the alphabetical order a reader reaches for, and this port had two of them swapped. The three questions that localised it are written down with it, because a window that never ends looks identical whether the comparator did not expire, the GIC was never told, or the core never took the exception. CNTHP: TVAL, CTL and the 64-bit CVAL a frame is actually built on, with why the absolute form is right here and the relative form is right one level down inside a guest's own handler. Which CPU-interface registers an EL1 access is redirected for once FMO is set, from TRM 9.3.5, and why ICC_PMR being in the "common to both groups" row is the one that bites. That the floating-point traps block the S32Z280 debug probe's register read, measured by comparing an image that stops before them with one that stops after. The state-a-switch-must-save table is now what a switch DOES rather than what it will have to, with the measured cost of each group on both targets beside it -- and one figure that disagrees with D4's by half on both targets, which is recorded as unexplained rather than quietly replacing the published one. Assisted-by: Claude Code (Opus 5) --- docs/armv8r-el2-reference.md | 235 +++++++++++++++++++++++++--- docs/decisions.md | 290 +++++++++++++++++++++++++++++++++++ 2 files changed, 506 insertions(+), 19 deletions(-) diff --git a/docs/armv8r-el2-reference.md b/docs/armv8r-el2-reference.md index 2855936..ab67866 100644 --- a/docs/armv8r-el2-reference.md +++ b/docs/armv8r-el2-reference.md @@ -108,7 +108,7 @@ All are reached with `MRC`/`MCR p15, 4, , …`. `opc1 = 4` is the Hyp bank. | Register | `CRn, CRm, opc2` | Notes | |---|---|---| | `HVBAR` | `c12, c0, 0` | EL2 vector base, 32-byte aligned. The ThreadX Cortex-R52 port's `entry.S` already installs it. | -| `HCR` | `c1, c1, 0` | `VM[0]` stage-2 enable · `AMO[3]` · `IMO[4]` · `FMO[5]` · `TGE[27]` · `HCD[29]` — keep `HCD` clear to keep `HVC`. The ThreadX Cortex-R52 port clears all six; ZoneX sets `VM`. | +| `HCR` | `c1, c1, 0` | `VM[0]` stage-2 enable · **`FMO[3]` · `IMO[4]` · `AMO[5]`** · `TGE[27]` · `HCD[29]` — keep `HCD` clear to keep `HVC`. ZoneX sets `VM` and `FMO`. ⚠ The three routing bits are **not** in alphabetical order and this file said they were; see the warning below, which cost a run. | | `HSCTLR` | `c1, c0, 0` | `M[0]` EL2 MPU enable · `BR[17]` background region · `TE` Thumb exceptions · `I` · `C`. | | `HSR` | `c5, c2, 0` | `EC[31:26]`, `IL[25]`, `ISS[24:0]`. | | `HDFAR` | `c6, c0, 0` | Data fault address. | @@ -362,6 +362,122 @@ mapping at all. All are UNDEFINED until `ICC_HSRE.SRE` is set — see below. given an end-of-interrupt: the running priority was never raised, so dropping it corrupts the GIC's priority stack rather than merely being redundant. +### ⚠ HCR's routing bits are FMO, IMO, AMO going UP from bit 3 + +| Bit | Name | What it overrides | +|---|---|---| +| 3 | `FMO` | `CPSR.F` for physical FIQ at EL0/EL1, and routes it to EL2 | +| 4 | `IMO` | `CPSR.I` for physical IRQ at EL0/EL1, and routes it to EL2 | +| 5 | `AMO` | `CPSR.A` for asynchronous aborts at EL0/EL1 | + +TRM Table 3-70, and the architecture agrees. **The mnemonic order a reader +reaches for is the alphabetical one — AMO, FMO, IMO — and it is wrong.** This +port had `ZX_HCR_FMO` and `ZX_HCR_AMO` defined the other way round from its +first commit. + +It survived because `IMO` is bit 4 under either reading, and because every use +of the three until time partitioning arrived was a single `BIC` of all three at +once — where swapping two of them changes nothing whatever. The reset path had +been clearing exactly the right bits for the wrong reasons. + +**What it cost when the first image SET one of them.** Routing FIQ to EL2 set +bit 5, which is AMO. Physical FIQ was never routed anywhere, so the guest's own +`PSTATE.F` masked the hypervisor's tick as an ordinary EL1 FIQ and no +partition's window ever ended. Every other check in the run was green: the +comparator expired, the GIC made PPI 26 pending, the priorities were right, and +the CPU interface was open. The image printed a flawless set-up and then went +quiet for ever. + +**And a check written against the symbol could not have caught it**, because +`(HCR & ZX_HCR_FMO) != 0` is the same symbol on both sides of the comparison +and passes against either definition. The two-partition image therefore checks +HCR **by bit position** — `0x8` for FMO, `0x10` for IMO, `0x20` for AMO — which +is the only form of the check a swapped definition cannot satisfy. The +positions are also asserted at compile time in `zx_context.c`. + +The diagnosis took three questions asked separately, and it is worth recording +the order because a window that never ends looks identical in all three cases: + +| Question | Answered by | Result | +|---|---|---| +| did the comparator expire? | `CNTHP_CTL.ISTATUS` | yes | +| did the GIC make it pending? | `GICR_ISPENDR0` bit 26 | yes | +| did the core take it? | unmask `PSTATE.F` at EL2 | yes, **at EL2** | + +The third one is what localised it: the FIQ was delivered and taken the moment +EL2 stopped masking it, so the CPU interface and the GIC were both correct and +only the routing from EL1 was missing. + +### The hypervisor timer, CNTHP — the register a frame is built on + +| Register | Encoding | Note | +|---|---|---| +| `CNTHP_TVAL` | `p15, 4, c14, c2, 0` | 32-bit down-count, relative | +| `CNTHP_CTL` | `p15, 4, c14, c2, 1` | `ENABLE` = 0, `IMASK` = 1, `ISTATUS` = 2 | +| `CNTHP_CVAL` | `MRRC`/`MCRR p15, 6, …, c14` | **64-bit, ABSOLUTE, and the one to use** | +| `CNTHCTL` | `p15, 4, c14, c1, 0` | `PL1PCTEN`/`PL1PCEN`, left CLEAR — D23 | + +TRM Tables 3-11 and 3-15. It compares against the **physical** counter, which +is the one thing in the system no partition can move — `CNTHCTL.PL1PCTEN` stays +clear so a guest cannot even read it. + +**`CNTHP_CVAL` and not `CNTHP_TVAL`, and this is the whole determinism claim.** +`TVAL` is a down-count loaded relative to now, so re-arming it inside the +handler adds the handler's own latency to every window — a frame declared as +10 ms becomes 10 ms plus the switch, cumulatively, for the life of the run. The +schedule still looks fixed and the windows stay in the right proportion. `CVAL` +is an absolute comparison, so the switch's own cost comes out of the window it +happens in. + +The opposite choice is right one level down and for the opposite reason: a +guest re-arming its OWN virtual timer inside its own handler wants `CNTV_TVAL`, +because that is one register write with no counter read and no 64-bit +arithmetic. Two timers, two registers, and the reasoning inverts between them. + +**Write the comparator BEFORE the enable, at both levels.** Enabling first +leaves the comparator holding the PREVIOUS deadline — already in the past — for +the few cycles it takes to write the new one, so the level asserts and the +interrupt is taken again immediately. On a level-triggered PPI that is a storm +rather than a glitch. + +### ⚠ Setting `HCR.FMO` moves the guest's `ICC_PMR` write to the VIRTUAL interface + +**Measured consequence: a partition that was receiving its timer perfectly well +stops receiving anything at all, with no fault and no message.** + +TRM 9.3.5 lists which CPU-interface registers an EL1 access is redirected for: + +| When | Registers redirected to `ICV_*` | +|---|---| +| `HCR.FMO == 1` | `ICC_AP0R0`, `ICC_BPR0`, `ICC_EOIR0`, `ICC_HPPIR0`, `ICC_IAR0`, `ICC_IGRPEN0` | +| `HCR.IMO == 1` | `ICC_AP1R0`, `ICC_BPR1`, `ICC_EOIR1`, `ICC_HPPIR1`, `ICC_IAR1`, `ICC_IGRPEN1` | +| **either** | `ICC_RPR`, `ICC_CTLR`, `ICC_DIR`, **`ICC_PMR`** | + +`ICC_PMR` is in the third row, so it moves the instant FMO is set even though +`IMO` stays clear and the guest's Group 1 path is otherwise untouched. Every +guest writes `ICC_PMR = 0xFF` at start-up to unmask its interrupts; from then +on that write lands in `ICV_PMR` and changes nothing about physical delivery. +The physical priority mask **resets to zero, which masks everything**. + +So the hypervisor owns the physical mask and opens it. Priority ORDER still +does all the work it did before — the hypervisor's tick is numerically lower +and preempts a partition's timer — and what changes is only who sets the +threshold below which nothing is delivered at all. + +**And the same redirection is what makes the FIQ design airtight rather than +merely cheap.** `ICC_IGRPEN0` is redirected too, so once FMO is set a partition +cannot reach the physical Group 0 enable to switch off the interrupt that ends +its own window: it writes `ICV_IGRPEN0` and nothing happens. Before FMO was +set that register was genuinely shared with EL1, and D24 could only argue that +a partition had no Group 0 interrupt worth enabling. + +| Register | Encoding | Used by | +|---|---|---| +| `ICC_IGRPEN0` | `p15, 0, c12, c12, 6` | EL2, to enable Group 0 signalling | +| `ICC_IAR0` | `p15, 0, c12, c8, 0` | EL2, to acknowledge an FIQ | +| `ICC_EOIR0` | `p15, 0, c12, c8, 1` | EL2, to complete one | +| `GICR_ISPENDR0` | SGI frame, offset `0x0200` | reading whether a PPI is pending | + ### The generic-timer registers, AArch32 | Register | Encoding | Level | @@ -891,31 +1007,112 @@ can support; it is repeatability, not the cost of anything. Not a decision — a **list of registers**, kept here because that is what it is, and because every entry on it was found by needing it rather than by reading a -manual. Nothing switches partitions yet; this is what will have to. - -`—` in the last column means nothing in ZoneX touches it today, which with one -partition is exactly correct and with two is a defect. - -| State | Encoding (AArch32) | Whose | Handled? | +manual. **Every row is now handled**, and the column that used to say what was +missing says how instead. + +**Almost all of it is reachable from C at EL2**, and that turned out to be the +most useful fact on this page. AArch32 Hyp mode banks only `SP`, `LR` and +`SPSR`, so at the instant the hypervisor takes control a guest's banked +registers, its EL1 system registers and its whole EL1 MPU are STILL IN THE +MACHINE and can be read and written with ordinary `MRS`/`MSR` (banked) and +`MRC`/`MCR` forms. Only fifteen words cannot wait: `r0`–`r12`, which Hyp mode +SHARES with EL1 and which the first C instruction destroys, plus `ELR_hyp` and +`SPSR_hyp`. Those are captured in the vector; everything else is C. + +**The banked-register forms, verified by assembling them** under both +`arm-none-eabi-gcc` and ATfE clang: `MRS Rd, SP_usr` / `LR_usr` / `R8_usr` … +`R12_usr`, and the same for `_svc`, `_irq`, `_abt`, `_und`, `_fiq`, plus +`SPSR_svc`/`_irq`/`_abt`/`_und`/`_fiq` and `R8_fiq`…`R12_fiq`. `MSR` in the +other direction. **User and System share `SP` and `LR`**, so `SP_usr` covers +both, and there is no `SPSR_usr` because User mode cannot take an exception +into itself. + +| State | Encoding (AArch32) | Whose | How | |---|---|---|---| -| **EL1 banked registers** — `SP`, `LR` per mode, `SPSR` per mode | mode-banked | guest | — | -| `VBAR` | `p15, 0, c12, c0, 0` | guest | — · the guest writes it in its own board support, and a switch that did not restore it would send the next partition's faults to the previous one's vector table | -| `SCTLR`, `CONTEXTIDR`, `TPIDRURW`/`TPIDRURO`/`TPIDRPRW` | `p15, 0, c1, c0, 0` / `c13, c0, 1` / `c13, c0, {2,3,4}` | guest | — | -| **The whole EL1 MPU region set** — `PRBAR`/`PRLAR` per region, selected through `PRSELR` | `p15, 0, c6, c3, {0,1}` with `p15, 0, c6, c2, 1` | guest | — · **20 regions × 2 registers on the S32Z280.** This is likely to dominate the switch cost and it is the number the next step has to measure. | -| **FPU** — `FPEXC`, `FPSCR`, `D0`–`D15` | `VMRS`/`VMSR` | guest | — · `HCPTR.TCP10/TCP11` are cleared for a guest (D23), so a guest *may* use the FPU and nothing saves it | -| `CNTV_CTL`, `CNTV_TVAL`/`CNTV_CVAL` | `p15, 0, c14, c3, {1,0}` / `MRRC p15, 3, …, c14` | guest | **stop only.** `zx_el2_guest_timer_stop` disarms the timer when a partition yields, which makes an armed comparator harmless for one partition and does not make it correct for two: a switch that saved neither would hand the next partition the previous one's deadline. | -| `CNTVOFF` | `MRRC`/`MCRR p15, 4, …, c14` | **hypervisor, per partition** | **yes**, for one partition. Becomes an array indexed by partition, along with the suspend/resume instants recorded beside it. | -| **CPU interface** — `ICC_PMR`, `ICC_BPR1`, `ICC_IGRPEN1` | see the table above | guest | — · a partition brings these up itself in `board_init`, so a switch that did not restore them would leave the next partition running with the previous one's priority mask | -| **Which PPIs are enabled** — `GICR_ISENABLER0` | memory-mapped, SGI frame | **hypervisor** | **yes** — every one is disabled at bring-up and only the granted INTID enabled. A switch changes which INTID that is, and it is one register write. | +| `r0`–`r12`, `ELR_hyp`, `SPSR_hyp` | shared with EL1 / Hyp-banked | guest | **assembly, in the vector.** These are the only fifteen words that cannot wait: Hyp mode shares `r0`–`r12` with EL1. `SPSR` carries the guest's own masks and `PSTATE.T` back with it, so a T32 guest resumes in T32 without the switch knowing which. | +| **EL1 banked registers** — `SP`, `LR` per mode, `SPSR` per mode, `R8_fiq`–`R12_fiq` | `MRS`/`MSR` (banked) | guest | **C.** FIQ mode's `r8`–`r12` are saved too, although a partition here can take no FIQ: "the guest cannot be using these" is a claim about a configuration bit, and a switch correct only while one bit stays set is a trap for whoever changes it. | +| `VBAR`, `SCTLR`, `CPACR`, `CONTEXTIDR`, `TPIDRURW`/`TPIDRURO`/`TPIDRPRW`, `MAIR0/1`, `AMAIR0/1` | `p15, 0, c12, c0, 0` / `c1, c0, 0` / `c1, c0, 2` / `c13, c0, {1,2,3,4}` / `c10, c2, {0,1}` / `c10, c3, {0,1}` | guest | **C.** `SCTLR` is restored LAST of these, because it carries `M` — the bit that makes every region below start being consulted. | +| **The whole EL1 MPU region set** — `PRBAR`/`PRLAR` per region, selected through `PRSELR` | `p15, 0, c6, c3, {0,1}` with `p15, 0, c6, c2, 1` | guest | **C, and it DOMINATES — measured.** 20 regions on the S32Z280, 32 on the model. One barrier pair for the whole block, never one per region; the `ISB` after each `PRSELR` write is the one that cannot be hoisted, because the next instruction reads `PRBAR` *through* it. | +| **FPU** — `FPEXC`, `FPSCR`, `D0`–`D15` | `VMRS`/`VMSR` | guest | **DENIED, not saved.** `HCPTR.TCP10/TCP11` are set for a time-partitioned system, so a guest touching floating point takes an exception at EL2 with a syndrome naming the cause. With one partition an open FPU was exactly right; with two it is a shared register bank whose failure mode is a wrong ANSWER rather than a fault. See D23 and D25. | +| `CNTV_CTL`, `CNTV_CVAL` | `p15, 0, c14, c3, 1` / `MRRC p15, 3, …, c14` | guest | **saved, then DISARMED.** Both in that order: the comparator is a deadline in the partition's own time and must come back with it, and an armed expired comparator holds the PPI asserted at the GIC — on the same INTID the next partition is about to be given. Restore the comparator BEFORE the control, or the incoming guest takes a tick it never scheduled as its first act. | +| `CNTVOFF`, and the suspend/resume instants beside it | `MRRC`/`MCRR p15, 4, …, c14` | **hypervisor, per partition** | **yes, in the context block.** One register, several partitions, so the running partition's value is in the register and the others' are in their blocks. | +| **CPU interface** — `ICC_PMR`, `ICC_CTLR`, `ICC_IGRPEN0` | see the redirection table above | **hypervisor, once** | **not switched at all, and this is what `FMO` changed.** An EL1 access to any of them is redirected to the virtual interface once `FMO` is set, so the physical copies are EL2's and are set once at boot. They came OFF this list. | +| **CPU interface** — `ICC_BPR1`, `ICC_IGRPEN1` | see the table above | guest | **not switched.** Group 1 is not redirected while `IMO` is clear, and both partitions program them identically at start-up. A manifest whose partitions wanted different values would put these back on the list. | +| **Which PPIs are enabled** — `GICR_ISENABLER0` | memory-mapped, SGI frame | **hypervisor** | **yes, and it does not change.** Both partitions use INTID 27 and never run at once, so what a switch changes is the COMPARATOR behind it rather than the enable. Two INTIDs would make this guest state and grow the switch for no gain. | | The stage-2 region set | `HPRENR` | hypervisor | **yes** — one write, measured; see D4 | +| The next window boundary | `MCRR p15, 6, …, c14` (`CNTHP_CVAL`) | hypervisor | **yes**, absolute, computed from the frame's epoch — see D25 | The two ends of that table are worth contrasting, because they are the whole shape of the problem. The hypervisor's own per-partition state is *small and -cheap* — one `HPRENR` write, one `CNTVOFF` write, one `GICR_ISENABLER0` write. -The **guest's** state is large, and the EL1 MPU dominates it. A partition switch -is therefore not expensive because the hypervisor does much; it is expensive +cheap* — one `HPRENR` write, one `CNTVOFF` write, one comparator. The +**guest's** state is large, and the EL1 MPU dominates it. A partition switch is +therefore not expensive because the hypervisor does much; it is expensive because a guest has a lot of registers. +**Measured 2 September 2026, over thirty-nine boundaries of a twenty-frame run +on each target.** The FVP is a functional model and its numbers are not timing; +they are here because printing them on every run keeps the measurement path +exercised on a machine with no board attached. **Quote the silicon column.** + +| | Armv8-R AEM FVP | **S32Z280-594EVB** | +|---|---|---| +| EL1 MPU regions a switch carries | 32 | **20** | +| whole switch, min / mean / max | 1,401 / 1,405 / 1,411 | **5,630 / 5,684 / 5,944** | +| spread | 10 | **314** | +| save, everything | 537 | **1,702** | +| — of which the EL1 MPU | 458 | **1,460** | +| restore, everything | 537 | **2,087** | +| — of which the EL1 MPU | 458 | **1,729** | +| stage-2 region set, one `HPRENR` write | 9 | **117** | +| the time freeze, `CNTVOFF` | 75 | **404** | +| arming the next boundary, `CNTHP_CVAL` | 24 | **90** | +| counter-read overhead, subtracted from every row | 8 | **122** | + +**The EL1 MPU is 85% of each direction on BOTH targets**, and that proportion +holding across a 32-region model and a 20-region part is the more useful half +of the result. + +**Report the MAXIMUM, not the mean.** A schedule has to be built to survive the +worst switch it will ever take, so a mean quoted alone hides exactly the +excursion a reviewer is asking about. The spread is the interesting shape: a +switch whose cost depended on what a guest had been doing would show it here. + +⚠ **It did, until the guest console came out of the measured span.** The spread +on silicon was **9,562** cycles — min 5,928 against max 15,490 — because +releasing the console closes a partial line, and on this board that is +characters through a polled UART. Only the switches that happened to leave a +partition mid-sentence paid it. A partition switch in a product has no console +in it, so the tag is now taken and given back outside the two counter readings; +left inside, the published figure described what a guest had been printing. + +⚠ **The `HPRENR` row disagrees with D4's and the disagreement is unexplained.** +D4 measured the same function by the same method and got **235** cycles on the +S32Z280 and **13** on the model, against **117** and **9** here — about half on +both targets, which is too consistent to be noise. Both are recorded. D4's +decision is unaffected either way, because it rests on a region write's cost +GROWING with the incoming partition's region count while a mask's does not, and +both readings agree the mask is much cheaper. Settling it belongs with the +hardening work and should start by running both measurements in one image. + +### ⚠ The floating-point traps block the debugger's register read, on silicon + +With `HCPTR.TCP10/TCP11` SET, the S32Z280's debug probe cannot read the core's +register file: gdb reports *"Could not read registers; remote failure reply +'01'"* and the harness around the image exits non-zero **on a run whose own +console said ALL CHECKS PASSED**. The debugger reads the VFP registers as part +of the register file, and a trapped coprocessor is one it cannot reach. + +Measured by comparison on the board, which is the only way to attribute it: an +image that stops BEFORE the traps are set produces no such error, and the same +image stopping AFTER produces two. + +The answer is not to stop denying the FPU — the traps exist because nothing +saves the register bank across a partition switch (D25). What they protect is +the interval in which PARTITIONS run, so they are lifted once the last window +has closed and before the hypervisor parks. A harness that reports a passing +run as a failure is the one outcome a regression must never have, in either +direction. + --- ## Sources diff --git a/docs/decisions.md b/docs/decisions.md index 78da24d..92c6916 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1008,3 +1008,293 @@ business enabling. Verified on both targets: the partition receives INTID 27 and no other, and the hypervisor's own timer PPI reads back disabled. +--- + +## D25 — How a partition's window ENDS · **settled, and run on both targets** + +**`HCR.FMO` set, `IMO` and `AMO` clear. The hypervisor's own timer, CNTHP, +in GIC Group 0 — delivered as an FIQ, taken at EL2 — armed with an +ABSOLUTE deadline in `CNTHP_CVAL`. Every partition interrupt stays Group 1, +stays an IRQ, and is still delivered straight to EL1 with no injection and +no List Register.** + +This is D24's Design A, built and measured. D24 established who owns the GIC +and corrected its own first answer about how a hypervisor tick reaches EL2; +this entry is what happened when that was implemented, and what it turned out +to cost. + +### Why FIQ, and not injection + +Routing is by exception **type**, not by INTID. So the choice is not between +"the hypervisor gets interrupts" and "the guest does" — both can be true at +once, and the whole of interrupt virtualization is avoidable in Phase 0. + +It is also **better** than injection rather than merely cheaper, and the +reason is one sentence: with `FMO` set, `PSTATE.F` is IGNORED at EL0 and EL1, +so **a partition cannot mask the interrupt that ends its own window.** A tick +delivered as an IRQ to EL1 could be deferred by any guest that disabled +interrupts, which is precisely the property temporal partitioning is bought +to have. + +That is now a test result and not a claim. `zx_two_partitions_hog.elf` runs a +partition that masks IRQ **and** FIQ at EL1 and then spins for ever, making +no kernel call and asking the hypervisor for nothing. On the model it is +preempted six times out of six, exactly on schedule; what its masking costs +it is its own kernel's tick, which stops at 4 while its liveness counter +reaches 229,233. A frame driven by an interrupt taken at EL1 would hang on +that build, which is why it exists as a build rather than as a paragraph. + +### The two things this cost that the design did not predict + +**`ICC_PMR` moves to the virtual CPU interface the moment `FMO` is set.** +TRM 9.3.5: an EL1 access to any register COMMON to both interrupt groups is +redirected to `ICV_*` when `FMO` **or** `IMO` is 1, and `ICC_PMR` is one of +the common ones. Every guest writes `ICC_PMR = 0xFF` at start-up to unmask +its interrupts; from the instant FMO is set that write lands in `ICV_PMR` and +changes nothing about physical delivery, while the physical mask — which +resets to **zero, masking everything** — is left closed. A partition that was +receiving its timer end to end a moment earlier stops receiving anything, +with no fault and nothing in the log. + +So the physical priority mask becomes the hypervisor's, and that turns out to +be the right owner for the same reason the redistributor is: a partition able +to raise the physical mask could refuse the interrupt that ends its own +window. Priority ORDER still does the work it always did — the tick at `0x20` +preempts a partition's timer at `0xA0`, with four implemented steps between +them on both targets. + +**And the same redirection closes the last hole in D24's argument.** +`ICC_IGRPEN0` is redirected too, so a partition cannot reach the physical +Group 0 enable at all: it writes `ICV_IGRPEN0` and nothing happens. Before +`FMO` was set, that register was genuinely shared with EL1 and D24 could only +argue that a partition had no Group 0 interrupt worth enabling. Now it cannot +reach the enable even in principle. + +### `HCR.AMO` stays clear, and that is a decision rather than an oversight + +The design sketch this work came from listed `AMO = 1` beside `FMO = 1`. +It is not set, for a reason specific to this codebase: an asynchronous abort +routed to EL2 arrives at `HVBAR + 0x10`, the vector that today means *"ZoneX +faulted on its own access"* and reports a hypervisor bug. Setting `AMO` +without reworking that vector would report a **guest's** abort as a +hypervisor bug — a worse diagnosis than the one it replaces. + +Nothing about ending a window needs it. Distinguishing the two cases needs +`SPSR_hyp.M` checked in that vector, which is small and belongs with the +fault-path hardening rather than with the schedule. + +### An absolute deadline, and the drift it prevents + +`CNTHP_CVAL`, not `CNTHP_TVAL`. A relative countdown re-armed inside the +handler adds the handler's own latency to every window: a frame declared as +10 ms becomes 10 ms plus the switch, cumulatively, for the life of the run. +The schedule still looks fixed, the windows stay in the right proportion, and +the frame slowly stops being the length it was declared to be. It is +invisible in any run short enough to read, which is why the host suite runs +ten thousand frames of the arithmetic and asserts the last boundary is exact. + +The opposite choice is right one level down: a guest re-arming its own +virtual timer inside its own handler wants `TVAL`, because that is one +register write with no counter read and no 64-bit arithmetic. Two timers, two +registers, and the reasoning inverts between them. + +### What a partition switch turned out to cost + +**Measured 2 September 2026. The S32Z280 figures are the ones to quote; the +model is functional, not cycle-accurate, and its numbers are reported only so +that the measurement path is exercised on a machine with no board attached.** + +Twenty major frames, thirty-nine window boundaries, no missed deadlines and +no unexplained interrupts on either target. + +| | Armv8-R AEM FVP | **S32Z280-594EVB** | +|---|---|---| +| EL1 MPU regions a switch carries | 32 | **20** | +| whole switch, min / mean / max | 1,401 / 1,405 / 1,411 | **5,630 / 5,684 / 5,944** | +| spread | 10 | **314** | +| save, everything | 537 | **1,702** | +| — of which the EL1 MPU | 458 | **1,460** | +| restore, everything | 537 | **2,087** | +| — of which the EL1 MPU | 458 | **1,729** | +| stage-2 region set, one `HPRENR` write | 9 | **117** | +| the time freeze, `CNTVOFF` | 75 | **404** | +| arming the next boundary, `CNTHP_CVAL` | 24 | **90** | +| counter-read overhead, subtracted from every row | 8 | **122** | + +**The guest's EL1 MPU is 85% of each direction on both targets**, and that +proportion holding across a 32-region model and a 20-region part is the more +useful half of the result. It is the answer to the question a safety customer +asks, and it is the opposite of the intuitive one: a partition switch is not +expensive because the hypervisor does much — its own per-partition state is +three register writes — but because a **guest** has a lot of registers, and +most of them are its memory protection unit. + +**Report the maximum.** A schedule has to be built to survive the worst switch +it will ever take, so a mean quoted alone hides the excursion a reviewer is +asking about. The spread is the shape that matters: 314 cycles in 5,600 is +5.6%, and a switch whose cost depended on what a guest had been doing would +show it here. + +⚠ **The spread was 9,562 cycles until the guest CONSOLE came out of the +measured span.** Releasing the console closes a partial line, which on +silicon is characters through a polled UART — thousands of cycles, and only +on the switches that happened to leave a partition mid-sentence. A partition +switch in a product has no console in it; the tag is a demonstration +facility, so it is now taken and given back outside the two counter readings. +Left inside, the published number would have described what a guest had been +printing rather than what a switch costs. + +⚠ **The `HPRENR` figure disagrees with D4's and the disagreement is not +explained.** D4 measured the same function by the same method — eight +operations, alternating between two masks, with the counter-read cost +measured separately and subtracted — and reported **235** cycles on the +S32Z280 and **13** on the model. This measurement gets **117** and **9**: +about half, on both targets, which is too consistent to be noise and points +at a difference in method or in surroundings rather than in the hardware. +Both figures are recorded rather than one quietly replacing the other. **The +decision D4 rests on is unaffected either way** — it rests on the region +write's cost GROWING with the incoming partition's region count while the +mask's does not, and both readings agree that the mask is much the cheaper. +Settling which number is right belongs with the hardening work, and it should +start by running both measurements in one image. + +The way to make the switch cheaper is known and is not taken: `HSTR` traps a +guest's own CP15 accesses by register group, so a hypervisor could shadow the +guest's MPU writes and skip the save half entirely. That trades a bounded +cost at every switch for an unbounded number of traps while a guest runs — +the wrong trade for a static frame. The naive version is measured first so +that a later phase has a number to beat. + +### And what the two partitions' clocks did + +The number the whole step exists to produce, measured on the S32Z280 over +twenty frames with partition A holding seven ticks of every ten and B three: + +| | counts on the core | its own ticks | +|---|---|---| +| partition A | 11,157,245 | 139 | +| partition B | 4,822,000 | 51 | +| total | 15,979,245 | — | + +The frame is 20 × 10 × 80,000 = **16,000,000 counts**, so the 20,755 counts +unaccounted for are the thirty-nine switches themselves — charged to neither +partition, which is what makes each partition's figure its own. + +**A × 3 = 33,471,735 against B × 7 = 33,754,000: within 0.84% of exact.** +That equality *is* the temporal claim rather than a symptom of it. A +partition that could see wall clock would be out by a FACTOR of three here, +not by a percentage; the residue is the first window, entered from a cold +start rather than from a switch, and the last, cut short by the frame limit +rather than by a boundary. + +**And it is measured from both sides, which is the only way it means +anything.** ZoneX knows how much of the physical counter each partition was +given; only the guest can say what its OWN counter did over the same span, +and each publishes its tick count from inside its own window. A guest whose +virtual time matched wall clock would be a system that runs perfectly and +lies about time — invisible to every other check in this suite. + +### Where the switch is, and why it is mostly C + +The fifteen words that cannot wait — `r0`-`r12`, `ELR_hyp`, `SPSR_hyp` — are +captured in the vector, in assembly, because Hyp mode SHARES `r0`-`r12` with +EL1 and the first C instruction would destroy them. Everything else a +partition owns is still in the machine while the hypervisor runs: its banked +SPs and LRs, its EL1 system registers, its whole EL1 MPU. Those are C. + +The plan this work came from expected one large assembly routine. The split +is better, and the reason is the twenty-region loop: as C it is eight lines a +reader can check against the region count the part reported, and unrolled in +assembly it is a hundred and twenty coprocessor moves in which one transposed +operand hands a partition its neighbour's permissions. Nothing is given up — +the loop's trip count is read from `MPUIR` once at boot and depends on +nothing a guest did, which is the only property a worst-case-execution-time +argument actually needs. + +### One defect this step found in ZoneX's own foundations + +`HCR`'s routing bits are `FMO[3]`, `IMO[4]`, `AMO[5]`. This port had `FMO` +and `AMO` defined the other way round from its first commit, because the +mnemonic order a reader reaches for is the alphabetical one. It survived +because `IMO` is bit 4 either way and because every use of the three until +now was a single `BIC` of all three at once, where swapping two changes +nothing. + +The first image that SET one of them routed nothing: bit 5 is `AMO`. Every +other check was green — the comparator expired, the GIC made PPI 26 pending, +the priorities were right, the CPU interface was open — and no window ever +ended. **A check written as `(HCR & ZX_HCR_FMO) != 0` could not have caught +it**, because that is the same symbol on both sides of the comparison. The +image now checks HCR by BIT POSITION, which is the only form of the check a +swapped definition cannot satisfy, and the positions are asserted at compile +time. The full account, including the three questions that localised it, is +in `docs/armv8r-el2-reference.md`. + +### The isolation claim, between two partitions that are both alive + +One partition can be shown to be confined; it cannot be shown to be confined +FROM ANYTHING. `zx_two_partitions_cross.elf` closes that: partition B is +handed an address inside partition A's window and told to write to it, and +its guest **grants itself that granule in its own EL1 MPU first**, so stage 1 +permits the access and B genuinely believes it owns the memory. + +Stage 2 refuses. A's region is not in B's enabled set, and PMSAv8-R gives +EL0/EL1 no background map — an access that hits no enabled region faults +regardless of `HSCTLR.BR`. That is "the stricter of the two stages wins" with +the two stages genuinely disagreeing, between two live partitions, and the +syndrome names it: `EC 0x24`, a data abort routed to EL2, with `HDFAR` naming +the address B aimed at. + +**The second half of the result matters as much as the first.** B is stopped +and **partition A runs to the end of the frame with its schedule untouched** +— every window, every tick, every boundary. Being attacked cost it nothing. +A run in which A's clock had slowed because B misbehaved would satisfy every +memory check in this suite and would not be temporal partitioning. + +And B's windows are still **spent** rather than handed to A. That path — a +frame stepping over a dead partition's slot — is exercised nowhere else, and +it is the one place the "an idle partition burns its window" rule is +observable rather than merely stated. + +⚠ **It also exposed a measurement defect.** Burning a stopped partition's +window happens inside the boundary handler, so it was being counted as switch +cost: a switch of a few thousand cycles reported as three million, with the +difference showing up as jitter. A boundary that had to wait out a window is +a switch PLUS a wait, and a figure averaging the two describes neither. Such +boundaries are now counted and not timed, and the run prints both counts — +which are equal in any run where both partitions live to the end. + +### What is deliberately not here + +* **Interrupt LATENCY is not a hypervisor-controlled quantity.** Guest + interrupts go straight to EL1, which is why they cost what they always did + — and why bounding them needs the List Registers this core has and this + phase does not use. That stays where the roadmap put it. +* **An idle partition burns its window.** Trapping `WFI` to hand the + remainder to the next partition would raise throughput and would make one + partition's start time depend on its neighbour's behaviour, which is the + coupling temporal partitioning is bought to remove. Declined, on purpose. +* **A faulted partition is stopped, not restarted.** Supervised restart is a + later phase, and a hypervisor that quietly re-entered a partition which had + just violated its boundary would be doing the easy half of it. +* **A partition faulting at the exact instant of a frame boundary is not a + build**, and the gap is deliberate. It is a race a few cycles wide and + cannot be provoked reproducibly; a test that caught it once would be an + anecdote rather than a regression. What can be said is structural: the two + events arrive at DIFFERENT VECTORS — a fault at the Hyp trap entry, a + boundary at the FIQ vector — and a core takes one exception at a time. + Whichever arrives first is taken and the other is still pending: a fault + taken first returns to the hypervisor with the boundary's FIQ still + asserted at the GIC, and a boundary taken first re-enters the faulting + partition, which faults again on the same instruction. Neither order loses + an event, because neither event is edge-triggered and neither is + acknowledged until it is handled. +* **The FPU is DENIED to a time-partitioned system rather than saved.** D23 + recorded that opening `HCPTR.TCP10/TCP11` was exactly right with one + partition and a shared register bank with two, since nothing saves `FPEXC`, + `FPSCR` or the D-registers across a switch. The failure mode is a wrong + ANSWER and not a fault, so ZoneX closes the traps instead: a guest touching + floating point takes an exception at EL2 with a syndrome naming the cause. + Saving the bank is a real later-phase option and needs the register file's + width read from `MVFR0` rather than assumed; sharing it silently is not an + option at all. From f094a711376f21c2ca1002a1c717faea491307ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 2 Sep 2026 21:44:03 -0400 Subject: [PATCH 21/40] Taught the reference check to catch a roadmap identifier Two went in while the partition switch was being written, and both were in CODE COMMENTS rather than in documents -- which is where nobody thinks to look for them. The existing check rejects a local path and a numbered step and had nothing to say about either. The planning documents such identifiers name live outside every repository, so a comment citing one points a reader at something they cannot obtain and never will. The FACT is almost always worth keeping and is almost always citable another way: those measurements were taken during a named piece of work on a named board, so "measured on the S32Z280 during the Cortex-R52 Modules port work" carries everything the identifier did and survives the reader. Checked case-sensitively and separately from the rest, because folding it into the case-insensitive pattern would make an ordinary lower-case word an error. Seen to fail before it was believed. The pattern spells the shapes it rejects, because a denylist has to. That is the same bargain scripts/check_terminology.sh makes and states: one place holds the spellings, so the list cannot drift away from what is enforced. Assisted-by: Claude Code (Opus 5) --- platform/cortex_r52/src/zx_context.S | 7 ++--- platform/cortex_r52/src/zx_context.c | 14 ++++----- scripts/check_references.sh | 47 ++++++++++++++++++++++++++-- 3 files changed, 54 insertions(+), 14 deletions(-) diff --git a/platform/cortex_r52/src/zx_context.S b/platform/cortex_r52/src/zx_context.S index 1844f17..e9bf99d 100644 --- a/platform/cortex_r52/src/zx_context.S +++ b/platform/cortex_r52/src/zx_context.S @@ -63,10 +63,9 @@ @ one fixed depth however many boundaries a run takes. A switch whose @ stack grew per boundary would survive a demonstration and not a @ deployment. The one path that does push -- the capture from the trap -@ vector -- balances it explicitly, for the reason AR2 invariant 17 was -@ written: an unbalanced handler leaked bytes of a 1 KB stack per fault -@ during the Cortex-R52 Modules port work and presented as nothing at all -@ until the run got long. +@ vector -- balances it explicitly. An unbalanced handler leaked bytes of +@ a 1 KB stack per fault during the Cortex-R52 Modules port work and +@ presented as nothing at all until the run got long. @ @ NOTE ON SYNTAX @ diff --git a/platform/cortex_r52/src/zx_context.c b/platform/cortex_r52/src/zx_context.c index 8839c94..986119d 100644 --- a/platform/cortex_r52/src/zx_context.c +++ b/platform/cortex_r52/src/zx_context.c @@ -536,13 +536,13 @@ void zx_context_restore_mpu(const ZX_GUEST_CONTEXT *context_ptr) /* that stops being harmless when somebody adds one. */ /* */ /* ONE BARRIER PAIR, AT THE END, NOT ONE PER REGION. The barriers */ -/* dominate: AR2 measured 434-470 cycles for a region written directly */ -/* at EL1 with its own barriers, and most of that was the closing */ -/* DSB/ISB rather than the write. Forty region writes with forty */ -/* barrier pairs would be forty times a cost that only has to be paid */ -/* once, because nothing between the first write and the ERET depends */ -/* on the new permissions. The ISB before the ERET is what makes them */ -/* take effect, and there is exactly one. */ +/* dominate: the Cortex-R52 Modules port work measured 434-470 cycles */ +/* for a region written directly at EL1 with its own barriers, and most */ +/* of that was the closing DSB/ISB rather than the write. Forty region */ +/* writes with forty barrier pairs would be forty times a cost that */ +/* only has to be paid once, because nothing between the first write */ +/* and the ERET depends on the new permissions. The ISB before the */ +/* ERET is what makes them take effect, and there is exactly one. */ /* */ /* The PRSELR write inside the loop is different and does need its ISB: */ /* the very next instruction reads PRBAR THROUGH it, so the selection */ diff --git a/scripts/check_references.sh b/scripts/check_references.sh index 1775410..707cc6b 100755 --- a/scripts/check_references.sh +++ b/scripts/check_references.sh @@ -35,6 +35,27 @@ # Development-environment instructions are not project documentation, and # a committed one starts being treated as though it were. # +# 4. A ROADMAP OR PLAN IDENTIFIER. Planning documents live outside every +# repository, so a comment citing one by its identifier points a reader +# at something they cannot obtain and never will. The FACT such a +# comment carries is almost always worth keeping and is almost always +# citable another way: the measurements those documents record were +# taken during a named piece of work on a named board, so "measured on +# the S32Z280 during the Cortex-R52 Modules port work" carries +# everything the identifier did and survives the reader. +# +# Worth its line because two went in while the partition switch was +# being written, in CODE COMMENTS rather than in documents, which is +# where nobody thinks to look for them. +# +# THE PATTERN BELOW SPELLS THE SHAPES IT REJECTS, because a denylist has +# to. That is the same bargain scripts/check_terminology.sh makes and +# states: it is the single place those spellings appear, so that the +# list cannot drift away from what is actually enforced. A shape in a +# denylist is not a citation -- it points at nothing and carries no +# content -- but it is the one place in this repository where the shapes +# appear at all, and that is deliberate rather than accidental. +# # Deliberately narrow. It catches the shapes that have actually gone wrong # rather than trying to be a general secret scanner, because a check that # cries wolf gets bypassed and then catches nothing at all. @@ -55,23 +76,43 @@ readonly FORBIDDEN_FILES='^(AGENTS|CLAUDE)\.md$|/(AGENTS|CLAUDE)\.md$' # "~/" only when it opens a path, so that prose using a tilde is not caught. # "step " case-insensitively, because "Step 5" reads as naturally as # "step 5" and both are the same mistake. +# The plan identifiers are matched with word boundaries and a digit, so that +# ordinary prose is not caught, and case-SENSITIVELY so that lower-case text +# cannot trip them. That is why they are a separate pattern below rather +# than folded into the case-insensitive one above. readonly PATTERN='(^|[^A-Za-z0-9_.-])(/home/[A-Za-z0-9._-]+|~/[A-Za-z0-9._-])|\bstep[ -][0-9]+\b' status=0 # This script is excluded by name: it has to spell the patterns to look for # them. Tracked files only -- a build tree is not this repository's content. +# The plan identifiers are checked case-SENSITIVELY and separately, because +# folding them into the pattern above would make "ar2" in ordinary prose an +# error. The document names are capitalised and the code never is. +readonly PLAN_PATTERN='\b(AR[0-9]+|A[0-9]+-step[0-9]+)\b' + findings="$(git ls-files -z \ | grep -zZv '^scripts/check_references\.sh$' \ | xargs -0 grep -nEiI "${PATTERN}" 2>/dev/null \ | grep -v "${MARKER}" || true)" +plan_findings="$(git ls-files -z \ + | grep -zZv '^scripts/check_references\.sh$' \ + | xargs -0 grep -nEI "${PLAN_PATTERN}" 2>/dev/null \ + | grep -v "${MARKER}" || true)" + +if [ -n "${plan_findings}" ]; then + findings="${findings}${findings:+$'\n'}${plan_findings}" +fi + if [ -n "${findings}" ]; then echo "ZoneX reference check FAILED." >&2 echo "" >&2 - echo "These lines name a local path, or a numbered step of a document" >&2 - echo "that is not in this repository. Keep the fact and drop the" >&2 - echo "citation: a reader cannot follow either one." >&2 + echo "These lines name a local path, a numbered step, or a roadmap" >&2 + echo "identifier from a document that is not in this repository. Keep" >&2 + echo "the fact and drop the citation: a reader cannot follow any of" >&2 + echo "them, and the measurements those documents record ARE citable --" >&2 + echo "by the work and the board they were taken on." >&2 echo "" >&2 echo "${findings}" >&2 status=1 From 2664f3da33535f0792f1999a2d21d47362d214b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 2 Sep 2026 22:02:54 -0400 Subject: [PATCH 22/40] Measured the core clock, and found the figures qualified both ways Every switch cost this suite publishes is in core CYCLES, which is the right unit for a worst-case-execution-time argument and is not a self-contained one. Nobody could convert them: CNTFRQ reads zero out of reset and this part reports its core clock nowhere, so "5,715 cycles" was a number no reader could turn into a duration -- or argue with, which is not the same as one they should accept. MEASURED AGAINST THE SYSTEM COUNTER, whose frequency is the one in this system that has actually been established -- three independent ways, during the Cortex-R52 port work. 48,050,135 Hz on the S32Z280-594EVB, over one millisecond of counter time. AND THE ANSWER EXPLAINS ITSELF. The S32Z27 reference manual gives FIRC as 48 MHz and says FIRC_CLK is the default clock for the entire system at power-up. ZoneX configures no clock tree, so the core spends every run on the part's backup oscillator. THAT QUALIFIES EVERY CYCLE FIGURE, AND IN THE OPPOSITE DIRECTION FROM THE OTHER CONDITIONS. The EL2 caches are off and the image is built -Og, so a warm, optimised switch can only be faster: both make the figure an over-estimate. A backup-clocked core makes it an UNDER-estimate -- at 48 MHz the memory a switch touches is cheap in core cycles, and raising the core clock without raising the memory's makes the same code cost more cycles, not fewer. So a switch figure from this bench is an over-estimate for two reasons and an under-estimate for a third, on one board, and it is NOT a worst case in either direction until it is taken again with the clock tree configured. Listing only the conservative conditions and letting a reader conclude it was an upper bound would have been the easy thing to do and the wrong one. The image now prints all four conditions, the measured clock, and a nanosecond conversion ABOVE its own numbers, together with the two things deliberately excluded from them -- so a figure quoted out of a log carries its conditions with it instead of leaving them in a document. Assisted-by: Claude Code (Opus 5) --- docs/armv8r-el2-reference.md | 50 ++++++++++++ docs/decisions.md | 15 ++++ examples/common/zx_two_partitions.c | 116 +++++++++++++++++++++++++++- platform/cortex_r52/inc/zx_port.h | 13 ++++ platform/cortex_r52/src/zx_timer.c | 80 +++++++++++++++++++ 5 files changed, 272 insertions(+), 2 deletions(-) diff --git a/docs/armv8r-el2-reference.md b/docs/armv8r-el2-reference.md index ab67866..3ccb5a6 100644 --- a/docs/armv8r-el2-reference.md +++ b/docs/armv8r-el2-reference.md @@ -1067,6 +1067,14 @@ exercised on a machine with no board attached. **Quote the silicon column.** | the time freeze, `CNTVOFF` | 75 | **404** | | arming the next boundary, `CNTHP_CVAL` | 24 | **90** | | counter-read overhead, subtracted from every row | 8 | **122** | +| core clock the cycles were counted at | (model) | **48 MHz, measured** | + +⚠ **Read the conditions above the table before quoting anything in it.** The +S32Z280 column was taken with the EL2 caches OFF, at `-Og`, on one board, and +with the core on the part's 48 MHz power-up RC oscillator because ZoneX +configures no clock tree — which makes the figure an over-estimate for the +first two reasons and an under-estimate for the third. See the section on the +core clock below. **The EL1 MPU is 85% of each direction on BOTH targets**, and that proportion holding across a 32-region model and a 20-region part is the more useful half @@ -1094,6 +1102,48 @@ GROWING with the incoming partition's region count while a mask's does not, and both readings agree the mask is much cheaper. Settling it belongs with the hardening work and should start by running both measurements in one image. +### ⚠ The core runs at 48 MHz — the RC oscillator — because nothing sets a clock tree + +**Measured against the system counter, 48,050,135 Hz on the S32Z280-594EVB**, +by timing the PMU cycle counter across one millisecond of counter time. The +counter's own frequency is the one in this system that has been established +independently, three ways, which is what makes it the right thing to measure +against; reading a clock-tree register instead would report what somebody +programmed rather than what the core is doing. + +The S32Z27 reference manual accounts for it exactly: **FIRC is 48 MHz** and +*"FIRC_CLK is the default clock for the entire system at power-up. All system +clock dividers are set to their default or reset values."* ZoneX configures no +clock tree, so the core is on the part's backup oscillator for the whole run. + +**This qualifies every cycle figure this project publishes, and it qualifies +them in the OPPOSITE direction from the other conditions.** Two of those make a +measured switch an over-estimate: + +| Condition | Direction | +|---|---| +| EL2 caches OFF (`HSCTLR.C`, `.I` cleared at reset) | over-estimate — a warm switch can only be faster | +| built `-Og`, not `-O2` | over-estimate | +| **core on the 48 MHz RC oscillator** | **UNDER-estimate** — at that clock the memory a switch touches is cheap in CORE cycles, so raising the core clock without raising the memory's makes the same code cost MORE cycles | + +So a switch figure from this bench is an over-estimate for two reasons and an +under-estimate for a third, and **it is not a worst case in either direction** +until it is taken again with the clock tree configured. Anybody quoting it as +a WCET is quoting something this project has not measured. + +Two consequences worth carrying: + +* **Cycles remain the right unit** — they are what a WCET argument is made in, + and `CNTFRQ` reads zero on both targets so there is no frequency to convert + with anyway. What changed is that the conversion is now *possible*: the + two-partition image measures the core clock on every run and prints it above + its own figures, so a cycle count published from this suite can always be + turned into a duration by the reader who wants one. +* **Configuring the clock tree is board bring-up and is not done**, on purpose. + Running at the reset default is legitimate and reproducible as long as it is + stated; what would not be legitimate is publishing a duration derived from it + without saying which clock it came from. + ### ⚠ The floating-point traps block the debugger's register read, on silicon With `HCPTR.TCP10/TCP11` SET, the S32Z280's debug probe cannot read the core's diff --git a/docs/decisions.md b/docs/decisions.md index 92c6916..8156f73 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1120,6 +1120,7 @@ no unexplained interrupts on either target. | the time freeze, `CNTVOFF` | 75 | **404** | | arming the next boundary, `CNTHP_CVAL` | 24 | **90** | | counter-read overhead, subtracted from every row | 8 | **122** | +| core clock the cycles were counted at | (model) | **48 MHz, measured** | **The guest's EL1 MPU is 85% of each direction on both targets**, and that proportion holding across a 32-region model and a 20-region part is the more @@ -1129,6 +1130,20 @@ expensive because the hypervisor does much — its own per-partition state is three register writes — but because a **guest** has a lot of registers, and most of them are its memory protection unit. +⚠ **AND THE CONDITIONS ARE PART OF THE FIGURE.** Two of them make it an +over-estimate — the EL2 caches are OFF and the image is built `-Og`, so a +warm, optimised switch can only be faster. **One makes it an under-estimate, +and it is the one that would otherwise be found by somebody else:** ZoneX +configures no clock tree, so the core runs on this part's power-up RC +oscillator at 48 MHz, measured. At that clock the memory a switch touches is +cheap in CORE cycles; raise the core clock without raising the memory's and +the same code costs MORE cycles, not fewer. + +So the figure is a real measurement of a real switch on real silicon and it +is **not a worst case in either direction** until it is taken again with the +clock tree configured. The image prints all four conditions above its own +numbers on every run, so a figure quoted out of a log carries them with it. + **Report the maximum.** A schedule has to be built to survive the worst switch it will ever take, so a mean quoted alone hides the excursion a reviewer is asking about. The spread is the shape that matters: 314 cycles in 5,600 is diff --git a/examples/common/zx_two_partitions.c b/examples/common/zx_two_partitions.c index 1a242a1..1cfcb3c 100644 --- a/examples/common/zx_two_partitions.c +++ b/examples/common/zx_two_partitions.c @@ -762,10 +762,80 @@ static void zx_report_partition(UINT index) /* zx_report_switch_cost */ /**************************************************************************/ -static void zx_report_switch_cost(void) +static void zx_report_switch_cost(uint32_t core_hz) { zx_console_puts("\n--- what a partition switch costs, by group ---\n"); + /* THE CONDITIONS COME FIRST, ABOVE THE NUMBERS, and they are printed on + every run rather than kept in a document. + * + A cycle count is quoted back at people. It travels out of this log + into a slide, and the four facts that decide what it MEANS do not + travel with it unless they are attached to it: what the core is + clocked at, whether the caches were on, what optimisation the image + was built at, and how many samples the spread came from. A reader + who has all four can argue with the number, and a reader who has to + ask for them is entitled to assume the answer was inconvenient. */ + + zx_console_puts(" the conditions these were measured under:\n"); + zx_console_puts(" core clock, measured against the system counter = "); + zx_console_putdec(core_hz / 1000000U); + zx_console_puts(" MHz\n"); + zx_console_puts(" EL2 caches: OFF. HSCTLR.C and .I are cleared in\n" + " the reset path, so every figure below is a\n" + " CACHE-COLD cost and a warm switch can only be\n" + " faster. Conservative.\n"); + zx_console_puts(" optimisation: -Og, not -O2. Conservative again.\n"); + zx_console_puts(" one board, one part, one run. Reproducible on this\n" + " bench is not the same claim as characterised\n" + " across a population, and nothing here supports\n" + " the second.\n"); + + /* AND THE ONE THAT CUTS THE OTHER WAY, which is the reason this block + exists at all. + + It would be easy to list only the conditions that make the figure + conservative and let a reader conclude it is an upper bound. It is + not. ZoneX configures no clock tree, so the core runs on the part's + power-up default -- the internal RC oscillator, which the reference + manual gives as 48 MHz and names as the default clock for the entire + system at power-up. At that clock the memory this switch touches is + CHEAP in core cycles. Raise the core clock without raising the + memory's and the same code costs MORE cycles, not fewer. + + So: cache-cold and unoptimised make this an over-estimate, and a + backup-clocked core makes it an under-estimate, and nobody should + quote it as a worst case in either direction until it has been + measured again with the clock tree configured. Saying so here is + cheaper than having it pointed out. */ + + zx_console_puts("\n" + " AND ONE CONDITION THAT CUTS THE OTHER WAY. ZoneX\n" + " configures no clock tree, so the core runs on\n" + " this part's POWER-UP DEFAULT -- the internal RC\n" + " oscillator, 48 MHz nominal, which the reference\n" + " manual names as the default clock for the whole\n" + " system at power-up. At that clock the memory this\n" + " switch touches is cheap in CORE CYCLES; raise the\n" + " core clock without raising the memory's and the\n" + " same code costs MORE cycles, not fewer.\n" + "\n" + " So this figure is an over-estimate for two reasons\n" + " and an under-estimate for a third. It is a real\n" + " measurement of a real switch on real silicon, and\n" + " it is NOT a worst case in either direction until\n" + " it has been taken again with the clock tree\n" + " configured.\n"); + + if (core_hz != 0U) + { + zx_console_puts(" at that clock, one thousand cycles is about "); + zx_console_putdec(1000000000U / (core_hz / 1000U)); + zx_console_puts(" ns\n"); + } + + zx_console_puts("\n"); + if (zx_pmu_is_running() == 0U) { zx_console_puts(" the PMU cycle counter is not advancing, so no\n" @@ -786,6 +856,29 @@ static void zx_report_switch_cost(void) zx_note("the time freeze, CNTVOFF ", zx_cost.zx_cost_time_freeze); zx_note("arming the next boundary ", zx_cost.zx_cost_deadline); + zx_console_puts( + "\n" + " AND WHAT IS DELIBERATELY NOT IN THE TOTAL, said here rather than\n" + " somewhere a reader has to go and look for it. Two things are\n" + " excluded from the per-switch figures, and both would inflate them\n" + " for reasons that are not a partition switch:\n" + "\n" + " the guest CONSOLE. Handing the console back closes a partial\n" + " line, which on this board is characters through a polled UART.\n" + " A product switch has no console in it; leaving it in made the\n" + " published figure describe what a guest had been printing, and\n" + " it showed up as a spread of 9,562 cycles against the 190 below.\n" + "\n" + " BURNING A STOPPED PARTITION'S WINDOW. A boundary that has to\n" + " wait out a dead neighbour's slot is a switch plus a wait of up\n" + " to a whole window. Such boundaries are COUNTED and not timed;\n" + " the two counts are printed above and are equal in any run where\n" + " both partitions live to the end.\n" + "\n" + " Nothing else is excluded. The groups above sum to the whole\n" + " switch, and the per-boundary figures are taken around the real\n" + " thing rather than around a reconstruction of it.\n"); + zx_console_puts( "\n" " THE EL1 MPU DOMINATES, and that is the answer to the question a\n" @@ -869,6 +962,7 @@ ZX_NORETURN void zx_el2_main(void) uint32_t probe_b; uint32_t ticks_a; uint32_t ticks_b; + uint32_t core_hz; ZX_MANIFEST_FAULT fault; /* ---------------------------------------------------------------- */ @@ -1148,6 +1242,24 @@ ZX_NORETURN void zx_el2_main(void) zx_pmu_enable(); + /* WHAT THE CORE IS CLOCKED AT, measured against the system counter -- + whose own frequency is the one number in this system that has + actually been established. Every switch figure below is in core + CYCLES, and a cycle count nobody can convert into a duration is a + number nobody can argue with, which is not the same as one they + should accept. */ + + core_hz = zx_pmu_core_hz(zx_board_counter_hz(), zx_board_counter_hz() + / 1000U); + + zx_note("core clock, Hz, measured", core_hz); + zx_check("the core clock could be measured against the system counter,\n" + " so every cycle figure in this run converts to a\n" + " duration. It is measured rather than read out of a\n" + " clock-tree register, which would report what somebody\n" + " programmed rather than what the core is doing", + (core_hz > 0U) ? 1U : 0U); + /* PARTITION A IS QUIET AND PARTITION B IS LOUD, and the asymmetry is the demonstration rather than a saving. A guest's console is one hypercall PER CHARACTER through a polled UART, which on silicon is @@ -1351,7 +1463,7 @@ ZX_NORETURN void zx_el2_main(void) /* ---------------------------------------------------------------- */ zx_report_frame(outcome); - zx_report_switch_cost(); + zx_report_switch_cost(core_hz); zx_report_partition(ZX_PART_A); zx_report_partition(ZX_PART_B); diff --git a/platform/cortex_r52/inc/zx_port.h b/platform/cortex_r52/inc/zx_port.h index 3cbb1ec..6c34964 100644 --- a/platform/cortex_r52/inc/zx_port.h +++ b/platform/cortex_r52/inc/zx_port.h @@ -811,6 +811,19 @@ ZX_NODISCARD uint32_t zx_pmu_cycles(void); ZX_NODISCARD uint32_t zx_pmu_is_running(void); +/* What the CORE clock runs at, measured against the system counter over a + short bounded interval. + * + * Every switch figure this suite publishes is in core CYCLES, which is the + * right unit for a worst-case-execution-time argument and is not a + * self-contained one: nobody can turn a cycle count into a duration without + * knowing the core clock, and this part reports it nowhere. Measured + * against the counter because the counter's frequency is the one in this + * system that has actually been established. Returns zero when the counter + * or the cycle counter did not move. */ + +ZX_NODISCARD uint32_t zx_pmu_core_hz(uint32_t counter_hz, uint32_t counts); + /* Turning protection on is TWO steps, and separating them is not tidiness. zx_el2_mpu_enable sets HSCTLR.BR and HSCTLR.M: the EL2-controlled MPU, diff --git a/platform/cortex_r52/src/zx_timer.c b/platform/cortex_r52/src/zx_timer.c index 0549162..90ebfa7 100644 --- a/platform/cortex_r52/src/zx_timer.c +++ b/platform/cortex_r52/src/zx_timer.c @@ -895,3 +895,83 @@ void zx_el2_allow_guest_fp(void) __asm__ volatile("mcr p15, 4, %0, c1, c1, 2" : : "r"(hcptr) : "memory"); __asm__ volatile("isb" ::: "memory"); } + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_pmu_core_hz Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* What the CORE clock runs at, measured against the system counter. */ +/* */ +/* WHY A CYCLE COUNT NEEDS THIS TO MEAN ANYTHING TO A READER. Every */ +/* figure this suite publishes about a partition switch is in CORE */ +/* CYCLES, because cycles are the unit a worst-case-execution-time */ +/* argument is made in and because CNTFRQ reads zero out of reset on */ +/* both targets. That is the right unit and it is not a self- */ +/* contained one: "5,715 cycles" cannot be turned into a duration by */ +/* anybody who does not know what the core is clocked at, and this */ +/* part reports that nowhere. A number a reader cannot convert is a */ +/* number they cannot argue with, which is not the same as one they */ +/* should accept. */ +/* */ +/* MEASURED, NOT LOOKED UP, and measured against the one frequency in */ +/* this system that IS established: the counter's, which on the */ +/* S32Z280 was pinned three independent ways -- against host wall */ +/* clock over thirty-two seconds, from the RTU divider, and from the */ +/* crystal the boot ROM's baud divisors confirm. Reading a clock-tree */ +/* register instead would be reporting what somebody programmed rather */ +/* than what the core is doing. */ +/* */ +/* ONE MILLISECOND OF COUNTER TIME by default, which is short enough */ +/* that PMCCNTR cannot wrap -- it is 32 bits, so a core would have to */ +/* exceed four terahertz -- and long enough that the two counter reads */ +/* at each end, which cost about 93 counts on this part, are a */ +/* thousandth of the interval rather than a part of it. */ +/* */ +/* Returns zero when the counter did not move or the cycle counter did */ +/* not advance, which are the two ways this can be asked of a machine */ +/* that cannot answer -- and a zero a caller must check, because a */ +/* frequency of zero would otherwise divide. */ +/* */ +/**************************************************************************/ + +uint32_t zx_pmu_core_hz(uint32_t counter_hz, uint32_t counts) +{ + uint64_t started; + uint64_t ended; + uint32_t cycles_at_start; + uint32_t cycles; + uint64_t elapsed; + + if ((counter_hz == 0U) || (counts == 0U)) + { + return 0U; + } + + started = zx_read_cntpct(); + cycles_at_start = zx_pmu_cycles(); + + zx_el2_dwell_until(started + (uint64_t)counts); + + cycles = zx_pmu_cycles() - cycles_at_start; + ended = zx_read_cntpct(); + + if (ended <= started) + { + return 0U; + } + + elapsed = ended - started; + + /* cycles / elapsed x counter_hz, with the multiplication FIRST so that + an integer division does not throw the ratio away before it is + scaled. In 64-bit throughout: the product reaches 10^13 on this + part, which a 32-bit intermediate would wrap without any sign that + it had. */ + + return (uint32_t)(((uint64_t)cycles * (uint64_t)counter_hz) / elapsed); +} From 4f886bfa8feb0cfcb708e6d39594d3a055b74a58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 2 Sep 2026 22:13:14 -0400 Subject: [PATCH 23/40] Brought the README up to what the repository actually does Its status section said there was no ThreadX guest, no partition manifest and no scheduler. All three exist, and two partitions now time-share a core under a static frame on both targets, so the front page had drifted three pieces of work behind the tree. WHAT THE REWRITE IS CAREFUL ABOUT, because a front page is where a number gets quoted from and the conditions do not travel with it: IT LEADS WITH MATURITY. "This is not production software and is not close to it." The heading keeps "under construction" and gains "demonstrator". The section below it already said an overclaimed demonstrator is worth less than an honest one; the top of the file now says the same thing first. IT SEPARATES MECHANISMS FROM MEASUREMENTS, and does not hedge both the same way. A partition cannot reach its neighbour's memory, a window ends whether the partition agrees or not, and each clock advances only in its own windows -- none of those move when a number does, and burying them under the caveats the numbers need would underclaim the results that are settled. An underclaimed demonstrator is worth less than an honest one too. IT MARKS WHICH NUMBER IS ROBUST. The seven-to-three ratio is a ratio of two readings of the system counter, whose frequency was established three independent ways, so it cancels the core clock, the caches and the optimisation level outright. The switch cost does not, and says so. IT COMMITS RATHER THAN CAVEATS. Configuring the clock tree is named as a later phase and the switch figure is stated to be expected to change when it lands -- with why it will move in each direction, and why it is worth publishing now anyway: the next measurement needs something to be compared against. IT NAMES THE EXCLUSIONS. The guest console and boundaries that had to wait out a stopped partition's window are both left out of the per-switch figures, both for stated reasons. An exclusion a reader discovers reads as concealment whatever its justification. The third architectural finding replaces the region-budget one, because the ICC_PMR redirection is the more useful warning to somebody about to set HCR.FMO for themselves. Assisted-by: Claude Code (Opus 5) --- README.md | 108 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 88 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 44dd3ca..7c88f8c 100644 --- a/README.md +++ b/README.md @@ -22,32 +22,100 @@ It runs at EL2 on Armv8-R, gives each partition a statically declared slice of memory and of time, and treats a partition stepping outside either as a fault to be reported rather than a condition to be recovered from. -## Status: Phase 0, under construction - -**There is no complete hypervisor in this repository yet — but stage 2 is -alive.** ZoneX now boots at EL2 on both targets, programs stage-2 MPU regions, -drops to EL1, and takes, decodes and reports a stage-2 access violation by -name. There is no ThreadX guest, no partition manifest and no scheduler yet; -each of those assumes what the current image exists to establish. - -`examples/` holds the stage-2 probe: one EL2 program, one trivial EL1 payload -and one deliberate fault. Run it with `scripts/test_fvp.sh` on the Armv8-R AEM -FVP, or `examples/s32z280_evb/tools/run_zx_probe.sh` on the board. - -The suite includes builds that must **fail**, registered as such: one whose -deliberate violation is aimed at an address the payload *is* granted, and one -told it needs more MPU regions than exist. A check that has never been seen to -fail is not evidence that it can. - -Along the way it settled four things about this architecture that the -Cortex-R52 TRM describes ambiguously or contradicts itself about, on both a -model and real silicon. The two most consequential: +## Status: Phase 0, under construction — a demonstrator that now time-partitions + +**This is not production software and is not close to it.** It is a +demonstrator, built to establish that a small set of mechanisms work on real +Armv8-R silicon and to measure what they cost. Read the numbers below as a +first measurement on one bench, not as characterisation. + +**What it does today.** Two ThreadX kernels run at EL1, each confined to its +own stage-2 window, time-sharing one logical core under a static major frame +taken from a manifest — on the Armv8-R AEM FVP and on the S32Z280-594EVB. +Partition A holds seven ticks of every ten and partition B three, and each +partition's clock advances by its own windows and by nothing else. + +Three results, and these are mechanisms rather than measurements — they do not +move when the numbers below do: + +* **A partition cannot reach its neighbour's memory**, even after granting + itself that memory in its own EL1 MPU. Stage 2 refuses, the offender is + stopped, and the other partition runs to the end of the frame with its + schedule untouched. Being attacked costs the neighbour nothing. +* **A window ends whether the partition agrees or not.** The hypervisor's + timer sits in GIC Group 0, so it arrives as an FIQ at EL2, and with + `HCR.FMO` set `PSTATE.F` is ignored at EL0 and EL1. One build proves it by + trying: a partition that masks IRQ and FIQ and spins for ever is preempted + exactly on schedule, and all its masking costs it is its own kernel's tick. +* **Each partition's clock is its own**, and advances in its own windows and + in nobody else's. + +Measured on the board over twenty frames and thirty-nine window boundaries, +with no missed deadline: + +| | counts on the core | its own ticks | +|---|---|---| +| partition A | 11,164,192 | 139 | +| partition B | 4,818,043 | 51 | + +A × 3 = 33,492,576 against B × 7 = 33,726,301 — within 0.7% of exact. A +partition able to see wall clock would be out by a factor of three, not by a +percentage. This one is a ratio of two readings of the system counter, whose +frequency was established three independent ways, so it does not depend on the +core clock, the caches or the optimisation level. + +**And one measurement, which will change.** A partition switch costs +**5,672 / 5,715 / 5,862 cycles** min / mean / max on the S32Z280, and the +guest's own EL1 MPU is 85% of it — on both a 32-region model and a 20-region +part. A switch is not expensive because the hypervisor does much; its +per-partition state is three register writes. It is expensive because a guest +has a lot of registers. + +Expect that figure to move, and to move for reasons already known. The EL2 +caches are off and the image is built `-Og`, which makes it an over-estimate. +But **ZoneX configures no clock tree yet**, so the core runs on this part's +power-up RC oscillator — 48 MHz, measured against the counter, and the +reference manual names it the default clock for the whole system at power-up. +At that clock the memory a switch touches is cheap in *core cycles*; raise the +core clock without raising the memory's and the same code costs more, not +fewer. **Configuring the clock tree is a later phase, and the number is +expected to change when it lands.** It is published now because a measurement +with its conditions stated is worth more than no measurement, and because the +next one will have something to be compared against. + +The image prints those conditions, the measured clock and a nanosecond +conversion above its own figures on every run, along with the two things +excluded from them: the guest console, because closing a partial line is a +polled-UART write and a product switch has no console in it, and boundaries +that had to wait out a stopped partition's window, because a switch plus a +wait is neither. + +`examples/` holds four experiments, each a separate image: the stage-2 probe, +one ThreadX guest confined by stage 2, one preempted by a timer of its own, +and two under a frame. Run them with `scripts/test_fvp.sh` on the model, or +`examples/s32z280_evb/tools/run_zx_probe.sh` on the board. + +**The suite includes builds that must fail, registered as such**, because a +check that has never been seen to fail is not evidence that it can: a +violation aimed at an address the payload *is* granted, an image told it needs +more MPU regions than exist, a manifest whose two windows overlap, and a +hypervisor whose own tick cannot be delivered. + +Along the way it settled several things about this architecture that the +Cortex-R52 TRM describes ambiguously, contradicts itself about, or states +somewhere nobody looks — on both a model and real silicon. The three most +consequential: * **`HPRENR` really is wider than 16 bits**, and a bit above 15 really does disable its region — proven functionally, not by reading the register back. * **`HPFAR` does not mean the same thing on the two targets.** The TRM describes it two ways in one section, and the FVP and the S32Z280 each implement a different one. ZoneX uses `HDFAR`. +* **Setting `HCR.FMO` moves a guest's `ICC_PMR` write to the virtual CPU + interface.** Every guest unmasks its own interrupts at start-up; from that + moment the write changes nothing about physical delivery, while the physical + priority mask — which resets to zero — is left closed. A partition that was + receiving its timer stops receiving anything, with no fault and no message. `docs/armv8r-el2-reference.md` carries all of it, with the measured values. From e5a5bc8603f0ef48d0be7536ac059122ab7fd03f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Thu, 3 Sep 2026 09:50:46 -0400 Subject: [PATCH 24/40] Proved neither partition can reach the other, nor disturb its clock The regression this phase exists to produce, on the model and on silicon. Fourteen isolation cases in one run, seven in each direction: the neighbour's data read and written, its code branched into, the ungranted granule immediately next door, the hypervisor's own manifest, the GIC distributor, and each partition's own window marked granule by granule and read back. Every case aims at an address of its own, so the fault it provoked is attributable to it; every row is judged from two independent sources, because a partition resumed past a faulting instruction cannot report having been denied and a case that was never attempted leaves the hypervisor nothing to look at. And the critical partition's window period, measured continuously while the untrusted one is steered through five behaviours in phases of one run. On silicon its period is 800,000 counts and it moves by nine while its neighbour idles, fifteen while it computes with its own interrupts masked, and sixty-nine while it violates its boundary a hundred and seventeen thousand times. Nothing a partition does through the schedule reaches its neighbour. What does reach it is the hypervisor's own console: a guest's output is one hypercall per character through a polled UART, and a window ending with a partial line has that line closed by the boundary handler, which delays the next partition's entry by 24,000 counts. That is measured, bounded by one line of output, and left to be fixed by buffering the console off the boundary path. A stage-2 violation still STOPS the partition. A build may ask at build time to be resumed past the access instead, so that one image can sweep the matrix rather than needing fourteen images and fourteen debug sessions; the mode is the image's to enable, its default is the value of zeroed memory, the trap vector takes the same route in every build, and halt is tested rather than assumed by a build of the same image without it. Four negative builds, each of which must fail and does, with its text archived: a region limit one granule too generous, the per-partition time freeze removed, a shared read-only granule its reader may write, and the manifest overlap rule. Three of them were WRONG first and passed, and each time it was the runner's --expect fail that noticed rather than anything in the image. The whole of core/ is now on the 100% line-and-branch coverage floor. The README's switch figure is requalified with it. Re-measuring the PREVIOUS commit's own code on the same bench reads three per cent higher than the number it was published with, and this commit's tree a further two -- none of which is attributable to any instruction added to the switch. With the caches off the cost depends on where the code sits in memory, and the oscillator the core runs on drifts. Four significant figures was over-precise; it is about six thousand cycles, plus or minus four per cent on one bench. Assisted-by: Claude Code (Opus 5) --- .github/workflows/zx_fvp.yml | 40 +- README.md | 46 +- core/CMakeLists.txt | 2 + core/inc/zx_fault_log.h | 194 ++ core/src/zx_fault_log.c | 390 +++ docs/decisions.md | 337 +++ examples/common/zx_frame_setup.c | 347 +++ examples/common/zx_frame_setup.h | 68 + examples/common/zx_guest_abi.h | 236 +- examples/common/zx_guest_launch.c | 117 + examples/common/zx_guest_launch.h | 79 + examples/common/zx_partitions.c | 44 + examples/common/zx_regression.c | 2246 +++++++++++++++++ examples/common/zx_two_partitions.c | 289 +-- examples/fvp_baser_aemv8r/CMakeLists.txt | 228 +- .../guest/link_partition.lds.in | 24 +- .../fvp_baser_aemv8r/zx_one_partition.lds | 14 +- .../fvp_baser_aemv8r/zx_two_partitions.lds | 16 +- examples/guest_common/zx_guest_app.c | 604 ++++- examples/guest_common/zx_guest_bsp.c | 138 +- examples/guest_common/zx_guest_bsp.h | 78 + examples/guest_common/zx_guest_head.S | 113 +- examples/s32z280_evb/CMakeLists.txt | 129 + .../s32z280_evb/guest/link_partition.lds.in | 20 +- examples/s32z280_evb/zx_one_partition.lds | 14 +- examples/s32z280_evb/zx_two_partitions.lds | 16 +- platform/cortex_r52/CMakeLists.txt | 1 + platform/cortex_r52/inc/zx_port.h | 237 ++ platform/cortex_r52/src/zx_context.c | 152 +- platform/cortex_r52/src/zx_fault_continue.c | 242 ++ platform/cortex_r52/src/zx_frame.c | 109 + platform/cortex_r52/src/zx_trap_handler.S | 28 + test/host/CMakeLists.txt | 1 + test/host/run.sh | 13 + test/host/test_zx_fault.c | 17 + test/host/test_zx_fault_log.c | 345 +++ 36 files changed, 6628 insertions(+), 346 deletions(-) create mode 100644 core/inc/zx_fault_log.h create mode 100644 core/src/zx_fault_log.c create mode 100644 examples/common/zx_frame_setup.c create mode 100644 examples/common/zx_frame_setup.h create mode 100644 examples/common/zx_regression.c create mode 100644 platform/cortex_r52/src/zx_fault_continue.c create mode 100644 test/host/test_zx_fault_log.c diff --git a/.github/workflows/zx_fvp.yml b/.github/workflows/zx_fvp.yml index 4717332..e27e89c 100644 --- a/.github/workflows/zx_fvp.yml +++ b/.github/workflows/zx_fvp.yml @@ -30,12 +30,13 @@ name: zx_fvp # repository-variable arrangement for the model, because Arm has no stable # unauthenticated download URL for it. # -# WHAT IT RUNS. Two families of image, and every one of them through CTest. +# WHAT IT RUNS. Five families of image, and every one of them through CTest. # # The stage-2 probes: zx_probe.elf, which must PASS, plus builds of the same # image that must FAIL -- a violation aimed at an address the payload is # granted, a region budget claimed larger than any implementation has, a window -# widened by one granule, an overlapping manifest, and an unwritten MAIR index. +# widened by one granule, an overlapping manifest, an unwritten MAIR index, and +# a shared read-only granule given write permission to its READER. # # The one-partition images: a REAL ThreadX kernel booting at EL1 inside a # stage-2 confined partition, one build that stops it at its stage-2 boundary, @@ -49,6 +50,25 @@ name: zx_fvp # interrupt left disabled in a frame the guest cannot reach, and one where the # hypervisor does not give the partition back the time it spent descheduled. # +# The two-partition images: two ThreadX kernels time-sharing one core under a +# static major frame, a HOSTILE build in which one of them masks its own +# interrupts and spins for ever and must be preempted anyway, a cross-partition +# build in which one reaches into the other's window and is stopped while its +# neighbour runs to the end of the frame untouched -- plus two that must FAIL. +# +# AND THE ISOLATION AND DETERMINISM REGRESSION, which is what this whole phase +# exists to produce: fourteen violation cases swept in one run, seven in each +# direction, each aimed at an address of its own; the critical partition's +# window period measured continuously while the untrusted one is steered +# through five behaviours; a build without the test-only continue mode that +# must halt on its first violation, which is how the SHIPPING fault policy is +# tested rather than assumed; and two builds that must FAIL -- a region limit +# one granule too generous, and the per-partition time freeze removed. +# +# It is the slowest lane here by a wide margin and that is deliberate: sixty +# major frames of simulated time with two kernels ticking through them, because +# a maximum period taken over ten periods is not a maximum. +# # The builds that must fail are registered with the runner's "--expect fail", # because a check that has never been seen to fail is not evidence that it can. # (CTest's WILL_FAIL is deliberately NOT used: it inverts the exit status and @@ -317,7 +337,7 @@ jobs: # it would mean half this suite quietly stopped running. The stage-2 # probes would still be green, and nothing would say that a real # kernel had not been booted at all. - if ! echo "$images" | grep -qE '^zx_(one_partition|preemptive)\.elf$'; then + if ! echo "$images" | grep -qE '^zx_(one_partition|preemptive|regression)\.elf$'; then echo "::error::The ThreadX guest images were not built. ZX_THREADX_SOURCE_DIR did not reach CMake, so no real kernel was booted and half this suite did not run." { echo "### ZoneX FVP execution: NO GUEST IMAGES" @@ -329,12 +349,20 @@ jobs: exit 1 fi - # BOTH guest families, named separately. The check above catches a - # missing ThreadX checkout, which removes them together; this one + # EVERY guest family, named separately. The check above catches a + # missing ThreadX checkout, which removes them all together; this one # catches an image being dropped from the CMake graph on its own, # which would leave the suite green while a whole claim stopped being # tested. - for required in zx_one_partition.elf zx_preemptive.elf; do + # + # THE REGRESSION IS ON THIS LIST BECAUSE IT IS THE DELIVERABLE. It is + # also the image most likely to be dropped by accident: it is the + # slowest lane by a wide margin, so it is the one somebody under time + # pressure would reach for -- and its absence would leave every + # isolation and determinism claim in this repository untested while + # twenty other lanes stayed green. + for required in zx_one_partition.elf zx_preemptive.elf \ + zx_two_partitions.elf zx_regression.elf; do if ! echo "$images" | grep -qx "$required"; then echo "::error::$required is not in the CMake graph, so the claim it carries is no longer being tested." { diff --git a/README.md b/README.md index 7c88f8c..639dc15 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,23 @@ taken from a manifest — on the Armv8-R AEM FVP and on the S32Z280-594EVB. Partition A holds seven ticks of every ten and partition B three, and each partition's clock advances by its own windows and by nothing else. +**And that is now asserted rather than shown.** A regression sweeps fourteen +isolation cases in one run — seven in each direction, each aimed at an address +of its own — and measures the critical partition's window period continuously +while the untrusted one is steered through five behaviours: idle, computing, +computing with its own interrupts masked, storming the console, and violating +its boundary on every iteration of its own loop. On the board A's period is +800,000 counter counts and moves by **nine** counts while its neighbour idles, +**fifteen** while it computes with interrupts masked, and **sixty-nine** while +it commits a hundred and seventeen thousand boundary violations. + +Nothing a partition does *through the schedule* reaches its neighbour. One +thing does, and it is the hypervisor's own doing: a guest's console is one +hypercall per character through a polled UART, and a window that ends with a +partial line has that line closed by the boundary handler — which delays the +next partition's entry by 24,000 counts. It is measured, bounded by one line +of output, and the fix is to buffer the console off that path. + Three results, and these are mechanisms rather than measurements — they do not move when the numbers below do: @@ -64,12 +81,18 @@ percentage. This one is a ratio of two readings of the system counter, whose frequency was established three independent ways, so it does not depend on the core clock, the caches or the optimisation level. -**And one measurement, which will change.** A partition switch costs -**5,672 / 5,715 / 5,862 cycles** min / mean / max on the S32Z280, and the -guest's own EL1 MPU is 85% of it — on both a 32-region model and a 20-region -part. A switch is not expensive because the hypervisor does much; its -per-partition state is three register writes. It is expensive because a guest -has a lot of registers. +**And one measurement, which will change.** A partition switch costs **about +6,000 cycles** on the S32Z280 — 6,030 / 6,078 / 6,370 min / mean / max on the +most recent run — and the guest's own EL1 MPU is 85% of it, on both a +32-region model and a 20-region part. A switch is not expensive because the +hypervisor does much; its per-partition state is three register writes. It is +expensive because a guest has a lot of registers. + +**Read that as ±4%, not as four significant figures.** The same code +re-measured a day later, on the same bench, reads 3% higher — and the two +reasons below are why: with no caches the cost depends on where the code sits +in memory, so an unrelated change elsewhere in the hypervisor moves it, and +the oscillator the core runs on drifts with temperature. Expect that figure to move, and to move for reasons already known. The EL2 caches are off and the image is built `-Og`, which makes it an over-estimate. @@ -90,16 +113,19 @@ polled-UART write and a product switch has no console in it, and boundaries that had to wait out a stopped partition's window, because a switch plus a wait is neither. -`examples/` holds four experiments, each a separate image: the stage-2 probe, +`examples/` holds five experiments, each a separate image: the stage-2 probe, one ThreadX guest confined by stage 2, one preempted by a timer of its own, -and two under a frame. Run them with `scripts/test_fvp.sh` on the model, or +two under a frame, and the isolation and determinism regression. Run them with +`scripts/test_fvp.sh` on the model, or `examples/s32z280_evb/tools/run_zx_probe.sh` on the board. **The suite includes builds that must fail, registered as such**, because a check that has never been seen to fail is not evidence that it can: a violation aimed at an address the payload *is* granted, an image told it needs -more MPU regions than exist, a manifest whose two windows overlap, and a -hypervisor whose own tick cannot be delivered. +more MPU regions than exist, a manifest whose two windows overlap, a +hypervisor whose own tick cannot be delivered, a region limit one granule too +generous, the per-partition time freeze removed, and a shared read-only +granule its reader may write. Along the way it settled several things about this architecture that the Cortex-R52 TRM describes ambiguously, contradicts itself about, or states diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index 2b21493..48f5265 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -22,6 +22,7 @@ target_sources(zonex_core PRIVATE ${CMAKE_CURRENT_LIST_DIR}/src/zx_fault.c + ${CMAKE_CURRENT_LIST_DIR}/src/zx_fault_log.c ${CMAKE_CURRENT_LIST_DIR}/src/zx_guest_console.c ${CMAKE_CURRENT_LIST_DIR}/src/zx_manifest_verify.c ${CMAKE_CURRENT_LIST_DIR}/src/zx_mm_setup.c @@ -40,6 +41,7 @@ target_sources(zonex_core PUBLIC FILES ${CMAKE_CURRENT_LIST_DIR}/../common/inc/zx_api.h ${CMAKE_CURRENT_LIST_DIR}/../common/inc/zx_console.h ${CMAKE_CURRENT_LIST_DIR}/inc/zx_fault.h + ${CMAKE_CURRENT_LIST_DIR}/inc/zx_fault_log.h ${CMAKE_CURRENT_LIST_DIR}/inc/zx_guest_console.h ${CMAKE_CURRENT_LIST_DIR}/inc/zx_manifest.h ${CMAKE_CURRENT_LIST_DIR}/inc/zx_mm.h diff --git a/core/inc/zx_fault_log.h b/core/inc/zx_fault_log.h new file mode 100644 index 0000000..b535cd8 --- /dev/null +++ b/core/inc/zx_fault_log.h @@ -0,0 +1,194 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_fault_log.h PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* MANY faults, kept, attributed and searchable -- as against the */ +/* single zx_fault_record_t, which holds the LAST capture. */ +/* */ +/* The single record is right for a hypervisor whose policy is to stop */ +/* on the first violation, and that is still the shipping policy. It */ +/* is not enough for a regression that has to show a whole matrix of */ +/* violations detected, because each one overwrites the evidence of */ +/* the last -- so a sweep of twelve cases could only ever report the */ +/* twelfth. */ +/* */ +/* WHY IT KEEPS THE FIRST ENTRIES AND NOT THE LAST */ +/* */ +/* A ring buffer is the reflex and it is wrong here. A determinism */ +/* run has a phase in which a partition faults on every iteration of */ +/* its own loop -- thousands of times in one window -- and a ring */ +/* would let that phase evict every entry the isolation matrix had */ +/* carefully recorded before it. The evidence would be destroyed by */ +/* the part of the run that needs it least: repeated faults are */ +/* interesting only as a COUNT, and their count is kept separately. */ +/* */ +/* So the array fills once and then stops accepting entries, while */ +/* the totals keep rising. A reader gets "these are the first N */ +/* distinct violations, and there were M in total", which is what both */ +/* halves of the regression actually claim. */ +/* */ +/* WHY ATTRIBUTION IS BY PARTITION AND ADDRESS */ +/* */ +/* Every case in the matrix aims at a DIFFERENT address, on purpose, */ +/* so the address is the case's name and the partition is the */ +/* direction. A regression that counted faults without attributing */ +/* them would pass on a run where one case faulted twelve times and */ +/* eleven never ran -- which is exactly the failure mode a sweep */ +/* invites, because a skipped case and a denied case both leave a */ +/* guest that reported nothing. */ +/* */ +/* Nothing here touches a register. The port captures the words and */ +/* hands them over, so the host suite exercises the whole of this file */ +/* without a model or a board. */ +/* */ +/**************************************************************************/ + +#ifndef ZX_FAULT_LOG_H +#define ZX_FAULT_LOG_H + +#include "zx_fault.h" +#include "zx_manifest.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* HOW MANY ENTRIES. The isolation matrix is six violating cases in each + direction, so twelve is the whole of what a two-partition sweep produces + and thirty-two leaves room for a third partition's sweep plus a margin + that makes a surprise VISIBLE rather than silently dropped. It is a + static array in a hypervisor with no allocator, so the number is a + deliberate budget: 32 entries at 28 bytes is 896 bytes of .bss. */ + +#define ZX_FAULT_LOG_ENTRIES 32U + +/* How a partition was let go after its fault was recorded. A log that did + not carry this could not distinguish the shipping policy from the + test-only one, and the whole point of recording a swept matrix is that + the sweep only happens under the second. */ + +#define ZX_FAULT_RESUMED_STOPPED 0U /* halt: the partition was stopped */ +#define ZX_FAULT_RESUMED_SKIPPED 1U /* continue: past the instruction */ +#define ZX_FAULT_RESUMED_REDIRECTED 2U /* continue: to a guest-named PC */ + +typedef struct zx_fault_log_entry_struct +{ + uint32_t zx_log_hsr; /* HSR, so EC and DFSC are recoverable */ + uint32_t zx_log_hdfar; /* the data-abort virtual address */ + uint32_t zx_log_hifar; /* the prefetch-abort virtual address */ + uint32_t zx_log_elr; /* where the partition was */ + uint32_t zx_log_spsr; /* and in what mode, with what masks */ + UINT zx_log_partition; /* whose window it happened in */ + uint32_t zx_log_resumed; /* ZX_FAULT_RESUMED_* */ +} ZX_FAULT_LOG_ENTRY; + +typedef struct zx_fault_log_struct +{ + ZX_FAULT_LOG_ENTRY zx_log_entries[ZX_FAULT_LOG_ENTRIES]; + + /* KEPT and TOTAL are different numbers and both are printed. A run + where they differ is a run whose array filled, which is information + rather than an error -- but a report showing only one of them would + let a reader believe the log was complete. */ + + uint32_t zx_log_kept; + uint32_t zx_log_total; + uint32_t zx_log_dropped; + + /* Per partition, because "which partition faulted, how often" is the + determinism claim's own number: a phase in which one partition + faults on every iteration has to be shown to have happened at all + before "and its neighbour's period did not move" means anything. */ + + uint32_t zx_log_partition_total[ZX_MAX_PARTITIONS]; +} ZX_FAULT_LOG; + +/**************************************************************************/ +/* Recording */ +/**************************************************************************/ + +void zx_fault_log_reset(ZX_FAULT_LOG *log_ptr); + +/* Add one capture. `partition` is the index of the partition that was + running; it is recorded even when it is out of range, because a fault + attributed to nobody is a fact worth keeping rather than one to discard. + Only the per-partition total is guarded, since that one indexes. */ + +void zx_fault_log_add(ZX_FAULT_LOG *log_ptr, UINT partition, + const zx_fault_record_t *record_ptr, + uint32_t resumed); + +/**************************************************************************/ +/* Reading back */ +/**************************************************************************/ + +/* The virtual address an entry faulted on: HDFAR for a data abort, HIFAR + for a prefetch abort. A single accessor because a caller asking "did + this address fault" must not have to know which kind of access it was -- + and because reading HDFAR after a prefetch abort returns whatever the + last data abort left there, which is stale rather than wrong and reads + like a plausible answer. */ + +ZX_NODISCARD uint32_t zx_fault_log_entry_address( + const ZX_FAULT_LOG_ENTRY *entry_ptr); + +/* The first entry in which `partition` faulted on `address`, or a null + pointer. This is how one row of the isolation matrix is judged: the case + aimed at that address, in that direction, and the fault that came back + names both. */ + +ZX_NODISCARD const ZX_FAULT_LOG_ENTRY *zx_fault_log_find( + const ZX_FAULT_LOG *log_ptr, UINT partition, uint32_t address); + +/* How many KEPT entries match, which is not the same question. A case that + faulted twice is a case whose partition was resumed onto the same + instruction, and that is a defect in the continue path rather than in + isolation. */ + +ZX_NODISCARD uint32_t zx_fault_log_count_at(const ZX_FAULT_LOG *log_ptr, + UINT partition, + uint32_t address); + +ZX_NODISCARD uint32_t zx_fault_log_partition_total( + const ZX_FAULT_LOG *log_ptr, UINT partition); + +/* True when every kept entry is a GUEST violation -- EC 0x24 or 0x20 -- and + none is ZoneX faulting on its own access. A matrix that had provoked one + hypervisor fault among its twelve guest ones would otherwise report + twelve violations detected and hide the one that mattered. */ + +ZX_NODISCARD uint32_t zx_fault_log_all_guest_violations( + const ZX_FAULT_LOG *log_ptr); + +/**************************************************************************/ +/* Reporting */ +/**************************************************************************/ + +void zx_fault_log_report(const ZX_FAULT_LOG *log_ptr); + +ZX_NODISCARD const char *zx_fault_resumed_name(uint32_t resumed); + +#ifdef __cplusplus +} +#endif + +#endif /* ZX_FAULT_LOG_H */ diff --git a/core/src/zx_fault_log.c b/core/src/zx_fault_log.c new file mode 100644 index 0000000..fb54257 --- /dev/null +++ b/core/src/zx_fault_log.c @@ -0,0 +1,390 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_fault_log.c PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Many faults, kept and attributed. The rationale for keeping the */ +/* FIRST entries rather than the last, and for attributing by */ +/* partition and address, is in core/inc/zx_fault_log.h. */ +/* */ +/* Arithmetic and storage only, so the host suite covers all of it. */ +/* */ +/**************************************************************************/ + +#include "zx_fault_log.h" +#include "zx_console.h" + +/**************************************************************************/ +/* zx_fault_log_reset */ +/**************************************************************************/ + +void zx_fault_log_reset(ZX_FAULT_LOG *log_ptr) +{ + uint32_t index; + + if (log_ptr == (ZX_FAULT_LOG *)0) + { + return; + } + + for (index = 0U; index < ZX_FAULT_LOG_ENTRIES; index++) + { + log_ptr->zx_log_entries[index].zx_log_hsr = 0U; + log_ptr->zx_log_entries[index].zx_log_hdfar = 0U; + log_ptr->zx_log_entries[index].zx_log_hifar = 0U; + log_ptr->zx_log_entries[index].zx_log_elr = 0U; + log_ptr->zx_log_entries[index].zx_log_spsr = 0U; + log_ptr->zx_log_entries[index].zx_log_partition = ZX_MANIFEST_NO_INDEX; + log_ptr->zx_log_entries[index].zx_log_resumed = + ZX_FAULT_RESUMED_STOPPED; + } + + log_ptr->zx_log_kept = 0U; + log_ptr->zx_log_total = 0U; + log_ptr->zx_log_dropped = 0U; + + for (index = 0U; index < ZX_MAX_PARTITIONS; index++) + { + log_ptr->zx_log_partition_total[index] = 0U; + } +} + + +/**************************************************************************/ +/* zx_fault_log_add */ +/* */ +/* THE TOTALS ARE UPDATED WHETHER OR NOT THE ENTRY IS KEPT, and that is */ +/* the whole reason the two numbers are separate. A run whose array */ +/* filled must still be able to say how many faults there were, because */ +/* "this partition faulted eleven thousand times and its neighbour's */ +/* period did not move" is a claim about the count and not about the */ +/* syndromes. */ +/**************************************************************************/ + +void zx_fault_log_add(ZX_FAULT_LOG *log_ptr, UINT partition, + const zx_fault_record_t *record_ptr, + uint32_t resumed) +{ + if ((log_ptr == (ZX_FAULT_LOG *)0) + || (record_ptr == (const zx_fault_record_t *)0)) + { + return; + } + + log_ptr->zx_log_total++; + + /* Guarded because it indexes. An unattributable fault still counts in + the total above -- discarding it would make a fault the frame could + not place disappear, which is the opposite of what a log is for. */ + + if (partition < ZX_MAX_PARTITIONS) + { + log_ptr->zx_log_partition_total[partition]++; + } + + if (log_ptr->zx_log_kept >= ZX_FAULT_LOG_ENTRIES) + { + log_ptr->zx_log_dropped++; + + return; + } + + { + ZX_FAULT_LOG_ENTRY *entry_ptr = + &log_ptr->zx_log_entries[log_ptr->zx_log_kept]; + + entry_ptr->zx_log_hsr = record_ptr->zx_fault_hsr; + entry_ptr->zx_log_hdfar = record_ptr->zx_fault_hdfar; + entry_ptr->zx_log_hifar = record_ptr->zx_fault_hifar; + entry_ptr->zx_log_elr = record_ptr->zx_fault_elr; + entry_ptr->zx_log_spsr = record_ptr->zx_fault_spsr; + entry_ptr->zx_log_partition = partition; + entry_ptr->zx_log_resumed = resumed; + } + + log_ptr->zx_log_kept++; +} + + +/**************************************************************************/ +/* zx_fault_log_entry_address */ +/* */ +/* HIFAR for a prefetch abort, HDFAR for everything else. Reading HDFAR */ +/* after a prefetch abort returns whatever the last DATA abort left in */ +/* it, which is stale rather than wrong -- and stale reads exactly like */ +/* a plausible answer, which is what makes it worth one function. */ +/**************************************************************************/ + +uint32_t zx_fault_log_entry_address(const ZX_FAULT_LOG_ENTRY *entry_ptr) +{ + uint32_t address = 0U; + + if (entry_ptr != (const ZX_FAULT_LOG_ENTRY *)0) + { + uint32_t ec = zx_fault_ec(entry_ptr->zx_log_hsr); + + if ((ec == ZX_EC_PABT_ROUTED) || (ec == ZX_EC_PABT_FROM_HYP)) + { + address = entry_ptr->zx_log_hifar; + } + else + { + address = entry_ptr->zx_log_hdfar; + } + } + + return address; +} + + +/**************************************************************************/ +/* zx_fault_log_find */ +/**************************************************************************/ + +const ZX_FAULT_LOG_ENTRY *zx_fault_log_find(const ZX_FAULT_LOG *log_ptr, + UINT partition, uint32_t address) +{ + const ZX_FAULT_LOG_ENTRY *found = (const ZX_FAULT_LOG_ENTRY *)0; + uint32_t index; + + if (log_ptr == (const ZX_FAULT_LOG *)0) + { + return found; + } + + for (index = 0U; (index < log_ptr->zx_log_kept) && + (found == (const ZX_FAULT_LOG_ENTRY *)0); index++) + { + const ZX_FAULT_LOG_ENTRY *entry_ptr = &log_ptr->zx_log_entries[index]; + + if ((entry_ptr->zx_log_partition == partition) + && (zx_fault_log_entry_address(entry_ptr) == address)) + { + found = entry_ptr; + } + } + + return found; +} + + +/**************************************************************************/ +/* zx_fault_log_count_at */ +/**************************************************************************/ + +uint32_t zx_fault_log_count_at(const ZX_FAULT_LOG *log_ptr, UINT partition, + uint32_t address) +{ + uint32_t matches = 0U; + uint32_t index; + + if (log_ptr == (const ZX_FAULT_LOG *)0) + { + return matches; + } + + for (index = 0U; index < log_ptr->zx_log_kept; index++) + { + const ZX_FAULT_LOG_ENTRY *entry_ptr = &log_ptr->zx_log_entries[index]; + + if ((entry_ptr->zx_log_partition == partition) + && (zx_fault_log_entry_address(entry_ptr) == address)) + { + matches++; + } + } + + return matches; +} + + +/**************************************************************************/ +/* zx_fault_log_partition_total */ +/**************************************************************************/ + +uint32_t zx_fault_log_partition_total(const ZX_FAULT_LOG *log_ptr, + UINT partition) +{ + uint32_t total = 0U; + + if ((log_ptr != (const ZX_FAULT_LOG *)0) + && (partition < ZX_MAX_PARTITIONS)) + { + total = log_ptr->zx_log_partition_total[partition]; + } + + return total; +} + + +/**************************************************************************/ +/* zx_fault_log_all_guest_violations */ +/* */ +/* An EMPTY log answers NO. "Every entry is a guest violation" is */ +/* vacuously true of no entries, and a check written to accept that would */ +/* pass on a run in which the whole matrix was skipped -- which is the */ +/* single most likely way a sweep goes wrong. */ +/**************************************************************************/ + +uint32_t zx_fault_log_all_guest_violations(const ZX_FAULT_LOG *log_ptr) +{ + uint32_t all = 1U; + uint32_t index; + + if ((log_ptr == (const ZX_FAULT_LOG *)0) || (log_ptr->zx_log_kept == 0U)) + { + return 0U; + } + + for (index = 0U; index < log_ptr->zx_log_kept; index++) + { + if (zx_fault_classify(log_ptr->zx_log_entries[index].zx_log_hsr) + != ZX_FAULT_GUEST_VIOLATION) + { + all = 0U; + } + } + + return all; +} + + +/**************************************************************************/ +/* zx_fault_resumed_name */ +/**************************************************************************/ + +const char *zx_fault_resumed_name(uint32_t resumed) +{ + const char *name; + + switch (resumed) + { + case ZX_FAULT_RESUMED_STOPPED: + name = "the partition was STOPPED (the shipping policy)"; + break; + + case ZX_FAULT_RESUMED_SKIPPED: + name = "resumed past the faulting instruction (test-only)"; + break; + + case ZX_FAULT_RESUMED_REDIRECTED: + name = "resumed at the address the guest named (test-only)"; + break; + + default: + name = "resumed in a way this build does not have a name for"; + break; + } + + return name; +} + + +/**************************************************************************/ +/* zx_fault_log_report */ +/* */ +/* One line per entry, not a full zx_fault_report each: twelve of those */ +/* is four hundred lines of console through a polled UART, and the whole */ +/* value of the log is that a reader can see the MATRIX rather than one */ +/* fault at a time. The last capture is still reported in full */ +/* elsewhere, so no detail is lost -- only repeated. */ +/**************************************************************************/ + +void zx_fault_log_report(const ZX_FAULT_LOG *log_ptr) +{ + uint32_t index; + + if (log_ptr == (const ZX_FAULT_LOG *)0) + { + zx_console_puts("[FAULTLOG] there is no fault log to report\n"); + + return; + } + + zx_console_puts("\n--- every violation this run provoked, in order ---\n"); + zx_console_puts(" kept "); + zx_console_putdec(log_ptr->zx_log_kept); + zx_console_puts(" of "); + zx_console_putdec(log_ptr->zx_log_total); + zx_console_puts(" total, "); + zx_console_putdec(log_ptr->zx_log_dropped); + zx_console_puts(" dropped once the array was full.\n"); + zx_console_puts(" The FIRST entries are kept, never the last: a phase\n" + " that faults on every iteration would otherwise evict\n" + " the matrix it was supposed to run after.\n\n"); + + for (index = 0U; index < log_ptr->zx_log_kept; index++) + { + const ZX_FAULT_LOG_ENTRY *entry_ptr = &log_ptr->zx_log_entries[index]; + uint32_t ec = + zx_fault_ec(entry_ptr->zx_log_hsr); + + zx_console_puts(" ["); + zx_console_putdec(index); + zx_console_puts("] partition "); + zx_console_putdec(entry_ptr->zx_log_partition); + zx_console_puts(" at "); + zx_console_puthex(zx_fault_log_entry_address(entry_ptr)); + zx_console_puts(" EC "); + zx_console_puthex(ec); + zx_console_puts(" = "); + zx_console_puts(zx_fault_ec_name(ec)); + + if ((ec == ZX_EC_DABT_ROUTED) || (ec == ZX_EC_DABT_FROM_HYP)) + { + zx_console_puts("\n DFSC "); + zx_console_puthex(zx_fault_dabt_dfsc(entry_ptr->zx_log_hsr)); + zx_console_puts( + (zx_fault_dabt_dfsc(entry_ptr->zx_log_hsr) + == ZX_DFSC_MPU_PERMISSION) + ? " = a region COVERED it and its AP refused" + : " = no enabled region covered it"); + zx_console_puts( + zx_fault_dabt_is_write(entry_ptr->zx_log_hsr) != 0U + ? ", on a WRITE" : ", on a READ"); + } + else + { + /* A prefetch abort has no DFSC to decode, and the address + above already came from HIFAR. */ + } + + zx_console_puts("\n PC "); + zx_console_puthex(entry_ptr->zx_log_elr); + zx_console_puts(" "); + zx_console_puts(zx_fault_resumed_name(entry_ptr->zx_log_resumed)); + zx_console_puts("\n"); + } + + zx_console_puts("\n by partition:\n"); + + for (index = 0U; index < ZX_MAX_PARTITIONS; index++) + { + if (log_ptr->zx_log_partition_total[index] != 0U) + { + zx_console_puts(" partition "); + zx_console_putdec(index); + zx_console_puts(" faulted "); + zx_console_putdec(log_ptr->zx_log_partition_total[index]); + zx_console_puts(" times\n"); + } + } +} diff --git a/docs/decisions.md b/docs/decisions.md index 8156f73..49b4382 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1304,6 +1304,20 @@ which are equal in any run where both partitions live to the end. partition, which faults again on the same instruction. Neither order loses an event, because neither event is edge-triggered and neither is acknowledged until it is handled. + + **Amended: it is now exercised, incidentally and in bulk, and it is still + not a targeted test.** The isolation regression's last phase has one + partition violate its boundary on every iteration of its own loop, under + the test-only continue mode of D26 — on the model, 117,561 violations + across ten major frames, against twenty window boundaries in the same + span. The two events are therefore in flight together thousands of times + in every run, and no run has lost one: no missed deadline, no unexplained + FIQ, and the neighbour's period unmoved. That is real evidence where there + was none, and it is worth being precise about what kind: it is not a test + of the race, because nothing arranges the coincidence and nothing would + report having hit it. It is a very large number of chances for the + structural argument above to be wrong, taken every time the regression + runs. * **The FPU is DENIED to a time-partitioned system rather than saved.** D23 recorded that opening `HCPTR.TCP10/TCP11` was exactly right with one partition and a shared register bank with two, since nothing saves `FPEXC`, @@ -1313,3 +1327,326 @@ which are equal in any run where both partitions live to the end. Saving the bank is a real later-phase option and needs the register file's width read from `MVFR0` rather than assumed; sharing it silently is not an option at all. + +--- + +## D26 — Halt on fault, and a test-only continue mode · **settled, and run on both targets** + +**A stage-2 violation STOPS the partition. That is the shipping policy and it +is unchanged. A build may additionally ask, at build time, for the partition +to be RECORDED and RESUMED past the access instead, so that one image can +sweep a whole matrix of violations rather than needing one image and one run +per case.** + +### Why the mode exists + +Phase 0's specified behaviour is fault, log, halt, and it is right: a +hypervisor that quietly re-entered a partition which had just violated its +boundary would be doing the easy half of supervised restart. + +But the isolation regression is **fourteen cases** — seven in each direction — +and under halt-on-fault each case ends its partition's excursion. Fourteen +cases would then need fourteen images and fourteen runs. On the S32Z280 that +is fourteen debug-probe sessions, most of a working day; on the functional +model it is minutes of simulation each, on a lane that already dominates the +suite's wall clock. The alternative — one image per case with a shared +manifest — was weighed and rejected on exactly that arithmetic. + +### The four things that keep it honest + +* **The policy is a build-time decision and cannot be reached by a manifest, + a mailbox word, or a partition.** It arrives through + `zx_el2_fault_continue_configure`, which an image calls with a value taken + from its own compile-time definition. +* **The default is HALT because the variable holding the policy lives in + `.bss`.** An image that configures nothing gets the value of zeroed memory. + A default that has to be written down somewhere is a default somebody can + write down wrongly. +* **The shipping path is the same code.** There is no conditional in the trap + vector: every build calls the same function on every guest violation and + that function decides. A continue mode reached through a *different* route + would leave the shipping route the less exercised of the two. +* **Halt is tested rather than assumed.** `zx_regression_halt.elf` is the same + image without the definition, asks each partition for one case, and must + report that both were stopped — on both targets. + +And one more build exists for a reason that has nothing to do with the fault +policy and everything to do with the same rule: `zx_regression_no_tick.elf` +leaves the hypervisor's own timer PPI disabled at the GIC, so no window can +end and the regression must REFUSE to start the frame. The refusal itself was +dead code in every build until then. The demonstration image beside it had +always had the equivalent build, and the two now share the bring-up routine — +so the shared half was covered and the regression's own three lines of refusal +were not. A branch nothing has ever taken is not evidence that it works, which +is the same sentence that justifies every other negative build here. + +⚠ **It was an `#ifdef` in the hypervisor first, and it could not work.** ZoneX +is a library, built once per build tree and linked into every image in it, so +a compile definition on one image's target never reaches the hypervisor's own +translation units. The option was set on the regression target, the library +was built without it, and the run reported every partition STOPPED while its +own banner claimed continue mode. The definition now lives where every other +build variant in this suite has always lived — on the image — and what the run +PRINTS about its policy comes from the translation unit that acts on it rather +than from a macro the image tests itself. + +### A prefetch abort cannot be resumed the way a data abort can + +"Step over the faulting instruction" needs the instruction, and for a prefetch +abort there is none: the **fetch** is what failed. Resuming at `ELR + 4` would +resume inside the neighbour's window and fault again on the next word, and +again, for the rest of the window — a partition walking through its +neighbour's code taking one exception per instruction, which is a livelock +wearing the costume of a test. + +So the **guest** publishes where it wants to be put back, before it branches, +and the hypervisor asks the image for that address through a hook rather than +reading a mailbox itself: a mailbox layout is an example's business. An address +the hypervisor was not given means the partition is stopped, which is the +shipping behaviour and the right answer for a path with no answer. + +### What the continue mode revealed about what a guest can report + +Two findings, and the second was a defect in the regression's own first +version. + +**A guest cannot report having been denied.** It is resumed past the faulting +instruction, so the store that would have recorded "this case survived" +executes anyway, one instruction later, on a case that was refused. Every "it +faulted" judgement therefore belongs to EL2 and comes out of the fault log, +matched by ADDRESS — which is why every case in the matrix aims at an address +of its own. + +**And it cannot report what it failed to READ either.** A denied load never +writes its destination register — but the instruction the partition is resumed +at is the STORE of that register, so the mailbox is written with whatever the +compiler happened to leave there. On a real run that was the number one, and +the check "the read returned the poison the guest wrote first" duly failed. +The hypervisor now reads what is really at the address — it can, because +stage-2 AP cannot deny EL2 — and compares. That is the claim anyway (*the +neighbour's data did not reach the partition*) rather than a proxy for it. + +The converse holds and is why both halves are recorded: **EL2 cannot report +what was attempted.** A case the guest refused to run and a case stage 2 denied +both leave the hypervisor with no fault to look at. A row of the matrix +therefore passes only when the guest says it tried, the log says it was +refused, and the syndrome is the right *kind* of refusal — three conditions +from two independent sources. + +--- + +## D27 — Where the shared read-only granule is demonstrated · **settled** + +**The stage-2 probe image declares one and demonstrates both halves of it. The +frame images — the two-partition demonstration and the isolation regression — +declare NONE, deliberately.** + +A shared granule is a **declared exception** to isolation: one range readable +by both partitions and writable by exactly one. Both halves are worth +demonstrating, and the interesting one is the second — a reader that cannot +write it fails with `DFSC 0x0C`, a region PERMISSION failure, and not with +`DFSC 0x04`, a region miss. Those are two different claims and only one of +them means "read-only". + +### Why it stays in the probe image + +Putting one in the frame images as well would cost their central claim its +simplicity for no new evidence. Every temporal number those runs produce is +currently a number about two partitions that share **nothing but a core**; +with a shared granule it would be a number about two partitions that share a +core and sixty-four bytes, and a reader asking "could the shared line be how B +reached A" would be entitled to an answer the run could not give. + +So the exception is demonstrated where it is cheap and asserted where it is +sharp, and it is not smuggled into the image whose job is to say the two +partitions are disjoint. + +### And its read-only half now has a negative build + +`zx_probe_shared_rw.elf` gives the READER's copy of the shared range write +permission, so the publish must survive and the run must report FAILED. + +⚠ **It has to break the manifest AFTER the validator has passed it,** and that +is the interesting part rather than a workaround. There are two independent +defences. `zx_manifest_shared_check` requires the non-publisher's copy of a +shared range to be read-only, so a manifest declaring it writable is refused +before a single region is programmed — which was the first version of this +build, and it reported the validator refusing the manifest rather than the +access being permitted. A real result, and a test of a different check. Reaching +the second defence means handing the region programmer a descriptor the +validator never saw; only the permission field changes, so nothing else in the +run moves and the one thing that can catch it is the access itself. + +--- + +## D28 — What the determinism regression measures, and against what bound · **settled, and run on both targets** + +**The critical partition's WINDOW PERIOD, observed by the hypervisor, together +with its OWN VIRTUAL COUNTER, reported by the guest — both measured in phases +of ONE run while the untrusted partition is steered through five behaviours. +Absolute figures are reported; a RELATIVE bound is asserted; the clock is +stated beside every number.** + +### One run in phases, not one run per behaviour + +The obvious shape was six images: one per row of the determinism table. It is +the weaker one. Six images is six boots, six epochs and six sets of switch +measurements, so comparing the critical partition's period across them +compares **runs** — and a difference between two runs has a dozen explanations +that a difference between two phases of one run does not. It is also a sixth of +the model time, on the slowest lane in the suite. + +What a partition is doing is a mailbox word the guest re-reads on every +iteration of its endless phase, so the hypervisor can change it at a frame +boundary. Re-reading costs one load from a granule the partition already owns: +no hypercall, no kernel call, and nothing the masked phase could not do with +its interrupts off — which is what lets a partition be steered *out* of a phase +it entered by disabling interrupts. + +The first phase is **discarded**: the untrusted partition is still booting its +kernel and reaching its verdict during it, so a baseline that included it would +be a baseline of a partition doing real work and every later phase would look +artificially good. It is still printed, so that what was discarded is visible +rather than taken on trust. This is the same discipline the switch measurement +already uses. + +### The bound: report absolute, assert relative, state the clock + +The assertion is **one eighth of one window**, and the derivation is printed +beside every figure it governs. + +The worst partition switch measured on the S32Z280-594EVB by the two-partition +image is 5,862 core cycles at a measured 48.05 MHz — about 122 µs, which at +that board's 8 MHz system counter is roughly 976 counts, or 1.2% of one +window. The bound is **ten times that**, rounded to a power of two. + +* **Relative and not absolute**, which was an open question worth deciding. An + absolute bound in counts is what a safety customer wants and it does not + survive two targets whose counters differ by a factor of twelve — the model's + runs at 100 MHz and the board's at 8. A fraction of a *window* is the same + claim on both, and it is also the claim that matters: what a schedule has to + survive is jitter relative to the window it must fit in. +* **Ten times the switch and not one.** A bound at the measured spread would + fail the first time somebody added a register to the switch, which is a + regression reporting a change rather than a defect. What it has to catch is + COUPLING, and coupling is not a percentage: a partition that lost a window to + its neighbour is out by a whole window, eight times this bound. + +### Two halves, and they fail separately + +⚠ **A serviced tick count is insensitive to the time freeze, and finding that +out cost a negative build that passed.** The guest-side half was first written +as "the critical partition saw the same number of its own ticks in every +phase". The virtual timer asserts a LEVEL, so several expiries while a +partition is descheduled coalesce into one interrupt, and the number a guest +services is bounded by the core time it was given whether or not its clock was +frozen. The build with the freeze removed had a partition whose own clock ran +42% ahead of the time it had been given — and an identical tick count in every +phase. + +The assertion is now on the partition's own **counter** against the core time +the hypervisor knows it was given, which is the temporal claim in its sharpest +form: *its clock advanced by the counts it spent on the core and by none of the +counts it did not.* The tick count is still printed, because it is the number a +reader recognises. + +The two halves are separate because they fail separately, and the negative +build proves it: with the freeze removed the window period is untouched — the +schedule is what the period measures — while the clock is out by three windows +per frame. A regression that measured only the period would have stayed green +while every partition's clock lied. + +⚠ **And `zx_launch_freeze_time` was dead on the frame path.** That field +governs the single-excursion path `zx_guest_run` takes; under a major frame the +freeze is the context's, applied by the switch on every entry. The first +version of the negative build cleared the launch field, changed nothing, and +passed — and it was the runner's `--expect fail` that caught it rather than +anything in the image, which is the whole argument for registering negative +builds that way. The freeze is now a per-context field, `zx_ctx_credit_time`, +set by `zx_context_time_reset` so that no image can forget it; the negative +build clears the field that actually governs it. It costs the switch one +compare, inside the group the measurement reports as the time freeze, and that +is said here rather than discovered later. + +### What is deliberately not measured + +* **Interrupt latency**, for the reason D25 gives: guest interrupts go straight + to EL1, so bounding them needs the List Registers this phase does not use. +* **Anything about timing on the functional model.** A green run there proves + the code is right; every period and every cycle count has to come from + silicon before it is quoted, and the run says so in its own output. + +### How many frames a determinism run needs, and what a longer one found + +Open question, answered by measurement rather than by assertion: **sixty major +frames is enough for the bound being asserted, and is not enough to have found +the true maximum.** A build with ten times the frame count exists and is run by +hand on silicon; it is not in the automated suite, because sixty seconds of +silicon is cheap and ten minutes of a functional model on every pull request is +not. + +Jitter in counter counts, S32Z280-594EVB, ten frames per phase against a +hundred: + +| the untrusted partition is… | 60 frames | 600 frames | +|---|---|---| +| idle | 9 | 17 | +| in a tight compute loop | 15 | 16 | +| computing with IRQ and FIQ masked | 15 | 19 | +| storming the console | 24,420 | 24,584 | +| violating its boundary every iteration | 69 | 282 | + +The quiet phases roughly double and stay in the tens of counts. The fault phase +grows fourfold — 117,000 violations in the short run, over a million in the long +one, so the tail had more chances to appear. The console phase is stable to +0.7%, which is what a bound set by a UART's character rate should look like. + +Every one of them stays two to three orders of magnitude inside the bound +asserted against it. So the short run is sound for the claim it makes, and +nobody should quote these maxima as worst cases: they are the largest values +seen in a run of a stated length, and the longer run says plainly that a longer +one would see more. + +### ⚠ What re-measuring the partition switch found, and what it means for the figure + +The switch cost was re-measured on the same bench, in one session, to separate +this step's changes from the bench itself. Four readings of the same +measurement: + +| | min | mean | max | spread | measured core clock | +|---|---|---|---|---|---| +| archived, 2 Sep | 5,672 | 5,715 | 5,862 | 190 | 48,050,135 | +| the **same code**, re-measured today | 5,854 | 5,893 | 6,046 | 192 | 48,193,299 | +| with this step's changes | 6,030 | 6,078 | 6,370 | 340 | 48,187,087 | +| …and with the one instruction this step adds to the switch removed | 5,988 | 6,036 | 6,232 | 244 | 48,203,161 | + +Two things follow, and the second is the important one. + +**The one instruction this step puts on the switch path costs nothing +measurable.** Removing the `zx_ctx_credit_time` compare moves the mean by 42 +cycles, which is inside the run-to-run scatter of the other rows. The field +stays. + +**And the figure reproduces to about ±4% on this bench, across days and across +unrelated code changes — so quoting it to four significant figures was always +over-precise.** The same code measures 3.2% higher today than when it was +archived; this step's changes account for a further 2.4% that is not +attributable to any instruction added to the path. The EL2 caches are off, which +the image's own conditions block already gives as a reason the figure is an +over-estimate; what re-measurement adds is that with caches off the cost also +depends on **where the code sits in memory**, so adding a translation unit to +the hypervisor library moves it. The RC oscillator drifts too — 48.05 to 48.20 +MHz across these readings, and it is temperature-dependent. + +None of this touches anything that rests on the number. The jitter bound is ten +times the worst switch rounded to a power of two, and 6,370 cycles is as +comfortably inside one eighth of a window as 5,862 was. The ratio claim between +the two partitions' clocks is counter-only and does not involve the core clock +at all. What changes is how the figure should be written down: **about 6,000 +cycles, reproducible to a few per cent on one bench**, and not a worst case in +either direction until the clock tree lands. + +An amendment to the linker script's own placement, or `-ffunction-sections` +with an ordering file, would make the figure repeatable across code changes. +That is worth doing before anybody characterises this across a population, and +it is not worth doing to make a demonstrator's number look tidier. diff --git a/examples/common/zx_frame_setup.c b/examples/common/zx_frame_setup.c new file mode 100644 index 0000000..dcc0f27 --- /dev/null +++ b/examples/common/zx_frame_setup.c @@ -0,0 +1,347 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* EXAMPLE DESCRIPTION */ +/* */ +/* zx_frame_setup.c SHARED */ +/* */ +/* DESCRIPTION */ +/* */ +/* Everything at EL2 that makes a TIME-PARTITIONED system possible, in */ +/* one place and in one order, shared by every image that runs a major */ +/* frame. */ +/* */ +/* WHY THIS IS ONE FILE AND NOT ONE COPY PER IMAGE */ +/* */ +/* It was a copy in one image until a second image needed it, and a */ +/* second copy is the wrong answer here for a sharper reason than */ +/* tidiness. */ +/* */ +/* Half of what follows is CHECKS THAT EXIST BECAUSE SOMETHING WENT */ +/* WRONG ONCE. HCR is checked by BIT POSITION rather than by symbol, */ +/* because this port had FMO and AMO defined the other way round and a */ +/* check written against the symbol passed against either definition. */ +/* EL2's own CPU interface is brought up LAST, after FMO is set, */ +/* because setting FMO redirects a guest's ICC_PMR writes to the */ +/* virtual interface and the physical mask resets to zero -- so a */ +/* partition that was receiving its timer perfectly well stops */ +/* receiving anything, with no fault and no message. The comparator */ +/* is armed for a short interval before any partition runs, because a */ +/* window that never ends has three causes in three different places */ +/* and they present identically as a harness timeout naming nothing. */ +/* */ +/* A second copy of that would drift, and the drift would not present */ +/* as a wrong answer. It would present as a partition that silently */ +/* stopped being interrupted, in whichever image was edited second. */ +/* */ +/* WHAT IS A PARAMETER AND WHY */ +/* */ +/* ONE thing: whether the hypervisor's own timer PPI is enabled at the */ +/* GIC. A negative build deliberately leaves it disabled -- the */ +/* comparator still expires and the controller is simply never told, */ +/* so no window can end -- and that build is what makes every */ +/* preemption claim in the suite evidence rather than observation. It */ +/* is an argument rather than a compile-time test inside this file */ +/* because the file is shared and the negative build is one image's. */ +/* */ +/**************************************************************************/ + +#include "zx_probe.h" +#include "zx_platform.h" +#include "zx_frame_setup.h" + +/**************************************************************************/ +/* zx_frame_grant_the_clocks */ +/* */ +/* EVERYTHING AT EL2 THAT MAKES A TIME-PARTITIONED SYSTEM POSSIBLE, in */ +/* one place, because the order matters and several steps of it fail */ +/* silently on their own. */ +/* */ +/* THE COUNTER, then the GIC, then the two interrupts -- one for the */ +/* partitions and one for the hypervisor -- and then the ROUTING. */ +/* */ +/* THE PARTITIONS' TIMER IS ONE INTID FOR BOTH OF THEM. They never */ +/* run at once, and its meaning is switched with everything else a */ +/* switch switches: the outgoing partition's comparator is saved and */ +/* DISARMED, the incoming one's is restored. Two INTIDs would make */ +/* "which PPIs are enabled" guest state and grow the switch by a */ +/* register write for no gain; one INTID keeps it hypervisor state */ +/* that never changes. This is open question 2 of the step this image */ +/* was written for, answered the way it recommended, and recorded here */ +/* rather than only in the manifest's comments. */ +/* */ +/* THE HYPERVISOR'S TIMER GOES IN GROUP 0, which is what makes the */ +/* whole design work: routing is by exception TYPE, so Group 0 arrives */ +/* as an FIQ and HCR.FMO brings FIQ to EL2, while every partition */ +/* interrupt stays Group 1, stays an IRQ, and is delivered straight to */ +/* EL1 exactly as it was before this image existed. */ +/* */ +/* AND EL2'S OWN CPU INTERFACE COMES UP LAST, AFTER FMO IS SET, */ +/* because that is where the trap is. See zx_gic_el2_cpu_interface_ */ +/* init: setting FMO redirects the guest's ICC_PMR writes to the */ +/* VIRTUAL interface, so the physical priority mask -- which resets to */ +/* zero, masking everything -- becomes the hypervisor's to open. A */ +/* partition that was receiving its timer perfectly well stops */ +/* receiving anything, with no fault and no message. */ +/* */ +/* Returns non-zero when everything both partitions depend on is up. */ +/**************************************************************************/ + +uint32_t zx_frame_grant_the_clocks(ZX_GIC_LAYOUT *gic_ptr, + uint32_t guest_timer_priority, + uint32_t enable_hyp_tick) +{ + uint32_t running; + uint32_t awake; + uint32_t priority_bits; + uint32_t hyp_priority; + uint32_t tick_deliverable; + + zx_console_puts("\n--- the clocks and the interrupts ZoneX grants ---\n"); + + zx_board_counter_start(); + running = zx_counter_is_running(); + + zx_note("CNTFRQ ", zx_read_cntfrq()); + zx_check("the system counter is RUNNING, not merely declared. Every\n" + " window boundary in this run is an absolute comparison\n" + " against it, so a counter that does not move is a frame\n" + " that never turns", + running); + + awake = zx_gic_el2_init(gic_ptr); + zx_check("the redistributor cleared ProcessorSleep and reports its\n" + " children awake, so this core can be delivered to", + awake); + + priority_bits = zx_gic_priority_bits(gic_ptr, ZX_PPI_VIRTUAL_TIMER); + hyp_priority = zx_frame_hyp_tick_priority(); + + zx_note("implemented priority bits", priority_bits); + zx_note("partition timer priority ", guest_timer_priority); + zx_note("hypervisor tick priority ", hyp_priority); + + /* THE TWO PRIORITIES MUST STILL DIFFER AFTER THE PART HAS THROWN AWAY + THE BITS IT DOES NOT IMPLEMENT. Both targets keep only the top five, + so two values differing below that are the SAME priority -- and equal + priorities do not preempt. A hypervisor tick that could not preempt + a partition's timer handler would let a guest defer the end of its + own window for as long as its handler ran, which is the masking hole + wearing a different hat. */ + + { + uint32_t keep = (uint32_t)(0xFFU << (8U - priority_bits)); + + zx_check("the hypervisor's tick is a HIGHER priority than a\n" + " partition's timer even after this part has\n" + " discarded the priority bits it does not implement --\n" + " numerically lower wins, and equal priorities do not\n" + " preempt at all", + ((hyp_priority & keep) < (guest_timer_priority & keep)) + ? 1U : 0U); + } + + /* ONE INTID FOR BOTH PARTITIONS. Enabled once, here, and never touched + by a switch: the comparator behind it is what changes hands. */ + + zx_gic_enable_guest_ppi(gic_ptr, ZX_PPI_VIRTUAL_TIMER, + guest_timer_priority); + +if (enable_hyp_tick != 0U) + { + zx_gic_enable_hyp_ppi(gic_ptr, ZX_PPI_HYPERVISOR_TIMER, hyp_priority); + } + else + { + zx_console_puts( + "\n NEGATIVE BUILD: the HYPERVISOR's own timer PPI is\n" + " deliberately NOT enabled. The comparator is still armed at\n" + " every boundary and still expires; the GIC is simply never\n" + " told to deliver it. No window can then end, the first\n" + " partition runs for ever, and the second never runs at all --\n" + " so this run must report FAILED.\n"); + } + + zx_check("the partitions' virtual-timer PPI reads back ENABLED and in\n" + " GROUP 1, which is what the GIC delivers as an IRQ,\n" + " straight to EL1 with no injection and no List Register", + (zx_gic_ppi_is_enabled(gic_ptr, ZX_PPI_VIRTUAL_TIMER) + && zx_gic_ppi_is_group1(gic_ptr, ZX_PPI_VIRTUAL_TIMER)) + ? 1U : 0U); + + zx_check("and the HYPERVISOR's own timer PPI is in GROUP 0, which is\n" + " what the GIC delivers as an FIQ. Routing is by\n" + " exception TYPE and not by INTID: that one bit is the\n" + " whole difference between a tick that reaches EL2 and a\n" + " tick that would have to be injected into a List\n" + " Register", + (zx_gic_ppi_is_group1(gic_ptr, ZX_PPI_HYPERVISOR_TIMER) == 0U) + ? 1U : 0U); + + /* AND THAT IT IS ENABLED, which is the check the whole refusal below + rests on. A hypervisor whose own tick is not deliverable cannot end + a window, and a frame started in that state does not fail -- it + HANGS, with the first partition running until something outside the + image stops it. That is the least informative outcome this suite can + produce and it names nothing at all. */ + + tick_deliverable = zx_gic_ppi_is_enabled(gic_ptr, + ZX_PPI_HYPERVISOR_TIMER); + zx_note("hypervisor tick PPI enabled", tick_deliverable); + + zx_check("and the PHYSICAL timer's PPI is enabled for nobody. A\n" + " partition could not read it anyway -- CNTHCTL.PL1PCTEN\n" + " and PL1PCEN are left clear on purpose -- because\n" + " physical time keeps running while a partition is\n" + " descheduled, and a guest that can read it can see that\n" + " it was not running", + (zx_gic_ppi_is_enabled(gic_ptr, ZX_PPI_PHYSICAL_TIMER) == 0U) + ? 1U : 0U); + + /* ROUTING, AND THEN EL2's OWN CPU INTERFACE. In that order, because + the second only matters once the first has changed where the guest's + own writes go. */ + + zx_el2_route_fiq(); + + zx_note("HCR after routing", zx_read_hcr()); + + /* THE BIT, BY POSITION, AND NOT BY NAME. This port had ZX_HCR_FMO and + ZX_HCR_AMO defined the other way round, and a check written as + "(HCR & ZX_HCR_FMO) != 0" passes cheerfully against either + definition -- it is the same symbol on both sides of the comparison. + What it cost was a run in which every set-up check was green, the + comparator expired, the GIC made the interrupt pending, and no window + ever ended: bit 5 is AMO, and physical FIQ had never been routed + anywhere. Bit 3 is FMO on this architecture and on this part, TRM + Table 3-70, and comparing against the NUMBER is the only form of this + check a swapped definition cannot satisfy. */ + + zx_check("HCR bit 3 -- FMO, by position and not by the name this port\n" + " happens to give it -- is SET, so a physical FIQ is\n" + " taken to EL2. And with it set, PSTATE.F is IGNORED at\n" + " EL0 and EL1: a partition cannot mask the interrupt\n" + " that ends its own window, whatever its kernel does\n" + " with its own masks", + ((zx_read_hcr() & 0x8U) != 0U) ? 1U : 0U); + zx_check("and HCR bit 5 -- AMO -- is CLEAR. Routing asynchronous aborts\n" + " to EL2 would send them to the vector that today means\n" + " 'ZoneX faulted on its own access', so a guest's abort\n" + " would be reported as a hypervisor bug. Not needed for\n" + " a window to end, and deliberately deferred", + ((zx_read_hcr() & 0x20U) == 0U) ? 1U : 0U); + zx_check("and HCR bit 4 -- IMO -- is CLEAR, so every partition interrupt\n" + " is still a physical IRQ delivered straight to EL1. The\n" + " guest side of this arrangement needed no change\n" + " whatever, which is the strongest argument the design\n" + " has", + ((zx_read_hcr() & 0x10U) == 0U) ? 1U : 0U); + + zx_gic_el2_cpu_interface_init(); + + zx_note("EL2 ICC_PMR ", zx_gic_el2_priority_mask()); + zx_note("EL2 ICC_IGRPEN0", zx_gic_el2_group0_enabled()); + + zx_check("the PHYSICAL priority mask is open, and it is the\n" + " HYPERVISOR'S to open now. Setting HCR.FMO redirects an\n" + " EL1 write of ICC_PMR to the VIRTUAL interface, so the\n" + " guest's own 'unmask everything' stops affecting\n" + " physical delivery -- and the physical mask resets to\n" + " zero, which masks it all. A partition that received\n" + " its timer end to end a moment ago would stop receiving\n" + " anything, with nothing to fault on", + (zx_gic_el2_priority_mask() != 0U) ? 1U : 0U); + + zx_check("and Group 0 is enabled at EL2's own CPU interface, without\n" + " which an FIQ could not be signalled to the core at all", + zx_gic_el2_group0_enabled()); + + /* ---------------------------------------------------------------- */ + /* A PRE-FLIGHT, BEFORE ANY PARTITION RUNS. */ + /* */ + /* A window that never ends has three possible causes in three */ + /* different places, and they present identically: a hypervisor */ + /* that starts a frame and is never heard from again, with no */ + /* fault, no message and a harness timeout that names nothing. */ + /* That is the least informative failure this suite can produce. */ + /* */ + /* So the comparator is armed for a short interval HERE, with no */ + /* partition running and FIQ still masked at EL2, and two */ + /* questions are asked separately: did it expire, and did the GIC */ + /* make the interrupt pending. A run that fails either of them */ + /* says which half is broken instead of hanging. */ + /* */ + /* The same discipline as zx_counter_is_running, one level up: */ + /* a guest that armed a timer against a stopped counter was the */ + /* failure that check was written for, and this is the */ + /* hypervisor's own version of it. */ + /* ---------------------------------------------------------------- */ + + { + uint64_t soon = zx_read_cntpct() + (uint64_t)(zx_board_counter_hz() + / 1000U); + uint32_t armed = zx_el2_hyp_timer_arm(soon); + + zx_el2_dwell_until(soon + (uint64_t)(zx_board_counter_hz() / 1000U)); + + zx_console_puts("\n--- the hypervisor's own tick, before any " + "partition runs ---\n"); + zx_note("armed for a future deadline", armed); + zx_note("CNTHP expired (ISTATUS) ", zx_el2_hyp_timer_fired()); + zx_note("GIC has PPI 26 pending ", + zx_gic_ppi_is_pending(gic_ptr, ZX_PPI_HYPERVISOR_TIMER)); + + zx_check("the hypervisor's own comparator EXPIRED. This is the\n" + " first of three questions a window that never ends\n" + " raises, and the only one that is about the timer\n" + " rather than about the interrupt controller", + zx_el2_hyp_timer_fired()); + +if (enable_hyp_tick != 0U) + { + zx_check("and the GIC made it PENDING, which is the second: a\n" + " comparator that expires into a controller that\n" + " was never told is indistinguishable, from the\n" + " outside, from a core that never took the\n" + " exception", + zx_gic_ppi_is_pending(gic_ptr, + ZX_PPI_HYPERVISOR_TIMER)); + } + + zx_el2_hyp_timer_stop(); + } + + zx_console_puts( + "\n" + " AND THE SAME REDIRECTION IS WHAT MAKES THIS AIRTIGHT RATHER THAN\n" + " MERELY CHEAP. ICC_IGRPEN0 is redirected too, so a partition\n" + " cannot reach the physical Group 0 enable to switch off the\n" + " interrupt that ends its window: it writes the virtual copy and\n" + " nothing happens. Before FMO was set that register was genuinely\n" + " shared, and the argument had to be that a partition was granted\n" + " no Group 0 interrupt worth enabling. Now it cannot reach the\n" + " enable at all.\n"); + + /* THREE THINGS, AND A FRAME NEEDS ALL OF THEM. The counter has to be + moving, the GIC has to be awake, and the hypervisor's own tick has to + be deliverable. Returned as one answer so that the caller has one + decision to make, and the caller's decision is to REFUSE rather than + to start a frame whose windows could never end. */ + + return ((running != 0U) && (awake != 0U) && (tick_deliverable != 0U)) + ? 1U : 0U; +} diff --git a/examples/common/zx_frame_setup.h b/examples/common/zx_frame_setup.h new file mode 100644 index 0000000..36fc44e --- /dev/null +++ b/examples/common/zx_frame_setup.h @@ -0,0 +1,68 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* EXAMPLE DESCRIPTION */ +/* */ +/* zx_frame_setup.h SHARED */ +/* */ +/* DESCRIPTION */ +/* */ +/* The one routine every image that runs a major frame calls before it */ +/* starts one. Why it is shared rather than copied, and which of its */ +/* checks exist because something went wrong once, is in */ +/* zx_frame_setup.c. */ +/* */ +/**************************************************************************/ + +#ifndef ZX_FRAME_SETUP_H +#define ZX_FRAME_SETUP_H + +#include "zx_port.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* THE COUNTER, then the GIC, then the two interrupts -- one for the + * partitions and one for the hypervisor -- and then the ROUTING, and then + * EL2's own CPU interface. In that order, because several steps of it fail + * SILENTLY on their own and the last two only matter once the ones before + * have changed where a guest's own writes go. + * + * `enable_hyp_tick` is zero in exactly one build, which deliberately leaves + * the hypervisor's own timer PPI disabled at the GIC so that no window can + * end. That build is what makes every preemption claim in this suite + * evidence rather than observation, and it is an argument rather than a + * compile-time test inside the shared file because the negative build + * belongs to one image. + * + * Returns non-zero when all three things a window boundary depends on are in + * place: the counter is moving, the redistributor is awake, and the + * hypervisor's own tick is deliverable. ONE answer, so that the caller has + * one decision to make -- and the caller's decision is to REFUSE rather than + * to start a frame whose windows could never end, because a frame started in + * that state does not fail, it HANGS. */ + +ZX_NODISCARD uint32_t zx_frame_grant_the_clocks(ZX_GIC_LAYOUT *gic_ptr, + uint32_t guest_timer_priority, + uint32_t enable_hyp_tick); + +#ifdef __cplusplus +} +#endif + +#endif /* ZX_FRAME_SETUP_H */ diff --git a/examples/common/zx_guest_abi.h b/examples/common/zx_guest_abi.h index 2d79b8d..b8a91bc 100644 --- a/examples/common/zx_guest_abi.h +++ b/examples/common/zx_guest_abi.h @@ -292,7 +292,9 @@ * the mailbox goes from 64 bytes to 128, and NOTHING at stage 2 changes, * because the partition window is one region covering the whole of it. It * does move the entry branch, which is why ZX_GUEST_IMAGE_OFF_ENTRY below is - * 0x80 and the guest's linker script asserts it. + * a compile-time constant every linker script asserts. (It has since grown + * a THIRD granule, for the isolation matrix; the note there says what that + * cost and why the alternative was refused a second time.) * * The alternative -- overloading words that a kernel guest happens not to use * -- was rejected after ZX_GD_OPTIONS. One alias, with one reader at each @@ -383,7 +385,190 @@ #define ZX_GD_PROBE_SCRATCH 0x58U /* written by the probe, read by nobody */ -#define ZX_GD_WINDOW_SIZE 0x80U +/**************************************************************************/ +/* THE THIRD GRANULE: the isolation matrix and the behaviour */ +/**************************************************************************/ + +/* WHY THE MAILBOX IS THREE GRANULES AND NOT TWO. + * + * The same reason it went from one to two, and the note above says what that + * cost: the guest's stage-1 MPU region for the mailbox grows by 64 bytes, + * NOTHING at stage 2 changes because the partition window is one region + * covering the whole of it, and the entry branch moves -- so + * ZX_GUEST_IMAGE_OFF_ENTRY is 0xC0 and every linker script asserts it. + * + * THE ALTERNATIVE WAS OVERLOADING, AND IT WAS REJECTED FOR THE SECOND TIME. + * Five words of the second granule were spare, and the matrix needs six from + * the hypervisor and six back. Aliasing eleven words onto the spares and + * onto fields "a kernel guest happens not to use" is precisely the layout + * this file already refused to build once: one alias with one reader at each + * end is explicable, eleven is a map only its author can read. Sixty-four + * bytes of a 256 KB window is the cheaper price. + * + * THE IMAGE MAGIC IS BUMPED WITH IT, which is what the note beside + * ZX_GUEST_IMAGE_MAGIC asked for and the first occasion to use it. Moving + * the entry branch is an ABI change: a guest built against the old layout + * and loaded by a hypervisor expecting the new one would be ERETed into the + * middle of its own mailbox, which is a jump to whatever the handover last + * wrote there. A version in the header turns that into a refusal naming + * both numbers. */ + +/* WHAT THE HYPERVISOR ASKS FOR. Six addresses and a mask, because the + * matrix is "one address per case, one case per bit" and the address IS the + * case's name -- it is what the fault log is searched by afterwards. + * + * EVERY ADDRESS COMES FROM EL2 AND NOT FROM THE GUEST'S OWN LINKER, with one + * exception noted below, and that is the point rather than an inconvenience. + * A guest computing its neighbour's window from its own base would be a + * guest that had been TOLD the layout; a guest handed an address it cannot + * derive is a guest being asked to reach somewhere it has no business + * knowing about, which is the situation the isolation claim is about. */ + +#define ZX_GD_M_CASES 0x80U /* EL2 writes: ZX_MC_* to run */ +#define ZX_GD_M_NEIGHBOUR 0x84U /* EL2: the other partition's + data, read AND written */ +#define ZX_GD_M_NEIGHBOUR_CODE 0x88U /* EL2: the other partition's + code, BRANCHED to */ +#define ZX_GD_M_HOLE 0x8CU /* EL2: the ungranted granule + ADJACENT to this window */ +#define ZX_GD_M_HYP_DATA 0x90U /* EL2: hypervisor .data/.bss */ +#define ZX_GD_M_HYP_MMIO 0x94U /* EL2: the console or the GIC */ +#define ZX_GD_M_MARK_BASE 0x98U /* EL2: first granule to mark */ +#define ZX_GD_M_MARK_COUNT 0x9CU /* EL2: how many to mark */ + +/* WHAT THE GUEST REPORTS BACK. + * + * NONE OF THESE IS A CLAIM THAT A CASE WAS DENIED, and that is deliberate to + * the point of being the most important thing in this block. + * + * Under the test-only continue mode the hypervisor resumes a faulting + * partition PAST the faulting instruction -- so the store that would have + * recorded "this case survived" executes anyway, one instruction later, on + * a case that was refused. A guest cannot report its own denial: it is not + * conscious of having been denied. Every "it faulted" judgement therefore + * belongs to EL2 and comes out of the fault log, matched by address. + * + * What the guest CAN say honestly is what it ATTEMPTED, which EL2 cannot + * know -- a case skipped because the guest refused it and a case denied by + * stage 2 both leave a hypervisor with no fault to look at, and only the + * guest can tell them apart. That asymmetry is why both halves exist. */ + +#define ZX_GD_M_ATTEMPTED 0xA0U /* guest: ZX_MC_* it tried */ +#define ZX_GD_M_REFUSED 0xA4U /* guest: ZX_MC_* it would not */ +#define ZX_GD_M_RESUME 0xA8U /* guest: where to put it back + after a failed BRANCH */ +#define ZX_GD_M_READ_VALUE 0xACU /* guest: what the read case + returned, poison if denied */ +#define ZX_GD_M_MARKS_WRITTEN 0xB0U /* guest: marks it wrote */ +#define ZX_GD_M_MARKS_OK 0xB4U /* guest: its own readback */ + +/* What a partition is doing while its neighbour is being measured. + * + * A WORD AND NOT A BUILD OPTION, and the reason is the whole shape of the + * determinism run. Six behaviours as six images is six boots, six epochs + * and six sets of switch measurements, and comparing the critical + * partition's period ACROSS them then compares runs rather than phases. + * Re-read on every iteration of the endless loop, so one run can put the + * untrusted partition through every behaviour in turn while the critical + * one is measured continuously -- which is a strictly stronger comparison + * and a sixth of the model time. */ + +#define ZX_GD_M_BEHAVIOUR 0xB8U /* EL2 writes: ZX_GB_* */ +#define ZX_GD_M_PHASE_SEEN 0xBCU /* guest: behaviours it obeyed */ + +#define ZX_GD_WINDOW_SIZE 0xC0U + +/**************************************************************************/ +/* The isolation matrix, as bits */ +/**************************************************************************/ + +/* One bit per case, in the order the sweep runs them. + * + * THE ORDER IS NOT ARBITRARY: the cases that must SUCCEED come last. A + * sweep that marked its own window first and then went on to be denied six + * times would leave a reader unable to say whether the marks were written + * before or after the denials, and "the partition still owned its memory + * after being refused six times" is part of what case 7 asserts. + * + * THE BRANCH CASE IS LAST OF THE DENIALS for a different reason. A + * prefetch abort cannot be resumed by stepping over the faulting + * instruction -- the instruction was never fetched -- so it is the one case + * whose continuation depends on the guest having published a resume + * address. Running it after the data cases means a defect in that path + * costs the sweep one case rather than all of them. */ + +#define ZX_MC_NEIGHBOUR_READ 0x01U /* case 1 */ +#define ZX_MC_NEIGHBOUR_WRITE 0x02U /* case 2 */ +#define ZX_MC_HOLE_WRITE 0x04U /* case 4 -- the ADJACENT one */ +#define ZX_MC_HYP_DATA 0x08U /* case 5 -- read and write */ +#define ZX_MC_HYP_MMIO 0x10U /* case 6 */ +#define ZX_MC_NEIGHBOUR_EXEC 0x20U /* case 3 -- a BRANCH */ +#define ZX_MC_OWN_MARKS 0x40U /* case 7 -- must SUCCEED */ + +#define ZX_MC_ALL 0x7FU + +/* Why the guest would refuse a case. Reported in ZX_GD_M_REFUSED so that a + case which never ran is distinguishable from one that was denied, which + from EL2 look identical: both leave the guest reporting nothing. */ + +#define ZX_MR_NO_ADDRESS 0x0100U /* EL2 left the word zero */ +#define ZX_MR_NO_REGION 0x0200U /* no spare stage-1 region */ +#define ZX_MR_INSIDE_ITSELF 0x0400U /* the address is its own, so + the case would prove nothing */ + +/* The mark a granule carries: whose window it is in the middle byte, and + * the granule's index in the low one. + * + * DISTINCT PER GRANULE, because five copies of one value in five granules + * proves one extent was programmed five times over -- a region whose base + * is wrong accepts every store and puts them all somewhere else, and every + * readback of an identical mark agrees. Only distinct marks read back at + * distinct addresses catch it. + * + * AND DISTINCT PER PARTITION, from the SENTINEL the handover already gives + * each guest, so that a mark read out of the wrong window is attributable + * rather than merely present. Its low byte is what varies -- 0x11 and + * 0x22 -- and folding the whole sentinel in would overflow the field for + * no gain. + * + * UNSIGNED LONG AND NOT uint32_t, because this header is the CONTRACT and + * both sides of it spell their types differently: the hypervisor is ZoneX + * C17 with stdint, the guest is ThreadX C99 with ULONG, and the file is + * also included by assembly, which has no types at all. So it names the + * one spelling that is 32-bit and available on every side. A uint32_t here + * failed the guest's build, which is the header doing its job. */ + +#define ZX_MARK_BASE 0x5A5A0000UL +#define ZX_MARK_FOR(sentinel, index) \ + (ZX_MARK_BASE | (((unsigned long)(sentinel) & 0xFFUL) << 8) \ + | ((unsigned long)(index) & 0xFFUL)) + +/* What EL2 writes into a granule that belongs to NOBODY, before the run. + * It has to be a value no mark can take and no zeroed window can hold: a + * hole reading as zero after the run is indistinguishable from a hole + * nobody looked at. */ + +#define ZX_HOLE_POISON 0xDEAD00FFU + +/* What the guest leaves in ZX_GD_M_READ_VALUE before it attempts the read, + so that a denied load -- whose destination register is never written -- + reports this rather than a stale value that reads like an answer. */ + +#define ZX_READ_POISON 0xBAD0BAD0U + +/**************************************************************************/ +/* What an untrusted partition is asked to do */ +/**************************************************************************/ + +/* Re-read on every iteration of the endless loop. Each of these is one row + of the determinism table, and the critical partition's period is measured + across all of them in one run. */ + +#define ZX_GB_QUIET 0x00U /* republish, and nothing else */ +#define ZX_GB_SPIN 0x01U /* compute, never yield */ +#define ZX_GB_MASKED 0x02U /* CPSID if, then compute */ +#define ZX_GB_STORM 0x03U /* a hypercall per character */ +#define ZX_GB_FAULT 0x04U /* violate, over and over */ /**************************************************************************/ /* Progress bits */ @@ -445,6 +630,23 @@ #define ZX_GP_TIMESLICED 0x020000U /* two spinners both advanced */ #define ZX_GP_NO_CLOCK 0x040000U /* refused to block: no counter */ +/* THE ONE THING THE ISOLATION SWEEP CAN CLAIM FOR ITSELF, and it is inside + the seal because it is a claim about what the partition ACHIEVED rather + than about what it was refused. Set when every granule of its own window + that the hypervisor pointed it at took a distinct mark and read that mark + back -- which is the sweep's one positive case, and the reason a + regression that only proved things fault would not pass with every region + disabled. + * + NOT a bit meaning "the sweep ran". The sweep's attempted set is a word + of its own, ZX_GD_M_ATTEMPTED, and it is a word rather than a bit for the + reason the whole third granule exists: a guest cannot report having been + denied, so "ran" and "was refused" are the hypervisor's to distinguish + and only the attempted set helps it. */ + +#define ZX_GP_MARKS_OK 0x080000U /* its own window, marked and + read back, every granule */ + /* WHAT THE GUEST'S OWN VECTORS SAW, in ZX_GD_STAGE1. * * A stage-1 fault is taken to EL1 and never reaches the hypervisor, which is @@ -537,8 +739,8 @@ * defined starting state rather than whatever the window held before. The * hypervisor writes its handover fields after the copy, never before. */ -#define ZX_GUEST_IMAGE_OFF_MAILBOX 0x00U /* TWO granules, ZX_GD_* inside */ -#define ZX_GUEST_IMAGE_OFF_ENTRY 0x80U /* the branch the ERET lands on */ +#define ZX_GUEST_IMAGE_OFF_MAILBOX 0x00U /* THREE granules, ZX_GD_* in */ +#define ZX_GUEST_IMAGE_OFF_ENTRY 0xC0U /* the branch the ERET lands on */ /* THE IMAGE HEADER, three words after the entry branch. * @@ -555,16 +757,34 @@ * an EMPTY section rather than an error, so "the header does not carry the * magic" catches both a missing guest and a wrong one. */ -#define ZX_GUEST_IMAGE_OFF_LINK_BASE 0x84U /* the window it was linked for */ -#define ZX_GUEST_IMAGE_OFF_LINK_SIZE 0x88U /* the size it was linked to fit */ -#define ZX_GUEST_IMAGE_OFF_MAGIC 0x8CU /* ZX_GUEST_IMAGE_MAGIC */ +#define ZX_GUEST_IMAGE_OFF_LINK_BASE 0xC4U /* the window it was linked for */ +#define ZX_GUEST_IMAGE_OFF_LINK_SIZE 0xC8U /* the size it was linked to fit */ +#define ZX_GUEST_IMAGE_OFF_MAGIC 0xCCU /* ZX_GUEST_IMAGE_MAGIC */ + +/* AND THE FIRST BYTE PAST THE HEADER, which is the smallest blob that can + carry one. Every hypervisor linker script asserts its embedded guest is + at least this big, because a blob too small to hold its own header makes + the loader's magic check an out-of-bounds read of whatever follows the + section -- and an .incbin whose file was missing produces exactly that. + The number lived as a literal in four linker scripts and was WRONG in all + four the day the mailbox grew from one granule to two: it stayed at 0x50 + while the header moved to 0x8C, so a blob of 0x51 to 0x8F bytes linked + cleanly and read past its own section. It is a named constant now, and + the scripts still restate it -- a linker script cannot include a C header + -- but there is one place to look when it moves again. */ + +#define ZX_GUEST_IMAGE_MIN_SIZE 0xD0U /* "ZXG" and a version. The version is here so that a later ABI change is a REFUSAL rather than a guest that starts and misbehaves: the loader can say "this image was built against a different contract" and name both numbers. */ -#define ZX_GUEST_IMAGE_MAGIC 0x5A584731 +/* "ZXG2". Version 1 had the entry branch at 0x80 and a two-granule + mailbox; a version-1 blob loaded by this hypervisor would be ERETed into + the middle of its own mailbox. */ + +#define ZX_GUEST_IMAGE_MAGIC 0x5A584732 /* The verdict a kernel guest publishes into ZX_GD_VERDICT. Distinct non-zero values, so that an unwritten word -- zero -- is neither a pass diff --git a/examples/common/zx_guest_launch.c b/examples/common/zx_guest_launch.c index acab759..4a3268a 100644 --- a/examples/common/zx_guest_launch.c +++ b/examples/common/zx_guest_launch.c @@ -245,6 +245,123 @@ void zx_guest_hand_over(const ZX_GUEST_LAUNCH *launch_ptr, zx_guest_mailbox_write(launch_ptr, ZX_GD_VCT_NOW, 0U); zx_guest_mailbox_write(launch_ptr, ZX_GD_TICKS_NOW, 0U); + /* The third granule: the isolation matrix, and what a partition is + doing while its neighbour is being measured. + * + ZEROED HERE AND ASKED FOR SEPARATELY. Every one of these is either + an address only the hypervisor can supply or a report only the guest + can make, and none belongs in this function's argument list: a + handover taking eight more addresses would be a handover most images + passed eight zeroes to. The regression image calls + zx_guest_ask_for_matrix after this, and every other image simply + does not -- so a zero here is what "no sweep was asked for" means, + and the guest's own first test is that the case mask is non-zero. + * + ZERO IS THE RIGHT ABSENT VALUE FOR ALL OF THEM. An address of zero + is refused by the sweep with ZX_MR_NO_ADDRESS rather than probed, + which matters: the alternative -- leaving whatever the copy put there + -- would have a guest reach for an address nobody chose and report a + fault nobody can attribute. */ + + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_CASES, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_NEIGHBOUR, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_NEIGHBOUR_CODE, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_HOLE, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_HYP_DATA, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_HYP_MMIO, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_MARK_BASE, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_MARK_COUNT, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_ATTEMPTED, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_REFUSED, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_RESUME, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_READ_VALUE, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_MARKS_WRITTEN, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_MARKS_OK, 0U); + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_BEHAVIOUR, ZX_GB_QUIET); + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_PHASE_SEEN, 0U); + + __asm__ volatile("dsb" ::: "memory"); +} + + +/**************************************************************************/ +/* zx_guest_ask_for_matrix -- the isolation sweep, described. */ +/* */ +/* SEPARATE FROM THE HANDOVER, and deliberately. Only one image asks */ +/* for a sweep; folding six addresses into zx_guest_hand_over would make */ +/* every other image pass six zeroes to say it did not want one, and a */ +/* parameter that is zero at almost every call site is a parameter nobody */ +/* reads. Called after the handover, which has already zeroed all of */ +/* these, so the words this does not set stay absent rather than stale. */ +/* */ +/* A DSB AT THE END, because the guest is ERETed into after this and the */ +/* first thing its sweep does is read these words. The hypervisor's own */ +/* caches are off, so this is currently redundant -- and it is here */ +/* anyway, for the same reason the loader's cache maintenance is: the */ +/* change that turns caches on will be made by somebody with no reason to */ +/* think about a mailbox. */ +/**************************************************************************/ + +void zx_guest_ask_for_matrix(const ZX_GUEST_LAUNCH *launch_ptr, + const ZX_GUEST_MATRIX *matrix_ptr) +{ + if ((launch_ptr == (const ZX_GUEST_LAUNCH *)0) + || (matrix_ptr == (const ZX_GUEST_MATRIX *)0)) + { + return; + } + + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_NEIGHBOUR, + matrix_ptr->zx_matrix_neighbour); + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_NEIGHBOUR_CODE, + matrix_ptr->zx_matrix_neighbour_code); + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_HOLE, + matrix_ptr->zx_matrix_hole); + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_HYP_DATA, + matrix_ptr->zx_matrix_hyp_data); + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_HYP_MMIO, + matrix_ptr->zx_matrix_hyp_mmio); + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_MARK_BASE, + matrix_ptr->zx_matrix_mark_base); + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_MARK_COUNT, + matrix_ptr->zx_matrix_mark_count); + + /* THE CASE MASK LAST, and it is the only word whose order matters. It + is what the guest tests before it reads any of the others, so writing + it first would leave a window -- if a partition somehow ran between + the two -- in which the sweep was asked for and its addresses were + still zero. Nothing can run between them today; the ordering costs + one line and removes the question. */ + + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_CASES, + matrix_ptr->zx_matrix_cases); + + __asm__ volatile("dsb" ::: "memory"); +} + + +/**************************************************************************/ +/* zx_guest_set_behaviour -- what an untrusted partition does next. */ +/* */ +/* ONE WORD, RE-READ BY THE GUEST ON EVERY ITERATION of its endless */ +/* phase, which is what lets one run put a partition through every row of */ +/* the determinism table while its neighbour is measured continuously. */ +/* Six builds would have been the obvious shape and it is the weaker one: */ +/* comparing the critical partition's period across six runs compares */ +/* RUNS, and a difference between two runs has a dozen explanations a */ +/* difference between two phases of one run does not. */ +/**************************************************************************/ + +void zx_guest_set_behaviour(const ZX_GUEST_LAUNCH *launch_ptr, + uint32_t behaviour) +{ + if (launch_ptr == (const ZX_GUEST_LAUNCH *)0) + { + return; + } + + zx_guest_mailbox_write(launch_ptr, ZX_GD_M_BEHAVIOUR, behaviour); + __asm__ volatile("dsb" ::: "memory"); } diff --git a/examples/common/zx_guest_launch.h b/examples/common/zx_guest_launch.h index 6a082d3..73e4611 100644 --- a/examples/common/zx_guest_launch.h +++ b/examples/common/zx_guest_launch.h @@ -147,11 +147,90 @@ void zx_guest_image_load(const ZX_PARTITION_LOAD *load_ptr); never before: the mailbox is a LOADED section in the guest image, zeroed by the copy, so a handover written first would be copied over. */ +/**************************************************************************/ +/* ZX_GUEST_MATRIX -- the addresses one partition is asked to reach for. */ +/* */ +/* A STRUCT RATHER THAN SEVEN ARGUMENTS, because seven addresses in a */ +/* call are seven chances to transpose two of them -- and two of these */ +/* are the neighbour's DATA and the neighbour's CODE, which differ by a */ +/* region attribute rather than by anything a reader would notice at a */ +/* call site. Transposed, the sweep would branch into data and write */ +/* into code, and both would still fault: the run would pass and two rows */ +/* of the matrix would have swapped places silently. */ +/* */ +/* EVERY FIELD IS AN ADDRESS THE GUEST COULD NOT HAVE DERIVED, which is */ +/* the point of handing them over at all. A guest computing its */ +/* neighbour's window from its own base would be a guest that had been */ +/* TOLD the layout; a guest handed an address it cannot derive is a guest */ +/* being asked to reach somewhere it has no business knowing about. */ +/**************************************************************************/ + +typedef struct ZX_GUEST_MATRIX_STRUCT +{ + /* Which cases to run, as ZX_MC_* bits. Zero means no sweep, which is + what every image but the regression asks for. */ + + uint32_t zx_matrix_cases; + + /* The other partition's data window, read at this address and written + four bytes above it -- so that the read case and the write case have + an address each and the fault log can tell them apart. */ + + uint32_t zx_matrix_neighbour; + + /* And its code, BRANCHED to. A prefetch abort rather than a data + abort, EC 0x20 rather than 0x24, reported through HIFAR. */ + + uint32_t zx_matrix_neighbour_code; + + /* The ungranted granule ADJACENT to this partition's own window. Not + "an ungranted address": adjacency is the whole test, because the + defect class worth catching is a region limit out by one granule and + an address far from every grant proves only that unmapped memory + faults. */ + + uint32_t zx_matrix_hole; + + /* The hypervisor's own memory, and its own MMIO. Under decision D2 -- + HSCTLR.BR set, hypervisor memory covered by no enabled region -- + these pass structurally rather than by a permission check, and the + regression says so in its output because WHY they pass is the + interesting part. */ + + uint32_t zx_matrix_hyp_data; + uint32_t zx_matrix_hyp_mmio; + + /* Where the marks go, and how many. Inside the partition's own window + and above its image, so that marking cannot corrupt the program doing + the marking; the guest checks that independently and refuses a range + it does not own. */ + + uint32_t zx_matrix_mark_base; + uint32_t zx_matrix_mark_count; + +} ZX_GUEST_MATRIX; + void zx_guest_hand_over(const ZX_GUEST_LAUNCH *launch_ptr, uint32_t probe_target, uint32_t options); /* One excursion, measured in PMU cycles. Returns ZX_RUN_*. */ +/* Ask a partition for the isolation sweep. Called AFTER zx_guest_hand_over, + which has already zeroed every word this touches -- so a word this does + not set stays absent rather than stale. */ + +void zx_guest_ask_for_matrix(const ZX_GUEST_LAUNCH *launch_ptr, + const ZX_GUEST_MATRIX *matrix_ptr); + +/* Tell a partition what to do next, as one of the ZX_GB_* behaviours. Safe + to call between windows, which is when the frame calls it: the guest + re-reads the word on every iteration of its endless phase and needs no + interrupt to notice, which is what lets a partition be steered OUT of a + phase it entered by masking its own interrupts. */ + +void zx_guest_set_behaviour(const ZX_GUEST_LAUNCH *launch_ptr, + uint32_t behaviour); + ZX_NODISCARD uint32_t zx_guest_run(const ZX_GUEST_LAUNCH *launch_ptr, ZX_PARTITION_CB *partition_ptr, uint32_t *cycles_ptr); diff --git a/examples/common/zx_partitions.c b/examples/common/zx_partitions.c index 194056b..0e322b5 100644 --- a/examples/common/zx_partitions.c +++ b/examples/common/zx_partitions.c @@ -143,6 +143,7 @@ static void zx_build_manifest(uint32_t board_regions) __zx_shared_start, __zx_shared_end, ZX_AP_EL2_RO_GUEST_RO, ZX_XN_NEVER); + zx_shared[0].zx_shared_base = zx_symbol_address(__zx_shared_start); zx_shared[0].zx_shared_limit = zx_symbol_address(__zx_shared_end) - 1U; zx_shared[0].zx_shared_publisher_id = ZX_PARTITION_A_ID; @@ -450,6 +451,49 @@ void zx_phase_two_partitions(uint32_t board_regions, uint32_t el2_regions) zx_check("the manifest passes every rule", 1U); +#ifdef ZX_PROBE_SHARED_RW + + /* THE NEGATIVE VERIFICATION OF THE SHARED GRANULE'S READ-ONLY HALF, and + * it has to be applied HERE -- after the validator has passed a manifest + * that says the right thing -- because there are TWO independent + * defences and this build exists to test the second one. + * + * A shared granule is the one DECLARED EXCEPTION to isolation in this + * whole component: one range readable by both partitions and writable by + * exactly one. Its read-only half's passing condition is the ABSENCE of + * a successful write, and an absence nobody has seen turn into a + * presence is not evidence of anything. + * + * THE FIRST DEFENCE IS THE VALIDATOR, and it is why this edit cannot go + * where the other negative builds' edits go. zx_manifest_shared_check + * requires the non-publisher's copy of a shared range to be read-only, + * so a manifest declaring it writable is REFUSED before a single region + * is programmed -- which was the first version of this build, and it + * reported the validator refusing the manifest rather than the access + * being permitted. That is a real result and a good one; it is simply + * a test of a different check. + * + * THE SECOND IS THE HARDWARE, and reaching it means handing the region + * programmer a descriptor the validator never saw. What that then + * demonstrates is the sharp end of the claim: the write SURVIVES, so + * the run must fail -- and the two DFSC values the positive check rests + * on, a region MISS against a region PERMISSION failure, are what + * separate "read-only" from "not mapped here at all". + * + * ONLY THE PERMISSION CHANGES. The range, the attributes and the + * declared publisher stay exactly as validated, so nothing else in the + * run moves and the one thing that can catch this is the access. */ + + zx_console_puts("\n SHARED-WRITABLE BUILD: the READER's copy of the\n" + " shared granule is being given write permission AFTER\n" + " the validator passed a manifest that said read-only.\n" + " Partition B's publish must then SURVIVE and this run\n" + " must report FAILED.\n"); + + zx_regions_b[ZX_PART_REGION_SHARED].zx_region_ap = ZX_AP_EL2_RW_GUEST_RW; + +#endif + /* ---------------------------------------------------------------- */ /* The plan. */ /* ---------------------------------------------------------------- */ diff --git a/examples/common/zx_regression.c b/examples/common/zx_regression.c new file mode 100644 index 0000000..1be375f --- /dev/null +++ b/examples/common/zx_regression.c @@ -0,0 +1,2246 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* EXAMPLE DESCRIPTION */ +/* */ +/* zx_regression.c SHARED */ +/* */ +/* DESCRIPTION */ +/* */ +/* THE ISOLATION AND DETERMINISM REGRESSION. Two claims, proved in */ +/* one run and asserted on rather than observed: */ +/* */ +/* NEITHER PARTITION CAN READ, WRITE OR EXECUTE the other's memory, */ +/* the ungranted granule next door to its own, or the hypervisor's */ +/* -- and it still owns every byte of its OWN window afterwards. */ +/* Fourteen cases, seven in each direction, each aimed at an address */ +/* of its own so that the fault it provoked is attributable to it. */ +/* */ +/* AND PARTITION A'S TIMING IS UNAFFECTED BY ANYTHING PARTITION B */ +/* DOES. A's window period is measured continuously while B is */ +/* steered through five behaviours -- quiet, computing, computing */ +/* with its interrupts masked, storming the console, and violating */ +/* its boundary on every iteration of its own loop. */ +/* */ +/* WHY THIS IS A SEPARATE IMAGE FROM zx_two_partitions.c */ +/* */ +/* That image DEMONSTRATES: two kernels time-sharing one core, with */ +/* the switch measured and the clocks compared. This one ASSERTS, and */ +/* the difference shows up in three places rather than one. It runs */ +/* sixty major frames instead of twenty, because a maximum taken over */ +/* ten periods is not a maximum. It needs the hypervisor's test-only */ +/* continue mode, which that image must not have. And its output is */ +/* a matrix rather than a narrative. */ +/* */ +/* Everything UNDER them is shared byte for byte: the same loader, the */ +/* same frame driver, the same guest image, the same linker script and */ +/* the same two windows. A regression built on a different foundation */ +/* from the demonstration would be a regression for a different */ +/* hypervisor. */ +/* */ +/* FIVE WAYS A PASSING ISOLATION TEST IS WORTHLESS, AND WHAT IS DONE */ +/* ABOUT EACH */ +/* */ +/* Every one of these was paid for during the Cortex-R52 Modules port */ +/* work, and every one of them is a way this file could be green and */ +/* mean nothing. */ +/* */ +/* 1. A TEST THAT HAS NEVER BEEN SEEN TO FAIL IS NOT EVIDENCE. */ +/* Every case has a build that breaks it deliberately and must */ +/* report FAILED -- see the negative builds at the foot of this */ +/* file's CMake entry. A check whose pass condition is the */ +/* ABSENCE of something is worthless without one. */ +/* */ +/* 2. THE UNGRANTED ADDRESS MUST BE ADJACENT TO A GRANTED ONE. An */ +/* address far from every grant proves only that unmapped memory */ +/* faults. The defect class worth catching is a region limit out */ +/* by ONE GRANULE, so each partition is aimed at the sixty-four */ +/* bytes immediately after its own window. */ +/* */ +/* 3. A WRITE THAT IS NEVER READ BACK PROVES ONLY THAT THE CORE DID */ +/* NOT OBJECT. A region programmed with the wrong BASE accepts */ +/* every store and puts it somewhere else. So each granted */ +/* granule takes a DISTINCT mark and every one of them is read */ +/* back -- by the guest, and independently by the hypervisor. */ +/* */ +/* 4. READ A PARTITION'S MEMORY WHILE IT STILL OWNS IT. A */ +/* post-mortem read attributes the last writer's value to */ +/* everyone who shared the address. The two windows here are */ +/* disjoint so aliasing cannot arise -- and the marks are still */ +/* read at a synchronisation point where neither partition is */ +/* running, with each partition's own sentinel folded into its */ +/* marks so that attribution does not rest on the address alone. */ +/* */ +/* 5. RECORD WHAT THE LOADER DECIDED IMMEDIATELY, BEFORE ANYTHING */ +/* THAT CAN FAIL. Otherwise a failed setup leaves the fields */ +/* zero and the end-of-run consistency check reports a second, */ +/* FALSE failure pointing at the wrong thing. Every address this */ +/* file hands a partition is recorded in its own table first. */ +/* */ +/* WHAT THIS DOES NOT PROVE */ +/* */ +/* Printed in the run's own output as well, because the sentence is */ +/* what a stranger reads without context and it costs nothing: */ +/* */ +/* This demonstrates memory isolation between two ThreadX partitions */ +/* and temporal determinism on ONE LOGICAL CORE (dual-core lockstep */ +/* presents as one core). It does NOT demonstrate spatial */ +/* partitioning across multiple cores, which requires split-mode SMP */ +/* and is deferred. */ +/* */ +/**************************************************************************/ + +#include "zx_probe.h" +#include "zx_platform.h" +#include "zx_guest_abi.h" +#include "zx_guest_launch.h" +#include "zx_manifest.h" +#include "zx_mm.h" +#include "zx_partition.h" +#include "zx_schedule.h" +#include "zx_guest_console.h" +#include "zx_frame_setup.h" + +/* WHETHER THIS IMAGE ASKS FOR THE TEST-ONLY CONTINUE MODE. + * + * DEFINED ON THE IMAGE AND NOT ON THE HYPERVISOR, and that is a discovered + * constraint rather than a preference. ZoneX is a library built once per + * build tree and linked into every image in it, so a compile definition on + * one image's target does not reach the hypervisor's own translation units + * at all -- which was the first version of this seam, and it failed in the + * direction that reads as a pass: the option was set on this target, the + * library was built without it, and the run reported every partition + * STOPPED while its banner claimed continue mode. + * + * So the definition lives here, where every other build variant in this + * suite lives, and the value is handed to the hypervisor once at start-up. + * The default the hypervisor holds without being told anything is HALT, + * because the variable is in .bss. */ + +#ifdef ZX_TEST_FAULT_CONTINUE +#define ZX_REGRESSION_CONTINUE 1U +#else +#define ZX_REGRESSION_CONTINUE 0U +#endif + +/* The same static assertions the demonstration image makes. Repeated + rather than shared because they are about THIS translation unit being + able to see both spellings of the contract, and a header that asserted + them would assert them wherever it was included and nowhere usefully. */ + +_Static_assert(ZX_HVC_GUEST_YIELD == (int)ZX_HVC_YIELD, + "the guest and the hypervisor disagree about the yield HVC"); +_Static_assert(ZX_HVC_GUEST_PUTC == (int)ZX_HVC_PUTC, + "the guest and the hypervisor disagree about the console HVC"); +_Static_assert(ZX_GUEST_TIMER_INTID == (int)ZX_PPI_VIRTUAL_TIMER, + "the guest acknowledges a different INTID from the one ZoneX " + "enables for it"); + +extern char __zx_guest_a_blob_start[]; +extern char __zx_guest_a_blob_end[]; +extern char __zx_guest_b_blob_start[]; +extern char __zx_guest_b_blob_end[]; +extern char __zx_partition_a_start[]; +extern char __zx_partition_a_end[]; +extern char __zx_partition_a_hole_start[]; +extern char __zx_partition_b_start[]; +extern char __zx_partition_b_end[]; +extern char __zx_partition_b_hole_start[]; + +#define ZX_PART_A 0U +#define ZX_PART_B 1U +#define ZX_PART_COUNT 2U + +#define ZX_PARTITION_A_ID 1U +#define ZX_PARTITION_B_ID 2U + +#define ZX_GUEST_A_SENTINEL 0xA5A50011U +#define ZX_GUEST_B_SENTINEL 0xA5A50022U + +/* The same major frame as the demonstration image, and the same asymmetry + for the same reason: seven and three rather than five and five, so that + no ratio in the run comes out as one half by accident. */ + +#define ZX_TICK_HZ 100U +#define ZX_WINDOW_A_TICKS 7UL +#define ZX_WINDOW_B_TICKS 3UL +#define ZX_FRAME_TICKS (ZX_WINDOW_A_TICKS + ZX_WINDOW_B_TICKS) + +/* SIX PHASES OF TEN FRAMES, and both numbers are answers to questions the + * shorter demonstration did not have to ask. + * + * TEN FRAMES PER PHASE, because A is entered once per frame and a maximum + * taken over three periods is not a maximum. Nine periods per phase is not + * a large sample either -- it is the smallest one where the spread stops + * being a coin toss, and it is checked rather than assumed: a build with ten + * times the frame count exists and is run once on silicon, so that "does + * the maximum stop moving" is answered by measurement instead of by + * assertion. It is not in the automated suite, because sixty seconds of + * silicon is cheap and ten minutes of a functional model is not. + * + * SIX PHASES, because there are five behaviours worth measuring and the + * FIRST TEN FRAMES ARE DISCARDED. Partition B is still booting its kernel, + * running its threads and reaching its verdict during them, so a "baseline" + * that included them would be a baseline of a partition doing real work -- + * and every later phase would then look artificially good. Discarding a + * warm-up is the same discipline the switch measurement already uses, and + * the discarded phase is still REPORTED so that a reader can see what was + * thrown away rather than take it on trust. */ + +#define ZX_PHASE_FRAMES 10UL +#define ZX_PHASE_COUNT 6U +#define ZX_FRAME_LIMIT (ZX_PHASE_FRAMES * (ULONG)ZX_PHASE_COUNT) + +/* Which phase is which. Phase 0 is the warm-up and phase 1 is the baseline + every other phase is compared against. */ + +#define ZX_PHASE_WARMUP 0U +#define ZX_PHASE_BASELINE 1U + +#ifdef ZX_REGRESSION_LONG + +/* The ten-times-longer build. One question, asked once: does A's maximum + period stop moving? A regression that asserted a bound over sixty frames + without ever having looked at six hundred would be asserting that its own + sample was large enough, which is the thing in question. */ + +#undef ZX_PHASE_FRAMES +#define ZX_PHASE_FRAMES 100UL + +#endif + +#define ZX_GUEST_TIMER_PRIORITY 0xA0U + +/* HOW MANY GRANULES EACH PARTITION MARKS, and where they are. + * + * THE LAST EIGHT GRANULES OF THE WINDOW, so that mark seven lands on the + * very last granule a partition owns -- which is the one immediately before + * the ungranted hole. That pairing is the whole test: the mark says the + * limit reaches the end of the window, and the poison in the hole next door + * says it reaches no further. A limit out by one granule fails exactly one + * of the two, and which one says whether it was short or long. + * + * EIGHT rather than two, because the marks also have to show that the + * extent between the ends was programmed as one region rather than as + * something that happens to cover both ends. Eight distinct values at + * eight distinct addresses is cheap -- eight stores and eight loads -- and + * it is read back twice, once by the guest and once by the hypervisor. + * + * The top of the window is free by a wide margin: a guest links to roughly a + * tenth of its 256 KB. The guest checks that independently and refuses a + * range it does not own, because the hypervisor computing this wrongly would + * otherwise have a partition scribble over its own kernel. */ + +#define ZX_MARK_GRANULES 8U + +/* WHERE INSIDE THE NEIGHBOUR'S WINDOW THE DATA CASES AIM. + * + * Well past its mailbox and inside its CODE, so that a run in which the + * write succeeded would be visibly catastrophic rather than quietly wrong. + * Nothing is written on a correct run; the point of choosing a violent + * target is that an incorrect run cannot look like a correct one. */ + +#define ZX_NEIGHBOUR_OFFSET 0x1000U + +static ZX_REGION zx_regions_a[1]; +static ZX_REGION zx_regions_b[1]; +static ZX_PARTITION zx_partitions[ZX_PART_COUNT]; +static ZX_MANIFEST zx_manifest; +static ZX_MM_LAYOUT zx_layout; +static ZX_MANIFEST_ENV zx_env; +static ZX_PARTITION_CB zx_partition_cb[ZX_PART_COUNT]; +static ZX_REGION zx_mmio[ZX_BOARD_MAX_MMIO_REGIONS]; +static ZX_GIC_LAYOUT zx_gic; +static ZX_GUEST_LAUNCH zx_launch[ZX_PART_COUNT]; +static ZX_SCHEDULE zx_schedule; +static ZX_FRAME zx_frame; +static ZX_GUEST_CONTEXT zx_context[ZX_PART_COUNT]; + +/* WHERE EVERY VIOLATION THIS RUN PROVOKES IS KEPT. The single fault record + holds the LAST capture, which is right for a hypervisor that stops on the + first violation and useless for a sweep of fourteen: each would overwrite + the evidence of the one before. See core/inc/zx_fault_log.h, including + why it keeps its FIRST entries rather than its last. */ + +static ZX_FAULT_LOG zx_fault_log; + +/* WHAT EACH PARTITION WAS ASKED TO REACH FOR, recorded before anything can + fail. This is the fifth invariant in the file header made concrete: the + end-of-run judgement searches the fault log by these addresses, so if the + setup had failed halfway they would be zero -- and every case would then + report "no fault at address zero", which is a second failure pointing at + the wrong thing. Filled in one place, read in one place. */ + +static ZX_GUEST_MATRIX zx_matrix[ZX_PART_COUNT]; + +/* Each partition's period, per phase, snapshotted at the phase boundary and + reset there. The context accumulates; this remembers. */ + +typedef struct ZX_PHASE_RECORD_STRUCT +{ + uint64_t zx_phase_min; + uint64_t zx_phase_mean; + uint64_t zx_phase_max; + uint32_t zx_phase_samples; + + /* WHAT THE GUEST SAW OF ITS OWN CLOCK OVER THE SAME SPAN, against what + the hypervisor knows it was given. This is the other half of the + temporal claim and it cannot be derived from the period: a partition + whose virtual clock the hypervisor was mismanaging would see a + perfectly steady window period through a clock running at the wrong + rate. + * + THE COUNTER AND NOT THE TICK COUNT, and that distinction was found by + measurement rather than reasoned out. A guest's SERVICED tick count + is insensitive to the freeze: the virtual timer asserts a LEVEL, so + several expiries while the partition is descheduled coalesce into one + interrupt, and the number a guest services is bounded by the core + time it was given whether or not its clock was frozen. The build + with the freeze removed had a partition whose own clock ran 42% ahead + of the time it had been given -- and its tick count was identical to + the correct run's, in every phase. + * + Its own COUNTER is what moves. Both numbers are recorded: the + counter is what the check rests on, and the tick count is printed + beside it because it is what a reader recognises. + * + LOW HALVES, SUBTRACTED IN 32 BITS. The guest publishes the low half + of its virtual count -- see zx_guest_abi.h on why -- and a difference + of two low halves is correct across one wrap, which a run long enough + to wrap will produce. */ + + uint32_t zx_phase_own_ticks; + uint32_t zx_phase_own_counts; + uint32_t zx_phase_core_counts; + + /* And how many times the untrusted partition violated its boundary + during the phase, so that the fault phase can be shown to have + HAPPENED before "and its neighbour did not notice" means anything. */ + + uint32_t zx_phase_violations; + +} ZX_PHASE_RECORD; + +static ZX_PHASE_RECORD zx_phase[ZX_PHASE_COUNT]; +static uint32_t zx_phase_index; +static uint32_t zx_phase_ticks_at_start; +static uint32_t zx_phase_vct_at_start; +static uint32_t zx_phase_core_at_start; +static uint32_t zx_phase_violations_at_start; + +/* What partition B is asked to do in each phase, in order. A table rather + than a switch, so that the shape of the experiment is one line and a + reader can see that the baseline and the warm-up ask for the same thing + -- which is what makes discarding the warm-up a matter of arithmetic + rather than of a difference in what B was doing. */ + +static const uint32_t zx_phase_behaviour[ZX_PHASE_COUNT] = +{ + ZX_GB_QUIET, /* 0 warm-up: B is still booting. DISCARDED. */ + ZX_GB_QUIET, /* 1 the baseline every other phase is against */ + ZX_GB_SPIN, /* 2 a tight compute loop that never yields */ + ZX_GB_MASKED, /* 3 the same, with IRQ and FIQ masked at EL1 */ + ZX_GB_STORM, /* 4 one hypercall per character, for ever */ + ZX_GB_FAULT /* 5 a stage-2 violation every iteration */ +}; + + +/**************************************************************************/ +/* zx_describe_launches */ +/**************************************************************************/ + +static void zx_describe_launches(void) +{ + zx_launch[ZX_PART_A].zx_launch_window_base = + zx_symbol_address(__zx_partition_a_start); + zx_launch[ZX_PART_A].zx_launch_window_end = + zx_symbol_address(__zx_partition_a_end); + zx_launch[ZX_PART_A].zx_launch_image_start = + zx_symbol_address(__zx_guest_a_blob_start); + zx_launch[ZX_PART_A].zx_launch_image_end = + zx_symbol_address(__zx_guest_a_blob_end); + zx_launch[ZX_PART_A].zx_launch_sentinel = ZX_GUEST_A_SENTINEL; + zx_launch[ZX_PART_A].zx_launch_partition_id = ZX_PARTITION_A_ID; + zx_launch[ZX_PART_A].zx_launch_partition_name = + zx_partitions[ZX_PART_A].zx_partition_name; + + zx_launch[ZX_PART_B].zx_launch_window_base = + zx_symbol_address(__zx_partition_b_start); + zx_launch[ZX_PART_B].zx_launch_window_end = + zx_symbol_address(__zx_partition_b_end); + zx_launch[ZX_PART_B].zx_launch_image_start = + zx_symbol_address(__zx_guest_b_blob_start); + zx_launch[ZX_PART_B].zx_launch_image_end = + zx_symbol_address(__zx_guest_b_blob_end); + zx_launch[ZX_PART_B].zx_launch_sentinel = ZX_GUEST_B_SENTINEL; + zx_launch[ZX_PART_B].zx_launch_partition_id = ZX_PARTITION_B_ID; + zx_launch[ZX_PART_B].zx_launch_partition_name = + zx_partitions[ZX_PART_B].zx_partition_name; + + /* zx_launch_freeze_time governs the SINGLE-EXCURSION path -- the one + zx_guest_run takes -- and not the frame. Under a major frame the + freeze is the context's, applied by the switch on every entry, so + this image sets both and the negative build clears the field that + actually governs it. Setting it here anyway keeps the launch + structure fully described rather than half-filled: see + zx_guest_hand_over on why every field is written. */ + + zx_launch[ZX_PART_A].zx_launch_freeze_time = 1U; + zx_launch[ZX_PART_B].zx_launch_freeze_time = 1U; +} + + +/**************************************************************************/ +/* zx_build_manifest */ +/* */ +/* TWO PARTITIONS, ONE REGION EACH, NO DEVICE AND NO SHARED GRANULE -- */ +/* the same manifest the demonstration image declares, deliberately. */ +/* */ +/* THE SHARED READ-ONLY GRANULE IS NOT HERE, and that is a decision */ +/* rather than an omission. A shared granule is a DECLARED EXCEPTION to */ +/* isolation: one range readable by both partitions and writable by one. */ +/* Its two halves are worth demonstrating -- the reader can read it, and */ +/* the reader CANNOT write it, which is a permission failure rather than */ +/* a region miss and a different syndrome says so -- and they ARE */ +/* demonstrated, by the stage-2 probe image, which declares one and shows */ +/* both. */ +/* */ +/* Putting one here as well would cost this file's central claim its */ +/* simplicity for no new evidence. Every temporal number below is */ +/* currently a number about two partitions that share NOTHING but a core; */ +/* with a shared granule it would be a number about two partitions that */ +/* share a core and sixty-four bytes, and a reader asking "could the */ +/* shared line be how B reached A" would be entitled to an answer this */ +/* run could not give. The exception is demonstrated where it is cheap */ +/* and asserted where it is sharp; it is not smuggled into the image */ +/* whose job is to say the two partitions are disjoint. */ +/**************************************************************************/ + +static void zx_build_manifest(uint32_t board_regions) +{ + zx_regions_a[0].zx_region_base = + zx_symbol_address(__zx_partition_a_start); + zx_regions_a[0].zx_region_limit = + zx_symbol_address(__zx_partition_a_end) - 1U; + zx_regions_a[0].zx_region_ap = ZX_AP_EL2_RW_GUEST_RW; + zx_regions_a[0].zx_region_xn = ZX_XN_EXECUTABLE; + zx_regions_a[0].zx_region_sh = ZX_SH_NON_SHAREABLE; + zx_regions_a[0].zx_region_attr_index = (UCHAR)ZX_ATTR_NORMAL_WB; + + zx_regions_b[0].zx_region_base = + zx_symbol_address(__zx_partition_b_start); + zx_regions_b[0].zx_region_limit = + zx_symbol_address(__zx_partition_b_end) - 1U; + zx_regions_b[0].zx_region_ap = ZX_AP_EL2_RW_GUEST_RW; + zx_regions_b[0].zx_region_xn = ZX_XN_EXECUTABLE; + zx_regions_b[0].zx_region_sh = ZX_SH_NON_SHAREABLE; + zx_regions_b[0].zx_region_attr_index = (UCHAR)ZX_ATTR_NORMAL_WB; + +#ifdef ZX_REGRESSION_WIDENED + + /* THE NEGATIVE VERIFICATION OF THE ADJACENT-GRANULE CASE, and the one + that matters most in this file. + * + Partition A's limit is stretched by one granule, which swallows the + ungranted sixty-four bytes immediately after its window. Case 4's + write must then SURVIVE, the poison in that granule must be gone, and + the run must FAIL by name. + * + WITHOUT THIS BUILD, A GREEN RUN OF CASE 4 SHOWS ONLY THAT THE HOLE'S + ADDRESS FAULTS. It does not show that the limit is EXACT, which is + the claim -- and a limit one granule too generous is the defect class + that otherwise reads as a working system, because everything a + partition legitimately touches still works. This is the single most + valuable negative build in the set for exactly that reason: it is the + one whose absence would leave a plausible defect undetectable. + * + ONE GRANULE AND NOT A KILOBYTE. A wildly wrong limit would be caught + by the manifest validator's own overlap rule, so it would test the + validator rather than the region programming. One granule is inside + the hole and outside partition B, so nothing refuses it and the only + thing that can catch it is the access itself. */ + + zx_regions_a[0].zx_region_limit += 64U; + +#endif + + zx_partitions[ZX_PART_A].zx_partition_id = ZX_PARTITION_A_ID; + zx_partitions[ZX_PART_A].zx_partition_name = "A (critical)"; + zx_partitions[ZX_PART_A].zx_partition_image_start = + zx_symbol_address(__zx_guest_a_blob_start); + zx_partitions[ZX_PART_A].zx_partition_image_end = + zx_symbol_address(__zx_guest_a_blob_end); + zx_partitions[ZX_PART_A].zx_partition_entry = + zx_symbol_address(__zx_partition_a_start) + ZX_GUEST_IMAGE_OFF_ENTRY; + zx_partitions[ZX_PART_A].zx_partition_regions = zx_regions_a; + zx_partitions[ZX_PART_A].zx_partition_region_count = 1U; + zx_partitions[ZX_PART_A].zx_partition_window_ticks = ZX_WINDOW_A_TICKS; + + zx_partitions[ZX_PART_B].zx_partition_id = ZX_PARTITION_B_ID; + zx_partitions[ZX_PART_B].zx_partition_name = "B (untrusted)"; + zx_partitions[ZX_PART_B].zx_partition_image_start = + zx_symbol_address(__zx_guest_b_blob_start); + zx_partitions[ZX_PART_B].zx_partition_image_end = + zx_symbol_address(__zx_guest_b_blob_end); + zx_partitions[ZX_PART_B].zx_partition_entry = + zx_symbol_address(__zx_partition_b_start) + ZX_GUEST_IMAGE_OFF_ENTRY; + zx_partitions[ZX_PART_B].zx_partition_regions = zx_regions_b; + zx_partitions[ZX_PART_B].zx_partition_region_count = 1U; + zx_partitions[ZX_PART_B].zx_partition_window_ticks = ZX_WINDOW_B_TICKS; + + zx_manifest.zx_manifest_partitions = zx_partitions; + zx_manifest.zx_manifest_partition_count = ZX_PART_COUNT; + zx_manifest.zx_manifest_shared = (const ZX_SHARED *)0; + zx_manifest.zx_manifest_shared_count = 0U; + zx_manifest.zx_manifest_major_frame_ticks = ZX_FRAME_TICKS; + + zx_board_describe_mmio_regions(zx_mmio); + + zx_env.zx_env_mmio_regions = zx_mmio; + zx_env.zx_env_mmio_region_count = board_regions; + zx_env.zx_env_attr_written_mask = 0x07U; +} + + +/**************************************************************************/ +/* zx_describe_matrix -- what each partition is asked to reach for. */ +/* */ +/* FILLED BEFORE ANYTHING IS PROGRAMMED, and this is the fifth invariant */ +/* in the file header made concrete. The end-of-run judgement searches */ +/* the fault log BY THESE ADDRESSES; if the setup failed halfway they */ +/* would be zero, every case would report "no fault at address zero", and */ +/* the log would be full of failures pointing at a table rather than at */ +/* the thing that broke. Recorded first, and read from one place. */ +/* */ +/* THE HOLE IS EACH PARTITION'S OWN, and that is the difference between */ +/* this case and case 1. A's hole is the granule immediately AFTER A's */ +/* window; B's is the granule immediately after B's. Handing both */ +/* partitions the same hole would leave one of them testing an address */ +/* that is not adjacent to anything it owns, which is a test of unmapped */ +/* memory again. */ +/* */ +/* AND THE MMIO ADDRESS IS THE GIC DISTRIBUTOR, on both boards. The */ +/* console would do as well on the S32Z280 and not on the model, whose */ +/* console is semihosting and has no address at all; the GIC exists at a */ +/* real address on both. It is also the more pointed target of the two: */ +/* a partition that could write GICD could disable the interrupt that */ +/* ends its own window, which is the one privilege this whole design is */ +/* built to withhold. */ +/**************************************************************************/ + +static void zx_describe_matrix(void) +{ + uint32_t a_base = (uint32_t)zx_symbol_address(__zx_partition_a_start); + uint32_t b_base = (uint32_t)zx_symbol_address(__zx_partition_b_start); + uint32_t a_end = (uint32_t)zx_symbol_address(__zx_partition_a_end); + uint32_t b_end = (uint32_t)zx_symbol_address(__zx_partition_b_end); + uint32_t cases = (uint32_t)ZX_MC_ALL; + +#ifdef ZX_REGRESSION_HALT_ONLY + + /* THE SHIPPING FAULT POLICY, TESTED RATHER THAN ASSUMED. + * + * This build has no continue mode, so the FIRST case a partition + * attempts ends its excursion: the partition is stopped, its windows + * are still spent, and the run reports which partition and which + * address. Asking for the whole matrix here would be asking for + * thirteen cases that cannot run, and the difference between "denied" + * and "never attempted" would then be the difference between a green + * run and a red one for a reason that has nothing to do with + * isolation. + * + * So it asks for ONE case, and the one it asks for is the neighbour's + * data -- the case a reader recognises without explanation. What this + * build proves is that halt-on-fault is what a shipping build does, + * on both targets, which is the constraint that makes the continue + * mode acceptable at all. */ + + cases = (uint32_t)ZX_MC_NEIGHBOUR_READ; + +#endif + + /* PARTITION A REACHES FOR B, and B reaches for A. Both directions, + because a region-set bug is very often asymmetric: a set programmed + at the wrong index denies one partition everything and grants its + neighbour more than it should have, and a one-directional sweep sees + half of that as a pass. */ + + zx_matrix[ZX_PART_A].zx_matrix_cases = cases; + zx_matrix[ZX_PART_A].zx_matrix_neighbour = b_base + + ZX_NEIGHBOUR_OFFSET; + zx_matrix[ZX_PART_A].zx_matrix_neighbour_code = b_base + + ZX_GUEST_IMAGE_OFF_ENTRY; + zx_matrix[ZX_PART_A].zx_matrix_hole = + (uint32_t)zx_symbol_address(__zx_partition_a_hole_start); + zx_matrix[ZX_PART_A].zx_matrix_mark_base = + a_end - ((uint32_t)ZX_MARK_GRANULES * 64U); + zx_matrix[ZX_PART_A].zx_matrix_mark_count = ZX_MARK_GRANULES; + + zx_matrix[ZX_PART_B].zx_matrix_cases = cases; + zx_matrix[ZX_PART_B].zx_matrix_neighbour = a_base + + ZX_NEIGHBOUR_OFFSET; + zx_matrix[ZX_PART_B].zx_matrix_neighbour_code = a_base + + ZX_GUEST_IMAGE_OFF_ENTRY; + zx_matrix[ZX_PART_B].zx_matrix_hole = + (uint32_t)zx_symbol_address(__zx_partition_b_hole_start); + zx_matrix[ZX_PART_B].zx_matrix_mark_base = + b_end - ((uint32_t)ZX_MARK_GRANULES * 64U); + zx_matrix[ZX_PART_B].zx_matrix_mark_count = ZX_MARK_GRANULES; + + /* THE HYPERVISOR'S OWN MEMORY IS THE SAME ADDRESS FOR BOTH, and it is + the MANIFEST. Not the fault log: a write that succeeded there would + corrupt the evidence the run is judged from, which is the one place a + violation must not be able to reach even in a broken build. The + manifest is the right target for the opposite reason -- it is + read-only to the whole system after boot, so a successful write is + both detectable and obviously catastrophic. */ + + { + UINT part; + + for (part = 0U; part < ZX_PART_COUNT; part++) + { + zx_matrix[part].zx_matrix_hyp_data = + (uint32_t)(uintptr_t)&zx_manifest; + zx_matrix[part].zx_matrix_hyp_mmio = + (uint32_t)zx_gic.zx_gic_dist_base; + } + } +} + + +/**************************************************************************/ +/* zx_matrix_resume_point -- where a refused BRANCH puts a partition. */ +/* */ +/* THE HOOK THE HYPERVISOR'S CONTINUE PATH ASKS THROUGH. A prefetch */ +/* abort cannot be stepped over -- the fetch is what failed, so there is */ +/* no instruction whose length says where the next one starts -- so the */ +/* guest publishes where it wants to be put back, before it branches, and */ +/* this reads that word. */ +/* */ +/* IT LIVES HERE AND NOT IN THE PORT because a mailbox layout is an */ +/* example's business. The hypervisor's business is that an address it */ +/* was not given means the partition is stopped, which is what returning */ +/* zero says. */ +/* */ +/* AND THE WORD IS CLEARED AS IT IS READ. A resume address left standing */ +/* would be used again by the NEXT prefetch abort that partition took, */ +/* which would put it back at a point it had already left -- an infinite */ +/* loop wearing the costume of a successful test. One use per */ +/* publication is the contract, and clearing it here is what enforces it. */ +/**************************************************************************/ + +static uint32_t zx_matrix_resume_point(UINT partition) +{ + uint32_t resume; + + if (partition >= ZX_PART_COUNT) + { + return 0U; + } + + resume = zx_guest_mailbox_read(&zx_launch[partition], ZX_GD_M_RESUME); + + if (resume != 0U) + { + zx_guest_mailbox_write(&zx_launch[partition], ZX_GD_M_RESUME, 0U); + } + + return resume; +} + + +/**************************************************************************/ +/* The ungranted granules, poisoned before the run and read after it. */ +/* */ +/* POISONED BY THE HYPERVISOR, which can write them because stage-2 AP */ +/* cannot deny EL2 -- the same property that stops AP from isolating */ +/* partitions is what gives the regression its readback channel, and it */ +/* is why the isolation claim is between PARTITIONS and never against */ +/* the hypervisor. */ +/* */ +/* A VALUE NO MARK CAN TAKE AND NO ZEROED WINDOW CAN HOLD. A hole */ +/* reading as zero after the run is indistinguishable from a hole nobody */ +/* looked at, and a hole holding a MARK says a region limit is one */ +/* granule too wide -- which the run must fail by name rather than */ +/* silently. */ +/**************************************************************************/ + +static volatile uint32_t *zx_hole_word(UINT partition) +{ + return (volatile uint32_t *)(uintptr_t)zx_matrix[partition].zx_matrix_hole; +} + + +static void zx_poison_the_holes(void) +{ + UINT part; + + for (part = 0U; part < ZX_PART_COUNT; part++) + { + *zx_hole_word(part) = ZX_HOLE_POISON; + } + + __asm__ volatile("dsb" ::: "memory"); +} + + +/**************************************************************************/ +/* zx_phase_hook -- called once per completed major frame. */ +/* */ +/* WHAT IT DOES: closes the phase that just ended, opens the next one, */ +/* and tells partition B what to do in it. */ +/* */ +/* IT IS CALLED OUTSIDE THE SWITCH'S TIMED BRACKET, which is a */ +/* requirement and not a courtesy: a hook inside it would add its own */ +/* cost to every switch figure this repository has published, and the */ +/* whole point of measuring a switch is that the number does not depend */ +/* on what the image happens to be doing around it. */ +/* */ +/* ONCE PER FRAME AND NOT PER WINDOW. A phase boundary in the middle of */ +/* a frame would mean the frame straddling it was measured half under one */ +/* behaviour and half under another, and that frame's period is exactly */ +/* the one a reader looks at first. */ +/* */ +/* AND IT CANNOT CHANGE WHAT RUNS NEXT. It writes a mailbox word and */ +/* snapshots some counters. A hook that could reorder the schedule would */ +/* make the determinism claim a claim about a schedule this file was */ +/* editing while measuring it. */ +/**************************************************************************/ + +static void zx_close_phase(uint32_t index) +{ + const ZX_GUEST_CONTEXT *a = &zx_context[ZX_PART_A]; + uint32_t ticks; + + if (index >= ZX_PHASE_COUNT) + { + return; + } + + zx_phase[index].zx_phase_min = a->zx_ctx_period_min; + zx_phase[index].zx_phase_max = a->zx_ctx_period_max; + zx_phase[index].zx_phase_mean = zx_context_period_mean(a); + zx_phase[index].zx_phase_samples = a->zx_ctx_period_samples; + + /* A'S OWN CLOCK OVER THE PHASE, against the core time it was given. + The guest-side half of the claim, and the pair is what makes it a + claim: the hypervisor knows how much of the physical counter this + partition spent on the core, and only the guest can say what its OWN + counter did over the same span. See the note on the fields. */ + + { + uint32_t vct = zx_guest_mailbox_read(&zx_launch[ZX_PART_A], + ZX_GD_VCT_NOW); + uint32_t core = (uint32_t)(a->zx_ctx_time_on_core & 0xFFFFFFFFU); + + zx_phase[index].zx_phase_own_counts = vct - zx_phase_vct_at_start; + zx_phase[index].zx_phase_core_counts = core - zx_phase_core_at_start; + + zx_phase_vct_at_start = vct; + zx_phase_core_at_start = core; + } + + ticks = zx_guest_mailbox_read(&zx_launch[ZX_PART_A], ZX_GD_TICKS_NOW); + zx_phase[index].zx_phase_own_ticks = ticks - zx_phase_ticks_at_start; + zx_phase_ticks_at_start = ticks; + + /* And how hard B was actually misbehaving, so that a phase can be shown + to have HAPPENED. "A did not notice B faulting" is worth nothing if + B never faulted. */ + + zx_phase[index].zx_phase_violations = + zx_context[ZX_PART_B].zx_ctx_violations - zx_phase_violations_at_start; + zx_phase_violations_at_start = zx_context[ZX_PART_B].zx_ctx_violations; +} + + +static void zx_phase_hook(void *argument, ULONG frames_completed) +{ + uint32_t next; + + (void)argument; + + next = (uint32_t)(frames_completed / ZX_PHASE_FRAMES); + + if ((next == zx_phase_index) || (next >= ZX_PHASE_COUNT)) + { + return; + } + + zx_close_phase(zx_phase_index); + + /* THE PERIOD STATISTICS ARE RESET, AND SO IS THE LAST TIMESTAMP, so + that every sample a phase records lies entirely inside it. + * + AN ABSOLUTE-DEADLINE SCHEDULE ANSWERS ONE LATE ENTRY WITH ONE SHORT + CORRECTION -- that is what the absolute deadline is for -- so one + perturbation produces two samples: a long one caused by the + neighbour's behaviour, and a short one caused by the frame putting + itself right. Carrying the timestamp across a phase boundary put the + correction in the NEXT phase, which then inherited a minimum from its + predecessor's worst moment. Measured on silicon before the fix: a + console-storm phase 14,451 counts long at its worst, and the + following phase reporting a minimum 16,020 counts short. It costs + one sample per phase, which is the honest price of attribution. */ + + zx_context_period_reset(&zx_context[ZX_PART_A]); + zx_context_period_reset(&zx_context[ZX_PART_B]); + + zx_phase_index = next; + + zx_guest_set_behaviour(&zx_launch[ZX_PART_B], zx_phase_behaviour[next]); +} + + +/**************************************************************************/ +/* zx_prepare_partition -- validate, load and hand over one guest. */ +/**************************************************************************/ + +static uint32_t zx_prepare_partition(UINT index, uint32_t options) +{ + UINT status; + + zx_partition_reset(&zx_partition_cb[index], &zx_partitions[index]); + + status = zx_partition_prepare(&zx_partition_cb[index]); + + if (status != ZX_MANIFEST_SUCCESS) + { + zx_note("zx_partition_prepare", status); + zx_check("the loader located this partition's window and the image\n" + " fits it", 0U); + zx_partition_report(&zx_partition_cb[index]); + + return 0U; + } + + if (zx_guest_image_check(&zx_launch[index]) == 0U) + { + zx_console_puts("\n *** REFUSING to launch this image. It is either\n" + " *** absent, truncated, built for a different\n" + " *** window, or built against a different version\n" + " *** of the guest contract.\n"); + + return 0U; + } + + zx_guest_image_load(&zx_partition_cb[index].zx_partition_load); + + /* NO SINGLE PROBE TARGET. The sweep and the single probe are + alternatives at the guest end -- the probe ENDS the excursion when + stage 2 refuses it, which is the shipping policy and the wrong shape + for a matrix -- so this image passes zero and asks for the sweep + instead. */ + + zx_guest_hand_over(&zx_launch[index], 0U, options); + zx_guest_ask_for_matrix(&zx_launch[index], &zx_matrix[index]); + zx_partition_loaded(&zx_partition_cb[index]); + + zx_check("the partition may be entered now that its image is loaded", + zx_partition_enter(&zx_partition_cb[index])); + + zx_context_init(&zx_context[index], + zx_partition_cb[index].zx_partition_load.zx_load_entry); + zx_context_time_reset(&zx_context[index]); + +#ifdef ZX_REGRESSION_NO_FREEZE + + /* THE NEGATIVE VERIFICATION OF THE TEMPORAL CLAIM, and it has to be + * done HERE, after zx_context_time_reset has switched the freeze on. + * + * With the field clear a partition is NOT credited the interval it + * spent descheduled, so its virtual counter runs at wall clock and its + * own clock advances by time it did not have. Its tick count then + * depends on how long its NEIGHBOUR spent on the core, which is + * precisely the coupling temporal partitioning is bought to remove. + * + * IT BREAKS THE GUEST-SIDE HALF AND LEAVES THE HYPERVISOR-SIDE HALF + * INTACT, which is exactly why it is worth a build of its own. A's + * window PERIOD is still one frame -- the schedule is untouched, and + * the schedule is what the period measures -- so a regression that + * measured only the period would stay green while every partition's + * clock lied. What moves is A's own ticks per phase, and this build + * is the proof that the check on them is capable of failing. + * + * AN EARLIER VERSION OF THIS BUILD CLEARED zx_launch_freeze_time + * INSTEAD, and it was a no-op: that field governs the + * single-excursion path and the frame does not take it. The build + * passed, which for a negative build is the failure -- and it was the + * runner's "--expect fail" that caught it rather than anything in this + * file, which is the entire argument for registering negative builds + * that way. */ + + zx_context[index].zx_ctx_credit_time = 0U; + +#endif + + return 1U; +} + + +/**************************************************************************/ +/* zx_report_matrix_row -- judge ONE case, and say what judged it. */ +/* */ +/* THE JUDGEMENT IS THE HYPERVISOR'S AND THE ATTEMPT IS THE GUEST'S, and */ +/* both halves are required for a row to mean anything. */ +/* */ +/* The guest cannot report having been DENIED. Under the continue mode */ +/* it is resumed past the faulting instruction, so the store that would */ +/* have recorded "this survived" runs anyway. Only the fault log knows. */ +/* */ +/* And the hypervisor cannot report having been ATTEMPTED. A case the */ +/* guest refused to run and a case stage 2 denied both leave EL2 with */ +/* no fault to look at -- so a row judged only from the log would count */ +/* a case that never happened as a pass, which is the single most likely */ +/* way a sweep goes quietly wrong. */ +/* */ +/* So a row passes when the guest says it tried AND the log says it was */ +/* refused AND the syndrome is the right KIND of refusal. Three */ +/* conditions, from two independent sources. */ +/**************************************************************************/ + +static void zx_report_matrix_row(UINT partition, const char *what, + uint32_t address, uint32_t case_bit, + uint32_t expected_ec, uint32_t expect_write) +{ + uint32_t attempted = + zx_guest_mailbox_read(&zx_launch[partition], ZX_GD_M_ATTEMPTED); + const ZX_FAULT_LOG_ENTRY *entry = + zx_fault_log_find(&zx_fault_log, partition, address); + uint32_t ec = 0U; + uint32_t ok; + + zx_console_puts("\n partition "); + zx_console_putdec(partition); + zx_console_puts(" -> "); + zx_console_puts(what); + zx_console_puts(" at "); + zx_console_puthex(address); + zx_console_puts("\n"); + + if (entry != (const ZX_FAULT_LOG_ENTRY *)0) + { + ec = zx_fault_ec(entry->zx_log_hsr); + } + + ok = ((attempted & case_bit) != 0U) + && (entry != (const ZX_FAULT_LOG_ENTRY *)0) + && (ec == expected_ec) ? 1U : 0U; + + /* AND THE DIRECTION OF THE ACCESS, for the data cases. A region set + with the wrong AP grants reads and denies writes, or the other way + round; a row that accepted "something faulted here" would call that a + pass in whichever direction happened to be checked. ISS[6] is WnR + and it is the only thing that separates the two. */ + + if ((ok != 0U) && (expected_ec == ZX_EC_DABT_ROUTED)) + { + uint32_t was_write = zx_fault_dabt_is_write(entry->zx_log_hsr); + + ok = ((was_write != 0U) == (expect_write != 0U)) ? 1U : 0U; + } + + if (entry == (const ZX_FAULT_LOG_ENTRY *)0) + { + zx_console_puts(" NO FAULT was recorded at this address. That is\n" + " either an access that SUCCEEDED, or a case the\n" + " partition never attempted -- and the attempted\n" + " set below is what separates the two.\n"); + } + else + { + zx_console_puts(" refused: EC "); + zx_console_puthex(ec); + zx_console_puts(" = "); + zx_console_puts(zx_fault_ec_name(ec)); + + if (expected_ec == ZX_EC_DABT_ROUTED) + { + zx_console_puts(zx_fault_dabt_is_write(entry->zx_log_hsr) != 0U + ? ", on a WRITE" : ", on a READ"); + zx_console_puts(", DFSC "); + zx_console_puthex(zx_fault_dabt_dfsc(entry->zx_log_hsr)); + } + + zx_console_puts("\n"); + } + + zx_note(" the partition says it attempted", (attempted & case_bit)); + + zx_check(what, ok); +} + + +/**************************************************************************/ +/* zx_report_marks -- the case that must SUCCEED, read back by EL2. */ +/* */ +/* READ INDEPENDENTLY, and that is the point of doing it here at all. */ +/* The guest already read its own marks back and reported whether they */ +/* agreed; a hypervisor that took the guest's word for it would be */ +/* trusting the party under test to grade itself, and a guest whose */ +/* region base was wrong would read its own stores back from the same */ +/* wrong place and report agreement. Two readers at two exception */ +/* levels through two different region sets is what makes the marks */ +/* evidence. */ +/* */ +/* AND IT HAPPENS WHILE THE PARTITION STILL OWNS THE MEMORY -- after the */ +/* frame has stopped and before anything else runs. A later allocation */ +/* at the same address would make a post-mortem read attribute the last */ +/* writer's value to everyone, which cost a wrong conclusion during the */ +/* Cortex-R52 Modules port work. The two windows here are disjoint so */ +/* the aliasing cannot arise; the sentinel folded into each mark is what */ +/* answers attribution anyway, so the claim does not rest on the layout. */ +/**************************************************************************/ + +static void zx_report_marks(UINT partition) +{ + uint32_t base = zx_matrix[partition].zx_matrix_mark_base; + uint32_t count = zx_matrix[partition].zx_matrix_mark_count; + uint32_t sentinel = zx_launch[partition].zx_launch_sentinel; + uint32_t index; + uint32_t agreed = 1U; + + zx_console_puts("\n partition "); + zx_console_putdec(partition); + zx_console_puts(" -> its own window, marked and read back by ZoneX\n"); + + for (index = 0U; index < count; index++) + { + const volatile uint32_t *granule = + (const volatile uint32_t *)(uintptr_t)(base + (index * 64U)); + uint32_t expected = (uint32_t)ZX_MARK_FOR(sentinel, index); + + if (*granule != expected) + { + agreed = 0U; + + zx_console_puts(" *** granule "); + zx_console_putdec(index); + zx_console_puts(" at "); + zx_console_puthex(base + (index * 64U)); + zx_console_puts(" holds "); + zx_console_puthex(*granule); + zx_console_puts(" and should hold "); + zx_console_puthex(expected); + zx_console_puts("\n"); + } + } + + zx_note(" granules the guest says it wrote", + zx_guest_mailbox_read(&zx_launch[partition], + ZX_GD_M_MARKS_WRITTEN)); + zx_note(" and that its own readback agreed", + zx_guest_mailbox_read(&zx_launch[partition], ZX_GD_M_MARKS_OK)); + + zx_check("EVERY GRANTED GRANULE HOLDS ITS OWN DISTINCT MARK, read back\n" + " by the hypervisor rather than by the guest that wrote\n" + " them. Eight different values at eight different\n" + " addresses is what proves eight distinct extents were\n" + " programmed -- one value read back eight times proves\n" + " one extent was programmed eight times over, which is\n" + " exactly what a region with the wrong BASE produces", + agreed); + + zx_check("and the guest's OWN readback agreed too, through its own\n" + " stage-1 region set rather than through the\n" + " hypervisor's. Two readers at two exception levels: a\n" + " guest grading itself would report agreement even with\n" + " its region base wrong, because its stores and its\n" + " loads would go to the same wrong place", + ((zx_guest_mailbox_read(&zx_launch[partition], + ZX_GD_M_MARKS_OK) != 0U) + && (zx_guest_mailbox_read(&zx_launch[partition], + ZX_GD_M_MARKS_WRITTEN) == count)) + ? 1U : 0U); +} + + +/**************************************************************************/ +/* zx_report_holes */ +/**************************************************************************/ + +static void zx_report_holes(void) +{ + UINT part; + uint32_t intact = 1U; + + zx_console_puts("\n--- the granules that belong to nobody ---\n"); + + for (part = 0U; part < ZX_PART_COUNT; part++) + { + uint32_t held = *zx_hole_word(part); + + zx_console_puts(" after partition "); + zx_console_putdec(part); + zx_console_puts(", at "); + zx_console_puthex(zx_matrix[part].zx_matrix_hole); + zx_console_puts(", holds "); + zx_console_puthex(held); + zx_console_puts("\n"); + + if (held != ZX_HOLE_POISON) + { + intact = 0U; + } + } + + zx_check("BOTH UNGRANTED GRANULES STILL HOLD THEIR POISON. Each sits\n" + " IMMEDIATELY after a partition's window, so a region\n" + " limit one granule too generous reaches into it -- and\n" + " this is the check that catches that, which no access\n" + " a partition legitimately makes ever would. A hole\n" + " holding a MARK means a limit is too wide; a hole\n" + " holding zero means nobody looked", + intact); +} + + +/**************************************************************************/ +/* zx_report_determinism */ +/* */ +/* REPORT ABSOLUTE, ASSERT RELATIVE, AND STATE THE CLOCK. All three on */ +/* every run, because a jitter figure travels out of a log into a slide */ +/* and the counter frequency does not travel with it unless it is */ +/* attached. */ +/* */ +/* NOT COMPILED INTO THE HALT-ON-FAULT BUILD, and the omission is honest */ +/* rather than convenient. That build stops both partitions inside its */ +/* first frames, on purpose, so there are no periods to report -- and a */ +/* determinism section printed there would be a table of zeroes under a */ +/* heading claiming to measure something. A regression that padded its */ +/* output to look uniform across builds would be harder to read, not */ +/* easier. */ +/**************************************************************************/ + +#ifndef ZX_REGRESSION_HALT_ONLY + +/* Everything the determinism half needs, in one place and compiled only + into the builds that have a determinism half. The halt-on-fault build + stops both partitions inside its first frames on purpose, so it has no + periods to bound and no phases to name. */ + +static const char *const zx_phase_name[ZX_PHASE_COUNT] = +{ + "0 B booting (warm-up, DISCARDED)", + "1 B idle ", + "2 B in a tight compute loop ", + "3 B computing, IRQ+FIQ MASKED ", + "4 B storming the console ", + "5 B faulting on every iteration " +}; + +/**************************************************************************/ +/* zx_bound -- the jitter bound, and where it comes from. */ +/* */ +/* ONE EIGHTH OF ONE WINDOW, and the derivation is printed beside every */ +/* number this file asserts against, because a bound whose origin is not */ +/* in the log is a bound a reader has to take on trust. */ +/* */ +/* THE DERIVATION. The worst partition switch measured on the */ +/* S32Z280-594EVB by the two-partition image is 5,862 core cycles at a */ +/* measured 48.05 MHz, which is about 122 microseconds -- and at that */ +/* board's 8 MHz system counter, about 976 counts, or 1.2% of one */ +/* window. The bound is TEN TIMES that, rounded to a power of two: one */ +/* eighth of a window. */ +/* */ +/* WHY RELATIVE AND NOT ABSOLUTE, which was an open question worth */ +/* deciding rather than defaulting. An absolute bound in counts is what */ +/* a safety customer wants and it does not survive the two targets: the */ +/* model's counter runs at 100 MHz and the board's at 8, so one number */ +/* would be twelve times too tight on one of them. A bound expressed as */ +/* a fraction of a WINDOW is the same claim on both, and it is also the */ +/* claim that matters -- what a schedule has to survive is jitter */ +/* relative to the window it has to fit in. So: REPORT ABSOLUTE, ASSERT */ +/* RELATIVE, and state the clock, which the report does on every run. */ +/* */ +/* WHY IT IS TEN TIMES THE SWITCH AND NOT ONE. A bound at the measured */ +/* spread would be a bound that fails the first time somebody adds a */ +/* register to the switch, which is a regression reporting a change */ +/* rather than a defect. What this has to catch is COUPLING -- A's */ +/* timing depending on B's behaviour -- and coupling is not a percentage: */ +/* a partition that lost a window to its neighbour is out by a whole */ +/* window, which is eight times this bound. Ten times the switch leaves */ +/* room for the switch to grow and still catches the failure by a factor */ +/* of eight. */ +/**************************************************************************/ + +static uint64_t zx_counts_per_window(void) +{ + return (uint64_t)(zx_board_counter_hz() / ZX_TICK_HZ); +} + + +static uint64_t zx_jitter_bound(void) +{ + return zx_counts_per_window() / 8U; +} + + +/**************************************************************************/ +/* zx_console_bound -- the bound for the ONE phase that needs its own. */ +/* */ +/* A SEPARATE BOUND BECAUSE THERE IS A SEPARATE MECHANISM, and it is a */ +/* defect in the hypervisor rather than a limit of the partitioning. */ +/* Measured on the S32Z280-594EVB, and invisible on the model. */ +/* */ +/* WHAT WAS FOUND. With the untrusted partition storming the console, */ +/* the critical partition's window period moved by 20,806 counts -- two */ +/* and a half per cent of a frame, and twice the bound every other phase */ +/* meets. In the same run, that partition violating its boundary a */ +/* hundred and seventeen thousand times moved it by SEVENTY-THREE. */ +/* Nothing a partition does through the SCHEDULE reaches its neighbour; */ +/* what reaches it is the hypervisor's own console driver. */ +/* */ +/* THE MECHANISM. A guest's console is one hypercall per character, and */ +/* the hypervisor writes it through a POLLED UART. When a window ends */ +/* with a partial line outstanding, the boundary handler closes that line */ +/* before handing the console to the next partition -- so the switch is */ +/* delayed by however many characters were owed, at EL2, with FIQ masked. */ +/* The tag has to be right, and a line begun by one partition must not */ +/* be continued under its neighbour's name; closing it at the boundary is */ +/* the simplest way to guarantee that and it is the wrong place. */ +/* */ +/* THE STRUCTURAL BOUND, and it is derived rather than fitted to the */ +/* number above. The worst case is one whole line of the untrusted */ +/* partition's output: forty-nine characters, at 115,200 baud 8N1, is */ +/* ten bits each and about 86.8 microseconds each -- roughly 34,000 */ +/* counts of this board's 8 MHz counter. Rounded up to HALF A WINDOW. */ +/* The measured 20,806 sits comfortably inside it, and the bound fails if */ +/* the console ever costs more than one line. */ +/* */ +/* THE FIX IS NAMED AND IS NOT DONE HERE. The console must be buffered */ +/* and flushed off the boundary path, so that a partition's output is */ +/* charged to a window that partition owns. That is a change to the */ +/* guest console's line-tagging contract, which has properties of its own */ +/* under test, and it belongs with the console rather than with the */ +/* regression that found this. What the regression owes is the number, */ +/* the mechanism, and a bound that still catches it getting worse. */ +/**************************************************************************/ + +static uint64_t zx_console_bound(void) +{ + return zx_counts_per_window() / 2U; +} + +static void zx_report_determinism(uint32_t core_hz) +{ + uint64_t window = zx_counts_per_window(); + uint64_t bound = zx_jitter_bound(); + uint64_t base_mean; + uint32_t base_ticks; + uint32_t index; + uint32_t within = 1U; + uint32_t steady = 1U; + uint32_t ticked = 1U; + uint32_t provoked; + + zx_console_puts("\n=========================================================\n" + " PARTITION A'S TIMING, WHILE PARTITION B MISBEHAVES\n" + "=========================================================\n"); + + zx_console_puts(" the conditions, above the numbers and on every run:\n"); + zx_console_puts(" system counter = "); + zx_console_putdec(zx_board_counter_hz() / 1000000U); + zx_console_puts(" MHz, so one window of "); + zx_console_putdec((uint32_t)(window & 0xFFFFFFFFU)); + zx_console_puts(" counts\n"); + zx_console_puts(" core clock, measured against it = "); + zx_console_putdec(core_hz / 1000000U); + zx_console_puts(" MHz\n"); + zx_console_puts(" one major frame = "); + zx_console_putdec((uint32_t)ZX_FRAME_TICKS); + zx_console_puts(" windows, and A is entered once per frame\n"); + zx_console_puts(" the bound asserted below = "); + zx_console_putdec((uint32_t)(bound & 0xFFFFFFFFU)); + zx_console_puts(" counts, one EIGHTH of a window\n"); + + zx_console_puts( + "\n" + " WHERE THE BOUND COMES FROM, because a bound whose origin is not\n" + " in the log is a bound a reader has to take on trust. The worst\n" + " partition switch measured on the S32Z280-594EVB by the\n" + " two-partition image is 5,862 core cycles at a measured 48 MHz --\n" + " about 122 microseconds, which at that board's 8 MHz system\n" + " counter is roughly 976 counts, or 1.2% of one window. The bound\n" + " is TEN TIMES that, rounded to a power of two.\n" + "\n" + " RELATIVE AND NOT ABSOLUTE, and that was a decision. An absolute\n" + " bound in counts is what a safety customer wants and it does not\n" + " survive two targets whose counters differ by a factor of twelve.\n" + " A fraction of a WINDOW is the same claim on both -- and it is\n" + " also the claim that matters, since what a schedule has to survive\n" + " is jitter relative to the window it must fit in.\n" + "\n" + " AND TEN TIMES THE SWITCH RATHER THAN ONE, because what this has\n" + " to catch is COUPLING and coupling is not a percentage: a\n" + " partition that lost a window to its neighbour is out by a WHOLE\n" + " WINDOW, which is eight times this bound. Ten times the switch\n" + " leaves room for the switch to grow and still catches the failure\n" + " by a factor of eight.\n"); + + zx_console_puts("\n A's window period, in counter counts, by phase:\n\n"); + zx_console_puts(" phase min mean" + " max jitter ticks own clock / core time\n"); + + for (index = 0U; index < ZX_PHASE_COUNT; index++) + { + uint64_t jitter = (zx_phase[index].zx_phase_samples > 1U) + ? (zx_phase[index].zx_phase_max + - zx_phase[index].zx_phase_min) + : 0U; + + zx_console_puts(" "); + zx_console_puts(zx_phase_name[index]); + zx_console_puts(" "); + zx_console_putdec((uint32_t)(zx_phase[index].zx_phase_min + & 0xFFFFFFFFU)); + zx_console_puts(" "); + zx_console_putdec((uint32_t)(zx_phase[index].zx_phase_mean + & 0xFFFFFFFFU)); + zx_console_puts(" "); + zx_console_putdec((uint32_t)(zx_phase[index].zx_phase_max + & 0xFFFFFFFFU)); + zx_console_puts(" "); + zx_console_putdec((uint32_t)(jitter & 0xFFFFFFFFU)); + zx_console_puts(" "); + zx_console_putdec(zx_phase[index].zx_phase_own_ticks); + zx_console_puts(" "); + zx_console_putdec(zx_phase[index].zx_phase_own_counts); + zx_console_puts(" / "); + zx_console_putdec(zx_phase[index].zx_phase_core_counts); + zx_console_puts(" ("); + zx_console_putdec(zx_phase[index].zx_phase_samples); + zx_console_puts(" periods)\n"); + } + + zx_console_puts( + "\n" + " THE MAXIMUM IS THE NUMBER, not the mean. A schedule has to be\n" + " built to survive the worst period it will ever see, so a mean\n" + " quoted alone hides exactly the excursion a safety reviewer is\n" + " asking about. Phase 0 is REPORTED AND NOT ASSERTED ON: partition\n" + " B is still booting its kernel and reaching its verdict during it,\n" + " so a baseline that included it would be a baseline of a partition\n" + " doing real work -- and every later phase would look\n" + " artificially good. It is printed rather than dropped so that\n" + " what was discarded is visible instead of taken on trust.\n"); + + base_mean = zx_phase[ZX_PHASE_BASELINE].zx_phase_mean; + base_ticks = zx_phase[ZX_PHASE_BASELINE].zx_phase_own_ticks; + (void)base_ticks; + + for (index = ZX_PHASE_BASELINE; index < ZX_PHASE_COUNT; index++) + { + uint64_t jitter = (zx_phase[index].zx_phase_samples > 1U) + ? (zx_phase[index].zx_phase_max + - zx_phase[index].zx_phase_min) + : 0U; + uint64_t apart = (zx_phase[index].zx_phase_mean > base_mean) + ? (zx_phase[index].zx_phase_mean - base_mean) + : (base_mean - zx_phase[index].zx_phase_mean); + uint32_t own = zx_phase[index].zx_phase_own_counts; + uint32_t core = zx_phase[index].zx_phase_core_counts; + uint32_t drift = (own > core) ? (own - core) : (core - own); + + if (zx_phase[index].zx_phase_samples < 2U) + { + /* A PHASE THAT PRODUCED NO PERIODS IS A FAILURE AND NOT A PASS. + "Its jitter was within the bound" is vacuously true of a + phase in which A was never entered, and a run whose schedule + had stopped turning would satisfy every comparison below. */ + + within = 0U; + steady = 0U; + } + + /* WHICH BOUND THIS PHASE IS HELD TO, chosen by MECHANISM and not + by outcome. The console phase is the one in which the + hypervisor itself writes characters through a polled UART on the + boundary path, so it is the one phase whose perturbation is the + hypervisor's own doing rather than the partitioning's. That is + known in advance of any measurement, and it is the only reason + the phase is treated differently -- see zx_console_bound for the + number, its derivation, and the fix it is standing in for. */ + + uint64_t limit = (zx_phase_behaviour[index] == (uint32_t)ZX_GB_STORM) + ? zx_console_bound() : bound; + + if (jitter > limit) + { + within = 0U; + } + + if (apart > limit) + { + steady = 0U; + } + + /* A'S OWN CLOCK EQUALS THE CORE TIME IT WAS GIVEN. That is the + temporal claim in its sharpest form -- not "its clock is steady" + but "its clock advanced by the counts it spent on the core and by + none of the counts it did not". + * + ONE WINDOW OF SLACK, and it is a sampling tolerance rather than + an allowance for error. The two numbers are read at different + instants: the hypervisor's at the frame boundary, the guest's + whenever the guest last got round to publishing it -- which in + the compute and masked phases is once per pass of its own loop + and in the fault phase is once per trap. What one window will + not absorb is a partition seeing its neighbour's time: that is + three windows per frame, thirty per phase. */ + + if ((uint64_t)drift > window) + { + ticked = 0U; + } + } + + zx_console_puts( + "\n" + " ONE PHASE IS HELD TO A DIFFERENT BOUND, and it is worth reading\n" + " the reason rather than the number. While the untrusted partition\n" + " STORMS THE CONSOLE, the critical partition's period moves by\n" + " more than any other phase -- and the mechanism is the\n" + " HYPERVISOR'S OWN CONSOLE DRIVER, not the partitioning. A guest's\n" + " console is one hypercall per character through a polled UART, and\n" + " a window that ends with a partial line outstanding has that line\n" + " closed by the BOUNDARY HANDLER before the console changes hands.\n" + " The switch is then delayed by however many characters were owed.\n" + "\n" + " So that phase is bounded by one partial line of output -- about\n" + " forty-nine characters at this board's baud rate, rounded up to\n" + " half a window -- and every other phase by one eighth of a window.\n" + " The distinction is by MECHANISM and was made before the\n" + " measurement: it is the one phase in which the hypervisor writes\n" + " characters on the boundary path.\n" + "\n" + " AND THE COMPARISON IS THE POINT. In the same run, the untrusted\n" + " partition VIOLATING ITS BOUNDARY on every iteration of its own\n" + " loop -- over a hundred thousand times -- moves the critical\n" + " partition's period by a few tens of counts. Nothing a partition\n" + " does through the schedule reaches its neighbour. What reaches it\n" + " is a polled UART write in a boundary handler, which is a defect\n" + " to be fixed by buffering the console off that path.\n"); + + zx_check("A'S PERIOD IS STEADY WITHIN ITS BOUND IN EVERY MEASURED\n" + " PHASE. max - min for each phase from the baseline\n" + " onwards -- against one eighth of a window, and\n" + " against half a window for the one phase in which the\n" + " hypervisor writes console characters on the boundary\n" + " path. A phase that produced fewer than two periods\n" + " FAILS rather than passing vacuously: 'the jitter was\n" + " within the bound' is true of a partition that was\n" + " never entered at all", + within); + + zx_check("AND A'S MEAN PERIOD DOES NOT MOVE BETWEEN PHASES. This is\n" + " the mixed-criticality claim itself rather than a\n" + " symptom of it: the critical partition's period under\n" + " a neighbour that computes, masks its interrupts,\n" + " storms the console and violates its boundary is the\n" + " SAME period it has when that neighbour is idle. A\n" + " run where it differed would satisfy every memory\n" + " check in this file and would not be temporal\n" + " partitioning", + steady); + + zx_check("AND IN EVERY PHASE A'S OWN CLOCK ADVANCED BY THE CORE TIME\n" + " IT WAS GIVEN, and by none of the time it was not.\n" + " The guest-side half, and it cannot be derived from the\n" + " period: a partition whose virtual clock the hypervisor\n" + " was mismanaging would see a perfectly steady window\n" + " period through a clock running at the wrong rate. The\n" + " two halves therefore fail separately -- the build with\n" + " the time freeze removed fails this one and passes the\n" + " two above, which is exactly why it is a build", + ticked); + + /* AND THE PHASES HAVE TO HAVE HAPPENED. Every claim above is of the + form "A did not notice B doing X", which is worth nothing if B never + did X. The two phases that leave evidence are checked; the compute + and masked phases leave none by construction -- a partition computing + quietly is indistinguishable from one idling, which is the whole + reason the storm and fault phases are in the table. */ + + provoked = zx_phase[ZX_PHASE_COUNT - 1U].zx_phase_violations; + + zx_note("violations B committed in its fault phase", provoked); + zx_note("behaviours B acknowledged, as a bitmask ", + zx_guest_mailbox_read(&zx_launch[ZX_PART_B], ZX_GD_M_PHASE_SEEN)); + + zx_check("AND PARTITION B ACTUALLY DID THE THINGS IT WAS ASKED TO DO.\n" + " Every claim above has the form 'A did not notice B\n" + " doing X', which is worth nothing if B never did X --\n" + " and a mailbox word B never read would leave every\n" + " phase looking identical because every phase WAS\n" + " identical. B acknowledges each behaviour as it\n" + " enters it, and it violated its boundary repeatedly in\n" + " the phase that asked it to", + ((provoked > 0U) + && (zx_guest_mailbox_read(&zx_launch[ZX_PART_B], + ZX_GD_M_PHASE_SEEN) + == ((1U << ZX_GB_QUIET) | (1U << ZX_GB_SPIN) + | (1U << ZX_GB_MASKED) | (1U << ZX_GB_STORM) + | (1U << ZX_GB_FAULT)))) ? 1U : 0U); +} + +#endif /* ZX_REGRESSION_HALT_ONLY */ + + +/**************************************************************************/ +/* zx_report_not_proved */ +/* */ +/* IN THE RUN'S OWN OUTPUT, because this is the log a stranger reads */ +/* without context, and overclaiming costs credibility with exactly the */ +/* audience the demonstration exists to convince. The sentence costs */ +/* nothing. */ +/**************************************************************************/ + +static void zx_report_not_proved(void) +{ + zx_console_puts( + "\n=========================================================\n" + " what this run does and does not prove\n" + "=========================================================\n" + " " ZX_PLATFORM_NAME "\n" + "\n" + " This demonstrates MEMORY ISOLATION between two ThreadX\n" + " partitions and TEMPORAL DETERMINISM on ONE LOGICAL CORE\n" + " (dual-core lockstep presents as one core). It does NOT\n" + " demonstrate spatial partitioning across multiple cores,\n" + " which requires split-mode SMP and is deferred.\n" + "\n" + " AND THREE MORE OMISSIONS, deliberate rather than pending:\n" + "\n" + " * On a functional model, a pass proves the CODE is right\n" + " and says nothing about timing: the model is functional,\n" + " not cycle-accurate. Every period and every cycle count\n" + " printed above has to come from silicon before it is\n" + " quoted.\n" + "\n" + " * A faulted partition is STOPPED, not restarted, and its\n" + " windows are still SPENT. Supervised restart is a later\n" + " phase.\n" + "\n" + " * Interrupt LATENCY is not bounded here. Guest interrupts\n" + " go straight to EL1, which is why they cost what they\n" + " always did -- and why bounding them needs the List\n" + " Registers this core has and this phase does not use.\n"); +} + + +/**************************************************************************/ +/* zx_el2_main */ +/**************************************************************************/ + +ZX_NORETURN void zx_el2_main(void) +{ + uint32_t board_regions; + uint32_t el2_regions; + uint32_t status; + uint32_t granted; + uint32_t outcome; + uint32_t core_hz; + uint32_t options; + ZX_MANIFEST_FAULT fault; + + el2_regions = zx_el2_region_count(); + board_regions = zx_board_mmio_region_count(); + + if (el2_regions >= board_regions) + { + zx_mair_program(); + + if (board_regions > 0U) + { + zx_board_program_mmio_regions(0U); + } + + zx_el2_mpu_enable(); + } + + zx_board_init(); + + zx_console_puts( + "\n" + "=========================================================\n" + " Eclipse ThreadX ZoneX -- THE ISOLATION AND DETERMINISM\n" + " REGRESSION\n" + " " ZX_PLATFORM_NAME "\n" + " Armv8-R AArch32, EL2, PMSAv8-R at both stages\n" + "=========================================================\n" + "\n" + " Two claims, asserted rather than observed.\n" + "\n" + " NEITHER PARTITION CAN READ, WRITE OR EXECUTE the other's\n" + " memory, the ungranted granule next door to its own, or the\n" + " hypervisor's -- and each still owns every byte of its OWN\n" + " window afterwards. Seven cases in each direction, each\n" + " aimed at an address of its own so that the fault it\n" + " provoked is attributable to it and to nothing else.\n" + "\n" + " AND PARTITION A'S TIMING IS UNAFFECTED BY ANYTHING\n" + " PARTITION B DOES. A's window period is measured\n" + " continuously while B is steered through five behaviours in\n" + " ONE run: idle, computing, computing with its interrupts\n" + " masked, storming the console, and violating its boundary on\n" + " every iteration of its own loop.\n"); + + /* WHICH FAULT POLICY THIS BUILD HAS, SAID FIRST AND BY THE CODE THAT + IMPLEMENTS IT. A log from a continue-mode build must not be + mistakable for one from a shipping build, and the answer comes from + the translation unit that decides rather than from a macro this file + could disagree with. */ + + zx_console_puts("\n--- the fault policy this build was compiled with ---\n"); + + if (zx_el2_fault_continue_enabled() != 0U) + { + zx_console_puts( + " TEST-ONLY CONTINUE MODE IS ON. A partition that violates its\n" + " boundary is recorded and RESUMED past the access, so that one\n" + " image can sweep the whole matrix instead of needing one image\n" + " and one run per case -- fourteen of each, which on silicon is\n" + " most of a working day.\n" + "\n" + " THIS IS NOT THE SHIPPING POLICY AND NO SHIPPING IMAGE HAS IT.\n" + " The mode is compile-time, so it cannot be turned on by a\n" + " manifest, a mailbox word or a debugger; the shipping default\n" + " is HALT; and halt is tested rather than assumed, by a build of\n" + " this same image without the option, on both targets.\n"); + } + else + { + zx_console_puts( + " HALT ON FAULT -- THE SHIPPING POLICY, under test. The first\n" + " violation a partition commits stops it: its windows are still\n" + " spent, its neighbour is unaffected, and the run reports which\n" + " partition and which address.\n" + "\n" + " So this build asks each partition for ONE case rather than\n" + " seven. Asking for the whole matrix would be asking for\n" + " thirteen cases that cannot run, and 'denied' and 'never\n" + " attempted' would then differ for a reason that has nothing to\n" + " do with isolation.\n"); + } + + zx_fault_record_reset(zx_el2_fault_record()); + zx_fault_log_reset(&zx_fault_log); + zx_guest_console_reset(); + + /* THE LOG AND THE RESUME HOOK, BEFORE ANY PARTITION CAN FAULT. A + violation taken before this would be captured in the single fault + record and dropped from the log, which is a hole in the evidence at + exactly the point where the evidence is the whole product. */ + + zx_el2_fault_continue_configure(&zx_fault_log, zx_matrix_resume_point, + ZX_REGRESSION_CONTINUE); + + zx_console_puts("\n--- identity, as the core reports it ---\n"); + zx_note("MIDR ", zx_read_midr()); + zx_note("EL2 regions ", el2_regions); + zx_note("EL1 regions ", zx_el1_region_count()); + zx_note("HPRENR bits ", zx_hprenr_implemented_bits); + zx_board_report(); + + zx_check("HSCTLR.M is set, so the EL2 MPU is live", + ((zx_read_hsctlr() & ZX_HSCTLR_M) != 0U) ? 1U : 0U); + + if (el2_regions == 0U) + { + zx_console_puts("\nZONEX RESULT: FAILED -- the target cannot host " + "stage 2\n"); + zx_console_exit(1U); + } + + zx_context_probe_el1_regions(); + + zx_note("EL1 regions a switch carries", zx_context_el1_regions()); + zx_check("no EL1 MPU region was left out of the switch. A part with\n" + " more regions than a context block can hold would have\n" + " its highest ones silently dropped from every save, so\n" + " the outgoing partition's last windows would stay\n" + " programmed and the incoming one would inherit them --\n" + " an isolation failure at stage ONE that every stage-2\n" + " check in this file would miss", + (zx_context_el1_regions_clamped() == 0U) ? 1U : 0U); + + /* ---------------------------------------------------------------- */ + /* The manifest, the plan, the schedule, and the matrix. */ + /* ---------------------------------------------------------------- */ + + zx_build_manifest(board_regions); + zx_describe_launches(); + zx_board_gic_layout(&zx_gic); + zx_env.zx_env_region_budget = el2_regions; + + /* RECORDED BEFORE ANYTHING THAT CAN FAIL. See zx_describe_matrix: the + end-of-run judgement searches the fault log by these addresses, and a + table left zero by a failed setup would turn one failure into + fourteen pointing at the wrong thing. */ + + zx_describe_matrix(); + + status = zx_manifest_verify(&zx_manifest, &zx_env, &fault); + + zx_console_puts("\n--- the manifest, checked before anything is " + "programmed ---\n"); + zx_note("zx_manifest_verify", status); + + if (status != ZX_MANIFEST_SUCCESS) + { + zx_note(" offending partition", fault.zx_fault_partition); + zx_note(" offending region ", fault.zx_fault_region); + zx_note(" the other offender ", fault.zx_fault_other_partition); + zx_check("the manifest passes every rule", 0U); + zx_console_puts("\nZONEX RESULT: FAILED\n"); + zx_console_exit(zx_probe_failures()); + } + + zx_check("the manifest passes every rule, including the one that makes\n" + " two partitions possible at all: their windows do not\n" + " overlap", + 1U); + + zx_check("and NEITHER partition is granted a device region of any kind,\n" + " nor a shared granule. The two are disjoint with no\n" + " exceptions, so every temporal number below is a number\n" + " about two partitions that share NOTHING but a core", + ((zx_partitions[ZX_PART_A].zx_partition_region_count == 1U) + && (zx_partitions[ZX_PART_B].zx_partition_region_count == 1U) + && (zx_manifest.zx_manifest_shared_count == 0U)) ? 1U : 0U); + + status = zx_mm_plan(&zx_manifest, board_regions, el2_regions, + zx_hprenr_implemented_bits, &zx_layout); + zx_note("zx_mm_plan", status); + zx_check("the layout fits this part's region budget and every region it\n" + " assigned has an HPRENR enable bit", + (status == ZX_MANIFEST_SUCCESS) ? 1U : 0U); + + if (status != ZX_MANIFEST_SUCCESS) + { + zx_console_puts("\nZONEX RESULT: FAILED\n"); + zx_console_exit(zx_probe_failures()); + } + + zx_mm_report(&zx_layout, &zx_manifest); + + status = zx_schedule_build(&zx_manifest, + (uint64_t)(zx_board_counter_hz() / ZX_TICK_HZ), + ZX_FRAME_LIMIT, &zx_schedule); + + zx_console_puts("\n--- the major frame ---\n"); + zx_note("zx_schedule_build", status); + zx_note("frames this run ", (uint32_t)ZX_FRAME_LIMIT); + zx_note("frames per phase ", (uint32_t)ZX_PHASE_FRAMES); + zx_check("the schedule was built, and the major frame equals the sum of\n" + " its windows", + (status == ZX_MANIFEST_SUCCESS) ? 1U : 0U); + + if (status != ZX_MANIFEST_SUCCESS) + { + zx_console_puts("\nZONEX RESULT: FAILED\n"); + zx_console_exit(zx_probe_failures()); + } + + zx_schedule_report(&zx_schedule, &zx_manifest); + + /* ---------------------------------------------------------------- */ + /* The region set, programmed once and read back. */ + /* ---------------------------------------------------------------- */ + + { + UINT part; + + zx_console_puts("\n--- the region set, programmed and read back ---\n"); + + for (part = 0U; part < ZX_PART_COUNT; part++) + { + uint32_t index = + (uint32_t)zx_layout.zx_layout_partition_first[part]; + const ZX_REGION *region = (part == ZX_PART_A) + ? &zx_regions_a[0] : &zx_regions_b[0]; + + zx_stage2_region_program(index, region); + + zx_note("region index", index); + zx_check("this partition's window reads back as the manifest\n" + " declared it -- an under-aligned base does not\n" + " fault, its low bits land on SH, AP and XN and\n" + " silently change what it grants", + zx_stage2_region_matches(index, region)); + } + } + + /* THE POISON GOES IN AFTER THE REGIONS ARE PROGRAMMED AND BEFORE ANY + PARTITION RUNS. After, because a widened region would otherwise be + poisoned and then have its own window's marks written over the poison + in a different order; before, because the whole question is whether a + partition can reach it while it is running. */ + + zx_poison_the_holes(); + + zx_console_puts("\n the ungranted granules are poisoned with "); + zx_console_puthex(ZX_HOLE_POISON); + zx_console_puts("\n after partition A at "); + zx_console_puthex(zx_matrix[ZX_PART_A].zx_matrix_hole); + zx_console_puts("\n after partition B at "); + zx_console_puthex(zx_matrix[ZX_PART_B].zx_matrix_hole); + zx_console_puts("\n"); + + /* ---------------------------------------------------------------- */ + /* Clocks, interrupts, routing. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n--- what ZoneX takes over from a guest's boot path " + "---\n"); + zx_el2_prepare_guest_el1(zx_board_counter_hz()); + + zx_check("CNTFRQ is programmed, because it is writable only at the\n" + " highest implemented exception level and a guest\n" + " deriving a tick interval from a zero divides by zero", + (zx_read_cntfrq() == zx_board_counter_hz()) ? 1U : 0U); + + /* THE HYPERVISOR'S OWN TICK, AND ONE BUILD THAT DELIBERATELY CANNOT + DELIVER IT. A frame whose windows can never end does not fail -- it + HANGS, with the first partition running until something outside the + image stops it and a log that ends mid-sentence naming nothing. The + refusal below is what turns that into a verdict, and the refusal is + reached by exactly one build, because a branch nothing has ever taken + is not evidence that it works. */ + +#ifdef ZX_REGRESSION_NO_TICK + granted = zx_frame_grant_the_clocks(&zx_gic, + ZX_GUEST_TIMER_PRIORITY, 0U); +#else + granted = zx_frame_grant_the_clocks(&zx_gic, + ZX_GUEST_TIMER_PRIORITY, 1U); +#endif + + if (granted == 0U) + { + zx_console_puts( + "\n *** REFUSING TO START THE FRAME. Something a window\n" + " *** boundary depends on is not in place. Starting anyway\n" + " *** would run the first partition until something outside\n" + " *** this image stopped it, and the log would end mid-sentence\n" + " *** with no verdict to read.\n"); + + zx_check("everything a window boundary depends on is in place", 0U); + zx_console_puts("\nZONEX RESULT: FAILED -- the hypervisor's own tick " + "cannot be delivered\n"); + zx_console_exit(zx_probe_failures()); + } + + zx_stage2_enable(); + zx_check("HCR.VM is set, so stage 2 applies to EL0/EL1 for the whole of\n" + " this run", + ((zx_read_hcr() & ZX_HCR_VM) != 0U) ? 1U : 0U); + + zx_pmu_enable(); + + core_hz = zx_pmu_core_hz(zx_board_counter_hz(), + zx_board_counter_hz() / 1000U); + + zx_note("core clock, Hz, measured", core_hz); + zx_check("the core clock could be measured against the system counter,\n" + " so every duration in this run converts to a number a\n" + " reader can argue with", + (core_hz > 0U) ? 1U : 0U); + + /* ---------------------------------------------------------------- */ + /* Load both guests. */ + /* */ + /* BOTH QUIET WHILE THEY WORK. The demonstration image makes B */ + /* chatty because a partition spending its window printing is the */ + /* instructive thing to watch; here it would put a hypercall per */ + /* character on the path being measured for the first ten frames, */ + /* and B has to reach its verdict inside the warm-up phase. B */ + /* storms the console LATER, on command, in the phase that asks for */ + /* it -- which is the same demonstration under control. */ + /* ---------------------------------------------------------------- */ + + options = ZX_GO_TICK | ZX_GO_QUIET | ZX_GO_FOREVER; + + zx_console_puts("\n--- loading partition A ---\n"); + + if (zx_prepare_partition(ZX_PART_A, options) == 0U) + { + zx_console_puts("\nZONEX RESULT: FAILED\n"); + zx_console_exit(zx_probe_failures()); + } + + zx_console_puts("\n--- loading partition B ---\n"); + + if (zx_prepare_partition(ZX_PART_B, options) == 0U) + { + zx_console_puts("\nZONEX RESULT: FAILED\n"); + zx_console_exit(zx_probe_failures()); + } + + /* ---------------------------------------------------------------- */ + /* Run. */ + /* ---------------------------------------------------------------- */ + + zx_el2_deny_guest_fp(); + + zx_frame_configure(&zx_frame, &zx_schedule, zx_context, &zx_layout, + &zx_manifest, ZX_PART_COUNT); + zx_frame_set_frame_hook(&zx_frame, zx_phase_hook, (void *)0); + + zx_console_puts("\n=========================================================\n" + " THE FRAME STARTS HERE\n" + "=========================================================\n" + " Partition A is quiet for the whole run, because a\n" + " measured partition has to be measurable. Partition B\n" + " does whatever the phase asks of it.\n\n"); + + outcome = zx_frame_run(&zx_frame); + + zx_el2_allow_guest_fp(); + + /* THE LAST PHASE IS CLOSED HERE, because no hook fires after the frame + ends: the phase boundary that would have closed it is the end of the + run. Without this the fault phase -- the one that matters most -- + would report no periods at all, and the check that a phase producing + fewer than two periods FAILS would then fail for the wrong reason. */ + + zx_close_phase(zx_phase_index); + + /* ---------------------------------------------------------------- */ + /* What happened. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n--- the frame, as it ran ---\n"); + zx_note("outcome ", outcome); + zx_note("major frames completed", zx_schedule.zx_schedule_frames); + zx_note("window boundaries ", zx_frame.zx_frame_switches); + zx_note("boundaries timed ", zx_frame.zx_frame_timed); + zx_note("missed deadlines ", zx_schedule.zx_schedule_missed); + zx_note("unexplained FIQs ", zx_frame.zx_frame_spurious); + + if ((zx_pmu_is_running() != 0U) && (zx_frame.zx_frame_timed != 0U)) + { + zx_note("switch cycles, min ", zx_frame.zx_frame_switch_min); + zx_note("switch cycles, max ", zx_frame.zx_frame_switch_max); + zx_note("switch cycles, spread", + zx_frame.zx_frame_switch_max - zx_frame.zx_frame_switch_min); + } + + /* ---------------------------------------------------------------- */ + /* THE ISOLATION MATRIX. */ + /* ---------------------------------------------------------------- */ + + zx_console_puts("\n=========================================================\n" + " THE ISOLATION MATRIX\n" + "=========================================================\n" + " Each row is judged from TWO independent sources: the\n" + " hypervisor's fault log, searched by the address the\n" + " case was aimed at, and the partition's own record of\n" + " what it ATTEMPTED. Neither alone would do -- a guest\n" + " resumed past a faulting instruction cannot report\n" + " having been denied, and a case that was never\n" + " attempted leaves the hypervisor no fault to look at.\n"); + + zx_fault_log_report(&zx_fault_log); + + zx_check("every violation recorded is a GUEST violation -- EC 0x24 or\n" + " 0x20 -- and none is ZoneX faulting on its own access.\n" + " EC 0x25 and 0x21 mean the HYPERVISOR overstepped, and\n" + " a matrix that counted one of those among its\n" + " fourteen would report fourteen violations detected\n" + " while hiding the one that mattered", + zx_fault_log_all_guest_violations(&zx_fault_log)); + + { + UINT part; + + for (part = 0U; part < ZX_PART_COUNT; part++) + { + uint32_t cases = zx_matrix[part].zx_matrix_cases; + + zx_console_puts("\n---------------------------------------------\n" + " partition "); + zx_console_puts(zx_partitions[part].zx_partition_name); + zx_console_puts(" reaching for its neighbour\n" + "---------------------------------------------\n"); + + zx_note("cases it was asked for", cases); + zx_note("cases it attempted ", + zx_guest_mailbox_read(&zx_launch[part], + ZX_GD_M_ATTEMPTED)); + zx_note("cases it REFUSED ", + zx_guest_mailbox_read(&zx_launch[part], ZX_GD_M_REFUSED)); + + zx_check("the partition refused NO case it was asked for. A\n" + " case the guest declined and a case stage 2\n" + " denied look identical from EL2 -- both leave\n" + " no fault -- so a sweep with refusals in it is\n" + " a sweep whose green rows cannot be trusted", + (zx_guest_mailbox_read(&zx_launch[part], ZX_GD_M_REFUSED) + == 0U) ? 1U : 0U); + + if ((cases & ZX_MC_NEIGHBOUR_READ) != 0U) + { + zx_report_matrix_row(part, + "reading the other partition's data", + zx_matrix[part].zx_matrix_neighbour, + ZX_MC_NEIGHBOUR_READ, + ZX_EC_DABT_ROUTED, 0U); + + { + /* WHAT IS REALLY AT THAT ADDRESS, read by the + hypervisor -- which can, because stage-2 AP cannot + deny EL2 -- and compared against what the partition + reported. That comparison IS the claim: the + neighbour's data did not reach this partition. + * + IT IS NOT COMPARED AGAINST THE POISON, and the reason + is a property of the continue mode worth knowing. + The guest writes a poison value before attempting the + load, so that a denied load -- which never writes its + destination register -- would report the poison. It + does not. Under the continue mode the partition is + resumed at the instruction AFTER the load, and that + instruction is the STORE of the loaded value: the + mailbox is written with whatever the compiler left in + that register, which on a real run was the number + one. A guest cannot report having been denied, and + it cannot report what it failed to read either. + * + So the hypervisor reads the truth and compares. */ + + const volatile uint32_t *real = + (const volatile uint32_t *)(uintptr_t) + zx_matrix[part].zx_matrix_neighbour; + uint32_t reported = + zx_guest_mailbox_read(&zx_launch[part], + ZX_GD_M_READ_VALUE); + + zx_note(" what the partition reported reading", + reported); + zx_note(" what is actually at that address ", + *real); + + zx_check("AND THE NEIGHBOUR'S DATA DID NOT REACH THE\n" + " PARTITION. What it reported is not\n" + " what is really there -- read by the\n" + " hypervisor, which can reach both\n" + " windows, and compared. Not compared\n" + " against a poison the guest wrote\n" + " first: the continue mode resumes a\n" + " partition at the instruction AFTER the\n" + " denied load, which is the STORE of its\n" + " result, so the word gets written with\n" + " whatever was left in that register", + (reported != *real) ? 1U : 0U); + } + } + + if ((cases & ZX_MC_NEIGHBOUR_WRITE) != 0U) + { + zx_report_matrix_row(part, + "writing the other partition's data", + zx_matrix[part].zx_matrix_neighbour + 4U, + ZX_MC_NEIGHBOUR_WRITE, + ZX_EC_DABT_ROUTED, 1U); + } + + if ((cases & ZX_MC_HOLE_WRITE) != 0U) + { + zx_report_matrix_row(part, + "writing the ADJACENT ungranted granule", + zx_matrix[part].zx_matrix_hole, + ZX_MC_HOLE_WRITE, + ZX_EC_DABT_ROUTED, 1U); + } + + if ((cases & ZX_MC_HYP_DATA) != 0U) + { + zx_report_matrix_row(part, + "reading the hypervisor's own manifest", + zx_matrix[part].zx_matrix_hyp_data, + ZX_MC_HYP_DATA, + ZX_EC_DABT_ROUTED, 0U); + + zx_console_puts( + " WHY THIS ONE PASSES IS THE INTERESTING PART, and it\n" + " is not a permission check. The hypervisor's memory\n" + " is covered by NO ENABLED REGION at all: HSCTLR.BR\n" + " lets EL2's own accesses fall back to the background\n" + " map, while an EL0 or EL1 access that hits no enabled\n" + " region faults regardless of BR. So ZoneX costs no\n" + " region and is protected from a partition precisely\n" + " by not being mapped -- which is why the syndrome is\n" + " a MISS and not a permission failure. An\n" + " 'everything is mapped, the guest just is not\n" + " supposed to look' design fails this case.\n"); + } + + if ((cases & ZX_MC_HYP_MMIO) != 0U) + { + zx_report_matrix_row(part, + "writing the GIC distributor", + zx_matrix[part].zx_matrix_hyp_mmio, + ZX_MC_HYP_MMIO, + ZX_EC_DABT_ROUTED, 1U); + + zx_console_puts( + " The most pointed target in the matrix: a partition\n" + " that could write GICD could disable the interrupt\n" + " that ends its own window, which is the one\n" + " privilege this whole design is built to withhold.\n"); + } + + if ((cases & ZX_MC_NEIGHBOUR_EXEC) != 0U) + { + zx_report_matrix_row(part, + "BRANCHING into the other partition's " + "code", + zx_matrix[part].zx_matrix_neighbour_code, + ZX_MC_NEIGHBOUR_EXEC, + ZX_EC_PABT_ROUTED, 0U); + + zx_console_puts( + " A PREFETCH abort and not a data abort, reported\n" + " through HIFAR rather than HDFAR. The guest granted\n" + " itself that granule EXECUTABLE at stage 1 first: a\n" + " branch into memory its own MPU marked execute-never\n" + " is refused at EL1 and never reaches the hypervisor,\n" + " so it would have proved nothing about stage 2.\n"); + } + + if ((cases & ZX_MC_OWN_MARKS) != 0U) + { + zx_report_marks(part); + } + } + } + + zx_report_holes(); + + /* ---------------------------------------------------------------- */ + /* Whether the run as a whole did what it was built to do. */ + /* ---------------------------------------------------------------- */ + +#ifdef ZX_REGRESSION_HALT_ONLY + + zx_console_puts("\n=========================================================\n" + " HALT ON FAULT: THE SHIPPING POLICY, UNDER TEST\n" + "=========================================================\n"); + + zx_check("A PARTITION WAS STOPPED AT ITS BOUNDARY, by a stage-2 fault\n" + " rather than by anything it asked for. This is the\n" + " shipping policy and it is tested rather than assumed:\n" + " without this build, 'the mode is compile-time and off\n" + " by default' would be a claim about a path no run had\n" + " ever taken", + ((zx_frame.zx_frame_stopped_index != ZX_MANIFEST_NO_INDEX) + && (zx_frame.zx_frame_stop_outcome == ZX_RUN_FAULTED)) + ? 1U : 0U); + + zx_note("the partition that was stopped first", zx_frame.zx_frame_stopped_index); + + zx_check("and NEITHER partition was resumed past its violation. Every\n" + " entry in the log says STOPPED, which is what makes\n" + " this build the witness for the shipping policy rather\n" + " than a shorter run of the other one", + ((zx_context[ZX_PART_A].zx_ctx_violations == 0U) + && (zx_context[ZX_PART_B].zx_ctx_violations == 0U)) ? 1U : 0U); + + zx_check("and the run still reached a verdict rather than hanging. A\n" + " halt policy that stopped every partition and then sat\n" + " there would prove the policy and lose the report", + (zx_fault_log.zx_log_total > 0U) ? 1U : 0U); + +#else + + zx_check("the frame ran to its declared limit rather than stopping\n" + " early. A bounded run is what lets any of this be\n" + " asserted on -- 'the harness timed out' is not a result", + ((outcome == ZX_RUN_FRAME_DONE) + && (zx_schedule.zx_schedule_frames == ZX_FRAME_LIMIT)) + ? 1U : 0U); + + zx_check("no window boundary was MISSED. A missed deadline means a\n" + " window was shorter than its own partition switch: the\n" + " schedule would keep turning, in the right order and\n" + " the right proportions, with every frame longer than it\n" + " was declared to be -- and every period measured below\n" + " would be of a frame that was not the frame declared", + (zx_schedule.zx_schedule_missed == 0UL) ? 1U : 0U); + + zx_check("and no FIQ arrived that the hypervisor's own timer had not\n" + " raised, so every switch was performed for a reason\n" + " somebody can name", + (zx_frame.zx_frame_spurious == 0U) ? 1U : 0U); + + zx_check("BOTH partitions survived the whole run. Under the continue\n" + " mode a violation does not stop a partition, so a\n" + " partition that stopped anyway did so for a reason\n" + " nothing here asked for", + ((zx_context[ZX_PART_A].zx_ctx_entries >= ZX_FRAME_LIMIT) + && (zx_context[ZX_PART_B].zx_ctx_entries >= ZX_FRAME_LIMIT)) + ? 1U : 0U); + + zx_check("and both reached their own verdicts BEFORE the sweep, so the\n" + " loader, the entry points and both kernels are not\n" + " what is being tested here", + ((zx_guest_mailbox_read(&zx_launch[ZX_PART_A], ZX_GD_VERDICT) + == ZX_GV_PASSED) + && (zx_guest_mailbox_read(&zx_launch[ZX_PART_B], ZX_GD_VERDICT) + == ZX_GV_PASSED)) ? 1U : 0U); + + zx_check("and neither guest's own vectors reported a stage-1 fault.\n" + " Every case in the matrix was granted at stage 1 by\n" + " the guest itself before it was attempted, so a\n" + " stage-1 fault would mean a case had been refused by\n" + " the wrong stage and had proved nothing about the\n" + " partition boundary", + ((zx_guest_mailbox_read(&zx_launch[ZX_PART_A], ZX_GD_STAGE1) + == ZX_GS_NONE) + && (zx_guest_mailbox_read(&zx_launch[ZX_PART_B], ZX_GD_STAGE1) + == ZX_GS_NONE)) ? 1U : 0U); + + zx_report_determinism(core_hz); + +#endif + + zx_console_puts("\n--- each partition's context ---\n"); + zx_context_report(&zx_context[ZX_PART_A], + zx_partitions[ZX_PART_A].zx_partition_name); + zx_context_report(&zx_context[ZX_PART_B], + zx_partitions[ZX_PART_B].zx_partition_name); + + zx_report_not_proved(); + + zx_console_puts("\n checks failed: "); + zx_console_putdec(zx_probe_failures()); + zx_console_puts("\n"); + + if (zx_probe_failures() == 0U) + { + zx_console_puts("\nZONEX RESULT: ALL CHECKS PASSED\n"); + } + else + { + zx_console_puts("\nZONEX RESULT: FAILED\n"); + } + + zx_console_exit(zx_probe_failures()); +} diff --git a/examples/common/zx_two_partitions.c b/examples/common/zx_two_partitions.c index 1cfcb3c..7f1ad40 100644 --- a/examples/common/zx_two_partitions.c +++ b/examples/common/zx_two_partitions.c @@ -91,6 +91,7 @@ #include "zx_partition.h" #include "zx_schedule.h" #include "zx_guest_console.h" +#include "zx_frame_setup.h" /* THE GUEST'S CONTRACT AND THE HYPERVISOR'S MUST AGREE, and this is one of the few translation units that can see both spellings. The guest is a @@ -375,282 +376,36 @@ static void zx_build_manifest(uint32_t board_regions) /**************************************************************************/ -/* zx_grant_the_clocks */ +/* EVERYTHING AT EL2 THAT MAKES A TIME-PARTITIONED SYSTEM POSSIBLE now */ +/* lives in zx_frame_setup.c, shared with the isolation and determinism */ +/* regression, which needs the same bring-up in the same order. */ /* */ -/* EVERYTHING AT EL2 THAT MAKES A TIME-PARTITIONED SYSTEM POSSIBLE, in */ -/* one place, because the order matters and several steps of it fail */ -/* silently on their own. */ +/* IT WAS MOVED RATHER THAN COPIED, and the reason is sharper than */ +/* tidiness: half of that routine is checks that exist because something */ +/* went wrong once -- HCR examined by BIT POSITION because this port had */ +/* FMO and AMO defined the other way round, EL2's CPU interface brought */ +/* up LAST because setting FMO redirects a guest's ICC_PMR writes and */ +/* the physical mask resets to zero, and a pre-flight of the comparator */ +/* before any partition runs because a window that never ends has three */ +/* causes that present identically. A second copy would drift, and the */ +/* drift would present as a partition that silently stopped being */ +/* interrupted in whichever image was edited second. */ /* */ -/* THE COUNTER, then the GIC, then the two interrupts -- one for the */ -/* partitions and one for the hypervisor -- and then the ROUTING. */ -/* */ -/* THE PARTITIONS' TIMER IS ONE INTID FOR BOTH OF THEM. They never */ -/* run at once, and its meaning is switched with everything else a */ -/* switch switches: the outgoing partition's comparator is saved and */ -/* DISARMED, the incoming one's is restored. Two INTIDs would make */ -/* "which PPIs are enabled" guest state and grow the switch by a */ -/* register write for no gain; one INTID keeps it hypervisor state */ -/* that never changes. This is open question 2 of the step this image */ -/* was written for, answered the way it recommended, and recorded here */ -/* rather than only in the manifest's comments. */ -/* */ -/* THE HYPERVISOR'S TIMER GOES IN GROUP 0, which is what makes the */ -/* whole design work: routing is by exception TYPE, so Group 0 arrives */ -/* as an FIQ and HCR.FMO brings FIQ to EL2, while every partition */ -/* interrupt stays Group 1, stays an IRQ, and is delivered straight to */ -/* EL1 exactly as it was before this image existed. */ -/* */ -/* AND EL2'S OWN CPU INTERFACE COMES UP LAST, AFTER FMO IS SET, */ -/* because that is where the trap is. See zx_gic_el2_cpu_interface_ */ -/* init: setting FMO redirects the guest's ICC_PMR writes to the */ -/* VIRTUAL interface, so the physical priority mask -- which resets to */ -/* zero, masking everything -- becomes the hypervisor's to open. A */ -/* partition that was receiving its timer perfectly well stops */ -/* receiving anything, with no fault and no message. */ -/* */ -/* Returns non-zero when everything both partitions depend on is up. */ +/* ONE thing is a parameter: whether the hypervisor's own timer PPI is */ +/* enabled. This image's ZX_TWO_NO_TICK build leaves it disabled on */ +/* purpose, which is what makes its preemption checks evidence. */ /**************************************************************************/ static uint32_t zx_grant_the_clocks(void) { - uint32_t running; - uint32_t awake; - uint32_t priority_bits; - uint32_t hyp_priority; - uint32_t tick_deliverable; - - zx_console_puts("\n--- the clocks and the interrupts ZoneX grants ---\n"); - - zx_board_counter_start(); - running = zx_counter_is_running(); - - zx_note("CNTFRQ ", zx_read_cntfrq()); - zx_check("the system counter is RUNNING, not merely declared. Every\n" - " window boundary in this run is an absolute comparison\n" - " against it, so a counter that does not move is a frame\n" - " that never turns", - running); - - awake = zx_gic_el2_init(&zx_gic); - zx_check("the redistributor cleared ProcessorSleep and reports its\n" - " children awake, so this core can be delivered to", - awake); - - priority_bits = zx_gic_priority_bits(&zx_gic, ZX_PPI_VIRTUAL_TIMER); - hyp_priority = zx_frame_hyp_tick_priority(); - - zx_note("implemented priority bits", priority_bits); - zx_note("partition timer priority ", ZX_GUEST_TIMER_PRIORITY); - zx_note("hypervisor tick priority ", hyp_priority); - - /* THE TWO PRIORITIES MUST STILL DIFFER AFTER THE PART HAS THROWN AWAY - THE BITS IT DOES NOT IMPLEMENT. Both targets keep only the top five, - so two values differing below that are the SAME priority -- and equal - priorities do not preempt. A hypervisor tick that could not preempt - a partition's timer handler would let a guest defer the end of its - own window for as long as its handler ran, which is the masking hole - wearing a different hat. */ - - { - uint32_t keep = (uint32_t)(0xFFU << (8U - priority_bits)); - - zx_check("the hypervisor's tick is a HIGHER priority than a\n" - " partition's timer even after this part has\n" - " discarded the priority bits it does not implement --\n" - " numerically lower wins, and equal priorities do not\n" - " preempt at all", - ((hyp_priority & keep) < (ZX_GUEST_TIMER_PRIORITY & keep)) - ? 1U : 0U); - } - - /* ONE INTID FOR BOTH PARTITIONS. Enabled once, here, and never touched - by a switch: the comparator behind it is what changes hands. */ - - zx_gic_enable_guest_ppi(&zx_gic, ZX_PPI_VIRTUAL_TIMER, - ZX_GUEST_TIMER_PRIORITY); - -#ifndef ZX_TWO_NO_TICK - - zx_gic_enable_hyp_ppi(&zx_gic, ZX_PPI_HYPERVISOR_TIMER, hyp_priority); - +#ifdef ZX_TWO_NO_TICK + const uint32_t enable_hyp_tick = 0U; #else - - zx_console_puts("\n NEGATIVE BUILD: the HYPERVISOR's own timer PPI is\n" - " deliberately NOT enabled. The comparator is still\n" - " armed at every boundary and still expires; the GIC is\n" - " simply never told to deliver it. No window can then\n" - " end, partition A runs for ever, and partition B never\n" - " runs at all -- so this run must report FAILED.\n"); - -#endif - - zx_check("the partitions' virtual-timer PPI reads back ENABLED and in\n" - " GROUP 1, which is what the GIC delivers as an IRQ,\n" - " straight to EL1 with no injection and no List Register", - (zx_gic_ppi_is_enabled(&zx_gic, ZX_PPI_VIRTUAL_TIMER) - && zx_gic_ppi_is_group1(&zx_gic, ZX_PPI_VIRTUAL_TIMER)) - ? 1U : 0U); - - zx_check("and the HYPERVISOR's own timer PPI is in GROUP 0, which is\n" - " what the GIC delivers as an FIQ. Routing is by\n" - " exception TYPE and not by INTID: that one bit is the\n" - " whole difference between a tick that reaches EL2 and a\n" - " tick that would have to be injected into a List\n" - " Register", - (zx_gic_ppi_is_group1(&zx_gic, ZX_PPI_HYPERVISOR_TIMER) == 0U) - ? 1U : 0U); - - /* AND THAT IT IS ENABLED, which is the check the whole refusal below - rests on. A hypervisor whose own tick is not deliverable cannot end - a window, and a frame started in that state does not fail -- it - HANGS, with the first partition running until something outside the - image stops it. That is the least informative outcome this suite can - produce and it names nothing at all. */ - - tick_deliverable = zx_gic_ppi_is_enabled(&zx_gic, - ZX_PPI_HYPERVISOR_TIMER); - zx_note("hypervisor tick PPI enabled", tick_deliverable); - - zx_check("and the PHYSICAL timer's PPI is enabled for nobody. A\n" - " partition could not read it anyway -- CNTHCTL.PL1PCTEN\n" - " and PL1PCEN are left clear on purpose -- because\n" - " physical time keeps running while a partition is\n" - " descheduled, and a guest that can read it can see that\n" - " it was not running", - (zx_gic_ppi_is_enabled(&zx_gic, ZX_PPI_PHYSICAL_TIMER) == 0U) - ? 1U : 0U); - - /* ROUTING, AND THEN EL2's OWN CPU INTERFACE. In that order, because - the second only matters once the first has changed where the guest's - own writes go. */ - - zx_el2_route_fiq(); - - zx_note("HCR after routing", zx_read_hcr()); - - /* THE BIT, BY POSITION, AND NOT BY NAME. This port had ZX_HCR_FMO and - ZX_HCR_AMO defined the other way round, and a check written as - "(HCR & ZX_HCR_FMO) != 0" passes cheerfully against either - definition -- it is the same symbol on both sides of the comparison. - What it cost was a run in which every set-up check was green, the - comparator expired, the GIC made the interrupt pending, and no window - ever ended: bit 5 is AMO, and physical FIQ had never been routed - anywhere. Bit 3 is FMO on this architecture and on this part, TRM - Table 3-70, and comparing against the NUMBER is the only form of this - check a swapped definition cannot satisfy. */ - - zx_check("HCR bit 3 -- FMO, by position and not by the name this port\n" - " happens to give it -- is SET, so a physical FIQ is\n" - " taken to EL2. And with it set, PSTATE.F is IGNORED at\n" - " EL0 and EL1: a partition cannot mask the interrupt\n" - " that ends its own window, whatever its kernel does\n" - " with its own masks", - ((zx_read_hcr() & 0x8U) != 0U) ? 1U : 0U); - zx_check("and HCR bit 5 -- AMO -- is CLEAR. Routing asynchronous aborts\n" - " to EL2 would send them to the vector that today means\n" - " 'ZoneX faulted on its own access', so a guest's abort\n" - " would be reported as a hypervisor bug. Not needed for\n" - " a window to end, and deliberately deferred", - ((zx_read_hcr() & 0x20U) == 0U) ? 1U : 0U); - zx_check("and HCR bit 4 -- IMO -- is CLEAR, so every partition interrupt\n" - " is still a physical IRQ delivered straight to EL1. The\n" - " guest side of this arrangement needed no change\n" - " whatever, which is the strongest argument the design\n" - " has", - ((zx_read_hcr() & 0x10U) == 0U) ? 1U : 0U); - - zx_gic_el2_cpu_interface_init(); - - zx_note("EL2 ICC_PMR ", zx_gic_el2_priority_mask()); - zx_note("EL2 ICC_IGRPEN0", zx_gic_el2_group0_enabled()); - - zx_check("the PHYSICAL priority mask is open, and it is the\n" - " HYPERVISOR'S to open now. Setting HCR.FMO redirects an\n" - " EL1 write of ICC_PMR to the VIRTUAL interface, so the\n" - " guest's own 'unmask everything' stops affecting\n" - " physical delivery -- and the physical mask resets to\n" - " zero, which masks it all. A partition that received\n" - " its timer end to end a moment ago would stop receiving\n" - " anything, with nothing to fault on", - (zx_gic_el2_priority_mask() != 0U) ? 1U : 0U); - - zx_check("and Group 0 is enabled at EL2's own CPU interface, without\n" - " which an FIQ could not be signalled to the core at all", - zx_gic_el2_group0_enabled()); - - /* ---------------------------------------------------------------- */ - /* A PRE-FLIGHT, BEFORE ANY PARTITION RUNS. */ - /* */ - /* A window that never ends has three possible causes in three */ - /* different places, and they present identically: a hypervisor */ - /* that starts a frame and is never heard from again, with no */ - /* fault, no message and a harness timeout that names nothing. */ - /* That is the least informative failure this suite can produce. */ - /* */ - /* So the comparator is armed for a short interval HERE, with no */ - /* partition running and FIQ still masked at EL2, and two */ - /* questions are asked separately: did it expire, and did the GIC */ - /* make the interrupt pending. A run that fails either of them */ - /* says which half is broken instead of hanging. */ - /* */ - /* The same discipline as zx_counter_is_running, one level up: */ - /* a guest that armed a timer against a stopped counter was the */ - /* failure that check was written for, and this is the */ - /* hypervisor's own version of it. */ - /* ---------------------------------------------------------------- */ - - { - uint64_t soon = zx_read_cntpct() + (uint64_t)(zx_board_counter_hz() - / 1000U); - uint32_t armed = zx_el2_hyp_timer_arm(soon); - - zx_el2_dwell_until(soon + (uint64_t)(zx_board_counter_hz() / 1000U)); - - zx_console_puts("\n--- the hypervisor's own tick, before any " - "partition runs ---\n"); - zx_note("armed for a future deadline", armed); - zx_note("CNTHP expired (ISTATUS) ", zx_el2_hyp_timer_fired()); - zx_note("GIC has PPI 26 pending ", - zx_gic_ppi_is_pending(&zx_gic, ZX_PPI_HYPERVISOR_TIMER)); - - zx_check("the hypervisor's own comparator EXPIRED. This is the\n" - " first of three questions a window that never ends\n" - " raises, and the only one that is about the timer\n" - " rather than about the interrupt controller", - zx_el2_hyp_timer_fired()); - -#ifndef ZX_TWO_NO_TICK - - zx_check("and the GIC made it PENDING, which is the second: a\n" - " comparator that expires into a controller that was\n" - " never told is indistinguishable, from the outside,\n" - " from a core that never took the exception", - zx_gic_ppi_is_pending(&zx_gic, ZX_PPI_HYPERVISOR_TIMER)); - + const uint32_t enable_hyp_tick = 1U; #endif - zx_el2_hyp_timer_stop(); - } - - zx_console_puts( - "\n" - " AND THE SAME REDIRECTION IS WHAT MAKES THIS AIRTIGHT RATHER THAN\n" - " MERELY CHEAP. ICC_IGRPEN0 is redirected too, so a partition\n" - " cannot reach the physical Group 0 enable to switch off the\n" - " interrupt that ends its window: it writes the virtual copy and\n" - " nothing happens. Before FMO was set that register was genuinely\n" - " shared, and the argument had to be that a partition was granted\n" - " no Group 0 interrupt worth enabling. Now it cannot reach the\n" - " enable at all.\n"); - - /* THREE THINGS, AND A FRAME NEEDS ALL OF THEM. The counter has to be - moving, the GIC has to be awake, and the hypervisor's own tick has to - be deliverable. Returned as one answer so that the caller has one - decision to make, and the caller's decision is to REFUSE rather than - to start a frame whose windows could never end. */ - - return ((running != 0U) && (awake != 0U) && (tick_deliverable != 0U)) - ? 1U : 0U; + return zx_frame_grant_the_clocks(&zx_gic, ZX_GUEST_TIMER_PRIORITY, + enable_hyp_tick); } diff --git a/examples/fvp_baser_aemv8r/CMakeLists.txt b/examples/fvp_baser_aemv8r/CMakeLists.txt index bab885a..e551426 100644 --- a/examples/fvp_baser_aemv8r/CMakeLists.txt +++ b/examples/fvp_baser_aemv8r/CMakeLists.txt @@ -152,6 +152,22 @@ target_compile_definitions(zx_probe_overlap.elf PRIVATE ZX_PROBE_MANIFEST_OVERLA zx_add_fvp_probe(zx_probe_badattr.elf) target_compile_definitions(zx_probe_badattr.elf PRIVATE ZX_PROBE_BAD_ATTR) +# THE SHARED GRANULE'S READ-ONLY HALF, BROKEN. The reader's copy of the +# shared range is given write permission, so its attempt to publish must +# SURVIVE and the run must report FAILED. +# +# A shared granule is the one DECLARED EXCEPTION to isolation in this +# component -- one range readable by both partitions and writable by one -- +# which makes it the place where "the check can fail" is least optional. Its +# passing condition is the ABSENCE of a successful write, and an absence +# nobody has seen turn into a presence is not evidence of anything. +# +# Only the PERMISSION field changes. The range, the attributes and the +# declared publisher are untouched, so the manifest validator has nothing to +# object to and the only thing that can catch it is the access itself. +zx_add_fvp_probe(zx_probe_shared_rw.elf) +target_compile_definitions(zx_probe_shared_rw.elf PRIVATE ZX_PROBE_SHARED_RW) + ############################################################################### # The ThreadX guest, and the image that launches it ############################################################################### @@ -449,6 +465,7 @@ if(ZX_THREADX_SOURCE_DIR) function(zx_add_two_partitions target_name) add_executable(${target_name} EXCLUDE_FROM_ALL ${ZX_COMMON_DIR}/zx_two_partitions.c + ${ZX_COMMON_DIR}/zx_frame_setup.c ${ZX_COMMON_DIR}/zx_guest_launch.c ${ZX_COMMON_DIR}/zx_report.c ${ZX_COMMON_DIR}/zx_guest_image.S @@ -530,6 +547,164 @@ if(ZX_THREADX_SOURCE_DIR) zx_add_two_partitions(zx_two_partitions_cross.elf) target_compile_definitions(zx_two_partitions_cross.elf PRIVATE ZX_TWO_CROSS) + ########################################################################### + # THE ISOLATION AND DETERMINISM REGRESSION + ########################################################################### + # + # The image the whole of this phase exists to produce: proof that neither + # partition can reach the other's memory, the ungranted granule next door + # to its own, or the hypervisor's -- and that the critical partition's + # timing is unaffected by anything the untrusted one does. + # + # IT IS A SEPARATE PROGRAM FROM zx_two_partitions.elf AND SHARES + # EVERYTHING UNDER IT: the same loader, the same frame driver, the same + # guest blobs, the same linker script and the same two windows. What + # differs is that this one ASSERTS where that one DEMONSTRATES -- sixty + # major frames instead of twenty, because a maximum over ten periods is + # not a maximum; the test-only fault-continue mode, which that image must + # not have; and a matrix rather than a narrative for output. + # + # A regression built on a different foundation from the demonstration + # would be a regression for a different hypervisor. + function(zx_add_regression target_name) + add_executable(${target_name} EXCLUDE_FROM_ALL + ${ZX_COMMON_DIR}/zx_regression.c + ${ZX_COMMON_DIR}/zx_frame_setup.c + ${ZX_COMMON_DIR}/zx_guest_launch.c + ${ZX_COMMON_DIR}/zx_report.c + ${ZX_COMMON_DIR}/zx_guest_image.S + ${ZX_FVP_DIR}/zx_board.c + ) + + add_dependencies(${target_name} zx_guest_a zx_guest_b) + + target_include_directories(${target_name} PRIVATE + ${ZX_FVP_DIR} + ${ZX_COMMON_DIR} + ) + + set_source_files_properties(${ZX_COMMON_DIR}/zx_guest_image.S + PROPERTIES + COMPILE_OPTIONS + "-Wa,-I${ZX_GUEST_A_BUILD};-Wa,-I${ZX_GUEST_B_BUILD};-DZX_GUEST_B_BLOB" + OBJECT_DEPENDS "${ZX_GUEST_A_BLOB};${ZX_GUEST_B_BLOB}" + TARGET_DIRECTORY ${target_name} + ) + + target_link_libraries(${target_name} PRIVATE + eclipse-threadx::zonex + zonex::warnings + ) + + target_link_options(${target_name} PRIVATE + -T${ZX_FVP_DIR}/zx_two_partitions.lds + -nostartfiles + -Wl,-Map=${target_name}.map + ${ZX_LINK_QUIET_RWX} + ) + + set_target_properties(${target_name} PROPERTIES + LINK_DEPENDS ${ZX_FVP_DIR}/zx_two_partitions.lds + ) + endfunction() + + # THE REGRESSION ITSELF, and it must pass. ZX_TEST_FAULT_CONTINUE is what + # lets one image sweep fourteen violation cases: without it the first case + # a partition attempts ends its excursion, and the matrix would need + # fourteen images and fourteen runs -- which on silicon is most of a + # working day and on a functional model is minutes of simulation per case. + # + # THE OPTION IS SET HERE AND NOWHERE ELSE IN THIS FILE. Every other image + # in the suite is built without it, which is the whole basis for saying the + # shipping policy is halt: it is not a claim about a default, it is a claim + # about which single target carries the definition. + zx_add_regression(zx_regression.elf) + target_compile_definitions(zx_regression.elf PRIVATE ZX_TEST_FAULT_CONTINUE) + + # HALT ON FAULT -- THE SHIPPING POLICY, TESTED RATHER THAN ASSUMED, and it + # must PASS because halting is the correct outcome. + # + # The same image without the continue option, asking each partition for + # ONE case. What it proves is the constraint that makes the continue mode + # acceptable at all: a build without the definition stops a partition on + # its first violation, records which partition and which address, and + # still reaches a verdict. Without this build, "the shipping default is + # halt" would be a claim about a path no automated run had ever taken. + # + # It is also fast: both partitions are stopped within the first couple of + # frames, so the run ends long before the frame limit. + zx_add_regression(zx_regression_halt.elf) + target_compile_definitions(zx_regression_halt.elf PRIVATE + ZX_REGRESSION_HALT_ONLY) + + # THE TWO NEGATIVE VERIFICATIONS, and they break different halves of the + # deliverable. Each must report FAILED; each starting to PASS means the + # check it was built to violate has stopped detecting the violation, which + # makes the corresponding positive row evidence of nothing. + + # A REGION LIMIT ONE GRANULE TOO GENEROUS -- the most valuable negative + # build in the suite, because it is the one whose absence would leave a + # plausible defect undetectable. Partition A's window is stretched to + # swallow the ungranted granule immediately after it, so the adjacent- + # granule case must SURVIVE and the poison in that granule must be gone. + # + # Without it, a green run of that case shows only that the hole's address + # faults -- not that the limit is EXACT. And a limit one granule too wide + # is the defect class that otherwise reads as a working system, because + # everything a partition legitimately touches still works. + # + # One granule and not a kilobyte: a wildly wrong limit would be caught by + # the manifest validator's own overlap rule, which would test the + # validator rather than the region programming. + zx_add_regression(zx_regression_widened.elf) + target_compile_definitions(zx_regression_widened.elf PRIVATE + ZX_TEST_FAULT_CONTINUE ZX_REGRESSION_WIDENED) + + # THE TIME FREEZE REMOVED. Neither partition's virtual clock is frozen + # while it is descheduled, so each sees wall clock rather than its own + # windows -- and the critical partition's own tick count then depends on + # how long its neighbour spent on the core. + # + # IT BREAKS THE GUEST-SIDE HALF AND LEAVES THE HYPERVISOR-SIDE HALF + # INTACT, which is exactly why it is worth a build. A's window PERIOD is + # still one frame -- the schedule is untouched -- so a regression that + # measured only the period would stay green. What moves is A's own ticks + # per phase, and this build is the proof that the check on them can fail. + zx_add_regression(zx_regression_no_freeze.elf) + target_compile_definitions(zx_regression_no_freeze.elf PRIVATE + ZX_TEST_FAULT_CONTINUE ZX_REGRESSION_NO_FREEZE) + + # TEN TIMES LONGER, AND NOT REGISTERED WITH CTest. + # + # One question, asked once: does the critical partition's MAXIMUM period + # stop moving? A regression that asserted a bound over sixty frames + # without ever having looked at six hundred would be asserting that its + # own sample was large enough, which is the thing in question. + # + # Not in the suite because sixty seconds of silicon is cheap and ten + # minutes of a functional model on every pull request is not. Run it by + # hand -- on the board -- and record whether the maximum changed. + # AND THE REFUSAL PATH, which must FAIL. The hypervisor's own timer PPI + # is deliberately left disabled at the GIC: the comparator still expires + # and the controller is simply never told, so no window can end. + # + # THE POINT IS THAT THIS IS A VERDICT AND NOT A HANG. A frame started in + # that state runs its first partition until something outside the image + # stops it, and the log ends mid-sentence with nothing to point at -- the + # least informative outcome this suite can produce. The regression + # refuses to start such a frame, and this build is the only one that + # reaches the refusal, because a branch nothing has ever taken is not + # evidence that it works. + # + # It is also the cheapest run in the suite: it stops before the frame. + zx_add_regression(zx_regression_no_tick.elf) + target_compile_definitions(zx_regression_no_tick.elf PRIVATE + ZX_REGRESSION_NO_TICK) + + zx_add_regression(zx_regression_long.elf) + target_compile_definitions(zx_regression_long.elf PRIVATE + ZX_TEST_FAULT_CONTINUE ZX_REGRESSION_LONG) + # And two overlapping windows, which the validator must refuse BY NAME # before a single region is programmed. Two enabled regions on one # address is CONSTRAINED UNPREDICTABLE and aborts on the S32Z280, so @@ -590,7 +765,7 @@ if(ZX_FVP_BASER_AEMV8R) # rule as above: each must report FAILED, and each starting to PASS # means the check it was built to violate has stopped detecting the # violation. - foreach(zx_negative widened overlap badattr) + foreach(zx_negative widened overlap badattr shared_rw) add_test(NAME zx-fvp-probe-${zx_negative} COMMAND ${Python3_EXECUTABLE} ${ZX_FVP_RUNNER} --elf $ @@ -670,6 +845,57 @@ if(ZX_FVP_BASER_AEMV8R) --expect fail) endforeach() + # THE ISOLATION AND DETERMINISM REGRESSION. Two that must pass and + # two that must fail. + # + # The timeout is generous because this image runs SIXTY major + # frames of simulated time with two kernels ticking through them, + # against the demonstration image's twenty -- sixty is what it + # takes for a maximum period per phase to be a maximum rather than + # a coin toss. A timeout that had to be guessed is one that + # eventually fires on somebody's slower machine and is read as a + # hang, so it is set with room rather than trimmed to the run. + add_test(NAME zx-fvp-regression + COMMAND ${Python3_EXECUTABLE} ${ZX_FVP_RUNNER} + --elf $ + --fvp ${ZX_FVP_BASER_AEMV8R} + --timeout 1200) + + # HALT ON FAULT, and it must PASS: halting is the correct outcome. + # Fast, because both partitions are stopped within the first + # frames and the run ends long before the frame limit. + add_test(NAME zx-fvp-regression-halt + COMMAND ${Python3_EXECUTABLE} ${ZX_FVP_RUNNER} + --elf $ + --fvp ${ZX_FVP_BASER_AEMV8R} + --timeout 600) + + # And the two negative verifications. Each must report FAILED; + # each starting to PASS means the check it was built to violate + # has stopped detecting the violation, which makes the + # corresponding positive row evidence of nothing. + foreach(zx_negative widened no_freeze no_tick) + add_test(NAME zx-fvp-regression-${zx_negative} + COMMAND ${Python3_EXECUTABLE} ${ZX_FVP_RUNNER} + --elf $ + --fvp ${ZX_FVP_BASER_AEMV8R} + --timeout 1200 + --expect fail) + endforeach() + + add_custom_target(zx-run-regression + COMMAND ${ZX_FVP_BASER_AEMV8R} + -C cluster0.NUM_CORES=1 + -C bp.vis.disable_visualisation=1 + -C bp.terminal_0.start_telnet=0 + -C bp.pl011_uart0.out_file=- + -C bp.pl011_uart0.unbuffered_output=1 + -a $ + DEPENDS zx_regression.elf + USES_TERMINAL + COMMENT "Running the ZoneX isolation and determinism regression on FVP_BaseR_AEMv8R..." + ) + add_custom_target(zx-run-two-partitions COMMAND ${ZX_FVP_BASER_AEMV8R} -C cluster0.NUM_CORES=1 diff --git a/examples/fvp_baser_aemv8r/guest/link_partition.lds.in b/examples/fvp_baser_aemv8r/guest/link_partition.lds.in index c389254..ec6b929 100644 --- a/examples/fvp_baser_aemv8r/guest/link_partition.lds.in +++ b/examples/fvp_baser_aemv8r/guest/link_partition.lds.in @@ -65,8 +65,8 @@ * from examples/common/zx_guest_abi.h, because it has a raw blob and no * symbol table to look anything up in: * - * +0x00 the readback mailbox, TWO granules - * +0x80 one branch to the kernel's reset symbol -- the ERET target + * +0x00 the readback mailbox, THREE granules + * +0xC0 one branch to the kernel's reset symbol -- the ERET target * * The ASSERTs at the end are what make those constants true rather than * hoped for. @@ -120,7 +120,7 @@ MEMORY SECTIONS { /* ---------------------------------------------------------------- */ - /* +0x00 the mailbox, two granules. */ + /* +0x00 the mailbox, three granules. */ /* */ /* LOADED, not NOLOAD, and that is what fixes the whole layout. */ /* objcopy -O binary emits only sections with contents, so a */ @@ -141,7 +141,7 @@ SECTIONS } > WINDOW /* ---------------------------------------------------------------- */ - /* +0x80 the entry branch, then everything executable. */ + /* +0xC0 the entry branch, then everything executable. */ /* */ /* One stage-1 region covers all of it, read-only and executable. */ /* Read-only at EL1 while stage 2 permits the write is the case */ @@ -268,15 +268,21 @@ SECTIONS ASSERT(__zx_guest_mailbox_start == __zx_guest_window_base, "the mailbox is not at the base of the partition window") - /* TWO granules, and the number is ZX_GD_WINDOW_SIZE in + /* THREE granules, and the number is ZX_GD_WINDOW_SIZE in examples/common/zx_guest_abi.h. A linker script cannot include a C header, so the constant is restated here and asserted -- which is the point: a mailbox that grew past its slot fails the LINK rather than moving an entry point the hypervisor computes as a - compile-time constant. */ - ASSERT((__zx_guest_mailbox_end - __zx_guest_mailbox_start) == 128, - "the mailbox is not exactly two granules") - ASSERT(__zx_guest_code_start == (__zx_guest_window_base + 0x80), + compile-time constant. It HAS grown, twice, and both times this + assert is what said so before anything ran. */ + ASSERT((__zx_guest_mailbox_end - __zx_guest_mailbox_start) == 192, + "the mailbox is not exactly three granules") + /* ZX_GUEST_IMAGE_OFF_ENTRY. Restated for the same reason as the + mailbox size above, and it moves with it: three granules of mailbox + put the entry branch at 0xC0. A guest whose branch is four bytes + out of place is invisible in a manifest and costs a hardware + session to find. */ + ASSERT(__zx_guest_code_start == (__zx_guest_window_base + 0xC0), "the entry branch is not at the offset the hypervisor ERETs to") ASSERT(_start >= __zx_guest_code_start, "the kernel's reset symbol is outside the guest's code region") diff --git a/examples/fvp_baser_aemv8r/zx_one_partition.lds b/examples/fvp_baser_aemv8r/zx_one_partition.lds index c83fc3b..cd15203 100644 --- a/examples/fvp_baser_aemv8r/zx_one_partition.lds +++ b/examples/fvp_baser_aemv8r/zx_one_partition.lds @@ -209,16 +209,22 @@ SECTIONS check would then be reading past the end of the section, which is the out-of-bounds read this assert exists to prevent. - 0x90 is ZX_GUEST_IMAGE_OFF_MAGIC + 4, from + 0xD0 is ZX_GUEST_IMAGE_MIN_SIZE, from examples/common/zx_guest_abi.h. A linker script cannot include a C header, so the number is restated here -- and it is restated WRONG the moment the header moves, which is exactly what happened when the mailbox grew to two granules and pushed the entry branch and the header from 0x40 to 0x80. The bound stayed at 0x50, so a blob of 0x51 to 0x8F bytes linked cleanly and the magic check read past the - section into whatever followed it. */ - - ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) >= 0x90, + section into whatever followed it. + The mailbox has since grown a THIRD granule, for the isolation + matrix, which moved the header again -- to 0xCC, so the bound is + 0xD0. The header end is a NAMED constant now, ZX_GUEST_IMAGE_MIN_ + SIZE, for exactly this reason: four scripts restating a literal is + four chances to leave one behind, and last time all four were left + behind together. */ + + ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) >= 0xD0, "the embedded guest image is empty or too small to carry its header") ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) diff --git a/examples/fvp_baser_aemv8r/zx_two_partitions.lds b/examples/fvp_baser_aemv8r/zx_two_partitions.lds index 7366804..fe0d27c 100644 --- a/examples/fvp_baser_aemv8r/zx_two_partitions.lds +++ b/examples/fvp_baser_aemv8r/zx_two_partitions.lds @@ -283,18 +283,24 @@ SECTIONS check would then be reading past the end of the section, which is the out-of-bounds read this assert exists to prevent. - 0x90 is ZX_GUEST_IMAGE_OFF_MAGIC + 4, from + 0xD0 is ZX_GUEST_IMAGE_MIN_SIZE, from examples/common/zx_guest_abi.h. A linker script cannot include a C header, so the number is restated here -- and it is restated WRONG the moment the header moves, which is exactly what happened when the mailbox grew to two granules and pushed the entry branch and the header from 0x40 to 0x80. The bound stayed at 0x50, so a blob of 0x51 to 0x8F bytes linked cleanly and the magic check read past the - section into whatever followed it. */ - - ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) >= 0x90, + section into whatever followed it. + The mailbox has since grown a THIRD granule, for the isolation + matrix, which moved the header again -- to 0xCC, so the bound is + 0xD0. The header end is a NAMED constant now, ZX_GUEST_IMAGE_MIN_ + SIZE, for exactly this reason: four scripts restating a literal is + four chances to leave one behind, and last time all four were left + behind together. */ + + ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) >= 0xD0, "guest A's embedded image is empty or too small to carry its header") - ASSERT((__zx_guest_b_blob_end - __zx_guest_b_blob_start) >= 0x90, + ASSERT((__zx_guest_b_blob_end - __zx_guest_b_blob_start) >= 0xD0, "guest B's embedded image is empty or too small to carry its header") ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) diff --git a/examples/guest_common/zx_guest_app.c b/examples/guest_common/zx_guest_app.c index 52d54e7..6291e32 100644 --- a/examples/guest_common/zx_guest_app.c +++ b/examples/guest_common/zx_guest_app.c @@ -452,6 +452,411 @@ static ULONG preemptive_phase(void) } +/**************************************************************************/ +/* THE ISOLATION MATRIX -- one sweep, every case, inside one window. */ +/* */ +/* WHAT THIS CAN AND CANNOT SAY, because getting that wrong is how a */ +/* swept matrix passes while proving nothing. */ +/* */ +/* IT CANNOT REPORT THAT A CASE WAS DENIED. Under the hypervisor's */ +/* test-only continue mode a faulting partition is resumed PAST the */ +/* faulting instruction, so the very next store -- the one that would */ +/* have recorded "this case survived" -- executes anyway, one instruction */ +/* later, on a case that was refused. A guest is not conscious of having */ +/* been refused. Every "it faulted" judgement therefore belongs to EL2 */ +/* and comes out of its fault log, matched by ADDRESS, which is why every */ +/* case below aims at an address of its own. */ +/* */ +/* WHAT IT CAN SAY HONESTLY IS WHAT IT ATTEMPTED, and EL2 cannot know */ +/* that: a case the guest refused to run and a case stage 2 denied both */ +/* leave the hypervisor with no fault to look at. Only the guest can */ +/* tell those two apart. So it publishes the attempted set and the */ +/* refused set and leaves the judging alone. */ +/* */ +/* AND ONE CASE IS THE OTHER WAY ROUND. Its own window must SUCCEED, and */ +/* success is something a guest CAN demonstrate: distinct marks written */ +/* into distinct granules and read back. It runs LAST, deliberately, so */ +/* that what the marks say is "this partition still owned every byte of */ +/* its own window after being refused six times". */ +/* */ +/* EVERY ADDRESS IS HANDED OVER RATHER THAN COMPUTED, and that is the */ +/* point rather than an inconvenience. A guest deriving its neighbour's */ +/* window from its own base would be a guest that had been TOLD the */ +/* layout; a guest handed an address it could not have derived is a guest */ +/* being asked to reach somewhere it has no business knowing about, which */ +/* is the situation the isolation claim is actually about. */ +/**************************************************************************/ + +/* THE MARKS. Written all of them, then read back all of them -- never one + at a time. A region whose BASE is wrong accepts every store and puts + them all somewhere else, so a write-then-check of one granule reads its + own store back from the same wrong place and agrees. Distinct marks at + distinct addresses, checked only after the last one is written, is what + makes the readback mean "these are five different extents". */ + +static ULONG matrix_write_marks(void) +{ + unsigned long base = guest_mailbox_read(ZX_GD_M_MARK_BASE); + unsigned long count = guest_mailbox_read(ZX_GD_M_MARK_COUNT); + unsigned long sentinel = guest_mailbox_read(ZX_GD_SCRATCH); + unsigned long index; + ULONG agreed = 1UL; + + if ((base == 0UL) || (count == 0UL)) + { + guest_mailbox_write(ZX_GD_M_MARKS_WRITTEN, 0UL); + guest_mailbox_write(ZX_GD_M_MARKS_OK, 0UL); + + return 0UL; + } + + if (guest_owns_range(base, count * 64UL) == 0U) + { + console_puts("REFUSING to mark: the range I was handed is not inside\n" + "the writable part of my own window\n"); + guest_mailbox_write(ZX_GD_M_MARKS_WRITTEN, 0UL); + guest_mailbox_write(ZX_GD_M_MARKS_OK, 0UL); + + return 0UL; + } + + for (index = 0UL; index < count; index++) + { + volatile ULONG *granule = + (volatile ULONG *)(void *)(base + (index * 64UL)); + + *granule = (ULONG) ZX_MARK_FOR(sentinel, index); + } + + /* PUBLISHED BEFORE THE READBACK, so that a partition stopped between + the two is recorded as having written them rather than as having + written nothing. */ + + guest_mailbox_write(ZX_GD_M_MARKS_WRITTEN, count); + + for (index = 0UL; index < count; index++) + { + volatile const ULONG *granule = + (volatile const ULONG *)(const void *)(base + (index * 64UL)); + + if (*granule != (ULONG) ZX_MARK_FOR(sentinel, index)) + { + agreed = 0UL; + } + } + + guest_mailbox_write(ZX_GD_M_MARKS_OK, (unsigned long) agreed); + + console_puts("marked "); + console_putdec(count); + console_puts(" granules of my own window, each with a different value,\n" + "and read every one of them back: "); + console_puts((agreed != 0UL) ? "all agreed\n" + : "*** ONE DISAGREED ***\n"); + + return (ULONG) ZX_MC_OWN_MARKS; +} + + +/* One denial case: grant the granule at stage 1, then touch it. + * + * THE GRANT IS THE HALF THAT MAKES THIS A STAGE-2 TEST AT ALL. Without it + * stage 1 refuses first, the abort is taken to EL1, and the hypervisor + * never hears about it -- so the run would demonstrate the guest's own MPU + * working and would say nothing whatever about the partition boundary. + * + * Returns the case's bit when the access may be attempted, and ORs a reason + * into *refused_ptr when it may not. */ + +static ULONG matrix_grant(unsigned long address, ULONG bit, + unsigned int executable, ULONG *refused_ptr) +{ + unsigned int granted; + + if (address == 0UL) + { + *refused_ptr |= (ULONG) ZX_MR_NO_ADDRESS; + + return 0UL; + } + + granted = guest_grant_scratch_region(address, executable); + + if (granted == ZX_GRANT_ALREADY) + { + /* The hypervisor pointed this case at memory the guest genuinely + owns. Reported rather than run: an access that succeeds because + it was always permitted is not evidence about anything, and a + sweep counting it as a pass would be counting its own mistake. */ + + *refused_ptr |= (ULONG) ZX_MR_INSIDE_ITSELF; + + return 0UL; + } + + if (granted == ZX_GRANT_NO_REGION) + { + *refused_ptr |= (ULONG) ZX_MR_NO_REGION; + + return 0UL; + } + + return bit; +} + + +static ULONG isolation_matrix(void) +{ + ULONG cases = (ULONG) guest_mailbox_read(ZX_GD_M_CASES); + ULONG attempted = 0UL; + ULONG refused = 0UL; + ULONG progress = 0UL; + + if (cases == 0UL) + { + return 0UL; + } + + console_puts("\nTHE ISOLATION MATRIX. For every address below I grant\n" + "MYSELF the granule in my own MPU first, so stage 1 permits\n" + "the access and I genuinely believe I own the memory.\n" + "Stage 2 has the last word, and I am not able to tell you\n" + "whether it took it -- only the hypervisor can.\n"); + + /* CASE 1 -- the neighbour's data, READ. + * + * THE POISON GOES IN FIRST, AND IT IS NOT ENOUGH. A denied load never + * writes its destination register -- but under the continue mode the + * hypervisor resumes this guest at the instruction AFTER the load, and + * that instruction is the store of the loaded value. So the mailbox + * gets written with whatever the compiler happened to leave in that + * register, which on a real run was the number one. The poison is + * still written because a word nobody wrote is worse than a word + * written with rubbish, but it CANNOT be what the check tests against. + * + * The hypervisor tests the reported value against what is REALLY at + * that address, which it can read and this guest cannot. That is the + * claim anyway -- the neighbour's data did not reach this partition -- + * rather than the proxy for it. */ + + if ((cases & (ULONG) ZX_MC_NEIGHBOUR_READ) != 0UL) + { + unsigned long address = guest_mailbox_read(ZX_GD_M_NEIGHBOUR); + ULONG bit = matrix_grant(address, + (ULONG) ZX_MC_NEIGHBOUR_READ, + 0U, &refused); + + if (bit != 0UL) + { + volatile const ULONG *target = + (volatile const ULONG *)(const void *)address; + + guest_mailbox_write(ZX_GD_M_READ_VALUE, + (unsigned long) ZX_READ_POISON); + attempted |= bit; + guest_mailbox_write(ZX_GD_M_ATTEMPTED, (unsigned long) attempted); + + console_puts(" reading my neighbour's data at "); + console_puthex(address); + console_puts("\n"); + + guest_mailbox_write(ZX_GD_M_READ_VALUE, (unsigned long) *target); + } + } + + /* CASE 2 -- the neighbour's data, WRITTEN. A DIFFERENT WORD of the same + granule, so that this case has an address of its own and the fault log + can name it. Two cases sharing one address would be two log entries + the hypervisor could not tell apart -- and "the read was refused and + the write was not" is exactly the asymmetry worth being able to see, + since a region set with the wrong AP grants one and denies the + other. */ + + if ((cases & (ULONG) ZX_MC_NEIGHBOUR_WRITE) != 0UL) + { + unsigned long neighbour = guest_mailbox_read(ZX_GD_M_NEIGHBOUR); + unsigned long address = (neighbour != 0UL) ? (neighbour + 4UL) : 0UL; + ULONG bit = matrix_grant(address, + (ULONG) ZX_MC_NEIGHBOUR_WRITE, + 0U, &refused); + + if (bit != 0UL) + { + volatile ULONG *target = (volatile ULONG *)(void *)address; + + attempted |= bit; + guest_mailbox_write(ZX_GD_M_ATTEMPTED, (unsigned long) attempted); + + console_puts(" writing into my neighbour's data at "); + console_puthex(address); + console_puts("\n"); + + *target = (ULONG) ZX_GUEST_SENTINEL_A; + } + } + + /* CASE 4 -- the ADJACENT ungranted granule, which is the case that + catches a region limit out by ONE granule. That is the defect class + which otherwise reads as a working system: an address far from every + grant proves only that unmapped memory faults, and two region sets a + whole kilobyte apart would pass a test that only touched the + neighbour's data. */ + + if ((cases & (ULONG) ZX_MC_HOLE_WRITE) != 0UL) + { + unsigned long address = guest_mailbox_read(ZX_GD_M_HOLE); + ULONG bit = matrix_grant(address, (ULONG) ZX_MC_HOLE_WRITE, + 0U, &refused); + + if (bit != 0UL) + { + volatile ULONG *target = (volatile ULONG *)(void *)address; + + attempted |= bit; + guest_mailbox_write(ZX_GD_M_ATTEMPTED, (unsigned long) attempted); + + console_puts(" writing into the granule immediately next door\n" + " to my own window, at "); + console_puthex(address); + console_puts(", which belongs to nobody\n"); + + *target = (ULONG) ZX_GUEST_SENTINEL_A; + } + } + + /* CASE 5 -- the HYPERVISOR'S OWN memory, read and then written. The + case a safety reviewer asks about first, and the one an "everything + is mapped, the guest just is not supposed to look" design fails. + Read BEFORE write, so that a denied read stepped over by the continue + path still leaves the write to be attempted. + * + THE READ LANDS IN ZX_GD_PROBE_SCRATCH, which is the word that exists + to be written by a probe and read by nobody. It is there so the load + cannot be optimised away, and it is that word rather than any other + because a probe writing into a word something reports through was a + real defect once: the sentinel landed on the progress bits and the + seal then certified them. */ + + if ((cases & (ULONG) ZX_MC_HYP_DATA) != 0UL) + { + unsigned long address = guest_mailbox_read(ZX_GD_M_HYP_DATA); + ULONG bit = matrix_grant(address, (ULONG) ZX_MC_HYP_DATA, + 0U, &refused); + + if (bit != 0UL) + { + volatile ULONG *target = (volatile ULONG *)(void *)address; + + attempted |= bit; + guest_mailbox_write(ZX_GD_M_ATTEMPTED, (unsigned long) attempted); + + console_puts(" reading and then writing the HYPERVISOR'S own\n" + " memory at "); + console_puthex(address); + console_puts("\n"); + + guest_mailbox_write(ZX_GD_PROBE_SCRATCH, (unsigned long) *target); + *(target + 1) = (ULONG) ZX_GUEST_SENTINEL_A; + } + } + + /* CASE 6 -- hypervisor MMIO. A partition that could reach the console + could print as its neighbour; one that could reach the interrupt + controller could switch off the interrupt that ends its own window. */ + + if ((cases & (ULONG) ZX_MC_HYP_MMIO) != 0UL) + { + unsigned long address = guest_mailbox_read(ZX_GD_M_HYP_MMIO); + ULONG bit = matrix_grant(address, (ULONG) ZX_MC_HYP_MMIO, + 0U, &refused); + + if (bit != 0UL) + { + volatile ULONG *target = (volatile ULONG *)(void *)address; + + attempted |= bit; + guest_mailbox_write(ZX_GD_M_ATTEMPTED, (unsigned long) attempted); + + console_puts(" writing to hypervisor MMIO at "); + console_puthex(address); + console_puts("\n"); + + *target = (ULONG) ZX_GUEST_SENTINEL_A; + } + } + + /* CASE 3 -- EXECUTE the neighbour's code. Last of the denials, and the + only one whose continuation depends on the resume point the helper + publishes: a prefetch abort cannot be stepped over, because the + instruction that would be stepped over is the one that could not be + fetched. Running it after the data cases means a defect in that path + costs the sweep one case rather than all of them. + * + THE GRANT IS EXECUTABLE HERE. A branch into memory the guest's own + MPU marks execute-never is refused by STAGE 1, at EL1, and never + reaches the hypervisor -- so an execute case run through an ordinary + data grant would report a stage-1 fault and prove nothing at all + about stage 2. */ + + if ((cases & (ULONG) ZX_MC_NEIGHBOUR_EXEC) != 0UL) + { + unsigned long address = guest_mailbox_read(ZX_GD_M_NEIGHBOUR_CODE); + ULONG bit = matrix_grant(address, + (ULONG) ZX_MC_NEIGHBOUR_EXEC, + 1U, &refused); + + if (bit != 0UL) + { + attempted |= bit; + guest_mailbox_write(ZX_GD_M_ATTEMPTED, (unsigned long) attempted); + + console_puts(" BRANCHING into my neighbour's code at "); + console_puthex(address); + console_puts("\n"); + + if (guest_branch_probe(address, + guest_mailbox_slot(ZX_GD_M_RESUME)) != 0UL) + { + console_puts(" *** the branch SUCCEEDED and returned. I\n" + " *** executed my neighbour's code.\n"); + } + } + } + + /* THE SCRATCH GRANT COMES OFF HERE, before the case that must succeed. + A stage-1 permission left standing after the sweep is a partition + carrying a grant nothing gave it into every window that follows, and + every later claim in the run would then be about a machine nobody + described. */ + + guest_release_scratch_region(); + + /* CASE 7 -- ITS OWN WINDOW, and this one must SUCCEED. A regression + that only proved things fault would pass with every region + disabled. */ + + if ((cases & (ULONG) ZX_MC_OWN_MARKS) != 0UL) + { + if (matrix_write_marks() != 0UL) + { + attempted |= (ULONG) ZX_MC_OWN_MARKS; + progress |= (ULONG) ZX_GP_MARKS_OK; + } + } + + guest_mailbox_write(ZX_GD_M_ATTEMPTED, (unsigned long) attempted); + guest_mailbox_write(ZX_GD_M_REFUSED, (unsigned long) refused); + + console_puts("the sweep is over. I attempted "); + console_puthex((unsigned long) attempted); + console_puts(" and refused "); + console_puthex((unsigned long) refused); + console_puts("\n"); + + return progress; +} + + /**************************************************************************/ /* endless_phase -- what a partition does when it has finished but the */ /* frame has not. */ @@ -492,11 +897,79 @@ static ULONG preemptive_phase(void) /* iteration would spend the partition's window computing a checksum */ /* nobody reads. These words are outside the seal and zx_guest_abi.h */ /* says so. */ +/* */ +/* AND IT IS STEERABLE, WHICH IS WHAT MAKES THE DETERMINISM RUN ONE RUN. */ +/* */ +/* ZX_GD_M_BEHAVIOUR is re-read on every iteration, so the hypervisor can */ +/* change what this partition is DOING between phases of a single run: */ +/* quiet, then computing, then computing with its interrupts masked, then */ +/* storming the console, then violating its boundary over and over. Each */ +/* is one row of the determinism table, and the critical partition's */ +/* period is measured continuously across all of them. */ +/* */ +/* SIX BUILDS WOULD HAVE BEEN THE OBVIOUS SHAPE AND IT IS THE WEAKER ONE. */ +/* Six images is six boots, six epochs and six sets of switch */ +/* measurements, so comparing the critical partition's period across them */ +/* compares RUNS rather than phases -- and a difference between two runs */ +/* has a dozen explanations that a difference between two phases of one */ +/* run does not. It is also a sixth of the model time. */ +/* */ +/* RE-READING COSTS NOTHING THE MEASUREMENT CARES ABOUT. It is one load */ +/* from a granule this partition already owns: no hypercall, no kernel */ +/* call, and nothing the masked phase could not do with its interrupts */ +/* off -- which is what lets the hypervisor steer a partition OUT of a */ +/* phase it entered by disabling interrupts. A design that needed an */ +/* interrupt to end the masked phase could not have one. */ /**************************************************************************/ +/* How much arithmetic one pass of a compute phase does. + * + * A BOUND AND NOT A DURATION. Its only job is to be long enough that the + * partition is genuinely computing for a useful fraction of its window and + * short enough that the behaviour word is re-read often enough to steer. + * Nothing rests on the number: the phase is ended by the hypervisor + * changing the word, never by this loop finishing. */ + +#define GUEST_COMPUTE_PASS 4000UL + +static ULONG endless_compute(ULONG seed) +{ + ULONG index; + ULONG value = seed; + + /* Arithmetic on a volatile-free local, so the compiler keeps it: a + compute phase optimised away would leave a partition that looked + busy in the log and was idle on the core, which is the one thing + this phase must not be. The accumulator is returned and published + for the same reason. */ + + for (index = 0UL; index < (ULONG) GUEST_COMPUTE_PASS; index++) + { + value = (value * 1103515245UL) + 12345UL; + value ^= (value >> 7); + } + + return value; +} + + static void endless_phase(ULONG options) { - ULONG live = 0UL; + ULONG live = 0UL; + ULONG work = 1UL; + ULONG seen = 0UL; + ULONG masked = 0UL; + ULONG behaviour = (ULONG) ZX_GB_QUIET; + + /* A VALUE NO BEHAVIOUR TAKES, so that the FIRST pass through the loop + counts as a change and the opening behaviour is acknowledged like + every other one. Initialising this to ZX_GB_QUIET instead left the + quiet phase unacknowledged -- the hypervisor's check that B had + obeyed every behaviour it was given saw four of five and failed, + which is the check working: a phase nobody entered and a phase + nobody recorded are the same thing from EL2. */ + + ULONG previous = 0xFFFFFFFFUL; guest_mailbox_write(ZX_GD_VCT_START, guest_virtual_count()); @@ -510,24 +983,137 @@ static void endless_phase(ULONG options) /* Both masks. IRQ is this kernel's own tick; F is the one that does nothing, and it is set precisely so that the run - demonstrates it doing nothing. */ + demonstrates it doing nothing. + + ZX_GO_HOG MASKS FOR THE WHOLE RUN and is not a phase. It is the + build that turns the central design claim into a test result, so + it must not be steerable: a hog the hypervisor could talk out of + hogging would be proving something weaker. */ __asm__ volatile("cpsid if" ::: "memory"); + masked = 1UL; } else { console_puts("finished, and staying runnable so that the frame has\n" "something to preempt. My tick count and my own\n" - "virtual counter keep going into the mailbox.\n"); + "virtual counter keep going into the mailbox, and I do\n" + "whatever the hypervisor's behaviour word asks of me.\n"); } for (;;) { live++; + /* THE THREE WORDS FIRST, on every iteration and in every phase. + They are what the temporal claim is checked against from the + inside, and a phase that stopped publishing them would look + exactly like a partition that had stopped running. Three stores + into a granule this partition owns: no hypercall, no kernel call, + and nothing the masked phase cannot do. */ + guest_mailbox_write(ZX_GD_LIVE, live); guest_mailbox_write(ZX_GD_VCT_NOW, guest_virtual_count()); guest_mailbox_write(ZX_GD_TICKS_NOW, tx_time_get()); + + if ((options & (ULONG) ZX_GO_HOG) != 0UL) + { + /* The hog build does its own thing and is not steered. */ + work = endless_compute(work); + + continue; + } + + behaviour = (ULONG) guest_mailbox_read(ZX_GD_M_BEHAVIOUR); + + if (behaviour != previous) + { + /* LEAVING a phase, before entering the next one. Each of the + three phases that changes the machine's state undoes it here + rather than at the point of entry, so that the state a phase + establishes is exactly the state that phase is measured + under -- and so that no two phases can ever be half in force + at once. */ + + if ((previous == (ULONG) ZX_GB_MASKED) && (masked != 0UL)) + { + __asm__ volatile("cpsie if" ::: "memory"); + masked = 0UL; + } + + if (previous == (ULONG) ZX_GB_STORM) + { + console_set_quiet(1U); + } + + if (previous == (ULONG) ZX_GB_FAULT) + { + guest_release_scratch_region(); + } + + /* ENTERING the next one. */ + + if (behaviour == (ULONG) ZX_GB_MASKED) + { + __asm__ volatile("cpsid if" ::: "memory"); + masked = 1UL; + } + + if (behaviour == (ULONG) ZX_GB_STORM) + { + console_set_quiet(0U); + } + + if (behaviour == (ULONG) ZX_GB_FAULT) + { + /* THE GRANT IS WHAT MAKES THIS A STAGE-2 FAULT. Without + it the guest's own MPU refuses first, the abort is taken + to EL1, and the hypervisor is never involved -- so the + phase would measure a partition faulting on ITSELF, + which costs its neighbour nothing for an entirely + different reason. */ + + (void) guest_grant_scratch_region( + guest_mailbox_read(ZX_GD_M_NEIGHBOUR), 0U); + } + + seen |= (ULONG) 1U << behaviour; + guest_mailbox_write(ZX_GD_M_PHASE_SEEN, (unsigned long) seen); + previous = behaviour; + } + + if (behaviour == (ULONG) ZX_GB_STORM) + { + /* A hypercall per character, which on silicon is the slowest + thing a partition can do to itself. Its neighbour must not + notice. */ + + console_puts("B is talking, and talking, and talking, which is\n" + "one hypercall per character through a console it\n" + "does not own.\n"); + } + else if (behaviour == (ULONG) ZX_GB_FAULT) + { + unsigned long address = guest_mailbox_read(ZX_GD_M_NEIGHBOUR); + + if (address != 0UL) + { + volatile ULONG *target = (volatile ULONG *)(void *)address; + + *target = (ULONG) ZX_GUEST_SENTINEL_A; + } + } + else if (behaviour != (ULONG) ZX_GB_QUIET) + { + /* SPIN and MASKED both compute; the difference between them is + the mask, which was set above and is the whole point. */ + + work = endless_compute(work); + } + else + { + /* Quiet: the three words above and nothing else. */ + } } } @@ -669,6 +1255,18 @@ static void consumer_entry(ULONG thread_input) * has to be able to fail, which is what the granted-address build is * for. */ + /* THE SWEEP, if the hypervisor asked for one. It and the single probe + below are alternatives rather than a sequence: the probe aims one + access at one address and ENDS the excursion when stage 2 refuses it, + which is the shipping fault policy and the right shape for an image + demonstrating one violation. The sweep needs the hypervisor's + test-only continue mode to get past its first case at all, so an + image that set both would run the sweep and then have its probe cut + short by whichever case happened to be last. The hypervisor sets one + or the other; this is where they part. */ + + progress |= isolation_matrix(); + if (guest_mailbox_read(ZX_GD_TARGET) != 0UL) { volatile ULONG *target = diff --git a/examples/guest_common/zx_guest_bsp.c b/examples/guest_common/zx_guest_bsp.c index 0350369..b3f9fec 100644 --- a/examples/guest_common/zx_guest_bsp.c +++ b/examples/guest_common/zx_guest_bsp.c @@ -690,22 +690,33 @@ static unsigned int within(unsigned long address, const char *base, } -unsigned int guest_grant_probe_region(unsigned long target) +static unsigned int guest_covers(unsigned long target) { - unsigned long granule_base = target & GRANULE_MASK; - if (within(target, zx_guest_mailbox, &zx_guest_mailbox[ZX_GD_WINDOW_SIZE]) != 0U) { - return 0U; + return 1U; } if (within(target, __zx_guest_code_start, __zx_guest_code_end) != 0U) { - return 0U; + return 1U; } if (within(target, __zx_guest_data_start, __zx_guest_window_end) != 0U) + { + return 1U; + } + + return 0U; +} + + +unsigned int guest_grant_probe_region(unsigned long target) +{ + unsigned long granule_base = target & GRANULE_MASK; + + if (guest_covers(target) != 0U) { return 0U; } @@ -728,6 +739,117 @@ unsigned int guest_grant_probe_region(unsigned long target) } +/**************************************************************************/ +/* guest_grant_scratch_region / guest_release_scratch_region */ +/* */ +/* ONE region, reprogrammed per case. The rationale is in */ +/* zx_guest_bsp.h; what matters here is the two properties that make it */ +/* safe to reuse. */ +/* */ +/* IT IS CLAIMED ONCE AND KEPT. The index is allocated on the first */ +/* call and never released back into guest_mpu_regions_used, so a later */ +/* guest_grant_probe_region cannot be handed the same index and end up */ +/* with two enabled regions on one address -- which is CONSTRAINED */ +/* UNPREDICTABLE and aborts on the S32Z280. Reuse WITHIN the scratch */ +/* region is safe for the same reason it is cheap: there is only ever one */ +/* of it, and reprogramming a region is not the same as adding one. */ +/* */ +/* AND IT IS DISABLED WHEN THE SWEEP ENDS. A grant left standing is a */ +/* partition carrying a stage-1 permission the manifest never gave it */ +/* into every window that follows, which would make every later claim in */ +/* the run about a machine in a state nobody described. */ +/**************************************************************************/ + +static unsigned int guest_scratch_index; +static unsigned int guest_scratch_claimed; + +unsigned int guest_grant_scratch_region(unsigned long target, + unsigned int executable) +{ + unsigned long granule_base = target & GRANULE_MASK; + + if (guest_covers(target) != 0U) + { + return ZX_GRANT_ALREADY; + } + + if (guest_scratch_claimed == 0U) + { + if (guest_mpu_region_count() <= guest_mpu_regions_used) + { + return ZX_GRANT_NO_REGION; + } + + guest_scratch_index = guest_mpu_regions_used; + guest_mpu_regions_used++; + guest_scratch_claimed = 1U; + } + + /* EXECUTABLE MEANS READ-ONLY AS WELL, and that pairing is forced rather + than chosen. A region that is both writable and executable at EL1 is + what the linker's own RWX warning exists to complain about, and the + execute case does not need to write -- it needs to BRANCH. Asking + for both would widen the grant past what the case demonstrates. */ + + program_region(guest_scratch_index, granule_base, granule_base + 63UL, + (executable != 0U) ? EL1_AP_PRIV_RO : EL1_AP_PRIV_RW, + (executable != 0U) ? 0UL : 1UL, + ATTR_NORMAL_WB); + + __asm__ volatile("dsb" ::: "memory"); + __asm__ volatile("isb" ::: "memory"); + + return ZX_GRANT_PROGRAMMED; +} + + +unsigned int guest_owns_range(unsigned long base, unsigned long size) +{ + unsigned long low = (unsigned long)(unsigned char *)__zx_guest_data_start; + unsigned long high = (unsigned long)(unsigned char *)__zx_guest_window_end; + + /* The FREELY WRITABLE part, which starts at .data and not at the window + base. The mailbox and the code are inside the window and inside the + guest's own regions, and marking either would corrupt the report or + the program producing it. */ + + if ((base < low) || (size == 0UL)) + { + return 0U; + } + + /* Written as a subtraction against the top rather than as base + size, + because base + size on a range at the end of the address space wraps + and the comparison then passes. No window is there today; a check + that only works for windows below 4 GB minus a bit is not a check. */ + + if (size > (high - base)) + { + return 0U; + } + + return 1U; +} + + +void guest_release_scratch_region(void) +{ + unsigned long zero = 0UL; + + if (guest_scratch_claimed == 0U) + { + return; + } + + __asm__ volatile("mcr p15, 0, %0, c6, c2, 1" + : : "r"((unsigned long)guest_scratch_index)); + __asm__ volatile("isb"); + __asm__ volatile("mcr p15, 0, %0, c6, c3, 1" : : "r"(zero)); /* EN=0 */ + __asm__ volatile("isb"); + __asm__ volatile("dsb" ::: "memory"); +} + + /**************************************************************************/ /* guest_vectors_install */ /* */ @@ -895,6 +1017,12 @@ void guest_mailbox_write(unsigned long offset, unsigned long value) } +volatile unsigned long *guest_mailbox_slot(unsigned long offset) +{ + return mailbox_word(offset); +} + + /**************************************************************************/ /* guest_report */ /* */ diff --git a/examples/guest_common/zx_guest_bsp.h b/examples/guest_common/zx_guest_bsp.h index 6f734e7..cb47ef9 100644 --- a/examples/guest_common/zx_guest_bsp.h +++ b/examples/guest_common/zx_guest_bsp.h @@ -204,6 +204,74 @@ unsigned int guest_mpu_is_enabled(void); unsigned int guest_grant_probe_region(unsigned long target); +/* THE SAME THING, FOR A SWEEP, and it costs ONE region rather than one per + * case. + * + * guest_grant_probe_region above allocates a fresh region per call, which is + * right for a single probe and wrong for the isolation matrix: six cases + * would consume six of the sixteen regions an Armv8-R part is allowed to + * have, and a part at the low end of that range would run out mid-sweep and + * report the remaining cases as denied when they had simply never been + * attempted. A sweep whose length is bounded by the region budget is a + * sweep that silently shortens on the smallest target. + * + * So this REPROGRAMS one dedicated scratch region instead. The cost is + * fixed at one region however many cases there are, and the region is + * DISABLED again by guest_release_scratch_region -- because a stale grant + * left enabled at the end of a sweep is a guest carrying a permission + * nothing granted it into every window that follows. + * + * `executable` asks for XN clear, which case 3 needs and nothing else does. + * A branch into memory the guest's own MPU marks execute-never is refused by + * STAGE 1, at EL1, and never reaches the hypervisor -- so an execute case + * run through a non-executable grant would report a stage-1 fault and prove + * nothing whatever about stage 2. + * + * Returns one of the ZX_GRANT_* codes below, because "already covered" and + * "no region to spare" are different facts and the matrix reports them + * differently: the first makes a case meaningless, the second makes it + * unattempted. */ + +#define ZX_GRANT_PROGRAMMED 0U /* the scratch region now covers it */ +#define ZX_GRANT_ALREADY 1U /* the guest's own regions cover it */ +#define ZX_GRANT_NO_REGION 2U /* nothing to spare -- not attempted */ + +unsigned int guest_grant_scratch_region(unsigned long target, + unsigned int executable); + +void guest_release_scratch_region(void); + +/* Is this range inside the part of the window the guest can freely write -- + * above its own image and below the window's end? + * + * ASKED BECAUSE THE HYPERVISOR COMPUTES THE MARK RANGE, from the manifest, + * which is the right source: the guest must not be the one deciding which + * of its granules count as evidence. But a hypervisor that had computed it + * wrongly would have the guest scribble over its own kernel, and the run + * would then fail somewhere with no visible connection to the mistake. Two + * independent statements of the same geometry, checked against each other, + * cost four comparisons. */ + +unsigned int guest_owns_range(unsigned long base, unsigned long size); + +/* Branch to an address the guest must not be able to execute, publishing + * where it wants to be resumed if the branch is refused. + * + * Implemented in zx_guest_head.S, for a reason worth knowing before reading + * the call: the address of the instruction after a branch is not something + * C can name portably, and taking the address of a label is a GNU extension + * this project builds with extensions OFF specifically to reject. + * + * Returns 0 when the hypervisor put the guest back at the published point -- + * which is what a REFUSED branch looks like from here -- and 1 when the + * branch succeeded and the target returned, which is an isolation failure. + * It cannot report the branch succeeding and NOT returning: that is the + * other partition's kernel starting up on this guest's stack, and no code + * here would run again to say so. */ + +unsigned long guest_branch_probe(unsigned long target, + volatile unsigned long *resume_slot); + /* Install the guest's own EL1 vector table. Called before anything can fault at EL1 with an opinion worth recording. */ @@ -229,4 +297,14 @@ void guest_report(unsigned long progress_bits); unsigned long guest_mailbox_read(unsigned long offset); void guest_mailbox_write(unsigned long offset, unsigned long value); +/* THE ADDRESS of one mailbox word, for the one caller that needs to hand a + mailbox slot to something else rather than read or write it here: the + execute case's assembly helper, which publishes its resume point. Kept + to that one purpose deliberately -- a pointer into the mailbox handed + around freely would make "who writes this word" unanswerable, and the + whole layout in zx_guest_abi.h is built on that question having one + answer per word. */ + +volatile unsigned long *guest_mailbox_slot(unsigned long offset); + #endif /* ZX_GUEST_BSP_H */ diff --git a/examples/guest_common/zx_guest_head.S b/examples/guest_common/zx_guest_head.S index 51b82a1..fb47abe 100644 --- a/examples/guest_common/zx_guest_head.S +++ b/examples/guest_common/zx_guest_head.S @@ -104,19 +104,24 @@ .arm /**************************************************************************/ -/* The readback mailbox: two granules at the window base. */ +/* The readback mailbox: three granules at the window base. */ /* */ -/* Thirty-two words of zero, in its own loaded section. The hypervisor */ +/* Forty-eight words of zero, in its own loaded section. The hypervisor */ /* writes the handover fields into it after copying the image and reads */ /* the reported ones out afterwards; stage-2 AP cannot deny EL2 access to */ /* a partition's memory, which is what makes the mailbox free. */ /* */ -/* The second granule is what a guest with a timer of its own has to say, */ -/* and it costs nothing at stage 2 -- the partition window is one region */ -/* covering the whole of it. It does cost the guest's stage-1 region for */ -/* the mailbox 64 more bytes, and it moves the entry branch, which is why */ -/* the offsets in zx_guest_abi.h and the ASSERTs in the guest's linker */ -/* script both name 0x80 rather than 0x40. */ +/* The second granule is what a guest with a timer of its own has to say */ +/* and the third is the isolation matrix -- the addresses a partition is */ +/* asked to reach for, and what it attempted. Neither costs anything at */ +/* stage 2: the partition window is one region covering the whole of it. */ +/* Each costs the guest's stage-1 region for the mailbox 64 more bytes, */ +/* and each MOVES THE ENTRY BRANCH, which is why the offsets in */ +/* zx_guest_abi.h and the ASSERTs in the guest's linker script now name */ +/* 0xC0 rather than 0x80 or 0x40 -- and why the image header carries a */ +/* version the loader refuses on a mismatch. The size is taken from */ +/* ZX_GD_WINDOW_SIZE rather than written out, so this section cannot be */ +/* the copy that was forgotten. */ /**************************************************************************/ .section .zx_guest_mailbox, "aw", %progbits @@ -168,9 +173,14 @@ zx_guest_entry: /* So the guest declares it. Three words the loader can check before it */ /* ERETs anywhere: */ /* */ -/* +0x44 the window base this image was linked for */ -/* +0x48 the window size it was linked to fit */ -/* +0x4C a magic number saying it is a ZoneX guest at all */ +/* +4 the window base this image was linked for */ +/* +8 the window size it was linked to fit */ +/* +12 a magic number saying it is a ZoneX guest at all, AND which */ +/* version of this contract it was built against */ +/* */ +/* Offsets from the entry branch rather than from the window base, so */ +/* that this comment does not have to be edited every time the mailbox */ +/* grows. It has been edited twice for that reason already. */ /* */ /* The magic earns its four bytes twice over: a window that was never */ /* written reads as zero, and an .incbin whose input pattern matched */ @@ -332,3 +342,84 @@ zx_guest_trap_pabt_hang: zx_guest_trap_irq: b el1_irq_entry .size zx_guest_trap_irq, . - zx_guest_trap_irq + +/**************************************************************************/ +/* guest_branch_probe -- branch somewhere the guest must not be able to */ +/* execute, and survive being refused. */ +/* */ +/* unsigned long guest_branch_probe(unsigned long target, */ +/* volatile unsigned long *resume); */ +/* */ +/* r0 = the address to branch to, r1 = where to publish the resume point. */ +/* Returns 1 if the branch RETURNED, which on a correct run it does not. */ +/* */ +/* WHY THIS IS ASSEMBLY AND NOT C. */ +/* */ +/* The isolation matrix's execute case needs the address of the */ +/* instruction AFTER a branch, and it needs it as data, before the branch */ +/* is taken. C has no portable way to name that: taking the address of a */ +/* label is a GNU extension, and ZoneX builds with extensions off and */ +/* -Wpedantic precisely so that a construct like that fails the build */ +/* rather than the port. Four instructions of assembly is the honest */ +/* answer. */ +/* */ +/* WHY THE GUEST PUBLISHES THE RESUME POINT AT ALL. */ +/* */ +/* A stage-2 PREFETCH abort cannot be continued the way a data abort can. */ +/* "Step over the faulting instruction" needs the instruction's length, */ +/* and there is no instruction: the fetch is what failed. Resuming at */ +/* ELR + 4 would resume INSIDE the neighbour's window and fault again on */ +/* the next word, and again, for the rest of the window -- a partition */ +/* walking through its neighbour's code taking one exception per */ +/* instruction. */ +/* */ +/* So the guest names where it wants to be put back, and the hypervisor's */ +/* test-only continue path uses that and nothing else. If the word is */ +/* zero the hypervisor declines and stops the partition, which is the */ +/* shipping behaviour and the safe default. The address is published */ +/* with a DSB in front of the branch so that it is visible to EL2 before */ +/* the fault it is for can be taken. */ +/* */ +/* BLX RATHER THAN BX, so that a branch which WRONGLY SUCCEEDED comes */ +/* back here and is reported instead of running the neighbour's code with */ +/* this guest's stack. It should not be possible; a test whose failure */ +/* mode is "the other partition's kernel starts up underneath us" is not */ +/* a test worth the risk when one letter avoids it. */ +/**************************************************************************/ + + .section .zx_guest_head, "ax", %progbits + .balign 4 + .global guest_branch_probe + .type guest_branch_probe, %function +guest_branch_probe: + push {r4, lr} @ r4 is padding: AAPCS wants SP at 8 + + @ The resume point, published BEFORE the branch and with a DSB behind + @ it, so that EL2 can read it while handling the fault the branch is + @ about to take. r0 is not touched: it is the target. + + adr r2, guest_branch_probe_back + str r2, [r1] + dsb + isb + + blx r0 @ must be REFUSED at stage 2 + + @ Reached only if the branch SUCCEEDED and the target returned, which + @ is a failure of the isolation claim rather than of this routine. + + mov r0, #1 + b guest_branch_probe_done + + @ Where the hypervisor's continue path puts the partition back. SP is + @ exactly where the push left it -- a prefetch abort disturbs nothing + @ -- so the pop below is balanced on this path too. + + .global guest_branch_probe_back +guest_branch_probe_back: + mov r0, #0 + +guest_branch_probe_done: + pop {r4, lr} + bx lr + .size guest_branch_probe, . - guest_branch_probe diff --git a/examples/s32z280_evb/CMakeLists.txt b/examples/s32z280_evb/CMakeLists.txt index 458047a..d874c31 100644 --- a/examples/s32z280_evb/CMakeLists.txt +++ b/examples/s32z280_evb/CMakeLists.txt @@ -101,6 +101,28 @@ target_compile_definitions(zx_probe_el2_fault.elf PRIVATE zx_add_s32z_probe(zx_probe_starved.elf) target_compile_definitions(zx_probe_starved.elf PRIVATE ZX_PROBE_STARVE_REGIONS) +# THE SHARED GRANULE'S READ-ONLY HALF, BROKEN. The reader's copy of the +# shared range is given write permission, so its attempt to publish must +# SURVIVE and the run must report FAILED. +# +# A shared granule is the one DECLARED EXCEPTION to isolation in this +# component -- one range readable by both partitions and writable by one -- +# which makes it the place where "the check can fail" is least optional. Its +# passing condition is the ABSENCE of a successful write, and an absence +# nobody has seen turn into a presence is not evidence of anything. +# +# Only the PERMISSION field changes. The range, the attributes and the +# declared publisher are untouched, so the manifest validator has nothing to +# object to and the only thing that can catch it is the access itself. +# +# WORTH RUNNING ON THIS BOARD SPECIFICALLY. The two DFSC values the check +# rests on -- a region MISS against a region PERMISSION failure -- are what +# separate "read-only" from "not mapped here at all", and they were measured +# on this part as well as on the model. A permission failure that reported +# a miss would make the two halves of the shared granule indistinguishable. +zx_add_s32z_probe(zx_probe_shared_rw.elf) +target_compile_definitions(zx_probe_shared_rw.elf PRIVATE ZX_PROBE_SHARED_RW) + ############################################################################### # The ThreadX guest, and the image that launches it ############################################################################### @@ -346,6 +368,7 @@ if(ZX_THREADX_SOURCE_DIR) function(zx_add_s32z_two_partitions target_name) add_executable(${target_name} EXCLUDE_FROM_ALL ${ZX_COMMON_DIR}/zx_two_partitions.c + ${ZX_COMMON_DIR}/zx_frame_setup.c ${ZX_COMMON_DIR}/zx_guest_launch.c ${ZX_COMMON_DIR}/zx_report.c ${ZX_COMMON_DIR}/zx_guest_image.S @@ -410,6 +433,112 @@ if(ZX_THREADX_SOURCE_DIR) target_compile_definitions(zx_two_partitions_overlap.elf PRIVATE ZX_TWO_OVERLAP) + ########################################################################### + # THE ISOLATION AND DETERMINISM REGRESSION, on silicon + ########################################################################### + # + # THIS IS THE BOARD THE NUMBERS COME FROM. The model is functional, so a + # green run there proves the code is right and says nothing at all about + # timing; every period, every jitter figure and every cycle count in the + # regression's output has to come from here before it is quoted. + # + # Same sources, same linker script and same two windows as the + # demonstration image beside it -- a regression built on a different + # foundation would be a regression for a different hypervisor. + function(zx_add_s32z_regression target_name) + add_executable(${target_name} EXCLUDE_FROM_ALL + ${ZX_COMMON_DIR}/zx_regression.c + ${ZX_COMMON_DIR}/zx_frame_setup.c + ${ZX_COMMON_DIR}/zx_guest_launch.c + ${ZX_COMMON_DIR}/zx_report.c + ${ZX_COMMON_DIR}/zx_guest_image.S + ${ZX_S32Z_DIR}/zx_board.c + ) + + add_dependencies(${target_name} zx_guest_a zx_guest_b) + + target_include_directories(${target_name} PRIVATE + ${ZX_S32Z_DIR} + ${ZX_COMMON_DIR} + ) + + set_source_files_properties(${ZX_COMMON_DIR}/zx_guest_image.S + PROPERTIES + COMPILE_OPTIONS + "-Wa,-I${ZX_GUEST_A_BUILD};-Wa,-I${ZX_GUEST_B_BUILD};-DZX_GUEST_B_BLOB" + OBJECT_DEPENDS "${ZX_GUEST_A_BLOB};${ZX_GUEST_B_BLOB}" + TARGET_DIRECTORY ${target_name} + ) + + target_link_libraries(${target_name} PRIVATE + eclipse-threadx::zonex + zonex::warnings + ) + + target_link_options(${target_name} PRIVATE + -T${ZX_S32Z_DIR}/zx_two_partitions.lds + -nostartfiles + -Wl,-Map=${target_name}.map + ${ZX_LINK_QUIET_RWX} + ) + + set_target_properties(${target_name} PROPERTIES + LINK_DEPENDS ${ZX_S32Z_DIR}/zx_two_partitions.lds + ) + endfunction() + + # THE REGRESSION. ZX_TEST_FAULT_CONTINUE is what lets one image sweep + # fourteen violation cases; on this board the alternative is fourteen + # images and fourteen debug-probe sessions, which is most of a working + # day. The option is set on this target and nowhere else in this file, + # which is the whole basis for saying the shipping policy is halt. + zx_add_s32z_regression(zx_regression.elf) + target_compile_definitions(zx_regression.elf PRIVATE ZX_TEST_FAULT_CONTINUE) + + # HALT ON FAULT, tested on silicon as well as on the model, and it must + # PASS. Both targets, because "the shipping default is halt" is a claim + # about behaviour on real hardware and a model cannot make it. + zx_add_s32z_regression(zx_regression_halt.elf) + target_compile_definitions(zx_regression_halt.elf PRIVATE + ZX_REGRESSION_HALT_ONLY) + + # The two negative verifications, exactly as on the model: a region limit + # one granule too generous, and the time freeze removed. Each must report + # FAILED. + zx_add_s32z_regression(zx_regression_widened.elf) + target_compile_definitions(zx_regression_widened.elf PRIVATE + ZX_TEST_FAULT_CONTINUE ZX_REGRESSION_WIDENED) + + zx_add_s32z_regression(zx_regression_no_freeze.elf) + target_compile_definitions(zx_regression_no_freeze.elf PRIVATE + ZX_TEST_FAULT_CONTINUE ZX_REGRESSION_NO_FREEZE) + + # TEN TIMES LONGER, and THIS is the board it is for. Six hundred major + # frames is sixty seconds of silicon and ten minutes of a functional + # model, so the question it answers -- does the critical partition's + # MAXIMUM period stop moving? -- is cheap here and expensive there. Run + # it by hand and record whether the maximum changed. + # AND THE REFUSAL PATH, which must FAIL. The hypervisor's own timer PPI + # is deliberately left disabled at the GIC: the comparator still expires + # and the controller is simply never told, so no window can end. + # + # THE POINT IS THAT THIS IS A VERDICT AND NOT A HANG. A frame started in + # that state runs its first partition until something outside the image + # stops it, and the log ends mid-sentence with nothing to point at -- the + # least informative outcome this suite can produce. The regression + # refuses to start such a frame, and this build is the only one that + # reaches the refusal, because a branch nothing has ever taken is not + # evidence that it works. + # + # It is also the cheapest run in the suite: it stops before the frame. + zx_add_s32z_regression(zx_regression_no_tick.elf) + target_compile_definitions(zx_regression_no_tick.elf PRIVATE + ZX_REGRESSION_NO_TICK) + + zx_add_s32z_regression(zx_regression_long.elf) + target_compile_definitions(zx_regression_long.elf PRIVATE + ZX_TEST_FAULT_CONTINUE ZX_REGRESSION_LONG) + else() message(STATUS "ZoneX: ZX_THREADX_SOURCE_DIR is empty, so the ThreadX guest images " diff --git a/examples/s32z280_evb/guest/link_partition.lds.in b/examples/s32z280_evb/guest/link_partition.lds.in index 303b410..08e2401 100644 --- a/examples/s32z280_evb/guest/link_partition.lds.in +++ b/examples/s32z280_evb/guest/link_partition.lds.in @@ -88,7 +88,7 @@ MEMORY SECTIONS { - /* +0x00 the mailbox, two granules: LOADED, outside .bss, at the + /* +0x00 the mailbox, three granules: LOADED, outside .bss, at the window base. See the model's script for why all three of those are forced. */ @@ -100,7 +100,7 @@ SECTIONS __zx_guest_mailbox_end = .; } > WINDOW - /* +0x80 the entry branch, then everything executable. */ + /* +0xC0 the entry branch, then everything executable. */ .zx_guest_head : ALIGN(64) { @@ -191,15 +191,21 @@ SECTIONS ASSERT(__zx_guest_mailbox_start == __zx_guest_window_base, "the mailbox is not at the base of the partition window") - /* TWO granules, and the number is ZX_GD_WINDOW_SIZE in + /* THREE granules, and the number is ZX_GD_WINDOW_SIZE in examples/common/zx_guest_abi.h. A linker script cannot include a C header, so the constant is restated here and asserted -- which is the point: a mailbox that grew past its slot fails the LINK rather than moving an entry point the hypervisor computes as a - compile-time constant. */ - ASSERT((__zx_guest_mailbox_end - __zx_guest_mailbox_start) == 128, - "the mailbox is not exactly two granules") - ASSERT(__zx_guest_code_start == (__zx_guest_window_base + 0x80), + compile-time constant. It HAS grown, twice, and both times this + assert is what said so before anything ran. */ + ASSERT((__zx_guest_mailbox_end - __zx_guest_mailbox_start) == 192, + "the mailbox is not exactly three granules") + /* ZX_GUEST_IMAGE_OFF_ENTRY. Restated for the same reason as the + mailbox size above, and it moves with it: three granules of mailbox + put the entry branch at 0xC0. A guest whose branch is four bytes + out of place is invisible in a manifest and costs a hardware + session to find. */ + ASSERT(__zx_guest_code_start == (__zx_guest_window_base + 0xC0), "the entry branch is not at the offset the hypervisor ERETs to") ASSERT(_start >= __zx_guest_code_start, "the kernel's reset symbol is outside the guest's code region") diff --git a/examples/s32z280_evb/zx_one_partition.lds b/examples/s32z280_evb/zx_one_partition.lds index 1136224..b255b78 100644 --- a/examples/s32z280_evb/zx_one_partition.lds +++ b/examples/s32z280_evb/zx_one_partition.lds @@ -233,16 +233,22 @@ SECTIONS check would then be reading past the end of the section, which is the out-of-bounds read this assert exists to prevent. - 0x90 is ZX_GUEST_IMAGE_OFF_MAGIC + 4, from + 0xD0 is ZX_GUEST_IMAGE_MIN_SIZE, from examples/common/zx_guest_abi.h. A linker script cannot include a C header, so the number is restated here -- and it is restated WRONG the moment the header moves, which is exactly what happened when the mailbox grew to two granules and pushed the entry branch and the header from 0x40 to 0x80. The bound stayed at 0x50, so a blob of 0x51 to 0x8F bytes linked cleanly and the magic check read past the - section into whatever followed it. */ - - ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) >= 0x90, + section into whatever followed it. + The mailbox has since grown a THIRD granule, for the isolation + matrix, which moved the header again -- to 0xCC, so the bound is + 0xD0. The header end is a NAMED constant now, ZX_GUEST_IMAGE_MIN_ + SIZE, for exactly this reason: four scripts restating a literal is + four chances to leave one behind, and last time all four were left + behind together. */ + + ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) >= 0xD0, "the embedded guest image is empty or too small to carry its header") ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) diff --git a/examples/s32z280_evb/zx_two_partitions.lds b/examples/s32z280_evb/zx_two_partitions.lds index 8356e03..0ef757a 100644 --- a/examples/s32z280_evb/zx_two_partitions.lds +++ b/examples/s32z280_evb/zx_two_partitions.lds @@ -292,18 +292,24 @@ SECTIONS check would then be reading past the end of the section, which is the out-of-bounds read this assert exists to prevent. - 0x90 is ZX_GUEST_IMAGE_OFF_MAGIC + 4, from + 0xD0 is ZX_GUEST_IMAGE_MIN_SIZE, from examples/common/zx_guest_abi.h. A linker script cannot include a C header, so the number is restated here -- and it is restated WRONG the moment the header moves, which is exactly what happened when the mailbox grew to two granules and pushed the entry branch and the header from 0x40 to 0x80. The bound stayed at 0x50, so a blob of 0x51 to 0x8F bytes linked cleanly and the magic check read past the - section into whatever followed it. */ - - ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) >= 0x90, + section into whatever followed it. + The mailbox has since grown a THIRD granule, for the isolation + matrix, which moved the header again -- to 0xCC, so the bound is + 0xD0. The header end is a NAMED constant now, ZX_GUEST_IMAGE_MIN_ + SIZE, for exactly this reason: four scripts restating a literal is + four chances to leave one behind, and last time all four were left + behind together. */ + + ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) >= 0xD0, "guest A's embedded image is empty or too small to carry its header") - ASSERT((__zx_guest_b_blob_end - __zx_guest_b_blob_start) >= 0x90, + ASSERT((__zx_guest_b_blob_end - __zx_guest_b_blob_start) >= 0xD0, "guest B's embedded image is empty or too small to carry its header") ASSERT((__zx_guest_a_blob_end - __zx_guest_a_blob_start) diff --git a/platform/cortex_r52/CMakeLists.txt b/platform/cortex_r52/CMakeLists.txt index 605b786..2aea9e0 100644 --- a/platform/cortex_r52/CMakeLists.txt +++ b/platform/cortex_r52/CMakeLists.txt @@ -22,6 +22,7 @@ target_sources(zonex_port PRIVATE ${CMAKE_CURRENT_LIST_DIR}/src/zx_context.S ${CMAKE_CURRENT_LIST_DIR}/src/zx_cache.c ${CMAKE_CURRENT_LIST_DIR}/src/zx_context.c + ${CMAKE_CURRENT_LIST_DIR}/src/zx_fault_continue.c ${CMAKE_CURRENT_LIST_DIR}/src/zx_frame.c ${CMAKE_CURRENT_LIST_DIR}/src/zx_console.c ${CMAKE_CURRENT_LIST_DIR}/src/zx_el2_cpu.c diff --git a/platform/cortex_r52/inc/zx_port.h b/platform/cortex_r52/inc/zx_port.h index 6c34964..8d5c95e 100644 --- a/platform/cortex_r52/inc/zx_port.h +++ b/platform/cortex_r52/inc/zx_port.h @@ -422,6 +422,7 @@ #include "zx_api.h" #include "zx_console.h" #include "zx_fault.h" +#include "zx_fault_log.h" /* The region descriptor (ZX_REGION) and the AP/SH/XN encodings it holds come from here. The port programs what a manifest declares, so the descriptor @@ -1027,6 +1028,86 @@ typedef struct ZX_GUEST_CONTEXT_STRUCT uint32_t zx_ctx_entries; /* times this context was entered */ uint32_t zx_ctx_preemptions; /* times a boundary took the core */ uint32_t zx_ctx_started; /* zero until its first entry */ + + /* ---- EVERYTHING BELOW IS AT THE END OF THE STRUCTURE ON PURPOSE ---- + * + * A SWITCH DOES NOT TOUCH ANY OF IT, and where it sits changes what a + * switch COSTS. These fields were first declared next to the ones they + * belong with -- the freeze beside the offset it governs, the period + * beside the timestamps it is computed from -- which was better to read + * and 370 cycles more expensive, measured on the S32Z280: 6,042 / 6,097 + * / 6,298 against 5,672 / 5,715 / 5,862. + * + * Nothing here is on the switch path, so nothing here should have been + * able to cost it anything. What it cost was OFFSETS. The EL1 MPU + * arrays dominate the switch and they come after the time group, so a + * word inserted before them moves every one of their offsets -- and an + * offset that no longer fits an instruction's immediate field is an + * extra instruction, twenty regions and two registers deep, with the + * caches off. + * + * So they go at the end, where an addition cannot move anything the + * switch reads, and the published figures stay comparable with the ones + * taken before this step. It is worth knowing that the cost of a field + * in this structure depends on where in it the field is put. */ + + /* Stage-2 violations this partition committed and was RESUMED past. + Only a build with the test-only continue mode can make this non-zero; + the shipping policy stops a partition on its first violation, so + there is no second one to count. It is here rather than in the fault + log because the log keeps its first entries and then stops, and the + determinism phase in which a partition faults on every iteration of + its own loop is a claim about the COUNT. */ + uint32_t zx_ctx_violations; + + /* WHETHER THIS PARTITION IS CREDITED THE TIME IT SPENT DESCHEDULED. + * + * Non-zero -- which is what zx_context_time_reset establishes and what + * every real configuration wants -- means the interval this partition + * was away is added to its CNTVOFF when it comes back, so its own clock + * advances by the counts it spent ON THE CORE and by nothing else. + * That is the freeze, and it is the difference between temporal + * partitioning and time slicing a guest can observe: see + * docs/decisions.md D7. + * + * IT IS A FIELD RATHER THAN AN INVARIANT OF THE CODE for two reasons. + * The decision belongs in the data where a reader can see it -- and a + * claim whose negation cannot be built is not a claim that has been + * tested. Clearing it gives a partition WALL CLOCK, so its own clock + * starts advancing by time its NEIGHBOUR spent on the core, which is + * the coupling the whole mechanism is bought to remove; the regression + * has a build that does exactly that and must report FAILED. */ + uint32_t zx_ctx_credit_time; + + /* ---- HOW OFTEN THIS PARTITION'S WINDOW COMES ROUND ---- */ + + /* The determinism claim, made measurable, and it is the frame's own + observation rather than the guest's: a partition's period is the + interval between the instants ZoneX gave it the core, and only ZoneX + knows those. The guest's own tick-to-tick interval is the other half + and the guest reports it separately -- neither alone would do, + because a partition whose clock the hypervisor was mismanaging would + see a steady period through a moving clock. + * + MEASURED FROM THE TIMESTAMPS THE SWITCH ALREADY TAKES, so this costs + no counter read at all: zx_ctx_resumed_at is read by the time freeze + on every entry, and a period is the difference between consecutive + values. The bookkeeping is done OUTSIDE the timed bracket in the + boundary handler, so a switch cost measured with this present is the + same number as one measured without it -- which matters, because the + silicon figures this repository quotes were taken before it existed. + * + And the offset between "the core was given to the partition" and + "the timestamp was taken" is CONSTANT, so it cancels between two + consecutive entries: the period is unaffected by where in the switch + the reading happens, which is what makes moving it out of the + bracket free rather than merely cheap. */ + + uint64_t zx_ctx_period_prev; /* the previous entry's timestamp */ + uint64_t zx_ctx_period_min; + uint64_t zx_ctx_period_max; + uint64_t zx_ctx_period_total; + uint32_t zx_ctx_period_samples; } ZX_GUEST_CONTEXT; /* How many EL1 MPU regions this part actually has, clamped to the array @@ -1094,6 +1175,116 @@ void zx_context_time_reset(ZX_GUEST_CONTEXT *context_ptr); void zx_context_report(const ZX_GUEST_CONTEXT *context_ptr, const char *name_ptr); +/**************************************************************************/ +/* How often a partition's window came round */ +/**************************************************************************/ + +/* Forget the period statistics, INCLUDING the last timestamp, so that the + * next period measured begins a fresh chain. + * + * DROPPING THE TIMESTAMP IS THE WHOLE POINT, and the first version of this + * kept it for a reason that turned out to be backwards. The argument was + * that the period straddling a phase change is the most interesting sample + * in the new phase. It is not: it is the SAME EVENT as the last sample of + * the OLD phase, attributed to the wrong behaviour. + * + * The schedule works from absolute deadlines, so a late entry is followed + * by a period that is short by exactly as much as the previous one was + * long -- the frame self-corrects, which is the property the absolute + * deadline is for. One perturbation therefore produces two samples: a + * long one, caused by whatever the neighbour was doing, and a short + * correction. Keeping the timestamp across the reset put the correction in + * the next phase, so a phase that had done nothing to deserve it inherited + * a minimum from its predecessor's worst moment. Measured on silicon: a + * console-storm phase with a maximum 14,451 counts long, and the following + * phase reporting a minimum 16,020 counts short. + * + * So each phase now measures only periods that lie entirely inside it, and + * pays for its own behaviour and nothing else. It costs one sample per + * phase, which is the honest price of attribution. */ + +/* Record that this partition was given the core at `now`. Called by the + frame, OUTSIDE the timed bracket, with the timestamp the time freeze + already took -- so it costs no counter read and does not move a switch + figure. The first entry records no period: there is nothing to subtract + from, and the interval since the context was initialised is a number with + no meaning that would become the minimum on every run. */ + +void zx_context_period_note(ZX_GUEST_CONTEXT *context_ptr, uint64_t now); + +void zx_context_period_reset(ZX_GUEST_CONTEXT *context_ptr); + +/* The mean, or zero when nothing has been sampled. A separate function + because a caller computing it inline is a caller dividing by a sample + count it has not checked, and a period of zero reads exactly like a + partition whose window never came round. */ + +ZX_NODISCARD uint64_t zx_context_period_mean( + const ZX_GUEST_CONTEXT *context_ptr); + +/* max - min, or zero when fewer than two periods were sampled. ONE sample + has no spread, and reporting the difference between a min and a max that + are the same reading as "no jitter" would be the most flattering possible + way to describe a run too short to say anything. */ + +ZX_NODISCARD uint64_t zx_context_period_jitter( + const ZX_GUEST_CONTEXT *context_ptr); + +/**************************************************************************/ +/* What happens AFTER a violation has been recorded */ +/**************************************************************************/ + +/* THE SHIPPING POLICY IS HALT, and zx_el2_fault_continue below is what + * implements it. An image that configures the test-only alternative gets a + * partition resumed past the access instead, so that one image can sweep a + * whole matrix of violations rather than needing one image and one run per + * case. The reasoning, and the four things that keep the mode honest, are + * in platform/cortex_r52/src/zx_fault_continue.c. + * + * THE POLICY IS THE IMAGE'S TO SET AT BUILD TIME, and its default is HALT + * because the variable holding it lives in .bss -- an image that configures + * nothing gets the value of zeroed memory. It is not a compile definition + * on the hypervisor, and that is a discovered constraint rather than a + * preference: ZoneX is a library built once per build tree and linked into + * every image in it, so a compile definition on one image's target cannot + * reach the hypervisor's own translation units. + * + * THE VECTOR CALLS THIS ON EVERY BUILD. There is no conditional in the + * trap handler: a continue mode that took a different route through it + * would leave the shipping route less exercised rather than more. */ + +/* Where a partition wants to be resumed after a BRANCH it was refused. + * + * A prefetch abort cannot be stepped over -- the fetch is what failed, so + * there is no instruction whose length would say where the next one starts + * -- and resuming at ELR + 4 would walk the partition through its + * neighbour's code one exception at a time. So the guest publishes a + * resume point before it branches, and the hypervisor asks the IMAGE for it + * through this hook: where that address lives is a mailbox layout, which is + * an example's business and not the port's. + * + * Returning zero means the partition is stopped, which is the shipping + * behaviour and the right answer for a path with no answer. */ + +typedef uint32_t (*ZX_FAULT_RESUME_FN)(UINT partition); + +void zx_el2_fault_continue_configure(ZX_FAULT_LOG *log_ptr, + ZX_FAULT_RESUME_FN resume_fn, + uint32_t continue_past_violations); + +/* Whether the test-only continue mode is in force. What an image PRINTS + about its own fault policy must come from the translation unit that ACTS + on it, or the two can disagree -- which they did, in the first version of + this seam, in the direction that reads as a pass. */ + +ZX_NODISCARD uint32_t zx_el2_fault_continue_enabled(void); + +/* Called from the Hyp trap vector once a guest violation is captured. + Returns the context to resume, or a null pointer to stop the partition. + Not called from C. */ + +ZX_NODISCARD ZX_GUEST_CONTEXT *zx_el2_fault_continue(void); + /**************************************************************************/ /* Entering a time-partitioned frame */ /**************************************************************************/ @@ -1145,6 +1336,12 @@ extern ZX_GUEST_CONTEXT *zx_el2_current_context; * uint32_t and the total is not: a switch is a few thousand cycles so one * difference cannot wrap, and ten thousand of them summed comfortably can. */ +/* What a frame calls once per completed major frame. Declared before the + structure that holds one, since the structure holds it; what it is FOR is + beside zx_frame_set_frame_hook below. */ + +typedef void (*ZX_FRAME_HOOK_FN)(void *argument, ULONG frames_completed); + typedef struct ZX_FRAME_STRUCT { ZX_SCHEDULE *zx_frame_schedule; @@ -1205,6 +1402,14 @@ typedef struct ZX_FRAME_STRUCT ZX_RUN_FRAME_DONE when nobody left early. */ UINT zx_frame_stopped_index; uint32_t zx_frame_stop_outcome; + + /* The per-major-frame hook, and the frame count it was last called at. + See zx_frame_set_frame_hook: this is how a regression divides one run + into phases, and it is called OUTSIDE the timed bracket so that it + cannot move a switch figure. Null in every image but one. */ + ZX_FRAME_HOOK_FN zx_frame_hook; + void *zx_frame_hook_argument; + ULONG zx_frame_hook_frames; } ZX_FRAME; /* Wire the frame up. Nothing is entered and no timer is armed: this only @@ -1242,6 +1447,38 @@ ZX_NODISCARD ZX_GUEST_CONTEXT *zx_el2_window_boundary(void); extern uint32_t zx_el2_frame_result; +/* Which partition the schedule says is running, or ZX_MANIFEST_NO_INDEX. + * + * Asked by the fault path, which has a syndrome and a context pointer and + * no way to name whose window the violation happened in. A caller could + * compare zx_el2_current_context against the contexts array and get the + * same answer; going through the SCHEDULE instead means the attribution in + * a fault log and the attribution in the frame's own report come from one + * source, and cannot disagree about a partition that had just been + * switched. */ + +ZX_NODISCARD UINT zx_frame_current_partition(void); + +/* CALLED ONCE PER COMPLETED MAJOR FRAME, outside the timed bracket. + * + * WHAT THIS IS FOR AND WHAT IT IS NOT. It is not a scheduler hook: it + * cannot change what runs next, and it is deliberately not called per + * WINDOW. It exists so that a regression can divide one run into PHASES -- + * changing what the untrusted partition is doing every so many frames while + * the critical one is measured continuously -- and a frame boundary is the + * coarsest place that can happen, which is the right one. + * + * OUTSIDE THE TIMED BRACKET, and that is a requirement rather than a + * courtesy: a hook called inside it would add its own cost to every switch + * figure this repository has published, and the whole point of measuring a + * switch is that the number does not depend on what the image happens to be + * doing with it. + * + * A null pointer means no hook, which is every image but one. */ + +void zx_frame_set_frame_hook(ZX_FRAME *frame_ptr, ZX_FRAME_HOOK_FN hook_fn, + void *argument); + /* MEASURE THE SWITCH, BY GROUP, using the same code the switch runs. * * Averages `rounds` iterations of each group with the cost of reading the diff --git a/platform/cortex_r52/src/zx_context.c b/platform/cortex_r52/src/zx_context.c index 986119d..150e62b 100644 --- a/platform/cortex_r52/src/zx_context.c +++ b/platform/cortex_r52/src/zx_context.c @@ -731,6 +731,16 @@ void zx_context_time_reset(ZX_GUEST_CONTEXT *context_ptr) context_ptr->zx_ctx_resumed_at = now; context_ptr->zx_ctx_virtual_at_stop = 0U; context_ptr->zx_ctx_time_on_core = 0U; + + /* AND THE FREEZE IS ON, established HERE rather than left to the + caller. Every image calls this; an image that had to remember to + switch the freeze on would be an image that could forget, and a + forgotten freeze is a partition that can see its neighbour's time + while every other check in the suite stays green. The one build + that wants it off clears the field after this returns, deliberately + and visibly. */ + + context_ptr->zx_ctx_credit_time = 1U; } @@ -785,7 +795,8 @@ void zx_context_time_resume(ZX_GUEST_CONTEXT *context_ptr) entry -- which looks exactly like a working freeze and is in fact a partition whose time never advances at all. */ - if (now > context_ptr->zx_ctx_suspended_at) + if ((context_ptr->zx_ctx_credit_time != 0U) + && (now > context_ptr->zx_ctx_suspended_at)) { offset += now - context_ptr->zx_ctx_suspended_at; } @@ -852,4 +863,143 @@ void zx_context_report(const ZX_GUEST_CONTEXT *context_ptr, zx_console_putdec((uint32_t)(context_ptr->zx_ctx_virtual_at_stop & 0xFFFFFFFFU)); zx_console_puts("\n"); + + if (context_ptr->zx_ctx_violations != 0U) + { + zx_console_puts(" stage-2 violations it was RESUMED past "); + zx_console_putdec(context_ptr->zx_ctx_violations); + zx_console_puts("\n"); + } + + if (context_ptr->zx_ctx_period_samples != 0U) + { + zx_console_puts(" window period, counts: min "); + zx_console_putdec((uint32_t)(context_ptr->zx_ctx_period_min + & 0xFFFFFFFFU)); + zx_console_puts(" mean "); + zx_console_putdec((uint32_t)(zx_context_period_mean(context_ptr) + & 0xFFFFFFFFU)); + zx_console_puts(" max "); + zx_console_putdec((uint32_t)(context_ptr->zx_ctx_period_max + & 0xFFFFFFFFU)); + zx_console_puts(" over "); + zx_console_putdec(context_ptr->zx_ctx_period_samples); + zx_console_puts(" periods\n"); + } +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_context_period_note Cortex-R52 */ +/* zx_context_period_reset */ +/* zx_context_period_mean */ +/* zx_context_period_jitter */ +/* */ +/* DESCRIPTION */ +/* */ +/* How often a partition's window came round, from the timestamps the */ +/* switch already takes. The rationale is beside the fields in */ +/* zx_port.h; two properties are worth restating where they are */ +/* implemented. */ +/* */ +/* THE FIRST ENTRY YIELDS NO PERIOD, and it must not be allowed to. A */ +/* partition's first window has no predecessor, so the difference */ +/* against zx_ctx_period_prev would be the interval since the CONTEXT */ +/* WAS INITIALISED -- a number with no meaning that is smaller than a */ +/* real period and would therefore become the minimum, and the reported */ +/* jitter would be a whole frame wide on every run. The sample count */ +/* guards it. */ +/* */ +/* AND THE JITTER OF ONE SAMPLE IS ZERO RATHER THAN max - min. With */ +/* one period recorded the min and the max are the same reading, and */ +/* reporting their difference as "no jitter" would be the most */ +/* flattering possible description of a run too short to say anything. */ +/* Zero samples and one sample both report zero, and the sample count */ +/* is printed next to it so that a reader can tell which. */ +/* */ +/**************************************************************************/ + +void zx_context_period_note(ZX_GUEST_CONTEXT *context_ptr, uint64_t now) +{ + if (context_ptr == (ZX_GUEST_CONTEXT *)0) + { + return; + } + + if (context_ptr->zx_ctx_period_prev != 0U) + { + uint64_t period = now - context_ptr->zx_ctx_period_prev; + + if ((context_ptr->zx_ctx_period_samples == 0U) + || (period < context_ptr->zx_ctx_period_min)) + { + context_ptr->zx_ctx_period_min = period; + } + + if (period > context_ptr->zx_ctx_period_max) + { + context_ptr->zx_ctx_period_max = period; + } + + context_ptr->zx_ctx_period_total += period; + context_ptr->zx_ctx_period_samples++; + } + + context_ptr->zx_ctx_period_prev = now; +} + + +void zx_context_period_reset(ZX_GUEST_CONTEXT *context_ptr) +{ + if (context_ptr == (ZX_GUEST_CONTEXT *)0) + { + return; + } + + /* THE PREVIOUS TIMESTAMP GOES WITH THEM, so that the next period + measured begins a fresh chain and every sample a phase records lies + entirely inside it. See zx_port.h for why the first version of this + kept it and why that was backwards: an absolute-deadline schedule + answers one late entry with one short correction, and keeping the + timestamp put the correction in the following phase. */ + + context_ptr->zx_ctx_period_prev = 0U; + + context_ptr->zx_ctx_period_min = 0U; + context_ptr->zx_ctx_period_max = 0U; + context_ptr->zx_ctx_period_total = 0U; + context_ptr->zx_ctx_period_samples = 0U; +} + + +uint64_t zx_context_period_mean(const ZX_GUEST_CONTEXT *context_ptr) +{ + uint64_t mean = 0U; + + if ((context_ptr != (const ZX_GUEST_CONTEXT *)0) + && (context_ptr->zx_ctx_period_samples != 0U)) + { + mean = context_ptr->zx_ctx_period_total + / (uint64_t)context_ptr->zx_ctx_period_samples; + } + + return mean; +} + + +uint64_t zx_context_period_jitter(const ZX_GUEST_CONTEXT *context_ptr) +{ + uint64_t jitter = 0U; + + if ((context_ptr != (const ZX_GUEST_CONTEXT *)0) + && (context_ptr->zx_ctx_period_samples > 1U)) + { + jitter = context_ptr->zx_ctx_period_max + - context_ptr->zx_ctx_period_min; + } + + return jitter; } diff --git a/platform/cortex_r52/src/zx_fault_continue.c b/platform/cortex_r52/src/zx_fault_continue.c new file mode 100644 index 0000000..ace7d9b --- /dev/null +++ b/platform/cortex_r52/src/zx_fault_continue.c @@ -0,0 +1,242 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* zx_fault_continue.c Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* What happens after a partition's stage-2 violation has been */ +/* recorded: it is STOPPED, which is the shipping policy, or -- in an */ +/* image that configured the test-only alternative -- it is resumed */ +/* past the access, so that one image can sweep a whole matrix of */ +/* violations. */ +/* */ +/* WHY A TEST-ONLY CONTINUE MODE EXISTS AT ALL */ +/* */ +/* Phase 0's specified behaviour is stage-2 fault, log, halt, and that */ +/* is right: a hypervisor that quietly re-entered a partition which */ +/* had just violated its boundary would be doing the easy half of */ +/* supervised restart. But a regression with a dozen violation cases */ +/* would then need a dozen images and a dozen runs, which on silicon */ +/* is most of a working day and on a functional model is minutes of */ +/* simulation per case. The isolation matrix is fourteen cases. */ +/* */ +/* So the mode exists, and four things keep it honest. */ +/* */ +/* IT IS DECIDED AT BUILD TIME BY THE IMAGE, and cannot be reached by */ +/* a manifest, a mailbox word or a partition. The policy arrives */ +/* through zx_el2_fault_continue_configure, which an image calls with */ +/* a value taken from its own compile-time definition -- and the */ +/* default is HALT because the variable holding it lives in .bss, so */ +/* an image that configures nothing gets the value of zeroed memory. */ +/* */ +/* IT WAS AN #ifdef IN THIS FILE FIRST, AND IT COULD NOT WORK. ZoneX */ +/* is a LIBRARY, built once per build tree and linked into every image */ +/* in it, so a compile definition on one image's target never reaches */ +/* the hypervisor's own translation units: the option was set, the */ +/* library was built without it, and the regression dutifully reported */ +/* every partition STOPPED while claiming to be in continue mode. The */ +/* definition now lives where the build variants live -- on the image */ +/* -- which is where every other positive and negative variant in this */ +/* suite has always been defined. */ +/* */ +/* THE SHIPPING PATH IS THE SAME CODE. There is no #ifdef in the */ +/* vector: every build calls this function on every guest violation */ +/* and this function decides. A continue mode that took a DIFFERENT */ +/* route through the trap handler would leave the shipping route the */ +/* less exercised of the two, which is the wrong way round for the */ +/* path that matters. */ +/* */ +/* THE IMAGE SAYS SO IN ITS OWN OUTPUT, from this file rather than */ +/* from its own macro, so that a log cannot be mistaken for one from a */ +/* shipping build -- and so that the two ends cannot disagree, which */ +/* is exactly what they did the first time. */ +/* */ +/* AND HALT IS STILL TESTED RATHER THAN ASSUMED. A build of the same */ +/* image without the definition attempts one case and must halt, on */ +/* both targets, so the shipping policy has a run of its own. */ +/* */ +/* WHY A PREFETCH ABORT IS NOT RESUMED THE SAME WAY */ +/* */ +/* "Step over the faulting instruction" needs the instruction, and for */ +/* a prefetch abort there is none: the FETCH is what failed. Resuming */ +/* at ELR + 4 would resume INSIDE the neighbour's window and fault */ +/* again on the next word, and again, for the rest of the window -- a */ +/* partition walking through its neighbour's code taking one exception */ +/* per instruction, which is a livelock wearing the costume of a test. */ +/* */ +/* So the GUEST names where it wants to be put back, before it */ +/* branches, and this file asks the image for that address through a */ +/* hook rather than reading a mailbox itself. The mailbox layout is */ +/* an example's business; the hypervisor's business is that an address */ +/* it was not given means the partition is stopped. */ +/* */ +/**************************************************************************/ + +#include "zx_port.h" +#include "zx_fault_log.h" + +/* Where violations are recorded, and who to ask for a branch's resume + point. BOTH SUPPLIED BY THE IMAGE, and neither is allocated here. + * + * The log is 896 bytes of .bss and only one image needs it; a hypervisor + * that carried it unconditionally would be charging every shipping build + * for a regression's evidence. The hook is the image's because the + * mailbox layout is the image's -- see the file header. */ + +static ZX_FAULT_LOG *zx_fault_continue_log; +static ZX_FAULT_RESUME_FN zx_fault_continue_resume; + +/* THE POLICY, AND ITS DEFAULT IS THE VALUE OF ZEROED MEMORY. In .bss, so + an image that never calls configure -- which is every shipping image and + every other image in this suite -- gets HALT without having asked for it. + A default that has to be written down somewhere is a default somebody can + write down wrongly. */ + +static uint32_t zx_fault_continue_policy; + + +/**************************************************************************/ +/* zx_el2_fault_continue_configure */ +/**************************************************************************/ + +void zx_el2_fault_continue_configure(ZX_FAULT_LOG *log_ptr, + ZX_FAULT_RESUME_FN resume_fn, + uint32_t continue_past_violations) +{ + zx_fault_continue_log = log_ptr; + zx_fault_continue_resume = resume_fn; + zx_fault_continue_policy = continue_past_violations; +} + + +/**************************************************************************/ +/* zx_el2_fault_continue_enabled */ +/* */ +/* A FUNCTION AND NOT THE IMAGE'S OWN MACRO. What an image prints about */ +/* its own fault policy has to come from the translation unit that ACTS */ +/* on it, or the two can disagree -- an image that tested its own */ +/* definition and then failed to pass it in would print "continue" and */ +/* halt. That is not hypothetical: this seam was an #ifdef in this file */ +/* first, and the two ends disagreed the other way round. One value, set*/ +/* once, read by both the policy and the report. */ +/**************************************************************************/ + +uint32_t zx_el2_fault_continue_enabled(void) +{ + return (zx_fault_continue_policy != 0U) ? 1U : 0U; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_el2_fault_continue Cortex-R52 */ +/* */ +/* DESCRIPTION */ +/* */ +/* Called from the Hyp trap vector once a guest violation has been */ +/* captured, on EVERY build. Returns the context to resume, or a null */ +/* pointer to stop the partition -- which is what the shipping policy */ +/* returns, always. */ +/* */ +/* RECORDING HAPPENS IN BOTH POLICIES. The single fault record holds */ +/* the LAST capture and is overwritten by the next one, so a halting */ +/* build gains nothing from a log of one entry -- except that the */ +/* logging code is then the same code in both, and the report an image */ +/* prints is the same report. A logger only the test build ran would */ +/* be a logger only the test build had ever been seen to work in. */ +/* */ +/**************************************************************************/ + +ZX_GUEST_CONTEXT *zx_el2_fault_continue(void) +{ + const zx_fault_record_t *record_ptr = zx_el2_fault_record(); + ZX_GUEST_CONTEXT *context_ptr = zx_el2_current_context; + UINT partition = zx_frame_current_partition(); + uint32_t ec; + uint32_t resumed = ZX_FAULT_RESUMED_STOPPED; + uint32_t resume_pc = 0U; + + ec = zx_fault_ec(record_ptr->zx_fault_hsr); + + /* WHAT WOULD HAPPEN, DECIDED BEFORE ANYTHING IS RECORDED, so that the + log entry says how the partition was actually let go rather than how + it usually is. An entry claiming a resume that did not happen is + worse than no entry: it is evidence pointing the wrong way. */ + + if ((zx_el2_fault_continue_enabled() != 0U) + && (context_ptr != (ZX_GUEST_CONTEXT *)0)) + { + if (ec == ZX_EC_DABT_ROUTED) + { + /* HSR.IL gives the faulting instruction's length: set for a + 32-bit instruction, clear for a 16-bit one. A guest in T32 + would otherwise be resumed two bytes into a wide instruction, + which is not a fault -- it is an arbitrary instruction, and + the run would go wrong somewhere else entirely. */ + + resume_pc = context_ptr->zx_ctx_elr + + (((record_ptr->zx_fault_hsr & ZX_HSR_IL_MASK) != 0U) + ? 4U : 2U); + resumed = ZX_FAULT_RESUMED_SKIPPED; + } + else if (ec == ZX_EC_PABT_ROUTED) + { + /* The guest's own published resume point, via the image. Zero + means it published none, and a partition that asked to be + resumed nowhere is stopped -- the shipping behaviour, which + is the right default for a path with no answer. */ + + if (zx_fault_continue_resume != (ZX_FAULT_RESUME_FN)0) + { + resume_pc = zx_fault_continue_resume(partition); + } + + if (resume_pc != 0U) + { + resumed = ZX_FAULT_RESUMED_REDIRECTED; + } + } + else + { + /* Some other class routed to EL2. Not a stage-2 violation, so + not something this path has any business resuming past. */ + } + } + + zx_fault_log_add(zx_fault_continue_log, partition, record_ptr, resumed); + + if (resumed == ZX_FAULT_RESUMED_STOPPED) + { + return (ZX_GUEST_CONTEXT *)0; + } + + context_ptr->zx_ctx_elr = resume_pc; + + /* AND ITS FAULT IS COUNTED AGAINST IT. A partition resumed past a + violation has still violated, and a run has to be able to say how + many times: the determinism phase in which one partition faults on + every iteration of its own loop is a claim about that count. */ + + context_ptr->zx_ctx_violations++; + + return context_ptr; +} diff --git a/platform/cortex_r52/src/zx_frame.c b/platform/cortex_r52/src/zx_frame.c index 19ecf08..fd5174e 100644 --- a/platform/cortex_r52/src/zx_frame.c +++ b/platform/cortex_r52/src/zx_frame.c @@ -172,6 +172,9 @@ void zx_frame_configure(ZX_FRAME *frame_ptr, frame_ptr->zx_frame_spurious = 0U; frame_ptr->zx_frame_stopped_index = ZX_MANIFEST_NO_INDEX; frame_ptr->zx_frame_stop_outcome = ZX_RUN_FRAME_DONE; + frame_ptr->zx_frame_hook = (ZX_FRAME_HOOK_FN)0; + frame_ptr->zx_frame_hook_argument = (void *)0; + frame_ptr->zx_frame_hook_frames = 0UL; zx_frame_active = frame_ptr; zx_el2_current_context = (ZX_GUEST_CONTEXT *)0; @@ -239,6 +242,96 @@ static void zx_frame_console_claim(const ZX_FRAME *frame_ptr, UINT index) /* zx_frame_stopped -- has this partition left its window for good? */ /**************************************************************************/ +/**************************************************************************/ +/* zx_frame_current_partition -- whose window is this? */ +/* */ +/* THROUGH THE SCHEDULE, NOT THROUGH THE CONTEXT POINTER. A caller could */ +/* compare zx_el2_current_context against the contexts array and get the */ +/* same answer nearly always -- and disagree exactly once, at a boundary */ +/* where one has been changed and the other has not. The fault log's */ +/* attribution and the frame's own report then name different partitions */ +/* for one event, which is the least useful moment for two sources of */ +/* truth to exist. One source: the schedule. */ +/**************************************************************************/ + +UINT zx_frame_current_partition(void) +{ + if (zx_frame_active == (ZX_FRAME *)0) + { + return ZX_MANIFEST_NO_INDEX; + } + + return zx_schedule_current_partition(zx_frame_active->zx_frame_schedule); +} + + +/**************************************************************************/ +/* zx_frame_set_frame_hook */ +/**************************************************************************/ + +void zx_frame_set_frame_hook(ZX_FRAME *frame_ptr, ZX_FRAME_HOOK_FN hook_fn, + void *argument) +{ + if (frame_ptr == (ZX_FRAME *)0) + { + return; + } + + frame_ptr->zx_frame_hook = hook_fn; + frame_ptr->zx_frame_hook_argument = argument; + frame_ptr->zx_frame_hook_frames = 0UL; +} + + +/**************************************************************************/ +/* zx_frame_after_switch -- the bookkeeping that is NOT a switch. */ +/* */ +/* CALLED AFTER THE CYCLE COUNTER HAS BEEN READ THE SECOND TIME, always, */ +/* and that placement is a requirement rather than a tidiness. Both */ +/* things below are here because a determinism regression needs them and */ +/* a partition switch does not, and putting either inside the timed */ +/* bracket would add its cost to every switch figure this repository has */ +/* published -- so a measurement taken with this code present would stop */ +/* being comparable with the silicon figures taken before it existed. */ +/* */ +/* THE PERIOD IS UNAFFECTED BY BEING MEASURED LATE. It is the difference */ +/* between two consecutive entry timestamps, and the offset between "the */ +/* core was given to the partition" and "the timestamp was taken" is the */ +/* same on both -- so it cancels. That is what makes moving this out of */ +/* the bracket free rather than merely cheap, and it is why the timestamp */ +/* used is the one the time freeze already took rather than a fresh read. */ +/**************************************************************************/ + +static void zx_frame_after_switch(ZX_FRAME *frame_ptr, UINT index) +{ + ULONG frames; + + zx_context_period_note(&frame_ptr->zx_frame_contexts[index], + frame_ptr->zx_frame_contexts[index] + .zx_ctx_resumed_at); + + if (frame_ptr->zx_frame_hook == (ZX_FRAME_HOOK_FN)0) + { + return; + } + + /* ONCE PER COMPLETED MAJOR FRAME, and the comparison is against the + count the hook was last called at rather than a modulo of the + boundary number. A frame in which a partition was stopped takes + fewer boundaries per frame, so counting boundaries would drift out of + step with the schedule exactly on the runs where a phase change + matters most. */ + + frames = frame_ptr->zx_frame_schedule->zx_schedule_frames; + + if (frames != frame_ptr->zx_frame_hook_frames) + { + frame_ptr->zx_frame_hook_frames = frames; + frame_ptr->zx_frame_hook(frame_ptr->zx_frame_hook_argument, frames); + } +} + + static uint32_t zx_frame_stopped(const ZX_FRAME *frame_ptr, UINT index) { return ((frame_ptr->zx_frame_stopped_mask @@ -586,6 +679,12 @@ ZX_GUEST_CONTEXT *zx_el2_window_boundary(void) } } + /* THE PERIOD AND THE PHASE HOOK, both of them after the cycle counter + has been read for the second time. Neither is part of a switch and + neither may be allowed to cost one -- see zx_frame_after_switch. */ + + zx_frame_after_switch(frame_ptr, index); + if (frame_ptr->zx_frame_schedule->zx_schedule_missed >= (ULONG)ZX_FRAME_MISS_LIMIT) { @@ -668,6 +767,16 @@ uint32_t zx_frame_run(ZX_FRAME *frame_ptr) zx_schedule_note_missed(frame_ptr->zx_frame_schedule); } + /* THE COLD START AND EVERY RE-ENTRY AFTER AN EARLY DEPARTURE. This + loop body runs once per partition that LEFT ITS WINDOW EARLY, and + once at the beginning; every ordinary boundary is handled in the + vector and never comes back here. Both paths have to record the + entry, or the first period of the run and every period after a + yield would be missing -- and the periods either side of a gap + are the ones a reader looks at first. */ + + zx_frame_after_switch(frame_ptr, index); + zx_frame_console_claim(frame_ptr, index); outcome = zx_el2_enter_partition(zx_el2_current_context); diff --git a/platform/cortex_r52/src/zx_trap_handler.S b/platform/cortex_r52/src/zx_trap_handler.S index 953a196..a626bea 100644 --- a/platform/cortex_r52/src/zx_trap_handler.S +++ b/platform/cortex_r52/src/zx_trap_handler.S @@ -265,6 +265,34 @@ zx_el2_trap_not_hypercall: b zx_el2_resume zx_el2_trap_guest_violation: + + @ A GUEST OVERSTEPPED ITS PARTITION. What happens next is a policy + @ decision, and it is made in C -- zx_el2_fault_continue -- rather than + @ here, because a vector has no context to decide anything with. The + @ shipping answer is "stop the partition", which the call reports by + @ returning zero; a build compiled with the test-only continue mode can + @ instead be handed back the context to resume, so that one image sweeps + @ a whole matrix of violations rather than needing one run per case. + @ + @ THE CALL IS UNCONDITIONAL AND THERE IS NO #ifdef HERE. Every build + @ takes this path on every guest violation and the callee decides from a + @ compile-time constant. A continue mode reached through a DIFFERENT + @ route through this vector would mean the shipping route was the less + @ exercised of the two, which is the wrong way round for the path that + @ matters -- and it would put a conditional in the one file where a + @ mistake is hardest to see. + @ + @ The guest's fifteen words were captured before this, by + @ zx_el2_context_capture_from_stack, and the two words the vector pushed + @ are already off the stack. SP_hyp is inside zx_el2_enter_partition's + @ frame, which is a valid Hyp stack at one fixed depth, so the BL costs + @ no more per fault than the boundary handler's does per boundary. + + bl zx_el2_fault_continue + + cmp r0, #0 + bne zx_el2_context_resume @ resumed: r0 is the context + mov r0, #ZX_RUN_FAULTED b zx_el2_resume .size zx_el2_hyp_trap_entry, . - zx_el2_hyp_trap_entry diff --git a/test/host/CMakeLists.txt b/test/host/CMakeLists.txt index 962fc93..402c2bd 100644 --- a/test/host/CMakeLists.txt +++ b/test/host/CMakeLists.txt @@ -51,6 +51,7 @@ endfunction() # to LINK, which is a confusing way to discover that a test needs a console. zx_add_host_test(test_zx_api) zx_add_host_test(test_zx_fault) +zx_add_host_test(test_zx_fault_log) zx_add_host_test(test_zx_guest_console) zx_add_host_test(test_zx_manifest) zx_add_host_test(test_zx_mm) diff --git a/test/host/run.sh b/test/host/run.sh index 3fc84ee..3a36910 100755 --- a/test/host/run.sh +++ b/test/host/run.sh @@ -134,6 +134,17 @@ case "${command}" in # characters. There is nowhere better than a workstation to assert # those, and nowhere worse than a model log read by eye. # + # THE FAULT DECODER AND THE FAULT LOG JOINED IT when the isolation + # matrix made them reachable in full. Both were previously short of + # the floor for a reason that has now gone: the decoder's "on a + # READ" arm had never been printed, because every fault the suite + # provoked happened to be a write. The matrix provokes a read and a + # write against the SAME address deliberately -- a region set that + # denied writes and permitted reads would pass a write-only sweep -- + # so the arm is now exercised, and the file that decides which + # violation a log entry describes is held to the same standard as + # the validator that refused the manifest. + # # A file joins this list when it becomes reachable in full, not # when it is written. Adding one that cannot be is how a floor # ends up lowered to whatever passes. @@ -144,6 +155,8 @@ case "${command}" in echo "" echo "Enforcing the coverage floor on the fully reachable core:" gcovr --root "${ROOT}" \ + --filter "${ROOT}/core/src/zx_fault.c" \ + --filter "${ROOT}/core/src/zx_fault_log.c" \ --filter "${ROOT}/core/src/zx_guest_console.c" \ --filter "${ROOT}/core/src/zx_manifest_verify.c" \ --filter "${ROOT}/core/src/zx_mm_setup.c" \ diff --git a/test/host/test_zx_fault.c b/test/host/test_zx_fault.c index 6083068..e58a0a9 100644 --- a/test/host/test_zx_fault.c +++ b/test/host/test_zx_fault.c @@ -383,6 +383,23 @@ ZX_TEST_MAIN("zx_fault", ZX_CHECK_EQ(zx_capture_contains("no\n[FAULT] portable decode"), 1U); /* No spurious disagreement warning on a self-consistent record. */ ZX_CHECK_EQ(zx_capture_contains("DISAGREE"), 0U); + + /* THE SAME VIOLATION ON A READ, because the report has to say which and + a report that only ever printed one of the two would be indetectable + from any run that only ever provoked one. The isolation matrix + provokes both against the same address on purpose -- a region set + that denied writes and permitted reads would pass a write-only + sweep -- so the two words in this report carry a claim rather than + decoration. ISS[6] is WnR; clearing it is the whole difference. */ + + zx_capture_reset(); + zx_fill_measured_guest_record(&record); + record.zx_fault_hsr &= ~ZX_ISS_WNR_MASK; + zx_fault_report(&record); + + ZX_CHECK_EQ(zx_capture_contains("on a READ"), 1U); + ZX_CHECK_EQ(zx_capture_contains("on a WRITE"), 0U); + ZX_CHECK_EQ(zx_capture_contains("data abort ROUTED to Hyp"), 1U); ZX_CHECK_EQ(zx_capture_contains("CARRIES NO SYNDROME"), 0U); /* The hypervisor fault must read as a DIFFERENT event. */ diff --git a/test/host/test_zx_fault_log.c b/test/host/test_zx_fault_log.c new file mode 100644 index 0000000..e5e7b40 --- /dev/null +++ b/test/host/test_zx_fault_log.c @@ -0,0 +1,345 @@ +/*************************************************************************** + * Copyright (c) 2026 Eclipse ThreadX contributors + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available at + * https://opensource.org/licenses/MIT. + * + * AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). + * The AI-generated portions may be considered public domain (CC0-1.0) + * and not subject to the project's licence. The human contributor has + * reviewed and verified that the code is correct. + * + * SPDX-License-Identifier: MIT and CC0-1.0 + **************************************************************************/ + +/**************************************************************************/ +/* */ +/* COMPONENT DESCRIPTION */ +/* */ +/* test_zx_fault_log.c HOST TESTS */ +/* */ +/* DESCRIPTION */ +/* */ +/* Covers core/src/zx_fault_log.c: the many-fault log the isolation */ +/* matrix is judged from. */ +/* */ +/* This file exists because the log's two most important properties */ +/* are the ones a target run cannot demonstrate cheaply. */ +/* */ +/* IT KEEPS THE FIRST ENTRIES AND NOT THE LAST. Showing that on a */ +/* board means provoking more than thirty-two faults and then */ +/* arguing about which ones survived. Here it is four lines. */ +/* */ +/* A PREFETCH ABORT'S ADDRESS COMES FROM HIFAR AND A DATA ABORT'S */ +/* FROM HDFAR. Get that wrong and the whole matrix still passes on */ +/* a target -- because HDFAR after a prefetch abort holds a STALE */ +/* address from the previous data abort, and the previous data abort */ +/* in the sweep was aimed at the same partition. A plausible wrong */ +/* answer, in other words, and exactly the class of defect a host */ +/* test catches for nothing. */ +/* */ +/* The register values are the ones measured on the two targets and */ +/* already quoted in test_zx_fault.c, so a change to the decode has to */ +/* disagree with real hardware output rather than with an expectation. */ +/* */ +/**************************************************************************/ + +#include "zx_fault_log.h" +#include "zx_console_capture.h" +#include "zx_test.h" + +/* Measured on the Armv8-R AEM FVP, 2 September 2026, by the stage-2 probe: + a guest violation is EC 0x24, DFSC 0x04 (a stage-2 MISS), on a write. */ + +#define ZX_LOG_HSR_DABT_MISS_WRITE 0x93810044U + +/* The same class with DFSC 0x0C, a stage-2 PERMISSION failure: a region DID + cover the address and its AP refused. That is the syndrome a read-only + shared granule produces, and telling it from a miss is the difference + between two distinct claims. */ + +#define ZX_LOG_HSR_DABT_PERM_WRITE 0x9381004CU + +/* And on a READ -- ISS[6], WnR, clear. */ + +#define ZX_LOG_HSR_DABT_MISS_READ 0x93810004U + +/* A prefetch abort routed to EL2: EC 0x20. Its faulting address is in + HIFAR and there is no DFSC to decode. */ + +#define ZX_LOG_HSR_PABT_ROUTED 0x82000004U + +/* A prefetch abort taken FROM Hyp: EC 0x21. ZoneX faulting on its own + instruction fetch, which must never be counted as a guest violation. */ + +#define ZX_LOG_HSR_PABT_FROM_HYP 0x86000004U + +/* A data abort taken FROM Hyp: EC 0x25. Measured on the FVP. */ + +#define ZX_LOG_HSR_DABT_FROM_HYP 0x9600004CU + +#define ZX_PART_A 0U +#define ZX_PART_B 1U + +static ZX_FAULT_LOG zx_log; +static zx_fault_record_t zx_record; + + +/**************************************************************************/ +/* zx_make -- one capture, built rather than measured. */ +/**************************************************************************/ + +static void zx_make(uint32_t hsr, uint32_t hdfar, uint32_t hifar) +{ + zx_fault_record_reset(&zx_record); + + zx_record.zx_fault_vector = ZX_VECTOR_HYP_TRAP; + zx_record.zx_fault_hsr = hsr; + zx_record.zx_fault_hdfar = hdfar; + zx_record.zx_fault_hifar = hifar; + zx_record.zx_fault_elr = 0x02001234U; + zx_record.zx_fault_spsr = 0x000001D3U; +} + + +ZX_TEST_MAIN("zx_fault_log", { + + /* ---------------------------------------------------------------- */ + /* Every entry point survives a null pointer. A hypervisor that */ + /* crashed inside its own fault logger would replace a diagnosable */ + /* violation with an undiagnosable one. */ + /* ---------------------------------------------------------------- */ + + zx_fault_log_reset((ZX_FAULT_LOG *)0); + zx_make(ZX_LOG_HSR_DABT_MISS_WRITE, 0x02000000U, 0U); + zx_fault_log_add((ZX_FAULT_LOG *)0, ZX_PART_A, &zx_record, + ZX_FAULT_RESUMED_SKIPPED); + + ZX_CHECK_EQ(zx_fault_log_entry_address((const ZX_FAULT_LOG_ENTRY *)0), 0U); + ZX_CHECK(zx_fault_log_find((const ZX_FAULT_LOG *)0, ZX_PART_A, 0U) + == (const ZX_FAULT_LOG_ENTRY *)0); + ZX_CHECK_EQ(zx_fault_log_count_at((const ZX_FAULT_LOG *)0, ZX_PART_A, 0U), + 0U); + ZX_CHECK_EQ(zx_fault_log_partition_total((const ZX_FAULT_LOG *)0, + ZX_PART_A), 0U); + ZX_CHECK_EQ(zx_fault_log_all_guest_violations((const ZX_FAULT_LOG *)0), + 0U); + + zx_capture_reset(); + zx_fault_log_report((const ZX_FAULT_LOG *)0); + ZX_CHECK_EQ(zx_capture_contains("no fault log to report"), 1U); + + /* ---------------------------------------------------------------- */ + /* A fresh log. */ + /* ---------------------------------------------------------------- */ + + zx_fault_log_reset(&zx_log); + + ZX_CHECK_EQ(zx_log.zx_log_kept, 0U); + ZX_CHECK_EQ(zx_log.zx_log_total, 0U); + ZX_CHECK_EQ(zx_log.zx_log_dropped, 0U); + ZX_CHECK_EQ(zx_log.zx_log_entries[0].zx_log_partition, + (UINT)ZX_MANIFEST_NO_INDEX); + + /* AN EMPTY LOG IS NOT "all entries are guest violations". That is + vacuously true and it would pass on a run in which the entire matrix + was skipped, which is the single most likely way a sweep goes + wrong. */ + + ZX_CHECK_EQ(zx_fault_log_all_guest_violations(&zx_log), 0U); + + /* A null RECORD is refused too, and it must not move the totals: a + counter bumped for a capture that carried nothing would report a + fault nobody can look at. */ + + zx_fault_log_add(&zx_log, ZX_PART_A, (const zx_fault_record_t *)0, + ZX_FAULT_RESUMED_SKIPPED); + ZX_CHECK_EQ(zx_log.zx_log_total, 0U); + + /* ---------------------------------------------------------------- */ + /* Two directions, two addresses: the shape of the matrix. */ + /* ---------------------------------------------------------------- */ + + zx_make(ZX_LOG_HSR_DABT_MISS_READ, 0x02040100U, 0U); + zx_fault_log_add(&zx_log, ZX_PART_A, &zx_record, + ZX_FAULT_RESUMED_SKIPPED); + + zx_make(ZX_LOG_HSR_DABT_MISS_WRITE, 0x02000100U, 0U); + zx_fault_log_add(&zx_log, ZX_PART_B, &zx_record, + ZX_FAULT_RESUMED_SKIPPED); + + ZX_CHECK_EQ(zx_log.zx_log_kept, 2U); + ZX_CHECK_EQ(zx_log.zx_log_total, 2U); + ZX_CHECK_EQ(zx_fault_log_partition_total(&zx_log, ZX_PART_A), 1U); + ZX_CHECK_EQ(zx_fault_log_partition_total(&zx_log, ZX_PART_B), 1U); + ZX_CHECK_EQ(zx_fault_log_all_guest_violations(&zx_log), 1U); + + /* ATTRIBUTION IS BY PARTITION AND ADDRESS TOGETHER. Each of the four + combinations below is asked for, because a finder that matched on + only one of the two would satisfy the two positive cases and would + call the two negative ones hits -- and a matrix judged that way + would pass with every case aimed in one direction. */ + + ZX_CHECK(zx_fault_log_find(&zx_log, ZX_PART_A, 0x02040100U) + != (const ZX_FAULT_LOG_ENTRY *)0); + ZX_CHECK(zx_fault_log_find(&zx_log, ZX_PART_B, 0x02000100U) + != (const ZX_FAULT_LOG_ENTRY *)0); + ZX_CHECK(zx_fault_log_find(&zx_log, ZX_PART_A, 0x02000100U) + == (const ZX_FAULT_LOG_ENTRY *)0); + ZX_CHECK(zx_fault_log_find(&zx_log, ZX_PART_B, 0x02040100U) + == (const ZX_FAULT_LOG_ENTRY *)0); + + ZX_CHECK_EQ(zx_fault_log_count_at(&zx_log, ZX_PART_A, 0x02040100U), 1U); + ZX_CHECK_EQ(zx_fault_log_count_at(&zx_log, ZX_PART_A, 0x02000100U), 0U); + + /* A CASE THAT FAULTED TWICE. The count exists to catch it: a + partition resumed onto the same instruction faults again, and that + is a defect in the continue path rather than in isolation. */ + + zx_make(ZX_LOG_HSR_DABT_MISS_READ, 0x02040100U, 0U); + zx_fault_log_add(&zx_log, ZX_PART_A, &zx_record, + ZX_FAULT_RESUMED_SKIPPED); + ZX_CHECK_EQ(zx_fault_log_count_at(&zx_log, ZX_PART_A, 0x02040100U), 2U); + + /* ---------------------------------------------------------------- */ + /* HIFAR, NOT HDFAR, FOR A PREFETCH ABORT. */ + /* */ + /* HDFAR is given a DIFFERENT, plausible value here on purpose. A */ + /* decode that read HDFAR would find that address instead and the */ + /* matrix would pass, because on a target HDFAR after a prefetch */ + /* abort holds whatever the sweep's previous data abort left in it. */ + /* ---------------------------------------------------------------- */ + + zx_make(ZX_LOG_HSR_PABT_ROUTED, 0xDEADBEEFU, 0x02040080U); + zx_fault_log_add(&zx_log, ZX_PART_A, &zx_record, + ZX_FAULT_RESUMED_REDIRECTED); + + ZX_CHECK(zx_fault_log_find(&zx_log, ZX_PART_A, 0x02040080U) + != (const ZX_FAULT_LOG_ENTRY *)0); + ZX_CHECK(zx_fault_log_find(&zx_log, ZX_PART_A, 0xDEADBEEFU) + == (const ZX_FAULT_LOG_ENTRY *)0); + + /* And the same rule for a prefetch abort taken FROM Hyp, which is + ZoneX's own bug rather than a guest's -- so the log must stop + claiming every entry is a guest violation. */ + + zx_make(ZX_LOG_HSR_PABT_FROM_HYP, 0xDEADBEEFU, 0x00001234U); + zx_fault_log_add(&zx_log, ZX_PART_A, &zx_record, + ZX_FAULT_RESUMED_STOPPED); + + ZX_CHECK(zx_fault_log_find(&zx_log, ZX_PART_A, 0x00001234U) + != (const ZX_FAULT_LOG_ENTRY *)0); + ZX_CHECK_EQ(zx_fault_log_all_guest_violations(&zx_log), 0U); + + /* ---------------------------------------------------------------- */ + /* A fault nobody can place. It counts, and it does not index. */ + /* ---------------------------------------------------------------- */ + + zx_fault_log_reset(&zx_log); + zx_make(ZX_LOG_HSR_DABT_MISS_WRITE, 0x02000100U, 0U); + zx_fault_log_add(&zx_log, (UINT)ZX_MANIFEST_NO_INDEX, &zx_record, + ZX_FAULT_RESUMED_STOPPED); + + ZX_CHECK_EQ(zx_log.zx_log_total, 1U); + ZX_CHECK_EQ(zx_log.zx_log_kept, 1U); + ZX_CHECK_EQ(zx_fault_log_partition_total(&zx_log, ZX_PART_A), 0U); + ZX_CHECK_EQ(zx_fault_log_partition_total(&zx_log, ZX_MAX_PARTITIONS), 0U); + + /* ---------------------------------------------------------------- */ + /* THE ARRAY FILLS AND THEN STOPS TAKING ENTRIES, while the totals */ + /* keep rising. This is the property a ring buffer would not have */ + /* and the reason for choosing one over the other: the determinism */ + /* run's fault phase would otherwise evict the matrix. */ + /* ---------------------------------------------------------------- */ + + zx_fault_log_reset(&zx_log); + + { + uint32_t index; + + /* The first entry names an address nothing later uses, so that + finding it AFTER the flood is the assertion. */ + + zx_make(ZX_LOG_HSR_DABT_MISS_WRITE, 0x02040100U, 0U); + zx_fault_log_add(&zx_log, ZX_PART_B, &zx_record, + ZX_FAULT_RESUMED_SKIPPED); + + for (index = 0U; index < (ZX_FAULT_LOG_ENTRIES * 4U); index++) + { + zx_make(ZX_LOG_HSR_DABT_MISS_WRITE, 0x02000200U, 0U); + zx_fault_log_add(&zx_log, ZX_PART_B, &zx_record, + ZX_FAULT_RESUMED_SKIPPED); + } + } + + ZX_CHECK_EQ(zx_log.zx_log_kept, ZX_FAULT_LOG_ENTRIES); + ZX_CHECK_EQ(zx_log.zx_log_total, (ZX_FAULT_LOG_ENTRIES * 4U) + 1U); + ZX_CHECK_EQ(zx_log.zx_log_dropped, + ((ZX_FAULT_LOG_ENTRIES * 4U) + 1U) - ZX_FAULT_LOG_ENTRIES); + ZX_CHECK_EQ(zx_fault_log_partition_total(&zx_log, ZX_PART_B), + (ZX_FAULT_LOG_ENTRIES * 4U) + 1U); + + /* The evidence recorded FIRST is still there, which is the whole + point. A ring buffer would have thrown it away. */ + + ZX_CHECK(zx_fault_log_find(&zx_log, ZX_PART_B, 0x02040100U) + == &zx_log.zx_log_entries[0]); + + /* ---------------------------------------------------------------- */ + /* The report. */ + /* ---------------------------------------------------------------- */ + + zx_fault_log_reset(&zx_log); + zx_capture_reset(); + zx_fault_log_report(&zx_log); + + ZX_CHECK_EQ(zx_capture_contains("kept 0 of 0 total"), 1U); + ZX_CHECK_EQ(zx_capture_contains("The FIRST entries are kept"), 1U); + + zx_make(ZX_LOG_HSR_DABT_MISS_WRITE, 0x02040100U, 0U); + zx_fault_log_add(&zx_log, ZX_PART_B, &zx_record, + ZX_FAULT_RESUMED_SKIPPED); + zx_make(ZX_LOG_HSR_DABT_PERM_WRITE, 0x02080000U, 0U); + zx_fault_log_add(&zx_log, ZX_PART_B, &zx_record, + ZX_FAULT_RESUMED_SKIPPED); + zx_make(ZX_LOG_HSR_DABT_MISS_READ, 0x02000100U, 0U); + zx_fault_log_add(&zx_log, ZX_PART_A, &zx_record, + ZX_FAULT_RESUMED_STOPPED); + zx_make(ZX_LOG_HSR_PABT_ROUTED, 0U, 0x02040080U); + zx_fault_log_add(&zx_log, ZX_PART_A, &zx_record, + ZX_FAULT_RESUMED_REDIRECTED); + zx_make(ZX_LOG_HSR_DABT_FROM_HYP, 0x00006940U, 0U); + zx_fault_log_add(&zx_log, ZX_PART_A, &zx_record, 0xFFU); + + zx_capture_reset(); + zx_fault_log_report(&zx_log); + + /* THE TWO DFSC VALUES MUST READ DIFFERENTLY IN THE LOG. A report that + called a permission failure and a region miss the same thing would + make case 8's two halves indistinguishable, which is the one place + the difference carries the whole claim. */ + + ZX_CHECK_EQ(zx_capture_contains("no enabled region covered it"), 1U); + ZX_CHECK_EQ(zx_capture_contains("its AP refused"), 1U); + ZX_CHECK_EQ(zx_capture_contains("on a WRITE"), 1U); + ZX_CHECK_EQ(zx_capture_contains("on a READ"), 1U); + + /* Every resume disposition has a name, including one this build has no + name for -- a log entry reading as a blank is worse than one reading + as an admission. */ + + ZX_CHECK_EQ(zx_capture_contains("STOPPED (the shipping policy)"), 1U); + ZX_CHECK_EQ(zx_capture_contains("past the faulting instruction"), 1U); + ZX_CHECK_EQ(zx_capture_contains("the address the guest named"), 1U); + ZX_CHECK_EQ(zx_capture_contains("does not have a name for"), 1U); + + ZX_CHECK_EQ(zx_capture_contains("partition 0 faulted 3 times"), 1U); + ZX_CHECK_EQ(zx_capture_contains("partition 1 faulted 2 times"), 1U); + + /* A prefetch abort has no DFSC line of its own, and the address it + reports is HIFAR's. */ + + ZX_CHECK_EQ(zx_capture_contains("prefetch abort"), 1U); + ZX_CHECK_EQ(zx_capture_overflowed(), 0U); +}) From 6d6fb4540a3f92ed17357eb77f074c04559a05ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Thu, 3 Sep 2026 13:37:45 -0400 Subject: [PATCH 25/40] Hardened the fault paths, and took the console off the window boundary THE STRICT BUILD DID NOT BUILD, and had not since the major frame landed. -Wconversion on three ULONG-to-uint32_t console calls in the schedule report, and four (void) casts that do not silence warn_unused_result -- GCC deliberately ignores a cast there. CI enforces that preset on every push, so this was one pull request away from being found in public. Then the MISRA-aligned warning set, by the method the Cortex-R52 Modules port work established: re-compile every translation unit in every configuration with the candidate flags and adopt the ones that come back clean. Twenty flags adopted over 293 distinct (source, -D set) compilations across the host, the model and the board. Two candidates were run and REJECTED on evidence, and both are recorded with what they found: -Wwrite-strings collides with ThreadX's pre-const _txe_*_create signatures, and -Wunused-macros would force a fake use on every register bit a driver deliberately leaves clear. -Wredundant-decls paid for the exercise: zx_board_init was declared in two headers, which is Rule 8.5. AN EXCEPTION CLASS ZONEX DOES NOT HANDLE IS NOW PROVOKED. HCR.TID1 and an EL1 read of MPUIR give EC 0x03 on both targets: classified, named, vector and syndrome agreeing, and the run carries on -- which is the property that distinguishes an unhandled class from a hang. Until now the trap vector's ZX_RUN_TRAPPED arm and the classifier's last branch were dead code. Two routes were tried first and are recorded: WFI under HCR.TWI hangs if the trap is absent, and a floating-point instruction under HCPTR is UNDEFINED AT EL1 on the model, which has no FPU. AND THE VECTORS THAT CANNOT RESUME NO LONGER RUN ON A SUSPECT STACK. They reset SP_hyp before calling C -- the stack the fault arrived on may be the reason for it -- and the C side counts its depth so that a fault inside the fault report parks with its own exit code instead of recursing for ever. Neither guard has been provoked; that is recorded rather than left to look finished. THE CONSOLE. A window ending mid-sentence had that line closed by the BOUNDARY HANDLER: CR and LF into a polled UART, at EL2, with FIQ masked, on the switch path, with a trip count decided by what the outgoing guest had been printing. It moved the critical partition's window period by 24,420 counts of the board's 8 MHz counter, against 69 counts for that same neighbour violating its boundary a hundred thousand times. The newline is now deferred to whoever speaks next, inside a window that party owns. The character stream is identical -- only the moment of the write moves -- which is what makes it safe, and the host suite asserts the text rather than a flag. Sixty frames now measure 999, 1,159 and 1,196 counts. A rare excursion of about 15,300 counts remains on the HYPERCALL path, in roughly two six-hundred-frame runs in five; it is measured, bounded, and NOT explained, and the console phase keeps its own bound because of it. AND THE HPRENR FIGURE THAT TWO IMAGES DISAGREED ABOUT WAS ARITHMETIC. The switch measurement divided a loop's span by the round count and THEN subtracted a whole counter read, charging one read to every iteration where one is paid per loop. Same bench, same session, after the fix: 217 cycles from the probe against 219 from the frame, where it had been 235 against 117. Every per-component switch figure published before this was understated by about one counter read. Codegen identity, checked ELF by ELF before the fault-path work: all 49 hypervisor images byte-identical, with only the two guest images moving, by 16 bytes in one function, from one deliberate fix. Full silicon matrix run: five images pass, six negatives fail, each on the check it was built to fail and no other. FVP 24/24. Host coverage holds at 100% of lines and branches. Assisted-by: Claude Code (Opus 5) --- cmake/zx_warnings.cmake | 105 ++++++++++- core/inc/zx_guest_console.h | 23 +++ core/src/zx_guest_console.c | 108 +++++++++++- core/src/zx_schedule.c | 19 +- examples/common/zx_payload.S | 68 ++++++++ examples/common/zx_probe.h | 8 +- examples/common/zx_probe_main.c | 146 ++++++++++++++++ examples/common/zx_regression.c | 165 +++++++++++------- .../fvp_baser_aemv8r/guest/CMakeLists.txt | 11 +- examples/guest_common/zx_guest_app.c | 33 +++- examples/s32z280_evb/guest/CMakeLists.txt | 11 +- examples/s32z280_evb/zx_board.c | 14 +- platform/cortex_r52/src/zx_el2_fault_path.c | 69 ++++++++ platform/cortex_r52/src/zx_frame.c | 138 +++++++++++---- platform/cortex_r52/src/zx_trap_handler.S | 33 ++++ test/host/test_zx_guest_console.c | 124 +++++++++++++ test/host/test_zx_schedule.c | 31 +++- 17 files changed, 976 insertions(+), 130 deletions(-) diff --git a/cmake/zx_warnings.cmake b/cmake/zx_warnings.cmake index 74d5e24..504515f 100644 --- a/cmake/zx_warnings.cmake +++ b/cmake/zx_warnings.cmake @@ -23,6 +23,24 @@ # because retrofitting a codebase to a warning set costs far more than building # to one, and because ZoneX has a certification back end that will ask. # +# THE SECOND BLOCK BELOW IS THE MISRA-ALIGNED SET, and it was added by the +# hardening pass rather than at the beginning, by the method the Cortex-R52 +# Modules port work established: take the build's compile_commands.json, +# re-compile every translation unit in every configuration with the candidate +# flags, and adopt the ones that come back clean. Over 293 distinct +# (source, -D set) compilations across the model, the board and the host +# configurations, every flag in that block reported nothing -- so adopting it +# costs no change to the code and stops the class of defect from arriving +# later. Two candidates did report something and are named at the bottom of +# this file with what they found and why they were not adopted; the rule +# numbers beside each flag are the MISRA C:2012 rules it approximates, so +# that a reviewer can go the other way -- from a rule to the check that +# enforces it. +# +# -Wredundant-decls is the one that paid for the exercise: it found +# zx_board_init declared in both zx_port.h and examples/common/zx_probe.h, +# which is MISRA Rule 8.5. +# # Warnings become errors under the ci-strict configure preset, through CMake's # own CMAKE_COMPILE_WARNING_AS_ERROR. A developer build stays warning-tolerant # so that a half-finished change can still be compiled and looked at. @@ -53,6 +71,50 @@ set(ZX_WARNING_FLAGS -Wpointer-arith -Waggregate-return -Wfloat-equal + + # The MISRA-aligned block. Portable across GCC and ATfE clang; the + # GNU-only additions are in ZX_WARNING_FLAGS_GNU below. + -Wshadow # Rule 5.3 an inner declaration hiding an outer + -Wcast-qual # Rule 11.8 a cast that discards const + -Wbad-function-cast # Rule 11.1 a function result cast to another type + -Wswitch-default # Rule 16.4 every switch has a default + -Wswitch-enum # Rule 16.x every enumerator has a case + -Wstrict-prototypes # Rule 8.2 no unprototyped declaration + -Wold-style-definition # Rule 8.2 nor an unprototyped definition + -Wredundant-decls # Rule 8.5 declared once, in one file + -Wundef # Rule 20.9 no #if on an undefined identifier + -Wdouble-promotion # Dir 4.6 no silent float promotion + -Wjump-misses-init # Rule 15.x no jump over an initialisation + -Wnull-dereference # Rule 1.3 undefined behaviour + -Wvla # Rule 18.8 no variable-length array + -Wmissing-noreturn # Rule 17.x a function that cannot return says so + -Wformat=2 # Dir 4.x ZoneX has no printf, and will not gain + # one by accident + -Wstrict-overflow=2 # Rule 12.x no optimisation that assumes no + # signed overflow +) + +# GNU-only additions. Clang implements neither spelling and rejects both as +# unknown warnings, which is merely noisy until CMAKE_COMPILE_WARNING_AS_ERROR +# turns it into a failed build for every ZoneX source in the ATfE lane. +# +# PUBLISHED AS A VARIABLE for the same reason ZX_WARNING_FLAGS is: the guest +# support is compiled by a separate CMake invocation that cannot see this +# project's targets, and until this pass it restated -Wlogical-op by hand -- +# a second source of truth that agreed only because there was one flag in it. +set(ZX_WARNING_FLAGS_GNU + -Wlogical-op # Rule 10.1 a bitwise operator on a boolean + -Wduplicated-cond # Rule 14.3 an always-true or repeated condition + -Wduplicated-branches # Rule 14.3 two arms of an if that are identical + + # A CEILING ON ONE FUNCTION'S STACK FRAME, and it is a real number rather + # than a round one. The EL2 Hyp stack is 1 KB and the fault path runs on + # it, so a frame that grew without anybody noticing would be found by a + # corrupted run on the bench and not by a build. Measured across every + # translation unit in both board configurations: the largest single frame + # in the tree is 80 bytes, in the manifest validator. 256 leaves room to + # write ordinary code and still fails long before 1 KB is in danger. + -Wstack-usage=256 ) add_library(zx_warnings INTERFACE) @@ -60,12 +122,41 @@ add_library(zonex::warnings ALIAS zx_warnings) target_compile_options(zx_warnings INTERFACE ${ZX_WARNING_FLAGS}) -# -Wlogical-op is a GNU extension. Clang does not implement it: it has -# -Wlogical-op-parentheses, which is a different check, and rejects the GNU -# spelling as an unknown warning. That is merely noisy until -# CMAKE_COMPILE_WARNING_AS_ERROR turns it into a failed build for every ZoneX -# source under the ATfE lane, so it is selected by compiler rather than -# spelled unconditionally. target_compile_options(zx_warnings INTERFACE - $<$:-Wlogical-op> + $<$:${ZX_WARNING_FLAGS_GNU}> ) + +# TWO CANDIDATES THAT WERE RUN AND NOT ADOPTED. Recorded here rather than +# left out silently, because "we did not think of it" and "we looked at it and +# said no" are different answers to a reviewer, and only one of them is worth +# anything. +# +# -Wunused-macros reported two macros, and both are documentation rather +# than dead code: ZX_UARTCR_PCE in the S32Z280 board file, which names the +# parity bit whose ABSENCE from the value written is the decision, and one +# phase index in the regression. The regression's was adopted -- the macro +# is now used -- and the board's is a justified deviation from MISRA Rule +# 2.5 (advisory), written down at the definition. Adopting the flag would +# force every register bit a driver deliberately leaves clear to be either +# deleted or given a fake use, and a hardware header full of fake uses is +# worse than an advisory rule unmet. +# +# -Wwrite-strings was run and rejected on evidence. It does not merely +# diagnose: it changes the TYPE of a string literal to const char[], and +# ZoneX's guest support passes literal thread, queue and semaphore names +# straight into ThreadX's _txe_*_create, whose signatures take CHAR * and +# predate const by decades. Seven call sites, and the only way to silence +# them is a cast that discards const at each -- trading MISRA Rule 7.4, +# which this code already meets (every ZoneX string parameter is const CHAR +# *), for seven deviations from Rule 11.8, which it would not. The +# qualifier is missing from an API ZoneX does not own; the flag reports the +# call rather than the signature, and that is the wrong end. +# +# -Wcast-align=strict reported nothing today and is still not adopted. +# ZoneX reaches MMIO through ZX_REG32, which casts an integer address to a +# volatile pointer -- a cast the flag does not see, because there is no +# source pointer to compare alignment against. So a clean report from it +# says nothing about the code it would matter for, and a check that cannot +# fail where the risk is is a check that reads as coverage without being +# any. The alignment of a device register is a manifest and linker-script +# property here, asserted where those are built. diff --git a/core/inc/zx_guest_console.h b/core/inc/zx_guest_console.h index dc1f470..d00def8 100644 --- a/core/inc/zx_guest_console.h +++ b/core/inc/zx_guest_console.h @@ -92,6 +92,29 @@ void zx_guest_console_attach(UINT partition_id, const CHAR *name_ptr); void zx_guest_console_detach(void); +/* Hand the console back WITHOUT WRITING ANYTHING. What a WINDOW BOUNDARY + calls, where detach is what everything else calls. + + The difference is timing and it was measured rather than reasoned about. + Closing a partial line is two characters -- CR and LF -- through a polled + UART, and a boundary that did it wrote them at EL2 with FIQ masked, on + the switch path, with a trip count decided by what the OUTGOING guest had + been printing. On the S32Z280 that moved the critical partition's window + period by 24,000 counts of an 8 MHz counter while the same partition + violating its boundary a hundred and seventeen thousand times moved it by + sixty-nine. + + So a release records that a line is owed and writes nothing. The next + party to speak -- the incoming partition's first character, or the + hypervisor through detach -- writes the newline first, inside a window it + owns. The character stream is identical either way; only the moment the + newline is written moves. + + Use detach wherever the HYPERVISOR is about to print, and this wherever + the console is simply changing hands. */ + +void zx_guest_console_release(void); + /* One character from the running guest. Called from the hypercall path, so it must be safe to call with a guest's registers still live and must not itself be able to fault: it touches no guest memory at all -- the diff --git a/core/src/zx_guest_console.c b/core/src/zx_guest_console.c index e500f30..cdd3123 100644 --- a/core/src/zx_guest_console.c +++ b/core/src/zx_guest_console.c @@ -65,6 +65,35 @@ static const CHAR *zx_console_guest_name; static UINT zx_console_guest_at_line_start = 1U; +/* AND WHETHER A LINE IS STILL OWED A NEWLINE. Set by + zx_guest_console_release when a partition hands the console back + mid-sentence; cleared by whoever next writes, which pays for it. + + THIS EXISTS FOR A TIMING REASON AND NOT A FORMATTING ONE, and it is the + one number in this file that was measured on silicon. Closing the line + EAGERLY, at the window boundary, was two characters -- CR and LF -- into + a polled UART, on the switch path, at EL2, with FIQ masked. Two + characters back to back is also the exact case the board driver's + DTF-clear guard exists for, so the pair is the write most likely to spin + there. With the untrusted partition storming the console, the CRITICAL + partition's window period moved by 24,000 counts of an 8 MHz counter -- + two and a half per cent of a major frame -- while that same partition + violating its boundary a hundred and seventeen thousand times moved it by + sixty-nine. Nothing a partition did through the SCHEDULE reached its + neighbour; the hypervisor's own console driver did. + + Deferring the newline costs the same two characters, but charges them to + whoever writes next -- inside a window that party owns -- and takes the + console off the boundary path entirely. + + THE CHARACTER STREAM IS UNCHANGED, and that is what makes this safe: the + newline still appears in exactly the same place relative to every other + character, because the only thing that can follow it is a tag or a + hypervisor message, and both go through code that closes the line first. + What moves is WHEN it is written, not WHERE it appears. */ + +static UINT zx_console_guest_line_owed; + static UINT zx_console_guest_characters; static UINT zx_console_guest_orphans; @@ -87,8 +116,27 @@ static UINT zx_console_guest_orphans; /* */ /**************************************************************************/ +static void zx_guest_console_close_owed_line(void) +{ + if (zx_console_guest_line_owed != 0U) + { + zx_console_guest_line_owed = 0U; + zx_console_puts("\n"); + } +} + + static void zx_guest_console_tag(void) { + /* THE DEBT IS PAID BEFORE THE TAG, by the party about to speak. A + partition that handed the console back mid-sentence left a line open; + whoever opens the next one closes it first, so no two partitions ever + share a physical line and no tag is ever written into the middle of + one. Same characters, same order -- and none of them on the switch + path. */ + + zx_guest_console_close_owed_line(); + if (zx_console_guest_attached == 0U) { /* Not a formatting choice. A character arriving with no partition @@ -124,11 +172,18 @@ void zx_guest_console_attach(UINT partition_id, const CHAR *name_ptr) /* Any partial line belongs to whoever was attached before, so it is closed here as well as in detach. Attaching twice without detaching is a caller error, and the honest response to it is to keep the two - partitions' text on separate lines rather than to refuse. */ + partitions' text on separate lines rather than to refuse. + + A line left owed by zx_guest_console_release is NOT closed here. It + is closed by the tag, on the incoming partition's first character -- + so a partition that is scheduled and prints nothing costs nothing, + and the newline is charged to the window whose output needs it. */ + if (zx_console_guest_at_line_start == 0U) { - zx_console_puts("\n"); + zx_console_guest_line_owed = 0U; zx_console_guest_at_line_start = 1U; + zx_console_puts("\n"); } zx_console_guest_attached = 1U; @@ -158,7 +213,55 @@ void zx_guest_console_detach(void) { if (zx_console_guest_at_line_start == 0U) { + zx_console_guest_line_owed = 0U; + zx_console_guest_at_line_start = 1U; zx_console_puts("\n"); + } + else + { + /* Nothing is open now, but an earlier RELEASE may have left a line + owed -- and the hypervisor is the next thing to speak, which is + precisely the case this rule exists for. Settle it here, where + the cost is not on any switch path: detach is called when a + partition has left its window early or the frame has ended, and + in both the run is about to print rather than to schedule. */ + + zx_guest_console_close_owed_line(); + } + + zx_console_guest_attached = 0U; + zx_console_guest_name = (const CHAR *)0; +} + + +/**************************************************************************/ +/* */ +/* FUNCTION RELEASE */ +/* */ +/* zx_guest_console_release PORTABLE C */ +/* */ +/* DESCRIPTION */ +/* */ +/* Hand the console back WITHOUT writing anything. */ +/* */ +/* The window-boundary version of detach, and the difference between */ +/* them is a measured one rather than a stylistic one -- see the note */ +/* on zx_console_guest_line_owed above. A boundary must not write to */ +/* a polled UART: it runs at EL2 with FIQ masked, on the path whose */ +/* cost the whole determinism claim is about, and what it would write */ +/* depends on what the outgoing GUEST happened to be printing. */ +/* */ +/* Use detach, not this, wherever the hypervisor is about to speak. */ +/* The distinction is which side of the switch the two characters are */ +/* charged to, and both callers are in zx_frame.c a few lines apart. */ +/* */ +/**************************************************************************/ + +void zx_guest_console_release(void) +{ + if (zx_console_guest_at_line_start == 0U) + { + zx_console_guest_line_owed = 1U; zx_console_guest_at_line_start = 1U; } @@ -261,6 +364,7 @@ void zx_guest_console_reset(void) zx_console_guest_id = 0U; zx_console_guest_name = (const CHAR *)0; zx_console_guest_at_line_start = 1U; + zx_console_guest_line_owed = 0U; zx_console_guest_characters = 0U; zx_console_guest_orphans = 0U; } diff --git a/core/src/zx_schedule.c b/core/src/zx_schedule.c index e389094..b8ee784 100644 --- a/core/src/zx_schedule.c +++ b/core/src/zx_schedule.c @@ -460,8 +460,21 @@ void zx_schedule_report(const ZX_SCHEDULE *schedule_ptr, return; } + /* ULONG NARROWED TO uint32_t, EXPLICITLY, HERE AND TWICE BELOW. + zx_console_putdec takes a uint32_t and ULONG is the suite's "natural + word": 32 bits on every ZoneX target and 64 on the workstation that + runs the host suite. So the narrowing is a no-op where this code + ships and is real only in a host test, whose tick counts are + fixtures of a few thousand. + + MISRA C:2012 Rule 10.3 (assigning to a narrower essential type) is + deviated from by making the conversion explicit, which turns it into + a Rule 10.8 cast the reader can see and account for. The alternative + -- a 64-bit console primitive -- would put a 64-bit divide in the + hypervisor's console driver to print a number that never needs one. */ + zx_console_puts(" major frame: "); - zx_console_putdec(schedule_ptr->zx_schedule_frame_ticks); + zx_console_putdec((uint32_t)schedule_ptr->zx_schedule_frame_ticks); zx_console_puts(" ticks, "); zx_console_putdec(schedule_ptr->zx_schedule_window_count); zx_console_puts(" windows, tick = "); @@ -490,7 +503,7 @@ void zx_schedule_report(const ZX_SCHEDULE *schedule_ptr, } zx_console_puts(", "); - zx_console_putdec( + zx_console_putdec((uint32_t) schedule_ptr->zx_schedule_windows[index].zx_window_ticks); zx_console_puts(" ticks = "); zx_console_putdec((uint32_t)( @@ -506,7 +519,7 @@ void zx_schedule_report(const ZX_SCHEDULE *schedule_ptr, else { zx_console_puts(" stopping after "); - zx_console_putdec(schedule_ptr->zx_schedule_frame_limit); + zx_console_putdec((uint32_t)schedule_ptr->zx_schedule_frame_limit); zx_console_puts(" frames, so a test can assert on a total rather\n" " than on a run the harness had to kill\n"); } diff --git a/examples/common/zx_payload.S b/examples/common/zx_payload.S index d4caf8f..0adbf17 100644 --- a/examples/common/zx_payload.S +++ b/examples/common/zx_payload.S @@ -238,6 +238,74 @@ zx_payload_violation: hvc #ZX_ASM_HVC_IMM_YIELD .size zx_payload_violation, . - zx_payload_violation +/**************************************************************************/ +/* zx_payload_read_id_register -- read MPUIR, which EL2 has trapped. */ +/* */ +/* AN EXCEPTION CLASS ZONEX DOES NOT HANDLE, provoked so that the arm of */ +/* the trap vector that returns ZX_RUN_TRAPPED is a path something has */ +/* actually taken. HCR.TID1 traps EL1 READS of TCMTR, AIDR, MPUIR, */ +/* TLBTR and REVIDR to EL2 (TRM Table 3-65), and the syndrome is EC 0x03 */ +/* -- a trapped MCR/MRC on coprocessor 15. */ +/* */ +/* MPUIR IS THE RIGHT ONE TO READ, and not merely a convenient one. It */ +/* is the register that tells a partition how many EL1 MPU regions it */ +/* has, so trapping it is what a later ZoneX would do to virtualise that */ +/* number rather than let a guest read the part's. The probe therefore */ +/* exercises a trap the design has a reason to want, not one invented to */ +/* make an exception happen. */ +/* */ +/* AND IT CANNOT HANG OR GO UNDEFINED IN EITHER DIRECTION, which is what */ +/* makes it fit for a suite that runs unattended on a bench. If the trap */ +/* fires, EL2 gets the exception; if it does not, the MRC simply reads */ +/* MPUIR and the payload yields normally and the phase reports that the */ +/* trap did not fire. Two other routes were tried and rejected: WFI */ +/* under HCR.TWI hangs the run if the trap is absent, and a */ +/* floating-point instruction under HCPTR is UNDEFINED AT EL1 on the */ +/* Armv8-R AEM FVP -- measured -- so it never reaches EL2 there at all. */ +/* That measurement is recorded in docs/armv8r-el2-reference.md, because */ +/* it also qualifies what the FPU-denial argument can be shown to prove. */ +/**************************************************************************/ + + .global zx_payload_read_id_register + .type zx_payload_read_id_register, %function +zx_payload_read_id_register: + ZX_PAYLOAD_ENTER + + @ ATTEMPTED AND SURVIVED ARE CLEARED FIRST, and this is not tidiness. + @ The progress word is CUMULATIVE across every phase of the image, and + @ zx_payload_violation sets both of these bits before this payload ever + @ runs -- SURVIVED in the negative build, where the violation is aimed + @ somewhere the payload is granted and therefore does succeed. Without + @ this, the phase at EL2 read that build's leftover SURVIVED and + @ reported that a trapped MPUIR read had completed at EL1, which it had + @ not. The negative build caught it, which is what negative builds are + @ for. zx_payload_probe_read clears PROBE_OK on entry for exactly the + @ same reason. + + ldr r3, [r2] + bic r3, r3, #(ZX_PAYLOAD_ATTEMPTED | ZX_PAYLOAD_SURVIVED) + orr r3, r3, #ZX_PAYLOAD_ATTEMPTED + str r3, [r2] + dsb + + mrc p15, 0, r1, c0, c0, 4 @ MPUIR -- must trap, EC 0x03 + + @ Reached only if HCR.TID1 did NOT trap the read. SURVIVED says so, + @ and the value read is published so that the log can show the trap was + @ genuinely absent rather than merely unrecorded. The bit means the + @ same thing as it does in zx_payload_violation: the access this + @ payload exists to have refused went through. + + ldr r0, =zx_payload_probe_value + str r1, [r0] + + ldr r3, [r2] + orr r3, r3, #ZX_PAYLOAD_SURVIVED + str r3, [r2] + dsb + hvc #ZX_ASM_HVC_IMM_YIELD + .size zx_payload_read_id_register, . - zx_payload_read_id_register + .balign 64 /**************************************************************************/ diff --git a/examples/common/zx_probe.h b/examples/common/zx_probe.h index 89fb6c2..adfeca3 100644 --- a/examples/common/zx_probe.h +++ b/examples/common/zx_probe.h @@ -93,7 +93,12 @@ void zx_probe_fail(void); void zx_phase_two_partitions(uint32_t board_regions, uint32_t el2_regions); -void zx_board_init(void); +/* zx_board_init is NOT declared here. It is the reset path's entry into the + board, so it belongs to zx_port.h -- which this header includes -- and a + second declaration of it here was one, in violation of MISRA C:2012 + Rule 8.5 (an external function shall be declared in one and only one + file). Two declarations of the same function agree until somebody + changes one of them. */ /* How many EL2 regions the hypervisor's own MMIO needs on this board, and the code that programs them starting at first_index. @@ -214,6 +219,7 @@ extern char zx_payload_grant_check[]; extern char zx_payload_hvc_check[]; extern char zx_payload_probe_read[]; extern char zx_payload_violation[]; +extern char zx_payload_read_id_register[]; extern uint32_t zx_payload_result; extern uint32_t zx_payload_scratch; diff --git a/examples/common/zx_probe_main.c b/examples/common/zx_probe_main.c index e199a15..0520d5d 100644 --- a/examples/common/zx_probe_main.c +++ b/examples/common/zx_probe_main.c @@ -726,6 +726,151 @@ static void zx_phase_violation(void) } +/**************************************************************************/ +/* zx_phase_unexpected_ec -- the trap vector's LAST arm, taken on purpose.*/ +/* */ +/* EVERY HSR.EC VALUE MUST HAVE A DEFINED OUTCOME, and three of the four */ +/* classes ZoneX recognises are provoked somewhere in this suite: a guest */ +/* violation (EC 0x24 in this image, EC 0x20 in the regression), a */ +/* hypercall (EC 0x12), and ZoneX faulting on itself (EC 0x25, in the */ +/* build below this one). The fourth -- "routed to EL2 and not handled" */ +/* -- had never been taken by anything, so the arm of the trap vector */ +/* that returns ZX_RUN_TRAPPED and the last branch of the fault */ +/* classifier were both dead code. A branch nothing has ever taken is */ +/* not evidence that it works. */ +/* */ +/* HOW. HCR.TID1 traps EL1 reads of the ID group that includes MPUIR to */ +/* EL2 (TRM Table 3-65), with EC 0x03 -- a trapped MCR/MRC on */ +/* coprocessor 15. ZoneX handles neither the class nor the register, so */ +/* this is a genuine unhandled exception rather than a simulated one, and */ +/* it is the exact shape a later phase would use to virtualise the region */ +/* count a partition sees. See zx_payload_read_id_register for why this */ +/* route was chosen over the two that were tried first. */ +/* */ +/* WHAT IS ASSERTED. That the excursion comes BACK -- an unhandled class */ +/* that halted silently would be indistinguishable from a hang -- that it */ +/* comes back classified as an unexpected trap, that the vector and the */ +/* syndrome agree, and that the decoder NAMES the class instead of */ +/* falling through to its own "not documented" string. The EC value */ +/* itself is printed rather than asserted: what Phase 0 is claiming is */ +/* that an unhandled exception is diagnosable, and pinning 0x03 would */ +/* turn a part's documented freedom into a suite failure. */ +/**************************************************************************/ + +/* HCR.TID1, bit 16. Spelled here rather than in zx_port.h because it is a + TEST facility and not a hypervisor policy: ZoneX traps no ID register in + any shipping configuration, and a constant in the port's header would + read as though it did. */ + +#define ZX_PROBE_HCR_TID1 0x00010000U + +static void zx_probe_set_hcr_tid1(uint32_t enable) +{ + uint32_t hcr = zx_read_hcr(); + + if (enable != 0U) + { + hcr |= ZX_PROBE_HCR_TID1; + } + else + { + hcr &= ~(uint32_t)ZX_PROBE_HCR_TID1; + } + + __asm__ volatile("mcr p15, 4, %0, c1, c1, 0" : : "r"(hcr) : "memory"); + __asm__ volatile("isb" ::: "memory"); +} + + +static void zx_phase_unexpected_ec(void) +{ + uint32_t outcome; + const zx_fault_record_t *record; + uint32_t ec; + + zx_console_puts("\n=========================================================\n" + " AN EXCEPTION CLASS ZONEX DOES NOT HANDLE\n" + "=========================================================\n" + " HCR.TID1 is set below, so an EL1 read of MPUIR is\n" + " trapped to EL2. ZoneX handles neither that class nor\n" + " that register, which is the point: the run must come\n" + " back with the exception NAMED, and must carry on\n" + " afterwards -- an unhandled class that stopped without\n" + " saying so would look exactly like a hang.\n"); + + zx_probe_set_hcr_tid1(1U); + + outcome = zx_run_phase("read MPUIR at EL1 with HCR.TID1 set", + zx_payload_read_id_register, 0U); + record = zx_el2_fault_record(); + + /* Cleared again immediately, and BEFORE anything is asserted. Later + phases in this image are entitled to the machine they were written + against, and a check failing here would otherwise leave the trap + armed and take a second phase down with it. */ + + zx_probe_set_hcr_tid1(0U); + + zx_check("the payload got as far as the trapped read", + ((zx_payload_result & ZX_PAYLOAD_ATTEMPTED) != 0U) ? 1U : 0U); + + if ((zx_payload_result & ZX_PAYLOAD_SURVIVED) != 0U) + { + zx_console_puts( + "\n *** THE READ WAS NOT TRAPPED. HCR.TID1 was set and an EL1\n" + " *** read of MPUIR returned "); + zx_console_puthex(zx_payload_probe_value); + zx_console_puts(" instead of reaching EL2,\n" + " *** so this phase provoked nothing and the vector's\n" + " *** unhandled-class arm is still untested.\n"); + } + + zx_check("the read did NOT complete at EL1", + ((zx_payload_result & ZX_PAYLOAD_SURVIVED) == 0U) ? 1U : 0U); + + zx_check("the excursion came BACK, as ZX_RUN_TRAPPED -- neither a\n" + " violation nor a hypercall, and not a hang", + (outcome == ZX_RUN_TRAPPED) ? 1U : 0U); + + if (outcome != ZX_RUN_TRAPPED) + { + return; + } + + zx_fault_report(record); + + ec = zx_fault_ec(record->zx_fault_hsr); + + zx_note("HSR.EC as this part reported it", ec); + + zx_check("it is classified as an UNEXPECTED TRAP, which is the class\n" + " whose whole job is to be neither of the other three", + (zx_fault_classify(record->zx_fault_hsr) + == ZX_FAULT_UNEXPECTED_TRAP) ? 1U : 0U); + + zx_check("it arrived at the Hyp trap entry, +0x14, like everything else\n" + " routed from EL1", + (record->zx_fault_vector == ZX_VECTOR_HYP_TRAP) ? 1U : 0U); + + zx_check("the vector taken and HSR.EC agree with each other", + zx_fault_vector_agrees(record->zx_fault_vector, + record->zx_fault_hsr)); + + /* THE CHECK THIS PHASE EXISTS FOR. A class ZoneX does not handle must + still be REPORTED BY NAME. An exception that reached EL2, was not + recognised and printed "not a documented exception class" would be a + hypervisor telling its operator nothing at the one moment it has + something to say. Comparing against the decoder's OWN fallback + string is deliberate: it asks the question a reader of the log would + ask, rather than re-listing the table here and drifting from it. */ + + zx_check("and the decoder NAMES it rather than falling back on\n" + " \"not a documented exception class\"", + (zx_fault_ec_name(ec) + != zx_fault_ec_name(0xFFFFFFFFU)) ? 1U : 0U); +} + + /**************************************************************************/ /* zx_phase_provoke_el2_fault */ /* */ @@ -919,6 +1064,7 @@ ZX_NORETURN void zx_el2_main(void) zx_phase_hypercall(); zx_phase_hprenr(el2_regions); zx_phase_violation(); + zx_phase_unexpected_ec(); /* Last, because it reprograms the region set from a manifest and the single-payload phases above depend on the set they were given. */ diff --git a/examples/common/zx_regression.c b/examples/common/zx_regression.c index 1be375f..c772417 100644 --- a/examples/common/zx_regression.c +++ b/examples/common/zx_regression.c @@ -1192,44 +1192,68 @@ static uint64_t zx_jitter_bound(void) /**************************************************************************/ -/* zx_console_bound -- the bound for the ONE phase that needs its own. */ +/* zx_console_bound -- the ONE phase that still needs a bound of its own.*/ /* */ -/* A SEPARATE BOUND BECAUSE THERE IS A SEPARATE MECHANISM, and it is a */ -/* defect in the hypervisor rather than a limit of the partitioning. */ -/* Measured on the S32Z280-594EVB, and invisible on the model. */ +/* A SEPARATE BOUND BECAUSE THERE IS A SEPARATE MECHANISM, and it is a */ +/* defect in the hypervisor rather than a limit of the partitioning. */ +/* Measured on the S32Z280-594EVB; invisible on the model, whose console */ +/* is semihosting and costs the simulation no time at all. */ /* */ -/* WHAT WAS FOUND. With the untrusted partition storming the console, */ -/* the critical partition's window period moved by 20,806 counts -- two */ -/* and a half per cent of a frame, and twice the bound every other phase */ -/* meets. In the same run, that partition violating its boundary a */ -/* hundred and seventeen thousand times moved it by SEVENTY-THREE. */ -/* Nothing a partition does through the SCHEDULE reaches its neighbour; */ -/* what reaches it is the hypervisor's own console driver. */ +/* HALF OF THE MECHANISM IS NOW GONE AND HALF IS NOT, and the difference */ +/* between them is worth more than either number. */ /* */ -/* THE MECHANISM. A guest's console is one hypercall per character, and */ -/* the hypervisor writes it through a POLLED UART. When a window ends */ -/* with a partial line outstanding, the boundary handler closes that line */ -/* before handing the console to the next partition -- so the switch is */ -/* delayed by however many characters were owed, at EL2, with FIQ masked. */ -/* The tag has to be right, and a line begun by one partition must not */ -/* be continued under its neighbour's name; closing it at the boundary is */ -/* the simplest way to guarantee that and it is the wrong place. */ +/* GONE: THE LINE CLOSED AT THE WINDOW BOUNDARY. A guest's console is */ +/* one hypercall per character through a polled UART, and a window that */ +/* ended mid-sentence had that line closed by the BOUNDARY HANDLER -- */ +/* CR and LF, at EL2, with FIQ masked, on the switch path, and back to */ +/* back, which is the exact case the board driver's DTF-clear guard */ +/* exists for. It cost 24,420 counts over sixty frames and 24,584 over */ +/* six hundred, EVERY RUN. The newline is now deferred to whoever */ +/* speaks next, inside a window that party owns; the characters and */ +/* their order are unchanged and only the moment of the write moves, */ +/* which the host suite asserts directly. Afterwards, over sixty */ +/* frames, three consecutive runs: 999, 1,159, 1,196. A sixty-frame run */ +/* no longer sees this at all. */ /* */ -/* THE STRUCTURAL BOUND, and it is derived rather than fitted to the */ -/* number above. The worst case is one whole line of the untrusted */ -/* partition's output: forty-nine characters, at 115,200 baud 8N1, is */ -/* ten bits each and about 86.8 microseconds each -- roughly 34,000 */ -/* counts of this board's 8 MHz counter. Rounded up to HALF A WINDOW. */ -/* The measured 20,806 sits comfortably inside it, and the bound fails if */ -/* the console ever costs more than one line. */ +/* NOT GONE: A RARE EXCURSION OF ABOUT 15,300 COUNTS. Over SIX HUNDRED */ +/* frames, seven runs across three builds of this file, gave 1,169; */ +/* 1,169; 1,204; 1,236 -- and 30,615; 30,639; 30,689. The large ones */ +/* are one long period and one short correction of some 15,300 each, so */ +/* they are ONE event in ninety-nine, in roughly two runs in five. Four */ +/* consecutive clean runs said it had gone; the fifth said it had not, */ +/* and the fifth is the one to believe. */ /* */ -/* THE FIX IS NAMED AND IS NOT DONE HERE. The console must be buffered */ -/* and flushed off the boundary path, so that a partition's output is */ -/* charged to a window that partition owns. That is a change to the */ -/* guest console's line-tagging contract, which has properties of its own */ -/* under test, and it belongs with the console rather than with the */ -/* regression that found this. What the regression owes is the number, */ -/* the mechanism, and a bound that still catches it getting worse. */ +/* It is NOT the boundary handler, which now writes nothing. What is */ +/* left on the console path is the HYPERCALL: every character is still */ +/* written at EL2 with FIQ masked, and the driver then spins on a */ +/* write-one-to-clear flag under a guard of a hundred thousand */ +/* iterations. A partial spin of that guard is the right order of */ +/* magnitude and is the first place to look. It is not confirmed, and */ +/* naming a mechanism to go with a number before measuring it is how a */ +/* guess becomes a fact in somebody else's document. */ +/* */ +/* THE BOUND IS HALF A WINDOW, unchanged, and it is not tightened to fit */ +/* what a sixty-frame run happens to measure. The worst observed is */ +/* 30,689 and it sits inside; every other phase is held to one eighth of */ +/* a window, which is four times tighter. Tightening it to the */ +/* sixty-frame figure would produce a suite that passes in CTest and */ +/* fails on the bench two runs in five, which is worse than no bound. */ +/* */ +/* WHAT THIS COSTS THE CLAIM, said here because it is the honest place. */ +/* A partition's period is unaffected by a neighbour that computes, that */ +/* masks its own interrupts, or that violates its boundary ten thousand */ +/* times a run -- those move it by tens of counts. It is NOT unaffected */ +/* by a neighbour that PRINTS: that can still move it by about 15,300 */ +/* counts, rarely. Saying so is worth more than the sentence it costs. */ +/* */ +/* AND ONE THING THAT IS NOT EXPLAINED EITHER. The WIDENED build -- the */ +/* negative one, whose stage-2 limit for partition A is deliberately one */ +/* granule too generous -- measures 19,094, 19,188 and 19,284 counts in */ +/* this phase on three consecutive SIXTY-frame runs, where the correct */ +/* build measures twelve hundred and never more. The two builds differ */ +/* by one number in a region descriptor and print the same characters. */ +/* A wrong region limit perturbing the timing as well as the memory */ +/* would be a useful thing to be true; it is recorded and not claimed. */ /**************************************************************************/ static uint64_t zx_console_bound(void) @@ -1297,7 +1321,13 @@ static void zx_report_determinism(uint32_t core_hz) zx_console_puts(" phase min mean" " max jitter ticks own clock / core time\n"); - for (index = 0U; index < ZX_PHASE_COUNT; index++) + /* FROM THE WARM-UP, not from the baseline. This table REPORTS every + phase including the one that is discarded, and the loop below that + ASSERTS starts at ZX_PHASE_BASELINE -- so the two bounds are the + difference between the two loops and are spelled, rather than being + 0 and 1 for the reader to work out. */ + + for (index = ZX_PHASE_WARMUP; index < ZX_PHASE_COUNT; index++) { uint64_t jitter = (zx_phase[index].zx_phase_samples > 1U) ? (zx_phase[index].zx_phase_max @@ -1370,12 +1400,12 @@ static void zx_report_determinism(uint32_t core_hz) /* WHICH BOUND THIS PHASE IS HELD TO, chosen by MECHANISM and not by outcome. The console phase is the one in which the - hypervisor itself writes characters through a polled UART on the - boundary path, so it is the one phase whose perturbation is the - hypervisor's own doing rather than the partitioning's. That is - known in advance of any measurement, and it is the only reason - the phase is treated differently -- see zx_console_bound for the - number, its derivation, and the fix it is standing in for. */ + hypervisor's own polled console driver runs at EL2 with FIQ + masked while the untrusted partition prints, so it is the one + phase whose perturbation is the hypervisor's doing rather than + the partitioning's. That is known in advance of any + measurement. See zx_console_bound for what was fixed this step, + what is still there, and the seven runs either side of it. */ uint64_t limit = (zx_phase_behaviour[index] == (uint32_t)ZX_GB_STORM) ? zx_console_bound() : bound; @@ -1414,38 +1444,45 @@ static void zx_report_determinism(uint32_t core_hz) "\n" " ONE PHASE IS HELD TO A DIFFERENT BOUND, and it is worth reading\n" " the reason rather than the number. While the untrusted partition\n" - " STORMS THE CONSOLE, the critical partition's period moves by\n" - " more than any other phase -- and the mechanism is the\n" - " HYPERVISOR'S OWN CONSOLE DRIVER, not the partitioning. A guest's\n" - " console is one hypercall per character through a polled UART, and\n" - " a window that ends with a partial line outstanding has that line\n" - " closed by the BOUNDARY HANDLER before the console changes hands.\n" - " The switch is then delayed by however many characters were owed.\n" + " STORMS THE CONSOLE, the critical partition's period moves by more\n" + " than in any other phase -- and the mechanism is the HYPERVISOR'S\n" + " OWN CONSOLE DRIVER, not the partitioning.\n" + "\n" + " HALF OF IT HAS BEEN REMOVED. A window ending mid-sentence used\n" + " to have the line closed BY THE BOUNDARY HANDLER -- CR and LF into\n" + " a polled UART, at EL2, with FIQ masked, on the switch path --\n" + " which cost 24,420 counts on this board on EVERY run. The newline\n" + " is now deferred to whoever speaks next, inside a window that\n" + " party owns; the characters and their order are unchanged. A\n" + " sixty-frame run now measures about 1,200 counts.\n" "\n" - " So that phase is bounded by one partial line of output -- about\n" - " forty-nine characters at this board's baud rate, rounded up to\n" - " half a window -- and every other phase by one eighth of a window.\n" - " The distinction is by MECHANISM and was made before the\n" - " measurement: it is the one phase in which the hypervisor writes\n" - " characters on the boundary path.\n" + " HALF OF IT IS STILL HERE. Over six hundred frames the phase\n" + " reaches about 30,600 counts in roughly two runs in five -- one\n" + " long period and one short correction of some 15,300 each. That\n" + " is not the boundary handler, which now writes nothing; it is\n" + " somewhere on the HYPERCALL path, where every character is still\n" + " written at EL2 with FIQ masked. It is measured, it is inside the\n" + " bound below, and it is NOT explained.\n" "\n" " AND THE COMPARISON IS THE POINT. In the same run, the untrusted\n" " partition VIOLATING ITS BOUNDARY on every iteration of its own\n" - " loop -- over a hundred thousand times -- moves the critical\n" + " loop -- ten thousand times in this run and over a million in the\n" + " long one -- moves the critical\n" " partition's period by a few tens of counts. Nothing a partition\n" - " does through the schedule reaches its neighbour. What reaches it\n" - " is a polled UART write in a boundary handler, which is a defect\n" - " to be fixed by buffering the console off that path.\n"); + " does through the schedule reaches its neighbour -- and, since the\n" + " console stopped closing lines on the boundary path, nothing a\n" + " partition does through the CONSOLE reaches it either. That was\n" + " 24,420 counts on this board and is now the tail of one character.\n"); zx_check("A'S PERIOD IS STEADY WITHIN ITS BOUND IN EVERY MEASURED\n" " PHASE. max - min for each phase from the baseline\n" - " onwards -- against one eighth of a window, and\n" - " against half a window for the one phase in which the\n" - " hypervisor writes console characters on the boundary\n" - " path. A phase that produced fewer than two periods\n" - " FAILS rather than passing vacuously: 'the jitter was\n" - " within the bound' is true of a partition that was\n" - " never entered at all", + " onwards -- against one eighth of a window, and against\n" + " half a window for the one phase in which the\n" + " hypervisor's own console driver runs at EL2 while the\n" + " neighbour prints. A phase that produced fewer than\n" + " two periods FAILS rather than passing vacuously: 'the\n" + " jitter was within the bound' is true of a partition\n" + " that was never entered at all", within); zx_check("AND A'S MEAN PERIOD DOES NOT MOVE BETWEEN PHASES. This is\n" diff --git a/examples/fvp_baser_aemv8r/guest/CMakeLists.txt b/examples/fvp_baser_aemv8r/guest/CMakeLists.txt index d005cce..454e27a 100644 --- a/examples/fvp_baser_aemv8r/guest/CMakeLists.txt +++ b/examples/fvp_baser_aemv8r/guest/CMakeLists.txt @@ -126,11 +126,14 @@ else() "compiled with ThreadX's warning settings alone.") endif() -# -Wlogical-op is GNU-only; ATfE clang rejects the spelling outright, which is -# merely noisy until warnings are errors. Selected by compiler, exactly as -# cmake/zx_warnings.cmake does it for the hypervisor. +# The GNU-only half of the set, which ATfE clang rejects outright as unknown +# warnings -- merely noisy until warnings are errors. Selected by compiler, +# exactly as cmake/zx_warnings.cmake does it for the hypervisor, and read from +# the SAME module rather than restated: the list used to be spelled here as +# "-Wlogical-op" and agreed with the hypervisor's only because there was one +# flag in it. if(CMAKE_C_COMPILER_ID STREQUAL "GNU") - list(APPEND ZX_WARNING_FLAGS -Wlogical-op) + list(APPEND ZX_WARNING_FLAGS ${ZX_WARNING_FLAGS_GNU}) endif() if(ZX_GUEST_WARNINGS_AS_ERRORS) diff --git a/examples/guest_common/zx_guest_app.c b/examples/guest_common/zx_guest_app.c index 6291e32..50e41ed 100644 --- a/examples/guest_common/zx_guest_app.c +++ b/examples/guest_common/zx_guest_app.c @@ -953,7 +953,19 @@ static ULONG endless_compute(ULONG seed) } -static void endless_phase(ULONG options) +/* _Noreturn, AND IT IS A CLAIM RATHER THAN A HINT. The loop below has no + exit: a partition that reached its window's end and stopped being runnable + would spend every later window being idled through, and the schedule would + have nothing left to demonstrate. ATfE clang proved the function cannot + return and asked for the declaration (-Wmissing-noreturn); GCC did not. + Saying it out loud makes a future edit that adds an exit fail to compile + instead of quietly ending the phase. + + The spelling is the C keyword and not ZX_NORETURN: this file is guest code + compiled into a ThreadX image and does not include zx_api.h -- see the + header of zx_guest_bsp.h. */ + +static _Noreturn void endless_phase(ULONG options) { ULONG live = 0UL; ULONG work = 1UL; @@ -1269,11 +1281,20 @@ static void consumer_entry(ULONG thread_input) if (guest_mailbox_read(ZX_GD_TARGET) != 0UL) { - volatile ULONG *target = - (volatile ULONG *)(void *)(unsigned long) - guest_mailbox_read(ZX_GD_TARGET); - - unsigned long address = guest_mailbox_read(ZX_GD_TARGET); + /* READ ONCE, THEN CAST THE VALUE. The mailbox was read three times + here -- once for the test above and twice more to build these two + -- and the address the guest probed was therefore not provably the + address it printed. The hypervisor does not rewrite the mailbox + under a running partition today, so the three reads did agree; + "did agree" is not the property this line needs. + + And casting the READ rather than the CALL is what -Wbad-function- + cast asks for, which is MISRA C:2012 Rule 11.4's territory: an + integer-to-pointer conversion is worth a name and a line of its + own, not a cast stack in the middle of an initialiser. */ + + unsigned long address = guest_mailbox_read(ZX_GD_TARGET); + volatile ULONG *target = (volatile ULONG *)(void *)address; console_puts("probing "); console_puthex(address); diff --git a/examples/s32z280_evb/guest/CMakeLists.txt b/examples/s32z280_evb/guest/CMakeLists.txt index 75e66c7..db1fa83 100644 --- a/examples/s32z280_evb/guest/CMakeLists.txt +++ b/examples/s32z280_evb/guest/CMakeLists.txt @@ -124,11 +124,14 @@ else() "compiled with ThreadX's warning settings alone.") endif() -# -Wlogical-op is GNU-only; ATfE clang rejects the spelling outright, which is -# merely noisy until warnings are errors. Selected by compiler, exactly as -# cmake/zx_warnings.cmake does it for the hypervisor. +# The GNU-only half of the set, which ATfE clang rejects outright as unknown +# warnings -- merely noisy until warnings are errors. Selected by compiler, +# exactly as cmake/zx_warnings.cmake does it for the hypervisor, and read from +# the SAME module rather than restated: the list used to be spelled here as +# "-Wlogical-op" and agreed with the hypervisor's only because there was one +# flag in it. if(CMAKE_C_COMPILER_ID STREQUAL "GNU") - list(APPEND ZX_WARNING_FLAGS -Wlogical-op) + list(APPEND ZX_WARNING_FLAGS ${ZX_WARNING_FLAGS_GNU}) endif() if(ZX_GUEST_WARNINGS_AS_ERRORS) diff --git a/examples/s32z280_evb/zx_board.c b/examples/s32z280_evb/zx_board.c index d54bb70..d702998 100644 --- a/examples/s32z280_evb/zx_board.c +++ b/examples/s32z280_evb/zx_board.c @@ -73,11 +73,21 @@ #define ZX_LINCR1_INIT 0x00000001U /* initialisation mode */ /* UARTCR. PCE at bit 2 and TxEn at bit 4 are the two that matter here and - are easy to transpose. */ + are easy to transpose. + + ZX_UARTCR_PCE IS DEFINED AND NEVER USED, and that is deliberate. MISRA + C:2012 Rule 2.5 (advisory) says a project should contain no unused macro + declaration; -Wunused-macros reports this one. The deviation is taken + because the bit's ABSENCE from the value written below is the decision -- + the BootROM leaves parity enabled and this console has to clear it (see + the note at the write) -- and a bit that has to be off is worth naming at + the same place as the bits that have to be on. Naming it here is also + what makes the transposition this comment warns about visible: PCE at + bit 2 next to TXEN at bit 4, in one list, in register order. */ #define ZX_UARTCR_UART 0x00000001U /* UART rather than LIN mode */ #define ZX_UARTCR_WL0 0x00000002U /* with WL1 clear: 8-bit */ -#define ZX_UARTCR_PCE 0x00000004U /* parity enable */ +#define ZX_UARTCR_PCE 0x00000004U /* parity enable -- see above */ #define ZX_UARTCR_TXEN 0x00000010U #define ZX_UARTCR_RXEN 0x00000020U diff --git a/platform/cortex_r52/src/zx_el2_fault_path.c b/platform/cortex_r52/src/zx_el2_fault_path.c index 53f270a..99efa4f 100644 --- a/platform/cortex_r52/src/zx_el2_fault_path.c +++ b/platform/cortex_r52/src/zx_el2_fault_path.c @@ -46,6 +46,32 @@ /* an implementation detail: a handler that prints before it captures */ /* can lose the syndrome to its own console driver faulting. */ /* */ +/* AND NEITHER MAY BE ALLOWED TO RECURSE */ +/* */ +/* Everything below this point is code that runs AFTER ZoneX has */ +/* already failed once, so the ordinary assumption -- that the */ +/* hypervisor's own memory and its own console work -- is the */ +/* assumption most in doubt. If reporting the first fault takes a */ +/* second one, the vector captures again and calls back in here, and */ +/* a path whose entire purpose is to be unmistakable becomes a loop */ +/* that says nothing. That is the same failure the trap handler's */ +/* header warns about from the other side: a vector that falls through */ +/* is how a fault becomes a hang. */ +/* */ +/* So the depth is counted, in .bss, and the SECOND entry does not */ +/* print at all. It stores a verdict and parks, which is the one */ +/* action left that cannot itself fault: the console is the most */ +/* likely thing to have failed and is certainly the most likely thing */ +/* to fail again. The exit code says so, so that a silicon harness */ +/* reading zx_run_failures with no console at all can tell "ZoneX */ +/* faulted at EL2" from "ZoneX faulted at EL2 while reporting a fault */ +/* at EL2" -- which are different bugs and point at different code. */ +/* */ +/* The vector also puts SP_hyp back to the top of the Hyp stack before */ +/* calling either of these, so that a stack that had run away is not */ +/* the stack the report runs on. See ZX_FATAL_STACK in */ +/* zx_trap_handler.S. */ +/* */ /**************************************************************************/ #include "zx_port.h" @@ -57,6 +83,45 @@ #define ZX_EXIT_HYPERVISOR_FAULT 0x5AU #define ZX_EXIT_UNEXPECTED_VECTOR 0x5BU +#define ZX_EXIT_FAULT_WHILE_FAULTING 0x5CU + +/* HOW DEEP INTO THE UNRESUMABLE PATH THIS RUN IS. In .bss, so it is zero + before anything can read it and no initialiser has to be trusted -- the + same argument the fault-continue policy is built on. Shared by both + functions below rather than one each: a data abort taken while reporting + an unexpected VECTOR is the same problem as one taken while reporting a + data abort, and counting them separately would let the pair alternate for + ever. */ + +static uint32_t zx_el2_fault_path_depth; + + +/**************************************************************************/ +/* zx_el2_fault_path_enter */ +/* */ +/* Returns non-zero on the FIRST entry, when reporting is still worth */ +/* attempting. On any later entry it does not return at all: it parks */ +/* with the verdict that says the report itself faulted. */ +/**************************************************************************/ + +static uint32_t zx_el2_fault_path_enter(void) +{ + if (zx_el2_fault_path_depth != 0U) + { + /* NOT ONE CHARACTER IS PRINTED HERE. Reaching this line means + something between the vector and here has already faulted once, + and the console driver is both the largest part of that and a + polled device write on silicon. zx_console_exit stores the + verdict, executes a DSB and parks on the symbol a debug harness + breaks on; none of that touches a device. */ + + zx_console_exit(ZX_EXIT_FAULT_WHILE_FAULTING); + } + + zx_el2_fault_path_depth++; + + return 1U; +} /**************************************************************************/ @@ -65,6 +130,8 @@ ZX_NORETURN void zx_el2_hypervisor_fault(void) { + (void) zx_el2_fault_path_enter(); + zx_console_puts("\n" "=========================================================\n" " ZONEX FAULTED AT EL2. This is a HYPERVISOR bug.\n" @@ -88,6 +155,8 @@ ZX_NORETURN void zx_el2_hypervisor_fault(void) ZX_NORETURN void zx_el2_unexpected_vector(void) { + (void) zx_el2_fault_path_enter(); + zx_console_puts("\n" "=========================================================\n" " UNEXPECTED EL2 VECTOR. An exception nothing was expecting\n" diff --git a/platform/cortex_r52/src/zx_frame.c b/platform/cortex_r52/src/zx_frame.c index fd5174e..a9a2170 100644 --- a/platform/cortex_r52/src/zx_frame.c +++ b/platform/cortex_r52/src/zx_frame.c @@ -225,6 +225,29 @@ static void zx_frame_console_release(const ZX_FRAME *frame_ptr) } +/* THE BOUNDARY'S VERSION, AND IT WRITES NOTHING. See + zx_guest_console_release: a partial line closed here is CR and LF into a + polled UART, on the switch path, at EL2, with FIQ masked -- measured at + 24,000 counts of the S32Z280's 8 MHz counter on the critical partition's + window period, against 69 counts for the same partition violating its + boundary a hundred and seventeen thousand times. + + The line is still closed, and with the same two characters in the same + place in the stream; it is closed by whoever speaks next, inside a window + that party owns. The one above stays, and is what the RUN LOOP calls -- + a partition that left its window early, or a frame that has ended, is + followed by the hypervisor printing, and there the two characters are on + nobody's switch. */ + +static void zx_frame_console_hand_over(const ZX_FRAME *frame_ptr) +{ + if (frame_ptr->zx_frame_manifest != (const ZX_MANIFEST *)0) + { + zx_guest_console_release(); + } +} + + static void zx_frame_console_claim(const ZX_FRAME *frame_ptr, UINT index) { if (frame_ptr->zx_frame_manifest != (const ZX_MANIFEST *)0) @@ -587,12 +610,15 @@ ZX_GUEST_CONTEXT *zx_el2_window_boundary(void) intid = zx_gic_el2_acknowledge(); - /* The console is handed back BEFORE the measurement starts. See - zx_frame_console_release: closing a partial line is a polled-UART - write on silicon, and a switch cost that included it would depend on - what a guest had been printing. */ + /* The console changes hands BEFORE the measurement starts, and hands + over WITHOUT WRITING. Both matter and they are different points. + The first keeps the switch figure from depending on what a guest had + been printing; the second keeps the partition's PERIOD from doing so, + which the first never could -- a period is measured from entry to + entry and everything between the boundary and the entry is inside it, + measured or not. See zx_frame_console_hand_over. */ - zx_frame_console_release(frame_ptr); + zx_frame_console_hand_over(frame_ptr); start = zx_pmu_cycles(); @@ -877,6 +903,51 @@ uint32_t zx_frame_run(ZX_FRAME *frame_ptr) /* */ /**************************************************************************/ +/**************************************************************************/ +/* zx_frame_measure_per_round */ +/* */ +/* ONE COUNTER READ IS PAID PER LOOP, NOT PER ITERATION, and getting */ +/* that wrong is what every row below used to do. */ +/* */ +/* Each row brackets a loop of `rounds` operations between two reads of */ +/* the cycle counter, so the span holds `rounds` operations and ONE */ +/* extra read -- the closing one. The arithmetic here was */ +/* */ +/* per_round = span / rounds; cost = per_round - counter_read; */ +/* */ +/* which subtracts a WHOLE counter read from every iteration and */ +/* therefore over-subtracts by very nearly one read from each row. */ +/* */ +/* IT WAS FOUND BY TWO IMAGES DISAGREEING, and the disagreement had been */ +/* carried as unexplained since the switch was first measured. The */ +/* probe image times the same HPRENR mask write with the same alternating*/ +/* loop and subtracts the overhead the other way round -- from the span, */ +/* before dividing -- and reported 235 cycles on the S32Z280 where this */ +/* function reported 117, and 13 on the model where this reported 9. */ +/* Half on one target and two thirds on the other is not noise, and the */ +/* two differences are each almost exactly this function's own */ +/* zx_cost_counter_read: 118 cycles on the board and 4 on the model. */ +/* That is the whole of it. */ +/* */ +/* WHAT IT DOES NOT AFFECT. The published END-TO-END switch figure is */ +/* taken around the real boundary in zx_el2_window_boundary, with two */ +/* reads and no loop, and is untouched by this. What was understated is */ +/* every COMPONENT row -- the save, the restore, the EL1 MPU either way, */ +/* the region mask, the time freeze and the deadline -- each by about */ +/* one counter read, and all by the SAME amount, which is why their sum */ +/* fell short of the end-to-end figure by a constant rather than looking */ +/* wrong. */ +/**************************************************************************/ + +static uint32_t zx_frame_measure_per_round(uint32_t span, uint32_t rounds, + uint32_t counter_read) +{ + uint32_t net = (span > counter_read) ? (span - counter_read) : 0U; + + return net / rounds; +} + + void zx_frame_measure_switch(ZX_GUEST_CONTEXT *scratch_a, ZX_GUEST_CONTEXT *scratch_b, uint32_t mask_a, uint32_t mask_b, @@ -925,6 +996,14 @@ void zx_frame_measure_switch(ZX_GUEST_CONTEXT *scratch_a, total = zx_pmu_cycles() - start; cost_ptr->zx_cost_counter_read = total / rounds; + /* WHAT THIS ROW IS, PRECISELY, because every row below subtracts it. + It is the cost of ONE zx_pmu_cycles() plus the loop control around + it, and it is subtracted ONCE FROM THE SPAN of each row rather than + once from each iteration -- see zx_frame_measure_per_round. Being an + over-estimate of a bare read is the right way to be wrong here: the + loop control it carries is also present in the rows it is subtracted + from, so it cancels rather than accumulating. */ + /* ---- the full save ---------------------------------------------- */ start = zx_pmu_cycles(); @@ -934,10 +1013,9 @@ void zx_frame_measure_switch(ZX_GUEST_CONTEXT *scratch_a, zx_context_save(((round & 1U) == 0U) ? scratch_a : scratch_b); } - total = (zx_pmu_cycles() - start) / rounds; - cost_ptr->zx_cost_save_full = - (total > cost_ptr->zx_cost_counter_read) - ? (total - cost_ptr->zx_cost_counter_read) : 0U; + total = zx_pmu_cycles() - start; + cost_ptr->zx_cost_save_full = zx_frame_measure_per_round( + total, rounds, cost_ptr->zx_cost_counter_read); /* ---- the full restore ------------------------------------------- */ @@ -948,10 +1026,9 @@ void zx_frame_measure_switch(ZX_GUEST_CONTEXT *scratch_a, zx_context_restore(((round & 1U) == 0U) ? scratch_a : scratch_b); } - total = (zx_pmu_cycles() - start) / rounds; - cost_ptr->zx_cost_restore_full = - (total > cost_ptr->zx_cost_counter_read) - ? (total - cost_ptr->zx_cost_counter_read) : 0U; + total = zx_pmu_cycles() - start; + cost_ptr->zx_cost_restore_full = zx_frame_measure_per_round( + total, rounds, cost_ptr->zx_cost_counter_read); /* ---- the EL1 MPU alone, both directions ------------------------- */ @@ -962,10 +1039,9 @@ void zx_frame_measure_switch(ZX_GUEST_CONTEXT *scratch_a, zx_context_save_mpu(((round & 1U) == 0U) ? scratch_a : scratch_b); } - total = (zx_pmu_cycles() - start) / rounds; - cost_ptr->zx_cost_save_mpu = - (total > cost_ptr->zx_cost_counter_read) - ? (total - cost_ptr->zx_cost_counter_read) : 0U; + total = zx_pmu_cycles() - start; + cost_ptr->zx_cost_save_mpu = zx_frame_measure_per_round( + total, rounds, cost_ptr->zx_cost_counter_read); start = zx_pmu_cycles(); @@ -974,10 +1050,9 @@ void zx_frame_measure_switch(ZX_GUEST_CONTEXT *scratch_a, zx_context_restore_mpu(((round & 1U) == 0U) ? scratch_a : scratch_b); } - total = (zx_pmu_cycles() - start) / rounds; - cost_ptr->zx_cost_restore_mpu = - (total > cost_ptr->zx_cost_counter_read) - ? (total - cost_ptr->zx_cost_counter_read) : 0U; + total = zx_pmu_cycles() - start; + cost_ptr->zx_cost_restore_mpu = zx_frame_measure_per_round( + total, rounds, cost_ptr->zx_cost_counter_read); /* ---- the stage-2 region set: one HPRENR write ------------------- */ @@ -988,10 +1063,9 @@ void zx_frame_measure_switch(ZX_GUEST_CONTEXT *scratch_a, zx_stage2_enable_set(((round & 1U) == 0U) ? mask_a : mask_b); } - total = (zx_pmu_cycles() - start) / rounds; - cost_ptr->zx_cost_region_mask = - (total > cost_ptr->zx_cost_counter_read) - ? (total - cost_ptr->zx_cost_counter_read) : 0U; + total = zx_pmu_cycles() - start; + cost_ptr->zx_cost_region_mask = zx_frame_measure_per_round( + total, rounds, cost_ptr->zx_cost_counter_read); /* ---- the freeze: a suspend and a resume, which is CNTVOFF ------- */ @@ -1005,10 +1079,9 @@ void zx_frame_measure_switch(ZX_GUEST_CONTEXT *scratch_a, zx_context_time_resume(which); } - total = (zx_pmu_cycles() - start) / rounds; - cost_ptr->zx_cost_time_freeze = - (total > cost_ptr->zx_cost_counter_read) - ? (total - cost_ptr->zx_cost_counter_read) : 0U; + total = zx_pmu_cycles() - start; + cost_ptr->zx_cost_time_freeze = zx_frame_measure_per_round( + total, rounds, cost_ptr->zx_cost_counter_read); /* ---- arming the next boundary ----------------------------------- */ @@ -1023,10 +1096,9 @@ void zx_frame_measure_switch(ZX_GUEST_CONTEXT *scratch_a, armed = zx_el2_hyp_timer_arm(far_away + (uint64_t)round); } - total = (zx_pmu_cycles() - start) / rounds; - cost_ptr->zx_cost_deadline = - (total > cost_ptr->zx_cost_counter_read) - ? (total - cost_ptr->zx_cost_counter_read) : 0U; + total = zx_pmu_cycles() - start; + cost_ptr->zx_cost_deadline = zx_frame_measure_per_round( + total, rounds, cost_ptr->zx_cost_counter_read); /* Consumed rather than discarded: every deadline above is far in the future, so `armed` is non-zero by construction, and reading diff --git a/platform/cortex_r52/src/zx_trap_handler.S b/platform/cortex_r52/src/zx_trap_handler.S index a626bea..3bd9683 100644 --- a/platform/cortex_r52/src/zx_trap_handler.S +++ b/platform/cortex_r52/src/zx_trap_handler.S @@ -109,6 +109,32 @@ dsb .endm +/**************************************************************************/ +/* ZX_FATAL_STACK -- put SP_hyp somewhere known before calling C. */ +/* */ +/* USED ONLY BY THE VECTORS THAT DO NOT RESUME, and it is safe there for */ +/* exactly that reason: nothing below is coming back, so the stack the */ +/* exception was taken on is not needed and discarding it costs nothing. */ +/* */ +/* WHAT IT BUYS. The reporting path is C, and C needs a stack. Until */ +/* this macro existed it ran on whatever SP_hyp happened to hold when the */ +/* exception was taken -- which, for the one class of fault these vectors */ +/* exist to report, is a value that may be the reason for the fault. A */ +/* hypervisor whose stack has run away then faults again inside its own */ +/* fault report, and the run ends with nothing said. This is the vector */ +/* bracket rule from the other side: a handler may not assume the state */ +/* it was entered with is usable, and here the caller is ZoneX itself. */ +/* */ +/* It does not make the path re-entrant. A second fault taken INSIDE the */ +/* report would come back here, reset the same stack and call the same */ +/* function -- so the C side counts its own depth and stops reporting on */ +/* the second entry. See zx_el2_fault_path.c. */ +/**************************************************************************/ + + .macro ZX_FATAL_STACK + ldr sp, =__zx_hyp_stack_top + .endm + /**************************************************************************/ /* The EL2 vector table. 32-byte aligned, as HVBAR requires. */ /* */ @@ -371,12 +397,14 @@ zx_el2_resume: .type zx_el2_vector_pabt, %function zx_el2_vector_pabt: ZX_CAPTURE ZX_ASM_VEC_PABT_HYP + ZX_FATAL_STACK bl zx_el2_hypervisor_fault .size zx_el2_vector_pabt, . - zx_el2_vector_pabt .type zx_el2_vector_dabt, %function zx_el2_vector_dabt: ZX_CAPTURE ZX_ASM_VEC_DABT_HYP + ZX_FATAL_STACK bl zx_el2_hypervisor_fault .size zx_el2_vector_dabt, . - zx_el2_vector_dabt @@ -393,24 +421,28 @@ zx_el2_vector_dabt: .type zx_el2_vector_reset, %function zx_el2_vector_reset: ZX_CAPTURE ZX_ASM_VEC_RESET + ZX_FATAL_STACK bl zx_el2_unexpected_vector .size zx_el2_vector_reset, . - zx_el2_vector_reset .type zx_el2_vector_undef, %function zx_el2_vector_undef: ZX_CAPTURE ZX_ASM_VEC_UNDEF + ZX_FATAL_STACK bl zx_el2_unexpected_vector .size zx_el2_vector_undef, . - zx_el2_vector_undef .type zx_el2_vector_svc, %function zx_el2_vector_svc: ZX_CAPTURE ZX_ASM_VEC_SVC + ZX_FATAL_STACK bl zx_el2_unexpected_vector .size zx_el2_vector_svc, . - zx_el2_vector_svc .type zx_el2_vector_irq, %function zx_el2_vector_irq: ZX_CAPTURE ZX_ASM_VEC_IRQ + ZX_FATAL_STACK bl zx_el2_unexpected_vector .size zx_el2_vector_irq, . - zx_el2_vector_irq @@ -418,6 +450,7 @@ zx_el2_vector_irq: .type zx_el2_vector_fiq, %function zx_el2_vector_fiq: ZX_CAPTURE ZX_ASM_VEC_FIQ + ZX_FATAL_STACK bl zx_el2_unexpected_vector .size zx_el2_vector_fiq, . - zx_el2_vector_fiq diff --git a/test/host/test_zx_guest_console.c b/test/host/test_zx_guest_console.c index 9ad5722..aedfcfb 100644 --- a/test/host/test_zx_guest_console.c +++ b/test/host/test_zx_guest_console.c @@ -186,6 +186,126 @@ static void test_carriage_return_is_dropped(void) } +/**************************************************************************/ +/* The DEFERRED close, which is the boundary path. */ +/* */ +/* zx_guest_console_release exists so that a WINDOW BOUNDARY writes no */ +/* characters -- see the header for the silicon measurement that made it */ +/* necessary. What has to be true of it is that the text a reader ends */ +/* up with is the text detach would have produced, character for */ +/* character: the whole point is that only the MOMENT the newline is */ +/* written moves, and a fix for a timing problem that quietly changed the */ +/* log would be a worse bug than the one it fixed. */ +/* */ +/* So each case below is asserted against the stream, not against the */ +/* flag. */ +/**************************************************************************/ + +static void test_release_writes_nothing_and_still_closes_the_line(void) +{ + zx_guest_console_reset(); + zx_capture_reset(); + + zx_guest_console_attach(1U, "guest A"); + guest_says("mid-sentenc"); + + /* THE BOUNDARY. Not one character may come out of it: this is the + instant the switch cost and the incoming partition's period are both + measured across. */ + + zx_capture_reset(); + zx_guest_console_release(); + ZX_CHECK_EQ((int)(zx_capture_text()[0]), (int)'\0'); + + /* And the incoming partition pays for it, on its first character, + inside its own window -- newline first, then its own tag. */ + + zx_guest_console_attach(2U, "guest B"); + ZX_CHECK_EQ((int)(zx_capture_text()[0]), (int)'\0'); + + guest_says("mine\n"); + ZX_CHECK(zx_capture_contains("\n[P2 guest B] mine\n") != 0U); + + /* The forged-continuation failure this must not have: guest B's text + must never appear on guest A's line. */ + ZX_CHECK(zx_capture_contains("mid-sentenc[P2") == 0U); + ZX_CHECK(zx_capture_contains("mid-sentencmine") == 0U); +} + + +static void test_a_release_at_a_line_start_owes_nothing(void) +{ + zx_guest_console_reset(); + zx_capture_reset(); + + /* The ordinary boundary: the outgoing partition finished its line. + Nothing is owed, so the incoming partition's first character must + NOT be preceded by a blank line -- which in a two-partition log is a + reader wondering what was lost. */ + + zx_guest_console_attach(1U, "guest A"); + guest_says("complete\n"); + zx_guest_console_release(); + zx_guest_console_attach(2U, "guest B"); + guest_says("mine\n"); + + ZX_CHECK(zx_capture_contains("[P1 guest A] complete\n[P2 guest B] mine\n") + != 0U); +} + + +static void test_the_hypervisor_settles_the_debt_before_it_speaks(void) +{ + zx_guest_console_reset(); + zx_capture_reset(); + + /* A window ends mid-line and the FRAME ends with it, so the next thing + to print is the hypervisor rather than another partition. detach is + what the run loop calls there, and it has to close a line that + RELEASE left owed -- otherwise the fault report or the verdict is + appended to a guest's unfinished sentence and read as part of it, + which is the exact failure the eager close existed to prevent. */ + + zx_guest_console_attach(1U, "guest A"); + guest_says("about to fau"); + zx_guest_console_release(); + + zx_capture_reset(); + zx_guest_console_detach(); + ZX_CHECK_EQ((int)(zx_capture_text()[0]), (int)'\n'); + + /* And only once. A second detach must add no further blank line. */ + zx_capture_reset(); + zx_guest_console_detach(); + ZX_CHECK_EQ((int)(zx_capture_text()[0]), (int)'\0'); +} + + +static void test_attaching_over_a_released_partial_line(void) +{ + zx_guest_console_reset(); + zx_capture_reset(); + + /* release then attach then release then attach, with nothing printed in + between: a partition that is scheduled and says nothing must not turn + one owed newline into two, and must not lose it either. The debt + survives an idle window and is paid by whoever finally speaks. */ + + zx_guest_console_attach(1U, "guest A"); + guest_says("owed"); + zx_guest_console_release(); + + zx_guest_console_attach(2U, "guest B"); + zx_guest_console_release(); /* B printed nothing at all */ + + zx_guest_console_attach(1U, "guest A"); + guest_says("again\n"); + + ZX_CHECK(zx_capture_contains("[P1 guest A] owed\n[P1 guest A] again\n") + != 0U); +} + + static void test_an_orphan_character_is_named(void) { zx_guest_console_reset(); @@ -217,5 +337,9 @@ ZX_TEST_MAIN("test_zx_guest_console", test_an_unterminated_line_is_closed(); test_attaching_over_a_partial_line(); test_carriage_return_is_dropped(); + test_release_writes_nothing_and_still_closes_the_line(); + test_a_release_at_a_line_start_owes_nothing(); + test_the_hypervisor_settles_the_debt_before_it_speaks(); + test_attaching_over_a_released_partial_line(); test_an_orphan_character_is_named(); ) diff --git a/test/host/test_zx_schedule.c b/test/host/test_zx_schedule.c index 5cfd210..cc624f5 100644 --- a/test/host/test_zx_schedule.c +++ b/test/host/test_zx_schedule.c @@ -199,6 +199,29 @@ static void test_build_rejections(void) } +/**************************************************************************/ +/* advance_for_its_side_effect */ +/* */ +/* zx_schedule_advance is ZX_NODISCARD, and a (void) cast does NOT */ +/* silence GCC's warn_unused_result -- deliberately, because an */ +/* attribute that a cast could switch off would not be worth having. */ +/* Three tests below turn the schedule over for its effect on the FRAME */ +/* -- the drift arithmetic, the frame count, the frame limit -- and have */ +/* no business asserting which partition comes next; the ones that do */ +/* assert it call zx_schedule_advance directly, a few lines above. */ +/* */ +/* So the value is consumed here, once, where the reason is written */ +/* down, rather than four times where it would read as an oversight. */ +/**************************************************************************/ + +static void advance_for_its_side_effect(ZX_SCHEDULE *schedule_ptr) +{ + UINT next = zx_schedule_advance(schedule_ptr); + + (void) next; +} + + static void test_round_robin(void) { UINT index; @@ -248,7 +271,7 @@ static void test_round_robin(void) ZX_CHECK_EQ(zx_schedule_current_partition(&schedule), index); ZX_CHECK_EQ(zx_schedule_deadline(&schedule), (uint64_t)(index + 1U) * FVP_TICK_COUNTS); - (void)zx_schedule_advance(&schedule); + advance_for_its_side_effect(&schedule); } ZX_CHECK_EQ(zx_schedule_current_partition(&schedule), 0U); @@ -293,8 +316,8 @@ static void test_no_drift_over_many_frames(void) for (frame = 0UL; frame < 10000UL; frame++) { - (void)zx_schedule_advance(&schedule); - (void)zx_schedule_advance(&schedule); + advance_for_its_side_effect(&schedule); + advance_for_its_side_effect(&schedule); } ZX_CHECK_EQ(schedule.zx_schedule_frames, 10000UL); @@ -399,7 +422,7 @@ static void test_frame_limit(void) for (step = 0UL; step < 1000UL; step++) { - (void)zx_schedule_advance(&schedule); + advance_for_its_side_effect(&schedule); } } From b9e4cf83570854f5b010ce87da05b8275460547f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Thu, 3 Sep 2026 13:38:16 -0400 Subject: [PATCH 26/40] Recorded the hardening pass: the ABI shape, the errata, coverage and WCET Four new documents and two amended ones. They exist because the certification back end asks for evidence rather than for assertions, and because most of what this pass produced is a REASON rather than a diff. docs/hypercall-abi.md freezes the SHAPE the later phases inherit: a sixteen-bit id space divided by what a call does to control flow, half of it reserved to an integrator so nobody has to fork the list, a discovery call that reports feature bits rather than a version to branch on, and the rule that Phase 0 implements console output and the empty vector and NOTHING else. Sections 3 and 6 specify behaviour that is not built -- discovery and a defined error for an unimplemented id -- and say so in both places: the two arrive together with the first fourth hypercall or neither is worth having. docs/errata.md is the sweep, and a sweep with no findings is only worth something if what was searched is written down. The part is r1p3, from its own MIDR. Seven Cortex-R52 errata are open there, four of them debug and trace only; the other three do not apply and the reasoning is given so a reader can check it rather than take it. Two leave a standing constraint -- HSCTLR.FI must stay clear, and an ISB before any SETEND -- and the first is the kind of bit a hypervisor arguing about interrupt latency would be tempted by. No S32Z2 erratum mentions EL2, stage 2, HPRENR or HCR at all; one bears on device isolation in a later phase and is recorded there rather than here. docs/coverage.md is the other half of the decision not to hold the suite's usual threshold repository-wide: which seven files are held to 100% of lines AND branches, what covers the port instead -- a build that must fail, or one that must pass with a named check -- and the three conditional-compilation axes whose every arm is assembled and executed somewhere in the matrix. Structural coverage of target code is named as a later, funded concern and no number here stands in for it. docs/wcet-inputs.md is the data-dependence list, read path by path. The dominant cost is CONSTANT: the EL1 MPU walk, whose trip count is a part constant read once at boot. What is data-dependent is named with what is known about each -- the console on the hypercall path, the board driver's guard spin bounded by an iteration count rather than by time, and burning a stopped partition's window, which is bounded and deliberate. decisions.md gains the counter-read arithmetic correction to D4 with the corrected component table, the finding that D23's FPU argument cannot be demonstrated on a model with no FPU, and an amendment to D28 saying what the console phase was really measuring. D29 records the lazy line close; D30 records the stack and depth guards on the vectors that cannot resume, including that neither has been provoked. armv8r-el2-reference.md gains a table of which exception classes have actually been TAKEN and how, and the measured fact that the model implements no FPU. One claim in it -- the CPACR-before-HCPTR check order -- is marked as read from the architecture manual and NOT verified, because the attempt to verify it is what found the model has no FPU, and this sheet's convention is that anything unmarked was measured. Assisted-by: Claude Code (Opus 5) --- docs/armv8r-el2-reference.md | 56 ++++++++ docs/coverage.md | 162 +++++++++++++++++++++++ docs/decisions.md | 250 ++++++++++++++++++++++++++++++++++- docs/errata.md | 187 ++++++++++++++++++++++++++ docs/hypercall-abi.md | 224 +++++++++++++++++++++++++++++++ docs/wcet-inputs.md | 191 ++++++++++++++++++++++++++ 6 files changed, 1068 insertions(+), 2 deletions(-) create mode 100644 docs/coverage.md create mode 100644 docs/errata.md create mode 100644 docs/hypercall-abi.md create mode 100644 docs/wcet-inputs.md diff --git a/docs/armv8r-el2-reference.md b/docs/armv8r-el2-reference.md index 3ccb5a6..a60aa4d 100644 --- a/docs/armv8r-el2-reference.md +++ b/docs/armv8r-el2-reference.md @@ -282,6 +282,62 @@ Phase-0 demonstrator exists to show. `0x25` means the hypervisor faulted on itself — a ZoneX bug — and must be reported as one rather than folded in with `0x24`. +### Which classes have actually been PROVOKED, and how + +A table of exception classes says what a part can report. It says nothing about +whether the hypervisor's decode of them has ever run. These have been taken +deliberately, on both targets: + +| `EC` | provoked by | outcome asserted | +|---|---|---| +| `0x03` | `HCR.TID1` set, then an EL1 read of `MPUIR` | classified as an unexpected trap, named by the decoder, and **the run carries on** | +| `0x12` | `HVC #0`, the empty vector | returns to the guest with its registers intact | +| `0x20` | a guest branching into its neighbour's code | the partition is stopped, or resumed at an address it published | +| `0x24` | a guest writing outside its window | the partition is stopped; `HDFAR` is exactly the address touched | +| `0x25` | an EL2 region made read-only at EL2 and then written from EL2 | reported as a **hypervisor** bug, with its own vector and its own exit code | + +**`HCR.TID1` is the route to an unhandled class, and the choice matters.** It +traps EL1 reads of `TCMTR`, `AIDR`, `MPUIR`, `TLBTR` and `REVIDR` to EL2 (TRM +Table 3-65) with `EC 0x03`. ZoneX handles neither the class nor the register, so +it is a genuine unhandled exception rather than a simulated one — and `MPUIR` +is the register a later phase would trap in earnest, to virtualise the region +count a partition sees. **It also cannot hang or go undefined in either +direction**, which is what makes it fit for an unattended bench: if the trap +fires EL2 gets the exception, and if it does not the `MRC` simply reads `MPUIR`. + +Two routes were tried first and rejected, and both are worth recording: + +* **`WFI` under `HCR.TWI`** hangs the run if the trap is absent. +* **A floating-point instruction under `HCPTR.TCP10`/`TCP11`** is UNDEFINED AT + EL1 on the Armv8-R AEM FVP — see below. + +### ⚠ The Armv8-R AEM FVP implements no FPU + +*Measured 3 September 2026.* + +`VMOV s0, r0` executed at EL1 on the model is **UNDEFINED** — with +`HCPTR.TCP10`/`TCP11` set, with them clear, and with `CPACR.cp10`/`cp11` +enabled by the payload first. It never reaches EL2 in any of those states: the +payload's own undefined-instruction vector takes it. + +Three things follow. + +* **A floating-point trap to EL2 cannot be demonstrated on the model at all.** + Any test of `HCPTR`-based FPU denial is silicon-only. +* **The check order matters, and it is NOT measured here.** The architecture + checks `CPACR` before `HCPTR` before `FPEXC`, so on a part that *has* an FPU + a guest which has not enabled coprocessor access for itself should take an + undefined-instruction exception at **EL1** and the hypervisor should never + see it. ⚠ **That is read from the architecture manual and not verified on + silicon** — it could not be, because the attempt to verify it is what found + the model has no FPU. It is stated here as the thing to check first on the + board, not as a measured fact, and this sheet's convention is that anything + unmarked *was* measured. +* **`-mfloat-abi=soft` is the default for both toolchain files**, so ZoneX + itself emits no VFP instruction at EL2 on either target. The hard-float + option exists and selecting it would put floating point in the hypervisor; + see `docs/errata.md` for what that implies on this part. + --- ## Timers and the GIC diff --git a/docs/coverage.md b/docs/coverage.md new file mode 100644 index 0000000..5d58a32 --- /dev/null +++ b/docs/coverage.md @@ -0,0 +1,162 @@ + + +# Coverage: what is measured, what is not, and what stands in for it + +*Eclipse ThreadX ZoneX. Last updated 3 September 2026.* + +ZoneX does not hold the Eclipse ThreadX suite's usual coverage threshold over +its whole repository, and `docs/decisions.md` D11 records why. This document +is the other half of that decision: **which files are held to a floor, what the +floor is, which files are not, and what the evidence for those is instead.** + +A coverage number quoted without that division is worth very little. A reviewer +reading "72% of ZoneX" learns nothing about whether the parts that can be +tested were. + +--- + +## The floor: 100% of the fully reachable core, lines *and* branches + +`test/host/run.sh coverage` enforces it, and CI runs that script on every push +and pull request. It is not a report; it fails the build. + +| file | what it is | +|---|---| +| `core/src/zx_fault.c` | the fault classifier and syndrome decoder | +| `core/src/zx_fault_log.c` | the many-fault log | +| `core/src/zx_guest_console.c` | the guest console's tagging rules | +| `core/src/zx_manifest_verify.c` | the manifest validator | +| `core/src/zx_mm_setup.c` | the region-layout planner | +| `core/src/zx_partition_manager.c` | the partition loader | +| `core/src/zx_schedule.c` | the time-partition schedule | + +**100% is not aspirational — it is what they measure.** All seven are pure +functions with no hardware in them, all seven are reachable in full from a +workstation, and none has an excuse for an unreached line. A validator rule +added without a case that fails it drops the number and fails the build, which +is the entire point: **a rule nothing has ever seen reject anything is not a +rule, it is a comment.** + +**Branches as well as lines, and that is the half that does the work.** Line +coverage is satisfied by a run that takes one arm of every `if`. The failures +these files exist to prevent live in the arms nobody took — the manifest that is +refused, the fault that is a read rather than a write, the partial line closed +before the hypervisor speaks. + +### A file joins this list when it becomes reachable in full + +Not when it is written. Two files joined it during the isolation work — the +fault decoder and the fault log — once the isolation matrix made them reachable: +the decoder's "on a READ" arm had never been printed, because every fault the +suite provoked happened to be a write, and the matrix provokes a read and a +write against the same address deliberately. + +**Adding a file that cannot be reached in full is how a floor ends up lowered +to whatever passes.** The list is short on purpose. + +--- + +## What is not measured by gcov, and what covers it instead + +### `platform/cortex_r52/` — the port + +CP15 assembly, EL2 vector tables, MPU region programming, GIC and generic timer +setup, and the context switch. **gcov cannot reach any of it from a +workstation**, and instrumenting it on the target would change the code +generation of the thing being measured — which for a file whose subject is a +partition switch's cost is not a small objection. + +**The evidence is the model and the board.** Specifically: + +| what | covered by | +|---|---| +| stage-2 region programming, and that limits are EXACT | the isolation matrix, and the `widened` build that proves the adjacent-granule check can fail | +| the trap vector, all four exception classes | the probe image: a violation (`EC 0x24`), a hypercall (`EC 0x12`), an unexpected class (`EC 0x03`, via `HCR.TID1`), and ZoneX faulting on itself (`EC 0x25`, in its own build) | +| the window boundary and the context switch | the two-partition image and the determinism regression, on both targets | +| the EL2 MPU enable order, and the refusal when regions are short | the `starved` build, which refuses to start and says why | +| the time freeze | the `no_freeze` build, which must fail and does | +| the boundary's own tick | the `no_tick` build, which must refuse rather than hang | +| halt-on-fault, the shipping policy | the `halt` build, run on both targets | + +**Every one of those is a build that must FAIL, or a build that must pass with +a named check.** That is the substitute for structural coverage here, and it is +a weaker guarantee in one direction and a stronger one in another: it says +nothing about unreached lines, and it says a great deal about whether the +reached ones do what they claim. + +### `examples/` — the demonstration images + +Not measured and not a target. They are the test harness, not the product. + +### Structural coverage of target code is a later, funded concern + +MC/DC and structural coverage of the port belong to the certification phase and +need their own tooling — an instrumenting target build, or a trace-based +measurement off the ETM. **They are not attempted here and no number in this +repository should be read as standing in for them.** + +--- + +## Configurations, and the trap of merging them + +`gcovr` merges across the host suite's translation units, and the floor is +enforced on that merged report. But the interesting question is not units, it +is **configurations**: code that one build compiles and another does not. + +ZoneX has three such axes, and **every arm of all three is compiled somewhere +in the matrix**: + +| condition | one arm | the other arm | +|---|---|---| +| `ZX_RESET_IN_THUMB` | the S32Z280 build, where the core resets in T32 and needs a trampoline | the FVP build, which resets in A32 | +| `ZX_CONSOLE_BOARD` | the S32Z280 build, a polled LINFlexD | the FVP build, semihosting | +| `ZX_GUEST_B_BLOB` | every two-partition image | every one-partition image | + +**This is the trap the Cortex-R52 port work recorded and it is worth naming: +conditionally compiled code that a default build never assembles reads as +covered because it is never counted.** The answer here is not a merged gcov +report — the first two arms are assembly on a target — it is that the union of +the two board configurations assembles both arms of all three, and each arm is +*executed*: the T32 trampoline on every silicon run, the A32 entry on every +model run, and both console backends by whichever board is running. + +A fourth axis existed and was removed for exactly this reason: the test-only +fault-continue mode was once an `#ifdef` in the hypervisor, which meant the +shipping path and the test path were different code. It is now a value the +image supplies at build time and **there is no conditional in the trap vector +at all** — every build takes the same path and the callee decides. See +`docs/decisions.md` D26. + +--- + +## Reproducing it + +```bash +./test/host/run.sh coverage +``` + +builds instrumented, runs the suite, writes an HTML and XML report, and then +enforces the floor as a separate `gcovr` invocation so that the number being +enforced is visible next to the failure. + +**It deletes the counters first, and that is not tidiness.** `gcov` +*accumulates*: a `.gcda` left from an earlier run is added to by the next one, +never replaced, so a second run over the same build tree reports the union of +both and coverage can only ever appear to go up. Measured, not feared: +disabling one rule's tests and re-running still reported 100%, and the floor +passed. CI never sees it because every run starts from a fresh checkout — +which is exactly why it survives, since the machine that gets the wrong answer +is the contributor's, checking whether their own change dropped coverage. diff --git a/docs/decisions.md b/docs/decisions.md index 49b4382..f4ee1b1 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -239,6 +239,56 @@ The one case that would reopen the mechanism is two partitions needing different *attributes* on one address, which no manifest the validator accepts can ask for today. +### ⚠ The per-component figures were understated, and by how much + +*Amended 3 September 2026, during the hardening pass.* + +**Two images measured the same `HPRENR` mask write and disagreed by a factor of +two — 235 cycles against 117 on the S32Z280, 13 against 9 on the model.** The +disagreement was carried as unexplained through two rounds of work. It was +arithmetic, in the hypervisor's own measurement helper and not in the thing +being measured. + +Each row brackets a loop of *n* operations between two counter reads, so the +span holds *n* operations and **one** extra read — the closing one. The helper +divided the span by *n* and then subtracted a whole counter read, which +subtracts one read from every **iteration** where only one is paid per **loop**. +It over-subtracted by very nearly one counter read from every row. The probe +image, which subtracts from the span before dividing, was right all along. + +The two differences were each almost exactly the helper's own +`zx_cost_counter_read`: 118 cycles on the board and 4 on the model. Same bench, +same session, after the fix: **217 cycles** from the probe's synthetic loop and +**219** from the helper. The question is closed. + +**The corrected component figures**, S32Z280-594EVB, cycles at 48.19 MHz: + +| | cycles | +|---|---| +| counter read, subtracted | 110 | +| save, everything | 2,109 — of which the EL1 MPU 1,838 | +| restore, everything | 2,251 — of which the EL1 MPU 1,916 | +| stage-2 region set, `HPRENR` | 219 | +| the time freeze, `CNTVOFF` | 531 | +| arming the next boundary | 198 | + +Against an end-to-end switch of 6,046 / 6,069 / 6,262. The components now sum +to 5,308 and the 761-cycle remainder is the GIC acknowledge and end-of-interrupt, +the frame bookkeeping and the vector entry — real work the rows do not bracket. +Before the fix they summed to about 4,758 and the gap was 1,311. + +**Nothing that rests on this decision changes.** The end-to-end figure is taken +around the real boundary with two reads and no loop and was never affected. The +ratio the mechanism was chosen on — a block rewrite growing with the incoming +partition's region count, where a mask does not — is a property of the design +and not of a measurement. What changes is that **every per-component figure +published before this was too small by about one counter read**, and anyone +quoting them should use the table above. + +*The general lesson, which is why this is written down at length: subtract an +overhead from the SPAN, before dividing, or state in the code which one you +meant. Both images were readable and only one was right.* + --- ## D5 — How an address is spelled in the manifest · **settled** @@ -914,6 +964,47 @@ The same list, written from the kernel's side, is at the `#ifndef` in the S32Z280 `entry.S` — next to the code it replaces, which is where somebody adding a third board will be looking. +### ⚠ The FPU denial cannot be demonstrated on the model + +*Added 3 September 2026, by measurement.* + +The argument for **denying** the FPU to a time-partitioned system rather than +saving it is that a guest touching floating point then takes an exception at EL2 +**with a syndrome naming the cause**, instead of silently reading its +neighbour's registers. That half of the argument had never been run, and an +attempt to run it during the hardening pass found that it cannot be run on the +Armv8-R AEM FVP at all. + +**Measured on the model:** a `VMOV s0, r0` executed at EL1 is UNDEFINED — with +`HCPTR.TCP10`/`TCP11` set, with them clear, and with `CPACR.cp10`/`cp11` +enabled first. It never reaches EL2. The payload's own undefined-instruction +vector took it and reported the exception, so what the run saw was +`EC 0x12` from the guest's own handler rather than `EC 0x07` from `HCPTR`. +**The model as ZoneX configures it implements no FPU.** + +Two consequences, and the second is the one that matters for the decision: + +* The claim is **silicon-only**. Any test of it belongs on the board, and the + suite's unexpected-exception case is provoked another way — see + `docs/armv8r-el2-reference.md`. +* On a part with an FPU the trap is still the right mechanism, but the + behaviour a guest sees **may depend on its own `CPACR`**: the architecture + checks `CPACR` before `HCPTR`, so a guest that has not enabled coprocessor + access for itself would get an undefined-instruction exception at EL1, which + its own kernel handles and the hypervisor never sees. That is safe — the + register bank is not shared either way — but it is not diagnosable at EL2. + ⚠ **Read from the architecture manual and not verified**, because the + attempt to verify it is what found the model has no FPU. If it holds, the + sentence "a guest touching floating point takes an exception at EL2 naming + the cause" is true of a guest whose kernel uses floating point and not of one + that does not — and that is worth knowing before the claim is made in front + of anybody. + +The decision does not change. Denying is still right and saving is still the +later-phase option. What changes is that the argument's evidence is a bench +result that has not been taken yet, and saying so is better than leaving a +claim that reads as tested. + --- ## D24 — Who owns the GIC, and how a partition's interrupt is delivered · **settled, and measured on both targets** @@ -1586,7 +1677,8 @@ silicon is cheap and ten minutes of a functional model on every pull request is not. Jitter in counter counts, S32Z280-594EVB, ten frames per phase against a -hundred: +hundred. **These are the figures BEFORE the console was taken off the boundary +path** — see the amendment below for what the console row measures now: | the untrusted partition is… | 60 frames | 600 frames | |---|---|---| @@ -1599,7 +1691,9 @@ hundred: The quiet phases roughly double and stay in the tens of counts. The fault phase grows fourfold — 117,000 violations in the short run, over a million in the long one, so the tail had more chances to appear. The console phase is stable to -0.7%, which is what a bound set by a UART's character rate should look like. +0.7% — which looked like a bound set by a UART's character rate and was in fact +**the hypervisor closing a partial line on the switch path**. See the amendment +below. Every one of them stays two to three orders of magnitude inside the bound asserted against it. So the short run is sound for the claim it makes, and @@ -1650,3 +1744,155 @@ An amendment to the linker script's own placement, or `-ffunction-sections` with an ordering file, would make the figure repeatable across code changes. That is worth doing before anybody characterises this across a population, and it is not worth doing to make a demonstrator's number look tidier. + +### ⚠ The console phase was measuring a defect, and half of it is now fixed + +*Amended 3 September 2026, during the hardening pass.* + +The console row above is not a property of partitioning. It was **the +hypervisor's own console driver perturbing its own switch**, and the fix and +the residual are both worth recording because the residual is what the temporal +claim now has to be written around. + +**What it was.** A guest's console is one hypercall per character through a +polled UART. A window that ended with a partial line outstanding had that line +closed by the **boundary handler** — `CR` and `LF`, at EL2, with FIQ masked, on +the switch path, back to back, which is the exact case the board driver's +write-one-to-clear guard exists for. The trip count was decided by what the +outgoing guest had been printing. + +**What was done.** A release now records that a line is owed and writes +nothing; the newline is emitted by whoever speaks next, inside a window that +party owns. **The character stream is identical** — only the moment of the +write moves — which is what made it safe, and the host suite asserts it against +the text rather than against a flag. See D29. + +**What it measures now**, S32Z280-594EVB, jitter in counter counts: + +| | before | after | +|---|---|---| +| 60 frames | 24,420, every run | 999 · 1,159 · 1,196 | +| 600 frames | 24,584 | 1,169 · 1,169 · 1,204 · 1,236 — **and** 30,615 · 30,639 · 30,689 | + +A sixty-frame run no longer sees it. A six-hundred-frame run does, in roughly +**two runs in five**: one long period and one short correction of about 15,300 +counts each. It is **not** the boundary handler, which now writes nothing. It +is somewhere on the hypercall path, where every character is still written at +EL2 with FIQ masked, and **the mechanism is not confirmed** — the board +driver's guard spin is the leading suspect and naming it before measuring it +would be inventing a cause to go with a number. + +**⚠ Four consecutive clean long runs said it had gone; the fifth said it had +not.** That is recorded because it nearly went into this document the other way +round. Nothing here should be concluded from fewer than six or seven long runs. + +**The bound therefore stays at half a window for this phase**, and it is +deliberately not tightened to what a sixty-frame run measures: a bound that +passes in CTest and fails on the bench two runs in five is worse than no bound. +Every other phase is held to one eighth of a window. + +**What this costs the claim.** A partition's window period is unaffected by a +neighbour that computes, that masks its own interrupts, or that violates its +boundary ten thousand times a run — those move it by tens of counts. It is +**not** unaffected by a neighbour that **prints**. That sentence needs the +console off the hypercall path as well, and the work is scoped separately. + +### ⚠ And one thing that is not explained + +The negative build whose stage-2 limit for one partition is deliberately **one +granule too generous** measures **19,094 · 19,188 · 19,284** counts in the +console phase on three consecutive sixty-frame runs, where the correct build +measures about twelve hundred and never more. The two builds differ by one +number in a region descriptor and print the same characters. + +A wrong region limit perturbing the **timing** as well as the memory would be a +useful thing to be true. It is recorded and **not claimed**, because a +mechanism nobody has found is not a result. + +--- + +## D29 — The guest console closes a line LAZILY · **settled 3 Sep 2026, by measurement** + +**A window boundary hands the console over without writing anything. The +newline that closes the outgoing partition's partial line is emitted by +whoever speaks next — the incoming partition's first character, or the +hypervisor — inside a window that party owns.** + +The eager alternative is what was there first and it is the obvious one: close +the line when the console changes hands, so that no two partitions can ever +share a physical line. It is correct and it is in the wrong place. Closing a +line is `CR` and `LF` into a polled UART, and at a window boundary that is at +EL2, with FIQ masked, on the switch path, with a trip count decided by what a +guest had been printing. Measured cost to the critical partition's window +period: **24,420 counts of an 8 MHz counter**, against 69 counts for that same +neighbour violating its boundary a hundred thousand times. See D28. + +**The character stream is unchanged, and that is the whole safety argument.** +The newline still appears in exactly the same place relative to every other +character, because the only things that can follow it are a tag or a hypervisor +message and both go through code that settles the debt first. What moves is +*when* it is written, not *where* it appears. The host suite asserts the +resulting text, and asserts separately that a release emits nothing at all. + +**Two functions, not one, and the distinction is which side of the switch the +two characters are charged to.** A *release* is what a window boundary calls +and it writes nothing. A *detach* is what everything else calls — a partition +that left its window early, a frame that has ended — and it is eager, because +in both cases the hypervisor is about to print anyway and the characters are on +nobody's switch. + +The rejected alternative was to buffer each partition's output and flush it off +the boundary path entirely. That is the better answer and it is a larger one: +it needs a bound on the buffer, an answer for what happens when it fills that +is not "the guest blocks", and it changes the line-tagging contract rather than +preserving it. The lazy close was chosen because it removes the boundary cost +**without moving a single character**, which is a change that can be reviewed +by reading one file. The buffer remains the option for the phase that needs the +console off the hypercall path as well. + +--- + +## D30 — The vectors that cannot resume get a known stack and refuse to recurse · **settled 3 Sep 2026** + +**The EL2 vectors that report and stop — a fault taken from Hyp mode, and every +vector nothing should reach — reset `SP_hyp` to the top of the EL2 stack before +calling C, and the C side counts its own depth and stops reporting on a second +entry.** + +Both are defence on a path that only runs after ZoneX has already failed once, +and both close a way for a diagnosable bug to become a hang. + +**The stack.** The reporting path is C and C needs a stack. Until this it ran +on whatever `SP_hyp` held when the exception was taken — which, for the one +class of fault these vectors exist to report, may be the *reason* for the +fault. A hypervisor whose stack has run away then faults again inside its own +fault report and the run ends with nothing said. Resetting is safe precisely +because nothing below is coming back: there is nothing to resume, so the stack +the exception arrived on is not needed. This is the vector-bracket rule from +the other side — a handler may not assume the state it was entered with is +usable, and here the caller is ZoneX itself. + +**The depth.** Resetting the stack does not make the path re-entrant: a second +fault taken inside the report would arrive at the same vector, reset the same +stack and call the same function, for ever. So the depth is counted in `.bss` +and the **second entry does not print at all**. It stores a verdict and parks, +which is the one action left that cannot itself fault — the console is the most +likely thing to have failed and is certainly the most likely thing to fail +again. It carries its own exit code, so that a silicon harness reading the +verdict word with no console at all can tell *"ZoneX faulted at EL2"* from +*"ZoneX faulted at EL2 while reporting a fault at EL2"*. Those are different +bugs and they point at different code. + +**What this does not cover, said plainly.** A fault taken inside the vector's +own capture macro — before any C runs — would recurse with nothing to count it. +That is closed by architecture rather than by code: ZoneX spends no EL2 MPU +region on its own memory and is protected by not being covered by any enabled +region (D2), so the fault record and the depth counter cannot be denied to it +by any region set a manifest can produce. + +**⚠ Neither guard has been provoked.** They are defence, and by the standard +this suite holds itself to — *a branch nothing has ever taken is not evidence +that it works* — that is a gap and not a completed item. Provoking one needs an +image whose second fault lands in the C report rather than in the capture, which +on the board means denying the console's own MMIO after the first fault. It is +recorded here as not done rather than left to look finished. diff --git a/docs/errata.md b/docs/errata.md new file mode 100644 index 0000000..3a50e51 --- /dev/null +++ b/docs/errata.md @@ -0,0 +1,187 @@ + + +# Silicon errata, and what ZoneX does about them + +*Eclipse ThreadX ZoneX. Swept 3 September 2026.* + +**A sweep with no findings is a result, and it is only worth anything if what +was searched is written down.** This document records which errata notices were +read, at which revisions, what was looked for, what was found, and — for the +entries that do not apply — the reason they do not, so that a reader can check +the reasoning rather than take the conclusion. + +Two notices were swept: + +* **Arm Cortex-R52 MP040 Software Developer Errata Notice**, issue 19, + document SDEN-857344, issued 11 May 2026. +* **NXP S32Z2 Mask Set Errata for mask 0P91J**, revision 3, 1 July 2025. + +--- + +## The part + +`MIDR_EL1` reads **`0x411FD133`** on the S32Z280-594EVB's RTU0 core 0, which is +**Cortex-R52 r1p3**. Every applicability judgement below is against r1p3; the +value is printed by the probe image's identity block on every run, so a board +with a different revision reports it rather than inheriting this conclusion. + +The Armv8-R AEM FVP is a functional model and has no errata. It is also not +evidence about any of this: the model reports a region count no Cortex-R52 can +have, and — measured during this sweep — it implements no FPU at all. + +--- + +## What was searched for + +The EL2 registers and the units ZoneX programs, which is a smaller set than the +part as a whole: + +EL2 system control and the EL2 MPU (`HSCTLR`, `HPRBAR`, `HPRLAR`, `HPRENR`, +`HPRSELR`, `HMPUIR`, `HMAIR`), the exception syndrome and fault address +registers (`HSR`, `HDFAR`, `HIFAR`, `HPFAR`), `HCR` and its trap bits, the +generic timer, the GIC and its redistributor, cache maintenance, and the +LINFlexD instance the board console uses. + +--- + +## Cortex-R52: seven errata are open at r1p3 + +Everything else in issue 19 is fixed at or before r1p3. Of the seven, **four +are debug and trace only** and cannot affect a running image: 1328481 (the +debugger's view of core count across Split/Lock), 2072382 and 2077568 (ATB +flush response), and 2289294 (DTR flags on debugger access). ZoneX uses a +debug probe to *load and judge* runs on silicon, and none of these four affects +memory, timing or exception behaviour. + +The remaining three are in scope and are examined below. **None applies to +ZoneX as it is built**, and two of them place a standing constraint on code +that has not been written yet. + +### 2130897 — VLDM/VSTM may corrupt data · Category A · **does not apply** + +Present r0p0–r1p3, fixed in r1p4, so **open on this part**, and the only +Category A entry in the notice. + +It requires a program that **changes endianness with `SETEND`**, together with +a `VLDM`/`VSTM` on an 8-byte-unaligned address in the correct path of a +mispredicted branch. The gating condition is the endianness change. + +**ZoneX contains no `SETEND`, and neither does the guest support or ThreadX.** +The whole suite is little-endian from reset and has no reason to be otherwise. +The workaround — an `ISB` before `SETEND` — is therefore not needed, and the +condition under which it would be is one an image would have to go out of its +way to create. + +*Standing constraint:* an `ISB` before any `SETEND` ZoneX ever executes. + +### 2918152 — LDM corruption when `HSCTLR.FI` is set · Category B · **does not apply** + +Present r0p0–r1p4, open, so **open on this part**. NXP mirror the entry as +ERR052126. + +`HSCTLR` is an EL2 register that ZoneX programs, so this one was read closely. +It requires **both**: + +* out-of-order floating-point divides enabled (`CPUACTLR.OOODIVDIS` clear, + which is the reset value), **and** +* **Fast Interrupts enabled — `HSCTLR.FI` set, which is not the reset value and + has to be programmed deliberately**; + +and then a specific sequence of a `VDIV`/`VSQRT`, a load-multiple and an +asynchronous exception. + +**ZoneX never sets `HSCTLR.FI`.** The reset path clears `TE`, `C` and `I` and +touches nothing else in that register; `FI` is not even given a name in the +port header. The bit's reset value is zero, so the erratum's second condition +is unmet on every ZoneX image, and it would take a deliberate write to meet it. + +*Standing constraint, and it is the useful output of this entry:* **`HSCTLR.FI` +must stay clear.** Fast Interrupts trade a bounded interrupt latency for this +erratum, and a hypervisor whose whole claim is about a timer interrupt arriving +on time is exactly the kind of code that would be tempted. The workaround if it +is ever wanted is `CPUACTLR.OOODIVDIS = 1`, at a cost Arm measures as under +0.1% on their benchmarks — but the first question is whether `FI` buys anything +here at all, and ZoneX's boundary FIQ is not currently latency-bound. + +### 2743885 — `GICR_TYPER` does not reflect affinity inputs · Category B · **does not apply** + +Present r0p0–r1p4, open, so **open on this part**. NXP mirror the entry as +ERR051613. + +**ZoneX never reads `GICR_TYPER`.** It is a single-core hypervisor on one +logical core with one Redistributor, and the redistributor registers it does +touch are `GICR_IGROUPR0`, `GICR_IPRIORITYR`, `GICR_ICFGR1` and +`GICR_ISENABLER0` — none of which carries affinity. The workaround, comparing +only `Aff0` to identify a Redistributor, would apply to a multicore ZoneX and +is recorded here for the phase that becomes one. + +--- + +## S32Z2 mask 0P91J: nothing touches EL2 + +**No erratum in the notice mentions EL2, the hypervisor, stage-2 translation, +`HPRENR` or `HCR` at all.** That is a search result rather than an inference: +the whole notice was searched for each of those terms and for the generic +timer, and it returns nothing. + +The entries that touch the RTU's Cortex-R52 are the two NXP mirrors of Arm +errata dealt with above (ERR052126, ERR051613) plus ERR051614, which is the +debug DTR entry. The great majority of the notice concerns GTM, NETC, PSI5, +DDR and the Cortex-M33 cores, none of which a ZoneX image programs. The "Core:" +entries that mention `VLLDM` and `AIRCR` are Armv8-M and describe the CM33 +cores, not the RTU. + +Two entries were read in full because they are adjacent to something ZoneX +does, and both are recorded for what they say about *later* phases rather than +this one. + +### ERR051441 — XRDC MDACs cannot control privilege for RTU accesses to LLPP + +The Cortex-R52 drives the privilege attribute on its own accesses to +peripherals on the Low-Latency Peripheral Port, and the XRDC's MDAC8–MDAC11 are +ineffective at changing it. NXP's workaround is to treat the R52 cores as the +sole masters for privilege when accessing peripherals. + +**It does not affect the Phase-0 isolation claim**, because Phase 0 maps **no +device into any partition**: there is one console and the guest reaches it by +asking the hypervisor (D8), so no partition ever issues a peripheral access at +all. It is recorded because the claim it *would* affect is the one a later +phase makes when a partition is granted a device — and at that point the answer +is that partition-level device isolation on this part cannot rest on XRDC MDACs +for privilege, and needs the stage-2 MPU and the region attributes to carry it. + +### ERR006425 — LINFlexD FIFO counter decrements on debugger reads + +The receive FIFO counter is decremented by debugger reads of `BDRM` when +`UARTCR.RFBM` is set. **ZoneX's console transmits only**, never sets `RFBM`, +and never reads `BDRM`. It does not apply, and it is recorded because the +console is the one peripheral ZoneX drives directly and a reader is entitled to +know it was checked. + +--- + +## What the sweep did not cover, said plainly + +* **Errata in units ZoneX does not program.** GTM, NETC, PSI5, DDR, LFAST, + the CM33 cores and the Ethernet subsystem were not read entry by entry. A + system integrating ZoneX alongside those must sweep them on its own account; + this document covers the hypervisor. +* **Anything below the errata notices.** Documentation ambiguities that are not + errata — and there are several that mattered — are in + `docs/armv8r-el2-reference.md`, which is the verified register sheet and + records what was measured where the manuals disagree. +* **The Armv8-R AEM FVP.** A functional model has no errata and is not evidence + about a part's. diff --git a/docs/hypercall-abi.md b/docs/hypercall-abi.md new file mode 100644 index 0000000..3a087fc --- /dev/null +++ b/docs/hypercall-abi.md @@ -0,0 +1,224 @@ + + +# The ZoneX hypercall ABI + +*Eclipse ThreadX ZoneX. Last updated 3 September 2026.* + +Phase 0 implements three hypercalls. This document is not about those three — +it is about the **shape** they sit in, because that shape is what every later +phase inherits and it is far cheaper to settle now than after anything depends +on it. + +Read `docs/decisions.md` D20 first: it records *why* a hypercall is selected by +the `HVC` immediate rather than by a function id in a register, and that +decision is assumed throughout here. + +--- + +## 1. What a hypercall is + +A guest asks the hypervisor for something by executing `HVC #imm16`. The +immediate selects the call. Arguments and results are passed in the AArch32 +core registers under the ordinary AAPCS rules, because a guest's C compiler has +already put them there. + +``` + HVC #imm16 imm16 selects the call + r0-r3 arguments, in order + r0 result + r0-r3 may be clobbered + r4-r12, sp, lr preserved +``` + +**The immediate is a compile-time constant.** That is a property of the +instruction and not a choice: `HVC` encodes its immediate, so a call selected at +run time needs a dispatch through a register, which is the thing D20 declined. +A guest that wants to select a call at run time writes a switch. + +`HSR.ISS[15:0]` carries the immediate to the hypervisor, which is why the trap +vector can decode it without touching a guest register. + +--- + +## 2. The id space + +Sixteen bits, divided by **what a call does to control flow**, because that is +the division the trap vector has to make first and the one a guest most needs to +be able to reason about. + +| range | meaning | Phase 0 | +|---|---|---| +| `0x0000`–`0x00FF` | **Discovery and control.** Calls that describe the hypervisor, or that end the guest's excursion. | `0x0000`, `0x0001` | +| `0x0100`–`0x01FF` | **Console.** Output the hypervisor performs on a guest's behalf. | `0x0002` — see §5 | +| `0x0200`–`0x02FF` | **Reserved: inter-partition communication.** | none | +| `0x0300`–`0x03FF` | **Reserved: partition lifecycle** — restart, stop, status. | none | +| `0x0400`–`0x04FF` | **Reserved: schedule** — yield-with-hint, deadline query. | none | +| `0x0500`–`0x7FFF` | **Reserved to ZoneX.** Unassigned. | none | +| `0x8000`–`0xFFFF` | **Available to an integrator.** ZoneX will never assign in this range. | none | + +**The ranges are reserved, not improvised later.** The point of writing them +down before they exist is that the first phase to need inter-partition +communication does not get to choose where it goes, and a guest built against a +later ZoneX cannot collide with an integrator's own extension. + +**The integrator range is half the space and that is deliberate.** A hypervisor +that reserves everything to itself forces every integrator to fork the +immediate list, and forked lists are how two systems end up meaning different +things by the same number. + +--- + +## 3. Discovery + +**A guest must be able to ask what it is running under rather than assume.** +The alternative is a guest built for one hypervisor version failing on another +in whatever way its first unimplemented call happens to fail — which, on a +partitioned system, is a partition that stops for a reason nobody can read. + +``` + HVC #0x0000 "are you there, and what are you?" + + in nothing + out r0 ZX_ABI_MAGIC, 0x5A58AB10, if a ZoneX is present + r1 ABI version, major in [31:16], minor in [15:0] + r2 implemented feature bits (see below) + r3 reserved, zero +``` + +**Feature bits, rather than a version comparison.** A guest that tests +`version >= 2` breaks the moment a call is removed or an id range is +reassigned; a guest that tests a feature bit keeps working. The version is +there to be *reported* — in a log, in a fault report, by a person diagnosing a +mismatch — and not to be branched on. + +| bit | feature | +|---|---| +| 0 | console output (§5) | +| 1 | inter-partition communication | +| 2 | partition lifecycle | +| 3 | schedule queries | +| 4–31 | reserved, read as zero | + +**Phase 0 reports version 1.0 and feature bit 0 only.** + +--- + +## 4. Control + +``` + HVC #0x0001 yield: the guest is done with its window + + in nothing + out does not return to the instruction after the HVC +``` + +A yield hands the machine back to EL2. It is not a call that returns; the +hypervisor decides what happens next, and under a time-partitioned schedule +what happens next is that the partition is descheduled until its next window. + +`HVC #0x0000` in Phase 0 is currently the **empty vector** — it is counted and +returns transparently, and it is what proves EL2 can be entered and left again +without disturbing a guest's state. §3 is the shape it takes when discovery is +implemented; the two are compatible because the empty vector already returns to +the guest with its registers intact. + +--- + +## 5. Console + +``` + HVC #0x0002 print one character + + in r0 the character, in the low eight bits + out r0 preserved, and the call returns transparently +``` + +One character per call, and the cost of that is real and is stated rather than +hidden: on silicon it is a trap and a polled UART write per character, at EL2 +with FIQ masked. `docs/decisions.md` D8 records why the hypervisor owns the +console at all, and the temporal cost of this call is recorded in +`docs/wcet-inputs.md`. + +**The immediate is `0x0002` and not `0x0100`.** It predates this document, it +is baked into every guest image built so far, and moving it would break the one +compatibility rule this document exists to establish for the sake of tidiness. +The console range `0x0100`–`0x01FF` is reserved for the calls that follow it — +a string, a buffer, a flush — and `0x0002` stays where it is as the record of +what was there first. + +--- + +## 6. What an unimplemented id does + +**It must never hang, and it must be reported by name.** + +The rule for every id ZoneX does not implement, in every range including the +integrator's, is that the guest is stopped and the hypervisor says why. That is +the same treatment as any other exception class routed to EL2 that ZoneX does +not handle: the fault record is captured, the class is named, and the run +reaches a verdict rather than stalling. `core/src/zx_fault.c` names the class +and `zx_fault_report` prints it. + +**What Phase 0 does today, precisely, because it is not yet what this section +specifies.** An `HVC` with an immediate that is none of the three implemented +ones falls through the trap vector's comparison chain, has its fault record +captured, and returns `ZX_RUN_TRAPPED` to the hypervisor — so the guest's +excursion **ends** and the hypervisor reports an unexpected trap naming +`EC 0x12` and the immediate. It does not hang, and it is not silent. + +What it does *not* do is return a defined error to the guest and let it carry +on. That is the behaviour this section specifies, and it is deliberately left to +the first phase that adds a fourth hypercall: + +``` + r0 = ZX_ABI_NOT_IMPLEMENTED, 0xFFFFFFFF, and the call returns +``` + +**Why it is specified now and implemented later.** A guest that can probe for a +feature needs a call it can make *and survive making* — that is what discovery +is for — so the error return and discovery arrive together or neither is worth +having. Phase 0 has three calls, no guest that probes, and a rule that a +partition doing something unexpected is stopped and reported, which is the +right default for a system whose claim is isolation rather than compatibility. +Reserving the value and the behaviour costs nothing; implementing half of a +compatibility mechanism would cost the ability to tell a Phase-0 hypervisor +from a broken later one. + +--- + +## 7. What Phase 0 implements, and nothing more + +**Console output and the empty vector. That is the whole list.** + +This is a rule and not a description. Inter-partition communication, partition +restart and schedule queries are reserved id ranges (§2) and are **not** to be +added ahead of the phase that owns them, because each of them is a design +question with a safety argument attached — what a partition may learn about its +neighbour, who may restart whom, whether a guest can influence its own schedule +— and an id assigned early is an answer given before the question was asked. + +--- + +## 8. The two spellings, and why they cannot diverge + +A guest is a separate program built by a separate toolchain invocation and +cannot include the hypervisor's headers. The immediates are therefore stated +twice: once for the hypervisor and once in the guest-facing contract header. + +The translation unit that builds an image sees both and `_Static_assert`s that +they agree. A renumbered immediate fails the **build** rather than making a +guest's console output vanish on the model. Any id added under this document +inherits that requirement: **two spellings, one assertion, or one spelling.** diff --git a/docs/wcet-inputs.md b/docs/wcet-inputs.md new file mode 100644 index 0000000..831305a --- /dev/null +++ b/docs/wcet-inputs.md @@ -0,0 +1,191 @@ + + +# Data dependence on the switch and trap paths + +*Eclipse ThreadX ZoneX. Last read through 3 September 2026.* + +**Input to the worst-case-execution-time work, written by the people who wrote +the code.** Every path a partition switch or a trap can take was read with one +question: *is anything here data-dependent?* — a loop whose trip count something +outside the hypervisor controls, an early exit, conditional cache maintenance, a +print in a fault path. + +This is not a WCET analysis. It is the list of places where one would have to +start, with what is known about each, so that the funded work begins from +measurements rather than from a fresh reading of the source. + +Figures are from the S32Z280-594EVB: 8 MHz system counter, 48.19 MHz core +clock, one window 80,000 counts, one major frame 800,000. The Armv8-R AEM FVP +is a functional model and its numbers describe nothing. + +--- + +## 1. Constant, and the dominant cost + +**The EL1 MPU save and restore.** `zx_context_save_mpu` and +`zx_context_restore_mpu` walk every EL1 region, and the trip count is +`zx_el1_regions` — read **once at boot** from `MPUIR` and clamped to what the +context block can hold. It is a property of the part, not of a guest, not of a +manifest, and not of anything that can change during a run. + +Measured, per switch, with the counter-read overhead subtracted: + +``` + EL1 MPU regions walked 20 + save, everything 2109 cycles of which the EL1 MPU 1838 + restore, everything 2251 cycles of which the EL1 MPU 1916 + stage-2 region set, HPRENR 219 cycles + the time freeze, CNTVOFF 531 cycles + arming the next boundary 198 cycles +``` + +against an end-to-end switch of 6,046 / 6,069 / 6,262 cycles (min / mean / max). +**The EL1 MPU is roughly 3,750 of those 6,069 and it is a fixed cost.** That is +the answer to the question a safety audience asks, and it is the good case: the +largest term in a partition switch has a trip count nothing outside the +hypervisor can influence. + +*The 761-cycle remainder between the component sum and the end-to-end figure is +the GIC acknowledge and end-of-interrupt, the frame bookkeeping and the vector +entry — real work the component rows do not bracket.* + +**Also constant:** the fifteen-word guest register capture (one `STM` plus two +`MRS`), the stage-2 region-set switch (one `HPRENR` write and a barrier pair — +and 219 cycles for one register write says the cost is in the `DSB`/`ISB` that +must retire before the next fetch can depend on the new permissions, not in the +write), the generic-timer comparator arm, and `zx_fault_log_add`, which is +straight-line and bounded and appends without searching. + +--- + +## 2. Data-dependent, and known + +### 2.1 The console, on the hypercall path · **the open item** + +A guest's console is one hypercall per character, written at EL2 **with FIQ +masked**, through a polled UART. The boundary FIQ that ends a partition's +window is therefore deferred for as long as the hypervisor is inside a console +hypercall — and how often that happens is decided by how much the *guest* +chooses to print. + +Two costs, and only one of them is gone: + +* **The line closed at the window boundary — REMOVED.** A window ending + mid-sentence used to have that line closed by the boundary handler, `CR` and + `LF` into the polled UART, on the switch path, with a trip count set by what + the outgoing guest had been printing. It moved the critical partition's + window period by **24,420 counts** — three per cent of a major frame — on + every run. The newline is now deferred to whoever speaks next, inside a + window that party owns. +* **The character itself — STILL THERE.** One character is ten bits at 115,200 + baud, about 86.8 µs, about **694 counts**. Over sixty frames that is all that + remains and the phase measures 999–1,196 counts. Over six hundred frames, + in roughly **two runs in five**, an excursion of about **15,300 counts** + appears — one long period and one short correction. It is not the boundary + handler, which now writes nothing. It is somewhere on the hypercall path and + **the mechanism is not confirmed**; the leading suspect is the board driver's + write-one-to-clear guard (§2.2). + +**Consequence:** a partition's window period is unaffected by a neighbour that +computes, that masks its own interrupts, or that violates its boundary ten +thousand times a run — those move it by tens of counts. It is **not** unaffected +by a neighbour that prints. + +### 2.2 The console driver's write-one-to-clear guard + +`zx_board_console_putc` clears `UARTSR.DTF` and then spins waiting for the clear +to take effect, bounded by an **iteration count of 100,000** rather than by +time. The spin exists for a measured reason — without it the next byte's poll +observes the previous byte's flag and its write is silently dropped, which cost +the first character of every line — but **an iteration count is not a bound a +WCET argument can use**, and this spin runs at EL2 with FIQ masked. + +Nobody has measured how long it can actually take. That measurement, and a +bound expressed in counter counts, is the smallest useful thing to do here. + +### 2.3 Burning a stopped partition's window + +`zx_frame_next` advances the schedule past partitions that have stopped, and +**spends** each window it passes over rather than giving that time to the next +partition. The trip count is therefore the number of consecutive stopped +partitions — which is guest behaviour. + +**It is bounded and it is deliberate.** Each iteration waits out one whole +window on the physical counter, so the loop cannot spin: it costs real time by +construction, and the whole loop is cut short once every partition has stopped. +Giving a dead partition's time to its neighbour would be the throughput answer +and it would make one partition's start time a function of another's behaviour +— which is the coupling temporal partitioning is bought to remove. + +Such boundaries are **counted and not timed**: a boundary that had to wait out a +dead neighbour is a switch plus a wait of up to a whole window, and averaging +the two describes neither. On the cross-partition build it turned a switch of a +few thousand cycles into one of three million. + +*For a WCET argument the relevant statement is: a switch is bounded by the +figures in §1; a **boundary** is bounded by that plus (number of stopped +partitions) × (window length), and the second term is visible in the schedule.* + +### 2.4 `zx_el2_dwell` and `zx_el2_dwell_until` + +Guard-bounded spins on the physical counter, reached only when burning a +stopped partition's window or when a test needs a real interval. Bounded twice +over — by the deadline and by an iteration count — so that a stopped counter +cannot turn a burned window into a hang. + +### 2.5 The 64-bit counter read + +`zx_read_cntpct` reads until two consecutive reads agree about the high half. +`MRRC` from `CNTPCT` is a 64-bit atomic read on this architecture, so **the loop +should never iterate**, and by construction it cannot iterate more than twice: +the high half can only increment once per 2^32 low counts. It is defensive and +it is on the switch path; a WCET argument can treat it as one read plus one +comparison. + +--- + +## 3. Not data-dependent, and worth saying so + +* **No cache maintenance on the switch path.** `zx_cache_sync_after_load` runs + when a guest image is loaded and never during a switch. EL2 runs with + `HSCTLR.C` and `HSCTLR.I` clear. +* **No print on the trap path.** `zx_el2_fault_continue` records and returns; it + prints nothing. The fault *report* prints, and it runs only when a partition + has been stopped or ZoneX has failed — never on a path that resumes. +* **No allocation anywhere.** ZoneX has no heap. +* **No loop in the fault log's write path.** `zx_fault_log_add` appends at a + known index and drops when full, counting the drop. +* **The stack.** The largest single stack frame in the tree is **80 bytes**, in + the manifest validator, measured across every translation unit in both board + configurations. The EL2 stack is 1 KB. A ceiling of 256 bytes per frame is + enforced at build time under GCC. + +--- + +## 4. Where the funded work should start + +1. **Measure the console driver's guard spin (§2.2)** and bound it in counter + counts. It is the smallest item, it is on a path with FIQ masked, and it is + the leading suspect for the one unexplained figure in this document. +2. **Take the console off the hypercall path (§2.1)**, which is the change that + makes the temporal claim unconditional rather than qualified. +3. **Bound interrupt latency**, which this phase does not attempt at all. Guest + interrupts go straight to EL1 and cost what they always did; bounding them + needs the List Registers this core has and this phase does not use. +4. **Structural coverage of the port**, which needs its own tooling — see + `docs/coverage.md`. + +The first two are the difference between "measured on one bench" and "bounded". From 795bd5b0e52638a711dbdef7197e777d9ed3b32c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Thu, 3 Sep 2026 13:59:47 -0400 Subject: [PATCH 27/40] Provoked the guards on the vectors that cannot resume, and fixed a name D30 recorded two guards on the unresumable EL2 vectors -- SP_hyp reset before calling C, and a depth count so that a fault inside the fault report parks instead of recursing -- and recorded that NEITHER HAD BEEN PROVOKED. A branch nothing has ever taken is not evidence that it works, which is the standard that put the tick-refusal build in the suite, so the admission was a gap rather than a completed item. zx_probe_refault.elf closes it. It makes the region covering the board's OWN CONSOLE read-only at EL2 and prints one character: the store takes a data abort from Hyp mode, the vector resets the stack and calls the report at depth one, the report's first console write faults for the same reason, and the depth guard stops the second entry without printing. On the S32Z280 it parks with zx_run_failures = 0x5C and the harness stops cleanly on zx_console_run_parked rather than timing out. Both guards are now paths something has taken. Without the depth guard the sequence has no end and does not even overflow the stack -- each fault resets it -- so it spins silently for ever, which is the failure the entry exists to prevent. THE RUN HAS NO CONSOLE AFTER ITS BANNER, by construction, so it is the first build judged entirely from memory. The model builds it and reports it UNPROVOKABLE rather than passing: semihosting is not reached through a region, so no access permission can refuse a write to it. It is not in CTest, for the reason the EL2-fault build is not. AND IT FOUND A DEFECT IN THE HARNESS RATHER THAN IN THE GUARDS. The gdb script read zx_run_failures as a count of failing checks, so it reported 0x5C as "92 failing check(s) -- the console log is where they are named": wrong about the number, and wrong about there being a log, for the one build that denies itself a console on purpose. The three fault-path exit codes are named there now. It had been misreporting 0x5A the same way for as long as the EL2-fault build has existed. MIDR_EL1 IS AN AArch64 SPELLING and had reached the errata document. The terminology check exists for exactly that and did not catch it, because it selects files with git ls-files and the document was still untracked when the check was run. Corrected to MIDR, and worth knowing about the checkers: they say nothing about a file until it is added. Assisted-by: Claude Code (Opus 5) --- docs/coverage.md | 1 + docs/decisions.md | 34 ++++++-- docs/errata.md | 2 +- examples/common/zx_probe.h | 20 +++++ examples/common/zx_probe_main.c | 90 +++++++++++++++++++++ examples/fvp_baser_aemv8r/CMakeLists.txt | 12 +++ examples/fvp_baser_aemv8r/zx_board.c | 18 +++++ examples/s32z280_evb/CMakeLists.txt | 13 +++ examples/s32z280_evb/tools/run_zx_probe.gdb | 20 ++++- examples/s32z280_evb/zx_board.c | 41 ++++++++++ 10 files changed, 243 insertions(+), 8 deletions(-) diff --git a/docs/coverage.md b/docs/coverage.md index 5d58a32..d62d27d 100644 --- a/docs/coverage.md +++ b/docs/coverage.md @@ -90,6 +90,7 @@ partition switch's cost is not a small objection. | the time freeze | the `no_freeze` build, which must fail and does | | the boundary's own tick | the `no_tick` build, which must refuse rather than hang | | halt-on-fault, the shipping policy | the `halt` build, run on both targets | +| the unresumable vectors' stack reset and depth guard | the `refault` build, which makes the fault report fault on its own console write and is judged from memory because it has none | **Every one of those is a build that must FAIL, or a build that must pass with a named check.** That is the substitute for structural coverage here, and it is diff --git a/docs/decisions.md b/docs/decisions.md index f4ee1b1..10103df 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1890,9 +1890,31 @@ region on its own memory and is protected by not being covered by any enabled region (D2), so the fault record and the depth counter cannot be denied to it by any region set a manifest can produce. -**⚠ Neither guard has been provoked.** They are defence, and by the standard -this suite holds itself to — *a branch nothing has ever taken is not evidence -that it works* — that is a gap and not a completed item. Provoking one needs an -image whose second fault lands in the C report rather than in the capture, which -on the board means denying the console's own MMIO after the first fault. It is -recorded here as not done rather than left to look finished. +**Both are provoked, on silicon, by a build that exists for it.** They were +recorded here as unprovoked first, because *a branch nothing has ever taken is +not evidence that it works* and writing them down as finished would have been +the claim this suite does not make. The build closes that. + +It makes the region covering the board's **own console** read-only to EL2 and +then prints one character. The store to the data register takes a data abort +from Hyp mode; the vector captures, resets `SP_hyp` and calls the report at +depth one; the report's first console write faults for the same reason; the +vector captures again, resets the stack again, and the depth guard stops it — +no printing, verdict stored, parked. + +**The run has no console at all after the banner, so it is judged from +memory**, which is what the third exit code was invented for and this is the +first build to need it: `zx_run_failures = 0x5C`, read over the debug +connection, with the harness stopping cleanly on the park rather than timing +out. Without the depth guard that sequence has no end and does not even +overflow the stack — each fault resets it — so it spins silently for ever, +which is precisely the failure this entry exists to prevent. + +⚠ **And provoking it found a defect in the harness rather than in the guards.** +The gdb script read `zx_run_failures` as a count of failing checks, so it +reported `0x5C` as *"92 failing check(s) — the console log is where they are +named"*: wrong about the number, and wrong about there being a log, for the one +build that denies itself a console on purpose. The three fault-path exit codes +are now named there. That is worth recording because the harness had been +misreporting `0x5A` the same way for as long as the EL2-fault build has +existed, and nobody noticed until a build arrived whose console was gone. diff --git a/docs/errata.md b/docs/errata.md index 3a50e51..7a823ae 100644 --- a/docs/errata.md +++ b/docs/errata.md @@ -33,7 +33,7 @@ Two notices were swept: ## The part -`MIDR_EL1` reads **`0x411FD133`** on the S32Z280-594EVB's RTU0 core 0, which is +`MIDR` reads **`0x411FD133`** on the S32Z280-594EVB's RTU0 core 0, which is **Cortex-R52 r1p3**. Every applicability judgement below is against r1p3; the value is printed by the probe image's identity block on every run, so a board with a different revision reports it rather than inheriting this conclusion. diff --git a/examples/common/zx_probe.h b/examples/common/zx_probe.h index adfeca3..6840958 100644 --- a/examples/common/zx_probe.h +++ b/examples/common/zx_probe.h @@ -116,6 +116,26 @@ void zx_phase_two_partitions(uint32_t board_regions, uint32_t el2_regions); ZX_NODISCARD uint32_t zx_board_mmio_region_count(void); void zx_board_program_mmio_regions(uint32_t first_index); +/* Reprogram the region covering the hypervisor's OWN CONSOLE so that EL2 may + read it and not write it, and return non-zero if that was possible. + + IT EXISTS FOR EXACTLY ONE BUILD, and that build's purpose is to make the + fault report itself fault -- so that the guards on the unresumable vectors + are a path something has taken rather than a path somebody wrote. See + docs/decisions.md D30. + + Returns zero on a target whose console is not reached through an EL2 MPU + region at all, which is the model: its console is semihosting and no + region covers it, so there is nothing to deny and the build says so + rather than reporting a success it did not achieve. + + AP can do this and cannot do the opposite. At EL2 the access permissions + cannot deny EL2 outright, but they can make a region READ-ONLY to it -- + which is enough, because a console write is a store to the data register. + The same mechanism is what zx_phase_provoke_el2_fault uses. */ + +ZX_NODISCARD uint32_t zx_board_deny_console_writes(uint32_t first_index); + /* The SAME geometry, described rather than programmed, so the manifest validator can reject a partition window that overlaps the hypervisor's own console or interrupt controller. Those regions stay enabled while a diff --git a/examples/common/zx_probe_main.c b/examples/common/zx_probe_main.c index 0520d5d..42db534 100644 --- a/examples/common/zx_probe_main.c +++ b/examples/common/zx_probe_main.c @@ -918,6 +918,92 @@ static void zx_phase_provoke_el2_fault(void) #endif +/**************************************************************************/ +/* zx_phase_provoke_refault */ +/* */ +/* MAKES THE FAULT REPORT ITSELF FAULT, so that the two guards on the */ +/* unresumable vectors are a path something has TAKEN rather than a path */ +/* somebody wrote. docs/decisions.md D30 named them and recorded that */ +/* neither had been provoked; this build is the answer to that. */ +/* */ +/* WHAT IT DOES. The region covering the hypervisor's own console is */ +/* made read-only to EL2, and then one character is printed. The store */ +/* to the data register takes a data abort FROM Hyp mode, which is the */ +/* vector that reports and stops: */ +/* */ +/* the vector captures the record, resets SP_hyp, and calls C; */ +/* the C side counts depth 0 -> 1 and starts printing the report; */ +/* the report's FIRST console write faults for the same reason; */ +/* the vector captures again, resets SP_hyp again, and calls C again; */ +/* the C side sees a non-zero depth and PRINTS NOTHING -- it stores */ +/* the verdict and parks. */ +/* */ +/* Without the depth guard that sequence has no end: each fault resets */ +/* the stack and calls the same function, so it does not even overflow -- */ +/* it spins for ever, silently, which is the exact failure mode the */ +/* fault path exists to prevent. */ +/* */ +/* HOW IT IS JUDGED, AND IT CANNOT BE JUDGED FROM THE CONSOLE. After the */ +/* region changes, this board has no console: nothing printed arrives, */ +/* including the report. The verdict is ZX_EXIT_FAULT_WHILE_FAULTING in */ +/* zx_run_failures, read out of memory by the debug harness -- which is */ +/* precisely the case that exit code was invented for, and the first */ +/* build to need it. */ +/* */ +/* It is LAST, it is behind a build option, and it is not in CTest: the */ +/* run's expected outcome is a failure with no console at all, and a */ +/* suite that judges runs by their printed verdict must not be taught to */ +/* accept one that prints nothing. */ +/**************************************************************************/ + +#ifdef ZX_PROBE_PROVOKE_REFAULT +static void zx_phase_provoke_refault(void) +{ + zx_console_puts("\n=========================================================\n" + " PROVOKING A FAULT INSIDE THE FAULT REPORT\n" + "\n" + " The region covering THIS CONSOLE is about to be made\n" + " read-only at EL2. The next character printed then takes\n" + " a data abort from Hyp mode, and the report of THAT fault\n" + " faults again on its own first write.\n" + "\n" + " EVERYTHING BELOW THIS LINE IS SILENCE, and that is the\n" + " expected result. The run is judged from memory: the\n" + " harness must find zx_run_failures = 0x5C, which means\n" + " ZoneX faulted at EL2 WHILE REPORTING a fault at EL2 --\n" + " a different value from 0x5A, which is the first fault\n" + " reported normally, and from 0x00, which is a pass.\n" + "\n" + " If the guard is absent the board spins here for ever and\n" + " the harness times out with no verdict at all.\n" + "=========================================================\n"); + + if (zx_board_deny_console_writes(ZX_REGION_BOARD_FIRST) == 0U) + { + zx_console_puts("\n SKIPPED. This target reaches its console without an\n" + " EL2 region -- it is semihosting -- so there is no\n" + " access permission that can refuse a write to it and\n" + " nothing here can be provoked. This build proves\n" + " nothing on this target and says so rather than\n" + " passing.\n"); + zx_probe_fail(); + + return; + } + + /* From here the console is gone. This write is the first fault. */ + + zx_console_puts("x"); + + /* Not reached. If it is, a store to a region marked read-only at EL2 + did not fault, which is the same finding zx_phase_provoke_el2_fault + exists to catch and is worth reporting rather than passing over. */ + + zx_probe_fail(); +} +#endif + + /**************************************************************************/ /* zx_el2_main -- entered from zx_el2_entry.S at EL2. Does not return. */ /**************************************************************************/ @@ -1070,6 +1156,10 @@ ZX_NORETURN void zx_el2_main(void) single-payload phases above depend on the set they were given. */ zx_phase_two_partitions(board_regions, el2_regions); +#ifdef ZX_PROBE_PROVOKE_REFAULT + zx_phase_provoke_refault(); +#endif + #ifdef ZX_PROBE_PROVOKE_EL2_FAULT zx_phase_provoke_el2_fault(); #endif diff --git a/examples/fvp_baser_aemv8r/CMakeLists.txt b/examples/fvp_baser_aemv8r/CMakeLists.txt index e551426..28ab6b6 100644 --- a/examples/fvp_baser_aemv8r/CMakeLists.txt +++ b/examples/fvp_baser_aemv8r/CMakeLists.txt @@ -115,6 +115,18 @@ zx_add_fvp_probe(zx_probe_el2_fault.elf) target_compile_definitions(zx_probe_el2_fault.elf PRIVATE ZX_PROBE_PROVOKE_EL2_FAULT) +# A FAULT INSIDE THE FAULT REPORT -- see the S32Z280 example for what it does. +# +# BUILT HERE AND UNPROVOKABLE HERE, deliberately. This console is semihosting, +# so no EL2 region covers it and no access permission can refuse a write to it; +# the build says so and reports FAILED rather than passing. It is registered +# anyway so that the model compiles the same code path silicon runs, which is +# the same reason zx_board_mmio_region_count exists on a board with no MMIO +# regions. Not in CTest. +zx_add_fvp_probe(zx_probe_refault.elf) +target_compile_definitions(zx_probe_refault.elf PRIVATE + ZX_PROBE_PROVOKE_REFAULT) + # Claims to need more EL2 regions than any Armv8-R implementation can have, so # that the REFUSAL path is exercised. Like the negative build, it must FAIL, # and it is a build rather than a temporary edit so the demonstration stays diff --git a/examples/fvp_baser_aemv8r/zx_board.c b/examples/fvp_baser_aemv8r/zx_board.c index 1f1cd9b..0c69de9 100644 --- a/examples/fvp_baser_aemv8r/zx_board.c +++ b/examples/fvp_baser_aemv8r/zx_board.c @@ -74,6 +74,24 @@ void zx_board_program_mmio_regions(uint32_t first_index) } +/**************************************************************************/ +/* zx_board_deny_console_writes */ +/**************************************************************************/ + +uint32_t zx_board_deny_console_writes(uint32_t first_index) +{ + (void) first_index; + + /* NOTHING TO DENY. This console is semihosting -- an HLT that the model + interprets -- so it is not memory, no EL2 region covers it, and no + access permission can be made to refuse it. Returning zero is the + honest answer and the caller reports the build as unprovokable here + rather than as passing. */ + + return 0U; +} + + /**************************************************************************/ /* zx_board_report */ /**************************************************************************/ diff --git a/examples/s32z280_evb/CMakeLists.txt b/examples/s32z280_evb/CMakeLists.txt index d874c31..10b4e16 100644 --- a/examples/s32z280_evb/CMakeLists.txt +++ b/examples/s32z280_evb/CMakeLists.txt @@ -94,6 +94,19 @@ zx_add_s32z_probe(zx_probe_el2_fault.elf) target_compile_definitions(zx_probe_el2_fault.elf PRIVATE ZX_PROBE_PROVOKE_EL2_FAULT) +# A FAULT INSIDE THE FAULT REPORT. Makes the region covering this board's own +# console read-only at EL2, so that the report of the resulting abort faults on +# its own first write -- which is the only way to reach the depth guard on the +# unresumable vectors (docs/decisions.md D30). +# +# IT PRINTS NOTHING AFTER THE BANNER, by construction, so it is judged from +# MEMORY: the harness must find zx_run_failures = 0x5C. Not in CTest for the +# same reason the EL2-fault build is not -- a suite that judges a run by its +# printed verdict cannot judge one that has no console. +zx_add_s32z_probe(zx_probe_refault.elf) +target_compile_definitions(zx_probe_refault.elf PRIVATE + ZX_PROBE_PROVOKE_REFAULT) + # Claims to need more EL2 regions than any Armv8-R implementation can have, so # that the REFUSAL path is exercised. Like the negative build, it must FAIL, # and it is a build rather than a temporary edit so the demonstration stays diff --git a/examples/s32z280_evb/tools/run_zx_probe.gdb b/examples/s32z280_evb/tools/run_zx_probe.gdb index 5dcaad8..2c09834 100644 --- a/examples/s32z280_evb/tools/run_zx_probe.gdb +++ b/examples/s32z280_evb/tools/run_zx_probe.gdb @@ -178,11 +178,29 @@ failures = 0 # Read as well as trusted: zx_console_exit sets it from its argument, so this # is the same number the console printed. Reading it here is what makes a run # whose console capture failed still judgeable. +# +# AND THE FAULT PATH PUTS AN EXIT CODE IN THE SAME WORD, not a count. An +# image that reaches its own verdict stores how many checks failed; one that +# dies on an EL2 fault stores a code from zx_el2_fault_path.c. Reported as a +# count, 0x5C read as "92 failing check(s), named in the console log" -- which +# is wrong about the number and, for the build that denies itself a console to +# provoke that code, wrong about there being a log at all. +ZX_FAULT_EXIT_CODES = { + 0x5A: "ZoneX FAULTED AT EL2 -- a hypervisor bug, not a guest one", + 0x5B: "an UNEXPECTED EL2 VECTOR -- an assumption in the reset path is false", + 0x5C: "ZoneX faulted at EL2 WHILE REPORTING a fault at EL2. The report's " + "own console write faulted; the depth guard stopped it recursing", +} + verdict = val("zx_run_failures") -print(" zx_run_failures = %d" % verdict) +print(" zx_run_failures = %d (0x%02X)" % (verdict, verdict)) if verdict == 0xFFFFFFFF: print(" *** FAIL: the image never reached its verdict.") failures += 1 +elif verdict in ZX_FAULT_EXIT_CODES: + print(" *** FAIL: %s." % ZX_FAULT_EXIT_CODES[verdict]) + print(" This is an EXIT CODE and not a count of checks.") + failures += 1 elif verdict != 0: print(" *** FAIL: the image reported %d failing check(s)." % verdict) print(" The console log is where they are named.") diff --git a/examples/s32z280_evb/zx_board.c b/examples/s32z280_evb/zx_board.c index d702998..3e46989 100644 --- a/examples/s32z280_evb/zx_board.c +++ b/examples/s32z280_evb/zx_board.c @@ -328,6 +328,47 @@ void zx_board_program_mmio_regions(uint32_t first_index) } +/**************************************************************************/ +/* zx_board_deny_console_writes */ +/* */ +/* THE CONSOLE'S OWN REGION, MADE READ-ONLY TO EL2. Everything else in */ +/* it is exactly what zx_board_program_mmio_regions wrote; only the */ +/* access permission changes, so the attributes stay Device and the */ +/* address range stays the console's. */ +/* */ +/* AP 0b10 CAN DO THIS, and it is the same mechanism the deliberate */ +/* EL2-fault build already uses on a spare region. At EL2 the access */ +/* permissions cannot deny EL2 outright -- 0b00 denies EL0 and EL1 and */ +/* grants EL2 read/write -- but they can make a region read-only to it, */ +/* and a console write is a store to the data register. Reads of the */ +/* status register keep working, which does not matter: the store comes */ +/* first. */ +/* */ +/* AFTER THIS RETURNS, THIS BOARD HAS NO CONSOLE. Nothing printed will */ +/* arrive, including the fault report for the fault this causes. That is */ +/* the point of the one build that calls it, and it is why the verdict */ +/* has to be read out of memory by the debug harness rather than off the */ +/* wire. */ +/**************************************************************************/ + +uint32_t zx_board_deny_console_writes(uint32_t first_index) +{ + ZX_REGION region; + + region.zx_region_base = (zx_addr_t)ZX_S32Z_LINFLEX_9_BASE; + region.zx_region_limit = (zx_addr_t)((ZX_S32Z_LINFLEX_9_BASE + + ZX_S32Z_LINFLEX_9_SIZE) - 1UL); + region.zx_region_ap = ZX_AP_EL2_RO_GUEST_NONE; + region.zx_region_xn = ZX_XN_NEVER; + region.zx_region_sh = ZX_SH_NON_SHAREABLE; + region.zx_region_attr_index = ZX_ATTR_DEVICE; + + zx_stage2_region_program(first_index, ®ion); + + return 1U; +} + + /**************************************************************************/ /* zx_board_report */ /**************************************************************************/ From c488444a6c3ac1f66603bf004db641e7b938618f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Tue, 8 Sep 2026 09:28:55 -0400 Subject: [PATCH 28/40] Measured what a printing neighbour costs, and bounded the console spins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console phase of the determinism regression carried an unexplained excursion of about 15,300 counts that appeared in roughly two runs in five, with the board driver's write-one-to-clear guard as the leading suspect. The image now measures the whole path on the run it reports: the longest guest console hypercall in core cycles, how many bytes that one hypercall put on the wire, both of the driver's spins in iterations, and -- directly -- how late each window boundary arrived against the absolute deadline the ending window was armed with. The suspect was wrong. The guard has never spun: nought iterations, every phase, every run. The cost is the LINE TAG. Nearly every console hypercall writes the one byte the guest asked for; the one that opens a line writes twenty-two -- the owed newline, the tag naming the partition, and the guest's character -- at EL2 with FIQ masked, so the boundary interrupt is held off for all of it. 106,214 to 106,352 core cycles over seven runs: 2.2 ms, about 17,640 counts of this board's 8 MHz counter. Nor was the residual rare. A deferral that is constant costs a period nothing, because a period is a difference and a constant cancels in it; what reaches the number is the change. Whether a boundary falls inside a line tag is decided by a phase relationship between a fixed schedule and a guest printing a fixed message, and two cycle-counter reads per character -- under one per cent of a character time -- were enough to move it. Instrumented, the excursion is on every run: 17,830 · 17,889 · 17,943 · 17,963 · 18,101 · 18,141 · 18,327. That also closes the widened build, carried as unexplained for a step. Instrumented it measures 12,557 · 13,132 · 13,714, and the correct sixty-frame build now measures 15,909 and 17,327 -- larger. The two builds were never doing different things. Both spins in the board driver are now bounded by TIME as well as by iterations, sharing one deadline of ten character times computed from the counter frequency and the baud rate. The wait for the byte to go out had no bound at all before this, which is the larger hole of the two. Both bounds are kept for the reason zx_el2_dwell keeps both: the time bound is what a WCET argument reads, and the iteration bound is what survives a counter that is not running. The console phase's bound stays at half a window and is now derived rather than observed: the worst deferral is one line tag, a period sees it as one long entry and one short correction, so the worst jitter the mechanism can produce is 35,280 counts against a bound of 40,000. Host suite 100% lines and branches, full FVP suite green, and the silicon matrix run on the S32Z280-594EVB with every negative build failing on the check it was built to fail and on no other. Assisted-by: Claude Code (Opus 5) --- common/inc/zx_console.h | 56 +++++ core/src/zx_guest_console.c | 21 +- docs/decisions.md | 128 +++++++++++ docs/wcet-inputs.md | 108 ++++++--- examples/common/zx_regression.c | 262 +++++++++++++++++----- examples/fvp_baser_aemv8r/zx_board.c | 37 +++ examples/s32z280_evb/zx_board.c | 120 +++++++++- platform/cortex_r52/inc/zx_port.h | 35 +++ platform/cortex_r52/src/zx_console.c | 140 ++++++++++++ platform/cortex_r52/src/zx_frame.c | 64 ++++++ platform/cortex_r52/src/zx_trap_handler.S | 8 +- 11 files changed, 877 insertions(+), 102 deletions(-) diff --git a/common/inc/zx_console.h b/common/inc/zx_console.h index f3ffdb2..cc4908b 100644 --- a/common/inc/zx_console.h +++ b/common/inc/zx_console.h @@ -103,6 +103,62 @@ void zx_board_console_init(void); void zx_board_console_putc(char character); #endif +/**************************************************************************/ +/* WHAT THE CONSOLE COSTS, published rather than argued */ +/* */ +/* A guest's console is one hypercall per character, answered at EL2 */ +/* with FIQ masked, so the hypervisor's console driver decides how long */ +/* the boundary interrupt that ends a partition's window can be */ +/* DEFERRED. That is a worst-case-execution-time input and it was an */ +/* argument for as long as nobody published a number for it. These are */ +/* the numbers. */ +/* */ +/* Each is a MAXIMUM since the last reset rather than an average: what a */ +/* schedule has to survive is the worst deferral it will ever see, and a */ +/* mean quoted alone hides exactly that. A regression resets them at */ +/* each phase boundary so that a phase's worst case is the phase's own */ +/* and not something inherited from the boot. */ +/**************************************************************************/ + +/* Every BYTE this hypervisor has put on the console, by any route -- its own + reports as well as characters forwarded for a guest. Bytes and not + characters, because those two numbers differ where it matters: a newline + is one character to a caller and TWO bytes on the wire, and it is the wire + that costs the time. Counted so that what one hypercall wrote can be + recovered as a difference, which is what makes a burst visible. */ + +uint32_t zx_console_characters_written(void); + +/* The longest single guest console hypercall, in core cycles, and the most + characters any one of them wrote. The two together are the whole + statement: a hypercall that wrote one character and one that wrote a + tag's worth cost the UART the same per character, and only the count + says which happened. */ + +uint32_t zx_console_hvc_cycles_max(void); +uint32_t zx_console_hvc_characters_max(void); +uint32_t zx_console_hvc_calls(void); + +void zx_console_hvc_measure_reset(void); + +/* AND WHAT THE BOARD'S DRIVER SPUN FOR, which is a different question and + was the leading suspect before either was measured. The first is the + wait for the byte to go out, which is a character time and cannot be + avoided; the second is the wait for the write-one-to-clear flag to + de-assert afterwards, which is bounded by an ITERATION COUNT and so is + not a bound a WCET argument can use. A board whose console is + semihosting spins for neither and reports zero for both. */ + +uint32_t zx_board_console_spin_max(void); +uint32_t zx_board_console_guard_max(void); + +/* And every byte the board's driver has actually sent. A board with no UART + sends none and says so. */ + +uint32_t zx_board_console_bytes(void); + +void zx_board_console_spin_reset(void); + #ifdef __cplusplus } #endif diff --git a/core/src/zx_guest_console.c b/core/src/zx_guest_console.c index cdd3123..3dbdf7c 100644 --- a/core/src/zx_guest_console.c +++ b/core/src/zx_guest_console.c @@ -128,7 +128,26 @@ static void zx_guest_console_close_owed_line(void) static void zx_guest_console_tag(void) { - /* THE DEBT IS PAID BEFORE THE TAG, by the party about to speak. A + /* THIS IS THE EXPENSIVE HYPERCALL, and on a target with a polled + console it is the only expensive one. Every other character a guest + prints is one byte; the one that opens a line is the owed newline, + the tag below and the character itself -- twenty-two bytes on the + wire of the S32Z280-594EVB, 2.2 ms at 115,200 8N1, all of it at EL2 + with FIQ masked because a hypercall is an exception to EL2. A window + boundary that falls inside it is deferred for the rest of it, and + that is the whole of what a neighbour who PRINTS costs the critical + partition: measured at 17,640 counts of that board's 8 MHz counter, + every run. See zx_console_bound in the regression image for the + measurements and docs/decisions.md for what it costs the claim. + + NOTHING HERE IS THE PLACE TO FIX IT. The tag cannot be shortened + without changing the contract, and it cannot be split across + hypercalls without letting another partition's tag into the middle of + a line. What removes it is a console the hypervisor can hand a byte + to without waiting for the wire, which is a driver and not a tagging + rule. + + THE DEBT IS PAID BEFORE THE TAG, by the party about to speak. A partition that handed the console back mid-sentence left a line open; whoever opens the next one closes it first, so no two partitions ever share a physical line and no tag is ever written into the middle of diff --git a/docs/decisions.md b/docs/decisions.md index 10103df..35db05b 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1809,6 +1809,16 @@ A wrong region limit perturbing the **timing** as well as the memory would be a useful thing to be true. It is recorded and **not claimed**, because a mechanism nobody has found is not a result. +> **Amended 8 Sep 2026 — everything under these two headings has since been +> measured, and both readings above were wrong.** The excursion is not rare and +> is not on the guard spin: it is the **line tag**, twenty-two bytes written in +> one hypercall with FIQ masked, and it happens on every run. The widened build +> is not perturbing anything — instrumented, it measures *less* than the +> correct build does. Both were artefacts of a phase relationship that a change +> of under one per cent in the per-character cost is enough to move. The +> numbers above stand as what was seen; **D31 is what they were.** The +> half-window bound is unchanged and is now derived rather than observed. + --- ## D29 — The guest console closes a line LAZILY · **settled 3 Sep 2026, by measurement** @@ -1918,3 +1928,121 @@ build that denies itself a console on purpose. The three fault-path exit codes are now named there. That is worth recording because the harness had been misreporting `0x5A` the same way for as long as the EL2-fault build has existed, and nobody noticed until a build arrived whose console was gone. + +--- + +## D31 — What a neighbour that PRINTS costs, and where the cost actually is · **settled 8 Sep 2026, by measurement** + +**Nothing a partition does through the SCHEDULE reaches its neighbour. +Computing, masking its own interrupts and violating its boundary without pause +each move the critical partition's period by tens of counts.** + +**What reaches it is the HYPERVISOR'S OWN CONSOLE DRIVER.** A guest that prints +moves that period by up to one line tag — 22 bytes at 115,200 8N1, 17,640 +counts of this board's 8 MHz counter — every run. That is a defect in ZoneX, +not a limit of the partitioning, and it is bounded, derived and reproducible. + +### The mechanism, named and measured + +A guest's console is one hypercall per character. The hypervisor answers it in +the vector, at EL2, and a hypercall is an exception to EL2 — so `PSTATE.F` is +set for the whole of it and the FIQ that ends a partition's window is deferred +until it returns. + +Nearly every one of those hypercalls writes the single byte the guest asked +for. **The one that opens a line writes twenty-two:** the newline a deferred +close (D29) left owed, which the board driver expands to `CR` `LF`; the tag +that says which partition is speaking, `[P2 B (untrusted)] `; and then the +guest's own character. Measured end to end on the S32Z280-594EVB, over seven +runs of the six-hundred-frame regression: + +``` + longest guest console hypercall 106,214 .. 106,352 core cycles + 2.2 ms, about 17,640 counts + bytes it wrote 22, every run + window boundary lateness 45 .. 9,334 counts (console phase) + 45 .. 48 counts (every other phase) + A's window period jitter 17,830 · 17,889 · 17,943 · 17,963 + 18,101 · 18,141 · 18,327 +``` + +The image measures all of it on the run it reports, including the boundary +lateness — the physical count at the top of the boundary handler against the +absolute deadline the ending window was armed with. That column and the +hypercall column are printed side by side because the second bounds the first. + +### The leading suspect was wrong, and which one it was + +The board driver clears `UARTSR.DTF` after each byte and then spins waiting for +the write-one-to-clear to take effect, under a guard of a hundred thousand +iterations. A partial spin of that guard was the obvious candidate for a +millisecond-scale excursion and had been carried as such. + +**It has never spun. The maximum is zero iterations, in every phase of every +run.** The spin that does cost something is the *other* one — the wait for the +byte to go out, which is a character time by construction and **had no bound at +all**. It reaches 32 iterations. Both are now bounded by **time** as well as by +iterations: ten character times, computed from the counter frequency and the +baud rate, which is the same "ten times the worst legitimate case" rule D28 +uses for the jitter bound. Both bounds are kept, for the reason `zx_el2_dwell` +keeps both: the time bound is what a WCET argument reads, and the iteration +bound is what survives a counter that is not running. + +### The residual was never rare, and that is the finding + +Before any of this was instrumented, seven six-hundred-frame runs gave 1,169; +1,169; 1,204; 1,236 — and 30,615; 30,639; 30,689. That reads as one event in +ninety-nine appearing in roughly two runs in five, and it was recorded that way. + +**It is not an event.** The deferral is very nearly constant, and a period is a +difference between two entries, so a constant deferral cancels in it and only a +*change* reaches the number. Whether a boundary lands inside a line tag is +decided by the phase relationship between a fixed schedule and a guest printing +a fixed message. Adding two cycle-counter reads per character — under one per +cent of a character time — moved that phase, and the excursion became +reproducible on every run. + +The four quiet runs were luck about where the boundary fell. **The bound was +always one line tag**, and this is why the regression file warns against +setting the console bound from what a short run measures. + +### And it explains the widened build, carried unexplained since D28 + +The negative build whose stage-2 limit for partition A is deliberately one +granule too generous used to measure 19,094 counts in this phase where the +correct build measured twelve hundred — two builds differing by **one number in +a region descriptor**, printing the same characters. "A wrong region limit +perturbs the timing as well as the memory" was recorded as a possibility and +not claimed. + +It was never true. Instrumented, three runs of the widened build give 12,557; +13,132; 13,714 — and the **correct** sixty-frame build now gives 15,909 and +17,327, which is larger. Both show the same 22-byte hypercall and the same +lateness shape. One number in a region descriptor was enough to shift the phase +relationship, and the phase relationship is what decided whether the boundary +landed in a tag. **The anomaly is closed as a measurement artefact of that +phase, not as a property of the region descriptor.** + +### Why it is not fixed here, said plainly + +The tag cannot be shortened without changing the contract D8 settles — the id +and the name are both there so a reader can get from a log line back to the +manifest that produced it. It cannot be split across hypercalls without letting +another partition's tag into the middle of a line, which is the one thing the +tagging rules exist to prevent. And it cannot be made cheaper: a guest emits +one byte per hypercall and the console must put about 1.65 bytes on the wire +for each one, so no scheme that writes a single byte per hypercall can keep up. + +**What removes it is a console the hypervisor can hand a byte to without +waiting for the wire** — an interrupt-driven driver with a polled fallback the +fault path forces, because `zx_el2_hypervisor_fault` and +`zx_el2_unexpected_vector` print at the moment ZoneX has already failed once. +That is a driver and two code paths and a test that proves the fallback works. +It is named, it is costed, and it is not this change. + +**The bound stands at half a window and is now derived rather than observed.** +The worst deferral is one line tag, 17,640 counts. A period sees that as one +long entry and one short correction, so the worst jitter the mechanism can +produce is twice it — 35,280 counts, against a half-window bound of 40,000. +Every other phase is held to one eighth of a window, which is four times +tighter. diff --git a/docs/wcet-inputs.md b/docs/wcet-inputs.md index 831305a..1ff1783 100644 --- a/docs/wcet-inputs.md +++ b/docs/wcet-inputs.md @@ -15,7 +15,7 @@ SPDX-License-Identifier: MIT and CC0-1.0 # Data dependence on the switch and trap paths -*Eclipse ThreadX ZoneX. Last read through 3 September 2026.* +*Eclipse ThreadX ZoneX. Last read through 8 September 2026.* **Input to the worst-case-execution-time work, written by the people who wrote the code.** Every path a partition switch or a trap can take was read with one @@ -73,7 +73,7 @@ straight-line and bounded and appends without searching. ## 2. Data-dependent, and known -### 2.1 The console, on the hypercall path · **the open item** +### 2.1 The console, on the hypercall path · **measured, bounded, still there** A guest's console is one hypercall per character, written at EL2 **with FIQ masked**, through a polled UART. The boundary FIQ that ends a partition's @@ -81,7 +81,7 @@ window is therefore deferred for as long as the hypervisor is inside a console hypercall — and how often that happens is decided by how much the *guest* chooses to print. -Two costs, and only one of them is gone: +Two costs. One is gone; the other is now named and bounded rather than open. * **The line closed at the window boundary — REMOVED.** A window ending mid-sentence used to have that line closed by the boundary handler, `CR` and @@ -89,32 +89,67 @@ Two costs, and only one of them is gone: the outgoing guest had been printing. It moved the critical partition's window period by **24,420 counts** — three per cent of a major frame — on every run. The newline is now deferred to whoever speaks next, inside a - window that party owns. -* **The character itself — STILL THERE.** One character is ten bits at 115,200 - baud, about 86.8 µs, about **694 counts**. Over sixty frames that is all that - remains and the phase measures 999–1,196 counts. Over six hundred frames, - in roughly **two runs in five**, an excursion of about **15,300 counts** - appears — one long period and one short correction. It is not the boundary - handler, which now writes nothing. It is somewhere on the hypercall path and - **the mechanism is not confirmed**; the leading suspect is the board driver's - write-one-to-clear guard (§2.2). - -**Consequence:** a partition's window period is unaffected by a neighbour that -computes, that masks its own interrupts, or that violates its boundary ten -thousand times a run — those move it by tens of counts. It is **not** unaffected -by a neighbour that prints. - -### 2.2 The console driver's write-one-to-clear guard - -`zx_board_console_putc` clears `UARTSR.DTF` and then spins waiting for the clear -to take effect, bounded by an **iteration count of 100,000** rather than by -time. The spin exists for a measured reason — without it the next byte's poll -observes the previous byte's flag and its write is silently dropped, which cost -the first character of every line — but **an iteration count is not a bound a -WCET argument can use**, and this spin runs at EL2 with FIQ masked. - -Nobody has measured how long it can actually take. That measurement, and a -bound expressed in counter counts, is the smallest useful thing to do here. + window that party owns (D29). +* **The line TAG — STILL THERE, and it is the whole of what is left.** Nearly + every console hypercall writes the one byte the guest asked for, about 694 + counts. **The one that opens a line writes twenty-two:** the owed newline as + `CR` `LF`, the tag naming the partition, and the guest's character. That is + **106,214 to 106,352 core cycles** measured over seven runs — 2.2 ms, about + **17,640 counts** — with the boundary interrupt masked throughout. + +**The trip count is not the guest's.** It is set by the tagging rules: the tag +length is a property of the manifest's partition name, and the owed newline by +whether the previous window ended mid-line. A guest decides *how often* a line +opens, not what one costs. + +**Measured directly, not inferred.** The regression image records how late each +window boundary arrived against the absolute deadline the ending window was +armed with. In the console phase that is **45 to 9,334 counts**; in every other +phase, **45 to 48**. The lateness and the longest hypercall are reported side +by side because the second bounds the first. + +**What a period sees is the CHANGE in the deferral, not the deferral.** A +period is a difference between two entries, so a constant deferral cancels in +it. This is why the excursion once looked rare: for a fixed schedule and a +guest printing a fixed message, whether the boundary falls inside a line tag is +decided by a phase relationship, and a change of under one per cent in the +per-character cost moves it. Instrumented, the jitter is 17,830 to 18,327 +counts on every run of seven. See D31. + +*For a WCET argument the relevant statement is: a boundary is deferred by at +most one console hypercall; a console hypercall is at most one line tag, whose +length is `(2 + strlen(tag) + 1)` bytes at the configured baud rate plus about +95 counts per byte of hypervisor overhead; and a partition's period is +perturbed by at most twice that.* + +### 2.2 The console driver's two spins · **both now bounded by time** + +`zx_board_console_putc` contains two spins, and until this was measured the +attention was on the wrong one. + +* **The wait for the byte to go out** polls `UARTSR.DTF` until the transmitter + reports the byte gone. It is a character time by construction — 32 iterations + measured — and it **had no bound at all**. An unbounded spin at EL2 with FIQ + masked is the larger WCET hole of the two, and it was there because nobody + had asked the question of this loop. +* **The write-one-to-clear guard** clears `DTF` and waits for the clear to take + effect, bounded by an iteration count of 100,000. This was the leading + suspect for the millisecond-scale excursion in §2.1. **It has never spun: the + maximum is zero iterations, in every phase of every run.** The spin exists + for a measured reason — without it the next byte's poll observes the previous + byte's flag and its write is silently dropped, which cost the first character + of every line — and it earns its place by being correct, not by being taken. + +**Both are now bounded by time as well as by iterations**, sharing one deadline +taken before either: **ten character times**, computed from the counter +frequency and the baud rate, which is the same "ten times the worst legitimate +case" rule D28 uses for the jitter bound. An iteration count is not a bound a +WCET argument can use; a cap in counter counts is. + +**Both bounds are kept, and that is deliberate.** The time bound is the one a +WCET argument reads. The iteration bound is what survives a counter that is not +running, which would otherwise turn the time bound into the infinite loop it +was introduced to remove. Same pair, same reason, as `zx_el2_dwell` (§2.4). ### 2.3 Burning a stopped partition's window @@ -177,15 +212,20 @@ comparison. ## 4. Where the funded work should start -1. **Measure the console driver's guard spin (§2.2)** and bound it in counter - counts. It is the smallest item, it is on a path with FIQ masked, and it is - the leading suspect for the one unexplained figure in this document. +1. ~~Measure the console driver's guard spin and bound it in counter counts.~~ + **Done, and it was the wrong suspect** — the guard has never spun. Both + spins are bounded in counter counts now, and the excursion turned out to be + the line tag (§2.1, §2.2, D31). 2. **Take the console off the hypercall path (§2.1)**, which is the change that - makes the temporal claim unconditional rather than qualified. + makes the temporal claim unconditional rather than qualified. It is now the + *only* thing between the two, and it is scoped: an interrupt-driven driver + with a polled fallback that the fault path can force, plus a test that + proves the fallback works. Two code paths, not one. 3. **Bound interrupt latency**, which this phase does not attempt at all. Guest interrupts go straight to EL1 and cost what they always did; bounding them needs the List Registers this core has and this phase does not use. 4. **Structural coverage of the port**, which needs its own tooling — see `docs/coverage.md`. -The first two are the difference between "measured on one bench" and "bounded". +Item 2 is now the whole of the difference between a temporal claim that needs a +clause carved out of it and one that does not. diff --git a/examples/common/zx_regression.c b/examples/common/zx_regression.c index c772417..757ad4e 100644 --- a/examples/common/zx_regression.c +++ b/examples/common/zx_regression.c @@ -333,6 +333,41 @@ typedef struct ZX_PHASE_RECORD_STRUCT uint32_t zx_phase_violations; + /* WHAT THE HYPERVISOR'S OWN CONSOLE COST DURING THE PHASE, which is + the one perturbation in this run that is ZoneX's doing rather than + the partitioning's. Every one of these is a maximum over the phase + and is reset at its boundary, so that a phase's worst case is its + own and not the boot's. + + THE FIRST TWO ARE THE MECHANISM. A guest's console is one hypercall + per character, answered at EL2 with FIQ masked, so the longest + hypercall is the longest a window boundary can be DEFERRED -- and + the character count is what says whether that hypercall wrote the + one character the guest asked for or a line tag as well. + + THE NEXT TWO ARE THE BOARD DRIVER'S TWO SPINS, in iterations: the + wait for the byte to go out, and the wait for the write-one-to-clear + flag to de-assert afterwards. The second was the leading suspect + before any of this was measured, and an iteration count is not a + bound a WCET argument can use, so it is reported next to the thing + it was suspected of causing. + + AND THE LAST TWO ARE THE CONSEQUENCE, measured directly: how late + the window boundary arrived against the absolute deadline it was + armed with. Min as well as max, because a lateness that is constant + costs a PERIOD nothing -- a period is a difference between two + entries and a constant cancels in it. What a period sees is the + spread between these two. */ + + uint32_t zx_phase_hvc_cycles; + uint32_t zx_phase_hvc_chars; + uint32_t zx_phase_hvc_calls; + uint32_t zx_phase_spin_max; + uint32_t zx_phase_guard_max; + uint32_t zx_phase_late_min; + uint32_t zx_phase_late_max; + uint32_t zx_phase_late_count; + } ZX_PHASE_RECORD; static ZX_PHASE_RECORD zx_phase[ZX_PHASE_COUNT]; @@ -766,6 +801,25 @@ static void zx_close_phase(uint32_t index) zx_phase[index].zx_phase_violations = zx_context[ZX_PART_B].zx_ctx_violations - zx_phase_violations_at_start; zx_phase_violations_at_start = zx_context[ZX_PART_B].zx_ctx_violations; + + /* AND WHAT THE CONSOLE COST, taken and reset together so that the next + phase starts from nothing. Read here rather than at the end of the + run because the question these answer is which PHASE paid, and a + high-water mark over a whole run cannot say. */ + + zx_phase[index].zx_phase_hvc_cycles = zx_console_hvc_cycles_max(); + zx_phase[index].zx_phase_hvc_chars = zx_console_hvc_characters_max(); + zx_phase[index].zx_phase_hvc_calls = zx_console_hvc_calls(); + zx_console_hvc_measure_reset(); + + zx_phase[index].zx_phase_spin_max = zx_board_console_spin_max(); + zx_phase[index].zx_phase_guard_max = zx_board_console_guard_max(); + zx_board_console_spin_reset(); + + zx_frame_lateness_take(&zx_frame, + &zx_phase[index].zx_phase_late_min, + &zx_phase[index].zx_phase_late_max, + &zx_phase[index].zx_phase_late_count); } @@ -1199,61 +1253,78 @@ static uint64_t zx_jitter_bound(void) /* Measured on the S32Z280-594EVB; invisible on the model, whose console */ /* is semihosting and costs the simulation no time at all. */ /* */ -/* HALF OF THE MECHANISM IS NOW GONE AND HALF IS NOT, and the difference */ -/* between them is worth more than either number. */ +/* THE MECHANISM, NAMED AND MEASURED. A guest's console is one */ +/* hypercall per character and the hypervisor answers it at EL2 with FIQ */ +/* masked, so the boundary interrupt that ends a partition's window is */ +/* deferred for as long as that hypercall takes. Nearly every one of */ +/* them writes the single byte the guest asked for. THE ONE THAT OPENS */ +/* A LINE WRITES TWENTY-TWO: the newline a deferred close left owed (CR */ +/* and LF), the tag that says which partition is speaking, and then the */ +/* guest's own character. At 115,200 8N1 that is 22 x 694 counts of */ +/* wire time; measured end to end it is 106,216 to 106,352 core cycles */ +/* over six runs -- 2.2 ms, about 17,640 counts of this board's 8 MHz */ +/* counter. */ +/* */ +/* The image measures all of this on the run it reports: the longest */ +/* hypercall in cycles, how many bytes it wrote, and -- directly -- how */ +/* late each window boundary arrived against the absolute deadline it */ +/* was armed with. In the console phase that lateness runs 45 to some */ +/* 9,300 counts; in every other phase it is 45 to 48. */ /* */ -/* GONE: THE LINE CLOSED AT THE WINDOW BOUNDARY. A guest's console is */ -/* one hypercall per character through a polled UART, and a window that */ -/* ended mid-sentence had that line closed by the BOUNDARY HANDLER -- */ -/* CR and LF, at EL2, with FIQ masked, on the switch path, and back to */ -/* back, which is the exact case the board driver's DTF-clear guard */ -/* exists for. It cost 24,420 counts over sixty frames and 24,584 over */ -/* six hundred, EVERY RUN. The newline is now deferred to whoever */ -/* speaks next, inside a window that party owns; the characters and */ -/* their order are unchanged and only the moment of the write moves, */ -/* which the host suite asserts directly. Afterwards, over sixty */ -/* frames, three consecutive runs: 999, 1,159, 1,196. A sixty-frame run */ -/* no longer sees this at all. */ +/* THE LEADING SUSPECT WAS WRONG, and it is worth recording which one. */ +/* The board driver spins on a write-one-to-clear flag after each byte, */ +/* under a guard of a hundred thousand iterations, and a partial spin of */ +/* it was the obvious candidate. IT HAS NEVER SPUN AT ALL: the maximum */ +/* is zero iterations, in every phase of every run. The other spin -- */ +/* the wait for the byte to go out, which had no bound whatsoever -- */ +/* reaches 32. Both are now bounded by TIME as well as by iterations. */ /* */ -/* NOT GONE: A RARE EXCURSION OF ABOUT 15,300 COUNTS. Over SIX HUNDRED */ -/* frames, seven runs across three builds of this file, gave 1,169; */ -/* 1,169; 1,204; 1,236 -- and 30,615; 30,639; 30,689. The large ones */ -/* are one long period and one short correction of some 15,300 each, so */ -/* they are ONE event in ninety-nine, in roughly two runs in five. Four */ -/* consecutive clean runs said it had gone; the fifth said it had not, */ -/* and the fifth is the one to believe. */ +/* AND THE RESIDUAL WAS NEVER RARE. Before this was instrumented, seven */ +/* six-hundred-frame runs gave 1,169; 1,169; 1,204; 1,236 -- and 30,615; */ +/* 30,639; 30,689, which read as one event in ninety-nine appearing in */ +/* two runs in five. It is not an event. The deferral is very nearly */ +/* CONSTANT, and a period is a difference between two entries, so a */ +/* constant deferral cancels in it and only a CHANGE reaches the number */ +/* above. Whether a boundary lands inside a line tag is decided by the */ +/* phase relationship between a fixed schedule and a guest printing a */ +/* fixed message -- and adding two cycle-counter reads per character, */ +/* under one per cent of a character time, moved that phase and made the */ +/* excursion reproducible on EVERY run: 17,889; 17,963; 18,101; 17,943; */ +/* 18,141; 18,327 over six. The bound was always one line tag. The */ +/* four quiet runs were luck, and they are the reason this file warns */ +/* against setting this bound from what a short run measures. */ /* */ -/* It is NOT the boundary handler, which now writes nothing. What is */ -/* left on the console path is the HYPERCALL: every character is still */ -/* written at EL2 with FIQ masked, and the driver then spins on a */ -/* write-one-to-clear flag under a guard of a hundred thousand */ -/* iterations. A partial spin of that guard is the right order of */ -/* magnitude and is the first place to look. It is not confirmed, and */ -/* naming a mechanism to go with a number before measuring it is how a */ -/* guess becomes a fact in somebody else's document. */ +/* THAT ALSO EXPLAINS THE WIDENED BUILD, which was carried as an */ +/* unexplained neighbour for a whole step. The negative build whose */ +/* stage-2 limit for partition A is one granule too generous used to */ +/* measure 19,094 in this phase where the correct build measured twelve */ +/* hundred, and the two differ by ONE NUMBER IN A REGION DESCRIPTOR and */ +/* print the same characters. Instrumented, three runs of it give */ +/* 12,557; 13,132; 13,714 -- and the CORRECT sixty-frame build now gives */ +/* 15,909 and 17,327, which is larger. The two builds were never doing */ +/* different things. One number in a region descriptor was enough to */ +/* shift the same phase relationship, and the phase relationship is what */ +/* decided whether the boundary landed in a tag. */ /* */ -/* THE BOUND IS HALF A WINDOW, unchanged, and it is not tightened to fit */ -/* what a sixty-frame run happens to measure. The worst observed is */ -/* 30,689 and it sits inside; every other phase is held to one eighth of */ -/* a window, which is four times tighter. Tightening it to the */ -/* sixty-frame figure would produce a suite that passes in CTest and */ -/* fails on the bench two runs in five, which is worse than no bound. */ +/* THE BOUND IS HALF A WINDOW, unchanged, and it is now DERIVED rather */ +/* than observed. The worst deferral is one line tag, 17,640 counts. A */ +/* period sees that as one long entry and one short correction, so the */ +/* worst jitter the mechanism can produce is twice it: 35,280 counts, */ +/* against a half-window bound of 40,000. Every other phase is held to */ +/* one eighth of a window, which is four times tighter. Do not tighten */ +/* this one to what a run happens to measure -- that was tried twice and */ +/* both attempts passed CTest and would have failed on the bench. */ /* */ /* WHAT THIS COSTS THE CLAIM, said here because it is the honest place. */ /* A partition's period is unaffected by a neighbour that computes, that */ /* masks its own interrupts, or that violates its boundary ten thousand */ /* times a run -- those move it by tens of counts. It is NOT unaffected */ -/* by a neighbour that PRINTS: that can still move it by about 15,300 */ -/* counts, rarely. Saying so is worth more than the sentence it costs. */ -/* */ -/* AND ONE THING THAT IS NOT EXPLAINED EITHER. The WIDENED build -- the */ -/* negative one, whose stage-2 limit for partition A is deliberately one */ -/* granule too generous -- measures 19,094, 19,188 and 19,284 counts in */ -/* this phase on three consecutive SIXTY-frame runs, where the correct */ -/* build measures twelve hundred and never more. The two builds differ */ -/* by one number in a region descriptor and print the same characters. */ -/* A wrong region limit perturbing the timing as well as the memory */ -/* would be a useful thing to be true; it is recorded and not claimed. */ +/* by a neighbour that PRINTS: that moves it by up to 17,640 counts, one */ +/* line tag, every run. Removing that needs the character off the */ +/* hypercall path altogether -- a console the hypervisor can hand a byte */ +/* to without waiting for the wire -- and until then the number and its */ +/* mechanism are the deliverable. Saying so is worth more than the */ +/* sentence it costs. */ /**************************************************************************/ static uint64_t zx_console_bound(void) @@ -1370,6 +1441,61 @@ static void zx_report_determinism(uint32_t core_hz) " artificially good. It is printed rather than dropped so that\n" " what was discarded is visible instead of taken on trust.\n"); + /* THE SECOND TABLE: WHERE THE PERTURBATION COMES FROM. The table + above says what A's period did; this one says what the hypervisor's + console was doing while it did it, so that a reader can attribute + the one to the other instead of being asked to believe an + explanation. Every column is measured on the run being reported. */ + + zx_console_puts( + "\n AND WHAT THE HYPERVISOR'S CONSOLE WAS DOING, per phase:\n" + "\n" + " the longest guest console hypercall is the longest a window\n" + " boundary can be DEFERRED, because a character is written at EL2\n" + " with FIQ masked. 'bytes' is how many bytes that one hypercall\n" + " put on the wire: one is the character the guest asked for, and\n" + " more than one means it wrote a line tag as well. 'spin' and\n" + " 'guard' are the board driver's two waits, in ITERATIONS. 'late'\n" + " is the boundary itself, measured against the absolute deadline\n" + " it was armed with, in counter counts.\n" + "\n"); + + zx_console_puts(" phase longest HVC bytes" + " HVCs spin guard boundary late\n"); + + for (index = ZX_PHASE_WARMUP; index < ZX_PHASE_COUNT; index++) + { + zx_console_puts(" "); + zx_console_puts(zx_phase_name[index]); + zx_console_puts(" "); + zx_console_putdec(zx_phase[index].zx_phase_hvc_cycles); + zx_console_puts(" cyc "); + zx_console_putdec(zx_phase[index].zx_phase_hvc_chars); + zx_console_puts(" "); + zx_console_putdec(zx_phase[index].zx_phase_hvc_calls); + zx_console_puts(" "); + zx_console_putdec(zx_phase[index].zx_phase_spin_max); + zx_console_puts(" "); + zx_console_putdec(zx_phase[index].zx_phase_guard_max); + zx_console_puts(" "); + zx_console_putdec(zx_phase[index].zx_phase_late_min); + zx_console_puts(" .. "); + zx_console_putdec(zx_phase[index].zx_phase_late_max); + zx_console_puts(" ("); + zx_console_putdec(zx_phase[index].zx_phase_late_count); + zx_console_puts(" boundaries)\n"); + } + + zx_console_puts( + "\n" + " READ THE LAST COLUMN AGAINST THE FIRST. A boundary that is late\n" + " by a CONSTANT costs a partition's period nothing: the period is\n" + " the difference between two entries and the constant cancels. So\n" + " the number that reaches the table above is the SPREAD of the\n" + " lateness, and the spread cannot exceed the longest hypercall --\n" + " which is why the first column and the last one are printed side\n" + " by side.\n"); + base_mean = zx_phase[ZX_PHASE_BASELINE].zx_phase_mean; base_ticks = zx_phase[ZX_PHASE_BASELINE].zx_phase_own_ticks; (void)base_ticks; @@ -1404,8 +1530,8 @@ static void zx_report_determinism(uint32_t core_hz) masked while the untrusted partition prints, so it is the one phase whose perturbation is the hypervisor's doing rather than the partitioning's. That is known in advance of any - measurement. See zx_console_bound for what was fixed this step, - what is still there, and the seven runs either side of it. */ + measurement. See zx_console_bound for the mechanism, the runs + it was measured over, and where the half-window comes from. */ uint64_t limit = (zx_phase_behaviour[index] == (uint32_t)ZX_GB_STORM) ? zx_console_bound() : bound; @@ -1448,31 +1574,43 @@ static void zx_report_determinism(uint32_t core_hz) " than in any other phase -- and the mechanism is the HYPERVISOR'S\n" " OWN CONSOLE DRIVER, not the partitioning.\n" "\n" - " HALF OF IT HAS BEEN REMOVED. A window ending mid-sentence used\n" + " THE BOUNDARY PATH IS CLEAR. A window ending mid-sentence used\n" " to have the line closed BY THE BOUNDARY HANDLER -- CR and LF into\n" " a polled UART, at EL2, with FIQ masked, on the switch path --\n" " which cost 24,420 counts on this board on EVERY run. The newline\n" " is now deferred to whoever speaks next, inside a window that\n" - " party owns; the characters and their order are unchanged. A\n" - " sixty-frame run now measures about 1,200 counts.\n" + " party owns; the characters and their order are unchanged.\n" + "\n" + " THE HYPERCALL PATH IS NOT, AND THE MECHANISM IS NOW NAMED. Every\n" + " character is still written at EL2 with FIQ masked, so a boundary\n" + " is deferred by however long the hypercall it lands in takes.\n" + " Nearly all of them write one byte. THE ONE THAT OPENS A LINE\n" + " WRITES TWENTY-TWO -- the owed newline, the tag naming the\n" + " partition, and the guest's own character -- and that is 106,000\n" + " core cycles, 2.2 ms, about 17,640 counts, with the boundary\n" + " interrupt held off for all of it. The table above measures it on\n" + " this run: the longest hypercall, the bytes it wrote, and how late\n" + " the boundary actually was.\n" "\n" - " HALF OF IT IS STILL HERE. Over six hundred frames the phase\n" - " reaches about 30,600 counts in roughly two runs in five -- one\n" - " long period and one short correction of some 15,300 each. That\n" - " is not the boundary handler, which now writes nothing; it is\n" - " somewhere on the HYPERCALL path, where every character is still\n" - " written at EL2 with FIQ masked. It is measured, it is inside the\n" - " bound below, and it is NOT explained.\n" + " AND IT IS NOT RARE. A deferral that is CONSTANT costs a period\n" + " nothing, because a period is a difference and a constant cancels\n" + " in it. What reaches the number above is the CHANGE, and whether\n" + " a boundary lands inside a line tag is decided by the phase\n" + " relationship between a fixed schedule and a guest printing a\n" + " fixed message. Runs that once looked clean were runs where that\n" + " phase kept the boundary out of the tag. The bound is the tag.\n" "\n" " AND THE COMPARISON IS THE POINT. In the same run, the untrusted\n" " partition VIOLATING ITS BOUNDARY on every iteration of its own\n" " loop -- ten thousand times in this run and over a million in the\n" " long one -- moves the critical\n" " partition's period by a few tens of counts. Nothing a partition\n" - " does through the schedule reaches its neighbour -- and, since the\n" - " console stopped closing lines on the boundary path, nothing a\n" - " partition does through the CONSOLE reaches it either. That was\n" - " 24,420 counts on this board and is now the tail of one character.\n"); + " does through the SCHEDULE reaches its neighbour. What a partition\n" + " does through the CONSOLE still does, and this is the honest shape\n" + " of it: not a rare excursion nobody can account for, but one line\n" + " tag written with the boundary interrupt masked, every run, whose\n" + " cost is on the line above and whose cure is a console the\n" + " hypervisor can hand a byte to without waiting for the wire.\n"); zx_check("A'S PERIOD IS STEADY WITHIN ITS BOUND IN EVERY MEASURED\n" " PHASE. max - min for each phase from the baseline\n" diff --git a/examples/fvp_baser_aemv8r/zx_board.c b/examples/fvp_baser_aemv8r/zx_board.c index 0c69de9..854c191 100644 --- a/examples/fvp_baser_aemv8r/zx_board.c +++ b/examples/fvp_baser_aemv8r/zx_board.c @@ -50,6 +50,43 @@ void zx_board_init(void) } +/**************************************************************************/ +/* zx_board_console_spin_max, zx_board_console_guard_max */ +/* */ +/* ZERO, AND NOT BECAUSE NOBODY LOOKED. This target's console is */ +/* semihosting: there is no transmitter to wait for and no flag to watch */ +/* de-assert, so the two spins these report do not exist here at all. */ +/* Zero is the honest answer and it is also the reason the model can say */ +/* nothing about this step -- a console that costs the simulation no time */ +/* cannot defer anything. The functions exist so that the regression */ +/* reports the same table on both targets, with the difference visible */ +/* rather than compiled out. */ +/**************************************************************************/ + +uint32_t zx_board_console_spin_max(void) +{ + return 0U; +} + + +uint32_t zx_board_console_bytes(void) +{ + return 0U; +} + + +uint32_t zx_board_console_guard_max(void) +{ + return 0U; +} + + +void zx_board_console_spin_reset(void) +{ + /* Intentionally empty; there is nothing to reset. */ +} + + /**************************************************************************/ /* zx_board_mmio_region_count */ /**************************************************************************/ diff --git a/examples/s32z280_evb/zx_board.c b/examples/s32z280_evb/zx_board.c index 3e46989..db6973c 100644 --- a/examples/s32z280_evb/zx_board.c +++ b/examples/s32z280_evb/zx_board.c @@ -103,6 +103,31 @@ #define ZX_LINFLEXD_GUARD 100000U +/* AND BOUNDED BY TIME AS WELL, which is the bound that means anything. + Every spin in this file runs at EL2 with FIQ masked, so what it costs is + what a partition's window boundary can be DEFERRED by -- and an iteration + count cannot be compared with a window, a deadline, or anything else a + schedule is built out of. Ten character times is the same rule + docs/decisions.md D28 already uses for the jitter bound: ten times the + worst legitimate case, which for a transmitter is the one character it is + sending. + + BOTH BOUNDS ARE KEPT, and that is deliberate rather than belt-and-braces. + The time bound is the one a WCET argument reads; the iteration bound is + what survives a counter that is not running, which would otherwise turn + the time bound into the infinite loop it was introduced to remove. This + is the same pair, for the same reason, as zx_el2_dwell. */ + +#define ZX_LINFLEXD_BAUD 115200U +#define ZX_LINFLEXD_CHAR_BITS 10U /* 8N1: start + eight data + stop */ +#define ZX_LINFLEXD_SPIN_CHARS 10U + +#define ZX_LINFLEXD_SPIN_COUNTS \ + ((uint32_t)((((uint64_t)ZX_S32Z_SYSTEM_COUNTER_HZ \ + * (uint64_t)(ZX_LINFLEXD_CHAR_BITS \ + * ZX_LINFLEXD_SPIN_CHARS)) \ + / (uint64_t)ZX_LINFLEXD_BAUD))) + #define ZX_LINFLEXD_IBRR_115200 21U #define ZX_LINFLEXD_FBRR_115200 11U @@ -113,6 +138,28 @@ #define ZX_S32Z_MMIO_REGIONS 2U +/* THE TWO SPINS IN THE CHARACTER WRITE, AT THEIR WORST. Both are below in + zx_board_console_putc and neither had ever been measured: the first waits + for the byte to go out and is a character time by construction, and the + second waits for a write-one-to-clear flag to de-assert and is bounded by + an ITERATION COUNT, which is not a bound anything can reason about in + time. The whole write happens at EL2 with FIQ masked, so what these cost + is what a window boundary can be deferred by. + + A counter and a comparison per iteration, and nothing else: the loops + they instrument are each an MMIO read of a peripheral on another clock + domain, so the increment is lost in the noise of the thing it counts. */ + +static uint32_t zx_console_spin_high; +static uint32_t zx_console_guard_high; + +/* AND EVERY BYTE THIS DRIVER HAS PUT ON THE WIRE. Counted here rather than + where the string is handed over, because those two numbers are not the + same: a newline is ONE character to a caller and TWO bytes on the wire, + and it is the wire that costs the time. */ + +static uint32_t zx_console_bytes; + /**************************************************************************/ /* zx_linflexd_configure_once */ /* */ @@ -201,12 +248,17 @@ void zx_board_console_init(void) void zx_board_console_putc(char character) { uint32_t guard; + uint32_t spins = 0U; + uint32_t used; + uint64_t deadline; if (character == '\n') { zx_board_console_putc('\r'); } + zx_console_bytes++; + /* Start the byte, wait for completion, clear the flag, then wait for the clear to actually take effect. @@ -229,20 +281,82 @@ void zx_board_console_putc(char character) /* Polled on purpose: this console runs before any interrupt controller is configured, and it must work inside a fault handler. */ - while ((ZX_REG32(ZX_CONSOLE_BASE + ZX_LINFLEXD_UARTSR) & ZX_UARTSR_DTF) - == 0U) + /* THE DEADLINE IS TAKEN ONCE, before either spin, and both share it: + what the two of them cost together is what defers a boundary, so a + bound given to each separately would bound neither. */ + + deadline = zx_read_cntpct() + (uint64_t)ZX_LINFLEXD_SPIN_COUNTS; + + guard = ZX_LINFLEXD_GUARD; + + while (((ZX_REG32(ZX_CONSOLE_BASE + ZX_LINFLEXD_UARTSR) & ZX_UARTSR_DTF) + == 0U) + && (guard > 0U) && (zx_read_cntpct() < deadline)) { /* wait for this byte to go out */ + guard--; + spins++; } ZX_REG32(ZX_CONSOLE_BASE + ZX_LINFLEXD_UARTSR) = ZX_UARTSR_DTF; guard = ZX_LINFLEXD_GUARD; while (((ZX_REG32(ZX_CONSOLE_BASE + ZX_LINFLEXD_UARTSR) & ZX_UARTSR_DTF) - != 0U) && (guard > 0U)) + != 0U) && (guard > 0U) && (zx_read_cntpct() < deadline)) { guard--; } + + /* Both maxima updated AFTER the write rather than inside either loop, + so that the branch a new high costs is paid once per character and + not once per iteration. */ + + if (spins > zx_console_spin_high) + { + zx_console_spin_high = spins; + } + + used = ZX_LINFLEXD_GUARD - guard; + + if (used > zx_console_guard_high) + { + zx_console_guard_high = used; + } +} + + +/**************************************************************************/ +/* zx_board_console_spin_max, zx_board_console_guard_max */ +/* */ +/* What the two spins above reached, in ITERATIONS, since the last reset. */ +/* Iterations rather than time because that is what the loops are written */ +/* in; converting one to the other is the caller's job and needs a */ +/* measured cost per iteration, which is exactly the thing a bound */ +/* expressed in iterations does not give anybody. */ +/**************************************************************************/ + +uint32_t zx_board_console_spin_max(void) +{ + return zx_console_spin_high; +} + + +uint32_t zx_board_console_bytes(void) +{ + return zx_console_bytes; +} + + +uint32_t zx_board_console_guard_max(void) +{ + return zx_console_guard_high; +} + + +void zx_board_console_spin_reset(void) +{ + zx_console_spin_high = 0U; + zx_console_guard_high = 0U; } diff --git a/platform/cortex_r52/inc/zx_port.h b/platform/cortex_r52/inc/zx_port.h index 8d5c95e..7b05b46 100644 --- a/platform/cortex_r52/inc/zx_port.h +++ b/platform/cortex_r52/inc/zx_port.h @@ -1403,6 +1403,25 @@ typedef struct ZX_FRAME_STRUCT UINT zx_frame_stopped_index; uint32_t zx_frame_stop_outcome; + /* HOW LATE THE BOUNDARY ITSELF WAS, which is a different question from + how long the switch took and is the one a printing neighbour can + answer badly. The comparator holds an ABSOLUTE deadline, so a + boundary that arrives after it was deferred -- and the only thing at + EL2 that defers one is EL2 itself, running with FIQ masked. + + Measured as the physical count at the top of the boundary handler + minus the deadline the ending window was armed with. It therefore + includes the vector entry, which is a constant, and the deferral, + which is not. Min as well as max, because the two together say + whether a run's lateness is a steady offset or something that moves: + a constant lateness costs a partition's PERIOD nothing at all, since + a period is a difference between two entries and a constant cancels + in it. What a period sees is the CHANGE. */ + + uint32_t zx_frame_late_min; + uint32_t zx_frame_late_max; + uint32_t zx_frame_late_count; + /* The per-major-frame hook, and the frame count it was last called at. See zx_frame_set_frame_hook: this is how a regression divides one run into phases, and it is called OUTSIDE the timed bracket so that it @@ -1442,6 +1461,15 @@ ZX_NODISCARD uint32_t zx_frame_run(ZX_FRAME *frame_ptr); ZX_NODISCARD ZX_GUEST_CONTEXT *zx_el2_window_boundary(void); +/* Read the boundary lateness recorded since the last call, and reset it. + Read-and-reset rather than read, so that a caller dividing a run into + phases gets each phase's own worst case instead of a high-water mark the + first phase set and every later one inherited. Writes zero to all three + when no boundary has been timed. */ + +void zx_frame_lateness_take(ZX_FRAME *frame_ptr, uint32_t *min_ptr, + uint32_t *max_ptr, uint32_t *count_ptr); + /* Where the boundary handler leaves the reason a frame ended, because the vector it runs in has no caller to return one to. */ @@ -1665,6 +1693,13 @@ void zx_board_init(void); ZX_NORETURN void zx_el2_hypervisor_fault(void); ZX_NORETURN void zx_el2_unexpected_vector(void); +/* The guest console hypercall, answered in the trap vector. Wraps the + portable tagging code with the one measurement the port is in a position + to take: how long this call held FIQ masked, which is how long a window + boundary can be deferred by a partition that prints. See zx_console.h. */ + +void zx_el2_guest_console_hypercall(char character); + #ifdef __cplusplus } #endif diff --git a/platform/cortex_r52/src/zx_console.c b/platform/cortex_r52/src/zx_console.c index b02d117..2affed9 100644 --- a/platform/cortex_r52/src/zx_console.c +++ b/platform/cortex_r52/src/zx_console.c @@ -55,6 +55,7 @@ /**************************************************************************/ #include "zx_port.h" +#include "zx_guest_console.h" /* Number of hexadecimal digits in a 32-bit value. */ @@ -70,6 +71,20 @@ uint32_t zx_run_failures = 0xFFFFFFFFU; +/* HOW MANY CHARACTERS THIS CONSOLE HAS WRITTEN, and the cost of the + longest guest hypercall that wrote any of them. See the block comment + in zx_console.h for why a hypervisor that prints for a guest has to + publish these: the character is written at EL2 with FIQ masked, so this + IS the bound on how long a window boundary can be deferred. */ + +#ifndef ZX_CONSOLE_BOARD +static uint32_t zx_console_characters; +#endif +static uint32_t zx_console_hvc_cycles; +static uint32_t zx_console_hvc_characters; +static uint32_t zx_console_hvc_count; + + #ifndef ZX_CONSOLE_BOARD /**************************************************************************/ @@ -132,6 +147,16 @@ void zx_console_puts(const char *string_ptr) #else + { + const char *cursor = string_ptr; + + while (*cursor != '\0') + { + zx_console_characters++; + cursor++; + } + } + (void) zx_semihost_call(ZX_SYS_WRITE0, string_ptr); #endif @@ -191,6 +216,121 @@ void zx_console_putdec(uint32_t value) } +/**************************************************************************/ +/* zx_el2_guest_console_hypercall */ +/* */ +/* ONE GUEST CONSOLE HYPERCALL, MEASURED. The trap handler calls this */ +/* rather than zx_guest_console_putc directly, and the only thing it adds */ +/* is the measurement -- which belongs in the PORT because it is the port */ +/* that masks FIQ, and not in the portable tagging code that has no idea */ +/* what an interrupt is. */ +/* */ +/* WHY THE COST OF THIS CALL IS THE INTERESTING NUMBER. The guest */ +/* executes HVC once per character, so it is at EL1 with the boundary FIQ */ +/* deliverable between characters and at EL2 with it MASKED during one. */ +/* The window boundary can therefore be deferred by exactly as long as */ +/* this function takes, and by no longer -- which makes the maximum below */ +/* the bound the determinism claim needs, and makes it measurable rather */ +/* than arguable. */ +/* */ +/* ONE CHARACTER IN, MORE THAN ONE OUT. A hypercall that opens a line */ +/* also writes the tag that says who is speaking, and a hypercall that */ +/* opens one after a deferred close writes the newline that closes the */ +/* previous line as well. Those are the expensive ones, they are decided */ +/* by the tagging rules rather than by the guest, and the character count */ +/* is what tells them apart from an ordinary one. */ +/* */ +/* THE PMU RATHER THAN THE SYSTEM COUNTER, because this runs once per */ +/* character on the path being measured: a cycle-counter read is one MRC */ +/* where a 64-bit CNTPCT read is a pair plus the loop that makes it */ +/* atomic. Two reads and three compares against a character time of some */ +/* four thousand cycles is under one per cent, which is the most a */ +/* measurement may cost the thing it measures. */ +/**************************************************************************/ + +void zx_el2_guest_console_hypercall(char character) +{ + uint32_t start = zx_pmu_cycles(); + uint32_t characters = zx_console_characters_written(); + uint32_t spent; + + zx_guest_console_putc(character); + + spent = zx_pmu_cycles() - start; + characters = zx_console_characters_written() - characters; + + zx_console_hvc_count++; + + if (spent > zx_console_hvc_cycles) + { + zx_console_hvc_cycles = spent; + } + + if (characters > zx_console_hvc_characters) + { + zx_console_hvc_characters = characters; + } +} + + +/**************************************************************************/ +/* The measurements, read back */ +/**************************************************************************/ + +uint32_t zx_console_characters_written(void) +{ +#ifdef ZX_CONSOLE_BOARD + + /* THE BOARD'S OWN COUNT, because only the board knows what it put on the + wire: its driver expands a newline into CR and LF, so a caller + counting the string it handed over would be one byte short of the + truth for every line -- and it is the wire that costs the time. */ + + return zx_board_console_bytes(); + +#else + + return zx_console_characters; + +#endif +} + + +uint32_t zx_console_hvc_cycles_max(void) +{ + return zx_console_hvc_cycles; +} + + +uint32_t zx_console_hvc_characters_max(void) +{ + return zx_console_hvc_characters; +} + + +uint32_t zx_console_hvc_calls(void) +{ + return zx_console_hvc_count; +} + + +/**************************************************************************/ +/* zx_console_hvc_measure_reset */ +/* */ +/* THE MAXIMA ONLY, and deliberately not the character total. A phase's */ +/* worst case has to be the phase's own or the boot's output would set a */ +/* floor under every phase after it; the running total is what makes a */ +/* burst recoverable as a difference and resetting it would break that. */ +/**************************************************************************/ + +void zx_console_hvc_measure_reset(void) +{ + zx_console_hvc_cycles = 0U; + zx_console_hvc_characters = 0U; + zx_console_hvc_count = 0U; +} + + /**************************************************************************/ /* zx_console_exit */ /* */ diff --git a/platform/cortex_r52/src/zx_frame.c b/platform/cortex_r52/src/zx_frame.c index a9a2170..9d946d2 100644 --- a/platform/cortex_r52/src/zx_frame.c +++ b/platform/cortex_r52/src/zx_frame.c @@ -170,6 +170,9 @@ void zx_frame_configure(ZX_FRAME *frame_ptr, frame_ptr->zx_frame_switch_last = 0U; frame_ptr->zx_frame_switch_total = 0U; frame_ptr->zx_frame_spurious = 0U; + frame_ptr->zx_frame_late_min = 0xFFFFFFFFU; + frame_ptr->zx_frame_late_max = 0U; + frame_ptr->zx_frame_late_count = 0U; frame_ptr->zx_frame_stopped_index = ZX_MANIFEST_NO_INDEX; frame_ptr->zx_frame_stop_outcome = ZX_RUN_FRAME_DONE; frame_ptr->zx_frame_hook = (ZX_FRAME_HOOK_FN)0; @@ -355,6 +358,30 @@ static void zx_frame_after_switch(ZX_FRAME *frame_ptr, UINT index) } +/**************************************************************************/ +/* zx_frame_lateness_take */ +/**************************************************************************/ + +void zx_frame_lateness_take(ZX_FRAME *frame_ptr, uint32_t *min_ptr, + uint32_t *max_ptr, uint32_t *count_ptr) +{ + if ((frame_ptr == (ZX_FRAME *)0) || (min_ptr == (uint32_t *)0) + || (max_ptr == (uint32_t *)0) || (count_ptr == (uint32_t *)0)) + { + return; + } + + *count_ptr = frame_ptr->zx_frame_late_count; + *max_ptr = frame_ptr->zx_frame_late_max; + *min_ptr = (frame_ptr->zx_frame_late_count != 0U) + ? frame_ptr->zx_frame_late_min : 0U; + + frame_ptr->zx_frame_late_min = 0xFFFFFFFFU; + frame_ptr->zx_frame_late_max = 0U; + frame_ptr->zx_frame_late_count = 0U; +} + + static uint32_t zx_frame_stopped(const ZX_FRAME *frame_ptr, UINT index) { return ((frame_ptr->zx_frame_stopped_mask @@ -608,6 +635,43 @@ ZX_GUEST_CONTEXT *zx_el2_window_boundary(void) return (ZX_GUEST_CONTEXT *)0; } + /* HOW LATE THIS BOUNDARY IS, taken FIRST and before the acknowledge, + because everything after it is work this handler chose to do and the + question is what happened BEFORE the handler got the core at all. + The deadline read here is the ENDING window's: the schedule has not + been advanced yet, and zx_schedule_deadline reports the current + window's end. + + ONE COUNTER READ, OUTSIDE THE TIMED BRACKET. The switch figure is + taken from zx_pmu_cycles below and is untouched by this, so the + published cost of a partition switch stays comparable with every + figure this repository has already published. What it does add is a + constant to every window, and a constant is exactly what a PERIOD + cannot see -- see zx_frame_after_switch on why the same argument + makes the period measurement free. */ + + { + uint64_t deadline = zx_schedule_deadline(frame_ptr->zx_frame_schedule); + uint64_t arrived = zx_read_cntpct(); + + if ((deadline != 0U) && (arrived > deadline)) + { + uint32_t late = (uint32_t)(arrived - deadline); + + frame_ptr->zx_frame_late_count++; + + if (late < frame_ptr->zx_frame_late_min) + { + frame_ptr->zx_frame_late_min = late; + } + + if (late > frame_ptr->zx_frame_late_max) + { + frame_ptr->zx_frame_late_max = late; + } + } + } + intid = zx_gic_el2_acknowledge(); /* The console changes hands BEFORE the measurement starts, and hands diff --git a/platform/cortex_r52/src/zx_trap_handler.S b/platform/cortex_r52/src/zx_trap_handler.S index 3bd9683..dfbda0b 100644 --- a/platform/cortex_r52/src/zx_trap_handler.S +++ b/platform/cortex_r52/src/zx_trap_handler.S @@ -220,7 +220,11 @@ zx_el2_trap_hvc_not_nop: bne zx_el2_trap_hvc_not_putc @ THE GUEST CONSOLE. One character, in the guest's r0, printed by the - @ hypervisor and tagged with the partition it scheduled. Handled HERE, + @ hypervisor and tagged with the partition it scheduled. The port's + @ wrapper is called rather than the portable zx_guest_console_putc so + @ that the cost of this call is measured where it is paid: FIQ is masked + @ for the whole of it, so its maximum IS the bound on how long a window + @ boundary can be deferred by a guest that prints. Handled HERE, @ in the vector, rather than by returning a result code the way a fault @ is: zx_el2_run_payload is a one-way transfer out, so returning through @ it would end the guest's excursion, and a console write must leave the @@ -241,7 +245,7 @@ zx_el2_trap_hvc_not_nop: push {r2, r3, r12, lr} ldr r0, [sp, #16] @ the guest's r0 uxtb r0, r0 - bl zx_guest_console_putc + bl zx_el2_guest_console_hypercall pop {r2, r3, r12, lr} pop {r0, r1} @ balanced with the push eret From 78df114f02c8b43119f182305356a8ab5e1e9c70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Tue, 8 Sep 2026 10:52:01 -0400 Subject: [PATCH 29/40] Removed the references a reader outside this repository cannot check Two kinds of statement, both of which asked a reader to trust something they have no way to reach. Eleven pointed at planning documents that are not in this repository. A contributor who has only this repository learns nothing from "stays where the roadmap put it", "the plan this work came from expected the opposite", or "the spelling the focused task list uses" -- and a citation of a document nobody else can open dates badly besides. Each one kept its fact and dropped its citation, because the fact was always the content: the interrupt work is a later phase, the split into assembly and C is better than one large routine, and tx_hv_* was rejected for reading as a ThreadX subsystem. None of those needed a source outside the tree to stand up. One asserted a third party's design rationale with no source at all. The EL2 reference sheet said that Phase 0 had been expected to paravirtualise interrupts because NXP's EL2M does, and that EL2M's reason is the shared GIC Distributor rather than absent injection hardware. Every other claim in that sheet is either measured here or cited to the technical reference manual, and this one was neither. It has been replaced by what was actually established: the part implements four List Registers, so injection is available, and Phase 0 keeps interrupt handling simple as a scope decision rather than a limitation inherited by assumption. The conclusion is unchanged; what goes is an unverifiable claim about somebody else's product. The reference check passed on all twelve, and that is worth recording rather than fixing blind. It matches path shapes and document identifiers, not prose, so "the roadmap" walks straight through it. It has deliberately not been extended: "the plan" is ZoneX's own name for the region layout that zx_mm_plan produces, with eleven legitimate uses in the core and the examples, so a pattern broad enough to catch the prose form would fire on every one of them. A check that cries wolf gets bypassed and then catches nothing, which is the reasoning already written into that script. Assisted-by: Claude Code (Opus 5) --- docs/armv8r-el2-reference.md | 12 ++++++------ docs/decisions.md | 27 ++++++++++++++------------- platform/cortex_r52/src/zx_context.c | 17 +++++++++-------- platform/cortex_r52/src/zx_el2_cpu.c | 2 +- platform/cortex_r52/src/zx_gic.c | 4 ++-- 5 files changed, 32 insertions(+), 30 deletions(-) diff --git a/docs/armv8r-el2-reference.md b/docs/armv8r-el2-reference.md index a60aa4d..ad9de75 100644 --- a/docs/armv8r-el2-reference.md +++ b/docs/armv8r-el2-reference.md @@ -351,12 +351,12 @@ Three things follow. `ICH_VTR`, and **four List Registers, `ICH_LR0`–`ICH_LR3`**. Interrupt injection is therefore available on this part. - This is worth recording because it corrects an assumption. The focused - roadmap expected Phase 0 to have to paravirtualise interrupts because NXP's - EL2M does. EL2M's reason is that the GIC **Distributor** is a shared - resource, not that injection is unavailable. Phase 0 still keeps interrupt - handling simple — but that is now a scope decision taken knowingly rather - than a limitation inherited by assumption. + This is worth recording because it corrects an assumption. Phase 0 was + expected to have to emulate interrupt delivery, on the belief that the + injection hardware was not there. It is, and this is the measurement that + says so. Phase 0 still keeps interrupt handling simple — but that is now a + scope decision taken knowingly rather than a limitation inherited by + assumption. See `docs/decisions.md` D24. ### A partition's timer PPI is delivered to EL1 directly — measured on both targets diff --git a/docs/decisions.md b/docs/decisions.md index 35db05b..42c712e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -62,7 +62,7 @@ Every other component of the suite uses a two-letter prefix — `tx_`, `fx_`, its own capability-macro prefix for exactly the reason prefixes exist: headers get included together, and a collision is discovered at the worst moment. -The alternative was `tx_hv_*`, which is the spelling the focused task list uses +The alternative was `tx_hv_*`, the spelling an earlier sketch of this work used (`tx_hv_mm_setup.c`, `tx_hv_partition_manager.c`, `tx_hv_trap_handler.S`). It was dropped because it reads as a ThreadX subsystem, and ZoneX is not one — it does not link ThreadX (D9) and it is released as its own component. @@ -496,9 +496,10 @@ like the ThreadX one and does not mean the same thing. This entry originally said ZoneX "is the reference implementation of the suite-wide C17/CMake plan rather than an exception to it." That overstated it in both halves, and the claim was load-bearing in a later argument before -anyone checked it — see D19. The plan makes no such claim: it says it is -independent of the ZoneX roadmap, and asks only that ZoneX be started directly -in C17 with the capability macros. And ZoneX *is* an exception on one point, +anyone checked it — see D19. The suite-wide C17 and CMake work makes no such +claim: it is independent of ZoneX's own sequencing, and asks only that ZoneX be +started directly in C17 with the capability macros. And ZoneX *is* an +exception on one point, recorded below. Every other component migrates to C17 from C99. ZoneX is the only one *born* @@ -508,7 +509,7 @@ capability macro block, `cmake_minimum_required(VERSION 3.28...4.2)`, the HEADERS` are all in place from commit one. A later component copying the shape should copy ZoneX's. -**Where ZoneX deviates.** The plan proposes `CMAKE_C_EXTENSIONS OFF` in the CI +**Where ZoneX deviates.** That work proposes `CMAKE_C_EXTENSIONS OFF` in the CI strict preset with extensions **ON** in the default build, so that ports relying on compiler builtins can be fixed incrementally. ZoneX has it off in the *default* build. That is stricter, it is worth keeping — a repository with @@ -746,9 +747,9 @@ were considered and both fail: ever respelled one of these would then fail loudly at the include rather than diverge quietly. * *"ZoneX is the reference implementation of the C17 migration, so it should - set the precedent."* The suite-wide plan says the opposite — it is - independent of the ZoneX roadmap and asks only that ZoneX start at C17 with - the capability macros. It also settles the migration risk outright: it + set the precedent."* The suite-wide C17 work says the opposite — it is + independent of ZoneX's own sequencing and asks only that ZoneX start at C17 + with the capability macros. It also settles the migration risk outright: it changes no struct layout, no calling convention and no type name, and regression-tests that a C99 application still compiles against the new headers. Those names are load-bearing for that promise, and therefore @@ -1083,9 +1084,9 @@ never enables `ICC_IGRPEN0`, so nothing can deliver an FIQ to it. What is missin is all at EL2 — `FMO`, PPI 26 in Group 0, and a real body on the FIQ vector — and none of it has been run. -Injection through the List Registers stays where the roadmap put it: a later -phase, for making interrupt latency a hypervisor-controlled and WCET-bounded -quantity. It is not a prerequisite for a partition tick, and treating it as one +Injection through the List Registers stays a later phase, for making interrupt +latency a hypervisor-controlled and WCET-bounded quantity. It is not a +prerequisite for a partition tick, and treating it as one would have made the next step look far larger than it is. ### What a partition ends up touching @@ -1308,7 +1309,7 @@ EL1 and the first C instruction would destroy them. Everything else a partition owns is still in the machine while the hypervisor runs: its banked SPs and LRs, its EL1 system registers, its whole EL1 MPU. Those are C. -The plan this work came from expected one large assembly routine. The split +This work was originally expected to be one large assembly routine. The split is better, and the reason is the twenty-region loop: as C it is eight lines a reader can check against the region count the part reported, and unrolled in assembly it is a hundred and twenty coprocessor moves in which one transposed @@ -1375,7 +1376,7 @@ which are equal in any run where both partitions live to the end. * **Interrupt LATENCY is not a hypervisor-controlled quantity.** Guest interrupts go straight to EL1, which is why they cost what they always did — and why bounding them needs the List Registers this core has and this - phase does not use. That stays where the roadmap put it. + phase does not use. That stays a later phase. * **An idle partition burns its window.** Trapping `WFI` to hand the remainder to the next partition would raise throughput and would make one partition's start time depend on its neighbour's behaviour, which is the diff --git a/platform/cortex_r52/src/zx_context.c b/platform/cortex_r52/src/zx_context.c index 150e62b..a999d2c 100644 --- a/platform/cortex_r52/src/zx_context.c +++ b/platform/cortex_r52/src/zx_context.c @@ -35,14 +35,15 @@ /* SPSR_hyp have to be captured before the first C instruction runs, */ /* and those fifteen words are zx_context.S's whole job. */ /* */ -/* That split is worth its explanation because the plan this work came */ -/* from expected the opposite -- one large assembly routine doing all */ -/* of it. The dominant cost here is the EL1 MPU: twenty regions on */ -/* the S32Z280, two registers each, selected through a third. As a */ -/* loop it is eight lines that a reader can check against the region */ -/* count the part reported. Unrolled in assembly it is a hundred and */ -/* twenty coprocessor moves in which a single transposed operand is */ -/* invisible and hands one partition another's memory permissions. */ +/* That split is worth its explanation because this work was */ +/* originally expected to be the opposite -- one large assembly */ +/* routine doing all of it. The dominant cost here is the EL1 MPU: */ +/* twenty regions on the S32Z280, two registers each, selected through */ +/* a third. As a loop it is eight lines that a reader can check */ +/* against the region count the part reported. Unrolled in assembly */ +/* it is a hundred and twenty coprocessor moves in which a single */ +/* transposed operand is invisible and hands one partition another's */ +/* memory permissions. */ /* */ /* Nothing is given up. The loop's trip count is read from MPUIR once */ /* at boot and does not depend on anything a guest did, so the switch */ diff --git a/platform/cortex_r52/src/zx_el2_cpu.c b/platform/cortex_r52/src/zx_el2_cpu.c index bb7178a..9898c0f 100644 --- a/platform/cortex_r52/src/zx_el2_cpu.c +++ b/platform/cortex_r52/src/zx_el2_cpu.c @@ -190,7 +190,7 @@ uint32_t zx_read_ich_vtr(void) number of List Registers MINUS ONE -- the Cortex-R52 resets it to 0x90180003, which is four registers, ICH_LR0 to ICH_LR3. Recorded because interrupt injection being AVAILABLE on this part corrects an - assumption the roadmap carried; see docs/armv8r-el2-reference.md. */ + earlier assumption; see docs/armv8r-el2-reference.md. */ __asm__ volatile("mrc p15, 4, %0, c12, c11, 1" : "=r"(value)); return value; diff --git a/platform/cortex_r52/src/zx_gic.c b/platform/cortex_r52/src/zx_gic.c index 3ef1e0c..afb52eb 100644 --- a/platform/cortex_r52/src/zx_gic.c +++ b/platform/cortex_r52/src/zx_gic.c @@ -84,8 +84,8 @@ /* nothing can deliver an FIQ to it. What is missing is at EL2 -- */ /* FMO, PPI 26 in Group 0, and a real body on the FIQ vector -- and */ /* none of it has been run. Injection through the List Registers stays */ -/* where the roadmap put it: a later phase, for bounding interrupt */ -/* latency, not a prerequisite for a partition tick. */ +/* a later phase, for bounding interrupt latency, not a prerequisite */ +/* for a partition tick. */ /* */ /* MISRA C:2012 deviations (justified) */ /* */ From a5eebcb663e7fb9750087a909f69c3016770d682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Tue, 8 Sep 2026 11:04:37 -0400 Subject: [PATCH 30/40] Narrowed the reference check to what a reader of this repository needs It carried four checks and only three of them are the project's business. The three that stay are ordinary hygiene, and CONTRIBUTING.md already describes exactly these: a local absolute path, which resolves nowhere for anybody else and in a build script means the script runs on one machine; a citation of a numbered stage of something not in the tree, which a reader cannot follow; and a tracked agent-instruction file, which no Eclipse ThreadX repository carries. The fourth enforced an authoring convention belonging to one working setup. It rejected the identifiers of documents that live outside any repository -- and to reject them a denylist has to spell them, which made this file the single place those names appeared. Their spelling is of no use to anybody reading this repository, and enforcement of how one contributor cites their own notes does not belong in a project's continuous integration. It was also not the check that works, and that is the part worth recording. The shape that actually occurs is prose -- a sentence deferring to an external document by description rather than by name -- and the removed pattern matched none of it. Twelve such sentences were found by hand while preparing to publish, in the documentation and in code comments both, every one of them after that pattern had passed. What replaced it is a hook on the authoring side, where the risk is, and where a commit message can be read as well: the committed checks never see one. Each remaining check was provoked and seen to fail before this went in, and the removed one was confirmed silent, because a check nobody has watched fail is not evidence that it can. Assisted-by: Claude Code (Opus 5) --- scripts/check_references.sh | 56 +++++++++---------------------------- 1 file changed, 13 insertions(+), 43 deletions(-) diff --git a/scripts/check_references.sh b/scripts/check_references.sh index 707cc6b..fd59dd6 100755 --- a/scripts/check_references.sh +++ b/scripts/check_references.sh @@ -35,26 +35,15 @@ # Development-environment instructions are not project documentation, and # a committed one starts being treated as though it were. # -# 4. A ROADMAP OR PLAN IDENTIFIER. Planning documents live outside every -# repository, so a comment citing one by its identifier points a reader -# at something they cannot obtain and never will. The FACT such a -# comment carries is almost always worth keeping and is almost always -# citable another way: the measurements those documents record were -# taken during a named piece of work on a named board, so "measured on -# the S32Z280 during the Cortex-R52 Modules port work" carries -# everything the identifier did and survives the reader. -# -# Worth its line because two went in while the partition switch was -# being written, in CODE COMMENTS rather than in documents, which is -# where nobody thinks to look for them. -# -# THE PATTERN BELOW SPELLS THE SHAPES IT REJECTS, because a denylist has -# to. That is the same bargain scripts/check_terminology.sh makes and -# states: it is the single place those spellings appear, so that the -# list cannot drift away from what is actually enforced. A shape in a -# denylist is not a citation -- it points at nothing and carries no -# content -- but it is the one place in this repository where the shapes -# appear at all, and that is deliberate rather than accidental. +# WHAT THIS CHECK DELIBERATELY DOES NOT DO, because the question comes up. +# It does not look for the identifiers of any particular external planning +# scheme. A denylist has to spell what it rejects, and the spelling of one +# contributor's private document names is of no use to anybody reading this +# repository -- while committing it would make this file the one place those +# names appear. The three checks above catch what a reader can actually be +# harmed by: a path that resolves nowhere, a citation that leads nowhere, and +# a file that should not be tracked. Enforcement of an authoring +# convention belongs in whatever hook the author runs, not in a project's CI. # # Deliberately narrow. It catches the shapes that have actually gone wrong # rather than trying to be a general secret scanner, because a check that @@ -76,43 +65,24 @@ readonly FORBIDDEN_FILES='^(AGENTS|CLAUDE)\.md$|/(AGENTS|CLAUDE)\.md$' # "~/" only when it opens a path, so that prose using a tilde is not caught. # "step " case-insensitively, because "Step 5" reads as naturally as # "step 5" and both are the same mistake. -# The plan identifiers are matched with word boundaries and a digit, so that -# ordinary prose is not caught, and case-SENSITIVELY so that lower-case text -# cannot trip them. That is why they are a separate pattern below rather -# than folded into the case-insensitive one above. readonly PATTERN='(^|[^A-Za-z0-9_.-])(/home/[A-Za-z0-9._-]+|~/[A-Za-z0-9._-])|\bstep[ -][0-9]+\b' status=0 # This script is excluded by name: it has to spell the patterns to look for # them. Tracked files only -- a build tree is not this repository's content. -# The plan identifiers are checked case-SENSITIVELY and separately, because -# folding them into the pattern above would make "ar2" in ordinary prose an -# error. The document names are capitalised and the code never is. -readonly PLAN_PATTERN='\b(AR[0-9]+|A[0-9]+-step[0-9]+)\b' - findings="$(git ls-files -z \ | grep -zZv '^scripts/check_references\.sh$' \ | xargs -0 grep -nEiI "${PATTERN}" 2>/dev/null \ | grep -v "${MARKER}" || true)" -plan_findings="$(git ls-files -z \ - | grep -zZv '^scripts/check_references\.sh$' \ - | xargs -0 grep -nEI "${PLAN_PATTERN}" 2>/dev/null \ - | grep -v "${MARKER}" || true)" - -if [ -n "${plan_findings}" ]; then - findings="${findings}${findings:+$'\n'}${plan_findings}" -fi - if [ -n "${findings}" ]; then echo "ZoneX reference check FAILED." >&2 echo "" >&2 - echo "These lines name a local path, a numbered step, or a roadmap" >&2 - echo "identifier from a document that is not in this repository. Keep" >&2 - echo "the fact and drop the citation: a reader cannot follow any of" >&2 - echo "them, and the measurements those documents record ARE citable --" >&2 - echo "by the work and the board they were taken on." >&2 + echo "These lines name a local path, or a numbered step of a document" >&2 + echo "that is not in this repository. Keep the fact and drop the" >&2 + echo "citation: a reader cannot follow either, and a measurement IS" >&2 + echo "citable -- by the work and the board it was taken on." >&2 echo "" >&2 echo "${findings}" >&2 status=1 From 82acaa4b910ce58b61a3ef76a0cb9a1820150450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Tue, 8 Sep 2026 11:44:14 -0400 Subject: [PATCH 31/40] Brought the README's claim and its numbers up to what was measured Five changes, and two of them correct something wrong rather than stale. THE CONSOLE PARAGRAPH DESCRIBED A MECHANISM THAT IS NO LONGER THERE. It said a window ending on a partial line has that line closed by the boundary handler, at a cost of 24,000 counts. The boundary handler now writes nothing, and the residual is on the hypercall path: one line tag, twenty-two bytes, 17,640 counts, with the boundary interrupt masked throughout. It also said the fix is to buffer the console, and buffering was the rejected option -- what removes it is an interrupt-driven driver with a polled fallback the fault path can force, because the fault reporter prints when ZoneX has already failed once. The claim is now split by cause: nothing a partition does through the schedule reaches its neighbour, and what does reach it is the hypervisor's own driver. That is a defect here rather than a limit of the partitioning, and the bound is derived from the mechanism -- 35,280 counts against a half window of 40,000. AND THE EL1 MPU WAS GIVEN THE WRONG DENOMINATOR. "A partition switch costs about 6,000 cycles and the EL1 MPU is 85% of it" reads as 85% of the switch. It is 3,407 cycles: 85% of the save and restore, and 60% of the switch. docs/wcet-inputs.md had this right, so the README was the only place the two were conflated. Both denominators are now stated. The claim that the split holds "on a 32-region model" is gone with it: that is a timing proportion, and timing on a functional model describes nothing. The regression figures move to the six-hundred-frame run -- 21, 21, 22 and 304 counts -- because they are the larger and therefore the honest ones; the sixty-frame numbers they replace read better and prove less. The switch figure is quoted as a band across five readings rather than one run's triple, since the paragraph's own point is that it moves. A region-budget section is new, and it is the portability statement a reader needs before choosing a board rather than after: the permitted EL2 region counts, what the hypervisor's own device memory costs out of them and why that is a property of the board, and the switch mask as a second budget that a region can fall outside of with nothing left to fault on. A part configured with no EL2 MPU cannot run ZoneX at all, and there is no software fallback. Every number in it is measured on both targets, the model included. The claim section is retitled from what Phase 0 WILL prove to what it does, gains the positive claim it never carried, and loses one overclaim: a later phase was described as delivering a CERTIFIED worst-case interrupt latency. Nothing here is certified and that phase bounds rather than certifies. That interrupt latency is not measured at all now leads its own paragraph instead of sitting inside a list, because it is the first thing a safety reader has to know, and the conditions every figure was taken under sit beside the claim so that a quotation carries them. Assisted-by: Claude Code (Opus 5) --- README.md | 147 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 121 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 639dc15..3207632 100644 --- a/README.md +++ b/README.md @@ -40,17 +40,44 @@ isolation cases in one run — seven in each direction, each aimed at an address of its own — and measures the critical partition's window period continuously while the untrusted one is steered through five behaviours: idle, computing, computing with its own interrupts masked, storming the console, and violating -its boundary on every iteration of its own loop. On the board A's period is -800,000 counter counts and moves by **nine** counts while its neighbour idles, -**fifteen** while it computes with interrupts masked, and **sixty-nine** while -it commits a hundred and seventeen thousand boundary violations. - -Nothing a partition does *through the schedule* reaches its neighbour. One -thing does, and it is the hypervisor's own doing: a guest's console is one -hypercall per character through a polled UART, and a window that ends with a -partial line has that line closed by the boundary handler — which delays the -next partition's entry by 24,000 counts. It is measured, bounded by one line -of output, and the fix is to buffer the console off that path. +its boundary on every iteration of its own loop. + +On the board A's window period is 800,000 counts of an 8 MHz counter. Over six +hundred major frames it moves by **21** counts while its neighbour idles, +**21** while that neighbour computes, **22** while it computes with IRQ and FIQ +masked, and **304** while it commits a hundred and three thousand boundary +violations. + +**One thing does reach a neighbour, and it is the hypervisor's own doing.** In +the same run, the phase where the untrusted partition storms the console moves +A's period by **17,830** counts. + +Nothing a partition does *through the schedule* reaches its neighbour — the +figures above are tens of counts, against a violation count in six figures. +What reaches it is ZoneX's console driver. A guest's console is one hypercall +per character, answered at EL2 with `PSTATE.F` set, so the FIQ that ends a +window waits for it. Nearly every one of those hypercalls writes the single +byte the guest asked for. **The one that opens a line writes twenty-two** — +the newline a deferred close still owed, the tag naming the partition, and the +guest's own character — and that is 106,214 core cycles, 2.2 ms, about +**17,640 counts**, with the boundary interrupt held off throughout. + +**That is a defect in ZoneX, not a limit of the partitioning**, and it is +bounded, derived and reproducible. The bound is one line tag: a period is a +difference between two window entries, so a constant deferral cancels in it +and only a *change* reaches the number — one long period and one short +correction, 35,280 counts against a half-window bound of 40,000. Every other +phase is held to one eighth of a window. + +It is also not rare, and that took finding out. Runs that once looked clean +were runs where the phase relationship between a fixed schedule and a guest +printing a fixed message happened to keep the boundary out of a tag; adding +two cycle-counter reads per character, under one per cent of a character time, +moved that phase and the excursion appeared on every run. **What removes it is +a console the hypervisor can hand a byte to without waiting for the wire** — +an interrupt-driven driver with a polled fallback the fault path can force, +because the fault reporter prints at the moment ZoneX has already failed once. +That is named and costed in `docs/wcet-inputs.md`, and it is not done. Three results, and these are mechanisms rather than measurements — they do not move when the numbers below do: @@ -82,11 +109,13 @@ frequency was established three independent ways, so it does not depend on the core clock, the caches or the optimisation level. **And one measurement, which will change.** A partition switch costs **about -6,000 cycles** on the S32Z280 — 6,030 / 6,078 / 6,370 min / mean / max on the -most recent run — and the guest's own EL1 MPU is 85% of it, on both a -32-region model and a 20-region part. A switch is not expensive because the -hypervisor does much; its per-partition state is three register writes. It is -expensive because a guest has a lot of registers. +6,000 cycles** on the S32Z280 — 5,662 / 5,705 / 5,964 min / mean / max on the +most recent run, with the mean spanning 5,705 to 6,078 across five readings +taken on this bench over a week. The guest's own EL1 MPU is **3,407 of those +5,705** — 85% of the save and restore, and 60% of the whole switch. A switch is +not expensive because the hypervisor does much; its per-partition state is +three register writes. It is expensive because a guest has a lot of registers, +and most of them are its memory protection unit. **Read that as ±4%, not as four significant figures.** The same code re-measured a day later, on the same bench, reads 3% higher — and the two @@ -154,16 +183,82 @@ Phase 0 is a partitioning demonstrator with a deliberately small scope: Targets are the Armv8-R AEM FVP and the NXP S32Z280-594EVB. -### What Phase 0 will prove, and what it will not - -The demonstrator runs on a Cortex-R52 in **lockstep**. On that configuration it -demonstrates **memory isolation and temporal determinism on one logical core**. - -It does **not** demonstrate spatial partitioning across cores; that requires -split-mode SMP and is deferred to a later phase. Interrupt virtualisation with -a certified worst-case execution time, inter-partition communication, the full -time-partition scheduler, TraceX integration, supervised partition restart and -the safety package are likewise later phases, and are not in Phase 0. +### The region budget, and where this port will not fit + +ZoneX's isolation is region descriptors in the EL2 MPU, and they are the scarce +resource. **Check this number against your part before you choose a board.** + +`HMPUIR[7:0]` gives the EL2 region count, and on a Cortex-R52 the +architecturally permitted values are **0, 16, 20 or 24**. Measured: + +| | EL2 regions | ZoneX's own MMIO | the two-partition demonstrator | +|---|---|---|---| +| S32Z280-594EVB | 20 | 2 | 4 of 20 | +| Armv8-R AEM FVP | 32 | 0 | 2 of 32 | + +Three consequences, and the first decides a board. + +* **A Cortex-R52 configured with no EL2 MPU cannot run ZoneX at all.** Zero is + a legal `HMPUIR` value, and there is no software fallback: on Armv8-R there + are no page tables at either stage of translation, + so the region MPU is the only memory control there is. A part with 16 will + run ZoneX with less headroom than this bench. +* **The hypervisor's own MMIO is not free, and its cost is a property of the + board rather than of ZoneX.** On the S32Z280 the console and the GIC both + fall in the background map's Normal write-through band, not the Device band, + and a memory-mapped device reached with cacheable attributes is not a working + peripheral. Each therefore costs a Device-attributed EL2 region. On the FVP + both sit in the Device band and cost nothing — so **the model cannot show you + this constraint**, and it reports 32 regions, which is not a legal Cortex-R52 + value at either stage. +* **A region must also be reachable by the switch.** A partition switch enables + and disables region sets with one write to `HPRENR`, whose implemented width + is a property of the part — `0x000fffff`, all 20 bits, on this board. A + region seated past that mask would be programmed with its own enable bit set + and left there, so the outgoing partition's window would stay live under the + incoming one with nothing to fault on. ZoneX checks the count and the mask + separately at boot and refuses to start if the layout does not fit both. + +The manifest allows four partitions of six regions each, static-asserted +against 24. That ceiling is a build-time constant; the boot-time check against +the real `HMPUIR` and `HPRENR` is what holds it to account on a given part. + +### What Phase 0 demonstrates, and what it does not + +Two ThreadX kernels run at EL1 on one logical Cortex-R52 core, each confined to +its own stage-2 window, time-sharing the core under a static major frame taken +from a manifest. Neither can read, write or execute the other's memory or the +hypervisor's — not even after granting itself that memory in its own EL1 MPU. A +violation is caught by the stage-2 MPU at EL2 and reported with the partition, +the address and the guest PC, and the system halts. Each partition's virtual +time advances only inside its own windows. + +**Nothing a partition does through the schedule reaches its neighbour.** +Computing, masking its own interrupts and violating its boundary without pause +each move the critical partition's period by tens of counts. + +**What reaches it is the hypervisor's own console driver.** A guest that prints +moves that period by up to one line tag — 22 bytes at 115,200 8N1, 17,640 +counts of the board's 8 MHz counter — every run. That is a defect in ZoneX, not +a limit of the partitioning, and it is bounded, derived and reproducible. + +And what it does not demonstrate. + +The demonstrator runs on a Cortex-R52 in **lockstep**, which presents as **one +logical core**, so this is temporal and memory partitioning on a single core. +It is **not** spatial partitioning across multiple cores; that needs split-mode +SMP and is deferred. + +**Interrupt latency is not measured at all.** Guest interrupts go straight to +EL1 and cost what they always did; bounding them needs the GIC List Registers +this core has and this phase does not use. Interrupt virtualisation with a +bounded worst-case latency, inter-partition communication, the full +time-partition scheduler, supervised partition restart, TraceX integration and +the safety-artifact package are later phases and are not in Phase 0. + +Every timing figure above comes from one part on one bench, with the EL2 caches +off, built `-Og`, and with no clock tree configured. Read them as a first +measurement with its conditions stated, not as characterisation. We state this plainly because the audience for this work is safety-savvy, and because an overclaimed demonstrator is worth less than an honest one. From 3cb4ad8cb5f2b02b7318c030283271523e3bf158 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Tue, 8 Sep 2026 15:30:09 -0400 Subject: [PATCH 32/40] Gave ZoneX the build and hotfix version constants the suite carries zx_api.h declared a major, a minor and a patch and stopped there, where every other component of the suite declares five. Phase 0 is 0.1.0.202603 with no hotfix, so ZX_BUILD_VERSION is 202603 and ZX_HOTFIX_VERSION is a space. THE BUILD NUMBER IS YYYYQQ -- the year and then the QUARTER of publication, not the month -- and that is written into the header rather than left to be looked up, because the value reads as a month. 202603 is the third quarter of 2026. The neighbouring components carry the same shape and 202602 was Q2. CONTRIBUTING.md already documents the scheme under "Version numbers"; what was missing was the constants and a check. So the host suite range-checks the quarter rather than pinning the whole value. A version carrying a month satisfies every other property a version has: it is an integer, it sorts, it is six digits, it looks like a date. It is simply wrong by two quarters, and nothing would have said so. Pinning 202603 outright would have caught it too and would need editing every release, which means being edited without being read. The lower bound is Q3 2025, when the quarterly model was adopted. The hotfix character is checked for being a space or a letter, because a digit there would mean somebody had treated it as a number. Both checks were provoked and seen to fail before this went in -- 202609 rejected, '1' rejected -- since a check nobody has watched fail is not evidence that it can. Assisted-by: Claude Code (Opus 5) --- common/inc/zx_api.h | 15 ++++++++++++++- test/host/test_zx_api.c | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/common/inc/zx_api.h b/common/inc/zx_api.h index 0fedfca..e8c4b35 100644 --- a/common/inc/zx_api.h +++ b/common/inc/zx_api.h @@ -100,10 +100,23 @@ extern "C" { /* Version identification. ZoneX follows the suite-wide scheme described in CONTRIBUTING.md, "Release model and support". Phase 0 is pre-release and carries a 0 major deliberately: the interfaces below are expected to change - until the demonstrator is complete. */ + until the demonstrator is complete. + + ZX_BUILD_VERSION is the suite's YYYYQQ form: the year followed by the + QUARTER of publication, not the month. 202603 is the THIRD QUARTER of + 2026, and the neighbouring components carry the same shape -- 202602 is + Q2. Worth stating outright in the header a reader reaches first, because + the value reads as a month, a month would be accepted silently, and it + would be wrong by two quarters. The host suite range-checks it. + + ZX_HOTFIX_VERSION is a CHARACTER, and a space means no hotfix. A letter + appears only when one is issued against a build that is already + published, and hotfixes are component-specific rather than suite-wide. */ #define ZX_MAJOR_VERSION 0 #define ZX_MINOR_VERSION 1 #define ZX_PATCH_VERSION 0 +#define ZX_BUILD_VERSION 202603 +#define ZX_HOTFIX_VERSION ' ' /**************************************************************************/ /* Fundamental types */ diff --git a/test/host/test_zx_api.c b/test/host/test_zx_api.c index 6ec2210..de3dc31 100644 --- a/test/host/test_zx_api.c +++ b/test/host/test_zx_api.c @@ -88,4 +88,22 @@ ZX_TEST_MAIN("zx_api", deliberately 0.x, and something that bumped the major without meaning to should be visible here rather than only in a release note. */ ZX_CHECK_EQ(ZX_MAJOR_VERSION, 0); + + /* The build number is YYYYQQ -- the year then the QUARTER. A value + carrying a MONTH is the mistake this shape invites: 202609 for + September would satisfy every other property a version has, and be + wrong by two quarters. So the quarter is range-checked rather than + the whole value pinned, which would need editing every release and + would then be edited without being read. + + The lower bound is Q3 2025, when the project adopted the quarterly + model -- see CONTRIBUTING.md, "Release cadence". */ + ZX_CHECK(ZX_BUILD_VERSION >= 202503); + ZX_CHECK((ZX_BUILD_VERSION % 100) >= 1); + ZX_CHECK((ZX_BUILD_VERSION % 100) <= 4); + + /* A space means no hotfix; anything else is a letter. A digit here + would mean somebody had treated it as a number. */ + ZX_CHECK((ZX_HOTFIX_VERSION == ' ') + || ((ZX_HOTFIX_VERSION >= 'a') && (ZX_HOTFIX_VERSION <= 'z'))); }) From 5403c5f12bc43b0867d2d6c905e11d09022a54cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Tue, 8 Sep 2026 17:00:21 -0400 Subject: [PATCH 33/40] Moved the memory-type vocabulary out of the port and into the manifest A manifest could not name a memory type without a Cortex-R52 header. ZX_ATTR_NORMAL_WB, ZX_ATTR_DEVICE and ZX_ATTR_NORMAL_NC were defined in platform/cortex_r52/inc/zx_port.h, while the other three fields of the same region descriptor -- AP, XN and SH -- were in core/inc/zx_manifest.h. That split was an oversight against a rule this tree already states. The include of zx_manifest.h in the port header carries the reason next to it: the port programs what a manifest declares, so the descriptor belongs to the manifest and not to the port, because the host-side validator has to build and check the same objects with no Cortex-R52 header in reach. The consequence was in the test suite the whole time. test/host cannot include a port header, so test_zx_partition.c set an attribute index of bare 0 where it meant Normal write-back -- the one field of four that had to be a magic number. It names the type now. WHAT SPLITS, AND WHERE THE LINE IS. The INDEX each memory type occupies is a manifest's vocabulary and is now a contract in zx_manifest.h: a port may choose the attribute byte that expresses "Normal, write-back" on its own hardware, but not the index it sits at, because a manifest names the index. The BYTES and the register values stay in the port, which is the only thing here that is Cortex-R52-specific. AND THE CONTRACT IS ASSERTED RATHER THAN DESCRIBED. zx_stage2_mpu.c now extracts, from the HMAIR0 value it actually programs, the byte sitting at each contracted index, and fails the build if it is not the byte that index promises. Extraction rather than restatement: a restatement would be a second copy of the same number and the two would drift together. A byte moved to the wrong index would otherwise give every region naming that type the wrong memory attribute, silently, with the validator satisfied and the hardware not objecting -- which is the same failure mode the unwritten-index rule already exists to prevent, one level up. A range check runs first and masks the index inside the shift, so that an index moved into HMAIR1 reports itself rather than reporting an undefined shift by 32. Both were provoked: swapping two bytes in HMAIR0 fails the build naming the type, and moving a type to index 4 fails it naming the register, with no pedantic warning in either case. No runtime behaviour changes. The values are the same values at the same indices; what changed is which header owns them and that the agreement is now checked. Verified by the case that exposed it: a static const manifest compiles and passes zx_manifest_verify against common/inc and core/inc alone, with no port header and no local workaround. Assisted-by: Claude Code (Opus 5) --- core/inc/zx_manifest.h | 20 ++++++++++++++ platform/cortex_r52/inc/zx_port.h | 7 ++--- platform/cortex_r52/src/zx_stage2_mpu.c | 35 +++++++++++++++++++++++++ test/host/test_zx_partition.c | 2 +- 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/core/inc/zx_manifest.h b/core/inc/zx_manifest.h index eb77861..c07270f 100644 --- a/core/inc/zx_manifest.h +++ b/core/inc/zx_manifest.h @@ -97,6 +97,26 @@ extern "C" { that reason. */ #define ZX_ATTR_INDEX_COUNT 8U +/* The memory types a manifest may name, and THE INDEX EACH ONE OCCUPIES IS A + CONTRACT EVERY PORT MUST KEEP. A port is free to choose the attribute + byte that expresses "Normal, write-back" on its hardware; it is not free + to put it at a different index, because a manifest names the index. + + These live here rather than in a port for the same reason the AP, XN and + SH encodings do: the port programs what a manifest declares, so the + vocabulary belongs to the manifest. The host-side validator has to build + and check the same descriptors with no port header in reach, and until + this moved it could not name a memory type at all -- test/host wrote a + bare 0 where it meant Normal write-back. + + A port binds itself to this with _Static_assert over the register values + it programs; see platform/cortex_r52/src/zx_stage2_mpu.c. Index 3 and + above are deliberately unnamed: they are inside ZX_ATTR_INDEX_COUNT, no + port programs them, and the validator refuses a region that names one. */ +#define ZX_ATTR_NORMAL_WB 0x00U /* Normal, write-back, RW-alloc */ +#define ZX_ATTR_DEVICE 0x01U /* Device-nGnRnE */ +#define ZX_ATTR_NORMAL_NC 0x02U /* Normal, non-cacheable */ + /**************************************************************************/ /* Region descriptor */ /**************************************************************************/ diff --git a/platform/cortex_r52/inc/zx_port.h b/platform/cortex_r52/inc/zx_port.h index 7b05b46..cfaad70 100644 --- a/platform/cortex_r52/inc/zx_port.h +++ b/platform/cortex_r52/inc/zx_port.h @@ -215,9 +215,10 @@ enough to pass a careless test. Programming every index ZoneX names means a wrong index produces a WRONG ATTRIBUTE rather than a plausible one. */ -#define ZX_ATTR_NORMAL_WB ZX_C32(0x0) /* Normal, write-back, RW-alloc */ -#define ZX_ATTR_DEVICE ZX_C32(0x1) /* Device-nGnRnE */ -#define ZX_ATTR_NORMAL_NC ZX_C32(0x2) /* Normal, non-cacheable */ +/* The INDICES are not defined here. They are a manifest's vocabulary and + they live in core/inc/zx_manifest.h as ZX_ATTR_NORMAL_WB and friends; what + belongs to this port is the byte each index has to hold on this hardware, + below, and the assertions in zx_stage2_mpu.c that bind the two. */ #define ZX_MAIR_BYTE_NORMAL_WB ZX_C32(0xFF) #define ZX_MAIR_BYTE_DEVICE ZX_C32(0x00) diff --git a/platform/cortex_r52/src/zx_stage2_mpu.c b/platform/cortex_r52/src/zx_stage2_mpu.c index b76dc72..a63733c 100644 --- a/platform/cortex_r52/src/zx_stage2_mpu.c +++ b/platform/cortex_r52/src/zx_stage2_mpu.c @@ -229,6 +229,41 @@ static uint32_t zx_read_hprlar(void) /* are on. */ /**************************************************************************/ +/* THIS PORT BINDING ITSELF TO THE MANIFEST'S CONTRACT. zx_manifest.h fixes + which INDEX each memory type occupies, because a manifest names the index; + this port chooses the BYTE that expresses that type on Cortex-R52. The + two have to agree, and nothing above checks that they do -- a byte moved + to the wrong index would give every region naming that type the wrong + memory attribute, silently, with the validator satisfied and the hardware + not objecting. Each assertion extracts the byte the register value + actually carries at the contracted index and compares it. + + Written as extraction from ZX_HMAIR0_VALUE rather than as a restatement of + it: a restatement would be a second copy of the same number, and the two + would drift together. */ +/* Every type this port names sits in HMAIR0, so all three indices are below + 4. Checked FIRST because the extraction below depends on it: an index of + 4 or more is an HMAIR1 byte, and this port programs HMAIR1 as zero. */ +_Static_assert((ZX_ATTR_NORMAL_WB < 4U) && (ZX_ATTR_DEVICE < 4U) + && (ZX_ATTR_NORMAL_NC < 4U), + "a named memory type moved into HMAIR1, which this port " + "programs as zero; the byte checks below read HMAIR0"); + +/* The index is masked to 0-3 inside the shift so that the expression stays a + constant expression whatever the index is. Without the mask an index of 4 + shifts a uint32_t by 32, which is undefined, and the compiler reports THAT + instead of the assertion above -- a worse diagnostic for the same bug. */ +#define ZX_MAIR0_BYTE_AT(idx) (((ZX_HMAIR0_VALUE) >> (8U * ((idx) & 3U))) \ + & 0xFFU) + +_Static_assert(ZX_MAIR0_BYTE_AT(ZX_ATTR_NORMAL_WB) == ZX_MAIR_BYTE_NORMAL_WB, + "HMAIR0 does not carry Normal write-back at ZX_ATTR_NORMAL_WB"); +_Static_assert(ZX_MAIR0_BYTE_AT(ZX_ATTR_DEVICE) == ZX_MAIR_BYTE_DEVICE, + "HMAIR0 does not carry Device-nGnRnE at ZX_ATTR_DEVICE"); +_Static_assert(ZX_MAIR0_BYTE_AT(ZX_ATTR_NORMAL_NC) == ZX_MAIR_BYTE_NORMAL_NC, + "HMAIR0 does not carry Normal non-cacheable at " + "ZX_ATTR_NORMAL_NC"); + void zx_mair_program(void) { uint32_t mair0 = ZX_HMAIR0_VALUE; diff --git a/test/host/test_zx_partition.c b/test/host/test_zx_partition.c index 91fde6e..beb044a 100644 --- a/test/host/test_zx_partition.c +++ b/test/host/test_zx_partition.c @@ -73,7 +73,7 @@ static void set_region(ZX_REGION *region_ptr, zx_addr_t base, zx_addr_t limit, region_ptr->zx_region_ap = ZX_AP_EL2_RW_GUEST_RW; region_ptr->zx_region_xn = xn; region_ptr->zx_region_sh = ZX_SH_NON_SHAREABLE; - region_ptr->zx_region_attr_index = 0U; + region_ptr->zx_region_attr_index = (UCHAR)ZX_ATTR_NORMAL_WB; } /* The known-good partition, rebuilt before every case. Region 0 is From 5ab2ba163b44fd894680f09d2a9c57174c7e9b24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Tue, 8 Sep 2026 17:08:59 -0400 Subject: [PATCH 34/40] Moved the region granule in beside the rules that enforce it ZX_MPU_GRANULE was the last piece of a manifest's vocabulary living outside the manifest header. It is a rule about what a manifest may DECLARE -- every base and every limit one carries is checked against it, and someone writing one needs it to hand -- so it now sits with the AP, XN, SH and attribute-index encodings rather than in the general API two headers away. Nothing was broken before this and nothing is fixed by it; both headers are architecture-independent, so this is cohesion rather than layering. It follows the memory-type move for the same reason: a reader looking for what a region descriptor may contain should find all of it in one place. THE TESTS FOLLOWED THE CONSTANT, and that mattered more than the move. test/host/test_zx_api.c includes zx_api.h and no other ZoneX header, which is what makes it evidence that the header stands alone -- so it could not simply gain an include. Its granule assertions moved to the manifest suite, beside the alignment rules they underwrite, and the named memory types are asserted there too. What stays behind is the alignment arithmetic itself, now written against an explicit alignment argument: it was always exercising zx_addr_t and the ZX_NODISCARD declaration rather than the constant, and saying so makes the division obvious. Verified from both ends: zx_api.h compiles as the only ZoneX header included, and a static const manifest still compiles and passes zx_manifest_verify against common/inc and core/inc with no port header. Assisted-by: Claude Code (Opus 5) --- common/inc/zx_api.h | 10 ---------- core/inc/zx_manifest.h | 15 +++++++++++++++ test/host/test_zx_api.c | 33 ++++++++++++++++----------------- test/host/test_zx_manifest.c | 26 ++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 27 deletions(-) diff --git a/common/inc/zx_api.h b/common/inc/zx_api.h index e8c4b35..67906a4 100644 --- a/common/inc/zx_api.h +++ b/common/inc/zx_api.h @@ -183,16 +183,6 @@ typedef uintptr_t zx_addr_t; what makes (base + size - 1) safe to compute as a limit. */ typedef uintptr_t zx_size_t; -/* PMSAv8-R programs a region as a base and an inclusive limit, both of which - must be aligned to the 64-byte granule. An under-aligned base does not - fault: its low bits land on the SH, AP and XN fields of HPRBAR and silently - change the region's attributes. An unmasked limit is worse -- (base + size - - 1) ends in 0x3F, which lands on HPRLAR's AttrIndx field and selects an - unwritten MAIR byte. Both traps were paid for at EL1 during the - Cortex-R52 Modules port work, and are repeated here because they apply - unchanged at stage 2. */ -#define ZX_MPU_GRANULE 64U - /* The C17 baseline is a project decision (docs/decisions.md D12), not an accident of whichever compiler happens to be installed. Asserting it here means a build that quietly fell back to an older standard fails at the first diff --git a/core/inc/zx_manifest.h b/core/inc/zx_manifest.h index c07270f..cbdf9da 100644 --- a/core/inc/zx_manifest.h +++ b/core/inc/zx_manifest.h @@ -64,6 +64,21 @@ extern "C" { /* Stage-2 region attributes */ /**************************************************************************/ +/* PMSAv8-R programs a region as a base and an inclusive limit, both of which + must be aligned to the 64-byte granule. An under-aligned base does not + fault: its low bits land on the SH, AP and XN fields of HPRBAR and silently + change the region's attributes. An unmasked limit is worse -- (base + size + - 1) ends in 0x3F, which lands on HPRLAR's AttrIndx field and selects an + unwritten MAIR byte. Both traps were paid for at EL1 during the + Cortex-R52 Modules port work, and are repeated here because they apply + unchanged at stage 2. + + It sits with the manifest's other region vocabulary rather than in the + general API because it is a rule about what a manifest may DECLARE: every + base and every limit a manifest carries is checked against it, and someone + writing one needs it to hand. */ +#define ZX_MPU_GRANULE 64U + /* AP[2:1] as stage 2 encodes it. These are NOT the EL1 AP encodings, and the difference is the one that matters for isolation: there is no encoding that grants a guest access while denying EL2. A partition is isolated diff --git a/test/host/test_zx_api.c b/test/host/test_zx_api.c index de3dc31..8ba9705 100644 --- a/test/host/test_zx_api.c +++ b/test/host/test_zx_api.c @@ -44,11 +44,12 @@ is only validated when it sits on a real declaration; a version of the block that compiled but produced an attribute the compiler silently ignored would pass any test that only checked the macro was defined. */ -ZX_NODISCARD static zx_addr_t zx_test_align_down_to_granule(zx_addr_t address); +ZX_NODISCARD static zx_addr_t zx_test_align_down(zx_addr_t address, + zx_addr_t alignment); -static zx_addr_t zx_test_align_down_to_granule(zx_addr_t address) +static zx_addr_t zx_test_align_down(zx_addr_t address, zx_addr_t alignment) { - return address & ~(zx_addr_t)(ZX_MPU_GRANULE - 1U); + return address & ~(alignment - 1U); } static void zx_test_unused_is_accepted(ZX_MAYBE_UNUSED int ignored) @@ -57,23 +58,21 @@ static void zx_test_unused_is_accepted(ZX_MAYBE_UNUSED int ignored) ZX_TEST_MAIN("zx_api", { - /* The 64-byte granule is not a tunable. PMSAv8-R lays HPRBAR out as - BASE[31:6] with SH, AP and XN in the low bits, so a base that is not a - multiple of 64 does not fault -- it silently rewrites the region's - attributes. If this constant is ever not 64, every mask in the port is - wrong. */ - ZX_CHECK_EQ(ZX_MPU_GRANULE, 64U); - ZX_CHECK_EQ(ZX_MPU_GRANULE & (ZX_MPU_GRANULE - 1U), 0U); + /* Alignment arithmetic on zx_addr_t behaves as region programming will + need it to: an aligned address is left alone, and an unaligned one is + brought DOWN rather than up. Down, because a region must not start + before the memory the manifest declared. - /* Alignment behaves as the region programming will need it to: an aligned - base is left alone, and an under-aligned one is brought down rather - than up. Down, because a region must not start before the memory the - manifest declared. */ - ZX_CHECK_EQ(zx_test_align_down_to_granule((zx_addr_t)0x80000000U), + This exercises the type and the ZX_NODISCARD declaration above; the + GRANULE itself belongs to the manifest and is asserted by the manifest + suite. zx_api.h is deliberately tested with no other ZoneX header + included, which is what makes it evidence that the header stands + alone. */ + ZX_CHECK_EQ(zx_test_align_down((zx_addr_t)0x80000000U, 64U), (zx_addr_t)0x80000000U); - ZX_CHECK_EQ(zx_test_align_down_to_granule((zx_addr_t)0x8000003FU), + ZX_CHECK_EQ(zx_test_align_down((zx_addr_t)0x8000003FU, 64U), (zx_addr_t)0x80000000U); - ZX_CHECK_EQ(zx_test_align_down_to_granule((zx_addr_t)0x80000040U), + ZX_CHECK_EQ(zx_test_align_down((zx_addr_t)0x80000040U, 64U), (zx_addr_t)0x80000040U); /* zx_addr_t and zx_size_t must be able to express a limit computed as diff --git a/test/host/test_zx_manifest.c b/test/host/test_zx_manifest.c index ea48890..66c67c6 100644 --- a/test/host/test_zx_manifest.c +++ b/test/host/test_zx_manifest.c @@ -632,7 +632,33 @@ static void test_region_edges(void) EXPECT_STATUS(ZX_MANIFEST_SUCCESS); } +/**************************************************************************/ +/* The granule constant itself, which now lives in this header. */ +/**************************************************************************/ + +static void test_granule_constant(void) +{ + /* The 64-byte granule is not a tunable. PMSAv8-R lays HPRBAR out as + BASE[31:6] with SH, AP and XN in the low bits, so a base that is not a + multiple of 64 does not fault -- it silently rewrites the region's + attributes. If this constant is ever not 64, every mask in the port + is wrong and every alignment rule below is checking the wrong thing. */ + ZX_CHECK_EQ(ZX_MPU_GRANULE, 64U); + ZX_CHECK_EQ(ZX_MPU_GRANULE & (ZX_MPU_GRANULE - 1U), 0U); + + /* And the named memory types are the indices the ports are held to. A + value changed here without the matching HMAIR byte moving is caught at + build time by the assertions in the port; this catches a change made + with no thought at all. */ + ZX_CHECK_EQ(ZX_ATTR_NORMAL_WB, 0U); + ZX_CHECK_EQ(ZX_ATTR_DEVICE, 1U); + ZX_CHECK_EQ(ZX_ATTR_NORMAL_NC, 2U); + ZX_CHECK(ZX_ATTR_NORMAL_NC < ZX_ATTR_INDEX_COUNT); +} + + ZX_TEST_MAIN("test_zx_manifest", + test_granule_constant(); test_good_manifest(); test_null_pointers(); test_partition_structure(); From f69952d286f1d11c18478d18bde696bf4731cf52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Tue, 8 Sep 2026 17:38:58 -0400 Subject: [PATCH 35/40] Requoted the README from a run of the shipped revision The figures came from a run taken before the three commits that moved the memory-type vocabulary and the region granule between headers. Those changes are behaviour-neutral and the assertions binding them are compile-time, but a README quoting silicon should quote the silicon the code ships as. Re-run on the same board: the critical partition's period moves by 22, 20, 24 and 185 counts across the quiet and fault phases, and 17,951 while its neighbour storms the console, whose longest hypercall is 106,116 cycles and still exactly 22 bytes. The switch reads 5,662 / 5,703 / 5,982. THE EARLIER RUN IS CITED BESIDE IT RATHER THAN REPLACED. Two independent runs six days and three commits apart, agreeing to within the run-to-run scatter and showing the identical 22-byte line tag, is a different claim from one run's numbers -- and it is the only basis on which a single bench is worth quoting. The 22 bytes matter most: that is the mechanism the half-window bound is derived from, so its reproducing is what the bound rests on. Assisted-by: Claude Code (Opus 5) --- README.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3207632..7385c86 100644 --- a/README.md +++ b/README.md @@ -43,14 +43,18 @@ computing with its own interrupts masked, storming the console, and violating its boundary on every iteration of its own loop. On the board A's window period is 800,000 counts of an 8 MHz counter. Over six -hundred major frames it moves by **21** counts while its neighbour idles, -**21** while that neighbour computes, **22** while it computes with IRQ and FIQ -masked, and **304** while it commits a hundred and three thousand boundary +hundred major frames it moves by **22** counts while its neighbour idles, +**20** while that neighbour computes, **24** while it computes with IRQ and FIQ +masked, and **185** while it commits a hundred and three thousand boundary violations. +**An independent run six days earlier agreed to within the scatter** — 21, 21, +22 and 304 counts, with the same 22-byte console hypercall — so these are a +reproduced measurement rather than one run's luck. + **One thing does reach a neighbour, and it is the hypervisor's own doing.** In the same run, the phase where the untrusted partition storms the console moves -A's period by **17,830** counts. +A's period by **17,951** counts. Nothing a partition does *through the schedule* reaches its neighbour — the figures above are tens of counts, against a violation count in six figures. @@ -59,7 +63,7 @@ per character, answered at EL2 with `PSTATE.F` set, so the FIQ that ends a window waits for it. Nearly every one of those hypercalls writes the single byte the guest asked for. **The one that opens a line writes twenty-two** — the newline a deferred close still owed, the tag naming the partition, and the -guest's own character — and that is 106,214 core cycles, 2.2 ms, about +guest's own character — and that is 106,116 core cycles, 2.2 ms, about **17,640 counts**, with the boundary interrupt held off throughout. **That is a defect in ZoneX, not a limit of the partitioning**, and it is @@ -109,10 +113,10 @@ frequency was established three independent ways, so it does not depend on the core clock, the caches or the optimisation level. **And one measurement, which will change.** A partition switch costs **about -6,000 cycles** on the S32Z280 — 5,662 / 5,705 / 5,964 min / mean / max on the -most recent run, with the mean spanning 5,705 to 6,078 across five readings +6,000 cycles** on the S32Z280 — 5,662 / 5,703 / 5,982 min / mean / max on the +most recent run, with the mean spanning 5,703 to 6,078 across six readings taken on this bench over a week. The guest's own EL1 MPU is **3,407 of those -5,705** — 85% of the save and restore, and 60% of the whole switch. A switch is +5,703** — 85% of the save and restore, and 60% of the whole switch. A switch is not expensive because the hypervisor does much; its per-partition state is three register writes. It is expensive because a guest has a lot of registers, and most of them are its memory protection unit. From b473614003e681e338ac9a328fe25c2fee93e17f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 9 Sep 2026 11:11:30 -0400 Subject: [PATCH 36/40] Made CONTRIBUTING.md true about the checks, the floor and the compiler Four findings from reading the file against what the repository does. Two were wording; two were the repository being wrong and the file describing it faithfully. THE TWO CHECKS WERE PATH-FILTERED, AND THEY MUST NOT BE. check_terminology.sh and check_references.sh ran as a job inside host_tests.yml, which gates on the build files, cmake/, common/, core/, scripts/ and test/host/. Both checks select their input with git ls-files and scan EVERY tracked file, so any file at all can carry a finding -- and a change touching only docs/ and platform/ ran neither of them. That is not hypothetical. The commit earlier on this branch that removed twelve unpublishable references touched exactly docs/ and platform/, and its message notes that the reference check passed on all twelve. As a pull request it would have merged with neither check having run. The file states the principle this violates: a workflow that gates no pull request anybody opens is worse than no workflow, because it looks like coverage. A path filter is a property of a workflow rather than of a job, so the checks move to repo_checks.yml with no paths on either trigger. A fifth workflow rather than wider filters on the fourth: widening host_tests.yml would run a full instrumented build and a coverage pass on every typo, where these two need no toolchain, no build and no cache. AND THE REFERENCE COMPILER WAS NOT THE ONE CI USED. The file names GCC 14 as the Linux reference and says CI pins these versions. install.sh installed build-essential, which on ubuntu-24.04 is GCC 13.3 -- measured, not inferred. install.sh now installs gcc-14 and g++-14 alongside it, and host_tests.yml names CC and CXX so the version is exact where it has to be. It deliberately does NOT rewire the default gcc: a dependency installer should not change which compiler every other build on a contributor's machine picks up, and the file already says a local build with a different version is fine while a verified one is not. Built strict with GCC 14 before pinning it, because a newer compiler under warnings-as-errors is exactly where new diagnostics appear: clean, and the suite passes. THE COVERAGE FLOOR WAS DESCRIBED AS A REPORT. "Pass coverage for a gcovr report" undersold the strongest quality statement here: it writes a report and then runs gcovr a second time over the seven fully reachable files of core/src, and FAILS THE BUILD below 100% of lines or of branches. The failure condition is now written down, along with why branches carry as much as lines. The workflow step was called "Coverage"; it is now called what it does. AND ZX_THREADX WAS NOWHERE IN THE FILE. A contributor reading only this one could not learn that the model and silicon runners need it to build the ThreadX guest images. Both runner rows now name it, with a worked invocation and what happens without it -- the guests are skipped with a configure-time message rather than an error, so most of the suite still runs, but a change to the launch path or anything temporal is not verified until it has been run with the variable set. Verified while here: CMake 3.28 matches cmake_minimum_required, the gcovr pin matches install.sh, the seven-file floor matches the script, the dev-targeting rule and the component list are both still right. Assisted-by: Claude Code (Opus 5) --- .github/workflows/host_tests.yml | 45 +++++++++---------- .github/workflows/repo_checks.yml | 75 +++++++++++++++++++++++++++++++ CONTRIBUTING.md | 25 ++++++++--- scripts/install.sh | 15 +++++++ 4 files changed, 130 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/repo_checks.yml diff --git a/.github/workflows/host_tests.yml b/.github/workflows/host_tests.yml index c3176c2..251bef0 100644 --- a/.github/workflows/host_tests.yml +++ b/.github/workflows/host_tests.yml @@ -16,8 +16,11 @@ name: host_tests # Builds and runs the ZoneX host unit-test suite, under the strict warning set -# with warnings as errors, and reports coverage. Also runs the repository -# checks that need no toolchain. +# with warnings as errors, and enforces the coverage floor. +# +# The repository-wide terminology and reference checks used to run here and +# now live in repo_checks.yml, unfiltered: this workflow is path-filtered, and +# a check that scans every tracked file must not be. # # What this suite covers, and what it deliberately does not: it exercises the # architecture-independent half of ZoneX -- the manifest and its validator, the @@ -65,28 +68,6 @@ on: - "test/host/**" jobs: - # Cheap, and independent of every toolchain, so it runs as its own job and - # gives an unambiguous answer rather than being buried in a build log. - terminology: - runs-on: ubuntu-24.04 - steps: - # Actions are pinned to a commit SHA with the version in the trailing - # comment. A tag can be moved; a SHA cannot, which is what makes "which - # code ran in our CI" answerable from the repository -- something the - # certification work needs on its own account. Dependabot moves these pins - # and rewrites the comments with them; see .github/dependabot.yml. - - name: Check out the repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Check for A-profile and RISC-V terminology - run: scripts/check_terminology.sh - - # Both checks run in the same job, and both run even if the first fails, - # so that one push reports every finding rather than one per re-run. - - name: Check for local paths and unreachable references - if: '!cancelled()' - run: scripts/check_references.sh - host: runs-on: ubuntu-24.04 steps: @@ -99,9 +80,17 @@ jobs: # ci-strict rather than the default preset: CMAKE_COMPILE_WARNING_AS_ERROR # is on there, and the point of the strict warning set is that CI enforces # it while a developer build stays warning-tolerant. + # GCC 14 explicitly. build-essential on ubuntu-24.04 is GCC 13, so + # without naming the compiler CI would build with a version the project + # does not treat as its reference -- and CONTRIBUTING.md would be claiming + # a pin that did not exist. install.sh provides gcc-14. - name: Build and test, warnings as errors + env: + CC: gcc-14 + CXX: g++-14 run: | set -eu + gcc-14 --version | head -1 cmake --preset ci-strict cmake --build --preset ci-strict ctest --preset ci-strict @@ -109,7 +98,13 @@ jobs: # A separate, instrumented build. Coverage instrumentation changes code # generation, so the run that gates the pull request should not be the # instrumented one. - - name: Coverage + # The floor is ENFORCED here, not merely reported: test_host.sh coverage + # runs a second gcovr invocation that fails if the fully reachable core is + # below 100% of lines or of branches. + - name: Coverage, and the floor it enforces + env: + CC: gcc-14 + CXX: g++-14 run: scripts/test_host.sh coverage - name: Upload the coverage report diff --git a/.github/workflows/repo_checks.yml b/.github/workflows/repo_checks.yml new file mode 100644 index 0000000..1d72c91 --- /dev/null +++ b/.github/workflows/repo_checks.yml @@ -0,0 +1,75 @@ +############################################################################### +# Copyright (c) 2026 Eclipse ThreadX contributors +# +# This program and the accompanying materials are made available under the +# terms of the MIT License which is available at +# https://opensource.org/licenses/MIT. +# +# AI Disclosure: This file was largely AI-generated by Claude Code (Opus 5). +# The AI-generated portions may be considered public domain (CC0-1.0) +# and not subject to the project's licence. The human contributor has +# reviewed and verified that the code is correct. +# +# SPDX-License-Identifier: MIT and CC0-1.0 +############################################################################### + +name: repo_checks + +# The two repository-wide checks: terminology, and references. +# +# THEY LIVE IN THEIR OWN WORKFLOW BECAUSE THEY MUST NOT BE PATH-FILTERED, and +# a path filter is a property of a workflow rather than of a job. Both checks +# select their input with `git ls-files` and scan EVERY tracked file, so any +# file at all can carry a finding: a stale AArch64 register name in a design +# note, a local path in a build script, a citation of a document nobody else +# has. A filter that skipped documentation would skip precisely the files +# whose prose these checks exist to police. +# +# They were part of host_tests.yml until this workflow existed, and that was +# measurably wrong rather than merely untidy. host_tests.yml gates on +# common/, core/, scripts/, test/host/ and the build files -- so a change +# touching only docs/ and platform/ ran neither check. A commit correcting +# twelve unpublishable references in docs/ and platform/ would have merged +# without either check running, which is the shape of hole that CONTRIBUTING.md +# warns about: a workflow that gates no pull request anybody opens is worse +# than no workflow, because it looks like coverage. +# +# Cheap enough that running them on everything costs nothing worth measuring: +# they are grep over a repository this size, and they need no toolchain, no +# build and no cache. + +on: + # No `paths:` on either trigger, deliberately. See above. + push: + branches: [ dev, main ] + pull_request: + branches: [ dev, main ] + +# A second push to the same branch makes the first answer irrelevant. +concurrency: + group: repo-checks-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + checks: + runs-on: ubuntu-24.04 + steps: + # Actions are pinned to a commit SHA with the version in the trailing + # comment. A tag can be moved; a SHA cannot, which is what makes "which + # code ran in our CI" answerable from the repository -- something the + # certification work needs on its own account. Dependabot moves these + # pins and rewrites the comments with them; see .github/dependabot.yml. + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check for A-profile and RISC-V terminology + run: scripts/check_terminology.sh + + # Both checks run even if the first fails, so that one push reports every + # finding rather than one per re-run. + - name: Check for local paths and unreachable references + if: '!cancelled()' + run: scripts/check_references.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4540cf..5832140 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,6 +90,8 @@ Compilers: These versions are what CI pins and what the project treats as the reference. Building with a different version is fine while you develop, but a contribution is only considered verified once it passes with the versions above. +The host compiler is named explicitly rather than inherited: `build-essential` on the runner image is GCC 13, so `scripts/install.sh` installs `gcc-14` alongside it and the workflow sets `CC` and `CXX`. It does not rewire the default `gcc` on your machine — a dependency installer should not change which compiler every other build picks up. + All assembly code targeting Linux toolchains must use GCC syntax. ## Building and testing @@ -98,15 +100,25 @@ ZoneX follows the convention the other Eclipse ThreadX repositories use: `script | Script pair | Target | What it does | | ----------- | ------ | ------------ | -| `scripts/build_host.sh`, `scripts/test_host.sh` | host | Builds and runs the host unit tests over the architecture-independent code. Pass `coverage` to `test_host.sh` for a gcovr report. | -| `scripts/build_fvp.sh`, `scripts/test_fvp.sh` | Armv8-R AEM FVP | Cross-builds the Cortex-R52 images and executes them on the model. | -| `scripts/build_s32z280.sh`, `scripts/test_s32z280.sh` | NXP S32Z280-594EVB | Cross-builds the same images for silicon. Running them needs the board. | +| `scripts/build_host.sh`, `scripts/test_host.sh` | host | Builds and runs the host unit tests over the architecture-independent code. Pass `coverage` to `test_host.sh` to build instrumented, run the suite, write an HTML and XML report, and **enforce the coverage floor** - see below. | +| `scripts/build_fvp.sh`, `scripts/test_fvp.sh` | Armv8-R AEM FVP | Cross-builds the Cortex-R52 images and executes them on the model. Set `ZX_THREADX` to build the ThreadX guest images too - see below. | +| `scripts/build_s32z280.sh`, `scripts/test_s32z280.sh` | NXP S32Z280-594EVB | Cross-builds the same images for silicon. Running them needs the board, and `ZX_THREADX` again for the guest images. | | `scripts/install.sh` | — | Installs the build and test dependencies on Ubuntu. | | `scripts/check_terminology.sh` | — | Rejects register and concept names that belong to other architectures. See below. | | `scripts/check_references.sh` | — | Rejects local absolute paths, citations of documents that are not in the repository, and a tracked agent-instruction file. | `CMakePresets.json` offers the same builds directly: `--preset default` for a warning-tolerant host build, `--preset ci-strict` for the host build with warnings as errors, `--preset coverage`, and `--preset fvp` / `--preset s32z280` for the cross builds. +**The coverage floor is enforced, not reported.** `test_host.sh coverage` writes a report and then runs `gcovr` a second time over the seven files of `core/src` that are reachable in full from a workstation, and **fails the build** if any is below 100% of lines *or* of branches. It is a floor rather than a target: a validator rule added without a case that rejects anything drops the number and breaks the build, which is the point - a rule nothing has ever seen reject anything is a comment, not a rule. Branches matter as much as lines, because the failures those files exist to prevent live in the arms nobody took. [`docs/coverage.md`](docs/coverage.md) records which files are held to the floor, which are not, and what stands in for the ones that are not. + +**Building the ThreadX guest images needs `ZX_THREADX`.** ZoneX runs ThreadX kernels inside its partitions, but it does not link ThreadX and keeps no copy of it, so the guest images are built from a checkout you point it at: + +```sh +ZX_THREADX=/path/to/a/threadx/checkout scripts/test_fvp.sh +``` + +Leave it unset and the guest images are skipped - with a message at configure time, not an error - while the stage-2 probe images still build and run. That is deliberate, so a contributor with no ThreadX to hand can still exercise most of the suite; but a change touching the guest launch path, the partition switch or anything temporal is not verified until it has been run with the variable set. + **Which suite does your change belong in?** ZoneX runs two, and the split is deliberate. Architecture-independent logic — the partition manifest and its validator, the partition tables, the schedule arithmetic — is covered by the host suite, which runs anywhere in seconds. Stage-2 MPU programming, the trap path, isolation and every timing claim are only true on the FVP and on silicon and are tested there; a host simulator would be testing a simulation of the mechanism rather than the mechanism. [`docs/decisions.md`](docs/decisions.md) D11 has the full reasoning, including why ZoneX does not hold the suite's usual coverage threshold over the whole repository. **A note on terminology.** ZoneX targets Armv8-R AArch32, where both stages of address control are region-based MPUs. Register names from the AArch64 system-register set, the translation-table registers, and RISC-V memory-protection vocabulary are wrong here by construction, and code that uses one was written against the wrong architecture. `scripts/check_terminology.sh` checks this mechanically and runs in CI; [`docs/armv8r-el2-reference.md`](docs/armv8r-el2-reference.md) holds the verified names, encodings and field layouts. Read it before writing anything that touches a register. @@ -119,11 +131,14 @@ Whatever you build, describe in your pull request how you verified your change. ## Continuous integration -Four GitHub Actions workflows. **Every one of them triggers on `pull_request` against `dev` and `main`, and on `push` to `dev` and `main`** — a workflow that gates no pull request anybody opens is worse than no workflow, because it looks like coverage. +Five GitHub Actions workflows. **Every one triggers on `pull_request` against `dev` and `main`, and on `push` to the same two branches** — a workflow that gates no pull request anybody opens is worse than no workflow, because it looks like coverage. + +**Four of the five are additionally path-filtered, and one deliberately is not.** A filter keeps a cross-build lane from running when nothing it compiles has changed. But the terminology and reference checks select their input with `git ls-files` and scan *every* tracked file, so any file at all can carry a finding — a stale AArch64 register name in a design note, a local path in a script, a citation of a document nobody else has. Filtering those would skip precisely the prose they exist to police, so `repo_checks.yml` carries no `paths:` on either trigger and runs on everything. | Workflow | What it checks | | -------- | -------------- | -| `host_tests.yml` | The host unit tests, built with warnings as errors, plus a coverage report. Runs `check_terminology.sh` and `check_references.sh` in a separate job so their answers are unambiguous. | +| `repo_checks.yml` | `check_terminology.sh` and `check_references.sh`, over every tracked file. Unfiltered, and needs no toolchain, no build and no cache. | +| `host_tests.yml` | The host unit tests, built with warnings as errors under GCC 14, and the coverage floor — which it enforces rather than reports. | | `gcc_check.yml` | Cross-builds every Cortex-R52 configuration — FVP, S32Z280, hard float — with the Arm GNU Toolchain and warnings as errors. Compiles and links; executes nothing. | | `clang_check.yml` | The same sources with Arm Toolchain for Embedded. GNU `as` accepts non-canonical assembly forms that LLVM's assembler rejects, and ZoneX is going to be substantially assembly. | | `zx_fvp.yml` | Builds the Cortex-R52 images and **executes** them on the Armv8-R AEM FVP, judging each by its self-reported result. There is no static check for "the partition still runs". | diff --git a/scripts/install.sh b/scripts/install.sh index 6c66300..80216b9 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -65,10 +65,25 @@ retry() { } retry sudo "${TIMEOUT[@]}" apt-get "${APT_OPTIONS[@]}" update +# gcc-14 and g++-14 EXPLICITLY, alongside build-essential. +# +# build-essential on ubuntu-24.04 is GCC 13, and GCC 14 is the compiler the +# project names as its Linux reference -- so installing only build-essential +# left CONTRIBUTING.md claiming a version CI did not have. Both are +# installed: build-essential brings the rest of the toolchain furniture that +# gcc-14 alone does not. +# +# This does NOT rewire the default `gcc`, deliberately. A dependency +# installer should not change which compiler every other build on the machine +# picks up. The workflows name CC and CXX where the version has to be exact; +# a local build uses whatever `gcc` you have, which is what the "verified +# once it passes with the versions above" wording in CONTRIBUTING.md is for. retry sudo "${TIMEOUT[@]}" apt-get "${APT_OPTIONS[@]}" install -y \ --no-install-recommends \ build-essential \ cmake \ + g++-14 \ + gcc-14 \ git \ ninja-build \ python3 \ From dcf5b328b92b54f27671baf4a3cdce10e0b9a352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 9 Sep 2026 11:15:34 -0400 Subject: [PATCH 37/40] Took the project's resourcing out of the source comments Five places said the certification back end is funded, or called work "funded": two in comments on the partition-switch path and the validator, and three in docs/coverage.md and docs/wcet-inputs.md. All five publish with the component. None of them broke a rule and both checkers passed on all five. But a statement about a project's budget is not a fact about the code, and a header comment is an odd place to assert one -- it dates faster than anything around it, it is not something a contributor can verify or act on, and where it sat it was doing no work: what those comments needed to say is that the code is INTENDED FOR CERTIFICATION, which is why MC/DC coverage has to stay achievable and why an indirect call does not belong on the switch path. That reasoning stands on its own and now says so without the budget. The two headings lose it too. "Where the funded work should start" is a list of the places a worst-case-execution-time analysis would begin from; whether that work is resourced is somebody else's question and does not change the list. "Structural coverage of target code belongs to the certification phase" says what the section says and nothing more. Comments only, plus two headings. No behaviour changes, and nothing that rests on the reasoning moves: verified with both cross builds, the host suite at 100% of lines and branches under GCC 14, and both repository checks. Assisted-by: Claude Code (Opus 5) --- core/inc/zx_mm.h | 2 +- core/src/zx_manifest_verify.c | 4 ++-- docs/coverage.md | 2 +- docs/wcet-inputs.md | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/core/inc/zx_mm.h b/core/inc/zx_mm.h index 43c40ea..eb681bd 100644 --- a/core/inc/zx_mm.h +++ b/core/inc/zx_mm.h @@ -47,7 +47,7 @@ /* it can be if it is interleaved with CP15 writes. The alternative */ /* -- passing the programmer in as a function pointer -- would keep */ /* one function but put an indirect call on the partition-switch path */ -/* of a codebase whose certification back end is funded. */ +/* of a codebase intended for certification. */ /* */ /**************************************************************************/ diff --git a/core/src/zx_manifest_verify.c b/core/src/zx_manifest_verify.c index cf38dd8..f72a968 100644 --- a/core/src/zx_manifest_verify.c +++ b/core/src/zx_manifest_verify.c @@ -45,8 +45,8 @@ /* */ /* Every condition is its own if with a single predicate. That costs */ /* nothing at run time and it is what makes MC/DC coverage achievable */ -/* later, on a codebase whose certification back end is funded. There */ -/* are no compound predicates and no side effects inside a condition. */ +/* in the certification phase. There are no compound predicates and */ +/* no side effects inside a condition. */ /* */ /* There is no goto: project policy forbids it. The function returns */ /* on first failure instead, which is also the behaviour the callers */ diff --git a/docs/coverage.md b/docs/coverage.md index d62d27d..8659010 100644 --- a/docs/coverage.md +++ b/docs/coverage.md @@ -102,7 +102,7 @@ reached ones do what they claim. Not measured and not a target. They are the test harness, not the product. -### Structural coverage of target code is a later, funded concern +### Structural coverage of target code belongs to the certification phase MC/DC and structural coverage of the port belong to the certification phase and need their own tooling — an instrumenting target build, or a trace-based diff --git a/docs/wcet-inputs.md b/docs/wcet-inputs.md index 1ff1783..dc25c9d 100644 --- a/docs/wcet-inputs.md +++ b/docs/wcet-inputs.md @@ -24,7 +24,7 @@ outside the hypervisor controls, an early exit, conditional cache maintenance, a print in a fault path. This is not a WCET analysis. It is the list of places where one would have to -start, with what is known about each, so that the funded work begins from +start, with what is known about each, so that later work begins from measurements rather than from a fresh reading of the source. Figures are from the S32Z280-594EVB: 8 MHz system counter, 48.19 MHz core @@ -210,7 +210,7 @@ comparison. --- -## 4. Where the funded work should start +## 4. Where the next work should start 1. ~~Measure the console driver's guard spin and bound it in counter counts.~~ **Done, and it was the wrong suspect** — the guard has never spun. Both From 7637fdafe518dc2ad1f1542b820388206975a8ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 9 Sep 2026 11:25:15 -0400 Subject: [PATCH 38/40] Fixed the two lanes the first pull request run failed Both failures were real and neither reproduced on this machine before it was made to behave like a runner. That is the finding worth keeping. THE ATfE PATH NEVER CROSSED INTO THE GUEST SUB-BUILD. ZoneX's clang toolchain file takes ZX_ATFE_TOOLCHAIN_PATH; the ThreadX one the guest images are built with takes ATFE_TOOLCHAIN_PATH, without the prefix. Nothing bridged the two, and an ExternalProject inherits nothing that is not listed in its CMAKE_ARGS -- which the file next door already says, about warnings-as-errors, for the same reason. So the guest fell back to whatever clang was on PATH. On a machine that keeps ATfE at the default location that is the right compiler, which is why this built here and has probably never been exercised anywhere else: the workflows trigger on dev and main, and a fork's feature branch reaches neither, so the clang lane had never seen this code until the pull request opened. On the runner, ATfE is unpacked into the workspace and the default does not exist, so it picked the system clang and could not compile for a bare-metal Cortex-R52. The compiler's own directory is forwarded rather than the variable, because that is right however the toolchain file found it -- passed on the command line, or defaulted. Both boards, since both configure guest sub-projects. AND THE COVERAGE FLOOR BROKE ON A gcov THAT DID NOT MATCH ITS gcc. That one is mine, from the commit before: pinning CC=gcc-14 without pinning gcov left gcovr shelling out to the image's default gcov, which refuses a .gcno written by a different major -- "version 'B42*', prefer 'B33*'" -- and ends the run with no coverage rather than with wrong coverage. run.sh now derives gcov from CC when CC names a versioned gcc, and fails loudly if the matching gcov is absent rather than producing that error a second time. A run with CC unset is unchanged, so a plain local invocation behaves as it did. Verified by reproducing both conditions rather than by reasoning about them: the clang lane with HOME pointed at an empty directory so the default ATfE path is unavailable, and coverage from a clean build tree with CC=gcc-14 -- 974 of 974 lines and 469 of 469 branches, and again with CC unset. Both GCC cross builds and both repository checks still pass. Assisted-by: Claude Code (Opus 5) --- examples/fvp_baser_aemv8r/CMakeLists.txt | 21 ++++++++++++++++ examples/s32z280_evb/CMakeLists.txt | 21 ++++++++++++++++ test/host/run.sh | 31 ++++++++++++++++++++++-- 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/examples/fvp_baser_aemv8r/CMakeLists.txt b/examples/fvp_baser_aemv8r/CMakeLists.txt index 28ab6b6..a21a0c4 100644 --- a/examples/fvp_baser_aemv8r/CMakeLists.txt +++ b/examples/fvp_baser_aemv8r/CMakeLists.txt @@ -237,6 +237,7 @@ if(ZX_THREADX_SOURCE_DIR) CMAKE_GENERATOR ${CMAKE_GENERATOR} CMAKE_ARGS -DCMAKE_TOOLCHAIN_FILE=${ZX_GUEST_TOOLCHAIN} + ${ZX_GUEST_ATFE_ARG} -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} -DZX_THREADX_SOURCE_DIR=${ZX_THREADX_SOURCE_DIR} -DZX_GUEST_COMMON_DIR=${ZX_COMMON_DIR} @@ -294,6 +295,26 @@ if(ZX_THREADX_SOURCE_DIR) ${ZX_THREADX_SOURCE_DIR}/cmake/cortex_r52.cmake) endif() + # AND THE COMPILER'S LOCATION HAS TO CROSS THE BOUNDARY UNDER ITS OTHER + # NAME. ZoneX's clang toolchain file takes ZX_ATFE_TOOLCHAIN_PATH; + # ThreadX's takes ATFE_TOOLCHAIN_PATH, without the prefix. Nothing + # bridged the two, so the guest sub-build fell back to whatever `clang` + # was on PATH -- which on a machine that keeps ATfE at the default + # location happens to be right, and on a CI runner that unpacks it into + # the workspace is the system clang, unable to compile for a bare-metal + # Cortex-R52. The lane passed locally and failed in CI for exactly that + # reason. + # + # The compiler's own directory is forwarded rather than the variable, + # because that is correct however the toolchain file found it: passed on + # the command line, or defaulted. + if(CMAKE_C_COMPILER_ID STREQUAL "Clang") + get_filename_component(ZX_GUEST_ATFE_BIN "${CMAKE_C_COMPILER}" DIRECTORY) + set(ZX_GUEST_ATFE_ARG -DATFE_TOOLCHAIN_PATH=${ZX_GUEST_ATFE_BIN}) + else() + set(ZX_GUEST_ATFE_ARG "") + endif() + zx_add_guest_project(guest_a ${ZX_PART_A_BASE} ${ZX_PART_A_SIZE}) zx_add_guest_project(guest_b ${ZX_PART_B_BASE} ${ZX_PART_B_SIZE}) diff --git a/examples/s32z280_evb/CMakeLists.txt b/examples/s32z280_evb/CMakeLists.txt index 10b4e16..a6bb802 100644 --- a/examples/s32z280_evb/CMakeLists.txt +++ b/examples/s32z280_evb/CMakeLists.txt @@ -208,6 +208,7 @@ if(ZX_THREADX_SOURCE_DIR) CMAKE_GENERATOR ${CMAKE_GENERATOR} CMAKE_ARGS -DCMAKE_TOOLCHAIN_FILE=${ZX_GUEST_TOOLCHAIN} + ${ZX_GUEST_ATFE_ARG} -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} -DZX_THREADX_SOURCE_DIR=${ZX_THREADX_SOURCE_DIR} -DZX_GUEST_COMMON_DIR=${ZX_COMMON_DIR} @@ -236,6 +237,26 @@ if(ZX_THREADX_SOURCE_DIR) ) endfunction() + # AND THE COMPILER'S LOCATION HAS TO CROSS THE BOUNDARY UNDER ITS OTHER + # NAME. ZoneX's clang toolchain file takes ZX_ATFE_TOOLCHAIN_PATH; + # ThreadX's takes ATFE_TOOLCHAIN_PATH, without the prefix. Nothing + # bridged the two, so the guest sub-build fell back to whatever `clang` + # was on PATH -- which on a machine that keeps ATfE at the default + # location happens to be right, and on a CI runner that unpacks it into + # the workspace is the system clang, unable to compile for a bare-metal + # Cortex-R52. The lane passed locally and failed in CI for exactly that + # reason. + # + # The compiler's own directory is forwarded rather than the variable, + # because that is correct however the toolchain file found it: passed on + # the command line, or defaulted. + if(CMAKE_C_COMPILER_ID STREQUAL "Clang") + get_filename_component(ZX_GUEST_ATFE_BIN "${CMAKE_C_COMPILER}" DIRECTORY) + set(ZX_GUEST_ATFE_ARG -DATFE_TOOLCHAIN_PATH=${ZX_GUEST_ATFE_BIN}) + else() + set(ZX_GUEST_ATFE_ARG "") + endif() + zx_add_guest_project(guest_a ${ZX_PART_A_BASE} ${ZX_PART_A_SIZE}) zx_add_guest_project(guest_b ${ZX_PART_B_BASE} ${ZX_PART_B_SIZE}) diff --git a/test/host/run.sh b/test/host/run.sh index 3a36910..b594220 100755 --- a/test/host/run.sh +++ b/test/host/run.sh @@ -85,7 +85,34 @@ case "${command}" in # and the symptom is a SanityCheckError naming a file that "doesn't # exist but no error from GCOV detected". ThreadX paid for that one. mkdir -p "${ROOT}/coverage_report" - gcovr --root "${ROOT}" \ + + # THE gcov MUST MATCH THE gcc THAT INSTRUMENTED. gcovr shells out to + # plain `gcov` unless told otherwise, and gcov refuses a .gcno written + # by a different major -- "version 'B42*', prefer 'B33*'" -- which + # ends the run with no coverage rather than with wrong coverage. + # + # CI builds with CC=gcc-14 while the image's default gcc is 13, so + # this is not hypothetical: it is what the first pull request run + # failed on. Derive gcov from CC when CC names a versioned gcc, and + # otherwise leave gcovr to its default so a plain local run is + # unchanged. + gcov_args=() + case "${CC:-}" in + *gcc-[0-9]*) + gcov_candidate="${CC/gcc-/gcov-}" + if command -v "${gcov_candidate}" >/dev/null 2>&1; then + gcov_args=(--gcov-executable "${gcov_candidate}") + echo "Using ${gcov_candidate} to match ${CC}." + else + echo "run.sh: ${CC} is set but ${gcov_candidate} is not" >&2 + echo "run.sh: installed; coverage would fail on a gcov" >&2 + echo "run.sh: version mismatch." >&2 + exit 1 + fi + ;; + esac + + gcovr "${gcov_args[@]}" --root "${ROOT}" \ --filter "${ROOT}/core/" \ --filter "${ROOT}/common/" \ --html-details "${ROOT}/coverage_report/index.html" \ @@ -154,7 +181,7 @@ case "${command}" in # dying with a threshold the reader has to go and look up. echo "" echo "Enforcing the coverage floor on the fully reachable core:" - gcovr --root "${ROOT}" \ + gcovr "${gcov_args[@]}" --root "${ROOT}" \ --filter "${ROOT}/core/src/zx_fault.c" \ --filter "${ROOT}/core/src/zx_fault_log.c" \ --filter "${ROOT}/core/src/zx_guest_console.c" \ From 53f4b9fa5135b2d89b9a6cf5d55ed61098b05ba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 9 Sep 2026 12:11:28 -0400 Subject: [PATCH 39/40] Titled the MISRA blocks by what each of them actually contains Every one of the seventeen was headed "MISRA C:2012 deviations (justified)", and several contained the opposite: an argument that the rule is OBSERVED. Two said so outright -- "observed rather than violated" -- and others described assembly isolated in a one-line function, which is Directive 4.3 being complied with rather than deviated from. The cost of that was a heading nobody could count from, and not slightly: Directive 4.3 appears in eleven blocks and exactly two of them are deviations. Anyone auditing by grepping the headings would have found eleven. Three headings now, chosen per block by reading it: MISRA C:2012 deviations (justified) 7 blocks, one of which records having none MISRA C:2012 notes: deviations and compliance 7 blocks carrying both MISRA C:2012 compliance notes 3 blocks arguing a rule is observed, recorded because the question arises there Ten blocks retitled; the seven that were already accurate are untouched. Comment text is otherwise unchanged -- no justification was rewritten, added or removed, and the box width of each line is preserved exactly, including the three files whose header row was already one column wider than its neighbours. Verified as a no-op on the build: host suite at 974 of 974 lines and 469 of 469 branches under GCC 14, both cross builds, both repository checks. Assisted-by: Claude Code (Opus 5) --- core/src/zx_fault.c | 2 +- examples/common/zx_guest_launch.c | 2 +- examples/guest_common/zx_guest_bsp.c | 2 +- platform/cortex_r52/src/zx_cache.c | 2 +- platform/cortex_r52/src/zx_console.c | 2 +- platform/cortex_r52/src/zx_el2_cpu.c | 2 +- platform/cortex_r52/src/zx_stage2_mpu.c | 2 +- platform/cortex_r52/src/zx_timer.c | 6 +++--- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/core/src/zx_fault.c b/core/src/zx_fault.c index 9adc838..5eb9639 100644 --- a/core/src/zx_fault.c +++ b/core/src/zx_fault.c @@ -30,7 +30,7 @@ /* matters more for this file than for most, because the alternative */ /* is testing a fault decoder by provoking faults. */ /* */ -/* MISRA C:2012 deviations (justified) */ +/* MISRA C:2012 notes: deviations and compliance */ /* */ /* Rule 15.5 (single point of exit) -- deviated in the two name */ /* lookups below, which return from inside a switch. The */ diff --git a/examples/common/zx_guest_launch.c b/examples/common/zx_guest_launch.c index 4a3268a..a2822ac 100644 --- a/examples/common/zx_guest_launch.c +++ b/examples/common/zx_guest_launch.c @@ -25,7 +25,7 @@ /* example image that launches one. See zx_guest_launch.h for why it */ /* is shared and what is deliberately left with each experiment. */ /* */ -/* MISRA C:2012 deviations (justified) */ +/* MISRA C:2012 notes: deviations and compliance */ /* */ /* Rule 11.4/11.6 -- casting a partition window's address to a */ /* volatile pointer is inherent to a hypervisor writing a guest's */ diff --git a/examples/guest_common/zx_guest_bsp.c b/examples/guest_common/zx_guest_bsp.c index b3f9fec..ad57fab 100644 --- a/examples/guest_common/zx_guest_bsp.c +++ b/examples/guest_common/zx_guest_bsp.c @@ -60,7 +60,7 @@ /* and read a clock; it may not decide which interrupts exist or how */ /* fast time runs. */ /* */ -/* MISRA C:2012 deviations (justified) */ +/* MISRA C:2012 notes: deviations and compliance */ /* */ /* Directive 4.3 -- every asm statement below is one hypercall or one */ /* coprocessor operation, in a function that does nothing else. */ diff --git a/platform/cortex_r52/src/zx_cache.c b/platform/cortex_r52/src/zx_cache.c index 5d1267b..505d635 100644 --- a/platform/cortex_r52/src/zx_cache.c +++ b/platform/cortex_r52/src/zx_cache.c @@ -71,7 +71,7 @@ /* mistake available for no benefit: nothing in ZoneX wants one */ /* without the other. */ /* */ -/* MISRA C:2012 deviations (justified) */ +/* MISRA C:2012 notes: deviations and compliance */ /* */ /* Directive 4.3 (assembly shall be encapsulated and isolated) -- */ /* observed rather than violated: every asm statement below is one */ diff --git a/platform/cortex_r52/src/zx_console.c b/platform/cortex_r52/src/zx_console.c index 2affed9..4e8959e 100644 --- a/platform/cortex_r52/src/zx_console.c +++ b/platform/cortex_r52/src/zx_console.c @@ -44,7 +44,7 @@ /* decision, docs/decisions.md D8, deliberately left to the step that */ /* first has two guests wanting it. */ /* */ -/* MISRA C:2012 deviations (justified) */ +/* MISRA C:2012 notes: deviations and compliance */ /* */ /* Directive 4.3 -- the single asm statement is alone in */ /* zx_semihost_call and appears nowhere else in this file. */ diff --git a/platform/cortex_r52/src/zx_el2_cpu.c b/platform/cortex_r52/src/zx_el2_cpu.c index 9898c0f..29b5515 100644 --- a/platform/cortex_r52/src/zx_el2_cpu.c +++ b/platform/cortex_r52/src/zx_el2_cpu.c @@ -36,7 +36,7 @@ /* their call sites: the EL1 count lives in MPUIR[15:8] and the EL2 */ /* count in HMPUIR[7:0]. Different registers, different fields. */ /* */ -/* MISRA C:2012 deviations (justified) */ +/* MISRA C:2012 notes: deviations and compliance */ /* */ /* Directive 4.3 (assembly shall be encapsulated and isolated) -- */ /* observed rather than violated: every asm statement in this file is */ diff --git a/platform/cortex_r52/src/zx_stage2_mpu.c b/platform/cortex_r52/src/zx_stage2_mpu.c index a63733c..3e1a280 100644 --- a/platform/cortex_r52/src/zx_stage2_mpu.c +++ b/platform/cortex_r52/src/zx_stage2_mpu.c @@ -55,7 +55,7 @@ /* Both are masked in zx_region_bar/zx_region_lar below, which is why */ /* programming a region is a function call and not two stores. */ /* */ -/* MISRA C:2012 deviations (justified) */ +/* MISRA C:2012 notes: deviations and compliance */ /* */ /* Directive 4.3 -- every asm statement below is alone in a function */ /* that does nothing else. */ diff --git a/platform/cortex_r52/src/zx_timer.c b/platform/cortex_r52/src/zx_timer.c index 90ebfa7..8fb670f 100644 --- a/platform/cortex_r52/src/zx_timer.c +++ b/platform/cortex_r52/src/zx_timer.c @@ -53,7 +53,7 @@ /* context block; zx_context.c holds that version, using the same */ /* arithmetic against the same register. */ /* */ -/* MISRA C:2012 deviations (justified) */ +/* MISRA C:2012 compliance notes */ /* */ /* Directive 4.3 -- each asm statement below is one coprocessor */ /* operation in a function that does nothing else. */ @@ -384,7 +384,7 @@ void zx_el2_dwell(uint32_t counts) /* gap is the other partition's window, and closing it is what makes */ /* the two clocks independent. */ /* */ -/* MISRA C:2012 deviations (justified) */ +/* MISRA C:2012 compliance notes */ /* */ /* Directive 4.3 -- CNTVOFF is reachable only through a 64-bit */ /* coprocessor transfer, and every such access is encapsulated in */ @@ -634,7 +634,7 @@ void zx_el2_guest_timer_stop(void) /* miss that the checking itself caused. One read, taken after the */ /* comparator is set, is both the cheapest and the only honest answer. */ /* */ -/* MISRA C:2012 deviations (justified) */ +/* MISRA C:2012 compliance notes */ /* */ /* Directive 4.3 -- CNTHP_CVAL is reachable only through a 64-bit */ /* coprocessor transfer, encapsulated here. */ From 9097d6f4f5449f88cce97dcbacd154c059fefa7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Desbiens?= Date: Wed, 9 Sep 2026 12:11:47 -0400 Subject: [PATCH 40/40] Added the Phase-0 evidence summary and the MISRA deviation index Two documents for two readers neither of the existing ones serves. docs/phase-0-evidence.md answers "what did this establish, what did it cost, and what should be built next" in one place. README.md is written for someone cloning the repository, docs/decisions.md for someone changing the code, and the documentation site for a ThreadX user; none of them is a summary of the evidence. It carries the claim and the non-claim, the six-phase run that is the central exhibit, the numbers with their conditions, the eleven builds that must fail, the certification posture, and what the measurements de-risk. It cites rather than restates wherever a derivation already has a home, so what it adds is one copy of the measured figures rather than one copy of the reasoning. What it will not do is imply more than was measured: interrupt latency is not measured at all, there is no structural coverage of the port, boot to first partition entry is not instrumented, and each of those is said in the document rather than left to be noticed. It also carries the maintenance rule the repository lacked. A silicon figure lives in four prose files that have to move together, and two further copies carry the DERIVED bound instead and do not move with a run. That distinction has been paid for twice in one week; it is now written down. docs/misra-deviations.md is the index over the per-site annotations. The convention -- name the rule, justify it where it occurs -- was already met, but a per-site record cannot answer how many deviations there are or where they cluster. There are EIGHTEEN, across seven rules, and the useful fact is the distribution: every one is in the port, the board support or the examples except two, a single-point-of-exit deviation in the fault decoder and one narrowing assignment in the schedule printer. Neither touches isolation or timing, so the architecture-independent core that carries the coverage floor is very nearly deviation-free and the deviations sit where the code meets the hardware. Compiling it is what found the heading problem fixed in the commit before, and it lists the compliance notes separately so that the count is not inflated by them. Both are explicit about what they are not. The deviation index is assembled by reading source rather than by a checking tool, so it is evidence of discipline and not of coverage; a qualified record, tool-produced against a stated rule set and accounting for every rule rather than only the deviated ones, belongs to the certification package and does not exist. Assisted-by: Claude Code (Opus 5) --- docs/misra-deviations.md | 201 +++++++++++++++++++++++++++++++++++++ docs/phase-0-evidence.md | 211 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 412 insertions(+) create mode 100644 docs/misra-deviations.md create mode 100644 docs/phase-0-evidence.md diff --git a/docs/misra-deviations.md b/docs/misra-deviations.md new file mode 100644 index 0000000..8007116 --- /dev/null +++ b/docs/misra-deviations.md @@ -0,0 +1,201 @@ + + +# MISRA C deviations, and the compliance notes that are not deviations + +*Eclipse ThreadX ZoneX. Compiled from the source annotations, 9 September 2026.* + +Project convention is that a deviation from MISRA C is explicit, names the rule +it circumvents, and justifies it where it occurs. That convention is met: every +file carrying a deviation carries a block naming the rule and the reason, and +one file affirmatively records that it has **none**. + +This document is the index over those blocks. It exists because a per-site +record answers "why is this line like this?" and cannot answer "what does this +codebase deviate from, and how many times?" — which is the question a reviewer +or an assessor asks first. + +**What it is not.** It is not a compliance matrix and not a certification +deliverable. It is assembled by reading the source, not by a checking tool, so +it is evidence of intent and of discipline rather than of coverage. A qualified +deviation record, produced by a tool against a stated rule set with every rule +accounted for and not only the deviated ones, belongs to the certification +package and does not exist yet. Nothing here should be read as standing in for +it. + +--- + +## The classification problem, and what it cost to fix + +Compiling this index found that **the in-code blocks were all headed "MISRA +C:2012 deviations (justified)", and several of them contained the opposite: an +argument that the rule is *observed*.** Two said so outright — "observed rather +than violated". Others described assembly isolated in a one-line function, +which is Directive 4.3 being *complied with*, not deviated from. + +That mattered because anyone counting deviations from those headings would +overcount, and not slightly: Directive 4.3 appears in eleven blocks and only +**two** of them are deviations. + +The headings now say what each block contains: + +| Heading | Blocks | What it means | +|---|---|---| +| `MISRA C:2012 deviations (justified)` | 7 | Deviations only. One of the seven records having **none** | +| `MISRA C:2012 notes: deviations and compliance` | 7 | Both, in the same block | +| `MISRA C:2012 compliance notes` | 3 | A rule observed, recorded because the question arises at that line | + +So the heading is now load-bearing rather than decorative, and a reader can +count from it. The classification below is by what each entry argues, which is +now the same thing. + +--- + +## Genuine deviations + +### Rule 11.4 and Rule 11.6 — conversions between a pointer and an integer + +**Eight sites.** The most frequent deviation, and it has two distinct causes. + +*Memory-mapped device access*, in `platform/cortex_r52/src/zx_gic.c`, +`examples/s32z280_evb/zx_board.c` and `examples/s32z280_evb/zx_platform.h`. +Casting an integer address to a volatile pointer is inherent to reaching a +peripheral register. In each case the cast is confined to a single accessor +macro — `ZX_GIC_REG`, `ZX_REG32` — and appears nowhere else in the file. + +*Describing memory to an MPU*, in `examples/common/zx_report.c`, +`examples/common/zx_probe_main.c` and `examples/guest_common/zx_guest_bsp.c`. +A linker symbol's address has to become an integer for a region descriptor to +carry it. Confined to `zx_symbol_address`. + +*Writing a guest's memory*, in `examples/common/zx_guest_launch.c` — a +hypervisor loading a partition necessarily writes through an address it computed +— and in `examples/guest_common/zx_guest_app.c`, where a mailbox word is read +as an integer and then used as a pointer. The second names its reasoning: the +conversion gets a name and a line of its own rather than a cast stack inside an +initialiser, which is also what `-Wbad-function-cast` asks for. + +**Why it is accepted:** there is no standard C construct that reaches a +memory-mapped register or turns a linker symbol into a region bound. The +mitigation in every case is confinement — one accessor, named, with the cast +nowhere else. + +### Rule 1.1 and Rule 1.2 — language extensions + +**Four files:** `zx_cache.c`, `zx_console.c`, `zx_el2_cpu.c`, `zx_stage2_mpu.c`. + +Inline assembly, and in one case register-asm bindings. No standard C construct +reaches a CP15 coprocessor register, a cache maintenance operation or a +semihosting trap. The extensions used are GCC's and are accepted by the two +toolchains the project builds with, both of which CI exercises. + +### Rule 15.5 — a function should have a single point of exit + +**One file:** `core/src/zx_fault.c`, in the two syndrome-name lookups, which +return from inside a `switch`. + +The justification is worth quoting because it argues from readability rather +than convenience: the alternative is a local pointer assigned in every case and +returned once, which "adds a variable and a store per lookup and makes a missing +case harder to see, not easier." + +### Rule 8.9 — an object should be defined at block scope if possible + +**One file:** `platform/cortex_r52/src/zx_frame.c`. The frame pointer is +file-scope because the boundary handler is reached from a **vector**, which +hardware calls and can be passed nothing. + +### Rule 10.3 — assignment to a narrower essential type + +**One site:** `core/src/zx_schedule.c`, printing a major-frame figure. + +Deviated by making the conversion **explicit**, which converts it into a Rule +10.8 cast a reader can see and account for. The alternative — a 64-bit console +primitive — would put a 64-bit divide in the hypervisor's console driver to +print a number that never needs one. + +### Rule 2.5 (advisory) — a project should contain no unused macro + +**One site:** `examples/s32z280_evb/zx_board.c`, `ZX_UARTCR_PCE`. + +The macro is defined and never used, deliberately: the bit's **absence** from +the value written is the decision, because the boot ROM leaves parity enabled +and the console has to clear it. A bit that has to be off is worth naming beside +the bits that have to be on. `-Wunused-macros` reports it, so the deviation is +visible in every build rather than silent. + +### Directive 4.3 — assembly shall be encapsulated and isolated + +**Two files deviate:** `platform/cortex_r52/src/zx_context.c`, where coprocessor +access appears inside the EL1 MPU loop as well as in accessor macros, so the +assembly is not isolated in a function that does nothing else; and +`platform/cortex_r52/src/zx_gic.c`, whose barriers are single instructions +placed where they are needed. + +Every other Directive 4.3 entry in the tree is a compliance note — see below. + +--- + +## Compliance notes, recorded where a reader would otherwise ask + +These are **not** deviations. They are in the source because the question comes +up at that line, and they are listed here so that the count above is not +inflated by them. + +| Rule | Where | What it records | +|---|---|---| +| Directive 4.3 | `zx_cache.c`, `zx_el2_cpu.c` | "Observed rather than violated" — each `asm` is one coprocessor operation in a one-line static function | +| Directive 4.3 | `zx_console.c`, `zx_stage2_mpu.c`, `zx_timer.c` (×3), `zx_guest_bsp.c`, `zx_guest_launch.c` | The same argument: every `asm` statement is alone in a function that does nothing else. The two 64-bit timer accessors are named individually | +| Rule 16.1 | `core/src/zx_fault.c` | Every `switch` is well formed — each clause a single `return`, and a `default` present — recorded beside the Rule 15.5 deviation it sits with | +| Rule 8.4 | `examples/guest_common/zx_guest_bsp.h` | Why the functions are declared at all: a definition with no prototype is a Rule 8.4 finding, and the caller is assembly, which cannot be checked against a signature either way | +| Rule 8.5 | `examples/common/zx_probe.h` | Why `zx_board_init` is **not** declared here: a second declaration was a Rule 8.5 violation and was removed. "Two declarations of the same function agree until somebody changes one of them" | + +## And one file records having none + +`core/src/zx_partition_manager.c` states outright that it has no deviations: +"every function here is straight-line arithmetic over a manifest with a single +loop and no pointer arithmetic beyond array subscripting." + +An affirmative "none" is worth more than silence, because silence is +indistinguishable from nobody having looked. + +--- + +## Summary + +| Rule or directive | Deviating sites | +|---|---| +| Rule 11.4 / 11.6 — pointer-integer conversion | 8 | +| Rule 1.1 / 1.2 — language extensions | 4 | +| Directive 4.3 — assembly encapsulation | 2 | +| Rule 15.5 — single point of exit | 1 | +| Rule 8.9 — block-scope objects | 1 | +| Rule 10.3 — narrowing assignment | 1 | +| Rule 2.5 (advisory) — unused macro | 1 | + +**Every deviation is in the port, the board support or the examples, with two +exceptions in `core/src`:** the Rule 15.5 returns in the fault decoder and the +Rule 10.3 narrowing in the schedule printer. Neither touches isolation or +timing. That distribution is the useful fact in this document: the +architecture-independent core — the part held to a 100% line and branch +coverage floor — is very nearly deviation-free, and the deviations cluster where +the code meets the hardware, which is where they are unavoidable. + +## Keeping this document honest + +It is compiled by reading the source, so it goes stale silently. When a +deviation is added or removed, this index changes with it. `docs/coverage.md` +lists the files held to the coverage floor; a deviation appearing in one of +those is worth a second look, because those files are meant to be plain C. diff --git a/docs/phase-0-evidence.md b/docs/phase-0-evidence.md new file mode 100644 index 0000000..3bc97df --- /dev/null +++ b/docs/phase-0-evidence.md @@ -0,0 +1,211 @@ + + +# ZoneX Phase 0 — what it demonstrates, and what it cost + +*A deterministic partitioning hypervisor for Armv8-R. Evidence for the decision about what to build next.* + +Version 0.1.0.202603 · measured on an NXP S32Z280-594EVB and the Armv8-R AEM FVP · September 2026 + +--- + +## 1. The claim, and the non-claim + +Two ThreadX kernels run at EL1 on one logical Cortex-R52 core, each confined to its own stage-2 window, time-sharing the core under a static major frame taken from a manifest. Neither can read, write or execute the other's memory or the hypervisor's — not even after granting itself that memory in its own EL1 MPU. A violation is caught by the stage-2 MPU at EL2 and reported with the partition, the address and the guest PC, and the system halts. Each partition's virtual time advances only inside its own windows. + +**Nothing a partition does through the schedule reaches its neighbour.** Computing, masking its own interrupts and violating its boundary without pause each move the critical partition's period by tens of counts. + +**What reaches it is the hypervisor's own console driver.** A guest that prints moves that period by up to one line tag — 22 bytes at 115,200 8N1, 17,640 counts of the board's 8 MHz counter — every run. That is a defect in ZoneX, not a limit of the partitioning, and it is bounded, derived and reproducible. + +### And what it does not demonstrate + +Dual-core lockstep presents as **one logical core**, so this is temporal and memory partitioning on a single core. It is **not** spatial partitioning across multiple cores; that needs split-mode SMP and is deferred. + +**Interrupt latency is not measured at all.** Guest interrupts go straight to EL1 and cost what they always did; bounding them needs the GIC List Registers this core has and this phase does not use. Interrupt virtualisation with a bounded worst-case latency, inter-partition communication, the full time-partition scheduler, supervised partition restart, TraceX integration and the safety-artifact package are later phases and are not here. + +**There is no structural coverage of the port.** The architecture-independent core is held to a 100% line-and-branch floor enforced by the build. The port cannot be measured that way from a workstation, and instrumenting it on the target would change the code generation of the thing being measured. Its evidence is the must-fail build list in section 4. MC/DC and structural coverage of the port need their own tooling and no number here stands in for them. + +Every timing figure below comes from one part on one bench, with the EL2 caches off, built `-Og`, and with no clock tree configured — the core runs on the part's power-up RC oscillator. Read them as a first measurement with its conditions stated, not as characterisation across a population. + +--- + +## 2. The central exhibit: one run, six phases + +A regression sweeps fourteen isolation cases in one run — seven in each direction, each aimed at an address of its own — and measures the critical partition's window period **continuously** while the untrusted partition is steered through five behaviours by a mailbox word it re-reads on every iteration of its own loop. + +One run in phases rather than one run per behaviour, deliberately: comparing across six boots compares runs, and a difference between two runs has a dozen explanations that a difference between two phases of one run does not. + +S32Z280-594EVB · 8 MHz system counter · window 80,000 counts · major frame 800,000 · 600 frames, 100 per phase + +| While the untrusted partition is… | min | mean | max | spread | A's own clock / core time given | +|---|---|---|---|---|---| +| booting *(warm-up, discarded)* | 799,979 | 800,005 | 800,520 | 541 | 55,949,461 / 55,949,965 | +| idle | 799,989 | 799,999 | 800,011 | **22** | 55,949,413 / 55,949,423 | +| in a tight compute loop | 799,990 | 799,999 | 800,010 | **20** | 55,949,623 / 55,949,606 | +| computing, IRQ **and** FIQ masked | 799,989 | 799,999 | 800,013 | **24** | 55,949,494 / 55,949,528 | +| storming the console | 791,111 | 800,085 | 809,062 | **17,951** | 55,522,772 / 55,522,773 | +| violating its boundary every iteration | 799,922 | 800,000 | 800,107 | **185** | 55,933,614 / 55,933,590 | + +**The first phase is discarded and still printed**, so that what was discarded is visible rather than taken on trust. **The maximum is the number, not the mean** — a schedule has to survive the worst period it will ever see. + +Read the last column against the fourth: in every phase the critical partition's own virtual clock advanced by the core time the hypervisor knows it was given, and by none of the time it was not. That is the temporal claim in its sharpest form, and it fails separately from the period — the build with the time freeze removed keeps a perfectly steady period while its clock runs three windows per frame fast. + +### The comparison that carries the claim + +In the fault phase the untrusted partition committed **103,836 boundary violations** — each one caught by the stage-2 MPU, decoded at EL2, and resumed past under a test-only continue mode — and its neighbour's period moved by **185 counts out of 800,000**. + +### And the one row that is the hypervisor's fault + +The same run measures what the console was doing, per phase: + +| phase | longest console hypercall | bytes it wrote | hypercalls | boundary lateness | +|---|---|---|---|---| +| idle | 0 | 0 | 0 | 45 .. 47 | +| computing | 0 | 0 | 0 | 45 .. 47 | +| IRQ+FIQ masked | 0 | 0 | 0 | 45 .. 48 | +| storming the console | **106,116 cycles** | **22** | 16,801 | 45 .. **9,134** | +| violating its boundary | 105,714 cycles | 22 | 111 | 45 .. 183 | + +A guest's console is one hypercall per character, answered at EL2 with `PSTATE.F` set, so the FIQ that ends a window waits for it. Nearly every hypercall writes the one byte the guest asked for. **The one that opens a line writes twenty-two** — the newline a deferred close still owed, the tag naming the partition, and the guest's own character. + +The bound is one line tag, and it is **derived rather than observed**: a period is a difference between two window entries, so a constant deferral cancels and only a *change* reaches the number — one long period and one short correction, 35,280 counts against a half-window bound of 40,000. Every other phase is held to one eighth of a window. `docs/decisions.md` D31 carries the derivation and the measurements it rests on; `docs/wcet-inputs.md` §2.1 carries what a WCET argument needs from it. + +**It reproduces.** An independent run six days earlier, on an earlier revision, gave 21, 21, 22, 17,830 and 304 counts for those rows, with the same 22-byte hypercall at 106,214 cycles. Two runs, three commits apart, agreeing to within the scatter — and the 22 bytes identical, which is the mechanism the bound rests on. + +It also took finding out, and D31 records how: earlier runs that looked clean were runs where the phase relationship between a fixed schedule and a guest printing a fixed message kept the boundary out of a line tag. Adding two cycle-counter reads per character — under one per cent of a character time — moved that phase, and the excursion appeared on every run. A separate anomaly, a negative build that appeared to perturb timing through one number in a region descriptor, closed as the same artefact. + +--- + +## 3. The numbers + +### Partition switch, S32Z280-594EVB, 48 MHz measured + +| | cycles | +|---|---| +| min / mean / max | 5,662 / 5,703 / 5,982 | +| of which the guest's EL1 MPU | **3,407** — 85% of the save and restore, 60% of the whole switch | +| stage-2 region set, one `HPRENR` write | 177 | +| the time freeze, `CNTVOFF` | 564 | +| arming the next boundary | 233 | + +**The largest term is a fixed cost**, and that is the answer to the question a safety audience asks first: the EL1 MPU walk's trip count is read once at boot from `MPUIR`. It is a property of the part — not of a guest, not of a manifest, and not of anything that can change during a run. A switch is not expensive because the hypervisor does much; its own per-partition state is three register writes. It is expensive because a guest has a lot of registers, and most of them are its memory protection unit. + +Read it as **about 6,000 cycles reproducible to a few per cent on one bench**, not as four significant figures. Six readings over a week span 5,703 to 6,078 on the mean. With the EL2 caches off the cost depends on where code sits in memory, so an unrelated change elsewhere moves it, and the RC oscillator drifts with temperature. Expect it to change when the clock tree lands, and to change in both directions: caches off and `-Og` make it an over-estimate, while raising the core clock without raising the memory's makes the same code cost more cycles, not fewer. + +### Region budget + +| | EL2 regions | ZoneX's own MMIO | the two-partition demonstrator | +|---|---|---|---| +| S32Z280-594EVB | 20 | 2 | **4 of 20** | +| Armv8-R AEM FVP | 32 | 0 | 2 of 32 | + +The permitted Cortex-R52 values are **0, 16, 20 or 24**. A part configured with none cannot run ZoneX at all and there is no software fallback — on Armv8-R both stages of address control are region-based, so there is nothing else to fall back on. The hypervisor's own device memory is not free and what it costs is a property of the board: on this part the console and the GIC fall in the background map's Normal write-through band rather than the Device band, so each costs a Device-attributed region. **On the model both sit in the Device band and cost nothing, so the model cannot show you this constraint** — and it reports 32 regions, which is not a legal Cortex-R52 value at either stage. + +### Footprint + +| | text | data | bss | +|---|---|---|---| +| the hypervisor itself | **28,256** | 28 | 184 | +| two-partition demonstrator image | 106,728 | 28 | 138,368 | +| isolation and determinism regression | 113,972 | 28 | 138,688 | + +The hypervisor is under 28 KB of code. The rest of an image is two ThreadX guests and the partition RAM their windows describe. + +### Ratio, over twenty frames and thirty-nine boundaries, no missed deadline + +Partition A took 11,164,192 counts and 139 of its own ticks; partition B took 4,818,043 and 51. A × 3 against B × 7 is 33,492,576 against 33,726,301 — within **0.7%** of the 7:3 the manifest declares. A partition able to see wall clock would be out by a factor of three, not by a percentage. This is a ratio of two readings of the system counter, whose frequency was established three independent ways, so it does not depend on the core clock, the caches or the optimisation level. + +### What is not measured, said plainly + +**Boot to first partition entry is not instrumented.** It would be two counter reads and it has not been done. **Interrupt latency is not measured at all**, for the reason in section 1. And nothing on the functional model is a timing measurement: a green run there proves the code is right, and every period and cycle count quoted here comes from silicon. + +--- + +## 4. The evidence that the tests can fail + +This is the section a safety-savvy reader should read first, because it is what makes everything above evidence rather than decoration. + +**A check that has never been seen to fail is not evidence that it can.** So the suite contains builds that *must* fail, registered as such, each violating exactly one thing: + +| The build | What it violates | What must happen | +|---|---|---| +| `negative` | aims its violation at an address the payload **is** granted | the payload survives — so the suite fails | +| `starved` | declares more MPU regions than the part has | refuses to start, and says why | +| `overlap` | a manifest whose two partition windows overlap | the validator refuses, naming both offenders | +| `badattr` | names an `HMAIR` index the image never programmed | the validator refuses; the hardware would not | +| `widened` | a region limit one granule too generous | the adjacent-granule check catches it | +| `shared_rw` | lets a shared read-only granule's reader write it | stage 2 refuses the write | +| `no_freeze` | removes the per-partition time freeze | the clock check fails — and the period checks still pass | +| `no_tick` | makes the hypervisor's own tick undeliverable | refuses to start rather than hanging | +| `no_ppi` | denies a partition its timer interrupt | the guest never ticks and says so | +| `el2_fault` | makes ZoneX fault at EL2 | reports, and halts | +| `refault` | makes the fault report fault on its own console write | the depth guard stops it; judged from memory, because it has no console | + +Every one of these was run and seen to fail on the check it was built to violate **and on no other**. A build that starts *passing* fails the suite — which is the property that survives the code changing underneath it. + +Two of them are worth singling out. `no_freeze` is why the temporal claim has two halves: it keeps a perfectly steady window period while every partition's clock lies, so a regression measuring only the period would have stayed green. And `refault` is judged with **no console at all** — it makes the fault reporter fault on its own first write, and the verdict is read out of memory over the debug connection, which is what the third exit code was invented for. + +**Coverage.** The seven fully reachable files of the architecture-independent core hold **100% of 974 lines and 100% of 469 branches**, enforced as a floor by the build rather than reported: a validator rule added without a case that rejects anything drops the number and breaks the build. Branches are the half that does the work, because the failures those files exist to prevent live in the arms nobody took. `docs/coverage.md` records which files are held to the floor, which are not, and what stands in for the ones that are not. + +--- + +## 5. Certification posture + +Written MISRA-aware and coverage-friendly from the first commit, because the question a safety audience asks first is whether the code was shaped for it rather than whether it can be retrofitted. + +**MISRA.** Deviations are recorded at the point of deviation, and `docs/misra-deviations.md` is the index over them: **eighteen deviating sites across seven rules**, every one in the port, the board support or the examples bar two — a single-point-of-exit deviation in the fault decoder and one narrowing assignment in the schedule printer. Neither touches isolation or timing, so the architecture-independent core that carries the coverage floor is very nearly deviation-free and the deviations cluster where the code meets the hardware. + +Compiling that index found something the per-site records had hidden: the in-code blocks are headed "deviations" and several of them argue the opposite — that a rule is *observed*. The index classifies by what each entry argues rather than by its heading, so the count above is not inflated by compliance notes. What still does not exist is a **qualified** deviation record: one produced by a checking tool against a stated rule set, accounting for every rule rather than only the deviated ones. That belongs to the certification package, and nothing here stands in for it. + +**Errata, swept and written down** in `docs/errata.md`, at stated notice revisions: the Cortex-R52 Software Developer Errata Notice and the S32Z2 mask-set errata for this board's mask. `MIDR` reads r1p3 and the probe prints it on every run, so a different part reports itself rather than inheriting the conclusion. Seven Cortex-R52 errata are open at r1p3; four are debug and trace only; the three in scope do not apply, each with its reasoning recorded so a reader can check it rather than take it. No S32Z2 erratum touches EL2, stage 2, `HPRENR` or `HCR` at all — a search result, not an inference. Two standing constraints fall out of the sweep and are recorded for code not yet written: an `ISB` before any `SETEND`, and `HSCTLR.FI` must stay clear. + +**Worst-case execution time.** `docs/wcet-inputs.md` is not an analysis — it is the input to one. Every path a switch or a trap can take was read with a single question: *is anything here data-dependent?* The result names each data-dependent site with what is known about it, so that the analysis begins from measurements rather than a fresh reading of the source. Both spins in the console driver are now bounded by time as well as by iterations; the leading suspect for the millisecond excursion turned out never to have spun at all, while the spin that did cost something had no bound. + +**Structure.** No heap, no allocation anywhere, no external dependencies. The largest single stack frame in the tree is **80 bytes**, measured across every translation unit in both board configurations, against a 1 KB EL2 stack and a 256-byte-per-frame ceiling enforced at build time. No cache maintenance on the switch path. No print on any path that resumes. + +--- + +## 6. What this de-risks, and what comes next + +**Done and measured, and therefore not a risk any more:** stage-2 region programming with limits exact to the granule; EL2 residency, the vector table and the syndrome decode for every class the hypervisor sees; the guest launch path and what the hypervisor must take over from a guest's boot sequence; the partition switch, broken down by group, with its largest term identified as a fixed cost; and temporal isolation against a hostile neighbour, with one exception named and bounded. + +Those were the questions with real uncertainty in them. Several were answered against expectation: `HPFAR` does not mean the same thing on the model and the silicon, so ZoneX uses `HDFAR`; `HPRENR` really is wider than sixteen bits; setting `HCR.FMO` silently moves a guest's priority-mask write to the virtual CPU interface and leaves physical delivery closed, which stops a partition receiving anything with no fault and no message. None of those is in a manual in the form a reader needs. All are now written down with the measurement beside them. + +**The next change is not a phase.** Take the console off the hypercall path. It is the defect section 2 carves a clause out of, and **the only thing standing between a temporal claim that needs a qualification and one that does not.** It is scoped — an interrupt-driven driver plus a polled fallback the fault path can force, because the fault reporter prints when the hypervisor has already failed once — and it is far smaller than anything below. It is tracked as its own next change rather than grouped with work of a different order of magnitude, which is the surest way to leave a qualification standing indefinitely. + +**Then, in order:** interrupt worst-case latency, which this phase does not attempt and which needs the List Registers this core has; inter-partition communication, replacing the single shared granule the manifest allows; the full time-partition scheduler, with mode changes and a defined overrun behaviour; supervised partition restart, replacing report-and-halt; unified TraceX across partitions and hypervisor; and the safety-artifact package — structural and MC/DC coverage of the port, the full WCET analysis, the consolidated deviation record and tool qualification. + +**Spatial partitioning across cores** is the item most often assumed present and is not: lockstep is one logical core, and partitioning across physical cores needs split-mode SMP. + +--- + +--- + +## Keeping this document honest + +**A silicon figure lives in more than one place, and they have to move together.** That is a real maintenance cost and it is written down here rather than rediscovered, because it has already been paid twice. + +When a measurement changes, these change with it: + +| | what it carries | +|---|---| +| this document | the measured tables and the summary figures | +| `README.md` | the same figures, for someone arriving at the repository | +| `docs/decisions.md` | the decision the measurement supports, and how it was reached | +| the ZoneX pages on the documentation site | the claim and its measurement table, in the `rtos-docs-asciidoc` repository | + +Two further copies do **not** move with a run, and knowing which is the point of this list: `core/src/zx_guest_console.c` and `examples/common/zx_regression.c` carry the *derived bound* — twenty-two bytes, half a window — which is a property of the mechanism rather than of a run. A new measurement does not change them. A change to the tagging rules would. + +**And a figure quoted here should say which run it came from.** Every number above is from silicon, on the revision it describes, with the conditions stated in section 1. The functional model is not a timing measurement and nothing here is quoted from it.