Building My Smallest Personal Linux (from Kernel to Lua)

By leen 10 Dec 2025 · 12:00

The world is full of ready-made Linux distributions. From Ubuntu and Fedora to Arch and Nix and thousands of others that boot with just a few clicks, give you a desktop, and work without much hassle. But at some point in the journey of learning operating systems, you kind of want to ask a very simple question:

“What is the minimum kernel I need to boot a 64-bit Linux? If I build or configure everything from scratch myself, what does that operating system actually look like?”

This article is the story of that question; a story in three stages:

  1. Building a minimal kernel and booting it in QEMU
  2. Writing an init in Rust + assembly, without libc and without std
  3. Adding Lua, building a real initramfs, and finally creating a bootable ISO

This text is a companion to the video, not just a transcript. If you want to go deeper, this is where I walk through all the details at a slower, more careful pace.


Why did I even do this?

We usually see Linux as “a distro”: Ubuntu, Arch, Debian, Nix… But underneath, Linux is much more like a puzzle:

  • the kernel
  • the bootloader
  • user space
  • tools, shell, libraries, languages…

Once you roll up your sleeves and build a small system yourself, a few things change:

  • The OS stops looking like “magic”; you actually see where it starts and how it’s built layer by layer.
  • You understand tools like Docker, QEMU, initramfs, systemd, etc. on much more solid ground.
  • Most importantly: your sense of ownership over your system changes.

This project is not meant to be a “practical” distro for daily use; it’s more of an educational microscope to look at the simplest possible shape of an OS.


Part One – Building the smallest possible kernel and booting it in QEMU

In the first part, the goal was:

  • grab the kernel source
  • create an ultra-minimal configuration (tinyconfig)
  • tweak a few important options by hand
  • and finally boot the kernel until we hit the classic message: no working init found

Getting the kernel source

Under ~/_dev/smallest_linux I created a directory for the kernel:

cd ~/_dev/smallest_linux
git clone https://github.com/torvalds/linux.git
cd linux

Just to be sure:

pwd
ls

The directory structure (arch, fs, kernel, drivers, …) is the same kernel tree we always see and are a bit scared of =)

Using tinyconfig – the smallest possible config

The kernel comes with a built-in target for “very small” configs:

make tinyconfig

This command generates a super minimal .config. But:

  • I want 64-bit
  • I need initramfs
  • I need logging and a text console

So I have to tweak a few settings via menuconfig:

make menuconfig

Here are the key options I changed:

1. Enabling a 64-bit kernel

In the relevant menus (depends on kernel version):

  • Processor type and features or similar
  • enable the 64-bit kernel option.

2. Enabling initramfs

From General setup:

  • Initial RAM filesystem and RAM disk (initramfs/initrd) support

This is critical, because later I’ll hand the kernel all of init and Lua through initramfs.

3. Enabling printk

Still under General setup, in the “expert” section:

  • Enable support for printk

If you leave this disabled, the kernel won’t print anything to the screen and is basically useless for this project.

4. Enabling TTY / console

Under Device DriversCharacter devices, I enabled the relevant options for Virtual terminal / TTY / Console on virtual terminal. Without these, we don’t get any textual output.

5. Enabling ELF binary support

Under Executable file formats / Emulations, I turned on Kernel support for ELF binaries. My init is a real ELF binary; if this isn’t enabled, the kernel literally can’t execute it.

After these changes, exit menuconfig and save so the final .config is written.

Building the kernel

Now:

make -j 5

or:

make -j $(nproc)

After the build:

ls -lh arch/x86/boot/bzImage

That bzImage is the core of our tiny little world.

First boot with QEMU (no initramfs)

To make sure the kernel actually boots:

qemu-system-x86_64 \
  -kernel arch/x86/boot/bzImage \
  -m 64M

You’ll see a bunch of messages scroll by; the important part is at the end:

Run /sbin/init as init process
...
No working init found. Try passing init= option to kernel.
Kernel panic - not syncing: Attempted to kill init!

At this point:

  • the kernel has booted
  • it has set up memory, CPU, etc.
  • it just doesn’t have an init, so it quite reasonably panics

This is where part one ends and the next question appears:

What if I write my own init?


Part Two – Writing a minimal init in Rust + assembly

In part two, the goal was:

  • build a tiny binary called init
  • with no std, no libc, no main
  • talk to the kernel directly via system calls
  • and run this init as PID 1 on my own kernel

Here, Rust is used much more like a high-level assembly language than a comfy high-level language.

High-level design of init

