Projects · about 120 min

Project: a tiny shell

Why this matters

Every time you type a command in a terminal, a shell does the same short dance: read a line, split it into words, fork a copy of itself, exec the program in the copy, and wait for it to finish. That dance is the foundation of process management on Unix, and it is surprisingly little code. Building tsh ties together almost the whole track: strings and tokenizing, heap buffers, error handling with errno, file descriptors, and the process system calls from the systems module. After this project, the behavior of real shells (why cd is special, how > works, what $? really holds) stops being folklore.

What we are building

$ ./tsh
tsh> echo hello
hello
tsh> cd /tmp
tsh> pwd
/tmp
tsh> sort < data.txt > sorted.txt
tsh> nosuch
tsh: nosuch: command not found
tsh> exit
$ echo $?
127

When stdin is not a terminal (for example, ./tsh < script.txt), the prompt is not printed, so the shell can also run scripts. isatty(STDIN_FILENO) tells the two cases apart, the same way real shells decide whether they are interactive.

The read-eval loop

        +--------------------------------------------+
        v                                            |
  print prompt (if interactive)                      |
        |                                            |
  getline  --EOF-->  exit with last status           |
        |                                            |
  split into argv[]                                  |
        |                                            |
  builtin? --yes--> run it in the shell itself ------+
        | no                                         |
  fork --> child:  redirect, execvp, (_exit 127)     |
        |                                            |
  parent: waitpid, record status --------------------+

Step 1: splitting the line

execvp wants an array of char * terminated by NULL, exactly the shape of main's argv. strtok produces it in place: each word is terminated where the separator was, and argv holds pointers into the line buffer.

  line:  "ls   -l /tmp\n"   becomes   "ls\0  -l\0/tmp\0"
          ^     ^  ^                   ^     ^   ^
  argv:  [0]   [1] [2]  [3] = NULL

The capacity check reserves one slot for the terminating NULL, the same off-by-one discipline as reserving a byte for a string's '\0'. This simple splitter does not understand quotes, so echo "a b" passes "a and b" as two words; handling quoting properly is a parser of its own and a good extension.

Step 2: fork, exec, wait

fflush(stdout);
pid_t pid = fork();
if (pid == 0) {                       /* child */
    execvp(argv[0], argv);            /* only returns on failure */
    fprintf(stderr, "tsh: %s: command not found\n", argv[0]);
    _exit(127);
}
int status;
waitpid(pid, &status, 0);             /* parent */

The key facts, from the systems module:

Step 3: builtins

A child process cannot change its parent. The working directory belongs to each process separately, so a cd program would change only its own directory and then exit, leaving the shell where it was. cd must therefore run inside the shell, by calling chdir directly. The same goes for exit, which must end the shell itself. These are builtins: checked by name before forking.

cd with no argument uses getenv("HOME"). The environment is a list of NAME=value strings every process inherits from its parent; the sandbox sets HOME=/home/learner.

Step 4: redirection

> and < are implemented entirely with file descriptors. Every process starts with fd 0 (stdin), 1 (stdout) and 2 (stderr). Programs like sort just read fd 0 and write fd 1; they have no idea what those refer to. So, in the child, between fork and exec:

