Writing an OS Kernel from Scratch | Process and Scheduling — From fork to schedule, How a Process is Born

You press ./a.out and hit Enter — a process starts running. How is this process “created” by the operating system? What does fork do? What does exec do? How does the scheduler decide which process runs first?

This is one of the most core concepts of an operating system: Process Management. From when you type a command in the terminal to when the program actually runs on the CPU, the operating system does a tremendous amount of work behind the scenes. Today, we’ll walk through this entire process completely.


1. What is a Process: task_struct

In Linux, a process is a task_struct object — a structure describing all states of a process. It contains:

Bash
task_struct Core Fields (simplified):

Process Identity:
  pid_t pid;           // Process ID, unique identifier
  pid_t ppid;          // Parent process ID
  char name[16];       // Process name

State:
  volatile long state; // Process state (R/T/S/Z)
  unsigned int flags; // Process flags

Scheduling:
  int static_prio;    // Static priority
  int dynamic_prio;   // Dynamic priority
  unsigned int weight;// Scheduling weight (used by cfs_rq)
  struct sched_entity se;  // Scheduling entity (red-black tree node)

Memory:
  struct mm_struct *mm;   // Memory descriptor (virtual address space)
  unsigned long rss;      // Physical memory usage

Files:
  struct files_struct *files;  // Open file descriptor table

Time:
  unsigned long start_time;    // Process start time
  unsigned long utime;         // User mode time (jiffies)
  unsigned long stime;         // Kernel mode time (jiffies)

Thread:
  struct thread_struct thread; // CPU register context (stack, IP, etc.)

Difference between process and thread: In Linux, a thread is just a lightweight process that shares some task_struct fields. Threads have their own thread (stack and registers), but share mm, files, signal handling, etc.


2. fork: Creating a New Process

fork is one of Unix’s most elegant designs — it creates two views of a process with a single system call:

Bash
The essence of fork:

Parent process calls fork()
    ↓
Kernel does three things:
    1. Allocate a new task_struct
    2. Copy the parent's address space (COW, Copy-On-Write)
    3. Add the new process to the scheduler
    ↓
Return: In the parent process, returns child PID; in the child process, returns 0

Result: Two nearly identical processes running, differing only in the return value

2.1 Kernel Implementation of fork (do_fork)

C
// Linux fork core function (simplified)
// Source: kernel/fork.c

int do_fork(unsigned long clone_flags,
            unsigned long stack_start,
            struct pt_regs *regs,
            unsigned long stack_size) {
    // Step 1: Allocate task_struct (copy_process)
    struct task_struct *p = copy_process(clone_flags,
                                          stack_start,
                                          regs,
                                          stack_size);

    // Step 2: Allocate PID
    p->pid = alloc_pid();

    // Step 3: Add to scheduler
    wake_up_new_task(p);

    // Step 4: In parent, return child PID
    // In child, return 0 (by modifying stack/regs)
    return p->pid;
}

2.2 Copy-On-Write (COW)

Traditional fork would immediately copy the entire address space — very slow.

Modern Linux uses COW:

Bash
COW principle:

After fork:
  Parent and child share the same physical memory pages
  All pages are marked read-only (write-protect)

On write (either parent or child tries to write):
  → CPU triggers #PF (Page Fault)
  → Page fault handler detects: "COW page being written"
  → Allocate a new physical page
  → Copy the old page content
  → Map the new page to the writing process
  → Retry the write instruction

Result: Only pages that are actually modified get copied

3. exec: Loading a New Program

fork creates a process, but the process still runs the parent’s code. To run a new program, you need exec:

Bash
exec workflow:

User calls execve("/bin/ls", argv, envp)
    ↓
Kernel does:
    1. Open the executable file (/bin/ls)
    2. Read ELF header, verify validity
    3. Parse ELF's program headers
       └── Map LOAD segments
           ├── Text segment (code) → mapped read-only, executable
           └── Data segment (data) → mapped read-write
    4. Set up initial stack (argc, argv, envp)
    5. Set new entry point (e_entry from ELF header)
    6. Release old address space
    ↓
Return to user mode: start executing from the new entry point

3.1 fork + exec Complete Process

Bash
Typical shell workflow:

bash:
    // 1. Fork: create a child process
    pid = fork();

    if (pid == 0) {
        // Child process:
        // 2. exec: load new program
        execve("/bin/ls", argv, envp);
        // Never returns here on success
    } else {
        // Parent process (bash):
        // 3. wait: wait for child to finish
        waitpid(pid, &status, 0);
    }

4. Scheduler: Deciding Who Runs

When multiple processes are ready to run, who goes first? That’s the scheduler’s job.

4.1 Scheduling State Machine

Bash
Process states and transitions:

   Created (fork/exec)
        ↓
     Ready (TASK_RUNNING) ←───────┐
        ↓                          │
     Running (on CPU)              │ I/O complete / wake_up
        ↓                          │
   ┌───┴──────────────┐           │
   │                  │            │
I/O Wait (sleep)   Time slice    (new process)
TASK_INTERRUPTIBLE  exhausted     wake_up
   │                  │            │
   └──────────────────┴────→ Ready┘
        ↓
   Termination (exit)
        ↓
     Zombie (TASK_ZOMBIE)
        ↓ (parent calls wait)
     Cleaned up

4.2 Scheduling Algorithm Comparison

Bash
Scheduling Algorithm Comparison:

O(n) Scheduler (Linux 2.4):
  - Traverse all processes, find the one with the highest "goodness"
  - O(n) complexity, poor for many processes

O(1) Scheduler (Linux 2.6):
  - 140 priority levels, each with a runqueue
  - O(1) complexity, excellent for servers
  - But interactive performance is mediocre

CFS (Completely Fair Scheduler, Linux 2.6.23+):
  - Red-black tree stores process vruntime
  - Always selects the process with the smallest vruntime
  - Essentially O(log n), perfectly fair
  - Modern Linux standard

5. Practical Example: Multi-Process Programming

C
// Complete fork + exec example
#include <unistd.h>
#include <sys/wait.h>
#include <stdio.h>

int main() {
    pid_t pid = fork();

    if (pid < 0) {
        // Fork failed
        perror("fork");
        return 1;
    } else if (pid == 0) {
        // Child process: execute the 'ls' program
        execl("/bin/ls", "ls", "-l", NULL);
        // If exec returns, it failed
        perror("exec");
        return 1;
    } else {
        // Parent process: wait for child to finish
        int status;
        waitpid(pid, &status, 0);
        printf("Child process %d exited with status %dn",
               pid, WEXITSTATUS(status));
    }
    return 0;
}

6. Summary

  1. task_struct is the kernel’s representation of a process, storing everything
  2. fork creates an almost identical copy; exec loads a new program
  3. COW (Copy-On-Write) optimizes fork to avoid copying memory unnecessarily
  4. Scheduler decides which process runs next; Linux uses CFS
  5. Process states: Ready → Running → Waiting → Terminated → Zombie
  6. fork + exec + wait is the fundamental workflow of process management

Next up: Context switching — how does the CPU save and restore process state?

OS #KernelDevelopment #Process #Scheduling #fork #exec #COW #CFS #WritingFromScratch

Last modified: 2024年3月27日

Author

Comments

Write a Reply or Comment

Your email address will not be published.