Fermi OS is a bare-metal aarch64 (ARMv8-A) kernel built from scratch in C and assembly, targeting QEMU's virt machine with a Cortex-A72 processor.
- PL011 UART Driver — Full serial I/O, hex/decimal/binary output and formatted print with
%s %d %u %x %p %b %c %%format specifiers - Physical Memory Manager (PMM) — Bitmap-based page allocator managing 8 GB of RAM, with single and contiguous multi-page allocation
- MMU (Memory Management Unit) — 3-level page tables (L0→L1→L2) with 2 MB blocks, 48-bit virtual address space, 4 KB granule
- Higher-Half Kernel — Kernel linked at Virt Memory Address
0xFFFF_0000_4000_0000with physical Load Memory Address0x4000_0000. Dual address space with TTBR0 (user) and TTBR1 (kernel) - Kernel Heap — First-fit allocator with block splitting, coalescing, double-free detection, and bounds checking (
kmalloc/kfree) - Kernel Panic Handler — System register dump and CPU halt on unrecoverable errors
- Exception Handling — Full ARMv8-A vector table, trap frame save/restore, ESR decoding, register dump on fault
- GICv3 Interrupt Controller — Minimal GICv3 bringup with Distributor/Redistributor initialization, affinity routing, system register interface, IRQ acknowledge/EOI
- ARM Generic Timer — Configurable periodic tick (default 1 s) driving the scheduler, routed through GICv3 PPI
- Preemptive Scheduler — Round-robin task scheduler with timer-driven preemption, per-task kernel stacks, context switching via callee-saved register save/restore, task creation/exit/reaping lifecycle, and a circular run queue
- Task Sleep — Tick-based voluntary sleep via
sleep_ms(ms), with per-task deadlines and automatic wakeup on timer IRQ (sched_wake_sleepers) - EL0 User-Space Tasks — Full EL1→EL0 privilege separation via
eret, per-task user text and stack mappings with proper permission bits (UXN, PXN, AP), separate kernel and user stacks, and a trampoline that setsSP_EL0/ELR_EL1/SPSR_EL1before dropping to user mode - Per-Task Address Spaces (TTBR0 Switching) — Each task gets its own L0→L1→L2 user page tables allocated from the PMM;
context_switchswapsTTBR0_EL1and performsTLBI/DSB/ISBon every task switch, with recursive page table teardown on task reap - System Call Interface (SVC) — AAPCS64-based
svc #0dispatch withx8as syscall number andx0–x7for arguments; return value written back via the trap frame - POSIX-style Syscalls —
SYS_READ,SYS_WRITE,SYS_OPEN,SYS_CLOSE,SYS_EXIT,SYS_YIELD,SYS_SLEEP,SYS_GETPID,SYS_LSEEK,SYS_UPTIME,SYS_NET_PING,SYS_KILL,SYS_WAITPID(pinned to Linux aarch64 number 260). I/O syscalls route through the current task's fd table; the dispatch path unmasks IRQs so blocking syscalls don't starve other tasks. User pointers are range-checked against[0, USER_VA_TOP)to close kernel-pointer-injection holes - POSIX
errno— Per-task error codes, not one global. Each task owns a TLS page atUSER_TLS_BASEandTPIDR_EL0points at it;errnois theintat offset 0 — the same shape Linux uses (thread pointer inTPIDR_EL0, errno at the TCB base), so a libc can implement__errno_location()directly.context_switchreloadsTPIDR_EL0on every switch, not just at exception entry: a syscall that blocks mid-way context-switches away and must find the register correct when it finally resumes. The kernel stores errno through thePHYS_TO_VIRTalias so the write works regardless of which task'sTTBR0is loaded. Failures return-1and set a meaningful code (EOPNOTSUPPfor features deliberately unsupported,ENOMEMfor address-space exhaustion,EINVALfor bad arguments); unknown syscalls returnENOSYSso a libc can feature-probe. Successful calls leave errno untouched, as POSIX requires - Syscall-level verification — In-kernel assertions plus
user/errno.c, a realexec'd ELF64 binary linked against the public<sys.h>headers. That exercises the whole chain (ELF loader → exec-time TLS allocation → user-sideTPIDR_EL0accessor) rather than the kernel's internal view of errno: 18 checks covering that failures set the right code, that success leaves errno alone, that user space can write it, and that a forked child's errno is independent - Virtual Memory Areas +
mmap— Anonymousmmap/munmap/mprotectwith per-task VMA bookkeeping (src/mm/vma/). Regions start empty and are demand-paged: no physical page is allocated until the region is first touched, sommapis O(1) in physical memory regardless of size. The demand-fault handler (sched_try_handle_vma_fault) checks the faulting access against the VMA'sPROT_*bits, so a write to a read-only region still faults instead of being silently satisfied. Backing pages are recorded per-VMA and released onmunmapor task reap.mprotectrewrites both live PTEs and the VMA record, so pages faulted in later get the new protection too. Syscall numbers deliberately match Linux aarch64 (mmap=222,munmap=215,mprotect=226,brk=214) - VMA Split & Merge —
munmapandmprotectaccept partial ranges, as POSIX requires.vma_split_at()cuts a VMA in two, giving the tail half its own page array;vma_merge_adjacent()welds neighbours back together when they are contiguous and share protection, so a long alloc/free loop doesn't consume a VMA slot per iteration. Both are atomic: the page array is allocated before any list entry is touched, so a failure leaves the set unmodified brk/sbrkHeap — A contiguous program break backed by a fixed per-task 16 MiB arena reservation atUSER_BRK_BASE. Growth extends the VMA in place and commits no physical memory until the heap is touched; shrinking is refused rather than unmapped, so a stale pointer below the new break keeps working. Returns the current break on failure, matching Linux rather than an errno- Deliberate current limits:
mmapis anonymous-private only (file-backed needs a page cache), andMAP_SHAREDis refused rather than silently downgraded to private — downgrading would produce a program that appears to work and silently loses data
- Deliberate current limits:
forkMemory Isolation — A forked child gets a genuine private copy of its address space:vma_clone()duplicates the VMA records, then each faulted-in page is re-allocated andmemcpy'd into the child's page tables, with the child's break inherited from the parent. Pages the parent never touched are left unmapped in the child, so each task demand-faults its own zero page independently. The read-only.textwindow is the one region shared — safe by permission rather than by luck, since those pages are EL0 read-only and neither side can write them. Demand-grown stack pages are copied too, so a child doesn't lose stack its parent had faulted in- This is an eager copy, not lazy copy-on-write. Real COW would map both spaces read-only and copy on the first write fault, which is the better design but needs a per-page refcount and write-fault recovery — and this kernel's fault path currently treats every write fault as fatal. Until that exists, copying is the only way to get the correctness POSIX demands
forkfd Inheritance — A forked child inherits its parent's descriptors rather than getting a fresh console-only table. Because the inherited slots share one refcounted open file description, a rawread()in the child advances the parent's file offset too, and aclose()in the child leaves the parent's descriptor working — both are POSIX requirements that a copied table gets wrong. Buffered stdio is not shared: the child'sFILEbuffer is a copy in the child's own memory, so a read it satisfies from that buffer correctly leaves the shared offset alonewaitpid/waitand Exit Status —SYS_WAITPIDcollects an exited child and returns its pid.sched_waitpidseparates the two cases POSIX treats completely differently: no matching child is an immediateECHILD, while a child that has not exited yet means the caller must block (yield()until the child is scheduled and exits).WNOHANGreturns 0 instead of blocking. Collapsing those two into one answer breaks the most common sequence there is,fork()immediately followed bywait()— the parent usually asks before the child has ever run- A task whose parent has not collected it becomes a zombie (
TASK_ZOMBIE, held on the dead list) rather than being freed bysched_reap(), which would throw away the exit status. Orphaned children are detected on reap and re-parented so they can't accumulate forever - The status word follows POSIX exactly: exit code in bits 8-15 for a normal exit, signal in bits 0-6 with
0x80set for a signalled one. A fault death goes through the signalled encoding asSIGSEGV— packing it into bits 8-15 would give0xFF00, whose low 7 bits are still zero, soWIFEXITED()would report a segfault as "exited normally". There is a real signal 11 behind it, so the stockWIFSIGNALED/WTERMSIGmacros work with no Fermi-specific encoding - Deliberate current limits: negative pids below
-1(process groups) are refused rather than silently reinterpreted, and blocking waits consume CPU instead of parking the task on a wait queue — nothing can wake such a queue yet, because child-exit notification needs signals
- A task whose parent has not collected it becomes a zombie (
- EL0 Page-Fault Handling — Data and Instruction aborts from a lower EL kill only the offending task (logging pid/ELR/FAR/ESR) and continue scheduling; aborts from EL1 still trip a full
kernel_panicsince they indicate a real kernel bug. Instruction aborts never fault a page into existence, so aUXNviolation stays fatal - Demand-Paged User Stack — Stack faults below the initial region grow the stack on demand up to a 256 KiB cap, one page at a time, with the TLB invalidated for the specific
(VA, ASID)pair - Kernel-Mode (EL1) Tasks —
sched_create_kernel_taskprovides a parallel scheduler path with its ownkernel_task_trampoline(noeret, no TTBR0 swap). Used by the in-treenetddaemon
- PCI Express (ECAM) — Brute-force bus enumeration, device discovery and caching, BAR assignment (32-bit and 64-bit MMIO), Memory Space + Bus Master enable via Command register
- VirtIO PCI Transport — Capability list walking (vendor-specific 0x09), common/notify/ISR/device config BAR+offset resolution, MMIO register access via named offsets (virtio spec 4.1.4.3)
- Virtqueue (Split) — Reusable split virtqueue module with descriptor table, available ring, used ring,
VIRT_TO_PHYSDMA address conversion, submit/notify/poll API - VirtIO RNG Driver — Full VirtIO device init sequence (reset → ack → driver → feature negotiation → FEATURES_OK → queue setup → DRIVER_OK), random byte generation via
virtio-rng-pci - VirtIO Block Driver —
virtio-blk-pcidevice init, capacity readout from device config, and synchronous 512-byte sectorblk_read/blk_writeusing chained descriptors (header + data + status) over the split virtqueue - VirtIO Net Driver —
virtio-net-pcidevice init (modern dev id0x1041,VIRTIO_F_VERSION_1required), MAC + link-status readout, RX queue pre-fill (8 1 600-byte buffers),net_tx/net_rx_pollsynchronous APIs, and per-direction packet counters
- Layer 2 — Ethernet — Hand-built ARP requests/replies. Boot path sends a broadcast ARP for the slirp gateway, parses the reply, and caches the resulting
gateway_mac - Layer 3 — IPv4 + ICMP echo — RFC 1071 internet checksum helper, IPv4 header builder, and ICMP echo request that drives a full L3 round-trip to QEMU's slirp gateway (
10.0.2.2). Verified: ARP → IPv4 → ICMP echo reply with TTL - netd kernel daemon — Periodic background pinger running at EL1: drains incoming RX, sends an ICMP echo every 5 s, and prints reply latency in ticks
SYS_NET_PING— EL0-callable ICMP echo so user-space tasks (notably the shell'spingcommand) can fire pings without leaving the syscall ABI
- Virtual Filesystem (VFS) — Unix-style vnode tree with path resolution (
.,.., multi-slash tolerant). Per-vnodefile_operationsvtable (read/write) andvnode_operationsvtable (lazylookup). Supports char devices, block devices, directories, regular files - Per-Process File Descriptor Table —
fd_table_tallocated per task, freed on reap. fd 0/1/2 auto-opened to/dev/console(stdin/stdout/stderr).fd_open/fd_read/fd_write/fd_close/fd_seek(SEEK_SET/SEEK_CUR) dispatching through vnode ops- Open file descriptions are refcounted, which is what makes
forkcorrect:fd_table_clone()gives the child its own table whose occupied slots point at the samefile_t, andfd_close/fd_table_destroydrop a reference instead of freeing. Without this, a child that closed an inherited descriptor would destroy the description the parent was still using
- Open file descriptions are refcounted, which is what makes
- Built-in Char Devices —
/dev/console(UART read/write),/dev/null(discard/EOF),/dev/zero(zero-fill),/dev/rng(virtio-rng with bounce buffer for DMA) - Block Device Node —
/dev/blkexposing the virtio-blk disk with sector-aligned byte-offset read/write - FAT32 (VFS-backed) — Mounted at
/mnt/fat32. Lazy directory traversal: eachlookupwalks on-disk directory entries and creates a vnode on demand with per-vnode(first_cluster, size)state.open/readon regular files goes through the full VFS → fd →file_operations.readpath. 8.3 short names only — there is no long-filename (LFN) support, so a file whose name exceeds 8 characters plus extension is stored bymcopyunder a generated short name (ERRNOT~1) and looking it up by its full name fails. User binaries shipped on the disk image are named to fit 8.3 for this reason /procsynthetic filesystem — Mounted at/proc, regenerates content per-read from live kernel state. Files:/proc/uptime,/proc/meminfo(PMM + heap + VMA mapped/resident totals across all tasks),/proc/tasks(run-queue snapshot with state names),/proc/interrupts(per-INTID counts via the GIC dispatch hook),/proc/netinfo(MAC, link, IP, gateway MAC, packet counters),/proc/cmdline,/proc/version
- A foreign static Linux binary runs —
user/linux/buildsLINHELLO.ELF: a static aarch64 executable produced by the Linux toolchain, linked against nothing (no crt0, nolibc.a, no Fermi header), speaking only Linux syscall numbers and the Linux process-startup contract. It execs through exactly the samesys_execpath as our own programs, because no special case is needed: 32 checks covering the stack entry convention, the auxiliary vector,arch_prctl, and the startup calls every libc makes- This is the milestone that makes the Linux-compat half of this project real rather than aspirational. It is deliberately a hand-written binary and not musl: proving the ABI contract is a prerequisite for musl working, and musl's own startup is a much larger surface (TLS setup,
set_tid_address,rseq, robust lists, ifunc)
- This is the milestone that makes the Linux-compat half of this project real rather than aspirational. It is deliberately a hand-written binary and not musl: proving the ABI contract is a prerequisite for musl working, and musl's own startup is a much larger surface (TLS setup,
- Dual syscall numbering — our libc uses small Fermi numbers; a Linux binary always uses the asm-generic table. Rather than renumbering,
translate_linux_syscall()maps Linux numbers onto the same handlers, so both ABIs coexist and the translation is one readable, auditable table. Only numbers whose argument layout is identical are aliased; anything that shifts or reshapes arguments (openat, thestatfamily,nanosleep) gets its own case, because an alias would silently read the wrong argument. Unlisted numbers reach theENOSYSdefault rather than aliasing onto an unrelated Fermi call that happens to share the number - Linux process startup —
execnow builds the stack layout a Linux binary expects:argcatSP_EL0, thenargv[], aNULL-terminatedenvp, then the auxiliary vector terminated byAT_NULL. Previously we only handed the programargc/argvinx0/x1(AAPCS64), which is enough for our own crt0 and useless to anything else. Both conventions are now satisfied at once, so Fermi-linked and foreign binaries share oneexecpath with nothing to tell them apart - Auxiliary vector —
AT_PHDR/AT_PHENT/AT_PHNUM/AT_PAGESZ/AT_BASE/AT_FLAGS/AT_ENTRY/AT_UID/AT_EUID/AT_GID/AT_EGID/AT_HWCAP/AT_CLKTCK/AT_SECURE/AT_RANDOM/AT_EXECFN. Without these a loader cannot find its interpreter, sold.sois impossible without themAT_PHDRis computed, not assumed.e_phoffis a file offset, and the virtual address is not that number — for a normal static link the firstPT_LOADis offset 0 atvaddr 0x400000, so the headers land atvaddr + e_phoff. It is advertised only when aPT_LOADactually mapped covers the whole table; otherwiseAT_PHDRis 0 so a guest's header walk fails loudly instead of reading whatever happens to be mapped thereAT_HWCAPis reported as 0 rather than advertising the CPU's features: this is a fixed CPU model with no runtime feature detection, and a guest that trusted a non-zero value would assume instructions never verified.AT_BASEis 0 because there is nold.soyetAT_RANDOMpoints at 16 bytes seeded from virtio-rng (zeros if the device is absent — acceptable, since the value only ever seeds canaries and hash state, never a secret)
arch_prctl(ARCH_SET_FS)/ARCH_GET_FS— how a Linux aarch64 process installs its thread pointer.SET_FSreturns 0 on success (every libc aborts on a non-zero return),GET_FSreports the register. Misaligned pointers are refused rather than silently rounded, andSET_GSis refused rather than accepted-and-ignored so a guest cannot believe it has shadow state it does not- This required fixing
switch.S, which reloadedTPIDR_EL0from the hardcodedUSER_TLS_BASEconstant on every context switch. That is immune to struct layout but silently undoes anySET_FSthe instant the task was preempted.TPIDR_EL0is now saved to and loaded fromtask->tpidr_el0, and the offset is asserted against the real struct layout at build time - The hand-maintained
task_toffsets used byswitch.S(TASK_SP,TASK_TTBR0,TASK_TPIDR_EL0) are now numeric constants insched.hwith_Static_asserts againstoffsetof. They were previously comments only: inserting a field mid-struct would have made the context switch read and write the wrong words, which is exactly the kind of bug that survives until a task is switched under load
- This required fixing
set_tid_address— returns the caller's tid. The address is recorded but nothing writes through it, since there are no threads yet; the call must still succeed, because glibc and musl both abort when it fails- Time:
clock_gettime/nanosleep—clock_gettimereportsCLOCK_REALTIMEandCLOCK_MONOTONIC; any other clock id is refused, because answering from the wrong clock is indistinguishable from a correct answer.nanosleeprounds a sub-tick request up to a whole 10 ms tick: rounding down would return early and break the retry backoff and pacing loops that rely on sleeping at least the interval asked for. It reports a zero remainder, which is only truthful because nothing can currently interrupt a sleep- There is no wall clock — no RTC, and nothing reads one — so both clocks report time since boot.
CLOCK_MONOTONICis exactly right;CLOCK_REALTIMEis a limitation a caller cannot detect, so it is documented rather than dressed up
- There is no wall clock — no RTC, and nothing reads one — so both clocks report time since boot.
- Entropy:
getrandom— served straight from virtio-rng. ReportsEIOwhen no entropy device exists rather than zero-filling: a caller seeding a key from a silently-zeroed buffer is far worse off than one that knows randomness was unavailable.GRND_NONBLOCK/GRND_RANDOMare accepted and ignored, which is accurate rather than a pretence — both only request behaviour this already has - File metadata:
fstat/newfstatat— fills the Linux 128-bytestruct stat. The layout was read out of the toolchain's ownasm-generic/stat.hrather than written from memory, and every field offset is_Static_asserted on both sides — in the kernel'slinux_abi.hand again in the test binary's hand-declared copy. If either drifts the build fails, instead of a guest reading a size out of the wrong slot and getting plausible garbage- Timestamps are left zero because nothing records them. A plausible-looking
st_mtimewould be a lie a program could act on (incremental builds, cache validation), so the field is honest-empty instead st_blockscounts 512-byte units as Linux defines it, not bytes. Directories get the execute bit, or a guest cannot descend into one at all.st_nlinkis 1 everywhere: claiming more would let a program believe a file survives anunlinkthis kernel cannot perform.newfstatatacceptsAT_SYMLINK_NOFOLLOWas a no-op (there are no symlinks, so nothing to not-follow) but rejects unrecognised flag bits rather than ignoring themO_*open flags already matched the Linux values exactly (O_CREAT=64,O_TRUNC=512,O_APPEND=1024), soopenatworks with real Linux flag bits and needed no translation
- Timestamps are left zero because nothing records them. A plausible-looking
- Deliberate current limits — static binaries only. Dynamic linking needs
ld.so, ifunc/IRELATIVErelocations,PT_INTERPhandling and real TLS block setup, and is not started.openatandnewfstatataccept onlyAT_FDCWD: resolving a relative path against another descriptor needs per-descriptor working directories this VFS does not track, and resolving against the wrong directory would be worse than refusing.getdents64,ioctl,readv/writev,mremap,futexandrt_sigactionare not yet implemented, so a Linux binary calling them getsENOSYS— a truthful "not implemented" rather than a wrong answer
- Real archive, not header-only stubs —
user/lib/*.ccompiles intolibc.a, which every user program links against (make libcbuilds it alone). Deliberately an archive: a libc needs one definition per function, and header-only inline code silently duplicates state — a header-only malloc counter orerrnowould become a per-translation-unit copy rather than one per process <string.h>—memcpy,memmove,memset,memcmp,memchr,strlen,strnlen,strcmp,strncmp,strcpy,strncpy,strcat,strchr,strrchr,strstr. Comparisons are unsigned by contract: a signedcharcomparison inverts the ordering of every byte >= 0x80.memcpydelegates tomemmovebecause the kernel hands it overlapping user buffers<stdlib.h>allocator — First-fit free-list over the kernel'sbrk()arena, with block splitting and bidirectional coalescing (blocks form an address-ordered doubly-linked list sofree()can merge with the previous block). Payloads are 16-byte aligned, and a header-magic check turns a double free into a diagnostic instead of free-list corruption. Providesmalloc/calloc/realloc/freeplusmalloc_usable_size/malloc_live_blocksfor testingmalloczeroes its payload — stricter than POSIX, which permits indeterminate bytes. Recycled and freshly-brk'd memory are indistinguishable at this layer, and zeroing removes any chance of leaking one allocation's stale contents into another. Worth dropping for speed once the allocator tracks which bytes came straight frombrk
<errno.h>—strerror()for the codes this kernel actually produces, falling back to"Unknown error"rather thanNULL<unistd.h>process calls —open/close/read/write/lseek,_exit, plusfork()andgetpid()<sys/wait.h>—waitpid/waitwithWNOHANG, and the POSIX status predicatesWIFEXITED/WEXITSTATUS/WIFSIGNALED/WTERMSIG/WCOREDUMP. Fixed-width types across the libc come from GCC's own__INT*_TYPE__macros rather than being spelled out insys.h: spelling them out (long long int64_t) disagrees with the toolchain's<stdint.h>, which on aarch64 LP64 sayslong int, and the two collide the moment a program pulls in a real header<stdio.h>— BufferedFILEstreams:fopen/fclose/fread/fwrite/fseek/ftell/rewind/fflush/feof/ferror/clearerr, the character layer (fgetc/getc/getchar/ungetc/fgets/fputc/putc/putchar/fputs/puts), andprintf/fprintf/sprintf/snprintf/perror. One buffer per stream with a single invariant — at most one of the read/write regions is ever non-empty — so "what does buf hold" is one flag rather than two interdependent counters. Requests at least as large as the buffer bypass it entirelystderris written through unbuffered so diagnostics can't reorder against buffered stdout- Position tracking keeps
base= the file offsetbuf[0]corresponds to, which is what makesftell()correct across partial fills and flushes
- Formatted output — One sink-agnostic core (
vformat) that every wrapper shares, sosprintfandprintfcan't drift apart. Supports%d %i %u %o %x %X %c %s %p %%with field width, precision, the-+0#flags,*width/precision, and thehh h l ll z j tlength modifiers.snprintfreturns the length the untruncated result needed, per the standard- Floating point (
%f %e %g %a) is not implemented. It needs correctly-rounded decimal expansion of a binary fraction — 128-bit integer arithmetic or a soft-float helper — so an unimplemented conversion prints a visible<float unsupported>marker rather than a plausible-looking wrong number open()'smodeargument is fixed at0644: there is no variadic support in user space yet, so the caller's mode is accepted and not honoured. Documented rather than silently dropped
- Floating point (
- Verification —
user/libctest.c(shipped asLIBCT.ELF) is a normal exec'd program linked against the reallibc.a: 54 checks covering string edge cases (overlappingmemmovein both directions, unsigned byte ordering, empty-needlestrstr,strrchrlast-match), allocator behaviour (alignment, zeroing on reuse,callocsize overflow,reallocgrow/shrink content preservation, 200 alloc/free cycles as a coalescing check),qsort, anderrno <stdio.h>verification —user/stdiotest.c(shipped asSTDIO.ELF) is a normal exec'd program that writes ~6 KB to a real file on the FAT32 disk and reads it back: 62 checks covering format-specifier edge cases (%05dplacing zeros after the sign,%.*d,%p,NULL→(null),LLONG_MINnot overflowing,snprintfreturning the untruncated length), stream positioning (SEEK_SET/SEEK_CUR/SEEK_END,ftellacross a partial buffer fill, rejecting a seek before the start of the file), access-mode enforcement,ungetc, and confirming the on-disk size and tail marker survive the round trip- Process-model verification —
user/proctest.c(shipped asPROCT.ELF) is a normal exec'd program: 25 checks coveringforkreturning a child pid, a blockingwaitpidcollecting exit code 42,WNOHANGreturning 0 for a child that is still sleeping and then reaping that same child,ECHILDonce no children remain, fd inheritance with a genuinely shared raw file offset (the child'sreadmoves the parent'slseekposition, and the child'sclosedoes not break the parent's descriptor), a buffered stream staying per-process acrossfork, and a deliberately faulting child being reported as signalled withSIGSEGVrather than as a normal exit
- virtio-blk serialization — The virtqueue, request header, and status byte are single-instance state, so two tasks issuing I/O at once would interleave descriptor writes and read each other's status.
blk_read/blk_writetake a device lock acquired with interrupts masked and released with DAIF restored, so it cannot deadlock against a timer interrupt preempting a task mid-request. Spinning briefly unmasks interrupts, since the lock holder is a different task and could not otherwise make progress. Header and status are now stack locals rather than function statics, so no unlocked path can reuse another caller's in-flight descriptor - FAT32 write path —
fat32_write_at()walks to the cluster holding a byte offset, extending the cluster chain as needed, and does read-modify-write only for partial sectors (a whole-sector overwrite skips the read entirely). The fiddly requirement is that a FAT32 file's length lives in the directory entry, not the cluster chain, so every write pushes the new size back to disk — a length that is correct in RAM but stale on disk produces a file that looks truncated to the next reader, including this kernel after a reopen.open()supportsO_CREAT/O_EXCL/O_TRUNC/O_APPEND, with creation delegated to the mounted filesystem through a hook so the filesystem-agnostic VFS layer never needs on-disk knowledge - FAT32 per-call sector buffer — The sector buffer was a file-scope static, so two tasks reading the disk would land their sectors in the same 512 bytes and silently corrupt each other. It is now a per-call local in each of the eight functions that touch it. This only surfaced once two tasks began doing
execconcurrently: which of them failed varied run to run, which is the signature of a race rather than a logic bug
task_shell— An EL0 task that loops reading lines from/dev/console(with backspace/DEL editing and echo) and dispatches built-ins. Pure user-space — talks to the kernel only viasvc. Built-ins:help,pid,uptime,ps,free,ifconfig,irqs,version,cat <path>,kill <pid>,top(5× refresh tasks/mem/net),ping,sleep <ms>,clear,exit
Note: This project is being developed and tested on Mac M4 chip. There is a possibility that you might encounter environment setup errors on other platforms.
Install docker on your host machine.
git clone https://github.com/rituparna-ui/fermi-os.git
cd fermi-os
docker run -d -it -v .:/root/fermi-os --name osdev ubuntuOnce the Docker container is up and running, start a shell in the container that was just created.
docker exec -it osdev bashInside the Docker container, install required dependencies.
apt update && apt upgrade
apt install make qemu-system gcc-aarch64-linux-gnu gdb-multiarch tmux mtools dosfstools
ln -sf aarch64-linux-gnu-as /usr/bin/as# Build the kernel ELF
make
# Build kernel + all user programs + the disk image
make full
# Build and run in QEMU (serial console)
make run
# Build the foreign static Linux binary on its own (no Fermi libc on the link line)
make user/linux/linuxhello.elf
# Build libc.a alone, without relinking user programs
make libc
# Clean build artifacts
make clean
makebuilds only the kernel. Editinguser/lib/*.crelinkslibc.a, but nothing rebuilds the user programs or the disk image unless you ask — so the stale ELF keeps running on the device and a fix appears not to work. Usemake fullwhen changing anything underuser/.
To exit QEMU: Ctrl-A then X
# Launch QEMU paused + GDB in a tmux split
make tmuxOr manually in two terminals:
# Terminal 1: QEMU waiting for debugger
make debug
# Terminal 2: GDB connecting to QEMU
make gdb# Generate compile_commands.json for clangd / IDE support
make compile_commands.json
# Dump QEMU device tree source (DTS)
make dump_dts