Kernel Startup
In this article, we will discuss what happens between power-on and the first call to your kernel's Run() method: the boot chain, the initialization phases, and the BeforeRun/Run/AfterRun lifecycle your kernel is built on.
The main differences if you come from Gen2:
| Gen2 | Gen3 | |
|---|---|---|
| Compiler | IL2CPU | .NET NativeAOT (ILC) + the Cosmos IL patcher |
| Firmware | BIOS | UEFI, on x64 and ARM64 |
| Bootloader | Limine | Limine |
| Entry plumbing | IL2CPU emits the call to Kernel.Start() |
A source generator emits CosmosEntryPoint.Main() |
| Lifecycle | BeforeRun / Run / AfterRun |
BeforeRun / Run / AfterRun (unchanged) |
The boot chain
UEFI firmware
│
Limine loads kernel.elf, maps it in the higher half, sets up the framebuffer
│
kmain() native C bootstrap (Cosmos.Kernel/Bootstrap/kmain.c)
├─ Phase 1 CPU: enable SIMD, initialize the serial port
├─ Phase 2 Platform: RSDP + HHDM from Limine, early ACPI parse (MADT, MCFG)
├─ Phase 3 Managed runtime: heap, GC, type system, library initializers
└─ Phase 4 User kernel: Main(argc, argv) → Kernel.Start()
The Limine bootloader loads the kernel ELF produced by the build pipeline, and jumps to kmain(), a small C bootstrap compiled into every kernel. From there:
- Phase 1: CPU. SIMD is enabled first (NativeAOT-generated code uses XMM registers from the very first instruction) and the serial port is initialized, so everything after this line is logged. On ARM64 the alignment check is disabled here too.
- Phase 2: Platform. The bootstrap asks Limine for the ACPI RSDP and the higher-half direct-map offset, then does an early ACPI parse: the MADT (where the interrupt controllers and CPUs are) and the MCFG (where PCIe configuration space lives).
- Phase 3: Managed runtime. The NativeAOT startup path runs. This is where the C# world comes alive, one package at a time (see the next section).
- Phase 4: User kernel. The bootstrap builds
argvfrom the kernel command line and calls the managedMain, which ends up in your kernel'sStart().
Phase 3: how the managed kernel comes up
Each Cosmos package contributes a library initializer that the runtime executes before any of your code. The SDK orders them in three stages: the heap first, then the runtime's own initializers, then the kernel libraries, each after the packages it references.
- Cosmos.Kernel.Core: carves the heap out of the Limine memory map, initializes the garbage collector, then registers the type system (statics, eager static constructors, module initializers). Nothing allocates before this step.
- The runtime's own initializers (
System.Private.CoreLiband its companions): the preallocatedOutOfMemoryException, the class constructor runner, the type loader and reflection callbacks, stack trace metadata. The class constructor runner is created here, so a static field whose type has a lazy static constructor can be read from this step on and not before. - Cosmos.Kernel.HAL: platform HAL; the device tree the bootloader handed over, if any, is recorded (
[Firmware] Device tree at 0x..., N bytes, or[Firmware] No device treeon q35 and on an ACPI boot of the virt machine); the interrupt controller, platform hardware (APIC/GIC, timers), then the machine's root platform nodes are published into the driver kit right afterInitializeHardware(on x64 the 8042 keyboard controller,platform:i8042@60, then the PCI host,platform:pci@cf8; on ARM64 the ECAM host from MCFG or from the device tree and oneplatform:virtio_mmio@...node per occupied slot, from the device tree or the virt machine's window, whatever the switches say), where they wait for the driver stage. The 8042, AHCI, NVMe and xHCI controllers are not brought up here: their drivers live in the driver kit and bind them during the driver stage. Last, whenever graphics are on, interrupts or not, the framebuffer Limine handed over is recorded ([KERNEL] - Recording the firmware framebuffer...) for the driver stage to publish as the firmware display. - Cosmos.Kernel: CPU exception handlers and the scheduler (one idle thread per CPU, preemption on a 10 ms quantum).
- Cosmos.Kernel.System: the service managers
TimerManager,KeyboardManager,MouseManager,NetworkManager,DisplayManager,StorageManager.KeyboardManager,MouseManager,NetworkManager,DisplayManagerandStorageManagereach install their driver kit consumer here, before the driver stage, so a keyboard, a pointer, an interface, a display or a disk a kit driver publishes reaches its manager, and so does the firmware display the driver stage publishes first; no platform network device is registered here, and the first interface a kit driver publishes becomes the primary. No platform keyboard or mouse is registered here either: the PS/2 devices arrive through the consumers during the driver stage, like every other input device. The storage manager's disks all arrive through its consumer during the driver stage.
Every step in 3-5 is gated by a feature switch (CosmosEnableInterrupts, CosmosEnablePCI, CosmosEnableTimer, CosmosEnableKeyboard, CosmosEnableMouse, CosmosEnableNetwork, CosmosEnableStorage, CosmosEnableGraphics, CosmosEnableScheduler, all true by default, and CosmosEnableUsb, on by default whenever the keyboard, the mouse or the storage switch is). Set one to false in your .csproj and the corresponding subsystem is skipped here and compiled out of the kernel.
The generated entry point
You never write a Main for a Cosmos kernel. The SDK ships a source generator that emits it from the CosmosKernelClass project property:
// <auto-generated/> CosmosEntryPoint.g.cs
namespace Cosmos.Kernel.System.Internal;
public static class CosmosEntryPoint
{
public static void Main()
{
global::Cosmos.Kernel.System.Internal.DriverManifest.Register();
global::Cosmos.Kernel.System.Internal.KernelEntry.Start(new global::MyOS.Kernel());
}
}
DriverManifest is the second generated file: the list of driver classes the kernel carries, registered before the kernel starts so the driver stage in Start() can offer them devices. A kernel with no drivers of its own still carries the twenty-one Cosmos ships in Cosmos.Kernel.Drivers: the PCI host driver, the PCI Express root port driver, the Intel E1000E driver, the virtio PCI and MMIO transport drivers, the virtio-net, virtio-input and virtio-blk drivers, the two display drivers, virtio-gpu and VMware SVGA II, the two storage drivers, AHCI and NVMe, the HD Audio driver, the five USB drivers, the xHCI host controller with the hub, HID boot keyboard, HID boot mouse and mass storage class drivers, and the three PS/2 drivers, the 8042 controller with the keyboard and mouse class drivers, eighteen of them behind a feature switch (the USB five behind CosmosEnableUsb, the PS/2 keyboard and mouse behind CosmosEnableKeyboard and CosmosEnableMouse, the HD Audio driver behind CosmosEnableAudio). The driver manifest page describes how the list is built and how a project excludes or opts into a driver.
CosmosKernelClass defaults to <RootNamespace>.Kernel, so a class named Kernel in your project's root namespace is picked up automatically. To use a different type, set it explicitly:
<PropertyGroup>
<CosmosKernelClass>MyOS.Boot.MyKernel</CosmosKernelClass>
</PropertyGroup>
KernelEntry.Start then registers that instance as Kernel.Current and calls its Start(). KernelEntry is boot plumbing for this generated code only, public because the code compiles into your kernel's assembly; your own code never calls it.
Sys.Kernel.Start()
Cosmos.Kernel.System.Kernel is the abstract base class of every user kernel. Its Start() drives the whole lifecycle:
- Enables hardware interrupts (everything before this point ran with interrupts off).
- Runs the driver stage: the kit logs its manifest, publishes the firmware framebuffer recorded in phase 3 as the firmware display (the display manager's primary until a display driver publishes one), and then the drivers listed in the kernel's manifest are offered every node the buses have published, the platform nodes seeded in phase 3 first (the 8042 driver publishes its two PS/2 port nodes from its probe, and the keyboard and mouse drivers bind them during the stage), then every PCI function the PCI host driver found and every one a root port driver found behind its hot-plug slot, then every virtio device a transport driver published beneath a function or a slot, and the step returns once each node, children included, has been offered; the AHCI, NVMe, virtio-blk and USB mass storage drivers publish the disks they find here (the xHCI driver enumerates its root ports inside its probe, so a stick present at boot is published during the stage too), and the storage manager registers and scans each one inside the publish, so
StorageManager.Partitionsis filled by the time the step returns. The xHCI driver's hot-plug thread, started inside its probe, follows the devices plugged in or pulled out from then on; it needs the scheduler. The root port driver's slot thread does the same for a device plugged into or pulled out of a PCI Express hot-plug slot, placing the registers of one that arrives; it needs the scheduler too. - Calls
OnBoot(), whose default implementation initializes the graphicalKernelConsoleon whatever display is primary by then, which is what makesConsole.WriteLinework. - Turns off the early-boot text renderer: up to here, the boot log you see on screen is the serial log mirrored by a minimal framebuffer writer; from now on the screen belongs to
Consoleand the Canvas. - Calls
BeforeRun()once. - Calls
Run()in a loop untilStop()is called. - Calls
AfterRun()once. - Halts the CPU. There is no operating system to return to: a kernel never exits.
Stopping the machine
Step 8 above is where a kernel ends up on its own. Power is how you get there deliberately, and the three members differ in how far they go:
using Cosmos.Kernel.System;
Power.Halt(); // park this CPU until an interrupt wakes it
Power.Reboot(); // restart the machine; does not return
Power.Shutdown(); // power off; does not return
Halt() is the one that returns. It parks the CPU rather than spinning, so it is what an idle loop should call instead of while (true) { }, which burns a core and, on a single-CPU kernel, keeps the scheduler from making progress.
Reboot() and Shutdown() do not return on success. Both route through the platform's power operations, and where the firmware offers none they fall back to parking the CPU forever rather than continuing, which is why the compiler treats them as never returning.
To end the main loop without ending the machine, call Stop() on your kernel. Run() stops being called, AfterRun() runs once, and the CPU halts.
Static code that has no this to call it on reaches the running instance through Kernel.Current, which the generated entry point sets before your kernel starts (it is null until then, in your kernel's constructor included):
using Cosmos.Kernel.System;
Kernel.Current?.Stop();
A minimal kernel
using System;
using Sys = Cosmos.Kernel.System;
namespace MyOS;
public class Kernel : Sys.Kernel
{
protected override void BeforeRun()
{
Console.WriteLine("Cosmos booted successfully!");
}
protected override void Run()
{
Console.Write("Input: ");
string? input = Console.ReadLine();
Console.Write("Text typed: ");
Console.WriteLine(input);
}
}
BeforeRun(): one-time setup (mount a filesystem, configure the network, draw a splash screen).Run(): your main loop body. It is called again as soon as it returns, so it does not need to loop itself; keep it re-entrant.Stop(): call it from anywhere to exit the loop after the currentRun()completes.AfterRun(): optional cleanup once the loop has ended.
An uncaught exception inside Run() propagates out of the loop, so wrap the body in try/catch if a command failing should not take the kernel down.
Customizing startup
OnBoot() runs before the console exists, the right place for early setup of your own. Interrupts are already enabled and the driver stage has run, so a device a driver bound is usable from here on; code that must run with interrupts off belongs in an override of Start() itself:
protected override void OnBoot()
{
base.OnBoot(); // keep the KernelConsole setup; drop this line to boot headless
// your early initialization here
}
A headless kernel that later wants Console output calls KernelConsole.Initialize() itself: it is the only route on the ring, Console.WriteLine does not bring the console up on its own. The call is idempotent, so it is safe whether or not base.OnBoot() already ran, and it returns false when graphics are compiled out, no display is published, or the display has no mode.
For total control you can override Start() itself and take over the lifecycle: the default implementation in Cosmos.Kernel.System/Kernel.cs is small and a good starting point to copy from.
The kernel command line
Limine passes a command line to the kernel: the cmdline: entry of the Bootloader/limine.conf file in your kernel project. It comes out the standard way:
foreach (string arg in Environment.GetCommandLineArgs())
{
Console.WriteLine(arg);
}
Watching a boot
Every phase above logs to the serial port (COM1), which cosmos run connects to your terminal, the first thing to read when a kernel does not come up:
========================================
CosmosOS v3.0.62 (gen3)
Architecture: x86-64
========================================
[KMAIN] Phase 1: CPU initialization
[KMAIN] Phase 2: Platform initialization
[KMAIN] - RSDP found at: 0xFFFF8000000F52D0
[KMAIN] - Initializing ACPI...
[KMAIN] Phase 3: Managed kernel initialization
[KERNEL] - Initializing heap...
[KERNEL] - Initializing garbage collector...
[KERNEL] - Initializing HAL...
[KERNEL] - Recording the firmware device tree...
[Firmware] No device tree
[KERNEL] - Initializing interrupts...
[KERNEL] - Initializing platform hardware...
[KERNEL] - Publishing platform nodes...
[KERNEL] - Recording the firmware framebuffer...
[KERNEL] - Initializing scheduler...
[KMAIN] Phase 4: User kernel
[KernelEntry] Registering kernel
[Kernel] Enabling interrupts...
[Kernel] Starting drivers...
[Drivers] manifest: HdAudioDriver(prio 0) PcieRootPortDriver(prio 0) VirtioPciTransportDriver(prio 0) XhciDriver(prio 0) VmwareSvgaDriver(prio 0) E1000EDriver(prio 0) AhciDriver(prio 0) NvmeDriver(prio 0) I8042Driver(prio 0) PciHostDriver(prio 0) VirtioMmioTransportDriver(prio 0) Ps2KeyboardDriver(prio 0) Ps2MouseDriver(prio 0) UsbHubDriver(prio 0) UsbKeyboardDriver(prio 0) UsbMouseDriver(prio 0) UsbMassStorageDriver(prio 0) VirtioGpuDriver(prio 0) VirtioInputDriver(prio 0) VirtioNetDriver(prio 0) VirtioBlkDriver(prio 0)
[Display] primary: firmware "framebuffer" (the only display)
[Drivers] firmware published display "framebuffer" (consumed)
[Drivers] engine started, worker thread
...
[StorageManager] sata0 registered by AhciDriver (primary)
[Drivers] pci:0000:00:03.0 AhciDriver published block "sata0" (consumed)
...
[Kernel] Calling OnBoot()...
[Kernel] Calling BeforeRun()...
[Kernel] Entering main loop...
[Kernel] Calling Run()...
The two sata0 lines appear when a disk is attached (cosmos run --disk disk.img): the kit's AHCI driver publishes it during the driver stage, and the storage manager registers it inside the publish, scanning its partitions, which is why the manager's line comes before the kit's. A USB stick (cosmos run --disk disk.img,usb) shows the xHCI driver's lines in their place: the device the root port scan found inside the probe, the controller line, then the stick's interface node offered to the mass storage driver and its disk registered inside that probe:
[Drivers] pci:0000:00:03.0 XhciDriver: usb 1-1: 46f4:0001 SuperSpeed, 1 interface(s)
[Drivers] pci:0000:00:03.0 XhciDriver: version 0x100, 64 slots, 8 ports, 32-byte contexts, 0 scratchpad buffers, events via message interrupt, 1 device(s)
[Drivers] pci:0000:00:03.0 offer XhciDriver -> bound
[Drivers] usb:1-1:0 UsbMassStorageDriver: usb0 (LUN 0): QEMU QEMU HARDDISK, 524288 blocks of 512 bytes
[StorageManager] usb0 registered by UsbMassStorageDriver (primary)
[Drivers] usb:1-1:0 UsbMassStorageDriver published block "usb0" (consumed)
[Drivers] usb:1-1:0 UsbMassStorageDriver: 1 unit(s), max LUN 0
[Drivers] usb:1-1:0 offer UsbMassStorageDriver -> bound
For interactive debugging on top of the serial log, see Debugging with VSCode and QEMU.