Skip to content

Part 3 · Firmware Systems37 min read

Performance and Code Size Optimization

Sooner or later the firmware does not fit, or does not keep up: the image overflows flash by 3 KB the week before release, or a control loop that must finish in 50 µs takes 80. This lesson is about fixing that methodically. You will measure first (cycles with the DWT counter, time with a scope, hot spots with a sampling profiler), learn what the compiler flags really do for size and speed, find bloat in the map file, understand the Cortex-M4’s memory system (flash wait states, the ART accelerator, CCM), and work through an optimisation of CRC-32 from about 58 cycles per byte to 1.6. The recurring theme: intuition about performance on a microcontroller is wrong surprisingly often, and only measurements tell you which half of it.

The DWT cycle counter CYCCNT increments once per core clock. You enabled it in Clocks, reset, and timing; wrapped up for measurement it looks like this:

cycles.h
#ifndef CYCLES_H
#define CYCLES_H
#include <stdint.h>
#include "stm32f4xx.h"
static inline void cycles_init(void)
{
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; /* power the DWT */
DWT->CYCCNT = 0u;
DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
}
static inline uint32_t cycles_now(void)
{
return DWT->CYCCNT;
}
#endif

Measure with uint32_t t0 = cycles_now(); work(); uint32_t dt = cycles_now() - t0;. Unsigned subtraction handles a counter wrap correctly, as long as the interval is shorter than 2^32 cycles (25 seconds at 168 MHz). Measure an empty interval once and subtract that overhead (typically a few cycles). Disable interrupts around the measurement, or repeat it many times and take the minimum, because an interrupt in the middle adds its whole handler to your number.

For anything involving interrupts, peripherals, or real time, a pin toggled with one BSRR write at the start and end of the code under test, viewed on a scope, shows duration, period, and jitter (the spread across many runs) with zero code overhead worth mentioning. Use the scope’s persistence mode to see the worst case, which is what deadlines care about.

When you do not know where the time goes, sample the program counter. A timer interrupt fires at an odd rate (say 10 kHz, chosen so it does not beat against your periodic tasks), reads the pc that was stacked when the interrupt arrived, and increments a histogram bucket. After a few seconds, the buckets with the most hits are the hot code.

profiler.c
#include <stdint.h>
#include "stm32f4xx.h"
#define PROF_BUCKET_SHIFT 8u /* 256-byte buckets */
#define PROF_BUCKETS 1024u /* covers the first 256 KB of flash */
uint16_t prof_hist[PROF_BUCKETS];
__attribute__((used)) void prof_sample(const uint32_t *frame)
{
TIM2->SR = ~TIM_SR_UIF; /* clear the update flag */
uint32_t pc = frame[6]; /* stacked r0 r1 r2 r3 r12 lr pc xpsr */
uint32_t idx = (pc - FLASH_BASE) >> PROF_BUCKET_SHIFT;
if (idx < PROF_BUCKETS && prof_hist[idx] != UINT16_MAX) {
prof_hist[idx]++;
}
}
__attribute__((naked)) void TIM2_IRQHandler(void)
{
__asm volatile(
"tst lr, #4 \n" /* EXC_RETURN bit 2: which stack? */
"ite eq \n"
"mrseq r0, msp \n"
"mrsne r0, psp \n"
"b prof_sample \n"); /* tail call, returns via lr */
}

The naked handler picks the stack the hardware frame was pushed on (MSP or PSP, from bit 2 of EXC_RETURN in lr, exactly as in the HardFault handler of Watchdogs, faults, and recovering from them) and tail-calls prof_sample with a pointer to the frame. The stacked pc is word 6. used stops the compiler from discarding prof_sample, which is referenced only from assembly. Dump prof_hist over the debugger (dump binary memory prof.bin &prof_hist[0] &prof_hist[1024] in gdb), convert bucket indices back to addresses, and look them up with arm-none-eabi-nm -n or addr2line.

A sampling profiler cannot see code that runs with interrupts disabled or in handlers of higher priority than the timer, so it under-reports them. Know the bias. An RTOS gives you a coarser but free view: FreeRTOS’s run-time statistics (configGENERATE_RUN_TIME_STATS with a fast counter such as CYCCNT) report the percentage of CPU each task used.

Undefined behavior, volatile, and what the optimizer assumes covered what each -O level enables. For size and speed on Cortex-M, the practical summary:

FlagEffect on an M4 project
-O0Every variable lives on the stack; two to three times larger and slower than -O2. Only for stepping through code
-OgDebuggable, close to -O1 in size and speed. Good default for development builds
-O2The usual speed setting. No size-increasing transformations like aggressive unrolling
-O3Unrolls and vectorises loops more. Often larger, and on a flash-based M4 not reliably faster, because bigger code means more flash fetches
-Os-O2 minus the transformations that grow code. Typically 10 to 25% smaller, often nearly as fast
-Oz(GCC 12 and later, Clang) even more aggressive size reduction, at a real speed cost
-fltoLink-time optimisation: inlining and dead-code removal across files. Often 5 to 15% smaller

