Lab 1: The Raw Keyboard Byte Inspector (Milestone 1)

Background

Before you can build a terminal, you must know what a terminal receives. You have used a keyboard for years under the comfortable fiction that pressing a key produces "a character." It does not. It produces bytes — sometimes one, sometimes three, sometimes six — and which bytes depends on the key, the modifiers, the terminal, and the terminal's current mode.

This lab is ~150 lines of Rust with no PTY, no crates beyond libc, and no abstraction. It puts your own terminal into raw mode so the kernel stops helping, reads standard input, and prints every byte in hexadecimal with a decoded description. It is the smallest possible program that makes the invisible visible.

It also teaches the discipline that everything else depends on: you changed the terminal, so you must change it back — on every exit path.

Why This Lab Matters

  • Every input-encoding decision in Milestone 8 is the inverse of what you observe here. You cannot encode ESC [ A for the Up arrow with conviction until you have watched a real terminal send it.
  • The termios handling here is exactly what your GUI frontend and your mux client will do.
  • The restore-on-panic guard is the pattern you will copy into four other programs.
  • It is the first time you will see that Enter is 0x0d, not 0x0a — a fact that explains a large fraction of terminal bugs.

Prerequisites

rustc --version
stty -a | head -3        # note: icanon, echo, isig are present

Predict First

Before you write a line, write your predictions in predictions.md. Include a confidence level.

KeyYour prediction (bytes)Confidence
a
A
Enter
Tab
Backspace
Esc
Up arrow
F1
Ctrl+C
Ctrl+A
Alt+B (Option+B on macOS)
é
An emoji, e.g. 🙂
Pasting the two-line text ab⏎cd

Do not skip this. The value of the lab is proportional to how wrong these are.


Step-by-Step Tasks

Step 1: Create the crate

cd mini-terminal
cargo new --bin crates/raw-inspector --name raw-inspector

Add the one dependency:

# crates/raw-inspector/Cargo.toml
[package]
name = "raw-inspector"
version = "0.1.0"
edition = "2021"

[dependencies]
libc = "0.2"

Note: libc is not an abstraction — it is the declarations of the C library, so that Rust can call it. There is no logic in it. This is the only dependency this lab is allowed.

Step 2: The raw-mode guard

This is the most important 40 lines in Section 1. Create src/term.rs:

#![allow(unused)]
fn main() {
use std::io;
use std::mem::MaybeUninit;

/// Puts fd 0 into raw mode and restores the original settings on Drop.
///
/// Restoring is not optional: a terminal left in raw mode has no echo, no line
/// editing, and no ^C, which makes the user's shell effectively unusable.
pub struct RawMode {
    fd: i32,
    original: libc::termios,
}

impl RawMode {
    pub fn enable(fd: i32) -> io::Result<Self> {
        // A zeroed termios is NOT a valid termios, so we must not construct one.
        // tcgetattr fully initializes it on success.
        let mut original = MaybeUninit::<libc::termios>::uninit();
        // SAFETY: `fd` is an open fd; tcgetattr initializes the struct on success.
        if unsafe { libc::tcgetattr(fd, original.as_mut_ptr()) } != 0 {
            return Err(io::Error::last_os_error());
        }
        // SAFETY: tcgetattr returned success, so the value is initialized.
        let original = unsafe { original.assume_init() };

        let mut raw = original;
        // SAFETY: `raw` is a valid, initialized termios.
        unsafe { libc::cfmakeraw(&mut raw) };

        // cfmakeraw does NOT set these. Without them, VMIN inherits whatever was in
        // the VEOF slot — often 0 — and read() spins returning 0 bytes at 100% CPU.
        raw.c_cc[libc::VMIN] = 1;   // block until at least 1 byte
        raw.c_cc[libc::VTIME] = 0;  // no inter-byte timeout

        // TCSAFLUSH: wait for pending output to drain, then DISCARD any input typed
        // before the switch. Without the discard, a partially typed line arrives under
        // the new rules and you get a phantom byte.
        // SAFETY: `raw` is a valid termios; `fd` is open.
        if unsafe { libc::tcsetattr(fd, libc::TCSAFLUSH, &raw) } != 0 {
            return Err(io::Error::last_os_error());
        }
        Ok(RawMode { fd, original })
    }

    /// Restore explicitly. Idempotent enough to be safe to call before Drop.
    pub fn restore(&self) {
        // SAFETY: `self.original` came from tcgetattr on this same fd.
        unsafe { libc::tcsetattr(self.fd, libc::TCSAFLUSH, &self.original) };
    }

    pub fn original(&self) -> libc::termios {
        self.original
    }
}

impl Drop for RawMode {
    fn drop(&mut self) {
        // Drop must not panic and there is nothing useful to do on failure.
        self.restore();
    }
}
}

Line-by-line, the parts that carry meaning:

  • MaybeUninit rather than mem::zeroed(): constructing an invalid termios is undefined behavior even if you immediately overwrite it. tcgetattr is what makes it valid.
  • cfmakeraw clears ICANON, ECHO, ISIG, IEXTEN, OPOST, ICRNL, IXON, and more — the full list is in the termios chapter.
  • VMIN=1, VTIME=0 is the interactive-reader setting. Omitting it is the classic 100%-CPU bug.
  • TCSAFLUSH versus TCSANOW is a real behavioral difference, not style.
  • Drop covers normal return and panic-with-unwind. It does not cover process::exit, abort, or a fatal signal — which is why Step 5 exists.

Step 3: Decoding bytes into something readable

Create src/decode.rs. The goal is a one-line description per byte and, where a sequence is recognizable, a description of the whole sequence.

#![allow(unused)]
fn main() {
/// A human-readable name for a single byte.
pub fn describe_byte(b: u8) -> String {
    match b {
        0x00 => "NUL  (Ctrl+@ / Ctrl+Space)".into(),
        0x01..=0x1a => format!("{:<4} (Ctrl+{})", c0_name(b), (b'A' + b - 1) as char),
        0x1b => "ESC  (Ctrl+[ / Escape key)".into(),
        0x1c => "FS   (Ctrl+\\)".into(),
        0x1d => "GS   (Ctrl+])".into(),
        0x1e => "RS   (Ctrl+^)".into(),
        0x1f => "US   (Ctrl+_)".into(),
        0x20 => "SP   (space)".into(),
        0x21..=0x7e => format!("'{}'", b as char),
        0x7f => "DEL  (Backspace on most terminals)".into(),
        0x80..=0xff => format!("0x{:02x} (UTF-8 continuation or high byte)", b),
    }
}

fn c0_name(b: u8) -> &'static str {
    match b {
        0x01 => "SOH", 0x02 => "STX", 0x03 => "ETX", 0x04 => "EOT",
        0x05 => "ENQ", 0x06 => "ACK", 0x07 => "BEL", 0x08 => "BS",
        0x09 => "HT",  0x0a => "LF",  0x0b => "VT",  0x0c => "FF",
        0x0d => "CR",  0x0e => "SO",  0x0f => "SI",  0x10 => "DLE",
        0x11 => "DC1", 0x12 => "DC2", 0x13 => "DC3", 0x14 => "DC4",
        0x15 => "NAK", 0x16 => "SYN", 0x17 => "ETB", 0x18 => "CAN",
        0x19 => "EM",  0x1a => "SUB", _ => "?",
    }
}

/// Recognize a whole input sequence. Deliberately simple — this is a *reader's*
/// decoder, not the parser you build in Milestone 4.
pub fn describe_sequence(buf: &[u8]) -> Option<String> {
    match buf {
        [0x1b, b'[', b'A'] => Some("CSI A — Up arrow (normal cursor keys)".into()),
        [0x1b, b'[', b'B'] => Some("CSI B — Down arrow".into()),
        [0x1b, b'[', b'C'] => Some("CSI C — Right arrow".into()),
        [0x1b, b'[', b'D'] => Some("CSI D — Left arrow".into()),
        [0x1b, b'O', b'A'] => Some("SS3 A — Up arrow (APPLICATION cursor keys, DECCKM set)".into()),
        [0x1b, b'O', b'B'] => Some("SS3 B — Down arrow (application mode)".into()),
        [0x1b, b'O', b'C'] => Some("SS3 C — Right arrow (application mode)".into()),
        [0x1b, b'O', b'D'] => Some("SS3 D — Left arrow (application mode)".into()),
        [0x1b, b'[', b'H'] => Some("CSI H — Home".into()),
        [0x1b, b'[', b'F'] => Some("CSI F — End".into()),
        [0x1b, b'O', b'P'] => Some("SS3 P — F1".into()),
        [0x1b, b'O', b'Q'] => Some("SS3 Q — F2".into()),
        [0x1b, b'O', b'R'] => Some("SS3 R — F3".into()),
        [0x1b, b'O', b'S'] => Some("SS3 S — F4".into()),
        [0x1b, b'[', b'2', b'~'] => Some("CSI 2 ~ — Insert".into()),
        [0x1b, b'[', b'3', b'~'] => Some("CSI 3 ~ — Delete".into()),
        [0x1b, b'[', b'5', b'~'] => Some("CSI 5 ~ — Page Up".into()),
        [0x1b, b'[', b'6', b'~'] => Some("CSI 6 ~ — Page Down".into()),
        [0x1b, b'[', b'2', b'0', b'0', b'~'] => Some("CSI 200 ~ — BRACKETED PASTE START".into()),
        [0x1b, b'[', b'2', b'0', b'1', b'~'] => Some("CSI 201 ~ — BRACKETED PASTE END".into()),
        // Modified keys: CSI 1 ; <mods> <final>
        [0x1b, b'[', b'1', b';', m, f] => Some(format!(
            "CSI 1 ; {} {} — modified key. modifier bits = {} \
             (1=+Shift 2=+Alt 4=+Ctrl 8=+Meta, encoded as 1+sum)",
            *m as char, *f as char, (*m as char).to_digit(10).unwrap_or(0).saturating_sub(1)
        )),
        [0x1b, rest @ ..] if !rest.is_empty() && rest[0] != b'[' && rest[0] != b'O' => Some(
            format!("ESC + {:?} — probably Alt/Meta + that key", rest)
        ),
        [0x1b] => Some("ESC alone — the Escape key (or the start of a sequence that has not arrived yet)".into()),
        _ => None,
    }
}
}

