DEV Community

Cover image for Writing My Own Shell
Frank Rosner
Frank Rosner

Posted on

Writing My Own Shell

Introduction

Operating systems provide high level functionality to interact with the computer hardware. This functionality needs to be made available to the user in some way, e.g. by a layer around the kernel, exposing simple commands. This outer layer is typically called a "shell".

As we only have a very simple text based VGA driver we will write a command-line shell. Graphical shells are beyond the scope of this series. The remainder of the post is structured as follows.

First, we will implement a key buffer that stores the user input and modify our keyboard callback to fill the buffer in addition to printing on screen. Next, we are going to add backspace functionality so we can correct typos. Thirdly, we will implement a very simple command parsing when the enter key is pressed. Finally, we modify our kernel entry to display a prompt after all initialization work is done.

Key Buffer

Our shell should support complex commands potentially having subcommands and arguments. This means having single key commands is not going to get us very far but we would rather have the user type commands consisting of multiple characters. We need a place to store the command as it is being typed, however. This is where the key buffer comes in.

We can implement the key buffer as an array of characters. It will be initialized with 0 bytes and key presses will be recorded from index 0 upwards. Inspecting this data structure a bit closer you will notice that this is just how we encoded strings. A series of characters, terminated by a 0 byte.

To work with the key buffer efficiently we need two more string utility functions: A function to calculate the length of a string and a function to append a character to a given string. The latter function is going to make use of the former.

int string_length(char s[]) {
    int i = 0;
    while (s[i] != '\0') {
        ++i;
    }
    return i;
}

void append(char s[], char n) {
    int len = string_length(s);
    s[len] = n;
    s[len + 1] = '\0';
}
Enter fullscreen mode Exit fullscreen mode

Next, we can make a few adjustments to our keyboard callback function from the previous post. First, we want to get rid of the humongous switch statement and replace it by an array lookup based on the scan code. Secondly, we ignore all key up and non-alphanumeric scan codes. Lastly, we record each key in the key buffer and output it to the screen.

#define SC_MAX 57

static char key_buffer[256];

