TTY and PTY: Master, Slave, and the Missing Wire

Every concept in this section is presented in six parts: the problem it solves, where it lives in the OS, who owns it, the bytes/signals/syscalls involved, an experiment, and the failure mode when you get it wrong. This chapter covers the first two concepts: TTY versus PTY, and the master/slave pair.


Concept 1: TTY

1. What problem it solves

Unix was designed for machines with terminals on the end of serial lines. A program needed a file-like object it could read() and write() that represented "the human at the other end of the wire," plus a place to put the policy that a human needs but a file does not: line editing, echo, "stop this program," flow control. That object is the TTY.

The name is an abbreviation of teletypewriter — a literal electromechanical typewriter on the end of a wire. Almost every strange thing about terminals is explained by that sentence.

2. Where it exists in the OS

Kernel. A TTY is a character device with an associated line discipline — kernel code that sits between the device and the process reading it. On Linux, the default line discipline is N_TTY.

 ┌──────────────┐   ┌───────────────────┐   ┌──────────────────┐
 │  hardware    │──▶│  tty driver       │──▶│  line discipline │──▶ read()
 │  (UART, KBD) │◀──│  (device-specific)│◀──│  (N_TTY: echo,   │◀── write()
 └──────────────┘   └───────────────────┘   │   canonical, ^C) │
                                            └──────────────────┘
                     ▲ device-specific ▲     ▲ device-INdependent ▲

The split matters: the driver knows about the hardware; the line discipline is the same code regardless of whether the device is a serial port, a virtual console, or a pseudo-terminal. When you learn termios, you are learning the line discipline, and that knowledge transfers to every kind of tty.

3. Who owns or interacts with it

ActorInteraction
The kernelOwns the device and runs the line discipline
A sessionMay have at most one TTY as its controlling terminal
ProcessesHold fds to it; usually 0, 1, 2
getty/login (on real ttys)Opens it, sets it up, becomes session leader, execs the shell

4. Bytes and syscalls

open("/dev/ttyS0", O_RDWR)      acquire an fd
read(fd, buf, n)                 get input (after line-discipline processing)
write(fd, buf, n)                send output (before output processing)
tcgetattr / tcsetattr            read/modify the line-discipline configuration
ioctl(fd, TIOCGWINSZ, &ws)       ask the window size
isatty(fd)                       "is this fd a tty?"  → implemented as tcgetattr succeeding
ttyname(fd)                      the device path

5. Experiment

tty                       # the device path of your shell's controlling terminal
ls -l "$(tty)"            # a character device: the leading 'c'
stty -a                   # the line-discipline configuration of that device
echo hello > "$(tty)"     # write directly to the device — bypasses your shell entirely

Then, on Linux, look at how the kernel sees it:

ls -l /proc/self/fd/0 /proc/self/fd/1 /proc/self/fd/2   # all three point at the same tty

Predict first: what will ls -l /proc/self/fd/1 show when you run ls -l /proc/self/fd/1 > out.txt? Write it down, then run it.

6. Failure mode

A program that assumes its stdin is a tty and calls tcgetattr without checking will fail with ENOTTY when piped. That is exactly why isatty() exists and why ls turns off color when piped: the failure is designed for. Programs that do not check produce the classic stty: 'standard input': Inappropriate ioctl for device.


Concept 2: PTY (Pseudo-Terminal)

1. What problem it solves

There is no wire and no teletype any more, but every interactive Unix program still expects one. Worse, we want a program — a terminal emulator, ssh, script, expect, tmux — to be the thing on the other end of the wire.

A pseudo-terminal is the kernel's answer: a pair of endpoints where one side (the slave) behaves exactly like a TTY — complete with line discipline, termios, window size, and job-control semantics — and the other side (the master) is a plain fd that a program reads and writes.

   THE 1970 PICTURE                      THE MODERN PICTURE

  ┌──────────┐  wire  ┌────────┐        ┌──────────┐        ┌────────┐
  │ VT100    │◀──────▶│ kernel │        │ emulator │◀──────▶│ kernel │
  │ hardware │        │  tty   │        │ process  │ master │  PTY   │
  └──────────┘        └────────┘        └──────────┘        │ pair   │
                           │                                └───┬────┘
                           ▼                             slave  ▼
                       ┌───────┐                            ┌───────┐
                       │ shell │                            │ shell │
                       └───────┘                            └───────┘

The master end replaces the hardware. That is the one-sentence definition of a PTY.

2. Where it exists in the OS

Kernel, entirely. There is no user-space component to a PTY. Both ends are kernel objects; the line discipline between them is the same N_TTY code that serves a real serial port.

 ══════════════════════ KERNEL ═══════════════════════════════════════
  ┌──────────────────────────────────────────────────────────────┐
  │  PTY PAIR                                                    │
  │                                                              │
  │  master fd                        slave  /dev/pts/7          │
  │      │                                        ▲              │
  │      │  write(master, "ls\n")                 │              │
  │      ▼                                        │              │
  │  ┌──────────────┐   INPUT processing    ┌─────┴───────────┐  │
  │  │ input queue  │──▶ (ICRNL, ISIG,  ──▶ │ what read(slave)│  │
  │  │              │     ICANON, ECHO)     │ returns         │  │
  │  └──────────────┘                       └─────────────────┘  │
  │                                                              │
  │  ┌──────────────┐   OUTPUT processing   ┌─────────────────┐  │
  │  │ output queue │◀── (OPOST, ONLCR) ◀── │ write(slave,..) │  │
  │  └──────┬───────┘                       └─────────────────┘  │
  │         │  read(master) returns this                         │
  │         ▼                                                    │
  └──────────────────────────────────────────────────────────────┘

