Skip to content

Configuring & Minifying the Kernel

Aegis is a single source tree that spans an enormous range of builds — from a stripped, MCU-class kernel up to a full-featured x86-64 / arm64 system — selected entirely by a .config, the same way menuconfig tailors a Linux kernel. The goal is deliberately ambitious: smaller than a Linux tinyconfig at one end, and reaching for the full breadth of a general-purpose kernel at the other — from one tree, one build system.

Where the configuration engine lives

The engine is its own repository: github.com/LoricaOS/kconfig. It is a small, dependency-free C program — no Python, no flex/bison, no ncurses — so it builds with a plain C compiler and can eventually run on LoricaOS itself. The kernel-specific Kconfig files, the defconfigs, and the Makefile hooks live in the Aegis tree; the tool just consumes them.

How it works

kconf reads a tree of Kconfig files (a pragmatic, bool-only subset of the Linux Kconfig language — LoricaOS has no loadable modules, so there is no tristate), resolves your selection to a .config, and generates the two artifacts the build consumes:

Artifact For Example
build/generated/autoconf.h C source #ifdef guards #define CONFIG_NET 1
build/auto.conf Makefile object gating CONFIG_NET=y

The Makefile -includes auto.conf to gate which source files compile, and -includes autoconf.h into every translation unit so #ifdef CONFIG_* works in the source. Dependency resolution matches Linux semantics where they overlap: depends on gates whether a symbol can be set, select forces its target on, and values settle to a fixpoint.

Tiers

Several defconfigs ship in configs/, and you can write your own:

Tier For Includes
nano The minification floor — leanest bootable kernel Security core + boot + mm + sched + a serial console. No storage, USB, framebuffer, or anything else optional.
microvm A Firecracker / cloud-hypervisor / QEMU-microvm guest PVH direct boot (no bootloader), no PCI bus, no ACPI, virtio-mmio transport, virtio-blk + virtio-net, ext2 root, networking, serial. The smallest useful Aegis — see MicroVMs below.
tiny Minimal but with the usual PC devices The core plus block storage, USB, framebuffer — just no net/VM/audio/debug.
workstation A text-only, networked system with minimal bloat The above plus the full network stack, persistent ext2, procfs, pipes — but no audio, no debug trace, only the drivers a headless text workstation needs.
full Everything on Every subsystem and driver. Identical to the historical bare-make build.

The workstation tier is the feature contract for an eventual PicoCalc spin (see below): everything you want in a pocket text terminal, and nothing you don't. The microvm tier is the cloud counterpart — a kernel that boots straight into a microVM the way Firecracker expects.

Using it

make tiny_defconfig          # smallest bootable kernel
make microvm_defconfig       # Firecracker/microvm guest (PVH, no PCI/ACPI, virtio-mmio)
make workstation_defconfig   # text-only, networked
make full_defconfig          # everything on (the default)
make allnoconfig             # every optional prompt off
make oldconfig               # keep your .config, fill in new symbols

make                         # builds the selected .config
                             # (a bare make with no .config seeds full_defconfig,
                             #  so the historical everything-on build is unchanged)

make test-microvm            # build the microvm tier + a test rootfs and boot it
                             # in QEMU -machine microvm to a ring-3 userspace all-pass

The one thing you can never turn off

The security core has no config symbol

The capability subsystem (kernel/cap), the cap_check at the syscall boundary, and uaccess are the product. There is deliberately no CONFIG_ knob for them — they stay in the unconditional base build, and a link-time check keeps them present. A minified Aegis is still, in full, a capability kernel.

This is the one place LoricaOS departs hard from Linux's tinyconfig philosophy: here, "tiny" can never mean "drop the security model." Minification only ever exposes knobs for genuinely optional code.

CONFIG_MMU — the embedded axis

The paging-based memory model (VMM, page tables, per-process address spaces, demand paging, copy_to/from_user with its page-fault exception table) sits behind CONFIG_MMU, a first-class symbol (default y) from the very first skeleton. The point is that a no-MMU target is a config axis, not a fork.

The long-term aim is a build that fits an RP2350 (Raspberry Pi Pico 2) — 520 KB of SRAM, a Cortex-M33 with an MPU, not an MMU — so a Clockwork PicoCalc could run a real capability-secure OS: a sibling to LoricaOS, or a dedicated PicoCalc spin. That needs CONFIG_MMU=n to select a flat, MPU-isolated memory backend, a new arch/armv8m port, and PicoCalc drivers (LCD console, matrix keyboard, SD, Wi-Fi) — all future work. But the elegant part is already true: the capability model is software-enforced and MMU-independent, so the thing that makes Aegis Aegis survives the port. You lose hardware memory isolation (a real, loudly-flagged trade-off); you keep capability authority.

