Skip to content

x86-64: FSGSBASE is taken, and the switch carries each thread's FS and GS bases - #1264

Merged
Japabu merged 6 commits into
mainfrom
gsentry-fsgsbase
Nov 12, 2026
Merged

Japabu merged 6 commits into
mainfrom
gsentry-fsgsbase

Conversation

@Japabu

@Japabu Japabu commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Based on main ce202b2, which carries #1262 (the swapgs entry), #1243 and #1251.

Why it needed #1243: once WRGSBASE exists, a gate whose swapgs is mispredicted as skipped speculates its GS loads through a kernel address the thread chose. On AMD the gates' LFENCE stops that only where it is dispatch-serializing (MSR C001_1029[1], AMD 70586 G-2), which #1243 sets.

What changes

Intel allows FSGSBASE only with a complete OS implementation (Guidance for Enabling FSGSBASE, ID 767615):

  • a switch and process-trace calls that manage the FS and GS bases apart from their selectors;
  • ring transitions insensitive to the GS base.

#1262 made the transitions insensitive, and ToyOS has no call that reads or writes another thread's registers. What was left is the switch, and this PR takes it (issues/fsgsbase-is-taken.md):

  • CR4.FSGSBASE joins CR4_REQUIRED. Every CPU of the kernel's range has it, so a CPU without it is refused by name. CR4_FORBIDDEN is deleted.
  • The switch carries each thread's bases. For a thread of a process, it reads the outgoing GS base from IA32_KERNEL_GS_BASE and the FS base with RDFSBASE. It writes each of the incoming thread's only where it differs from the per-CPU record of what the register holds: A switch loads only what the incoming thread does not already have #1251's for FS, and a new percpu::user_gs_base for GS. Its start, 1 << 63, is not canonical, so it equals no base a thread can hold, and a CPU's first switch into a thread always writes that thread's base. The per-thread value lives in arch::hw::UserBases, which AArch64 defines empty.
  • NMI, #DB, #MC and #DF read and install GS's base with RDGSBASE/WRGSBASE, clobbering only rax.
  • An AP applies its control_regs declaration before load_gdt. So from the ltr that gives the IST entries their stacks, they also have CR4.FSGSBASE for their instructions.
  • The zero-base premise goes: percpu::clear_user_gs_base and the descriptor-base assert.
  • Stage C of issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md no longer brings back gsbase_locked or user-writable-gsbase.

No AArch64 row is owed. The only shared change is KernelCtx's new field, which is zero-sized on AArch64, and AArch64's switch writes nothing new.

No manual change: the CPU declaration's page names no individual bit.

Test

fsgs_bases_per_thread pins two threads to one CPU and starts both behind a barrier. Each writes FS and GS bases of its own with WRFSBASE/WRGSBASE (one GS base is kernel-half), yields, and reads both back. Each counts how many of its rounds the other ran across, and that count must be above 0.

It is a shared-boot member, judged by its own exit in testcases_members_exit_0, the guest row #1271 made of the testcases boot. The standalone row a_threads_fs_and_gs_bases_stay_its_own, which ran it alone, is gone: #1271's row covers it. That row's mutation run below remains the member's negative control.

Control: gsentry-fsgsbase-mutation hands the incoming thread the outgoing thread's GS base, and must red on the readback assertion.

Checks and evidence

Head a72a3d8, on main 3c5c555. The rebase over #1271 conflicted only in tests/toyos.rs, where the standalone row and its function are deleted. git range-diff shows every other commit unchanged, and no kernel line moved. At a72a3d8, the kernel build, x86-64 clippy and toyos-checks (55) exit 0.

Before that rebase the head was 0658715, on main ce202b2. Since 01ec758:

  • the per-CPU GS record starts at the non-canonical 1 << 63 rather than at u64::MAX, which is a base a thread can hold;
  • fsgs_bases_per_thread asserts each thread's GS base is 0 before either writes one;
  • the defect record drops a stale sentence.

At 0658715, the kernel build and the six x86-64 clippy shapes exit 0, and fsgs_bases_per_thread builds.

The rebase onto main conflicted in kernel code. #1251 landed first and rewrote the switch lines this PR changes, and added an fpu field beside KernelCtx's new one.

So git range-diff shows the first commit changed. The runs at e753534 and 32a7b4d do not stand for the switch, and the rows it reaches are run again below.

01ec758 adds a records-only commit: the GS-base defect's record, which #1251 brought to main, now says what #1262 built and what it owes on metal.

Host checks at 9e28356, whose code 01ec758 shares, all exit 0:

  • kernel builds;
  • kernel clippy, x86-64 ×6 and AArch64 ×4;
  • toyos-checks (53), the kernel library (496), the source gate;
  • cargo run -- --build-only;
  • fsgs_bases_per_thread, gs_segment_load and debug_trap built through the harness's builder.

Guest runs:

