Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Project: Build a Unix Shell

Put your C skills to the test by building a functional Unix shell. This project covers process management, signal handling, pipes, and I/O redirection. A shell is fundamentally a loop that does three things: read a command, parse it, and execute it. But the “execute” step involves some of the most important system calls in Unix: fork() to create a new process, exec() to replace that process with a different program, wait() to synchronize with child processes, pipe() to connect processes together, and dup2() to redirect I/O. These are the same primitives that bash, zsh, and fish use under the hood. Understanding them gives you deep insight into how Unix processes work — knowledge that pays off every time you debug process-related issues in production.

Project Overview

We’ll build a shell with these features:
1

Basic Command Execution

Parse and execute simple commands
2

Built-in Commands

cd, exit, pwd, history, etc.
3

I/O Redirection

>, >>, < operators
4

Pipes

Command pipelines with |
5

Job Control

Background processes, Ctrl+C handling

Phase 1: Basic Shell Loop

// shell.h
#ifndef SHELL_H
#define SHELL_H

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <errno.h>
#include <signal.h>
#include <fcntl.h>

#define MAX_LINE 1024
#define MAX_ARGS 64
#define MAX_HISTORY 100

// Command structure
typedef struct {
    char **args;        // Argument array
    int argc;           // Argument count
    char *input_file;   // < redirection
    char *output_file;  // > redirection
    int append;         // >> instead of >
    int background;     // & at end
} Command;

// Function prototypes
void shell_loop(void);
char *read_line(void);
Command *parse_line(char *line);
int execute(Command *cmd);
void free_command(Command *cmd);

// Built-in commands
int builtin_cd(char **args);
int builtin_exit(char **args);
int builtin_pwd(char **args);
int builtin_history(char **args);
int builtin_help(char **args);

#endif
// main.c
#include "shell.h"

int main(int argc, char *argv[]) {
    // Setup signal handling
    signal(SIGINT, SIG_IGN);   // Ignore Ctrl+C in parent
    signal(SIGQUIT, SIG_IGN);
    signal(SIGTSTP, SIG_IGN);  // Ignore Ctrl+Z
    
    printf("Welcome to MyShell!\n");
    printf("Type 'help' for available commands.\n\n");
    
    shell_loop();
    
    return 0;
}

void shell_loop(void) {
    char *line;
    Command *cmd;
    int status = 1;
    
    while (status) {
        // Print prompt
        char cwd[1024];
        getcwd(cwd, sizeof(cwd));
        printf("\033[1;32mmysh\033[0m:\033[1;34m%s\033[0m$ ", cwd);
        fflush(stdout);
        
        // Read input
        line = read_line();
        if (line == NULL) {
            printf("\n");
            break;  // EOF
        }
        
        // Skip empty lines
        if (strlen(line) == 0) {
            free(line);
            continue;
        }
        
        // Parse and execute
        cmd = parse_line(line);
        if (cmd && cmd->argc > 0) {
            status = execute(cmd);
        }
        
        free_command(cmd);
        free(line);
    }
}

char *read_line(void) {
    char *line = NULL;
    size_t bufsize = 0;
    
    if (getline(&line, &bufsize, stdin) == -1) {
        free(line);
        return NULL;
    }
    
    // Remove trailing newline
    size_t len = strlen(line);
    if (len > 0 && line[len - 1] == '\n') {
        line[len - 1] = '\0';
    }
    
    return line;
}

Phase 2: Command Parsing

// parser.c
#include "shell.h"

Command *parse_line(char *line) {
    Command *cmd = calloc(1, sizeof(Command));
    cmd->args = malloc(MAX_ARGS * sizeof(char*));
    
    char *token;
    char *saveptr;
    int i = 0;
    
    // Simple tokenization (doesn't handle quotes yet)
    token = strtok_r(line, " \t", &saveptr);
    
    while (token != NULL && i < MAX_ARGS - 1) {
        // Handle I/O redirection
        if (strcmp(token, "<") == 0) {
            token = strtok_r(NULL, " \t", &saveptr);
            if (token) cmd->input_file = strdup(token);
        }
        else if (strcmp(token, ">") == 0) {
            token = strtok_r(NULL, " \t", &saveptr);
            if (token) {
                cmd->output_file = strdup(token);
                cmd->append = 0;
            }
        }
        else if (strcmp(token, ">>") == 0) {
            token = strtok_r(NULL, " \t", &saveptr);
            if (token) {
                cmd->output_file = strdup(token);
                cmd->append = 1;
            }
        }
        else if (strcmp(token, "&") == 0) {
            cmd->background = 1;
        }
        else {
            cmd->args[i++] = strdup(token);
        }
        
        token = strtok_r(NULL, " \t", &saveptr);
    }
    
    cmd->args[i] = NULL;
    cmd->argc = i;
    
    return cmd;
}