Note on the [0x1b] case: a bare ESC is genuinely ambiguous. Real terminals resolve it with a timeout — if nothing follows within ~25–50 ms, it was the Escape key. That ambiguity is why pressing Escape in vim over a slow SSH link sometimes does the wrong thing. You will implement the timeout in Lab 13.

Step 4: The main loop

src/main.rs:

mod decode;
mod term;

use std::io::{self, Read, Write};

fn main() -> io::Result<()> {
    let stdin_fd = 0;

    // Refuse to run when stdin is not a terminal: tcgetattr would fail with ENOTTY,
    // and the whole program is meaningless without a terminal to configure.
    // SAFETY: isatty just inspects the fd.
    if unsafe { libc::isatty(stdin_fd) } != 1 {
        eprintln!("raw-inspector: stdin is not a terminal. Run me from a terminal.");
        std::process::exit(2);
    }

    let raw = term::RawMode::enable(stdin_fd)?;

    // Drop covers unwind, but NOT abort and NOT a fatal signal. A panic hook that
    // restores before printing means a crash does not leave the user's shell wedged.
    let original = raw.original();
    std::panic::set_hook(Box::new(move |info| {
        // SAFETY: restoring termios from a panic hook is safe; the fd is still open.
        unsafe { libc::tcsetattr(0, libc::TCSAFLUSH, &original) };
        eprintln!("panic: {info}");
    }));

    // Note the \r\n: OPOST is cleared, so a bare \n moves DOWN without returning
    // to column 0. This is the staircase bug, and it is the first thing raw mode
    // teaches everyone.
    print!("raw-inspector — press keys. Ctrl+Q to quit.\r\n");
    print!("Everything else, including Ctrl+C, is shown as bytes.\r\n\r\n");
    io::stdout().flush()?;

    let mut stdin = io::stdin();
    let mut buf = [0u8; 64];

    loop {
        // One read() returns everything the terminal delivered in one go. A single
        // arrow key press usually arrives as one 3-byte read; a fast paste arrives
        // as one large read. That grouping is information — print it.
        let n = match stdin.read(&mut buf) {
            Ok(0) => break,                                       // EOF
            Ok(n) => n,
            Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,  // EINTR
            Err(e) => return Err(e),
        };
        let chunk = &buf[..n];

        // 1. Raw hex, exactly as received.
        print!("[{:2} byte{}]  ", n, if n == 1 { " " } else { "s" });
        for b in chunk { print!("{:02x} ", b); }

        // 2. A printable rendering, with control bytes shown in caret notation.
        print!("  |");
        for &b in chunk {
            match b {
                0x20..=0x7e => print!("{}", b as char),
                0x00..=0x1f => print!("^{}", (b'@' + b) as char),
                0x7f => print!("^?"),
                _ => print!("."),
            }
        }
        print!("|\r\n");

        // 3. A decoded description of the sequence, if we recognize one.
        if let Some(desc) = decode::describe_sequence(chunk) {
            print!("            → {}\r\n", desc);
        } else if n == 1 {
            print!("            → {}\r\n", decode::describe_byte(chunk[0]));
        } else if let Ok(s) = std::str::from_utf8(chunk) {
            // Multi-byte UTF-8: show the codepoints.
            print!("            → UTF-8 text {:?}, codepoints: ", s);
            for c in s.chars() { print!("U+{:04X} ", c as u32); }
            print!("\r\n");
        }

        io::stdout().flush()?;

        // Ctrl+Q = 0x11 = DC1. Chosen because Ctrl+C is data here and we want to
        // demonstrate that. Note IXON is off in raw mode, so ^Q is not flow control.
        if chunk.contains(&0x11) {
            print!("\r\nCtrl+Q — restoring terminal and exiting.\r\n");
            io::stdout().flush()?;
            break;
        }
    }

    // Explicit restore, then the guard's Drop runs harmlessly afterwards.
    raw.restore();
    Ok(())
}

