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.
Measure first
Section titled “Measure first”Cycle counting with the DWT
Section titled “Cycle counting with the DWT”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:
#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;}
#endifMeasure 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.
GPIO toggle and a scope
Section titled “GPIO toggle and a scope”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.
A sampling profiler
Section titled “A sampling profiler”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.
#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.
What the compiler does for you
Section titled “What the compiler does for you”Undefined behavior, volatile, and what the optimizer assumes covered what each -O level enables. For size and speed on Cortex-M, the practical summary:
| Flag | Effect on an M4 project |
|---|---|
-O0 | Every variable lives on the stack; two to three times larger and slower than -O2. Only for stepping through code |
-Og | Debuggable, close to -O1 in size and speed. Good default for development builds |
-O2 | The usual speed setting. No size-increasing transformations like aggressive unrolling |
-O3 | Unrolls 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 |
-flto | Link-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.
Finding bloat
Section titled “Finding bloat”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.elfbuild/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 0Flash 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 -808002c48 00000d24 T _dtoa_r08004a10 00000800 R sensor_lut080039bc 0000046c T _printf_float080014e0 00000440 T __aeabi_ddiv08001920 00000318 T __aeabi_dmul08005210 00000300 T shell_process08001c38 0000025c T _svfprintf_r08000f08 000001f4 T __aeabi_daddThe 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:
printfwith float support, andprintfin general. newlib-nano’sprintfwithout floats is a few KB; with floats, another 5 to 10 KB plusdoubleroutines. 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: callingsin()instead ofsinf()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
constdata: astatic constarray defined in a header gets a private copy in every file that includes it. Define it once in a.cfile and declare itexternin 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.
Floating point on the M4F
Section titled “Floating point on the M4F”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:
#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 -> floatscale_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->ACRfirst 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.
Helping the compiler with loops
Section titled “Helping the compiler with loops”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:
#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.
DSP on the M4
Section titled “DSP on the M4”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:
#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:
#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.
Interrupt latency
Section titled “Interrupt latency”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.BASEPRImasking 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 (
s0tos15,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 singlefloatpays roughly 18 extra cycles of stacking. Keep floating point out of the shortest, most frequent handlers; check the disassembly forvmov,vldr, orvmulin them.
Code size tricks
Section titled “Code size tricks”When -Os, --gc-sections, and removing printf floats are not enough:
- newlib-nano (
--specs=nano.specs) shrinksprintf,malloc, and friends substantially; it is standard for MCU builds. - Assert messages. The standard
assertstores 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:
#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)
#endifEach 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 inlinefunctions 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-enumsmakes 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=symbolredirects every call tosymbolto__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 makemallocfail 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_ENABLEDis defined instm32f4xx_hal_conf.h. Generated projects often enable everything.--gc-sectionsremoves 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.
The candidates
Section titled “The candidates”#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.
The harness
Section titled “The harness”#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 4096ustatic 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.
The results
Section titled “The results”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/bytebitwise unrolled crc=5e4e1995 155853 cycles 38.05 cycles/bytetable (flash) crc=5e4e1995 57426 cycles 14.02 cycles/bytetable (SRAM) crc=5e4e1995 36905 cycles 9.01 cycles/byteslice-by-4 (SRAM) crc=5e4e1995 21709 cycles 5.30 cycles/bytehardware CRC crc=5e4e1995 6554 cycles 1.60 cycles/byteAll six print the same CRC, which you can confirm on the host with zlib.crc32 over the same byte pattern. Correctness first, then speed.
| Version | Code | Table | Cycles/byte | 64 KB at 168 MHz |
|---|---|---|---|---|
| Bitwise | about 36 B | none | 57.8 | 22.5 ms |
| Bitwise, unrolled | about 100 B | none | 38.1 | 14.8 ms |
| Byte table in flash | about 30 B | 1 KB flash | 14.0 | 5.5 ms |
| Byte table in SRAM | about 30 B | 1 KB RAM | 9.0 | 3.5 ms |
| Slicing-by-4 in SRAM | about 120 B | 4 KB RAM | 5.3 | 2.1 ms |
| Hardware CRC unit | about 40 B | none | 1.6 | 0.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.
When not to optimise
Section titled “When not to optimise”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.
Common mistakes
Section titled “Common mistakes”- 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
CYCCNTor a scope before and after every change. - Accidental
double. A2.5literal or a call tosin()pulls in software double routines and kilobytes of code. Use2.5f,sinf(), and-Wdouble-promotion. -ffast-mathin a release build. It deletesisnanchecks 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->ACRafter clock setup. - Reading
.debug_*sizes as flash usage. Debug sections stay in the ELF. Usesizeor--print-memory-usagefor what is actually on the chip. - Mixing
-fshort-enumswith libraries built without it. Struct layouts and enum arguments disagree silently apart from a linker warning. All or nothing.
Exercises
Section titled “Exercises”- On your host, compile the five CRC implementations (replace
crc32_hwwith a stub) withgcc -std=c11 -O2 -Wall -Wextraand check that all of them return0xCBF43926for"123456789"and0x5E4E1995for the 4096-byte pattern used in the harness. Never benchmark code you have not verified. - Compile
scale_badandscale_goodwitharm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 -O2 -Sand find the__aeabi_f2d,__aeabi_dmuland__aeabi_d2fcalls in the first. Then add-Wdouble-promotionand read the warnings. Finally link a test program with each and comparearm-none-eabi-sizeoutput. - Build your largest project with
-Wl,-Map=app.map, runarm-none-eabi-nm -S --size-sort -ron the ELF, and list the ten largest symbols. For each one, use the “Archive member included” section of the map file or-Wl,--trace-symbolto find which of your files pulled it in. Remove or replace at least one and report the saving. - 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? MoveTinto CCM RAM and rerun slicing-by-4. - 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 -nand prints the top ten functions by sample count. Optimise the top one and show the before and after profiles.
Check yourself
Section titled “Check yourself”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?
At 168 MHz the F407 flash needs 5 wait states. The ART hides them for sequential code but its data cache is only 128 bytes, so scattered table reads mostly pay the full latency.
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?
Unsuffixed floating constants are double, which promotes the whole expression. The M4F has only single-precision hardware, so double operations become library calls.
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?
Without restrict or a local accumulator, the compiler must assume the two pointers may alias. A local accumulator, or restrict, lets it keep the sum in a register.
Question 4:Why does -ffast-math make if (isnan(reading)) report_fault(); dangerous?
-ffast-math includes -ffinite-math-only. Under that assumption isnan is always false and the optimiser deletes the fault handling.
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?
Sections with address 0 and no load flag, such as .debug_*, are never programmed into flash. Only loadable sections like .isr_vector, .text, .rodata and the initial values of .data occupy flash.
Key takeaways
Section titled “Key takeaways”- Measure before and after every change:
DWT->CYCCNTfor cycles, a GPIO toggle and a scope for timing and jitter, a PC-sampling profiler to find hot spots. -Oswith-ffunction-sections -fdata-sections -Wl,--gc-sectionsis the baseline for size;-fltoadds more; the map file andnm --size-sorttell you where the rest went and why.- The M4F has single-precision hardware only: unsuffixed constants and
math.hdouble functions silently pull in slow software doubles. Usefsuffixes 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.