On the RP2350's 520 KB SRAM the relevant budget is .bss + .data, not .text — the chip executes code from external QSPI flash via XIP, so code and rodata live in flash (up to 16 MB) and only static/heap/stack data occupy SRAM. That is exactly why the arena work matters more than code trimming for that target: tiny's .bss is already 396 KB — under the RP2350's 520 KB SRAM — and it still carries x86-only per-CPU / GDT / TSS structures an armv8-M core wouldn't have, plus a PMM bitmap that sizes to actual RAM (a fraction on an MCU). Every knob a constrained target needs now exists; what's left is the arch port itself. A staged pico2_defconfig already records the target's decisions — MMU=n, every x86/VM subsystem off, arenas sized for 520 KB, and the four PicoCalc drivers (GRAM char-cell LCD, I²C keyboard, SPI-SD + FAT, CYW43 Wi-Fi) on the bring-up list. It resolves cleanly through kconf today; it builds once arch/armv8m + the no-MMU backend land.

MicroVMs — boot in Firecracker

If the MMU axis reaches down to the MCU, the microVM axis reaches up to the cloud. A microVM (Firecracker, cloud-hypervisor, QEMU -machine microvm) is a deliberately stripped machine: no bootloader, no BIOS, no PCI bus, no legacy devices, and — on Firecracker — no ACPI at all. That is not a limitation to work around; it is exactly the minification frontier. Building for a microVM and deleting the biggest remaining platform chunks are the same work, so the microvm tier is both the leanest useful Aegis and a first-class deploy target. It boots to a full ring-3 userland — verified by make test-microvm, which runs the entire 22-test userspace suite (processes, fork/COW, signals, exec, mmap, clock_nanosleep, capability enforcement, filesystem) to an all-pass inside a bare microVM.

Three config axes and a boot path make it work:

PVH direct boot. MicroVMs run no bootloader — the VMM loads the kernel ELF and jumps to a 32-bit entry named by an ELF note, handing over an hvm_start_info (the Xen PVH boot ABI). Aegis emits that note and translates the struct into the same arch-neutral bootinfo the Limine path uses, so it launches directly in Firecracker and cloud-hypervisor. Limine and multiboot2 still work for bare-metal / QEMU-pc; PVH is simply a third entry, always present.

CONFIG_VIRTIO_MMIO — the transport. MicroVMs expose virtio devices not on a PCI bus but as flat MMIO register blocks at fixed addresses (the base differs per VMM). The virtio stack is split into a transport-neutral core plus PCI and mmio back-ends, so every device driver — blk, net, rng, … — works over either transport unchanged. Devices are discovered from the Linux-standard virtio_mmio.device=<size>@<base>:<irq> kernel-cmdline entries (which Firecracker appends automatically), with a fixed-window scan of QEMU microvm's transport array as a fallback.

CONFIG_PCI — droppable now. With virtio-mmio there is no PCI bus to enumerate, so CONFIG_PCI=n swaps the whole PCIe/ECAM path for a stub reporting an empty bus. Every PCI-only device (NVMe, AHCI, xHCI/USB, PVSCSI, HD-audio) depends on PCI, so they leave with it. This is minification the microVM makes correct: dead weight on a machine that has no PCI at all.

CONFIG_ACPI — droppable for Firecracker. Firecracker ships no ACPI tables. CONFIG_ACPI=n swaps acpi.c for a stub that reports no MADT/MCFG; the kernel falls back to BSP-only SMP and drives its timekeeping tick from the LAPIC timer instead of the (absent) 8254 PIT IRQ — so nanosleep and every timeout still work with no I/O APIC. PCI depends on ACPI (x86 PCIe config space comes from the MCFG table), so this drops the bus too.

The result is a ~233 KB-.text kernel that boots a real capability-secured userland the way Firecracker boots one — raw ELF in, virtio-mmio out, nothing legacy in between:

make microvm_defconfig && make
qemu-system-x86_64 -machine microvm -global virtio-mmio.force-legacy=false \
    -kernel build/aegis.elf -append "boot=text" \
    -drive id=root,file=rootfs.img,format=raw,if=none \
    -device virtio-blk-device,drive=root -nographic

cloud-hypervisor is covered by the same PVH boot with ACPI + PCI + virtio-pci left on (it uses a PCI bus), so a single tree serves the whole microVM matrix.

Pluggable disk filesystem — ext2 is now optional

The root/disk filesystem sits behind a small vtable (fs_ops, resolved through a single g_rootfs pointer), so the disk backend is a config choice and exactly one is linked:

CONFIG_ Backend Notes
FS_EXT2 (default) ext2 The historical backend, full Unix ownership + permissions (DAC). ~34 KB.
FS_FAT FAT32 Read-write, ~⅓ of ext2's code. The format every SD card and PC speaks.
(neither) nullfs No disk at all — the VFS serves only initrd (RO) + tmpfs; any disk path fails. For a diskless embedded build.

FS_FAT depends on !FS_EXT2 — both provide the same root backend, so you pick one. The FAT driver is a complete read-write filesystem: create/read/write/ unlink/truncate, mkdir/rmdir, rename/move, and long filenames (VFAT LFN, both reassembled on read and generated on create), with FAT date fields decoded into inode times. It is on-disk-compatible with standard tooling — a volume written by Aegis mounts and reads correctly under mtools/Linux/Windows, and vice-versa.

