Building a Unix Shell in C Without AI — Learning the Hard Way
I wanted to understand how a Unix shell actually works under the hood.
Not from lecture slides. Not from a polished tutorial. And definitely not from auto-generated code.
So I decided to build one from scratch in C. I picked a shell specifically because it sits right at the boundary between user-space and the operating system — every command you type goes through this one small program before the kernel ever sees it. I wanted to know exactly what happens in that gap.
There was one rule for the entire project:
- No AI assistance for writing the code.
- No Copilot. No ChatGPT. No autocomplete writing logic for me.
If something broke, the workflow was simple:
- Read the man page
- Compile again
- Run gdb
- Inspect memory
- Repeat until the program stopped crashing
This post covers Phase 1 — building the basic command loop, implementing a few built-ins, launching programs using the fork–exec model, and surviving the first wave of segmentation faults.
The Shell Loop
Every shell starts with the same idea: read → parse → execute → repeat.
My shell loop reads input, tokenizes it, checks for built-in commands, and otherwise tries to execute a
program from $PATH.
printf("$ ");
char input[100];
while (fgets(input, sizeof(input), stdin))
{
input[strcspn(input, "\r\n")] = 0;
char *cpy = malloc(strlen(input) + 1);
strcpy(cpy, input);
char *cmd = strtok(input, " ");
}
One subtle but important detail here is the copy of the input string — and it only exists because I learned why the hard way.
The strtok() Trap
At first I used strtok() directly on the input string. That seemed harmless.
Then parts of my parsing logic started behaving strangely in ways I couldn't immediately explain. Commands would parse correctly on their own, then silently break when other functions ran nearby.
The reason is that strtok() modifies the original string by inserting null
terminators in place of delimiters. If the user types:
echo hello world
After tokenization the memory actually looks like this:
echo\0hello\0world
The original string is destroyed. Any other part of the shell that still needed the full command string was now reading garbage.
The fix was simple once I understood it: tokenize a copy instead.
char *cpy = malloc(strlen(input) + 1);
strcpy(cpy, input);
char *cmd = strtok(input, " ");
There is also a second trap that bit me later: strtok() maintains global internal state. If
another function calls strtok() while the first tokenization is still in progress, the internal
pointer resets and the original parse breaks silently. No error. No warning. Just wrong behavior.
Searching for Commands in $PATH
If the command is not a shell builtin, the shell needs to locate the executable inside the directories
listed in the PATH environment variable.
I implemented that using a helper function:
char *find_in_path(char *command)
{
char *path_env = getenv("PATH");
if (!path_env)
return NULL;
char *path_copy = strdup(path_env);
char *dir = strtok(path_copy, ":");
static char result_path[4096];
The shell iterates through each directory in $PATH and checks if the command exists there:
while (dir != NULL)
{
snprintf(result_path, sizeof(result_path), "%s/%s", dir, command);
if (access(result_path, X_OK) == 0)
{
found = 1;
break;
}
dir = strtok(NULL, ":");
}
Again, notice the copy of PATH. Whenever C code starts modifying strings, you quickly learn
that ownership of memory matters — and that the standard library will not protect you from yourself.
The Fork–Exec Model
The most important part of the shell is how it actually runs programs.
A shell does not run programs itself. Instead it:
- forks a child process
- the child executes the requested program
- the parent waits for the child to finish
Here is the core logic:
pid_t pid = fork();
if (pid == 0)
{
execvp(path_found, args);
perror("exec failed!\n");
}
else if (pid < 0)
{
perror("FORK FAILED!\n");
}
else
{
wait(NULL);
}
The first time I ran this, I expected something more complicated. Instead fork() just
duplicated the process, the child replaced itself with the new program via execvp(), and the
parent sat in wait() until it finished.
One thing I learned immediately: if the parent does not call wait(), the child becomes a zombie
process — it finishes execution but its entry stays in the process table because nothing collected its exit
status.
Building the Argument List
Commands also need arguments, so the shell builds an argument array dynamically:
char **args = NULL;
args = realloc(args, (count + 1) * sizeof(char *));
args[count] = malloc(strlen(name) + 1);
strcpy(args[count], name);
count++;
Additional arguments are appended the same way:
char *arg = strtok(NULL, " ");
while (arg != NULL)
{
args = realloc(args, (count + 1) * sizeof(char *));
args[count] = malloc(strlen(arg) + 1);
strcpy(args[count], arg);
count++;
arg = strtok(NULL, " ");
}
Finally the array must be terminated with NULL because execvp() expects that
format:
args = realloc(args, (count + 1) * sizeof(char *));
args[count] = NULL;
Debugging with GDB
Compile with debugging symbols:
gcc shell.c -g
Then run:
gdb ./a.out
The workflow that actually helped:
break lsh_loop
run
step
print args
Finding Memory Leaks with Valgrind
Once the shell was running, I checked it with Valgrind:
valgrind --leak-check=full ./shell
And the fix pattern:
for (int i = 0; i < count; i++)
{
free(args[i]);
}
free(args);
free(name);
Phase 1 Complete
At this point the shell can:
- run external commands
- support basic built-ins (echo, pwd, type, exit)
- resolve programs using
$PATH - execute programs using
fork()andexecvp() - manage memory without leaks
Next: pipelines, redirection, and job control.