The Linux Shell and Terminal

Every time you interact with Linux from the command line, two different things are working together: a terminal, which is just a window that displays text and sends your keystrokes onward, and a shell, the program that actually reads what you type, makes sense of it, and asks the kernel to do the work. Understanding where one ends and the other begins is the single most useful mental model you can build before writing your first script, because almost every confusing "why doesn’t this work" moment traces back to mixing the two up. This lesson breaks down what a terminal and a shell really are, how a command travels from your keyboard to a running process and back, and the habits that make you fast and safe at the prompt.

Overview: What a Terminal and a Shell Actually Are

When you open a program like GNOME Terminal, Konsole, xterm, or connect over SSH, you are opening a terminal emulator — software that recreates the behavior of the physical text terminals older Unix systems used. The terminal emulator’s only real job is to display characters and forward your keystrokes. On Linux it does this through a kernel-provided device called a pseudo-terminal, or PTY, which comes in a matched pair: a "master" side the terminal emulator writes to and reads from, and a "slave" side (visible as something like /dev/pts/0) that behaves like a real terminal device to whatever program is attached to it.

That "whatever program" is the shell. The moment a terminal emulator starts, it forks a child process and execs a shell into it — on almost every modern Linux distribution, that shell is Bash (/bin/bash), though /bin/sh, dash, zsh, and fish also exist and can be set as a login shell. The shell is a command interpreter: an ordinary program, running as an ordinary process with its own process ID (PID), whose entire purpose is to read a line of text, figure out what you meant, and ask the kernel to carry it out — either directly, for shell builtins like cd or export that must run inside the shell’s own process because they change the shell’s own state, or by starting a brand-new process, for external commands like ls or grep that live as files on disk.

Login, interactive, and non-interactive shells

Every Linux user has a default login shell recorded in /etc/passwd, visible via the $SHELL environment variable and changeable with chsh -s /bin/bash. A login shell is one started as part of logging in — a fresh SSH session or a virtual console — and reads startup files like ~/.bash_profile. An interactive non-login shell — an ordinary terminal window on your desktop — reads ~/.bashrc instead. A non-interactive shell, the kind Bash starts to run a script, skips prompts and interactive-only startup files and just executes commands top to bottom. The important takeaway for now: the terminal and the shell are two separate programs talking through a kernel device, and closing one does not leave the other unaffected — closing a terminal window normally sends the shell, and anything it’s running, a SIGHUP signal, which is exactly the gotcha covered in Common Mistakes below.

Syntax: The Anatomy of a Command Line

Every command you type follows the same general shape, no matter which program you’re running:

command [options] [arguments...]

command is the name of a builtin or an executable file the shell can find. options (also called flags) turn on optional behavior and come in a short form like -l or a long form like --all; several short flags can often be combined, so -la means the same as -l -a. arguments are the things the command operates on, such as file or directory names. Bash itself doesn’t understand any of this — ls knows what -l means, not Bash. Bash’s job at this stage is only to split your line into words (respecting quotes), expand variables and wildcards, and hand the resulting words to whichever command you named.

Your prompt — the text before your cursor, such as:

ada@devbox:~/projects$ 

— is generated by the shell from the PS1 environment variable, and by convention shows the username, hostname, and current directory, ending in $ for a regular user or # if logged in directly as root. The prompt is simply Bash’s way of saying "I’m an interactive shell, ready to read a command" — it is not a property of the terminal window itself.

A handful of special variables let you inspect the shell and the commands it runs, and you’ll use them constantly once you start scripting:

Symbol Meaning
$0 The name of the currently running script or shell
$$ The process ID (PID) of the current shell
$! The PID of the most recently started background job
$? The exit status of the last command that finished
$SHELL Path to the account’s default login shell, e.g. /bin/bash
$PATH Colon-separated directories the shell searches for external commands

Examples

Example 1: Which shell am I actually running?

echo $SHELL
bash --version

Output:

/bin/bash
GNU bash, version 5.1.16(1)-release (x86_64-pc-linux-gnu)

echo $SHELL prints your account’s default login shell as recorded in /etc/passwd — this doesn’t necessarily match what you’re running right now if you launched a different shell manually. bash --version confirms the actual interpreter reading this command and its version, which matters because newer Bash features (like associative arrays, added in Bash 4) aren’t available in older versions.

Example 2: Finding your shell’s own process ID

echo "My shell's PID is $$"
ps -p $$
My shell's PID is 4521
    PID TTY          TIME CMD
   4521 pts/0    00:00:00 bash

$$ is a special parameter that always expands to the PID of the current shell process itself — not a subshell, not a child. Piping that PID into ps -p shows the same process from the kernel’s point of view: which pseudo-terminal it’s attached to, how much CPU time it has used, and the command running. Both lines describe the exact same process, PID 4521.

Example 3: Seeing what launched your shell

ps -o pid,ppid,comm -p $$
    PID    PPID COMMAND
   4521    4487 bash

PPID is the parent process ID — the process that forked this shell into existence. In a desktop terminal that’s usually the terminal emulator’s backend process; over SSH it’s typically sshd. Tracing PPID chains like this is how you confirm exactly what’s hosting your shell, which matters when debugging why a shell has certain environment variables or resource limits.

Example 4: Running a job in the background

sleep 300 &
jobs
echo "Job PID: $!"
[1] 4711
[1]+  Running                 sleep 300 &
Job PID: 4711

Appending & after a command tells Bash not to wait for it — the command becomes a background job, control returns to the prompt immediately, and $! captures its PID. jobs lists every background or suspended job attached to this specific shell, tagged with a job number you can use with fg %1 or kill %1. Because these jobs are children of your shell, they belong to this one terminal session — the exact mechanism Mistake 2 below exploits.

