Build A Linux Shell In C++ (With Code)


If you have been using a computer for a while you have probably seen a terminal or maybe even used one. A terminal is a window where a user can interact with the computer through text commands. It is also called a Command Line Interface (CLI) different than the Graphical User Interface (GUI) . The underlying program that allows the communication between the user and the OS is called the shell.
Command Prompt, powershell, Bash are different shells with different features.
In this tutorial, we will dive deep into how a shell works under the hood and make our own version of a shell. We’ll use C++ with some POSIX system calls (common in Linux/Unix environments).
Windows shells like Command Prompt or PowerShell don’t use POSIX calls like fork
/execvp
, but the concept of parsing and executing commands remains the same
Prerequisites:
Basic knowledge of C++ is required.
What can a shell do?
To build a shell, we need to understand what a shell does. The following are some well known features of a minimal shell:
List all the files in the current folder.
Travel between directories.
Create a new folder.
Remove a folder.
Create new files.
So our task is to somehow get commands from the user and then relay them to the OS so it can execute them.
Build Procedure:
The development process will look something like this:
Get the command from the user in the form of a string e.g.
ls
,mkdir
etc.Split (parse) the command into separate words. “
mkdir newfolder
” becomes [mkdir,newfolder].Create a new process for the task.
Pass the set of words (Tokens) to the OS so it can run.
1. Getting user input:
First of all, open your IDE of choice and make a new .cpp
file.
To simulate the shell, we will add a symbol to indicate that the our program is ready to receive input.
#include<iostream>
using std :: cout;
int main() {
while (true) {
cout << "->" ;
}
}
The ‘→‘ symbol will be an indicator to the user that they should give an input. Next we will take an input in a string.
#include<iostream>
#include<string>
using std :: cout;
using std :: string;
int main() {
string command="";
while (true) {
cout << "->" ;
std :: getline(cin,command); //Getline is used to capture multiple words with spaces
if ( command.empty() ) continue;// In case nothing is entered
}
}
This program will now continuously prompt the user to enter a command until a command is entered.
2. Splitting the Input (Parsing):
Now that we have a string “command“ with some command inside. We will split it into tokens. We will write a function which takes in the input string “command“ and returns a container of tokens.
#include<iostream>
#include<string>
#include<vector>
#include<sstream>
using std :: cout;
using std :: string;
std::vector<string> parser(const string& input ){
std::vector<string> args;// make a container that contains strings
std::istringstream iss (input);// split the string word by word
string token;
while (iss>>token) { // while the string has remaining words
args.push_back(token);// add each token to the vector
}
return args;
}
int main() {
string command="";
while (true) {
cout << "->" ;
std :: getline(cin,command);
if ( command.empty() ) continue;
std::vector<string>parsed=parser(command);// The vector full of tokens is returned
if(parsed.empty()) continue;
if (parsed[0]=="quit"|| parsed[0]=="exit") {
break;
}
}
}
The parser might seem a little new but all it really does is take a string , separates words if they have spaces between them and add each to a vector container. Then the vector is returned .
We check the if the first word is empty. We also check if the first word is exit
or quit
, then the program exits.
3. Make a vector of C-type strings:
This step is the heart of our shell. We will use an inbuilt function called execvp
from the unistd.h
header library. However, we cannot pass the vector of strings directly as execvp
is a function from the C programming language (where strings don’t exist). So we need to convert it into a vector of C-type strings first.
What are C-type strings:
In the C programming language, a ”string” can be written as an array of characters ( char[] ). A C-type string is a pointer that points to the memory address of the first character of the “string“. The “string“ ends with a ‘ \0 ‘ indicating the end of the string.
#include<iostream>
#include<string>
#include<vector>
#include<sstream>
using std :: cout;
using std :: string;
std::vector<string> parser(const string& input ){
std::vector<string> args;
std::istringstream iss (input);
string token;
while (iss>>token) {
args.push_back(token);
}
return args;
}
int main() {
string command="";
while (true) {
cout << "->" ;
std :: getline(cin,command);
if ( command.empty() ) continue;
std::vector<string>parsed=parser(command);
if(parsed.empty()) continue;
if (parsed[0]=="quit"|| parsed[0]=="exit") {
break;
}
std::vector<char*> c_strs; // create a vector of C-type strings
for (auto &it:parsed){// iterate the vector of tokens
c_strs.push_back(const_cast<char*>(it.data()));// cast the token into char pointer
}
c_strs.push_back(nullptr);// add the null terminating character
}
}
Now that we have a vector of C-type strings , we can simply create a new process and execute the command using execvp
.
4. Execute the command:
Now that we have a vector of C-type strings. We can go ahead and pass it through execvp
. But we first have to create a process for the task. We can do so using the fork
command found in the unistd.h
header library. Using pid_t pid=fork()
. We can create a new process. That new process is called the child process and the old one is called the parent process.
fork
creates two paths and returns 0 to indicate the child and a non-zero to indicate the parent. The parent receives the process ID of the child.
Next, We check for the return value of fork
, a value of zero means that we are in the child process and we can just execute the command. If the value is greater than zero, that means we are in the parent process and we tell the parent to “wait“ using the function wait
present in the sys/wait.h
header library.
In case the process id is below zero, that means that the fork
failed and we should exit.
#include<iostream>
#include<string>
#include<vector>
#include<sstream>
#include<unistd.h>
#include<sys/wait.h>
using std :: cout;
using std :: string;
std::vector<string> parser(const string& input ){
std::vector<string> args;
std::istringstream iss (input);
string token;
while (iss>>token) {
args.push_back(token);
}
return args;
}
int main() {
string command="";
while (true) {
cout << "->" ;
std :: getline(cin,command);
if ( command.empty() ) continue;
std::vector<string>parsed=parser(command);
if(parsed.empty()) continue;
if (parsed[0]=="quit"|| parsed[0]=="exit") {
break;
}
std::vector<char*> c_strs;
for (auto &it:parsed){
c_strs.push_back(const_cast<char*>(it.data()));
}
c_strs.push_back(nullptr);
pid_t pId=fork(); // create a child process
if (pId==0) {// if in child process
execvp(c_strs[0],c_strs.data()); // execute the command i.e vector of C-type strings
perror("Failed");// in case execvp() fails , print failed
exit(1);// exit
}
else if (pId>0) {// if in parent
wait(nullptr);// wait for child process to terminate
}
else {// In case pid is below zero
perror("Failed");
exit(1);
}
}
Our basic shell is complete. But if you run it, you will notice that it cannot run “cd“ which is the command to change directory. That is because changing the directory only affects the child process, not the parent shell. Once the child exits, the parent’s working directory remains unchanged.
So we are going to add a bit of code to ensure support for cd.
If the first token is cd, then we get the location of the directory using getenv
. If a location is not provided , the directory location will be home
. Then we run chdir
to that directory to change the current directory.
#include<iostream>
#include<string>
#include<vector>
#include<unistd.h>
#include<sstream>
#include<sys/wait.h>
using std::cout;
using std::cin;
using std::string;
std::vector<string> parser(const string& command) {
std::vector<string> args;
std::istringstream iss(command);
string token;
while (iss>>token) {
args.push_back(token);
}
return args;
}
int main() {
string command;
while (true) {
cout<<"->";
std::getline(cin,command);
if (command.empty()) {
continue;
}
std::vector<string>parsed=parser(command);
if(parsed.empty()) continue;
if (parsed[0]=="quit"|| parsed[0]=="exit") {
break;
}
if (parsed[0]=="cd") { // if first token is "cd"
const char* dir=(parsed.size()>1)?(parsed[1].c_str()):(getenv("HOME")); // get the directory if provided
if (chdir(dir)!=0) { // change the directory.
perror("cd Failed"); //if return code is non-zero , print "cd failed" .
}
continue;
}
std::vector<char*> c_strs;
for (auto &it:parsed){
c_strs.push_back(const_cast<char*>(it.data()));
}
c_strs.push_back(nullptr);
pid_t pId=fork();
if (pId==0) {
execvp(c_strs[0],c_strs.data());
perror("Failed");
exit(1);
}
else if (pId>0) {
wait(nullptr);
}
else {
perror("Failed");
exit(1);
}
}
}
Our shell is now complete. It supports basic command execution, cd
, and exiting. If you’ve followed along, you’ve just built a working mini Unix shell and learned about process creation, execution, and even system calls like fork
, execvp
, wait
, and chdir
. That’s a huge step into the world of system-level programming!
Below is a demo of the project in action:
The github repository link to the code: Simple-Unix-Shell .
Subscribe to my newsletter
Read articles from Halonix directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
