Chapter 39: Shell
- What a shell is and how it interacts with the kernel
- How to read input, parse commands, and execute programs
- How fork, exec, and waitpid work together to run external programs
- How to implement pipes and I/O redirection
- How to add job control, environment variables, and built-in commands
- Our minimal shell implementation for the kernel
39.1 What Is a Shell?
A shell is a user-space program that provides a text-based interface to the operating system. It reads commands from the user, parses them, and executes programs. The shell is the primary way users interact with the system when no graphical interface is available.
Every shell follows the same basic loop:
- Print a prompt (
$or#) - Read a line of input
- Parse the line into a command and arguments
- Execute the command (either built-in or external)
- Go back to step 1
This is called the REPL (Read-Eval-Print Loop).
39.2 Reading Input
The shell reads from stdin (file descriptor 0) using the read() syscall. Input is character-by-character to allow interactive features like tab completion and line editing:
#define MAX_LINE 512
#define MAX_ARGS 64
/* Read one line of input with basic line editing */
int read_line(char *buf, int size) {
int i = 0;
char c;
while (i < size - 1) {
if (read(0, &c, 1) != 1) break;
if (c == '\n') { buf[i] = 0; return i; }
if (c == '\b' && i > 0) { i--; write(1, "\b \b", 3); continue; }
buf[i++] = c;
write(1, &c, 1); /* Echo */
}
buf[i] = 0;
return i;
}
39.3 Parsing Commands
The input line is split into tokens (the command name and arguments). Tokens are separated by whitespace. Quoted strings are treated as single tokens:
/* Parse a line into argv array. Returns argc. */
int parse_line(char *line, char **argv, int max_args) {
int argc = 0;
while (*line) {
/* Skip whitespace */
while (*line == ' ' || *line == '\t') line++;
if (!*line) break;
if (argc >= max_args - 1) break;
if (*line == '"') {
/* Quoted string */
line++;
argv[argc] = line;
while (*line && *line != '"') line++;
if (*line) { *line = 0; line++; }
} else {
/* Unquoted token */
argv[argc] = line;
while (*line && *line != ' ' && *line != '\t') line++;
if (*line) { *line = 0; line++; }
}
argc++;
}
argv[argc] = NULL;
return argc;
}
39.4 Built-in Commands
Some commands are handled directly by the shell without creating a new process. These are built-in commands:
/* Execute a built-in command. Returns 1 if handled, 0 if not. */
int exec_builtin(char **argv) {
if (!strcmp(argv[0], "exit")) {
exit(0);
}
if (!strcmp(argv[0], "cd")) {
/* chdir syscall (hypothetical: syscall 49 on ARM64) */
if (argv[1]) syscall(49, (long)argv[1], 0, 0, 0, 0, 0);
return 1;
}
if (!strcmp(argv[0], "help")) {
write(1, "built-in: exit, cd, help, echo, ls\n", 35);
return 1;
}
if (!strcmp(argv[0], "echo")) {
for (int i = 1; argv[i]; i++) {
write(1, argv[i], strlen(argv[i]));
if (argv[i+1]) write(1, " ", 1);
}
write(1, "\n", 1);
return 1;
}
return 0;
}
39.5 Executing External Programs
External commands are executed by forking a child process and calling execve(). The parent waits for the child to finish:
/* Execute an external program */
int exec_external(char **argv) {
pid_t pid = fork();
if (pid < 0) return -1;
if (pid == 0) {
/* Child: execute the program */
char *envp[] = { "PATH=/bin", NULL };
execve(argv[0], argv, envp);
/* If execve returns, it failed */
write(2, "command not found\n", 18);
exit(1);
}
/* Parent: wait for child */
int status;
waitpid(pid, &status, 0);
return status;
}
39.6 Pipes and Redirection
A shell must support I/O redirection (>, <) and pipes (|). Redirection changes stdin/stdout to files. Pipes connect the stdout of one process to the stdin of another:
/* Parse and handle pipes */
int parse_and_execute(char *line) {
/* Split on | */
char *commands[MAX_ARGS];
int ncmds = 0;
char *p = line;
commands[ncmds++] = p;
while (*p) {
if (*p == '|') {
*p = 0;
p++;
commands[ncmds++] = p;
}
p++;
}
int prev_pipe[2] = { -1, -1 };
for (int i = 0; i < ncmds; i++) {
int next_pipe[2] = { -1, -1 };
if (i < ncmds - 1) pipe(next_pipe);
pid_t pid = fork();
if (pid == 0) {
if (prev_pipe[0] != -1) {
dup2(prev_pipe[0], 0);
close(prev_pipe[0]);
close(prev_pipe[1]);
}
if (next_pipe[1] != -1) {
dup2(next_pipe[1], 1);
close(next_pipe[0]);
close(next_pipe[1]);
}
char *argv[MAX_ARGS];
parse_line(commands[i], argv, MAX_ARGS);
execve(find_in_path(argv[0]), argv, environ);
exit(1);
}
if (prev_pipe[0] != -1) {
close(prev_pipe[0]);
close(prev_pipe[1]);
}
prev_pipe[0] = next_pipe[0];
prev_pipe[1] = next_pipe[1];
}
/* Wait for all children */
for (int i = 0; i < ncmds; i++) wait(NULL);
return 0;
}
39.7 The Main Loop
int main(int argc, char **argv) {
char line[MAX_LINE];
char *args[MAX_ARGS];
write(1, "OS Shell v1.0\n", 14);
while (1) {
write(1, "$ ", 2);
if (read_line(line, MAX_LINE) <= 0) break;
int argc = parse_line(line, args, MAX_ARGS);
if (argc == 0) continue;
/* Check for pipes */
int has_pipe = 0;
for (int i = 0; args[i]; i++) {
if (args[i][0] == '|') { has_pipe = 1; break; }
}
if (has_pipe) {
parse_and_execute(line);
} else if (!exec_builtin(args)) {
exec_external(args);
}
}
write(1, "\n", 1);
return 0;
}
39.8 Our Implementation
Our shell (/bin/sh) runs as the first user-space process (PID 1) after kernel init. It provides:
- Command parsing: whitespace-separated tokens with quoted string support
- Built-in commands: exit, cd, help, echo
- External program execution: fork + execve + waitpid
- I/O redirection: > (stdout), < (stdin), >> (append)
- Pipes: connect multiple commands with |
- PATH lookup: searches /bin, /sbin, /usr/bin for executables
The shell source is kept minimal (~500 lines of C) to serve as both a usable shell and a reference implementation. To add a new built-in, add an entry to exec_builtin() and update the help text.
39.9 Exercises
Exercise 1: Add a Built-in
Add a clear built-in that writes an escape sequence (CSI 2J) to stdout to clear the terminal.
Exercise 2: Implement PATH Search
Write find_in_path() that searches the PATH environment variable for the executable. Return the full path.
Exercise 3: History
Add command history: store the last 16 commands and allow navigating them with arrow keys.
Exercise 4: Signal Handling
Make the shell ignore SIGINT in the parent process but forward it to the foreground child.
39.10 Summary
The shell is the user's primary interface to the operating system. It implements a read-eval-print loop: read a line, parse it into arguments, execute (either built-in or via fork/exec), and loop. Pipes and redirection are implemented by manipulating file descriptors between forked children. Our shell is minimal by design but forms the foundation for all user interaction with the kernel.