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