Minimal Unix shell with pipes and redirection, in C.
A small, self-contained demo written in pure C — no external libraries, just the standard library and POSIX. Part of the Corg-Labs collection of single-file C programs.
- A command line is split on the pipe character into stages
- fork() + execvp() run each stage as a child process
- pipe() and dup2() wire one stage's output to the next's input
- < and > redirect a stage's input/output to files
This tutorial walks through the source code of cshell.c step by step, explaining each
concept and pipeline stage so you can understand — and extend — the shell yourself.
The single compile-time constant caps the number of arguments per command:
#defineMAXARGS 64Before any parsing, the trim helper strips leading spaces and trailing newlines/spaces
from a string in place, returning a pointer to the cleaned start:
staticchar*trim(char*s){
while(*s==' ')s++;
char*e=s+strlen(s);
while(e>s&&(e[-1]=='\n'||e[-1]==' '))*--e=0;
returns;
}Every raw line read from the user passes through trim before the shell tries to
interpret it, ensuring blank lines and extra whitespace are handled gracefully.
main runs a simple while(1) REPL (Read-Eval-Print Loop). It reads one line at a
time with fgets, trims it, then dispatches to built-ins or the pipeline runner:
charline[1024];
while(1){
printf("csh$ "); fflush(stdout);
if(!fgets(line,sizeof(line),stdin)) break;
char*cmd=trim(line);
if(!*cmd) continue;
if(!strcmp(cmd,"exit")) break;
if(!strncmp(cmd,"cd",2)){ char*d=trim(cmd+2); if(chdir(*d?d:getenv("HOME"))) perror("cd"); continue; }
...
}The loop exits on EOF (fgets returns NULL) or when the user types exit.
Two commands are handled directly in the shell process without forking — they must be built-ins because they affect the shell's own state:
exit— breaks the REPL loop and terminates the shell.cd— callschdir()to change the shell's working directory; falls back to$HOMEwhen no argument is given:
if(!strncmp(cmd,"cd",2)){
char*d=trim(cmd+2);
if(chdir(*d?d:getenv("HOME"))) perror("cd");
continue;
}Any other command falls through to the pipe-splitting stage.
The trimmed command line is tokenised on | to produce an ordered array of segment
strings, each representing one stage of the pipeline:
char*segs[16]; intns=0;
char*s=strtok(cmd,"|");
while(s&&ns<16){ segs[ns++]=s; s=strtok(NULL,"|"); }Up to 16 stages are supported. For a command like ls | grep .c | wc -l this
produces segs = {"ls ", " grep .c ", " wc -l"} with ns = 3.
For each stage the shell optionally creates a new pipe(), forks a child, then
wires file descriptors with dup2 so the previous stage's read end becomes the
child's stdin, and the current pipe's write end becomes its stdout:
intprevfd=-1;
for(inti=0;i<ns;i++){
intpipefd[2]={-1,-1};
if(i<ns-1) pipe(pipefd);
pid_tpid=fork();
if(pid==0){
if(prevfd!=-1){ dup2(prevfd,0); close(prevfd); }
if(i<ns-1){ close(pipefd[0]); dup2(pipefd[1],1); close(pipefd[1]); }
charseg[512]; strncpy(seg,segs[i],sizeof(seg)-1); seg[sizeof(seg)-1]=0;
run_segment(trim(seg));
}
if(prevfd!=-1) close(prevfd);
if(i<ns-1){ close(pipefd[1]); prevfd=pipefd[0]; }
}
while(wait(NULL)>0);After all children are started, the parent calls wait in a loop to reap every
child before printing the next prompt, preventing zombie processes.
run_segment handles one pipeline stage. It tokenises the segment on spaces,
scanning for < and > redirect tokens, and collects the rest as execvp arguments:
staticvoidrun_segment(char*seg){
char*args[MAXARGS]; intn=0;
char*in=NULL,*out=NULL;
char*tok=strtok(seg," ");
while(tok&&n<MAXARGS-1){
if(!strcmp(tok,"<")){ tok=strtok(NULL," "); in=tok; }
elseif(!strcmp(tok,">")){ tok=strtok(NULL," "); out=tok; }
elseargs[n++]=tok;
tok=strtok(NULL," ");
}
args[n]=NULL;
...
}When a < token is found the next token becomes the input filename; > captures
the output filename. All other tokens are pushed onto the args array.
With the argument list and optional filenames ready, run_segment opens any
redirect files and wires them to stdin/stdout with dup2 before calling
execvp:
if(in){ intfd=open(in,O_RDONLY); if(fd<0){perror(in);exit(1);} dup2(fd,0); close(fd); }
if(out){ intfd=open(out,O_WRONLY|O_CREAT|O_TRUNC,0644); if(fd<0){perror(out);exit(1);} dup2(fd,1); close(fd); }
execvp(args[0],args);
fprintf(stderr,"cshell: %s: command not found\n",args[0]);
exit(127);execvp replaces the child process image with the requested command. If execvp
returns at all the command was not found; the shell prints an error and exits with
status 127 — the conventional "command not found" exit code.
gcc cshell.c -o cshell
./cshell
Built-ins: cd, exit. Try ls | grep .c | wc -l or cat < in > out.