void free_command(Command *cmd) {
    if (cmd == NULL) return;
    
    for (int i = 0; cmd->args[i] != NULL; i++) {
        free(cmd->args[i]);
    }
    free(cmd->args);
    free(cmd->input_file);
    free(cmd->output_file);
    free(cmd);
}

Phase 3: Built-in Commands

// builtins.c
#include "shell.h"

// Built-in command names and functions
char *builtin_names[] = {
    "cd",
    "exit",
    "pwd",
    "history",
    "help",
    NULL
};

int (*builtin_funcs[])(char**) = {
    builtin_cd,
    builtin_exit,
    builtin_pwd,
    builtin_history,
    builtin_help,
    NULL
};

// History storage
static char *history[MAX_HISTORY];
static int history_count = 0;

void add_to_history(const char *line) {
    if (history_count < MAX_HISTORY) {
        history[history_count++] = strdup(line);
    } else {
        free(history[0]);
        memmove(history, history + 1, (MAX_HISTORY - 1) * sizeof(char*));
        history[MAX_HISTORY - 1] = strdup(line);
    }
}

int is_builtin(const char *cmd) {
    for (int i = 0; builtin_names[i] != NULL; i++) {
        if (strcmp(cmd, builtin_names[i]) == 0) {
            return i;
        }
    }
    return -1;
}

int builtin_cd(char **args) {
    const char *path = args[1];
    
    if (path == NULL) {
        path = getenv("HOME");
        if (path == NULL) {
            fprintf(stderr, "cd: HOME not set\n");
            return 1;
        }
    }
    
    if (chdir(path) != 0) {
        perror("cd");
        return 1;
    }
    
    return 1;
}

int builtin_exit(char **args) {
    int code = 0;
    if (args[1] != NULL) {
        code = atoi(args[1]);
    }
    printf("Goodbye!\n");
    exit(code);
}

int builtin_pwd(char **args) {
    (void)args;
    char cwd[1024];
    
    if (getcwd(cwd, sizeof(cwd)) != NULL) {
        printf("%s\n", cwd);
    } else {
        perror("pwd");
    }
    
    return 1;
}

int builtin_history(char **args) {
    (void)args;
    for (int i = 0; i < history_count; i++) {
        printf("%4d  %s\n", i + 1, history[i]);
    }
    return 1;
}

int builtin_help(char **args) {
    (void)args;
    printf("MyShell - A simple Unix shell\n\n");
    printf("Built-in commands:\n");
    printf("  cd [dir]      Change directory\n");
    printf("  pwd           Print working directory\n");
    printf("  history       Show command history\n");
    printf("  help          Show this help\n");
    printf("  exit [code]   Exit shell\n");
    printf("\nFeatures:\n");
    printf("  cmd > file    Redirect stdout to file\n");
    printf("  cmd >> file   Append stdout to file\n");
    printf("  cmd < file    Redirect stdin from file\n");
    printf("  cmd1 | cmd2   Pipe output\n");
    printf("  cmd &         Run in background\n");
    return 1;
}

Phase 4: External Command Execution

The fork() + exec() pattern is one of the most fundamental concepts in Unix systems programming. Here is what happens when you type ls -la:
  1. The shell calls fork(), which creates an exact copy of the shell process (same code, same memory, same file descriptors).
  2. In the child process, the shell calls execvp("ls", ["ls", "-la", NULL]), which replaces the child’s entire memory image with the ls program.
  3. The parent shell calls waitpid() to block until the child exits.
  4. When ls finishes, the shell prints the next prompt.
This two-step dance (fork then exec) seems wasteful — why copy everything just to throw it away? The answer is that fork gives the child a chance to set up its environment (redirect I/O, close file descriptors, change directories) before exec replaces the program. This separation of concerns is why Unix I/O redirection and piping are so elegant.
// execute.c
#include "shell.h"

