DEV Community

Cover image for Your Shell Is Just a Loop
Douxx
Douxx

Posted on

Your Shell Is Just a Loop

Every developer uses a shell daily. Most people assume it's some complex, arcane piece of software. It's not. At its core, a shell is just a loop that reads a command, runs it, and waits for the next one. That's it.
So let's build one.

A Prompt That Reads Commands

Every shell starts the same way: show a prompt, wait for input, chop it into pieces. Let's build that first.

What we want to do is read a string from stdin (the terminal input), and then split it into multiple args.

int main() {
    char input[MAX_INPUT];
    char **args;

    while (1) {
        printf("\n^shell> ");
        fflush(stdout); // used to be sure that the stdout buffer is printed

        fgets(input, MAX_INPUT, stdin); // capture stdin

        args = parse(input); // parse input

        for (int i = 0; i < MAX_ARGS; i++) {
            if (args[i] != NULL)
                printf("%s, ", args[i]);
        }

        free(args);  // free the allocated memory for args
        fflush(stdout);
    }
}
Enter fullscreen mode Exit fullscreen mode

We have our basic command reader, right now it only prints back the parsed args.

^shell> yo
yo, 
^shell> hello world
hello, world, 
Enter fullscreen mode Exit fullscreen mode

As for the parse(input), it's also quite simple:

char **parse(char *line) {
    char **args = malloc(sizeof(char*) * MAX_ARGS); // allocate the array on the heap so it stays alive after the function returns
    int i = 0;

    char *token = strtok(line, " \t\n");
    while (token != NULL && i < MAX_ARGS - 1) {
        args[i++] = token;
        token = strtok(NULL, " \t\n");
    }

    args[i] = NULL;
    return args;
}
Enter fullscreen mode Exit fullscreen mode

strtok splits the string by spaces, tabs and newlines, and we store each chunk as a pointer in our args array. The final NULL is required for the next part.

Running The Commands

Right now, we just print them, what we want to do is run them.

The first thing we'll do is edit the main loop: instead of printing the args, we'll call a new execute(args) function.

args = parse(input); // parse input

execute(args);
Enter fullscreen mode Exit fullscreen mode

This function will actually do the work, and here it is:

void execute(char **args) {
    pid_t pid = fork();

    if (pid == 0) { // pid = 0, we're in the child
        char *resolved = find_in_path(args[0]);
        if (!resolved) {
            fprintf(stderr, "^shell: command not found: %s\n", args[0]);
            exit(1);
        }

        // launches the program
        execve(resolved, args, environ);

    } else if (pid > 0) {
        wait(NULL); // wait for the child to die
    } else {
        perror("fork"); // something went wrong
    }
}
Enter fullscreen mode Exit fullscreen mode

One of the two important parts is the fork() call. This one is directly a request to the Linux Kernel telling it to duplicate the current process into a new one, with the exact same memory values, and current execution point.

The created process is a child, it inherits from the parent (our shell), and becomes orphan if its parent dies.

fork() returns a process id (pid), if it is equal to 0, it means that we're the child. if it's a positive value, we're in the parent.

If we're the parent, it's easy, another syscall: wait(NULL), and we're blocked until the child exits.

On the other hand, we got a bit more work if we're the child.

The first thing we need to do is find the program to execute, the find_in_path() function will do the job:

  • It fetches the PATH environment variable, formatted like this: /first/path:/second/path:/etc/bin
  • It'll then look in each one of the colon-separated directories for an executable with the same name as args[0] – the program name.

Once it found the program (if it did), it runs one last syscall: execve.

This one is a bit special, since it completely replaces the current program, including its stack, heap, code segments and data segments with the new program ones.

Once done, the child is no longer our shell process. Once it exits, so does the child process, and the shell can loop again.

^shell> ls
blog.md  main  main.c  pbp  pbp.c

^shell> 
Enter fullscreen mode Exit fullscreen mode

Builtins, And Why We Can't Call cd

Another important piece of what constitutes a shell is builtins – commands that aren't executed, but used to directly talk to the shell application itself.

A good example to understand why we need them is the cd (change directory) command. It allows the user to navigate through its file system easily.

See, processes carry kernel-managed state beyond memory – like their current working directory (cwd).
As you probably guessed it, it indicates which directory the program is currently in.

We can see the cwd of programs using ls -l /proc/<PID>/cwd
cwd seeking in bash

If cd was called like any other program, here is what would happen:

  • The current process gets duplicated, the child and parent now have separate states
  • The cd process would be called, and change its own directory to the destination
  • The cd process would exit
  • We come back to our shell, but without its directory changed – the main program and its child don't share the same attributes

Here is what builtins are for: executing actions on the current program.

