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.
The fork() + exec() pattern is one of the most fundamental concepts in Unix systems programming. Here is what happens when you type ls -la:
The shell calls fork(), which creates an exact copy of the shell process (same code, same memory, same file descriptors).
In the child process, the shell calls execvp("ls", ["ls", "-la", NULL]), which replaces the child’s entire memory image with the ls program.
The parent shell calls waitpid() to block until the child exits.
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;}
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-terminatedCommand **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;}