badc compiles every C translation unit of a Linux 7.1.10 defconfig kernel on
x86_64 and aarch64, links it, and the result boots. The harness is
demos/linux/, whose
README documents every script, flag and pin.
Compile. Every kernel C unit of the architecture’s own defconfig is
badc’s, with zero fallbacks to another compiler. Two unit counts circulate at
the 7.1.10 pin, and they differ only in which make targets were built:
| build | x86_64 | aarch64 | measured by |
|---|---|---|---|
kernel image (bzImage / Image, vmlinux included, no modules) |
2921 | 4434 | verify.py |
kernel plus modules ( ... modules) |
2953 | 10489 | packages.py |
Both come from the same counter – one manifest line per successful badc
compile of a kernel C unit – so the deltas are exactly the module corpus.
x86_64 defconfig builds virtio, ext4 and most of the rest =y and aarch64
builds them =m, which is why the aarch64 count more than doubles while the
x86_64 count moves by 32.
A C unit badc cannot compile fails the build rather than being handed to gcc:
buildcc.py, the CC= shim, removes the partial object and exits nonzero. The
one route to another compiler is $BADC_FALLBACK, which names units explicitly
and marks the build impure in the manifest; the gate fails when that count is
nonzero. Assembly units take the same route and do not use it either.
Boot. Both kernels boot under qemu, checked by more than reaching
userspace. The initramfs /init prints a marker, then mounts procfs and sysfs
and asserts the contents of a fixed set of files, reading them in 64-byte
requests and once more from a non-zero offset, so a seq_file that replays
records rather than continuing is caught. Only then does it print the second
marker. Each boot’s Linux version banner – the text /proc/version serves
– must name badc, which pins the claim in the booted kernel rather than in the
configuration. One more boot carries the marker archive followed by 250 MB
compressed with the method the configuration decompresses – zstd at
defconfig – and the time the kernel spends unpacking it, read from the
console’s timestamps, is held to a bound: the marker image unpacks in a
fraction of a second, too little for a decompressor regression to show.
Nested KVM. On request (verify.py --nested-kvm, the local boxes’
validate_local_boxes.py --nested-kvm) one more boot runs under the host’s
KVM with its CPU model, and the badc kernel is the hypervisor: its initramfs
carries the badc-built qemu the qemu demo produces with its libraries and
ROM set, this build’s KVM modules, the kernel image itself and the marker
initramfs, and /init loads the modules and runs the emulator on the image
under the kernel’s own KVM once it has reported the virtualization
extension /proc/cpuinfo lists and that /dev/kvm opened. The guest’s
console arrives between bracket lines on the outer one, and both boots are
held to the marker checks. The step is skipped, not passed, where the host,
the emulator or the CPU model offers no nesting – the aarch64 box’s KVM
does not – and on x86_64 the build makes KVM as modules, which defconfig
leaves out.
Relocated output. The aarch64 gate boots at pinned KASLR displacements: it writes seeds into the machine’s own device tree and boots against the result, covering a fixed set of displacements plus one drawn per run. x86_64 derives its displacement from RDRAND, the TSC and the i8254 counter, so nothing there can be pinned and those boots run unpinned. A displacement probe runs first and the gate fails if a randomizing configuration produced no displaced boot.
Modules. All defconfig modules build and load, with per-module verdicts
identical to a gcc-built kernel.
Link. LD=badc links the kernel through kbuild’s own link-vmlinux.sh on
both architectures, and those kernels boot. kallsyms converges in two passes.
On x86 the KASLR relocation table built from badc’s --emit-relocs output is
byte-identical to GNU ld’s. badc’s --version reports
GNU ld (badc <version>) 2.30, which scripts/ld-version.sh reads as a BFD
linker at the kernel’s own floor and deliberately no higher.
LD=badc leaves nothing to GNU ld: the -r merges, every vmlinux kallsyms
pass, the x86 boot decompressor, all three vDSOs, the -m elf_i386 boot links
(arch/x86/boot/setup.elf, arch/x86/realmode/rm/realmode.elf, vdso32) and
the scriptless probes are badc’s.
The i386 links read ELF32 EM_386 relocatables whose relocations are SHT_REL
– the addend lives in the field being relocated rather than in the relocation
record – and write an ELF32 image. The reader materializes each implicit
addend from the input bytes at the field’s own width, so the same relocation
engine covers both formats; the writer emits Elf32_Ehdr/Phdr/Shdr,
16-byte Elf32_Sym, 8-byte .dynamic entries and 32-bit .gnu.hash Bloom
words. --emit-relocs writes back in the target’s own format, so
realmode.elf carries .rel.<section> for arch/x86/tools/relocs to read.
The vDSOs are shared objects, so badc emits the dynamic-linking metadata a
loader searches – .dynsym/.dynstr, .hash and .gnu.hash per
--hash-style, .gnu.version/.gnu.version_d for the version the symbols are
exported under, and a .dynamic with DT_SONAME, the table addresses and the
REL/RELA/RELR tags – plus PT_DYNAMIC. A link with no -T runs under a
built-in default script per output kind, as GNU ld falls back on its internal
one, which is what scripts/tools-support-relr.sh probes with.
Assembly. All of it. badc -c foo.S -o foo.o assembles a unit
directly, and kbuild routes .S through $(CC), so buildcc.py decides each
assembly unit the same way it decides a C one. Over the four 7.1.10
distribution configurations gas assembles nothing:
| package | configuration | assembly units | badc | gas |
|---|---|---|---|---|
| rpm, x86_64 | Fedora 44 | 141 | 141 | 0 |
| rpm, aarch64 | Fedora 44 | 103 | 103 | 0 |
| deb, x86_64 | Ubuntu 26.04 | 129 | 129 | 0 |
| deb, aarch64 | Ubuntu 26.04 | 94 | 94 | 0 |
measured with the fallback lists empty. The last two holdouts were the GFNI affine instructions the three ARIA ciphers spell, and on aarch64 a lane-indexed register list, the widening multiply by element and the narrowing shift right, which one crypto unit needed together.
An assembly unit built -m16 or -m32 gets an ELFCLASS32 / EM_386 object:
Elf32_Ehdr / Shdr / Sym widths, SHT_REL tables named .rel<section>
whose addends ride in the field each relocation patches, and the R_386_*
numbering. The class also picks the assembler’s starting code mode, the way
as --32 does for either spelling; .code16 / .code32 move it from there.
badc generates no i386 machine code, so a C source under either is refused by
name and only the assembler reaches the 32-bit container.
Against GNU as 2.46.1’s object for the same source, byte for byte over every
allocatable section plus the
symbol table and the relocations, all 72 of the .S-derived objects the
7.1.10 defconfig builds under arch/arm64/ and lib/crypto/arm64/ are
identical, setting aside the DWARF badc emits none of for an assembled unit
and the AArch64 $x / $d mapping symbols. The last object to differ did so
by a single padding word, because a literal pool was keyed by section where
GNU as keys it by section and subsection.
One class of difference remains on x86_64: a branch to a named label in the
same section always takes the wide displacement, where GNU as relaxes it to
rel8. The bytes execute the same; the sections are longer.
Distribution. The kernel packages as a .deb and an .rpm with the
kernel’s own bindeb-pkg / binrpm-pkg targets, installs into stock Debian
13, Ubuntu 26.04 and Fedora 44 cloud images under qemu, and reboots into
systemd multi-user with udev-bound devices and modules autoloading on demand.
The system disk and the NIC ride on the storage controller and NIC model the
run selects – the paravirtual pair by default, or an emulated NVMe, AHCI or
SCSI controller with an e1000e / rtl8139-class NIC, whose drivers the booted
kernel then has to bring up – and the run asserts the booted kernel drives
the root disk and the NIC with those models’ drivers. The guests boot under
EFI firmware – OVMF on x86_64 q35, AAVMF on aarch64 virt – which is what
the machines these packages target boot with; --vm-firmware selects it and
falls back to SeaBIOS on x86_64 where no firmware is installed.
/proc/version names badc. The run also checks an untainted kernel, a clean
dmesg, and disk/network I/O, against a baseline taken from the image’s stock
kernel in the same userspace.
The configuration is the distribution’s, not defconfig: each pinned cloud
image’s own /boot/config-$(uname -r), mirrored on the vendor-deps release
under a pinned sha256, so a package build resolves it without booting anything
(packages.py --config vendor). That corpus is what a distribution kernel is,
and it is several times defconfig’s:
| package | configuration | C units | assembly units | links |
|---|---|---|---|---|
| rpm, x86_64 | Fedora 44 | 21701 | 141 | 11535 |
| rpm, aarch64 | Fedora 44 | 23446 | 103 | 13076 |
| deb, x86_64 | Ubuntu 26.04 | 26227 | 129 | 15677 |
| deb, aarch64 | Ubuntu 26.04 | 30555 | 94 | 19136 |
Every count is badc’s, measured with the fallback lists empty: nothing fell
back to another compiler, assembler or linker. All four packages are complete.
The Ubuntu configurations set
CONFIG_BUILTIN_MODULE_RANGES, which reads the map a relocatable link writes;
that path wrote none until the linker was taught to, which is what had held
those two lanes short of a package.
Real hardware. packages.py --phases hw runs the same install, one-shot
selection, boot, probes and exercise stage on a physical machine instead of a
guest: the machine is reached over ssh and its console is read from a serial
port on the host, byte for byte, so the same scanners judge both. What a
driver has to bind to comes from the machine’s own baseline rather than from
an emulated model, and the machine’s identity – board, BIOS, CPU, firmware
mode – is recorded next to the verdict, so a hardware run is diffable against
a VM run. A box with no remote power cut can only be recovered by its standing
boot default and its watchdog, so the run refuses to start unless that default
is a distribution kernel, selects the kernel under test for exactly one boot,
and after a boot that does not answer reports the stage the console reached and
then waits for the machine to fall back. It is not in CI: it needs a bench
machine.
Two bench machines exist and they differ in what a failure leaves behind. One
has a physical serial port, so a kernel that dies before userspace is still
readable and earlycon covers the window from firmware handoff onward; that
lane is written up in ../demos/linux/micropc-testing.md.
The other has neither a serial port nor a display, so nothing is observable
until the network driver probes: there the console is netconsole over UDP,
the post-mortem is efi_pstore read back on the next boot, and the early
window is dark – recovery rests entirely on the one-shot boot
selection and a bounded panic=. That lane, and the four rollback layers it
needs, are in
../demos/linux/xps8930-testing.md.
Exercised. Booting reaches a few dozen of the several thousand modules a
distribution kernel ships, and reaching userspace says little about whether
the kernel computes correct answers: a cipher that returns wrong ciphertext
leaves taint at 0, logs nothing, and lets systemd come up. So a stage inside
the booted badc kernel drives the rest, and the cheap part of it runs on
every boot rather than on request – socket families, a storage
write-and-read-back, the crypto known-answer sweep, the kunit suites, and a
dmesg scan that fails on anything a driver reports at KERN_ERR or worse. That
set costs about ten seconds against a phase that installs a package and boots
twice. --exercise adds the two expensive steps, module-by-module loading and
the filesystem matrix; --no-exercise-gate opts out of the default set.
The socket-family step is there because of what it caught: a kernel whose
bind(AF_VSOCK, ...) always failed passed every passive check on a
distribution whose init happens not to use that family. socket() succeeding
is not the test – the step binds, listens and reads back.
Every crypto implementation the
kernel registers is checked through AF_ALG by its driver name – so an
arch-optimized path is a subject on its own – against hashlib where the
standard library implements the algorithm and against the generic
implementation registered under the same name otherwise; where the
configuration keeps CONFIG_CRYPTO_SELFTESTS, the registration self-tests and
a tcrypt mode sweep run as well. Every built module is loaded once and
classified, with absent hardware counted apart from a fault, a hang, a missing
symbol or an oops taint bit. Filesystems are built on loop devices over sparse
files at both 512- and 4096-byte logical block sizes, stressed by parallel
write, churn, tree-copy and O_DIRECT jobs, then verified: file content is
checked against digests taken from a tmpfs seed rather than from the
filesystem, after dropping the caches and remounting, and each instance ends
with its own check-only fsck. The matrix includes the checksum-heavy
configurations on purpose – btrfs with crc32c, xxhash, sha256 and blake2,
btrfs with zstd compression, ext4 and xfs with metadata checksums, LUKS2
dm-crypt and md raid1 – because they route file data through the same kernel
crypto and compression code, where a miscompile shows up as corrupted data
rather than as a message in dmesg. The kunit suites run where the
configuration builds them.
Self-host. The optional selfhost phase turns the installed kernel into a
build host: inside the VM running the badc kernel, badc builds the kernel
again. Everything the build needs is staged before the reboot, so the badc
kernel carries the build with no network. What is measured is the kernel, not
the compiler – the kernel log window bracketed by /dev/kmsg markers, taint,
OOM records and core dumps – plus two results only a second build can give:
the in-guest unit outcomes against the host build’s for the units both reached,
and a sample of the objects rebuilt and required to repeat byte for byte. It is
off by default and --selfhost-scope sizes it.
Configuration classification. scripts/cc-version.sh still classifies the
reference compiler, so CONFIG_GCC_VERSION keeps the reference toolchain’s
value. Identification follows the compiler that built the objects;
classification stays with the toolchain whose bug-history gates the corpus was
captured under. badc’s claimed __GNUC__ is 4.3.0, below the kernel’s gcc
floor (8.1.0).
Compiling is not the test; the kernel’s own build and boot are. With
CONFIG_OBJTOOL=y kbuild runs objtool (--orc, jump-label and static-call
rewriting) over every object, the vmlinux linker script asserts an empty
.got, so badc’s GOTPCRELX relocations must relax, and the boot exercises the
asm-emitted metadata sections (__jump_table, .altinstructions,
__ex_table, .smp_locks).
Kernel objects also constrain codegen directly. The shim forwards -mno-sse
and -mgeneral-regs-only, because a linked kernel object must keep off the
FP/SIMD register file, which the kernel runs with trapped and whose callers do
not maintain the System V al convention. It forwards -mstrict-align for
early-boot units running with the MMU off, and -fPIC/-fpic/-fPIE/-fpie
for the EFI-stub island and the boot decompressor, which reject absolute
relocations; those units take badc’s position-independent object form while
every other unit keeps the absolute form whose relocations the ORC pass reads.
It forwards the ftrace patch sites: -fpatchable-function-entry=N,M gives
every function its NOP area, aligned as the function is, and a
__patchable_function_entries record linked to the function’s text section;
on x86_64 -pg -mfentry -mrecord-mcount gives it the call __fentry__ at
the symbol and the __mcount_loc entry, the forms gcc emits.
Every -ffixed-REG reaches badc as written: the shadow-call-stack register
-ffixed-x18 of the aarch64 build, and the -ffixed-q16 .. -ffixed-q31
set a NEON unit passes to keep the compiler off the registers it holds state
in. badc keeps the register out of its allocator and its own scratch picks,
and fails the unit on a name it cannot honour. -Wframe-larger-than=, the
bound CONFIG_FRAME_WARN sets, reaches badc too, so a function whose frame
threatens the kernel stack is reported at the unit that defines it rather
than found on a machine.
sweep.py is the measurement: it replays every C compile of a completed gcc
reference build against badc and buckets what fails by normalized error
signature, producing a ranked work list. It gates nothing.
verify.py is the gate: it builds with no fallback list, links, and boots,
and fails on any unit badc could not compile, any unit that fell back, any
undefined reference, any boot that misses either marker, any banner that does
not name badc, an unpack boot slower than its bound, a linked image whose
largest function or count of functions over 4 KiB exceeds the
architecture’s budget, and on a build that compiled fewer units than
--expect-units
(make skips current objects, so without a floor a tree that rebuilt nothing
would pass while testing nothing).
Because the kernel decides what its code may use by probing the compiler at
configure time, a corpus captured from a gcc build carries gcc’s answers.
probecfg.py re-runs the kernel’s own configuration step with badc as the
probe compiler, so a replayed measurement can reflect badc instead.
CI runs verify.py on every push for both architectures, under an emulator
that the qemu lane itself compiled and linked with badc. The packaging and
install path runs in .github/workflows/kernel-packages.yml on demand and on
a pull request carrying the kernel-packages label – it is off the push path
because GitHub-hosted runners expose no /dev/kvm and the VM would run under
TCG, and it carries no schedule because the corpus is pinned and a repeat run
repeats the previous run’s work.