Native compilation

badc compiles and links for five targets from any host. It carries its own assembler and linker, so a build needs no ld, lld or link.exe. This page covers the targets, object and image formats, the linker, the bundled headers and their library bindings, the instruction-set baseline, optimization, and the code-generation options for kernel and firmware builds.

Contents

Targets

--target= also accepted image data model
macos-aarch64 aarch64-apple-darwin Mach-O LP64
linux-aarch64 aarch64-unknown-linux-gnu ELF LP64
linux-x64 linux-x86-64, x86_64-unknown-linux-gnu ELF LP64
windows-x64 windows-x86-64, x86_64-pc-windows-gnu, x86_64-pc-windows-msvc PE32+ LLP64
windows-arm64 windows-aarch64, aarch64-pc-windows-gnullvm, aarch64-pc-windows-msvc PE32+ LLP64

The default is the host. On linux-x64, -m16 and -m32 assemble an assembly unit into an ELF32 i386 object. badc generates no i386 code, so a C source under either flag is refused unless the command is -E.

Executables are position-independent: an ELF executable is ET_DYN (PIE), and Mach-O and PE images always are. On Linux, -no-pie links the executable at its link address (ET_EXEC), as gcc’s -no-pie does:

The last of -pie / -no-pie wins, and -c and --shared ignore both. -no-pie is refused for a Mach-O or PE target.

--freestanding leaves out the embedded startup runtime. The program defines the entry: __c5_entry by default, or the name #pragma entrypoint or --entry= gives. On Linux the image is linked at its link address (ET_EXEC) in two PT_LOAD segments, read-execute and read-write, as ld lays out a static executable. It has no interpreter and no dynamic section unless it calls a shared-library function. Then it has both, and badc warns (-Wfreestanding-import). -ffreestanding is a compile option only: it sets __STDC_HOSTED__ to 0 and implies -fno-builtin.

An EFI image is a PE image with an EFI subsystem; see Windows subsystems.

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. A processor that lacks one is not a target.

-mno-sse and -mgeneral-regs-only take the floating-point and vector registers out of the set (Hardening and code-model knobs). Nothing else narrows it. The driver refuses -march= and -mtune=.

Feature macros:

Objects, archives and libraries

One badc invocation can mix .c and assembly sources, objects and archives:

badc -c foo.c bar.c               # writes foo.o and bar.o
badc -o app foo.o bar.o           # links them
badc --ar -o libfoo.a foo.c bar.c # compiles and archives, with a symbol index
badc -o app main.c -L. -lfoo      # links against libfoo.a

-c writes one object per source; -o names it only when there is one source. For every target the object is an ELF64 ET_REL file: machine code in .text, static storage in .data and .bss, a symbol table, .rela.* relocations, and a .note.badc section of records badc’s linker reads. The target is fixed at compile time. A Linux object also links with GNU ld or lld. The address of an external symbol, and a data load from one, go through the GOT, so the object links into a PIE the system toolchain builds.

The linker also reads ELF objects from other compilers, Mach-O MH_OBJECT files, PE/COFF objects and import libraries, ar archives, and shared libraries (.so, .dylib, .tbd, .dll). Archives are ar(5) with a SysV symbol index. Named ar or *-ar, or run as badc --ar <key>, badc is a POSIX ar; named ranlib it indexes archives.

-l<name> is searched in the -L directories, in order, then in the standard directories under --sysroot=<dir>:

image directories under the sysroot
ELF usr/lib64, lib64, usr/lib, lib, usr/lib/<arch>-linux-gnu, lib/<arch>-linux-gnu
Mach-O usr/lib, usr/local/lib
PE lib, usr/lib

No other directory is read, on a native link too. The host’s /usr/lib is an input only when the command names it (--sysroot=/), so one command builds the same image on every host. The sysroot’s include directories supply the headers the bundled set lacks (<zlib.h>). They are searched after the bundled headers, so a standard header keeps the bundled copy. For a Mach-O target $SDKROOT is the default sysroot, as for Apple’s tools; --sysroot= with no directory withdraws it.

Linkage follows C99 6.2.2. A file-scope static is internal; any other file-scope declaration is external. extern T x; with no definition in the unit is an undefined reference that the other objects or the archives must resolve.

The badc binary needs the full cargo feature: native output (native-emit) plus the linker. The library’s default feature set is std, which has the compiler and the in-process JIT but no native output; see library-and-no-std.md.

Linking

The driver passes link options with -Wl, and -Xlinker. It takes GNU ld’s linker scripts (-T / --script, with .o and .a inputs only), --emit-relocs, -z keywords, --build-id, link maps (-Map, --print-map), --whole-archive spans, --start-group / --end-group, and export control (--export-all, --export-data). --help lists the image options. A link option badc does not implement is refused by name.

