DEV Community

Cover image for Building a Cross-Platform Shell in C++: What fork() Does That _spawnvp() Can't
Waasila Asif
Waasila Asif

Posted on • Originally published at Medium

Building a Cross-Platform Shell in C++: What fork() Does That _spawnvp() Can't

Originally published on Medium.

Throughout our childhood, we've all seen the same depiction of hacking in film and TV: a guy in a black hoodie, black-and-green text scrolling across his screen. I used to imagine being that hacker myself, hunched over my toy laptop as a kid. Then I got into Computer Science and started interacting with that mysterious screen for real. And it turned out to be every bit as interesting as I had imagined. A CLI (Command Line Interface) shell, something every developer interacts with almost daily.

So I started wondering how it actually works. I already knew how to "use" one. But what if I skipped past the movie-hacker fantasy entirely and just learned to build one? Because as the saying goes:

What I cannot create, I do not understand.

So here I am. This project started on the shoulders of Stephen Brennan's excellent "Write a Shell in C". Genuinely essential reading for any CS major getting curious about what's underneath the terminal. I have also compiled a list of the other articles and resources I used along the way at the end of this post.

In this article I will walk you through the explanations and concepts (with code) on how you can create your own shell. But there are numerous articles on that — what makes mine different? We will try to create a cross OS shell for both Windows and Unix like systems (apology to the mac developers :) ). So let's begin.

What is a shell?

A shell is a program that provides an interface for running commands and interacting with the operating system's services such as files, processes, and hardware. It sits between you and the OS translating what you type (or click) to something the OS can act upon.

Simple, right?

There are generally 2 types of shells:

a) CLI based shells : Shells interacted through typing commands. For Example : bash, powershell, cshell etc.

b) GUI based shells: Where the user interacts with the operating system using windows, icons and menus instead of typing commands. For Example: GNOME Shell in Linux and Windows Explorer in Microsoft Windows.

A diagram representing the hierarchical level of shell. Here the kernel is the core component of an OS. For now we can assume it to be synonymous to the OS.

Developers mostly prefer CLI based shells because of their high efficiency and speedy execution. Moreover it allows shell scripting to automate tasks and run personalized commands on boot. And that's what we will be building towards as well.

Shell Loop and its Life Cycle

A shell loop is a programming construct to execute a block of code repeatedly. An example to understand could be a game loop; a game keeps running till it is switched off. A shell loop is the same. It continues to take input from user till switched off or killed.

A shell does three main things in its lifetime.

Initialize: In this step, a typical shell would read and execute its configuration files. These change aspects of the shell's behavior.

Interpret: Next, the shell reads commands from stdin (which could be interactive, or a file) and executes them.

Terminate: After its commands are executed, the shell executes any shutdown commands, frees up any memory, and terminates.

An actual shell is far more complex but for now we will try to understand its working through a simple loop. But in terms of architecture, it's important to keep in mind that the lifetime of the program is more than just looping.

Main block

int main()
{
    //load config files if any. In this tutorial we will assume none.

    //call the shell loop to start the program
    lsh_loop();

    //clean up and shut down
    return EXIT_SUCCESS;
}
Enter fullscreen mode Exit fullscreen mode

This is going to be the starting point of our program. We call the shell loop and run it in main. We will see the implementation of the shell loop and the functions it allows next.

Basic loop of a shell

After deciding how our program is going to decide; now we need to figure out what exactly does the shell do during this loop? Simply put commands in a shell are handled in 3 steps:

Read: Read the command from the input.

Decode: Separate the string read into commands and arguments (Tokenization).

Execute: Run the parsed command.

Keeping these in mind let's look at the function body of lsh_loop().

void lsh_loop()
{
    string line; //line read
    vector<string> args; //tokenized arguments from inputs
    int status; //our flag for when to stop the shell loop. We could use the bool datatype but it does not necessary make a difference

    //we use a do while loop because the shell loop needs to run at least once on powering on
    do
    {
        cout << "> ";
        line = lsh_read_line();
        args = lsh_split_line(line);
        status = lsh_execute(args);
    } while (status);
};
Enter fullscreen mode Exit fullscreen mode

Let's walk through the code. The first few lines are just declarations. The do-while loop is more convenient for checking the status variable, because it executes once before checking its value. Do checkout my previous article on the assembly behind loops ;) What actually happens when a For Loop Runs: A GDB walkthrough

Our shell loop achieves its purpose through another 3 functions. lsh_read_line(), lsh_split_line(line), and lsh_execute(args). We will implement them one by one.