You can mix levels: build the whole image with -Os and the few hot files with -O2, or mark a single hot function __attribute__((optimize("O2"))).

The section flags deserve to be on in every build: -ffunction-sections -fdata-sections put every function and every variable in its own section, and -Wl,--gc-sections lets the linker drop any section nothing references, as covered in Linker scripts. Without them, using one function from a file links in the whole file.

Two flags you will see in old makefiles are now defaults: -fno-common (default since GCC 10; makes tentative definitions in two files a link error instead of silently merging them) and -fomit-frame-pointer (on at -O1 and above on Arm; frees r7 for general use).

LTO has a few traps on bare metal. Compile and link with -flto and link with arm-none-eabi-gcc (not ld directly); use arm-none-eabi-gcc-ar for static libraries so the archive index understands LTO objects. Anything referenced only from assembly or the linker script must be marked used or the optimiser removes it. Interrupt handlers referenced from the vector table survive, because the table references them. Debugging an LTO build is harder, since functions migrate across files.

When the image is too big, do not guess. Three tools answer “where did my flash go?”.

size -A gives a per-section breakdown:

$ arm-none-eabi-size -A build/app.elf
build/app.elf :
section size addr
.isr_vector 392 134217728
.text 27340 134218120
.rodata 3112 134245460
.ARM.exidx 8 134248572
.data 112 536870912
.bss 2384 536871024
.ARM.attributes 48 0
.comment 73 0
.debug_info 61822 0
.debug_abbrev 9871 0
.debug_line 14590 0

Flash usage is .isr_vector + .text + .rodata + .ARM.exidx + .data (.data needs its initial values stored in flash). Sections at address 0 (.debug_*, .comment) live only in the ELF file, never on the chip; a 2 MB ELF does not mean a 2 MB image. The addresses are in decimal: 134217728 is 0x08000000.

nm --size-sort lists the biggest symbols:

$ arm-none-eabi-nm -S --size-sort -r build/app.elf | head -8
08002c48 00000d24 T _dtoa_r
08004a10 00000800 R sensor_lut
080039bc 0000046c T _printf_float
080014e0 00000440 T __aeabi_ddiv
08001920 00000318 T __aeabi_dmul
08005210 00000300 T shell_process
08001c38 0000025c T _svfprintf_r
08000f08 000001f4 T __aeabi_dadd

The second column is the size in hex. Here 3.3 KB of _dtoa_r and 1.1 KB of _printf_float come from enabling floating-point printf (-u _printf_float with newlib-nano), 2 KB is one lookup table, and about 2.5 KB of __aeabi_d* functions are the software implementation of double arithmetic. On an M4F, double arithmetic in a project that only needs float is almost always an accident.

The map file tells you why something was linked. Near the top, under “Archive member included to satisfy reference by file (symbol)”, the linker lists every library object it pulled in and the first reference that caused it:

Archive member included to satisfy reference by file (symbol)
.../libgcc.a(_arm_muldivdf3.o)
build/src/filter.o (__aeabi_dmul)
.../libc_nano.a(lib_a-dtoa.o)
.../libc_nano.a(lib_a-nano-vfprintf_float.o) (_dtoa_r)

So filter.o performs a double multiplication somewhere. -Wl,--trace-symbol=__aeabi_dmul (short form -Wl,-y,__aeabi_dmul) prints every file that references or defines a symbol, if the map is not enough.

The usual suspects, in rough order of how often they bite:

  • printf with float support, and printf in general. newlib-nano’s printf without floats is a few KB; with floats, another 5 to 10 KB plus double routines. The C standard library on a microcontroller covers the replacements: a small formatter, deferred logging, or printing fixed-point values as integers.
  • Accidental double. See the next section.
  • libm: calling sin() instead of sinf() drags in the double version and its dependencies.
  • Big tables: fonts, lookup tables, test vectors left in the release build. Check whether a table should be computed at startup into RAM, compressed, or reduced in resolution.
  • Duplicated const data: a static const array defined in a header gets a private copy in every file that includes it. Define it once in a .c file and declare it extern in the header.
  • C++ runtime: exceptions, RTTI, and iostreams add tens of KB; firmware C++ uses -fno-exceptions -fno-rtti.
  • Vendor HAL modules you never call, which the next-to-last section addresses.