Invoked as ld, ld.badc or <prefix>-ld, or as badc --ld, badc takes GNU ld’s command line with its own flag table. That is how it serves as LD= in an existing build, including the Linux kernel’s. Its --version prints GNU ld (badc <version>) 2.33.1.

As in GNU ld, an input whose .note.GNU-stack is executable makes the stack executable, with a warning (-Wexecstack), unless -z noexecstack is given.

Inputs enter the link in command-line order, after the startup runtime, where gcc puts crt1.o. Constructors of one priority run in that order, and the first of two weak definitions wins. An ELF link takes archives as GNU ld does:

A Mach-O or PE link searches every archive until nothing changes, as ld64 and link.exe do.

Undefined and weak references

Each image format follows its own system linker:

image executable shared library weak reference no input defines
ELF error load-time import, as GNU ld’s -shared; an error under --no-undefined / -z defs 0
Mach-O error error, as ld64’s default -undefined error error
PE error error: a PE import must name its DLL 0

On Mach-O, -undefined dynamic_lookup, suppress or warning (also as -Wl,-undefined,<value>) turns such a reference into a flat-namespace lookup at load time, in an executable or a dylib. warning also reports each one (-Wundefined-lookup). An ELF or PE link refuses -undefined. A Mach-O import that only weak references reach binds as a weak import: its address is 0 when the loaded dylib lacks the name. __attribute__((weak_import)) is weak on Mach-O and ignored elsewhere, as gcc ignores it.

Unwind tables

Language and predefined macros

badc compiles C17 by default, with part of C23 and much of the GNU C extension set. std-conformance.md lists the rejected constructs, the divergent behavior and badc’s own extensions.

Target macros:

macro defined for
__BADC_VERSION__ every target; the crate version as a string, e.g. "0.4.8"
__BADC_TARGET__ every target; the target name as a string, e.g. "macos-aarch64"
__aarch64__, __arm64__ AArch64 targets
__x86_64__, __x86_64, __amd64__, __amd64 x86-64 targets
_WIN32, _WIN64, __BADC_WINDOWS__ Windows targets
__APPLE__ macOS
__linux__, __ELF__ Linux

