Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions LAYOUT.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ linux-kernel-internals/
│ ├── web/ # the browser half: the channel, the shell protocol, the page
│ ├── web/vendor/v86/ # pinned upstream by commit and sha256, BSD-2-Clause, not committed
│ ├── kernel/ # pin.toml, config fragments, build.sh, results
│ └── rootfs/ # a pinned busybox and a thirty line init
│ └── rootfs/ # busybox, strace, three small programs, a thirty line init
├── kxdiff/ # comparing two traces: policy.py says what, levels.py says how strictly
├── kxshapes/ # the nine shapes, as data, drawn by both renderers below
├── kxwidgets/ # shapes.py draws the nine, the rest compose them into widgets
Expand Down Expand Up @@ -167,7 +167,7 @@ There is no profile called kasan and the milestone asked for one. `arch/x86/Kcon

`kxbox/web/headless.js` boots the same kernel under node instead of on a page, which is what makes a boot something CI and a bisect can do rather than something a person has to click through. It shares `serialFor` and `waitForBoot` with `page.js` and everything below them, so the two ways in can disagree about the machine but not about us.

`kxbox/rootfs/` is the userland the kernel boots into: a pinned busybox and a thirty line init that mounts four filesystems and prints one marker. It stops there on purpose. An init that turns a tracer on behind the reader's back makes every lesson about that tracer a lie.
`kxbox/rootfs/` is the userland the kernel boots into: a pinned busybox, a pinned strace built from source because nobody publishes a static 32-bit one, three small programs that each put one system call inside the tracer window so a trace of one has one thing in it, and a thirty line init that mounts four filesystems and prints one marker. The init stops there on purpose. An init that turns a tracer on behind the reader's back makes every lesson about that tracer a lie.

`kxbox/web/serve.py` is there because a blocking worker needs a `SharedArrayBuffer`, a useful `SharedArrayBuffer` needs the page to be cross origin isolated, and that needs two response headers `python3 -m http.server` does not send. Getting it wrong does not look like a header problem, it looks like the emulator hanging, so the headers have a test.

Expand Down
2 changes: 1 addition & 1 deletion kxbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ These three names are separate from the six build profiles in `kernel/pin.toml`.

`kernel/` is the pin, the config fragments and the build script. `pin.toml` says which kernel, from where, with which checksum.

`rootfs/` is the initramfs: a pinned busybox, an init script that mounts the filesystems the lessons need and prints the ready marker, and a build script that makes the cpio. `web/vendor.toml` pins v86 and `tools/vendor.py` fetches it against those checksums.
`rootfs/` is the initramfs: a pinned busybox, a pinned strace built from source, three small programs that each do one thing so a trace of one has one thing in it, an init script that mounts the filesystems the lessons need and prints the ready marker, and a build script that makes the cpio. `web/vendor.toml` pins v86 and `tools/vendor.py` fetches it against those checksums.

## Booting one

Expand Down
15 changes: 14 additions & 1 deletion kxbox/kernel/RESULTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,20 @@ A boot that reaches a shell and cannot trace is a failure for this project even
| `B-btf-external` | yes | yes | yes | yes | yes | no, and that is the point |
| `C-longterm` | not measured | not measured | not measured | not measured | not measured | not measured |

The `A-full` row is what `headless.js smoke` checks, nine checks, each one named after a lesson that stops working without it. Alongside those: 50787 symbols in `/proc/kallsyms`, 16755 functions in `available_filter_functions`, and a real filtered `function_graph` tape came back through the four call protocol in 3.2 seconds.
The `A-full` row is what `headless.js smoke` checks, eleven checks, each one named after a lesson that stops working without it. Alongside those: 50787 symbols in `/proc/kallsyms`, 16755 functions in `available_filter_functions`, and a real filtered `function_graph` tape came back through the four call protocol in 3.2 seconds.

## What the rootfs costs

The kernel is one of two things a reader downloads and the initramfs is the other, so its size belongs in the same table as the image sizes above.

| What is in it | Compressed | Measured |
| --- | --- | --- |
| busybox, three small programs, `abba.ko` | 712410 bytes (696 KiB) | 2026-09-02 |
| the same plus strace 7.2 | 1737376 bytes (1.66 MiB) | 2026-09-06 |

strace more than doubles the initramfs and adds a quarter to what a reader fetches in total, which goes from 3.93 MiB to 4.91 MiB alongside `A-full`. That is the same kind of trade `B-btf-external` was built to price and it has the same answer: everything measured here is served from `127.0.0.1`, so a megabyte costs nothing on this machine and this cannot settle it. What it does settle is the other half. A boot with the larger image still reaches a shell under node in 6.3 seconds and all eleven checks pass, so the extra megabyte does not slow the boot down in any way this can detect. Whether it is worth a quarter of the download is a question for a real network.

Both numbers move by a few bytes between runs, because the cpio carries the modification time of every file in it and gzip compresses those times along with everything else. The strace binary itself does not move at all: two builds from the same tarball in two fresh containers came out with the same sha256, which is the part worth being able to say.

The `no` in the `B-btf-external` row is the profile working. `/sys/kernel/btf` is not there at all, tracing and kallsyms are untouched, and the type information a blueprint needs has to come down as a second file. What that costs a reader is one more download and one more thing that can be missing, which is exactly the trade this profile exists to price.

Expand Down
44 changes: 39 additions & 5 deletions kxbox/rootfs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,52 @@

The kernel is the subject of this book. The rootfs is the smallest thing that lets you talk to it.

One statically linked busybox, three nine kilobyte programs, and one init script. That is the whole image, and it comes to 649 KiB compressed, next to a 3.25 MiB kernel.
One statically linked busybox, one strace, three nine kilobyte programs, and one init script. That is the whole image, and it comes to 1.66 MiB compressed, next to a 3.25 MiB kernel.

## Building it

```sh
sh kxbox/rootfs/build.sh
```

No root. It downloads busybox, checks it against the sha256 in `pin.toml`, asks the binary which applets it has and stops if one the bridge needs is missing, compiles the three programs, then makes a cpio archive with `cpio -R 0:0` so every file is owned by root without anybody having to be root to say so.
No root. It downloads busybox, checks it against the sha256 in `pin.toml`, asks the binary which applets it has and stops if one the bridge needs is missing, compiles the three programs, builds strace, then makes a cpio archive with `cpio -R 0:0` so every file is owned by root without anybody having to be root to say so.

The compiled programs need the container, because they are 32-bit x86 and the machine building them usually is not. That is the same container the kernel is built in, so it adds no toolchain that was not already needed. A machine with no docker gets an image without it and a warning saying what that costs.
Everything compiled needs the container, because it is all 32-bit x86 and the machine building it usually is not. That is the same container the kernel is built in, so it adds no toolchain that was not already needed. A machine with no docker gets an image without any of it and a warning saying what that costs.

The output is `build/initrd.gz` and it is not committed.

## strace

The function graph tracer says what the kernel did once it was inside a call, and how long each frame took. It does not say what the program asked for. The arguments, the file descriptors, the flags and the errno on the way back are not in a tape and no amount of reading one recovers them, because the tracer records entry and exit and those values live in registers it never samples.

Those are the first questions anybody has, and until now the box could not answer them. Here is `writebyte`, which the section below describes in words:

```
execve("/bin/writebyte", ["/bin/writebyte", "/tmp/probe"], 0xbffaf98c /* 6 vars */) = 0
open("/tmp/.writebyte-warmup", O_WRONLY|O_CREAT|O_TRUNC, 0600) = 3
write(3, "x", 1) = 1
close(3) = 0
unlink("/tmp/.writebyte-warmup") = 0
open("/tmp/one-byte", O_WRONLY|O_CREAT|O_TRUNC, 0600) = 3
open("/sys/kernel/tracing/tracing_on", O_WRONLY) = 4
write(4, "1\n", 2) = 2
write(3, "x", 1) = 1
write(4, "0\n", 2) = 2
close(4) = 0
close(3) = 0
write(1, "wrote 1 byte to /tmp/one-byte\n", 30) = 30
exit(0) = ?
+++ exited with 0 +++
```

Everything the section below claims is on that page. The warmup runs the whole sequence once and throws it away. The file is opened before the window rather than inside it. Between the two writes to `tracing_on` there is one `write`, which is the claim all three programs are built on and which nothing was checking until this binary went in.

It is built from the release tarball rather than downloaded, because nobody publishes a static i686 strace. The build is the expensive thing here: about thirty seven minutes on an arm laptop, where almost all of it is qemu translating x86 one instruction at a time, and a couple of minutes on an x86 machine. The result is cached beside busybox and reused, so it happens once.

It costs about a megabyte compressed, which more than doubles the image. Most of that is a static glibc plus the tables that turn a syscall number and six longs into a line somebody can read, and those tables are the entire feature.

Three things it will not do here. `-k` needs libunwind and there is none, so `strace -V` reports `Optional features enabled: (none)`. The `seconds` column of `strace -c` is emulated time on Tier 0 and means nothing, the same as every other duration in the box. And attaching to a process stops it twice per system call, so a tape taken while strace is attached is a tape of ptrace doing its job, which is a real trace of something nobody asked about.

## Modules

The image also carries any `.ko` it finds beside a lesson, which today means `abba.ko` from `lessons/C09/assets`, put in at `/lib/modules` where `insmod` can reach it.
Expand Down Expand Up @@ -52,11 +84,13 @@ Both writes are in one tracer window rather than in two. Two captures taken a se

`corpora/traces/tier0/two-writes.txt` is what it produces.

## Why an upstream binary
## Why busybox is downloaded and strace is built

The busybox here is somebody else's build, downloaded from busybox.net. Building it from source would need a second cross toolchain and a second container, and would make the fast half of Tier 0 as slow as the kernel half.

The checksum is what makes that safe rather than a shrug. `pin.toml` records the version, the URL, the size and the sha256, and the build refuses a binary that does not match. If this ever needs to become a real build, it happens in this directory and nothing above it changes.
strace goes the other way for a duller reason than any argument: there is no static i686 binary to download. So it is built, and the thirty seven minutes that costs is the price of the only option there was.

The checksum is what makes both safe rather than a shrug. `pin.toml` records the version, the URL, the size and the sha256 of each, and the build refuses anything that does not match. For strace the checksum is on the source tarball, which is the thing that came off the network, and `built_bytes` beside it records what the compiler then made of it, so a build that starts producing something a long way from that is a build somebody should look at.

## What init does, and what it deliberately does not

Expand Down
105 changes: 68 additions & 37 deletions kxbox/rootfs/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
#
# ./kxbox/rootfs/build.sh
#
# The result is kxbox/rootfs/build/initrd.gz, which is one busybox, one init script and four empty
# directories. It is about half a megabyte and it is not committed, the same as the kernel.
# The result is kxbox/rootfs/build/initrd.gz, which is one busybox, one strace, three small
# programs, one init script and four empty directories. It is not committed, the same as the
# kernel.
#
# This one needs no container and no root. Everything in it is a regular file, a directory or a
# This one needs no root. Everything in it is a regular file, a directory or a
# symlink, and the device nodes the kernel needs are made by devtmpfs before init runs, which is
# what CONFIG_DEVTMPFS_MOUNT in the teaching fragment is for. A rootfs that needed mknod would need
# root, and asking a reader for root to build a teaching image is a bad trade.
Expand All @@ -18,54 +19,50 @@ OUT="$HERE/build"
STAGE="$OUT/root"
PIN="$HERE/pin.toml"

# One reader for the whole pin file, called as `read_pin <section> <key>`. There were three of
# these, one per section, identical apart from the name in the middle, and a fourth was about to be
# written for strace. A list comes back space separated because that is what a shell loop wants.
read_pin() {
python3 - "$PIN" "$1" <<'PY'
python3 - "$PIN" "$1" "$2" <<'PY'
import sys, tomllib
pin = tomllib.load(open(sys.argv[1], "rb"))["busybox"]
value = pin[sys.argv[2]]
value = tomllib.load(open(sys.argv[1], "rb"))[sys.argv[2]][sys.argv[3]]
print(" ".join(value) if isinstance(value, list) else value)
PY
}

read_programs() {
python3 - "$PIN" "$1" <<'PY'
import sys, tomllib
pin = tomllib.load(open(sys.argv[1], "rb"))["programs"]
value = pin[sys.argv[2]]
print(" ".join(value) if isinstance(value, list) else value)
# Fetch a file once and check it against the sha256 the pin records. Both things this image
# downloads come through here, and neither is used before the checksum has matched.
fetch() {
url=$1
into=$2
want=$3
if [ ! -f "$into" ]; then
echo "fetching $url"
curl -fL --progress-bar -o "$into.part" "$url"
mv "$into.part" "$into"
fi
python3 - "$into" "$want" <<'PY'
import hashlib, sys
digest = hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest()
if digest != sys.argv[2]:
sys.exit(f"checksum mismatch, refusing to build a rootfs\n wanted {sys.argv[2]}\n got {digest}")
print(f"checksum ok: {sys.argv[1].rsplit('/', 1)[-1]}")
PY
}

read_modules() {
python3 - "$PIN" "$1" <<'PY'
import sys, tomllib
pin = tomllib.load(open(sys.argv[1], "rb"))["modules"]
value = pin[sys.argv[2]]
print(" ".join(value) if isinstance(value, list) else value)
PY
}
read_programs() { read_pin programs "$1"; }
read_modules() { read_pin modules "$1"; }
read_strace() { read_pin strace "$1"; }

VERSION=$(read_pin version)
URL=$(read_pin url)
SHA=$(read_pin sha256)
APPLETS=$(read_pin required_applets)
VERSION=$(read_pin busybox version)
URL=$(read_pin busybox url)
SHA=$(read_pin busybox sha256)
APPLETS=$(read_pin busybox required_applets)
BUSYBOX="$OUT/busybox-$VERSION"

mkdir -p "$OUT"

if [ ! -f "$BUSYBOX" ]; then
echo "fetching $URL"
curl -fL --progress-bar -o "$BUSYBOX.part" "$URL"
mv "$BUSYBOX.part" "$BUSYBOX"
fi

python3 - "$BUSYBOX" "$SHA" <<'PY'
import hashlib, sys
digest = hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest()
if digest != sys.argv[2]:
sys.exit(f"checksum mismatch, refusing to build a rootfs\n wanted {sys.argv[2]}\n got {digest}")
print("checksum ok")
PY
fetch "$URL" "$BUSYBOX" "$SHA"

# What the binary says it can do, checked against what the bridge needs. The list of applets is in
# the binary itself, so this is asking it rather than trusting the version number.
Expand Down Expand Up @@ -116,6 +113,40 @@ else
echo " without them: a page fault trace is thirty faults and a write trace is nine writes"
fi

# strace, built from the release tarball rather than downloaded as a binary, because nobody
# publishes a static i686 one and this project is not going to be the first.
#
# The result is cached beside busybox and reused, which matters more here than anywhere else in
# this file. The build is a configure and a make, it took thirty seven minutes the first time it
# ran on an arm laptop, and almost all of that is qemu translating x86 instructions one at a time.
# On an x86 machine it is a couple of minutes. Either way it happens once.
STRACE_VERSION=$(read_strace version)
STRACE="$OUT/strace-$STRACE_VERSION-i686"
if command -v docker >/dev/null 2>&1; then
if [ ! -f "$STRACE" ]; then
TARBALL="$OUT/strace-$STRACE_VERSION.tar.xz"
fetch "$(read_strace url)" "$TARBALL" "$(read_strace sha256)"
echo "building strace $STRACE_VERSION, which takes a while and then never happens again"
docker run --rm -v "$OUT:/out" "$(read_strace image)" sh -eu -c "
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq >/dev/null
apt-get install -y -qq --no-install-recommends $(read_strace packages) >/dev/null
cd /tmp && tar xf /out/strace-$STRACE_VERSION.tar.xz && cd strace-$STRACE_VERSION
./configure $(read_strace configure) LDFLAGS=-static >/tmp/configure.log 2>&1 \
|| { tail -30 /tmp/configure.log; exit 1; }
make -j4 >/tmp/make.log 2>&1 || { tail -40 /tmp/make.log; exit 1; }
$(read_strace strip) src/strace
cp src/strace /out/strace-$STRACE_VERSION-i686
"
fi
cp "$STRACE" "$STAGE/bin/strace"
chmod 755 "$STAGE/bin/strace"
echo "strace: $STRACE_VERSION, $(wc -c < "$STRACE") bytes"
else
echo "no docker, so no strace in this image"
echo " without it: nothing shows which system calls a program made, only what they did inside"
fi

# Any module somebody has built, copied in as it is. Nothing here compiles one, because a module
# needs the kernel tree and the kernel tree needs the container and the volume, which is what
# `kxbox/kernel/module.sh` is for. This just carries whatever is already on disk.
Expand Down
Loading
Loading