Contents
Contents
The project is fully open source. Please refer to the following link https://github.com/RohitRTdev/Archis. Before we proceed to the details of each subsystem within the os, lets look at a demo.
Archis booting and running the shell from an emulator
When you are writing an application, you'd like to not worry about system level information. Things such as cpu architecture, the gpu card model, whether the system has a touch screen or uses a keyboard. The OS is essentially a big library of routines that a process can call into to get what they want. For ex: when you call the input() method in python, the os is doing the heavy lifting of finding the attached input devices (keyboard, touch screen etc), abstracting away the low level details required to fetch the data from these devices and then giving you the input. Python is simply calling into an OS routine to do this.
Python
name = input("Enter your name: ")
print(f"Hello, {name}!")
On Windows, that single line of Python unwinds into roughly this sequence of Win32 calls:
C
/* 1. Grab a handle to the console's input buffer */
HANDLE hStdin = GetStdHandle(STD_INPUT_HANDLE);
/* 2. Block until the console driver (condrv.sys) has line-buffered
keystrokes terminated by Enter, then copy them into our buffer */
DWORD read;
char buffer[256];
ReadConsoleA(hStdin, buffer, sizeof(buffer), &read, NULL);
/* 3. Write the prompt/response back out the same way */
HANDLE hStdout = GetStdHandle(STD_OUTPUT_HANDLE);
WriteConsoleA(hStdout, "Hello, ...\n", 11, &read, NULL);
ReadConsoleA itself is a thin wrapper: it issues an ALPC (type of IPC) request to conhost.exe (the console
host process), which owns the actual input queue and line-editing state, and which in turn is fed keystrokes by the
keyboard driver stack through the Windows message loop.
Archis is also such an operating system that provides a host of these routines to user. In addition, the os is also responsible for initializing and powering on the devices within the system and provides a platform for the user. This platform is the first thing you see once you login to a system. In modern GUI based operating systems, it's mostly a file manager process (In windows, explorer.exe). In general, it is referred to as the shell process.
Most operating systems also allow you to multitask. We take it for granted nowadays, but it wasn't always the case. Early systems like GM-NAA I/O (1956) ran a single job at a time - the computer belonged entirely to whatever program was loaded, with no notion of a "process". Multiprogramming changed that: systems like IBM's OS/360 (1960s) began juggling several jobs in memory at once to keep the CPU busy while one waited on I/O, which is where the process abstraction as we know it emerged. Threads came later still, letting a single process run multiple concurrent paths of execution, popularized by systems like Mach and Windows NT in the late 1980s/early 90s.
Archis is a multitasking operating system without a GUI. The shell is a simple terminal. You can type commands such as ls to list the files in the current working directory. Shutdown system with the shutdown command, be able to create/read/write/rename/delete files and much more. If you have used any linux distro or bsd (or wsl or cygwin in windows), then you would already be familiar with the terminology.
Archis boot flow - from UEFI firmware to the shell
Although the OS is designed, code-wise, to make it easy to add support for multiple platforms, it currently supports UEFI/ACPI-based platforms with Intel/AMD-based CPUs (x86_64 architecture). The CPU architecture tells us what instruction set the CPU uses - these are the instructions we use to make the CPU do stuff (add, move data between registers, write to an I/O device, etc). Then there is the platform model itself. Most desktop platforms used by regular consumers fall under UEFI/ACPI-based systems. UEFI is the model used by the platform firmware (the first code that runs on your system after powering on, provided by the motherboard manufacturer). Similarly, ACPI (Advanced Configuration and Power Interface) is another model, used by the motherboard to manage system and device power.
Neither of these choices is universal though. On the CPU side, x86_64 is just one instruction set among several in wide use today - ARM64/AArch64 powers most smartphones and Apple's own Silicon Macs, RISC-V is an open, royalty-free instruction set gaining traction in embedded and increasingly server hardware, and older/embedded systems may still be running 32-bit ARM or MIPS. On the platform-firmware side, UEFI itself replaced legacy BIOS on PCs, and many ARM-based embedded boards boot through U-Boot instead. And where UEFI systems use ACPI to describe and manage hardware at runtime, most ARM and RISC-V platforms instead rely on a Device Tree - a static blob describing what hardware exists and where, handed to the kernel at boot instead of being queried dynamically.
When you press the power button on your laptop or PC, the platform pulses the reset line on the attached CPU. This starts CPU software reset process
where it first jumps to a fixed, hardware-defined address called the
reset vector. On x86_64, this is the physical address 0xFFFFFFF0, which the chipset maps to the top of the
firmware's ROM/flash chip. The CPU also always starts in something called 16-bit real mode. This means that we can't yet use
the features and instruction set that is provided to modern processors yet. The CPU starts in this mode for backwards compatibility.
This mode emulates the old 8086 processors and uses that instruction set. The registers are only 16 bits, and address space is only 20 bits.
This means the highest address we can access is 220-1.
Code at the reset vector belongs to the platform firmware, not the OS. On a modern UEFI system, this firmware runs through several
internal phases (SEC, PEI, DXE in UEFI terminology) that aren't really our concern here - what matters is that firmware performs
POST (power-on self-test), brings up the memory controller and other core chipset devices, and eventually reaches its Boot Device
Selection phase. There, it walks its configured boot entries, finds the EFI System Partition, and loads our bootloader
(bootx64.efi) into memory. By this point the firmware has long since switched the CPU out of real mode into 64-bit long
mode with flat, identity-mapped paging already active - that's the environment our bootloader's main() actually starts
executing in, which is where the diagram above picks up.
From here on, this section (and this blog in general) describes the boot process specifically for platforms built around x86_64 CPUs with UEFI firmware and ACPI - the combination Archis currently targets.
bootx64.efi is our bootloader, and it's actually two crates working together - a
thin UEFI application (crate boot) that drives everything through UEFI's own boot services, and a helper library
(crate blr) that does the platform-independent heavy lifting - parsing the kernel's ELF image, applying relocations, and, eventually, jumping
to it. Before it can jump anywhere though, it has to gather everything the kernel will need once UEFI's boot services are gone:
it enumerates the FAT32 boot volume (where the rest of our OS lives) and loads every file listed in initfs.conf (the kernel image, drivers
, the boot animation module, userspace binaries) into an in-memory ramdisk; it asks UEFI's Graphics Output Protocol
for the primary framebuffer; and, on ACPI builds, it grabs the RSDP pointer out of the UEFI configuration table.
Finally we build the memory map. We essentially query the firmware to give us a complete mapping of the entire
physical address space. This gives the os information on which regions of memory are free to use, which are used by devices,
and which ones are reserved by the firmware. Following this, we create a self contained BootInfo struct which packages all the information the kernel
requires to complete its boot flow. Then we call the entry point to the kernel image with this struct.
Rust
// Illustrative - the real handoff struct carries a few more fields
struct BootInfo {
kernel_desc: KernelDescriptor,
framebuffer_desc: FramebufferDescriptor,
memory_map_desc: MemoryMapDescriptor,
init_fs: FileTable,
rsdp: usize
}
fn main() -> ! {
// ... gather kernel image, framebuffer, memory map, init-fs ...
let boot_info = BootInfo { /* ... */ };
blr::jump_to_kernel(&boot_info)
}
jump_to_kernel lands us at kern_start_asm, a small assembly stub within aris (our kernel image)
which allocates some space to set up the initial stack and hands off to
kern_start(boot_info) function in Rust - the main entry point of the kernel. This first stashes the BootInfo away, brings up just
enough of itself to function (a heap, a framebuffer-backed logger, device enumeration, per-CPU bookkeeping), and registers the kernel
image and the init-fs contents as loadable modules so they can be relocated later. Then we call: hal::init()
which is responsible for platform specific initialization (HAL stands for Hardware abstraction layer). This initializes x64 control registers,
finds out what cpu features are available,
builds the kernel's own page tables - independent of anything UEFI set up - and switches the CPU onto them. From here,
the execution resumes at kern_addr_space_start(), now running
entirely inside the kernel's own address space. This function re-applies the kernel's relocations at their final virtual addresses
and then works through a short, ordered list of platform bring-up steps before it's willing to call kern_main().
I brought up the term relocation a few times, so let's take a look at what that means. When a compiler turns your source into machine code, it usually can't fill in every address right there and then: a reference to a global variable, a call to a function defined somewhere else, a jump to code that hasn't even been linked in yet - all of these need an address the compiler simply doesn't know yet. So instead of guessing, it leaves a placeholder at that spot and writes down, separately, a relocation: an entry recording "once you know where this thing actually lives, come back and patch this exact location," left for whoever runs next - the linker, or, the loader.
Not every relocation asks for the same kind of patch. A few of the common shapes:
Let's look at an example to understand this.
Rust
fn handler_a() { /* ... */ }
fn handler_b() { /* ... */ }
// Compiled with no idea what base address this image will run at
static HANDLERS: [fn(); 2] = [handler_a, handler_b];
The compiler has no way of knowing, while compiling this file in isolation, what virtual address the kernel image will actually
be loaded at - that's only decided at boot, once the bootloader or the kernel itself picks a base. So the values it bakes into
HANDLERS aren't real addresses at all, just offsets computed as if the image were loaded at address 0, plus one
relocation entry per slot recording what still needs to happen:
Relocation entry (simplified)
Offset &HANDLERS[0] - where in the data section to patch
Type R_X86_64_RELATIVE - "write base_address + addend, no symbol lookup needed"
Addend 0x4110 - handler_a's offset within the image, as compiled
Resolving it is just executing that formula for real: once the kernel knows the base address it's actually running at, it computes
base_address + addend for every one of these entries and overwrites the placeholder at Offset with the
result.
The Global Descriptor Table comes from the days when x86 used segmentation as its primary memory-protection scheme:
every memory access was relative to some segment, and each segment was described by an 8-byte record - a descriptor - giving
its base address, its size (limit), and a handful of permission bits. A running program never touches these descriptors
directly; instead, a segment register (CS, SS, DS, and so on) holds a 16-bit
selector, and the CPU does the lookup for you:
Segment selector (16 bits, loaded into CS/SS/DS/...)
bits 15..3 Index - which entry in the table
bit 2 TI - 0 = Global Descriptor Table, 1 = Local Descriptor Table
bits 1..0 RPL - Requested Privilege Level (0 = kernel ... 3 = user)
A register called GDTR holds the base address and size of the table in memory. Load a selector into a segment
register - either explicitly, or implicitly as part of a privilege-level change like an interrupt or a syscall - and the CPU indexes
into the table with the selector's Index field, fetches the 8-byte descriptor sitting there, and caches its fields
internally so ordinary memory accesses don't have to re-read the table every time. Bundled into that same descriptor is a
Descriptor Privilege Level, and the CPU compares it against the selector's RPL (and the code that's currently
running) on every privilege-sensitive transition - this comparison is the actual mechanism that stops ring-3 code from simply
jumping into a ring-0 code segment on its own.
In 64-bit long mode, most of a descriptor's base/limit fields are ignored outright - every segment is effectively the full address
space starting at 0, so segmentation itself no longer does anything for memory protection. What's still very much alive is the
privilege-level machinery above, plus one extra kind of descriptor: the Task State Segment (TSS). Long mode strips the TSS
down from its original job (holding a full saved register set for hardware task-switching) to just two things the CPU still reads
on its own: rsp0, the stack pointer it loads automatically the instant a ring-3 thread interrupts or syscalls into ring
0, and the Interrupt Stack Table - seven alternate stacks an interrupt can be configured to force-switch onto unconditionally,
regardless of what stack was already in use.
Archis builds a small, 7-entry table per CPU core - null, kernel code, kernel data, user data, user code, and (twice the width of a
normal entry) the TSS - giving selectors 0x8/0x10 for kernel code/data and 0x1B/0x23
for user data/code. It reserves IST slot 1 as a known-good backup stack for exactly the double-fault and NMI vectors, so a handler
reporting a kernel stack overflow isn't forced to run on the very stack that just overflowed.
The Interrupt Descriptor Table is the GDT's counterpart for interrupt handling: whether that's a CPU-detected exception (a page fault, a divide-by-zero), an external hardware interrupt from a device, or a deliberate software interrupt instruction. It's a fixed array of 256 gate descriptors, one per interrupt vector. It tells the processor what action needs to be taken when a specific interrupt happens (identified using vector number).
Interrupt gate descriptor (16 bytes on x86_64)
Offset - the handler's address, split across 3 fields for legacy reasons
Selector - which code segment to run the handler in (almost always the kernel's)
IST - 0, or 1-7 to force a switch onto one of the TSS's alternate stacks
Type - interrupt gate (clears the interrupt flag on entry) or trap gate (doesn't)
DPL - the minimum privilege level allowed to invoke this gate directly
An IDTR register, just like GDTR, points the CPU at this table. When vector N fires, the CPU
indexes entry N, checks that gate's DPL against whoever's asking (this is what stops user code from directly
invoking a kernel-only interrupt by hand), figures out which stack to use (the TSS's rsp0 on a privilege change, or an
IST stack if the gate asks for one), and - entirely in hardware, before a single instruction of your handler executes - pushes the
old SS, RSP, RFLAGS, CS and RIP onto that stack (plus, for a
handful of specific exceptions, a hardware error code), then loads CS:RIP from the gate and starts running your code.
An iretq at the end reverses the whole thing, popping those saved values back off and resuming exactly where execution
left off. In the interest of time, I won't go too much into the details of what some of the terms mentioned here means. If you're
interested on a detailed read on this, please
refer to Intel architectures software developer manual
Archis keeps this about as simple as a real IDT can be: a single table shared by every core (unlike the GDT), where all 256 gates point at auto-generated stubs that are identical in shape - push the vector number, jump to one common handler. The real main logic lives entirely in software from there, as a plain 256-entry table of function pointers: page faults get routed to the memory manager, most other low-numbered exceptions turn into posix signals delivered back to whatever user process caused them (or a kernel panic, if it happened in ring 0), and everything else is handed out to device drivers that have registered interrupt handlers.
Knowing how to jump into a handler doesn't explain how a hardware interrupt picks which vector, and on which core, to jump into in the first place - that routing job belongs to a pair of chips collectively called the APIC (Advanced Programmable Interrupt Controller), which on any modern multi-core x86 machine replaces the old single-core 8259 PIC entirely.
Every core has its own Local APIC sitting right next to it, reachable as a block of memory-mapped (or, in newer "x2APIC" mode, MSR-mapped) registers. It's the thing that actually delivers an interrupt to its core, and it does a few other jobs besides: it carries its own local timer (useful for anything a specific core needs to be woken up for, like a scheduler tick), and it exposes an Interrupt Command Register that lets one core interrupt another core directly - an Inter-Processor Interrupt, the mechanism a kernel uses to tell another core "please reschedule" or "please invalidate this page table entry" without any shared memory to poll. Whatever fires an interrupt on a core, that core's handler has to explicitly tell its Local APIC "I'm done" (an End-Of-Interrupt write) before the same vector can ever fire again.
The second half is the I/O APIC, a shared chip that's where actual device interrupt lines land. Instead of hard-wiring each line to a fixed vector the way the old PIC did, it holds a small redirection table - typically 24 entries, one per input pin, each a 64-bit record you program with the vector to fire, the delivery mode, which core(s) should receive it, the pin's polarity and trigger mode (edge vs. level), and a mask bit to disable that line entirely. Setting up a device's interrupt is really just picking a free vector and writing one of these entries so that a specific physical wire ends up, eventually, jumping into your IDT gate on whichever core you chose.
Archis masks the legacy PIC off completely during early boot and does all its routing through the I/O APIC, pointing every device interrupt at the boot processor's Local APIC. The timer interrupt driving the scheduler (covered in the Device initialization section below) comes from each core's own Local APIC timer rather than the I/O APIC, since it's local to that core by nature and doesn't need routing at all.
For the scheduler's tick, Archis uses each CPU core's own Local APIC timer, running in one-shot mode - it counts down from a
programmed value and fires an interrupt at zero. The handler calls schedule() which calls the main scheduling code. Once
scheduling is done, it reprograms the lapic timer to start again. Currently, its setup such that we get a timer interrupt every 10ms.
Wall-clock time comes from the RTC (real-time clock) - its a battery-backed CMOS chip within the motherboard that keeps running even when no power is connected to the system. It's read directly through two I/O ports (an index port and a data port), returning seconds/minutes/hours/day/month/year one register at a time, in whatever format (binary or BCD, 12- or 24-hour) the chip happens to be configured for. Archis reads it exactly when something asks what time it is - logging a timestamp, or answering a userspace "what's the wall clock" request.
On a multi-core machine, every one of these steps - GDT, TSS, IDT registration, timer calibration - gets repeated for each additional
core as it's brought online. Following this, every processor except the boot processor is put to sleep and is woken up only when new tasks
are scheduled to run on it. The boot processor continues on to run kern_main.
kern_main() is where subsystems come up roughly in dependency order - synchronization primitives and pipes first, then
the scheduler (so processes can exist at all), then the VFS (mounted, at this point, from the in-memory init-fs ramdisk rather than a
real disk), then the module/ELF loader. At this point, the boot screen animation is kicked off. Right after that, io::init()
kicks off the driver/PnP (Plug and play) stack asynchronously: it parses drivers/boot.conf, brings up the root device stacks (ACPI,
the i8042 keyboard controller feeding the input subsystem, the TTY, the disk driver, PCI), and a background worker keeps recursively
discovering and loading child drivers as devices are enumerated.
Rust
// Illustrative - simplified from kern_main()'s actual init order
fn kern_main() {
sync::init();
pipe::init();
sched::init();
fs::init(); // mount in-memory VFS from init-fs
loader::init();
start_boot_animation();
io::init(); // async: parses boot.conf, loads drivers
intf::init(); // syscall / process interfaces
stop_boot_animation(true);
let init_proc = sched::create_process(&["/bin/init"]);
init_proc.wait();
}
Once the drivers are loaded and the device stacks are ready, the boot animation is stopped and
the kernel spawns its very first userspace process, /bin/init.
With multiple cores now in the picture, a new problem shows up: two cores (or a core and an interrupt handler that preempts it) can end up touching the same piece of kernel data at once, and if both read-modify-write it without coordination, the result is a race condition - the final state depends on timing you don't control and generally can't reproduce on purpose. The fix is mutual exclusion: making sure only one thread of execution is ever inside a given critical section at a time.
The simplest tool for this at the kernel level is a spinlock: one memory location (free or held) and a loop that keeps trying
to flip it from free to held until it succeeds, rather than giving up the CPU while it waits. The "keeps trying" part has to be an
indivisible, hardware-guaranteed atomic operation (on x86, a lock-prefixed compare-and-swap or bit-test-and-set) -
without that guarantee, two cores could both read "free" at the same instant and both walk away believing they hold the lock.
An atomic instruction alone still isn't the whole story for a kernel lock: if a piece of code takes a spinlock and then gets interrupted on that same core, and the interrupt handler that runs also needs that lock, you've deadlocked - the handler will spin forever waiting for a lock that can only be released by the exact code it just preempted, which will never run again until the handler returns. So a kernel spinlock generally disables interrupts on its own core for as long as it's held, closing that window entirely. The tradeoff is exactly what it sounds like: that core won't respond to any interrupt, including its own timer tick, until the lock is released - which is why a spinlock should only ever guard something short.
Archis wraps exactly this pattern - an atomic flag plus disabling interrupts for the duration - into a single generic
Spinlock<T> type, and it's what guards essentially every piece of shared kernel state you'll meet for the rest of
this blog: allocator control blocks, device state, scheduler run queues. It's deliberately not the only synchronization primitive
in the kernel, though - spinlocks are for the short stuff. Anything that might genuinely need to wait a while (for another thread to
finish, for data to arrive) wants a primitive that actually gives the CPU back instead of busy-waiting rather than spinning on it -
which is what the next two types are for.
A semaphore is way to "let up to N things happen at once, and make anything past that wait its turn." It's just an integer permit count plus two operations: wait (sometimes called down or P), which decrements the count and blocks the caller if that would take it below zero, and signal (up/V), which increments the count and wakes one waiting thread if any are blocked. Set the initial permit count to the number of interchangeable resources you have (database connections, buffer slots, whatever), and a semaphore naturally throttles access to exactly that many at a time. Set it to 1, and it degenerates into a plain mutex - at most one thing at a time.
You can define semaphore using KSem::new(init_count, max_count). It
takes both how many permits are available up front and the maximum the count is ever allowed to reach. A signal()
past that ceiling is simply rejected. wait(is_interruptible) is the blocking half - block the calling
thread until its signalled or if the interruptibility flag is set, then until a POSIX signal is fired.
wait_with_timeout(timeout, is_interruptible): block until either a permit becomes
available or the given timeout elapses, whichever happens first, and let the caller tell the two outcomes apart afterwards
. Under the hood this rides on the same per-core timer list the scheduler already needs for
its own bookkeeping - every scheduler tick walks that list, decrements each pending timeout, and once one hits zero, wakes its
thread up exactly as if the semaphore had been signaled.
An event solves a related but different problem: not "how many of this resource are free," but "has this one specific thing happened yet." It's a single boolean flag rather than a counter, and it comes in two flavors that differ in what happens the moment it's signaled. A manual-reset event stays signaled once set, until something explicitly clears it - useful for a one-time broadcast, where every thread that checks afterwards, no matter when, should see that it already happened. An auto-reset event does the opposite: signaling it wakes up exactly one waiter and immediately flips itself back to unsignaled.
Memory subsystem is responsible for providing processes with memory when requested. Archis used multiple types of allocators that all serve different purposes.
Archis's allocator layering, bottom to top
At the very bottom sits the frame allocator: it only knows about physical memory, carved into fixed-size pages (4 KiB
on x86_64), and its entire job is answering "give me N contiguous free pages" and "here are N pages back." It has no concept of
virtual addresses at all. The virtual allocator manages a process's (or the kernel's) address
space: it reserves ranges of virtual addresses, and - unless you specifically ask it not to - asks the frame allocator for physical
pages to back them and wires up the page table entries connecting the two. A process always interacts with virtual address and not physical address.
The heap allocator is a classic general-purpose, arbitrary-size allocator (this is what backs Rust's
Box and Vec) that occasionally asks the virtual allocator for another page-sized chunk when it runs low.
The pool allocator exists alongside it for a narrower but very common case: lots of same-size objects being created and
destroyed constantly (a linked-list node, for instance). A general first-fit heap has to search for a suitable free block every
time; a pool allocator sidesteps that entirely by keeping one free-list per distinct block size, so both allocation and freeing are
O(1) with none of the fragmentation a general heap accumulates from constant same-size churn. Finally, tucked underneath everything,
is the fixed allocator - a tiny, statically-sized slab that exists purely to bootstrap the other allocators' own internal
bookkeeping (their free-list node structures) before a real heap is even available to allocate them from. It's the first allocator
to come up, and after boot it's never used.
Of these, the one everyday kernel code actually reaches for directly is the virtual allocator - the frame allocator sits mostly
behind it, and the heap allocator is invisible, reached only through Box/Vec. Here's the shape of the
interface each layer exposes:
Virtual allocator
fn allocate_memory(layout: Layout, flags: u8) -> Result<*mut u8, KError> fn deallocate_memory(addr: *mut u8, layout: Layout, flags: u8) -> Result<(), KError> fn map_memory(phys_addr: usize, virt_addr: usize, size: usize, flags: u8) -> Result<(), KError> fn map_to_kernel(phys_addr: usize, size: usize) -> Result<*mut u8, KError> fn get_physical_address(virt_addr: usize, flags: u8) -> Option<usize>
layout is Rust's own core::alloc::Layout - a (size, alignment) pair, the same type Box
uses internally - so the virtual allocator's alloc path takes a size and an alignment, not an explicit page count; it rounds
up to whole pages for you. flags is a small bitmask, and it's worth slowing down on, because each bit is really
answering a different question about what a virtual mapping should mean:
VIRTUAL - Leave it unset and you get a physical-only allocation: just some
free page frames handed back to you, with no virtual mapping created at all (useful only if you're going to map them
yourself later, or hand the physical address to something outside the CPU's page tables entirely, like a device that
does its own DMA). Set it, and you get a real virtual address, backed by physical memory
and already wired into the page tables, ready to read and write through directly.
USER - x86_64's virtual address space is conventionally split into a low "user" half and a high "kernel"
half (the canonical-address gap in between is left permanently unmapped), and every address space has both halves
present in its page tables at once - it's how a syscall can dereference kernel pointers without switching page tables
first. This flag just picks which half a new allocation should land in.
NO_ALLOC - reserve a range of virtual addresses without asking the frame allocator for any
physical memory to back it. On its own this is mostly a bookkeeping move (claiming space you intend to map into
yourself later, piece by piece); paired with a physical address you already know - a device's memory-mapped registers,
say - it's how you build a mapping to something that was never going to come from the frame allocator's free-page pool
in the first place.
MMIO and WC - both control the page's caching mode.
For a memory-mapped device register: every read has to reach the actual device, and every write
has to happen, in order, exactly as many times as you issued it - a cached, reordered, or coalesced access could mean
your driver never actually reads the hardware's current state, or the device never sees a write at all.
MMIO marks a page fully uncached for exactly this reason. WC ("write-combining") is a
deliberately looser middle ground: writes are still batched up and reordered for throughput, but reads aren't cached -
a good fit for something like a framebuffer, where you're issuing a lot of sequential pixel writes and don't care
about read-back consistency, but do care about not paying full uncached-memory prices for every single pixel.
(The framebuffer is a region of memory that maps the screen. Any writes made to this region is reflected on the screen)
Frame allocator
fn allocate(&mut self, layout: Layout) -> Result<*mut u8, KError> fn deallocate(&mut self, addr: *mut u8, layout: Layout) -> Result<(), KError>
Same Layout convention, but this is a pure physical-page bookkeeper - no address space, no page tables, just a
best-fit search over a free-list of page ranges. In practice, ordinary kernel code almost never calls this directly; it goes
through the virtual allocator, which calls down into this whenever a VIRTUAL allocation needs physical pages to
back it.
Pool allocator
fn allocate_block(size: usize, align: usize) -> Result<NonNull<u8>, KError> unsafe fn deallocate_block(ptr: NonNull<u8>, size: usize, align: usize)
The first allocation of a given size lazily creates a new
free-list for that size class, and every later allocation of the same size reuses it. Most kernel code doesn't call this
directly either; it's wired up as an alternate allocator for specific Rust container types via a small trait. This is useful
when you allocate/deallocate a lot of same sized nodes (Ex: Entries of a vector).
One thing you won't find anywhere in this API: a permissions flag for read/write/execute. Every page Archis maps is readable and writable; there's currently no mechanism for marking a mapping read-only or non-executable.
Once devices can interrupt the CPU and memory can be handed out on demand, the next question is what's actually running. Archis keeps process and thread as genuinely separate concepts, the way most Unix-family kernels do: a process owns an address space, a handle table (set of allocated resources), signal handlers - everything a program needs to exist - while a thread is just a schedulable unit of execution, a saved register context and a stack, that happens to run inside some process's address space. A process can own several threads; a thread always belongs to exactly one process.
Process
fn create_process(args: &[&str], envp: &[&str], is_user: bool, is_suspended: bool) -> Result<KProcess, KError> fn resume_process(pid: usize) -> bool fn kill_process(proc_id: usize, exit_info: ExitInfo, forced_kill: bool) fn exit_process(exit_info: ExitInfo) -> ! fn wait(&self, is_interruptible: bool) -> bool
create_process is the one that does the heavy lifting: a fresh address space, a handle table, and a first
thread, all set up from args/envp (which become the new process's argv/environ)
and an is_user flag picking a kernel-mode or user-mode process.
kill_process and
exit_process are the two ways a process stops - from the outside, or from within - and notice
exit_process returns !: a process that exits does not get to keep running afterwards, by
construction. wait blocks the caller until the target process terminates, and is_interruptible
(which you'll see on almost every blocking call in this API) decides whether a pending signal is allowed to cut that wait
short.
Thread
fn create_thread(handler: DispatchRoutine, context_ptr: *mut c_void) -> Result<KThread, KError> fn create_system_thread(handler: DispatchRoutine, context_ptr: *mut c_void) -> Result<KThread, KError> fn kill_thread(task_id: usize, exit_info: ExitInfo, forced_kill: bool) fn exit_thread(exit_info: ExitInfo) -> ! fn wait(&self, is_interruptible: bool) -> bool fn yield_cpu() fn delay_ms(value: usize, is_interruptible: bool) -> bool
handler is the function the new thread starts executing at, and context_ptr is an opaque pointer
handed straight to it - however that thread wants to receive its arguments. create_thread adds the new thread
to the calling process; create_system_thread instead always parents it to the kernel's own process,
which is what you want for something like a driver's background worker that isn't logically tied to whichever process
happened to trigger it. yield_cpu is a thread voluntarily giving up its remaining time slice early.
Archis uses the simplest scheduling policy there is: round robin, with a fixed time slice (a quantum) of 10 milliseconds, and - importantly - a completely separate run queue per CPU core rather than one shared queue for the whole machine. There's no concept of thread priority, and no work-stealing between cores either: when a new thread is created, it's assigned to a core once (a simple round-robin over the available cores) and stays there for its entire lifetime. This keeps the implementation simple.
The core of the scheduler is a single function, schedule(), and it's called from
the timer interrupt handler, once every quantum. Each time it runs, it decrements the current
thread's remaining tick count; if that's hit zero, or the thread has since blocked on something, it pulls the next thread off the
front of this core's run queue and switches to it. If the run queue is empty, it falls back to an idle thread that just halts the CPU
until the next interrupt arrives - and if there's truly nothing left to schedule anywhere, it even stops rearming the timer and the
core goes to sleep.
When a thread a switched, the current context (which is saved by the interrupt handler) is loaded and saved within the thread control block. The context for the new thread is then loaded, and execution switches to it.
Besides the timer tick, a thread can also give up the CPU voluntarily - calling yield_cpu directly, or blocking on a
semaphore or event while waiting for something, both trigger an immediate switch rather than waiting for the next tick.
Every thread carries a status: Running, Active, Waiting, WaitingInterruptible, Terminated, and
Suspended. The transition between various states is shown below
Task state transitions
Waiting and WaitingInterruptible both mean "blocked on a semaphore or event," and the split matters only for
user threads: a WaitingInterruptible wait can be woken early by a pending signal (Posix signal like SIGINT, SIGTERM etc), while a plain Waiting wait
cannot be interrupted at all. Suspended is only used when a thread is first created. A thread created in suspended mode will
not be put into active/ready queue and is instead placed in the suspended queue. Only when resume is called, we bring the thread from suspended to
active queue. The quirk with suspend mode is that it can only be used once at creation time. Once a thread is resumed, it cannot be suspended again.
When a user thread is killed, its not immediately terminated. Instead the kernel marks a flag within the thread control block to notify that this thread should be killed. When the thread reaches a safe point (about to return back to user space), then it is terminated.
Waiting itself is built on the KSem and KEvent primitives which I covered in the Synchronization primitives section.
These cause the thread to be put into a waiting queue within the scheduler run queue until there is a signal or timeout.
Similar to threads, the process also maintains a separate state. There are only 3 - Ready,
Suspended and Terminated. When the process is created, it is either put into ready/suspended state.
If the process is ready, the first thread that runs within it is put into active queue, otherwise its put into the suspended queue.
When a kill signal is sent to a process or when terminate syscall is made to the process id, the state is changed to Terminated.
This causes a kill to be issued to every thread within the process. Additionally, it blocks any new thread from being created under this process at this time.
Once all the threads are killed, we destroy the address space allocated to the process and cleanup any resources that were part of it. Open handles within the handle table
are closed and any devices that were opened by the process gets a Close request sent to them.
Archis's driver model is heavily inspired from the windows driver model (It even borrows a fair amount of its vocabulary): devices are represented as objects arranged in a vertical device stack, drivers register a table of callbacks rather than exposing arbitrary functions, and I/O flows through the stack as discrete I/O Request Packets (IRPs) rather than direct function calls.
A driver is just a shared object (.so) with one mandatory export. The loader looks specifically for a symbol called
_shim_driver_init, generated automatically by a #[kmod::init(driver)] attribute wrapped around an ordinary
function you write: This is the first function that is called when your driver is loaded. You perform any one time init actions in this function.
Rust
#[kmod::init(driver)]
fn driver_init(driver: &mut DriverObject) -> Status {
kmod::dispatch_init!(driver, dispatch_add, dispatch_pnp);
Status::Success
}
extern "C" fn dispatch_add(driver: *const DriverObject, pdo: *const DeviceObject) -> Status {
// Allocate our own private per-device context, then create exactly
// one device object as a child of the PDO we were handed.
let ctx = Box::into_raw(Box::new(MyDeviceCtx { /* ... */ }));
create_device(driver, Some("mydev0"), ctx as *mut _, Some(pdo), false, DeviceType::None);
Status::Success
}
As part of your driver init, you need to call dispatch_init!, which fills in a dispatch table on the driver object - function pointers for adding a device, handling
Plug-and-Play requests, reads, writes, device-control calls, opens and closes. You only wire up the ones your driver actually needs;
a driver that never reads or writes data can leave those slots empty. The
one callback almost every driver implements is dispatch_add, invoked once when your driver gets matched onto a device -
its job is to create a device object which is a logical representation of the physical device your driver is controlling.
Once a device object is created, the Start pnp request is sent to the driver to start the device. The driver is expected to
perform any device specific initialization, install interrupt handlers if any. If the driver returns a Status::Success on this, the
device is considered to be operational and any i/o such as read/write/open/close are now sent to the driver.
Every device object moves through a small, strictly-ordered lifecycle: Stopped → Started → Stopping →
Removing → Removed. If a device is being removed it first calls stop on the device. This triggers the stop pnp
request on the driver, followed by the remove pnp request. The kernel ensures that any child devices started by your driver is stopped
before your device is stopped.
When a userspace process tries to open and read/write to a device handle, that request is forwarded to the driver managing that device object. The read/write handler which was registered by the driver during its init is called and the device object along with request params (like the output buffer the caller expects the data to be at) are passed as arguments to the function.
Every request flowing through a device stack - read this, write that, start yourself, enumerate your children - is represented the
same way: as an Irp, carrying a major code (broadly, what kind of operation - Read, Write, Pnp, Control, ...) and a minor
code (specifically which one - Start, Stop, Remove, Enumerate, Query, ...), a data buffer, and a status that starts out
Pending and gets set to Success/Failed/Cancelled once someone completes it.
When your driver is done processing a request, it must call Irp::complete_request() method to complete the request.
Within this, you mention whether the request has failed/succeeded. In case your driver cannot immediately complete the request, you return
Status::Pending. The io manager, will keep your Irp in a queue. Once you complete the processing at a later time, the driver calls
Irp::complete_request() which will return the completed parameters back to the caller and Irp resources are deallocated.
Because a thread can be killed or interrupted while it's sitting in exactly that kind of wait, a driver that returns
Pending is expected to register a cancel routine - a callback the kernel will invoke if it needs to cancel that request.
The real terminal driver is a good illustration: a blocking read stashes its IRP in a small pending
list and registers a cancel routine that simply finds and removes it again, completing the IRP with Status::Cancelled
instead of leaving the caller blocked forever if, say, the reading process gets killed mid-read.
Bus drivers (Ex: acpi, pci) implement one extra piece: an Enumerate handler that walks whatever hardware
they're responsible for and creates a PDO (physical device object) for each child it finds - For ex: PCI does this by probing all 256
possible bus/device/function combinations directly through configuration-space I/O. Each new PDO is then asked to identify itself
(a Query request) by handing back a short hardware-id string, and the kernel matches that string against the
[DeviceStack] blocks in a plain text config file, drivers/boot.conf:
[description]
name = i8042
path = /sys/drivers/libi8042.so
boot_start = true
[DeviceStack]
PNP0303
i8042
input
Read that [DeviceStack] block bottom-up: a PDO reporting hardware id PNP0303 (the ACPI id for a PS/2
keyboard controller) gets i8042 loaded directly on top of it as the function driver, and then input gets
loaded again on top of that - a small class driver that i8042 hands keystrokes up to once its interrupt handler
has decoded them. Matching is deliberately simple - a flat string comparison against the id, first match wins - there's no
vendor/device-ID database or wildcard rules like a Windows INF file has, just whatever string a bus driver happens to report.
A real Archis device stack, bottom to top
The acpi driver loads first among the real hardware drivers, as a Root-level stack of its own - it doesn't
talk to a physical bus so much as it wraps ACPICA, Intel's reference C implementation of the ACPI specification, which parses
the ACPI tables the bootloader located via the RSDP and exposes the resulting object namespace to the rest of the kernel. Several other
drivers lean on it directly: ec (the embedded controller) is a good example of a minimal driver - it implements only
dispatch_add and dispatch_pnp, no read/write at all, and rather than an IRQ it registers an ACPI General
Purpose Event callback that fires when the embedded controller needs attention (battery status changing, a hotkey being pressed).
pci is the bus driver described above; i8042 is the more typical hardware driver, claiming a real interrupt
vector and feeding decoded keystrokes up to input; and tty/disk round out the root stacks,
providing the terminal and block-storage abstractions the rest of the OS is built on.
We've covered how code runs and how memory and devices are managed - what's left is how files and programs actually get onto disk, into memory, and running. This section covers both: the filesystem layer underneath, and the userspace layer sitting on top of it.
Every real OS puts a virtual filesystem layer between userspace and whatever actually stores the bytes, so that
open("/some/path") doesn't need to know or care whether /some/path lives on an ext4 partition, a FAT32 USB
stick, or nowhere at all. Archis's VFS resolves a path by walking a small mount table - a flat list of
(mount point, backend) pairs - finding the longest matching prefix, and handing the rest of the path off to whichever
backend is mounted there. Mount points aren't restricted to the root; anything that's already a directory can have another filesystem
mounted on top of it.
Archis's default backend is a genuine memory filesystem, not a caching layer in
front of a disk. At boot, the bootloader packs every file the early system needs - the kernel image, driver .sos, the
boot animation, every userspace binary - into an in-memory ramdisk, and the VFS mounts that ramdisk at / as a tree of
plain heap-allocated nodes. A file is either a static byte slice pointing straight at that baked-in ramdisk data, or - the moment
anything writes to it - a heap-allocated, growable buffer. There is no disk I/O anywhere in this path; the entire filesystem the OS
boots from is just data sitting in RAM, and it behaves like a normal filesystem (directories, symlinks, open/read/write/rename/delete)
purely because the VFS layer implements those semantics on top of an ordinary tree data structure.
A memory filesystem is enough to boot with, but it's not durable - reboot and it's gone. Mounting a real disk filesystem works
through a second backend, one that doesn't implement filesystem logic itself but instead forwards every VFS operation across a C ABI
to a filesystem driver. When you mount a device rather than the memory backend, Archis doesn't
ask you which filesystem type it is - it probes: it loads each candidate driver module in turn and asks it a single yes/no
question, "is this your filesystem?", by handing it the device and calling an exported identify_fs function. Today there's
exactly one candidate (FAT32), so this is a probe list of length one, but the mechanism is extensible to multiple filesystems. Each filesystem
driver needs to export a small, fixed set of functions (open, close, read, write, stat, create, delete, rename,
readdir, and the identify probe) and the VFS can drive it without knowing anything about its internals.
The FAT32 driver is the only real filesystem supported right now. It handles cluster-chain allocation and traversal across however many copies of the File Allocation Table the volume has, growing a file's chain on demand as it's written to and patching the parent directory's cached size/cluster fields when that changes. It also has long file names support. FAT32's on-disk format only really supports 8.3-style names natively; anything longer is stored as a chain of specially-flagged directory entries that reconstruct the real name, with a legacy short name synthesized alongside it for compatibility. It also treats symlinks as a special directory entry type (by using a reserved attribute bit as symlink entry).
Two related but separate loaders sit on top of the filesystem: one for kernel modules (drivers, filesystem backends, even the kernel image itself), and one for userspace programs. Both parse an ELF file, resolve its dependencies, and apply relocations.
If more than one process is loading the same user space module (this is very common. For ex: every C process loads the libc library) then it only allocates new memory to accomodate the write only sections within the elf. The read only sections such as the code section is shared among both the processes. This drastically reduces memory usage.
Everything userspace can ask the kernel to do funnels through one narrow gate: the x86_64 syscall/sysret
instruction pair, which is exactly why the GDT's slightly odd selector ordering back in the Bootloader section mattered - those
instructions derive the code/data selectors for both privilege levels from a single model-specific register, populated once at boot,
which is also where the kernel tells the CPU the address of its syscall entry point. From there it's genuinely simple: a syscall
number and up to six arguments in registers, a flat array of function pointers indexed by that number, bounds-checked and dispatched.
C
/* userspace side - every libc syscall wrapper looks like this */
syscall_status_t sys_write(handle_t fd, const void *buf, size_t len) {
return do_syscall(SYSCALL_WRITE, fd, (uint64_t)buf, len, 0, 0, 0);
}
Roughly forty syscalls cover the ground you'd expect: process and thread control (create, exit, wait, get/set process group), file I/O (open, read, write, seek, stat, mkdir, rename, readdir), memory (allocate/deallocate), synchronization (create a named or anonymous semaphore/event, wait, signal), and signals (register a handler, issue a signal, return from one). Let's look at the open syscall in detail.
C
handle_t sys_open(const char *type, const char *name, uint64_t flags);
type picks which namespace to resolve name in, and each namespace is free to interpret name
however makes sense for it: "fs" hands name to the VFS as a path, to be walked through the mount table and
node tree from the Filesystem section above; "device" looks it up by name in the device tree the driver stack built at
boot; "sync" and "pipe" each check a small global table of currently-live named objects. Every kernel
subsystem that wants to be reachable through open registers itself against a type string and a resolver function once,
at boot, and open itself does nothing more than look up which resolver owns the type you asked for and hand your name
straight to it - it has no built-in knowledge of files or devices at all.
C
handle_t tty = sys_open("device", "tty", OPEN_INHERITABLE_FLAG); /* device namespace */
handle_t file = sys_open("fs", "/etc/motd", 0); /* fs namespace */
handle_t pipe = sys_open("pipe", "myfifo", OPEN_WRITE_FLAG); /* pipe namespace */
Whichever namespace it came from, open always hands back the same kind of thing: an integer handle, an index
into the calling process's own handle table. From that point on, the caller doesn't need to know or care which namespace it came
from - read/write/close all just take a handle.
This interface is useful since any subsystem that wants an open like function doesn't need to create a new syscall, it just needs to register itself with an open handler and the existing code will route it correctly.
Archis's C library is written entirely from scratch rather than ported from glibc or musl - small on purpose, covering just enough of
stdio, stdlib, string, pthread, signal and semaphore to
get a shell and a set of coreutils running, rather than aiming for exhaustive POSIX coverage (there's no regex.h, no
locale support, and floating point isn't usable in userspace yet at all, since the scheduler doesn't currently save/restore FPU state
across a context switch). Every userspace binary starts the same way regardless of what it does: a small assembly stub _start,
unpacks argc/argv/envp off the initial stack and calls into crt_main(),
which does exactly three things - wires up the standard I/O handles, initializes the signal-handling machinery, and populates the
global environ pointer - before finally calling the program's own main().
On top of libc sits a small POSIX-ish shell (parsing, forking and executing commands, pipes, I/O redirection, and the job-control
machinery from the Scheduler section) and a set of GNU-coreutils-style programs - ls, cat, cp,
mv, rm, mkdir, head, tail, wc, and so on.
POSIX is a decades-old standard specifying how a Unix-like
shell and its surrounding utilities are supposed to behave, and GNU coreutils is the specific, hugely popular implementation of
those utilities that ships on virtually every Linux distribution - between them they're basically the reason ls -la or a
pipeline like cat file | grep foo works the same way across wildly different systems. That said, Archis isn't remotely close to being
fully gnu/posix compatible.
The forty-odd syscalls above cover the common operations every process needs, but plenty of useful kernel-introspection features don't
really deserve their own permanent syscall number - a debug-log dump, a process list, the kind of thing you'd reach for a
/proc file or an ioctl for on Linux. Archis handles this with a single generic syscall,
SYSCALL_INTF_REQUEST, that takes a name string and a request buffer, and looks the name up in a small kernel-side
registry mapping names to handler functions. Adding a new introspection feature means registering a new named handler, not adding a
new syscall number and plumbing it through the entire dispatch table.
There are exactly two interfaces registered today: "system", whose only operation drains the kernel's in-memory debug log
into a userspace buffer (this is what the dmesg utility calls), and "process", which can hand back a
snapshot of every running process (pid, parent, group, thread count, status) or a specific process's command line - what powers
ps. This is similar in spirit to the /sys interface in linux.
dmesg itself is a clean illustration of the two-phase calling convention every interface follows: call once with an
empty buffer to find out how many bytes you'll need, allocate exactly that much, then call again to actually fill it in.
C
int main(void) {
intf_system_request_t req = {0};
req.type = INTF_SYSTEM_KLOG;
/* Pass 1: bytes_needed is still 0, so the kernel only reports the size */
sys_intf_request("system", &req);
char *buf = malloc(req.bytes_needed);
req.buffer = buf;
/* Pass 2: bytes_needed is now set, so the kernel actually copies the log out */
sys_intf_request("system", &req);
fwrite(buf, 1, req.bytes_written, stdout);
free(buf);
return 0;
}
The kernel side is a plain handler matching on req_type - nothing more than the ring log from logger::kring
copied out through copy_to_user, gated by whether the caller has told it a buffer is ready yet:
Rust
fn handle_klog(req: &mut IntfSystemRequest) -> Result<(), KError> {
let log = collect_kernel_log();
if req.bytes_needed == 0 {
// First pass: just report how big the buffer needs to be.
req.bytes_needed = log.len();
return Ok(());
}
// Second pass: the caller has allocated bytes_needed bytes - copy into it.
let copy_len = log.len().min(req.bytes_needed);
mem::copy_to_user(req.buffer, log.as_ptr(), copy_len)?;
req.bytes_written = copy_len;
Ok(())
}
Neither pass ever blocks or grows a kernel-side buffer to fit an unbounded userspace request - the caller does the allocating, the kernel just tells it how much to allocate first.
C
handle_t file = sys_open("fs", "/etc/motd", 0);
handle_t tty = sys_open("device", "tty", 0);
handle_t rd = sys_open("pipe", "myfifo", 0); /* reader */
handle_t wr = sys_open("pipe", "myfifo", OPEN_WRITE_FLAG); /* writer, other process */
sys_read(file, buf, len, &got);
sys_read(tty, buf, len, &got);
sys_read(rd, buf, len, &got);
The file resolves through the "fs" resolver: /etc/motd gets walked through the mount table and
node tree from the Filesystem section, landing on a FileHandle. sys_read matches on that variant and
calls straight into the VFS node's own read - copying bytes out of whatever's actually backing it (heap memory, for
the in-memory root filesystem; a page pulled off disk through the FAT32 driver, for a mounted one).
The tty resolves through the "device" resolver instead, landing on a DeviceHandle pointing at the
device object the tty driver created. This time sys_read takes a
completely different path: rather than touching any data directly, it builds an Irp - major code Read,
a buffer, nothing else - and sends it synchronously down the device stack, blocking the calling thread until the driver completes
it.
The pipe resolves through the "pipe" resolver, and which handle variant it lands on depends on
OPEN_WRITE_FLAG: a PipeReadHandle for the reader, a PipeWriteHandle for the writer -
deliberately two different handles for two different ends of the same underlying object, so a process that only ever opened the
read end has no way to accidentally write into it. sys_read/sys_write on either one goes straight into
the pipe's own internal buffer
That completes the overview on Archis - from the moment power reaches the CPU, through a custom bootloader and kernel, down to a shell prompt you can type into. There's plenty this blog skipped in the interest of staying readable (SMP scheduling details, ACPI's object namespace, the full syscall table, to name a few), and plenty the OS itself doesn't do yet - no read-only/executable page permissions, no floating point in userspace, no filter drivers, no dynamic driver hot-attach. If any of this was interesting enough to make you want to poke at the actual code, the repository is linked at the top of this page - issues, questions and pull requests are all welcome.