iOS: fix Legacy-mode SIGBUS on cross-window JIT code patches

Boot crash on iOS 18 under LiveContainer (Legacy W^X mode): CPU thread,
EXC_BAD_ACCESS KERN_PROTECTION_FAILURE in Arm64BaseBlocks::New -- a str of
a branch encoding into an r-x page. In Legacy mode armStartBlock flips
only the current block's 1 MiB window to RW, but New()'s link-repoint
loop, Remove()'s entry stubs and the backedge patch write into EARLIER
blocks, whose windows are execute-protected by then. PatchWord trusted
its callers to hold a Begin/EndCodeWrite scope; no caller on those paths
did. The toggle modes (macOS, Simulator) masked it because the compile
thread's write-protect is off arena-wide during recRecompile.

Give the patch primitive its own scope instead: armPatchCodeWord stores
through the dual-map alias and, when the target page is outside the open
emit window, wraps the store in a page-granular Begin/EndCodeWriteRange.
The page check matters -- the bump allocator routinely puts the previous
block's link site on the same page as the current block's start, and
RX-flipping that page mid-emit would kill the compile in a new way.

The whole-arena Begin/EndCodeWrite scopes that only existed to cover
those patches are gone with it. They were their own crash: Legacy range
windows bypass the refcount, so recClear's paired EndCodeWrite RX-flipped
the open emit window whenever the stale-overlap walk cleared blocks
mid-compile, and recClearIOP paid a whole-arena mprotect pair per covered
IOP store. The fastmem backpatch and the cold-island patches route
through armPatchCodeWord for the same reason, and microVU's code-cache
scope narrows to its own buffer span so an MTVU close can no longer
RX-flip an EE emit window open on the other thread.
This commit is contained in:
J1coding
2026-07-26 13:48:45 +02:00
committed by Jeen
parent 80782e3781
commit dac184c894
7 changed files with 99 additions and 55 deletions
+38 -7
View File
@@ -191,6 +191,43 @@ u8* armEndBlock()
return armAsmPtr;
}
// Patch one instruction word at `site`, handling W^X itself instead of
// trusting the caller to hold a write scope — link sites in earlier blocks
// sit in windows armStartBlock never opened, which is a SIGBUS on iOS
// Legacy mode. Pages inside the open emit window are stored to directly
// (the previous block's link site often shares a page with the current
// block's start, and RX-flipping that mid-emit would kill the compile);
// anything else gets its own page-granular Begin/EndCodeWriteRange.
// Aligned 4-byte stores are atomic on AArch64, and mprotect is fine from
// the Mach exception-handler thread, so the fastmem-fault path can use it.
void armPatchCodeWord(void* site, u32 instr)
{
u8* const rx_site = static_cast<u8*>(site);
bool in_open_window = false;
if (s_arm_block_start)
{
static const uintptr_t page_mask = []() {
size_t page_size = HostSys::GetRuntimePageSize();
if (page_size == 0)
page_size = 4096;
return ~(static_cast<uintptr_t>(page_size) - 1);
}();
const uintptr_t site_page = reinterpret_cast<uintptr_t>(rx_site) & page_mask;
const uintptr_t first_page = reinterpret_cast<uintptr_t>(s_arm_block_start) & page_mask;
const uintptr_t last_page =
(reinterpret_cast<uintptr_t>(s_arm_block_start) + s_arm_block_write_size - 1) & page_mask;
in_open_window = (site_page >= first_page && site_page <= last_page);
}
if (!in_open_window)
HostSys::BeginCodeWriteRange(rx_site, sizeof(u32));
*reinterpret_cast<volatile u32*>(armGetWritableCodePtr(rx_site)) = instr;
if (!in_open_window)
HostSys::EndCodeWriteRange(rx_site, sizeof(u32));
}
void armDisassembleAndDumpCode(const void* ptr, size_t size)
{
#ifdef INCLUDE_DISASSEMBLER
@@ -254,13 +291,7 @@ void armEmitJmpPtr(void* code_address, const void* target, bool flush_icache)
pxAssertRel((off & 3) == 0, "armEmitJmpPtr: branch offset not 4-byte aligned");
const intptr_t imm26 = off >> 2;
pxAssertRel(imm26 >= -(1 << 25) && imm26 < (1 << 25), "armEmitJmpPtr: branch offset out of B imm26 range");
// code_address is the RX alias; under iOS dual-mapping the store must go
// through the RW mirror. Begin/EndCodeWrite covers the toggle modes
// (refcounted, so it nests inside an open emit scope).
HostSys::BeginCodeWrite();
*reinterpret_cast<volatile u32*>(armGetWritableCodePtr(static_cast<u8*>(code_address))) =
0x14000000u | (static_cast<u32>(imm26) & 0x03FFFFFFu);
HostSys::EndCodeWrite();
armPatchCodeWord(code_address, 0x14000000u | (static_cast<u32>(imm26) & 0x03FFFFFFu));
if (flush_icache)
HostSys::FlushInstructionCache(code_address, 4);
}
+5
View File
@@ -144,6 +144,11 @@ u8* armEndBlock();
void armDisassembleAndDumpCode(const void* ptr, size_t size);
void armEmitJmp(const void* ptr, bool force_inline = false);
void armEmitCall(const void* ptr, bool force_inline = false);
// Store one instruction word into code memory, opening its own W^X scope when
// the target lies outside the open emit window. Use for anything that patches
// code outside the block being emitted — link sites, entry stubs, fastmem
// backpatch. No cache maintenance: callers own the flush policy.
void armPatchCodeWord(void* site, u32 instr);
// In-place patch: overwrite the 4-byte B at `code_address` with a branch to
// `target`. Used by EE block chaining to rewrite a link site (not tied to the
// current emit cursor). `code_address` must already hold a single B instruction.
+11 -13
View File
@@ -64,7 +64,7 @@
#include <map>
#include "common/HostSys.h"
#include "arm64/AsmHelpers.h" // armGetWritableCodePtr (iOS dual-map W^X)
#include "arm64/AsmHelpers.h" // armPatchCodeWord (self-scoped W^X code patching)
#include "x86/BaseblockEx.h" // BASEBLOCK, BASEBLOCKEX, BaseBlockArray, recLUT_SetPage
class Arm64BaseBlocks
@@ -123,20 +123,18 @@ protected:
return (call ? 0x94000000u : 0x14000000u) | (static_cast<u32>(imm26) & 0x03FFFFFFu);
}
// Store only, no cache maintenance. Valid ONLY for a site inside the
// block currently being emitted: that buffer has not been executed yet,
// and armEndBlock() issues one whole-range flush over it before it can
// be. (AetherSX2 does the same — its Link() performs no flush at all,
// leaving it to the single range flush in armEndBlock.)
// Never use this on code that is already live; use PatchAtomic.
// Store only, no cache maintenance. Fine for a site inside the block
// currently being emitted (that buffer has not been executed yet, and
// armEndBlock() issues one whole-range flush over it), and for the
// New() repoint loop, whose callers flush via FlushPatchedSites.
// Never use this on code that is already live without a flush; use
// PatchAtomic.
static void PatchWord(uptr site, u32 instr)
{
// 4-byte aligned word stores are atomic on AArch64. `site` is the RX
// address; under iOS dual-map W^X the store goes through the RW alias
// (identity elsewhere). Callers hold an open Begin/EndCodeWrite scope
// for the toggle modes; on Darwin the "signal handler" caller is
// really the Mach exception-handler thread, so that scope is safe.
*reinterpret_cast<volatile u32*>(armGetWritableCodePtr(reinterpret_cast<u8*>(site))) = instr;
// armPatchCodeWord owns the W^X handling — sites outside the open
// emit window get their own page-granular write scope. Nothing on
// the New()/Remove() paths holds one.
armPatchCodeWord(reinterpret_cast<u8*>(site), instr);
}
// (HostSys::FlushInstructionCache, not the raw builtin: on Darwin the
+7 -8
View File
@@ -360,14 +360,13 @@ void vtlb_DynBackpatchLoadStore(uptr code_address, u32 code_size, u32 guest_pc,
pxAssertRel(branch_imm26 >= -0x2000000 && branch_imm26 <= 0x1FFFFFF,
"Backpatch thunk too far from faulting instruction for B instruction");
HostSys::BeginCodeWrite();
// iOS dual-map W^X: the faulting instruction lives at an RX address; the
// store goes through the RW alias (identity elsewhere). The branch
// displacement above was computed against the RX address, which is what
// the CPU executes.
u32* patch_ptr = reinterpret_cast<u32*>(armGetWritableCodePtr(reinterpret_cast<u8*>(code_address)));
*patch_ptr = 0x14000000u | (static_cast<u32>(branch_imm26) & 0x03FFFFFFu);
HostSys::EndCodeWrite();
// armPatchCodeWord handles W^X (page-granular scope on iOS Legacy mode,
// RW alias under dual-mapping) — an arena-wide Begin/EndCodeWrite here
// would RX-flip any emit window open on another thread on its way out.
// The branch displacement above was computed against the RX address,
// which is what the CPU executes.
armPatchCodeWord(reinterpret_cast<u8*>(code_address),
0x14000000u | (static_cast<u32>(branch_imm26) & 0x03FFFFFFu));
// Flush icache at the patch point too.
HostSys::FlushInstructionCache(reinterpret_cast<void*>(code_address), 4);
+9 -11
View File
@@ -1247,20 +1247,18 @@ static __fi u32 psxRecClearMem(u32 pc)
static void recClearIOP(u32 Addr, u32 Size)
{
// On macOS/Apple Silicon the IOP code cache is MAP_JIT (per-thread W^X).
// recClearIOP is IOP SMC detection: every qualifying store in IopMem.cpp (plus
// DMA/SIF/BIOS-HLE) funnels here via psxCpu->Clear while the IOP thread is
// *executing* recompiled code, i.e. execute-protected with no emit scope open.
// psxRecClearMem's recBlocks.Remove() patches compiled block entry points
// (Arm64BaseBlocks::Remove -> PatchAtomic) inside that cache, so we must flip
// to write mode first or the patch faults (ESR 0x9200004f, byte-write
// permission). Refcounted (nests safely), no-op on Linux. Mirrors the EE
// recClear fix (b54fbcdad).
HostSys::BeginCodeWrite();
// IOP SMC detection: every qualifying store funnels here via psxCpu->Clear
// (IopMem.cpp, DMA/SIF/BIOS-HLE, and the out-of-line store stubs'
// iopStoreClearHit). The only code writes underneath are
// Arm64BaseBlocks::Remove()'s entry stubs, and those open their own
// per-site W^X scope (armPatchCodeWord) — SetFnptr targets are heap data.
// The arena-wide Begin/EndCodeWrite this used to hold was both wasteful
// (a whole-arena mprotect pair per covered store in iOS Legacy mode) and
// hazardous: Legacy range windows bypass the refcount, so the paired
// EndCodeWrite RX-flipped any emit window open on another thread.
u32 end = Addr + Size * 4;
for (u32 i = Addr; i < end; i += PSXREC_CLEARM(i))
;
HostSys::EndCodeWrite();
}
// Called from the JIT RAM-store fast-path stubs when the stored-to granule
+15 -14
View File
@@ -1793,8 +1793,10 @@ static void recPatchIslandB(u8* site, const u8* target)
{
const intptr_t imm26 = (reinterpret_cast<intptr_t>(target) - reinterpret_cast<intptr_t>(site)) >> 2;
pxAssertRel(imm26 >= -(1 << 25) && imm26 < (1 << 25), "Cold-exit island out of B imm26 range");
// iOS dual-map W^X: store via the RW alias; displacement/flush stay on RX.
*reinterpret_cast<volatile u32*>(armGetWritableCodePtr(site)) = 0x14000000u | (static_cast<u32>(imm26) & 0x03FFFFFFu);
// armPatchCodeWord opens its own W^X scope: the islands live in the hot
// block, whose window armEndBlock() already closed by the time the cold
// session patches them.
armPatchCodeWord(site, 0x14000000u | (static_cast<u32>(imm26) & 0x03FFFFFFu));
HostSys::FlushInstructionCache(site, 4);
}
@@ -1831,10 +1833,11 @@ static void recEmitColdSideExits()
pxAssert(armGetCurrentCodePointer() < SysMemory::GetEERecEnd());
s_coldPtr = armEndBlock();
HostSys::BeginCodeWrite();
// Each island patch opens its own write scope (armPatchCodeWord); an
// arena-wide Begin/EndCodeWrite here would RX-flip a concurrently open
// MTVU emit window in Legacy mode on its way out.
for (int k = 0; k < n; k++)
recPatchIslandB(s_sideExitIslands[k], coldStart[k]);
HostSys::EndCodeWrite();
g_branch = 1;
}
@@ -2675,14 +2678,14 @@ static void recClear(u32 addr, u32 size)
if (blockidx == -1)
return;
// macOS/Apple Silicon (no-op elsewhere): recClear runs from the EE
// page-fault handler (fastmem backpatch + SMC via mmap_ClearCpuBlock) and
// from runtime SMC on the executing CPU thread — both enter with the
// MAP_JIT code cache in execute-protected (W^X) mode. The SetFnptr writes
// and Arm64BaseBlocks::Remove() stub patches below target that region, so
// a write faults (SIGBUS) unless we flip the thread to write mode first.
// Refcounted, so it nests harmlessly when reached from an open emit scope.
HostSys::BeginCodeWrite();
// No write scope here: the only code writes below are Remove()'s entry
// stubs, and those open their own per-site scope (armPatchCodeWord).
// SetFnptr/recLUT targets are ordinary heap data. The arena-wide
// Begin/EndCodeWrite this used to hold was worse than useless on iOS
// Legacy mode — the range-window emit scope bypasses the refcount, so
// when recClear runs mid-compile (the stale-overlap walk in
// recRecompile) the paired EndCodeWrite RX-flipped the open emit window
// and the next emitted instruction faulted.
// Track the EE-address span of all blocks we touch so the post-walk
// tail can reset interior BLOCKs across the *full* extent of the
@@ -2795,8 +2798,6 @@ static void recClear(u32 addr, u32 size)
// into the middle of a freshly-recompiled block.
if (upperextent > lowerextent)
iopClearRecLUT(GETBLOCK(lowerextent), upperextent - lowerextent);
HostSys::EndCodeWrite();
}
static void iopClearRecLUT(BASEBLOCK* base, int count)
+14 -2
View File
@@ -457,6 +457,18 @@ void normJumpCompile(mV, microFlagCycles& mFC, bool isEvilJump)
// constructed once per mVUreset over the whole post-dispatcher cache range.
static thread_local ptrdiff_t s_mVUblockStartOffset = 0;
// The write scope covers exactly the persistent MA's buffer span
// [x86start, physical region end]: every byte a session emits lands in
// there. Range form, not the whole-arena BeginCodeWrite: in iOS Legacy
// mode the whole-arena EndCodeWrite RX-flips the entire arena on close,
// which killed any EE emit window open on the other thread (Legacy range
// windows bypass the refcount). Toggle modes fall through to the same
// refcounted whole-region toggle as before; dual-mapping is a no-op.
static size_t mVUcodeCacheWriteSpan(microVU& mVU)
{
return static_cast<size_t>(mVU.prog.x86end - mVU.prog.x86start) + (mVUcacheSafeZone * _1mb);
}
static void mVUopenCodeCache(microVU& mVU)
{
// Nested call (same thread re-enters before its outer close): no-op.
@@ -465,7 +477,7 @@ static void mVUopenCodeCache(microVU& mVU)
pxAssert(mVU.jitAsm); // mVUreset must have built it.
HostSys::BeginCodeWrite();
HostSys::BeginCodeWriteRange(mVU.prog.x86start, mVUcodeCacheWriteSpan(mVU));
// armAsmPtr MUST equal the MA's buffer base (= mVU.prog.x86start) for
// armGetCurrentCodePointer() to return the real write position:
// armGetCurrentCodePointer() = armAsmPtr + armAsm->GetCursorOffset()
@@ -519,7 +531,7 @@ static void mVUcloseCodeCache(microVU& mVU)
mVUPersist::EndEpisode(mVU, codeStart + codeSize);
armAsm = nullptr; // unbind; do not delete (persistent)
HostSys::EndCodeWrite();
HostSys::EndCodeWriteRange(mVU.prog.x86start, mVUcodeCacheWriteSpan(mVU));
if (codeSize > 0)
{
HostSys::FlushInstructionCache(codeStart, codeSize);