Step 5: Run it

cargo run -p raw-inspector

Press every key in your prediction table.


Expected Output

raw-inspector — press keys. Ctrl+Q to quit.
Everything else, including Ctrl+C, is shown as bytes.

[ 1 byte ]  61   |a|
            → 'a'
[ 1 byte ]  41   |A|
            → 'A'
[ 1 byte ]  0d   |^M|
            → CR   (Ctrl+M)
[ 1 byte ]  09   |^I|
            → HT   (Ctrl+I)
[ 1 byte ]  7f   |^?|
            → DEL  (Backspace on most terminals)
[ 1 byte ]  1b   |^[|
            → ESC alone — the Escape key (or the start of a sequence that has not arrived yet)
[ 3 bytes]  1b 5b 41   |^[[A|
            → CSI A — Up arrow (normal cursor keys)
[ 3 bytes]  1b 4f 50   |^[OP|
            → SS3 P — F1
[ 1 byte ]  03   |^C|
            → ETX  (Ctrl+C)
[ 1 byte ]  01   |^A|
            → SOH  (Ctrl+A)
[ 2 bytes]  1b 62   |^[b|
            → ESC + [98] — probably Alt/Meta + that key
[ 2 bytes]  c3 a9   |..|
            → UTF-8 text "é", codepoints: U+00E9
[ 4 bytes]  f0 9f 99 82   |....|
            → UTF-8 text "🙂", codepoints: U+1F642
[ 6 bytes]  1b 5b 32 30 30 7e   |^[[200~|
            → CSI 200 ~ — BRACKETED PASTE START

The Observations That Matter

Go through these one at a time and compare against your predictions.

ObservationWhy
Enter is 0x0d (CR), not 0x0a (LF)The keyboard sends CR, as a real terminal's Return key did. ICRNL normally translates it to LF on input — but you cleared ICRNL. This is why a program reading raw input must handle CR, and why \r\n exists at all.
Backspace is 0x7f (DEL), not 0x08 (BS)Historical: DEC terminals sent DEL. VERASE defaults to 0x7f to match. Some terminals and some configurations send 0x08; a robust program accepts both. Ctrl+H does send 0x08.
Escape is 0x1b, and so is the start of every arrow keyThis ambiguity is fundamental. Terminals disambiguate with a timeout. It is why "Escape feels laggy in vim over SSH."
Ctrl+letter = letter & 0x1fCtrl+A = 0x01, Ctrl+M = 0x0d, Ctrl+[ = 0x1b. Ctrl+M is Enter. Ctrl+I is Tab. Ctrl+[ is Escape. They are not "like" each other — they are the same byte.
Ctrl+C prints 03 and does not kill the programISIG is cleared. The kernel is no longer turning that byte into SIGINT. This is the whole point of raw mode.
Arrow keys are 3 bytes, arriving in one read()The terminal writes them as one burst. But this is not guaranteed — over a slow link they can split. Your parser must handle a sequence arriving one byte at a time.
F1 is ESC O P on some terminals and ESC [ 1 1 ~ on othersThe VT100 lineage (SS3) versus the VT220 lineage (CSI ~). Both are in the wild. This is why terminfo exists.
é is 2 bytes; 🙂 is 4The terminal sends UTF-8. Your decoder must be a real UTF-8 decoder, not a byte-to-char cast.
Alt+B may be 1b 62 or e2 — or nothingOn Linux, Alt usually prefixes ESC. On macOS, Option composes characters by default (Option+B gives ∫) unless the terminal is configured to send Meta.
A paste arrives as one huge readWith bracketed paste enabled, it is wrapped in ESC[200~…ESC[201~. Without it, a pasted newline is indistinguishable from a typed Enter — which is why editors auto-indent pasted code into a staircase.

Debugging Steps

Nothing appears when I press keys

VMIN/VTIME are wrong, or you never flushed. Check raw.c_cc[VMIN] == 1, and that you call io::stdout().flush() after each print.

100% CPU, output scrolls forever

VMIN is 0, so read() returns immediately with 0 bytes in a tight loop. Set VMIN = 1.

Output climbs diagonally down the screen

You printed \n instead of \r\n. OPOST is cleared, so nothing inserts the carriage return for you. This is the staircase bug and you should see it once deliberately.

My shell is broken after the program exits

The restore did not run. Type (blind) stty sane and press Enter — or Ctrl+J if Enter does not work, because ICRNL is off.

Then find out why:

stty -a > /tmp/before
cargo run -p raw-inspector      # exit with Ctrl+Q
stty -a > /tmp/after
diff /tmp/before /tmp/after     # must be empty

tcgetattr fails with ENOTTY

You piped stdin: echo x | cargo run has no terminal. The isatty guard catches this.

Ctrl+C kills the program anyway

You are running under something that re-cooks the terminal, or cargo run is between you and the program in a way that matters. Build and run the binary directly: cargo build -p raw-inspector && ./target/debug/raw-inspector.


Experiment

CLAIM. The kernel, not the shell and not the terminal emulator, performs echo — and raw mode is what turns it off.

METHOD.

# 1. Canonical mode with echo (the default). Run `cat` and type.
cat
#    → you see what you type. Press ^D.

# 2. Disable echo at the kernel level, then run cat again.
stty -echo
cat
#    → type: you see NOTHING, but cat still receives the bytes and echoes
#      them back itself when you press Enter.
#    ^D, then:
stty echo

# 3. Now run your inspector. Echo is off (cfmakeraw cleared ECHO), yet you
#    still "see" your keystrokes — because YOUR PROGRAM is printing them.
cargo run -p raw-inspector

PREDICTION. Write, before running: in step 2, when you type hello and press Enter, what appears on screen and in what order?

RESULT. Record what happened and which belief was wrong.


Test

Testing an interactive program requires separating the pure part from the terminal part. The decoder is pure; test it.

#![allow(unused)]
fn main() {
// crates/raw-inspector/src/decode.rs  — append:
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn up_arrow_is_csi_a() {
        // ESC [ A  = 1b 5b 41. The normal-cursor-key encoding of the Up arrow.
        let d = describe_sequence(b"\x1b[A").expect("Up arrow must be recognized");
        assert!(d.contains("Up arrow"), "got: {d}");
    }

    #[test]
    fn application_cursor_keys_differ_from_normal() {
        // DECCKM (?1h) changes CSI A into SS3 A. The two must NOT decode identically,
        // because the difference is exactly what Milestone 8 must reproduce.
        let normal = describe_sequence(b"\x1b[A").unwrap();
        let app = describe_sequence(b"\x1bOA").unwrap();
        assert_ne!(normal, app);
        assert!(app.contains("APPLICATION"));
    }

    #[test]
    fn ctrl_letter_is_letter_and_0x1f() {
        // Ctrl+A..Ctrl+Z are 0x01..0x1a. This identity is the whole rule.
        for (byte, letter) in [(0x01u8, 'A'), (0x03, 'C'), (0x1a, 'Z')] {
            let d = describe_byte(byte);
            assert!(d.contains(&format!("Ctrl+{letter}")), "byte {byte:#04x} → {d}");
        }
    }

    #[test]
    fn enter_is_cr_not_lf() {
        // The single most important byte-level fact in this lab.
        assert!(describe_byte(0x0d).contains("CR"));
        assert!(describe_byte(0x0a).contains("LF"));
    }

    #[test]
    fn backspace_is_del() {
        assert!(describe_byte(0x7f).contains("Backspace"));
    }

    #[test]
    fn bracketed_paste_markers_are_recognized() {
        assert!(describe_sequence(b"\x1b[200~").unwrap().contains("PASTE START"));
        assert!(describe_sequence(b"\x1b[201~").unwrap().contains("PASTE END"));
    }
}
}
cargo test -p raw-inspector

Challenge Extensions

  1. Handle split sequences. Right now a 3-byte arrow key is only recognized if it arrives in one read(). Buffer incomplete sequences across reads and add a timeout so a lone ESC is reported as the Escape key after ~50 ms. This is precisely the ambiguity real terminals live with — and the machinery you need in Milestone 8.

  2. Enable bracketed paste and mouse reporting on your own terminal. Print \x1b[?2004h\x1b[?1000h\x1b[?1006h at startup and the matching l (reset) sequences before exiting. Now paste text and click the mouse, and watch the reports arrive as input. Make sure you reset them on every exit path — leaving mouse reporting on makes the user's shell emit garbage on every click, and it is a rude bug to ship.

  3. Add a --hex-only mode that writes raw bytes to a file for later replay. This is the seed of Lab 5.

  4. Query the terminal. Write \x1b[6n (DSR — Device Status Report) to stdout and observe the reply arriving on stdin as \x1b[<row>;<col>R. You have just discovered that terminals talk back, which is why your terminal core will need a reply channel.

  5. Compare terminals. Run the inspector in three different terminal emulators and diff the byte sequences for F1–F12, Home/End, and Ctrl+Arrow. Write up the differences. That table is why terminfo exists.


Implementation Requirements / Deliverables

  • raw-inspector builds and runs; every key in the prediction table produces output.
  • predictions.md filled in before running, with confidence levels, and annotated afterwards with what was wrong.
  • Terminal restored on: normal exit, Ctrl+Q, and a deliberately-induced panic. Verified by stty -a diff.
  • All six unit tests pass.
  • A written answer to: why is Enter 0x0d and Backspace 0x7f?
  • At least one challenge extension completed.

Validation / Self-check

You are ready for Lab 2 when you can answer all twelve of the Milestone-0 questions from the introduction, plus these:

  1. Why does cfmakeraw need VMIN/VTIME set separately, and what happens if you forget?
  2. Why TCSAFLUSH and not TCSANOW?
  3. Name every exit path from your program and say which mechanism restores the terminal on each. Which path cannot be handled at all?
  4. Why is Ctrl+M the same byte as Enter, and Ctrl+[ the same byte as Escape?
  5. A 3-byte arrow key arrives as two reads: 1b 5b, then 41. What must your input handling do?
  6. Your program prints \n and the output stair-steps. Name the termios flag and explain the mechanism.
  7. What is bracketed paste, and what bug does it fix?
  8. You wrote \x1b[6n to stdout and something appeared on stdin. Explain the round trip, naming every component it passed through.

Next: Lab 2 — The PTY Shell Runner, where you stop using someone else's terminal and make your own.