I wanted to know two things about a routine on a Cortex-M4F: how many cycles it costs, and how many bytes of flash it adds. Both answers are only worth having if nothing else in the image contributes to either figure.

That became nucleo-bench: a bare-metal benchmarking harness for the STM32F446RE. You hand it an algorithm, it runs the thing a few million times with the core’s cycle counter running, and it tells you what that cost. No operating system, no vendor hardware abstraction layer, no vendor startup code.

Nothing forced me down that road. The vendor path - CubeMX, ST’s HAL, CMSIS startup - gets you running in minutes, but it puts code in your image that you did not write and cannot easily account for. In an application that is a fair trade. In a measurement tool it is fatal, because unaccounted code is exactly the thing whose cost you are claiming to report.

So I went freestanding. What I learned is that freestanding is not a build flag you set once. It is a property you have to defend against your own compiler, and I had to defend it three times. Each time the compiler had quietly taken the property back, and each time the build had stayed perfectly green. The end of that road is an empty baseline of 1392 bytes of text, with no libc and no libgcc linked, and a check anyone can run.

What a benchmark harness actually has to guarantee

Two numbers matter, time and size, and both collapse the moment the harness contributes something invisible.

Time has to mean cycles rather than milliseconds, because a millisecond tick cannot resolve a function that costs a few dozen cycles. Size has to mean a delta: build the harness empty, note the code size, build it again with the algorithm, subtract. That subtraction is only honest if nothing gets linked implicitly and rides along uncounted.

Then came the trap that started all of this. An empty baseline is precisely the kind of code an optimizer is best at deleting. My first -O3 run reported a duration of roughly zero. The harness had done everything right - start counter, run loop, stop counter, print delta - but the loop was no longer there. GCC had proved the body had no effect and removed it, and the harness had faithfully measured nothing.

That makes the requirement stronger than “no OS, no HAL”. The image must link no support library at all, and the loop must survive -O3 without being distorted by whatever keeps it alive.

Building it: startup, clock, timer, output, loop

Everything below is GCC on the arm-none-eabi toolchain. The flags, the attributes, the inline assembly, and the libraries I am keeping out - newlib-nano and libgcc - are all specific to it. Clang or IAR would have their own equivalents, and their own version of the same fights.

The tour below follows the boot order, and it begins with the flag that sets the tone for everything after it: -nostartfiles. With it there is no vendor startup object, so Reset_Handler is mine. It copies initialized data from flash into RAM and zeroes .bss. It brings the clock up to 180 MHz through a direct register sequence, which src/clock.c walks through in full; that sequence is a prerequisite for the timing rather than the point of the project. Finally it walks .preinit_array and .init_array, the linker-built tables of static constructors, by hand. It has to: the harness keeps its runner in a global C++ object, and with no vendor startup there is nobody else to run that constructor.

The timing comes from the Data Watchpoint and Trace unit, a debug block inside the Cortex-M core usually abbreviated DWT. It holds a free-running 32-bit counter, DWT_CYCCNT, that increments once per core clock cycle - no timer peripheral, no interrupt. Three helpers are the entire timer:

void _dwt_init(void)
{
    DEMCR |= 0x01000000;
    DWT_CYCCNT = 0;
    DWT_CTRL |= 1;
}

uint32_t _dwt_cyccnt(void)
{
    return DWT_CYCCNT;
}

Those 32 bits are the one thing a user has to design around. At 180 MHz the counter wraps after 23.861 seconds, and it wraps silently: you get a smaller number, no error and no flag, so a wrong measurement looks exactly like a right one. The harness therefore prints the wrap limit as its first line of output, right next to the measured duration, which puts the failure mode where you cannot miss it.

Output is semihosting. The program puts a string pointer in a register and executes a breakpoint with a magic operand; the attached debugger notices, reads the string out of target memory, and prints it. The whole channel is thirteen lines, and it keeps a UART, its baud configuration, and its driver out of the image:

void _semihost_write0(const char *s)
{
    __asm volatile (
        "mov r0, #4\n\t"
        "mov r1, %[str]\n\t"
        "bkpt #0xAB"
        :
        : [str] "r" (s)
        : "r0", "r1", "memory"
    );
}

The price is that every write halts the core, which is why nothing prints inside the measured region.

The benchmark itself is a global object that calls algo() three million times by default. AlgoRunner runs between a counter reset and a counter read, with nothing else in between:

public:
    AlgoRunner(uint32_t runs) : m_runs{runs} {}
    uint32_t runs(void) const { return m_runs; }
    void run(void)
    {
        for (uint32_t r{0U}; r < m_runs; r++) {
            algo();
        }
    }
} g_runner{kBenchRuns};

Three times the compiler took something back

The three obstacles below all have the same shape: the compiler doing its job correctly, and in doing so dismantling a property the harness depends on.

One: the empty loop that -O3 deleted. With no algorithm selected the loop body is empty, so dead-code elimination proves the for loop has no effect and the loop disappears. The fix is a barrier:

static inline void compiler_barrier(void)
{
    __asm volatile("");
}

An empty inline-assembly statement marked volatile emits no instruction at all, yet counts as an observable side effect that the optimizer is not allowed to remove. The body stops being provably empty and the loop survives. Because the barrier itself costs zero cycles, what the harness then measures is pure loop overhead: counter, compare, branch.

