// lesson: the-terminal-is-a-file
The Terminal Is a File
Run a program in a terminal and three file descriptors are already open:
0 (stdin), 1 (stdout), 2 (stderr). Usually all three point at the same
kernel object โ a tty device. isatty(0) asks whether fd 0 is one:
#include <unistd.h>
if (!isatty(STDIN_FILENO)) {
// stdin is a pipe or a file โ an interactive editor can't run here.
}
The name is a fossil: tty = teletype, the electromechanical printing
terminals of the 1960s. The terminal "screen" your editor will draw on is,
as far as the kernel is concerned, a serial device you write(2) bytes to
and read(2) bytes from. There is no "move the cursor" system call, no
"key event" structure. Bytes in, bytes out. Everything a full-screen
program does โ vim, htop, less โ is built on exactly two tricks:
- Output: certain byte sequences, when written to the terminal, are interpreted as commands (move cursor, clear line, change color) instead of text. Lesson 3 is about those.
- Input: keys arrive as bytes, and you reconfigure the tty so they arrive immediately and unmodified. That is this lesson.
Canonical mode: the line discipline is editing before you are
By default a tty is in canonical mode. The kernel's line discipline
โ a layer between the device and your process โ buffers input until the
user presses Enter, and implements its own primitive line editor:
backspace erases, Ctrl-U kills the line, and only the finished line is
delivered to read(). That's why a plain std::getline program feels
"line based": the kernel is doing the editing, not the C++ runtime.
The line discipline also echoes every typed character back to the
screen, translates carriage returns to newlines, and turns certain bytes
into signals: Ctrl-C sends SIGINT, Ctrl-Z sends SIGTSTP, Ctrl-\ sends
SIGQUIT. Useful defaults for a shell session; fatal for an editor. If the
kernel echoes keystrokes, every j the user types to move down gets
printed into your carefully painted screen. If Ctrl-C kills you, you can't
bind it. An editor needs the terminal in raw mode: every byte
delivered as typed, nothing echoed, nothing translated, no signals.
termios, flag by flag
The tty's configuration lives in a struct termios (POSIX,
<termios.h>), read with tcgetattr(fd, &t) and written with
tcsetattr(fd, TCSAFLUSH, &t). It has four flag words โ input (c_iflag),
output (c_oflag), control (c_cflag), local (c_lflag) โ plus an array
c_cc of control characters and thresholds. "Raw mode" is not a single
switch (well, non-portably there is cfmakeraw); it is a specific set of
flags to clear, and knowing why each one matters is knowing what the
terminal actually does for you:
ECHO(local): kernel echoes input back to the display. Off โ the editor decides what appears on screen.ICANON(local): canonical (line-buffered) mode. Off โread()returns bytes as they are typed, not lines.ISIG(local): Ctrl-C โ SIGINT, Ctrl-Z โ SIGTSTP. Off โ those bytes (0x03, 0x1A) are delivered to you like any other key, so vi-style bindings can use them.IXON(input): software flow control โ Ctrl-S freezes output until Ctrl-Q resumes it. A gift to 1970s serial links, a trap today (everyone has "frozen" a terminal with a stray Ctrl-S). Off, so Ctrl-S is just a byte.IEXTEN(local): extended input processing โ on many systems Ctrl-V means "quote the next character" (and on macOS Ctrl-O is swallowed). Off.ICRNL(input): translate carriage return (0x0D, what the Enter key actually sends) into newline (0x0A). Off โ you want to see the real byte, and to tell Enter (0x0D) apart from Ctrl-J (0x0A).OPOST(output): output post-processing, in practice\nโ\r\ntranslation. Off โ from now on, when you want the cursor at the start of the next line you write"\r\n"yourself. (Forget this and your output staircases across the screen.)BRKINT,INPCK,ISTRIP(input): break-condition SIGINT, parity checking, and stripping the 8th bit. All relics of real serial hardware; all off, both for tradition and becauseISTRIPwould mangle UTF-8.CS8(control): a two-bit field, set rather than cleared โ 8-bit characters. Almost certainly already set.
Two entries of c_cc control when read() returns in non-canonical
mode, and they are worth internalizing because they define your event
loop's personality:
VMINโ minimum bytes beforeread()may return.VTIMEโ read timeout in tenths of a second.
VMIN=1, VTIME=0 makes read() block until at least one byte arrives โ
a pure event-driven loop. VMIN=0, VTIME=1 makes read() return after
at most 100 ms even with no input โ a polling loop that gives you a
natural place to handle "no key pressed" work (like noticing the window
was resized, or timing out a lone ESC โ a plot point in the next lesson).
We'll use VMIN=0, VTIME=1.
In real code, entering raw mode looks like this โ and note that we modify a copy of the original settings rather than building a termios from zero, because the struct has fields we don't understand and must not clobber:
#include <termios.h>
#include <unistd.h>
termios orig; // saved so we can restore at exit
void enter_raw() {
tcgetattr(STDIN_FILENO, &orig);
termios raw = orig; // copy, then surgically edit
raw.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON);
raw.c_oflag &= ~(OPOST);
raw.c_cflag |= CS8;
raw.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG);
raw.c_cc[VMIN] = 0;
raw.c_cc[VTIME] = 1;
tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}
TCSAFLUSH applies the change after pending output drains and discards
unread input โ so a half-typed line from before raw mode doesn't leak
into the editor as phantom keystrokes.
Leaving the terminal the way you found it
Exit your editor without restoring the original termios and the user's
shell inherits raw mode: nothing echoes, Enter doesn't work, and they're
typing reset blind. Restoration must happen on every exit path โ
normal quit, early return, exception. C++ has a dedicated idiom for
"must happen on every exit path": RAII (Resource Acquisition Is
Initialization). Acquire in a constructor, release in the destructor, and
let scope exit โ however it happens โ do the bookkeeping:
class RawMode {
public:
RawMode() {
tcgetattr(STDIN_FILENO, &orig_);
termios raw = orig_;
make_raw(raw); // this lesson's challenge
tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}
~RawMode() { tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_); }
RawMode(const RawMode&) = delete; // copying makes no sense:
RawMode& operator=(const RawMode&) = delete; // who restores, and when?
private:
termios orig_;
};
int main() {
RawMode raw; // terminal is raw from here on
run_editor(); // may return early, may throw
} // ...restored here, no matter what
A raw-mode guard is the canonical RAII example for a reason: the resource isn't memory, it's terminal state, and leaking it hurts a human immediately. Two design points worth dwelling on:
- Copying is deleted. If two
RawModeobjects existed, the second's constructor would save anorig_that is already raw โ restoring it would "restore" rawness. Deleting the copy operations makes the broken program not compile. This is the rule of five in action: since we wrote a destructor with side effects, we must decide what copy/move mean, and here the right answer is "they don't". - The flip side is the rule of zero: classes that don't own a
resource should define none of the five special members and let the
compiler-generated ones work. Most classes you write in this course โ
positions, ranges, buffers built on
std::stringโ follow the rule of zero. Only resource guards need the full ceremony.
The exit paths RAII cannot catch
Destructors run on returns and exceptions โ but not on std::abort, not
on _exit, and not when a signal kills the process. Two signals need
explicit handling in a real editor, and both are pure terminal lore worth
knowing:
SIGTSTP/SIGCONT(Ctrl-Z suspend,fgresume): withISIGoff you won't get Ctrl-Z from the keyboard, butkill -TSTPcan still arrive. The polite dance: on SIGTSTP, restore the original termios, then re-raise SIGTSTP with default disposition so the shell actually suspends you; on SIGCONT, re-enter raw mode and repaint the whole screen (the shell owned the display while you slept).- Crash safety: register the restore with
atexit, and keep signal handler paths async-signal-safe โtcsetattrandwriteare on the safe list;std::coutandmallocare not.
The grader has no tty (and that's a feature)
Everything above is glue you'll assemble in your own editor binary and
verify by running it in a real terminal. The grader, though, runs your
code headless โ no tty at all โ which enforces a discipline this whole
course is built on: separate the OS glue from the logic, and make the
logic pure. The first challenge tests your raw-mode flag surgery
against a stand-in Termios struct with the same field names and (Linux)
flag values as the real one โ swap Termios for termios and the
identical function body drops into your editor. The second distills the
RAII guard into a reusable, tty-free scope guard.
โบ Raw Mode, Flag by Flag
10 ptsImplement make_raw, which edits a Termios in place to configure raw
mode exactly as specified:
- Clear in
c_lflag:kECHO,kICANON,kISIG,kIEXTEN. - Clear in
c_iflag:kBRKINT,kICRNL,kINPCK,kISTRIP,kIXON. - Clear in
c_oflag:kOPOST. - Set in
c_cflag:kCS8(set the whole two-bit field). - Set
c_cc[kVMIN] = 0andc_cc[kVTIME] = 1.
Every bit you were not told to touch must survive unchanged โ the tests
plant unrelated bits in every flag word and check they're still there.
This mirrors the real API contract: termios carries settings you don't
own, so you edit, never overwrite.
Log in to submit a solution and earn points.
โบ A Scope Guard
10 ptsImplement ScopedAction: it stores a callable at construction and
invokes it exactly once when the guard dies โ unless disarmed. This is
the lesson's RawMode with the terminal dependency injected, and it's
the shape of every "undo this on scope exit" problem you'll meet later
(leave the alternate screen, re-show the cursor, restore termios before
suspending).
Unlike RawMode, this guard is movable: a factory function like
ScopedAction enter_raw_mode() must be able to return the guard to its
caller, handing off responsibility for the cleanup. That makes it a
worked example of the rule of five: destructor, deleted copies, and move
operations that keep the invariant the action runs exactly once.
Semantics to implement:
ScopedAction(std::function<void()> f)storesf; the destructor invokes it if the guard is still armed.release()disarms: after it, the destructor does nothing.- Move constructor: transfers the action; the moved-from guard is disarmed (the action must not run twice).
- Move assignment: the target first runs its own pending action (it is being destroyed, morally), then takes over the source's; the source is disarmed. Self-assignment is a no-op.
- Copying is deleted โ two owners of one cleanup is the bug this class exists to prevent.
armed()reports whether the destructor would fire.
Log in to submit a solution and earn points.