termios and the Line Discipline: Canonical Mode, Raw Mode, and Echo
The line discipline is the kernel code between a terminal device and the process reading it. It is
the same code for a serial port, a virtual console, and a PTY. termios is the structure that
configures it. Between them they explain: why your read() waits for Enter, why you see what you
type, why Ctrl+C interrupts, why \n prints as \r\n, and why every full-screen program's first act
is to reconfigure the terminal.
Concept: The Line Discipline
1. What problem it solves
A human at a keyboard makes mistakes and needs to fix them before submitting. In 1975 that could not be the application's job — every application would have had to reimplement backspace, and on a printing teletype the application could not even see the screen. So the kernel buffers a line, handles editing, echoes characters, and hands the finished line to the program.
It also solves a second problem: how do you interrupt a runaway program when your only channel to the machine is a byte stream the program is reading? Answer: the kernel intercepts certain bytes and turns them into signals before the program ever sees them.
2. Where it exists in the OS
Kernel. On Linux, the default discipline is N_TTY (drivers/tty/n_tty.c). Other disciplines
exist (N_SLIP, N_PPP) that turn a serial line into a network interface — proof that the
discipline is a swappable layer, not part of the device.
┌──────────────── LINE DISCIPLINE (N_TTY) ───────────────┐
write(master) ──────▶│ INPUT side: │
│ c_iflag: IGNCR, ICRNL, INLCR, ISTRIP, IXON, IUTF8 │
│ c_lflag: ICANON, ECHO, ECHOE, ISIG, IEXTEN │
│ c_cc: VINTR VQUIT VSUSP VERASE VKILL VEOF │
│ VMIN VTIME │
│ │
│ ┌─ canonical line buffer ─┐ → released on NL/EOF │──▶ read(slave)
│ └─────────────────────────┘ │
│ signals generated here ────────────────▶ fg pgroup │
│ echo copies bytes to the OUTPUT side ──┐ │
│ │ │
read(master) ◀──────│ OUTPUT side: ▼ │◀── write(slave)
│ c_oflag: OPOST, ONLCR, OCRNL, ONLRET, TAB3 │
└────────────────────────────────────────────────────────┘
3. Who owns or interacts with it
| Actor | Interaction |
|---|---|
| Kernel | Runs it |
| Any process with an fd on the tty | Reads/writes the configuration with tcgetattr/tcsetattr |
| The shell | Sets it up for itself (readline puts it in a raw-ish mode) and restores it before running a child |
Full-screen programs (vim, less, top) | Save it, set raw mode, restore it on exit |
| Your emulator | Sets its own outer terminal to raw. Should generally leave the PTY's settings alone. |
Warning: There is exactly one
termiosper PTY pair, reachable from either fd. When your emulator callstcsetattr(master, ...)it is changing what the child experiences. Almost always a bug. The settings you want to change are on fd 0 — your outer terminal.
4. The termios structure
struct termios {
tcflag_t c_iflag; // input modes
tcflag_t c_oflag; // output modes
tcflag_t c_cflag; // control modes (baud, bits, parity — mostly vestigial on a PTY)
tcflag_t c_lflag; // "local" modes — the interesting ones
cc_t c_cc[NCCS]; // control characters
};
c_lflag — local modes (the ones that matter)
| Flag | Meaning | Set in canonical? | Set in raw? |
|---|---|---|---|
ICANON | Canonical mode: buffer input into lines; read() returns a line at a time; VERASE/VKILL do line editing | ✅ | ❌ |
ECHO | Echo input characters back to the output side | ✅ | ❌ |
ECHOE | Echo VERASE as backspace-space-backspace (visually erase) | ✅ | ❌ |
ECHOK | Echo VKILL by killing the line | ✅ | ❌ |
ECHONL | Echo NL even when ECHO is off | — | ❌ |
ISIG | Generate SIGINT/SIGQUIT/SIGTSTP from VINTR/VQUIT/VSUSP | ✅ | ❌ |
IEXTEN | Enable implementation-defined processing (VLNEXT ^V, VDISCARD ^O) | ✅ | ❌ |
NOFLSH | Do not flush the input/output queues on SIGINT/SIGQUIT/SIGTSTP | ❌ | — |
TOSTOP | Send SIGTTOU when a background process writes | ❌ (default off) | — |
c_iflag — input modes
| Flag | Meaning |
|---|---|
ICRNL | Translate incoming CR (0x0D) to NL (0x0A). This is why pressing Enter, which sends CR, results in a newline. |
INLCR | Translate incoming NL to CR |
IGNCR | Discard incoming CR entirely |
ISTRIP | Strip the 8th bit (7-bit legacy) |
IXON/IXOFF | Software flow control: ^S (VSTOP) pauses output, ^Q (VSTART) resumes |
IUTF8 | Tell the discipline input is UTF-8 so VERASE erases a whole multi-byte character (Linux) |
BRKINT, IGNBRK, PARMRK, INPCK | Break and parity handling — serial-line legacy |
Tip:
IXONis why^Sappears to freeze your terminal. Turning it off (stty -ixon) is the standard fix, and it is also how you free^Sfor use in an editor.
c_oflag — output modes
| Flag | Meaning |
|---|---|
OPOST | Enable output processing at all. Clearing it disables everything below. |
ONLCR | Translate outgoing NL to CR NL. This is why programs can print "\n" and get a proper newline. |
OCRNL | Translate outgoing CR to NL |
ONLRET | NL also performs the carriage return |
TAB3/XTABS | Expand tabs to spaces on output |
c_cc[] — control characters
| Index | Default | Role |
|---|---|---|
VINTR | ^C (0x03) | → SIGINT (if ISIG) |
VQUIT | ^\ (0x1C) | → SIGQUIT + core |
VSUSP | ^Z (0x1A) | → SIGTSTP |
VERASE | ^? (0x7F DEL) | Erase the previous character (canonical) |
VKILL | ^U (0x15) | Erase the whole line (canonical) |
VEOF | ^D (0x04) | End of input: read() returns immediately with whatever is buffered — 0 bytes if the line is empty, hence "EOF" |
VEOL | — | An extra line terminator |
VSTART/VSTOP | ^Q/^S | Flow control |
VLNEXT | ^V (0x16) | Take the next character literally (needs IEXTEN) |
VREPRINT | ^R (0x12) | Reprint the line |
VWERASE | ^W (0x17) | Erase the previous word |
VMIN | 1 | Non-canonical only: minimum bytes for read() to return |
VTIME | 0 | Non-canonical only: timeout in deciseconds |
Note:
VMINandVTIMEshare storage withVEOFandVEOLon many systems. That is why they only have meaning whenICANONis off — the same array slots mean different things in the two modes. Do not set them and expect them to persist through a mode change.
The four VMIN/VTIME combinations, which you will need in Lab 1:
VMIN | VTIME | read() behavior |
|---|---|---|
| 0 | 0 | Poll: return immediately with whatever is available, possibly 0 bytes |
| >0 | 0 | Block until at least VMIN bytes are available |
| 0 | >0 | Block until at least 1 byte, or VTIME deciseconds elapse |
| >0 | >0 | Inter-byte timer: block for the first byte, then return when VMIN bytes arrive or VTIME passes between bytes |
VMIN=1, VTIME=0 is what you want for an interactive raw-mode reader. VMIN=0, VTIME=1 is what
you want if you must poll with a 100 ms timeout.
Canonical Mode vs. Raw Mode
Canonical (cooked) mode — the default
you type: h e l l o ^H(0x7f) ! Enter(0x0d)
↓
LINE DISCIPLINE:
'h','e','l','l','o' → appended to line buffer, ECHOed
0x7f (VERASE) → removes 'o' from the buffer; ECHOE emits "\b \b" to visually erase
'!' → appended, echoed
0x0d → ICRNL → 0x0a → LINE TERMINATOR: release the buffer
↓
the program's read(0, buf, 1024) returns 6 bytes: "hell!\n"
The program never saw the backspace. It never saw the individual keystrokes. It got one line.
Raw mode
you type: h
↓
LINE DISCIPLINE: ICANON off → no buffering. ECHO off → no echo. ISIG off → ^C is data.
↓
the program's read(0, buf, 1024) returns 1 byte: "h" immediately
The program sees every keystroke as it happens, must echo it itself if it wants it visible, must
implement its own backspace, and gets 0x03 as data when you press Ctrl+C.
The comparison
| Behavior | Canonical | Raw |
|---|---|---|
read() returns | On a line terminator | As soon as VMIN bytes are available |
| Backspace | Kernel edits the buffer | Your program's problem |
| Echo | Kernel does it | Your program's problem |
^C | SIGINT to the foreground group | Byte 0x03 delivered as data |
^Z | SIGTSTP | Byte 0x1A as data |
^D | Ends the line / signals EOF | Byte 0x04 as data |
\n on output | ONLCR makes it \r\n | You emit \r\n yourself |
| Who uses it | read builtin, cat, simple filters | vim, less, top, shells with line editors, your emulator |
Note: "Raw mode" is not a single flag; it is a set of changes.
cfmakeraw()applies them all at once. Programs frequently want something in between — bash's readline, for example, leavesISIGon so that^Cstill interrupts, while turning offICANONandECHOso it can do its own editing. Do not assume "not canonical" means "cfmakeraw".
What cfmakeraw() actually does
termios.c_iflag &= ~(IGNBRK|BRKINT|PARMRK|ISTRIP|INLCR|IGNCR|ICRNL|IXON);
termios.c_oflag &= ~OPOST;
termios.c_lflag &= ~(ECHO|ECHONL|ICANON|ISIG|IEXTEN);
termios.c_cflag &= ~(CSIZE|PARENB);
termios.c_cflag |= CS8;
Note it does not set VMIN/VTIME. You must do that yourself:
termios.c_cc[VMIN] = 1;
termios.c_cc[VTIME] = 0;
Forgetting this is the classic "my raw-mode program returns 0 bytes in a busy loop" bug — inherited
VMIN=0 from VEOF's slot.
Reading and Writing termios
tcgetattr(fd, &t); // read current settings
tcsetattr(fd, TCSANOW, &t); // apply immediately
tcsetattr(fd, TCSADRAIN, &t); // apply after pending output drains
tcsetattr(fd, TCSAFLUSH, &t); // drain output, DISCARD pending input, then apply
Use TCSAFLUSH when entering raw mode. Otherwise input typed before the switch is interpreted
under the new rules — you get a stray byte from a partially-typed line.
Warning:
tcsetattrreturns success if it changed any of the requested settings, not all of them. If you must be certain,tcgetattrafterwards and compare. This matters when atermiosflag is unsupported on the device.
In Rust, at the libc level
#![allow(unused)] fn main() { use std::io; use std::mem::MaybeUninit; use std::os::fd::{AsRawFd, BorrowedFd}; /// RAII guard: restores the terminal on Drop, including during unwind. pub struct RawModeGuard { fd: i32, original: libc::termios, } impl RawModeGuard { pub fn enter(fd: BorrowedFd<'_>) -> io::Result<Self> { let fd = fd.as_raw_fd(); let mut original = MaybeUninit::<libc::termios>::uninit(); // SAFETY: fd is a valid borrowed fd; tcgetattr fully initializes the struct on success. if unsafe { libc::tcgetattr(fd, original.as_mut_ptr()) } != 0 { return Err(io::Error::last_os_error()); } let original = unsafe { original.assume_init() }; let mut raw = original; // SAFETY: raw is a fully initialized termios. unsafe { libc::cfmakeraw(&mut raw) }; // cfmakeraw does NOT set these — a read() would otherwise spin returning 0. raw.c_cc[libc::VMIN] = 1; // block until at least one byte raw.c_cc[libc::VTIME] = 0; // no timeout // TCSAFLUSH: drain output, discard input typed before the switch. if unsafe { libc::tcsetattr(fd, libc::TCSAFLUSH, &raw) } != 0 { return Err(io::Error::last_os_error()); } Ok(Self { fd, original }) } } impl Drop for RawModeGuard { fn drop(&mut self) { // Best effort: there is nothing useful to do if this fails, and Drop must not panic. unsafe { libc::tcsetattr(self.fd, libc::TCSAFLUSH, &self.original) }; } } }
Three things to notice:
MaybeUninit, notmem::zeroed(). A zeroedtermiosis not a valid one, and constructing an invalid value is UB even if you overwrite it.tcgetattrinitializes it.- The
Dropimpl is the whole point.Dropruns during unwind, so a panic restores the terminal. It does not run onstd::process::exit, onabort, or on a fatal signal — see the next section. Dropmust not panic. Ignoring the return value here is correct.
Restoring on every exit path
A Drop guard is necessary but not sufficient. All four of these must restore the terminal:
| Exit path | Handled by |
|---|---|
Normal return from main | Drop |
| Panic (unwind) | Drop |
panic = "abort" | A panic::set_hook that restores before aborting |
std::process::exit() | Do not call it while a guard is alive; or restore explicitly first |
Fatal signal (SIGTERM, SIGHUP) | A handler that restores and re-raises with the default disposition |
SIGKILL | Nothing. The terminal is left raw. This is why stty sane exists. |
#![allow(unused)] fn main() { // Before entering raw mode: let original_for_panic = original; // Copy std::panic::set_hook(Box::new(move |info| { // SAFETY: restoring termios is async-signal-safe enough for a panic hook. unsafe { libc::tcsetattr(0, libc::TCSAFLUSH, &original_for_panic) }; eprintln!("\r\npanic: {info}\r\n"); // note the \r — we may still be in raw mode })); }
Tip: When (not if) you kill a raw-mode program with
kill -9and your shell becomes unusable, typestty saneand press Enter — even though you cannot see what you are typing, and even though Enter may need to beCtrl+JbecauseICRNLis off. This will happen to you in Lab 1.
Experiments
Experiment A — see the flags change
stty -a # baseline: note icanon, echo, isig, opost, onlcr
stty raw -echo
stty -a # note: -icanon -echo -isig -opost (use Ctrl+J for Enter now)
stty sane # restore
Predict first: after stty raw -echo, what happens when you type ls and press Enter?
Experiment B — echo is the kernel's, not the shell's
cat > /dev/null # cat reads; the kernel echoes. Type — you see characters.
# ^D to exit
stty -echo
cat > /dev/null # type — you see NOTHING. cat still receives the bytes.
# ^D, then:
stty echo
The shell was never involved in making your characters appear. This is the experiment that proves who echoes.
Experiment C — canonical buffering
# Canonical: the read does not return until Enter.
cat
# type "hello" (no Enter) — nothing is echoed BACK by cat; the kernel echoed it.
# press Enter — now cat prints "hello".
# Non-canonical, one byte at a time:
stty -icanon min 1 time 0
cat
# type "h" — cat immediately prints "h" (so you see it TWICE: kernel echo + cat's output)
stty sane
Experiment D — \n vs \r\n
printf 'a\nb\n' # normal: ONLCR turns each \n into \r\n
stty -opost
printf 'a\nb\n' # staircase: \n only moves down, not to column 0
stty sane
This is exactly the bug you will hit in Lab 1 when your raw-mode program prints with \n.
Experiment E — flow control
yes | head -1000000 > /dev/null &
yes # flood the screen
# press ^S → output freezes (IXON: VSTOP)
# press ^Q → resumes
# ^C to stop
stty -ixon # now ^S is just a byte
Failure Modes
| Mistake | Symptom | Fix |
|---|---|---|
cfmakeraw without setting VMIN/VTIME | read() returns 0 in a hot loop; 100% CPU | c_cc[VMIN]=1; c_cc[VTIME]=0 |
TCSANOW instead of TCSAFLUSH on entry | A stray byte from before the switch | Use TCSAFLUSH |
| No restore on panic | Unusable shell after any crash | Drop guard + panic::set_hook |
Printing \n in raw mode | Staircase output | Print \r\n |
Setting termios on the PTY master | You changed the child's terminal | Set it on fd 0 instead |
| Setting raw mode on the inner PTY "to be safe" | Breaks the shell's own line editing and job control | Leave the PTY's termios alone |
Assuming Backspace sends 0x08 | Your erase handling never fires | Most terminals send 0x7F (DEL); VERASE defaults to it |
Not handling EINTR from read | Spurious failures whenever a signal arrives | Retry on EINTR |
Using mem::zeroed() for termios | UB; occasionally wrong flags | MaybeUninit + tcgetattr |
Validation / Self-check
- Name the four
termiosflag fields and one flag from each that changes visible behavior. - In canonical mode, name every step between pressing
aand a program'sread()returning. - Which component performs echo, and how do you prove it in one command?
- What exactly does
cfmakeraw()change, and what does it not set that you must? - Give the four
VMIN/VTIMEcombinations and when you would use each. - Why
TCSAFLUSHrather thanTCSANOWwhen entering raw mode? - Why does
printf 'a\nb\n'produce a staircase afterstty -opost? - Which flag makes Ctrl+C a signal, and what does Ctrl+C do when it is cleared?
- Why does bash's readline leave
ISIGon while turning offICANONandECHO? - List every exit path a raw-mode program can take and how each restores the terminal — including the one that cannot.
- Your emulator calls
tcsetattr(master, TCSAFLUSH, &raw). Name two things that break. - What does
IUTF8do, and what breaks without it?