int execute(Command *cmd) {
    if (cmd->argc == 0 || cmd->args[0] == NULL) {
        return 1;
    }
    
    // Check for built-in commands first -- these must run in the shell process
    // itself (e.g., "cd" must change the shell's working directory, not a child's).
    int builtin_idx = is_builtin(cmd->args[0]);
    if (builtin_idx >= 0) {
        return builtin_funcs[builtin_idx](cmd->args);
    }
    
    // External commands: fork a child process
    pid_t pid = fork();
    
    if (pid < 0) {
        perror("fork");
        return 1;
    }
    
    if (pid == 0) {
        // Child process -- this is the copy created by fork().
        
        // Reset signal handlers to defaults. The shell ignores SIGINT (Ctrl+C)
        // so the shell itself does not die, but child processes SHOULD respond
        // to Ctrl+C normally. If we did not reset these, "Ctrl+C" while running
        // "sleep 100" would do nothing because the child inherited the ignore.
        signal(SIGINT, SIG_DFL);
        signal(SIGQUIT, SIG_DFL);
        signal(SIGTSTP, SIG_DFL);
        
        // Handle input redirection
        if (cmd->input_file) {
            int fd = open(cmd->input_file, O_RDONLY);
            if (fd < 0) {
                perror(cmd->input_file);
                exit(1);
            }
            dup2(fd, STDIN_FILENO);
            close(fd);
        }
        
        // Handle output redirection
        if (cmd->output_file) {
            int flags = O_WRONLY | O_CREAT;
            flags |= cmd->append ? O_APPEND : O_TRUNC;
            
            int fd = open(cmd->output_file, flags, 0644);
            if (fd < 0) {
                perror(cmd->output_file);
                exit(1);
            }
            dup2(fd, STDOUT_FILENO);
            close(fd);
        }
        
        // Replace this process image with the requested program.
        // execvp searches PATH for the command, just like a real shell.
        // If exec succeeds, THIS LINE IS NEVER REACHED -- the entire process
        // has been replaced with the new program. The only way we reach the
        // fprintf below is if exec fails (e.g., command not found).
        execvp(cmd->args[0], cmd->args);
        
        // exec failed -- program not found or not executable.
        // Exit code 127 is the convention for "command not found" (same as bash).
        fprintf(stderr, "%s: command not found\n", cmd->args[0]);
        exit(127);
    }
    
    // Parent process
    if (!cmd->background) {
        int status;
        waitpid(pid, &status, 0);
    } else {
        printf("[%d] Started in background\n", pid);
    }
    
    return 1;
}

Phase 5: Pipes

Pipes are Unix’s composition mechanism: small programs that each do one thing well, connected together to solve complex problems. When you write cat file.txt | grep "error" | wc -l, three processes run simultaneously, connected by kernel-managed buffers. Each | creates a pipe: a pair of file descriptors where one end writes and the other reads. The kernel handles the synchronization — if the reader is slow, the writer blocks automatically. The tricky part of implementing pipes in a shell is getting the plumbing right: each process needs the correct file descriptors connected and all the others closed. A common bug is forgetting to close pipe ends in the parent process, which causes the reader to hang forever (it keeps waiting for EOF, which only happens when ALL write ends are closed).
// pipes.c
#include "shell.h"

// Parse pipe-separated commands
// Returns array of Command pointers, NULL-terminated
Command **parse_pipeline(char *line) {
    Command **pipeline = malloc(MAX_ARGS * sizeof(Command*));
    int count = 0;
    
    char *segment;
    char *saveptr;
    
    segment = strtok_r(line, "|", &saveptr);
    while (segment != NULL && count < MAX_ARGS - 1) {
        // Trim whitespace
        while (*segment == ' ') segment++;
        char *end = segment + strlen(segment) - 1;
        while (end > segment && *end == ' ') *end-- = '\0';
        
        pipeline[count++] = parse_line(segment);
        segment = strtok_r(NULL, "|", &saveptr);
    }
    
    pipeline[count] = NULL;
    return pipeline;
}

