The Terminal and the Shell
19.0 What this chapter gives you#
- You will be able to say what a terminal actually was, as a physical machine, and why the word survived after the machine died.
- You will be able to tell apart five things that people constantly mix up: terminal, terminal emulator, shell, console, and command line.
- You will be able to explain what a pseudo-terminal is, and describe exactly what happens inside the machine when you press Ctrl-C.
- You will be able to describe the shell’s read-parse-expand-execute loop, and name the two system calls that start every program you run.
- You will be able to use redirection and pipes correctly, including the case where
2>&1in the wrong place does nothing useful. - You will be able to read an exit code, chain commands with
&&and||, and say why continuous integration systems only look at that number. - You will be able to fix a broken
PATH, and say which file to edit on macOS, on a Linux server, and why the answer differs between the two. - You will be able to explain the Windows registry properly, and say why a macOS or Linux developer almost never thinks about one.
- You will be able to use around forty commands with confidence, and read a manual page to find the fortieth-first.
- You will be able to write a real shell script with error handling that stops instead of continuing into damage.
19.1 What a terminal actually is#
PLAIN19.1.1 in simple words#
- Today, a terminal is a window on your screen with text in it.
- That is a costume. The word means something older and physical.
- A terminal was a machine that sat at the end of a wire.
- The other end of the wire went to a computer, often in another building.
- The terminal had a keyboard and a way to show what came back.
- It had almost no brain of its own. It sent characters out and printed characters in. Nothing more.
- The earliest ones printed onto a paper roll, like a till receipt that never ends.
- Later ones had a small screen instead of paper, and that was a huge saving in paper and noise.
- When personal computers arrived, nobody needed the physical machine any more. The computer was on your desk.
- But all the software already knew how to talk to a terminal. So we kept pretending. A program on your screen now acts like the old machine.
- That is why the window is called a terminal, and why it still obeys commands designed for a device made in 1978.
PLAIN19.1.2 a picture in your head#
- Imagine a hotel in 1970 with one telephone switchboard in the basement.
- Every room has a simple phone. The phone has no brain. It has a handset and a dial.
- All the intelligence, all the routing, all the connections, live in the basement.
- The phone in the room is the terminal. The switchboard is the computer.
- Ten rooms can be connected at once. The basement machine handles all ten, giving each a slice of its attention.
- Now the hotel modernizes. Every room gets its own full computer. The basement machine is thrown away.
- But the guests learned to use the handset. So the new computers put a picture of a handset on their screens, and it works the same way.
- That picture is the terminal emulator you use today.
Where this comparison breaks: a hotel phone carries sound as a continuous wave, while a terminal carries discrete characters, one byte at a time, with a strict alphabet. And the old terminal was not entirely brainless. It knew how to move its own cursor, clear its own screen, and pick a colour, when told to by special character sequences. That small amount of local intelligence is exactly the part that survives in your terminal window today.
PLAIN19.1.3 a worked example#
- Picture the Teletype Model 33, sold from 1963. Engineers called it the ASR-33.
- ASR stands for Automatic Send-Receive: it could also punch and read paper tape.
- It weighed around 25 kilograms. It printed onto a roll of paper. It was loud, like a typewriter that never stopped.
- It ran at 110 bits per second. That is 10 characters per second.
- Type a line of 80 characters and wait 8 seconds for the reply to finish printing. That was the normal rhythm of computing.
- Because printing was slow, command names were made short. This is the real reason UNIX has
ls,cp,mv,rmand notlist,copy,move,remove. - There is no scrolling back on a paper terminal. What is printed is printed. To see something again, you print it again.
- There is no clearing the screen either. There is no screen.
- Now compare the DEC VT100, introduced in 1978. It had a glass screen showing 24 rows of 80 characters.
- Suddenly the cursor could be moved anywhere, text could be erased, and the screen could be redrawn. Full-screen text editors became possible.
- Below is a real capture of the bytes a modern terminal still uses to turn text red and bold. This was taken from the sandbox used to write this chapter.
$ printf '\033[1;31mRED BOLD\033[0m\n' | od -c
0000000 033 [ 1 ; 3 1 m R E D B O
0000020 L D 033 [ 0 m \n
033is the octal number for the ESC character, decimal 27, hex 1B.ESC [ 1 ; 3 1 mmeans “bold, red foreground”.ESC [ 0 mmeans “reset everything”.- That exact sequence was defined for terminals of the VT100 era. Your terminal in 2026 still speaks it.
PLAIN19.1.4 what is really happening inside#
- A terminal and a computer are joined by a serial line: one wire for characters going out, one for characters coming in.
- When you press the A key, the terminal sends the single byte 65 down the wire. It does not draw an A on its own.
- The computer receives 65, decides what to do, and usually sends 65 straight back. That is called echo.
- Only when the byte comes back does the terminal print it. So on a slow or broken line, your typing does not appear.
- This is why an old terminal feels like a conversation: everything you see is something the far end chose to send you.
- Most bytes mean “print this character”. A few mean “do this action”.
- Byte 7 rings the bell. Byte 8 moves the print head back one space. Byte 10 moves down a line. Byte 13 returns to the left margin.
- Byte 27, ESC, means “the next few bytes are not text, they are an instruction”.
ESC [starts a control sequence. Numbers and a final letter follow.ESC [ 2 Jclears the screen.ESC [ 10 ; 5 Hputs the cursor on row 10, column 5.- Every manufacturer invented their own sequences at first, which was chaos. A standard fixed that, and the VT100 was the terminal that made the standard popular.
- In UNIX, every device is a file. The file that represents the connection to a terminal was named after the machine on the other end.
- Teletype was shortened to tty. The device is
/dev/tty. That is the whole origin of the name.
TECHNICAL19.1.5 the engineer’s version#
- The Teletype Corporation Model 33 was introduced as a commercial product in 1963, after an original design for the United States Navy.
- It was one of the first products to use ASCII, first published in 1963. Over half a million Model 33 units had been built by 1975.
- The Model 33 defined by accident two long-lived conventions: code 17 (Ctrl-Q, DC1) as XON and code 19 (Ctrl-S, DC3) as XOFF for flow control.
- Ctrl-S still freezes many terminals today, and confused users still think their machine has crashed. It has not. Press Ctrl-Q.
- The DEC VT52 arrived in September 1975 with a proprietary escape set. The DEC VT100 arrived in 1978 and implemented the emerging ANSI sequences.
- The relevant standards, in order: ECMA-48 adopted 1976; ANSI X3.41-1974 and ANSI X3.64-1977 cited in the VT100 manual; the name “ANSI escape sequence” dates from ANSI’s 1979 adoption of X3.64.
- ECMA-48 and X3.64 were merged into ISO/IEC 6429. ANSI withdrew X3.64 in 1994 in favour of the international standard. Japan adopted JIS X 0211.
- So the correct name in 2026 is ECMA-48 or ISO/IEC 6429. “ANSI escape codes” is a convention of speech, not a live standard name.
- The VT100 used an Intel 8080 processor, showed 24 lines of 80 columns, and supported 132-column mode. Its market success created a clone industry: Zenith Z-19 in 1979, Qume QVT-108, Televideo TVI-970, Wyse WY-99GT.
- Because sequences still varied, UNIX grew a capability database: termcap, then terminfo. Programs ask the database, not the hardware.
- The
TERMenvironment variable names your terminal type.tputandinfocmpread the database. Real output from this sandbox:
$ TERM=xterm-256color tput colors
256
$ TERM=xterm-256color tput setaf 1 | od -c
0000000 033 [ 3 1 m
$ infocmp xterm | head -5
xterm|xterm-debian|xterm terminal emulator (X Window System),
am, bce, km, mc5i, mir, msgr, npc, xenl,
colors#8, cols#80, it#8, lines#24, pairs#64,
bel=^G, blink=\E[5m, bold=\E[1m, cbt=\E[Z,
- Note the honest detail: plain
xtermclaims 8 colours,xterm-256colorclaims 256. SettingTERMwrongly is a common cause of broken colours and broken arrow keys over SSH.
| Machine | Year | Output | Speed |
|---|---|---|---|
| Teletype 33ASR | 1963 | paper roll | 110 bit/s |
| DEC VT52 | 1975 | 24x80 screen | up to 19200 |
| DEC VT100 | 1978 | 24x80 screen | up to 19200 |
| Modern emul. | 2026 | window | memory speed |
- The honest version: your terminal is not emulating a VT100 exactly. It emulates a superset, usually announced as
xterm-256color. Mouse reporting, bracketed paste, true colour and Unicode are all later additions that no VT100 ever had.
WORDS19.1.6 remember these#
- Terminal — a machine at the end of a wire for typing and reading — a character-oriented I/O device with a keyboard and display.
- Teleprinter — a typewriter that talks over a wire — an electromechanical send-receive device using a serial character code.
- ASR-33 — the famous noisy paper terminal — Teletype Model 33 Automatic Send-Receive, 1963, 110 bit/s, 10 characters per second.
- Escape sequence — a few characters that mean “do something” not “print something” — a control string beginning with ESC, per ECMA-48.
- tty — the name of the terminal device file — abbreviation of teletype, exposed as
/dev/ttyand/dev/ttyN. - terminfo — a list of what each terminal model can do — a compiled capability database consulted through
tputand curses. - Echo — the far end sending your keystroke back so you can see it — local or remote reflection of input characters, controlled by the line discipline.
19.2 Terminal, terminal emulator, shell, console and command line#
PLAIN19.2.1 in simple words#
- Five words get used as if they mean the same thing. They do not.
- A terminal is the device, real or pretended, that carries characters in and out.
- A terminal emulator is a program that draws a terminal on your screen. Terminal.app and iTerm2 on macOS are terminal emulators.
- A shell is a different program entirely. It reads what you type and runs programs for you.
bashandzshare shells. - A console originally meant the one screen physically attached to the machine, used for its most important messages.
- A command line is not a program at all. It is a style of working: you type a line, you press Enter, something happens.
- When you open Terminal.app on a Mac, you start a terminal emulator, and it starts a shell inside itself.
- The window is the emulator. The
%or$prompt inside it comes from the shell. - If the shell crashes, the window stays but nothing responds. If the window closes, the shell dies with it.
PLAIN19.2.2 a picture in your head#
- Think of a theatre.
- The building is the terminal emulator. It has seats, lighting, a stage, and a door to the street.
- The play performed on the stage is the shell. It is what you actually came to watch.
- The building does not know the plot. It only supplies a place to perform and a way for the audience to see and be heard.
- You can put a different play in the same building. Run
zshinstead ofbashand nothing about the window changes. - You can also put the same play in a different building. Run
bashunder iTerm2 instead of Terminal.app and the play is identical. - The console is the stage manager’s desk backstage: the one place that gets the emergency announcements even when the audience does not.
Where this comparison breaks: a theatre and a play are physically separate, while your emulator and shell are joined by a specific and strange piece of kernel plumbing, not by air. And the shell can run with no building at all, reading commands from a file, with nobody watching. That is a shell script.
PLAIN19.2.3 a worked example#
- Open Terminal.app on macOS. You now have four things stacked up.
- Terminal.app itself is a normal macOS application, written with Apple’s UI toolkit. It draws characters and reads your keyboard.
- It asked the operating system for a pseudo-terminal, a fake wire.
- It started
/bin/zshon the far end of that fake wire. zshprinted a prompt into the fake wire, and Terminal.app drew it.- Prove it to yourself. Type
ttyand press Enter. On macOS you will see something like/dev/ttys003. On Linux you will see/dev/pts/0. - Real output from the Linux sandbox used to write this chapter, running a shell inside a real pseudo-terminal:
$ tty
/dev/pts/0
$ echo $SHELL
/bin/bash
$ ps -o pid,ppid,pgid,sid,tpgid,stat,tty,comm
PID PPID PGID SID TPGID STAT TT COMMAND
30511 30510 30511 30511 30512 Ss pts/0 bash
30512 30511 30512 30511 30512 R+ pts/0 ps
- Read that table.
bashhas process ID 30511.pshas 30512 and its parent is 30511, so the shell started it. - Both are attached to terminal
pts/0. That is the fake wire. TPGIDis 30512, meaning the terminal’s foreground group is currentlyps, not the shell. That is what “the shell is waiting” looks like from outside.
PLAIN19.2.4 what is really happening inside#
- The emulator owns one end of the fake wire. The shell owns the other.
- Everything you type goes into the emulator, which writes those bytes into its end.
- The operating system carries them across and hands them to the shell.
- Everything the shell or its programs print goes the other way, and the emulator draws it.
- The emulator has one extra job: it must understand escape sequences. When bytes
ESC [ 2 Jarrive, it clears its own drawing area. - The shell has one extra job: it must decide what your line of text means.
- Neither knows much about the other. You can replace either one.
- The console is a separate idea. On a Linux server it is where kernel messages appear, even before any emulator exists.
- On a physical Linux machine, pressing Ctrl-Alt-F2 gives you a real text console,
/dev/tty2, drawn by the kernel itself with no emulator involved. - On macOS there is no such thing for the user. There is a boot-time verbose console and a system log, but no switchable text consoles.
- Windows is different again. Its window is
conhost.exeor Windows Terminal, and the thing inside iscmd.exeorpowershell.exe.
TECHNICAL19.2.5 the engineer’s version#
| Thing | What it is | Examples |
|---|---|---|
| Terminal | character device | tty, pty, serial |
| Terminal emulator | GUI application | Terminal.app, iTerm2 |
| Shell | command language | bash, zsh, fish |
| Console | primary device | /dev/console, tty1 |
| Command line | interaction mode | not a program |
- On Linux, virtual consoles are
/dev/tty1through/dev/tty63, driven by the kernel VT subsystem, with no user-space emulator. /dev/consoleis the kernel’s message destination, set by theconsole=kernel command-line parameter at boot./dev/ttywith no number is a magic alias meaning “the controlling terminal of the process reading it”.- Pseudo-terminal slaves on Linux are
/dev/pts/N, provided by thedevptsfilesystem. On macOS and BSD they are/dev/ttysNNN. - Common emulators, with real facts: xterm, first released 1984, still the compatibility reference. GNOME Terminal, KDE Konsole, Alacritty, kitty, WezTerm, iTerm2 on macOS, Windows Terminal from Microsoft.
- Windows Terminal reached version 1.0 on 19 May 2020. Before it, Windows had only the legacy console host.
- macOS Terminal.app ships with macOS. It defaults
TERMtoxterm-256colorin recent versions. iTerm2 is a third-party replacement with split panes and its own escape extensions. - The distinction matters operationally. Colour problems and key problems are emulator or
TERMproblems. Completion and prompt problems are shell problems. Never debug one by changing the other.
WORDS19.2.6 remember these#
- Terminal emulator — the window program — a user-space application that allocates a pty and renders ECMA-48 output.
- Shell — the program that runs your commands — a command language interpreter, specified for
shby POSIX IEEE Std 1003.1. - Console — the machine’s own primary screen —
/dev/console, the kernel’s message device, selected at boot. - Virtual console — a full-screen text session on Linux —
/dev/ttyN, implemented in the kernel VT layer. - Controlling terminal — the terminal that owns your session — the tty a session leader has opened, reachable as
/dev/tty.
19.3 The pseudo-terminal#
PLAIN19.3.1 in simple words#
- There is no wire and no machine any more. So the operating system fakes one.
- The fake is called a pseudo-terminal, usually shortened to PTY.
- It comes as a pair of ends that are joined inside the kernel.
- One end is held by the terminal emulator. Whatever it writes appears as keyboard input on the other end.
- The other end is held by the shell. Whatever the shell prints comes out of the first end for the emulator to draw.
- In between sits a piece of kernel code called the line discipline.
- The line discipline is not a pipe. It is an active thing that changes the characters as they pass.
- It is why backspace deletes a letter instead of printing a strange symbol.
- It is why the shell does not see your line until you press Enter.
- It is why Ctrl-C stops a program, instead of being delivered to it as the letter it technically is.
PLAIN19.3.2 a picture in your head#
- Imagine two people passing notes through a slot in a wall.
- On the wall’s slot sits a clerk. The clerk reads every note before passing it on.
- When you write a letter, the clerk copies it onto a public board so you can see what you wrote. That is echo.
- When you scribble out a letter, the clerk erases it from the board and from the note. That is backspace.
- The clerk holds each note until you write a full stop. Only then is the note pushed through. That is line buffering.
- If you write a special mark meaning “stop”, the clerk does not pass the mark along. The clerk runs to the other room and pulls the person out of their chair. That is Ctrl-C.
- The clerk can be told to stop doing all of this. In that mode every letter goes through instantly, unchanged, uncopied.
- Text editors and games ask for exactly that mode, because they want to react to every single key.
Where this comparison breaks: the clerk is not slow and not optional. It is kernel code running in microseconds, and there is always one, even in the mode where it does almost nothing. Also, the clerk does not understand your notes. It only recognizes about a dozen specific characters and treats every other byte as data to be passed along.
PLAIN19.3.3 a worked example#
- Here are the special characters the line discipline is watching for, taken from a real
stty -arun inside a pseudo-terminal on Linux.
$ stty -a
speed 38400 baud; rows 0; columns 0; line = 0;
intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^D;
start = ^Q; stop = ^S; susp = ^Z; rprnt = ^R; werase = ^W;
lnext = ^V; discard = ^O; min = 1; time = 0;
isig icanon iexten echo echoe echok -noflsh -tostop
- Read the important ones.
intr = ^Cmeans Ctrl-C is the interrupt key. susp = ^Zmeans Ctrl-Z suspends.eof = ^Dmeans Ctrl-D signals end of input.erase = ^?means the Delete character erases one letter.kill = ^Uerases the whole line.werase = ^Werases one word.lnext = ^Vmeans “take the next key literally, do not treat it specially”.- Now the flags on the last line, which are the settings, not the keys.
icanonmeans canonical mode is on: input is collected into lines.echomeans the kernel prints your keystrokes back for you.isigmeans the special keys generate signals. Turn this off withstty -isigand Ctrl-C becomes an ordinary byte.- Notice
speed 38400 baud. There is no wire, so this number is fiction. It is kept only because programs still ask for it.
PLAIN19.3.4 what is really happening inside#
- Here is the whole chain, from your finger to a character on the glass.
your keyboard
|
v
[ window system: macOS AppKit / X11 / Wayland ]
| key event
v
[ terminal emulator process ]
| writes bytes
v
+-----------------------------+
| PTY MASTER (/dev/ptmx) |
+-----------------------------+
| kernel
v
+-----------------------------+
| LINE DISCIPLINE |
| echo, erase, line buffer, |
| Ctrl-C -> SIGINT, |
| Ctrl-Z -> SIGTSTP |
+-----------------------------+
|
v
+-----------------------------+
| PTY SLAVE (/dev/pts/0) |
+-----------------------------+
| read() returns a line
v
[ shell process: bash or zsh ]
| fork + exec
v
[ the command you ran ]
| writes to fd 1
v
back up through slave, line
discipline, master, emulator,
and onto the screen
- Step by step, when you type
lthensthen Enter: - The emulator writes the byte
linto the master. - The line discipline copies it back towards the master, so the emulator draws
l. It also stores it in a small buffer, not yet given to the shell. - Same for
s. The buffer now holdsls. - You press Enter, which sends carriage return, byte 13. The line discipline translates it to newline, byte 10, and now considers the line complete.
- The shell’s
read()call, which was blocked and waiting, returns the three bytesl,s, newline. - Now the interesting one. You press Ctrl-C. That is byte 3.
- The line discipline sees byte 3 matches
intr. It does not put it in the buffer. - Instead it sends the signal SIGINT to every process in the terminal’s foreground process group.
- The default action of SIGINT is to end the process. So your running command stops. The shell was not in the foreground group, so it survives.
- Ctrl-D is different. It is not a signal. It means “end of file now”.
- If the buffer has text in it, Ctrl-D pushes that text through immediately.
- If the buffer is empty,
read()returns zero bytes, which means end of input, and the shell exits. - That is why Ctrl-D on an empty line logs you out, and why pressing it in the middle of a typed line does nothing visible.
- Ctrl-Z sends SIGTSTP, which suspends. The process freezes in place. The shell notices, takes back the terminal, and prints your prompt.
TECHNICAL19.3.5 the engineer’s version#
- A PTY is a bidirectional character device pair. On Linux the master is obtained by opening
/dev/ptmx, which allocates a new slave in/dev/pts. - The relevant calls are
posix_openpt,grantpt,unlockpt,ptsname, thenopenon the slave.openptyandforkptywrap all of it. - The slave is made the controlling terminal by the child calling
setsidthenioctl(TIOCSCTTY), or implicitly on first open by a session leader. - Terminal settings live in
struct termioswith four flag sets:c_iflaginput,c_oflagoutput,c_cflagcontrol,c_lflaglocal, plus thec_ccarray of control characters. ICANONinc_lflagselects canonical mode. Cleared, you are in raw mode andVMINandVTIMEinc_cccontrol read behaviour.ECHO,ECHOE,ECHOK,ISIG,IEXTENare the otherc_lflagbits you will actually touch.OPOSTandONLCRinc_oflagtranslate newline to carriage-return newline on output.- Signals generated by the line discipline, with their Linux numbers:
| Key | Byte | Signal | Default action |
|---|---|---|---|
| Ctrl-C | 0x03 | SIGINT (2) | terminate |
| Ctrl-\ | 0x1C | SIGQUIT (3) | terminate, core |
| Ctrl-Z | 0x1A | SIGTSTP (20) | stop |
| none | none | SIGTTIN (21) | stop background |
| none | none | SIGTTOU (22) | stop background |
- Signals go to the foreground process group of the controlling terminal, found with
tcgetpgrpand set withtcsetpgrp. This is the field shown asTPGIDbyps. - Job control was first implemented in the C shell by Jim Kulp at IIASA in Austria, using features of the 4.1BSD kernel, released 1981.
- A background process that reads from the terminal gets SIGTTIN and stops. That is why a backgrounded interactive program mysteriously freezes.
- Window size is not part of
termios. It isstruct winsizeset withioctl(TIOCSWINSZ), and changing it delivers SIGWINCH, signal 28, to the foreground group. - Real job-control evidence from a pseudo-terminal session in this sandbox:
demo$ sleep 300 &
[1] 30514
demo$ jobs
[1]+ Running sleep 300 &
demo$ ps -o pid,pgid,stat,tty,comm --ppid $$
PID PGID STAT TT COMMAND
30514 30514 S pts/0 sleep
30515 30515 R+ pts/0 ps
demo$ kill %1
- Note that
sleephas its own PGID equal to its PID. Each job gets its own process group, which is what makes Ctrl-C hit one job and not all of them. - The
+inR+means “in the foreground process group”.sleephas no plus, because it is a background job. - Windows had no equivalent until ConPTY, the Windows Pseudo Console API, which first shipped in the Windows 10 October 2018 update, version 1809. Before that, tools faked it by screen-scraping the console.
- The honest version: a PTY is not a perfect terminal. There is no real baud rate, no parity, no modem control lines.
sttywill happily accept settings for hardware that does not exist and silently ignore them.
WORDS19.3.6 remember these#
- PTY — a fake wire between a window and a shell — a pseudo-terminal device pair, master plus slave, joined in the kernel.
- Line discipline — the kernel code that edits your typing — the tty layer implementing canonical mode, echo and signal generation.
- Canonical mode — input is handed over one whole line at a time —
ICANONset intermios.c_lflag, with line editing by the kernel. - Raw mode — every key reaches the program instantly —
ICANONandECHOcleared,VMINandVTIMEcontrollingreadbehaviour. - Foreground process group — the job that owns the keyboard right now — the process group returned by
tcgetpgrpon the controlling terminal. - SIGINT — the polite “stop that” signal from Ctrl-C — signal 2, default action terminate, catchable and ignorable.
- Job control — running several programs from one terminal — process groups, sessions and the SIGTSTP, SIGCONT, SIGTTIN, SIGTTOU family.
19.4 What a shell is#
PLAIN19.4.1 in simple words#
- A shell is a program with a very small job description.
- It reads a line of text. It works out what you meant. It asks the operating system to run something. It waits. It prints a prompt again.
- That is the whole loop. Everything else is decoration on those four steps.
- The shell is not part of the operating system. It is an ordinary program.
- You can have several shells installed and switch between them freely.
- The shell is also a programming language, with variables, conditions and loops. That is what makes shell scripts possible.
- A few commands are built into the shell itself, because they must be.
cdis the main one. cdcannot be a separate program, because a separate program cannot change its parent’s current directory.- Everything else,
lsandgrepandpython, is a real file on disk that the shell finds and launches.
PLAIN19.4.2 a picture in your head#
- Think of a restaurant with one waiter and a kitchen.
- You say “two coffees and the soup”. The waiter does not cook.
- The waiter interprets. “Two coffees” becomes two separate drink orders. “The soup” means today’s soup, so the waiter fills in the detail.
- The waiter walks to the kitchen and passes the orders. The kitchen is the operating system.
- The waiter then stands and waits until the food is ready, and brings it back.
- Then the waiter returns to your table and stands ready again. That is the prompt.
- If you ask for something not on the menu, the waiter comes back and says so. That is
command not found. - The waiter can also do a few things without the kitchen: move you to another table, remember your name. Those are the built-in commands.
Where this comparison breaks: the waiter understands meaning, and will guess sensibly if you are vague. The shell does not understand anything. It applies fixed textual rules in a fixed order. If those rules turn your line into nonsense, it passes the nonsense to the kitchen without hesitation. A waiter who behaved like a shell would fetch a chair when you said “chair” in the middle of a sentence about the weather.
PLAIN19.4.3 a worked example#
- You type this and press Enter:
grep -c 500 access.log
- Step 1, read. The shell has the line as a string of 24 characters.
- Step 2, split into words. It uses spaces and tabs, giving four words:
grep,-c,500,access.log. - Step 3, expand. It checks each word for things it must rewrite: variables, wildcards, braces, tildes, backticks. Here there are none, so nothing changes.
- Step 4, find the program.
grephas no slash in it, so the shell searches the directories listed inPATH, in order. - It finds
/usr/bin/grepand stops looking. - Step 5, run it. The shell makes a copy of itself, and the copy replaces itself with
grep. - Step 6, wait. The shell sleeps until
grepfinishes. grepprints3and exits with status 0.- Step 7, record the result. The shell stores 0 in the variable
?and prints the prompt. - The real run, from this sandbox:
$ grep -c 500 access.log
3
$ echo $?
0
PLAIN19.4.4 what is really happening inside#
- The shell cannot simply “become”
grep, because then it would be gone and you would have no shell left. - So it uses a two-step trick that UNIX has used since the beginning.
- First it calls fork. The operating system makes a near-identical copy of the shell process. Two processes now exist, running the same code.
- Both come back from
fork, but with different answers. The parent gets the child’s process number. The child gets zero. - That difference is how each one knows who it is.
- The child then calls exec. This does not create a process. It throws away the current program’s memory and loads a new program in its place.
- The process number does not change. Open files do not change. Environment variables do not change. Only the code and data are replaced.
- This is exactly why redirection works. Between fork and exec, the child can quietly rearrange its own files, and the new program inherits the arrangement without knowing.
- The parent calls wait. It sleeps until the child ends, and collects the child’s exit number.
- If you typed
&at the end, the parent skips the waiting and prints the prompt immediately. That is a background job. - Between pressing Enter and seeing output, then: line read, words split, expansions applied, program located, fork, file descriptors set up, exec, program runs, output travels back up the pseudo-terminal, parent collects the exit status, prompt printed.
- On a modern machine that whole sequence takes well under a millisecond, plus however long your program actually takes.
TECHNICAL19.4.5 the engineer’s version#
- The canonical loop, in C-like pseudocode, is short enough to memorize:
for (;;) {
print_prompt();
line = read_line(); /* from fd 0 */
words = tokenize(line);
words = expand(words); /* order matters, see 19.12 */
if (is_builtin(words[0])) { run_builtin(words); continue; }
pid = fork();
if (pid == 0) {
setup_redirections(); /* dup2 on 0,1,2 */
execvp(words[0], words); /* only returns on failure */
_exit(127); /* command not found */
}
waitpid(pid, &status, 0);
last_status = WEXITSTATUS(status);
}
forkis defined in POSIX. Linux implements it withclone. Modern implementations use copy-on-write, so the copy is cheap: page tables are duplicated, physical pages are shared until written.vforkandposix_spawnexist as cheaper variants. Real shells often useforkanyway because the child needs to run arbitrary setup code.execvpis the variant that searchesPATHand takes an argument vector. Theevariants take an explicit environment; without them the currentenvironis inherited.- A successful
execnever returns. Any code after it runs only on failure, which is why the example calls_exit(127). - Exit status is packed into an integer by
wait.WEXITSTATUSextracts the low 8 bits.WIFSIGNALEDandWTERMSIGcover death by signal. - Builtins are required for anything that must change shell state:
cd,export,exec,set,unset,shift,trap,read,wait,eval,source,umask, and the job control commands. - Some builtins exist only for speed.
echo,test,[,pwd,killandprintfall exist as real binaries in/usr/binas well. - You can see the duplication:
$ type -a echo
echo is a shell builtin
echo is /usr/bin/echo
echo is /bin/echo
$ type cd
cd is a shell builtin
- This matters.
/usr/bin/echo -eand the bash builtinecho -ebehave differently, and scripts that assume one get the other undersh. Useprintfwhen it matters. That advice is a convention, not a standard, but it is close to universal among careful script authors. - Instrument the whole thing with
strace -f -e trace=execve,clone,wait4on Linux, ordtrusson macOS, which needs elevated privileges and System Integrity Protection considerations.
WORDS19.4.6 remember these#
- Shell — the program that runs your typed commands — a command language interpreter and scripting language.
- Built-in — a command the shell performs itself — a function inside the shell binary, not a file in
PATH. - fork — make a copy of the running program — the POSIX system call that duplicates a process, returning 0 to the child.
- exec — replace this program with another — the
execvefamily, which overwrites the address space and keeps the PID. - wait — pause until the child is finished —
waitpid, which reaps the child and returns its termination status. - Prompt — the text asking you for a command — a shell-generated string from
PS1, printed to the terminal before each read.
19.5 The shells themselves#
PLAIN19.5.1 in simple words#
- There have been many shells. A handful matter.
- The first UNIX shell was written by Ken Thompson in 1971. It could run programs and redirect their input and output. It could not do much else.
- Stephen Bourne wrote a much better one at Bell Labs, and it shipped as the default in Version 7 UNIX. That is the Bourne shell, the program
sh. - It added variables, loops, conditions and functions. It made shell scripting a real thing. Everything since is measured against it.
- Bill Joy wrote the C shell at Berkeley, with a syntax that looked more like the C language, and with job control and command history.
- David Korn at Bell Labs wrote the Korn shell, taking the good ideas from the C shell and putting them into a Bourne-compatible shell.
- Brian Fox wrote bash for the GNU project, free of licence restrictions, and it became the shell of Linux.
- Paul Falstad wrote zsh, which is Bourne-compatible but with far better completion and matching. Apple made it the macOS default in 2019.
- fish broke compatibility on purpose, to be friendly out of the box.
- PowerShell from Microsoft is not in this family at all. It moves objects between commands instead of text.
PLAIN19.5.2 a picture in your head#
- Think of English and its descendants over centuries.
- Old English is the Thompson shell: recognizable, but you cannot read a newspaper with it.
- Middle English is the Bourne shell: the grammar settles, and texts written in it are still readable today with effort.
- Then two dialects grow in different towns. One is the Korn shell, which keeps the old grammar and adds new words. The other is the C shell, which changes the grammar and confuses travellers.
- bash is the modern standard dialect, taught in schools, understood everywhere, slightly dull.
- zsh is the same language spoken by people who care about pronunciation, with a large vocabulary of convenience.
- fish is a constructed language: cleaner and easier, but nobody else’s documents work in it.
- PowerShell is not a dialect of English. It is a different language family entirely, from a different continent.
Where this comparison breaks: languages drift by accident, but shells were designed on purpose, by named people, with written justifications. And unlike English, there is a formal written standard for shell grammar: POSIX. A script written to that standard genuinely runs on all of the Bourne-family shells, which is not something you can say about human dialects.
PLAIN19.5.3 a worked example#
- The same task, count files ending in
.md, in four shells.
# sh, bash, zsh, ksh: the Bourne family
n=$(ls *.md | wc -l)
echo "there are $n files"
# csh and tcsh: different assignment syntax entirely
set n = `ls *.md | wc -l`
echo "there are $n files"
# fish: no dollar on assignment, no equals sign
set n (ls *.md | wc -l)
echo "there are $n files"
# PowerShell: no text at all, real objects
$n = (Get-ChildItem *.md).Count
"there are $n files"
- Notice that the Bourne family line is identical across four shells. That is the value of POSIX compatibility.
- Notice that the C shell needs spaces around
=and usesset. Scripts do not port between the families. - Notice that PowerShell never counted lines of text.
Get-ChildItemreturned a list of file objects, and.Countasked the list how long it was. - That last point is the whole design difference. In UNIX shells, the thing passing between commands is bytes. In PowerShell it is typed objects with properties.
PLAIN19.5.4 what is really happening inside#
- Why did so many shells appear? Because the shell is small, personal, and used constantly. Small annoyances are worth fixing.
- The C shell’s improvements were real: job control, history with
!!, aliases, directory stacks. - Its scripting was genuinely bad. A widely circulated essay by Tom Christiansen, “Csh Programming Considered Harmful”, listed the reasons, and the argument was largely won.
- The Korn shell showed you could have both: Bourne syntax plus the interactive features. It became the commercial UNIX default.
- bash exists for a legal reason as much as a technical one. GNU needed a shell it could ship freely, without AT&T code.
- zsh went further on interactive quality: completion that understands the command you are typing, spelling correction, shared history, better globbing.
- Apple’s move to zsh in 2019 was also partly legal. bash version 4 changed to the GPL version 3 licence, which Apple will not ship, so macOS was frozen on bash 3.2 from 2007.
- fish decided that compatibility was the thing holding shells back. It has syntax highlighting and autosuggestions with no configuration at all.
- PowerShell came from a different problem. Windows configuration is not text files, so parsing text was useless. Passing objects was the natural answer on that system.
TECHNICAL19.5.5 the engineer’s version#
| Shell | Year | Author | Note |
|---|---|---|---|
| sh (V6) | 1971 | Ken Thompson | first UNIX shell |
| sh | 1979 | Stephen Bourne | shipped in V7 UNIX |
| csh | 1978 | Bill Joy | job control, history |
| ksh | 1983 | David Korn | Bourne plus csh ideas |
| bash | 1989 | Brian Fox | GNU, Linux default |
| zsh | 1990 | Paul Falstad | macOS default 2019 |
| fish | 2005 | A. Liljencrantz | not POSIX by design |
| PwrShell | 2006 | Jeffrey Snover | object pipeline |
- Precise dates worth knowing. Bash 1.0 was released on 8 June 1989 by Brian Fox at the Free Software Foundation. The name is Richard Stallman’s pun: Bourne-again shell.
- zsh 1.0 was released in 1990 by Paul Falstad, then a sophomore at Princeton University. The name comes from the login ID of a Princeton teaching assistant, Zhong Shao.
- fish 1.0 was released on 13 February 2005 by Axel Liljencrantz. The name is “friendly interactive shell”.
- PowerShell’s design was published in the Monad Manifesto by Jeffrey Snover in August 2002. It was demonstrated in October 2003, renamed PowerShell on 25 April 2006, and version 1.0 shipped on 14 November 2006.
- PowerShell Core 6.0, released January 2018, made it cross-platform and open source. It runs on Linux and macOS today.
- macOS 10.15 Catalina, released 7 October 2019, made zsh the default login shell for new accounts. Existing accounts kept bash. Apple ships
/bin/bashas version 3.2.57 for licence reasons and it is not updated. - The POSIX shell is specified in IEEE Std 1003.1, the Shell and Utilities volume.
shon Debian and Ubuntu isdash, not bash, which is a common source of “works on my machine” script failures. - Verified in this sandbox:
$ ls -l /bin/sh
lrwxrwxrwx 1 root root 4 Mar 31 2024 /bin/sh -> dash
$ bash --version | head -1
GNU bash, version 5.2.21(1)-release (x86_64-pc-linux-gnu)
- Practical advice, and this is opinion held by most working engineers rather than a rule: write scripts for
shorbash, use whatever you like interactively. Never write scripts incsh. Never assume#!/bin/shgives you bash. - Where experts disagree: some argue that fish and zsh’s improvements should be adopted in scripts too, and that POSIX compatibility is a museum concern. Others point out that servers, containers and rescue images often contain only
sh, so portable scripts still pay. Both are right in their own context.
WORDS19.5.6 remember these#
- Bourne shell — the original serious shell —
sh, from Version 7 UNIX 1979, ancestor of the POSIX shell grammar. - POSIX shell — the written standard for shell syntax — IEEE Std 1003.1 Shell and Utilities volume.
- bash — the common Linux shell — Bourne-again shell, GNU project, first released June 1989.
- zsh — the macOS default since 2019 — Z shell, Bourne-compatible with advanced completion and globbing.
- dash — a small fast
sh— Debian Almquist shell, POSIX-only,/bin/shon Debian and Ubuntu. - Object pipeline — passing structured data instead of text — PowerShell’s model, where commands emit and consume .NET objects.
19.6 stdin, stdout and stderr#
PLAIN19.6.1 in simple words#
- Every program starts life with three channels already open.
- Channel 0 is standard input. It is where the program reads from. By default, your keyboard.
- Channel 1 is standard output. It is where results go. By default, your screen.
- Channel 2 is standard error. It is where complaints go. Also your screen, by default.
- Output and errors are separate on purpose. This is one of the best design decisions in UNIX.
- If they were mixed, saving a program’s results to a file would also save its error messages into the same file, ruining the data.
- Because they are separate, you can capture the results and still see the errors on your screen, live.
- The shell lets you point any of these channels somewhere else before the program starts.
- That is called redirection, and the program never knows it happened.
PLAIN19.6.2 a picture in your head#
- Think of a factory machine with three pipes attached.
- One pipe brings raw material in at the top.
- One pipe sends finished product out of the front.
- One pipe sends scrap and warning notes out of the side.
- The machine does not know or care where the pipes go. It just pushes things into them.
- Before switching the machine on, you can move any pipe. Put the product pipe into a barrel. Leave the scrap pipe pointing at the floor so you notice it.
- Or connect the product pipe of one machine to the input pipe of the next machine. Now you have a production line.
- Nothing inside either machine changed. Only the plumbing.
Where this comparison breaks: real pipes have no memory, but these have a small buffer, so a fast machine can run ahead of a slow one for a while. And the scrap pipe is not really for scrap. Progress messages, prompts and warnings all come out of it, including from programs that are working perfectly.
PLAIN19.6.3 a worked example#
- Here is a real run. The directory has
a.txtand does not havenope.txt. - First, no redirection at all. Both streams reach the screen and get mixed:
$ ls a.txt nope.txt
ls: cannot access 'nope.txt': No such file or directory
a.txt
- Now send only standard output to a file:
$ ls a.txt nope.txt > out.txt
ls: cannot access 'nope.txt': No such file or directory
$ cat out.txt
a.txt
- The error still appeared live on the screen. The file has only the good result. That is exactly what you want.
- Now send only standard error to a file:
$ ls a.txt nope.txt 2> err.txt
a.txt
$ cat err.txt
ls: cannot access 'nope.txt': No such file or directory
- Now send both to the same file, correctly:
$ ls a.txt nope.txt > both.txt 2>&1
$ cat both.txt
ls: cannot access 'nope.txt': No such file or directory
a.txt
- Now the same two pieces in the wrong order. Watch what happens:
$ ls a.txt nope.txt 2>&1 > wrong.txt
ls: cannot access 'nope.txt': No such file or directory
$ cat wrong.txt
a.txt
- The error went to the screen, not the file. The redirection did not work.
- Why:
2>&1means “make channel 2 point wherever channel 1 points right now”. At that moment channel 1 still points at the screen. - Only afterwards did
> wrong.txtmove channel 1 to the file. Channel 2 was already aimed at the screen and stayed there. - The rule to remember: redirections are applied left to right, and
2>&1copies the current destination, not a promise to follow. - So
> file 2>&1is right and2>&1 > fileis almost always a mistake.
PLAIN19.6.4 what is really happening inside#
- The three channels are numbers, not names. The number is a file descriptor: an index into a table the kernel keeps for your process.
- Entry 0, 1 and 2 are filled in before your program starts. They are not special to the kernel. They are special only by agreement.
- When you run a program from a terminal, all three entries point at the same pseudo-terminal device. That is why output and errors both land on screen.
- When you write
> out.txt, the shell does this between fork and exec: - It opens
out.txtfor writing, creating or emptying it. The kernel gives back the lowest free number, say 3. - It calls
dup2(3, 1). That copies entry 3 over entry 1. Entry 1 now points at the file. - It closes 3, which is no longer needed. Then it calls exec.
- The new program starts with entry 1 already pointing at the file. It writes to 1 as usual, with no idea anything is different.
2>&1is justdup2(1, 2). Copy whatever entry 1 currently holds into entry 2.- That is the entire mechanism. There is no magic and no cooperation from the program.
- You can see the table on Linux. Every process has one under
/proc:
$ ls -l /proc/self/fd
lr-x------ 0 -> /dev/null
l-wx------ 1 -> pipe:[159625]
l-wx------ 2 -> /dev/null
lr-x------ 3 -> /proc/10189/fd
- In that real capture, the process was reading nothing, writing into a pipe, and throwing errors away.
TECHNICAL19.6.5 the engineer’s version#
| Syntax | Meaning | Underlying call |
|---|---|---|
> f |
stdout to f, truncate | open O_TRUNC, dup2 |
>> f |
stdout to f, append | open O_APPEND |
< f |
stdin from f | open O_RDONLY |
2> f |
stderr to f | dup2 to fd 2 |
2>&1 |
stderr to current stdout | dup2(1, 2) |
&> f |
both to f (bash, zsh) | two dup2 calls |
<<< str |
here-string as stdin | temp file or pipe |
<< EOF |
here-document as stdin | temp file or pipe |
2>/dev/null |
discard errors | open the null dev |
>&- |
close the descriptor | close(1) |
&>and&>>are bash and zsh extensions. The portable POSIX spelling is> file 2>&1. Use the portable form in scripts with#!/bin/sh.|&in bash is shorthand for2>&1 |. It is also not POSIX.- Buffering is a libc behaviour, not a kernel one, and it surprises people. The C standard library uses line buffering for stdout when it is a terminal and full buffering, typically 4096 or 8192 bytes, when it is a pipe or file.
- stderr is unbuffered by default. That is why error messages can appear before the output that logically came first.
- Fix it when it matters with
stdbuf -o0 -e0 command, orunbufferfrom the expect package, or in Python withpython3 -u. /dev/nullis the discard device, major 1 minor 3 on Linux. Writes succeed and vanish, reads return end of file immediately./dev/stdout,/dev/stderrand/dev/fd/Nexist on Linux and macOS and let you name descriptors as paths. Useful with tools that only accept a filename.- Descriptors above 2 are yours to use.
exec 3< fileopens a private read channel that survives across commands untilexec 3<&-closes it. - Inspect a live process’s descriptors with
lsof -p PID, or on Linux read/proc/PID/fd. Reallsofoutput from this sandbox:
$ lsof -i -P -n | head -3
COMMAND PID USER FD TYPE NAME
claude 462 root 11u IPv4 TCP 192.0.2.2:39400->160.79.104.10:443
claude 462 root 13u IPv4 TCP 127.0.0.1:43279 (LISTEN)
- The honest version: “everything is a file” is a slogan, not a fact. Descriptors point to file descriptions, which may be regular files, pipes, sockets, devices, epoll instances, timers or signal queues. They all support
readandwritebut not equally: you cannot seek in a pipe, andwriteto a socket can partially succeed.
WORDS19.6.6 remember these#
- File descriptor — the number a program uses to name an open channel — a small non-negative integer indexing the process file descriptor table.
- stdin — where a program reads from — file descriptor 0,
FILE *stdinin C. - stdout — where results go — file descriptor 1, line buffered to a terminal, block buffered otherwise.
- stderr — where complaints go — file descriptor 2, unbuffered by default, kept separate so results stay clean.
- Redirection — pointing a channel somewhere else —
openplusdup2performed by the shell between fork and exec. /dev/null— the bin — the null device, discards writes, returns EOF on read.- Here-document — inline text fed to a program as input — the
<< WORDconstruct, terminated by WORD on its own line.
19.7 Pipes#
PLAIN19.7.1 in simple words#
- A pipe connects the output of one program straight to the input of the next.
- You write it with the vertical bar:
a | b. - Nothing is saved on disk. The data goes from one program to the other through memory.
- Both programs run at the same time. The second does not wait for the first to finish.
- This means you can process something enormous without ever holding all of it at once.
- It also means each program can be small, do one job, and know nothing about the others.
- That combination is generally considered the single best idea in UNIX.
- Doug McIlroy proposed it at Bell Labs, and it was added to UNIX in 1973.
- Before pipes, you saved to a temporary file and read it back. Everyone did it. Nobody enjoyed it.
PLAIN19.7.2 a picture in your head#
- Think of a bucket chain at a fire, before fire engines existed.
- Ten people stand in a line from the well to the fire.
- The first person does not fill a thousand buckets and then start passing them. They pass each bucket as soon as it is full.
- Water starts arriving at the fire within seconds, not hours.
- Nobody in the chain knows where the water comes from or where it goes. Each person knows only “take from the left, give to the right”.
- If a person in the middle is slow, the people behind them naturally wait. Nobody needs to coordinate this. The line just backs up.
- If the fire is put out, the person at the end walks away. The next person tries to hand over a bucket, finds nobody there, and stops too.
- That last case is important, and it has a name in UNIX. We come back to it.
Where this comparison breaks: real people can hold one bucket, while a pipe holds a fixed amount of data, 65536 bytes on Linux by default. And the writer does not “see” that the reader has gone. It gets told, forcefully, by a signal.
PLAIN19.7.3 a worked example#
- Here is a real log file, eight lines, from this sandbox. We will find which client caused the most server errors.
10.0.0.7 ... "GET /index.html HTTP/1.1" 200 5120
10.0.0.9 ... "GET /style.css HTTP/1.1" 200 812
10.0.0.7 ... "GET /missing HTTP/1.1" 404 152
10.0.0.12 ... "POST /api/login HTTP/1.1" 500 90
10.0.0.7 ... "GET /api/user HTTP/1.1" 500 90
10.0.0.9 ... "GET /index.html HTTP/1.1" 200 5120
10.0.0.12 ... "GET /api/user HTTP/1.1" 500 90
10.0.0.3 ... "GET /index.html HTTP/1.1" 200 5120
- Stage 1, keep only the server errors. Real output:
$ grep " 500 " access.log
10.0.0.12 ... "POST /api/login HTTP/1.1" 500 90
10.0.0.7 ... "GET /api/user HTTP/1.1" 500 90
10.0.0.12 ... "GET /api/user HTTP/1.1" 500 90
- Stage 2, keep only the first field, which is the client address:
$ grep " 500 " access.log | cut -d' ' -f1
10.0.0.12
10.0.0.7
10.0.0.12
- Stage 3, sort, so identical lines sit together:
$ ... | sort
10.0.0.12
10.0.0.12
10.0.0.7
- Stage 4, collapse the runs and count them:
$ ... | uniq -c
2 10.0.0.12
1 10.0.0.7
- Stage 5, sort by that count, largest first:
$ ... | sort -rn
2 10.0.0.12
1 10.0.0.7
- Stage 6, keep the top two:
$ grep " 500 " access.log | cut -d' ' -f1 | sort | uniq -c \
| sort -rn | head -2
2 10.0.0.12
1 10.0.0.7
- Six programs. None of them knows about logs. Each does one small thing.
uniq -conly collapses adjacent duplicates, which is exactly whysortmust come before it. This trips up everyone once.- The same pipeline works unchanged on a log of eight lines or eight hundred million, and uses the same tiny amount of memory either way.
PLAIN19.7.4 what is really happening inside#
- When the shell sees
a | b, it asks the kernel for a pipe. - The kernel creates a small buffer in memory and gives back two file descriptors: one you can read from, one you can write to.
- The shell forks twice, once for
aand once forb. - In the child that will run
a, it points descriptor 1 at the write end. - In the child that will run
b, it points descriptor 0 at the read end. - Both children close the ends they do not need. This matters enormously.
- Then both children exec. Both are now running at the same time.
awrites as usual to descriptor 1, thinking it is writing to the screen.breads as usual from descriptor 0, thinking it is reading a keyboard.- When the buffer is full,
a’s next write simply blocks. The kernel puts it to sleep untilbtakes some data out. - When the buffer is empty,
b’s read blocks untilaputs something in. - This automatic waiting is called back pressure, and it is why a pipeline cannot run out of memory no matter how much data flows through.
- Here is real proof from this sandbox that a pipe streams rather than waits:
$ time (seq 1 50000000 | head -1)
1
real 0m0.002s
- Producing fifty million numbers would take seconds. It finished in two thousandths of a second, because
headstopped after one line andseqwas killed. - Now the ending case. When
bexits, the read end is closed. - The next time
awrites, the kernel sees there is no reader left and sendsathe signal SIGPIPE. - The default action of SIGPIPE is to kill the process silently. That is by design: it stops a producer from filling the world with data nobody wants.
- This is why
yes | head -3ends instead of running forever.
TECHNICAL19.7.5 the engineer’s version#
- The system call is
pipe(int fd[2]), givingfd[0]for reading andfd[1]for writing.pipe2adds flags such asO_CLOEXEC. - On Linux the default pipe capacity is 65536 bytes, which is sixteen pages of 4096 bytes. Verified here:
$ python3 -c 'import os, fcntl
> r, w = os.pipe()
> print(fcntl.fcntl(w, 1032))'
65536
$ cat /proc/sys/fs/pipe-max-size
1048576
PIPE_BUF, which is 4096 on Linux and 512 minimum by POSIX, is a different number. It is the largest write guaranteed to be atomic when several writers share one pipe.- Capacity can be changed per pipe with
fcntl(fd, F_SETPIPE_SZ, n)up to/proc/sys/fs/pipe-max-size. On macOS the pipe buffer starts at 16384 bytes and the kernel may grow it; there is noF_SETPIPE_SZ. - Closing unused ends is mandatory. If the shell left the write end open in the reader, the reader would never see end of file and the pipeline would hang forever. This is the classic pipe bug.
- SIGPIPE is signal 13. The shell reports death by signal N as exit status 128 plus N, so a SIGPIPE death shows as 141.
$ yes | head -3 >/dev/null; echo "${PIPESTATUS[@]}"
141 0
$ seq 1 100000000 | head -2 >/dev/null; echo "${PIPESTATUS[@]}"
141 0
PIPESTATUSis a bash array holding every stage’s status. zsh calls itpipestatus. The plain$?gives only the last stage, which is why a failing first stage is invisible by default.set -o pipefailmakes the pipeline’s status the rightmost non-zero status. Verified:
$ set -o pipefail; yes | head -3 >/dev/null; echo $?
141
- Servers and daemons usually call
signal(SIGPIPE, SIG_IGN)and check forEPIPEfromwriteinstead, because a killed web server is worse than a dropped client. - Doug McIlroy proposed pipes at Bell Labs; Ken Thompson implemented them in Version 3 UNIX in 1973. The vertical bar notation and the
teecommand date from that period. - Named pipes, or FIFOs, are the same buffer with a filesystem name, created with
mkfifo. Opening one for writing blocks until a reader appears, which is a real behaviour, demonstrated in this sandbox: a writer sat blocked for a full second untilcatopened the other end. - Process substitution,
<(command), is bash and zsh only. It creates a FIFO or a/dev/fdentry and substitutes its path, letting you feed a command’s output to a tool that demands a filename.
WORDS19.7.6 remember these#
- Pipe — a direct connection from one program’s output to another’s input — an anonymous unidirectional kernel buffer with two file descriptors.
- Back pressure — a fast producer waiting for a slow consumer — blocking of
writewhen the pipe buffer is full. - SIGPIPE — the “nobody is listening” signal — signal 13, sent to a writer whose read end has closed, default action terminate.
- PIPESTATUS — the list of results from every stage — a bash array of exit statuses for the last foreground pipeline.
- FIFO — a pipe with a name on disk — a named pipe created by
mkfifo, typepinls -l. tee— split the flow so you can see it and save it — a filter that copies stdin to stdout and to named files.
19.8 Exit codes#
PLAIN19.8.1 in simple words#
- Every program, when it finishes, hands back a single number.
- That number is called the exit code or exit status.
- It has nothing to do with what the program printed. It is a separate, private answer to one question: did this work?
- Zero means success. Anything else means failure.
- That feels backwards. It is not. There is one way to succeed and many ways to fail, so zero is the single success and the rest label the failure.
- The number is small: 0 to 255 only. It cannot carry a message, only a code.
- You see the last one with
echo $?. - The shell uses it to decide whether to run the next command when you chain commands together.
- Automated build systems test only this number. Not your output. Not your colours. The number.
PLAIN19.8.2 a picture in your head#
- Think of a factory quality inspector at the end of a line.
- The inspector does not describe the product. There is a label for that.
- The inspector stamps one thing on the crate: a code.
- Code 0 means “passed”. Any other code means “rejected”, and different codes say which test it failed.
- The next station on the line reads only the stamp. It never opens the crate.
- If the stamp is 0, the crate moves on. If not, the line stops and an alarm sounds.
- A crate can contain a beautiful description of a disaster and still be stamped 0, if the inspector was careless. Then the disaster moves down the line unnoticed.
Where this comparison breaks: the stamp is applied by the program itself, and a badly written program can stamp 0 on anything. This happens constantly and is the single most common cause of a build system reporting success on a broken build. The check is only as honest as the program doing the checking.
PLAIN19.8.3 a worked example#
- Real runs from this sandbox. Each shows the command, the message, and the code.
$ ls /nonexistent
ls: cannot access '/nonexistent': No such file or directory
$ echo $?
2
$ true; echo $? -> 0
$ false; echo $? -> 1
$ grep -q zzz /etc/hostname; echo $?
1
$ bash -c 'exit 42'; echo $?
42
$ /etc/hostname; echo $? # a file that is not executable
126
$ nosuchcommand123; echo $?
127
$ bash -c 'kill -INT $$'; echo $?
130
$ bash -c 'kill -TERM $$'; echo $?
143
- Read the meanings.
grepreturning 1 is not an error. It means “I searched correctly and found nothing”. - That distinction is why
grep -q pattern file && do_somethingworks cleanly. It is also whygrepreturning 2 means a real error, like an unreadable file. - 126 means “found it, but could not run it”. Usually a missing execute permission.
- 127 means “could not find it at all”. If you see 127 in a build log, look at
PATHfirst. - 130 is 128 plus 2, and signal 2 is SIGINT. Somebody pressed Ctrl-C.
- 143 is 128 plus 15, and signal 15 is SIGTERM. Something asked it to stop, usually a timeout or an orchestrator.
- 137 is 128 plus 9, SIGKILL. In a container that almost always means the memory limit was hit and the kernel killed it.
- Now chaining. Real output:
$ true && echo "ran because true"
ran because true
$ false && echo "not printed"
$ false || echo "ran because false"
ran because false
$ true || echo "not printed"
$ false ; echo "semicolon always runs"
semicolon always runs
A && Bruns B only if A succeeded.A || Bruns B only if A failed.A ; Bruns B either way. This is the one that hides failures.
PLAIN19.8.4 what is really happening inside#
- When a program calls
exit(3), the number 3 goes to the kernel. - The process dies, but a small record stays behind holding that number.
- The parent calls
wait, collects the record, and the record is freed. A process whose record has not yet been collected is a zombie. - The kernel packs more than the exit code into that record. It also stores whether the process was killed by a signal, and which one.
- The shell unpacks it. If the process exited normally,
$?is the code. If it was killed by signal N, the shell reports 128 plus N. - That 128 rule is a shell convention, not a kernel fact. The kernel keeps the two cases genuinely separate.
- Why the limit of 255: the exit status field is eight bits wide in the traditional
waitencoding. - So
exit 256becomes 0 andexit -1becomes 255. Both are silent traps. - In a pipeline,
$?reports only the last command. The others are lost unless you ask for them. set -emakes the shell exit as soon as any command returns non-zero. It is how you stop a script from carrying on after a disaster.
TECHNICAL19.8.5 the engineer’s version#
| Code | Meaning | Typical source |
|---|---|---|
| 0 | success | everything |
| 1 | general failure | most tools |
| 2 | misuse or real error | grep, ls, bash |
| 64-78 | sysexits.h categories | BSD-style tools |
| 126 | found but not executable | shell |
| 127 | command not found | shell |
| 128+N | killed by signal N | shell reporting |
| 130 | Ctrl-C, SIGINT | interactive use |
| 137 | SIGKILL, often out of memory | containers |
| 143 | SIGTERM | timeouts, systemd |
| 255 | out of range or -1 | buggy exit calls |
- The kernel encoding is defined by POSIX macros:
WIFEXITED,WEXITSTATUS,WIFSIGNALED,WTERMSIG,WCOREDUMP,WIFSTOPPED. WEXITSTATUSis only valid whenWIFEXITEDis true. Reading it otherwise returns rubbish, and this is a real bug in real code.- The 64 to 78 range comes from
sysexits.h, introduced in 4.3BSD.EX_USAGEis 64,EX_DATAERR65,EX_NOINPUT66,EX_UNAVAILABLE69,EX_SOFTWARE70,EX_CONFIG78. It is a convention, widely ignored, but useful if you follow it consistently within one project. grepdocuments its own contract in its manual page, and the exact wording from the real page in this sandbox is worth reading:
EXIT STATUS
Normally the exit status is 0 if a line is selected, 1 if
no lines were selected, and 2 if an error occurred.
diffuses 0 for identical, 1 for different, 2 for trouble.curlhas about 100 documented codes; 6 is “could not resolve host”, 7 “failed to connect”, 28 “operation timed out”, 56 “failure receiving data”. A real failure captured here returned 56.- Continuous integration works exactly like this. A GitHub Actions step fails when the process exits non-zero. Nothing else is inspected.
- Consequences that bite in practice. A step that ends with a pipeline reports only the last stage, so
run_tests | tee log.txtreports the status oftee, which basically always succeeds. - The fix is
set -o pipefail, or checkingPIPESTATUS, or restructuring so the important command is last. set -ehas genuine sharp edges. It does not trigger inside a condition, inside&&or||chains except the last element, or inside a command whose status is being tested. Experts disagree about whether it is safe.- Both sides of that argument: the “always use it” camp says most scripts are short and unhandled failure is worse than surprising exits. The “never use it” camp says the exceptions are too subtle to remember and explicit
if ! cmd; thenchecks are honest. The compromise most teams settle on isset -euo pipefailplus explicit handling around known-noisy commands.
WORDS19.8.6 remember these#
- Exit code — the single number a program leaves behind — the 8-bit exit status collected by
wait. $?— the shell variable holding the last one — expands to the exit status of the most recently completed foreground command.- Zombie — a finished process whose result nobody collected — a process in state
Z, holding only its exit record. &&— run the next one only if this worked — the AND list operator, short circuits on non-zero status.||— run the next one only if this failed — the OR list operator, short circuits on zero status.set -e— stop the script at the first failure —errexit, which exits on any untested non-zero status.- sysexits — an agreed table of failure categories — the 64 to 78 range from the BSD
sysexits.hheader.
19.9 Environment variables#
PLAIN19.9.1 in simple words#
- Every running program carries a small list of name and value pairs.
- That list is the environment. It is just text, copied into the program when it starts.
HOMEsays where your home directory is.PATHsays where to look for commands.LANGsays which language and character set to use.- When a program starts another program, the child gets a copy of the list.
- It is a copy, not a link. The child can change its own copy and the parent never notices.
- There is no way for a child to change its parent’s environment. This is a hard rule, and it explains many confusing situations.
- Inside the shell you can also have plain variables that are not in the environment. They exist only in that shell.
- To move one into the environment you
exportit. That is the whole difference. - Where these get set is a separate mess, and it is why your
PATHcan be right in one window and wrong in another.
PLAIN19.9.2 a picture in your head#
- Think of a person leaving home for work with a small notebook in their pocket.
- The notebook lists their address, their language, and the list of shops they are allowed to visit.
- When they hire an assistant, they photocopy the notebook and hand the copy over.
- The assistant can scribble in their copy. The original is untouched.
- When the assistant hires their own assistant, another photocopy is made, including any scribbles.
- So changes flow downwards only, and only to people hired after the change.
- If you edit the master notebook at home, everybody already out working still has the old copy.
- That is exactly why changing a setting file does not affect terminal windows you already have open.
Where this comparison breaks: a notebook can hold anything, but the environment holds only text, with a size limit, and no structure. There are no lists, no numbers and no nesting. Everything is a string, and any structure you think you see, like the colons in PATH, is a convention agreed by the programs reading it.
PLAIN19.9.3 a worked example#
- Real run showing the difference between a shell variable and an exported one:
$ MYVAR=hello
$ echo "in this shell: $MYVAR"
in this shell: hello
$ bash -c 'echo child sees: [$MYVAR]'
child sees: []
$ export MYVAR
$ bash -c 'echo child sees: [$MYVAR]'
child sees: [hello]
- Before
export, the child saw nothing. Afterexport, it saw the value. Nothing else changed. - Now
PATH. Here is the real one from this sandbox, shortened:
$ echo $PATH
/home/claude/.npm-global/bin:/root/.local/bin:/root/.cargo/bin:
/usr/local/go/bin:/opt/node22/bin:/usr/local/sbin:/usr/local/bin:
/usr/sbin:/usr/bin:/sbin:/bin
- It is one string. Directories separated by colons. Searched strictly left to right, first match wins.
- Find out which one wins:
$ which ls
/usr/bin/ls
$ type ls
ls is /usr/bin/ls
$ command -v grep
/usr/bin/grep
- Order matters enormously. If
/usr/local/bincomes before/usr/binand both containpython3, you get the one in/usr/local/bin. - This is the whole explanation for “it works in my terminal but not in the cron job”. Different
PATH, different program.
PLAIN19.9.4 what is really happening inside#
- The environment is passed to
execveas an array of strings, each shapedNAME=value, ending with a null pointer. - The kernel copies that array onto the new program’s stack. The C library makes it available as the global
environ. - Nothing validates it. Any string with an
=is accepted. - When you type a command with no slash in it, the shell splits
PATHon colons and tries each directory in turn. - For each, it builds a full path and tries to execute it. First success wins. If none work, you get 127.
- An empty entry in
PATH, such as a leading colon or two colons together, means the current directory. That is a security hazard and should be avoided. - The current directory is not in
PATHby default on any sane system. That is deliberate. - If it were, someone could leave a file called
lsin a shared directory, and you would run it by accident. - So to run a program in the directory you are standing in, you must say
./program. The slash tells the shell not to search at all. - Searching
PATHfor every command would be slow, so shells remember. This is called hashing.
$ hash -r # forget everything
$ ls > /dev/null
$ grep --version > /dev/null
$ hash
hits command
1 /usr/bin/grep
1 /usr/bin/ls
- The cache is why installing a new program sometimes does not take effect until you run
hash -ror open a new shell. bash and zsh both do this.
TECHNICAL19.9.5 the engineer’s version#
| Variable | Holds | Typical value |
|---|---|---|
| PATH | command search list | /usr/local/bin:/usr/bin |
| HOME | your home directory | /Users/name, /home/name |
| PWD | current directory | maintained by the shell |
| OLDPWD | previous directory | used by cd - |
| SHELL | your login shell | /bin/zsh |
| USER | your login name | from the passwd entry |
| TERM | terminal capability name | xterm-256color |
| LANG | locale for everything | en_US.UTF-8 |
| EDITOR | preferred text editor | vim, nano, code -w |
| TMPDIR | scratch directory | /tmp, or per-user |
| LD_LIBRARY_PATH | extra library dirs | avoid unless forced |
PWDis maintained by the shell, not the kernel. The kernel truth isgetcwd. They can disagree when symbolic links are involved, which is whatcd -Pandpwd -Pexist to resolve.SHELLrecords your login shell from the password database. It does not tell you which shell is currently running. To find that, check$0, or$BASH_VERSIONand$ZSH_VERSION.LANGand theLC_*family change program behaviour in ways people do not expect. Sort order is the classic case. Real measurement from this sandbox:
$ printf 'b\nA\na\nB\n' > letters.txt
$ LC_ALL=C sort letters.txt -> A B a b
$ LC_ALL=en_US.UTF-8 sort letters.txt -> a A b B
- That is the same data, the same command, and a different answer. Scripts that must be reproducible set
LC_ALL=Cexplicitly. - Startup file order is the part everyone gets wrong. It depends on two independent questions: is this a login shell, and is it interactive.
| Shell | Login shell reads | Interactive non-login |
|---|---|---|
| bash | /etc/profile, then the | ~/.bashrc only |
| first of ~/.bash_profile | ||
| ~/.bash_login, ~/.profile | ||
| zsh | /etc/zprofile, ~/.zprofile | /etc/zshrc, |
| then /etc/zshrc, ~/.zshrc, | ~/.zshrc | |
| /etc/zlogin, ~/.zlogin | ||
| sh | /etc/profile, ~/.profile | ENV file if set |
- bash reads only one of
~/.bash_profile,~/.bash_login,~/.profile, in that order, and stops at the first that exists. If you have both a.bash_profileand a.profile, the.profileis silently ignored. - zsh always reads
~/.zshrcfor interactive shells, login or not. That is why zsh setup is simpler and why macOS advice usually says “put it in.zshrc”. - This explains the classic complaint. An SSH login is a login shell. A new tab in Terminal.app on macOS is also a login shell, by Apple’s choice, which differs from most Linux terminal emulators where a new tab is an interactive non-login shell.
- So a
PATHline added to~/.bashrcon a Linux server works in new tabs but not overssh host command, which is non-interactive and reads neither. - Real check of which files exist, from this sandbox:
$ for f in /etc/profile ~/.bash_profile ~/.bashrc \
> ~/.profile ~/.zshrc
> do
> [ -e $f ] && s=EXISTS || s=missing
> printf '%-20s %s\n' "$f" "$s"
> done
/etc/profile EXISTS
/root/.bash_profile missing
/root/.bashrc EXISTS
/root/.profile EXISTS
/root/.zshrc EXISTS
- GUI applications on macOS do not read your shell startup files at all. They inherit the environment from
launchd. This is why a program launched from the Dock cannot find a tool that works fine in Terminal. - Environment size is limited. On Linux the combined size of arguments and environment is capped by
MAX_ARG_STRLENat 128 KiB per string, and the total by the stack limit, typically a quarter ofulimit -s. Exceeding it givesE2BIG, seen as “Argument list too long”. - Never put secrets in environment variables on a shared machine. On Linux
/proc/PID/environis readable by the owner, and process listings can leak command-line arguments to everyone.
WORDS19.9.6 remember these#
- Environment — the list of settings a program inherits — a null-terminated array of
NAME=valuestrings passed throughexecve. - Export — move a shell variable into the environment — mark it for inclusion in the child’s environment on the next
exec. - PATH — where the shell looks for commands — a colon-separated directory list, searched left to right, first match wins.
- Hashing — remembering where a command was found — the shell’s command location cache, cleared with
hash -r. - Login shell — the shell started when you sign in — a shell whose argv[0] begins with
-, reading the profile files. - Locale — language and formatting rules — the
LANGandLC_*variables controlling collation, case, dates and messages.
19.10 Configuration on each system#
PLAIN19.10.1 in simple words#
- Every operating system needs somewhere to keep settings.
- Windows keeps almost all of them in one giant database called the registry.
- macOS and Linux keep them in ordinary files scattered around the disk.
- The Windows way is one place, one format, one tool. It is fast to read and hard to inspect by eye.
- The UNIX way is many places, many formats, and any text editor works.
- On Linux, system-wide settings live in
/etc, and your personal settings live in files in your home directory whose names begin with a dot. - A leading dot means “hidden” by convention.
lswill not show it unless you ask with-a. - These are called dotfiles, and people keep them in version control and copy them between machines.
- macOS does both. It has UNIX dotfiles and
/etcunderneath, and Apple’s own settings system on top, using files called property lists. - That is why a Mac developer edits
~/.zshrcfor the shell and never thinks about a registry, even though macOS has a settings database of its own.
PLAIN19.10.2 a picture in your head#
- Imagine two libraries.
- The first library has one enormous card catalogue in the entrance hall. Every fact about every book is in a drawer somewhere in it.
- Finding anything is fast, if you know the drawer. Browsing is impossible. The drawers are labelled in code.
- If the catalogue is damaged, the whole library stops working, because no book can be located.
- The second library writes each subject’s notes on a sheet of paper and pins it to the shelf that subject lives on.
- Finding a note means walking to the right shelf. Slower, but you can read it with your eyes, and you can photocopy one shelf’s notes and carry them elsewhere.
- If one sheet is torn, only that shelf is affected.
- Library one is the Windows registry. Library two is
/etcand dotfiles.
Where this comparison breaks: the registry is far better engineered than a card catalogue. It is transactional, it supports permissions per entry, and it can be changed by policy across ten thousand machines at once. The scattered-files approach has no transactions at all: a half-written config file is simply a broken config file.
PLAIN19.10.3 a worked example#
- Say you want to change the shell prompt colour and the editor a tool opens.
- On Linux or macOS, you edit one text file:
# in ~/.zshrc or ~/.bashrc
export EDITOR=vim
export PS1='%n@%m %1~ %# '
- You then run
source ~/.zshrcto apply it to the current shell, or open a new window. - You can email that file to a colleague. It is 20 lines of text.
- On macOS, an application setting is different. To make Finder show hidden files:
defaults write com.apple.finder AppleShowAllFiles -bool true
killall Finder
- That wrote into a property list file at
~/Library/Preferences/com.apple.finder.plist. - On Windows, the same class of change is a registry edit. In
regedityou would navigate to a path such as:
HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion
\Explorer\Advanced
- Then set the value
Hiddento the number 1. - Or from PowerShell, without the graphical tool:
Set-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\
CurrentVersion\Explorer\Advanced' -Name Hidden -Value 1
- Three systems, one idea, three completely different mechanisms.
PLAIN19.10.4 what is really happening inside#
- The registry is a tree, like a filesystem, but stored in a few binary files.
- The top-level branches are called hives. The two you meet are:
HKEY_LOCAL_MACHINE, shortened to HKLM. Settings for the whole computer. Changing it needs administrator rights.HKEY_CURRENT_USER, shortened to HKCU. Settings for the person signed in right now. You can change your own.- Inside a hive are keys, which are like folders, and values, which are like files.
- A value has a name, a type and data. Types include string, expandable string, 32-bit number, 64-bit number, binary blob and multi-string.
regedit.exeis the graphical browser. It is a plain tree view with the values on the right.- Why it exists: before it, Windows kept settings in hundreds of
.initext files. There was no locking, no types, no permissions and no network management. - Why it became a problem: it is a single shared namespace with no ownership rules. Any program can write anywhere it has rights to.
- Uninstalling a program often leaves its keys behind. Over years the registry accumulates dead entries, and a whole industry of “registry cleaner” products grew up around this, most of them useless or harmful.
- On macOS, the equivalent store is thousands of separate property list files, most in
~/Library/Preferencesand/Library/Preferences. - Each is named after a reverse domain identifier, such as
com.apple.finder.plist, so applications cannot collide by accident. - They are usually stored in a compact binary format, not readable text. That is a storage choice, not a philosophy choice.
- On Linux, there is no central store at all. Each program picks its own file and its own format, guided loosely by an agreed set of directories.
TECHNICAL19.10.5 the engineer’s version#
| System | Store | Tool to read |
|---|---|---|
| Windows | registry hives | regedit, reg.exe |
| macOS | plist files | defaults, plutil |
| Linux | /etc and dotfiles | any text editor |
| Modern | XDG directories | any text editor |
- Registry history. It first appeared in Windows 3.1, released 1992, as a single file
REG.DATlimited to 64 KB, holding only OLE registration and file associations. - Windows NT 3.1 in 1993 and Windows 95 in 1995 expanded it into the full hive structure with
HKEY_LOCAL_MACHINEandHKEY_CURRENT_USER, and moved registry access into kernel mode. - On modern Windows the hive files live in
C:\Windows\System32\configfor machine hives, andNTUSER.DATin each user profile for HKCU. They are not editable as text. - The five root keys are HKLM, HKCU, HKCR (classes, a merged view), HKU (all loaded user hives) and HKCC (current hardware profile). Only HKLM and HKU are truly stored; the others are views.
- Value types are named
REG_SZ,REG_EXPAND_SZ,REG_DWORD,REG_QWORD,REG_BINARY,REG_MULTI_SZ. - Windows also has Group Policy, which writes into the registry under
Software\Policiesand is enforced centrally. That central-management ability is the strongest argument for the registry’s existence. - macOS plists conform to Apple’s property list format. The XML variant has a DOCTYPE from Apple; the binary variant starts with the eight bytes
bplist00. defaults read com.apple.finderprints a domain.defaults writesets a value.plutil -convert xml1 file.plistmakes a binary file readable, andplutil -lintvalidates one.- There is a caching daemon,
cfprefsd. Editing a plist file by hand while an application is running can be overwritten by the cache. Usedefaultsor quit the application first. This is a real and frequent trap. - Linux and modern macOS command-line tools increasingly follow the XDG Base Directory Specification, version 0.8, published 8 May 2021 by freedesktop.org.
| XDG variable | Default | For |
|---|---|---|
| XDG_CONFIG_HOME | $HOME/.config | settings |
| XDG_DATA_HOME | $HOME/.local/share | app data |
| XDG_STATE_HOME | $HOME/.local/state | logs, history |
| XDG_CACHE_HOME | $HOME/.cache | rebuildable data |
| XDG_CONFIG_DIRS | /etc/xdg | system settings |
| XDG_DATA_DIRS | /usr/local/share/ | system data |
- The point of XDG is that a home directory used to fill with dozens of top-level dotfiles. Now well-behaved tools use
~/.config/toolname/. - Adoption is partial.
gitsupports~/.config/git/config.sshstill insists on~/.ssh. bash still uses~/.bashrc. This is a convention with incomplete uptake, not a standard anyone enforces. - System configuration on Linux lives in
/etc, a hierarchy defined loosely by the Filesystem Hierarchy Standard, current version 3.0 from 2015. Examples:/etc/passwd,/etc/hosts,/etc/ssh/sshd_config,/etc/fstab,/etc/systemd/system. - Why a macOS or Linux developer never thinks about a registry: everything they touch daily, the shell, the editor, the compiler, git, ssh, docker, reads a text file at a documented path. Nothing they use has a central database, so the concept never comes up.
- The honest version: macOS does have a registry-like system, and its problems are the same as Windows’. Stale preference domains accumulate,
cfprefsdcaching causes lost edits, and there is no reliable uninstall. Developers avoid it only because their tools are UNIX tools, not Mac applications.
WORDS19.10.6 remember these#
- Registry — Windows’ one big settings database — a hierarchical binary store of keys and typed values, held in hive files.
- Hive — a top-level branch of the registry — a discrete file-backed subtree such as HKLM or a user’s NTUSER.DAT.
- HKLM and HKCU — machine settings and my settings — HKEY_LOCAL_MACHINE requires elevation, HKEY_CURRENT_USER does not.
- plist — a macOS settings file — an Apple property list, XML or binary, keyed by reverse-domain identifier.
- Dotfile — a hidden settings file in your home directory — a file whose name starts with
., omitted bylsunless-ais given. - XDG base directories — the agreed places for config, data and cache — the freedesktop.org specification defining
XDG_CONFIG_HOMEand friends. /etc— where system settings live on UNIX — the machine-wide configuration hierarchy described by the Filesystem Hierarchy Standard.
19.11 The essential commands#
PLAIN19.11.1 in simple words#
- There are thousands of commands. About forty carry almost all daily work.
- They fall into groups, and learning them by group is far easier than learning them alphabetically.
- Navigation: where am I, what is here, move somewhere else.
- Files: create, copy, move, delete.
- Viewing: show me the contents, the top, the bottom, one page at a time.
- Searching: find files by name, find text inside files.
- Text processing: cut columns, replace text, sort, count.
- Permissions: who is allowed to do what.
- Processes: what is running, stop it.
- Disk: how much space, what is using it.
- Network: is it reachable, fetch it, log in to it.
- Archives: bundle a folder into one file, unpack it again.
- Help: what does this command do, where does it live, what did I type before.
PLAIN19.11.2 a picture in your head#
- Think of a workshop with a pegboard of hand tools.
- You do not learn a pegboard by reading every label. You learn it by doing three or four jobs.
- The saw, the hammer and the tape measure get used in every job. Those are
ls,cdandcat. - Some tools look alike but are for different materials.
grepsearches inside files;findsearches for files. Beginners reach for the wrong one constantly. - Some tools are power tools with a manual.
sedandawkare those. You will use ten percent of them forever, and that is fine. - Every tool has flags, which are like the settings on a drill. Two or three settings per tool are worth memorizing. The rest you look up.
Where this comparison breaks: hand tools are shaped so you cannot use them wrongly. Command flags are one letter long and unforgiving. rm -rf / and rm -rf ./ differ by one character and by everything else.
PLAIN19.11.3 a worked example#
- Navigation and files, run for real in this sandbox:
$ pwd
/tmp/shdemo
$ ls -l
-rw-r--r-- 1 root root 610 Aug 13 01:55 access.log
prw-r--r-- 1 root root 0 Aug 13 01:55 f
-rw-r--r-- 1 root root 0 Aug 13 01:56 note1.txt
- Read a long listing left to right. The first character is the type:
-for a regular file,dfor a directory,lfor a symbolic link,pfor a named pipe, which is what thatfis. - The next nine characters are permissions in three groups of three: owner, group, everyone else.
rread,wwrite,xexecute. - Then the link count, the owner, the group, the size in bytes, the modification time and the name.
- Permissions changing for real:
$ echo 'echo hi' > s.sh
$ ls -l s.sh
-rw-r--r-- 1 root root 8 Aug 13 01:56 s.sh
$ chmod +x s.sh
$ ls -l s.sh
-rwxr-xr-x 1 root root 8 Aug 13 01:56 s.sh
$ chmod 640 s.sh
$ stat -c '%a %A %n' s.sh
640 -rw-r----- s.sh
- The number form is three octal digits. Read 4 for read, 2 for write, 1 for execute, added together. So 6 is read plus write, 4 is read only, 0 is nothing. 640 means owner read and write, group read, others nothing.
- Archives for real:
$ tar -czf proj.tar.gz proj
$ tar -tzf proj.tar.gz
proj/
proj/src/
proj/src/main.py
proj/README.md
$ tar -xzf proj.tar.gz -C out
- Remember the letters:
ccreate,tlist,xextract,zgzip,fthe filename follows,vbe chatty. Always putflast, because the filename comes straight after it. - Networking for real, using the reader’s own recorded session on their home connection in India:
$ dig +short github.com
20.207.73.82
$ curl -v https://github.com
* Trying 20.207.73.82:443...
... times out after 15 seconds, no response at all
- That address is in a Microsoft-owned range, because GitHub is owned by Microsoft, acquired 2018. The name resolved fine. The connection did not complete. Those are two different failures and the commands separate them.
- A trace from the same session showed the path leaving the home router at
192.168.0.1, crossing private ISP addresses, reaching Microsoft’s network at hop 7, and then falling silent.
PLAIN19.11.4 what is really happening inside#
- Almost every one of these commands is a small program in
/usr/binor/bin. The shell finds it inPATHand runs it. - Most read from standard input when given no filename, which is why they all work in pipes without any special support.
- Most write results to standard output and complaints to standard error, so redirection works uniformly.
- Most return 0 on success and non-zero on failure, so
&&chains work. - These four properties are the entire reason the toolkit composes. Nothing else is shared between them.
cdis the exception. It is a shell builtin, because changing directory must affect the shell itself.lssorts alphabetically because it chooses to, not because directories are sorted. On disk, directory entries are in whatever order the filesystem likes.rmdoes not erase data. It removes a name. If another name points at the same data, the data survives. Only when the last name goes and no process has the file open is the space freed.mvwithin one filesystem does not move data either. It just changes which directory holds the name. Across filesystems it must copy and then delete, which is why it is slow there.killdoes not necessarily kill. It sends a signal. The default is SIGTERM, which is a polite request the program may handle or ignore.kill -9sends SIGKILL, which the program cannot refuse.
TECHNICAL19.11.5 the engineer’s version#
- Navigation and inspection.
| Command | Plain job | Flags worth knowing |
|---|---|---|
| pwd | print current path | -P resolve symlinks |
| cd | change directory | - go back, no flag |
| ls | list directory | -l -a -h -t -r -S |
| tree | show nested structure | -L depth, -a |
Real note:
ls -ltsorts newest first,ls -ltrreverses so newest is at the bottom, which is what you want on a long log directory.Files.
| Command | Plain job | Flags worth knowing |
|---|---|---|
| mkdir | make a directory | -p make parents |
| touch | create or update time | -t set a time |
| cp | copy | -r -a -i -v |
| mv | move or rename | -i -n -v |
| rm | delete | -r -f -i |
| ln | make another name | -s symbolic link |
cp -ameans archive: recursive, preserve permissions, times and links.rm -iasks before each delete and is worth aliasing on shared machines.There is no undelete.
rmis final. The only protection is backups and care.Viewing.
| Command | Plain job | Flags worth knowing |
|---|---|---|
| cat | print whole file | -n number lines |
| less | page through a file | -N -S, / to search |
| head | first lines | -n N, -c bytes |
| tail | last lines | -n N, -f follow |
| wc | count lines and words | -l -w -c |
tail -f logfileis the standard way to watch a log grow.tail -Falso survives the file being rotated and recreated.Inside
less:/textsearches forward,nnext match,Gend,gstart,qquit. It is also the default pager forman.Searching.
| Command | Plain job | Flags worth knowing |
|---|---|---|
| grep | find text in files | -i -n -r -v -c -E |
| find | find files by attribute | -name -type -mtime |
| which | show which file will run | -a show all matches |
| locate | fast name search | needs an index |
- Real
grepandfindruns from this sandbox:
$ grep -c "500" access.log
3
$ grep -n "404" access.log
3:10.0.0.7 ... "GET /missing HTTP/1.1" 404 152
$ grep -v "200" access.log | wc -l
4
$ find /home/claude/book/chapters -name 'ch1*.md' -size +100k \
-printf '%s %p\n' | sort -rn | head -3
131233 /home/claude/book/chapters/ch14.md
124198 /home/claude/book/chapters/ch17.md
123297 /home/claude/book/chapters/ch16.md
findsyntax is unusual: path first, then tests, then actions.-exec cmd {} \;runs once per file;-exec cmd {} +batches them and is much faster.-print0withxargs -0handles filenames with spaces.Text processing.
| Command | Plain job | Flags worth knowing |
|---|---|---|
| cut | take columns | -d delim, -f fields |
| sort | order lines | -n -r -u -k -t |
| uniq | collapse adjacent dups | -c -d -u |
| tr | swap or delete chars | -d delete, -s squash |
| sed | edit a stream | -n, s///, -i |
| awk | field-aware processing | -F, patterns, END |
- Real runs:
$ cut -d: -f1,7 /etc/passwd | head -3
root:/bin/bash
daemon:/usr/sbin/nologin
bin:/usr/sbin/nologin
$ printf '10\n9\n100\n2\n' | sort -> 10 100 2 9
$ printf '10\n9\n100\n2\n' | sort -n -> 2 9 10 100
$ echo "Hello World" | tr 'a-z' 'A-Z'
HELLO WORLD
$ sed 's/GET/FETCH/' access.log | head -1
10.0.0.7 ... "FETCH /index.html HTTP/1.1" 200 5120
$ awk '{bytes += $10} END {print "total:", bytes, "lines:", NR}' \
access.log
total: 16594 lines: 8
$ awk '$9 == 500 {print $1}' access.log
10.0.0.12
10.0.0.7
10.0.0.12
Note
sortwithout-nputs 100 before 2, because it is comparing text. That single default has produced more wrong reports than any other flag in UNIX.sed -iedits in place. GNU sed takes-ialone; BSD and macOS sed require an argument, sosed -i '' 's/a/b/' fon macOS andsed -i 's/a/b/' fon Linux. This difference breaks scripts crossing the two systems constantly.Permissions and ownership.
| Command | Plain job | Flags worth knowing |
|---|---|---|
| chmod | change permissions | -R, +x, octal 644 |
| chown | change owner and group | -R, user:group |
| umask | default for new files | 022 typical |
| id | who am I, which groups | -u -g -G |
| sudo | run as another user | -u, -i, -l |
Real:
umaskprinted0022here, meaning new files get 644 and new directories 755, because the mask removes write from group and others.chownneeds root for changing the owner. Changing only the group is allowed if you belong to the target group.Processes.
| Command | Plain job | Flags worth knowing |
|---|---|---|
| ps | snapshot of processes | aux, -ef, -o fmt |
| top | live process view | -b -n batch mode |
| kill | send a signal | -TERM -KILL -HUP |
| pkill | signal by name | -f match full line |
| jobs | this shell’s jobs | %1 refers to job 1 |
| nohup | survive terminal close | with & |
- Real, from this sandbox:
$ ps aux --sort=-%mem | head -3
USER PID %CPU %MEM VSZ RSS STAT TIME COMMAND
root 462 3.8 9.0 6045508 746736 Rsl 3:46 claude
root 473 0.3 0.4 1800580 33860 Sl 0:21 environment-manager
$ ps -o pid,ppid,stat,etime,comm -p 1
PID PPID STAT ELAPSED COMMAND
1 0 SLl 01:38:18 process_api
ps auxis BSD-style,ps -efis System V style. Both work on Linux and macOS.RSSis resident memory in kilobytes, the number that actually matters.VSZis address space reserved and is usually meaningless.Escalation order for stopping something:
kill PIDfirst, wait a few seconds, thenkill -9 PID. Going straight to-9skips the program’s chance to flush data and clean up.Disk.
| Command | Plain job | Flags worth knowing |
|---|---|---|
| df | free space per mount | -h -i inodes |
| du | space used by a tree | -sh, -d1, -h |
| ncdu | interactive disk usage | not always installed |
- Real:
$ df -h | head -4
Filesystem Size Used Avail Use% Mounted on
/dev/vda 252G 12G 30G 29% /
/dev/vdc 327M 295M 26M 93% /opt/claude-code
$ du -sh /home/claude/book/chapters
1.6M /home/claude/book/chapters
If
dfshows space free but writes still fail, checkdf -i. You may have run out of inodes, which are the records that name files, not the bytes.Network.
| Command | Plain job | Flags worth knowing |
|---|---|---|
| curl | fetch a URL | -I -L -o -s -v |
| wget | download a file | -c continue, -O |
| ssh | log in to another host | -p port, -i key, -v |
| scp | copy files over ssh | -r, -P port |
| ping | is the host answering | -c count |
| dig | ask DNS a question | +short, @server |
| traceroute | show the path there | -m max hops |
| netstat | sockets and listeners | -tlnp |
| ss | modern netstat | -tlnp |
| lsof | what has this file open | -i, -p PID |
- Real runs from this sandbox:
$ dig +short github.com
140.82.112.4
$ dig @1.1.1.1 +short pypi.org
151.101.64.223
151.101.192.223
$ curl -s -o /dev/null \
-w 'code=%{http_code} time=%{time_total}s ip=%{remote_ip}\n' \
pypi.org
code=301 time=0.039756s ip=151.101.192.223
$ netstat -tlnp | head -3
Proto Local Address State PID/Program name
tcp 127.0.0.1:43279 LISTEN 462/claude
tcp 0.0.0.0:2024 LISTEN -
$ ssh -V
OpenSSH_9.6p1 Ubuntu-3ubuntu13.18, OpenSSL 3.0.13 30 Jan 2024
netstatis deprecated on Linux in favour ofssfrom the iproute2 package, but it is still present on macOS and on many servers. Learn both spellings of the same idea.pingneeds ICMP to be allowed. Many cloud hosts drop ICMP by policy, so a failed ping proves nothing on its own. Prove reachability with a TCP connection instead:curl -vornc -vz host port.Archives and transfer.
| Command | Plain job | Flags worth knowing |
|---|---|---|
| tar | bundle a directory | -czf -xzf -tzf |
| gzip | compress one file | -d decompress, -9 |
| zip | Windows-friendly bundle | -r recursive |
| unzip | unpack a zip | -l list, -d dir |
| rsync | sync trees efficiently | -av –delete -n |
tarbundles then compresses the whole bundle, so it compresses better but cannot extract one file without reading the stream.zipcompresses each file separately, so it is worse at compression but supports random access. Real measurement here: the same tiny tree gave 213 bytes as.tar.gzand 640 bytes as.zip.Help and history.
| Command | Plain job | Flags worth knowing |
|---|---|---|
| man | read the manual | -k search, section |
| which | path of the command | -a all matches |
| type | what kind of thing is it | -a all definitions |
| history | what did I type before | Ctrl-R to search |
| apropos | search manual summaries | same as man -k |
- Real history behaviour, captured in a pseudo-terminal here:
demo$ history
1 echo alpha
2 ls note1.txt
3 pwd
4 history
demo$ !2
ls note1.txt
note1.txt
!!repeats the previous command.sudo !!is the standard recovery after forgettingsudo.!$is the last argument of the previous line.
WORDS19.11.6 remember these#
- Flag — a short option that changes a command’s behaviour — a single-letter or long option parsed by
getopt. - Recursive — apply to everything underneath — the
-ror-Roption, descending the directory tree. - Inode — the record that describes a file — the filesystem structure holding permissions, times and block pointers, not the name.
- Signal — a message sent to a running process — an asynchronous notification such as SIGTERM 15 or SIGKILL 9.
- Pager — a program that shows text one screen at a time —
lessormore, the target of thePAGERvariable. - Symbolic link — a file that points at another path — created with
ln -s, shown as typelbyls -l.
19.12 Globbing, quoting and expansion#
PLAIN19.12.1 in simple words#
- The shell rewrites your line before any program sees it.
- By the time
ls *.txtreachesls, the star is gone.lsreceives a list of real filenames. - This surprises people.
lshas no wildcard support at all. It never needed any. - The rewriting has several kinds, and they happen in a fixed order.
~becomes your home directory.{a,b}becomes two separate words.$NAMEbecomes the value of a variable.$(command)becomes whatever that command printed.*and?become matching filenames.- Quotes switch parts of this off. That is their only job.
PLAIN19.12.2 a picture in your head#
- Think of a mail room that opens every letter before delivery.
- Wherever the letter says “the boss”, the clerk writes in the boss’s actual name. Wherever it says “all the branches”, the clerk writes out every branch address in full.
- The recipient never sees the shorthand. They see a fully spelled-out letter.
- Quotation marks are an instruction to the clerk: leave this part exactly as written.
- Single quotes mean “change nothing at all inside here”.
- Double quotes mean “you may still fill in names, but do not split the text into pieces or expand wildcards”.
Where this comparison breaks: the clerk works in a strict order and cannot go back. Once a variable has been replaced by text containing a space, the shell may split that text into two words, and there is no way to un-split it afterwards. That one-way order is the cause of nearly every quoting bug.
PLAIN19.12.3 a worked example#
- All real output from this sandbox. The directory holds
note1.txt,note2.txt,notes.md,report.csvandmy file.txt.
$ echo *.txt
my file.txt note1.txt note2.txt
$ echo note?.txt
note1.txt note2.txt
$ echo note[12].txt
note1.txt note2.txt
$ echo nomatch*.zzz
nomatch*.zzz
$ echo file{1..4}.log
file1.log file2.log file3.log file4.log
$ echo {a,b,c}.txt
a.txt b.txt c.txt
$ echo ~
/root
$ echo "there are $(ls | wc -l) entries"
there are 7 entries
$ echo $((3 + 4 * 2))
11
- Note the fourth one. When a wildcard matches nothing, bash leaves it unchanged. The program then receives a literal star and usually complains. zsh instead reports an error and runs nothing, which is arguably safer.
- Note that brace expansion produced filenames that do not exist. Braces are pure text generation; they never look at the disk.
- Quoting, for real:
$ NAME=world
$ echo "double: $NAME"
double: world
$ echo 'single: $NAME'
single: $NAME
$ echo backslash: \$NAME
backslash: $NAME
- Now the spaces problem, which is the one that destroys data:
$ f="my file.txt"
$ printf "[%s]\n" $f
[my]
[file.txt]
$ printf "[%s]\n" "$f"
[my file.txt]
$ rm $f
rm: cannot remove 'my': No such file or directory
rm: cannot remove 'file.txt': No such file or directory
- Unquoted, one filename became two arguments. With
rmin a directory that happened to contain a file calledmy, that command would have deleted the wrong file silently. - The rule is simple and absolute: put double quotes around every variable expansion unless you have a specific reason not to.
PLAIN19.12.4 what is really happening inside#
- You can watch the rewriting happen.
set -xmakes bash print each line after expansion. Real output:
$ bash -xc 'x=5; ls note*.txt
> echo "x is $x, dir $(basename /tmp/shdemo)"'
+ x=5
+ ls note1.txt note2.txt
++ basename /tmp/shdemo
+ echo 'x is 5, dir shdemo'
- Look at line two.
ls note*.txtbecamels note1.txt note2.txtbefore running. The wildcard is gone. - Look at the double plus. That is a nested expansion: the command substitution ran first, at one level deeper.
- The order the shell uses is fixed and worth memorizing:
- Brace expansion first. Purely textual, no filesystem involved.
- Then tilde expansion.
- Then, all at the same time and left to right: parameter expansion (
$VAR), command substitution ($(...)) and arithmetic expansion ($((...))). - Then word splitting, which cuts the results on spaces, tabs and newlines.
- Then filename expansion, the wildcards.
- Finally quote removal, which strips the quote characters themselves so the program never sees them.
- Two consequences follow directly. Word splitting happens after variable expansion, which is why unquoted variables split.
- And filename expansion happens after word splitting, which is why a variable holding a star can still expand to filenames unless quoted.
TECHNICAL19.12.5 the engineer’s version#
| Form | Name | Consults disk |
|---|---|---|
{a,b} |
brace expansion | no |
~user |
tilde expansion | passwd file |
$VAR |
parameter expansion | no |
$(cmd) |
command substitution | runs a program |
$((x+1)) |
arithmetic expansion | no |
* ? [] |
pathname expansion | yes |
<(cmd) |
process substitution | creates a fifo |
- The controlling variable for word splitting is
IFS, the internal field separator. Its default is space, tab and newline. SettingIFS=$'\n'restricts splitting to line boundaries. - Glob characters are
*any string including empty,?any single character,[abc]one of a set,[!abc]or[^abc]none of a set. Character classes such as[[:digit:]]are POSIX. - A leading dot is never matched by
*by default.shopt -s dotglobin bash changes that. This is whyrm *does not remove.git. shopt -s nullglobmakes a non-matching pattern expand to nothing instead of itself.shopt -s failglobmakes it an error, matching zsh’s default.shopt -s globstarenables**for recursive matching in bash 4 and later. macOS’s bundled bash 3.2 does not have it; zsh has it always.- Parameter expansion has a large sub-language worth learning:
${VAR:-default}use a default,${VAR:?message}fail if unset,${VAR#prefix}and${VAR%suffix}strip,${VAR//old/new}replace all,${#VAR}length,${VAR:2:3}substring. - Backticks are the old command substitution syntax.
$(...)is POSIX, nests cleanly, and is what you should write. Backticks are a legacy convention. - Single quotes protect everything, including backslashes. There is no way to put a single quote inside single quotes; you must close, escape, and reopen:
'it'\''s'. - Double quotes still allow
$, backtick and backslash. They suppress word splitting and globbing. "$@"expands to each argument as a separate quoted word."$*"joins them into one word. This distinction matters in every wrapper script ever written.- For filenames from
find, use-print0withxargs -0orwhile IFS= read -r -d ''. Newlines are legal in UNIX filenames, and any line-based loop over filenames is broken in principle. shellcheckis a static analyser that catches unquoted expansions and dozens of similar faults. Running it on every script is the cheapest quality improvement available in shell programming.
WORDS19.12.6 remember these#
- Glob — a wildcard filename pattern — pathname expansion performed by the shell, not by the command.
- Expansion — the shell rewriting your line before running it — the ordered sequence from brace expansion to quote removal.
- Word splitting — cutting expanded text into separate arguments — splitting on the characters in
IFS, after parameter expansion. - IFS — the characters that separate words — the internal field separator, defaulting to space, tab and newline.
- Command substitution — replacing a command with its output —
$(cmd), with trailing newlines removed. - Quote removal — the final step that deletes the quote marks — performed after all expansions, so programs never see quotes.
19.13 Shell scripting#
PLAIN19.13.1 in simple words#
- A shell script is a file containing the same commands you would type.
- Put them in a file, mark the file as runnable, and you can run the whole sequence with one word.
- The first line should say which program interprets the file. That line starts with
#!and is called the shebang. #!/usr/bin/env bashis the usual choice, because it finds bash wherever it is installed.- Inside, you get variables,
iffor decisions,forandwhilefor repetition, and functions for naming a block of steps. - Arguments arrive as
$1,$2, and so on.$@is all of them.$#is how many. exit Nends the script with the code N, which the caller can test.- One line near the top prevents most disasters:
set -euo pipefail. - It means: stop on the first failure, stop if I use a variable I never set, and notice failures inside pipelines.
PLAIN19.13.2 a picture in your head#
- Think of a recipe card taped to the kitchen wall.
- Typing commands is cooking from memory. A script is writing the recipe down so tomorrow’s version is identical.
- Without
set -e, the recipe says “if the oven fails to heat, carry on anyway”, which produces a raw cake presented as finished. - With
set -e, the cook stops and tells you the oven failed. - Functions are named sub-recipes: “make the sauce” written once, used three times.
- Arguments are the parts you leave blank: how many people, which flavour.
Where this comparison breaks: a human cook applies judgement and notices when something looks wrong. A script notices nothing at all. It will pour salt for an hour if you tell it to, and its only safety comes from checks you wrote in advance.
PLAIN19.13.3 a worked example#
- Here is the real difference
set -euo pipefailmakes. Both scripts were run in this sandbox.
#!/usr/bin/env bash
cd /nonexistent-dir
echo "still running, and about to work in $(pwd)"
echo "value is: $UNDEFINED_VAR"
- Real output:
./noset.sh: line 2: cd: /nonexistent-dir: No such file or directory
still running, and about to work in /tmp/shdemo
value is:
exit=0
- Read that carefully. The
cdfailed. The script continued in the wrong directory. It used an undefined variable as an empty string. And it reported success. - If line 3 had been
rm -rf ./*, it would have deleted the wrong directory and told the caller everything was fine. - Now the same script with the guard:
#!/usr/bin/env bash
set -euo pipefail
cd /nonexistent-dir
echo "this line never runs"
- Real output:
./withset.sh: line 3: cd: /nonexistent-dir: No such file
or directory
exit=1
- It stopped at the failure and reported failure. That is the whole argument for the line.
PLAIN19.13.4 what is really happening inside#
- The
#!line is read by the kernel, not the shell. When you execute a file, the kernel looks at its first two bytes. - If they are
#!, the kernel reads the rest of the line, runs that program, and hands it the script’s path as an argument. - So
./script.shreally becomes/usr/bin/env bash ./script.sh. envthen searchesPATHforbashand executes it. That indirection is why theenvform works on machines where bash is not in/bin.- Without the execute permission bit, the kernel refuses, and you get exit code 126.
set -esets theerrexitshell option. After every simple command the shell checks the status and exits if it is non-zero and untested.set -usetsnounset. Expanding an unset variable becomes an error instead of an empty string.set -o pipefailchanges a pipeline’s status from “the last command” to “the rightmost command that failed”.- Functions are not separate processes. They run inside the same shell, so they can change its variables and its directory.
- A subshell, written
( ... ), is a forked copy. Changes inside it are thrown away when it ends. That is whycdinside( )does not move you.
TECHNICAL19.13.5 the engineer’s version#
- Here is a complete, working script, run for real in this sandbox. It reads a web server log and reports error counts, with a meaningful exit code.
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "usage: $(basename "$0") LOGFILE [MIN_ERRORS]" >&2
exit 64
}
[ $# -ge 1 ] || usage
logfile=$1
min_errors=${2:-1}
if [ ! -r "$logfile" ]; then
echo "error: cannot read $logfile" >&2
exit 66
fi
total=$(wc -l < "$logfile")
echo "file: $logfile"
echo "lines: $total"
echo "status counts:"
awk '{print $9}' "$logfile" | sort | uniq -c | sort -rn
echo "clients with at least $min_errors error(s):"
found=0
while read -r count ip; do
if [ "$count" -ge "$min_errors" ]; then
printf ' %-12s %s\n' "$ip" "$count"
found=$((found + 1))
fi
done < <(awk '$9 >= 400 {print $1}' "$logfile" \
| sort | uniq -c | sort -rn)
if [ "$found" -eq 0 ]; then
echo " none"
exit 0
fi
exit 1
- Line by line.
#!/usr/bin/env bashselects bash viaPATH. set -euo pipefailturns on the three guards described above.usage()defines a function.>&2sends its message to standard error, so it is not captured by a caller collecting output.exit 64isEX_USAGE.[ $# -ge 1 ] || usageis an idiom: if there is not at least one argument, runusage.$#is the argument count.min_errors=${2:-1}uses parameter expansion to default the second argument to 1 when it is absent. Without this,set -uwould abort.[ ! -r "$logfile" ]tests readability. The quotes are essential; a path with a space would otherwise become two arguments and break the test.wc -l < "$logfile"uses redirection rather than passing the filename, sowcprints only the number without the filename after it.- The
while read -r count iploop reads two fields per line.-rstops backslashes being interpreted, and should always be present. done < <(...)is process substitution. The pipeline runs in a separate process and its output is fed to the loop’s standard input.- This matters: a plain
pipeline | while readputs the loop in a subshell, sofoundwould be lost when the loop ends. Process substitution keeps the loop in the main shell. $((found + 1))is arithmetic expansion. No externalexpris needed.- The script exits 0 when nothing was found and 1 when errors were found, which lets a monitoring system use it directly in an
if. - Real runs:
$ ./logsum.sh access.log 2
file: access.log
lines: 8
status counts:
4 200
3 500
1 404
clients with at least 2 error(s):
10.0.0.7 2
10.0.0.12 2
exit=1
$ ./logsum.sh
usage: logsum.sh LOGFILE [MIN_ERRORS]
exit=64
$ ./logsum.sh /nope.log
error: cannot read /nope.log
exit=66
- Portability notes.
[[ ]]is a bash and zsh feature with better quoting behaviour;[ ]is POSIX and is really the programtest. Process substitution and arrays are bash and zsh only, notsh. trap 'rm -f "$tmp"' EXITis the standard way to clean up on any exit path, including errors. Create temporary files withmktemp, never with a fixed name in/tmp.- Where experts disagree: some hold that anything over roughly 100 lines should be rewritten in Python. Others keep shell scripts of a thousand lines running happily. The practical dividing line most teams use is data structures: the moment you want a list of records with fields, leave shell.
WORDS19.13.6 remember these#
- Shebang — the first line naming the interpreter — the
#!magic number read by the kernel’sexecve. set -e— stop at the first failure — theerrexitoption, with documented exceptions inside conditions.set -u— treat unset variables as errors — thenounsetoption.pipefail— a pipeline fails if any stage fails — a bash and zsh option, not in POSIX.- Subshell — a forked copy of the shell — created by
( ), pipelines and command substitution; its variable changes do not escape. - Process substitution — treat a command’s output as a file —
<(cmd), implemented with a FIFO or/dev/fd, bash and zsh only. trap— run cleanup code when the script ends — a handler registered for a signal or the pseudo-signalEXIT.
19.14 Getting comfortable#
PLAIN19.14.1 in simple words#
- Speed at the command line comes from about a dozen habits, not from typing faster.
- Tab completion: type the first few letters and press Tab. The shell finishes the name.
- History search: press Ctrl-R and type part of an old command. It appears. Press Enter to run it.
- Line editing: Ctrl-A jumps to the start of the line, Ctrl-E to the end, Ctrl-U deletes to the start, Ctrl-W deletes the last word.
- Aliases: give a long command a short name you choose.
- Sessions that survive:
tmuxkeeps your work running when your connection drops. - Reading manuals properly: every manual page has the same sections, and you only ever need three of them.
PLAIN19.14.2 a picture in your head#
- Think of learning a musical instrument.
- Nobody plays fast by moving their fingers faster. They play fast because common patterns became automatic.
- Tab completion is like a scale you no longer think about.
- Ctrl-R is like remembering a phrase you played last week instead of re-inventing it.
- tmux is like leaving your instrument set up on its stand, tuned, so tomorrow you sit down and continue mid-piece.
Where this comparison breaks: an instrument gives immediate feedback when you get it wrong. A shell often gives no feedback at all, so bad habits survive for years. The only cure is deliberately learning the correct form once.
PLAIN19.14.3 a worked example#
- Keyboard shortcuts worth memorizing. These come from the readline library, used by bash, and zsh has the same set in emacs mode.
| Keys | What it does | Keys | What it does |
|---|---|---|---|
| Ctrl-A | start of line | Ctrl-E | end of line |
| Ctrl-U | delete to line start | Ctrl-K | delete to end |
| Ctrl-W | delete previous word | Ctrl-Y | paste last delete |
| Ctrl-R | search history back | Ctrl-G | cancel the search |
| Ctrl-L | clear the screen | Ctrl-C | cancel this line |
| Ctrl-D | end of input, log out | Ctrl-Z | suspend the job |
| Alt-B | back one word | Alt-F | forward one word |
| Alt-. | last argument of prev | Ctrl-_ | undo the edit |
- On macOS, Alt is the Option key, and Terminal.app needs “Use Option as Meta key” enabled in its settings before Alt-B and Alt-F work.
- Aliases, real output from this sandbox:
$ alias ll="ls -lah"
$ alias
alias l='ls -CF'
alias la='ls -A'
alias ll='ls -lah'
alias ls='ls --color=auto'
$ type ll
ll is aliased to `ls -lah'
- Aliases go in
~/.zshrcor~/.bashrcso they exist in every new shell. - An alias is text substitution on the first word only. For anything needing arguments in the middle, write a function instead.
PLAIN19.14.4 what is really happening inside#
- Tab completion is a shell feature, not a terminal feature. The terminal just sends byte 9.
- The shell’s line editor intercepts it, looks at what you have typed, and works out the candidates.
- bash uses the GNU readline library for this. zsh has its own editor, ZLE.
- Modern completion is programmable. When you type
git cheand press Tab, the shell runs a completion function shipped with git, which knows about branches. - History is kept in memory during the session and written to a file when the shell exits. bash uses
~/.bash_history; zsh uses~/.zsh_history. - That end-of-session write is why history from one window can be missing in another.
setopt INC_APPEND_HISTORYandsetopt SHARE_HISTORYin zsh, orshopt -s histappendwithPROMPT_COMMANDin bash, fix it. - tmux works by putting your shell inside a pseudo-terminal that tmux itself owns, rather than the one your emulator made.
- When your SSH connection dies, your emulator’s pty is destroyed, but tmux’s is not, because the tmux server process is still running on the far machine.
- Reconnect, run
tmux attach, and tmux re-draws its saved screen contents into your new terminal. - Without tmux, losing the connection sends SIGHUP to your shell, which normally kills everything it started.
TECHNICAL19.14.5 the engineer’s version#
- tmux, real version and behaviour from this sandbox:
$ tmux -V
tmux 3.4
$ tmux new-session -d -s demo 'sleep 60'
$ tmux ls
demo: 1 windows (created Thu Aug 13 02:00:38 2026)
$ tmux kill-session -t demo
- The commands worth knowing:
tmux new -s name,tmux ls,tmux attach -t name,tmux kill-session -t name. Inside, the prefix key is Ctrl-B by default:Ctrl-B ddetach,Ctrl-B cnew window,Ctrl-B "split horizontally,Ctrl-B %split vertically,Ctrl-B [scrollback mode. - GNU Screen is the older equivalent, first released 1987, prefix Ctrl-A. tmux, first released 2007 by Nicholas Marriott, is the more actively developed choice today.
nohup cmd &anddisownare lighter alternatives: they detach a single job from the terminal’s hangup signal but give you no way to reattach and see it.- Manual page structure is standardized. The sections, in the order they always appear, and confirmed against the real
greppage in this sandbox:
GREP(1) User Commands GREP(1)
NAME
SYNOPSIS
DESCRIPTION
OPTIONS
REGULAR EXPRESSIONS
EXIT STATUS
ENVIRONMENT
NOTES
COPYRIGHT
BUGS
EXAMPLE
SEE ALSO
- How to read one properly: read NAME to confirm it is the right tool, read SYNOPSIS to learn the argument shape, then jump straight to EXAMPLES at the bottom. Read DESCRIPTION only when the examples are not enough.
- Inside
less, which pages the manual:/wordsearches,nrepeats,qquits,Ggoes to the end where the examples are. - Manual sections are numbered. 1 user commands, 2 system calls, 3 library functions, 4 devices, 5 file formats, 7 miscellaneous and overviews, 8 administration commands.
- That numbering matters.
man 1 printfis the command;man 3 printfis the C function.man 5 passwdis the file format;man 1 passwdis the tool. man -k wordorapropos wordsearches all the NAME lines when you do not know the command’s name.- GNU tools often have fuller documentation in
inforather thanman.curl --help allandgit help -aare other real sources. tldr, a community project, gives example-first summaries and is worth installing when a manual page is 900 lines long.
WORDS19.14.6 remember these#
- readline — the library giving bash its line editing — GNU readline, configured through
~/.inputrc. - Tab completion — the shell finishing a word for you — programmable completion driven by shell functions.
- Reverse search — finding an old command by fragment — Ctrl-R, an incremental backward search through the history list.
- Alias — a short name for a longer command — first-word text substitution performed before expansion.
- tmux — keeps sessions alive across disconnection — a terminal multiplexer holding its own ptys in a persistent server process.
- SIGHUP — the “your terminal has gone” signal — signal 1, sent to the foreground group when the controlling terminal is lost.
19.15 Package managers#
PLAIN19.15.1 in simple words#
- A package manager installs software for you and keeps track of what it installed.
- It knows which files belong to which program, so it can remove them cleanly.
- It knows which programs depend on which other programs, and installs those too.
- It gets the software from a repository: a server holding a catalogue and the files.
- It checks a cryptographic signature, so you can tell the files really came from the people who claim to have made them.
- Linux distributions each have one. Debian and Ubuntu use
apt. Fedora and Red Hat usednf. Arch usespacman. - macOS has no official one for developer tools, so the community built Homebrew.
- Windows now has
winget, built by Microsoft. - The pattern is always: update the catalogue, then install by name.
PLAIN19.15.2 a picture in your head#
- Think of a library with a strict lending system.
- You ask for one book. The librarian knows that book refers constantly to two others, so brings all three.
- Every book has a stamp proving it came from a real publisher, not a forgery somebody left on a shelf.
- When you return the first book, the librarian checks whether anyone else is using the other two before shelving them.
- Installing software by downloading it yourself is walking into the building and taking a book without telling anyone. Nothing tracks it and nothing removes it later.
Where this comparison breaks: books do not conflict. Software versions do. Two programs may need incompatible versions of the same library, and no librarian can satisfy both from one shelf. That problem is called dependency hell, and it is the reason containers and per-language package managers exist.
PLAIN19.15.3 a worked example#
- Real
aptoutput from this sandbox, showing what a package manager actually knows:
$ apt-cache policy curl
curl:
Installed: 8.5.0-2ubuntu10.9
Candidate: 8.5.0-2ubuntu10.11
Version table:
8.5.0-2ubuntu10.11 500
500 archive.ubuntu.com/ubuntu noble-updates/main
*** 8.5.0-2ubuntu10.9 100
$ apt-cache depends curl
curl
Depends: libc6
Depends: libcurl4t64
Depends: zlib1g
$ dpkg -L curl | head -4
/usr
/usr/bin
/usr/bin/curl
/usr/share
- Three separate facts there: which version is installed, which is available, what it needs, and exactly which files it owns.
- The repositories it trusts are listed in
/etc/apt/sources.list.d/, and the signing keys in/etc/apt/trusted.gpg.d/. In this sandbox those heldubuntu.sources,docker.list, and the Ubuntu archive keyring files. - Equivalent commands across systems:
| Task | apt | dnf / pacman |
|---|---|---|
| refresh | apt update | dnf check-update |
| install | apt install curl | dnf install curl |
| remove | apt remove curl | dnf remove curl |
| search | apt search curl | dnf search curl |
| upgrade | apt upgrade | dnf upgrade |
- On Arch the same five are
pacman -Sy,pacman -S curl,pacman -R curl,pacman -Ss curl,pacman -Syu. - On macOS:
brew update,brew install curl,brew uninstall curl,brew search curl,brew upgrade. - On Windows:
winget search curl,winget install curl,winget upgrade --all.
PLAIN19.15.4 what is really happening inside#
apt updatedownloads an index file listing every package, its version, its dependencies and a hash of its contents.- That index is signed. The manager verifies the signature against keys you already trust, and refuses to proceed if it fails.
apt install Xreads the index and solves a puzzle: pick versions of X and everything it needs so that all constraints hold at once.- This is genuinely a hard computational problem. Real solvers use SAT solving techniques, and they can fail with a message about held broken packages.
- It then downloads each chosen package, verifies each hash, and unpacks the files to their recorded locations.
- It runs the package’s own install scripts, and writes down which files were placed, so removal is exact.
- Homebrew works differently. It downloads pre-built binaries, called bottles, into its own directory tree and symlinks them into place, so it never touches Apple’s system files.
- Now the warning. You will see installation instructions of this shape:
curl -fsSL some-site.example/install.sh | sh
- That downloads a script and runs it immediately, with your permissions, with no chance to read it.
- There is no signature check, no record of what it installed, and no way to uninstall it cleanly.
- Worse, a hostile server can send different content to
curlthan it sends to a browser, so reading the page first proves nothing. - The safer form is two steps: download to a file, read it, then run it. That is not paranoia, it is the minimum you would apply to any other code you were about to give full access to your account.
TECHNICAL19.15.5 the engineer’s version#
| Manager | Year | System | Format |
|---|---|---|---|
| dpkg | 1994 | Debian | .deb |
| RPM | 1995 | Red Hat 2.0 | .rpm |
| APT | 1998 | Debian | wraps dpkg |
| pacman | 2002 | Arch Linux | .pkg.tar |
| YUM | 2002 | RPM systems | wraps rpm |
| Homebrew | 2009 | macOS | formulae |
| DNF | 2015 | Fedora 22 | wraps rpm |
| winget | 2020 | Windows | manifests |
- Precise history. Ian Murdock created dpkg for Debian in January 1994. Red Hat Linux 2.0 shipped RPM on 20 September 1995. APT 0.0.1 was released by Scott K. Ellis in 1998; APT 1.0 came on 1 April 2014.
- YUM was created by Seth Vidal and Michael Stenner at Duke University on 7 June 2002. DNF replaced it as Fedora’s default in May 2015 with Fedora 22.
- Judd Vinet created pacman alongside the launch of Arch Linux in March 2002.
- Max Howell created Homebrew on 21 May 2009. Version 1.0 arrived on 21 September 2016.
- Microsoft released winget in preview at Build on 19 May 2020, and version 1.0 on 27 May 2021. Chocolatey, the community predecessor, released 0.6.0 on 23 March 2011.
- Two distinct layers exist and confusing them causes real problems. System package managers own
/usrand system libraries. Language package managers,pip,npm,cargo,gem, own their own trees. - Installing a Python library with the system manager and another with
pipinto the same interpreter is a known way to break a machine. Use virtual environments, orpipx, or containers. - Signature mechanics. Debian signs the
Releasefile with OpenPGP; the file contains hashes of the index files, which contain hashes of the packages. So one signature check covers everything by chaining hashes. - RPM signs individual packages as well. Both models are documented, both are sound, and both fail if you add an untrusted key without thinking.
- Homebrew does not sign bottles with OpenPGP. It relies on HTTPS transport and on checksums recorded in the formula in a public git repository. That is weaker than Debian’s model, and it is a fair criticism.
- Reproducibility is active work rather than settled fact. The Reproducible Builds project, running since 2013, aims to make a package’s binary byte-for-byte derivable from its source. Debian reports high but not complete coverage. Treat any claim of full reproducibility as a target, not a delivered guarantee.
- Supply chain attacks are the reason all of this matters. Real incidents include the event-stream npm package compromise in 2018 and the xz-utils backdoor discovered in March 2024, which reached Debian and Fedora testing branches before being caught.
WORDS19.15.6 remember these#
- Package — one installable unit of software — an archive plus metadata describing version, dependencies and file list.
- Repository — the server holding the catalogue — a signed index plus the package files it describes.
- Dependency resolution — working out what else must be installed — constraint solving over the version graph, often SAT-based.
- Signature — proof the files came from who they claim — an OpenPGP signature over the index or the package.
- Bottle — a pre-built Homebrew package — a relocatable binary archive built by Homebrew’s own infrastructure.
- Supply chain attack — hostile code inserted upstream — compromise of a package, its maintainer or its build system, rather than of your machine.
19.98 Common wrong ideas#
- Wrong: the terminal and the shell are the same program. Right: the terminal emulator draws the window; the shell runs inside it and can be swapped for another without changing the window.
- Wrong:
lsunderstands the*wildcard. Right: the shell expands*into a list of filenames first;lsonly ever receives real names. - Wrong: Ctrl-C sends the letter C to the program. Right: the kernel’s line discipline sees byte 3 and sends SIGINT to the foreground process group; the byte itself is never delivered.
- Wrong:
2>&1 > filesends both streams to the file. Right: redirections are applied left to right, so stderr is aimed at the screen first and stays there. Write> file 2>&1. - Wrong: a pipeline runs the first command fully, then the second. Right: all stages run at the same time, connected by a kernel buffer, with the writer blocking when it is full.
- Wrong: exit code 1 always means something crashed. Right: many tools use 1 for a normal negative answer.
grepreturns 1 when it searched correctly and found nothing. - Wrong: a child process can change its parent’s environment variables. Right: the child gets a copy at
exectime. Nothing it does can reach back. That is whycdmust be a shell builtin. - Wrong: editing
~/.zshrcchanges shells that are already open. Right: it is read at startup. Existing shells keep the old values until yousourcethe file or open a new window. - Wrong:
kill -9is the normal way to stop a program. Right:killsends SIGTERM, which lets the program flush data and clean up. SIGKILL cannot be handled and should be the second attempt, not the first. - Wrong: piping a downloaded script into a shell is fine because you trust the website. Right: there is no signature, no record and no clean removal, and a server can serve different bytes to
curlthan to a browser.
19.99 Chapter summary in 20 lines#
- A terminal was a physical machine at the end of a wire, with a keyboard and a printer or screen, and almost no intelligence of its own.
- The Teletype Model 33 of 1963 ran at 110 bits per second, printed on paper, and is why UNIX commands have two-letter names.
- The DEC VT100 of 1978 popularized the escape sequences, standardized as ECMA-48 and ISO/IEC 6429, that still control your terminal today.
- The device file is called tty because the machine on the other end was a teletype.
- Terminal, terminal emulator, shell, console and command line are five different things; you use an emulator containing a shell.
- A pseudo-terminal is a kernel-made fake wire with a master end for the emulator and a slave end for the shell.
- The line discipline sits between them, doing echo, line editing and buffering, and turning Ctrl-C into SIGINT for the foreground process group.
- A shell reads a line, expands it, forks, execs and waits. That loop is the whole job.
- The shell family runs from Thompson 1971 and Bourne 1979 through bash 1989 to zsh 1990, which macOS made the default in Catalina in 2019.
- PowerShell, from 2006, is a different design entirely: it passes typed objects between commands instead of text.
- Every process starts with descriptors 0, 1 and 2 for input, output and errors, kept separate so results stay clean.
- Redirection is
openplusdup2performed between fork and exec, applied strictly left to right, which is why2>&1order matters. - A pipe is a kernel buffer, 65536 bytes on Linux, joining two processes that run at the same time, with blocking as natural back pressure.
- When a reader exits early the writer gets SIGPIPE, signal 13, reported by the shell as exit status 141.
- Exit codes are 0 for success and 1 to 255 for failure, with 126 not executable, 127 not found, and 128 plus N for death by signal N.
&&runs the next command only on success,||only on failure, and;always. Build systems test only these numbers.- The environment is a copied list of strings;
PATHis searched left to right, the result is cached, and./is required for the current directory. - Windows keeps settings in one registry of hives, keys and typed values; macOS and Linux use plists,
/etcand dotfiles, increasingly under the XDG directories. - The shell rewrites your line in a fixed order, and word splitting happens after variable expansion, which is why you quote every expansion.
set -euo pipefailat the top of a script turns silent wrong behaviour into a loud stop, which is the single highest-value line you can write.