This article was originally published on Valerius Petrini's Programming Blog on 06/29/2025
If you've ever wanted to build your own shell (the program that lets you interact with your operating system using commands like git
, ls
, or node
) in Linux using C, you'll quickly come across the function fork()
. In Linux, fork()
is what allows a shell to create new processes for the commands you run. It's how shells can execute programs without freezing or crashing themselves, and it's the foundation of multitasking in Unix systems.
What are Processes?
If you run ps
in your Linux command line (or tasklist
on Windows), you'll see a list of all the processes running on your computer, including the process ID (PID) for all of them. Each entry represents something actively running on your computer, like your web browser, games, or system processes. On Unix systems, every process except the very first one is created by fork()
from a parent process. If you were to trace the process tree all the way up, you'd eventually reach a single initial process started directly by the kernel.
How fork works
fork()
works by essentially duplicating the currently running process. For example, say you run the ls
command, your shell would:
- Duplicate itself using
fork()
, creating a parent and a child process. - The child gets a copy of the environment and a different PID (though
fork()
returns0
in the child, and the child’s PID in the parent). - The child replaces its memory with the
ls
program usingexecvp()
. - The parent waits for the child to finish using
waitpid()
.
This design is useful because if something goes wrong with ls
, only the child is affected.
This is how that process would work in C
:
pid_t pid = fork();
if (pid == 0) { // Child process
execvp(args[0], args); // Run the given command (e.g., "ls")
perror("exec failed"); // Only reached if exec fails
exit(1);
} else if (pid > 0) { // Parent process
// You'll want to track your jobs using an array
add_job(pid)
int status;
waitpid(pid, &status, 0); // Wait for the child to finish
printf(WIFEXITED(status) ? "Success\n" : "Failure\n");
} else {
perror("fork failed"); // Handle fork error
}
Even though it looks like the code runs twice, it’s actually two separate processes executing the same code: one (the parent) receives the child’s PID from fork(), and the other (the child) receives 0.
Background Processes and Signals
Background processes run independently of the shell, allowing the shell to continue accepting new commands without waiting for those processes to finish. If we modify our previous C
code and remove the waitpid
call, the parent process continues immediately, while the child runs in the background. This is useful for long-running tasks like servers or background jobs.
However, if the parent never calls wait()
or waitpid()
on a child process, that child can become a zombie process once it finishes. A zombie process is a process that has completed execution but still has an entry in the process table because the parent hasn’t read its exit status. To avoid this, most shells handle the SIGCHLD
signal, which is sent to the parent when a child process exits. The shell can then clean up by calling wait()
inside a signal handler.
If we were to handle the signal ourselves in our custom shell, we would need to use the sigaction
function provided by signal.h
:
// This function is called automatically when a SIGCHLD signal is received.
// It reaps any child processes that have exited to prevent zombies.
void handleSigChild(int signum) {
int status;
pid_t pid;
// Use a loop in case multiple children exited before the handler ran.
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
// WIFEXITED: child exited normally
// WIFSIGNALED: child was terminated by a signal
if (WIFEXITED(status) || WIFSIGNALED(status))
// Update your job status here if you track background jobs.
// You should store jobs in an array and keep track of the PID and the state (foreground, stopped, background, etc.)
jobData->status = DONE; // Replace this with your job-tracking logic
}
}
struct sigaction sa_chld;
// Set the handler function for SIGCHLD
sa_chld.sa_handler = handleSigChild;
// SA_RESTART: restart interrupted system calls
// SA_NOCLDSTOP: don’t call handler when children are stopped (only when they exit)
sa_chld.sa_flags = SA_RESTART | SA_NOCLDSTOP;
// Clear the signal mask so no signals are blocked during handler execution
sigemptyset(&sa_chld.sa_mask);
// Install the signal handler
if (sigaction(SIGCHLD, &sa_chld, NULL) == -1) {
perror("sigaction SIGCHLD");
exit(1);
}
Shells use signals for a wide range of tasks, such as:
-
SIGINT
(sent by pressingCtrl+C
) to interrupt a foreground process -
SIGTSTP
(sent byCtrl+Z
) to stop a foreground process -
SIGCONT
to resume a stopped process
You can find a full list of Unix signals and their meanings here.
Prompting the user
In order for our shell to actually be usable, we need to combine the previous two code segments and add a loop to prompt the user. Here's a basic structure that also implements background process handling:
void handleSigChild(int signum) { ... }
int main() {
// setup sa_chld
while (true) {
printf(">>> ");
// Get input
// You can implement a check to see if the input ends with &
bool bg = input.endsWith("&");
pid_t pid = fork();
if (pid == 0) {
// Parse input
char* args[64];
size_t arg_count = 0;
char* token = strtok(input, " ");
while (token != NULL && arg_count < 63) {
args[arg_count++] = token;
token = strtok(NULL, " ");
}
args[arg_count] = NULL;
execvp(args[0], args);
perror("exec failed");
exit(1);
} else if (pid > 0) {
int status;
if (!bg) {
waitpid(pid, &status, 0);
printf(WIFEXITED(status) ? "Success\n" : "Failure\n");
} else {
printf("Child started in background");
}
} else {
perror("fork failed"); // Handle fork error
}
}
}
With this code, we have a basic working shell that can run foreground processes. Of course, if you're making your own shell, make sure to make this memory safe by checking for NULL
and by freeing memory when necessary to avoid memory leaks.
Next Steps
If you want to continue building out your shell, you'll want to do a few more things before it's ready:
- Try experimenting with different
exec
variants likeexeclp
. - Handle builtin commands, like
cd
,exit
, andjobs
. These can't be run byexecvp
so you'll need to handle them yourself - Handle
Ctrl-Z
, which stops a foreground process. - Implement piping with
|
, where the output of one command is the input for another. To implement piping, you'll need to usepipe()
anddup2()
to redirect output from one command to the input of another.
Gist
If you'd like a minimum working shell to test yours against or to follow along, you can visit my Github page where I have a gist that implements an entire shell with job control.
Happy coding!
Top comments (0)