The Cortex-M4F has a single-precision FPU: vadd.f32 and vmul.f32 take one cycle, vdiv.f32 and vsqrt.f32 about 14. It has no double-precision hardware: every double operation is a library call (__aeabi_dadd, __aeabi_dmul) costing tens to over a hundred cycles, plus code size.

C promotes to double more often than people expect:

promotion.c
#include <math.h>
float scale_bad(float x)
{
return x * 2.5 + sin(x); /* 2.5 is a double, sin() is the double version */
}
float scale_good(float x)
{
return x * 2.5f + sinf(x); /* all single precision */
}

In scale_bad, 2.5 is a double literal, so x is converted to double, multiplied in software, sin runs in double, and the result is converted back. The disassembly tells the story:

scale_bad:
push {r3, r4, r5, lr}
vmov r0, s0
bl __aeabi_f2d @ float -> double
...
bl __aeabi_dmul @ software double multiply
...
bl sin @ double sin from libm
...
bl __aeabi_d2f @ double -> float
scale_good:
push {r3, lr}
vpush {d8} @ s16 is callee-saved, kept across the call
vmov.f32 s15, #2.5 @ single-precision constant
vmul.f32 s16, s0, s15 @ one-cycle hardware multiply
bl sinf
vadd.f32 s0, s16, s0
vpop {d8}
pop {r3, pc}

Two tools catch this: -Wdouble-promotion warns whenever a float is implicitly promoted to double, and -fsingle-precision-constant makes GCC treat unsuffixed floating constants as float. Note that passing a float to printf always promotes it to double (variadic arguments), which is one more reason float printf is expensive.

Fixed point versus the FPU. On an M0 or M3 with no FPU, fixed-point arithmetic (from ADC, DAC, and analog basics) is far faster than software float. On the M4F, single-precision float is as fast as integer arithmetic for add and multiply, and easier to get right, so use it. Fixed point still makes sense on the M4F when you need exactly reproducible results across platforms, when values must fit 16-bit SIMD instructions, or inside interrupt handlers where you want to avoid the FPU’s extra stacking cost (covered below).

-ffast-math lets the compiler pretend floating point is real-number arithmetic: it reassociates expressions (changing results), flushes denormals, and assumes NaN and infinity never occur. That last assumption means if (isnan(reading)) can be compiled to if (0), silently removing your sensor-fault check. If you want some of its speed, enable the safe parts individually (for example -fno-math-errno, which lets sqrtf compile to a single vsqrt.f32), never the whole bundle in safety-relevant code.

The memory system: wait states, ART, and CCM

Section titled “The memory system: wait states, ART, and CCM”

The F407’s flash cannot be read at 168 MHz. At 3.3 V and 168 MHz it needs 5 wait states: each flash access takes 6 cycles, as you configured in FLASH_ACR in Clocks, reset, and timing. If every instruction fetch paid that, the chip would run at a sixth of its rated speed. ST’s ART accelerator hides most of it:

  • Flash is read 128 bits at a time, which holds 4 to 8 Thumb instructions.
  • A prefetch buffer (PRFTEN) fetches the next 128-bit line while the current one executes, so straight-line code runs at nearly full speed.
  • An instruction cache (ICEN) of 64 lines of 128 bits (1 KB) holds recently executed lines, so small loops run from the cache with no wait states after the first iteration.
  • A data cache (DCEN) of only 8 lines of 128 bits (128 bytes) holds recently read constants and literal pools.

The consequences you can measure:

  • Tight loops are fast. Code with many taken branches to uncached locations pays up to 5 extra cycles per jump.
  • Random reads from large tables in flash are slow. A 1 KB lookup table accessed at random indices misses the 128-byte data cache most of the time, adding wait states to every lookup. The same table in SRAM costs no wait states. You will see this in the CRC example.
  • Disabling the ART (or forgetting to enable it) makes everything several times slower. Check FLASH->ACR first when “the chip seems slow”.

Running code from RAM. You can place a function in SRAM with __attribute__((section(".ramfunc"))) and a matching linker section that the startup code copies, as shown in Linker scripts. On the F4 this is not automatically faster: SRAM code is fetched over the system bus, competing with data accesses and DMA, while ART-cached flash code is fetched over the dedicated instruction bus. RAM execution is worth it for code that must run while flash is busy (erase/program, as in Flash, EEPROM, and non-volatile storage), and for code whose timing must be the same on every run regardless of cache state, such as some ISRs. Because SRAM is more than 16 MB away from flash in the address map, calls between them are out of range for a bl instruction; the GNU linker inserts a small veneer automatically, costing a few cycles per call (or mark the function __attribute__((long_call))).

CCM RAM. The F407’s 64 KB of core-coupled memory at 0x10000000 sits on the CPU’s data bus only: zero wait states, no contention with DMA, but it cannot hold code (it is not on the instruction bus) and DMA cannot reach it. It is the ideal place for hot data: lookup tables, stacks, filter state, and the RTOS’s task stacks.

