Vex
Vex is a hobby operating system kernel written in C (gnu2x) and built with
Meson. Limine boots it over UEFI. amd64 is the only implemented architecture:
the Meson build knows the riscv64 and aarch64 names, but there is no
kernel/arch/ code for them yet.
This book is the architecture guide. It explains how the kernel is put together
and why each part is done the way it is. It is not an API reference: the
declarations in kernel/include/ carry their own /// documentation, and
clangd shows it on hover.
Where to start
- Build the kernel and the test image.
- Run it in QEMU.
- Source map to find where something lives.
- Overview for the whole picture.
How this book is organised
- Getting started covers the build, the test image and the source tree.
- Architecture describes the kernel-wide mechanisms: boot, memory, processes, system calls, the VFS, the device model.
- Drivers, Filesystems and Buses hold one page per implementation. A page exists when the implementation makes a decision worth explaining.
- Reference holds tables: syscalls, the ABI, capabilities, device names.
- Development covers testing and how to update this book.
Status
The getting-started pages are complete. Most architecture pages are still
stubs. Until they are complete, docs/kernel/architecture.md describes the
kernel-wide mechanisms, and CONTRIBUTING.md holds the full style rules.
Build
Meson and Ninja build Vex. Every build is a cross build: the kernel runs on amd64, and the host that builds it is some other machine. Meson stops with an error when the setup has no cross file.
Requirements
| Tool | Use |
|---|---|
| Meson 1.3.2 or later, Ninja | Configure and build |
| Python 3 | Read the Kconfig tree |
| Clang and LLD | Compile and link the kernel |
A GNU-compatible ld | The final link |
sh | The image and QEMU scripts |
sfdisk, mkfs.fat, mtools, mke2fs | The test image |
| QEMU and OVMF | Run the kernel |
- Kconfiglib sits in
contrib/kconfiglib. Python needs no extra packages. - The build runs on Linux and on WSL. Native Windows is not supported.
- The test image tools and QEMU are only needed for the test image and for running.
Compilers
cross/amd64-clang.ini is the tested cross file. It uses clang, ld.lld
and llvm-ar, and it adds -Wdocumentation, so a broken /// block fails
the build.
cross/amd64-gcc.ini uses an x86_64-elf-* toolchain. The
vex-os/bootstrap repository builds the kernel with it. Do not expect it to
work without that toolchain.
The kernel link does not use the linker from the cross file. It calls ld
from PATH directly, because Clang has no bare-metal driver for amd64.
Install GNU ld, or make ld name LLD. Both understand the link script.
Configure
meson setup build --cross-file cross/amd64-clang.ini
The screenshot comes from a kernel checkout inside the vex-os/bootstrap
repository, so its paths differ from yours.
The output names the cross file, the compiler, the host and the target, the
generated files and the number of build targets. Meson reads x86_64 from the
cross file and the build maps it to the amd64 arch name.
The setup options are:
| Option | Meaning |
|---|---|
-Ddefconfig=<name> | File in configs/ that seeds .config. The default is <arch>_defconfig |
-Dktest=false | Do not build the test image, the runner or the test programs |
To change an option after the setup, run meson configure build -D<option>
and then ninja -C build. Meson reconfigures on its own when a Kconfig file
changes.
Build
ninja -C build
The build writes the kernel to build/kernel/vexsys.elf. With the test layer
enabled, the same command also builds the test programs.
| Target | Output |
|---|---|
ninja -C build | build/kernel/vexsys.elf |
ninja -C build ktest-image | build/ktest/vexsys.boot.img |
ninja -C build ktest-run | Builds the image and boots it in QEMU |
The project sets werror=true and warning_level=2. A new warning fails the
build. For a local experiment only, add -Dwerror=false at setup.
Configuration
Vex uses Kconfig, the configuration language that Linux uses. Kconfiglib is vendored, so the configuration works without a Linux source tree.
The model
The configuration is a tree of symbols. Each symbol has a type, a prompt, a default and help text. The types are:
bool, on or offintandhex, a number with an optional rangestring, free textchoice, one symbol out of a group
A symbol can depend on another symbol through depends on. It then exists
only when that symbol is set. select forces another symbol on. This is how
ARCH_AMD64 selects ARCH_HAS_SMP: the arch code offers SMP, and the
generic code tests for it.
Each directory has its own Kconfig file. The file holds the help text for
its options, so the description of an option sits next to the code that reads
it. The menu shows that text.
The menu
ninja -C build menuconfig
The top level groups the options by subsystem. The entries follow
kernel/Kconfig, which sources one file per directory.
The help pane shows the text from the Kconfig file, then the type, the
default, the dependencies and the location in the tree.
A choice offers one value out of a list. The framebuffer font is one: the
list holds every font that kernel/fbcons/Kconfig declares, and the chosen
symbol names the file that the build converts.
The config file
The configuration lives in build/.config. At configure time,
scripts/kconfig/sync_config.py does this:
- If
build/.configexists, load it. - If it does not exist, copy the defconfig into it.
The defconfig holds only the values that differ from the Kconfig defaults.
configs/amd64_defconfig is empty for that reason: every option is at its
default.
The defconfig is a seed, not a mirror. A change to the defconfig does
nothing to a build tree that already has a .config. Delete build/.config,
or run ninja -C build defconfig, to seed the tree again.
To change the defaults, run ninja -C build savedefconfig and copy
build/defconfig over configs/<arch>_defconfig.
In the code
At build time, scripts/kconfig/gen_config_h.py turns .config into
build/kernel/generated.config.h:
- an enabled
boolbecomes#define CONFIG_X 1. - a disabled
boolhas no definition. - an
int,hexorstringbecomes a macro with its value.
The build force-includes that header into every kernel translation unit with
-include. CONFIG_* macros are always visible, so never include the header.
meson.build gates an optional file:
if kconfig.get('CONFIG_EXT2', 'n') == 'y'
subdir('ext2')
endif
Do not add an #ifdef around a whole file. The gate belongs in Meson, so the
file does not build at all when its option is off.
How the build works
Kconfig tree -> build/.config -> generated.config.h
(force-included in every unit)
kernel/meson.build
+-- one archive for each top directory of kernel/
+-- ld --whole-archive -T kernel/arch/amd64/kernel.ld
+-- build/kernel/vexsys.elf
The archives keep the top directories independent. Each meson.build only
appends its files to kernel_sources. The table in kernel/meson.build names
the directories and the log tag of each one. A new top directory needs a row
in that table.
The source map explains the directories and the archives.
Traps
- A change to the defconfig does nothing while
build/.configexists. -Ddefconfigtakes a file name insideconfigs/, not a path.- The generated config header is force-included. Do not include it.
- An optional file needs a Kconfig symbol and a Meson gate, not an
#ifdef. - A top directory without a
kernel_subsystemsrow is not built and does not exist as an archive.
Run
The Vex repository has no userspace. The kernel loads one program from the root filesystem and gives control to it. This page makes a disk that boots such a program, and explains what that program must do.
The test image boots kst, a test runner. It is one userspace, and
the next page covers it. This page covers the general case.
What a boot needs
Three parts:
- Limine, the bootloader, as an EFI executable
vexsys.elf, the kernel- a block device with a root filesystem and an init program on it
The kernel receives one command line from Limine. It names the root device, the init path, the consoles and the log level.
The first process
In a Unix-like system, process 1 is the first user process. The kernel does not start a shell or a service manager. It starts one program, and that program starts everything else. Process 1 adopts the orphans and reaps the zombies. Linux, the BSDs and Vex all work this way.
Vex does this in kmain():
- Run the BSP initcalls.
- Mount the device from
root=at/. - Open the program from
init=, orCONFIG_DEFAULT_INIT. - Load it and spawn it.
- Enable interrupts and idle.
See kernel/init/kmain.c.
In the test image, kst is process 1. A test runner needs the rights and the
duties of init: it forks the tests, waits for them and owns the terminal. When
it exits, the kernel panics, because init must not die.
The contract
| Property | Value |
|---|---|
| Path | init= or CONFIG_DEFAULT_INIT, resolved in the root filesystem |
| Argument vector | { path, NULL } |
| Environment | Empty |
| File descriptors | None open |
| Credentials | uid 0 and the full capability set |
| Executable | ELF64, static, ET_EXEC |
| Stack | 128 KiB mapped at exec, grows to RLIMIT_STACK |
| Working directory | / |
The consequences:
- The program must open its own console. The kernel gives it no standard input, output or error.
- The kernel mounts only the root filesystem. If init wants
/dev, it makes the directory and mounts devfs. If it wants/tmp, it mounts tmpfs. - The executable must not have
PT_INTERPorPT_DYNAMIC. The loader returnsENOSYSfor both, so there is nold.soand no shared library yet. - Nothing reaps for init. It must wait for its own children.
exitfrom init is fatal to the machine. Shutdown is a separate system call.
The working example is kst itself: it mounts devfs, opens /dev/tty1337,
makes it the controlling terminal and duplicates it onto 0, 1 and 2. See
ktest/kst.c.
Make a disk
The test image has a GPT disk with an EFI system partition, an ext2 root and a FAT partition for the FAT tests. A userspace image needs the first two.
The commands below make a 40 MiB disk with the same layout. The kernel and the bootloader go on the ESP, the root filesystem in the second partition.
# 1. An empty disk with an ESP and a root partition
dd if=/dev/zero of=vex.img bs=1M count=40
sfdisk --quiet --force vex.img <<'EOF'
label: gpt
start=2048,size=16384,type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B,name=ESP
start=18432,size=53248,type=0FC63DAF-8483-4772-8E79-3D69D8477DE4,name=ROOT
EOF
# 2. The filesystems
mkfs.fat -F16 -s 1 --offset 2048 vex.img $((16384 / 2))
mke2fs -q -F -t ext2 -b 4096 \
-E offset=$((18432 * 512)),root_owner=0:0 \
-d root vex.img $((53248 / 2))k
# 3. Limine, the kernel and the config on the ESP
esp="vex.img@@2048s"
mmd -i "$esp" ::/EFI ::/EFI/BOOT
mcopy -i "$esp" contrib/limine/BOOTX64.EFI ::/EFI/BOOT/BOOTX64.EFI
mcopy -i "$esp" build/kernel/vexsys.elf ::/vexsys.elf
mcopy -i "$esp" limine.conf ::/limine.conf
The root/ directory holds the root filesystem. It needs an init program
and the files that the program opens:
root/
└── init
mke2fs -d copies the tree, and root_owner=0:0 makes root own it. The
partition sizes come from ktest/mkimage.sh. Any size works if the filesystem
fits.
The @@2048s suffix is mtools syntax for an offset in sectors. mmd and
mcopy write into the FAT filesystem inside the image without a loop device.
The Limine config
Limine reads /limine.conf from the ESP. On EFI it also tries the directory
of its own executable, then /boot/limine/limine.conf, /boot/limine.conf
and /limine/limine.conf. See the
Limine configuration reference.
timeout: 0
verbose: yes
/Vex
comment: Vex
protocol: limine
path: boot():/vexsys.elf
cmdline: root=ada0n1p2 init=/init console=serial1,115200 serial=serial1,tty0
kaslr: yes
timeout: 0boots the entry at once, with no menu.pathis<resource>(<argument>):/<file>.boot()means the partition that holds this config file, which is the ESP.protocol: limineis the Limine protocol, which the kernel uses for its requests.cmdlineis the kernel command line. See below.kaslr: yesasks Limine to place the kernel at a random base. The kernel asks Limine for its base address.
The config is a set of menu entries and options. A line that starts with /
opens an entry, and the options after it belong to that entry. Lines that
start with # are comments.
Boot it
qemu-system-x86_64 \
-bios contrib/edk2/RELEASEX64_OVMF.fd \
-machine type=q35 \
-drive format=raw,file=vex.img \
-serial mon:stdio \
-vga std \
-m 512M \
-smp 4
-biosloads the OVMF firmware. The repository vendors it.-driveattaches the disk as an ATA disk. The kernel names itada0.-serial mon:stdioconnects the first serial port to the terminal. Ctrl-A X quits QEMU, and Ctrl-A C opens its monitor.-vga stdgives the kernel a framebuffer.
Under WSL, call qemu-system-x86_64.exe and convert the paths with
wslpath -a -w. ktest/qemu.amd64.sh does this for the test image.
The kernel logs to the console. When the init opens its terminal, the init writes there too.
The example is the test image, because kst is the only init that exists
today. The kernel log and the init share the serial port.
The command line
The kernel reads the command line as a list of key=value words. The keys
that matter for a boot are:
| Option | Meaning |
|---|---|
root=<device> | Block device to mount at /. A name such as ada0n1p2 |
init=<path> | First process. Defaults to CONFIG_DEFAULT_INIT |
console=<name>[,<options>] | Register a kernel console |
serial=<port>,<consumer>[<index>] | Bind a serial port to a tty or a test device |
unit=<name>,<device path> | Pin a device name to a node path |
loglevel=<level> | Lowest level that the kernel logs |
The consoles are serial<N> for a serial port and fbd0 for the
framebuffer. The kernel passes the options to the console. A serial console
takes its baud rate. A key can repeat: one console= per console, one unit=
per device.
Device names are <prefix><unit>n<index>p<partition>. In ada0n1p2, ada is
the ATA class, 0 is the controller, 1 is the port or connector and 2 is
the partition. The probe order decides the numbers. unit= pins a number to a
node path, as /dev/hwtree shows it. The test image pins its serial ports for
this reason.
loglevel takes verbose, inform, notice, warning, error,
critical or alert. The default shows the important messages.
Consoles
The kernel can draw its log in two places at once:
- Serial.
console=serial1,115200sends the log to the first serial port. QEMU connects that port to the terminal with-serial mon:stdio. - Framebuffer.
console=fbd0draws the log on the framebuffer that Limine set up. This is thefbconsbackend, and it needsCONFIG_FBCONS.
The same boot on fbd0. The framebuffer console shows the kernel log only.
The init writes to its own terminal.
Both consoles can be active. The test image sets both, so a failure to open the serial port still leaves the log on the screen.
Limits
- The init is static ELF64. Dynamic linking needs the loader support that does not exist yet.
- The kernel does not ship a C library. The system call numbers and the user
ABI are in
kernel/include/usr/vex/. - The kernel mounts only
root=. Everything else is the init’s job. - An init that exits panics the kernel. Use the shutdown system call to stop the machine.
The test image and kst
ktest/ is the kernel’s own test image. It holds the Limine config, the
script that makes the disk, the test runner kst and the test programs that
kst runs. It is not the userspace: it exists to check that the kernel
behaves as it should.
The image boots kst as init. kst runs on the kernel as a normal process,
with the full capability set, and forks the tests.
The image
The image is a 40 MiB GPT disk with three partitions:
| Partition | Sectors | Filesystem | Contents |
|---|---|---|---|
| ESP | 2048 to 18432 | FAT16 | BOOTX64.EFI, limine.conf, vexsys.elf |
| DATA | 18432 to 71680 | ext2 | kst.elf, kst/*.elf, the test tree |
| FAT | 71680 to 79872 | FAT16 | DIR/, one long file name |
The kernel names them ada0n1p1, ada0n1p2 and ada0n1p3. Limine boots from
the ESP and the kernel mounts DATA at /. The FAT tests write to ada0n1p3.
The ext2 partition holds a tree that the tests need:
hello.txt,dir/nested/file.txt, a symlink and a long symlinkempty/andmany/, the second with 200 long file namesbig.bin, 5 MiB, which reaches the double indirect blocksparse.bin, a file with a holedev/andtmp/, the mount points thatkstand the tests usekst.elfat the root and one ELF per test underkst/
ktest/mkimage.sh makes the disk. The offsets live in ktest/meson.build and
in the script.
Build and boot
ninja -C build ktest-image # build/ktest/vexsys.boot.img
ninja -C build ktest-run # build the image and boot it
The image needs sfdisk, mkfs.fat, mtools (mmd, mcopy) and mke2fs.
ninja -C build ktest-run runs ktest/qemu.amd64.sh, which starts QEMU with
OVMF and attaches the image.
-Dktest=false at configure time excludes the whole directory, so the two
targets disappear.
ktest/limine.conf.in is a template. The build fills in the port names:
| Port | Name | QEMU | Device path |
|---|---|---|---|
| Shell | serial1 | COM1, stdio | acpi:PCI0/pci:0000:00:1f.0/isa:3f8 |
| Log | serial2 | COM2, file | acpi:PCI0/pci:0000:00:1f.0/isa:2f8 |
The kernel command line is:
root=ada0n1p2 init=/kst.elf
unit=serial1,acpi:PCI0/pci:0000:00:1f.0/isa:3f8
unit=serial2,acpi:PCI0/pci:0000:00:1f.0/isa:2f8
serial=serial1,tty1337
console=serial1,115200 console=serial2,115200 console=fbd0
The config pins the ports with unit=. The probe order does not decide the
names.
serial1 becomes the tty tty1337, and kst opens it. The kernel writes its
log to both serial ports and to the framebuffer console.
kst
kst is init. It does what an init must do:
- Make
/devand mount devfs. - Open
/dev/tty1337. - Make it the controlling terminal with
TIOCSCTTY. - Duplicate it onto file descriptors 0, 1 and 2.
- Set the line discipline for a serial terminal.
- Clear the screen and print the banner.
Before step 2 it writes to kplog, because it has no output yet. If the
terminal does not open, the message says so and names serial= on the command
line.
The commands are:
| Command | Effect |
|---|---|
help | Print the command list |
list | Print the tests in the order that all runs them |
run <test|path>... | Run tests by name, or programs by path |
all | Run every test |
clear | Clear the screen |
exit | Leave, which panics the kernel because init died |
run forks, execs /kst/<name>.elf and waits. The child leads a process
group and owns the terminal. Ctrl-C then reaches the test, not the runner.
The runner prints [ OK ] or [FAIL] with a timestamp for each program, and
the reason for a failure: an exit status, a signal or a failed waitpid.
A test that draws on the framebuffer: the fbtest logo.
The same test fills the screen. The framebuffer test runs last, because it takes the machine over.
The QEMU wrapper
ktest/qemu.amd64.sh connects the shell port to stdio and the log port to
kernel.log. It switches the terminal to raw mode: the terminal would
otherwise echo each line and the guest would echo it again. Under WSL it
calls qemu-system-x86_64.exe and converts the paths.
| Argument | Effect |
|---|---|
slow | Enable -icount. One instruction takes 1 ns |
slow=N | One instruction takes 2^N ns. A bigger N is slower |
verbose | Add -d cpu_reset,int -no-reboot -no-shutdown |
N | Log to qemu_N.log instead of qemu.log |
nocpurng | Hide RDRAND and RDSEED from the guest |
The script removes qemu.log and kernel.log at the start. The kernel log is
also on the framebuffer console, which survives a crash that loses the file.
Ctrl-A X quits QEMU. Ctrl-A C opens the QEMU monitor.
After a run
The tests write to the ext2 partition. Check it after a run:
e2fsck -fn "build/ktest/vexsys.boot.img?offset=$((18432 * 512))"
The offset is the first sector of the DATA partition in bytes. e2fsck
accepts the ?offset= suffix and reads the filesystem inside the image.
Tests
Each test is a separate ELF from ktest/test.<name>.c. ktest/meson.build
lists them in ktest_programs, in the order that all runs them. A test
returns 0 for success and checks with ktest_expect.
Writing a test explains the test ABI.
Source map
The kernel lives in kernel/. Each top directory is one subsystem, one
archive and one log tag. This page explains the layout, the rules that keep
the directories apart, and where new code belongs.
The tree
kernel/
|-- arch/amd64/ paging, CPU, IRQs, timers, ACPI glue
|-- bus/ PCI, ISA and IDE buses
|-- core/ kprintf, kplog, consoles, panic, cmdline
|-- crypto/ blake2s, chacha20, crc32
|-- dev/ block, char, fb, serial and tty interfaces
|-- device/ device tree, binding, resources, /dev/hwtree
|-- drivers/ storage, display, input, serial hardware
|-- exec/ binfmt registry and the ELF64 loader
|-- fbcons/ framebuffer console and fonts
|-- firmware/ fwnode providers, ACPI through uACPI
|-- fs/ VFS and the devfs, echfs, ext2, fat, tmpfs backends
|-- include/ the kernel API and the user ABI
|-- init/ kmain and the initcall runner
|-- ipc/ pipes
|-- irq/ IRQ core and interrupt controllers
|-- libk/ freestanding C support
|-- mm/ physical and virtual memory
|-- proc/ processes, threads, signals, syscalls
|-- random/ entropy sources, /dev/random, /dev/urandom
`-- time/ clocksources, clock events, RTC, wallclock
| Directory | What lives there |
|---|---|
arch/amd64/ | The arch half of mm, proc, irq, time, firmware, bus and core, plus the linker script |
bus/ | One directory per bus: pci/, isa/, ide/ |
core/ | Kernel messages, consoles, panic, the command line, the version |
crypto/ | The hashes and ciphers that other subsystems use |
dev/ | The interface registries that userspace sees, partition tables, ATA helpers, device names |
device/ | The device tree, driver binding, resources, the /dev/hwtree device |
drivers/ | Hardware drivers, one directory per class |
exec/ | The binary format registry and the ELF64 loader |
fbcons/ | A console backend that draws on a framebuffer |
firmware/ | What the firmware describes, including ACPI |
fs/ | The VFS and the filesystem backends |
init/ | kmain() and the code that walks the initcall sections |
ipc/ | Inter-process communication. Pipes today |
irq/ | Interrupt delivery and the interrupt controller interface |
libk/ | The freestanding functions that the kernel needs from C |
mm/ | Page allocators, page tables, the heap, commit accounting |
proc/ | Processes, threads, the scheduler, signals, credentials, system calls |
random/ | The random pool and its sources |
time/ | Timekeeping and timers |
Archives
Each top directory builds into its own static archive. The table in
kernel/meson.build names the directories:
- The key is the directory, such as
fsorarch/amd64. - The value is the log tag, such as
KP_FS, that everypr_*call in that archive uses.
The archive receives the tag with -DKP_SUBSYSTEM=<tag>. A file that moves to
another top directory logs under the new tag.
Two details follow from the table:
- A new top directory needs a row. Without one, the directory is not built.
fbconsis a top directory that logs asKP_CORE.firmwarealso builds the vendored uACPI sources into its archive.
The build links the archives with ld --whole-archive. Every object then
survives the link. That is what keeps the registration sections below intact.
Sections, not calls
A top directory does not call another directory’s init function. It registers an entry in a linker section, and one generic file walks that section. Four registries work this way:
| Registry | Macro | Section | Walker |
|---|---|---|---|
| Initcalls | initcall_bsp_*, initcall_smp_* | .initcalls.bsp.*, .initcalls.smp.* | kmain() and the per-hart code |
| System calls | define_syscall, define_syscall_fd, define_syscall_dirfd | .syscalls | proc/syscall.c |
| Drivers | define_driver | .drivers | The device core |
| Random sources | define_random_source | .random_sources | random/random_source.c |
The linker script keeps these sections and sorts the initcalls by name. The name starts with a stage number, so the sort gives the boot order. The order is: early, arch, bootinfo, pmm, slab, mmu, heap, irq, timer, syscall, multicore, bus, vfs, dev, fs, cpudev, binfmt, services, late.
This pattern is the reason the directories can stay independent. It also means that the boot order is data, not code: a new initcall belongs where its stage says, not where a call site says.
Headers
| Path | Role |
|---|---|
kernel/include/vex/ | The kernel API, included as <vex/...> |
kernel/include/usr/vex/ | The user ABI: errno, stat, fcntl, syscall numbers |
kernel/arch/<arch>/include/arch/ | What the arch provides to generic code, included as <arch/...> |
kernel/arch/<arch>/include/arch/<arch>.*.h | Arch-private headers that generic code must not include |
<name>_internal.h | What the files of one directory share, included as "<name>_internal.h" |
Rules:
- A generic header such as
vex/pmap.hincludes<arch/pmap.h>and declares the arch-neutral interface. Generic code includes thevex/header only. - A userspace header re-exports the
__k_types fromusr/vex/as_ttypedefs, for exampletypedef struct __k_stat stat_t;. - A filesystem has no public header. Other code reaches it through the VFS and names its driver with a string.
- A kernel service that needs files of its own has a public header under its
own name, such as
vex/shmem.h.
Headers under include/usr/ must compile as C89. Kernel headers use
#pragma once only.
Generic and arch
kernel/arch/amd64/ mirrors the generic directories and implements their
arch-specific halves:
kernel/mm/ kernel/arch/amd64/mm/
kernel/proc/ kernel/arch/amd64/proc/
kernel/irq/ kernel/arch/amd64/irq/
kernel/time/ kernel/arch/amd64/time/
kernel/firmware/ kernel/arch/amd64/firmware/
kernel/bus/ kernel/arch/amd64/bus/
kernel/core/ kernel/arch/amd64/core/
A file that the architecture implements lives in its arch directory. A file
that the architecture only offers to the generic code lives in
include/arch/. amd64 is the only implemented architecture. The build knows
the names of riscv64 and aarch64, but there is no code for them.
Naming and placement
- Every kernel type has a
_ttypedef. The tag ends in_s,_uor_e:struct thread_s,union fat_entry_u,enum cpu_feature_e. - A public function is
<subsystem>_<verb>:block_interface_add_root, notadd_block_interface_root. - Name a variable after the stem of its type:
thread_t* thread. A role name such asparentorcurris better when it says more. - Use whole words in new names. Established terms stay:
vnode,pmap,pslab,pdma,hart,irq. - Do not rename an existing file. The short file names will be renamed together later.
Placement follows the device model:
- A driver belongs in
drivers/<class>/. - A class core, such as the tty layer, belongs in
dev/. - A device that shows a kernel subsystem to userspace stays with that
subsystem:
/dev/hwtreeindevice/,/dev/kplogincore/. - A filesystem belongs in
fs/<name>/and registers withvfs_driver_add. - Vendored code belongs in
contrib/, not inkernel/.
CONTRIBUTING.md holds the full style rules.
Where to add things
| Task | Touch |
|---|---|
| A driver | A file or directory in kernel/drivers/<class>/, a Kconfig entry, a Meson gate, define_driver |
| A filesystem | kernel/fs/<name>/, a Kconfig entry, a Meson gate, vfs_driver_add |
| A system call | A number in include/usr/vex/syscall.h, a handler in proc/sys_*.c with define_syscall* |
| An initcall | A function with initcall_bsp_<stage>(fn, INIT_*) or initcall_smp_<stage>, right after its definition |
| A top directory | The directory and its meson.build, a row in kernel_subsystems |
| A Kconfig option | A config block in the directory’s Kconfig, a gate in its meson.build |
| A test | ktest/test.<name>.c and a line in ktest_programs |
| A font | A .bin file in fbcons/fonts/, an entry in the FBCONS_FONT choice and a default in FBCONS_FONT_FILE |
A new system call needs no dispatch table change. The registration macro
places the handler in the .syscalls section, and proc/syscall.c builds the
table at the syscall stage.
Overview
Vex is one kernel binary. Limine jumps to kmain, the kernel prepares its
parts through initcalls, and then it creates the first process. This page
gives the layer map of the source tree and follows the path from boot to
process 1.
The kernel has many parts, and no one function can prepare them in a safe order. An initcall places each preparation in a linker section, and one runner walks the sections. The code declares no layer object and no dependency graph. The map below groups the top directories by their work. The initcall stage order is the only boot order that the code states.
The layer map
| Layer | Top directories | What the layer provides |
|---|---|---|
| Boot | init/ | kmain, mount_root, spawn_init and the runner |
| Execution | proc/, exec/, ipc/ | processes, threads, system calls, ELF64, pipes |
| Files | fs/ | the VFS and the devfs, ext2, FAT, echfs and tmpfs backends |
| Devices | device/, drivers/, dev/, fbcons/ | the device tree, drivers, interfaces and consoles |
| Buses | bus/, firmware/ | PCI, ISA, IDE and ACPI |
| Core | core/, irq/, time/ | logging, interrupts and clocks |
| Entropy | random/, crypto/ | the random pool, its sources and three primitives |
| Memory | mm/ | pdma, pslab, the slab allocator, pmap, kmalloc, vms |
| Machine | arch/amd64/, libk/ | CPU tables, paging and the freestanding C support |
arch/amd64/ is not only the bottom layer. It mirrors mm/, proc/, irq/,
time/, firmware/, bus/, core/ and random/, and supplies the machine
half of each. This map gives that machine half no entry of its own.
amd64 is the only architecture with code today. The build maps the riscv64
and aarch64 names, and the EDK2 and Limine binaries for both are vendored.
A port adds kernel/arch/<arch>/ and implements the machine half of each
mirrored directory. The generic half does not change.
kernel/include/ has no archive. It holds the headers that all layers include.
A directory is not a stage. One directory can add initcalls at several
stages: random/ registers at early, bootinfo, services and dev. The
stage of a call, not the directory, gives the boot order.
Each enabled entry in kernel_subsystems builds one static archive. The table
gives each archive one KP_SUBSYSTEM tag, so its pr_* calls share a log
prefix. ld links the archives into vexsys.elf with --whole-archive,
which keeps an object that holds only a registration.
The path from boot to the first process
The linker script names kmain in ENTRY(kmain). Limine loads vexsys.elf
and jumps to kmain. The kernel is position-independent, and it reads its
real base from limine_executable_address_request.
kmain then does five things:
- It prints the version banner.
- It calls
invoke_initcalls(initcalls_bsp_start, initcalls_bsp_end). - It mounts the filesystem from the
root=device at/. - It loads
init=orCONFIG_DEFAULT_INITas the first process. - It enables interrupts and spins in
hart_spin().
Interrupts are off for steps 1 to 4, and the scheduler does not switch yet.
An initcall is a { .function, .name } entry in a linker section. The macro
initcall_bsp_<stage>(fn, order) writes it to
.initcalls.bsp.<stage number><order>. The linker script brackets the BSP
sections with initcalls_bsp_start and initcalls_bsp_end, and SORT puts
them in name order. The stage number is the prefix in the section name. The
order is one of INIT_FIRST, INIT_EARLY, INIT_MIDDLE, INIT_LATE or
INIT_LAST.
The stages run in this order: early, arch, bootinfo, pmm, slab,
mmu, heap, irq, timer, syscall, multicore, bus, vfs, dev,
fs, cpudev, binfmt, services, late.
mount_root reads root= with cmdline_argument and finds the interface with
block_interface_find. It calls vfs_mount for / with a null type. The VFS
then probes each registered driver that has an ops->probe, and stops at the
first that accepts the volume.
spawn_init calls process_create(nullptr). next_pid starts at INIT_PID,
which is 1, so the process gets PID 1. It holds a reference to
kernel_credentials, and its working directory is the VFS root. spawn_init
then opens the program, loads it, and starts the user thread and the
init_watchdog kernel thread.
load_binary finds the ELF64 loader because init_elf64 added it to the
registry at the binfmt stage. thread_spawn_* only enqueues the thread. The
first scheduler interrupt after enable_interrupts() switches to the init
thread. The kernel does not return to the kmain stack.
The boot order is the dependency order. The dev stage adds the block
interface and parses its partition table before kmain calls mount_root. The
vfs and fs stages register the filesystem drivers before vfs_mount probes
them. The binfmt stage registers the loader before load_binary asks for it.
Invariants and traps
- The stage is the dependency. An initcall uses only what an earlier
stage prepared.
slab_allocbeforeslab, orvfs_*beforevfs, is a bug. - The order is data, not a call list. No code runs the functions in
sequence. The link must keep an object that no symbol references, and
--whole-archivedoes. - Within one stage and order, link order decides. Do not rely on it. Use a different stage or order.
- Interrupts are off in a BSP initcall. Do not block, sleep or take a lock that can block.
bsp_runs once,smp_runs on every hart. The boot hart also runs the SMP stream, frominit_hart_bootatmulticore/INIT_LAST.- An initcall cannot report failure. It returns
void.invoke_initcallshas no error path, so an initcall callspanic(). spawn_initstarts one user process and one kernel thread.initis PID 1.init_watchdogis not a process.initmust not exit.init_watchdogwaits oninit_exited, callsvfs_sync_all()and panics.
Limits
- The code has no declared dependency graph between directories.
kernel_subsystemsis a flat list of archives and log tags. Nothing stopsfs/from including a header ofproc/, or a lower layer from calling a higher one. Real files cross the layers.core/kplog.cuses the char interface ofdev/andfile_toffs/.dev/tty_interface.cusesprocess_tand the scheduler. arch/amd64/is not a bottom layer. It mirrors eight generic directories, and its code interleaves them, so a strict stack is a simplification.- Only amd64 has code. The build maps the riscv64 and aarch64 names, but
kernel/arch/has no code for them. cpudevis a stage with no call. The order list is complete, but the stage is empty today.kmainstarts no shell, service manager or second user process. Everything after process 1 is the job ofinit.init_hart_boothas no timeout. A hart that never reports online hangs the boot.- The layer map describes the source tree, not a runtime boundary. Every layer runs in ring 0 with the same heap and the same direct map.
Source
| File | What it holds |
|---|---|
| kernel/meson.build | The kernel_subsystems table and the --whole-archive link |
| kernel/arch/amd64/kernel.ld | ENTRY(kmain) and the SORT of the initcall sections |
| kernel/include/vex/initcall.h | The stage macros and the order constants |
| kernel/init/initcall.c | The runner that walks a start and end pair |
| kernel/init/kmain.c | kmain, mount_root, spawn_init and init_watchdog |
| kernel/proc/process.c | process_create, the PID counter and init_process_root |
| kernel/proc/sched.c | thread_spawn_user and thread_spawn_kernel |
| kernel/fs/mount.c | vfs_mount and the probe that picks a filesystem driver |
| kernel/exec/binfmt.c | load_binary and the registry walk |
| kernel/exec/elf64.c | init_elf64, the ELF64 loader for the binfmt stage |
Boot and init
Vex boots in three steps. Limine loads the kernel and jumps to kmain. The boot hart runs the BSP initcalls with interrupts off. kmain then mounts the root filesystem and starts init. This page follows that path. The full contract of the first process is in Run.
The handoff
Limine loads vexsys.elf, builds the page tables, and jumps to kmain (ENTRY(kmain) in the linker script). The kernel is linked at 0xFFFFFFFF80000000 and is position-independent, so Limine may slide it. The kernel reads the real bases from limine_executable_address_request. It does not assume the link address.
Limine gives the kernel a stack of at least 64 KiB, clears IF, and loads a GDT. The IDT is undefined, so the kernel loads its own. The kernel places its requests in .limine_requests with base revision 6. They cover the HHDM, memory map, MP table, executable address, command line and RSDP. They also cover the framebuffer, entropy, boot date and paging mode.
The Limine boot protocol defines the handoff, the machine state and each request.
The BSP initcalls
kmain() calls invoke_initcalls(initcalls_bsp_start, initcalls_bsp_end). Interrupts are off, and the scheduler does not run yet. The calls run in the order that the linker script gives them.
A subsystem declares a call with initcall_bsp_<stage>(fn, order):
static void init_ext2(void)
{
...
}
initcall_bsp_vfs(init_ext2, INIT_EARLY);
The macro writes a { .function = fn, .name = "fn" } entry into .initcalls.bsp.<stage><order>. For init_ext2, that is .initcalls.bsp.3100000.3. The stage is the suffix of the macro name, vfs. The macro gives the stage the number 3100000 in the section name. The order is INIT_FIRST, EARLY, MIDDLE, LATE or LAST. The linker script brackets the BSP sections with initcalls_bsp_start and initcalls_bsp_end, and it sorts them with SORT.
The stages
| Stage | Prefix | What it prepares | Examples |
|---|---|---|---|
early | 0000000 | The boot thread and the earliest output | arch_init_boot_thread, arch_init_bxcons, init_random |
arch | 0100000 | CPU tables and the APs | init_harts, arch_init_gdt_bsp, arch_init_idt_bsp, arch_init_isr |
bootinfo | 0200000 | What Limine reported | init_kbase, init_hhdm, init_memmap, init_cmdline_parse |
pmm | 1000000 | Physical page allocators | init_pdma, init_pframe, init_pslab, init_commit |
slab | 1100000 | The slab allocator | init_slab |
mmu | 1200000 | The kernel page tables | init_acpi_tables, init_tlb, init_mmu |
heap | 1300000 | The kernel heap and stacks | init_liminefb, init_kstack, init_cmdline_preserve |
irq | 2000000 | The interrupt controllers | arch_init_ioapic |
timer | 2100000 | Clock sources and events | arch_init_hpet, init_acpi_pmt, init_cycles, init_wallclock |
syscall | 2200000 | The dispatch table | init_syscall |
multicore | 2300000 | Idle threads and the AP release | report_cpucaps, sched_init, init_acpi_work, init_hart_boot |
bus | 3000000 | PCI, ISA and the ACPI nodes | arch_init_pci_CF8, init_fixed_options, init_acpi |
vfs | 3100000 | The VFS and its drivers | init_process_root, init_devfs, init_ext2, init_fat, init_tmpfs |
dev | 3200000 | Devices and their interfaces | init_tty, init_block_ptable, init_kplog, init_device_tree, init_fb_boot |
fs | 3300000 | Filesystems that need devices | init_echfs, init_pipefs, init_shmem |
cpudev | 3400000 | Per-CPU devices | No calls today |
binfmt | 3500000 | Executable formats | init_elf64 |
services | 4000000 | Kernel threads for services | init_random_worker |
late | ff00000 | The last BSP work | sched_start, report_lost_options |
The stage and the order are the sort key. A call may use what the earlier stages prepared. This order is the dependency system. Within one stage and order, the link order sets the sequence. Do not rely on it. invoke_initcalls does not look at a return value. An initcall returns void and calls panic() on failure.
A bsp_ call runs once, on the boot hart. An smp_ call runs on every hart that comes online, the boot hart included. The SMP stages are hart, arch, irq, syscall, timer and late. bring_online runs them.
The APs
init_harts at arch/INIT_FIRST reads the Limine MP response. It registers each hart. It calls arch_start_hart to give the AP an entry point through goto_address. The AP enters hart_park, probes its CPU features, and waits.
The boot hart does not wait. It continues through the stages. At multicore/INIT_LAST, init_hart_boot sets the bring-up state to GO. It then waits until every usable hart reports online.
Each AP then runs run_idle:
- Switch to
sys_pmap. - Call
bring_online. - Enable interrupts.
- Halt in
hart_stopuntil the scheduler wakes it.
bring_online calls hart_setup, runs the initcalls_smp_* stream, marks the TLB as online, and sets HART_FLAG_ONLINE. On amd64, the SMP stream initializes the GDT and TSS, the IDT, the PAT and the FPU. It also initializes the PIC, the LAPIC, the syscall entry, and the LAPIC timer on each hart.
The boot hart runs the same bring_online path inside init_hart_boot. After that call, its thread pointer is the boot hart’s idle thread. The static boot thread from arch_init_boot_thread is no longer current.
The first process
After the BSP initcalls, kmain() runs the last steps:
- It prints the version banner.
- It mounts the block device from
root=at/. - It opens the path from
init=, or fromCONFIG_DEFAULT_INIT. - It loads the ELF through the binfmt registry and creates the process.
- It spawns the user thread and the
init_watchdogkernel thread. - It enables interrupts and spins.
The order matters. The dev stage attached the block driver and parsed the partition tables, so root= names a device. The binfmt stage registered the ELF loader, so load_binary finds it. The vfs stage registered the root source and the filesystem drivers, so vfs_mount can mount the root.
The process is PID 1. It has the kernel credentials, / as its working directory, no open file descriptors, and an empty environment. Run has the full contract.
init_watchdog waits on init_exited. When init dies, the watchdog syncs the filesystems and panics. Init must not exit.
sched_start at late/INIT_FIRST already sent the scheduler IPI to every hart. The boot hart has interrupts off, so its IPI stays pending until step 6. The first scheduler interrupt runs sched_irq_handler. That handler updates the clock, wakes expired waits, runs expired timers, and switches to the init thread. The kernel does not return to the kmain stack.
Invariants and traps
- The stage declares dependencies. A call may use only what earlier stages prepared.
kmallocbeforeheap, orvfs_*beforevfs, is a bug. The bug may survive until the first use. - Interrupts are off. Do not block, sleep, or take a lock that can block in a BSP initcall. A blocking call cannot work. The scheduler switch needs interrupts enabled.
- The scheduler does not run yet.
thread_spawn_*only enqueues.sched_startstarts the ticks. bsp_once,smp_on every hart. The boot hart runs the SMP stream too, atmulticore/INIT_LAST.- Within one stage and one order, the link order sets the sequence. Do not rely on it. Use a different stage or order.
- The runner ignores return values. An initcall returns
void. Panic on failure. init_hart_boothas no timeout. A hart that never reports online hangs the boot.- The command line points into bootloader memory.
init_cmdline_preservecopies it atheap/INIT_LAST. Usecmdline_argumentafter that point. - The page tables change at
mmu. Beforeinit_mmu, the kernel runs on Limine’s tables. After it, the kernel runs onsys_pmap. The kernel maps the HHDM in the same step. cpudevis empty. The stage exists. No code uses it today.- The link must include the whole archive. A registration is data that no code references. The link uses
--whole-archive. Without it, the linker drops the object and the initcall.
Where the code is
| File | Contents |
|---|---|
| kmain.c | Entry point, root= mount, init= load, watchdog |
| initcall.c | The runner |
| initcall.h | The macros, stages and orders |
| kernel.ld | Entry point, sections, start and end symbols |
| hart.c | Hart registration, bring-up, SMP stream |
| hart.c (amd64) | MP request, AP entry, GS and TSS |
| sched.c | Idle threads, sched_start, the tick |
| process.c | process_create, the init root source |
| elf64.c | The ELF64 loader |
Memory
The kernel gives each address one of three kinds and each frame one allocator. The kinds stop a physical address from becoming a pointer by mistake. The commit accounting rejects a mapping that the limit cannot cover.
user_addr_t is an address as a system call received it, not checked
yet. virt_addr_t is an address in an address space, which the kernel
cannot follow as a pointer. phys_addr_t is a physical address.
Three allocators hand out memory. pdma owns the frames below
PDMA_APPROX_END, which ISA devices can reach. pslab owns single
frames above that range. The slab allocator serves objects up to
SLAB_MAX_SIZE. kmalloc builds the kernel heap from pslab pages.
pdma serves a run of pages. pslab serves one page at a time.
An address space holds user translations below pmap_user_end and the
shared kernel mappings above it. pmap_create copies the kernel half of
the top-level entries from sys_pmap. A later kernel mapping does not
reach the new address space.
What they share
The direct map reaches every range that memmap_is_ram_type accepts,
at hhdm_base. phys_to_virt and virt_to_phys convert between the
two names. Each frame below the top of usable RAM has a pframe_t from
pframe_of(phys). It holds the reference count of the frame and the
translations that reach it.
Four rules hold across the subsystem.
An address of one kind never takes the place of another. A number
becomes an address through user_addr(), virt_addr() or
phys_addr(). A virt_addr_t becomes a pointer only through
virt_addr_pointer, and only while its address space is current.
Memory that the kernel owns, such as the heap and the kernel stacks,
stays a plain pointer.
A frame returns to pslab only when its last user drops it. A
pmap_unmap call removes one translation and frees no frame. The
reverse map in rmap.c lists the translations of a frame. A write fault
uses it to tell whether it must copy.
A change to a translation that another hart may have cached needs
tlb_shootdown. The pmap_*_noflush calls change the tables and leave
the flush to the caller. A caller that unmaps many pages can flush once
for a batch.
The kernel charges a private writable mapping when it appears, not when
a page faults. counts_commit selects the regions that count: not device,
not shared, not VMS_NORESERVE, and writable. So mmap, mprotect and
fork fail at the call, not at the first touch. A stack is the
exception: it grows on a fault and charges then. A shared anonymous
mapping is a file with no name. The file takes its whole size from the
commit limit, so the region does not count.
Pages
- Address types — the three address types, the
_Genericoperations invex/address.hand the pointer rule. - Physical memory — the memory map,
pdma,pslab, thepframe_tarray, the slab allocator andkmalloc. - Virtual memory —
pmap_t,vmspace_tandvm_region_t, the user half and the direct map. - Commit accounting —
commit_charge, the kernel reserve,VMS_NORESERVEand shared anonymous memory.
Limits
The kernel has no swap, so only the file cache returns frames under
pressure, from fcache_reclaim.
The kernel fixes commit_limit_pages at boot. It is pslab_numpages
minus the reserve, so free pages do not raise it. The reserve covers the
slab, the kernel stacks and the page tables. They take pages from
pslab without a charge.
mlock counts bytes in vmspace.locked_bytes against
RLIMIT_MEMLOCK and pins nothing.
The pmap code is generic, but only the amd64 page tables have an implementation.
Address types
An address is a number, and C lets one number stand for any other. A user address, a virtual address and a physical address are not interchangeable. The same value names different memory in each. Vex gives each kind its own type, so that the compiler rejects a mix at the call.
The three types are the same size. Each holds one uintptr_t, and each passes
in one register. The type names the kind of the address. It does not check
that the address is mapped or that it is in range.
The three kinds
user_addr_t is an address as a system call got it, before any check. It is a
transparent union with one member, uintptr_t value. A define_syscall
handler takes it as a parameter. The dispatcher passes the raw register, so no
cast is needed.
virt_addr_t and phys_addr_t are structs with one uintptr_t value field.
Their shape is the same, and the types are still separate. A virtual address
cannot take the place of a physical one.
A virt_addr_t is an address in the address space of a pmap_t or a
vmspace_t. The kernel cannot follow it as a pointer. A phys_addr_t is an
address in physical memory. The kernel reaches RAM through the direct map.
Operations
DEFINE_ADDRESS_OPS(kind, type) in address.h defines the same set for the
three kinds:
<kind>_addr()makes an address from auintptr_t.<kind>_addr_add()and<kind>_addr_sub()move it by asize_toffset.<kind>_addr_diff()gives the distance from a low address to a high one.<kind>_addr_equal(),_less(),_less_equal(),_greater()and_greater_equal()compare two addresses of the kind.<kind>_addr_min()and<kind>_addr_max()pick one of two.<kind>_addr_null()and<kind>_addr_valid()test for zero.
All of them are static __always_inline __nodiscard. An address becomes a
number through its value field.
The address_* macros dispatch with _Generic on the first argument. They
are address_add(), address_sub(), address_diff(), address_equal(),
address_less(), address_less_equal(), address_greater(),
address_greater_equal(), address_min(), address_max(), address_null()
and address_valid(). A second argument, where there is one, is an address of
the same kind. The exception is the size_t offset of address_add() and
address_sub().
Page operations live in page.h, because they need PAGE_SIZE:
<kind>_addr_page_align(), <kind>_addr_page_align_up() and
<kind>_addr_page_offset(). The generic page_align(), page_align_up()
and page_offset() also accept a plain number through their default:
branch. The first two give a uintptr_t for a plain number, and
page_offset() gives a size_t.
Between the kinds
user_addr_to_virt() names a user address as one in the address space of a
vmspace_t. It changes no bit and checks nothing. The vms_* functions check
the range.
The kernel reaches a user page through the direct map, not by following the
user address. access_begin_locked() in vms.c pins the page, calls
phys_to_virt() on the frame, and adds page_offset(virt). Device memory is
the exception. It is not in the HHDM, so the code calls virt_addr_pointer()
on the user address. It does so only when that address space is current.
virt_addr_from_pointer() and virt_addr_pointer() convert between a kernel
pointer and a virt_addr_t. The result of virt_addr_pointer() has a meaning
only in the address space that is current.
phys_to_virt() and virt_to_phys() in hhdm.h convert between a
phys_addr_t and a kernel pointer through hhdm_base. They return no
virt_addr_t. They do not check that the address is RAM.
pmap_resolve_address() returns the phys_addr_t that a virt_addr_t maps
to, or phys_addr(0). vms_pin() and vms_touch() return a phys_addr_t
for an address in a vmspace_t, with the page offset added.
The user half
The user half of the address space ends at pmap_user_end(). The function
computes the top from pmap->num_levels. Four levels give
0x0000800000000000. A start at or above that end is not a user address, and
neither is a length that passes it.
user_range_ok() compares a virt_addr_t start and a size_t size with the
end. user_range_end() in sys_mman.c does the same for a range that must be
page aligned, and it rejects a zero length.
VMS_MMAP_TOP is 0x0000600000000000, and VMS_MMAP_MIN is
0x0000000000010000. A placement without a fixed address searches down from
the first and stops at the second. Neither is a limit on a fixed mapping.
vms_map() accepts one up to pmap_user_end(). The user stack ends at
ELF64_STACK_TOP, 0x0000700000000000, which is above VMS_MMAP_TOP.
Invariants and traps
address_diff(high, low)is valid only whenhighis not belowlow. The subtraction is unsigned, so the other order gives a hugesize_t.address_add()andaddress_sub()do not detect a wrap. Asize_toffset can wrap past zero or past the top of the address space.- Zero is the failure value of the physical allocators.
pdma_alloc_phys()andpslab_alloc_phys()returnphys_addr(0), and the callers test it withaddress_null()oraddress_valid(). - For a user address, zero is a hint and not a failure.
sys_mmap()testsaddress_valid(addr)to tell “no hint” from a hint.sys_mmap()still accepts aMAP_FIXEDmapping at address 0. user_addr_to_virt()andvirt_addr_pointer()check nothing. An address that thevmspace_tdoes not map becomes a pointer all the same.address_valid()says only that the value is not zero. It does not say that the address is mapped, in range, or in RAM.- A check of the start of a range does not cover the bytes after it.
in_user_space()insys_thread.ccompares oneuser_addr_twithpmap_user_end(), and the object at that address can still cross the end. phys_to_virt()accepts any value. Aphys_addr_toutside RAM gives a pointer outside the direct map.
Limits
- There is no type for a range of addresses. Each call takes a start and an
end of one kind, or a start and a
size_tsize. - The code provides no operation that turns a
phys_addr_tinto avirt_addr_t. It goes through a kernel pointer withphys_to_virt()andvirt_addr_from_pointer(). - There is no 32-bit user address type. amd64 is the only implemented architecture.
- The userspace ABI does not use these types. A user address in a structure
under
include/usr/is a__k_uintptr_t. - The address types do not keep a reference, a mapping or a lock. A
virt_addr_tdoes not say whichpmap_tgives it a meaning.
Source
| File | Holds |
|---|---|
| types.h | the three types, the transparent union and the size checks |
| address.h | DEFINE_ADDRESS_OPS, the address_* macros and the pointer helpers |
| page.h | the page operations and their plain-number branches |
| hhdm.h | phys_to_virt() and virt_to_phys() through hhdm_base |
| vms.h | VMS_MMAP_TOP, VMS_MMAP_MIN and the vmspace_t interface |
| arch/pmap.h | pmap_user_end() from pmap->num_levels |
| usr/vex/types.h | __k_uintptr_t, the ABI form of a user address |
| sys_mman.c | user_range_end() and the mmap family |
| vms.c | user_range_ok(), vms_copyin() and the direct-map access |
| pmap.c | pmap_resolve_address() |
| pdma.c | pdma_alloc_phys(), which returns phys_addr(0) on failure |
| pslab.c | pslab_alloc_phys(), which returns phys_addr(0) on failure |
Physical memory
The bootloader gives the kernel a map of physical memory. Two page allocators
split that map. pdma owns the low 16 MiB, which ISA DMA can reach. pslab
owns single pages above that zone. A slab allocator serves objects of up to
half a page from pslab pages, and kmalloc builds its heap on pslab
pages too.
A frame is one page of PAGE_SIZE bytes. One pframe_t describes each frame
below the highest usable end.
The memory map
memmap points at the Limine memory map response. The request itself sits in
.limine_requests. init_memmap() runs at bootinfo/INIT_FIRST and
panics when the bootloader gives no map or an empty one.
memmap_is_ram_type() treats five Limine types as RAM:
LIMINE_MEMMAP_USABLE, LIMINE_MEMMAP_ACPI_RECLAIMABLE,
LIMINE_MEMMAP_ACPI_NVS, LIMINE_MEMMAP_BOOTLOADER_RECLAIMABLE and
LIMINE_MEMMAP_EXECUTABLE_AND_MODULES. memmap_is_ram() checks a byte run
against the map and follows the run across entry boundaries. Every byte of
the run must lie in an entry of one of those five types.
Both page allocators take memory from LIMINE_MEMMAP_USABLE entries only.
The other four types are not free, although memmap_is_ram() accepts them.
0x00000000 pdma: bitmap and pages, from USABLE entries
0x00FFFFFF PDMA_APPROX_END, the end of the DMA zone
0x01000000 pslab zone; pslab excludes the pframe_t array
each free pslab page points at the next
The kernel keeps the bootloader’s response, not a copy, so the map must stay valid for the life of the kernel.
The frame descriptors
pframe_of() indexes a pframe_t array with phys.value / PAGE_SIZE.
pframe_phys() reverses it. init_pframe() sizes the array by the highest
usable end in the map. The array therefore also covers a frame in a hole and
a frame that no allocator owns.
The array lives in the first usable entry with room above
PDMA_APPROX_END. init_pframe() runs at pmm/INIT_EARLY, before
pslab, so pslab can keep the array out of its range.
refcount is 0 for a free frame. pslab sets it to 1 when it gives a page
out, and pframe_incref() adds a reference for a mapping or a sharing.
pframe_decref() decrements only a count above 1 and returns true when the
count is 1. The caller frees the page at that point. PFRAME_PSLAB marks a
frame that pslab manages.
pdma
pdma serves ISA DMA, which cannot address memory at or above 16 MiB.
PDMA_APPROX_END is 0xFFFFFF. A bitmap with one bit per page tracks the
zone, and a set bit means free. init_pdma() clears the bitmap and then sets
the bits of each usable run, so a hole in the map never becomes allocatable.
The bitmap comes from the first usable entry below PDMA_APPROX_END with
room for it, and that entry gives the bitmap pages up. pdma_numpages is
rounded up to a multiple of BITMAP_CHUNK_BITS pages.
pdma_alloc_phys() finds a run of num_pages set bits, starting at
pdma_lastpage and wrapping to 0 once. It clears the bits, records the new
hint and returns page * PAGE_SIZE. pdma_alloc_virt() returns the direct
map address of the same pages.
pdma_free_phys() sets the bits and lowers pdma_numused. pdma_lock
protects the bitmap, and alloc and free take it with interrupts off.
pslab
pslab is a free list of single pages. init_pslab() adds every usable page
at or above PDMA_APPROX_END + 1, less the range that pframe_storage()
reports. It runs at pmm/INIT_MIDDLE, after pframe and pdma.
A free page holds the next link in its first sizeof(void*) bytes, so the
list costs no memory outside the pages. take_page() pops a page under
pslab_lock, sets refcount to 1 and returns the direct map address.
pslab_free_virt() asserts PFRAME_PSLAB, a refcount of 1 and no
mappings, sets refcount to 0 and pushes the page. pslab_alloc_phys()
and pslab_free_phys() convert through the direct map.
pslab can ask the file cache for pages. When the list is empty,
pslab_alloc_virt() calls fcache_reclaim(RECLAIM_BATCH) once and retries
if it frees a page. When fewer than RESERVE_PAGES pages remain, it reclaims
ahead.
The slab allocator
slab_alloc() serves an object of up to SLAB_MAX_SIZE, which is half a
page. The slab table has one entry per power of two from SLAB_BLOCKSIZE(0)
to SLAB_MAX_SIZE, so nine sizes from 8 to 2048 bytes on amd64.
init_slab() runs at slab/INIT_MIDDLE and starts each slab with one
pslab page.
The table of slab_t comes from pdma_alloc_virt() and stays in the low
zone. A page starts with a slab_t*, and the rest of the page is blocks. A
free block holds the next free block in its first sizeof(void*) bytes.
expand_slab() builds that chain when a slab runs empty.
slab_alloc() takes the slab of the exact size when one exists, and
otherwise the first slab whose blocks are large enough. It zeroes size
bytes, not the whole block. slab_realloc() copies
min(sl->blocksize, size) bytes and frees the old block. slab_free() finds
the slab_t* at the start of the page and pushes the block.
Who takes pages
pdma: the slab table, the AHCI DMA area, the PATA scratch page and the bounce buffer of a block request.pslab: page tables,vmspages, the file cache,kmalloc,kstackand many small buffers.kmallocmaps itspslabpages fromHEAP_REGION_BASEinsys_pmap, and describes each block with amemblock_tfrom the slab.kstackmapsKSTACK_PAGESpslabpages into a slot inKSTACK_REGION_BASEand leaves the page below them unmapped as a guard.
Invariants and traps
- A
pframe_texists for every frame below the highest usable end, but only a frame withPFRAME_PSLABbelongs topslab. pdma_free_phys()accepts any non-null address. It checks neither the zone nor the allocation that the address came from. Apslabpage writes past the bitmap. A run that crosses a hole marks the hole free. Both decrementpdma_numused.pdma_alloc_phys()returnsphys_addr(0)for a request of 0 pages and for a request larger than the zone.- A
pslabpage comes back with its old contents, but its firstsizeof(void*)bytes hold the free-list link. Neither page allocator zeroes a page.slab_alloc()zeroessizebytes of a block. - The
kassertchecks inpslab_free_virt()vanish withoutCONFIG_KASSERT. A wrong free then corrupts the free list in silence. slab_free()cannot detect a double free. A second free with no allocation between makes a cycle in the free list of the slab.slab_alloc()returnsnullptraboveSLAB_MAX_SIZE. Usekmallocfor a larger object.- A guard page is never mapped, so a stack overflow faults at once.
kstack_is_guard()identifies the guard page of a slot inKSTACK_REGION_BASE.
Limits
pdmais the only address-limited zone. There is no NUMA node, no high-memory zone and no memory hotplug.pdmacannot serve a page abovePDMA_APPROX_END. A caller that needs a page above the zone usespslab.pslabhas one lock and one list. It has no per-CPU cache and no fragmentation control.- The slab allocator has no constructor, no destructor, no per-object metadata and no debugging mode. Size classes are powers of two only.
- The heap does not charge the commit limit. It takes
pslabpages, so a large heap leaves fewer pages for mappings. Commit accounting describes the limit itself. memmap_is_ram()scans the entries for each run. It has no interval index.
Source
| File | Contents |
|---|---|
| memmap.c | The Limine map, the RAM types and the run check |
| pframe.c | The frame array, its storage and the reference count |
| pdma.c | The DMA zone bitmap and its allocator |
| pslab.c | The single-page free list and the reclaim reserve |
| slab.c | The size classes, the page header and the block lists |
| kmalloc.c | The heap, its blocks and the coalescing |
| kstack.c | The stack slots, the guard page and the slot bitmap |
| memmap.h | memmap and the two queries |
| pframe.h | pframe_t and the PFRAME_* flags |
| pdma.h | PDMA_APPROX_END and the pdma calls |
| pslab.h | The pslab calls and pslab_numfree |
| slab.h | SLAB_MAX_SIZE and the slab calls |
Virtual memory
A program names addresses that no hart can follow as pointers. The kernel must give each page a frame at the first access. It cannot know in advance which pages the program will touch. It must also reach any RAM frame from its own code, at any time.
Three objects divide the work:
pmap_tis a hardware page table.vmspace_tis the set of ranges that a program may name, asvm_region_trecords them.- The HHDM, the direct map, makes every RAM frame a plain pointer.
The page table and the region list have their own locks. The kernel half of
every pmap_t holds the same mappings, and the HHDM belongs to all address
spaces. The page table is the amd64 one. A port implements the same pmap_*
functions for its own table format.
Page tables
pmap_t holds a root page (virt and phys), num_levels, a lock, and
the counters resident_pages and peak_pages.
pmap_create() takes a root page from pslab and copies the entries from
AMD64_PAGEMAP_START_KERN through PAGEMAP_SIZE - 1 from sys_pmap. Every
address space therefore shares the kernel half and starts with an empty user
half.
The copy takes entry values, not tables. init_mmu() creates every kernel
top-level entry first, because a copy does not propagate later additions.
root table entry
+-----------------------------------+
0 .. 255 | user half: one program |
| pmap_create leaves it empty |
+-----------------------------------+
256 .. 511 | kernel half: shared by every pmap |
| arch_pmap_kcopy copies it from |
| sys_pmap when the pmap is made |
+-----------------------------------+
pmentry_walk() descends from the root to a leaf or a large leaf. It
allocates the missing tables on the way. pmentry_get() takes each table
page from pslab. split_large_nolock() replaces one large leaf with a
table of 512 entries, so that one page below it can change.
pick_leaf_level() keeps a user page at 4 KiB. A 2 MiB or 1 GiB leaf needs
virtual and physical addresses aligned and the range at least that size.
A 1 GiB leaf needs CPU_FEATURE_PAGE_1G.
pmentry_setup() encodes the VPROT_* bits. A user page with no right
clears AMD64_PML_PRESENT and sets AMD64_PML_PROTNONE, because a present
x86 page is always readable. pmentry_valid() accepts either bit.
pmap_release() frees the tables of the user half only. The kernel half
belongs to sys_pmap.
Address spaces and regions
vmspace_t holds regions, a list sorted by start, its pmap and a
lock. find_region() walks that list.
vms_map() inserts a vm_region_t and maps no page. The first access of
each page faults. vms_unmap() splits the list at both ends with
split_region() and drops the whole regions between them.
vm_region_t keeps vprot without VPROT_USER, because every user page
has that bit. file and file_offset make a file mapping. shared,
may_write, device, noreserve, grows_down and grow_floor describe
how the range faults and what it costs.
The commit limit pays for a private writable region in
alloc_region(), and free_region() gives the pages back. A read-only,
shared, device or VMS_NORESERVE region takes nothing.
A pmap answers a virtual address with a frame. The rmap answers a frame
with its mappings. rmap_map() writes the page table entry and adds an
rmap_entry_t to the pframe_t. A frame then knows every pmap and virt
that holds it. A device page has no pframe, so it stays out of the rmap.
rmap_pin() returns a frame with a new reference. It also sets sole when
the frame has one mapping and no file cache (PFRAME_CACHE). A write fault
uses that answer to choose between a patch and a copy.
vms_map_any() searches down from VMS_MMAP_TOP. The lowest address is
VMS_MMAP_MIN, which keeps a null pointer dereference faulting.
Faults
isr.c reads CR2 and the error code, enables interrupts, and calls
vms_fault() with VPROT_READ, VPROT_WRITE or VPROT_EXEC. A fault from
the kernel itself panics.
fault_locked() runs under vmspace->lock and looks for the region. A page
that no region contains may extend a stack down (grow_stack_locked()). The
growth stops at grow_floor, and it needs a free STACK_GUARD_BYTES gap
above the mapping below.
Then the fault checks the access against region->vprot. A device region
goes to fault_device_locked(), which maps the frame that
vnode_resolve_page() returns, with the cache bits of the node.
Anonymous memory takes a page from pslab, zeroes it and maps it with
rmap_map(). A file calls vnode_fault(), which lets the filesystem map the
page. A private page of a file starts read-only. A write to it reaches
unshare_locked(). It patches the entry when the frame is sole. Otherwise
it copies the page and calls rmap_replace().
A shared page becomes writable only on a write fault, and that fault marks it dirty.
A successful fault returns VMS_FAULT_MINOR or VMS_FAULT_MAJOR, and the
handler counts it. -EFAULT becomes SEGV_MAPERR and -EACCES becomes
SEGV_ACCERR. Any other error becomes SIGBUS with BUS_ADRERR.
vms_clone() maps every RAM frame of the parent read-only in the child. A
private writable region loses write in the parent too, so both sides copy on
the next write.
The direct map
hhdm_base comes from the Limine HHDM response. phys_to_virt() adds that
offset and virt_to_phys() subtracts it.
map_hhdm() in mm/pmap.c walks memmap for memmap_is_ram_type()
entries, merges runs that touch, and calls pmap_map_range() with
VPROT_RW. It panics when a run fails. The mapping lands in sys_pmap in
the kernel half, so every pmap_t shares it.
The HHDM covers RAM only. Device registers live in the window at
MMIO_REGION_BASE. mmio_map() finds a gap there and maps the range into
sys_pmap. It refuses a physical range that another mapping holds with a
different memory type. mmio_unmap() keeps the entry until the last
reference goes.
A device page of a user mapping is not in the HHDM. The kernel reaches it through the user mapping, and only while that address space is loaded.
Invariants and traps
- The locks nest in one order:
vmspace->lock, then the rmap lock of the frame, thenpmap->lock. The rmap lock runs with interrupts off. pmap_flush()invalidates one address on the local hart only.tlb_shootdown()raisestlb_generationand sends an IPI to every other hart, and each one flushes its CR3 intlb_poll().- A
_noflushcall leaves the old translation in a TLB. The caller runstlb_shootdown()before the old entry can matter, or it knows that no hart used the address. vms_release()flushes nothing. No hart may have the pmap loaded.pmap_unmap_range_noflush()clears a large leaf only when the range covers all of it. It splits a leaf that the range covers in part.- A frame that
rmap_unmap()returns is the caller’s reference. The pmap does not free it.put_frame()does. vms_clone()takes only the lock of the source and needsvmspace->regions == nullptr.- A file region that
vms_release()frees goes tostale_regionsand dies inrelease_thread, becausefile_close()can sleep. type_conflicts_nolock()refuses one physical range with two memory types. x86 leaves the result undefined.
Limits
- Only amd64 provides
arch_pmap_*. The kernel detects 3, 4 or 5 paging levels from the bootloader response. - A user page is always 4 KiB. There is no huge page for user memory.
- The page tables have no reclaim, and there is no swap. A table page
returns to
pslabonly when the pmap goes. - A device region works in the loaded address space only. The case of
another one is marked
UNDONEinaccess_begin_locked(). vms_protect()cannot add write wheremay_writeis false, and it removes write from a shared page.locked_bytescounts locked regions againstRLIMIT_MEMLOCK. Eachvm_region_thas alockedbit.mlock,munlock, andmlockallsplit regions at range edges.MCL_FUTUREcharges new mappings. Nothing is pinned yet.
Source
| File | What it holds |
|---|---|
| mm/pmap.c | pmap_create(), the walk, the leaf choice and the HHDM setup |
| vex/pmap.h | the pmap interface and the flush rules |
| arch/pmap.h | the amd64 entry bits and the pmentry_* encoders |
| amd64/mm/pmap.c | the root page, the level count and the kernel-half copy |
| vms.c | regions, faults, remap, protect and the user copies |
| vms.h | the vmspace_t and vm_region_t fields |
| rmap.c | the reverse map, the pins and the resident page counts |
| rmap.h | the preconditions of the rmap calls |
| hhdm.c | reads hhdm_base from Limine |
| hhdm.h | phys_to_virt() and virt_to_phys() |
| mmio.c | the MMIO window, its reuse and its memory types |
| tlb.c | the shootdown and the generation counter |
| isr.c | the page fault entry and the signal choice |
| fcache.c | the file cache side of a file fault |
| vprot.h | the permission and memory type bits |
Commit accounting
Vex reserves physical pages before it lets a process map memory that can become private and writable. The reservation is a count of pages, not a range and not an object. One global limit bounds the total of these counts.
A charge that the limit cannot cover returns -ENOMEM. A mapping call fails
at that point, not at a later fault. The caller can still handle the error
before it relies on the memory.
Shared anonymous memory and pipe pages use the same counter. The counter thus covers more than the writable mappings of a process.
The commit limit
init_commit() computes commit_limit_pages once, at the INIT_LATE stage
of the pmm initcalls. The value is pslab_numpages minus a reserve for the
kernel. The reserve is an eighth of the pages, or 4096 pages (16 MiB),
whichever is larger. When the page count is not larger than the reserve, the
limit stays 0 and the kernel logs a warning.
commit_charged_pages counts the charged pages. charge() updates it with a
compare-and-swap, so any context can charge without a lock. A charge that
fails takes nothing and returns -ENOMEM. commit_charge() takes pages with
no credit, and commit_uncharge() gives them back.
cap_reserve_pages is a second slice, inside the limit. It is a 32nd of the
limit, or 256 pages (1 MiB), whichever is larger. It never passes the limit.
A charge with use_reserve set may use the whole limit. A charge without
use_reserve stops at commit_limit_pages - cap_reserve_pages.
0 limit - cap_reserve limit
+--------------------+------------------------+
| any charge | use_reserve charges |
+--------------------+------------------------+
|<------------- commit_limit_pages ----------->|
commit_charge_replacing() takes the pages that the caller will give back as
credit. The total may then pass the limit by that many pages for a moment. A
plain charge fails while the total is over the limit, until the caller
uncharges the replaced pages. vms_map_replace() charges the new region, then
removes the old one.
What takes pages
alloc_region() in vms.c charges a region when region_counted() is true.
That needs four conditions. The region is neither device memory nor shared. It
does not carry VMS_NORESERVE, and its protection has VPROT_WRITE. The
charge is region_pages(), the page count of the range. free_region() gives
the pages back.
- A private anonymous writable mapping charges for its whole range.
- A private writable file mapping charges too, because a write can make a
private page. A read-only private mapping charges nothing until a
mprotectadds write. - A stack charges for its initial range at
exec, which is at most 128 KiB.grow_stack_locked()charges the pages that a growth adds, at the fault that grows it. forkcharges the private writable regions of the child again. The frames stay shared until a write, so the pair charges twice for the same frames.- A tmpfs file charges for each page that its size grows by.
shmem_file_create()sets the size of its file, so the whole file charges at creation. - A pipe charges one page for each page that it holds.
mprotect can move a charge when it changes the write bit. vms_protect()
charges the regions that gain a charge and uncharges those that lose one.
mremap charges the pages that a growth adds. A move charges the new region,
with the pages of the old one as credit. The old region uncharges when it
goes.
MAP_NORESERVE
sys_mmap() sets VMS_NORESERVE only when the calling thread holds
CAP_VM_NORESERVE. Without the capability the kernel accepts the flag and
ignores it. A program that passes the flag out of habit gets the same charge
as without it.
A region with VMS_NORESERVE takes nothing from the limit. An anonymous page
of it comes from pslab at the fault. When pslab_alloc_phys() returns null,
the fault fails with SIGBUS and BUS_ADRERR. The fault does not read the
commit limit, because the region has no charge.
The capability has a second effect. may_use_reserve() reads it at each
mapping charge, so a mapping without MAP_NORESERVE may use the cap reserve.
fork and mremap copy the noreserve flag of a region. They do not check
the capability again.
Shared anonymous memory
MAP_SHARED | MAP_ANONYMOUS makes a file in the internal tmpfs.
map_shared_anonymous() gives the page-aligned length as the size of the
file. tmpfs_charge() then charges the whole size at once.
The mapping is a shared file mapping, so the region takes no charge. The
charge belongs to the file, not to the region. A fork copies the region but
not the charge, so two processes share one charge. See
Shared memory.
MAP_NORESERVE does not remove this charge. The file takes its whole size
from the limit when the mapping appears. A CAP_VM_NORESERVE holder can still
make that charge use the cap reserve.
Invariants
- The charged total stays at or below the limit, except while a
commit_charge_replacing()credit is outstanding. commit_uncharge()gives back pages that an earlier charge took. Itskassertfails when the counter is smaller.- The reserve check uses the credentials of the calling thread, not those of the process that owns the mapping.
- A split keeps the total charge.
split_region()copies the accounting fields, and each part uncharges its own pages. - The kernel fixes the limit at boot. It does not follow the free pages of
pslab, and kernel allocations come from the same pool. The reserve is headroom, not a hard boundary.
Traps
MAP_NORESERVEwithoutCAP_VM_NORESERVEis not an error. The mapping counts like any other.- A shared anonymous mapping with
MAP_NORESERVEstill charges its whole size. - A charge is not an allocation. The page does not exist until the fault, and
a fault can still fail with
SIGBUSwhenpslabis empty. forkcharges the private writable memory of the child again. A process with a large private writable mapping can fail to fork at about half of the commit limit.- A stack growth over the limit fails at the fault, with
SIGSEGV. Only the initial range is charged atexec. tmpfs_charge()returns-ENOSPCwhen its page limit or the commit limit is full.shmem_file_create()turns that into-ENOMEM.commit_charged_pagesuses relaxed atomics, so a concurrent reader can see a stale value.tmpfs_statvfs()reads it for the free space report.
Limits
- There is no reservation object and no record per mapping. The accounting is two counters and two reserved slices.
- There is no swap and no overcommit mode.
- Only a
CAP_VM_NORESERVEholder gets the effect ofMAP_NORESERVE. - Device memory takes no charge. A device mapping is always shared.
- The kernel does not enforce
RLIMIT_AS. - There is no reclaim, and no charge shrinks under memory pressure. A charge goes away only when the pages are freed.
- The unit is
PAGE_SIZE. A user page is never huge.
Source
| File | What it holds |
|---|---|
| commit.c | the counters, charge() and the initcall |
| commit.h | the charge interface and its contract |
| vms.c | alloc_region(), free_region() and the stack growth |
| sys_mman.c | the MAP_NORESERVE test and the shared anonymous path |
| shmem.c | the unnamed tmpfs file of a shared mapping |
| shmem.h | the contract of shmem_file_create() |
| tmpfs.c | tmpfs_charge() and its reserve rule |
| tmpfs_vnode.c | the size change that charges and uncharges |
| isr.c | the signal for a fault that fails |
| pipe.c | one charge for each pipe page |
Processes
A process holds an address space, a table of descriptors and one signal
disposition table. A thread is the unit that the scheduler runs. A process
starts with one thread, and sys_thread_create adds more.
kernel/proc/ creates and reaps processes, and creates, schedules and reaps
their threads. Three pages follow:
| Page | Owns |
|---|---|
| Scheduler | one runqueue_t per hart and the tick |
| Threads | the thread_t, its kernel stack and its process |
| Signals | the pending_signal_t queues and the handlers |
Harts
A hart is one hardware thread of the CPU. harts[MAX_HARTS] holds one
hart_local_t per hart. MAX_HARTS is CONFIG_MAX_HARTS with CONFIG_SMP,
and 1U without it. num_harts counts the harts that arch_enumerate_harts
registered.
Hart 0 is the boot hart, and boot_hart() returns it. The kernel counts a
hart past MAX_HARTS in ignored_harts and leaves it unused. init_harts
starts the others with arch_start_hart.
A secondary hart waits in hart_park on hart->bringup. init_harts marks a
hart that cpucaps_merge refused with HART_BRINGUP_PARK, and init_hart_boot
sets HART_BRINGUP_GO for the others. A parked hart never runs a thread.
Boot and init give the full order.
Every online hart runs bring_online, so the initcalls_smp_* stream runs on
each hart, the boot hart included. this_hart() returns
this_thread()->last_hart: the hart that runs the thread now, or the last hart
that ran it.
Each hart has one run queue, runqueues[hart->index]. Every online hart also
has one idle thread that runs sched_idle. A hart with HART_FLAG_ONLINE set
and HART_FLAG_ACTIVE clear is idle, as hart_is_idle reports. Only a hart
that would otherwise idle takes work from another queue, in rq_steal.
A thread with no mask of its own, thread->affinity == nullptr, may run on
every hart. A thread created from another thread inherits its mask. The first
sys_setaffinity gives a thread without one a mask of SCHED_AFFINITY_WORDS
words, with one bit per hart. A thread with bound_hart set runs only on that
hart. Only the uACPI worker sets it.
What they share
The three mechanisms meet at thread->pending_work, a word of THREAD_WORK_*
bits. A thread reads it on the way back to user mode. exit_to_user acts on it
in a fixed order: THREAD_WORK_EXIT calls sched_terminate,
THREAD_WORK_STOP calls job_park, and THREAD_WORK_SIGNAL calls
signal_deliver. The scheduler never runs that work itself.
The pid and the tid come from one counter, process_alloc_id. The first thread
of a process takes its pid as its tid, in attach_thread_user. A tid therefore
never names a thread of another process.
The process keeps the resources. A thread keeps its process alive.
attach_thread_user takes a reference with process_incref, and
process_thread_reaped drops it when the scheduler frees the thread. When the
scheduler reaps the last thread, the process gives up its vmspace with
process_set_vmspace(process, nullptr). It then sets PROCESS_ZOMBIE and
sends SIGCHLD to the parent. It stays a zombie until a waiter claims it with
process_claim and calls process_destroy.
The lock order is signal_actions->lock, then process->lock, then a
thread->sched_lock, then the run queue lock. rq_take_nolock holds the run
queue lock and only tries the thread locks. Elsewhere sched_lock comes before
the run queue lock. A held hart_lock means another hart is still on that
thread’s stack.
A thread changes state only under its sched_lock. A wait follows one
protocol. Register as a waiter under the lock of the waker, and call
sched_dequeue before that lock is released. Then call sched_yield, and
check the condition again. Without the first two steps, a wakeup gets lost.
Pages
- Scheduler — the run queue of each hart,
nice, affinity and the tick. - Threads —
process_create, the kernel stack, kernel threads and the end of a process. - Signals — the pending queues, the mask, delivery and job control.
Limits
- There is no hart hotplug.
num_hartsand the set of parked harts never change afterinit_harts. - The only policy is
SCHED_OTHERwith priority 0.sys_setschedulerrefuses every other policy, andsys_getschedparamreports priority 0.sys_sched.cmarksSCHED_FIFOandSCHED_RRasUNDONE. - A hart takes work from another queue only when its own queue has nothing it
can run. The
TODOinchoose_hartnotes that the tick does not balance queues between busy harts. niceis a field ofprocess_t, not ofthread_t. Threads of one process share one nice value, so their timeslices have the same length.- The kernel cannot use more than
MAX_HARTSharts. A machine with more harts runs on the firstMAX_HARTSthat the kernel registers.
Scheduler
Each hart runs one thread at a time. The scheduler chooses the next thread and
gives the current thread a timeslice. It uses one run queue per hart, a
per-thread affinity mask and the nice value of the process. All threads use
SCHED_OTHER.
The scheduler has two jobs. It shares each hart among the runnable threads, so one thread cannot hold a hart forever. It also keeps a thread on a hart that the affinity mask allows.
Run queues
Each hart has a runqueue_t in runqueues[]. It holds a lock, a head and
a tail for an intrusive doubly linked list of thread_t. num_queued is
an atomic count that an idle or stealing hart reads without the lock.
A thread in THREAD_RUNNABLE is on exactly one run queue. A thread in
THREAD_RUNNING is on a hart. A thread in THREAD_BLOCKED is on no queue.
THREAD_DEAD is a thread that must leave its stack before the kernel frees it.
sched_enqueue() wakes a blocked thread. It calls choose_hart(), marks the
thread THREAD_RUNNABLE and puts it at the tail of that hart’s queue.
choose_hart() returns bound_hart first. Otherwise it prefers last_hart,
the hart that ran the thread last. It keeps that hart when the thread may run
there. The hart must also be idle, or the thread must still hold its
hart_lock. Otherwise it looks for an idle hart that the affinity mask
permits. If every permitted hart is busy, it still returns one of them.
At a tick, rq_take_nolock() walks the queue from head. It takes the first
thread that thread_may_run_on() permits and whose hart_lock and
sched_lock it can acquire. A held hart_lock shows that another hart still
uses the stack of the thread. The scheduler skips such a thread.
A hart that would otherwise idle calls rq_steal(). It tries the other queues
in a rotating order, starting after its own index. When no thread is
available, the hart clears HART_FLAG_ACTIVE, loads idle_thread and stops.
sched_idle() sends itself a scheduler IPI when its queue becomes non-empty.
wake sched_enqueue --> choose_hart --> rq_push_nolock
|
tick rq_take_nolock <-------------------+
| empty
v
rq_steal --> next thread, else idle_thread
The queue does not order threads by priority. It is first in, first out. A thread that becomes runnable goes behind the threads that are already queued.
Priorities
Vex does not have priority queues. The nice value of a process changes only
the timeslice of its threads. process->nice holds the value, and thread_t
has no nice field.
CONFIG_SCHED_TIMESLICE sets the base timeslice in microseconds. The default
is 5000, so SCHED_TIMESLICE_NS is 5 ms. init_nice_timeslices() builds the
table for the 40 levels from NICE_MIN to NICE_MAX.
A step down from 0 multiplies the timeslice by 5/4. A step up from 0
multiplies it by 4/5. The result stays between SCHED_TIMESLICE_MIN_NS
(100000 ns) and SCHED_TIMESLICE_MAX_SCALE * SCHED_TIMESLICE_NS, which is
four times the base. sched_timeslice_for_nice() clamps a value outside the
range to the nearest end.
A kernel thread has no process. It always receives the base timeslice.
getpriority() returns NICE_BIAS - nice, with NICE_BIAS equal to 20.
setpriority() clamps the value and checks ownership or CAP_SYS_NICE. A
thread without the capability cannot lower a nice value below the floor that
RLIMIT_NICE sets.
All threads use SCHED_OTHER. sched_setscheduler() rejects any other
policy. It and sched_setparam() reject a nonzero sched_priority.
Affinity
thread_t.affinity points to SCHED_AFFINITY_WORDS words of 64 bits. Each
bit is one hart. A null pointer means that every hart is allowed.
thread_may_run_on() checks bound_hart first, then tests the bit of the
current hart. bound_hart is an internal constraint for a thread that must
stay on one hart.
thread_reserve_affinity() allocates a mask with every bit set if the thread
has none. thread_set_affinity() stores the new words in place and calls
affinity_changed(). It does not wait for the thread to move.
The words are atomic, but a mask update is not one transaction. A reader can see a mix of old and new words. That mix only changes where the thread goes next, and the next check corrects the choice.
affinity_changed() moves a runnable thread that sits on a disallowed hart.
It removes the thread from that queue and wakes it on a permitted hart. For a
running thread, it sends an IPI to the hart. At the next tick the thread
becomes blocked and the scheduler enqueues it again.
The affinity system calls apply online_word() to the user mask.
sched_setaffinity() fails with EINVAL when the mask has no online hart.
sched_getaffinity() reports only online harts. sched_setaffinity() with a
tid of 0 gives each thread of the process its own mask.
Preemption
A thread has preempt_count. preempt_disable() raises the count and
preempt_enable() lowers it. spinlock_acquire() and spinlock_release()
call both functions. A thread with a nonzero count must not be preempted.
Another thread on the same hart could spin on the lock.
When the timeslice expires, sched_irq_handler() runs. If a running thread has
a nonzero preempt_count, the handler sets need_resched and rearms the
timer. It does not switch. preempt_enable() calls sched_yield() when the
count reaches zero, need_resched is set and interrupts are enabled.
A thread that waits calls sched_dequeue() and then sched_yield().
sched_dequeue() marks the thread THREAD_BLOCKED and takes a runnable
thread off its queue, but it does not switch. sched_yield() raises a
self-IPI. The thread continues only after the scheduler picks it again. It
requires interrupts enabled and preempt_count equal to zero.
For a live thread, the handler saves its context at thread_context(curr).
It takes the next thread from a queue, or the idle thread, loads the chosen
context and calls sched_enter(). The assembly sets
%rsp to that context. It releases the old hart_lock only after the stack
change. Another hart can then resume the old thread.
A wakeup does not replace the current thread at once. The scheduler sends an IPI only to an idle hart. The current thread then runs until its next tick or until it yields.
Invariants and traps
- A
THREAD_RUNNABLEthread is on exactly one run queue. The scheduler changes itsstateundersched_lockand its queue links under the runqueue lock. - When the scheduler holds both, it takes
sched_lockbefore a runqueue lock. With a runqueue lock held, it only trieshart_lockandsched_lock. - A thread taken from a run queue runs only after
rq_take_nolock()acquires itshart_lock. sched_enterreleases thehart_lockof the previous live thread only after the stack switch.- The timeslice is not a hard limit. A thread with a spinlock can hold a hart past the end of its timeslice.
- A mask read can mix two updates. A running thread can stay on a disallowed hart until the next tick.
- The kernel does not free a dead thread on its own stack. The next tick reaps
hart->dead_thread.
Limits
SCHED_OTHERis the only policy. The constantsSCHED_FIFO,SCHED_RR,SCHED_BATCHandSCHED_IDLEexist in the ABI, but the calls reject them.- Nice changes the length of a timeslice only. It does not change queue order or select a different queue.
- Nice belongs to a process, not to a thread. Every thread of a process gets the same nice value.
- There is no periodic load balance. A TODO in
sched.crecords this limit. A busy hart keeps its queue, and only an idle hart steals from it. - The kernel does not provide a user interface for
bound_hart. Affinity is the only user control of placement. SCHED_AFFINITY_WORDSfollowsMAX_HARTSat compile time. A larger machine needs a new build.
Source
| File | What it holds |
|---|---|
| sched.c | the run queues, hart choice, timeslices, the tick and affinity changes |
| sched.S | sched_enter, the context switch and the delayed hart_lock release |
| thread.c | the save and load of the amd64 thread context |
| sched.h | thread creation, enqueue and the affinity interface |
| sys_sched.c | the nice, policy and affinity system calls |
| thread.h | thread state, affinity, run queue links and preempt_count |
| spinlock.h | spinlocks hold off preemption and call preempt_enable() on release |
Threads
Vex schedules threads, not processes. A process_t owns the address space,
the credentials, the descriptor table and the signal actions. A thread_t
owns the register context, the kernel stack and the state that a hart needs
to run it. The process ends when its last thread leaves.
Scheduler covers where a thread runs.
The two objects
process_t in vex/process.h holds pid, ppid, flags and vmspace.
It also holds the threads list, the child list, credentials, rlimits,
the descriptor table and cwd. process.c caps its size at
PROCESS_SIZE_BUDGET bytes with a static_assert, and it comes from the
slab.
Every process sits in one bucket of proctable[PROCTABLE_BUCKETS], keyed by
pid % PROCTABLE_BUCKETS and chained through hash_next. process_locate()
takes proctable_lock with interrupts disabled, because the scheduler looks
up parents there.
thread_t in vex/thread.h holds tid, state, the scheduling locks and
affinity. It also holds pending_work, the signal masks and queue, usage,
kstack_top, io_page and arch. Its process_next and process_prev
links put it on the list of its process, under process->lock.
Process lifetime
process_create() builds the object and returns it with one reference. It
creates the vmspace and takes the rlimits of the parent, or creates them.
It allocates the signal actions and two SIGCHLD notes, exit_note and
adopt_note. It takes the pid from process_alloc_id(). Then it adds the
process to the child list of the parent and to the process table.
spawn_init() calls it with nullptr.
A user thread comes from thread_create_user(), thread_create_fork(),
thread_create_spawn() or thread_create_sibling(). Each calls
attach_thread_user(), which adds the thread and takes a reference, so the
process outlives it.
The exit has two halves, and one thread does not do both:
begin_exit
sets PROCESS_EXITING once, records exit_code and exit_signal
posts THREAD_WORK_EXIT on every thread
|
v
each thread, on the way out
process_finish_exit: itimer, terminal, groups, files
|
v
the last reap, in the scheduler
vmspace released, PROCESS_ZOMBIE set, SIGCHLD to the parent
|
v
the waiter
process_claim -> process_destroy
begin_exit() sets PROCESS_EXITING with one atomic OR, so only the first
call records exit_code and exit_signal. It posts THREAD_WORK_EXIT on
every thread under process->lock and calls sched_kick() for each one.
A thread acts on that bit only when it returns to user mode, in
exit_to_user(). A thread in an interruptible wait leaves that wait when the
wakeup arrives. Nothing stops a thread in the middle of a system call.
process_thread_exit() decrements num_live_threads for a thread that is not
already leaving. The thread that finds it at zero calls
process_exit(process, 0). The process ends with its last live thread.
The half that may sleep runs in the exiting thread, not in the scheduler.
process_finish_exit() cancels the itimer and hangs up the terminal of a
session leader. It hangs up the groups that the exit orphans and calls
release_files(). PROCESS_EXIT_FINISHED makes the call idempotent, and a
filesystem close may block here.
process_thread_reaped() runs once for each thread that the scheduler frees.
For the last one it releases the vmspace with
process_set_vmspace(process, nullptr), because no hart has those page tables
loaded anymore. It sets PROCESS_ZOMBIE and triggers init_exited for init.
Then it sends SIGCHLD to the parent and moves the children to init.
The event goes out before the signal, so a handler that calls waitpid()
finds the zombie. process_claim() then hands it to exactly one waiter.
process_destroy() unlinks it from the process table and the child list and
releases the files again. It also detaches the group and drops the reference
that process_create() returned.
Thread lifetime
A new thread starts in THREAD_BLOCKED. The caller publishes it with
sched_enqueue(), and the thread_spawn_* forms and sys_thread_create()
do that themselves. A thread attached while the process is already exiting
gets THREAD_WORK_EXIT at once under the same lock. So does a thread attached
while another thread execs. Neither can slip past the walk in begin_exit()
or process_dethread().
attach_thread_user() gives the first thread of a process the pid as its tid.
A later thread takes a new id from process_alloc_id(), the same counter.
That is why a tid never names a thread of another process.
sys_thread_create() receives the entry, the user stack top, the TCB, the
argument and the address of the clear word. It checks each address against
pmap_user_end() and then calls thread_create_sibling().
sys_thread_exit() leaves the process first, with process_thread_exit(),
then clears the word at clear_tid and wakes the waiters on it. It ends in
sched_terminate(), which marks the thread THREAD_DEAD and gives up the
hart.
A thread never frees itself. The next tick on that hart calls reap_thread()
on hart->dead_thread, when the stack is no longer in use. It drops the
process reference, the credentials, the pending signals and the kernel stack
with kstack_free(). It also frees io_page, the event list, the affinity
mask and the thread_t.
Kernel threads and stacks
Every thread, user or kernel, runs on a kernel stack. kstack_alloc() takes a
slot from free_slots, allocates KSTACK_PAGES pages from pslab and maps
them VPROT_RW in sys_pmap. It returns the top of the stack, and the page
below the stack stays unmapped.
The slots lie in one region, KSTACK_REGION_BASE. A slot is one guard page
and then the stack, so KSTACK_SLOT_SIZE is (KSTACK_PAGES + 1) pages.
KSTACK_NUM_SLOTS is CONFIG_MAX_THREADS, and free_slots has exactly that
many bits.
The stack is mapped in every address space. A system call and an interrupt
from user mode run on it. arch_thread_ctx_load() writes thread->kernel_rsp
into TSS.rsp_0, and amd64_syscall_entry loads it from %gs:8.
The saved context sits at the very top of the stack, where the running code
never reaches. thread_context() returns kstack_top - THREAD_CTX_RESERVE,
and the live stack grows down from kernel_rsp.
A stack that runs off its bottom hits the guard page. The fault handler
cannot run on the broken stack. On the double fault the handler reads CR2, and
kstack_is_guard() decides whether to panic with a kernel stack overflow.
A kernel thread has process == nullptr, credentials set to
kernel_credentials, the CR3 of sys_pmap and no FPU state. Its entry has the
type kernel_thread_entry_t. arch_thread_setup_kern() enters it with the
context as the initial %rsp. The code installs no return address: no kernel
thread in the tree returns, and a kernel-mode page fault panics.
init_watchdog in kmain.c is a kernel thread, and so is the idle thread of
each hart, made by sched_init() with sched_idle. A system call that needs
a process returns ENOSYS on one.
Invariants and traps
- A process flag is only ever set, with one atomic OR.
PROCESS_EXITING,PROCESS_ZOMBIE,PROCESS_COLLECTEDandPROCESS_EXIT_FINISHEDmark the stages of the exit, andPROCESS_DID_EXECmarks an exec. - A process that nobody waits for stays a zombie. It keeps its
process_tand its pid, but no files and no page tables. - Each thread holds one reference on its process, and each reaped thread drops
it. The waiter’s
process_destroy()drops the reference thatprocess_create()returned. process_thread_exit()returns at once for a thread that already carriesTHREAD_WORK_EXIT, sonum_live_threadscannot fall twice.attach_thread_user(),begin_exit(),process_dethread()andprocess_thread_exit()change a thread or the count underprocess->lock.process_dethread()makes the execing thread the only thread and gives it the pid as its tid.execwaits untilnum_threadsis one.- Signals no longer pick a thread once it carries
THREAD_WORK_EXIT, andprocess_has_thread()reports it as gone. kstack_free()panics for any address that is not the top of a slot. A thread cannot free its own stack, andreap_thread()runs on another stack.
Limits
- There is no thread join in the kernel.
clear_tidand the futex wake are the only notice, and user space waits for it. - A thread has no name and no detached state. Creation takes the entry, the stack top, the TCB, the argument and the address of the clear word.
CONFIG_MAX_THREADScaps kernel and user threads together, and the stack slots are static. A thread that finds no slot fails withENOMEM.- A kernel stack has a fixed size,
CONFIG_KSTACK_PAGESpages. It does not grow, and every reap unmaps its pages. - Nothing reaps an unclaimed zombie, and only init adopts the children of a dying process. There is no subreaper.
- No pid namespace exists, and a pid is never reused below a wrap.
- User space cannot create a kernel thread, and a kernel thread cannot return. A subsystem that needs one starts it from its initcall.
Source
| File | What it holds |
|---|---|
| kernel/include/vex/process.h | The process_t fields and the PROCESS_* flags |
| kernel/include/vex/thread.h | The thread_t, THREAD_WORK_* and thread_context() |
| kernel/proc/process.c | process_create, the exit stages, the zombie and the process table |
| kernel/proc/sched.c | Thread creation, attach_thread_user, reap_thread and sched_terminate |
| kernel/mm/kstack.c | The stack slots, the guard page and kstack_is_guard() |
| kernel/arch/amd64/proc/thread.c | The amd64 context, the TSS stack and the CR3 of a thread |
| kernel/proc/sys_thread.c | SYS_THREAD_CREATE, SYS_THREAD_EXIT, SYS_GETTID and SYS_TCB_SET |
| kernel/proc/user_exit.c | exit_to_user and the pending work before ring 3 |
| kernel/proc/sys_process.c | fork, exec, waitpid and the claim of a zombie |
| kernel/init/kmain.c | spawn_init, the first thread and init_watchdog |
Signals
A signal is an asynchronous notification to a process or to one thread. It
reports a fault, a terminal event or a request from another process. The
kernel queues it and chooses a thread that can take it. On the way back to
ring 3, it builds a handler frame for a caught signal. The ABI follows POSIX:
there are 64 signal numbers, and SIGRTMIN starts the real-time range.
The kernel sends a signal for a fault, a timer or a terminal event. Another
process sends one through kill(2), tgkill(2) or sigqueue(2).
Delivery
A sender calls signal_send(), signal_queue() or signal_raise().
signal_queue() serves sigqueue(2), and signal_raise() serves the timer.
signal_send() allocates a pending_signal_t. When memory is short, a
standard signal still arrives, with SI_KERNEL if no entry waits for it.
Only a signal from SIGRTMIN up queues more than one entry in one queue. A
standard signal sets one bit, and a second send to the same queue merges with
the pending entry.
queue_signal() takes both locks, then chooses the thread. It drops a signal
as ignored only when some thread could take it now. A blocked signal stays
pending, because its action may change before it is unblocked.
choose_thread_locked() prefers the caller, then walks process->threads. It
skips a thread that blocks the signal or has THREAD_WORK_EXIT. A signal for
one tid goes to the queue of that thread.
recalc_locked() sets THREAD_WORK_SIGNAL when a thread has a deliverable
signal. exit_to_user() handles that bit before it returns to ring 3.
sched_kick() makes a running thread pass that point.
deliver() takes the lowest signal number first. It looks at the queue of the
thread, then at the queue of the process. It removes the entry, or clears the
bit, under signal_actions->lock.
A signal with the default action runs the action in default_actions[]. The
actions are terminate, core, ignore, stop and continue. A signal with a
handler gets a sigframe_t.
The init process is special. Only a signal it handles reaches it, and SIGKILL
spares it, because its death stops the system. A fault that resolves to the
default action still kills it, through signal_force().
signal_force() raises a fault signal for the calling thread. It turns an
ignored action or a blocked signal into the default action. A fatal default
action kills the process at once.
kill(2) -> signal_send -> queue of the process or of a thread
|
queue_signal sets
THREAD_WORK_SIGNAL
|
exit_to_user, on the way to ring 3
|
signal_actions->actions[sig - 1]
/ \
SIG_DFL a handler
stop, kill or ignore arch_signal_setup
Masks
sig_blocked is per thread. sigset_t is a 64-bit mask, and sigbit(sig)
maps a signal number to its bit. signal_mask() applies SIG_BLOCK,
SIG_UNBLOCK or SIG_SETMASK.
SIGKILL and SIGSTOP are never blocked. sigaction(2) also removes them
from sa_mask.
signal_replace_mask() saves the mask for a wait, and signal_restore_mask()
puts it back. A handler restores the saved uc_sigmask through sigreturn.
A handler runs with the old mask, sa_mask and its own signal blocked.
SA_NODEFER keeps its own signal unblocked. SA_RESETHAND resets the action
to SIG_DFL.
fork copies the mask and the alternate stack. exec keeps the mask and the
pending signals of the thread and the process. It clears the alternate stack
and resets every handler except SIG_IGN.
Waiting
sigsuspend() replaces the mask and sleeps. A signal of the set ends the
sleep and runs its handler on the way out. A stop does not end the wait. The
process stays in the call when it continues.
sigtimedwait() takes a pending signal of the set without running its
handler. It unblocks the wanted set for the time of the wait, and returns
-EAGAIN on a timeout.
sigpending() reports the set that is pending and blocked. waitpid() and
waitid() wait on process->child_exited.
wait_child() takes a zombie, or a stop or continue report, from
job.report. WNOHANG returns zero when no child reports. SA_NOCLDSTOP
suppresses SIGCHLD for a stop or a continue, but not the wait report.
An interruptible wait that gets a signal returns -ERESTART. The system call
entry rewinds the call and sets thread->syscall_restart. A caught handler
without SA_RESTART turns it into -EINTR. Otherwise the call runs again.
Job control
job_control_t holds the state of one process: JOB_RUNNING or
JOB_STOPPED, the stop signal, two report bits and the resume event.
process->lock protects the state. The report bits change with atomic
operations.
job_stop() sets JOB_STOPPED, records stop_signal, sets
JOB_REPORT_STOPPED and posts THREAD_WORK_STOP to every live thread.
job_park() blocks a thread until the process runs again.
SIGCONT acts when it is sent. It purges the stop signals. A stopped process
clears JOB_REPORT_STOPPED, sets JOB_REPORT_CONTINUED and triggers
resume. A stop signal purges a pending SIGCONT.
SIGSTOP, SIGTSTP, SIGTTIN and SIGTTOU stop by default. A terminal
sends SIGTTIN or SIGTTOU to a background group. A refused SIGTTIN, or an
orphaned group, fails the access with -EIO. A refused SIGTTOU lets the
access through.
process_group_orphaned_locked() says that no member has a parent outside the
group in the same session. A terminal stop signal does not stop an orphaned
group, because nobody could continue it.
An exiting session leader sends SIGHUP and SIGCONT to the foreground
group. An orphaned group with a stopped member also gets SIGHUP and
SIGCONT.
kill(2) sends to a process, a group or every process. kill_all skips the
sender and init. may_signal() requires CAP_KILL or a matching uid.
Invariants and traps
signal_actions->lockis always taken beforeprocess->lock.process_group_locknests outside both.- The kernel looks up a target thread under the locks that queue the signal. The thread therefore cannot be reaped in between.
- The kernel drops a signal as ignored only when a thread could take it now. A signal that is blocked at send time stays pending.
- A handler frame is never built inside a system call.
signal_deliver_uncaught()leaves a caught signal pending for the return. - The process dies when
arch_signal_setup()cannot build a frame. - A fault signal must not return to the faulting instruction. The kernel turns an ignored action or a blocked signal into the default action, which kills.
signal_take()removes a pending signal, and no handler runs for it.- A standard signal has at most one entry in a queue. Each real-time entry
takes memory, and
RLIMIT_SIGPENDINGlimitssigqueue(2)only. - A zombie goes to exactly one waiter.
process_claim()decides which one.
Limits
- There is no
sigwait(), nosigwaitinfo()and nosignalfd. The only synchronous wait for a signal issigtimedwait(). SA_SIGINFOpasses the flag check, but no kernel code reads it. The frame always holds asiginfo_t, and the kernel always callssa_handlerwith three arguments.SA_NOCLDWAITpasses the flag check, but no kernel code reads it. A zombie stays until a wait claims it.DEFAULT_COREterminates the process likeDEFAULT_TERM. The kernel writes no core file.RLIMIT_SIGPENDINGcounts the process queue and the thread queues together.kill(2),signal_send()andsignal_raise()do not check it.- A real-time signal can be lost when no entry is available and an entry of the same signal waits already.
SI_TIMER,SI_MESGQandSI_ASYNCIOare defined, but no kernel path sends them.
Source
| File | What it holds |
|---|---|
| signal.h | the action and pending objects, and the signal API |
| process.h | job_control_t and the job control calls |
| thread.h | the per-thread mask, queue and alternate stack |
| signal.c | queueing, thread choice, delivery and masks |
| amd64 signal.c | the handler frame and sigreturn |
| sys_signal.c | the signal system calls and the permission check |
| job.c | stop, continue, park and wait reports |
| pgrp.c | groups, sessions and orphan detection |
| process.c | process exit, kill and the SIGCHLD note |
| user_exit.c | delivery on the way to ring 3 |
| amd64 syscall.c | the syscall rewind and -EINTR |
| sys_process.c | waitpid(2) and waitid(2) |
| tty_interface.c | terminal foreground checks and job signals |
System calls
A system call is how a process asks the kernel to do work for it. The number in a register selects a handler from one table. The handler reads the arguments and returns one value.
The mechanism has two halves. The generic half is the number blocks, the
table, the dispatcher and the handler contract. The arch half is the entry
stub, the register layout, the restart and the return to user. amd64 is the
only implemented arch, so the syscall instruction and every register name on
this page are amd64. The paragraphs that describe an instruction or a register
say so. A new port supplies its own arch half and keeps the generic contract.
The path
user space | arch (amd64 today) | generic kernel
---------------+---------------------+---------------------
| |
mov number/args| |
syscall ------>| entry stub |
| save the frame |
| arch handler ----->| syscall_dispatch
| | table[number]
| | handler
| intptr_t <----|
| restart and check |
| exit_to_user ----->| deliver signals
| restore the frame |
sysretq <------| return stub |
The middle column is the arch code. A port replaces it and keeps the right
column and the table. The names in the middle column are roles. The amd64
functions are amd64_syscall_entry, amd64_syscall_handler and the return
path in the same stub.
The arch half, on amd64
Vex uses the SysV AMD64 ABI for a system call, as Linux does. rax holds the
number and the result. The arguments are in rdi, rsi, rdx, r10, r8
and r9. The fourth argument is r10 and not rcx, because syscall keeps
the return address in rcx. The
SysV ABI specification defines
the rest of the register state.
The entry stub runs with the user stack still loaded. It swaps gs to the
kernel base, saves the user stack pointer in thread::entry_scratch, and
loads the kernel stack from the thread. It then pushes a hart_context_t
frame with the user registers. usage_enter_kernel() runs before sti, so
an interrupt in that window counts as system time. Interrupts are enabled
when the dispatcher runs.
arch_init_syscall() sets EFER.SCE, STAR and LSTAR on every hart, and
clears TF, IF, DF and AC in SFMASK. A call therefore starts with
interrupts disabled and with a clear direction flag.
The generic code never names a register of the hart. It reads the frame
through the syscall_number, syscall_retval, syscall_arg_1 to
syscall_arg_6 and syscall_sp macros of <arch/hart.h>. A port defines
these macros for its own registers, and its entry stub fills the frame. The
port also supplies the restart rewind and the return path in the next
section.
Dispatch
syscall_dispatch() reads the number through syscall_number. A number at or
past syscall_table_size is not a call, and a null entry is not a call
either. Both return -ENOSYS. The function copies the six arguments into an
array, records the frame in thread::syscall_ctx and calls the handler.
thread::syscall_ctx stays valid for the whole call. fork, sigreturn and
the job control code read it.
A handler returns intptr_t. A negative value is an error, and the absolute
value is the errno number. Zero and positive values are results. The
dispatcher stores the value through syscall_retval and returns.
The table
A handler declares itself with define_syscall(function, number, count).
count is the number of registers that the call reads. The macro makes a
syscall_t entry in the .syscalls section, and the linker script brackets
that section with unsorted_syscalls_start and unsorted_syscalls_end.
init_syscall() builds the table once, at the syscall stage of the BSP
initcalls. It sizes the table from the highest number, allocates it with
kmalloc, and copies every entry into its slot. A duplicate number trips a
kassert. A slot that no call claimed stays null. The table does not change
after this point, and nothing removes a call.
Two other macros do work around the handler:
define_syscall_fdresolves the first register as a descriptor and passes afile_t*in its place. The dispatcher holds a reference for the call and closes the file when the handler returns.define_syscall_dirfdcopies the second register, a path, into aPATH_MAXbuffer on the kernel stack. A path without a terminator fails with-ENAMETOOLONG. The handler gets the rawdirfdand the copied path, and resolves the base itself withsyscall_dirfd()orsyscall_resolve_at().
A new macro is for work that the dispatcher must do around every call of that shape. It is not for work that one handler does with its arguments.
Number blocks
Each subsystem owns a block of 32 numbers, and the block starts at a multiple
of 0x20. A new call takes the next free number in its block. Number 0 stays
invalid, so that a zeroed register fails with ENOSYS and not with a real
call. A gap in a block also fails with ENOSYS.
Restart and signals
A handler that cannot finish yet returns -ERESTART. The arch handler keeps
the number, rewinds the saved program counter to the entry instruction, and
sets thread::syscall_restart. The call then runs again, unless a signal
needs a handler first.
exit_to_user() delivers the pending signals. A caught signal without
SA_RESTART replaces the restarted call with -EINTR and moves the program
counter past the entry instruction. A signal with SA_RESTART leaves the
rewind in place, and the call runs again. SYS_SIGRETURN is exempt, as its
frame brings back the interrupted result register.
On amd64, the rewind and the skip are two bytes, the size of the syscall
instruction. arch_syscall_interrupted() sets -EINTR in rax and moves
RIP and RCX by the same amount. Before it leaves, the handler checks that
the user RIP is below the end of the address space. A higher value kills the
process with SIGSEGV, because SYSRET with such a value faults in ring 0 on
Intel. If the frame no longer matches RCX and R11, the return goes through
IRETQ instead of SYSRET.
Rules for a handler
- The signature of the handler is the signature of the call. The arguments
are register-wide integers, except the object that a
_fdor_dirfdmacro passes in front. - A call reads at most six registers. A seventh argument needs a struct or a pointer.
- A user pointer arrives as a
user_addr_t. The handler copies through it with theuser_*helpers, and never follows it as a pointer. - A handler that the
_fdmacro wrapped must not close the file. The dispatcher owns that reference. - The handler runs with interrupts enabled, and it may block.
- A negative return is an errno. Only
-ERESTARTis special, and only the arch return path sees it.
See also
- Syscalls lists every call, with its number, arguments and handler.
Source
Generic:
- syscall.c — dispatch, descriptors and path resolution
- syscall_internal.h — the registration macros
- syscall.h — the table entry and the dispatch interface
- syscall.h (usr) — the numbers
- signal.c —
SA_RESTARTand-EINTR - user_exit.c — pending work on the way out
amd64:
- syscall.c — the handler, the restart and the MSRs
- syscall.S — the entry and exit stubs
- hart.h — the register aliases of
hart_context_t - kernel.ld — the
.syscallssection
VFS
The VFS turns a path into an object. It gives every filesystem one interface. The rest of the kernel then reads and writes a file without the name of the filesystem that holds it.
The design follows the vnode interface of Kleiman: Vnodes: An Architecture for Multiple File System Types in Sun UNIX (PDF, 1986). A name and an inode are separate. A vnode stands for one inode. The VFS keeps no name cache. Its cache maps the inode number of a filesystem to the vnode of that inode.
The objects
| Object | Type | Stands for |
|---|---|---|
| Mounted filesystem | vfs_t (mount.h:46) | One filesystem instance, at one mount point |
| Vnode | vnode_t (vnode.h:188) | One inode of one filesystem |
| Open file | file_t (file.h:20) | One open file description |
| Cache entry | vcache_entry_t (vcache.h:11) | The map from a pair of filesystem and inode number to a vnode |
A vfs_t holds the root vnode, the ops table of the driver, the block volume, the device number, and the flags readonly and unmounting. It also counts the vnodes that lost their last name but still have references.
A vnode_t holds its type, its filesystem, its inode number and the ops table. It holds the parent of a directory, the filesystem mounted over it, the page cache of the inode, and the list of file locks. vnode_alloc makes an uncached vnode. The filesystem fills it in lookup, create, mknod or symlink.
A file_t holds the vnode, the access flags, the offset and a mode word. The ctxdata field belongs to the vnode and its open op. file_dupe shares the same file_t, so two descriptors share one offset.
The interface itself is vnode_ops_t (vnode.h:64). A filesystem implements the operations that it supports. An operation set to nullptr fails in a default way that the VFS chooses. The table has no per-operation version number, so a new op needs a default in the VFS.
The cache
A vnode starts with a reference count of one. The caller that got the vnode owns that reference. The last vnode_decref does not free the vnode at once. It calls vcache_keep (vnode.c:691):
- The VFS destroys a vnode that has no cache entry at once.
- A vnode that has an entry goes to the tail of the unused list.
- The VFS destroys the oldest entries past
CONFIG_VCACHE_MAX_UNUSED(1024 by default).
The cache keeps a vnode without references, so a second lookup of the same path costs no disk access. vcache_get takes an entry back from the unused list and gives the caller a new reference.
The cache key is the pair of fs and ino, not the name (vcache.c:21). An inode takes one vnode in the whole system. Two names of one inode, such as a hard link, share the same vnode.
A pinned entry belongs to its owner, which holds a reference until it calls vcache_remove (vcache.h:19). Devfs uses this for every device node (devfs.c:124). The pin fails with -EEXIST when another vnode already holds the key.
A file that loses its last name while it is open becomes an orphan. unlink marks the vnode orphaned and adds one to fs->orphans (vnode.c:310). The vnode lives until its last reference goes. The count keeps the filesystem from unmounting, because an orphan has no cache entry for the reference check to see.
vnode_destroy (vnode.c:706) drops the page cache, the file locks and the parent reference. It calls ops->release last, which frees the storage of the inode. A filesystem therefore frees its inode data in release, not in unlink.
Lookup and crossing
A lookup starts with a directory vnode and one name:
vnode_lookup(dir, name)
|
|-- vfs_cross_mount(dir) -> the root of a mounted filesystem, if any
|
|-- dir->ops->lookup(name) -> ino
|
|-- vcache_get(fs, ino) ------------ hit --> return the cached vnode
|
`-- vnode_alloc(fs, ino)
|
|-- fs->ops->fill_node(node)
`-- vcache_get_or_insert(node)
The cache can already hold the node, because another thread looked it up in the meantime. vcache_get_or_insert then returns the cached vnode, and the caller destroys its new vnode (vnode.c:113).
A mount point changes the answer of every operation. vfs_cross_mount (mount.c:650) returns the root of the mounted filesystem in place of the mount point directory. Every operation that takes a vnode from a path calls it at the start. The vnode from a path lookup can be a mount point, and the operation that uses it crosses on its own.
A vnode that is not a directory never crosses. A mount point is always a directory.
vfs_cross_mount fails with -EBUSY while a filesystem unmounts. The operation then fails before it reaches the filesystem.
Paths
path_resolve_at walks one component at a time (path.c:170). Each component:
- Must not be longer than
NAME_MAX. The VFS returns-ENOTDIRfor a longer one. - Needs
X_OKon the current directory. - Goes through
vnode_lookup. - Follows a symbolic link, unless it is the last component and
flagscontainsPATH_NOFOLLOW.
The walk skips a dot component. A dot-dot component goes to parent_of, which climbs the mount stack: the root of a mounted filesystem stands for the directory that it covers (path.c:113). The walk stops at the root of the calling process. It never leaves that tree.
The walk reads a symbolic link into one page. A relative target continues from the directory that holds the link. An absolute target continues from the root of the process. The depth stops at 8 links, and the walk fails with -ELOOP (path.c:11).
A trailing separator means that the path continues, as POSIX says. link/ therefore follows the link even with PATH_NOFOLLOW, because the directory behind it must be a directory.
The root of the walk is per process. process.c sets path_set_root_source to the root of the current process (process.c:1068). chroot moves that root. A thread with no process root starts at the root of the VFS.
Two other entry points share the walk:
path_resolve_parent_atresolves the directory that holds a new name and returns the name.file_openuses it forO_CREAT.path_buildmakes a path from a vnode. A vnode has no name, so it climbs to the root and iterates each parent directory to find the entry with the right inode number (path.c:376). A directory outside the root of the process fails with-ENOENT.
Mount and unmount
A driver declares itself with a vfs_driver_t and calls vfs_driver_add in a vfs stage initcall. The call needs a name, a mount op and an unmount op. A second driver with the same name fails with -EEXIST.
vfs_mount takes a path and a type. A type of nullptr asks each driver to probe the device: the first probe that returns 0 wins (mount.c:53). The VFS itself reads ro and readonly from the parameters and sets MNT_RDONLY from the flags.
vfs_mount_at does the part that needs a vnode. The mount point must be a directory. A second mount on the same directory fails with -EBUSY. On success the vnode holds the mounted filesystem in mounted_fs, and the filesystem holds the vnode in mountpoint. The VFS holds one reference to the mount point, and the driver holds one to its root. The VFS also adds the filesystem to its list of mounts.
kmain mounts the root with a nullptr mount point (kmain.c:39). That filesystem becomes the root of the VFS, and its root vnode lives in vfs_root.
vfs_create makes a filesystem that no path leads to (mount.h:73). It has no device and no mount point, it is not in the list of mounts, and nothing can unmount it. Pipefs and the internal tmpfs of shared memory use it. A driver marks itself internal to keep vfs_mount from mounting it by name.
A busy filesystem refuses to unmount. vfs_unmount_at first sets unmounting, so new operations fail. It syncs the page cache, then checks two counts (mount.c:470):
fs->orphansmust be zero.- The sum of the reference counts of the vnodes must not exceed the references that the VFS knows about. Those are the references of the root and of the owners of pinned entries, plus one per cached directory with a parent.
The second check is exact. A leaked reference to a vnode fails the unmount with -EBUSY, and the leak stays visible.
Unmount then syncs the filesystem, removes the entry, clears mounted_fs, evicts every vnode of the filesystem, and calls ops->unmount. The driver drops its own state and the reference to its root.
Open files
file_open resolves the path. If the path does not exist and the flags have O_CREAT, it resolves the parent, checks W_OK | X_OK, and calls vnode_create. A lost race with another creator retries the lookup.
A create with O_EXCL, and any open with O_NOFOLLOW, resolves the last component with PATH_NOFOLLOW, so a symbolic link there counts as an existing name.
file_open_node then applies the checks that need the vnode (file.c:89):
O_DIRECTORYneeds a directory.- A write to a directory fails with
-EISDIR. - A write to a regular file on a read-only filesystem fails with
-EROFS. A device node stays writable, because its filesystem is not its device. O_TRUNCremoves set-id bits and file capabilities first, then sets the size to zero.
The file layer decides if a file is seekable by asking vnode_seek(..., SEEK_CUR). A file that answers -ESPIPE is a stream, such as a FIFO or a terminal. Every other answer makes the file seekable (file.c:136).
file_read and file_write take a pointer to the offset. A normal call passes &file->offset. pread and pwrite pass a private copy, so they do not move the shared offset. The file layer ignores O_APPEND: the caller picks the position.
A write to a regular file stops at RLIMIT_FSIZE. A write that starts at the limit fails with -EFBIG and sends SIGXFSZ to the calling thread. A file that is longer than the limit can still shrink.
The last file_close releases the file locks of the open file, calls vnode_close, drops the vnode reference and frees the file_t.
Two kinds of lock live on the vnode: a POSIX record lock, which belongs to a process, and an open-file lock, which belongs to a file_t (file_lock.h:14). The VFS keeps the ranges in a list on the vnode. A process loses its locks on a file when any descriptor of that file closes (process.c:210). The conflict test, the wait and the deadlock check are in file_lock.c.
The page cache
Every vnode has an fcache_t (vnode.h:203). It holds the pages of the inode by page index, and it is the only cache of file data. A filesystem passes its block map to fcache_read, fcache_write and fcache_fault. The cache calls back for the sectors that it needs.
A page that a caller dirties goes to one global write-back queue. A thread writes the queue back at CONFIG_FCACHE_WRITEBACK_MSEC intervals. A write that finds more than CONFIG_FCACHE_DIRTY_PERCENT of memory dirty writes its own file back before it returns. fcache_sync writes back one filesystem, and unmount calls it.
A tmpfs vnode sets the cache to memory mode (fcache_set_memory): the pages are the only copy, they are never dirty, and a hole reads as zeroes.
The cache lives inside the vnode, so the vnode’s lifetime sets its lifetime. vnode_destroy deinits the cache, and the deinit asserts that the vnode is not on the write-back queue.
Invariants and traps
- The cache key is
(fs, ino). A filesystem must give one inode number to one file. A filesystem whose inode number follows the location of the entry callsvcache_rekeyafter a rename (vnode.c:423). - The last-name rule. When the last name of an inode goes, the filesystem calls
vcache_removeunder its own lock, before the entry can be reused (vnode.h:85). Otherwise a new file at that location gets the vnode of the old one. The VFS cannot do this for the filesystem, because only the filesystem knows which name was the last. - The VFS holds no filesystem lock. Each filesystem serializes its own operations with one mutex (
ext2_internal.h:386,fat_internal.h:173,echfs_internal.h:79,tmpfs_internal.h:19). The VFS locks are narrow:mounts_lockfor the mount list and the crossing,cache_lockfor the cache,drivers_lockfor the driver list. No filesystem call runs under one of them. - The per-vnode
lockis not the file lock. It guards the parent link and the capability snapshot. It does not guard the ops. - One mutex covers every rename in the kernel. The mutex (
vnode.c:48) also covers the cache steps of the rename, so they cannot interleave with another rename. - Crossing happens at the operation, not at the lookup. A path can end on a mount point. The vnode is the mount point, and the next operation crosses it.
- A dot-dot never leaves the root of the process. The check is
current == root, not “the parent is null” (path.c:231). - A missing op has a default.
lookup,create,unlink,rename,read,writeandstatfail with-ENOSYS.mknod,link,symlinkandset_attrfail with-EPERM.fallocateand the xattr ops fail with-EOPNOTSUPP.mmapsucceeds. - The VFS owns the parts that every filesystem would repeat. It applies the read-only check, the new-name check and the rename checks. It also applies the access time policy, the capability removal on write, and the namespace split of xattrs. A filesystem stores xattrs and does not check their permissions.
- Access time follows
CONFIG_ATIME_*. The VFS sets it on a successful read and on the start of a directory iteration, throughset_attr(vnode.c:479). A read-only filesystem and a filesystem withoutset_attrnever get one. - A directory holds a reference to its parent. The chain ends at the root, which has no parent. This reference is why the unmount check counts expected references. The check does not test for zero.
vnode_fault,vnode_mmap,vnode_resolve_pageandvnode_fallocatedo not cross a mount. They run on the vnode of an open file. A mount point is a directory, so a file vnode never needs the crossing there.
Limits
- The VFS keeps no name cache. Every lookup of a component asks the filesystem for the inode number, even when the cache holds the vnode. A name cache would need invalidation from every filesystem.
- One mutex covers all renames in the kernel. Two renames on unrelated filesystems wait for each other.
- The unused list of the cache is global. A filesystem that fills it evicts the unused vnodes of the other filesystems.
- A device node on a filesystem other than devfs does not open.
device_node_openreturns-ENXIO, because the kernel has no table from a device number to an interface (vnode.c:215). - There are no mount namespaces. The kernel has one tree of mounts. A process root is the only per-process view of it.
- There is no lazy unmount. A busy filesystem stays mounted until its last user goes.
Source
| File | Contents |
|---|---|
| vnode.c | The operation wrappers, the crossing, the checks, the times, the xattrs |
| vcache.c | The keyed cache, the unused list, the eviction |
| mount.c | The driver list, mount, unmount, the root, vfs_create |
| path.c | The walk, the symbolic links, the dot-dot rule, path_build |
| file.c | Open, close, read, write, seek, the size limit |
| fcache.c | The page cache, the write-back queue, the fault path |
| vnode.h | vnode_t, vnode_ops_t and the contracts of the ops |
| vcache.h | vcache_entry_t and the cache calls |
| mount.h | vfs_t, vfs_ops_t, the mount calls |
| file.h | file_t and the file calls |
| fcache.h | The page cache interface |
Device model
Vex must drive hardware that firmware describes and hardware that code finds.
kernel/device/ holds one tree of device_t nodes for this. A bus adds and
removes the nodes that it finds. A driver claims a node and publishes an
interface on top, which devfs shows to userspace.
After init, the tree changes only on device_worker, one kernel thread.
Probe, attach and detach run there. A driver can sleep during attach, and the
tree needs no lock around driver code.
The device tree
device_get_root() makes the root on the first call, from fwnode_root() when
the firmware describes one. The root has id 0 and the name "". Every other
node takes the next value of next_id, so no id repeats. device_add() adds
a child below a parent and gives the caller a new reference to it.
All children of a parent share one list through priv.next and
priv.children. add_node() walks that list and returns -EEXIST for a
name that a sibling already has. A name is unique among siblings only, not in
the tree. device_get_path() writes the path from the root, such as
acpi:PCI0/pci:0000:00:1f.0/isa:3f8.
A child holds a reference to its parent. The tree holds one reference to each
node, and device_add() gives the caller another. device_decref() frees a
node at its last reference. It asserts that its children, interrupts, mappings,
providers, consumers and waits_for are all empty. remove_node() takes
the node out of the list and then drops the tree’s reference. A node that
others still hold stays alive.
copy_resources() copies the resources of the node’s firmware node. It skips
a range with FWNODE_RANGE_WINDOW, which a bridge passes on to its children.
The copy holds at most DEVICE_RESOURCE_MAX (16) entries.
device_sync_firmware_children() compares the firmware children with the
tree. It places a present child through bus_ops->place_child(). When that op
returns -ENOENT or is missing, it adds the child as an unplaced node. It
removes an absent child only when firmware_judges() is true. The test holds
for an unplaced child. It also holds when the parent is not a bus, or has no
rescan op. A bus with rescan judges its own children.
device_bus_of() returns nullptr for an unplaced child, even when the parent
is a bus. Such a child uses its firmware description for ranges and interrupts,
and the parent bus ignores it.
device_set_gone() marks the node and its subtree as gone.
device_is_gone() is true for that flag, or for the state DEVICE_GONE. A
bus that lost the hardware calls it before it queues the removal.
device_worker
Every change after init goes through the worker:
device_queue_request(node, request)
| pending |= request, one reference, enqueue once
v
queue_head --> device_worker
| queue_pop, then run_queue
v
EJECT > REMOVE > UNBIND, then RESCAN
|
v
bind -> wait -> attach -> rescan children
device_queue_request() accepts any context. It sets the bit of the request
in priv.pending under queue_lock, puts the node in a FIFO once, takes a
reference, and triggers worker_wakeup. A second request for a queued node
only sets another bit.
run_queue() pops the node and takes all pending bits. It acts in this order:
DEVICE_REQUEST_EJECTrunseject_node(), which callsfwnode_eject().DEVICE_REQUEST_REMOVErunstake_offline(), thenremove_node().DEVICE_REQUEST_UNBINDrunstake_offline(), thenunbind_node().DEVICE_REQUEST_RESCANrunsrescan_node().
A removal or an eject makes the other requests of the node moot. An unbind
comes before a rescan, so a node asked for both ends up bound anew.
take_offline() calls offline on every child and consumer first, and on
the node last. If any of them refuses, online_node() restores all of them
and the request does not happen.
init_device_tree() runs at INIT_LATE. It warns about each driver that
is_complete() rejects, and makes the root. The scheduler does not run yet,
so it rescans the root on the spot and then starts the worker.
/dev/hwtree reports each change. Its ioctls queue a rescan, an unbind or an
eject for a node id. Userspace never changes the tree directly.
Probe and attach
is_complete() accepts a driver only with detach and release, and with
both offline and online or neither. init_device_tree() warns about the
other drivers and leaves them out.
bind() walks the drivers_start to drivers_end range, which the
.drivers section fills through define_driver. For each driver it takes the
first driver_match_t that matches and calls probe(). The probe returns a
DRIVER_SCORE_* level, a higher value, or -ENODEV. It never returns zero.
The device goes to the driver with the highest score. A tie goes to the driver
that comes first in the section. attach() then runs with no tree lock, and
may sleep.
On success the node gets driver and the state DEVICE_BOUND.
device_wake_waiters() then queues a rescan of every node that waited for it.
On failure:
attachreturning-EAGAINwith a dependency setsDEVICE_WAITING. The node attaches later, whendevice_wake_waiters()queues its rescan.- Any other error sets
DEVICE_PRESENTandattach_failed. A rescan does not bind the node again. undo_attach()runsdevice_release_resources()andrelease(), and clearsctxdata.- The device stays unbound. It does not fall back to a lower-scored driver.
device_wait_for_dependencies() walks fwnode_for_each_dependency(), which
ACPI’s _DEP fills. device_wait_for() links the consumer to a bound
provider, so unbind_consumers() unbinds the consumer first. It returns
-EDEADLK for a cycle and -ENODEV for a provider that is gone. A provider
that is not bound yet gives -EAGAIN. device_request_irq() calls
device_use() for the controller of the GSI.
Interfaces
A node is not a file. A driver publishes a block_interface_t with
block_interface_add_root(), or a char_interface_t with
char_interface_add(). The interface has a name from dev_name_t, an ops
table and a reference count. Its device pointer names the node, and the
driver sets it before the add.
devfs subscribes to the block and char event
buses and mirrors each interface as a vnode. A block interface with
BLOCK_FLAG_HIDDEN gets no vnode. It forwards the vnode operations to the
interface, so userspace reaches a device only through that interface. The
interface name follows Device names.
char_interface_t serves a byte stream: open, read, write, seek,
ioctl, poll, mmap and resolve_page. block_interface_t serves
sectors through the submit op. A driver for a terminal line, a serial port
or a framebuffer embeds a char_interface_t. The containing type is
tty_interface_t, serial_interface_t or fb_interface_t, and the ops
belong to that core.
device_request_irq() and device_map_io() record what
device_release_resources() takes back. That happens after the driver detaches
the device, or after its attach fails. The framework does not remove an
interface. detach() removes it, and release() frees the state.
Invariants and traps
device_tstate changes only ondevice_worker.device_tree_mutexguardschildren,next, the provider and consumer links, and the interrupt and mapping lists. No driver call runs under it.- A child holds a reference to its parent. A node cannot outlive its children.
- A failed
attachis final for the node: a rescan does not bind it again. An unbind clearsattach_failed. - An
offlinerefusal cancels the whole request.online_node()undoes everyofflinethat succeeded. - A resource list holds 16 entries.
collect_resource()drops the 17th entry without an error. device_bus_of()ignores the bus of an unplaced node. A child thatbus_ops->place_child()rejected gets no bus translation.remove_node()unbinds the root but leaves it in the tree.
Limits
- There is one worker thread. A slow
attachdelays every other node. - After a failed
attach, the node waits for an unbind, and no other driver gets the device. - The tree has no device class, no power management beyond
offlineandonline, and no user-space driver. - Hotplug comes only from a rescan. A bus finds its hardware, a firmware notify starts one, or userspace asks for one.
- Sibling names are unique. Names at different depths need not be.
- A name that does not fit
DEVICE_NAME_MAX(32) bytes truncates silently. - A path that does not fit
DEVICE_PATH_MAX(256) truncates silently. - An interface is the driver’s to remove in
detach(). The framework never removes one, so a driver that skips it leaks the interface.
Source
| File | Holds |
|---|---|
| tree.c | the root, the child list, device_add, resources and the reference count |
| bind.c | the worker, the request queue, the score and the attach result |
| depend.c | the provider links and the wait for a provider |
| resource.c | ranges, mappings, interrupts and device_release_resources |
| device_internal.h | the request bits, device_link_t and device_bus_of |
| device.h | device_t, the state enum and the resource API |
| device_bus.h | bus_ops_t and the range translation |
| driver.h | driver_t, driver_match_t, the scores and define_driver |
| fwnode.h | the firmware node, its ops and its resources |
| hwtree.c | the userspace view of the tree and its requests |
| char_interface.h | the char interface, its ops and the registry |
| char_interface.c | the char registry and the open count |
| block_interface.h | the block interface, its ops and the request |
Interrupts
A device raises an interrupt when it needs the CPU. The CPU suspends the
running thread and takes a vector from the IDT. One stub saves the registers,
and irq_dispatch() calls the handler that a driver registered. The mechanism
turns a hardware signal into a call of a C function.
Three numbers appear on that path, and the page keeps them apart. A vector
is an index in the IDT. An IRQ number is an index in the handler tables of
kernel/irq/irq.c. A GSI names an input of an interrupt controller, and the
firmware or the bus gives it once.
The hardware on this page is amd64: the IDT, the 8259 PIC, the IOAPIC and the
LAPIC. A port implements the same arch_* entry points for its own controller
model.
Vectors
The IDT has IDT_SIZE entries, 256 in all. Every installed gate points into a
stub in idts.S. The CPU pushes an error code for some exceptions only, so a
stub that has none pushes a zero itself.
arch_init_idt_bsp() loads the table at INIT_EARLY. Every hart runs
arch_init_idt_smp() at INIT_MIDDLE, and it loads the same table.
device --> IOAPIC RTE or MSI message --> LAPIC --> IDT gate
--> irq_stub --> amd64_irq_handler --> EOI
--> irq_dispatch(number) --> irq_handler_t handler
The low vectors serve the CPU. Vectors 0x00 to 0x1F are the exceptions, and
amd64_isr_handler() turns one into a panic or a signal. The gates of 0x02,
0x08 and 0x12 use an IST from IDT_IST_NMI, IDT_IST_DOUBLE_FAULT and
IDT_IST_MACHINE_CHECK. A stack overflow still finds a stack there.
Vectors 0x20 to 0x2F hold the spurious stubs of the old PIC. The gates from
0x30 to 0xFF use an IRQ stub. Only 0x30 to 0xFE carry an IRQ number:
ARCH_IRQ_BASE is 0x30, and ARCH_IRQ_MAX is 0xFE. The gate 0xFF is the
LAPIC spurious vector, outside the number space.
amd64_irq_handler() sends the EOI before it dispatches. A handler therefore
runs with the interrupt already acknowledged, and an asserted level-triggered
input can raise again during it.
Handler numbers
irq_handler_add() gives a number that means the same on every hart. The tick
and the TLB shootdown use it, because a hart sends an IPI to another hart at
that number. irq_handler_add_hart() gives a number on one hart only, and
interrupt_line_t uses it.
irq_table holds the shared entries, and hart_tables[] holds one table per
hart. The first call that needs the table of a hart allocates it. A number is
free only when irq_table has no handler for it and no hart uses it:
is_free_everywhere().
Shared numbers come from the top, and per-hart numbers from the bottom. A higher vector has a higher priority in the LAPIC, so the tick and the IPIs sit above the devices.
irq_dispatch() reads the shared entry first, and the entry of its hart
otherwise (entry_of()). It raises in_flight before it loads the handler,
and the removal waits for that count to reach zero. A dispatch that finds no
handler logs an unhandled IRQ.
Lines
The device framework calls interrupt_attach() for a GSI. A GSI becomes one
interrupt_line_t: one hart, one IRQ number of that hart, and a list of
interrupt_action_t. interrupt_controller_find() selects the controller
whose gsi_base and gsi_count cover the GSI, and the attach fails with
ENODEV without one.
Two drivers share a line only when both ask for INTERRUPT_SHARED and agree
on trigger and polarity (may_join()). Any other case fails with EBUSY.
bind_line() starts with the boot hart and keeps it unless another online
hart has fewer lines. If that hart cannot take the line, it tries every other
hart. A hart that accepts the line adds one entry with
irq_handler_add_hart(), and hart_lines[] holds the load of that hart.
program_line() writes irq_number + ARCH_IRQ_BASE into the controller or
into the MSI message. A new line routes its GSI with the entry masked.
add_action() unmasks it after the handler is in the list. The order keeps an
interrupt from reaching a handler that is not there yet.
interrupt_detach() removes the action, and destroy_line() runs when the
last one goes. The destroy masks the line, gives the number back and frees the
memory.
The IOAPIC
arch_init_ioapic() reads the MADT and makes one interrupt_controller_t per
IOAPIC entry. gsi_count comes from the version register: the maximum
redirect index plus one. Every redirection entry starts masked.
ioapic_route() writes a whole RTE. It sets the vector, the destination LAPIC
id, level from TRIGGER_LEVEL, active_low from ACTIVE_LOW, and masked.
ioapic_mask() and ioapic_unmask() change one bit of the entry.
An ISA IRQ does not always reach the IOAPIC at the same GSI.
interrupt_isa_route() maps it through the MADT interrupt source overrides,
which record_override() records. Without an override the GSI is the IRQ
itself, at TRIGGER_EDGE and ACTIVE_HIGH.
The IOAPIC controllers enter the device tree at INIT_LATE. A driver that
uses their GSIs can then depend on their nodes.
The PIC
The kernel does not route an interrupt through the PIC. arch_init_pic_bsp()
remaps the two chips to 0x20 to 0x2F, masks every input and sends
end-of-interrupt commands to both. arch_init_pic_smp() installs
amd64_spur_20 to amd64_spur_2F on those vectors. Their handler only logs,
and it sends no EOI.
The local APIC
arch_init_lapic() reads MSR_IA32_APIC_BASE, and it panics when the global
enable bit is clear. It then maps one page of registers with mmio_map(). It
sets LAPIC_SVR_ENABLE and the spurious vector in LAPIC_REG_SVR.
lapic_write(LAPIC_REG_EOI, 0) acknowledges an interrupt. lapic_ipi()
sends an IPI, and it disables interrupts while it writes LAPIC_REG_ICR_HI
and LAPIC_REG_ICR_LO. The LAPIC timer puts irq + ARCH_IRQ_BASE in its LVT
entry.
The spurious vector 0xFF is above ARCH_IRQ_MAX, so no IRQ number maps to
it. amd64_irq_handler() returns before the EOI for it. A spurious interrupt
is not in service, so an EOI would end another one.
Message-signalled interrupts
A message source needs no interrupt controller. interrupt_attach_message()
allocates a line with message_ops and source, masks the source, composes
the message and adds the action.
arch_msi_compose() builds the address 0xFEE00000 OR the LAPIC id shifted
left by 12. The data byte is the vector. A PCI function writes those values
through its MSI capability, in msi_write(). The write clears
PCI_MSI_CONTROL_MME, so the function sends one message. It unmasks vector 0
when the capability supports per-vector masking. It also sets
PCI_CMD_INT_DISABLE, so the INTx pin stays quiet.
pci_attach_irq() uses MSI when the index is 0 and pci_get_cap() finds
PCI_CAP_MSI. With no such capability at index 0, the device framework falls
back to the INTx pin of the bus. msi_mask() and msi_unmask() clear and set
PCI_MSI_CONTROL_ENABLE.
Invariants and traps
- A number has one handler on a hart. A shared handler and a hart handler never take the same number, in either order.
- A per-hart number goes back with
irq_handler_remove_hart(), which asserts that the entry exists.irq_handler_remove()owns the shared entries. irq_handler_remove()holdsirq_table_lockwhile it waits forin_flight. A handler that callsirq_handler_add()can spin on that lock while a removal waits for it to return.irq_handler_add_hart()can allocate the table of the hart. Its caller must have interrupts enabled.- All handlers run with interrupts disabled. A line handler runs on the hart
of its line, and
line->lockserializes the actions of one line. - A line keeps a raw
controllerpointer.interrupt_controller_remove()asserts that no line uses the controller, so the caller detaches them first. - A message line is not in
line_head, sofind_line()never returns it.
Limits
- There is no MSI-X.
PCI_CAP_MSIXis defined, and nothing reads it. A function gets one MSI message at most. - No trap gate is installed.
idt_set_kernel_gate()takes the flag, and every caller passesfalse. - The IOAPIC RTEs use the fixed delivery mode only. There is no NMI, SMI or
ExtINTroute, and no interrupt remapping. - The PIC only reports spurious interrupts. Nothing routes an ISA IRQ through it, and its vectors never map to an IRQ number.
- A line cannot change its hart. There is no affinity request from userspace, and no way to move a line off a busy hart.
- The set of IRQ numbers is fixed by
ARCH_IRQ_BASEandARCH_IRQ_MAX. The tables do not grow, and a full table returnsUINT_MAX. irq_handler_add_hart()needs memory for the table of the hart. A hart whose first line arrives under low memory loses that line withENOMEM.
Source
| File | Holds |
|---|---|
| irq/irq.c | the two number tables, entry_of() and irq_dispatch() |
| intctl.c | the lines, the hart binding and the controller registry |
| irq.h | irq_handler_t and the two add calls |
| intctl.h | the controller and message ops, and interrupt_attach() |
| idt.c | the 256 gates, the selector and the IST index |
| idts.S | the stubs, the saved frame and the return to ring 3 |
| isr.c | amd64_isr_handler(), the faults and the signals |
| arch irq.c | the EOI, the spurious vector and the IRQ gates |
| ioapic.c | the MADT parse, the RTEs and the ISA overrides |
| pic.c | the remap of the two chips and the spurious stubs |
| lapic.c | the MMIO window, LAPIC_REG_SVR, the EOI and the IPIs |
| msi.c | the MSI address and data words |
| pci.c | the MSI capability and the INTx fallback |
| resource.c | the attach of a device IRQ by bus or by controller |
Time
Vex keeps time on three independent scales. The clock source counts the time since boot. The wall clock adds an offset to that count. The cycle counter counts only the time of one hart, for CPU time accounting. Each scale has its own path, and its section shows it.
Clock sources
counter --> clock source --> monotonic ns --> CLOCK_MONOTONIC
A clock source wraps a counter that never stops, such as the HPET or the ACPI
power management timer. Its read op returns the raw value. The registration
gives the frequency of the counter, and the kernel converts the ticks to
nanoseconds with a fixed-point multiplier and shift. A source declares the
bits of the counter that wrap, so a counter that is narrower than 64 bits keeps
the time across its rollover. The tick also calls clock_source_update(), so
a narrow counter that wraps between two reads still converts correctly.
Each source has a rating, and the highest rating is active. A source that
arrives with a higher rating takes over at once. It starts from the time that
the old source reached, so the clock never goes backwards. A source that goes
away hands the time to the best source that is left. The last active source
cannot be removed, because the kernel would have no time at all. The call
fails with EBUSY.
The clock source lock is a spinlock with interrupts disabled, so a read is
short. clock_source_peek_ns() tries the lock and reads the last value when
another caller holds it, because a panic path must not wait. delay_nsec()
and its two larger units spin on the source.
The amd64 sources are the HPET, at rating 200, and the ACPI power management timer, at rating 100. The TSC is not a clock source, as its rate can change.
Clock events
A clock event is a timer that raises an interrupt on request. Each hart has
one active timer, the best of the events that serve it. An event with
CLOCK_EVENT_PER_HART serves the hart that adds it, and every other event
serves the boot hart, because device interrupts go there.
The caller asks for a delay from now, and the timer clamps it to the range of
the device. With clock_event_oneshot() the interrupt comes once. With
clock_event_periodic() it repeats until clock_event_stop(). A timer with
only one of the two modes uses that mode for both requests.
A switch from one timer to another arms the new timer at its shortest delay
when it serves this hart. When it serves another hart, the kernel sends that
hart an IPI, and the handler arms the timer there. A removal that would leave
a hart without a timer fails with EBUSY.
The amd64 timers are the LAPIC timer and the PIT. The LAPIC timer has rating 300 and serves one hart. The PIT has rating 100 and serves the boot hart.
The tick
clock event --> tick --> scheduler, sleeps, itimers, CPU charge
There is no periodic tick. The scheduler arms a one-shot event for the timeslice of the running thread, and arms it again on every switch. That interrupt is the tick. It stops the timer and refreshes the clock source. It wakes the threads whose sleep expired, and runs the expired interval timers and the deferred events. It charges the CPU time of the thread and picks the next thread.
An interval timer of a process is a deadline on the clock source, with an optional interval. The armed timers sit in one list, sorted by deadline. The tick walks the list from the front. It sends the signal of each timer that expired, and moves an interval timer to the next point of its grid.
The CPU timers of a process count down in the tick instead. The tick compares the time that the hart charged to the thread, so such a timer fires at most one tick late.
The wall clock
monotonic ns + wall clock offset --> CLOCK_REALTIME
The wall clock is the monotonic time plus an offset. The offset is a
timespec_t under a seqlock, so a reader retries when a writer is in the
middle of a change.
wallclock_set() computes a new offset, publishes it, and writes the RTC. A
failure to write the RTC only logs a warning: the system clock has the new
time all the same. The call needs CAP_SYS_TIME.
At boot the kernel takes the date from the Limine date request. The RTC lives in the device tree, which arrives later. When the first RTC comes into use, the kernel takes its time if no date is known, or compares it with the boot date. A difference of more than 2 seconds gets a warning. If the difference is close to a whole number of 15 minutes, the warning adds that the RTC may hold local time instead of UTC.
wallclock_wait_until() sleeps until a wall clock target. A wallclock_set()
moves the target with the clock, so the wait ends on the new scale and not on
the old one.
CPU time
cycle counter --> local_clock_ns --> CPU time
local_clock_ns() reads the cycle counter of the hart when the counter has a
constant rate, and the clock source otherwise. Its value is only compared with
a value of the same hart. The scheduler uses it to separate user time from
system time, and CLOCK_THREAD_CPUTIME_ID and CLOCK_PROCESS_CPUTIME_ID
report the sum.
The amd64 probe takes the TSC frequency from CPUID, or from the hypervisor
leaf. It marks the value as inexact, so the kernel also measures the frequency
against the clock source. The measure is the median of five rounds of 20 ms,
and it keeps the CPUID value when that value is not zero. A difference of more
than 1% logs a warning. The cputime=clocksrc command line skips the TSC and
leaves CPU time on the clock source.
Interfaces
The clock IDs of the system calls reduce to two clocks: realtime and
monotonic. The raw and the coarse IDs read as their fine ID, because the
kernel steps the clock and never slews it. The CPU time IDs read the usage
counters instead. A nanosleep(2) with TIMER_ABSTIME on the realtime clock
uses wallclock_wait_until(), and a sleep on the monotonic clock uses a
deadline on the clock source.
Limits
- A global timer serves the boot hart alone. A broadcast to harts whose own timer stops in idle is not done yet.
- The RTC is read as UTC, and the kernel applies no zone.
- The wall clock cannot be set past the point where the offset and the uptime
would overflow. The conversion of a
timespec_tsaturates instead. - The cycle counter is the better CPU clock only on a CPU with a constant rate, and only after the calibration.
- The interval timers and the sleeps have the resolution of the tick, not of the clock source.
Source
- clocksrc.c — the registry, the conversion and the active source
- clockevt.c — the timers of each hart and the switch
- wallclock.c — the offset, the boot date and the RTC check
- rtc.c — the clocks that keep the time while the system is off
- cycles.c — the calibration of the cycle counter
- delay.c — the busy waits
- calendar.c — the proleptic Gregorian calendar
- hpet.c and acpi_pmt.c — the clock sources
- lapic_timer.c and pit.c — the clock events
- tsc.c — the rate and the frequency of the cycle counter
- cmos_rtc.c — the RTC driver
- sched.c — the one-shot tick
- itimer.c — the interval timers
- sys_time.c — the clock IDs and the calls
Firmware and ACPI
The firmware or the code knows which devices exist, where their registers are
and which interrupt they raise. A driver reads that knowledge through
fwnode_t, not from an ACPI namespace or from the command line.
A provider fills a firmware node. Vex has two providers: ACPI through uACPI, and fixed descriptions. The device tree consumes both in the same way.
The firmware node
fwnode_t holds an ops table and an opaque handle. The ops say what a
consumer may ask. They cover presence, ids, bus address, CPU id, name, children,
resources, INTx route, dependencies and eject.
The fwnode_t is a value with no lifetime of its own. A provider passes it to
a callback on the stack. A consumer that keeps a node copies it, as the device
tree does in device_add(). Identity is the pair of ops and handle, which
fwnode_is_same() compares.
ACPI tables / namespace code, or fixed= on the command line
| |
uACPI fwnode_fixed_t
| |
+------------> fwnode_t <----------+
|
device tree and driver match
fwnode_root() returns the node that the devices of the system hang from.
ACPI answers with its predefined System Bus node. Without that node the call
returns -ENODEV, and the tree starts with an empty root.
ACPI through uACPI
contrib/uacpi holds an unmodified copy of uACPI. The kernel implements the
uacpi_kernel_* API in two files. kernel/firmware/acpi/uacpi.c holds memory,
time, semaphores, PCI config access, interrupt install and a work queue.
kernel/arch/amd64/firmware/acpi/uacpi.c holds port I/O, the interrupt flag
and the arch spinlock.
The RSDP comes from a Limine request and lands in rsdp_address. uACPI gets
it from uacpi_kernel_get_rsdp(). init_acpi_tables() lends uACPI one page
for early table access at the mmu stage. At the bus stage, init_acpi()
loads and initializes the namespace. It sets the interrupt model through
_PIC.
acpi_node_ops answers from the uACPI namespace:
is_presentaccepts a node whose_STAreports present and enabled.matchesreads_HID, then_CID. AProcessorobject matches the idACPI0007, which older firmware such as QEMU still uses.get_cpu_idreads_MATfirst. Without_MAT, it matches the processor id against a MADT entry.get_namewritesacpi:and the four-character name segment, with the trailing underscores removed.for_each_resourcetranslates_CRSintofwnode_resource_tentries. A memory, port or bus range carries its translation and flags.route_pci_interruptreads_PRT. A route without a source uses its index as the GSI, level-triggered, active-low and shared.for_each_dependencyresolves the name paths of_DEP.ejectruns_EJ0. On success it reports_OST. A busy device getsOST_DEVICE_BUSYand no_EJ0.
The child walk visits Device and Processor objects only. It includes the
children that the firmware says are absent. This is the for_each_child
contract. fwnode_is_present() tells an absent child from a present one.
The kernel turns an ACPI interrupt into a device. uACPI asks for a GSI, and
the kernel builds a fixed node named sci or gpe_gsi%u. Its id is
ACPI_IRQ, and it carries one IRQ resource. The acpi_irq driver attaches to
the node and calls device_request_irq(). The GSI then goes through the
interrupt controller that owns it, like every other device.
ACPI 6.5, 5.2.9 defines the SCI as level-triggered, active-low and shared. A GPE block interrupt has the same shape.
_Lxx and _Exx send notifications. handle_notify() rescans the tree
around the node, or ejects the node on request. It runs as uACPI work on a
kernel thread, so it queues the tree work that device_worker runs. The work
thread runs on the boot hart because some firmware SMI handlers assume it.
Fixed descriptions
fwnode_fixed_t holds a name, an id and a resource table.
fwnode_init_fixed() installs fixed_node_ops. The node is always present.
Its id matches exactly. It has no children, no dependency and no address.
Eject does nothing.
The name becomes the path component fixed:<name>. The id keys
DRIVER_MATCH_FIRMWARE(), as _HID does for an ACPI device.
A fixed description gives a node to hardware that code knows is there. The
PIT adds one for its ports and its IRQ. An IDE channel builds one for its
command and control ranges, its bus-master range and its IRQ. An ACPI
interrupt node is a fixed description. Its fwnode_fixed_t is the first field
of acpi_irq_trampoline_t.
A fixed= command line option puts a fixed description under an existing
device. fixed=PNP0501,io:0x2e8+8,irq:3 adds the node under the root. A path
before the id selects another parent, as in fixed=acpi:PCI0/PNP0501,....
parse_option() takes the text before the first comma. The part after the
last slash is the id. The part before it is the parent path, and no slash
means the root. The remaining fields are resources: io:<start>+<len>,
mem:<start>+<len>, irq:<n>, gsi:<n> and dma:<n>.
An irq: is an ISA IRQ. It gets FWNODE_IRQ_ISA and defaults to
edge-triggered and active-high. A gsi: has no ISA flag. A range sets its
translation and flags to zero.
The kernel builds the list once, at INIT_FIRST of the bus stage. A node
appears when device_sync_firmware_children() runs on its parent. A parent
path that never matches produces one warning at INIT_LAST, not an error.
Invariants and traps
- A
fwnode_tfrom a callback lives on the stack. Copy it if it must outlive the callback. - The description behind
handlemust outlive every device that holds the node.fwnode_init_fixed()stores the pointer, not a copy. fwnode_is_same()comparesopsandhandle. Two nodes are the same device only when both match.- A child walk includes absent nodes. A consumer that skips
fwnode_is_present()sees hardware that is not there. - A failed
_STAread is not an error to the consumer. The node is simply absent. - The kernel writes the
fixed=list once and never changes it, so its walk takes no lock. - A notify handler does not change the tree. It queues work for
device_worker. - An ACPI interrupt node is never removed. uACPI never tears down.
Limits
- There is no flattened device tree provider. Without a System Bus node, only fixed descriptions add devices.
- A
fixed=id is at most 15 characters. A fixed option holds at most 8 resources. - A fixed node has no bus address. A bus that places children by address cannot place it, so the node becomes unplaced.
- A
_PRTlink with no interrupt returns-ENXIO. The kernel does not pick one from_PRSor set it through_SRS. - The kernel still reports an extended IRQ with a resource source as a system GSI. It does not route the IRQ to another interrupt controller.
- No op exposes
_DSDor a power resource. uacpi_kernel_uninstall_interrupt_handler()returnsUACPI_STATUS_UNIMPLEMENTED, because the kernel never tears uACPI down.
Source
| File | Holds |
|---|---|
| fwnode.h | the node, its ops, its resources and fwnode_is_same |
| fixed_fwnode.c | the ops of a fixed description |
| fixed_option.c | the fixed= parser, the list and the lost-path warning |
| acpi.c | the uACPI setup and the namespace load |
| acpi_fwnode.c | the ACPI ops, the notify handler and fwnode_root |
| uacpi.c | the uACPI kernel API: memory, time, PCI, interrupts and work |
| acpi_rsdp.c | the RSDP from the Limine request |
| uacpi.c (amd64) | port I/O, the interrupt flag and the arch spinlock |
| tree.c | the firmware child sync and the fixed= hook |
| uart16550_fw.c | a driver that matches firmware ids and uses the node’s resources |
IPC
Processes talk to each other through three mechanisms:
- a pipe or a FIFO, a byte stream with one read end and one write end.
- a futex, a wait on a 32-bit word that both sides map.
- a shared mapping, memory that several processes read and write.
There is no socket layer, no message queue and no semaphore.
A named shared memory object, as shm_open makes it, is not a kernel object
either. The C library uses a file on a tmpfs that userspace mounted at a fixed
path. The shared memory page gives the detail.
| Mechanism | Made of | Blocks on | Lives while |
|---|---|---|---|
| Pipes and FIFOs | a pipe_t behind a vnode | events on the pipe | the vnode |
| Futexes | a queue in one of 256 buckets | the scheduler | the waiting call |
| Shared memory | a tmpfs file with no name | no wait, faults on touch | a mapping holds it |
What they share
A blocked read or write, and a contended futex, sleep through the scheduler. The tick wakes a sleeper whose deadline passed, so a timeout lands on a tick and not earlier.
Pipes and shared mappings count their pages against the commit limit. A pipe page takes one page while the queue holds it. A shared file takes its whole size when the mapping appears. A futex takes none, as its waiter lives on the stack of its thread.
Only pipes and shared mappings carry an access check. A pipe holds the
effective user and group of the process that made it. A shared file is made
with the credentials of the kernel and reached only through a mapping that
mmap already accepted. A futex trusts the mapping of the caller.
Pipes reuse the file path of the VFS, so read, write, poll
and ioctl reach the same vnode ops as any other file. A futex and a shared
mapping have their own system call paths.
Pages
- Pipes and FIFOs — the ring of pages, the open handshake and
poll. - Futexes — the private and the shared key, the waiter and the timeout.
- Shared memory — the unnamed file, the commit charge and the lifetime.
Pipes and FIFOs
A pipe is a byte queue with one read end and one write end. pipe(2) makes
one that no name leads to. A FIFO is a vnode of a filesystem with the same
queue behind it, and any process that opens the path joins the queue.
Both use pipe_t. The queue is a ring of 16 pages, and the pipe holds no page
until the first byte arrives.
The queue
pipe_put() fills the last page and adds a page when that one is full.
pipe_get() reads the first page and gives it back as soon as its last byte is
read. An empty pipe therefore holds no page, and a reader that copies slowly
does not pin the whole queue behind one page.
Each page takes one page from the commit limit when the pipe allocates it, and
gives it back when the read drops it. If the charge fails, the write stops and
returns the bytes that it wrote so far, or ENOMEM when there are none.
A write of up to PIPE_ATOMIC_SIZE (one page) goes in all or nothing. A larger
write waits only for one byte of room, and it then writes as much as the queue
takes. The system call cuts a large write into pieces of FILE_IO_CHUNK, so a
piece never exceeds the atomic size.
Blocking and the ends
A reader waits on the readable event while the queue is empty and a writer
still exists. It returns end of file when the last writer closed. A writer
waits on writable while the queue has no room. It fails with EPIPE and
sends SIGPIPE when the last reader closed.
The open and close counts of each side drive those events. The last close of a
reader triggers writable and the last close of a writer triggers readable.
O_NONBLOCK turns both waits into EAGAIN.
poll reports POLL_READABLE when a byte waits, POLLHUP when no writer is
left, POLLERR when no reader is left, and POLL_WRITABLE only when a whole
atomic write fits. FIONREAD returns the bytes in the queue. seek always
fails with ESPIPE.
FIFOs
A FIFO node starts with no queue. The first open attaches a pipe_t to the
node, and the last close detaches it and frees the data, as POSIX requires.
An open that waits for the other end uses the partner event and two counters
of opens that ran so far. A reader that opens alone waits for a writer. A
writer that opens alone waits for a reader, and with O_NONBLOCK it fails with
ENXIO instead. A reader with O_NONBLOCK goes on at once. An open for both
directions never waits.
The counter of opens, and not only the count of open files, is the reason that the wait ends. A partner that came and closed again still satisfies it. The check runs under the queue lock, so the open that ends the wait cannot pass between the check and the sleep.
The pipe filesystem
pipefs is an internal filesystem, in the sense of
VFS: no kernel command line can mount it. Its root is a
directory, and pipe(2) adds a FIFO vnode below it with a queue attached.
Both ends of the pipe are ordinary file_t objects on that vnode, so the file
code, the file descriptor table and poll need no pipe-specific path.
The queue carries the effective user and group of the process that created the pipe. A FIFO shows the owner of its node instead.
Why it is done this way
- Pages, not a ring buffer. An empty pipe costs nothing, and a slow reader returns each page as soon as it is done with it.
- One atomic size. A writer of a small message cannot be interleaved with another writer. A larger write is a stream by design.
- The same code for a pipe and a FIFO. Only the attach and the open handshake differ.
- The open counters. A wait for a partner must end even when that partner opened and closed before the waiter ran again.
Limits
- A pipe holds at most 16 pages, which is 64 KiB.
- There is no
splice(2), no packet mode and no way to read one message at a time. - A call can block on a full queue while another thread of its process closes
the reader. The writer then wakes with
EPIPE, as POSIX has it.
Source
- pipe.c — the queue, the events, the open handshake and
pipefs - pipe.h —
pipe_create - sys_file.c —
pipe(2) - poll.c — the
pollandppollcalls
Futexes
A futex is a wait on a 32-bit word that several threads or processes map. The kernel holds no state while nobody waits, so the fast path of a lock stays in user space. Only contention enters the kernel.
SYS_FUTEX_WAIT takes the address, the value that the caller expects, an
optional relative timeout and the flags. SYS_FUTEX_WAKE takes the address,
the number of waiters to wake and the flags. The only flag is FUTEX_SHARED.
The key
The kernel identifies a futex by a key of two numbers: a vmspace_t*, which
is null for a shared futex, and an address.
- A private futex keys on the
vmspace_tof the caller and the virtual address. Two threads of one process reach the same key through the same mapping, and another process never does. - A shared futex keys on the physical address of the page. Two processes that map the same page reach the same key even when the page is at a different virtual address in each process.
The wait pins the page with vms_pin() and reads the word through the direct
map. A private wait unpins the page before it sleeps. A shared wait keeps the
pin until it is done, because the physical address is part of the key and the
frame must not move.
The keys spread over 256 buckets, with the top 8 bits of a multiplication by the golden ratio as the index. Each bucket holds one lock and one list.
Wait
The wait runs these steps under the lock of the bucket:
- It reads the word. A value other than the expected one fails with
EAGAIN. - A deadline that already passed fails with
ETIMEDOUT. - It puts a
futex_waiter_ton the list. The waiter lives on the stack of its thread and is linked in arrival order. - It removes the thread from the scheduler, asks for a wake at the deadline, and yields.
The waker stores the new word before it calls the wake, and the wake takes the bucket lock. A store either happens before the read of step 1, or it happens after the waiter is linked, where the waker finds it.
On the way back the thread takes the bucket lock again. A waiter that is still on the list timed out or lost a wake race. It unlinks itself.
The call then reports these results, in this order. It reports 0 when the
waker took the waiter, EINTR when work is pending, ETIMEDOUT when the
deadline passed, and 0 for a spurious wake. The caller looks at the word
again in every case.
Wake
The wake takes the bucket of the key and walks the list from the head. The
waiters of one futex leave in the order that they arrived. It re-enqueues up to
count threads whose key matches, and it returns the number. A shared wake
pins the page first to learn the physical address, then unpins it.
Why it is done this way
- The waiter lives on the stack. A contended wait allocates nothing, and the bucket lock is the only thing that keeps the waiter valid.
- The word is checked once, under the lock. A store that happens before the check is seen by it. A store that happens after finds the waiter on the list.
- The shared key is physical. Two processes rarely map a shared page at the same virtual address.
- The timeout is a scheduler deadline. The tick already wakes threads whose deadline passed, so no separate timer queue exists.
Limits
- There is no priority inheritance, no
FUTEX_ROBUSTsupport and no owner-death handling. - Only
FUTEX_WAITandFUTEX_WAKEexist. There is no requeue, no bitset wait and noFUTEX_WAITV. - A large
countwakes every waiter on the key. There is no separate broadcast flag.
Source
- futex.c — the buckets, the waiter and the two operations
- futex.h — the interface
- sys_futex.c — the two system calls
- sched.h —
sched_wake_at()andsched_wake_cancel()
Shared memory
A shared anonymous mapping, MAP_SHARED | MAP_ANONYMOUS, is a mapping of a
file that no name leads to. The kernel makes the file in an internal
tmpfs, unlinks it at once, and maps it as a
shared file. Both processes of a fork then map the same file and see the same
pages.
POSIX shared memory is not a kernel object. Userspace mounts a tmpfs at a
fixed path, such as /run/shmem, and shm_open creates a file there. The C
library hardcodes that path. The mount needs CAP_MOUNT, so the init process
or a service manager makes it. The kernel serves the file with the same tmpfs
and mmap code as any other file.
The file
shmem_file_create() makes the file. It takes a name from a counter, creates
a vnode in the internal tmpfs, and unlinks the name before it returns. Nothing
else can reach the file, so the name only serves the moment of creation, and
the number in it is never reused.
The call then sets the size of the file, and the tmpfs charges the whole size to the commit limit. A file of 4 MiB takes 1024 pages of 4 KiB at this point. The pages themselves arrive later, on the faults of the mapping. The tmpfs of this file sets no page limit of its own, so the commit limit is the whole bound.
The mapping
map_shared_anonymous() maps the file with backing.shared set. The mapping
takes its own reference to the file, and the system call closes the file at
once. The mapping is the only thing that keeps the file and its pages alive,
and the last unmap takes the last reference and frees them.
The shared flag also changes how fork treats the mapping. A private anonymous mapping is copied on write, so the child starts with its own pages. A shared mapping of a file stays shared: both processes fault on the same vnode and get the same page.
MAP_NORESERVE has no effect here. The whole size is taken from the commit
limit when the mapping appears, and the reserve of
CAP_VM_NORESERVE changes nothing
for this kind of mapping.
Why it is done this way
- A file, not a new object. The VFS already shares a file-backed region across fork, handles the faults and frees the pages on the last unmap. A second memory object with its own lifetime rules would repeat that work.
- The anonymous mapping has no name. It exists for a fork and nothing else. Named shared memory is a file on a mounted tmpfs. The path, the permissions and the removal rule belong to the filesystem, and not to a new kernel object.
- The size is charged at once. A shared mapping must not fail on a fault
after another process already mapped it, so the whole range is reserved at
mmap. - The file uses the kernel credentials. It is made for the kernel and reached only through the mapping, so no path check applies to it.
Limits
- The kernel has no
shm_open(3). A C library provides it on a tmpfs that userspace mounted, at a path that both agree on. - A shared anonymous mapping has no name, so only the children of a fork reach it.
- There is no System V shared memory, no
memfd_create(2)and no sealing. msynchas nothing to flush for a tmpfs file.
Source
- shmem.c — the unnamed file and the tmpfs
- shmem.h —
shmem_file_create - sys_mman.c — the shared anonymous mapping
- vms.c — the reference that a region takes
- tmpfs.c — the charge of the pages
Security
Vex has no security module and no policy engine. A privileged decision reads the credentials of the calling thread and compares them with the object. The credentials say who the thread is, and which capabilities it holds. The kernel also contains a few cryptographic primitives for its own use.
Capabilities are one field of the credentials. Their bits and sets have their own page: Capabilities.
The credential object
credentials_t holds a reference count. It holds the real, effective and
saved user and group ids, and four capability masks with a flag word. It also
holds a sorted array of supplementary group ids. The array is part of the
allocation, so the structure has a size that depends on the number of groups.
The object never changes after a process publishes it. A call that changes an
id or a capability takes the credentials_lock of the process, copies the
object, changes the copy, and swaps it in with process_set_cred(). The old
object loses the reference that the process held, and it dies when its last
reference goes. A small copy comes from the slab, and a copy with many groups
from kmalloc.
kernel_credentials is a static object with every capability and no group. It
never dies. The kernel uses it for its own file operations, and a kernel
thread starts with it.
Which copy a thread uses
A thread caches a reference in thread->credentials. Another thread of the
same process can replace the credentials between two system calls, so the
kernel checks the cache at every system call entry. catch_up_cred() compares
the cached object with process->credentials, takes the new one when they
differ, and drops the old reference.
process_get_cred() returns a new reference. A caller that keeps the pointer
across a call that can block holds that reference, so a concurrent change
cannot free the object under it.
A fork gives the child a reference to the credentials of the parent. An exec builds a copy, applies the setuid and setgid bits and the file capabilities, and swaps it in.
Ids
Each of the user and group ids has three values: real, effective and saved. A
process may set one of them to its current value or to one of the other two.
CAP_SETUID or CAP_SETGID removes that limit. The saved id keeps a program
able to return to the id that started it.
A change of a user id also moves the capability sets. The rules are in Capabilities.
The supplementary groups are sorted in ascending order, and cred_in_group()
finds one with a binary search. CONFIG_NGROUPS_MAX sets the limit, and
setgroups(2) needs CAP_SETGID.
Access checks
vnode_access() makes the decision for a file:
CAP_DAC_OVERRIDEgrants the request. An execute request still fails when the file has no execute bit for any user and is not a directory.- The owner bits apply when the effective user id is the owner.
- The group bits apply when the effective group id or a supplementary group matches.
- The other bits apply in every other case.
The path walk and the file operations use the effective ids. access(2) is
the exception: it checks the real ids, unless AT_EACCESS is set.
cred_copy_real() makes that copy. It also makes the effective set equal to
the permitted set when the real user id is 0, and empty otherwise.
chmod, chown and the timestamp calls compare the effective user id with
the owner, and a capability lifts each comparison. The bits table of
Capabilities names the capability
for each operation.
Program start
exec copies the credentials. It applies the setuid bit to the effective user
id. It applies the setgid bit to the effective group id when the group execute
bit is set. The saved ids follow the effective ids. cred_exec_caps() then
works out the capability sets. The kernel reports AT_SECURE to the new
program when an id differs from the real id. It also reports it when the real
id is not 0 and the permitted set has a bit that is not ambient.
Cryptography
There is no general cryptographic interface. Three primitives exist, and each one serves one subsystem:
- BLAKE2s hashes the entropy pool and mixes a seed into the key.
- ChaCha20 generates the random bytes from that key. Both primitives live in Random numbers.
- CRC32 is a checksum, not a security primitive. The GPT parser checks the header and the entry array with it, and the device-name code hashes a prefix with it.
The CRC32 code builds a table for a polynomial and an initial value, so a second user can pick its own.
Traps
- The cached credentials of a thread can be one change behind. Only the system call entry brings them up to date.
- A
credentials_t*that crosses a blocking call needs its own reference. cred_capable()reads the effective set only.- Never copy
credentials_twithsizeof(credentials_t). The group array is outside it. Usecred_copy(). - A kernel thread has no process. A system call from one returns
ENOSYS.
Source
- cred.h — the object and its interface
- cred.c — the copy, the reference count, the id rules and the exec transition
- sys_cred.c — the id system calls
- syscall.c — the refresh of the cached credentials
- process.c — the swap of the published object
- vnode.c — the access check
- sys_path.c —
access(2)and the real ids - blake2s.c, chacha20.c and crc32.c — the primitives
Random numbers
Vex supplies random bytes to getrandom(2), /dev/random and /dev/urandom. One generator serves all three. The design has three layers: sources, a pool and a generator.
The three layers
sources pool output
+------------+ +------------+ +------------+
| rdseed | | BLAKE2s | seed | ChaCha20 |
| rdrand |-->| hash state |----->| fast key |--> getrandom(2)
| jitter | | + credit | | erasure |--> /dev/random
| limine | +------------+ +------------+--> /dev/urandom
| irq events |
+------------+
The sources collect noise. The pool mixes the samples and counts the entropy that the kernel trusts. The generator turns the pool into output.
Sources
A source declares itself with define_random_source_polled or define_random_source_event. The declaration lands in the .random_sources linker section. The kernel walks that section at boot.
| Source | Kind | Trusted | Sample | Credit |
|---|---|---|---|---|
rdseed | polled | yes | 32 bytes | 256 bits |
rdrand | polled | no | 32 bytes | 256 bits, not credited |
jitter | polled | yes | 64 bytes | 8 bits |
limine | polled | yes | 64 bytes | 512 bits, capped at 256 |
irq | event | yes | 32 values per buffer | 1 bit per 64 events |
A polled source has a probe function and a poll function. The probe tells whether the hardware exists. The poll fills a buffer of at most 64 bytes. A short poll earns a proportional share of the credit.
An event source has no poll function. Its code pushes an event into a buffer for the current hart. The IRQ source mixes the interrupt number into that buffer with a timestamp.
The trusted flag controls only the credit. Every sample enters the pool. An untrusted source can add bytes to the pool. It cannot make the generator ready.
The random.trust_<name> kernel option changes the default. The value 0 removes the credit. Any other value grants it.
The kernel polls a polled source at most once in 100 ms. A caller can poll the sources before it reads bytes. A worker thread also polls them when the pool needs noise. random_source_poll_all() takes a trylock, so one poller runs at a time.
The kernel stops asking a source that returns -ENODEV. It also stops after five failures in a row.
Why the trust differs
rdseed is the architectural entropy instruction. The kernel counts its output as entropy.
rdrand returns the output of a random generator in the CPU. The kernel mixes the bytes and counts no entropy. A compromised CPU must not seed the kernel alone.
The kernel trusts limine as a whole. Limine collects entropy from RDSEED, RDRAND and the UEFI random generator. It falls back to the PCG32 generator. The reply does not say which source made a value. The kernel cannot separate the sources. It trusts the bootloader, unless the command line changes the trust.
jitter measures time deltas around a small workload. The timing of a virtual machine can be regular. The source claims one bit for eight measurements. A poll with more than 32 repeated deltas returns -EAGAIN.
irq events are weak because software can observe interrupt timing. One event earns 1/64 of a bit. The stream is continuous, so the events still contribute.
Credit accounting
The pool counts trusted bits in credit_bits. The count stops at 256 bits. The generator needs 256 bits to take its first seed. A reseed sets the count back to 0.
This count is a policy, not a measurement. The kernel cannot test a sample. The estimate comes from the author of the source. The trust flag says whether the kernel accepts the estimate.
Event credit accumulates in fractions of a bit. The worker keeps the remainder for the next drain.
The pool and reseeds
The pool is a BLAKE2s-256 hash state, not a byte buffer. random_add() feeds the sample into the state. It also adds the credit.
A reseed does four steps:
- It finishes a copy of the pool. The result is a 32-byte seed.
- It starts the pool again and feeds the seed back into it. The next seed depends on this one.
- It mixes the seed into the key with a keyed BLAKE2s. The old key is an input, so a weak seed cannot reduce the strength of the key.
- It sets the credit to 0. On the first reseed it also triggers the ready event.
Two conditions start a reseed. The credit must be at least 256 bits, and 100 ms must have passed since the last reseed. The first reseed ignores the interval.
The code checks the conditions in two places. random_add() checks them while the generator has no seed. The output path checks them for each 256-byte chunk. There is no periodic reseed timer.
Between reseeds, the generator produces bytes from its key.
Output bytes
fill() produces output in 256-byte chunks. For each chunk it calls generate():
- It starts ChaCha20 with the current key, a zero nonce and counter 0.
- It XORs the keystream into the output buffer.
- It takes the next 64-byte block as the new key.
- It zeroes the block and the stream state.
Each chunk uses a fresh key, so the nonce stays zero. The erasure means a later compromise of the key does not reveal earlier output.
Before the first seed, generate_unseeded() derives a temporary key from the pool. It feeds that key back into the pool, so the next call differs. The bytes do not repeat, but an attacker can guess them. GRND_INSECURE uses this path.
Waiting for the first seed
random_wait_ready() polls the sources and checks the ready flag. A blocking caller waits on the ready event with a 100 ms timeout. The timeout matters because no other thread polls the sources while the caller sleeps. The caller wakes, polls again and repeats.
getrandom(2) has two flags:
GRND_NONBLOCKfails withEAGAINwhen the generator has no seed.GRND_INSECUREskips the wait and accepts weak bytes.
Without GRND_INSECURE, the call waits for the first seed. Then it copies the bytes out in 256-byte chunks. A pending signal or stop ends a long call early. The call returns the bytes that it produced.
/dev/random and /dev/urandom use the same generator and the same rules. Both wait for the first seed. O_NONBLOCK turns that wait into EAGAIN. A write to either device stirs the pool and earns no credit. poll() reports readable only after the first seed.
Boot sequence
| Stage | Action |
|---|---|
| early | The kernel initializes the ready event and the worker event. |
| bootinfo, late | The kernel moves the Limine entropy out of bootloader memory and zeroes the original. |
| bootinfo, last | The kernel probes and polls the sources once. The generator usually takes its first seed here. |
| services | The kernel creates the per-hart event buffers and starts the worker thread. |
| dev | The kernel registers /dev/random and /dev/urandom. |
The IRQ hook drops events until the worker publishes its buffers. Event noise therefore starts after the services stage.
Why it is done this way
- The pool is a hash, not a buffer. The sources produce different sizes and different claims. A hash accepts any size and keeps no copy of the samples.
- The key chains. The new key depends on the old key and the new seed. A weak reseed cannot lower the entropy that the key already has.
- The key changes for each chunk. This gives forward secrecy. It also holds the pool lock for less time.
- The credit is explicit. An untrusted source cannot seed the kernel alone. The rule is visible in the code and in the command line.
- Both devices are the same. This follows the FreeBSD model. Vex does not copy the Linux behavior where
/dev/randomblocks after the first seed. - The reseed is lazy. A timer would add a thread and a wakeup for no gain. The output path already knows when it needs a new key.
Limits
- The kernel cannot prove an entropy claim. A trusted source that lies can seed the generator with weak data.
- Limine can use the PCG32 fallback. The kernel cannot detect this case.
- The jitter source measures timing. Timing is regular on some virtual machines.
- The tests check basic properties: length, distinct calls, bit balance and error paths.
- The secure
random_bytes()has no caller in the tree. The syscall and the devices wait for the seed, then they use the insecure path.
Source
- random.c — pool, reseed, output and wait
- random_source.c — polling, event buffers and the worker
- random_source.h — the source structure and the registration macros
- random_dev.c —
/dev/randomand/dev/urandom - sys_random.c —
getrandom(2) - limine.c — the bootloader entropy source
- jitter.c — the timing source
- rdseed.c and rdrand.c — the CPU instructions
- irq.c — the interrupt source
- test.random.c — the tests
Drivers
TODO: write this page.
Covers: how a driver is written and which drivers Vex has.
Storage
TODO: write this page.
Covers: AHCI, PATA and the helpers they share.
AHCI
TODO: write this page.
Covers: port bring-up, the command list, PRDT and completion.
PATA
TODO: write this page.
Covers: PIO and DMA transfers on the legacy ATA interface.
Display
TODO: write this page.
Covers: framebuffers, the Bochs VBE adapter and fbcons.
Limine framebuffer
TODO: write this page.
Covers: the framebuffer that Limine hands over.
Bochs VBE
TODO: write this page.
Covers: the QEMU std-vga display adapter.
Framebuffer console
TODO: write this page.
Covers: fonts, glyph drawing and the console backend.
Input
TODO: write this page.
Covers: input drivers.
PS/2 keyboard
TODO: write this page.
Covers: the i8042 controller and scan code translation.
Serial
TODO: write this page.
Covers: UART drivers and the serial interface.
UART 16550
TODO: write this page.
Covers: the core port, PCI discovery and firmware discovery.
Filesystems
TODO: write this page.
Covers: the backends behind the VFS.
ext2
TODO: write this page.
Covers: the ext2 layout and what the driver supports.
FAT
TODO: write this page.
Covers: FAT12, FAT16, FAT32, long names and the FAT tests.
echfs
TODO: write this page.
Covers: the echfs layout and the driver.
devfs
devfs is the filesystem of device nodes. It has one directory, its root, and one node for each block or char interface that the kernel publishes. A node appears when the interface appears, and it goes when the interface goes.
Discovery
devfs does not search for hardware. It subscribes to the two event buses of the device model, one for block interfaces and one for char interfaces. Each subscription replays every interface that already exists. A devfs that is mounted after the disks are up therefore shows them at once.
A block node appears on BLOCK_EVENT_MEDIA_CONNECTED or
BLOCK_EVENT_PARTITION_ADDED, and it goes on BLOCK_EVENT_MEDIA_DISCONNECTED
or BLOCK_EVENT_PARTITION_REMOVED. An interface with BLOCK_FLAG_HIDDEN gets
no node. A char node appears on CHAR_EVENT_INTERFACE_ADDED and goes on
CHAR_EVENT_INTERFACE_REMOVED. CHAR_EVENT_MEMORY_CHANGED revokes the
mappings of the node and keeps it.
The name of the node is the name of the interface, in the form that
Device names describes, such as ada0n1p1,
fbd0 or serial1. The kernel services that show their own state, such as
/dev/kplog and /dev/hwtree, register a char interface, so devfs makes their
nodes like any other.
Attach
A mount allocates the state of the filesystem, which is one lock, one list of
nodes and one count, and makes the root directory. The root has mode 0555,
and the mount reads neither a block volume nor an option string.
The mount then subscribes to the two buses. When a subscription fails, the mount removes what it made so far and fails. An unmount unsubscribes, removes every node from the cache of vnodes, and drops the reference of each node and of the root.
The kernel does not mount devfs. An init process mounts it on /dev, and the
test image mounts it there before it runs the first test.
Data path
A lookup walks the list of nodes and compares the names. The inode number of a node is its vnode pointer, so it is unique while the node lives and costs no allocation. An iteration gives the dots first, then the nodes in the order of the list. The list is unsorted, and each insert puts the new node at the head.
A char node keeps a reference to its interface. Every operation of the vnode
forwards to the char interface. Open and close make the per-file context.
read, write, seek, ioctl, poll and mmap call the matching op.
stat reports S_IFCHR with mode 0660 and the rdev of the interface.
A block node keeps the interface in vnode->volume and gives the device as a
file of bytes. A read or a write maps the byte offset to a sector and a byte
inside it. A run that starts on a sector and covers whole sectors goes to
block_read() or block_write() in one call. A partial first or last sector
goes through one bounce buffer of the node, which a mutex protects. A write
reaches the device at once, with no page cache to hold it. stat reports the
size as the sector count times the sector size.
A removal takes the node out of the list and out of the cache, and it drops the reference of the filesystem. An open file or a mapping still holds a reference, so the vnode and its context live until the last one goes.
Why it is done this way
- The bus replays. The mount does not need a second path to find the devices that already exist. A device that arrives later uses the same handler as one that was there first.
- The interface owns the work. devfs forwards every operation, so a driver has one implementation, in the char or block interface, and not a second one in the filesystem.
- The vnode pointer is the inode number. A devfs node has no number of its own to allocate or to free.
- A separate handler per interface kind. The two paths share the list and the cache, and nothing else, so a char device does not carry block fields.
Limits
- devfs has one directory. There is no
/dev/disk/by-id, no per-bus directory and no symbolic link. - A lookup and an iteration walk the list. The position of an iteration is an
index in that list. A node that is added or removed between two
getdentscalls can then move what the reader sees next. - The permissions are fixed:
0555for the root,0660for a char or block node. There is no request that changes them, and no create or mknod op, so userspace cannot add a node. - A block node offers
read,writeandstat. It has noioctl, nopoll, nommapand noseek. - A name is unique only because the interfaces keep their names unique. A second insert with the same name adds a second node, and a lookup finds the newest one.
Source
- devfs.c — the mount, the list and the insert
- devfs_dir.c — the root directory
- devfs_char.c — the char nodes and the event handler
- devfs_block.c — the block nodes and the bounce buffer
- devfs_internal.h — the node and the state
tmpfs
tmpfs is the in-memory filesystem. It has no volume and no copy on a disk: the pages of a file are the only copy. Userspace mounts it for a directory that must not survive a reboot, and the kernel makes instances of it that no path leads to.
Discovery
A filesystem has no discovery step. init_tmpfs adds the tmpfs driver to
the registry of the VFS at the vfs stage, and a mount finds it by that name.
The driver is not marked internal, so userspace can mount it.
vfs_create() makes an instance with no device and no mount point, and
nothing can unmount it. Shared memory uses one
such instance for the file behind a shared anonymous mapping.
Attach
The mount takes no block volume, and a mount that names one fails with
EINVAL. The options come from the mount parameters:
| Option | Meaning |
|---|---|
size | The most pages of file contents, with an IEC suffix such as 64m |
nodes | The most inodes |
mode | The permission bits of the root directory, in octal |
uid | The owner of the root directory |
gid | The group of the root directory |
The root directory defaults to mode 01777 and to the owner and group 0. Any
process may create a file in it, and only the owner may remove one. Mounted
with no option, the filesystem has no limit of its own and the
commit limit is the only bound.
A mount holds one mutex, one list of inodes, the two limits and two counters.
Data path
Every node has a vnode and a tmpfs_inode_t. The inode holds the permission
bits, the owner, the link count and three timestamps. It also holds the size,
the target of a symbolic link and the entries of a directory. The type lives
in the vnode, and the inode number comes from a counter of the mount.
The data pages come from the file cache with fcache_set_memory(). That mode
makes the pages of the vnode the only copy. They are never dirty, and the
cache needs no bmap and no volume. A read of a hole gives zeroes and takes
no page, and reclaim never takes a page. Nothing writes the pages back,
because there is nowhere to write them.
A write holds the mutex of the mount. When it extends the file, it first
charges the pages of the new size to the limit of the mount and to the commit
limit. If the charge fails, the write fails whole. A short write then shrinks
the file back to the bytes that it kept. truncate and fallocate use the
same resize, and a shorter file gives its pages back.
A page fault runs fcache_fault(), which puts the page of the file into the
page map of the process. The pages were paid for when the size grew, so a
fault does not charge them again.
A directory is a list of entries, in the order that they were created. Each
entry carries a sequence number that grows and never repeats, and getdents
reports that number as the position. A reader that removes an entry in the
middle does not move the position of the entries after it. A lookup walks the
list and compares the names.
A symbolic link keeps its target in the inode. A target of at most
SLAB_MAX_SIZE bytes sits in a slab object, and a longer one takes a page.
A FIFO, a character device and a block device have an inode and no data.
An entry holds one reference to its vnode for as long as the name exists. An
unlink drops the name and the reference, and it removes the node from the
cache of the vnode when the link count reaches 0. The last reference, which an
open file or a mapping can still hold, frees the pages, the target and the
inode.
Why it is done this way
- The pages are the only copy. A tmpfs has no disk to read from and no
writeback to schedule. The memory mode of the file cache removes the
bmap, the dirty list and the volume that other filesystems need. - The whole size is charged. A hole costs a page in the commit limit, because the file can be written there at any time. This keeps a write from failing after it has already copied part of the data.
- One mutex for the mount. The filesystem is small and its operations are short. A lock per inode would add nothing but state.
- Sequence numbers in the directories. POSIX lets another thread remove an
entry between two
getdentscalls, and the position of the reader must still name the next entry. - No discovery. A tmpfs instance exists only because somebody mounted it, or because the kernel created one.
Limits
- Every operation takes the one mutex, so two writes to different files also wait for each other.
- A directory lookup walks a list. A directory with many entries is slow.
- There are no extended attributes and no per-user quota. The
sizeandnodesoptions are the whole limit. - A page is never reclaimed or moved out. The file holds it until a truncate or an unlink.
- A sparse file still counts its holes in the commit limit.
- Nothing survives a reboot.
Source
- tmpfs.c — the mount, the options and the limits
- tmpfs_vnode.c — the vnode operations
- tmpfs_internal.h — the mount and the inode
- fcache.h — the memory mode of the file cache
- vnode.h — the operations that the vnode offers
Buses
TODO: write this page.
Covers: bus ops and the buses that Vex enumerates.
PCI
TODO: write this page.
Covers: configuration space, BARs, MSI and the PCI bus.
ISA
TODO: write this page.
Covers: the ISA bus and fixed resources.
IDE
TODO: write this page.
Covers: the legacy IDE controller and its two channels.
Syscalls
This page lists every system call: its number, the arguments that the call reads, and the kernel function that runs it. The mechanism is in System calls.
The numbers come from kernel/include/usr/vex/syscall.h. Each subsystem owns a
block of 32 numbers that starts at a multiple of 0x20. The table gives the
calls of each block in number order and leaves out the numbers that no call
uses. A call with such a number fails with ENOSYS. A number in the header
without a registered handler fails with ENOSYS too.
Conventions
- The arguments are in the order of the registers, one for each register. A call reads at most six.
user_addr_tis a pointer into the address space of the caller. The handler copies through it. It is never a kernel pointer.- A descriptor is an
int. When the_fdmacro registered the handler, the handler gets the resolved file instead, and the table shows the descriptor that the caller passes. - A call with a
dirfdalso takes the path as a user pointer. The kernel copies the path before it uses it. - The return value is an
intptr_t. A negative value is an errno number that is negated, and every other value is a result. - The link on a name goes to the handler.
Processes and scheduling
| Number | Call | Arguments |
|---|---|---|
0x020 | SYS_EXIT | int code |
0x021 | SYS_FORK | none |
0x022 | SYS_EXEC | user_addr_t path, user_addr_t argv, user_addr_t envp |
0x023 | SYS_WAITPID | pid_t pid, user_addr_t wstatus, uintptr_t options, user_addr_t usage |
0x024 | SYS_GETPID | none |
0x025 | SYS_GETPPID | none |
0x026 | SYS_SETSID | none |
0x027 | SYS_SETPGID | pid_t pid, pid_t pgid |
0x028 | SYS_GETPGID | pid_t pid |
0x029 | SYS_GETSID | pid_t pid |
0x02B | SYS_GETPRIORITY | int which, pid_t who |
0x02C | SYS_SETPRIORITY | int which, pid_t who, intptr_t value |
0x02D | SYS_GETSCHEDULER | pid_t pid |
0x02E | SYS_SETSCHEDULER | pid_t pid, uintptr_t policy, user_addr_t param |
0x02F | SYS_GETSCHEDPARAM | pid_t pid, user_addr_t param |
0x030 | SYS_SETSCHEDPARAM | pid_t pid, user_addr_t param |
0x031 | SYS_GETSCHEDRANGE | uintptr_t policy, user_addr_t range |
0x032 | SYS_GETSCHEDINTERVAL | pid_t pid, user_addr_t interval |
0x033 | SYS_GETAFFINITY | pid_t pid, pid_t tid, size_t size, user_addr_t mask |
0x034 | SYS_SETAFFINITY | pid_t pid, pid_t tid, size_t size, user_addr_t mask |
0x035 | SYS_WAITID | unsigned idtype, pid_t id, user_addr_t info, uintptr_t options, user_addr_t usage |
0x036 | SYS_GETCPU | none |
0x037 | SYS_SPAWN | user_addr_t address |
Threads
| Number | Call | Arguments |
|---|---|---|
0x040 | SYS_THREAD_CREATE | user_addr_t entry, user_addr_t stack_top, user_addr_t tcb, uintptr_t argument, user_addr_t clear_tid |
0x041 | SYS_THREAD_EXIT | none |
0x042 | SYS_GETTID | none |
0x043 | SYS_TCB_SET | user_addr_t tcb |
0x044 | SYS_YIELD | none |
0x045 | SYS_FUTEX_WAIT | user_addr_t address, uint32_t value, user_addr_t timeout, uintptr_t flags |
0x046 | SYS_FUTEX_WAKE | user_addr_t address, size_t count, uintptr_t flags |
Signals
| Number | Call | Arguments |
|---|---|---|
0x060 | SYS_KILL | pid_t pid, int sig |
0x061 | SYS_TGKILL | pid_t pid, pid_t tid, int sig |
0x062 | SYS_SIGACTION | int sig, user_addr_t act, user_addr_t oact |
0x063 | SYS_SIGPROCMASK | int how, user_addr_t set, user_addr_t oset |
0x064 | SYS_SIGPENDING | user_addr_t set |
0x065 | SYS_SIGRETURN | none |
0x066 | SYS_SIGALTSTACK | user_addr_t ss, user_addr_t old |
0x067 | SYS_SIGSUSPEND | user_addr_t set |
0x068 | SYS_SIGTIMEDWAIT | user_addr_t set, user_addr_t info, user_addr_t timeout |
0x069 | SYS_SIGQUEUE | pid_t pid, int sig, uintptr_t value |
0x06A | SYS_SETITIMER | int which, user_addr_t value, user_addr_t old |
Credentials
| Number | Call | Arguments |
|---|---|---|
0x080 | SYS_GETRESUID | user_addr_t ruid, user_addr_t euid, user_addr_t suid |
0x081 | SYS_SETRESUID | uid_t ruid, uid_t euid, uid_t suid |
0x082 | SYS_GETRESGID | user_addr_t rgid, user_addr_t egid, user_addr_t sgid |
0x083 | SYS_SETRESGID | gid_t rgid, gid_t egid, gid_t sgid |
0x084 | SYS_GETGROUPS | size_t size, user_addr_t list |
0x085 | SYS_SETGROUPS | size_t size, user_addr_t list |
0x086 | SYS_GETUMASK | none |
0x087 | SYS_SETUMASK | mode_t mask |
0x088 | SYS_CAPGET | user_addr_t caps |
0x089 | SYS_CAPSET | user_addr_t caps |
Memory
| Number | Call | Arguments |
|---|---|---|
0x0A0 | SYS_MMAP | user_addr_t addr, uintptr_t length, uintptr_t prot, uintptr_t flags, int fd, uintptr_t offset |
0x0A1 | SYS_MUNMAP | user_addr_t addr, uintptr_t length |
0x0A2 | SYS_MPROTECT | user_addr_t addr, uintptr_t length, uintptr_t prot |
0x0A3 | SYS_MSYNC | user_addr_t addr, uintptr_t length, uintptr_t flags |
0x0A4 | SYS_MADVISE | user_addr_t addr, uintptr_t length, uintptr_t advice |
0x0A5 | SYS_MLOCK | user_addr_t addr, uintptr_t length |
0x0A6 | SYS_MUNLOCK | user_addr_t addr, uintptr_t length |
0x0A7 | SYS_MLOCKALL | uintptr_t flags |
0x0A8 | SYS_MUNLOCKALL | none |
0x0A9 | SYS_MREMAP | user_addr_t addr, uintptr_t old_size, uintptr_t new_size, uintptr_t flags |
Files
| Number | Call | Arguments |
|---|---|---|
0x0C0 | SYS_OPENAT | int dirfd, const char* path, uintptr_t flags, mode_t mode |
0x0C1 | SYS_CLOSE | int fd |
0x0C2 | SYS_READ | int fd, user_addr_t buffer, size_t count |
0x0C3 | SYS_WRITE | int fd, user_addr_t buffer, size_t count |
0x0C4 | SYS_SEEK | int fd, off_t offset, int origin |
0x0C5 | SYS_IOCTL | int fd, uint32_t request, uintptr_t argument |
0x0C6 | SYS_DUP | int old_fd, int new_fd, uintptr_t flags |
0x0C9 | SYS_FSTAT | int fd, user_addr_t stat |
0x0CA | SYS_FTRUNCATE | int fd, off_t length |
0x0CB | SYS_FSYNC | int fd |
0x0CC | SYS_FCNTL | int fd, unsigned command, uintptr_t argument |
0x0CD | SYS_PREAD | int fd, user_addr_t buffer, size_t count, off_t offset |
0x0CE | SYS_PWRITE | int fd, user_addr_t buffer, size_t count, off_t offset |
0x0CF | SYS_READV | int fd, user_addr_t iov, size_t count |
0x0D0 | SYS_WRITEV | int fd, user_addr_t iov, size_t count |
0x0D1 | SYS_PREADV | int fd, user_addr_t iov, size_t count, off_t offset |
0x0D2 | SYS_PWRITEV | int fd, user_addr_t iov, size_t count, off_t offset |
0x0D3 | SYS_PIPE | user_addr_t fds, uintptr_t flags |
0x0D4 | SYS_PPOLL | user_addr_t fds, size_t count, user_addr_t timeout, user_addr_t mask |
0x0D5 | SYS_FLOCK | int fd, uintptr_t operation |
0x0D6 | SYS_FALLOCATE | int fd, uintptr_t mode, off_t offset, off_t length |
Paths
| Number | Call | Arguments |
|---|---|---|
0x0E0 | SYS_FSTATAT | int dirfd, const char* path, user_addr_t stat, unsigned flags |
0x0E1 | SYS_FCHMODAT | int dirfd, const char* path, mode_t mode, unsigned flags |
0x0E2 | SYS_FCHOWNAT | int dirfd, const char* path, uid_t uid, gid_t gid, unsigned flags |
0x0E3 | SYS_UTIMENSAT | int dirfd, const char* path, user_addr_t times, unsigned flags |
0x0E4 | SYS_MKDIRAT | int dirfd, const char* path, mode_t mode |
0x0E5 | SYS_UNLINKAT | int dirfd, const char* path, unsigned flags |
0x0E6 | SYS_RENAMEAT | int old_dirfd, user_addr_t old_path, int new_dirfd, user_addr_t new_path |
0x0E7 | SYS_LINKAT | int old_dirfd, user_addr_t old_path, int new_dirfd, user_addr_t new_path, unsigned flags |
0x0E8 | SYS_SYMLINKAT | user_addr_t target, int dirfd, user_addr_t path |
0x0E9 | SYS_READLINKAT | int dirfd, const char* path, user_addr_t buffer, size_t size |
0x0EA | SYS_GETDENTS | int fd, user_addr_t buffer, size_t size |
0x0EB | SYS_GETCWD | user_addr_t buffer, size_t size |
0x0EC | SYS_CHDIR | user_addr_t path |
0x0ED | SYS_FCHDIR | int fd |
0x0EE | SYS_FACCESSAT | int dirfd, const char* path, unsigned mode, unsigned flags |
0x0EF | SYS_CHROOT | user_addr_t path |
0x0F0 | SYS_MKNODAT | int dirfd, const char* path, mode_t mode, dev_t rdev |
Extended attributes and mounts
| Number | Call | Arguments |
|---|---|---|
0x100 | SYS_GETXATTRAT | int dirfd, const char* path, user_addr_t name, user_addr_t value, size_t size, unsigned flags |
0x101 | SYS_SETXATTRAT | int dirfd, const char* path, user_addr_t name, user_addr_t value, size_t size, unsigned flags |
0x102 | SYS_LISTXATTRAT | int dirfd, const char* path, user_addr_t list, size_t size, unsigned flags |
0x103 | SYS_REMOVEXATTRAT | int dirfd, const char* path, user_addr_t name, unsigned flags |
0x104 | SYS_MOUNT | user_addr_t source, user_addr_t target, user_addr_t type, uintptr_t flags, user_addr_t params |
0x105 | SYS_UMOUNT | user_addr_t path |
0x106 | SYS_SYNC | none |
0x107 | SYS_SYNCFS | int fd |
0x108 | SYS_STATVFS | int fd, user_addr_t buffer |
System
| Number | Call | Arguments |
|---|---|---|
0x120 | SYS_UNAME | user_addr_t buffer |
0x121 | SYS_GETTIME | uintptr_t clock, user_addr_t time |
0x122 | SYS_SETTIME | uintptr_t clock, user_addr_t time |
0x123 | SYS_SETHOSTNAME | user_addr_t name, size_t length |
0x124 | SYS_NANOSLEEP | uintptr_t clock_id, uintptr_t flags, user_addr_t request, user_addr_t remaining |
0x125 | SYS_GETTIMERES | uintptr_t clock, user_addr_t resolution |
0x126 | SYS_SYSINFO | user_addr_t buffer |
0x127 | SYS_GETRLIMIT | uintptr_t resource, user_addr_t out |
0x128 | SYS_SETRLIMIT | uintptr_t resource, user_addr_t in |
0x129 | SYS_GETRUSAGE | intptr_t who, user_addr_t out |
0x12A | SYS_GETRANDOM | user_addr_t buffer, size_t size, uintptr_t flags |
0x12B | SYS_REBOOT | reserved; ENOSYS |
0x12C | SYS_SHUTDOWN | reserved; ENOSYS |
ABI
The ABI is the set of headers that a program outside the kernel compiles
against. They live in kernel/include/usr/vex/ and a program includes them as
<vex/...>. They fix the numbers of the system calls, the errors, the ioctls
and the layout of every structure that a call passes.
Where it lives
The headers under kernel/include/usr/ are the whole userspace ABI. The
architecture adds its own, such as arch/amd64/include/usr/arch/limits.h, and
<vex/types.h> includes the one of the target. The test image compiles
against both directories, and a C library takes the same two.
A header here compiles as ANSI C89 or ISO C90. It uses /* */ comments, no
nullptr, no bool, no static_assert, no restrict, no inline and no
designated initializers, and it declares every variable at the start of its
block. A C library can therefore read the headers with an old compiler.
The guard comes from the path: usr/vex/errno.h becomes USR_VEX_ERRNO_H,
and the #endif repeats the name. The name never starts with _.
Only usr/vex/types.h uses <stdint.h>, and only when the compiler is C23 or
C++. In C89 the header picks its types from the widths in <limits.h>.
Names
Every name in the ABI starts with __k_. That prefix keeps the POSIX name
free for the C library:
| ABI | C library | Kernel |
|---|---|---|
__k_EINVAL | EINVAL | EINVAL |
__k_O_RDONLY | O_RDONLY | O_RDONLY |
__k_VINTR | VINTR | VINTR |
struct __k_stat | struct stat | stat_t |
Most names reach the C library this way. A few constants, such as the
RLIMIT_* numbers, the library writes as the same literal. It also adds a few
flags of its own, such as the O_* bits that the kernel ignores.
The system call numbers are the exception: there is no public SYS_* name.
The C library in the bootstrap tree, mlibc, includes <vex/syscall.h> inside
its own system call layer and passes __k_SYS_* to the kernel. A program calls
read, and not a number.
The kernel does not include the ABI headers directly. A header under
kernel/include/vex/ includes the one under usr/, defines the plain names
for kernel code, and gives each structure its _t typedef.
Types
usr/vex/types.h defines every integer the ABI uses:
| Type | Width | Use |
|---|---|---|
__k_int8_t … __k_int64_t | 8, 16, 32, 64 | Signed fields |
__k_uint8_t … __k_uint64_t | 8, 16, 32, 64 | Unsigned fields |
__k_intptr_t, __k_uintptr_t | The pointer width | An address in an integer |
__k_ssize_t, __k_size_t | 64 | A count or a result with an error |
__k_id_t, __k_pid_t, __k_uid_t, __k_gid_t | 32 | Ids |
__k_off_t | 64 | A file offset |
__k_mode_t | 32 | A mode, a flag or an int argument |
__k_dev_t, __k_ino_t | 64 | A device and an inode |
__k_time_t, __k_clock_t | 64 | Seconds and ticks |
A structure in the ABI uses these types for every field, and never int,
long or a plain pointer where an integer holds an address. The layout then
does not depend on the widths that the compiler gives to its plain types.
A field with the address of user memory is a __k_uintptr_t or a pointer. A
field with a size in memory is a __k_size_t. A structure keeps the order
that its header shows, and its fields are naturally aligned.
struct __k_stat is the largest common example. It holds the device, the
inode, the mode and the link count. It also holds the owner and group, the file
size, three __k_timespec values and the block counts. Its mode constants are
the ext2 ones, __k_S_IFREG and the rest, so a filesystem passes the value
through without a translation.
struct __k_dirent is the record of getdents: inode, the position of the
next record, the length of this record, the type and the name. The record ends
after the name, padded to 8 bytes, and d_reclen is the step to the next one.
Errors
A system call returns the negated error number in its result register. The C
library turns that into -1 and stores the positive number in errno. The
numbers are Vex numbers and not the Linux ones. A library defines each kernel
error from the header, and not from a number that it chose. It may add numbers
above them for errors that the kernel never returns. Errors holds
the whole list.
ioctls
An ioctl request is one 32-bit number with a direction, the size of its argument, a group and a command. ioctls gives the layout and the groups. Each group page lists the requests of its device class:
- Serial port covers the
'S'group, the line settings and the binding to a consumer. - Terminal covers the
'T'group and the line discipline. - Framebuffer covers the
'F'group. - Device feeds covers the
'L'and'H'groups and the record format that/dev/kplogand/dev/hwtreeuse.
The headers
| Area | Headers |
|---|---|
| Types and limits | types.h, limits.h, errno.h |
| Calls | syscall.h |
| Files | fcntl.h, stat.h, statvfs.h, dirent.h, uio.h, mount.h, xattr.h |
| Processes | signal.h, wait.h, sched.h, resource.h, time.h, futex.h, spawn.h |
| Memory | mman.h |
| Devices | ioctl.h, termios.h, serial.h, fb.h, dev.h, feed.h, kplog.h, hwtree.h |
| Kernel facts | sysinfo.h, utsname.h, auxv.h, random.h |
| Security | capability.h |
The values of a header that an outside standard fixes, such as the S_IF*
bits of stat.h and the RLIMIT_* numbers of resource.h, keep the values
of that standard. A value that Vex invents starts at a free bit or a free
number and never moves.
Changing the ABI
There is no version field in these headers. A program that a compiler built against an old copy of them keeps calling with the old numbers. A value that a program can see must not change. A new option gets a new bit, a new error gets the next number, and a new structure is a new name. A change to a field of an existing structure changes the ABI, and the ioctl request of the call carries its size.
Source
- types.h — the integer types of the ABI
- stat.h —
struct __k_statand the mode bits - dirent.h —
struct __k_dirent - syscall.h — the system call numbers
Errors
An error is a positive number in usr/vex/errno.h. A system call
returns its negative in the result register, and the C library turns
that into -1 with the number in errno.
The numbers are Vex numbers. They are not the Linux numbers. A program that
compares an error against a literal from Linux is wrong on Vex. A C library
defines each POSIX name from the __k_ name in this header. It may add
numbers of its own above the kernel ones for errors that the kernel never
returns. The bootstrap C library, mlibc, does so from 2001.
ISO C takes the range 101 to 103. POSIX takes 201 and up. The kernel also has
-ERESTART (4095), which never reaches a program: the return path of a system
call turns it into a restart or into -EINTR. The C library has its own
ERESTART (2032) in the range that the kernel never returns, so the two do
not clash. The mechanism is in
System calls.
The string of an error comes from strerror_r() in kernel/libk/string.c. It
reads the absolute value of its argument, so a caller may pass the positive
number or the negated one from a system call. A number that it does not know
gives unknown errnum (N), and 0 gives no error.
The values
| Error | Value |
|---|---|
__k_EDOM | 101 |
__k_EILSEQ | 102 |
__k_ERANGE | 103 |
__k_E2BIG | 201 |
__k_EACCES | 202 |
__k_EADDRINUSE | 203 |
__k_EADDRNOTAVAIL | 204 |
__k_EAFNOSUPPORT | 205 |
__k_EAGAIN | 206 |
__k_EALREADY | 207 |
__k_EBADF | 208 |
__k_EBADMSG | 209 |
__k_EBUSY | 210 |
__k_ECANCELED | 211 |
__k_ECHILD | 212 |
__k_ECONNABORTED | 213 |
__k_ECONNREFUSED | 214 |
__k_ECONNRESET | 215 |
__k_EDEADLK | 216 |
__k_EDESTADDRREQ | 217 |
__k_EDQUOT | 218 |
__k_EEXIST | 219 |
__k_EFAULT | 220 |
__k_EFBIG | 221 |
__k_EHOSTUNREACH | 222 |
__k_EIDRM | 223 |
__k_EINPROGRESS | 224 |
__k_EINTR | 225 |
__k_EINVAL | 226 |
__k_EIO | 227 |
__k_EISCONN | 228 |
__k_EISDIR | 229 |
__k_ELOOP | 230 |
__k_EMFILE | 231 |
__k_EMLINK | 232 |
__k_EMSGSIZE | 233 |
__k_EMULTIHOP | 234 |
__k_ENAMETOOLONG | 235 |
__k_ENETDOWN | 236 |
__k_ENETRESET | 237 |
__k_ENETUNREACH | 238 |
__k_ENFILE | 239 |
__k_ENOBUFS | 240 |
__k_ENODATA | 241 |
__k_ENODEV | 242 |
__k_ENOENT | 243 |
__k_ENOEXEC | 244 |
__k_ENOLCK | 245 |
__k_ENOLINK | 246 |
__k_ENOMEM | 247 |
__k_ENOMSG | 248 |
__k_ENOPROTOOPT | 249 |
__k_ENOSPC | 250 |
__k_ENOSR | 251 |
__k_ENOSTR | 252 |
__k_ENOSYS | 253 |
__k_ENOTCONN | 254 |
__k_ENOTDIR | 255 |
__k_ENOTEMPTY | 256 |
__k_ENOTSOCK | 257 |
__k_ENOTSUP | 258 |
__k_ENOTTY | 259 |
__k_ENXIO | 260 |
__k_EOPNOTSUPP | 261 |
__k_EOVERFLOW | 262 |
__k_EPERM | 263 |
__k_EPIPE | 264 |
__k_EPROTO | 265 |
__k_EPROTONOSUPPORT | 266 |
__k_EPROTOTYPE | 267 |
__k_EROFS | 268 |
__k_ESPIPE | 269 |
__k_ESRCH | 270 |
__k_ESTALE | 271 |
__k_ETIME | 272 |
__k_ETIMEDOUT | 273 |
__k_ETXTBSY | 274 |
__k_EXDEV | 276 |
__k_ENOTRECOVERABLE | 277 |
__k_EOWNERDEAD | 278 |
A new error takes the next free number above the current ones. A number that a program can see must not change.
Source
- errno.h — the numbers
- vex/errno.h — the plain names for kernel code
- string.c —
strerror_rand the string of each error
ioctls
An ioctl request is one 32-bit number. It carries the direction of the data, the size of the argument, the class of the device and the command:
31 30 29 16 15 8 7 0
+--------+----------------+----------+----------+
| dir | size (14 bit) | group | command |
+--------+----------------+----------+----------+
The head of usr/vex/ioctl.h fixes that layout with __k_IOCTL_SET_DIR,
__k_IOCTL_SET_SIZE, __k_IOCTL_SET_GROUP and __k_IOCTL_SET_CMD. The
helpers __k_IOCTL_VOID, __k_IOCTL_READ, __k_IOCTL_WRITE and
__k_IOCTL_RDWR build a request from these parts, and each takes sizeof()
of the structure that it passes.
Direction and size
| Direction | Constant | Data moves |
|---|---|---|
| 0 | __k_IOCTL_DIR_VOID | Nothing |
| 1 | __k_IOCTL_DIR_WRITE | User to kernel |
| 2 | __k_IOCTL_DIR_READ | Kernel to user |
| 3 | __k_IOCTL_DIR_RDWR | Both ways |
The size field holds the size of the argument, at most 16383 bytes. A driver can therefore accept a structure of an older size when the kernel has one more field. The request of each size stays different.
A request of a group that a file does not know fails with ENOTTY.
The groups
| Group | Header | Device |
|---|---|---|
'f' | ioctl.h | Any file, such as __k_FIONREAD |
'F' | fb.h | The framebuffer |
'H' | hwtree.h | The device tree feed |
'L' | kplog.h | The kernel log feed |
'S' | serial.h | A serial port |
'T' | termios.h | A terminal |
__k_FIONREAD is the only request that every file takes. It reports a
__k_int32_t with the bytes that are ready to read.
The pages of the device classes list the requests of each group:
- Serial port —
'S' - Terminal —
'T' - Framebuffer —
'F' - Device feeds —
'L'and'H'
A new request takes the next free command in its group. A new class of device takes a free letter.
Source
- ioctl.h — the layout and the generic request
Serial port
A serial port is a character device that gives raw access to a line: the speed, the framing, the modem signals and the bytes. It is not a terminal. A terminal is one consumer that can take a port, and a port that carries a packet protocol is another.
Reading a setting needs only an open file. A request that changes the port
needs the file opened with O_EXCL, which keeps every other open out, and
CAP_SYS_TTY_CONFIG. The rule lets a program dial a modem and then bind the
port without another program changing the line in between.
The line
__k_SERIAL_GET_INFO fills a struct __k_serial_info:
| Field | Meaning |
|---|---|
driver | The name of the driver, at most 32 bytes with the terminator |
firmware_id | The id that the firmware gives the device, or empty |
max_speed | The fastest speed in bits per second |
modem_lines | The __k_SERIAL_MODEM_* lines that the port has |
struct __k_serial_line holds the speed in bits per second, 5 to 8 data bits
and 1 or 2 stop bits. It also holds a parity of __k_SERIAL_PARITY_NONE,
ODD, EVEN, MARK or SPACE, and a flow field with
__k_SERIAL_FLOW_RTSCTS.
The modem mask has DTR and RTS as outputs and DCD, DSR, CTS and RI
as inputs. __k_SERIAL_SET_MODEM takes one mask to raise and one to lower.
__k_SERIAL_BREAK sends a break for a number of milliseconds, at most 10000.
__k_SERIAL_EXCHANGE is the only request that moves bytes while no consumer
holds the port. It sends and receives at most 256 bytes each way, waits at
most 60000 ms, and stops at the terminator byte or at
__k_SERIAL_NO_TERMINATOR. The receive size comes back as the bytes that
arrived. The request fails with EBUSY while a consumer holds the port.
Binding a consumer
The port itself has no read and no write. The bytes belong to a consumer that
takes the port with __k_SERIAL_BIND. The kernel keeps a registry of consumers
and the binding names one:
| Field | Meaning |
|---|---|
consumer | The name of the consumer, at most 16 bytes with the terminator |
options | An option string for that consumer, at most 64 bytes |
index | The object to take, or __k_SERIAL_ANY_INDEX for the first free one |
object | The name of the object that the consumer made, such as tty0 |
The consumer decides what the bytes mean. The kernel registers "tty" today,
and that consumer makes a terminal on the port. A packet protocol such as
SLIP, PPP or KISS for AX.25 would be another consumer, with its own option
string and its own objects. The port ABI does not change for it: the program
sets the line, then binds the port to the consumer by name.
__k_SERIAL_GET_BINDING reads the binding back, or a zeroed one when nothing
is bound. __k_SERIAL_UNBIND fails with ENXIO when no consumer holds the
port, and with EBUSY when the object of the consumer is still open.
Why there is no read and no write
An unbound port is a control device, and not an I/O device. It has no read
and no write. A program therefore cannot take the bytes of a port that a
service is about to bind. Two programs cannot talk over one line.
__k_SERIAL_EXCHANGE is the one exception. A service uses it to probe a
device before it assigns a consumer: it sends a command and reads the answer
under the exclusive open. The request needs the same O_EXCL and
CAP_SYS_TTY_CONFIG as every other change, and it fails with EBUSY while a
consumer holds the port.
After the bind, the consumer owns the bytes. A program then reads and writes through the object of the consumer, such as a terminal. The port has no direct path to the line any more.
The requests
| Request | Argument | Meaning |
|---|---|---|
__k_SERIAL_GET_INFO | __k_serial_info | The driver, the firmware id, the fastest speed and the modem lines |
__k_SERIAL_GET_LINE | __k_serial_line | The line settings |
__k_SERIAL_SET_LINE | __k_serial_line | Set the speed, the data bits, the stop bits, the parity and the flow control |
__k_SERIAL_GET_MODEM | __k_uint32_t | The state of the modem lines |
__k_SERIAL_SET_MODEM | __k_serial_modem | Raise and lower the output lines |
__k_SERIAL_BREAK | __k_int32_t | Send a break for that many milliseconds |
__k_SERIAL_EXCHANGE | __k_serial_exchange | Send bytes and read the answer, with no consumer bound |
__k_SERIAL_BIND | __k_serial_binding | Hand the port to a consumer |
__k_SERIAL_UNBIND | None | Take it back |
__k_SERIAL_GET_BINDING | __k_serial_binding | Read the binding, or a zeroed one when there is none |
Limits
- An exchange sends and receives at most 256 bytes each way and waits at most 60000 ms.
- A break lasts at most 10000 ms.
- The exchange uses the line settings of the port, and has no flow control of its own.
- A fixed field of a structure holds a string with a terminator, and the
longest one is
options[64].
Source
- serial.h — the structures and the
'S'requests - serial_interface.c — bind, unbind and the requests
- tty_serial.c — the
"tty"consumer
Terminal
A terminal is a character device with a line discipline and a set of line settings. A program reads and writes bytes, and the line discipline turns them into lines, signals and echoes. A terminal has a carrier, such as a serial port, but the carrier is separate: Serial port describes it.
The settings
The state of the line discipline is struct __k_termios:
| Field | Meaning |
|---|---|
c_iflag | Input flags: parity, line breaks, flow control |
c_oflag | Output flags: newline and tab processing |
c_cflag | Control flags: data bits, stop bits, parity, CLOCAL, CRTSCTS |
c_lflag | Local flags: ICANON, ECHO, ISIG, IEXTEN |
c_cc | The 20 control characters |
c_ispeed, c_ospeed | Speeds in bits per second |
An output speed of 0 hangs up the line, and an input speed of 0 follows the
output speed. The control characters have fixed indices in c_cc. VINTR is
at 0, VQUIT is at 1, VERASE is at 2 and VSUSP is at 9 for the signals
and the editing. VMIN is at 14 and VTIME is at 15 for a read in
noncanonical mode. __k_POSIX_VDISABLE turns one off.
The requests
| Request | Argument | Meaning |
|---|---|---|
__k_TCGETS | __k_termios | Read the settings |
__k_TCSETS | __k_termios | Set them now |
__k_TCSETSW | __k_termios | Set them after the output drains |
__k_TCSETSF | __k_termios | Set them after the output drains and the input is flushed |
__k_TIOCGWINSZ | __k_winsize | Read the size of the window |
__k_TIOCSWINSZ | __k_winsize | Set the size of the window |
__k_TIOCGPGRP | __k_pid_t | Read the foreground process group |
__k_TIOCSPGRP | __k_pid_t | Set the foreground process group |
__k_TIOCGSID | __k_pid_t | Read the session id |
__k_TIOCSCTTY | __k_int32_t | Make the terminal the controlling terminal of the caller |
__k_TIOCNOTTY | None | Leave the controlling terminal |
__k_TCFLSH | __k_int32_t | Flush the input, the output or both |
__k_TCXONC | __k_int32_t | Stop or start the input or the output |
__k_TCDRAIN | None | Wait until the output drains |
__k_TCSENDBREAK | __k_int32_t | Send a break for that many milliseconds |
__k_TIOCEXCL | None | Refuse later opens by other files |
__k_TIOCNXCL | None | Allow them again |
__k_TIOCOUTQ | __k_int32_t | Read the bytes still to send |
__k_TIOCSCTTY needs a session leader. A nonzero argument takes the terminal
from another session, and that needs CAP_SYS_ADMIN. The flush argument is one
of __k_TCIFLUSH, __k_TCOFLUSH and __k_TCIOFLUSH, and the flow argument is
one of __k_TCOOFF, __k_TCOON, __k_TCIOFF and __k_TCION.
Limits
- The 20 control characters are fixed by
__k_NCCS. - On a terminal whose carrier is a serial port,
TCSETSwith a nonzeroc_ospeedsets the line of the port. It sets the speed, the data bits, the stop bits, the parity andCRTSCTS. A zeroc_ospeedhangs up and drops DTR. - A request that the terminal does not know fails with
ENOTTY. A break on a terminal whose carrier has no break succeeds and does nothing.
Source
- termios.h — the terminal structure and the
'T'requests - tty_interface.c — the line discipline and the requests
- tty_serial.c — a terminal on a serial port
Framebuffer
A framebuffer is a character device that a program maps into its address
space. The device node is a name such as fbd0. A mapping of the whole device
gives the pixels, and the ioctls of the 'F' group read the mode and change
it.
The mode
__k_VEXFB_GET_INFO fills a struct __k_vexfb_info:
| Field | Meaning |
|---|---|
width, height | The size in pixels |
pitch | The bytes from the start of one row to the next |
bpp | The bits of one pixel |
red, green, blue, alpha | A bit offset and a bit length for each channel |
size | The bytes of one whole mapping |
palette_size | The entries of the palette, or 0 for direct color |
flags | __k_VEXFB_FLAG_FLUSH when the memory is not scanned out directly |
With a palette, the four channel structures have zero lengths and each pixel
is an index. __k_VEXFB_GET_PALETTE and __k_VEXFB_SET_PALETTE move a range
of entries, each one a __k_uint32_t of the form 0x00RRGGBB.
The flag __k_VEXFB_FLAG_FLUSH means that a store to the memory does not
reach the display on its own. __k_VEXFB_FLUSH then sends a list of
rectangles, at most 256 of them.
The requests
| Request | Argument | Meaning |
|---|---|---|
__k_VEXFB_GET_INFO | __k_vexfb_info | Read the mode and the layout |
__k_VEXFB_FLUSH | __k_vexfb_flush | Send a list of rectangles to the display |
__k_VEXFB_GET_PALETTE | __k_vexfb_palette | Read entries, which the kernel fills |
__k_VEXFB_SET_PALETTE | __k_vexfb_palette | Write entries |
__k_VEXFB_SET_MODE | __k_vexfb_mode | Change the mode |
__k_VEXFB_GET_MODES | __k_vexfb_modes | Read the modes that the display suggests |
__k_VEXFB_GET_PALETTE carries the direction of a write, because the
structure itself goes from user to kernel. The kernel then fills the array in
the user memory that the structure points to. A program reads the constant
from the header and does not build it.
__k_VEXFB_SET_MODE takes one mode: width, height, bits per pixel and no
flags. It unmaps the memory of the old mode, so a later touch of an old
mapping faults again and gets the new mode. __k_VEXFB_GET_MODES takes room
for a list and gives the count back. A mode with __k_VEXFB_MODE_PREFERRED is
native to the display.
The mapping
mmap of the device node with PROT_READ and PROT_WRITE, and MAP_SHARED,
maps the pixels at offset 0. The size and the layout come from the info
request, and the mapping has the size of one screen.
Limits
- A list of rectangles holds at most 256 entries.
- A palette entry is 32 bits,
0x00RRGGBB, with no alpha. - There is no request for the cursor, the blanking or the display power.
- A mode change invalidates every mapping of the device.
Source
- fb.h — the structures and the
'F'requests - limine.c — the early framebuffer
- bochs.c — the display that can change its mode
Device feeds
A feed is a virtual node of devfs that gives records of kernel events. Vex has
two: /dev/kplog for the kernel log and /dev/hwtree for the device tree.
Each read gives only whole records. When the next record does not fit in the
buffer of the caller, the read fails with EINVAL. A program therefore reads
with a buffer that is large enough for the largest record.
Every record begins with struct __k_feed_header:
| Field | Meaning |
|---|---|
size | The bytes of the whole record, header and padding included, a multiple of 8 |
type | The kind of record, never zero |
flags | Their meaning depends on type |
seq | The sequence number of the record |
time_ns | The moment of the record on __k_CLOCK_MONOTONIC |
The type __k_FEED_TYPE_LOST says that the feed dropped records before this
one, and struct __k_feed_lost gives the count. A type of a device is
__k_FEED_TYPE_DEF(n), which is 0x0100 + n.
The kernel log
A struct __k_kplog_record adds the subsystem, the level, the length and the
text. The text is not terminated, and its field holds at most
__k_KPLOG_TEXT_MAX (512) bytes. The flag __k_KPLOG_FLAG_TRUNCATED says
that the message was longer than the record.
The levels run from __k_KP_EMERG (0) to __k_KP_VERBOSE (7). The subsystem
is one of __k_KP_*, and __k_KP_USER marks a write from user space. A write
to /dev/kplog needs CAP_SYSLOG.
The requests of the 'L' group:
| Request | Argument | Meaning |
|---|---|---|
__k_KPLOG_SEEK_FIRST | None | Go to the oldest record in the ring |
__k_KPLOG_SEEK_END | None | Go to the record to come |
__k_KPLOG_SEEK_SEQ | __k_uint64_t | Go to that sequence number |
__k_KPLOG_GET_INFO | __k_kplog_info | The first and the next sequence number |
__k_KPLOG_GET_CONSOLE_LEVEL | __k_int32_t | The level that the console prints |
__k_KPLOG_SET_CONSOLE_LEVEL | __k_int32_t | Set that level |
The device tree
A struct __k_hwtree_record adds the id of the node, the id of its parent, its
state and a block of fields. The fields are zero-separated key=value pairs
in one array of at most __k_HWTREE_FIELDS_MAX (512) bytes. The flag
__k_HWTREE_FLAG_TRUNCATED says that some fields did not fit and were left
out whole.
A reader gets the tree as it was at open, between a snapshot begin record and a snapshot end record, with parents before their children. After that come the changes. After a lost record comes a new snapshot.
| Type | Meaning |
|---|---|
__k_FEED_TYPE_HWTREE_SNAPSHOT_BEGIN | The beginning of a snapshot |
__k_FEED_TYPE_HWTREE_SNAPSHOT_END | The end of a snapshot |
__k_FEED_TYPE_HWTREE_ADD | A node appeared |
__k_FEED_TYPE_HWTREE_REMOVE | A node left |
__k_FEED_TYPE_HWTREE_BIND | A driver took the node |
__k_FEED_TYPE_HWTREE_UNBIND | The driver let it go |
__k_FEED_TYPE_HWTREE_WAITING | The best driver waits for a provider |
__k_FEED_TYPE_HWTREE_ATTACH_FAILED | The attach returned an error |
__k_FEED_TYPE_HWTREE_INTERFACE | An interface appeared on the node |
__k_FEED_TYPE_HWTREE_INTERFACE_REMOVE | The interface left |
The state of a node is one of __k_HWTREE_STATE_PRESENT, PROBING,
WAITING, BOUND, DETACHING and GONE.
The requests of the 'H' group change the tree, so they need a file opened
for writing, and that open needs CAP_DEVICE:
| Request | Argument | Meaning |
|---|---|---|
__k_HWTREE_RESNAPSHOT | None | Give the tree again from the beginning |
__k_HWTREE_RESCAN | __k_uint64_t | Rescan the buses below that node |
__k_HWTREE_UNBIND | __k_uint64_t | Unbind the driver of that node |
__k_HWTREE_EJECT | __k_uint64_t | Eject the device of that node |
Limits
- A record is at most the fixed part plus 512 bytes of text or fields, and its size is a multiple of 8.
- A read buffer that is too small for the next record fails with
EINVAL. The kernel does not cut a record and does not skip one. - The log is a ring. A reader that falls behind gets a lost record and misses the records in between.
Source
- feed.h — the common header and the lost record
- kplog.h — the log record and the
'L'requests - hwtree.h — the tree record and the
'H'requests
Capabilities
A capability is one bit of a 64-bit mask. The kernel tests the effective set of the credentials of the thread at each privileged operation. The bits of the Linux capabilities keep their Linux numbers, so a mask moves between the two systems without a change of meaning. The Vex-only bits start at 48.
The check is cred_capable(). It reads one bit of cap_effective, and
nothing else.
The bits
| Bit | Name | What it allows |
|---|---|---|
| 0 | CAP_CHOWN | Change the owner or the group of a file |
| 1 | CAP_DAC_OVERRIDE | Bypass the read, write and execute permission checks |
| 3 | CAP_FOWNER | Act as the owner: chmod, timestamps, and removal from a sticky directory |
| 4 | CAP_FSETID | Keep the setuid and setgid bits that a change would clear |
| 5 | CAP_KILL | Signal a process of another user |
| 6 | CAP_SETGID | Change any group id, and set the supplementary groups |
| 7 | CAP_SETUID | Change any user id |
| 14 | CAP_IPC_LOCK | Lock memory beyond RLIMIT_MEMLOCK |
| 18 | CAP_SYS_CHROOT | Change the root directory |
| 21 | CAP_SYS_ADMIN | Read and write trusted.* extended attributes, write the other security.* ones, set the host name, and force a session on a terminal |
| 23 | CAP_SYS_NICE | Set the nice value of a process of another user, and lower its own past the floor |
| 24 | CAP_SYS_RESOURCE | Raise a hard resource limit |
| 25 | CAP_SYS_TIME | Step the realtime clock |
| 26 | CAP_SYS_TTY_CONFIG | Change the line settings of a serial port, and bind or unbind its terminal |
| 27 | CAP_MKNOD | Make a device node |
| 31 | CAP_SETFCAP | Set or remove the security.capability extended attribute |
| 34 | CAP_SYSLOG | Write to /dev/kplog |
| 48 | CAP_MOUNT | Mount and unmount a filesystem |
| 49 | CAP_DEVICE | Write to /dev/hwtree, which rescans, unbinds and ejects devices |
| 50 | CAP_VM_NORESERVE | Map memory with MAP_NORESERVE, and use the part of the commit limit that is kept for this capability |
The unused bits stay unused. Vex does not move a Linux bit to a different number, so a check that a program makes against a Linux mask keeps its answer.
CAP_DAC_OVERRIDE still fails an execute check on a file that has no execute
bit for any user. Linux does the same, and it keeps a data file from becoming
executable through the capability alone.
The sets
struct __k_caps holds four masks and a word of flags: effective,
permitted, ambient, bounding and flags. The capget(2) and
capset(2) calls copy this structure.
- The effective set gates the checks.
- The permitted set is the ceiling of the effective and ambient sets.
- The ambient set survives the exec of a program that has no setuid or setgid bit and no file capabilities.
- The bounding set limits what an exec may grant. Only a lower mask is accepted, so it can shrink and never grow.
capset(2) needs no capability of its own. The subset rules are the whole
check. It accepts a permitted set that is not larger than the current one, and
a bounding set that is not larger. It accepts effective and ambient sets that
are subsets of the new permitted set. A reserved field or a flag other than
CAPS_KEEP fails with EINVAL.
CAPS_KEEP (0x0001) keeps the permitted set when the last user id 0 goes
away. Without it, that change clears the permitted and ambient sets. A change
of the effective id away from 0 clears the effective set. A change to 0 sets
the effective set to the permitted set.
Program start
An exec works out the new capability sets in this order:
- It clears the flags. It also clears the ambient set when the program has a setuid or setgid bit, or when the file has capabilities.
- If the new effective id is 0, the permitted set becomes the bounding set, and the effective set becomes the permitted set.
- Otherwise the permitted set becomes the ambient set masked by the bounding set, and the effective set becomes the permitted set.
- The file capabilities add the file permitted set, masked by the bounding
set. When the file effective flag is set, the effective set becomes the
permitted set. A file that asks for an effective set with a bit outside the
bounding set fails the exec with
EPERM.
Vex has no inheritable capability set, so the inheritable mask of a file never grants anything.
The kernel sets AT_SECURE for the new program when an id differs from the
real id. It also sets it when the real id is not 0 and the permitted set has a
bit that is not ambient. A C library uses it to discard the environment of the
caller.
File capabilities
The capabilities of a program live in the security.capability extended
attribute. The layout is the Linux one, and the fields are little-endian:
| Field | Bytes | Meaning |
|---|---|---|
magic_etc | 4 | Revision in the top byte, flags in the low byte |
data[0] | 8 | Low halves of permitted and inheritable |
data[1] | 8 | High halves of permitted and inheritable |
rootid | 4 | Revision 3 only |
Three revisions exist, and the size tells them apart:
| Revision | Size | Sets |
|---|---|---|
| 1 | 12 | 32-bit, no rootid |
| 2 | 20 | 64-bit, no rootid |
| 3 | 24 | 64-bit, with rootid |
VFS_CAP_FLAGS_EFFECTIVE (0x0001) in magic_etc asks for the permitted set
to be effective at the start. A revision 3 attribute with a rootid that is
not 0 names a user namespace, and Vex has none. It reads as no capabilities,
as Linux does.
The VFS removes the attribute when the file is written or truncated, and when
a chown changes its owner. A caller without CAP_SETFCAP cannot set it or
remove it.
Source
- capability.h — the bits, the sets and the xattr layout
- cred.c —
cred_capable,capsetrules, exec and the parsing of the xattr - cred.h — the interface of the credential code
- sys_cred.c —
capget(2)andcapset(2) - vnode.c — the xattr permissions and the VFS checks
- sys_process.c — the file capabilities at exec
- commit.c — the reserve for
CAP_VM_NORESERVE
Device names
Every block and char interface gets a name when it registers. Userspace opens
that name under /dev. The interface also keeps a 64-bit dev_t, and the
kernel keeps the name and the number in step.
A name is stable text. For example, ada0n1p2 names a partition of an ATA
disk. A dev_t is the same device as one integer, which stat reports as
st_rdev.
The name
A name has four parts:
<prefix><unit>n<index>p<partition>
ada 0 n1 p2
- The prefix is the device class, in letters.
- The unit is the number that the class gives the device.
- The index is a connector or a lane below the device.
- The partition is a slice of the block device.
The letters n and p are literal, and each one introduces the part after it.
The parts nest. A partition needs an index, and an index needs a unit. The
format rejects a name that breaks this rule. A name with no part for a level
stops at the last part that it has.
| Name | Prefix | Unit | Index | Partition |
|---|---|---|---|---|
kplog | kplog | none | none | none |
fbd0 | fbd | 0 | none | none |
serial1 | serial | 1 | none | none |
ada0 | ada | 0 | none | none |
ada0n1 | ada | 0 | 1 | none |
ada0n1p2 | ada | 0 | 1 | 2 |
tty1337 | tty | 1337 | none | none |
A unit and an index use 12 bits each. A partition uses 8 bits. A part that
does not fit fails with -ERANGE. A name of 64 characters or more fails with
-ENAMETOOLONG, because the buffer holds 63 characters.
Prefixes
Each prefix is one global dev_prefix_t in
dev_name.c.
The same file lists them in dev_prefixes[].
| Prefix | Form | Class |
|---|---|---|
ada | ada<unit> | ATA disk |
acd | acd<unit> | ATAPI drive |
nvd | nvd<unit> | NVMe namespace, no driver yet |
vbd | vbd<unit> | virtio block, no driver yet |
tty | tty<unit> | Terminal on a serial port |
pty | pty<unit> | Pseudo terminal, no user yet |
serial | serial<unit> | Serial port |
serialtest | serialtest | Far end of the test serial port |
fbd | fbd<unit> | Framebuffer device |
kplog | kplog | Kernel log |
hwtree | hwtree | Device tree |
random | random | Random source |
urandom | urandom | Second random source, same behavior as random |
anon | anon<unit> | Anonymous filesystem, no /dev node |
A new prefix needs three things:
- an entry in the header,
- a global in
dev_name.c, - a line in
dev_prefixes[].
There is no runtime registry.
The device number
A dev_t holds the class in the top half and the parts in the low half:
63 32 31 20 19 8 7 0
+----------------+------------+-------------+--------+
| prefix hash | unit | index | part |
+----------------+------------+-------------+--------+
| Field | Bits | Range |
|---|---|---|
| Prefix | 63:32 | CRC-32/ISO-HDLC of the prefix text |
| Unit | 31:20 | 0 to 4094 |
| Index | 19:8 | 0 to 4094 |
| Partition | 7:0 | 0 to 254 |
DEV_SET packs the fields and DEV_GET_* reads them. DEV_GET_MAJOR is the
prefix hash and DEV_GET_MINOR is the low 32 bits. The macros are in
usr/vex/dev.h,
because a program reads them through st_rdev.
The kernel hashes every prefix once, in init_dev_prefixes. A panic stops the
boot when two prefixes have the same hash.
Taking a number
Three calls give a name to an interface. All of them run under one spinlock.
dev_unit_alloc takes the next free number of a prefix. It takes the number
that a unit= option gives the device. Without one, it takes the lowest number
that no unit has. It also skips a number that a unit= option keeps for
another device. A device without a node passes nullptr and gets the lowest
free number.
dev_unit_claim takes one number that the caller chooses. The hardware, or a
binding option, decides it. It fails with -EEXIST when another unit has the
number. It fails with -ERANGE when a dev_t cannot hold the number.
dev_prefix_claim gives the whole prefix to one device, which then has no
unit. It fails when another device holds the prefix or any unit below it. No
unit can appear afterwards, until the owner calls dev_prefix_release.
dev_unit_free and dev_prefix_release release the name. The device that
holds the name must be gone first.
| Caller | Call | Name |
|---|---|---|
| AHCI, PATA | dev_unit_alloc for the controller or channel. The index is the port or drive plus one | ada0n1 |
| Partition table | root name with a partition | ada0n1p2 |
| Serial port | dev_unit_alloc or dev_unit_claim | serial1 |
| tty on a serial port | dev_unit_alloc, or the index of serial= | tty1337 |
| Framebuffer | dev_unit_alloc | fbd0 |
kplog, hwtree, random, urandom, serialtest | dev_prefix_claim | kplog |
| Anonymous filesystem | dev_unit_alloc without a node | anon0 |
The kernel warns when another unit already has the pinned number. The device then takes the lowest free number.
Pinning a name
A probe can run in a different order on each boot. The kernel therefore lets the command line pin a name to a node:
unit=serial1,acpi:PCI0/pci:0000:00:1f.0/isa:3f8
The path is the one that /dev/hwtree shows for the node. It holds the device
names from the top-level device down, joined by /. A device name is at most
32 characters and the path at most 256.
The prefix of a unit= option is the run of letters before the first digit.
The kernel reads every option twice. init_pinned_units runs at the bus stage
and warns about a malformed option. dev_unit_alloc reads the options again,
at each registration.
An option keeps its number even for an interface that never appears. A second device that asks for that number takes the lowest free number instead.
The /dev namespace
devfs listens to the block and char events. It makes one node per interface, with the interface name. It removes the node when its interface is removed.
root= is a block name, not a path. mount_root finds the interface with
block_interface_find and passes the interface to vfs_mount. The names
therefore exist before devfs mounts.
Why it is done this way
- A name does not depend on the probe order. A test or an fstab entry needs
the same name on each boot.
unit=makes that name, and the test image pins its serial ports for this reason. - The unit is per class. The prefix says what the device is and the unit
says which one it is. A partition nests below an index, so
ada0n1p2names both the disk and the slice. - The class lives in the number. The kernel has no major and minor
registry. A
dev_tcarries the hash of the prefix, so a new class needs no number allocation.st_rdevis enough to tell two devices apart. - A class of one claims its prefix. A device such as
/dev/kploghas no need for a number. The claim stops a later unit from taking the name.
Limits
- A prefix has 4095 unit values, from 0 to 4094. The value 4095 is the
sentinel
DEV_NO_UNIT. - A device has at most 254 partitions, numbered from 1.
- One spinlock covers every prefix. An allocation on one class waits for an allocation on another.
- The prefix table is code. A new class needs an edit of
dev_name.cand a rebuild. - A name has one owner. There are no aliases, and no second name for a partition.
- A wrong
unit=path is not an error. The device takes the lowest free number, and the pinned number stays unused.
Source
| File | Contents |
|---|---|
| dev_name.c | The prefixes, the unit list, the allocation, the format |
| dev_name.h | dev_prefix_t, dev_unit_t, dev_name_t and their calls |
| usr/vex/dev.h | The bit layout of dev_t |
| block_interface.c | The root and partition names of a block device |
| char_interface.c | The name of a char device |
| tty_serial.c | The number that serial= gives a tty |
| devfs_char.c | The node under /dev and st_rdev |
Logging
The kernel writes one message with pr_*. The call appends a record to a ring
buffer, and every console copies the new records to its device. Userspace reads
the same records from /dev/kplog.
The ring holds the last CONFIG_KPRINTF_BUFSIZE bytes. A console that appears
late can therefore replay the boot. A crash leaves the recent messages for a
reader.
Levels
A level says how important a message is. The value is smaller for a more important message.
| Value | Macro | Meaning |
|---|---|---|
| 0 | pr_emerg | The system is unusable |
| 1 | pr_alert | Action is necessary at once, usually unrecoverable |
| 2 | pr_critical | Critical condition, probably unrecoverable |
| 3 | pr_error | Error condition, probably recoverable |
| 4 | pr_warning | Warning, definitely recoverable |
| 5 | pr_notice | Normal but important message |
| 6 | pr_inform | Informational message |
| 7 | pr_verbose | Debug message |
pr_emerg and the other macros pass a subsystem and a level to kprintf. A
newline at the end of the text does not enter the record.
The variable loglevel holds the level of the least important message that a
console writes. A console writes a record when record.level <= loglevel. The
default is KP_INFORM, so the kernel hides pr_verbose messages.
The loglevel= kernel option sets the variable. The option takes verbose,
inform, notice, warning, error, critical or alert, in any letter
case. It does not take emerg.
Subsystems
A subsystem says which part of the kernel wrote the message. The build passes
one value to each archive: kernel/meson.build maps a top directory to a
KP_* value with -DKP_SUBSYSTEM=.
| Value | Name | Top directory |
|---|---|---|
| 0x00 | KP_CORE | core, fbcons, init, libk |
| 0x01 | KP_ARCH | arch/amd64 |
| 0x02 | KP_MM | mm |
| 0x03 | KP_PROC | proc |
| 0x04 | KP_IRQ | irq |
| 0x05 | KP_TIME | time |
| 0x06 | KP_FIRMWARE | firmware |
| 0x07 | KP_DEVICE | device |
| 0x08 | KP_BUS | bus |
| 0x09 | KP_DEV | dev |
| 0x0A | KP_DRIVERS | drivers |
| 0x0B | KP_FS | fs |
| 0x0C | KP_EXEC | exec |
| 0x0D | KP_CRYPTO | crypto |
| 0x0E | KP_IPC | ipc |
| 0x0F | KP_RANDOM | random |
| 0xFF | KP_USER | A write to /dev/kplog |
A file that needs another subsystem defines KP_SUBSYSTEM before its
includes. Without that define the header picks KP_CORE.
The ring buffer
The ring is one static array. Each record starts with a kp_record_t header
and holds the text after it. The record and its text take a whole number of
8-byte units.
ring_tail ring_head
| |
v v
+-------+-------+-------+-------+-----------+
| rec 0 | rec 1 | rec 2 | rec 3 | free |
+-------+-------+-------+-------+-----------+
| Field | Contents |
|---|---|
seq | The record number, from 0, one more for each record |
time_ns | Nanoseconds since boot |
size | Text size, without a newline or a NUL |
subsystem | One KP_* value |
level | One pr_* value |
flags | KPLOG_FLAG_TRUNCATED when the formatter cut the text |
A write holds the ring lock with interrupts off, formats the text, and appends one record. A text longer than 512 bytes loses its tail and gets the truncated flag. The kernel drops the oldest records when the ring is full.
The tail and the head are byte positions that only grow. A record that would cross the end of the array moves to the beginning. The kernel writes a padding marker when the space left can hold one. The head moves only after the whole record is in the array. A panic therefore never reads half a record.
The append triggers the kprintf_appended event and flushes every console.
Consoles
A console is one console_t with its own read cursor. A flush gives each
console the records that its cursor has not passed. The console core serializes
the calls.
| Console | Name | Flags |
|---|---|---|
| Serial port | serial<N> | CONSF_RECALL |
| Framebuffer | fbd0 | CONSF_RECALL |
| Bochs debug port | bxcons | CONSF_RECALL, CONSF_LOGLEVEL |
A console with CONSF_RECALL starts at the oldest record and replays the
history. A console without it starts at the newest record. bxcons sets its
own level to KP_VERBOSE, so it prints every record that the other consoles
hide.
The output line holds a timestamp, the text, and CR LF:
[ 12.3456] kernel message
The timestamp is the time since boot in seconds, then four digits of the fraction, so its resolution is 100 µs.
The first open of a device releases its console. The driver then uses the
port for its own data. The kernel keeps no claim on the device.
A panic takes every released device back with takeover, because the message
must reach the terminal. A clean shutdown will take the devices back in the
same way. The kernel has no clean shutdown yet, so a panic is the only
takeover.
A console that falls behind writes the count of records that it missed:
** 32 messages dropped **
/dev/kplog
/dev/kplog is a char device. A read gives whole feed records. Each record
starts with a feed_header: the size, the type, the flags, the sequence number
and the timestamp. The subsystem, the level, the text size and the text follow.
A read that has no new record waits for the next append. O_NONBLOCK makes it
return -EAGAIN.
A record that the ring dropped arrives as a lost record: the feed type is
FEED_TYPE_LOST and the header holds the count. A read that cannot fit the
next record fails with -EINVAL.
A write appends one record at level KP_NOTICE, or at the level of a leading
<N> prefix, as syslog writes it. The kernel rejects a write with an embedded
line break or a level more severe than KP_ERROR. An open for write needs
CAP_SYSLOG.
| ioctl | Action |
|---|---|
KPLOG_SEEK_FIRST | Move the cursor to the oldest record |
KPLOG_SEEK_END | Move the cursor past the newest record |
KPLOG_SEEK_SEQ | Move the cursor to a sequence number |
KPLOG_GET_INFO | Read the first and the next sequence number |
KPLOG_GET_CONSOLE_LEVEL | Read loglevel |
KPLOG_SET_CONSOLE_LEVEL | Write loglevel, with a write-capable descriptor |
An open starts at the oldest record, so a program sees the boot. The ioctl
names say “console level”, but the value is the global loglevel of the
consoles.
Panic and assertions
panic(fmt, ...) records the file and the line at KP_EMERG, then the
formatted message. It stops the other harts with an NMI and waits about one
second for them to stop. It sets panic_in_progress, takes every released
console back, writes the message, and spins for good.
While panic_in_progress is true, the ring and the console core skip their
locks. The panic can thus log while another hart holds a lock that will never
be released.
A second panic on any hart stops that hart and does not write a message.
kassert(expr) checks an expression with CONFIG_KASSERT, which defaults to
yes. A false expression panics with the expression, the file, the line and the
function. Without the option, the kernel still evaluates the expression and
ignores the result.
Why it is done this way
- Records, not lines. A console can appear after the boot or go away. The ring keeps the records, and each console reads them at its own pace.
- Every level enters the ring. A console filters when it writes, so
bxconsand/dev/kplogsee the verbose records that the serial console hides. - Growing positions, not indexes. A reader keeps a byte position and a sequence number. A dropped record then becomes a count, not a wrong record.
- A panic writes without locks. A hart can panic with a lock held. The
ring and the console core allow this after
panic_in_progress, and the head shows only whole records. - A console borrows its device. The kernel does not own an output device as Linux does. The driver owns the device, and the first open takes it from the console without a question. This simple rule has no state machine for a device that the kernel and a program share.
- Userspace draws after the boot. The kernel keeps one small framebuffer
console. The build fixes its font and its character size (
CONFIG_FBCONS,FBCONS_FONT). A program that opensfbd0gets the screen and must draw on it, askmscondoes on Linux. A future X server would own the screen in the same way. The kernel asks for the device back only when it must report a panic or a shutdown.
Limits
- The kernel cannot take a device back from a running program. A panic, or a future clean shutdown, is the only takeover.
- The ring holds 64 KiB by default.
CONFIG_KPRINTF_BUFSIZEsets another size, from 2 KiB to 1 MiB, in a multiple of 8. - One record holds 512 bytes of text. A longer text loses its tail.
- A console that does not set
CONSF_LOGLEVELshares the globalloglevel. - Userspace cannot write a record more severe than
KP_ERROR. - The command line cannot set
logleveltoemerg. - The timestamp has a resolution of 100 µs.
Source
| File | Contents |
|---|---|
| kprintf.c | The ring, the append, the readers, loglevel |
| kprintf.h | pr_*, kp_record_t, the cursor calls |
| console.c | The console list, the filter, the flush, the dropped count |
| console.h | console_t, console_ops_t, the CONSF_* flags |
| kplog.c | /dev/kplog, its feed, its write and its ioctls |
| usr/vex/kplog.h | The level and subsystem values, the record, the ioctl numbers |
| usr/vex/feed.h | The feed header and the lost record |
| panic.c | The hart stop, the emergency output, panic_in_progress |
| assert.c | The message of a failed assertion |
Kconfig
The configuration is a tree of symbols in the Kconfig files. menuconfig
shows that tree, so this page does not list the options. It says where they
live and how the build uses them.
Where the options live
| Menu | File |
|---|---|
| Processes and scheduling | kernel/proc/Kconfig |
| Console and logging | kernel/core/Kconfig, which sources kernel/fbcons/Kconfig |
| Block devices | kernel/dev/Kconfig |
| Buses | kernel/bus/Kconfig |
| Device drivers | kernel/drivers/Kconfig, which sources one file per class |
| Filesystems | kernel/fs/Kconfig |
| Debugging | kernel/Kconfig |
| Arch capabilities | kernel/arch/<arch>/Kconfig |
kernel/Kconfig is the root of the tree. It sources the arch file, then one
file per menu.
Each file holds the help text for its options. The help text is the
description of how the option changes the build and the behaviour of the
kernel. menuconfig shows it in the help pane.
Conventions
- A
boolis on or off. The generated header defines it to 1 when it is on, and leaves it undefined when it is off. - An
intor ahexhas a range. Astringis free text. - A
choicepicks one symbol out of a group. The symbols in the group share one prompt. depends onhides a symbol until another symbol is on.selectforces a symbol on from another one.- The default belongs in the
Kconfigfile. A defconfig stores only the values that differ from the defaults. - Every option needs a Meson gate in the directory that it turns on. See Build.
The generated header
scripts/kconfig/gen_config_h.py turns build/.config into
build/kernel/generated.config.h. The build force-includes the header into
every translation unit, so CONFIG_* macros are always visible.
Update the config
ninja -C build menuconfig # change the values
ninja -C build savedefconfig # write build/defconfig
Copy build/defconfig over configs/<arch>_defconfig to make the change the
new default.
Writing a test
A test is one C file in ktest/. The build makes one ELF for it, and kst
runs it as a child process on the test image.
The file
Name the file ktest/test.<name>.c. The program returns 0 for success and
uses the checks from ktest/ktest.h:
#include "ktest.h"
int main(int argc, char** argv)
{
ktest_printf("hello from %s\n", argv[0]);
ktest_assert(argc == 1);
return 0;
}
ktest_expect(expression, expected)compares the value and keeps the test running after a mismatch.ktest_assert(condition)stops the test with a message.ktest_result()returns 0 when every check held, and 1 otherwise. A test that usesktest_expectreturns it frommain.
ktest.h also has file helpers, formatted output, thread helpers and raw
system call wrappers (ktest_syscall1 to ktest_syscall6). The user ABI is
in kernel/include/usr/vex/; a test includes it as <vex/...>.
Add it to the build
Add the name to ktest_programs in ktest/meson.build:
ktest_programs = [
'hello',
'uname',
# ...
]
The order in that list is the order that kst all runs the tests. A test that
takes the machine over, such as the framebuffer test, goes last.
The build copies each ELF to /kst/<name>.elf on the DATA partition, and
kst finds it by name.
Run it
Build and boot the image:
ninja -C build ktest-run
Then run one test or all of them at the kst prompt:
> run hello
> all
kst prints [ OK ] or [FAIL] with a timestamp for each program. A failure
names the exit status, the signal or the failed waitpid.
Rules
- A test is a separate process, forked by
kst. It cannot see the memory of another test. - A test runs with the full capability set, because
kstis init. A test can mount, format and corrupt the DATA partition. - The tests share the DATA partition. A test that writes leaves the files for the tests that follow.
- There is no unit test suite. Check a kernel change on the test image.
- After a run, check the ext2 partition with
e2fsck. See the test image.
Where the code is
| File | Contents |
|---|---|
ktest/ktest.h | The checks and the helpers that a test uses |
ktest/ktest.c | The implementation of the helpers |
ktest/kst.c | The runner |
ktest/meson.build | The test list and the build rules |
ktest/test.<name>.c | One test |
Writing docs
Page shape
A page about a kernel-wide mechanism follows this order:
- what it is and which problem it solves;
- how it works, with one diagram if the flow is not obvious;
- the invariants and the traps that a reader must know;
- where the code is.
A page about one implementation (a driver, a filesystem, a bus) uses fixed headings:
## Discovery## Attach## Data path## Why it is done this way## Limits## Source
## Why it is done this way is the reason the page exists. Use one
sub-section per decision: the constraint, the choice, and what breaks
otherwise. If a page has nothing for that section and the implementation is
small, do not create the page. Add a row to the table of its section instead.
Where a page lives
architecture/ explains a mechanism that several parts use. drivers/,
filesystems/ and buses/ explain one concrete implementation. A mechanism
page must not grow into a tour of one driver, and a driver page must not
repeat the mechanism.
Source links
Link to the source with an absolute GitHub URL:
[ahci.c](https://github.com/vex-os/kernel/blob/main/kernel/drivers/storage/ahci.c)
Use the line anchor for the function or the decision that the text is about.
Relative links work on GitHub but break in the built book, because the book
contains only docs/src.
Diagrams
Use a plain ASCII diagram. Keep every line within 70 columns. The page column fits about 80 columns of code on a 1080p screen, so a wider diagram scrolls sideways.
Synced tables
A table that must not drift from the code, such as the syscall numbers, can
include the source block at build time. mdBook reads the file when it builds
the page, so the table follows the header. Put ANCHOR: and ANCHOR_END:
comments around the block in the header, then point the include directive at
it. The path is relative to the page file; the mdBook guide has the exact
syntax.
If the path gets awkward or the block needs formatting, generate the page with a script instead. The kconfig scripts are the local precedent.
Prose
Write in ASD-STE100 Simplified Technical English, as the code comments do:
short sentences, one statement per sentence, active voice, present tense.
Explain the reason and the invariant, not the steps of the code. Do not copy a
/// block from a header into the page.
Adding a page
Add the file under docs/src, then add one line to SUMMARY.md. A file that
is not in SUMMARY.md is not part of the book. Run mdbook serve docs to
check the page while you write it, and mdbook build docs before you commit.