Assembly (x86-64, AArch64, RISC-V) Module 8 – Talking to the operating system
Reading input safely with read()
Why read() returns fewer bytes than asked, how to loop to end of file, keep a line inside its buffer and parse a 64-bit number with overflow checks.
What you will learn
- Read standard input with read() in a loop that handles short reads and end of file
- Predict the counts read() returns from a file, a pipe and a closed descriptor
- Validate the length and content of input before using it
- Parse a decimal integer from text with an overflow check on every digit
Before you start
What you’ll learn · Before you start
What you will learn
- Read standard input with read() in a loop that handles short reads and end of file
- Predict the counts read() returns from a file, a pipe and a closed descriptor
- Validate the length and content of input before using it
- Parse a decimal integer from text with an overflow check on every digit
Before you start
On this page
A program asks read() for 4,096 bytes of a 10,000-byte file three times and gets 4,096, 4,096 and then 1,808.
Nothing went wrong: read() hands over what is there, up to the request, and the code that treats every return as a
full buffer is the code that breaks on a pipe, a terminal or the last chunk of a file.
This lesson runs those calls in AArch64 assembly on macOS and records every count. You will write loops that keep reading until they have what they need, keep a line inside its buffer whatever its length, accept only the bytes you expect, and turn digits into a 64-bit number that refuses to wrap past 18,446,744,073,709,551,615.
read() returns what is there now, not what you asked for
read(fd, buf, n) copies up to n bytes from the file descriptor: A small non-negative number a process uses to name an open file, pipe, terminal or socket in system calls such as read and write. 0, 1 and 2 are standard input, output and error. More in “Linux system calls on three ISAs” into buf
and returns how many it copied. read(2) puts the rule plainly: a count smaller than the request is not an error.
The program below calls it until it stops and keeps every count. Its function read_counts asks for 4,096 bytes at
a time into a buffer on its stack, through libSystem’s _read; on Linux the same call is the read
system call: A request a running program makes to the kernel, through a special instruction (syscall on x86-64, svc on AArch64, ecall on RISC-V) that switches privilege. The program leaves a call number and arguments in agreed registers and the kernel reads them. More in “First program: return an exit status”, 0 on x86-64 and 63 in the generic table, made the way
the Linux system-call lesson shows:
macos/count_reads.s
// count_reads.s: call read() on a descriptor until end of file and keep every count it returned
// (macOS on Apple silicon, AArch64, libSystem).
// int read_counts(int fd, long *counts, int max)
// Each read asks for 4096 bytes into a buffer on the stack. Returns how many counts it stored; the last one is 0
// at end of file, or -1 if read failed.
.text
.globl _read_counts
.p2align 2
_read_counts:
stp x29, x30, [sp, #-48]!
stp x19, x20, [sp, #16]
stp x21, x22, [sp, #32]
mov x29, sp
sub sp, sp, #4096 // the buffer
mov w19, w0 // fd
mov x20, x1 // counts
mov w21, w2 // max
mov w22, #0 // stored so far
1: cmp w22, w21
b.ge 2f // no room for another count
mov w0, w19
mov x1, sp
mov x2, #4096
bl _read // x0 = bytes read, 0 at end of file, -1 on error
str x0, [x20, w22, sxtw #3]
add w22, w22, #1
cmp x0, #0
b.gt 1b // more may follow
2: mov w0, w22
add sp, sp, #4096
ldp x21, x22, [sp, #32]
ldp x19, x20, [sp, #16]
ldp x29, x30, [sp], #48
ret macos/count_main.c
// count_main.c: prints every count read_counts collected from standard input.
#include <stdio.h>
int read_counts(int fd, long *counts, int max);
int main(void) {
long counts[64];
int n = read_counts(0, counts, 64);
printf("read() returned:");
for (int i = 0; i < n; i++) printf(" %ld", counts[i]);
printf("\n");
return 0;
} """Collect every count read() returns from three kinds of standard input: a file, a pipe and a closed descriptor."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
session.run(
"""
clang -o macos/count_reads macos/count_main.c macos/count_reads.s
head -c 10000 /dev/zero > zeros.bin
# A regular file of 10000 bytes, read in requests of 4096.
./macos/count_reads < zeros.bin
# A pipe that one printf filled with 3 bytes and then closed.
printf 'abc' | ./macos/count_reads
# Standard input closed before the program starts.
./macos/count_reads <&-
"""
) Output
$ clang -o macos/count_reads macos/count_main.c macos/count_reads.s $ head -c 10000 /dev/zero > zeros.bin # A regular file of 10000 bytes, read in requests of 4096. $ ./macos/count_reads < zeros.bin read() returned: 4096 4096 1808 0 # A pipe that one printf filled with 3 bytes and then closed. $ printf 'abc' | ./macos/count_reads read() returned: 3 0 # Standard input closed before the program starts. $ ./macos/count_reads <&- read() returned: -1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 reads_session.py
The file gave 4,096, 4,096 and 1,808, then 0. The third read is a short read: A read() that returns fewer bytes than were asked for, which is not an error: a pipe, a terminal or a socket hands over what has arrived, and a file what is left before its end.: only
1,808 bytes were left. The fourth returned 0, which means end of file: The state read() reports by returning 0: no more bytes will come from that descriptor, because a file has been read to its end or every writer of a pipe has closed it., the one value
that says no more input will ever come. The pipe: A one-way channel between processes: bytes written to one end are read from the other in order. A read returns what has arrived, so one message can take several reads. gave 3, everything printf had written, then
0 once printf had exited and closed its end. With standard input closed, the first call failed at once.
read() returns |
Meaning | What the caller does |
|---|---|---|
n equal to the request |
the buffer is full | use the bytes; there may be more |
1 to n - 1 |
a short read: what was there | use the bytes and call again for the rest |
| 0 | end of file | stop: nothing more will come |
| -1 (libSystem, glibc) or -errno (raw Linux call) | an error, its number in errno |
stop, or retry after EINTR |
A pipe or a terminal returns whatever has arrived, so a program that reads a 10-byte record with one call can get 5
bytes, then 5 more from the next call. pipe(7) gives the one promise there is: a write of at most PIPE_BUF bytes
(at least 512 by POSIX) goes into the pipe as one contiguous piece, never mixed with another writer’s bytes. The
3 bytes of printf were one such write, which is why they came back from a single read in every run.
Checkpoint
Check: What read() returns, and short reads
Loop until the job is done: copy to end of file, or read exactly n bytes
cat.s copies standard input to standard output with a 4,096-byte buffer on its stack. Its outer loop reads until
read() returns 0. Its inner loop writes until every byte of that chunk is out, because write(2) may also take
fewer bytes than offered; it keeps the bytes still to write in x19 and the next byte in x20:
// cat.s: copy standard input to standard output in 4096-byte chunks (macOS AArch64, libSystem)
.text
.globl _main
.p2align 2
_main:
stp x29, x30, [sp, #-32]!
stp x19, x20, [sp, #16]
mov x29, sp
sub sp, sp, #4096 // the buffer
read_more:
mov x0, #0 // fd 0
mov x1, sp
mov x2, #4096
bl _read // x0 = bytes read, 0 at end of file, -1 on error
cmp x0, #0
b.eq done // end of file
b.lt failed
mov x19, x0 // bytes left to write
mov x20, sp // next byte to write
write_more:
mov x0, #1
mov x1, x20
mov x2, x19
bl _write // may write fewer bytes than asked
cmp x0, #0
b.lt failed
add x20, x20, x0
subs x19, x19, x0
b.ne write_more
b read_more
done:
mov w0, #0
b out
failed:
mov w0, #1
out:
add sp, sp, #4096
ldp x19, x20, [sp, #16]
ldp x29, x30, [sp], #32
ret """Copy standard input to standard output with cat.s and check that every byte arrived."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
session.run(
"""
clang -o macos/cat macos/cat.s
head -c 10000 /dev/zero > zeros.bin
# 10000 bytes from a file and from a pipe: wc -c counts what came out.
./macos/cat < zeros.bin | wc -c
head -c 10000 /dev/zero | ./macos/cat | wc -c
./macos/cat < zeros.bin | cmp - zeros.bin && echo "same bytes"
# An empty input, then a closed one.
./macos/cat < /dev/null | wc -c
./macos/cat <&-; echo "exit=$?"
"""
) Output
$ clang -o macos/cat macos/cat.s
$ head -c 10000 /dev/zero > zeros.bin
# 10000 bytes from a file and from a pipe: wc -c counts what came out.
$ ./macos/cat < zeros.bin | wc -c
10000
$ head -c 10000 /dev/zero | ./macos/cat | wc -c
10000
$ ./macos/cat < zeros.bin | cmp - zeros.bin && echo "same bytes"
same bytes
# An empty input, then a closed one.
$ ./macos/cat < /dev/null | wc -c
0
$ ./macos/cat <&-; echo "exit=$?"
exit=1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cat_session.py
10,000 bytes went in and 10,000 came out, from a file and from a pipe, and cmp found them identical; an empty
input copied nothing, and a closed one took the error path and exited 1. Step through the loop on the
10,000-byte file:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The first read. read asks for 4096 bytes of the 10000-byte file and gets all 4096: a regular file hands over as much as it has, up to the request.
- Neither end nor error. n is positive, so the loop has bytes to write: 4096 of them, starting at the beginning of the buffer.
- Write may write less. write can also take fewer bytes than offered, so the loop keeps the count still to write in x19 and the next byte in x20.
- All written, read again. left is now 0, so the loop goes back for more input. The second read returns 4096 bytes too and is written the same way.
- A short read. Only 1808 bytes are left in the file, so the third read returns 1808 although it asked for 4096. Nothing is wrong: the count is all the loop needs.
- End of file. The fourth read returns 0. Zero means end of file, so the loop stops, having written 4096 + 4096 + 1808 = 10000 bytes.
Copying stops at end of file. A record of fixed size needs the other loop: keep reading until n bytes are in, end
of file comes first, or an error stops it. read_full in read_full.s does that, and retries when read() fails
with EINTR, which read(2) documents for a call interrupted by a signal handler before it read anything. Its
neighbour read_once makes the single call the bug relies on:
macos/read_full.s
// read_full.s: one read() call, and a loop that reads until it has what it asked for
// (macOS on Apple silicon, AArch64, libSystem).
// long read_once(int fd, void *buf, size_t n) a single read(): it may return fewer than n bytes
// long read_full(int fd, void *buf, size_t n) read() until n bytes, end of file or an error; returns the bytes
// read (n, or fewer at end of file) or -1, and retries after EINTR
.equ EINTR, 4
.text
.globl _read_once, _read_full
.p2align 2
_read_once:
b _read // a tail call: read's result is read_once's result
_read_full:
stp x29, x30, [sp, #-48]!
stp x19, x20, [sp, #16]
stp x21, x22, [sp, #32]
mov x29, sp
mov w19, w0 // fd
mov x20, x1 // buf
mov x21, x2 // n, the bytes wanted
mov x22, #0 // got so far
1: cmp x22, x21
b.hs 3f // all n bytes are in
mov w0, w19
add x1, x20, x22 // the first byte still missing
sub x2, x21, x22 // how many are still missing
bl _read
cmp x0, #0
b.eq 3f // end of file: return what arrived
b.lt 2f // -1: look at errno
add x22, x22, x0
b 1b
2: bl ___error
ldr w9, [x0]
cmp w9, #EINTR
b.eq 1b // a signal interrupted the wait: try again
mov x22, #-1
3: mov x0, x22
ldp x21, x22, [sp, #32]
ldp x19, x20, [sp, #16]
ldp x29, x30, [sp], #48
ret macos/record_main.c
// record_main.c: reads one 10-byte record from standard input, with read_once or with read_full.
#include <stdio.h>
#include <string.h>
long read_once(int fd, void *buf, unsigned long n);
long read_full(int fd, void *buf, unsigned long n);
int main(int argc, char **argv) {
char record[10];
int full = argc > 1 && strcmp(argv[1], "full") == 0;
long got = full ? read_full(0, record, sizeof record) : read_once(0, record, sizeof record);
if (got < 0) { puts("read failed"); return 1; }
printf("%s: got %ld of %zu bytes: %.*s\n", full ? "read_full" : "read_once", got, sizeof record, (int)got, record);
return got == (long)sizeof record ? 0 : 2;
} The driver below feeds both programs the same record through a pipe, in two writes: HELLO, then WORLD. It sends
WORLD to read_once only after that program has finished, so the run cannot depend on timing:
"""Send a 10-byte record through a pipe in two writes, HELLO and then WORLD, to read_once and to read_full.
The driver writes WORLD to read_once only after that program has finished, so its single read() can only ever see
HELLO. read_full gets WORLD a moment after HELLO and keeps reading until all 10 bytes are in.
"""
import pathlib
import subprocess
import sys
import time
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
session.run(
"""
clang -o macos/record macos/record_main.c macos/read_full.s
"""
)
def feed(mode: str) -> None:
"""Runs ./macos/record <mode> with a pipe as standard input and prints what it printed and its status."""
print(f"$ ./macos/record {mode} # standard input: a pipe that receives HELLO, then WORLD")
program = subprocess.Popen(["./macos/record", mode], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
program.stdin.write(b"HELLO")
program.stdin.flush()
if mode == "once":
printed = program.stdout.read() # the program ends after its one read
try:
program.stdin.write(b"WORLD")
program.stdin.close()
except BrokenPipeError:
pass
else:
time.sleep(0.3)
program.stdin.write(b"WORLD")
program.stdin.close()
printed = program.stdout.read()
status = program.wait()
print(printed.decode(), end="")
print(f"exit={status}")
feed("once")
feed("full") Output
$ clang -o macos/record macos/record_main.c macos/read_full.s $ ./macos/record once # standard input: a pipe that receives HELLO, then WORLD read_once: got 5 of 10 bytes: HELLO exit=2 $ ./macos/record full # standard input: a pipe that receives HELLO, then WORLD read_full: got 10 of 10 bytes: HELLOWORLD exit=0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pieces_session.py
read_once got 5 of 10 bytes and the program exited with 2; read_full got all 10. Nothing in the first case was
an error: the pipe held 5 bytes when the one call asked, and that is all a single read() promises.
One read() per message
A protocol reader that calls read() once and treats the result as a whole message works on a fast local pipe
and fails on a slow network or a busy machine, where the same bytes arrive in pieces. Loop until the length you
need is in, as read_full does, and treat 0 before that as a message cut off by end of file.
The two loops differ only in when they stop: the copy at end of file, the record once its n bytes are in.
Key idea
The count read() returns is the only truth about what arrived: loop on it, stop at 0, and never assume the
request was filled.
Checkpoint
Check: Copy loops and read-exactly loops
Check the length before every store, then allow only the bytes you expect
A line can be longer than any buffer. read_line reads one byte at a time into a 16-byte buffer and compares the
count with the capacity before every store, so no input can write past buf[15]. A line that does not fit is read
to its newline anyway, so the next call starts at the next line, and the caller gets -2 instead of a silently cut
line. The check before each store is the whole defence against a
buffer overflow: Writing past the end of a buffer, usually because input was copied without comparing its length with the buffer's size first. It overwrites whatever lies beyond, such as saved registers., which MITRE’s CWE-120 describes as copying input into a buffer
without checking its size.
Length is half of input validation: Checking input before using it: its length against the space it will occupy, and its content against what is allowed, rejecting anything that fails rather than repairing it.. The other half is content, and the OWASP
Input Validation Cheat Sheet’s advice is an allowlist: A definition of exactly what input is allowed, such as lower-case letters, digits and '-', with everything else rejected. It is safer than a list of known-bad input, which always misses a case.: define what is allowed and reject
everything else, rather than trying to list what is bad. valid_name allows 1 to 16 bytes of a to z, 0 to 9
and -. Each range is one subtraction and one unsigned compare: c - 'a' is at most 25, read as unsigned, only
for a lower-case letter, as in the branches lesson.
macos/read_line.s
// read_line.s: read one line without ever writing past the buffer (macOS on Apple silicon, AArch64, libSystem).
// long read_line(int fd, char *buf, size_t cap)
// Reads one byte at a time up to a newline, which it drops, and stores at most cap bytes in buf. Returns:
// 0 to cap the number of bytes stored, for a line that fit (a last line without a newline counts too)
// -2 the line was longer than cap: buf holds its first cap bytes, the rest was read and dropped
// -1 end of file before any byte of a new line
// -3 read() failed
.text
.globl _read_line
.p2align 2
_read_line:
stp x29, x30, [sp, #-64]!
stp x19, x20, [sp, #16]
stp x21, x22, [sp, #32]
str x23, [sp, #48] // [sp, #56] holds the byte just read
mov x29, sp
mov w19, w0 // fd
mov x20, x1 // buf
mov x21, x2 // cap
mov x22, #0 // bytes stored
mov x23, #0 // 1 once a byte had to be dropped
next:
mov w0, w19
add x1, sp, #56
mov x2, #1
bl _read
cmp x0, #0
b.lt failed
b.eq at_eof
ldrb w9, [sp, #56]
cmp w9, #10 // '\n' ends the line
b.eq line_done
cmp x22, x21
b.hs no_room // the check comes before the store, every time
strb w9, [x20, x22]
add x22, x22, #1
b next
no_room:
mov x23, #1 // drop the byte, but keep reading to the end of the line
b next
at_eof:
orr x9, x22, x23
cbnz x9, line_done // a last line without a newline
mov x0, #-1
b out
line_done:
mov x0, x22
cbz x23, out
mov x0, #-2
b out
failed:
mov x0, #-3
out:
ldr x23, [sp, #48]
ldp x21, x22, [sp, #32]
ldp x19, x20, [sp, #16]
ldp x29, x30, [sp], #64
ret macos/valid_name.s
// valid_name.s: an allowlist check (macOS on Apple silicon, AArch64)
// int valid_name(const char *s, size_t n)
// 1 when n is 1 to 16 and every byte is a lower-case letter a-z, a digit 0-9 or '-'; otherwise 0.
.text
.globl _valid_name
.p2align 2
_valid_name:
cbz x1, reject // empty
cmp x1, #16
b.hi reject // too long
mov x9, #0
1: ldrb w10, [x0, x9]
sub w11, w10, #'a'
cmp w11, #25
b.ls accept_byte // 'a' to 'z': one unsigned compare covers the range
sub w11, w10, #'0'
cmp w11, #9
b.ls accept_byte // '0' to '9'
cmp w10, #'-'
b.ne reject // anything else is refused, whatever it is
accept_byte:
add x9, x9, #1
cmp x9, x1
b.lo 1b
mov w0, #1
ret
reject:
mov w0, #0
ret macos/line_main.c
// line_main.c: reads lines into a 16-byte buffer with read_line and checks each one with valid_name.
#include <stdio.h>
long read_line(int fd, char *buf, unsigned long cap);
int valid_name(const char *s, unsigned long n);
int main(void) {
char buf[16];
for (int line = 1;; line++) {
long n = read_line(0, buf, sizeof buf);
if (n == -1) break;
if (n == -3) { puts("read failed"); return 1; }
if (n == -2) { printf("line %d: longer than %zu bytes, rest dropped: rejected\n", line, sizeof buf); continue; }
printf("line %d: %ld bytes \"%.*s\": %s\n", line, n, (int)n, buf, valid_name(buf, (unsigned long)n) ? "accepted" : "rejected");
}
return 0;
} """Read six lines into a 16-byte buffer with read_line, then accept or reject each with the valid_name allowlist."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
session.run(
"""
clang -o macos/line macos/line_main.c macos/read_line.s macos/valid_name.s
printf 'learner-42\\n\\nan-input-line-much-longer-than-16\\nHas Space\\nexactly-16-bytes\\nlast-line' | ./macos/line
"""
) Output
$ clang -o macos/line macos/line_main.c macos/read_line.s macos/valid_name.s $ printf 'learner-42\n\nan-input-line-much-longer-than-16\nHas Space\nexactly-16-bytes\nlast-line' | ./macos/line line 1: 10 bytes "learner-42": accepted line 2: 0 bytes "": rejected line 3: longer than 16 bytes, rest dropped: rejected line 4: 9 bytes "Has Space": rejected line 5: 16 bytes "exactly-16-bytes": accepted line 6: 9 bytes "last-line": accepted
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 line_session.py
The 33-byte line came back as “longer than 16 bytes” and was rejected; the 16-byte line fitted exactly and was
accepted, and so was the last line, which had no newline. The empty line and Has Space were stored safely but
failed the allowlist: an upper-case letter and a space are not in it. Step through the long line and the exact fit:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The first byte. read_line reads one byte, a. It is not a newline, and 0 bytes are stored, fewer than the cap of 16, so the byte goes to buf[0].
- Up to the cap. The next 15 bytes pass the same check. After an-input-line-mu the count is 16, and buf is full: every byte of it is used.
- Byte 17 is refused. The 17th byte, c, finds 16 stored, which is not below the cap. The check runs before the store, so nothing is written past buf[15]: the byte is dropped.
- Read to the end of the line. read_line keeps reading and dropping, 17 bytes in all, so the next call starts at the next line instead of in the middle of this one.
- The caller is told. At the newline it returns -2: the line was too long. buf holds the first 16 bytes, and line_main.c rejects the line instead of using a cut one.
- Exactly 16 fits. Line 5, exactly-16-bytes, stores 16 bytes and then meets its newline, not a 17th byte, so nothing is dropped and read_line returns 16.
Storing first, checking after
A loop that does strb w9, [x20, x22], then adds 1 and compares with the capacity, writes one byte past the
buffer before it notices, because the test comes one store too late. Compare first (cmp x22, x21 and b.hs
before the strb), as read_line.s does, so a full buffer refuses the byte.
Checkpoint
Check: Length checks and allowlists
Parse digits with an overflow check on every digit
Text is not a number until it is parsed, and parsing is where input turns into a size, a count or an index. The
rule is value = value * 10 + digit, and both steps can overflow 64 bits. parse_u64 checks each one:
umulh x13, x9, x10 gives the high 64 bits of value * 10, which must be 0, and adds sets the
carry flag: The flag that records a carry out of the top bit of an addition, which means the unsigned sum did not fit. After a subtraction, x86-64's CF = 1 means a borrow, while AArch64's C = 1 means no borrow. More in “Flags and condition codes” when adding the digit wraps, the overflow test of
the multi-precision lesson. A byte is a digit only if c - '0' is at
most 9 as an unsigned number, which rejects every other byte, below 0 as well as above 9, in one compare:
macos/parse_u64.s
// parse_u64.s: decimal text to an unsigned 64-bit number, with an overflow check on every digit
// (macOS on Apple silicon, AArch64).
// int parse_u64(const char *s, size_t n, unsigned long *out)
// 0: *out holds the value; 1: no digits at all; 2: a byte that is not 0-9; 3: the value does not fit in 64 bits.
.text
.globl _parse_u64
.p2align 2
_parse_u64:
cbz x1, empty
mov x9, #0 // the value so far
mov x10, #10
mov x11, #0 // index
1: ldrb w12, [x0, x11]
sub w12, w12, #'0' // a digit becomes 0-9; any other byte lands above 9, read as unsigned
cmp w12, #9
b.hi not_digit
umulh x13, x9, x10 // the high 64 bits of value * 10
cbnz x13, too_big // nonzero: value * 10 needs more than 64 bits
mul x9, x9, x10
adds x9, x9, x12 // + digit; the carry flag says it wrapped
b.cs too_big
add x11, x11, #1
cmp x11, x1
b.lo 1b
str x9, [x2]
mov w0, #0
ret
empty:
mov w0, #1
ret
not_digit:
mov w0, #2
ret
too_big:
mov w0, #3
ret macos/parse_main.c
// parse_main.c: runs parse_u64 on eight inputs and prints what it decided.
#include <stdio.h>
#include <string.h>
int parse_u64(const char *s, unsigned long n, unsigned long *out);
int main(void) {
static const char *input[] = {"0", "42", "007", "18446744073709551615", "18446744073709551616",
"99999999999999999999", "12a", ""};
static const char *why[] = {"ok", "no digits", "not a digit", "does not fit in 64 bits"};
for (size_t i = 0; i < sizeof input / sizeof input[0]; i++) {
unsigned long v = 0;
int r = parse_u64(input[i], strlen(input[i]), &v);
if (r == 0) printf("%-22s -> %lu\n", input[i][0] ? input[i] : "(empty)", v);
else printf("%-22s -> error %d, %s\n", input[i][0] ? input[i] : "(empty)", r, why[r]);
}
return 0;
} """Run parse_u64 on eight inputs: small numbers, the largest 64-bit value, two that are too big, and bad text."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
session.run(
"""
clang -o macos/parse macos/parse_main.c macos/parse_u64.s
./macos/parse; echo "exit=$?"
"""
) Output
$ clang -o macos/parse macos/parse_main.c macos/parse_u64.s $ ./macos/parse; echo "exit=$?" 0 -> 0 42 -> 42 007 -> 7 18446744073709551615 -> 18446744073709551615 18446744073709551616 -> error 3, does not fit in 64 bits 99999999999999999999 -> error 3, does not fit in 64 bits 12a -> error 2, not a digit (empty) -> error 1, no digits exit=0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 parse_session.py
18446744073709551615, 2⁶⁴ − 1, parsed; one more, 18446744073709551616, was refused with error 3, because at the
last digit value * 10 is 18,446,744,073,709,551,610 and adding 6 carries out of 64 bits. Without the check the sum
would wrap to 0 and the program would carry on with a length of zero, the wraparound MITRE’s CWE-190 describes.
99999999999999999999 failed earlier, at the multiply. 12a was refused at the a, and the empty string because
it has no digits; 007 became 7.
| Input | Where parse_u64 decides |
Result |
|---|---|---|
18446744073709551615 |
every check passes | 18,446,744,073,709,551,615 |
18446744073709551616 |
adds carries at the last digit |
error 3 |
99999999999999999999 |
umulh is not 0 at the twentieth digit |
error 3 |
12a |
'a' - '0' is above 9 |
error 2 |
POSIX’s strtoul reports the same overflow by returning ULONG_MAX and setting errno to ERANGE; a hand-written
parser has to make the same decision itself, before it uses the value.
Checkpoint
Check: Parsing digits without overflow
Practise
Work on copies of the files above, and reuse the clang line of the matching session to build each one.
- Give
line_main.ca buffer of 8 bytes instead of 16 and run the same six lines. Before you run it, write down which lines now come back as too long, and check thatexactly-16-bytesis one of them. Then changeread_lineto return the full length of a too-long line instead of -2, and check thatline_main.ccan still tell it apart. - Extend
parse_u64to accept one optional leading+and to refuse a leading0in a number of two or more digits (007). Add both cases toparse_main.c, and keep18446744073709551616failing.
Practice that adapts to you
Practise this lesson
12 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Why does read() return fewer bytes than you asked for, and what should the caller do?
Because read() returns what is available now, up to the request, and a count below the request is not an error. A
regular file runs out at its end: 10,000 bytes read in requests of 4,096 come back as 4,096, 4,096 and 1,808. A pipe,
a terminal or a socket returns whatever has arrived, so a 10-byte record sent in two writes can come back as 5 and
5. A signal can also interrupt a read before any data arrives, which fails with EINTR. The caller loops: it adds
each count to what it has, asks again for the rest, stops at 0, which is end of file, and handles -1 by checking
errno, retrying after EINTR. Code that needs an exact amount wraps this in a read_full loop and treats an early 0
as a truncated record.
The module’s bank adds two problems with first steps and follow-ups, one about a length field read off the network and one about a parser that turns 2⁶⁴ into 0:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A service reads messages that start with a 4-byte length, then copies that many bytes into a 4096-byte buffer on the stack with a read loop. A test with a length of 100000 crashes it. What is wrong, how do you fix it, and how do you show the fix holds for every length?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Why can read() return fewer bytes than you asked for?
It returns what is available now, up to the request, and a short count is not an error. A 10,000-byte file read 4,096 at a time came back as 4,096, 4,096 and 1,808; a pipe or socket hands over whatever has arrived so far.
-
What does read() returning 0 mean?
End of file: no more bytes will come from that descriptor, because the file has been read to its end or every writer of the pipe has closed it.
-
How should code read exactly n bytes?
Loop: add each count to what it has, ask again for the rest, retry when a read fails with EINTR, and treat a 0 before n bytes as a truncated record.
-
How does a line reader avoid writing past its buffer?
It checks the length before every store, so no line can go past the end, and it reads a too-long line to its end so the caller can reject it instead of seeing the rest as the next line.
-
How do you validate the content of input, and how cheap is each range test?
With an allowlist: accept only the bytes you expect and reject everything else. Each range is one subtraction and one unsigned compare, because a byte below the range wraps to a large unsigned value.
-
How must a decimal parser test each step of value * 10 + digit?
Check both steps for overflow: the multiply (umulh on AArch64 gives the high half) and the carry after the add. Without the checks, 18,446,744,073,709,551,616 parses as 0 instead of an error.
Key takeaways
read()returns the bytes that are there, up to the request: a 10,000-byte file came back as 4,096, 4,096 and 1,808, a pipe as whatever had been written, and 0 means end of file.- Loops do the real work: copy until
read()returns 0 and write until every byte of a chunk is out, or keep reading until a record is complete, retrying afterEINTR, asread_fulldid for a record that arrived in two pieces. - Check the length before every store, as
read_linedoes, so no line can write past its buffer, and read a too-long line to its end so the caller can reject it. - Validate content with an allowlist of the bytes you expect, each range checked with one subtraction and one unsigned compare, and reject everything else.
- Parse digits with
value * 10 + digitand test both steps,umulhfor the multiply and the carry afteradds, so 18,446,744,073,709,551,616 is an error instead of 0.
Terms in this lesson
- allowlist
- A definition of exactly what input is allowed, such as lower-case letters, digits and '-', with everything else rejected. It is safer than a list of known-bad input, which always misses a case.
- buffer overflow
- Writing past the end of a buffer, usually because input was copied without comparing its length with the buffer's size first. It overwrites whatever lies beyond, such as saved registers.
- end of file
- The state read() reports by returning 0: no more bytes will come from that descriptor, because a file has been read to its end or every writer of a pipe has closed it.
- input validation
- Checking input before using it: its length against the space it will occupy, and its content against what is allowed, rejecting anything that fails rather than repairing it.
- pipe
- A one-way channel between processes: bytes written to one end are read from the other in order. A read returns what has arrived, so one message can take several reads.
- short read
- A read() that returns fewer bytes than were asked for, which is not an error: a pipe, a terminal or a socket hands over what has arrived, and a file what is left before its end.
References
- read(2), the Linux manual page (Linux man-pages project)
- write(2), the Linux manual page (Linux man-pages project)
- pipe(7): pipe capacity and PIPE_BUF (Linux man-pages project)
- read, pread: read from a file (POSIX.1-2024) (The Open Group)
- syscall(2), the Linux manual page (Linux man-pages project)
- arch/x86/entry/syscalls/syscall_64.tbl: the x86-64 Linux syscall table (The Linux kernel project)
- scripts/syscall.tbl: the generic Linux syscall table used by arm64 and riscv (The Linux kernel project)
- Input Validation Cheat Sheet (OWASP Foundation)
- CWE-120: Buffer Copy without Checking Size of Input (MITRE)
- CWE-190: Integer Overflow or Wraparound (MITRE)
- strtoul: convert a string to an unsigned long (POSIX.1-2024) (The Open Group)
Report a problem with this lesson
Kept only in this browser. Your Learn progress