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);
}
}
We have our basic command reader, right now it only prints back the parsed args.
^shell> yo
yo,
^shell> hello world
hello, world,
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;
}
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);
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
}
}
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
PATHenvironment 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>
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.
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
cdprocess would be called, and change its own directory to the destination - The
cdprocess 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);
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 ›
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);
}
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();
We now have a nice prompt, let's escalate our privileges with this new exploit (update your systems, folks!):
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 ›
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) {
Now, let's run ^C:
local@DouLen ~/c/shell $ ^C
local@DouLen ~/c/shell $ hello^C
local@DouLen ~/c/shell $
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 $
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;
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
Top comments (0)