In the previous part, we learned how to create and manage processes using fork(), exec() and wait(). Now it’s time to put those concepts into practice by writing our own mini shell.
A shell is simply a program that:
- Reads user input.
- Parses the command and arguments.
- Creates a new process with fork().
- Replaces it with the requested program using exec().
- Waits for it to complete (unless it runs in the background).
That’s it, everything else (job control, fancy prompts, scripting etc.) builds on these basics.
1. Features of Our Mini Shell
Our shell will support:
- Running commands like ls -l or echo hello.
- Input parsing into program name + arguments.
- Waiting for processes to finish.
- Background execution using &.
- Redirection (> and <).
- Simple pipelines (|).
2. A Bare-Bones Shell
The simplest possible shell reads a command and runs it.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>
#define MAX_INPUT 1024
#define MAX_ARGS 64
void parse_input(char *input, char **args)
{
int i = 0;
args[i] = strtok(input, " \t\n");
while (args[i] != NULL && i < MAX_ARGS - 1)
{
args[++i] = strtok(NULL, " \t\n");
}
}
int main()
{
char input[MAX_INPUT];
char *args[MAX_ARGS];
while (1)
{
printf("panda-sh> ");
if (fgets(input, sizeof(input), stdin) == NULL)
{
break;
}
parse_input(input, args);
if (args[0] == NULL)
{
continue;
}
if (strcmp(args[0], "exit") == 0)
{
break;
}
pid_t pid = fork();
if (pid == 0)
{
execvp(args[0], args);
perror("exec failed");
exit(1);
}
else if (pid > 0)
{
wait(NULL);
}
else
{
perror("fork failed");
}
}
return 0;
}
Try compiling this (gcc panda-sh.c -o panda-sh) and running it. You’ve just made a real working shell!
3. Background Processes (&)
To support background jobs, we check if the last argument is “&”. If yes, we don’t wait for the child.
int background = 0;
int i = 0;
while (args[i] != NULL)
{
i++;
}
if (i > 0 && strcmp(args[i - 1], "&") == 0)
{
background = 1;
args[i - 1] = NULL; // remove '&'
}
pid_t pid = fork();
if (pid == 0)
{
execvp(args[0], args);
perror("exec failed");
exit(1);
}
else if (pid > 0)
{
if (!background)
{
waitpid(pid, NULL, 0);
}
else
{
printf("[Background] PID %d\n", pid);
}
}
4. I/O Redirection (> and <)
We can redirect stdin/stdout using dup2() before execvp().
Example: ls > out.txt
#include <fcntl.h>
// inside child process before execvp()
for (int i = 0; args[i] != NULL; i++)
{
if (strcmp(args[i], ">") == 0)
{
int fd = open(args[i + 1], O_CREAT | O_WRONLY | O_TRUNC, 0644);
dup2(fd, STDOUT_FILENO);
close(fd);
args[i] = NULL;
}
else if (strcmp(args[i], "<") == 0)
{
int fd = open(args[i+1], O_RDONLY);
dup2(fd, STDIN_FILENO);
close(fd);
args[i] = NULL;
}
}
5. Simple Pipelines (|)
Pipes connect the output of one process to the input of another.
Example: ls | grep shell
int pipefd[2];
pipe(pipefd);
pid_t pid1 = fork();
if (pid1 == 0)
{
dup2(pipefd[1], STDOUT_FILENO);
close(pipefd[0]);
close(pipefd[1]);
char *args1[] = { "ls", NULL };
execvp(args1[0], args1);
}
pid_t pid2 = fork();
if (pid2 == 0)
{
dup2(pipefd[0], STDIN_FILENO);
close(pipefd[0]);
close(pipefd[1]);
char *args2[] = { "grep", "shell", NULL };
execvp(args2[0], args2);
}
close(pipefd[0]);
close(pipefd[1]);
waitpid(pid1, NULL, 0);
waitpid(pid2, NULL, 0);
This can be extended to arbitrary pipelines by chaining multiple processes.
6. Key Takeaways
- A shell is just a loop: read → parse → fork → exec → wait.
- Adding background jobs, redirection and pipes builds on top of process and file descriptor syscalls.
- Even a basic shell teaches core Linux concepts: process management, I/O redirection and IPC.
7. Next Steps
- Add signals (Ctrl + C handling).
- Implement job control (fg, bg).
- Support command history.
- Add scripting and configuration.
At this point, you have a functional mini-shell and the foundation to explore more advanced features.