msh (short for "my shell") is a small command-line shell written in C. It works a lot
like bash or zsh, just stripped down to the essentials. You type a command, it runs
the program for you, and then it gives you the prompt back so you can type the next one.
Under the hood it does the same things a real shell does: it parses what you type, spins up child processes to run your commands, keeps track of background and foreground jobs, remembers your command history, and responds to keyboard signals like Ctrl-C and Ctrl-Z.
- Run programs — type the full path to a program plus its arguments and
mshwill run it. - Run several commands at once — separate them with
;(run one after another) or&(run in the background). - Background & foreground jobs — start something in the background with
&, then move it around withbgandfg. - Job tracking — see everything that's currently running with
jobs. - Send signals — use the built-in
killto send signals (like terminate or stop) to a process. - Command history — every command you type is remembered, even between sessions. Recall an old command by number with
!N. - Keyboard signals — Ctrl-C cancels the current foreground program, and Ctrl-Z suspends it, without killing the shell itself.
There's a tiny build script in scripts/:
cd scripts
./build.shThat just calls gcc to compile everything in src/ and drops the finished msh
binary into bin/. Under the covers it runs:
gcc -I../include/ -o ../bin/msh ../src/*.c./bin/mshYou'll get a prompt:
msh>
Type a command and hit Enter. To leave, just type exit.
You can tweak a few limits when you launch it:
| Flag | What it does | Default |
|---|---|---|
-s N |
How many history entries to keep | 10 |
-j N |
How many jobs can run at once | 16 |
-l N |
Max characters allowed per command line | 1024 |
For example:
./bin/msh -s 50 -j 8 -l 2048msh expects the full path to whatever you want to run, just like the kernel does:
msh> /bin/ls -l
msh> /bin/echo hello world
;runs commands in the foreground, one after another:msh> /bin/echo first ; /bin/echo second&runs a command in the background so you get your prompt right back:msh> /bin/sleep 10 &
msh> jobs # list everything that's running or stopped
msh> bg %1 # resume job #1 in the background
msh> fg %1 # bring job #1 to the foreground
msh> kill -9 12345 # send signal 9 (kill) to process 12345
Job control supports signals 2 (interrupt), 9 (kill), 18 (continue), and 19 (stop).
msh> history # show your recent commands, numbered
msh> !3 # re-run command number 3
Your history is saved to data/.msh_history when you exit, so it's still there the next
time you start the shell.
Here's the journey a command takes from the moment you press Enter:
-
You type a line.
msh.cholds the main loop. It prints themsh>prompt, reads your whole line withgetline, and (unless you typedexit) hands it off toevaluate. -
The line gets split into separate commands.
parse_tok(inshell.c) walks through the line and breaks it apart at every;or&. Each chunk is one command, and it also notes whether that command should run in the foreground or background. -
Each command gets split into arguments.
separate_argstakes a single command and chops it into anargv-style array (the program name plus its arguments), just like theargvyour ownmainfunction receives. -
Built-in commands are handled directly. Some commands (
history,jobs,bg,fg,kill, and!N) are handled by the shell itself inbuiltin_cmd— there's no separate program to run, somshjust does the work and moves on. -
Everything else becomes a child process. For a normal program,
mshcallsforkto make a copy of itself, and the child callsexecveto replace itself with the program you asked for. The parent (the shell) records the new job in its jobs table.- If it's a foreground job, the shell waits for it to finish before showing the prompt.
- If it's a background job, the shell records it and immediately gives you the prompt back.
-
Signals keep everything in sync.
signal_handlers.csets up handlers so the shell reacts to events:SIGCHLDfires when a child finishes or changes state — the handler "reaps" finished children (so they don't become zombies) and updates job states.SIGINT(Ctrl-C) is forwarded to whatever is running in the foreground.SIGTSTP(Ctrl-Z) suspends the foreground job instead of the shell.
-
On exit, things get cleaned up. When you type
exit, the shell waits for any background jobs to finish, saves your command history to disk, frees all its memory, and shuts down.
Unix-Shell-Project/
├── src/
│ ├── msh.c # main entry point + the read-eval loop and argument parsing
│ ├── shell.c # the heart of it: parse the line, run built-ins, fork/exec programs
│ ├── job.c # the jobs table — add, delete, and free background/foreground jobs
│ ├── history.c # command history: load from file, add lines, recall, save on exit
│ └── signal_handlers.c # handlers for Ctrl-C, Ctrl-Z, and child process events
│
├── include/ # matching header files for each source file
│ ├── shell.h
│ ├── job.h
│ ├── history.h
│ └── signal_handlers.h
│
├── scripts/
│ └── build.sh # one-line gcc build script
│
├── bin/ # where the compiled msh binary lands
├── data/
│ └── .msh_history # your saved command history
└── tests/ # unit tests + milestone test cases
├── test_parse_tok.c
├── test_separate_args.c
└── milestone1/ # input/expected-output test pairs
- Use full paths.
mshruns programs withexecveand doesn't search yourPATH, so use/bin/lsrather than justls. - History skips a few things. Blank lines,
exit, and!Nrecalls aren't saved to history — only the real commands you ran. - History rolls over. Once history is full, the oldest entry drops off to make room for the newest one.
- Each job gets its own process group, which is what makes Ctrl-C and Ctrl-Z affect the right program instead of the shell.
The tests/ folder has small unit tests for the parsing functions (test_parse_tok and
test_separate_args) plus a set of milestone test cases in tests/milestone1/. Each
milestone case is a trio of files — an .in (what to type), .args (how to launch the
shell), and .ans (the expected output) — that get compared automatically by
test_msh.sh.