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 interrupt controller, PCI enumeration over ECAM, platform hardware (APIC/GIC, device drivers such as the NIC), and the AHCI/NVMe storage controllers.
- 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,StorageManager.
Every step in 3-5 is gated by a feature switch (CosmosEnableInterrupts, CosmosEnablePCI, CosmosEnableTimer, CosmosEnableKeyboard, CosmosEnableMouse, CosmosEnableNetwork, CosmosEnableStorage, CosmosEnableGraphics, CosmosEnableScheduler, all true by default). 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
public static class CosmosEntryPoint
{
public static void Main()
{
Cosmos.Kernel.System.Global.RegisterKernel(new MyOS.Kernel());
Cosmos.Kernel.System.Global.StartKernel();
}
}
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>
Global.StartKernel() then calls Start() on the registered instance.
Sys.Kernel.Start()
Cosmos.Kernel.System.Kernel is the abstract base class of every user kernel. Its Start() drives the whole lifecycle:
- Calls
OnBoot(), whose default implementation initializes the graphicalKernelConsole, which is what makesConsole.WriteLinework. - Enables hardware interrupts (everything before this point ran with interrupts off).
- 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 7 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 Global.CurrentKernel, which the generated entry point sets before your kernel starts:
Global.CurrentKernel?.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 interrupts are enabled and before the console exists, the right place for early hardware setup:
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.
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] - Initializing interrupts...
[KERNEL] - Initializing PCI...
[KERNEL] - Initializing AHCI...
[KERNEL] - Initializing scheduler...
[KMAIN] Phase 4: User kernel
[Global] Registering kernel
[Kernel] Calling OnBoot()...
[Kernel] Enabling interrupts...
[Kernel] Calling BeforeRun()...
[Kernel] Entering main loop...
[Kernel] Calling Run()...
For interactive debugging on top of the serial log, see Debugging with VSCode and QEMU.