| name | modern-cpp |
| description | C++20 best practices for ESP32/embedded, aligned with Google C++ Style Guide. Use when writing or reviewing C++ code. |
Modern C++ for ESP32 (C++20)
Guidelines for safe, efficient embedded C++ code based on the Google C++ Style Guide, adapted for ESP32/embedded conventions.
Source: Google C++ Style Guide
C++ Version: C++20 is supported on ESP-IDF 5.x (GCC 12.2+) and Arduino-ESP32 2.x+ (with build flag override). See Tooling section for build configuration.
Toolchain: ESP-IDF 5.x ships GCC 12.2. C++20 core language features are fully supported. Most <concepts>, <span>, <ranges> headers work. std::format is NOT available (libstdc++ 12 doesn't ship it). Use snprintf instead.
Naming Conventions
| Entity | Style | Example |
|---|
| Types (classes, structs, enums) | PascalCase | UrlTable, SensorState |
| Variables | snake_case | table_name, num_entries |
| Class data members | trailing _ | table_name_, count_ |
| Struct data members | no trailing _ | x, y |
| Constants | UPPER_SNAKE with prefix | ELERO_MAX_RETRIES |
| Functions | PascalCase | ReadSensor(), ProcessData() |
| Accessors/mutators | snake_case | count(), set_count() |
| Namespaces | snake_case | esphome::elero |
| Macros | UPPER_SNAKE with prefix | ELERO_MAX_SIZE |
| Enumerators | PascalCase or UPPER_SNAKE | kIdle, STATE_IDLE |
Note on constants: Google style uses kPascalCase, but ESP-IDF, ESPHome, and Arduino all use UPPER_SNAKE_CASE for constants. Follow ecosystem conventions for embedded code.
class SensorReader {
public:
void UpdateReading();
int reading() const { return reading_; }
private:
static constexpr int MAX_RETRIES = 3;
int reading_{0};
};
enum class State : uint8_t { IDLE, RUNNING, ERROR };
Integer Types
Use fixed-width types from <cstdint> when size matters. Use int for general purposes.
uint8_t register_value;
int32_t packet_count;
for (int i = 0; i < n; ++i)
Avoid: unsigned just for non-negativity, short/long/long long.
Error Handling (No Exceptions)
ESP32/embedded code doesn't use exceptions. Use return values instead.
[[nodiscard]] bool WriteRegister(uint8_t addr, uint8_t val);
[[nodiscard]] std::optional<Data> Parse(std::string_view input);
enum class Status { OK, INVALID_ARG, TIMEOUT };
[[nodiscard]] Status Initialize();
Use [[nodiscard]] on functions where ignoring the return value is likely a bug.
Constants
constexpr int MAX_RETRIES = 3;
constexpr uint32_t TIMEOUT_MS = 5000;
inline constexpr uint8_t ENCODE_TABLE[] = {0x08, 0x02, 0x0d};
consteval uint32_t hash(const char *s) { }
#define MAX_SIZE 100
const int MAX = ComputeMax();
static const uint8_t TABLE[] = {};
RAII and Smart Pointers
auto widget = std::make_unique<Widget>();
class SpiTransaction {
public:
explicit SpiTransaction(SpiDevice* dev) : dev_(dev) { dev_->Enable(); }
~SpiTransaction() { dev_->Disable(); }
SpiTransaction(const SpiTransaction&) = delete;
SpiTransaction& operator=(const SpiTransaction&) = delete;
private:
SpiDevice* dev_;
};
Concepts (C++20)
Use concepts to constrain templates. Provides clear error messages at the call site instead of deep template instantiation failures.
#include <concepts>
template<std::invocable<Device &> F>
void for_each_active(F &&fn);
template<typename T>
concept OutputAdapterLike = requires(T a, const Device &d) {
a.on_device_added(d);
a.on_state_changed(d);
a.setup(std::declval<DeviceRegistry &>());
};
template<typename T>
requires std::is_trivially_copyable_v<T>
void persist(const T &data);
When to use concepts:
- Template functions accepting callables (
std::invocable)
- Iterator/range parameters (
std::input_range, std::contiguous_range)
- Type constraints that would otherwise be SFINAE (
requires)
- Interface-like constraints on template parameters
Avoid: Over-constraining simple templates. If a template only has 1-2 call sites, a concept adds ceremony without value.
std::span (C++20)
Use std::span for non-owning views of contiguous data. Replaces (T* ptr, size_t len) pairs.
#include <span>
void process_buffer(const uint8_t *data, size_t len);
void process_buffer(std::span<const uint8_t> data);
void process_packet(std::span<const uint8_t, 64> packet);
uint8_t buf[64];
process_buffer(buf);
process_buffer({buf, 32});
process_buffer(std::span{buf}.first(n));
When to use std::span:
- Function parameters accepting buffer + length
- Iterating over contiguous data without ownership
- Replacing
const uint8_t *data, uint8_t len pairs
When NOT to use std::span:
- Hardware register APIs that need raw pointers (SPI
write_byte())
- C interop boundaries
- Single-element parameters (just use
const T&)
Designated Initializers (C++20, standardized)
Designated initializers are now standard C++20 (previously a GCC extension).
struct Context {
uint32_t open_duration_ms{0};
uint32_t close_duration_ms{0};
uint32_t timeout_ms{120000};
};
auto ctx = Context{
.open_duration_ms = 10000,
.close_duration_ms = 8000,
.timeout_ms = 60000,
};
Classes
Structs vs Classes
struct Point { float x; float y; };
class Circle {
public:
explicit Circle(float r) : radius_(r) {}
float Area() const;
private:
float radius_;
};
Declaration Order
class MyClass {
public:
protected:
private:
};
Rule of Zero/Five
class Widget {
std::string name_;
std::vector<int> data_;
};
class RawResource {
public:
~RawResource();
RawResource(const RawResource&);
RawResource& operator=(const RawResource&);
RawResource(RawResource&&) noexcept;
RawResource& operator=(RawResource&&) noexcept;
};
Move-only and Non-copyable
class UniqueHandle {
public:
UniqueHandle(const UniqueHandle&) = delete;
UniqueHandle& operator=(const UniqueHandle&) = delete;
UniqueHandle(UniqueHandle&&) noexcept = default;
UniqueHandle& operator=(UniqueHandle&&) noexcept = default;
};
Inheritance
- Prefer composition over inheritance
- Use
override and final explicitly
- Avoid multiple implementation inheritance (interface inheritance OK)
class Base {
public:
virtual void Process() = 0;
virtual ~Base() = default;
};
class Derived : public Base {
public:
void Process() override;
};
Functions
Parameter Passing
| Type | When |
|---|
T (value) | Small types (≤16 bytes), will copy anyway |
const T& | Large types, read-only |
T* | Nullable output parameter |
std::string_view | Read-only strings |
std::span<const T> | Read-only contiguous data (C++20) |
Guidelines
- Prefer return values over output parameters
- Write short, focused functions (~40 lines max)
- Use
explicit on single-argument constructors
- Don't use default arguments on virtual functions
Type Deduction
Use auto only when it improves clarity.
auto widget = std::make_unique<Widget>();
auto it = map.find(key);
auto [key, value] = *it;
auto result = Compute();
Other Guidelines
Preincrement
++i;
sizeof
memset(&data, 0, sizeof(data));
const
void Process(const std::string& input);
int value() const { return value_; }
Switch — exhaustive, no default
switch (state) {
case State::IDLE:
break;
case State::RUNNING:
break;
case State::ERROR:
break;
}
Switch — with fallthrough
switch (state) {
case State::IDLE:
break;
case State::RUNNING:
[[fallthrough]];
case State::ERROR:
break;
}
Scoped Enums
enum class State : uint8_t { IDLE, RUNNING };
Casting
static_cast<int>(x);
Three-way Comparison (C++20)
auto operator<=>(const Point&) const = default;
Headers
Include Guards
#pragma once
Inline Variables (C++17+)
inline constexpr uint8_t ENCODE_TABLE[] = {0x08, 0x02};
static const uint8_t ENCODE_TABLE[] = {0x08, 0x02};
Forward Declarations
Use when only pointers/references needed to reduce compile dependencies.
Formatting
- 2-space indentation, no tabs
- 80-character line limit
- Opening brace on same line
- Space after
if, for, while, switch
Tooling
Compiler Warnings
The compiler is your primary type checker. Use strict flags:
-Wall -Wextra -Wpedantic
-Wshadow
-Wconversion
-Wsign-conversion
-Wnull-dereference
-Wold-style-cast
-Woverloaded-virtual
-Wformat=2
-Wdouble-promotion
-Werror
ESP32/ESPHome Configuration
ESP-IDF 5.x defaults to C++23 (-std=gnu++23). Arduino framework defaults to C++11. Override Arduino for C++20:
esphome:
platformio_options:
build_unflags: -std=gnu++11
build_flags:
- -std=gnu++2a
build_unflags = -std=gnu++11
build_flags =
-std=gnu++2a
-Wall -Wextra -Wshadow -Wconversion
Note: Use -std=gnu++2a (not -std=c++20) for Arduino to keep GNU extensions that ESP32 Arduino core depends on. ESP-IDF configs don't need any override.
Static Analysis
| Tool | Usage |
|---|
| clang-tidy | Gold standard linter. Use .clang-tidy config file |
| cppcheck | Fast, good for CI: cppcheck --enable=all src/ |
Example .clang-tidy:
Checks: >
-*,
bugprone-*,
cppcoreguidelines-*,
modernize-*,
performance-*,
readability-*,
-modernize-use-trailing-return-type,
-readability-magic-numbers
WarningsAsErrors: ''
HeaderFilterRegex: '.*'
Run: clang-tidy src/*.cpp -- -std=c++20 -I include/
Formatting
Use clang-format with a .clang-format file:
BasedOnStyle: Google
IndentWidth: 2
ColumnLimit: 100
AllowShortFunctionsOnASingleLine: Inline
AllowShortIfStatementsOnASingleLine: false
Run: clang-format -i src/*.cpp include/*.h
IDE Integration
- VS Code: clangd extension (provides clang-tidy + format)
- CLion: Built-in clang-tidy and clang-format support
- PlatformIO IDE: Basic integration available
ESP Platform Constraints
Atomics: Platform Support
| Platform | std::atomic | Notes |
|---|
| ESP32 | Supported | Lock-free for 8/16/32-bit types, use freely |
| ESP8266 | Linker errors | undefined reference to '__atomic_exchange_1' |
| RP2040 | Linker errors | No hardware atomic support |
For ESP32-only projects: Use std::atomic — it's the correct solution for ISR-to-loop communication and avoids race conditions in read-modify-write patterns.
std::atomic<bool> received_{false};
void IRAM_ATTR isr() {
received_.store(true, std::memory_order_release);
}
void loop() {
if (received_.exchange(false)) {
process();
}
}
For portable code (ESP8266/RP2040 support): Use volatile instead. Single bool read/write is inherently atomic on ARM and Xtensa, but read-modify-write patterns have race conditions.
volatile bool received_{false};
void IRAM_ATTR isr() {
received_ = true;
}
void loop() {
if (received_) {
received_ = false;
process();
}
}
When you need atomics (ESP32):
- ISR flag read-and-clear (avoids lost updates)
- Multi-core synchronization (ESP32 is dual-core)
- Any read-modify-write operation shared with ISR
Memory Constraints
| Platform | RAM | Flash | Notes |
|---|
| ESP8266 | 80KB | 4MB | Very limited, avoid large buffers |
| ESP32 | 520KB | 4-16MB | Comfortable for most uses |
| RP2040 | 264KB | 2MB | Moderate constraints |
Prefer stack allocation for small objects, static allocation for fixed buffers.
C++20 Features Available on ESP32 (GCC 12.2)
| Feature | Header | Status | Notes |
|---|
| Concepts | <concepts> | Available | Use for template constraints |
std::span | <span> | Available | Non-owning buffer views |
| Designated initializers | (language) | Available | Standardized from GCC extension |
consteval | (language) | Available | Guaranteed compile-time |
constinit | (language) | Available | Guaranteed static init |
[[no_unique_address]] | (language) | Available | EBO for members |
| Three-way comparison | <compare> | Available | operator<=> |
std::ranges | <ranges> | Partial | Views work, some algorithms missing |
std::format | <format> | NOT available | Use snprintf instead |
| Modules | (language) | NOT available | GCC 12 has no module support |
| Coroutines | <coroutine> | Available but avoid | Too heavy for embedded |
Anti-Patterns
| Avoid | Prefer |
|---|
new/delete | Smart pointers, containers |
(int)x | static_cast<int>(x) |
NULL | nullptr |
#define CONST 5 | constexpr int CONST = 5 |
typedef | using Alias = Type |
int arr[N] | std::array<int, N> |
| Output parameters | Return values |
i++ (unused value) | ++i |
static const in header | inline constexpr |
| Implicit conversions | explicit constructors |
volatile for ISR read-clear (ESP32) | std::atomic<bool> with .exchange() |
(T* buf, size_t len) pairs | std::span<T> (C++20) |
| Unconstrained templates | std::invocable, concepts (C++20) |
default: in exhaustive switch | Handle all cases, return after switch |