With SNAP_VIEW_RECLAIM the report carries, behind "VIEWRCL1" and a word of value bytes << 32, the kernel views' count, its high-water mark, the reclaims that retired tables and how many, the refusals that found nothing to retire, and the capacity. The record sits at a fixed place just below the IB async slot (used or not); every chained record is asserted to end before it. tools/perf_counters.py decode_view_reclaim reads it (refusing a record whose counts disagree), decode_report adds it as view_reclaim, the speed/installed-fresh summary keeps it, and probes/run.py prints one line. 88 bytes of monitor code. Hardware: not run. probes/run.py's reservation note now gives the reclaim's exact cost with this record: 1,352 bytes.
3400 lines
185 KiB
Python
Executable File
3400 lines
185 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: MIT
|
|
"""Run a Snapintosh probe on the A16 through q1n1's proxy.
|
|
|
|
The probes are flat AArch64 blobs uploaded with P_CALL, so they execute at EL2
|
|
on the real machine with q1n1 as the harness. Discover a responding CDC/NCM
|
|
link automatically, or select a serial path or udp://enN with --device.
|
|
|
|
python3 probes/run.py idregs
|
|
python3 probes/run.py tgran16
|
|
"""
|
|
import argparse
|
|
import os
|
|
import runpy
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT / 'probes'))
|
|
from bootcontract import HostRuntime
|
|
from xnuboot_terminal import require_xnuboot_terminal
|
|
from devicemodel import MODEL_MAX, RESET_WORDS
|
|
import monitor_work
|
|
from monitor_work import resident_work
|
|
Q1N1 = ROOT.parent / 'q1n1'
|
|
sys.path.insert(0, str(Q1N1 / 'tools'))
|
|
import q1n1proxy as q1n1 # noqa: E402
|
|
|
|
LLVM = Path('/opt/homebrew/opt/llvm/bin')
|
|
# q1n1's Makefile resolves the linker the same way: lld from its own keg
|
|
# when present, otherwise whatever is on PATH.
|
|
LLD = str(Path('/opt/homebrew/opt/lld/bin/ld.lld')) \
|
|
if Path('/opt/homebrew/opt/lld/bin/ld.lld').exists() else 'ld.lld'
|
|
DEFAULT_DEVICE = None
|
|
FIRMWARE = '/opt/homebrew/share/qemu/edk2-aarch64-code.fd'
|
|
|
|
|
|
class Qemu:
|
|
"""q1n1 under QEMU, as a control.
|
|
|
|
A probe that reports "feature absent" on one machine proves nothing on its
|
|
own -- the probe could simply be wrong. Running the identical blob on an
|
|
emulated CPU that HAS the feature is what separates those two cases, so
|
|
every negative result here should be paired with a run under --qemu.
|
|
"""
|
|
|
|
def __init__(self, root):
|
|
self.esp = root / 'build' / 'qemu-esp'
|
|
self.esp.mkdir(parents=True, exist_ok=True)
|
|
payload = root.parent / 'q1n1' / 'build' / 'uefi' / 'q1n1.efi'
|
|
if not payload.exists():
|
|
raise SystemExit(f'build q1n1 first: make -C {root.parent / "q1n1"} uefi')
|
|
shutil.copyfile(payload, self.esp / 'q1n1.efi')
|
|
(self.esp / 'startup.nsh').write_text('fs0:\nq1n1.efi --uart-proxy\n')
|
|
self.sock = f'/tmp/snapintosh-proxy-{os.getpid()}.sock'
|
|
self.log = root / 'build' / 'qemu.log'
|
|
self.proc = None
|
|
|
|
def __enter__(self):
|
|
Path(self.sock).unlink(missing_ok=True)
|
|
self.proc = subprocess.Popen(
|
|
['qemu-system-aarch64', '-machine', 'virt,virtualization=on,gic-version=3',
|
|
# 4 GiB because xnuboot needs half a gigabyte of contiguous free
|
|
# physical memory for the image, its tables and the handoff, and
|
|
# virt starts RAM at 0x40000000. -cpu max is what supplies FEAT_NV2.
|
|
'-accel', 'tcg', '-cpu', 'max', '-smp', '1', '-m', '4G',
|
|
'-bios', FIRMWARE, '-drive', f'format=raw,file=fat:rw:{self.esp}',
|
|
# q1n1 refuses to run without a framebuffer; ramfb is what the
|
|
# q1n1 QEMU harness uses.
|
|
'-device', 'ramfb',
|
|
'-display', 'none', '-monitor', 'none', '-nic', 'none', '-no-reboot',
|
|
'-chardev', f'socket,id=s,path={self.sock},server=on,wait=off',
|
|
'-serial', 'chardev:s'],
|
|
stdout=subprocess.DEVNULL, stderr=open(self.log, 'w'))
|
|
return self.sock
|
|
|
|
def __exit__(self, *exc):
|
|
if self.proc and self.proc.poll() is not None:
|
|
print(f'qemu exited early ({self.proc.returncode}); see {self.log}')
|
|
if self.proc:
|
|
self.proc.terminate()
|
|
self.proc.wait(timeout=10)
|
|
Path(self.sock).unlink(missing_ok=True)
|
|
|
|
|
|
def connect(path=None, tries=60):
|
|
"""A live socket is not a live proxy: QEMU creates the socket immediately,
|
|
long before q1n1 has booted through firmware to the proxy loop. Retry until
|
|
a request actually comes back."""
|
|
last = None
|
|
for attempt in range(tries):
|
|
try:
|
|
# The shared reader discovers either A16 controller, handshakes
|
|
# CDC/NCM/socket candidates, resets checksum negotiation, and
|
|
# closes failed links. Only discovery is retried; never P_CALL.
|
|
return q1n1.connect(device=path)
|
|
except (OSError, RuntimeError) as error:
|
|
last = error
|
|
if attempt + 1 < tries:
|
|
time.sleep(1.0)
|
|
raise SystemExit(f'no q1n1 proxy on {path or "automatically discovered links"}: {last}')
|
|
|
|
|
|
# Probes that are more than one translation unit. They link against the shadow
|
|
# MMU sources and use probe.ld to keep the entry stub at offset 0, since a
|
|
# P_CALL blob is branched to at its front.
|
|
MULTI = {'pauth-address': ['probes/pauth-address.S','compat/pauth-native.S'],
|
|
'pauth-sign-address': ['probes/pauth-sign-address.S','compat/pauth-native-sign.S'],
|
|
'shadowmmu': ['probes/shadowmmu.S', 'probes/shadowmmu.c',
|
|
'shadow/mmu.c', 'shadow/shadow.c'],
|
|
'xnuboot': ['probes/xnuboot.S', 'probes/xnuboot.c',
|
|
'probes/secondary.S', 'probes/secondary.c', 'compat/cpu-release.c',
|
|
'shadow/mmu.c', 'shadow/shadow.c', 'shadow/stage2.c', 'shadow/sprr-view.c', 'shadow/native-view.c', 'shadow/native-shared.c',
|
|
'shadow/dockchannel.c', 'compat/sptm.c', 'compat/sptm-unmap.c', 'compat/sptm-papt.c',
|
|
'compat/txm.c', 'compat/txm-copy.c', 'compat/txm-native.c', 'compat/uat.c', 'compat/pauth.c', 'compat/pauth-user.c',
|
|
'compat/pauth-domain.c','compat/pauth-switch.S',
|
|
'compat/pauth-batch.c','compat/pauth-pointer.c','compat/pauth-native.S','compat/pauth-native-sign.S',
|
|
'compat/exception.c', 'compat/panic-state.c', 'compat/cpu.c', 'compat/cpu-runtime.c', 'compat/service-lock.c', 'compat/sptm-update.c', 'compat/sptm-refs.c',
|
|
'compat/sptm-slide.c', 'compat/sptm-user.c', 'compat/sptm-root.c', 'compat/clock.c', 'compat/ipi.c',
|
|
'compat/device.c', 'compat/aic.c', 'compat/watchdog.c', 'compat/dart.c',
|
|
'compat/mmio.c', 'compat/sptm-dart.c', 'compat/aes.c', 'compat/aes-engine.c',
|
|
'compat/sep-bootstrap.c', 'compat/sep-pnon.c', 'compat/sep-xars.c',
|
|
'compat/sep-sks.c', 'compat/sep-sks-endpoint.c', '../q1n1/platform/uefi/sha256.c',
|
|
'compat/adreno.c', 'compat/adreno-fill.c', 'compat/adreno-ib.c', 'compat/irq-forward.c', 'compat/ans.c', 'compat/ans-window.c', 'compat/nvme-host.c', 'compat/early-stop.c', 'compat/a16-thermal.c', 'probes/memory.S']}
|
|
# Units linked only when their opt-in define is in FLAGS, after MULTI's, so a build without the define links exactly
|
|
# the objects it always did and its ELF stays byte-identical.
|
|
OPTIONAL = {'xnuboot': (('-DSNAP_ADRENO_COHERENT=1', 'compat/adreno-coherent.c'),
|
|
('-DSNAP_EDP_BACKLIGHT=1', 'compat/edp-backlight.c'))}
|
|
|
|
|
|
def optional_sources(name, flags):
|
|
return [rel for define, rel in OPTIONAL.get(name, ()) if define in flags]
|
|
|
|
|
|
FLAGS = ['--target=aarch64-unknown-none-elf', '-ffreestanding', '-fno-pic',
|
|
'-mgeneral-regs-only', '-O1', '-Wall', '-Wextra', '-Werror',
|
|
'-fno-stack-protector', '-mcmodel=tiny']
|
|
|
|
|
|
def build(name, out_dir):
|
|
"""Build a probe to a flat blob whose entry is at offset 0."""
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
binary = out_dir / f'{name}.bin'
|
|
def payload():
|
|
blob=binary.read_bytes()
|
|
# Direct callers of build() may use P_CALL without run()'s later guard.
|
|
# A single-file .text.entry probe otherwise extracts an empty .text
|
|
# and silently calls whatever executable bytes were already resident.
|
|
if not blob or len(blob)>OUT_AT-CODE_AT:
|
|
raise ValueError(f'{name}: probe payload outside reserved code size ({len(blob)} bytes)')
|
|
return blob
|
|
if name in MULTI:
|
|
objs = []
|
|
for rel in MULTI[name] + optional_sources(name, FLAGS):
|
|
src = ROOT / rel
|
|
obj = out_dir / (src.stem + '-' + src.suffix.lstrip('.') + '.o')
|
|
subprocess.run([str(LLVM / 'clang'), *FLAGS, f'-I{ROOT / "shadow"}',
|
|
f'-I{ROOT.parent / "q1n1/platform/uefi"}',
|
|
'-c', str(src), '-o', str(obj)], check=True)
|
|
objs.append(str(obj))
|
|
elf = out_dir / f'{name}.elf'
|
|
subprocess.run([LLD, '-T', str(ROOT / 'probes' / 'probe.ld'),
|
|
'-o', str(elf), *objs], check=True)
|
|
subprocess.run([str(LLVM / 'llvm-objcopy'), '-O', 'binary', str(elf), str(binary)],
|
|
check=True)
|
|
return payload()
|
|
|
|
source = next(p for p in (ROOT / 'probes').iterdir()
|
|
if p.stem == name and p.suffix in ('.S', '.c'))
|
|
obj = out_dir / f'{name}.o'
|
|
subprocess.run([str(LLVM / 'clang'), *FLAGS, '-c', str(source), '-o', str(obj)], check=True)
|
|
subprocess.run([str(LLVM / 'llvm-objcopy'), '-O', 'binary', '--only-section=.text',
|
|
str(obj), str(binary)], check=True)
|
|
return payload()
|
|
|
|
|
|
# The report's geometry, matching probes/xnuboot.c's EVENT_MAX and CONSOLE_MAX.
|
|
#
|
|
# These were spelled out as literal 64s in five places -- the console's offset,
|
|
# the log's length, the slice bound, and two structure offsets derived from
|
|
# them -- so growing the log meant finding all five and getting all five right.
|
|
# Two of them are offsets into a buffer the probe writes from the other side, so
|
|
# a miss does not fault: it reads the console as events, or the counters as
|
|
# register snapshots, and prints the result with a straight face.
|
|
# tools/test-classify.py rebuilds the same arithmetic independently and refuses
|
|
# a mismatch, which is what has caught this class of mistake before.
|
|
EVENT_MAX = 512
|
|
EVENT_WORDS = 6
|
|
CONSOLE_MAX = 1024
|
|
# Where the log starts, in bytes from `out`: eight report words, past the
|
|
# sixteen the C side's pointer sits above the host's.
|
|
LOG_OFFSET = 16 + 8 * 8
|
|
LOG_BYTES = EVENT_MAX * EVENT_WORDS * 8
|
|
CONSOLE_OFFSET = LOG_OFFSET + LOG_BYTES
|
|
|
|
# Where the report sits in q1n1's heap, and how much room it has.
|
|
#
|
|
# Spelled out because the report just grew eightfold and the headroom stopped
|
|
# being obviously sufficient. The probe writes this buffer from EL2 with the
|
|
# MMU configured its own way; a report that ran past OUT_HEADROOM would not
|
|
# fault, it would write into the shadow tables the hardware is walking, and the
|
|
# failure would look like a page-table bug anywhere in the run.
|
|
# tools/test-classify.py checks the whole report fits.
|
|
# 464 KiB for probe code since SNAP_VIEW_RECLAIM: the next1 qualification's
|
|
# monitor (--edp-backlight) was 458,552 bytes, 200 short of the previous
|
|
# 448 KiB, and the reclaim adds 1,352 (with VIEWRCL1). CODE_AT moves down
|
|
# 16 KiB instead of OUT_AT up: the report ends exactly where the stopped-probe
|
|
# scratch begins (heap+0x480000, tools/probe-a16-cpus.py), which
|
|
# tools/secondary-runtime.py collect writes after the monitor returns and
|
|
# before this file reads the report, so a higher report would lose its tail
|
|
# (thermal and DVFS records). Below CODE_AT nothing in q1n1's heap is in use
|
|
# during a run; the only other user of that range is the fresh-stage
|
|
# qualification of tools/qualify-txm-image4-native.py (heap+0x100000, 3 MiB).
|
|
# 448 KiB came from native tables 3b (phase 3b's monitor is 320,526 bytes and
|
|
# the lock-free trap paths add 6,288, over the previous 320 KiB, itself raised
|
|
# from 256 KiB for the ANS2 M2 monitor, docs/ANS2-M2-20260925.md) by moving
|
|
# OUT_AT. The report, SECONDARY_CONFIG_AT and WORK_AT are unchanged, the
|
|
# monitor binary does not depend on its load address, and every consumer reads
|
|
# the code through run.CODE_AT and the report through run.OUT_AT
|
|
# (tools/test-monitor-reservation.py).
|
|
CODE_AT = 0x3fc000
|
|
OUT_AT = 0x470000 # heap_base + this; 464 KiB reserved for probe code
|
|
REPORT_RESERVE = 0x10000 # includes metrics for all 24 descriptor slots
|
|
STOPPED_PROBE_AT = 0x480000 # stopped-probe scratch, from the report's end (tools/probe-a16-cpus.py)
|
|
SECONDARY_CONFIG_AT = 0x4f0000 # the SMP secondaries' configuration page (tools/secondary-runtime.py)
|
|
WORK_AT = 0x500000 # generic probe scratch; xnuboot stores its work locator here
|
|
# The report must end before the next thing in the heap, the SMP configuration page.
|
|
OUT_HEADROOM = SECONDARY_CONFIG_AT - OUT_AT
|
|
|
|
# The registers the probe traps, by (op0, op1, CRn, CRm, op2) -- the same
|
|
# encoding the ESR's ISS carries, and the same SR_* constants xnuboot.c defines.
|
|
# A name here is the difference between "S3_0_C2_C0_2, eighteen accesses" and
|
|
# "the kernel is reprogramming TCR_EL1 eighteen times".
|
|
SYSREGS = {
|
|
(3, 0, 1, 0, 0): 'SCTLR_EL1', (3, 0, 2, 0, 0): 'TTBR0_EL1',
|
|
(3, 0, 2, 0, 1): 'TTBR1_EL1', (3, 0, 2, 0, 2): 'TCR_EL1',
|
|
(3, 0, 10, 2, 0): 'MAIR_EL1', (3, 6, 15, 2, 7): 'AMXIDR_EL1',
|
|
(3, 0, 11, 1, 2): 'CONST_LOWER', (3, 0, 11, 1, 3): 'CONST_UPPER',
|
|
(3, 6, 15, 8, 0): 'GXF_STATUS_EL1',
|
|
(3, 0, 5, 2, 0): 'ESR_EL1', (3, 0, 6, 0, 0): 'FAR_EL1',
|
|
(3, 0, 2, 3, 0): 'APGAKEYLO_EL1', (3, 0, 2, 3, 1): 'APGAKEYHI_EL1',
|
|
(3, 5, 15, 0, 0): 'IPI_RR_LOCAL_EL1', (3, 5, 15, 0, 1): 'IPI_RR_GLOBAL_EL1',
|
|
(3, 5, 15, 1, 1): 'IPI_SR_EL1',
|
|
(3, 6, 15, 1, 5): 'SPRR_UPERM_EL0', (3, 6, 15, 1, 6): 'SPRR_PPERM_EL1',
|
|
(3, 6, 15, 3, 0): 'SPRR_UMPRR_EL1', (3, 6, 15, 3, 1): 'SPRR_PMPRR_EL1',
|
|
}
|
|
|
|
EVENTS = {1: 'monitor call', 2: 'stage-2 fault', 3: 'XNU took an exception',
|
|
4: 'sysreg', 5: 'other', 6: 'watchdog: guest still running',
|
|
7: 'handoff field', 8: 'dispatch command',
|
|
9: 'XNU: fatal exception', 10: 'MAP_PAGE failed',
|
|
11: 'MAP_PAGE outside our memory',
|
|
12: 'SError: the bus refused an access', 13: 'IRQ/FIQ, not the timer',
|
|
# 14 was 'MAP_PAGE withheld from the hardware'. Nothing withholds
|
|
# now, and the number is left unused rather than reassigned so an
|
|
# old transcript keeps its meaning.
|
|
15: 'MAP_PAGE onto a device the tree names',
|
|
16: 'device access with no model',
|
|
17: 'RETYPE', 18: 'RETYPE rejected',
|
|
19: 'MAP_TABLE', 20: 'table operation rejected',
|
|
21: 'UNMAP_REGION', 22: 'UNMAP rejected',
|
|
23: 'MAP_PAGE needs table expansion',
|
|
24: 'kernel exception return', 25: 'exception return rejected',
|
|
26: 'UPDATE_DISJOINT rejected', 27: 'UPDATE_REGION rejected',
|
|
28: 'mapping references rejected', 29: 'SLIDE_REGION rejected',
|
|
30: 'TXM request rejected',
|
|
31: 'SLIDE_REGION completed', 32: 'clock request rejected',
|
|
33: 'guest timer FIQ', 34: 'virtual device operation rejected',
|
|
35: 'guest AIC IRQ', 36: 'virtual device reset request',
|
|
37: 'IPI request rejected', 38: 'CPU monitor contract rejected',
|
|
39: 'SPRR permission operation rejected',
|
|
40: 'user exception return', 41: 'first EL0 instruction verified',
|
|
42: 'EL0 execution witness rejected', 43: 'panic-state contract rejected',
|
|
44: 'native writer breakpoint rejected',
|
|
45: 'linked AuxKC leaf gained EL1 execute',
|
|
46: 'trapped Adreno block touched', 47: 'Adreno SMMU fault or mirror refusal',
|
|
48: 'guest HVC to TrustZone', 49: 'monitor Adreno test fence',
|
|
50: 'virtual ANS2 refusal', 51: 'guest Adreno IB',
|
|
52: 'early stop: deadline reached', 53: 'early stop request',
|
|
54: 'shared-region group records exhausted',
|
|
55: 'stage-2 fault retried',
|
|
57: 'QCC2072 command register written for the guest',
|
|
58: 'Adreno GPU recovery (SNAP_ADRENO_RECOVER)',
|
|
59: 'Adreno IO-coherency self-test (SNAP_ADRENO_COHERENT)'}
|
|
|
|
SPRR_FAILURES = {2:'invalid SPRR register state',
|
|
3:'permission change needs a shadow transaction',
|
|
4:'SPRR control-register write is not implemented',
|
|
5:'private kernel permission view transaction failed'}
|
|
|
|
IPI_FAILURES = {2:'invalid IPI state or CPU affinity',3:'IPI request register is write-only',
|
|
4:'target CPU has no executing guest backend',5:'unsupported IPI register bits'}
|
|
CPU_FAILURES = {1:'invalid CPU registry state',2:'unknown guest CPU affinity',
|
|
3:'CPU not registered',4:'invalid CPU slot',5:'invalid per-CPU result area'}
|
|
|
|
DEVICE_FAILURES = {1: 'outside device window', 2: 'invalid device configuration',
|
|
3: 'unknown register', 4: 'read-only register',
|
|
5: 'unsupported access width, alignment or syndrome',
|
|
6: 'unsupported register value or event index'}
|
|
|
|
CLOCK_FAILURES = {
|
|
2: 'counter is read-only',
|
|
3: 'unsupported clock/timer control bits or event bit index',
|
|
4: 'requested timer register or mode has no implemented clock/interrupt backend',
|
|
5: 'clock backend failed',
|
|
}
|
|
|
|
RETYPE_FAILURES = {
|
|
1: 'invalid monitor memory configuration',
|
|
2: 'page is unaligned or outside XNU allocator memory',
|
|
3: 'unsupported frame-type transition',
|
|
4: 'unsupported monitor operation options',
|
|
5: 'frame metadata or page is not accessible',
|
|
6: 'current type disagrees with the shared frame map',
|
|
7: 'frame has in-flight state or data references',
|
|
8: 'unsupported or malformed page table',
|
|
9: 'parent table is missing',
|
|
10: 'table is already present',
|
|
11: 'shadow mapping preflight or publication failed',
|
|
12: 'mapping reference count would underflow or overflow',
|
|
}
|
|
|
|
# Why a device access stopped the run, from probes/xnuboot.c's MMIO_STOP_*.
|
|
#
|
|
# Each of these is a thing to go and model, and the event carries everything
|
|
# needed to do it: which window, which offset, and -- through the ESR -- how
|
|
# wide the access was and which way it went. A modelled access is never logged;
|
|
# there is one per character of the kernel's output and the log holds 64
|
|
# entries, so the counters carry those instead.
|
|
MMIO_STOPS = {
|
|
1: 'the window is one we model, but this offset is not',
|
|
2: 'ISS.ISV clear -- a pair or writeback form, so the syndrome does not say '
|
|
'which registers moved and emulating it would be a guess',
|
|
3: 'an instruction fetch from a device page',
|
|
4: 'the stage-1 table walk faulted into a device page, so the syndrome '
|
|
'describes the access that provoked the walk, not the walk',
|
|
5: 'the tree declares device windows that are not the shape the model was '
|
|
'written against',
|
|
}
|
|
|
|
# The windows, in the order /arm-io/dockchannel-uart's `reg` lists them, which
|
|
# is the order the loader derives them in and the order they arrive in params.
|
|
MMIO_WINDOWS = {0: 'dockchannel data/config (0x10000)',
|
|
1: 'dockchannel irq (0x1000)'}
|
|
|
|
|
|
# A refused virtual-device access, placed the way the registry names it.
|
|
# The kind and IPA alone make every such stop a lookup; with the node, the
|
|
# window offset and the decoded direction/width/register it is one line of
|
|
# triage. The SEP mailbox's service stage reports an unrecognised submitted
|
|
# message through the same result code as an unknown register, so that one
|
|
# case is named for what it is.
|
|
def device_stop_where(models, ipa, esr, kind, reason):
|
|
model = next((m for m in models if m.base <= ipa < m.base + m.span), None)
|
|
iss = esr & 0x1ffffff
|
|
write = (iss >> 6) & 1
|
|
if (esr >> 26) == 0x24 and (iss >> 24) & 1:
|
|
size = 1 << ((iss >> 22) & 3)
|
|
reg = (iss >> 16) & 31
|
|
access = (f'{"write" if write else "read"} of {size} byte'
|
|
f'{"s" if size > 1 else ""} {"from" if write else "into"} '
|
|
f'{"xzr" if reg == 31 else f"x{reg}"}')
|
|
elif (esr >> 26) == 0x24:
|
|
access = (f'{"write" if write else "read"} with no syndrome '
|
|
f'(pair or writeback form, decoded from the instruction)')
|
|
else:
|
|
access = f'exception class {esr >> 26:#x}'
|
|
if model is None or model.kind != kind:
|
|
return f' ({access})', ''
|
|
offset = ipa - model.base
|
|
note = ''
|
|
if kind == 45 and reason == 3 and write and offset in (0x8800, 0x8808):
|
|
note = ('; the transport accepted the pair, then the virtual SEP '
|
|
'service did not recognise the submitted message')
|
|
return f' ({model.node} +{offset:#x}, {access})', note
|
|
|
|
|
|
def s2_owner_words(ipa, inventory):
|
|
"""Which guest-tree window an unanswered IPA lies in, tightest first.
|
|
|
|
Same rule as xnuboot.owner_of: windows nest (pmgr contains the console),
|
|
and only the smallest containing one names the device that was touched.
|
|
An absent-IPA stop inside a named window is a driver reaching hardware
|
|
the virtual board does not back: model it, or defer the node."""
|
|
best = None
|
|
for base, size, who in inventory:
|
|
if base <= ipa < base + size and (best is None or size < best[2]):
|
|
best = (who, base, size)
|
|
if best is None:
|
|
return ''
|
|
who, base, size = best
|
|
return f'; inside the guest tree\'s {who} window +{ipa - base:#x} of {size:#x}, which no virtual model answers'
|
|
|
|
|
|
COHERENT_STATES = {0: 'NOT_RUN', 1: 'ON', 2: 'FAILED', 3: 'TIMEOUT', 4: 'REFUSED', 5: 'INCONCLUSIVE',
|
|
6: 'GPU_ERROR'} # compat/adreno-guest.h ADRENO_COHERENT_STATE_*
|
|
COHERENT_PHASES = {1: 'CPU->GPU control', 2: 'GPU->CPU control', 3: 'CPU->GPU', 4: 'GPU->CPU',
|
|
5: 'DC IVAC probe (retired)'} # compat/adreno-coherent.h ADRENO_COHERENT_PHASE_*
|
|
|
|
|
|
def coherent_event_lines(pc, a, b, c):
|
|
"""EV_GPU_COHERENT (59) in words: probes/xnuboot.c records pc = the CPU->GPU control's PMU passes, a = state << 32
|
|
| detail, b = passed | the GPU->CPU control's PMU passes << 16, c = the controls' stale words. A forced ON (a
|
|
debug monitor, SNAP_ADRENO_COHERENT_FORCE) is said first and loudly: nothing was proven."""
|
|
state, detail = a >> 32, a & 0xffff
|
|
lines = []
|
|
if detail & 0x2000:
|
|
lines.append('*** FORCED ON WITHOUT THE SELF-TEST (SNAP_ADRENO_COHERENT_FORCE, debug only): GPU write-back '
|
|
'maintenance skipped UNPROVEN ***')
|
|
phase = (detail >> 8) & 0x1f
|
|
where = '' if state in (1, 4) or not phase else (
|
|
f', at {COHERENT_PHASES.get(phase, f"phase {phase}")}'
|
|
+ (f' round {(detail >> 4) & 15} case {detail & 15}' if phase in (3, 4) else ''))
|
|
today = [d for bit, d in ((0x8000, 'CPU->GPU'), (0x4000, 'GPU->CPU')) if detail & bit]
|
|
lines.append(f'state {COHERENT_STATES.get(state, state)} detail {detail:#x}{where}, {b & 0xffff} coherent '
|
|
f'iterations passed' + (f"; today's leaves already coherent {' and '.join(today)}" if today else '')
|
|
+ (f' (REFUSED precondition {detail})' if state == 4 else ''))
|
|
if state != 4 and not detail & 0x2000:
|
|
for name, stale, pmu in (('CPU->GPU', c >> 32, pc), ('GPU->CPU', c & 0xffffffff, b >> 16)):
|
|
lines.append(f'{name} control: {stale} stale words; PMU refills: witness miss {pmu & 0xffff}, '
|
|
f'resident {(pmu >> 16) & 0xffff}, witness after {(pmu >> 32) & 0xffff}')
|
|
return lines
|
|
|
|
|
|
def mmio_stop_words(a, esr):
|
|
"""One device access that had no answer, in words."""
|
|
reason, window, offset = (a >> 48) & 0xffff, (a >> 32) & 0xffff, a & 0xffffffff
|
|
iss = esr & 0x1ffffff
|
|
if (esr >> 26) == 0x24 and (iss >> 24) & 1:
|
|
size = 1 << ((iss >> 22) & 3)
|
|
direction = 'write' if (iss >> 6) & 1 else 'read'
|
|
access = f'{direction} of {size} byte{"s" if size > 1 else ""}'
|
|
elif (esr >> 26) == 0x20:
|
|
access = 'instruction fetch'
|
|
else:
|
|
access = 'write' if (iss >> 6) & 1 else 'read'
|
|
where = MMIO_WINDOWS.get(window, f'window {window}')
|
|
return (f'window {window} ({where}) +{offset:#x}, {access}; '
|
|
f'{MMIO_STOPS.get(reason, f"reason {reason}")}')
|
|
|
|
# Monitor call routing, from tools/kc-analyze.py against this kernelcache.
|
|
#
|
|
# Keyed by (domain, table), not by table alone. Domain was dropped here for the
|
|
# same reason the dispatch command was: everything seen so far was domain 0, so
|
|
# the field looked like a constant. It is not -- domain 2 table 0 is TXM and
|
|
# domain 3 table 0 is the cL4 ringgate, and both would have printed as
|
|
# "XNU_BOOTSTRAP" under a table-only lookup.
|
|
TABLES = {(0, 0): 'XNU_BOOTSTRAP', (0, 3): 'T8110_DART', (0, 5): 'SART',
|
|
(0, 6): 'NVME', (0, 7): 'UAT', (0, 9): 'CPUTRACE', (0, 10): 'HIB',
|
|
(0, 11): 'GEN3_DART', (0, 13): 'T6000_DART',
|
|
(2, 0): 'TXM_XNU', (3, 0): 'cL4 ringgate'}
|
|
|
|
|
|
def route_name(domain, table):
|
|
return TABLES.get((domain, table), f'domain {domain} table {table}')
|
|
|
|
# x16 bits 63:56: which kind of call this is, not which endpoint.
|
|
#
|
|
# Only command 0 is an endpoint call. The others were invisible here until the
|
|
# transcript started printing the raw x16 -- they decode to domain 0, table 0,
|
|
# endpoint 0, which reads as LOCKDOWN, and that is how four distinct calls came
|
|
# to share one line and one answer. SPTM's dispatch_cmd_handler
|
|
# (0xfffffff0270f2270) takes this field with `lsr x20, x0, #56` and rejects
|
|
# anything at 7 or above; XNU is its calling domain 1, and the handler table at
|
|
# 0xfffffff02701f4f8 has entries for that row at 0, 4, 5 and 6, with 2
|
|
# special-cased ahead of the lookup. Apple does not name the individual
|
|
# commands anywhere in the binary -- the panic path prints "dispatch_cmd_id(%u)"
|
|
# as a number -- so neither do we. What each one *does* is recorded instead,
|
|
# because that came out of the kernel's own trampolines.
|
|
DISPATCH_CMDS = {
|
|
0: 'endpoint call',
|
|
2: 'returns normally (stub ends in ret)',
|
|
4: 'does not return to the call site',
|
|
5: 'does not return; SPTM enters domain 2 (TXM)',
|
|
6: 'does not return; SPTM enters domain 3 (SK)',
|
|
}
|
|
|
|
# How a run ended.
|
|
#
|
|
# The exit code is the only thing a caller that is not reading the transcript
|
|
# ever sees, so every distinct way of stopping gets its own, and none of them
|
|
# is zero. Until this run has a checkpoint it can claim to have reached, EXIT_OK
|
|
# is unreachable by construction -- entering the kernel, answering monitor
|
|
# calls, being taken back by the watchdog and acknowledging an endpoint we have
|
|
# not written are all stops, and a harness that cannot tell them apart from
|
|
# success cannot be used to judge whether a change helped.
|
|
EXIT_OK = 0 # reached the checkpoint that defines this milestone
|
|
EXIT_UNCLASSIFIED = 1 # stopped for a reason this function does not recognise
|
|
EXIT_LOADER = 2 # the host could not build or place the image
|
|
EXIT_PREPARE = 3 # EL2 refused to set the guest up; the kernel never ran
|
|
EXIT_EL2_FAULT = 4 # the monitor itself faulted
|
|
EXIT_NO_ENTRY = 5 # prepared, but the guest never started
|
|
EXIT_GUEST_EXC = 6 # stopped at a guest exception or unmapped stage-2 access
|
|
EXIT_UNSUPPORTED = 7 # the kernel asked for something not implemented
|
|
EXIT_PANIC = 8 # the kernel panicked, in words
|
|
EXIT_WATCHDOG = 9 # the guest was still running when its time ran out
|
|
EXIT_SERROR = 10 # the interconnect refused an access the guest made
|
|
EXIT_GUEST_RESET = 11 # a virtual device requested guest reset
|
|
EXIT_EARLY_STOP = 12 # stopped before the budget on an admitted early-stop request
|
|
|
|
EXIT_NAMES = {
|
|
EXIT_OK: 'milestone reached',
|
|
EXIT_UNCLASSIFIED: 'stopped, unclassified',
|
|
EXIT_LOADER: 'loader failure',
|
|
EXIT_PREPARE: 'guest preparation failed',
|
|
EXIT_EL2_FAULT: 'EL2 fault',
|
|
EXIT_NO_ENTRY: 'never entered',
|
|
EXIT_GUEST_EXC: 'guest exception',
|
|
EXIT_UNSUPPORTED: 'unsupported operation',
|
|
EXIT_PANIC: 'guest panic',
|
|
EXIT_WATCHDOG: 'watchdog',
|
|
EXIT_SERROR: 'SError',
|
|
EXIT_GUEST_RESET: 'guest reset request',
|
|
EXIT_EARLY_STOP: 'early stop on request',
|
|
}
|
|
|
|
EV_MONITOR, EV_S2_FAULT, EV_GUEST_EXC, EV_WATCHDOG = 1, 2, 3, 6
|
|
EV_DISPATCH_CMD, EV_PANIC_ENTRY, EV_MAP_FAILED = 8, 9, 10
|
|
EV_MAP_OUTSIDE = 11
|
|
EV_SERROR, EV_ASYNC = 12, 13
|
|
EV_MMIO_STOP = 16
|
|
EV_DEVICE_RESET = 36
|
|
EV_EARLY_STOP, EV_EARLY_STOP_REQUEST = 52, 53
|
|
|
|
# Why xnuboot_prepare() refused to enter the guest. Every negative code
|
|
# xnuboot.c can return is here, at module scope so the suite can check that
|
|
# none is missing: three of them were added to the probe and never named, so a
|
|
# prepare that failed on the TTBR0 shadow printed a bare "-8" -- a number with
|
|
# no story, on the one path where the run produced nothing else to read.
|
|
PREPARE_FAILURES = {
|
|
-28: 'A16 thermal preflight refused; inspect the thermal report',
|
|
-1: 'no room for the shadow root',
|
|
-2: 'the 16K walk failed',
|
|
-3: 'no room for the stage-2 root',
|
|
-4: 'could not protect a monitor page',
|
|
-5: 'could not protect the vectors',
|
|
-6: 'the hardware refused the shadow for the entry point (that walk reads '
|
|
'the shadow through the read-only work-area range of stage 2; if this '
|
|
'is new, that range is the suspect)',
|
|
-7: 'could not protect a watched handoff page',
|
|
-8: 'no room for the TTBR0 shadow root -- the low regime, which is what the '
|
|
'hardware walks for addresses the kernel treats as physical',
|
|
-9: 'the TTBR0 walk failed over the reserve at low_base',
|
|
-10: 'the stage-2 range list is empty, unaligned, past 36 bits or overlaps '
|
|
'itself; nothing was mapped',
|
|
-11: 'the stage-2 map could not be written (out of work area)',
|
|
-12: 'the write-watch page is outside guest memory, unaligned, or could not be protected',
|
|
-13: 'SLIDE_REGION shadow destination could not be reserved',
|
|
-14: 'virtual device registry has unsupported geometry or overlaps mapped memory',
|
|
-15: 'clock adapter requires valid rates, matching host frequency and FEAT_ECV',
|
|
-16: 'CPU affinity or six per-CPU result areas do not match the boot contract',
|
|
-19: 'the host secondary configuration was refused (magic, regime, layout; mask bit 0, a relocated vCPU 0, '
|
|
'needs a monitor built with -DSNAP_BOOT_RELOCATE=1)',
|
|
-21: 'static trust-cache payload, reservation or read-only protection is invalid',
|
|
-27: 'SNAP_USER_HW_WALK_S2RO: the stage-2 trees could not be pre-split to level 3 over guest RAM '
|
|
'(out of work area)',
|
|
# Stage 4 (compat/adreno.h): adoption of q1n1's GPU, before the guest runs.
|
|
-60: 'Adreno params invalid, or the arena overlaps guest memory, or SPI 0x14C is Secure',
|
|
-61: 'Adreno CX GDSC is off: run the q1n1 bring-up and adopt again',
|
|
-62: 'Adreno GX is off (GMU_PWR_CLK_STATUS): OOB GPU_SET no longer held',
|
|
-63: 'Adreno SMMU context bank 0 is not the one q1n1 adopted',
|
|
-64: 'Adreno CB0 root entries 1..3 are in use: the mirror window is not free',
|
|
-65: 'Adreno SQE is not running',
|
|
-66: 'Adreno ring pointers disagree with the adoption record, or the fenced WPTR write was dropped',
|
|
-67: 'an Adreno SMMU fault is already recorded',
|
|
-68: 'the Adreno did not write the monitor test fence within 50 ms',
|
|
-71: 'Adreno GX-side GBIF is halted (a previous quiesce): q1n1 quiesce --write, redo cp-fence, adopt again',
|
|
-72: 'Adreno guest fence target is absent, outside native byte bounds or mismatched with its writable SMMU mirror',
|
|
-73: 'Adreno native DART unmap failed or left a live leaf; retain resource backing and reservation',
|
|
# SNAP_BOOT_RELOCATE (compat/boot-relocate.h): vCPU 0 never entered on its relocated CPU; the secondary
|
|
# result's boot_relocation row holds record 0 (on_result, phase, error, id_differences).
|
|
-80: 'vCPU 0 relocation: the target CPU was not OFF, or PSCI CPU_ON refused it',
|
|
-81: 'vCPU 0 relocation: the target CPU turned OFF without reaching the monitor (secondary_entry refused it)',
|
|
-82: 'vCPU 0 relocation: the target CPU stopped before guest entry (record 0 error: 0x100 GIC, 0x103 '
|
|
'registers, 0x104/0x105 FP, 0x107 an ID register the shared preparation used differs, 0x108 '
|
|
'CNTVOFF_EL2 or CNTFRQ_EL0 differs from q1n1\'s CPU)',
|
|
}
|
|
# Which vector an asynchronous exception arrived at, from probes/xnuboot.S.
|
|
ASYNC_VECTORS = {0: 'IRQ', 1: 'FIQ', 2: 'SError'}
|
|
# EV_ASYNC's kind: a vector, or probes/xnuboot.c ASYNC_IRQ_OTHER (SNAP_FAST_TIMER_IRQ), an
|
|
# IRQ whose lock-free acknowledge returned, and completed, another INTID than the timer's 26.
|
|
ASYNC_KINDS = {**ASYNC_VECTORS, 3: 'IRQ (its lock-free acknowledge returned another INTID than the timer\'s)'}
|
|
# compat/adreno.h block classes (EV_GPU_TRAP c).
|
|
ADRENO_BLOCKS = {1: 'passthrough window', 2: 'CX_MISC (read-only)', 3: 'GMU_GX', 4: 'RDVM',
|
|
5: 'GMU_AO', 6: 'RSCC', 7: 'SMMU aperture', 8: 'GX clock controller', 9: 'GMU',
|
|
10: 'GPUCC', 11: 'CX_MISC', 12: 'SMMU', 13: 'hole'}
|
|
|
|
# A stage-2 fault's syndrome, in words. EC 0x24 is a data abort and 0x20 an
|
|
# instruction abort from EL1; both carry the fault status in ISS[5:0], whose
|
|
# top bits say the kind and whose low two say the level. A data abort with
|
|
# ISS.ISV set also names the access -- size, register, direction -- which is
|
|
# what a device model needs and what makes the next step cheap without
|
|
# decoding the instruction at the PC. ISV clear (pair loads, writeback forms)
|
|
# is said as such, because then the PC is the only way to know.
|
|
FSC_KINDS = {0: 'address size fault', 1: 'translation fault',
|
|
2: 'access flag fault', 3: 'permission fault'}
|
|
FSC_OTHER = {0x10: 'synchronous external abort', 0x21: 'alignment fault',
|
|
0x30: 'TLB conflict', 0x35: 'unsupported atomic update'}
|
|
|
|
|
|
def s2_fault_words(ipa, esr):
|
|
ec, iss = esr >> 26, esr & 0x1ffffff
|
|
fsc = iss & 0x3f
|
|
if fsc >> 2 in FSC_KINDS:
|
|
kind = f'stage-2 {FSC_KINDS[fsc >> 2]} at level {fsc & 3}'
|
|
else:
|
|
kind = FSC_OTHER.get(fsc, f'fault status {fsc:#04x}')
|
|
if ec == 0x20:
|
|
access = 'instruction fetch'
|
|
elif ec == 0x24:
|
|
direction = 'write' if (iss >> 6) & 1 else 'read'
|
|
if (iss >> 24) & 1: # ISV
|
|
size = 1 << ((iss >> 22) & 3)
|
|
reg = (iss >> 16) & 0x1f
|
|
width = 'x' if (iss >> 15) & 1 else 'w'
|
|
access = (f'{direction} of {size} byte{"s" if size > 1 else ""}, '
|
|
f'{width}{reg}')
|
|
if (iss >> 21) & 1:
|
|
access += ', sign-extended'
|
|
else:
|
|
access = (f'{direction}, ISV clear -- a pair or writeback form; '
|
|
f'the instruction at the PC says the rest')
|
|
else:
|
|
return f'EC {ec:#x}, not an abort from EL1 (ESR {esr:#x})'
|
|
if (iss >> 7) & 1:
|
|
access += ', on the stage-1 table walk (S1PTW: the IPA is a table page)'
|
|
if not ipa:
|
|
return (f'{kind}; {access}; IPA unknown -- the stage-1 walk failed and '
|
|
f'HPFAR read zero')
|
|
return f'{kind}; {access}'
|
|
|
|
|
|
def s2_where_words(ipa, ranges):
|
|
"""The IPA against the map: inside a range it is that range's permission
|
|
that refused it; in none, the address does not exist for the guest."""
|
|
if not ipa:
|
|
return 'nowhere we can name'
|
|
for lo, hi, access, what in ranges:
|
|
if lo <= ipa < hi:
|
|
return f'inside the {what} range {lo:#x}..{hi:#x} ({access})'
|
|
return 'in no range of the stage-2 map: a device, or memory we did not declare'
|
|
SPTM_FN_MAP_PAGE = 2 # matches probes/xnuboot.c
|
|
|
|
# classify()'s last resort, as a constant because conclude() has to tell it
|
|
# apart from the other unclassified verdicts. EXIT_UNCLASSIFIED is reached both
|
|
# when nothing was found and when something was found that is not modelled -- a
|
|
# stray IRQ, a fault word that cannot be a syndrome -- and only the first of
|
|
# those is a run whose story is the events it lost.
|
|
NOTHING_RECOGNISED = ('the run ended without reaching a checkpoint and '
|
|
'without a recognised stop')
|
|
|
|
|
|
def is_syndrome(word):
|
|
"""Could this word be an ESR at all?
|
|
|
|
The fault slot in the report is a syndrome and nothing else, but it used to
|
|
be overwritten with the exception-return trampoline XNU registers -- a
|
|
kernel code address -- so classify() read "is it non-zero" and answered
|
|
"a fault was recorded", printing a return address under the label ESR.
|
|
|
|
ESR puts EC in bits 31:26 and leaves everything above bit 36 clear; a kernel
|
|
virtual address on this machine has the top sixteen bits set, so the two
|
|
shapes cannot be confused once anything looks. EC zero is "unknown reason",
|
|
which nothing here records, so it is refused too."""
|
|
return bool(word) and (word >> 36) == 0 and ((word >> 26) & 0x3f) != 0
|
|
|
|
|
|
def verdict(code, reason):
|
|
"""Print how the run ended and hand back the exit code, in one place so a
|
|
stop cannot be reported in the transcript and lost on the way out."""
|
|
print(f'\n== {EXIT_NAMES[code].upper()} (exit {code}) ==\n {reason}')
|
|
return code
|
|
|
|
|
|
def console_panic(console):
|
|
"""A panic announcement, not incidental words such as gPanicBase."""
|
|
return next((line for line in console.splitlines()
|
|
if line.lstrip().lower().startswith(
|
|
('panic:', 'panic(', 'nested panic detected -'))), '')
|
|
|
|
|
|
def early_stop_module():
|
|
"""tools/early_stop.py: the record decoder and the SNAP_EARLY_STOP* settings."""
|
|
import importlib.util
|
|
if str(ROOT/'tools') not in sys.path: sys.path.insert(0,str(ROOT/'tools'))
|
|
spec = importlib.util.spec_from_file_location('early_stop', ROOT / 'tools/early_stop.py')
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def early_stop_reason(record, log):
|
|
"""The verdict sentence for EXIT_EARLY_STOP, from the report record or,
|
|
without one, from the EV_EARLY_STOP entry (a = source << 32 | reason)."""
|
|
names = early_stop_module()
|
|
if record:
|
|
source, reason, detail = record['source'], record['reason'], record['detail']
|
|
delay, text = record['accepted_delay'], record.get('text', '')
|
|
else:
|
|
entry = next(event for event in log if event[0] == EV_EARLY_STOP)
|
|
source, reason, detail, delay, text = entry[2] >> 32, entry[2] & 0xffffffff, entry[3], entry[4], ''
|
|
said = f', "{text}"' if text else ''
|
|
return (f'stopped early on a {names.SOURCES.get(source, source)} request: '
|
|
f'{names.reason_name(source, reason)}{said} (detail {detail:#x}, {delay}s after it was '
|
|
f'accepted) through the watchdog\'s own stop; RAM is retained as at a budget stop')
|
|
|
|
|
|
def classify(entered, fault, console, log, tally=None, dropped=0,
|
|
last_event=None, models=(), inventory=()):
|
|
"""Why did this run stop? Returns (exit code, one-line reason).
|
|
|
|
`tally` is the probe's counters when they are available. They refuse
|
|
verdicts the run's own arithmetic contradicts, and they supply every count
|
|
that appears in a reason -- never a verdict of their own -- because the log
|
|
can be truncated and collapses repeats while the counters can do neither.
|
|
|
|
`dropped` is how many events the log had no room for. It suppresses exactly
|
|
the inferences that read the log's TAIL, and nothing else. The log fills
|
|
from the front, so what survives an overflow is the START of the run: a
|
|
MAP_PAGE we refused, a panic announcement, a stage-2 fault are facts sitting
|
|
in that head, and losing the tail does not un-record them. Only "the last
|
|
event was a monitor call, so that is where it stopped" stops being available,
|
|
because with a tail missing the last surviving event is not the last one.
|
|
|
|
`last_event` is the probe's terminal word: the last event it recorded, kept
|
|
outside the ring and so never dropped. When it is present it is what the
|
|
tail branches read, and truncation stops mattering to them.
|
|
|
|
Ordered most specific first, and every branch is reading something the run
|
|
actually recorded rather than inferring from what it did not.
|
|
|
|
The unsupported-endpoint case is the subtle one. xnuboot.c logs every call
|
|
before dispatching it, and when the dispatch finds an endpoint it has not
|
|
implemented in strict mode it logs the call a second time and stops -- so a
|
|
run whose last event is a monitor call stopped because of that call.
|
|
|
|
This used to argue that no terminal word was needed, because "every other
|
|
way of stopping records its own terminal event". That was true of the
|
|
PROBE and false of the REPORT, and the difference is the whole bug: the
|
|
events are recorded, and then the 64-entry log drops them. A run that
|
|
overflows has no tail to read, so the tail branches were unavailable
|
|
exactly when the boot got far enough to reach them -- which is how a run
|
|
that made 390 monitor calls ended "stopped, unclassified ... the run
|
|
stopped for a reason nothing recorded". No log size fixes that; a longer
|
|
log only moves the point where it starts lying. The terminal word does."""
|
|
if not entered:
|
|
return EXIT_NO_ENTRY, 'the guest never started'
|
|
|
|
if tally:
|
|
owner = tally.get('service_owner', 0)
|
|
stop = tally.get('service_stop', 0)
|
|
faults = tally.get('service_faults', ())
|
|
lifecycle = tally.get('service_lifecycle')
|
|
reusable = tally.get('service_reusable')
|
|
bad_lifecycle = ((lifecycle is not None or reusable is not None) and
|
|
(lifecycle not in (1 << 63, (1 << 63) | 3) or
|
|
reusable != 1 or not stop))
|
|
if owner or any(faults) or bad_lifecycle or (stop and
|
|
((stop >> 32) != 1 or not 1 <= (stop & 0xffffffff) <= 6)):
|
|
return EXIT_EL2_FAULT, (f'monitor service lock failure: owner token {owner}, '
|
|
f'stop {stop:#x}, CPU faults {tuple(faults)}, '
|
|
f'lifecycle {lifecycle}, reusable {reusable}')
|
|
|
|
# Where the tail comes from. With the terminal word it is a fact about how
|
|
# the run ended; without one it is an inference from the log's last row,
|
|
# and truncation takes even that away. Everything that reads `kinds` is a
|
|
# fact about what happened either way.
|
|
if last_event is not None:
|
|
tail, last = last_event, last_event[0]
|
|
else:
|
|
tail = log[-1] if log else None
|
|
last = None if dropped else (tail[0] if tail else None)
|
|
# The out-of-ring terminal event is also evidence. Otherwise a fault or
|
|
# watchdog after log saturation becomes "unclassified" despite its record.
|
|
if last_event is not None and last_event not in log:
|
|
log = [*log, last_event]
|
|
kinds = {event[0] for event in log}
|
|
|
|
# An SError outranks everything for the same reason the watchdog does: it
|
|
# ends the run from outside whatever the guest was doing, so no verdict read
|
|
# off the log's tail describes how it actually stopped. It used to BE the
|
|
# watchdog verdict -- one vector served IRQ, FIQ and SError alike -- and the
|
|
# two readings are opposites: "the kernel needed longer" against "the kernel
|
|
# touched something the interconnect refused to answer".
|
|
if EV_SERROR in kinds:
|
|
entry = next(event for event in log if event[0] == EV_SERROR)
|
|
pc, esr = entry[1], entry[2]
|
|
return EXIT_SERROR, (
|
|
f'the guest took an SError at pc {pc:#x} -- ESR_EL2 {esr:#x} '
|
|
f'(EC {esr >> 26:#x}, IDS {(esr >> 24) & 1}, DFSC {esr & 0x3f:#04x}) '
|
|
f'-- an access the interconnect refused, not a kernel that needed '
|
|
f'more time')
|
|
if EV_DEVICE_RESET in kinds:
|
|
entry = next(event for event in log if event[0] == EV_DEVICE_RESET)
|
|
bitmap=entry[2] | (entry[3]<<64)
|
|
return EXIT_GUEST_RESET, (f'virtual device requested guest reset at pc {entry[1]:#x}; '
|
|
f'model bitmap {bitmap:#x}; host reset was not issued')
|
|
# An admitted early stop (compat/early-stop.h) ends the run through the
|
|
# watchdog's own path before the budget. The report's record says so even
|
|
# when the log saturated and the terminal word moved on. A panic the kernel
|
|
# announced first is still the finding, exactly as for the watchdog.
|
|
early = (tally or {}).get('early_stop') or {}
|
|
if EV_EARLY_STOP in kinds or early.get('outcome') == 1:
|
|
if (first := console_panic(console)):
|
|
return EXIT_PANIC, (f'the kernel panicked before the early stop took effect: '
|
|
f'{first.strip()[:120]}')
|
|
return EXIT_EARLY_STOP, early_stop_reason(early, log)
|
|
if EV_WATCHDOG in kinds:
|
|
if (first := console_panic(console)):
|
|
return EXIT_PANIC, (f'the kernel panicked before the watchdog recovered '
|
|
f'control: {first.strip()[:120]}')
|
|
return EXIT_WATCHDOG, ('the guest was still running when its time ran '
|
|
'out, which is not the same as getting anywhere')
|
|
# An IRQ or FIQ with the comparator not fired. Named, not modelled: nothing
|
|
# here routes any interrupt but the EL2 physical timer to EL2, so what
|
|
# raised this is something the project does not yet know about. Calling it a
|
|
# recognised stop would be the same confident falsehood in the other
|
|
# direction, so the code stays unclassified and the sentence carries the
|
|
# fact.
|
|
if EV_ASYNC in kinds:
|
|
entry = next(event for event in log if event[0] == EV_ASYNC)
|
|
pc, which = entry[1], entry[2]
|
|
return EXIT_UNCLASSIFIED, (
|
|
f'an {ASYNC_KINDS.get(which, which)} was taken to EL2 at guest pc '
|
|
f'{pc:#x} with the EL2 physical timer not fired -- nothing here '
|
|
f'routes any other interrupt to EL2, so what raised it is unknown')
|
|
# Words beat syndromes: if the kernel managed to say what it thought went
|
|
# wrong, that is the finding, and the exception that carried it is detail.
|
|
# A map we refused, named. Checked before the console because a panic
|
|
# downstream of our own error should say so: "pmap_enter failed" is the
|
|
# kernel's symptom, and our MAP_PAGE returning an error is the cause. The
|
|
# panic text is kept in the reason either way.
|
|
if EV_MAP_FAILED in kinds:
|
|
entry = next(event for event in log if event[0] == EV_MAP_FAILED)
|
|
reason, va, detail = entry[2] & 0xff, entry[3], entry[4]
|
|
# From the probe's own counter, never from counting log lines. record()
|
|
# collapses repeats by (kind, pc, a), so N failures at N addresses can
|
|
# arrive as one entry, and an overflowed log drops the rest outright --
|
|
# counting entries would under-report both ways. The log count is the
|
|
# fallback only when there is no tally, which is how the offline tests
|
|
# call this. `entry` is genuinely the first: the log fills from the
|
|
# front, so the head is the beginning of the run.
|
|
counted = (tally or {}).get('map_failures')
|
|
failures = counted if counted else sum(
|
|
1 for event in log if event[0] == EV_MAP_FAILED)
|
|
why = MAP_FAILURES.get(reason, f'reason {reason}, unrecognised')
|
|
said = ''
|
|
if (line := console_panic(console)):
|
|
said = f'; the kernel then panicked: {line.strip()[:90]}'
|
|
return EXIT_UNSUPPORTED, (
|
|
f'MAP_PAGE returned an error {failures} time'
|
|
f'{"" if failures == 1 else "s"}, first at va {va:#x} '
|
|
f'(detail {detail:#x}): {why}{said}')
|
|
if last == 34:
|
|
_, pc, packed, ipa, esr = tail[:5]
|
|
kind, reason = packed >> 32, packed & 0xffffffff
|
|
where, note = device_stop_where(models, ipa, esr, kind, reason)
|
|
return EXIT_UNSUPPORTED, (
|
|
f'virtual device model {kind} rejected access at {pc:#x}, '
|
|
f'IPA {ipa:#x}{where}, ESR {esr:#x}: '
|
|
f'{DEVICE_FAILURES.get(reason, f"unknown device error {reason}")}{note}')
|
|
if last == 50:
|
|
# compat/ans.h: a = ANS_E_* << 32 | model kind (MMIO) or table << 8 |
|
|
# endpoint (SPTM); b, c = IPA and ESR, or x0 and x1.
|
|
import ansstate
|
|
_, pc, packed, b, c = tail[:5]
|
|
reason, where = packed >> 32, packed & 0xffffffff
|
|
name = ansstate.ERRORS[reason] if reason < len(ansstate.ERRORS) else f'error {reason}'
|
|
site = (f'model {where}, IPA {b:#x}, ESR {c:#x}' if where in (49, 50) else
|
|
f'SPTM table {where >> 8} endpoint {where & 0xff}, x0 {b:#x}, x1 {c:#x}')
|
|
return EXIT_UNSUPPORTED, f'the virtual ANS2 refused ({name}) at {pc:#x}: {site}'
|
|
if last == 39:
|
|
_, pc, reason, iss, value = tail[:5]
|
|
return EXIT_UNSUPPORTED, (
|
|
f'SPRR permission operation rejected at {pc:#x}, ISS {iss:#x}, value {value:#x}: '
|
|
f'{SPRR_FAILURES.get(reason, f"unknown SPRR error {reason}")}')
|
|
if last == 43:
|
|
return EXIT_UNSUPPORTED, (f'panic-state contract stopped at {tail[1]:#x}: '
|
|
f'command {tail[2]:#x}, owner {tail[3]:#x}, CPU {tail[4]}')
|
|
if last == 44:
|
|
return EXIT_UNSUPPORTED, (f'native writer breakpoint rejected at {tail[1]:#x}: '
|
|
f'ESR {tail[2]:#x}, instruction PA {tail[3]:#x}, word {tail[4]:#x}')
|
|
if last == 46:
|
|
return EXIT_UNSUPPORTED, (f'the guest touched a trapped Adreno block at {tail[1]:#x}: '
|
|
f'IPA {tail[2]:#x} ({ADRENO_BLOCKS.get(tail[4], tail[4])}), {s2_fault_words(tail[2], tail[3])}')
|
|
if last == 48 and tail[4] == (1 << 64) - 1:
|
|
return EXIT_UNSUPPORTED, (f'the guest made an HVC the monitor does not forward at {tail[1]:#x}: '
|
|
f'x0 {tail[2]:#x}, x1 {tail[3]:#x}')
|
|
if last == 47 and tail[2] >> 63:
|
|
return EXIT_UNSUPPORTED, (f'the Adreno SMMU mirror refused at {tail[1]:#x}: '
|
|
f'{tail[2] - (1 << 64)}, IOVA {tail[3]:#x}, endpoint {tail[4]}')
|
|
if last == 38:
|
|
_, pc, reason, endpoint, argument = tail[:5]
|
|
return EXIT_UNSUPPORTED, (
|
|
f'CPU monitor contract rejected at {pc:#x}, endpoint {endpoint:#x}, argument {argument:#x}: '
|
|
f'{CPU_FAILURES.get(reason, f"unknown CPU error {reason}")}')
|
|
if last == 37:
|
|
_, pc, reason, iss, value = tail[:5]
|
|
return EXIT_UNSUPPORTED, (
|
|
f'IPI request rejected at {pc:#x}, ISS {iss:#x}, value {value:#x}: '
|
|
f'{IPI_FAILURES.get(reason, f"unknown IPI error {reason}")}')
|
|
if last == 32:
|
|
_, pc, reason, iss, value = tail[:5]
|
|
return EXIT_UNSUPPORTED, (
|
|
f'clock request rejected at {pc:#x}, ISS {iss:#x}, value {value:#x}: '
|
|
f'{CLOCK_FAILURES.get(reason, f"unknown clock error {reason}")}')
|
|
if last == 54:
|
|
_, pc, full, root, groups = tail[:5]
|
|
return EXIT_UNSUPPORTED, (
|
|
f'shared-region group records exhausted at {pc:#x}, root {root:#x}: all {groups} records '
|
|
f'are in use (full {full}); SNAP_NATIVE_SHARED cannot mirror another nested level-3 table')
|
|
if last in (18, 20, 22, 26, 27, 28, 29, 30):
|
|
_, pc, reason, pa, new_type = tail[:5]
|
|
return EXIT_UNSUPPORTED, (
|
|
f'{ {18: "RETYPE", 20: "SPTM table/root operation", 22: "UNMAP_REGION", 26: "UPDATE_DISJOINT", 27: "UPDATE_REGION", 28: "mapping references", 29: "SLIDE_REGION", 30: "TXM request"}[last]} rejected at {pc:#x}, '
|
|
f'address {pa:#x}, requested value {new_type:#x}: '
|
|
f'{RETYPE_FAILURES.get(reason, f"unknown error {reason}")}')
|
|
if (first := console_panic(console)):
|
|
return EXIT_PANIC, f'the kernel panicked: {first.strip()[:120]}'
|
|
# XNU saying so itself, which beats us inferring it from a syndrome. This
|
|
# call is made on the way out of a fatal exception, before panic() has run,
|
|
# so it fires even when no panic string is ever produced.
|
|
if EV_PANIC_ENTRY in kinds:
|
|
entry = next(event for event in log if event[0] == EV_PANIC_ENTRY)
|
|
caller, elr, esr = entry[2], entry[3], entry[4]
|
|
if caller == WEDGE_CALLER:
|
|
return EXIT_PANIC, (
|
|
f'XNU wedged: an integrity check failed at ELR_EL1 {elr:#x} '
|
|
f'(ESR {esr:#x}) and it is spinning on wfe forever, so nothing '
|
|
f'after that call runs whatever we answer')
|
|
return EXIT_PANIC, (
|
|
f'XNU announced a fatal exception from {caller:#x} '
|
|
f'({PANIC_CALLERS.get(caller, "an unrecognised path")}), '
|
|
f'ELR_EL1 {elr:#x} ESR_EL1 {esr:#x} (EC {esr >> 26:#x})')
|
|
if last == EV_GUEST_EXC:
|
|
return EXIT_GUEST_EXC, 'the probe stopped at entry to the guest exception handler'
|
|
if last == 25:
|
|
return EXIT_UNSUPPORTED, (f'unsupported exception return at {tail[1]:#x}: '
|
|
f'target {tail[2]:#x}, SPSR {tail[3]:#x}, '
|
|
f'instruction {tail[4]:#x}')
|
|
if last == 42:
|
|
return EXIT_UNSUPPORTED, (f'EL0 execution witness rejected at {tail[1]:#x}: '
|
|
f'entry {tail[2]:#x}, X0 {tail[3]:#x}, '
|
|
f'physical SPSR {tail[4]:#x}')
|
|
# A device we model, at a register we do not. Checked before the plain
|
|
# stage-2 fault because it is the more specific of the two: the address is
|
|
# already known to be a device window the kernel's own tree names, and the
|
|
# event carries the offset, which is the thing the next iteration needs.
|
|
if EV_MMIO_STOP in kinds:
|
|
entry = next(event for event in log if event[0] == EV_MMIO_STOP)
|
|
pc, a, esr = entry[1], entry[2], entry[3]
|
|
return EXIT_UNSUPPORTED, (
|
|
f'the kernel used a device register we do not model: '
|
|
f'{mmio_stop_words(a, esr)}, at pc {pc:#x}')
|
|
if EV_S2_FAULT in kinds:
|
|
entry = next(event for event in log if event[0] == EV_S2_FAULT)
|
|
pc, ipa, esr = entry[1], entry[2], entry[3]
|
|
return EXIT_GUEST_EXC, (
|
|
f'the kernel touched an intermediate address stage 2 does not give '
|
|
f'it: IPA {f"{ipa:#x}" if ipa else "unknown"} '
|
|
f'({s2_fault_words(ipa, esr)}) at pc {pc:#x}'
|
|
f'{s2_owner_words(ipa, inventory) if ipa else ""}')
|
|
# A call that is not an endpoint call at all. Checked before EV_MONITOR
|
|
# because the guest records both for one of these -- the call, then the
|
|
# refusal -- and the refusal is the more specific of the two.
|
|
if last == EV_DISPATCH_CMD:
|
|
cmd, _, x16 = tail[2], tail[3], tail[4]
|
|
return EXIT_UNSUPPORTED, (
|
|
f'the kernel made dispatch command {cmd:#x} (x16 {x16:#018x}), which '
|
|
f'is not an endpoint call and is not implemented')
|
|
if last == EV_MONITOR:
|
|
x16 = tail[2]
|
|
cmd, domain = (x16 >> 56) & 0xff, (x16 >> 48) & 0xff
|
|
table, fn = (x16 >> 32) & 0xffff, x16 & 0xffffffff
|
|
name = route_name(domain, table)
|
|
if cmd:
|
|
return EXIT_UNSUPPORTED, (
|
|
f'the kernel made dispatch command {cmd:#x} (x16 {x16:#018x}), '
|
|
f'which is not an endpoint call and is not implemented')
|
|
# Never claim an endpoint is unimplemented when this same run counted
|
|
# completions of it. That sentence was emitted for MAP_PAGE after 379
|
|
# successful calls, off a truncated log, and it is exactly the kind of
|
|
# confident falsehood the transcript must not contain. The check is two
|
|
# lines and does not depend on anyone reading the log.
|
|
completed = (tally or {}).get('map_pages', 0)
|
|
if domain == 0 and table == 0 and fn == SPTM_FN_MAP_PAGE and completed:
|
|
return EXIT_UNCLASSIFIED, (
|
|
f'the last event is a {name} endpoint {fn:#x} call, but this run '
|
|
f'completed {completed} of them -- the log does not reach the '
|
|
f'real ending, so no cause can be named from it')
|
|
return EXIT_UNSUPPORTED, (f'the kernel asked for {name} endpoint {fn:#x}, '
|
|
f'which is not implemented')
|
|
if fault:
|
|
# The slot carries a syndrome. Anything else in it is a bug on the
|
|
# probe's side, and saying so is better than decoding a code address as
|
|
# an ESR -- which is what this branch did for every run after XNU
|
|
# registered its exception-return trampoline.
|
|
if not is_syndrome(fault):
|
|
return EXIT_UNCLASSIFIED, (
|
|
f'the run ended without a recognised stop, and the fault word '
|
|
f'holds {fault:#x}, which is not a syndrome -- so nothing here '
|
|
f'may report it as one')
|
|
return EXIT_GUEST_EXC, (f'a fault was recorded (ESR {fault:#x}, '
|
|
f'EC {fault >> 26:#x})')
|
|
# Nothing above matched. With the terminal word we can still say WHAT the
|
|
# run was doing when it stopped, even though we cannot say why -- and that
|
|
# is the difference between "the run stopped for a reason nothing recorded"
|
|
# and a sentence somebody can act on. The code stays unclassified, because
|
|
# naming the last event is not the same as recognising the stop.
|
|
if last_event is not None:
|
|
kind, pc, a, b, c = last_event[:5]
|
|
return EXIT_UNCLASSIFIED, (
|
|
f'the run stopped in a way this does not model; the last thing it '
|
|
f'recorded was "{EVENTS.get(kind, kind)}" at pc {pc:#x} '
|
|
f'(a {a:#x}, b {b:#x}, c {c:#x}) -- the terminal word, which the '
|
|
f'log cannot drop')
|
|
# Nothing above matched and there is no terminal word either, so the run
|
|
# ended in a way this does not model. Saying so is the point: the
|
|
# alternative is calling it success.
|
|
return EXIT_UNCLASSIFIED, NOTHING_RECOGNISED
|
|
|
|
|
|
def conclude(entered, fault, console, log, tally=None, dropped=0,
|
|
panic_said=None, last_event=None, models=(), inventory=()):
|
|
"""The verdict for a finished run, log loss and all.
|
|
|
|
An overflowed log used to skip classify() entirely and report only the loss.
|
|
That threw away everything the surviving head held -- including MAP_PAGE
|
|
failures, which is what the last two commits exist to surface -- and as the
|
|
boot gets longer, overflow stops being the edge case and becomes the normal
|
|
one. So: classify from what survived, with the tail inferences suppressed,
|
|
and fall back to the loss report only when what survived names nothing."""
|
|
code, why = classify(entered, fault, console, log, tally, dropped,
|
|
last_event, models, inventory)
|
|
if panic_said and code in (EXIT_GUEST_EXC, EXIT_PANIC, EXIT_UNCLASSIFIED):
|
|
code, why = EXIT_PANIC, f'the kernel panicked: {panic_said.strip()[:240]}'
|
|
if not dropped:
|
|
return code, why
|
|
# With a terminal word the ending is not in doubt, whichever branch named
|
|
# it: either a fact from the surviving head, or the last event itself. What
|
|
# the loss costs is the middle of the run, and that is what this says.
|
|
if last_event is not None:
|
|
return code, (f'{why} ({dropped} events did not fit the {len(log)}-entry '
|
|
f'log; the run\'s last event is recorded outside it, so '
|
|
f'how it ended is known and what led there is not)')
|
|
if why == NOTHING_RECOGNISED:
|
|
# Nothing in the head named a cause, so the loss is the finding -- and
|
|
# XNU's panic record, which memory holds and truncation cannot reach,
|
|
# is the better answer when there is one. Keyed on what was found and
|
|
# not on the exit code, because a stray IRQ and a fault word that is not
|
|
# a syndrome are both unclassified AND both facts the head still holds.
|
|
return truncated(dropped, len(log), panic_said)
|
|
# A fact from the head. It stands, and the loss is disclosed beside it so
|
|
# nobody reads it as a complete account of how the run ended.
|
|
return code, (f'{why} ({dropped} events did not fit the {len(log)}-entry '
|
|
f'log, so this is what the surviving head names, not '
|
|
f'necessarily the last thing that happened)')
|
|
|
|
|
|
FOUR_GIB = 1 << 32
|
|
|
|
|
|
def free_region(proxy, info, need, xb_align=0x2000000, required_base=None, exclude=()):
|
|
"""The largest free physical span the target reports, big enough for `need`.
|
|
|
|
q1n1 publishes the UEFI memory map it captured before ExitBootServices, so
|
|
the target can be asked where its memory is instead of being told. What it
|
|
reports is where firmware said RAM was; what q1n1 then took out of it --
|
|
its own image, heap, DMA buffer and staging area -- is in the bootinfo, and
|
|
is carved back out here, because the map was taken before those existed.
|
|
|
|
Under 4 GiB is preferred but not required: it lets TCR_EL1.IPS stay at 0 and
|
|
keeps the stage-2 identity map to 36 bits.
|
|
|
|
`exclude` is [(low, high)] carved out as well: reservations made for this
|
|
launch only (the QCC2072 arena, probes/qcc2072wifi.RESERVATION)."""
|
|
free = monitor_work.firmware_free(proxy, info)
|
|
for low, high in exclude:
|
|
free = [piece for start, end in free
|
|
for piece in ((start, min(end, low)), (max(start, high), end)) if piece[0] < piece[1]]
|
|
best = None
|
|
for start, end in free:
|
|
# On the 32 MiB grid, both ends. XNU rounds the top of DRAM up to a
|
|
# 32 MiB boundary (0xfffffe000ba7a7ec), so a region that ends anywhere
|
|
# else has arithmetic pointing just past it.
|
|
start = (start + xb_align - 1) // xb_align * xb_align
|
|
end = end // xb_align * xb_align
|
|
if required_base is not None:
|
|
if required_base % xb_align or not start <= required_base < end:
|
|
continue
|
|
start = required_base
|
|
if end - start < need:
|
|
continue
|
|
score = (end - start >= need and start + need <= FOUR_GIB, end - start)
|
|
if best is None or score > best[0]:
|
|
best = (score, start, end)
|
|
return None if best is None else (best[1], best[2])
|
|
|
|
|
|
def check_panel_control(proxy):
|
|
"""Require panel ownership before a large upload, retaining its old state."""
|
|
try:
|
|
previous = proxy.fb_console(False)
|
|
if previous not in (0, 1):
|
|
raise ValueError('q1n1 returned an invalid panel ownership state')
|
|
if previous and proxy.fb_console(True) != 0:
|
|
raise ValueError('q1n1 did not retain the panel ownership transition')
|
|
except q1n1.ProxyRemoteError as error:
|
|
raise ValueError('resident q1n1 rejected panel ownership; RAM-chainload '
|
|
'the latest q1n1 stage before running XNU') from error
|
|
|
|
|
|
# q1n1's call service (q1n1 platform/uefi/call-service.h), bootinfo words 66
|
|
# and 67. Read directly, so an older tools/q1n1proxy.py field list still finds
|
|
# them; its state record starts with magic, version and size.
|
|
CALL_SERVICE_WORD = 66
|
|
CALL_STATE_MAGIC = 0x4c4c4143314e3151 # "Q1N1CALL"
|
|
|
|
|
|
def call_service(proxy, info):
|
|
"""(entry, state) of the running q1n1 stage's call service, or (0, 0)."""
|
|
base = info['address']
|
|
size = struct.unpack('<Q', proxy.readmem(base + 16, 8))[0]
|
|
if size < (CALL_SERVICE_WORD + 2) * 8:
|
|
return 0, 0
|
|
entry, state = struct.unpack('<2Q', proxy.readmem(base + CALL_SERVICE_WORD * 8, 16))
|
|
if not entry:
|
|
return 0, 0
|
|
image, span = info['image_base'], info['image_size']
|
|
if entry & 3 or not image <= entry < image + span or not image <= state < image + span - 24:
|
|
raise ValueError(f'call service {entry:#x}/{state:#x} lies outside the running q1n1 image')
|
|
magic, version, length = struct.unpack('<3Q', proxy.readmem(state, 24))
|
|
if magic != CALL_STATE_MAGIC or version != 1 or length < 24 or length > 4096:
|
|
raise ValueError('q1n1 call-service record has the wrong magic, version or size')
|
|
return entry, state
|
|
|
|
|
|
def host_stop_link(proxy):
|
|
"""The host trigger reaches q1n1 only as a UDP datagram on the USB-NCM
|
|
link the proxy itself uses (the stop port beside the proxy's). Over CDC or
|
|
a socket nothing could deliver one, so a launch there must not claim it."""
|
|
endpoint = str(getattr(proxy, 'endpoint', '') or '')
|
|
if not endpoint.startswith('udp://') or not endpoint[6:]:
|
|
raise ValueError('the host trigger needs the proxy on USB-NCM (udp://INTERFACE), not '
|
|
+ (endpoint or 'an unnamed link'))
|
|
return endpoint
|
|
|
|
|
|
def run_xnuboot(proxy, info, code, guest_profile='j700ap-26A428',
|
|
firmware_dir=None, kernelcache=None, console_mode='auto',
|
|
relay_panel=False, ramdisk=None, root_hash=None, ramdisk_mode='md0', isc_image=None,
|
|
graphics_boot=False, auxkc=None, trust_additions=None, ans_disk=None, ans_writable=False,
|
|
code_size=None):
|
|
"""Put a stock kernelcache in memory and enter it at EL1.
|
|
|
|
The image, its 16K page tables and the handoff are all built and checked on
|
|
the host first (probes/xnuboot.py); this uploads them and makes the call."""
|
|
import importlib.util
|
|
import preload
|
|
try:
|
|
cached_files = preload.resident_cache(proxy, info)
|
|
except ValueError as error:
|
|
return verdict(EXIT_LOADER, f'invalid EFI asset cache: {error}')
|
|
spec = importlib.util.spec_from_file_location('xnuboot', ROOT / 'probes/xnuboot.py')
|
|
xb = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(xb)
|
|
try:
|
|
xb.configure_guest(guest_profile, firmware_dir)
|
|
# SNAP_TXM_ASPACE_WIDE=1 goes with a -DSNAP_TXM_ASPACE_WIDE=1 monitor
|
|
# (compat/txm.h A2); unset keeps the 8-page layout. Before place().
|
|
xb.configure_txm_arena()
|
|
# SNAP_ADRENO_VA_WIDE=1 goes with a -DADRENO_VA_WIDE=1 monitor and kext and a q1n1
|
|
# `cp-fence/adopt --va-wide` CB0 (agents/gpu-va-wide); unset keeps the 3 GiB window. Before place().
|
|
if xb.configure_adreno_va_wide() and xb.GUEST_PROFILE.adreno is None:
|
|
raise ValueError(f'{xb.ADRENO_VA_WIDE_ENV}=1 needs an Adreno profile')
|
|
if xb.GUEST_PROFILE.sep_asc and xb.GUEST_PROFILE.sep_asc.pnon_recovery and (
|
|
not ramdisk or not root_hash or ramdisk_mode!='iokit'):
|
|
raise ValueError('ordinary-recovery pnon requires the hash-bound IOKit BaseSystem root')
|
|
host = HostRuntime.from_q1n1(info, with_framebuffer=
|
|
not bool(os.environ.get('SNAP_NO_FB')))
|
|
if console_mode == 'auto':
|
|
console_mode = 'panel' if host.framebuffer else 'serial'
|
|
if graphics_boot:
|
|
if console_mode!='panel' or not host.framebuffer or relay_panel:
|
|
raise ValueError('--graphics-boot requires a native framebuffer and panel route')
|
|
xb.VIDEO_DISPLAY_MODE=1
|
|
xb.BOOT_CMDLINE=' '.join(arg for arg in xb.BOOT_CMDLINE.split() if arg!='-v')
|
|
if relay_panel and host.framebuffer:
|
|
raise ValueError('--relay-panel requires SNAP_NO_FB=1; host drawing '
|
|
'cannot share a framebuffer measurement')
|
|
xb.HOST_TIMEBASE_HZ = host.timer_hz
|
|
kernelcache = kernelcache or xb.DEFAULT_KERNELCACHE
|
|
except ValueError as error:
|
|
return verdict(EXIT_LOADER, str(error))
|
|
# The QCC2072 Wi-Fi passthrough (probes/qcc2072wifi.py), opt-in: its three
|
|
# inputs, staged and hash-checked by cycle.py installed-fresh --prepare,
|
|
# read before anything is placed. Without SNAP_QCC2072_WIFI nothing here runs.
|
|
qcc_inputs = None
|
|
xb.QCC2072_WIFI = None
|
|
# --present-beam's read-only DPU INTF_5 page (probes/displaytiming.py), opt-in: the loader half of a
|
|
# -DSNAP_DISPLAY_TIMING=1 monitor. Without SNAP_DISPLAY_TIMING nothing here runs.
|
|
xb.DISPLAY_TIMING = None
|
|
if os.environ.get('SNAP_DISPLAY_TIMING'):
|
|
if os.environ['SNAP_DISPLAY_TIMING'] != '1':
|
|
return verdict(EXIT_LOADER, 'SNAP_DISPLAY_TIMING must be 1')
|
|
import displaytiming
|
|
xb.DISPLAY_TIMING = displaytiming.SPEC
|
|
if os.environ.get('SNAP_QCC2072_WIFI'):
|
|
import qcc2072wifi
|
|
try:
|
|
if os.environ['SNAP_QCC2072_WIFI'] != '1':
|
|
raise ValueError('SNAP_QCC2072_WIFI must be 1')
|
|
qcc_inputs = qcc2072wifi.load_inputs(os.environ[qcc2072wifi.INPUTS_ENV])
|
|
except (KeyError, OSError, ValueError) as error:
|
|
return verdict(EXIT_LOADER, f'QCC2072 inputs refused: {error!r}')
|
|
|
|
# The profile, before anything is built. Every address in xnuboot.py was
|
|
# read out of one specific set of firmware images, and none of them are
|
|
# re-derived at run time -- so a firmware swap does not fail, it produces a
|
|
# full transcript describing a machine that does not exist. Checking four
|
|
# hashes costs under a second of a run that spends fifty on the upload.
|
|
bad = xb.check_profile(kernelcache=kernelcache)
|
|
if bad:
|
|
return verdict(EXIT_LOADER,
|
|
f'firmware does not match profile {xb.PROFILE_NAME}: '
|
|
+ '; '.join(bad))
|
|
print(f' profile {xb.PROFILE_NAME} ({xb.PROFILE_TARGET}, {xb.PROFILE_BUILD}), '
|
|
f'firmware verified')
|
|
print(f' host adapter {host.adapter}, timer {host.timer_hz} Hz; '
|
|
f'guest counter {xb.TIMEBASE_HZ} Hz; guest ABI {xb.GUEST_PROFILE.abi}')
|
|
if host.framebuffer:
|
|
try:
|
|
check_panel_control(proxy)
|
|
except ValueError as error:
|
|
return verdict(EXIT_LOADER, str(error))
|
|
|
|
# Full-install M2: the virtual ANS2's RAM disk, pinned by the profile and
|
|
# checked whole (both GPT copies, the APFS container geometry) before any
|
|
# placement. It reaches the guest only through the monitor's controller.
|
|
ans_image = None
|
|
ans_window = xb.GUEST_PROFILE.ans_boot is not None and xb.GUEST_PROFILE.ans_boot.window
|
|
if ans_writable and (not ans_window or not xb.GUEST_PROFILE.installed_root):
|
|
return verdict(EXIT_LOADER, '--ans-writable requires an installed SSD window profile')
|
|
if ans_window and ans_disk:
|
|
return verdict(EXIT_LOADER, '--ans-disk does not apply to the SSD window profile')
|
|
if xb.GUEST_PROFILE.ans_boot is not None and not ans_window:
|
|
import ansdisk
|
|
import hashlib
|
|
spec = xb.GUEST_PROFILE.ans_boot
|
|
try:
|
|
if not ans_disk:
|
|
raise ValueError('an ANS2 profile needs --ans-disk '
|
|
'(build/build-ans-test-disk-20260925.py)')
|
|
ans_image = Path(ans_disk).read_bytes()
|
|
if (len(ans_image) != spec.disk_bytes or
|
|
hashlib.sha256(ans_image).hexdigest() != spec.disk_sha256):
|
|
raise ValueError(f'{ans_disk} is not the disk profile {xb.PROFILE_NAME} pins')
|
|
ansdisk.inspect(ans_image)
|
|
except (OSError, ValueError) as error:
|
|
return verdict(EXIT_LOADER, f'virtual ANS2 disk refused: {error}')
|
|
print(f' virtual ANS2 disk {ans_disk}: {len(ans_image) >> 20} MiB, 4 KiB LBAs, '
|
|
f'sha256 {spec.disk_sha256[:16]}... (profile pin)')
|
|
elif ans_disk:
|
|
return verdict(EXIT_LOADER, '--ans-disk needs a profile with a virtual ANS2')
|
|
# How much memory the guest gets, stated rather than inherited from whatever
|
|
# the firmware left free. See xnuboot.DEFAULT_GUEST_MIB.
|
|
guest_span = int(os.environ.get('SNAP_GUEST_MIB', xb.DEFAULT_GUEST_MIB)) << 20
|
|
want = os.environ.get('SNAP_PHYS_BASE')
|
|
try:
|
|
# Ask for the reserve too. The kernel puts a structure above the
|
|
# DRAM we declare, so the free span has to extend past the
|
|
# declaration by that much.
|
|
need = guest_span + xb.bootstrap_reserve(guest_span) + monitor_work.WORK_SIZE
|
|
if xb.GUEST_PROFILE.ans_boot is not None:
|
|
need += xb.ans_reservation()[1] + ((1 << 20) if ans_window else 0) # window: 1 MiB alignment
|
|
if xb.ADRENO_VA_WIDE:
|
|
import adreno
|
|
need += adreno.pool_reservation_bytes() + adreno.POOL_RESERVATION_ALIGN # its 64 KiB alignment
|
|
region = free_region(proxy, info, need, xb.REGION_ALIGN,
|
|
required_base=int(want, 0) if want else None,
|
|
**({'exclude': [qcc2072wifi.RESERVATION]} if qcc_inputs else {}))
|
|
if region is None:
|
|
# Not a warning. Carrying on with the default placement runs the
|
|
# image somewhere the target did not say was free, and whatever
|
|
# happens next is evidence about the wrong machine.
|
|
return verdict(EXIT_LOADER,
|
|
f'no free region of {need >> 20} MiB in the '
|
|
f"target's memory map")
|
|
start, end = region
|
|
xb.place(start, end - start, guest_span=guest_span)
|
|
work = (xb.BOOTSTRAP_PA + xb.BOOTSTRAP_SPAN + 65535) & ~65535
|
|
work_size = monitor_work.WORK_SIZE
|
|
monitor_work.validate_region(work, work_size, xb.PHYS_BASE,
|
|
xb.BOOTSTRAP_PA + xb.BOOTSTRAP_SPAN, info, [region])
|
|
print(f' layout placed at {start:#x} .. {xb.REGION_END:#x} '
|
|
f'({xb.REGION_SPAN >> 20} MiB guest, of {(end - start) >> 20} '
|
|
f'MiB free, {xb.BOOTSTRAP_SPAN >> 20} MiB held back above it)')
|
|
print(f' monitor work arena {work:#x} .. {work + work_size:#x} '
|
|
f'({work_size >> 20} MiB, separate from guest and q1n1 heap)')
|
|
ans_range = xb.ans_reservation()
|
|
monitor_end = work + work_size
|
|
if ans_range:
|
|
monitor_work.validate_ans(*ans_range, work, work_size, info, [region])
|
|
monitor_end = ans_range[0] + ans_range[1]
|
|
print(f' virtual ANS2 reservation {ans_range[0]:#x} .. {monitor_end:#x} '
|
|
+ ('(state, GPT head/tail, SSD params and its 1 MiB DMA window; outside stage 2)'
|
|
if ans_window else
|
|
f'(state, reserved page, {len(ans_image) >> 20} MiB disk; outside stage 2)'))
|
|
gpu_pool = xb.gpu_pool_reservation()
|
|
if gpu_pool:
|
|
monitor_work.validate_gpu_pool(*gpu_pool, monitor_end, info, [region])
|
|
monitor_end = gpu_pool[0] + gpu_pool[1]
|
|
print(f' GPU table reservation {gpu_pool[0]:#x} .. {monitor_end:#x} (ADRENO_VA_WIDE: 15 level-2 and '
|
|
'7680 level-3 tables for GPU VA [1 GiB, 16 GiB); outside stage 2)')
|
|
except (ValueError, SystemExit) as error:
|
|
return verdict(EXIT_LOADER, f'could not place the layout: {error}')
|
|
|
|
# Full-install M6: q1n1 takes the SSD over with its queues in the reservation's DMA window,
|
|
# checks the partition and its window header through its own reads, and leaves it running for
|
|
# the monitor (q1n1 tools/nvme_m3.py adopt mode). Nothing else writes that 1 MiB afterwards.
|
|
ans_window_parts = None
|
|
if ans_window:
|
|
import argparse as _argparse
|
|
import answindow
|
|
import nvme_m3
|
|
spec = xb.GUEST_PROFILE.ans_boot
|
|
adopt = dict(tool='nvme_m3', format=1, mode='adopt', stopped=None)
|
|
try:
|
|
status = nvme_m3.run(proxy, _argparse.Namespace(
|
|
adopt_dma=ans_range[0] + answindow.WINDOW_DMA, partition_guid=spec.window_guid,
|
|
survey=None, timed_mib=0), adopt)
|
|
adopt['exit_code'] = status
|
|
if status:
|
|
raise ValueError(f'adopt exited {status}')
|
|
head = adopt['window_armed']['header']
|
|
desc = answindow.layout(spec.disk_bytes, head['isc_offset'], head['isc_region'],
|
|
head['container_offset'])
|
|
import uuid as _uuid
|
|
overlays = answindow.overlays(desc, *(_uuid.UUID(g) for g in spec.window_gpt))
|
|
params = answindow.nvmeh_params(adopt, ans_range[0], host.timer_hz, writable=ans_writable)
|
|
adopt['guest_writable'] = ans_writable
|
|
adopt['guest_params_hex'] = params.hex()
|
|
ans_window_parts = (overlays[0], overlays[1], params)
|
|
except (nvme_m3.Stop, ValueError, KeyError) as error:
|
|
adopt['stopped'] = adopt['stopped'] or dict(reason=str(error))
|
|
return verdict(EXIT_LOADER, f'SSD window refused: {error}')
|
|
finally:
|
|
prefix = os.environ.get('SNAP_SMP_REPORT_PREFIX')
|
|
if prefix:
|
|
import json as _json
|
|
with (ROOT/'build'/f'{prefix}-nvme-adopt.json').open('x') as output:
|
|
_json.dump(adopt, output, indent=1)
|
|
armed = adopt['window_armed']
|
|
print(f' SSD window armed: partition {armed["partition"]} LBA {armed["first_lba"]}..'
|
|
f'{armed["last_lba"]}, header {armed["header"]["provisioned"]}, '
|
|
f'queues left running at {ans_range[0] + answindow.WINDOW_DMA:#x}, '
|
|
f'guest {"read/write with FUA and Flush" if ans_writable else "read-only"}')
|
|
|
|
# Also guard explicit SNAP_PHYS_BASE placement. The normal allocator only
|
|
# chooses conventional memory; the cache and its table are LoaderData.
|
|
cache_ranges = [(f['base'], f['allocation']) for f in cached_files]
|
|
if info.get('preload_table'):
|
|
cache_ranges.append((info['preload_table'], (info['preload_size']+4095)&~4095))
|
|
if any(xb.PHYS_BASE < base+size and base < monitor_end
|
|
for base, size in cache_ranges):
|
|
return verdict(EXIT_LOADER, 'guest or monitor placement overlaps reserved EFI asset cache')
|
|
# Stage 4: the GPU q1n1 powered, adopted from `a16gpu.py adopt`. The record
|
|
# must describe this q1n1 stage and its arena, and the arena must miss the
|
|
# guest, the monitor, the EFI asset cache and the panel.
|
|
xb.ADRENO_RECORD = None
|
|
if xb.GUEST_PROFILE.adreno is not None:
|
|
import adreno
|
|
path = os.environ.get('SNAP_ADRENO_ADOPT')
|
|
if not path:
|
|
return verdict(EXIT_LOADER, 'an Adreno profile needs SNAP_ADRENO_ADOPT '
|
|
'(q1n1 tools/a16gpu.py adopt --record FILE, just before the launch)')
|
|
try:
|
|
record = adreno.load_adoption(path, info, xb.ADRENO_VA_WIDE)
|
|
adreno.check_disjoint(record, [(xb.PHYS_BASE, monitor_end - xb.PHYS_BASE),
|
|
*cache_ranges,
|
|
(info.get('fb_base', 0), info.get('fb_size', 0))])
|
|
if xb.ADRENO_VA_WIDE:
|
|
adreno.check_pool_reservation(record, xb.gpu_pool_reservation()[0],
|
|
[(xb.PHYS_BASE, xb.BOOTSTRAP_PA + xb.BOOTSTRAP_SPAN - xb.PHYS_BASE),
|
|
*cache_ranges, (info.get('fb_base', 0), info.get('fb_size', 0))])
|
|
except (OSError, ValueError, KeyError) as error:
|
|
return verdict(EXIT_LOADER, f'Adreno adoption refused: {error}')
|
|
xb.ADRENO_RECORD = record
|
|
print(f' Adreno adopted from q1n1 generation {record["stage_generation"]}: arena '
|
|
f'{record["arena"]["base"]:#x}+{record["arena"]["size"]:#x}, ring wptr '
|
|
f'{record["ring"]["wptr"]}, CB0 TTBR0 {record["cb0"]["ttbr0"]:#x}, '
|
|
f'TZ init {"done" if record.get("tz_init_done") else "not made"}')
|
|
|
|
# The QCC2072 Wi-Fi admission, live and before the image is built: the
|
|
# reservation in this boot's free memory and clear of every placement, the
|
|
# arena in the PCIe SMMU's identity stage 2, the endpoint in PBL with its
|
|
# command register at memory decode only. The record binds this stage's
|
|
# generation and a fresh cookie. Retained as PREFIX-qcc2072-admission.json.
|
|
qcc_record = None
|
|
if qcc_inputs:
|
|
import json as _json
|
|
import secrets
|
|
qcc_record = dict(status='ADMITTING', stage_generation=info['stage_generation'])
|
|
try:
|
|
occupied = [(xb.PHYS_BASE, monitor_end - xb.PHYS_BASE), *cache_ranges,
|
|
(info.get('fb_base', 0), info.get('fb_size', 0))]
|
|
if xb.ADRENO_RECORD:
|
|
occupied.append((xb.ADRENO_RECORD['arena']['base'], xb.ADRENO_RECORD['arena']['size']))
|
|
qcc_record.update(qcc2072wifi.admit(proxy, info, monitor_work.firmware_free(proxy, info), occupied))
|
|
spec = qcc2072wifi.Spec(info['stage_generation'] or 1, secrets.randbits(63) | 1,
|
|
xb.PHYS_BASE, (1 << 36) - xb.PHYS_BASE)
|
|
xb.QCC2072_WIFI = qcc2072wifi.grant(spec, qcc_inputs)
|
|
qcc_record.update(status='ADMITTED', generation=spec.generation, cookie=hex(spec.cookie),
|
|
guest=[hex(spec.guest_pa), hex(spec.guest_bytes)],
|
|
record_hex=xb.QCC2072_WIFI.record.hex(),
|
|
inputs={n: qcc2072wifi.SHA(d) for n, d in qcc_inputs.items()})
|
|
except (KeyError, OSError, ValueError) as error:
|
|
qcc_record.update(status='REFUSED', error=repr(error))
|
|
prefix = os.environ.get('SNAP_SMP_REPORT_PREFIX')
|
|
if prefix:
|
|
with (ROOT / 'build' / f'{prefix}-qcc2072-admission.json').open('x') as output:
|
|
_json.dump(qcc_record, output, indent=1)
|
|
if qcc_record['status'] != 'ADMITTED':
|
|
return verdict(EXIT_LOADER, f'QCC2072 admission refused: {qcc_record["error"]}')
|
|
print(f' QCC2072 admitted: command {qcc_record["command_before"]} -> {qcc_record["command_after"]}, '
|
|
f'BHI EE {qcc_record["mhi"].get("BHI_EXECENV")}, SMMU S2 {qcc_record["smmu"]["s2ttb"]} maps '
|
|
f'the arena identity RW, record generation {spec.generation}')
|
|
|
|
# The panel, from the machine in front of us. Giving XNU the real thing is
|
|
# what turns our transcript of its console into its own console.
|
|
#
|
|
# On by default; SNAP_NO_FB reproduces the original serial-only baseline.
|
|
# The host adapter has already validated its geometry and allocation.
|
|
if host.framebuffer:
|
|
if not info.get('fb_base'):
|
|
return verdict(EXIT_LOADER,
|
|
'SNAP_FB was asked for but q1n1 reports no framebuffer')
|
|
# Bytes per pixel from GOP's own PixelFormat, not from dividing the
|
|
# allocation by the geometry. The allocation is whatever the firmware
|
|
# rounded up to; on a panel whose fb_size has slack, that division
|
|
# silently produced a depth the hardware does not use. Formats 0
|
|
# (RGBX8888) and 1 (BGRX8888) are the two 32-bit ones. Format 2 is a
|
|
# bitmask and 3 is blt-only, and boot_args.Video has no way to say
|
|
# either, so those are refused rather than guessed at.
|
|
fmt = info.get('fb_format', 0)
|
|
if fmt not in (0, 1):
|
|
return verdict(EXIT_LOADER,
|
|
f'GOP pixel format {fmt} is not one boot_args.Video '
|
|
f'can describe (0 = RGBX8888, 1 = BGRX8888)')
|
|
depth = 4
|
|
xb.FRAMEBUFFER = host.framebuffer.boot_video()
|
|
print(f' panel {info["fb_width"]}x{info["fb_height"]}x{depth * 8} '
|
|
f'({"RGB" if fmt == 0 else "BGR"}) at {info["fb_base"]:#x} '
|
|
f'handed to XNU')
|
|
else:
|
|
print(' serial-only baseline (SNAP_NO_FB); panel withheld')
|
|
if console_mode == 'panel':
|
|
if not host.framebuffer:
|
|
return verdict(EXIT_LOADER, '--console panel requires a framebuffer')
|
|
xb.BOOT_CMDLINE = xb.BOOT_CMDLINE.replace('serial=3', 'serial=2')
|
|
print(f' console route {console_mode}; host relay {"on" if relay_panel else "off"}')
|
|
if graphics_boot:
|
|
print(' native graphics boot handoff, verbose text disabled; this is not GUI qualification')
|
|
if os.environ.get('SNAP_PROVOKE'):
|
|
xb.PROVOKE = os.environ['SNAP_PROVOKE']
|
|
print(f' provoking a panic: withholding {xb.PROVOKE}')
|
|
if os.environ.get('SNAP_WATCH'):
|
|
xb.WATCH_OFF = False
|
|
# Early stop (compat/early-stop.h, tools/early_stop.py): off unless
|
|
# SNAP_EARLY_STOP names a trigger. The guest trigger needs our Adreno kext;
|
|
# the host trigger needs a q1n1 stage that publishes its call service.
|
|
early_names = early_stop_module()
|
|
early_call = (0, 0)
|
|
try:
|
|
early = early_names.settings()
|
|
if early is not None:
|
|
if early.thermal:
|
|
runpy.run_path(str(ROOT/'tools/a16_thermal.py'))['admit_target'](info)
|
|
if early.guest and xb.GUEST_PROFILE.adreno is None:
|
|
raise ValueError('the guest trigger needs an Adreno profile; its kext sends the request')
|
|
if early.host:
|
|
host_stop_link(proxy)
|
|
early_call = call_service(proxy, info)
|
|
if not early_call[0]:
|
|
raise ValueError('this q1n1 stage publishes no call service; chainload one that does')
|
|
early_words = early.params_words(*early_call)
|
|
except (OSError, ValueError, RuntimeError) as error:
|
|
return verdict(EXIT_LOADER, f'early stop refused: {error}')
|
|
xb.EARLY_STOP = early
|
|
print('building the image and its address space...')
|
|
try:
|
|
built = xb.build(str(kernelcache), ramdisk=ramdisk, root_hash=root_hash,
|
|
ramdisk_mode=ramdisk_mode, isc_image=isc_image,
|
|
auxkc=auxkc, trust_additions=trust_additions)
|
|
except (OSError, ValueError, SystemExit) as error:
|
|
# Includes the frame table outgrowing its arena, which is what a
|
|
# too-large automatic region currently produces.
|
|
return verdict(EXIT_LOADER, f'could not build the image: {error}')
|
|
if os.environ.get('SNAP_NO_PROTECT'):
|
|
# A control: with nothing marked execute-never, a fault on the very
|
|
# first fetch cannot be the stage-2 protection and has to be stage 1.
|
|
# The vector page stays protected even here: it is the only thing that
|
|
# turns a fault XNU takes into a report instead of a hang.
|
|
built['stub_pa'] = []
|
|
built['chunks'][xb.PARAMS_PA] = xb.params_blob(built)
|
|
print(' control run: no pages protected')
|
|
chunks = dict(built['chunks'])
|
|
if os.environ.get('SNAP_TXM_NATIVE'):
|
|
import txmnative
|
|
try:
|
|
if not xb.GUEST_PROFILE.installed_root or not built['trust_cache'][0]:
|
|
raise ValueError('native Image4 is qualified only for the installed J700 profile')
|
|
native_work = work + work_size - txmnative.WORK_BYTES
|
|
built['txm_native'], native_record = txmnative.install(proxy, info,
|
|
os.environ['SNAP_TXM_NATIVE'], chunks[xb.DEVICETREE_PA], native_work)
|
|
prefix = os.environ.get('SNAP_SMP_REPORT_PREFIX')
|
|
if not prefix:
|
|
raise ValueError('native Image4 requires a source-bound run prefix')
|
|
import json as _json
|
|
with (ROOT / 'build' / f'{prefix}-txm-native.json').open('x') as output:
|
|
_json.dump(native_record, output, indent=2)
|
|
print(f' native Image4: qualified helper, private workspace {native_work:#x}, actual boot tree')
|
|
except (OSError, ValueError) as error:
|
|
return verdict(EXIT_LOADER, f'native Image4 refused: {error}')
|
|
for pa, page in built['tables'].chunks():
|
|
chunks[pa] = bytes(page)
|
|
|
|
total = sum(len(b) for b in chunks.values())
|
|
print(f' {len(chunks)} chunks, {total / (1 << 20):.1f} MiB, entry '
|
|
f'{built["entry"]:#x}')
|
|
# Re-uploading 115 MiB costs ~47s an iteration, and the bring-up loop is one
|
|
# iteration per endpoint. SNAP_FAST re-sends only what XNU can have dirtied
|
|
# -- the data segments, the tables and the handoff -- and leaves the text
|
|
# alone. Wrong only if XNU writes its own text, which it does not.
|
|
if os.environ.get('SNAP_PERMISSIVE'):
|
|
built['permissive'] = True
|
|
built['chunks'][xb.PARAMS_PA] = xb.params_blob(built)
|
|
chunks[xb.PARAMS_PA] = built['chunks'][xb.PARAMS_PA]
|
|
print(' permissive: unimplemented endpoints are acknowledged')
|
|
built['watchdog'] = int(os.environ.get('SNAP_WATCHDOG', '2'))
|
|
if early is not None:
|
|
built['early_stop'] = early_words
|
|
print(f' early stop on: {"guest " if early.guest else ""}{"host " if early.host else ""}'
|
|
f'trigger{", two-way console (tools/guest-console.py)" if early.console else ""}, '
|
|
f'default delay {early.delay}s, bound {early.max_delay}s'
|
|
+ (f', q1n1 polled every {early.poll_ms} ms, session {early.session:#018x}'
|
|
if early.host else ''))
|
|
if os.environ.get('SNAP_WATCH_WRITE'):
|
|
built['write_watch_va'] = int(os.environ['SNAP_WATCH_WRITE'], 0)
|
|
print(f' stop before writes to the 4K page at {built["write_watch_va"]:#x}')
|
|
if os.environ.get('SNAP_SKIP_UNDEF'):
|
|
# Step over encodings this silicon does not implement, and count them.
|
|
# Opt-in: a run that only gets further because of this is not a run that
|
|
# works, and the counter is what keeps the two apart.
|
|
built['skip_undef'] = True
|
|
built['chunks'][xb.PARAMS_PA] = xb.params_blob(built)
|
|
chunks[xb.PARAMS_PA] = built['chunks'][xb.PARAMS_PA]
|
|
print(' stepping over unimplemented instruction encodings')
|
|
if os.environ.get('SNAP_LET_PANIC'):
|
|
built['let_panic'] = True
|
|
built['chunks'][xb.PARAMS_PA] = xb.params_blob(built)
|
|
chunks[xb.PARAMS_PA] = built['chunks'][xb.PARAMS_PA]
|
|
print(' faults resume into XNU\'s own handler, so it can panic in words')
|
|
# Serialize after all runtime overrides, including the watchdog. Otherwise
|
|
# a watchdog-only run silently keeps build()'s two-second default.
|
|
try:
|
|
chunks[xb.PARAMS_PA] = xb.params_blob(built)
|
|
except ValueError as error:
|
|
return verdict(EXIT_LOADER, str(error))
|
|
if os.environ.get('SNAP_FAST'):
|
|
keep = {}
|
|
for name, vmaddr, vmsize, _, filesize in built['image'].segments:
|
|
if filesize and ('DATA' in name or name == '__LINKEDIT'):
|
|
keep[xb.IMAGE_PA + (vmaddr - built['vmin'])] = True
|
|
chunks = {pa: b for pa, b in chunks.items()
|
|
if pa in keep or pa >= xb.HANDOFF_PA}
|
|
print(f' fast path: {len(chunks)} chunks')
|
|
# Recheck APF metadata against resources known only after host discovery.
|
|
# Do this before uploading, not after an inconsistent tree has been sent.
|
|
out = info['heap_base'] + OUT_AT
|
|
secondary_api = None
|
|
host_cpus = 0
|
|
if os.environ.get('SNAP_SMP') == '1':
|
|
secondary_api = runpy.run_path(str(ROOT/'tools/secondary-runtime.py'))
|
|
try:
|
|
# The uploaded image's size: a monitor built outside build/ (cycle.py
|
|
# speed-test) must not be measured by whatever build/xnuboot.bin holds.
|
|
host_cpus = secondary_api['prepare'](proxy,info,code,
|
|
code_size or (ROOT/'build/xnuboot.bin').stat().st_size,
|
|
work,work_size,guest_profile)
|
|
except ValueError as error:
|
|
return verdict(EXIT_LOADER,str(error))
|
|
# The monitor's own context size: FLAGS are the build's (cycle.py sets them before
|
|
# building and before booting). A define-free size is short by whatever the build's
|
|
# defines add (SNAP_UNNEST_4K_FAST: 0xf8000 bytes), which put the last part of the
|
|
# private context inside the guest-readable table window below.
|
|
monitor_layout=runpy.run_path(str(ROOT/'tools/monitor_layout.py'))['MonitorLayout'](FLAGS)
|
|
table_start=(work+monitor_layout.size('context')+4095)&~4095
|
|
table_end = built['txm_native'][1] if built.get('txm_native') else work+work_size
|
|
s2_ranges = xb.stage2_ranges(work=(table_start, table_end-table_start))
|
|
try:
|
|
xb.validate_apf_memory(built['dart_apf_slices'],
|
|
[(lo, hi-lo) for lo, hi, _, _ in s2_ranges]+[(work,work_size)])
|
|
except ValueError as error:
|
|
return verdict(EXIT_LOADER, str(error))
|
|
cached_disk = None
|
|
# The EFI cache contains the original authenticated BaseSystem, including
|
|
# when it occupies one partition in a larger GPT RAM disk.
|
|
disk_range = built.get('ramdisk_root_range')
|
|
if disk_range and not os.environ.get('SNAP_NO_PRELOAD'):
|
|
import hashlib
|
|
disk_pa, disk_size = disk_range
|
|
matches = [f for f in cached_files if f['size'] == disk_size]
|
|
if matches:
|
|
digest = hashlib.sha256(chunks[disk_pa]).hexdigest()
|
|
cached_disk = next((f for f in matches if f['sha256'] == digest), None)
|
|
if os.environ.get('SNAP_REQUIRE_PRELOAD') and cached_disk is None:
|
|
return verdict(EXIT_LOADER, 'required matching EFI RAMDisk cache is unavailable')
|
|
start = time.time()
|
|
if cached_disk:
|
|
try:
|
|
preload.copy_cached(proxy, info, cached_disk, disk_pa, disk_size)
|
|
except ValueError as error:
|
|
return verdict(EXIT_LOADER, f'EFI RAMDisk cache refused: {error}')
|
|
del chunks[disk_pa]
|
|
print(f' SSD preload: verified and copied {disk_size / (1 << 20):.1f} MiB '
|
|
f'locally in {time.time() - start:.1f}s; no BaseSystem USB upload')
|
|
if built.get('ramdisk_isc_range'):
|
|
import bootmedia
|
|
isc_pa, isc_size = built['ramdisk_isc_range']
|
|
sent = bootmedia.upload_auxiliary(proxy, isc_pa, chunks[isc_pa])
|
|
del chunks[isc_pa]
|
|
print(f' ISC initialized {isc_size >> 20} MiB locally; '
|
|
f'uploaded {sent / (1 << 20):.2f} MiB of nonzero blocks')
|
|
for pa, blob in sorted(chunks.items()):
|
|
proxy.writemem(pa, blob)
|
|
if ans_range and ans_window:
|
|
# Everything below the SSD's DMA window: state and firmware page zero, the synthesized
|
|
# GPT's head and tail, the SSD params (state zero). The queues q1n1 left running in the
|
|
# DMA window are not touched.
|
|
import answindow
|
|
import bootmedia
|
|
head, tail, params = ans_window_parts
|
|
base = ans_range[0]
|
|
payload = bytearray(answindow.WINDOW_DMA)
|
|
payload[answindow.WINDOW_HEAD:answindow.WINDOW_HEAD + len(head)] = head
|
|
payload[answindow.WINDOW_TAIL:answindow.WINDOW_TAIL + len(tail)] = tail
|
|
payload[answindow.WINDOW_NVMEH:answindow.WINDOW_NVMEH + len(params)] = params
|
|
sent = bootmedia.upload_auxiliary(proxy, base, bytes(payload))
|
|
checks = [(answindow.WINDOW_HEAD, 3 * 4096), (answindow.WINDOW_TAIL + len(tail) - 5 * 4096, 5 * 4096),
|
|
(answindow.WINDOW_NVMEH, 0x140)]
|
|
if any(proxy.readmem(base + off, n) != bytes(payload[off:off + n]) for off, n in checks):
|
|
return verdict(EXIT_LOADER, 'virtual ANS2 window reservation did not verify')
|
|
print(f' virtual ANS2 window placed; uploaded {sent / (1 << 20):.2f} MiB of nonzero blocks, '
|
|
f'GPT and SSD params read back')
|
|
elif ans_range:
|
|
# Every byte of the reservation is written: the state page and the
|
|
# reserved page zero (the monitor's prepare resets the state), the
|
|
# disk, then zero to the end. Only nonzero 64 KiB blocks cross USB.
|
|
import bootmedia
|
|
from devicemodel import ANS_DISK_OFFSET
|
|
base, span = ans_range
|
|
payload = (bytes(ANS_DISK_OFFSET) + ans_image +
|
|
bytes(span - ANS_DISK_OFFSET - len(ans_image)))
|
|
sent = bootmedia.upload_auxiliary(proxy, base, payload)
|
|
edges = [(ANS_DISK_OFFSET, 4096), (ANS_DISK_OFFSET + 4096, 4096),
|
|
(ANS_DISK_OFFSET + len(ans_image) - 4096, 4096)]
|
|
if any(proxy.readmem(base + off, n) != payload[off:off + n] for off, n in edges):
|
|
return verdict(EXIT_LOADER, 'virtual ANS2 disk upload did not verify')
|
|
print(f' virtual ANS2 disk placed; uploaded {sent / (1 << 20):.2f} MiB of nonzero '
|
|
f'blocks, GPT headers read back')
|
|
if qcc_record is not None:
|
|
# Every byte of the QCC2072 arena (the firmware container, the board ELF and
|
|
# the regulatory database at the record's offsets, zero elsewhere) and of
|
|
# the control page's guest page, cleaned and invalidated to PoC, read back.
|
|
import bootmedia
|
|
try:
|
|
qcc_record['upload'] = qcc2072wifi.upload(proxy, xb.QCC2072_WIFI, qcc_inputs,
|
|
bootmedia.upload_auxiliary)
|
|
except ValueError as error:
|
|
return verdict(EXIT_LOADER, f'QCC2072 arena refused: {error}')
|
|
print(f' QCC2072 arena {qcc2072wifi.DMA_PA:#x} +64 MiB and control page {qcc2072wifi.CONTROL_PA:#x} '
|
|
f'written ({qcc_record["upload"]["sent_bytes"] / (1 << 20):.2f} MiB nonzero), cleaned to PoC, '
|
|
f'record {qcc_record["upload"]["record_sha256"][:16]} read back')
|
|
# The image is code, and the caches have to agree with memory before
|
|
# anything fetches from it.
|
|
proxy.flush_code(xb.IMAGE_PA, built['mapped'])
|
|
print(f' uploaded in {time.time() - start:.1f}s')
|
|
|
|
# The shadow is 4K where the guest's tables are 16K, so it needs four leaf
|
|
# entries per guest page -- and the physical aperture added half a gigabyte
|
|
# of linear mapping, which is most of the leaves. 4 MiB was enough before it
|
|
# existed and is not now; the failure is legible (prepare returns -1/-2/-3)
|
|
# but it costs a round trip, and q1n1's heap is 64 MiB.
|
|
# The stage-2 map, stated before the run so a stop with an address can be
|
|
# read against it: what the guest may touch, and that nothing else exists.
|
|
# The probe builds the same list from its params and its work area.
|
|
for lo, hi, access, what in s2_ranges:
|
|
print(f' stage 2 {lo:#x} .. {hi:#x} {access} {what}')
|
|
print(' stage 2 everything else absent: a touch stops the run '
|
|
'with its IPA')
|
|
# Framebuffer ownership is witnessed inside the probe, with q1n1's UI
|
|
# suspended. Serial row samples outside that interval add no guest-write
|
|
# evidence and can fail before entry or prevent reading a completed report.
|
|
if host.framebuffer:
|
|
proxy.fb_console(False)
|
|
print(' q1n1 status drawing paused; XNU retains panel ownership after return')
|
|
# Allow the guest watchdog to stop every CPU and publish its report before
|
|
# the host gives up waiting. A fixed 600s timeout races a 600s guest run.
|
|
call_timeout = max(600, built['watchdog'] + 60)
|
|
monitor_work.publish_work(proxy, info, work, work_size, xb.PHYS_BASE,
|
|
xb.BOOTSTRAP_PA + xb.BOOTSTRAP_SPAN)
|
|
if any(m.kind==48 for m in built['device_models']):
|
|
import hashlib, json
|
|
from devicemodel import seed_rtc_models
|
|
# Image upload is complete. The virtual UTC epoch is sampled here and
|
|
# starts advancing when the timed device registry initializes. Record
|
|
# that boundary; this is not a precision-synchronized physical RTC.
|
|
epoch_us=time.time_ns()//1000
|
|
built['device_models']=seed_rtc_models(built['device_models'],epoch_us)
|
|
parameters=xb.params_blob(built)
|
|
proxy.writemem(built['params_pa'],parameters)
|
|
if proxy.readmem(built['params_pa'],len(parameters))!=parameters:
|
|
raise RuntimeError('RTC seed parameter readback mismatch')
|
|
prefix=os.environ.get('SNAP_SMP_REPORT_PREFIX')
|
|
if not prefix or any(c not in 'abcdefghijklmnopqrstuvwxyz0123456789-' for c in prefix):
|
|
raise RuntimeError('RTC qualification requires a fresh report prefix')
|
|
path=ROOT/'build'/f'{prefix}-rtc-seed.json'
|
|
with path.open('x') as output:
|
|
json.dump(dict(epoch_us=epoch_us,after_upload=True,
|
|
parameter_sha256=hashlib.sha256(parameters).hexdigest(),
|
|
pre_call_host_us=time.time_ns()//1000,
|
|
boundary='UTC sampled before call; counter origin at device registry start'),output,indent=2)
|
|
print(f' virtual RTC seeded from host UTC: {epoch_us} microseconds since Unix epoch')
|
|
early_prefix = os.environ.get('SNAP_SMP_REPORT_PREFIX')
|
|
if early is not None and early_prefix:
|
|
# Before the call: tools/request-stop.py --launch-record binds a host
|
|
# request to this session, and collectors find the settings here.
|
|
import json as early_json
|
|
with (ROOT/'build'/f'{early_prefix}-early-stop-launch.json').open('x') as output:
|
|
early_json.dump(dict(early.record(), call_service=f'{early_call[0]:#x}',
|
|
call_state=f'{early_call[1]:#x}', watchdog_s=built['watchdog'],
|
|
stage_generation=info['stage_generation']), output, indent=2)
|
|
result = proxy.call(code, out, work, work_size, built['params_pa'], host_cpus,
|
|
timeout=call_timeout)
|
|
require_xnuboot_terminal(result) # before any report/peer/ANS read or cleanup
|
|
if qcc_record is not None:
|
|
# The card may still master the bus. The launcher turns that off once the
|
|
# stopped state is preserved (cycle_lib/installed_fresh.py qcc2072_stop),
|
|
# never before: a proxy fault here would freeze the capture.
|
|
print(' QCC2072: bus mastering is left to the launcher, after preservation')
|
|
if secondary_api:
|
|
secondary_result=secondary_api['collect'](proxy,work,work_size)
|
|
print(' physical secondary result:',secondary_result)
|
|
if ans_range:
|
|
# The monitor's own record of the virtual ANS2 (compat/ans.h), read
|
|
# from the reservation after the stop. Observation only.
|
|
import ansstate, json
|
|
try:
|
|
state = ansstate.decode(proxy.readmem(ans_range[0], ansstate.SIZE))
|
|
print(' virtual ANS2:', ansstate.describe(state))
|
|
physical_nvme = None
|
|
if ans_window:
|
|
physical_nvme = answindow.decode_nvmeh(proxy.readmem(ans_range[0]+answindow.WINDOW_NVMEH,0x120))
|
|
print(' physical NVMe:', physical_nvme['state'])
|
|
prefix = os.environ.get('SNAP_SMP_REPORT_PREFIX')
|
|
if prefix:
|
|
with (ROOT/'build'/f'{prefix}-ans.json').open('x') as output:
|
|
json.dump(dict(reservation=list(ans_range), state=state,
|
|
physical_nvme=physical_nvme), output, indent=2)
|
|
except (OSError, ValueError) as error:
|
|
print(' virtual ANS2 state unreadable:', error)
|
|
|
|
status, entered = struct.unpack('<2Q', proxy.readmem(out, 16))
|
|
report = struct.unpack('<8Q', proxy.readmem(out + 16, 64))
|
|
s_root, leaves, traps, events, steps, s2_root, fault, calls = report
|
|
monitor_calls, console_len = calls & 0xffffffff, calls >> 32
|
|
fb_span, fb_before_hash, fb_after_hash = struct.unpack('<3Q',
|
|
proxy.readmem(out + FRAMEBUFFER_WITNESS_OFFSET, 24))
|
|
if fb_span:
|
|
print(f' framebuffer during guest execution: {fb_span} bytes, '
|
|
f'hash {fb_before_hash:016x} -> {fb_after_hash:016x}, '
|
|
f'{"CHANGED" if fb_before_hash != fb_after_hash else "unchanged"}; '
|
|
'q1n1 UI suspended for both reads')
|
|
aborts, steps = steps & 0xffffffff, steps >> 32
|
|
|
|
if xb.GUEST_PROFILE.adreno is not None:
|
|
# Not `counters`: that name is the report function this one calls later (Stage 4 run 1
|
|
# lost the end of its report to a TypeError when the local shadowed it).
|
|
gpu_flow = runpy.run_path(str(ROOT / 'tools/adreno_counters.py'))
|
|
decoded = gpu_flow['decode'](proxy.readmem(out + GPU_FLOW_OFFSET, gpu_flow['SIZE']))
|
|
for line in gpu_flow['summary'](decoded):
|
|
print(f' {line}')
|
|
# The early-stop record (OUT_EARLY_STOP): zero words on an ordinary launch.
|
|
early_record = early_names.decode(proxy.readmem(out + EARLY_STOP_OFFSET, early_names.SIZE))
|
|
for line in early_names.summary(early_record, host.timer_hz):
|
|
print(f' {line}')
|
|
if '-DSNAP_VIEW_RECLAIM=1' in FLAGS:
|
|
perf = runpy.run_path(str(ROOT/'tools/perf_counters.py'))
|
|
offset, size = perf['view_reclaim_spec']()
|
|
try:
|
|
views = perf['decode_view_reclaim'](proxy.readmem(out + offset, size))
|
|
print(f" kernel views: {views['count']} of {views['capacity']} tables (high water "
|
|
f"{views['high_water']}), {views['reclaims']} reclaims retired {views['retired']}, "
|
|
f"{views['full']} refused with nothing to retire")
|
|
except ValueError as error:
|
|
print(' kernel views record unreadable:', error)
|
|
thermal_record = None
|
|
if early is not None and early.thermal:
|
|
thermal_names = runpy.run_path(str(ROOT/'tools/a16_thermal.py'))
|
|
thermal_record = thermal_names['decode'](proxy.readmem(out+thermal_names['REPORT_OFFSET'],thermal_names['SIZE']))
|
|
print(' '+thermal_names['summary'](thermal_record,host.timer_hz))
|
|
if early is not None and early_prefix:
|
|
call_state = None
|
|
if early_call[1]:
|
|
length = struct.unpack('<Q', proxy.readmem(early_call[1] + 16, 8))[0]
|
|
if 24 <= length <= 4096 and not length % 8:
|
|
call_state = proxy.readmem(early_call[1], length).hex()
|
|
import json as early_json
|
|
with (ROOT/'build'/f'{early_prefix}-early-stop.json').open('x') as output:
|
|
early_json.dump(dict(settings=early.record(), record=early_record,
|
|
q1n1_call_state_hex=call_state, thermal=thermal_record), output, indent=2)
|
|
if status and status >> 63:
|
|
names = PREPARE_FAILURES
|
|
code_ = status - (1 << 64)
|
|
print(f'\nFAILED before entry: {names.get(code_, code_)}')
|
|
print(f' shadow root (4K) {s_root:#x} leaves {leaves}')
|
|
print(f' stage-2 root {s2_root:#x}')
|
|
if code_ == -6:
|
|
print(f' entry {built["entry"]:#x}')
|
|
print(f' PAR_EL1 {fault:#x} {par_report(fault)}')
|
|
return verdict(EXIT_PREPARE, names.get(code_, f'prepare returned {code_}'))
|
|
if status:
|
|
elr, far = read_bad_regs(proxy, out)
|
|
print(f'\nEL2 FAULT ESR {status:#x} (EC {status >> 26:#x} '
|
|
f'FSC {status & 0x3f:#04x})')
|
|
print(f' at ELR_EL2 {elr:#x} (inside the probe)')
|
|
print(f' reaching for {far:#x} FAR_EL2')
|
|
return verdict(EXIT_EL2_FAULT,
|
|
f'the monitor faulted at {elr:#x} reaching for {far:#x}, '
|
|
f'ESR {status:#x} (EC {status >> 26:#x})')
|
|
|
|
print(f' shadow root (4K) {s_root:#x} leaves {leaves}')
|
|
print(f' stage-2 root {s2_root:#x} monitor calls answered '
|
|
f'{monitor_calls}')
|
|
print(f' entered {"yes" if entered else "NO"}')
|
|
print(f' sysreg traps {traps} aborts {aborts} steps {steps}')
|
|
returns, signatures, return_pc = struct.unpack('<3Q',
|
|
proxy.readmem(out + EXCEPTION_FLOW_OFFSET, 3 * 8))
|
|
print(f' kernel ERET returns {returns}; PACGA operations {signatures}; '
|
|
f'last return target {return_pc:#x}')
|
|
updates, update_entries, regions, region_entries = struct.unpack('<4Q',
|
|
proxy.readmem(out + UPDATE_FLOW_OFFSET, 4 * 8))
|
|
print(f' UPDATE_DISJOINT completed {updates} calls, {update_entries} entries')
|
|
print(f' UPDATE_REGION completed {regions} calls, {region_entries} entries')
|
|
slides, slide_pages, slide_cursor = struct.unpack('<3Q',
|
|
proxy.readmem(out + UPDATE_FLOW_OFFSET + 4 * 8, 3 * 8))
|
|
print(f' SLIDE_REGION completed {slides} calls, {slide_pages} pages; '
|
|
f'next destination {slide_cursor:#x}')
|
|
ticks, first_tick, last_tick, writes, guest_ctl, hz, host_ctl, saved_ctl = struct.unpack(
|
|
'<8Q', proxy.readmem(out + CLOCK_FLOW_OFFSET, 8 * 8))
|
|
print(f' clock {ticks} reads, {first_tick:#x} -> {last_tick:#x}, {hz} Hz; '
|
|
f'CNTKCTL {writes} writes, guest {guest_ctl:#x}, hardware {host_ctl:#x}, '
|
|
f'restored {saved_ctl:#x}')
|
|
tr, tw, irqs, asserts, clears, vectors, ctl, deadline, irq_pc, budget, repairs = struct.unpack(
|
|
'<11Q', proxy.readmem(out + TIMER_FLOW_OFFSET, 11 * 8))
|
|
print(f' deadline timer {tr} reads, {tw} writes, {irqs} host IRQs; '
|
|
f'VF {asserts} sets/{clears} clears, {vectors} guest FIQ vector entries; '
|
|
f'CTL {ctl:#x}, deadline {deadline:#x}, last IRQ PC {irq_pc:#x}, budget {budget:#x}')
|
|
print(f' stepped guest exception contexts repaired {repairs}')
|
|
pending, requests, reads, acks, coalesced, target, request_pc, ack_pc = struct.unpack(
|
|
'<8Q',proxy.readmem(out+IPI_FLOW_OFFSET,8*8))
|
|
print(f' fast IPI: {requests} requests, {reads} status reads, {acks} pending acknowledgements, '
|
|
f'{coalesced} coalesced; pending {pending}, last target {target:#x}; '
|
|
f'request PC {request_pc:#x}, acknowledge PC {ack_pc:#x}')
|
|
mask, registrations, ids, outputs, active, affinity, slot, reserved = struct.unpack(
|
|
'<8Q',proxy.readmem(out+CPU_FLOW_OFFSET,8*8))
|
|
print(f' CPU registry: mask {mask:#x}, {registrations} registrations, {ids} ID queries, '
|
|
f'{outputs} result-area queries; active slot {active}, last affinity {affinity:#x}, '
|
|
f'last slot {slot}, reserved {reserved}')
|
|
for row in struct.iter_unpack('<12Q',proxy.readmem(out+VCPU_FLOW_OFFSET,6*12*8)):
|
|
(address, index, mpidr, prepared, ttbr1, timer_reads, ipi_requests, exceptions,
|
|
stage2_root, vttbr, guards, open_guards) = row
|
|
print(f' vCPU {index}: bank {address:#x}, MPIDR {mpidr:#x}, prepared {prepared}, '
|
|
f'TTBR1 {ttbr1:#x}, timer reads {timer_reads}, IPI requests {ipi_requests}, '
|
|
f'exceptions {exceptions}; stage2 {stage2_root:#x}, VTTBR {vttbr:#x}, '
|
|
f'guards {guards}, open {open_guards}')
|
|
tlb_syncs, root_checks, root_busy = struct.unpack('<3Q',proxy.readmem(out+SHARED_FLOW_OFFSET,24))
|
|
print(f' shared translation state: {tlb_syncs} all-VMID invalidations, '
|
|
f'{root_checks} root-liveness checks, {root_busy} busy/invalid refusals')
|
|
service_values = struct.unpack('<16Q',proxy.readmem(out+SERVICE_FLOW_OFFSET,16*8))
|
|
service_owner, service_stop = service_values[:2]
|
|
service_acquires, service_faults = service_values[2:14:2], service_values[3:14:2]
|
|
service_lifecycle, service_reusable = service_values[14:]
|
|
print(f' service lock: owner {service_owner}, stop {service_stop:#x}; '
|
|
f'acquisitions {service_acquires}, faults {service_faults}')
|
|
print(f' executor lifecycle: {service_lifecycle:#x}, memory reusable {service_reusable}')
|
|
storage_values=struct.unpack('<31Q',proxy.readmem(out+RUNTIME_STORAGE_OFFSET,31*8))
|
|
for cpu in range(6):
|
|
vncr,output,stack_low,stack_high,hcr=storage_values[cpu*5:(cpu+1)*5]
|
|
print(f' CPU{cpu} runtime storage: VNCR {vncr:#x}, output {output:#x}, '
|
|
f'EL2 stack [{stack_low:#x}, {stack_high:#x}), HCR {hcr:#x}')
|
|
print(f' active hardware VNCR_EL2: {storage_values[30]:#x}')
|
|
(physical_hz, virtual_hz, saved_cnthctl, guest_cnthctl, wfe, wakes, early,
|
|
architectural, raw_first, raw_last, virtual_first, virtual_last) = struct.unpack(
|
|
'<12Q', proxy.readmem(out + CLOCK_ADAPTER_OFFSET, 12*8))
|
|
print(f' clock adapter {physical_hz} -> {virtual_hz} Hz; '
|
|
f'WFE {wfe} traps, {wakes} event wakes, {early} early wakes; '
|
|
f'architectural virtual reads {architectural}; '
|
|
f'CNTHCTL guest {guest_cnthctl:#x}, saved {saved_cnthctl:#x}')
|
|
scaled = physical_hz and all(g == ((h * virtual_hz // physical_hz) & ((1 << 64)-1))
|
|
for h, g in ((raw_first, virtual_first), (raw_last, virtual_last)))
|
|
print(f' counter conversion: physical {raw_first:#x} -> {raw_last:#x}, '
|
|
f'guest {virtual_first:#x} -> {virtual_last:#x}; exact samples {bool(scaled)}')
|
|
pending, irq_sets, irq_clears, irq_vectors, aic_events = struct.unpack(
|
|
'<5Q', proxy.readmem(out + AIC_FLOW_OFFSET, 5*8))
|
|
print(f' AIC IRQ pending {pending}; VI {irq_sets} sets/{irq_clears} clears; '
|
|
f'{irq_vectors} guest IRQ vector entries, {aic_events} acknowledged events')
|
|
models = built.get('device_models', ())
|
|
wd_words = RESET_WORDS + 3*MODEL_MAX
|
|
wd_state = struct.unpack(f'<{wd_words}Q', proxy.readmem(out + WATCHDOG_FLOW_OFFSET, wd_words*8))
|
|
reset_bitmap=sum(word<<(64*i) for i,word in enumerate(wd_state[:RESET_WORDS]))
|
|
print(f' virtual device reset bitmap {reset_bitmap:#x}')
|
|
dev_notifications, trace_queries = struct.unpack('<2Q',
|
|
proxy.readmem(out + OPTIONAL_FLOW_OFFSET, 16))
|
|
print(f' optional monitor services: {dev_notifications} developer-mode notifications; '
|
|
f'CPUTRACE reservation address/size queries '
|
|
f'{trace_queries & 0xffffffff}/{trace_queries >> 32} (counts saturate at 32 bits)')
|
|
device_words = 1 + 2*MODEL_MAX
|
|
device_counts = struct.unpack(f'<{device_words}Q',
|
|
proxy.readmem(out + DEVICE_FLOW_OFFSET, device_words * 8))
|
|
if device_counts[0] != len(models):
|
|
return verdict(EXIT_UNCLASSIFIED, 'virtual device registry report count mismatch')
|
|
for i, model in enumerate(models):
|
|
print(f' virtual device {model.kind} {model.node} [{model.base:#x}, '
|
|
f'{model.base+model.span:#x}): {device_counts[1+2*i]} reads, '
|
|
f'{device_counts[2+2*i]} writes')
|
|
if model.kind == 5:
|
|
cur_ctl, bite_reset, sample = wd_state[RESET_WORDS+3*i:RESET_WORDS+3*(i+1)]
|
|
print(f' WD1 current {cur_ctl & 0xffffffff}, bite {bite_reset & 0xffffffff}, '
|
|
f'control {cur_ctl >> 32:#x}, reset {bite_reset >> 32}, sample {sample:#x}')
|
|
async_words = struct.unpack('<37Q', proxy.readmem(out + ASYNC_REGS_OFFSET, 37 * 8))
|
|
if async_words[0]:
|
|
stop_kind = {1: 'asynchronous', 2: 'monitor', 3: 'stage-2'}.get(async_words[0], 'unknown')
|
|
print(f' {stop_kind} stop PC {async_words[34]:#x}, '
|
|
f'SP {async_words[32]:#x}, PSTATE {async_words[33]:#x}')
|
|
dump_gprs(async_words[1:32])
|
|
exc_return = read_exc_return(proxy, out)
|
|
if exc_return:
|
|
print(f' exception return {exc_return:#x} (registered by XNU; this '
|
|
f'is NOT a fault)')
|
|
|
|
# Post-mortem. The guest is gone but its memory is not, and the proxy can
|
|
# read it: every kernel VA in the image is IMAGE_PA + (va - vmin), because
|
|
# that mapping is linear by construction. This is much cheaper than
|
|
# reasoning backwards from a disassembly about what a global ended up
|
|
# holding -- the machine already knows.
|
|
def peek(va, n=8):
|
|
if built['vmin'] <= va and va + n <= built['image'].vmax:
|
|
pa = built['image_pa'] + (va - built['vmin'])
|
|
raw = proxy.readmem(pa, n)
|
|
else:
|
|
# Panic va_lists move onto dynamically allocated kernel stacks.
|
|
# Linear image arithmetic on those VAs can issue arbitrary
|
|
# physical reads. Walk the live guest tables within owned RAM.
|
|
from guestmemory import read_16k
|
|
root = read_guest_sysregs(proxy, out)['TTBR1_EL1'] & 0x0000ffffffffc000
|
|
raw = read_16k(proxy.readmem, root, xb.PHYS_BASE,
|
|
xb.BOOTSTRAP_PA + xb.BOOTSTRAP_SPAN, va, n)
|
|
return int.from_bytes(raw, 'little')
|
|
|
|
print('\n--- what SPTM handed over, as XNU stored it ---')
|
|
gate = peek(0xFFFFFE0007EC0238, 1)
|
|
count_ptr, table_ptr = peek(0xFFFFFE0007EC0240), peek(0xFFFFFE0007EC0248)
|
|
print(f' handoff consumed {gate}')
|
|
print(f' region count ptr {count_ptr:#x}')
|
|
print(f' region table ptr {table_ptr:#x}')
|
|
ap = proxy.readmem(xb.HANDOFF_PA + xb.REGIONS_OFFSET, 0x28)
|
|
n = int.from_bytes(ap[:4], 'little')
|
|
print(f' aperture {n} region(s)')
|
|
regions = []
|
|
# SLIDE_REGION splits the original aperture into more than eight ranges.
|
|
# Keep the monitor's 50-entry bound; truncating at eight also broke the
|
|
# diagnostic VA-to-PA lookup for table pointers in the final RAM range.
|
|
if n > 50:
|
|
print(' invalid region count: exceeds the monitor ABI limit of 50')
|
|
for r in range(min(n, 50)):
|
|
entry = proxy.readmem(xb.HANDOFF_PA + xb.REGIONS_OFFSET + 0x10 + r * 0x18, 0x18)
|
|
base = int.from_bytes(entry[0:8], 'little')
|
|
virt = int.from_bytes(entry[8:16], 'little')
|
|
pages = int.from_bytes(entry[16:20], 'little')
|
|
regions.append((base, virt, pages << 14))
|
|
print(f' phys {base:#x} .. {base + (pages << 14):#x} -> va {virt:#x}')
|
|
|
|
# XNU's own panic record, read back off the machine.
|
|
#
|
|
# 0xe7ffdeff is not an instruction we failed to emulate: it is XNU's
|
|
# TRAP_DEBUGGER poison word (osfmk/kern/debug.c), executed on purpose from
|
|
# DebuggerTrapWithState() once the kernel has already decided to panic. The
|
|
# hardware reports EC 0 because no CPU implements it -- that is the point.
|
|
# So the interesting value is not the instruction, it is the record the
|
|
# kernel filled in just before trapping, which is still in memory.
|
|
#
|
|
# x23 at the trap is &debugger_state[cpu]; with x8 == 0 that is the percpu
|
|
# base itself. Field offsets are read out of the stores in the trap site.
|
|
DEBUGGER_STATE = 0xFFFFFE000C6F4140
|
|
# +0x20 is db_panic_args, and it is read out of the same stores as the rest:
|
|
#
|
|
# stp x1, x2, [x23, #0x10] ; db_message, db_panic_str
|
|
# stp x3, x5, [x23, #0x20] ; <-- x3 is the va_list*
|
|
# stp x7, x8, [x23, #0x30] ; db_panic_caller
|
|
#
|
|
# which matches DebuggerTrapWithState's argument order. Without it the
|
|
# record gives the shape of the complaint and not the complaint: reading
|
|
# "pmap_enter failed, map_addr=%#lx, phys_page=%u" tells you nothing about
|
|
# which mapping.
|
|
FIELDS = ((0x08, 'db_current_op'), (0x10, 'db_message'),
|
|
(0x18, 'db_panic_str'), (0x20, 'db_panic_args'),
|
|
(0x30, 'db_panic_caller'), (0x44, 'db_op_return'))
|
|
|
|
def peekstr(va, limit=256):
|
|
"""Follow a kernel VA to a C string, if it is one of ours to read."""
|
|
if not built['vmin'] <= va < built['image'].vmax:
|
|
return None
|
|
limit = min(limit, built['image'].vmax - va)
|
|
try:
|
|
pa = xb.IMAGE_PA + (va - built['vmin'])
|
|
raw = proxy.readmem(pa, limit)
|
|
except Exception:
|
|
return None
|
|
end = raw.find(b'\0')
|
|
raw = raw[:end if end >= 0 else limit]
|
|
return raw.decode('utf-8', 'replace') if raw else ''
|
|
|
|
print("\n--- XNU's panic record (debugger_state) ---")
|
|
panic_said = None
|
|
try:
|
|
for off, name in FIELDS:
|
|
width = 4 if off == 0x44 else 8
|
|
v = peek(DEBUGGER_STATE + off, width)
|
|
line = f' +{off:#04x} {name:<16} {v:#x}'
|
|
if name in ('db_message', 'db_panic_str') and v:
|
|
s = peekstr(v)
|
|
if s:
|
|
line += f' "{s}"'
|
|
print(line)
|
|
except Exception as exc:
|
|
print(f' could not read it: {exc}')
|
|
|
|
# And the arguments, which are what turn the format string into a sentence.
|
|
#
|
|
# db_panic_args is an AAPCS va_list, whose first word is the pointer to the
|
|
# stack argument block -- so one dereference reaches the values the call
|
|
# site pushed. Both fields are zero for a panic that never formatted, so
|
|
# this is guarded on the pointer rather than on having panicked.
|
|
try:
|
|
fmt_va = peek(DEBUGGER_STATE + 0x18)
|
|
args_va = peek(DEBUGGER_STATE + 0x20)
|
|
if fmt_va and args_va:
|
|
block = peek(args_va)
|
|
words = [peek(block + 8 * n) for n in range(8)]
|
|
print(f'\n db_panic_args {args_va:#x} -> block {block:#x}')
|
|
for n, w in enumerate(words):
|
|
s = peekstr(w, 64) if 0xfffffe0000000000 <= w else None
|
|
extra = f' "{s}"' if s and s.isprintable() and s else ''
|
|
print(f' arg[{n}] {w:#018x} ({w}){extra}')
|
|
said = format_panic(peekstr(fmt_va), words, peekstr)
|
|
panic_said = said or peekstr(fmt_va)
|
|
if said:
|
|
print(f'\n == {said}')
|
|
except Exception as exc:
|
|
print(f' could not read the panic arguments: {exc}')
|
|
|
|
# The audited debugger trap can stop before debugger_state receives the
|
|
# format pointer. Its entry registers retain that pointer and va_list.
|
|
# Decode only this profile's known trap/opcode shape, never arbitrary x2/x3.
|
|
if panic_said is None:
|
|
raw = proxy.readmem(out + GUEST_REGS_OFFSET, GUEST_REGS_BYTES)
|
|
count = struct.unpack_from('<Q', raw)[0]
|
|
if count:
|
|
words = struct.unpack_from(f'<{GUEST_REGS_WORDS}Q', raw, 8)
|
|
if words[33] == xb.GUEST_PROFILE.panic_trap_pc and words[0] == 1:
|
|
try:
|
|
fmt = peekstr(words[2])
|
|
if built['vmin'] <= words[3] < built['image'].vmax - 8:
|
|
block = peek(words[3])
|
|
if built['vmin'] <= block < built['image'].vmax - 64:
|
|
args = [peek(block + 8 * n) for n in range(8)]
|
|
panic_said = format_panic(fmt, args, peekstr)
|
|
if panic_said:
|
|
print(f' captured panic trap: {panic_said}')
|
|
except (OSError, ValueError, TypeError):
|
|
pass
|
|
|
|
# Which structure XNU is actually reading the seed out of.
|
|
#
|
|
# The panic comes from a length test: x21 = *(0xfffffe0007ebf100), and
|
|
# [x21+0x178] must be >= 8. The consuming code wants an 8-byte tag
|
|
# "randseed" at +0x6c and the seed bytes at +0x74. Whether x21 is our
|
|
# boot_args is a measurement, not an inference -- so take it.
|
|
SEED_GLOBAL = 0xFFFFFE0007EBF100
|
|
print('\n--- the randseed structure XNU reads ---')
|
|
try:
|
|
ptr = peek(SEED_GLOBAL)
|
|
ba_pa = xb.HANDOFF_PA + 0x1000
|
|
ba_va = built.get('bootargs_va')
|
|
print(f' *{SEED_GLOBAL:#x} {ptr:#x}')
|
|
print(f' our boot_args pa {ba_pa:#x}'
|
|
+ (f' va {ba_va:#x}' if ba_va else ''))
|
|
print(f' same structure? '
|
|
f'{"yes" if ba_va and ptr == ba_va else "NO -- x21 is not our boot_args"}')
|
|
# Is that structure the copy of our SPTM handoff? Compare bytes.
|
|
dst = proxy.readmem(xb.IMAGE_PA + (ptr - built['vmin']), 64)
|
|
src = proxy.readmem(xb.HANDOFF_PA, 64)
|
|
print(f' structure[0:16] {dst[:16].hex()}')
|
|
print(f' our handoff[0:16] {src[:16].hex()}')
|
|
print(f' is our handoff? '
|
|
f'{"yes" if dst == src else "no -- different bytes"}')
|
|
for off in (0x6c, 0x74, 0x178):
|
|
b = proxy.readmem(xb.IMAGE_PA + (ptr - built['vmin']) + off, 16)
|
|
print(f' +{off:#05x} {b.hex()} {b[:8]!r}')
|
|
except Exception as exc:
|
|
print(f' could not read it: {exc}')
|
|
|
|
# The walk that kills this run, from the inside.
|
|
#
|
|
# 0xfffffe000ba7aa00 loads a fixed VA into x20 and walks it through a root
|
|
# taken from *(*(__DATA_CONST+0x6e0) + 0x3f60). That global is zero in the
|
|
# image on disk, so whatever fills it in does so while the kernel runs --
|
|
# which means the only way to see it is to read it back afterwards. The
|
|
# guest is gone but its memory is not.
|
|
WALK_VA = 0xfffffecfffcfc000
|
|
ROOT_GLOBAL = 0xfffffe0007da06e0
|
|
print('\n--- the walk at 0xfffffe000ba7ab68 ---')
|
|
holder = peek(ROOT_GLOBAL)
|
|
print(f' root global {ROOT_GLOBAL:#x} -> {holder:#x}')
|
|
# peek() only knows image addresses. Anything the kernel stores as an
|
|
# aperture VA has to come back through the region table, the same way the
|
|
# kernel's own phystokv would run it -- backwards.
|
|
def kvtophys(va):
|
|
for base, virt, span in regions:
|
|
if virt <= va < virt + span:
|
|
return base + (va - virt)
|
|
return None
|
|
|
|
if not holder:
|
|
print(' still zero: the kernel never filled it in, so the walk started '
|
|
'from nothing')
|
|
else:
|
|
pa = kvtophys(holder)
|
|
if pa is None:
|
|
print(' that is not an aperture address, so it is not one of ours')
|
|
else:
|
|
print(f' = physical {pa:#x}, which is our table arena'
|
|
if pa == built['tables'].root else f' = physical {pa:#x}')
|
|
entry = int.from_bytes(proxy.readmem(pa + 0x3f60, 8), 'little')
|
|
print(f' [+0x3f60] (index {0x3f60 // 8}) {entry:#018x}'
|
|
f'{"" if entry else " <-- zero"}')
|
|
|
|
# And the same walk through the tables we built, which is what the guest's
|
|
# TTBR1 points at. A zero here is us not having filled the entry in.
|
|
offset = (WALK_VA - xb.KERNEL_BASE) & ((1 << (64 - xb.T1SZ)) - 1)
|
|
table = built['tables'].root
|
|
print(f' walking {WALK_VA:#x} through our own root {table:#x}')
|
|
for level in range(xb.start_level(), 4):
|
|
i = xb.index(level, offset)
|
|
entry = int.from_bytes(proxy.readmem(table + i * 8, 8), 'little')
|
|
note = '' if entry & 1 else ' <-- zero, the walk stops here'
|
|
print(f' level {level} index {i:5d} entry {entry:#018x}{note}')
|
|
if not entry & 1:
|
|
break
|
|
table = entry & 0x0000FFFFFFFFC000
|
|
|
|
# The early physical allocator, read back.
|
|
#
|
|
# Every page pmap_steal_memory maps is the word at 0xfffffe0007da0858
|
|
# (avail_start), unchanged: 0xfffffe000ba12440 masks it to 46 bits and
|
|
# 0xfffffe000ba627c8 ORs it into the PTE. avail_start is written once, at
|
|
# the end of arm_vm_init (0xfffffe000ba7aba4), as the rounded cursor at
|
|
# 0xfffffe0007da06c8; the cursor is seeded from handoff +0x18
|
|
# (0xfffffe000c244814 stores it at 0xfffffe0007d9fd60, 0xfffffe000ba7947c
|
|
# loads it) and is moved only by pmap_bootstrap's own allocations -- a
|
|
# page, 0x30000 for ptd descriptors, and the ASID bitmap. Offline that
|
|
# chain ends a few hundred KiB above +0x18; the hardware maps 12 GiB.
|
|
# arm_vm_init also stores {seed, cursor - seed} at 0xfffffe000c6c0068 as it
|
|
# returns, which is the cursor frozen at that moment. Reading the chain
|
|
# back says which link holds the 12 GiB number, and that is the question.
|
|
ALLOCATOR = (
|
|
('handoff copy +0x18', 0xfffffe0007da0278 + 0x18, 8),
|
|
('seed', 0xfffffe0007d9fd60, 8),
|
|
('cursor', 0xfffffe0007da06c8, 8),
|
|
('avail_start', 0xfffffe0007da0858, 8),
|
|
('avail_end', 0xfffffe0007da06e8, 8),
|
|
('arm_vm_init: seed', 0xfffffe000c6c0068, 8),
|
|
('arm_vm_init: cursor - seed', 0xfffffe000c6c0070, 8),
|
|
('virtual_space_start', 0xfffffe000c738320, 8),
|
|
('free pages (u32)', 0xfffffe000c817344, 4),
|
|
('vm_pages array (VA)', 0xfffffe0007e2c010, 8),
|
|
)
|
|
PHYSICAL = ('handoff copy +0x18', 'seed', 'cursor', 'avail_start',
|
|
'avail_end', 'arm_vm_init: seed')
|
|
print('\n--- the early physical allocator, as the kernel left it ---')
|
|
ours = struct.unpack_from('<Q', built['chunks'][xb.HANDOFF_PA],
|
|
xb.SPTM_AVAIL_START)[0]
|
|
print(f' {"our handoff +0x18":<30} {ours:#x}')
|
|
try:
|
|
for name, va, width in ALLOCATOR:
|
|
v = peek(va, width)
|
|
note = ''
|
|
if name in PHYSICAL:
|
|
inside = xb.PHYS_BASE <= v < xb.BOOTSTRAP_PA + xb.BOOTSTRAP_SPAN
|
|
note = ' inside our memory' if inside else ' OUTSIDE our memory'
|
|
print(f' {name:<30} {v:#x}{note}')
|
|
except Exception as exc:
|
|
print(f' could not read it: {exc}')
|
|
|
|
kprintf_gate(peek, built)
|
|
video_console_state(peek, built)
|
|
guest_sysregs(proxy, out, built)
|
|
|
|
guest_registers(proxy, out)
|
|
panic_entry(proxy, out)
|
|
counters(proxy, out)
|
|
endpoint_calls(proxy, out, monitor_calls)
|
|
map_ranges(proxy, out, xb, built,
|
|
read_counters(proxy, out).get('map_outside'))
|
|
|
|
console = ''
|
|
if console_len:
|
|
text = proxy.readmem(out + CONSOLE_OFFSET,
|
|
min(console_len, CONSOLE_MAX))
|
|
console = text.decode('utf-8', 'replace')
|
|
print('\n--- XNU console ---')
|
|
print(console)
|
|
print('-------------------')
|
|
|
|
# An explicitly requested diagnostic relay, excluded from guest panel runs.
|
|
if console and relay_panel:
|
|
try:
|
|
import importlib.util as _il
|
|
_s = _il.spec_from_file_location('fbconsole', ROOT / 'probes/fbconsole.py')
|
|
fbconsole = _il.module_from_spec(_s)
|
|
_s.loader.exec_module(fbconsole)
|
|
# Say what this is. XNU's own video console is a different thing
|
|
# -- initialize_screen, its font, its layout -- and it cannot run
|
|
# this early, which is the whole meaning of "panicked very early
|
|
# before serial init". What is on the panel here is the early
|
|
# SPTM_FN_SERIAL_PUTC stream, XNU's words relayed and drawn by the
|
|
# host. Useful precisely because it covers the phase where the
|
|
# kernel has no console of its own; not a substitute for the one it
|
|
# will draw once PE_init_platform runs.
|
|
header = 'XNU early console (SPTM serial, relayed) - Snapdragon X2E'
|
|
n = fbconsole.show(proxy, info, console, header=header)
|
|
print(f' drawn on the A16: {n:,} bytes to {info["fb_base"]:#x}')
|
|
except Exception as error: # noqa: BLE001
|
|
print(f' (could not draw on the panel: {error})')
|
|
raw = proxy.readmem(out + LOG_OFFSET, LOG_BYTES)
|
|
# Collected rather than printed straight through: the verdict is decided by
|
|
# what is in here, and deciding it from the same list the transcript shows
|
|
# means the two cannot disagree.
|
|
log = [struct.unpack_from('<6Q', raw, n * EVENT_WORDS * 8)
|
|
for n in range(min(events, EVENT_MAX))]
|
|
print(f'\n{events} events:')
|
|
for n in range(len(log)):
|
|
kind, pc, a, b, c, repeats = log[n]
|
|
label = EVENTS.get(kind, kind)
|
|
if repeats:
|
|
label += f' x{repeats + 1}'
|
|
if kind == 11:
|
|
# a = the PTE's output address, b = the VA, c = the whole PTE.
|
|
#
|
|
# The call completes, the guest's table is written, and the
|
|
# mapping IS installed in the hardware's. It used to say "MAPPED
|
|
# ANYWAY", then for a while the mapping was withheld and a second
|
|
# line said so; neither is true now. Under the narrowed stage-2 map
|
|
# these addresses are absent, so installing means the access traps
|
|
# to EL2 with its address rather than faulting inside XNU's own
|
|
# handler. The span table below says which device each of these
|
|
# belongs to. Attributes are decoded here rather than in the probe
|
|
# because the raw PTE is what was recorded and the host can say
|
|
# more about it.
|
|
attrs = c & 0xfff
|
|
print(f' {n:>2} {label:<22} pc {pc:#x} va {b:#x} -> pa {a:#x} '
|
|
f'({a / (1 << 30):.2f} GiB)')
|
|
print(f' pte {c:#018x} attrs {attrs:#05x} '
|
|
f'AttrIndx {(c >> 2) & 7} SH {(c >> 8) & 3} AP {(c >> 6) & 3}'
|
|
f' outside the memory we declared, and installed -- so a '
|
|
f'touch of it stops at EL2 with its address')
|
|
elif kind == 12:
|
|
# a = ESR_EL2, b = ESR_EL1, c = ELR_EL1. EC 0x2f is SError. IDS
|
|
# (bit 24) set means the rest is implementation defined, so the
|
|
# AET/EA/DFSC decode below is only meaningful when it is clear.
|
|
print(f' {n:>2} {label:<22} guest pc {pc:#x} ESR_EL2 {a:#x} '
|
|
f'(EC {a >> 26:#x} IDS {(a >> 24) & 1} AET {(a >> 10) & 7} '
|
|
f'EA {(a >> 9) & 1} DFSC {a & 0x3f:#04x})')
|
|
print(f' ESR_EL1 {b:#x} ELR_EL1 {c:#x} the bus refused an '
|
|
f'access -- this is NOT the timer expiring')
|
|
elif kind == 13:
|
|
# a = which vector, b = ESR_EL1, c = ELR_EL1.
|
|
print(f' {n:>2} {label:<22} guest pc {pc:#x} '
|
|
f'{ASYNC_KINDS.get(a, a)} with the EL2 timer not fired '
|
|
f'ESR_EL1 {b:#x} ELR_EL1 {c:#x}')
|
|
elif kind == 10:
|
|
# a = reason, with the level a walk stopped at in bits 15:8.
|
|
# b = the VA being mapped, c = detail, whose meaning is per reason.
|
|
reason, level = a & 0xff, (a >> 8) & 0xff
|
|
why = MAP_FAILURES.get(reason, f'reason {reason}, unrecognised')
|
|
detail = {2: 'last table reached', 3: 'entry', 4: 'entry',
|
|
5: 'the PTE we were given', 6: 'next free shadow table',
|
|
7: 'shadow root', 8: 'what the shadow resolves to',
|
|
9: 'the PTE we were given', 10: 'the leaf base',
|
|
11: 'the root we were handed'}
|
|
print(f' {n:>2} {label:<22} pc {pc:#x} va {b:#x} MAP_PAGE FAILED: '
|
|
f'{why}')
|
|
print(f' {detail.get(reason, "detail")} {c:#x}'
|
|
+ (f' stopped at level {level}' if reason == 2 else ''))
|
|
elif kind == 9:
|
|
# a = x30 (which fatal path), b = ELR_EL1, c = ESR_EL1.
|
|
where = PANIC_CALLERS.get(a, 'an unrecognised fatal path')
|
|
print(f' {n:>2} {label:<22} pc {pc:#x} from {a:#x} -- {where}')
|
|
print(f' ELR_EL1 {b:#x} ESR_EL1 {c:#x} '
|
|
f'(EC {c >> 26:#x} FSC {c & 0x3f:#04x})')
|
|
elif kind == 8:
|
|
# a = dispatch command, b = domain, c = the raw x16.
|
|
what = DISPATCH_CMDS.get(a, 'unknown to us')
|
|
print(f' {n:>2} {label:<22} pc {pc:#x} x16 {c:#018x} '
|
|
f'command {a:#x} -- not an endpoint call ({what}); '
|
|
f'domain {b:#x} NOT ANSWERED')
|
|
elif kind == 1:
|
|
# The whole x16, because three of its four fields used to be
|
|
# invisible here and the top one is what distinguishes four
|
|
# different calls that all read as endpoint 0.
|
|
cmd, domain = (a >> 56) & 0xff, (a >> 48) & 0xff
|
|
table, endpoint = (a >> 32) & 0xffff, a & 0xffffffff
|
|
name = route_name(domain, table)
|
|
note = ''
|
|
if cmd != 0:
|
|
print(f' {n:>2} {label:<22} pc {pc:#x} x16 {a:#018x} '
|
|
f'command {cmd:#x}, not an endpoint call')
|
|
continue
|
|
# Table 7 endpoint 13 exists in this kernelcache -- 14 UAT stubs,
|
|
# endpoints 0..13 -- and m1n1-j700's private.h stops at 12, with
|
|
# uat_handle_table7() returning 0 for anything past its max. So the
|
|
# one reference implementation for this SoC answers this call by
|
|
# ignoring it. Flag it rather than let it look handled.
|
|
if table == 7 and endpoint >= 13:
|
|
note = ' <-- past the last named UAT endpoint'
|
|
print(f' {n:>2} {label:<22} pc {pc:#x} x16 {a:#018x} {name} '
|
|
f'endpoint {endpoint:#x} x0 {b:#x} x1 {c:#x}{note}')
|
|
elif kind == 2:
|
|
# a = IPA (0: unrecoverable), b = ESR_EL2, c = the guest VA. The
|
|
# run stopped here; the IPA is the answer and the rest is what makes
|
|
# the next step cheap.
|
|
print(f' {n:>2} {label:<22} pc {pc:#x} IPA {a:#x} va {c:#x} '
|
|
f'ESR {b:#x}')
|
|
print(f' {s2_fault_words(a, b)}')
|
|
print(f' {s2_where_words(a, s2_ranges)}')
|
|
elif kind == 17:
|
|
print(f' {n:>2} {label:<22} pc {pc:#x} PA {a:#x} '
|
|
f'type {b >> 32:#x} -> {b & 0xffffffff:#x} options {c:#x}')
|
|
elif kind == 18:
|
|
print(f' {n:>2} {label:<22} pc {pc:#x} PA {b:#x} '
|
|
f'new type {c:#x}: {RETYPE_FAILURES.get(a, a)}')
|
|
elif kind == 16:
|
|
# a = (reason << 48) | (window << 32) | offset, b = ESR_EL2,
|
|
# c = the IPA. The run stopped here and this names the register to
|
|
# model next.
|
|
print(f' {n:>2} {label:<22} pc {pc:#x} IPA {c:#x} ESR {b:#x}')
|
|
print(f' {mmio_stop_words(a, b)}')
|
|
elif kind == 3:
|
|
print(f' {n:>2} {label:<22} at {c:#x} ESR_EL1 {a:#x} '
|
|
f'(EC {a >> 26:#x} FSC {a & 0x3f:#04x} {"write" if (a >> 6) & 1 else "read"})'
|
|
f' FAR_EL1 {b:#x} from {pc:#x}')
|
|
elif kind == 7:
|
|
which = 'x2 (SPTM handoff)' if a < 0x1000 else 'x1'
|
|
print(f' {n:>2} {label:<22} {which} +{a & 0xfff:#05x} pc {pc:#x} '
|
|
f'{"write" if (b >> 6) & 1 else "read"}')
|
|
elif kind == 6:
|
|
print(f' {n:>2} {label:<22} pc {pc:#x} ESR_EL1 {a:#x} '
|
|
f'(EC {a >> 26:#x} FSC {a & 0x3f:#04x}) FAR_EL1 {b:#x} '
|
|
f'ELR_EL1 {c:#x}')
|
|
elif kind == 4:
|
|
op0, op2 = (a >> 20) & 3, (a >> 17) & 7
|
|
op1, crn = (a >> 14) & 7, (a >> 10) & 0xf
|
|
crm, rd = (a >> 1) & 0xf, 'read' if a & 1 else 'write'
|
|
name = SYSREGS.get((op0, op1, crn, crm, op2), '')
|
|
# These collapse by register now, not by (pc, ISS): the direction
|
|
# and the destination register belong to the first access, and the
|
|
# count covers every access to that register that followed. Saying
|
|
# "read" for a run of eighteen that included writes would be the
|
|
# label lying about what it counted.
|
|
what = f'{rd}' if not repeats else f'{repeats + 1} accesses, first a {rd}'
|
|
print(f' {n:>2} {label:<22} pc {pc:#x} '
|
|
f'S{op0}_{op1}_C{crn}_C{crm}_{op2}'
|
|
f'{" " + name if name else ""} {what}')
|
|
elif kind == 5 and pc in (8, 11, 19, 22):
|
|
print(f' {n:>2} {"guest registers":<22} x{pc} {a:#x} '
|
|
f'x{pc + 1} {b:#x} x{pc + 2} {c:#x}')
|
|
elif kind == 5 and pc == 0:
|
|
# What this CPU says about itself, read once at setup. This used to
|
|
# print as "other pc 0x0" followed by three bare hex words, which is
|
|
# a line nobody can read and nobody did. XNU configures SME
|
|
# unconditionally on this board, so whether the silicon has it
|
|
# decides whether that is deniable or has to be emulated.
|
|
print(f' {n:>2} {"this CPU, as it reports itself":<22}')
|
|
print(f' MIDR_EL1 {c:#018x} '
|
|
f'implementer {(c >> 24) & 0xff:#x} part {(c >> 4) & 0xfff:#x}')
|
|
print(f' ID_AA64PFR0_EL1 {a:#018x}')
|
|
print(f' ID_AA64PFR1_EL1 {b:#018x} '
|
|
f'SME {(b >> 24) & 0xf}'
|
|
f'{"" if (b >> 24) & 0xf else " -- this silicon has no SME"}')
|
|
elif kind == 5 and pc == 1:
|
|
# Whether this silicon has FEAT_NV2, which decides whether XNU can
|
|
# be run at all without pretending to be something it is not: it
|
|
# writes EL2 registers, and NV2 is what redirects those into memory
|
|
# instead of leaving them undefined.
|
|
nv, frac = (a >> 24) & 0xf, (b >> 24) & 0xf
|
|
has = nv == 2 or (nv == 1 and frac)
|
|
print(f' {n:>2} {"nested virtualization":<22}')
|
|
print(f' ID_AA64MMFR2_EL1 {a:#018x} NV {nv}')
|
|
print(f' ID_AA64MMFR4_EL1 {b:#018x} NV_frac {frac}')
|
|
print(f' ID_AA64MMFR0_EL1 {c:#018x} '
|
|
f'{"FEAT_NV2 present" if has else "NO FEAT_NV2"}')
|
|
elif kind == 5 and pc == 2:
|
|
# SNAP_IDREG_MMFR3 (opt-in), one per vCPU: whether this core has
|
|
# stage-1 permission indirection or overlays (the hardware analogues
|
|
# of Apple's SPRR and TPRO), and its AF/dirty and PAN features.
|
|
print(f' {n:>2} {"permission features":<22} vCPU {c}')
|
|
print(f' ID_AA64MMFR3_EL1 {a:#018x} S1PIE {(a >> 8) & 15} S1POE {(a >> 16) & 15}')
|
|
print(f' ID_AA64MMFR1_EL1 {b:#018x} HAFDBS {b & 15} HPDS {(b >> 12) & 15} '
|
|
f'PAN {(b >> 20) & 15}')
|
|
elif kind == 5 and pc == 3:
|
|
# SNAP_LEAN_TRAPS=2, one per vCPU: whether FEAT_FGT replaced
|
|
# HCR_EL2.TVM/TRVM on this CPU (compat/lean-trap.h).
|
|
state = {1: 'active: ESR/FAR reads and CONTEXTIDR writes untrapped',
|
|
2: 'declined: no FEAT_FGT', 3: 'declined: MMFR3 adds TVM registers',
|
|
4: 'declined: FGT registers did not read back', 5: 'declined: saved by another CPU'}
|
|
print(f' {n:>2} {"fine-grained traps":<22} vCPU {c & 0xff} '
|
|
f'{state.get(c >> 8, f"status {c >> 8}")}')
|
|
print(f' HFGRTR_EL2 {a:#018x} HFGWTR_EL2 {b:#018x}')
|
|
elif kind == 59:
|
|
print(f' {n:>2} {label:<22} pc {pc:#x} {a:#x} {b:#x} {c:#x}')
|
|
for line in coherent_event_lines(pc, a, b, c):
|
|
print(f' {line}')
|
|
else:
|
|
print(f' {n:>2} {label:<22} pc {pc:#x} {a:#x} {b:#x} {c:#x}')
|
|
|
|
# A run that overflowed the log has a last event we cannot see, and
|
|
# classifying off a truncated tail reports the wrong stop with full
|
|
# confidence -- which it did, naming "endpoint 0x2 not implemented" for a
|
|
# run that completed 379 of them.
|
|
#
|
|
# `events > len(log)` was supposed to catch that and could not: the probe's
|
|
# record() returned without incrementing once the log was full, so `events`
|
|
# capped at the log size and the two were always equal. The probe now counts
|
|
# what it dropped, and that count is the honest test. The old comparison is
|
|
# kept as well, because it costs nothing and covers a short read.
|
|
tally = read_counters(proxy, out)
|
|
tally.update(service_owner=service_owner,service_stop=service_stop,service_faults=service_faults,
|
|
service_lifecycle=service_lifecycle,service_reusable=service_reusable,
|
|
early_stop=early_record)
|
|
dropped = tally['log_dropped'] or max(0, events - len(log))
|
|
# The terminal word. Printed whether or not the log overflowed, because it
|
|
# is also the cheapest check that the log's tail and the probe's own last
|
|
# record agree -- and on a run that fitted they must.
|
|
last_event = read_last_event(proxy, out)
|
|
if last_event is not None:
|
|
kind, pc, a, b, c = last_event[:5]
|
|
print(f'\nlast event (outside the log, never dropped):\n'
|
|
f' {EVENTS.get(kind, kind):<26} pc {pc:#x} {a:#x} {b:#x} {c:#x}')
|
|
last_event, hpfar = stage2_stop_hpfar(last_event)
|
|
if hpfar is not None:
|
|
print(f' {"hardware IPA (HPFAR_EL2)":<26} {hpfar_ipa(hpfar, c, b):#x} (HPFAR_EL2 {hpfar:#x}; '
|
|
f'the IPA above is the stage-1 re-walk\'s)')
|
|
if log and log[-1][:5] != last_event[:5] and not dropped:
|
|
print(' NOTE: the log\'s last entry is not this one, and nothing '
|
|
'was dropped -- the two should agree')
|
|
try:
|
|
inventory = xb.device_inventory(built['dt'])
|
|
except (KeyError, SystemExit, ValueError):
|
|
inventory = () # the verdict never depends on naming the window
|
|
return verdict(*conclude(entered, fault, console, log, tally, dropped,
|
|
panic_said, last_event, models, inventory))
|
|
|
|
|
|
# The guest's register file at an exception, written by probes/xnuboot.c.
|
|
#
|
|
# Byte offset from `out`, matching OUT_GUEST_REGS there: the C side's pointer is
|
|
# 16 bytes above the host's (probes/xnuboot.S, OUT_REPORT), and the area sits
|
|
# past the console so nothing already parsed moved.
|
|
#
|
|
# out + 4176 how many exceptions were captured (0 = none, nothing below)
|
|
# out + 4184 first exception: x0..x30, selected SP, SPSR_EL1, PC, ESR_EL1
|
|
# out + 4464 last exception: the same 35 words
|
|
GUEST_REGS_OFFSET = CONSOLE_OFFSET + CONSOLE_MAX
|
|
GUEST_REGS_WORDS = 36
|
|
GUEST_REGS_BYTES = (1 + 2 * GUEST_REGS_WORDS) * 8
|
|
# Appended after those two: the frame at XNU's panic-entry call.
|
|
PANIC_REGS_OFFSET = GUEST_REGS_OFFSET + GUEST_REGS_BYTES
|
|
PANIC_REGS_BYTES = (1 + GUEST_REGS_WORDS) * 8
|
|
|
|
# Where dispatch command 0x2 was called from, by return address. x30 is the one
|
|
# register that says which of XNU's six fatal paths this is, and one of them
|
|
# decides whether anything afterwards runs at all.
|
|
PANIC_CALLERS = {
|
|
0xfffffe000b86ebc0: ('the wedge: an integrity check failed. XNU goes to '
|
|
'`wfe` and branches to itself forever, so NOTHING '
|
|
'after this call runs, whatever we answer'),
|
|
0xfffffe000b86faa4: 'synchronous exception vector',
|
|
0xfffffe000b86f80c: 'synchronous exception vector',
|
|
0xfffffe000b86f6a8: 'synchronous exception vector',
|
|
0xfffffe000b8c1b4c: 'handle_debugger_trap (DBOP_PANIC or DBOP_DEBUGGER)',
|
|
0xfffffe000b8c13bc: 'a fatal path this project has not named yet',
|
|
}
|
|
|
|
# Which callee-saved register holds what, per call site.
|
|
#
|
|
# There is no single convention, and assuming one gets a register labelled with
|
|
# another's meaning. Two shapes:
|
|
#
|
|
# The EL1h vectors (0xfffffe000b86fa90, 0xfffffe000b86f7f8) do
|
|
# mov x25, x0 ; mrs x26, ELR_EL1 ; mrs x27, ESR_EL1 ; mrs x28, FAR_EL1
|
|
#
|
|
# The kernel-mode continuation (0xfffffe000b86f61c) does
|
|
# mrs x25, ELR_EL1 ... mov x26, x0 ; mov x27, x1(ESR) ; mov x28, x2(FAR)
|
|
#
|
|
# so x25 and x26 swap roles between them. x27 is ESR and x28 is FAR on both,
|
|
# which is why those two can be read anywhere this table has an entry.
|
|
#
|
|
# The other three call sites latch nothing: at 0xfffffe000b8c13ac x26 is a
|
|
# per-CPU pointer, and the wedge at 0xfffffe000b86ebbc sets none of them.
|
|
# Printing those as a fault would be a pointer wearing the label "faulting
|
|
# address", so sites absent from this table get no register decode at all.
|
|
PANIC_LATCH_CALLERS = {
|
|
0xfffffe000b86faa4: {25: 'saved_state', 26: 'ELR', 27: 'ESR', 28: 'FAR'},
|
|
0xfffffe000b86f80c: {25: 'saved_state', 26: 'ELR', 27: 'ESR', 28: 'FAR'},
|
|
0xfffffe000b86f6a8: {25: 'ELR', 26: 'saved_state', 27: 'ESR', 28: 'FAR'},
|
|
}
|
|
|
|
# The one caller that makes everything after it moot.
|
|
WEDGE_CALLER = 0xfffffe000b86ebc0
|
|
|
|
# Why a MAP_PAGE failed. All six branches used to return one status and record
|
|
# nothing, so a failed map read exactly like a working one and pmap panicking
|
|
# afterwards had six candidate causes.
|
|
MAP_FAILURES = {
|
|
1: 'T1SZ gives no valid starting level -- the regime is wrong, not the map',
|
|
2: 'a table in the walk is absent; this is the one MAP_TABLE exists to fix',
|
|
3: 'the level-3 entry could not be read',
|
|
4: 'the level-3 entry could not be written',
|
|
5: 'written, but reading it back through the guest tables disagreed',
|
|
6: 'the 4K shadow refused the leaf, usually its table arena being exhausted',
|
|
7: 'the shadow accepted the leaf and then does not resolve the address -- '
|
|
'the insert reported success without inserting',
|
|
8: 'the shadow resolves the address to the wrong physical page',
|
|
9: "the guest's own tables say this address is unmapped, so the shadow was "
|
|
'about to be left stale',
|
|
10: 'shadow_insert accepted the leaf and inserted nothing -- usually the '
|
|
'clip and the leaf being in different address spaces',
|
|
11: 'the page-table root we were handed is not inside the memory we own, '
|
|
'so walking it at EL2 would have faulted with nothing to name it',
|
|
12: 'selector2 needs a private CPU permission path; only the ROZONE RETYPE transaction is admitted',
|
|
}
|
|
|
|
# Counters the event log cannot carry, because it holds 64 entries and
|
|
# collapses repeats.
|
|
COUNTERS_OFFSET = PANIC_REGS_OFFSET + PANIC_REGS_BYTES
|
|
COUNTER_KEYS = ('map_pages', 'map_failures', 'dispatch_cmds', 'panic_calls',
|
|
'undef_skipped', 'map_outside', 'log_dropped',
|
|
'console_dropped', 'ranges_dropped', 'map_device',
|
|
'mmio_reads', 'mmio_writes', 'uart_tx')
|
|
# And after them, one word that is not a counter: the exception-return
|
|
# trampoline XNU registers. It lives here because it used to live in the fault
|
|
# slot, where it made every run look like a run that recorded a fault.
|
|
EXC_RETURN_OFFSET = COUNTERS_OFFSET + len(COUNTER_KEYS) * 8
|
|
# And after that, ELR_EL2 and FAR_EL2 as the `bad` vector found them. ESR alone
|
|
# says an EL2 fault happened and what kind; these say where in the probe and
|
|
# what address it was reaching for.
|
|
BAD_REGS_OFFSET = EXC_RETURN_OFFSET + 8
|
|
# And after that, the terminal word: the last event the probe recorded, in the
|
|
# shape of a log entry and held outside the ring, so a run whose log overflowed
|
|
# can still say what it was doing when it stopped.
|
|
LAST_EVENT_OFFSET = BAD_REGS_OFFSET + 16
|
|
# And after that, one counter per endpoint plus one for everything else.
|
|
ENDPOINT_BUCKETS = 64
|
|
ENDPOINTS_OFFSET = LAST_EVENT_OFFSET + 6 * 8
|
|
|
|
# SPTM's own names for its endpoints, from its dispatch table in __DATA_CONST
|
|
# (51 entries; tools/sptm-dispatch.py prints it out of
|
|
# downloads/extracted/j700ap/sptm.t8140.release). The gaps are holes in Apple's
|
|
# numbering, not endpoints this monitor refuses, so they are absent rather than
|
|
# named "unknown" -- a call to one would be a genuine surprise and should read
|
|
# as one.
|
|
SPTM_ENDPOINTS = {
|
|
0x00: 'LOCKDOWN', 0x01: 'RETYPE', 0x02: 'MAP_PAGE', 0x03: 'MAP_TABLE',
|
|
0x04: 'UNMAP_TABLE', 0x05: 'UPDATE_REGION', 0x06: 'UPDATE_DISJOINT',
|
|
0x07: 'UNMAP_REGION', 0x08: 'UNMAP_DISJOINT',
|
|
0x09: 'CONFIGURE_SHAREDREGION', 0x0a: 'NEST_REGION',
|
|
0x0b: 'UNNEST_REGION', 0x0c: 'CONFIGURE_ROOT', 0x0d: 'SWITCH_ROOT',
|
|
0x0e: 'REGISTER_CPU', 0x0f: 'FIXUPS_COMPLETE',
|
|
0x10: 'SIGN_USER_POINTER', 0x11: 'AUTH_USER_POINTER',
|
|
0x12: 'REGISTER_EXC_RETURN', 0x13: 'CPU_ID', 0x14: 'SLIDE_REGION',
|
|
0x15: 'UPDATE_DISJOINT_MULTIPAGE', 0x18: 'GUEST_VA_TO_IPA',
|
|
0x19: 'GUEST_STAGE1_TLBOP', 0x1a: 'GUEST_STAGE2_TLBOP',
|
|
0x1b: 'GUEST_DISPATCH', 0x1c: 'GUEST_EXIT', 0x1e: 'HIB_BEGIN',
|
|
0x1f: 'HIB_VERIFY_HASH_NON_WIRED', 0x20: 'HIB_FINALIZE_NON_WIRED',
|
|
0x21: 'IOFILTER_PROTECTED_WRITE', 0x27: 'SET_SHARED_REGION',
|
|
0x28: 'BATCH_SIGN_USER_POINTER', 0x29: 'SURT_ALLOC', 0x2a: 'SURT_FREE',
|
|
0x2b: 'CONDEMN_LEAF_TABLE', 0x2c: 'UNCONDEMN_LEAF_TABLE',
|
|
0x2d: 'SPTM_SERIAL_PUTC', 0x2e: 'SPTM_SERIAL_DISABLE',
|
|
0x32: 'OUTPUT_AREA',
|
|
}
|
|
|
|
|
|
def read_endpoint_calls(proxy, out):
|
|
"""How many calls each endpoint took, and how many went elsewhere.
|
|
|
|
Returns (per-endpoint list of 64, other). The log cannot answer this: it
|
|
collapses repeats and then drops what does not fit, so an endpoint called
|
|
four hundred times and one called once look the same in it."""
|
|
words = struct.unpack(f'<{ENDPOINT_BUCKETS + 1}Q',
|
|
proxy.readmem(out + ENDPOINTS_OFFSET,
|
|
(ENDPOINT_BUCKETS + 1) * 8))
|
|
return list(words[:ENDPOINT_BUCKETS]), words[ENDPOINT_BUCKETS]
|
|
|
|
|
|
def endpoint_calls(proxy, out, monitor_calls=None):
|
|
"""The per-endpoint tally, printed, with the arithmetic checked.
|
|
|
|
The sum is the point as much as the breakdown: the last run made 390
|
|
monitor calls and the transcript could account for 385, and nothing in it
|
|
could say where the other five went. Here the columns add up or they are
|
|
said not to."""
|
|
per, other = read_endpoint_calls(proxy, out)
|
|
total = sum(per) + other
|
|
if not total:
|
|
return
|
|
print('\nmonitor calls by endpoint:')
|
|
for fn, count in enumerate(per):
|
|
if count:
|
|
name = SPTM_ENDPOINTS.get(fn, 'not in SPTM\'s dispatch table')
|
|
print(f' {fn:#04x} {name:<30} {count}')
|
|
if other:
|
|
print(f' {"another domain, table or command":<30} {other}')
|
|
print(f' {"= total calls to the dispatcher":<30} {total}')
|
|
# The one number the probe counts twice by two different routes. A
|
|
# disagreement means a call path that increments one and not the other,
|
|
# which is exactly the kind of gap these counters exist to close.
|
|
if monitor_calls is not None and monitor_calls != total:
|
|
print(f' NOTE: the report says {monitor_calls} calls were answered and '
|
|
f'these columns total {total}; the difference is calls that '
|
|
f'reached the dispatcher and were not answered')
|
|
|
|
|
|
# The out-of-memory mappings, as spans. Slots of {va, pa, pages, attrs} after a
|
|
# count and a count of what did not fit.
|
|
MAP_RANGES = 128
|
|
MAP_RANGE_WORDS = 4
|
|
RANGES_OFFSET = ENDPOINTS_OFFSET + (ENDPOINT_BUCKETS + 1) * 8
|
|
# And after that, the guest's translation registers as the probe last saw them.
|
|
GUEST_SYSREGS_OFFSET = RANGES_OFFSET + (2 + MAP_RANGES * MAP_RANGE_WORDS) * 8
|
|
GUEST_SYSREG_NAMES = ('TTBR0_EL1', 'TTBR1_EL1', 'TCR_EL1', 'MAIR_EL1',
|
|
'SCTLR_EL1')
|
|
FRAMEBUFFER_WITNESS_OFFSET = GUEST_SYSREGS_OFFSET + 5 * 8
|
|
ASYNC_REGS_OFFSET = FRAMEBUFFER_WITNESS_OFFSET + 3 * 8
|
|
EXCEPTION_FLOW_OFFSET = ASYNC_REGS_OFFSET + (1 + GUEST_REGS_WORDS) * 8
|
|
UPDATE_FLOW_OFFSET = EXCEPTION_FLOW_OFFSET + 3 * 8
|
|
CLOCK_FLOW_OFFSET = UPDATE_FLOW_OFFSET + (4 + 3) * 8
|
|
TIMER_FLOW_OFFSET = CLOCK_FLOW_OFFSET + 8 * 8
|
|
DEVICE_FLOW_OFFSET = TIMER_FLOW_OFFSET + 11 * 8
|
|
CLOCK_ADAPTER_OFFSET = DEVICE_FLOW_OFFSET + (1 + 2*MODEL_MAX) * 8
|
|
AIC_FLOW_OFFSET = CLOCK_ADAPTER_OFFSET + 12 * 8
|
|
WATCHDOG_FLOW_OFFSET = AIC_FLOW_OFFSET + 5 * 8
|
|
OPTIONAL_FLOW_OFFSET = WATCHDOG_FLOW_OFFSET + (RESET_WORDS + 3*MODEL_MAX) * 8
|
|
IPI_FLOW_OFFSET = OPTIONAL_FLOW_OFFSET + 2 * 8
|
|
CPU_FLOW_OFFSET = IPI_FLOW_OFFSET + 8 * 8
|
|
VCPU_FLOW_OFFSET = CPU_FLOW_OFFSET + 8 * 8
|
|
SHARED_FLOW_OFFSET = VCPU_FLOW_OFFSET + 6 * 12 * 8
|
|
SERVICE_FLOW_OFFSET = SHARED_FLOW_OFFSET + 3 * 8
|
|
RUNTIME_STORAGE_OFFSET = SERVICE_FLOW_OFFSET + 16 * 8
|
|
GPU_FLOW_OFFSET = RUNTIME_STORAGE_OFFSET + (6 * 5 + 1) * 8 # OUT_GPU_FLOW
|
|
EARLY_STOP_OFFSET = GPU_FLOW_OFFSET + 45 * 8 # OUT_EARLY_STOP, after ADRENO_STATE_WORDS
|
|
|
|
|
|
def uploaded(built, va, n=8):
|
|
"""What the loader PUT at `va`, read back out of the chunks it uploaded.
|
|
|
|
The other half of a readback: `peek` says what is there now, this says what
|
|
was there before the guest ran, and the pair is what turns a byte into a
|
|
statement about whether anything wrote it.
|
|
|
|
Chunks are keyed by the physical address they start at and are as long as
|
|
the segment they came from, so the one wanted is the one whose range covers
|
|
the address -- not the one whose key equals it. Chunks overlap and are
|
|
uploaded sorted, which is why this takes the LAST containing chunk: that is
|
|
the one whose bytes survive on the target."""
|
|
pa = built['image_pa'] + (va - built['vmin'])
|
|
found = None
|
|
for base, blob in sorted(built['chunks'].items()):
|
|
if base <= pa and pa + n <= base + len(blob):
|
|
found = int.from_bytes(blob[pa - base:pa - base + n], 'little')
|
|
if found is None:
|
|
raise KeyError(f'{va:#x} (physical {pa:#x}) is in no uploaded chunk')
|
|
return found
|
|
|
|
|
|
def read_guest_sysregs(proxy, out):
|
|
"""The guest's TTBR0, TTBR1, TCR, MAIR and SCTLR, as a dict by name."""
|
|
words = struct.unpack(f'<{len(GUEST_SYSREG_NAMES)}Q',
|
|
proxy.readmem(out + GUEST_SYSREGS_OFFSET,
|
|
len(GUEST_SYSREG_NAMES) * 8))
|
|
return dict(zip(GUEST_SYSREG_NAMES, words))
|
|
|
|
|
|
def read_map_ranges(proxy, out):
|
|
"""The span table: (list of (va, pa, pages, attrs), how many did not fit)."""
|
|
words = struct.unpack(f'<{2 + MAP_RANGES * MAP_RANGE_WORDS}Q',
|
|
proxy.readmem(out + RANGES_OFFSET,
|
|
(2 + MAP_RANGES * MAP_RANGE_WORDS) * 8))
|
|
count, dropped = words[0], words[1]
|
|
spans = [tuple(words[2 + MAP_RANGE_WORDS * n:2 + MAP_RANGE_WORDS * (n + 1)])
|
|
for n in range(min(count, MAP_RANGES))]
|
|
return spans, dropped
|
|
|
|
|
|
def map_ranges(proxy, out, xb, built, outside=None):
|
|
"""The device surface the kernel asked for, printed with the tree's names.
|
|
|
|
The log keeps five of these and a counter keeps the total; neither says
|
|
which devices, nor how much of each. A span does, at a fixed cost, and the
|
|
physical base resolves back through the guest's own device tree -- which is
|
|
the only authority on what lives at an address this machine has nothing
|
|
at."""
|
|
spans, dropped = read_map_ranges(proxy, out)
|
|
if not spans and not dropped:
|
|
return
|
|
inventory = xb.device_inventory(built['dt'])
|
|
print('\nmappings onto addresses outside the memory we declared:')
|
|
pages = 0
|
|
for va, pa, n, attrs in spans:
|
|
pages += n
|
|
owner = xb.owner_of(pa, inventory)
|
|
if owner:
|
|
who, base, size = owner
|
|
where = f'{who} +{pa - base:#x} of {size:#x}'
|
|
else:
|
|
where = 'no node of the guest tree claims this address'
|
|
print(f' va {va:#x} -> pa {pa:#x} {n:>4} page'
|
|
f'{"" if n == 1 else "s"} of 16K {where}')
|
|
print(f' attrs {attrs:#05x} AttrIndx {(attrs >> 2) & 7} '
|
|
f'SH {(attrs >> 8) & 3} AP {(attrs >> 6) & 3}')
|
|
if dropped:
|
|
print(f' and {dropped} span{"" if dropped == 1 else "s"} that did not '
|
|
f'fit the {MAP_RANGES}-slot table -- the surface is WIDER than '
|
|
f'what is printed above')
|
|
print(f' {len(spans)} span{"" if len(spans) == 1 else "s"}, {pages} pages '
|
|
f'in total')
|
|
# The table and the counter are written at the same place for one reason:
|
|
# so this has to hold. If it does not, one of them is counting something
|
|
# the other is not, and a device surface is the wrong thing to be vague
|
|
# about.
|
|
if outside is not None and not dropped and pages != outside:
|
|
print(f' NOTE: the counter says {outside} such mappings and these '
|
|
f'spans hold {pages}; nothing was dropped, so the two should '
|
|
f'agree exactly')
|
|
|
|
|
|
def read_bad_regs(proxy, out):
|
|
"""ELR_EL2 and FAR_EL2 at the fault, or (0, 0) if none was taken."""
|
|
return struct.unpack('<2Q', proxy.readmem(out + BAD_REGS_OFFSET, 16))
|
|
|
|
|
|
def stage2_stop_hpfar(last_event):
|
|
"""SNAP_S2_FAULT_RETRY (compat/s2-fault-retry.h): a stage-2 stop's terminal word carries
|
|
HPFAR_EL2 in its sixth word, which is otherwise always zero (the slot holds one event, so
|
|
it never counts repeats). (the event with that word zeroed, HPFAR) -- or (event, None)."""
|
|
if last_event is None or last_event[0] != EV_S2_FAULT or not last_event[5]:
|
|
return last_event, None
|
|
return (*last_event[:5], 0), last_event[5]
|
|
|
|
|
|
def hpfar_ipa(hpfar, far, esr):
|
|
"""compat/s2-fault-retry.h s2_retry_hpfar_ipa: HPFAR_EL2.FIPA plus FAR's page offset,
|
|
which is not the address's when the stage-1 walk faulted (S1PTW: a table page)."""
|
|
page = ((hpfar & 0x00000ffffffffff0) >> 4) << 12
|
|
return page | (0 if (esr >> 7) & 1 else far & 0xfff)
|
|
|
|
|
|
def read_last_event(proxy, out):
|
|
"""The last event recorded, or None if the probe never recorded one.
|
|
|
|
Kind zero is the empty slot: every EV_* is one or more, so the word is
|
|
self-describing and a run that recorded nothing is not confused with a
|
|
short read."""
|
|
row = struct.unpack('<6Q', proxy.readmem(out + LAST_EVENT_OFFSET, 48))
|
|
return row if row[0] else None
|
|
COUNTER_NAMES = ('MAP_PAGE completed', 'MAP_PAGE failed',
|
|
'dispatch commands refused', 'fatal-exception announcements',
|
|
'undefined instructions skipped',
|
|
'MAP_PAGE PTEs outside our memory',
|
|
'events the log had no room for',
|
|
'console bytes the buffer had no room for',
|
|
'out-of-memory spans the table had no room for',
|
|
'MAP_PAGE mappings onto device windows',
|
|
'device registers read', 'device registers written',
|
|
"bytes XNU put on its own serial line")
|
|
|
|
|
|
def read_exc_return(proxy, out):
|
|
"""The trampoline XNU registered, from the word that is only ever that."""
|
|
return struct.unpack('<Q', proxy.readmem(out + EXC_RETURN_OFFSET, 8))[0]
|
|
|
|
|
|
def read_counters(proxy, out):
|
|
"""The raw tallies, as a dict the classifier can consult."""
|
|
values = struct.unpack(f'<{len(COUNTER_KEYS)}Q',
|
|
proxy.readmem(out + COUNTERS_OFFSET,
|
|
len(COUNTER_KEYS) * 8))
|
|
return dict(zip(COUNTER_KEYS, values))
|
|
|
|
|
|
def counters(proxy, out):
|
|
"""The tallies, printed only when there is something to say."""
|
|
values = struct.unpack(f'<{len(COUNTER_KEYS)}Q',
|
|
proxy.readmem(out + COUNTERS_OFFSET,
|
|
len(COUNTER_KEYS) * 8))
|
|
if not any(values):
|
|
return
|
|
print('\ncounters:')
|
|
for name, value in zip(COUNTER_NAMES, values):
|
|
if value:
|
|
print(f' {name:<32} {value}')
|
|
|
|
|
|
# The kprintf gate, by address, in kernelcache.release.mac17p.
|
|
#
|
|
# Measured addresses, not derived ones: they came out of a disassembly of THIS
|
|
# binary and nothing at run time re-finds them. Named at module scope so a test
|
|
# can pin them, because a digit wrong here reads some other __DATA_CONST byte
|
|
# and prints a confident number about the wrong variable -- and both of these
|
|
# ship as values (0x01 and null) common enough nearby that a wrong address
|
|
# would not look wrong.
|
|
#
|
|
# mac17j and mac17g are different binaries of the same source and every one of
|
|
# these addresses is different in them. The device boots mac17p.
|
|
KPRINTF_GATE = 0xfffffe0007ebf160 # two SECURITY_READ_ONLY_LATE gate bytes
|
|
PE_KPUTC = 0xfffffe0007ebf220 # the console function pointer they guard
|
|
|
|
|
|
def video_console_state(peek, built):
|
|
"""A profile-scoped memory witness, independent of silent kprintf gates."""
|
|
witness = built.get('video_console_witness')
|
|
if witness is None:
|
|
print('\n--- no audited video-console witness for this guest profile ---')
|
|
return None
|
|
print('\n--- XNU video-console state (guest memory) ---')
|
|
state = witness.read(peek) # failure must not look like a valid witness
|
|
print(f' vinfo {state["width"]}x{state["height"]}x{state["depth"]}, '
|
|
f'stride {state["stride"]}, PA {state["physical_base"]:#x}, '
|
|
f'VA {state["mapped_va"]:#x}')
|
|
fb = built.get('framebuffer')
|
|
expected = (fb[0], fb[1], fb[2], fb[3], fb[4]) if fb else None
|
|
actual = tuple(state[k] for k in ('physical_base', 'width', 'height', 'stride', 'depth'))
|
|
state['geometry_matches'] = expected is not None and actual == expected
|
|
print(f' geometry matches handover: {state["geometry_matches"]}; '
|
|
f'graphics mode byte {state["graphics_mode"]}; acquired byte {state["acquired"]}')
|
|
if not state['acquired']:
|
|
print(' mapped/configured is not acquired: XNU has not acquired its video console')
|
|
else:
|
|
print(' acquired; this state alone does not prove glyphs were drawn')
|
|
return state
|
|
|
|
|
|
def kprintf_gate(peek, built):
|
|
"""The two SECURITY_READ_ONLY_LATE bytes that decide whether kprintf
|
|
reaches a wire, and the function pointer behind them, live against the
|
|
values the loader uploaded."""
|
|
# Why kprintf is silent, measured rather than argued.
|
|
#
|
|
# The story. XNU's kprintf reaches a wire only if three things hold where
|
|
# it is called (0xfffffe000b91c930 and 0xfffffe000b91c9f4, identical):
|
|
# a flag in w8 is set, bit 0 of the byte at 0xfffffe0007ebf160 is CLEAR,
|
|
# and the function pointer PE_kputc at 0xfffffe0007ebf220 is non-null.
|
|
# `tbnz w9, #0` on that byte branches PAST the call, so 1 means silence.
|
|
#
|
|
# Both gate bytes ship as 1 in kernelcache.release.mac17p, and that is the
|
|
# whole question. Byte 0xfffffe0007ebf160 has one writer in the image, a
|
|
# strb at 0xfffffe000c24f8e8, 0x34 bytes after the str that sets PE_kputc
|
|
# -- one function arms both. Byte 0xfffffe0007ebf161 has ONE reader, an
|
|
# ldrb at 0xfffffe000c1eff50 inside serial init, and NO writer anywhere in
|
|
# the image: scanned for every adrp+store form against that page, there is
|
|
# nothing that can clear it. If that holds it is permanently 1 and the
|
|
# branch it guards is permanently taken.
|
|
#
|
|
# What this measures is the LIVE bytes at the stop, against the image's
|
|
# shipped 01 01 00 00. Byte .160 clearing to 0 means PE_init_printf ran and
|
|
# kprintf should have reached the wire, so silence has another cause; .160
|
|
# still 1 means it never ran. .161 is expected to be 1 forever, and it
|
|
# being 0 would refute the "no writer" reading outright.
|
|
#
|
|
# This decides whether docs/PLAN-HISTORY-20260922.md's witness 3 is reachable as written: both of
|
|
# initialize_screen's diagnostics go through kprintf, and a permanently
|
|
# closed gate sends them to os_log and never to us.
|
|
print('\n--- why kprintf is or is not on the wire ---')
|
|
try:
|
|
live = peek(KPRINTF_GATE, 4)
|
|
shipped = uploaded(built, KPRINTF_GATE, 4)
|
|
for i, (what, why) in enumerate((
|
|
('gate byte +0x160', 'kprintf is suppressed while bit 0 is set; '
|
|
'one writer in the image, with PE_kputc'),
|
|
('gate byte +0x161', 'read once, in serial init; NO writer '
|
|
'found anywhere in the image'),
|
|
('byte +0x162', 'not part of the kprintf gate; shown for context'),
|
|
('byte +0x163', 'not part of the kprintf gate; shown for context'))):
|
|
now, was = (live >> (8 * i)) & 0xff, (shipped >> (8 * i)) & 0xff
|
|
moved = '' if now == was else f' <-- CHANGED from {was:#04x}'
|
|
print(f' {what:<20} {now:#04x} (image ships {was:#04x}){moved}')
|
|
if i < 2:
|
|
print(f' {why}')
|
|
kputc = peek(PE_KPUTC)
|
|
print(f' {"PE_kputc":<20} {kputc:#018x}'
|
|
f'{" still null: no console function was ever installed" if not kputc else ""}')
|
|
if not (live & 1) and kputc:
|
|
print(' +0x160 is clear and PE_kputc is installed; this alone '
|
|
'does not prove a kprintf call reached the wire')
|
|
if live & 0x100:
|
|
print(' +0x161 remains set: the serial-init gate is still '
|
|
'closed; do not infer that both gates are open')
|
|
elif live & 1:
|
|
print(' bit 0 of +0x160 is set, so every kprintf call branches '
|
|
'past PE_kputc: witness 3 cannot arrive this way')
|
|
except Exception as exc: # noqa: BLE001
|
|
print(f' could not read it: {exc}')
|
|
|
|
|
|
def guest_sysregs(proxy, out, built):
|
|
"""The guest's translation registers, and whether MAIR moved."""
|
|
# And the guest's own MAIR, which is the hardware check on a static claim.
|
|
#
|
|
# The loader publishes the 0x0c0d00ff01a040ff SPTM's common start leaves
|
|
# for XNU, because there is no `msr MAIR_EL1` anywhere in this kernelcache.
|
|
# If that reading of 120 MiB of image is wrong, this word comes back as
|
|
# something else.
|
|
try:
|
|
sysregs = read_guest_sysregs(proxy, out)
|
|
print('\n--- the guest\'s translation registers, as we last saw them ---')
|
|
for name, value in sysregs.items():
|
|
note = ''
|
|
if name == 'MAIR_EL1':
|
|
note = (' unchanged: XNU never wrote it, as the image said'
|
|
if value == built['mair'] else
|
|
f' CHANGED from the {built["mair"]:#018x} we published '
|
|
f'-- something wrote MAIR and the image scan missed it')
|
|
print(f' {name:<12} {value:#018x}{note}')
|
|
except Exception as exc: # noqa: BLE001
|
|
print(f' could not read the guest sysregs: {exc}')
|
|
|
|
|
|
def guest_registers(proxy, out):
|
|
"""Print the guest's registers as of its exceptions, if any were captured.
|
|
|
|
Two snapshots: the first exception explains the run, and every later one is
|
|
usually XNU's own panic path, whose registers belong to the handler rather
|
|
than to the code that failed. When only one was seen they are the same and
|
|
only one is printed."""
|
|
raw = proxy.readmem(out + GUEST_REGS_OFFSET, GUEST_REGS_BYTES)
|
|
count = struct.unpack_from('<Q', raw, 0)[0]
|
|
if not count:
|
|
return
|
|
|
|
def snapshot(index):
|
|
base = 8 + index * GUEST_REGS_WORDS * 8
|
|
words = struct.unpack_from(f'<{GUEST_REGS_WORDS}Q', raw, base)
|
|
return (words[:31], words[31], words[32], words[33], words[34],
|
|
words[35])
|
|
|
|
shown = [('first', 0)] if count == 1 else [('first', 0), ('last', 1)]
|
|
print(f'\nguest registers at {count} exception{"" if count == 1 else "s"}:')
|
|
for label, index in shown:
|
|
x, sp, spsr, pc, esr, far = snapshot(index)
|
|
head = '' if count == 1 else f' ({label})'
|
|
print(f' PC {pc:#018x} ESR_EL1 {esr:#x} (EC {esr >> 26:#x} '
|
|
f'FSC {esr & 0x3f:#04x}) FAR_EL1 {far:#018x}')
|
|
print(f' SP {sp:#018x} SPSR_EL1 {spsr:#x}{head}')
|
|
dump_gprs(x)
|
|
|
|
|
|
def dump_gprs(x, indent=' '):
|
|
for row in range(0, 31, 4):
|
|
cells = ' '.join(f'x{n:<2} {x[n]:#018x}'
|
|
for n in range(row, min(row + 4, 31)))
|
|
print(f'{indent}{cells}')
|
|
|
|
|
|
def panic_entry(proxy, out):
|
|
"""The frame at XNU's panic-entry call, dispatch command 0x2.
|
|
|
|
XNU makes this call on its way out of a fatal exception, before panic() has
|
|
run and before any panic record exists -- so this is the earliest point at
|
|
which the fault can be read, and often the only one."""
|
|
raw = proxy.readmem(out + PANIC_REGS_OFFSET, PANIC_REGS_BYTES)
|
|
count = struct.unpack_from('<Q', raw, 0)[0]
|
|
if not count:
|
|
return
|
|
words = struct.unpack_from(f'<{GUEST_REGS_WORDS}Q', raw, 8)
|
|
x, sp, spsr, pc, esr, far = (words[:31], words[31], words[32],
|
|
words[33], words[34], words[35])
|
|
|
|
print(f'\nXNU announced a fatal exception {count} time'
|
|
f'{"" if count == 1 else "s"} (dispatch command 0x2):')
|
|
|
|
caller = x[30]
|
|
where = PANIC_CALLERS.get(caller)
|
|
print(f' called from {caller:#018x} '
|
|
f'{where if where else "a site this project has not seen before"}')
|
|
|
|
# EL1's own registers, read at EL2 when the call arrived. These do not
|
|
# depend on which caller it was: XNU copies them, it does not rewrite them.
|
|
print(f' ELR_EL1 {pc:#018x} ESR_EL1 {esr:#x} '
|
|
f'(EC {esr >> 26:#x} FSC {esr & 0x3f:#04x} '
|
|
f'{"write" if (esr >> 6) & 1 else "read"})')
|
|
print(f' FAR_EL1 {far:#018x} SP {sp:#018x} SPSR_EL2 {spsr:#x}')
|
|
|
|
# And the latched copies, with each register given the role this caller
|
|
# actually assigns it -- x25 and x26 swap between the two shapes.
|
|
roles = PANIC_LATCH_CALLERS.get(caller)
|
|
if roles:
|
|
cells = [f'x{n} {x[n]:#018x} ({roles[n]})' for n in sorted(roles)]
|
|
print(f' latched {cells[0]} {cells[1]}')
|
|
print(f' {cells[2]} {cells[3]}')
|
|
else:
|
|
print(' x25/x26/x27/x28 are NOT the fault here -- this caller does '
|
|
'not latch them,')
|
|
print(' so they hold unrelated values and are left undecoded above.')
|
|
dump_gprs(x, ' ')
|
|
|
|
|
|
def truncated(dropped, capacity, panic_said):
|
|
"""How to report a run whose event log overflowed.
|
|
|
|
The log is the only thing truncation damages. XNU's panic record is read out
|
|
of memory after the run, so it cannot overflow and it is not inferred from
|
|
anything -- if the kernel wrote down why it stopped, that answer survives
|
|
intact and is strictly better than refusing to answer.
|
|
|
|
So the guard suppresses verdicts INFERRED FROM THE LOG and leaves the ones
|
|
that come from the record. A truncated log means we cannot say which monitor
|
|
call was last; it does not mean we cannot read what the kernel wrote."""
|
|
lost = (f'{dropped} events did not fit the {capacity}-entry log, so no '
|
|
f'monitor-call cause can be named from it')
|
|
if panic_said:
|
|
return EXIT_PANIC, (
|
|
f'the kernel panicked: {panic_said.strip()[:160]} '
|
|
f'(read from the panic record, which memory holds and truncation '
|
|
f'cannot reach; {lost})')
|
|
return EXIT_UNCLASSIFIED, (
|
|
f'{lost}, and the panic record is empty -- so the run stopped for a '
|
|
f'reason nothing recorded')
|
|
|
|
|
|
def format_panic(fmt, words, peekstr):
|
|
"""Substitute a panic's arguments into its format string.
|
|
|
|
The kernel pushes each argument into an 8-byte stack slot, so the words are
|
|
consumed one per conversion in order. This is deliberately forgiving: a
|
|
conversion it does not know stops the substitution and returns what it had,
|
|
because half a sentence plus the raw words printed above is honest, and a
|
|
confidently wrong sentence is the failure mode this whole exercise exists to
|
|
avoid.
|
|
|
|
Returns None when there is nothing useful to say."""
|
|
if not fmt:
|
|
return None
|
|
out, i, arg = [], 0, 0
|
|
while i < len(fmt):
|
|
ch = fmt[i]
|
|
if ch != '%':
|
|
out.append(ch)
|
|
i += 1
|
|
continue
|
|
i += 1
|
|
if i < len(fmt) and fmt[i] == '%':
|
|
out.append('%')
|
|
i += 1
|
|
continue
|
|
# flags, width, precision, length -- skipped, they only affect padding
|
|
while i < len(fmt) and fmt[i] in '-+ #0123456789.*':
|
|
i += 1
|
|
length = ''
|
|
while i < len(fmt) and fmt[i] in 'hlLqjzt':
|
|
length += fmt[i]
|
|
i += 1
|
|
if i >= len(fmt):
|
|
break
|
|
conv = fmt[i]
|
|
i += 1
|
|
if arg >= len(words):
|
|
return None # ran out; do not invent
|
|
value = words[arg]
|
|
arg += 1
|
|
if conv == 's':
|
|
text = peekstr(value, 128)
|
|
out.append(f'"{text}"' if text else f'<{value:#x}>')
|
|
elif conv == 'c':
|
|
out.append(chr(value & 0xff))
|
|
elif conv in 'xX':
|
|
out.append(f'{value:x}' if conv == 'x' else f'{value:X}')
|
|
elif conv == 'p':
|
|
out.append(f'{value:#x}')
|
|
elif conv in 'di':
|
|
width = 64 if length else 32
|
|
v = value & ((1 << width) - 1)
|
|
if v >= 1 << (width - 1):
|
|
v -= 1 << width
|
|
out.append(str(v))
|
|
elif conv == 'u':
|
|
out.append(str(value & (0xFFFFFFFFFFFFFFFF if length else 0xFFFFFFFF)))
|
|
else:
|
|
# Unknown conversion: stop rather than guess at how many words it ate.
|
|
out.append(f'%{length}{conv}<unformatted>')
|
|
break
|
|
return ''.join(out).strip()
|
|
|
|
|
|
def par_report(par):
|
|
"""PAR_EL1: bit 0 set means the walk faulted."""
|
|
if par & 1:
|
|
return f'FAULT (FST {(par >> 1) & 0x3f:#04x})'
|
|
return f'ok PA {par & 0x000ffffffffff000:#x}'
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument('probe', choices=['idregs', 'tgran16', 'tid3', 'shadowmmu',
|
|
'xnuboot'])
|
|
ap.add_argument('--device', default=DEFAULT_DEVICE,
|
|
help='serial/socket path or udp://enN (default: discover CDC/NCM)')
|
|
ap.add_argument('--guest-profile', default='j700ap-26A428')
|
|
ap.add_argument('--firmware-dir')
|
|
ap.add_argument('--kernelcache', help='must match the selected guest profile')
|
|
ap.add_argument('--ramdisk', help='raw APFS root image, reserved in guest RAM')
|
|
ap.add_argument('--ramdisk-mode', choices=('md0', 'iokit'), default='md0',
|
|
help='BSD memory device or native IOKit RAM-image attachment')
|
|
ap.add_argument('--root-hash', help='raw root-hash payload, bound by the guest profile to --ramdisk')
|
|
ap.add_argument('--isc-image', help='fresh auxiliary raw APFS ISC image; requires IOKit mode')
|
|
ap.add_argument('--auxkc', help='kmutil-linked AuxKC (build/build-auxkc.py) for a linked-collection profile')
|
|
ap.add_argument('--trust-additions', help='JSON CDHash rows for an augmented trust-cache profile')
|
|
ap.add_argument('--ans-disk', help='4 KiB-LBA GPT disk for a virtual ANS2 profile '
|
|
'(build/build-ans-test-disk-20260925.py)')
|
|
ap.add_argument('--ans-writable', action='store_true',
|
|
help='enable persistent guest writes inside the installed SSD window')
|
|
ap.add_argument('--console', dest='console_mode', choices=['auto', 'serial', 'panel'],
|
|
default='auto',
|
|
help='auto uses the native panel console when a framebuffer is available, '
|
|
'otherwise serial; use serial for UART capture')
|
|
ap.add_argument('--relay-panel', action='store_true',
|
|
help='draw captured serial output; requires SNAP_NO_FB=1')
|
|
ap.add_argument('--graphics-boot',action='store_true',
|
|
help='native graphics-mode boot handoff without -v; requires the panel route')
|
|
ap.add_argument('--qemu', action='store_true',
|
|
help='run against q1n1 in QEMU (-cpu max) instead of hardware')
|
|
args = ap.parse_args()
|
|
if args.probe == 'xnuboot':
|
|
from guestprofile import get_profile
|
|
try:
|
|
get_profile(args.guest_profile)
|
|
except ValueError as error:
|
|
ap.error(str(error))
|
|
boot_options = {key: getattr(args, key) for key in
|
|
('guest_profile', 'firmware_dir', 'kernelcache',
|
|
'console_mode', 'relay_panel', 'ramdisk', 'root_hash', 'ramdisk_mode', 'isc_image',
|
|
'graphics_boot', 'auxkc', 'trust_additions', 'ans_disk', 'ans_writable')}
|
|
|
|
blob = build(args.probe, ROOT / 'build')
|
|
if args.qemu:
|
|
with Qemu(ROOT) as sock:
|
|
proxy = connect(sock)
|
|
try:
|
|
return run(proxy, blob, args.probe, 'QEMU -cpu max', boot_options)
|
|
finally:
|
|
proxy.link.close()
|
|
proxy = connect(args.device)
|
|
try:
|
|
return run(proxy, blob, args.probe, proxy.endpoint, boot_options)
|
|
finally:
|
|
proxy.link.close()
|
|
|
|
|
|
def run(proxy, blob, probe, where, boot_options=None):
|
|
# Check before any target operation: growing the runtime must never let
|
|
# report initialization overwrite its executable image.
|
|
if not blob or len(blob) > OUT_AT - CODE_AT:
|
|
raise ValueError('probe image exceeds its reserved code region')
|
|
info = proxy.bootinfo()
|
|
print(f'{where}: q1n1 generation {info["stage_generation"]}, EL{info["current_el"]}, '
|
|
f'heap {info["heap_base"]:#x}+{info["heap_size"] >> 20}M')
|
|
|
|
code = info['heap_base'] + CODE_AT
|
|
out = info['heap_base'] + OUT_AT
|
|
# 16K aligned, and clear of both code and the output buffer.
|
|
table = (out + 0x20000 + 0x3fff) & ~0x3fff
|
|
proxy.writemem(code, blob)
|
|
proxy.flush_code(code, len(blob))
|
|
|
|
if probe == 'xnuboot':
|
|
return run_xnuboot(proxy, info, code, code_size=len(blob), **(boot_options or {}))
|
|
|
|
if probe == 'shadowmmu':
|
|
work = (info['heap_base'] + 0x500000 + 0xffff) & ~0xffff
|
|
stage = int(os.environ.get('SNAP_STAGE', '0'))
|
|
proxy.call(code, out, work, 0x200000, stage)
|
|
magic, ttbr_back = struct.unpack('<2Q', proxy.readmem(out, 16))
|
|
g_ttbr, g_tcr, s_root, traps, builds, leaves, par_code, par_test = struct.unpack(
|
|
'<8Q', proxy.readmem(out + 16, 64))
|
|
expect, test_va, entered = struct.unpack('<3Q', proxy.readmem(out + 80, 24))
|
|
f_esr, f_elr, f_far = struct.unpack('<3Q', proxy.readmem(out + 104, 24))
|
|
(stub_pa, stub_calls, stub_result, stub_arg, steps, neighbour,
|
|
oslsr, mdcr, mdscr, s_root1, leaves1, par_k,
|
|
kernel, kernel_want) = struct.unpack('<14Q', proxy.readmem(out + 128, 112))
|
|
aborts, steps = steps & 0xffffffff, steps >> 32
|
|
if f_esr:
|
|
print(f' EL2 FAULT ESR {f_esr:#x} (EC {f_esr >> 26:#x}) '
|
|
f'ELR {f_elr:#x} FAR {f_far:#x}')
|
|
# shadowmmu_selftest returns int, so the verdict is 32-bit.
|
|
verdict = (entered & 0xffffffff)
|
|
if verdict >= (1 << 31):
|
|
verdict -= (1 << 32)
|
|
if stage == 0:
|
|
names = {0: 'ok', -1: 'prepare: out of work area', -2: 'no shadow root',
|
|
-3: 'shadow_build failed', -4: 'shadow walk failed',
|
|
-5: 'test VA unmapped in the shadow', -6: 'wrong data at the mapped PA',
|
|
-7: 'code walk failed', -8: 'code not identity-mapped in the shadow',
|
|
-9: 'hardware refused the shadow for the guest PC',
|
|
-10: 'hardware refused the shadow for the test VA'}
|
|
print(f' EL2 selftest {names.get(verdict, verdict)}')
|
|
par0 = lambda v: ('not attempted' if v == 0 else
|
|
f'FAULT (FST {(v >> 1) & 0x3f:#04x})' if v & 1 else
|
|
f'ok PA {v & 0x000ffffffffff000:#x}')
|
|
print(f' hw walk guest PC {par0(par_code)}')
|
|
print(f' hw walk test VA {par0(par_test)}')
|
|
else:
|
|
print(f' guest entered {"yes" if entered else "NO"}')
|
|
print(f' guest TTBR0 (16K) {g_ttbr:#x} TCR {g_tcr:#x} TG0 {(g_tcr >> 14) & 3}')
|
|
print(f' shadow root (4K) {s_root:#x} leaves {leaves}')
|
|
if s_root1 or stage >= 8:
|
|
print(f' upper-half shadow {s_root1:#x} leaves {leaves1}')
|
|
guest_fault = builds >> 32
|
|
print(f' sysreg traps {traps} shadow builds {builds & 0xffffffff}')
|
|
if guest_fault:
|
|
print(f' guest EL1 fault ESR {guest_fault:#x} (EC {guest_fault >> 26:#x}, '
|
|
f'FSC {guest_fault & 0x3f:#04x})')
|
|
print(f' guest read {magic:#018x} from VA {test_va:#x}, wanted {expect:#018x}')
|
|
print(f' guest read back TTBR0 {ttbr_back:#x}')
|
|
if stage == 0:
|
|
return 0 if verdict == 0 else 1
|
|
|
|
par = lambda v: ('not attempted' if v == 0 else
|
|
f'FAULT (FST {(v >> 1) & 0x3f:#04x})' if v & 1 else
|
|
f'ok PA {v & 0x000ffffffffff000:#x}')
|
|
print(f' hw walk of guest PC {par(par_code)}')
|
|
print(f' hw walk of test VA {par(par_test)}')
|
|
if stage >= 8:
|
|
print(f' hw walk of kernel VA {par(par_k)}')
|
|
print(f' kernel-half load {kernel:#018x}, wanted {kernel_want:#018x}')
|
|
# Stage 6 arms the monitor-call interception: one page of the blob is
|
|
# execute-never at stage 2, so the guest's call into it is answered by
|
|
# EL2. STUB_REPLY and the argument are fixed in the probe.
|
|
want = (0x5150 + 0x504d0000) if stage >= 6 else 0
|
|
if stage >= 6:
|
|
print(f' stub page {stub_pa:#x} calls {stub_calls}')
|
|
print(f' stub call EL2 saw x0 {stub_arg:#x}, guest got '
|
|
f'{stub_result:#x}, wanted {want:#x}')
|
|
if stage >= 7:
|
|
print(f' neighbour {neighbour:#x}, wanted 0x1235 '
|
|
f'({aborts} aborts, {steps} single-steps)')
|
|
print(f' debug state OSLSR {oslsr:#x} (OS lock '
|
|
f'{"held" if oslsr & 2 else "clear"}) MDCR_EL2 {mdcr:#x} '
|
|
f'(TDE {(mdcr >> 8) & 1}) MDSCR_EL1 {mdscr:#x} (SS {mdscr & 1})')
|
|
if not entered:
|
|
print('\nFAILED: never reached the guest (no room in the work area?)')
|
|
elif (builds & 0xffffffff) < 1:
|
|
print('\nFAILED: the shadow was not built when the guest enabled its MMU.')
|
|
elif magic != expect:
|
|
print('\nFAILED: the load through the guest VA did not resolve.')
|
|
elif ttbr_back != g_ttbr:
|
|
print('\nFAILED: TRVM leaked the shadow root to the guest.')
|
|
elif stage >= 6 and stub_calls != 1:
|
|
print(f'\nFAILED: the stub page was entered {stub_calls} times, wanted 1 '
|
|
'-- stage 2 did not make it execute-never.')
|
|
elif stage >= 6 and stub_result != want:
|
|
print('\nFAILED: the guest got the wrong answer from the stub '
|
|
f'({stub_result:#x}); the page executed instead of trapping.')
|
|
elif stage >= 8 and kernel != kernel_want:
|
|
print(f'\nFAILED: the load through the kernel-half VA returned '
|
|
f'{kernel:#x}; TTBR1 is not being shadowed correctly.')
|
|
elif stage >= 7 and neighbour != 0x1235:
|
|
print(f'\nFAILED: ordinary code on the protected page returned '
|
|
f'{neighbour:#x}; the single-step fallback did not run it.')
|
|
elif stage >= 7 and steps != aborts:
|
|
print(f'\nFAILED: {aborts} aborts but {steps} steps. The page was '
|
|
'unprotected and never protected again -- the right answer for '
|
|
'the wrong reason, because the step exception never fired.')
|
|
elif stage >= 8:
|
|
print(f'\nA GUEST RAN WITH 16K PAGE TABLES IN BOTH HALVES. It resolved '
|
|
f'{test_va:#x} through TTBR0 and a kernel address through TTBR1 '
|
|
f'({leaves} + {leaves1} leaves re-translated), and its monitor call '
|
|
'was answered by EL2. That is every translation mechanism XNU needs '
|
|
'to be entered.')
|
|
elif stage >= 7:
|
|
print(f'\nA GUEST RAN WITH 16K PAGE TABLES; its call into {stub_pa:#x} was '
|
|
'answered by EL2, and ordinary code on that same execute-never page '
|
|
f'still ran, one instruction per step ({aborts} aborts, {steps} steps). '
|
|
"That is both halves of what XNU's monitor-call pages need.")
|
|
elif stage >= 6:
|
|
print(f'\nA GUEST RAN WITH 16K PAGE TABLES, and its call into {stub_pa:#x} '
|
|
'was answered by EL2 without the page ever executing. That is the '
|
|
"mechanism XNU's SPTM call stubs need, since `genter` is an "
|
|
'undefined encoding on this silicon.')
|
|
else:
|
|
print(f'\nA GUEST RAN WITH 16K PAGE TABLES. TCR_EL1.TG0={(g_tcr >> 14) & 3} '
|
|
f'(16K) never reached the MMU; {leaves} leaves were re-translated into '
|
|
f'a 4K shadow at {s_root:#x}, and the guest resolved {test_va:#x} '
|
|
f'through its own tables.')
|
|
elif probe == 'tid3':
|
|
proxy.call(code, out)
|
|
real, seen, isar2, traps, esr, elr = struct.unpack('<6Q', proxy.readmem(out, 48))
|
|
tg = lambda v: (v >> 20) & 0xf
|
|
tg2 = lambda v: (v >> 32) & 0xf
|
|
print(f' EL2 sees MMFR0 {real:#018x} TGran16 {tg(real):#x} TGran16_2 {tg2(real):#x}')
|
|
print(f' guest sees MMFR0 {seen:#018x} TGran16 {tg(seen):#x} TGran16_2 {tg2(seen):#x}')
|
|
print(f' guest ISAR2 {isar2:#018x} traps taken {traps}')
|
|
if esr:
|
|
print(f' UNEXPECTED exception ESR {esr:#x} ELR {elr:#x}')
|
|
return 1
|
|
untouched = (real & ~0x0000000f00f00000) == (seen & ~0x0000000f00f00000)
|
|
if traps < 2:
|
|
print('\nFAILED: the reads did not trap, so TID3 is not in effect.')
|
|
elif tg(seen) != 2 or tg2(seen) != 2:
|
|
print('\nFAILED: the guest did not see the forged granule.')
|
|
elif not untouched:
|
|
print('\nFAILED: fields other than TGran16 changed; the guest would see '
|
|
'an inconsistent machine.')
|
|
elif isar2 != 0 and tg(real) != 2:
|
|
print(f'\nTID3 WORKS. The guest believes in 16K pages on hardware that '
|
|
f'reports TGran16 {tg(real):#x}, and every other field is unchanged.')
|
|
else:
|
|
print('\nTID3 works, but this CPU already has 16K so the forgery is not '
|
|
'observable here — run it on the A16 for the real result.')
|
|
elif probe == 'tgran16':
|
|
proxy.call(code, out, table)
|
|
hcr, a, b, c, d, mmfr0, expect = struct.unpack('<7Q', proxy.readmem(out, 56))
|
|
print(f'HCR_EL2 {hcr:#018x} (TGE {(hcr >> 27) & 1}, VM {hcr & 1})')
|
|
print(f' A 4K entry0 mapped, VA 0x1000 {par_report(a)}')
|
|
print(f' B 4K empty table, VA 0x1000 {par_report(b)}')
|
|
print(f' C 4K entry1 mapped, VA 0x2001000 {par_report(c)}')
|
|
print(f' D 16K entry1 mapped, VA 0x2001000 {par_report(d)}')
|
|
print(f'ID_AA64MMFR0_EL1 {mmfr0:#018x} '
|
|
f'TGran16 {(mmfr0 >> 20) & 0xf:#x} TGran16_2 {(mmfr0 >> 32) & 0xf:#x}')
|
|
ok_a, faulted_b, faulted_c = not (a & 1), bool(b & 1), bool(c & 1)
|
|
got = d & 0x000ffffffffff000
|
|
if not ok_a:
|
|
print('\nINCONCLUSIVE: the 4K control did not resolve, so the harness '
|
|
'is wrong. Fix that before reading anything else.')
|
|
elif not faulted_b:
|
|
print('\nINCONCLUSIVE: the empty table did not fault, so SCTLR_EL1.M '
|
|
'was not in effect and every trial would pass regardless.')
|
|
elif not faulted_c:
|
|
print('\nINCONCLUSIVE: 4K resolved an address only 16K should reach, '
|
|
'so the discriminator does not discriminate.')
|
|
elif d & 1:
|
|
print('\n16K DOES NOT WALK. The controls are sound and the granule '
|
|
'fell back or faulted, so ID_AA64MMFR0_EL1 is telling the '
|
|
'truth: XNU cannot run on this silicon in any mode.')
|
|
elif got == expect:
|
|
advertised = (mmfr0 >> 20) & 0xf
|
|
agrees = ('which matches ID_AA64MMFR0_EL1' if advertised in (1, 2)
|
|
else 'even though ID_AA64MMFR0_EL1 says it is absent -- the '
|
|
'register understates the hardware, like the Huawei '
|
|
'PAuth case')
|
|
print(f'\n16K WALKS, resolving {got:#x}, an address the 4K '
|
|
f'interpretation faults on -- {agrees}.')
|
|
else:
|
|
print(f'\nUNEXPECTED: resolved {got:#x}, wanted {expect:#x}. '
|
|
'Do not draw a conclusion from this.')
|
|
else:
|
|
proxy.call(code, out)
|
|
names = ['MIDR', 'PFR0', 'PFR1', 'ISAR0', 'ISAR1', 'ISAR2',
|
|
'MMFR0', 'MMFR1', 'MMFR2', 'DFR0', 'CurrentEL', 'MPIDR']
|
|
for name, value in zip(names, struct.unpack('<12Q', proxy.readmem(out, 96))):
|
|
print(f'{name:10s} {value:#018x}')
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|