On the Cortex-M7 (STM32F7/H7), the picture changes: real L1 instruction and data caches (up to 16 KB each) and tightly coupled memories (ITCM for code, DTCM for data) with single-cycle access. Hot code goes in ITCM, hot data in DTCM, and DMA buffers need cache maintenance, as discussed in DMA.

The compiler is good at local optimisation. It needs your help where the information is outside the code it can see.

Aliasing and restrict. In this function the compiler cannot keep the running total in a register:

alias.c
#include <stddef.h>
#include <stdint.h>
void sum_into(uint32_t *total, const uint32_t *vals, size_t n)
{
for (size_t i = 0; i < n; i++) {
*total += vals[i]; /* load and store *total on every iteration */
}
}
void sum_into_fast(uint32_t *restrict total, const uint32_t *restrict vals, size_t n)
{
uint32_t acc = *total; /* or simply use a local accumulator */
for (size_t i = 0; i < n; i++) {
acc += vals[i];
}
*total = acc;
}

In sum_into, total might point into vals, so each store to *total could change a later vals[i]; the compiler must load and store through the pointer every iteration. restrict (from Pointers II) promises no overlap, and a local accumulator makes the point without any promise at all. Both produce a loop with one load and one add per element.

Unrolling. Each loop iteration costs a compare and a taken branch, and on the M4 a taken branch costs 1 to 3 extra cycles for the pipeline refill. The M4 has no branch predictor: its three-stage pipeline simply discards fetched instructions on every taken branch. Unrolling amortises that. GCC unrolls little at -O2; #pragma GCC unroll 4 before a loop asks for a specific factor without changing the whole file’s flags. Unrolling grows code, and too much of it can spill out of the 1 KB ART instruction cache and get slower, so measure.

Short conditionals become branch-free. For x = (a > b) ? a : b; GCC emits a compare followed by an it block with conditional moves, no branch at all. Code with simple conditionals is cheaper than its source suggests; code with unpredictable branches to far-away code is more expensive.

Division. The M4 has hardware divide (udiv, sdiv) taking 2 to 12 cycles depending on the operands. Division by a constant is cheaper still: the compiler replaces x / 10 with a multiply by a magic reciprocal and a shift. Division by a variable inside a hot loop costs the full udiv every time; hoist it out, or multiply by a precomputed reciprocal. On an M0, which has no divide instruction, every division is a library call costing tens of cycles or more. The classic case is a ring buffer index: i = (i + 1) % size with a variable size is a division per byte; a power-of-two size and i = (i + 1) & (size - 1) is one and.

Lookup tables versus computation. A table replaces computation with a memory access. On a desktop CPU with big caches that is usually a win; on an M4 with flash wait states and a 128-byte data cache, a table in flash can be slower than a few instructions of arithmetic. Put tables that are accessed randomly in SRAM or CCM, keep them small, and measure.

The Cortex-M4 adds DSP instructions that operate on two 16-bit or four 8-bit values packed in one register, plus saturating arithmetic. CMSIS exposes them as intrinsics:

dsp.c
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include "stm32f4xx.h" /* brings in the CMSIS intrinsics */
/* Dot product of two Q15 vectors; n must be even. */
int32_t dot_q15(const int16_t *a, const int16_t *b, size_t n)
{
uint32_t acc = 0;
for (size_t i = 0; i < n; i += 2u) {
uint32_t x, y;
memcpy(&x, &a[i], sizeof x); /* two samples in one 32-bit load */
memcpy(&y, &b[i], sizeof y);
acc = __SMLAD(x, y, acc); /* acc += a[i]*b[i] + a[i+1]*b[i+1] */
}
return (int32_t)acc;
}
int16_t q31_to_q15_sat(int32_t v)
{
return (int16_t)__SSAT(v >> 15, 16); /* clamp to -32768..32767, no wrap */
}

__SMLAD performs two signed 16-by-16 multiplies and adds both products to the accumulator in a single-cycle instruction, so with one 32-bit load per input pair the loop processes two samples per iteration. memcpy of 4 bytes compiles to a single ldr on the M4, which supports unaligned word loads, and avoids the aliasing problem of casting int16_t * to uint32_t *. __SSAT saturates instead of wrapping: an overflowing audio sample clips to full scale rather than flipping sign, which is the difference between a slightly distorted sound and a loud crack. Overflow of the 32-bit accumulator itself is your responsibility: one full-scale Q15 product is already up to 2^30, so two of them can reach 2^31 and overflow. Scale the inputs down, or use __SMLALD with a 64-bit accumulator.

For real signal processing, do not write the kernels yourself. Arm’s CMSIS-DSP library provides filters, FFTs, matrix operations, and statistics, hand-optimised for each core:

