From c5e51c3bb9b8b99f066ea566b74c314cf4cdb018 Mon Sep 17 00:00:00 2001 From: Francisco Javier Trujillo Mata Date: Sun, 1 Nov 2026 23:28:09 +0100 Subject: [PATCH 1/5] ps2gdb: Probe EE interrupted application PC without modifying state Add a standalone C VBlank interrupt observer, with no network calls or register manipulation in the handler. The first real-PS2 run recorded twelve interrupts during a CPU-bound uninstrumented loop and observed application instruction addresses in the extended interrupt callback while CP0 EPC belonged to the kernel. Add a stricter busy-loop address-range assertion for the next hardware run, build it in CI without execution, and document the register-context and instruction-replay requirements for true preemptive Ctrl-C. --- .github/workflows/compilation.yml | 14 ++ Makefile | 5 + docs/EE_PREEMPTIVE_CTRL_C_RESEARCH.md | 125 ++++++++++++++++++ tests/preemptive/Makefile | 11 ++ tests/preemptive/ee_interrupt_context_probe.c | 124 +++++++++++++++++ 5 files changed, 279 insertions(+) create mode 100644 docs/EE_PREEMPTIVE_CTRL_C_RESEARCH.md create mode 100644 tests/preemptive/Makefile create mode 100644 tests/preemptive/ee_interrupt_context_probe.c diff --git a/.github/workflows/compilation.yml b/.github/workflows/compilation.yml index 5833f4f..6dabc8c 100644 --- a/.github/workflows/compilation.yml +++ b/.github/workflows/compilation.yml @@ -58,6 +58,20 @@ jobs: mips64r5900el-ps2-elf-nm sample/testgdb.elf | grep -q 'gdb_hw_test_function' sh tests/check_hw_direct_cache_flush.sh sample/testgdb.elf + preemptive-context-probe-build: + name: EE preemptive context observation (compile-only) + runs-on: ubuntu-latest + container: ghcr.io/ps2dev/ps2dev:latest + steps: + - uses: actions/checkout@v4 + - name: Install build requirements + run: apk add build-base git + - name: Build VBlank context probe, never execute it in CI + run: | + make preemptive-probe-build + mips64r5900el-ps2-elf-nm tests/preemptive/ee_interrupt_context_probe.elf | grep -q 'ps2gdb_probe_busy_begin' + mips64r5900el-ps2-elf-nm tests/preemptive/ee_interrupt_context_probe.elf | grep -q 'ps2gdb_probe_busy_end' + cooperative-ctrlc-build: name: EE cooperative Ctrl-C compile-only runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index e73126e..306e26a 100644 --- a/Makefile +++ b/Makefile @@ -21,6 +21,11 @@ check: tests/r5900_rsp_test tests/registers_test tests/mock_rsp_server tests/rsp hardware-probe-build: $(MAKE) -C tests/hardware clean all +# Non-mutating VBlank interrupt-context research; compile only in CI. +# The binary itself runs exclusively by explicit ps2client command. +preemptive-probe-build: + $(MAKE) -C tests/preemptive clean all + # Diagnostic macros compile only; the script restores a non-triggering ELF. hardware-probe-variants-build: sh tests/hardware/check_variants.sh diff --git a/docs/EE_PREEMPTIVE_CTRL_C_RESEARCH.md b/docs/EE_PREEMPTIVE_CTRL_C_RESEARCH.md new file mode 100644 index 0000000..f8f9203 --- /dev/null +++ b/docs/EE_PREEMPTIVE_CTRL_C_RESEARCH.md @@ -0,0 +1,125 @@ + +# Preemptive EE Ctrl-C — initial interrupt context investigation + +## Scope + +PR #12 integrated cooperative Ctrl-C: the EE application calls +`gdbstub_poll_interrupt()` on its own thread. The next goal is to stop an +**uninstrumented** EE application without any calls in the application loop. +This requires a separate transport/command receiver and a safe mechanism to +capture or reconstruct the interrupted application's register state. + +The initial test below **only observes interrupts**. It never changes a saved +register, instruction, exception vector, or GDB connection. + +## Verified in physical hardware + +A standalone `AddIntcHandler2(INTC_VBLANK_S, ...)` callback was registered +on the real PS2 at `192.168.31.10`. The EE executed a bounded, CPU-bound +loop without a syscall, debugger poll, or scheduler yield inside it. +The first hardware test produced: + +```text +PS2GDB_PREEMPT_PROBE_BEGIN thread=50 +PS2GDB_PREEMPT_PROBE_IRQ hits=12 iterations=4962944 + arg_first=001216c0 arg_last=001215c4 + cp0_epc_first=00081fe0 cp0_epc_last=00081fe0 +PS2GDB_PREEMPT_PROBE_DONE status=0 +``` + +PS2Link remained responsive immediately afterward. Disassembly shows that +`0x001215c4` is an instruction in the application's `main` busy loop, while +`0x00081fe0` is in kernel address space. Thus the callback's **third +argument carries a useful interrupted application address**, whereas CP0 +EPC sampled *from inside the callback* cannot be used as the application's +PC. We still cannot assert that it contains the entire exception context. + +A second, tighter probe was executed successfully on physical hardware on +2026-11-02 after confirming PS2Link connectivity. It places externally +visible assembly labels around the no-yield busy loop and checks that the +observed interrupt addresses fall inside those markers: + +```text +PS2GDB_PREEMPT_PROBE_IRQ hits=12 iterations=4709310 arg_first=001217ec arg_last=0010113c cp0_epc_first=00081fe0 cp0_epc_last=00081fe0 +PS2GDB_PREEMPT_PROBE_APPPC busy_hits=11 busy_first=0010113c busy_last=0010113c busy_min=00101138 busy_max=0010115c busy_begin=00101114 busy_end=00101168 +PS2GDB_PREEMPT_PROBE_DONE status=0 +``` + +The first callback arrived before the busy-loop guard was set, hence +`arg_first` is outside the loop. The 11 callbacks counted *during* the loop +all reported aligned addresses within the labeled range. R5900 disassembly +of the actual ELF confirms `0x00101138` is `lw`, `0x0010113c` is `addiu`, +and `0x0010115c` is `bnez`, all inside the loop. These measurements support +using the callback argument as an interrupted PC observation; they do not +establish whether it is a delay-slot-adjusted EPC or expose any other saved +registers. No interrupt handler wrote kernel state or performed network I/O. + +The existing `ee/gdb-low.S` normal-exception path saves 128-bit GPRs with +`sq`, plus special and FPU state, into its own debugger frame. It then +changes EPC and performs an `eret` into its debugger handoff. That frame +capture occurs on the debugger's exception path, **not** in the ordinary +PS2SDK VBlank callback. Reusing its frame layout does not by itself make +VBlank context capture safe. The next isolated test must establish ownership +of the interrupted thread, exception-entry register preservation and +branch-delay semantics before any interruption or restoration is attempted. + +## Next read-only CP0 diagnostic + +The probe now also records CP0 Cause and Status on its first and last VBlank +callbacks. Like the prior EPC sample, these are **handler-time values** and +must not be presented as a saved application exception frame. Captures are +plain `mfc0` reads; printing occurs after the busy loop, not from the IRQ. + +This extension cross-compiled on 2026-11-02, but the subsequent connectivity +check reported `ps2link at 192.168.31.10 is not answering`, so the extended +diagnostic has **not been hardware-validated**. No reset or ELF launch was +attempted after the failed ping. The existing verified busy-loop result above +remains from the previous probe version. + +PS2SDK declares `AddIntcHandler2` as a kernel syscall wrapper with the +third callback parameter named `addr`. Its public header does not define +whether that parameter is a raw EPC, a branch-delay-adjusted address, or a +pointer to other saved registers. The implementation of the EE kernel +interrupt dispatcher, rather than the public syscall wrapper, must establish +those semantics before register-frame access is attempted. + +## Why this is not yet preemptive GDB + +The PS2SDK `ee_thread_status_t` returned by `ReferThreadStatus()` contains +thread metadata (stack, priority, status) but **no saved GPR/PC register +frame**. The `AddIntcHandler2()` callback provides an address-like third +argument, not a publicly documented full register snapshot. In particular, +we must not treat its address as a pointer to a mutable kernel register frame. + +Before extending PS2GDB's asynchronous mode, validation must establish: + +1. Which EE thread an asynchronous interrupt interrupted. Other network and + system threads can be scheduled at the same time. +2. How to capture the full 128-bit GPRs, HI/LO, SA, CP0 EPC/Cause and + relevant floating-point context *without overwriting kernel-owned state*. +3. How to distinguish branch instructions and delay-slot exceptions, so + interrupted instructions execute exactly once after `continue`. +4. How a network reader requests interruption without performing TCP/RPC + from an interrupt handler or causing a recursive debugger stop. +5. How to restore the original instruction and all exception handlers on + stop, detach, disconnect, error and debugger shutdown. + +Do not install an unconditional address-breakpoint comparator or patch a +running instruction based only on an unvalidated callback address. The +previous hardware-debug work already established that a debugger's own +cache-flush wrapper can recurse if it gets temporarily patched. + +## Build and manually execute + +```sh +make preemptive-probe-build +cd tests/preemptive +ps2client -h 192.168.31.10 -t 3 ping +# When a prior EE test is suspended, host-side `ps2client reset` is allowed. +# NEVER reset the IOP and NEVER call Exit() from the probe. +ps2client -h 192.168.31.10 -t 18 -e PS2GDB_PREEMPT_PROBE_DONE \ + execee host:ee_interrupt_context_probe.elf +``` + +The probe ends in `SleepThread()`, preserving PS2Link. The CI build +**cross-compiles** the ELF only; it never triggers an EE interrupt. diff --git a/tests/preemptive/Makefile b/tests/preemptive/Makefile new file mode 100644 index 0000000..808802a --- /dev/null +++ b/tests/preemptive/Makefile @@ -0,0 +1,11 @@ +EE_BIN = ee_interrupt_context_probe.elf +EE_OBJS = ee_interrupt_context_probe.o +EE_CFLAGS += -g -Wall + +all: $(EE_BIN) + +clean: + rm -f $(EE_BIN) $(EE_OBJS) + +include $(PS2SDK)/samples/Makefile.pref +include $(PS2SDK)/samples/Makefile.eeglobal diff --git a/tests/preemptive/ee_interrupt_context_probe.c b/tests/preemptive/ee_interrupt_context_probe.c new file mode 100644 index 0000000..1c6e1ff --- /dev/null +++ b/tests/preemptive/ee_interrupt_context_probe.c @@ -0,0 +1,124 @@ +/* + * Isolated, non-mutating EE interrupt-context investigation. + * Never interpret/dereference the opaque third callback argument. + * No network, file I/O, or thread operations from the interrupt handler. + * This is NOT a GDB stop implementation. + */ +#include +#include + +static volatile unsigned int irq_hits; +static volatile unsigned int first_callback_addr; +static volatile unsigned int last_callback_addr; +static volatile unsigned int first_handler_epc; +static volatile unsigned int last_handler_epc; +static volatile unsigned int first_handler_cause; +static volatile unsigned int last_handler_cause; +static volatile unsigned int first_handler_status; +static volatile unsigned int last_handler_status; +static volatile unsigned int busy_iterations; +static volatile unsigned int busy_active; +static volatile unsigned int busy_irq_hits; +static volatile unsigned int busy_irq_first_pc; +static volatile unsigned int busy_irq_last_pc; +static volatile unsigned int busy_irq_min_pc = 0xffffffffu; +static volatile unsigned int busy_irq_max_pc; + +extern unsigned char ps2gdb_probe_busy_begin[]; +extern unsigned char ps2gdb_probe_busy_end[]; + +__attribute__((noinline)) static void run_unyielding_busy_loop(void) +{ + busy_active = 1; + __asm__ volatile(".globl ps2gdb_probe_busy_begin\nps2gdb_probe_busy_begin:" ::: "memory"); + while (irq_hits < 12 && busy_iterations < 400000000u) + busy_iterations++; + /* Clear the observation guard before the return/epilogue so an IRQ + * at the return instruction is not falsely classified as busy-loop PC. */ + busy_active = 0; + __asm__ volatile(".globl ps2gdb_probe_busy_end\nps2gdb_probe_busy_end:\n nop" ::: "memory"); +} + +static int observe_vblank(int cause, void *user, void *addr) +{ + unsigned int epc, cp0_cause, cp0_status; + (void)cause; + (void)user; + __asm__ volatile("mfc0 %0, $14" : "=r"(epc)); + __asm__ volatile("mfc0 %0, $13" : "=r"(cp0_cause)); + __asm__ volatile("mfc0 %0, $12" : "=r"(cp0_status)); + if (irq_hits == 0) { + first_callback_addr = (unsigned int)addr; + first_handler_epc = epc; + first_handler_cause = cp0_cause; + first_handler_status = cp0_status; + } + last_callback_addr = (unsigned int)addr; + last_handler_epc = epc; + last_handler_cause = cp0_cause; + last_handler_status = cp0_status; + if (busy_active) { + unsigned int interrupted_pc = (unsigned int)addr; + if (busy_irq_hits == 0) + busy_irq_first_pc = interrupted_pc; + busy_irq_last_pc = interrupted_pc; + if (interrupted_pc < busy_irq_min_pc) + busy_irq_min_pc = interrupted_pc; + if (interrupted_pc > busy_irq_max_pc) + busy_irq_max_pc = interrupted_pc; + ++busy_irq_hits; + } + ++irq_hits; + ExitHandler(); + return 0; +} + +int main(void) +{ + int handler_id, old_intr; + int ok; + unsigned int hits; + printf("PS2GDB_PREEMPT_PROBE_BEGIN thread=%d\n", GetThreadId()); + fflush(stdout); + + old_intr = DIntr(); + handler_id = AddIntcHandler2(INTC_VBLANK_S, observe_vblank, -1, NULL); + if (handler_id >= 0) + EnableIntc(INTC_VBLANK_S); + if (old_intr) EIntr(); + if (handler_id < 0) { + printf("PS2GDB_PREEMPT_PROBE_DONE status=1 add_handler=%d\n", handler_id); + fflush(stdout); + SleepThread(); + for (;;) SleepThread(); + } + + /* This loop does not poll any debugger flag or yield. It only observes + * the IRQ counter once per iteration. INTC interrupts run asynchronously. */ + run_unyielding_busy_loop(); + + old_intr = DIntr(); + RemoveIntcHandler(INTC_VBLANK_S, handler_id); + if (old_intr) EIntr(); + hits = irq_hits; + ok = hits >= 12 && first_callback_addr != 0 && + busy_irq_hits >= 8 && + busy_irq_min_pc >= (unsigned int)ps2gdb_probe_busy_begin && + busy_irq_max_pc < (unsigned int)ps2gdb_probe_busy_end; + printf("PS2GDB_PREEMPT_PROBE_IRQ hits=%u iterations=%u arg_first=%08x arg_last=%08x cp0_epc_first=%08x cp0_epc_last=%08x\n", + hits, busy_iterations, first_callback_addr, last_callback_addr, + first_handler_epc, last_handler_epc); + printf("PS2GDB_PREEMPT_PROBE_APPPC busy_hits=%u busy_first=%08x busy_last=%08x busy_min=%08x busy_max=%08x busy_begin=%08x busy_end=%08x\n", + busy_irq_hits, busy_irq_first_pc, busy_irq_last_pc, + busy_irq_min_pc, busy_irq_max_pc, + (unsigned int)ps2gdb_probe_busy_begin, + (unsigned int)ps2gdb_probe_busy_end); + printf("PS2GDB_PREEMPT_PROBE_CP0 cause_first=%08x cause_last=%08x status_first=%08x status_last=%08x\n", + first_handler_cause, last_handler_cause, + first_handler_status, last_handler_status); + printf("PS2GDB_PREEMPT_PROBE_DONE status=%d\n", ok ? 0 : 2); + fflush(stdout); + /* Leave PS2Link running; recovery is via ps2client reset (host EE only). */ + SleepThread(); + for (;;) SleepThread(); +} From 6c0cb91b893f57b79dd76b0d8263e86ad259b269 Mon Sep 17 00:00:00 2001 From: Francisco Javier Trujillo Mata Date: Mon, 2 Nov 2026 13:01:35 +0100 Subject: [PATCH 2/5] test: Add isolated EE branch-delay interrupt observation --- .github/workflows/compilation.yml | 5 +- docs/EE_PREEMPTIVE_CTRL_C_RESEARCH.md | 153 ++++++++++++++++-- tests/preemptive/Makefile | 9 +- tests/preemptive/ee_branch_delay_probe.c | 144 +++++++++++++++++ tests/preemptive/ee_interrupt_context_probe.c | 5 +- 5 files changed, 301 insertions(+), 15 deletions(-) create mode 100644 tests/preemptive/ee_branch_delay_probe.c diff --git a/.github/workflows/compilation.yml b/.github/workflows/compilation.yml index 6dabc8c..02b9584 100644 --- a/.github/workflows/compilation.yml +++ b/.github/workflows/compilation.yml @@ -66,11 +66,14 @@ jobs: - uses: actions/checkout@v4 - name: Install build requirements run: apk add build-base git - - name: Build VBlank context probe, never execute it in CI + - name: Build VBlank context and branch-delay probes, never execute in CI run: | make preemptive-probe-build mips64r5900el-ps2-elf-nm tests/preemptive/ee_interrupt_context_probe.elf | grep -q 'ps2gdb_probe_busy_begin' mips64r5900el-ps2-elf-nm tests/preemptive/ee_interrupt_context_probe.elf | grep -q 'ps2gdb_probe_busy_end' + mips64r5900el-ps2-elf-nm tests/preemptive/ee_branch_delay_probe.elf | grep -q 'ps2gdb_branch_loop_dec' + mips64r5900el-ps2-elf-nm tests/preemptive/ee_branch_delay_probe.elf | grep -q 'ps2gdb_branch_loop_branch' + mips64r5900el-ps2-elf-nm tests/preemptive/ee_branch_delay_probe.elf | grep -q 'ps2gdb_branch_loop_delay' cooperative-ctrlc-build: name: EE cooperative Ctrl-C compile-only diff --git a/docs/EE_PREEMPTIVE_CTRL_C_RESEARCH.md b/docs/EE_PREEMPTIVE_CTRL_C_RESEARCH.md index f8f9203..e2e5eb9 100644 --- a/docs/EE_PREEMPTIVE_CTRL_C_RESEARCH.md +++ b/docs/EE_PREEMPTIVE_CTRL_C_RESEARCH.md @@ -39,11 +39,13 @@ A second, tighter probe was executed successfully on physical hardware on visible assembly labels around the no-yield busy loop and checks that the observed interrupt addresses fall inside those markers: + ```text PS2GDB_PREEMPT_PROBE_IRQ hits=12 iterations=4709310 arg_first=001217ec arg_last=0010113c cp0_epc_first=00081fe0 cp0_epc_last=00081fe0 PS2GDB_PREEMPT_PROBE_APPPC busy_hits=11 busy_first=0010113c busy_last=0010113c busy_min=00101138 busy_max=0010115c busy_begin=00101114 busy_end=00101168 PS2GDB_PREEMPT_PROBE_DONE status=0 ``` + The first callback arrived before the busy-loop guard was set, hence `arg_first` is outside the loop. The 11 callbacks counted *during* the loop @@ -54,14 +56,18 @@ using the callback argument as an interrupted PC observation; they do not establish whether it is a delay-slot-adjusted EPC or expose any other saved registers. No interrupt handler wrote kernel state or performed network I/O. -The existing `ee/gdb-low.S` normal-exception path saves 128-bit GPRs with -`sq`, plus special and FPU state, into its own debugger frame. It then +The existing `ee/gdb-low.S` normal-exception path saves most 128-bit GPRs +with `sq`, plus special and FPU state, into its own debugger frame. It then changes EPC and performs an `eret` into its debugger handoff. That frame capture occurs on the debugger's exception path, **not** in the ordinary PS2SDK VBlank callback. Reusing its frame layout does not by itself make VBlank context capture safe. The next isolated test must establish ownership of the interrupted thread, exception-entry register preservation and branch-delay semantics before any interruption or restoration is attempted. +In particular, `trap_low` does not explicitly save or restore the +`GDB_FR_REG26`/`GDB_FR_REG27` (k0/k1) slots; comments assume they are +already saved. We have not validated that assumption against the EE kernel's +vector-entry contract, so this is **not yet a proven complete register frame**. ## Next read-only CP0 diagnostic @@ -70,11 +76,25 @@ callbacks. Like the prior EPC sample, these are **handler-time values** and must not be presented as a saved application exception frame. Captures are plain `mfc0` reads; printing occurs after the busy loop, not from the IRQ. -This extension cross-compiled on 2026-11-02, but the subsequent connectivity -check reported `ps2link at 192.168.31.10 is not answering`, so the extended -diagnostic has **not been hardware-validated**. No reset or ELF launch was -attempted after the failed ping. The existing verified busy-loop result above -remains from the previous probe version. +The extension ran on physical hardware on 2026-11-02 after a successful +PS2Link ping. The result was: + + +```text +PS2GDB_PREEMPT_PROBE_BEGIN thread=16 +PS2GDB_PREEMPT_PROBE_IRQ hits=12 iterations=4973473 arg_first=00121848 arg_last=00101140 cp0_epc_first=00081fe0 cp0_epc_last=00081fe0 +PS2GDB_PREEMPT_PROBE_APPPC busy_hits=11 busy_first=00101148 busy_last=00101140 busy_min=00101140 busy_max=0010115c busy_begin=00101114 busy_end=00101168 +PS2GDB_PREEMPT_PROBE_CP0 cause_first=70008000 cause_last=70008000 status_first=70030c10 status_last=70030c10 +PS2GDB_PREEMPT_PROBE_DONE status=0 +``` + + +The callback-side Cause value has BD clear (bit 31) and BD2 set (bit 30), +but no conclusion about the interrupted application's delay-slot state may +be drawn from that sample. Both Cause and Status remained unchanged between +the first and last callbacks even though the callback PCs differed. The +post-run PS2Link ping **failed**; the program's success marker does not +prove that the console remained reachable. No IOP reset was attempted. PS2SDK declares `AddIntcHandler2` as a kernel syscall wrapper with the third callback parameter named `addr`. Its public header does not define @@ -83,6 +103,114 @@ pointer to other saved registers. The implementation of the EE kernel interrupt dispatcher, rather than the public syscall wrapper, must establish those semantics before register-frame access is attempted. +## Isolated branch-delay observation + +`tests/preemptive/ee_branch_delay_probe.c` is a separate, read-only ELF. It +executes a fixed-count three-instruction loop with an explicit `bnez` and a +non-NOP `addiu` in the delay slot. The delay-slot operation counts executed +iterations, so the final count can be checked against the initial bound. An +`AddIntcHandler2` VBlank callback only counts addresses matching the labeled +decrement, branch and delay-slot instructions (plus other/out-of-range PCs). +The callback never performs a syscall, prints, patches code, or modifies a +register frame. The ELF returns to `SleepThread()` after printing results. + +The cross-compiled R5900 disassembly was checked to verify the adjacent +instructions and labels: + +```text +00101114 : addiu v0,v0,-1 +00101118 : bnez v0,0x00101114 +0010111c : addiu v1,v1,1 +00101120 : jr ra +``` + +This probe **passed on physical PS2 on 2026-11-02** after a successful +PS2Link ping. Its exact captured output was: + + +```text +PS2GDB_BRANCH_PROBE_BEGIN thread=16 iterations=120000000 +PS2GDB_BRANCH_PROBE_IRQ hits=42 loop_hits=41 in_range=41 outside=0 first=00101114 last=00101118 +PS2GDB_BRANCH_PROBE_PC dec=00101114 branch=00101118 delay=0010111c begin=00101110 end=00101120 dec_hits=19 branch_hits=22 delay_hits=0 other_hits=0 +PS2GDB_BRANCH_PROBE_DONE status=0 slots=120000000 +``` + + +All 41 callback addresses counted during the loop fell within its labels; +19 matched the decrement instruction, 22 matched the branch and none matched +the delay-slot instruction. The increment in the delay slot ran exactly +120,000,000 times. The test establishes that the **stock EE interrupt return** +preserved this loop's behavior; it does not establish complete context +capture or correct debugger-controlled instruction replay. In particular, +an EPC pointing to the branch may represent a branch-delay exception, and +the callback never records the *entry-time* BD bit. + +The original post-run `ps2client ping` **failed**, as it did after the +extended CP0 probe. A later hardware experiment isolated an omitted VBlank +mask cleanup, documented below. The test's `DONE status=0` marker is not a +recovery test; post-run ping must be checked independently. + +CI still only compiles the probe and checks ELF symbols; it never executes +the interrupt test on a runner. + +## VBlank teardown and PS2Link lifecycle (verified) + +Both diagnostics originally called `RemoveIntcHandler()` without disabling +`INTC_VBLANK_S`. PS2SDK's `graph_remove_vsync_handler()` demonstrates the +required cleanup order: `DisableIntc(INTC_VBLANK_S)` before +`RemoveIntcHandler(INTC_VBLANK_S, id)` while interrupts are suspended. The +tests now follow the same ordering. An enabled interrupt source with no +registered handler is a plausible cause of the former EE-side stall, though +the dispatcher internals have not been independently inspected. + +On a physical PS2 at `192.168.31.10`, the updated branch-delay test returned: + + +```text +PS2GDB_BRANCH_PROBE_IRQ hits=42 loop_hits=41 in_range=41 outside=0 first=00101114 last=00101114 +PS2GDB_BRANCH_PROBE_PC dec=00101114 branch=00101118 delay=0010111c begin=00101110 end=00101120 dec_hits=21 branch_hits=20 delay_hits=0 other_hits=0 +PS2GDB_BRANCH_PROBE_DONE status=0 slots=120000000 +``` + + +Before this corrected test, PS2Link ping succeeded in 37 ms; after the test +it **also succeeded** in 47 ms, unlike earlier runs. A subsequent ping again +succeeded in 25 ms. This validates the source-mask cleanup under the tested +conditions without any IOP reset, console reboot or GDB-stub changes. + +However, a subsequent attempt to `execee` the other diagnostic timed out +without an ELF launch. PS2Link still answered ping in 61 ms afterward. The +PS2Link EE loader keeps a nonzero `userThreadID` and refuses a second launch +while that slot is occupied. Our user thread intentionally ends in +`SleepThread()`, so the two outcomes are **distinct**: connectivity can be +healthy while new ELF launches remain blocked. Do not interpret a second +`execee` timeout as another lost PS2Link connection. + +The PS2Link source's `pkoReset()` calls `SifIopReset(NULL, 0)`; therefore +`ps2client reset` **does reset the IOP** in this implementation and is not +permitted under the current hardware-safety requirements. Run one standalone +probe per externally restarted PS2Link session, or develop a separately +validated EE-only user-thread release mechanism before attempting repeated +launches. The probe does not return to the PS2 OSD. + +To run it manually after checking PS2Link: + +```sh +make preemptive-probe-build +cd tests/preemptive +ps2client -h 192.168.31.10 -t 3 ping +ps2client -h 192.168.31.10 -t 18 -e PS2GDB_BRANCH_PROBE_DONE \ + execee host:ee_branch_delay_probe.elf +``` + +Even successful loop accounting would demonstrate only that the *kernel's +existing interrupt return* preserved the loop, not that a custom debugger +could save and restore all state. A third callback address at the branch +instruction cannot by itself distinguish a branch interruption from an +exception in its delay slot, since architectural EPC may refer back to the +branch. Full-context capture therefore still requires a validated exception +entry with BD, EPC, GPR, HI/LO, SA, FPU and thread ownership preserved. + ## Why this is not yet preemptive GDB The PS2SDK `ee_thread_status_t` returned by `ReferThreadStatus()` contains @@ -115,11 +243,14 @@ cache-flush wrapper can recurse if it gets temporarily patched. make preemptive-probe-build cd tests/preemptive ps2client -h 192.168.31.10 -t 3 ping -# When a prior EE test is suspended, host-side `ps2client reset` is allowed. -# NEVER reset the IOP and NEVER call Exit() from the probe. +# NEVER run `ps2client reset` here: PS2Link's handler resets the IOP. +# Never return to the PS2 OSD; this probe leaves its user thread asleep. ps2client -h 192.168.31.10 -t 18 -e PS2GDB_PREEMPT_PROBE_DONE \ execee host:ee_interrupt_context_probe.elf ``` -The probe ends in `SleepThread()`, preserving PS2Link. The CI build -**cross-compiles** the ELF only; it never triggers an EE interrupt. +The probe ends in `SleepThread()` rather than returning to the PS2 OSD. +This preserves the PS2Link environment but leaves its user-thread slot +occupied. Check connectivity with ping separately; a second `execee` +requires a new safe launch session. CI **cross-compiles** the ELF only; +it never triggers an EE interrupt. diff --git a/tests/preemptive/Makefile b/tests/preemptive/Makefile index 808802a..2d2a888 100644 --- a/tests/preemptive/Makefile +++ b/tests/preemptive/Makefile @@ -1,11 +1,16 @@ EE_BIN = ee_interrupt_context_probe.elf EE_OBJS = ee_interrupt_context_probe.o +BRANCH_BIN = ee_branch_delay_probe.elf +BRANCH_OBJS = ee_branch_delay_probe.o EE_CFLAGS += -g -Wall -all: $(EE_BIN) +all: $(EE_BIN) $(BRANCH_BIN) clean: - rm -f $(EE_BIN) $(EE_OBJS) + rm -f $(EE_BIN) $(EE_OBJS) $(BRANCH_BIN) $(BRANCH_OBJS) include $(PS2SDK)/samples/Makefile.pref include $(PS2SDK)/samples/Makefile.eeglobal + +$(BRANCH_BIN): $(BRANCH_OBJS) + $(EE_CC) -T$(EE_LINKFILE) $(EE_OPTFLAGS) -o $@ $^ $(EE_LDFLAGS) $(EXTRA_LDFLAGS) $(EE_LIBS) diff --git a/tests/preemptive/ee_branch_delay_probe.c b/tests/preemptive/ee_branch_delay_probe.c new file mode 100644 index 0000000..530fcb7 --- /dev/null +++ b/tests/preemptive/ee_branch_delay_probe.c @@ -0,0 +1,144 @@ +/* + * Read-only VBlank observation around a fixed R5900 branch/delay-slot loop. + * This does not install an exception vector or stop an application thread. + */ +#include +#include + +#define BRANCH_ITERATIONS 120000000u + +static volatile unsigned int irq_hits; +static volatile unsigned int loop_active; +static volatile unsigned int loop_hits; +static volatile unsigned int in_range_hits; +static volatile unsigned int outside_hits; +static volatile unsigned int decrement_hits; +static volatile unsigned int branch_hits; +static volatile unsigned int delay_hits; +static volatile unsigned int other_hits; +static volatile unsigned int first_pc; +static volatile unsigned int last_pc; + +extern unsigned char ps2gdb_branch_loop_begin[]; +extern unsigned char ps2gdb_branch_loop_dec[]; +extern unsigned char ps2gdb_branch_loop_branch[]; +extern unsigned char ps2gdb_branch_loop_delay[]; +extern unsigned char ps2gdb_branch_loop_end[]; + +__attribute__((noinline)) static unsigned int run_branch_delay_loop(void) +{ + unsigned int remaining = BRANCH_ITERATIONS; + unsigned int delay_slots; + + /* The delay slot increments exactly once for each loop iteration. + * GCC allocates distinct registers; the branch and its slot stay adjacent. */ + __asm__ volatile( + ".set push\n" + ".set noreorder\n" + ".globl ps2gdb_branch_loop_begin\n" + "ps2gdb_branch_loop_begin:\n" + "move %1, $zero\n" + ".globl ps2gdb_branch_loop_dec\n" + "ps2gdb_branch_loop_dec:\n" + "addiu %0, %0, -1\n" + ".globl ps2gdb_branch_loop_branch\n" + "ps2gdb_branch_loop_branch:\n" + "bnez %0, ps2gdb_branch_loop_dec\n" + ".globl ps2gdb_branch_loop_delay\n" + "ps2gdb_branch_loop_delay:\n" + "addiu %1, %1, 1\n" + ".globl ps2gdb_branch_loop_end\n" + "ps2gdb_branch_loop_end:\n" + ".set pop\n" + : "+&r"(remaining), "=&r"(delay_slots) + : + : "memory"); + + return remaining + delay_slots; +} + +static int observe_vblank(int cause, void *user, void *addr) +{ + unsigned int pc = (unsigned int)addr; + (void)cause; + (void)user; + + if (loop_active) { + if (loop_hits == 0) + first_pc = pc; + last_pc = pc; + ++loop_hits; + + if (pc >= (unsigned int)ps2gdb_branch_loop_begin && + pc < (unsigned int)ps2gdb_branch_loop_end) { + ++in_range_hits; + if (pc == (unsigned int)ps2gdb_branch_loop_dec) + ++decrement_hits; + else if (pc == (unsigned int)ps2gdb_branch_loop_branch) + ++branch_hits; + else if (pc == (unsigned int)ps2gdb_branch_loop_delay) + ++delay_hits; + else + ++other_hits; + } else { + ++outside_hits; + } + } + + ++irq_hits; + ExitHandler(); + return 0; +} + +int main(void) +{ + int handler_id, old_intr, ok; + unsigned int slots; + + printf("PS2GDB_BRANCH_PROBE_BEGIN thread=%d iterations=%u\n", + GetThreadId(), BRANCH_ITERATIONS); + fflush(stdout); + + old_intr = DIntr(); + handler_id = AddIntcHandler2(INTC_VBLANK_S, observe_vblank, -1, NULL); + if (handler_id >= 0) + EnableIntc(INTC_VBLANK_S); + if (old_intr) + EIntr(); + + if (handler_id < 0) { + printf("PS2GDB_BRANCH_PROBE_DONE status=1 add_handler=%d\n", handler_id); + fflush(stdout); + SleepThread(); + for (;;) + SleepThread(); + } + + loop_active = 1; + slots = run_branch_delay_loop(); + loop_active = 0; + + old_intr = DIntr(); + /* Mask the source before unregistering its handler. Otherwise a later + * VBlank could reach the kernel with no callback installed. */ + DisableIntc(INTC_VBLANK_S); + RemoveIntcHandler(INTC_VBLANK_S, handler_id); + if (old_intr) + EIntr(); + + ok = slots == BRANCH_ITERATIONS && in_range_hits >= 8; + printf("PS2GDB_BRANCH_PROBE_IRQ hits=%u loop_hits=%u in_range=%u outside=%u first=%08x last=%08x\n", + irq_hits, loop_hits, in_range_hits, outside_hits, first_pc, last_pc); + printf("PS2GDB_BRANCH_PROBE_PC dec=%08x branch=%08x delay=%08x begin=%08x end=%08x dec_hits=%u branch_hits=%u delay_hits=%u other_hits=%u\n", + (unsigned int)ps2gdb_branch_loop_dec, + (unsigned int)ps2gdb_branch_loop_branch, + (unsigned int)ps2gdb_branch_loop_delay, + (unsigned int)ps2gdb_branch_loop_begin, + (unsigned int)ps2gdb_branch_loop_end, + decrement_hits, branch_hits, delay_hits, other_hits); + printf("PS2GDB_BRANCH_PROBE_DONE status=%d slots=%u\n", ok ? 0 : 2, slots); + fflush(stdout); + SleepThread(); + for (;;) + SleepThread(); +} diff --git a/tests/preemptive/ee_interrupt_context_probe.c b/tests/preemptive/ee_interrupt_context_probe.c index 1c6e1ff..cb7e043 100644 --- a/tests/preemptive/ee_interrupt_context_probe.c +++ b/tests/preemptive/ee_interrupt_context_probe.c @@ -98,6 +98,8 @@ int main(void) run_unyielding_busy_loop(); old_intr = DIntr(); + /* Match PS2SDK's VBlank teardown: mask before removing the callback. */ + DisableIntc(INTC_VBLANK_S); RemoveIntcHandler(INTC_VBLANK_S, handler_id); if (old_intr) EIntr(); hits = irq_hits; @@ -118,7 +120,8 @@ int main(void) first_handler_status, last_handler_status); printf("PS2GDB_PREEMPT_PROBE_DONE status=%d\n", ok ? 0 : 2); fflush(stdout); - /* Leave PS2Link running; recovery is via ps2client reset (host EE only). */ + /* Do not return to the OSD. The PS2Link launcher retains this sleeping + * user thread, so another execee requires a fresh launcher session. */ SleepThread(); for (;;) SleepThread(); } From ceec8ad258f5f969f856b36a3d909cbd7533b324 Mon Sep 17 00:00:00 2001 From: Francisco Javier Trujillo Mata Date: Mon, 2 Nov 2026 14:13:08 +0100 Subject: [PATCH 3/5] test: Model incomplete EE preemptive register context --- .github/workflows/compilation.yml | 6 ++ Makefile | 8 +- docs/EE_PREEMPTIVE_CTRL_C_RESEARCH.md | 41 +++++++++ tests/ee_context_candidate_test.c | 114 ++++++++++++++++++++++++ tests/preemptive/Makefile | 5 +- tests/preemptive/ee_context_candidate.S | 63 +++++++++++++ tests/preemptive/ee_context_candidate.h | 107 ++++++++++++++++++++++ 7 files changed, 340 insertions(+), 4 deletions(-) create mode 100644 tests/ee_context_candidate_test.c create mode 100644 tests/preemptive/ee_context_candidate.S create mode 100644 tests/preemptive/ee_context_candidate.h diff --git a/.github/workflows/compilation.yml b/.github/workflows/compilation.yml index 02b9584..56e9925 100644 --- a/.github/workflows/compilation.yml +++ b/.github/workflows/compilation.yml @@ -74,6 +74,12 @@ jobs: mips64r5900el-ps2-elf-nm tests/preemptive/ee_branch_delay_probe.elf | grep -q 'ps2gdb_branch_loop_dec' mips64r5900el-ps2-elf-nm tests/preemptive/ee_branch_delay_probe.elf | grep -q 'ps2gdb_branch_loop_branch' mips64r5900el-ps2-elf-nm tests/preemptive/ee_branch_delay_probe.elf | grep -q 'ps2gdb_branch_loop_delay' + mips64r5900el-ps2-elf-nm tests/preemptive/ee_context_candidate.o | grep -q 'ee_context_candidate_only' + mips64r5900el-ps2-elf-objdump -d tests/preemptive/ee_context_candidate.o > /tmp/ee_context_candidate.dis + test "$(grep -c -E '[[:space:]]sq[[:space:]].*\(k0\)' /tmp/ee_context_candidate.dis)" -eq 32 + grep -q 'sq[[:space:]]*ra,496(k0)' /tmp/ee_context_candidate.dis + ! mips64r5900el-ps2-elf-nm tests/preemptive/ee_interrupt_context_probe.elf | grep -q 'ee_context_candidate_only' + ! mips64r5900el-ps2-elf-nm tests/preemptive/ee_branch_delay_probe.elf | grep -q 'ee_context_candidate_only' cooperative-ctrlc-build: name: EE cooperative Ctrl-C compile-only diff --git a/Makefile b/Makefile index 306e26a..9f5b2cb 100644 --- a/Makefile +++ b/Makefile @@ -11,9 +11,10 @@ all: $(MAKE) -C ee $(MAKE) -C sample -check: tests/r5900_rsp_test tests/registers_test tests/mock_rsp_server tests/rsp_protocol_test tests/hw_breakpoint_state_test tests/ctrlc_rsp_client +check: tests/r5900_rsp_test tests/registers_test tests/ee_context_candidate_test tests/mock_rsp_server tests/rsp_protocol_test tests/hw_breakpoint_state_test tests/ctrlc_rsp_client ./tests/r5900_rsp_test ./tests/registers_test + ./tests/ee_context_candidate_test ./tests/hw_breakpoint_state_test sh tests/check_protocol.sh @@ -50,6 +51,9 @@ tests/r5900_rsp_test: tests/r5900_rsp_test.c ee/r5900-rsp.h tests/registers_test: tests/registers_test.c ee/r5900-registers.h ee/r5900-rsp.h $(HOST_CC) $(HOST_CFLAGS) $< -o $@ +tests/ee_context_candidate_test: tests/ee_context_candidate_test.c tests/preemptive/ee_context_candidate.h ee/gdb-stub.h + $(HOST_CC) $(HOST_CFLAGS) $< -o $@ + tests/hw_breakpoint_state_test: tests/hw_breakpoint_state_test.c ee/hw-breakpoint-state.h ee/r5900-rsp.h $(HOST_CC) $(HOST_CFLAGS) $< -o $@ @@ -69,4 +73,4 @@ install: clean: $(MAKE) -C ee clean $(MAKE) -C sample clean - rm -f tests/r5900_rsp_test tests/registers_test tests/mock_rsp_server tests/rsp_protocol_test tests/hw_breakpoint_state_test tests/ctrlc_rsp_client + rm -f tests/r5900_rsp_test tests/registers_test tests/ee_context_candidate_test tests/mock_rsp_server tests/rsp_protocol_test tests/hw_breakpoint_state_test tests/ctrlc_rsp_client diff --git a/docs/EE_PREEMPTIVE_CTRL_C_RESEARCH.md b/docs/EE_PREEMPTIVE_CTRL_C_RESEARCH.md index e2e5eb9..50d3966 100644 --- a/docs/EE_PREEMPTIVE_CTRL_C_RESEARCH.md +++ b/docs/EE_PREEMPTIVE_CTRL_C_RESEARCH.md @@ -213,6 +213,47 @@ entry with BD, EPC, GPR, HI/LO, SA, FPU and thread ownership preserved. ## Why this is not yet preemptive GDB +### Compile-only register-frame candidate + +The next milestone now has an **unlinked object**, not a running exception +handler: `tests/preemptive/ee_context_candidate.S`. Its only entry assumes +`$k0` already contains a valid, 16-byte-aligned frame address. Under that +assumption it uses `sq` to record 30 complete 128-bit GPRs (0-25 and 28-31), +128-bit LO/HI, SA, LO1/HI1 and the currently readable CP0 EPC/Cause/Status. +It does not call the kernel, patch an instruction, install a vector, switch +stacks, or execute `eret`. **It is never linked into a hardware test ELF or +the production debugger**, and must not be called from an interrupt handler. + +The missing GPRs are not merely an assembler oversight: using `$k0` as the +frame pointer has already destroyed the previous `$k0` value. The standard +EE kernel vector may also have changed `$k0` and `$k1` before dispatching a +registered handler. The publicly available PS2SDK syscall-wrapper source +does not establish a contract for recovering their original values. The +snapshot prototype therefore does **not** claim either register or preserve +an interrupted application's complete state. Its CP0 reads are also values +at **invocation time**, not proven exception-entry values; its FPU and +interrupted thread identity remain uncaptured. + +`tests/preemptive/ee_context_candidate.h` defines a 720-byte aligned frame +with explicit per-register and provenance validity masks. The +`ee_context_is_complete()` gate rejects incomplete GPR masks, missing +entry-time CP0/branch-delay data, missing FPU/thread evidence, or unaligned +EPC. The prototype does not set any of those masks itself. This avoids +exposing an untrusted partial observation as a restorable GDB stop frame. + +The native C test `tests/ee_context_candidate_test.c` checks the snapshot +offsets, the older `gdb_regs_ps2` ABI, preservation of all 16 bytes of each +GPR, and the refusal to resume incomplete/unaligned/unknown-thread frames. +The EE object is cross-compiled but never linked or run; CI additionally +checks its disassembly and its absence from the runnable probes. + +The next step before **any** live preemptive handoff is to establish the +actual EE kernel interrupt-vector entry contract and a non-destructive +means of reading the interrupted thread's original saved context, including +`k0/k1` and entry-time BD/EPC. Only then can the candidate be extended to +capture and restore 128-bit GPR, FPU, and special-register state, with +deterministic exact-once instruction replay on `continue`. + The PS2SDK `ee_thread_status_t` returned by `ReferThreadStatus()` contains thread metadata (stack, priority, status) but **no saved GPR/PC register frame**. The `AddIntcHandler2()` callback provides an address-like third diff --git a/tests/ee_context_candidate_test.c b/tests/ee_context_candidate_test.c new file mode 100644 index 0000000..cc593b6 --- /dev/null +++ b/tests/ee_context_candidate_test.c @@ -0,0 +1,114 @@ +#include +#include +#include +#include +#include "../ee/gdb-stub.h" +#include "preemptive/ee_context_candidate.h" + +static int failures; + +static void check(int ok, const char *message) +{ + if (!ok) { + fprintf(stderr, "FAIL %s\n", message); + ++failures; + } +} + +int main(void) +{ + ee_context_candidate ctx = {0}; + gdb_regs_ps2 legacy = {0}; + unsigned int n, byte; + +#define CHECK_OFFSET(field, offset) \ + check(offsetof(ee_context_candidate, field) == (offset), #field " offset") + CHECK_OFFSET(gpr, 0); + CHECK_OFFSET(lo, EE_CTX_LO); + CHECK_OFFSET(hi, EE_CTX_HI); + CHECK_OFFSET(sa, EE_CTX_SA); + CHECK_OFFSET(lo1, EE_CTX_LO1); + CHECK_OFFSET(hi1, EE_CTX_HI1); + CHECK_OFFSET(epc, EE_CTX_EPC); + CHECK_OFFSET(cause, EE_CTX_CAUSE); + CHECK_OFFSET(status, EE_CTX_STATUS); + CHECK_OFFSET(fpr, EE_CTX_FPR); + CHECK_OFFSET(fsr, EE_CTX_FSR); + CHECK_OFFSET(acc, EE_CTX_ACC); + CHECK_OFFSET(interrupted_thread_id, EE_CTX_THREAD); + CHECK_OFFSET(gpr_valid_mask, EE_CTX_GPR_MASK); + CHECK_OFFSET(state_valid_mask, EE_CTX_STATE); +#undef CHECK_OFFSET + check(sizeof(ctx) == EE_CTX_SIZE, "snapshot size"); + check(_Alignof(ee_context_candidate) == 16, "aligned frame"); + check(offsetof(gdb_regs_ps2, reg0) == GDB_FR_REG0, "GDB r0 ABI"); + check(offsetof(gdb_regs_ps2, reg26) == GDB_FR_REG26, "GDB k0 ABI"); + check(offsetof(gdb_regs_ps2, reg27) == GDB_FR_REG27, "GDB k1 ABI"); + check(offsetof(gdb_regs_ps2, reg31) == GDB_FR_REG31, "GDB r31 ABI"); + check(offsetof(gdb_regs_ps2, lo) == GDB_FR_LO, "GDB LO ABI"); + check(offsetof(gdb_regs_ps2, hi) == GDB_FR_HI, "GDB HI ABI"); + check(offsetof(gdb_regs_ps2, cp0_epc) == GDB_FR_EPC, "GDB EPC ABI"); + + for (n = 0; n != 32; ++n) + for (byte = 0; byte != 16; ++byte) + ctx.gpr[n][byte] = (uint8_t)(n * 17u + byte); + memcpy(&legacy.reg0, ctx.gpr, sizeof(ctx.gpr)); + check(memcmp(&legacy.reg0, ctx.gpr, sizeof(ctx.gpr)) == 0, + "128-bit GPRs retain all bytes in legacy frame layout"); + check(ee_context_gpr_bytes(&ctx, 5)[15] == ctx.gpr[5][15], + "GPR accessor retains all 16 bytes"); + ctx.lo[15] = 0xa5; + ctx.hi[15] = 0x5a; + ctx.sa = 31; + ctx.lo1 = 0x78563412u; + ctx.hi1 = 0xaabbccddu; + ctx.fpr[31] = 0x11223344u; + ctx.fsr = 0x003fffffu; + ctx.acc = 0x87654321u; + ctx.cause = 0x80008000u; + ctx.status = 0x70030c10u; + check(ee_context_lo_bytes(&ctx)[15] == 0xa5, + "128-bit LO accessor"); + check(ee_context_hi_bytes(&ctx)[15] == 0x5a, + "128-bit HI accessor"); + check(ee_context_sa_value(&ctx) == 31, + "shift amount accessor"); + check(ee_context_lo1_value(&ctx) == 0x78563412u, + "LO1 accessor"); + check(ee_context_hi1_value(&ctx) == 0xaabbccddu, + "HI1 accessor"); + check(ee_context_fpr_values(&ctx)[31] == 0x11223344u, + "FPU register accessor"); + check(ee_context_fsr_value(&ctx) == 0x003fffffu, + "FPU status accessor"); + check(ee_context_acc_value(&ctx) == 0x87654321u, + "FPU accumulator accessor"); + check(ee_context_cause_value(&ctx) == 0x80008000u, + "raw Cause observation accessor"); + check(ee_context_status_value(&ctx) == 0x70030c10u, + "raw Status observation accessor"); + + /* A candidate cannot be used to stop/resume: k0/k1 and the original + * exception/thread/FPU provenance have not been validated. */ + ctx.gpr_valid_mask = EE_CTX_GPRS_EXCEPT_K01; + ctx.epc = 0x00101118u; + ctx.interrupted_thread_id = 16; + ctx.state_valid_mask = EE_CTX_REQUIRED_STATE; + check(!ee_context_is_complete(&ctx), "missing k0/k1 refuses resume"); + ctx.gpr_valid_mask = UINT32_MAX; + check(ee_context_is_complete(&ctx), "synthetic complete-frame contract"); + ctx.state_valid_mask &= ~EE_CTX_VALID_ENTRY_BD; + check(!ee_context_is_complete(&ctx), "missing exception-entry BD"); + ctx.state_valid_mask = EE_CTX_REQUIRED_STATE; + ctx.interrupted_thread_id = 0; + check(!ee_context_is_complete(&ctx), "unknown interrupted thread"); + ctx.interrupted_thread_id = 16; + ctx.epc |= 1u; + check(!ee_context_is_complete(&ctx), "unaligned EPC"); + check(!ee_context_is_complete(NULL), "null frame"); + + if (failures) + return 1; + puts("PASS EE candidate frame offsets, wide GPRs and incomplete-context rejection"); + return 0; +} diff --git a/tests/preemptive/Makefile b/tests/preemptive/Makefile index 2d2a888..e9739d2 100644 --- a/tests/preemptive/Makefile +++ b/tests/preemptive/Makefile @@ -2,12 +2,13 @@ EE_BIN = ee_interrupt_context_probe.elf EE_OBJS = ee_interrupt_context_probe.o BRANCH_BIN = ee_branch_delay_probe.elf BRANCH_OBJS = ee_branch_delay_probe.o +CONTEXT_OBJ = ee_context_candidate.o EE_CFLAGS += -g -Wall -all: $(EE_BIN) $(BRANCH_BIN) +all: $(EE_BIN) $(BRANCH_BIN) $(CONTEXT_OBJ) clean: - rm -f $(EE_BIN) $(EE_OBJS) $(BRANCH_BIN) $(BRANCH_OBJS) + rm -f $(EE_BIN) $(EE_OBJS) $(BRANCH_BIN) $(BRANCH_OBJS) $(CONTEXT_OBJ) include $(PS2SDK)/samples/Makefile.pref include $(PS2SDK)/samples/Makefile.eeglobal diff --git a/tests/preemptive/ee_context_candidate.S b/tests/preemptive/ee_context_candidate.S new file mode 100644 index 0000000..d44f7c1 --- /dev/null +++ b/tests/preemptive/ee_context_candidate.S @@ -0,0 +1,63 @@ +/* Compile-only partial register snapshot. No vector installation. */ +#define __ASSEMBLY__ +#include "ee_context_candidate.h" +.set push +.set noreorder +.set noat +.text +.globl ee_context_candidate_only +.type ee_context_candidate_only, @function +ee_context_candidate_only: + sq $0, 0($k0) + sq $1, 16($k0) + sq $2, 32($k0) + sq $3, 48($k0) + sq $4, 64($k0) + sq $5, 80($k0) + sq $6, 96($k0) + sq $7, 112($k0) + sq $8, 128($k0) + sq $9, 144($k0) + sq $10, 160($k0) + sq $11, 176($k0) + sq $12, 192($k0) + sq $13, 208($k0) + sq $14, 224($k0) + sq $15, 240($k0) + sq $16, 256($k0) + sq $17, 272($k0) + sq $18, 288($k0) + sq $19, 304($k0) + sq $20, 320($k0) + sq $21, 336($k0) + sq $22, 352($k0) + sq $23, 368($k0) + sq $24, 384($k0) + sq $25, 400($k0) + sq $28, 448($k0) + sq $29, 464($k0) + sq $30, 480($k0) + sq $31, 496($k0) + /* R26 and R27 were used by the entry mechanism; leave them invalid. */ + pmflo $k1 + sq $k1, EE_CTX_LO($k0) + pmfhi $k1 + sq $k1, EE_CTX_HI($k0) + mfsa $k1 + sw $k1, EE_CTX_SA($k0) + mflo1 $k1 + sw $k1, EE_CTX_LO1($k0) + mfhi1 $k1 + sw $k1, EE_CTX_HI1($k0) + /* These CP0 values describe invocation, not validated exception entry. */ + mfc0 $k1, $14 + sw $k1, EE_CTX_EPC($k0) + mfc0 $k1, $13 + sw $k1, EE_CTX_CAUSE($k0) + mfc0 $k1, $12 + sw $k1, EE_CTX_STATUS($k0) + /* No FPU or thread capture, no validity-mask updates. */ + jr $ra + nop +.size ee_context_candidate_only, .-ee_context_candidate_only +.set pop diff --git a/tests/preemptive/ee_context_candidate.h b/tests/preemptive/ee_context_candidate.h new file mode 100644 index 0000000..b8b4f67 --- /dev/null +++ b/tests/preemptive/ee_context_candidate.h @@ -0,0 +1,107 @@ +#ifndef PS2GDB_EE_CONTEXT_CANDIDATE_H +#define PS2GDB_EE_CONTEXT_CANDIDATE_H + +/* Compile-only snapshot ABI. No live exception-entry contract. */ +#define EE_CTX_GPR_STRIDE 16 +#define EE_CTX_LO 512 +#define EE_CTX_HI 528 +#define EE_CTX_SA 544 +#define EE_CTX_LO1 548 +#define EE_CTX_HI1 552 +#define EE_CTX_EPC 556 +#define EE_CTX_CAUSE 560 +#define EE_CTX_STATUS 564 +#define EE_CTX_FPR 568 +#define EE_CTX_FSR 696 +#define EE_CTX_ACC 700 +#define EE_CTX_THREAD 704 +#define EE_CTX_GPR_MASK 708 +#define EE_CTX_STATE 712 +#define EE_CTX_SIZE 720 + +#define EE_CTX_GPRS_EXCEPT_K01 0xf3ffffffu +#define EE_CTX_VALID_LO_HI (1u << 0) +#define EE_CTX_VALID_SA_HILO1 (1u << 1) +#define EE_CTX_VALID_ENTRY_CP0 (1u << 2) +#define EE_CTX_VALID_FPU (1u << 3) +#define EE_CTX_VALID_THREAD (1u << 4) +#define EE_CTX_VALID_ENTRY_BD (1u << 5) +#define EE_CTX_REQUIRED_STATE 0x3fu + +#ifndef __ASSEMBLY__ +#include +typedef struct __attribute__((aligned(16))) ee_context_candidate { + uint8_t gpr[32][EE_CTX_GPR_STRIDE]; + uint8_t lo[16], hi[16]; + uint32_t sa, lo1, hi1, epc, cause, status; + uint32_t fpr[32], fsr, acc; + int32_t interrupted_thread_id; + uint32_t gpr_valid_mask, state_valid_mask; +} ee_context_candidate; + +static inline const uint8_t *ee_context_gpr_bytes(const ee_context_candidate *ctx, + unsigned int index) +{ + return ctx->gpr[index]; +} + +static inline const uint8_t *ee_context_lo_bytes(const ee_context_candidate *ctx) +{ + return ctx->lo; +} + +static inline const uint8_t *ee_context_hi_bytes(const ee_context_candidate *ctx) +{ + return ctx->hi; +} + +static inline uint32_t ee_context_sa_value(const ee_context_candidate *ctx) +{ + return ctx->sa; +} + +static inline uint32_t ee_context_lo1_value(const ee_context_candidate *ctx) +{ + return ctx->lo1; +} + +static inline uint32_t ee_context_hi1_value(const ee_context_candidate *ctx) +{ + return ctx->hi1; +} + +static inline const uint32_t *ee_context_fpr_values(const ee_context_candidate *ctx) +{ + return ctx->fpr; +} + +static inline uint32_t ee_context_fsr_value(const ee_context_candidate *ctx) +{ + return ctx->fsr; +} + +static inline uint32_t ee_context_acc_value(const ee_context_candidate *ctx) +{ + return ctx->acc; +} + +/* Raw observation only: these values are not verified entry-time CP0 state. */ +static inline uint32_t ee_context_cause_value(const ee_context_candidate *ctx) +{ + return ctx->cause; +} + +static inline uint32_t ee_context_status_value(const ee_context_candidate *ctx) +{ + return ctx->status; +} + +/* A validated entry provider must set the masks; the prototype does not. */ +static inline int ee_context_is_complete(const ee_context_candidate *ctx) +{ + return ctx != 0 && ctx->gpr_valid_mask == UINT32_MAX && + (ctx->state_valid_mask & EE_CTX_REQUIRED_STATE) == EE_CTX_REQUIRED_STATE && + ctx->interrupted_thread_id > 0 && (ctx->epc & 3u) == 0; +} +#endif +#endif From 4591e8a6f18f79e0b322ae604eb1db00c331c75b Mon Sep 17 00:00:00 2001 From: Francisco Javier Trujillo Mata Date: Mon, 2 Nov 2026 15:26:57 +0100 Subject: [PATCH 4/5] test: Audit lossy EE bootstrap exception save sequence --- Makefile | 8 +- docs/EE_PREEMPTIVE_CTRL_C_RESEARCH.md | 61 +++++++++++ tests/ee_bootstrap_vector_audit.c | 146 ++++++++++++++++++++++++++ 3 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 tests/ee_bootstrap_vector_audit.c diff --git a/Makefile b/Makefile index 9f5b2cb..56a2d85 100644 --- a/Makefile +++ b/Makefile @@ -11,10 +11,11 @@ all: $(MAKE) -C ee $(MAKE) -C sample -check: tests/r5900_rsp_test tests/registers_test tests/ee_context_candidate_test tests/mock_rsp_server tests/rsp_protocol_test tests/hw_breakpoint_state_test tests/ctrlc_rsp_client +check: tests/r5900_rsp_test tests/registers_test tests/ee_context_candidate_test tests/ee_bootstrap_vector_audit tests/mock_rsp_server tests/rsp_protocol_test tests/hw_breakpoint_state_test tests/ctrlc_rsp_client ./tests/r5900_rsp_test ./tests/registers_test ./tests/ee_context_candidate_test + ./tests/ee_bootstrap_vector_audit --self-test ./tests/hw_breakpoint_state_test sh tests/check_protocol.sh @@ -54,6 +55,9 @@ tests/registers_test: tests/registers_test.c ee/r5900-registers.h ee/r5900-rsp.h tests/ee_context_candidate_test: tests/ee_context_candidate_test.c tests/preemptive/ee_context_candidate.h ee/gdb-stub.h $(HOST_CC) $(HOST_CFLAGS) $< -o $@ +tests/ee_bootstrap_vector_audit: tests/ee_bootstrap_vector_audit.c + $(HOST_CC) $(HOST_CFLAGS) $< -o $@ + tests/hw_breakpoint_state_test: tests/hw_breakpoint_state_test.c ee/hw-breakpoint-state.h ee/r5900-rsp.h $(HOST_CC) $(HOST_CFLAGS) $< -o $@ @@ -73,4 +77,4 @@ install: clean: $(MAKE) -C ee clean $(MAKE) -C sample clean - rm -f tests/r5900_rsp_test tests/registers_test tests/ee_context_candidate_test tests/mock_rsp_server tests/rsp_protocol_test tests/hw_breakpoint_state_test tests/ctrlc_rsp_client + rm -f tests/r5900_rsp_test tests/registers_test tests/ee_context_candidate_test tests/ee_bootstrap_vector_audit tests/mock_rsp_server tests/rsp_protocol_test tests/hw_breakpoint_state_test tests/ctrlc_rsp_client diff --git a/docs/EE_PREEMPTIVE_CTRL_C_RESEARCH.md b/docs/EE_PREEMPTIVE_CTRL_C_RESEARCH.md index 50d3966..5895ed7 100644 --- a/docs/EE_PREEMPTIVE_CTRL_C_RESEARCH.md +++ b/docs/EE_PREEMPTIVE_CTRL_C_RESEARCH.md @@ -254,6 +254,67 @@ means of reading the interrupted thread's original saved context, including capture and restore 128-bit GPR, FPU, and special-register state, with deterministic exact-once instruction replay on `continue`. +### Read-only bootstrap-vector audit (not the live interrupt vector) + +The EE distinguishes the normal RAM interrupt vector (`0x80000200`) from +the bootstrap ROM interrupt vector (`0xBFC00400`) when the corresponding +bootstrap-vector mode is enabled. See [ps2tek's EE exception-vector +reference](https://israpps.github.io/ps2tek/). The standard PS2SDK public +`AddIntcHandler2()` wrapper does not describe the internal save frame. + +`tests/ee_bootstrap_vector_audit.c` is a native **C-only, read-only** audit +for an optional, locally owned raw EE BIOS image. It recognizes the +bootstrap-vector sequence that loads an address into `k0` and uses +`jalr k1,k0` to enter a shared save helper. On this path, **both original +`k0` and `k1` are overwritten before that helper can save them**. The +recognized helper uses 32 consecutive `sd` operations (8 bytes per slot), +with zero stored in slots 26 and 27 rather than the original `k0/k1` values. +Consequently, even the other registers are only captured as **64 bits** +on this particular bootstrap path, not complete 128-bit R5900 GPR values. + +The tool's fixture tests verify all 32 stores, the entry-to-helper jump, +short-image rejection, and rejection of modified `k0` or jump instructions. +It matched two existing local PCSX2 BIOS references (V6 PAL and V12 PAL): + + +```text +BOOTSTRAP_ONLY helper_offset=0x0680 gpr_slots=32 slot_width=64 k0_saved=0 k1_saved=0 +``` + + +The raw BIOS images and any extracted disassembly **remain local**, outside +the repository; CI only executes synthetic tests. To inspect another local +reference without copying or uploading it: + +```sh +make tests/ee_bootstrap_vector_audit +./tests/ee_bootstrap_vector_audit --self-test +./tests/ee_bootstrap_vector_audit --bios "/path/to/your/local/BIOS.bin" +``` + +**Crucial limitation:** this only proves the **bootstrap ROM** path on the +tested reference images. It does **not** establish the save logic of the +normal **live RAM** vector at `0x80000200`, the active console firmware, +or the behavior of any custom level-2 debug handler. It cannot be used as +evidence that a preemptive debugger can safely resume from a callback. + +For an additional physical-PS2 cross-check, PS2Link's read-only `dumpmem` +returned 64 bytes from the application's known mapped address `0x00101114`, +and 1024 bytes from mapped low memory `0x00081f00`. In contrast, attempted +1024-byte reads from the physical vector page `0x00000000` and its KSEG0 +alias `0x80000000` produced empty host files. PS2Link still responded to +ping after those attempts, and no IOP reset or kernel write occurred. An +empty dump is **not** a valid vector image, and no runtime ABI conclusions +are derived from it. The low-memory `0x00081f00` region is not equivalent +to an image of `0x80000200`. + +The next research milestone must obtain the **actual installed RAM entry** +through an explicitly validated read-only mechanism (possibly PCSX2's +debugger or a documented kernel-mode copy operation), then compare its +first instructions and saved register locations against the bootstrap +reference. Until then the candidate frame's `k0/k1`, thread, FPU and +entry-time BD/EPC validity flags remain unset. + The PS2SDK `ee_thread_status_t` returned by `ReferThreadStatus()` contains thread metadata (stack, priority, status) but **no saved GPR/PC register frame**. The `AddIntcHandler2()` callback provides an address-like third diff --git a/tests/ee_bootstrap_vector_audit.c b/tests/ee_bootstrap_vector_audit.c new file mode 100644 index 0000000..8c99894 --- /dev/null +++ b/tests/ee_bootstrap_vector_audit.c @@ -0,0 +1,146 @@ +/* + * Read-only audit of an EE BIOS bootstrap interrupt vector. + * Never install or execute a vector. No BIOS bytes are included in this repo. + */ +#include +#include +#include +#include + +#define BIOS_BASE 0xbfc00000u +#define IRQ_VECTOR 0x400u +#define MIN_IMAGE 0x800u +#define K0 26u +#define K1 27u + +struct bootstrap_result { + unsigned int helper_offset; + unsigned int saved_registers; + unsigned int zeroed_registers; +}; + +static uint32_t little_u32(const unsigned char *data, size_t offset) +{ + return (uint32_t)data[offset] | ((uint32_t)data[offset + 1] << 8) | + ((uint32_t)data[offset + 2] << 16) | + ((uint32_t)data[offset + 3] << 24); +} + +static void put_u32(unsigned char *data, size_t offset, uint32_t value) +{ + data[offset] = (unsigned char)value; + data[offset + 1] = (unsigned char)(value >> 8); + data[offset + 2] = (unsigned char)(value >> 16); + data[offset + 3] = (unsigned char)(value >> 24); +} + +/* A match describes only the ROM bootstrap path, never the active RAM vector. + * It proves that this path overwrites k0/k1 and stores low 64-bit GPR slots. */ +static int inspect_bootstrap(const unsigned char *data, size_t size, + struct bootstrap_result *result) +{ + uint32_t lui, addiu, target; + unsigned int helper, reg; + + if (!data || !result || size < MIN_IMAGE) + return 0; + + lui = little_u32(data, IRQ_VECTOR); + addiu = little_u32(data, IRQ_VECTOR + 4); + if ((lui & 0xffff0000u) != 0x3c1a0000u || + (addiu & 0xffff0000u) != 0x275a0000u || + little_u32(data, IRQ_VECTOR + 8) != 0x0340d809u) + return 0; /* lui k0; addiu k0,k0,imm; jalr k1,k0 */ + + target = ((lui & 0xffffu) << 16) + + (uint32_t)(int32_t)(int16_t)(addiu & 0xffffu); + if (target < BIOS_BASE || target - BIOS_BASE > size - 8 - 32 * 4) + return 0; + helper = target - BIOS_BASE; + if ((little_u32(data, helper) & 0xffff0000u) != 0x3c1a0000u || + (little_u32(data, helper + 4) & 0xffff0000u) != 0x375a0000u) + return 0; /* helper immediately reassigns k0 as its save pointer */ + + for (reg = 0; reg < 32; ++reg) { + unsigned int source = reg == K0 || reg == K1 ? 0 : reg; + uint32_t expected = 0xff400000u | (source << 16) | (reg * 8); + if (little_u32(data, helper + 8 + reg * 4) != expected) + return 0; + } + + result->helper_offset = helper; + result->saved_registers = 32; + result->zeroed_registers = (1u << K0) | (1u << K1); + return 1; +} + +static int self_test(void) +{ + unsigned char data[MIN_IMAGE] = {0}; + struct bootstrap_result result = {0}; + unsigned int reg; + + put_u32(data, IRQ_VECTOR, 0x3c1abfc0u); + put_u32(data, IRQ_VECTOR + 4, 0x275a0680u); + put_u32(data, IRQ_VECTOR + 8, 0x0340d809u); + put_u32(data, 0x680, 0x3c1abc1fu); + put_u32(data, 0x684, 0x375af010u); + for (reg = 0; reg < 32; ++reg) { + unsigned int source = reg == K0 || reg == K1 ? 0 : reg; + put_u32(data, 0x688 + reg * 4, + 0xff400000u | (source << 16) | (reg * 8)); + } + + if (!inspect_bootstrap(data, sizeof(data), &result) || + result.helper_offset != 0x680 || result.saved_registers != 32 || + result.zeroed_registers != ((1u << K0) | (1u << K1))) + return 1; + if (inspect_bootstrap(data, 0x400, &result)) + return 1; /* truncated ROM */ + + put_u32(data, 0x688 + K0 * 4, 0xff5a00d0u); + if (inspect_bootstrap(data, sizeof(data), &result)) + return 1; /* original k0 cannot be claimed as saved */ + put_u32(data, 0x688 + K0 * 4, 0xff4000d0u); + put_u32(data, IRQ_VECTOR + 8, 0); + if (inspect_bootstrap(data, sizeof(data), &result)) + return 1; /* vector no longer establishes k1 clobber */ + + puts("PASS EE bootstrap vector audit and negative fixtures"); + return 0; +} + +int main(int argc, char **argv) +{ + unsigned char data[MIN_IMAGE]; + struct bootstrap_result result; + FILE *file; + int matched; + + if (argc == 2 && strcmp(argv[1], "--self-test") == 0) + return self_test(); + if (argc != 3 || strcmp(argv[1], "--bios") != 0) { + fprintf(stderr, "Usage: %s --self-test | --bios /path/to/local/bios.bin\n", + argv[0]); + return 2; + } + file = fopen(argv[2], "rb"); + if (!file) { + perror("Cannot open local BIOS reference"); + return 2; + } + matched = fread(data, 1, sizeof(data), file) == sizeof(data); + if (ferror(file)) + matched = 0; + fclose(file); + + if (!matched || !inspect_bootstrap(data, sizeof(data), &result)) { + fprintf(stderr, "Unsupported or unrecognized bootstrap save sequence\n"); + return 1; + } + printf("BOOTSTRAP_ONLY helper_offset=0x%04x gpr_slots=%u " + "slot_width=64 k0_saved=0 k1_saved=0\n", + result.helper_offset, result.saved_registers); + puts("Do not apply this result to the live 0x80000200 RAM vector."); + return 0; +} From 2dd4b8d7ab05b329df1fed3cd6b1b32f9dd41a1a Mon Sep 17 00:00:00 2001 From: Francisco Javier Trujillo Mata Date: Tue, 3 Nov 2026 20:32:15 +0100 Subject: [PATCH 5/5] test: Build EE observation probes with strict warnings and clean outputs --- .github/workflows/compilation.yml | 5 +++++ Makefile | 1 + tests/preemptive/Makefile | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/compilation.yml b/.github/workflows/compilation.yml index 56e9925..dfffa9d 100644 --- a/.github/workflows/compilation.yml +++ b/.github/workflows/compilation.yml @@ -80,6 +80,11 @@ jobs: grep -q 'sq[[:space:]]*ra,496(k0)' /tmp/ee_context_candidate.dis ! mips64r5900el-ps2-elf-nm tests/preemptive/ee_interrupt_context_probe.elf | grep -q 'ee_context_candidate_only' ! mips64r5900el-ps2-elf-nm tests/preemptive/ee_branch_delay_probe.elf | grep -q 'ee_context_candidate_only' + # The standalone probes must be cleaned by the root clean target. + make -C tests/preemptive clean + test ! -e tests/preemptive/ee_interrupt_context_probe.elf + test ! -e tests/preemptive/ee_branch_delay_probe.elf + test ! -e tests/preemptive/ee_context_candidate.o cooperative-ctrlc-build: name: EE cooperative Ctrl-C compile-only diff --git a/Makefile b/Makefile index 56a2d85..6907bad 100644 --- a/Makefile +++ b/Makefile @@ -77,4 +77,5 @@ install: clean: $(MAKE) -C ee clean $(MAKE) -C sample clean + $(MAKE) -C tests/preemptive clean rm -f tests/r5900_rsp_test tests/registers_test tests/ee_context_candidate_test tests/ee_bootstrap_vector_audit tests/mock_rsp_server tests/rsp_protocol_test tests/hw_breakpoint_state_test tests/ctrlc_rsp_client diff --git a/tests/preemptive/Makefile b/tests/preemptive/Makefile index e9739d2..b437211 100644 --- a/tests/preemptive/Makefile +++ b/tests/preemptive/Makefile @@ -3,7 +3,7 @@ EE_OBJS = ee_interrupt_context_probe.o BRANCH_BIN = ee_branch_delay_probe.elf BRANCH_OBJS = ee_branch_delay_probe.o CONTEXT_OBJ = ee_context_candidate.o -EE_CFLAGS += -g -Wall +EE_CFLAGS += -g -Wall -Wextra -Werror all: $(EE_BIN) $(BRANCH_BIN) $(CONTEXT_OBJ)