Design decisions#
This page records the standing design decisions of EmbCC: what was decided,
why, and where a decision has since been revised or superseded. Each has a
permanent identifier, D-001 to D-019, which source comments and tests
cite; identifiers are never reused or renumbered. The page is for
contributors who need to know why the compiler is shaped as it is before
they change it. A new decision is added at the end with the next free
identifier, its reason, and what would reopen it
(Contributing).
Each entry gives the decision as it stands today. Where the original decision was revised, the entry says so in its Status line.
| Id | Decision | Status |
|---|---|---|
| D-001 | EmbCC is a separate, parallel project, not OS work | current |
| D-002 | Bootstrap as a C compiler, not a new language | current |
| D-003 | Emit EMBX (native, capability-carrying) and keep ELF for porting | revised; implemented |
| D-004 | Grow backward from a runnable artifact, not forward from a lexer | applied |
| D-005 | Prove on the host first; the target is the final judge | current |
| D-006 | Adoption is earned by capability, not authorship | current |
| D-007 | The kernel stays out of scope | superseded: the kernel is in scope |
| D-008 | Target languages: C, then C++; no language of our own | current |
| D-009 | Own libc | superseded in form: one library, per-OS backends |
| D-010 | Debug info: DWARF as the bridge, native .embdbg derived last |
current |
| D-011 | A second architecture: aarch64, as a peer backend | current; parts superseded by D-012 |
| D-012 | Each architecture in its own directory | current |
| D-013 | C++: C++20 and libstdc++, lowered through C | current |
| D-014 | Host operating systems as targets: Linux, macOS, Windows | current; partly implemented |
| D-015 | A third architecture: ARMv7-M, the first 32-bit target | current |
| D-016 | RISC-V as two targets and one backend | current |
| D-017 | A configured default target, compiled in | current |
| D-018 | AVR, the first 8-bit target | current |
| D-019 | Running a program is an optional host capability | current |
D-001: EmbCC is a separate, parallel project, not OS work#
Decision. EmbCC lives in its own repository and moves on its own schedule. EmbLinkOS does not depend on it; whether the operating system uses EmbCC for any build is decided separately, under D-006.
Why. A half-built compiler inside the operating system's tree would put a shippable system at the mercy of an unfinished one. Testing against the OS does not require living in it, and adopting EmbCC, once it has earned it, is a one-line change in an EmbBuild manifest.
Status. Current. EmbCC can build the EmbLinkOS kernel (D-007); the separation is now a choice rather than a necessity.
D-002: Bootstrap as a C compiler, not a new language#
Decision. EmbCC's first language is C.
Why. C can be tested against existing compilers and existing programs from the first object EmbCC emits, where a new language would have to invent its own notion of "correct" while being implemented. C can express a compiler, which the self-hosting loop needs. Starting with C does not rule out a language of EmbLink's typed values later.
Status. Current, and extended by D-008 and D-013. Reopens if ten lines written in the language one wishes existed create a real need for it, or a concrete program proves painful in C and obvious in the other language.
D-003: Emit EMBX (native, capability-carrying) and keep ELF for porting#
Decision. The compiler emits ELF relocatable objects on every ELF
target. The native executable format of EmbLinkOS, EMBX, is a link output:
embld --embx --cap NAME writes an EMBX image with a declared capability
table from the same linked layout it would write as an ELF executable. ELF
executables remain the format for programs ported from elsewhere; the
operating system has a loader for each.
Why. An EMBX application is fully linked, with fixed addresses and no relocations, so only the linker can produce one. The format exists to carry a declared capability set, and it was designed after the capability model it carries existed in the operating system, not before. Keeping ELF for ported software mirrors the operating system's two filesystems (its own for its data, FAT32 for foreign disks): two loaders, no converter.
Status. Revised once. The original decision was to emit ELF only and to
add any native format as an ELF superset; it named a capability model as
the one thing that could justify a native format, and that condition was
met. EMBX output is implemented in EmbLD. Still open: a program declaring
its capabilities in its source, recorded by EmbCC and collected by EmbLD,
instead of --cap on the link line.
D-004: Grow backward from a runnable artifact, not forward from a lexer#
Decision. The first milestone was an object that links and runs on the operating system (a program that exits with 42), with a front end only as large as that needed. The language grew afterward, in increments each covered by a test.
Why. A compiler's invariant is that the machine runs what it emitted. Starting from the output end validates the target contract (object format, relocations, calling convention, loader) while it is still cheap to be wrong about it.
Status. Applied. The milestone it ordered, M1, closed on 2026-07-20.
D-005: Prove on the host first; the target is the final judge#
Decision. Development is done on the host: EmbCC's output is compared
against reference compilers and tools (readelf, objdump, another
linker) and run under emulation. Running on the target confirms; it is
not the first check.
Why. A host cycle takes seconds and an operating-system boot takes minutes, under an emulator that runs at a fraction of native speed. The same bugs are found far sooner on the host.
Status. Current. It is the shape of the test suite: golden tests against references, and QEMU harnesses for every target (Testing).
D-006: Adoption is earned by capability, not authorship#
Decision. EmbLinkOS moves a build from its existing toolchain to EmbCC only when EmbCC is better for a stated reason: it clears a wall the other toolchain cannot (C++, thread-local storage, code quality), it serves a language that fits the operating system, or the operating system reproducing its own toolchain becomes a goal in itself.
Why. The refusal rule applied to the project itself: "we wrote it" is not a capability.
Status. Current. Being able to build something (the kernel, the C library) is a capability; choosing EmbCC for the operating system's official build is a separate decision this one governs.
D-007: The kernel stays out of scope#
Decision as it stands. The EmbLinkOS kernel is in scope. EmbCC compiles it, EmbAS assembles its hand-written assembly, EmbLD links it with no linker script, and the result boots. Kernel design questions (the debugging contract, syscall numbering, capabilities) remain the operating system's, not EmbCC's.
Why. The original decision kept the kernel out because it needs
freestanding code generation, inline assembly and linker control that a
young compiler should not attempt. Each obstacle was then built: the
freestanding options (-mno-sse, -mno-red-zone, -mcmodel=kernel, with
floating point refused where SSE is off), an inline-assembly assembler
checked against objdump, EmbAS (byte-identical to nasm on the kernel's
.asm files), linker-defined end symbols and a load-address offset in
EmbLD, and an optimizer whose -O0, -O1 and -O2 kernels all boot. No
kernel source was changed to achieve it.
Status. Superseded by its own reopen condition (EmbCC became an
optimizing compiler with proven freestanding support). Whether the
operating system adopts EmbCC for its kernel build is governed by
D-006. tests/golden/x86_64/embbuild-kernel.sh builds and boots
the kernel.
D-008: Target languages: C, then C++; no language of our own#
Decision. EmbCC's languages are C and C++. A new language built around EmbLink's typed values is not planned.
Why. What the operating system needs is to build the software that exists, which is C and C++; C++ is also the wall the operating system's other compiler cannot clear. A new language would multiply every cost: no existing compiler to test against, a second bootstrap problem, and programs to rewrite.
Status. Current. C++ became concrete in D-013. The gate back to a new language is still D-002's ten-lines test.
D-009: Own libc#
Decision as it stands. EmbCC's freestanding and Linux targets link
against EmbCC's own C library, lib/libc: portable C above a small
operating-system contract (lib/libc/os/backend.h), with one backend per
operating system (os/emblinkos, os/linux, os/posixlike). EmbLinkOS's
former separate library, emlibc, is now the os/emblinkos backend, so the
operating system gets the same printf, strtod, malloc and math as
every other target. Hosted macOS and Windows use the platform's own C
library (D-014). musl and glibc are not used.
Why. The ownership loop the project aims at (language, compiler, library, format, loader, operating system) closes only if the library is owned too. Most of a C library is independent of the operating system; only a thin rim of I/O, process, time and entropy calls is not, and EmbLinkOS already owned that rim. One implementation for every target means one set of bugs, fixed once.
Status. Superseded in form: the decision originally made emlibc, a separate non-POSIX library in the operating system's tree, the link target. The library is now one implementation shared by all targets, and emlibc survives as its EmbLinkOS backend. See Libraries.
D-010: Debug info: DWARF as the bridge, native .embdbg derived last#
Decision. -g emits DWARF 4 (.debug_info, .debug_abbrev,
.debug_line). EmbLD carries the DWARF into the linked image, so gdb can
read it, and also writes the native .embdbg sidecar, whose layout was
derived from what EmbDBG needs. EmbDBG reads both.
Why. DWARF has a real consumer (gdb) on the host from the day it is
emitted, so debug information could be proved on the host
(D-005) before EmbDBG existed. A native format designed before
its producer and consumer existed would have repeated the mistake
D-003 avoided. The linker writes .embdbg because only the link
assigns the final addresses.
Status. Current. -g is refused for Darwin and Windows targets, whose
debug formats (a __DWARF segment, CodeView) are not written.
D-011: A second architecture: aarch64, as a peer backend, not a fork#
Decision. One embcc binary emits for every architecture, chosen at
run time with --target=. Only the phases that genuinely differ consult
the target. Code generation records machine-neutral relocation kinds and
the driver maps each (kind, target) to a concrete relocation type, because
the same act (taking a symbol's address) costs one relocation on x86-64 and
two on aarch64. Each backend places arguments itself from their types,
because calling conventions disagree in ways the IR's System V
classification cannot carry.
Why. A compiler meant to run on the operating system it compiles for must choose its target when it is used, not when it is built. Reusing the System V numbers for AAPCS64 would have miscompiled every call with more than six arguments, every large composite and every homogeneous floating-point aggregate.
Status. Current. Everything this decision listed as refused at the
time (inline assembly, atomics, va_start, HFA arguments, -g) has since
been implemented. Its instruction to keep the two backends' code
generation separate until a real algorithm was duplicated was acted on:
the register allocator was lifted into shared code (D-012).
D-012: Each architecture in its own directory#
Decision. Everything that depends on the target machine lives under
src/arch/: the shared pieces at the top (target.c for selection, the
data model and relocation kinds; backend.h for the backend contract;
code.c for the machine-code buffer; predef.c for choosing the macro
table; regalloc.c, the shared register allocator), and one directory per
architecture (x86_64/, aarch64/, thumb/, riscv/, avr/), each with
its code generator, encoder, share of IR generation, assemblers and
predefined macros. thumbv8m/, riscv32/ and riscv64/ hold only
generated macro tables. The rest of src/ never names a machine. Tests
follow the same rule: tests/golden/ and tests/exec/ apply to every
target, tests/golden/<arch>/ and tests/exec/<arch>/ to one.
Why. A target becomes a directory with a known set of files, and what it lacks becomes a table, instead of something found by searching.
Status. Current. The reorganization was a pure move, verified by
byte-identical output on both targets. The shared register allocator
(Chaitin-Briggs colouring, used by all six backends, each through a
struct ra_target) was lifted out of the x86-64 backend afterwards, as
D-011 foresaw.
D-013: C++: C++20 and libstdc++, on both architectures, lowered through C#
Decision. EmbCC compiles C++20 toward the Itanium C++ ABI, so its
objects link with g++'s. The front end (src/cxx/) parses C++ with
semantic analysis interleaved, and lowers it to C that goes through the
existing pipeline: classes become structs laid out by the Itanium rules,
member functions become mangled functions with an explicit this,
templates are instantiated by replaying their tokens. What plain C cannot
express (landing pads and unwind tables, the aarch64 result pointer) is a
small internal extension of EmbCC's C. The standard library is GCC's
libstdc++, compiled by EmbCC. The runtime below it (operator new, RTTI,
the __cxa_* layer, the personality routine) is EmbCC's own lib/libcxx;
the reference g++ referees every C++ test.
Why. One front end serves every backend from the first line. The C parser could not be extended in place: its parse-then-check shape cannot resolve C++'s ambiguities, and C output had to stay byte-identical while C++ grew. Writing a standard library would be a multi-year project whose conformance only EmbCC could vouch for; libc++ is not compatible with the g++-built C++ already on the operating system.
Status. Current. The milestones CX1 to CX9 are tracked in C++ support. C++ is tested on x86-64 and aarch64; C++ exceptions do not work on the 32-bit and 8-bit targets (Status).
D-014: Host operating systems as targets: Linux, macOS, Windows#
Decision. A target is a triple: architecture, operating system and
object format. EmbLinkOS is an operating system in that set
(x86_64-emblink, aarch64-emblink, predefining __emblink__), not
"no operating system". Two predicates keep apart questions that only
EmbLinkOS answers differently: target_has_os() and
target_is_hosted() (whether the platform supplies its own C library and
start-up). EMBX is a link output, not a fourth object format
(D-003). A calling convention is a property of the target, so
Microsoft x64 is a second convention on x86-64.
Per operating system:
| Object format | Convention | C library and link | |
|---|---|---|---|
| Linux | ELF | System V, AAPCS64 | EmbCC's lib/libc over raw syscalls, static, with EmbCC's compiler runtime and unwinder (lib/rt); linked by EmbLD on x86-64, by an external linker on aarch64 |
| macOS | Mach-O | Apple's arm64 convention and data model; System V on x86-64 | the system's; link with the system toolchain |
| Windows (MinGW) | COFF | Microsoft x64 | none yet; link with the system toolchain |
Windows means MinGW first, because it uses the Itanium C++ ABI that EmbCC's runtime implements; MSVC compatibility is a separate decision not taken. A capability a triple lacks is an error naming the triple, never a silent fallback to another platform's behaviour.
Why. EmbCC had a second architecture, an optimizer, exceptions and a C++ front end; the remaining distance to a hosted platform was mostly format and convention. Linux came first because three of its five pieces already existed, so a failure there was a failure of the triple refactor and nothing else. On Linux EmbCC uses its own library rather than glibc because the library's seam is shaped for a kernel, because it needs no sysroot, and because it can be built where there is no glibc; a static image built against the syscall ABI runs on any kernel of that architecture.
Status. Current; partly implemented. Linux runs C and C++ on a real
kernel (tests/golden/linux.sh), with threads and TLS; no dynamic linking
or PIE, and a kernel floor of 4.11 (statx). macOS runs natively
(tests/golden/darwin-abi.sh). On Windows no program has run: Win64 code
is executed only inside an x86-64 test image (win-abi-run.sh). The
"system linker" half of the original decision is not implemented: the
driver refuses to link for macOS, Windows and aarch64 Linux and asks for
-c and a platform linker. What each hosted target still refuses is listed
in Status.
D-015: A third architecture: ARMv7-M (Cortex-M), and the first 32-bit target#
Decision. thumbv7m-none-eabi (Cortex-M3) and its variants generate
Thumb-2 code with AAPCS32. Because it was the first ILP32 target, the data
model became a table of named questions (target_ptr_size,
target_long_size, target_ldouble_size, target_char_unsigned,
target_wchar_unsigned, target_has_int128, ...) with one row per
architecture, and long long became a type distinct from long. Objects
are ELF32, built in the 64-bit structures and converted where they are
written. The predefined macros come from clang. EmbLD reads and writes
ELF32 ARM (SHT_REL relocations, a .vectors section placed first, a
firmware layout with -Tdata), so a firmware image needs no other
toolchain. Floating point without an FPU is a call into lib/rt/softfp.c
under libgcc's names; 64-bit integers are register pairs in the backend,
not an IR legalization pass. Variants are levels of the same target, not
new ones: thumbv7em (Cortex-M4/M7), the hard-float -eabihf triples and
-mfloat-abi=hard with the FPv4-SP/FPv5-SP single-precision FPU, and
thumbv8m.main (Cortex-M33). Objects carry .ARM.attributes, and EmbLD
refuses to link objects whose float ABI or enum size disagree.
Why. EmbLinkOS is meant to carry embedded tooling. Testing "is the target aarch64?" had stood in for half a dozen different questions; each now has a name, and a new target fills in a row rather than inheriting x86-64's answers. A soft-float runtime that computes binary32 by widening to binary64 and rounding back gives the same results with one core.
Status. Current. Statements in the original record that no longer
hold: there is a code generator (with the shared register allocator), and
atomics, variable-length arrays, -g, long double (as a double) and
hard-float are implemented. Still refused: computed goto, __int128.
ARMv8-M's security extension is not supported.
D-016: RISC-V, as two targets and one backend#
Decision. riscv32-unknown-elf and riscv64-unknown-elf are two
values of enum target_arch, because their data models differ (ILP32
against LP64, __int128 only at RV64), but one backend, src/arch/riscv/,
parameterized by target_xlen(), because they are one instruction set at
two widths. riscv32/ and riscv64/ hold only the generated macro
tables. Code is RV32IMAC or RV64IMAC with the soft-float ABI; the C
extension is applied where instructions become bytes, not by a selector.
Addresses use the medany model (auipc+addi) at both widths, because
lui sign-extends bit 31 and cannot reach RAM at 0x80000000 on RV64.
embld -Tstack ADDR writes the instructions that set sp before the
entry point, so a firmware image needs no assembly. long double is IEEE
binary128 and wchar_t is signed while char is unsigned.
Why. The enum keys the data model, and two data models cannot share a value; two copies of one instruction set's backend would drift.
Status. Current. The backend, register allocator, inline assembly,
atomics (A extension) and compressed instructions are implemented. Still
refused: 128-bit values in the backend (so long double arithmetic at both
widths and __int128 at RV64), computed goto, hardware floating point
and -march=. Reopens if RV32 and RV64 come to need different
lowering beyond 64-bit register pairs.
D-017: A configured default target, compiled in, with every backend still there#
Decision. make DEFAULT_TARGET=TRIPLE builds a compiler whose default
target is TRIPLE. EMBCC_DEFAULT_TARGET overrides it for one shell, and
--target= overrides both. Only the default is compiled in: every backend
is still present and reachable. An unknown default is refused at start-up,
naming where it came from.
Why. A cross compiler should not have to be told it is one on every
command line, and an installed compiler should behave the same for every
caller, including a build that scrubs the environment; so the durable
setting is compiled in and the variable is the temporary override, the
order GCC's ./configure --target= users expect. The variable is not
EMBCC_TARGET because the test suite uses that name for the target it is
exercising; tests/golden/default-target.sh keeps the two apart. Falling
back to x86_64-elf on a bad default would produce objects that link and
a board that does not run.
Status. Current.
D-018: AVR, the first 8-bit target, and what a byte-wide register file changes#
Decision. avr targets the ATmega328P (__AVR_ATmega328P__,
avr5) with avr-gcc's documented ABI. int and pointers are two bytes, and
double and long double are four-byte binary32. Because the IR computes
at four bytes, the backend works out how many low bytes each use reads and
computes only those. A local is reachable only through Y with a six-bit
displacement, so Y is the frame pointer; temporaries share frame slots by
live range, and slots are placed densest first. Program memory is a
separate, word-addressed space: function pointers use the program-memory
relocations, and .rodata is placed in RAM and copied from flash at
start-up, since no instruction EmbCC emits reads flash. Values live in
runs of registers: the shared allocator assigns call-saved register pairs,
and quads and call-clobbered homes are added by the backend, which
generates each allocation mode and keeps the shortest. Multiply, divide and
floating point are calls into lib/rt (avr.c, avr64.c, and binary32
arithmetic in eight avrfp*.c objects so a program links only what it
uses). EmbCC assembles AVR source itself, and supports interrupt handlers
(signal, interrupt) and inline assembly with AVR's constraint letters.
Why. Every scalar here is wider than a register, a frame larger than
63 bytes is out of ldd reach, and the part has 2 KB of RAM and 32 KB of
flash, so frame size and code size are budgets rather than preferences.
The calling convention is checked against avr-libc's documented rules
rather than against clang, whose AVR struct convention is not avr-gcc's.
Status. Current. The original record's "no register allocator yet" no
longer holds. Still refused: variable-length arrays, label addresses and
computed goto, byte swaps, sqrt, and atomic accesses wider than one
byte; dense switches are not lowered to jump tables on this target. The
__flash qualifier is supported (program-memory data read with lpm);
the progmem attribute is ignored with a -Wattributes warning. Plain char is signed, following
clang; whether avr-gcc's default agrees has not been checked against
avr-gcc.
D-019: Running a program is an optional host capability#
Decision. The platform layer gains one optional capability: running
a program and waiting for it (plat_can_run, plat_run_start,
plat_run_wait, plat_ncpus). The build chooses the implementation --
process_spawn.c (POSIX posix_spawn, the default on macOS and Linux)
or process_none.c (PROCESS=none: EmbLinkOS, and the default with
PLATFORM=iso). The driver uses it for one thing: several sources in one
command, each compiled by a run of the driver of its own, -j N at a
time. Nothing else may use it, and where it is absent the driver does
what it always did -- one source per command, the second refused with
the way round it.
Why. Every Makefile and CMake build hands the compiler several sources, and refusing them made EmbCC the one compiler a build had to be rewritten for. Compiling them in one process would mean resetting every global the front end, the optimizer and the backends keep -- hundreds, in every stage -- and a reset that missed one would carry one unit's state into the next silently. A process per source has no such state to leak, is what GCC's driver does (it runs cc1 per file), and gives parallel compiles for free. Threads were considered and rejected for the same reason: the stages are not reentrant, and making them so is a much larger change than this one. The rule that EmbLinkOS cannot spawn stays true and stays respected: the assembler and the linker remain libraries in the driver's process, and a host without the capability loses only the convenience.
Status. Current. posix_spawn is the only implementation that runs
anything; Windows (CreateProcess) and EmbLinkOS (should it gain a spawn
call -- EmbBuild already runs one program per recipe) would each be a
process_NAME.c.
D-020: ARMv8-M Baseline is ARMv6-M's code generator with a flag#
Decision. thumbv8m.base-none-eabi (the Cortex-M23) is Thumb level 6
-- target_thumb_arch() answers 6 and src/arch/thumb/v6m.c generates
its code -- with a flag, target_thumb_v8m_base(), which the few places
where Baseline differs from ARMv6-M ask: the 32-bit divide and the one-,
two- and four-byte atomics become instructions instead of librt.a
calls, the encoding scan (t_thumb1_ok32) admits Baseline's 32-bit
encodings, the predefined macros and build attributes are Baseline's,
and the security extension (-mcmse) is available. Constants stay in
literal pools and branches keep ARMv6-M's forms, as clang keeps them for
this core.
Why. Every other question asked of level 6 -- no IT block, no unaligned access, r0-r7 only, no FPU, word-aligned arrays -- has the same answer on Baseline, and a separate level would have had to be added to each of those checks to say so. A third code generator was not worth writing for what Baseline adds; ARMv7-M's is Thumb-2 throughout and emits nothing a Cortex-M23 has a use for that ARMv6-M's lacks.
Status. Current. MOVW/MOVT for constants and CBZ for a compare with zero are instructions the scan admits and the backend does not choose yet; if code size on Baseline matters, they are the next step.
D-021: TrustZone-M's Secure side, and veneers in the linker#
Decision. -mcmse compiles for the Secure state of an ARMv8-M part,
as ACLE's CMSE and clang define it: cmse_nonsecure_entry functions
return through BXNS with every register and flag that could hold a
secret overwritten, calls through a cmse_nonsecure_call pointer save
r4-r11 and clear the rest before BLXNS, and <arm_cmse.h> provides the
TT intrinsics and the pointer checks. embld makes the secure gateway
veneers from the __acle_se_ symbols, writes the import library, and
adds a long-branch veneer for a Thumb call to an absolute symbol out of
reach -- the first veneers it makes. The floating-point state is not
handled: -mcmse is refused with an FPU.
Why. The security extension is useless without the linker half, and both halves have exactly one correct shape, which clang and GNU ld define; following them lets a Secure image built by EmbCC serve a Non-secure one built by either. The long-branch veneer is limited to absolute targets because that is the case an import library creates and the only one a pre-layout pass can decide; a general range-extension pass would need layout to iterate. Refusing the FPU rather than clearing s0-s15 keeps every Secure image this compiler produces free of floating point state to leak.
Status. Current. --in-implib (stable veneer addresses across
releases), the FPU, cmse_nonsecure_caller(), and entry functions or
Non-secure calls with arguments on the stack are refused by name; the
last is what clang refuses too.