The goal was not just "have a working shell." The goal was to use building a shell as a vehicle to revise core OS/Linux concepts — processes, file descriptors, syscalls, signals — the same concepts a university OS course or the well-known 42 School "minishell" project are built around, in a resume-worthy, from-scratch way.
The explicit working agreement for how this project was built:
- Requirements were decided before code, one step at a time.
- For each step: the concept/requirement was explained first (why this piece exists in a real shell), then it was designed, then code for only that step was written — modular, one concern at a time.
- No jumping ahead. Each step was tested and confirmed working before moving to the next.
- The code itself was generated by an LLM. The learning was in deciding what to build and why, reading and questioning the generated code, choosing the scope, finding/understanding bugs, and connecting each piece back to the underlying OS concept. This document is the record of that reasoning — not a claim that the code was hand-typed.
Before writing anything, three scoping questions were answered:
- Language: C — chosen specifically because it stays close to
real syscalls (
fork,execve,pipe,dup2), unlike a higher-level language that would hide them. - How much of the full 42-style "minishell" spec to attempt:
rather than either the full spec (very large) or a toy version, a
deliberately reduced but still "real" scope was chosen — every
core OS concept, without the long tail of bash edge cases that add
grind but not learning:
- In scope: REPL + readline, lexer/parser with quoting,
$VAR/$?expansion,fork/execve/PATHexecution, builtins (cd,pwd,echo,export,unset,env,exit), pipes, redirections (<,>,>>,<<), signal handling, exit-status propagation, error handling. - Out of scope (stretch goals, skipped on purpose):
&&/||, wildcards (*), subshells (()), a fully customenvparray.
- In scope: REPL + readline, lexer/parser with quoting,
- Starting comfort level with C/syscalls: already comfortable — so the priority was structure and sequencing of the build, not basic C syntax.
Each step below follows the same three-part shape: what we built, a challenge/bug actually hit, what it taught.
Built: A Makefile (-Wall -Wextra -Werror, links -lreadline)
and a loop that prints a prompt, reads a line with readline(), adds
it to history, and exits on exit or Ctrl-D.
Challenge: Distinguishing "empty line" from "end of input."
readline() returns an empty string "" for a blank Enter press, but
returns NULL specifically on EOF (Ctrl-D / closed input stream).
Treating those the same would make the shell either never exit on
Ctrl-D, or exit on every blank line.
Learned: EOF is a real, distinct signal at the syscall level too —
read() returns 0 at end-of-stream, not an error and not "empty
data." readline's NULL is the library-level version of the same
idea. Also: why a Makefile matters at all — once there's more than one
translation unit, make recompiles only what changed and relinks
consistently, instead of a manually-typed gcc command per file.
Built: Converts a raw input line into a linked list of tokens:
TOKEN_WORD, TOKEN_PIPE, TOKEN_REDIR_IN, TOKEN_REDIR_OUT,
TOKEN_APPEND, TOKEN_HEREDOC. Handles 'single' and "double"
quotes, including quoted and unquoted text glued into a single word
(ab"cd"ef → one token abcdef).
Challenge: Quoting isn't just "strip the quote characters" —
which quote was used has to be remembered per-character, because it
changes what's allowed to happen later (expansion). An unterminated
quote (echo 'oops) also has to be detected and reported as a syntax
error instead of reading past the end of the string.
Learned: Real shells track quoting as metadata alongside the
text, not just as a one-time strip. That's why each token carries an
expand_mask string (per-character: was this char inside single
quotes?) in parallel with its value — a small preview of how real
parsers separate "the data" from "the rules about the data."
Built: Turns the flat token list into a pipeline of t_cmd nodes
— each with an argv array and a linked list of redirections.
Detects syntax errors: empty command around a pipe (| cmd, cmd |),
and a redirection with no target (cmd < with nothing after it).
Challenge: Deciding where validation belongs. Catching "no command between two pipes" has to happen while walking tokens (there's no tree yet to inspect afterward), which meant tracking "has the current command accumulated anything yet" as parsing proceeds, not as a separate pass.
Learned: Parsing and validation are naturally interleaved for a simple grammar like this — you don't always need a two-pass "parse-then-validate" design; the shape of the input tells you when an error condition (like an empty command) has become impossible to fix later.
Built: Walks each word's characters alongside its quote mask and
substitutes $? → last exit status, $NAME → getenv("NAME") (or
empty if unset), while leaving anything inside single quotes
untouched.
Challenge: The expanded result can be a completely different
length than the original word ($? might become "127", an unset
$FOO becomes "") — so it can't be built in-place in the original
buffer. It also has to not expand $ inside single quotes, but
must expand it inside double quotes — two different rules living in
the same word.
Learned: Why '...' and "..." are genuinely different in every
real shell (not just cosmetic): single quotes mean "no substitution at
all," double quotes mean "still substitute variables." Also a small,
concrete lesson in dynamic-buffer growth (t_dstr: doubling capacity
instead of reallocating one byte at a time).
Built: find_executable() resolves a command name against $PATH
(or uses it directly if it contains a /); a child process is
fork()'d, and execve() replaces its image with the target program.
Challenge: Understanding that execve replaces the calling
process rather than starting a new one alongside it — so anything
after a successful execve() call in the code is unreachable, and if
execve() returns at all, it already failed.
Learned: The fork → execve → wait pattern that essentially
every Unix shell, container runtime, and job scheduler is built on: to
run something and keep control of your own process, you must
duplicate yourself first (fork), then optionally replace the copy's
program image (execve), then collect its result (wait/waitpid).
Built: cd, pwd, echo, export, unset, env, exit — each
one dispatched and run in the shell's own process, never forked.
Challenge: Realizing why cd can't be forked. A forked child
has its own independent working-directory state; if cd ran in a
child, chdir() would change that throwaway child's directory and
have zero effect on the shell itself the moment the child exits.
Learned: The process/child boundary isn't just about
performance — some operations are only meaningful if they mutate the
calling process's own state (current directory, environment
variables, or terminating the shell itself via exit). This is a
direct, hands-on encounter with the idea of process state as
something that doesn't automatically propagate from parent to child or
back.
Built: execute_pipeline() — wires 1..N commands together with
pipe() + dup2(), forking one child per command, closing unused fd
ends, and waiting for all of them.
Challenge: Getting the order of operations right in each child:
duplicate the correct pipe end onto stdin/stdout before closing
any pipe fds, and close every pipe fd the child doesn't need — a
forgotten close() on the write end of a pipe is a classic way to
make a reader hang forever (it never sees EOF because a writable
copy of the fd is still open somewhere).
Learned: A pipe's "EOF" isn't automatic — it happens when every copy of the write-end fd across every process is closed. This is the core of why pipeline code always looks like "dup2, then close everything else, in every child."
Built: apply_redirs() — opens the right fd for <, >, >>,
duplicates it onto fd 0 or fd 1 via dup2(). Multiple redirections of
the same direction on one command correctly let the last one win
(matching bash).
Real bug hit — worth remembering: printf()/echo output doesn't
go to the file descriptor immediately — it sits in a userspace buffer
inside libc (stdio buffering) and flushes later. When a builtin ran
with a redirect and then the original stdout was restored via
dup2(), that restore happened before the buffer had flushed — so
the text ended up going to the now-restored terminal fd instead of the
file it was supposed to go to. Fix: fflush(stdout) while the
redirect is still active, before restoring the original fd.
Learned: "Write" at the C standard-library level and "write" at the syscall level are two different things with a buffer sitting between them. This is a genuinely common real-world class of bug, not a toy-project quirk.
Built: read_heredoc_body() reads lines interactively until one
exactly matches the delimiter, writes the captured body to a temp file
(tmpfile()), and dup2()s that onto stdin. Quoted delimiters
(<<'EOF') disable $VAR expansion inside the body; unquoted ones
allow it — matching bash exactly.
Challenge: Deciding when to collect the heredoc body.
collect_heredocs() runs in the parent shell, before any fork(),
specifically because a forked child's stdin might already be wired to
a pipe by the time it exists — the terminal has to be read from while
the shell itself still owns fd 0.
Learned: Why a temp file was used instead of a live pipe: a pipe has a limited kernel buffer, so a large heredoc body could deadlock (the writer blocks on a full pipe, waiting for a reader that hasn't started yet). A temp file has no such size ceiling.
Built: Two signal "modes": set_signals_interactive() while
sitting at the prompt (Ctrl-C redraws the prompt instead of quitting;
Ctrl-\ is ignored, matching bash), and set_signals_executing()
while a child runs (the shell ignores both signals; each child resets
them to default right after fork(), so it is the one that reacts).
Challenge: Getting Ctrl-C to kill a running child without
killing the shell that's waiting on it — the naive approach (shell and
child sharing the same signal disposition) kills both.
Learned: Signal dispositions are per-process and are inherited at
fork() time, then can diverge — this is exactly how real shells give
"the thing you're running" and "the shell itself" independent
reactions to the same keypress. Also: exit-status convention
128 + signal_number (e.g. Ctrl-C → SIGINT (2) → exit code 130) is
bash's real convention, not something invented for this project.
Built: Fixed a real, reproducible crash: a line that is only a
redirection with no command at all (> file.txt) segfaulted, because
the code path called is_builtin(argv[0]) while argv was NULL (no
words were ever added to it). Added a dedicated apply_redirs_only()
path for this case, applied the same guard inside a pipeline stage
(> file.txt | cat), and added checks for pipe()/fork() failing
(instead of assuming they always succeed and later calling waitpid()
on a garbage/negative pid).
Challenge: This bug only shows up for an input that's syntactically valid in real bash but easy to overlook when you're mentally testing "a command, maybe with redirections" — not "redirections with zero commands." Finding it required deliberately trying inputs shaped like edge cases, not just the happy path.
Learned: NULL propagation is the most common crash shape in C —
argv being NULL is a valid, reachable state in this parser (a
command can have redirections and no words), and every place that
reads argv[0] has to either know that's impossible for that call
site, or check first. This is the same class of bug that null-pointer
checks and defensive programming exist to catch in any C codebase.
Running make on this project actually goes through four distinct
stages of the C compilation pipeline (this is what the single gcc
invocation in the Makefile hides):
- Preprocessing —
#includes are textually inlined, macros expanded.<stdio.h>,<readline/readline.h>, etc. get pulled in here. - Compilation — the preprocessed C source is translated into
assembly for the target architecture. This is where
-Wall -Wextra -Werrordoes its job: warnings about implicit conversions, unused variables, or missing prototypes are caught here, and-Werrorpromotes every one of them to a hard build failure — which is why keeping the build "clean" (zero warnings) was treated as a hard requirement throughout, not a nice-to-have. - Assembly — the assembly is turned into machine code, producing
an object file (
main.o) — machine code plus a table of symbols this file defines and symbols it needs but doesn't define (likereadline,execve,fork). - Linking — the object file(s) are combined with the needed
libraries.
-lreadlinetells the linker to resolve thereadlinesymbol againstlibreadline; the C library (fork,execve,pipe,dup2,waitpid, ...) is linked in automatically. Output: the finalminishellexecutable.
make's actual value here: after the first build, only files whose
source changed get re-compiled; unaffected object files are reused
and just relinked. For a single-file project this is a smaller win,
but it's the same mechanism that makes larger multi-file C/C++
projects buildable at all in reasonable time.
- Process creation & replacement:
fork()duplicates the calling process;execve()replaces a process's program image in place;waitpid()reaps a child and retrieves its exit status. - File descriptors as the universal handle: stdin/stdout/stderr
are just fds 0/1/2 by convention —
dup2()is how both pipes and redirections work, because both are really "make this fd number point at that open file/pipe end instead." - Pipes & EOF semantics: a pipe's read end sees EOF only once every writable copy of the write end, across every process, is closed — not automatically when "the writer is done."
- Signal disposition & inheritance: signal handlers/dispositions
are per-process, inherited at
fork(), and can be changed independently afterward — the mechanism behind "Ctrl-C kills the child, not the shell." - Exit status conventions: normal exit → the given code (0–255);
killed by signal →
128 + signal number(bash's convention, reproduced here). - Environment as inherited process state: child processes get a
copy of the parent's environment at
execve()time — which is exactly whyexport/unsetin this shell must modify the shell's own long-lived environment, not something scoped to a child that will vanish anyway. - Buffered I/O vs. syscall I/O: the stdio layer (
printf,echo) buffers in userspace before it reaches an actualwrite()syscall — a distinct layer from the fd-level redirection work, and the source of thefflush()bug in Step 8.
The code in main.c was generated by an LLM, one modular step at a
time. That's stated plainly rather than glossed over. What made this
a real learning exercise rather than copy-pasting an answer:
- Every step's requirement was decided first, based on "what does a real shell need to do here, and why" — not by asking for a finished project.
- Each generated piece was tested immediately (piped test inputs,
valgrind, deliberately-broken inputs) before moving on, and several real bugs (the stdio-buffering issue, theNULLargv crash, uncheckedfork/pipefailures) were found and understood by testing rather than assumed away. - The reasoning connecting each step to an OS/POSIX concept — the content of this document — is the actual deliverable of the learning process; the compiled binary is a side effect of it.
Implemented and tested: REPL, lexer, parser, expansion, execution, builtins, pipes, redirections (including heredocs), signal handling, and error hardening against the crashes described above.
Deliberately not implemented (see README.md for the reasoning):
&&/||, wildcards, subshells, a fully custom envp array. The
project was intentionally stopped here — every core OS concept on the
original roadmap had been exercised, and further scope would have
added bash-compatibility edge cases without adding new concepts.