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

ActorInteraction
KernelRuns it
Any process with an fd on the ttyReads/writes the configuration with tcgetattr/tcsetattr
The shellSets 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 emulatorSets its own outer terminal to raw. Should generally leave the PTY's settings alone.

Warning: There is exactly one termios per PTY pair, reachable from either fd. When your emulator calls tcsetattr(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)

FlagMeaningSet in canonical?Set in raw?
ICANONCanonical mode: buffer input into lines; read() returns a line at a time; VERASE/VKILL do line editing✅❌
ECHOEcho input characters back to the output side✅❌
ECHOEEcho VERASE as backspace-space-backspace (visually erase)✅❌
ECHOKEcho VKILL by killing the line✅❌
ECHONLEcho NL even when ECHO is off—❌
ISIGGenerate SIGINT/SIGQUIT/SIGTSTP from VINTR/VQUIT/VSUSP✅❌
IEXTENEnable implementation-defined processing (VLNEXT ^V, VDISCARD ^O)✅❌
NOFLSHDo not flush the input/output queues on SIGINT/SIGQUIT/SIGTSTP❌—
TOSTOPSend SIGTTOU when a background process writes❌ (default off)—

c_iflag — input modes

FlagMeaning
ICRNLTranslate incoming CR (0x0D) to NL (0x0A). This is why pressing Enter, which sends CR, results in a newline.
INLCRTranslate incoming NL to CR
IGNCRDiscard incoming CR entirely
ISTRIPStrip the 8th bit (7-bit legacy)
IXON/IXOFFSoftware flow control: ^S (VSTOP) pauses output, ^Q (VSTART) resumes
IUTF8Tell the discipline input is UTF-8 so VERASE erases a whole multi-byte character (Linux)
BRKINT, IGNBRK, PARMRK, INPCKBreak and parity handling — serial-line legacy

Tip: IXON is why ^S appears to freeze your terminal. Turning it off (stty -ixon) is the standard fix, and it is also how you free ^S for use in an editor.

c_oflag — output modes

FlagMeaning
OPOSTEnable output processing at all. Clearing it disables everything below.
ONLCRTranslate outgoing NL to CR NL. This is why programs can print "\n" and get a proper newline.
OCRNLTranslate outgoing CR to NL
ONLRETNL also performs the carriage return
TAB3/XTABSExpand tabs to spaces on output

c_cc[] — control characters

IndexDefaultRole
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/^SFlow control
VLNEXT^V (0x16)Take the next character literally (needs IEXTEN)
VREPRINT^R (0x12)Reprint the line
VWERASE^W (0x17)Erase the previous word
VMIN1Non-canonical only: minimum bytes for read() to return
VTIME0Non-canonical only: timeout in deciseconds

Note: VMIN and VTIME share storage with VEOF and VEOL on many systems. That is why they only have meaning when ICANON is 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:

VMINVTIMEread() behavior
00Poll: return immediately with whatever is available, possibly 0 bytes
>00Block until at least VMIN bytes are available
0>0Block until at least 1 byte, or VTIME deciseconds elapse
>0>0Inter-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

BehaviorCanonicalRaw
read() returnsOn a line terminatorAs soon as VMIN bytes are available
BackspaceKernel edits the bufferYour program's problem
EchoKernel does itYour program's problem
^CSIGINT to the foreground groupByte 0x03 delivered as data
^ZSIGTSTPByte 0x1A as data
^DEnds the line / signals EOFByte 0x04 as data
\n on outputONLCR makes it \r\nYou emit \r\n yourself
Who uses itread builtin, cat, simple filtersvim, 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, leaves ISIG on so that ^C still interrupts, while turning off ICANON and ECHO so 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: tcsetattr returns success if it changed any of the requested settings, not all of them. If you must be certain, tcgetattr afterwards and compare. This matters when a termios flag 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:

  1. MaybeUninit, not mem::zeroed(). A zeroed termios is not a valid one, and constructing an invalid value is UB even if you overwrite it. tcgetattr initializes it.
  2. The Drop impl is the whole point. Drop runs during unwind, so a panic restores the terminal. It does not run on std::process::exit, on abort, or on a fatal signal — see the next section.
  3. Drop must 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 pathHandled by
Normal return from mainDrop
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
SIGKILLNothing. 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 -9 and your shell becomes unusable, type stty sane and press Enter — even though you cannot see what you are typing, and even though Enter may need to be Ctrl+J because ICRNL is 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

MistakeSymptomFix
cfmakeraw without setting VMIN/VTIMEread() returns 0 in a hot loop; 100% CPUc_cc[VMIN]=1; c_cc[VTIME]=0
TCSANOW instead of TCSAFLUSH on entryA stray byte from before the switchUse TCSAFLUSH
No restore on panicUnusable shell after any crashDrop guard + panic::set_hook
Printing \n in raw modeStaircase outputPrint \r\n
Setting termios on the PTY masterYou changed the child's terminalSet it on fd 0 instead
Setting raw mode on the inner PTY "to be safe"Breaks the shell's own line editing and job controlLeave the PTY's termios alone
Assuming Backspace sends 0x08Your erase handling never firesMost terminals send 0x7F (DEL); VERASE defaults to it
Not handling EINTR from readSpurious failures whenever a signal arrivesRetry on EINTR
Using mem::zeroed() for termiosUB; occasionally wrong flagsMaybeUninit + tcgetattr

Validation / Self-check

  1. Name the four termios flag fields and one flag from each that changes visible behavior.
  2. In canonical mode, name every step between pressing a and a program's read() returning.
  3. Which component performs echo, and how do you prove it in one command?
  4. What exactly does cfmakeraw() change, and what does it not set that you must?
  5. Give the four VMIN/VTIME combinations and when you would use each.
  6. Why TCSAFLUSH rather than TCSANOW when entering raw mode?
  7. Why does printf 'a\nb\n' produce a staircase after stty -opost?
  8. Which flag makes Ctrl+C a signal, and what does Ctrl+C do when it is cleared?
  9. Why does bash's readline leave ISIG on while turning off ICANON and ECHO?
  10. List every exit path a raw-mode program can take and how each restores the terminal — including the one that cannot.
  11. Your emulator calls tcsetattr(master, TCSAFLUSH, &raw). Name two things that break.
  12. What does IUTF8 do, and what breaks without it?

Next: Signals, Window Size & the Child Lifecycle.