run head rows result
43147838891 a72a3d8 testcases_members_exit_0, shared_debug_members_exit_0: every shared-boot member under FSGSBASE, fsgs_bases_per_thread, gs_segment_load, debug_trap and the nmi-user-gs self-test among them green: both rows passed
43140337644 01ec758 a_ring3_gs_load_leaves_the_kernel_its_gs, vector_state_survives_a_switch, std_threads_count_the_cpus_and_give_back_ended_threads, affinity_pins_hold_on_every_cpu, plus the then-standalone a_threads_fs_and_gs_bases_stay_its_own. These are the rows the switch reaches. Only the record's start and records changed since, and the rebase moved no kernel line. green
43142558662 0658715 the then-standalone a_threads_fs_and_gs_bases_stay_its_own, with the test's first-base check green
43140341877 5313bcf: in the new comparison, the switch writes the outgoing thread's GS base a_threads_fs_and_gs_bases_stay_its_own red on the readback assertion: thread 0 read thread 1's GS base with its FS intact
43081128403 e753534 nmi_under_a_user_gs_base, nested_nmi_is_loud, a_kernel_stack_overflow_names_its_stack green; no conflict touched the entries these rows reach
43094000903 32a7b4d ring3_debug_traps_end_their_process green; the same holds

The earlier mutation run, 43081134180 at 3e34d08, red on the readback assertion against the pre-#1251 switch.

Owed on metal. The benches are down, so metal is not a landing condition. It is recorded in issues/fsgsbase-is-taken.md. On the T14 and the AMD bench, main must show:

Unresolved

  • Switch cost: the switch moves the GS base by MSR, because Hw::switch may run with IF set. Its net cost is unmeasured until the T14 A/B.
  • The remaining AP span: before ltr, an AP's IST vectors have no TSS. Recorded in issues/an-ap-takes-ist-vectors-before-its-tss.md.
  • Track exit: issues/fsgsbase-is-taken.md stays open until its runs are read on each proving machine and under TCG. The guest runner runs x86-64 under KVM.

🤖 Generated with Claude Code

@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review: #1264 at 98890f6 (gsentry-fsgsbase, stacked on #1262)

Architecture

This meets Intel's condition for FSGSBASE (ID 767615, read: "In context switching and process trace system calls, the kernel needs to manage the FS/GS base entirely separate from its selector. The ring transition code has to be insensitive to the GS base address."):

The PR deletes the zero-base premise #1262 needed, rather than keeping it beside the new design: CR4_FORBIDDEN, clear_user_gs_base, the descriptor-base assert, and stage C's gsbase_locked. UserBases is the right owner: an architecture type inside the generic KernelCtx, empty on AArch64.

Net change:

  • kernel +82/−72
  • tests +118
  • issues +35/−15

No manual change is owed (verified): the manual names no CR4 bit or GS mechanism, and every x86-64 machine in its "2020 and later" range has FSGSBASE.

The switch is correct:

  • It reads the outgoing base from IA32_KERNEL_GS_BASE, where the user base always is while the kernel runs.
  • It writes the incoming base before that thread can reach Ring 3. A new thread gets 0, so no base crosses threads or processes.
  • Nothing that can run inside the switch touches IA32_KERNEL_GS_BASE. A CPL-0 interrupt does not swap, and the NMI saves and restores the live base.
  • Avoiding swapgs pairs is right while Hw::switch may run with IF set: sched/driver.rs:625 does not mask; only mask-windows does.

Cost and the NMI entry:

  • FS moves from a serializing WRMSR to WRFSBASE, and GS adds an RDMSR/WRMSR pair. A user-to-user switch therefore costs about what it did on main; that stays unmeasured until the T14 benchcase pair runs.
  • The NMI and fatal entries get cheaper.
  • p2-nmi.dis matches the source: nested rip/rsp at +0x10/+0x28, rflags at +0x68.

Required

  1. Inherited from x86-64: every entry from Ring 3 swaps in the kernel's GS base, and NMI, #MC and #DF install it whatever GS holds #1262. kernel/src/arch/x86_64/idt/nmi.rs:155,180 still call crate::log! in an NMI_SILENT file, so build/sourcegate.rs's nmi_does_not_log fails here too. Rebase on x86-64: every entry from Ring 3 swaps in the kernel's GS base, and NMI, #MC and #DF install it whatever GS holds #1262's fix.

  2. Land after A pure function decides a CPU's speculation mitigations, and each boot says its verdict #1243.

  3. The AMD answer to x86-64: every entry from Ring 3 swaps in the kernel's GS base, and NMI, #MC and #DF install it whatever GS holds #1262's #DB finding must come first.

    • The risk: if AMD defers the MOV-SS single-step trap, TF; mov ss; int3 puts a #DB at a gate's first instruction with the user's GS live and a Ring-0 CS in its frame.
    • Under x86-64: every entry from Ring 3 swaps in the kernel's GS base, and NMI, #MC and #DF install it whatever GS holds #1262 that crashes the kernel through base 0.
    • Under this PR it becomes add dword ptr gs:[preempt_count], 1, followed by the crash path's per-CPU reads, through a kernel address the user chose. That is a kernel write from userland.
    • Condition: land only once the AMD bench passes both MOV SS arms, or once #DB is a paranoid entry by the owner's decision.