fir.c
#include "arm_math.h"
#define NUM_TAPS 32u
#define BLOCK_SIZE 64u
static const float32_t coeffs[NUM_TAPS] = { 0 }; /* from your filter design tool */
static float32_t state[NUM_TAPS + BLOCK_SIZE - 1u];
static arm_fir_instance_f32 fir;
void filter_init(void)
{
arm_fir_init_f32(&fir, NUM_TAPS, coeffs, state, BLOCK_SIZE);
}
void filter_block(const float32_t *in, float32_t *out)
{
arm_fir_f32(&fir, in, out, BLOCK_SIZE); /* processes 64 samples */
}

The state buffer holds the filter’s history between blocks and must be NUM_TAPS + BLOCK_SIZE - 1 samples. Processing in blocks (from DMA half-transfer and transfer-complete interrupts, as in DMA) is far more efficient than sample by sample, because the per-call overhead is amortised. q15 and q31 variants use the SIMD instructions above; on the M4F the f32 versions are often just as fast and much easier to scale. Older CMSIS-DSP versions need ARM_MATH_CM4 and __FPU_PRESENT defined; check your version’s documentation.

Interrupts done right covered the 12-cycle entry and what adds to it. The levers you control:

  • Priorities. The only way to bound the latency of a critical interrupt is to give it a higher priority than everything that can run long.
  • Short critical sections. Every __disable_irq() window adds directly to the worst-case latency of every interrupt. BASEPRI masking leaves the most urgent ones enabled.
  • Handler code in RAM removes the flash-wait-state variation on the first fetch after an ART cache miss. It matters for jitter more than for average latency.
  • Lazy FPU stacking. If a handler uses the FPU, the hardware must save the caller’s 18 FPU words (s0 to s15, FPSCR, and a reserved word). With lazy stacking (the default), it reserves the space at entry but only writes the registers if the handler actually executes an FPU instruction. A handler that touches a single float pays roughly 18 extra cycles of stacking. Keep floating point out of the shortest, most frequent handlers; check the disassembly for vmov, vldr, or vmul in them.

When -Os, --gc-sections, and removing printf floats are not enough:

  • newlib-nano (--specs=nano.specs) shrinks printf, malloc, and friends substantially; it is standard for MCU builds.
  • Assert messages. The standard assert stores the expression text, function name, and full file path as strings for every assertion. Dozens of asserts with long paths cost kilobytes. Replace them with an assert that records a numeric file ID and line:
fw_assert.h
#ifndef FW_ASSERT_H
#define FW_ASSERT_H
#include <stdint.h>
void fw_assert_failed(uint16_t file_id, uint16_t line); /* logs, then resets */
/* Each .c file defines FILE_ID before including this header. */
#define FW_ASSERT(expr) \
do { \
if (!(expr)) { \
fw_assert_failed((uint16_t)FILE_ID, (uint16_t)__LINE__); \
} \
} while (0)
#endif

Each assertion now costs a compare, a branch, and two constant loads; there are no strings at all. A table on the host maps IDs back to file names. GCC 12 and later also offer __FILE_NAME__ (just the base name), and -fmacro-prefix-map=/long/build/path/= shortens __FILE__ for code you cannot change.

  • Share, do not duplicate. Large static inline functions in headers are duplicated into every caller. Copy-pasted driver code for UART1 and UART2 doubles the size; one driver with an instance pointer does not.
  • -fshort-enums makes enums as small as their values allow. It also changes the ABI: objects compiled with and without it disagree on struct layouts and function arguments, and the linker warns about “variable-size enums”. Use it for the whole project and all its libraries, or not at all.
  • -Wl,--wrap=symbol redirects every call to symbol to __wrap_symbol, which can call the original as __real_symbol. Use it to replace a heavy library function without editing the code that calls it, or to make malloc fail loudly at link time in a project that must not allocate.
  • Remove unused vendor HAL modules. ST’s HAL compiles a module only if HAL_xxx_MODULE_ENABLED is defined in stm32f4xx_hal_conf.h. Generated projects often enable everything. --gc-sections removes unused functions, but not the initialisation code and tables that the enabled modules reference.

Worked example: CRC-32 from 58 cycles per byte to 1.6

Section titled “Worked example: CRC-32 from 58 cycles per byte to 1.6”

The bootloader in Bootloaders and firmware update computed CRC-32 bit by bit. That is fine for a one-time check at boot, but suppose the application now verifies a 64 KB data block every time it is written, and that must happen in under 2 ms. Measure, then optimise, keeping the zlib-compatible result unchanged.