The logic for this init is:

  • entry point: _start

  • print some greeting messages

  • enter a shell-like loop:

    • print "$ "

    • read a line from stdin

    • strip newline

    • if empty → continue

    • otherwise:

      • call fork
      • in the child → execve(path, argv, envp)
      • in the parent → wait4 for the child

Because I have no PATH and no environment, I must type the full path, e.g. /bin/lua.

Rust code without std and without main

The idea is:

  • #![no_std] → no standard library
  • #![no_main] → I define my own entry point
  • system calls are declared with extern "C" and implemented in assembly

The main (simplified) code looks like this:

#![no_std]
#![no_main]

use core::panic::PanicInfo;

extern "C" {
    fn sys_write(fd: isize, buf: *const u8, len: usize) -> isize;
    fn sys_read(fd: isize, buf: *mut u8, len: usize) -> isize;
    fn sys_fork() -> isize;
    fn sys_execve(path: *const u8,
                  argv: *const *const u8,
                  envp: *const *const u8) -> isize;
    fn sys_wait4(pid: i32,
                 status: *mut i32,
                 options: i32,
                 rusage: *mut u8) -> isize;
    fn sys_exit(code: i32) -> !;
}

#[no_mangle]
pub extern "C" fn _start() -> ! {
    write_str(b"\n\n[init] Rust minimal init started.\n");
    write_str(b"[init] Type a full path to exec (e.g. /bin/lua).\n\n");
    write_str(b"_______________________________\n");

    shell_loop();
}

fn shell_loop() -> ! {
    let mut buf = [0u8; 256];

    loop {
        write_str(b"-$ ");

        let n = unsafe { sys_read(0, buf.as_mut_ptr(), buf.len()) };
        if n <= 0 {
            unsafe { sys_exit(0) }
        }

        let mut n = n as usize;
        n = strip_newline(&mut buf[..n]);
        if n == 0 {
            continue;
        }

        let path_ptr = buf.as_ptr();
        let argv: [*const u8; 2] = [path_ptr, core::ptr::null()];

        let pid = unsafe { sys_fork() };

        if pid == 0 {
            unsafe {
                sys_execve(path_ptr, argv.as_ptr(), core::ptr::null());
                write_str(b"[child] execve failed, exiting.\n");
                sys_exit(1);
            }
        } else if pid > 0 {
            let mut status: i32 = 0;
            unsafe {
                sys_wait4(-1, &mut status as *mut i32, 0, core::ptr::null_mut());
            }
        } else {
            write_str(b"[parent] fork failed.\n");
        }
    }
}

fn strip_newline(buf: &mut [u8]) -> usize {
    let mut len = buf.len();
    while len > 0 && (buf[len - 1] == b'\n' || buf[len - 1] == b'\r') {
        len -= 1;
    }
    if len < buf.len() {
        buf[len] = 0;
    }
    len
}

fn write_str(s: &[u8]) {
    unsafe {
        sys_write(1, s.as_ptr(), s.len());
    }
}

#[panic_handler]
fn panic(_: &PanicInfo) -> ! {
    unsafe { sys_exit(1) }
}

No println!, no heap, no Vec, no String, nothing. Everything sits on a fixed buffer plus a handful of system calls.

The assembly layer: implementing system calls

To make sys_write and friends actually work, we implement them in a .s file:

.section .text
.intel_syntax noprefix

.globl sys_write
.globl sys_read
.globl sys_fork
.globl sys_execve
.globl sys_wait4
.globl sys_exit

# isize sys_write(isize fd, const u8 *buf, usize len)
sys_write:
    mov rax, 1          # __NR_write
    syscall
    ret

# isize sys_read(isize fd, u8 *buf, usize len)
sys_read:
    mov rax, 0          # __NR_read
    syscall
    ret

# isize sys_fork(void)
sys_fork:
    mov rax, 57         # __NR_fork
    syscall
    ret

# isize sys_execve(const u8 *path, const u8 **argv, const u8 **envp)
sys_execve:
    mov rax, 59         # __NR_execve
    syscall
    ret

# isize sys_wait4(i32 pid, i32 *status, i32 options, u8 *rusage)
sys_wait4:
    mov rax, 61         # __NR_wait4
    syscall
    ret

# void sys_exit(i32 code) -> !
sys_exit:
    mov rax, 60         # __NR_exit
    syscall
.Lhang:
    jmp .Lhang
  • The numbers (0, 1, 57, 59, 60, 61) are the system-call numbers on x86_64.
  • Arguments follow the Linux ABI conventions in rdi, rsi, rdx, rcx, r8, r9.
  • I only set rax and then issue syscall.

Compiling and linking to a static non-PIE binary