int f = open("sorted.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(f, STDOUT_FILENO);    /* fd 1 now refers to the same open file as f */
close(f);                  /* the extra descriptor is no longer needed */
execvp(argv[0], argv);     /* sort inherits fd 1 = sorted.txt */
  before dup2:  fd 0 -> terminal   fd 1 -> terminal   fd 3 -> sorted.txt
  after dup2:   fd 0 -> terminal   fd 1 -> sorted.txt fd 3 -> sorted.txt
  after close:  fd 0 -> terminal   fd 1 -> sorted.txt

Doing it in the child is what keeps the shell's own stdout pointing at the terminal. This "fork, rearrange descriptors, exec" pattern is how every shell implements >, <, 2> and pipes: a pipeline a | b is two children whose fd 1 and fd 0 are joined with pipe and dup2.

The redirection words must be removed from argv before exec, or sort would try to open a file named <. Compacting the array with a read index and a write index does that in place.

Common mistakes

Recap

Going further: add pipelines (ls | wc -l) with pipe, two forks and dup2; then 2> for stderr, & for background jobs (and reaping them with waitpid(-1, &st, WNOHANG)), and quoting in the splitter. Compare your shell's behavior with bash on the same scripts. The classic reading is the "Shell" chapter of Advanced Programming in the UNIX Environment by Stevens and Rago.

Exercises

Exercise 1 · write code

Step 1: split a command line

Write int split_line(char *line, char **argv, int max). It splits line in place into words separated by spaces, tabs and newlines, stores a pointer to each word in argv[0], argv[1], ..., sets the entry after the last word to NULL (as execvp requires), and returns the number of words. max is the size of the argv array, so at most max - 1 words fit; if there are more, return -1.

main prints each word in brackets, for example for the input line ls -l /tmp:

3: [ls] [-l] [/tmp]

File: main.c

Starting point

#include <stdio.h>
#include <string.h>

#define MAX_ARGS 8

int split_line(char *line, char **argv, int max)
{
    argv[0] = NULL;
    return 0;
}

int main(void)
{
    char line[256];
    char *argv[MAX_ARGS];
    while (fgets(line, sizeof line, stdin) != NULL) {
        int n = split_line(line, argv, MAX_ARGS);
        if (n < 0) {
            printf("too many words\n");
            continue;
        }
        printf("%d:", n);
        for (int i = 0; argv[i] != NULL; i++)
            printf(" [%s]", argv[i]);
        printf("\n");
    }
    return 0;
}

Example

input

ls   -l /tmp

expected output

3: [ls] [-l] [/tmp]

Example

input

	echo	hi 

1 2 3 4 5 6 7 8
1 2 3 4 5 6 7

expected output

2: [echo] [hi]
0:
too many words
7: [1] [2] [3] [4] [5] [6] [7]
Hint 1

strtok(line, " \t\n") returns the first word and terminates it in place; strtok(NULL, " \t\n") returns the next.

Hint 2

Before storing a word, check that there is still room for it AND the final NULL: n must be less than max - 1.

Hint 3

After the loop, argv[n] = NULL; return n;

Reference solution
#include <stdio.h>
#include <string.h>

#define MAX_ARGS 8

int split_line(char *line, char **argv, int max)
{
    int n = 0;
    for (char *w = strtok(line, " \t\n"); w != NULL; w = strtok(NULL, " \t\n")) {
        if (n == max - 1)
            return -1;
        argv[n++] = w;
    }
    argv[n] = NULL;
    return n;
}

int main(void)
{
    char line[256];
    char *argv[MAX_ARGS];
    while (fgets(line, sizeof line, stdin) != NULL) {
        int n = split_line(line, argv, MAX_ARGS);
        if (n < 0) {
            printf("too many words\n");
            continue;
        }
        printf("%d:", n);
        for (int i = 0; argv[i] != NULL; i++)
            printf(" [%s]", argv[i]);
        printf("\n");
    }
    return 0;
}

Exercise 2 · write code

Step 2: run commands

Make the shell run programs. For each non-empty input line (lines starting with # are comments), run_external must:

  1. fflush(stdout) (so nothing buffered is duplicated into the child),
  2. fork(); in the child, execvp(argv[0], argv). If exec fails, print tsh: NAME: command not found to stderr when errno is ENOENT (otherwise tsh: NAME: followed by strerror(errno)), and end the child with _exit(127);
  3. in the parent, waitpid for the child and return its status: the exit code if it exited normally (WIFEXITED/WEXITSTATUS), or 128 plus the signal number if a signal killed it (WIFSIGNALED/WTERMSIG).

When input ends, the shell exits with the status of the last command (0 if there was none). The prompt is only printed when stdin is a terminal, so the checks see no prompt.

$ printf 'echo hello
nosuch
' | ./prog
hello
tsh: nosuch: command not found
$ echo $?
127

File: main.c

Starting point

#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>

#define MAX_ARGS 64

int split_line(char *line, char **argv, int max)
{
    int n = 0;
    for (char *w = strtok(line, " \t\n"); w != NULL; w = strtok(NULL, " \t\n")) {
        if (n == max - 1)
            return -1;
        argv[n++] = w;
    }
    argv[n] = NULL;
    return n;
}

/* Run argv as a child process and return its status. */
static int run_external(char **argv)
{
    (void)argv;
    return 0;
}

int main(void)
{
    char *line = NULL;
    size_t cap = 0;
    char *argv[MAX_ARGS];
    int last = 0;
    int interactive = isatty(STDIN_FILENO);

    for (;;) {
        if (interactive) {
            printf("tsh> ");
            fflush(stdout);
        }
        if (getline(&line, &cap, stdin) == -1)
            break;
        if (line[0] == '#')
            continue;
        int n = split_line(line, argv, MAX_ARGS);
        if (n < 0) {
            fprintf(stderr, "tsh: too many arguments\n");
            last = 2;
            continue;
        }
        if (n == 0)
            continue;
        last = run_external(argv);
    }
    free(line);
    return last;
}

Example

input

echo hello

# a comment
echo  a   b

expected output

hello
a b

Example

input

echo before
nosuchcommand

expected output

before
Hint 1

fork() returns 0 in the child, the child's pid in the parent, and -1 on failure.

Hint 2

execvp only returns if it failed. Everything after it in the child is error handling, and the child must end with _exit(127) so it does not fall back into the shell's loop.

Hint 3

Use _exit, not exit, in the child: exit would flush the parent's stdio buffers a second time.

Hint 4

In the parent: int status; waitpid(pid, &status, 0); then decode with WIFEXITED(status) ? WEXITSTATUS(status) : ...

Reference solution
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>

#define MAX_ARGS 64

int split_line(char *line, char **argv, int max)
{
    int n = 0;
    for (char *w = strtok(line, " \t\n"); w != NULL; w = strtok(NULL, " \t\n")) {
        if (n == max - 1)
            return -1;
        argv[n++] = w;
    }
    argv[n] = NULL;
    return n;
}

/* Run argv as a child process and return its status. */
static int run_external(char **argv)
{
    fflush(stdout);
    pid_t pid = fork();
    if (pid < 0) {
        perror("tsh: fork");
        return 1;
    }
    if (pid == 0) {
        execvp(argv[0], argv);
        if (errno == ENOENT)
            fprintf(stderr, "tsh: %s: command not found\n", argv[0]);
        else
            fprintf(stderr, "tsh: %s: %s\n", argv[0], strerror(errno));
        _exit(127);
    }
    int status;
    while (waitpid(pid, &status, 0) < 0) {
        if (errno != EINTR) {
            perror("tsh: waitpid");
            return 1;
        }
    }
    if (WIFEXITED(status))
        return WEXITSTATUS(status);
    if (WIFSIGNALED(status))
        return 128 + WTERMSIG(status);
    return 1;
}

int main(void)
{
    char *line = NULL;
    size_t cap = 0;
    char *argv[MAX_ARGS];
    int last = 0;
    int interactive = isatty(STDIN_FILENO);

    for (;;) {
        if (interactive) {
            printf("tsh> ");
            fflush(stdout);
        }
        if (getline(&line, &cap, stdin) == -1)
            break;
        if (line[0] == '#')
            continue;
        int n = split_line(line, argv, MAX_ARGS);
        if (n < 0) {
            fprintf(stderr, "tsh: too many arguments\n");
            last = 2;
            continue;
        }
        if (n == 0)
            continue;
        last = run_external(argv);
    }
    free(line);
    return last;
}

Exercise 3 · write code

Step 3: cd and exit

Some commands cannot be separate programs. Add two builtins, handled in main before run_external:

$ printf 'cd /tmp
pwd
exit 3
echo never
' | ./prog
/tmp
$ echo $?
3

File: main.c

Starting point

#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>

#define MAX_ARGS 64

int split_line(char *line, char **argv, int max)
{
    int n = 0;
    for (char *w = strtok(line, " \t\n"); w != NULL; w = strtok(NULL, " \t\n")) {
        if (n == max - 1)
            return -1;
        argv[n++] = w;
    }
    argv[n] = NULL;
    return n;
}

static int run_external(char **argv)
{
    fflush(stdout);
    pid_t pid = fork();
    if (pid < 0) {
        perror("tsh: fork");
        return 1;
    }
    if (pid == 0) {
        execvp(argv[0], argv);
        if (errno == ENOENT)
            fprintf(stderr, "tsh: %s: command not found\n", argv[0]);
        else
            fprintf(stderr, "tsh: %s: %s\n", argv[0], strerror(errno));
        _exit(127);
    }
    int status;
    while (waitpid(pid, &status, 0) < 0) {
        if (errno != EINTR) {
            perror("tsh: waitpid");
            return 1;
        }
    }
    if (WIFEXITED(status))
        return WEXITSTATUS(status);
    if (WIFSIGNALED(status))
        return 128 + WTERMSIG(status);
    return 1;
}

int main(void)
{
    char *line = NULL;
    size_t cap = 0;
    char *argv[MAX_ARGS];
    int last = 0;
    int interactive = isatty(STDIN_FILENO);

    for (;;) {
        if (interactive) {
            printf("tsh> ");
            fflush(stdout);
        }
        if (getline(&line, &cap, stdin) == -1)
            break;
        if (line[0] == '#')
            continue;
        int n = split_line(line, argv, MAX_ARGS);
        if (n < 0) {
            fprintf(stderr, "tsh: too many arguments\n");
            last = 2;
            continue;
        }
        if (n == 0)
            continue;
        /* TODO: builtins exit and cd */
        last = run_external(argv);
    }
    free(line);
    return last;
}

Example

input

cd /tmp
pwd
cd /
pwd

expected output

/tmp
/

Example

input

cd /tmp
cd
pwd

expected output

/home/learner
Hint 1

Compare argv[0] with "exit" and "cd" using strcmp before calling run_external.

Hint 2

For exit, break out of the loop (so line is still freed) after setting last from atoi(argv[1]) when an argument is given.

Hint 3

For cd: const char *dir = argv[1] ? argv[1] : getenv("HOME"); then if (chdir(dir) != 0) report with strerror(errno).

Reference solution
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>

#define MAX_ARGS 64

int split_line(char *line, char **argv, int max)
{
    int n = 0;
    for (char *w = strtok(line, " \t\n"); w != NULL; w = strtok(NULL, " \t\n")) {
        if (n == max - 1)
            return -1;
        argv[n++] = w;
    }
    argv[n] = NULL;
    return n;
}

static int run_external(char **argv)
{
    fflush(stdout);
    pid_t pid = fork();
    if (pid < 0) {
        perror("tsh: fork");
        return 1;
    }
    if (pid == 0) {
        execvp(argv[0], argv);
        if (errno == ENOENT)
            fprintf(stderr, "tsh: %s: command not found\n", argv[0]);
        else
            fprintf(stderr, "tsh: %s: %s\n", argv[0], strerror(errno));
        _exit(127);
    }
    int status;
    while (waitpid(pid, &status, 0) < 0) {
        if (errno != EINTR) {
            perror("tsh: waitpid");
            return 1;
        }
    }
    if (WIFEXITED(status))
        return WEXITSTATUS(status);
    if (WIFSIGNALED(status))
        return 128 + WTERMSIG(status);
    return 1;
}

static int builtin_cd(char **argv)
{
    const char *dir = argv[1] != NULL ? argv[1] : getenv("HOME");
    if (dir == NULL) {
        fprintf(stderr, "tsh: cd: HOME not set\n");
        return 1;
    }
    if (chdir(dir) != 0) {
        fprintf(stderr, "tsh: cd: %s: %s\n", dir, strerror(errno));
        return 1;
    }
    return 0;
}

int main(void)
{
    char *line = NULL;
    size_t cap = 0;
    char *argv[MAX_ARGS];
    int last = 0;
    int interactive = isatty(STDIN_FILENO);

    for (;;) {
        if (interactive) {
            printf("tsh> ");
            fflush(stdout);
        }
        if (getline(&line, &cap, stdin) == -1)
            break;
        if (line[0] == '#')
            continue;
        int n = split_line(line, argv, MAX_ARGS);
        if (n < 0) {
            fprintf(stderr, "tsh: too many arguments\n");
            last = 2;
            continue;
        }
        if (n == 0)
            continue;
        if (strcmp(argv[0], "exit") == 0) {
            if (argv[1] != NULL)
                last = atoi(argv[1]);
            break;
        }
        if (strcmp(argv[0], "cd") == 0) {
            last = builtin_cd(argv);
            continue;
        }
        last = run_external(argv);
    }
    free(line);
    return last;
}

Exercise 4 · write code

Step 4: redirection

Add < FILE and > FILE for external commands. The operators are separate words (sort < data.txt > sorted.txt), may appear anywhere after the command name, and are removed from argv before execvp.

In the child, before execvp: for < FILE open it with O_RDONLY and dup2 it onto standard input (fd 0); for > FILE open it with O_WRONLY | O_CREAT | O_TRUNC and mode 0644 and dup2 it onto standard output (fd 1). Close the original descriptor after dup2. If a file cannot be opened, print tsh: FILE: REASON to stderr and _exit(1). An operator with no file name after it prints tsh: syntax error: missing file after > (or <) to stderr and gives status 2 without running anything.

The workspace contains data.txt with two lines, line two and line one.

$ printf 'echo hi > out.txt
cat out.txt
sort < data.txt
' | ./prog
hi
line one
line two

File: main.c

Starting point

#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>

#define MAX_ARGS 64

int split_line(char *line, char **argv, int max)
{
    int n = 0;
    for (char *w = strtok(line, " \t\n"); w != NULL; w = strtok(NULL, " \t\n")) {
        if (n == max - 1)
            return -1;
        argv[n++] = w;
    }
    argv[n] = NULL;
    return n;
}

/* TODO: remove "< file" and "> file" from argv, remembering the file names */

static int run_external(char **argv)
{
    fflush(stdout);
    pid_t pid = fork();
    if (pid < 0) {
        perror("tsh: fork");
        return 1;
    }
    if (pid == 0) {
        /* TODO: set up redirections here */
        execvp(argv[0], argv);
        if (errno == ENOENT)
            fprintf(stderr, "tsh: %s: command not found\n", argv[0]);
        else
            fprintf(stderr, "tsh: %s: %s\n", argv[0], strerror(errno));
        _exit(127);
    }
    int status;
    while (waitpid(pid, &status, 0) < 0) {
        if (errno != EINTR) {
            perror("tsh: waitpid");
            return 1;
        }
    }
    if (WIFEXITED(status))
        return WEXITSTATUS(status);
    if (WIFSIGNALED(status))
        return 128 + WTERMSIG(status);
    return 1;
}

static int builtin_cd(char **argv)
{
    const char *dir = argv[1] != NULL ? argv[1] : getenv("HOME");
    if (dir == NULL) {
        fprintf(stderr, "tsh: cd: HOME not set\n");
        return 1;
    }
    if (chdir(dir) != 0) {
        fprintf(stderr, "tsh: cd: %s: %s\n", dir, strerror(errno));
        return 1;
    }
    return 0;
}

int main(void)
{
    char *line = NULL;
    size_t cap = 0;
    char *argv[MAX_ARGS];
    int last = 0;
    int interactive = isatty(STDIN_FILENO);

    for (;;) {
        if (interactive) {
            printf("tsh> ");
            fflush(stdout);
        }
        if (getline(&line, &cap, stdin) == -1)
            break;
        if (line[0] == '#')
            continue;
        int n = split_line(line, argv, MAX_ARGS);
        if (n < 0) {
            fprintf(stderr, "tsh: too many arguments\n");
            last = 2;
            continue;
        }
        if (n == 0)
            continue;
        if (strcmp(argv[0], "exit") == 0) {
            if (argv[1] != NULL)
                last = atoi(argv[1]);
            break;
        }
        if (strcmp(argv[0], "cd") == 0) {
            last = builtin_cd(argv);
            continue;
        }
        last = run_external(argv);
    }
    free(line);
    return last;
}

Example

input

echo hi > out.txt
cat out.txt
sort < data.txt

expected output

hi
line one
line two

Example

input

wc -l < data.txt

expected output

2
Hint 1

Scan argv with two indexes: i reads every word, j writes the words you keep. When argv[i] is "<" or ">", record argv[i + 1] as the file and skip both words.

Hint 2

Do the redirection in the child only, after fork and before execvp, so the shell's own stdin and stdout are untouched.

Hint 3

open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644) then dup2(fd, STDOUT_FILENO) makes fd 1 refer to the file; then close(fd).

Hint 4

execvp keeps open file descriptors, so the new program inherits fd 0 and 1 already pointing at the files.

Reference solution
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>

#define MAX_ARGS 64

int split_line(char *line, char **argv, int max)
{
    int n = 0;
    for (char *w = strtok(line, " \t\n"); w != NULL; w = strtok(NULL, " \t\n")) {
        if (n == max - 1)
            return -1;
        argv[n++] = w;
    }
    argv[n] = NULL;
    return n;
}

/* Remove "< file" and "> file" from argv, remembering the file names.
   Returns 0, or -1 (after printing a message) if an operator has no file. */
static int find_redirects(char **argv, const char **in, const char **out)
{
    int j = 0;
    *in = NULL;
    *out = NULL;
    for (int i = 0; argv[i] != NULL; i++) {
        if (strcmp(argv[i], "<") == 0 || strcmp(argv[i], ">") == 0) {
            if (argv[i + 1] == NULL) {
                fprintf(stderr, "tsh: syntax error: missing file after %s\n", argv[i]);
                return -1;
            }
            if (argv[i][0] == '<')
                *in = argv[i + 1];
            else
                *out = argv[i + 1];
            i++;                       /* skip the file name too */
        } else {
            argv[j++] = argv[i];       /* keep: compact the array */
        }
    }
    argv[j] = NULL;
    return 0;
}

/* In the child: make fd refer to path. Does not return on failure. */
static void redirect(const char *path, int flags, int fd)
{
    int f = open(path, flags, 0644);
    if (f < 0) {
        fprintf(stderr, "tsh: %s: %s\n", path, strerror(errno));
        _exit(1);
    }
    if (dup2(f, fd) < 0) {
        perror("tsh: dup2");
        _exit(1);
    }
    close(f);
}

static int run_external(char **argv)
{
    const char *in, *out;
    if (find_redirects(argv, &in, &out) != 0)
        return 2;
    if (argv[0] == NULL)
        return 0;
    fflush(stdout);
    pid_t pid = fork();
    if (pid < 0) {
        perror("tsh: fork");
        return 1;
    }
    if (pid == 0) {
        if (in != NULL)
            redirect(in, O_RDONLY, STDIN_FILENO);
        if (out != NULL)
            redirect(out, O_WRONLY | O_CREAT | O_TRUNC, STDOUT_FILENO);
        execvp(argv[0], argv);
        if (errno == ENOENT)
            fprintf(stderr, "tsh: %s: command not found\n", argv[0]);
        else
            fprintf(stderr, "tsh: %s: %s\n", argv[0], strerror(errno));
        _exit(127);
    }
    int status;
    while (waitpid(pid, &status, 0) < 0) {
        if (errno != EINTR) {
            perror("tsh: waitpid");
            return 1;
        }
    }
    if (WIFEXITED(status))
        return WEXITSTATUS(status);
    if (WIFSIGNALED(status))
        return 128 + WTERMSIG(status);
    return 1;
}

static int builtin_cd(char **argv)
{
    const char *dir = argv[1] != NULL ? argv[1] : getenv("HOME");
    if (dir == NULL) {
        fprintf(stderr, "tsh: cd: HOME not set\n");
        return 1;
    }
    if (chdir(dir) != 0) {
        fprintf(stderr, "tsh: cd: %s: %s\n", dir, strerror(errno));
        return 1;
    }
    return 0;
}

int main(void)
{
    char *line = NULL;
    size_t cap = 0;
    char *argv[MAX_ARGS];
    int last = 0;
    int interactive = isatty(STDIN_FILENO);

    for (;;) {
        if (interactive) {
            printf("tsh> ");
            fflush(stdout);
        }
        if (getline(&line, &cap, stdin) == -1)
            break;
        if (line[0] == '#')
            continue;
        int n = split_line(line, argv, MAX_ARGS);
        if (n < 0) {
            fprintf(stderr, "tsh: too many arguments\n");
            last = 2;
            continue;
        }
        if (n == 0)
            continue;
        if (strcmp(argv[0], "exit") == 0) {
            if (argv[1] != NULL)
                last = atoi(argv[1]);
            break;
        }
        if (strcmp(argv[0], "cd") == 0) {
            last = builtin_cd(argv);
            continue;
        }
        last = run_external(argv);
    }
    free(line);
    return last;
}

Exercise 5 · quiz

Why must cd be a builtin?

Suppose cd were an ordinary program, /usr/bin/cd, that calls chdir and exits. What would happen when you type cd /tmp in the shell, then pwd?

Answer
  • pwd still prints the old directory: the child process changed its own working directory and then exited; the shell's working directory was never touched

The working directory is a per-process attribute, like the open file table and the environment. fork gives the child a copy; changes in the child never flow back to the parent. Anything that must change the shell's own state has to run inside the shell process: cd, exit, export (which changes the shell's environment for future children), ulimit, and the redirections on exec. That is why every shell has builtins, and why bash -c 'cd /tmp' cannot change the directory of the terminal you typed it in.