crc_impl.c
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include "stm32f4xx.h"
#define POLY 0xEDB88320u
/* 1. Bitwise, as in the bootloader. ~36 bytes of code, no tables. */
uint32_t crc32_bitwise(const uint8_t *p, size_t n)
{
uint32_t crc = 0xFFFFFFFFu;
while (n--) {
crc ^= *p++;
for (int k = 0; k < 8; k++) {
crc = (crc >> 1) ^ (POLY & (0u - (crc & 1u)));
}
}
return crc ^ 0xFFFFFFFFu;
}
/* 2. Bitwise with the inner loop unrolled. */
uint32_t crc32_bitwise_unrolled(const uint8_t *p, size_t n)
{
uint32_t crc = 0xFFFFFFFFu;
while (n--) {
crc ^= *p++;
#pragma GCC unroll 8
for (int k = 0; k < 8; k++) {
crc = (crc >> 1) ^ (POLY & (0u - (crc & 1u)));
}
}
return crc ^ 0xFFFFFFFFu;
}
/* Tables for 3 and 4, built at startup in SRAM (4 KB). */
static uint32_t T[4][256];
void crc32_tables_init(void)
{
for (uint32_t i = 0; i < 256u; i++) {
uint32_t c = i;
for (int k = 0; k < 8; k++) {
c = (c >> 1) ^ (POLY & (0u - (c & 1u)));
}
T[0][i] = c;
}
for (uint32_t i = 0; i < 256u; i++) {
T[1][i] = (T[0][i] >> 8) ^ T[0][T[0][i] & 0xFFu];
T[2][i] = (T[1][i] >> 8) ^ T[0][T[1][i] & 0xFFu];
T[3][i] = (T[2][i] >> 8) ^ T[0][T[2][i] & 0xFFu];
}
}
/* 3. One table lookup per byte (uses T[0], 1 KB). */
uint32_t crc32_table(const uint8_t *p, size_t n)
{
uint32_t crc = 0xFFFFFFFFu;
while (n--) {
crc = (crc >> 8) ^ T[0][(crc ^ *p++) & 0xFFu];
}
return crc ^ 0xFFFFFFFFu;
}
/* 4. Slicing-by-4: one 32-bit load and four lookups per word. */
uint32_t crc32_slice4(const uint8_t *p, size_t n)
{
uint32_t crc = 0xFFFFFFFFu;
while (n >= 4u) {
uint32_t w;
memcpy(&w, p, sizeof w); /* single ldr on the M4 */
crc ^= w; /* little-endian: byte 0 in bits 7..0 */
crc = T[3][crc & 0xFFu] ^ T[2][(crc >> 8) & 0xFFu] ^
T[1][(crc >> 16) & 0xFFu] ^ T[0][crc >> 24];
p += 4;
n -= 4u;
}
while (n--) {
crc = (crc >> 8) ^ T[0][(crc ^ *p++) & 0xFFu];
}
return crc ^ 0xFFFFFFFFu;
}
/* 5. The STM32 CRC unit, made zlib-compatible with bit reversal. n % 4 == 0. */
uint32_t crc32_hw(const uint8_t *p, size_t n)
{
CRC->CR = CRC_CR_RESET; /* DR = 0xFFFFFFFF */
for (size_t i = 0; i < n; i += 4u) {
uint32_t w;
memcpy(&w, p + i, sizeof w);
CRC->DR = __RBIT(w);
}
return __RBIT(CRC->DR) ^ 0xFFFFFFFFu;
}

Versions 1 and 2 are the same algorithm; 2 only removes the inner loop’s compare-and-branch.

Version 3 is the classic table method: T[0][i] is the result of running the 8 bit-steps on the byte value i, so one lookup replaces eight steps.

Version 4, slicing-by-4, extends the idea: T[k][i] is the effect of byte value i followed by k zero bytes. XORing a whole little-endian word into the CRC and doing four independent lookups processes four bytes per iteration with one load instead of four. The four lookups do not depend on each other, so the M4 pipelines the loads back to back.

Version 5 uses the STM32’s hardware CRC unit (at 0x40023000, clock enable CRCEN in RCC_AHB1ENR bit 12, which you set once at startup). It computes the same CRC-32 polynomial but processes each 32-bit word most-significant bit first, with no final XOR. zlib’s CRC is the bit-reflected form, processing each byte least-significant bit first. Feeding __RBIT(w) (a single-cycle bit reversal) makes the unit see the bits in reflected order, reversing the final register gives the reflected result, and the final XOR completes the zlib definition. The trade-off: the length must be a multiple of four (handle a tail in software), and the unit is a shared peripheral, so two tasks using it need a mutex.

