Coding Guidelines
This document establishes the coding style and architecture patterns for Cosmos Gen3. These guidelines ensure a clean, modern, and consistent codebase across all contributors.
Table of Contents
- 1. Project Architecture
- 2. Naming Conventions
- 3. File Organization
- 4. Class & Type Design
- 5. Kernel Lifecycle
- 6. HAL Architecture
- 7. Plug System
- 8. Native Interop
- 9. Architecture-Specific Code
- 10. Memory & Safety
- 11. Error Handling
- 12. Nullable Reference Types
- 13. Modern C# / .NET 10 Idioms
- 14. AOT Constraints
- 15. Documentation
- 16. Testing
- 17. Public API Surface
1. Project Architecture
Layer Dependency Rules
The project is split into strict layers. Dependencies flow downward only. These rules are enforced at compile time by the LayerAnalyzer Roslyn analyzer in Cosmos.Build.Analyzer.Patcher, which judges a project on the types and members its code names, not on the reference list restore builds, and reports each assembly used across a boundary once, at its first use. A user kernel, and a driver assembly (<CosmosDriverAssembly>true</CosmosDriverAssembly>, see Public API Tracking), may also name what Cosmos.Kernel.HAL offers: the driver kit seam and the device contracts (IBlockDevice, MacAddress).
User Kernel (DevKernel, test kernels)
Cosmos.Kernel.Drivers (the shipped drivers, a driver assembly held to the User layer)
└── Cosmos.Kernel.System ← high-level OS APIs (Console, Graphics, Network)
└── Cosmos.Kernel.HAL ← hardware abstraction (shared logic, boot and device contracts)
├── Cosmos.Kernel.HAL.X64 ← x64 platform code (machine description, line routing, PIT, RTC)
├── Cosmos.Kernel.HAL.ARM64 ← ARM64 platform code (machine description, line routing, generic timer, RTC)
└── Cosmos.Kernel.Core ← low-level runtime (memory, scheduler, serial)
├── Cosmos.Kernel.Native.X64 ← x64 assembly (.s)
├── Cosmos.Kernel.Native.ARM64 ← ARM64 assembly (.s)
└── Cosmos.Kernel.Native.MultiArch ← cross-platform native C code (ACPI, libc stubs)
For the full dependency graph, project descriptions, and rules, see Kernel Project Layout.
When to Create a New Project
- New hardware the kit's buses reach (a PCI function, a virtio device, a USB interface, a PS/2 port, a platform node) → a
[Driver]class inCosmos.Kernel.Drivers, written over the public seam (Writing a Driver); the aggregator carries the package, so every kernel gets it. A new bus kind → an identity, a match, an access object and a path in the kit. Cross-platform HAL code goes toCosmos.Kernel.HAL, platform code toCosmos.Kernel.HAL.X64/Cosmos.Kernel.HAL.ARM64. - New OS-level feature, user API exposed → in
Cosmos.Kernel.System. - New low-level runtime concern → in
Cosmos.Kernel.Core.
2. Naming Conventions
Some core naming rules are enforced by .editorconfig; the table below documents the full naming guidelines:
| Element | Convention | Example |
|---|---|---|
| Namespace | Cosmos.Kernel.{Layer}.{Category} (the project, then its folders) |
Cosmos.Kernel.Core.Scheduler |
| Public class/struct | PascalCase | GarbageCollector, Thread |
| Interface | I + PascalCase |
IScheduler, IPlatformInitializer |
| Public method/property | PascalCase | InitializeStack(), StackPointer |
| Private/internal field | _camelCase |
_currentScheduler, _kernel |
| Private/internal static field | s_camelCase |
s_cursorPattern, s_nextThreadId |
| Constant | PascalCase | DefaultStackSize, MaxThreadCount |
| Private constant | PascalCase | COM1_BASE (hardware regs use UPPER_SNAKE for readability) |
| Local variable | camelCase | stackTop, entryPoint |
| Parameter | camelCase | cpuState, threadId |
| Type parameter | T + PascalCase |
TValue, TKey |
| Enum member | PascalCase | ThreadState.Running |
In Cosmos.Kernel.System.Diagnostics a suffix says what a type is: a *Diagnostics type is the static diagnostic view of one subsystem (DriverDiagnostics, SchedulerDiagnostics, MemoryDiagnostics), which mostly reads but may act (SchedulerDiagnostics.RequestKill, MemoryDiagnostics.Collect), and an *Info type is a readonly snapshot struct such a view hands out (DriverInfo, DeviceNodeInfo, KernelThreadInfo). See Public API Tracking.
Avoid
- Hungarian notation (
m_,p_,g_), use_prefix for private fields only. - Legacy
mStoppedstyle, use_stopped. - Abbreviations unless universally understood (
GC,CPU,HAL,IO,IP,MAC).
3. File Organization
One Type Per File
Each public type gets its own file, named after the type:
Cosmos.Kernel.Core/
Scheduler/
SchedulerThread.cs
SchedulerThreadState.cs
IScheduler.cs
SchedulerManager.cs
PerCpuState.cs
Two kinds of companion share the primary type's file, which keeps the primary
type's name: a type's own tightly-coupled companion, as Gpt has
GptPartitionEntry and TcpPacket has TcpFlags and TcpOption, and a
subclass family, as IcmpPacket has IcmpEchoRequest and IcmpEchoReply.
An implementation is not a companion of its interface: 24 files declare a
top-level interface and each one declares that interface and nothing else.
Partial Classes for Large Types
Split large classes across multiple files using partial. Name the files {ClassName}.{Aspect}.cs:
Memory/
GarbageCollector.cs ← core structure, fields, initialization
GarbageCollector.Alloc.cs ← allocation methods
GarbageCollector.Mark.cs ← mark phase
GarbageCollector.Sweep.cs ← sweep phase
GarbageCollector.GCHandler.cs ← GC handle table
GarbageCollector.Frozen.cs ← frozen segment support
Architecture-Specific Files
For types that need per-architecture implementations within the same project, use a suffix:
Scheduler/
ThreadContext.X64.cs ← x64-specific context layout
ThreadContext.ARM64.cs ← ARM64-specific context layout
These are conditionally compiled via .csproj:
<ItemGroup Condition="'$(CosmosArch)' != 'arm64'">
<Compile Remove="Scheduler\ThreadContext.ARM64.cs" />
</ItemGroup>
This is only allowed now in Cosmos.Kernel.Core but this may change in the future.
Namespaces Follow Folders
A file's namespace is its project's name followed by the folders between
the project and the file: Cosmos.Kernel.Core/Scheduler/SchedulerThread.cs
declares Cosmos.Kernel.Core.Scheduler. The build enforces it with IDE0130
at error for every src/Cosmos.Kernel* project, the test kernels, the
host-side unit tests in tests/Cosmos.Kernel.Tests.System and
examples/DevKernel, so dotnet build fails on a file that drifts (the
format CI checks the projects in nativeaot-patcher.slnx, which leaves out
the test kernels). Move the file or fix the namespace rather than adding an
exemption. The exemptions, each with its reason, close .editorconfig:
names ILC matches by full name (each LibraryInitializer,
EagerStaticClassConstructionAttribute, Core/Runtime/Stdllib.cs), the
source generators' IsExternalInit (the C# compiler's init-accessor
marker, which must live in System.Runtime.CompilerServices), the
Bridge/Import and Bridge/Export folders, whose files share the
project's Bridge namespace, the vendored code under
Cosmos.Kernel.System/ThirdParty/ and the dotnet/runtime mirrors under
Cosmos.Kernel.Core/Runtime/Internal/. IDE0130 does not check a namespace
holding a partial type split across files (every [LibraryImport] class)
or a nested namespace; those follow the rule unchecked.
Driver Folders
Cosmos.Kernel.Drivers keeps each driver three folders deep, bus kind, then
category, then driver, and each file's namespace follows its folder:
Cosmos.Kernel.Drivers/
Pci/ ← the bus kind the driver matches on (PciMatch)
Bus/ ← a bus driver: its binding publishes child nodes
Xhci/
XhciDriver.cs ← Cosmos.Kernel.Drivers.Pci.Bus.Xhci
Storage/ ← otherwise, what it publishes (a block device)
Nvme/
NvmeDriver.cs
Virtio/
Display/
VirtioGpu/
VirtioGpuDriver.cs ← Cosmos.Kernel.Drivers.Virtio.Display.VirtioGpu
The bus kind is the one the driver matches on, named after its match type:
Pci, Platform, Ps2, Usb or Virtio. The category is Bus for a bus
driver and otherwise names what the driver publishes: Audio, Display,
Input (a keyboard or pointer), Network or Storage (a block device). The driver
folder is the class name without Driver, unless a type inside the folder
already has that name, since a namespace must not share its name with one of
its types: the transport drivers sit in VirtioPci and VirtioMmio, beside
their VirtioPciTransport and VirtioMmioTransport classes. A driver's
state, protocol and vocabulary types stay in its folder, in subfolders when
there are many (VmwareSvga/Enums, VmwareSvga/Structs). The namespace is
part of the full type name that CosmosDriverExclude names and that the
manifest sorts by (Driver Manifest), so moving a
driver changes both.
HAL Folders
Cosmos.Kernel.HAL gives each concept one top-level folder, and no folder
name appears twice in the tree:
Cosmos.Kernel.HAL/
Experimentals.cs ← the seam's diagnostic ID (COSMOS0003)
Boot/ ← internal: IPlatformInitializer, PlatformHAL
Timers/ ← internal: TickSource, TimerEntry
Internal/ ← the LibraryInitializer ILC finds by full name
Devices/ ← what a device is, a folder per category
Audio/ ← IAudioOutput, AudioFormat, AudioSink, AudioConsumer
Display/ ← IDisplay, its facets, DisplayMode, DisplaySink, DisplayConsumer, FirmwareDisplay
Input/
Network/ ← INetworkInterface, NetworkSink, NetworkConsumer, MacAddress
Storage/ ← IBlockDevice, BlockConsumer
DriverKit/ ← how a driver finds and binds a device
Driver.cs ← the driver and its binding: matching, probing, the node tree
DeviceBinding.cs
Resources/ ← what a binding hands out: RegisterWindow, DeviceRegion, DmaBuffer
Interrupts/ ← InterruptSource, InterruptHandle, InterruptHandler, InterruptContext
Threading/ ← DeviceLock, DeviceEvent, DriverThread, WorkItem
Buses/
Pci/ ← PciIdentity, PciMatch, PciAccess, PciLineInterruptSource, …
Usb/
Engine/ ← internal: DeviceRegistry, DriverEngine, Arbitration, DriverLog
A device category under Devices/ holds everything about its kind: the
contract a driver implements, its facets and vocabulary, the sink the
driver reports through (the block kind has none) and the ring's internal
consumer, which learns of each device's arrival and departure and receives
its reports. The categories are the ones Cosmos.Kernel.Drivers files its
drivers under (Driver Folders), less Bus. Display/
also holds FirmwareDisplay, the display the engine publishes over the boot
framebuffer that Core's BootFirmware read. HAL has no firmware folder:
reading what the bootloader and the firmware hand over takes pointers, so it
is Core's (Unsafe Code).
The DriverKit/ root holds the driver and its binding: Driver and its
attribute, the registry, DeviceBinding, the node tree with its identity
and match bases, and the probe and detach vocabulary. What a binding hands
a driver sits one folder down, by concern: Resources/ for the ways to
reach the hardware (resources, register windows, mapped regions, DMA
buffers), Interrupts/ for the sources and the handler contract, and
Threading/ for the locks, events, threads and work items a driver
synchronizes with. A driver names the root and then the folders it uses.
A bus kind under DriverKit/Buses/ gives each role the same name in every
folder: <Bus>Identity, <Bus>Match, <Bus>Access when a driver reaches
the device through the bus (the platform bus has none: a platform driver
maps its node's resources, and only the PCI host node carries an access
object, the PCI folder's PciHostAccess), <Bus>InterruptSource when the
bus delivers its own interrupts (on PCI and the platform bus the name also
says how: PciLineInterruptSource, PciMessageInterruptSource,
PlatformLineInterruptSource), and the bus's own vocabulary beside them.
The base class a bus's host implements for the kit keeps the name its
specification uses (Ps2Controller, UsbHostController,
VirtioTransport) rather than a suffix shared across buses; the platform
bus's is PlatformLineRouting, which the machine description implements.
Stability is marked on each type, not by folder. Every public HAL type
carries [Experimental(Experimentals.DriverKitSeamDiagId)] except the two
stable contracts, IBlockDevice and MacAddress, and the
CosmosGuardHalPromotion target in the HAL project fails the build when any
other public type loses the attribute
(Public API Tracking).
Member Order
There is no separator convention to follow. Four garbage-collector files carry
// --- Section Name --- headers, written three weeks before this page was,
and the display driver files in Cosmos.Kernel.Drivers were later written to
match them. The other 353 non-vendored files in the four tracked assemblies do
not, no file has ever been converted, and no analyzer checks it. Neither is
there a member order to describe: across 131 types with three or more kinds of
member there are 93 distinct orders, and the best-fitting single order covers
43 of them.
Two orderings are still worth following, because a reader checks them inside one screen:
- Fields and constants come before the members that read them, not after the first method.
- Constructors come after fields and properties and before methods, and a static factory sits with the constructors it stands in for.
If you want a model for the full separator form in a new file,
Cosmos.Kernel.Drivers/Virtio/Display/VirtioGpu/VirtioGpuDriver.cs is the
file that demonstrates it. Do not convert an existing file to it.
Using Directives
- Place
usingdirectives outside the namespace. - Sort
Systemnamespaces first. - Use file-scoped namespaces (eg.
namespace Cosmos.Kernel.Core.Scheduler;). The vendored trees underThirdParty/(BigGustave, SharpZipLib, LunarFonts) and the dotnet/runtime mirrors keep the block form so they stay diffable against upstream, andCore/Runtime/Stdllib.cscannot take the file-scoped form at all: it declares four namespaces, one of them nested, which is CS8955.
4. Class & Type Design
Static Utility Classes
Use static class for stateless kernel utilities that have no per-instance state.
Managers
Use a static class. Managers coordinate subsystem state without requiring an instance. Boot-path setup is an internal static void Initialize() called from LibraryInitializer, never from a kernel:
// Simple manager: no underlying instance to expose
public static class TimerManager
{
private static TickSource? s_tickSource;
public static bool IsInitialized => s_tickSource is not null;
internal static void RegisterTickSource(TickSource tickSource) { ... }
public static void Wait(uint ms) { ... }
}
// Manager wrapping a pluggable implementation: expose via Current
public static class SchedulerManager
{
private static IScheduler? s_currentScheduler;
public static IScheduler? Current => s_currentScheduler;
internal static void Initialize(uint cpuCount) { ... }
}
Only add a Current property when the manager wraps a pluggable implementation (eg. IScheduler for multiple scheduling algorithms). Most managers don't need one.
Do not carry a separate _initialized flag next to the state it stands for. Derive IsInitialized from that state, and have Initialize guard re-entry on the same field, assigning it last so no reader sees IsInitialized go true before the state behind it exists.
When a manager sits behind a feature switch, its members follow the rule in Public API Tracking: reads answer honestly with the feature off, actions throw and name the switch.
Structs for Low-Level Data
Use struct with [StructLayout(LayoutKind.Sequential)] for data that maps to hardware or memory layouts:
[StructLayout(LayoutKind.Sequential)]
internal struct FreeBlock
{
public unsafe MethodTable* MethodTable;
public nuint Size;
public unsafe FreeBlock* Next;
}
Enums
Use enum for state machines and flags. Apply [Flags] where appropriate:
public enum ThreadState
{
Ready,
Running,
Blocked,
Sleeping,
Dead
}
[Flags]
public enum ThreadFlags
{
None = 0,
IsIdle = 1 << 0,
IsKernel = 1 << 1
}
Types Compared by Value
A type whose identity is its content (Address, MacAddress, Mode) is sealed, keeps its state in get-only members set by the constructor, and implements IEquatable<T> beside Equals(object?) and GetHashCode(). The three agree: two instances that are Equals hash alike, and GetHashCode never throws. Ordering, where it exists, is IComparable<T>, never the non-generic IComparable. A caller who needs the bytes gets a ReadOnlySpan<byte>; the backing array is never handed out, because a caller who can write it changes the identity behind every dictionary keyed on the value.
public sealed class Address : IComparable<Address>, IEquatable<Address>
{
public ImmutableArray<byte> Parts { get; }
public Address(ReadOnlySpan<byte> buffer) { ... }
public ReadOnlySpan<byte> ToSpan()
{
return Parts.AsSpan();
}
public bool Equals(Address? other)
{
return other is not null && Parts.SequenceEqual(other.Parts);
}
public override bool Equals(object? obj)
{
return Equals(obj as Address);
}
public override int GetHashCode()
{
return HashCode.Combine(Id);
}
public int CompareTo(Address? other)
{
return other is null ? 1 : Id.CompareTo(other.Id);
}
}
Registered Instances
When the subsystem must see every live instance (TcpConnection.Connections), the constructor is private and a static factory creates and registers the instance (TcpConnection.CreateConnection). The registry stays private, and removal disposes the instance so what it rented goes back to its pool.
5. Kernel Lifecycle
User Kernel Pattern
User kernels inherit from Cosmos.Kernel.System.Kernel and override lifecycle methods:
using Cosmos.Kernel.System;
namespace MyKernel;
public class Kernel : Cosmos.Kernel.System.Kernel
{
protected override void BeforeRun()
{
// One-time setup (after boot, before main loop)
Console.WriteLine("Booted!");
}
protected override void Run()
{
// Called in a loop until Stop() is called
string? input = Console.ReadLine();
ProcessCommand(input);
}
protected override void AfterRun()
{
// Cleanup (after main loop exits)
}
}
Rules:
- Override
OnBoot()only to customize boot (the default brings upKernelConsole). - Override
BeforeRun()for one-time setup after the system is ready. Run()is the main loop body, keep it focused.- Call
Stop()to exit the main loop cleanly. - Return from the kernel should be avoided, the base class halts the CPU after
AfterRun().
6. HAL Architecture
Interface-Driven HAL
Platform services go through interfaces. Implementations are registered at boot:
// Interface (Cosmos.Kernel.Core, internal: no kernel registers or obtains one)
internal interface ICpuOps
{
void Halt();
void DisableInterrupts();
void EnableInterrupts();
}
// Implementation (Cosmos.Kernel.HAL.X64)
public class X64CpuOps : ICpuOps
{
public void Halt()
{
Native.Cpu.Halt();
}
// ...
}
Platform Initializer Pattern
Each architecture provides a factory that creates all platform-specific components. The factory, the contract and everything it returns are internal to the HAL: a kernel never installs one. The initializer is also the machine description: it publishes the root platform nodes a bus driver binds, from the sources the platform knows: ACPI's tables, the device tree the bootloader handed over, or a per-machine table; and it holds no device driver.
internal class X64PlatformInitializer : IPlatformInitializer
{
public string PlatformName => "x86-64";
public PlatformArchitecture Architecture => PlatformArchitecture.X64;
public IPortIO CreatePortIO()
{
return new X64PortIO();
}
public ICpuOps CreateCpuOps()
{
return new X64CpuOps();
}
public IInterruptController CreateInterruptController()
{
return new X64InterruptController();
}
public TickSource CreateTickSource()
{
return new PIT();
}
public void PublishPlatformNodes()
{
// the 8042 and the PCI host, with their port ranges and lines
}
public uint GetCpuCount()
{
return /* ACPI/MADT */ 1;
}
public void InitializeHardware()
{
// ACPI, APIC, timers
}
public void StartSchedulerTimer(uint quantumMs)
{
// Configure PIT/APIC timer for preemptive scheduling
}
}
Adding a New Device
Write a [Driver] class in Cosmos.Kernel.Drivers over the kit and publish the device through its binding (Writing a Driver). When the device sits on a bus the kit does not know:
- Add the bus kind to the kit, in its own folder under
DriverKit/Buses/: an identity, a match, an access object when a driver reaches the device through the bus, an interrupt source when the bus delivers its own interrupts, and a path format (HAL Folders). - Publish its nodes from the machine description (
PublishPlatformNodes) or from a bus driver (PublishChild). - Write the leaf driver over the access object.
Nothing goes into Cosmos.Kernel.HAL.X64 or Cosmos.Kernel.HAL.ARM64 but platform code. The device tree parser is Core code under Cosmos.Kernel.Core/Firmware, beside the other readers of what the firmware hands over; the ARM64 description only reads it.
HAL Registration
// At boot (from the library initializer, or from OnBoot):
PlatformHAL.Initialize(new X64PlatformInitializer());
7. Plug System
Plugs replace BCL methods at the IL level. The patcher rewires calls at build time. For full documentation on plug attributes ([Plug], [PlugMember], [PlatformSpecific]) and the plug template, see Plugs.
When to Use Plugs vs. Other Approaches
| Scenario | Approach |
|---|---|
| Replace a BCL method | Plug |
| Add kernel-level API | New class in Cosmos.Kernel.System |
| Provide runtime stub | [RuntimeExport] in Core |
| Call native code | [LibraryImport] in Core |
8. Native Interop
C# callable from native
There are two patterns depending on the caller:
[RuntimeExport]: for NativeAOT runtime stubs (called by the runtime itself). These match the [RuntimeImport] declarations in System.Private.CoreLib/RuntimeImports.cs:
[RuntimeExport("RhNewArray")]
internal static unsafe void* RhNewArray(MethodTable* pEEType, int length)
{
return GarbageCollector.AllocArray(pEEType, length);
}
[UnmanagedCallersOnly]: for C# methods callable from native C code (bridges):
[UnmanagedCallersOnly(EntryPoint = "__cosmos_serial_write")]
public static void CosmosSerialWrite(byte* str)
{
// C code calls this via the symbol __cosmos_serial_write
}
Use [RuntimeExport] for runtime/ABI-required exports (for example Rh* stubs and libc/math or memory symbols such as ceil, sqrt, memmove, memset). Prefer [UnmanagedCallersOnly] for other native-to-managed callbacks (interrupt handlers, C library bridges, etc.).
Native callable from C#
Modern P/Invoke pattern for calling assembly routines:
[LibraryImport("*", EntryPoint = "_native_cpu_rdtsc")]
[SuppressGCTransition]
private static partial ulong NativeReadTSC();
Rules:
- Use
LibraryImport(notDllImport), it's source-generated and AOT-compatible. - Use
"*"as the library name (links to the kernel binary itself). - Add
[SuppressGCTransition]for hot-path calls that don't trigger GC. - Keep native interop methods
privateand wrap them in a clean public API. - Assembly entry point names use
_snake_casewith a category prefix:_native_cpu_rdtsc,_native_io_write_byte.
9. Architecture-Specific Code
Conditional Compilation
Use #if ARCH_X64 / #if ARCH_ARM64 for code that differs by architecture. For now this is only tolerated in Cosmos.Kernel.Core but this aims to disappear.
public static void ComWrite(byte value)
{
#if ARCH_ARM64
while ((Native.MMIO.Read32(PL011_BASE + PL011_FR) & FR_TXFF) != 0) ;
Native.MMIO.Write8(PL011_BASE + PL011_DR, value);
#else
while ((Native.IO.Read8(COM1_BASE + REG_LSR) & LSR_TX_EMPTY) == 0) ;
Native.IO.Write8(COM1_BASE + REG_DATA, value);
#endif
}
When to Use What
| Pattern | When |
|---|---|
#if ARCH_X64 |
Same class, small code differences (eg. serial driver) |
Separate files (*.X64.cs) |
Same class, large per-arch blocks (eg. ThreadContext) |
| Separate HAL projects | Different implementations behind a shared interface |
| Separate Native projects | Assembly code (.s) |
Guard Rules
- The default
#elsepath should be x64 (most common development target). - Always have both paths, no dangling
#ifwithout the other arch. - Consider whether the code belongs in a HAL implementation instead of
#if.
10. Memory & Safety
Unsafe Code
Unsafe code lives in Cosmos.Kernel.Core and its architecture projects
(Cosmos.Kernel.Core.X64, Cosmos.Kernel.Core.ARM64), and in the pieces
outside the layers (Cosmos.Kernel.Boot.Limine, Cosmos.Kernel.Plugs,
Cosmos.Kernel.Debug and the Cosmos.Kernel aggregator).
Cosmos.Kernel.HAL, its platform projects, Cosmos.Kernel.System and
Cosmos.Kernel.Drivers compile with
<AllowUnsafeBlocks>false</AllowUnsafeBlocks>, so the compiler refuses a
pointer there. What those layers need from memory, Core hands out by
address:
MemoryBlock(Core/Memory/, the gen2 type) is a view over memory at an address:SpanandAsSpan<T>read and write it.PageAllocator.AllocBlockhands out pages as a block andPageAllocator.Free(MemoryBlock)takes them back. A kit resource (DeviceRegion,DmaBuffer) holds a block and refuses its span once its binding is torn down.AddressSpace.HhdmOffsetis Limine's higher-half offset, read in one place.Core/Firmware/reads what the bootloader and the firmware hand over: the framebuffer, the device tree, the boot time, the ACPI MCFG entry and the EFI clock (BootFirmware,DeviceTree,AcpiMcfg,EfiRtc).
A structure the device reads in place, such as a virtqueue ring, is reached
through MemoryMarshal.Cast and MemoryMarshal.AsRef over such a span, with
Volatile.Read for a field the device writes. When a layer above Core needs
what only a pointer gives, the fix is a Core entry point, not an unsafe
block. Inside Core, keep unsafe to where it is needed:
// Good: unsafe scoped to where needed
public static unsafe void WriteString(string str)
{
fixed (char* ptr = str)
{
for (int i = 0; i < str.Length; i++)
{
ComWrite((byte)ptr[i]);
}
}
}
// Good: unsafe class for types that inherently work with pointers
public unsafe class Thread : SchedulerExtensible
{
public ThreadContext* GetContext()
{
return (ThreadContext*)StackPointer;
}
}
Memory Allocation
// Kernel heap allocation (manual, non-GC)
void* ptr = MemoryOp.Alloc(size);
MemoryOp.Free(ptr);
// GC-managed allocation (via RuntimeExport stubs)
// Happens automatically through normal C# object creation
List<int> list = new(); // uses RhAllocateNewArray under the hood
Pointer Safety
- Always check pointers before dereferencing in GC/scanning code.
- Use
nuintfor addresses (notuint, which avoids 64-bit truncation). - Use
nint/nuintfor pointer arithmetic, notint/uint. - Use
stackallocfor small, short-lived buffers instead of heap allocation.
Buffers
| Buffer | Use |
|---|---|
| Small, fixed size, scoped to one call | stackalloc |
| Variable size, held across calls (a receive buffer) | ArrayPool<T>.Shared.Rent, returned from Dispose() |
| Read-only input | A ReadOnlySpan<T> parameter, not byte[] |
| Copying | source.CopyTo(destination) on spans, not Buffer.BlockCopy |
A rented array is at least as long as requested, not exactly as long, so the owner tracks the length and offset it uses and exposes the live part as a ReadOnlySpan<T>, never the array. TcpConnection is the model: _data, _dataOffset and _dataLength behind Data => _data.AsSpan().Slice(_dataOffset, _dataLength), with AppendToData writing in place while the rented array has room and Dispose returning it.
Critical Sections
// Preferred: scoped interrupt disable
using (InternalCpu.DisableInterruptsScope())
{
// Interrupts disabled here
// Re-enabled automatically at scope exit
}
11. Error Handling
Kernel Panic
For unrecoverable errors, use Panic.Halt(). It disables interrupts, prints
the message and the calling method, file and line, then halts the CPU:
if (ptr == null)
Panic.Halt("Memory allocation failed");
The caller information is filled in by the compiler, so pass the message and
nothing else. Panic.Halt is [DoesNotReturn], which is what lets the code
after a null check dereference the value it just rejected.
Exceptions
Exceptions work, but use them judiciously:
// Good: validate at API boundaries
public static void RescanPartitions(IBlockDevice device)
{
ArgumentNullException.ThrowIfNull(device);
// ...
}
// Good: feature guards
private static void ThrowIfKeyboardDisabled()
{
if (!CosmosFeatures.KeyboardEnabled)
throw new InvalidOperationException("Console input requires keyboard feature.");
}
// Bad: exceptions in interrupt handlers, GC, or scheduler hot paths
// These paths cannot allocate (exception objects are heap-allocated)
Argument Checks
When the check is one comparison, the throw helper replaces the hand-written if and throw. The parameter named is the one the value derives from, so a caller reading the exception finds the argument to fix:
ArgumentNullException.ThrowIfNull(device);
ArgumentOutOfRangeException.ThrowIfNegative(width);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, MaxPartitions);
ArgumentOutOfRangeException.ThrowIfNotEqual(buffer.Length, 4, nameof(buffer));
ArgumentOutOfRangeException.ThrowIfGreaterThan(blockCount, (ulong)data.Length / sector, nameof(blockCount));
ObjectDisposedException.ThrowIf(_disposed, this);
A range is two helpers, one per bound (ThrowIfLessThan then ThrowIfGreaterThan), not one hand-written ||. A condition no helper expresses (sectorCount > host.BlockCount - startSector) keeps the explicit throw, and so does a check that must log before it throws. The parameter name is always nameof(x), never a string literal, and never the message: new ArgumentOutOfRangeException("Invalid offset or size") names a parameter called Invalid offset or size.
When to Panic vs. Throw
| Situation | Action |
|---|---|
| Hardware failure, corrupted state | Panic.Halt() |
| GC/allocator internal error | Panic.Halt() |
| Invalid API usage | throw appropriate exception |
| Missing feature at runtime, in a member that acts on it | throw InvalidOperationException naming the switch |
| Missing feature at runtime, in a member that answers a question | Return 0/null/false/empty |
| User-facing error in kernel shell | try/catch + print error message |
The type thrown is one a caller can catch by name: InvalidOperationException, ArgumentException and its subclasses, NotSupportedException. A bare throw new Exception(...) is not written (CA2201). Which channel a ring member uses to report failure, and when a Try returns false instead, is in Public API Tracking.
12. Nullable Reference Types
Nullable reference types are enabled solution-wide (<Nullable>enable</Nullable> in Directory.Build.props). Annotations are part of the API contract: a reference type without ? is a promise that it is never null. New and modified code must not introduce nullable warnings: annotate honestly instead of silencing.
Rules
| Pattern | Use for |
|---|---|
T? on fields, parameters, returns |
Any value that can legitimately be null |
?? throw |
Converting a nullable into a required value |
ArgumentNullException.ThrowIfNull(arg) |
Argument validation at API boundaries |
[MemberNotNull(...)] guard helpers |
Initialization checks the compiler can follow |
is null / is not null |
All null tests (not == null / != null). A pointer keeps == null: patterns are not allowed on pointer types |
Sentinel initialization (= []) |
Fields where null and empty mean the same thing |
[NotNullWhen(true)] on a Try method's out |
The value a true return populates; see How a member reports failure |
[MemberNotNullWhen(true, ...)] on a bool Initialize() |
A true return that proves a static member for the caller (KernelConsole.Initialize and Default) |
T?[] |
An array whose slots can be empty (IrqDelegate?[]) |
Honest Annotations
A member that can return null is declared with ?: never hide it behind ! to preserve an old signature. Conversely, do not add runtime null checks for values the annotations already guarantee: no if (buffer == null) throw on a non-nullable parameter, no ?? "fallback" on a non-nullable return.
// Declare what can actually be null: callers are forced to handle it
public static IScheduler? Current => _currentScheduler;
public static PerCpuState? GetCpuState(uint cpuId)
{
return _cpuStates?[cpuId];
}
// Prefer a sentinel over a nullable field when null and empty mean the same thing
private byte[] _window = []; // not: private byte[]? _window;
Guard Helpers
Managers with deferred initialization expose ThrowIf*NotInitialized() guards annotated with [MemberNotNull(...)] and call them at the top of public entry points (see Managers). One call proves the field non-null for the rest of the method, with no ! and no per-line checks:
private static PerCpuState[]? s_cpuStates;
[MemberNotNull(nameof(s_cpuStates))]
private static void ThrowIfCpuStateNotInitialized()
{
if (s_cpuStates is null)
{
throw new InvalidOperationException($"{nameof(SchedulerManager)} not initialized");
}
}
public static void CreateThread(uint cpuId, Thread thread)
{
ThrowIfCpuStateNotInitialized();
PerCpuState state = s_cpuStates[cpuId]; // no warning, no !
// ...
}
The same attribute family covers a bool initializer: [MemberNotNullWhen(true, nameof(Default))] on KernelConsole.Initialize() lets the caller read KernelConsole.Default inside if (KernelConsole.Initialize()) with no guard of its own.
Callers of a Try
After an annotated Try returns true, its out is non-null and no call site re-tests it: if (!TryOpen(path, out handle) || handle == null) is written if (!TryOpen(path, out handle)). Dictionary<TKey, TValue>.TryGetValue is annotated by the BCL the same way, so && value != null after it guards nothing when TValue is non-nullable. A redundant test is not harmless: it hides the one place where a guard is real.
Required Values
Use ?? throw where a null result is a programming error:
Address source = IPConfig.FindNetwork(destination)
?? throw new InvalidOperationException("No network route to destination");
public MacAddress MacAddress
{
get
{
return _macAddress ?? throw new InvalidOperationException($"{nameof(_macAddress)} is null");
}
}
Constructors
Every constructor must leave all non-nullable members initialized. Delete unused parameterless constructors instead of letting them poison the type:
// Bad: leaves RawData null and forces nullable noise on every user of the class
internal IcmpPacket()
{
}
The ! Operator
Null-forgiving ! is a last resort for invariants flow analysis cannot see (eg. a field guaranteed non-null while a guard flag is true). If the invariant can be expressed with [MemberNotNull] or [NotNullWhen], use the attribute instead.
Enforcement
The format CI (dotnet format --severity error) enforces the null-handling style rules in .editorconfig: csharp_style_throw_expression, dotnet_style_coalesce_expression, dotnet_style_null_propagation, csharp_style_prefer_null_check_over_type_check, csharp_style_pattern_matching_over_as_with_null_check, dotnet_style_prefer_is_null_check_over_reference_equality_method, and csharp_style_conditional_delegate_call.
Nullable warnings (CS86xx) are not errors yet: do not introduce new ones.
13. Modern C# / .NET 10 Idioms
The project targets <LangVersion>latest</LangVersion> and .NET 10. Use modern language features throughout.
Modern Patterns
// File-scoped namespaces
namespace Cosmos.Kernel.Core.Scheduler;
// Expression bodies for a property, indexer or accessor that fits on one line
public string PlatformName => "x86-64";
// Pattern matching
switch (args[i])
{
case null:
WriteString("null");
break;
case string s:
WriteString(s);
break;
case int n:
WriteNumber(n);
break;
}
// Null-conditional and coalescing
IBlockDevice? device = StorageManager.PrimaryDevice;
if (device?.BlockSize is not > 0)
{
return;
}
Address ip = config?.Address ?? defaultAddress;
// Collection expressions
DeviceResource[] resources = [DeviceResource.PortRange(0x60, 1), DeviceResource.PortRange(0x64, 1)];
// Target-typed new
Thread thread = new();
List<int> items = new();
// Numeric separators for readability
public const nuint DefaultStackSize = 64 * 1024;
public static long TscFrequency { get; set; } = 1_000_000_000;
// stackalloc for small buffers (no heap allocation)
Span<byte> buffer = stackalloc byte[64];
// field keyword: auto-property with validation without a manual backing field
public int Priority
{
get => field;
set => field = value >= 0 ? value : throw new ArgumentOutOfRangeException();
}
// Interpolation, not concatenation
Serial.WriteString($"[{Table[(int)Status]}] {packet}\n");
// required members for state the constructor cannot take; derive what follows from it
public sealed class DhcpOption
{
public required byte Type { get; init; }
public required byte[] Data { get; init; }
public byte Length => (byte)Data.Length;
}
// One dictionary call carries the answer
if (!s_clients.TryGetValue(port, out UdpClient? client))
{
return;
}
s_registeredTypes.TryAdd(name, fileSystemType);
cache[id] = mac; // upsert: no ContainsKey first
// readonly on every field assigned only at its declaration or in a constructor
private readonly Canvas _canvas;
private static readonly ArrayPool<byte> s_arrayPool = ArrayPool<byte>.Shared;
// Auto-property over a field behind a pass-through accessor
public static KernelConsole? Default { get; private set; }
Three limits on the last two. readonly on a field of a mutable struct type (SpinLock) is wrong: every method call would act on a defensive copy, and the lock would never be taken. The analyzer behind dotnet_style_readonly_field does not know which struct methods mutate, so its suggestion is taken for reference types and for structs with no mutating members only. A field a plug reaches by name ([FieldAccess]) stays a field: an auto-property's backing field has a compiler-generated name. And a static initializer that allocates is a class constructor, which runs on first touch through a lock that needs a current thread: a type reachable from device bring-up (MacAddress, whose None and Broadcast any driver may read) keeps its lazily filled statics, with a comment saying why.
Avoid
// Don't use var when type isn't obvious
var x = GetValue(); // Bad: what type is x?
int count = GetValue(); // Good: explicit type
// Don't use var for built-in types
var i = 0; // Bad
int i = 0; // Good
// Don't use this. qualification
this._field = value; // Bad
_field = value; // Good
// Don't allocate arrays in hot paths when Span works
byte[] temp = new byte[16]; // Bad in hot path: heap allocation
Span<byte> temp = stackalloc byte[16]; // Good: stack allocation
// Always use braces, no single-line if/for/while without braces
if (ptr == null) return; // Bad
if (ptr == null) // Bad
return;
if (ptr == null) // Good
{
return;
}
// Don't give a method, constructor, operator or local function an expression body
public void Halt() => Native.Cpu.Halt(); // Bad
public void Halt() // Good
{
Native.Cpu.Halt();
}
// Don't wrap an expression body: past one line, a property takes a get block
public bool IsFixedRoot => // Bad
Parent is null && Superblock.Boot.Type != FatType.Fat32;
public bool IsFixedRoot // Good
{
get
{
return Parent is null && Superblock.Boot.Type != FatType.Fat32;
}
}
// Don't compare a bool to a literal
if (applied == false) // Bad
if (!applied) // Good
// Don't test a key, then index; the lookup answers once
if (map.ContainsKey(key)) // Bad: two lookups
{
device = map[key];
}
Braces are enforced by
.editorconfig(csharp_prefer_braces = true:error) and by CI (dotnet format style --severity error).
Methods, constructors, operators and local functions take a block body, even for a single statement: a wrapped => hides where the body starts. A property, indexer or accessor uses => only when the whole member fits on one line, and a lambda keeps its =>. The vendored trees under ThirdParty/ follow this rule too, and each one's README lists the members it changed. .editorconfig records this in the csharp_style_expression_bodied_* options at silent, so CI does not enforce it.
Feature Switches
Use [FeatureSwitchDefinition] for compile-time feature toggling (trimmed by NativeAOT linker). All feature flags live in Cosmos.Kernel.Core.CosmosFeatures:
// In CosmosFeatures.cs: one property per feature
[FeatureSwitchDefinition("Cosmos.Kernel.HAL.Interrupts.Enabled")]
public static bool InterruptsEnabled
{
get
{
return AppContext.TryGetSwitch("Cosmos.Kernel.HAL.Interrupts.Enabled", out bool enabled) ? enabled : true;
}
}
// The ILC linker trims the dead branch entirely
if (CosmosFeatures.KeyboardEnabled)
{
// This code is removed from the binary when keyboard is disabled
}
14. AOT Constraints
NativeAOT imposes strict limitations. All kernel code must be AOT-compatible.
.NET 10 has improved NativeAOT with smaller binaries and broader platform support, but the fundamental constraints remain.
Forbidden
System.Reflection.Emit(no runtime code generation).dynamickeyword.Assembly.Loadat runtime.Type.GetType("...")by string at runtime (types must be statically reachable).MakeGenericType/MakeGenericMethodat runtime (generic instantiations must be known at compile time).
Use with Caution
- Generic virtual methods, NativeAOT must generate all possible instantiations at compile time. Avoid deeply nested or open-ended generic virtual dispatch.
- LINQ, many LINQ operators allocate iterators and closures. Avoid in hot paths (GC, scheduler, interrupt handlers).
15. Documentation
License Header
Every .cs file must start with the license header (enforced by .editorconfig):
// This code is licensed under the BSD 3-Clause license (see LICENSE for details)
XML Documentation
Document all public APIs with <summary>. Keep it concise:
/// <summary>
/// Triggers a kernel panic with the specified message.
/// Disables interrupts and halts the CPU.
/// </summary>
/// <param name="message">The panic message describing the error.</param>
public static void Halt(string message) { ... }
Rules:
- Document public classes, interfaces, methods, properties.
- Skip documentation on obvious members (
get/setfor self-documenting property names). - Use
<param>for non-obvious parameters. - Use
<returns>when return value needs explanation. - Use
<see cref="..."/>to reference related types. - Don't document private implementation details unless the logic is complex.
Inline Comments
Use sparingly, only when the code isn't self-explanatory:
// Stack layout (growing downward from top):
// [StackBase + stackSize] = Top of usable stack
// [contextAddr] = Start of ThreadContext (where StackPointer points)
// [StackBase] = Bottom of stack
// Align context to 16 bytes (required for XMM operations)
contextAddr = (contextAddr + 0xF) & ~(nuint)0xF;
Commented-out code is deleted; the history keeps it. A TODO names what is missing and why it can wait; a doubt about the code (// is this correct?) is an issue, not a comment. A member with an empty body is not a placeholder: it is not written until it does something.
16. Testing
For the full testing guide (unit tests, kernel integration tests, UART protocol, CI, writing test kernels), see Testing.
Logic that needs no hardware (TcpConnection receive-buffer arithmetic, address parsing) is unit-tested in the host process from tests/Cosmos.Kernel.Tests.System (NUnit): one nested fixture per member under test, named after it, test names of the form WhenX_AndY_ResultZ, and [TestCase(..., ExpectedResult = ...)] for value tables. The project holds an InternalsVisibleTo grant from Cosmos.Kernel.System.
Code coverage: Add the run-coverage label to a PR to trigger the coverage CI. It runs the kernel test suites and outputs which code paths are covered by the integration tests.
17. Public API Surface
Three rules decide what is public (the full policy and its mechanisms live in Public API Tracking):
- One supported ring.
Cosmos.Kernel.Systemis the API kernels program against, plus the contract types its signatures expose (the HAL's device contractsIBlockDeviceandMacAddress, Core's platform interfaces). Only that surface is tracked, documented, and covered by deprecation cycles. - Chosen experimental seams. An extension point outside the ring is opened deliberately and marked
[Experimental("COSMOSxxxx")]: usable now, no compatibility promise, promoted by removing the attribute. Never open a seam by just making something public. - Everything else is
internal. Visibility is not the extension mechanism. First-party assemblies and white-box test kernels useInternalsVisibleTo; external code uses[UnsafeAccessor](Accessing internals) at its own risk.
Practical rules that follow:
// Good: new user-facing capability lands as a Cosmos.Kernel.System facade
public static class MemoryDiagnostics { public static ulong FreePages => PageAllocator.FreePageCount; }
// Bad: making the Core type public so a kernel can reach it
public static class PageAllocator { ... }
- New types default to
internal. Making a symbolpublicin a tracked project is a reviewed decision: the build fails (RS0016) untilmake apirecords it inPublicAPI.Unshipped.txt, and the txt diff belongs in the same commit. - The enforcement test is DevKernel.
examples/DevKernelcompiles with noInternalsVisibleTogrant; anything it needs must come from the supported ring. - Tracked surface must be documented. Enabling
CosmosTrackPublicApiturns missing XML docs (CS1591) into build errors for the project's public symbols. - A white-box test kernel gets an
InternalsVisibleTogrant, with a comment in the granting.csprojsaying what it observes; it never forces a symbol public.