I’ve recently started diving deep into the internals of Operating Systems using the excellent IIT Bombay OS Course by Prof. Mythili Vutukuru.
There is no better way to understand how an OS manages processes than by building the one tool developers use every day: The Shell.
In this series, I’m going to document my journey building a UNIX-style shell from scratch. We start with a simple template and will eventually build a fully functional shell capable of background processes, signal handling, and parallel execution.
The Starting Point
The lab provides a basic C template. Its only job right now is to accept a string input and "tokenize" it (split it by spaces).
Here is the core structure provided. It handles the messy string manipulation so we can focus on the system calls:
// ... (Include headers)
// The tokenizer splits input like "ls -l" into ["ls", "-l", NULL]
char **tokenize(char *line) {
// ... implementation of string splitting ...
}
int main(int argc, char* argv[]) {
char line[MAX_INPUT_SIZE];
char **tokens;
while(1) {
printf("$ "); // The Prompt
// ... (Input scanning logic) ...
tokens = tokenize(line);
// TODO: Make the shell actually DO something!
// ... (Memory cleanup) ...
}
return 0;
}
The Mission (Part A)
The lab is broken down into 5 distinct phases. This post focuses on Part A: The Foundation.
My goal for Part A is to establish the Core Execution Loop. The shell needs to:
- Read the user's command.
forka new process.execthe command in that new process.waitfor it to finish.- Handle the
cd(change directory) command separately.
The Solution
1. The Fork-Exec-Wait Pattern
The heart of any shell is the fork(), exec(), and wait() cycle.
When you type ls in your terminal, the shell doesn't run ls itself. It creates a clone of itself (the child), and that clone transforms into the ls program.
Here is how I implemented that logic inside the main loop:
int pid = fork(); // 1. Create a clone
if (pid < 0){
// Fork failed (system is likely out of memory or process limits)
exit(1);
}
if (pid == 0) {
// === CHILD PROCESS ===
// This is where the magic happens.
// We replace the current process image with the command the user typed.
// execvp looks for the command in the system PATH
if (execvp(tokens[0], tokens) != 0){
printf("Command not found.\n");
exit(1);
}
} else {
// === PARENT PROCESS (The Shell) ===
// We must wait for the child to die, otherwise the prompt returns too early.
int status;
wait(&status);
}
2. Where am I? (Improving the Prompt)
A shell isn't helpful if you don't know where you are. I used getcwd (Get Current Working Directory) to update the prompt dynamically before asking for input.
char current_working_directory[256];
getcwd(current_working_directory, 256);
// Now the prompt looks like: /Users/rakshit/dev $
printf("%s $ ", current_working_directory);
3. The "cd" Trap (Why exec doesn't work)
This was the most interesting concept in Part A. Initially, you might think you can just run cd like any other command.
However, try running this in your current terminal:
which ls
# Output: /bin/ls (Or similar path)
which cd
# Output: cd: shell built-in command
Why is cd a built-in?
If we fork a child process and run cd inside it, the child's working directory would change, and then the child would exit. The parent (our shell) would stay in the exact same place! The child cannot alter the parent's environment.
To fix this, we have to trap the cd command before forking and execute it in the parent process using the system call chdir():
// Check for built-ins BEFORE forking
if (strcmp(tokens[0], "cd") == 0) {
// Ensure the user actually provided a directory
if (tokens[1] != NULL) {
// chdir returns 0 on success, -1 on failure
if (chdir(tokens[1]) != 0) {
printf("Shell: Incorrect directory.\n");
}
}
continue; // Skip the fork/exec step entirely
}
The Result
Here is the current working state of the UNIX shell. It can navigate directories and execute standard binaries!
What's Next?
That concludes Part A. We have a working shell, but it's "blocking"—meaning we can only run one thing at a time.
In the next post, I will tackle Part B: Background Execution. We will learn how to run processes in the background using &, manage concurrency, and deal with the dreaded "Zombie Processes."