Lesson 3: PTY & Terminal Driver
The pseudoterminal (PTY) is the kernel abstraction that makes terminal emulators possible. It is the bridge between a GUI application (Ghostty) and the command-line programs running inside it. After this lesson, you understand the full byte path from a keystroke in Ghostty to a character appearing in vim.
PTY Architecture
A PTY is a pair of character devices:
flowchart LR
subgraph Emulator["Terminal Emulator"]
G["Ghostty<br/>reads/writes master fd"]
end
subgraph Kernel["Kernel"]
M["master fd<br/>/dev/ptmx"]
LD["Line Discipline"]
S["slave fd<br/>/dev/pts/N"]
end
subgraph Child["Child Process"]
Z["zsh / vim<br/>fds 0,1,2"]
end
G <-->|"read/write"| M
M --- LD
LD --- S
S <-->|"stdin/stdout/stderr"| Z
- Master fd (
/dev/ptmx): the emulator's end. Writing to master sends bytes to the slave (like typing). Reading from master receives bytes the slave wrote (output to display). - Slave fd (
/dev/pts/N): the child process's end. Writing to slave sends data to the master (program output). Reading from slave receives data written to master (user input).
The PTY is bidirectional but asymmetric. The master is controlled by the emulator. The slave is given to the child process as its stdin/stdout/stderr. The line discipline sits between them in the kernel, transforming the raw byte stream.
Creating a PTY
#define _XOPEN_SOURCE 600
#include <stdlib.h>
#include <fcntl.h>
int master_fd, slave_fd;
pid_t pid;
master_fd = posix_openpt(O_RDWR | O_NOCTTY);
grantpt(master_fd);
unlockpt(master_fd);
char *slave_name = ptsname(master_fd);
pid = fork();
if (pid == 0) {
// Child
setsid(); // new session, no controlling terminal
slave_fd = open(slave_name, O_RDWR);
close(master_fd);
dup2(slave_fd, STDIN_FILENO);
dup2(slave_fd, STDOUT_FILENO);
dup2(slave_fd, STDERR_FILENO);
close(slave_fd);
execlp("zsh", "zsh", NULL);
}
// Parent: read/write master_fd to communicate with child
Key steps:
posix_openpt()opens the master, returns master fdgrantpt()/unlockpt()set permissions on the slavefork()creates the childsetsid()creates a new session (required for the slave to become the controlling terminal)- Child opens the slave and dups it to 0/1/2
- Parent communicates via master fd
O_NOCTTY in posix_openpt prevents the master from becoming a controlling terminal. Only the slave side should be the controlling terminal for the foreground process group.
The Line Discipline
The line discipline is a kernel module that sits between the master and slave ends of the PTY. It processes the byte stream before the child process sees it.
graph LR
A[Master fd] --> B[Line Discipline]
B --> C[Slave fd]
C --> B
B --> A
What the line discipline does:
- Buffering and line editing (canonical mode): in cooked mode, the line discipline buffers characters until Enter is pressed. It handles backspace (erase previous character), Ctrl+W (erase word), Ctrl+U (erase line). The program only receives complete lines.
- Echo: in cooked mode, characters typed are echoed back to the master (so the terminal emulator can display them). In raw mode, echo is disabled — the program handles display.
- Signal generation: specific control characters generate signals: Ctrl+C → SIGINT (
VINTR), Ctrl+\ → SIGQUIT (VQUIT), Ctrl+Z → SIGTSTP (VSUSP). - Flow control: Ctrl+S (XOFF) pauses output. Ctrl+Q (XON) resumes. The terminal driver inserts XOFF when the buffer is nearly full.
- Carriage return processing:
\n→\r\nconversion (ONLCR).
The line discipline is the terminal's "default UI." In cooked mode, it provides line editing, echo, and signal handling — a basic command-line interface without any program involvement. Terminal applications (vim, tmux, Ghostty) turn all of this off (raw mode) because they implement their own UI.
termios Deep Dive
The termios structure controls the line discipline's behavior:
struct termios {
tcflag_t c_iflag; // input flags
tcflag_t c_oflag; // output flags
tcflag_t c_cflag; // control flags
tcflag_t c_lflag; // local flags (the important ones)
cc_t c_cc[NCCS]; // special control characters
};
The lflag (local flags) — what you actually care about
| Flag | Cooked | Raw | Effect |
|---|---|---|---|
ICANON |
ON | OFF | Canonical (line-buffered) mode |
ECHO |
ON | OFF | Echo typed characters |
ECHOE |
ON | OFF | Echo backspace as space-backspace (visual erase) |
ECHOK |
ON | OFF | Echo newline after Ctrl+U (kill line) |
ECHONL |
ON | OFF | Echo newline even when ECHO is off |
ISIG |
ON | OFF | Enable signal-generating characters |
IEXTEN |
ON | OFF | Enable implementation-defined input processing |
TOSTOP |
OFF | OFF | Send SIGTTOU to background processes that write to terminal |
Raw mode setup (what Ghostty does)
struct termios raw;
tcgetattr(slave_fd, &raw);
cfmakeraw(&raw); // clears everything useful
// Or manually:
raw.c_lflag &= ~(ECHO | ICANON | ISIG | IEXTEN);
raw.c_iflag &= ~(IXON | ICRNL | BRKINT | INPCK | ISTRIP);
raw.c_oflag &= ~(OPOST);
raw.c_cflag |= (CS8);
raw.c_cc[VMIN] = 1; // read() returns after 1 byte
raw.c_cc[VTIME] = 0; // no timeout
tcsetattr(slave_fd, TCSAFLUSH, &raw);
In raw mode:
- No line buffering (
ICANONoff):read()returns immediately for each byte - No echo (
ECHOoff): the program decides what to display - No signal generation (
ISIGoff): Ctrl+C is just a byte (0x03), not SIGINT - No flow control (
IXONoff): Ctrl+S/Q are just bytes - No output processing (
OPOSToff):\nstays\n, no\r\nconversion
VMIN=1, VTIME=0 is critical for terminal emulators. Without it, read() on the master fd blocks until a full buffer is available. With it, each byte is available immediately — the emulator can respond to each keystroke without delay.
Process Groups and Sessions
Terminal job control depends on three kernel concepts:
Session
A session is a collection of process groups. Each session has at most one controlling terminal. setsid() creates a new session and makes the calling process the session leader.
Process group
A process group is a set of processes that should receive signals together. setpgid(pid, pgid) sets the process group. The foreground process group receives terminal-generated signals (SIGINT, SIGTSTP). Background process groups receive SIGTTIN/SIGTTOU if they try to read/write the terminal.
Controlling terminal
Each session can have one controlling terminal (the slave side of a PTY). tcsetpgrp(fd, pgid) sets which process group is in the foreground. The foreground group receives keyboard signals. Only the foreground group can read from the terminal.
graph TD
subgraph Session
A[Session Leader<br>shell] --> B[Foreground Group<br>pgid=100<br>vim]
A --> C[Background Group<br>pgid=200<br>sleep 100 &]
end
A -.-> D[Controlling Terminal<br>/dev/pts/5]
Job Control Mechanics
Ctrl+Z (suspend)
- User presses Ctrl+Z
- Line discipline receives
0x1A(ASCII SUB) - If
ISIGis set, line discipline sends SIGTSTP to the foreground process group - Default action: suspend all processes in the group (state changes to TASK_STOPPED)
- Parent (shell) receives SIGCHLD, discovers child stopped (WIFSTOPPED)
- Shell puts the job in background, updates its job list
- Shell calls
tcsetpgrp()to reclaim terminal control
fg command
- Shell calls
tcsetpgrp(shell_terminal, job_pgid)— gives terminal to the job - Shell calls
kill(-job_pgid, SIGCONT)— resumes the stopped processes - Shell calls
waitpid()— blocks until the job finishes or stops again
bg command
- Shell calls
kill(-job_pgid, SIGCONT)— resumes the stopped processes - Shell does NOT wait — the job runs in the background
- If the job tries to read from terminal: kernel sends SIGTTIN, which stops it again
Ghostty relevance: src/termio/Exec.zig calls setsid() in the child process before exec. This creates a new session and detaches from the emulator's own terminal. The child's process group management is handled by the shell running inside the PTY, not by Ghostty itself.
Window Size
struct winsize {
unsigned short ws_row; // rows in characters
unsigned short ws_col; // columns in characters
unsigned short ws_xpixel; // width in pixels
unsigned short ws_ypixel; // height in pixels
};
ioctl(master_fd, TIOCSWINSZ, &ws); // set size
ioctl(master_fd, TIOCGWINSZ, &ws); // get size
When the emulator window resizes:
- Ghostty calculates new rows/cols based on font metrics and pixel dimensions
- Calls
ioctl(master_fd, TIOCSWINSZ, &new_size) - Kernel sends SIGWINCH to the foreground process group
- Applications (vim, tmux, less) catch SIGWINCH and re-render
The font metrics determine the grid. cols = pixel_width / glyph_width. rows = pixel_height / glyph_height. The emulator must recalculate these on every resize event and every font change.
ptycat Project
Build a PTY inspector: open a PTY, spawn a shell inside, and hex-dump the byte stream flowing through the master fd. This is your first look at the raw escape sequences that terminal programs emit.
// ptycat: open pty, fork shell, print hexdump of master reads
master_fd = posix_openpt(O_RDWR | O_NOCTTY);
grantpt(master_fd);
unlockpt(master_fd);
pid = fork();
if (pid == 0) {
setsid();
slave_fd = open(ptsname(master_fd), O_RDWR);
close(master_fd);
dup2(slave_fd, 0); dup2(slave_fd, 1); dup2(slave_fd, 2);
close(slave_fd);
execlp("zsh", "zsh", NULL);
}
// Set raw mode on the slave (important!)
// Read from master_fd, hexdump each byte, write user input to master_fd
while (1) {
char buf[4096];
int n = read(master_fd, buf, sizeof(buf));
for (int i = 0; i < n; i++) {
printf("%02x ", (unsigned char)buf[i]);
if (i % 16 == 15) printf("\n");
}
}
Run it, type some commands, and watch the escape sequences fly. Then run vim and watch the bytes explode.
What you'll see: ls\r\n (command echo), \e[0m\e[36m\e[1m (color codes), \e[?25l (hide cursor), \e[?1049h (switch to alternate screen in vim). Every one of these bytes is a terminal command. Lesson 6 covers parsing them.
Ghostty Source to Study
| File | What to study |
|---|---|
src/termio/Termio.zig |
termios configuration, raw mode setup, TIOCSWINSZ |
src/termio/Exec.zig |
process group and session management, setsid |
src/termio/StreamHandler.zig |
reading bytes from master fd, write path |
Self-Check
Can you:
- Draw the full PTY architecture (master, slave, line discipline, child process)
- Write the
openpty()+fork()+dup2()+exec()sequence from memory - Explain the difference between cooked mode and raw mode — name at least 5 flags that differ
- Explain why
VMIN=1, VTIME=0matters for a terminal emulator - Trace the full pathway of Ctrl+Z: keystroke → line discipline → SIGTSTP → process group → shell notification
- Explain why
setsid()is necessary when creating a PTY child process - Write code that responds to window resize and propagates
TIOCSWINSZto the slave