C / CAPSTONE PROJECTS
Project: a small shell with pipes and redirection
Build a shell that runs one-line pipelines: parse <, > and |, then wire each child's fds with pipe/dup2/execvp and wait on every child.
What you will learn
- Set up redirection inside the child, between fork and execvp, never in the parent
- Close every pipe end you do not use, or the reading command never sees EOF
- Strip |, < and > while parsing so they never appear in the command's argv
- waitpid every child and report the last stage's WEXITSTATUS as $?
Understanding Project: a small shell with pipes and redirection
A shell is small because it does almost none of the work itself. It splits a line into words, decides which words are commands and which are redirection syntax, arranges descriptors 0 and 1 for each command, and then hands control to the real program with execvp. sort has no idea whether its input is a terminal, a file, or a pipe; it just reads descriptor 0. Redirection is therefore a property of the descriptor table you build, not a feature of the program you run.
pipe() fills a two-element array with the ends of one kernel buffer: whatever is written to fd[1] can be read from fd[0]. Because fork copies the descriptor table, every fork after pipe() creates another holder of both ends, and read() on fd[0] returns 0 for EOF only when the very last descriptor pointing at the write end has been closed. So the writing child closes fd[0], the reading child closes fd[1], and the parent, which needs neither once both children exist, closes both. Miss one of those closes and "the command finished" silently becomes "the command hangs".
dup2(fd, STDOUT_FILENO) closes descriptor 1 if it is open and makes it a second name for the same open file description as fd, which is why you can close the original fd immediately afterwards and still keep the redirection. Do this after fork and before execvp, because exec replaces the program image but leaves the descriptor table untouched, and because the parent's descriptors are a separate copy the shell keeps printing its prompt to the terminal. Once the stages are running, waitpid each child; a pipeline's status is the status of its last stage, which is what the shell stores in $?.
_POSIX_C_SOURCE
<fcntl.h>
<stdio.h>
<stdlib.h>
<sys/wait.h>
<unistd.h>
/* Run: argv1 < in_path | argv2 > out_path */
static void run_pipeline(char **argv1, const char *in_path,
char **argv2, const char *out_path)
{
int fd[2];
if (pipe(fd) == -1) { perror("pipe"); exit(1); }
pid_t left = fork();
if (left == -1) { perror("fork"); exit(1); }
if (left == 0) {
int in = open(in_path, O_RDONLY);
if (in == -1) { perror(in_path); _exit(1); }
dup2(in, STDIN_FILENO); /* stdin <- the file */
dup2(fd[1], STDOUT_FILENO); /* stdout -> the pipe */
close(in); close(fd[0]); close(fd[1]);
execvp(argv1[0], argv1);
perror(argv1[0]);
_exit(127);
}
pid_t right = fork();
if (right == -1) { perror("fork"); exit(1); }
if (right == 0) {
int out = open(out_path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (out == -1) { perror(out_path); _exit(1); }
dup2(fd[0], STDIN_FILENO); /* stdin <- the pipe */
dup2(out, STDOUT_FILENO); /* stdout -> the file */
close(out); close(fd[0]); close(fd[1]);
execvp(argv2[0], argv2);
perror(argv2[0]);
_exit(127);
}
close(fd[0]); /* the parent needs neither end: while it still */
close(fd[1]); /* holds the write end, the reader gets no EOF */
int st1, st2;
waitpid(left, &st1, 0);
waitpid(right, &st2, 0);
printf("left exit %d, right exit %d\n",
WEXITSTATUS(st1), WEXITSTATUS(st2));
}
int main(void)
{
FILE *f = fopen("words.txt", "w");
if (!f) { perror("words.txt"); return 1; }
fputs("pear\napple\nmango\n", f);
fclose(f);
char *left[] = { "sort", NULL };
char *right[] = { "tr", "a-z", "A-Z", NULL };
fflush(NULL); /* nothing buffered should be inherited by a child */
run_pipeline(left, "words.txt", right, "upper.txt");
f = fopen("upper.txt", "r");
if (!f) { perror("upper.txt"); return 1; }
char line[64];
while (fgets(line, sizeof line, f)) fputs(line, stdout);
fclose(f);
return 0;
}
Pipes and redirection are one operation seen from two angles: rearrange the child's descriptors 0 and 1 after fork and before exec, and every command works unchanged.
Worked examples
Redirection alone: > without a pipe
Shows that a single dup2 in the child is the whole of output redirection, and that the parent's own stdout is unaffected.
_POSIX_C_SOURCE
<fcntl.h>
<stdio.h>
<sys/wait.h>
<unistd.h>
int main(void)
{
char *argv[] = { "echo", "written by a child", NULL };
fflush(NULL);
pid_t pid = fork();
if (pid == -1) { perror("fork"); return 1; }
if (pid == 0) {
int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) { perror("out.txt"); _exit(1); }
dup2(fd, STDOUT_FILENO); /* descriptor 1 now names out.txt */
close(fd);
execvp(argv[0], argv);
perror(argv[0]);
_exit(127);
}
wait(NULL);
printf("parent stdout still goes to the terminal\n");
FILE *f = fopen("out.txt", "r");
if (!f) { perror("out.txt"); return 1; }
char line[128];
while (fgets(line, sizeof line, f)) printf("out.txt: %s", line);
fclose(f);
return 0;
}
Example explained
Line 1open() returns the lowest free descriptor, usually 3, and at that moment nothing is redirected yet.
Line 2dup2(fd, STDOUT_FILENO) closes descriptor 1 and re-points it at out.txt, so the child's writes to stdout land in the file.
Line 3close(fd) drops the spare name for the file; the redirection survives because descriptor 1 already refers to the same open file description.
Line 4execvp keeps the descriptor table, so /bin/echo writes into the file without knowing it, while the parent's descriptor 1 is a separate copy still attached to the terminal.
Parsing a line into stages and filenames
Turns one command line into per-stage argv arrays plus redirection targets, proving that the operators are removed before exec ever sees them.
<stdio.h>
<string.h>
MAXARG
struct cmd {
char *argv[MAXARG];
int argc;
char *infile;
char *outfile;
};
static int parse(char *line, struct cmd *cmds, int max)
{
int n = 1;
cmds[0].argc = 0;
cmds[0].infile = NULL;
cmds[0].outfile = NULL;
for (char *tok = strtok(line, " "); tok; tok = strtok(NULL, " ")) {
struct cmd *c = &cmds[n - 1];
if (strcmp(tok, "|") == 0) {
if (n == max) return -1;
cmds[n].argc = 0;
cmds[n].infile = NULL;
cmds[n].outfile = NULL;
n++;
} else if (strcmp(tok, "<") == 0) {
c->infile = strtok(NULL, " ");
} else if (strcmp(tok, ">") == 0) {
c->outfile = strtok(NULL, " ");
} else if (c->argc < MAXARG - 1) {
c->argv[c->argc++] = tok;
}
}
for (int i = 0; i < n; i++)
cmds[i].argv[cmds[i].argc] = NULL; /* execvp needs the NULL */
return n;
}
int main(void)
{
char line[] = "sort < names.txt | grep -v bob > kept.txt";
struct cmd cmds[4];
int n = parse(line, cmds, 4);
for (int i = 0; i < n; i++) {
printf("cmd %d:", i);
for (int j = 0; j < cmds[i].argc; j++)
printf(" [%s]", cmds[i].argv[j]);
printf(" in=%s out=%s\n",
cmds[i].infile ? cmds[i].infile : "-",
cmds[i].outfile ? cmds[i].outfile : "-");
}
return 0;
}
Example explained
Line 1char line[] copies the text into writable memory, which strtok requires because it overwrites each separator with '\0'.
Line 2The "|" branch starts a new struct cmd instead of appending a word, so each stage collects its own argv.
Line 3"<" and ">" call strtok again to swallow the following word as a filename, which is why the operator never reaches argv.
Line 4After the loop each argv gets a NULL element, because execvp finds the end of the argument list by looking for a null pointer, not by a count.
Important notes
cd, exit and export cannot be run in a child, because chdir affects only the process that calls it and that child then exits; builtins must be handled in the parent before you fork.
When a later stage exits early, as in sort words.txt | head -1, the earlier stage is killed by SIGPIPE; test WIFSIGNALED before using WEXITSTATUS, whose value is meaningless for a killed child (bash reports 128+13 = 141 there).
Common mistakes
Leaving the pipe's write end open in the parent: the right-hand command's read() never returns 0, so cat words.txt | sort prints nothing and the shell looks frozen until you press Ctrl+C.
Leaving > and the filename in argv: execvp runs a four-word command, grep treats > and out.txt as files to search, and you get "grep: >: No such file or directory" instead of a redirect.
Calling open and dup2 in the parent instead of the child: the shell's own descriptor 1 becomes the file, so the prompt and every later command's output vanish into out.txt for the rest of the session.
Try it yourself
Change, predict, then run
Extend the main program to three stages, sort < words.txt | uniq | tr a-z A-Z > upper.txt, using two pipes and a duplicated word in words.txt so uniq visibly removes something. Make sure the middle child closes all four pipe descriptors after its two dup2 calls.
Open the C workspaceCheck your understanding
Your shell runs `sort < in.txt | cat > out.txt` correctly. You delete one line and now it freezes even though sort has already exited. Which deleted line explains the freeze?
- close(in) in the left child, after its dup2 calls
- waitpid on the left child
- close(fd[1]) in the parent, after both forks
- close(out) in the right child, after its dup2 calls
Show answer
read() on a pipe returns EOF only when every descriptor referring to the write end is closed. The parent inherited both ends from pipe(), so if it keeps fd[1] open, cat still has a live writer and blocks forever after sort exits (the right child's own close(fd[1]) matters for exactly the same reason). Dropping close(in) or close(out) only leaks a descriptor in a process that is about to exec and exit, and a missing waitpid leaves a zombie without blocking any reader.