notes / Systems Programming
Building a Minimal Unix Shell in C
Field notes from building a small Unix-like shell in C to understand process creation, command parsing, PATH lookup, environment handling, and low-level Linux behavior.
Why This Note Exists
This note documents the process of building a minimal Unix-like shell in C.
The project is valuable because a shell sits close to the operating system. Even a small version forces you to understand how Linux starts programs, passes arguments, handles environment variables, waits for child processes, and reports failures.
The goal was not to replace Bash or Zsh.
The goal was to understand the lower-level mechanics behind a command like:
ls -la
A normal user sees a command. A shell needs to:
read input
split arguments
find the executable
create a child process
run the command
wait for it
return to the prompt
That makes it a useful systems-programming note.
Project Context
The shell was built in C as a fundamentals project focused on Linux process behavior.
It belongs in a portfolio as a lower-level technical note, not as a business/client project.
The value is in showing understanding of:
- C programming
- Unix process creation
- command parsing
- executable lookup
- environment variables
- error handling
- memory allocation
- edge cases
- simple interactive programs
This project is different from infrastructure notes like WireGuard, OpenWrt, or game-server hosting. It shows the operating-system side of the stack.
What This Project Is Meant To Prove
- a shell is a process manager, not just a text prompt
- Linux command execution depends on
fork,exec, andwait - arguments must be parsed before execution
- commands without
/need PATH resolution - built-in commands must be handled by the shell itself
- memory must be allocated and freed carefully
- error messages should be predictable
- small C programs require discipline because there is no runtime safety net
Stack and Tools Used
Language and Runtime
- C
- Linux
- POSIX-style system calls
- GCC
- standard C library
System Calls and Functions
forkexecvewait/waitpidgetlinemallocfreestrtokor manual tokenizationaccessstat- environment variable handling
Shell Concepts
- prompt loop
- command parsing
- argument vector
- PATH lookup
- child process execution
- built-in commands
- exit status
- error handling
Intended Build
The intended build is a minimal shell that can:
- display a prompt
- read user input
- parse a command into arguments
- execute binaries
- search through PATH
- handle command errors
- support basic built-ins
- exit cleanly
- avoid obvious memory leaks
- work in interactive and non-interactive modes
A minimal shell does not need advanced features at first.
It does not need to support:
- pipes
- redirection
- job control
- command history
- tab completion
- scripting syntax
- aliases
- advanced quoting
Those are later layers.
Basic Shell Loop
The core shell loop is simple in concept:
while shell is running:
display prompt
read line
parse line into arguments
check if command is built-in
if built-in, run inside shell
else fork child process
child executes command
parent waits
This loop is the heart of the program.
The challenge is not writing the loop once. The challenge is handling all the weird cases around it.
Reading Input
A shell needs to read complete lines from standard input.
Using getline is practical because it can handle variable-length input.
The shell should handle:
- normal input
- empty lines
- end-of-file
- non-interactive input
- memory allocation failure
Example behavior:
$ ls
$
$ exit
If the user presses Ctrl+D, the shell should exit cleanly rather than crash.
Parsing Commands
Input like:
ls -la /tmp
needs to become an argument vector:
argv[0] = "ls"
argv[1] = "-la"
argv[2] = "/tmp"
argv[3] = NULL
The final NULL matters because execve expects a null-terminated argument array.
A simple tokenizer can split on spaces and tabs.
More advanced parsing would support quotes and escaping, but a minimal shell can start with simpler rules.
Executing Commands
If the command contains a path:
/bin/ls
the shell can try to execute it directly.
If the command does not contain a slash:
ls
the shell needs to search through PATH.
That means:
read PATH environment variable
split PATH by :
join each directory with command name
check if executable exists
run the first match
Example:
/usr/local/bin
/usr/bin
/bin
The shell checks:
/usr/local/bin/ls
/usr/bin/ls
/bin/ls
until it finds a valid executable.
Fork, Exec, Wait
The core Unix process model is:
fork creates a child process
exec replaces the child process image with the target program
wait lets the parent wait for the child to finish
The parent shell should not become the command.
Instead:
parent shell
↓ fork
child process
↓ execve("/bin/ls", argv, envp)
parent shell
↓ wait
prompt returns
This is why exec is usually called in the child process, not the parent.
If the parent calls exec directly, the shell disappears and becomes the command.
Built-in Commands
Some commands cannot be handled by simply running another executable.
Examples:
cd
exit
env
cd must change the working directory of the shell process itself.
If cd runs only inside a child process, the child changes directory and exits, but the parent shell remains in the old directory.
That is why built-ins are handled before fork.
A minimal built-in flow:
if command is "exit":
clean up and quit
if command is "cd":
call chdir in parent shell
if command is "env":
print environment variables
PATH Resolution
PATH resolution is one of the first places bugs appear.
The shell needs to handle:
- missing PATH
- empty PATH entries
- command with absolute path
- command with relative path
- executable exists but permission denied
- command not found
- directories mistaken for commands
A reliable shell should not assume every command lives in /bin.
It should use the environment.
Environment Handling
A shell passes environment variables to child processes.
For example, commands often depend on:
PATH
HOME
USER
PWD
SHELL
A minimal shell may use the inherited environment and pass it to execve.
If the shell supports modifying environment variables later, that becomes a bigger feature.
At minimum, the shell should understand that the environment is part of command execution.
Error Handling
Errors should be clear and consistent.
Common errors:
command not found
permission denied
no such file or directory
fork failed
malloc failed
execve failed
A bad shell crashes on these.
A better shell reports the error and returns to the prompt when possible.
Example behavior:
$ unknowncmd
unknowncmd: not found
$
The shell should stay alive after a failed command unless the failure is fatal.
Exit Status
Real shells track exit status.
A minimal version can start by waiting for the child process and reading its status.
Useful cases:
- command exits normally
- command exits with non-zero status
- command is killed by signal
execvefails
Even if the shell does not expose $?, understanding exit status is important.
Memory Management
C makes memory management part of the project.
The shell may allocate memory for:
- input line
- token array
- copied strings
- PATH directories
- full command path
- temporary buffers
Every allocation needs a cleanup path.
A common shell bug is leaking memory every loop.
A safer rule:
allocate for one command
execute command
free command resources
return to prompt
Long-running shells make leaks visible because the process does not exit after each command.
Interactive vs Non-Interactive Mode
A shell can run interactively:
./shell
$ ls
$ exit
or non-interactively:
echo "ls" | ./shell
Interactive mode usually shows a prompt.
Non-interactive mode often should not show a prompt, depending on requirements.
This distinction matters for testing.
Testing Checklist
Basic Input
- empty line
- spaces only
- one command
- command with arguments
- Ctrl+D
exit
Command Execution
/bin/lslspwd- invalid command
- command with relative path
- command without execute permission
PATH Handling
- normal PATH
- missing PATH
- empty PATH
- command in
/bin - command in
/usr/bin - invalid directory in PATH
Built-ins
exitenvcdcdwith no argument if supported- invalid
cdtarget
Error Handling
- malloc failure direction
- fork failure direction
- exec failure
- permission denied
- file not found
- directory passed as command
Memory
- repeated commands
- long session
- Valgrind check if available
- cleanup after errors
- cleanup before exit
Common Bugs
Shell Disappears After Running Command
Cause:
exec was called in parent instead of child
Fix:
fork first, exec only in child
cd Does Not Work
Cause:
cd was executed in a child process
Fix:
handle cd as a built-in in the parent shell
Command Works With /bin/ls But Not ls
Cause:
PATH lookup not implemented
Fix:
search directories listed in PATH
Segmentation Fault On Empty Input
Cause:
parser assumes argv[0] exists
Fix:
check for empty command before execution
Memory Grows Forever
Cause:
allocated command data not freed after each loop
Fix:
free line/token/path buffers consistently
Wrong Error Message
Cause:
all failures are treated as command not found
Fix:
distinguish not found, permission denied, and execution failure
Practical Decisions
Start with a small feature set
A minimal shell should execute commands reliably before adding pipes, redirects, or history.
Handle built-ins separately
Commands like cd and exit belong to the shell process itself.
Treat PATH lookup as a real feature
Most commands users type are not absolute paths.
Keep parsing simple first
Basic whitespace parsing is enough for a first version.
Check every allocation
In C, allocation failure must be considered.
Free per-command memory
A shell is long-running. Small leaks repeat forever.
Prefer clear errors
A small shell should still explain what failed.
What A Finished Version Should Show
A strong finished minimal shell should show:
- interactive prompt
- command reading loop
- argument parsing
- direct path execution
- PATH lookup
fork/execve/wait- built-in
exit - built-in
env - built-in
cdif implemented - clean error handling
- memory cleanup
- non-interactive input support
- README with examples
- tests or documented test cases
Evidence Worth Capturing
Useful evidence for this note would include:
- shell prompt screenshot
- running
/bin/ls - running
lsthrough PATH lookup - invalid command output
cdbehavior- non-interactive test
- source tree
- main loop code excerpt
- PATH lookup code excerpt
- Valgrind output if available
- README usage section
Technical Assumptions
This note assumes a Linux/POSIX-like environment.
It assumes the shell is written in C and focuses on fundamentals, not full Bash compatibility.
It also assumes the project is meant to demonstrate process handling and low-level programming discipline.
Key Risks
- calling
execin the parent process - not handling empty input
- not null-terminating
argv - memory leaks in the command loop
- poor PATH handling
- confusing built-ins with external commands
- wrong environment passed to child process
- ignoring fork/exec errors
- crashing on Ctrl+D
- trying to implement advanced shell features too early
Current State
This note represents a systems-programming project focused on understanding how Unix shells work internally.
It is not as business-facing as ERP, VPN, or deployment tooling, but it adds a useful lower-level layer to the portfolio.
It shows that the technical foundation is not only web apps and server configuration, but also process-level Linux behavior.
What This Note Does Not Claim
This note does not claim to replace Bash, Zsh, Fish, or other real shells.
It does not claim to support full shell syntax.
It does not claim to include every advanced feature like pipes, redirection, jobs, signals, aliases, or scripting.
It documents a minimal C shell used to understand core Unix process mechanics.
Practical Takeaway
The useful lesson is:
A shell is a loop that turns text into processes.
To build even a small one, you need to understand:
- input reading
- token parsing
- argument vectors
- PATH lookup
- child processes
- executable replacement
- waiting
- built-ins
- errors
- memory cleanup
That makes the project a strong fundamentals note when framed as systems programming, not as a generic training exercise.