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 [ Afor the Up arrow with conviction until you have watched a real terminal send it. - The
termioshandling 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, not0x0a— a fact that explains a large fraction of terminal bugs.
Prerequisites
- The Terminal Mental Model read (Milestone 0).
- termios & the Line Discipline read.
- A Rust toolchain and a terminal you can afford to wedge (you will wedge it at least once).
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.
| Key | Your 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:
libcis 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:
MaybeUninitrather thanmem::zeroed(): constructing an invalidtermiosis undefined behavior even if you immediately overwrite it.tcgetattris what makes it valid.cfmakerawclearsICANON,ECHO,ISIG,IEXTEN,OPOST,ICRNL,IXON, and more — the full list is in the termios chapter.VMIN=1, VTIME=0is the interactive-reader setting. Omitting it is the classic 100%-CPU bug.TCSAFLUSHversusTCSANOWis a real behavioral difference, not style.Dropcovers normal return and panic-with-unwind. It does not coverprocess::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 bareESCis 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 invimover 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.
| Observation | Why |
|---|---|
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 key | This ambiguity is fundamental. Terminals disambiguate with a timeout. It is why "Escape feels laggy in vim over SSH." |
| Ctrl+letter = letter & 0x1f | Ctrl+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 program | ISIG 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 others | The VT100 lineage (SS3) versus the VT220 lineage (CSI ~). Both are in the wild. This is why terminfo exists. |
é is 2 bytes; 🙂 is 4 | The 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 nothing | On 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 read | With 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
-
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 loneESCis 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. -
Enable bracketed paste and mouse reporting on your own terminal. Print
\x1b[?2004h\x1b[?1000h\x1b[?1006hat startup and the matchingl(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. -
Add a
--hex-onlymode that writes raw bytes to a file for later replay. This is the seed of Lab 5. -
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. -
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
terminfoexists.
Implementation Requirements / Deliverables
-
raw-inspectorbuilds and runs; every key in the prediction table produces output. -
predictions.mdfilled 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 bystty -adiff. - All six unit tests pass.
-
A written answer to: why is Enter
0x0dand Backspace0x7f? - 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:
- Why does
cfmakerawneedVMIN/VTIMEset separately, and what happens if you forget? - Why
TCSAFLUSHand notTCSANOW? - Name every exit path from your program and say which mechanism restores the terminal on each. Which path cannot be handled at all?
- Why is Ctrl+M the same byte as Enter, and Ctrl+[ the same byte as Escape?
- A 3-byte arrow key arrives as two reads:
1b 5b, then41. What must your input handling do? - Your program prints
\nand the output stair-steps. Name thetermiosflag and explain the mechanism. - What is bracketed paste, and what bug does it fix?
- You wrote
\x1b[6nto 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.