feat(kernel): lazy user FPU: off until first FP insn (illegal-insn trap -> zeroed regs, retry), save on dirty trap, restore on switch, off for threads without + kernel; spawntest per-thread FP check

This commit is contained in:
2026-10-28 03:20:41 +01:00
parent 34a44b5928
commit c256f4ecf3
7 changed files with 302 additions and 1 deletions

View File

@@ -0,0 +1,165 @@
//! Floating point for user threads, lazily. A thread starts with the FPU
//! off (`sstatus.FS`); its first floating-point instruction traps as an
//! illegal instruction, and it is given zeroed registers and the FPU and
//! retries. From then on its registers are saved when a trap finds them
//! changed (FS dirty) and loaded whenever it is switched to; a thread
//! without them runs with the FPU off, so it never sees another's. The
//! kernel itself runs with the FPU off.
use core::arch::asm;
use domain::thread::{FpState, SavedContext};
use super::TrapFrame;
const FS: usize = 3 << 13;
const FS_OFF: usize = 0;
const FS_CLEAN: usize = 2 << 13;
const FS_DIRTY: usize = 3 << 13;
/// Turns the FPU off for the kernel; what the trap frame says the thread
/// returns with is unchanged.
pub(super) fn kernel_off() {
// SAFETY: clears sstatus.FS only.
unsafe { asm!("csrc sstatus, {0}", in(reg) FS) };
}
fn hardware_on() {
// SAFETY: sets sstatus.FS to (at least) clean, so the FP registers can
// be read and written; `kernel_off` turns it off again.
unsafe { asm!("csrs sstatus, {0}", in(reg) FS_CLEAN) };
}
/// On a trap from a thread: its registers into its context if it changed
/// them, and the frame then says they are saved.
pub(super) fn save_if_dirty(frame: &mut TrapFrame, ctx: &mut SavedContext) {
if frame.sstatus & FS != FS_DIRTY {
return;
}
if let Some(state) = ctx.fp_mut() {
hardware_on();
save(state);
frame.sstatus = (frame.sstatus & !FS) | FS_CLEAN;
}
}
/// Returning to a thread: its registers into the FPU, or the FPU off.
pub(super) fn load_for(frame: &mut TrapFrame, ctx: &SavedContext) {
frame.sstatus &= !FS;
match ctx.fp() {
Some(state) => {
hardware_on();
load(state);
frame.sstatus |= FS_CLEAN;
}
None => frame.sstatus |= FS_OFF,
}
}
/// An illegal instruction from a thread with the FPU off and no registers
/// yet: it gets them, zeroed, and retries. Whether that was it (otherwise
/// the instruction is a real fault).
pub(super) fn first_use(frame: &mut TrapFrame, ctx: &mut SavedContext) -> bool {
if frame.sstatus & FS != FS_OFF || ctx.fp().is_some() {
return false;
}
let state = ctx.enable_fp();
hardware_on();
load(state);
// sepc still names the instruction, so it runs again.
frame.sstatus = (frame.sstatus & !FS) | FS_CLEAN;
true
}
/// `$op` (fsd or fld) for every FP register, at `{base}` + 8 each.
macro_rules! fp_registers {
($op:literal) => {
concat!(
$op,
" f0, 0({base})\n",
$op,
" f1, 8({base})\n",
$op,
" f2, 16({base})\n",
$op,
" f3, 24({base})\n",
$op,
" f4, 32({base})\n",
$op,
" f5, 40({base})\n",
$op,
" f6, 48({base})\n",
$op,
" f7, 56({base})\n",
$op,
" f8, 64({base})\n",
$op,
" f9, 72({base})\n",
$op,
" f10, 80({base})\n",
$op,
" f11, 88({base})\n",
$op,
" f12, 96({base})\n",
$op,
" f13, 104({base})\n",
$op,
" f14, 112({base})\n",
$op,
" f15, 120({base})\n",
$op,
" f16, 128({base})\n",
$op,
" f17, 136({base})\n",
$op,
" f18, 144({base})\n",
$op,
" f19, 152({base})\n",
$op,
" f20, 160({base})\n",
$op,
" f21, 168({base})\n",
$op,
" f22, 176({base})\n",
$op,
" f23, 184({base})\n",
$op,
" f24, 192({base})\n",
$op,
" f25, 200({base})\n",
$op,
" f26, 208({base})\n",
$op,
" f27, 216({base})\n",
$op,
" f28, 224({base})\n",
$op,
" f29, 232({base})\n",
$op,
" f30, 240({base})\n",
$op,
" f31, 248({base})\n",
)
};
}
/// The FPU's registers into `state` (the FPU must be on).
fn save(state: &mut FpState) {
let base = state.registers.as_mut_ptr();
let control: u64;
// SAFETY: `base` points at 32 u64s, written in place; frcsr only reads.
unsafe {
asm!(fp_registers!("fsd"), base = in(reg) base, options(nostack));
asm!("frcsr {0}", out(reg) control, options(nostack));
}
state.control = control;
}
/// `state` into the FPU's registers (the FPU must be on).
fn load(state: &FpState) {
let base = state.registers.as_ptr();
// SAFETY: `base` points at 32 u64s, only read; fscsr writes fcsr.
unsafe {
asm!(fp_registers!("fld"), base = in(reg) base, options(nostack, readonly));
asm!("fscsr {0}", in(reg) state.control, options(nostack));
}
}

View File

@@ -1,4 +1,5 @@
mod fast; mod fast;
mod fp;
mod sched; mod sched;
mod vector; mod vector;
@@ -17,6 +18,7 @@ const CAUSE_INTERRUPT_BIT: usize = 1 << 63;
const CAUSE_S_SOFTWARE: usize = 1; const CAUSE_S_SOFTWARE: usize = 1;
const CAUSE_S_TIMER: usize = 5; const CAUSE_S_TIMER: usize = 5;
const CAUSE_S_EXTERNAL: usize = 9; const CAUSE_S_EXTERNAL: usize = 9;
const CAUSE_ILLEGAL_INSTRUCTION: usize = 2;
const CAUSE_ECALL_U: usize = 8; const CAUSE_ECALL_U: usize = 8;
/// Set in `sstatus` when the trap came from supervisor mode. /// Set in `sstatus` when the trap came from supervisor mode.
const SSTATUS_SPP: usize = 1 << 8; const SSTATUS_SPP: usize = 1 << 8;
@@ -29,6 +31,8 @@ pub fn init() {
let addr = trap_vector as *const () as usize; let addr = trap_vector as *const () as usize;
core::arch::asm!("csrw stvec, {0}", in(reg) addr); core::arch::asm!("csrw stvec, {0}", in(reg) addr);
core::arch::asm!("csrw sscratch, zero"); core::arch::asm!("csrw sscratch, zero");
// The kernel never uses the FPU; user threads get it lazily.
core::arch::asm!("csrc sstatus, {0}", in(reg) 3usize << 13);
// SIE stays clear; interrupts are enabled only by sret into userspace. // SIE stays clear; interrupts are enabled only by sret into userspace.
let sie_bits = (1usize << 5) | (1usize << 9) | (1usize << 1); let sie_bits = (1usize << 5) | (1usize << 9) | (1usize << 1);
core::arch::asm!("csrs sie, {0}", in(reg) sie_bits); core::arch::asm!("csrs sie, {0}", in(reg) sie_bits);
@@ -90,6 +94,14 @@ extern "C" fn trap_handler_rust(frame: &mut TrapFrame) {
} }
crate::smp::service_ipi(); crate::smp::service_ipi();
crate::smp::lock(); crate::smp::lock();
// The thread's FP registers are saved before anything can switch away.
if let Some(ctx) = kern()
.current_thread()
.and_then(|t| kern().thread_context_mut(t))
{
fp::save_if_dirty(frame, ctx);
}
fp::kernel_off();
if scause & CAUSE_INTERRUPT_BIT != 0 { if scause & CAUSE_INTERRUPT_BIT != 0 {
handle_interrupt(frame, scause & !CAUSE_INTERRUPT_BIT); handle_interrupt(frame, scause & !CAUSE_INTERRUPT_BIT);
} else { } else {
@@ -163,6 +175,11 @@ fn handle_exception(frame: &mut TrapFrame, scause: usize) {
} }
} }
} }
CAUSE_ILLEGAL_INSTRUCTION
if kern()
.current_thread()
.and_then(|t| kern().thread_context_mut(t))
.is_some_and(|ctx| fp::first_use(frame, ctx)) => {}
_ => { _ => {
let uart = crate::hw::uart::Uart::new(); let uart = crate::hw::uart::Uart::new();
uart.write_str("FAULT: scause="); uart.write_str("FAULT: scause=");

View File

@@ -14,6 +14,7 @@ fn load_context_to_frame(ctx: &domain::thread::SavedContext, frame: &mut TrapFra
frame.sstatus = ctx.status(); frame.sstatus = ctx.status();
frame.sstatus &= !(1 << 8); // SPP: return to U-mode frame.sstatus &= !(1 << 8); // SPP: return to U-mode
frame.sstatus |= 1 << 5; // SPIE frame.sstatus |= 1 << 5; // SPIE
super::fp::load_for(frame, ctx);
} }
fn apply_pending_result(frame: &mut TrapFrame, tid: types::id::ThreadId) { fn apply_pending_result(frame: &mut TrapFrame, tid: types::id::ThreadId) {

View File

@@ -173,6 +173,57 @@ fn encrypted_home(fs: &mut Storage, home_fs: &mut Storage) -> Result<(), &'stati
Ok(()) Ok(())
} }
/// Floating-point work that only comes out right if nobody else's
/// registers get in: a sum over `ROUNDS`, yielding (so switching threads)
/// all the way.
fn float_work(step: f64) -> u64 {
const ROUNDS: u32 = 2000;
let mut sum = 0.0f64;
let mut x = core::hint::black_box(step);
for _ in 0..ROUNDS {
sum += x * 0.5;
x += step;
syscall::yield_now();
}
sum.to_bits()
}
/// What `float_work(step)` gives when nothing interferes.
fn float_expected(step: f64) -> u64 {
let mut sum = 0.0f64;
let mut x = step;
for _ in 0..2000 {
sum += x * 0.5;
x += step;
}
sum.to_bits()
}
/// The other thread's answer, once it has one.
static FLOAT_RESULT: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
fn float_thread(_: &'static core::sync::atomic::AtomicU64) -> ! {
FLOAT_RESULT.store(float_work(0.25), core::sync::atomic::Ordering::Release);
syscall::exit_thread()
}
/// Two threads computing with floats at once each get their own registers
/// (the kernel saves and restores them on every switch).
fn floating_point_is_kept_per_thread() -> Result<(), &'static str> {
if !libkos::thread::spawn(&FLOAT_RESULT, float_thread) {
return Err("cannot start a thread");
}
let mine = float_work(1.75);
while FLOAT_RESULT.load(core::sync::atomic::Ordering::Acquire) == 0 {
syscall::yield_now();
}
let theirs = FLOAT_RESULT.load(core::sync::atomic::Ordering::Acquire);
if mine != float_expected(1.75) || theirs != float_expected(0.25) {
return Err("floating-point registers leaked between threads");
}
Ok(())
}
/// How many requests the `flaky` answering now has served. /// How many requests the `flaky` answering now has served.
fn flaky() -> Result<usize, &'static str> { fn flaky() -> Result<usize, &'static str> {
syscall::request(FLAKY, [flaky::op::WHO, 0, 0, 0]).map(|r| r[0]).map_err(|_| "flaky does not answer") syscall::request(FLAKY, [flaky::op::WHO, 0, 0, 0]).map(|r| r[0]).map_err(|_| "flaky does not answer")
@@ -246,6 +297,9 @@ fn check() -> Result<(), &'static str> {
revoke_stops_a_running_app(died)?; revoke_stops_a_running_app(died)?;
libkos::println!("spawntest: revoking stops a running app, allowing lets it run: ok"); libkos::println!("spawntest: revoking stops a running app, allowing lets it run: ok");
floating_point_is_kept_per_thread()?;
libkos::println!("spawntest: floating point is kept per thread: ok");
restarted_after_crashes()?; restarted_after_crashes()?;
libkos::println!("spawntest: a crashed server is restarted, a crash loop backs off: ok"); libkos::println!("spawntest: a crashed server is restarted, a crash loop backs off: ok");

View File

@@ -1,9 +1,27 @@
//! Saved register state of a thread. //! Saved register state of a thread.
/// A thread's floating-point registers and their control word. A thread
/// has none until its first floating-point instruction (see
/// `SavedContext::enable_fp`): until then it cannot see another thread's.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FpState {
pub registers: [u64; 32],
pub control: u64,
}
impl FpState {
pub const ZERO: Self = Self {
registers: [0; 32],
control: 0,
};
}
pub struct SavedContext { pub struct SavedContext {
registers: [usize; 32], registers: [usize; 32],
pc: usize, pc: usize,
status: usize, status: usize,
fp: FpState,
fp_enabled: bool,
} }
impl SavedContext { impl SavedContext {
@@ -12,6 +30,8 @@ impl SavedContext {
registers: [0; 32], registers: [0; 32],
pc: 0, pc: 0,
status: 0, status: 0,
fp: FpState::ZERO,
fp_enabled: false,
} }
} }
@@ -48,4 +68,39 @@ impl SavedContext {
pub fn registers_mut(&mut self) -> &mut [usize; 32] { pub fn registers_mut(&mut self) -> &mut [usize; 32] {
&mut self.registers &mut self.registers
} }
/// Its floating-point registers, if it has used any.
pub fn fp(&self) -> Option<&FpState> {
self.fp_enabled.then_some(&self.fp)
}
pub fn fp_mut(&mut self) -> Option<&mut FpState> {
self.fp_enabled.then_some(&mut self.fp)
}
/// Gives it floating-point registers, all zero.
pub fn enable_fp(&mut self) -> &mut FpState {
self.fp = FpState::ZERO;
self.fp_enabled = true;
&mut self.fp
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_thread_has_no_fp_until_it_is_given_zeroed_registers() {
let mut c = SavedContext::empty();
assert!(c.fp().is_none() && c.fp_mut().is_none());
c.enable_fp().registers[3] = 7;
assert_eq!(c.fp().unwrap().registers[3], 7);
c.enable_fp();
assert_eq!(
c.fp(),
Some(&FpState::ZERO),
"enabling again starts from zero"
);
}
} }

View File

@@ -9,7 +9,7 @@ use crate::error::KernelError;
mod context; mod context;
mod table; mod table;
pub use context::SavedContext; pub use context::{FpState, SavedContext};
pub use table::{MAX_THREADS, ThreadTable}; pub use table::{MAX_THREADS, ThreadTable};
#[cfg(test)] #[cfg(test)]

View File

@@ -320,6 +320,15 @@ set it stays masked.
- A saved reply is named only within the thread that saved it: it cannot - A saved reply is named only within the thread that saved it: it cannot
be passed on, and another thread's number means nothing. be passed on, and another thread's number means nothing.
## Floating point
A thread starts without floating-point registers, and the FPU off. Its
first floating-point instruction traps; the kernel gives it registers, all
zero, turns the FPU on and runs the instruction again. From then on its
registers are saved when a trap finds them changed and loaded whenever it
runs; a thread without them runs with the FPU off, so it can never read
another's. The kernel itself never uses the FPU.
## Budgets ## Budgets
Every process has a budget: frames (its pages, page tables and regions' Every process has a budget: frames (its pages, page tables and regions'