Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

10 · A Small Integer That Means a Device

The question

Why does a program name what it reads by number, and what does that number find?

If you arrived from a language with a runtime, you have a picture of a file as an object: a thing with methods, which knows how to read and write itself, and which you hold a reference to. The C and Unix answer is stranger and much smaller. You hold an integer. The integer is not the file, does not point at the file, and means nothing outside the program holding it.

This is where Part I’s claim — that everything is an index — arrives one level up.

The material

Two tables, and the difference between them

A descriptor — the integer — is an index into a table the kernel keeps for each process; here, with no kernel, the machine-mode half of the program keeps it. What the table holds is not the file either: it holds which open file, and the open file is a separate thing in a separate table.

descriptors.c
/* Two tables, and the distinction between them is the whole subject.
 *
 * `descriptors` is indexed by the small integer and holds nothing but a number: which open file
 * this one refers to. `open_files` holds what is actually open, including how far through it we
 * are. Two descriptors can name one open file, and then they share a cursor — which is why a
 * shell can point a program's output somewhere and have it append rather than overwrite.
 *
 * Putting the cursor in the descriptor instead would be simpler and would quietly make `dup` a
 * copy rather than a second name. It is the same distinction the fork chapter's `fork` turns on: the table
 * is copied, the things it refers to are not. */
#define OPEN_FILES 4
#define NO_FILE (-1)

static int descriptors[SLOTS];
static struct open_file open_files[OPEN_FILES];
static char scratch[SCRATCH_BYTES];

/* The length of what is in `scratch`, as opposed to where any descriptor currently is. */
static uint64 written_bytes;

Collapsing those two into one would be simpler and is the obvious first design. It is also wrong: two descriptors can refer to one open file and share its position, which the last section of this chapter shows.

Two things worth having a table for

With one possible destination, an indirection demonstrates nothing: every descriptor would find the same thing and the table would be decoration. So there are two backends — things a descriptor can find. One is a real device at a physical address; the other is an array of bytes with a cursor, which is a few lines long and is not a file system and is not pretending to be one.

descriptors.c
/* The two things a descriptor can find. A third would need nothing but another case here and
 * another `kind`, which is the point: the table is the interface. */
enum backend { BACKEND_NONE, BACKEND_CONSOLE, BACKEND_MEMORY };

The dispatch is a switch, and a third kind of backend would be one more case. The caller never learns which backend it got.

The same call, twice

descriptors.c
    /* 1. The same call, twice, differing in one register — and reaching a device the first time
     *    and an array in memory the second. Neither the calling code nor the compiler knows. */
    wrote_to_console = bare_call(SYS_WRITE, 1, (uint64)message, length);
    wrote_to_memory = bare_call(SYS_WRITE, 2, (uint64)message, length);
    same_call_reached_both = (wrote_to_console == length) && (wrote_to_memory == length);

Two calls, identical but for one number, and they end up in completely unrelated places — one in a device register, one in an array in memory. Neither the calling code nor the compiler knows the difference, which is how a shell can point a program’s output at a file without the program being told.

Run it and watch:

./run descriptors

The hello on the first line of output is the console write arriving. Everything after it is the program reporting on the other one.

The position is not a record of what you last did

Write six bytes into the array and the position is at six. So read them straight back:

descriptors.c
    /* 2. Read it straight back, without moving the cursor. This is the part worth running the
     *    program for: the write left the position at the end, so there is nothing after it to
     *    read, and a call that looks obviously correct returns nothing at all. */
    read_without_rewinding = bare_call(SYS_READ, 2, (uint64)taken, length);

It returns nothing. Not an error, not a short read — zero bytes, from a call that is plainly correct and an array that plainly has six bytes in it.

A descriptor’s position is where you are in the open file, not a note about whether you were last reading or writing, and that is the one thing about descriptors that catches everybody. The write left it at the end, and the end is where the read started, and after the end there is nothing. There is no separate read position to fall back on, because there is no separate read position at all.

descriptors.c
    /* 3. Move the position, then read. Same descriptor, same array, different answer. */
    bare_call(SYS_SEEK, 2, 0, 0);
    read_after_rewinding = bare_call(SYS_READ, 2, (uint64)taken, length);

Same descriptor, same array, same call — and six bytes this time, because the position moved first. read and write here are the same two lines apart from which direction the bytes go:

descriptors.c
/* Reads move the cursor exactly as writes do, because there is only one cursor and it is the
 * position *in the open file* rather than a position belonging to reading or to writing.
 *
 * That is the whole of why a program that has just written something and wants to read it back
 * has to move the cursor first. It is the commonest surprise about file descriptors and it is
 * not a quirk: the position is where you are, not what you last did. */
static uint64 read_from(int fd, char *into, uint64 count) {
    struct open_file *file = file_for(fd);
    if (!file || file->kind != BACKEND_MEMORY) {
        return BAD_DESCRIPTOR; /* the console here is write-only, and says so rather than lying */
    }
    uint64 taken = 0;
    while (taken < count && file->cursor < written_bytes) {
        into[taken++] = scratch[file->cursor++];
    }
    return taken;
}

What dup shares

dup copies a descriptor onto another number, and it is the reason the two tables had to be two:

