A simplified Unix shell implemented in C using POSIX system calls.
This shell supports signal handling, I/O redirection, and multi-stage pipelines, demonstrating how Unix shells manage processes and file descriptors.
This project implements a minimal Unix shell capable of executing commands with features commonly found in real shells.
The shell must handle SIGINT (Ctrl+C) without terminating itself while waiting for input.
Example:
^C
Your shell cannot be terminated using an interrupt signal!
When a command is running, the shell should remain alive while the child process follows the default SIGINT behavior.
The shell must redirect standard input and output using file descriptors.
Examples:
ls > file.txt
ls >> file.txt
sort < file.txt
sort < file.txt > sorted.txt
Supported redirections:
| Operator | Description |
|---|---|
< |
Redirect input |
> |
Redirect output (overwrite) |
>> |
Redirect output (append) |
The shell must connect multiple commands so that the output of one process becomes the input of the next.
Examples:
ls -l | head
cat test.c | sort | uniq
ls | wc -l > out.txt
The project supports multi-stage pipelines and their combination with I/O redirection.
The shell is organized around a simple execution flow:
User Input
↓
Syntax Validation
↓
Pipeline Splitting
↓
fork()
↓
I/O Redirection with dup2()
↓
Argument Parsing
↓
execvp()
↓
Parent Waits with waitpid()
Each stage of a pipeline is executed in a separate child process, while the parent shell waits for all child processes to terminate.
The main loop repeatedly reads a command line, validates it, and passes it to eval().
Inside eval():
- The shell temporarily ignores
SIGINT - The command line is split into pipeline stages
- Each pipeline stage is executed in a child process
- In each child:
- redirection is applied
- the command string is converted into
argv execvp()is called
- The parent waits for all child processes using
waitpid()
This structure separates the shell process from the actual command execution process, which is how real Unix shells behave.
Pipelines are handled in the pipelining() function.
For a command such as:
command1 | command2 | command3
The shell:
- finds each
|separator in the command line - splits the original command string into individual pipeline stages
- creates a new pipe between adjacent commands
- calls
fork()for each stage
Each child process then connects its standard input or output using dup2():
- the first command writes to a pipe
- middle commands read from the previous pipe and write to the next pipe
- the last command reads from the previous pipe
This produces a process structure like:
shell
├── child1 (command1)
│ stdout → pipe1
│
├── child2 (command2)
│ stdin ← pipe1
│ stdout → pipe2
│
└── child3 (command3)
stdin ← pipe2
The parent shell closes unused pipe file descriptors after each fork(), preventing descriptor leaks and ensuring proper pipeline behavior.
Redirection is handled separately for each pipeline stage in the redirection() function.
The shell scans the command string for:
<: input redirection>: output redirection (overwrite)>>: output redirection (append)
For each operator, the shell:
- identifies the redirection type
- parses the target file name
- opens the file with
open() - redirects
STDIN_FILENOorSTDOUT_FILENOusingdup2() - closes the original file descriptor
A notable implementation detail is that the shell removes the redirection portion from the command string after processing it.
This prevents file names and redirection symbols from remaining in the final argv passed to execvp().
Because redirection is applied after pipeline splitting, each command in a pipeline can independently have its own input/output redirection.
After redirection is processed, each command string is converted into an argument vector by cmd_to_argv().
This function:
- skips leading spaces
- splits the command by delimiters such as spaces or redirection symbols
- stores each token into
argv - terminates the array with
NULL
The resulting argv is then passed directly to execvp().
Before execution, the shell performs basic syntax validation with check_syntax_error().
It rejects malformed commands such as:
| ls
ls |
sort <
In particular, the shell checks for:
- a pipeline starting with
| - a pipeline ending with
| - missing file names after
<,>, or>> - unexpected special tokens appearing where a file name or command should exist
By rejecting invalid input before fork() and execvp(), the shell avoids unnecessary process creation and produces clearer error messages.
The shell installs a custom SIGINT handler so that pressing Ctrl+C does not terminate the shell itself while waiting for input.
Behavior is divided into two cases:
| Situation | Result |
|---|---|
| Shell waiting for input | Print a warning message and return to the prompt |
| Child process running | Child follows the default SIGINT behavior |
This is implemented by:
- installing a custom signal handler in the shell loop
- using
sigsetjmp()/siglongjmp()to return safely to the prompt - ignoring
SIGINTin the parent during command evaluation - restoring the default
SIGINTbehavior in child processes beforeexecvp()
As a result, Ctrl+C interrupts running commands, but does not terminate the shell itself.
The shell also supports a built-in quit command.
Unlike external commands, quit is handled without calling execvp().
When the command is detected, the shell exits directly through built-in command handling.
[JS]# echo Hello World
Hello World
[JS]# sort < myshell.c > sorted.txt
[JS]# ls | wc -l
6
[JS]# cat myshell.c | sort | uniq > out.txt
Compile using the provided Makefile:
$ make
Run the shell:
./myshell
The shell intentionally implements only a subset of full Unix shell functionality.
Limitations include:
- Background execution (
&) is not supported - Stderr redirection (
2>) is not implemented - Some edge cases of shell syntax are not handled
- Double Quotes (") are not supported
Despite these limitations, the shell correctly supports the core mechanisms required for command execution.
