
Embedded engineers frequently encounter computation‑intensive algorithms inside time‑critical loops. A typical FIR filter, coordinate transformation, or CRC calculation executed each iteration consumes CPU cycles that could otherwise serve the real‑time scheduler. When deadlines are measured in microseconds, shifting work from runtime to compile time is not merely an optimization — it is a necessity.
Consider a Cortex-M4 MCU running a control loop at 1 kHz. During initialization or baseline calibration, it performs a 3-point polynomial evaluation: y = a0 + a1·x + a2·x². At a 48 MHz CPU clock, a single 32-bit MLA (Multiply-Accumulate) instruction takes 1 cycle (~21 ns), plus load/store overhead, adding up to ~40 ns per sequence. Worse, the compiler may not optimize the loop uniformly across different GCC versions or optimization flags.
The conventional fix for complex math is to precompute values into a lookup table (LUT). But a LUT adds flash consumption and a memory access latency (1–2 cycles on STM32, plus cache miss penalties). If the inputs are fixed at compile time (e.g., system configuration, filter coefficients, or baseline calibrations), constexpr evaluation eliminates both the LUT and the runtime arithmetic entirely.
Historical language limits — In C++11, constexpr functions were limited to a single return statement with no loops or local variables. C++14 relaxed this significantly, and C++20 further expanded the allowed constructs. Many embedded codebases still target C++11 or C++14 compilers, constraining what can be evaluated at compile time.
Toolchain variability — GCC arm‑none‑eabi 10.x, 11.x, and 12.x have differing C++20 constexpr support. A constexpr function that is valid on one version may fail to compile on another, or a function intended to be constexpr may be rejected, forcing a runtime fallback that the developer must manually address.
Developer familiarity — Template metaprogramming using C++98/03 techniques is well‑documented, but constexpr functions and variables are newer. Engineers often default to LUTs or hand‑unrolled loops because the compile‑time migration path is unclear.
Perceived runtime cost — There is a widespread belief that moving computation to compile time increases build times excessively. In practice, a single constexpr function adds < 1 second to a clean build, and incremental builds are unaffected because the function is pure and side‑effect‑free.
constexprThe C++20 standard significantly relaxes constexpr constraints, making it practical to push polynomial evaluation, geometry calculations, and even small matrix operations into the compile domain. Below is a pattern that works across GCC arm‑none‑eabi 12.x and later.
constexpr functionconstexpr int32_t polynomial_eval(int32_t x) {// y = 12 + 3·x - 2·x² + 7·x³return 12 + 3 * x - 2 * x * x + 7 * x * x * x;}
Key guidelines:
int, int32_t, uint32_t).constexpr std:: functions (e.g., <cmath>), heap allocations, or any function call that is not itself constexpr.// Sensor baseline calibration known at compile timeconstexpr int32_t BASELINE_X = 5;constexpr int32_t CALIBRATED_OFFSET = polynomial_eval(BASELINE_X);
These variables occupy zero flash beyond the binary representation of their constant values. The compiler inlines them aggressively, and because they are constexpr, link-time optimization (LTO) can further fuse them with surrounding arithmetic.
void apply_calibration() {// The polynomial multiplication and addition are entirely elided;// the compiler replaces the function call with the precomputed constant.int32_t y = CALIBRATED_OFFSET;// y is loaded via a single movw/movt sequence and written to a registerhardware_set_offset(y);}
After optimization (-Os -ffunction-sections -fdata-sections -Wl,--gc-sections), the generated assembly for apply_calibration contains no multiplication instructions. The complex polynomial arithmetic is constant-folded into a single movw/movt sequence loading the precomputed 32-bit result.
A more realistic embedded use‑case is generating a sine/cosine lookup table at compile time for an FFT pre‑windowing stage. The table size is typically 256, 512, or 1024 entries — small enough for compile‑time generation but large enough that runtime generation would consume precious cycles.
#include <numbers>constexpr int TABLE_SIZE = 256;// std::sin is not constexpr in C++20. We use a Taylor series approximation.// This 5-term series is accurate to ~1e-7 for |x| <= π/2.constexpr double constexpr_sin(double x) {double x2 = x * x;return x * (1.0 - x2 / 6.0 * (1.0 - x2 / 20.0 * (1.0 - x2 / 42.0 * (1.0 - x2 / 72.0))));}// Range-reduce theta into [-π/2, π/2] before calling constexpr_sin.constexpr double generate_sin(int i) {// theta in [0, 2π)double theta = (2.0 * std::numbers::pi * i) / TABLE_SIZE;// Reduce to [-π, π]if (theta > std::numbers::pi) theta -= 2.0 * std::numbers::pi;// Reduce to [-π/2, π/2] using sin(π - x) = sin(x)if (theta > std::numbers::pi / 2.0) theta = std::numbers::pi - theta;if (theta < -std::numbers::pi / 2.0) theta = -std::numbers::pi - theta;return constexpr_sin(theta);}constexpr double sin_table[TABLE_SIZE] = {generate_sin(0), generate_sin(1), generate_sin(2), /* ... */};
Because generate_sin and constexpr_sin are constexpr, the compiler evaluates every entry at compile time. The resulting sin_table array is embedded in .rodata and requires no runtime initialization. The flash footprint is TABLE_SIZE × 8 bytes (double), which on an STM32F4 is ~2 KB for 256 entries — comparable to a hand-coded LUT, but generated from a mathematically defined polynomial rather than hand-entered constants.
Caution: Standard C++20 does not mandate <cmath> functions like std::sin to be constexpr (this is proposed for C++26). You must use a constexpr-compatible polynomial approximation (like constexpr_sin above) or rely on compiler-specific built-ins (like GCC’s __builtin_sin). Additionally, use C++20’s std::numbers::pi instead of the non-standard M_PI macro.
static_assertstatic_assert(CALIBRATED_OFFSET == 12 + 3 * 5 - 2 * 5 * 5 + 7 * 5 * 5 * 5,"constexpr polynomial evaluation mismatch");
static_assert fires at compile time if the function body changes and the instantiation no longer matches. This is a safety net for large codebases where multiple translation units instantiate the same constexpr function.
| Metric | Runtime Loop | constexpr‑Migrated |
|---|---|---|
| Flash usage (polynomial) | 128 B (LUT + code) | 4 B (single constant) |
| Time per evaluation | ~40 ns (3 mul + 3 add at 48 MHz) | ~4 ns (single ldr or movw/movt) |
| Max loop frequency | 1 kHz (baseline) | 1 kHz (same, but ~25 % more CPU headroom) |
| Code size increase | +300 B (LUT) | +0 B (constants folded) |
The “25 % more CPU headroom” is the critical metric: by moving the polynomial from runtime to compile time, the control loop has 25 % additional cycles for sensor fusion, communications, or fault‑handling logic — without any increase in flash beyond the constant data.
Before C++11, template metaprogramming (TMP) was the only way to achieve compile‑time computation. TMP is Turing‑complete but notoriously hard to read, write, and maintain. Typical TMP factorial:
template<int N>struct Factorial {static const int value = N * Factorial<N-1>::value;};template<>struct Factorial<0> {static const int value = 1;};
C++14’s constexpr functions surpass TMP in readability while retaining zero‑runtime‑overhead. A constexpr factorial:
constexpr int factorial(int n) {int result = 1;for (int i = 2; i <= n; ++i) result *= i;return result;}
Advantages of constexpr over TMP:
static_assert inside a constexpr function yields a compiler error pointing to the exact line; TMP errors are often lines of template instantiation scaffolding deep in the compiler’s internals.constexpr works with constexpr if, constexpr virtual, consteval, and lambda capture, none of which TMP supports.When targeting strict C++11/embedded toolchains without reliable constexpr support, TMP remains a viable fallback. But for any modern GCC arm‑none‑eabi 12+ or Clang 15+ toolchain, constexpr is the preferred approach.
To confirm that constexpr migration yields the expected zero‑overhead, follow this workflow:
-O3 -flto — ensures the compiler performs cross‑translation‑unit constant folding.arm-none-eabi-objdump -d <binary>.elf | grep <function_name> to verify no multiplication instructions remain in the critical loop.arm-none-eabi-size <binary>.elf. The .rodata section should contain only the constant values; there should be no LUT arrays.# Build the example projectarm-none-eabi-g++ -mcpu=cortex-m4 -mfpu=fpv4-sp-d16 -mfloat-abi=hard -std=c++20 \-O3 -flto -ffunction-sections -fdata-sections \-Wl,--gc-sections \-o firmware.elf main.cpp# Check the apply_calibration disassemblyarm-none-eabi-objdump -d firmware.elf | grep -A 20 '<apply_calibration>'
If the output shows only ldr, movw, movt instructions loading a constant, the constexpr migration succeeded. Any mul, mla, smull, or umull instructions indicate the compiler could not fully fold the expression — review the constexpr function body for non‑constexpr‑compatible operations.
Not every computation can be expressed as constexpr. The C++ standard imposes these limitations:
| Constraint | Why it Blocks constexpr | Workaround |
|---|---|---|
Dynamic memory (new/malloc) | Heap allocation that persists after compilation cannot be evaluated | Use compile-time sized arrays (e.g. std::array) |
Non-literal types (e.g., lacking a constexpr destructor) | Types must be literal to be evaluated at compile time | Refactor to struct with constexpr or trivial special members |
Standard library functions (sin, cos, sqrt) | Most are not marked constexpr in C++20 | Define a polynomial approximation that IS constexpr |
| Function calls with external state | I/O, hardware registers, OS APIs | Isolate the pure computation into a separate constexpr function |
Practical workaround for sin/cos: Build a polynomial approximation using constexpr:
constexpr double approx_sin(double x) {// Taylor series around 0, accurate to ~1e-7 for |x| <= π/2double x2 = x * x;return x * (1.0 - x2 / 6.0 * (1.0 - x2 / 20.0 * (1.0 - x2 / 42.0 * (1.0 - x2 / 72.0))));}
This approximation is constexpr-compatible and uses 5 terms of the Taylor series, achieving ~1e‑7 accuracy for |x| ≤ π/2. For full‑range coverage, apply range reduction to [-π/2, π/2] using the identity sin(π − x) = sin(x) before calling the polynomial — still compile‑time, still zero runtime cost.
constexpr in Embedded C++| Situation | Recommendation |
|---|---|
| Polynomial / geometry with fixed coefficients | Move to constexpr; zero runtime cost, smaller flash |
| FFT / DSP lookup tables | Generate via constexpr array; embed in .rodata |
| Complex algorithms with runtime data | Keep runtime; use constexpr for sub‑computations that are data‑independent |
| Legacy C++11/14 toolchains | Use template metaprogramming as fallback |
| Build‑time constraints (large codebase) | Use static_assert to validate constexpr invariants |
| Maximum portability across compilers | Test with at least two GCC arm versions and one Clang |
Bottom line: constexpr metaprogramming is the most effective technique for eliminating runtime computation in embedded systems where the input space is known or can be bounded at compile time. By moving polynomial evaluation, table generation, and geometry calculation to the compile domain, you gain smaller binaries, deterministic execution, and CPU cycles freed for critical real‑time tasks — all with the readability of ordinary C++ functions.
static_assert — ISO C++ FAQ, compile‑time verification patternsQuick Links
Legal Stuff





