What a shell actually does
Strip away the fancy features — tab completion, history, job control —
and a shell is this loop: read a line, parse it into a command and arguments,
fork a child, exec the command in the child, wait for it to finish, repeat.
Output redirection is just dup2 before exec. Pipes are two forks
sharing a pipe fd. Built-in commands like cd run in the shell
process itself (because forking and exec-ing cd would change the
child's directory, not the shell's).
Part 1: the main loop
Part 2: parsing and running a simple command
This handles simple commands. execvp searches PATH
automatically — so ls finds /bin/ls without you
specifying the full path.
Part 3: output redirection
To handle cmd > file, scan the argument list for >,
pull out the filename, open it, and dup2 it over fd 1 before calling exec.
Part 4: pipes
For cmd1 | cmd2, split the line on |, create a pipe,
fork twice. The left child writes to the pipe's write end (via dup2);
the right child reads from the read end.
What this shell doesn't handle
Real shells are much more complex. This mini shell omits:
- Job control — Ctrl+Z suspending a foreground job,
fg/bg, managing process groups and terminal ownership. - Signal handling — the shell should ignore SIGINT in the parent so Ctrl+C only kills the foreground child, not the shell itself.
- Multiple pipes —
a | b | crequires chaining pipe fds across three processes. - Quoting and escaping —
"hello world"as a single argument,\nin strings. - Environment variables, globbing, history — each of these is a project in itself.
dash (the Debian Almquist shell, used as /bin/sh on Ubuntu)
is about 14,000 lines and handles essentially everything. It's well-organized
and readable. Compare its forkshell() to your fork, and
redirect() to your dup2 — the concepts are identical, just with
all the edge cases filled in.
A shell is a loop of read → parse → fork → exec → wait, with pipes and redirection implemented entirely through file descriptor manipulation before exec.