const char scancode_to_char[] = {
  '?', '?', '1', '2', '3', '4', '5',
  '6', '7', '8', '9', '0', '-', '=',
  '?', '?', 'Q', 'W', 'E', 'R', 'T',
  'Y', 'U', 'I', 'O', 'P', '[', ']',
  '?', '?', 'A', 'S', 'D', 'F', 'G',
  'H', 'J', 'K', 'L', ';', '\', '`',
  '?', '\\', 'Z', 'X', 'C', 'V', 'B',
  'N', 'M', ',', '.', '/', '?', '?',
  '?', ' '
};

static void keyboard_callback(registers_t *regs) {
    uint8_t scancode = port_byte_in(0x60);

    if (scancode > SC_MAX) return;

    char letter = scancode_to_char[(int) scancode];
    append(key_buffer, letter);
    char str[2] = {letter, '\0'};
    print_string(str);
}
Enter fullscreen mode Exit fullscreen mode

This method works but it has two problems. First, it does not check the boundaries of the key buffer before appending, risking a buffer overflow. Secondly, it does not leave any room for mistakes when typing a command. We will leave fixing the buffer overflow to the reader and implement backspace functionality next.

Backspace

The user should be able to correct typos by pressing backspace, effectively deleting the last character from the buffer and from the screen.

Implementing the buffer modification can be done by reversing the append function. We simply set the last non-0 byte in the buffer to 0. The method will return true if we successfully removed an element from the buffer and false otherwise. Note that you have to import the type definition for bool using #include <stdbool.h>.

bool backspace(char buffer[]) {
    int len = string_length(buffer);
    if (len > 0) {
        buffer[len - 1] = '\0';
        return true;
    } else {
        return false;
    }
}
Enter fullscreen mode Exit fullscreen mode

Printing a backspace character on screen can be implemented by printing an empty character at the position right before the current cursor position and moving the cursor backwards. We will make use of our get_cursor, set_cursor, and set_char_at_video_memory functions from the VGA driver.

void print_backspace() {
    int newCursor = get_cursor() - 2;
    set_char_at_video_memory(' ', newCursor);
    set_cursor(newCursor);
}
Enter fullscreen mode Exit fullscreen mode

To complete the backspace functionality we modify the keyboard callback function by adding a branch specifically for backspace key presses. When backspace is pressed, we first attempt to delete the last character from the key buffer. If this was successful, we also show the backspace on screen. It is important to perform this check because otherwise the user would be able to backspace all the way through the screen without being stopped by prompts.

#define BACKSPACE 0x0E

static void keyboard_callback(registers_t *regs) {
    uint8_t scancode = port_byte_in(0x60);
    if (scancode > SC_MAX) return;

    if (scancode == BACKSPACE) {
        if (backspace(key_buffer)) {
            print_backspace();
        }
    } else {
        char letter = scancode_to_char[(int) scancode];
        append(key_buffer, letter);
        char str[2] = {letter, '\0'};
        print_string(str);
    }
}
Enter fullscreen mode Exit fullscreen mode

Having a key buffer and backspace functionality in place, we can move to the last step: parsing and executing commands.

Parsing and Executing Commands

Whenever the user hits the enter key, we want to execute the given command. That typically involves parsing the command first, potentially splitting it into multiple subcommands, parsing arguments or invoking external functionality. For the sake of simplicity we will only implement very basic "parsing" that checks whether the string is a known command and if it is not, shows an error.

First, we need to write a function to compare two strings. It will go through both strings step by step, comparing the character values. Here goes the code.

int compare_string(char s1[], char s2[]) {
    int i;
    for (i = 0; s1[i] == s2[i]; i++) {
        if (s1[i] == '\0') return 0;
    }
    return s1[i] - s2[i];
}
Enter fullscreen mode Exit fullscreen mode

Next, we have to implement a function execute_command that executes a given command. Our first version of the shell will only recognize a single command called EXIT that halts the CPU. Later we can implement other commands such as rebooting or interacting with a file system. If the command is unknown, we print an error message. Finally, we print a new prompt.

void execute_command(char *input) {
    if (compare_string(input, "EXIT") == 0) {
        print_string("Stopping the CPU. Bye!\n");
        asm volatile("hlt");
    }
    print_string("Unknown command: ");
    print_string(input);
    print_string("\n> ");
}
Enter fullscreen mode Exit fullscreen mode

Finally, we adjust the keyboard callback to move the cursor to the next line, invoke execute_command, and reset the key buffer when the enter key is pressed.

#define ENTER 0x1C

static void keyboard_callback(registers_t *regs) {
    uint8_t scancode = port_byte_in(0x60);
    if (scancode > SC_MAX) return;

    if (scancode == BACKSPACE) {
        if (backspace(key_buffer) == true) {
            print_backspace();
        }
    } else if (scancode == ENTER) {
        print_nl();
        execute_command(key_buffer);
        key_buffer[0] = '\0';
    } else {
        char letter = scancode_to_char[(int) scancode];
        append(key_buffer, letter);
        char str[2] = {letter, '\0'};
        print_string(str);
    }
}
Enter fullscreen mode Exit fullscreen mode

We are almost done! Let's update the main kernel function.

Updated Kernel Function

Actually, there is not much to do. We will clear the screen and display the initial prompt after all initialization work is done and that's it! The updated keyboard handler will do the rest. Here comes the code and a demo!

void start_kernel() {
    clear_screen();
    print_string("Installing interrupt service routines (ISRs).\n");
    isr_install();

    print_string("Enabling external interrupts.\n");
    asm volatile("sti");

    print_string("Initializing keyboard (IRQ 1).\n");
    init_keyboard();

    clear_screen();
    print_string("> ");
}
Enter fullscreen mode Exit fullscreen mode

shell demo

Amazing, although not very practical until we add new commands :D. In the next post we will add dynamic memory allocation.


Cover image by ETA+ on Unsplash.

Top comments (0)