Suggestions

  • kernel/src/arch/x86_64/hw.rs:317-318: read the bases only for Runs::User, as A switch loads only what the incoming thread does not already have #1251 does for FS.
    • Today every switch out of idle pays an RDMSR whose value is never read, on the wake path, which kernel/CLAUDE.md treats as audio latency.
    • When the two PRs rebase, A switch loads only what the incoming thread does not already have #1251's per-CPU "write only when different" record extends to GS.
    • Its "stale while a thread runs" comment then needs WRFSBASE named.
  • tests/guest/rust-tests/src/bin/fsgs_bases_per_thread.rs:23-35: add a Barrier after both pins.
    • Thread 0 starts its 500 rounds as soon as it is pinned. If the sibling has not yet started and migrated to cpu0, thread 0 can finish alone, and crossed > 0 reds a correct kernel.
    • With the barrier, the crossing is the test's premise rather than a race.
  • kernel/src/arch/x86_64/percpu.rs:598-599: a new sub-window on APs.
  • PR body: say why no AArch64 row is owed. KernelCtx is shared switch state, and the change there is a zero-sized field.

Evidence and landing

No guest rows have been dispatched yet. On the fixed head, landing needs:

  • Guest: a_threads_fs_and_gs_bases_stay_its_own a_ring3_gs_load_leaves_the_kernel_its_gs nmi_under_a_user_gs_base nested_nmi_is_loud a_kernel_stack_overflow_names_its_stack green.
  • Mutation faa81a1: a_threads_fs_and_gs_bases_stay_its_own reds on the readback assertion ("thread N round R wrote … read …"). A crash does not count.
  • Metal, T14 and AMD:
    • testcases completes, with fsgs_bases_per_thread (both crossing counts above 0), gs_segment_load and every debug_trap arm.
    • shared-debug shows nmi-user-gs: PASS through the RDGSBASE/WRGSBASE entry.
  • T14 benchcase on both heads: the pingpong.pipe.* figure is reported. It decides nothing, but a regression beyond the recorded bound gets investigated.

SEND BACK

Japabu added a commit that referenced this pull request Nov 11, 2026
… an AP takes its CPU declaration before its TSS

Review round 1 of #1264, on #1262's round. The rebase onto #1262 also gave
its new `#DB` entry `RDGSBASE` and `WRGSBASE`, as the NMI's has.

- The switch reads the outgoing FS and GS bases only for a thread of a
  process; the idle context's were never read back, and the read sat on
  the wake path.
- An AP applies its `control_regs` declaration before `load_gdt`, so from
  the `ltr` that gives the IST entries their stacks they also have
  `CR4.FSGSBASE` for their instructions; the record of the earlier span,
  before `ltr`, says only what is left in it.
- `fsgs_bases_per_thread` starts its rounds once both threads are pinned,
  so each thread running across the other's is the test's premise and not
  a race.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Re-review: #1264 at e753534 (round 2, on #1262's 2fc799b)

Scope: whether round 1's required findings are resolved, and whether the code that changed is correct.

Architecture

Unchanged from round 1 and still sound. The rebase gave #DB the NMI's RDGSBASE/WRGSBASE form.

The #DB/WRGSBASE question you asked holds. On the CPL-0 path (r2-p2-entries.dis matches the source):

  • rdgsbase reads the base register, not memory.
  • install_kernel_gs reads only the IST4 stack: sgdt [rsp+6], then [rsp+8], minus OFF_GDT.
  • wrgsbase installs the kernel's base before deferred runs.
  • The found base goes back with wrgsbase just before the iretq, and nothing after that reads GS.

The Ring 3 path never addresses GS. So a user-chosen kernel-half base never reaches memory from #DB. On a mispredicted ring test:

  • A Ring 3 frame taken as CPL 0 writes the kernel's base before any GS load.
  • A CPL-0 frame taken as Ring 3 reaches common_entry's LFENCE without a GS load.

Offsets are unchanged:

  • #DB: rip at +0x58.
  • NMI: FIRST_PUSHES = 2, so the nested rip/rsp are at +0x10/+0x28 and rflags at 8 × 11 + 16 = +0x68.

Round 1's findings

  1. Resolved. The branch is rebased on x86-64: every entry from Ring 3 swaps in the kernel's GS base, and NMI, #MC and #DF install it whatever GS holds #1262's fix. r2-p2-checks.log:509 shows nmi_does_not_log ... ok, and the suite passes 385 tests, with the same recorded macOS failure.
  2. Resolved. The body states that this PR lands after x86-64: every entry from Ring 3 swaps in the kernel's GS base, and NMI, #MC and #DF install it whatever GS holds #1262 and A pure function decides a CPU's speculation mitigations, and each boot says its verdict #1243.
  3. #DB is now paranoid, by the orchestrator's decision. This branch inherits x86-64: every entry from Ring 3 swaps in the kernel's GS base, and NMI, #MC and #DF install it whatever GS holds #1262's blocker, the #DB that resumes onto debug_entry itself. Here the outcome is the same CPU lockup and no worse, because each pass installs the kernel's base before anything else. x86-64: every entry from Ring 3 swaps in the kernel's GS base, and NMI, #MC and #DF install it whatever GS holds #1262's fix rebases onto this wrgsbase form.