int execute_pipeline(Command **pipeline) {
    int num_cmds = 0;
    while (pipeline[num_cmds] != NULL) num_cmds++;
    
    if (num_cmds == 0) return 1;
    if (num_cmds == 1) return execute(pipeline[0]);
    
    int pipes[num_cmds - 1][2];
    pid_t pids[num_cmds];
    
    // Create pipes
    for (int i = 0; i < num_cmds - 1; i++) {
        if (pipe(pipes[i]) < 0) {
            perror("pipe");
            return 1;
        }
    }
    
    // Fork processes
    for (int i = 0; i < num_cmds; i++) {
        pids[i] = fork();
        
        if (pids[i] < 0) {
            perror("fork");
            return 1;
        }
        
        if (pids[i] == 0) {
            // Child process
            signal(SIGINT, SIG_DFL);
            signal(SIGQUIT, SIG_DFL);
            
            // Connect to previous pipe (stdin)
            if (i > 0) {
                dup2(pipes[i - 1][0], STDIN_FILENO);
            }
            
            // Connect to next pipe (stdout)
            if (i < num_cmds - 1) {
                dup2(pipes[i][1], STDOUT_FILENO);
            }
            
            // Close all pipe fds
            for (int j = 0; j < num_cmds - 1; j++) {
                close(pipes[j][0]);
                close(pipes[j][1]);
            }
            
            // Handle first command's input redirection
            if (i == 0 && pipeline[i]->input_file) {
                int fd = open(pipeline[i]->input_file, O_RDONLY);
                if (fd >= 0) {
                    dup2(fd, STDIN_FILENO);
                    close(fd);
                }
            }
            
            // Handle last command's output redirection
            if (i == num_cmds - 1 && pipeline[i]->output_file) {
                int flags = O_WRONLY | O_CREAT;
                flags |= pipeline[i]->append ? O_APPEND : O_TRUNC;
                int fd = open(pipeline[i]->output_file, flags, 0644);
                if (fd >= 0) {
                    dup2(fd, STDOUT_FILENO);
                    close(fd);
                }
            }
            
            execvp(pipeline[i]->args[0], pipeline[i]->args);
            fprintf(stderr, "%s: command not found\n", pipeline[i]->args[0]);
            exit(127);
        }
    }
    
    // Parent: close all pipe fds
    for (int i = 0; i < num_cmds - 1; i++) {
        close(pipes[i][0]);
        close(pipes[i][1]);
    }
    
    // Wait for all children
    for (int i = 0; i < num_cmds; i++) {
        waitpid(pids[i], NULL, 0);
    }
    
    return 1;
}

Phase 6: Signal Handling and Job Control

// signals.c
#include "shell.h"

// Global for signal handler
static volatile pid_t foreground_pid = 0;

void sigint_handler(int sig) {
    (void)sig;
    if (foreground_pid > 0) {
        kill(foreground_pid, SIGINT);
    }
    printf("\n");
}

void sigchld_handler(int sig) {
    (void)sig;
    int status;
    pid_t pid;
    
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
        if (WIFEXITED(status)) {
            printf("\n[%d] Done (exit %d)\n", pid, WEXITSTATUS(status));
        } else if (WIFSIGNALED(status)) {
            printf("\n[%d] Killed by signal %d\n", pid, WTERMSIG(status));
        }
    }
}

void setup_signals(void) {
    struct sigaction sa;
    
    // Handle SIGINT (Ctrl+C)
    sa.sa_handler = sigint_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;
    sigaction(SIGINT, &sa, NULL);
    
    // Handle SIGCHLD (child terminated)
    sa.sa_handler = sigchld_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
    sigaction(SIGCHLD, &sa, NULL);
    
    // Ignore SIGQUIT and SIGTSTP in shell
    signal(SIGQUIT, SIG_IGN);
    signal(SIGTSTP, SIG_IGN);
}

void set_foreground_pid(pid_t pid) {
    foreground_pid = pid;
}

Makefile

CC = gcc
CFLAGS = -Wall -Wextra -Werror -std=c11 -g
LDFLAGS = 

SRCS = main.c parser.c builtins.c execute.c pipes.c signals.c
OBJS = $(SRCS:.c=.o)
TARGET = mysh

all: $(TARGET)

$(TARGET): $(OBJS)
	$(CC) $(OBJS) $(LDFLAGS) -o $@

%.o: %.c shell.h
	$(CC) $(CFLAGS) -c $< -o $@

clean:
	rm -f $(OBJS) $(TARGET)

.PHONY: all clean

Testing Your Shell

# Basic commands
ls -la
pwd
cd /tmp
pwd
cd

# I/O redirection
echo "Hello World" > test.txt
cat < test.txt
echo "More text" >> test.txt
wc -l < test.txt

# Pipes
ls -la | grep ".c"
cat /etc/passwd | grep root | wc -l
ps aux | grep bash | head -5

# Background processes
sleep 10 &
jobs

# Built-in commands
history
help
exit 0

Extensions to Implement

Globbing

Expand wildcards like *.c and file?.txt

Environment Variables

Support $VAR expansion and export

Command Substitution

Implement $(command) or backtick substitution

Tab Completion

Add readline-style tab completion

Scripting

Execute shell scripts with conditionals and loops

Job Control

Full jobs, fg, bg implementation

Next Up

Build a Database

Build a key-value store with persistence