Native compilation

Five targets, cross-compiled from any host to any of them:

--target= format
macos-aarch64 Mach-O
linux-aarch64 ELF
linux-x64 ELF
windows-x64 PE32+
windows-arm64 PE32+

Executables are position-independent (ELF ET_DYN / PIE, matching Mach-O). --freestanding drops the embedded startup runtime; the program supplies the entry (__c5_entry, #pragma entrypoint or --entry). On Linux such an image is placed at its link address (ET_EXEC), since nothing in it applies load-time relocations, and carries no interpreter and no dynamic section unless it binds a shared-library symbol, which the driver reports (-Wfreestanding-import). It takes two PT_LOADs, read-execute and read-write, as ld lays out a static executable. EFI images are supported through the PE subsystem selector.

Instruction-set baseline

On x86_64 badc assumes x86-64-v3: AVX, AVX2, FMA3, BMI1, BMI2, LZCNT, MOVBE and F16C on top of SSE4.2 and POPCNT, as in Intel Haswell, AMD Zen and later. On AArch64 it assumes what the Apple M1 implements, less Apple’s own extensions: ARMv8.4-A with FP16, DotProd, FHM, AES, PMULL, SHA3 and SHA512, plus FRINTTS, FlagM2, SB and SSBS from ARMv8.5-A, without BTI, BF16 or I8MM. The emitted code may use any instruction of the set at any optimization level, and a processor that lacks one is not a target. -mno-sse / -mgeneral-regs-only take the floating-point and vector registers out of the set (Hardening and code-model knobs); nothing else narrows it, and the driver refuses -march= and -mtune=. No instruction-set feature macro (__SSE2__, __AVX2__, __ARM_NEON, …) is predefined but __ARM_FEATURE_AES, __ARM_FEATURE_SHA2 and __ARM_FEATURE_CRYPTO, which -mcpu=’s +aes, +sha2 and +crypto modifiers define.

Multiple translation units

A single badc invocation can mix .c sources, .o objects, and .a archives:

badc -c foo.c bar.c               # emits foo.o + bar.o (ELF64 ET_REL, target pinned)
badc -o app foo.o bar.o           # links them into a final binary

badc --ar -o libfoo.a foo.c bar.c # bundles into a SysV ar(5) archive
badc -o app main.c -L. -l foo     # link against libfoo.a, gcc-style

badc ships its own linker – there is no ld / lld / link.exe dependency. Object files are standard ELF64 ET_REL relocatables: a .text section of native machine code, .data / .bss for static storage, .symtab / .strtab for the name table, and .rela.text carrying the relocations the linker applies once each unit’s final position is known. The target is pinned at -c time, and the objects are also linkable by ld / lld. The address of – or a data load from – an external symbol routes through the GOT, so a badc -c object links into a PIE produced by the system toolchain. Archives are ar(5) with a SysV-style symbol index.

The full cargo feature gates the entire pipeline; library consumers that do not need multi-TU artifacts can opt out via default-features = false, features = ["std"].

-l<name> is resolved in the -L directories, then in the standard library directories under --sysroot=<dir> (usr/lib, lib, their 64-bit and multiarch variants on ELF; usr/lib and usr/local/lib on Mach-O). No other directory is searched, on a native link too: the host’s /usr/lib is an input only when the command names it (--sysroot=/), so one command emits one image on every host. The same root supplies the system headers the bundled set lacks (<zlib.h>), probed after the bundled headers so a standard header keeps the embedded copy. For a Mach-O target $SDKROOT is the default sysroot, as for the platform’s own tools; --sysroot= with no directory withdraws it.

Storage-class linkage follows C99 6.2.2: static at file scope is internal, bare or extern declarations are external, and extern T x; with no defining declaration becomes an unresolved external that the linker tries to satisfy from the remaining objects or archive members.

Linking

The linker also takes GNU-ld-shaped work directly: linker scripts (-T / --script), --emit-relocs, -z keywords, --build-id, link maps (-Map, --print-map), --whole-archive spans, and symbol-export control (--export-all, --export-data). Invoked as ld, ld.badc, or with --ld, badc presents a GNU ld persona with its own flag table, which is what lets it stand in for LD= in an existing build – including the Linux kernel’s.

What is supported

c5 covers most of C99, the C11 and C23 features real code gates on, and a wide GCC extension surface. std-conformance.md enumerates the rejected idioms, the divergent behavior, and the c5-only extensions.

From the preprocessor side

The preprocessor predefines a standard set, double-underscore wrapped in the gcc / clang / msvc convention so it does not collide with user identifiers:

    __BADC_VERSION__   <crate version>   // string literal from Cargo.toml, e.g. "0.4.2"
    __BADC_TARGET__    "macos-aarch64"   // canonical target id (string literal)
    __aarch64__ / __arm64__              // AArch64 targets
    __x86_64__ / __amd64__               // x86_64 targets
    _WIN32 / _WIN64                      // Windows targets only
    __BADC_WINDOWS__                     // Windows targets only
    __APPLE__                            // macOS target only
    __linux__                            // Linux targets only

alongside the C99 / C11 set (__STDC__, __STDC_VERSION__, __SIZEOF_*__, __BYTE_ORDER__, the __ATOMIC_* orders) and, under --gnu, the GCC identity macros. std-conformance.md lists them all. __DATE__ and __TIME__ are the time of translation in UTC, one instant for every unit of an invocation; setting SOURCE_DATE_EPOCH fixes it, so a build that expands either still emits the same bytes on every run.

Comparing the string-literal predefines with #if X == "..." / != is a c5 extension over C99, which restricts a #if controlling expression to an integer constant expression; a string is admitted in no other operand position (std-conformance.md states the rule).

The MSVC/MinGW mimicry surface (_MSC_VER / __MINGW32__ / __int64 / __declspec / etc.) lives in libc/include/msvc_compat.h and is opted into per translation unit with -include msvc_compat.h.

Headers and bindings

The header tells the compiler which dylibs / shared objects / DLLs the target offers and which local names resolve to which exported symbols:

#pragma dylib(libsystem, "/usr/lib/libSystem.B.dylib")
#pragma binding(libsystem::printf, "_printf")

int printf(char *fmt, ...);

The codegen drives its IAT / .got / DT_NEEDED records from these declarations. When the source calls printf, the parser type-checks the call against the prototype; the codegen looks up the binding to learn that the loader should resolve _printf from libSystem.B.dylib. Switching target swaps the header and the bindings change with it – printf lands on bare printf from libc.so.6 on Linux, printf from msvcrt.dll on Windows.

Validation runs at codegen entry: every intrinsic the program references must have a matching binding for the chosen target. An unused binding describes the surface without pulling in what it names. A library a bundled header declares reaches the image only when an import binds through it, so including <math.h> without calling into it leaves no DT_NEEDED behind, which is what ld --as-needed does. A #pragma dylib in your own source is a load-time dependency and is recorded whether or not a symbol binds through it: it is how a program names a library it reaches only by runtime lookup, such as a framework whose initializer has to run before dlsym or objc_getClass resolves a name.

Source-driven build flags via #pragma

badc uses #pragmas to lighten the command line. Dylib bindings, exports, alignment, the entry-point name, and the Windows subsystem each live next to the code they configure, so the source carries enough context to build with a bare badc <file>.

#pragma once                       // single-inclusion guard for headers.
#pragma dylib(libc, "libc.so.6")   // declare a dylib c5 can bind into.
#pragma binding(libc::sin, "sin")  // map a portable name to its dylib symbol.
#pragma export(my_api)             // promote a function to a shared-object export.
#pragma pack(N) / pop / push       // override the default 8-byte struct alignment.
#pragma entrypoint(WinMain)        // override the default `main` entry point.
#pragma subsystem(windows)         // pick the PE subsystem (console | windows | native | efi_*).

#pragma entrypoint(<name>) lets the source declare a non-main entry without a build-driver flag; the compiler resolves the name through the same symbol-table lookup it uses for main. #pragma subsystem(<kind>) drives the PE optional-header Subsystem byte. The accepted kinds are console (default, IMAGE_SUBSYSTEM_WINDOWS_CUI = 3), windows (IMAGE_SUBSYSTEM_WINDOWS_GUI = 2), native (IMAGE_SUBSYSTEM_NATIVE = 1, with nt / driver as aliases), and the EFI variants efi_application, efi_boot_service_driver, efi_runtime_driver, and efi_rom; cui and gui name console and windows, and every kind is taken in any case and with - for _. --subsystem=<kind> takes the same set through the same lookup and overrides the pragma. With console / windows, entrypoint(WinMain) plus subsystem(windows) is what a Win32 GUI app needs to skip the loader’s auto-attach to a console window. Non-PE targets keep the default and ignore the directive, so the same source builds for every OS.

Unknown #pragmas and unknown preprocessor directives warn rather than failing the build. An #include that resolves through neither the search paths nor the embedded headers is an error, as in gcc / clang; pass -H / --show-includes for the gcc--H-shape resolution trace on stderr.

Reaching beyond the predefined set

If something is not available, declare it yourself, or use runtime linking with dlopen / dlsym (or LoadLibrary / GetProcAddress):

int main() {
    int *h, *fn;
    h = dlopen(0, 2);                  // RTLD_NOW
    fn = dlsym(h, "strlen");
    return fn("hello, world!");        // exits 13
}

dlopen(NULL, RTLD_NOW) returns the calling process’s symbol scope – libc on POSIX, the loaded set on Windows.

What is reachable from each system:

In-process JIT (--jit)

Same encoder and relocations as the AOT path. badc mmaps the result executable, resolves libc through a runtime-built fake GOT, and calls main directly via a transmuted function pointer. Parse, lower and exec happen inside the badc process, with no subprocess and no on-disk binary:

badc --jit tests/fixtures/c/c4.c hello.c       # JIT'd c4 self-hosts hello.c

Five hosts are supported:

host mapping
Linux/aarch64 mmap RW -> mprotect RX, manual dc cvau / ic ivau
Linux/x86_64 mmap RW -> mprotect RX, hardware-coherent I-cache (no-op)
macOS/aarch64 mmap RWX + MAP_JIT, pthread_jit_write_protect_np toggle
Windows/x86_64 VirtualAlloc RW -> VirtualProtect RX, FlushInstructionCache (no-op)
Windows/aarch64 VirtualAlloc RW -> VirtualProtect RX, FlushInstructionCache

libc is bound at JIT time: a writable fake GOT gets one entry per resolved import, and the codegen’s existing GOT relocations are patched against this region. POSIX uses dlopen(NULL, RTLD_NOW) + dlsym to find each symbol in the loaded process; Windows uses LoadLibraryA per declared dylib (kernel32, msvcrt, ws2_32, …) + GetProcAddress. macOS uses Apple’s MAP_JIT plus the per-thread W^X toggle the hardware requires on Apple Silicon.

Optimizations

The codegen always lowers through an SSA intermediate representation and a graph-coloring register allocator. A handful of cheap rewrites run unconditionally; --optimize adds a set of SSA passes on top.

Always on: drop self-movs and fuse compare + branch into cmp / b.cond (or cmp / jcc) without materializing a 0/1 boolean in between. The register allocator builds an interference graph over phi-congruence classes and colors it greedily, spilling to frame slots only under pressure.

--optimize (-O, and the -O1/-O2/-O3/-Os/-Oz/-Ofast/-Og spellings, which all select the same single level) runs mem2reg, inlining, rotate and branch const-folding, and immediate dedup, and predefines NDEBUG=1 and __OPTIMIZE__=1.

-O contracts a*b+c into one fused multiply-add on both architectures, an instruction of the baseline.

examples/bench.rs runs a few pure-computation workloads (fib32, quicksort-50k, matmul-50) through the VM and the in-process JIT and reports per-iteration timings:

cargo run --release --example bench -- --iter 10

Assembly and SSA snapshots of the test fixtures live under tests/snapshots/, where a codegen change shows up as a reviewable diff.

Hardening and code-model knobs

For kernel and firmware work the driver accepts the shapes those builds require: -mcmodel=small|kernel|tiny, -mno-sse / -mgeneral-regs-only (keep codegen off the FP/SIMD register file), -mstrict-align, -fPIC/-fpic/-fPIE/-fpie, -mindirect-branch= and -mfunction-return= (retpolines), -mharden-sls=, -fcf-protection=branch (endbr64), -mbranch-protection=none|bti|pac-ret|standard, and the stack-protector family below. Options badc does not implement are rejected rather than accepted and ignored, so a configure-time probe gets a truthful answer.

-ffixed-REG keeps a register out of the allocator, so no compiler-chosen value lives in it – what the kernel asks for the shadow-call-stack register (-ffixed-x18) and for the NEON registers a unit keeps guest or caller state in (-ffixed-q16 .. -ffixed-q31). Every architectural spelling names the register (x9 / w9, q16 / v16 / d16 / s16, rax / eax / ax / al, r8 / r8d / r8w / r8b, xmm5). The ABI still passes arguments and results through a reserved register, and an inline-asm operand, clobber or register variable may still name it; the code generator’s own scratch picks avoid it, and an FP scratch it would have used moves to another register outside the allocator’s banks, into the callee-saved bank’s tail when none is left there. The stack and frame pointers, the AArch64 link register and the scratch registers the code generator cannot give up (x16, x17, x19; r10, r11) are refused, as is a reservation that leaves a function with floating-point work no scratch register at all.

-fstack-protector, -fstack-protector-strong and -fstack-protector-all select which functions carry a stack canary; -fno-stack-protector (the default) selects none. The per-function rule is gcc’s, read off the declared automatic objects: a character array of at least --param ssp-buffer-size= bytes (default 8) or a call to alloca for the plain form, plus any array, any aggregate with an array member, and any object whose address the body takes for strong. The canary occupies the frame’s topmost slot, between the locals and the saved return address; the prologue stores the guard there and every return path – including a tail call’s teardown – reloads it, compares, and calls __stack_chk_fail on a mismatch.

-mstack-protector-guard=global|tls|sysreg says where the guard value comes from, with -mstack-protector-guard-reg=, -mstack-protector-guard-offset= and -mstack-protector-guard-symbol= as its operands. The default follows the target: %fs:0x28 on Linux/x86-64, the C library’s __stack_chk_guard object elsewhere. tls is the x86-64 segment-relative form the kernel selects (-mstack-protector-guard=tls, -mstack-protector-guard-reg=gs, -mstack-protector-guard-symbol=__ref_stack_chk_guard); sysreg is the aarch64 form that reads a per-task offset above a system register (-mstack-protector-guard-reg=sp_el0, -mstack-protector-guard-offset=N). The family needs relocatable output – the failure branch is a relocation against __stack_chk_fail – so --jit and --interp reject it, as do the Windows targets, whose C library exports neither symbol.

-ftrivial-auto-var-init=uninitialized|zero|pattern (the kernel’s CONFIG_INIT_STACK_ALL_ZERO passes zero) initializes every automatic object declared without an initializer – scalars, aggregates, arrays and variable-length arrays – where its storage is established: the value is supplied in the front end as an ordinary initializer, so every output mode carries it and the -O promotion treats it as any written one. pattern stores the byte 0xFE gcc stores. A scalar that fits a register takes the value as a literal of its own type; anything wider is filled byte-wise, unrolled within the inline bound and as a store loop past it, and a variable-length array’s loop follows its allocation. __attribute__((uninitialized)) opts an object out, as does binding it to a register with asm; a declaration a goto or switch jumps past is not covered, as in gcc. -fzero-init-padding-bits=standard|unions|all is accepted with every value and changes nothing: an automatic aggregate initializer already zero-fills the whole object, padding included, before it stores the members, for structs and unions alike.

-Wframe-larger-than=<n> reports a function whose stack frame exceeds n bytes: what the prologue reserves below the return address, saved registers and frame record included, alloca and variable-length arrays excluded. The report breaks the size down by region: locals, spill slots, saved registers, inline-asm scratch, an over-aligned region, the canary and the frame record. It is the frame-larger-than row, so -Werror= makes it fatal and -Wno- silences it, and n takes gcc’s byte-size suffixes (kB, KiB, MB, MiB, …). Without the option no bound applies, as in gcc and clang; the kernel passes CONFIG_FRAME_WARN through it.

pac-ret signs the return address of every function that stores the link register: paciasp ahead of the prologue, autiasp after the last teardown instruction of each epilogue, where sp – the signing modifier – holds its function-entry value again. A frameless leaf stores no link register and is left alone. standard is bti+pac-ret; a signed function opens with paciasp, which is itself a landing pad for the branch types a function entry is reached with, so it takes no separate BTI C. An aarch64 object built with either claims the matching bits in a .note.gnu.property GNU_PROPERTY_AARCH64_FEATURE_1_AND word. The linker intersects that word across inputs, so the compiler sets a bit only where it emitted the instructions.

^ To the top