The suggestions taken are correct:

  • hw.rs:314-320. The switch reads the bases only for Runs::User. Idle never returns to Ring 3, and every incoming user thread gets both bases written, so nothing is lost. The SAFETY clause's "or zero" holds: a new thread's thread_pointer is 0 (process.rs:609, loader/mod.rs:231).
  • fsgs_bases_per_thread. The Barrier comes after both pins, so the crossing is the premise rather than a race.
  • percpu.rs:598-600. control_regs::init runs before load_gdt.
    • Nothing in it needs the AP's GDT or TSS: init_paging and the microcode load precede it in ap_entry, and the trampoline has already set the GS base for its log.
    • The IST entries therefore have CR4.FSGSBASE from the ltr on.
    • The record of the span before ltr says only what is left in it.

Required

  1. kernel/src/arch/x86_64/entry.rs:16-21 and :45-46: the rebase reverted two of x86-64: every entry from Ring 3 swaps in the kernel's GS base, and NMI, #MC and #DF install it whatever GS holds #1262's round-2 fixes.

Evidence and landing

SEND BACK

Japabu added a commit that referenced this pull request Nov 11, 2026
… an AP takes its CPU declaration before its TSS

Review round 1 of #1264, on #1262's round. The rebase onto #1262 also gave
its new `#DB` entry `RDGSBASE` and `WRGSBASE`, as the NMI's has.

- The switch reads the outgoing FS and GS bases only for a thread of a
  process; the idle context's were never read back, and the read sat on
  the wake path.
- An AP applies its `control_regs` declaration before `load_gdt`, so from
  the `ltr` that gives the IST entries their stacks they also have
  `CR4.FSGSBASE` for their instructions; the record of the earlier span,
  before `ltr`, says only what is left in it.
- `fsgs_bases_per_thread` starts its rounds once both threads are pinned,
  so each thread running across the other's is the test's premise and not
  a race.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 11, 2026
…t, and the metal #1264 owes is recorded

Review round 2 of #1264. The earlier rebase resolved `arch::entry`'s header
and the gate macro's contract with this branch's text from before #1262's
round 2, which said no `#DB` lands in the swapped spans and that every
`iretq` to Ring 3 is `gate_iretq`'s. Both are #1262's again; this branch
keeps only its `install_kernel_gs` in `WRGSBASE` form. The rebase onto
#1262's round 3 gave the `#DB` entry's thread-ending path the `WRGSBASE`
restore.

The metal rows #1264 owes, with the benches down, are recorded in
`issues/fsgsbase-is-taken.md`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Re-review: #1264 at 32a7b4d (round 3, on #1262's a530d5d)

Scope: this review covers round 2's blocker and the #DB thread-ending path that the rebase gave a WRGSBASE form.

Architecture

The architecture is unchanged and still sound. This branch's whole difference in the #DB entry is #1262's entry with the RDGSBASE/WRGSBASE base handling.

Round 2's blocker: resolved

git diff a530d5d68 32a7b4dc6 -- kernel/src/arch/x86_64/entry.rs is now a single hunk at :96, the install_kernel_gs macro. The header and the gate contract are #1262's text.

The thread-ending path under FSGSBASE

r3-p2-debug.dis matches the source:

  • Entry. push rax, then rdgsbase rax and push. The install is sgdt, then sub 0x2098, then wrgsbase. Nine more pushes make eleven words, so the frame's rip sits at +0x58.
  • Exit. The pops run from rbp to rcx. Then the found base goes back with wrgsbase, rax is restored, the ring test runs, and the path continues at 2:.

Checked:

  • CR4.FSGSBASE is always set when the path runs. RDGSBASE and WRGSBASE need it, and every CPU has it from control_regs::init onward, which runs before its ltr (round 2). A CPL-0 #DB on debug_entry requires a Ring 3 thread to have run on that CPU first.
  • The restoring wrgsbase cannot #GP. It writes back what rdgsbase read, and every way of setting the base (WRGSBASE, or an MSR write) refuses a non-canonical value.
  • A user-chosen kernel-half base is never dereferenced. Under FSGSBASE the base this path finds can be such a value, but the path only holds it in a register and writes it back. Nothing between that wrgsbase and common_entry's swapgs/lfence addresses GS, either architecturally or speculatively. After the swapgs, the base sits in IA32_KERNEL_GS_BASE, as every Ring 3 entry leaves it.
  • NMI, attribution, and no return to Ring 3. All three hold exactly as on x86-64: every entry from Ring 3 swaps in the kernel's GS base, and NMI, #MC and #DF install it whatever GS holds #1262 (see that review). The NMI entry here uses the same RDGSBASE/WRGSBASE form, and like x86-64: every entry from Ring 3 swaps in the kernel's GS base, and NMI, #MC and #DF install it whatever GS holds #1262's it does not decide by CS.