First build the assembly object:

as --64 syscalls.s -o syscalls.o

Then link using rustc directly:

rustc \
  -C opt-level=z \
  -C lto \
  -C panic=abort \
  -C relocation-model=static \
  -C link-arg=-nostartfiles \
  -C link-arg=-static \
  -C link-arg=-no-pie \
  -C link-arg=syscalls.o \
  shell.rs -o init

And finally:

strip init
file init
ldd init || echo "ldd failed (probably static / no interpreter)"

Now I have an init that:

  • is statically linked
  • is non-PIE
  • has no dynamic dependencies

Building a minimal initramfs containing only init

A tiny rootfs:

mkdir -p rootfs
cp init rootfs/init

cd rootfs
find . | cpio -H newc -o > ../init.cpio

And then:

cp init.cpio ~/_dev/linux/

Booting the kernel with my own init

Back to the kernel:

cd ~/_dev/linux

qemu-system-x86_64 \
  -kernel arch/x86/boot/bzImage \
  -initrd ./init.cpio \
  -append "console=ttyS0 loglevel=3 quiet" \
  -m 64M

If everything is correct, this time there’s no no working init found. You should see something like:

[init] Rust minimal init started.
[init] Type a full path to exec (e.g. /bin/lua).

-$

At this moment, my tiny kernel is running, and my Rust-written init is PID 1 on the system.


Part Three – Adding Lua and building a full ISO

So far:

  • I have a kernel
  • I have an init
  • I have an initramfs

But I don’t have any real user space yet. In part three, the goal was:

  • build a real program (Lua) statically
  • place it next to init inside rootfs
  • build a proper initramfs
  • and finally use the kernel’s own tools to build a bootable ISO.

Building static Lua

In ~/_dev:

cd ~/_dev/smallest_linux/
wget https://www.lua.org/ftp/lua-5.4.7.tar.gz
tar xvf lua-5.4.7.tar.gz
cd lua-5.4.7

make clean
make linux MYLDFLAGS="-static"

And then:

file src/lua
ldd src/lua
strip src/lua
ls -lh src/lua

This lua binary is a static ELF, exactly what we want.

Building the final rootfs (init + Lua)

In the Rust project:

cd ~/_dev/smallest_linux/shell_min
mkdir -p rootfs/bin

cp init rootfs/init
cp ~/ _dev/smallest_linux/lua-5.4.7/src/lua rootfs/bin/lua

cd rootfs
find . -print

The structure:

.
./init
./bin
./bin/lua

Now I have a small user space: init and Lua.

Building the real initramfs

Inside rootfs:

find . | cpio -H newc -o > ../init.cpio

And copy again:

cd ..
cp init.cpio ~/_dev/smallest_linux/linux/

Building the ISO with make isoimage

Now in the Linux source:

cd ~/_dev/smallest_linux/linux

make isoimage \
  FDARGS="loglevel=3 quiet initrd=init.cpio" \
  FDINITRD=/home/snape/_dev/smallest_linux/linux/init.cpio

Result:

ls -lh arch/x86/boot/image.iso

This ISO contains:

  • your tiny kernel
  • your own initramfs
  • and your own init + Lua

Booting the ISO in QEMU

qemu-system-x86_64 \
  -cdrom arch/x86/boot/image.iso \
  -m 64M

If everything is correct, the same init messages appear and you land at the prompt.

Now it’s time to run Lua:

-$ /bin/lua
Lua 5.4.7 Copyright ...
>

And for example:

> for i=1,10 do print("hello", i) end

This is the peak of the project:

  • the kernel I built myself
  • the init I wrote in Rust + ASM
  • the initramfs I packed myself
  • and Lua running inside this tiny “universe”

All of that fits into about 3.4 megabytes.


Conclusion – When an operating system stops being a “ready-made tool” and becomes a personal artifact

This project is not meant to replace any distro, and it’s not meant to become a “consumer OS” for daily use.

What mattered to me was:

  • seeing what the smallest Linux that I genuinely understand looks like
  • from the kernel layer, to init, to running a real program like Lua
  • and walking that path myself, all the way through — not just reading about it

After this journey:

  • the kernel is no longer a “scary black box”; it’s a 1–2 MB file I built myself
  • init is no longer some anonymous PID 1 process; it’s a Rust program I wrote
  • and an “operating system” is no longer just a company logo or a distro name; it’s something I can build myself, even if only in a tiny form.

The world is full of ready-made distros; but building your own smallest Linux world, even once, changes the way you look at all of them.

Comments

Be the first to share your thoughts.

Leave a comment