Audience: All contributors
Execution context: Host (Linux dev machine)
Maturity: CMake + Clang baseline
DuetOS uses CMake (3.25+) with Clang 18+ as both the freestanding kernel compiler and the cross-compiler for the userland Windows PE toolchain. The build produces:
kernel/duetos-kernel.elf— the kernel ELFuserland/libs/<dll>/<dll>.dll— userland Win32 DLLs (PE32+)kernel/smoke-pes/<app>/<app>.exe— generated Win32 smoke fixtures (PE32+)duetos.iso— supported hybrid ISO: GRUB + Multiboot2 on SeaBIOS or UEFI firmware
cmake --preset x86_64-debug # Kernel + userland, debug (UBSAN + ASAN-equiv on)
cmake --preset x86_64-release # Kernel + userland, release
cmake --preset x86_64-debug-san # Debug + full sanitizer suite (incl. integer family)
cmake --preset x86_64-kasan # Debug + KASAN-equivalent + full auditsPresets live in CMakePresets.json at the repo root. All configure
presets inherit CMAKE_EXPORT_COMPILE_COMMANDS=ON, so each build tree
contains a compile_commands.json database for clangd, clang-tidy, and
other source-indexing tools after configuration.
The default x86_64-debug preset is the maximum-diagnostics build:
every check that can be on without making the kernel unbootable or
drowning its own signal is on. Beyond the build-type defaults (KASSERT,
boot self-tests, lock-order audit, klog compiled down to Trace, KASLR,
GDB server) it adds:
DUETOS_CAP_AUDIT=Full— a trace hook on every cap-gated syscall (not the default every-1024th sample).DUETOS_SHELL_SELFTEST=ON— bakes/etc/selftest.shinto ramfs and auto-sources it on boot, so a headless boot exercises the shell scripting surface and emits grep-able PASS/FAIL markers.-fstack-protector-all(debug-scoped, applied inkernel/CMakeLists.txt) — a stack cookie on every function, not just the-fstack-protector-strongheuristic set. Release keeps-strong(the every-function prologue/epilogue is real per-call overhead the steady-state kernel shouldn't pay).
…plus both sanitizer families, the same way:
DUETOS_ENABLE_UBSAN=ON— every kernel TU is built with-fsanitize=undefined,nullability,float-divide-by-zero-fno-sanitize=function -fno-sanitize-trap=all. The emitted__ubsan_handle_*calls resolve to the in-tree runtime inkernel/diag/ubsan.cpp(one klog WARN + serial line per incident, then execution continues — visibility, not enforcement). Thex86_64-debug-ubsan-trappreset (DUETOS_UBSAN_TRAP=ON) flips this to fail-fast: each check emits aud2instead of a handler call, so the first UB faults into the#UDhandler (→ ring-3 task-kill / ring-0 panic) and no log-and-continue runtime is consulted. Use trap mode as a CI / on-hardware gate where "stop at the first UB" beats "log them all"; use the default log mode to enumerate every site in one boot.DUETOS_KASAN=ON— the in-tree ASAN-equivalent diagnostics (heap trailer canaries, freed-payload / freed-page poison, plus the+kasanboot banner). Real-fsanitize=address/-fsanitize=kernel-addresscannot run in this freestandingx86_64-unknown-elfkernel (no shadow memory, no host-style runtime; clang rejects the flag for the target), so the in-tree layer is the ASan stand-in. TSan / MSan are infeasible for the same reason and are not provided.
Dedicated single-axis presets mirror this:
| Preset | What it adds over x86_64-debug |
|---|---|
x86_64-debug-ubsan |
re-asserts DUETOS_ENABLE_UBSAN only (log-and-continue) |
x86_64-debug-ubsan-trap |
UBSan in trap mode (DUETOS_UBSAN_TRAP): first UB → ud2 → #UD, no log runtime — fail-fast gate |
x86_64-debug-asan |
re-asserts DUETOS_KASAN only |
x86_64-debug-san |
the full suite: -fsanitize=integer family on (DUETOS_ENABLE_UBSAN_INTEGER), KASAN, lock-order audit, full cap audit |
x86_64-debug-conv |
DUETOS_ENABLE_CONVERSION_AUDIT=ON — -Wconversion/-Wsign-conversion as non-fatal warnings (compile-time analogue of the integer sanitizer; see the conversion-audit note below) |
x86_64-debug-redteam |
DUETOS_ATTACK_SIM=ON — runs the AttackSim red-team suite at end of kernel_main. Escalates the security guard to Enforce and the block write-guard to Deny, which poisons every subsequent image-load / sensitive-LBA write for the session — not a normal-boot build. |
Knobs deliberately not in the default debug preset, because they make the build unbootable or unusable rather than more-checked:
- The integer family (
unsigned-integer-overflow,implicit-conversion, …). The kernel deliberately relies on unsigned wraparound; on the crypto paths (blake2b,argon2id, …) this is thousands of false-positive incidents per boot — enough to prevent the boot from completing inside the QEMU smoke window. Lives inx86_64-debug-sanfor targeted conversion/truncation hunts. -Wconversion/-Wsign-conversionas a build-floor gate. Same reason as the integer sanitizer above: the kernel narrows deliberately and pervasively (network header fields areu16, lengths live inu32, ring indices wrap), so ~150 sites are intentional, not bugs. A permanent-Werrorgate would impose explicit-cast friction on every future net/fs line for almost no steady-state signal. Instead they are an opt-in compile-time audit (x86_64-debug-conv/DUETOS_ENABLE_CONVERSION_AUDIT): the flags surface as non-fatal warnings so one build lists every narrowing at once, and you eyeball the list for the dangerous cases — an oversize length truncated tou32, a negative reaching an unsignedsize. (The first audit found zero such bugs; the fallout was entirely intentional narrowing and bounds-checked decoder paths.)DUETOS_KLOG_DEFAULT=0(Trace runtime default). The compile floor is already Trace in debug, sologlevel tat runtime exposes every trace site on demand; making Trace the boot default emits ~80 k lines before steady state and the smoke never finishes. Verbosity is not a check.DUETOS_PANIC_DEMO/CANARY_DEMO/TRAP_DEMO/GDB_DEMO— deliberate-crash injectors that panic/halt at end ofkernel_main; driven per-invocation by theirtools/debug/test-*.shscripts, not a preset.DUETOS_ATTACK_SIM— seex86_64-debug-redteamabove.
The x86_64-kasan preset is the heavier forensic variant (KASAN +
UBSAN + lock-order audit + full capability-gate audit);
x86_64-debug-kasan remains as a compatibility alias.
Two repository-owned scripts cover the checks contributors most often need before opening a PR:
# Read-only dependency check for the compiler, linker, PE fixture,
# ISO, and optional QEMU smoke-test toolchains.
tools/dev/doctor.sh --build
tools/dev/doctor.sh --live
# Local CI-style gate. Defaults to doctor + wiki checks +
# clang-format dry-run + CMake configure. Expensive steps are opt-in.
tools/dev/check-local.sh
tools/dev/check-local.sh --build --ctest
tools/dev/check-local.sh --allUse --preset <name> with check-local.sh to validate one of the
non-default presets from CMakePresets.json. --all includes the QEMU
smoke harness, so it needs the live-test packages listed below.
cmake --build build/x86_64-debug --parallel $(nproc)
cmake --build build/x86_64-release --parallel $(nproc)Output trees:
build/x86_64-debug/build/x86_64-release/
- Clang 18+ (compiler)
- CMake 3.25+ (build system)
- lld (linker, preferred —
-fuse-ld=lld) - GNU assembler via clang for
.Sfiles (Intel syntax) - NASM 2.16+ if/when hand-written boot ASM lands; not required today
- MinGW-w64 x86_64 GCC (
x86_64-w64-mingw32-gcc) — optional. Used byuserland/apps/build-smokes.shto compile the Win32 smoke PE fixtures that CMake embeds into the kernel image. If it isn't on the host, CMake detects this at configure time, prints a STATUS message naming the install command, and emits each smoke-PE header as a_len = 0stub viatools/build/embed-blob.py --empty. The kernel'sSpawnPeFilecheckspe_len == 0and skips zero-length blobs, so missing fixtures don't break the build or the boot path; the smoke PEs simply don't run. Install withsudo apt-get install -y gcc-mingw-w64-x86-64and re-run CMake configure (orcmake --build --fresh) to pick it up. - Rust via rustup nightly pinned in
rust-toolchain.toml(when Rust subsystems land — see Roadmap > Rust bring-up)
The kernel image builds -Werror with a floor that goes well beyond
-Wall -Wextra -Wpedantic -Wshadow (defined in
cmake/toolchains/x86_64-kernel.cmake). The additions are the warnings
that catch freestanding-kernel mistakes the base set misses — where
the wrong cast / promotion / stack shape is a fault on real hardware,
not a lint nit:
- FP tripwires —
-Wdouble-promotion -Wfloat-equal. The kernel builds-mgeneral-regs-only -mno-sse, so any float in codegen is a latent#UD/ corrupted-FPU-state bug. - Memory / cast hygiene —
-Wcast-qual(const/volatile drop — a volatile-drop silently breaks MMIO ordering),-Wold-style-cast(C++ casts only, so the cast's intent is explicit),-Wpointer-arith,-Wover-aligned,-Wnull-dereference,-Wzero-as-null-pointer-constant. - Stack safety —
-Wvla(a runtime-sized stack array on our small fixed kernel / IRQ stacks is a stack-overflow → triple-fault). - Control-flow / init —
-Wconditional-uninitialized,-Wimplicit-fallthrough(force[[fallthrough]]),-Wundef(typo'd config macro evaluating to 0). - Surface / format —
-Wmissing-declarations(link-surface drift),-Wformat=2,-Wcomma,-Wextra-semi,-Wnon-virtual-dtor,-Woverloaded-virtual. -Wthread-safety— clang's lock-capability analysis. Inert until headers carryGUARDED_BY/REQUIRESannotations, but on now so the enforcement lands the moment they do.
Intentionally omitted: -Walloca and -Wredundant-decls (clang
no-ops for this target), -Wcast-align (never fires on x86_64 — legal
unaligned access; revisit for the aarch64 tier), and the conversion
family (opt-in audit, see Presets above).
-Wconversion/-Wsign-conversion already gate the host tests
(tests/host) and tools/, where the code is ordinary rather than
hardware-narrowing.
The dev host does not ship with qemu-system-x86_64,
grub-mkrescue, xorriso, mtools, or ovmf. Build-clean is the
only signal available until they are installed.
If a task legitimately requires a live-boot smoke test, install the packages before proceeding:
sudo apt-get update
sudo apt-get install -y \
qemu-system-x86 grub-common grub-pc-bin grub-efi-amd64-bin \
xorriso mtools ovmfCounts as "legitimately requires":
- The commit introduces or changes an observable runtime behaviour (scheduler ordering, new syscall return codes, new boot-log line, new trap path, new sandbox-policy refusal).
- The commit claims end-to-end correctness for a path that a compile-time check cannot prove (address-space isolation, TLB shootdown, IRQ routing, timer drift, PE-image execution).
- A previous slice's runtime claim has never been verified on this host and the new slice depends on it.
Does not count:
- Pure refactors with no behavioural delta.
- Docs / CLAUDE.md /
wiki/changes only. - Code that compiles but is not yet wired into any live path.
DUETOS_TIMEOUT=30 tools/qemu/run.sh build/x86_64-debug/duetos.isoDUETOS_QMP=1 (default) exposes a QMP control socket at
build/<preset>/qmp.sock — orthogonal to the serial log and the GDB
transport. Inspect or nudge a running guest with
tools/qemu/qmp.sh status | screenshot <out.ppm> | quit. Set
DUETOS_QMP=0 to omit it.
See QEMU Smoke Tests for the smoke harness (including
the boot-observability phase ladder, structured [boot-report], and
hierarchical exit codes) and
Getting Started for the
end-to-end build + boot flow.
cd build/x86_64-debug && ctest --output-on-failure && cd -Hosted unit tests live under tests/. The on-target self-tests run
during the QEMU smoke boot.
tests/host/CMakeLists.txt also registers the compiler-free static
gates from tools/test/ and tools/build/ as ctest entries, so
ctest is the one entrypoint for both kinds of check. Among them
include_tracked (tools/test/include-tracked-audit.py) fails when a
tracked source #includes a file that exists in the working tree but
is missing from git ls-files — the shape a targeted-path commit
produces when it forgets a brand-new header. It compiles fine locally
and breaks every clean checkout, so it is worth catching before the
push, not in CI.
When a build tree is not available — low-memory host, missing WSL
toolchain, or a fleet-preflight STOP — the tools/test/test-*.py
contract gates still run standalone, because they are static source
checks rather than compiled tests:
python tools/test/run-contract-tests.py # all gates
python tools/test/run-contract-tests.py --pattern arp # one subsystemThe runner defaults to a 420s per-test timeout on purpose:
test-parallel-claim-safety.py drives real git repositories through
subprocess and legitimately takes ~240s on a loaded host, so a short
timeout misreports a passing suite as a hang. Pass --jsonl <path> to
capture per-test timings.
CI is wired in .github/workflows/:
build.ymlruns format + debug/release builds and CI smoke checks. Itspublish-rollingjob is the sole automatic publisher entered by a qualifying push tomain(documentation-only pushes are path-ignored). Publication waits for format, Rust, debug, release, QEMU smoke, hosted tests, and the pre-publish lifetime-download snapshot before replacinglatest-debugandlatest-release.release.ymlis the intentional promotion path. It runs only forv*tag pushes or manual dispatch, builds and smoke-gates the release assets, and can refreshlatest-debug,latest-release, andlatest-flavors. A push tomaincannot enter this workflow, so it cannot racebuild.yml's rolling publisher.lifetime-downloads.ymlmaintains a cumulative download tally on astatsbranch that the README's "lifetime downloads" badge reads via shields.io'sendpointtype. Without it, the badge resets to zero every time CI republishes a rolling channel, becausesoftprops/action-gh-release@v2 overwrite_files: truedeletes the old asset object and uploads a fresh one withdownload_count = 0. The workflow runs before each publish to fold accumulated downloads into the tally, plus on a 30-minute schedule for organic downloads.
Tag and manual promotions must identify an immutable source. A v* tag must
already be protected and treated as immutable; a manual source_ref must be a
full commit SHA or an equivalently protected immutable version tag, never a
moving branch such as main. release.yml currently accepts an arbitrary ref
string (and retains its legacy main UI default), so this is an operator rule,
not yet an enforced provenance gate.
See Architecture Overview > CI topology.
| CMake option | Default | What it does |
|---|---|---|
DUETOS_INSTALLER_KERNEL_EMBED |
OFF |
Embed the stage-1 duetos-kernel.elf bytes into stage 2 via .incbin so the disk-installer's install <handle> INSTALL writes a real /system/boot/duetos-kernel.elf onto the freshly-formatted system partition. Cost: doubles the kernel binary size (~10 MiB → ~21 MiB on debug); ISO grows from ~18 MiB to ~28 MiB. Boot caveat: the larger kernel image consumes most of the 0..16 MiB DMA zone, currently tripping the mm/zone boot self-test. Closing that needs a linker-script change to place the blob at a higher physical region (e.g. 32 MiB+) — separate slice. Until then the option is "build-only" (image lays down, but the resulting kernel doesn't boot itself; the bytes are correct for an installer that targets a different machine).Build with: cmake -DDUETOS_INSTALLER_KERNEL_EMBED=ON --preset x86_64-debug. |
Effective speedups in current use:
-DCMAKE_C_COMPILER_LAUNCHER=ccache+-DCMAKE_CXX_COMPILER_LAUNCHER=ccachefor incremental rebuilds.-fuse-ld=lldas the linker;lldis ~2×ld.bfdon the full kernel link.- Parallel build with
--parallel $(nproc). - clang-format:
find kernel drivers subsystems userland \( -name '*.h' -o -name '*.hpp' -o -name '*.c' -o -name '*.cpp' \) | xargs clang-format -ifor the bulk format pass; CI runs--dry-run --Werrorover the same set to enforce.