DEV Community

Cover image for How I Built My Own Shell From Scratch in C — Ion
Prajwal zore
Prajwal zore

Posted on

How I Built My Own Shell From Scratch in C — Ion

I never really knew what a shell actually was until I switched my entire workflow to Linux and started using EndeavourOS as my daily driver. That completely changed the way I think and work inside a terminal.

As I started using the terminal more interactively, I realized that the terminal itself is just a container that provides a medium for interaction. The actual thing working behind it is the shell, which sits between the user and the kernel for communication. This made me curious about shells — how they process commands and what they are actually made of.

So I started building my own shell.

The basic idea was simple: start a loop that takes input and performs some operation. But as I moved ahead, lots of things started appearing in the way.

The first thing I faced was handling basic input/output, but without using printf and scanf, because these methods require formatting and are more one-ended operations. Instead, I switched to read() and write() system calls, which can continuously read and write through specified streams (STDIN_FILENO / STDOUT_FILENO).


Parsing User Input

The first thing to do was parsing the user input into commands.

Suppose you enter:

echo "hello world"
Enter fullscreen mode Exit fullscreen mode

Here, the shell must identify what is the command and what are the arguments. That is exactly what the parser does — it parses user input into an array of strings.


Executing Commands using fork()

After parsing, the next step was executing commands entered by the user.

This was a very intuitive part for me because I used the fork() child-process mechanism for command execution. Every command is basically a child process of the shell process.

Here I handled two cases:

  • what the child process should do when created
  • what the parent process should do while the child is executing

The parent process must wait until the child process completes.

pid_t pid = fork();

if (pid == 0) {
    // child operations
} else if (pid < 0) {
    perror("failed forking child");
}

waitpid(pid, &status, 0);
Enter fullscreen mode Exit fullscreen mode

Pipes and Multiple Processes

Things became even more interesting when handling pipes.

For executing multiple commands together, we need to fork multiple child processes. Currently, I have implemented support for only two child processes, meaning only one pipe is allowed in a command.

int fds[2];
pipe(fds);
Enter fullscreen mode Exit fullscreen mode

This is how a pipe is created with two ends:

  • one for reading
  • one for writing

Another important thing here is dup2().

I like to think of dup2() as linking two bottle openings together into a single flow. It helps connect two stream ends and treat them as one stream.

Here it connects:

  • the writing end of the pipe with stdout
  • the reading end of the pipe with stdin

But one important thing to watch out for is resource management. The OS has limited resources, so if we are not using any pipe ends, we must close them.

if (child1 == 0) {
    close(fds[0]); // read end not needed here

    dup2(fds[1], STDOUT_FILENO);

    close(fds[1]); // close after usage

    execvp(left[0], left);

    perror("execvp failed");
    exit(1);
}
Enter fullscreen mode Exit fullscreen mode

The exact opposite happens for the second child process with fds[0] and stdin.


Raw Mode and Autocompletion

The next thing that really tinkered with my mind was runtime user tracking.

Suppose you are typing a command and want autocompletion. You press Tab, and the shell completes the command.

But here is the tricky part:

How does the shell know you pressed Tab if you never pressed Enter?

This is solved using a special package in C called termios, which changes terminal settings and switches it from canonical mode to raw mode.

Handling user commands in raw mode is itself a hard and complex task because we must manually:

  • print every character typed by the user
  • control cursor movement

But the tradeoff is worth it because now we get exact keypress data in real time. This improves the user experience a lot.

As I spent more time working on this, I realized one thing:
A great shell has a lot of headaches hidden behind a smooth user experience.

// termios to enter raw mode
struct termios orig_termios;

void disable_raw_mode() {
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_termios);
}

void enable_raw_mode() {
    tcgetattr(STDIN_FILENO, &orig_termios);

    atexit(disable_raw_mode);

    struct termios raw = orig_termios;

    raw.c_lflag &= ~(ECHO | ICANON);

    raw.c_lflag |= ISIG;

    tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}
Enter fullscreen mode Exit fullscreen mode

My shell also supports:

  • command autocompletion
  • redirections

Both of these became possible because of raw mode handling.


Building Extra Features

Once the user experience improved, the next part was adding flexibility to the shell.

1. Built-in Tree View

This was probably my favorite part while building the shell.

I used getcwd() to read the current working directory, then read all directory contents, stored them inside an array, and printed them recursively.

struct Direntry *entries = malloc(512 * sizeof(struct Direntry));

int count = 0;

DIR *dir;
struct dirent *entry;

dir = opendir(path);

if (dir == NULL) {
    perror("failed opening dir");
    return;
}

while ((entry = readdir(dir)) != NULL) {

    if (strcmp(entry->d_name, ".") == 0 ||
        strcmp(entry->d_name, "..") == 0)
        continue;

    entries[count].name = strdup(entry->d_name);

    entries[count].is_dir = (entry->d_type == DT_DIR);

    count++;
}
Enter fullscreen mode Exit fullscreen mode

The most exciting phase was printing the tree connectors while maintaining indentation levels.

The idea is simple:
print all entries until the last entry in the current directory, then break the connector.

for (int i = 0; i < count; i++) {

    printf("\n");

    for (int j = 0; j < depth; j++)
        printf("│   ");

    if (i == count - 1) {
        printf("└── %s", entries[i].name);
    } else {
        printf("├── %s", entries[i].name);
    }

    if (entries[i].is_dir) {

        char fullpath[512];

        snprintf(fullpath,
                 sizeof(fullpath),
                 "%s/%s",
                 path,
                 entries[i].name);

        tree(fullpath, depth + 1, maxdepth);
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Directory Jumps

I also created a feature that lets you jump to recently visited directories directly, similar to zoxide.

Ion stores all visited directories inside a history file and checks for matching strings to jump directly using chdir().


That’s basically how I built my shell from scratch.

I still feel like there are many things left to talk about, but I think the blog is already too long at this point.

I’m writing a post, not the full documentation of my shell haha.

Would love to hear your thoughts in comments and thank you for your time!.

Repository: Ion Shell on GitHub

Top comments (0)