Checks I ran

Evidence

Required

None of this PR's own. It must rebase onto #1262 once #1262's record fix lands. That fix is records only, so the rebase owes no rerun.

LAND, once the three queued runs read as required. Because this PR is stacked, it lands after #1262.

Japabu added a commit that referenced this pull request Nov 11, 2026
… an AP takes its CPU declaration before its TSS

Review round 1 of #1264, on #1262's round. The rebase onto #1262 also gave
its new `#DB` entry `RDGSBASE` and `WRGSBASE`, as the NMI's has.

- The switch reads the outgoing FS and GS bases only for a thread of a
  process; the idle context's were never read back, and the read sat on
  the wake path.
- An AP applies its `control_regs` declaration before `load_gdt`, so from
  the `ltr` that gives the IST entries their stacks they also have
  `CR4.FSGSBASE` for their instructions; the record of the earlier span,
  before `ltr`, says only what is left in it.
- `fsgs_bases_per_thread` starts its rounds once both threads are pinned,
  so each thread running across the other's is the test's premise and not
  a race.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 11, 2026
…t, and the metal #1264 owes is recorded

Review round 2 of #1264. The earlier rebase resolved `arch::entry`'s header
and the gate macro's contract with this branch's text from before #1262's
round 2, which said no `#DB` lands in the swapped spans and that every
`iretq` to Ring 3 is `gate_iretq`'s. Both are #1262's again; this branch
keeps only its `install_kernel_gs` in `WRGSBASE` form. The rebase onto
restore.

The metal rows #1264 owes, with the benches down, are recorded in
`issues/fsgsbase-is-taken.md`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator: review r3 LAND. Head fde1fb4 is 32a7b4d rebased onto #1262's record fix (code identical; verified). Queues after #1262 and #1243 land, on 43081128403 + 43094000903 green and mutation 43081134180 red on its readback assertion.

Japabu added a commit that referenced this pull request Nov 11, 2026
… an AP takes its CPU declaration before its TSS

Review round 1 of #1264, on #1262's round. The rebase onto #1262 also gave
its new `#DB` entry `RDGSBASE` and `WRGSBASE`, as the NMI's has.

- The switch reads the outgoing FS and GS bases only for a thread of a
  process; the idle context's were never read back, and the read sat on
  the wake path.
- An AP applies its `control_regs` declaration before `load_gdt`, so from
  the `ltr` that gives the IST entries their stacks they also have
  `CR4.FSGSBASE` for their instructions; the record of the earlier span,
  before `ltr`, says only what is left in it.
- `fsgs_bases_per_thread` starts its rounds once both threads are pinned,
  so each thread running across the other's is the test's premise and not
  a race.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 11, 2026
…t, and the metal #1264 owes is recorded

Review round 2 of #1264. The earlier rebase resolved `arch::entry`'s header
and the gate macro's contract with this branch's text from before #1262's
round 2, which said no `#DB` lands in the swapped spans and that every
`iretq` to Ring 3 is `gate_iretq`'s. Both are #1262's again; this branch
keeps only its `install_kernel_gs` in `WRGSBASE` form. The rebase onto
restore.

The metal rows #1264 owes, with the benches down, are recorded in
`issues/fsgsbase-is-taken.md`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from gsentry-swapgs to main November 12, 2026 02:06
Japabu added a commit that referenced this pull request Nov 12, 2026
… an AP takes its CPU declaration before its TSS

Review round 1 of #1264, on #1262's round. The rebase onto #1262 also gave
its new `#DB` entry `RDGSBASE` and `WRGSBASE`, as the NMI's has.

- The switch reads the outgoing FS and GS bases only for a thread of a
  process; the idle context's were never read back, and the read sat on
  the wake path.
- An AP applies its `control_regs` declaration before `load_gdt`, so from
  the `ltr` that gives the IST entries their stacks they also have
  `CR4.FSGSBASE` for their instructions; the record of the earlier span,
  before `ltr`, says only what is left in it.
- `fsgs_bases_per_thread` starts its rounds once both threads are pinned,
  so each thread running across the other's is the test's premise and not
  a race.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 12, 2026
…t, and the metal #1264 owes is recorded

Review round 2 of #1264. The earlier rebase resolved `arch::entry`'s header
and the gate macro's contract with this branch's text from before #1262's
round 2, which said no `#DB` lands in the swapped spans and that every
`iretq` to Ring 3 is `gate_iretq`'s. Both are #1262's again; this branch
keeps only its `install_kernel_gs` in `WRGSBASE` form. The rebase onto
restore.

