Building My Smallest Personal Linux (from Kernel to Lua)
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:
- Building a minimal kernel and booting it in QEMU
- Writing an init in Rust + assembly, without
libcand withoutstd - 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 featuresor similar- enable the
64-bit kerneloption.
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 Drivers → Character 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, nolibc, nomain - talk to the kernel directly via system calls
- and run this
initas 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 →
wait4for the child
- call
-
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
raxand then issuesyscall.
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.