Here's how I handle builtins in the main loop:

args = parse(input); // parse input

if (strcmp(args[0], "exit") == 0) {
    free(args);
    break;
}

if (strcmp(args[0], "cd") == 0) {
    if (args[1] == NULL)
        fprintf(stderr, "myshell: cd: missing argument\n");

    else if (chdir(args[1]) == -1) // change dir syscall failed
        perror("myshell: cd");

    free(args);
    continue;
}

execute(args);
Enter fullscreen mode Exit fullscreen mode

The cd builtin uses the chdir syscall to change its own cwd, and the exit one exits the loop, and therefore the program.

^shell> pwd
/home/local/c/shell

^shell> cd ..

^shell> pwd
/home/local/c

^shell> exit

[local@DouLen] ~/c/shell › 
Enter fullscreen mode Exit fullscreen mode

The Command Prompt

Currently, the command prompt is just ^shell>. It's not bad, but it could be better.

Let's improve it to:

  • Show the current user
  • Show the machine hostname
  • Show the working directory

To do this, I'll make a new function, spawn_prompt() that will handle it cleanly:

void spawn_prompt() {

    char *dir = get_curr_dir();
    char prompt = get_prompt_char();
    char *hostname = get_hostname();
    struct passwd *pw = getpwuid(getuid());

    printf("\n%s@%s %s %c ", pw->pw_name, hostname, dir, prompt);

    free(dir);
    fflush(stdout);
}
Enter fullscreen mode Exit fullscreen mode

I'll leave out the get_* functions for brevity, but you'll be able to find the full code in this gist :)

After replacing the current implementation in main()

while (1) {
    spawn_prompt();
Enter fullscreen mode Exit fullscreen mode

We now have a nice prompt, let's escalate our privileges with this new exploit (update your systems, folks!):

new prompt

As you can see, it updates correctly the prompt, and when root, the prompt character switches from $ to #!

Surviving Ctrl-C

Right now, if we Ctrl-C inside our shell, it simply exits:

local@DouLen ~/c/shell $ ^C

[local@DouLen] ~/c/shell › 
Enter fullscreen mode Exit fullscreen mode

That's a bit annoying, so let's fix it.

To do so, we need to setup signal handlers. Signals in Linux are a mechanism for communicating directly with a process, there are quite a few of them; the one that interests us is the SIGINT (signal interrupt), that pressing Ctrl-C sends.

The idea is simple: when we catch a signal, spawn a new clean prompt instead of exiting.

The implementation isn't complicated either:

int main() {
    signal(SIGINT, spawn_prompt); // call spawn_prompt on ^C

    char input[MAX_INPUT];
    char **args;

    while (1) {
Enter fullscreen mode Exit fullscreen mode

Now, let's run ^C:

local@DouLen ~/c/shell $ ^C
local@DouLen ~/c/shell $ hello^C
local@DouLen ~/c/shell $ 
Enter fullscreen mode Exit fullscreen mode

Nice, it works! However, let's run a program:

local@DouLen ~/c/shell $ nasmserver
Started the NASMServer static files HTTP server.

16:01:25 [INFO] Listening on 0.0.0.0:8080
^C16:01:26 [INFO] Stopping... (signal received)

local@DouLen ~/c/shell $ 
local@DouLen ~/c/shell $ 
Enter fullscreen mode Exit fullscreen mode

The double prompt happens because ^C doesn't just signal the child – it signals the entire foreground process group, meaning both the child and our shell receive the SIGINT at the same time. So spawn_prompt() fires in our shell while the child is still running. Then, once the child exits, the main loop iterates and calls spawn_prompt() again – giving us two prompts.

Easy fix – just check tell that we got a SIGINT to the loop, so it skips the prompt the next iteration:

void spawn_prompt(int sig) {
    if (sig == SIGINT)
        got_sigint = 1;
// [...]

while (1) {
    if (!got_sigint)
        spawn_prompt(0); // 0 = not a real signal, just drawing the prompt
    got_sigint = 0;
Enter fullscreen mode Exit fullscreen mode

What's next?

I'll probably keep hacking on this – pipes (|) and output redirection (>) are the obvious next steps, and I'd love to add command history at some point. There's also a bunch of smaller things, like proper quote handling, or $VAR expansion, that would make it feel a lot more like a real shell.

It's been a fun little project honestly. Writing your own shell really makes you appreciate how much work goes into the ones we use every day – bash and zsh are doing a lot under the hood (especially since they also handle job control, complex signal management, scripting with control flow, and still manage to feel snappy and responsive).

Again, full source with ~ and * expansion is available on this gist, if you're interested in it :^D

end meme

Top comments (0)