The metal rows #1264 owes, with the benches down, are recorded in
`issues/fsgsbase-is-taken.md`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator Author

Review: #1264's rebase at 01ec758, on main ce202b2 (code identical to 9e28356)

Scope: the conflict resolution against #1251, the new per-CPU GS record, the rebuilt mutation and the records-only commit. Settled design is not reopened.

Architecture

The architecture is unchanged and still sound. The resolution takes #1251's switch exactly and adds GS beside FS:

git range-diff against fde1fb4 shows that only the first and third commits changed. #1262's records commit dropped out because it is on main. Nothing else of #1251's switch moved: TLB resume, fpu::leave/resume and the SS reload are as on main.

Net change against main:

  • kernel +120/−93
  • tests +123
  • issues +57/−15

The per-CPU records, path by path

Each item below was checked against the code at 01ec758.

  • swapgs on entry and exit (x86-64: every entry from Ring 3 swaps in the kernel's GS base, and NMI, #MC and #DF install it whatever GS holds #1262). The switch runs only in the kernel, between an entry's swapgs and an exit's. Throughout, IA32_KERNEL_GS_BASE holds the user base. A new thread's first exit (entry.rs first_entry) swaps the base the switch wrote into IA32_GS_BASE.
  • User WRGSBASE. It writes IA32_GS_BASE, and the next entry's swapgs moves that value into IA32_KERNEL_GS_BASE. The switch-out then reads the register and refreshes both the thread's saved base and the record (hw.rs:337-339).
    • Every switch's save is cpu.loaded_ctx, the context last switched in (kernel/pure/sched/cpu.rs:2232, :2253), so a thread's switch-out always takes the Runs::User arm.
    • That includes its last switch-out after its exit, since its context is freed only by a later pass.
    • The suspected stale record therefore does not survive a switch-out.
  • FS. The same reasoning holds. WRFSBASE and SYS_SET_THREAD_POINTER write IA32_FS_BASE directly, and RDFSBASE at switch-out reads it.
    • fs_base's start, 0, is exact: load_gdt loads FS with KERNEL_DS.
    • Moving the AP's control_regs::init before load_gdt changes nothing here.
  • NMI, #DB, #MC and #DF. They read and write only IA32_GS_BASE. NMI and #DB restore the base they found, and #MC and #DF never return.
    • None of them touches IA32_KERNEL_GS_BASE.
    • The nmi-user-gs self-test writes it, with interrupts closed, and puts back the value it read.
    • A CPL-0 interrupt inside Hw::switch while IF is set does not swap.
  • Migration. The record is per CPU, and the value travels in the context.
  • Idle switches.
    • An idle save reads nothing back, and an idle incoming gets nothing written.
    • Idle never returns to Ring 3.
    • On the next user switch-in, the register is either equal to the incoming thread's base, so nothing leaks, or it is written.
  • The first switch on each CPU. This is the one case that fails (finding 1).

Required

  1. kernel/src/arch/x86_64/percpu.rs:135-138 and :417: the record's u64::MAX start is a base a thread can hold.
    • The defect: 0xFFFF_FFFF_FFFF_FFFF is canonical under both 4- and 5-level paging. WRGSBASE refuses only a non-canonical value (SDM Vol. 2D, WRGSBASE: #GP(0) on a non-canonical source), and this branch's own test writes a kernel-half GS base.
    • The failing path: a thread writes GS base u64::MAX, is switched out, and is next switched in on a CPU that has not yet run a user thread. That CPU's record still holds u64::MAX, so the comparison at hw.rs:372 skips the write. The thread resumes with whatever IA32_KERNEL_GS_BASE held before the kernel, the firmware's or reset's value.
    • Why this is new: this branch deleted clear_user_gs_base, so nothing sets that register at boot.
    • Consequence: a user register is silently wrong. The comment ("u64::MAX, which no base is") and the PR body ("whose u64::MAX start no base equals") state an invariant that does not hold.
    • Why it differs from A switch loads only what the incoming thread does not already have #1251's records:
      • root: u64::MAX is sound, because no root value is all ones.
      • fs_base: 0 is sound, because it is exact from load_gdt on.
    • Fix, either of these:
      • a non-canonical start such as 1 << 63, which neither WRGSBASE nor WRMSR to IA32_KERNEL_GS_BASE accepts and which no segment load produces;
      • zeroing IA32_KERNEL_GS_BASE per CPU before its first switch, with the record starting at 0 as fs_base does.
    • Re-check owed: for the non-canonical start, a compile and the six x86-64 kernel clippy shapes. No row can reach this path, and inspection settles it.

Suggestions

  • tests/guest/rust-tests/src/bin/fsgs_bases_per_thread.rs:41: read the GS base once after the barrier, before the first write, and assert 0.
    • The gap: each thread writes before it reads, and the two threads' values never coincide. So neither the test nor the mutation exercises the record. A switch-out that dropped percpu::set_user_gs_base(gs) would still pass, even though it lets a thread that has not yet written its base resume with another thread's.
    • What the check catches: that case, at the cost of one rdgsbase.
    • Not required: inspection shows that the refresh is present.
  • issues/ring-3-replaces-the-kernels-gs-base-with-a-segment-load.md:51-52: delete the stale sentence. It reads "the owner's to choose; issues/fsgsbase-is-taken.md weighs the swapgs one". It contradicts the new lead, "Fixed in code by x86-64: every entry from Ring 3 swaps in the kernel's GS base, and NMI, #MC and #DF install it whatever GS holds #1262", which 01ec758 added to bring this record up to date.

Mutation 5313bcf

It is a meaningful control.

  • What it changes: it writes (*save).user_bases.gs but records the incoming thread's base.
  • What it does to the test:
    • On A→B, B resumes with A's base.
    • On idle→B, B resumes with idle's 0.
    • Neither equals B's base, so the readback assertion reds on the first crossing.
    • The FS path is untouched, so the red comes from GS alone.
  • Its run, 43140341877: red on the readback assertion, not a crash. The log reads "thread 0 round 7 wrote FS 0x700000000070 and GS 0x600000000070 and read (0x700000000070, 0xffff900000000000)". That is thread 1's round-0 GS base, with FS intact.
  • Its limit: it does not exercise the record's refresh or its start. See the suggestion above.

Evidence

Checks I ran at 01ec758, in a detached worktree with its own target directory, all exit 0:

  • kernel library tests: 496 passed;
  • toyos-checks: 53 passed;
  • the six x86-64 kernel clippy shapes from build/clippy.rs, with ADOPTED and -D warnings;
  • the plain AArch64 kernel shape.

The kernel library tests are the pure crate, which does not compile the x86-64 switch.

The author's checks: pr-1264/r5/r6-p2-checks.log is at 9e28356. git diff 9e283568b 01ec758d3 touches only issues/ring-3-replaces-…md, so it stands for the head.

Carry-over of 43081128403 (e753534) and 43094000903 (32a7b4d): holds.

New runs, at the time of review:

  • 43140337644 at 01ec758, the five switch rows: still queued behind four other branches. It must be green.
  • 43140341877 at the mutation: completed, red on the readback assertion, as required (above).

A non-canonical start owes neither run again: no row reaches it. Zeroing the register at CPU init changes boot instead, and the switch rows should then run on the fixed head.

Metal is not a landing condition while the benches are down. What is owed is recorded in issues/fsgsbase-is-taken.md.

SEND BACK

Japabu added a commit that referenced this pull request Nov 12, 2026
…d the readback test checks a thread's first base

Review of #1264's rebase.

`percpu::user_gs_base` started at `u64::MAX`, which is canonical, so a
thread that had written that base and was first switched in on a fresh CPU
kept whatever `IA32_KERNEL_GS_BASE` held there: nothing sets that register
at boot since `clear_user_gs_base` went. The record now starts at
`1 << 63`, which is not canonical; `WRGSBASE` and `WRMSR` refuse it, so no
thread holds it, and a CPU's first switch into any thread writes that
thread's base.

`fsgs_bases_per_thread` reads each thread's GS base once after both are
pinned and before either writes one, and asserts 0. A switch-out that
failed to refresh the record could otherwise leave a thread that had not
yet written its base on another thread's, and the rounds after would not
see it.

The GS-base defect's record drops a sentence about the fix being the
owner's to choose, which its new lead had already answered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator verification of the rebase review's fix at 0658715: the per-CPU GS record starts at the non-canonical NO_GS_BASE = 1 << 63, which neither WRGSBASE nor WRMSR accepts (read in percpu.rs); the test now asserts each thread's GS base is 0 before it writes one; the stale record sentence is gone. Lands on 43140337644 (four switch rows) + 43142558662 (the changed fsgs test at 0658715), with 43081128403 and 43094000903 carrying over and the mutation 43140341877 red on its readback assertion.

Japabu and others added 6 commits November 12, 2026 05:21
…d GS bases

Intel has FSGSBASE used only with a complete OS implementation: a switch
and process-trace calls that manage the FS and GS bases apart from their
selectors, and ring transitions insensitive to the GS base (Guidance for
Enabling FSGSBASE, ID 767615). The previous commits made every transition
insensitive to it, and ToyOS has no call that reads or writes another
thread's registers, so what was left is the switch:

- `CR4.FSGSBASE` joins `CR4_REQUIRED`: every CPU of the kernel's range
  (Intel since 2012, AMD since Zen) has it, so one without it is refused by
  name like one without SMEP, and `CR4_FORBIDDEN` goes.
- The switch saves the outgoing thread's user GS base from
  `IA32_KERNEL_GS_BASE`, where the kernel keeps it while it runs, and writes
  the incoming one's (`arch::hw::UserBases`, which AArch64 defines empty:
  its one base register is the thread pointer). A thread's FS base moves as
  before, now by `RDFSBASE` and `WRFSBASE`.
- NMI, `#MC` and `#DF` read and install GS's base with `RDGSBASE` and
  `WRGSBASE` instead of the MSR, clobbering only `rax`. No branch precedes
  the write, so no load through GS after it is speculated with another
  base.
- The zero-base premise goes with them: a thread now writes any canonical
  GS base, a kernel-half one included, so `percpu::clear_user_gs_base` and
  the compile-time check of the user descriptors' bases are deleted. The
  switch writes every incoming thread's base before it can reach Ring 3.

`fsgs_bases_per_thread` pins two threads to one CPU; each writes bases of its
own with `WRFSBASE` and `WRGSBASE` (one GS base kernel-half), yields and
reads them back. It is a shared-boot member on each bench;
`a_threads_fs_and_gs_bases_stay_its_own` runs it under QEMU.

Stage C of `issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md`
no longer brings back `gsbase_locked` or its `user-writable-gsbase` kernel:
a user-written GS base is now the design, and the readback is its test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found while moving the IST entries to `RDGSBASE`/`WRGSBASE`: an AP loads
the IDT in its trampoline, and its TSS and `CR4` only in `init_ap`, so an
IST vector in between has neither its stack nor, now, its instructions.
Unchanged by this branch beyond the `#UD`, and recorded with its exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… an AP takes its CPU declaration before its TSS

Review round 1 of #1264, on #1262's round. The rebase onto #1262 also gave
its new `#DB` entry `RDGSBASE` and `WRGSBASE`, as the NMI's has.

- The switch reads the outgoing FS and GS bases only for a thread of a
  process; the idle context's were never read back, and the read sat on
  the wake path.
- An AP applies its `control_regs` declaration before `load_gdt`, so from
  the `ltr` that gives the IST entries their stacks they also have
  `CR4.FSGSBASE` for their instructions; the record of the earlier span,
  before `ltr`, says only what is left in it.
- `fsgs_bases_per_thread` starts its rounds once both threads are pinned,
  so each thread running across the other's is the test's premise and not
  a race.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t, and the metal #1264 owes is recorded

Review round 2 of #1264. The earlier rebase resolved `arch::entry`'s header
and the gate macro's contract with this branch's text from before #1262's
round 2, which said no `#DB` lands in the swapped spans and that every
`iretq` to Ring 3 is `gate_iretq`'s. Both are #1262's again; this branch
keeps only its `install_kernel_gs` in `WRGSBASE` form. The rebase onto
restore.

The metal rows #1264 owes, with the benches down, are recorded in
`issues/fsgsbase-is-taken.md`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… owes on metal

#1251 brought the record to main, and #1262 then fixed the defect without
touching it, so main's record still read as an open, unfixed hole. It now
says what #1262 built, how the exit's guest test and its negative control
came out under QEMU, and what the benches still owe before it closes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d the readback test checks a thread's first base

Review of #1264's rebase.

`percpu::user_gs_base` started at `u64::MAX`, which is canonical, so a
thread that had written that base and was first switched in on a fresh CPU
kept whatever `IA32_KERNEL_GS_BASE` held there: nothing sets that register
at boot since `clear_user_gs_base` went. The record now starts at
`1 << 63`, which is not canonical; `WRGSBASE` and `WRMSR` refuse it, so no
thread holds it, and a CPU's first switch into any thread writes that
thread's base.

`fsgs_bases_per_thread` reads each thread's GS base once after both are
pinned and before either writes one, and asserts 0. A switch-out that
failed to refresh the record could otherwise leave a thread that had not
yet written its base on another thread's, and the rounds after would not
see it.

The GS-base defect's record drops a sentence about the fix being the
owner's to choose, which its new lead had already answered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator verification at a72a3d8. The rebase onto 3c5c555 deleted the standalone row as I decided. #1271's shared rows judge fsgs_bases_per_thread as a member, and the range-diff touches only tests/toyos.rs.

The landing run 43147838891 passed both shared rows at this head (test result: ok. 2 passed), and the body now says so. The other runs stand: 43142558662, 43140337644, 43081128403 and 43094000903 green, and the mutation 43140341877 red.

The PR merges cleanly onto main ee15a2e; #1252 and #1258 touch neither the entry path nor the switch path. The metal debts are listed under "Owed on metal". Queueing.

@Japabu
Japabu marked this pull request as ready for review November 12, 2026 05:54
@Japabu
Japabu added this pull request to the merge queue Nov 12, 2026
Merged via the queue into main with commit cc7217d Nov 12, 2026
11 checks passed
@Japabu
Japabu deleted the gsentry-fsgsbase branch November 12, 2026 06:13
Japabu added a commit that referenced this pull request Nov 12, 2026
…he switch carries FS and GS (#1264)

No conflicts. #1227 removed libc's `refuse_files`: every refusal now goes through
`set_errno`, which reads `toyos::Failure`'s one table. A refused map of a
file answers through it too. A file its server maps no pages of still
answers `ENODEV`, as POSIX names that refusal of `mmap`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant