Chapter 21: User I/O Subsystems¶
TTY/PTY, console/logging, input (evdev), audio (ALSA), display/graphics (DRM/KMS)
User I/O subsystems bridge the kernel to human-facing hardware: terminals (TTY/PTY), input devices (evdev), audio (ALSA-compatible), and display/graphics (DRM/KMS). Each subsystem presents a Linux-compatible userspace API while using UmkaOS-internal driver isolation and zero-copy paths where applicable.
21.1 TTY and PTY Subsystem¶
Deployment placement: A line-discipline instance's state, ingress rings, per-port
worker processing, and drain loop are owned by the line-discipline provider module's
current domain, wherever that module is deployed. Placement is bind-time deployment
configuration (selected by the loader per Section 11.3), never identity:
the SAME provider code runs co-located with the VFS in the Core domain, isolated in a
Tier 1 domain, or in a Tier 2 process, and may be promoted/demoted at runtime. In the
default deployment the PTY subsystem (/dev/ptmx, /dev/pts/*), the serial line
disciplines, and the devpts pseudo-filesystem are co-located with the VFS in the Core
domain — same-domain ingress resolves to a direct enqueue with no cross-domain hop.
Every cross-module TTY edge (a driver's TtyOps, a port's line discipline) is reached
through a rebindable handle whose transport the domain service selects at bind time
(same domain → direct, different domain → ring); callers never assume a tier.
The one exception is the early-boot/emergency serial console (/dev/console, and the
/dev/ttyS0 panic-time output path), which is statically linked Tier-0-only
(non-evolvable, panic-safe) and lives in arch::current::serial
(Section 21.2) because it is the diagnostic output path during boot
before any driver isolation domain exists. This early-boot exception does NOT freeze the
post-boot /dev/ttyS* provider: once the isolation domains are up, the runtime serial
line discipline is tier-agnostic like every other provider.
21.1.1 The Problem¶
Linux's TTY layer is a historical artifact designed for 300-baud hardware teletypes. It features monolithic locks (tty_mutex, termios_rwsem), synchronous line discipline processing (handling backspace and signals in the critical path), and a complex buffer management system that scales poorly to thousands of concurrent terminal sessions.
In modern systems, the TTY layer is primarily used for Pseudo-Terminals (PTYs) — the backends for SSH sessions, terminal emulators (GNOME Terminal, Alacritty), and container multiplexers (Docker, Kubernetes). The Linux PTY implementation requires every byte of terminal output to traverse the kernel data path, acquiring locks and waking sleeping processes, making it a significant bottleneck for high-density container logging and high-throughput terminal applications.
21.1.2 UmkaOS's Lock-Free Ring Architecture¶
UmkaOS completely rearchitects the TTY/PTY subsystem around lock-free, single-producer/single-consumer (SPSC) ring buffers, identical to the KABI ring buffers used for storage and networking (Section 11.7).
The PTY Data Path:
A PTY consists of a master side (/dev/ptmx, held by SSHd or Docker) and a slave side (/dev/pts/N, held by the shell or containerized application).
In UmkaOS, a PTY pair shares a pair of mapped memory pages (8 KB total) containing two SPSC ring buffers (master-to-slave and slave-to-master). Each ring buffer occupies one 4 KB page, providing adequate buffer space for interactive terminal sessions and container logging.
/// A PTY ring position counter. Two naturally-aligned 32-bit halves so the
/// SPSC protocol is lock-free AND tear-free on EVERY architecture — including
/// PPC32, the sole supported leg without a native 64-bit atomic
/// (`target_has_atomic = "64"` is false there ALONE; ARMv7-A satisfies it via
/// LDREXD/STREXD). The split-half representation is chosen on the
/// bounded-distance argument below, not as a 32-bit fallback.
///
/// Only store-release (producer) and load-acquire (consumer) are ever used —
/// never CAS or read-modify-write. Because a PTY ring holds at most
/// `PTY_RING_DATA_SIZE` (3968) bytes, the outstanding distance `head - tail`
/// is ALWAYS `< 2^32`, so every full/empty and buffer-offset computation is
/// derived from `lo` ALONE with wrapping subtraction — the proven
/// `SpscRing<u8, N>` model ([Section 3.6](03-concurrency.md#lock-free-data-structures)).
/// A single 32-bit aligned load/store is single-copy-atomic on all 8 arches,
/// so neither half is ever observed torn. `hi` is a wrap odometer only: the
/// producer bumps it when `lo` wraps past zero, and it is NEVER load-bearing
/// for ring correctness — it backs only the 64-bit byte-throughput counters
/// exposed for diagnostics. (The earlier `AtomicU64` fallback — "u64 write +
/// release fence" — was unsound: a fence orders surrounding accesses but does
/// not make a two-instruction 64-bit store single-copy-atomic, so a 32-bit
/// consumer could observe `(new_hi, old_lo)`. This split-half representation
/// removes the hazard structurally.)
// Userspace boundary struct — mmap'd into userspace in zero-copy PTY mode. Layout is stable.
#[repr(C, align(8))]
pub struct PtyRingCounter {
/// Low 32 bits — the load-bearing position. Producer store-release,
/// consumer load-acquire. Wrapping arithmetic (relative distance only).
pub lo: AtomicU32,
/// High 32 bits — wrap odometer (diagnostics only, not load-bearing).
pub hi: AtomicU32,
}
const_assert!(core::mem::size_of::<PtyRingCounter>() == 8);
/// PTY ring buffer header. 128 bytes, cache-line padded to prevent false
/// sharing between the producer's head line and the consumer's tail line.
///
/// This is a simplified SPSC ring buffer format (not the full DomainRingBuffer
/// from Section 11.6.2, which has 128 bytes of header for MPSC/broadcast support).
/// Each direction of a PTY carries at most one logical producer and one logical
/// consumer at a time; the kernel enforces that discipline on shared file
/// descriptors — see **Ring ownership and serialization** below.
///
/// Layout (128 bytes, cache-line padded to prevent false sharing):
/// - bytes [0..8]: head counter (write position, PtyRingCounter)
/// - bytes [8..9]: hangup flag (AtomicU8)
/// - bytes [9..64]: padding (separate head from tail cache line)
/// - bytes [64..72]: tail counter (read position, PtyRingCounter)
/// - bytes [72..128]: padding (separate tail from data)
/// - bytes [128..4095]: data buffer (3968 bytes usable)
///
/// The 64-byte cache-line separation between head and tail eliminates false
/// sharing: the producer writes head (cache line 0) while the consumer writes
/// tail (cache line 1).
// Userspace boundary struct — mmap'd into userspace in zero-copy PTY mode. Layout is stable.
#[repr(C, align(64))]
pub struct PtyRingHeader {
/// Write position (producer advances). Counts bytes written; the low half
/// applied modulo data capacity gives the buffer offset. Store-release on
/// the producer side, load-acquire on the consumer side (`PtyRingCounter`).
pub head: PtyRingCounter,
/// Hangup flag: set to 1 (Release) by `hangup_slave` when the master fd
/// closes. A slave reader that wakes on an empty ring loads this (Acquire)
/// and, if set, returns a zero-length read (EOF) instead of re-sleeping —
/// distinguishing hangup from a spurious wake. `0` = live, `1` = hung up.
/// Occupies one byte of the former head-line padding, so head/tail offsets
/// and the 128-byte layout are unchanged. Read-only from userspace (it is
/// in the mmap'd header, but only the kernel ever writes it).
pub hung_up: AtomicU8,
/// Padding to push tail to the next 64-byte cache line.
pub _pad_head: [u8; 55],
/// Read position (consumer advances). Counts bytes read; low half applied
/// modulo data capacity gives the buffer offset (`PtyRingCounter`).
pub tail: PtyRingCounter,
/// Padding to fill the second cache line (64 bytes total per line).
pub _pad_tail: [u8; 56],
}
// PtyRingHeader is exactly 128 bytes (2 cache lines):
// head PtyRingCounter(8) + hung_up AtomicU8(1) + _pad_head(55) + tail
// PtyRingCounter(8) + _pad_tail(56) = 128. head at offset 0, tail at offset 64.
const_assert!(core::mem::size_of::<PtyRingHeader>() == 128);
/// **Ring ownership and serialization.** A `PtyRingPage` direction is SPSC:
/// one producer and one consumer advance its counters. Two access regimes
/// preserve that invariant against ordinary POSIX fd sharing (`dup`, `fork`,
/// `CLONE_FILES`) and against multiple independent openers of a `/dev/pts/N`
/// slave — VFS character streams take no `f_pos_lock` and delegate ordering to
/// "their own internal locks" ([Section 14.1](14-vfs.md#virtual-filesystem-layer)), so the TTY
/// layer supplies that lock:
///
/// - **Kernel-mediated mode (default).** Userspace never touches the ring
/// counters. `read()`/`write()` syscalls on a PTY end copy through the
/// per-port ingress/egress seam ([Section 21.1](#tty-and-pty-subsystem--asynchronous-line-disciplines-ntty)),
/// which serializes same-direction callers on the port's `ingress_lock`
/// (producers) and `read_lock` (consumers). Concurrent writers from several
/// `dup`'d master fds are therefore one serialized logical producer into the
/// ring; concurrent readers on several slave fds are one serialized logical
/// consumer. The counters are only ever advanced by the kernel under that
/// serialization, so the SPSC protocol holds.
/// - **Zero-copy mode (`PTY_REQ_DIRECT`).** The ring pages are mapped into the
/// consenting master/slave processes and the counters are advanced in
/// userspace. Single-producer/single-consumer is then a **contract on the
/// participants**, established by the consent handshake (mutual `CAP_TTY_DIRECT`
/// + nonce + same mount namespace, restrictions 1-3 below): each side agrees
/// to serialize its own concurrent accessors (multiple threads / `dup`'d fds
/// on one side are that process's responsibility, exactly as for any shared
/// `mmap`). A side that cannot honor SPSC must not enable zero-copy; the
/// kernel-mediated path above is always available as the fallback.
/// Data capacity of each PTY ring after the 128-byte cache-line-padded header.
/// 4096 (page) - 128 (header) = 3968 usable bytes. The load-bearing position is
/// the counter's low 32 bits; because capacity `< 2^32`, the wrapping distance
/// `head.lo - tail.lo` unambiguously reports fill level, so no sentinel slot is
/// needed.
pub const PTY_RING_DATA_SIZE: usize = 4096 - 128; // 3968
/// PTY ring buffer page. 4 KB total, 3968 bytes usable data.
/// Aligned to page boundary for direct mmap() into userspace.
///
/// Let `h = head.lo.load()` and `t = tail.lo.load()` (each a single-copy-atomic
/// 32-bit access). The producer writes at (`h as usize % PTY_RING_DATA_SIZE +
/// 128`), advancing `head`; the consumer reads at (`t as usize %
/// PTY_RING_DATA_SIZE + 128`), advancing `tail`.
///
/// **Full/empty detection**: the low halves are relative wrapping counters
/// (`AtomicU32`, the `SpscRing` model), so full/empty is detected by wrapping
/// subtraction — `let used = h.wrapping_sub(t) as usize;`:
/// - Empty when `used == 0` (`h == t`)
/// - Full when `used >= PTY_RING_DATA_SIZE`
/// - Available for write: `PTY_RING_DATA_SIZE - used`
/// - Available for read: `used`
/// `used` is always in `[0, PTY_RING_DATA_SIZE]` because the producer never
/// advances head past `tail + PTY_RING_DATA_SIZE`, and `PTY_RING_DATA_SIZE`
/// (3968) is far below `2^32`, so the wrap of `lo` never corrupts the distance.
/// The 64-bit throughput odometer (`hi:lo`) is diagnostics-only and does not
/// participate in full/empty or offset arithmetic.
// Userspace boundary struct — mmap'd into userspace in zero-copy PTY mode. Layout is stable.
#[repr(C, align(4096))]
pub struct PtyRingPage {
/// Ring buffer header (128 bytes, 2 cache lines).
pub header: PtyRingHeader,
/// Data buffer (PTY_RING_DATA_SIZE bytes).
pub data: [u8; PTY_RING_DATA_SIZE],
}
// PtyRingPage: 128 (header) + 3968 (data) = 4096 bytes (one page).
// mmap'd into userspace — boundary struct.
const _: () = assert!(core::mem::size_of::<PtyRingPage>() == 4096);
/// The reverse-direction ring (slave→master) is a separate page allocation.
/// Same layout as PtyRingPage. This design allows each direction to be
/// mapped independently if needed, and avoids the 8 KB allocation exceeding
/// the page granularity.
// Userspace boundary struct — mmap'd into userspace in zero-copy PTY mode. Layout is stable.
#[repr(C, align(4096))]
pub struct PtyRingPageReverse {
/// Ring buffer header (128 bytes, 2 cache lines).
pub header: PtyRingHeader,
/// Data buffer (PTY_RING_DATA_SIZE bytes).
pub data: [u8; PTY_RING_DATA_SIZE],
}
// PtyRingPageReverse: same layout as PtyRingPage = 4096 bytes.
const _: () = assert!(core::mem::size_of::<PtyRingPageReverse>() == 4096);
/// Terminal state shared between master and slave.
/// Stored in a separate small allocation (not a full page) within a per-master
/// state arena. Multiple PTYs from the same master share a single arena,
/// amortizing the page allocation overhead.
///
/// Total size: 32 bytes (8-byte aligned, fits in half a cache line).
/// Not cache-line padded (64 bytes) — multiple AtomicTtyState structs
/// are packed into a shared 4 KB arena; padding to 64 bytes would halve
/// the arena capacity from 128 to 64 PTYs per page.
/// Layout: termios_flags(4) + winsize_seq(4) + winsize_data(8) + flow_control(1)
/// + zero_copy_enabled(1) + _pad(14) = 32.
#[repr(C, align(8))]
pub struct AtomicTtyState {
/// Terminal flags (ICANON, ECHO, ISIG, etc.) as bit positions.
/// Modified atomically via compare-and-swap.
pub termios_flags: AtomicU32,
/// Window size (rows, columns). Modified via seqlock protocol.
/// Layout: [seq_counter: AtomicU32 (4 bytes), winsize: Winsize (8 bytes)]
pub winsize_seq: AtomicU32,
pub winsize_data: UnsafeCell<Winsize>,
/// Flow control state (stopped/running).
pub flow_control: AtomicBool,
/// Zero-copy mode enabled flag. Set by mutual consent handshake.
pub zero_copy_enabled: AtomicBool,
/// Padding to 32 bytes for cache alignment.
_pad: [u8; 14],
}
const_assert!(core::mem::size_of::<AtomicTtyState>() == 32);
/// Window size structure (matches POSIX struct winsize from <sys/ioctl.h>).
/// Used by TIOCGWINSZ/TIOCSWINSZ ioctls.
#[repr(C)]
#[derive(Clone, Copy)]
pub struct Winsize {
pub ws_row: u16,
pub ws_col: u16,
pub ws_xpixel: u16,
pub ws_ypixel: u16,
}
const_assert!(core::mem::size_of::<Winsize>() == 8);
PtyPair struct — the kernel's handle for one PTY master+slave pair:
/// Kernel-side state for a single PTY pair (master + slave).
///
/// # Safety
/// The three raw pointer fields (`master_tx`, `slave_tx`, `state`) point to
/// slab-allocated objects exclusively owned by this `PtyPair`. They are:
/// - Allocated in `PtyPair::new()` from the PTY slab pool (rings: 4 KiB page
/// each; state: 32-byte slot from `pty_state_slab()`).
/// - Valid for the entire lifetime of the `PtyPair` (until `Drop`).
/// - ALL three are freed in `Drop::drop()` by returning to their respective
/// slab pools (`pty_slab_pool` for rings, `pty_state_slab` for state).
/// No aliasing occurs: only this `PtyPair` and the zero-copy mapped processes
/// (if enabled) hold references to the physical pages. The kernel retains
/// ownership and revokes user mappings on process exit or exec.
pub struct PtyPair {
/// Master→slave ring buffer (1 page, 4 KiB). See Safety on `PtyPair`.
/// `Option` so that `Drop` can `.take()` to free the page exactly once.
pub master_tx: Option<*mut PtyRingPage>,
/// Slave→master ring buffer (1 page, 4 KiB). See Safety on `PtyPair`.
/// `Option` so that `Drop` can `.take()` to free the page exactly once.
pub slave_tx: Option<*mut PtyRingPageReverse>,
/// Shared atomic terminal state (termios flags, winsize, flow control).
/// See Safety on `PtyPair`.
pub state: *mut AtomicTtyState,
/// Mount namespace ID of the process that opened the master fd.
/// Used for zero-copy security checks (see restriction 3 below).
/// Raw `ns_id: u64`, matching the canonical namespace identity convention
/// ([Section 17.1](17-containers.md#namespace-architecture)) — namespaces carry a bare `u64` id, not a
/// per-type newtype.
pub owner_mnt_ns_id: u64,
/// PTY index (/dev/pts/N) within the devpts mount.
pub pts_index: u32,
/// Initial termios snapshot used during zero-copy negotiation.
/// For PTY devices, the authoritative termios state is `TtyPort.termios`,
/// updated by `tcsetattr()`. `AtomicTtyState.termios_flags` is a lock-free
/// cache of the hot-path flags, updated atomically on every `tcsetattr()`.
pub termios: SpinLock<Termios>,
/// Line discipline attached to this PTY (default: N_TTY).
pub ldisc: AtomicU8,
/// Control ring for out-of-band signal delivery in zero-copy mode.
///
/// **Lifecycle**: Allocated as a single 4 KB page from the per-NUMA
/// PTY slab pool (`pty_slab_pool()` — the same pool that owns the
/// `master_tx`/`slave_tx` ring pages; ONE allocator owns every
/// per-pair page, so `Drop for PtyPair` has a single teardown path)
/// when the terminal emulator enables zero-copy mode (ioctl
/// `TIOCSETZCOPY`). The page is mapped read-write into the terminal
/// emulator's address space and read-only into the kernel's.
///
/// **Deallocation**: On PTY close (`tty_release()`), the kernel:
/// 1. Unmaps the control ring page from the terminal emulator's VMA
/// (if still mapped — the process may have already exited).
/// 2. Drains any pending events from the ring (no-op if empty).
/// 3. Returns the page to the PTY slab pool (`pty_slab_pool().free()`,
/// same as the TX rings — see `Drop for PtyPair` below).
/// The `Option` is set to `None` after deallocation.
/// If the terminal emulator process exits first, the VMA teardown
/// in `exit_mmap()` unmaps its side; the kernel retains the page
/// until `tty_release()` runs (triggered by the last fd close).
pub control_ring: Option<*mut PtyControlRing>,
/// Packet mode (`TIOCPKT`) enable flag, master side only. `false` by
/// default; set/cleared by the `TIOCPKT` ioctl on the MASTER fd. While
/// set, every master `read()` is prefixed by one control byte — see
/// [Section 21.1](#tty-and-pty-subsystem--tiocpkt-packet-mode).
pub pkt_mode: AtomicBool,
/// Pending `TIOCPKT_*` status bits, master side only. Producers OR bits in
/// with `fetch_or(bits, Release)`; the master read consumes the whole set
/// with `swap(0, AcqRel)` and returns it as the single control byte.
/// Non-zero also makes the master poll report `EPOLLPRI`. See
/// [Section 21.1](#tty-and-pty-subsystem--tiocpkt-packet-mode).
pub pkt_status: AtomicU8,
}
/// Drop impl returns all allocated pages/slots to the PTY slab pool.
/// Runs when the last `Arc<PtyPair>` reference is dropped (both master
/// and slave fds closed, XArray entry removed).
impl Drop for PtyPair {
fn drop(&mut self) {
// Order: unmap userspace mappings first (if still mapped), then
// return pages/slots. The slab pool is per-NUMA-node for locality.
if let Some(ring) = self.master_tx.take() {
// SAFETY: ring was allocated from PTY slab in PtyPair::new()
// and is exclusively owned by this PtyPair (no aliasing).
unsafe { pty_slab_pool().free(ring as *mut u8) };
}
if let Some(ring) = self.slave_tx.take() {
// SAFETY: same invariant as master_tx above.
unsafe { pty_slab_pool().free(ring as *mut u8) };
}
if let Some(ctrl) = self.control_ring.take() {
// SAFETY: the control ring page comes from the same pool
// (see the `control_ring` field's Lifecycle doc).
unsafe { pty_slab_pool().free(ctrl as *mut u8) };
}
// Free the AtomicTtyState slot back to the state arena.
// SAFETY: `state` was allocated from the PTY state slab in
// PtyPair::new() and is exclusively owned by this PtyPair.
// The 32-byte slot is returned to the per-NUMA slab allocator.
if !self.state.is_null() {
unsafe { pty_state_slab().free(self.state as *mut u8, 32) };
}
}
}
impl PtyPair {
/// Signal a HANGUP on the slave side when the master fd is closed
/// (`devpts_ptmx_release`). Wakes every blocked slave reader so it returns
/// EOF / EIO instead of sleeping forever on a master that no longer exists.
/// The slave reads the master→slave ring (`master_tx`); when it is empty the
/// reader futex-waits on the ring head, so a hangup futex-wakes ALL waiters
/// there (the design's "buffer full/empty wakeups via futex" mechanism).
///
/// **Hangup observability and SIGHUP delivery**:
/// (1) The ring header's `hung_up` flag is set (Release) *before* the futex
/// wake, so a woken slave reader that finds the ring empty loads
/// `hung_up` (Acquire) and returns a zero-length read (EOF) instead of
/// re-sleeping — the woken reader can now distinguish hangup from a
/// spurious wake.
/// (2) POSIX also requires SIGHUP (+ SIGCONT) to the slave's controlling
/// session's foreground process group AND disassociation of that
/// session's controlling terminal. That state lives on the slave
/// `TtyPort` (`session` / `pgrp`), which `PtyPair` has NO back-link to
/// (the link is one-way: `TtyPort.driver_data → PtyPair`). The full
/// hangup is therefore a HANDOFF to the canonical `tty_hangup()` path
/// ([Section 8.7](08-process.md#process-groups-and-sessions)); `devpts_ptmx_release` invokes it
/// via the slave inode's `TtyPort` alongside this call — see the release
/// path below.
pub fn hangup_slave(&self) {
// Wake all slave readers blocked on the master→slave ring head.
if let Some(ring) = self.master_tx {
// SAFETY: master_tx is a live, kernel-mapped PtyRingPage for the
// PtyPair's lifetime (freed only in Drop, after both fds close).
// Set the hangup flag with Release BEFORE waking, so any reader
// woken here observes it (Acquire) and returns EOF.
unsafe { (*ring).header.hung_up.store(1, Ordering::Release); }
// futex operates on a 32-bit word: target the load-bearing low
// half of the head counter (the word a blocked reader waits on).
let head_addr = unsafe { &(*ring).header.head.lo as *const AtomicU32 as usize };
futex_wake(head_addr, u32::MAX); // wake every waiter → EOF recheck
}
}
}
Memory layout: A PTY pair consists of three shared memory regions:
1. Master→slave ring (1 page, 4 KB): Written by master, read by slave
2. Slave→master ring (1 page, 4 KB): Written by slave, read by master
3. State arena (shared across PTYs from same master): Contains multiple AtomicTtyState structs (32 bytes each). A 4 KB arena supports up to 128 PTYs.
Seqlock protocol for window size (see Section 3.6 for the formal SeqLock<T> specification): Reads use the standard seqlock pattern:
1. Read winsize_seq with Acquire ordering. If odd, retry (writer in progress).
2. Read winsize_data with Relaxed ordering (protected by the epoch fence).
3. Read winsize_seq again with Acquire ordering. If changed, retry.
Writes: acquire TTY write mutex, store winsize_seq odd with Release ordering,
update winsize_data with Relaxed, store winsize_seq even with Release.
The Acquire/Release pairs on winsize_seq ensure that data reads are fenced
by the epoch loads on all architectures (including ARM/RISC-V/PPC with weak ordering).
Writer serialization: Concurrent
TIOCSWINSZcallers must acquire the TTY write mutex before entering the seqlock write section (incrementingwinsize_seqto odd). Without this, two concurrent writers can interleave their begin/end increments, leavingwinsize_seqin an odd (permanently-locked) state and corruptingwinsize_data. The reader path (TIOCGWINSZ) requires no mutex — pure seqlock retry is sufficient.
When the slave application calls write() to stdout, the UmkaOS syscall interface (umka-sysapi) writes the data directly into the slave_tx ring buffer. If the master application is polling via epoll() or io_uring, the kernel signals the eventfd associated with the ring.
Zero-Copy PTYs for Containers:
For high-density container environments, UmkaOS supports a zero-copy PTY mode. If both the master and slave processes explicitly request it via an UmkaOS-specific ioctl(PTY_REQ_DIRECT), the kernel maps the PtyRingPage directly into the address spaces of both processes. The master and slave can then exchange terminal data entirely in userspace, bypassing the kernel data path completely. The kernel is only invoked to handle buffer full/empty wakeups (via futex). This allows a single node to stream gigabytes of container logs per second with near-zero CPU overhead.
Zero-copy mode restrictions:
- Raw mode only: Zero-copy mode requires the PTY to be in raw mode (ICANON flag clear in termios). The kernel's asynchronous TTY worker thread (Section 21.1) is bypassed, so no inline line discipline processing occurs. Applications receive raw bytes without backspace handling or line buffering. Signal generation is handled out-of-band via the control ring (see next bullet).
- Signal generation via control ring: Because the kernel data path is bypassed, inline byte-stream interception cannot detect control characters. Instead, zero-copy PTY uses a dedicated control ring for out-of-band signal delivery (see Section 21.1 below). POSIX semantics (Ctrl+C → SIGINT, Ctrl+\ → SIGQUIT, Ctrl+Z → SIGTSTP) are preserved.
- No echo processing: Local echo (ECHO flag) is disabled automatically when zero-copy mode is activated. The master must implement echo if required.
- Termios changes require renegotiation: If either side calls tcsetattr() to change terminal settings, the kernel automatically disables zero-copy mode and falls back to kernel-mediated mode. To re-enable, both sides must repeat the consent handshake.
Security Model for Zero-Copy PTYs:
Trust boundary note: Zero-copy mode creates a shared-memory channel between master and slave. The master process can directly read all slave terminal output without kernel mediation. Zero-copy mode requires mutual trust between master and slave and is not suitable for security-isolation boundaries (e.g., between different security domains, privilege levels, or container trust zones).
Zero-copy PTY mode requires explicit security checks before enabling direct memory sharing:
-
Capability requirement: The master side (the process requesting zero-copy mode) must hold
CAP_TTY_DIRECT(defined in Section 9.2). This capability grants permission to bypass the kernel's TTY data path security checks. Container runtimes (Docker, containerd) typically hold this capability; unprivileged processes do not. -
Mutual consent: Both master and slave must explicitly agree to zero-copy mode. The
PtyDirectParamsstructure passed toPTY_REQ_DIRECTis:
/// Parameters for PTY_REQ_DIRECT ioctl.
/// Layout: C-compatible, 64-byte fixed size (padding ensures ABI stability).
#[repr(C)]
pub struct PtyDirectParams {
/// Random 64-bit nonce generated by the master. The slave must echo
/// this value in its PTY_ACK_DIRECT ioctl to prove consent.
/// The kernel verifies nonce equality. Generated via `getrandom(2)`.
pub nonce: u64,
/// Timeout for slave acknowledgement in milliseconds.
/// If the slave does not call PTY_ACK_DIRECT within this window,
/// PTY_REQ_DIRECT returns -ETIMEDOUT. Range: 100-30000 ms.
/// Default (0): kernel uses 5000 ms.
pub timeout_ms: u32,
/// Requested ring buffer size for the shared data ring (bytes).
/// Must be a power of two in [4096, 4194304] (4 KB to 4 MB).
/// Default (0): kernel uses 65536 bytes (64 KB, matching pipe default).
pub ring_size_bytes: u32,
/// Flags. Currently reserved, must be 0.
pub flags: u64,
/// On success, filled by the kernel with the file descriptor for
/// the shared ring mmap. The caller maps this fd to access the ring.
/// Negative value on failure.
pub ring_fd: i32,
/// Padding to 64 bytes for ABI stability.
pub _pad: [u8; 36],
}
const_assert!(core::mem::size_of::<PtyDirectParams>() == 64);
Error codes for PTY_REQ_DIRECT:
- -EPERM: caller lacks CAP_TTY_DIRECT
- -EINVAL: timeout_ms or ring_size_bytes out of range, or flags != 0
- -ETIMEDOUT: slave did not acknowledge within timeout_ms
- -EBUSY: zero-copy mode already active on this PTY
- -ENOMEM: ring buffer allocation failed
Error codes for PTY_ACK_DIRECT:
- -ENOENT: no pending PTY_REQ_DIRECT request on this slave fd
- -EINVAL: nonce mismatch (wrong value supplied)
- Master requests via
ioctl(fd, PTY_REQ_DIRECT, ¶ms)where params includes a nonce - Slave acknowledges via
ioctl(slave_fd, PTY_ACK_DIRECT, nonce)within a timeout window - If the slave never acknowledges, the request fails with
-ETIMEDOUT -
This prevents a malicious master from forcing zero-copy mode on an unsuspecting slave
-
Same mount namespace constraint: Both processes must share the same mount namespace as the PTY owner. The check is:
// Zero-copy PTY access requires same mount namespace as the PTY owner.
// Mount namespace is stable for a process's lifetime (cannot be changed
// after unshare(CLONE_NEWNS)), unlike cgroup membership which can be
// changed after the zero-copy channel is established (TOCTOU bypass).
if current_task().mnt_ns_id == pty.owner_mnt_ns_id {
enable_zero_copy_for_pair(master_fd, slave_fd)
} else {
Err(KernelError::PermissionDenied)
}
/// Establish the zero-copy shared-ring mapping for a validated PTY
/// master/slave fd pair. Preconditions (checked by the caller above): the
/// slave has acknowledged the zero-copy upgrade and both fds share the PTY
/// owner's mount namespace.
///
/// Maps the `PtyPair`'s TX/RX ring pages `PROT_READ | PROT_WRITE` into both
/// processes and records the kernel back-reference used for revocation
/// (restriction 4 below). Returns `Err(KernelError::PermissionDenied)` if
/// either fd is no longer a valid end of the pair, or
/// `Err(KernelError::Timeout)` if the mapping cannot be installed within the
/// negotiation window.
fn enable_zero_copy_for_pair(master_fd: i32, slave_fd: i32) -> Result<(), KernelError>;
Mount namespace is the correct isolation boundary for PTY zero-copy: it is
immutable after unshare(CLONE_NEWNS) and correctly scopes to a container
boundary. Using cgroup membership would be vulnerable to cgroup migration
attacks — a process can be moved between cgroups by any holder of
CAP_SYS_ADMIN, creating a TOCTOU bypass where the check passes but the
process is subsequently migrated out of the container's cgroup scope before
the zero-copy channel is used. Mount namespace membership, by contrast, is
fixed for the lifetime of the process after the initial unshare() call and
cannot be changed by any external actor.
The PtyPair struct stores owner_mnt_ns_id: u64 (not owner_cgroup)
for this check. The mount-namespace id is recorded when the PTY master fd is opened
(at posix_openpt() time) and never updated. Processes without CAP_SYS_ADMIN
cannot change their mount namespace after creation.
-
Memory isolation guarantee: The
PtyRingPageis mapped withPROT_READ | PROT_WRITEinto both processes, but the kernel retains a back-reference to the physical pages. If either process exits or execs a binary with elevated capability grants (Section 9.2), the kernel immediately revokes the direct mapping and falls back to standard ring-buffer mode. This prevents privilege escalation via persistent shared memory. -
Audit logging: Successful zero-copy mode activation generates an audit event (Section 20.2) with both PIDs and the PTY device identifier, enabling post-incident forensics.
The fallback path (when zero-copy is not requested or denied) uses the standard kernel-mediated ring buffer with full security checks on every data transfer.
21.1.2.1.1 Signal Generation in Zero-Copy Mode¶
Problem: In standard PTY mode, the kernel's line discipline (N_TTY) reads every byte written to the PTY master, detects control characters (INTR=0x03 → SIGINT, QUIT=0x1C → SIGQUIT, SUSP=0x1A → SIGTSTP, EOF=0x04), and delivers signals to the foreground process group. In zero-copy mode (PTY_REQ_DIRECT), the terminal emulator writes directly to the shared ring buffer without a kernel read path — so the kernel cannot intercept control characters inline.
Solution — Sentinel ring for control characters:
Zero-copy PTY uses a dual-ring design:
- Data ring (shared mmap, zero-copy): carries printable characters. The terminal emulator writes here at full speed.
- Control ring (small kernel-visible ring, 64 entries): carries out-of-band events. The terminal emulator writes here when it detects a control character.
/// Out-of-band control event sent from the terminal emulator to the kernel
/// via the control ring. Each variant corresponds to a POSIX signal or
/// terminal state change that the kernel must process.
// Size: 16 bytes per entry under #[repr(C, u8)] due to FlushTo { offset: u64 } alignment.
// The discriminant occupies the first byte; the largest variant (FlushTo/WindowResize)
// determines the enum size.
#[repr(C, u8)]
pub enum PtyControlEvent {
/// Terminal emulator detected INTR character (default: Ctrl+C = 0x03).
/// Kernel delivers SIGINT to foreground process group.
SignalIntr = 1,
/// Terminal emulator detected QUIT character (default: Ctrl+\ = 0x1C).
/// Kernel delivers SIGQUIT to foreground process group.
SignalQuit = 2,
/// Terminal emulator detected SUSP character (default: Ctrl+Z = 0x1A).
/// Kernel delivers SIGTSTP to foreground process group.
SignalSusp = 3,
/// Terminal window resized. Kernel delivers SIGWINCH and updates winsize.
WindowResize { cols: u16, rows: u16, xpixel: u16, ypixel: u16 } = 4,
/// Terminal emulator detected EOF (default: Ctrl+D = 0x04).
/// Kernel sets hangup condition on PTY slave.
Eof = 5,
/// Flush the data ring up to this byte offset (for atomic command delivery).
FlushTo { offset: u64 } = 6,
}
const_assert!(core::mem::size_of::<PtyControlEvent>() == 16);
Control ring layout:
/// Written to the control ring page (mapped read-write by terminal emulator).
/// The control ring occupies a single 4 KB page, separate from the data ring
/// pages. The terminal emulator writes events; the kernel drains them.
#[repr(C)]
pub struct PtyControlRing {
/// Write index (terminal emulator advances).
///
/// **Memory ordering**: Terminal emulator stores with `Release` after
/// writing the event entry. Kernel loads with `Acquire` before reading
/// the entry, ensuring the event data is visible before consumption.
pub write_idx: AtomicU32,
/// Padding to separate from kernel's read_idx (avoid false sharing).
_pad: [u8; 60],
/// Read index (kernel advances).
///
/// **Memory ordering**: Kernel stores with `Release` after processing
/// the event. Terminal emulator loads with `Acquire` before checking
/// free slots, ensuring the slot is fully consumed before reuse.
pub read_idx: AtomicU32,
/// Ring entries. 64 entries × 16 bytes = 1024 bytes.
/// Total struct: 4 + 60 + 4 + 1024 = 1092 bytes (fits in a 4 KB page
/// with room for additional metadata). PtyControlEvent entries are NOT
/// cache-line aligned because the control ring is a low-frequency path
/// (human input rate: <1000 events/sec). The 60-byte pad between
/// write_idx and read_idx prevents false sharing between the userspace
/// producer and the kernel consumer on the hot indices only.
/// Note: read_idx (kernel-written) shares a cache line with entries[0..3]
/// (terminal-emulator-written). At <1000 events/sec this false sharing
/// adds negligible overhead and is not worth the 60-byte padding cost.
pub entries: [PtyControlEvent; 64],
}
const_assert!(core::mem::size_of::<PtyControlRing>() == 1092);
Terminal emulator protocol: When the terminal emulator detects a control character in the input stream (from the physical keyboard), it:
- Writes the control character's
PtyControlEventto the control ring at indexwrite_idx % 64. - Increments
write_idxwithReleaseordering. - Triggers the kernel via
write(ctl_fd, &SIG_NOTIFY, 1)— a 1-byte write to a dedicated control file descriptor that does not carry data, just wakes the kernel.
The kernel, on receiving the ctl_fd write:
- Drains the control ring: reads
entries[read_idx % 64](a 16-byte slot), inspects the u8 discriminant at offset 0 of the slot; values outside the valid range[1, 6]are silently discarded (the entry is skipped andread_idxadvances). This prevents undefined behavior from a malicious or buggy terminal emulator writing invalid discriminants into the user-mapped control ring page. - For each
SignalIntr/SignalQuit/SignalSusp: loadsslave_pgrp— theProcessGroupIdfrom the slaveTtyPort.pgrp(0 = no foreground group → the event is dropped) — resolves it withPROCESS_GROUPS.get(slave_pgrp), and, on a live group, delivers via the spec-native foreground-group primitivesend_signal_to_pgrp(&group, sig)(Section 8.7). This is the same primitivedevpts_ptmx_releaseuses for hangup; there is nokill_pgrp(pgid, sig, priv)(that 3-arg shape is Linux'skill_pgrpand is not a UmkaOS symbol —send_signal_to_pgrptakes an already-resolved&ProcessGroup). - For
WindowResize: updatesPtyState.winsizeand deliversSIGWINCHto the foreground process group. - For
Eof: sets the hangup condition on the PTY slave, waking any blocked readers with zero-length reads. - Advances
read_idxwithReleaseordering.
Security: The control ring is in a user-mapped page. A malicious terminal emulator could spam SIGINT events, but: (1) signals can only be delivered to processes in the session the PTY controls — cross-session delivery is impossible; (2) rate limiting: at most 64 control events per ctl_fd write (ring size); (3) the mapping is per-PTY, allocated only in zero-copy mode. A compromised terminal emulator already has full control over the PTY master side (it can close the fd, inject arbitrary bytes, resize the window), so the control ring does not expand the attack surface.
SIGINT rate limiting: PTY SIGINT rate limiting is applied per PTY slave device, not per master FD. Multiple master FDs opened to the same slave share one token bucket. This prevents a misbehaving terminal emulator from bypassing the rate limit by opening N master FDs (each with its own bucket) and interleaving SIGINT injections across them.
- Token bucket:
capacity = 100,refill_rate = 1000 tokens/second - Each injected SIGINT, SIGQUIT, SIGTSTP, or SIGHUP consumes 1 token
- When the bucket is empty, excess signals are dropped silently
- The terminal emulator is expected to coalesce input events; the rate limit prevents a malicious or buggy terminal emulator from flooding the foreground process group. 1000 signals/second is far above any legitimate interactive use.
Rate limiting state is stored in PtySlaveState (not in per-fd structures):
pub struct PtySlaveState {
// ... existing fields ...
/// Shared SIGINT rate limiter for this slave device.
/// All master FDs to this slave share this bucket.
/// Capacity: 100 signals; refill rate: 1000 signals/second.
pub signal_token_bucket: TokenBucket,
}
When any master FD injects a SIGINT to this slave: deduct one token from
PtySlaveState::signal_token_bucket. If the bucket is empty, the injection is
rate-limited (SIGINT is either queued or dropped, depending on policy).
Token bucket lifetime: same as PTY slave device lifetime — NOT tied to any specific master FD's lifetime.
Compatibility: Applications using standard read()/write() on the PTY master continue to work unchanged — signal generation is handled by the kernel's line discipline (Section 21.1). The control ring is only allocated when zero-copy mode is activated via PTY_REQ_DIRECT ioctl. Falling back from zero-copy mode (due to tcsetattr() or process exit) automatically returns to kernel-mediated signal generation.
21.1.2.1.2 XON/XOFF Flow Control in Zero-Copy Mode¶
Problem: Classical TTY processes XON/XOFF software flow control by scanning each
byte as it passes through the line discipline — when XOFF (Ctrl-S, 0x13) is seen,
output is paused; when XON (Ctrl-Q, 0x11) is seen, output resumes. This is
fundamentally incompatible with zero-copy: you cannot scan a buffer you are not
copying. The solution is a two-layer architecture that preserves the zero-copy property
for bulk data while enforcing POSIX flow control semantics.
Layer 1 — Data path (zero-copy): In zero-copy mode, the slave writes directly to the master's ring buffer without scanning for XON/XOFF characters. This preserves the zero-copy property for bulk data (container logs, remote shell output, etc.).
Layer 2 — Control path (XON/XOFF scanning): XON/XOFF scanning is performed only
when IXON or IXOFF is set in termios.c_iflag. The scan happens at the ring
buffer consumer (master read) side — bytes are examined as the master application
reads them, not as the slave writes them. The flow control state is communicated back
to the slave writer via an atomic flag in AtomicTtyState.
/// Flow control state for one side of a PTY (master or slave).
///
/// Manages XON/XOFF (software flow control, IXON/IXOFF termios flags).
///
/// **PTY TIOCMGET/TIOCMSET behavior**: Linux PTYs expose no modem-status
/// operation, so `TIOCMGET` returns `-ENOTTY` (errno 25).
/// UmkaOS matches this: PTY ioctl dispatch returns
/// `-ENOTTY` for `TIOCMGET`/`TIOCMSET`/`TIOCMBIS`/`TIOCMBIC`. The `modem_signals`
/// field below is used internally for XON/XOFF flow control state only; it is
/// NOT exposed via TIOCMGET for PTY devices. Physical serial ports (via
/// `SerialTtyOps` KABI) DO implement TIOCMGET and return real modem signal state.
///
/// XON character: `termios.c_cc[VSTART]` (default Ctrl-Q = 0x11).
/// XOFF character: `termios.c_cc[VSTOP]` (default Ctrl-S = 0x13).
///
/// All fields are atomic so the master consumer and slave writer can read/write
/// without holding a lock on the hot data path.
/// Kernel-internal, Rust-managed layout. Not ABI.
pub struct PtyFlowControlState {
/// True if this side is currently in XOFF state (transmission suspended).
/// Set when the read buffer crosses `rx_high_watermark`; cleared when it
/// drops below `rx_low_watermark`. The slave write path checks this before
/// writing to the ring.
pub tx_stopped: AtomicBool,
/// True if the remote side (peer) is in XOFF state (we must stop sending).
/// Set when we receive an XOFF character or when the peer's `tx_stopped` is true.
pub peer_stopped: AtomicBool,
/// Number of bytes currently in the receive buffer for this side.
pub rx_bytes: AtomicU32,
/// High watermark: when `rx_bytes` exceeds this, send XOFF to the peer.
/// Default: 3/4 of the receive buffer capacity.
pub rx_high_watermark: u32,
/// Low watermark: when `rx_bytes` drops below this (after XOFF was sent),
/// send XON to the peer to resume transmission.
/// Default: 1/4 of the receive buffer capacity.
pub rx_low_watermark: u32,
/// Total receive buffer capacity in bytes. Set at PTY creation.
pub rx_capacity: u32,
/// Simulated modem control signals using Linux TIOCM_* bit positions.
/// AtomicU16 to accommodate DSR (bit 8 = 0x100). Bits used:
/// bit 1: TIOCM_DTR (0x002), bit 2: TIOCM_RTS (0x004),
/// bit 5: TIOCM_CTS (0x020), bit 6: TIOCM_CAR (0x040),
/// bit 7: TIOCM_RNG (0x080), bit 8: TIOCM_DSR (0x100).
/// No translation needed: TIOCMGET returns the raw value.
pub modem_signals: AtomicU16,
/// Number of XON characters sent to the peer (telemetry).
pub xon_sent: AtomicU32,
/// Number of XOFF characters sent to the peer (telemetry).
pub xoff_sent: AtomicU32,
/// If true, software flow control (XON/XOFF) is enabled for this side.
/// Matches the IXON/IXOFF termios flags.
pub sw_flow_enabled: AtomicBool,
/// Whether `IXON` is currently active (derived from `termios.c_iflag`).
/// When false, the master consumer skips XON/XOFF scanning entirely.
pub ixon_enabled: AtomicBool,
/// Whether `IXOFF` is currently active.
/// When true, the kernel sends XOFF/XON to the slave based on ring fill level.
pub ixoff_enabled: AtomicBool,
/// Whether `IXANY` is set: any character from master resumes output.
pub ixany_enabled: AtomicBool,
/// Tracks whether an XOFF has been injected into the slave for IXOFF
/// threshold enforcement. True from the moment XOFF is injected until XON
/// is injected (when the ring drains below `rx_low_watermark`).
pub ixoff_sent: AtomicBool,
/// XOFF character value (default 0x13 = Ctrl-S). From termios.c_cc[VSTOP].
pub xoff_char: u8,
/// XON character value (default 0x11 = Ctrl-Q). From termios.c_cc[VSTART].
pub xon_char: u8,
}
impl PtyFlowControlState {
/// Default watermarks: high = 3/4 capacity, low = 1/4 capacity.
pub fn new(capacity: u32) -> Self {
Self {
tx_stopped: AtomicBool::new(false),
peer_stopped: AtomicBool::new(false),
rx_bytes: AtomicU32::new(0),
rx_high_watermark: capacity * 3 / 4,
rx_low_watermark: capacity / 4,
rx_capacity: capacity,
// PTY-specific default; physical serial drivers derive initial
// modem state from hardware.
modem_signals: AtomicU16::new(0x000), // PTY: no modem signals. TIOCMGET returns -ENOTTY
// (matching Linux, which has no tiocmget op for PTYs).
// Physical serial drivers set initial modem state
// from hardware (DTR+RTS+CAR typically).
xon_sent: AtomicU32::new(0),
xoff_sent: AtomicU32::new(0),
sw_flow_enabled: AtomicBool::new(false),
ixon_enabled: AtomicBool::new(false),
ixoff_enabled: AtomicBool::new(false),
ixany_enabled: AtomicBool::new(false),
ixoff_sent: AtomicBool::new(false),
xoff_char: 0x13, // Ctrl-S
xon_char: 0x11, // Ctrl-Q
}
}
/// Called when `rx_bytes` increases. Returns true if XOFF should be sent to peer.
pub fn on_rx(&self, added: u32) -> bool {
let new = self.rx_bytes.fetch_add(added, Ordering::Relaxed) + added;
if self.sw_flow_enabled.load(Ordering::Relaxed)
&& new > self.rx_high_watermark
&& !self.tx_stopped.swap(true, Ordering::Release)
{
self.xoff_sent.fetch_add(1, Ordering::Relaxed);
return true; // caller should inject XOFF into the peer's write path
}
false
}
/// Called when `rx_bytes` decreases. Returns true if XON should be sent to peer.
/// Uses a CAS loop (not `fetch_sub`) to prevent underflow wrapping:
/// `AtomicU32::fetch_sub` wraps to `u32::MAX - delta` if `consumed > current`,
/// which would corrupt the flow control state irreversibly. The CAS loop
/// loads the current value, clamps the subtraction to zero, and retries
/// on contention with concurrent `on_rx()` increments.
pub fn on_tx(&self, consumed: u32) -> bool {
let (prev, new) = loop {
let current = self.rx_bytes.load(Ordering::Relaxed);
let clamped = current.saturating_sub(consumed);
match self.rx_bytes.compare_exchange_weak(
current, clamped, Ordering::Relaxed, Ordering::Relaxed,
) {
Ok(old) => break (old, clamped),
Err(_) => continue, // contention with on_rx(); retry
}
};
debug_assert!(consumed <= prev, "on_tx: consumed {} > rx_bytes {}", consumed, prev);
if self.sw_flow_enabled.load(Ordering::Relaxed)
&& new < self.rx_low_watermark
&& self.tx_stopped.swap(false, Ordering::Release)
{
self.xon_sent.fetch_add(1, Ordering::Relaxed);
return true; // caller should inject XON into the peer's write path
}
false
}
}
Slave write path (when ixon_enabled is set):
fn pty_slave_write(ring: &PtyRingPage, flow: &PtyFlowControlState, data: &[u8]):
0. if data.is_empty():
// write(fd, buf, 0) returns 0 immediately with NO side effects: it does
// NOT block on flow control, does not touch the ring, and does not
// signal the master. The guard MUST precede the tx_stopped check —
// without it a zero-length write on a stopped terminal sleeps waiting
// for an XON that has nothing to release, which POSIX write(2) forbids
// ("If nbyte is zero ... the function may detect and return errors ...
// and returns zero"). Same rule as the n_tty read machine's
// zero-length guard.
return Ok(0)
1. if flow.tx_stopped.load(Acquire):
// Block until master sends XON (or zero-copy mode is exited).
wait_event(&flow.write_waitq, !flow.tx_stopped.load(Relaxed))
2. Write `data` to ring buffer (zero-copy; no character scanning).
3. Signal master via eventfd (data available).
The slave never scans bytes — it only checks the tx_stopped flag before each
write(). The wait is on a standard wait queue; wakeup is delivered by the master
consumer path when XON is detected.
Master read path (consumer side, when ixon_enabled):
fn pty_master_read(ring: &PtyRingPage, flow: &PtyFlowControlState, buf: &mut [u8]):
// read(fd, buf, 0) returns 0 immediately, consuming NOTHING from the ring
// and acting on no flow-control byte. Without this guard the loop below has
// no buffer-capacity bound to stop it: it would consume ring bytes it cannot
// deliver, and XON/XOFF bytes among them would be acted on and then dropped,
// losing flow-control state on a call POSIX defines as having no effect.
// Zero-length pair with `pty_slave_write` step 0.
if buf.is_empty():
return Ok(0)
let xon = flow.xon_char; // plain u8, set by tcsetattr
let xoff = flow.xoff_char; // plain u8, set by tcsetattr
let ixany = flow.ixany_enabled.load(Relaxed);
// Bounded by buffer capacity: the loop stops at the first of "ring empty"
// or "buf full". Consumed XON/XOFF bytes act on flow-control state without
// occupying buffer space (they are never delivered), so they do not count
// against the bound.
for each byte `b` consumed from the ring, while buf.len() < buf.capacity():
if b == xoff && flow.ixon_enabled.load(Relaxed):
flow.tx_stopped.store(true, Release)
// Wake slave to re-check stopped state on next write attempt.
wake_up(&flow.write_waitq)
// XON/XOFF bytes are NOT delivered to the master application (POSIX).
continue
elif b == xon && flow.ixon_enabled.load(Relaxed) && !ixany:
flow.tx_stopped.store(false, Release)
wake_up(&flow.write_waitq) // Unblock paused slave writers.
continue
elif flow.tx_stopped.load(Relaxed) && ixany:
// IXANY: any character from master resumes paused output.
flow.tx_stopped.store(false, Release)
wake_up(&flow.write_waitq)
// The character itself IS delivered to master (unlike plain XON).
buf.push(b)
else:
buf.push(b)
POSIX character-stripping rules:
- When IXON is set and IXANY is not set: XON (VSTART) and XOFF (VSTOP)
bytes are consumed by the flow control layer and not delivered to the master
application. This matches POSIX termios(3) semantics.
- When IXANY is set: any character received from the master resumes paused output;
only VSTOP pauses. The character that resumed output IS passed to the master
application (it is not a dedicated control byte in this mode).
- When IXOFF is set: the kernel automatically injects XOFF (VSTOP) into the
slave's input stream when the slave-to-master ring reaches 75% capacity, and injects
XON (VSTART) when the ring drains below 25% capacity. This back-pressures the
slave from the kernel side without application involvement.
IXOFF kernel-side injection:
fn pty_check_ixoff_thresholds(ring: &PtyRingPage, flow: &PtyFlowControlState):
// Load-bearing position is the counter low half; wrapping distance is the
// fill level (always in [0, PTY_RING_DATA_SIZE], capacity << 2^32).
let h = ring.header.head.lo.load(Relaxed);
let t = ring.header.tail.lo.load(Relaxed);
let used = h.wrapping_sub(t) as u32;
let capacity = PTY_RING_DATA_SIZE as u32; // 3968 bytes
if flow.ixoff_enabled.load(Relaxed):
if used >= (capacity * 3 / 4) && !flow.ixoff_sent.load(Relaxed):
inject_byte_to_slave(ring, flow.xoff_char) // plain u8
flow.ixoff_sent.store(true, Release)
elif used <= (capacity / 4) && flow.ixoff_sent.load(Relaxed):
inject_byte_to_slave(ring, flow.xon_char) // plain u8
flow.ixoff_sent.store(false, Release)
This check runs on the master consumer path after each read batch; it does not require a background timer or dedicated thread.
Termios change interaction: XON/XOFF mode is part of termios.c_iflag. When
tcsetattr() is called while zero-copy mode is active:
- If only IXON/IXOFF/IXANY bits change, zero-copy mode remains active.
AtomicTtyState is updated in place; the consumer and producer paths pick up the
new values on their next iteration.
- If ICANON is re-enabled or any flag incompatible with zero-copy is set, zero-copy
mode falls back to kernel-mediated mode (as documented in the zero-copy restrictions
above). The tx_stopped flag is cleared during the transition to prevent the
slave from blocking indefinitely after the fallback.
OPOST interaction: Zero-copy mode is active only when OPOST is clear in
termios.c_oflag. When OPOST is enabled, output processing (newline translation
ONLCR, tab expansion, etc.) is required on each byte — this is fundamentally
incompatible with zero-copy. Setting OPOST forces the copy path for output
processing; zero-copy mode is automatically suspended until OPOST is cleared again.
Overhead: The XON/XOFF consumer-side check adds approximately 2 ns per byte on
x86-64 (one atomic byte load per byte consumed, branch predicted not-taken for bulk
data where flow control is inactive). For bulk container logging — where IXON is
typically not set — there is zero overhead (the ixon_enabled atomic check short-
circuits the entire scanning path). For interactive terminals where XON/XOFF flow
control is active, the per-byte overhead is acceptable and consistent with the
terminal's interactive (non-bulk) nature.
21.1.3 Character Device Registration¶
TTY devices register with the VFS character device subsystem (Section 14.5) during subsystem init. Linux assigns two well-known majors to TTY:
| Major | Minor range | Device nodes | Description |
|---|---|---|---|
| 4 | 0–63 | /dev/tty0–/dev/tty63 |
Virtual consoles (VTs) |
| 4 | 64–255 | /dev/ttyS0–/dev/ttyS191 |
Serial ports (ttySN = minor 64+N) |
| 5 | 0 | /dev/tty |
Controlling terminal (current process) |
| 5 | 1 | /dev/console |
System console |
| 5 | 2 | /dev/ptmx |
PTY master multiplexer |
| 136 | 0–1048575 | /dev/pts/N |
PTY slave devices (devpts, up to 1M PTYs) |
/// Called from tty_subsystem_init() during boot Phase 5.3+ (after Tier 1 driver loading).
fn tty_register_chrdevs() {
// Major 4: VTs (minors 0-63) + serial ports (minors 64-255)
register_chrdev_region(ChrdevRegion {
major: 4,
minor_base: 0,
minor_count: 256, // 0-63 = VTs, 64-255 = serial (ttyS0 = minor 64)
fops: &TTY_FOPS,
name: "tty",
}).expect("TTY major 4 registration");
// /dev/tty, /dev/console, /dev/ptmx: major 5, minors 0-2
register_chrdev_region(ChrdevRegion {
major: 5,
minor_base: 0,
minor_count: 3,
fops: &TTY_FOPS,
name: "tty_misc",
}).expect("TTY major 5 registration");
// PTY slaves: major 136, devpts (dynamically allocated minors, up to 1M)
register_chrdev_region(ChrdevRegion {
major: 136,
minor_base: 0,
minor_count: 1_048_576, // 2^20 = 1M PTYs via devpts
fops: &PTY_SLAVE_FOPS,
name: "pts",
}).expect("PTY slave registration (devpts)");
}
/// FileOps vtable type for TTY master / console / VT nodes (majors 4 and 5).
/// `open()` decodes the minor and dispatches to the owning `TtyDriver` (serial,
/// VT, or PTY-master allocator) via the `XArray<Arc<TtyDriver>>` registry;
/// read/write/poll/ioctl then route through the resolved driver's `TtyOps`.
// kernel-internal, not KABI — dispatched through the VFS `FileOps` trait object.
pub struct TtyFileOps;
impl FileOps for TtyFileOps {
// open / read / write / poll / ioctl / release as described below; each
// resolves the `TtyDriver` for the minor and forwards to its `TtyOps`.
}
/// The shared TTY `FileOps` table registered for majors 4 and 5. Coerces to
/// `&'static dyn FileOps` at `register_chrdev_region()`.
pub static TTY_FOPS: TtyFileOps = TtyFileOps;
/// FileOps vtable type for PTY slave nodes (`/dev/pts/N`, major 136). Delegates
/// read/write/poll to the owning `PtyPair`'s slave-side ring buffer.
// kernel-internal, not KABI — dispatched through the VFS `FileOps` trait object.
pub struct PtySlaveFileOps;
impl FileOps for PtySlaveFileOps {
// open resolves the devpts slave inode → `PtyPair`; read/write/poll drive
// the slave-side ring; ioctl handles the slave termios subset.
}
/// The shared PTY-slave `FileOps` table registered for major 136 (devpts).
pub static PTY_SLAVE_FOPS: PtySlaveFileOps = PtySlaveFileOps;
TTY_FOPS dispatches open() to the appropriate TTY driver based on the
minor number (serial driver, VT driver, or PTY master allocator). PTY_SLAVE_FOPS
delegates to the PtyPair's slave-side ring buffer. Minor-to-driver lookup uses the
per-driver TtyDriver registry (an XArray<Arc<TtyDriver>> keyed by minor range).
Serial device node creation (devtmpfs): When a serial port driver (8250/16550,
PL011, etc.) probes a UART, it calls tty_register_device(driver, port_index).
This calls devtmpfs_create_node() (Section 14.5)
to create /dev/ttyS<N> (major 4, minor 64+N) in the devtmpfs filesystem. The
device node inherits the standard permissions (0660, root:dialout) from the
ChrdevRegion registration. On serial port removal (hot-unplug or driver unbind),
tty_unregister_device() calls devtmpfs_remove_node() to remove the device node.
VT device nodes (/dev/tty0–/dev/tty63) are created statically at boot by
tty_register_chrdevs() and are never removed.
21.1.4 The devpts Pseudo-Filesystem¶
devpts is the pseudo-filesystem that provides /dev/pts/* device nodes for PTY slave
devices. It is the kernel component that bridges open(/dev/ptmx) to the creation of a
numbered /dev/pts/N inode visible to userspace. Without devpts, containers cannot have
isolated PTY namespaces — Docker, Kubernetes pods, and unshare --mount all depend on
per-mount-namespace devpts instances.
21.1.4.1 Filesystem Type and Superblock¶
devpts registers as a filesystem type (fs_type = "devpts") with the VFS
(Section 14.1). Each mount creates an independent superblock with its own
PTY index allocator and inode set:
/// devpts superblock — one per mount instance.
pub struct DevptsSuperblock {
/// Per-instance PTY index allocator. Bitmap-based, O(1) alloc/free.
/// Size is determined by the `max` mount option (default: 1048576).
pub index_bitmap: SpinLock<DynBitmap>,
/// Maximum PTY index for this instance (from `max=` mount option).
/// Range: 1–1048576. Default: 1048576 (2^20, matching Linux).
pub max_ptys: u32,
/// Permission mode for `/dev/pts/ptmx` within this mount.
/// From `ptmxmode=` mount option. Default: 0o000 (disabled).
pub ptmx_mode: u16,
/// UID assigned to newly created PTY slave inodes.
/// From `uid=` mount option. Default: UID of the mounting process.
pub default_uid: Uid,
/// GID assigned to newly created PTY slave inodes.
/// From `gid=` mount option. Default: GID of group "tty" (typically 5).
pub default_gid: Gid,
/// Permission mode for newly created PTY slave inodes.
/// From `mode=` mount option. Default: 0o620 (owner rw, group w).
pub default_mode: u16,
/// Back-reference to the mount namespace that owns this instance. Raw `u64`
/// ns id, matching the canonical namespace identity convention
/// ([Section 17.1](17-containers.md#namespace-architecture)).
pub mnt_ns_id: u64,
/// Active PTY pairs keyed by pts_index. Used for inode lookup on
/// `open("/dev/pts/N")` and for teardown on unmount.
pub active_ptys: XArray<Arc<PtyPair>>,
}
21.1.4.2 Mount Options¶
devpts supports the following mount options, matching Linux's fs/devpts/inode.c:
| Option | Type | Default | Description |
|---|---|---|---|
newinstance |
flag | (required for namespaced mounts) | Creates a new, isolated devpts instance. Without this flag, the mount joins the legacy singleton instance (compat only). Container runtimes always pass newinstance. |
max |
u32 | 1048576 | Maximum number of PTYs allocatable on this instance. Range: 1–1048576. |
ptmxmode |
octal | 0o000 | Permission mode for the /dev/pts/ptmx node within this mount. Set to 0o666 to allow unprivileged PTY allocation inside containers (the standard container runtime configuration). |
mode |
octal | 0o620 | Permission mode for newly created /dev/pts/N slave inodes. |
uid |
u32 | caller UID | Owner UID for new PTY slave inodes. |
gid |
u32 | GID of "tty" group | Group GID for new PTY slave inodes. Typically 5 (tty). |
/// Parsed devpts mount options.
pub struct DevptsMountOpts {
/// True if `newinstance` was specified. Required for namespace-scoped mounts.
pub new_instance: bool,
/// Maximum PTY count for this instance.
pub max: u32,
/// Permission mode for `/dev/pts/ptmx`.
pub ptmx_mode: u16,
/// Permission mode for `/dev/pts/N` slave nodes.
pub mode: u16,
/// Owner UID for slave nodes.
pub uid: Uid,
/// Group GID for slave nodes.
pub gid: Gid,
}
/// Parse devpts mount option string.
/// Returns error on invalid option names or out-of-range values.
fn devpts_parse_mount_opts(data: &[u8]) -> Result<DevptsMountOpts, Errno> {
// Parse comma-separated key=value pairs.
// Unrecognized options return -EINVAL (Linux compat).
// ...
}
21.1.4.3 Namespace Scoping¶
Since Linux 4.7 (commit eedf265a), each mount namespace gets its own devpts instance
when mounted with newinstance (Section 17.1). UmkaOS adopts this as the
sole mode for new mounts — the legacy single-instance mode exists only for the initial
root namespace's boot-time mount (compatibility with init scripts that predate
newinstance).
Namespace isolation guarantees:
- PTY indices are local to each devpts instance. Two containers can both have
/dev/pts/0without conflict — they refer to differentDevptsSuperblockinstances. open("/dev/pts/N")resolves through the calling process's mount namespace. A process in namespace A cannot access PTY slave nodes from namespace B's devpts mount.- When a mount namespace is destroyed, its devpts superblock is torn down: all active PTY pairs receive a hangup condition on the slave side, and the index bitmap is freed.
Container runtime integration:
OCI container runtimes (runc, crun) perform the following devpts setup during container creation, which UmkaOS supports identically to Linux:
unshare(CLONE_NEWNS)— create new mount namespace.mount("devpts", "/dev/pts", "devpts", 0, "newinstance,ptmxmode=0666,mode=0620,gid=5")— mount a fresh devpts instance.bind_mount("/dev/pts/ptmx", "/dev/ptmx")— ensure/dev/ptmxinside the container points to this instance's multiplexer, not the host's.
21.1.4.4 devpts Helpers and Typed File Private Data¶
The devpts implementation bridges an open file to its owning superblock and
manages the /dev/pts/N slave inodes. The PTY master/slave file descriptors
carry typed private data via FilePrivateData (stored behind VFS's opaque
OpenFile::private_data).
/// Resolve the devpts superblock backing an open `/dev/ptmx` (or
/// `/dev/pts/ptmx`) file. For `/dev/ptmx` this follows the bind mount to the
/// real devpts instance; for `/dev/pts/ptmx` the superblock is the parent
/// mount. Returns `Errno::ENODEV` if the file is not backed by a devpts mount.
fn devpts_resolve_superblock(file: &File) -> Result<Arc<DevptsSuperblock>, Errno>;
/// Resolve the devpts superblock that owns a live `PtyPair` (recorded at
/// allocation time). Infallible: the pair holds a strong reference to its
/// instance for its whole lifetime.
fn devpts_resolve_superblock_from_pty(pty: &PtyPair) -> Arc<DevptsSuperblock>;
/// Create the `/dev/pts/N` character-device inode (major `136 + idx / 256`,
/// minor `idx % 256`) owned by `sb.default_uid`/`default_gid` with mode
/// `sb.default_mode`, and link it into the devpts directory so `readdir` /
/// `stat` observe it.
fn devpts_create_slave_inode(
sb: &DevptsSuperblock,
pts_index: u32,
pty: &PtyPair,
) -> Result<(), Errno>;
/// Open the existing `/dev/pts/N` slave inode of `sb` with `flags`, returning a
/// new file object to install into the caller's fd table (used by
/// `TIOCGPTPEER`). Returns `Errno::EIO` if the slave was already torn down.
fn devpts_open_slave_inode(
sb: &DevptsSuperblock,
pts_index: u32,
flags: OpenFlags,
) -> Result<Arc<OpenFile>, Errno>;
/// Unlink the `/dev/pts/N` slave inode from the devpts directory on teardown.
fn devpts_remove_slave_inode(sb: &DevptsSuperblock, pts_index: u32);
/// Resolve the slave `TtyPort` for `pts_index` (the slave device's
/// controlling-terminal state, carrying `session`/`pgrp`) from its devpts
/// inode. Returns `None` if the slave was never opened as a controlling
/// terminal (no `TtyPort` bound). Used by `devpts_ptmx_release` to deliver the
/// POSIX hangup SIGHUP/SIGCONT to the slave's foreground process group — the
/// documented handoff for the one-way `TtyPort.driver_data → PtyPair` link.
fn devpts_slave_tty_port(sb: &DevptsSuperblock, pts_index: u32) -> Option<Arc<TtyPort>>;
/// Typed view of a TTY/PTY file descriptor's private data. UmkaOS VFS stores
/// per-file private data in `OpenFile::private_data`, an opaque `AtomicPtr<()>`
/// ([Section 14.1](14-vfs.md#virtual-filesystem-layer)); the TTY subsystem boxes one of these
/// variants behind that pointer and reinterprets it through the accessors. The
/// `file.private_data = FilePrivateData::…` and `.as_pty_master()` forms in this
/// section are shorthand for that typed store/load through the opaque pointer.
pub enum FilePrivateData {
/// PTY master side — owns the `PtyPair`.
PtyMaster(Arc<PtyPair>),
/// PTY slave side — shares the `PtyPair` opened via devpts.
PtySlave(Arc<PtyPair>),
}
impl FilePrivateData {
/// Borrow the `PtyPair` of a master fd. Debug-panics if this is not a
/// `PtyMaster` (reached only after master-fd dispatch).
pub fn as_pty_master(&self) -> &PtyPair;
}
21.1.4.5 PTY Allocation via /dev/ptmx¶
When a process opens /dev/ptmx (or /dev/pts/ptmx inside a container), the kernel
allocates a new PTY pair from the devpts instance associated with the caller's mount
namespace:
/// Called when userspace opens /dev/ptmx (major 5, minor 2) or
/// /dev/pts/ptmx (the per-instance ptmx node).
///
/// Returns a file descriptor for the PTY master side.
fn devpts_ptmx_open(file: &mut File) -> Result<(), Errno> {
// 1. Resolve the devpts superblock from the mount point.
// For /dev/ptmx: follow the bind mount to find the real devpts instance.
// For /dev/pts/ptmx: the superblock is the parent directory's mount.
let sb = devpts_resolve_superblock(file)?;
// 2. Allocate a PTY index from the per-instance bitmap.
let pts_index = {
let mut bitmap = sb.index_bitmap.lock();
let idx = bitmap.find_first_zero()
.ok_or(Errno::ENOSPC)?; // all PTY slots full
if idx >= sb.max_ptys as usize {
return Err(Errno::ENOSPC);
}
bitmap.set(idx);
idx as u32
};
// 3. Create the PtyPair (ring buffers, termios state, flow control).
let pty = PtyPair::new(pts_index, current_task().mnt_ns_id)?;
// 4. Create the /dev/pts/N inode in this devpts instance.
devpts_create_slave_inode(&sb, pts_index, &pty)?;
// 5. Register the PtyPair in the superblock's active set.
sb.active_ptys.store(pts_index as u64, pty.clone());
// 6. Set up the master file descriptor.
file.private_data = FilePrivateData::PtyMaster(pty);
Ok(())
}
Index allocation: The DynBitmap is a dynamically-sized bitmap (allocated at mount
time based on max option). find_first_zero() scans for the lowest available index —
O(N/64) in the worst case (scanning 64-bit words), O(1) amortized with a cached hint of
the last-freed position. The bitmap is protected by a SpinLock because PTY allocation
is a warm-path operation (not per-packet/per-syscall) and contention is low.
Slave inode creation: devpts_create_slave_inode() creates a character device inode
with major 136 + (pts_index / 256), minor pts_index % 256, owned by sb.default_uid /
sb.default_gid with mode sb.default_mode. The inode is inserted into the devpts
directory so that readdir("/dev/pts") and stat("/dev/pts/N") work correctly.
21.1.4.6 PTY Teardown¶
When the PTY master file descriptor is closed (last reference dropped):
/// Canonical terminal-hangup primitive (the `tty_hangup()` the process layer
/// references, [Section 8.7](08-process.md#process-groups-and-sessions)). Performs the full POSIX
/// controlling-terminal hangup sequence when a terminal is closed by its last
/// opener (modem hangup, or the terminal emulator closing the PTY master),
/// matching Linux `__tty_hangup` + `disassociate_ctty`:
/// (1) SIGHUP then (2) SIGCONT to the controlling session's foreground process
/// group; (3) clear the terminal-side associations (`TtyPort.session`/`pgrp`→0);
/// (4) clear the controlling session's `controlling_terminal` and every session
/// member's cached `Process.tty`. Steps 3-4 route entirely through the existing
/// process-management surface (`send_signal_to_pgrp`,
/// `tty_clear_ctty_associations`, `session_clear_cached_tty`,
/// [Section 8.2](08-process.md#process-lifecycle-teardown--step-11c-session-leader-controlling-terminal-disassociation));
/// no TTY/pgrp/session state is owned by any new entity. A terminal with no
/// foreground group (`pgrp == 0`) or no controlling session (`session == 0`) is
/// a no-op for the corresponding step.
fn tty_hangup(port: &Arc<TtyPort>) {
// POSIX order: signal the foreground group FIRST, then break associations.
let pgid = port.pgrp.load(Ordering::Acquire);
if pgid != 0 {
if let Some(group) = PROCESS_GROUPS.get(pgid) {
send_signal_to_pgrp(&group, SIGHUP);
send_signal_to_pgrp(&group, SIGCONT);
}
}
// Terminal-side clear: TtyPort.session and TtyPort.pgrp → 0. Subsequent
// tcgetpgrp()/TIOCGSID on any fd for this terminal report ENOTTY.
let sid = port.session.load(Ordering::Acquire);
tty_clear_ctty_associations(port);
// Session-side clear: controlling_terminal → None and every member's cached
// Process.tty → None (the disassociation contract of that field).
if sid != 0 {
if let Some(session) = SESSIONS.load(sid) {
session.controlling_terminal.store(None);
session_clear_cached_tty(&session);
}
}
// Wake any reader blocked in `n_tty_read` so it observes EOF. Set the
// hangup flag (Release) BEFORE the wake so the woken reader's
// `tty_read_ready` predicate (Acquire) sees it and drains-then-EOFs.
port.hung_up.store(1, Ordering::Release);
port.read_wait.wake_up_all();
}
/// Called when the last reference to the PTY master fd is dropped.
fn devpts_ptmx_release(file: &File) {
let pty = file.private_data.as_pty_master();
let sb = devpts_resolve_superblock_from_pty(pty);
// 1. Send hangup to the slave side: set the ring `hung_up` flag and wake
// every blocked slave reader so it returns EOF.
pty.hangup_slave();
// 1b. Perform the full POSIX controlling-terminal hangup on the slave's
// TtyPort: SIGHUP + SIGCONT to the foreground group AND disassociation
// of the controlling session (Session::controlling_terminal → None,
// terminal-side associations cleared). The session/pgrp state lives on
// the slave's `TtyPort` (PtyPair has no back-link — this is the
// documented handoff). Resolve the slave's TtyPort from its devpts inode
// BEFORE step 2 removes the inode. Without this, a master close would
// signal the foreground group but leave the slave session's
// controlling_terminal dangling and tcgetpgrp() would not return ENOTTY.
if let Some(slave_port) = devpts_slave_tty_port(&sb, pty.pts_index) {
tty_hangup(&slave_port);
}
// 2. Remove the /dev/pts/N inode from the devpts directory.
devpts_remove_slave_inode(&sb, pty.pts_index);
// 3. Free the PTY index back to the bitmap.
{
let mut bitmap = sb.index_bitmap.lock();
bitmap.clear(pty.pts_index as usize);
}
// 4. Remove from the active PTY set.
sb.active_ptys.remove(pty.pts_index as u64);
// 5. PtyPair is dropped when Arc refcount reaches zero
// (ring buffer pages are freed, state arena slot is released).
}
21.1.4.7 TIOCGPTPEER — Open Slave from Master FD¶
Linux 4.13 added ioctl(master_fd, TIOCGPTPEER, flags) which returns an open file
descriptor to the PTY slave without requiring the caller to know the slave's path. This
is critical for containers where the slave path in the host's filesystem namespace may
differ from the container's view:
/// TIOCGPTPEER ioctl value (Linux ABI).
pub const TIOCGPTPEER: u32 = 0x5441;
/// Handle TIOCGPTPEER: open the slave side of a PTY from its master fd.
///
/// This avoids the race condition in the traditional open("/dev/pts/N") path
/// and works correctly across mount namespaces (the slave fd is opened in
/// the devpts instance of the master, not the caller's mount namespace).
fn pty_ioctl_tiocgptpeer(master: &PtyPair, flags: u32) -> Result<FileDesc, Errno> {
let sb = devpts_resolve_superblock_from_pty(master);
let inode = sb.active_ptys.load(master.pts_index as u64)
.ok_or(Errno::EIO)?; // slave already torn down
let open_flags = OpenFlags::from_bits_truncate(flags);
let slave_file = devpts_open_slave_inode(&sb, master.pts_index, open_flags)?;
Ok(current_task().fd_table.install(slave_file)?)
}
21.1.4.8 TIOCPKT — Packet Mode¶
Packet mode lets a PTY master learn about slave-side flow-control and flush
events that the data stream itself cannot express — the mechanism rlogin,
telnetd, screen and expect-class programs use to propagate ^S/^Q and
buffer flushes to a remote peer. It is Linux observable contract: the ioctl
values, the status-bit values, the one-control-byte-alone read shape, and the
EPOLLPRI signalling are all fixed. The MECHANISM below — where UmkaOS
composes the prefix in its ring architecture, the fetch_or/swap protocol,
and the zero-copy revocation — is native.
/// Enable/disable packet mode. MASTER fd only. Argument is a pointer to an
/// `int`: nonzero enables, zero disables. Linux ABI
/// (`include/uapi/asm-generic/ioctls.h`).
pub const TIOCPKT: u32 = 0x5420;
/// Read back packet-mode state (1 or 0). MASTER fd only.
/// `_IOR('T', 0x38, int)`.
pub const TIOCGPKT: u32 = 0x80045438;
// TIOCPKT_* status bits, delivered as ONE control byte to the master.
/// No control event — an ordinary data read's prefix byte.
pub const TIOCPKT_DATA: u8 = 0x00;
/// The slave's input queue was flushed.
pub const TIOCPKT_FLUSHREAD: u8 = 0x01;
/// The slave's output queue was flushed.
pub const TIOCPKT_FLUSHWRITE: u8 = 0x02;
/// Output to the slave was stopped (VSTOP / `^S`).
pub const TIOCPKT_STOP: u8 = 0x04;
/// Output to the slave was restarted (VSTART / `^Q`).
pub const TIOCPKT_START: u8 = 0x08;
/// The slave is no longer doing `^S`/`^Q` flow control (IXON cleared).
pub const TIOCPKT_NOSTOP: u8 = 0x10;
/// The slave is now doing `^S`/`^Q` flow control (IXON set).
pub const TIOCPKT_DOSTOP: u8 = 0x20;
/// The slave changed termios or window size while EXTPROC is set.
pub const TIOCPKT_IOCTL: u8 = 0x40;
State. Two fields on PtyPair (declared above): pkt_mode: AtomicBool
(default false) and pkt_status: AtomicU8 (pending control bits). Every
producer below does pkt_status.fetch_or(bits, Release) and then wakes the
master's readers and pollers through the EXISTING slave→master
data-available wake path — packet events reuse the readiness plumbing, they
do not add a second one. The master's read consumes the whole set atomically
with swap(0, AcqRel), so no bit is delivered twice and none is lost against
a concurrent producer.
Ioctl semantics.
TIOCPKTis MASTER-fd only; on the slave it returnsENOTTY. A nonzerointargument enables packet mode; zero disables it AND clearspkt_status(a disabled pair must not deliver a stale control byte if it is later re-enabled).TIOCGPKTis MASTER-fd only and writes1or0to the userint.
Master read with pkt_mode set. Exactly one of three shapes:
pkt_status != 0→ the read returns EXACTLY ONE byte, the swapped-out status, and NO data bytes in the same read. A control byte never shares a read with data, so the reader can always distinguish them by length and by theEPOLLPRIthat accompanied them.- otherwise → a data read returns one
0x00(TIOCPKT_DATA) prefix byte followed by up tobuf.len() - 1bytes drained fromslave_tx. - a zero-length buffer returns
Ok(0), consuming nothing.
Master poll. While pkt_status != 0 the master reports
EPOLLIN | EPOLLPRI, in addition to normal data readiness. EPOLLPRI is what
lets a select/poll-based relay treat a control byte as out-of-band.
Producers (exact hook sites in this section):
| Event | Bits | Where |
|---|---|---|
| Slave processed VSTOP / VSTART | TIOCPKT_STOP / TIOCPKT_START |
tty_flow_ctrl on the slave port |
| Slave input queue flushed | TIOCPKT_FLUSHREAD |
the !NOFLSH signal-flush block in n_tty_receive_buf, and TCFLSH with TCIFLUSH/TCIOFLUSH |
| Slave output queue flushed | TIOCPKT_FLUSHWRITE |
tty_flush_output, and TCFLSH with TCOFLUSH/TCIOFLUSH |
Slave tcsetattr newly SETS IXON |
TIOCPKT_DOSTOP |
slave tcsetattr |
Slave tcsetattr newly CLEARS IXON |
TIOCPKT_NOSTOP |
slave tcsetattr |
Slave tcsetattr while EXTPROC is set in c_lflag |
TIOCPKT_IOCTL |
slave tcsetattr |
DOSTOP/NOSTOP fire on a TRANSITION only — a tcsetattr that leaves IXON
unchanged posts neither.
Zero-copy interaction. Packet framing is composed by the kernel, so it is
incompatible with a bypassed kernel data path. Enabling packet mode on a pair
that is in zero-copy direct mode REVOKES zero-copy through the same
user-mapping revocation path as the ICANON-set transition (the kernel data
path resumes; both sides must repeat the consent handshake to re-enable).
Conversely, PTY_REQ_DIRECT / TIOCSETZCOPY attempted while pkt_mode is
set is refused with the same errno the raw-mode-only restriction uses.
21.1.5 Asynchronous Line Disciplines (N_TTY)¶
The line discipline (N_TTY) translates raw characters into canonical input (handling backspace, line buffering) and generates signals (translating Ctrl+C into SIGINT).
In Linux, this processing happens synchronously during the write() or read() syscall, while holding the tty_mutex.
In UmkaOS, line discipline processing is asynchronous and decoupled from the data path, and it runs in the line-discipline provider's current domain — the Core domain in the default co-located deployment, or an isolated Tier 1/Tier 2 domain when the provider is bound there (never a fixed tier; see the header Deployment placement note).
- When the user types
Ctrl+C, the raw byte (0x03) is enqueued into the port's per-port ingress ring (TtyPort.input_ring) and the port is marked ready — no character is processed inline, even when the driver and the discipline share a domain. This enqueue-and-wake is the ONLY ingress seam: a same-domain binding resolves it to a direct push + worker wake, a cross-domain binding resolves it to a ring submission. (Inline processing on the data path is exactly what the Linuxtty_mutexdesign does and what the async design exists to avoid; a same-domain fast path must not silently revert to it.) - The line discipline's TTY worker, running in the provider's domain, consumes the port's ingress ring, runs the
N_TTYrules against the port'stermiosstate, and either commits processed output into the port's canonical read state (TtyPort.ntty) or generates theSIGINTsignal to the foreground process group. - The foreground application reads processed input from the port's canonical read state.
The worker is the sole consumer of each port's ingress ring and the sole writer of that port's canonical read state; concurrent readers and the worker are serialized by the port's canonical-state lock (TtyPort.ntty), which replaces Linux's global tty_mutex with per-port locking — the actual scalability win. Producers into one ingress ring are serialized by the port's ingress_lock, so several dup'd fds writing at once form one logical producer (this is the single home for the eb6ddf8b6229 multi-producer defect).
Async TTY Worker Thread Configuration:
-
Count: One worker thread per physical CPU socket (NUMA node), not per CPU. Named
tty_worker/{socket_id}. Rationale: TTY throughput is not CPU-intensive (character processing + application wakeup); socket-scoped workers provide NUMA locality without per-CPU overhead. In a cross-domain deployment the "worker" is the provider domain's consumer thread reached over the ring; the drain contract below is identical. -
Priority:
SCHED_OTHER(normal timesharing) at nice -5. This gives TTY processing a small priority boost over typical user tasks (nice 0) without impacting RT workloads. Interactive terminal responsiveness is maintained because terminal input wakeup latency is dominated by the nice-level scheduling latency (~0.5–2ms), not TTY processing time. -
Ready-port set and fairness: each worker owns a bounded ready-port set — an intrusive FIFO of the ports on its node that have pending ingress, threaded through
TtyPort.ready_linkand deduplicated byTtyPort.readyso each port appears at most once (the set's size is therefore bounded by the node's port count, with zero per-enqueue allocation). Each drain pass processes a ready port for at mostTTY_DRAIN_BUDGETbytes, then moves to the next ready port; a port whose ingress ring is still non-empty after its budget is requeued at the tail. This gives round-robin fairness across ports and prevents one high-traffic PTY (bulk container logging) from starving an interactive terminal.TTY_DRAIN_BUDGETis a fairness knob (tunable viasysctl kernel.tty.drain_budget, analogous tonapi_budget), NOT a capacity limit. -
Per-port backpressure and overflow: input is bounded per port by the ingress ring (
SpscRing<u8, 4096>), not by any global queue. When a port's ingress ring is full, the enqueue seam applies backpressure at the source: a serial driver is asked tothrottle()(assert RTS / stop RX) and a PTY writer'swrite()returns short / blocks onwrite_wait, exactly as a full pipe does — so bytes are held at the producer, never silently dropped mid-stream. If a source that cannot be throttled (a hardware UART FIFO overrun) forces a drop, the port'sinput_overruncounter is incremented and surfaced via umkafs at/ukfs/kernel/tty/<dev>/input_overrun; recovery is automatic once the worker drains the ring below capacity. -
Shutdown: The worker is a kernel task; it receives a stop request and joins cleanly during system shutdown after all TTY devices have been closed.
-
Wake mechanism: The worker thread sleeps on a per-NUMA-node
WaitQueuebetween drain passes. The pending-data flag and wake call are issued from interrupt context (serial UART IRQ, PTY write path) — both paths must be IRQ-safe.
/// Per-port fairness budget for one drain pass, in bytes. A scheduling knob
/// (like `napi_budget`), tunable via `sysctl kernel.tty.drain_budget`; NOT a
/// capacity limit or a hardware-discovered quantity. Default chosen so an
/// interactive line's whole input burst clears in one pass while a bulk PTY
/// yields periodically.
pub const TTY_DRAIN_BUDGET: usize = 4096;
/// Per-NUMA-node TTY worker state. Indexed by NUMA node ID.
/// Boot-allocated: `nr_numa_nodes` entries discovered from ACPI SRAT / device tree.
/// `Box<[TtyWorkerState]>` because NUMA node count is runtime-discovered —
/// a static `[TtyWorkerState; N]` would require a compile-time constant.
/// Initialized once during TTY subsystem init; never resized.
pub static TTY_WORKER_STATES: BootOnceCell<Box<[TtyWorkerState]>> = BootOnceCell::new();
/// Drain the ready-port set for the given NUMA node. Called from the TTY worker
/// main loop after `has_pending` is observed true. Pops ready ports in FIFO
/// order, dispatches each port's pending ingress through its line discipline
/// under a per-port budget, and requeues a port that is not fully drained. This
/// is the SINGLE quiescence/drain boundary that also serves live evolution
/// (drain in-flight line-discipline operations,
/// [Section 13.18](13-device-classes.md#live-kernel-evolution)) and tier migration (rebind quiesce, below);
/// there is no second drain mechanism.
pub fn tty_drain_rings(numa_id: usize) {
let state = &TTY_WORKER_STATES.get().expect("TTY workers initialized")[numa_id];
// Pop ready ports until the set is empty. A port popped here is removed
// from the set and its `ready` flag cleared BEFORE draining, so a producer
// that enqueues more bytes mid-drain re-marks it ready (no lost wake).
while let Some(port) = state.pop_ready() {
// `port` is an `Arc<TtyPort>` held for the pass, so the port cannot be
// torn down under the worker (teardown quiesces the ready set first).
let mut budget = TTY_DRAIN_BUDGET;
let mut scratch = [0u8; 256];
// Snapshot the ldisc binding once per pass (TIOCSETD/rebind quiesce the
// port before swapping, so the binding is stable across this pass).
let ldisc = port.ldisc.lock().clone();
loop {
if budget == 0 {
// Budget exhausted with data still pending → requeue at tail
// for round-robin fairness, yield to the next ready port.
if !port.input_ring.is_empty() {
state.mark_ready(&port);
}
break;
}
let n = {
// Dequeue a chunk from the port's ingress ring (worker is the
// sole consumer). No lock needed on the consumer side of SPSC.
let mut k = 0;
while k < scratch.len() && k < budget {
match port.input_ring.try_pop() {
Ok(b) => { scratch[k] = b; k += 1; }
Err(RingError::Empty) => break,
}
}
k
};
if n == 0 {
break; // ring drained
}
budget -= n;
// Dispatch through the line discipline. Same-domain → direct call;
// different-domain → ring submission. The port is passed by its
// generation-safe id, never as a borrowed `&TtyPort` across a
// domain boundary (see `LdiscBinding`).
ldisc.receive_buf(port.port_id, &scratch[..n]);
}
}
}
/// Driver-facing operations the TTY core dispatches through for any TTY line
/// (serial, VT, PTY). Reached through `TtyDriver::ops` (a `TtyOpsBinding`,
/// below). For a hardware serial driver deployed cross-domain these calls are
/// forwarded across the KABI boundary to the driver's `SerialTtyOps` vtable
/// (see §SerialTtyOps KABI); a driver co-located in the TTY module's domain is
/// called directly. `&self` methods use interior mutability — per-line driver
/// state is never mutated through `&mut self` across a domain boundary.
pub trait TtyOps: Send + Sync {
/// Open the TTY line identified by `minor`, allocating per-line driver state.
fn open(&self, tty: &TtyPort, minor: u32) -> Result<(), KernelError>;
/// Final close of the line (last fd released); release driver state.
fn close(&self, tty: &TtyPort);
/// Queue `buf` for transmission; returns the number of bytes accepted (may
/// be short when the driver TX ring is full).
fn write(&self, tty: &TtyPort, buf: &[u8]) -> usize;
/// Bytes of room currently available in the driver's write buffer.
fn write_room(&self, tty: &TtyPort) -> usize;
/// Apply new line settings (baud, framing, flow control); `old` is the prior
/// termios for diffing.
fn set_termios(&self, tty: &TtyPort, old: &Termios);
/// Driver-specific ioctl (`TIOCMGET`, `TIOCGSERIAL`, …). Returns
/// `KernelError::ENOTTY` for commands the driver does not handle so the core
/// can apply generic handling.
fn ioctl(&self, tty: &TtyPort, cmd: u32, arg: usize) -> Result<i32, KernelError>;
/// Flow control: ask the driver to stop delivering received data.
fn throttle(&self, tty: &TtyPort);
/// Resume delivery after `throttle`.
fn unthrottle(&self, tty: &TtyPort);
}
/// TTY driver descriptor. Each driver registers with the TTY core at init
/// time and is stored in DRIVERS: XArray<Arc<TtyDriver>> keyed by major
/// number. Minor-to-driver lookup resolves the appropriate driver for
/// `open(/dev/ttyS*, /dev/tty*, /dev/pts/*)` operations.
pub struct TtyDriver {
/// Human-readable driver name (e.g., "serial", "pty_master", "pty_slave").
pub name: &'static str,
/// Major device number (e.g., 4 for /dev/ttyS*, 136 for /dev/pts/*).
/// u16 matches Linux's `MAJOR()` range (0-4095, 12-bit) and the dev_t
/// encoding where major occupies bits [8:19] (12 bits).
pub major: u16,
/// First minor device number owned by this driver.
pub minor_start: u32,
/// Number of device instances managed by this driver.
pub num_devices: u32,
/// Rebindable binding to the driver's `TtyOps`, resolved at bind time by
/// the domain service (the landed R7 `Mount::resolve_aspace_ops` precedent,
/// [Section 14.6](14-vfs.md#mount-tree-data-structures-and-operations)): a driver co-located in
/// the TTY module's domain binds `Direct`; a driver isolated in a Tier 1/2
/// domain binds `Ring`. Promotion/demotion rebinds this under the driver's
/// quiesce ([Section 13.18](13-device-classes.md#live-kernel-evolution)); a raw `&'static dyn TtyOps` is NOT
/// used because it would hard-wire same-domain dispatch and forbid isolated
/// deployment.
pub ops: TtyOpsBinding,
}
/// Bind-time-resolved transport to a driver's `TtyOps`. Mirrors the two arms of
/// the ring/direct model: same domain → direct vtable call; different domain →
/// KABI ring dispatch to the driver's `SerialTtyOps` (§SerialTtyOps KABI). The
/// core dispatches through this binding, never through a fixed tier.
pub enum TtyOpsBinding {
/// Driver shares the TTY core's domain: direct `&'static dyn` dispatch.
Direct(&'static dyn TtyOps),
/// Driver is in a different domain: dispatch over the ring transport. The
/// handle carries the provider generation, so a driver reloaded or migrated
/// out from under the binding is rejected (`StaleHandle`) on the next call
/// rather than dispatched into a dead domain
/// ([Section 12.7](12-kabi.md#kabi-service-dependency-resolution)).
Ring(KabiServiceHandle),
}
/// State for one NUMA node's TTY worker thread.
pub struct TtyWorkerState {
/// Wait queue: worker sleeps here when no TTY on this node has pending data.
pub wq: WaitQueue,
/// Set to true (Release) before each `wq.notify_one()`. Cleared (Relaxed)
/// at the start of each drain pass. Prevents missed wake-ups in the race
/// where data arrives after the drain but before the worker re-enters sleep.
pub has_pending: AtomicBool,
/// Bounded ready-port set: the ports on this node with pending ingress, as
/// an intrusive FIFO threaded through each port's embedded `ready_link`
/// (same pattern as `InputDeviceEntry.clients`, [Section 21.3](#input-subsystem)).
/// Guarded for MPSC push (any producer CPU) and single-consumer pop (this
/// worker). Each port appears at most once (deduped by `TtyPort.ready`), so
/// the set is bounded by the node's live-port count with zero per-enqueue
/// allocation. Queued ports stay alive by their open refcount; teardown
/// evicts a port under its ingress quiesce before dropping it.
pub ready: SpinLock<IntrusiveList<TtyPort>>,
/// The kthread handle for this worker.
pub thread: Arc<Task>,
}
impl TtyWorkerState {
/// Mark `port` ready and link it into the set if it is not already there.
/// Called by the ingress seam. Idempotent via `TtyPort.ready` (a CAS
/// false→true): only the transition that wins the CAS links the port, so
/// concurrent producers on several fds of one port enqueue it exactly once.
pub fn mark_ready(&self, port: &Arc<TtyPort>) {
if !port.ready.swap(true, Ordering::AcqRel) {
// Link the port's embedded `ready_link` at the tail (intrusive; no
// allocation). The port outlives the link — it is open with pending
// data, and eviction happens under the ingress quiesce.
self.ready.lock().push_back(port);
}
}
/// Pop the next ready port (FIFO) and clear its `ready` flag, or `None` if
/// the set is empty. Resolves the unlinked port to an `Arc` via `TTY_PORTS`
/// (pinning it for the drain pass). Clearing the flag BEFORE the caller
/// drains the ring is what makes a concurrent mid-drain enqueue re-mark the
/// port (no lost wake).
pub fn pop_ready(&self) -> Option<Arc<TtyPort>> {
let port_id = self.ready.lock().pop_front()?.port_id;
let port = TTY_PORTS.load(port_id.0)?;
port.ready.store(false, Ordering::Release);
Some(port)
}
}
/// The ingress seam — the ONLY way raw bytes enter a line discipline. Called by
/// a driver RX path (serial UART IRQ, PTY master write, VT keyboard handoff)
/// after it has bytes for `port`. Enqueue-and-wake ONLY: it pushes into the
/// port's ingress ring under `ingress_lock` (serializing multiple producers on
/// one port into one logical producer) and marks the port ready, then wakes the
/// node's worker. It never runs `receive_buf` inline, even same-domain — that
/// keeps the async/ring design intact regardless of where the discipline is
/// bound. Returns the number of bytes accepted; a short return signals the
/// caller to apply backpressure (serial `throttle()`, PTY writer blocks).
/// IRQ-safe: only a spinlock, the SPSC push, an atomic, and the IRQ-safe wake.
///
/// **Framed-port producer contract (normative).** On a port whose
/// `ingress_framed` is `true`, the SOURCE encodes line errors in-band before
/// calling this function, so the byte ring carries data and error reports in
/// one stream and needs no parallel per-byte flag array:
///
/// - clean byte `b != 0xFF` → `[b]`
/// - clean `0xFF` → `[0xFF][0x00][0xFF]` (escape; `0x00` is the "no error" flag)
/// - error byte → `[0xFF][flag][b]`, `flag` from the EXISTING error table
/// ([Section 21.1](#tty-and-pty-subsystem--serial-service-provider-cluster-wide-serial-access)): `0x01` parity,
/// `0x02` framing, `0x04` overrun, `0x08` break (break carries `b = 0x00`)
///
/// This is ONE encoding, shared verbatim with the peer-protocol `RxData`
/// stream — the remote bridge passes its already-encoded payload through
/// unchanged. The whole 1-3-byte sequence is pushed contiguously inside a
/// SINGLE `ingress_lock` critical section: if the ring cannot hold the whole
/// sequence, the whole sequence is dropped and `input_overrun` is incremented.
/// A PARTIAL escape is never enqueued — a truncated `[0xFF][flag]` would make
/// the following clean byte decode as an error byte.
///
/// On a non-framed port (`ingress_framed == false`) every byte is data,
/// `0xFF` included; no escaping is applied or expected.
///
/// A framed source calls this ONCE PER ENCODED SEQUENCE (`buf.len()` is 1, 2,
/// or 3) so the all-or-nothing rule is expressible as a capacity precheck
/// under the same guard: the ring is 4096 bytes and `len()` is the producer's
/// own count, so `INPUT_RING_CAPACITY - len()` is the space this producer is
/// guaranteed (the consumer only ever frees more). A short return therefore
/// means "sequence dropped", never "sequence truncated".
pub fn tty_ingress_enqueue(port: &Arc<TtyPort>, buf: &[u8]) -> usize {
let mut accepted = 0;
{
let _g = port.ingress_lock.lock();
if port.ingress_framed
&& (INPUT_RING_CAPACITY - port.input_ring.len()) < buf.len() as u32
{
// Whole-sequence-or-nothing: never enqueue half an escape — a
// truncated `[0xFF][flag]` would make the NEXT clean byte decode
// as an error byte. Charge the dropped bytes to the ring counter.
port.input_overrun
.fetch_add(buf.len() as u64, Ordering::Relaxed);
return 0;
}
for &b in buf {
if port.input_ring.try_push(b).is_err() {
break; // ring full → caller applies backpressure at the source
}
accepted += 1;
}
}
if accepted > 0 {
let numa = port.numa_node as usize;
let state = &TTY_WORKER_STATES.get().expect("TTY workers initialized")[numa];
state.mark_ready(port);
tty_worker_wake(numa);
}
accepted
}
/// Called from interrupt/IRQ context (serial UART IRQ, PTY write) when new data
/// has been enqueued into a port's ingress ring on `numa_id`.
/// IRQ-safe: uses only atomics and the IRQ-safe WaitQueue notify path.
pub fn tty_worker_wake(numa_id: usize) {
// `BootOnceCell` exposes no `Index`/`Deref` — resolve via `.get()`, index the
// boxed slice, and borrow the element with `&`. (This differs from the
// `console` file's `KLOG_RING.get().copied()`: that cell's payload is a
// `&'static KlogRing` pointer, so `.copied()` yields the pointer by value;
// here the payload is a boxed slice of plain `TtyWorkerState` structs, which
// are neither `Copy` nor `AsRef`, so the element is taken by reference.)
let state = &TTY_WORKER_STATES.get().expect("TTY workers initialized")[numa_id];
state.has_pending.store(true, Release); // (1) publish: data is ready
state.wq.notify_one(); // (2) wake: interrupt worker sleep
}
/// TTY worker main loop (kthread).
pub fn tty_worker_main(numa_id: usize) -> ! {
let state = &TTY_WORKER_STATES.get().expect("TTY workers initialized")[numa_id];
loop {
// Wait until has_pending is true. The Acquire load pairs with the
// Release store in `tty_worker_wake()`, ensuring all ring data written
// before the wake call is visible here after the load.
state.wq.wait_until(|| state.has_pending.load(Acquire));
state.has_pending.store(false, Relaxed); // clear before drain
tty_drain_rings(numa_id);
// If new data arrived during the drain (race: producer enqueued data,
// set has_pending=true, called notify_one AFTER we cleared it but
// BEFORE tty_drain_rings completed), the loop continues immediately
// because has_pending was set again. No data is lost.
}
}
The Release/Acquire pair on has_pending closes the classic "missed wake-up"
race: any data written to a ring before tty_worker_wake() is called is guaranteed
visible to the worker after it observes has_pending = true.
21.1.5.1 Port identity and the line-discipline binding¶
Every cross-module TTY edge carries a generation-safe u64 port identity, never
a borrowed &TtyPort or an Arc<TtyPort> across a potential domain boundary:
/// Generation-safe kernel identity of a TTY port. `u64` (kernel-internal id,
/// never reused within the operational lifetime): the low bits index the
/// `TTY_PORTS` registry, and the whole value is validated on every cross-domain
/// dereference (opaque-handle ABA discipline — a stale/reused slot yields a
/// `Stale` error, never a use-after-free). Cross-module edges — a port handed
/// to a `receive_buf` running in another domain, a rebind message — carry this
/// id; the receiver resolves it through `TTY_PORTS` inside its own domain.
#[repr(transparent)]
#[derive(Clone, Copy)]
pub struct TtyPortId(pub u64);
/// Warm-path registry resolving a `TtyPortId` to its `Arc<TtyPort>`. Integer
/// key → `XArray` ([Section 3.13](03-concurrency.md#collection-usage-policy)). Populated at port open,
/// removed after the port's rebind/teardown quiesce.
pub static TTY_PORTS: XArray<Arc<TtyPort>> = XArray::new();
/// Rebindable binding from a port to its active line discipline. Resolved at
/// bind time by the domain service, mirroring `TtyOpsBinding`: a same-domain
/// discipline is a direct `&'static dyn LineDisciplineOps` module vtable (the
/// N_TTY per-port state lives on the port, so the vtable is stateless and needs
/// no `Arc`); a cross-domain discipline (a Tier-2 terminal multiplexer's custom
/// ldisc) is reached over the ring. `Arc<dyn LineDisciplineOps>` is NOT stored
/// here — it may not cross a domain boundary. Written ONLY under the port's
/// TIOCSETD / rebind quiesce, and carries a `generation` bumped on every swap
/// so a discipline replaced mid-flight is rejected rather than used after its
/// `close()`.
#[derive(Clone)]
pub struct LdiscBinding {
/// Transport-resolved discipline surface.
pub transport: LdiscTransport,
/// Line discipline ID (N_TTY=0, N_PPP=3, …).
pub ldisc_id: i32,
/// Bumped on every TIOCSETD swap / domain rebind. A dispatch snapshot whose
/// generation no longer matches the port's live binding is discarded.
pub generation: u64,
}
#[derive(Clone)]
pub enum LdiscTransport {
/// Discipline shares the port's domain: direct stateless-vtable dispatch.
Direct(&'static dyn LineDisciplineOps),
/// Discipline is in a different domain: dispatch over the ring transport.
Ring(KabiServiceHandle),
}
impl LdiscBinding {
/// Deliver a batch of raw bytes to the discipline. Same-domain → direct
/// call passing the port id (the callee resolves `TTY_PORTS` locally);
/// different-domain → ring submission of `(port_id, bytes)`.
pub fn receive_buf(&self, port_id: TtyPortId, buf: &[u8]) {
match &self.transport {
LdiscTransport::Direct(ops) => {
if let Some(port) = TTY_PORTS.load(port_id.0) {
ops.receive_buf(&port, buf);
}
}
LdiscTransport::Ring(handle) => {
// kabi_call! selects the ring transport; the discipline's
// domain resolves the port id and runs N_TTY there.
let _ = kabi_call!(handle, LineDisciplineOps::RECEIVE_BUF, port_id, buf);
}
}
}
}
21.1.6 Serial TTY — Full POSIX termios and Modem Control¶
This section answers: "can minicom run on UmkaOS?"
The POSIX termios interface controls the serial line discipline: character size, baud rate, parity, flow control, canonical vs raw mode, and modem control signals. It applies to both serial UART ports (/dev/ttyS0, /dev/ttyUSB0) and to PTYs (via the PTY slave). The preceding sub-sections ("The Problem" through "Character Device Registration") cover PTY; this section covers the serial-specific parts needed for programs like minicom, picocom, and screen.
21.1.6.1 struct termios¶
The full POSIX struct termios as exposed to userspace (Linux asm-generic/termbits.h layout, required for binary compat):
/// POSIX struct termios — character device terminal settings.
/// Layout matches Linux's `struct termios2` for TCGETS2/TCSETS2 ioctls.
/// The kernel-internal representation is `KernelTermios`; this is the
/// userspace-visible layout placed at ioctl argument pointers.
#[repr(C)]
pub struct Termios {
/// Input mode flags.
pub c_iflag: u32,
/// Output mode flags.
pub c_oflag: u32,
/// Control mode flags.
pub c_cflag: u32,
/// Local mode flags.
pub c_lflag: u32,
/// Line discipline index (N_TTY = 0).
pub c_line: u8,
/// Special character array (NCCS = 19 for Linux/POSIX).
pub c_cc: [u8; 19],
/// Input baud rate (encoded as Bxxx constant OR an actual numeric rate
/// when using TCSETS2/BOTHER — see §21.1.4.2).
pub c_ispeed: u32,
/// Output baud rate.
pub c_ospeed: u32,
}
// Termios: u32(4)*4 + u8(1) + [u8;19](19) + u32(4)*2 = 44 bytes.
// Userspace ABI struct (TCGETS2/TCSETS2 ioctl argument pointer).
const_assert!(core::mem::size_of::<Termios>() == 44);
// c_iflag bits
pub const IGNBRK: u32 = 0o000001; // Ignore BREAK condition
pub const BRKINT: u32 = 0o000002; // BREAK → SIGINT to foreground process group
pub const IGNPAR: u32 = 0o000004; // Ignore framing and parity errors
pub const PARMRK: u32 = 0o000010; // Mark parity and framing errors with 0xFF 0x00
pub const INPCK: u32 = 0o000020; // Enable input parity checking
pub const ISTRIP: u32 = 0o000040; // Strip 8th bit from input characters
pub const INLCR: u32 = 0o000100; // Translate NL to CR on input
pub const IGNCR: u32 = 0o000200; // Ignore CR on input
pub const ICRNL: u32 = 0o000400; // Translate CR to NL on input (unless IGNCR)
pub const IUCLC: u32 = 0o001000; // Map uppercase to lowercase (obsolete, not POSIX)
pub const IXON: u32 = 0o002000; // Enable XON/XOFF flow control on output
pub const IXANY: u32 = 0o004000; // Any character restarts output stopped by XOFF
pub const IXOFF: u32 = 0o010000; // Enable XON/XOFF flow control on input
pub const IMAXBEL: u32 = 0o020000; // Ring bell when input queue is full
pub const IUTF8: u32 = 0o040000; // Input is UTF-8; affects erase in canonical mode
// c_oflag bits
pub const OPOST: u32 = 0o000001; // Enable output processing
pub const OLCUC: u32 = 0o000002; // Map lowercase to uppercase (obsolete)
pub const ONLCR: u32 = 0o000004; // Map NL to CR-NL on output
pub const OCRNL: u32 = 0o000010; // Map CR to NL on output
pub const ONOCR: u32 = 0o000020; // No CR output at column 0
pub const ONLRET: u32 = 0o000040; // NL performs CR function
pub const OFILL: u32 = 0o000100; // Use fill characters for delay
pub const OFDEL: u32 = 0o000200; // Fill char is DEL (otherwise NUL)
// c_cflag bits
pub const CBAUD: u32 = 0o010017; // Baud rate mask (use BOTHER for non-standard rates)
pub const BOTHER: u32 = 0o010000; // Non-standard baud rate (rate in c_ispeed/c_ospeed)
pub const CS5: u32 = 0o000000; // 5-bit characters
pub const CS6: u32 = 0o000020; // 6-bit characters
pub const CS7: u32 = 0o000040; // 7-bit characters
pub const CS8: u32 = 0o000060; // 8-bit characters
pub const CSIZE: u32 = 0o000060; // Character size mask
pub const CSTOPB: u32 = 0o000100; // 2 stop bits (1 if not set)
pub const CREAD: u32 = 0o000200; // Enable receiver
pub const PARENB: u32 = 0o000400; // Enable parity generation on output and checking on input
pub const PARODD: u32 = 0o001000; // Odd parity (even if not set)
pub const HUPCL: u32 = 0o002000; // Hang up on last close (de-assert DTR/RTS)
pub const CLOCAL: u32 = 0o004000; // Ignore modem status lines
pub const CRTSCTS: u32 = 0o020000000000; // Enable RTS/CTS hardware flow control
// c_lflag bits
pub const ISIG: u32 = 0o000001; // Generate signal when INTR/QUIT/SUSP received
pub const ICANON: u32 = 0o000002; // Canonical mode (line-by-line)
pub const XCASE: u32 = 0o000004; // Fold uppercase (obsolete)
pub const ECHO: u32 = 0o000010; // Echo input characters
pub const ECHOE: u32 = 0o000020; // ERASE erases preceding character
pub const ECHOK: u32 = 0o000040; // KILL erases current line
pub const ECHONL: u32 = 0o000100; // Echo NL even if ECHO is not set
pub const NOFLSH: u32 = 0o000200; // No flush on INTR, QUIT, or SUSP
pub const TOSTOP: u32 = 0o000400; // Send SIGTTOU for background write attempts
pub const ECHOCTL: u32 = 0o001000; // Echo control chars as ^X
pub const ECHOPRT: u32 = 0o002000; // Echo erased chars (hardcopy terminal style)
pub const ECHOKE: u32 = 0o004000; // KILL erases by echoing spaces
pub const FLUSHO: u32 = 0o010000; // Output is being flushed
pub const PENDIN: u32 = 0o040000; // Re-print pending input at next read/newline
pub const IEXTEN: u32 = 0o100000; // Enable implementation-defined input processing
pub const EXTPROC: u32 = 0o200000; // External line-discipline processing: the slave's
// termios/winsize changes are reported to a packet-mode
// master as TIOCPKT_IOCTL ([Section 21.1](#tty-and-pty-subsystem--tiocpkt-packet-mode))
// c_cc indices (NCCS = 19)
pub const VINTR: usize = 0; // Interrupt (default ^C = 0x03)
pub const VQUIT: usize = 1; // Quit (default ^\ = 0x1C)
pub const VERASE: usize = 2; // Erase (default ^H/DEL)
pub const VKILL: usize = 3; // Kill line (default ^U)
pub const VEOF: usize = 4; // End-of-file (canonical, default ^D)
pub const VTIME: usize = 5; // Timeout for non-canonical read (tenths of second)
pub const VMIN: usize = 6; // Min chars for non-canonical read
pub const VSWTC: usize = 7; // Switch (not POSIX; 0 in Linux)
pub const VSTART: usize = 8; // Resume output (XON, default ^Q)
pub const VSTOP: usize = 9; // Pause output (XOFF, default ^S)
pub const VSUSP: usize = 10; // Suspend (default ^Z)
pub const VEOL: usize = 11; // Additional end-of-line (canonical)
pub const VREPRINT: usize = 12; // Reprint pending input (default ^R)
pub const VDISCARD: usize = 13; // Toggle discard output (default ^O)
pub const VWERASE: usize = 14; // Word erase (default ^W)
pub const VLNEXT: usize = 15; // Literal next (default ^V)
pub const VEOL2: usize = 16; // Second end-of-line (default NUL = disabled)
// indices 17, 18 are padding (unused)
21.1.6.2 Baud Rate Setting¶
Standard baud rates are encoded as Bxxx constants in c_cflag & CBAUD. Non-standard rates use BOTHER + numeric value in c_ispeed/c_ospeed, via the TCSETS2/TCGETS2 ioctls (Linux 2.6.32+, struct termios2):
/// Standard baud rate constants (in c_cflag bits 0-4, masked by CBAUD).
/// Values are in octal to match Linux `include/uapi/asm-generic/termbits.h`
/// where they are defined as `#define B9600 0000015` etc. Octal notation
/// makes the bit-field encoding clearer (each octal digit = 3 bits).
pub const B0: u32 = 0o000000; // Hang up (de-assert DTR)
pub const B50: u32 = 0o000001;
pub const B75: u32 = 0o000002;
pub const B110: u32 = 0o000003;
pub const B134: u32 = 0o000004;
pub const B150: u32 = 0o000005;
pub const B200: u32 = 0o000006;
pub const B300: u32 = 0o000007;
pub const B600: u32 = 0o000010;
pub const B1200: u32 = 0o000011;
pub const B1800: u32 = 0o000012;
pub const B2400: u32 = 0o000013;
pub const B4800: u32 = 0o000014;
pub const B9600: u32 = 0o000015;
pub const B19200: u32 = 0o000016;
pub const B38400: u32 = 0o000017;
pub const B57600: u32 = 0o010001;
pub const B115200: u32 = 0o010002;
pub const B230400: u32 = 0o010003;
pub const B460800: u32 = 0o010004;
pub const B500000: u32 = 0o010005;
pub const B576000: u32 = 0o010006;
pub const B921600: u32 = 0o010007;
pub const B1000000:u32 = 0o010010;
pub const B1152000:u32 = 0o010011;
pub const B1500000:u32 = 0o010012;
pub const B2000000:u32 = 0o010013;
pub const B2500000:u32 = 0o010014;
pub const B3000000:u32 = 0o010015;
pub const B3500000:u32 = 0o010016;
pub const B4000000:u32 = 0o010017;
UmkaOS ioctls for terminal settings:
- TCGETS (0x5401): get struct termios (old, 15 c_cc entries)
- TCSETS (0x5402): set immediately
- TCSETSW (0x5403): set after drain (wait for output to flush)
- TCSETSF (0x5404): set after flush (drain output + flush input)
- TCGETS2 (0x802C542A): get struct termios2 (19 c_cc, supports BOTHER)
- TCSETS2 (0x402C542B): set via termios2 (supports non-standard baud)
- TCSETSW2 / TCSETSF2: drain/flush variants of TCSETS2
21.1.6.3 Modem Control Lines¶
/// Modem control line bits (TIOCMGET/TIOCMSET/TIOCMBIS/TIOCMBIC).
pub const TIOCM_LE: u32 = 0x001; // Line Enable (DSR in LE role)
pub const TIOCM_DTR: u32 = 0x002; // Data Terminal Ready (output)
pub const TIOCM_RTS: u32 = 0x004; // Request To Send (output)
pub const TIOCM_ST: u32 = 0x008; // Secondary Transmit (rare)
pub const TIOCM_SR: u32 = 0x010; // Secondary Receive (rare)
pub const TIOCM_CTS: u32 = 0x020; // Clear To Send (input)
pub const TIOCM_CAR: u32 = 0x040; // Carrier Detect (input, alias DCD)
pub const TIOCM_RNG: u32 = 0x080; // Ring Indicator (input)
pub const TIOCM_DSR: u32 = 0x100; // Data Set Ready (input)
pub const TIOCM_CD: u32 = TIOCM_CAR;
pub const TIOCM_RI: u32 = TIOCM_RNG;
pub const TIOCM_OUT1:u32 = 0x2000;
pub const TIOCM_OUT2:u32 = 0x4000;
pub const TIOCM_LOOP:u32 = 0x8000;
/// Modem control ioctls.
/// TIOCMGET: read current modem line state → *argp = u32 bitmask
/// TIOCMSET: set modem lines → *argp = u32 bitmask (replaces all writable bits)
/// TIOCMBIS: set individual bits → *argp = u32 bitmask (OR into current)
/// TIOCMBIC: clear individual bits → *argp = u32 bitmask (AND NOT into current)
pub const TIOCMGET: u32 = 0x5415;
pub const TIOCMSET: u32 = 0x5418;
pub const TIOCMBIS: u32 = 0x5416;
pub const TIOCMBIC: u32 = 0x5417;
/// TIOCMIWAIT: wait for modem line state change.
/// *argp = bitmask of lines to wait on (TIOCM_CAR|TIOCM_DSR|TIOCM_RI|TIOCM_CTS).
/// Blocks until any of the specified lines changes. Returns 0 on change, EINTR on signal.
pub const TIOCMIWAIT: u32 = 0x545C;
/// TIOCGICOUNT: get modem line interrupt counter (counts transitions since last call).
/// *argp = struct serial_icounter_struct { cts, dsr, rng, dcd, rx, tx, frame, overrun, parity, brk, ... }
pub const TIOCGICOUNT: u32 = 0x545D;
21.1.6.4 Serial-Specific ioctls¶
/// TIOCEXCL: put tty into exclusive mode.
/// Subsequent open() calls on the device fail with EBUSY.
/// Required by minicom for exclusive serial port access.
pub const TIOCEXCL: u32 = 0x540C;
/// TIOCNXCL: clear exclusive mode.
pub const TIOCNXCL: u32 = 0x540D;
/// TIOCGEXCL: check if in exclusive mode (Linux 3.8+). *argp = int (1 = exclusive).
pub const TIOCGEXCL: u32 = 0x80045440;
/// TIOCGSERIAL: get serial port info (struct serial_struct, Linux ABI compat).
pub const TIOCGSERIAL: u32 = 0x541E;
/// TIOCSSERIAL: set serial port info.
pub const TIOCSSERIAL: u32 = 0x541F;
/// struct serial_struct (Linux ABI — must match exactly for compat).
/// minicom uses TIOCGSERIAL to detect and set ASYNC_LOW_LATENCY.
// Userspace ABI — matches Linux struct serial_struct (TIOCGSERIAL/TIOCSSERIAL). Layout frozen.
#[repr(C)]
pub struct SerialStruct {
pub type_: i32, // PORT_16550A etc.
pub line: i32, // tty line number
pub port: u32, // I/O port address
pub irq: i32,
pub flags: i32, // ASYNC_LOW_LATENCY = 0x2000, ASYNC_SKIP_TEST = 0x0200
pub xmit_fifo_size:i32,
pub custom_divisor:i32,
pub baud_base: i32, // base baud rate (usually 115200 or clock/16)
pub close_delay: u16, // delay before fully closed (jiffies/100)
pub io_type: u8,
pub reserved_char: [u8; 1],
pub hub6: i32,
pub closing_wait: u16, // delay before close (jiffies/100; ASYNC_CLOSING_WAIT_NONE=0xFFFF)
pub closing_wait2: u16,
pub iomem_base: usize, // MMIO base (Linux: `unsigned char *`; usize for 32/64-bit ABI compat)
pub iomem_reg_shift: u16,
pub port_high: u32,
pub iomap_base: usize, // Linux: `unsigned long`; usize for 32/64-bit ABI compat
}
#[cfg(target_pointer_width = "64")]
const _: () = assert!(core::mem::size_of::<SerialStruct>() == 72);
#[cfg(target_pointer_width = "32")]
const _: () = assert!(core::mem::size_of::<SerialStruct>() == 60);
// **32-bit compatibility**: On 64-bit kernels running 32-bit processes, `iomem_base`
// and `iomap_base` are `usize` (4 bytes in the 32-bit struct, 8 bytes in the
// 64-bit struct). The 32-bit compatibility handler for TIOCGSERIAL/TIOCSSERIAL must
// translate between the 32-bit and 64-bit layouts:
// - On TIOCGSERIAL (get): copy the 64-bit struct, truncating iomem_base and
// iomap_base to the lower 32 bits (MMIO addresses in the 32-bit compat
// address space are always <4 GiB).
// - On TIOCSSERIAL (set): zero-extend iomem_base and iomap_base from 32 to
// 64 bits. This matches Linux's `compat_serial_struct` handling.
// compat (32-bit) layout: same fields as SerialStruct but with iomem_base: u32
// and iomap_base: u32, giving total size 60 bytes.
21.1.6.5 Line Discipline Switching¶
/// TIOCSETD: set line discipline. *argp = int (discipline number).
pub const TIOCSETD: u32 = 0x5423;
/// TIOCGETD: get current line discipline. *argp = int.
pub const TIOCGETD: u32 = 0x5424;
/// Registered line disciplines.
pub const N_TTY: i32 = 0; // Default: terminal line discipline
pub const N_SLIP: i32 = 1; // SLIP (Serial Line Internet Protocol)
pub const N_MOUSE: i32 = 2; // Mouse driver (obsolete)
pub const N_PPP: i32 = 3; // PPP (Point-to-Point Protocol) — used by pppd
pub const N_STRIP: i32 = 4; // STRIP (Metricom Striper) — obsolete
pub const N_AX25: i32 = 5; // AX.25 packet radio — unused on UmkaOS
pub const N_X25: i32 = 6; // X.25 async — unused on UmkaOS
pub const N_6PACK: i32 = 7; // 6PACK packet radio — unused
pub const N_MASC: i32 = 8; // Reserved
pub const N_R3964: i32 = 9; // Simatic R3964
pub const N_PROFIBUS_FDL: i32 = 10; // Profibus — industrial
pub const N_IRDA: i32 = 11; // IrDA — legacy
pub const N_SMSBLOCK: i32 = 12; // SMS block protocol
pub const N_HDLC: i32 = 13; // HDLC sync — used by isdn/WAN drivers
pub const N_SYNC_PPP: i32 = 14; // Sync PPP
pub const N_HCI: i32 = 15; // Bluetooth HCI via UART (H4 protocol)
/// Capacity of a `TtyPort`'s ingress and output rings, in bytes. Named because
/// the framed-ingress producer contract (`tty_ingress_enqueue`) needs the free-
/// space arithmetic `INPUT_RING_CAPACITY - input_ring.len()` to reject a
/// sequence that would not fit whole.
pub const INPUT_RING_CAPACITY: u32 = 4096;
/// Per-TTY port state. Represents a single TTY device instance (serial port,
/// PTY slave, VT console). Passed to line discipline methods as the context
/// for all TTY operations. One `TtyPort` exists per open TTY device.
///
/// **Relationship to `PtyPair`**: For PTY devices, `TtyPort` is the
/// line-discipline-facing interface (termios, input/output rings, wait queues),
/// while `PtyPair` is the zero-copy data transport (shared ring pages, control
/// ring). A PTY slave's `TtyPort.driver_data` points to the owning `PtyPair`.
/// The `TtyPort` handles canonical processing (echo, line editing); the `PtyPair`
/// handles master-slave data transport. Serial ports have `TtyPort` only (no
/// `PtyPair`). This separation prevents serial-port code from depending on PTY
/// ring structures and vice versa.
pub struct TtyPort {
/// Generation-safe kernel identity of this port. Carried across every
/// cross-module edge (see §Port identity); the low bits key `TTY_PORTS`.
pub port_id: TtyPortId,
/// Major/minor device number for this TTY.
pub dev: DevId,
/// Current termios settings (baud rate, c_lflag, c_iflag, c_oflag, c_cflag).
pub termios: SpinLock<Termios>,
/// Rebindable binding to the port's active line discipline (§Port identity
/// and the line-discipline binding). Written ONLY under the TIOCSETD /
/// rebind quiesce; snapshotted (cloned) once per drain pass. Replaces the
/// old `Arc<dyn LineDisciplineOps>` + `ldisc_id` pair — an `Arc<dyn …>` may
/// not cross a domain boundary, and the discipline ID now lives inside the
/// binding.
pub ldisc: SpinLock<LdiscBinding>,
/// Per-port ingress ring: the driver RX path (serial IRQ, PTY master write,
/// VT keyboard handoff) pushes raw bytes here through `tty_ingress_enqueue`;
/// the line-discipline worker is the sole consumer.
pub input_ring: SpscRing<u8, { INPUT_RING_CAPACITY as usize }>,
/// Serializes producers into `input_ring`, so several `dup`'d fds writing at
/// once form one logical producer (the single home for the `eb6ddf8b6229`
/// multi-producer defect). Held only across the enqueue push, never across a
/// blocking call.
pub ingress_lock: SpinLock<()>,
/// Output ring buffer: application write path pushes bytes here.
pub output_ring: SpscRing<u8, 4096>,
/// Provider-owned canonical/processed input state and N_TTY read machine.
/// Written by the worker (via `receive_buf`), drained by the reader; the
/// per-port lock inside replaces Linux's global `tty_mutex`.
pub ntty: NTtyState,
/// Serializes concurrent CONSUMERS of this port's read side — several
/// `read()`s racing on `dup`'d / forked fds of the same port, or several
/// independent openers of one `/dev/pts/N` slave. Exactly one reader drains
/// `ntty.read_buf` at a time, so the slave→master ring has a single logical
/// consumer; this is the read-side counterpart of `ingress_lock` and the
/// same `SpinLock<()>` shape as `EvdevClient.read_lock`
/// ([Section 21.3](#input-subsystem)). `ntty.lock` still guards the buffer's structural
/// integrity against the worker; `read_lock` sits above it to exclude a
/// second consumer. Held only across the buffer drain, never across the
/// blocking wait.
pub read_lock: SpinLock<()>,
/// Ready-set membership flag (dedupe): true while this port is queued in its
/// worker's ready set. CAS false→true on enqueue; cleared on pop.
pub ready: AtomicBool,
/// Intrusive link in the worker's ready-port FIFO (`TtyWorkerState.ready`).
pub ready_link: IntrusiveListNode,
/// Wait queue for readers blocked on empty/short canonical input.
pub read_wait: WaitQueueHead,
/// Hangup flag for the async N_TTY read path: set to 1 (Release) by
/// `tty_hangup` when the terminal hangs up (last opener closed, PTY master
/// gone, or modem carrier lost), read (Acquire) by `tty_read_ready`. A
/// blocked `n_tty_read` is woken via `read_wait` and, seeing this set,
/// drains any residual input and then returns EOF (`Ok(0)`). Mirrors the
/// PTY ring header's `hung_up` flag ([Section 21.1](#tty-and-pty-subsystem)) for the
/// line-discipline read path. `0` = live, `1` = hung up.
pub hung_up: AtomicU8,
/// Wait queue for writers blocked on full output buffer.
pub write_wait: WaitQueueHead,
/// IXON software output-flow-control gate: `true` after a VSTOP (^S) was
/// received on the input stream, `false` after a VSTART (^Q). Set/cleared by
/// `tty_flow_ctrl` from the N_TTY receive machine; consulted by the port's
/// output path (the `output_ring`-to-driver drain, the write-side counterpart
/// of `tty_drain_rings`, exactly as `pty_slave_write` checks
/// `PtyFlowControlState.tx_stopped`) before handing bytes to the driver, so a
/// paused terminal stops transmitting. Mirrors Linux `tty->flow.stopped`.
/// `false` = transmitting, `true` = paused.
pub tx_stopped: AtomicBool,
/// True if TIOCEXCL has been set (exclusive access mode).
pub exclusive: AtomicBool,
/// Count of bytes dropped by an un-throttleable source overrunning the
/// ingress ring (surfaced via umkafs `/ukfs/kernel/tty/<dev>/input_overrun`).
pub input_overrun: AtomicU64,
/// Count of line-error OVERRUN conditions reported by the hardware source
/// (UART receive-FIFO overrun, error flag `0x04`). Distinct from
/// `input_overrun`, which counts bytes lost to a full ingress RING: this
/// counter records bytes the DEVICE lost before the kernel ever saw them.
/// Overrun is never delivered to a reader as data — it is surfaced via
/// umkafs at `/ukfs/kernel/tty/<dev>/hw_rx_overrun`.
pub hw_rx_overrun: AtomicU64,
/// Ingress framing mode (write-once at port registration, read-only
/// thereafter). `true` when this port's producer encodes line errors
/// in-band as `[0xFF][flag][byte]` (see the ingress producer contract on
/// `tty_ingress_enqueue`) and the discipline must decode them:
/// physical UART-class ports and the remote-serial bridge's injection
/// port. `false` for PTY and VT-keyboard ports, whose sources cannot
/// produce line errors — those ports skip the decoder entirely (one
/// per-batch branch). Set by the driver class that registers the port;
/// never changed afterwards, so the decoder's mode cannot shift under a
/// half-decoded escape.
pub ingress_framed: bool,
/// Modem control line state (DTR, RTS, CTS, DCD, RI, DSR).
pub modem_status: AtomicU32,
/// Kernel identity (`SessionId` = leader's ProcessId, u64, never
/// reused; 0 = no session) of the session this terminal controls —
/// for SIGHUP on hangup. Translated to a caller-namespace pid_t only
/// at ABI boundaries (`TIOCGSID`) via `sid_nr_ns()`
/// ([Section 8.7](08-process.md#process-groups-and-sessions--per-namespace-numbering-pgidnrns-sidnrns)).
pub session: AtomicU64,
/// Serializes foreground-process-group changes on this terminal.
///
/// `tcsetpgrp()` is not one store: it clears `foreground` on the outgoing
/// group, sets it on the incoming one, and stores `pgrp` here
/// ([Section 8.7](08-process.md#process-groups-and-sessions)). Two callers interleaving those
/// three steps can leave TWO groups with `foreground == true` while
/// `pgrp` names only one — breaking the `Session` invariant "at most one
/// process group in `process_groups` has `foreground == true`" and
/// desynchronizing the job-control state from the signal-routing state
/// that `pgrp` actually drives. Every writer of `pgrp` holds this lock
/// across all three steps; readers (`tcgetpgrp`, the input-signal
/// delivery path) stay lock-free on the atomic, which is why `pgrp`
/// remains an `AtomicU64` rather than moving inside the lock.
///
/// A leaf `SpinLock<()>`: held only across the three job-control updates,
/// never across a blocking call or signal delivery.
pub jobctl_lock: SpinLock<()>,
/// Foreground process group's kernel identity (`ProcessGroupId`, u64;
/// 0 = none) — for SIGINT/SIGTSTP delivery. Written by `tcsetpgrp()`
/// (which resolves the caller's ns-local pgid_t first); read by
/// `tcgetpgrp()` (which translates back to the caller's namespace)
/// and by the input-signal delivery path, which resolves it via
/// `PROCESS_GROUPS` and walks the group's member list
/// ([Section 8.7](08-process.md#process-groups-and-sessions)).
pub pgrp: AtomicU64,
/// NUMA node this TTY worker is assigned to.
pub numa_node: u16,
}
/// Provider-owned canonical input state for the N_TTY discipline. Lives in the
/// line-discipline provider's domain; the worker (write side, via `receive_buf`)
/// and the reading application (read side, via `read`) are serialized by the
/// single per-port `lock` — this is the per-port replacement for
/// Linux's global `tty_mutex`. Non-N_TTY disciplines (N_PPP, N_HDLC, …) keep their own per-line
/// state behind their binding and leave this at its default, unused.
pub struct NTtyState {
pub lock: SpinLock<NTtyReadState>,
}
/// How a committed canonical line ends. A flat byte count cannot deliver the
/// POSIX contract — the reader must reproduce the exact terminator boundary the
/// receive machine saw, because a VEOF-terminated segment carries no delimiter
/// byte and an empty-line VEOF carries no bytes at all yet must still surface as
/// an in-order zero-length read. (This is the boundary metadata Linux keeps in a
/// per-position bitmap; UmkaOS keeps it as a typed record queue —
/// see `line_ends`.)
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum CanonLineEnd {
/// Ended on `NL`/`VEOL`/`VEOL2`: the delimiter byte IS the last of the
/// record's bytes and is delivered to the reader.
Delimited,
/// Ended on `VEOF` (^D) typed after ≥1 editable byte: the record's bytes are
/// delivered WITHOUT a delimiter and the ^D is discarded. This is NOT an
/// end-of-file — `read()` returns the bytes (> 0), and the next line follows
/// normally.
Undelimited,
/// `VEOF` (^D) typed on an empty line: a zero-length record whose read
/// returns `Ok(0)` — the canonical end-of-file indication. Preserved in FIFO
/// order with surrounding lines and with other EOFs (double-^D → two
/// in-order zero-length reads), matching Linux.
Eof,
}
/// One committed canonical line (or EOF marker) awaiting a reader. Its `len`
/// bytes sit at the head of `read_buf` in commit order; `end` reproduces the
/// terminator boundary. `Copy` and 4 bytes — peeked, not moved, by the read
/// machine.
#[derive(Clone, Copy)]
pub struct CanonLine {
/// Byte length of this line in `read_buf` (`0..=4096`; always `0` for
/// `Eof`, always `>= 1` otherwise since a delimiter or ≥1 edited byte was
/// committed). Fits `u16`.
pub len: u16,
/// Terminator classification (see `CanonLineEnd`).
pub end: CanonLineEnd,
}
/// The N_TTY read machine's per-port state (behind `NTtyState.lock`).
pub struct NTtyReadState {
/// Committed processed bytes available to `read()`. In ICANON a byte is
/// committed only as part of a complete line (terminated by NL, VEOL, VEOL2,
/// or VEOF); in raw mode every received byte is committed immediately. FIFO.
/// **Compile-time capacity role (b)** — admission-validated bound
/// ([Section 3.13](03-concurrency.md#collection-usage-policy--compile-time-capacities-scratch-hints-and-validated-bounds-never-ownership)):
/// a canonical line is committed only when it fits whole (`commit_line`
/// checks free space first and drops the uncommitted edit buffer otherwise),
/// and raw-mode commits are refused when full — committed bytes are never
/// displaced after the fact.
pub read_buf: InlineBoundedRing<u8, 4096>,
/// FIFO of committed canonical-line boundaries (ICANON only), one record per
/// complete line or EOF, in commit order. Replaces the old flat
/// `canon_lines: u32` + `eof_pending: bool` pair, which could not represent
/// where one line ends and the next begins (a VEOF-terminated line has no
/// delimiter byte, so `'foo'^D` then `'bar\n'` must read as `"foo"` then
/// `"bar\n"`, never as a merged `"foobar\n"` with a trailing spurious
/// zero-read). A canonical `read()` consumes exactly the head record; an
/// `Eof` record returns `Ok(0)`. Sized to `read_buf` (each non-`Eof` record
/// consumes ≥1 byte there, and even an all-empty-line-^D stream is bounded to
/// 4096 EOF markers), so it never overflows before `read_buf` does — warm,
/// bounded, no per-event allocation ([Section 3.13](03-concurrency.md#collection-usage-policy)). Empty in
/// raw mode. **Compile-time capacity role (b)**: the sizing invariant above
/// makes overflow impossible before `read_buf`'s own admission check fires
/// ([Section 3.13](03-concurrency.md#collection-usage-policy--compile-time-capacities-scratch-hints-and-validated-bounds-never-ownership)).
pub line_ends: InlineBoundedRing<CanonLine, 4096>,
/// Bytes of the HEAD `line_ends` record already delivered by an earlier
/// short `read()` (user buffer smaller than the line). The next `read()`
/// resumes at this offset within the same record — the record is popped only
/// once fully delivered. `0` whenever the head record is untouched. `u16`
/// (line length ≤ 4096).
pub front_consumed: u16,
/// In-progress canonical line — bytes received since the last delimiter,
/// subject to VERASE / VWERASE / VKILL editing before they are committed to
/// `read_buf`. Empty in raw mode.
pub line_edit: ArrayVec<u8, 4096>,
/// Echo column, for ECHOE erase and control-char (`^X`) / tab echo.
pub column: u32,
/// True after VLNEXT (^V): the next input byte is taken literally.
pub lnext: bool,
/// In-band line-error decoder state for framed ports
/// (`TtyPort.ingress_framed`). `0` = idle, `1` = saw the `0xFF`
/// introducer, `2` = saw `0xFF` + flag and awaits the payload byte.
/// Persistent across drain batches because a 2- or 3-byte escape may be
/// split by a batch boundary — the ring is a byte stream, not a message
/// queue. Always `0` on a non-framed port (the decoder never runs).
pub frame_esc: u8,
/// Error flag latched at `frame_esc == 2`, applied to the payload byte
/// that completes the escape. Values are the shared error table's
/// (`0x01` parity, `0x02` framing, `0x04` overrun, `0x08` break).
pub frame_flag: u8,
}
/// LineDisciplineOps trait — implemented by each line discipline. Within the
/// discipline's own domain the methods receive a resolved `&TtyPort`; a
/// cross-domain binding marshals `(port_id, bytes)` over the ring and the far
/// side resolves `TTY_PORTS` before calling these (see `LdiscBinding`).
/// `&self` is stateless for N_TTY — the per-port canonical state lives on the
/// port (`TtyPort.ntty`), reached with interior mutability, so one `&'static`
/// N_TTY vtable serves every port.
pub trait LineDisciplineOps: Send + Sync {
/// KABI method selector for cross-domain `receive_buf` dispatch, used by
/// `LdiscBinding::receive_buf`'s `Ring` arm.
const RECEIVE_BUF: u32 = 0;
/// Called by the worker with a batch of raw bytes for canonical/raw
/// processing (echo, editing, signal generation). Commits processed output
/// into `tty.ntty` and wakes `tty.read_wait`.
///
/// For framed ports (`TtyPort.ingress_framed`) the byte stream carries the
/// in-band `[0xFF][flag][byte]` line-error encoding — one encoding, shared
/// verbatim with the remote-serial `RxData` stream (see the error-reporting
/// table) — and the discipline decodes it. For non-framed ports the batch is
/// data bytes only. Ring-overrun byte loss is surfaced separately via
/// `TtyPort.input_overrun`.
fn receive_buf(&self, tty: &TtyPort, buf: &[u8]);
/// Called when the application reads from the tty (drains `tty.ntty`).
fn read(&self, tty: &TtyPort, buf: &mut [u8]) -> Result<usize, KernelError>;
/// Called when the application writes to the tty.
fn write(&self, tty: &TtyPort, buf: &[u8]) -> Result<usize, KernelError>;
/// Handle ioctl (discipline-specific, e.g., PPPIOCGUNIT for N_PPP).
fn ioctl(&self, tty: &TtyPort, cmd: u32, arg: usize) -> Result<i32, KernelError>;
/// Called when line discipline is opened.
fn open(&self, tty: &TtyPort) -> Result<(), KernelError>;
/// Called when line discipline is closed.
fn close(&self, tty: &TtyPort);
}
N_TTY receive machine (n_tty_receive_buf, worker side). Runs in the
provider domain; the entire batch is processed under tty.ntty.lock:
fn n_tty_receive_buf(tty: &TtyPort, buf: &[u8]):
let t = tty.termios.lock().clone(); // snapshot flags + c_cc
let mut s = tty.ntty.lock.lock();
for (i, &b) in buf.iter().enumerate():
// In-band line-error decode, framed ports only. ONE per-batch branch on
// `ingress_framed` gates the whole decoder, so PTY/VT ports — the
// high-density path — pay nothing per byte. `frame_esc` persists across
// batches because an escape may straddle a batch boundary.
//
// The decoded grammar is exactly the producer/`RxData` encoding:
// [b] b != 0xFF → data byte b
// [0xFF][0x00][0xFF] → data byte 0xFF (literal)
// [0xFF][flag][b] flag != 0x00 → error byte (b, flag)
// Error bytes and escaped literals leave the loop through their own
// helpers and are never seen by the editing machine; only unescaped bytes
// fall through to the c_cc/signal handling below.
if tty.ingress_framed:
match s.frame_esc:
0 => if b == 0xFF { s.frame_esc = 1; continue } // introducer
1 => {
if b == 0x00 { s.frame_esc = 3; continue } // literal-0xFF escape
s.frame_flag = b; s.frame_esc = 2; continue
}
2 => { // payload of an error
let flag = s.frame_flag;
s.frame_esc = 0;
n_tty_receive_error(tty, &mut s, &t, b, flag);
continue
}
_ => { // 3: payload of `[0xFF][0x00]`
// A LITERAL 0xFF data byte. POSIX termios(3): with PARMRK set
// and ISTRIP clear it reaches the reader DOUBLED (`0xFF 0xFF`)
// so a reader can tell it from a PARMRK error prefix.
s.frame_esc = 0;
if t.c_iflag & PARMRK != 0 && t.c_iflag & ISTRIP == 0 {
n_tty_input_char(&mut s, &t, 0xFF, /*literal=*/true)
}
n_tty_input_char(&mut s, &t, 0xFF, /*literal=*/true)
continue
}
// Literal-next (^V): previous byte was VLNEXT — take b verbatim.
if s.lnext:
s.lnext = false
n_tty_input_char(&mut s, &t, b, /*literal=*/true) // no special handling
continue
// ISIG: INTR/QUIT/SUSP generate signals to the foreground group. The `b != 0`
// guard is the disabled-char rule: a c_cc slot at the POSIX disabled value
// (NUL, i.e. 0) must match nothing, so a NUL input byte
// never triggers a signal for a disabled VINTR/VQUIT/VSUSP (Linux clears
// zero from its configured-character lookup; every c_cc value compare in this machine
// is `b != 0`-gated for the same reason).
if t.c_lflag & ISIG != 0 && b != 0 && (b == t.c_cc[VINTR] || b == t.c_cc[VQUIT] || b == t.c_cc[VSUSP]):
// Match the received byte against the CONFIGURED control chars, not the
// c_cc INDEX constants (VINTR=0, VQUIT=1, VSUSP=10).
let sig = if b == t.c_cc[VINTR] { SIGINT }
else if b == t.c_cc[VQUIT] { SIGQUIT }
else { SIGTSTP }; // b == t.c_cc[VSUSP]
// Unless NOFLSH is set, INTR/QUIT/SUSP flush BOTH queues (POSIX
// termios(3)). Input side: the in-progress edit line, every committed
// canonical line/EOF, and the partial-read cursor. Output side: any queued
// output is discarded and the echo column reset. This matches Linux
// behavior: when `NOFLSH` is clear, it resets the input state AND the
// echo buffer and queued driver output — it is NOT
// input-only.
if t.c_lflag & NOFLSH == 0 {
s.line_edit.clear(); s.read_buf.clear();
s.line_ends.clear(); s.front_consumed = 0; s.column = 0;
tty_flush_output(tty) // discard queued output (both-queue flush)
}
tty_signal_foreground(tty, sig) // process-management surface
continue
// IXON: VSTOP/VSTART drive flow control, are not delivered. `b != 0` guards
// the disabled-char case (a disabled VSTOP/VSTART slot holds 0, must not match).
if t.c_iflag & IXON != 0 && b != 0 && (b == t.c_cc[VSTOP] || b == t.c_cc[VSTART]):
tty_flow_ctrl(tty, b == t.c_cc[VSTART])
continue
// VLNEXT (^V): same disabled-char guard — a disabled VLNEXT (0) must not
// swallow a NUL byte as literal-next.
if b != 0 && b == t.c_cc[VLNEXT] && t.c_lflag & IEXTEN != 0:
s.lnext = true; continue
n_tty_input_char(&mut s, &t, b, /*literal=*/false)
drop(s)
tty.read_wait.wake_up_all() // readers re-check availability
// Dispose of one decoded LINE-ERROR byte (framed ports only). `flag` is from
// the shared error table: 0x01 parity, 0x02 framing, 0x04 overrun, 0x08 break
// (break carries b == 0x00). POSIX termios(3) input-flag semantics:
//
// parity/framing — considered ONLY when INPCK is set; with INPCK clear the
// byte is ordinary data. Then: IGNPAR → discard; else PARMRK → deliver
// `0xFF 0x00 b`; else → deliver `\0`.
// break — IGNBRK → discard; else BRKINT → flush BOTH queues and
// SIGINT the foreground group (the same machinery the !NOFLSH signal path
// uses above); else → deliver `\0` (PARMRK: `0xFF 0x00 0x00`).
// overrun — NEVER delivered as data: the device lost bytes, there is
// no character to represent. Counted on the port and surfaced via umkafs
// at `/ukfs/kernel/tty/<dev>/hw_rx_overrun`, parallel to `input_overrun`
// (which stays ring-overrun-only).
//
// Delivered bytes go through `n_tty_input_char` with `literal=true`: a
// PARMRK-marked or NUL-substituted error byte is DATA, never a c_cc match — a
// framing error on the byte that happens to equal VINTR must not raise SIGINT.
fn n_tty_receive_error(tty: &TtyPort, s: &mut NTtyReadState, t: &Termios,
b: u8, flag: u8):
if flag == 0x04: // overrun
tty.hw_rx_overrun.fetch_add(1, Ordering::Relaxed)
return
if flag == 0x08: // break
if t.c_iflag & IGNBRK != 0 { return }
if t.c_iflag & BRKINT != 0:
s.line_edit.clear(); s.read_buf.clear()
s.line_ends.clear(); s.front_consumed = 0; s.column = 0
tty_flush_output(tty)
tty_signal_foreground(tty, SIGINT)
return
n_tty_deliver_marked(s, t, 0x00)
return
// parity (0x01) / framing (0x02)
if t.c_iflag & INPCK == 0:
n_tty_input_char(s, t, b, /*literal=*/true) // not checked → plain data
return
if t.c_iflag & IGNPAR != 0 { return } // discard
n_tty_deliver_marked(s, t, b)
// Deliver one error byte under the PARMRK rule: marked as `0xFF 0x00 b` when
// PARMRK is set, otherwise replaced by a single NUL.
fn n_tty_deliver_marked(s: &mut NTtyReadState, t: &Termios, b: u8):
if t.c_iflag & PARMRK != 0:
n_tty_input_char(s, t, 0xFF, /*literal=*/true)
n_tty_input_char(s, t, 0x00, /*literal=*/true)
n_tty_input_char(s, t, b, /*literal=*/true)
else:
n_tty_input_char(s, t, 0x00, /*literal=*/true)
// Process one input byte into the canonical/raw buffers.
fn n_tty_input_char(s: &mut NTtyReadState, t: &Termios, b: u8, literal: bool):
if t.c_lflag & ICANON == 0:
// Raw mode: commit immediately (backpressure if read_buf full).
let _ = s.read_buf.push_back(b);
return
if !literal:
// Compare the received byte against the CONFIGURED editing/EOL characters
// (`t.c_cc[V*]`), NEVER the c_cc INDEX constants (VERASE=2, VKILL=3, … per
// the `c_cc indices` table above). A c_cc slot set to the POSIX disabled
// value (NUL, i.e. 0) matches nothing, so a NUL input
// byte is not mistaken for a disabled VEOL/VEOL2. WERASE is IEXTEN-gated,
// matching Linux's canonical-input behavior.
if b != 0:
if b == t.c_cc[VERASE] { s.line_edit.pop(); return } // erase last char
if b == t.c_cc[VWERASE] && t.c_lflag & IEXTEN != 0 {
erase_word(&mut s.line_edit); return }
if b == t.c_cc[VKILL] { s.line_edit.clear(); return } // kill whole line
if b == t.c_cc[VEOF] { // ^D
// Empty line → an EOF marker (next read returns Ok(0)); non-empty line →
// deliver the accumulated bytes NOW, with no delimiter, and discard the
// ^D. Both are ordered records, so a subsequent line is never merged in.
if s.line_edit.is_empty() { commit_line(s, CanonLineEnd::Eof); }
else { commit_line(s, CanonLineEnd::Undelimited); }
return
}
// Canonical line terminators: '\n' (always), plus the configured VEOL/VEOL2
// when enabled (non-disabled). The delimiter byte is part of the line.
if b == NL || (b != 0 && (b == t.c_cc[VEOL] || b == t.c_cc[VEOL2])) {
s.line_edit.push(b).ok(); commit_line(s, CanonLineEnd::Delimited); return
}
// Ordinary character: accumulate in the editing line.
let _ = s.line_edit.push(b);
// Commit a canonical boundary. `Eof` records a zero-length end-of-file marker
// (no bytes). `Delimited`/`Undelimited` move the current edit line into
// `read_buf` as one record of that terminator kind. Backpressure (canonical
// line too long / buffers full): if the byte ring or the record queue lacks
// room the line is dropped at the source rather than committed half-tracked —
// matching canonical overflow, where input beyond the buffer is discarded. The
// record queue is sized to `read_buf`, so a well-behaved reader never sees this.
fn commit_line(s: &mut NTtyReadState, end: CanonLineEnd):
if end == CanonLineEnd::Eof:
let _ = s.line_ends.push_back(CanonLine { len: 0, end }); // zero-length EOF marker
return
let len = s.line_edit.len();
if s.line_ends.is_full() || s.read_buf.capacity() - s.read_buf.len() < len {
s.line_edit.clear(); return // cannot commit atomically → drop
}
for &c in s.line_edit.iter() { let _ = s.read_buf.push_back(c); }
let _ = s.line_ends.push_back(CanonLine { len: len as u16, end });
s.line_edit.clear();
// Canonical newline byte (POSIX '\n'). A hard line delimiter, NOT a c_cc slot,
// so it is matched ungated (a disabled c_cc value is 0, never '\n').
pub const NL: u8 = b'\n';
// WERASE (^W): erase the whitespace-delimited word at the end of the edit line —
// first drop trailing blanks, then the run of non-blank bytes — matching Linux
// N_TTY WERASE behavior.
fn erase_word(line: &mut ArrayVec<u8, 4096>):
while let Some(&c) = line.last() { if c == b' ' || c == b'\t' { line.pop(); } else { break } }
while let Some(&c) = line.last() { if c != b' ' && c != b'\t' { line.pop(); } else { break } }
// IXON software output flow control driven from the input stream: a received
// VSTOP (^S) halts transmission to the terminal, a VSTART (^Q) resumes it —
// neither byte is delivered to a reader. Sets the port's `tx_stopped` output
// gate (the same gate the `output_ring`→driver drain consults, exactly as
// `pty_slave_write` checks `PtyFlowControlState.tx_stopped`) and, on resume,
// wakes writers parked on `write_wait` so a paused `write()` re-checks the gate.
// Mirrors Linux `stop_tty`/`start_tty`.
fn tty_flow_ctrl(tty: &TtyPort, start: bool):
tty.tx_stopped.store(!start, Ordering::Release)
if start { tty.write_wait.wake_up_all() }
// Both-queue output flush for the `!NOFLSH` signal path (POSIX INTR/QUIT/SUSP):
// discard the port's queued output so a signal drops pending terminal output as
// well as input. Drains `output_ring` on the consumer side — it runs as the
// port's single output consumer, so it does not race the normal
// `output_ring`→driver drain — and wakes writers blocked on a formerly-full
// ring. Observable effect = Linux `tty_driver_flush_buffer`; the UART's own
// small hardware FIFO is out of the modeled surface.
fn tty_flush_output(tty: &TtyPort):
while tty.output_ring.try_pop().is_ok() {}
tty.write_wait.wake_up_all()
N_TTY read machine (n_tty_read, application side):
fn n_tty_read(tty: &TtyPort, buf: &mut [u8], nonblock: bool) -> Result<usize, KernelError>:
// Background read with TOSTOP-independent SIGTTIN rule: a process not in the
// terminal's foreground group is stopped with SIGTTIN (POSIX), unless the
// group is orphaned → EIO.
if !tty_reader_in_foreground(tty):
return tty_background_read_gate(tty) // sends SIGTTIN or returns EIO
// Zero-length read returns 0 immediately, consuming nothing. Linux's `while (nr)`
// loop head (drivers/tty/n_tty.c) never iterates when nr == 0, so read(fd, buf, 0)
// returns 0 after job control without popping a pending canonical EOF record,
// draining raw bytes, or blocking. Placed after the background-read gate to match
// Linux, which runs job control before the loop.
if buf.is_empty() { return Ok(0) }
let t = tty.termios.lock().clone();
// Raw-mode VMIN/VTIME. VTIME is in deciseconds (1 ds = 100 ms). Unused in
// ICANON. The four POSIX quadrants (termios(3)):
// MIN>0, TIME==0 : block until >= MIN bytes.
// MIN>0, TIME>0 : block until >= MIN bytes OR the INTER-BYTE timer fires;
// the timer is (re)armed on each received byte, so it can
// only ever expire with >= 1 byte already buffered.
// MIN==0, TIME>0 : OVERALL read timer armed now; return as soon as >= 1
// byte is available OR the timer expires (then return 0).
// MIN==0, TIME==0 : polling — return immediately with whatever is available.
let raw = t.c_lflag & ICANON == 0;
let vmin = t.c_cc[VMIN] as usize;
let vtime_ns = (t.c_cc[VTIME] as u64) * 100_000_000; // deciseconds → ns
// Overall timer (MIN==0, TIME>0) starts at call time; the inter-byte timer
// (MIN>0, TIME>0) is armed lazily once the first byte arrives (below).
let mut deadline: Option<u64> =
if raw && vmin == 0 && vtime_ns > 0 { Some(ktime_get_ns() + vtime_ns) } else { None };
let mut last_avail = 0usize;
loop:
{
// Consumer serialization: exactly one reader drains the buffer at a time
// (see `TtyPort.read_lock`). Held across the drain attempt only — released
// with `s` before the blocking wait below, never across it.
let _rg = tty.read_lock.lock();
let mut s = tty.ntty.lock.lock();
if !raw:
// Canonical: deliver the head committed record. `copy_one_line` returns
// the line's bytes for a Delimited/Undelimited record and `Ok(0)` for an
// Eof record (canonical ^D end-of-file). EOF ordering is intrinsic — an
// `Eof` record can only reach the front after every earlier line's bytes
// have been consumed — so no separate `eof_pending` flag is needed.
if !s.line_ends.is_empty():
let n = copy_one_line(&mut s, buf); // one record; 0 for EOF
return Ok(n)
else:
let avail = s.read_buf.len();
if vmin == 0:
// MIN==0: satisfy on any available byte; with TIME==0 return at once
// even with zero bytes (pure poll).
if avail > 0 || vtime_ns == 0:
let n = copy_bytes(&mut s, buf); // may be 0 for the poll case
return Ok(n)
else:
if avail >= vmin:
let n = copy_bytes(&mut s, buf);
return Ok(n)
// O_NONBLOCK: a raw read delivers whatever is ALREADY buffered rather
// than blocking for VMIN. With 1..VMIN-1 bytes available it returns the
// partial count; -EAGAIN is reported only when NOTHING is buffered (the
// `avail == 0` fall-through to the `nonblock` guard below). This is the
// read(2)/poll() contract — poll() reports readable on any buffered byte,
// so read() must not spuriously fail with EAGAIN. Matches Linux
// behavior: available bytes are copied and the `>= minimum` check fails, so the
// input wait returns -EAGAIN, and the trailing `if (kb - kbuf) retval = kb - kbuf;`
// converts it to the partial count. Blocking reads (nonblock == false)
// are unaffected — they fall through to the VMIN/VTIME wait below.
if nonblock && avail > 0:
let n = copy_bytes(&mut s, buf);
return Ok(n)
// Inter-byte timer (TIME>0): (re)arm on each newly received byte.
if vtime_ns > 0 && avail > 0 && avail != last_avail:
deadline = Some(ktime_get_ns() + vtime_ns);
last_avail = avail;
// Hangup: no complete line / satisfiable raw read is buffered (the checks
// above would have returned otherwise), so drain any residual committed
// bytes and then report EOF. `copy_bytes` returns 0 once drained, which is
// the EOF a subsequent read also sees.
if tty.hung_up.load(Ordering::Acquire):
let n = copy_bytes(&mut s, buf);
return Ok(n)
}
if nonblock { return Err(KernelError::EAGAIN) }
// Buffered-byte threshold at which the blocking wait should wake. Canonical
// ignores it (line completion / EOF / hangup drive readiness). Raw:
// MIN==0 → 1 byte (the overall VTIME timer, armed at call time,
// supplies the return-with-0 leg on expiry);
// MIN>0, TIME==0 → VMIN bytes — wake only when the read is satisfiable;
// MIN>0, TIME>0 → `last_avail + 1`, i.e. the NEXT new byte. This makes
// the four-quadrant contract hold BY THE WAIT STRUCTURE:
// the first byte (last_avail==0 ⇒ threshold 1) ends the
// indefinite first-byte block; each subsequent new byte
// wakes the reader so the drain above re-arms the
// inter-byte timer (and returns once VMIN is reached).
// A 1..VMIN-1 buffer no longer re-sleeps forever waiting
// for VMIN (the old `>= vmin` predicate defect), and the
// timer is not busy-polled — the threshold stays unmet
// until a genuinely new byte arrives, so between bytes the
// wait sleeps on the deadline alone.
let wake_at = if !raw || vmin == 0 { 1 }
else if vtime_ns > 0 { last_avail + 1 }
else { vmin };
// Block until the worker commits enough input, a hangup, a signal, or (if a
// VMIN/VTIME timer is armed) its deadline. `tty_read_ready` re-checks the
// same readiness the drain above uses (data ≥ wake_at, pending EOF, or
// hangup); the interruptible wait reports WHY it woke.
// `wait_event_interruptible_timeout` is the signal+deadline variant of
// `wait_event` ([Section 3.6](03-concurrency.md#lock-free-data-structures--waitqueuehead-blocking-wait-queue)).
let reason = match deadline:
Some(dl):
let now = ktime_get_ns();
if now >= dl { InterruptibleWaitResult::TimedOut } // already expired → drain below
else { tty.read_wait.wait_event_interruptible_timeout(dl - now, || tty_read_ready(tty, &t, wake_at)) }
None:
// No timer armed: a plain interruptible wait. Map its Ok/Err onto the
// shared outcome so the single `match` below handles every quadrant.
match tty.read_wait.wait_event(|| tty_read_ready(tty, &t, wake_at)):
Ok(()) => InterruptibleWaitResult::Ready,
Err(_) => InterruptibleWaitResult::Interrupted,
match reason:
InterruptibleWaitResult::Interrupted => return Err(KernelError::EINTR),
InterruptibleWaitResult::TimedOut => // VTIME fired: return what we have
let _rg = tty.read_lock.lock();
let mut s = tty.ntty.lock.lock();
let n = copy_bytes(&mut s, buf); // >=1 for inter-byte; maybe 0 for overall
return Ok(n)
InterruptibleWaitResult::Ready => continue, // re-drain under the locks (hangup → EOF there)
// Readiness predicate for `n_tty_read`'s blocking wait: true when the reader has
// something to observe — a committed canonical line, `wake_at` raw bytes, a
// pending canonical EOF, or a hangup. `wake_at` is the caller-computed raw
// threshold (see the wait site): 1 for MIN==0, VMIN for MIN>0/TIME==0, and
// `last_avail + 1` for the MIN>0/TIME>0 inter-byte phase (wake on each new byte
// so the drain re-arms the timer; VMIN itself is checked at drain). Called by the
// wait primitive (which re-checks it after each wakeup); the caller re-drains
// under `ntty.lock` on any Ready wake, so a hangup surfaces as EOF there. Acquires
// `ntty.lock` briefly and holds no other lock (the caller dropped
// `read_lock`/`ntty.lock` before waiting).
fn tty_read_ready(tty: &TtyPort, t: &Termios, wake_at: usize) -> bool:
if tty.hung_up.load(Ordering::Acquire) { return true } // hangup → wake → EOF
let s = tty.ntty.lock.lock();
if t.c_lflag & ICANON != 0:
!s.line_ends.is_empty() // a complete line OR a pending ^D EOF
else:
s.read_buf.len() >= wake_at // raw: caller-set threshold
// Deliver the head canonical record into `buf` (caller checked `line_ends`
// non-empty). Returns the bytes copied; an `Eof` record copies none and returns
// 0 — the canonical end-of-file. A user buffer shorter than the line takes a
// prefix: `front_consumed` advances and the record is RETAINED so the remainder
// is delivered by the next read (POSIX partial-line semantics); the record is
// popped only once fully delivered.
fn copy_one_line(s: &mut NTtyReadState, buf: &mut [u8]) -> usize:
let front = *s.line_ends.front().unwrap(); // CanonLine is Copy — peek, don't pop
if front.end == CanonLineEnd::Eof:
let _ = s.line_ends.pop_front(); // consume the EOF marker
s.front_consumed = 0;
return 0 // canonical end-of-file (Ok(0))
let remaining = front.len as usize - s.front_consumed as usize;
let n = remaining.min(buf.len());
for i in 0..n { buf[i] = s.read_buf.pop_front().unwrap(); }
s.front_consumed += n as u16;
if s.front_consumed as usize == front.len as usize: // whole line delivered → advance
let _ = s.line_ends.pop_front();
s.front_consumed = 0;
n
// Raw-mode drain: copy up to `buf.len()` committed bytes (raw mode has no line
// boundaries — `line_ends` stays empty). Returns the count, possibly 0.
fn copy_bytes(s: &mut NTtyReadState, buf: &mut [u8]) -> usize:
let n = s.read_buf.len().min(buf.len());
for i in 0..n { buf[i] = s.read_buf.pop_front().unwrap(); }
n
tty_signal_foreground, tty_reader_in_foreground, and
tty_background_read_gate route through the existing process-management surface
(TtyPort.pgrp / TtyPort.session u64 identities resolved via PROCESS_GROUPS,
delivery via send_signal_to_pgrp — Section 8.7); no TTY
state lives in the process layer and no new broker entity owns any pgrp/session
state.
Note — TIOCSETD behavior (D24): Line disciplines are not stacked.
TIOCSETDreplaces the current discipline with a new one; a TTY has exactly one active line discipline at any time (no STREAMS-style stacking, matching Linux behavior).
ioctl(fd, TIOCSETD, &ldisc_id): performs a quiesced swap through the port's single quiescence boundary (tty_port_quiesce_ingressbelow), NOT a bare close-then-open on a shared&TtyPort. The replacement never races the worker or a reader: (1) block new ingress and evict the port from its ready set; (2) drain any in-flightreceive_buf/read(the same drain the worker runs, so a byte mid-processing completes); (3) call the old discipline'sclose(); (4) bumpTtyPort.ldisc'sgenerationand install the newLdiscBindingunder the port'sldisclock; (5) call the new discipline'sopen(); (6) resume ingress. A drain snapshot whose generation no longer matches is discarded, so a discipline swapped out cannot be dispatched after itsclose(). ReturnsEINVALifldisc_id >= N_LDISC_MAX(31) or the discipline is not registered.ioctl(fd, TIOCGETD, &ldisc_id): returns the ID of the current line discipline (TtyPort.ldisc.lock().ldisc_id).N_TTY(ID 0) is always registered and is the fallback if a custom discipline'sopen()fails.N_LDISC_MAX = 31: the size of the line-discipline NUMBERING SPACE (a validldisc_idis0..N_LDISC_MAX), NOT a limit on simultaneous TTY instances. This matches LinuxNR_LDISCS = 31(include/uapi/linux/tty.h,torvalds/linuxmaster); Linux therefore ACCEPTSldisc_id 30(N_CAN327, added in v6.0) and the spec must notEINVALit. The value was 30 only before the v6.0N_CAN327addition.
21.1.6.5.1 Line-discipline quiescence, promotion/demotion, and live evolution¶
The SAME quiescence boundary serves TIOCSETD, tier promotion/demotion, and live evolution's "drain in-flight line discipline operations" (Section 13.18) — there is no second drain mechanism:
/// Quiesce a port's line-discipline processing: stop new ingress, evict the
/// port from its worker's ready set, and wait for any in-flight `receive_buf` /
/// `read` on this port to complete. Returns a guard whose drop resumes ingress.
/// Used by TIOCSETD, by promotion/demotion rebind, and by the live-evolution
/// drain of the TTY/PTY subsystem.
pub fn tty_port_quiesce_ingress(port: &Arc<TtyPort>) -> IngressQuiesceGuard;
/// RAII guard returned by `tty_port_quiesce_ingress`. While held, the port
/// accepts no new ingress and is absent from every worker ready set; `drop`
/// re-enables `tty_ingress_enqueue` and re-marks the port ready if bytes are
/// pending.
pub struct IngressQuiesceGuard {
port: Arc<TtyPort>,
}
Promotion/demotion (tier migration) rebinds a port's discipline (or a driver's
TtyOps) between domains with no data loss and identical observable behavior:
- Quiesce ingress —
tty_port_quiesce_ingress(port)stops producers and evicts the port from the ready set. - Drain accepted work — run the worker drain to empty the ingress ring and flush committed canonical output (bytes already accepted are never dropped).
- Migrate per-port state — the canonical
NTtyStateand termios travel with the port; because the port is addressed by itsTtyPortId(never a borrowed pointer), the state is re-homed in the destination domain by re-resolvingTTY_PORTSthere. - Rebind handles — the domain service re-resolves
TtyPort.ldisc/TtyDriver.opsfor the new domain (Direct↔Ring), bumping the binding generation so any stale snapshot is rejected. - Resume — drop the quiesce guard; producers re-mark the port ready and the (possibly relocated) worker resumes draining.
The boot console is the sole exception that is NOT rebound this way: it is statically Tier-0-only (header Deployment placement), so it has no ingress ring, no worker, and no discipline binding to migrate.
21.1.6.6 SerialTtyOps KABI¶
Hardware serial UART drivers implement SerialTtyOps:
/// KABI vtable for a serial UART driver. Tier-agnostic: the transport (direct
/// vtable call same-domain, or ring buffer + domain switch cross-domain) is
/// selected by `kabi_call!` at bind time from the driver's effective tier — the
/// same handle the console backend drives it through
/// ([Section 21.2](#console-and-kernel-logging--kabi-serial-console-backend)). No fixed
/// tier is baked into this vtable.
#[repr(C)]
pub struct SerialTtyOps {
pub vtable_size: usize,
/// Apply new termios settings to hardware (baud rate, framing, flow control).
pub set_termios: unsafe extern "C" fn(
ctx: *mut c_void,
new: *const Termios,
old: *const Termios,
),
/// Get current modem control line state (returns TIOCM_* bitmask).
pub get_mctrl: unsafe extern "C" fn(ctx: *mut c_void) -> u32,
/// Set modem control output lines (DTR, RTS).
pub set_mctrl: unsafe extern "C" fn(ctx: *mut c_void, mctrl: u32),
/// Send a BREAK condition for `duration_ms` milliseconds.
pub send_break: unsafe extern "C" fn(ctx: *mut c_void, duration_ms: u32),
/// Start transmitting (driver was stopped by throttle/stop_tx, now resume).
pub start_tx: unsafe extern "C" fn(ctx: *mut c_void),
/// Stop transmitting (XOFF received or output buffer full).
pub stop_tx: unsafe extern "C" fn(ctx: *mut c_void),
/// Enable/disable receiver (CREAD flag).
pub set_rx_enabled: unsafe extern "C" fn(ctx: *mut c_void, enabled: u8), // 0 = disabled, 1 = enabled
/// Wait for modem line changes (blocking; interruptible).
pub wait_mctrl_change: unsafe extern "C" fn(
ctx: *mut c_void,
wait_mask: u32,
timeout_ms: u32,
) -> u32,
/// Get serial port static info (for TIOCGSERIAL).
pub get_serial: unsafe extern "C" fn(ctx: *mut c_void, out: *mut SerialStruct),
/// Set serial port parameters (for TIOCSSERIAL).
pub set_serial: unsafe extern "C" fn(ctx: *mut c_void, new: *const SerialStruct) -> i32,
/// Transmit `len` bytes from `data` to the UART's TX path. Returns the
/// number of bytes accepted (>= 0) or a negative errno. This is the byte
/// transmission surface the console write path
/// ([Section 21.2](#console-and-kernel-logging)) and the TTY output flush submit
/// through — selected by `SerialTtyOps::TX_DATA`.
pub tx_data: unsafe extern "C" fn(
ctx: *mut c_void,
data: *const u8,
len: usize,
) -> isize,
}
impl SerialTtyOps {
/// KABI method selectors for `KabiServiceHandle::call(op, ..)`
/// ([Section 21.2](#console-and-kernel-logging)) — one per vtable slot, in field
/// order. The IDL dispatch resolves each id to the matching `SerialTtyOps`
/// function pointer.
pub const SET_TERMIOS: u32 = 0;
/// See `SET_TERMIOS`.
pub const GET_MCTRL: u32 = 1;
/// See `SET_TERMIOS`.
pub const SET_MCTRL: u32 = 2;
/// See `SET_TERMIOS`.
pub const SEND_BREAK: u32 = 3;
/// See `SET_TERMIOS`.
pub const START_TX: u32 = 4;
/// See `SET_TERMIOS`.
pub const STOP_TX: u32 = 5;
/// See `SET_TERMIOS`.
pub const SET_RX_ENABLED: u32 = 6;
/// See `SET_TERMIOS`.
pub const WAIT_MCTRL_CHANGE: u32 = 7;
/// See `SET_TERMIOS`.
pub const GET_SERIAL: u32 = 8;
/// See `SET_TERMIOS`.
pub const SET_SERIAL: u32 = 9;
/// Byte transmission selector — dispatches to `tx_data`.
pub const TX_DATA: u32 = 10;
}
// SerialTtyOps: vtable_size(usize) + 11 fn pointers.
// KABI vtable — size is pointer-width dependent.
#[cfg(target_pointer_width = "64")]
const _: () = assert!(core::mem::size_of::<SerialTtyOps>() == 96);
#[cfg(target_pointer_width = "32")]
const _: () = assert!(core::mem::size_of::<SerialTtyOps>() == 48);
21.1.6.7 Break Handling ioctls¶
/// TCSBRK: send a BREAK. If arg==0, send 0.25s break; if arg!=0, drain output.
pub const TCSBRK: u32 = 0x5409;
/// TCSBRKP: send break of arg*0.1s (POSIX break).
pub const TCSBRKP: u32 = 0x5425;
/// TIOCSBRK: start sending BREAK (until TIOCCBRK or TCSBRK arg=0).
pub const TIOCSBRK: u32 = 0x5427;
/// TIOCCBRK: stop sending BREAK.
pub const TIOCCBRK: u32 = 0x5428;
21.1.6.8 minicom Compatibility¶
minicom requires the following kernel features to operate correctly:
| Feature | UmkaOS mechanism |
|---|---|
| Open serial port exclusively | TIOCEXCL → sets TtyPort::exclusive flag |
| Set baud rate (e.g., 115200) | TCSETS2 with BOTHER or TCSETS with B115200 |
| Hardware flow control | CRTSCTS flag → set_mctrl(TIOCM_RTS) + hardware CTS monitoring |
| Software flow control | IXON/IXOFF handled in N_TTY line discipline |
| Raw mode (no echo, no canon) | c_lflag &= ~(ICANON|ECHO|ECHOE|ISIG) |
| Non-blocking I/O with timeout | VMIN=0, VTIME=10 (1-second timeout per read) |
| Modem control (dial) | TIOCMBIS(TIOCM_DTR|TIOCM_RTS) to assert DTR/RTS |
| Wait for DCD (carrier detect) | TIOCMIWAIT(TIOCM_CAR) |
| TIOCGSERIAL (low-latency mode) | TIOCSSERIAL with ASYNC_LOW_LATENCY flag |
| Z-modem (HDLC linedisc) | TIOCSETD(N_HDLC) for HDLC-based protocols |
All of these are implemented in UmkaOS. minicom, picocom, screen, and cu all work correctly.
21.1.7 Serial Service Provider (Cluster-Wide Serial Access)¶
Provider model: Serial service can be host-proxy (host kernel manages the UART
and forwards bytes) or device-native (a serial controller with Tier M firmware provides
the service directly). The wire protocol (SerialServiceOpcode) is identical in both
cases. Sharing model: exclusive (one peer at a time per serial port).
A node with a physical serial port can provide it as a cluster capability service. Any peer in the cluster can discover and use the serial port as if it were locally attached. This is the serial/TTY instantiation of the capability service provider model (Section 5.7).
Use cases: - Out-of-band management consoles (serial-connected switches, PDUs, UPS) - Industrial/embedded clusters (PLCs, sensors, GPS receivers, modems) - Debug consoles (kernel serial output from remote nodes) - Legacy equipment management (storage controllers, network appliances)
// umka-user-io/src/serial_service_provider.rs
/// Handle to a locally-attached serial/UART device (a registered `TtyDriver`
/// serial line). Opaque `u64` key into the serial driver's port table; the
/// serial service provider binds reads/writes to the physical port through it
/// without holding a direct driver reference across a domain boundary.
// kernel-internal, not KABI — opaque handle.
#[repr(transparent)]
pub struct SerialDeviceHandle(u64);
/// Provides a local serial port as a cluster service.
pub struct SerialServiceProvider {
/// Local serial device being served.
device: SerialDeviceHandle,
/// Service instance identifier for this exported port
/// ([Section 5.1](05-distributed.md#distributed-kernel-architecture--message-payload-structs)) —
/// distinct from the service TYPE `ServiceId("serial", 1)` below. Assigned
/// at export creation and stable across provider reboot and client
/// reconnect.
service_id: ServiceInstanceId,
/// Service endpoint on the peer protocol.
endpoint: PeerServiceEndpoint,
/// Current serial configuration (baud, parity, etc.).
config: Termios,
/// Connected client (at most one — serial is exclusive).
client: Option<PeerId>,
}
PeerCapFlags: SERIAL_PORT (bit 9) — advertised by peers that
provide serial port access.
ServiceId: ServiceId("serial", 1).
PeerServiceDescriptor.properties (32 bytes):
#[repr(C)]
pub struct SerialPortProperties {
/// Port name on the serving host (e.g., "ttyS0", "ttyUSB0").
pub port_name: [u8; 16],
/// Maximum supported baud rate.
pub max_baud: u32,
/// Capabilities bitmask.
/// bit 0: hardware flow control (RTS/CTS)
/// bit 1: modem control signals (DTR/DSR/DCD/RI)
/// bit 2: RS-485 mode
pub capabilities: u32,
pub _pad: [u8; 8],
}
// SerialPortProperties: [u8;16](16) + u32(4) + u32(4) + [u8;8](8) = 32 bytes.
// Wire struct (PeerServiceDescriptor.properties payload).
const_assert!(core::mem::size_of::<SerialPortProperties>() == 32);
Wire protocol — four opcodes via ServiceMessage/ServiceResponse:
#[repr(u16)]
pub enum SerialServiceOpcode {
/// Client → provider: transmit bytes.
/// Payload: raw bytes (up to 224 bytes per entry, continuation for more).
TxData = 0x0001,
/// Provider → client: received bytes from serial port.
/// Payload: raw bytes. Sent as data arrives (no batching delay).
RxData = 0x0002,
/// Client → provider: set serial configuration.
/// Payload: SerialConfig (baud, data bits, parity, stop bits, flow control).
SetConfig = 0x0010,
/// Client → provider: set/get modem control lines.
/// Payload: ModemControl (DTR, RTS, read DCD/DSR/RI/CTS).
ModemControl = 0x0020,
}
Serial service messages use the standard ServiceMessage/ServiceResponse
framing from the peer protocol
(Section 5.1). Each message
contains a ServiceMessage header (opcode, sequence_number, payload_length)
followed by an opcode-specific payload:
| Opcode | Dir | Payload | Details |
|---|---|---|---|
TxData (0x0001) |
Client->Provider | Raw bytes, 1-224 B per entry | Continuation entries for data > 224 bytes. No application-level sequence numbering within the byte stream — order is guaranteed by the RDMA RC QP (in-order delivery). |
RxData (0x0002) |
Provider->Client | Raw bytes or error-prefixed bytes | When error flags are present, payload uses per-character framing: normal byte = [byte]; error byte = [0xFF] [error_flag] [byte]. A literal 0xFF in data is escaped as [0xFF] [0x00] [0xFF]. This matches Linux PARMRK encoding. See the error reporting table below. |
SetConfig (0x0010) |
Client->Provider | SerialConfig struct (16 bytes) |
Response: ServiceResponse with status 0 (success) or -EINVAL (unsupported configuration). Synchronous — client blocks until response. |
ModemControl (0x0020) |
Bidirectional | ModemControlPayload (4 bytes) |
Client sends to set DTR/RTS. Provider sends asynchronously when DCD/DSR/RI/CTS change. |
/// Modem control payload for the ModemControl opcode. 4 bytes.
/// Direction: bidirectional. When sent by the client, bits 4-5 (DTR, RTS)
/// are commands. When sent by the provider, all bits reflect current
/// physical line state.
#[repr(C)]
pub struct ModemControlPayload {
/// Bitmask of modem signal states. Layout matches Linux TIOCM_* constants.
pub signals: u32,
}
// ModemControlPayload: u32(4) = 4 bytes. Wire struct.
const_assert!(core::mem::size_of::<ModemControlPayload>() == 4);
/// Serial line configuration. 16 bytes.
#[repr(C)]
pub struct SerialConfig {
pub baud_rate: u32, // e.g., 115200
pub data_bits: u8, // 5, 6, 7, or 8
pub parity: u8, // 0=none, 1=odd, 2=even
pub stop_bits: u8, // 1 or 2
pub flow_control: u8, // 0=none, 1=XON/XOFF, 2=RTS/CTS
pub flags: u8, // bit 0: BREAK active (1=assert, 0=deassert)
pub _pad: [u8; 7],
}
// SerialConfig: u32(4) + u8(1)*4 + u8(1) + [u8;7](7) = 16 bytes.
// Wire struct (SetConfig opcode payload).
const_assert!(core::mem::size_of::<SerialConfig>() == 16);
Capability gating: Remote serial access requires CAP_SERIAL_REMOTE
(Section 9.1). Checked at ServiceBind time.
Exclusive access: Serial ports are inherently single-client. If a second
peer tries to bind while a client is connected, ServiceBind returns
CapResponseStatus::Busy. The existing client must ServiceUnbind first.
Enforcement: the SerialServiceProvider.client field (Option<PeerId>) is
protected by the ServiceBind lock in the peer protocol layer
(Section 5.1). When a ServiceBind arrives:
- Acquire
ServiceBindlock (per-service spinlock). - Check
clientfield: ifSome(_), returnCapResponseStatus::Busy. - If
None: setclient = Some(new_peer_id), returnCapResponseStatus::Ok. - Release lock.
ServiceUnbind and peer failure clear the client field under the same lock.
No CAS retry loop is needed — the spinlock serializes all bind/unbind operations.
Latency: Byte-stream I/O at serial baud rates (115200 bps = ~14 KB/s
max) is negligible compared to RDMA bandwidth. The dominant latency is the
RDMA RTT (~3-5 us) per TxData/RxData message, which is invisible at
serial speeds. At 115200 baud, one character takes ~87 us on the wire --
the RDMA hop adds <6% latency.
Drain protocol: On graceful shutdown
(Section 5.8), the
serial service provider sends ServiceDrainNotify to the connected client.
The client closes the PTY and reconnects to an alternative peer (if
alternative_peer is set) or loses access. No data buffering needed --
serial is real-time, no writeback.
21.1.7.1 Serial Service Client (Consuming Peer)¶
On the consuming peer, the serial service client bridges the remote serial port into the local TTY subsystem via a PTY pair. Applications interact with the PTY slave and see a standard terminal device.
/// Client-side state for a bound remote serial port. One instance per
/// active ServiceBind to a serial service provider.
///
/// Deployment placement: lives in whatever domain its provider module (the
/// TTY/PTY machinery) is deployed to — bind-time configuration per the
/// section's Deployment-placement header, never identity. In the default
/// deployment that is the Core domain, co-located with the VFS; the
/// transport of every cross-module edge is selected at bind time.
pub struct SerialServiceClient {
/// ServiceBind connection to the remote serial port provider: the unique
/// owner of that connection and the `kabi_call!` dispatch anchor
/// ([Section 5.1](05-distributed.md#distributed-kernel-architecture--peer-connection-objects)).
/// Bridge shutdown calls `close()` explicitly, in process context, so the
/// drain and the final ServiceUnbind are ordered and observable; dropping
/// the handle without closing is safe but defers teardown to a work item.
connection: ServiceBindHandle,
/// Peer providing the serial port.
peer_id: PeerId,
/// PTY master file descriptor. The client kernel thread reads/writes
/// this fd to bridge data between the PTY and the ServiceMessage ring.
pty_master_fd: Fd,
/// PTY slave index (the N in /dev/pts/N). Used for symlink creation.
pty_slave_index: u32,
/// Shadow copy of the current serial configuration. Updated when the
/// client sends SetConfig to the provider, so the client can answer
/// local termios queries without a round trip.
config_shadow: SerialConfig,
/// Last known modem control line state from the provider.
/// Updated on each ModemControl response. Read by TIOCMGET ioctl.
modem_status: AtomicU32,
/// Bridge thread handle. Runs the PTY-to-service pump loop. A kthread is a
/// `Task`, so the canonical handle is `Arc<Task>`
/// ([Section 8.1](08-process.md#process-and-task-management)) — matching `TtyWorkerState::thread`.
bridge_thread: Arc<Task>,
/// Shutdown flag. Set to signal the bridge thread to exit.
shutdown: AtomicBool,
}
/// Modem control line state. Uses Linux TIOCM_* bitmask values directly.
/// TIOCMGET returns the raw `signals` field without translation.
#[repr(C)]
pub struct ModemControlState {
/// Bitmask of modem signal states using Linux TIOCM_* bit positions:
/// bit 1: TIOCM_DTR (0x002) — Data Terminal Ready (set by client)
/// bit 2: TIOCM_RTS (0x004) — Request To Send (set by client)
/// bit 5: TIOCM_CTS (0x020) — Clear To Send
/// bit 6: TIOCM_CAR (0x040) — Carrier Detect / DCD
/// bit 7: TIOCM_RNG (0x080) — Ring Indicator
/// bit 8: TIOCM_DSR (0x100) — Data Set Ready
pub signals: u32,
}
// ModemControlState: u32(4) = 4 bytes. Wire struct (TIOCMGET result).
const_assert!(core::mem::size_of::<ModemControlState>() == 4);
PTY bridge architecture: The kernel creates a PTY pair at ServiceBind time.
A dedicated kernel thread (serial_bridge_{N}) runs a pump loop:
-
RX path (provider -> user): The bridge thread polls the
ServiceMessagering for incomingRxDatamessages. Received bytes are written to the PTY master fd. The PTY slave's line discipline processes them (echo, canonical mode, signal generation) before delivering to the reading application. -
TX path (user -> provider): The bridge thread reads from the PTY master fd (which receives bytes written by applications to the PTY slave). Read bytes are packed into
TxDataServiceMessage entries and sent to the provider. Up to 224 bytes per ring entry; larger writes use continuation entries. -
Event loop: The bridge thread uses
poll()on both the PTY master fd and the ServiceMessage ring's eventfd, waking on either direction having data. This avoids busy-waiting and keeps CPU usage at zero when idle.
termios forwarding: When an application sets terminal attributes on the PTY
slave (tcsetattr(), stty), the PTY layer generates a TIOCSETS notification
on the master side. The bridge thread detects this by checking termios state
after each wake, compares against config_shadow, and sends a SetConfig
message to the provider for any changed parameters (baud rate, parity, stop
bits, flow control). The provider applies the configuration to the physical
UART.
Config_shadow synchronization: The bridge thread is single-threaded and
owns all config_shadow mutations. The sequence is:
- Detect termios change on PTY master.
- Copy new config into a local variable (NOT into
config_shadowyet). - Send
SetConfigto provider with the new config. - Wait for
ServiceResponse(synchronous — blocks the bridge thread). - On success: update
config_shadowto the new config. - On failure (
-EINVAL): revert PTY master termios toconfig_shadowvalues viatcsetattr()and returnEINVALto the application.
No CAS needed — single writer (bridge thread), atomic readers (TIOCMGET
reads modem_status with Acquire ordering). This eliminates the race
where config_shadow could temporarily hold a config that the provider
rejected.
Modem status: The provider sends ModemControl messages asynchronously when
physical modem control lines change state (DCD drop on disconnect, RI pulse on
incoming call, DSR/CTS transitions). The bridge thread receives these and
updates modem_status atomically. Applications querying TIOCMGET read the
cached modem_status without a network round trip. Setting modem lines
(TIOCMSET/TIOCMBIS/TIOCMBIC for DTR/RTS) generates a ModemControl
message to the provider.
BREAK forwarding: When an application sends a break condition (tcsendbreak(),
TCSBRK ioctl), the bridge thread sends a SetConfig message with flags
bit 0 set (BREAK active). The provider asserts BREAK on the physical serial
line. A second SetConfig with flags bit 0 clear deasserts BREAK. For
timed breaks (tcsendbreak(fd, duration)), the bridge thread sends assert,
sleeps for the requested duration (clamped to 250-500 ms per POSIX convention),
then sends deassert. The flags field is separate from flow_control to
avoid overloading flow control semantics with unrelated signaling.
Error reporting: The provider includes error flags in RxData messages when
the physical UART detects line errors. A one-byte error prefix per affected
character encodes the error type:
| Error Flag | Value | TTY Flag | Meaning |
|---|---|---|---|
| None | 0x00 |
Normal | Normal character |
| Parity | 0x01 |
Parity error | Parity error on this character |
| Framing | 0x02 |
Framing error | Framing error (missing stop bit) |
| Overrun | 0x04 |
Receive overrun | UART receive buffer overrun |
| Break | 0x08 |
Break condition | Break condition detected |
When error flags are present, RxData payload uses the Linux PARMRK-style
encoding: 0xFF, error_flag, character. This is the SAME encoding the local
framed-ingress producer contract uses (Section 21.1), so the
bridge's injection port is registered with ingress_framed = true and the
bridge passes the already-encoded RxData stream through VERBATIM — no
re-encoding, no flag translation, because the flag values are shared. The
client-side N_TTY decoder then applies the POSIX dispositions
(IGNPAR/INPCK/PARMRK for parity and framing, IGNBRK/BRKINT for break,
hw_rx_overrun for overrun).
Device naming: The client creates a symlink /dev/ttyRemote{N} pointing to
the PTY slave /dev/pts/{M}. The symlink is created via a sysfs device
registration under /sys/class/tty/ttyRemote{N}/ with attributes:
peer: peer ID of the provider nodeport: provider-side port name (fromSerialPortProperties.port_name)speed: current baud rate
Discovery: ls /sys/class/tty/ttyRemote*/ lists all remote serial ports.
Udev rules can create additional symlinks (e.g., /dev/serial/by-peer/).
Line discipline: The line discipline (N_TTY, N_SLIP, N_HDLC, etc.) always
runs on the client side, in the PTY slave's processing path. The provider
always sends and receives raw bytes -- it never interprets line editing,
signal generation, or protocol framing. This avoids split-brain where both
sides attempt line discipline processing, and ensures that stty settings
on the client are authoritative.
Reconnection: If the provider disconnects (peer failure, ServiceDrainNotify, or RDMA link error), the bridge thread enters a reconnection loop:
- The PTY stays open — applications don't see immediate errors. Reads block, writes buffer locally (bounded: 4 KB, matching typical serial buffer size).
- The bridge thread attempts to re-bind to the same service on the same peer
(or
alternative_peerif specified in ServiceDrainNotify). - On successful reconnect: re-send the last
SetConfigfromconfig_shadowto restore serial parameters, then drain the write buffer to the provider. - After
reconnect_timeout_secseconds (default: 30, configurable via sysfs at/sys/class/tty/ttyRemote{N}/reconnect_timeout) without successful reconnection: complete all pending reads with-EIO, discard write buffer. The PTY remains open but all subsequent operations return-EIOuntil a new provider connection is established. The configurable range is 5-300 seconds; values outside this range are clamped. - On provider return (peer re-joins cluster with same serial service): automatic rebind. Applications see no error.
Window size (TIOCGWINSZ/TIOCSWINSZ): Not forwarded to the provider. Serial ports have no concept of terminal window dimensions — window size is a property of the PTY slave, managed entirely on the client side by terminal emulators. This is correct behavior: the provider deals with a physical UART, not a terminal.
21.2 Console Framework and Kernel Logging¶
Deployment placement: The console framework (log ring buffer,
backend dispatch, console= parsing) lives in the Core domain and
is Evolvable — it is live-replaceable via EvolvableComponent
while retaining its Core-domain placement. Core-domain placement is
required because the framework must be callable from any kernel
context (interrupt, NMI, panic) without cross-domain ring
dispatch; during panic, the dispatch loop invokes each backend's
emergency_write() after isolation domains have been revoked.
Console backends (serial driver, netconsole) are tier-agnostic and
Evolvable; their manifests typically declare preferred_tier = 1
(latency-sensitive hardware access) and the loader selects
effective tier at bind time per Section 11.3. The
emergency serial output is the exception: it is statically linked
Tier-0-only (non-evolvable, panic-safe) and lives in
arch::current::serial so that it remains callable even if every
Evolvable component has crashed.
KABI interface name: console_backend_v1 (in interfaces/console_backend.kabi).
21.2.1 Kernel Log Ring Buffer¶
The kernel log ring buffer (klog) is the central store for all kernel diagnostic
messages. It replaces Linux's printk ring buffer with a lock-free, NMI-safe,
multi-producer design. All kernel subsystems write here; console backends read
from here.
21.2.1.1 Log Levels¶
// umka-nucleus/src/klog/mod.rs
/// Kernel log levels. Numerically compatible with Linux syslog(2) severity.
#[repr(u8)]
pub enum KlogLevel {
/// System is unusable (panic imminent).
Emerg = 0,
/// Action must be taken immediately.
Alert = 1,
/// Critical conditions (hardware failure, driver crash).
Crit = 2,
/// Error conditions (recoverable failures).
Err = 3,
/// Warning conditions (degraded operation).
Warning = 4,
/// Normal but significant events (driver loaded, device detected).
Notice = 5,
/// Informational messages (boot progress, configuration).
Info = 6,
/// Debug-level messages (disabled by default in production).
Debug = 7,
}
21.2.1.2 Log Entry Format¶
Each log entry is a descriptor (fixed-size metadata) plus variable-length text stored in a separate data ring. This two-ring design avoids wasting space on short messages and supports messages up to 1024 bytes without fragmentation.
/// Descriptor ring entry — fixed 64 bytes, cache-line aligned.
/// Writers claim a slot by CAS on the global sequence counter, then fill
/// the descriptor and mark it committed. Readers skip uncommitted slots.
#[repr(C, align(64))]
pub struct KlogDescriptor {
/// Monotonically increasing sequence number. Assigned by atomic
/// fetch_add on `KLOG_RING.next_seq`. Never wraps within 50-year
/// lifetime (u64 at 10M messages/sec = 58,000 years).
pub seq: u64,
/// Timestamp in nanoseconds since boot. Source:
/// `arch::current::cpu::read_timestamp_ns()`. In NMI context, this
/// may use a less precise source (TSC without interpolation).
pub timestamp_ns: u64,
/// Offset into the data ring where message text begins.
pub data_offset: u32,
/// Length of the message text in bytes (0..=1024).
pub text_len: u16,
/// Length of the subsystem prefix within text (e.g., 3 for "net").
/// Text format: "{subsystem}: {message}". If subsystem_len == 0,
/// no prefix is present.
pub subsystem_len: u8,
/// Log level (KlogLevel).
pub level: u8,
/// Syslog facility (0=kern, always 0 for kernel messages).
/// Stored for syslog(2) / /dev/kmsg compatibility.
pub facility: u8,
/// Flags.
pub flags: KlogFlags,
/// CPU that generated this message.
pub cpu: u16,
/// PID of the logging task. 0 for interrupt/NMI/idle context.
pub pid: u32,
/// Descriptor state. Writers set to COMMITTED after filling all
/// fields. Readers skip entries that are not COMMITTED.
/// On wrap, the reclaimer sets old entries to FREE.
pub state: AtomicU8,
/// Padding to 64 bytes. Fields end at offset 33; align(64) requires
/// 31 bytes of explicit padding to fill the cache line.
_pad: [u8; 31],
}
const_assert!(core::mem::size_of::<KlogDescriptor>() == 64);
bitflags! {
/// Per-entry flags.
pub struct KlogFlags: u8 {
/// Continuation of the previous message (no newline between).
const CONT = 1 << 0;
/// Message includes a trailing newline.
const NEWLINE = 1 << 1;
/// Written from NMI context (may have imprecise timestamp).
const NMI = 1 << 2;
/// Written during panic (after panic path entered).
const PANIC = 1 << 3;
}
}
/// Descriptor states.
#[repr(u8)]
pub enum KlogDescState {
/// Slot is free (available for writers).
Free = 0,
/// Slot is being written (writer claimed it but hasn't finished).
Reserved = 1,
/// Slot is committed and readable.
Committed = 2,
}
21.2.1.3 Ring Buffer Structure¶
/// The kernel log ring buffer. Two-ring design: a descriptor ring (fixed-size
/// entries) and a data ring (variable-length message text). The descriptor ring
/// is indexed by `seq % KLOG_DESC_COUNT`. The data ring is a byte-level
/// circular buffer with offsets stored in descriptors.
///
/// Concurrency model:
/// - **Writers** (any CPU, any context including NMI): claim a sequence number
/// via `AtomicU64::fetch_add(1, Relaxed)` on `next_seq`, write descriptor +
/// data, mark descriptor as COMMITTED.
/// - **Readers** (console backends, /dev/kmsg, pstore): track their own
/// `read_seq` and iterate forward, skipping FREE/RESERVED slots.
/// - **Reclaimer**: when the descriptor ring is full, the writer whose
/// `fetch_add` returns a seq that would overwrite a COMMITTED slot must
/// first mark that slot (and its data range) as FREE. Oldest messages
/// are silently lost (ring semantics).
///
/// NMI safety: no locks anywhere. Writers use CAS only for the sequence
/// counter. Data ring writes use the descriptor's `data_offset` + `text_len`
/// to claim a contiguous region (computed from the data ring's own atomic
/// write cursor). Worst case under NMI preemption: a RESERVED descriptor is
/// never committed; readers skip it, and it is eventually reclaimed.
/// Descriptor ring capacity. Power of 2 for fast modular indexing.
/// 4096 entries × 64 bytes = 256 KB descriptor ring.
const KLOG_DESC_COUNT: usize = 4096;
/// Data ring capacity. Sized for ~4096 average-length messages.
/// 256 KB data ring. Total klog memory: 512 KB (256 KB desc + 256 KB data).
const KLOG_DATA_SIZE: usize = 256 * 1024;
/// Maximum message text length. Messages longer than this are truncated.
const KLOG_MAX_TEXT: usize = 1024;
pub struct KlogRing {
/// Descriptor ring (fixed-size, indexed by seq % KLOG_DESC_COUNT).
pub descs: [KlogDescriptor; KLOG_DESC_COUNT],
/// Data ring (circular byte buffer for variable-length message text).
pub data: [u8; KLOG_DATA_SIZE],
/// Next sequence number to assign. Writers fetch_add(1) to claim.
pub next_seq: AtomicU64,
/// Next write offset in the data ring. Writers fetch_add(text_len)
/// to claim a contiguous region. Wraps modulo KLOG_DATA_SIZE.
pub data_write_pos: AtomicU32,
/// Console sequence: the oldest seq that has been delivered to all
/// console backends. Used by the console dispatcher to know where
/// to start reading after a new backend registers.
pub console_seq: AtomicU64,
/// Current default log level for console output (messages with level
/// > console_loglevel are not dispatched to console backends, but
/// are still stored in the ring for /dev/kmsg readers).
pub console_loglevel: AtomicU8,
}
/// Global klog ring. Allocated from slab at Phase 1.35 (post-slab-init).
/// Before that, all logging goes to the early log ring
/// ([Section 2.3](02-boot-hardware.md#boot-init-cross-arch--early-boot-log-ring)).
///
/// Held in a `BootOnceCell`, not a bare `OnceCell`: the ambient
/// `core::cell::OnceCell` is `!Sync` for every `T`, so a bare `static OnceCell`
/// does not satisfy the `Sync` bound on statics and will not compile.
/// `BootOnceCell` (§Write-Once Boot Publication Cell in [Section 2.3](02-boot-hardware.md#boot-init-cross-arch))
/// is a single-writer boot-publication cell: `set()` once at Phase 1.35, then
/// lock-free reads for the rest of the kernel lifetime. `Sync` for
/// `T: Send + Sync`, met here by `&'static KlogRing` (`KlogRing` is `Sync` — its
/// fields are all atomics and byte arrays).
pub static KLOG_RING: BootOnceCell<&'static KlogRing> = BootOnceCell::new();
21.2.1.4 Early Boot Ring Transition¶
Before slab init (Phases 0.x–1.2), the early log ring (Section 2.3) stores boot diagnostics as raw text in a 64 KB BSS buffer. At Phase 1.35 (post-slab-init):
- Allocate
KlogRingfrom slab (512 KB: 256 KB descriptors + 256 KB data). - Replay all early log entries into
KlogRingasKlogLevel::Infowith entries inseqorder, carrying the Phase-1.35 replay timestamp (emission times are not reconstructable — see the replay algorithm in Section 2.3). - Set
KLOG_RINGviaBootOnceCell::set(). - Redirect
early_log()to callklog()(the flag set byearly_log_replay()already handles this — see Section 2.3). - The early log ring BSS memory can be reclaimed after replay.
21.2.1.5 Writer Interface¶
/// Write a message to the kernel log ring buffer.
///
/// Safe to call from any context: process, softirq, hardirq, NMI.
/// Messages longer than KLOG_MAX_TEXT (1024 bytes) are truncated.
///
/// This is the kernel logging entry point. All kernel subsystems call this.
pub fn klog(level: KlogLevel, subsystem: &str, msg: &str);
/// Formatted variant (format string + args, no heap allocation).
/// Uses a per-CPU scratch buffer (1024 bytes) for formatting.
/// In NMI context, uses a separate NMI scratch buffer to avoid
/// corrupting the interrupted CPU's buffer.
pub fn klog_fmt(level: KlogLevel, subsystem: &str, fmt: core::fmt::Arguments<'_>);
21.2.1.6 Reader Interface¶
/// A klog reader tracks its position in the ring via `read_seq`.
/// Multiple independent readers can exist (console dispatcher,
/// /dev/kmsg file descriptors, pstore dumper).
pub struct KlogReader {
/// Next sequence number to read. Initialized to `KLOG_RING.console_seq`
/// for new readers (skip already-delivered messages) or to 0 for
/// /dev/kmsg readers opened with `SEEK_SET` to 0 (read full ring).
pub read_seq: u64,
}
impl KlogReader {
/// Read the next committed entry. Returns `None` if no new entries.
/// Skips FREE and RESERVED descriptors (treats them as gaps).
/// If the reader has fallen behind and entries were overwritten,
/// advances `read_seq` to the oldest available entry and sets
/// `KlogReadResult::gap` to the number of lost messages.
pub fn next(&mut self) -> Option<KlogReadResult>;
}
pub struct KlogReadResult {
/// The descriptor (metadata).
pub desc: KlogDescriptor,
/// The message text (copied from data ring).
pub text: ArrayVec<u8, KLOG_MAX_TEXT>,
/// Number of messages lost due to ring wrap since last read.
/// 0 in normal operation.
pub gap: u64,
}
21.2.1.7 /dev/kmsg Interface¶
The kernel log ring is exposed to userspace as /dev/kmsg (major 1, minor 11),
compatible with Linux's /dev/kmsg format:
- read(): Returns the next log entry in the format:
<priority>,<seq>,<timestamp_us>,<flags>;<text>\nwherepriority = facility * 8 + level, matching syslog(2). - write(): Injects a user-supplied message at
KlogLevel::Info(or level parsed from<N>prefix). Used bylogger(1)and systemd-journald. - poll():
POLLINwhen new entries are available after the reader'sread_seq. - lseek(SEEK_DATA, 0): Reset reader to oldest available entry.
- lseek(SEEK_END, 0): Reset reader to newest entry (skip history).
21.2.1.8 syslog(2) Syscall Compatibility¶
The syslog(2) syscall (not to be confused with the C library's syslog(3))
provides Linux-compatible access to the log ring:
| Command | Description |
|---|---|
SYSLOG_ACTION_READ (2) |
Read from ring, blocking. Requires CAP_SYSLOG. |
SYSLOG_ACTION_READ_ALL (3) |
Read entire ring (non-destructive). |
SYSLOG_ACTION_READ_CLEAR (4) |
Read and clear ring. |
SYSLOG_ACTION_CLEAR (5) |
Clear ring (advance console_seq). |
SYSLOG_ACTION_CONSOLE_OFF (6) |
Disable console output. |
SYSLOG_ACTION_CONSOLE_ON (7) |
Enable console output. |
SYSLOG_ACTION_CONSOLE_LEVEL (8) |
Set console_loglevel. |
SYSLOG_ACTION_SIZE_UNREAD (9) |
Return bytes available. |
SYSLOG_ACTION_SIZE_BUFFER (10) |
Return total ring buffer size. |
Commands that read or clear require CAP_SYSLOG (Linux capability bit 34).
21.2.2 Console Framework¶
The console framework dispatches log messages from the klog ring buffer to registered console backends. It is the kernel's fan-out mechanism: a single log message is delivered to every active backend (serial console, VGA text, netconsole, etc.).
Evolvable: The console framework implements EvolvableComponent. Its state
is the backend list, console_loglevel, and per-backend read positions. Live
evolution swaps the dispatch logic; backends are not disturbed. The framework
has no hot-path callers (log dispatch is warm-path: bounded by I/O throughput,
not CPU), so the EvolvableComponent overhead is acceptable.
21.2.2.1 ConsoleBackend Trait¶
// umka-nucleus/src/console/mod.rs — console backend contract
/// A console backend receives formatted log messages from the klog ring
/// and outputs them to a specific device (serial port, network, VGA).
///
/// Backends register via `console_register()` and are called by the
/// console dispatcher thread. Multiple backends can be active
/// simultaneously (fan-out).
pub trait ConsoleBackend: Send + Sync {
/// Write a log message to this console backend. Called from the
/// console dispatcher thread (process context, preemptible).
///
/// `text` is the formatted message including subsystem prefix and
/// newline. The backend must not assume any particular encoding
/// (UTF-8 text is typical but not guaranteed for binary dmesg).
///
/// Returns `Ok(())` on success, `Err(ConsoleError)` on failure.
/// Persistent failures cause the framework to deregister the backend
/// after `CONSOLE_MAX_ERRORS` (16) consecutive errors.
fn write(&self, text: &[u8], meta: &KlogDescriptor) -> Result<(), ConsoleError>;
/// Emergency write — called during panic with IRQs disabled, possibly
/// from NMI context. Must be lock-free and allocation-free.
///
/// Backends that cannot safely write in panic context should return
/// `Err(ConsoleError::NotAvailable)` immediately. The framework will
/// continue to the next backend in the priority chain.
///
/// Default implementation returns `NotAvailable`.
fn emergency_write(&self, text: &[u8]) -> Result<(), ConsoleError> {
Err(ConsoleError::NotAvailable)
}
/// Return this backend's priority. Lower values = higher priority.
/// Used for ordering during panic fallback (try high-priority backends
/// first). Standard priority ranges:
/// - 0–9: Emergency backends — statically linked, panic-safe, always
/// reachable after every isolation domain has been revoked
/// (e.g., `arch::current::serial`, emergency VGA).
/// - 10–19: Evolvable kernel-hosted backends — call hardware through
/// a KABI transport; reachable after the panic path revokes all
/// isolation domains (serial driver, netconsole).
/// - 20–29: Userspace log-aggregator backends — reachable only while
/// the network/IPC path to userspace is still alive.
fn priority(&self) -> u8;
/// Human-readable name for this backend (e.g., "ttyS0", "netcon0").
fn name(&self) -> &str;
/// Optional: backend-specific setup invoked when `console=` parameters
/// are parsed. `options` is the part after the device name and comma
/// (e.g., "115200n8" for `console=ttyS0,115200n8`).
///
/// Default implementation ignores options.
fn setup(&self, _options: &str) -> Result<(), ConsoleError> {
Ok(())
}
}
pub enum ConsoleError {
/// Backend cannot write (hardware not ready, network down, etc.).
NotAvailable,
/// Transient I/O error (retry may succeed).
IoError,
/// Backend is permanently failed (deregister it).
Failed,
}
21.2.2.2 Backend Registration¶
/// Maximum number of simultaneously active console backends.
/// Matches Linux's MAX_CMDLINECONSOLES (8).
const CONSOLE_MAX_BACKENDS: usize = 8;
/// Register a console backend. The backend is appended to the active
/// list and begins receiving log messages from the current klog position.
///
/// If `CONSOLE_MAX_BACKENDS` are already registered, returns
/// `Err(ConsoleError::Failed)`.
///
/// Called from driver init context (warm path, may allocate).
pub fn console_register(
backend: &'static dyn ConsoleBackend,
) -> Result<(), ConsoleError>;
/// Deregister a console backend. The backend stops receiving messages.
/// Called during driver unload or on persistent backend failure.
pub fn console_deregister(backend: &'static dyn ConsoleBackend);
/// Iterate the currently-registered console backends in dispatch fan-out order.
/// Called by the klogd dispatcher thread (warm path) once per drained entry.
/// The returned iterator borrows the active backend list; registration and
/// deregistration are serialized against dispatch, so the backend set is stable
/// for the duration of one fan-out pass. Backends appear in registration order;
/// the panic path instead walks them by `priority()` (see §Panic Console Path).
pub fn console_backends() -> impl Iterator<Item = &'static dyn ConsoleBackend>;
21.2.2.3 Console Dispatcher Thread¶
The console framework runs a dedicated kernel thread (klogd) that reads from
the klog ring buffer and dispatches messages to all registered backends:
/// Console dispatcher. Runs as a kernel thread started at Phase 2.8
/// (post-workqueue-init). Before this thread starts, log messages are
/// stored in the klog ring but not dispatched — they accumulate and are
/// delivered in a burst when the thread starts.
///
/// Priority: SCHED_OTHER, nice -5 (same as TTY workers). Elevated to
/// nice -15 if dispatch falls behind (> 256 undispatched entries).
fn klogd_main() -> ! {
// `KLOG_RING` is a `BootOnceCell` (no `Deref`): read the `&'static KlogRing`
// once via `.get()`. It is published (`set`) at Phase 1.35; klogd starts at
// Phase 2.8, so the cell is always initialized here — `.expect()` documents
// that boot-ordering invariant (same idiom as `ZERO_PAGE_PFN.get().copied()`).
let klog_ring = KLOG_RING.get().copied()
.expect("KLOG_RING published at Phase 1.35, before klogd starts at Phase 2.8");
let mut reader = KlogReader::new_from_console_seq();
loop {
// Wait for new entries.
klog_ring.wait_for_entries(&reader);
// Dispatch all available entries to all backends.
while let Some(entry) = reader.next() {
// Skip entries below console_loglevel.
if entry.desc.level > klog_ring.console_loglevel.load(Relaxed) {
continue;
}
// Fan-out to all registered backends.
for backend in console_backends() {
let _ = backend.write(&entry.text, &entry.desc);
}
}
}
}
During panic, the dispatcher thread is bypassed. The panic path calls
emergency_write() directly on each backend (see §Panic Console Path below).
21.2.2.4 Log Level Filtering¶
Console output is filtered by console_loglevel (default: KlogLevel::Info = 6).
Messages with level > console_loglevel are suppressed from console backends
but remain in the klog ring for /dev/kmsg readers.
Controllable via:
- Boot parameter: umka.loglevel=N (0–7)
- syslog(2): SYSLOG_ACTION_CONSOLE_LEVEL
- umkafs: /ukfs/kernel/console_loglevel (read-write)
21.2.3 Kernel Command Line Console Parameters¶
21.2.3.1 console= Syntax¶
The console= boot parameter selects which console backends are active and
configures their hardware parameters. Syntax is Linux-compatible:
Multiple console= parameters can be specified; all named backends receive
output. The last console= device becomes the primary console
(/dev/console points to it), matching Linux behavior.
Supported device specifiers:
| Device | Backend | Options Format | Example |
|---|---|---|---|
ttyS<N> |
Serial port N | [baudrate][parity][bits][flow] |
console=ttyS0,115200n8 |
ttyS<N> |
Serial port N | (no options = 115200,8N1) | console=ttyS1 |
uart[8250],io,<addr> |
8250 UART at I/O port | [,baudrate] |
console=uart,io,0x3f8,115200 |
uart[8250],mmio,<addr> |
8250 UART at MMIO addr | [,baudrate] |
console=uart,mmio,0x09000000 |
hvc<N> |
Hypervisor console N | (none) | console=hvc0 |
netcon<N> |
Netconsole target N | @<src_ip>/<dev>,@<dst_ip>/<dst_mac> |
See §Netconsole |
null |
Discard output | (none) | console=null |
Options parsing for ttyS:
baudrate: 300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200,
230400, 460800, 500000, 576000, 921600, 1000000, 1500000,
2000000, 3000000, 4000000 (default: 115200)
parity: n = none, o = odd, e = even (default: n)
bits: 7 or 8 (default: 8)
flow: r = RTS/CTS flow control (default: none)
Example: console=ttyS1,9600e7r → serial port 1, 9600 baud, even parity,
7 data bits, RTS/CTS flow control.
21.2.3.2 earlycon= Syntax¶
The earlycon= parameter configures the Tier 0 emergency serial console that
operates before the full console framework is available. Unlike console=,
earlycon= configures the arch::current::serial layer directly — it does
not register a ConsoleBackend.
| Type | Hardware | Platforms |
|---|---|---|
uart8250,io,<port> |
16550 UART at I/O port | x86-64 |
uart8250,mmio,<addr> |
16550 UART at MMIO address | RISC-V, PPC32 |
pl011,mmio,<addr> |
ARM PL011 UART | AArch64, ARMv7 |
sbi |
SBI console calls | RISC-V |
opal |
OPAL firmware calls | PPC64LE |
sclp |
SCLP console | s390x |
Without earlycon=, the emergency serial console uses platform defaults
(COM1/0x3F8 on x86-64, DTB stdout-path on DT platforms). The earlycon=
parameter overrides these defaults for non-standard hardware configurations.
21.2.3.3 Boot Parameter Registration¶
Console parameters are registered in the boot parameter registry (Section 20.9):
| Parameter | Schema | Description |
|---|---|---|
console |
String (multi) | Console backend device + options |
earlycon |
String | Early console type + address |
umka.loglevel |
u8 (0–7) | Default console log level |
umka.log_buf_len |
Size | Klog ring effective capacity, up to compile-time max of 512K. To increase beyond the default, reconfigure KLOG_DESC_COUNT and KLOG_DATA_SIZE at compile time. |
21.2.4 Serial Console Backend¶
The serial console backend connects the console framework to physical
serial ports via the UART driver (a tier-agnostic KABI driver whose
manifest typically declares preferred_tier = 1). It bridges the gap
between the kernel's log ring and the hardware UART, handling baud
rate configuration, port selection, and the transition from the
statically linked emergency serial to the Evolvable KABI UART driver
during boot.
21.2.4.1 Architecture¶
┌─────────────────────────────────────────────────────┐
│ klog ring buffer (Core-domain, Evolvable) │
│ ↓ klogd dispatcher thread │
│ ┌───────────────────────────────────────────┐ │
│ │ Console Framework (Core-domain, Evolvable)│ │
│ │ fan-out to all registered backends │ │
│ └──────┬────────────────┬───────────────────┘ │
│ │ │ │
│ ┌──────▼──────┐ ┌──────▼──────────┐ │
│ │ Serial │ │ Netconsole │ (other │
│ │ Console │ │ Backend │ backends) │
│ │ Backend │ │ (KABI, cross- │ │
│ │ (KABI, │ │ domain) │ │
│ │ cross- │ └──────┬──────────┘ │
│ │ domain) │ │ │
│ └──────┬──────┘ │ │
│ │ KABI transport │ UDP via umka-net │
│ ┌──────▼──────┐ │ │
│ │ UART Driver │ ┌──────▼──────────┐ │
│ │ (KABI) │ │ NIC Driver │ │
│ │ 16550/PL011 │ │ (KABI) │ │
│ └─────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Emergency Serial (Tier 0 static, panic-safe)│ │
│ │ arch::current::serial::puts() │ │
│ │ Panic-only fallback. No KABI, no isolation. │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
21.2.4.2 KABI Serial Console Backend¶
The serial console backend is a thin adapter between the console
framework and the UART driver exposed via the SerialTtyOps KABI
(Section 21.1). The UART driver is
tier-agnostic; the backend invokes it through kabi_call!, which
resolves to a direct vtable call when the driver binds at effective
Tier 0 (same domain as the console framework) or to a ring
transport otherwise.
/// Serial console backend. Wraps a UART driver's KABI service handle
/// regardless of the driver's effective tier at bind time.
pub struct SerialConsoleBackend {
/// KABI service handle to the UART driver (resolved at bind time).
service: KabiServiceHandle,
/// Which serial port this backend drives (0 = ttyS0, 1 = ttyS1, ...).
port_index: u8,
/// Human-readable port name: "ttyS0", "ttyS1", etc.
/// Computed from `port_index` at construction: `format!("ttyS{}", port_index)`.
port_name: ArrayString<8>,
/// Configured baud rate (from console= parameter or default 115200).
baud_rate: u32,
/// Whether this is the primary console (/dev/console target).
is_primary: bool,
}
impl ConsoleBackend for SerialConsoleBackend {
fn write(&self, text: &[u8], _meta: &KlogDescriptor) -> Result<(), ConsoleError> {
// kabi_call! into the UART driver's transmit function.
// If the driver is bound in a different domain from the console
// framework (effective Tier 1 with hardware domain isolation, or
// effective Tier 2 process isolation), `kabi_call!` resolves to
// the appropriate cross-domain ring transport; if the driver is
// bound in the Core domain (effective Tier 0), it resolves to a
// direct vtable call with no domain switch.
self.service.call(SerialTtyOps::TX_DATA, text)
.map_err(|_| ConsoleError::IoError)
}
fn emergency_write(&self, text: &[u8]) -> Result<(), ConsoleError> {
// During panic: domain isolation is revoked (PKRU=0 / all
// permissions). Call the UART driver's emergency path directly
// as a T0 call (no ring buffer, no domain switch).
// The driver's emergency_write must be lock-free and poll the
// UART TX-ready bit directly.
unsafe { self.service.emergency_call(SerialTtyOps::TX_DATA, text) }
.map_err(|_| ConsoleError::IoError)
}
fn priority(&self) -> u8 { 10 }
fn name(&self) -> &str {
// Returns "ttyS0", "ttyS1", etc.
// Name stored inline (ArrayString<8>).
&self.port_name
}
fn setup(&self, options: &str) -> Result<(), ConsoleError> {
// Parse "115200n8r" format and configure UART via KABI.
let config = parse_serial_options(options)?;
self.service.call(SerialTtyOps::SET_TERMIOS, &config)
.map_err(|_| ConsoleError::IoError)
}
}
// `KabiServiceHandle::emergency_call(op, data)` is the FORCED-direct
// panic-path transport bypass defined canonically in
// [Section 12.8](12-kabi.md#kabi-domain-runtime--emergency-direct-dispatch-panic-path-transport-bypass):
// it invokes vtable slot `op` (here `SerialTtyOps::TX_DATA`, whose `tx_data`
// method is `@emergency`-qualified: lock-free, allocation-free, polls the
// UART TX-ready bit directly) through the handle's raw `vtable`/`ctx`
// pointers — no ring, no completion wait, no quiescing interception, no
// generation check. Preconditions P1-P5 documented there; the panic console
// path satisfies P1/P2 by construction (all CPUs halted, isolation revoked
// before console output). If the UART driver is bound at Tier 2 (Ring 3
// process — unreachable by any direct call from panic context), the call
// returns `KabiError::InvalidHandle` and the console framework falls back to
// the statically linked emergency serial (`arch::current::serial`).
/// Parse a `console=` serial options string into a `SerialConfig`
/// ([Section 21.1](#tty-and-pty-subsystem)) for the UART's `SET_TERMIOS` KABI call.
///
/// The options string is the part after the device name and comma, in the
/// Linux `uart_parse_options` form `<baud><parity><bits><flow>`, e.g.
/// `"115200n8"` or `"115200n8r"` — baud (integer), parity (`n`/`o`/`e`), data
/// bits (`5`-`8`), optional flow control (`r` = RTS/CTS). Omitted fields take
/// the defaults 115200 baud, no parity, 8 data bits, 1 stop bit, no flow
/// control. Returns `Err(ConsoleError::IoError)` if the baud field is not a
/// valid integer or a field is out of range.
fn parse_serial_options(options: &str) -> Result<SerialConfig, ConsoleError>;
21.2.4.3 Port Discovery¶
Serial port discovery uses the same mechanisms as the TTY subsystem:
- ACPI platforms (x86-64, AArch64 servers): Serial ports enumerated from
ACPI SPCR (Serial Port Console Redirection Table) and ACPI namespace
\_SBdevice entries with_HID=PNP0501(16550) orARMH0011(PL011). - DT platforms: Serial ports discovered from
/serial@<addr>nodes oraliases(serial0,serial1, ...). Thestdout-pathproperty in/chosenidentifies the default console port. - x86-64 legacy: COM1–COM4 at standard I/O ports (0x3F8, 0x2F8, 0x3E8, 0x2E8) are probed if no ACPI SPCR is present.
The port discovery order determines the ttyS<N> numbering: the device
matching stdout-path (DT) or SPCR (ACPI) is always ttyS0.
21.2.4.4 Boot Transition: Emergency Serial → KABI UART Driver¶
During boot, serial console output transitions from the statically linked emergency serial to the Evolvable KABI UART driver:
| Boot Phase | Serial Output Path | Notes |
|---|---|---|
| 0.1–1.2 | arch::current::serial::puts() (Tier 0 static) |
Hardcoded port, 115200 8N1 |
| 1.3–4.x | early_log() → klog ring (stored, not dispatched) |
klogd not yet running |
| 2.8 | klogd starts, reads klog ring, dispatches to emergency serial backend | Emergency serial registered as ConsoleBackend with priority 5 |
| 5.3 | KABI UART driver loads, registers SerialConsoleBackend | Priority 10; emergency serial backend remains as fallback |
| 5.3+ | klogd dispatches to the SerialConsoleBackend (via kabi_call! to the UART driver) | Full baud rate / port config applied |
| Panic | Framework calls emergency_write() on all backends → falls through to Tier 0 arch::current::serial::puts() |
See §Panic Console Path |
/// `arch::current::serial` primitive: synchronously write the complete UTF-8
/// message through the boot-discovered emergency serial path. Direct,
/// unbuffered, lock-free, allocation-free, and callable from NMI/panic
/// handling; it must not route through `early_log()` or the console dispatcher.
fn puts(message: &str);
/// `arch::current::serial` primitive: synchronously write one byte through the
/// boot-discovered emergency serial path. Lock-free, allocation-free, and
/// callable during boot and panic handling.
fn putb(byte: u8);
Emergency serial as ConsoleBackend: Between Phase 2.8 and the
load of the KABI UART driver (typically Phase 5.3), the Tier 0
static emergency serial is wrapped in a minimal ConsoleBackend
adapter:
/// Tier 0 static emergency serial wrapped as a ConsoleBackend.
/// Active from Phase 2.8 until the KABI UART driver takes over.
/// Remains registered as a fallback after the KABI UART backend
/// registers its own SerialConsoleBackend.
struct EmergencySerialBackend;
impl ConsoleBackend for EmergencySerialBackend {
fn write(&self, text: &[u8], _meta: &KlogDescriptor) -> Result<(), ConsoleError> {
for &b in text {
arch::current::serial::putb(b);
}
Ok(())
}
fn emergency_write(&self, text: &[u8]) -> Result<(), ConsoleError> {
// Same as write() — already lock-free and allocation-free.
self.write(text, &KlogDescriptor::ZERO)
}
fn priority(&self) -> u8 { 5 } // Higher priority than the KABI UART SerialConsoleBackend
fn name(&self) -> &str { "earlycon" }
}
21.2.5 Netconsole¶
Netconsole sends kernel log messages over UDP to a remote log collector. It provides remote kernel debugging without physical serial access — critical for development on real hardware and for production monitoring of headless systems.
21.2.5.1 Design Constraints¶
- Co-located with umka-net (Evolvable): netconsole is a consumer
inside the umka-net domain rather than a Core component. It is
tier-agnostic and shares umka-net's effective tier at bind time.
Its manifest typically declares
preferred_tier = 1. - Available only after Phase 5.3: requires the network stack (Phase 4.6), a NIC driver (Phase 5.3), and a configured IP address.
- Not the primary console: netconsole supplements serial/VGA, it does not replace them. If the network is down, other backends continue working.
- Panic path: uses pre-allocated resources and direct NIC access to transmit final messages when the kernel is dying (see §Panic Transmit Path).
21.2.5.2 Target Configuration¶
Each netconsole target is a remote UDP endpoint that receives kernel log messages. Up to 4 targets can be configured simultaneously.
/// Maximum number of simultaneous netconsole targets.
const NETCONSOLE_MAX_TARGETS: usize = 4;
/// A netconsole target: a remote host receiving kernel log messages via UDP.
pub struct NetconsoleTarget {
/// Target name (for configfs identification, e.g., "target0").
pub name: ArrayString<16>,
/// Source IP address (0.0.0.0 = auto-select based on routing).
pub src_ip: IpAddr,
/// Source UDP port (default: 6665).
pub src_port: u16,
/// Network device name to use for transmission (e.g., "eth0").
/// Empty string = auto-select based on routing.
pub dev_name: ArrayString<16>,
/// Destination IP address (required).
pub dst_ip: IpAddr,
/// Destination UDP port (default: 6666).
pub dst_port: u16,
/// Destination MAC address (required for same-subnet targets;
/// ff:ff:ff:ff:ff:ff for broadcast; resolved via ARP for routed targets).
pub dst_mac: [u8; 6],
/// Whether this target is enabled (can be toggled at runtime).
pub enabled: AtomicBool,
/// Minimum log level to send to this target (default: KlogLevel::Info).
/// Messages with level > this value are not sent.
pub loglevel: AtomicU8,
/// Extended message format (include metadata headers). Default: true.
pub extended: bool,
/// Per-target transmit rate limiter (token bucket: capacity 100, refill
/// 1000 tokens/sec; one message costs one token). Prevents a logging storm
/// from saturating the link — excess messages are dropped silently. The
/// standard `TokenBucket` primitive is used rather than a bespoke limiter
/// ([Section 3.11](03-concurrency.md#workqueue-deferred-work)); single-sender is satisfied because the
/// console `write()` path is serialized by the console lock.
pub rate_limiter: TokenBucket,
/// Pre-allocated panic transmit resources (see §Panic Transmit Path).
pub panic_tx: Option<PanicTxResources>,
}
/// Netconsole console backend. Registered with the console framework as a single
/// `ConsoleBackend` ([Section 21.2](#console-and-kernel-logging)) that fans each log message
/// out to every enabled target. Normal-path transmission uses UDP through
/// umka-net (`udp_send`, below); the panic path bypasses the stack via each
/// target's pre-allocated `panic_tx` slot (see §Panic Transmit Path).
pub struct NetconsoleBackend {
/// Configured targets (bounded). Added/removed at runtime via configfs;
/// `NETCONSOLE_MAX_TARGETS` fixes the upper bound so no heap growth occurs
/// on the log-dispatch path.
targets: ArrayVec<NetconsoleTarget, NETCONSOLE_MAX_TARGETS>,
}
NetconsoleBackend::udp_send(&self, target, payload) is the normal-path helper:
it builds a UDP datagram (NetBuf) addressed to target.dst_ip:dst_port and
submits it through udp_sendmsg() in umka-net. It is best-effort — transmit
errors are dropped (netconsole never blocks the log path).
Boot parameter configuration:
The + prefix enables extended message format. Examples:
# Basic: send to 10.0.0.1 port 6666, auto-select source
netconsole=@/,@10.0.0.1/
# Extended format, from eth0, to specific MAC
netconsole=+@10.0.0.2/eth0,6666@10.0.0.1/aa:bb:cc:dd:ee:ff
# Multiple targets (multiple parameters)
netconsole=@/,@10.0.0.1/ netconsole=@/,@10.0.0.2/
21.2.5.3 configfs Runtime Configuration¶
Netconsole targets can be added, modified, and removed at runtime via
configfs, mounted at /sys/kernel/config/netconsole/:
/sys/kernel/config/netconsole/
├── target0/
│ ├── enabled # 0 or 1
│ ├── dev_name # "eth0"
│ ├── local_ip # "10.0.0.2"
│ ├── local_port # "6665"
│ ├── remote_ip # "10.0.0.1"
│ ├── remote_port # "6666"
│ ├── remote_mac # "aa:bb:cc:dd:ee:ff"
│ ├── extended # 0 or 1
│ └── loglevel # 0-7
└── target1/
└── ...
Creating a directory creates a target; removing the directory removes it. Writes to parameter files reconfigure the target atomically (the target is briefly disabled during reconfiguration, then re-enabled). The configfs interface matches Linux's netconsole configfs layout for tooling compatibility.
21.2.5.4 Message Format¶
Basic format (one UDP datagram per log message):
Where priority = facility * 8 + level (syslog encoding). Example:
<6>eth0: link up, 1000 Mbps\n
Extended format (enabled by + prefix or extended=1):
Extended format adds structured metadata as key=value continuation lines, matching Linux's extended netconsole format. This enables log aggregators (syslog-ng, rsyslog, Loki) to parse and index kernel messages without regex-based extraction.
21.2.5.5 Normal Transmit Path¶
During steady-state operation, netconsole transmits via the standard network stack:
klogd thread
→ ConsoleBackend::write() on NetconsoleBackend
→ for each enabled target:
→ build UDP datagram (NetBuf) with message payload
→ udp_sendmsg() via umka-net
→ route_lookup() → ipv4_send() → NetDevice::dispatch_xmit()
→ kabi_call! into NIC driver (direct vtable call if
same-domain, cross-domain ring otherwise) → hardware TX
This is an ordinary UDP send through the full network stack. No special bypass is needed for normal operation. The transmit path inherits all standard networking features: routing, ARP resolution, VLAN tagging, checksum offload.
Rate limiting: Netconsole limits transmission to 1000 messages/second per target (token bucket, capacity 100, refill 1000/sec). Excess messages are silently dropped. This prevents a logging storm from saturating the network link. The rate limit is per-target and configurable via configfs.
impl ConsoleBackend for NetconsoleBackend {
fn write(&self, text: &[u8], meta: &KlogDescriptor) -> Result<(), ConsoleError> {
for target in &self.targets {
if !target.enabled.load(Relaxed) {
continue;
}
if meta.level > target.loglevel.load(Relaxed) {
continue;
}
if !target.rate_limiter.try_consume(1) {
continue; // Rate limited, drop silently.
}
let payload = if target.extended {
format_extended(text, meta)
} else {
format_basic(text, meta)
};
// UDP send via umka-net. Errors are silently ignored
// (netconsole is best-effort).
let _ = self.udp_send(&target, &payload);
}
Ok(())
}
fn priority(&self) -> u8 { 15 }
fn name(&self) -> &str { "netcon" }
}
/// Maximum netconsole UDP payload: standard 1500-byte Ethernet MTU minus the
/// 20-byte IPv4 and 8-byte UDP headers. Messages longer than this are truncated
/// (netconsole never fragments). Return buffers are inline (no heap on the log
/// path).
const NETCONSOLE_MAX_PAYLOAD: usize = 1472;
/// Format a log message in **basic** netconsole form: `<priority>text\n`, where
/// `priority = facility * 8 + level` (syslog encoding). One datagram per message.
fn format_basic(
text: &[u8],
meta: &KlogDescriptor,
) -> ArrayVec<u8, NETCONSOLE_MAX_PAYLOAD>;
/// Format a log message in **extended** netconsole form
/// (`<level>,<seq>,<ts_us>,<flags>;text\n` followed by ` SUBSYSTEM=`, ` CPU=`,
/// ` PID=` continuation lines), matching Linux's extended format so aggregators
/// (rsyslog, syslog-ng, Loki) can index kernel messages without regex parsing.
fn format_extended(
text: &[u8],
meta: &KlogDescriptor,
) -> ArrayVec<u8, NETCONSOLE_MAX_PAYLOAD>;
21.2.5.6 Panic Transmit Path¶
During kernel panic, the normal network stack (umka-net) may be dead. The netconsole panic path bypasses the entire network stack and any driver isolation boundaries that were active before panic (hardware memory domains, process isolation, IOMMU translation) to transmit final messages directly via pre-allocated hardware resources.
Design: Each netconsole target pre-allocates a "panic TX slot" during normal operation. This slot contains everything needed to transmit one UDP datagram without any allocation, locking, or domain switching:
/// Pre-allocated resources for panic-time netconsole transmission.
/// Allocated during target setup (warm path). Used during panic (NMI-safe).
pub struct PanicTxResources {
/// DMA-coherent buffer for the panic message. Pre-allocated, pre-mapped
/// in the NIC's IOMMU domain. Contains a pre-built Ethernet + IP + UDP
/// header; only the UDP payload and lengths need updating at panic time.
pub tx_buf: CoherentDmaBuf,
/// Pre-built Ethernet header (dst MAC, src MAC, EtherType 0x0800).
pub eth_header: [u8; 14],
/// Pre-built IPv4 header (src IP, dst IP, protocol=UDP).
/// TTL, total_length, and header checksum are updated at panic time.
/// **IPv4-only**: Panic-time netconsole uses only IPv4 (20-byte fixed header).
/// IPv6 is not supported on the panic path because: (1) IPv6 headers are
/// 40 bytes + variable extension headers, increasing complexity in NMI context;
/// (2) IPv6 requires neighbor discovery which cannot run during panic; (3) most
/// datacenter monitoring infrastructure supports IPv4. An IPv6 netconsole target
/// configuration is rejected at setup time with -EAFNOSUPPORT.
pub ip_header: [u8; 20],
/// Pre-built UDP header (src port, dst port).
/// Length and checksum are updated at panic time.
pub udp_header: [u8; 8],
/// Maximum payload size (MTU - headers). Panic messages longer than
/// this are truncated (no fragmentation in panic path).
pub max_payload: u16,
/// NIC driver's panic transmit function. This is a raw function pointer
/// (not a KABI vtable call) that directly programs the NIC hardware to
/// transmit the pre-allocated DMA buffer. The function must:
/// - Be lock-free and allocation-free
/// - Not depend on NAPI, softirqs, or the network stack
/// - Write a TX descriptor to the NIC's hardware TX ring
/// - Poke the NIC's doorbell register
/// - Optionally poll for TX completion (best-effort)
///
/// The NIC driver registers this function during its init if it
/// supports panic polling (not all drivers do).
pub panic_xmit: Option<unsafe fn(buf_dma_addr: u64, len: u32)>,
}
Panic transmit procedure:
impl NetconsoleBackend {
/// Called from the panic console path with IRQs disabled and all
/// isolation domains revoked (PKRU=0 on x86-64).
fn panic_transmit(&self, text: &[u8]) {
for target in &self.targets {
let Some(ref ptx) = target.panic_tx else { continue };
let Some(panic_xmit) = ptx.panic_xmit else { continue };
// 1. Copy pre-built headers + message payload into the
// pre-allocated DMA buffer. No allocation, just memcpy.
let payload_len = text.len().min(ptx.max_payload as usize);
let total_len = 14 + 20 + 8 + payload_len; // eth + ip + udp + payload
unsafe {
let buf = ptx.tx_buf.as_mut_ptr();
// Ethernet header (pre-built, includes dst/src MAC).
core::ptr::copy_nonoverlapping(
ptx.eth_header.as_ptr(), buf, 14,
);
// IPv4 header (update total_length + checksum).
let mut ip = ptx.ip_header;
ip[2..4].copy_from_slice(
&((20 + 8 + payload_len) as u16).to_be_bytes(),
);
update_ip_checksum(&mut ip);
core::ptr::copy_nonoverlapping(ip.as_ptr(), buf.add(14), 20);
// UDP header (update length, zero checksum — allowed for IPv4).
let mut udp = ptx.udp_header;
udp[4..6].copy_from_slice(
&((8 + payload_len) as u16).to_be_bytes(),
);
udp[6..8].copy_from_slice(&[0, 0]); // Checksum = 0 (optional in IPv4 UDP).
core::ptr::copy_nonoverlapping(udp.as_ptr(), buf.add(34), 8);
// Payload.
core::ptr::copy_nonoverlapping(
text.as_ptr(), buf.add(42), payload_len,
);
// 2. Transmit via direct NIC hardware poke.
// Isolation domains are already revoked — this is a
// direct call through the driver-supplied function pointer
// (parenthesized to invoke the local `panic_xmit` binding).
(panic_xmit)(ptx.tx_buf.dma_addr(), total_len as u32);
}
}
}
}
/// Recompute the IPv4 header checksum in place over a 20-byte header (RFC 1071).
/// Zeroes the checksum field (bytes 10-11), sums the header as big-endian 16-bit
/// words with end-around carry, then writes the one's-complement back. Called on
/// the panic path after `total_length` is updated. Allocation- and lock-free
/// (NMI-safe).
fn update_ip_checksum(header: &mut [u8; 20]) {
header[10] = 0;
header[11] = 0;
let mut sum: u32 = 0;
let mut i = 0;
while i < 20 {
sum += u16::from_be_bytes([header[i], header[i + 1]]) as u32;
i += 2;
}
while (sum >> 16) != 0 {
sum = (sum & 0xFFFF) + (sum >> 16);
}
let bytes = (!(sum as u16)).to_be_bytes();
header[10] = bytes[0];
header[11] = bytes[1];
}
NIC driver contract for panic polling:
NIC drivers that support panic transmit must implement and register a
panic_xmit function with these constraints:
- Lock-free: must not acquire any lock (spinlock, mutex, RCU).
- Allocation-free: must not call slab, buddy, or any allocator.
- No NAPI/softirq: must not schedule softirqs or NAPI.
- Pre-reserved TX descriptor: the driver reserves one TX descriptor slot at init time exclusively for panic use. This slot is never used for normal traffic.
- Direct hardware access: writes the TX descriptor and pokes the NIC's doorbell register directly (MMIO write).
- Best-effort completion: optionally polls the TX completion status for up to 100μs. If the NIC doesn't confirm transmission, the function returns anyway (panic path cannot block indefinitely).
The panic_xmit function is registered via the NetDeviceOps KABI extension:
/// Extension to NetDeviceOps for panic-capable NIC drivers.
/// Optional — drivers that don't support panic polling leave this as None.
pub trait NetDevicePanicOps {
/// Register a panic transmit function and pre-allocate a TX slot.
/// Called once during driver init. The returned DMA address is the
/// pre-mapped buffer that panic_xmit will transmit from.
fn register_panic_tx(&self) -> Option<PanicTxRegistration>;
}
pub struct PanicTxRegistration {
/// DMA-coherent buffer for panic TX (pre-allocated, pre-mapped).
pub buf: CoherentDmaBuf,
/// Function pointer for lock-free panic transmit.
pub panic_xmit: unsafe fn(buf_dma_addr: u64, len: u32),
}
Which NIC drivers support panic polling:
| Driver | Panic TX | Notes |
|---|---|---|
| virtio-net | Yes | Single TX descriptor write + VIRTIO_PCI_QUEUE_NOTIFY |
| e1000/e1000e | Yes | Single TX descriptor write + tail pointer update |
| igb/ixgbe/ice | Yes | Single TX descriptor write + doorbell |
| mlx5 (ConnectX) | Best-effort | Requires WQE posting; may fail if WQ is corrupted |
| bnxt (Broadcom) | Yes | Single TX BD write + doorbell |
Drivers that do not support panic TX simply don't register panic_xmit.
The netconsole backend skips them during panic — the message is still
attempted on other targets and falls through to serial.
21.2.6 Panic Console Path¶
When the kernel panics, the normal klogd dispatcher thread stops. The panic handler takes over console output directly, bypassing all normal dispatch mechanisms. This path must work even when the scheduler is dead, locks are held, and cross-domain drivers have crashed.
21.2.6.1 Procedure¶
panic() enters:
1. Set PANIC flag (AtomicBool, globally visible; readable from any
context via panic_in_progress() — the predicate that panic-only
primitives such as the KABI emergency_call debug-assert).
2. Stop all other CPUs (NMI IPI on x86, FIQ on AArch64).
3. Open all isolation domains: arch::current::isolation::open_all_domains()
— every hardware isolation boundary is removed in a single register
write, a single prepared-translation-root install (AArch64
page-table path), or a no-op; see the per-architecture mapping
table below.
4. Write panic message to klog ring (KlogFlags::PANIC set).
5. Call emergency_write() on each registered ConsoleBackend,
in priority order (lowest priority number first):
a. EmergencySerialBackend (priority 5) — direct UART poke.
b. SerialConsoleBackend (priority 10) — calls UART driver's
emergency path (domain already revoked, so T0 direct call).
c. NetconsoleBackend (priority 15) — panic_transmit() via
pre-allocated DMA resources and direct NIC hardware poke.
6. Errors from any backend are silently ignored; next backend is tried.
7. After all backends attempted:
- Call pstore_kmsg_dump() to persist the log ring to non-volatile
storage ([Section 20.7](20-observability.md#pstore-panic-log-persistence--panic-handler-integration)).
- Execute panic action (halt, reboot, or kexec to crash kernel).
21.2.6.2 Domain Revocation During Panic¶
The panic path never issues a raw isolation-register write: it calls the generic
arch::current::isolation::open_all_domains() entry point
(Section 11.2 —
one of the interface's four sanctioned isolation-register writers, callable from
panic/NMI context with no locks and no allocation). The table below shows each
architecture module's implementation.
Revoking isolation domains during panic is safe because:
- All other CPUs are stopped (NMI IPI / FIQ). No concurrent access.
- The kernel is dying — isolation's purpose (crash containment) is moot.
- Cross-domain driver code becomes directly callable as if it were T0 (no ring buffer, no capability check, no domain switch overhead) because the domain-revocation step has removed every hardware isolation boundary.
- Pre-allocated resources (panic TX DMA buffers) are already mapped in the device's IOMMU domain — no IOMMU reprogramming needed.
open_all_domains() is a single register write, a single prepared-translation-root
install, or a no-op per architecture:
| Architecture | Instruction | Effect |
|---|---|---|
| x86-64 | WRPKRU(0) |
All 16 protection keys accessible |
| AArch64 POE | MSR POR_EL1, all-RWX |
All permission overlays grant full access |
| AArch64 page-table | MSR TTBR0_EL1, panic_root + ISB + local TLBI |
All live domains' code/data/MMIO reachable via the prepared all-domain panic translation root (maintained cold-path at domain create/teardown; see Section 11.2, open_all_domains()). Kernel-half translation untouched. |
| ARMv7 | MCR p15, DACR, 0xFFFFFFFF |
All 16 domains set to Manager |
| PPC32/RISC-V/s390x/LoongArch64/PPC64LE | No action | No fast isolation to open; Tier-1 requests run in the single Tier 0 kernel domain |
21.2.6.3 Panic Output Deduplication¶
Both the emergency serial backend and the KABI SerialConsoleBackend
may target the same physical UART. To avoid duplicated output during
panic:
- The
SerialConsoleBackend(KABI) checks whether its port matches the emergency serial port. If so,emergency_write()returnsConsoleError::NotAvailableto let the higher-priority emergency backend handle it. - This check uses the port's base address (I/O port or MMIO address), which is known at registration time. No locking required.
21.2.7 Boot Phase Integration¶
Console-related initialization is woven into the existing boot phase ordering (Section 2.3):
| Phase | Action | Component |
|---|---|---|
| 0.14 | arch_serial_init() — early serial init (fixed-address or firmware-discovered UART; Section 2.3) |
Tier 0 static |
| 0.15 | early_log_init() — 64 KB BSS ring available |
Early log ring |
| 0.x–1.2 | All output via early_log() — ring write + console mirror (its step 3a gates on EARLY_CONSOLE_READY, set at 0.14; Section 2.3); no caller-side serial pairing |
Tier 0 static |
| 1.3 | Allocate KlogRing (512 KB), replay early log entries | Klog ring |
| 2.8 | Start klogd thread, register EmergencySerialBackend |
Console framework |
| 2.8 | Parse console= and earlycon= from kernel command line |
Console framework |
| 4.6 | net_init() — network stack available (but no NIC yet) |
umka-net |
| 5.3 | KABI UART driver loads → SerialConsoleBackend registered |
Serial backend |
| 5.3 | KABI NIC driver loads → NetconsoleBackend registered (if configured) |
Netconsole |
| 5.3+ | Full console operation: klogd → fan-out to all backends | Steady state |
21.2.7.1 Evolution¶
All console components (framework, serial backend, netconsole backend) are
EvolvableComponent and can be live-replaced:
- Console framework evolution: new dispatch logic swapped via
AtomicPtrvtable swap. Backend list and klog ring (Nucleus-adjacent data) are preserved. Downtime: ~1 μs (stateless policy swap pattern). - Serial backend evolution: new UART driver binary loaded, bilateral KABI
exchange re-established. The serial port hardware state is preserved by the
driver's
export_state()/import_state()(baud rate, flow control, FIFO thresholds). Downtime: ~50–150 ms for bindings at effective Tier 1 (standard cross-domain Evolvable driver reload); ~10 ms for bindings at effective Tier 2 (process restart); direct-call rebind with no observable downtime for bindings at effective Tier 0. - Netconsole evolution: new netconsole module swapped. Target list and panic TX resources are preserved via state serialization. UDP socket is re-created in the new module. Downtime is the same per-binding category as the serial backend above.
During evolution of any console component, the emergency serial backend (Tier 0 static, non-evolvable) continues operating as a fallback.
21.3 Input Subsystem (evdev)¶
Linux's evdev interface (/dev/input/eventX) is the standard for delivering keyboard, mouse, touch, and joystick events to userspace (Wayland compositors, X11).
21.3.1 Input Drivers¶
In UmkaOS, modern input drivers (USB HID, Bluetooth HID, I2C
touchscreens) are tier-agnostic KABI drivers. Their manifests
typically declare preferred_tier = 2 because HID report parsing is
complex, carries significant attack surface (malformed report
descriptors are a common exploit class), and is latency-tolerant at
human-perception scale — cross-domain ring dispatch to a Ring 3
process with IOMMU fencing is the appropriate deployment. The loader
selects the effective tier at bind time per
Section 11.3. An input driver's only responsibility
is to parse hardware-specific reports and translate them into
standardized input_event structs.
The driver communicates with umka-nucleus via a shared memory ring established during driver registration (umka_driver_register, Section 12.2).
/// Internal kernel input event representation.
/// Uses 64-bit time fields for y2038 safety across all architectures.
///
/// **32-bit compatibility**: The userspace-visible `struct input_event` exposed
/// via `/dev/input/eventX` uses Linux-compatible layout that varies by architecture:
/// - 64-bit platforms: time_sec (u64), time_usec (u64), type (u16), code (u16), value (i32) = 24 bytes
/// - 32-bit platforms: time_sec (u32), time_usec (u32), type (u16), code (u16), value (i32) = 16 bytes
///
/// The `umka-sysapi` layer translates from this internal format to the
/// architecture-specific Linux input_event layout when copying to userspace.
/// This translation is zero-cost on 64-bit platforms (direct copy) and
/// requires field truncation/conversion on 32-bit platforms.
///
/// **Y2038 on 32-bit**: The 32-bit compat path preserves the Linux ABI
/// (u32 timestamps), which wraps in 2038. Linux solved y2038 for input
/// events by redefining `struct input_event` timestamp fields as
/// `__kernel_ulong_t` (unsigned 32-bit) in v5.0 (commit 152194fe9c3f),
/// extending the wrap date to 2106. UmkaOS follows the same approach: the
/// 32-bit compat layer uses unsigned timestamp fields (u32 sec, u32 usec),
/// matching Linux v5.0+ ABI. No separate ioctl is needed for input events.
#[repr(C)]
#[derive(Clone, Copy)]
pub struct InputEvent {
/// Event timestamp in seconds since boot (CLOCK_MONOTONIC).
/// 64-bit for y2038 safety. Truncated to u32 at the user-slice copy-out
/// (`UserSliceMut::write()`) during `read(2)` on `/dev/input/eventX` for 32-bit
/// processes (via the `umka-sysapi` read path); the truncation point is the
/// kernel→userspace copy, not ioctl registration or ring-buffer insertion.
pub time_sec: u64,
/// Event timestamp microseconds component.
/// 64-bit for consistency with time_sec. Truncated to u32 at the same user-slice
/// copy-out on the 32-bit compat read path (same truncation point as time_sec).
pub time_usec: u64,
/// Event type (EV_KEY, EV_REL, EV_ABS, etc.).
pub type_: u16,
/// Event code (key code, relative axis, absolute axis, etc.).
pub code: u16,
/// Event value (key state, relative delta, absolute position, etc.).
pub value: i32,
}
// InputEvent: u64(8) + u64(8) + u16(2) + u16(2) + i32(4) = 24 bytes.
// Userspace ABI struct — delivered via read(2) on /dev/input/eventX.
const_assert!(core::mem::size_of::<InputEvent>() == 24);
When a user presses a key, the USB HID driver pushes an InputEvent
into the shared ring and calls umka_driver_complete
(Section 12.3). The UmkaOS Core's input
multiplexer (umka-input) wakes up, reads the event, and copies it
to all open file descriptors for the corresponding
/dev/input/eventX node.
Input event ring buffer protocol:
Each /dev/input/eventX device uses a single-producer single-consumer (SPSC)
ring buffer for kernel → userspace event delivery:
- Ring capacity: Computed at
open()time fromdev.hint_events_per_packet * EVDEV_BUF_PACKETS, minimumEVDEV_MIN_BUFFER_SIZE = 64events, rounded up to the next power of two — matching Linux'sevdev_compute_buffer_size(). Immutable for the lifetime of the fd. UmkaOS extension:EVIOCSBUFSIZEioctl allows resizing (minimum 64, maximum 4096 events); see "UmkaOS Extensions" below. - Synchronization: The per-fd ring is kernel-internal (evdev delivers via
read(2), not mmap). The input core (producer) commits the batch with aReleasestore on the client ring'shead; the client'sread()path loadsheadwithAcquire. Both cursors areAtomicU64monotonic counters (offsets taken modulo capacity): at 1000 events/s au64counter does not wrap for ~5×10^8 years, so the overrun/SYN_DROPPEDdetection — measured at the staging frontier,(head + staged) - tail == capacity(see Overflow below) — is correct for the full 50-year uptime horizon; au32counter would alias after ~49.7 days and silently mis-detect overrun. (Occupancy can never exceed capacity — the precise trigger is== capacity, tested before staging the next event; see the load-bearing sites in Overflow below.) - Batching: The kernel batches all events between two
EV_SYN / SYN_REPORTmarkers as a single atomic update. Each event is written PAST the committedhead, at the staging frontierhead + staged, and counted instaged; only onSYN_REPORTdoes the committedheadadvance bystaged + 1(the batch plus its SYN event) with oneReleasestore. The committed region a reader (and the locklesspoll()) may observe is always[tail, head)— whole packets only, never a partial multi-axis touch. - Overflow: Live occupancy is measured at the STAGING FRONTIER, not the
committed head:
(head + staged) - tail, against capacitybuf.len(). When staging the next event would push occupancy past capacity — so the frontier slot(head + staged) % capacitycurrently aliases the oldest still-present event attail— the producer, holding that client'sread_lock(the buffer lock the reader also takes, so no reader can be mid-dequeue), reclaims the slot BEFORE writing it:- if committed-unread events remain (
tail < head), it advancestailpast the oldest one (tail += 1) and sets thedroppedflag, freeing the slot; - if none remain (
tail == head: a single SYN batch by itself larger than the whole ring), the batch cannot be delivered atomically — the producer setsdroppedand stops staging the rest of this batch; atSYN_REPORTthe staged region is discarded (staged = 0,headunchanged), so no partial packet is ever committed. Because the reclaim (tail advance or batch-discard) and the slot write happen in the sameread_lockcritical section, the frontier slot is overwritten only AFTERtailhas moved past it — the invariant theEvdevRingSAFETYnote states. A client whosedroppedflag is set emitsEV_SYN / SYN_DROPPEDon its nextread()(per-fd, so one slow reader never affects another); userspace must then ignore all events up to and including the followingSYN_REPORTand re-read device state via theEVIOCG*ioctls — theSYN_DROPPEDcontract, matching Linux__pass_event. Whenever the producer setsdroppedit also wakes this client's blockedread()/poll()waiters: in the batch-discard sub-caseheaddoes not advance, so no committed-event wake would otherwise fire and the pendingSYN_DROPPEDwould stall until the next batch — the explicit wake surfaces it promptly (poll()is already readable ondropped).
- if committed-unread events remain (
- Poll integration:
poll()/epoll_wait()returnsEPOLLINwhen the client has something to observe — committed events (ring.head != ring.tail) or a pendingSYN_DROPPED(ring.droppedset, which a fully-overrun batch can leave with an empty committed region).
Because input driver manifests typically declare preferred_tier = 2
and land at effective Tier 2 on default policy, a crash in the
complex USB HID parsing logic simply restarts the driver process
(~10 ms recovery) without dropping subsequent keystrokes. Operators
who pin an input driver to Tier 0 or Tier 1 via policy override
retain the same crash-recovery protocol (~50-150 ms reload for
effective Tier 1 bindings; kernel panic on effective Tier 0
bindings because the driver shares the Core domain).
21.3.2 Input Device Registration¶
Device class drivers (USB HID, Bluetooth HID, I2C touchscreen, camera button,
gamepad, etc.) register as input devices to emit events through the evdev
interface (/dev/input/eventX). Registration connects hardware input sources
to the userspace-visible evdev nodes.
/// Input device descriptor. Registered by device class drivers to connect
/// hardware input sources to the evdev userspace interface.
///
/// Each `InputDevice` represents one logical input source (e.g., one USB
/// keyboard, one touchpad). A single physical device may register multiple
/// `InputDevice` instances if it exposes multiple logical input paths
/// (e.g., a keyboard with an integrated touchpad registers one InputDevice
/// for keys and another for pointer events).
pub struct InputDevice {
/// Human-readable device name (e.g., "USB Keyboard", "PS/2 Mouse").
/// Exposed to userspace via `/sys/class/input/eventN/device/name` and
/// the `EVIOCGNAME` ioctl.
pub name: ArrayString<64>,
/// Physical path (e.g., "usb-0000:00:14.0-1/input0").
/// Identifies the hardware topology path. Exposed via `EVIOCGPHYS`.
pub phys: ArrayString<64>,
/// Device identity (bus type, vendor, product, version).
/// Exposed via `EVIOCGID` ioctl. Userspace udev rules match on these
/// fields to apply device-specific configuration.
pub id: InputId,
/// Capability bitmask: which event types this device can produce.
/// Bit positions match the Linux EV_* constants:
/// EV_SYN=0x00, EV_KEY=0x01, EV_REL=0x02, EV_ABS=0x03,
/// EV_MSC=0x04, EV_SW=0x05, EV_LED=0x11, EV_SND=0x12,
/// EV_REP=0x14, EV_FF=0x15.
/// Queried by userspace via `EVIOCGBIT(0, ...)`.
pub ev_bits: u32,
/// Per-event-type capability bitmaps. These detail WHICH codes within
/// each event type the device supports (e.g., which KEY_* codes for
/// EV_KEY, which REL_* axes for EV_REL). Queried via `EVIOCGBIT(type, ...)`.
///
/// Stored as a fixed-size array of bitmaps. Only the types set in
/// `ev_bits` have meaningful data; others are zeroed.
pub key_bits: [u64; 12], // 768 bits, covers KEY_MAX=0x2FF
pub rel_bits: u32, // REL_MAX=0x0F (16 bits needed)
pub abs_bits: u64, // ABS_MAX=0x3F (64 bits needed)
/// Driver-provided sizing hint: the typical number of events emitted per
/// SYN_REPORT packet for this device (e.g. a multi-touch panel reports many
/// ABS_MT_* events per frame; a mouse reports 2-3). The evdev layer computes
/// each client's default per-fd buffer size from it at `open()`
/// (`max(EVDEV_MIN_BUFFER_SIZE, hint_events_per_packet * EVDEV_BUF_PACKETS)`
/// rounded up to a power of two), matching Linux `evdev_compute_buffer_size()`.
/// A driver that leaves it 0 gets the `EVDEV_MIN_BUFFER_SIZE` floor.
pub hint_events_per_packet: u32,
// The SYN-batch staging cursor is per-CLIENT (`EvdevRing.staged`), not
// per-device: each reported event is fanned out and staged into every open
// client's own ring, counted by THAT ring's `staged` and committed on
// `SYN_REPORT`. Clients have independent capacities and overflow
// independently, so there is no device-level staged counter and no shared
// device ring — the per-fd `EvdevClient.ring` is what backs the per-client
// `EVIOCSBUFSIZE` capacity (64..4096); a single shared fixed-capacity
// device ring could not.
/// Producer serialization for this device (Linux `input_dev->event_lock`).
/// `input_report_event` acquires it around the whole staging write AND around
/// the `SYN_REPORT` batch commit, so several concurrent IRQ reporters on one
/// physical device (e.g. a keyboard interrupt and a companion consumer-control
/// endpoint mapped to the same `InputDevice`) serialize into the *single*
/// logical producer that every per-fd `EvdevRing` assumes. Held with IRQs
/// saved (`SpinLock` does the save/restore). Lock order:
/// `event_lock` → each grabbed/open client's `EvdevClient.read_lock`
/// (readers take only `read_lock`, so there is no inversion).
pub event_lock: SpinLock<()>,
}
/// Input device identity. Matches the Linux `struct input_id` layout
/// exactly (8 bytes, no padding) for binary compatibility with the
/// `EVIOCGID` ioctl.
#[repr(C)]
pub struct InputId {
/// Bus type: BUS_USB=0x03, BUS_BLUETOOTH=0x05, BUS_I2C=0x18,
/// BUS_HOST=0x19, BUS_VIRTUAL=0x06, etc. Full list in Linux
/// `include/uapi/linux/input.h`.
pub bustype: u16,
/// Vendor ID (USB VID, Bluetooth SIG company ID, etc.).
pub vendor: u16,
/// Product ID (USB PID, etc.).
pub product: u16,
/// Device version number (driver-defined).
pub version: u16,
}
const_assert!(core::mem::size_of::<InputId>() == 8);
/// Opaque handle returned by `input_register_device()`. The driver retains
/// this handle to report events and must pass it to `input_unregister_device()`
/// on teardown. Internally, this is an index into the global `INPUT_DEVICES`
/// XArray (integer-keyed, O(1) lookup).
pub struct InputHandle(u32);
/// Register an input device. Returns a handle for event reporting.
///
/// Side effects:
/// 1. Allocates a minor number from the `INPUT_MINOR_POOL` (0..1023).
/// 2. Creates `/dev/input/eventN` via devtmpfs
/// ([Section 14.17](14-vfs.md#pipes-and-fifos)).
/// 3. Inserts the device into the global `INPUT_DEVICES` XArray
/// (keyed by minor number).
/// 4. Emits a `KOBJ_ADD` uevent for udev/eudevd to process
/// (creates symlinks like `/dev/input/by-id/...`).
///
/// # Errors
/// - `InputError::MinorExhausted`: all 1024 minor numbers are in use.
/// - `InputError::DevtmpfsError`: failed to create the device node.
pub fn input_register_device(dev: InputDevice) -> Result<InputHandle, InputError>
/// Error returned by input device registration.
pub enum InputError {
/// All minor numbers in both the static (64–95) and dynamic (256–1023)
/// evdev ranges are in use — no free `/dev/input/eventN` slot remains.
MinorExhausted,
/// Failed to create (or later remove) the `/dev/input/eventN` node in
/// devtmpfs.
DevtmpfsError,
}
/// Unregister an input device (on driver unload or device disconnect).
///
/// Side effects:
/// 1. Removes `/dev/input/eventN` from devtmpfs.
/// 2. Wakes any blocked `read()` / `poll()` waiters with `ENODEV`.
/// 3. Removes the device from the `INPUT_DEVICES` XArray.
/// 4. Emits a `KOBJ_REMOVE` uevent.
/// 5. Releases the minor number back to `INPUT_MINOR_POOL`.
///
/// Any `EvdevClient` file descriptors still open on the device node
/// continue to exist but return `ENODEV` on subsequent `read()` / `ioctl()`.
pub fn input_unregister_device(handle: InputHandle)
/// Report a single input event. Called from device interrupt handler or
/// polling callback. Fans the event out to every open client's per-fd ring.
///
/// **Serialization**: the whole call runs under the device's `event_lock`
/// (Linux `dev->event_lock`), so concurrent IRQ reporters on one device form a
/// single logical producer. The fan-out iterates the device's client list under
/// the RCU read-side lock (IRQ context); the grabbing client, if any, is the
/// sole recipient. Each per-client staging write and commit is additionally
/// taken under that client's `EvdevClient.read_lock` (the Linux
/// per-client buffer-lock role), which the client's `read()` also holds while
/// dequeuing — so producer and consumer never touch one ring slot concurrently.
///
/// **Atomic batching protocol**: For each open client, the input core writes the
/// reported event into that `EvdevClient.ring` at the STAGING FRONTIER
/// `head + staged` (past the committed `head`) and increments THAT ring's own
/// `staged` count — clients overflow independently, so the staging count is
/// per-ring, not per-device. The committed `head` does NOT advance until
/// `EV_SYN / SYN_REPORT` arrives; then that ring's `head` advances by `staged + 1`
/// (the batch plus its SYN event) with a single `Release` store, committing the
/// whole batch atomically so a lockless `poll()` observes only whole packets.
/// Userspace never sees a partial multi-axis touch or partial key+syn pair.
///
/// **Overrun** is measured at the frontier: live occupancy is
/// `(head + staged) - tail` against capacity `buf.len()`. When staging the next
/// event would exceed capacity — the frontier slot `(head + staged) % capacity`
/// aliases the oldest still-present event at `tail` — the producer reclaims that
/// slot BEFORE writing it, all under the client's `read_lock` (the reader cannot
/// be mid-dequeue — it needs the same lock):
/// - committed-unread events remain (`tail < head`): advance `tail` past the
/// oldest (`tail += 1`) and set `dropped`, then write;
/// - none remain (`tail == head`: this single batch is larger than the whole
/// ring): set `dropped` and stop staging the rest of the batch; at
/// `SYN_REPORT` the staged region is discarded (`staged = 0`, `head`
/// unchanged) so no partial packet is committed.
/// The reclaim and the slot write are in one `read_lock` section, so the frontier
/// slot is overwritten only AFTER `tail` has passed it. The `dropped` client emits
/// `EV_SYN / SYN_DROPPED` on its next read (per-fd; one slow reader never affects
/// another); userspace then ignores events through the next `SYN_REPORT` and
/// re-reads state via `EVIOCG*` — the Linux `SYN_DROPPED` / `__pass_event` contract.
///
/// # Arguments
/// - `handle`: the device handle from `input_register_device()`.
/// - `type_`: event type (EV_KEY, EV_REL, EV_ABS, etc.).
/// - `code`: event code (KEY_A, REL_X, ABS_MT_POSITION_X, etc.).
/// - `value`: event value (1=press, 0=release for keys; delta for relative;
/// absolute position for absolute axes).
pub fn input_report_event(handle: &InputHandle, type_: u16, code: u16, value: i32)
/// Convenience: report a key press/release event with automatic SYN_REPORT.
///
/// Generates two events atomically written to the ring:
/// 1. `EV_KEY / code / (1 if pressed, 0 if released)`
/// 2. `EV_SYN / SYN_REPORT / 0`
///
/// For multi-event reports (e.g., multi-touch), drivers should use
/// `input_report_event()` directly and send `SYN_REPORT` once after
/// all axis values are written.
pub fn input_report_key(handle: &InputHandle, code: u16, pressed: bool) {
input_report_event(handle, EV_KEY, code, if pressed { 1 } else { 0 });
input_report_event(handle, EV_SYN, SYN_REPORT, 0);
}
evdev layer integration:
input_register_device()allocates/dev/input/eventN(major = 13, minor = EVDEV_MINOR_BASE + device index). Static range: minors 64-95 (first 32 devices); dynamic overflow: minors 256-1023 (shared with other input handlers, matching Linux's dynamic base of 256 and 1024-device ceiling). The character device is registered with the VFS viaregister_chrdev_region()(Section 14.5) with the evdev dispatcher as its file operations.open(): allocates a per-fdEvdevClientwith its ownEvdevRing(capacity fromdev.hint_events_per_packet). Multiple userspace processes can open the same/dev/input/eventXsimultaneously; each gets its own ring and cursor, and the input core fans every committed event out to all of them.read(): under the client'sread_lock— the same buffer lock the producer holds for its per-client write/commit, so a dequeue never overlaps a producer slot write — dequeues committed events fromclient.ringover[tail, head), advancingring.tailper event. If the client'sdroppedflag is set it emitsEV_SYN / SYN_DROPPEDfirst and clears the flag;tailneeds no recomputation — the producer already advanced it past every dropped event under the same lock. Blocks (interruptibly) if there is nothing to observe. Returns events instruct input_eventformat (architecture-specific layout, see theInputEventstruct above).poll(): returnsEPOLLINwhen the client has something to observe — committed events (client.ring.head != client.ring.tail) or a pendingSYN_DROPPED(client.ring.droppedset, which a fully-overrun batch can leave with an empty committed region).ioctl(): supports the full Linux evdev ioctl set:EVIOCGVERSION,EVIOCGID,EVIOCGNAME,EVIOCGPHYS,EVIOCGBIT(type, ...),EVIOCGABS(axis),EVIOCGRAB,EVIOCREVOKE,EVIOCSCLOCKID. UmkaOS extension:EVIOCSBUFSIZE(see below).- Grab semantics (
EVIOCGRAB): when a client grabs the device, all other clients stop receiving events (their rings are not written). Only one grab is active per device. Used by Wayland compositors to claim exclusive input.
UmkaOS Extensions (not present in Linux evdev):
EVIOCSBUFSIZE: Allows userspace to resize the per-client event buffer afteropen(). Linux computes that size once duringopen()and provides no mechanism to change it. UmkaOS addsEVIOCSBUFSIZEas an extension ioctl. The ioctl number uses bit 31 set (0x80000000 | _IOW('E', 0x90, u32)) to avoid collision with any current or future Linux evdev ioctl. Range: minimumEVDEV_MIN_BUFFER_SIZE, maximumEVDEV_MAX_BUFFER_SIZEevents. A Linux application that does not call this ioctl sees identical behavior to Linux (buffer computed at open, immutable).
Resize protocol. The ring's head/tail cursors are monotonic u64
counters mapped to slots by counter % buf.len(), so simply swapping
buf.len() under a live client would re-map every outstanding cursor to a
different slot and corrupt all buffered events — and a resize can arrive
mid-SYN-batch (staged > 0), between two of the producer's per-event
read_lock sections. EVIOCSBUFSIZE therefore ALLOCATES the replacement
buffer BEFORE taking read_lock and FREES the old one AFTER dropping it
(Section 3.13 — no heap alloc/free under the IRQ-disabling
spinlock, which the IRQ-context producer contends), and re-bases only under
the client's read_lock (excluding the producer and every other reader),
re-laying the live events out contiguously in the new buffer:
/// Resize the per-client ring to `requested` events. The replacement buffer is
/// ALLOCATED before `client.read_lock` is taken and the old buffer is FREED
/// after it is dropped ([Section 3.13](03-concurrency.md#collection-usage-policy) — no heap alloc/free
/// under the IRQ-disabling spinlock, which the IRQ-context producer contends);
/// only the bounded drop-policy computation, survivor copy, and pointer swap
/// run UNDER `client.read_lock` (excluding the producer and every other
/// reader), so no producer staging write, `SYN_REPORT` commit, overrun drop,
/// or concurrent `read()` can observe a half-swapped ring. The lockless
/// `poll()` reads only the `head`/`tail`/`dropped` atomics (never `buf` or
/// `buf.len()`), and both cursors are re-based together, so at worst it
/// observes a transient `head != tail` and reports spuriously readable —
/// harmless (the subsequent `read()` takes `read_lock` and sees consistent
/// state).
fn evdev_set_buffer_size(client: &EvdevClient, requested: u32) -> Result<(), Errno> {
// 1. Clamp to the extension range and round up to a power of two (same rule
// as the initial buffer allocation), giving the new capacity, then ALLOCATE
// the replacement buffer — all BEFORE taking `read_lock`. `new_len`
// derives only from `requested`, not from any locked ring state, so it is
// knowable here; keeping the ~96 KiB allocation and its O(capacity) init
// outside the IRQ-disabling spinlock is required by
// [Section 3.13](03-concurrency.md#collection-usage-policy) (no heap allocation under a spinlock / with
// IRQs disabled) and by the RawSpinLock 10 µs section budget.
if requested < EVDEV_MIN_BUFFER_SIZE || requested > EVDEV_MAX_BUFFER_SIZE {
return Err(Errno::EINVAL);
}
let new_len = (requested.next_power_of_two() as u64).min(EVDEV_MAX_BUFFER_SIZE as u64);
// Zeroed slots — `InputEvent` is POD, and every readable slot is either
// overwritten under the lock below or written by the producer before any read.
let newbuf: Box<[UnsafeCell<InputEvent>]> = (0..new_len)
.map(|_| UnsafeCell::new(unsafe { core::mem::zeroed::<InputEvent>() }))
.collect();
// 2. Under `read_lock` (excluding the producer and every other reader):
// compute the drop policy, copy the survivors into the pre-allocated
// buffer, and swap it in. NO allocation or free occurs inside this
// section — only the bounded copy of at most `new_len` POD slots followed
// by pointer swaps. `mem::replace` moves the OLD box OUT so it can be
// freed after the lock is dropped.
let old = {
let _g = client.read_lock.lock();
let r = &client.ring;
// `buf` is `UnsafeCell<Box<[..]>>`; `read_lock` is held, so these derefs
// of the boxed slice are exclusive.
let old_len = unsafe { (*r.buf.get()).len() } as u64;
let tail = r.tail.load(Relaxed);
let head = r.head.load(Relaxed);
let staged = r.staged.load(Relaxed) as u64;
// Live events are the committed region [tail, head) followed by the
// in-flight staged region [head, head + staged). If they cannot fit the
// new capacity, drop OLDEST committed events (advance a local `base`)
// and set `dropped`; if the staged batch alone still overflows, discard
// it (`keep_staged = 0`) — the same drop-oldest / discard policy as an
// overrun, so an undersizing resize degrades exactly like a full ring.
let mut base = tail;
let mut keep_staged = staged;
let mut dropped = r.dropped.load(Relaxed);
while (head + keep_staged) - base > new_len {
if base < head { base += 1; dropped = true; } // drop oldest committed
else { keep_staged = 0; dropped = true; break; } // staged batch alone too big
}
// Copy the survivors in FIFO order to slots 0.., so the re-based cursors
// map correctly under `% new_len`. Both rings are quiesced by
// `read_lock`, so these UnsafeCell reads/writes do not overlap any
// producer or reader access.
let live = (head + keep_staged) - base; // <= new_len
for k in 0..live {
let src = ((base + k) % old_len) as usize;
unsafe { *newbuf[k as usize].get() = *(*r.buf.get())[src].get(); }
}
// Re-base cursors to the fresh layout: tail = 0, head = committed count,
// frontier = live. All stores are under the lock; poll() only compares
// them. Monotonicity restarts from 0 (a resize is a cold, rare event;
// the u64 non-alias argument holds from each fresh base). The swap moves
// the old box out for the post-lock free.
let committed = head - base.min(head); // events in [base, head)
let old = core::mem::replace(unsafe { &mut *r.buf.get() }, newbuf);
r.tail.store(0, Relaxed);
r.head.store(committed, Release); // publish new committed extent
r.staged.store(keep_staged as u32, Relaxed);
r.dropped.store(dropped, Relaxed);
old
};
// 3. Free the old buffer AFTER releasing `read_lock` — deallocation must not
// happen under the IRQ-disabling spinlock ([Section 3.13](03-concurrency.md#collection-usage-policy)).
drop(old);
Ok(())
}
The in-flight batch (if any) survives the resize with its staged count intact
and commits normally at the next SYN_REPORT; a client that lost events sees
SYN_DROPPED on its next read() exactly as after an overrun.
Global input device registry:
/// Evdev minor range constants (matching Linux drivers/input/evdev.c).
const EVDEV_MINOR_BASE: u32 = 64;
const EVDEV_MINORS: u32 = 32;
/// Per-client ring sizing constants (matching Linux `drivers/input/evdev.c`).
/// `open()` computes the default capacity as
/// `max(EVDEV_MIN_BUFFER_SIZE, hint_events_per_packet * EVDEV_BUF_PACKETS)`
/// rounded up to a power of two (Linux `evdev_compute_buffer_size()`), and
/// `EVIOCSBUFSIZE` reallocates within `[EVDEV_MIN_BUFFER_SIZE,
/// EVDEV_MAX_BUFFER_SIZE]` (also power-of-two rounded).
const EVDEV_BUF_PACKETS: u32 = 8; // Linux `#define EVDEV_BUF_PACKETS 8`
const EVDEV_MIN_BUFFER_SIZE: u32 = 64; // Linux `#define EVDEV_MIN_BUFFER_SIZE 64U`
const EVDEV_MAX_BUFFER_SIZE: u32 = 4096; // UmkaOS `EVIOCSBUFSIZE` upper bound (extension)
/// Global input device table. XArray keyed by minor number (64..95
/// static, 256+ dynamic). O(1) lookup for evdev open/read/ioctl paths.
static INPUT_DEVICES: LazyLock<XArray<InputDeviceEntry>> =
LazyLock::new(|| XArray::new());
/// Minor number allocator for /dev/input/eventN devices.
/// Two-tier: first allocates from static range 64-95 (32 devices),
/// then overflows to dynamic range 256-1023 (matching Linux's
/// dynamic-minor allocation scheme).
static INPUT_MINOR_POOL: LazyLock<TwoTierMinorAllocator> =
LazyLock::new(|| TwoTierMinorAllocator::new(EVDEV_MINOR_BASE, EVDEV_MINORS, 256, 1024));
/// Two-tier minor-number allocator for `/dev/input/eventN`. Hands out minors
/// from a small **static** range first (contiguous, matching Linux's fixed evdev
/// minors 64–95), then overflows into a larger **dynamic** range. Both tiers are
/// tracked by one `DynBitmap` ([Section 3.13](03-concurrency.md#collection-usage-policy)); `alloc` is
/// find-first-zero, `free` is O(1). Warm path only (device register / unregister,
/// never per-event); the internal `SpinLock` serializes the rare concurrent
/// registrations.
pub struct TwoTierMinorAllocator {
inner: SpinLock<MinorAllocState>,
}
/// Interior state of `TwoTierMinorAllocator`, guarded by its `SpinLock`.
struct MinorAllocState {
/// First minor of the static tier (e.g. 64).
static_base: u32,
/// Number of minors in the static tier (e.g. 32 → 64..96).
static_count: u32,
/// First minor of the dynamic tier (e.g. 256).
dynamic_base: u32,
/// One-past-last minor of the dynamic tier (e.g. 1024).
dynamic_end: u32,
/// Allocation bitmap: index `minor - static_base` for the static tier and
/// `static_count + (minor - dynamic_base)` for the dynamic tier.
used: DynBitmap,
}
impl TwoTierMinorAllocator {
/// Build an allocator over static range
/// `[static_base, static_base + static_count)` and dynamic range
/// `[dynamic_base, dynamic_end)`.
pub fn new(
static_base: u32,
static_count: u32,
dynamic_base: u32,
dynamic_end: u32,
) -> Self;
/// Allocate the lowest free minor, preferring the static tier. Returns
/// `Err(InputError::MinorExhausted)` when both tiers are full.
pub fn alloc(&self) -> Result<u32, InputError>;
/// Return a previously-allocated minor to the pool. Freeing a minor that is
/// not currently allocated is a no-op (debug-asserted).
pub fn free(&self, minor: u32);
}
/// A per-fd evdev event ring — the real backing storage for one open file
/// descriptor. Runtime-sized (`buf.len()` is a power of two in `64..=4096`),
/// allocated at `open()` from the device's `hint_events_per_packet` and
/// reallocated by `EVIOCSBUFSIZE`. This is what makes per-client buffer sizes
/// > 64 representable — there is no shared device ring capping every client at
/// 64 slots. Single producer (the input-core fan-out); the consumer cursor is
/// shared by concurrent `read()`s on one fd and is therefore advanced only
/// under `EvdevClient.read_lock`.
pub struct EvdevRing {
/// Slot storage, capacity `= buf.len()` (power of two, 64..=4096). Warm/cold
/// path allocation (per open / per EVIOCSBUFSIZE), bounded — permitted by the
/// collection policy ([Section 3.13](03-concurrency.md#collection-usage-policy)). Each slot is an
/// `UnsafeCell<InputEvent>` because the producer writes slot `head % len`
/// through a SHARED `&EvdevRing` (the IRQ-context fan-out reaches this ring
/// via the RCU-iterated clients list — no `&mut` exists) while `read()` reads
/// committed slots. `Cell` cannot serve — it is not `Sync` and the producer
/// runs in IRQ context.
///
/// **SAFETY**: this is NOT lock-free SPSC. Every access to `buf`, `head`,
/// `tail`, and `staged` — the producer's staging write, its `SYN_REPORT`
/// commit, its overrun drop-oldest, the consumer's dequeue, and the
/// `EVIOCSBUFSIZE` slot-array swap (`evdev_set_buffer_size`) — happens
/// while the owning `EvdevClient.read_lock` is held (Linux `client->buffer_lock`).
/// Producer and consumer therefore never execute against this ring
/// concurrently, so no two accesses to one slot overlap and none is torn,
/// INCLUDING on overrun: overrun is detected at the staging frontier
/// (`(head + staged) - tail == capacity`), and the producer advances `tail`
/// past the frontier's aliased oldest slot — or, for a batch larger than the
/// ring, stops staging and discards the batch at `SYN_REPORT` — BEFORE writing,
/// so it overwrites a slot only after `tail` has moved past it, and the reader
/// cannot be mid-dequeue on that slot because it needs the same lock. The
/// `Release` store to `head` on commit and the `Acquire` load in the lockless
/// `poll()` path additionally make committed events visible to `poll()` without
/// the lock; `poll()` reads only the two cursors and `dropped`, never a slot.
///
/// The OUTER `UnsafeCell` wraps the boxed slice itself (not just each slot) so
/// `EVIOCSBUFSIZE` can swap the whole slot array — reallocating to a new
/// capacity — through the same shared `&EvdevRing`, under `read_lock`, with no
/// `&mut` (see `evdev_set_buffer_size`). Between resizes the pointer is
/// stable; the slot-level `UnsafeCell` still governs per-event interior
/// mutability. `poll()` never dereferences `buf`, so a concurrent swap cannot
/// affect it.
pub buf: UnsafeCell<Box<[UnsafeCell<InputEvent>]>>,
/// Committed producer cursor (monotonic `u64`; offset = `head % buf.len()`).
/// The reader-visible boundary: only `[tail, head)` is observable. The staging
/// frontier is `head + staged` (uncommitted); `head` advances by a whole SYN
/// batch at once (`+= staged + 1`) with a `Release` store. `u64` so it never
/// aliases within the operational lifetime (the `u32` wrap-at-49.7-days hazard
/// is removed).
pub head: AtomicU64,
/// In-progress SYN-batch count staged past `head` by the producer, folded
/// into `head` on SYN_REPORT. Written through a shared `&EvdevRing` (see
/// `buf`) under `EvdevClient.read_lock`, so `AtomicU32` with
/// `load`/`store(Relaxed)` — the lock already excludes every other accessor,
/// so no RMW is needed (the atomic type is only for the shared-reference
/// interior mutability, not for lock-free coordination).
pub staged: AtomicU32,
/// Consumer cursor for this fd (monotonic `u64`). Mutated only under
/// `EvdevClient.read_lock` (by the dequeue, and by the producer's overrun
/// drop-oldest); read locklessly by `poll()`, hence `AtomicU64`.
pub tail: AtomicU64,
/// Set (under `read_lock`) when the producer overran this ring — occupancy at
/// the staging frontier `(head + staged) - tail` reached capacity. The next
/// `read()` emits `EV_SYN / SYN_DROPPED` first and clears the flag; `tail`
/// needs no reader-side recomputation, because the producer already advanced it
/// past every dropped event under the same lock. While set it also makes
/// `poll()` report readable even if the committed region is empty (a
/// fully-overrun batch). Per-fd, so one slow reader never drops another
/// client's events.
pub dropped: AtomicBool,
}
// SAFETY: `EvdevRing` holds `UnsafeCell<InputEvent>` slots, which makes it `!Sync`
// by default. Sharing across threads is sound because ALL slot and cursor access
// is serialized by the owning `EvdevClient.read_lock` (Linux `client->buffer_lock`),
// as documented on `buf`: the producer holds it for its per-client staging write,
// `SYN_REPORT` commit, and overrun drop-oldest; every `read()` holds it for the
// dequeue. Producer and consumer thus never run against one ring concurrently —
// there is no lock-free disjointness claim to violate, and the overrun path
// (which overwrites the oldest slot) is safe because the reader cannot hold that
// slot without also holding the lock the producer is holding. The lockless
// `poll()` path touches only the `head`/`tail` and `dropped` atomics, never a
// slot and never the `buf` pointer (so an `EVIOCSBUFSIZE` swap cannot affect it).
unsafe impl Sync for EvdevRing {}
/// Per-fd state for an open evdev file descriptor. Each `open()` on
/// `/dev/input/eventN` allocates one `EvdevClient` with its own `EvdevRing`.
/// Multiple processes (or multiple fds in one process) each get independent
/// rings and cursors.
pub struct EvdevClient {
/// This fd's private event ring (per-client storage). Its capacity backs
/// the `EVIOCSBUFSIZE` range (64..4096); reallocated under `read_lock`.
pub ring: EvdevRing,
/// The per-client BUFFER LOCK (Linux `client->buffer_lock`): serializes ALL
/// access to `ring`, not just concurrent readers. Held by the input-core
/// producer for this client's staging write, `SYN_REPORT` commit, and overrun
/// drop-oldest, and by every `read()` for the dequeue — so producer and
/// consumer never touch a slot concurrently (the soundness argument for the
/// ring's `unsafe impl Sync`). It also serializes concurrent `read()`s on one
/// shared `OpenFile` (reached via `dup`/`fork`/`CLONE_FILES`): VFS character
/// streams take no `f_pos_lock` and delegate ordering to the driver's own lock
/// ([Section 14.1](14-vfs.md#virtual-filesystem-layer)), so without it two concurrent reads would
/// race `ring.tail` and duplicate or skip events. A `SpinLock`, so it saves
/// and restores IRQ state for the IRQ-context producer. Lock order:
/// `InputDevice.event_lock` → `read_lock` (readers take only `read_lock`).
pub read_lock: SpinLock<()>,
/// Client-specific event mask: filters which event types are delivered.
/// Set via `EVIOCSMASK` ioctl. Default: all events.
pub evmask: [u64; 4], // Bitmap covering EV_SYN..EV_MAX (0x1f)
/// Clock ID for event timestamps: `CLOCK_REALTIME` (default),
/// `CLOCK_MONOTONIC`, or `CLOCK_BOOTTIME`. Set via `EVIOCSCLOCKID`
/// ioctl. `CLOCK_BOOTTIME` includes suspend time (Linux 4.17+);
/// used by input libraries for gesture timeout calculations that
/// survive suspend/resume. Any other clock ID returns `-EINVAL`.
pub clock_id: i32,
/// Link in the InputDeviceEntry.clients intrusive list.
/// **RCU note**: The clients list is iterated under RCU read-side lock
/// during event broadcast (IRQ context → input_event() → iterate clients).
/// Client addition (open) and removal (close) are serialized by the
/// device's `clients_lock` mutex; add/remove update the intrusive list under
/// that lock, and removal waits through `rcu_synchronize()` before reclamation.
pub link: IntrusiveListNode,
/// Wait queue entry for blocking read/poll.
pub wait: WaitQueueEntry,
/// True if this client has been revoked (device removed while fd open).
/// Subsequent read/ioctl returns ENODEV.
pub revoked: bool,
}
/// Per-device state stored in the INPUT_DEVICES XArray.
pub struct InputDeviceEntry {
/// The registered device descriptor.
pub dev: InputDevice,
/// List of open EvdevClient instances (for event fan-out and grab tracking).
///
/// **Policy exception**: Intrusive list used here (instead of ring) because
/// N is small and bounded (<8).
///
/// **IRQ path note**: The `input_event()` broadcast path iterates this
/// list under RCU read-side lock in IRQ context. Iteration is O(N) in the
/// number of open clients. Typical N: 1-3 (one compositor + optional
/// libinput debug fd). Maximum expected N: <8 per device (a process per
/// open fd; evdev devices rarely have more than a handful of readers).
/// For this small N, the intrusive list has acceptable cache locality
/// (clients are allocated close in time from the same slab page).
/// This matches Linux's `evdev_event()` implementation which uses
/// `struct list_head` iterated under RCU for the same fan-out pattern.
pub clients: SpinLock<IntrusiveList<EvdevClient>>,
/// Currently grabbing client (if any). Only this client receives events.
pub grab: AtomicPtr<EvdevClient>,
}
21.3.3 Secure VT Switching and Panic Console¶
The Virtual Terminal (VT) subsystem provides the emergency text console and the mechanism for switching between graphical sessions (Ctrl+Alt+F1-F6).
In Linux, the VT subsystem is deeply entangled with the console driver, input layer, and DRM.
In UmkaOS, the VT subsystem is a minimal state machine inside umka-input:
1. Normal Operation: umka-input routes all input_event structs to the active Wayland compositor (the process holding the DRM master node).
2. VT Switch Detected: When umka-input detects a VT switch chord (e.g., Ctrl+Alt+F1), it immediately revokes the DRM master capability from the current compositor and pauses input event delivery to that process.
3. Panic Console Handoff: If the system panics, UmkaOS Core
forcefully reclaims the display hardware from the DRM driver
(regardless of the driver's effective tier). It resets the display
controller to a known-safe text mode (or simple framebuffer mode)
using a minimal, statically linked emergency VGA/EFI console that
lives in the Core domain, and dumps the panic log. The full DRM
driver is completely bypassed during a panic to ensure the log is
always visible, even if the GPU state machine is deadlocked.
21.3.3.1 VT Data Structures¶
// umka-nucleus/src/vt/mod.rs
/// Maximum number of virtual consoles (matching Linux MAX_NR_CONSOLES = 63;
/// serial lines occupy indices 64+).
pub const MAX_NR_CONSOLES: usize = 63;
/// Global VT state. Singleton, initialized at boot.
pub struct VtState {
/// Currently active VT number (1-based; default 1 at boot).
/// Updated atomically during VT switch. 0 = no active VT (headless boot).
pub active_vt: AtomicU8,
/// Per-VT console state. Index 0 = VT 1, index 62 = VT 63.
/// Each entry is independently locked to allow concurrent access
/// to different VTs (e.g., background login on VT 2 while VT 1 is active).
pub consoles: [SpinLock<VtConsole>; MAX_NR_CONSOLES],
}
/// TTY device state — canonical definition is `TtyPort` in
/// [Section 21.1](#tty-and-pty-subsystem). `TtyPort` includes
/// all fields listed here (dev, termios, ldisc, winsize, session, pgrp)
/// plus additional state (read/write buffers, driver_data, etc.).
/// VtConsole references `TtyPort` directly.
pub type TtyStruct = TtyPort;
/// DRM master handle. Grants exclusive modesetting access to a DRM device.
/// Only one DRM master is active per VT at a time.
pub struct DrmMaster {
/// Authentication magic number (for legacy DRM auth protocol).
pub auth_magic: u32,
/// Unique identifier string for this master (set via DRM_IOCTL_SET_UNIQUE).
pub unique: ArrayString<64>,
/// File descriptor of the DRM device (/dev/dri/card0).
pub master_fd: i32,
/// Whether this master is currently the active master (has modesetting rights).
pub is_active: bool,
}
/// Per-VT console state.
pub struct VtConsole {
/// Controlling session (the login session or Wayland compositor owning this VT).
/// `None` if the VT is unused.
pub session_id: Option<SessionId>,
/// Associated TTY device (e.g., `/dev/tty1`). `None` for graphical-only VTs.
pub tty: Option<Arc<TtyStruct>>,
/// Display mode.
pub mode: VtMode,
/// Keyboard input mode.
pub kbd_mode: KbdMode,
/// DRM master handle for this VT (the Wayland compositor's DRM master fd).
/// `None` for text-mode VTs or VTs without a graphical session.
/// On VT switch, the old VT's DRM master is revoked and the new VT's is granted.
pub drm_master: Option<Arc<DrmMaster>>,
}
/// VT display mode (matches Linux KD_TEXT / KD_GRAPHICS).
#[repr(u32)]
pub enum VtMode {
/// Text mode: kernel renders text console (fbcon or VGA text).
KdText = 0x00,
/// Graphics mode: userspace (Wayland compositor) owns the display.
/// Kernel does not write to the framebuffer.
KdGraphics = 0x01,
}
/// Keyboard input mode (matches Linux `K_RAW` / `K_XLATE` / `K_MEDIUMRAW` /
/// `K_UNICODE` / `K_OFF` from `include/uapi/linux/kd.h`).
#[repr(u32)]
pub enum KbdMode {
/// Raw scancode mode: scancodes passed directly to userspace.
KRaw = 0x00,
/// Translated mode: scancodes → keysyms via keymap.
KXlate = 0x01,
/// Medium-raw mode: scancodes with key up/down encoding.
KMediumRaw = 0x02,
/// Unicode mode: scancodes → UTF-8 via keymap (default for text VTs).
KUnicode = 0x03,
/// Off mode: keyboard input disabled. Wayland compositors (wlroots, KWin,
/// Mutter) set this via `ioctl(KDSKBMODE, K_OFF)` when taking VT control.
/// Without this variant, `KDSKBMODE(4)` returns `-EINVAL`, preventing
/// Wayland session startup.
KOff = 0x04,
}
VT switch protocol: When a VT switch is triggered (by ioctl(VT_ACTIVATE, n) or
the keyboard chord Ctrl+Alt+F1..F12):
- Validate target: Ensure
1 <= n <= MAX_NR_CONSOLESand the target VT exists. - Revoke old VT's DRM master: If the old VT has a
drm_master, calldrm_master_revoke()which sets the master'sis_currentflag to false, disabling modesetting ioctls. The old compositor's pending atomic commits are rejected with-EACCES. - Signal old session: Send
SIGUSR1to the old VT's controlling session (ifVT_SETMODEwas called withVT_PROCESSmode, enabling cooperative switching). If the old session does not acknowledge within 5 seconds, the switch proceeds forcibly (matching Linuxvt_reset()timeout behavior). - Update
active_vt: Atomically store the new VT number. - Grant new VT's DRM master: If the new VT has a
drm_master, calldrm_master_grant()which setsis_currentto true and triggers a full modeset restore (the compositor's last committed atomic state is replayed). - Signal new session: Send
SIGUSR2to the new VT's controlling session. - Redirect input:
umka-inputupdates its routing to deliverinput_eventstructs to the new VT's session.
The keyboard chord (Ctrl+Alt+Fn) is intercepted in the umka-input keyboard
processing path before events reach userspace. In KD_GRAPHICS mode, the chord
is only honored if the compositor has not set K_OFF via KDSKBMODE (Wayland
compositors typically set K_OFF and handle VT switching cooperatively via
logind's TakeControl/ReleaseControl D-Bus protocol).
21.3.3.2 Text-VT Keyboard-to-TTY Handoff¶
A text-mode VT (VtMode::KdText) is backed by a TtyPort (VtConsole.tty), and
its keyboard input must reach that port's line discipline. This is the input half
of the TTY design: after keymap translation, the produced bytes enter the port
through the same async ldisc ingress seam the serial and PTY paths use
(tty_ingress_enqueue, Section 21.1) —
never inline processing. There is exactly one ingress seam, so N_TTY canonical
editing, echo, and signal generation happen identically regardless of the input
source.
/// Highest keycode index the keymap tables cover (Linux `KEY_MAX + 1 = 0x300`).
pub const KEYCODE_MAX: usize = 0x300;
/// Keysym type/value split (Linux `KTYP`/`KVAL`): a keysym's high byte is its
/// type, its low byte the value. `loadkeys` keymaps use this encoding verbatim.
pub fn ktyp(keysym: u16) -> u8 { (keysym >> 8) as u8 }
pub fn kval(keysym: u16) -> u8 { (keysym & 0xff) as u8 }
/// Keysym type constants (Linux `include/linux/keyboard.h`). Only the two the
/// VT-to-TTY path special-cases post-lookup are named here:
pub const KT_LETTER: u8 = 0x0b; // letter — case-folds under CapsLock (KG_SHIFT re-index)
pub const KT_PAD: u8 = 0x03; // numeric keypad — gated by NumLock
/// Bit position of Shift within the modifier index (Linux `KG_SHIFT`). CapsLock
/// flips a letter's case by toggling this bit in the keymap lookup index.
pub const KG_SHIFT: u32 = 0;
/// Lock-toggle bits held in `KbdState.lock_state` (NOT part of the keymap index).
pub const LOCK_CAPS: u32 = 1 << 0; // CapsLock (Linux VC_CAPSLOCK)
pub const LOCK_NUM: u32 = 1 << 1; // NumLock (Linux VC_NUMLOCK)
pub const LOCK_SCROLL: u32 = 1 << 2; // ScrollLock (Linux VC_SCROLLLOCK)
/// Keyboard keymap: (modifier state × keycode) → keysym. Loaded/overridden via
/// the `KDGKBENT`/`KDSKBENT` ioctls (Linux `struct kbentry`), defaulting to the
/// built-in map. Warm-path table (config/ioctl), not per-event allocation.
pub struct VtKeymap {
/// `keysym[modifier_combo][keycode]` — modifier combos are the standard
/// Linux set (plain, Shift, AltGr, Control, …). Keysyms follow the Linux
/// `KT_*` type/value encoding so existing keymaps (`loadkeys`) load verbatim.
pub keysym: Box<[[u16; KEYCODE_MAX]]>,
}
impl VtKeymap {
/// Look up the keysym for `(combo, keycode)`, falling back to the plain
/// (row 0) map when `combo` addresses no row — matching Linux, which treats a
/// missing modifier row as "use the base map" rather than faulting on
/// an absent modifier combination. Row 0 (plain) is always present. `keycode`
/// is bounded by `KEYCODE_MAX` (the input layer never emits a code above
/// `KEY_MAX < KEYCODE_MAX`), so the intra-row index needs no further guard.
pub fn lookup(&self, combo: u32, keycode: u16) -> u16 {
let row = self.keysym.get(combo as usize).unwrap_or(&self.keysym[0]);
row[keycode as usize]
}
}
/// Per-console keyboard translation state (not per-fd — VT keyboard input is a
/// single logical device). Holds the active keymap and the live modifier and lock
/// state.
pub struct KbdState {
/// Active keymap (default or `loadkeys`-loaded).
pub keymap: VtKeymap,
/// Real shift-key modifier index (Shift/Ctrl/Alt/AltGr) — and ONLY those.
/// This is what indexes `VtKeymap.keysym`. CapsLock/NumLock/ScrollLock are NOT
/// folded in here: Linux's `shift_final` excludes the locks, so the base
/// keymap lookup must not see them (folding them in mis-indexes every key —
/// NumLock would shift the whole table, CapsLock would capitalise punctuation).
pub mod_state: u32,
/// Lock toggles (`LOCK_CAPS`/`LOCK_NUM`/`LOCK_SCROLL`). Kept OUT of the keymap
/// index: CapsLock re-indexes only `KT_LETTER` keysyms AFTER the lookup (case
/// flip via `KG_SHIFT`), and NumLock gates the `KT_PAD` keypad — matching the
/// Linux VT keyboard contract (`vc_kbd_led(VC_CAPSLOCK/VC_NUMLOCK)`).
pub lock_state: u32,
}
impl KbdState {
/// Update state for a modifier/lock keycode from the raw `EV_KEY` value
/// (`0` = release, `1` = press, `2` = autorepeat). Shift/Ctrl/Alt/AltGr set
/// their bit in `mod_state` on press and clear it on release, but IGNORE
/// autorepeat (Linux `k_shift`: `if (rep) return;`). CapsLock/NumLock/
/// ScrollLock TOGGLE their `LOCK_*` bit in `lock_state` on a genuine press
/// ONLY — both release AND autorepeat are ignored, so a held lock key does not
/// re-toggle the lock on every repeat (Linux `k_lock`:
/// release or autorepeat returns immediately). The locks never enter `mod_state`, so they
/// cannot perturb the keymap index. The value must be passed straight from
/// `ev.value` — collapsing it to a `pressed: bool` erases the press/repeat
/// distinction and re-introduces the every-repeat re-toggle bug.
pub fn update_modifiers(&mut self, keycode: u16, value: i32);
}
/// True if `keycode` is a modifier or lock key (Shift/Ctrl/Alt/AltGr/Caps/Num).
pub fn is_modifier(keycode: u16) -> bool;
/// Encode a keycode as the VT **K_RAW** byte stream: the AT/XT "set 1" scancodes.
/// This is a DIFFERENT stream from medium-raw (which emits keycodes) — a keycode
/// maps to its set-1 scancode; extended keys (arrows, Right-Ctrl/Alt, KP-Enter,
/// Home/End/Insert/Delete/PageUp/PageDown, the GUI keys) are prefixed with `0xe0`.
///
/// **Release (up) flag**: `0x80` is OR'd into every byte that carries a make
/// code, and NEVER into a `0xe0`/`0xe1` PREFIX byte (matching Linux
/// behavior, which applies the release bit per emitted scancode byte, not "the
/// final byte"). Concretely:
/// - ordinary key: `code` → release `code | 0x80`;
/// - extended key: `0xe0, code` → release `0xe0, code | 0x80` (prefix unchanged);
/// - Pause: `0xe1, 0x1d, 0x45` → release `0xe1, 0x9d, 0xc5`
/// (`0x1d|0x80`, `0x45|0x80`; the `0xe1` prefix keeps no up flag);
/// - PrintScreen/SysRq: `0xe0, 0x2a, 0xe0, 0x37` → release
/// `0xe0, 0xaa, 0xe0, 0xb7` (`0x2a|0x80`, `0x37|0x80`; both `0xe0`
/// prefixes unchanged). Alt+SysRq collapses to the single byte `0x54`
/// (release `0xd4`).
///
/// The set-1 mapping is a fixed table (x86 hardware heritage), but the K_RAW
/// set-1 stream is the ABI every VT raw consumer (X11, `kbd`) expects IDENTICALLY
/// on all eight architectures — so it is the CONTRACT here, arch-neutral, not
/// live hardware.
pub fn encode_raw_scancode(out: &mut ArrayVec<u8, 8>, keycode: u16, pressed: bool);
/// Encode a keycode as Linux medium-raw (matching `kbd_keycode`'s
/// medium-raw branch). `KEYCODE_MAX = 0x300` makes keycodes ≥ 128 real, so both
/// forms are required:
/// - `keycode < 128`: ONE byte `keycode | (up << 7)` — the keycode with bit 7
/// set on release (`up = !pressed`);
/// - `keycode >= 128`: the THREE-byte escape `up << 7`, `(keycode >> 7) | 0x80`,
/// `keycode | 0x80` — i.e. a leading byte that is `0x00` on press / `0x80` on
/// release, then the high 7 bits and low 7 bits of the keycode each with
/// bit 7 set. (A reader masks `& 0x7f` off the two data bytes and reads the
/// up flag from the leading byte's bit 7.)
pub fn encode_mediumraw(out: &mut ArrayVec<u8, 8>, keycode: u16, pressed: bool);
/// Encode a resolved keysym into output bytes. Text keysyms emit locale byte(s)
/// for `KXlate` or UTF-8 for `KUnicode`; cursor/function/keypad keysyms emit their
/// ANSI escape sequence (mode-independent). Applies the Meta/Alt `ESC` prefix per
/// `mod_state` where the termios convention calls for it.
pub fn encode_keysym(out: &mut ArrayVec<u8, 8>, keysym: u16, mode: KbdMode, mod_state: u32);
/// Resolve a `KT_PAD` keypad keysym under the current NumLock state, returning the
/// keysym `encode_keysym` should emit. NumLock ON → the numeric/operator glyph
/// (`kval` selects `0-9 + - * / . , Enter`); NumLock OFF → the cursor/navigation
/// counterpart (KP-8→Up, KP-2→Down, KP-4→Left, KP-6→Right, KP-0→Insert,
/// KP-Dot→Delete, KP-7/1/9/3→Home/End/PageUp/PageDown). Matches Linux `k_pad`
/// (the numeric glyph table when the NumLock LED is on, cursor/function dispatch when off).
pub fn keypad_resolve(keysym: u16, numlock_on: bool) -> u16;
/// Translate one key event for the active text VT and hand the resulting bytes
/// to that VT's line discipline. Called from the `umka-input` keyboard path
/// AFTER the VT-switch chord check and only when the active VT is `KdText`.
/// A no-op for `K_OFF` (input disabled) and, in the translated modes only, for
/// key releases. Modifier/lock keys always update keyboard state — shift keys into
/// `kbd.mod_state`, lock keys into `kbd.lock_state`; in the translated modes they
/// additionally produce no byte, but in raw / medium-raw they are delivered as
/// scancode/keycode bytes like any other key — matching Linux `kbd_keycode`, which
/// queues raw/medium-raw bytes for EVERY keycode before the `KT_SHIFT`/`KT_SPEC`
/// translation filter.
pub fn vt_keyboard_to_tty(vt: &VtConsole, kbd: &mut KbdState, ev: &InputEvent) {
if ev.type_ != EV_KEY { return; }
let Some(tty) = vt.tty.as_ref() else { return }; // no backing TTY → drop
let pressed = ev.value != 0; // 1 = press, 2 = autorepeat
let keycode = ev.code;
let modifier = is_modifier(keycode); // Shift/Ctrl/Alt/AltGr/Caps/Num
// Modifier/lock bookkeeping runs for EVERY mode so the XLATE/Unicode shift
// map stays correct. Unlike the translated arms, it does NOT short-circuit
// the raw/medium-raw arms — those deliver a byte for the modifier keycode
// too (Linux updates shift state AND still queues the raw byte, its `KT_SHIFT`
// early-return exemption).
if modifier { kbd.update_modifiers(keycode, ev.value); } // raw value: 0/1/2 (repeat distinguished)
// Encode per keyboard mode into a small stack buffer, then hand off.
let mut out: ArrayVec<u8, 8> = ArrayVec::new();
match vt.kbd_mode {
KbdMode::KOff => return,
KbdMode::KRaw => {
// K_RAW: AT/XT set-1 SCANCODES (0xe0-prefixed extended keys, 0x80 on
// release) — a DIFFERENT byte stream from medium-raw. Delivered for
// EVERY keycode (modifiers included) on press AND release, as Linux
// queues raw bytes ahead of the translation filter; an X11-class reader
// tracks modifier up/down from these scancodes itself.
encode_raw_scancode(&mut out, keycode, pressed);
}
KbdMode::KMediumRaw => {
// K_MEDIUMRAW: keycode bytes (`keycode | 0x80` on release, high
// keycodes escaped). Also delivered for EVERY keycode on press AND
// release, ahead of the translation filter.
encode_mediumraw(&mut out, keycode, pressed);
}
KbdMode::KXlate | KbdMode::KUnicode => {
// Translated modes: a modifier/lock key changes shift/lock state (done
// above) but emits no character; an ordinary key translates on
// press/autorepeat only, and a release produces no byte.
if modifier { return; } // shift/lock state already updated
if !pressed { return; } // ev.value == 0 (release)
// Base lookup uses the REAL shift modifiers only (`mod_state`); the
// locks are applied AFTER the lookup, never folded into the index.
// `lookup` falls back to the plain map for an absent combo (no panic).
let mut keysym = kbd.keymap.lookup(kbd.mod_state, keycode);
match ktyp(keysym) {
KT_LETTER => {
// CapsLock case-flips letters only: re-index the Shift-toggled
// map (Linux re-looks up KT_LETTER with `shift_final ^ KG_SHIFT`
// when the CapsLock LED is on). Non-letters are untouched.
if kbd.lock_state & LOCK_CAPS != 0 {
keysym = kbd.keymap.lookup(kbd.mod_state ^ (1 << KG_SHIFT), keycode);
}
}
KT_PAD => {
// NumLock gates the numeric keypad: LED off → cursor/navigation
// keysym, LED on → the digit/operator glyph. A keypad-arm
// selection, never via the lookup index.
keysym = keypad_resolve(keysym, kbd.lock_state & LOCK_NUM != 0);
}
_ => {}
}
// KXlate emits the locale byte(s); KUnicode emits UTF-8 for the
// keysym's Unicode value; cursor/keypad keysyms emit their escape
// sequence. All go through the same helper, which also applies the
// Meta/Alt prefix (ESC) per termios convention.
encode_keysym(&mut out, keysym, vt.kbd_mode, kbd.mod_state);
}
}
if !out.is_empty() {
// THE ingress seam: enqueue + wake the port's ldisc worker. Never inline
// N_TTY processing here — that keeps the async/ring design intact.
tty_ingress_enqueue(tty, &out);
}
}
The routing step of the VT switch protocol (step 7) selects which VT's
vt_keyboard_to_tty receives events; the active VT's TtyPort is the sole
recipient while it holds focus.
Panic console handoff procedure:
The full panic console path — including domain revocation, backend priority chain, netconsole panic transmit, and per-architecture isolation teardown — is specified in Section 21.2. Summary:
- IRQs already disabled by the panic path before reaching this code.
- Revoke all isolation domains via
arch::current::isolation::open_all_domains()(WRPKRU(0) on x86-64, equivalent on other architectures). All Tier 1 driver code becomes directly callable. - Call
emergency_write()on each registered ConsoleBackend in priority order: emergency serial (priority 5), Tier 1 serial (priority 10), netconsole (priority 15). Failures silently ignored. - Fall through to Tier 0 emergency console (
arch::current::serial::puts()) if all backends fail. Architecture-specific: - x86-64: COM1 serial (UART 16550, I/O port 0x3F8)
- AArch64/ARMv7: PL011 UART (MMIO, base address from DTB)
- RISC-V: SBI console extension (
sbi_console_putchar) - PPC32/PPC64LE: OpenFirmware/OPAL console (
opal_write) - s390x: SCLP console
- LoongArch64: NS16550 UART
- pstore persistence:
pstore_kmsg_dump()writes log ring to non-volatile storage (Section 20.7).
The Tier 0 console path is entirely lock-free and allocation-free. It MUST work unconditionally at panic time, including when the panic was caused by a Tier 1 driver crash, memory corruption, or scheduler deadlock.
21.4 Audio Architecture (ALSA Compatibility)¶
Linux's Advanced Linux Sound Architecture (ALSA) provides the /dev/snd/pcmC0D0p interfaces for audio playback and capture.
21.4.1 ALSA PCM as DMA Rings¶
Audio devices are uniquely suited for UmkaOS's architecture because audio playback is fundamentally a ring buffer problem. Modern audio interfaces (Intel HDA, USB Audio Class 2.0) operate by reading PCM audio samples from a host memory ring buffer via DMA.
In UmkaOS, audio drivers (manifest preferred_tier = 1 — see
Section 13.4) do not implement complex ALSA state machines.
Instead, an UmkaOS audio driver simply allocates an IOMMU-fenced DMA buffer
(Section 4.14, umka_driver_dma_alloc) and programs the hardware
to consume it.
When a userspace audio server (PipeWire or PulseAudio) opens the ALSA PCM node, umka-sysapi directly maps the hardware's DMA ring buffer into the PipeWire process's address space.
The Audio Data Path (mmap fast path): 1. PipeWire writes PCM audio samples directly into the mapped DMA buffer in userspace. 2. PipeWire publishes the new "appl_ptr" (application pointer) into the shared control page. 3. The audio hardware consumes the samples via DMA and generates a period interrupt. 4. The kernel handles the interrupt, publishes the new "hw_ptr" (hardware pointer) into the shared status page, and wakes PipeWire via a futex.
Publication ordering (normative). Steps 1→2 and 3→4 are publish-after-write pairs, and neither is safe as a plain store on a weakly ordered or non-coherent leg. The two rules:
- Producer side (step 2): the sample stores of step 1 must be visible to the
DEVICE before it can observe the advanced
appl_ptr. The mapping is coherent DMA memory (see the per-architecture coherency table in Section 21.4), so the required barrier isdma_wmb()(Section 4.14) — issued by the writer between the last sample store and theappl_ptrpublish, and by the kernel on the ioctl transfer path below. A userspace writer gets the same ordering from the release-store discipline the ALSA library uses on the control page; the kernel re-establishes it on everySNDRV_PCM_IOCTL_SYNC_PTR/FORWARDthat advancesappl_ptron the application's behalf. Without it the device may fetch a slot whose samples have not landed — audible garbage, not a lost wakeup. - Completion side (step 4): the
hw_ptrpublish is a RELEASE store and the futex wake happens AFTER it, so a woken reader that observes the wake also observes the new pointer. Before reading the hardware's position the handler issuesdma_rmb()(Section 4.14) so the position read is not reordered ahead of the DMA writes it accounts for. Readers ofhw_ptr(the status page,SNDRV_PCM_IOCTL_STATUS,SNDRV_PCM_IOCTL_SYNC_PTR) load with ACQUIRE.
PcmStream.hw_ptr/appl_ptr are AtomicU64Exact
(Section 21.4), so these orderings are expressible
on every architecture including the 32-bit legs.
Zero-Copy Routing: On memory-mapped DMA-ring devices (the HDA/PCI class, and any device whose ring the driver can expose directly), the mmap path above is purely zero-copy: the audio samples never pass through kernel memory and the kernel never executes a user copy. The kernel's only role in that data path is routing the hardware interrupt to the PipeWire futex.
Two paths are deliberately NOT zero-copy, and the claim above does not extend to them:
- The non-mmap ioctl transfer path.
SNDRV_PCM_IOCTL_WRITEI_FRAMES/WRITEN_FRAMES/READI_FRAMES/READN_FRAMES(see the ioctl table in Section 21.4) are mandatory ALSA ABI —aplay,arecord, and every application that does not mmap the PCM node use them, and they hand the kernel an ordinary userspace buffer that has no relationship to the DMA ring. The kernel copies: it computes the writable span fromappl_ptr/hw_ptr, copiesmin(requested, avail)frames from theUserSliceinto the DMA ring (playback) or out of it (capture) with the ordinary copy-to/from-user primitives, issuesdma_wmb()on the playback side, then advancesappl_ptrby the frames transferred. Blocking behaviour is the ALSA contract: ifavailis zero the caller sleeps on the stream'swaitersqueue until a period interrupt frees space, unlessO_NONBLOCK(-EAGAIN); inSNDRV_PCM_STATE_XRUNit returns-EPIPEwithout copying (Section 21.4). - Isochronous USB Audio. URB payloads live in USB-HCI-owned buffers, so a
per-period copy between the PCM ring and the URB is unavoidable — see step 5
of
uac_start_streamingin Section 21.4.
The zero-copy property is therefore a property of the mmap path on directly-mappable rings, not a system-wide invariant. An implementing agent must not read it as permission to omit the ioctl transfer path.
21.4.1.1 Xrun Handling (D25)¶
An xrun is a buffer underrun (playback) or overrun (capture) — the application failed to keep up with the real-time audio stream.
Underrun (playback: application fails to refill the DMA ring before the hardware
consumes it):
- The hardware continues running; the DMA ring outputs silence (zero samples) for
the duration of the underrun. No explicit silence padding by the kernel is required —
the hardware or DMA zeroes the consumed region.
- The PCM state transitions to SNDRV_PCM_STATE_XRUN.
- The next write() / snd_pcm_writei() call from the application returns -EPIPE.
- The application must call snd_pcm_recover() or snd_pcm_prepare() to restart
playback.
Overrun (capture: application fails to drain the DMA ring before it fills):
- Incoming samples overwrite the oldest samples in the circular buffer; the oldest
samples are silently dropped.
- The PCM state transitions to SNDRV_PCM_STATE_XRUN.
- The next read() / snd_pcm_readi() call returns -EPIPE.
- The application must call snd_pcm_recover() or snd_pcm_prepare() to restart
capture.
Recovery: snd_pcm_recover(pcm, -EPIPE, silent) calls snd_pcm_prepare() followed
by snd_pcm_start() internally. The silent parameter suppresses error logging for
expected xruns (e.g., during transient CPU load spikes).
No automatic recovery: UmkaOS does not silently recover from xruns on behalf of the
application. The application is responsible for detecting -EPIPE and calling recover.
This matches Linux ALSA behavior.
21.4.2 Audio Driver Tier Policy and Resilience¶
Audio driver manifests typically declare preferred_tier = 1, as
required for professional audio workloads with <5ms latency budgets
where period interrupts fire every 1.3–42.7ms. This is consistent with
the authoritative preferred_tier rationale in Section 13.4.
For consumer/desktop configurations where crash resilience is prioritized over latency, an operator policy override can pin an audio driver to effective Tier 2 at load time. The override adds ~20–50μs syscall overhead per interrupt, which is acceptable at ≥10ms buffer periods but unacceptable for professional RT audio.
Audio drivers (especially USB Audio and complex DSPs) are prone to state machine bugs. Regardless of tier, an audio driver crash is seamlessly contained via the standard driver crash recovery mechanism (Section 11.9).
When an audio driver process crashes, the kernel's device registry (Section 11.4) revokes its MMIO mappings, leaving the DMA ring buffer intact. The registry restarts the driver process. The new driver instance re-initializes the hardware and binds back to the existing DMA ring buffer. PipeWire experiences a brief audio glitch but does not need to close and reopen the ALSA device, as the memory mapping remains valid throughout the recovery process.
Recovery time breakdown: The ~10-20ms total glitch comprises: (a) crash detection via page fault on revoked MMIO mapping (~0 — synchronous), (b) driver process restart including ELF load and re-initialization (~2-5ms), (c) hardware re-initialization including codec probe and DMA ring rebind (~5-15ms depending on hardware; USB Audio Class devices are at the high end due to USB control transfer latency). The glitch duration corresponds to 1-2 audio periods at typical buffer sizes (≥5ms periods). Professional RT configurations with ≤2ms periods may experience 2-5 dropped periods.
The 5–15 ms hardware re-init figure applies when the device supports soft reset — firmware reload without a USB port cycle. Full USB port reset requires T_RSTRCY ≥ 10 ms per USB 2.0 §11.2.6.2 (and ≥100 ms for USB 1.1 Full Speed devices), making full reset recovery 10–300 ms depending on USB version and device speed. Whether a device supports soft reset is detected at driver load time via AudioDevice::probe_soft_reset() and recorded in AudioDeviceCaps. Devices not supporting soft reset incur the full port-reset recovery time on crash reload.
21.4.3 Audio Device Trait¶
Interface contract: Section 13.4 (
AudioDrivertrait,audio_device_v1KABI). This section specifies the Intel HDA, USB Audio Class, and HDMI/DP audio endpoint implementations of that contract. Tier decision and ALSA compat approach are authoritative in Section 13.4.
Architecture: Native UmkaOS audio driver framework with ALSA compatibility in umka-sysapi. The kernel provides a clean, low-latency PCM interface via the AudioDriver trait (Section 13.4). umka-sysapi translates snd_pcm_*/snd_ctl_* ioctls to native calls, enabling existing applications (PipeWire, PulseAudio, JACK) to work unmodified.
Audio types (AudioDeviceId, PcmDirection, PcmFormat, PcmParams,
PcmStreamHandle) are defined canonically in
Section 13.4. This section uses
them for PCM stream management and the ALSA-compatible userspace interface.
/// KABI service marker for an audio driver (`audio.kabi`). `PcmStream`'s
/// `driver_handle` is a `KabiHandle<AudioDriverService>`; the associated
/// `AudioDriverVTable` is the dispatch surface. Generated by `kabi-gen`; the
/// shape is shown here. Binds the `AudioDriver` trait ([Section 13.4](13-device-classes.md#audio-subsystem))
/// across the KABI boundary.
pub struct AudioDriverService;
impl KabiService for AudioDriverService {
type VTable = AudioDriverVTable;
const SERVICE_ID: ServiceId = {
// "audio_driver" NUL-padded into the fixed 60-byte name field.
let mut name = [0u8; 60];
let src = b"audio_driver";
let mut i = 0;
while i < src.len() { name[i] = src[i]; i += 1; }
ServiceId { name, major: 1 }
};
// VtableHeader prefix + the four MANDATORY fn-ptr slots below.
const MANDATORY_VTABLE_SIZE: u64 = core::mem::size_of::<VtableHeader>() as u64
+ 4 * core::mem::size_of::<usize>() as u64;
}
/// KABI vtable for an audio driver — the dispatch surface `PcmStream` reaches
/// through `driver_handle` via `kabi_call!`. Generated by `kabi-gen` from
/// `audio.kabi`; the shape below is NORMATIVE for what it emits, and every slot
/// obeys the generated-surface contract
/// ([Section 12.8](12-kabi.md#kabi-domain-runtime--kabicall-macro-specification)): slots are
/// `unsafe extern "C"`, take the provider `ctx` first, and return C-ABI-stable
/// values ONLY — never a Rust `Result`, which is not FFI-safe. The caller-side
/// generated stub converts the raw return into `Result<T, KabiError>`.
///
/// **Device identity**: `ctx` identifies the provider MODULE, not the device —
/// one audio driver binary routinely serves several controllers. Per-stream
/// slots are keyed by the `PcmStreamHandle` the driver assigned in `open_pcm`
/// (the handle IS the driver-side per-stream identity). `open_pcm` itself runs
/// BEFORE any handle exists, so it names its device explicitly with
/// `AudioDeviceId` ([Section 13.4](13-device-classes.md#audio-subsystem)).
///
/// The slots mirror the PCM-lifecycle methods of the `AudioDriver` trait
/// ([Section 13.4](13-device-classes.md#audio-subsystem)) — `open_pcm`/`start_stream`/`stop_stream`/
/// `close_pcm`, the four MANDATORY slots. The trait's mixer, jack, and power
/// methods are reached through their own service surfaces and are not part of
/// this vtable's mandatory prefix.
#[repr(C)]
pub struct AudioDriverVTable {
/// Live-evolution / bounds header shared by every KABI vtable
/// ([Section 12.1](12-kabi.md#kabi-overview)).
pub header: VtableHeader,
/// Negotiate a PCM stream on `device` and allocate its DMA ring. MANDATORY.
///
/// C-ABI return shape (d), out-pointer aggregate
/// ([Section 12.8](12-kabi.md#kabi-domain-runtime--intokabiresult-trait-and-kabiservice-trait)):
/// returns 0 and fully initializes `*out` on success, or a negative errno.
/// Shape (d) rather than a by-value return because the result is a 24-byte
/// aggregate; `PcmStream` itself can NEVER be the return type — it carries
/// the caller-side `KabiHandle` and kernel-side wait state, neither of
/// which can be serialized through `Ring`/`Tier2Ring`.
pub open_pcm: unsafe extern "C" fn(
ctx: *mut c_void,
device: AudioDeviceId,
params: *const PcmParams,
out: *mut PcmOpen,
) -> i32,
/// Begin DMA on the named stream. MANDATORY. Returns 0 or negative errno.
pub start_stream: unsafe extern "C" fn(
ctx: *mut c_void,
handle: PcmStreamHandle,
) -> i32,
/// Stop DMA on the named stream. MANDATORY. Returns 0 or negative errno.
///
/// `drain`: 1 = wait for the ring to empty (up to one period) before
/// clearing the RUN bit; 0 = immediate stop, pending frames discarded.
/// A `u8` rather than a `bool` because a separately-compiled driver could
/// send a byte outside `{0, 1}`, violating `bool`'s validity invariant.
/// Any nonzero value means drain.
pub stop_stream: unsafe extern "C" fn(
ctx: *mut c_void,
handle: PcmStreamHandle,
drain: u8, // 0 = immediate stop, 1 = drain first
) -> i32,
/// Release the stream and every resource `open_pcm` allocated for it.
/// MANDATORY. Returns 0 or negative errno.
pub close_pcm: unsafe extern "C" fn(
ctx: *mut c_void,
handle: PcmStreamHandle,
) -> i32,
}
// AudioDriverVTable: VtableHeader (88 on 64-bit, 72 on 32-bit) + 4 mandatory
// fn pointers (one word each) = 88 + 4*8 = 120 (64-bit); 72 + 4*4 = 88 (32-bit).
#[cfg(target_pointer_width = "64")]
const_assert!(core::mem::size_of::<AudioDriverVTable>() == 120);
#[cfg(target_pointer_width = "32")]
const_assert!(core::mem::size_of::<AudioDriverVTable>() == 88);
/// The ALSA PCM state machine. Values are Linux ABI — they are reported
/// verbatim in `snd_pcm_status.state` (`SNDRV_PCM_IOCTL_STATUS`) and in the
/// mmap status page, so the discriminants must match Linux exactly (verified
/// against `include/uapi/sound/asound.h`, torvalds/linux at baseline `fc02acf6ac0c`).
/// `#[repr(u32)]`: stored in `PcmStream.state` as an `AtomicU32`. `Copy` +
/// `Eq` so it can be compared and carried by value in `PcmOpError`.
#[derive(Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum SndPcmState {
/// Stream opened, no hardware parameters set yet.
Open = 0,
/// `HW_PARAMS` accepted; DMA buffer allocated, not yet prepared.
Setup = 1,
/// `PREPARE` done; pointers reset, ready for `START`.
Prepared = 2,
/// DMA running.
Running = 3,
/// Underrun (playback) or overrun (capture) — see
/// [Section 21.4](#audio-architecture--xrun-handling-d25). Transfers return `-EPIPE`
/// until the application prepares the stream again.
Xrun = 4,
/// `DRAIN` in progress: no new frames accepted, hardware still consuming
/// what is already in the ring.
Draining = 5,
/// `PAUSE` asserted; hardware stopped but pointers retained.
Paused = 6,
/// Suspended by system power management ([Section 18.4](18-virtualization.md#suspend-and-resume)); the
/// application must `RESUME` or `PREPARE`.
Suspended = 7,
/// The underlying device is gone (unplug, unrecoverable driver fault).
/// Terminal — every operation returns `-ENODEV`.
Disconnected = 8,
}
/// PCM stream (active playback or capture).
///
/// Kernel-internal, NOT a KABI struct: it holds the caller-side `KabiHandle`,
/// the kernel wait queue, and the state machine, none of which cross the KABI
/// boundary. What the driver hands back is the `PcmOpen` pair
/// ([Section 13.4](13-device-classes.md#audio-subsystem)); the kernel builds this object around it. Always
/// `Arc`-managed (`HdaPcmStream.pcm` and the PCM file's private state both
/// hold one), so the interior counters are plain fields — a per-field `Arc`
/// would be a second allocation with no independent owner and an extra pointer
/// chase on the period-interrupt path.
pub struct PcmStream {
/// Stream handle (opaque to the kernel; used as a key by the driver).
pub handle: PcmStreamHandle,
/// Which device of the provider module owns this stream. Passed to
/// `open_pcm`; retained so re-open after a driver crash-restart
/// ([Section 11.9](11-drivers.md#crash-recovery-and-state-preservation)) targets the same device.
pub device: AudioDeviceId,
/// Handle to the registered AudioDriver service (from the device registry).
/// Every dispatch back to the owning driver goes through `kabi_call!` on
/// this handle — see `start`/`stop`/`close` below.
pub driver_handle: KabiHandle<AudioDriverService>,
/// Parameters.
pub params: PcmParams,
/// DMA buffer (ring buffer, mapped into userspace via umka-sysapi).
pub dma_buffer: DmaBufferHandle,
/// Current PCM state, holding a `SndPcmState` discriminant. `AtomicU32`
/// (not a lock) because the period-interrupt handler must be able to post
/// `Xrun` from hard-IRQ context without a sleeping or IRQ-ordering
/// dependency, per the IRQ completion contract. All transitions go through
/// `transition()` below, never a bare `store` — a blind store would let an
/// ioctl overwrite the `Xrun` an interrupt just posted, which is exactly
/// the `-EPIPE`-never-delivered bug.
pub state: AtomicU32,
/// Hardware pointer: frames consumed (playback) or produced (capture) by
/// the device since `PREPARE`. Written by the period-interrupt handler,
/// mirrored into the mmap STATUS page for userspace.
///
/// `AtomicU64Exact` ([Section 3.5](03-concurrency.md#locking-strategy--64-bit-atomics-on-32-bit-legs-the-semantic-family)):
/// a torn read here is a wrong `avail` computation — that is silent audio
/// corruption and a bogus xrun verdict, not a tolerable hint — so this is
/// the tear-free family member, not `AtomicU64Cell`. **32-bit hot-path
/// justification** (required by that type's contract): the leaf lock is
/// taken once per PERIOD, i.e. at most a few thousand times a second at the
/// tightest professional settings, never per frame.
pub hw_ptr: AtomicU64Exact,
/// Application pointer: frames submitted (playback) or consumed (capture)
/// by the application. Written by userspace through the mmap CONTROL page
/// on the zero-copy path, and by the kernel on the ioctl transfer path
/// (`WRITEI_FRAMES`/`FORWARD`/`SYNC_PTR`). Same family member and same
/// justification as `hw_ptr`.
pub appl_ptr: AtomicU64Exact,
/// Ring-pointer wrap modulus, in frames: both pointers advance
/// monotonically modulo `boundary`, and `avail` is computed modulo it.
/// Set at `PREPARE` to the largest multiple of `buffer_frames` that is a
/// power of two and still fits in the ABI's `snd_pcm_uframes_t`
/// (`KernelULong` — 64-bit on LP64 targets, 32-bit on ARMv7/PPC32), and
/// reported to userspace in `snd_pcm_sw_params.boundary`. The kernel-side
/// counters are u64 on every architecture; `boundary` is what makes the
/// 32-bit ABI projection lossless.
pub boundary: u64,
/// Total xruns observed on this stream since `open_pcm`. Reported through
/// `snd_pcm_status.overrange` accounting and the audio FMA counters.
/// `AtomicU64Counter` ([Section 3.5](03-concurrency.md#locking-strategy--64-bit-atomics-on-32-bit-legs-the-semantic-family)):
/// a lossless accumulator whose readers tolerate a bounded transient
/// deviation — it is a statistic, never a control input.
pub xruns: AtomicU64Counter,
/// Waiters blocked on stream progress: the blocking ioctl transfer path
/// waiting for `avail`, and `SNDRV_PCM_IOCTL_DRAIN` waiting for the ring
/// to empty. Woken by the period-interrupt handler AFTER the `hw_ptr`
/// release store, and on every state transition out of `Running`. The
/// mmap path does not use this queue — PipeWire is woken by the futex on
/// the shared control page.
pub waiters: WaitQueueHead,
}
impl SndPcmState {
/// Decode a stored discriminant. Every value written into
/// `PcmStream.state` comes from a `SndPcmState`, so an out-of-range word
/// means the struct was corrupted; it is reported as `Disconnected`, the
/// terminal state, rather than trusted.
pub fn from_u32(v: u32) -> Self {
match v {
0 => Self::Open,
1 => Self::Setup,
2 => Self::Prepared,
3 => Self::Running,
4 => Self::Xrun,
5 => Self::Draining,
6 => Self::Paused,
7 => Self::Suspended,
_ => Self::Disconnected,
}
}
}
/// Why a PCM lifecycle operation failed. Two sources, kept distinct because
/// they need different ABI answers and different recovery by the caller.
///
/// A file-local type rather than `KernelError`
/// ([Section 3.14](03-concurrency.md#error-handling-and-fault-containment)): the ALSA ABI requires
/// `-EPIPE` after an xrun and `-EBADFD` for a wrong-state ioctl, and
/// `KernelError` models neither — routing them through it would collapse both
/// to `EIO` and break `snd_pcm_recover()`, which dispatches on exactly the
/// `-EPIPE` value. Widening `KernelError` for two audio-specific errnos is the
/// wrong trade at corpus scale; the conversion lives here instead.
// kernel-internal, not KABI.
pub enum PcmOpError {
/// The stream was not in the required state. Carries the state actually
/// observed, from which `umka-sysapi` derives the ioctl return:
/// `Xrun` → `-EPIPE`, `Disconnected` → `-ENODEV`, `Suspended` → `-ESTRPIPE`,
/// anything else → `-EBADFD`. These are the values Linux ALSA returns for
/// the same situations, so `snd_pcm_recover()` behaves identically.
BadState(SndPcmState),
/// The dispatch to the driver failed — transport or provider liveness
/// (`StaleHandle`, `ComponentQuiescing`, `QueueFull`, `DomainCrashed`,
/// `Timeout`) or the driver's own error, which arrives as
/// `KabiError::DriverError(errno)` carrying the negated `AudioError`
/// discriminant. `umka-sysapi` converts with `KabiError::to_errno()`
/// ([Section 12.3](12-kabi.md#kabi-bilateral-capability-exchange)).
Dispatch(KabiError),
}
impl From<KabiError> for PcmOpError {
fn from(e: KabiError) -> Self { PcmOpError::Dispatch(e) }
}
impl From<SndPcmState> for PcmOpError {
fn from(s: SndPcmState) -> Self { PcmOpError::BadState(s) }
}
impl PcmStream {
/// Current PCM state. Acquire-ordered: a reader that observes `Running`
/// also observes the pointer stores the transition published.
pub fn state(&self) -> SndPcmState {
SndPcmState::from_u32(self.state.load(Ordering::Acquire))
}
/// Attempt the transition `from -> to`. On failure returns the state
/// actually observed, which is what the ioctl layer needs to produce the
/// ALSA-mandated errno (see `PcmOpError` for the mapping).
///
/// `compare_exchange`, never a bare store: the period-interrupt handler
/// can post `Xrun` concurrently with an ioctl, and a blind store would
/// erase it — the application would then never see the `-EPIPE` that tells
/// it to recover ([Section 21.4](#audio-architecture--xrun-handling-d25)).
fn transition(&self, from: SndPcmState, to: SndPcmState)
-> Result<(), SndPcmState>
{
match self.state.compare_exchange(
from as u32,
to as u32,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => {
// Any departure from Running can satisfy a blocked transfer or
// a DRAIN waiter; wake them so they re-evaluate.
self.waiters.wake_up_all();
Ok(())
}
Err(actual) => Err(SndPcmState::from_u32(actual)),
}
}
/// Post an xrun. Called from the period-interrupt handler when the
/// hardware overran `appl_ptr` (playback) or `appl_ptr` fell a full buffer
/// behind `hw_ptr` (capture).
///
/// Hard-IRQ safe: one `compare_exchange` loop plus a `WaitQueueHead` wake,
/// no sleeping and no allocation, per the IRQ completion contract.
///
/// **Only a transferring stream can xrun**, so exactly two source states
/// are accepted: `Running` and `Draining`. Everything else is left
/// untouched and the xrun is not counted. A check-then-store — even one
/// that checks `Disconnected` first — is not equivalent: the check and the
/// store are two operations, and a hot-unplug or a concurrent `DROP`
/// landing between them is erased by the store. The application would then
/// be told `-EPIPE` for a device that is gone (it retries `PREPARE`
/// forever instead of seeing `-ENODEV`), or would be pulled back into an
/// xrun it had already abandoned.
///
/// `Xrun` stays sticky: nothing here clears it, and only `PREPARE` leaves
/// it. The loop terminates because every iteration either succeeds or
/// observes a state outside the accepted set and returns.
pub fn post_xrun(&self) {
let mut cur = self.state.load(Ordering::Acquire);
loop {
match SndPcmState::from_u32(cur) {
SndPcmState::Running | SndPcmState::Draining => {}
// Not transferring: Disconnected, Suspended, Paused, an
// already-posted Xrun, or a stream an ioctl has just parked.
// Each of those is a newer or stronger fact than this xrun.
_ => return,
}
match self.state.compare_exchange_weak(
cur,
SndPcmState::Xrun as u32,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => break,
Err(actual) => cur = actual,
}
}
self.xruns.add(1);
self.waiters.wake_up_all();
}
/// Start the stream (begin DMA). Backs `SNDRV_PCM_IOCTL_START`.
///
/// Programs the hardware DMA engine to transfer audio data between the
/// ring buffer and the codec. For playback, the hardware reads from
/// `dma_buffer[hw_ptr..appl_ptr]`. For capture, the hardware writes to
/// `dma_buffer[hw_ptr..]`. The caller (ALSA compat layer or PipeWire
/// bridge) must have buffered `start_threshold` frames (playback) or left
/// ring space (capture); the driver reports a violated precondition as a
/// `KabiError::DriverError` carrying the negated `AudioError` discriminant
/// (`Underrun` / `Overrun`).
///
/// Dispatch is `kabi_call!` — the single entry point to the domain model
/// ([Section 12.8](12-kabi.md#kabi-domain-runtime--kabicall-macro-specification)). It selects the
/// transport the handle recorded at bind time (direct vtable call when the
/// driver shares this domain, T1 ring or T2 ring when it does not) and runs
/// the dispatch prologue: the provider generation check that turns a
/// crashed, replaced, or revoked provider into `KabiError::StaleHandle`,
/// and the live-evolution gate that yields `KabiError::ComponentQuiescing`.
/// A raw `driver_handle.vtable()` dereference would be the same-domain fast
/// path ONLY — it cannot reach an audio driver bound at Tier 1 or Tier 2
/// (the manifest's default is `preferred_tier = 1`,
/// [Section 13.4](13-device-classes.md#audio-subsystem)) and would skip every liveness check above.
pub fn start(&self) -> Result<(), PcmOpError> {
// Claim the transition first: a stream that is not Prepared has no
// business programming DMA, and the claim is what makes a concurrent
// second START fail instead of double-arming the hardware.
self.transition(SndPcmState::Prepared, SndPcmState::Running)?;
match kabi_call!(&self.driver_handle, start_stream, self.handle) {
Ok(()) => Ok(()),
Err(e) => {
// The hardware never started: give the state back so the
// application can retry after PREPARE rather than being stuck
// in a Running state with a silent device.
//
// A CAS, not a store, and its failure is deliberately ignored:
// this call may only take back the transition it installed. If
// the period-interrupt handler posted `Xrun` or the device
// disconnected while the dispatch was in flight, that state is
// newer than this rollback and must stand — a store would
// erase it and the application would never see `-EPIPE` or
// `-ENODEV`. Either way the caller receives the dispatch
// error, and the state it observes on the next ioctl is the
// strongest fact known.
let _ = self.transition(SndPcmState::Running, SndPcmState::Prepared);
Err(PcmOpError::Dispatch(e))
}
}
}
/// Stop the stream. Backs both `SNDRV_PCM_IOCTL_DROP` (`drain = false`)
/// and `SNDRV_PCM_IOCTL_DRAIN` (`drain = true`).
///
/// `drain = false`: clears the RUN bit immediately and discards whatever
/// is still in the ring. `drain = true`: the hardware keeps consuming
/// until the ring is empty (bounded by the driver at one period) before
/// the RUN bit is cleared, so already-submitted frames are played out.
/// The parameter is explicit precisely because the two ALSA ioctls differ
/// only in this: a method that always requested an immediate abort could
/// not implement `DRAIN` at all.
///
/// Either way the DMA buffer stays mapped — the stream returns to `Setup`
/// and can be prepared and restarted without re-opening.
/// Dispatch rationale as in `start()`.
pub fn stop(&self, drain: bool) -> Result<(), PcmOpError> {
// DRAIN passes through Draining so a concurrent transfer sees that no
// new frames are accepted while the tail plays out.
if drain {
self.transition(SndPcmState::Running, SndPcmState::Draining)?;
}
let result = kabi_call!(
&self.driver_handle,
stop_stream,
self.handle,
drain as u8 // 0 = immediate stop, 1 = drain first
);
// Whatever the driver reported, DMA is no longer running from the
// kernel's point of view; parking the stream in Setup is what lets the
// application recover with PREPARE. A driver error is still returned.
//
// The park is a CAS loop rather than a store, and it refuses exactly
// one state: `Disconnected`. DROP/DRAIN is an explicit instruction to
// abandon what is in the ring, so it is entitled to discard an `Xrun`
// the interrupt handler posted mid-stop — but it is not entitled to
// resurrect a device that went away, whose every operation owes the
// application `-ENODEV`. A blind store cannot make that distinction.
let mut cur = self.state.load(Ordering::Acquire);
while SndPcmState::from_u32(cur) != SndPcmState::Disconnected {
match self.state.compare_exchange_weak(
cur,
SndPcmState::Setup as u32,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => break,
Err(actual) => cur = actual,
}
}
self.waiters.wake_up_all();
result.map_err(PcmOpError::Dispatch)
}
/// Release the stream and every driver-side resource behind it. Backs
/// `SNDRV_PCM_IOCTL_HW_FREE` and PCM `close(fd)`; the kernel-side steps
/// (mmap revoke, IOMMU teardown, buffer free) are in
/// [Section 21.4](#audio-architecture--pcm-dma-buffer-lifecycle).
///
/// The caller must have stopped the stream first — `close()` does not stop
/// it implicitly, because a driver that is asked to free a running ring
/// cannot tell an orderly teardown from a bug. Dispatch rationale as in
/// `start()`.
pub fn close(&self) -> Result<(), PcmOpError> {
kabi_call!(&self.driver_handle, close_pcm, self.handle)
.map_err(PcmOpError::Dispatch)
}
}
/// Mixer control (volume slider, mute toggle, input source selector).
// kernel-internal, not KABI — internal mixer state, translated to snd_ctl_elem_value
// at the ioctl boundary. Never exposed directly to userspace.
#[repr(C)]
pub struct MixerControl {
/// Control ID (for set_mixer_control).
pub id: u32,
/// Control type.
pub control_type: MixerControlType,
/// Name (e.g., "Master Playback Volume").
pub name: [u8; 64],
/// Min value (for volume controls).
pub min: i32,
/// Max value (for volume controls).
pub max: i32,
/// For Enum-type controls: number of valid items (0..num_enum_items-1).
/// The `value` field must be in range [0, num_enum_items). For non-enum
/// controls, this field is 0. Used for range validation on set_mixer_control:
/// attempts to set an enum value >= num_enum_items return -EINVAL.
pub num_enum_items: u32,
/// Current value. Signed per ALSA `snd_ctl_elem_value` (signed long values).
/// Volume controls use negative dB offsets; mute is 0.
pub value: AtomicI32,
}
/// Mixer control type.
#[repr(u32)]
pub enum MixerControlType {
/// Volume (integer range, min..max).
Volume = 0,
/// Mute (boolean, 0=unmuted, 1=muted).
Mute = 1,
/// Enumeration (e.g., input source: "Mic", "Line In", "CD").
Enum = 2,
}
21.4.3.1 PCM DMA Buffer Lifecycle¶
The PcmStream.dma_buffer field references a coherent DMA buffer allocated and
managed by the kernel on behalf of the audio driver. The lifecycle is:
Allocation: When userspace opens a PCM device and issues SNDRV_PCM_IOCTL_HW_PARAMS,
the kernel allocates a DMA buffer via dma_alloc_coherent() (Section 4.14).
The buffer size is periods * period_size_bytes, derived from the negotiated
PcmParams. Constraints: minimum 2 periods (required for double-buffering — the
hardware reads one period while the application fills the next), maximum 1 MiB per
stream (prevents a single PCM device from exhausting DMA-capable memory; professional
multi-channel configurations at 192 kHz / 32-bit with 8 periods fit within this limit).
The IOMMU mapping is established at allocation time, restricting DMA to the allocated
region only (Section 4.14 IOMMU integration).
Userspace mmap: The DMA buffer is exposed to userspace via mmap() on the PCM
file descriptor at three well-known offsets (matching Linux ALSA ABI):
| Offset | Constant | Content |
|---|---|---|
0x0000_0000 |
SNDRV_PCM_MMAP_OFFSET_DATA |
PCM sample data (DMA ring buffer) |
The NEW status and control page offsets are native on every architecture
(matching Linux include/uapi/sound/asound.h). They select the
__snd_pcm_mmap_status64 / __snd_pcm_mmap_control64 pages with 64-bit
timestamps (y2038-safe), including for 32-bit userspace.
| Architecture | SNDRV_PCM_MMAP_OFFSET_STATUS |
SNDRV_PCM_MMAP_OFFSET_CONTROL |
Layout |
|---|---|---|---|
| x86-64, AArch64, RISC-V 64, PPC64LE, s390x, LoongArch64 | 0x8200_0000 (NEW) |
0x8300_0000 (NEW) |
snd_pcm_mmap_status64 (64-bit tstamp) |
| ARMv7, PPC32 | 0x8200_0000 (NEW) |
0x8300_0000 (NEW) |
snd_pcm_mmap_status64 (64-bit tstamp) |
For reference, Linux defines all four constants in include/uapi/sound/asound.h:
- SNDRV_PCM_MMAP_OFFSET_STATUS_OLD = 0x8000_0000
- SNDRV_PCM_MMAP_OFFSET_CONTROL_OLD = 0x8100_0000
- SNDRV_PCM_MMAP_OFFSET_STATUS_NEW = 0x8200_0000 (default on 64-bit)
- SNDRV_PCM_MMAP_OFFSET_CONTROL_NEW = 0x8300_0000 (default on 64-bit)
UmkaOS accepts the NEW offsets on every target. On a 64-bit kernel it also
accepts either OLD offset as a compatibility alias and falls through to the
SAME kernel-native 64-bit-time status/control page selected by NEW; there is no
separate tstamp64: false page. On ARMv7 and PPC32 the OLD offsets return
ENXIO, allowing userspace to fall back to SNDRV_PCM_IOCTL_SYNC_PTR.
These offsets are defined as u32 constants. On LP64 platforms, they are zero-extended
to off_t (i64) for the mmap() offset parameter.
The status and control pages are single 4 KiB pages shared between kernel and
userspace. The pages carry the ABI projection of the two ring pointers as
snd_pcm_uframes_t (KernelULong — 64-bit on LP64 targets, 32-bit on ARMv7
and PPC32), taken modulo PcmStream.boundary; the kernel-internal counters
themselves are the u64 AtomicU64Exact fields of PcmStream
(Section 21.4).
That type — not a hand-rolled protocol — is what makes the update tear-free on
every leg: it is the tear-free member of the 64-bit-atomic semantic family
(Section 3.5), a
transparent AtomicU64 where target_has_atomic = "64" holds and a leaf-lock-
guarded u64 on PPC32, the one supported target without a native 64-bit atomic.
The family's classification rule is normative: a bare AtomicU64 here would not
compile on PPC32, and "SeqLock or doubleword CAS" is not a choice this section
gets to make locally. Ordering — as opposed to atomicity — is specified in
Section 21.4 under Publication ordering.
The SNDRV_PCM_IOCTL_SYNC_PTR ioctl provides an explicit synchronization path
for applications that do not mmap the status/control pages; it applies the same
release/acquire discipline on the kernel side.
Per-architecture DMA coherency:
| Platform | DMA Coherency | Buffer Mapping |
|---|---|---|
| x86-64 | Hardware-coherent (all PCIe devices) | Normal cacheable WB mapping |
| AArch64 (with CCI/CMN) | Hardware-coherent | Normal cacheable mapping |
| AArch64 (without CCI) | Non-coherent | Device-nGnRnE (uncached) or explicit cache maintenance via dma_sync_for_cpu / dma_sync_for_device |
| ARMv7 | Non-coherent (typical) | Uncached mapping or explicit dma_sync_* barriers |
| RISC-V | Platform-dependent | Non-coherent platforms use uncached mappings; coherent platforms (with IOPMP or AIA IOMMU) use cacheable |
| PPC32/PPC64LE | Hardware-coherent (cache-inhibited via WIMG bits) | Guarded + cache-inhibited mapping (WIMG=0101) |
On non-coherent platforms, the dma_alloc_coherent() path in Section 4.14
automatically selects uncached mappings, so audio drivers need no explicit cache
management. The userspace mmap inherits the same caching attributes as the kernel
mapping.
Teardown: On SNDRV_PCM_IOCTL_HW_FREE or PCM device close (close(fd)):
- DMA engine is stopped:
PcmStream::stop(drain = false)(Section 21.4) clears the RUN bit and waits for the current period to complete. Skipped when the stream is already in a non-Runningstate. - The driver releases its side:
PcmStream::close()dispatches the MANDATORYclose_pcmslot, so the driver frees the stream descriptor / endpoint reservation and drops its per-handle state. After this thePcmStreamHandleis dead. This step is what theAudioDriver::close_pcmcontract (Section 13.4) exists for; without it the driver-side stream resources leak for the device's lifetime even though the kernel-side buffer below is reclaimed. - Userspace mmap is revoked (VMA removed from the process address space).
- IOMMU mapping is removed (the device can no longer DMA to/from the buffer).
- DMA buffer is freed via
dma_free_coherent().
Steps 3–5 run even if step 2 reported an error (a crashed or quiescing driver
still yields PcmOpError::Dispatch): the kernel owns the mapping and the
buffer, and must not leak them because the provider is unreachable. The error
is surfaced to the caller and to FMA after the reclaim completes.
Crash recovery: If a Tier 1 audio driver crashes (Section 11.9), the kernel forcibly reclaims the DMA region: the IOMMU mapping is revoked immediately (preventing the crashed driver's hardware from issuing further DMA), the userspace mmap remains valid (the pages are still mapped, but DMA has stopped — PipeWire sees silence). The restarted driver re-initializes hardware and rebinds to the existing DMA buffer, resuming playback with a brief glitch (see Section 21.4).
21.4.4 Intel HDA Driver Model¶
Intel High Definition Audio (HDA) is the dominant audio controller on Intel and AMD x86 platforms. The HDA spec defines: - HDA controller: PCI device (class 0x0403), exposes MMIO registers for command/response, DMA buffer descriptors, interrupt status. - Codecs: Audio chips connected via the HDA link (typically 1-2 codecs: one for analog audio, one for HDMI/DP audio). Each codec has a tree of widgets (nodes: DAC, ADC, mixer, pin, amplifier).
// umka-hda-driver/src/lib.rs (Tier 1 driver, optionally Tier 2)
/// HDA controller global register block (HDA spec §3.3), mapped from PCI BAR0.
/// Hardware-facing MMIO layout — field offsets are fixed by the HDA
/// specification; reserved gaps are made explicit so the offsets are exact.
/// Accessed through a `*mut HdaRegisters` via volatile reads/writes.
/// Per-stream descriptor registers begin at offset 0x80 and are addressed by
/// computed offset (`0x80 + stream_idx * 0x20`), not through this struct.
#[repr(C)]
pub struct HdaRegisters {
pub gcap: u16, // 0x00 Global Capabilities
pub vmin: u8, // 0x02 Minor Version
pub vmaj: u8, // 0x03 Major Version
pub outpay: u16, // 0x04 Output Payload Capability
pub inpay: u16, // 0x06 Input Payload Capability
pub gctl: u32, // 0x08 Global Control
pub wakeen: u16, // 0x0C Wake Enable
pub statests: u16, // 0x0E State Change Status (codec-present bitmap)
pub gsts: u16, // 0x10 Global Status
pub _rsvd0: [u8; 6], // 0x12-0x17 reserved
pub outstrmpay: u16, // 0x18 Output Stream Payload Capability
pub instrmpay: u16, // 0x1A Input Stream Payload Capability
pub _rsvd1: [u8; 4], // 0x1C-0x1F reserved
pub intctl: u32, // 0x20 Interrupt Control
pub intsts: u32, // 0x24 Interrupt Status
pub _rsvd2: [u8; 8], // 0x28-0x2F reserved
pub walclk: u32, // 0x30 Wall Clock Counter
pub _rsvd3: [u8; 4], // 0x34-0x37 reserved
pub ssync: u32, // 0x38 Stream Synchronization
pub _rsvd4: [u8; 4], // 0x3C-0x3F reserved
pub corblbase: u32, // 0x40 CORB Lower Base Address
pub corbubase: u32, // 0x44 CORB Upper Base Address
pub corbwp: u16, // 0x48 CORB Write Pointer
pub corbrp: u16, // 0x4A CORB Read Pointer
pub corbctl: u8, // 0x4C CORB Control
pub corbsts: u8, // 0x4D CORB Status
pub corbsize: u8, // 0x4E CORB Size
pub _rsvd5: u8, // 0x4F reserved
pub rirblbase: u32, // 0x50 RIRB Lower Base Address
pub rirbubase: u32, // 0x54 RIRB Upper Base Address
pub rirbwp: u16, // 0x58 RIRB Write Pointer
pub rintcnt: u16, // 0x5A Response Interrupt Count
pub rirbctl: u8, // 0x5C RIRB Control
pub rirbsts: u8, // 0x5D RIRB Status
pub rirbsize: u8, // 0x5E RIRB Size
pub _rsvd6: u8, // 0x5F reserved
pub ic: u32, // 0x60 Immediate Command Output Interface
pub ir: u32, // 0x64 Immediate Command Input Interface
pub irs: u16, // 0x68 Immediate Command Status
pub _rsvd7: [u8; 6], // 0x6A-0x6F reserved
pub dplbase: u32, // 0x70 DMA Position Buffer Lower Base
pub dpubase: u32, // 0x74 DMA Position Buffer Upper Base
pub _rsvd8: [u8; 8], // 0x78-0x7F reserved
}
// HdaRegisters: global register block spans offset 0x00-0x7F = 128 bytes.
// Hardware-facing MMIO struct (HDA spec §3.3). Size verified against fixed
// HDA register offsets; per-stream descriptors follow at 0x80.
const_assert!(core::mem::size_of::<HdaRegisters>() == 128);
/// HDA codec verb identifiers and GET_PARAMETER parameter IDs.
/// Values are the 20-bit verb payloads written into the CORB command word by
/// `send_verb()` (which supplies codec address and NID separately). Verified
/// against Linux `include/sound/hda_verbs.h` (`AC_VERB_*` / `AC_PAR_*`).
///
/// The GET_PARAMETER verb is the 12-bit id `0xF00`; its 8-bit parameter goes in
/// the low byte, so the 20-bit payload is `(0xF00 << 8) | param = 0xF_0000 | param`.
pub const fn verb_get_parameter(param: u32) -> u32 {
0x000F_0000 | (param & 0xFF)
}
/// GET_PARAMETER param 0x00 — codec vendor/device id.
pub const PARAM_VENDOR_ID: u32 = 0x00;
/// GET_PARAMETER param 0x04 — subordinate node count.
pub const PARAM_NODE_COUNT: u32 = 0x04;
/// GET_PARAMETER param 0x09 — audio widget caps.
pub const PARAM_AUDIO_WIDGET_CAP: u32 = 0x09;
/// Read the codec's 32-bit vendor id: `verb_get_parameter(VENDOR_ID)`.
pub const VERB_GET_VENDOR_ID: u32 = verb_get_parameter(PARAM_VENDOR_ID);
/// Read a node's subordinate-node start/count: `verb_get_parameter(NODE_COUNT)`.
pub const VERB_GET_SUBORDINATE_NODE_COUNT: u32 = verb_get_parameter(PARAM_NODE_COUNT);
/// GET_PIN_SENSE verb id `0xF09` in the 12-bit verb field.
pub const VERB_GET_PIN_SENSE: u32 = 0x000F_0900;
/// SET_UNSOLICITED_ENABLE verb id `0x708` in the 12-bit verb field; the data
/// byte (bit 7 = enable, bits [5:0] = tag, bit 6 reserved) is OR'd into the low
/// 8 bits by callers.
pub const VERB_SET_UNSOLICITED_ENABLE: u32 = 0x0007_0800;
/// Bit position of the tag field in an unsolicited response payload. The tag
/// occupies bits [31:26] — it is the only identification of which pin raised
/// the event, so both the enable side and the handler key on it.
pub const UNSOL_TAG_SHIFT: u32 = 26;
/// Mask of the 6-bit unsolicited-response tag, applied after
/// `UNSOL_TAG_SHIFT` on the response and directly on the enable verb's data
/// byte.
pub const UNSOL_TAG_MASK: u32 = 0x3F;
/// Presence-detect bit in the RESPONSE to `VERB_GET_PIN_SENSE` (bit 31):
/// 1 = something is plugged into the jack. This bit is NOT present in the
/// unsolicited payload, whose bits [31:26] are the tag.
pub const PIN_SENSE_PRESENCE_DETECT: u32 = 1 << 31;
/// Errors from HDA controller/codec verb transactions.
/// Driver-internal — surfaced to callers as `AudioError` at the KABI boundary.
pub enum HdaError {
/// No RIRB response arrived within the verb timeout (1 ms).
ResponseTimeout,
/// CORB is full — command ring back-pressure; retry after RIRB drains.
CorbFull,
/// Codec address out of range (0-14) or codec not present in STATESTS.
InvalidCodec,
/// Malformed or unexpected RIRB response (solicited/unsolicited mismatch).
InvalidResponse,
/// Controller reported a DMA/bus error (RIRBSTS/CORBSTS error bit set).
ControllerError,
/// `enable_jack_detect()` would exceed `MAX_HDA_JACKS` registered jacks.
JackTableFull,
}
/// Maximum number of codecs on a single HDA link (HDA spec allows 0-14).
pub const MAX_HDA_CODECS: usize = 15;
/// Maximum jack-detection controls per controller. Real codecs expose a
/// handful of presence-detect pins (headphone, line-out, mic, per-connector
/// HDMI/DP); 32 covers multi-codec links and many-connector GPUs.
pub const MAX_HDA_JACKS: usize = 32;
/// Maximum concurrent PCM streams per controller (limited by HDA stream
/// descriptor count; typical controllers support 4-16 bidirectional streams).
pub const MAX_HDA_STREAMS: usize = 16;
/// Deadline for one CORB→RIRB verb round trip, in nanoseconds (1 ms). A
/// healthy codec answers in microseconds; 1 ms is the point at which the
/// transaction is abandoned and the rings are resynchronized (`HdaCmdRing`).
pub const HDA_VERB_TIMEOUT_NS: u64 = 1_000_000;
/// Deadline for one register reset handshake (100 µs). The CORBRP reset
/// acknowledge is an on-die register round trip, three orders of magnitude
/// below the codec-link round trip above; a controller that has not
/// acknowledged within this window is not responding to MMIO at all, which is
/// `HdaError::ControllerError`, not a slow codec.
pub const HDA_RESET_HANDSHAKE_NS: u64 = 100_000;
/// CORBCTL (HDA §3.3.22) bit 1 — CORB DMA engine run.
pub const CORBCTL_CORBRUN: u8 = 1 << 1;
/// CORBCTL bit 0 — CORB memory-error interrupt enable.
pub const CORBCTL_CMEIE: u8 = 1 << 0;
/// CORBRP (HDA §3.3.21) bit 15 — read-pointer reset. Software sets it, waits
/// for the controller to read it back set, clears it, and waits for it to read
/// back clear.
pub const CORBRP_RST: u16 = 1 << 15;
/// RIRBCTL (HDA §3.3.27) bit 1 — RIRB DMA engine run.
pub const RIRBCTL_RIRBDMAEN: u8 = 1 << 1;
/// RIRBCTL bit 0 — response interrupt enable.
pub const RIRBCTL_RINTCTL: u8 = 1 << 0;
/// RIRBCTL bit 2 — response overrun interrupt enable.
pub const RIRBCTL_RIRBOIC: u8 = 1 << 2;
/// RIRBWP (HDA §3.3.26) bit 15 — write-pointer reset. Write-only: it always
/// reads back 0, so there is no acknowledge handshake for this one.
pub const RIRBWP_RST: u16 = 1 << 15;
/// HDA controller state.
/// Uses fixed-capacity arrays to avoid heap allocation during audio playback.
/// Stream open/close modifies the array in-place without reallocation.
pub struct HdaController {
/// The controller's PCI function. `Arc`: `PciDevice` is always
/// `Arc`-managed (canonical definition:
/// [Section 11.4](11-drivers.md#device-registry-and-bus-management--pcidevice-canonical-pci-function-object)).
pub pci_dev: Arc<PciDevice>,
/// MMIO base address (from BAR0).
pub mmio: *mut HdaRegisters,
/// Codecs discovered on the HDA link.
pub codecs: ArrayVec<HdaCodec, MAX_HDA_CODECS>,
/// The CORB/RIRB command rings and the serialization that makes a verb
/// transaction atomic. See `HdaCmdRing`.
pub cmd: SpinLock<HdaCmdRing>,
/// Active PCM streams.
pub streams: ArrayVec<Arc<HdaPcmStream>, MAX_HDA_STREAMS>,
/// Index of the sound card this controller registered
/// (`/dev/snd/controlC<card_index>`); stamped on each `SndJack` so jack
/// reports notify the right card's control clients.
pub card_index: u32,
/// Jack-detection controls, one per presence-detect-enabled pin.
/// Populated at codec init by `enable_jack_detect()`
/// ([Section 21.4](#audio-architecture--jack-detection)); the hard-IRQ handler reads
/// immutable tag/codec identity, while control `ELEM_READ` reads the
/// interior-atomic `connected` flag.
pub jacks: ArrayVec<SndJack, MAX_HDA_JACKS>,
/// Bit N means unsolicited tag N needs a `GET_PIN_SENSE` read. The
/// hard-IRQ handler only ORs bits; the named jack-sense workqueue swaps and
/// drains them in process context. Tags are bounded by `MAX_HDA_JACKS = 32`,
/// so one naturally atomic u32 covers the discovered table without a
/// runtime limit beyond the controller's protocol-defined tag capacity.
pub jack_sense_pending: AtomicU32,
/// Re-armable, preallocated work handle for `hda_jack_sense_work`. Its data
/// pointer is initialized only after the owning `Arc<HdaController>` reaches
/// its final address. Controller teardown cancels it and flushes
/// `HDA_JACK_SENSE_WQ` before the last Arc can drop.
pub jack_sense_work: DelayedWork,
}
/// One RIRB (Response Inbound Ring Buffer) entry, written by the controller
/// via DMA (HDA spec §3.3.31). Hardware-facing DMA struct; native integers,
/// same convention as `HdaBdlEntry` above — the controller and the CPU are the
/// only parties and the ring never crosses a node or on-disk boundary.
#[repr(C)]
pub struct HdaRirbEntry {
/// The 32-bit codec response payload.
pub response: u32,
/// Extended response word: bit 4 = 1 marks an UNSOLICITED response
/// (a jack/presence event, [Section 21.4](#audio-architecture--jack-detection));
/// bits [3:0] carry the responding codec address.
pub response_ex: u32,
}
// HdaRirbEntry: u32(4) + u32(4) = 8 bytes, matching the HDA-defined RIRB
// entry stride.
const_assert!(core::mem::size_of::<HdaRirbEntry>() == 8);
/// `HdaRirbEntry.response_ex` bit marking an unsolicited response.
pub const RIRB_EX_UNSOLICITED: u32 = 1 << 4;
/// `HdaRirbEntry.response_ex` mask selecting the responding codec address.
pub const RIRB_EX_CODEC_MASK: u32 = 0x0F;
/// The controller's verb-transaction state: the two command-ring base pointers
/// plus everything one CORB→RIRB round trip mutates. Reached only through
/// `HdaController.cmd`, a driver-internal leaf `SpinLock` — the same shape as
/// the NVMe per-queue-pair lock ([Section 15.19](15-storage.md#nvme-driver-architecture)), and IRQ-safe
/// because `SpinLock::lock()` saves and disables interrupts.
///
/// **Why the lock exists.** `send_verb()` is a `&self` method reachable from
/// several contexts at once: two stream starts on one controller, a LINKed
/// multi-stream start, a mixer write racing an HDMI ELD read after a display
/// hot-plug. Advancing the CORB write pointer is a read-modify-write on
/// hardware state (read CORBWP → +1 mod entries → write the slot → publish
/// CORBWP), so two unserialized callers select the SAME slot: one command is
/// overwritten and lost, and CORBWP under-advances. The response side is
/// worse — a RIRB entry carries no caller identity, so an unserialized caller
/// can consume another caller's response and act on a value that answers a
/// different question. Slot claim, publish, and response consumption are
/// therefore ONE atomic transaction under this lock. The verb path is the sole
/// channel for codec probe, widget enumeration, jack enable, and stream-tag
/// programming, so this is not a rare race.
///
/// This is serialization, a different concern from the store-before-publish
/// ordering inside a single transaction — both are required.
// kernel-internal, not KABI.
pub struct HdaCmdRing {
/// CORB ring base: kernel VA of the DMA-coherent command ring allocated at
/// controller init, whose physical address was programmed into
/// CORBLBASE/CORBUBASE. Valid for the controller's lifetime.
pub corb_base: *mut u32,
/// RIRB ring base: kernel VA of the DMA-coherent response ring
/// (RIRBLBASE/RIRBUBASE). Valid for the controller's lifetime.
pub rirb_base: *mut HdaRirbEntry,
/// Driver-side shadow of the RIRB read pointer — the index one past the
/// last entry this driver consumed. The RIRB has no hardware read pointer
/// (unlike the CORB): the controller only advances RIRBWP, so the consumer
/// position must be tracked here.
pub rirb_rp: usize,
/// Set when a transaction was abandoned at its deadline and the rings have
/// not been resynchronized since. The next transaction runs
/// `resync_cmd_rings()` before it publishes anything; a resync that fails
/// its reset handshake leaves this set, so the retry happens on the call
/// after that rather than being silently forgotten.
///
/// A flag, NOT a count of owed responses. A count is only correct while
/// every abandoned command eventually answers: one that never answers
/// leaves the count permanently one too high, so the NEXT command's
/// legitimate response is discarded as stale, that command times out, and
/// the misalignment renews itself forever. Alignment is therefore
/// re-established by a barrier — the pre-publish drain in `send_verb` plus
/// the ring reset below — never by counting.
// `bool` is permitted: kernel-internal struct, not KABI (see the doc above).
pub resync_pending: bool,
/// Verb transactions abandoned at their deadline since controller init.
/// A lossless FMA statistic, never a control input.
///
/// `u64` per the counter-longevity rule
/// ([Section 1.3](01-overview.md#performance-budget--counter-and-identifier-longevity-budget)): a `u32` here would
/// wrap after 2^32 timeouts, which a codec that has entered a
/// timeout-per-transaction failure mode reaches in weeks, not decades —
/// well inside the operational lifetime.
pub verb_timeouts: u64,
}
/// HDA codec (represents one audio chip on the HDA link).
pub struct HdaCodec {
/// Codec address (0-14).
pub addr: u8,
/// Vendor ID (from root node).
pub vendor_id: u32,
/// Function groups discovered via GET_SUBORDINATE_NODE_COUNT on root node.
/// Bounded by HDA spec: max 1 Audio Function Group + 1 Modem Function Group per codec.
pub function_groups: ArrayVec<HdaFunctionGroup, 4>,
}
/// HDA function group (container for related widgets within a codec).
pub struct HdaFunctionGroup {
/// Node ID (NID) of this function group.
pub nid: u8,
/// Widgets within this function group.
/// Bounded by HDA spec: max 255 widgets per function group (NID range 8-bit).
pub widgets: ArrayVec<HdaWidget, 256>,
}
/// HDA widget (node in codec's audio routing graph).
pub struct HdaWidget {
/// Node ID (NID).
pub nid: u8,
/// Widget type (output, input, mixer, selector, pin, etc.).
/// Decoded from bits [23:20] of the Audio Widget Capabilities parameter
/// returned by the GET_PARAMETER verb (parameter ID 0x09).
pub widget_type: HdaWidgetType,
/// Capabilities (from GET_PARAMETER verb).
pub capabilities: u32,
}
/// HDA widget type.
#[repr(u8)]
pub enum HdaWidgetType {
/// Audio output (DAC - Digital-to-Analog Converter).
AudioOut = 0,
/// Audio input (ADC - Analog-to-Digital Converter).
AudioIn = 1,
/// Mixer (combines multiple inputs).
Mixer = 2,
/// Selector (mux: selects one of multiple inputs).
Selector = 3,
/// Pin (physical connector: headphone jack, speaker, mic).
Pin = 4,
/// Power widget.
Power = 5,
/// Volume knob.
VolumeKnob = 6,
/// Vendor-specific.
VendorDefined = 15,
}
impl HdaWidgetType {
/// Decode widget type from the Audio Widget Capabilities parameter (bits [23:20]).
/// Per HDA spec section 7.3.4.6: bits [23:20] encode the widget type.
pub fn from_caps(caps: u32) -> Self {
match (caps >> 20) & 0xF {
0 => Self::AudioOut,
1 => Self::AudioIn,
2 => Self::Mixer,
3 => Self::Selector,
4 => Self::Pin,
5 => Self::Power,
6 => Self::VolumeKnob,
15 => Self::VendorDefined,
_ => Self::VendorDefined, // Unknown types treated as vendor-defined
}
}
}
impl HdaController {
/// Send a verb (command) to a codec. Returns the response.
/// HDA verbs use CORB (Command Outbound Ring Buffer) and RIRB (Response Inbound Ring Buffer).
/// **Serialization**: the whole transaction runs under `self.cmd`, so a
/// concurrent caller cannot claim the same CORB slot or consume this
/// caller's RIRB response. See `HdaCmdRing` for why both halves need it.
pub fn send_verb(&self, codec_addr: u8, nid: u8, verb: u32) -> Result<u32, HdaError> {
if codec_addr as usize >= MAX_HDA_CODECS {
return Err(HdaError::InvalidCodec);
}
// Write to CORB: codec_addr | nid | verb.
// Wait for RIRB: response appears in ring buffer, signaled by interrupt or polling.
// Encode verb: bits [31:28] = codec_addr, [27:20] = nid, [19:0] = verb payload.
let command = ((codec_addr as u32) << 28) | ((nid as u32) << 20) | (verb & 0xF_FFFF);
// ONE lock for the whole round trip: claim, publish, consume.
let mut cmd = self.cmd.lock();
// Recovery barrier, BEFORE anything is published. A previous
// transaction that hit its deadline left the rings in an undefined
// state — its command may still be pending at the codec, and the CORB
// slot it claimed may still be unfetched — so the rings are reset back
// to a known-empty origin before this command is written.
if cmd.resync_pending {
self.resync_cmd_rings(&mut *cmd)?;
}
// Drain barrier: consume every entry the RIRB already holds. In steady
// state there are none — the previous transaction consumed its own
// answer. Anything present is an unsolicited event (dispatched) or a
// late answer to an abandoned command (discarded). Draining here, and
// only then publishing, is what makes "the next solicited entry after
// my publish is MINE" true by construction rather than by counting
// owed responses, which cannot survive a command that never answers.
self.rirb_drain(&mut *cmd);
// HDA fetches `CORBRP+1 ..= CORBWP`, so the command word MUST be resident
// in the target slot BEFORE CORBWP is advanced. Order: compute the next
// slot, write the command (volatile), write barrier, THEN publish CORBWP.
let wp = self.corb_next_wp();
// SAFETY: `cmd.corb_base` is the controller's CORB ring (DMA-coherent
// memory) valid for the controller's lifetime; `wp < corb_entries()`.
unsafe { cmd.corb_base.add(wp).write_volatile(command) };
// Ensure the command store is visible to the controller before it can
// observe the advanced write pointer — same discipline as the NVMe SQ
// doorbell ([Section 15.19](15-storage.md#nvme-driver-architecture)), the DMA-visible
// store-store ordering of [Section 4.14](04-memory.md#dma-subsystem).
core::sync::atomic::fence(core::sync::atomic::Ordering::Release);
self.corb_publish_wp(wp);
// Poll RIRB until THIS caller's response arrives. `&mut *cmd`
// reborrows through the guard — the lock stays held across the poll,
// which is what makes "the next solicited entry is mine" true.
match self.rirb_poll_response(&mut *cmd, HDA_VERB_TIMEOUT_NS) {
Ok(response) => Ok(response),
Err(e) => {
cmd.verb_timeouts += 1;
// The rings are now out of step with the codec: this command
// was published and never answered. Arm the barrier FIRST, so
// a resync that cannot complete is retried by the next
// transaction instead of being lost, then attempt the reset
// immediately so a controller that is still healthy is usable
// again on the very next call.
cmd.resync_pending = true;
let _ = self.resync_cmd_rings(&mut *cmd);
Err(e)
}
}
}
/// Consume RIRB entries until this transaction's solicited response
/// arrives, or `timeout` expires. Caller holds `self.cmd`, which is what
/// makes "the next solicited entry is MINE" true.
///
/// Two entry classes are distinguished, and conflating them is what
/// produces responses answering the wrong question:
///
/// - **Unsolicited** (`response_ex & RIRB_EX_UNSOLICITED`): a jack event
/// that the codec injected into the same ring. Routed to
/// `handle_unsolicited_response()` and NOT counted as this caller's
/// answer.
/// - **This caller's response**: the first solicited entry after this
/// caller's publish. Entries left over from an abandoned command cannot
/// appear here — `send_verb` drained the ring (`rirb_drain`) before
/// publishing, and resynchronized it if the previous transaction timed
/// out.
///
/// On timeout the command is abandoned but may still be outstanding at the
/// codec. This function only reports the timeout; `send_verb` owns the
/// recovery, because leaving the rings aligned is a property of the
/// TRANSACTION, not of the poll.
fn rirb_poll_response(&self, cmd: &mut HdaCmdRing, timeout_ns: u64)
-> Result<u32, HdaError>
{
let entries = self.rirb_entries();
// Busy-poll deadline: `self.cmd` is held with interrupts disabled, so
// this must not sleep. `read_cycle_counter_ns()` is the bounded-busy-wait
// clock ([Section 7.8](07-scheduling.md#timekeeping-and-clock-management--cycle-nanosecond-conversion)).
let deadline = arch::current::cpu::read_cycle_counter_ns() + timeout_ns;
loop {
// SAFETY: `self.mmio` is the controller's BAR0 region, valid for
// the controller's lifetime; volatile per the `HdaRegisters` doc.
let wp = unsafe {
core::ptr::addr_of!((*self.mmio).rirbwp).read_volatile()
} as usize % entries;
while cmd.rirb_rp != wp {
let idx = (cmd.rirb_rp + 1) % entries;
// Order the entry read after the RIRBWP read that advertised
// it, so the payload is not fetched from before the DMA write
// — the DMA-visible load-load ordering of [Section 4.14](04-memory.md#dma-subsystem).
core::sync::atomic::fence(core::sync::atomic::Ordering::Acquire);
// SAFETY: `rirb_base` is the controller's DMA-coherent RIRB,
// valid for the controller's lifetime; `idx < rirb_entries()`.
let entry = unsafe { cmd.rirb_base.add(idx).read_volatile() };
cmd.rirb_rp = idx;
if (entry.response_ex & RIRB_EX_UNSOLICITED) != 0 {
let codec = (entry.response_ex & RIRB_EX_CODEC_MASK) as u8;
self.handle_unsolicited_response(codec, entry.response);
continue;
}
return Ok(entry.response);
}
if arch::current::cpu::read_cycle_counter_ns() >= deadline {
return Err(HdaError::ResponseTimeout);
}
core::hint::spin_loop();
}
}
/// Consume every RIRB entry the controller has already written, dispatching
/// unsolicited events and discarding solicited leftovers. Caller holds
/// `self.cmd`.
///
/// Run by `send_verb` immediately before it publishes, so the ring is empty
/// at the moment the command becomes visible to the controller. This is the
/// drain half of the recovery barrier: a solicited entry seen here answers
/// a command no caller is waiting for any more (the abandoned command of a
/// previous timeout), and discarding it is unconditionally correct —
/// nothing in the driver can act on it. Unsolicited entries are NOT
/// discarded: a jack event is a real state change and is delivered through
/// `handle_unsolicited_response()` exactly as it would be on the interrupt
/// path.
///
/// Cost on the steady-state path is one MMIO read of RIRBWP and a compare.
fn rirb_drain(&self, cmd: &mut HdaCmdRing) {
let entries = self.rirb_entries();
// SAFETY: `self.mmio` is the controller's BAR0 region, valid for the
// controller's lifetime; volatile per the `HdaRegisters` doc.
let wp = unsafe {
core::ptr::addr_of!((*self.mmio).rirbwp).read_volatile()
} as usize % entries;
while cmd.rirb_rp != wp {
let idx = (cmd.rirb_rp + 1) % entries;
// As in `rirb_poll_response`: order the payload read after the
// RIRBWP read that advertised it.
core::sync::atomic::fence(core::sync::atomic::Ordering::Acquire);
// SAFETY: `rirb_base` is the controller's DMA-coherent RIRB, valid
// for the controller's lifetime; `idx < rirb_entries()`.
let entry = unsafe { cmd.rirb_base.add(idx).read_volatile() };
cmd.rirb_rp = idx;
if (entry.response_ex & RIRB_EX_UNSOLICITED) != 0 {
let codec = (entry.response_ex & RIRB_EX_CODEC_MASK) as u8;
self.handle_unsolicited_response(codec, entry.response);
}
// Solicited: an answer to an abandoned command. Dropped.
}
}
/// Reset the CORB/RIRB pair back to a known-empty origin after a
/// transaction was abandoned at its deadline. Caller holds `self.cmd`.
///
/// **Why a reset and not a counter.** The abandoned command may still be
/// unfetched in the CORB (the controller consumes `CORBRP+1 ..= CORBWP` at
/// its own pace) and may still be pending at the codec. Leaving it in place
/// means a command the driver has already reported as failed can be issued
/// to the hardware later and produce an answer no caller expects. Stopping
/// both DMA engines retires the command ring; resetting the pointers puts
/// producer, consumer, and hardware back at the same origin, so the state
/// after recovery is a definite one rather than a running correction.
///
/// Bit definitions are the HDA-specified fields of CORBCTL (§3.3.22),
/// CORBRP (§3.3.21), RIRBCTL (§3.3.27), and RIRBWP (§3.3.26).
///
/// Runs only on the timeout path, which has already spent
/// `HDA_VERB_TIMEOUT_NS` under this lock; the reset handshakes below are
/// register round trips bounded by `HDA_RESET_HANDSHAKE_NS`, so recovery
/// does not widen the interrupts-disabled window by another order of
/// magnitude. A handshake that does not settle is a dead controller:
/// `ControllerError` is returned with `resync_pending` still set, so no
/// later transaction publishes into rings that were never re-established.
fn resync_cmd_rings(&self, cmd: &mut HdaCmdRing) -> Result<(), HdaError> {
// SAFETY (every access below): `self.mmio` is the controller's BAR0
// region, valid for the controller's lifetime; volatile per the
// `HdaRegisters` doc.
unsafe {
// 1. Stop both engines. With RIRBDMAEN clear the controller writes
// no further responses, so an answer arriving from the codec
// during the reset is dropped by hardware instead of landing in
// the ring the next transaction is about to trust.
core::ptr::addr_of_mut!((*self.mmio).corbctl).write_volatile(0);
core::ptr::addr_of_mut!((*self.mmio).rirbctl).write_volatile(0);
// 2. CORB read-pointer reset handshake: set CORBRP_RST, wait for
// the controller to acknowledge by reading it back set, clear
// it, wait for it to read back clear.
core::ptr::addr_of_mut!((*self.mmio).corbrp).write_volatile(CORBRP_RST);
self.await_hw(|| self.corbrp_reset_bit_set())?;
core::ptr::addr_of_mut!((*self.mmio).corbrp).write_volatile(0);
self.await_hw(|| !self.corbrp_reset_bit_set())?;
// Producer side back to the origin the controller now reads.
core::ptr::addr_of_mut!((*self.mmio).corbwp).write_volatile(0);
// 3. RIRB write-pointer reset (write-only bit, no handshake) and
// the driver-side consumer position that shadows it.
core::ptr::addr_of_mut!((*self.mmio).rirbwp).write_volatile(RIRBWP_RST);
cmd.rirb_rp = 0;
// 4. Restart both engines with the control bits controller init
// programmed.
core::ptr::addr_of_mut!((*self.mmio).corbctl)
.write_volatile(CORBCTL_CORBRUN | CORBCTL_CMEIE);
core::ptr::addr_of_mut!((*self.mmio).rirbctl)
.write_volatile(RIRBCTL_RIRBDMAEN | RIRBCTL_RINTCTL | RIRBCTL_RIRBOIC);
}
cmd.resync_pending = false;
Ok(())
}
/// Current state of the CORBRP reset bit. Its own method so the reset
/// handshake reads the register volatilely on every poll iteration, as the
/// `HdaRegisters` doc mandates — a predicate over a plain `&HdaRegisters`
/// would let the compiler hoist the load out of the loop and spin forever.
fn corbrp_reset_bit_set(&self) -> bool {
// SAFETY: `self.mmio` is the controller's BAR0 region, valid for the
// controller's lifetime; volatile read per the `HdaRegisters` doc.
let rp = unsafe {
core::ptr::addr_of!((*self.mmio).corbrp).read_volatile()
};
(rp & CORBRP_RST) != 0
}
/// Poll a hardware predicate until it holds or `HDA_RESET_HANDSHAKE_NS`
/// elapses. Busy-wait: the caller holds `self.cmd` with interrupts
/// disabled and must not sleep.
fn await_hw(&self, ready: impl Fn() -> bool) -> Result<(), HdaError> {
let deadline =
arch::current::cpu::read_cycle_counter_ns() + HDA_RESET_HANDSHAKE_NS;
loop {
if ready() {
return Ok(());
}
if arch::current::cpu::read_cycle_counter_ns() >= deadline {
return Err(HdaError::ControllerError);
}
core::hint::spin_loop();
}
}
/// Number of RIRB ring entries, decoded from the RIRBSIZE register
/// (HDA §3.3.29): bits [1:0] select 2 / 16 / 256 entries, mirroring
/// CORBSIZE. Controllers program 256 near-universally.
fn rirb_entries(&self) -> usize {
// SAFETY: as `corb_entries` — a volatile read of the controller's MMIO.
let rirbsize = unsafe {
core::ptr::addr_of!((*self.mmio).rirbsize).read_volatile()
};
match rirbsize & 0x3 {
0 => 2,
1 => 16,
_ => 256, // encoding 2 = 256 entries
}
}
/// Compute the next CORB (Command Outbound Ring Buffer) write-pointer slot
/// WITHOUT publishing it, returning the slot index the caller writes its
/// command word into (`cmd.corb_base.add(wp)`). Callers hold
/// `HdaController.cmd`, without which this read-modify-write of CORBWP
/// races a concurrent `send_verb` onto the same slot. The CORB is a hardware ring of
/// `corb_entries()` slots (CORBSIZE register, HDA §3.3.24); the write pointer
/// wraps modulo that size. The controller fetches `CORBRP+1 ..= CORBWP`, so
/// the caller MUST make the command word resident and issue a write barrier
/// BEFORE calling `corb_publish_wp` (see `send_verb`).
fn corb_next_wp(&self) -> usize {
let entries = self.corb_entries();
// SAFETY: `self.mmio` is the controller's BAR0 MMIO region, valid for the
// controller's lifetime; a volatile read matches the register's MMIO
// semantics (the `HdaRegisters` doc mandates volatile access).
let cur = unsafe { core::ptr::addr_of!((*self.mmio).corbwp).read_volatile() } as usize;
(cur + 1) % entries
}
/// Publish the advanced CORB write pointer to the CORBWP MMIO register with a
/// volatile store. **Precondition**: the command word is already resident in
/// `cmd.corb_base[wp]` and a write barrier has ordered that store ahead of
/// this publish (`send_verb` issues the `dma_wmb()`). This split — compute
/// in `corb_next_wp`, publish here — is what closes the publish-before-write
/// hardware race. Callers hold `HdaController.cmd`.
fn corb_publish_wp(&self, wp: usize) {
// SAFETY: as `corb_next_wp` — a volatile store of the advanced write
// pointer into the controller's MMIO CORBWP register.
unsafe { core::ptr::addr_of_mut!((*self.mmio).corbwp).write_volatile(wp as u16) };
}
/// Number of CORB ring entries, decoded from the CORBSIZE register
/// (HDA §3.3.24): bits [1:0] select 2 / 16 / 256 entries (bits [7:4] are the
/// read-only size-capability field). Controllers program 256 near-universally.
fn corb_entries(&self) -> usize {
// SAFETY: `self.mmio` is the controller's MMIO region (see corb_next_wp);
// a volatile read is mandatory so the access is never elided or reordered.
let corbsize = unsafe { core::ptr::addr_of!((*self.mmio).corbsize).read_volatile() };
match corbsize & 0x3 {
0 => 2,
1 => 16,
_ => 256, // encoding 2 = 256 entries
}
}
/// Probe codecs on the HDA link.
pub fn probe_codecs(&mut self) -> Result<(), HdaError> {
// Read STATESTS register to discover codec addresses (bit set = codec present).
// Volatile read (MMIO): the register reflects hardware state and must not
// be elided or reordered.
let statests = unsafe { core::ptr::addr_of!((*self.mmio).statests).read_volatile() };
for addr in 0..15 {
if (statests & (1 << addr)) != 0 {
// Codec present: read vendor ID, build widget tree.
let vendor_id = self.send_verb(addr, 0, VERB_GET_VENDOR_ID)?;
let codec = self.build_codec(addr, vendor_id)?;
self.codecs.push(codec);
}
}
Ok(())
}
/// Build widget tree for a codec (enumerate all nodes, parse capabilities).
///
/// **Codec responses are untrusted input.** `fg_start`/`fg_count` and
/// `w_start`/`w_count` are decoded from what the codec chose to answer, and
/// a quirky or hostile codec is free to report anything the field can hold.
/// Two consequences the enumeration must survive, on the probe path where a
/// panic takes the driver down before the machine has any audio at all:
///
/// - **Range arithmetic.** `start + count` is computed in `u16`, not `u8`.
/// Both fields are 8-bit, so their sum reaches 510 — as `u8` that wraps
/// in release builds (producing an empty or wildly wrong range) and
/// panics in debug builds. The end is additionally clamped to the 8-bit
/// NID space, since a NID above 255 cannot be addressed by a verb.
/// - **Capacity.** `function_groups` is an `ArrayVec<_, 4>` and `widgets`
/// an `ArrayVec<_, 256>`; a codec reporting `fg_count = 255` would
/// overflow the former. Insertion uses `try_push` and stops at capacity
/// rather than panicking, and the count is clamped up front so the
/// surplus verbs are never issued. Truncation is the correct outcome: a
/// codec claiming more function groups than the HDA spec permits is
/// misreporting, and the groups within the bound still work.
fn build_codec(&self, addr: u8, vendor_id: u32) -> Result<HdaCodec, HdaError> {
// Send GET_SUBORDINATE_NODE_COUNT to root (NID 0) to discover function groups.
// Send GET_SUBORDINATE_NODE_COUNT to each function group to discover widgets.
// For each widget, send GET_PARAMETER to read capabilities.
// Root node (NID 0): get subordinate node count to discover function groups.
let sub = self.send_verb(addr, 0, VERB_GET_SUBORDINATE_NODE_COUNT)?;
let fg_start = (sub >> 16) as u8;
let fg_count = (sub & 0xFF) as u8;
let mut codec = HdaCodec { addr, vendor_id, function_groups: ArrayVec::new() };
// Clamp to the ArrayVec capacity BEFORE issuing verbs, and compute the
// range end in u16 so the addition cannot wrap.
let fg_count = (fg_count as u16).min(codec.function_groups.capacity() as u16);
let fg_end = (fg_start as u16 + fg_count).min(u8::MAX as u16 + 1);
for fg_nid in (fg_start as u16..fg_end).map(|n| n as u8) {
// Each function group: enumerate child widgets.
let fg_sub = self.send_verb(addr, fg_nid, VERB_GET_SUBORDINATE_NODE_COUNT)?;
let w_start = (fg_sub >> 16) as u8;
let w_count = (fg_sub & 0xFF) as u8;
let mut widgets: ArrayVec<HdaWidget, 256> = ArrayVec::new();
let w_end = (w_start as u16 + w_count as u16).min(u8::MAX as u16 + 1);
for w_nid in (w_start as u16..w_end).map(|n| n as u8) {
let caps = self.send_verb(addr, w_nid, verb_get_parameter(PARAM_AUDIO_WIDGET_CAP))?;
let wtype = HdaWidgetType::from_caps(caps);
// Capacity is 256 and the range is bounded by the 8-bit NID
// space, so this cannot fail; `try_push` keeps the enumeration
// panic-free regardless of what the codec reports.
if widgets.try_push(
HdaWidget { nid: w_nid, widget_type: wtype, capabilities: caps }
).is_err() {
break;
}
}
if codec.function_groups
.try_push(HdaFunctionGroup { nid: fg_nid, widgets })
.is_err()
{
break;
}
}
Ok(codec)
}
}
DMA buffer descriptor list (BDLIST): HDA uses a scatter-gather DMA model. Each PCM stream has a BDLIST (Buffer Descriptor List) in host memory, containing entries like:
/// HDA Buffer Descriptor List Entry (BDL entry).
#[repr(C)]
pub struct HdaBdlEntry {
/// Physical address of buffer segment.
pub addr: u64,
/// Length of buffer segment in bytes.
pub length: u32,
/// IOC (Interrupt On Completion) flag. Bit 0 only; upper 31 bits reserved per HDA
/// spec §4.4.3 and must be written as zero. Set bit 0 to 1 to generate an interrupt
/// when this segment completes; set to 0 for no interrupt on this entry.
pub ioc: u32,
}
// HdaBdlEntry: u64(8) + u32(4) + u32(4) = 16 bytes.
// Hardware-facing struct — HDA controller reads BDL entries via DMA.
const_assert!(core::mem::size_of::<HdaBdlEntry>() == 16);
The HDA controller DMA engine walks the BDLIST, fetching audio data from the buffers, and generates an interrupt when ioc=1 entries complete (every period).
HDA PCM stream state: Each active PCM stream on an HDA controller is represented
by HdaPcmStream, which binds a generic PcmStream (Section 21.4)
to HDA-specific hardware state:
/// HDA PCM stream — binds a generic PcmStream to HDA controller hardware.
/// One instance per active playback or capture stream on the HDA controller.
/// Referenced by `HdaController.streams` (max `MAX_HDA_STREAMS` = 16 per controller).
pub struct HdaPcmStream {
/// Parent PCM stream (generic ALSA state: params, DMA buffer, hw_ptr/appl_ptr).
pub pcm: Arc<PcmStream>,
/// HDA stream descriptor index (0-based, max 30 per controller).
/// The HDA spec allocates stream descriptors in MMIO space at offset
/// 0x80 + (stream_idx * 0x20). Typical controllers expose 4-16 descriptors.
pub stream_idx: u8,
/// HDA stream tag (1-15, assigned by the controller at stream open time).
/// The tag is written into the codec's converter widget via the
/// SET_CHANNEL_STREAMID verb and into the stream descriptor's CTL register.
/// Tag 0 is reserved (means "stream not running" per HDA spec §3.3.35).
pub stream_tag: u8,
/// Buffer Descriptor List (BDL): scatter-gather DMA entries.
/// Pre-allocated coherent DMA buffer of 32 entries (matching Linux
/// controller limit). Each entry points
/// to a page-aligned segment of the PCM DMA buffer. The hardware reads
/// BDL entries sequentially, wrapping at `bdl_count`.
pub bdl: DmaCoherentBuf<[HdaBdlEntry; 32]>,
/// Number of active BDL entries (1..=32). Set during hw_params based on
/// buffer size and page alignment.
pub bdl_count: u8,
/// Codec DAC/ADC widget node ID in the HDA codec graph.
/// Identified during codec probe by walking the widget tree from pin
/// widgets back to converter widgets.
pub codec_node: HdaNodeId,
/// Codec address (0-14) on the HDA link. Combined with `codec_node`
/// to address verbs for this stream's converter widget.
pub codec_addr: u8,
/// Link Position In Buffer register offset (MMIO, per-stream).
/// Read by the interrupt handler to update `pcm.hw_ptr`. Located at
/// stream descriptor base + 0x04 (LPIB register, HDA spec §3.3.37).
pub lpib_offset: usize,
/// DMA channel assignment (controller-internal; maps to stream descriptor).
pub dma_channel: u8,
}
/// HDA codec node identifier.
// kernel-internal, not KABI — internal codec addressing.
#[repr(C)]
pub struct HdaNodeId {
/// Node ID (NID) within the codec (0-127 per HDA spec).
pub nid: u8,
}
The BDL entries point to segments of the PCM DMA buffer allocated in
Section 21.4. At SNDRV_PCM_IOCTL_PREPARE
time, the driver populates the BDL: each entry's addr field is set to the physical
address of a page-aligned buffer segment, length to the segment size (typically
one page = 4096 bytes), and ioc bit 0 is set on period boundary entries to generate
interrupts. The BDL physical address is written to the stream descriptor's BDLPL/BDLPU
registers (lower/upper 32 bits), and the BDL entry count to the LVI (Last Valid Index)
register. On SNDRV_PCM_IOCTL_START, the driver sets the RUN bit in the stream
descriptor's CTL register, and the hardware begins DMA.
21.4.5 USB Audio Class 2.0 Driver Model¶
USB Audio Class (UAC) 2.0 devices are the dominant class of bus-powered and professional USB audio interfaces (studio DACs, microphones, multichannel interfaces). The UAC 2.0 spec (USB Device Class Definition for Audio Devices, Release 2.0) defines isochronous endpoints for PCM streaming and control requests for sample rate, volume, and mute.
Tier assignment: USB Audio drivers run as Tier 1 by default (same as HDA). USB Audio is crash-prone due to the complexity of device-specific quirks and the asynchronous nature of USB transfers. Crash recovery follows the standard driver restart mechanism (Section 11.9), with the added cost of a USB port reset (10–300ms depending on USB version; see Section 21.4).
// umka-usb-audio-driver/src/lib.rs (Tier 1 driver)
/// Maximum isochronous endpoints per USB Audio interface (UAC 2.0 §4.9).
/// Most devices expose 1 playback + 1 capture endpoint; multichannel
/// interfaces may expose up to 4 (e.g., 2 stereo pairs or 1 multichannel).
pub const MAX_UAC_ENDPOINTS: usize = 8;
/// Maximum alternate settings per streaming interface (USB spec §9.6.5).
/// Each altsetting represents a different sample format/rate/channel count.
pub const MAX_UAC_ALTSETTINGS: usize = 16;
/// USB Audio Class 2.0 controller state.
pub struct UacDevice {
/// USB device handle (from USB core driver framework).
pub usb_dev: UsbDeviceHandle,
/// Audio Control (AC) interface number (bInterfaceNumber from
/// the AC Interface Header Descriptor, UAC 2.0 §4.7.2).
pub ac_interface: u8,
/// Audio Streaming (AS) interfaces discovered during probe.
/// Each AS interface corresponds to one playback or capture endpoint.
pub as_interfaces: ArrayVec<UacStreamInterface, MAX_UAC_ENDPOINTS>,
/// Clock source entity ID (from Clock Source Descriptor, UAC 2.0 §4.7.2.1).
/// Used for sample rate control via SET_CUR/GET_CUR on the Clock Frequency
/// control (CS = 0x01, CN = 0x01).
pub clock_source_id: u8,
/// Device supports asynchronous mode (adaptive/async feedback endpoint).
/// Async mode devices provide a feedback endpoint (UAC 2.0 §3.16.2.2)
/// that reports the actual sample rate to the host for clock drift correction.
pub async_mode: bool,
/// Feedback endpoint address (valid only if `async_mode == true`).
/// The host reads 10.14 or 16.16 fixed-point feedback values from this
/// endpoint to adjust the number of samples per microframe.
pub feedback_ep: Option<u8>,
/// Device quirks (vendor-specific workarounds).
pub quirks: UacQuirks,
}
/// Per-streaming-interface state (one per playback or capture endpoint).
pub struct UacStreamInterface {
/// USB interface number (bInterfaceNumber).
pub interface_num: u8,
/// Direction: playback (OUT endpoint) or capture (IN endpoint).
pub direction: PcmDirection,
/// Endpoint address (bEndpointAddress from the AS Isochronous
/// Audio Data Endpoint Descriptor, UAC 2.0 §4.10.1.2).
pub endpoint_addr: u8,
/// Alternate settings (each defines a format/rate/channel combination).
/// Altsetting 0 is always zero-bandwidth (no active streaming).
pub altsettings: ArrayVec<UacAltsetting, MAX_UAC_ALTSETTINGS>,
/// Currently selected alternate setting index (0 = idle).
pub current_altsetting: u8,
}
/// One alternate setting for a USB Audio streaming interface.
pub struct UacAltsetting {
/// Alternate setting number (bAlternateSetting).
pub altsetting_num: u8,
/// Sample format (from Format Type I Descriptor, UAC 2.0 §4.9.2).
pub format: PcmFormat,
/// Number of channels (bNrChannels).
pub channels: u8,
/// Supported sample rates. UAC 2.0 devices report rates via the
/// Clock Source's frequency control (GET_RANGE request returns a
/// list of discrete rates or a continuous range).
pub sample_rates: ArrayVec<u32, 16>,
/// Maximum packet size in bytes (wMaxPacketSize from endpoint descriptor).
/// Determines the URB buffer size for isochronous transfers.
pub max_packet_size: u16,
/// Packets per microframe (1, 2, or 3 for high-speed; encoded in
/// bits [12:11] of wMaxPacketSize). Determines bandwidth reservation.
pub packets_per_microframe: u8,
}
bitflags! {
/// Device-specific quirks for USB Audio devices that deviate from the
/// UAC 2.0 spec. Discovered at probe time via USB VID/PID lookup table.
pub struct UacQuirks: u32 {
/// Device reports incorrect clock frequency (apply host-side rate detection).
const BROKEN_CLOCK = 0x0001;
/// Device requires SET_INTERFACE before SET_CUR for sample rate.
const RATE_BEFORE_FORMAT = 0x0002;
/// Device stalls on GET_RANGE for clock frequency (use fixed rate list).
const NO_CLOCK_RANGE = 0x0004;
/// Feedback endpoint returns values in 10.14 format (USB 2.0 Full Speed)
/// even on High Speed where 16.16 is expected.
const FEEDBACK_10_14 = 0x0008;
/// Device requires explicit clock source selection via
/// Clock Selector SET_CUR before streaming starts.
const EXPLICIT_CLOCK_SEL = 0x0010;
}
}
Isochronous URB submission: USB Audio streaming uses isochronous USB transfers (guaranteed bandwidth, no retransmission). The driver submits URBs (USB Request Blocks) in a double-buffering pattern: while one URB is being consumed by the hardware, the next is being filled by the host. For playback, the host writes PCM samples into URB buffers; for capture, the host reads samples from completed URBs.
uac_start_streaming(dev: &UacDevice, iface: &UacStreamInterface, params: &PcmParams):
1. Select alternate setting matching params (format, rate, channels).
Select USB interface iface.interface_num alternate setting altsetting_num on dev.usb_dev.
2. Set sample rate on clock source:
Issue UAC2 SET_CUR Clock Frequency Control to clock_source_id with rate.
3. Allocate URB ring (double-buffer: 2 URBs for low-latency, up to 4 for robustness).
Each URB buffer = max_packet_size * packets_per_microframe bytes.
URB buffers allocated via umka_driver_dma_alloc (coherent DMA for USB HCI).
4. Submit initial URBs to USB HCI (host controller interface).
5. On URB completion interrupt:
- Playback: copy next period's samples from PcmStream.dma_buffer to new URB,
resubmit URB. Update hw_ptr by the number of frames transferred.
- Capture: copy received samples from completed URB to PcmStream.dma_buffer,
resubmit URB. Update hw_ptr.
- If async_mode: read feedback endpoint, adjust samples-per-packet to match
device clock (prevents drift-induced xruns on long playback sessions).
6. Wake PipeWire/ALSA waiter via futex on hw_ptr update.
Not a zero-copy leg: step 5 copies a period between PcmStream.dma_buffer
and the URB buffer in each direction, every period. This is inherent to
isochronous USB — the URB payload lives in HCI-owned memory that the audio
application's mapping cannot alias — and it is why the zero-copy statement in
Section 21.4 is scoped to directly-mappable
DMA rings rather than claimed system-wide. The copy is one period at a time
(bounded, ≤ 1 MiB per the buffer cap) and runs in the URB completion handler,
so it obeys the IRQ completion contract: no sleeping, no allocation.
Clock drift correction (async mode): USB Audio async devices have their own crystal oscillator. The host and device clocks drift relative to each other (~50-200 ppm). Without correction, this causes periodic xruns every few minutes. The feedback endpoint reports the device's actual consumption rate as a fixed-point value. The driver adjusts the number of samples per USB microframe (125μs at high speed) to track the device clock: if the device is consuming faster, the driver sends one extra sample per N microframes; if slower, it skips one. This adjustment is invisible to userspace — PipeWire sees a steady hw_ptr advance.
21.4.6 HDMI/DP Audio Endpoint Model¶
HDMI and DisplayPort carry audio alongside video. On most x86 systems, HDMI/DP audio appears as a secondary codec on the Intel HDA link (the GPU's HDA controller, separate from the PCH's analog audio HDA controller). On systems with discrete GPUs (NVIDIA, AMD), the GPU exposes its own HDA controller on the PCI bus.
Architecture: HDMI/DP audio is not a separate driver — it is a specialization of the HDA driver model (Section 21.4). The HDA codec probe discovers HDMI/DP pin widgets (widget type = Pin, pin config indicates digital output with HDMI/DP connection type). Each HDMI/DP pin maps to one audio endpoint on a physical connector.
/// HDMI/DP audio endpoint state (extension of HdaPcmStream for digital outputs).
/// One instance per HDMI/DP connector that has an active audio stream.
pub struct HdmiDpAudioEndpoint {
/// Parent HDA PCM stream (reuses HDA DMA infrastructure).
pub hda_stream: Arc<HdaPcmStream>,
/// Pin widget NID for this HDMI/DP output.
pub pin_nid: u8,
/// Codec address on the HDA link.
pub codec_addr: u8,
/// ELD (EDID-Like Data): audio capabilities reported by the connected
/// display/AV receiver. Parsed from the ELD buffer obtained via the
/// GET_HDMI_ELD verb (vendor-specific) or HDA spec standard ELD retrieval.
/// Contains: supported audio formats, sample rates, channel counts,
/// speaker allocation, display name.
pub eld: HdmiEld,
/// Current audio infoframe (HDMI Audio InfoFrame or DP Secondary Data Packet).
/// Sent to the sink to describe the active audio stream format.
/// Updated on stream start and format change.
pub audio_infoframe: AudioInfoFrame,
/// Connection state (hot-plug detect status).
pub connected: AtomicBool,
}
/// ELD (EDID-Like Data) parsed from the connected HDMI/DP sink.
/// Contains the audio capabilities negotiated during display hot-plug.
pub struct HdmiEld {
/// ELD version (typically 0x02 for CEA-861-D and later).
pub eld_ver: u8,
/// Monitor name (from EDID, up to 16 bytes, null-terminated).
pub monitor_name: [u8; 16],
/// Number of Short Audio Descriptors (SADs) from the sink's EDID.
/// Each SAD describes one supported audio format (codec, channels, rates).
pub sad_count: u8,
/// Short Audio Descriptors. Maximum 15 per CEA-861 spec.
pub sads: ArrayVec<ShortAudioDescriptor, 15>,
/// Speaker Allocation Data Block (from EDID). Bitfield indicating which
/// speaker positions the sink supports (FL/FR, C, LFE, RL/RR, etc.).
/// Used to configure channel mapping in the Audio InfoFrame.
pub speaker_alloc: u8,
}
/// CEA-861 Short Audio Descriptor (3 bytes, parsed from EDID).
pub struct ShortAudioDescriptor {
/// Audio format code (1 = LPCM, 2 = AC-3, 7 = DTS, 11 = DTS-HD, etc.).
/// See CEA-861 Table 37.
pub format_code: u8,
/// Maximum number of channels minus 1 (0 = mono, 1 = stereo, 7 = 8ch).
pub max_channels: u8,
/// Supported sample rates (bitfield: bit 0 = 32kHz, 1 = 44.1kHz,
/// 2 = 48kHz, 3 = 88.2kHz, 4 = 96kHz, 5 = 176.4kHz, 6 = 192kHz).
pub sample_rates: u8,
/// For LPCM: supported bit depths (bit 0 = 16-bit, 1 = 20-bit, 2 = 24-bit).
/// For compressed formats: maximum bitrate / 8 kbit/s.
pub format_specific: u8,
}
/// HDMI Audio InfoFrame (CEA-861 §6.6.1) or DP Secondary Data Packet.
/// Describes the audio stream format to the sink.
#[repr(C)]
pub struct AudioInfoFrame {
/// Coding type (0 = refer to stream header, 1 = IEC 60958 PCM).
pub coding_type: u8,
/// Channel count minus 1 (0 = refer to stream header, 1-7 = 2-8 channels).
pub channel_count: u8,
/// Sample frequency (0 = refer to stream, 1 = 32kHz, 2 = 44.1kHz, 3 = 48kHz, etc.).
pub sample_freq: u8,
/// Sample size (0 = refer to stream, 1 = 16-bit, 2 = 20-bit, 3 = 24-bit).
pub sample_size: u8,
/// Channel/speaker allocation (CA field, CEA-861 Table 28).
/// Determines the mapping of PCM channels to physical speakers.
pub channel_allocation: u8,
/// Level shift value (0-15 dB, for downmix).
pub level_shift: u8,
/// Downmix inhibit flag.
pub downmix_inhibit: u8, // 0 = inhibit off, 1 = inhibit on
}
// AudioInfoFrame: u8(1)*7 = 7 bytes.
// Hardware-facing struct — HDMI/DP Audio InfoFrame packet fields.
const_assert!(core::mem::size_of::<AudioInfoFrame>() == 7);
Hot-plug and ELD update: When a display is connected or disconnected, the HDA
controller generates an unsolicited response (interrupt) on the HDMI/DP pin widget.
The driver handles this by:
1. Reading the pin sense register (GET_PIN_SENSE verb) to determine connection state.
2. If connected: reading the ELD buffer from the codec (GET_HDMI_ELD) and parsing
the sink's audio capabilities. Updating HdmiDpAudioEndpoint.eld.
3. Notifying the ALSA control interface via snd_ctl_notify()
(Section 21.4) — in the jack model,
snd_jack_report() on the connector's jack element
(Section 21.4) — which PipeWire/PulseAudio
monitor to update available audio sinks.
4. If disconnected while streaming: stopping the active PCM stream (triggers xrun in
the application) and clearing the ELD.
Audio InfoFrame programming: Before starting an HDMI/DP audio stream, the driver
programs the Audio InfoFrame via the HDA codec's Digital Converter verb set
(SET_DIGI_CONVERT_1/2, vendor-specific InfoFrame verbs). The InfoFrame must match the
actual PCM stream format; a mismatch causes the sink to mute or produce noise. The driver
validates that the requested format is supported by the sink's ELD (SAD list) before
programming the stream — unsupported formats are rejected at hw_params time with EINVAL.
Multi-display audio: Systems with multiple HDMI/DP outputs (common on discrete GPUs)
expose one HdmiDpAudioEndpoint per connector. Each endpoint is independently controllable
— different displays can play different audio streams simultaneously. The endpoints share
the GPU's HDA controller but use separate stream descriptors.
Cross-references: - HDA driver model (DMA, BDL, codec verbs): Section 21.4 - AudioDriver trait and KABI contract: Section 13.4 - Jack detection events: Section 21.4 - DMA buffer allocation: Section 4.14
21.4.7 PipeWire Integration¶
Section 21.4 defines PipeWire ring buffers for audio routing in userspace. The integration:
1. Kernel provides raw PCM streams (Section 21.4 PcmStream): a DMA ring buffer that hardware directly reads/writes.
2. PipeWire runs in userspace (Tier 2): implements the audio graph (mixing, routing, resampling, effects).
3. Zero-copy path: PipeWire's "audio device" node directly mmaps the kernel PCM DMA buffer. PipeWire writes mixed samples to appl_ptr, advances the pointer, the kernel driver sees the update and programs the hardware to consume up to appl_ptr.
Low-latency timer: PipeWire needs a periodic callback to refill the buffer every period. The kernel provides a timer (HPET or TSC-deadline APIC timer, configured to fire every period_frames / rate seconds, e.g., 1ms for 48-frame periods at 48kHz). Timer interrupt wakes PipeWire, which renders the next period's samples.
21.4.8 Character Device Registration¶
ALSA devices register with the VFS character device subsystem (Section 14.5) during audio subsystem init. Linux assigns a single well-known major:
| Major | Minor range | Device nodes | Description |
|---|---|---|---|
| 116 | 0 + 32×C | /dev/snd/controlC{C} |
Mixer/control per card |
| 116 | 1 | /dev/snd/seq |
MIDI sequencer |
| 116 | 33 | /dev/snd/timer |
ALSA timer (SNDRV_MINOR_TIMER = 33) |
| 116 | 4 + 32×C + D | /dev/snd/hwC{C}D{D} |
Hardware-specific access; D = 0–3 (SNDRV_MINOR_HWDEP = 4) |
| 116 | 16 + 32×C + D | /dev/snd/pcmC{C}D{D}p |
PCM playback; D = 0–7 (minors 16–23 per card) |
| 116 | 24 + 32×C + D | /dev/snd/pcmC{C}D{D}c |
PCM capture; D = 0–7 (minors 24–31 per card) |
Where C = card index (0–7); D = 0–3 for hwdep and 0–7 for each PCM
class. General minor formula:
minor = base_offset + 32 * card_index + device_index where base_offset is
0 (control), 4 (hwdep), 16 (PCM playback), 24 (PCM capture). Special devices:
seq = 1, timer = 33 (both card-independent). This formula matches Linux
exactly. The 32-per-card minor stride limits
the system to 32 cards (minors 0-1023) by default, matching the static
minor scheme used when dynamic minors are disabled. UmkaOS uses the static formula
for deterministic minor assignment. With static mode: 8 cards maximum
(minors 0-255, SNDRV_OS_MINORS=256). Dynamic mode
(when dynamic minors are enabled, supporting >8 cards up to 32) is also
available as a boot option for larger configurations.
Registration is per device class, not through a top-level dispatcher. Each
ALSA device class registers its own ChrdevRegion over its own minor
sub-range, carrying that class's FileOps table. A /dev/snd/pcmC0D0p open
therefore lands on the PCM table from the very first call, which is what
Section 14.5 requires:
"PCM device files (/dev/snd/pcmC0D0p) open with PCM-specific FileOps from
the start and do NOT use replace_fops." The only sanctioned ALSA use of
replace_fops is the CONTROL device switching into its event-monitoring mode
on SNDRV_CTL_IOCTL_SUBSCRIBE_EVENTS — a genuine post-open mode change on one
already-open file, not a dispatch mechanism.
This is why ALSA does not follow the TTY/DRM shape: majors 4/5 and 226 hand a
whole major to ONE class whose open() merely resolves which driver instance
backs the minor, so a single table is the right factoring there. ALSA's minor
space instead interleaves five unrelated classes inside every 32-minor card
block, and those classes have genuinely different data paths — PCM is mmap plus
transfer ioctls, control is event-queue reads, sequencer is event routing.
Routing them through one table would mean every data-path operation dispatching
through a vtable that cannot service it. Per-class regions also make the
ChrdevRegion the single source of truth for which minors exist, so an open of
an unregistered class is rejected by the chrdev registry with ENODEV instead
of by a hand-written minor decoder.
/// ALSA PCM mmap offsets (Linux ABI, `include/uapi/sound/asound.h`). Which of
/// the OLD/NEW pairs an architecture accepts is normative in
/// [Section 21.4](#audio-architecture--pcm-dma-buffer-lifecycle); these are the raw values
/// the `mmap()` handler below matches on.
pub const SNDRV_PCM_MMAP_OFFSET_DATA: u32 = 0x0000_0000;
/// Status page, 32-bit-timestamp layout. Accepted on every architecture.
pub const SNDRV_PCM_MMAP_OFFSET_STATUS_OLD: u32 = 0x8000_0000;
/// Control page, 32-bit-timestamp layout. Accepted on every architecture.
pub const SNDRV_PCM_MMAP_OFFSET_CONTROL_OLD: u32 = 0x8100_0000;
/// Status page, 64-bit-timestamp layout. 64-bit targets only.
pub const SNDRV_PCM_MMAP_OFFSET_STATUS_NEW: u32 = 0x8200_0000;
/// Control page, 64-bit-timestamp layout. 64-bit targets only.
pub const SNDRV_PCM_MMAP_OFFSET_CONTROL_NEW: u32 = 0x8300_0000;
/// `O_NONBLOCK` as it appears in `OpenFile::f_flags`. Value 0o4000 — the
/// asm-generic `fcntl.h` encoding, which is what all eight supported
/// architectures use (the architectures that renumber it, alpha and the
/// MIPS/SPARC families, are not targets). Named here because the PCM transfer
/// path is where the blocking-vs-`EAGAIN` contract of
/// [Section 21.4](#audio-architecture--alsa-pcm-as-dma-rings) is decided.
pub const O_NONBLOCK: u32 = 0o4000;
/// The `nr` byte of an ioctl command word (Linux encoding: `nr` occupies bits
/// [7:0]). The PCM surface uses one magic (`'A'`), so the `nr` alone selects
/// the operation — see the ioctl table in
/// [Section 21.4](#audio-architecture--alsa-pcm-compatibility-ioctls) for the full
/// encodings and their argument structs.
pub const fn pcm_ioctl_nr(cmd: u32) -> u32 { cmd & 0xFF }
/// `nr` values the file-operations layer acts on directly. The remainder of
/// the table is parameter negotiation and frame transfer, dispatched to
/// `pcm_ioctl_params()`.
pub const PCM_NR_PREPARE: u32 = 0x40;
/// `SNDRV_PCM_IOCTL_RESET` — stop and clear the ring.
pub const PCM_NR_RESET: u32 = 0x41;
/// `SNDRV_PCM_IOCTL_START` — begin DMA.
pub const PCM_NR_START: u32 = 0x42;
/// `SNDRV_PCM_IOCTL_DROP` — stop immediately, discarding queued frames.
pub const PCM_NR_DROP: u32 = 0x43;
/// `SNDRV_PCM_IOCTL_DRAIN` — stop after the queued frames play out.
pub const PCM_NR_DRAIN: u32 = 0x44;
/// `SNDRV_PCM_IOCTL_XRUN` — force the xrun state (test hook).
pub const PCM_NR_XRUN: u32 = 0x48;
/// `SNDRV_PCM_IOCTL_HW_FREE` — release the ring, keeping the file open.
pub const PCM_NR_HW_FREE: u32 = 0x12;
/// Per-open state for one PCM file description. Separate from `PcmStream`
/// because the two have different lifetimes: the file exists from `open(2)`,
/// the stream only from `HW_PARAMS` (which is what negotiates the format and
/// allocates the ring, [Section 21.4](#audio-architecture--pcm-dma-buffer-lifecycle)), and
/// `HW_FREE` destroys the stream while the file stays open and re-negotiable.
// kernel-internal, not KABI.
pub struct PcmOpenFile {
/// Key in `PCM_OPEN_FILES`, and the `OpenOutcome::private` token.
pub id: u64,
/// Card index decoded from the minor.
pub card: u32,
/// Device index within the card's PCM class.
pub device: u32,
/// Direction, decoded from WHICH class range the minor fell in — playback
/// nodes and capture nodes share one `FileOps` table
/// ([Section 21.4](#audio-architecture--character-device-registration)).
pub direction: PcmDirection,
/// The negotiated stream, `None` before `HW_PARAMS` and after `HW_FREE`.
/// A `SpinLock`, not an `RcuPtr`: the rebinding is per-`HW_PARAMS` (cold),
/// and every reader wants an owning `Arc` anyway. Readers clone the `Arc`
/// and DROP THE GUARD before touching userspace — a user copy can fault
/// and sleep, which under a spinlock would be a deadlock.
pub stream: SpinLock<Option<Arc<PcmStream>>>,
}
/// Every live PCM file description, keyed by `PcmOpenFile::id`. Integer key ⇒
/// `XArray` (collection policy); RCU-protected reads, so resolving the
/// `private` token on the transfer path is one lockless lookup.
pub static PCM_OPEN_FILES: XArray<Arc<PcmOpenFile>> = XArray::new();
/// Monotonic allocator for `PcmOpenFile::id`. Never hands out 0; `u64` and
/// never recycled, so a token cannot be made to name a different open by
/// outliving it ([Section 1.3](01-overview.md#performance-budget--counter-and-identifier-longevity-budget)).
pub static PCM_OPEN_ID_NEXT: AtomicU64 = AtomicU64::new(1);
/// Resolve the `private` token a `FileOps` method was handed back to its open.
/// An id rather than a pointer: `OpenOutcome::private` is a `u64`, so a pointer
/// token would be a truncation hazard on the 32-bit legs and would dangle if
/// the open were torn down concurrently; an id simply fails to resolve.
pub fn pcm_open_lookup(id: u64) -> Option<Arc<PcmOpenFile>> {
let guard = rcu_read_lock();
PCM_OPEN_FILES.xa_load(id, &guard).map(Arc::clone)
}
/// The device number of the inode a `FileOps` method was invoked on, whose
/// minor is the authoritative device identity for a character device
/// ([Section 14.5](14-vfs.md#device-node-framework): the region open path derives the instance
/// index from `i_rdev`, and delivers the inode to the driver as an `InodeId`).
/// Declared here because all five ALSA class tables need exactly this in
/// `open()`, which — unlike `read`/`write` — receives no `OpenFile`.
pub fn snd_inode_rdev(inode: InodeId) -> Option<DevId>;
/// Whether card `card` exposes PCM device `device` in direction `dir`. The
/// ALSA core populates this when the card registers its minor sub-ranges
/// (`snd_register_card()` above). A minor inside a registered class range but
/// with no backing device is `ENODEV` at open, not a later surprise.
pub fn snd_pcm_device_exists(card: u32, device: u32, dir: PcmDirection) -> bool;
/// Negotiate hardware parameters: validate the `snd_pcm_hw_params` at `arg`,
/// call `open_pcm` on the owning driver, build the `PcmStream` around the
/// returned `PcmOpen`, and install it in `file.stream`. The parameter surface
/// itself is specified in [Section 21.4](#audio-architecture--alsa-pcm-compatibility-ioctls).
pub fn pcm_ioctl_params(file: &Arc<PcmOpenFile>, cmd: u32, arg: u64) -> Result<i64>;
/// Build the `MmapResult` for one of the three ABI mappings: the DMA ring
/// itself, or the single status/control page. The page contents and the
/// release/acquire discipline over them are in
/// [Section 21.4](#audio-architecture--alsa-pcm-as-dma-rings).
pub fn pcm_map_region(
stream: &Arc<PcmStream>,
region: PcmMapRegion,
len: usize,
vm_flags: u64,
) -> Result<MmapResult>;
/// Which of the three ABI mappings an `mmap()` offset selected. OLD offsets
/// accepted by a 64-bit kernel alias these same native 64-bit-time pages; there
/// is no second 32-bit-timestamp layout.
pub enum PcmMapRegion {
/// The DMA ring (`SNDRV_PCM_MMAP_OFFSET_DATA`).
Data,
/// The native 64-bit-time status page.
Status,
/// The native 64-bit-time control page.
Control,
}
impl PcmStream {
/// Bytes per frame: one sample per channel.
fn frame_bytes(&self) -> usize {
let sample = match self.params.format {
PcmFormat::S16Le => 2,
// S24_LE is the 24-in-32 packing, so it is four bytes wide like
// S32_LE and F32_LE — not three.
PcmFormat::S24Le | PcmFormat::S32Le | PcmFormat::F32Le => 4,
};
sample * self.params.channels as usize
}
/// Frames the application may transfer right now: ring space the hardware
/// has already consumed (playback), or frames it has produced and the
/// application has not taken (capture). This is the ABI's `snd_pcm_avail()`.
///
/// The difference is taken modulo `boundary` WITHOUT relying on wrapping
/// arithmetic: `boundary` is a multiple of `buffer_frames`, not necessarily
/// a power of two, so a `wrapping_sub` followed by `%` would be wrong on
/// the wrap.
pub fn avail(&self) -> u64 {
let hw = self.hw_ptr.load(Ordering::Acquire);
let appl = self.appl_ptr.load(Ordering::Acquire);
match self.params.direction {
// Queued but not yet consumed; the rest of the ring is writable.
PcmDirection::Playback => {
let queued = if appl >= hw { appl - hw } else { self.boundary - hw + appl };
self.params.buffer_frames as u64 - queued
}
// Produced by the device and not yet taken.
PcmDirection::Capture => {
if hw >= appl { hw - appl } else { self.boundary - appl + hw }
}
}
}
/// Block until at least one frame is transferable, returning how many are.
/// The ALSA contract for the state machine, verbatim: `Xrun` is `-EPIPE`,
/// a departed device is `-ENODEV`, a suspended stream is `-ESTRPIPE`, and
/// any other non-transferring state is `-EBADFD`
/// ([Section 21.4](#audio-architecture--xrun-handling-d25)).
///
/// The wait is interruptible, so a blocked `aplay` still answers Ctrl-C;
/// the wakeup comes from the period-interrupt handler, which wakes
/// `waiters` after the `hw_ptr` release store, and from every state
/// transition out of `Running`.
fn wait_avail(&self, nonblock: bool) -> Result<u64, IoError> {
loop {
match self.state() {
SndPcmState::Running
| SndPcmState::Prepared
| SndPcmState::Draining => {}
SndPcmState::Xrun => return Err(IoError::new(Errno::EPIPE)),
SndPcmState::Disconnected => return Err(IoError::new(Errno::ENODEV)),
SndPcmState::Suspended => return Err(IoError::new(Errno::ESTRPIPE)),
_ => return Err(IoError::new(Errno::EBADFD)),
}
let avail = self.avail();
if avail > 0 {
return Ok(avail);
}
if nonblock {
return Err(IoError::new(Errno::EAGAIN));
}
self.waiters
.wait_event(|| self.avail() > 0 || self.state() != SndPcmState::Running)
.map_err(IoError::new)?;
}
}
/// CPU-side view of the coherent PCM ring named by `dma_buffer`: base
/// pointer and length in bytes. The mapping is established when the ring is
/// allocated and lives as long as the stream
/// ([Section 21.4](#audio-architecture--pcm-dma-buffer-lifecycle)).
///
/// A raw pointer rather than a slice: the device reads or writes the same
/// memory concurrently, so a `&mut [u8]` over the whole ring would assert
/// an exclusivity the DMA engine does not honour. Only the span `avail`
/// grants — which the hardware provably does not touch — is ever
/// materialized as a slice, and only for the length of one copy.
fn ring_view(&self) -> (*mut u8, usize);
/// One ioctl-path transfer in either direction: copy at most
/// `min(requested, avail)` frames between `buf` and the DMA ring, then
/// advance `appl_ptr` by what was transferred. Backs `read(2)`/`write(2)`
/// and the `WRITEI`/`READI`/`WRITEN`/`READN` ioctls — one implementation,
/// because they differ only in how the caller expressed the buffer.
///
/// This is the NON-zero-copy path and is mandatory ALSA ABI; the zero-copy
/// property belongs to the mmap path alone
/// ([Section 21.4](#audio-architecture--alsa-pcm-as-dma-rings)).
fn transfer(
&self,
buf: &mut PcmXferBuf<'_>,
nonblock: bool,
) -> Result<usize, IoError> {
let frame = self.frame_bytes();
let want_frames = buf.remaining() / frame;
if want_frames == 0 {
return Ok(0); // Not a whole frame: nothing to do, not an error.
}
let avail = self.wait_avail(nonblock)? as usize;
let frames = want_frames.min(avail);
let (base, ring_len) = self.ring_view();
let ring_frames = self.params.buffer_frames as usize;
// Where in the ring the application's window starts. `appl_ptr` counts
// modulo `boundary`; the ring index is that modulo the ring size.
let start = (self.appl_ptr.load(Ordering::Acquire) as usize) % ring_frames;
let mut done = 0usize;
// At most two spans: up to the ring end, then from the ring start.
while done < frames {
let idx = (start + done) % ring_frames;
let span = (ring_frames - idx).min(frames - done);
let off = idx * frame;
let len = span * frame;
debug_assert!(off + len <= ring_len);
// SAFETY (both arms): `off + len <= ring_len` (asserted above), so
// the span lies inside the ring mapping; and it lies inside the
// window `avail` reported, which is by construction the region the
// DMA engine is NOT accessing — the hardware only touches frames
// between `hw_ptr` and `appl_ptr`.
let dst_ptr = unsafe { base.add(off) };
match buf {
PcmXferBuf::Playback(src) => {
// `read_at` does not advance the slice's own cursor, so
// this loop supplies the byte offset itself. A short copy
// is an unresolvable fault: the frames already written are
// NOT published, because `appl_ptr` only advances below.
if src.read_at(done * frame, dst_ptr, len) != len {
return Err(IoError::new(Errno::EFAULT));
}
}
PcmXferBuf::Capture(dst) => {
let region = unsafe { core::slice::from_raw_parts(dst_ptr, len) };
let wrote = dst.write(region).map_err(IoError::new)?;
if wrote != len {
return Err(IoError::new(Errno::EFAULT));
}
}
}
done += span;
}
if matches!(buf, PcmXferBuf::Playback(_)) {
// The samples must be visible to the device BEFORE it can observe
// the advanced `appl_ptr` — the producer-side rule of
// [Section 21.4](#audio-architecture--alsa-pcm-as-dma-rings), the same
// DMA-visible store-store ordering `send_verb` issues before
// publishing CORBWP.
core::sync::atomic::fence(core::sync::atomic::Ordering::Release);
}
let appl = self.appl_ptr.load(Ordering::Acquire);
let next = (appl + done as u64) % self.boundary;
self.appl_ptr.store(next, Ordering::Release);
Ok(done * frame)
}
}
/// The userspace side of one `PcmStream::transfer()`, in whichever direction
/// the node's class fixed. An enum rather than two near-identical methods:
/// everything except the single copy call is shared, and duplicating the
/// windowing arithmetic is how the two directions drift apart.
pub enum PcmXferBuf<'a> {
/// Playback: userspace source, copied INTO the ring. A SHARED reference —
/// the copy uses `UserSlice::read_at()`, which is random-access and does
/// not move the slice's cursor, so the VFS's `&UserSlice` is enough.
Playback(&'a UserSlice),
/// Capture: userspace destination, copied OUT of the ring. Unique, because
/// `UserSliceMut::write()` advances the cursor.
Capture(&'a mut UserSliceMut),
}
impl PcmXferBuf<'_> {
/// Bytes left in the userspace buffer.
fn remaining(&self) -> usize {
match self {
PcmXferBuf::Playback(s) => s.remaining(),
PcmXferBuf::Capture(d) => d.remaining(),
}
}
}
/// `FileOps` for PCM playback and capture nodes (`/dev/snd/pcmC{C}D{D}p`,
/// `…c`). Installed by the chrdev registry at open — never via `replace_fops`.
// kernel-internal, not KABI — dispatched through the VFS `FileOps` trait object.
pub struct PcmFileOps;
impl FileOps for PcmFileOps {
/// Decode (card, device, direction) from the minor and publish a
/// `PcmOpenFile`. No stream yet: the format is not known until `HW_PARAMS`,
/// and allocating a ring for a format the application has not asked for
/// would pin DMA memory for every `open(2)` that only reads `INFO`.
fn open(&self, inode: InodeId, _flags: OpenFlags) -> Result<OpenOutcome> {
let minor = snd_inode_rdev(inode).ok_or(Errno::ENODEV)?.minor();
let card = minor / SNDRV_MINORS_PER_CARD;
let within = minor % SNDRV_MINORS_PER_CARD;
// The two PCM classes share this table, so the class range the minor
// fell in IS the direction.
let (direction, device) = if within >= SNDRV_MINOR_PCM_CAPTURE {
(PcmDirection::Capture, within - SNDRV_MINOR_PCM_CAPTURE)
} else if within >= SNDRV_MINOR_PCM_PLAYBACK {
(PcmDirection::Playback, within - SNDRV_MINOR_PCM_PLAYBACK)
} else {
// Unreachable through the chrdev registry, which routes only the
// two PCM sub-ranges to this table. Answered rather than
// subtracted: an underflow here would be a panic on a path a
// future region change could open.
return Err(Errno::ENODEV);
};
if !snd_pcm_device_exists(card, device, direction) {
return Err(Errno::ENODEV);
}
let id = PCM_OPEN_ID_NEXT.fetch_add(1, Ordering::Relaxed);
let file = Arc::try_new(PcmOpenFile {
id,
card,
device,
direction,
stream: SpinLock::new(None),
}).map_err(|_| Errno::ENOMEM)?;
PCM_OPEN_FILES.xa_store(id, file);
Ok(OpenOutcome { private: id, data_inode: None })
}
/// Last-descriptor teardown. Runs the release sequence of
/// [Section 21.4](#audio-architecture--pcm-dma-buffer-lifecycle) — stop, then
/// `close_pcm`, then the kernel-side reclaim — and only then drops the
/// registry entry, so a token that is still resolvable always names a live
/// open.
///
/// Errors are reported, never used to skip the reclaim: a driver that is
/// crashed or quiescing yields `PcmOpError::Dispatch`, and the kernel still
/// owns the mapping and the buffer.
fn release(&self, _inode: InodeId, private: u64) -> Result<()> {
let Some(file) = pcm_open_lookup(private) else {
return Ok(()); // Already torn down: releasing twice is not an error.
};
let taken = file.stream.lock().take();
let mut outcome = Ok(());
if let Some(stream) = taken {
// One state read, not two: a transition between them would decide
// the stop on a state that no longer holds.
if matches!(
stream.state(),
SndPcmState::Running | SndPcmState::Draining
) {
// close() does not stop implicitly, by contract.
let _ = stream.stop(false);
}
if stream.close().is_err() {
outcome = Err(Errno::EIO);
}
}
PCM_OPEN_FILES.xa_erase(private);
outcome
}
/// Capture transfer path (`read(2)` on `/dev/snd/pcmC{C}D{D}c`).
/// `offset` is advanced by the bytes delivered so the VFS keeps its
/// bookkeeping consistent, but a PCM node is a stream: the position is not
/// a seekable coordinate (`llseek` below rejects).
fn read(
&self,
file: &OpenFile,
buf: &mut UserSliceMut,
offset: &mut i64,
) -> Result<usize, IoError> {
let pcm = pcm_open_lookup(file.private_data.load(Ordering::Relaxed))
.ok_or(IoError::new(Errno::ENODEV))?;
if pcm.direction != PcmDirection::Capture {
return Err(IoError::new(Errno::EBADFD));
}
// Clone the Arc and RELEASE the guard: the copy below can fault.
let stream = pcm.stream.lock().as_ref().map(Arc::clone)
.ok_or(IoError::new(Errno::EBADFD))?; // no HW_PARAMS yet
let nonblock = (file.f_flags.load(Ordering::Relaxed) & O_NONBLOCK) != 0;
let n = stream.transfer(&mut PcmXferBuf::Capture(buf), nonblock)?;
*offset += n as i64;
Ok(n)
}
/// Playback transfer path (`write(2)` on `/dev/snd/pcmC{C}D{D}p`).
fn write(
&self,
file: &OpenFile,
buf: &UserSlice,
offset: &mut i64,
) -> Result<usize, IoError> {
let pcm = pcm_open_lookup(file.private_data.load(Ordering::Relaxed))
.ok_or(IoError::new(Errno::ENODEV))?;
if pcm.direction != PcmDirection::Playback {
return Err(IoError::new(Errno::EBADFD));
}
let stream = pcm.stream.lock().as_ref().map(Arc::clone)
.ok_or(IoError::new(Errno::EBADFD))?;
let nonblock = (file.f_flags.load(Ordering::Relaxed) & O_NONBLOCK) != 0;
let n = stream.transfer(&mut PcmXferBuf::Playback(buf), nonblock)?;
*offset += n as i64;
Ok(n)
}
/// The PCM ioctl surface. The lifecycle operations resolve to `PcmStream`
/// methods here; the parameter-negotiation and frame-transfer commands,
/// which carry ABI structs, go to `pcm_ioctl_params()`. Unknown commands
/// are `ENOTTY`, as the VFS contract requires.
fn ioctl(&self, _inode: InodeId, private: u64, cmd: u32, arg: u64) -> Result<i64> {
let pcm = pcm_open_lookup(private).ok_or(Errno::ENODEV)?;
let nr = pcm_ioctl_nr(cmd);
// Commands that need a negotiated stream resolve it once, here. Every
// arm below either uses this clone or does not touch the stream at
// all; the guard is released before any of them runs.
let stream = pcm.stream.lock().as_ref().map(Arc::clone);
match nr {
PCM_NR_START => {
stream.ok_or(Errno::EBADFD)?.start().map_err(pcm_errno)?;
Ok(0)
}
PCM_NR_DROP => {
stream.ok_or(Errno::EBADFD)?.stop(false).map_err(pcm_errno)?;
Ok(0)
}
PCM_NR_DRAIN => {
stream.ok_or(Errno::EBADFD)?.stop(true).map_err(pcm_errno)?;
Ok(0)
}
PCM_NR_XRUN => {
stream.ok_or(Errno::EBADFD)?.post_xrun();
Ok(0)
}
PCM_NR_HW_FREE => {
// The stream goes away; the file stays open and re-negotiable.
if let Some(stream) = pcm.stream.lock().take() {
let _ = stream.stop(false);
stream.close().map_err(pcm_errno)?;
}
Ok(0)
}
PCM_NR_PREPARE | PCM_NR_RESET => pcm_ioctl_params(&pcm, cmd, arg),
_ => pcm_ioctl_params(&pcm, cmd, arg),
}
}
/// Serve the data ring and the status/control pages at the ABI offsets.
/// NEW offsets are accepted on every target. OLD offsets are accepted only
/// on 64-bit kernels and alias the same native 64-bit-time pages, per
/// [Section 21.4](#audio-architecture--pcm-dma-buffer-lifecycle).
fn mmap(
&self,
_inode: InodeId,
private: u64,
offset: u64,
len: usize,
vm_flags: u64,
) -> Result<MmapResult> {
let pcm = pcm_open_lookup(private).ok_or(Errno::ENODEV)?;
let stream = pcm.stream.lock().as_ref().map(Arc::clone)
.ok_or(Errno::EBADFD)?;
let region = match offset as u32 {
SNDRV_PCM_MMAP_OFFSET_DATA => PcmMapRegion::Data,
SNDRV_PCM_MMAP_OFFSET_STATUS_NEW => PcmMapRegion::Status,
SNDRV_PCM_MMAP_OFFSET_CONTROL_NEW => PcmMapRegion::Control,
SNDRV_PCM_MMAP_OFFSET_STATUS_OLD
if cfg!(target_pointer_width = "64") => PcmMapRegion::Status,
SNDRV_PCM_MMAP_OFFSET_CONTROL_OLD
if cfg!(target_pointer_width = "64") => PcmMapRegion::Control,
// In particular, OLD on a 32-bit kernel is rejected so alsa-lib
// can fall back to the SYNC_PTR path.
_ => return Err(Errno::ENXIO),
};
pcm_map_region(&stream, region, len, vm_flags)
}
/// Readiness for `poll`/`select`/`epoll`. PipeWire on the mmap path is
/// woken by the control-page futex instead; this serves the ioctl-transfer
/// applications, and reports the error states so a poll loop is not left
/// waiting on a stream that has xrun'd or lost its device.
fn poll(
&self,
_inode: InodeId,
private: u64,
_events: PollEvents,
pt: Option<&mut PollTable>,
) -> Result<PollEvents> {
let pcm = pcm_open_lookup(private).ok_or(Errno::ENODEV)?;
let Some(stream) = pcm.stream.lock().as_ref().map(Arc::clone) else {
// No ring yet: nothing can become ready without another ioctl.
return Ok(PollEvents::empty());
};
// Registration BEFORE the readiness computation, so a period interrupt
// landing between the two wakes this waiter instead of being missed.
poll_wait(&stream.waiters, pt);
let mut mask = PollEvents::empty();
match stream.state() {
SndPcmState::Xrun => mask |= PollEvents::EPOLLERR,
SndPcmState::Disconnected => mask |= PollEvents::EPOLLERR | PollEvents::EPOLLHUP,
_ => {}
}
if stream.avail() > 0 {
mask |= match pcm.direction {
PcmDirection::Playback => PollEvents::EPOLLOUT | PollEvents::EPOLLWRNORM,
PcmDirection::Capture => PollEvents::EPOLLIN | PollEvents::EPOLLRDNORM,
};
}
Ok(mask)
}
// The remaining trait methods have no meaning on a PCM node. Each returns
// the errno Linux's ALSA returns for the same call, so an application that
// probes gets the answer it expects rather than a novel one.
/// A character device has no length.
fn truncate(&self, _inode: InodeId, _private: u64, _new_size: u64) -> Result<()> {
Err(Errno::EINVAL)
}
/// Nothing to write back: the ring is coherent DMA memory.
fn fsync(
&self,
_inode: InodeId,
_private: u64,
_start: u64,
_end: u64,
_datasync: u8,
) -> Result<()> {
Err(Errno::EINVAL)
}
/// The ring is allocated by `HW_PARAMS`, never by the file's length.
fn fallocate(
&self,
_inode: InodeId,
_private: u64,
_offset: u64,
_len: u64,
_mode: FallocateMode,
) -> Result<()> {
Err(Errno::ENODEV)
}
/// Not a directory.
fn readdir(
&self,
_inode: InodeId,
_private: u64,
_offset: u64,
_emit: &mut dyn FnMut(InodeId, u64, FileType, &OsStr) -> bool,
) -> Result<()> {
Err(Errno::ENOTDIR)
}
/// A PCM node is a stream: its position is the ring pointers, which
/// `SYNC_PTR`/`FORWARD`/`REWIND` move, not `lseek`.
fn llseek(
&self,
_inode: InodeId,
_private: u64,
_offset: i64,
_whence: SeekWhence,
) -> Result<u64> {
Err(Errno::ESPIPE)
}
/// Splicing a PCM node would bypass the frame-granular windowing the
/// transfer path exists to enforce.
fn splice_read(
&self,
_inode: InodeId,
_private: u64,
_offset: u64,
_pipe: PipeId,
_len: usize,
) -> Result<usize> {
Err(Errno::EINVAL)
}
/// As `splice_read`.
fn splice_write(
&self,
_pipe: PipeId,
_inode: InodeId,
_private: u64,
_offset: u64,
_len: usize,
) -> Result<usize> {
Err(Errno::EINVAL)
}
}
/// Map a PCM lifecycle failure to the errno the ALSA ABI requires, so
/// `snd_pcm_recover()` dispatches correctly. `BadState` carries the state
/// actually observed; `Dispatch` carries the transport/provider failure.
///
/// The `Dispatch` arm keeps the driver's own value rather than collapsing
/// everything to `EIO`: `KabiError::to_errno()` yields the NEGATED errno the
/// KABI boundary uses ([Section 12.3](12-kabi.md#kabi-bilateral-capability-exchange)), which
/// `IoError::from_neg_errno()` is the canonical decoder for
/// ([Section 14.1](14-vfs.md#virtual-filesystem-layer)). Re-deriving that mapping here would be a
/// second, drifting copy of it.
fn pcm_errno(e: PcmOpError) -> Errno {
match e {
PcmOpError::BadState(SndPcmState::Xrun) => Errno::EPIPE,
PcmOpError::BadState(SndPcmState::Disconnected) => Errno::ENODEV,
PcmOpError::BadState(SndPcmState::Suspended) => Errno::ESTRPIPE,
PcmOpError::BadState(_) => Errno::EBADFD,
PcmOpError::Dispatch(k) => IoError::from_neg_errno(k.to_errno()).errno(),
}
}
/// The shared PCM `FileOps` table. Coerces to `&'static dyn FileOps` at
/// `register_chrdev_region()`.
pub static PCM_FOPS: PcmFileOps = PcmFileOps;
/// `FileOps` for the per-card mixer/control node (`/dev/snd/controlC{C}`).
/// `open()` binds the card; `read()` drains the client's control-event queue
/// ([Section 21.4](#audio-architecture--control-event-notification)); `ioctl()` serves the
/// element enumeration and read/write surface. This is the ONE ALSA table that
/// uses `replace_fops`, and only from its own `SNDRV_CTL_IOCTL_SUBSCRIBE_EVENTS`
/// handler, to switch the already-open file into event-monitoring mode.
// kernel-internal, not KABI.
pub struct CtlFileOps;
impl FileOps for CtlFileOps {
// open / read / poll / ioctl / release as described above; unsupported
// trait methods reject with `NotSupported`.
}
/// The shared control `FileOps` table.
pub static CTL_FOPS: CtlFileOps = CtlFileOps;
/// `FileOps` for the sequencer node (`/dev/snd/seq`). `open()` allocates a
/// `SeqClient`; `read()`/`write()` carry `SeqEvent` records; `ioctl()` serves
/// the port/subscription surface. Specified in
/// [Section 21.4](#audio-architecture--alsa-midi-sequencer).
// kernel-internal, not KABI.
pub struct SeqFileOps;
impl FileOps for SeqFileOps {
// open / read / write / poll / ioctl / release per the sequencer section;
// unsupported trait methods reject with `NotSupported`.
}
/// The shared sequencer `FileOps` table.
pub static SEQ_FOPS: SeqFileOps = SeqFileOps;
/// `FileOps` for the timer node (`/dev/snd/timer`). Specified in
/// [Section 21.4](#audio-architecture--alsa-timer-interface).
// kernel-internal, not KABI.
pub struct TimerFileOps;
impl FileOps for TimerFileOps {
// open / read / poll / ioctl / release per the timer section; unsupported
// trait methods reject with `NotSupported`.
}
/// The shared timer `FileOps` table.
pub static TIMER_FOPS: TimerFileOps = TimerFileOps;
/// `FileOps` for hardware-dependent nodes (`/dev/snd/hwC{C}D{D}`). Specified
/// in [Section 21.4](#audio-architecture--alsa-hardware-dependent-hwdep-interface).
// kernel-internal, not KABI.
pub struct HwdepFileOps;
impl FileOps for HwdepFileOps {
// open / read / write / ioctl / mmap / release per the hwdep section;
// unsupported trait methods reject with `NotSupported`.
}
/// The shared hwdep `FileOps` table.
pub static HWDEP_FOPS: HwdepFileOps = HwdepFileOps;
/// Card-independent nodes. Called from snd_subsystem_init() during boot
/// Phase 5.3+ (after Tier 1 driver loading). Per-card regions are registered
/// later, by `snd_register_card()` below, because which cards exist is a
/// runtime discovery.
fn snd_register_chrdev() {
// Sequencer: minor 1, card-independent.
register_chrdev_region(ChrdevRegion {
major: 116,
minor_base: SNDRV_MINOR_SEQUENCER,
minor_count: 1,
fops: &SEQ_FOPS,
name: "snd/seq",
}).expect("ALSA sequencer minor registration");
// Timer: minor 33, card-independent.
register_chrdev_region(ChrdevRegion {
major: 116,
minor_base: SNDRV_MINOR_TIMER,
minor_count: 1,
fops: &TIMER_FOPS,
name: "snd/timer",
}).expect("ALSA timer minor registration");
}
/// Register the four per-card minor sub-ranges for card `card_index`, each
/// with its own class table. Called when a sound card is registered, before
/// the devtmpfs nodes are created.
///
/// The sub-ranges are disjoint and none of them covers minor 1 or 33, so the
/// card-independent sequencer and timer regions above are never shadowed:
/// within a card block the class offsets are 0 (control, 1 minor), 4 (hwdep,
/// 4 devices), 16 (PCM playback, 8 devices), and 24 (PCM capture, 8 devices).
fn snd_register_card(card_index: u32) -> Result<(), KernelError> {
let base = SNDRV_MINORS_PER_CARD * card_index;
register_chrdev_region(ChrdevRegion {
major: 116,
minor_base: base + SNDRV_MINOR_CONTROL,
minor_count: 1,
fops: &CTL_FOPS,
name: "snd/control",
})?;
register_chrdev_region(ChrdevRegion {
major: 116,
minor_base: base + SNDRV_MINOR_HWDEP,
minor_count: SNDRV_HWDEP_DEVICES_PER_CARD,
fops: &HWDEP_FOPS,
name: "snd/hwdep",
})?;
// Playback and capture share one table; `open()` derives the direction
// from the minor's class offset.
register_chrdev_region(ChrdevRegion {
major: 116,
minor_base: base + SNDRV_MINOR_PCM_PLAYBACK,
minor_count: SNDRV_PCM_DEVICES_PER_CARD,
fops: &PCM_FOPS,
name: "snd/pcmP",
})?;
register_chrdev_region(ChrdevRegion {
major: 116,
minor_base: base + SNDRV_MINOR_PCM_CAPTURE,
minor_count: SNDRV_PCM_DEVICES_PER_CARD,
fops: &PCM_FOPS,
name: "snd/pcmC",
})?;
Ok(())
}
/// Minor-number stride per sound card (Linux `SNDRV_MINOR_DEVICES`).
pub const SNDRV_MINORS_PER_CARD: u32 = 32;
/// Hardware-dependent devices per card (D = 0–3).
pub const SNDRV_HWDEP_DEVICES_PER_CARD: u32 = 4;
/// PCM devices per direction and card (D = 0–7).
pub const SNDRV_PCM_DEVICES_PER_CARD: u32 = 8;
/// Class offsets within a card's minor block — the `base_offset` of the
/// general minor formula above.
pub const SNDRV_MINOR_CONTROL: u32 = 0;
pub const SNDRV_MINOR_HWDEP: u32 = 4;
pub const SNDRV_MINOR_PCM_PLAYBACK: u32 = 16;
pub const SNDRV_MINOR_PCM_CAPTURE: u32 = 24;
/// Card-independent minors.
pub const SNDRV_MINOR_SEQUENCER: u32 = 1;
pub const SNDRV_MINOR_TIMER: u32 = 33;
devtmpfs node creation: When a sound card driver is registered,
snd_register_card() claims the card's minor sub-ranges and the ALSA core then calls
devtmpfs_create_node() for each PCM, control, and hwdep device, which
triggers devtmpfs to create the corresponding /dev/snd/* nodes automatically. The
nodes are removed and the regions unregistered when the card is unregistered
(driver unload or device removal).
No udev rule is required for basic node creation — devtmpfs handles it in-kernel.
Each class table's open() resolves its own instance from the minor: the card
index is minor / 32 and the device index within the class is
(minor % 32) - base_offset. No class needs to identify the other classes,
because the chrdev registry already selected the table by minor range.
21.4.9 ALSA PCM Compatibility Ioctls¶
The umka-sysapi layer translates Linux ALSA PCM ioctls on /dev/snd/pcmC*D*p and
/dev/snd/pcmC*D*c file descriptors to native UmkaOS audio calls. All ioctl numbers
use the Linux encoding: magic 'A' (0x41), with _IO, _IOR, _IOW, _IOWR
direction/size encoding. The following table lists the ioctls that umka-sysapi must
handle for ALSA application compatibility (PipeWire, PulseAudio, JACK, aplay/arecord):
| Ioctl | Macro | Nr | Dir | Description |
|---|---|---|---|---|
SNDRV_PCM_IOCTL_PVERSION |
_IOR('A', 0x00, int) |
0x00 | R | Protocol version |
SNDRV_PCM_IOCTL_INFO |
_IOR('A', 0x01, snd_pcm_info) |
0x01 | R | Stream info (card, device, subdevice, name) |
SNDRV_PCM_IOCTL_TSTAMP |
_IOW('A', 0x02, int) |
0x02 | W | Set timestamp mode (deprecated; use TTSTAMP) |
SNDRV_PCM_IOCTL_TTSTAMP |
_IOW('A', 0x03, int) |
0x03 | W | Set timestamp type (monotonic, monotonic_raw) |
SNDRV_PCM_IOCTL_HW_REFINE |
_IOWR('A', 0x10, snd_pcm_hw_params) |
0x10 | RW | Refine hardware parameter space (intersection) |
SNDRV_PCM_IOCTL_HW_PARAMS |
_IOWR('A', 0x11, snd_pcm_hw_params) |
0x11 | RW | Set hardware parameters (format, rate, channels, buffer size) |
SNDRV_PCM_IOCTL_HW_FREE |
_IO('A', 0x12) |
0x12 | — | Free hardware resources (DMA buffer) |
SNDRV_PCM_IOCTL_SW_PARAMS |
_IOWR('A', 0x13, snd_pcm_sw_params) |
0x13 | RW | Set software parameters (avail_min, start_threshold, stop_threshold) |
SNDRV_PCM_IOCTL_STATUS |
_IOR('A', 0x20, snd_pcm_status) |
0x20 | R | Get stream status (state, hw_ptr, tstamp, delay) |
SNDRV_PCM_IOCTL_DELAY |
_IOR('A', 0x21, snd_pcm_sframes_t) |
0x21 | R | Get current delay in frames |
SNDRV_PCM_IOCTL_HWSYNC |
_IO('A', 0x22) |
0x22 | — | Synchronize hw_ptr with hardware |
SNDRV_PCM_IOCTL_SYNC_PTR |
_IOWR('A', 0x23, snd_pcm_sync_ptr) |
0x23 | RW | Sync hw_ptr/appl_ptr (mmap mode; combined status+control update) |
SNDRV_PCM_IOCTL_CHANNEL_INFO |
_IOR('A', 0x32, snd_pcm_channel_info) |
0x32 | R | Per-channel mmap offset/stride info |
SNDRV_PCM_IOCTL_PREPARE |
_IO('A', 0x40) |
0x40 | — | Prepare stream for playback/capture (reset pointers) |
SNDRV_PCM_IOCTL_RESET |
_IO('A', 0x41) |
0x41 | — | Reset stream (stop + clear buffer) |
SNDRV_PCM_IOCTL_START |
_IO('A', 0x42) |
0x42 | — | Start DMA (begin playback/capture) |
SNDRV_PCM_IOCTL_DROP |
_IO('A', 0x43) |
0x43 | — | Stop immediately (discard pending frames) |
SNDRV_PCM_IOCTL_DRAIN |
_IO('A', 0x44) |
0x44 | — | Stop after all pending data played/captured |
SNDRV_PCM_IOCTL_PAUSE |
_IOW('A', 0x45, int) |
0x45 | W | Pause/resume (arg: 1=pause, 0=resume) |
SNDRV_PCM_IOCTL_REWIND |
_IOW('A', 0x46, snd_pcm_uframes_t) |
0x46 | W | Rewind appl_ptr by N frames |
SNDRV_PCM_IOCTL_RESUME |
_IO('A', 0x47) |
0x47 | — | Resume from suspend (power management) |
SNDRV_PCM_IOCTL_XRUN |
_IO('A', 0x48) |
0x48 | — | Force xrun state (testing) |
SNDRV_PCM_IOCTL_FORWARD |
_IOW('A', 0x49, snd_pcm_uframes_t) |
0x49 | W | Advance appl_ptr by N frames |
SNDRV_PCM_IOCTL_WRITEI_FRAMES |
_IOW('A', 0x50, snd_xferi) |
0x50 | W | Write interleaved frames (non-mmap path) |
SNDRV_PCM_IOCTL_READI_FRAMES |
_IOR('A', 0x51, snd_xferi) |
0x51 | R | Read interleaved frames (non-mmap path) |
SNDRV_PCM_IOCTL_WRITEN_FRAMES |
_IOW('A', 0x52, snd_xfern) |
0x52 | W | Write non-interleaved frames |
SNDRV_PCM_IOCTL_READN_FRAMES |
_IOR('A', 0x53, snd_xfern) |
0x53 | R | Read non-interleaved frames |
SNDRV_PCM_IOCTL_LINK |
_IOW('A', 0x60, int) |
0x60 | W | Link two PCM streams (synchronized start/stop) |
SNDRV_PCM_IOCTL_UNLINK |
_IO('A', 0x61) |
0x61 | — | Unlink PCM streams |
All struct sizes in the ioctl encoding match the Linux sizeof() on the target
architecture (LP64 for 64-bit, ILP32 for 32-bit). The umka-sysapi 32-bit compat
layer translates the 32-bit forms of snd_pcm_hw_params, snd_pcm_sw_params,
snd_pcm_status, and snd_pcm_sync_ptr (which contain pointer-sized fields that
differ between 32-bit and 64-bit ABIs).
snd_pcm_hw_params ABI struct — the primary parameter negotiation struct
used by HW_REFINE and HW_PARAMS. Must match Linux exactly (608 bytes on
64-bit, 604 bytes on 32-bit due to snd_pcm_uframes_t = unsigned long).
Size derivation (64-bit):
- flags: 4
- masks[3] + mres[5]: 8 × 32 = 256
- intervals[12] + ires[9]: 21 × 12 = 252
- rmask..rate_den: 6 × 4 = 24
- fifo_size: 8 (unsigned long on LP64)
- sync[16] + reserved[48]: 64
- Total: 608 (compile-time assert: assert!(size_of::<SndPcmHwParams>() == 608))
/// Linux ABI: include/uapi/sound/asound.h
#[cfg(target_pointer_width = "64")]
const _: () = assert!(core::mem::size_of::<SndPcmHwParams>() == 608);
#[cfg(target_pointer_width = "32")]
const _: () = assert!(core::mem::size_of::<SndPcmHwParams>() == 604);
// kernel-internal, not KABI
#[repr(C)]
pub struct SndPcmHwParams {
pub flags: u32, // 4 bytes
/// Bitmask parameters: ACCESS (0), FORMAT (1), SUBFORMAT (2).
/// Index = SNDRV_PCM_HW_PARAM_x - SNDRV_PCM_HW_PARAM_FIRST_MASK.
pub masks: [SndMask; 3], // 3 × 32 = 96 bytes
/// Reserved masks for future mask-type parameters.
pub mres: [SndMask; 5], // 5 × 32 = 160 bytes
/// Interval parameters: SAMPLE_BITS (8) through TICK_TIME (19).
/// Index = SNDRV_PCM_HW_PARAM_x - SNDRV_PCM_HW_PARAM_FIRST_INTERVAL.
pub intervals: [SndInterval; 12], // 12 × 12 = 144 bytes
/// Reserved intervals for future interval-type parameters.
pub ires: [SndInterval; 9], // 9 × 12 = 108 bytes
/// Request mask: which params the caller wants to set.
pub rmask: u32, // 4 bytes
/// Changed mask: which params were actually changed by REFINE.
pub cmask: u32, // 4 bytes
/// Info flags (SNDRV_PCM_INFO_*).
pub info: u32, // 4 bytes
/// Most significant bits of sample (for formats < 32 bits).
pub msbits: u32, // 4 bytes
/// Rate numerator (for exact rational rates).
pub rate_num: u32, // 4 bytes
/// Rate denominator.
pub rate_den: u32, // 4 bytes
/// Hardware FIFO size in frames.
pub fifo_size: usize, // snd_pcm_uframes_t (8 on LP64, 4 on ILP32)
/// Hardware synchronization ID (shared across linked streams).
pub sync: [u8; 16], // 16 bytes
pub _reserved: [u8; 48], // 48 bytes
}
/// Bitmask type for format/access/subformat masks (SNDRV_MASK_MAX = 256 bits).
/// Size: 32 bytes.
#[repr(C)]
pub struct SndMask {
pub bits: [u32; 8], // (SNDRV_MASK_MAX + 31) / 32 = 8
}
// SndMask: [u32;8] = 32 bytes. Userspace ABI sub-struct within SndPcmHwParams.
const_assert!(core::mem::size_of::<SndMask>() == 32);
/// Interval constraint: [min, max] with openmin/openmax/integer/empty bitflags.
/// Size: 12 bytes (NOT 16 — Linux uses C bitfields, not a separate flags word).
///
/// The bitfield word packs four single-bit flags into one u32:
/// bit 0: openmin (min is exclusive)
/// bit 1: openmax (max is exclusive)
/// bit 2: integer (only integer values allowed)
/// bit 3: empty (interval is empty / no valid values)
#[repr(C)]
pub struct SndInterval {
pub min: u32,
pub max: u32,
/// Packed bitflags: openmin(0), openmax(1), integer(2), empty(3).
/// Only the low 4 bits are meaningful; upper 28 bits are padding
/// (matching the C bitfield layout where the compiler packs four
/// 1-bit fields into a single 32-bit storage unit).
pub flags: u32,
}
// SndInterval: u32(4)*3 = 12 bytes. Userspace ABI sub-struct.
const_assert!(core::mem::size_of::<SndInterval>() == 12);
snd_pcm_sw_params ABI struct — software parameter control:
// Userspace ABI struct — copied to/from userspace via SNDRV_PCM_IOCTL_SW_PARAMS.
// Matches Linux `struct snd_pcm_sw_params` layout from `include/uapi/sound/asound.h`.
// The kernel MUST zero the struct before filling and copying to userspace to prevent
// information disclosure through implicit padding bytes.
#[repr(C)]
pub struct SndPcmSwParams {
pub tstamp_mode: i32, // SNDRV_PCM_TSTAMP_NONE/ENABLE
pub period_step: u32, // step between periods (usually 1)
pub sleep_min: u32, // deprecated, must be 0
// Explicit padding: `#[repr(C)]` alignment rules insert 4 bytes between
// `sleep_min` (u32, offset 12) and `avail_min` (usize, offset 16 on LP64).
// Making this explicit prevents information disclosure of uninitialized
// kernel memory when the struct is copied to userspace.
#[cfg(target_pointer_width = "64")]
pub _pad0: [u8; 4], // offset 12-15 (LP64 only)
pub avail_min: usize, // min frames avail before wakeup
pub xfer_align: usize, // deprecated, must be 0
pub start_threshold: usize, // frames written before auto-start
pub stop_threshold: usize, // frames available before auto-stop (xrun)
pub silence_threshold: usize, // silence frames threshold
pub silence_size: usize, // silence fill size
pub boundary: usize, // ring buffer boundary (buffer_size * n)
pub proto: u32, // protocol version
pub tstamp_type: u32, // SNDRV_PCM_TSTAMP_TYPE_*
pub _reserved: [u8; 56],
}
// SndPcmSwParams: Userspace ABI struct (SNDRV_PCM_IOCTL_SW_PARAMS).
// Seven usize fields: avail_min, xfer_align, start_threshold, stop_threshold,
// silence_threshold, silence_size, boundary.
// 64-bit: i32(4) + u32(4) + u32(4) + _pad0(4) + usize(8)*7 + u32(4) + u32(4) + [u8;56] = 136.
// 32-bit: i32(4) + u32(4) + u32(4) + usize(4)*7 + u32(4) + u32(4) + [u8;56] = 104.
#[cfg(target_pointer_width = "64")]
const _: () = assert!(core::mem::size_of::<SndPcmSwParams>() == 136);
#[cfg(target_pointer_width = "32")]
const _: () = assert!(core::mem::size_of::<SndPcmSwParams>() == 104);
21.4.10 Jack Detection¶
HDA codecs support unsolicited responses (jack detection events): when a
headphone is plugged/unplugged, the codec sends an event to the controller.
Delivery to userspace goes through the ALSA jack model — one Boolean,
read-only control element per detectable pin, with state changes posted as
SNDRV_CTL_EVENT_MASK_VALUE control events
(Section 21.4). This mirrors Linux
control creation/reporting behavior (verified
torvalds/linux at baseline fc02acf6ac0c 2026-07-09): the control lives on
SNDRV_CTL_ELEM_IFACE_CARD, its name carries the " Jack" suffix (e.g.
"Headphone Jack"), and reporting calls
snd_ctl_notify(card, SNDRV_CTL_EVENT_MASK_VALUE, &id). There is NO
system-event-bus involvement: the EventManager Event set
(Section 7.9) is reserved for system-management events (thermal,
power, block, driver recovery), and PipeWire already polls the control fd.
The D-Bus org.umkaos.Audio1.Jack.JackStateChanged signal is derived by the
bridge from the same control event
(Section 21.4).
/// `SndCtlElemId.iface` value for card-global controls (jack elements).
/// Linux `SNDRV_CTL_ELEM_IFACE_CARD = 0` (`include/uapi/sound/asound.h`,
/// verified torvalds/linux at baseline `fc02acf6ac0c` 2026-07-09).
pub const SNDRV_CTL_ELEM_IFACE_CARD: i32 = 0;
/// One jack-detection control. Created by the codec driver at init for every
/// presence-detect-capable pin (`HdaController::enable_jack_detect`); state
/// changes are reported with `snd_jack_report()`. The card's element
/// enumeration (`SNDRV_CTL_IOCTL_ELEM_LIST`/`ELEM_INFO`/`ELEM_READ`) serves
/// jack elements from the controller's `jacks` table alongside the mixer
/// elements: Boolean, read-only (`SNDRV_CTL_ELEM_ACCESS_READ`), value =
/// `connected`. Kernel-internal, not a wire/KABI struct.
pub struct SndJack {
/// Card index of the owning card (`/dev/snd/controlC<card_index>`).
pub card_index: u32,
/// The jack's control-element identity: `iface =
/// SNDRV_CTL_ELEM_IFACE_CARD`, name `"<function> Jack"` (NUL-padded),
/// `numid` assigned from the card's element-id counter when the element
/// is published.
pub elem_id: SndCtlElemId,
/// Sensed pin: HDA codec address (0-14) …
pub codec_addr: u8,
/// … and pin-widget NID. Used to address verbs to this pin (`GET_PIN_SENSE`,
/// `SET_UNSOLICITED_ENABLE`). NOT the demultiplexing key — an unsolicited
/// response does not carry the originating NID; see `unsol_tag`.
pub pin_nid: u8,
/// The unsolicited-response tag programmed into this pin, and the key the
/// interrupt handler demultiplexes on. HDA identifies the source of an
/// unsolicited response ONLY by the 6-bit tag the driver assigned when it
/// enabled the pin — the tag comes back in response bits [31:26]. Each
/// pin therefore needs a DISTINCT tag: with every pin sharing one tag,
/// events from the headphone and the speaker pin are indistinguishable and
/// headphone auto-switching cannot work. `MAX_HDA_JACKS` (32) is within
/// the 6-bit tag space (0-63), so the jack's index in `HdaController.jacks`
/// is used directly and uniqueness is structural.
pub unsol_tag: u8,
/// Current plug state (false = unplugged). Kernel-internal flag (not
/// KABI/wire, so `AtomicBool` is fine): written by the process-context
/// jack-sense work item with `swap(AcqRel)`, read by `ELEM_READ` with
/// `load(Acquire)`.
pub connected: AtomicBool,
}
/// Report a jack plug-state change after the process-context `GET_PIN_SENSE`
/// read. Stores the new state and — only when it actually changed — posts
/// `SNDRV_CTL_EVENT_MASK_VALUE` for the jack's element to all subscribed
/// control clients. The helper remains hard-IRQ safe (one atomic swap plus
/// enqueue into pre-allocated per-client queues), but the HDA unsolicited path
/// calls it from `HDA_JACK_SENSE_WQ` because the preceding verb round trip may
/// sleep ([Section 21.4](#audio-architecture--control-event-notification)).
///
/// **Adopted naming — the ALSA `snd_jack_report` / `snd_ctl_notify` helper
/// family and `SndJack` element vocabulary — NAME-ONLY.** Best design because
/// this ALSA control seam is contract-dense: `SNDRV_CTL_ELEM_IFACE_CARD`, the
/// `" Jack"` name suffix, and `SNDRV_CTL_EVENT_MASK_VALUE` delivery on the
/// control fd are observable ABI, while term-for-term naming anchors four
/// decades of ALSA driver literature and makes per-line contract verification
/// against Linux `sound/core/ctljack.c` and `sound/core/control.c` mechanical.
/// The mechanism is and stays UmkaOS-native: workqueue-deferred pin-sense
/// reads, `AtomicBool::swap` publication, and pre-allocated per-client event
/// queues. Linux `snd_kctl_jack_report()` and `snd_ctl_notify()` are reference,
/// never authority for that mechanism. Three axes: 50-year — these
/// literature-anchored internal names carry zero ABI weight and remain
/// renameable; performance — nil (names only); Linux — reference for the names
/// and the observable control contract, never for mechanism.
pub fn snd_jack_report(jack: &SndJack, connected: bool) {
let prev = jack.connected.swap(connected, Ordering::AcqRel);
if prev != connected {
snd_ctl_notify(jack.card_index, SNDRV_CTL_EVENT_MASK_VALUE, &jack.elem_id);
}
}
/// Named process-context queue for HDA pin-sense reads (`umkad-hda-jack-N`,
/// one worker set per NUMA node). Initialized with the standard workqueue
/// registry during audio-core init.
pub static HDA_JACK_SENSE_WQ: BootOnceCell<Arc<WorkQueue>> = BootOnceCell::new();
/// Deferred half of unsolicited jack detection. `data` points at the
/// address-stable `Arc<HdaController>` whose embedded `DelayedWork` queued this
/// call. A failed verb read leaves the published jack state unchanged and
/// re-arms the same preallocated work after one verb deadline; no false
/// unplug event is synthesized.
fn hda_jack_sense_work(data: *mut ()) {
// SAFETY: controller teardown cancels jack_sense_work and flushes the named
// queue before the last owning Arc drops.
let controller = unsafe { &*(data as *const HdaController) };
let mut pending = controller.jack_sense_pending.swap(0, Ordering::AcqRel);
let mut retry = 0u32;
while pending != 0 {
let index = pending.trailing_zeros() as usize;
pending &= pending - 1;
let Some(jack) = controller.jacks.get(index) else {
continue;
};
match controller.send_verb(
jack.codec_addr,
jack.pin_nid,
VERB_GET_PIN_SENSE,
) {
Ok(pin_sense) => {
let connected = (pin_sense & PIN_SENSE_PRESENCE_DETECT) != 0;
snd_jack_report(jack, connected);
}
Err(_) => {
// `send_verb` has armed/resolved its CORB/RIRB resync path.
// Preserve the last known jack state and retry outside IRQ.
retry |= 1u32 << index;
}
}
}
if retry != 0 {
controller.jack_sense_pending.fetch_or(retry, Ordering::Release);
let _ = HDA_JACK_SENSE_WQ
.get()
.expect("HDA jack-sense workqueue initialized")
.queue_delayed_work(
&controller.jack_sense_work,
Duration::from_nanos(HDA_VERB_TIMEOUT_NS),
);
}
}
impl HdaController {
/// Enable unsolicited responses on a pin widget and create its jack
/// control (`"<name> Jack"`). Codec-init path (`&mut self`: runs before
/// the controller is published to the interrupt path).
pub fn enable_jack_detect(&mut self, codec_addr: u8, pin_nid: u8, name: &str)
-> Result<(), HdaError>
{
let mut elem_id = SndCtlElemId {
numid: 0, // assigned by the control core when the element is published
iface: SNDRV_CTL_ELEM_IFACE_CARD,
device: 0,
subdevice: 0,
name: [0; 44],
index: 0,
};
// "<name> Jack", NUL-padded (the ctljack naming convention above).
// Truncation bound: 38 name bytes + 5 for " Jack" ≤ 43, keeping at
// least one trailing NUL in the 44-byte field.
let src = name.as_bytes();
let mut n = 0;
while n < src.len() && n < 38 {
elem_id.name[n] = src[n];
n += 1;
}
for &b in b" Jack".iter() {
elem_id.name[n] = b;
n += 1;
}
// The jack's index in the table IS its unsolicited-response tag. The
// table is append-only for the controller's lifetime and capped at
// MAX_HDA_JACKS = 32, inside the 6-bit tag space, so every enabled pin
// gets a distinct tag and the handler can tell the pins apart.
let tag = self.jacks.len() as u8;
debug_assert!((tag as usize) < MAX_HDA_JACKS);
self.jacks.try_push(SndJack {
card_index: self.card_index,
elem_id,
codec_addr,
pin_nid,
unsol_tag: tag,
connected: AtomicBool::new(false),
}).map_err(|_| HdaError::JackTableFull)?;
// Send SET_UNSOLICITED_ENABLE verb to pin widget.
// SET_UNSOLICITED_ENABLE (verb 0x708): bit 7 = enable, bits [5:0] = tag
// (bit 6 reserved). The tag is the ONLY identity the codec echoes back
// in the unsolicited response, so it must be the per-pin value above,
// never a constant.
// NID is encoded by send_verb() into CORB bits [27:20]; do NOT embed it in the verb payload.
let verb = VERB_SET_UNSOLICITED_ENABLE
| (1 << 7) // enable = 1
| (tag as u32 & UNSOL_TAG_MASK); // bits [5:0] = this pin's tag
self.send_verb(codec_addr, pin_nid, verb)?;
Ok(())
}
/// Handle unsolicited response interrupt (jack detection event).
/// Hard-IRQ path: demultiplex the tag, set one pending bit, and arm
/// preallocated work — no verb round-trip, no sleeping, no allocation.
/// `codec_addr` comes from the RIRB entry's `response_ex` low nibble;
/// `response` is the 32-bit payload.
pub fn handle_unsolicited_response(&self, codec_addr: u8, response: u32) {
// Demultiplex on the TAG, which is the only source identity an
// unsolicited response carries (bits [31:26], the value programmed by
// `enable_jack_detect`). The response does NOT contain the originating
// pin NID — deriving a "pin" from other response bits yields a
// fabricated value that matches the wrong jack or no jack at all.
let tag = ((response >> UNSOL_TAG_SHIFT) & UNSOL_TAG_MASK) as u8;
// The tag IS the append-only jack-table index. `codec_addr` is a
// consistency check because tags are unique across this controller.
let Some(jack) = self.jacks.get(tag as usize) else {
return;
};
if jack.codec_addr != codec_addr || jack.unsol_tag != tag {
return;
}
// The unsolicited payload has NO presence bit. Defer the
// GET_PIN_SENSE verb to process context; DelayedWork coalesces repeated
// interrupts for the same pin and retries workqueue backpressure.
self.jack_sense_pending
.fetch_or(1u32 << tag, Ordering::Release);
let _ = HDA_JACK_SENSE_WQ
.get()
.expect("HDA jack-sense workqueue initialized")
.queue_delayed_work(&self.jack_sense_work, Duration::ZERO);
}
}
Audio routing policy: Audio routing policy (default device selection, per-app routing, volume control) is handled by PipeWire in userspace. Kernel provides DMA ring buffers and jack detection events.
21.4.11 Architectural Decision¶
Audio: Native UmkaOS framework + ALSA compat
Kernel provides native PCM interface with clean ABI. umka-sysapi translates ALSA ioctls to native calls, enabling existing applications (PipeWire, PulseAudio, JACK) to work unmodified. Best of both worlds: clean kernel API, full userspace compatibility.
21.4.12 ALSA MIDI Sequencer¶
The ALSA sequencer provides a kernel-internal MIDI event bus. Applications connect ports and route MIDI events between synthesizers, hardware MIDI interfaces, and software instruments. It is distinct from raw MIDI device I/O (which goes through /dev/midiC0D0 raw devices).
21.4.12.1 Architecture¶
┌────────────────────────────────────────────────────────┐
│ snd_seq Core │
│ │
│ Clients: [app A] [app B] [snd_seq_dummy] [hw] │
│ │ │ │ │ │
│ Ports: [128:0] [129:0] [14:0] [20:0] │
│ │ │ │ │ │
│ Subscriptions (routing graph — many-to-many) │
│ └────────┴───────────┘────────────┘ │
│ Queues: [Q0: real-time] [Q1: MIDI tick-based] │
│ │ │ │
│ Timer: HrTimer (CLOCK_MONOTONIC) │
└────────────────────────────────────────────────────────┘
↕ /dev/snd/seq
21.4.12.2 Data Structures¶
/// Maximum MIDI ports per sequencer client.
/// Ports 0-191: user-space clients. Ports 192-255: kernel/system clients.
pub const SEQ_MAX_PORTS_PER_CLIENT: usize = 256;
/// MIDI event FIFO depth per sequencer client output queue.
/// 256 events × ~28 bytes each ≈ 7 KB per client — fixed, no heap allocation.
pub const SEQ_CLIENT_FIFO_DEPTH: usize = 256;
/// ALSA sequencer client (one per application or hardware source).
///
/// The `InlineBoundedRing<T, N>` bounded ring type is defined in
/// [Section 3.13](03-concurrency.md#collection-usage-policy--caller-synchronized-bounded-rings). Held
/// under a `Mutex` here because MIDI events are pushed from multiple contexts.
pub struct SeqClient {
/// Client number (0-191 = user clients; 192-255 = kernel clients).
pub client_id: u8,
/// Client type.
pub type_: SeqClientType,
/// Client name (for display in aconnect etc.).
pub name: [u8; 64],
/// Port table indexed directly by port ID (0-255). O(1) access by port_id.
/// Option<Arc> allows sparse allocation — clients need not use all 256 ports.
/// **Size**: 256 × 8 bytes = 2048 bytes inline. This is acceptable because
/// SeqClient is heap-allocated (Arc<SeqClient>) — it is NOT a stack variable.
/// The inline array avoids a second heap allocation and pointer indirection
/// on every port lookup (hot path for MIDI event routing).
pub ports: [Option<Arc<SeqPort>>; SEQ_MAX_PORTS_PER_CLIENT],
/// Output event ring buffer (kernel→client direction). Fixed-size, no heap allocation.
/// When full, `push_back` returns `Err(event)`: the new event is dropped and
/// `lost` is incremented. **Contractual lossy event stream, collection-policy role (d)**
/// ([Section 3.13](03-concurrency.md#collection-usage-policy--compile-time-capacities-scratch-hints-and-validated-bounds-never-ownership)):
/// the drop policy is NEWEST-rejected — the incoming event is refused and no
/// queued event is ever displaced — matching the Linux ALSA sequencer contract
/// (`snd_seq_fifo_event_in` rejects the incoming event and counts it in the
/// fifo overflow counter; queued events are never evicted). Loss is
/// client-observable via the `lost` counter below — the analog of ALSA's
/// `event_lost` reported through `SNDRV_SEQ_IOCTL_GET_CLIENT_INFO` — which is
/// this stream's documented loss-detection mechanism (role (d) requirements 1-2).
pub fifo: Mutex<InlineBoundedRing<SeqEvent, SEQ_CLIENT_FIFO_DEPTH>>,
/// Count of dropped events due to full FIFO. Monotonically increasing.
pub lost: AtomicU64,
}
pub enum SeqClientType {
/// Kernel client (e.g., hardware MIDI driver, snd_seq_dummy).
Kernel,
/// Userspace application connected via /dev/snd/seq.
User,
}
/// ALSA sequencer port.
/// A subscription connecting a sender port to a receiver port.
/// Created by `SNDRV_SEQ_IOCTL_SUBSCRIBE_PORT`.
pub struct SeqSubscription {
/// Sender port address (client_id, port_id).
pub sender: SeqAddr,
/// Destination port address.
pub dest: SeqAddr,
/// Subscription flags (e.g., exclusive, timestamp).
pub flags: u32,
}
/// Sender/destination address for ALSA sequencer subscriptions.
pub struct SeqAddr {
pub client_id: u8,
pub port_id: u8,
}
/// Maximum subscriptions per sequencer port direction (read or write).
/// 64 is sufficient because: ALSA sequencer ports in practice have ≤10
/// subscriptions (typically 1-3 for a MIDI instrument chain). The bound
/// prevents unbounded heap allocation under the RwLock — subscription
/// add/remove is a warm path (user-initiated connect/disconnect), not a
/// hot path, so the ArrayVec overhead is negligible. If a client attempts
/// to exceed 64 subscriptions on a single port, `snd_seq_subscribe_port()`
/// returns `-ENOSPC`.
pub const MAX_SUBS_PER_PORT: usize = 64;
pub struct SeqPort {
pub port_id: u8,
pub client_id: u8,
pub name: [u8; 64],
/// Port capability flags.
pub capability: SeqPortCapability,
/// Port type flags.
pub type_: SeqPortType,
/// Subscriber list: ports that send TO this port (WRITE direction).
pub write_subs: RwLock<ArrayVec<SeqSubscription, MAX_SUBS_PER_PORT>>,
/// Subscriber list: ports this port sends TO (READ direction).
pub read_subs: RwLock<ArrayVec<SeqSubscription, MAX_SUBS_PER_PORT>>,
/// Per-port kernel client callback (for kernel clients).
pub kernel_fn: Option<fn(port: &SeqPort, event: &SeqEvent)>,
}
bitflags! {
pub struct SeqPortCapability: u32 {
const READ = 1 << 0; // Other ports may receive from this port
const WRITE = 1 << 1; // Other ports may send to this port
const SYNC_READ = 1 << 2; // Obsolete
const SYNC_WRITE = 1 << 3; // Obsolete
const DUPLEX = 1 << 4; // Full-duplex port
const SUBS_READ = 1 << 5; // Subscription list readable by other clients
const SUBS_WRITE = 1 << 6; // Subscription list writable by other clients
const NO_EXPORT = 1 << 7; // Do not export this port via ANNOUNCE
}
}
bitflags! {
pub struct SeqPortType: u32 {
const SPECIFIC = 1 << 0; // Hardware-specific (not a standard MIDI port)
const MIDI_GENERIC = 1 << 1; // Standard MIDI port
const MIDI_GM = 1 << 2; // General MIDI compatible
const MIDI_GS = 1 << 3; // Roland GS compatible
const MIDI_XG = 1 << 4; // Yamaha XG compatible
const MIDI_MT32 = 1 << 5; // Roland MT-32 compatible
const MIDI_GM2 = 1 << 6; // General MIDI 2 compatible
const SYNTH = 1 << 10; // Software synthesizer
const DIRECT_SAMPLE = 1 << 11; // Sampling synthesizer
const SAMPLE = 1 << 12; // Sample player
const HARDWARE = 1 << 16; // Hardware port (MIDI interface)
const SOFTWARE = 1 << 17; // Software port (application)
const SYNTHESIZER = 1 << 18; // Synthesizer
const PORT = 1 << 19; // Port connector (MIDI port on a hardware device)
const APPLICATION = 1 << 20; // Application (sequencer, arpeggiator, etc.)
}
}
21.4.12.3 MIDI Event¶
/// ALSA sequencer event (matches struct snd_seq_event, 28 bytes).
#[repr(C)]
pub struct SeqEvent {
/// Event type (see SeqEventType enum).
pub type_: u8,
/// Flags: timestamp format, data format.
pub flags: u8,
/// Tag (for application use).
pub tag: u8,
/// Queue ID (for scheduled events; SNDRV_SEQ_QUEUE_DIRECT = 253 for immediate).
pub queue: u8,
/// Timestamp (union: tick or real-time depending on flags).
pub time: SeqTimestamp,
/// Source port (client_id, port_id).
pub source: SeqAddr,
/// Destination port (client_id, port_id; SNDRV_SEQ_ADDRESS_BROADCAST = 253 for all subscribers).
pub dest: SeqAddr,
/// Event data (union of MIDI event types).
pub data: SeqEventData,
}
const_assert!(core::mem::size_of::<SeqEvent>() == 28);
/// Timestamp union (8 bytes).
pub union SeqTimestamp {
/// MIDI tick timestamp (SNDRV_SEQ_TIME_STAMP_TICK flag).
pub tick: u32,
/// Real-time timestamp (SNDRV_SEQ_TIME_STAMP_REAL flag).
pub time: SeqRealTime,
}
#[repr(C)]
pub struct SeqRealTime {
pub tv_sec: u32,
pub tv_nsec: u32,
}
// SeqRealTime: u32(4)*2 = 8 bytes. Userspace ABI sub-struct.
const_assert!(core::mem::size_of::<SeqRealTime>() == 8);
/// Sequencer event data union (12 bytes, matching Linux `union snd_seq_event_data`).
/// All variants are exactly 12 bytes or smaller (padded to 12 by the union).
/// The active variant is determined by `SeqEvent.type_`.
#[repr(C)]
pub union SeqEventData {
/// Note events: NOTE_ON, NOTE_OFF, KEY_PRESSURE, NOTE.
pub note: SeqEvNote, // 8 bytes (padded to 12 by union)
/// Control events: CONTROLLER, PGMCHANGE, PITCHBEND, CHANPRESS.
pub control: SeqEvCtrl, // 12 bytes
/// Raw 8-bit data (inline SYSEX, up to 12 bytes).
pub raw8: SeqEvRaw8, // 12 bytes
/// Raw 32-bit data.
pub raw32: SeqEvRaw32, // 12 bytes
/// Extended data pointer (SYSEX > 12 bytes, bounce-buffered events).
pub ext: SeqEvExt, // 12 bytes (packed: len(4) + addr(8))
/// Queue control: start/stop/tempo/position.
pub queue: SeqEvQueue, // 12 bytes
/// Address (for announce events: CLIENT_START, PORT_START, etc.).
pub addr: SeqAddr, // 2 bytes (padded to 12 by union)
/// Port subscription: PORT_SUBSCRIBED, PORT_UNSUBSCRIBED.
pub connect: SeqEvConnect, // 4 bytes (padded to 12 by union)
/// Result/echo: ECHO, OSS, RESULT events.
pub result: SeqEvResult, // 8 bytes (padded to 12 by union)
}
const_assert!(core::mem::size_of::<SeqEventData>() == 12);
/// Note event data (8 bytes; union pads to 12).
/// Matches Linux `struct snd_seq_ev_note`.
#[repr(C)]
pub struct SeqEvNote {
/// MIDI channel (0-15).
pub channel: u8,
/// Note number (0-127).
pub note: u8,
/// Velocity (0-127; NOTE_OFF: release velocity).
pub velocity: u8,
/// Off-velocity (for compound NOTE event; ignored for NOTE_ON/NOTE_OFF).
pub off_velocity: u8,
/// Duration in ticks (for compound NOTE event; ignored for NOTE_ON/NOTE_OFF).
pub duration: u32,
}
// SeqEvNote: u8(1)*4 + u32(4) = 8 bytes. Userspace ABI sub-struct.
const_assert!(core::mem::size_of::<SeqEvNote>() == 8);
/// Control change event data (12 bytes).
/// Matches Linux `struct snd_seq_ev_ctrl`.
#[repr(C)]
pub struct SeqEvCtrl {
/// MIDI channel (0-15).
pub channel: u8,
/// Reserved padding (3 bytes).
pub _pad: [u8; 3],
/// Controller number (CC# for CONTROLLER), program number (PGMCHANGE), etc.
pub param: u32,
/// Controller value; pitch bend range is -8192..+8191.
pub value: i32,
}
// SeqEvCtrl: u8(1) + [u8;3](3) + u32(4) + i32(4) = 12 bytes. Userspace ABI sub-struct.
const_assert!(core::mem::size_of::<SeqEvCtrl>() == 12);
/// Raw 8-bit event data (12 bytes).
/// Matches Linux `struct snd_seq_ev_raw8`.
#[repr(C)]
pub struct SeqEvRaw8 {
/// Raw byte data.
pub d: [u8; 12],
}
// SeqEvRaw8: [u8;12] = 12 bytes. Userspace ABI sub-struct.
const_assert!(core::mem::size_of::<SeqEvRaw8>() == 12);
/// Raw 32-bit event data (12 bytes).
/// Matches Linux `struct snd_seq_ev_raw32`.
#[repr(C)]
pub struct SeqEvRaw32 {
/// Raw 32-bit words.
pub d: [u32; 3],
}
// SeqEvRaw32: [u32;3] = 12 bytes. Userspace ABI sub-struct.
const_assert!(core::mem::size_of::<SeqEvRaw32>() == 12);
/// Extended event data pointer (12 bytes, packed).
/// Matches Linux `struct snd_seq_ev_ext` (`__attribute__((packed))`).
/// Used for SYSEX messages and other variable-length data that exceeds
/// the 12-byte inline limit. The kernel copies the extended data into a
/// bounce buffer; `addr` points to kernel memory (not directly to userspace).
// kernel-internal, not KABI
#[repr(C, packed)]
pub struct SeqEvExt {
/// Length of extended data in bytes.
pub len: u32,
/// Address of extended data buffer (kernel address).
/// Uses usize to match Linux `void *ptr` — 8 bytes on 64-bit, 4 bytes
/// on 32-bit. SeqEvExt is 12 bytes on 64-bit (4+8), 8 bytes on 32-bit
/// (4+4); the union is always 12 bytes from other larger variants.
pub addr: usize,
}
// SeqEvExt (packed): u32(4) + usize. 64-bit: 12 bytes; 32-bit: 8 bytes.
// Userspace ABI sub-struct (Linux __attribute__((packed))).
#[cfg(target_pointer_width = "64")]
const _: () = assert!(core::mem::size_of::<SeqEvExt>() == 12);
#[cfg(target_pointer_width = "32")]
const _: () = assert!(core::mem::size_of::<SeqEvExt>() == 8);
/// Queue control event data (12 bytes).
/// Matches Linux `struct snd_seq_ev_queue_control`.
/// Used for queue start/stop/continue, tempo changes, and position updates.
#[repr(C)]
pub struct SeqEvQueue {
/// Affected queue ID.
pub queue: u8,
/// Reserved padding.
pub _pad: [u8; 3],
/// Parameter value union (8 bytes). Interpretation depends on event type:
/// - TEMPO: `value` = microseconds per quarter note
/// - SETPOS_TICK: `position` = tick position
/// - SETPOS_TIME: `time` = real-time position
/// - QUEUE_SKEW: `skew` = skew value/base pair
pub param: SeqQueueParam,
}
// SeqEvQueue: u8(1) + [u8;3](3) + SeqQueueParam(8) = 12 bytes.
// Userspace ABI sub-struct.
const_assert!(core::mem::size_of::<SeqEvQueue>() == 12);
/// Queue control parameter union (8 bytes).
/// Matches the inner union of Linux `struct snd_seq_ev_queue_control.param`.
#[repr(C)]
pub union SeqQueueParam {
/// Affected value (e.g., tempo in microseconds per quarter note).
pub value: i32,
/// Timestamp (for position set operations).
pub time: SeqTimestamp,
/// Sync position in ticks.
pub position: u32,
/// Queue skew (value/base pair for tempo scaling).
pub skew: SeqQueueSkew,
/// Raw access (two 32-bit words).
pub d32: [u32; 2],
/// Raw access (eight bytes).
pub d8: [u8; 8],
}
/// Queue skew parameters (8 bytes).
/// Matches Linux `struct snd_seq_queue_skew`.
#[repr(C)]
pub struct SeqQueueSkew {
/// Skew numerator.
pub value: u32,
/// Skew denominator.
pub base: u32,
}
// SeqQueueSkew: u32(4)*2 = 8 bytes. Userspace ABI sub-struct.
const_assert!(core::mem::size_of::<SeqQueueSkew>() == 8);
/// Port connection event data (4 bytes; union pads to 12).
/// Matches Linux `struct snd_seq_connect`.
/// Used for PORT_SUBSCRIBED and PORT_UNSUBSCRIBED events.
#[repr(C)]
pub struct SeqEvConnect {
/// Sender address (client, port).
pub sender: SeqAddr,
/// Destination address (client, port).
pub dest: SeqAddr,
}
// SeqEvConnect: SeqAddr(2)*2 = 4 bytes. Userspace ABI sub-struct.
const_assert!(core::mem::size_of::<SeqEvConnect>() == 4);
/// Result/echo event data (8 bytes; union pads to 12).
/// Matches Linux `struct snd_seq_result`.
/// Used for ECHO (loopback timing measurement) and RESULT (operation outcome) events.
#[repr(C)]
pub struct SeqEvResult {
/// Processed event type (the original event type that produced this result).
pub event: i32,
/// Result code (0 = success, negative = error).
pub result: i32,
}
// SeqEvResult: i32(4)*2 = 8 bytes. Userspace ABI sub-struct.
const_assert!(core::mem::size_of::<SeqEvResult>() == 8);
21.4.12.4 Event Types¶
Key event types (SNDRV_SEQ_EVENT_*):
| Type | Value | Description |
|---|---|---|
| NOTE_ON | 6 | Note On (channel, note, velocity) |
| NOTE_OFF | 7 | Note Off (channel, note, velocity) |
| KEYPRESS | 8 | Key Pressure / Aftertouch |
| CONTROLLER | 10 | Control Change (CC# 0-127) |
| PGMCHANGE | 11 | Program Change |
| CHANPRESS | 12 | Channel Pressure |
| PITCHBEND | 13 | Pitch Bend (±8192) |
| QFRAME | 22 | MIDI Quarter Frame (MTC) |
| SONGPOS | 20 | Song Position Pointer |
| SONGSEL | 21 | Song Select |
| START | 30 | MIDI Start |
| CONTINUE | 31 | MIDI Continue |
| STOP | 32 | MIDI Stop |
| CLOCK | 36 | MIDI Clock |
| RESET | 41 | Reset to power-on state |
| SENSING | 42 | Active Sensing |
| ECHO | 50 | Echo back to sender (for timing measurement) |
| SYSEX | 130 | System Exclusive (extended data format) |
| PORT_SUBSCRIBED | 66 | Port subscription created |
| PORT_UNSUBSCRIBED | 67 | Port subscription deleted |
21.4.12.5 Queues and Timers¶
/// A scheduled event in a sequencer queue's min-heap. Ordered by timestamp
/// so the queue can dispatch the earliest event first.
pub struct ScheduledEvent {
/// Delivery timestamp (ticks or nanoseconds depending on queue mode).
pub timestamp: u64,
/// The sequencer event payload.
pub event: SeqEvent,
/// Destination port address for delivery.
pub dest: SeqAddr,
}
impl PartialOrd for ScheduledEvent {
fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
// Min-heap ordering: earlier timestamps sort first.
other.timestamp.partial_cmp(&self.timestamp)
}
}
/// Maximum scheduled events per sequencer queue. MIDI workloads rarely
/// schedule more than a few hundred events ahead; 1024 is generous.
/// Events beyond this limit are dropped with an ENOMEM error to the client.
pub const SEQ_QUEUE_MAX_EVENTS: usize = 1024;
/// Sequencer queue (schedules events for future delivery).
pub struct SeqQueue {
pub queue_id: u8,
/// Queue owner client (only owner can start/stop/set tempo).
pub owner: u8,
/// Running state.
pub running: AtomicBool,
/// Tempo in microseconds per quarter note (default 500000 = 120 BPM).
pub tempo_us: AtomicU32,
/// Time signature numerator.
pub ppq: u32, // Pulses Per Quarter note (default 96)
/// Current position in ticks.
pub tick: AtomicU64,
/// Current real-time position.
pub real_time: AtomicU64, // nanoseconds
/// Scheduled event min-heap (sorted by timestamp).
///
/// Uses a fixed-capacity `ArrayVec` backing to avoid heap allocation on
/// the event push path. `BinaryHeap` allocates from the heap on every
/// `push()` that triggers a grow, which is unacceptable under `Mutex`
/// on the sequencer's warm path. The `ArrayVec` is sorted manually:
/// insert via binary search + shift, extract-min from position 0.
/// The capacity `SEQ_QUEUE_MAX_EVENTS` (1024) bounds memory to
/// ~56 KiB per queue (56 bytes per `ScheduledEvent`).
// O(N) insert/extract is acceptable for MIDI event rates (~1K events/sec).
// A custom binary min-heap on ArrayVec backing would give O(log N) but
// adds complexity for negligible gain at this scale.
pub events: Mutex<ArrayVec<ScheduledEvent, SEQ_QUEUE_MAX_EVENTS>>,
/// hrtimer for the next scheduled event. Fires in **hard-IRQ** context, where
/// the sleeping `events` Mutex may NOT be taken — the expiry callback only
/// kicks deferred work (see "Deferred sequencer dispatch" below).
pub timer: HrTimer,
}
Deferred sequencer dispatch (hard-IRQ → workqueue). timer's expiry
callback runs in hard-IRQ context (the HrTimerExpiryFn contract,
Section 7.8), so it cannot take the sleeping events
Mutex, cannot deliver MIDI (subscriber FIFOs may block), and cannot re-arm
under that lock. It therefore does the minimum: recover the SeqQueue by
container_of (the queue is Arc-held, address-stable) and enqueue one drain work
item on the named sequencer dispatch workqueue (umkad-seq-N,
Section 3.11). All event work — draining due events under the
Mutex, MIDI delivery, and re-arming timer at the next event's absolute
timestamp — runs in the work item, which is process context and may sleep:
fn seq_queue_timer_fire(t: &HrTimer) { // hard-IRQ: NO Mutex, NO delivery
let q = container_of!(t, SeqQueue, timer); // SeqQueue is Arc-held
// Enqueue a POD WorkItem (no allocation) on the sequencer dispatch queue.
let _ = SEQ_DISPATCH_WQ.queue_work(
WorkItem::new(seq_queue_dispatch, (q as *const SeqQueue) as *mut (), NO_DEADLINE));
}
fn seq_queue_dispatch(data: *mut ()) { // workqueue: process ctx, may sleep
let q = unsafe { &*(data as *const SeqQueue) };
let mut ev = q.events.lock(); // sleeping Mutex — legal here
let now = q.queue_clock_now(); // tick or real_time per queue mode
while let Some(head) = ev.first() { // extract-min at position 0
if head.timestamp > now { break; }
let due = ev.remove(0);
deliver_seq_event(q, due); // may block on a subscriber FIFO
}
if let Some(next) = ev.first() {
q.timer.rearm(next.timestamp); // absolute re-arm — drift-free
}
}
Converting events to a SpinLock so the hard-IRQ callback could drain inline
is rejected: extract-min at position 0 shifts up to
SEQ_QUEUE_MAX_EVENTS - 1 (1023) × 56-byte entries, and that bounded-but-heavy
memmove inside hard-IRQ would violate the completion-context latency doctrine
(Section 3.8). The workqueue hop keeps the hard-IRQ path O(1).
Queue operations via ioctl SNDRV_SEQ_IOCTL_START_QUEUE, SNDRV_SEQ_IOCTL_STOP_QUEUE, SNDRV_SEQ_IOCTL_CONTINUE_QUEUE. Tempo change via SNDRV_SEQ_IOCTL_SET_QUEUE_TEMPO.
21.4.12.6 /dev/snd/seq Interface¶
ioctls on /dev/snd/seq (one fd per client):
| ioctl | Description |
|---|---|
SNDRV_SEQ_IOCTL_PVERSION |
Get sequencer version |
SNDRV_SEQ_IOCTL_CLIENT_ID |
Get caller's client ID |
SNDRV_SEQ_IOCTL_SYSTEM_INFO |
Get max_queues, max_clients, max_ports, max_channels |
SNDRV_SEQ_IOCTL_CREATE_PORT |
Create a new port |
SNDRV_SEQ_IOCTL_DELETE_PORT |
Delete a port |
SNDRV_SEQ_IOCTL_GET_PORT_INFO |
Get port info (name, capability, type) |
SNDRV_SEQ_IOCTL_SET_PORT_INFO |
Set port info |
SNDRV_SEQ_IOCTL_SUBSCRIBE_PORT |
Create subscription (routing) |
SNDRV_SEQ_IOCTL_UNSUBSCRIBE_PORT |
Remove subscription |
SNDRV_SEQ_IOCTL_CREATE_QUEUE |
Create event queue |
SNDRV_SEQ_IOCTL_DELETE_QUEUE |
Delete queue |
SNDRV_SEQ_IOCTL_GET_QUEUE_STATUS |
Get queue running state |
SNDRV_SEQ_IOCTL_GET_QUEUE_TEMPO |
Get BPM/PPQ |
SNDRV_SEQ_IOCTL_SET_QUEUE_TEMPO |
Set BPM/PPQ |
SNDRV_SEQ_IOCTL_START_QUEUE |
Start queue timer |
SNDRV_SEQ_IOCTL_STOP_QUEUE |
Stop queue timer |
SNDRV_SEQ_IOCTL_CONTINUE_QUEUE |
Continue queue from pause |
SNDRV_SEQ_IOCTL_RUNNING_MODE |
Toggle real-time vs tick scheduling |
SNDRV_SEQ_IOCTL_GET_CLIENT_INFO |
Get client metadata |
SNDRV_SEQ_IOCTL_SET_CLIENT_INFO |
Set client name etc. |
Read/write on the fd: each read() returns one or more SeqEvent structs; write() sends events to destination ports immediately (queue=SNDRV_SEQ_QUEUE_DIRECT) or schedules them (queue=Q0/Q1 with timestamp). O_NONBLOCK supported.
21.4.12.7 snd_seq_dummy — Loopback Client¶
snd_seq_dummy creates one kernel client (client 14, "Midi Through") with two ports: port 0 (writable by apps, readable by output devices) and port 1 (reverse). All events written to port 0 are echoed back to all subscribers of port 0. This provides a software MIDI loopback for virtual instruments.
21.4.12.8 Linux Compatibility¶
/dev/snd/seqcharacter device (major 116, minor 1): same as Linux ALSA- ioctl codes identical to Linux ALSA
sound/asound.h - struct
snd_seq_eventbinary layout identical aconnect(1),aplaymidi(1),aseqdump(1)work without modification- JACK and PipeWire MIDI ports connect via
snd_seq(JACK usesseq_midi_eventtranslation) - Timidity++, FluidSynth, and other software synthesizers use
/dev/snd/seqdirectly
21.4.13 ALSA Timer Interface¶
The ALSA timer interface (/dev/snd/timer, major 116, minor 33) provides
high-resolution timer services to userspace audio applications. Required by
MIDI sequencer clients for tempo-accurate event scheduling and by some
audio frameworks for synchronized clock sources.
Ioctls (magic 'T', matching Linux include/uapi/sound/asound.h):
| Ioctl | Nr | Direction | Description |
|---|---|---|---|
SNDRV_TIMER_IOCTL_PVERSION |
0x00 | R | Protocol version (u32) |
SNDRV_TIMER_IOCTL_NEXT_DEVICE |
0x01 | RW | Enumerate next timer device |
SNDRV_TIMER_IOCTL_GINFO |
0x03 | RW | Get timer general info |
SNDRV_TIMER_IOCTL_GPARAMS |
0x04 | W | Set timer general parameters |
SNDRV_TIMER_IOCTL_GSTATUS |
0x05 | RW | Get timer general status |
SNDRV_TIMER_IOCTL_SELECT |
0x10 | W | Select timer by ID |
SNDRV_TIMER_IOCTL_INFO |
0x11 | R | Get selected timer info |
SNDRV_TIMER_IOCTL_PARAMS |
0x12 | W | Set selected timer parameters |
SNDRV_TIMER_IOCTL_STATUS |
0x14 | R | Get selected timer status (64-bit) |
SNDRV_TIMER_IOCTL_START |
0xA0 | None | Start selected timer |
SNDRV_TIMER_IOCTL_STOP |
0xA1 | None | Stop selected timer |
SNDRV_TIMER_IOCTL_CONTINUE |
0xA2 | None | Continue (unpause) timer |
SNDRV_TIMER_IOCTL_PAUSE |
0xA3 | None | Pause timer |
/// Timer device identifier. Matches Linux `struct snd_timer_id`.
#[repr(C)]
pub struct SndTimerId {
/// Device class (SNDRV_TIMER_CLASS_*).
pub dev_class: i32,
/// Device subclass (SNDRV_TIMER_SCLASS_*).
pub dev_sclass: i32,
/// Card number (-1 for global timers).
pub card: i32,
/// Device number (timer index within card).
pub device: i32,
/// Subdevice number.
pub subdevice: i32,
}
// SndTimerId: i32(4)*5 = 20 bytes. Userspace ABI struct (ALSA timer ioctl).
const_assert!(core::mem::size_of::<SndTimerId>() == 20);
/// Timer general info. Matches Linux `struct snd_timer_ginfo`.
// kernel-internal, not KABI
#[repr(C)]
pub struct SndTimerGinfo {
/// Timer identifier (input).
pub tid: SndTimerId,
/// Timer flags (output).
pub flags: u32,
/// Card number (output).
pub card: i32,
/// Timer ID string (output, NUL-terminated).
pub id: [u8; 64],
/// Timer name string (output, NUL-terminated).
pub name: [u8; 80],
/// Reserved.
pub reserved0: u64,
/// Resolution in nanoseconds (output).
pub resolution: u64,
/// Minimum resolution in nanoseconds (output).
pub resolution_min: u64,
/// Maximum resolution in nanoseconds (output).
pub resolution_max: u64,
/// Number of active clients (output).
pub clients: u32,
/// Reserved for future use.
pub reserved: [u8; 32],
}
// SndTimerGinfo: SndTimerId(20) + u32(4) + i32(4) + [u8;64] + [u8;80] + 4pad
// + u64(8)*4 + u32(4) + [u8;32] + 4pad_trailing = 248 bytes on 64-bit.
// Userspace ABI struct (SNDRV_TIMER_IOCTL_GINFO).
#[cfg(target_pointer_width = "64")]
const _: () = assert!(core::mem::size_of::<SndTimerGinfo>() == 248);
/// Timer device class constants.
pub const SNDRV_TIMER_CLASS_NONE: i32 = -1;
pub const SNDRV_TIMER_CLASS_SLAVE: i32 = 0;
pub const SNDRV_TIMER_CLASS_GLOBAL: i32 = 1;
pub const SNDRV_TIMER_CLASS_CARD: i32 = 2;
pub const SNDRV_TIMER_CLASS_PCM: i32 = 3;
/// Global timer device IDs.
pub const SNDRV_TIMER_GLOBAL_SYSTEM: i32 = 0;
pub const SNDRV_TIMER_GLOBAL_RTC: i32 = 1;
pub const SNDRV_TIMER_GLOBAL_HPET: i32 = 2;
pub const SNDRV_TIMER_GLOBAL_HRTIMER: i32 = 3;
read(2) returns SndTimerRead or SndTimerTread events (depending on
SNDRV_TIMER_IOCTL_PARAMS filter field). Events report timer ticks with
nanosecond resolution for tempo-synchronization.
21.4.14 ALSA Hardware-Dependent (hwdep) Interface¶
The ALSA hwdep interface (/dev/snd/hwC{C}D{D}, minor = 4 + 32*C + D) provides
hardware-specific access for firmware upload, DSP programming, and direct hardware
register access. Used by audio devices with proprietary DSP firmware (e.g., USB
audio DSP devices, HD Audio codecs with firmware patches).
Ioctls (magic 'H', matching Linux include/uapi/sound/asound.h):
| Ioctl | Nr | Direction | Description |
|---|---|---|---|
SNDRV_HWDEP_IOCTL_PVERSION |
0x00 | R | Protocol version (u32) |
SNDRV_HWDEP_IOCTL_INFO |
0x01 | R | Get device info (SndHwdepInfo) |
SNDRV_HWDEP_IOCTL_DSP_STATUS |
0x02 | R | Get DSP load status |
SNDRV_HWDEP_IOCTL_DSP_LOAD |
0x03 | W | Upload firmware to DSP |
/// Hwdep device info. Matches Linux `struct snd_hwdep_info`.
#[repr(C)]
pub struct SndHwdepInfo {
/// Device index within card.
pub device: u32,
/// Card number.
pub card: i32,
/// Hwdep device ID string (NUL-terminated).
pub id: [u8; 64],
/// Hwdep device name string (NUL-terminated).
pub name: [u8; 80],
/// Interface type (SNDRV_HWDEP_IFACE_*).
pub iface: i32,
/// Reserved.
pub reserved: [u8; 64],
}
// SndHwdepInfo: u32(4) + i32(4) + [u8;64] + [u8;80] + i32(4) + [u8;64] = 220 bytes.
// Userspace ABI struct (SNDRV_HWDEP_IOCTL_INFO).
const_assert!(core::mem::size_of::<SndHwdepInfo>() == 220);
/// DSP firmware image header. Matches Linux `struct snd_hwdep_dsp_image`
/// (in `include/uapi/sound/asound.h`).
///
/// Linux uses `unsigned char __user *image`, `size_t length`, and
/// `unsigned long driver_data` — all pointer-width types. UmkaOS uses
/// `usize` to match: on 64-bit systems these are 8 bytes, on 32-bit
/// systems 4 bytes. The ioctl number encodes the struct size, so the
/// size MUST match the target architecture exactly.
///
/// Verified against torvalds/linux at baseline `fc02acf6ac0c` `include/uapi/sound/asound.h`.
#[repr(C)]
pub struct SndHwdepDspImage {
/// DSP block index (for multi-part firmware).
pub index: u32,
// 4 bytes padding on 64-bit (alignment of `name` is 1, but `image` is
// pointer-aligned). On 32-bit, no padding here because all fields before
// `image` sum to 68 bytes (4 + 64), and `image` is 4-byte aligned.
// Note: #[repr(C)] inserts padding automatically per platform ABI.
/// Firmware image name (NUL-terminated, for diagnostics).
pub name: [u8; 64],
/// Userspace pointer to firmware data.
/// Copied via `UserSlice::read()` — never dereferenced directly.
pub image: usize,
/// Length of firmware data in bytes (`size_t`).
pub length: usize,
/// Driver-specific flags (`unsigned long`).
pub driver_data: usize,
}
// Size depends on pointer width:
// 64-bit: 4 (index) + 64 (name) + 8 (image) + 8 (length) + 8 (driver_data) = 92
// but #[repr(C)] aligns `image` to 8: 4 + 4pad + 64 + 8 + 8 + 8 = 96
// 32-bit: 4 (index) + 64 (name) + 4 (image) + 4 (length) + 4 (driver_data) = 80
#[cfg(target_pointer_width = "64")]
const_assert!(size_of::<SndHwdepDspImage>() == 96);
#[cfg(target_pointer_width = "32")]
const_assert!(size_of::<SndHwdepDspImage>() == 80);
/// Hwdep interface type constants.
pub const SNDRV_HWDEP_IFACE_OPL2: i32 = 0;
pub const SNDRV_HWDEP_IFACE_OPL3: i32 = 1;
pub const SNDRV_HWDEP_IFACE_OPL4: i32 = 2;
pub const SNDRV_HWDEP_IFACE_SB16CSP: i32 = 3;
pub const SNDRV_HWDEP_IFACE_EMU10K1: i32 = 4;
pub const SNDRV_HWDEP_IFACE_EMUX_WAVETABLE: i32 = 8;
pub const SNDRV_HWDEP_IFACE_BLUETOOTH: i32 = 9;
pub const SNDRV_HWDEP_IFACE_USX2Y: i32 = 10;
pub const SNDRV_HWDEP_IFACE_FW_DICE: i32 = 15;
pub const SNDRV_HWDEP_IFACE_FW_FIREWORKS: i32 = 16;
pub const SNDRV_HWDEP_IFACE_FW_BEBOB: i32 = 17;
pub const SNDRV_HWDEP_IFACE_FW_OXFW: i32 = 18;
pub const SNDRV_HWDEP_IFACE_FW_DIGI00X: i32 = 19;
pub const SNDRV_HWDEP_IFACE_FW_TASCAM: i32 = 20;
pub const SNDRV_HWDEP_IFACE_FW_MOTU: i32 = 21;
pub const SNDRV_HWDEP_IFACE_FW_FIREFACE: i32 = 22;
The read(2) and write(2) operations on hwdep fds are driver-specific
(raw byte transfer to/from device). The firmware upload path uses
SNDRV_HWDEP_IOCTL_DSP_LOAD which copies via UserSlice::read() and passes
to the driver's firmware-loading callback.
21.4.15 ALSA Control Interface¶
The ALSA control interface (/dev/snd/controlC*) exposes mixer controls, jack
detection events, and card-level information. Required by PipeWire, PulseAudio,
amixer, and alsamixer.
Ioctls (magic 'U', matching Linux include/uapi/sound/asound.h):
| Ioctl | Nr | Description |
|---|---|---|
SNDRV_CTL_IOCTL_PVERSION |
0x00 | Protocol version |
SNDRV_CTL_IOCTL_CARD_INFO |
0x01 | Card info (id, driver, name, longname, components) |
SNDRV_CTL_IOCTL_ELEM_LIST |
0x10 | List all control elements (count + offset pagination) |
SNDRV_CTL_IOCTL_ELEM_INFO |
0x11 | Get element info (type, access flags, value range) |
SNDRV_CTL_IOCTL_ELEM_READ |
0x12 | Read element value |
SNDRV_CTL_IOCTL_ELEM_WRITE |
0x13 | Write element value |
SNDRV_CTL_IOCTL_ELEM_LOCK |
0x14 | Lock element (exclusive write access) |
SNDRV_CTL_IOCTL_ELEM_UNLOCK |
0x15 | Unlock element |
SNDRV_CTL_IOCTL_SUBSCRIBE_EVENTS |
0x16 | Enable/disable event subscription |
SNDRV_CTL_IOCTL_ELEM_ADD |
0x17 | Add user-defined control element |
SNDRV_CTL_IOCTL_ELEM_REPLACE |
0x18 | Replace user-defined control element |
SNDRV_CTL_IOCTL_ELEM_REMOVE |
0x19 | Remove user-defined control element |
SNDRV_CTL_IOCTL_TLV_READ |
0x1A | Read TLV data for dB scale |
SNDRV_CTL_IOCTL_TLV_WRITE |
0x1B | Write TLV data |
SNDRV_CTL_IOCTL_TLV_COMMAND |
0x1C | TLV command (volatile data) |
SNDRV_CTL_IOCTL_HWDEP_NEXT_DEVICE |
0x20 | Enumerate hwdep devices |
SNDRV_CTL_IOCTL_HWDEP_INFO |
0x21 | Hwdep device info |
SNDRV_CTL_IOCTL_PCM_NEXT_DEVICE |
0x30 | Enumerate PCM devices |
SNDRV_CTL_IOCTL_PCM_INFO |
0x31 | PCM device info |
SNDRV_CTL_IOCTL_PCM_PREFER_SUBDEVICE |
0x32 | Set preferred PCM subdevice |
21.4.15.1 Control Event Notification¶
Control clients that enabled delivery via SNDRV_CTL_IOCTL_SUBSCRIBE_EVENTS
receive change events by read(2) on the control fd, one SndCtlEvent per
read, and poll(2) reports EPOLLIN while events are queued. This is the
kernel-side delivery path behind mixer-change and jack-detection
notifications; PipeWire/PulseAudio consume it directly, and the D-Bus bridge
derives its VolumeChanged / JackStateChanged signals from the same events
(Section 21.4).
/// Event masks for `SndCtlEvent.mask` — values verified against Linux
/// `include/uapi/sound/asound.h` (torvalds/linux at baseline `fc02acf6ac0c`, 2026-07-09).
pub const SNDRV_CTL_EVENT_MASK_VALUE: u32 = 1 << 0; // element value changed
pub const SNDRV_CTL_EVENT_MASK_INFO: u32 = 1 << 1; // element info changed
pub const SNDRV_CTL_EVENT_MASK_ADD: u32 = 1 << 2; // element added
pub const SNDRV_CTL_EVENT_MASK_TLV: u32 = 1 << 3; // element TLV changed
pub const SNDRV_CTL_EVENT_MASK_REMOVE: u32 = !0u32; // element removed (~0U)
/// The only defined `SndCtlEvent.event_type` value (Linux
/// `enum sndrv_ctl_event_type { SNDRV_CTL_EVENT_ELEM = 0 }`).
pub const SNDRV_CTL_EVENT_ELEM: i32 = 0;
/// Control-interface event — matches Linux `struct snd_ctl_event`
/// (`include/uapi/sound/asound.h`: `int type` + a union of the elem event
/// `{ unsigned int mask; struct snd_ctl_elem_id id; }` and `data8[60]`).
/// UmkaOS materializes only the elem arm (the sole defined event type);
/// the layout below is the elem-arm image and matches the Linux size:
/// the union is max(4 + 64, 60) = 68 bytes, so the struct is 4 + 68 = 72.
/// Userspace ABI struct, returned by read(2) on `/dev/snd/controlC*`.
/// kernel-internal, not KABI
#[repr(C)]
pub struct SndCtlEvent {
/// Event type: `SNDRV_CTL_EVENT_ELEM` (0).
pub event_type: i32,
/// `SNDRV_CTL_EVENT_MASK_*` bits describing what changed.
pub mask: u32,
/// Identity of the changed element.
pub id: SndCtlElemId,
}
// SndCtlEvent: i32(4) + u32(4) + SndCtlElemId(64) = 72 bytes — equals the
// Linux size (4 + union(68) = 72; the elem arm fills the union exactly).
const_assert!(core::mem::size_of::<SndCtlEvent>() == 72);
/// Post a control-interface event for `elem_id` on card `card_index`.
///
/// For every open `/dev/snd/controlC<card_index>` client that enabled
/// `SNDRV_CTL_IOCTL_SUBSCRIBE_EVENTS`: enqueue
/// `SndCtlEvent { event_type: SNDRV_CTL_EVENT_ELEM, mask, id: *elem_id }`
/// on the client's bounded per-client event queue and wake its
/// `read(2)`/`poll(2)` waiters. Linux parity: `snd_ctl_notify()`
/// (`sound/core/control.c`, verified torvalds/linux at baseline `fc02acf6ac0c` 2026-07-09) is the
/// same driver-facing entry point.
///
/// **Context**: safe from hard-IRQ and softirq — the per-client queues are
/// SpinLock-guarded bounded rings PRE-allocated at subscribe time (no
/// allocation on the notify path, unlike Linux's `GFP_ATOMIC` kmalloc per
/// event), and the wake is a plain waitqueue wake-up. On a full client queue
/// the OLDEST queued event is dropped; a client that observes any event must
/// treat its cached element values as potentially stale and re-read them —
/// the same resync discipline evdev's `SYN_DROPPED` imposes, so bounded
/// queues lose no correctness.
pub fn snd_ctl_notify(card_index: u32, mask: u32, elem_id: &SndCtlElemId);
Key ABI structs:
/// Control element identifier — matches Linux `struct snd_ctl_elem_id`
/// (`include/uapi/sound/asound.h`). Userspace ABI struct.
/// kernel-internal, not KABI
#[repr(C)]
pub struct SndCtlElemId {
/// Numeric element id (assigned by the control core; 0 = look up by name).
pub numid: u32,
/// Interface, Linux `snd_ctl_elem_iface_t` (`typedef int`): CARD=0, HWDEP=1,
/// MIXER=2, PCM=3, RAWMIDI=4, TIMER=5, SEQUENCER=6.
pub iface: i32,
/// Device number within the interface.
pub device: u32,
/// Subdevice (substream) number.
pub subdevice: u32,
/// ASCII element name (e.g., "Master Playback Volume"), NUL-padded.
/// `SNDRV_CTL_ELEM_ID_NAME_MAXLEN` = 44 in Linux.
pub name: [u8; 44],
/// Element index (for duplicated controls; usually 0).
pub index: u32,
}
// SndCtlElemId: u32(4)+i32(4)+u32(4)+u32(4)+[u8;44]+u32(4) = 64 bytes.
const_assert!(core::mem::size_of::<SndCtlElemId>() == 64);
/// Control element value type — Linux `snd_ctl_elem_type_t` (`typedef int`).
/// `#[repr(i32)]` matches the C `int` width and keeps a stable discriminant.
#[repr(i32)]
pub enum SndCtlElemType {
/// No type / invalid.
None = 0,
/// Boolean (0 or 1) values.
Boolean = 1,
/// Integer values in [min, max] with `step`.
Integer = 2,
/// Enumerated (index into a name list).
Enumerated = 3,
/// Opaque byte array.
Bytes = 4,
/// IEC958 (S/PDIF) channel status.
Iec958 = 5,
/// 64-bit integer values.
Integer64 = 6,
}
/// ENUMERATED variant image of `SndCtlElemInfoValue` — matches the
/// `enumerated` arm of the `value` union in Linux `struct snd_ctl_elem_info`.
/// kernel-internal, not KABI
#[repr(C)]
pub struct SndCtlEnumInfo {
/// Number of enumeration items.
pub items: u32,
/// Item index being queried/reported.
pub item: u32,
/// Name of `item` (NUL-padded ASCII).
pub name: [u8; 64],
/// Userspace pointer to the item-name table (ELEM_ADD path); `__u64` in
/// Linux so the field width is stable across 32-bit and 64-bit userspace.
pub names_ptr: u64,
/// Length in bytes of the name table pointed to by `names_ptr`.
pub names_length: u32,
/// Tail padding to the union's 8-byte alignment (from `names_ptr`).
pub _pad: [u8; 4],
}
// SndCtlEnumInfo: u32(4)+u32(4)+[u8;64]+u64(8)+u32(4)+[u8;4] = 88 bytes.
const_assert!(core::mem::size_of::<SndCtlEnumInfo>() == 88);
/// `value` union of `SndCtlElemInfo` — matches the anonymous union in Linux
/// `struct snd_ctl_elem_info`. Pinned to 128 bytes by the `reserved` variant;
/// `isize` models the C `long` (LP64 on 64-bit, ILP32 on 32-bit), matching
/// Linux exactly on every supported target. Unions have no cross-compiler
/// layout guarantees issue here (a single userspace-ABI shape per target).
/// kernel-internal, not KABI
#[repr(C)]
pub union SndCtlElemInfoValue {
/// INTEGER: [min, max, step] as `long`.
pub integer: [isize; 3],
/// INTEGER64: [min, max, step].
pub integer64: [i64; 3],
/// ENUMERATED: items/item/name/names_ptr/names_length.
pub enumerated: SndCtlEnumInfo,
/// Reserved — pins the union to Linux's 128-byte `value` field.
pub reserved: [u8; 128],
}
/// `value` union of `SndCtlElemValue` — matches the `value` union in Linux
/// `struct snd_ctl_elem_value`. `isize` models the C `long`. The `integer`
/// arm (128 `long`s) is the largest variant: 1024 B on 64-bit, 512 B on 32-bit.
/// kernel-internal, not KABI
#[repr(C)]
pub union SndCtlElemValueData {
/// INTEGER: up to 128 `long` values.
pub integer: [isize; 128],
/// INTEGER64: up to 64 values.
pub integer64: [i64; 64],
/// ENUMERATED: up to 128 item indices.
pub enumerated: [u32; 128],
/// BYTES: up to 512 raw bytes.
pub bytes: [u8; 512],
/// IEC958 (S/PDIF) channel status image: status[24] + subcode[147] +
/// pad(1) + dig_subframe[4] = 176 bytes (Linux `struct snd_aes_iec958`).
pub iec958: [u8; 176],
}
/// Element info (type, access, count, value range).
/// Userspace ABI struct — matches Linux `struct snd_ctl_elem_info`.
/// kernel-internal, not KABI
#[repr(C)]
pub struct SndCtlElemInfo {
pub id: SndCtlElemId, // numid + iface + name
pub elem_type: SndCtlElemType, // BOOLEAN, INTEGER, ENUMERATED, BYTES, etc.
pub access: u32, // SNDRV_CTL_ELEM_ACCESS_* flags
pub count: u32, // number of values per element
pub owner: i32, // PID of locking process (0 = unlocked)
pub value: SndCtlElemInfoValue, // union: integer{min,max,step}, enumerated{items,names}
pub _reserved: [u8; 64],
}
// SndCtlElemInfo — matches Linux `struct snd_ctl_elem_info` (verified against
// include/uapi/sound/asound.h on torvalds/linux at baseline `fc02acf6ac0c`: no `dimen` field;
// trailing reserved[64]). Layout: id (SndCtlElemId 64) + elem_type (i32 4) +
// access u32 4 + count u32 4 + owner i32 4 (offset 80) + value
// (SndCtlElemInfoValue union, pinned to 128 by reserved[128], align 8; offset
// 80..208) + _reserved[64] (208..272) = 272. The value union is 128 B on BOTH
// 32-bit and 64-bit (reserved[128] dominates the long/long-long variants) and
// SndCtlElemId is 64 B on both, so the total is pointer-width-independent.
const_assert!(core::mem::size_of::<SndCtlElemInfo>() == 272);
/// Element value (read/write payload).
/// Userspace ABI struct — matches Linux `struct snd_ctl_elem_value`
/// (`include/uapi/sound/asound.h`): `id`, then `unsigned int indirect:1`, then
/// the `value` union, then `reserved[128]`.
// kernel-internal, not KABI (but IS a userspace ioctl ABI struct — layout must
// match Linux byte-for-byte for SNDRV_CTL_IOCTL_ELEM_READ/WRITE).
#[repr(C)]
pub struct SndCtlElemValue {
pub id: SndCtlElemId,
/// Linux `unsigned int indirect:1` — obsolete indirect-access flag. Only
/// bit 0 is meaningful (0 = direct, 1 = indirect/obsolete); all other bits
/// are zero. Modeled as the full `u32` storage unit that encloses the C
/// bitfield (not a Rust bitfield — see CLAUDE.md §wire-struct rules), which
/// preserves the ABI offset of `value`.
pub indirect: u32,
/// Explicit padding: the C `value` union is 8-aligned (`long` / `long long`)
/// on every target, so `unsigned int indirect` (offset 64) is followed by 4
/// bytes of alignment padding before `value` at offset 72.
pub _pad0: [u8; 4],
pub value: SndCtlElemValueData, // union: integer[128], integer64[64], enumerated[128], bytes[512]
pub _reserved: [u8; 128],
}
// SndCtlElemValue layout (matches Linux `struct snd_ctl_elem_value`):
// id(64) + indirect(4) + _pad0(4) + value(1024|512) + _reserved(128).
// `value` (SndCtlElemValueData) is 1024 B on 64-bit (`isize`/`long` = 8) and
// 512 B on 32-bit (ARMv7/PPC32: `long` = 4, `long long` = 8-aligned), so the
// total differs per pointer width.
#[cfg(target_pointer_width = "64")]
const_assert!(core::mem::size_of::<SndCtlElemValue>() == 1224);
#[cfg(target_pointer_width = "32")]
const_assert!(core::mem::size_of::<SndCtlElemValue>() == 712);
Jack detection events: When a jack state changes (headphone insert/remove),
the driver calls snd_jack_report(jack, connected)
(Section 21.4). The control interface delivers
an SNDRV_CTL_EVENT_MASK_VALUE event on the jack's control element
(Section 21.4). PipeWire
detects this via poll() on the control fd and routes audio accordingly.
21.4.15.2 D-Bus Bridge Schema for Audio¶
The ALSA subsystem declares D-Bus interface schemas for the D-Bus bridge service
(Section 11.11). The bridge handles transport, connection management,
and bus registration; the audio subsystem only declares the schema. User-space
audio management tools (e.g., pavucontrol, gnome-control-center) use these
interfaces for volume control and card enumeration without opening /dev/snd/*
devices directly.
dbus_interface "org.umkaos.Audio1.Mixer" {
/// Get the current volume for a named control element (e.g., "Master Playback Volume").
/// Returns the volume as a percentage (0-100) normalized from the element's
/// hardware min/max range. The element name matches the ALSA mixer element
/// name reported by `SNDRV_CTL_IOCTL_ELEM_LIST`.
@dbus_method("GetVolume")
fn get_volume(card_index: u32, element_name: &str) -> Result<u32>;
/// Set the volume for a named control element.
/// `volume_pct` is clamped to [0, 100] and mapped linearly to the element's
/// hardware range. Requires the caller to have audio device access
/// (membership in the `audio` group or `CAP_SYS_ADMIN`).
@dbus_method("SetVolume")
fn set_volume(card_index: u32, element_name: &str, volume_pct: u32) -> Result<()>;
/// Get the mute state of a named control element.
/// Returns `true` if muted. Elements without a mute switch return `false`.
@dbus_method("GetMute")
fn get_mute(card_index: u32, element_name: &str) -> Result<bool>;
/// Set the mute state of a named control element.
@dbus_method("SetMute")
fn set_mute(card_index: u32, element_name: &str, muted: bool) -> Result<()>;
/// Emitted when any mixer element's value changes (volume, mute, or switch).
/// Bridges the ALSA `SNDRV_CTL_EVENT_MASK_VALUE` event to D-Bus.
@dbus_signal("VolumeChanged")
fn volume_changed(card_index: u32, element_name: &str, volume_pct: u32, muted: bool);
}
dbus_interface "org.umkaos.Audio1.Card" {
/// Return information about a sound card.
/// Fields match `SNDRV_CTL_IOCTL_CARD_INFO`: id, driver, name, longname, mixername.
@dbus_method("GetInfo")
fn get_info(card_index: u32) -> Result<AudioCardInfo>;
/// Return the operational status of the card.
/// `online`: card is present and functional.
/// `suspended`: card is in runtime PM suspend.
/// `disconnected`: card was hot-unplugged.
@dbus_method("GetStatus")
fn get_status(card_index: u32) -> Result<AudioCardStatus>;
/// List all available sound cards.
/// Returns an array of (card_index, card_id_string) pairs.
/// **Bound**: Maximum 32 sound cards in dynamic-minor mode.
/// The Vec is bounded at 32 entries; exceeding 32 cards returns the first 32.
@dbus_method("ListCards")
fn list_cards() -> Result<Vec<(u32, String)>>;
/// Emitted when a card is added or removed (hot-plug events).
@dbus_signal("CardChanged")
fn card_changed(card_index: u32, event: AudioCardEvent);
}
dbus_interface "org.umkaos.Audio1.Stream" {
/// Get the current PCM stream state (OPEN, SETUP, PREPARED, RUNNING, etc.).
@dbus_method("GetState")
fn get_state(card_index: u32, device: u32, subdevice: u32) -> Result<u32>;
/// Get stream parameters: (rate_hz, channels, format).
@dbus_method("GetParams")
fn get_params(card_index: u32, device: u32, subdevice: u32) -> Result<(u32, u32, u32)>;
/// Emitted when a PCM stream transitions state (e.g., PREPARED → RUNNING).
@dbus_signal("StateChanged")
fn state_changed(card_index: u32, device: u32, subdevice: u32, new_state: u32);
}
dbus_interface "org.umkaos.Audio1.Jack" {
/// Get the current state of a named jack (headphone, line-out, etc.).
/// Returns true if a plug is inserted.
@dbus_method("GetState")
fn get_state(card_index: u32, jack_name: &str) -> Result<bool>;
/// List all jacks on the given card.
/// Returns Vec of (jack_name, connected) tuples.
@dbus_method("List")
fn list(card_index: u32) -> Result<Vec<(String, bool)>>;
/// Emitted when a jack's plug state changes (insert/remove).
/// Desktop audio managers (PipeWire, PulseAudio) use this signal
/// to reroute audio streams when headphones are plugged/unplugged.
@dbus_signal("JackStateChanged")
fn jack_state_changed(card_index: u32, jack_name: String, connected: bool);
}
AudioCardInfo, AudioCardStatus, and AudioCardEvent are D-Bus struct types
derived from the ALSA control ABI structs (SndCtlCardInfo, SndCtlElemValue).
The bridge translates between the kernel's ioctl-based ALSA control interface
and the D-Bus wire format; no new kernel data paths are introduced.
Note on D-Bus schema types: The schema above uses logical types (&str, String,
Vec<...>) that correspond to D-Bus wire types (STRING, ARRAY). The D-Bus bridge
service (Section 11.11) handles serialization between these logical types
and the fixed-size repr(C) ring buffer entries used internally. The kernel-side KABI
ring messages use [u8; 64] NUL-terminated strings and bounded arrays; the bridge
performs the translation at the D-Bus protocol boundary.
21.5 Display and Graphics (DRM/KMS)¶
The Direct Rendering Manager (DRM) and Kernel Mode Setting (KMS) subsystems manage GPUs, display outputs, and hardware-accelerated rendering.
21.5.1 DRM Device Nodes and Per-Open State¶
DRM device numbering (Linux ABI-compatible):
- Major number: 226 (
DRM_MAJOR, assigned by LANANA). - Primary nodes (
/dev/dri/cardN): minorN(0, 1, 2, ..., max 63). One per GPU/display controller. Supports modesetting (requires DRM master). - Render nodes (
/dev/dri/renderDN): minor128 + N(128, 129, ..., max 191). One per GPU. Supports unprivileged GPU compute and rendering (no modesetting, no DRM master required). Minor numbering follows Linux's64 * 2render-base scheme (enum: PRIMARY=0, CONTROL=1, RENDER=2). - Allocation: device registry assigns the next free minor in the appropriate range when a DRM driver is registered.
/dev/dri/directory is created by devtmpfs. Symlinks in/dev/dri/by-path/map bus addresses to card/render nodes.
Character device registration (Section 14.5):
/// Called from drm_subsystem_init() during boot Phase 5.3+ (after Tier 1 driver loading).
fn drm_register_chrdev() {
register_chrdev_region(ChrdevRegion {
major: 226,
minor_base: 0,
minor_count: 256, // 64 primary + 64 control + 128 render
fops: &DRM_FOPS,
name: "dri",
}).expect("DRM major 226 registration");
}
DRM_FOPS.open() determines the node type from the minor number: 0–63 =
primary (card), 64–127 = control (legacy, usually disabled), 128–191 = render.
It then locates the DrmDevice instance registered by the GPU driver and
creates a per-open DrmFile state (GEM handle namespace, DRM master status,
authentication token). Render node opens bypass DRM master authentication
checks, matching Linux behavior.
/// Per-open state for a DRM device file descriptor. One per `open()` on
/// `/dev/dri/cardN` or `/dev/dri/renderDN`.
pub struct DrmFile {
/// The DRM device this file belongs to.
pub device: Arc<DrmDevice>,
/// Identity of this open file within its `DrmDevice`. Monotonically
/// allocated from `DrmDevice::next_file_id`, never reused within the
/// operational lifetime, and never 0 (0 is the "no master" sentinel in
/// `DrmDevice::master_file_id`). This is the value the device-wide master
/// record names — a scalar id rather than an `Arc<DrmFile>`, because the
/// file already holds `Arc<DrmDevice>` and a strong back-reference would
/// close a refcount cycle.
pub file_id: u64,
/// GEM handle namespace for this fd. Maps userspace u32 handles to
/// kernel GEM objects. XArray keyed by handle (integer key, O(1) lookup).
pub gem_handles: XArray<Arc<GemObject>>,
/// Next GEM handle to allocate (monotonically increasing per-fd).
pub next_handle: AtomicU32,
/// DRM authentication token. Non-master clients must authenticate
/// via DRM_AUTH ioctl before submitting GPU commands on primary nodes.
/// Render nodes skip authentication entirely.
///
/// `AtomicBool`, not `bool`: `DRM_IOCTL_AUTH_MAGIC` mutates it from the
/// master's thread while the owning client may be issuing ioctls on the
/// same (dup'd or thread-shared) fd. A plain `bool` written on one thread
/// and read on another is a data race on a security-relevant gate.
pub authenticated: AtomicBool,
/// Minor type: Primary (modesetting), Render (compute/render only).
pub minor_type: DrmMinorType,
/// Client capabilities negotiated via DRM_IOCTL_SET_CLIENT_CAP.
/// Tracks DRM_CLIENT_CAP_STEREO_3D, DRM_CLIENT_CAP_UNIVERSAL_PLANES,
/// DRM_CLIENT_CAP_ATOMIC, DRM_CLIENT_CAP_WRITEBACK_CONNECTORS.
pub client_caps: u32,
/// Event queue for this fd (VBlank events, page flip completions).
/// Stores full `DrmEventVblank` (32 bytes each) — the largest DRM event type.
/// `DrmEvent` is only the 8-byte ABI header; storing it would lose the payload.
/// All current DRM event types are 32 bytes (DrmEventVblank). If future event
/// types differ in size, change to a byte-level ring with length-prefixed entries.
/// Read via `read()` on the DRM fd. Consumer side (`read()`) acquires
/// with IRQs disabled to prevent deadlock with
/// the VBlank IRQ handler producer.
/// Admission-validated bound, collection-policy role (b)
/// ([Section 3.13](03-concurrency.md#collection-usage-policy--compile-time-capacities-scratch-hints-and-validated-bounds-never-ownership)):
/// 256 is this fd's event RESERVATION BUDGET, and the reservation check at
/// the event-generating request IS the admission validation — an event that
/// cannot be reserved never becomes owned, so the ring never overflows and
/// no queued event is ever displaced. This stream is NOT lossy: delivery of
/// an already-reserved event cannot fail. See the overflow policy in
/// [Section 21.5](#display-and-graphics--vblank-handling-and-synchronization).
/// (contract: `torvalds/linux` `drivers/gpu/drm/drm_file.c` at baseline `fc02acf6ac0c`, fetched at certify; finding 2ab57fca897d)
pub event_queue: SpinLock<InlineBoundedRing<DrmEventVblank, 256>>,
/// Wait queue for poll/select/epoll on this DRM fd.
pub waiters: WaitQueueHead,
}
pub enum DrmMinorType {
Primary, // /dev/dri/cardN (modesetting + render)
Render, // /dev/dri/renderDN (render/compute only, no modesetting)
}
21.5.1.1 DRM Device and Char-Device FileOps¶
A DRM device is created when a GPU driver registers it. It owns
the GEM object namespace shared across every DrmFile open of the device's
primary and render nodes, plus the per-device allocators for GEM flink names and
fake mmap offsets referenced by GemObject.
/// A registered DRM device — one per GPU/display controller.
///
/// Owns the device-global GEM sharing tables (flink names, mmap offsets) that
/// `GemObject` and `DrmFile` reference. Created when the driver registers the device and
/// held via `Arc<DrmDevice>` by each open `DrmFile`.
// kernel-internal, not KABI — lives entirely kernel-side; userspace sees only
// the `/dev/dri/*` char-device nodes, never this struct.
pub struct DrmDevice {
/// Globally-unique device id. `u64`, monotonically allocated, never reused
/// within the operational lifetime (50-year-safe).
pub id: u64,
/// Driver name (e.g., "i915", "amdgpu"), surfaced via `DRM_IOCTL_VERSION`.
pub driver_name: ArrayString<32>,
/// Primary-node minor (0–63) assigned at registration.
pub primary_minor: u32,
/// Render-node minor (128–191). `None` if the driver exposes no render node.
pub render_minor: Option<u32>,
/// The display controller this DRM device drives (modesetting state).
pub display: DisplayDeviceId,
/// `DrmFile::file_id` of the current DRM master, or 0 if the device has
/// none. This is the authoritative, device-wide record of an exclusivity
/// claim that is device-wide by definition — a per-open `bool` cannot
/// express "only one fd at a time", and cannot implement the
/// `DRM_IOCTL_SET_MASTER` contract ("returns EPERM if another fd is
/// already master") at all, because no fd can see the others.
///
/// All transitions are a single `compare_exchange`, so concurrent
/// `SET_MASTER`, `DROP_MASTER`, and VT-switch handoffs can never authorize
/// two fds; see the master state machine in
/// [Section 21.5](#display-and-graphics--drmkms-compatibility-interface).
pub master_file_id: AtomicU64,
/// Allocator for `DrmFile::file_id`. Monotonically increasing, never
/// reused; starts at 1 so that 0 stays the "no master" sentinel.
pub next_file_id: AtomicU64,
/// Legacy GEM flink-name table: global name (`u32`) → object. Populated by
/// `DRM_IOCTL_GEM_FLINK`. XArray keyed by flink name (integer key, O(1)).
///
/// The entries are `Weak`, not `Arc`. A strong reference here would be
/// unreleasable: the DRM ioctl surface has no un-flink operation, so a
/// strong device-table entry would keep every flinked object alive — and
/// its pages or VRAM pinned — for the whole lifetime of the `DrmDevice`.
/// With `Weak`, `GemObject::drop()` removes the entry and frees the
/// `flink_ids` slot, and a lookup whose `upgrade()` returns `None` reports
/// the name as unknown (`-ENOENT`) instead of resurrecting a dead object.
pub flink_table: XArray<Weak<GemObject>>,
/// Per-device allocator for GEM flink names (`GemObject::flink_name`).
/// A name is released only in `GemObject::drop()`, so a live object's name
/// is never handed to a different object.
pub flink_ids: Idr,
/// Per-device allocator for fake mmap offsets (`GemObject::mmap_offset`).
pub mmap_offset_ids: Idr,
/// Per-device framebuffer object table — the `DRM_IOCTL_MODE_ADDFB2` id
/// namespace with its generation slots. See
/// [Section 21.5](#display-and-graphics--framebuffer-objects).
pub framebuffers: FramebufferTable,
}
impl DrmFile {
/// Whether this fd currently holds DRM master. Derived from the device's
/// single owner record, never cached in the file: a cached copy is exactly
/// what lets a `DROP_MASTER` or VT switch race a modeset that already
/// passed its permission check.
///
/// Modesetting entry points re-evaluate this *inside* the
/// `DisplayDevice::commit_mutex` window (see
/// [Section 21.5](#display-and-graphics--hotplug-detection)), and `set_master` /
/// `drop_master` below take that same mutex — which is what actually makes
/// "master cannot be revoked between the check and the commit" true. A
/// derived read plus an unserialized writer would still leave the
/// authorization racy; only the shared lock removes the window.
pub fn is_master(&self) -> bool {
self.device.master_file_id.load(Ordering::Acquire) == self.file_id
}
/// `DRM_IOCTL_SET_MASTER`. Succeeds if the device has no master, or if the
/// caller already is master (idempotent). A caller holding `CAP_SYS_ADMIN`
/// may additionally take master from another fd — the path the VT
/// subsystem uses on a console switch.
///
/// **Serialized against modesetting by `commit_mutex`.** The CAS alone
/// makes the owner record consistent; it does not make AUTHORIZATION
/// consistent. A modeset re-checks master under `commit_mutex` and then
/// programs the hardware, so a transition that lands between that check and
/// the arming would let an fd that is no longer master (or that was master
/// for the check of a commit the new master did not issue) drive the
/// display. Taking the same mutex the modeset holds closes that window by
/// construction: a master transition either precedes a commit's check or
/// follows its arming, never falls inside. The cost lands on the cold path
/// — `SET_MASTER`/`DROP_MASTER` happen at compositor start and VT switch,
/// while a queued commit releases the mutex as soon as its flip is
/// installed and never holds it across a frame.
pub fn set_master(&self, force: bool) -> Result<(), DisplayError> {
let _modeset = self.device.display_device().commit_mutex.lock();
loop {
let cur = self.device.master_file_id.load(Ordering::Acquire);
if cur == self.file_id {
return Ok(());
}
if cur != 0 && !force {
return Err(DisplayError::PermissionDenied); // -EPERM
}
if self
.device
.master_file_id
.compare_exchange(cur, self.file_id, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
return Ok(());
}
// Another fd won the race; re-read and re-decide.
}
}
/// `DRM_IOCTL_DROP_MASTER`. A no-op if this fd is not the master. Also
/// invoked from `release()` so a closing fd cannot leave the device
/// permanently mastered by a dead file.
///
/// Takes `commit_mutex` for the same reason `set_master` does: revocation
/// must not fall between a commit's master re-check and its arming. The
/// `release()` caller is in process context, so the sleeping lock is legal
/// there; a closing fd simply waits out an in-flight commit's arming, which
/// is bounded and does not include the frame the commit is waiting for.
pub fn drop_master(&self) {
let _modeset = self.device.display_device().commit_mutex.lock();
let _ = self.device.master_file_id.compare_exchange(
self.file_id,
0,
Ordering::AcqRel,
Ordering::Acquire,
);
}
}
impl DrmDevice {
/// The display controller this DRM device drives, resolved from
/// `DrmDevice::display`. The DRM layer needs it for every modeset and for
/// the master transitions above, which serialize on its `commit_mutex`.
pub fn display_device(&self) -> &DisplayDevice;
}
/// FileOps vtable type for DRM primary/render nodes (major 226). One shared
/// instance (`DRM_FOPS`) is registered for the whole major range.
///
/// - `open()` decodes the minor → node type (0–63 primary, 128–191 render),
/// resolves the `DrmDevice`, and installs a per-open `DrmFile` as the file's
/// private state; render-node opens skip DRM-master authentication.
/// - `read()`/`poll()` drain `DrmFile::event_queue` (VBlank / page-flip events).
/// - `ioctl()` dispatches the DRM/KMS ioctl surface (modeset, GEM, PRIME).
/// - `mmap()` resolves `GemObject::mmap_offset` fake offsets to physical pages
/// via the DRM fault handler.
/// - `release()` tears down the `DrmFile` and drops its GEM handle references.
// kernel-internal, not KABI — dispatched through the VFS `FileOps` trait object.
pub struct DrmDeviceFileOps;
impl FileOps for DrmDeviceFileOps {
// open / read / poll / ioctl / mmap / release as described above. The full
// ioctl surface is specified in the Atomic Modesetting and GEM sections.
}
/// The single shared DRM `FileOps` table registered for major 226. Coerces to
/// `&'static dyn FileOps` at `register_chrdev_region()`.
pub static DRM_FOPS: DrmDeviceFileOps = DrmDeviceFileOps;
21.5.1.2 GEM Buffer Objects¶
The Graphics Execution Manager (GEM) provides the fundamental GPU memory allocation
and tracking layer. Every GPU buffer — dumb framebuffers, render targets, textures,
command buffers — is represented as a GemObject. Userspace references GEM objects
through per-fd u32 handles stored in the DrmFile::gem_handles XArray (integer-keyed,
O(1) lookup). The kernel holds Arc<GemObject> so that a single buffer can be shared
across multiple handles (via DRM_IOCTL_PRIME_FD_TO_HANDLE import) and across processes
(via DMA-BUF export).
/// A GEM buffer object — the fundamental GPU memory allocation unit.
/// Each GemObject is reference-counted (Arc) and tracked in a per-file
/// handle table (XArray<Arc<GemObject>>, integer-keyed by handle).
// kernel-internal, not KABI — never crosses compilation boundary or ioctl interface.
#[repr(C)]
pub struct GemObject {
/// Size of the allocation in bytes (page-aligned, immutable after creation).
pub size: usize,
/// Backing memory type. Determines where the physical pages live.
pub backing: GemBacking,
/// DMA address for GPU access (populated after pin/map via
/// [Section 4.14](04-memory.md#dma-subsystem) `umka_driver_dma_map_sg`). `None` until the
/// buffer is bound to a GPU address space.
pub dma_addr: Option<DmaAddr>,
/// Fake offset for userspace mmap via `DRM_IOCTL_MODE_MAP_DUMB`.
/// Unique per-device, allocated from a per-DrmDevice `Idr`.
/// Userspace passes this as the `offset` argument to `mmap()` on
/// the DRM fd; the DRM fault handler resolves it to physical pages.
pub mmap_offset: u64,
/// Global name for `DRM_IOCTL_GEM_FLINK` legacy sharing. 0 = unnamed.
/// Flink names are allocated from a per-DrmDevice `Idr` and stored as a
/// `Weak` entry in `DrmDevice::flink_table`. Deprecated in favour of
/// DMA-BUF / PRIME, but required for Xorg DDX compatibility.
pub flink_name: u32,
/// Reservation object for implicit fencing (shared with DMA-BUF layer).
/// Tracks read/write fences from GPU command submissions so that
/// cross-device synchronisation (e.g., GPU render → display scanout)
/// waits for the correct operations to complete.
pub resv: ReservationObject,
}
/// Backing memory type for a GEM buffer.
pub enum GemBacking {
/// System RAM pages (default for dumb buffers and most render targets).
/// Pages are allocated from the physical allocator ([Section 4.2](04-memory.md#physical-memory-allocator))
/// at buffer creation time. The page array is heap-allocated with a known
/// upper bound: `size / PAGE_SIZE` entries. Uses `Box<[PageRef]>` (not Vec)
/// because the size is fixed at creation and never grows — `Box<[T]>` avoids
/// the 8-byte capacity field overhead of Vec and signals immutable length.
Pages(Box<[PageRef]>),
/// VRAM carved from a PCI BAR region (discrete GPUs with dedicated memory).
/// `bar_offset` is relative to the BAR base; the driver's VRAM allocator
/// (a simple buddy or best-fit allocator over the BAR range) manages
/// sub-allocation.
Vram { bar_offset: u64, bar_index: u8 },
/// Imported DMA-BUF from another device (cross-device zero-copy sharing).
/// The GemObject does not own the backing pages — the exporting device
/// does. `sg_table` caches the scatter-gather mapping obtained from
/// `DmaBuf::map_attachment()` for the lifetime of the import.
DmaBuf { dmabuf: Arc<DmaBuf>, sg_table: DmaSgl },
}
ReservationObject — which tracks the implicit DMA fences on a shared buffer
(one per GemObject and per DMA-BUF; the writer sets the exclusive fence,
readers add shared fences, and a fence signals when the GPU completes the
associated command-buffer submission) — is defined canonically in the DMA-BUF
layer (Section 4.14). GemObject::resv
embeds one directly; it is not redefined here. The canonical object holds
lock: SpinLock<()>, exclusive_fence: Option<Arc<DmaFence>>, and
shared_fences: ArrayVec<Arc<DmaFence>, 16> — refcounted waitable fence
objects (Section 4.14).
Handle lifecycle: DRM_IOCTL_GEM_OPEN or DRM_IOCTL_PRIME_FD_TO_HANDLE inserts
an Arc<GemObject> into DrmFile::gem_handles at the next available handle slot
(returned to userspace as a u32). DRM_IOCTL_GEM_CLOSE removes the handle entry
and decrements the Arc refcount.
Liveness — one rule, no second counter. The Arc strong count is the sole
authority on a GEM object's lifetime: every reference that keeps the object alive
is an Arc — per-fd handles, DMA-BUF exports (the exporter holds one), imports
(PRIME_FD_TO_HANDLE inserts an Arc into the importing DrmFile's handle
table), in-flight GPU submissions, and scanout via a Framebuffer. When the last
one drops, GemObject::drop() releases the flink name (removing the
DrmDevice::flink_table entry and freeing the flink_ids slot) and frees the
backing memory: Pages are returned to the physical allocator, Vram to the BAR
sub-allocator, and DmaBuf detaches the scatter-gather mapping.
There is deliberately no separate import counter. Two liveness criteria that must
agree — "import count is zero and no local handles remain" versus "the last Arc
dropped" — are not equivalent unless every import is backed 1:1 by an Arc; if
they ever disagree the object is either freed while an importer still holds it
(use-after-free) or never freed at all (leak), and a builder cannot tell from the
spec which one gates destruction. Since imports already hold Arcs, the counter
was a second copy of a number the Arc already keeps. Diagnostics that want the
count read Arc::strong_count.
GPUs are complex, high-bandwidth devices that require aggressive GPU address-space and
buffer-placement management plus rapid command submission. UmkaOS GPU drivers
(e.g., umka-amdgpu, umka-i915) therefore declare preferred_tier = 1 in their
manifest, and the loader computes the effective tier at bind time
(Section 11.3) — on architectures without a fast privileged-domain
mechanism the same driver binary binds at Tier 0, and a fully offloaded display or
render device binds at Tier 2. Full implementation details covering display device models,
atomic modesetting, framebuffer objects, and scanout planes are specified in
Section 21.5–Section 21.5.
The GPU driver receives command buffers from userspace (Mesa/Vulkan) via shared memory rings. The driver validates the command buffers (ensuring they don't contain malicious GPU memory writes) and submits them to the hardware command rings.
Where the effective tier provides a hardware domain, a bug in the complex command validation logic (a frequent source of Linux CVEs) cannot corrupt UmkaOS Core memory or the page cache. If the GPU driver faults, it is reloaded (~50-150ms). Userspace rendering contexts are lost (triggering a VK_ERROR_DEVICE_LOST in Vulkan applications), but the system remains stable.
21.5.2 DMA-BUF and Secure File Descriptor Passing¶
Modern Linux graphics rely entirely on DMA-BUF: a mechanism for sharing hardware-backed memory buffers between different devices and processes (e.g., sharing a rendered frame from the GPU to the Wayland compositor, or from a V4L2 webcam to the GPU).
In Linux, a DMA-BUF is represented as a standard file descriptor. Passing the file descriptor over a UNIX domain socket grants access to the underlying memory.
UmkaOS's DMA-BUF Implementation:
UmkaOS implements DMA-BUF using the core Capability System (Section 9.1).
1. When the GPU driver allocates a framebuffer, it creates an UmkaOS Memory Object and mints a Capability Token granting MEM_READ | MEM_WRITE access.
2. umka-sysapi wraps this Capability Token in a synthetic file descriptor.
3. When the Wayland client passes the file descriptor to the compositor over AF_UNIX (using SCM_RIGHTS), the kernel securely delegates the Capability Token to the compositor's capability space.
4. The compositor uses the Capability Token to map the framebuffer into its own address space, or passes it back to the GPU driver to queue a page flip (KMS).
By backing DMA-BUF file descriptors with cryptographic Capability Tokens, UmkaOS guarantees that memory access rights cannot be forged or leaked, and seamlessly supports distributed graphics rendering (Section 5.1) where the compositor and the rendering client exist on different physical nodes in the cluster.
21.5.3 Display Device Model¶
Interface contract: Section 13.3 (
DisplayDrivertrait,display_device_v1KABI). This section specifies the Intel i915, AMD DCN, and embedded display pipeline implementations of that contract. Tier decision and atomic modesetting requirement are authoritative in Section 13.3.
Tier-agnostic: integrated-GPU drivers (Intel i915, AMD amdgpu iGPU)
declare preferred_tier = 1 in their manifest; fully-offloaded display
drivers (USB DisplayLink, network display servers) declare
preferred_tier = 2. The loader computes the effective tier at bind
time per Section 11.3.
// umka-nucleus/src/display/mod.rs
/// Display device handle.
// kernel-internal, not KABI — opaque handle, never exposed to userspace.
#[repr(C)]
pub struct DisplayDeviceId(u64);
/// Display connector type. Values from Linux 6.12 include/uapi/drm/drm_mode.h.
/// Binary compatibility requires exact value matches.
#[repr(u32)]
pub enum ConnectorType {
Unknown = 0,
VGA = 1,
DVII = 2,
DVID = 3,
DVIA = 4,
Composite = 5,
SVIDEO = 6,
LVDS = 7,
Component = 8,
NinePinDIN = 9,
DisplayPort = 10,
HDMIA = 11,
HDMIB = 12,
TV = 13,
EDP = 14,
VIRTUAL = 15,
DSI = 16,
DPI = 17,
WRITEBACK = 18,
SPI = 19,
USB = 20,
}
/// Display connector state. Matches Linux `enum drm_connector_status` in
/// `include/drm/drm_connector.h`. Value 0 is unused in Linux.
/// EDID availability is tracked separately in `ConnectorProps` — it is
/// orthogonal to the connection state (a connected display may lack EDID
/// if the DDC channel is broken).
///
/// Verified against torvalds/linux at baseline `fc02acf6ac0c` `include/drm/drm_connector.h`:
/// connector_status_connected = 1
/// connector_status_disconnected = 2
/// connector_status_unknown = 3
///
/// **Scope**: this is connection STATUS and nothing else. It is not a
/// per-connector change record and must not be used as one — it carries no
/// connector id, no mode, no CRTC routing, and no DPMS state, so a driver
/// handed a list of `Connected`/`Disconnected`/`Unknown` values cannot tell
/// which connector each refers to or what to program. The per-connector
/// element of an atomic commit is `ConnectorCommit` ([Section 13.3](13-device-classes.md#display-subsystem));
/// the userspace-side request element is `ConnectorUpdate` (below).
#[repr(u32)]
pub enum ConnectorState {
/// Display attached and sink detected (digital: HPD asserted; analog:
/// load detected). EDID may or may not have been read successfully.
Connected = 1,
/// No display attached. For digital outputs (DP, HDMI) this means HPD
/// is deasserted. For analog (VGA) this means no load detected.
Disconnected = 2,
/// Connection status could not be reliably determined. The connector
/// should be treated as potentially connected; the compositor may
/// attempt to light it up with fallback modes from the connector's
/// mode list.
Unknown = 3,
}
/// Display connector.
///
/// Mutable connector properties (EDID, modes, active mode) are grouped into
/// a single `ConnectorProps` snapshot, swapped atomically via RCU during
/// hotplug or modeset. This eliminates per-field RwLock overhead and ensures
/// readers always see a consistent snapshot (no half-updated EDID + stale
/// mode list). Connector state and DPMS are independent atomic fields
/// because they change on different paths (hotplug IRQ vs userspace ioctl).
///
/// **Core-side only — never crosses a domain boundary.** This struct owns a
/// `RcuPtr<Arc<ConnectorProps>>`, two atomics, and a raw driver context
/// pointer, so it is not plain data: copying it bytewise into a caller-supplied
/// buffer would duplicate the `Arc` without a refcount increment (premature
/// free / use-after-free when either copy drops) and would read the atomics
/// non-atomically. Enumeration across the KABI boundary uses the plain-data
/// `ConnectorInfo` snapshot instead ([Section 13.3](13-device-classes.md#display-subsystem)).
pub struct DisplayConnector {
/// Connector ID (unique per display device).
pub id: u32,
/// Connector type.
pub connector_type: ConnectorType,
/// Current state (connected, disconnected).
///
/// Transitions are `compare_exchange`, never a separate load followed by a
/// store: two hotplug sources on one device (an MST hub and an eDP panel
/// asserting in the same interval) or a hotplug racing a userspace-driven
/// update would both read the old value and both store, losing one
/// transition. The CAS winner is also the one that owns publishing the
/// consequences of that transition, so the follow-up work runs exactly once.
pub state: AtomicU32, // ConnectorState
/// DPMS (Display Power Management Signaling) state.
pub dpms: AtomicU32, // DpmsState
/// Mutable connector properties. Updated during hotplug (EDID read,
/// mode list rebuild) and modeset (active_mode change). RCU-protected:
/// readers (userspace mode queries, compositor enumeration) are lock-free.
///
/// **Writers serialize on `DisplayDevice::commit_mutex`**, whose guard is
/// the `WriterProof` that `RcuPtr::update()` requires
/// ([Section 3.4](03-concurrency.md#cumulative-performance-budget)). Both writers — the hotplug
/// worker and atomic commit — take it, which is what stops a commit from
/// validating a mode against a property snapshot that hotplug replaces with
/// an empty one an instant later, and then programming a connector that is
/// no longer attached. Two unsynchronized `update()` callers would also
/// swap the same pointer and double-free.
pub props: RcuPtr<Arc<ConnectorProps>>,
/// Back-reference to the parent display device's driver operations and
/// opaque driver context. Used by connector methods (VRR, DPMS, hotplug)
/// to call into the hardware driver via `DisplayHwOps` function pointers.
pub driver: DisplayDriverRef,
}
/// Reference to a display driver, as a transport-selecting KABI handle.
/// Stored in each `DisplayConnector` and in the owning `DisplayDevice` so
/// object-level methods (VRR enable, DPMS control, connector state) can reach
/// the driver without traversing the parent.
///
/// **A handle, not a vtable pointer plus context.** A `&'static DisplayHwOps`
/// with a raw `ctx` is reachable only by dereferencing the driver's function
/// pointers from the core's own domain — which works exactly when the driver
/// shares that domain and is undefined when it does not. A display driver is
/// tier-agnostic (an integrated engine bound at Tier 1, a USB DisplayLink or
/// network display server at Tier 2, either of them promoted or demoted at
/// runtime), so the call surface has to carry the binding, not assume it. The
/// handle is what `kabi_call!` consults; it also carries the provider
/// generation, so a crashed or replaced driver yields `KabiError::StaleHandle`
/// instead of a call into a dead image.
pub struct DisplayDriverRef {
/// Transport handle for this controller's driver, obtained from
/// `DomainService::resolve()` when the display device was bound. EVERY
/// call into the driver goes through `kabi_call!(&ref.handle, …)`; the
/// opaque provider context is inside the handle, not exposed here.
pub handle: KabiHandle<DisplayDriverService>,
/// Driver→core VBlank/page-flip ring negotiated through the SAME service
/// handle. This is an opaque registry handle, not a driver-domain pointer;
/// the core's vsync worker drains its consumer endpoint.
pub vsync_ring: RingBufferHandle,
}
impl DisplayDriverRef {
/// Finish a display-service bind. `vsync_ring` is itself a KABI method:
/// same-domain binding returns the local ring handle directly, while a
/// cross-domain binding returns the shared-ring handle over the selected
/// transport. No core code calls `DisplayDriver::vsync_ring()` through a
/// raw trait object or retains the driver's `&RingBuffer`.
pub fn bind(
handle: KabiHandle<DisplayDriverService>,
) -> Result<DisplayDriverRef, KabiError> {
let vsync_ring = kabi_call!(&handle, vsync_ring)?;
Ok(DisplayDriverRef { handle, vsync_ring })
}
}
/// Immutable snapshot of connector properties. Created during hotplug
/// (EDID parse → mode list → props swap) or atomic commit (active_mode
/// change). Freed after RCU grace period when superseded.
pub struct ConnectorProps {
/// EDID data. Fixed-size buffer avoids heap allocation during hotplug.
/// EDID standard: 128 bytes/block; E-EDID extensions up to 256 bytes;
/// DisplayID and CTA extensions can reach 512 bytes total.
pub edid: Option<ArrayVec<u8, 512>>,
/// Supported display modes (parsed from EDID or driver-provided fallbacks).
/// Typical displays advertise 10-40 modes; 64 is sufficient for 8K panels
/// with multiple refresh rates.
pub modes: ArrayVec<DisplayMode, 64>,
/// Currently active mode (if connected and enabled).
pub active_mode: Option<DisplayMode>,
}
/// Display mode (resolution, refresh rate).
#[repr(C)]
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct DisplayMode {
/// Horizontal resolution in pixels. **Kernel-internal type**: u16 is sufficient
/// for display resolutions (max 65535; 8K = 7680). Linux DRM uses `int` (i32) for
/// hdisplay/vdisplay, but the ABI-facing `drm_mode_modeinfo` uses `__u16`. UmkaOS
/// uses u16 for the kernel-internal type to match the ABI type and save space.
pub hdisplay: u16,
/// Vertical resolution in pixels (same u16 rationale as hdisplay).
pub vdisplay: u16,
/// Refresh rate in millihertz (60000 = 60.000 Hz).
pub vrefresh_mhz: u32,
/// Flags (interlaced, VRR capable, preferred mode).
pub flags: u32,
/// Pixel clock in kHz (for driver use, validates mode is achievable).
pub clock_khz: u32,
/// Horizontal timings (front porch, sync, back porch).
/// u32 matches Linux DRM `drm_display_mode` (uses `int` for all timing fields).
/// Required for 8K@120Hz+ with extended VRR blanking where htotal can exceed 65535.
pub hsync_start: u32,
pub hsync_end: u32,
pub htotal: u32,
/// Vertical timings (front porch, sync, back porch).
pub vsync_start: u32,
pub vsync_end: u32,
pub vtotal: u32,
}
// DisplayMode: u16(2)*2 + u32(4)*9 = 40 bytes.
// Kernel-internal display mode representation. The ioctl layer translates to/from
// Linux's drm_mode_modeinfo (u16 timing fields) for ABI compatibility. u32 timing
// fields allow htotal > 65535 for 8K@120Hz+ with extended VRR blanking.
//
// **Ioctl translation policy**: When copying to userspace `drm_mode_modeinfo`
// (e.g., `DRM_IOCTL_MODE_GETCONNECTOR`), timing fields exceeding `u16::MAX`
// cause the mode to be omitted from the userspace mode list. The kernel logs
// `klog(Info, "DRM: mode {}x{}@{}Hz omitted from userspace list (htotal={} > u16::MAX)",
// ...)` for discoverability. Such modes are accessible only via the UmkaOS-native
// atomic modesetting interface (Phase 4).
const_assert!(core::mem::size_of::<DisplayMode>() == 40);
/// Display mode flags.
pub mod mode_flags {
/// Interlaced mode.
pub const INTERLACED: u32 = 1 << 0;
/// Variable Refresh Rate (VRR) capable (FreeSync, G-Sync, HDMI VRR).
pub const VRR: u32 = 1 << 1;
/// Preferred mode (from EDID).
pub const PREFERRED: u32 = 1 << 2;
}
/// DPMS (Display Power Management Signaling) state.
#[repr(u32)]
pub enum DpmsState {
/// Display on, normal operation.
On = 0,
/// Display standby (monitor sleeps, can wake instantly).
Standby = 1,
/// Display suspend (lower power than standby).
Suspend = 2,
/// Display off (lowest power, may take 1-2 seconds to wake).
Off = 3,
}
21.5.4 Atomic Modesetting Protocol¶
UmkaOS uses an atomic modesetting model (same as Linux DRM atomic). Changes to the display configuration (resolution, framebuffer, connector enable/disable) are batched into a single atomic transaction. Either all changes apply or none do. This eliminates tearing and half-configured states.
// umka-nucleus/src/display/atomic.rs
/// Atomic modesetting request — the decoded form of one
/// `DRM_IOCTL_MODE_ATOMIC`.
///
/// **The ABI is a property list; the kernel form is typed.** `drm_mode_atomic`
/// (below) hands the kernel four parallel userspace arrays — object ids,
/// per-object property counts, property ids, and property values — plus a
/// flag mask and a `user_data` cookie. That flat encoding is the contract
/// UmkaOS owes ([Section 22.7](22-accelerators.md#accelerator-networking-rdma-and-linux-gpu-compatibility)
/// promises the full property-based interface), and it is what the ioctl layer
/// parses. It is *not* how the kernel then carries the request: the parser
/// resolves each object id to a hardware object, each property id to the field
/// it names, each blob id to its contents, and each fence fd to a `FencePoint`,
/// producing this struct. Everything a commit can express therefore has a
/// typed home here — including the CRTC-level colour blobs (`GAMMA_LUT`,
/// `DEGAMMA_LUT`, `CTM`), the completion-event cookie, and the in/out fences,
/// none of which a connector/plane-geometry-only request could carry.
///
/// Uses fixed-capacity `ArrayVec` instead of `Vec` to avoid heap allocation
/// on every display frame. The bounds are hardware-limited: no display
/// controller has more than `MAX_CONNECTORS` (8) connectors, `MAX_PLANES`
/// (32) planes, or `MAX_CRTCS` (8) CRTCs. At 60fps+ with multiple displays,
/// eliminating per-frame heap allocation avoids allocator contention on the
/// latency-sensitive commit path.
///
/// Unknown property ids, and properties not applicable to the named object,
/// are rejected with `-EINVAL` before any object is touched — a commit is
/// either fully decodable or not attempted.
pub struct AtomicModeset {
/// Connector changes (enable, disable, mode change, power state).
pub connectors: ArrayVec<ConnectorUpdate, MAX_CONNECTORS>,
/// Plane changes (scanout buffer, position, scaling, in-fence).
pub planes: ArrayVec<PlaneUpdate, MAX_PLANES>,
/// CRTC changes (enable, mode, colour pipeline, out-fence).
pub crtcs: ArrayVec<CrtcUpdate, MAX_CRTCS>,
/// Flags for this transaction. `CommitFlags` ([Section 13.3](13-device-classes.md#display-subsystem)) is
/// the single commit-flag type in the spec, carrying the Linux
/// `DRM_MODE_ATOMIC_*` / `DRM_MODE_PAGE_FLIP_*` bit values verbatim, so
/// the userspace mask needs no translation and cannot acquire a second,
/// contradictory meaning on the way to the driver.
///
/// A plain value, not an atomic cell: the request is decoded once and is
/// immutable through check and commit. A flag mask mutable mid-transaction
/// would let the latch mode change between validation and programming.
pub flags: CommitFlags,
/// Completion-event request, present when `CommitFlags::EVENT` is set.
pub event: Option<AtomicEventRequest>,
}
/// Completion-event request carried by an `AtomicModeset`.
pub struct AtomicEventRequest {
/// Opaque cookie echoed back in `DrmEventVblank::user_data` when the
/// commit latches. Supplied by the compositor; the kernel never
/// interprets it.
pub user_data: u64,
}
/// Connector update (part of atomic transaction).
pub struct ConnectorUpdate {
/// Connector ID.
pub connector_id: u32,
/// New mode (None = disable connector).
pub mode: Option<DisplayMode>,
/// CRTC to attach this connector to (if enabling).
pub crtc_id: Option<u32>,
/// New DPMS power state, when the transaction changes it
/// (the `DPMS` connector property). `None` leaves it unchanged.
pub dpms: Option<DpmsState>,
}
/// Plane update (part of atomic transaction).
pub struct PlaneUpdate {
/// Plane ID.
pub plane_id: u32,
/// CRTC this plane is attached to (`None` = disable plane).
pub crtc_id: Option<u32>,
/// Framebuffer id from `DRM_IOCTL_MODE_ADDFB2` (None = disable plane).
/// Resolved and generation-validated through `FramebufferTable::resolve()`
/// at ioctl entry; the resolved `FramebufferRef` is held by the
/// transaction, so the id cannot be retired out from under the commit.
pub fb: Option<FramebufferId>,
/// Source rectangle in framebuffer (for scaling/cropping).
pub src: Rectangle,
/// Destination rectangle on screen.
pub dst: Rectangle,
/// Resolved `IN_FENCE_FD` plane property: scanout of this plane's new
/// framebuffer waits for this fence. `None` = no wait.
pub in_fence: Option<FencePoint>,
}
/// CRTC update (part of atomic transaction).
pub struct CrtcUpdate {
/// CRTC ID.
pub crtc_id: u32,
/// New value of the `ACTIVE` CRTC property.
pub active: bool,
/// New mode from the `MODE_ID` property blob (None = leave unchanged).
pub mode: Option<DisplayMode>,
/// Contents of the `GAMMA_LUT` property blob (None = leave unchanged).
pub gamma_lut: Option<GammaLut>,
/// Contents of the `DEGAMMA_LUT` property blob (None = leave unchanged).
pub degamma_lut: Option<GammaLut>,
/// Contents of the `CTM` property blob (None = leave unchanged).
pub ctm: Option<ColorTransformMatrix>,
/// Whether the caller requested an `OUT_FENCE_PTR` for this CRTC. The
/// kernel creates the fence during the prepare phase and writes its fd
/// back before returning.
pub request_out_fence: bool,
}
/// `DRM_IOCTL_MODE_ATOMIC` argument. Userspace ABI — matches Linux
/// `struct drm_mode_atomic` in `include/uapi/drm/drm_mode.h` (torvalds/linux
/// master) field for field. The four `*_ptr` fields are userspace addresses of
/// parallel arrays: `objs_ptr[count_objs]` object ids, `count_props_ptr[count_objs]`
/// property counts per object, then `props_ptr[]` / `prop_values_ptr[]`
/// flattened across all objects in the same order.
#[repr(C)]
pub struct DrmModeAtomic {
/// Flag mask; validated against the `CommitFlags` bits
/// ([Section 13.3](13-device-classes.md#display-subsystem)), which are the Linux `DRM_MODE_ATOMIC_FLAGS`
/// values. Any other bit set → `-EINVAL`.
pub flags: u32,
pub count_objs: u32,
pub objs_ptr: u64,
pub count_props_ptr: u64,
pub props_ptr: u64,
pub prop_values_ptr: u64,
/// Reserved; must be zero.
pub reserved: u64,
/// Echoed back in `DrmEventVblank::user_data` when `EVENT` is requested.
pub user_data: u64,
}
// DrmModeAtomic: u32(4)*2 + u64(8)*6 = 56 bytes.
// Userspace ABI struct — DRM_IOCTL_MODE_ATOMIC argument.
const_assert!(core::mem::size_of::<DrmModeAtomic>() == 56);
/// Rectangle (for plane src/dst).
#[repr(C)]
#[derive(Clone, Copy)]
pub struct Rectangle {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
// Rectangle: u32(4)*4 = 16 bytes.
// Used in atomic modesetting plane source/destination parameters.
const_assert!(core::mem::size_of::<Rectangle>() == 16);
Atomic commit flow. Steps 4–9 are the protocol that makes "all or none" real: every hardware entry point is fallible, so a commit that programmed some objects and then failed would leave the display half-configured with no defined recovery. The commit therefore never writes a live register — it stages, then latches, then publishes, and it can abort cleanly until the latch is armed.
- Wayland compositor builds an
AtomicModesettransaction: "attach framebuffer FB123 to primary plane, set mode to 1920x1080@60Hz on connector 0, disable connector 1". - Compositor calls
ioctl(dri_fd, DRM_IOCTL_MODE_ATOMIC, &drm_mode_atomic); the ioctl layer decodes the property arrays into anAtomicModeset(Section 19.1). Modesetting requires DRM master, so the kernel rejects a non-master caller with-EACCEShere. - Acquire. The kernel takes
DisplayDevice::commit_mutex, re-checks DRM master under it, resolves everyFramebufferIdthroughFramebufferTable::resolve(), and holds the resultingFramebufferRefs in the transaction. From here to the arming in step 7 no other commit and no hotplug property swap can run on this device — with the one deliberate exception of aNONBLOCKcommit that parks on its fences at step 5, which releases the mutex and re-validates its ticket when it resumes. No resolved framebuffer can be freed for as long as the transaction (or the pending flip that succeeds it) holds its reference. - Check. The core invokes
kabi_call!(&device.driver.handle, atomic_check, &commit); the generateddisplay_device_v1stub returns aCommitTicketbound to the currentDisplayDevice::state_epoch: - Mode is supported by the connector (in the
modeslist from EDID). - Framebuffer format is supported by the plane (RGB888, XRGB8888, NV12, etc.).
- Plane→CRTC assignment is permitted by the plane's
possible_crtcsmask. - Bandwidth is achievable (pixel clock within limits, memory bandwidth sufficient).
With
CommitFlags::TEST_ONLYthe transaction ends here and reports the verdict; nothing is programmed. - Await fences — in the CORE, never in the driver. Every
PlaneCommit::in_fencemust signal before that plane's framebuffer may be latched. AFencePointis a core object (Section 13.5), so a driver in another domain cannot wait on one at all; the wait therefore belongs on this side of the KABI boundary, and the driver is called only once every fence has signaled. Two shapes, and this is whereNONBLOCKis actually honoured: - Blocking: the committing thread waits here, still holding
commit_mutex. NONBLOCK: the thread does NOT wait. The transaction — resolved framebuffer references, ticket, flags, event target, prepared snapshots — is moved into aQueuedCommitregistered on the device, a completion callback is armed on each unsignaled fence,commit_mutexis released, and the ioctl returnsCommitOutcome::Queued. The last fence to signal wakes the commit worker (umkad-drm-commit-N, process context), which re-takescommit_mutex, re-checksticket.epoch(re-runningatomic_checkif a hotplug moved the epoch meanwhile), and continues at step 6. A commit that returnedQueuedwhile its fences were unsignaled is precisely what the flag promises; blocking there would makeNONBLOCKa no-op for exactly the case it exists for — a compositor submitting a frame the GPU has not finished rendering.- Prepare. The core invokes
kabi_call!(&device.driver.handle, atomic_commit, &commit, ticket, flags); the driver stages the whole transaction into shadow registers. No live register is modified. If any step fails, the driver discards the shadow programming and returns the error; the kernel drops the transaction's framebuffer references and the display keeps running its previous configuration unchanged. This is the abort path — there is nothing to roll back because nothing took effect. For a queued commit the error is reported to the compositor as a completion event carrying the failure, since the ioctl has already returned. - Arm. The driver arms the hardware to latch the entire shadow set at once
(on the next vblank, or immediately with
CommitFlags::ASYNC). A failure here is still an abort: the latch was not armed, so the live configuration is untouched. Once armed, the kernel installs aPendingFlipon each affected CRTC holding the incoming framebuffer references, the outgoing ones to retire, that CRTC's new state snapshots, the arming VBlank sequence, and the completion-event target; theQueuedCommitregistration is then dropped, its ownership of the transaction having passed to the flips. - Return. Without
CommitFlags::NONBLOCKthe caller — which releasedcommit_mutexas soon as thePendingFlipwas installed — waits on the CRTC'svblank.waitersuntilvblank.completed_seqreaches itscommit_seq, then returnsCommitOutcome::Latched. ANONBLOCKcaller has already returnedCommitOutcome::Queuedat step 5 and learns of completion from the event. Neither holdscommit_mutexacross the frame; if it were held, the publication in step 9 could never take it. - Publish. The hardware latches and the driver posts a completion carrying
commit_seq(Section 21.5). The vsync worker takescommit_mutex, publishes the flip's own state snapshots at a single point under the publication seqlock (Section 21.5), bumpsstate_epoch, advancesvblank.completed_seq(releasing any blocking committer), posts the userspace event, and hands the retiring framebuffer references to the flip-retirement workqueue. The new framebuffer is now being scanned out (tear-free unlessASYNCwas requested).
Forced retirement. A PendingFlip is retired exactly once — by the latch
above, by CRTC disable, by device teardown/driver crash recovery, or by the
missed-completion rule below. All of those retire it with the current timestamp
so a compositor blocked on its completion event is never stranded, and so the
retiring framebuffer references are always released.
Missed completion. The driver→core ring drops its oldest entry rather than
stalling the interrupt handler (Section 21.5),
so a PAGE_FLIP_COMPLETE can be lost — and the three cases above supply no
trigger for a flip that DID latch but whose event vanished. The recovery is the
VBlank sequence, which every entry carries: a flip is armed to latch on the next
VBlank (sooner with ASYNC), so once the core observes any entry for that CRTC
whose sequence is past PendingFlip::armed_at_seq, the latch is a completed
fact. The vsync worker retires such a flip exactly as if its own completion had
arrived — publishing its snapshots, advancing completed_seq, posting the event
with the observed timestamp, releasing the retiring references. A commit
therefore cannot be stranded by ring overflow, and no framebuffer stays pinned
because its event was overwritten.
21.5.5 Framebuffer Objects¶
A framebuffer is a region of GPU memory containing pixel data. The display controller's scanout engine reads from the framebuffer via DMA and sends pixels to the monitor.
// umka-nucleus/src/display/framebuffer.rs
/// Kernel-internal framebuffer identity. `u64`, monotonically allocated per
/// device, never reused within the operational lifetime — the durable name a
/// log line or a trace record can carry without ambiguity.
///
/// Distinct from `FramebufferId` ([Section 13.3](13-device-classes.md#display-subsystem)), which is the
/// *userspace* DRM object id: `u32` because the Linux ABI fixes that width,
/// slot-and-generation encoded, and never re-minted after retirement. Nothing in the
/// kernel dereferences a `FramebufferId` directly — it is resolved through
/// `FramebufferTable::resolve()` below, which is what turns a possibly-stale
/// userspace id into a `FramebufferRef` that owns what it points at.
// kernel-internal, not KABI — opaque handle type.
#[repr(C)]
pub struct FramebufferHandle(u64);
/// Owning reference to a framebuffer. Every consumer that can keep the
/// display engine reading a buffer holds one: a published `PlaneState`, a
/// `PendingFlip` awaiting its latch, and an in-flight commit transaction.
/// The backing pages are unpinned only when the last one drops, which is what
/// makes `DRM_IOCTL_MODE_RMFB` safe against active scanout.
pub type FramebufferRef = Arc<Framebuffer>;
/// Per-device framebuffer object table — the `DRM_IOCTL_MODE_ADDFB2` id
/// namespace. Owned by `DrmDevice`.
///
/// Allocatable slot indices 1 through `FB_SLOT_MASK` may each serve generations
/// 0 through `FB_GEN_MASK`, but a projected generation is never reused. Slot
/// index 0 is permanently reserved because the wire value 0 means "no
/// framebuffer"; no framebuffer is ever installed there. After generation
/// `FB_GEN_MASK` is retired, that slot is permanently exhausted for this device
/// lifetime. Consequently no successful `ADDFB2` can ever mint an id previously
/// returned by this table, and `ADDFB2` never returns 0; namespace exhaustion is
/// reported as `-ENOSPC`, never converted into aliasing.
pub struct FramebufferTable {
/// Slot array, keyed by the low 20 bits of a `FramebufferId`
/// (integer key, O(1) lookup, RCU-friendly reads).
pub slots: XArray<Arc<FramebufferSlot>>,
/// Rotor for fair slot selection. Allocation scans at most one complete
/// 20-bit revolution, starting here, and selects the first FREE,
/// NON-EXHAUSTED slot. Occupied and exhausted slots are skipped.
///
/// `AtomicU32` and wrapping is intended here: this is a rotor over a
/// 20-bit space, not an identity counter and not part of id validity.
pub next_slot: AtomicU32,
/// Serializes `ADDFB2` slot installation with `RMFB` retirement. This is a
/// sleeping mutex: both ioctls are process-context control paths, and the
/// guard is also the `WriterProof` for `FramebufferSlot::fb.update()`.
pub namespace_lock: Mutex<()>,
}
/// One framebuffer table slot.
pub struct FramebufferSlot {
/// Next generation for this slot. Values `0..=FB_GEN_MASK` are issuable.
/// `FB_GEN_EXHAUSTED` means the slot has consumed every 12-bit projection
/// and can never be allocated again. Retirement uses checked addition and
/// stops at that sentinel; this counter neither wraps nor projects a second
/// time onto an old `FramebufferId`.
pub generation: AtomicU64,
/// The framebuffer occupying this slot, or `None` if the slot is free.
/// RCU-protected so `resolve()` is lock-free on the commit path.
pub fb: RcuPtr<FramebufferRef>,
}
impl FramebufferTable {
/// `ADDFB2` allocation protocol (normative):
///
/// 1. Take `namespace_lock`; snapshot `next_slot & FB_SLOT_MASK`.
/// 2. Inspect exactly `FB_SLOT_COUNT` consecutive indices modulo
/// `FB_SLOT_COUNT`. Slot index 0 is permanently ineligible. Any other
/// slot is eligible only when `fb == None` and
/// `generation <= FB_GEN_MASK`.
/// 3. For the first eligible slot, install the new `FramebufferRef`, mint
/// `FramebufferId((generation as u32) << FB_SLOT_BITS | slot_index)`,
/// advance `next_slot` to the following index, and return the id.
/// 4. If the full revolution contains no eligible slot, return
/// `DisplayError::FramebufferIdsExhausted` (`-ENOSPC`). Do not reset a
/// generation, replace an occupied slot, or recycle an exhausted one.
///
/// `RMFB`, under the same mutex, clears `fb` and changes generation `g` to
/// `g + 1` when `g < FB_GEN_MASK`, or to `FB_GEN_EXHAUSTED` when
/// `g == FB_GEN_MASK`. These are the only generation writes. Therefore an
/// id is minted at most once during the device lifetime, independently of
/// how long userspace stalls while holding a stale value.
/// Resolve a userspace framebuffer id to an owning reference.
///
/// Returns `Err(DisplayError::FramebufferNotFound)` (`-ENOENT`) if the slot
/// is free, if the id's generation field does not match the slot's current
/// generation (a retired id, or one from a previous occupant), or if a
/// retire raced the read. Called at ioctl entry, before a commit is built;
/// the returned reference is held for the life of the commit, so no
/// concurrent `RMFB` can retarget or free the buffer.
///
/// **What the two generation reads each do**, since they are different
/// checks and were once described as one:
///
/// - The FIRST rejects an exhausted slot and compares the id's 12
/// generation bits against the slot's current generation. Because the
/// allocation protocol never issues a projected generation twice, a
/// successful comparison identifies exactly one lifetime occupant.
/// - The SECOND re-reads the slot's `AtomicU64` generation and requires it
/// to be UNCHANGED. That is a concurrent-retire check, not a wider
/// identity check: it guarantees the `Arc` taken from `slot.fb` belongs to
/// the generation validated a moment earlier, rather than to an occupant
/// installed between the two reads.
///
/// **Non-aliasing invariant.** A retired id always fails: its slot either
/// carries the next still-issuable generation or is exhausted. Exhaustion
/// returns `-ENOSPC`; it never makes a stale id name a new framebuffer.
pub fn resolve(&self, id: FramebufferId) -> Result<FramebufferRef, DisplayError> {
let guard = rcu_read_lock();
let slot = self
.slots
.xa_load(fb_slot_index(id) as u64)
.ok_or(DisplayError::FramebufferNotFound)?;
// Id validity: the 12 generation bits the id carries against the
// slot's current generation.
let gen = slot.generation.load(Ordering::Acquire);
if gen > FB_GEN_MASK as u64 || fb_generation_bits(id) != gen as u32 {
return Err(DisplayError::FramebufferNotFound);
}
let fb = slot
.fb
.read(&guard)
.ok_or(DisplayError::FramebufferNotFound)?;
// Concurrent-retire check: a retire between the two reads must not
// hand back a reference belonging to a different generation than the
// one just validated.
if slot.generation.load(Ordering::Acquire) != gen {
return Err(DisplayError::FramebufferNotFound);
}
Ok(FramebufferRef::clone(fb))
}
}
/// Slot index encoded in a `FramebufferId` (low 20 bits).
pub fn fb_slot_index(id: FramebufferId) -> u32 {
id.0 & FB_SLOT_MASK
}
/// Generation bits encoded in a `FramebufferId` (high 12 bits).
pub fn fb_generation_bits(id: FramebufferId) -> u32 {
id.0 >> FB_SLOT_BITS
}
/// Width of the slot-index field in a `FramebufferId`.
pub const FB_SLOT_BITS: u32 = 20;
/// Size of the encoded slot-index space. Index 0 is permanently reserved, so
/// `FB_SLOT_MASK` (1 048 575) indices are allocatable.
pub const FB_SLOT_COUNT: u32 = 1 << FB_SLOT_BITS;
/// Mask selecting the slot-index field of a `FramebufferId`.
pub const FB_SLOT_MASK: u32 = (1 << FB_SLOT_BITS) - 1;
/// Largest issuable 12-bit generation value.
pub const FB_GEN_MASK: u32 = (1 << (32 - FB_SLOT_BITS)) - 1;
/// Non-issuable generation sentinel. Once a slot reaches this value it remains
/// exhausted until the display device (and therefore its whole id namespace)
/// is destroyed.
pub const FB_GEN_EXHAUSTED: u64 = FB_GEN_MASK as u64 + 1;
/// Framebuffer format (pixel layout).
#[repr(u32)]
pub enum FramebufferFormat {
/// 32bpp XRGB (X=unused, R=red, G=green, B=blue; 8 bits each).
Xrgb8888 = 0x34325258,
/// 32bpp ARGB (with alpha channel).
Argb8888 = 0x34325241,
/// 24bpp RGB (no alpha, no padding).
Rgb888 = 0x34324752,
/// 16bpp RGB565.
Rgb565 = 0x36314752,
/// YUV 4:2:0 planar (NV12, for video).
Nv12 = 0x3231564e,
}
/// Handle to a backing graphics memory object (a GEM object, dumb buffer, or an
/// imported DMA-BUF) that provides the physical pages a `Framebuffer` scans out.
/// An opaque `u64` key into the owning GPU driver's memory-object table; the
/// display subsystem never dereferences it — it passes the handle to the driver
/// (via `kabi_call!`) for pin/map and DMA-BUF export (see [Section 4.14](04-memory.md#dma-subsystem)).
// kernel-internal, not KABI — opaque handle, never exposed to userspace.
#[repr(transparent)]
pub struct MemoryObjectHandle(u64);
/// Framebuffer descriptor. Held through `FramebufferRef` by everything that
/// can keep it on screen; the backing memory object is unpinned in `drop()`.
pub struct Framebuffer {
/// Kernel-internal identity (never reused).
pub handle: FramebufferHandle,
/// Userspace DRM object id this framebuffer was published under, and the
/// id the driver knows it by. Retired by `DRM_IOCTL_MODE_RMFB`; the
/// object itself outlives the id for as long as it is referenced.
pub id: FramebufferId,
/// Width in pixels.
pub width: u32,
/// Height in pixels.
pub height: u32,
/// Pixel format.
pub format: FramebufferFormat,
/// Pitch (bytes per row; may be larger than width * bpp if aligned).
pub pitch: u32,
/// GPU memory object backing this framebuffer (for DMA-BUF export, Section 4.3/Section 21.4).
pub mem_obj: MemoryObjectHandle,
}
Framebuffer allocation: Compositor allocates GPU memory (via the GPU driver, Section 9.1/Section 22.1), renders the desktop into it (via Vulkan/OpenGL), then creates a framebuffer object pointing to that memory and passes it to the display subsystem for scanout.
Framebuffer destruction (DRM_IOCTL_MODE_RMFB): id retirement and memory
release are two separate events, and conflating them is what would let a
compositor free memory the display engine is actively reading:
- Under
namespace_lock, the slot'sfbpointer is cleared and its generation advances by the checked rule above. The id is now invalid — every laterresolve()reports-ENOENT. The slot becomes eligible under the next generation only while that generation is at mostFB_GEN_MASK; after its last projection it becomes permanently exhausted. - Every plane still scanning this framebuffer out is disabled by an internally
generated atomic commit, and any pending flip that references it is allowed
to complete normally. This matches the observable Linux contract:
RMFBreturns 0, the id is gone, and planes using the buffer are turned off rather than left pointing at freed memory. - The object itself lives until the last
FramebufferRefdrops — the table slot's, each publishedPlaneState's, eachPendingFlip's. Only then doesFramebuffer::drop()callDisplayDriver::destroy_framebuffer()to unpin the DMA-BUF and release the backing memory object.
Because step 3 is refcount-driven, there is no window in which the pages can be returned while the scanout engine still has the address programmed, and no way for a retired id to be recycled into a commit that was built against the old occupant.
21.5.6 Scanout Planes¶
Modern display controllers have multiple planes (hardware overlays) that can scan out independent framebuffers simultaneously: - Primary plane: The desktop/window contents (always present). - Cursor plane: The mouse cursor (small, can be moved with no desktop re-render). - Overlay planes: Video playback windows (compositor passes video framebuffer directly to hardware, zero-copy).
// umka-nucleus/src/display/plane.rs
/// Display plane (hardware overlay).
///
/// Plane state (framebuffer, position, scaling) is grouped into a single
/// `PlaneState` snapshot, swapped atomically via RCU during atomic commit.
/// This eliminates the need to acquire three separate RwLocks (fb, src, dst)
/// and guarantees readers see a consistent plane configuration.
pub struct DisplayPlane {
/// Plane ID (unique per display device).
pub id: u32,
/// Plane type.
pub plane_type: PlaneType,
/// Bitmask of CRTCs this plane can be assigned to. Bit N set means this
/// plane is compatible with the CRTC at index N in `DisplayDevice::crtcs`.
/// Required for atomic commit validation: the kernel rejects commits that
/// assign a plane to a CRTC not in its `possible_crtcs` set.
pub possible_crtcs: u32,
/// Supported framebuffer formats (immutable after probe).
// ArrayVec<_, 16>: KABI-stable (no heap pointer, no runtime allocation).
// 16 format slots cover all known display hardware (typical: 4–12 formats per plane).
// Cannot use Vec across KABI boundary (Rust Vec layout not guaranteed stable).
pub formats: ArrayVec<FramebufferFormat, 16>,
/// Current plane state. Replaced atomically during modeset commit.
/// Readers (vblank handlers, userspace queries) get a consistent
/// snapshot via `rcu_read_lock()` — no per-field locking.
pub state: RcuPtr<Arc<PlaneState>>,
}
/// Immutable snapshot of plane state. Created during atomic commit and
/// swapped via RCU. Freed after grace period when superseded.
pub struct PlaneState {
/// Current framebuffer attached (None = plane disabled).
///
/// An owning `FramebufferRef`, not a bare handle: this snapshot is exactly
/// the record of what the display engine is scanning out, so it is the
/// natural owner of the reference that keeps those pages pinned. A bare
/// handle here would make "which framebuffer is live" a fact nothing holds
/// a reference to, which is how `RMFB` during scanout becomes a
/// use-after-free. The reference is released when this state snapshot is
/// superseded and freed after the RCU grace period.
pub fb: Option<FramebufferRef>,
/// Source rectangle in framebuffer (for scaling/cropping).
pub src: Rectangle,
/// Destination rectangle on screen.
pub dst: Rectangle,
}
/// Plane type.
#[repr(u32)]
pub enum PlaneType {
/// Primary plane (desktop contents).
Primary = 0,
/// Cursor plane (mouse cursor, small, high-priority).
Cursor = 1,
/// Overlay plane (video, additional window).
Overlay = 2,
}
Cursor plane optimization: Moving the cursor only requires updating the cursor plane's dst rectangle. The compositor does NOT need to re-render the desktop or flip the primary plane. This is why modern desktops have smooth 144Hz cursors even with a 60Hz desktop.
21.5.7 Hotplug Detection¶
When a display is connected (USB-C DP Alt Mode, HDMI, etc.), the display controller raises an interrupt. The driver handles hotplug in the interrupt handler:
// umka-nucleus/src/display/hotplug.rs
/// Per-display-controller device state.
/// One instance per display controller (e.g., one per i915 GPU, one per
/// DisplayPort MST hub). Created by the display driver during probe.
pub struct DisplayDevice {
/// Device registry handle for this display controller.
pub device_id: DisplayDeviceId,
/// Driver operations table and opaque driver context for this controller.
/// The device-level counterpart of `DisplayConnector::driver`: without it
/// the device's own methods have no way to reach the hardware, since every
/// hardware entry point is a `DisplayHwOps` function taking `(ctx, ...)`.
pub driver: DisplayDriverRef,
/// All connectors attached to this controller (HDMI, DP, eDP, etc.).
pub connectors: ArrayVec<DisplayConnector, MAX_CONNECTORS>,
/// Hardware display planes available for composition.
pub planes: ArrayVec<DisplayPlane, MAX_PLANES>,
/// MMIO base address for display controller registers.
pub mmio_base: u64,
/// IRQ number for hotplug/vblank interrupts.
pub irq: u32,
/// Device-wide modeset serializer. Held across the whole `atomic_check` →
/// `atomic_commit` pair and across a hotplug property publication, so
/// those are the only two writers of display state and they never
/// interleave. Its guard is the `WriterProof` for every
/// `RcuPtr::update()` on this device's connectors, planes, and CRTCs
/// ([Section 3.4](03-concurrency.md#cumulative-performance-budget)).
///
/// A sleeping `Mutex` ([Section 3.5](03-concurrency.md#locking-strategy)), not a spinlock: commit
/// preparation waits on in-fences and on hardware. Leaf in the
/// sleeping-mutex order — no other sleeping lock is acquired while it is
/// held. Never taken from interrupt context; the hotplug and vblank
/// interrupt paths hand their work to process context precisely so they
/// can take it there.
pub commit_mutex: Mutex<ModesetState>,
/// Published-state epoch. Bumped once per publication — a committed
/// transaction, or a hotplug property swap. `atomic_check` samples it into
/// the `CommitTicket` it returns and `atomic_commit` re-checks it, which is
/// what makes a check binding on the commit that follows.
///
/// `AtomicU64`: monotonic for the life of the device, never wraps.
pub state_epoch: AtomicU64,
/// Publication seqlock. Odd while a transaction is publishing its object
/// snapshots, even when the published state is coherent.
///
/// The per-object `RcuPtr`s are swapped one at a time, so a reader that
/// needs a *transaction-consistent* view of several objects (a compositor
/// reading back the configuration it just committed, `GETRESOURCES`,
/// `GETCRTC`) must not sample them mid-publication or it sees a mix of old
/// and new — which is the same half-applied configuration the all-or-none
/// contract forbids. Such readers bracket their reads with `read_begin()` /
/// `read_retry()` on this counter and retry. Readers that only need one
/// object's snapshot (the vblank path, a single-plane query) read the
/// object's `RcuPtr` directly and ignore this counter.
///
/// Single writer (the publication step, under `commit_mutex`), so the
/// sequence is advanced with plain `load` + `store`, never `fetch_add`
/// ([Section 3.6](03-concurrency.md#lock-free-data-structures)).
pub publish_seq: AtomicU32,
/// `NONBLOCK` commits that have passed `atomic_check` and are waiting for
/// their in-fences to signal ([Section 21.5](#display-and-graphics--atomic-modesetting-protocol)
/// step 5). Registered here rather than owned by the committing thread —
/// which has returned — so that teardown, driver crash recovery, and CRTC
/// disable can abort them and release the framebuffer references they hold
/// instead of waiting on a GPU that may never signal.
///
/// `SpinLock<ArrayVec<…>>`: registration and completion are warm paths, and
/// the bound is `MAX_CRTCS` because a queued commit occupies every CRTC it
/// targets — a second commit for a busy CRTC is `CommitBusy` (`-EBUSY`),
/// so the list can never exceed one entry per CRTC.
pub queued_commits: SpinLock<ArrayVec<Arc<QueuedCommit>, MAX_CRTCS>>,
/// Bitmask of connectors whose hardware state changed and whose properties
/// the hotplug worker still has to rebuild — one bit per connector id
/// (`connector_bit`). Set from the interrupt handler, drained by the
/// worker; atomic because the two run in different contexts and the
/// interrupt cannot take `commit_mutex`.
pub hotplug_pending: AtomicU32,
/// Maximum aggregate scanout bandwidth in bytes/sec, used by the atomic
/// commit bandwidth check.
pub max_scanout_bandwidth: u64,
}
/// State guarded by `DisplayDevice::commit_mutex`.
pub struct ModesetState {
/// Allocator for `CommitTicket::commit_seq`. Monotonic, never reused.
pub next_commit_seq: u64,
}
impl DisplayDevice {
/// Hotplug interrupt handler. Runs in the driver's domain, in interrupt
/// context — so it does exactly two things: latch which connectors
/// changed, and wake the worker.
///
/// Everything the old shape did inline is illegal here. Reading EDID is a
/// DDC/I2C transaction that takes milliseconds and sleeps. Publishing
/// properties needs `commit_mutex`, a sleeping lock. Posting a userspace
/// event touches core-side state, which a driver-domain handler must not
/// reach into directly. All three run in `hotplug_worker` below, on the
/// named workqueue `umkad-drm-hotplug-N`.
///
/// **Reading the connector state is in the same class**, and that is why it
/// is not done here either. `read_connector_state` is a call INTO the
/// driver, and a call into the driver is `kabi_call!` — which resolves to a
/// ring round trip whenever the driver is in another domain, something no
/// interrupt handler may issue and wait on. Querying it here would make the
/// handler correct only for a same-domain driver, i.e. would bake a tier
/// assumption into the one place the spec forbids one. The handler
/// therefore records that this device needs re-examination and leaves every
/// hardware question to process context.
pub fn handle_hotplug_interrupt(&self) {
// Which connector moved is itself a hardware question. Mark them all;
// the worker asks the driver once per connector and rebuilds only the
// ones whose state actually changed. A hotplug is a human-timescale
// event, so the extra queries cost nothing measurable.
self.hotplug_pending.store(ALL_CONNECTORS_MASK, Ordering::Release);
// Named work queue, never anonymous submission: interrupt-context work
// that needs to sleep defers to a named queue per the IRQ-completion
// rule ([Section 3.11](03-concurrency.md#workqueue-deferred-work)).
// SAFETY: the DisplayDevice outlives every hotplug work item; the
// driver's teardown path drains this queue before dropping it.
let _ = DRM_HOTPLUG_WQ
.get()
.expect("set at drm_subsystem_init")
.queue_work(WorkItem::new(
drm_hotplug_work,
self as *const DisplayDevice as *mut (),
NO_DEADLINE,
));
}
/// Hotplug worker — process context, on `umkad-drm-hotplug-N`.
///
/// Takes `commit_mutex`, so it cannot run concurrently with an atomic
/// commit: a commit can no longer validate a mode against a property
/// snapshot that this worker replaces with an empty one an instant later,
/// and then program a connector that is no longer attached. The guard is
/// also the `WriterProof` that `RcuPtr::update()` requires — two
/// unserialized writers would swap the same pointer and double-free.
pub fn hotplug_worker(&self) {
let guard = self.commit_mutex.lock();
let pending = self.hotplug_pending.swap(0, Ordering::AcqRel);
let hw = &self.driver;
for connector in &self.connectors {
if pending & connector_bit(connector.id) == 0 {
continue;
}
// Ask the driver, in process context, through the bind-time
// transport. On a Tier 2 driver this is a ring round trip; on a
// same-domain one it is a direct call. A stale handle (crashed or
// replaced driver) leaves the published state alone — crash
// recovery republishes it.
let Ok(new_state) =
kabi_call!(&self.driver.handle, read_connector_state, connector.id)
else {
continue;
};
// A single atomic RMW, never load-then-store: this worker is the
// only writer, but the swap is also what tells us whether anything
// changed, without a check-then-act window against a second
// hotplug pass that the interrupt may already have queued.
let old_state = connector.state.swap(new_state as u32, Ordering::AcqRel);
if new_state as u32 == old_state {
continue; // Spurious for this connector: nothing to rebuild.
}
let connected = new_state as u32 == ConnectorState::Connected as u32;
// Connected: read EDID (a sleeping DDC/I2C transaction) and parse
// modes. Disconnected, or EDID unreadable on a connected sink
// (broken DDC): publish an empty mode list and let the compositor
// fall back to safe modes.
let props = if connected {
match read_edid_blocking(hw, connector.id) {
Ok(edid) => match parse_edid(&edid) {
Ok(modes) => ConnectorProps {
edid: Some(edid),
modes,
active_mode: None,
},
Err(_) => ConnectorProps::empty(),
},
Err(_) => ConnectorProps::empty(),
}
} else {
ConnectorProps::empty()
};
// `guard` is the WriterProof: writers of this RcuPtr are exactly
// this worker and atomic commit, both under `commit_mutex`.
if connector.props.update(Some(Arc::new(props)), &guard).is_err() {
// Allocation failure: leave the previous snapshot published
// (stale mode list is recoverable; a null props pointer is
// not) and re-arm so the next hotplug pass retries.
self.hotplug_pending
.fetch_or(connector_bit(connector.id), Ordering::AcqRel);
continue;
}
self.post_hotplug_event(
connector.id,
if connected {
HotplugEventType::Connected
} else {
HotplugEventType::Disconnected
},
);
}
// One publication, one epoch bump: any commit ticket issued before
// this point is now stale and its commit will be re-checked.
self.state_epoch.fetch_add(1, Ordering::AcqRel);
}
/// Post a hotplug transition to userspace: a `HOTPLUG=1` uevent plus a
/// hotplug record on every `DrmFile::event_queue` open on this device.
/// Core-side, process context — never called from the interrupt handler.
pub fn post_hotplug_event(&self, connector_id: u32, kind: HotplugEventType);
}
/// Bit position of a connector within `DisplayDevice::hotplug_pending`.
/// `DisplayConnector::id` is unique per display device and bounded by
/// `MAX_CONNECTORS` (8), so a `u32` mask covers every connector with room to
/// spare.
pub const fn connector_bit(connector_id: u32) -> u32 {
1 << connector_id
}
/// Every connector of a device, as a `hotplug_pending` mask. What the hotplug
/// interrupt sets: which connector actually moved is a hardware question that
/// only the worker may ask ([Section 21.5](#display-and-graphics--hotplug-detection)).
/// `MAX_CONNECTORS` (8) bits, so the mask never covers a connector id the
/// device does not have.
pub const ALL_CONNECTORS_MASK: u32 = (1 << MAX_CONNECTORS) - 1;
/// Read a connector's EDID through the driver's `read_edid` operation,
/// dispatched with `kabi_call!` on `DisplayDriverRef::handle` like every other
/// call into a display driver.
/// Blocking: a DDC/I2C transaction takes milliseconds. Process context only.
pub fn read_edid_blocking(
hw: &DisplayDriverRef,
connector_id: u32,
) -> Result<ArrayVec<u8, 512>, DisplayError>;
/// The named hotplug work queue (`umkad-drm-hotplug-N`). During boot Phase 5.3+,
/// `drm_subsystem_init()` creates the named queue and publishes it exactly once
/// with `DRM_HOTPLUG_WQ.set(queue)` before unmasking any hotplug interrupt.
/// Display hotplug has its own named queue rather than borrowing the shared
/// system queue: EDID reads are millisecond-scale DDC transactions and must not
/// sit behind unrelated work.
pub static DRM_HOTPLUG_WQ: BootOnceCell<WorkQueue> = BootOnceCell::new();
/// Work-item trampoline for `DisplayDevice::hotplug_worker`.
pub fn drm_hotplug_work(data: *mut ()) {
// SAFETY: `data` is the `&DisplayDevice` the interrupt handler submitted;
// the device outlives every queued item (teardown drains the queue first).
let device = unsafe { &*(data as *const DisplayDevice) };
device.hotplug_worker();
}
impl ConnectorProps {
/// The snapshot published for a connector with no readable mode list:
/// disconnected, or connected with an unreadable EDID.
pub fn empty() -> Self {
Self { edid: None, modes: ArrayVec::new(), active_mode: None }
}
}
/// The kind of connector hotplug transition reported to userspace via the DRM
/// event ring (plus a `HOTPLUG=1` uevent). Posted by `post_hotplug_event()` from
/// the hotplug interrupt handler.
pub enum HotplugEventType {
/// A display was plugged in — the connector became connected and its EDID is
/// now readable.
Connected,
/// A display was unplugged — the connector became disconnected and its mode
/// list was cleared.
Disconnected,
}
Compositor response: When the compositor receives a hotplug event (via the event ring buffer), it:
1. Re-enumerates connectors and modes (ioctl(DRM_IOCTL_MODE_GETRESOURCES)).
2. Decides how to configure the new display (extended desktop, mirror, ignore).
3. Allocates new framebuffers (if needed) for the new resolution.
4. Submits an atomic modesetting request to enable the new connector.
21.5.7.1.1 parse_edid() — EDID Parsing Specification¶
parse_edid() is called during hotplug handling (see handle_hotplug_interrupt above)
to convert raw EDID bytes read from the monitor's I2C DDC bus into a list of display modes.
/// Parse an EDID (Extended Display Identification Data) blob into display modes.
///
/// Supports EDID 1.0–1.4 (128 bytes) and E-EDID (DisplayID, CTA-861 extensions,
/// up to 512 bytes). Input is the raw bytes read from the monitor's I2C DDC bus.
///
/// # Algorithm
///
/// 1. **Header validation**: First 8 bytes must be `[0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00]`.
/// Return `Err(EdidError::InvalidHeader)` if not.
///
/// 2. **Checksum**: Sum all 128 bytes; result must be 0 (mod 256).
/// Return `Err(EdidError::BadChecksum)` if not.
///
/// 3. **Established timings** (bytes 35–37, 24 well-known modes):
/// Bit map to modes: bit 7 of byte 35 = 720×400@70Hz, bit 6 = 720×400@88Hz, ...
/// (See VESA EDID standard Table 3.20 for full mapping.)
/// Add each set bit as a `DisplayMode` to the output list.
///
/// 4. **Standard timing descriptors** (bytes 38–53, 8 entries × 2 bytes):
/// Each entry encodes horizontal active pixels and aspect ratio + refresh rate.
/// Skip entries equal to `0x0101` (unused).
/// Formula: `h_active = (byte0 + 31) * 8; v_active = h_active / aspect_ratio;
/// refresh = (byte1 & 0x3F) + 60`
///
/// 5. **Detailed timing descriptors** (bytes 54–125, 4 × 18-byte blocks):
/// Each 18-byte block is either a monitor descriptor (first byte 0x00) or
/// a detailed timing (first byte non-zero). Detailed timings encode pixel clock,
/// h/v active, h/v blanking, sync polarity, and flags for interlaced/stereo.
/// Parse each detailed timing as a `DisplayMode`.
///
/// 6. **CEA/CTA extensions** (each extension block is 128 bytes, same checksum rule):
/// Tag byte 0x02 = CEA-861 extension. Parse Video Data Block (tag=2), short
/// video descriptors (SVDs), and native mode indicator. VIC (Video Identification
/// Code) → mode lookup table (CEA-861-F Table 1).
///
/// # Output
///
/// Returns `ArrayVec<DisplayMode, 64>`: up to 64 modes. Modes are sorted by
/// descending priority: detailed timings first (native mode = bit15 of CEA block or
/// first detailed timing), then established timings, then standard timings.
/// Duplicate modes (same h×v×refresh) are deduplicated; the one from the highest-
/// priority source is kept.
///
/// # Error handling
/// Returns `Err(EdidError)` only for header/checksum failures on the base 128-byte
/// block. Invalid or unrecognized descriptor blocks are skipped silently (a partial
/// mode list is better than no modes at all).
pub fn parse_edid(raw: &[u8]) -> Result<ArrayVec<DisplayMode, 64>, EdidError>;
/// Errors returned by `parse_edid()`.
#[derive(Debug)]
pub enum EdidError {
/// Buffer too short (< 128 bytes).
TooShort,
/// Magic header bytes wrong (first 8 bytes are not the EDID header pattern).
InvalidHeader,
/// Checksum over 128 bytes != 0 mod 256.
BadChecksum,
}
21.5.8 Panel Self-Refresh (PSR)¶
When the compositor has not updated the framebuffer (static desktop), the display controller can enter Panel Self-Refresh mode: - The monitor's internal controller (eDP panel, DP monitor with PSR support) caches the last frame. - The GPU's scanout engine stops reading from VRAM (memory bandwidth saved). - The GPU's memory controller enters a low-power state (watts saved).
When the compositor updates the framebuffer (user moves the mouse, window animates), the display driver detects the change (via atomic commit) and exits PSR mode, resuming scanout.
Power savings: PSR saves 1-2W on a laptop when the screen is static (reading a document, watching a video with no UI movement). This extends battery life by ~10-15% for typical office workloads.
21.5.9 Variable Refresh Rate (VRR)¶
Modern monitors support VRR (FreeSync, G-Sync, HDMI VRR): the display refreshes at variable intervals (e.g., 40-144 Hz) synchronized with the compositor's render rate. This eliminates tearing without vsync's fixed-cadence latency.
VrrMode enum — the mode selector passed to the hardware driver:
// umka-nucleus/src/display/vrr.rs
/// Variable Refresh Rate mode selector.
///
/// Passed to [`DisplayHwOps::set_vrr_mode`] (Section 21.4.12) to program the
/// display controller's Adaptive-Sync (DP), HDMI VRR, or FreeSync registers.
#[repr(u32)]
pub enum VrrMode {
/// VRR disabled — the display runs at a fixed refresh rate determined by the
/// active `DisplayMode`.
Disabled = 0,
/// VRR enabled — the display refreshes at variable intervals within the range
/// reported by [`DisplayHwOps::get_vrr_range`] (Section 21.4.12).
Enabled = 1,
}
Call path: DisplayConnector::set_vrr() validates the active mode, then delegates
to the hardware driver via the DisplayHwOps::set_vrr_mode function pointer defined
in Section 21.5. The set_vrr_mode field is
Option<fn> — drivers that do not support VRR leave it None, and the call returns
DisplayError::VrrNotSupported.
impl DisplayConnector {
/// Enable or disable VRR on this connector.
///
/// Checks that the active mode advertises VRR capability and that the
/// hardware driver provides a `set_vrr_mode` implementation.
pub fn set_vrr(&self, enabled: bool) -> Result<(), DisplayError> {
// Read the RCU-protected ConnectorProps snapshot (lock-free).
let props = self.props.read();
let mode = props.active_mode.ok_or(DisplayError::NoActiveMode)?;
if (mode.flags & mode_flags::VRR) == 0 {
return Err(DisplayError::VrrNotSupported);
}
let vrr_mode = if enabled { VrrMode::Enabled } else { VrrMode::Disabled };
// `kabi_call!`, never a dereference of `set_vrr_mode`: the slot is
// OPTIONAL, and an absent optional slot is a property of the PUBLISHED
// vtable, which only the transport can see. A driver in another domain
// has no `Option<fn>` the core could inspect at all — the answer comes
// back as `NotSupported` from the slot-bounds check
// ([Section 12.8](12-kabi.md#kabi-domain-runtime--kabicall-macro-specification)).
match kabi_call!(&self.driver.handle, set_vrr_mode, self.id, vrr_mode) {
Ok(()) => Ok(()),
Err(KabiError::NotSupported) => Err(DisplayError::VrrNotSupported),
Err(_) => Err(DisplayError::HwError),
}
}
}
Range query: Compositors must know the monitor's supported VRR range to clamp their
render rate and decide whether to enable Low Framerate Compensation (LFC) below
min_mhz. The range is obtained via DisplayHwOps::get_vrr_range (also in
Section 21.5), which reads the range from
EDID/DisplayID (DP Adaptive-Sync), HDMI Forum VSDB, or vendor extensions.
Compositor use: Wayland compositors (KWin, Mutter, wlroots) query the VRR range, enable VRR via the atomic modesetting commit (Section 21.5), and schedule presentation to match the compositor's render loop (unlocked framerate, no vsync wait).
21.5.10 VBlank Handling and Synchronization¶
VBlank (vertical blanking interval) is the fundamental display timing primitive. The display controller generates a VBlank interrupt at the start of each blanking interval (between the last scanline of one frame and the first scanline of the next). All page flips, cursor moves, and mode changes are synchronized to VBlank to avoid tearing.
// umka-nucleus/src/display/vblank.rs
/// **Completion delivery has exactly two channels**, and every completion
/// travels both of them in order:
///
/// 1. **driver → core**: `VsyncEvent` on the driver's completion ring
/// (`DisplayDriverRef::vsync_ring`). The handle is obtained only by
/// `kabi_call!(&handle, vsync_ring)` in `DisplayDriverRef::bind`; it is
/// never obtained by directly invoking `DisplayDriver::vsync_ring()`
/// ([Section 13.3](13-device-classes.md#display-subsystem)). SPSC — the display interrupt handler is the
/// only producer, the display core the only consumer. This is the channel
/// that keeps completion tier-agnostic: the driver never reaches into core
/// state from its own domain.
/// 2. **core → userspace**: `DrmEventVblank` on the per-fd
/// `DrmFile::event_queue`, read with `read(2)` on the DRM fd. Per-fd, so
/// each subscriber gets its own copy and its own backpressure.
///
/// There is deliberately no third, per-CRTC kernel event ring. One ring shared
/// by the N clients that `VblankState::event_refcount` counts would be
/// single-producer/**multi**-consumer, so the lock-free SPSC discipline could
/// not be claimed for it; and a per-CRTC ring plus a per-fd queue leaves an
/// implementer no way to tell which one the interrupt is supposed to write.
///
/// Per-CRTC tracking state (kernel-internal). Counters and wait queue only —
/// the events themselves live on the two channels above.
pub struct VblankState {
/// Monotonically increasing VBlank counter.
pub count: AtomicU64,
/// Timestamp of the most recent VBlank (ns, CLOCK_MONOTONIC).
pub last_timestamp_ns: AtomicU64,
/// Wait queue for threads blocked on VBlank (epoll_wait, ioctl WAITVBLANK)
/// and for the caller of a blocking commit waiting on `completed_seq`.
pub waiters: WaitQueue,
/// Number of userspace clients requesting VBlank events on this CRTC.
/// When zero, the kernel masks the VBlank interrupt to save power. Kept in
/// step with `subscribers` under that field's lock, and readable without
/// it — the mask/unmask decision is taken on the completion path, which
/// must not take a lock it does not need.
pub event_refcount: AtomicU32,
/// WHO those clients are. `event_refcount` is a count, and a count cannot
/// be enumerated: the completion path is required to push a
/// `DrmEventVblank` onto "each subscribed fd's event_queue", which needs
/// the fds themselves.
///
/// `Weak<DrmFile>` per entry: a subscriber may close its fd or die between
/// subscribing and the next VBlank, and a periodic event must not keep a
/// dead file alive. An entry whose `upgrade()` fails is dropped from the
/// list by the completion path — close is not required to be synchronous
/// with the interrupt.
///
/// `SpinLock<ArrayVec<…>>`: subscribe/unsubscribe is a warm ioctl path,
/// while the completion path walks the list in the vsync worker. Bounded
/// by `MAX_VBLANK_SUBSCRIBERS` so the walk is O(1) with a known constant
/// and no allocation happens on the completion side.
pub subscribers: SpinLock<ArrayVec<VblankSubscriber, MAX_VBLANK_SUBSCRIBERS>>,
/// `commit_seq` of the most recent commit whose latch this CRTC has
/// published. The predicate a blocking commit waits on: it parks on
/// `waiters` until this reaches its own sequence, which is how a blocking
/// commit can release `commit_mutex` before waiting and still return only
/// after the latch. Monotonic, `u64`, never wraps.
pub completed_seq: AtomicU64,
/// Whether this CRTC is actively scanning out (false during DPMS off/suspend).
pub enabled: AtomicBool,
}
/// One userspace subscriber to periodic VBlank events on a CRTC.
pub struct VblankSubscriber {
/// The subscribing fd. `Weak` — see `VblankState::subscribers`.
pub file: Weak<DrmFile>,
/// Cookie echoed into `DrmEventVblank::user_data` for this subscriber's
/// periodic events, from its `DRM_IOCTL_WAIT_VBLANK` request.
pub user_data: u64,
}
/// Maximum concurrent VBlank subscribers per CRTC. A subscriber is a DRM fd
/// that asked for periodic events on this CRTC: the compositor, plus the
/// occasional direct-scanout client (a full-screen video player holding a
/// lease, a screen recorder, plymouth during boot handoff). 16 is far above
/// what a display pipeline ever carries; the 17th gets
/// `DrmError::SubscriberTableFull` rather than an unbounded list walked from
/// the completion path.
pub const MAX_VBLANK_SUBSCRIBERS: usize = 16;
/// A commit that has been armed on this CRTC and is waiting for the hardware
/// to latch it. Installed by the arming step of the commit flow
/// ([Section 21.5](#display-and-graphics--atomic-modesetting-protocol) step 7), retired
/// exactly once.
///
/// This record is what makes the completion path implementable: without it
/// "if a page flip was pending" has no predicate to test, "release the old
/// framebuffer's reference" has no reference to release — the outgoing
/// framebuffer would either leak (nobody drops it) or be freed while the
/// scanout engine is still reading it — and "post the completion to the
/// compositor" has no destination and no cookie.
pub struct PendingFlip {
/// Commit sequence this flip belongs to, matched against
/// `VsyncEvent::commit_seq` so the right flip is retired when several
/// CRTCs complete in the same interrupt.
pub commit_seq: u64,
/// The state snapshots this commit publishes for THIS CRTC when its latch
/// is confirmed — the new `CrtcState`, and the new `PlaneState` and
/// `ConnectorProps` for each object the transaction changed on it.
///
/// Held here because the publication happens after the committing thread
/// has returned: a `NONBLOCK` caller is gone by the time the hardware
/// latches, so "publish the transaction's new state" needs the transaction
/// to still exist somewhere. This record is that somewhere. Without it the
/// completion path is told to publish snapshots it has no way to name.
pub snapshots: CommitSnapshots,
/// Framebuffers this flip is bringing on screen. Held so the buffers stay
/// pinned between arming and latch, when no published `PlaneState`
/// references them yet.
pub incoming: ArrayVec<FramebufferRef, MAX_PLANES_PER_CRTC>,
/// Framebuffers the latch takes off screen. Released only after the latch
/// is confirmed — the hardware is still scanning them out until then.
/// Released on the retirement workqueue, never in interrupt context, since
/// the last reference can reach `DisplayDriver::destroy_framebuffer()`.
pub retiring: ArrayVec<FramebufferRef, MAX_PLANES_PER_CRTC>,
/// The CRTC's VBlank count when this flip was armed. The missed-completion
/// rule ([Section 21.5](#display-and-graphics--atomic-modesetting-protocol)) compares
/// it against the sequence of every entry drained for this CRTC: a flip
/// latches on the next VBlank at the latest, so an entry past this value
/// proves the latch happened even when its own `PAGE_FLIP_COMPLETE` was
/// overwritten in the ring.
pub armed_at_seq: u64,
/// Where the completion event goes, when `CommitFlags::EVENT` was set.
pub event: Option<FlipEventTarget>,
}
/// A `NONBLOCK` commit that has passed `atomic_check` but cannot be handed to
/// the driver yet, because at least one `PlaneCommit::in_fence` has not
/// signaled. It owns the whole transaction while it waits.
///
/// This record is what makes `NONBLOCK` mean what it says. Without an owner for
/// the waiting transaction, the only place to wait is the committing thread —
/// which is precisely the blocking behaviour the flag rules out. It is also
/// what teardown and CRTC-disable abort against: a queued commit holds
/// framebuffer references, and a device that goes away while fences are
/// outstanding must release them rather than wait for a GPU that may never
/// signal.
pub struct QueuedCommit {
/// Sequence allocated at check time; becomes the `PendingFlip::commit_seq`
/// when this commit is finally armed.
pub commit_seq: u64,
/// The validated transaction, with its resolved `FramebufferRef`s.
pub commit: AtomicCommit,
/// The ticket `atomic_check` returned. Re-validated against
/// `DisplayDevice::state_epoch` when the last fence signals; a stale
/// ticket re-runs the check before the driver is called.
pub ticket: CommitTicket,
/// Flags the caller submitted (`NONBLOCK` is set by construction).
pub flags: CommitFlags,
/// Per-CRTC snapshots prepared at check time, moved into each
/// `PendingFlip` at arm time.
pub snapshots: ArrayVec<(u32, CommitSnapshots), MAX_CRTCS>,
/// Completion-event target, moved into the `PendingFlip` at arm time.
pub event: Option<FlipEventTarget>,
/// In-fences still unsignaled. Each drops out as its callback fires; the
/// callback that empties this list wakes the commit worker.
pub pending_fences: ArrayVec<FencePoint, MAX_PLANES>,
}
/// The new object snapshots one commit publishes on one CRTC. Built during
/// the commit's prepare phase, carried by the `PendingFlip`, and installed by
/// the completion path under `commit_mutex`.
///
/// Snapshots, not ids: publication is an `RcuPtr::update()` per object, and the
/// value to install has to be the one the check validated. Recomputing it at
/// latch time would validate nothing and would race the very hotplug worker
/// `commit_mutex` exists to exclude.
pub struct CommitSnapshots {
/// New CRTC configuration, `None` if this commit did not change it.
pub crtc: Option<Arc<CrtcState>>,
/// New plane states, paired with the plane id each belongs to.
pub planes: ArrayVec<(u32, Arc<PlaneState>), MAX_PLANES_PER_CRTC>,
/// New connector properties (routing/active mode), paired with connector id.
pub connectors: ArrayVec<(u32, Arc<ConnectorProps>), MAX_CONNECTORS_PER_CRTC>,
}
/// Destination of a page-flip completion event.
pub struct FlipEventTarget {
/// The fd that submitted the commit. `Weak`: the compositor may close the
/// fd (or die) between arming and latch, and the flip must not keep a dead
/// file alive. `upgrade()` returning `None` means "nobody is listening" —
/// the event is dropped, never unwrapped.
pub file: Weak<DrmFile>,
/// Cookie from `AtomicEventRequest::user_data`, echoed back verbatim.
pub user_data: u64,
}
Completion path. The interrupt runs in the driver's domain; everything
core-side runs in the core. Splitting it this way is not bookkeeping — a
driver-domain handler must not touch core wait queues, per-fd event queues, or
framebuffer refcounts, and the last reference to a retiring framebuffer can
reach destroy_framebuffer(), which may sleep.
Stage A — display interrupt handler, driver domain:
VBlank IRQ fires:
1. Read the hardware VBlank status register, acknowledge the interrupt.
2. Push one VsyncEvent onto the driver's completion ring:
- timestamp_ns = CLOCK_MONOTONIC now
- sequence = the hardware/driver VBlank count for this CRTC
- crtc_index = the CRTC that raised it
- flags = VBLANK, plus PAGE_FLIP_COMPLETE if the hardware
latched an armed commit in this interval
- commit_seq = that commit's sequence, or 0 if no flip latched
3. Signal the ring doorbell. The handler does nothing else.
Stage B — display core, draining the completion ring. It runs in process
context, on the named workqueue umkad-drm-vsync-N, woken by the ring
doorbell. That is not an implementation detail: the publication step below takes
commit_mutex, a sleeping lock, because RcuPtr::update() requires the
single-writer proof that only that guard provides. A completion path that ran in
interrupt context could not take it, and publishing without it would put a
second unserialized writer on the same pointers as the hotplug worker.
The worker drains the core consumer endpoint resolved from
device.driver.vsync_ring. That handle can exist only after
DisplayDriverRef::bind successfully executes
kabi_call!(&handle, vsync_ring): same-domain and cross-domain drivers
therefore enter the identical Stage B, and no raw driver vtable or
driver-domain ring pointer is reachable here.
Taking commit_mutex here cannot deadlock against a committer: a commit
releases it once its PendingFlip is installed (step 7), before a blocking
caller waits for the latch. The waiter parks on vblank.waiters, holding
nothing.
For each VsyncEvent drained:
1. vblank_state.count.store(event.sequence) (monotonic per CRTC)
2. vblank_state.last_timestamp_ns.store(event.timestamp_ns)
3. If flags & PAGE_FLIP_COMPLETE, OR a flip is pending on this CRTC whose
armed_at_seq < event.sequence (its completion was lost to ring overflow —
see Missed completion in the atomic modesetting protocol):
a. Take crtc.pending_flip; if the event named a commit_seq and it differs
from the flip's, the event is stale (a forced retirement already ran)
— skip to 4.
b. Take device.commit_mutex and publish the flip's own snapshots
(PendingFlip::snapshots) under publish_seq — connector props, then
plane states, then the CRTC state — bump device.state_epoch, and
release the mutex. This is the publish() of Multi-Monitor
Coordination, and the guard is its WriterProof. The published
PlaneStates now own the incoming framebuffers.
c. vblank_state.completed_seq.store(flip.commit_seq, Release) — the
predicate a blocking committer is parked on.
d. If the flip carried an event target, upgrade the Weak<DrmFile>; on
Some, push a DrmEventVblank with type DRM_EVENT_FLIP_COMPLETE and the
caller's user_data onto that fd's event_queue and wake its waiters.
On None the compositor is gone and the event is dropped.
e. Hand `retiring` to the flip-retirement workqueue
(`umkad-drm-flip-N`), which drops the last references and unpins.
4. If vblank_state.event_refcount > 0, walk vblank_state.subscribers: for
each entry whose Weak<DrmFile> upgrades, push a DrmEventVblank with type
DRM_EVENT_VBLANK and that subscriber's user_data onto its event_queue;
drop entries that no longer upgrade (their fd is closed) and decrement
event_refcount to match, so the last close also unmasks nothing further.
5. Wake all threads on vblank_state.waiters.
A drained ring is re-examined before the worker sleeps. The ring can be overwritten while the worker is running (Stage A never waits), so the worker re-reads the producer index after step 5 and loops if it advanced; only then does it sleep. Without that, an entry pushed during the drain would wait for the NEXT VBlank to be noticed.
DrmEventVblank::sequence is vblank_state.count as u32; the truncation is
ABI-required (see the longevity comment at the DrmEventVblank definition).
VBlank event delivery to userspace: Compositors subscribe to VBlank events via
ioctl(dri_fd, UMKA_DRM_CRTC_ENABLE_VBLANK, crtc_id). Events are delivered through
the DRM file descriptor's event ring buffer (readable via read(2) or epoll). When
the last subscriber unsubscribes, the kernel masks the VBlank interrupt to avoid
unnecessary IRQ overhead on idle displays.
VBlank-synchronized page flips: When the compositor submits an atomic commit without
the ASYNC flag, the kernel programs the new framebuffer address into a shadow register.
The hardware latches the shadow register on the next VBlank, atomically switching scanout
to the new framebuffer. Whether the compositor waits for that latch is the separate
NONBLOCK decision (Section 13.3): a blocking commit returns only once the
flip is complete, while a NONBLOCK commit returns immediately and the compositor
learns of completion from the PAGE_FLIP_COMPLETE event on its DRM fd — which is the
whole point of a non-blocking commit, and why the event and the flag are independent.
Userspace DRM event structs — the ABI format delivered via read() on the
DRM device fd. Must match Linux include/uapi/drm/drm.h exactly:
/// Base DRM event header (8 bytes).
/// Userspace ABI — matches Linux `struct drm_event` from `include/uapi/drm/drm.h`.
#[repr(C)]
pub struct DrmEvent {
/// Event type: DRM_EVENT_VBLANK (0x01), DRM_EVENT_FLIP_COMPLETE (0x02),
/// DRM_EVENT_CRTC_SEQUENCE (0x03).
pub event_type: u32,
/// Total length of this event including header (e.g., 32 for DrmEventVblank).
pub length: u32,
}
// DrmEvent: u32(4)*2 = 8 bytes.
// Userspace ABI struct — delivered via read(2) on DRM device fd.
const_assert!(core::mem::size_of::<DrmEvent>() == 8);
/// VBlank / page-flip completion event (32 bytes, matches Linux drm_event_vblank).
// Userspace ABI struct — matches Linux struct drm_event_vblank from
// include/uapi/drm/drm.h. Delivered to userspace via read(drm_fd) to
// Wayland compositors, Mesa, and every DRM client. Do NOT modify layout.
#[repr(C)]
pub struct DrmEventVblank {
pub base: DrmEvent,
/// User-provided data from DRM_IOCTL_PAGE_FLIP or DRM_IOCTL_WAIT_VBLANK.
pub user_data: u64,
/// Timestamp (seconds since epoch or since boot, depending on clock source).
/// **ABI-constrained**: u32 matches Linux `drm_event_vblank.tv_sec`.
/// With CLOCK_MONOTONIC (default since Linux 4.15): wraps in ~136 years
/// from boot — well within 50-year uptime target. With CLOCK_REALTIME:
/// wraps in 2106 (Y2106 problem, same as Linux). Clock source selected
/// via `DRM_CAP_TIMESTAMP_MONOTONIC` capability query.
pub tv_sec: u32,
/// Timestamp (microseconds).
pub tv_usec: u32,
/// VBlank sequence counter.
/// **ABI-constrained**: u32 matches Linux `drm_event_vblank.sequence`.
/// At 120 Hz, wraps in ~414 days. DRM userspace (Mesa, Weston) handles
/// wrap via unsigned arithmetic comparison. Not a correctness issue.
pub sequence: u32,
/// CRTC ID that generated this event.
/// Historically named `reserved` for DRM_EVENT_VBLANK (type 0x01);
/// formally `crtc_id` since DRM_EVENT_CRTC_SEQUENCE (type 0x03).
/// Modern Linux drivers fill this for all event types.
pub crtc_id: u32,
}
const _: () = assert!(core::mem::size_of::<DrmEventVblank>() == 32);
When delivering events to userspace via read(drm_fd), the display core
translates the VsyncEvent it drained (internal, Section 13.3) into
DrmEventVblank (ABI): timestamp_ns splits into
tv_sec = timestamp_ns / 1_000_000_000 and
tv_usec = (timestamp_ns % 1_000_000_000) / 1_000; sequence truncates
to u32; user_data is filled from FlipEventTarget::user_data for a page-flip
completion, or from the subscription's vblank-wait ioctl request for a
periodic VBlank event.
The kernel writes DrmEventVblank entries into the DRM fd's read buffer.
Compositors read them via read(drm_fd, buf, sizeof(DrmEventVblank)).
Multiple events may be coalesced in one read() call; the length field
allows parsing sequential events.
Subscription mechanism:
/// Request a VBlank notification for a specific CRTC.
/// Returns a subscription that becomes readable when the next VBlank fires.
/// Equivalent to DRM_IOCTL_WAIT_VBLANK with DRM_VBLANK_EVENT flag.
///
/// Registers the calling fd in `VblankState::subscribers` and increments
/// `event_refcount` under that list's lock — the completion path enumerates the
/// list, so a subscription that only bumped the count would be invisible to it.
/// Unsubscribing (and `DrmFile::release`) removes the entry and decrements the
/// count under the same lock; the last removal masks the VBlank interrupt.
pub fn drm_vblank_subscribe(crtc_id: u32) -> Result<VblankSubscription, DrmError>;
pub struct VblankSubscription {
/// Event fd: readable when VBlank fires (or immediately if missed_vblanks > 0).
pub event_fd: EventFd,
/// If > 0, this subscription was registered late and missed this many VBlanks.
/// The first read from event_fd will return immediately to signal the miss.
pub missed_vblanks: u32,
}
/// Error returned by DRM/KMS entry points such as `drm_vblank_subscribe()`.
pub enum DrmError {
/// No CRTC with the given id exists on this device.
InvalidCrtc,
/// The CRTC exists but VBlank interrupts are not enabled for it.
VblankNotEnabled,
/// The per-fd DRM event ring is full; the subscription could not be queued.
EventRingFull,
/// This CRTC already has `MAX_VBLANK_SUBSCRIBERS` subscribers. Reported to
/// userspace as `-EBUSY`.
SubscriberTableFull,
/// The calling `DrmFile` lacks the DRM-master rights this operation requires.
NotMaster,
}
Overflow behavior: each channel has its own bound, and neither can block the interrupt handler.
Driver→core completion ring (vsync_ring, SPSC): the display interrupt
handler pushes without allocation, O(1), and never waits. If the core has not
drained it, the oldest entry is overwritten; a dropped entry costs at most one
VBlank tick of counter resolution, and a dropped PAGE_FLIP_COMPLETE is
recovered by the missed-completion rule
(Section 21.5) — the next entry the
core drains for that CRTC carries a sequence past the flip's armed_at_seq,
which retires it with its snapshots published, its event posted, and its
framebuffer references released. The recovery needs no additional entry: it
fires on the ordinary VBlank traffic that follows every latch.
Core→userspace queue (DrmFile::event_queue, 256 entries): admission is
RESERVATION-BASED, so a compositor that is not reading fast enough throttles
its own future requests rather than losing delivered events — the ring never
overflows and no event is ever dropped:
- Space for the completion event is reserved against this fd's 256-entry
budget at the event-GENERATING request — the
DRM_IOCTL_WAIT_VBLANKsubscription or the atomic-commit / page-flip ioctl. If the ring cannot hold the event, that REQUEST fails withENOMEM(DrmError::EventRingFull) and no event is generated. - Delivery of a reserved event therefore never fails and never drops: the producer pushes into space already accounted to this fd. There is no eviction and no sequence-gap loss to detect — a compositor that reads its events sees every one that its own requests generated.
- The producer still never blocks. This queue is per-fd, so its single
consumer is that fd's reader; the
SpinLockprotects it against the concurrentread(2)rather than against multiple producers.
This is the observable Linux contract: drm_event_reserve_init_locked fails
the generating ioctl with -ENOMEM when file_priv->event_space cannot hold
the event, and drm_send_event_locked appends to the file's event list
unconditionally, never dropping or evicting a reserved event.
(contract: torvalds/linux drivers/gpu/drm/drm_file.c at baseline fc02acf6ac0c, fetched at certify; finding 2ab57fca897d)
After pushing, the core calls waiters.wake_up_all() on the fd to wake the
subscribed compositor.
21.5.11 Multi-Monitor Coordination¶
A display controller typically has multiple CRTCs (CRT Controllers — the name is historical; they drive flat panels too). Each CRTC is an independent timing generator that scans out one framebuffer to one or more connectors. The mapping is:
Display Pipeline:
Planes → CRTC → Encoder → Connector → Monitor
│
├── Each CRTC has independent timing (mode, refresh rate, VBlank)
├── Each CRTC owns a set of planes (primary + optional cursor/overlay)
└── Multiple connectors can share a CRTC (clone/mirror mode)
21.5.11.1.1 Color Management: GammaLut and CrtcColorProperties¶
Before the CRTC structs, this section defines the color management types used by
CrtcState. These types represent the CRTC-level display pipeline color correction
stages: de-gamma (linearization), CTM (color space conversion), and gamma (re-gamma
for display encoding). They match the Linux DRM color management ABI so that
Wayland compositors and color management tools (colord, icc-profiles) work unmodified.
Display color pipeline (stages applied in hardware order):
Plane pixels (encoded, e.g., sRGB)
→ per-plane tone mapping (optional, plane-level property)
→ alpha compositing / blending
→ CRTC degamma LUT (encoded → linear light, using degamma_lut)
→ CTM (color space conversion, e.g., sRGB → display native)
→ CRTC gamma LUT (linear light → display encoding, using gamma_lut)
→ scanout to panel
// umka-nucleus/src/display/color.rs
/// Single entry in a hardware gamma lookup table.
///
/// Maps one input intensity level to per-channel output intensities.
/// The hardware applies the LUT per channel: `R_out = lut.red[R_in >> shift]`,
/// where `shift` accounts for the difference between input bit depth (e.g., 10-bit
/// pipe) and the LUT size (e.g., 256 entries → shift = 2 for a 10-bit pipe).
///
/// Layout matches Linux's `struct drm_color_lut` (see `include/uapi/drm/drm_mode.h`)
/// for binary ABI compatibility with Wayland compositors, Xorg, and color
/// management daemons that set gamma via `DRM_IOCTL_MODE_SETCRTC` or the
/// atomic `DRM_IOCTL_MODE_ATOMIC` with the `GAMMA_LUT` CRTC property blob.
#[repr(C)]
pub struct GammaLutEntry {
/// Red channel output value (16-bit, linear, range 0..=65535).
pub red: u16,
/// Green channel output value (16-bit, linear, range 0..=65535).
pub green: u16,
/// Blue channel output value (16-bit, linear, range 0..=65535).
pub blue: u16,
/// Reserved for alignment; must be zero.
/// (Matches the padding in `struct drm_color_lut` for ABI compatibility.)
pub _reserved: u16,
}
// GammaLutEntry: u16(2)*4 = 8 bytes.
// Userspace ABI struct — matches Linux `struct drm_color_lut`.
const_assert!(core::mem::size_of::<GammaLutEntry>() == 8);
/// Color correction gamma LUT (Look-Up Table).
/// Pre-allocated at display device initialization to the hardware's LUT capacity.
/// `Box<[GammaLutEntry]>` over `Vec<GammaLutEntry>`: allocated once at init,
/// never resized. Atomic modeset context writes into the pre-allocated slice
/// without risk of allocation failure.
///
/// Used for both the gamma LUT (post-blend, re-encodes into display gamma) and
/// the de-gamma LUT (pre-blend, linearizes sRGB input). The number of entries
/// is hardware-dependent — query via `CrtcProperties::gamma_lut_size` (the
/// read-only `GAMMA_LUT_SIZE` DRM CRTC property).
///
/// The kernel validates that `count <= entries.len()` on every atomic
/// commit that includes a `GAMMA_LUT` or `DEGAMMA_LUT` property update.
/// Mismatched sizes are rejected with `-EINVAL`.
///
/// **Default (linear) LUT**: When `CrtcColorProperties::gamma_lut` is `None`,
/// the hardware applies a linear identity mapping: entry `i` maps to
/// `i * 65535 / (size - 1)` per channel. This is the power-on default and the
/// behavior when color management is not requested.
///
/// **Allocation**: The `Box<[GammaLutEntry]>` is allocated once in
/// display-device initialization when the hardware's `gamma_size` is read from the
/// display controller (via `DRM_IOCTL_MODE_GETPROPBLOB` → `gamma_size`).
/// After initialization the slice is never reallocated; atomic modesetting
/// paths only update entries within the already-allocated slice, so there is
/// no allocation failure path in the atomic commit code.
pub struct GammaLut {
/// Pre-allocated LUT entries. Capacity = hardware LUT size (typically 256
/// or 1024 per channel, queried from display hardware at init via
/// `DRM_IOCTL_MODE_GETPROPBLOB` → `gamma_size`).
/// Entries in order from darkest (index 0, input = black) to
/// brightest (index count-1, input = full intensity).
pub entries: Box<[GammaLutEntry]>,
/// Number of valid entries (≤ entries.len()). Must match the hardware's
/// `GAMMA_LUT_SIZE` property for the target CRTC on every atomic commit.
pub count: u32,
}
/// 3×3 color transform matrix (CTM) in S31.32 fixed-point format.
///
/// Applied between the de-gamma and gamma stages to convert between color spaces
/// (e.g., sRGB → DCI-P3, BT.709 → BT.2020, or ICC profile adjustments).
///
/// Entry `matrix[i][j]` is the contribution of input channel `j` to output
/// channel `i`, where channels are ordered R=0, G=1, B=2. The fixed-point
/// format is S31.32: bit 63 is sign, bits 62..32 are the integer part, bits
/// 31..0 are the fractional part. This matches the Linux DRM CTM property blob
/// layout (`struct drm_color_ctm`, `include/uapi/drm/drm_mode.h`).
///
/// Identity matrix (no color conversion):
/// ```
/// matrix = [[1<<32, 0, 0],
/// [0, 1<<32, 0],
/// [0, 0, 1<<32]]
/// ```
#[repr(C)]
pub struct ColorTransformMatrix {
/// Row-major 3×3 matrix. `matrix[output_channel][input_channel]`.
pub matrix: [[i64; 3]; 3],
}
// ColorTransformMatrix: 9 × i64(8) = 72 bytes.
// Userspace ABI struct — matches Linux `struct drm_color_ctm`.
const_assert!(core::mem::size_of::<ColorTransformMatrix>() == 72);
/// CRTC color management properties.
///
/// Grouped as a sub-struct within `CrtcState` so that all color properties
/// are updated atomically as part of an RCU-swapped state snapshot. This
/// prevents a race where gamma is updated but the CTM is not yet applied,
/// which would briefly produce incorrect colors on a live display.
pub struct CrtcColorProperties {
/// Gamma LUT for post-blending correction (CRTC-level re-encoding).
/// Applied after the CTM, converts linear light to display-encoded values.
/// `None` means linear (no gamma correction — hardware applies identity LUT).
/// Set via the atomic `GAMMA_LUT` CRTC property blob.
pub gamma_lut: Option<GammaLut>,
/// De-gamma LUT for pre-blending linearization (CRTC-level).
/// Applied before plane blending, converts sRGB-encoded plane pixels to
/// linear light for physically correct alpha compositing and CTM application.
/// `None` means input is treated as linear (no de-gamma applied).
/// Set via the atomic `DEGAMMA_LUT` CRTC property blob.
pub degamma_lut: Option<GammaLut>,
/// Color transform matrix (CTM) for color space conversion.
/// Applied between de-gamma and gamma stages.
/// `None` means identity (no color space conversion).
/// Set via the atomic `CTM` CRTC property blob.
pub ctm: Option<ColorTransformMatrix>,
}
Linux DRM ABI compatibility:
- GammaLutEntry layout matches struct drm_color_lut exactly (field order and
sizes are identical, including the 16-bit reserved padding field).
- ColorTransformMatrix layout matches struct drm_color_ctm (nine S31.32 values
in row-major order).
- Gamma and de-gamma LUTs are set as blob properties via DRM_IOCTL_MODE_ATOMIC
with the CRTC property names GAMMA_LUT and DEGAMMA_LUT.
- The read-only CRTC property GAMMA_LUT_SIZE (and DEGAMMA_LUT_SIZE if the
hardware has a separate de-gamma LUT) reports the hardware LUT size in entries.
- The legacy DRM_IOCTL_MODE_SETCRTC gamma interface (which passes a simple 256-
entry RGB table) is translated internally to a GammaLut with count = 256
(written into a pre-allocated slice of at least 256 entries) and applied as the
gamma_lut property with degamma_lut = None, matching Linux behavior.
// umka-nucleus/src/display/crtc.rs
/// CRTC (display timing generator).
///
/// All mutable CRTC properties (mode, plane assignments, connectors, gamma)
/// are grouped into a single `CrtcState` snapshot, swapped atomically via
/// RCU during modeset commit. This eliminates four separate RwLocks and
/// guarantees readers see a fully consistent CRTC configuration — no
/// half-applied modeset where `active_mode` is updated but `planes` is stale.
///
/// **Per-object atomicity is not transaction atomicity.** Each connector has
/// its own `props` pointer, each plane its own `state` pointer, and each CRTC
/// the pointer below; a transaction spanning several objects swaps them one
/// after another, so a reader sampling in between sees new connector routing
/// against an old plane configuration — exactly the half-applied state the
/// all-or-none contract forbids. The publication step therefore runs under
/// `DisplayDevice::publish_seq` (see below), and readers that need a
/// transaction-consistent view across objects retry on it.
pub struct Crtc {
/// CRTC index (0..num_crtcs-1, unique per display device).
pub id: u32,
/// VBlank tracking for this CRTC (independent lifecycle, not part
/// of modeset state — vblank counters increment continuously).
pub vblank: VblankState,
/// Current CRTC state. Replaced atomically during modeset commit.
/// VBlank handlers and userspace queries read lock-free via RCU.
pub state: RcuPtr<Arc<CrtcState>>,
/// The commit armed on this CRTC and not yet latched, if any.
///
/// Leaf `SpinLock`: acquired with interrupts disabled by the commit path
/// (which installs the flip) and by the completion path (which retires
/// it); no other lock is taken while it is held. `Option` because a CRTC
/// usually has no flip in flight — the presence of the record *is* the
/// "flip pending" predicate the completion path tests.
pub pending_flip: SpinLock<Option<PendingFlip>>,
}
/// Immutable snapshot of CRTC configuration. Created during atomic commit
/// and swapped via RCU. Freed after grace period when superseded.
pub struct CrtcState {
/// Current display mode (None = CRTC disabled).
pub active_mode: Option<DisplayMode>,
/// Planes assigned to this CRTC.
pub planes: ArrayVec<u32, MAX_PLANES_PER_CRTC>,
/// Connectors currently routed to this CRTC.
pub connectors: ArrayVec<u32, MAX_CONNECTORS_PER_CRTC>,
/// Color management properties (degamma LUT, CTM, gamma LUT).
/// All three stages are updated atomically as part of this state snapshot.
/// See `CrtcColorProperties` and the color pipeline diagram above.
pub color: CrtcColorProperties,
}
/// Maximum CRTCs per display device (i915 = 4, AMD = 6, typical).
pub const MAX_CRTCS: usize = 8;
/// Maximum planes per CRTC (primary + cursor + overlays).
pub const MAX_PLANES_PER_CRTC: usize = 8;
/// Maximum connectors per CRTC (for clone/mirror).
pub const MAX_CONNECTORS_PER_CRTC: usize = 4;
/// Maximum connectors per display device.
pub const MAX_CONNECTORS: usize = 8;
/// Maximum planes per display device.
pub const MAX_PLANES: usize = 32;
Transaction publication point: a commit's new snapshots are published in one bracketed step, so "either all changes apply or none do" holds for readers as well as for the hardware:
publish(snapshots): # under commit_mutex, single writer
seq = device.publish_seq.load(Relaxed)
device.publish_seq.store(seq + 1, Release) # odd: publication in progress
for each (id, props) in snapshots.connectors: connector.props.update(props, &commit_guard)
for each (id, state) in snapshots.planes: plane.state.update(state, &commit_guard)
if snapshots.crtc is Some(state): crtc.state.update(state, &commit_guard)
device.publish_seq.store(seq + 2, Release) # even: coherent again
device.state_epoch.fetch_add(1, AcqRel)
Who calls this, and with what. Exactly two callers, both holding
commit_mutex — the hotplug worker (publishing rebuilt connector properties)
and the vsync worker's Stage B, which publishes PendingFlip::snapshots when a
latch is confirmed (Section 21.5).
The committing thread does NOT publish: a NONBLOCK caller has returned long
before the hardware latches, and publishing at commit time would show readers a
configuration the display is not yet scanning out. The snapshots therefore
travel with the pending flip rather than with the caller, and commit_guard is
the guard Stage B took, not one held since the check.
Plain load+store on the sequence rather than fetch_add: the writer is
single by construction (it holds commit_mutex), and fetch_add would hide a
missing-lock bug that the debug assertions otherwise catch
(Section 3.6).
Readers spanning several objects — DRM_IOCTL_MODE_GETRESOURCES,
DRM_IOCTL_MODE_GETCRTC, a compositor reading back the configuration it just
committed — bracket their reads with read_begin() / read_retry() on
publish_seq and retry if it changed or was odd. Readers of a single object's
snapshot (the completion path reading one CRTC, a single-plane query) read that
object's RcuPtr directly and ignore the sequence; per-object RCU already gives
them a coherent snapshot, and they have no cross-object invariant to violate.
Plane-to-CRTC assignment: Not all planes can drive all CRTCs. Each plane has a
possible_crtcs bitmask (set by the driver during probe) indicating which CRTCs it
can be attached to. The atomic commit validator checks this constraint. Example: on an
Intel Gen12 GPU, the cursor plane for pipe A cannot be assigned to pipe B.
Bandwidth validation: When an atomic commit enables multiple CRTCs at high resolutions, the kernel validates that the total scanout bandwidth does not exceed the display controller's memory bandwidth limit:
bandwidth_check(commit):
total_bw = 0
for each active CRTC in commit:
mode = crtc.active_mode
bpp = framebuffer.format.bytes_per_pixel()
total_bw += mode.clock_khz * 1000 * bpp // bytes/sec
if total_bw > display_device.max_scanout_bandwidth:
return Err(DisplayError::InsufficientBandwidth)
This prevents configurations like 4x 4K@120Hz on a controller that can only sustain 2x 4K@120Hz, which would cause visual corruption or FIFO underruns.
Independent timing: Each CRTC runs at its own refresh rate. A laptop with a 120Hz internal panel (eDP) and a 60Hz external monitor (HDMI) has two CRTCs with independent VBlank timing. The compositor receives separate VBlank events for each and renders at independent cadences.
21.5.12 Display Register Abstraction¶
Display drivers access hardware via MMIO-mapped registers. To maintain the tier isolation model and support multiple display controller families, register access is abstracted behind a per-driver operations table:
// umka-nucleus/src/display/hw.rs
/// KABI service marker for a display driver (`display_device_v1`). The
/// `DisplayDriverRef` a device or connector holds is a
/// `KabiHandle<DisplayDriverService>`, and `DisplayHwOps` is that service's
/// vtable — the shape `kabi-gen` emits, not something the core dereferences
/// itself.
pub struct DisplayDriverService;
impl KabiService for DisplayDriverService {
type VTable = DisplayHwOps;
const SERVICE_ID: ServiceId = {
// "display_device" NUL-padded into the fixed 60-byte name field.
let mut name = [0u8; 60];
let src = b"display_device";
let mut i = 0;
while i < src.len() { name[i] = src[i]; i += 1; }
ServiceId { name, major: 1 }
};
// VtableHeader prefix + the thirteen MANDATORY slots. The two VRR slots are
// OPTIONAL and sit beyond the mandatory prefix, so a driver that does not
// implement them publishes a shorter `vtable_size` and `kabi_call!`
// answers `KabiError::NotSupported` for them
// ([Section 12.8](12-kabi.md#kabi-domain-runtime--kabicall-macro-specification)).
const MANDATORY_VTABLE_SIZE: u64 = core::mem::size_of::<VtableHeader>() as u64
+ 13 * core::mem::size_of::<usize>() as u64;
}
/// Display hardware operations — implemented by each display driver
/// (i915, amdgpu, nouveau, etc.), published at probe as the
/// `display_device_v1` KABI service.
///
/// The core NEVER dereferences these fn pointers: they are the provider-side
/// surface. Every core-side invocation goes through
/// `kabi_call!(&driver.handle, method, args)`, which selects the transport the
/// handle recorded at bind time — a direct call in the same domain, a ring
/// otherwise — and runs the provider-generation and live-evolution checks that
/// a raw dereference skips.
#[repr(C)]
pub struct DisplayHwOps {
/// Live-evolution / bounds header shared by every KABI vtable
/// ([Section 12.1](12-kabi.md#kabi-overview)). Its `vtable_size` is what makes an absent
/// optional slot answer `NotSupported` instead of being called.
pub header: VtableHeader,
/// Write a 32-bit value to a display register (MMIO offset from base).
pub reg_write32: unsafe extern "C" fn(ctx: *mut c_void, offset: u32, value: u32),
/// Read a 32-bit value from a display register.
pub reg_read32: unsafe extern "C" fn(ctx: *mut c_void, offset: u32) -> u32,
/// Program a CRTC's timing generator with the given mode.
/// The driver translates DisplayMode into hardware-specific register values
/// (PLL dividers, pipe timings, sync polarities).
pub crtc_set_mode: unsafe extern "C" fn(
ctx: *mut c_void,
crtc_id: u32,
mode: *const DisplayMode,
) -> IoResultCode,
/// Enable/disable a CRTC's timing generator.
/// `enable`: 1 = enable, 0 = disable. u8 instead of bool for stable
/// C ABI (bool size is implementation-defined across compilers).
pub crtc_enable: unsafe extern "C" fn(
ctx: *mut c_void,
crtc_id: u32,
enable: u8,
) -> IoResultCode,
/// Program a plane's scanout address and position.
pub plane_update: unsafe extern "C" fn(
ctx: *mut c_void,
plane_id: u32,
fb: *const Framebuffer,
src: *const Rectangle,
dst: *const Rectangle,
) -> IoResultCode,
/// Commit all pending register writes atomically (latch on next VBlank).
/// Called after crtc_set_mode/plane_update to apply changes together.
pub commit_flush: unsafe extern "C" fn(ctx: *mut c_void) -> IoResultCode,
/// Read EDID from a connector's DDC/CI I2C bus.
pub read_edid: unsafe extern "C" fn(
ctx: *mut c_void,
connector_id: u32,
out_edid: *mut u8,
edid_buf_size: u32,
out_edid_len: *mut u32,
) -> IoResultCode,
/// Read connector hotplug state (connected/disconnected).
pub read_connector_state: unsafe extern "C" fn(
ctx: *mut c_void,
connector_id: u32,
) -> ConnectorState,
/// Acknowledge VBlank interrupt. Returns the CRTC ID that generated it.
pub ack_vblank: unsafe extern "C" fn(
ctx: *mut c_void,
out_crtc_id: *mut u32,
) -> IoResultCode,
/// Set DPMS power state on a connector.
pub set_dpms: unsafe extern "C" fn(
ctx: *mut c_void,
connector_id: u32,
state: DpmsState,
) -> IoResultCode,
/// Validate one IDL-generated `AtomicCommit` request. MANDATORY. The direct
/// arm receives the provider-local decoded value; Ring/Tier2Ring arms use
/// kabi-gen's `display_device_v1` serializer and decode into the same
/// provider-local type before entering this slot. On success the two out
/// words form the canonical `CommitTicket`.
pub atomic_check: unsafe extern "C" fn(
ctx: *mut c_void,
commit: *const AtomicCommit,
out_epoch: *mut u64,
out_commit_seq: *mut u64,
) -> IoResultCode,
/// Stage and arm a commit previously accepted by `atomic_check`.
/// MANDATORY. Returns `IO_OK` or a negative errno. On `IO_OK`,
/// `*out_outcome` is `0` for `CommitOutcome::Latched` or `1` for
/// `CommitOutcome::Queued`; when queued, `*out_commit_seq` is the sequence
/// carried by `VsyncEvent`.
pub atomic_commit: unsafe extern "C" fn(
ctx: *mut c_void,
commit: *const AtomicCommit,
ticket_epoch: u64,
ticket_commit_seq: u64,
flags: u32,
out_outcome: *mut u32,
out_commit_seq: *mut u64,
) -> IoResultCode,
/// Return the opaque driver→core completion-ring handle. MANDATORY.
/// `RingResult` is the canonical FFI-safe result for a
/// `RingBufferHandle`; the generated caller converts it to
/// `Result<RingBufferHandle, KabiError>`.
pub vsync_ring: unsafe extern "C" fn(
ctx: *mut c_void,
) -> RingResult,
/// Enable/disable VRR (Adaptive-Sync/FreeSync/HDMI VRR) on a connector.
/// Returns `IO_NOT_SUPPORTED` if monitor does not advertise VRR capability.
pub set_vrr_mode: Option<unsafe extern "C" fn(
ctx: *mut c_void,
connector_id: u32,
mode: VrrMode,
) -> IoResultCode>,
/// Query the Variable Refresh Rate range supported by the connected monitor.
/// The driver reads the VRR range from EDID/DisplayID (DP Adaptive-Sync),
/// HDMI Forum VSDB, or vendor extensions (FreeSync). Compositors need this
/// to clamp their render rate within the supported range and to decide
/// whether to enable Low Framerate Compensation (LFC) below `min_mhz`.
/// Returns `IO_NOT_SUPPORTED` if VRR is not advertised by the monitor.
pub get_vrr_range: Option<unsafe extern "C" fn(
ctx: *mut c_void,
connector_id: u32,
out_min_mhz: *mut u32,
out_max_mhz: *mut u32,
) -> IoResultCode>,
/// Explicit 32-bit trailing padding. `VtableHeader` is 8-aligned, while
/// fifteen 32-bit pointer slots end at offset 132.
#[cfg(target_pointer_width = "32")]
pub _pad32: u32,
}
// DisplayHwOps: `VtableHeader` + 15 pointer-sized slots. Thirteen mandatory
// fn pointers
// (reg_write32, reg_read32, crtc_set_mode, crtc_enable, plane_update,
// commit_flush, read_edid, read_connector_state, ack_vblank, set_dpms,
// atomic_check, atomic_commit, vsync_ring) plus two optional VRR entries.
// `Option<unsafe extern "C" fn>` is niche-optimized to one
// pointer word because an extern fn pointer is never null, so the optional
// entries cost the same as the required ones — and `None` is the all-zero word
// the slot-bounds check reads as "absent".
//
// The assertion is PER POINTER WIDTH: a pointer-sized slot is 8 bytes on the
// 64-bit legs and 4 on ARMv7 and PPC32, both first-class targets, so a single
// unconditional size would fail to compile on half the corpus.
// 64-bit: header(88) + 15 × 8 = 208. Struct align 8, no trailing padding.
#[cfg(target_pointer_width = "64")]
const_assert!(core::mem::size_of::<DisplayHwOps>() == 208);
// 32-bit: header(72) + 15 × 4 + explicit pad(4) = 136. Header align is 8
// (it contains u64 fields); the explicit pad removes the implicit 132→136 hole.
#[cfg(target_pointer_width = "32")]
const_assert!(core::mem::size_of::<DisplayHwOps>() == 136);
The display core (generic, hardware-independent code) calls DisplayHwOps methods to
program the hardware. Each driver (i915, amdgpu, etc.) provides its own DisplayHwOps
implementation that translates generic operations into hardware-specific register writes.
This is the display_device_v1 KABI vtable (Section 12.1), the same VTable
pattern used by all UmkaOS KABI interfaces.
Transport is selected at bind time, not assumed. The core invokes every
DisplayHwOps entry point through kabi_call!(handle, method, args), which
resolves to a direct vtable call when the core and the driver share a domain and
to a ring submission when they do not. Nothing in the display path names a tier:
an integrated display engine that declared preferred_tier = 1 and a USB
DisplayLink or network display server that declared preferred_tier = 2
(Section 13.3) go through the identical call sites, and a driver
promoted or demoted at runtime keeps working because the transport is a property
of the binding, not of the code.
Register access: reg_write32/reg_read32 are driver-internal accessors,
used by driver-side code composed into the driver's own domain (a shared
display-core helper library, for instance). The core never issues raw register
access — it has no business knowing a controller's register map, and a raw MMIO
poke marshalled across a ring would be both meaningless and slow. The driver's
MMIO regions are mapped into whatever isolation domain the loader placed it in.
Completions are posted, never performed in place. The display interrupt
handler pushes a VsyncEvent onto the completion ring and stops; the core-side
work — publishing state snapshots, waking wait queues, writing per-fd event
queues, dropping framebuffer references — happens in the core, on the far side
of that ring. That is what lets the same handler serve a driver in any domain,
and it also satisfies the interrupt-context rule that forbids a completion
handler from sleeping, allocating, or crossing a KABI domain boundary.
21.5.13 DRM/KMS Compatibility Interface¶
Userspace compositors (Wayland compositors, Xwayland, mpv) interact with the display
subsystem via Linux DRM/KMS ioctl() calls on /dev/dri/card* device nodes. UmkaOS's
umka-sysapi layer (Section 19.1) translates these ioctls into UmkaOS-native display operations.
Supported DRM ioctls (minimum viable set for Wayland compositors).
All use DRM_IOCTL_BASE = 'd' (0x64). Definitions from include/uapi/drm/drm.h:
| ioctl | Linux definition | UmkaOS handler | Description |
|---|---|---|---|
DRM_IOCTL_MODE_GETRESOURCES |
DRM_IOWR(0xA0, drm_mode_card_res) |
display_get_resources() |
Enumerate CRTCs, connectors, encoders |
DRM_IOCTL_MODE_GETCRTC |
DRM_IOWR(0xA1, drm_mode_crtc) |
display_get_crtc() |
Get current CRTC mode and framebuffer |
DRM_IOCTL_MODE_SETCRTC |
DRM_IOWR(0xA2, drm_mode_crtc) |
display_legacy_set_crtc() |
Legacy mode setting (translated to atomic internally) |
DRM_IOCTL_MODE_GETENCODER |
DRM_IOWR(0xA6, drm_mode_get_encoder) |
display_get_encoder() |
Get encoder↔CRTC mapping |
DRM_IOCTL_MODE_GETCONNECTOR |
DRM_IOWR(0xA7, drm_mode_get_connector) |
display_get_connector() |
Get connector properties and supported modes |
DRM_IOCTL_MODE_RMFB |
DRM_IOWR(0xAF, unsigned int) |
display_remove_framebuffer() |
Retire the framebuffer id; planes still scanning it out are disabled and the object is freed when its last reference drops (Section 21.5) |
DRM_IOCTL_MODE_PAGE_FLIP |
DRM_IOWR(0xB0, drm_mode_crtc_page_flip) |
display_page_flip() |
Flip primary plane (translated to atomic commit) |
DRM_IOCTL_MODE_CREATE_DUMB |
DRM_IOWR(0xB2, drm_mode_create_dumb) |
display_create_dumb() |
Allocate a dumb scanout buffer |
DRM_IOCTL_MODE_MAP_DUMB |
DRM_IOWR(0xB3, drm_mode_map_dumb) |
display_map_dumb() |
Obtain mmap offset for a dumb buffer |
DRM_IOCTL_MODE_DESTROY_DUMB |
DRM_IOWR(0xB4, drm_mode_destroy_dumb) |
display_destroy_dumb() |
Release a dumb buffer |
DRM_IOCTL_MODE_ADDFB2 |
DRM_IOWR(0xB8, drm_mode_fb_cmd2) |
display_add_framebuffer() |
Create framebuffer object from DMA-BUF / GEM handle |
DRM_IOCTL_MODE_ATOMIC |
DRM_IOWR(0xBC, drm_mode_atomic) |
display_atomic_commit() |
Full atomic modesetting |
DRM_IOCTL_MODE_CREATEPROPBLOB |
DRM_IOWR(0xBD, drm_mode_create_blob) |
display_create_blob() |
Create property blob (for gamma LUTs, HDR metadata) |
DRM_IOCTL_MODE_DESTROYPROPBLOB |
DRM_IOWR(0xBE, drm_mode_destroy_blob) |
display_destroy_blob() |
Destroy property blob |
DRM_IOCTL_GET_MAGIC |
DRM_IOR(0x02, drm_auth) |
drm_get_magic() |
Client obtains a magic token for master authentication |
DRM_IOCTL_AUTH_MAGIC |
DRM_IOW(0x11, drm_auth) |
drm_auth_magic() |
Master authenticates a client's magic token, granting rendering access |
DRM_IOCTL_SET_MASTER |
DRM_IO(0x1E) |
DRM master acquisition | Acquire DRM master status; returns EPERM if another fd is already master |
DRM_IOCTL_DROP_MASTER |
DRM_IO(0x1F) |
DRM master release | Release DRM master status, revoking modesetting privileges |
DRM_IOCTL_PRIME_HANDLE_TO_FD |
DRM_IOWR(0x2d, drm_prime_handle) |
DMA-BUF capability export | Export GEM handle as DMA-BUF fd |
DRM_IOCTL_PRIME_FD_TO_HANDLE |
DRM_IOWR(0x2e, drm_prime_handle) |
dma_buf_import() |
Import DMA-BUF fd as GEM handle |
Dumb buffer ABI structs (match Linux include/uapi/drm/drm_mode.h):
/// Request/response for DRM_IOCTL_MODE_CREATE_DUMB (0xB2).
/// Userspace fills width, height, bpp; kernel fills handle, pitch, size.
#[repr(C)]
pub struct DrmModeCreateDumb {
pub height: u32,
pub width: u32,
pub bpp: u32, // bits per pixel (must be multiple of 8)
pub flags: u32, // currently unused, must be zero
pub handle: u32, // [out] GEM handle
pub pitch: u32, // [out] bytes per row (may exceed width*bpp/8 for alignment)
pub size: u64, // [out] total buffer size in bytes
}
// DrmModeCreateDumb: u32(4)*6 + u64(8) = 32 bytes.
// Userspace ABI struct — DRM_IOCTL_MODE_CREATE_DUMB argument.
const_assert!(core::mem::size_of::<DrmModeCreateDumb>() == 32);
/// Request for DRM_IOCTL_MODE_MAP_DUMB (0xB3).
/// Returns an mmap offset for the buffer identified by `handle`.
#[repr(C)]
pub struct DrmModeMapDumb {
pub handle: u32,
pub pad: u32,
pub offset: u64, // [out] fake offset to pass to mmap()
}
// DrmModeMapDumb: u32(4) + u32(4) + u64(8) = 16 bytes.
// Userspace ABI struct — DRM_IOCTL_MODE_MAP_DUMB argument.
const_assert!(core::mem::size_of::<DrmModeMapDumb>() == 16);
/// Request for DRM_IOCTL_MODE_DESTROY_DUMB (0xB4).
#[repr(C)]
pub struct DrmModeDestroyDumb {
pub handle: u32,
}
// DrmModeDestroyDumb: u32(4) = 4 bytes.
// Userspace ABI struct — DRM_IOCTL_MODE_DESTROY_DUMB argument.
const_assert!(core::mem::size_of::<DrmModeDestroyDumb>() == 4);
DRM master state machine:
The DRM master is the file descriptor that holds exclusive modesetting privileges on a DRM device (KMS operations require master status).
┌──────────────────────────────────────────────────────┐
│ First open() on /dev/dri/cardN → auto-acquire master │
└──────────────────┬───────────────────────────────────┘
▼
┌────────────────┐
│ fd is MASTER │◄──── DRM_IOCTL_SET_MASTER (requires
│ (modesetting │ CAP_SYS_ADMIN or no current master)
│ permitted) │
└───────┬────────┘
│
DRM_IOCTL_DROP_MASTER / VT switch away
│
▼
┌────────────────┐
│ fd is NON- │
│ MASTER │
│ (render only) │
└────────────────┘
Where the state lives: in DrmDevice::master_file_id, one AtomicU64 per
device holding the DrmFile::file_id of the current master (0 = none) — not in
a per-open flag. Exclusivity here is device-wide by definition, and a per-open
bool cannot express "only one fd at a time" or implement SET_MASTER's
"EPERM if another fd is already master", because no fd can see the others.
Every transition is a single compare_exchange
(Section 21.5), so concurrent
SET_MASTER, DROP_MASTER, and VT handoffs cannot authorize two fds, and
DrmFile::is_master() reads the device record rather than a cached copy.
Modesetting entry points re-evaluate it inside the commit_mutex window, so a
DROP_MASTER or VT switch cannot slip between the permission check and the
commit it authorized.
- First opener: The first
open()on a primary node (/dev/dri/cardN) automatically acquires DRM master — thecompare_exchange(0, file_id)that only the first opener can win. Subsequent openers are non-master by default. - VT switch: When the user switches to a different virtual terminal, the VT subsystem
revokes DRM master status from the current compositor's fd and grants it to the
incoming one, via the forced form of the acquisition CAS (
set_master(force: true), which requiresCAP_SYS_ADMIN). This ensures only the active VT's compositor can modeset. - Close:
release()drops master if the closing fd held it, so a compositor that dies cannot leave the device permanently mastered by a dead file. - Wayland/X11 compositors hold master for the lifetime of their session. Clients
render via render nodes (
/dev/dri/renderDN) which never require master. - Client authentication (legacy): A non-master client on a primary node calls
DRM_IOCTL_GET_MAGICto obtain a magic token (u32), passes it to the master process via an out-of-band channel (e.g., a Unix socket), and the master callsDRM_IOCTL_AUTH_MAGICwith that token to grant the client rendering access. This mechanism predates render nodes and is used by legacy X11 DRI2 clients.
Error mapping: UmkaOS DisplayError variants map to Linux errno values:
/// Display subsystem error codes. Each variant maps to a unique Linux errno.
/// Multiple display errors mapping to the same errno (e.g., EINVAL) use the
/// variant's identity for internal dispatch; the errno value is only used at
/// the userspace ABI boundary (DRM ioctl return).
///
/// **Convention**: Discriminant values use the **negative** of the Linux errno,
/// following the standard kernel-internal convention where functions return
/// `-EFOO` on failure. The syscall return path (`umka-sysapi`) passes the
/// negative value directly to userspace via the register ABI; glibc then
/// negates it, stores it in `errno`, and returns -1. This matches Linux
/// kernel behavior (`return -EINVAL;` in C kernel code).
#[repr(i32)]
pub enum DisplayError {
/// Permission denied (DRM_MASTER required for modesetting).
PermissionDenied = -1, // -EPERM
/// Connector not found.
ConnectorNotFound = -2, // -ENOENT
/// CRTC not found.
CrtcNotFound = -6, // -ENXIO
/// Mode not supported by connector.
ModeNotSupported = -22, // -EINVAL
/// Bandwidth exceeded for display controller.
InsufficientBandwidth = -28, // -ENOSPC
/// This device has issued every non-aliasing `FramebufferId`; no free,
/// non-exhausted slot remains. A stale id is never recycled.
FramebufferIdsExhausted = -28, // -ENOSPC
/// Framebuffer format not supported by plane.
FormatNotSupported = -61, // -ENODATA
/// No active mode on connector (VRR without mode set).
NoActiveMode = -71, // -EPROTO
/// VRR not supported by connector/mode.
VrrNotSupported = -95, // -EOPNOTSUPP
/// Atomic test failed (TEST_ONLY flag).
AtomicTestFailed = -125, // -ECANCELED
/// Framebuffer id unknown, retired, or from a previous occupant of a
/// recycled table slot (`FramebufferTable::resolve` generation mismatch).
FramebufferNotFound = -2, // -ENOENT
/// A non-blocking commit targets a CRTC that already has a flip pending.
/// Surfaced to userspace; matches the `DRM_IOCTL_MODE_ATOMIC` contract.
CommitBusy = -16, // -EBUSY
/// The `CommitTicket`'s epoch no longer matches the device's published
/// state — another commit or a hotplug property swap intervened. Handled
/// internally: the core re-runs `atomic_check` and retries, so this
/// variant never reaches userspace.
CommitStale = -11, // -EAGAIN
/// Driver hardware operation failed (e.g., a `DisplayHwOps` callback such
/// as `set_vrr_mode` returned a nonzero rc). Generic hardware/I-O failure
/// with no more specific DRM cause. Matches DRM drivers returning `-EIO`
/// for failed hardware programming.
HwError = -5, // -EIO
}
Legacy compatibility: Older applications use DRM_IOCTL_MODE_SETCRTC and
DRM_IOCTL_MODE_PAGE_FLIP (non-atomic). UmkaOS translates these into atomic commits
internally — SETCRTC becomes an atomic commit with ALLOW_MODESET, PAGE_FLIP
becomes an atomic commit with only the primary plane updated. This matches the
approach used by modern Linux DRM drivers (i915, amdgpu) which internally implement
legacy ioctls as wrappers around atomic.
21.5.14 Architectural Decision¶
Display: Wayland-only + Xwayland
UmkaOS's KMS interface (Section 21.5) is Wayland-native (DRM atomic modesetting, DMA-BUF via capabilities). X11 support via Xwayland (same as Fedora, Ubuntu 22.04+). No native X11 server support — X11 protocol is a 40-year-old security liability (MIT-MAGIC-COOKIE-1, unrestricted window snooping). Xwayland provides compatibility for legacy apps without compromising security.