warming up your workspace

Build your own shell, and learn how every program gets started

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 becomes ls.
  • 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:

  • args must end in NULL. execvp reads the argument list until it hits a NULL pointer. Forget it and you get garbage or a crash.
  • exit is 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 reason cd has 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 execvp only runs on failure. A successful exec replaces the process, so there is no "after." Reaching perror("exec") means the command wasn't found, which is exactly how your real shell prints "command not found."
  • execvp searches PATH for you (the p) and takes a vector of arguments (the v). That is why you can type ls instead 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 a pipe(), fork two children, wire one's stdout to the other's stdin with dup2.
  • Redirection (> file): before exec, open the file and dup2 it onto stdout.
  • Background jobs (&): just don't wait for the child.
  • Signals (Ctrl-C): handle SIGINT so 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.