From dac184c89466e3df4e8ccc548fe027034b6b7ae2 Mon Sep 17 00:00:00 2001 From: J1coding Date: Sat, 25 Jul 2026 15:12:33 +0200 Subject: [PATCH] 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. --- pcsx2/arm64/AsmHelpers.cpp | 45 +++++++++++++++++++++++----- pcsx2/arm64/AsmHelpers.h | 5 ++++ pcsx2/arm64/BaseblockEx-arm64.h | 24 +++++++-------- pcsx2/arm64/RecStubs.cpp | 15 +++++----- pcsx2/arm64/iR3000A-arm64.cpp | 20 ++++++------- pcsx2/arm64/iR5900-arm64.cpp | 29 +++++++++--------- pcsx2/arm64/microVU_Branch-arm64.inl | 16 ++++++++-- 7 files changed, 99 insertions(+), 55 deletions(-) diff --git a/pcsx2/arm64/AsmHelpers.cpp b/pcsx2/arm64/AsmHelpers.cpp index f9a0761df8..b7b9032aa1 100644 --- a/pcsx2/arm64/AsmHelpers.cpp +++ b/pcsx2/arm64/AsmHelpers.cpp @@ -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(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(page_size) - 1); + }(); + + const uintptr_t site_page = reinterpret_cast(rx_site) & page_mask; + const uintptr_t first_page = reinterpret_cast(s_arm_block_start) & page_mask; + const uintptr_t last_page = + (reinterpret_cast(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(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(armGetWritableCodePtr(static_cast(code_address))) = - 0x14000000u | (static_cast(imm26) & 0x03FFFFFFu); - HostSys::EndCodeWrite(); + armPatchCodeWord(code_address, 0x14000000u | (static_cast(imm26) & 0x03FFFFFFu)); if (flush_icache) HostSys::FlushInstructionCache(code_address, 4); } diff --git a/pcsx2/arm64/AsmHelpers.h b/pcsx2/arm64/AsmHelpers.h index 3bea809efe..cf4045e096 100644 --- a/pcsx2/arm64/AsmHelpers.h +++ b/pcsx2/arm64/AsmHelpers.h @@ -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. diff --git a/pcsx2/arm64/BaseblockEx-arm64.h b/pcsx2/arm64/BaseblockEx-arm64.h index f2ea88f771..07612e4622 100644 --- a/pcsx2/arm64/BaseblockEx-arm64.h +++ b/pcsx2/arm64/BaseblockEx-arm64.h @@ -64,7 +64,7 @@ #include #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(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(armGetWritableCodePtr(reinterpret_cast(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(site), instr); } // (HostSys::FlushInstructionCache, not the raw builtin: on Darwin the diff --git a/pcsx2/arm64/RecStubs.cpp b/pcsx2/arm64/RecStubs.cpp index c9f993e4a8..dc7631acc3 100644 --- a/pcsx2/arm64/RecStubs.cpp +++ b/pcsx2/arm64/RecStubs.cpp @@ -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(armGetWritableCodePtr(reinterpret_cast(code_address))); - *patch_ptr = 0x14000000u | (static_cast(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(code_address), + 0x14000000u | (static_cast(branch_imm26) & 0x03FFFFFFu)); // Flush icache at the patch point too. HostSys::FlushInstructionCache(reinterpret_cast(code_address), 4); diff --git a/pcsx2/arm64/iR3000A-arm64.cpp b/pcsx2/arm64/iR3000A-arm64.cpp index 63fd6c8cfd..eb82bc21bd 100644 --- a/pcsx2/arm64/iR3000A-arm64.cpp +++ b/pcsx2/arm64/iR3000A-arm64.cpp @@ -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 diff --git a/pcsx2/arm64/iR5900-arm64.cpp b/pcsx2/arm64/iR5900-arm64.cpp index a48f111c4a..81cbaa951c 100644 --- a/pcsx2/arm64/iR5900-arm64.cpp +++ b/pcsx2/arm64/iR5900-arm64.cpp @@ -1793,8 +1793,10 @@ static void recPatchIslandB(u8* site, const u8* target) { const intptr_t imm26 = (reinterpret_cast(target) - reinterpret_cast(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(armGetWritableCodePtr(site)) = 0x14000000u | (static_cast(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(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) diff --git a/pcsx2/arm64/microVU_Branch-arm64.inl b/pcsx2/arm64/microVU_Branch-arm64.inl index 9ddd570cb8..e24c9fd170 100644 --- a/pcsx2/arm64/microVU_Branch-arm64.inl +++ b/pcsx2/arm64/microVU_Branch-arm64.inl @@ -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(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);