Skip to content

Latest commit

 

History

History
2049 lines (1658 loc) · 110 KB

decisions.md

File metadata and controls

2049 lines (1658 loc) · 110 KB

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_*, 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.

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.

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

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 · 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 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.

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.

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.

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.

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

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 · 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 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.

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.

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:

  • 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.


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 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.

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 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 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.

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 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 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 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 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.

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

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.


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 <stdint.h> 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 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 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.


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_asserts 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; amended when a partition was first given a clock

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 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.

  • 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 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.

⚠ 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

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, 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 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

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.


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
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 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.

⚠ 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 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.

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 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 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 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.

    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, 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.


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. 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
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 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 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.

⚠ 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.

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

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.

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.


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.