Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 15 minutes
  • Examples run with Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answer to its interview question.

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:

Keep every count read() returns

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;
}
What read() returns from a file, a pipe and a closed descriptor Python · reads_session.py
"""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

  1. Question 1 of 2 A 5000-byte regular file is read with requests of 2048 bytes. What does the third read() return?

    Type a number.

    A hint

    The first two reads take 2048 bytes each. How many are left?

    Show the answer: Question 1 of 2

    Answer: 904 bytes

    2048 + 2048 = 4096 bytes come first, so 5000 - 4096 = 904 are left, and the third read returns them: a short read. The fourth returns 0, end of file, as the 10000-byte run ended with 1808 and then 0.

  2. Question 2 of 2 What does it mean when read() returns 0?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: End of file; no more bytes will come from this descriptor.

    Zero is the one value that says the input is over. Any positive count, even a short one, means more may follow.

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 until end of file S · macos/cat.s
// 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
cat.s on a file, a pipe, an empty input and a closed one Python · cat_session.py
"""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:

Interactive · stage by stage cat.s copies 10000 bytes from a file Step through the copy loop on the 10000-byte input of the runs above. Each read asks for 4096 bytes.
cat.s copies 10000 bytes from a file, step 6 of 6. Showing read(0, buf, 4096) (n = 0), n = 0? n < 0?, write(1, p, left) (w = 4096), left -= w, p += w, return 0. In focus: return 0.left = 0n = 0read(0, buf, 4096)n = 0n = 0? n < 0?end of file or errorwrite(1, p, left)w = 4096left -= w, p += wuntil left = 0return 0every byte copiedn = 0

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. Neither end nor error. n is positive, so the loop has bytes to write: 4096 of them, starting at the beginning of the buffer.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

One read() against a loop, for a 10-byte record

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:

A 10-byte record that arrives in two pieces Python · pieces_session.py
"""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

  1. Question 1 of 2 read_full wants 10 bytes and has 6 when read() returns 0. What does it do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It stops and returns 6, because end of file means the other 4 bytes will never arrive.

    read_full has three ways out: all n bytes, end of file with what arrived, or an error. A count below n tells the caller the input ended early.

  2. Question 2 of 2 A program reads 10-byte records from a pipe with one read() per record, as read_once does. It works in tests and fails in production. What is the fix?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Loop until all 10 bytes of the record are in, stopping early only at end of file or on an error.

    A single read returns what the pipe holds at that moment, so a record can arrive as 5 and 5 bytes. Only a loop on the counts makes the record whole.

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.

read_line, the valid_name allowlist, and a driver

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;
}
Six lines through a 16-byte buffer and an allowlist Python · line_session.py
"""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:

Interactive · step by step read_line meets a line longer than its buffer Step through line 3 of the run, a 33-byte line read into a 16-byte buffer, then line 5, which fits exactly.
read_line meets a line longer than its buffer, step 6 of 6. Showing Next byte read (newline), Stored (16 of 16), buf (exactly-16-bytes), Stored < cap?, Dropped bytes (none), Returned (16: accepted). In focus: Returned, Stored.yesnoNext byte readnewlineStored16 of 16bufexactly-16-bytesStored < cap?before each storeDropped bytesnoneReturned16: accepted

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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].
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. Question 1 of 2 A line reader with a 16-byte buffer stores each byte, then adds 1 to the count, then stops when the count is above 16. How far does a 40-byte line write?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Into buf[16], one byte past the end, before the test stops it

    Put the compare before the store: stored < cap, then strb. Testing after the store lets exactly one byte past the buffer, a classic overflow by one.

  2. Question 2 of 2 Which input does valid_name accept?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: learner-42

    An allowlist names what may appear, here a-z, 0-9 and '-', between 1 and 16 bytes, and rejects every other byte without needing a list of bad ones.

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:

