Building a Simple Shell
Lesson 3 of 3
3Building a Simple Shell (C - Simple Shell)
The most complex project in this track: building a shell program from scratch that reads commands and runs them, a direct application of everything you learned about processes in the operating system.
What the picture shows: The shell prints the prompt and reads a line, copies itself with fork, the child becomes the program with execve, and the parent waits, then loops.
Two ways into the same idea
The shell you have typed commands into all along is not magic: it is a program repeating three steps forever: read what you typed, find the requested program, ask the system to run it and wait. In this project you write your own small shell.
A shell is a REPL loop: read the line with getline, split it into words, search the PATH, then fork a child process, execve the program in it, and wait in the parent. You add built-ins like exit and env and handle end of file (EOF) and errors.
Before You Start
🖥️Shell fundamentals
Review Part Three of the Foundations track (the shell as a system interface) before this project.
The Core Idea: Three System Calls That Build Any Shell
Figure 1, this triple loop (fork → exec → wait) is the core of every shell, from bash to the simplest teaching shell.
A shell, at its core, is a loop that reads text and has to turn that text into an actually-running program, without becoming that program itself, and without losing control once it finishes. Three completely separate system calls are what make this possible, and each one solves a piece of this puzzle the other two can't.
fork() starts the sequence: it creates a full, exact duplicate of the current process. But "exact duplicate" doesn't mean the kernel actually copies every byte of the parent's memory the instant the call happens, that would be hugely wasteful for a process that might call exec and throw all of that memory away a fraction of a second later. What actually happens on Linux is copy-on-write: the kernel duplicates the memory page tables and creates a new process structure, but the underlying memory pages themselves stay shared between parent and child until either one tries to write to a given page, only at that moment is that specific page actually copied, so the writer gets its own private copy. The practical consequence that matters most to you as a programmer: fork() is the only call in C that "returns twice", once with the child's PID in the parent process, and once with the value 0 in the child process itself (or -1 in the parent alone if the call failed entirely and no child was created at all). At the moment fork returns, both processes are identical in absolutely everything else, same code, same variable values, the exact same point of execution, which is precisely why the pid == 0 check is the only branching point: the value of pid is the only signal available telling you which copy you're actually in.
execve() (and the wider exec family) solves a completely different problem: the child process now exists, but it's still running the shell's own code, it needs to "become" the requested command. execve completely replaces the process image (executable code, data segment, stack) with an entirely different program, while the process keeps the same PID and the same table of open file descriptors. And because the replacement covers everything that belonged to the old program, execve never returns a value on success at all, not because the designers chose that arbitrarily, but because there is genuinely no "old code" left to return into; the piece of memory the processor would have resumed executing in simply doesn't exist anymore after the replacement. That's why any code written after execve in the source (like a perror call) only ever runs in the one case where execve actually failed and no replacement happened. The subtle point that's easy to get wrong: execve does not search PATH at all, it requires a complete, ready-made path (absolute, like /bin/ls, or relative to the current working directory). Pass it the bare string "ls" directly and it fails, because no file literally named that exists in the current directory. The PATH-searching behavior you actually rely on when you type ls in a terminal isn't part of execve at all, it's the shell's own responsibility (and exactly what your own shell is expected to do: walk PATH, build each candidate full path, and only once it finds the real file does it call execve with that resolved full path), or, if you use execvp/execlp instead of raw execve, the C library performs that same search internally on your behalf and calls execve itself the moment it finds a match.
waitpid() solves the last piece: it pauses the parent process until a specific child finishes, retrieving its exit code. Without this call, it isn't just "messy output", a child process that exits before its parent collects its exit status via wait/waitpid becomes, quite literally, a "zombie process": its entry stays reserved in the kernel's process table, holding only its exit code, waiting for the parent to come collect it. That's not merely a cosmetic annoyance, every zombie actually occupies a slot in a finite-size process table, so a shell that ran thousands of commands without ever reaping them could, in principle, exhaust that table entirely. In a simple shell that calls waitpid immediately after each fork for each command, a zombie exists only for the brief instant between the child actually exiting and the parent's waitpid call completing, nothing more.
Finally, searching PATH itself is exactly what fills the gap execve leaves by not searching it: the PATH variable is a single string containing directory paths separated by colons, like /usr/bin:/bin:/usr/local/bin. The shell takes each path in order, appends the typed command name to it (producing e.g. /usr/bin/ls), and checks whether a file actually exists at that path with execute permission; the moment it finds one, it calls execve with that exact full path and stops searching immediately without checking the remaining directories; and if every path listed in PATH runs out with no match, it prints a "command not found" error instead of calling execve with a string that was never going to resolve to anything.
Figure 2, searching PATH checks each directory in order until it finds the first matching executable file, stopping immediately at the first match.
Figure 3, execve never searches PATH at all and requires a complete, ready-made path; the search shown in the previous diagram has to be written by the shell itself before calling it, which is exactly what execvp/execlp do internally on your behalf if you use them instead of calling execve directly.
How It Works, Step by Step: The Core Shell Loop
#include <unistd.h>
#include <sys/wait.h>
void shell_loop(void)
{
char *line;
char **args;
pid_t pid;
int status;
while (1)
{
printf("$ ");
line = read_line(); /* read a command line from the user */
args = parse_line(line); /* split it into words: the command and its args */
pid = fork();
if (pid == 0)
{
/* this code runs only in the child process */
execve(args[0], args, NULL);
perror("execve failed"); /* only reached if execve failed */
exit(1);
}
else
{
/* this code runs only in the parent process (the shell) */
waitpid(pid, &status, 0); /* wait for the child before showing the next prompt */
}
}
}
Figure 4, Notice the pid == 0 check: this is the only difference between the parent's and the child's code path after fork, the same source code, but pid's value decides which branch runs in each process.
Let's trace what actually happens when the user types ls into this shell. At the fork() call, one process instantly splits into two completely identical processes standing at exactly the same point right after the call, the only difference between them is the returned value: the child process receives 0, and the parent process receives the child's actual PID (a positive integer). The condition if (pid == 0) is what uses that one difference to route each process down its correct path: the child enters this branch and calls execve(args[0], args, NULL), and since args[0] here is literally the string "ls" exactly as the user typed it, not a full path, execve will fail immediately (because, as explained above, it never searches PATH) unless the shell itself already resolved args[0] into a full path like /bin/ls before reaching this exact line, which is precisely the role the PATH-search step is supposed to play before execution gets here. If execve does succeed, then everything written after that call (the perror call and beyond) becomes dead code in a process that, in its old form, no longer exists at all, it never runs. The parent process, meanwhile, takes the else branch and calls waitpid(pid, &status, 0) immediately, blocking right at that line until the child finishes (whether by running ls successfully or by failing at execve itself and exiting with code 1), and only once waitpid returns does the while (1) loop move on to its next iteration and print a fresh $ prompt. The absence of exactly this one call is what separates a shell that behaves correctly from one that prints overlapping prompts while its previous commands are still running in the background with no coordination at all.
waitpid in the parent process. Without it, the shell prints the next prompt immediately while the previous command is still running in the background, and "zombie processes" whose exit code was never collected can accumulate.
Deep Dive: How a Shell Works Inside
A shell is one program that gathers everything you have learned about processes, the environment and files. We detail its pillars one by one, with diagrams explaining the flow, without any ready-made code for the tasks.
A short history
Unix was born at Bell Labs through Ken Thompson and Dennis Ritchie; Thompson wrote the first Unix shell (the Thompson shell) and also created the B language that directly preceded C. Knowing this chain is part of the learning objectives: the language, the system and the shell were born almost together.
Processes: PID and PPID
A process is a running instance of a program and has a unique number (PID). Every process has a parent (PPID), the process that created it. Each command you run from the shell becomes a new process whose parent is the shell. That is why a program printing its PPID repeatedly inside the same shell shows the same number each time, while the PID changes on every run.
The trio: fork, execve, wait
fork clones the current process so two processes continue from the same point: fork returns 0 to the child and the child’s PID to the parent. The child calls execve to replace itself entirely with another program (execve returns only on failure). The parent calls wait to pause until its child ends and learn how it ended.
Figure 5, The life of one command: the parent forks, the child transforms, the parent waits.
Forgetting that parent and child share the same code after fork: if you do not tell them apart by the return value, both run the same part. And if execve fails in the child, the child must exit, otherwise it lingers as a second copy of the shell.
How the shell finds the program: PATH
If the user types a path containing a slash (like /bin/ls) the shell uses it as is. If they type a bare name (like ls) the shell walks the directories listed in the PATH variable (separated by colons) in order, joins each directory with the name, checks whether the file exists and is executable (with calls such as stat or access), and runs the first hit. If nothing is found it prints a "not found" message with your program’s name and the line number.
Figure 6, The program lookup algorithm: a direct path or a search through PATH.
The environment and main’s arguments
Every process has an environment: an array of NAME=value strings ending in NULL, a copy of which is inherited by children. You reach it two ways: the global variable environ, or main’s third parameter. That is why main has three forms: no parameters, (ac, av), or (ac, av, env). av is a NULL-terminated array whose av[0] is the program name as invoked, which you use in error messages. When calling execve, pass the environment to the child so it inherits PATH and the rest.
Built-ins vs. external commands
| Kind | Examples | How it runs | Why |
|---|---|---|---|
| External | ls, cat | A new child then execve | A separate program on disk |
| Built-in | cd, exit, env | Inside the shell process itself | It must change the shell’s own state |
Consider why cd is a built-in: run in a child, it would change only the child’s directory and then the child dies, leaving the shell where it was. Changing the working directory belongs to the process doing it, so the shell process itself must do it. The same logic applies to modifying environment variables (setenv and unsetenv) and to exiting.
Interactive vs. non-interactive mode and EOF
In interactive mode the shell prints a prompt and waits for you to type. In non-interactive mode commands arrive from a pipe or file (like echo "ls" | ./hsh) and no prompt is printed. isatty tells them apart, and the shell must behave with identical output in both. EOF (end of input, sent with Ctrl+D) shows up as a failed line read, and the shell must then exit gracefully, freeing its memory.
Reading and splitting the line
The ready-made getline reads a whole line and returns -1 at EOF. If you are asked to write your own: each read call is expensive, so read a large block into a static buffer then hand it out line by line to reduce the number of calls. After reading, the line is split into words at spaces (strtok, for example) to form the argument array passed to execve.
Exit status and wait
When the child ends, wait stores its status in a variable you pass; from it you learn whether it ended normally and its exit code. Code 0 means success and any other number means an error. This status underlies the logical operators of the advanced features. If a child dies without its parent waiting, it remains a "zombie" occupying a slot in the process table.
Advanced features: a conceptual look
| Feature | The idea |
|---|---|
; | Run commands in sequence regardless of outcome |
&& | Run the next only if the previous succeeded (code 0) |
|| | Run the next only if the previous failed |
| alias | A replacement name expanded into command text before execution |
| Variables | $? the last exit code, $$ the shell’s PID, and $PATH an environment variable’s value |
| Comments | Everything after # is ignored |
| history | A record of earlier commands |
| File as input | Reading commands from a file passed as an argument |
Figure 7, The complete shell loop: read, split, expand, then run internally or externally.
Try it yourself
What sets fork apart from other C calls?
After fork the two processes are identical except for the return value, which is the only way to tell which copy you are.
What happens to the lines after a successful execve?
execve replaces the process image with the new program and returns only on failure, so only error handling follows it.
A teaching shell forgets waitpid in the parent. What should you expect?
waitpid pauses the parent until the child finishes. Without it, output interleaves and orphan processes can pile up.
Quick Review Flashcards
Check Your Understanding
If you checked pid == 0 but wrote the code in reverse order (execve in else instead of if), what happens?
execve would run in the parent process instead of the child, the shell itself would be replaced by the command's program and disappear, instead of the parent staying alive to wait and show a new prompt later.
You run a command and the prompt reappears before it finishes; what is the likely cause?
The parent is not waiting for its child: wait is missing or called in the wrong place.
Resources for this part
Sorted by kind: start with the official docs when you want the precise reference, or with an article or video when you want the explanation first.
Resources for this lesson
Grouped by type. Start with the official docs for precision, or an article or video for the explanation.
Official docs2
Done with this lesson?
Sign in to save your progress.