At this stage, we already have C implementations of commands such as ls, cat, etc., and we understand that a shell uses mechanisms such as fork() and exec() to run them.
The next question is:
If I am building my own mini operating system, what does the OS actually have to do when I run a program?
A program does not simply become a "process" by calling fork().
The operating system has to create and maintain a considerable amount of state for that process.
We will build a simplified version of this process-management system.
The OS needs a data structure representing every process.
This is commonly called a Process Control Block (PCB).
For our mini OS, we can keep it simple:
struct process {
int pid;
int state;
struct process *parent;
struct registers regs;
struct address_space *memory;
struct file_table *files;
int exit_status;
};
This structure represents the process from the kernel's point of view.
It tells us:
PID — which process is this?
state — is it ready, running, blocked, or finished?
parent — who created it?
regs — what CPU state should be restored when it runs?
memory — where is its address space?
files — which files does it have open?
exit_status — how did it finish?
The actual Linux task_struct is far more complicated. Our goal is to understand the basic idea first.
If the OS creates processes, it needs somewhere to keep track of them.
For a simple OS:
#define MAX_PROCESSES 64
struct process process_table[MAX_PROCESSES];
Conceptually:
Process Table
PID State Parent
--------------------------
1 RUNNING 0
2 READY 1
3 BLOCKED 1
4 READY 2
Now the kernel can find a process by its PID and maintain all currently existing processes.
Suppose our shell wants to run:
$ ls
Eventually, we want something like:
pid = create_process();
The kernel's job is roughly:
Create Process
│
├── Allocate process structure
│
├── Assign PID
│
├── Set process state
│
├── Set parent
│
├── Create memory/address space
│
├── Set CPU registers
│
├── Set up files
│
└── Add process to scheduler
This is the important idea:
Creating a process means creating and initializing all the state the kernel needs to manage that process.
We first need an unused PCB.
A simple implementation might look like:
struct process *allocate_process(void)
{
for (int i = 0; i < MAX_PROCESSES; i++) {
if (process_table[i].state == UNUSED) {
return &process_table[i];
}
}
return NULL;
}
The kernel now has a place to store the new process.
Every process needs an identifier.
int allocate_pid(void)
{
return next_pid++;
}
Then:
struct process *p = allocate_process();
p->pid = allocate_pid();
For example:
New process
PID = 5
The kernel can now refer to this process as process 5.
A newly created process isn't necessarily running immediately.
We can define:
enum process_state {
UNUSED,
READY,
RUNNING,
BLOCKED,
EXITED
};
Then:
p->state = READY;
The process is now ready to run, but the scheduler will decide when it actually gets the CPU.
If the shell creates a child process:
Shell
PID 1
│
└──────► ls
PID 2
The kernel records:
p->parent = current_process;
So the new process knows who its parent is.
This becomes important later for:
wait()
exit()
process cleanup
parent/child relationships
Now we reach one of the most important responsibilities of the OS.
A program needs memory for:
Code
Data
Heap
Stack
Conceptually:
Process Address Space
┌──────────────────┐
│ Stack │
├──────────────────┤
│ │
│ Heap │
│ │
├──────────────────┤
│ Data │
├──────────────────┤
│ Code │
└──────────────────┘
Our process structure therefore contains:
struct address_space *memory;
When creating a process:
p->memory = create_address_space();
The memory subsystem then needs to:
Allocate physical pages.
Create page tables.
Map virtual addresses to physical memory.
Set appropriate permissions.
For a first mini OS, these mechanisms can be extremely simple.
The CPU needs to know where the process should begin executing.
Our PCB therefore contains:
struct registers regs;
For example:
struct registers {
uint64_t rip; // instruction pointer
uint64_t rsp; // stack pointer
uint64_t rax;
uint64_t rbx;
uint64_t rcx;
uint64_t rdx;
uint64_t flags;
};
When starting a new program, the kernel sets something like:
p->regs.rip = program_entry;
p->regs.rsp = stack_top;
So the CPU knows:
Start executing at program_entry using this stack.
A process also needs access to files.
At minimum:
0 → stdin
1 → stdout
2 → stderr
So the process can contain:
struct file_table *files;
During process creation:
p->files = create_file_table();
For a child created using fork(), the child normally inherits the parent's open file descriptors.
The process has now been initialized.
We put it into the ready queue:
scheduler_add(p);
Conceptually:
Ready Queue
P2 → P3 → P4 → P5
↑
new process
The process now exists, but it still isn't necessarily executing.
We can now combine everything:
struct process *create_process(struct process *parent)
{
struct process *p;
p = allocate_process();
if (p == NULL)
return NULL;
p->pid = allocate_pid();
p->state = READY;
p->parent = parent;
p->memory = create_address_space();
p->files = create_file_table();
setup_registers(&p->regs);
scheduler_add(p);
return p;
}
This is obviously simplified, but it gives us the correct architecture.
The important thing is not the exact code.
The important thing is understanding what has to happen.
We have created a process structure, but we haven't actually loaded a program into memory.
Suppose we want to run:
/bin/ls
We need another subsystem:
Executable
│
▼
Executable Loader
│
├── Read executable
├── Read code section
├── Read data section
├── Create memory mappings
├── Create stack
└── Find entry point
For a Unix-like system, the executable will commonly be an ELF file.
So our kernel eventually needs an ELF loader.
A simplified function could look like:
int load_program(struct process *p, const char *path)
{
struct executable *exe;
exe = load_executable(path);
if (!exe)
return -1;
map_code(p->memory, exe);
map_data(p->memory, exe);
setup_stack(p->memory);
p->regs.rip = exe->entry_point;
p->regs.rsp = stack_top;
return 0;
}
Now the process actually has a program to execute.
Eventually the scheduler selects our process:
struct process *next = scheduler_next();
next->state = RUNNING;
switch_to(next);
The context-switch mechanism restores:
CPU registers
Instruction pointer
Stack pointer
CPU flags
Address-space information
Then the CPU begins executing the program.
The complete path is now:
Shell
│
│ create/fork
▼
Kernel
│
├── Create PCB
├── Assign PID
├── Set parent
├── Create address space
├── Set up files
├── Set CPU state
├── Load program
└── Add to scheduler
│
▼
READY
│
▼
Scheduler
│
▼
RUNNING
│
▼
Program
The OS doesn't just create a process and forget about it.
It continuously maintains it.
For example, the process may execute:
read(fd, buffer, 100);
The process enters the kernel:
Program
│
│ read()
▼
Kernel
│
├── Find process
├── Find file descriptor
├── Perform I/O
└── Possibly block process
If the disk isn't ready:
RUNNING
│
▼
BLOCKED
│
│ disk completes
▼
READY
│
▼
RUNNING
The scheduler can run another process while this one waits.
Suppose P1 is running and the timer interrupt occurs.
P1 running
│
▼
Timer interrupt
│
▼
Kernel
│
▼
Save P1 CPU state
│
▼
Scheduler
│
▼
Select P2
│
▼
Restore P2 CPU state
│
▼
P2 running
This is why the PCB contains CPU-register information.
The OS must be able to stop a process and later continue it as though it had never been stopped.
Eventually the program calls:
exit(0);
The kernel must then clean up.
Conceptually:
Process
│
exit()
▼
Kernel
│
├── Save exit status
├── Close files
├── Release memory
├── Change state to EXITED
└── Notify parent
A simplified implementation:
void process_exit(struct process *p, int status)
{
p->exit_status = status;
close_files(p);
free_address_space(p->memory);
p->state = EXITED;
wake_parent(p);
}
The process's resources can then eventually be completely removed from the process table.
We can now see the entire journey of a program:
PROGRAM
│
▼
Process Creation
│
▼
NEW
│
▼
READY
│
▼
SCHEDULER
│
▼
RUNNING
/ │ \
/ │ \
▼ ▼ ▼
BLOCKED SYSCALL INTERRUPT
│
│ event
▼
READY
│
▼
EXIT
│
▼
CLEANUP
At every stage, the kernel is maintaining the process's state.
For our mini OS, process management therefore consists of several cooperating components:
┌─────────────────────────────────────────┐
│ PROCESS MANAGEMENT │
├─────────────────────────────────────────┤
│ │
│ Process Control Block (PCB) │
│ │ │
│ ▼ │
│ Process Table │
│ │ │
│ ▼ │
│ Process Creation │
│ │ │
│ ├──────► Memory Manager │
│ │ │
│ ├──────► File Manager │
│ │ │
│ ├──────► Executable Loader │
│ │ │
│ ▼ │
│ Scheduler │
│ │ │
│ ▼ │
│ Context Switching │
│ │ │
│ ▼ │
│ Process Termination │
│ │
└─────────────────────────────────────────┘
This is the important architectural insight:
Process management is not one function. It is a collection of kernel subsystems working together.
Once we understand processes this way, we can apply the same method to the rest of the OS.
For memory, ask:
What does the kernel need to maintain?
→ Physical memory
→ Page allocator
→ Page tables
→ Virtual address spaces
→ Page faults
→ Memory protection
For files:
What does the kernel need to maintain?
→ File descriptor table
→ File objects
→ Inodes
→ Directories
→ Filesystem
→ Storage devices
For CPU scheduling:
What does the kernel need to maintain?
→ Ready queue
→ Process states
→ Timer
→ CPU context
→ Scheduler
→ Context switch
For I/O:
What does the kernel need to maintain?
→ Device drivers
→ Interrupt handlers
→ Buffers
→ Device state
→ Waiting processes
The overall approach is always the same:
Identify the state the OS must maintain → create the kernel data structures → implement the operations that modify that state → connect those operations to system calls, interrupts, and hardware.
That is the path from writing C programs that use an operating system to writing the kernel that provides the operating system.
So when a C kernel starts:
void kernel_main(void)
{
memory_init();
process_init();
scheduler_init();
filesystem_init();
create_initial_process();
...
}
BOOT
│
▼
Kernel loaded
│
▼
kernel_main()
│
┌───────┼────────┐
▼ ▼ ▼
memory_init process_init scheduler_init
│ │ │
▼ ▼ ▼
Memory PCB table Ready queue
manager
So the RAM looks like
RAM
┌──────────────────────────────┐
│ Linux/Your Kernel Code │
├──────────────────────────────┤
│ Kernel Global Data │
├──────────────────────────────┤
│ Process Table │
│ │
│ PCB 0 → UNUSED │
│ PCB 1 → UNUSED │
│ PCB 2 → UNUSED │
│ ... │
├──────────────────────────────┤
│ Scheduler structures │
├──────────────────────────────┤
│ Memory manager structures │
├──────────────────────────────┤
│ Filesystem structures │
├──────────────────────────────┤
│ Kernel heap │
├──────────────────────────────┤
│ Free memory │
└──────────────────────────────┘
Then when the shell asks to create a process:
fork()
│
▼
Kernel
│
├── Find free PCB
├── Fill in PID
├── Set state
├── Set parent
├── Allocate process memory
├── Set registers
├── Set file descriptors
└── Put process in ready queue