FAT has no Unix ownership or permissions, so chmod/chown are accepted no-ops and the passwd/shadow anchors report absent. That is not a security downgrade: on Aegis, authority never came from file-mode bits — it comes from the capability layer at the syscall boundary, which is identical on either backend. FAT simply drops a DAC layer Aegis was already treating as advisory.

This is the storage story for the PicoCalc spin: FAT on the SD card, no ext2, no DAC overhead, everything mountable on a host for flashing and recovery.

make nano_defconfig                 # ext2 off by default in nano → nullfs
echo CONFIG_FS_FAT=y      >> .config
echo CONFIG_VIRTIO=y      >> .config # (need a block transport to reach a disk)
echo CONFIG_VIRTIO_BLK=y  >> .config
make oldconfig && make              # links the FAT backend instead of nullfs

What it buys you (measured)

Gated so far — the network stack, the trace ring, the Hyper-V stack, the HDA driver, and the whole VirtIO family (per device) — across the three tiers on x86-64:

tier .text (code) .bss (static RAM)
full 585,886 B 3,089,924 B
workstation 551,182 B 2,403,396 B
tiny 453,910 B (−22.5 %) 396,516 B (−2.69 MB, −87 %)
nano 338,982 B (−42 %) 344,548 B (−89 %)

These figures are the baseline that established the subsystem-gating mechanism. Two changes since compound on top of them: --gc-sections (dead-code elimination, always on — ~15 % off full's .text alone) and a CONFIG_CC_OPTIMIZE_FOR_SIZE (-Os) knob, both on in the lean tiers. With those plus the microVM axis (no PCI, no ACPI), the microvm tier lands at ≈233 KB .text / ≈676 KB .bss — a networked, ext2-rooted, userspace- complete kernel smaller than the old nano floor, and nano itself now builds to ≈164 KB .text.

nano is the leanest bootable x86_64 build — serial console only, no block storage, no USB, no framebuffer (the 80 KB fb.o alone), no VirtIO/Hyper-V/ audio/trace/self-tests. It still boots to the no-init panic (rendered by the serial-only stub panic_halt).

The .bss collapse comes from making the fixed static arenas numeric knobs (kconf's int symbols). At the defaults they cost, per instance/table:

arena knob default in tiny
tmpfs /tmp + /run RAMFS_MAX_INODES × …PAGES_PER_FILE ~556 KB each 32×32 → 13.8 KB each
per-CPU (TSS/GDT/areas) MAX_CPUS 1024 → ~250 KB 8 → ~2 KB
PTY pool PTY_MAX_PAIRS 16 → ~140 KB 4 → 35 KB
kernel log ring KLOG_SIZE_KB 64 KB 8 KB
ext2 block cache EXT2_CACHE_SLOTS 16 → 66 KB 4 → 16 KB
PMM boot bitmap PMM_BOOT_GB 4 → 128 KB 2 → 64 KB

tiny's 396 KB .bss already fits under an RP2350's 520 KB SRAM — and it still carries x86-only per-CPU/GDT/TSS structures an armv8-M port wouldn't have.

Every tiny build still boots to the no-init smoke panic with the capability core intact. CONFIG_NET was the proving ground — the most coupled subsystem; everything since (the 640 KB trace ring, the Hyper-V stack, the HDA driver, the VirtIO devices) followed the same mechanism with far less friction. The next targets — xHCI/USB, VMware, and the static ramfs arenas — are the same again.

Not just tiers — deeply customizable

The tiers are only defconfigs; each one just sets a collection of independent CONFIG_* symbols. Anything a tier cuts, you can cut or keep on its own. Families are gated at per-device granularity, Linux-style: a transport/core symbol plus one knob per member that depends on it. VirtIO is the worked example — CONFIG_VIRTIO is the transport, and CONFIG_VIRTIO_BLK, CONFIG_VIRTIO_GPU, CONFIG_VIRTIO_NET, … each stand alone:

make tiny_defconfig                    # everything off
echo CONFIG_VIRTIO=y     >> .config    # add the transport back…
echo CONFIG_VIRTIO_BLK=y >> .config    # …and *only* virtio-blk
make oldconfig && make                 # compiles virtio_blk + virtio_pci + a
                                       # no-op stub for the other 10 devices

So "tiny", "workstation", and "full" are starting points, not a cage — the real surface is the full Kconfig tree, drivable with make menuconfig or by hand.

For adopters: how a subsystem is severed

Gating a subsystem cleanly is a repeatable pattern, proven first on the hardest-coupled one:

  1. Makefile gate. auto.conf's CONFIG_* variables drop the subsystem's source files (and anything that depends on it) from the build.
  2. sys_ni_syscall-style stubs. For the syscalls and cross-boundary symbols the core still references, a small stub TU — compiled only when the subsystem is off — returns -ENOSYS / inert values, so the syscall table and callers link unchanged (the Linux ni_syscall pattern).
  3. #ifdef CONFIG_* hooks. A handful of init / IRQ / poll call-sites are guarded directly, so a disabled subsystem is genuinely absent, not merely inert.
  4. Occasional extraction. Where a general facility was co-located with a subsystem (e.g. poll/select/epoll living inside the socket file), it is moved to its own always-compiled unit — cleaner code, and the subsystem file then gates without collateral.