Compiler Workbench
Edit C, compile for embedded targets, inspect diagnostics, assembly, sections, symbols, code size, and run a host-side test harness.
Embedded C Compiler Workbench
A compiler workbench makes the translation pipeline inspectable. The source editor holds one C translation unit. A selected cross-compiler parses and optimizes it for a declared architecture, emits machine instructions and creates a relocatable ELF object. Object inspection then reveals emitted sections and symbols. A separate host build can execute pure logic and test vectors in a sandbox. These are complementary forms of evidence: local declaration analysis explains likely storage, the object proves what one compiler emitted for one file, the linked map proves final placement across the whole image, and target measurement proves runtime behavior on hardware.
How it is built
- The source boundary is explicit. Editing, declaration classification and simple structure layout happen locally in the browser. A remote compile occurs only after the learner presses Compile or Run. The workbench warns that the public compiler service receives the submitted source and may cache compilation results, so secrets and confidential firmware do not belong in the editor.
- Target selection is a contract rather than a cosmetic label. Cortex-M4 uses Armv7E-M Thumb instructions, Cortex-M33 uses the Armv8-M Main profile, and RV32IM uses a 32-bit RISC-V integer ABI with multiply and divide instructions. The compiler identifier and architecture flags travel together so the assembly listing can be traced back to the exact target choice.
- Optimization changes the program representation while preserving allowed C behavior. -O0 usually keeps source structure visible and spills more state. -O2 performs inlining, constant propagation, dead-code removal and loop transformations. -Os applies size-oriented choices. Comparing these builds exposes undefined behavior assumptions, volatile access requirements and the difference between a source statement and the instructions that survive optimization.
- Strict diagnostics are part of the build contract. The workbench requests C11 mode with -Wall, -Wextra and -Wconversion, captures tagged error or warning locations, removes terminal color escapes and reports the exact effective compiler arguments. A warning-free build is useful evidence, but it cannot prove bounds, concurrency, protocol correctness or hardware behavior.
- The assembly view correlates emitted instructions with source line numbers when the compiler supplies that mapping. Addresses and opcode bytes reveal instruction width; mnemonics reveal loads, stores, branches, widening operations and calls. Directives and relocation records are preserved because an object file is not yet a final address image.
- The object map comes from the emitted ELF object rather than a guessed source model. readelf section headers supply byte counts for .text, .rodata, .data and .bss-style sections. nm supplies symbol type letters and exact object-level sizes. Debug information and relocation metadata are intentionally excluded from the allocatable total shown to the learner.
- The local memory model serves a different purpose. It identifies recognized global, static, automatic, const, array, structure and allocation declarations; predicts their likely flash, data, BSS, stack or heap category; and visualizes alignment holes and tail padding. It remains a teaching estimate because macros, compiler extensions, register spills, inlining, calling conventions and linker rules can change the real result.
- Host-side execution compiles the same source for an x86-64 sandbox and runs main. This is valuable for pure functions, parsers, fixed-point helpers, state machines and boundary tests. It is not target execution: integer widths can differ, endianness can differ, and MMIO, interrupts, DMA, caches, timing and startup behavior cannot be validated by the host run.
- The final firmware memory map is a linker artifact. It combines all translation units, startup objects and selected libraries under one linker script; resolves relocations; removes or retains sections; applies alignment; places load and run addresses; and can introduce veneers, tables or padding. An object-section total therefore answers what this file emitted, not whether the complete board image fits flash and SRAM.
- A disciplined workflow uses the views in order. First predict what the compiler should do. Compile and explain the important instructions. Compare object sections with the local declaration model. Inspect the largest symbols. Repair every diagnostic. Run deterministic host tests. Finally reproduce the build in the product toolchain, inspect its link map and stack-usage files, flash the target and collect timing, high-water and fault evidence.
Design procedure
- Choose the example nearest the intended task or paste a small self-contained C11 translation unit. Remove credentials, proprietary algorithms and secret constants before using the remote compiler.
- Select the exact embedded architecture and the optimization level used for the question being asked. Start with -O0 when learning source correspondence, then compare -O2 and -Os because production behavior is defined by the release build.
- Predict likely sections, structure padding and the key instructions before pressing Compile. Record the hypothesis so the tool produces feedback rather than passive output.
- Compile the target. If it fails, use tagged diagnostics and their source locations first. Do not interpret stale assembly from an earlier source revision as current evidence.
- Trace source-correlated assembly. Identify argument registers, loop control, loads and stores, sign or zero extension, multiply-accumulate instructions, branches and function return. Explain why each memory access remains after optimization.
- Open Object map and compare exact ELF section sizes with the local declaration categories. Differences should trigger a concrete question about optimization, unused objects, const placement, alignment or compiler-generated data.
- Inspect symbols to find which functions and objects dominate the translation unit. Remember that local and discarded symbols can vary with optimization and that library cost appears only after linking.
- Add a main-based test harness with boundary vectors and explicit nonzero failure status, then run host tests. Treat a zero exit status as one layer of evidence and preserve target-only cases for hardware verification.
- Rebuild the same source in the real firmware project. Archive compiler version, flags, linker script, map file, size report, stack-usage data and target measurements with the release evidence.
Key terms
- translation unit
- One preprocessed C source file presented to the compiler, including the headers it expands.
- cross-compiler
- A compiler running on one system but generating instructions and ABI objects for another target.
- relocatable object
- An ELF file containing sections, symbols and unresolved relocation records before final linking.
- source correlation
- Compiler metadata connecting an emitted assembly line to the source line that contributed to it.
- section
- A named group of code or data with shared placement and permission characteristics, such as .text or .bss.
- symbol
- A named function or object recorded with binding, type, section-relative address and often a byte size.
- host test
- Execution of target-independent logic on the development architecture; fast and useful but not a hardware test.
Worked example
The default dot-product program contains read-only coefficients, initialized writable state, a zero-initialized buffer, automatic arrays and an integer multiply-accumulate loop. Compiling for Cortex-M reveals Thumb instructions and exact object sections; the symbol view exposes function and object sizes; the local analyzer explains the likely storage of every recognized declaration; and main returns failure if the host-executed dot product differs from 60.Common pitfalls
What a test can prove about firmware
Most embedded logic can be tested on a host at full speed with no hardware at all, provided it is separated from the registers it eventually drives. That separation is the whole technique: pure functions that transform values are testable, and code that reads a register is not. The practical question is not how to test firmware but where to put the boundary.
How it is built
- Pure logic - protocol parsing, state machines, filters, unit conversion - depends on no hardware and runs identically on a host.
- Hardware access should be a thin layer that reads and writes registers and contains no decisions.
- With that split, the interesting code is host-testable and the untestable part is small enough to review by eye.
- A fake implementation of the hardware layer lets integration paths be exercised without a board.
- On-target tests remain necessary for timing, peripheral behaviour and anything the datasheet describes rather than your code.
Design procedure
- Extract the decision from the access: read the register, pass the value to a pure function, write the result back.
- Test the pure function exhaustively, including the boundary values that are awkward to produce on hardware.
- Keep the hardware layer free of branches, so there is little to get wrong where you cannot test it.
- Run the host tests on every commit; they are fast enough that there is no reason not to.
- Reserve on-target testing for what only hardware can answer, and write down which properties those are.
Key terms
- Hardware abstraction
- A thin layer containing register access and no decisions.
- Pure function
- Output determined by input, no side effects. Trivially testable.
- Host test
- A test compiled and run on the development machine, with no target involved.
- Fake
- A working stand-in for hardware, used to exercise integration paths off-target.
- Test boundary
- Where testable logic ends and untestable access begins. A design decision.
Worked example
The same feature, before and after the boundary is drawn:
// UNTESTABLE - decision and access are one
void update_led(void) {
uint32_t adc = ADC->DR;
if (adc > 2048) GPIOA->BSRR = LED;
else GPIOA->BSRR = LED << 16;
}
// TESTABLE - the decision is a function of a value
bool led_should_be_on(uint16_t adc, uint16_t threshold) {
return adc > threshold;
}
void update_led(void) {
bool on = led_should_be_on(ADC->DR, 2048);
GPIOA->BSRR = on ? LED : (LED << 16);
}
The second version can be tested at every boundary in microseconds:
assert(!led_should_be_on(2047, 2048));
assert(!led_should_be_on(2048, 2048)); // the off-by-one
assert( led_should_be_on(2049, 2048));
assert( led_should_be_on(0xFFFF, 0)); // saturation
And update_led is now four lines with no branch worth testing.Common pitfalls
Reading the compiler's output
The compiler will show you exactly what it generated, and doing so settles questions that reasoning cannot. Whether an access survived optimisation, whether a loop reloads a value, whether a bit operation became a read-modify-write, whether a function was inlined - all of these are visible in about thirty instructions, and none of them are reliably answerable from the source.
How it is built
- -S emits assembly rather than an object file, and -fverbose-asm annotates it with the source expressions.
- objdump -d disassembles an object or executable, showing addresses and encodings.
- A volatile access appears as a load or store that cannot be moved or removed; an elided one is simply absent.
- nm lists a translation unit's defined and required symbols, which answers most link errors directly.
- size reports the section totals, which is how a code-size change is measured rather than estimated.
Design procedure
- Compile the single file with -S -O2 and read the function; it is usually short enough to read in full.
- For a poll loop, check whether the load is above or below the label the branch targets - that alone answers whether it reloads.
- Count stores against the writes in the source; a mismatch is dead-store elimination.
- Compare -O0 and -O2 output for the same function when behaviour differs between builds.
- Use size before and after a change rather than guessing whether it helped.
Key terms
- -S
- Compile to assembly rather than to an object file.
- objdump -d
- Disassemble, with addresses and encodings alongside mnemonics.
- nm
- List symbols: what a file defines and what it needs.
- size
- Report .text, .data and .bss totals. How a size change is measured.
- Loop label
- The branch target. A load above it happens once; below it, every iteration.
Worked example
The one-glance diagnostic for a missing volatile:
while ((STATUS & READY) == 0) { }
plain pointer volatile pointer
LDR r1, [r0] .Lloop:
TST r1, #1 LDR r1, [r0] <- inside
BNE .Ldone TST r1, #1
.Lloop: BEQ .Lloop
B .Lloop <- an .Ldone:
.Ldone: infinite
loop
Left: the load is ABOVE the label, so it happens once. The body
is a branch to itself - the compiler has correctly compiled an
infinite loop from code that says "wait".
And measuring a size change rather than estimating it:
$ arm-none-eabi-size firmware.elf
text data bss dec
24316 108 8192 32616
Run it before and after. The number is not an opinion.Common pitfalls
Undefined behaviour, and the sanitizers that find it
Undefined behaviour is not a runtime error - it is a licence the compiler holds to assume the situation never occurs, and to optimise accordingly. That is why undefined behaviour so often shows up as deleted code rather than as a crash, and why the sanitizers matter: they turn the assumption into a report at the moment it is violated.
How it is built
- Signed overflow, out-of-bounds access, misaligned access, shifting too far and using an indeterminate value are all undefined.
- The compiler may assume none of them happen, which lets it delete checks that would only matter if they did.
- UBSan instruments the build to trap on undefined operations, reporting the file and line.
- ASan detects out-of-bounds and use-after-free on a host build; it does not run on a typical microcontroller.
- Host tests are where the sanitizers are usable, which is another reason to keep the logic host-testable.
Design procedure
- Run the host test suite under -fsanitize=undefined,address as part of the normal build.
- Treat every sanitizer report as a defect rather than a false positive, since it is reporting a real violation.
- Enable -Wall -Wextra -Wconversion and fix the warnings, which catches a large fraction before the sanitizer is needed.
- Compile the target build with -fno-strict-aliasing only if you know you are violating aliasing, and prefer fixing it.
- Check signed overflow explicitly where it can occur, since a post-hoc test may be optimised away.
Key terms
- Undefined behaviour
- The standard imposes no requirement; the compiler may assume it does not occur.
- UBSan
- The undefined-behaviour sanitizer, trapping at the point of violation.
- ASan
- The address sanitizer, detecting out-of-bounds and lifetime errors on a host build.
- Strict aliasing
- The rule that two pointers of incompatible types do not point at the same object.
- Deleted check
- A test the compiler removed because it could only matter if undefined behaviour occurred.
Worked example
The check that is legally deleted, which is the whole reason this matters:
int add(int a, int b) {
int sum = a + b;
if (sum < a) return -1; // intended overflow check
return sum;
}
Signed overflow is undefined, so the compiler reasons: if no
overflow occurred then a + b >= a whenever b >= 0, and overflow
cannot occur because it is undefined. The comparison is provably
false and the branch is removed. At -O2 there is no check.
Unsigned wrapping is defined, so this one survives:
unsigned sum = a + b;
if (sum < a) return -1; // a real wraparound test
And the correct signed form checks BEFORE:
if (b > 0 && a > INT_MAX - b) return -1;
if (b < 0 && a < INT_MIN - b) return -1;
UBSan reports the original at the moment the addition overflows, with the file
and line, which is considerably faster than discovering it from the listing.Common pitfalls
Static analysis and the warnings worth enabling
Compiler warnings are the cheapest static analysis available and most projects run with a fraction of them enabled. A handful in particular catch defect classes that are otherwise found by debugging: implicit conversions that lose data, comparisons between signed and unsigned, and switch statements that do not handle every enumerator.
How it is built
- -Wall and -Wextra are the baseline and are not exhaustive despite the name.
- -Wconversion reports every implicit narrowing, which is where truncation bugs live.
- -Wsign-conversion reports signed/unsigned mixing, which is where comparison bugs live.
- -Wswitch, included in -Wall, reports an enum switch missing an enumerator - but only where there is no default clause.
- -Werror makes warnings fail the build, which is what stops them accumulating into noise nobody reads.
Design procedure
- Enable -Wall -Wextra -Wconversion -Wsign-conversion and fix what appears, a file at a time if necessary.
- Add -Werror once the existing warnings are cleared, so new ones cannot accumulate.
- Omit the default clause from switches over an enum, so adding a state produces a warning at every switch.
- Suppress a specific warning at a specific site with a comment explaining why, rather than disabling it globally.
- Run a separate static analyser periodically for the interprocedural checks the compiler does not do.
Key terms
- -Wconversion
- Warns on implicit narrowing. Noisy initially and finds real truncation.
- -Wsign-conversion
- Warns on signed/unsigned mixing, the source of many bounds bugs.
- -Wswitch
- Warns on an enum switch missing a case. Silenced by a default clause.
- -Werror
- Warnings become errors, so they cannot be ignored into irrelevance.
- Targeted suppression
- Disabling one warning at one site with a reason, rather than globally.
Worked example
What each of the three catches, in one line apiece:
-Wconversion
uint8_t x = some_int; // silently truncates
warning: conversion from 'int' to 'uint8_t' may change value
-Wsign-conversion
for (int i = 0; i < len; i++) // len is size_t
warning: comparison of integer expressions of different
signedness
-> a negative i becomes an enormous unsigned value and the
loop runs past the end
-Wswitch
switch (mode) { // no default clause
case IDLE: break;
case RUN: break;
}
warning: enumeration value 'FAULT' not handled in switch
And why the default clause is worth omitting:
Adding a fourth mode produces a warning at every switch that does
not handle it. With `default: break;` present, it compiles
silently and the new mode does nothing at runtime.Common pitfalls
Measuring size and stack, rather than estimating them
Flash and RAM are fixed, and both are consumed by things that do not appear in any single place in the source. Code size is the sum of everything the linker kept; stack usage is the deepest call path plus interrupt nesting. Both are measurable with tools that ship with the toolchain, and both are routinely estimated instead - which is how a project discovers at integration that it does not fit.
How it is built
- size reports .text, .data and .bss; flash usage is text plus data, and RAM is data plus bss plus the stack and heap.
- The map file lists every symbol with its address and size, which is how the largest contributors are found.
- -fstack-usage emits a .su file per translation unit giving each function's frame size.
- Worst-case stack is the deepest call path's frames summed, plus the deepest interrupt nesting on top.
- Filling the stack with a pattern at startup and inspecting it later gives the actual high-water mark.
Design procedure
- Record size output on every build and watch the trend rather than the absolute number.
- Read the map file when the image grows unexpectedly; the cause is usually one library pulled in by one call.
- Generate stack usage with -fstack-usage and compute the worst path rather than assuming it.
- Fill the stack at startup and check the high-water mark after a realistic run, including error paths.
- Add an MPU guard region below the stack so an overflow faults rather than silently corrupting .bss.
Key terms
- size
- The toolchain utility reporting section totals.
- Map file
- The linker's record of every symbol's address and size.
- .su file
- Per-function stack frame sizes, emitted by -fstack-usage.
- High-water mark
- The deepest the stack actually reached, found from a fill pattern.
- Stack guard
- An MPU region that faults on overflow instead of allowing corruption.
Worked example
Where the flash actually went, which the map file answers directly:
$ arm-none-eabi-size firmware.elf
text data bss dec
52104 216 12288 64608
flash = text + data = 52,320 bytes
RAM = data + bss = 12,504, plus stack and heap
Grep the map for the largest symbols and one line usually
explains a jump:
_printf_float 6284 <- one %f in one debug line
And the stack, computed rather than guessed:
$ cat *.su
main.c:42:5:process_frame 256 static
parse.c:18:9:decode_message 128 static
crc.c:7:12:crc32 16 static
worst path 256 + 128 + 16 = 400
deepest ISR nesting = 192
margin = reserve at least 25%
-> allocate 768 bytes minimum
The fill-pattern check confirms it against a real run, which is the part that
catches the path nobody thought about.Common pitfalls
Assertions, defaults, and failing usefully
Firmware cannot print a stack trace and stop. What it can do is decide, in advance, what each kind of failure means and what should happen - and the decision is worth making explicitly, because the default of continuing with corrupted state is almost never the right one and is what happens when nobody chooses.
How it is built
- An assertion documents an invariant the code relies on, and in a debug build reports it immediately.
- assert compiles to nothing when NDEBUG is defined, so anything with a side effect inside one disappears in release.
- A release build usually needs a different response: log and reset, enter a safe state, or halt - not silently continue.
- A watchdog turns a hang into a reset, which is a recovery only if the reset cause is recorded.
- Recording the fault registers and the reset cause is what makes a field failure diagnosable at all.
Design procedure
- Assert on invariants your own code guarantees, and validate rather than assert on anything from outside.
- Never put an expression with a side effect inside an assertion, since the release build removes it.
- Define a release-build failure policy explicitly, and make it the same everywhere.
- Record the reset cause and the fault registers in non-volatile storage so a field reset is explainable.
- Test the failure paths deliberately, since they are the least-exercised code in the system and run when everything else has gone wrong.
Key terms
- Invariant
- Something that must always hold. What an assertion documents.
- NDEBUG
- The macro that compiles assertions out. Standard in release builds.
- Safe state
- The defined condition to enter on failure: outputs off, motors stopped.
- Reset cause
- The register recording why the part reset. Distinguishes a watchdog from a brown-out.
- Fault registers
- Cortex-M registers recording why a fault occurred, valid inside the handler.
Worked example
The side effect that vanishes in release:
assert(init_peripheral() == OK); // WRONG
With NDEBUG defined, assert expands to nothing and the whole
expression is removed - the peripheral is never initialised. The
release build differs from the debug build in a way nothing in
the source suggests.
Status s = init_peripheral(); // correct
assert(s == OK);
if (s != OK) enter_safe_state();
And a fault handler that leaves something behind:
void HardFault_Handler(void) {
uint32_t *sp = (uint32_t *)__get_MSP();
fault_record.cfsr = SCB->CFSR; // why
fault_record.pc = sp[6]; // where
fault_record.lr = sp[5]; // who called
fault_record.magic = FAULT_MAGIC; // survives the reset
NVIC_SystemReset();
}
On the next boot, check the magic and log the record. A field
unit that resets once a week becomes an address in the map file
rather than an unreproducible report.Common pitfalls
More in Embedded C
- Structs, Unions, Enums & TypedefStructs, unions, enums, typedef aliases, bit-fields, designated initialization, tagged variants, and opaque handles.
- Practice, Interviews & CapstonesDiagnostics, focused C practice, contextual DSA bridges, compiled examples, production projects, and design-defense prompts.
- Types, Promotion & ArithmeticInteger widths, signedness, promotion, conversions, overflow, qualifiers, floating point, size, and alignment.
- MMIO, Volatile & Access SemanticsMemory-mapped I/O, volatile guarantees, read and write side effects, read-modify-write hazards, barriers, and assembly evidence.
- Functions, Modules & ContractsFunction declarations, parameters, callbacks, linkage, reentrancy, error contracts, opaque modules, HAL seams, and failure states.
- Interrupts, Atomicity & PublicationInterrupt context, latency, shared state, atomic width, critical sections, ordering, publication, and deferred work.
- Bits, Fields, Endianness & EncodingsSafe mask construction, field extraction and insertion, shifts, Gray code, endianness, packing, and representation boundaries.
- State Machines, Dispatch & TimeoutsExplicit state, events, transition tables, function-pointer dispatch, timers, timeouts, invalid states, and exhaustive tests.
- Arrays, Strings & Bounded BuffersArray decay, explicit extents, C-string termination, spans, checked size arithmetic, and bounded buffer APIs.
- Fixed-Point Range, Scaling & SaturationQ formats, range and resolution, intermediate width, rounding, saturation, accumulated error, and integer DSP implementation.
- Serialization, Framing & Defensive ParsingExplicit byte order, field encoding, frame boundaries, resynchronization, checked arithmetic, transactional output, and hostile-input parsing.
- Stack, Pools, Arenas & AllocationBounded memory planning with stack budgets, fixed pools, arenas, fragmentation models, allocation failure, and instrumentation.