🧪 Project: Write a Minimal Init System (PID 1)

A minimal init system is a great way to understand Unix-like process management, signal handling, and what happens when a Linux system boots. In this project, you’ll write your own tiny init process and run it as PID 1.


🧠 What Is an Init System?

The init system is the first user-space process launched by the Linux kernel. It always has PID 1 and is responsible for:

  • Starting other user-space programs (like shells or daemons)
  • Reaping zombie processes (crucial)
  • Handling shutdown or reboot requests

We’re not building systemd — just a minimal educational version.


🛠 Prerequisites

You’ll need:

  • Intermediate knowledge of C programming
  • Familiarity with Linux system calls
  • Access to a VM, container, or custom boot environment

🎯 Goals

Your init program will:

  1. Run as PID 1
  2. Spawn a shell (like /bin/sh)
  3. Reap child processes (zombies)
  4. Handle basic signals like SIGTERM or SIGINT

🧱 Step-by-Step

1. Write the Minimal init.c

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// init.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>

void sigchld_handler(int sig) {
    // Reap zombie processes
    while (waitpid(-1, NULL, WNOHANG) > 0);
}

int main() {
    signal(SIGCHLD, sigchld_handler);

    // Mount essential pseudo-filesystems
    if (mount("proc", "/proc", "proc", 0, NULL) != 0) {
        perror("mount /proc failed");
    }

    if (mount("sysfs", "/sys", "sysfs", 0, NULL) != 0) {
        perror("mount /sys failed");
    }

    // Redirect stdio to /dev/console
    int fd = open("/dev/console", O_RDWR);
    if (fd >= 0) {
        dup2(fd, 0);
        dup2(fd, 1);
        dup2(fd, 2);
        if (fd > 2) close(fd);
    } else {
        perror("failed to open /dev/console");
    }

    pid_t pid = fork();
    if (pid == 0) {
        // Child process — launch a shell
        execl("/bin/sh", "/bin/sh", NULL);
        perror("execl failed");
        exit(1);
    }

    // Parent (PID 1)
    printf("Init started. Child PID: %d\n", pid);

    while (1) {
        pause();  // Sleep until signal
    }

    return 0;
}

2. Compile the Program

Use static linking to ensure it works in a minimal root filesystem:

1
gcc -static -o init init.c

3. Run It in a Minimal Environment

Option A: Using Docker

1
docker run -it --rm --init --name testinit -v $(pwd)/init:/init busybox /init

This version won’t run as PID 1 (when you run ps). If you run ps in the new shell, it will look something like:

1
2
3
4
5
PID   USER     TIME  COMMAND
  1   root      0:00  /sbin/docker-init -- /init       Dockers tini-style init
  7   root      0:00  /init                            Your compiled init.c
  8   root      0:00  /bin/sh                          The shell your init spawned
  9   root      0:00  ps                               The `ps` command you ran
🧾 What’s Going On?
  • PID 1 is not your init — it’s Docker’s /sbin/docker-init
  • Docker injects its own init process (usually tini) to reap zombies and forward signals correctly.
  • Your custom init is actually running as PID 7 — so it’s not truly PID 1.
✅ But Your Code Still Works

Even though it’s PID 7, your minimal init:

  • Spawned a /bin/sh shell (PID 8)
  • Stayed alive
  • Can still handle SIGCHLD, etc.

So your code is fine — it’s just not running as PID 1 due to Docker’s isolation behavior.

Option B: Want to Truly Run as PID 1? QEMU + Custom Initramfs

Here we will:

  • Build a minimal Linux kernel
  • Bundle your init in an initramfs
  • Boot it with qemu:

✅ What You Need

i. A Kernel Image: bzImage
Option A: Build It Yourself
1
2
3
4
git clone https://github.com/torvalds/linux.git
cd linux
make defconfig
make -j$(nproc) bzImage

After compilation, copy the result:

1
cp arch/x86/boot/bzImage /path/to/your/qemu/project/
Option B: Download Prebuilt Kernel (Lazy option 🤣)

You can download a bzImage from sources like TinyCore or GitHub projects, but compiling is best for full control and for the exploratory stuff we are doing here.

1
wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.1.tar.xz

Or search for “prebuilt bzImage”.


ii. A Minimal Initramfs: initramfs.cpio.gz

initramfs (short for initial RAM filesystem) is a temporary root filesystem that is loaded into memory and used by the Linux kernel very early in the boot process, before the real root filesystem is mounted.

It must include:

  • /init (your compiled static init program)
  • /bin/sh (e.g., via BusyBox)
  • /dev/console (required for proper boot output)
A. Download and Build BusyBox (If you don’t have it already)
1
2
3
4
5
wget https://busybox.net/downloads/busybox-1.36.1.tar.bz2
tar -xjf busybox-*.tar.bz2
cd busybox-*
make defconfig
make -j$(nproc) CONFIG_STATIC=y

This gives you a statically linked busybox binary in ./busybox.


B. Create Root Filesystem Layout
1
2
mkdir -p rootfs/{bin,sbin,etc,proc,sys,usr/bin,usr/sbin,dev}
cp busybox rootfs/bin/

1
2
3
for app in $(./busybox --list); do
  ln -s /bin/busybox rootfs/bin/$app
done

D. Create /dev/console device node:
1
sudo mknod -m 622 rootfs/dev/console c 5 1

E. Install Your Custom init

Make sure your compiled init binary (from earlier) is copied into the root filesystem as /init.

1
2
cp init rootfs/init
chmod +x rootfs/init

This will now be PID 1 when QEMU boots.

Make it executable:

1
chmod +x rootfs/init

F. Build the Initramfs
1
2
3
cd rootfs
find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../initramfs.cpio.gz
cd ..

iii. 📦 Boot It With QEMU (Once You Have the Files)

Make sure you’re in the directory with:

1
2
ls
bzImage  initramfs.cpio.gz

Then run:

1
2
3
4
5
qemu-system-x86_64 \
  -kernel bzImage \
  -initrd initramfs.cpio.gz \
  -append "console=ttyS0 init=/init" \
  -nographic

You should see:

1
2
3
4
BusyBox v1.36.1 built-in shell (ash)
Enter 'help' for a list of built-in commands.
/bin/sh: can't access tty; job control turned off
~ #

This is expected — job control is unavailable without a full controlling terminal, but your shell works perfectly without it.

iv. Confirm It Works

Run ps in the shell. You should see this, plus many other kernel threads:

1
2
3
4
PID   USER     COMMAND
  1   0        /init
 53   0        /bin/sh
 54   0        ps

✔️ Your init is PID 1
✔️ Your shell launched
✔️ ps shows all processes
✔️ Kernel threads like [kworker/…] are visible

✅ You are now officially running your own custom Linux init system from scratch inside QEMU!


4. Stretch Goals

Add extra functionality to your init:

  • Handle SIGTERM, SIGINT for clean shutdown
  • Spawn multiple child processes (like daemons)
  • Log messages to a file or pseudo-terminal
  • Implement shutdown: call sync() and reboot(RB_POWER_OFF)
  • Parse a config file (like a mini /etc/inittab)

📚 References


🔜 Next Steps

Coming soon:

  • Test zombie reaping with background processes
  • Send it signals and confirm it handles them
  • Add shutdown/reboot signal support
  • Launch other system services from your init