Note the crossover: bytes written to the master arrive as input on the slave; bytes written to the slave arrive as readable data on the master. And note that echo happens inside the kernel: a byte written to the master can come straight back out of the master without any process ever seeing it.

Note: That last sentence explains something that confuses everyone the first time. When you type into a terminal and see the character appear, the round trip is emulator → master → line discipline echo → master → emulator. The shell was not involved.

3. Who owns or interacts with it

EndHeld bySees
MasterThe terminal emulator, ssh server, script, tmux — the "device driver" sideRaw bytes the child wrote, plus echoed input
SlaveThe child process tree: shell, vim, top. Usually as fds 0/1/2A perfectly ordinary TTY

They cannot be swapped. Only the slave has a device file, a termios, a window size, and controlling-terminal semantics. The master has none of those as a terminal — you configure the pair's termios through either fd, but the master itself is never anyone's controlling terminal.

4. Bytes and syscalls: allocating a pair

The portable POSIX path:

int master = posix_openpt(O_RDWR | O_NOCTTY);   // open the PTY multiplexer, get a master
grantpt(master);                                 // fix ownership/permissions of the slave
unlockpt(master);                                // allow the slave to be opened
char *name = ptsname(master);                    // e.g. "/dev/pts/7"
int slave = open(name, O_RDWR | O_NOCTTY);       // open the slave
CallWhat it actually does
posix_openpt(O_RDWR|O_NOCTTY)Opens /dev/ptmx (the PTY multiplexer). The kernel allocates a new pair and returns the master fd. O_NOCTTY says "do not make this my controlling terminal" — important, though the master could not be one anyway.
grantpt(fd)Sets the slave device's owner to the calling user and mode to 0620. On modern Linux with devpts mounted correctly this is a no-op; historically it exec'd a setuid helper. Call it anyway for portability.
unlockpt(fd)Clears the lock flag on the slave so it can be opened. Implemented as ioctl(fd, TIOCSPTLCK, &0) on Linux. Forgetting it makes open(slave) fail with EIO.
ptsname(fd)Returns the slave's path. Not thread-safe (static buffer). glibc has ptsname_r; macOS has ptsname_r on recent versions but ptsname everywhere.

The BSD convenience path, available on both Linux (glibc, link -lutil) and macOS:

#include <pty.h>          // Linux;  <util.h> on macOS
int master, slave;
openpty(&master, &slave, NULL, NULL, NULL);       // does all four steps above
// and the all-in-one:
pid_t pid = forkpty(&master, NULL, NULL, NULL);   // openpty + fork + setsid + TIOCSCTTY + dup2

Warning: forkpty() is convenient and it is also where understanding goes to die. It hides setsid, TIOCSCTTY, and the three dup2 calls — precisely the four things this section exists to teach. Write the manual version first (Lab 2); use forkpty afterwards, if ever.

Platform differences:

LinuxmacOS
Multiplexer device/dev/ptmx/dev/ptmx
Slave names/dev/pts/N (a devpts filesystem)/dev/ttysNNN (pre-created device nodes)
Number of PTYsDynamic, up to /proc/sys/kernel/pty/maxBounded by the static device nodes
ptsname_rYes (glibc)Available on recent versions; ptsname is the portable choice
Read on master after slave closesFails with EIOReturns 0 (EOF)
Header for openpty<pty.h>, link -lutil<util.h>, in libSystem
Packet modeTIOCPKTTIOCPKT

That EIO-versus-EOF difference will bite you in Lab 3. Handle both explicitly, with a comment.

5. Experiment

# Watch a PTY get allocated. Terminal 1:
ls /dev/pts                      # Linux:  note the numbers present
# macOS:  ls /dev/ttys* | tail -5

# Terminal 2 — open a new terminal window or run `script`:
script -q /dev/null              # `script` allocates a PTY and runs a shell in it
tty                              # note the new device

# Back in terminal 1:
ls /dev/pts                      # a new entry appeared

Now prove the crossover and the echo, with two shells:

# Terminal A:
tty                              # → /dev/pts/5   (say)

# Terminal B:
echo "hello from B" > /dev/pts/5     # appears on A's screen

You just wrote to the slave of A's PTY. The bytes went through output processing and out A's master, where A's terminal emulator read and rendered them. Nothing in A's shell was involved.

Predict first: in terminal B, run cat > /dev/pts/5 and type. Where do the characters appear — in B, in A, or both? Why?

6. Failure mode

