control-systems-and-automation
Building a Basic Shell in C: Parsing Commands and Executing Processes
Table of Contents
Building a shell from scratch in C remains one of the best ways to deepen your understanding of how operating systems manage processes, handle user input, and execute programs. While modern shells like Bash, Zsh, and Fish are incredibly sophisticated, their core functionality boils down to a simple loop: read a command, parse it, create a new process, and wait for it to finish. In this expanded guide, you will walk through each of these steps in detail, from setting up a robust input parser to handling process creation with fork and exec. By the end, you will have a working shell that you can extend with redirection, pipelines, and job control. The code is kept deliberately straightforward to highlight the underlying system concepts.
What a Shell Actually Does
At its heart, a shell is a command-line interpreter. It provides a textual interface where users type commands, and the shell translates those commands into actions performed by the operating system. When you type ls -la, the shell must find the ls executable, create a new process to run it, pass the arguments -la, and then wait for that process to finish before prompting you for the next command. This seemingly simple behavior involves several fundamental system calls and careful management of memory and processes.
Building a minimal shell teaches you about:
- Reading and tokenizing input from standard input
- Creating and managing child processes with fork
- Replacing a process's memory image with exec
- Synchronizing parent and child with wait
- Handling common errors gracefully
Understanding these building blocks gives you insight into how all Unix-like systems operate under the hood, and it provides a solid foundation for learning about more advanced features such as signal handling, job control, and inter-process communication.
Setting Up the Shell Loop
Every shell is built around a main loop that repeats indefinitely until the user requests to exit. This loop prints a prompt, reads a line of input, parses it, and then acts on the parsed command. The simplest version looks like this in pseudocode:
while (1) {
print_prompt();
read_input();
parse_input();
execute_command();
}
In C, you typically use fgets to read input because it handles line boundaries and buffer overflows more safely than gets. The prompt can be as simple as a string like mysh> , but real shells often include the current working directory, username, and hostname. For now, a static prompt is sufficient to get started.
Handling Edge Cases in Input
User input is rarely clean. A robust shell must handle empty lines, leading and trailing whitespace, extremely long commands, and the end-of-file condition (Ctrl+D). If fgets returns NULL, you should break out of the loop and exit gracefully. If the input consists only of whitespace, the shell should simply re-prompt without attempting to execute anything. You should also handle the case where the input line is longer than your fixed buffer by either truncating it or dynamically resizing the buffer. For a basic implementation, a buffer of 1024 bytes is usually safe, but you should always check that the full line was read by verifying that the string ends with a newline character.
#define MAX_INPUT 1024
char input[MAX_INPUT];
if (fgets(input, MAX_INPUT, stdin) == NULL) {
printf("\n");
break; // EOF
}
// Remove trailing newline, if present
size_t len = strlen(input);
if (len > 0 && input[len-1] == '\n') {
input[len-1] = '\0';
} else {
// Input too long, flush remaining characters
int ch;
while ((ch = getchar()) != '\n' && ch != EOF);
}
Parsing Commands into Arguments
Once you have a clean input string, you need to split it into tokens. The first token is the command name (e.g., ls), and the remaining tokens are the arguments to that command. The C standard library provides strtok for this purpose, but you must be careful because strtok modifies the original string and is not thread-safe. For a single-threaded shell like this one, it works fine.
char *args[MAX_ARGS];
int arg_count = 0;
args[arg_count] = strtok(input, " \t");
while (args[arg_count] != NULL && arg_count < MAX_ARGS - 1) {
arg_count++;
args[arg_count] = strtok(NULL, " \t");
}
args[arg_count] = NULL; // execvp expects a NULL-terminated array
This tokenizer splits the input on spaces and tabs. It does not handle quoted strings, so a command like echo "hello world" would incorrectly break into three tokens rather than two. Handling quotes is a valuable enhancement that requires a more sophisticated parser, but for the basic shell, this simple approach is enough to run most single-word commands.
After tokenization, you should check if the first token is NULL (empty command). If so, simply continue to the next iteration of the loop without forking.
Built-In Commands
Not all commands should spawn a new process. Some commands, like cd and exit, must be executed by the shell process itself because they affect the shell's state. For example, cd changes the current working directory of the shell; if you forked a child process for cd, that child would change its own directory and then exit, leaving the parent's working directory unchanged.
Exit
The exit command terminates the shell immediately. It is the simplest built-in to implement: just check if the first token equals "exit" and break out of the main loop. Optionally, you can accept an exit status argument and pass it to the exit system call.
Change Directory (cd)
The cd command requires you to call chdir. The target directory is the second argument. If no argument is provided, you might default to the user's home directory (available via the HOME environment variable). Always check the return value of chdir and print an error message if the directory does not exist or is not accessible.
if (strcmp(args[0], "cd") == 0) {
const char *path = args[1];
if (path == NULL) {
path = getenv("HOME");
if (path == NULL) {
fprintf(stderr, "cd: HOME not set\n");
continue;
}
}
if (chdir(path) != 0) {
perror("cd");
}
continue; // skip fork/exec
}
Process Creation with Fork
For any command that is not a built-in, your shell must create a child process to execute it. The fork system call creates a new process by duplicating the calling process. The new process is called the child, and the original is the parent. After fork, both processes continue executing from the same point in the code. The only difference is the return value of fork: it returns 0 to the child, and the child's PID to the parent.
pid_t pid = fork();
if (pid == -1) {
perror("fork");
continue;
}
if (pid == 0) {
// Child process
// ...
} else {
// Parent process
// ...
}
Why Fork?
You might wonder why you need to create a separate process at all. The reason is that exec, which loads a new program into memory, replaces the current process entirely. If the shell called exec directly, the shell program would be replaced and never return to accept new commands. By forking first, the child can call exec without affecting the parent shell.
Executing a Program with Exec
The exec family of functions replaces the current process with a new program. There are several variants: execl, execlp, execle, execv, execvp, and execvpe. The key difference among them is how the program is located and how arguments are passed. For your shell, execvp is the most convenient because it searches the PATH environment variable for the executable and accepts a NULL-terminated array of arguments, which matches exactly what you built during parsing.
if (pid == 0) {
// Child process
execvp(args[0], args);
// If execvp returns, an error occurred
perror("exec");
exit(EXIT_FAILURE);
}
Note the call to exit after perror. If execvp fails (e.g., because the command does not exist), the child process must terminate; otherwise, it would continue running whatever code followed, which is usually the parent's shell loop. That would result in two shells running and competing for input.
Waiting for the Child Process
After forking, the parent process typically waits for the child to finish before prompting again. This is done with wait or waitpid. The waitpid function gives you more control because you can specify which child to wait for (using the PID returned by fork) and potentially set options to avoid blocking.
int status;
waitpid(pid, &status, 0);
The status variable contains information about how the child terminated. You can use macros like WIFEXITED, WEXITSTATUS, WIFSIGNALED, and WTERMSIG to extract details. For a basic shell, it is enough to know that the child has finished; printing the exit code is a nice touch for debugging.
Blocking vs. Non-Blocking
The simple waitpid call above blocks the parent until the child exits. This is the correct behavior for a foreground process. If you later add support for background processes (running a command with &), you would set the WNOHANG option to avoid blocking, and you would need to manage a list of child PIDs to reap them later.
Putting Together the Full Shell
Here is a complete, minimal shell that integrates all the pieces discussed so far. It handles the exit and cd built-ins, parses input using strtok, forks for external commands, and waits for the child to finish. For clarity, error checking is included but kept concise.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#define MAX_INPUT 1024
#define MAX_ARGS 64
int main(void) {
char input[MAX_INPUT];
char *args[MAX_ARGS];
int should_run = 1;
while (should_run) {
printf("mysh> ");
fflush(stdout);
if (fgets(input, MAX_INPUT, stdin) == NULL) {
printf("\n");
break;
}
// Remove trailing newline
size_t len = strlen(input);
if (len > 0 && input[len-1] == '\n') {
input[len-1] = '\0';
}
// Tokenize
int i = 0;
args[i] = strtok(input, " \t");
while (args[i] != NULL && i < MAX_ARGS - 1) {
i++;
args[i] = strtok(NULL, " \t");
}
args[i] = NULL;
if (args[0] == NULL) {
continue; // empty line
}
// Handle built-in commands
if (strcmp(args[0], "exit") == 0) {
should_run = 0;
continue;
}
if (strcmp(args[0], "cd") == 0) {
const char *path = args[1];
if (path == NULL) {
path = getenv("HOME");
}
if (chdir(path) != 0) {
perror("cd");
}
continue;
}
// Fork and execute external command
pid_t pid = fork();
if (pid < 0) {
perror("fork");
continue;
}
if (pid == 0) {
// Child
execvp(args[0], args);
perror("exec");
exit(EXIT_FAILURE);
} else {
// Parent waits
int status;
waitpid(pid, &status, 0);
}
}
return 0;
}
This code is a complete, working shell. Copy it into a file called myshell.c, compile it with gcc -o myshell myshell.c, and run it. You will see a prompt where you can type commands like ls, pwd, echo hello, and cd /tmp. The exit command terminates the shell.
Common Pitfalls and Debugging Tips
Even with this small amount of code, several things can go wrong. Here are the most frequent issues and how to fix them:
Command Not Found
If you type a command that does not exist (e.g., foobar), execvp returns and the child prints "exec: No such file or directory" before exiting. This is correct behavior, but you might want to print a friendler message. You can check errno after execvp fails to distinguish between "file not found" and "permission denied".
Missing Newline or Truncated Input
If your prompt appears without waiting for input, the likely cause is leftover characters in the input buffer from a previous call. Always check that fgets consumed the entire line (i.e., that the last character before the null terminator is a newline). If it is not, flush the remaining input as shown earlier.
Zombie Processes
If you forget to call wait (or waitpid), child processes that finish become zombies until you reap them. The shell in the example does call waitpid, so zombies should not appear. However, if you later add background processes and fail to reap them, zombie accumulation can become a problem. The solution is to install a SIGCHLD handler that calls waitpid with WNOHANG to clean up completed children.
Extending the Shell
Once you have the basic shell working, you can add features that bring it closer to a real-world shell. Each feature teaches you more about the operating system.
Input/Output Redirection
Supporting >, <, and >> requires you to parse the command line for these operators, open the appropriate files using open, and use dup2 to redirect standard input or output before calling execvp. This is done in the child process between fork and exec.
Pipes
Piping the output of one command into the input of another (cmd1 | cmd2) is more involved. You need to create a pipe with pipe, fork two child processes, redirect one child's output to the write end of the pipe and the other's input to the read end, then wait for both to complete.
Job Control
Adding background execution (& at the end of a command) and the ability to bring jobs to the foreground requires managing a job table, handling SIGTSTP, SIGCONT, and SIGCHLD, and using tcsetpgrp to manage terminal ownership.
Command History
Implementing a simple history mechanism (up/down arrows to recall previous commands) involves capturing input using raw terminal mode (via tcgetattr and tcsetattr) or using the readline library, which provides this functionality out of the box.
Further Reading and Resources
To deepen your understanding of the concepts presented here, the following resources are invaluable:
- GNU C Library manual: Process Creation Examples – Official documentation on fork, exec, and wait.
- The Open Group Base Specifications: Shell Command Language – The formal specification for POSIX shell behavior; useful if you want to match a standard.
- Wikipedia: Unix shell – A broad overview of shell history, variants, and features.
- Beej's Guide to Interprocess Communication – Covers pipes, FIFOs, message queues, and shared memory with practical examples.
Conclusion
Building a basic shell in C is more than an academic exercise; it forces you to engage directly with the operating system's core abstractions. You have seen how to read and parse user input, how fork creates a new process, and how exec replaces that process's memory with a requested program. You have also added built-in commands that must run in the shell's own context and handled errors gracefully. The complete shell code provided in this article is a solid starting point that you can extend with redirection, pipelines, job control, and history. Each extension will deepen your understanding of process management, file descriptors, and signals. By the time you have added even a few of these features, you will have a tool that is genuinely useful and a much stronger grasp of how modern shells work beneath the surface.