What you'll learn
Quick Answer
A terminal emulator and a shell are two separate programs connected through a kernel-provided pseudo-terminal (PTY): the emulator owns the PTY's master end for display and input, the shell runs attached to the slave end as just another process reading stdin. Every command you type triggers a fresh fork-then-exec cycle, and signals like Ctrl+C's SIGINT get delivered to an entire foreground process group at once, not to a single named program.
The Terminal and the Shell Are Two Completely Different Programs
Open a terminal app (Windows Terminal, iTerm2, GNOME Terminal, whatever) and type a command, and it feels like one continuous thing talking to you. It's actually two entirely separate programs with a narrow, well-defined boundary between them, and almost every confusing terminal behavior traces back to not knowing which side of that boundary something happens on.
The terminal emulator is the program that draws the window, renders text, handles your keyboard and mouse input, manages scrollback, tabs, and color themes. It has no idea what ls or git status mean — its only job is display and input/output plumbing. The shell (bash, zsh, PowerShell, fish) is a completely separate program that the terminal emulator starts up, and it's the shell that actually parses your command line, decides what program to run, and manages things like environment variables and your working directory. The terminal emulator and the shell communicate through a specific OS-provided mechanism built exactly for this split: the pseudo-terminal, or PTY.
The PTY: A Fake Serial Port Connecting Two Programs
Historically, a "terminal" was a physical device — a screen and keyboard wired to a mainframe over a serial line. Modern terminal emulators don't talk to real hardware, so the OS provides a pseudo-terminal (PTY) that behaves exactly like that old serial connection would, implemented entirely in software. A PTY always comes as a pair: a master end and a slave end. Whatever gets written to the master shows up as input on the slave, and whatever gets written to the slave (including a running program's normal output) shows up as readable data on the master.
When your terminal emulator starts up, it asks the kernel for a new PTY pair. It keeps the master end for itself — this is how it will send you keystrokes to the shell and receive text to display back. Then it forks a child process, and in that child, before running anything else, it wires the slave end of the PTY up as the child's stdin, stdout, and stderr. Only after that plumbing is in place does the child call exec to actually become the shell. From the shell's point of view, it's just a normal program reading from stdin and writing to stdout, with absolutely no awareness that there's a GUI window and a human on the other end — it's talking to a slave file descriptor that happens, through the PTY mechanism, to be connected to whatever the terminal emulator does with it.
What Actually Happens Between Pressing Enter and Seeing Output
Put the PTY split together with what the shell actually does, and pressing Enter after typing ls -la triggers a specific, mechanical sequence, not magic:
- The terminal emulator captures the keystrokes and writes the raw characters to the PTY master as you type, including the Enter key as a newline byte.
- The kernel's PTY layer delivers those bytes to the slave side, where the shell is blocked reading from its stdin. The shell has been accumulating characters into a line buffer and now sees the newline, signaling "a complete command line is ready."
- The shell parses that line: splits it into the command name and arguments, expands anything that needs expanding (globs like
*.txt, environment variables,~for home directory), and figures out which actual programlsrefers to by searching the directories listed in thePATHenvironment variable. - The shell calls
fork(), which creates a near-identical copy of the shell process itself. In that new child process, the shell calls one of theexecfamily of system calls, which replaces the child's entire program image with/bin/ls, keeping the same process ID and the same open file descriptors (which are still, ultimately, connected back through the PTY slave to the terminal). lsruns, writes its output to its stdout — which is the inherited PTY slave file descriptor — and exits. The kernel delivers that output through the PTY pair to the master, which the terminal emulator is continuously reading from, and the emulator renders those bytes as text in the window you're looking at.- The shell, which was waiting (via
wait()) for that child process to finish, wakes back up, prints a fresh prompt, and goes back to reading stdin for your next command.
Every single command you run is a brand new fork+exec cycle — the shell never "becomes" ls, it spawns a child that becomes ls while the shell itself keeps existing and waiting. This is exactly why cd has to be a built-in command implemented inside the shell itself rather than a separate program: if cd ran as a forked child process, it could only change the working directory of that short-lived child, which would vanish the instant the child exited, leaving the parent shell's own directory completely unaffected.
Ctrl+C, Background Jobs, and Process Groups
Job control is the layer that makes Ctrl+C, Ctrl+Z, and backgrounding commands with & work, and it depends on a concept one level above individual processes: the process group. When the shell launches a command (or a whole pipeline of commands connected with |), it puts all of the processes involved into a single process group, identified by a process group ID (PGID), and one of those groups is designated the current foreground process group for the terminal.
When you press Ctrl+C, the terminal driver (the kernel-level line-discipline code that watches for special control characters) doesn't go hunting for "the running program" by name — it sends a SIGINT signal directly to every process in the current foreground process group, all at once. That's why Ctrl+C correctly kills an entire pipeline like grep foo | sort | less in one keypress rather than only killing grep and leaving sort and less running: all three processes share the same PGID as members of that one pipeline, so the signal reaches all of them simultaneously. Ctrl+Z works the same way but sends SIGTSTP (suspend) instead of SIGINT (interrupt), which is what makes a suspended job resumable later with fg instead of being killed outright. Backgrounding a command with & simply skips making that process group the foreground one, so it keeps running without being attached to the terminal's keyboard-signal plumbing at all, and without blocking the shell from reading your next command.
Why Any of This Is Useful to Actually Know
This isn't just trivia — it explains real, otherwise-confusing behavior you've probably already hit. A GUI app launched from a shell script that doesn't background itself properly can hang the script's exit because the parent shell is still waiting for a child it forgot to detach from the process group. A program that ignores or re-handles SIGINT (some database clients and REPLs do this deliberately) won't quit on the first Ctrl+C, because it's choosing to catch the signal rather than die from it, not because Ctrl+C is broken. And remote tools like SSH, tmux, and screen are, at their core, just more PTY master ends — SSH's server side allocates a PTY on the remote machine exactly the same way a local terminal emulator does, forks a shell against its slave end, and tunnels the master end's bytes over the network connection, which is the entire reason a remote shell session feels indistinguishable from a local one: structurally, it is the same mechanism, just with the master and slave sitting on different machines.