crc_bench.c
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include "cycles.h"
uint32_t crc32_bitwise(const uint8_t *p, size_t n);
uint32_t crc32_bitwise_unrolled(const uint8_t *p, size_t n);
uint32_t crc32_table(const uint8_t *p, size_t n);
uint32_t crc32_table_flash(const uint8_t *p, size_t n);
uint32_t crc32_slice4(const uint8_t *p, size_t n);
uint32_t crc32_hw(const uint8_t *p, size_t n);
void crc32_tables_init(void);
typedef uint32_t (*crc_fn_t)(const uint8_t *p, size_t n);
#define LEN 4096u
static uint8_t buf[LEN] __attribute__((aligned(4)));
static void bench(const char *name, crc_fn_t fn)
{
__disable_irq();
uint32_t t0 = cycles_now();
uint32_t crc = fn(buf, LEN);
uint32_t dt = cycles_now() - t0;
__enable_irq();
printf("%-18s crc=%08lx %7lu cycles %3lu.%02lu cycles/byte\n", name,
(unsigned long)crc, (unsigned long)dt, (unsigned long)(dt / LEN),
(unsigned long)((dt * 100u / LEN) % 100u));
}
void crc_bench_run(void)
{
for (uint32_t i = 0; i < LEN; i++) {
buf[i] = (uint8_t)(i * 7u + 3u);
}
cycles_init();
crc32_tables_init();
bench("bitwise", crc32_bitwise);
bench("bitwise unrolled", crc32_bitwise_unrolled);
bench("table (flash)", crc32_table_flash);
bench("table (SRAM)", crc32_table);
bench("slice-by-4 (SRAM)", crc32_slice4);
bench("hardware CRC", crc32_hw);
}

crc32_table_flash is version 3 reading a static const uint32_t table that lives in flash, generated at build time by a short script into an include file. It isolates the effect of where the table lives. Every function runs on the same 4 KB buffer with interrupts disabled, at 168 MHz with 5 wait states and the ART prefetch, instruction cache and data cache all enabled, built with -O2.

Representative numbers for an M4 at 168 MHz look like this. They are estimates, not captured output, so measure your own; counts shift by a few percent with the compiler version, but the ranking does not:

bitwise crc=5e4e1995 236750 cycles 57.80 cycles/byte
bitwise unrolled crc=5e4e1995 155853 cycles 38.05 cycles/byte
table (flash) crc=5e4e1995 57426 cycles 14.02 cycles/byte
table (SRAM) crc=5e4e1995 36905 cycles 9.01 cycles/byte
slice-by-4 (SRAM) crc=5e4e1995 21709 cycles 5.30 cycles/byte
hardware CRC crc=5e4e1995 6554 cycles 1.60 cycles/byte

All six print the same CRC, which you can confirm on the host with zlib.crc32 over the same byte pattern. Correctness first, then speed.

VersionCodeTableCycles/byte64 KB at 168 MHz
Bitwiseabout 36 Bnone57.822.5 ms
Bitwise, unrolledabout 100 Bnone38.114.8 ms
Byte table in flashabout 30 B1 KB flash14.05.5 ms
Byte table in SRAMabout 30 B1 KB RAM9.03.5 ms
Slicing-by-4 in SRAMabout 120 B4 KB RAM5.32.1 ms
Hardware CRC unitabout 40 Bnone1.60.6 ms

What each step teaches:

  • Unrolling saved a third. The bitwise loop spent about 20 of its 58 cycles per byte on the inner loop’s counter and taken branch. With no branch predictor, every taken branch costs pipeline refill cycles.
  • The table beat computation by four times, replacing eight dependent shift-and-XOR steps with one load.
  • Where the table lives cost 55%. The 1 KB table in flash is read at effectively random indices, so it misses the ART’s 128-byte data cache and pays wait states on most lookups. The identical code with the table in SRAM runs at 9 cycles per byte. Nothing in the C source reveals this; only the measurement does.
  • Word-at-a-time beat byte-at-a-time by cutting loads and loop iterations by four, at a cost of 4 KB of RAM. In CCM RAM it runs at the same speed and stays off the bus matrix used by DMA.
  • Dedicated hardware won, at 4 core cycles per word inside the unit plus a load, a bit reversal, and a store. For the 2 ms requirement, slicing-by-4 just misses and the hardware unit meets it with room to spare. If the CPU must do something else meanwhile, a memory-to-memory DMA transfer can feed the CRC unit’s data register with no CPU involvement at all.

The right answer depends on the constraint. The bootloader keeps the 36-byte bitwise version because it runs once per boot and flash in the bootloader is precious. The application uses the hardware unit for bulk data and the SRAM byte table where the hardware is busy. That is what optimisation looks like in practice: a measurement, a requirement, and a trade-off you can explain.

