Week 5 · Streams of Bytes: cat, wc, and grep¶
Thu Sep 24
11c_cat· Fri Sep 2512c_wc,13c_grep, extra credit14c_headRead this page yourself before Midterm 1. Essentials is what Thursday and Friday assume. Going deeper is optional and is not on the exam.
Slides Thursday prep Friday prep
This week¶
In 53k you type run mygrep at your own rv6 shell, and the file you write
this week runs on your kernel. Each read it makes traps into code you wrote
and returns whatever your console or filesystem had ready.
This week you write three Unix commands with no heap and no format!. On
Thursday cat copies bytes through a read that may come back short. On
Friday wc counts an input of any size with a few variables, and grep
searches it line by line. Midterm 1 (Thu Oct 15) tests the short-read contract,
why write_all exists, and streaming with fixed buffers and O(1) state.
By Friday night you have three commands that handle a file of any size through one fixed buffer.
Essentials¶
Thursday · 11c cat¶
In 50k you write the kernel's side of read, and rv6 answers a file read
with at most 128 bytes. 11c_cat writes the other side now, and must be right
whatever read returns.
Everything is a descriptor¶
Unix reaches files, terminals and devices the same way: through a file descriptor, a small integer indexing a per-process table in the kernel. A program reading descriptor 3 cannot tell what stands behind it.
Three descriptors are open before your first line runs:
| fd | ulib name |
Default, and what changes it |
|---|---|---|
| 0 | STDIN |
the keyboard; < swaps in a file |
| 1 | STDOUT |
the screen; > swaps in a file |
| 2 | STDERR |
the screen, whatever > did |
Complaints use descriptor 2 so they survive redirection: wc gone.txt > counts
shows its message on the screen and leaves counts clean. With no file names,
a command reads descriptor 0, so cat < notes.txt prints a file cat never
opened. ulib opens and closes the rest:
Read Result<Fd, Error> as "a descriptor, or the news that there is none".
rv6 gives each process 16 descriptor slots, and those three are already in
use. Every open spends a slot and only close refunds it. Skip a close
and nothing fails there; a later open of a file that exists fails instead.
That delayed failure is a leak, and error paths leak as easily as normal
ones.
The short-read contract¶
read is a request with a ceiling, and its answer is the truth:
Read the usize as "how many bytes I actually put in buf", from 0 to
buf.len(). Fewer than you asked for is a short read, and it is normal. A
file can run out partway through your request, and a terminal hands over only
what has been typed.
Only 0 means end of input. A correct reader asks again until it hears 0,
and each time uses only the first n bytes. Here is cat with no arguments
on your laptop's terminal, with a 512-byte buffer:
you type hello, Enter read -> 6 buf[..6] is "hello\n"
you type ok, Enter read -> 3 buf[..3] is "ok\n"; buf[3..6] still says "lo\n"
you press Ctrl-D read -> 0 end of input: three reads, two lines
The second row is the whole contract: 3 is not the end signal, and the bytes past 3 belong to the line before.
write has the mirror problem, and write_all, from week 4,
repeats until every byte is gone. The test harness on your
laptop is generous in two ways rv6 is not: it takes every write whole, and it
never counts open descriptors. A bare write and a missing close both pass
there.
The one thing to get right: every small test passes, and a file larger than the buffer comes out cut short, or ends with a stretch of bytes from the chunk before.
read's answer, not the buffer's size, says how many bytes are real, and only 0 says the file is over.
One fixed buffer¶
An rv6 program runs without a heap, so Vec, String and format! are out
of reach. Everything a command stores is an array it declared, and its read
buffer is one of them, on the stack. Copying a 3 KB note or a 3 TB disk
image uses the same array.
Every read is a trip into the kernel, a trap whose fixed cost does not
shrink with the request. A bigger buffer spreads that cost over more bytes:
copying 1 MiB (1,048,576 bytes), not counting the final read of 0
1-byte buffer 1,048,576 reads
512-byte buffer 2,048 reads 0.2% as many
4 KiB buffer 256 reads and your whole stack
The first factor of 512 removes nearly every trap, and later growth saves far
less. An rv6 program gets one 4 KiB stack page, so a 1 KiB buffer is a quarter
of it and 8 KiB does not fit. That is why cat and wc use 512 bytes and
grep uses 1,024.
One bad file, one exit status¶
The i32 your run returns is the exit status: 0 for success, anything
else for failure.
Give the real cat a missing file and a good one:
The complaint about gone.txt went to standard error, and todo.txt printed
anyway. The final 1 describes the whole run, not the last file: a later
success does not erase an earlier failure.
Friday · 12c wc¶
From 45k on, rv6 gets your typing through device interrupts and keeps it in
a small fixed ring. 12c_wc practices that discipline on a file: lines, words
and bytes, counted in one pass.
Bytes, char, and UTF-8¶
Rust has four types that can hold text, and they differ in what they promise:
| Type | Size | Promises |
|---|---|---|
u8 |
1 byte | nothing: any of 256 values |
char |
4 bytes | one Unicode scalar value |
&str |
16 bytes (pointer, length) | the bytes are valid UTF-8 |
&[u8] |
16 bytes (pointer, length) | nothing |
In UTF-8 a character takes 1 to 4 bytes, and ASCII takes exactly one:
assert_eq!('ñ'.len_utf8(), 2); // one char, two bytes
assert_eq!("año".len(), 4); // len counts bytes
assert_eq!("año".chars().count(), 3);
let flag: &[u8; 2] = b"-n"; // a byte string, not a &str
Read "año".len() as "how many bytes", never "how many letters". So wc
counts bytes: a file holding año and a newline is 5 bytes. The commands use
&[u8] throughout: a disk block or a keystroke has no encoding, and checking
UTF-8 costs image space.
Bytes are safe on UTF-8 text anyway; Going deeper shows why.
To wc, a line is a \n byte, so a last line without one is not counted.
A word is a run of non-whitespace bytes. matches!(b, b'a' | b'e')
tests a byte against several patterns at once.
Streaming with O(1) state¶
Counting the easy way means holding the whole input and cutting it at whitespace. rv6 has no heap for either, and a file can outgrow all the memory a process gets. The alternative is to stream: examine every byte once, while it sits in the buffer, then reuse the buffer. What survives between bytes is O(1) state: the same handful of variables for a 50-byte file and a 50 GB one.
Streaming has one trap: anything spanning two bytes can span two reads. A
counter of Windows line endings, \r\n, shows the way out:
// Called once per chunk. The caller keeps `after_cr` between calls.
fn feed(chunk: &[u8], after_cr: &mut bool, pairs: &mut usize) {
for &b in chunk {
if *after_cr && b == b'\n' { *pairs += 1; }
*after_cr = b == b'\r';
}
}
Read after_cr as "the one fact about the past that the next byte might
need". Feed it one\r, then \ntwo\r\n, and pairs is 2, the same as one
feed of the whole. Declare after_cr inside feed instead and the
first pair vanishes, so the answer depends on where the reads fell.
State updated by each arriving byte is a state machine, and a correct one gives the same answer however its input is chunked.
The one thing to get right: a file holding
hello worldgets 10 words, one per letter. A word is not a kind of byte; it is an event, the change from separator to non-separator. One byte cannot show a change. Only what you remember about the byte before it can.
Friday · 13c grep¶
In 53k, grep runs as a user program on rv6, where a panic prints one word.
13c_grep gets its edge cases right under cargo test, reading lines through
a buffer it declares.
Lines without an allocator¶
To grep, a line is everything between two \n bytes; the \n itself
belongs to neither side. Reads know nothing about lines, so a 1 KiB read may
stop halfway through one. A heap would let you grow a buffer until the line
fits; without one, you move the unfinished part to the front and read more
behind it.
ulib::Lines does that for you:
impl<'b> Lines<'b> {
pub fn new(fd: Fd, buf: &'b mut [u8]) -> Lines<'b> { … }
pub fn next_line(&mut self) -> Option<&[u8]> { … }
}
Read buf: &'b mut [u8] as "your array, on loan to Lines for as long as
lines is in use". Each next_line hands back a slice of that array with the
\n removed, or None at end of file. An oversized line is cut to the
buffer's length, its tail is thrown away, and truncated() turns true.
Print buf[0] while lines is still in use and you get error[E0502]:
cannot borrow `buf[_]` as immutable because it is also borrowed as mutable;
write to it and you get E0506.
Hold a line across the next call and you get E0499, since that call may
write over its bytes. C compiles all three and hands you bytes that changed
under you.
while let¶
A for loop needs an iterator, and Lines is not one. Each line borrows the
buffer that the next call overwrites, and Iterator has no way to say that. So
you drive it by hand, with while let:
let mut stack = vec![10, 20, 30];
while let Some(top) = stack.pop() {
println!("{top}"); // 30, then 20, then 10
}
// pop() answered None: the loop is over
Read while let Some(top) = stack.pop() as "call pop; while the answer is
Some, bind it to top and run the body". The first answer that does not
match ends the loop.
Where a search breaks¶
Our grep matches a fixed string, not a regular expression. A line matches if
the pattern occurs in it as one contiguous run of bytes: ear is in
heart, but not in era. Search code calls the pattern the needle and the
line the haystack.
Sliding a needle along a haystack is a fencepost count: a 2-byte needle on a
5-byte line starts at 0, 1, 2 or 3, four starts, and the last is 5 − 2. Two
sizes need a decision before you count. An empty needle has nothing to
mismatch, so it is found in every line. A needle with more bytes than the line has no
start at all, and its "last start" is a negative number that a usize cannot
hold.
Rust has two range forms, and only one includes its end:
let a: Vec<u32> = (1..5).collect(); // [1, 2, 3, 4]: stops before 5
let b: Vec<u32> = (1..=5).collect(); // [1, 2, 3, 4, 5]
let gap = 2usize.checked_sub(5); // None: checked, no panic
Read ..= as "up to and including". A checked_ operation returns an
Option where plain arithmetic would panic: 2 - 5 on usize values panics
in a debug build.
The one thing to get right:
grep logprintslogfilebut notsyslog. Count the starts by hand: a 3-byte needle fits a 6-byte line at 0, 1, 2 and 3. The last start is exactly 6 − 3, and a range that stops one start early never tries 3, the only placelogsits insyslog.
An answer scripts can test¶
grep reports its verdict twice: as printed lines, and as its exit status,
which is what if and && read:
| Status | Meaning |
|---|---|
| 0 | some line matched |
| 1 | the search ran and nothing matched |
| 2 | trouble: no pattern, or a file that open refused |
Status 1 is the answer "no", not a failure:
Read -q as "print nothing; just answer". If grep exited 0 whether or not
it found anything, every if grep would take the same branch.
Extra credit ·
14c14c_headprints the first 10 lines of its input, or as many as-n COUNTasks for. The idea:headis judged by what it does not read.Linesfetches input only when you ask for a line, sohead's work is set by the count, not by the file's size.head -n 1on a 10 GB log should cost one buffer, not 10 GB.
For the exam¶
Not needed on Thursday or Friday. On Midterm 1.
Counting the calls · Midterm 1. When read fills the buffer whenever it
can, N bytes through a B-byte buffer take ⌈N/B⌉ reads that return data, then
one that returns 0, and each data read gets one write_all. For 1,000 bytes
and a 256-byte buffer: 256, 256, 256, 232, 0, so five reads and four writes.
A kernel that returns less per read changes the count, never the output. A
program that reads once prints the first chunk and silently drops the rest.
Problem 1 has this shape.
Why write_all exists · Midterm 1. write returns how many bytes it
took: a disk that fills mid-write takes what fits and reports the smaller
count. write_all calls write again on the part not yet taken until nothing
is left. It turns a write that takes 0 bytes into an error, not a loop that
spins forever. It returns no count, since success means every byte.
State across a chunk boundary · Midterm 1. Given a streaming routine, an
input and a buffer size, give the state after each chunk and the final answer.
A correct routine gives the same answer for every buffer size, because
nothing it needs is lost when a read ends. If the answer changes with the
chunking, some state was reset between reads; Problem 2 has
this shape. A routine that acts when a line ends may still hold a result when
read returns 0, so it needs one last step after the loop, a flush
(Problem 3).
Going deeper¶
Optional. Nothing here is needed on Thursday or Friday, and nothing here is on the exam.
Where a read goes on rv6¶
On rv6 each ulib call is one ecall: the call number in a7, up to three
arguments in a0–a2, the result back in a0. read is call 5, open 15,
write 16 and close 21:
flowchart LR
A["ulib::read(fd, buf)"] --> B["ecall, a7 = 5"]
B --> C["trap into the kernel (48k)"]
C --> D["descriptor table (50k)"]
D --> E["console: one byte (45k)"]
D --> F["file: at most 128 bytes (40k)"]
E --> G["the count, back in a0"]
F --> G
rv6 stages a file read through a 128-byte kernel buffer, and its files hold at
most 128 bytes, so a 512-byte request always comes back short. A console read
returns one keystroke, so cat with no arguments makes one read per key,
while the host fills 512 bytes when it can. One program works in all three
because it loops until 0. Linux's read(2) page says it outright: "It is not
an error if this number is smaller than the number of bytes requested."
Choosing a buffer size¶
Four forces set the number:
- The per-call cost wants it large, with fast-shrinking returns: 512 to 64 KiB removes 2,032 of the last 2,048 calls, at 128 times the memory.
- The device wants it aligned to 512-byte sectors or 4 KiB blocks and pages.
- The kernel's staging buffer caps it: rv6 never returns more than 128 bytes per read.
- Your memory wants it small. An rv6 program's stack is one page:
0x0001_1000 top of the stack (sp starts just below, under argv)
0x0001_0000 the stack: one 4 KiB page for every local
0x0000_0000 the program image: at most 16 pages (64 KiB)
glibc's BUFSIZ is 8,192 and nobody minds, because a Linux stack grows on
demand to 8 MB. On rv6, 8 KiB of locals runs sp off its page.
UTF-8, and why bytes are safe¶
Ken Thompson and Rob Pike designed UTF-8 in 1992, for Plan 9. A character's
first byte says how long it is, and every byte after it has the form
10xxxxxx:
0xxxxxxx 1 byte: ASCII, unchanged
110xxxxx 10xxxxxx 2 bytes: ñ is C3 B1
1110xxxx 10xxxxxx 10xxxxxx 3 bytes
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 4 bytes
Only ASCII bytes have the top bit clear, so a byte-wise search for ASCII never
hits inside a longer character. Validating UTF-8 takes a state machine of its
own and costs code in a 64 KiB image. wc -m, which counts characters, pays
for decoding every byte.
Inside Lines¶
next_line tries three cases in order. A buffered \n returns the bytes
before it, with no read at all. At end of file, leftover bytes come back as
the last line, which is why a final line with no newline still appears.
Otherwise it compacts: copy_within slides the unfinished tail to the
front. If that tail still fills the buffer with no \n, it comes back whole
and the rest of that line is skipped; if not, read fills the space behind
it. With a 16-byte buffer, after abc and def came back:
before [a b c \n d e f \n g h i j k l . .] start 8, len 14
compact [g h i j k l . . . . . . . . . .] start 0, len 6
refill read(fd, &mut buf[6..]) -> 10
[g h i j k l m n \n o p q r s t u] len 16
return "ghijklmn" start 9
C's fgets returns a long line's prefix with only a missing \n to warn
you, then the rest as if it were a new line. getline grows its buffer with
realloc, which needs a heap. Lines keeps one line out per line in, and
truncated() says when bytes were dropped.
The version rv6 cannot run¶
The in-class example
week05/12c_wc_vec_example
writes the easy wc in plain std. The idea fits in three lines of your
own:
let text = std::fs::read_to_string("notes.txt")?; // every byte, on the heap, checked as UTF-8
let words = text.split_whitespace().count(); // pass 1
let longest = text.lines().map(str::len).max(); // pass 2: the text is still there
Read read_to_string as "keep every chunk until end of file, then check it is
UTF-8". That buys a second pass for free, costs memory in proportion to the
file, and refuses a file with a single invalid byte. A streaming wc pays 512
bytes for any input and gets one pass. Take away the whole-file String and
you are back to chunks, and to the boundary problem.
Practice problems¶
Problem 1: Counting the calls¶
A correct copy program moves a 2,000-byte file to standard output through a 512-byte buffer.
- On the host, where
readfills the buffer whenever it can, what does eachreadreturn, and how many writes happen? - Under a kernel that returns at most 128 bytes per read, how many
readcalls, and how many writes? - The program copies standard input on rv6, and you type
okand press Enter. How manyreadcalls return data? - A classmate reads once into a 2 KiB (2,048-byte) buffer, "since the file fits". Which of the three settings print the right output?
Click to reveal solution
- 512, 512, 512, 464, then 0: five reads and four writes.
- 2,000 is 15 × 128 + 80: 15 reads of 128, one of 80, then 0, so 17 reads and 16 writes, and the same output.
- Three, one per byte:
o,kand the byte Enter sends, a\ron QEMU's serial console. An rv6 console read returns one byte, whatever the buffer size. - Only the host, and by luck. Under the 128-byte kernel it prints 128 bytes
and drops 1,872 with no error. On the console it prints
oand exits.
Problem 2: Carry it across¶
The \r\n counter reads the 10 bytes hi\r\n\r\r\nok\r through
a 3-byte buffer, starting with after_cr = false and pairs = 0.
- Give
after_crandpairsafter each chunk, and the final count. - A classmate declares
after_crinsidefeed, starting atfalseon every call. What does that version report here, and for which buffer sizes is it right?
Click to reveal solution
-
The chunks are
hi\r,\n\r\r,\nokand\r:After chunk after_crpairshi\rtrue 0 \n\r\rtrue 1 \nokfalse 2 \rtrue 2 -
It reports 0: each
\narrives at the start of a chunk, with a freshfalse. It is right for buffers of 4, 5, or 7 bytes and up, where no pair straddles a boundary, and wrong for 1, 2, 3 and 6. The kernel, not you, chooses where reads end.
Problem 3: Does it need a flush?¶
This streaming routine reports the length of the longest line:
fn feed(chunk: &[u8], cur: &mut usize, best: &mut usize) {
for &b in chunk {
match b {
b'\n' => { *best = (*best).max(*cur); *cur = 0; }
_ => *cur += 1,
}
}
}
- What does
besthold after the inputab\ncdef, with no final newline? What is the right answer? - What must happen at end of input, and why does the
\r\ncounter need nothing similar?
Click to reveal solution
bestis 2, from the one\n.cdefis still incur, as 4, when the input ends, so the right answer is 4.- After the read that returns 0, take
best.max(cur)once more: a flush. This routine acts when a line ends, and the last line may never end. The\r\ncounter acts when a pair completes, so nothing is pending.
Problem 4: Unsigned arithmetic and ranges¶
fn seats_between(first: usize, last: usize) -> usize {
last - first + 1 // seats first through last
}
- What is
seats_between(3, 7)? - What does
seats_between(7, 3)do in a debug build, and in a release build? - A loop over
first..lastvisits which seats for(3, 7)? - Rewrite it to return
Nonefor the second call.
Click to reveal solution
- 5: seats 3, 4, 5, 6 and 7.
- Debug panics:
attempt to subtract with overflow, "panicked at src/…". Release wraps: 3 − 7 is 2^64 − 4, and adding 1 gives 18,446,744,073,709,551,613 seats, a count some later loop will believe. - 3, 4, 5 and 6:
..stops before 7.first..=lastincludes it, matching the+ 1. -
Let
checked_subcatch it, andchecked_addthe one case left,(0, usize::MAX):
Key terms¶
| Term | Meaning | Where you use it |
|---|---|---|
| File descriptor | A small integer indexing the process's table of open files | 11c, 50k |
| Short read | read returning fewer bytes than asked; only 0 is end of file |
11c, 12c |
write_all |
Repeats write until every byte is taken |
10c–14c |
| Exit status | The i32 a program ends with; 0 is success |
11c, 13c, 46k |
| UTF-8 | 1 to 4 bytes per character; ASCII is one byte | 12c, 13c |
| Byte string | b"...": a &[u8; N], not a &str |
11c–14c |
| Streaming | Looking at each byte once, through a fixed buffer | 11c, 12c |
| O(1) state | A fixed set of variables, the same for any input length | 12c, 45k |
ulib::Lines |
Lines out of a buffer you own, without the \n |
13c, 14c |
while let |
Loop while a value matches a pattern | 13c, 14c |
Further reading¶
- Code and output: eleven programs that run
this page's examples (the
hello/okshort reads, 1 MiB through three buffers, the\r\ncounter fed in chunks, the 16-byte line buffer), each beside what it printed, and six files that must not compile. Pression one to edit it and run it. - ulib and the Command Set: the API, the test harness and the budget.
- Cheatsheet: file descriptors and open flags, the reference you bring to Midterm 1.
- The Linux
read(2)andwrite(2)manual pages: read the RETURN VALUE sections in full. - Rob Pike and Ken Thompson, Hello World, or Καλημέρα κόσμε, or こんにちは 世界 (USENIX Winter 1993): the paper that introduced UTF-8.
- xv6-riscv's
user/cat.c,user/wc.canduser/grep.c: the same commands in C, with agrepthat also handles^,.,*and$.