descriptors.c
static uint64 duplicate(int fd, int onto) {
    if (onto < 0 || onto >= SLOTS || !file_for(fd)) {
        return BAD_DESCRIPTOR;
    }
    /* One number is copied. The open file it refers to is not, so afterwards two descriptors are
     * two names for one thing — including one cursor between them. */
    descriptors[onto] = descriptors[fd];
    return (uint64)onto;
}

One number is copied. The open file it refers to is not — so afterwards two descriptors are two names for one thing, and they share its position.

You can watch that happen rather than take it on trust. Six bytes have been written through descriptor 2; the program duplicates it onto 3 and writes one byte through the copy:

descriptors.c
    /* 5. Duplicate, then write through the copy. Because the two numbers name one open file they
     *    share its position, so this lands *after* what is already there rather than on top of
     *    it. That is what makes redirecting a program's output an append rather than a
     *    truncation, and it is the reason the cursor lives where it does. */
    dup_result = bare_call(SYS_DUP, 2, 3, 0);
    wrote_through_dup = bare_call(SYS_WRITE, 3, (uint64)"!", 1);
    cursor_after_dup_write = bare_call(SYS_TELL, 2, 0, 0);

The byte lands at offset six, the position ends at seven, and reading the array back gives seven bytes. Had the position lived in the descriptor instead, the duplicate would have started from zero and written its byte over the h — both programs would still run, and the difference would surface much later as a file mysteriously overwriting itself from the beginning.

That shared position is the whole reason a second write continues rather than overwrites. Two descriptors onto one open file share one offset, so the write picks up where the last one left off — a consequence of where the position already was, not a mode the write asks for.

What we measured

Run it yourself before reading the table — the rows below are what you should see, and a figure you have reproduced is worth more than one you have been shown:

./run descriptors
One table, two destinations, one positionObserved
Descriptor slots4
Open-file slots4
Kinds of thing a descriptor can find2
One write, two destinations, one register differentyes
Where the position is after writing six bytes6
Bytes a read returns from there0
…and after moving the position back to the start6
The bytes read back are the bytes writtenyes
An unopened descriptor is refusedyes
A duplicate is a second name, not a copyyes
Position after one byte through the duplicate7
…which landed after the six, not on themyes
Bytes in the array altogether7

Conditions: target bare · qemu virt (-machine virt -bios none) · QEMU emulator version 8.2.2 (Debian 1:8.2.2+ds-0ubuntu1.18) · no firmware, no kernel · riscv64-linux-gnu-gcc · -march=rv64g -mabi=lp64 -mcmodel=medany -ffreestanding -nostdlib -fno-pic -fno-stack-protector -fno-builtin -O1 -g -Wall -Werror · 2026-09-17 · Source: bench/results/descriptors-bare.json, code hash ca976726e794d978.

What this cannot tell you

Anything about cost. A write here is a function call behind a trap. On a real system it is a trap, a permission check, a copy from the program’s memory into the kernel’s, and possibly a device. ch29 prices the real one.

What an open file really contains. A kind and a cursor is the smallest thing that shows the sharing. A real one has a mode, a reference count, a position that several processes may be contending over, and a pointer to something that knows how to be read. ch22 is that, in a kernel.

How the table gets entries. There is no open here, because there is nothing to open: the two backends are set up before the program starts. So the most interesting question about descriptors — how a name becomes a number — is entirely absent, and ch22 answers it.

What the numbers 1 and 2 mean. They mean what the two lines in main say they mean. On a real system 0, 1 and 2 are a convention held up by the program that started you, not by the kernel, and the convention is worth exactly as much as everyone’s agreement to keep it.

Problems

Three, in tests/a_small_integer_that_means_a_device/.

10.1 — Duplicate onto a number in use. dup here overwrites whatever was at the target. Decide what should happen when the target is already open, implement it, and justify the choice. The test checks your behaviour is consistent and that you did not simply refuse every duplicate.

python3 -m pytest tests/a_small_integer_that_means_a_device/test_problem_1_onto_open.py

10.2 — Add a third backend. Add a destination that discards everything written to it and reads back as nothing. Show that the calling code does not change. The test checks the caller is byte-identical across all three descriptors.

python3 -m pytest tests/a_small_integer_that_means_a_device/test_problem_2_third_backend.py

10.3 — Put the cursor in the wrong place. Move the cursor into the descriptor table and produce a program in which that change is visible in the output. Say in one sentence which real-world behaviour would break. The test checks the shared cursor claim now fails, and that your program shows it failing rather than asserting it.

python3 -m pytest tests/a_small_integer_that_means_a_device/test_problem_3_wrong_place.py

Where to go next

There is no specification for this one: a descriptor is not a hardware concept and nothing in the RISC-V documents has heard of it. The nearest primary source is POSIX The Open Group and IEEE (2018) on dup and write, and it is worth reading dup’s wording specifically — the standard is careful about what is shared in a way that only makes sense once you have built the two tables.

ch11 makes a second process, and the first question it has to answer is which of these two tables the child gets a copy of.

References
  1. The Open Group and IEEE. (2018). The Open Group Base Specifications Issue 7, 2018 edition (IEEE Std 1003.1-2017). https://pubs.opengroup.org/onlinepubs/9699919799/