MistakeSymptom
Forgetting unlockptopen("/dev/pts/N") fails with EIO
Using ptsname from multiple threadsTwo PTYs, one path string, corrupted; use ptsname_r or copy immediately
Holding the slave fd open in the parentThe master never reports EOF/EIO when the child exits, because the kernel still sees an open slave. Your loop hangs forever. This is the #1 PTY bug.
Holding the master fd open in the childThe child inherits a copy; SIGHUP semantics get confused, and closing the parent's master does not tear down the pair
Treating EIO as fatal on LinuxAn error message on every clean exit
Assuming /dev/pts/N exists on macOSIt does not; the path is /dev/ttysNNN. Never hardcode either — use ptsname.

Concept 3: Master and Slave, in Depth

The asymmetry, precisely

PropertyMasterSlave
Has a device fileNo (it comes from /dev/ptmx)Yes (/dev/pts/N)
isatty()Yes on Linux (it is a character device), but it is not a terminal in the job-control senseYes
Can be a controlling terminalNoYes
Has a termiosThe termios belongs to the pair; you can get/set it from either fdSame object
Has a window sizeSame — one struct winsize per pair, settable from either endSame object
Reading it gives youWhat the child wrote (after output processing), plus echoed inputWhat the emulator wrote (after input processing)
Writing it gives the other sideInput to the child (after input processing, including echo and signal generation)Output to the emulator (after output processing)
Closing itKernel sends SIGHUP to the slave's session leader; further slave I/O errorsWhen the last slave fd closes, master reads give EIO (Linux) or 0 (macOS)

Tip: "One termios per pair" is worth internalizing. When your emulator sets raw mode on the master fd, it is changing the settings the child sees. That is usually not what you want — the child (or its readline) manages its own termios. Your emulator should generally leave the PTY's termios alone and only manage the outer terminal's.

The buffers, and what "the child is not reading" means

Each direction has a bounded kernel buffer. When it fills:

   emulator ──write(master)──▶ [input queue: bounded]  ──▶ child read()
                                     │
                                     └─ full? write(master) BLOCKS (or returns EAGAIN)

   child ──write(slave)──▶ [output queue: bounded] ──▶ emulator read(master)
                                │
                                └─ full? the CHILD's write() blocks

That second case is the important one: if your emulator stops reading the master, the child blocks in write() and appears to hang. yes is the classic demonstration. A terminal that couples its read loop to its render loop will freeze the shell inside it. You will hit this in Milestone 7.

The canonical-mode input queue has a second limit: the line buffer (MAX_CANON, typically 4096). Type more than that without pressing Enter in canonical mode and the extra bytes are discarded, often with a bell.

Experiment: fill the buffer

# Start a program that produces output faster than anything can consume it,
# then stop reading. In your Lab 2 runner, comment out the read(master) branch:
#   yes
# The child will run briefly, fill the output queue, and block in write().
# Prove it (Linux):
ps -o pid,stat,wchan,comm -p <child pid>     # STAT shows S; wchan shows a tty wait
cat /proc/<pid>/stack 2>/dev/null || true

Failure mode: an emulator that renders synchronously with reading. Under heavy output it stops reading, the child blocks, and the user reports "my terminal froze." The fix is to always drain the master into a buffer and render on a timer.


Putting It Together: The Allocation Sequence You Will Write

 PARENT (your emulator)                     KERNEL                    CHILD
 ──────────────────────                     ──────                    ─────
 posix_openpt(O_RDWR|O_NOCTTY) ────────────▶ allocate pair
        ◀───────────────────────────────────  master fd
 grantpt(master)               ────────────▶ chown/chmod slave
 unlockpt(master)              ────────────▶ clear lock
 ptsname(master) ──────────────────────────▶ "/dev/pts/7"
 ioctl(master, TIOCSWINSZ, &ws)────────────▶ store winsize
 fork() ───────────────────────────────────────────────────────────▶ (child begins)
 close(slave_if_any)                                                setsid()
 keep master                                                        open("/dev/pts/7")
 poll(master)                                                       ioctl(slave,TIOCSCTTY,0)
                                                                    dup2(slave,0/1/2)
                                                                    close(slave); close(master)
                                                                    execve("/bin/bash")

Every one of those lines is a place a bug can hide, and Lab 2 walks each one.


Validation / Self-check

  1. Define a TTY and a PTY without using the word "terminal" in either definition.
  2. Which end of a PTY pair has a device file, and which end can be a controlling terminal?
  3. Where does echo happen, and how many processes are involved in the round trip?
  4. Name the four calls in the POSIX PTY allocation sequence and what each one does.
  5. What does unlockpt protect against, and what error do you get if you skip it?
  6. Why must the parent close the slave fd? What exact symptom appears if it does not?
  7. On Linux, what does read(master) return after the child exits? On macOS? Why do they differ, and how should your code handle both?
  8. What happens to the child if your emulator stops reading the master? Name the syscall it blocks in.
  9. Your emulator calls tcsetattr(master, TCSAFLUSH, &raw). Whose behavior did you just change?
  10. Why does this book tell you to avoid forkpty() until after Lab 2?

Next: File Descriptors, fork & exec.