Write Your Own Shell From Scratch

Hi, I am on my way to becoming a better engineer. So I am building stuff that people don’t build and learn outside their job. I am currently learning about operating systems and how they work. But instead of following the old textbook reading approach, I am doing this by building some projects along the way. In order to understand processes, I have decided to build a shell from scratch. This is part one of this series, and there will be 2 parts. We will be building a full-function shell from scratch.

What Is a Shell?

A shell is a program that acts as an interface between you and your operating system. It reads commands given by the user and gives them to the operating system for execution. Consider it as a command-line interpreter which will take commands from you and interpret them in a suitable way so that your operating system can execute them. Finally, it will return you the output.

Important

Many people think a shell and a terminal are the same thing, but they are different. A terminal emulator provides an interface through which you interact with a shell. A shell is the command-line interpreter that reads commands, parses them, and launches programs. We are not going to build a terminal emulator; we are building a shell.

But don’t worry; you will be able to execute commands in that as well.

Tech Stack

We are not going to use any external library. We are just going to use the C programming language and Linux concepts practically.

How Does a Shell Work?

  1. Print prefix (mysh>)
  2. Wait for command.
  3. Parse command.
  4. Create a new child process and execute command there.
  5. Wait for child process to complete.
  6. Repeat

Some of you may be thinking, “What is a process?” Let me give you a quick crash course.

Process

A process is a running program. Programs are stored on the hard disk or SSD in some executable format. Understand with an example. Google Chrome is installed on your computer. It resides in your storage disk (HDD or SSD). When you double-click on it, it magically opens. The magic behind this is that

  1. OS loads the program from disk to RAM.
  2. RAM is where currently opened programmes are stored because RAM is quickly accessible.
  3. CPU starts executing your program.

Now Google Chrome has become a process.

Child Process

When a process creates another process, the created process is called the child process, and the creator process is called the parent process.

 1#include <stdio.h>
 2#include <unistd.h>
 3
 4int main(int argc, char** argv) {
 5    int pid;
 6    pid = fork();
 7
 8    printf("fork() returned: %d\n", pid);
 9
10    if (pid == 0) {
11        printf("Child process.\n");
12    } else {
13        printf("Parent process.\n");
14    }
15    return 0;
16}

Can you guess the output of the above code?

1fork() returned: 69808
2Parent process.
3fork() returned: 0
4Child process.

A process can use the fork() system call to create another process. The child initially has a copy-on-write view of the parent’s address space and inherits many process attributes. This lets the child start from the same program state before it calls exec to replace its process image.

The fork() system call returns 0 in the child process, while the parent receives the child’s process ID. On failure, the parent receives -1. That’s why we added a simple if statement that distinguishes between which process is running.

Why Do We Need to Know About Processes?

You need to know about processes because processes are the building blocks of shell. If you recall the working of a shell, you will notice that we need to create child processes for the commands. Our shell will run as a parent process, and all the commands will run as child processes.

Step 1 - Basic Anatomy

 1#include <stdio.h>
 2#include <string.h>
 3
 4#define MAX_INPUT 1024
 5
 6
 7int main() {
 8	char input[MAX_INPUT];
 9
10	while (1) {
11		printf("mysh> ");
12		fflush(stdout);
13
14		if (fgets(input, MAX_INPUT, stdin) == NULL)
15			break;
16
17		input[strcspn(input, "\n")] = 0;
18
19		if (strlen(input) == 0)
20			continue;
21
22		if (strcmp(input, "exit") == 0)
23			break;
24	}
25
26	return 0;
27}

This code gives us a basic anatomy of our shell. It will accept commands from the user and does nothing. But if a user sends “exit”, it will break the loop and stop the program.

Step 2 - Let’s process simple “ls” command.

Let’s implement some command execution functionality to our shell. After validating that our command is not empty and not “exit”, we can spawn a child process that will be responsible for executing the command.

1pid_t pid = fork();
2
3if (pid == 0) {
4    // Child Process will process command here.
5} else {
6    // Parent will wait until child process completes.
7    wait(NULL);
8}

But here is an important detail. fork() creates a child process that initially has the same process image as the parent, using copy-on-write for memory pages. The child starts executing from the same point as the parent, but the two processes then have independent execution.

Let’s say a user entered the “ls” command in our shell. This command will print all the files and folders in the current directory.

Important

You need to understand that the commands we run in our terminal are also executables. On many Linux systems, common executables such as ls can be found under /bin, although the exact location depends on the operating system. A shell normally searches directories listed in PATH rather than assuming a fixed path.

When you type ls, the shell searches PATH for an executable named ls and launches it. Using /bin/ls is only one possible path on some systems. In our first implementation we can use execvp, which performs the PATH lookup for us.

Introducing exec

This is one of the most important system-call families for building a shell. exec replaces the current process image with another program. It does not create a new process; the child created by fork() is the process whose image gets replaced.

How exec Works

It’s important to understand how this system-call family works. In the simple execv form, you provide the executable path and an argument array.

  1. Path of the program we want to run.
  2. Arguments for that program.

For example, execv can run /bin/ls when given that path and an argument array. For a simple shell, execvp is more convenient because it searches PATH for the executable.

Let’s say we are running the command ls, so the argument array passed to execv can look like this.

1char *args[] = {"ls", NULL};

For ls -a, the argument array can look like this:

1char *args[] = {"ls", "-a", NULL};

The first argument identifies the executable, and the argument array supplies argv to that program. If exec succeeds, it does not return to the old program; the current child process is now running the new program.

The NULL at the end of the arguments array is there so that any other code accessing this array can properly find the end of it and not go beyond the end.

Complete Code

 1#include <stdio.h>
 2#include <string.h>
 3#include <unistd.h>
 4#include <sys/wait.h>
 5#include <stdlib.h>
 6
 7#define MAX_INPUT 1024
 8#define MAX_ARGS 64
 9
10int main(void) {
11    char input[MAX_INPUT];
12
13    while (1) {
14        printf("mysh> ");
15        fflush(stdout);
16
17        if (fgets(input, sizeof(input), stdin) == NULL)
18            break;
19
20        input[strcspn(input, "\n")] = '\0';
21
22        if (input[0] == '\0')
23            continue;
24
25        if (strcmp(input, "exit") == 0)
26            break;
27
28        char *args[MAX_ARGS];
29        int argc = 0;
30        char *token = strtok(input, " \t");
31
32        while (token != NULL && argc < MAX_ARGS - 1) {
33            args[argc++] = token;
34            token = strtok(NULL, " \t");
35        }
36
37        args[argc] = NULL;
38
39        pid_t pid = fork();
40
41        if (pid < 0) {
42            perror("fork");
43            continue;
44        }
45
46        if (pid == 0) {
47            execvp(args[0], args);
48            perror("execvp");
49            _exit(EXIT_FAILURE);
50        }
51
52        waitpid(pid, NULL, 0);
53    }
54
55    return 0;
56}

Now the shell can execute simple commands such as ls, ls -a, or another executable available through PATH. This is still intentionally limited: it does not yet implement quoting, pipes, redirection, environment expansion, or built-in commands beyond exit.