parse_u64 and eight inputs

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;
}
Eight inputs through parse_u64 Python · parse_session.py
"""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

  1. Question 1 of 2 Which instruction of parse_u64 catches 18446744073709551616?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: adds, whose carry is set when the last digit, 6, is added to 18446744073709551610

    Both steps can overflow. For 2^64 the multiply fits and the add carries; for 99999999999999999999 the multiply already fails, and umulh catches it.

  2. Question 2 of 2 Why do sub w12, w12, #'0' and cmp w12, #9 followed by b.hi reject a space, byte 0x20?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 0x20 - 0x30 wraps to 0xfffffff0, which is far above 9 when read as unsigned.

    Subtracting '0' maps the digits to 0 to 9 and everything else to values above 9, read as unsigned, so one branch covers both ends of the range.

Practise

Work on copies of the files above, and reuse the clang line of the matching session to build each one.

  1. Give line_main.c a 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 that exactly-16-bytes is one of them. Then change read_line to return the full length of a too-long line instead of -2, and check that line_main.c can still tell it apart.
  2. Extend parse_u64 to accept one optional leading + and to refuse a leading 0 in a number of two or more digits (007). Add both cases to parse_main.c, and keep 18446744073709551616 failing.

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.

  1. Question 1 of 12 libSystem's read returns -1. Where is the reason for the failure?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: In errno

    Through the C library a failure is always -1, and errno carries the number, such as 9 for a closed descriptor.

  2. Question 2 of 12 printf 'abcdef' writes 6 bytes into a pipe and exits. The reader asks for 4 bytes per call. What do its calls return?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: 4, 2, 0

    read returns at most the request and at most what is there; 0 comes once the writer has closed its end and the bytes are all read.

  3. Question 3 of 12 Code ported from libSystem to raw Linux system calls keeps the test cmn x0, #1 then b.eq failed after a read. The kernel returns -9. What happens?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: The failure goes unnoticed, because -9 is not -1, and the code may use -9 as a byte count.

    Error conventions differ by route. After a raw Linux call, test for any value from -4095 to -1, for example cmn x0, #4095 then b.hs on AArch64; after the C library, test for -1 and read errno.

  4. Question 4 of 12 Why does cat.s have a loop around write() as well as around read()?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: write may take fewer bytes than offered, so the rest must be written by later calls.

    Short counts happen in both directions. A copy is complete only when every byte read has also been written.

  5. Question 5 of 12 read_full(fd, buf, 10) reads from a pipe whose writer sends 3 bytes, 3 more, 3 more and then closes. What does read_full return?

    Type a number.

    A hint

    The loop adds each count and stops at end of file.

    Show the answer: Question 5 of 12

    Answer: 9 bytes

    The counts add up to 3 + 3 + 3 = 9; the next read returns 0, end of file, so read_full returns 9 and the caller sees a record one byte short.

  6. Question 6 of 12 A program with a signal handler blocks in read() on a pipe. The signal arrives before any byte. What do read_full and a loop that treats every -1 as fatal do?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: read() fails with EINTR; read_full calls it again, while the other loop reports a failure that never happened.

    EINTR is not a real error. read_full checks errno after -1 and loops again when it is EINTR (4 on Linux and macOS alike).

  7. Question 7 of 12 What is an allowlist in input validation?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: A definition of exactly what is allowed, with everything else rejected

    valid_name is an allowlist: a-z, 0-9 and '-', 1 to 16 bytes. A space, an upper-case letter or anything new is rejected without having been foreseen.

  8. Question 8 of 12 read_line(fd, buf, 16) meets a 40-byte line followed by a newline. How many bytes of the line does it drop?

    Type a number.

    A hint

    It stores up to the capacity and drops the rest of the line, up to the newline.

    Show the answer: Question 8 of 12

    Answer: 24 bytes

    The first 16 bytes are stored and 40 - 16 = 24 are read and dropped, then the newline ends the line and read_line returns -2. The 33-byte line of the run lost 17 bytes the same way.

  9. Question 9 of 12 Why does read_line keep reading to the newline after its buffer is full, instead of returning at once?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: So the rest of the long line is not taken as the next line, which would let the sender choose what that line holds.

    Rejecting a long line means discarding all of it. Reading to its newline keeps the next call aligned on the next real line.

  10. Question 10 of 12 What does umulh x13, x9, x10 put in x13?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: The high 64 bits of the 128-bit product of x9 and x10, read as unsigned

    A 64-bit by 64-bit multiply has a 128-bit result: mul keeps the low half and umulh the high half. A nonzero high half means the product does not fit.

  11. Question 11 of 12 A parser with no overflow checks computes value * 10 + digit in 64 bits. What value does it produce for 18446744073709551616?

    Type a number.

    A hint

    The true value is 2^64. What is 2^64 modulo 2^64?

    Show the answer: Question 11 of 12

    Answer: 0

    18446744073709551616 is exactly 2^64, so the wrapped result is 0. Used as a length or a count, that 0 slips past every later check that only asks for a value below some limit.

  12. Question 12 of 12 Why does one test, cmp w12, #9 then b.hi, after subtracting '0' reject both '/' (0x2f) and ':' (0x3a)?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: '/' - '0' wraps to 0xffffffff and ':' - '0' is 10, and both are above 9 as unsigned numbers.

    Shifting a range down to start at 0 and comparing unsigned turns a two-sided test into one. The same trick checks the letter ranges in valid_name.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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 after EINTR, as read_full did for a record that arrived in two pieces.
  • Check the length before every store, as read_line does, 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 + digit and test both steps, umulh for the multiply and the carry after adds, 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.