Reading the line

Reading a line is pretty simple. The only catch can be that you don't know beforehand how the user might expect the program to realize that the line has been read. Handling it is quite simple in C++. Although it could have been a bit of a hassle if we used C which has no String Object class.

string lsh_read_line()
{
    string line;

    if (!getline(cin, line))
    {    // to ensure a successful end of command only when the line has been fully read
        if (cin.eof())
        {

            // return line;
            exit(EXIT_SUCCESS);
        }
        else
        {  //in case the line read command stops earlier than expected or does not stop at all
            perror("lsh: read line error");
            exit(EXIT_FAILURE);
        }
    }
    return line;
}
Enter fullscreen mode Exit fullscreen mode

Decoding the line (Tokenization)

Now that we have the function to read the line, we need to actually break the line into its arguments for our shell to understand. We will use the vector data structure throughout mostly because it is the easiest to use and provides us with a convenient and dynamic solution.

vector<string> lsh_split_line(string line)
{
    vector<string> tokens; //initialize the vector to hold the args
    string current_token;  //to go over tokens

    if (line.empty())//empty command 
    {
        cout << "> lsh: allocation error. No command entered" << endl
             << ">";
        return tokens;
    }

    for (int i = 0; i < line.length(); i++)
    {
        char c = line[i];//loop over line to identify and ignore white spaces

        if (c == ' ' || c == '\t' || c == '\n' || c == '\r')
        {
            if (!current_token.empty())//this block executes when current_token stores one argument fully
            {
                tokens.push_back(current_token);//pushing arguments one by one
                current_token = "";//resetting the variable
            }
        }
        else
        {
            current_token += c; //if non empty add it to the current token string
        }
    }
    if (!current_token.empty())//push back the last token stored
    {
        tokens.push_back(current_token);
    }

    return tokens;
}
Enter fullscreen mode Exit fullscreen mode

Ok so explaining what I did here; We start with 2 main variables: tokens and current_token. A for loop loops over the line. We skip the white space characters (We will come to the nested if in a moment). If the character read is not a white space we add it to the current_token string. A short example. Suppose the user types cd ... Let's go over the iterations.

i = 0
 char c holds 'c'.
 current_token = c
i = 1 
 char c holds 'd'.
 current_token = cd
i = ' '
 char c holds space character.
 tokens[0] = current_token = cd.
 current_token = "".
Enter fullscreen mode Exit fullscreen mode

And so on. Now that we have read the line and tokenized it, it is time to execute the commands. Which begs the question, how do we do that?

Execution

This is the heart of what a shell actually does. Writing a shell means that you need to know exactly what's going on with processes and how they start. That's why we will take a little detour understanding processes in Unix and Windows OS.

Unix based Systems

Unix starts processes by 2 main ways: init and the fork() system call. The init process is the parent of all processes in Linux, identified by the process ID (PID) of 1. It is the first process that starts when a computer boots up and continues to run until the system shuts down. PIDs are unique identifiers given to programs running in a system.

Since most programs aren't init, that leaves only one practical way for processes to get started: the fork() system call. fork() creates another copy of the shell and extends it as a child of the original shell. Imagine you're the operating system. The shell is already running. Process #125. Now the user types cd. Should the shell's entire existence be limited to cd meaning the shell stops as soon as cd is executed?

No.

So UNIX creates another copy of the shell (fork()).

Now there are two identical programs. Both continue executing from the next line after fork(). Suppose:

cout << "Hello";
fork();
cout << "World";
Enter fullscreen mode Exit fullscreen mode

Output:

Hello
World
World
Enter fullscreen mode Exit fullscreen mode

