C++ has several keywords that look related because they contain const, but they solve different engineering problems. That difference matters in real code reviews: a value may be read-only through one reference while another owner can still change it, a helper may be available for compile-time use but still run at runtime, and a global may be initialized early while remaining mutable for the rest of the program.
When those guarantees are blurred, the code often looks cleaner than it really is. The practical way to choose between const, constexpr, consteval, and constinit is to start with the failure you want the compiler to catch, then pick the keyword that gives that guarantee without adding noise.
Start with the guarantee, not the spelling
The useful question is not which keyword looks most modern, but what failure you are trying to prevent. In a firmware review, the following problems may all look like “constant” problems at first, but they point to different fixes:
- A driver accidentally rewrites caller configuration.
- A buffer size calculation silently changes after a constant is edited.
- A protocol identifier is built from runtime data even though it should be fixed by the protocol.
- A global depends on dynamic initialization before the startup path is ready.
These are four different risks, even though the keywords look related at first glance. They overlap in spelling more than they overlap in purpose, which is why choosing by habit tends to produce code that is either too weak or unnecessarily strict.
const prevents mutation through this access path
const is mainly a local contract about mutation. It says an object cannot be modified through this name, pointer, or reference, but it does not automatically mean the value was computed at compile time, and it does not always mean the underlying object can never change.
That contract is useful in driver configuration, parser inputs, sample buffers, and any API where the callee should read data without changing the caller’s state. It also makes reviews easier, because the function signature tells you what the function is allowed to do before you read the implementation.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
#include <cstdint> struct UartConfig { std::uint32_t baud_rate; std::uint8_t data_bits; bool parity_enabled; }; void uart_init(const UartConfig& config) { /* Rejected: the init function must not rewrite caller configuration. */ // config.baud_rate = 9600; /* Good: read the configuration and apply it. */ uart_set_baud(config.baud_rate); uart_set_format(config.data_bits, config.parity_enabled); } |
The phrase “through this access path” is the detail worth keeping. A const reference does not freeze every possible reference to the same object; if another non-const name still exists, that name can still modify the object.
|
1 2 3 4 5 6 7 8 9 10 11 |
int retry_count = 3; const int& read_only_view = retry_count; /* Rejected: this view is read-only. */ // read_only_view = 4; /* Good: the original object is still mutable. */ retry_count = 4; |
That distinction is not language trivia. It matters when a function stores references, when multiple modules share state, or when a const reference is only a view into data owned elsewhere. const protects one access path, but it is not an ownership model.
Const member functions are API promises
Member functions use const as a promise that the call does not modify the observable state of the object. That is especially useful for small value types, register descriptions, decoded protocol fields, and measurement objects where readers expect inspection methods to be harmless.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
class SensorReading { public: explicit SensorReading(std::int32_t microvolts) : microvolts_{microvolts} {} double volts() const { /* Good: conversion does not change the stored sample. */ return microvolts_ / 1'000'000.0; } private: std::int32_t microvolts_ {}; }; |
There are legitimate uses of mutable, such as caching a derived value or protecting a lock object, but it should stay rare in small embedded types. If many members need to be mutable, the type is probably carrying more responsibility than its name suggests.
Use const when the rule is about write access. It is the right tool for API clarity, not a shortcut for compile-time constants.
constexpr makes compile-time use possible
constexpr is about constant expressions. A constexpr variable must be initialized with a value the compiler can compute, and a constexpr function can run at compile time when its inputs are known at compile time.
That is useful for fixed buffer sizes, register masks, protocol constants, table dimensions, and small calculations where an early compiler check is cheaper than a late debugging session.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
#include <array> #include <cstdint> constexpr std::uint32_t ADC_CHANNELS = 8u; constexpr std::uint32_t SAMPLES_PER_CHANNEL = 64u; constexpr std::uint32_t total_samples() { /* Good: fixed acquisition shape checked by the compiler. */ return ADC_CHANNELS * SAMPLES_PER_CHANNEL; } std::array<std::uint16_t, total_samples()> dma_buffer {}; /* Good: build fails if the buffer math changes unexpectedly. */ static_assert(total_samples() == 512u); |
A common mistake is assuming every constexpr function call happens at compile time. It does not. If the arguments are runtime values, the same function can execute at runtime.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
constexpr std::uint32_t bytes_for_samples(std::uint32_t count, std::uint32_t bytes_per_sample) { return count * bytes_per_sample; } /* Good: known at compile time. */ static_assert(bytes_for_samples(16u, 2u) == 32u); std::uint32_t requested = read_requested_sample_count(); /* Good: same helper, runtime input. */ auto bytes = bytes_for_samples(requested, 2u); |
That dual behavior is often exactly what you want, because one small helper can serve compile-time checks and normal runtime code. If runtime use would be a design error, though, constexpr is too permissive, and that is where consteval becomes useful.
consteval forces compile-time evaluation
consteval means the function must produce its result during compilation. If you call it with runtime data, the compiler rejects the program. The language reference calls this kind of function an immediate function, which is a good mental model because there is no runtime fallback path.
This is useful when runtime use would hide a design mistake: command identifiers built from literal fields, fixed register masks, compile-time pin validation, or any helper where the value must be known before the program runs.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
#include <cstdint> consteval std::uint16_t command_id(char high, char low) { return (static_cast<std::uint16_t>(high) << 8) | static_cast<std::uint16_t>(low); } /* Good: protocol constants are fixed at build time. */ constexpr auto CMD_READ = command_id('R', 'D'); constexpr auto CMD_WRITE = command_id('W', 'R'); char runtime_prefix = read_prefix_from_uart(); /* Rejected: live UART data is not a compile-time constant. */ // auto id = command_id(runtime_prefix, 'D'); |
consteval can also turn configuration mistakes into build errors. For embedded work, that is useful when a value belongs to the board design or protocol definition rather than to a runtime setting.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
consteval int valid_gpio_pin(int pin) { if (pin < 0 || pin > 31) { throw "GPIO pin is outside the supported range"; } return pin; } /* Good: board constant checked during the build. */ constexpr int LED_PIN = valid_gpio_pin(13); int pin = read_pin_setting(); /* Rejected: runtime configuration needs a runtime check. */ // auto checked_pin = valid_gpio_pin(pin); |
Use consteval when the compiler should refuse runtime use. Do not use it only because it looks stricter; if a helper is useful in both compile-time and runtime paths, constexpr is usually the more practical choice.
constinit controls static initialization
constinit is about startup initialization. It checks that a variable with static or thread-local storage duration is initialized during static initialization, but it does not make the variable read-only.
This matters in systems where startup order is part of reliability. Global objects across translation units can run into dynamic initialization order problems, and firmware startup is already complicated enough without a global quietly depending on runtime work before main() or before the scheduler starts.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
#include <cstdint> constexpr std::uint32_t default_timeout_ms() { return 250u; } /* Good: initialized before dynamic startup begins. */ constinit std::uint32_t global_timeout_ms = default_timeout_ms(); void set_timeout(std::uint32_t value) { /* Good: constinit controls initialization, not later writes. */ global_timeout_ms = value; } |
The compiler rejects an initializer that needs runtime work:
|
1 2 3 4 5 6 7 |
std::uint32_t read_timeout_from_flash(); /* Rejected: flash read is dynamic initialization, not static initialization. */ // constinit std::uint32_t timeout = read_timeout_from_flash(); |
For firmware-style code, constinit is reasonable for simple global state that must have a known early value, such as counters, flags, pointers initialized to nullptr, and small compile-time defaults.
|
1 2 3 4 5 6 7 8 9 10 11 |
constinit bool fault_latched = false; constinit std::uint32_t boot_counter = 0u; void record_boot() { /* Good: mutable state with predictable early initialization. */ ++boot_counter; } |
Use constinit when initialization timing is the problem you are solving. If the problem is mutation, use const; if the problem is compile-time use, use constexpr; and if runtime use must be impossible, use consteval.
Useful combinations and noisy combinations
Some combinations express a real design choice, while others only make the declaration look more deliberate than it really is.
A constexpr variable is already constant, so const constexpr is redundant in normal code.
|
1 2 3 4 5 6 7 8 |
/* Good: fixed value usable in constant expressions. */ constexpr int MAX_PACKET_BYTES = 256; /* Bad: redundant keyword adds no useful guarantee. */ // const constexpr int OLD_STYLE_MAX_PACKET_BYTES = 256; |
constinit const can be useful for a static object that must be initialized early and must not change later. If the value also needs to participate in static_assert, template arguments, or array bounds, constexpr is normally clearer. One language detail worth remembering is that constinit and constexpr cannot be applied to the same declaration.
|
1 2 3 4 5 6 7 8 9 10 |
/* Good: early initialization and read-only storage. */ constinit const int FIRMWARE_MAJOR = 3; /* Good: usable in compile-time checks. */ constexpr int FIRMWARE_MINOR = 7; static_assert(FIRMWARE_MINOR == 7); |
For member functions, constexpr and trailing const solve different problems, so it is common for them to appear together.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
struct RegisterField { int offset; int width; constexpr int mask() const { /* Good: compile-time capable and does not modify the field description. */ return ((1 << width) - 1) << offset; } }; static_assert(RegisterField{4, 3}.mask() == 0b01110000); |
Here constexpr says mask() can participate in compile-time evaluation, while the trailing const says the call does not mutate the RegisterField object. Those are separate guarantees, and both are useful.
Embedded fit and toolchain checks
For small embedded systems, const and constexpr are usually easy to justify because they improve clarity without adding runtime machinery. consteval and constinit are C++20 features, so support depends on the exact compiler, standard library, and selected language mode. Larger RTOS systems, ESP-IDF-class projects, embedded Linux, and host-side tools generally have more room to use modern C++ features, but the compiler mode still has to be real.
Before standardizing on C++20 keywords across a firmware codebase, check the toolchain and project constraints that decide whether the guarantees are actually available:
- The exact compiler version and C++ mode, such as
-std=c++20. - Vendor documentation for partial or nonconforming support.
- Whether the same code must build in host tests and target firmware.
- Whether startup code, linker scripts, and static storage rules are documented for the project.
- Whether the code is shared with older branches or customer SDKs that still use C++17.
Useful references:
- cv qualifiers reference
- constexpr reference
- consteval reference
- constinit reference
- GCC C++ status
- Clang C++ status
Selection table
| Keyword | Main guarantee | Good use case | Common mistake |
|---|---|---|---|
const |
This access path cannot modify the object | Read-only parameters, views, member functions | Treating it as a compile-time guarantee |
constexpr |
Can participate in constant expressions | Buffer sizes, masks, small reusable calculations | Assuming every call runs at compile time |
consteval |
Must run at compile time | Build-time IDs, board constants, validation helpers | Using it for values that may be runtime settings |
constinit |
Static or thread-local object is initialized early | Global flags, counters, simple startup state | Assuming it means immutable |
Practical takeaway
These keywords are easier to use when you name the failure you want to prevent. Use const to prevent accidental writes through an API, constexpr when compile-time use is valuable but runtime use is still valid, consteval when runtime use would hide a design error, and constinit when startup initialization order is the risk.
That is the practical distinction. Good modern C++ does not use the strongest-looking keyword everywhere; it uses the keyword that gives the right guarantee with the least confusion for the next engineer reading the code.