Two: the startup code that called memcpy, and the memcpy that called itself. Reset_Handler copies .data and zeroes .bss with plain byte loops. At -O3 GCC’s loop-idiom pass recognizes those loops for what they are and rewrites them into calls to memcpy and memset, which the linker happily satisfies from newlib-nano. Nothing failed. The image had simply grown the C library it was supposed to be doing without.

Renaming the functions to _memcpy and _memset is not enough, and this is the part that surprised me: GCC recognizes the byte loop inside my own replacement as the same idiom and rewrites it into a call to itself. Infinite recursion, courtesy of an optimization. The real fix is an attribute, in which every character is load-bearing:

__attribute__((optimize("no-tree-loop-distribute-patterns")))
static void _memcpy(void *dst, const void *src, size_t n)
{
    unsigned char *d = (unsigned char *)dst;
    const unsigned char *s = (const unsigned char *)src;
    while (n--) {
        *d++ = *s++;
    }
}

Three: the 64-bit divide that was a library call. Printing cycles as seconds and nanoseconds needs 64-bit arithmetic, and the Cortex-M4 has a 32-bit hardware divider but no 64-bit one. A single uint64_t / uint64_t is therefore enough to make GCC call libgcc’s __aeabi_uldivmod, which is a support library like any other. The replacement is shift-subtract division:

static uint64_t udivmod64(uint64_t num, uint64_t den, uint64_t &rem)
{
    uint64_t q{0};
    uint64_t r{0};
    for (uint32_t i{0U}; i < 64U; i++) {
        r = (r << 1) | (num >> 63);
        num <<= 1;
        q <<= 1;
        if (r >= den) {
            r -= den;
            q |= 1U;
        }
    }
    rem = r;
    return q;
}

Every shift in udivmod64() is by a compile-time constant, either 1 or 63. That is deliberate: a variable 64-bit shift would pull in __aeabi_llsl and __aeabi_llsr and put me straight back where I started. The routine is not fast and never needed to be, since it runs a handful of times, outside the timed region.

None of these three announced themselves. Nothing failed to build and nothing crashed. The image quietly grew a dependency, or the measurement quietly became meaningless, and the build stayed green either way. That is what turned “freestanding” from something I believed into something I wanted to check.

Where it stands: 1392 bytes and a number you can check

The empty baseline is 1392 bytes of text, 4 bytes of data, and 4 bytes of bss. That is the whole harness: vector table, startup, clock bring-up, DWT helpers, semihosting, print helpers, loop.

The freestanding claim is not a belief about flags. The linker specs put newlib-nano on the search path, and nothing is pulled from it - which you can see for yourself by listing every symbol in the ELF:

$ arm-none-eabi-nm -C -n build/firmware.elf   # (weak ISR aliases omitted)
08000000 R isr_vector
08000188 T _semihost_write0
08000194 T Default_Handler
08000198 t _memset.constprop.0
080001b4 t _memcpy.constprop.0
080001d8 T Reset_Handler
08000240 T _sysclk_180mhz
080002d0 T _dwt_init
...

That listing is abbreviated twice over: the weak ISR aliases are omitted, and the tail is cut where the print helpers begin. The property lives in the full output rather than in my excerpt, which is the point of it being a command you can run - every symbol nm reports is defined in the repository, and there are no undefined externals left for a library to supply. Since nothing is linked implicitly, the size delta stays honest end to end; function- and data-section splitting plus section garbage collection keep anything unreferenced from riding along uncounted.

The calibration that convinced me is the bundled example: a thousand nop instructions. It builds to 3400 bytes of text against the 1392-byte baseline, a delta of 2008 bytes. A nop on Thumb is two bytes, so a thousand of them are 2000, and the remaining 8 bytes are call glue.

The timing agrees with itself too. Here are three million runs, as the harness prints them:

=== make run_release (-O3 -g3) ===
--- start ---
wrap 23.861 s
runs 3000000
dt = 16.833 s  avg = 5611 ns

There is the wrap ceiling sitting next to the measured duration, as promised. And a thousand nops in 5611 ns works out to an effective 178 MHz against a 180 MHz core clock, the missing 2 MHz being the loop counter and branch that -O3 cannot remove.

What the exercise was actually worth

The lesson is not that bare metal is better. It is that a measurement tool answers to a different requirement than an application does. An application is allowed to contain code you never accounted for; that is what a HAL is for. A benchmark is not, because the thing measured and the thing measuring share one image, and every byte you cannot explain is a byte of error.

Freestanding turned out not to be a flag. Every one of the three obstacles was the compiler correctly optimizing code whose real constraint lived outside the language - the C standard has nothing to say about which library my image may link - and each one needed its own defence. That is why the nm check matters more to me than any list of flags. It converts a belief about the build into an acceptance criterion I can run. Flags express an intention; the symbol table is evidence.

For anyone weighing this against the vendor path, the cost is real: I wrote the startup code, the clock bring-up, and the output channel myself, and I own every bug in all three. What I bought is an image with nothing unexplained in it, which for a benchmark is the entire product.

The harness is the empty baseline by design. Drop in an algorithm, keep it alive against the optimizer using the barrier ideas above, and watch the delta. Each of the three traps is worth its own post.