Welcome back, future software engineers and tech innovators! If you are deep into your Computer Science or IT engineering curriculum, you have likely encountered the formidable Operating Systems (OS) course. And with it comes one of the most legendary, intimidating, yet incredibly rewarding assignments you will ever face: Building your own Shell in C.
I know the feeling. Staring at a blank terminal, trying to understand how typing ls -l actually makes files appear on the screen, while your professor casually throws around terms like zombie processes, forking, and exec families. It can feel like black magic.
But as a senior developer who has been exactly where you are, I promise you: there is no magic. A shell is just a C program. It is a while-loop that reads a string, splits it into words, and asks the operating system to run those words. That’s it!
Today, we are going to demystify this process completely. We will build a fully functional, custom shell from scratch. Grab your favorite caffeinated beverage, boot up your Linux environment (or WSL), and let’s dive deep into system-level C programming!
1. What Exactly is a Shell? (The OS Context)
Before writing a single line of code, we need to understand the architecture of an Operating System.
Imagine a massive, highly secure corporate building.
- The Hardware: This is the physical building, the vault, the electricity.
- The Kernel: This is the strict CEO locked inside the top-floor vault. The kernel controls the hardware, manages memory, and schedules CPU time. But the CEO does not talk directly to the general public.
- The Shell: This is the receptionist at the front desk. The shell takes your request (the command you type), translates it into a format the CEO understands (system calls), and hands it over. When the CEO is done, the receptionist hands the result back to you.
In technical terms, a Shell is a command-line interpreter. It provides a user interface for the Unix/Linux operating system. Examples include bash, zsh, sh, and fish. Today, we are writing our own!
2. The Architecture of a Shell: The REPL Loop
Every shell, at its core, operates on a very simple infinite loop known as the REPL cycle:
- Read: Read the command from standard input (the keyboard).
- Evaluate (Parse): Break the input string down into a program name and its arguments.
- Print (Execute): Run the program and print the output to the terminal.
- Loop: Loop back and wait for the next command.
Our C program will mirror this exact structure.
3. The Holy Trinity of OS System Calls: fork, exec, and wait
To build a shell, you must master three vital POSIX system calls. If you understand these three functions, you understand 80% of process management in Unix.
A. fork(): The Cloner
When a program calls fork(), the operating system creates an exact duplicate of the currently running process.
- The original process is called the Parent.
- The newly created clone is called the Child.
- They are identical in almost every way (same code, same variables), EXCEPT
fork()returns a different value to each. It returns0to the child, and the child’s Process ID (PID) to the parent.
B. execvp(): The Brain Transplant
If fork() creates a clone, execvp() is a brain transplant. It replaces the current running program’s memory space with a brand new program.
- If our shell (the parent) just called
execvp("ls"), our shell would be completely destroyed and replaced by thelsprogram. Oncelsfinished, the terminal would close. - The Solution: This is why we
fork()first! We create a child clone, and then the child performs theexecvp()brain transplant to become thelsprogram. Our parent shell remains safe and intact.
C. wait(): The Patient Parent
Because the parent and child run concurrently (at the same time), the parent shell might try to print the next prompt before the child (the ls command) has finished printing its output. This makes the terminal look messy.
- We use
wait()orwaitpid()in the parent process to pause its execution until the child process finishes its job and dies.
4. Step 1: Setting Up the Basic Loop (The Prompt)
Let’s start writing code. We need a basic main() function that loops infinitely, printing a prompt for the user. Let’s call our shell indieshell> .
C
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void shell_loop() {
char *line;
char **args;
int status = 1;
do {
printf("indieshell> ");
// We will write these functions next!
// line = read_line();
// args = split_line(line);
// status = execute_command(args);
// Free memory to avoid leaks
// free(line);
// free(args);
} while (status);
}
int main(int argc, char **argv) {
// Run the command loop
shell_loop();
return EXIT_SUCCESS;
}
5. Step 2: Reading User Input
In standard C, reading a line of text that could be of any length is notoriously tricky because of buffer overflows. Fortunately, modern POSIX systems provide the getline() function, which dynamically allocates exactly enough memory to hold whatever the user types.
Let’s implement our read_line function.
C
char *read_line(void) {
char *line = NULL;
size_t bufsize = 0; // getline will allocate the buffer for us
// getline reads from standard input (stdin)
if (getline(&line, &bufsize, stdin) == -1) {
// If getline fails (e.g., user presses Ctrl+D for EOF)
if (feof(stdin)) {
exit(EXIT_SUCCESS); // We received an EOF
} else {
perror("readline"); // Print error message
exit(EXIT_FAILURE);
}
}
return line;
}
6. Step 3: Parsing the Command (Tokenization)
If the user types ls -l /var/log, our read_line function gives us one giant string: "ls -l /var/log\n".
The operating system cannot execute that. It needs an array of separate strings (tokens): ["ls", "-l", "/var/log", NULL].
We will use the classic C function strtok() to split our string based on delimiters (spaces, tabs, and newline characters).
C
#define LSH_TOK_BUFSIZE 64
#define LSH_TOK_DELIM " \t\r\n\a"
char **split_line(char *line) {
int bufsize = LSH_TOK_BUFSIZE, position = 0;
// Allocate an array of string pointers
char **tokens = malloc(bufsize * sizeof(char*));
char *token;
if (!tokens) {
fprintf(stderr, "Allocation error\n");
exit(EXIT_FAILURE);
}
// Grab the first token
token = strtok(line, LSH_TOK_DELIM);
while (token != NULL) {
tokens[position] = token;
position++;
// If we exceed our buffer size, we must reallocate more memory!
if (position >= bufsize) {
bufsize += LSH_TOK_BUFSIZE;
tokens = realloc(tokens, bufsize * sizeof(char*));
if (!tokens) {
fprintf(stderr, "Allocation error\n");
exit(EXIT_FAILURE);
}
}
// Grab the next token
token = strtok(NULL, LSH_TOK_DELIM);
}
// The execvp function strictly requires the array to be null-terminated
tokens[position] = NULL;
return tokens;
}
7. Step 4: Launching Processes (The Magic of Fork & Exec)
Now for the core engineering. We have our array of arguments. We need to create a new process and execute them.
C
#include <unistd.h>
#include <sys/wait.h>
int launch_process(char **args) {
pid_t pid, wpid;
int status;
pid = fork(); // 1. Create the clone
if (pid == 0) {
// --- WE ARE IN THE CHILD PROCESS ---
// 2. Perform the brain transplant
// execvp takes the command name and the array of arguments
if (execvp(args[0], args) == -1) {
// If execvp returns, it means it FAILED to find the command
perror("indieshell error");
}
exit(EXIT_FAILURE); // Kill the child if exec fails
} else if (pid < 0) {
// --- FORK FAILED ---
// This happens if the system runs out of memory
perror("indieshell error");
} else {
// --- WE ARE IN THE PARENT PROCESS ---
// 3. Wait for the child to die
do {
wpid = waitpid(pid, &status, WUNTRACED);
} while (!WIFEXITED(status) && !WIFSIGNALED(status));
}
return 1; // Return 1 to keep the shell loop running
}
Let’s trace what happens when you type ls:
- Parent shell calls
fork(). A child shell is born. - The child shell calls
execvp("ls", ["ls", NULL]). The child shell ceases to exist and becomes thelsprogram. - The
lsprogram lists the directory to the screen, finishes its task, and exits. - The Parent shell, which was waiting at
waitpid, detects the child has exited. It breaks out of the wait loop and returns1, prompting the user for the next command!
8. Step 5: The Exception – Built-in Commands
If you compile and run the shell right now, commands like ls, pwd, and echo will work perfectly. But if you type cd .., nothing will happen!
Why does cd fail?
Remember our architecture. When you run a command, the shell creates a child process. If you run cd via exec, the child process changes its directory, and then immediately dies. The parent shell’s directory remains completely unchanged.
Commands that modify the state of the shell itself (like changing directories, or exiting the shell) cannot be handed off to a child process. They must be executed directly by the parent shell. These are called Built-in Commands.
Let’s implement cd and exit.
C
// Forward declarations for built-in functions
int shell_cd(char **args);
int shell_exit(char **args);
// Array of built-in command names
char *builtin_str[] = {
"cd",
"exit"
};
// Array of function pointers mapping to our functions
int (*builtin_func[]) (char **) = {
&shell_cd,
&shell_exit
};
// Helper function to get the number of built-ins
int shell_num_builtins() {
return sizeof(builtin_str) / sizeof(char *);
}
// Built-in function implementations
int shell_cd(char **args) {
if (args[1] == NULL) {
fprintf(stderr, "indieshell: expected argument to \"cd\"\n");
} else {
// chdir is a system call that changes the current working directory
if (chdir(args[1]) != 0) {
perror("indieshell");
}
}
return 1;
}
int shell_exit(char **args) {
return 0; // Returning 0 will break the main shell loop!
}
Now, we need a middleman function to decide whether to run a built-in command or to launch a new process.
C
int execute_command(char **args) {
int i;
// An empty command was entered (user just pressed enter)
if (args[0] == NULL) {
return 1;
}
// Check if the command matches any of our built-ins
for (i = 0; i < shell_num_builtins(); i++) {
if (strcmp(args[0], builtin_str[i]) == 0) {
// Call the matched built-in function
return (*builtin_func[i])(args);
}
}
// If it's not a built-in, launch it as a separate process
return launch_process(args);
}
9. Putting It All Together: The Complete Source Code
Here is the entire, fully functional shell.c code. This is production-ready for an academic submission.
C
#include <sys/wait.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
// --- BUILT-IN COMMAND DECLS ---
int shell_cd(char **args);
int shell_exit(char **args);
char *builtin_str[] = {"cd", "exit"};
int (*builtin_func[]) (char **) = {&shell_cd, &shell_exit};
int shell_num_builtins() {
return sizeof(builtin_str) / sizeof(char *);
}
// --- BUILT-IN IMPLEMENTATIONS ---
int shell_cd(char **args) {
if (args[1] == NULL) {
fprintf(stderr, "indieshell: expected argument to \"cd\"\n");
} else {
if (chdir(args[1]) != 0) { perror("indieshell"); }
}
return 1;
}
int shell_exit(char **args) {
return 0;
}
// --- PROCESS LAUNCHER ---
int launch_process(char **args) {
pid_t pid, wpid;
int status;
pid = fork();
if (pid == 0) {
// Child process
if (execvp(args[0], args) == -1) {
perror("indieshell");
}
exit(EXIT_FAILURE);
} else if (pid < 0) {
// Error forking
perror("indieshell");
} else {
// Parent process
do {
wpid = waitpid(pid, &status, WUNTRACED);
} while (!WIFEXITED(status) && !WIFSIGNALED(status));
}
return 1;
}
// --- COMMAND EXECUTION ROUTER ---
int execute_command(char **args) {
if (args[0] == NULL) return 1;
for (int i = 0; i < shell_num_builtins(); i++) {
if (strcmp(args[0], builtin_str[i]) == 0) {
return (*builtin_func[i])(args);
}
}
return launch_process(args);
}
// --- PARSER ---
#define LSH_TOK_BUFSIZE 64
#define LSH_TOK_DELIM " \t\r\n\a"
char **split_line(char *line) {
int bufsize = LSH_TOK_BUFSIZE, position = 0;
char **tokens = malloc(bufsize * sizeof(char*));
char *token;
if (!tokens) {
fprintf(stderr, "indieshell: allocation error\n");
exit(EXIT_FAILURE);
}
token = strtok(line, LSH_TOK_DELIM);
while (token != NULL) {
tokens[position] = token;
position++;
if (position >= bufsize) {
bufsize += LSH_TOK_BUFSIZE;
tokens = realloc(tokens, bufsize * sizeof(char*));
if (!tokens) {
fprintf(stderr, "indieshell: allocation error\n");
exit(EXIT_FAILURE);
}
}
token = strtok(NULL, LSH_TOK_DELIM);
}
tokens[position] = NULL;
return tokens;
}
// --- READER ---
char *read_line(void) {
char *line = NULL;
size_t bufsize = 0;
if (getline(&line, &bufsize, stdin) == -1){
if (feof(stdin)) {
exit(EXIT_SUCCESS);
} else {
perror("readline");
exit(EXIT_FAILURE);
}
}
return line;
}
// --- MAIN LOOP ---
void shell_loop(void) {
char *line;
char **args;
int status;
do {
printf("indieshell> ");
line = read_line();
args = split_line(line);
status = execute_command(args);
free(line);
free(args);
} while (status);
}
int main(int argc, char **argv) {
// Basic startup configuration can go here
shell_loop();
return EXIT_SUCCESS;
}
How to Compile and Run
Open your Linux/macOS terminal and type:
Bash
gcc shell.c -o indieshell
./indieshell
Boom! You are now operating entirely inside a command-line interpreter that you built from scratch.
10. Common Pitfalls & Advanced Pro-Tips
As an engineering student aiming for top placements, writing the code is only half the battle. You need to know why it works and what can break it. Interviewers love testing your deeper knowledge of these concepts.
A. The “Zombie Process” Problem
What happens if the parent process forgets to call wait()? The child process will finish its task and terminate, but its entry in the operating system’s Process Table cannot be removed until the parent acknowledges its death. This dead but unacknowledged process is called a Zombie Process. A system flooded with zombies will eventually crash because it runs out of PIDs. Always wait() on your children!
B. The “Orphan Process”
Conversely, what happens if the parent process crashes or is killed before the child process finishes? The child continues running, but it has no parent. This is an Orphan Process. In Linux, the mighty init (or systemd) process (PID 1) automatically adopts all orphan processes and safely waits on them when they finish, preventing them from becoming zombies.
C. Memory Leaks
Notice in our main loop that we specifically call free(line) and free(args) at the end of every iteration. The getline() and malloc() functions request memory from the OS Heap. If you do not explicitly free this memory, your shell will consume more and more RAM every time you type a command until the computer freezes. This is the hallmark of amateur C programming. Always manage your memory!
11. Frequently Asked Questions (FAQs)
Q1: What is the difference between execvp, execl, and execv?
Answer: They are all part of the same exec family, but they handle arguments differently. The v means arguments are passed as a Vector (an array of string pointers), which is ideal for our parsed tokens. The l means arguments are passed as a List (comma-separated). The p is crucial: it tells the OS to search the system’s PATH environment variable to find the program (so you can just type ls instead of /bin/ls).
Q2: How do I add support for background processes (using &)?
Answer: To support running processes in the background (e.g., sleep 10 &), you need to modify your parser to check if the last token is &. If it is, you remove the & from the arguments, call fork(), but in the parent process, you skip the wait() function. The parent immediately prints the prompt again while the child runs asynchronously. You will also need a signal handler (SIGCHLD) to eventually reap the background process when it finishes to prevent zombies.
Q3: Can I run this C shell on Windows?
Answer: Directly? No. Functions like fork(), execvp(), and waitpid() are POSIX standard system calls native to Unix-like operating systems (Linux, macOS). Windows handles process creation entirely differently using the Win32 API (CreateProcess). To run this code on Windows, you must use WSL (Windows Subsystem for Linux), Cygwin, or a Virtual Machine.
Q4: How do pipelines (like ls | grep txt) work?
Answer: Piping is a more advanced OS topic. To implement a pipe |, the shell creates a uni-directional data channel in memory using the pipe() system call. It then forks two child processes. It uses dup2() to connect the standard output (stdout) of the first process (e.g., ls) to the write-end of the pipe, and connects the standard input (stdin) of the second process (e.g., grep) to the read-end of the pipe.
Also, read How to Implement a linked list with all basic operations in C++