because now both parent and child execute the second line. (Due to buffering the exact ordering between parent and child can vary — don't rely on it, but the core idea remains the same.)

How do we know which is which? The return value.

When this function is called, the operating system makes a duplicate of the process and starts them both running. The original process is called the "parent", and the new one is called the "child". fork() returns 0 to the child process, and it returns to the parent the process ID number (PID) of its child. In essence, this means that the only way for new processes to start is by an existing one duplicating itself.

This might sound like a problem. Typically, when you want to run a new process, you don't just want another copy of the same program — you want to run a different program. That's what the exec() system call is all about. It replaces the current running program with an entirely new one. This means that when you call exec, the operating system stops your process, loads up the new program, and starts that one in its place. A process never returns from an exec() call (unless there's an error).

With these two system calls, we have the building blocks for how most programs are run on Unix. First, an existing process forks itself into two separate ones. Then, the child uses exec() to replace itself with a new program. The parent process can continue doing other things, and it can even keep tabs on its children, using the system call wait().

That's a lot of information, but with all that background, the following code for launching a program will actually make sense:

int lsh_launch(const vector<string> args)
{
    vector<char *> c_args; 
    for (const auto &arg : args)
    {
        c_args.push_back(const_cast<char *>(arg.c_str()));
    }
    c_args.push_back(nullptr);
    pid_t pid, wpid;
    int status;

    pid = fork();
    if (pid == 0) {
        // Child process is going on
        if (execvp(c_args[0], c_args.data()) == -1) {
            perror("lsh");
        }
        exit(EXIT_FAILURE);
    } else if (pid < 0) {
        // Error forking
        perror("lsh");
    } else {
        // Parent process
        do {
            wpid = waitpid(pid, &status, WUNTRACED);
        } while (!WIFEXITED(status) && !WIFSIGNALED(status));
    }

    return 1;
}
Enter fullscreen mode Exit fullscreen mode

Because Linux's execvp() cannot use C++ strings — they expect char *arg[] instead of a vector of strings — so we need to convert.

Suppose:

args = {
    "cd",
    ".."
}
Enter fullscreen mode Exit fullscreen mode

The loop runs 2 times.

First iteration: arg = "cd"
Second Iteration: arg = ".."

const auto& arg means const string& arg. The compiler figures out the type automatically. Why we are using & instead of simple assignment? Without reference every string gets copied. With & we pass by reference and no copying happens — it's faster.

Let's understand the body of the loop with an example. Suppose args = "Documents" then arg.c_str() converts a C++ string into a C string returning type const char* pointing to:

D o c u m e n t s \0
Enter fullscreen mode Exit fullscreen mode

But there is an issue. execvp() expects char* instead of a const char*. So, we use const_cast. It tells the compiler "Treat this pointer as if it weren't const". By nature the C string still remains unchangeable — it only changes the type.

From earlier example assuming args contains {"cd", ".."}, c_args now contains


those two pointers. c_args.push_back(nullptr) adds the null pointer at the end. Because execvp() keeps reading pointers till they encounter a null pointer. Without it, the function wouldn't know where the argument list ends.

The only thing left to explain is waitpid. waitpid is simply a method that tells the shell to wait for the ongoing process to complete before beginning or returning to another process. In this case, let the child perform its function before returning back to the parent shell.

Now that we understand the working of lsh_launch() function in Unix let's move on to Windows. Windows implementation is conceptually similar but there are some nuances. You might ask — if we have 2 implementations, how do we make them cross OS? Well the answer is we simply wrap them in an #if-else block as such.

int lsh_launch(const vector<string> args)
{
    vector<char *> c_args;
    for (const auto &arg : args)
    {
        c_args.push_back(const_cast<char *>(arg.c_str()));
    }
    c_args.push_back(nullptr);
#if defined(_WIN32)
    //Windows Implementation
#else
    //Unix Implementation
#endif
}
Enter fullscreen mode Exit fullscreen mode

Windows Implementation

The explanation of the c_args and the loop remain the same. Moving on to the fork() process we encounter an issue. Windows does not provide any fork() mechanism. Instead Windows provides us with a method called _spawnvp() to start another program. It is a routine used to create and execute a new child process by passing an array of command-line arguments. Let's look at its signature for better understanding:

_spawnvp(mode,
         filename,
         argv);
Enter fullscreen mode Exit fullscreen mode
  • mode: Execution flag for the parent process (e.g., _P_WAIT to wait for the child process to complete, or _P_NOWAIT to continue running concurrently).
  • cmdname: Pointer to the path or filename of the file to be executed.
  • argv: Pointer to an array of character pointers representing the command-line arguments where the last element must be nullptr (something we already handled previously).

In a way _spawnvp() combines both the functions of fork() and execvp() in a single function. Let's look at the Windows specific code before moving forward.

intptr_t status = _spawnvp(_P_WAIT, c_args[0], c_args.data());
if (status == -1)
{
    perror("lsh error");
}
return 1;
Enter fullscreen mode Exit fullscreen mode

A pretty small block compared to Unix's, right? Let's break down the code. The first parameter of _spawnvp is _P_WAIT. This tells Windows: launch the process and wait until it finishes. For Example a user types notepad — the shell waits until notepad closes. If we had used _P_NOWAIT the shell would have returned immediately to the prompt.

Second parameter we pass is c_args[0] — which is just the pointer to the first argument cd.

Third parameter c_args.data() returns the pointer to the first element. It acts like char* args[] which is exactly what _spawnvp() expects.

For the return value, intptr_t status receives the exit status. If execution is successful, status might be 0. If launching fails, status = -1. We handle possible errors with the line:

if (status == -1)
Enter fullscreen mode Exit fullscreen mode

which runs if the user types commands that do not exist. For the return values:

  • 1 → keep the shell running.
  • 0 → exit the shell (for example, after an exit built-in command).

So the shell loop can do something like:

int status = 1;
while (status) {
    vector<string> args = lsh_read_command();
    status = lsh_execute(args);
}
Enter fullscreen mode Exit fullscreen mode

As long as lsh_launch() returns 1, the shell continues accepting commands.

Execution Block

You must have noticed that the previous function we implemented was actually lsh_launch() while in our main loop we call lsh_execute(). It was done intentionally to provide better fallback and modularity. The lsh_execute() block is simple and implemented as follows:

int lsh_execute(vector<string> args)
{
    if (args.size() < 1) //empty argument
    {
        return 1;
    }
    for (int i = 0; i < lsh_num_builtins(); i++) //explanation ahead
    {
        if (args[0].compare(builtin_str[i]) == 0)
        {
            return (*builtin_func[i])(args);
        }
    }
    return lsh_launch(args);
}
Enter fullscreen mode Exit fullscreen mode

You must be wondering what is happening in the loop body. Well using the power of pointers in C++ we make use of pass by reference to speed up our processes. We declare some variables with global scope as:

string builtin_str[] = {
    "cd",
    "help",
    "exit",
    "cwd",
    "man",
    "tree",
    "tree_all"};

int (*builtin_func[])(vector<string> &args) = { //Instead of copying the functions. We pass in ADDRESSES of functions. Making the program far more efficient and fast as there is no copying at all  
    &lsh_cd,
    &lsh_help,
    &lsh_exit,
    &lsh_cwd,
    &lsh_man,
    &lsh_tree,
    &lsh_tree_all
};

int lsh_num_builtins()
{
    return (size(builtin_str));
}
Enter fullscreen mode Exit fullscreen mode

Because a separate program cannot change the shell's environment, the shell creators realized that certain commands cannot be external programs. They have to be executed by the shell itself, right inside the shell's own sandbox. These are called Shell Builtins.

When you type cd, the shell doesn't clone itself or create a child process. It says, "Oh, I know this command. I will run chdir() directly on myself." Because the shell runs it internally, its own directory actually changes.

It's the same for exit: If exit were a separate program, the child process would exit, but your shell would stay open. The shell itself needs to run the exit code to close its own sandbox.

Why const vector<string>&?

For the same reason we used const auto& in our loop:

  • No copy of the vector is made.
  • The function promises not to modify the arguments.
  • It's more efficient.

Then your whole shell has one consistent data flow:

User input
      │
      ▼
string line
      │
      ▼
vector<string> args
      │
      ├──────────────► Built-in?
      │                   │
      │                   ▼
      │          lsh_cd(const vector<string>&)
      │          lsh_help(const vector<string>&)
      │          lsh_exit(const vector<string>&)
      │
      └──────────────► External command?
                          │
                          ▼
                  lsh_launch(args)
                          │
                          ▼
             Convert to char*[] ONLY HERE
                          │
                          ▼
                     _spawnvp()
Enter fullscreen mode Exit fullscreen mode

Notice that only one function (lsh_launch) ever has to deal with char*. Everything else can stay modern C++.

There's something quietly elegant about this design that's easy to miss on first read: builtin_str[] and builtin_func[] aren't linked by any explicit key or map — they're linked purely by position. builtin_str[3] is "cwd", and builtin_func[3] is &lsh_cwd. The two arrays only work because they're declared in the same order, index for index. Move "exit" up a slot in one array without moving lsh_exit up the same slot in the other, and the shell will silently call the wrong function for the wrong command name — no compiler error, no warning, just a bug that only shows up when you type exit and get cwd's output instead.

This is exactly what lsh_execute()'s loop is exploiting: it searches builtin_str[] for a name match, and the moment it finds one at index i, it doesn't need to know what the command does — it just calls whatever's sitting at builtin_func[i]. The two arrays act as a single logical table split across two variables, and the loop treats "position in one array" as if it were a real, load-bearing piece of data. It's a small thing, but it's a nice example of how much a program can lean on implicit structure — an invariant that exists only in the programmer's head and the order lines were typed in, not in the type system.

Now we understand the loop and internal implementation of our shell — we can implement the actual commands that our shell will be able to perform.

Change directories and get current working directory

int lsh_cd(vector<string> &args)
{
    if (args.size() < 2) //AS cd command requires at least 2 arguments
    {
        cout << "lsh: expected an argument to the cd command, Need an argument to move to the directory required" << endl;
    }
    else
    {
        if (args[1] == "..") //Handling the .. scenario
        {
            cout << "Moving back in directory\t";
            fs::path cwd = fs::current_path(); //get the path to the current directory
            string path = cwd.string();
            cout << path << endl; //debug line
            string ncwd;
            int pos = path.find_last_of('\\');//find the last instance of \ in the path to slice
            ncwd = path.substr(0, pos); //Have the needed substring moving back one directory
            cout << "Moving to directory \t" << ncwd << endl; 
        //Windows Unix differentiator. There is only a minor change. Just on the names of the command called. Rest of the logic is same.
        #if defined(_WIN32)
            if (_chdir(ncwd.c_str()) != 0) //Windows uses _chdir
            {
                perror("lsh");
            }
        #else 
            if(chdir(ncwd.c_str()) !=0) //Unix uses chdir
            {
                perror("lsh");
            }
        #endif
        }
        else
        {
            cout << "Moving to directory \t" << args[1] << endl;
            #if defined(_WIN32)
            if (_chdir(args[1].c_str()) != 0)
            {
                perror("lsh");
            }
            #else
            if (chdir(args[1].c_str()) !=0){
                perror("lsh");
            }
            #endif
        }
    }
    try
    {
        fs::path current_dir = fs::current_path();
        cout << "Current working directory: " << current_dir << endl;
    }
    catch (const fs::filesystem_error &e)
    {
        std::cerr << "Error: " << e.what() << endl;
    }
    return 1;
}
Enter fullscreen mode Exit fullscreen mode

The implementation is pretty straight forward and much easier than all that we have previously discussed. The implementation for cwd is also similar. Just without changing the path.

int lsh_cwd(vector<string> &args)
{
    try
    {
        fs::path current_dir = fs::current_path();
        cout << "Current working directory: " << current_dir << endl;
    }
    catch (const fs::filesystem_error &e)
    {
        std::cerr << "Error: " << e.what() << std::endl;
    }
    return 1;
}
Enter fullscreen mode Exit fullscreen mode

Tree and tree_all commands

int lsh_tree(vector<string> &args){
    fs::path current_dir = fs::current_path(); //get current working directory
    cout << "Current working directory: " << current_dir << endl;
    if (fs::exists(current_dir) && fs::is_directory(current_dir)) { //check if directory exists
        for (const auto& entry : fs::recursive_directory_iterator(current_dir)) {
           //recursively loop through the contents of the directory using built in functions
            if(entry.path().string().find("\\.")== string::npos) {//find hidden files and ignore them — this is the main differentiator between tree and tree_all
            cout << entry.path().string() << endl;}
        }
    }
    else{
        cout<<"lsh error: Either your current directory has no files or it is not a valid directory. Please change directory and try again"<<endl;
    }
    return 1;
};
Enter fullscreen mode Exit fullscreen mode

Similarly for tree_all:

int lsh_tree_all(vector<string> &args){
    fs::path current_dir = fs::current_path();
    cout << "Current working directory: " << current_dir << endl;
    if (fs::exists(current_dir) && fs::is_directory(current_dir)) {
        for (const auto& entry : fs::recursive_directory_iterator(current_dir)) {
           //No filtering for hidden paths or files. Hence displaying all results
            cout << entry.path().string() << endl;
        }
    }
    else{
        cout<<"lsh error: Either your current directory has no files or it is not a valid directory. Please change directory and try again"<<endl;
    }
    return 1;
}
Enter fullscreen mode Exit fullscreen mode

Manual and help commands

Manual and help commands are probably my favourite commands with their simple, fun implementation. I went for if-else here but we could use switch as well.

int lsh_man(vector<string> &args)
{
    if (args.size() < 2) {
        cout << "lsh: man - display info about a builtin command" << endl;
        cout << "Usage: man <command>" << endl;
        cout << "Available commands: cd, cwd, exit, help, tree, tree_all" << endl;
        return 1;
    }

    string cmd = args[1];

    if (cmd == "cd") {
        cout << "cd - change the current working directory" << endl;
        cout << "Usage: cd <directory>" << endl;
        cout << "       cd ..   (move up one directory)" << endl;
    }
    else if (cmd == "cwd") {
        cout << "cwd - print the current working directory" << endl;
        cout << "Usage: cwd" << endl;
    }
    else if (cmd == "exit") {
        cout << "exit - terminate the shell" << endl;
        cout << "Usage: exit" << endl;
    }
    else if (cmd == "help") {
        cout << "help - list all builtin commands" << endl;
        cout << "Usage: help" << endl;
    }
    else if (cmd == "tree") {
        cout << "tree - recursively list files/directories from current path" << endl;
        cout << "Usage: tree" << endl;
    }
    else if (cmd == "tree_all") {
        cout << "tree_all display hidden files along with the regular tree function." << endl;
        cout << "Usage: tree_all" << endl;
    }
    else {
        cout << "lsh: man: no manual entry for '" << cmd << "'" << endl;
    }

    return 1;
}
Enter fullscreen mode Exit fullscreen mode

help command is just an intro to the shell.

int lsh_help(vector<string> &args)
{
    int i;
    cout << "My implementation of LSH inspired by Stephen Brennan's implementation" << endl;
    cout << "Most of the stuff online was in C and was only for Unix like systems. As like most people I started coding from Windows I wanted to make a cross OS shell" << endl;
    cout << "So here is my humble attempt" << endl;
    cout << "Type program names and arguments, and hit enter." << endl;
    cout << "The following are built in:" << endl;

    for (i = 0; i < lsh_num_builtins(); i++)
    {
        cout << i << ".  " << builtin_str[i] << endl;
    }

    return 1;
}
Enter fullscreen mode Exit fullscreen mode

Exit command

And finally the exit command, a fitting conclusion to our discussion.

int lsh_exit(vector<string> &args)
{
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

And ladies and gentlemen, now you have your own working shell and you can say you understand a bit more about systems than you did a few minutes or hours ago :)

*A screenshot of the shell running*

Now time for some reflections.

What I'd do differently

Looking back at this now, a few things stand out that I'd change if I started over:

The int status flag. I used int status throughout instead of bool, mostly out of habit from the C examples this project started from. It works fine — 0 and 1 map cleanly onto false/true — but there's no real reason not to use bool here in C++. A small thing, but worth naming since "it works" and "it's the right type" aren't always the same thing.

Uniform function signatures, even when unused. Every builtin takes vector<string>& args, even commands like lsh_cwd() and lsh_exit() that never touch args at all. I did this purely so all the builtins could live in one function-pointer array with one matching type signature, which is convenient, but it means the signature is lying a little about what the function actually needs. A cleaner design might split builtins into two categories: one for commands that genuinely take arguments, one for commands that don't — with two smaller, more honest tables instead of one array pretending every command needs the same shape of input. I didn't do this because it would have meant giving up the single dispatch loop, and at this scale that tradeoff didn't feel worth it, but it's the kind of decision that stops scaling once you add ten more builtins.

What's next

The two features I actually want to build, not just list as gaps: command history (so pressing up-arrow recalls the last command — currently lsh has no memory of anything you've typed), and signal handling (right now, Ctrl+C during a running child process will take the whole shell down with it, instead of just interrupting the foreground command the way a real shell does). Between the two, getting those right is what would actually turn this from a teaching project into something resembling a functional shell.

Contributing

lsh is open source and on GitHub.

If you've read this far and want to get your hands dirty, the roadmap in the README has a running list of what's missing: pipes, I/O redirection, signal handling, and command history are all open. Any of them is a solid "understand a real OS concept by implementing it" exercise, which is exactly the spirit this whole project was built in. PRs, issues, and "this line is wrong" corrections are all genuinely welcome. I built this to learn in public, and half the value of putting it on GitHub is having other people poke holes in it.

In the end building this shell didn't make me the hoodie-and-green-text hacker I imagined as a kid staring at my toy laptop. It turns out real systems knowledge looks a lot less cinematic than dramatic keystrokes. But I understand something today that I didn't before: what a process actually is, why Windows and Unix disagree about how to create one, and why that disagreement isn't an accident of history but a real difference in how the two OSes think about a running program's identity.

Resources and Further Readings

Here are some of the excellent articles and posts I read apart from the documentation that helped me understand the whole process better:

Top comments (0)