Skip to Content
Course content

220: Implementing a Simple Shell

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When you first decide to build a shell, the temptation is to get a prompt on the screen and make it "work" as quickly as possible. You want to type ls -la and see your files. If you look through the standard library, you'll find a function called system() that seems like a miracle. It takes a string, hands it to the OS, and runs it. It's seductive because it allows you to write a "shell" in about ten lines of code.

The shortcut that isn't actually a solution

In a naive implementation, you might write a loop that reads a line of input using fgets() and then passes that string directly into system(). It looks like this:

while (1) {
    printf("my_shell> ");
    fgets(input, 1024, stdin);
    system(input); 
}

Here is the problem: system() is a wrapper. Under the hood, it literally calls /bin/sh -c "your_command". If you use system(), you haven't actually built a shell; you've built a very thin, redundant wrapper around the existing system shell. You're not managing processes; you're just asking another program to do it for you.

Beyond the "cheating" aspect, this approach is a security disaster. Because system() invokes a shell, it's prone to shell injection. If your program ever took input from an untrusted source, a user could pass ls; rm -rf / and your program would happily execute both. I've seen juniors do this in internal tools, and it usually ends with a very stressful afternoon for the DevOps team.

Taking the steering wheel with fork and exec

To build a real shell, you need to understand how the Unix process model actually works. You don't just "run" a program; you create a copy of your current process and then transform that copy into the program you want. This is the fork() and exec() pattern.

Instead of system(), you first call fork(). This creates a child process that is an exact duplicate of your shell. At this point, you have two identical programs running the same code. You then use the return value of fork() to tell them apart: the parent continues to wait for the user, while the child calls execvp().

pid_t pid = fork();

if (pid == 0) {
    // I am the child. I will replace myself with the new program.
    if (execvp(args[0], args) == -1) {
        perror("Execution failed");
    }
    exit(EXIT_FAILURE);
} else if (pid < 0) {
    perror("Fork failed");
} else {
    // I am the parent. I need to wait for the child to finish.
    int status;
    waitpid(pid, &status, 0);
}

The execvp() function is the magic here. It doesn't "start" a new program in the way we think about it; it replaces the entire memory image of the child process with the binary of the command you're calling (like /bin/ls). The child process ceases to be your shell and becomes the ls program.

Why the extra boilerplate is worth the headache

You might look at the fork/exec/wait dance and think it's overkill. Why write twenty lines of code when system() does it in one? The answer is control. When you manage the process yourself, you gain abilities that system() hides from you.

  • Signal Handling: You can now decide what happens when the user hits Ctrl+C. Should the shell exit, or should only the running child process be killed? With fork(), you can send signals specifically to the child's PID.
  • Input/Output Redirection: If you want to implement ls > files.txt, you can call dup2() in the child process after the fork but before the exec. This swaps the child's standard output for a file descriptor. You can't do that with system() without manually concatenating strings and hoping for the best.
  • Environment Control: You can use execvpe() to pass a custom environment to the child process without altering the environment of your main shell.

The trade-off is that you are now responsible for "zombie" processes. If you fork() but forget to waitpid(), the child process remains in the system table after it finishes, wasting resources. It's a small price to pay for actually understanding how your OS handles execution.




📋 Practical Task

Exercise: Implementing a Directory-Aware Shell with Built-in Commands

One critical realization when building a shell is that some commands cannot be run via fork() and exec(). Specifically, the cd command. Since a child process cannot change the working directory of its parent, calling execvp("cd", ...) will change the directory for the child, which then immediately exits, leaving your shell exactly where it started.

Your task is to expand the shell logic discussed in the lesson. Create a program that:

  • Implements a standard fork/exec/wait loop for general commands (like ls, ps, or date).
  • Intercepts the cd command specifically. If the user types cd [path], the shell should call the C library function chdir() directly in the parent process instead of forking.
  • Handles the exit command to terminate the shell loop gracefully.
  • Properly parses the input string into an array of arguments (char *args[]) so that execvp can receive them correctly.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.