Optimisation costs readability, review time, and sometimes correctness. Before optimising, check:

  • Is there a measured problem? A requirement that is not met, or a budget that will not fit. “This loop looks slow” is not one.
  • Is this the hot spot? Doubling the speed of a function that takes 2% of the time gains 1%. The profiler tells you where the 80% is.
  • Is it the algorithm? A linear search over 500 entries, re-parsing a config string every loop, or recomputing something that never changes: fixing the algorithm usually beats any micro-optimisation.
  • Can the hardware do it? CRC units, DMA, timers in PWM or capture mode, and hardware FIFOs replace whole classes of CPU work.

Then optimise the one thing, measure again, keep the old version in a host unit test to check the new one gives identical results, and stop when the requirement is met.

  • Optimising without measuring. The CRC table in flash looked like an obvious win and was 55% slower than the same table in SRAM. Measure with CYCCNT or a scope before and after every change.
  • Accidental double. A 2.5 literal or a call to sin() pulls in software double routines and kilobytes of code. Use 2.5f, sinf(), and -Wdouble-promotion.
  • -ffast-math in a release build. It deletes isnan checks and changes results. Enable only the specific sub-flags you need.
  • Measuring with interrupts enabled. An ISR in the middle inflates the count. Mask interrupts or take the minimum of many runs.
  • Forgetting the ART. With prefetch and caches off, everything runs several times slower. Check FLASH->ACR after clock setup.
  • Reading .debug_* sizes as flash usage. Debug sections stay in the ELF. Use size or --print-memory-usage for what is actually on the chip.
  • Mixing -fshort-enums with libraries built without it. Struct layouts and enum arguments disagree silently apart from a linker warning. All or nothing.
  1. On your host, compile the five CRC implementations (replace crc32_hw with a stub) with gcc -std=c11 -O2 -Wall -Wextra and check that all of them return 0xCBF43926 for "123456789" and 0x5E4E1995 for the 4096-byte pattern used in the harness. Never benchmark code you have not verified.
  2. Compile scale_bad and scale_good with arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 -O2 -S and find the __aeabi_f2d, __aeabi_dmul and __aeabi_d2f calls in the first. Then add -Wdouble-promotion and read the warnings. Finally link a test program with each and compare arm-none-eabi-size output.
  3. Build your largest project with -Wl,-Map=app.map, run arm-none-eabi-nm -S --size-sort -r on the ELF, and list the ten largest symbols. For each one, use the “Archive member included” section of the map file or -Wl,--trace-symbol to find which of your files pulled it in. Remove or replace at least one and report the saving.
  4. Run the CRC benchmark on your board (or count instructions in the disassembly if you only have a simulator, since Renode does not model wait states). Then turn off the ART data cache (DCEN) and rerun: which versions change and why? Move T into CCM RAM and rerun slicing-by-4.
  5. Hard: implement the sampling profiler on a project that does something non-trivial (a UART shell and a sensor loop), collect 10 seconds of samples, and write a Python script that reads the histogram dump and the output of arm-none-eabi-nm -n and prints the top ten functions by sample count. Optimise the top one and show the before and after profiles.

Question 1:A 1 KB CRC lookup table is moved from a static const array in flash to a RAM copy, with identical code. The function gets 35% faster. Why?

Question 2:float y = x * 0.5 + 1.0; on a Cortex-M4F compiles to calls to __aeabi_f2d and __aeabi_dmul. What is the fix?

Question 3:In void add(uint32_t *total, const uint32_t *v, size_t n) { for (...) *total += v[i]; } the compiler stores to *total on every iteration. Why?

Question 4:Why does -ffast-math make if (isnan(reading)) report_fault(); dangerous?

Question 5:arm-none-eabi-size -A reports 61 KB of .debug_info for an image whose .text is 27 KB. How much flash do the debug sections use on the chip?

  • Measure before and after every change: DWT->CYCCNT for cycles, a GPIO toggle and a scope for timing and jitter, a PC-sampling profiler to find hot spots.
  • -Os with -ffunction-sections -fdata-sections -Wl,--gc-sections is the baseline for size; -flto adds more; the map file and nm --size-sort tell you where the rest went and why.
  • The M4F has single-precision hardware only: unsuffixed constants and math.h double functions silently pull in slow software doubles. Use f suffixes and -Wdouble-promotion.
  • On the F407, flash needs 5 wait states at 168 MHz; the ART hides them for sequential code, not for random table reads. Hot tables belong in SRAM or CCM; CCM cannot hold code.
  • The M4 has no branch predictor and no data cache worth the name, so unrolling, branch-free code, and memory placement matter more than on a desktop.
  • Hardware beats software when it exists: the CRC unit, DMA, and SIMD instructions often outperform the best C by an order of magnitude.

Next: Build systems, toolchain flags, and CI, where these flags move into a Makefile, a CMake project, and a pipeline that checks every commit.