DEV Community

Valerius Petrini
Valerius Petrini

Posted on • Originally published at valerius-petrini.vercel.app

Building a Shell in C using fork and Process Management

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:

  1. Duplicate itself using fork(), creating a parent and a child process.
  2. The child gets a copy of the environment and a different PID (though fork() returns 0 in the child, and the child’s PID in the parent).
  3. The child replaces its memory with the ls program using execvp().
  4. 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
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Shells use signals for a wide range of tasks, such as:

  • SIGINT (sent by pressing Ctrl+C) to interrupt a foreground process
  • SIGTSTP (sent by Ctrl+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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 like execlp.
  • Handle builtin commands, like cd, exit, and jobs. These can't be run by execvp 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 use pipe() and dup2() 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)