The C99 and C11 macros are defined as well (__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__ give the time of translation in UTC, one instant for every unit of an invocation. SOURCE_DATE_EPOCH fixes it, so a build that expands either writes the same bytes on every run.

#if X == "..." and != compare a string-literal macro such as __BADC_TARGET__ with a string. This is an extension: C99 requires an integer constant expression in #if, and badc admits a string in no other operand position.

The MSVC and MinGW compatibility macros and keywords (_MSC_VER, __MINGW32__, __int64, __declspec, …) are in libc/include/msvc_compat.h. A unit opts in with -include msvc_compat.h.

Headers and bindings

A bundled header names the shared libraries the target offers and the symbol each local name binds to:

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

int puts(const char *s);

The prototype type-checks a call. The binding tells the linker to import _puts from libSystem.B.dylib, and badc builds the image’s import records from it. Another target’s headers bind the same name elsewhere: puts from libc.so.6 on Linux and from msvcrt.dll on Windows. badc --dump-bindings --target=<t> lists every binding the bundled headers state for a target.

A binding declares nothing. C99 has no implicit declarations, so a call to an undeclared function is an error (B2022). When the name is a library function a bundled header declares, badc instead includes that header and reports it with an info: line; -fno-builtin and -nostdinc turn this off. A function the program calls that no input defines and no binding names is an undefined reference at link.

A library a bundled header names reaches the image only when an import binds through it. Including <math.h> without calling into it leaves no DT_NEEDED entry, as ld --as-needed would. A #pragma dylib in the program’s own source is always recorded as a load-time dependency. That is how a program names a library it reaches only through runtime lookup, such as a framework whose initializer has to run before dlsym or objc_getClass finds a name.

The Windows C runtime

The Windows targets use msvcrt.dll, as mingw-w64’s default configuration does. Where msvcrt.dll lacks a C99 function or gets one wrong, the bundled headers bind it to ucrtbase.dll (much of <math.h>, for example), or libc/lib/ defines it. The printf family is defined there and formats through ucrtbase.dll. A definition in libc/lib/ joins the link as an archive member would.

A bundled header declares the names mingw-w64’s header of the same name declares. A name mingw-w64 leaves undeclared stays undeclared, so a program’s own Windows fallback for it compiles under badc as under mingw-w64. setenv is one such name, as are fork, getuid and the other POSIX process, user and terminal calls. A POSIX header mingw-w64 does not ship (<termios.h>, <sys/mman.h>, <pwd.h>, …) is not found. Two tests hold this: windows_header_declarations_resolve_at_link walks every bundled header for both Windows targets and fails on a declared function or object that nothing defines, and on a Windows host windows_bindings_name_exports_of_their_dlls checks that each binding names an export of its DLL.

A native-subsystem image cannot load the user-mode C runtime. What the headers bind to msvcrt.dll or ucrtbase.dll, a driver imports from ntoskrnl.exe and a native application from ntdll.dll, by name. A name that module does not export is an undefined reference. The check reads the C runtime names each module exports on x64 and arm64, measured from Windows 11’s system images (src/c5/object/nt_crt.rs). On every PE image, an import library or DLL on the command line that supplies a name takes precedence over the header’s binding, as with link.exe and GNU ld.

Build settings in pragmas

These pragmas keep build settings next to the code, so a source can build with a bare badc <file>:

#pragma once                       // include the header once
#pragma dylib(libc, "libc.so.6")   // name a shared library to bind to
#pragma binding(libc::sin, "sin")  // bind a name to a symbol of that library
#pragma export(my_api)             // export a function from a shared library
#pragma pack(push, 1) / pack(pop)  // set the maximum member alignment
#pragma entrypoint(start)          // enter the program at `start`, not `main`
#pragma subsystem(windows)         // pick the PE subsystem

#pragma entrypoint(<name>) resolves the name as main would be resolved. --entry=<sym> overrides it.

Unknown pragmas and unknown directives warn and are ignored. An #include that neither the search path nor the bundled headers resolve is an error, as in gcc and clang. -H (--show-includes) prints each resolved include on stderr in gcc’s -H format.

Windows subsystems and default libraries

#pragma subsystem(<kind>) and --subsystem=<kind> set the PE optional header’s Subsystem field. The option overrides the pragma. Kinds are taken in any case and with - for _:

kind Subsystem image
console (cui) 3, WINDOWS_CUI console program; the default
windows (gui) 2, WINDOWS_GUI GUI program; the default for a program that defines WinMain or wWinMain, as link.exe infers
native (nt) 1, NATIVE NT-native user-mode program; C runtime from ntdll.dll
driver 1, NATIVE kernel-mode driver, as link.exe /DRIVER builds one: sections not paged; C runtime from ntoskrnl.exe
efi_application 10 EFI application
efi_boot_service_driver 11 EFI boot-service driver
efi_runtime_driver 12 EFI runtime driver
efi_rom 13 EFI ROM image

-mwindows and -mconsole are gcc’s spellings of windows and console. A non-PE target ignores the pragma, so the same source builds for every OS.

A console or windows image links kernel32.dll by default, as link.exe does through the C runtime library’s /DEFAULTLIB:kernel32.lib and the MinGW driver through -lkernel32. A function that no input resolves and kernel32 exports is imported from kernel32.dll, even when the source declares it itself with no header or binding. Only referenced names are imported. The check reads kernel32’s export lists for x64 and arm64, measured from Windows 11’s system images (src/c5/linker/kernel32.exports). A native or EFI image takes none. user32, advapi32 and shell32, which the MinGW driver also passes and link.exe does not, are not default libraries.

Which headers a target has

A bundled header is served on the targets whose C library, SDK or compiler ships it, as libc/header-targets.txt records. <sys/epoll.h> is not found for macOS, and __has_include(<windows.h>) is 0 for Linux, as with the system compilers. The tree --install writes holds every target’s headers and follows the same rule.

Runtime lookup

A function no bundled header declares can be declared in the program, or looked up at run time with dlopen / dlsym (LoadLibrary / GetProcAddress on Windows):

#include <dlfcn.h>
#include <stddef.h>

int main(void) {
    void *h = dlopen(NULL, RTLD_NOW);
    size_t (*len)(const char *) = (size_t (*)(const char *))dlsym(h, "strlen");
    return (int)len("hello, world!");   /* exits 13 */
}

On Linux and macOS, dlopen(NULL, RTLD_NOW) returns the process’s global symbol scope.

In-process JIT (--jit)

--jit compiles for the host with the same encoder and relocations as an image, maps the code executable in the badc process, and calls main. No file is written and no process is started:

badc --jit tests/fixtures/c/c4.c tests/fixtures/c/c4_selfhost_hello.c   # c4 compiles and runs the second file
host mapping
Linux/aarch64 mmap RW, then mprotect RX; dc cvau / ic ivau
Linux/x86_64 mmap RW, then mprotect RX; the I-cache is coherent
macOS/aarch64 mmap RWX with MAP_JIT, pthread_jit_write_protect_np, sys_icache_invalidate
Windows/x86_64 VirtualAlloc RW, then VirtualProtect RX; FlushInstructionCache
Windows/aarch64 VirtualAlloc RW, then VirtualProtect RX; FlushInstructionCache

Library calls go through a writable table of one slot per import, patched before the code runs. POSIX hosts fill it with dlopen(NULL, RTLD_NOW) and dlsym; Windows with LoadLibraryA per declared DLL and GetProcAddress.

Optimizations

Code generation always goes through an SSA form and a graph-coloring register allocator. The allocator colors an interference graph over phi-congruence classes and spills to frame slots only under pressure. Without -O, badc still fuses a compare with the branch that reads it, inlines always_inline functions, and lets locals whose lifetimes do not overlap share a stack slot.

-O (--optimize) adds the SSA passes: promotion of locals to registers, scalar replacement of aggregates, inlining, constant folding and propagation, branch simplification, loop rotation, unrolling of constant-trip loops, common-subexpression elimination, store-to-load forwarding, division by constants, tail-call and tail-recursion elimination, and block layout. It predefines NDEBUG=1 and __OPTIMIZE__=1. -O1, -O2, -O3, -Os, -Oz, -Ofast and -Og select the same single level; -O0 turns it off.

Under -O, a*b+c becomes one fused multiply-add on both architectures, an instruction of the baseline. -ffp-contract= selects where: on (the default) within one expression, off nowhere, fast wherever the product has no other use.

examples/bench.rs times a few computational workloads (fib32, quicksort-50k, matmul-50) under the interpreter and the in-process JIT:

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

Assembly and SSA snapshots of the test fixtures are under tests/snapshots/, where a code-generation change shows up as a diff.

Hardening and code-model knobs

For kernel and firmware builds badc takes these options. An option it does not implement is refused, so a configure-time probe gets a true answer.

option effect
-mcmodel=small the default
-mcmodel=kernel linux-x64 only; implies -fno-pic
-mcmodel=tiny linux-aarch64 only
-mno-sse, -mgeneral-regs-only keep the code off the floating-point and vector registers
-mstrict-align each generated access at most as wide as its operand’s alignment
-fPIC, -fpic, -fPIE, -fpie position-independent -c object
-mindirect-branch=keep\|thunk-extern\|thunk-inline retpoline thunks for indirect branches (x86-64)
-mfunction-return=keep\|thunk-extern return thunk (x86-64)
-mharden-sls=none\|return\|indirect-jmp\|all straight-line-speculation barriers (x86-64)
-fcf-protection=none\|branch endbr64 landing pads (x86-64); return and full are refused
-mbranch-protection=none\|bti\|pac-ret[+leaf]\|standard AArch64 only; see below

On an AArch64 target, badc accepts the four x86-64 options above and the object does not change.

Reserved registers

-ffixed-REG keeps a register out of the allocator, so no compiler-chosen value lives in it: the shadow-call-stack register (-ffixed-x18), or NEON registers a unit keeps state in (-ffixed-q16 .. -ffixed-q31). Any architectural spelling names the register (x9 / w9, q16 / v16 / d16 / s16, rax / eax / ax / al, r8 / r8d / r8w / r8b, xmm5).

Stack protector

option protected functions
-fstack-protector a character array of at least --param ssp-buffer-size= bytes (default 8), or a call to alloca, or a variable-length array
-fstack-protector-strong also any array, any aggregate with an array member, and any automatic object whose address is taken
-fstack-protector-all every function
-fno-stack-protector none; the default

The rule is gcc’s, read off the declared automatic objects. The canary takes the frame’s topmost slot, between the locals and the saved return address. The prologue stores the guard there. Every return path, a tail call’s included, reloads it, compares, and calls __stack_chk_fail on a mismatch.

-mstack-protector-guard=global|tls|sysreg says where the guard comes from, with -mstack-protector-guard-reg=, -mstack-protector-guard-offset= and -mstack-protector-guard-symbol= as operands:

The protector needs a linked image, since the failure path is a relocation against __stack_chk_fail. --jit and --interp refuse it, as do the Windows targets, whose C library exports neither symbol.

Automatic variable initialization

-ftrivial-auto-var-init=uninitialized|zero|pattern initializes every automatic object declared without an initializer: scalars, aggregates, arrays and variable-length arrays. The front end supplies the value as an ordinary initializer where the storage is established, so every output mode carries it and -O treats it as any other store.

-fzero-init-padding-bits=standard|unions|all takes 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.

Frame size

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

AArch64 branch protection

pac-ret signs the return address of every function that stores the link register: paciasp before the prologue, and autiasp after the last teardown instruction of each epilogue, where sp, the signing modifier, again holds its value at entry. A leaf with no frame stores no link register and is left alone unless +leaf is given. bti puts a BTI landing pad at each function entry and indirect-branch target. standard is bti+pac-ret. A signed function starts with paciasp, which is itself a landing pad for the branches that reach a function entry, so it takes no separate BTI C.

An ELF object built with either records it in the GNU_PROPERTY_AARCH64_FEATURE_1_AND word of .note.gnu.property. The linker intersects that word across its inputs, so the compiler sets a bit only where it emitted the instructions.

^ To the top