How It Works Step by Step

Here’s exactly what happens when you type ls -la /var/log at the prompt and press Enter:

  1. Bash reads the line and tokenizes it into words — ls, -la, /var/log — splitting on whitespace while respecting quotes.
  2. Bash performs expansions on each word: variables ($HOME), command substitution ($(...)), and filename globbing (*.log) all happen before anything runs. Here there’s nothing to expand.
  3. Bash checks whether ls is a keyword, function, or builtin. It isn’t, so Bash searches each directory listed in $PATH, in order, until it finds an executable file named ls (typically /usr/bin/ls on Debian/Ubuntu).
  4. Bash calls the fork() system call, asking the kernel to create a near-exact copy of the shell process — same open files, same environment, same working directory, but a new PID.
  5. Inside that new child process, Bash immediately calls exec() with the path it found. exec() replaces the child’s program code in memory with /usr/bin/ls, but keeps the same PID and file descriptors, which is why one command doesn’t need a third process to run.
  6. The parent shell calls wait() and pauses until the child finishes. ls reads the directory entries via the kernel and writes its listing to file descriptor 1 (standard output), still connected to your terminal’s PTY, so it appears on screen.
  7. When ls finishes it calls exit() with a status code — 0 for success, non-zero for an error. The kernel hands that number back to the shell, which stores it in $?, then prints a fresh prompt.

Every external command follows this same fork-exec-wait cycle. Builtins like cd skip steps 3–6 entirely because there’s no separate file to exec — they modify the running shell process directly, which is also why cd has to be a builtin: a child process changing its own working directory would have no effect on the parent shell that spawned it.

Common Mistakes

Mistake 1: Forgetting to quote a variable

It’s tempting to treat a shell variable holding a filename just like the string it contains, but Bash performs word splitting on unquoted expansions.

file="my report.txt"
cat $file
cat: my: No such file or directory
cat: report.txt: No such file or directory

$file expanded into two separate words, my and report.txt, because nothing protected the space in the value. cat then tried to open two nonexistent files instead of the one that exists. Quoting the expansion keeps the value intact as a single word:

file="my report.txt"
cat "$file"

Mistake 2: Closing the terminal kills your background job

Every process a shell starts, foreground or backgrounded with &, becomes a child of that shell. Closing the terminal window (or dropping the SSH connection) terminates the shell, which by default sends its children SIGHUP too.

./backup.sh

Left running in the foreground like this, closing the terminal kills backup.sh before it finishes. Running with nohup ("no hang up") tells the child to ignore SIGHUP, and disown removes it from the shell’s job table so the shell won’t wait on it either:

nohup ./backup.sh > backup.log 2>&1 &
disown

Mistake 3: Running a Bash script with sh

Bash supports syntax beyond the POSIX shell standard — [[ ]] conditionals, arrays, the =~ regex operator. On Debian/Ubuntu, /bin/sh is a symlink to dash, a smaller, stricter, POSIX-only shell that doesn’t understand Bash extensions.

sh deploy.sh
deploy.sh: 12: [[: not found

The script’s #!/usr/bin/env bash shebang line exists precisely so you never have to remember which interpreter a script needs — the kernel reads it and runs the right one automatically, but only if you execute the script directly instead of naming an interpreter yourself:

chmod +x deploy.sh
./deploy.sh

Best Practices

  • Confirm your shell before assuming Bash-only syntax works: echo $SHELL and bash --version.
  • Learn the core shortcuts: Ctrl+C sends SIGINT (interrupt), Ctrl+Z sends SIGTSTP (suspend), Ctrl+D sends EOF, Ctrl+R searches command history, Ctrl+A/Ctrl+E jump to the start/end of the line.
  • Use Tab completion for commands, files, and options — it prevents the typos that cause you to operate on the wrong file.
  • Check the exit status of commands that matter with $?, or use them directly in if, &&, or ||, instead of assuming success.
  • Avoid running an interactive shell as root; use sudo for individual privileged commands so actions stay scoped and logged.
  • For long jobs you want to survive a closed terminal, use nohup command & or a multiplexer like tmux or screen instead of a bare &.
  • Give scripts an explicit #!/usr/bin/env bash shebang and run them with ./script.sh rather than sh script.sh.

Practice Exercises

  1. Open a terminal and determine three things about your current shell: its PID (via $$), its parent process’s PID and name (via ps -o pid,ppid,comm -p $$), and whether it matches your login shell (compare against echo $SHELL).
  2. Start sleep 120 in the background with &, confirm it’s running with jobs, then bring it to the foreground with fg and stop it with Ctrl+C. Notice that the prompt returns immediately after backgrounding but waits while the job is in the foreground.
  3. Write a two-line script that uses a Bash-only feature such as [[ ]], save it as check.sh, run chmod +x check.sh, then execute it as ./check.sh and separately as sh check.sh. Compare the results and explain the difference.

Summary

  • A terminal emulator is just a text window; the shell is the separate program — usually Bash — that parses and executes what you type inside it.
  • The kernel connects a terminal to a shell through a pseudo-terminal (PTY); every terminal window runs its own independent shell process with its own PID.
  • Typing a command makes the shell tokenize the line, check builtins, search $PATH for external programs, then fork() and exec() a new process, recording its exit status in $?.
  • Special variables like $$, $!, $?, and $SHELL let you inspect the running shell and its jobs.
  • Background jobs started with & are children of your shell and normally die when it exits — use nohup, disown, or tmux/screen to keep them alive.
  • Always quote variable expansions, and run Bash scripts with Bash, not sh, unless they were specifically written to be POSIX-portable.