You type ls, press enter, and a program runs. The shell that made that happen feels like deep operating-system magic. It isn't. A real, working shell, one that can run any program on your system, is about 30 lines of C, and writing it teaches you the single most important pattern in operating systems: how one program starts another.
That pattern is three system calls: fork, exec, and wait. Every program you have ever launched, from your editor to your browser, was started this way.
The one idea: fork then exec
To run a program, a Unix shell does something that sounds strange the first time: it clones itself, and then the clone replaces itself with the program you asked for.
-
fork()creates a near-identical copy of the current process (the child). Now there are two processes running the same code. -
exec()replaces the calling process's memory with a new program. The child stops being a copy of the shell and becomesls. -
wait()lets the parent (the shell) pause until the child finishes, so it can prompt you again.
Clone, transform, wait. That separation, fork to make a process, exec to choose what it runs, is the deepest idea in how operating systems launch programs.
The loop
A shell is a read-eval-print loop: read a line, run it, repeat.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
char line[1024];
while (1) {
printf("> ");
if (!fgets(line, sizeof line, stdin)) break; // Ctrl-D / EOF: exit
line[strcspn(line, "\n")] = '\0'; // strip the newline
if (line[0] == '\0') continue; // empty line
// split the line into words: args[0]=command, rest=arguments
char *args[64];
int n = 0;
for (char *tok = strtok(line, " "); tok && n < 63; tok = strtok(NULL, " "))
args[n++] = tok;
args[n] = NULL; // execvp needs a NULL terminator
if (strcmp(args[0], "exit") == 0) break; // a builtin, handled by the shell itself
run(args);
}
return 0;
}
Two details that matter already:
-
argsmust end inNULL.execvpreads the argument list until it hits a NULL pointer. Forget it and you get garbage or a crash. -
exitis a builtin. It can't be a separate program, because a child process exiting wouldn't end the shell. Builtins are commands the shell must run itself, the same reasoncdhas to be a builtin (a child can't change the parent's directory).
The heart: fork, exec, wait
void run(char **args) {
pid_t pid = fork(); // clone this process
if (pid == 0) {
// --- child: become the requested program ---
execvp(args[0], args); // replaces this process; searches PATH for args[0]
perror("exec"); // only runs if exec FAILED (e.g. command not found)
_exit(127);
} else if (pid > 0) {
// --- parent (the shell): wait for the child to finish ---
waitpid(pid, NULL, 0);
} else {
perror("fork"); // fork itself failed
}
}
This is the whole engine. Compile it (cc shell.c -o myshell), run ./myshell, and you have a prompt that executes ls, echo hello, cat file.txt, any program on your PATH.
Three details that matter, and they are the entire concept:
-
fork()returns twice. Once in the parent (returning the child's PID, a positive number) and once in the child (returning 0). That single return value is how each process knows which one it is. This is the line that surprises everyone the first time. -
The code after
execvponly runs on failure. A successfulexecreplaces the process, so there is no "after." Reachingperror("exec")means the command wasn't found, which is exactly how your real shell prints "command not found." -
execvpsearchesPATHfor you (thep) and takes a vector of arguments (thev). That is why you can typelsinstead of/bin/ls.
What you just built, and where it goes
You have a real shell. The features you use every day are extensions of this core:
-
Pipes (
ls | grep x): create apipe(), fork two children, wire one's stdout to the other's stdin withdup2. -
Redirection (
> file): beforeexec,openthe file anddup2it onto stdout. -
Background jobs (
&): just don'twaitfor the child. -
Signals (Ctrl-C): handle
SIGINTso it interrupts the child, not the shell.
Every one of those is a small addition around the same fork/exec/wait skeleton. The terminal stops being magic once you've written the loop that launches a process, because that loop is, quite literally, how your operating system runs everything.
If you want to build the rest, pipes, job control, signals, a real mini-shell, that is one of the projects in the operating systems track, where you build the internals instead of just using them.













