When to Use C++ and Which Features to Use
Honestly, whenever I see a "C vs C++" holy war break out in the embedded community, I feel pretty helpless. The debate often quickly slides into the realm of faith—C users think C++ is a cult, and C++ users think C is primitive. But the real question is: For this project, on this hardware, is using this language cost-effective? No one can answer that for you, but I can share experience gained from actual projects to help you avoid some detours.
In this chapter, we need to clarify two things: First, what kind of project is worth upgrading to C++; second, once we use C++, which features should we use boldly, which should we use with caution, and which should be avoided if possible.
When is C++ Worth the Effort
Let's start with the major premise: If your project code scale exceeds tens of thousands of lines and involves multiple subsystems that need clear interface boundaries, then the advantages of C++ will start to become apparent. In C, maintaining projects of this scale is certainly doable, but you need the team to invest a lot of energy in maintaining the code structure—manually managing module division, hand-writing interface abstractions, and manually ensuring type safety. C++ classes, namespaces, and templates do exactly these things at the language level. Especially when multiple subsystems require strict interface definitions, C++'s type system can block a large number of interface misuse errors at compile time, whereas in C, these often only expose themselves at runtime.
Type safety is directly a matter of life and death in safety-critical systems. Automotive electronics, medical equipment, aerospace—in these fields, the implicit type conversions and loose typing common in C are simply ticking time bombs. C++'s strong type system, enum class, reference semantics, and constexpr correctness can prevent a large number of low-level errors from the compiler level. This is not some fancy theoretical advantage, but a tangible reduction in the probability of bugs entering the product.
Code reuse demand is also an important consideration. If your project needs to reuse components across multiple product lines, or has a large number of similar but not identical functional modules, C++'s template mechanism can be powerful—it generates type-safe code at compile time with zero runtime overhead. Compared to the "generics" cobbled together by macros and void* in C, C++'s solution is both safe and elegant.
But to be fair, the prerequisite for using C++ is that the team has the corresponding technical reserve. If the entire team has only written C, hasn't heard of RAII, and has no mechanism for training and code review, then rashly using C++ will most likely lead to a crash. Conversely, if the team has members familiar with modern C++ practices who can formulate and execute reasonable coding standards, then the advantages of C++ can truly be realized.
When C is Still the Better Choice
Conversely, in some scenarios, sticking with C is the more pragmatic choice. When the target platform is extremely resource-constrained—for example, low-cost MCUs with Flash less than 32KB and RAM less than 4KB—the simplicity and predictability of C are its greatest advantages. For simple applications with small code size (e.g., less than five thousand lines), introducing C++ adds unnecessary complexity. Additionally, if the project requires deep integration with a large amount of legacy C code, or if the target platform's toolchain has incomplete support for C++ (which is not uncommon on some niche chips), continuing with C is often the most worry-free decision.
Our Good Friends: Recommended Core Features
Alright, assuming you've decided to go with C++. Next, we need to figure out which features should become the basic toolbox for daily development. These features all conform to the principle of zero-overhead abstraction—enjoying better code organization without paying a runtime price.
Classes and Encapsulation
Classes and encapsulation are one of the most basic and valuable features of C++. Let's look directly at a practical example—a sensor driver. In C, you might be used to writing this:
// sensor.c
static volatile uint32_t* const SENSOR_ADDR = (uint32_t*)0x40000000;
void sensor_init() {
*SENSOR_ADDR = 0x01;
}
uint32_t sensor_read() {
return *SENSOR_ADDR;
}The problem with this approach is obvious: SENSOR_ADDR is a global variable, and any place can directly manipulate it; no one can stop it. The C++ approach is to encapsulate the register address and access logic inside the class, exposing only init and read interfaces:
// sensor.hpp
class Sensor {
static constexpr volatile uint32_t* const ADDR =
reinterpret_cast<volatile uint32_t*>(0x40000000);
public:
void init() const {
*ADDR = 0x01;
}
[[nodiscard]] uint32_t read() const {
return *ADDR;
}
};The key is that the code generated by the compiler is almost indistinguishable from the machine code of the C version above—member functions are inline by default, so there is no loss in performance. However, external code can no longer touch the registers directly, drastically compressing the possibility of errors.
Namespaces
Naming conflicts in large projects are a headache, especially when you integrate several third-party libraries. The traditional C approach is to add prefixes to function names, like sensor_init(), timer_init(), uart_init(), which works but isn't elegant. C++ namespaces provide a more systematic solution—organizing related functions and classes into logical groups, fundamentally eliminating naming conflicts:
namespace Drivers {
void init();
void transfer();
}
namespace Utils {
void init(); // No conflict with Drivers::init
void log();
}Best of all, namespaces are a pure compile-time feature and produce no runtime overhead.
Reference Semantics
Compared to pointers, references have two key advantages: first, references cannot be null, so there is no need for null pointer checks; second, reference syntax more clearly expresses the function's intent. When we need to pass a large struct but don't want to copy, a const reference is both efficient and safe; when a function needs to modify the passed argument, a non-const reference clearly indicates this intent:
void update_config(Config& cfg) {
cfg.baud_rate = 115200;
}
void print_status(const Config& cfg) {
printf("Baud: %d\n", cfg.baud_rate);
}Compared to the C pointer approach, we save the nullptr check, and the code is more concise. Underlying implementation, references are usually just pointers, so there is no additional performance overhead.
Compile-Time Calculation (constexpr)
constexpr is a killer feature of modern C++ in embedded development. It allows the compiler to complete calculations during the compilation phase, and the generated code directly contains the result value, with zero runtime overhead. For example, calculating the serial port baud rate divisor:
constexpr uint32_t calculate_baud_div(uint32_t clock, uint32_t baud) {
return clock / baud;
}
constexpr uint32_t UART_DIV = calculate_baud_div(72000000, 115200);The traditional approach is to do division at runtime, while with a constexpr function, this division is completed at compile time. When the program runs, the value of UART_DIV is already 625, requiring no calculation. This not only improves performance but also makes the intent of the code clearer. It can even be used directly as an array size:
std::array<uint32_t, UART_DIV> buffer;Strongly Typed Enums (enum class)
Traditional C enums have several annoying problems: they implicitly convert to integers, values between different enums can be mixed, and enum names pollute the outer scope. enum class introduced in C++11 solves all these problems at once:
enum class Periph : uint32_t {
UART1 = 0x40011000,
UART2 = 0x40004400
};
void reset(Periph p) {
*reinterpret_cast<volatile uint32_t*>(p) = 0;
}Now if you try to pass the wrong type, the compiler will directly report an error, instead of silently accepting it like C enums and giving you a runtime surprise:
reset(Periph::UART1); // OK
reset(0x40011000); // Compile errorMoreover, compilers usually optimize enum class into ordinary integers, so there is absolutely no loss in performance.
Templates: Don't Swing the Sword Blindly
Templates are C++'s most powerful but also most easily abused feature. In embedded environments, we need to find a balance between code reuse and code bloat.
Simple Templates: Use with Confidence
Simple function templates are usually safe because they are often inlined by the compiler, and the final generated code is exactly the same as a hand-written specific type version:
template<typename T>
T clamp(T val, T min, T max) {
return (val < min) ? min : (val > max) ? max : val;
}Class Templates: It Depends
Class templates are also useful in appropriate scenarios, with a typical example being a fixed-size ring buffer. By taking the element type and size as template parameters, we achieve a generic but zero-overhead buffer:
template<typename T, size_t N>
class RingBuffer {
T data[N];
size_t head = 0, tail = 0;
public:
bool push(T item) { /* ... */ }
bool pop(T& item) { /* ... */ }
};Since the size is determined at compile time, the compiler can fully optimize.
⚠️ But there is a pitfall to note: every different combination of template parameters generates a separate copy of the code. If you instantiate RingBuffer<uint8_t, 128> and RingBuffer<uint8_t, 256>, there will be two almost identical copies of the code in Flash. So use templates, but don't abuse them.
SFINAE and if constexpr: Use but Don't Overcomplicate
More advanced template techniques, like SFINAE and type traits, should be used with caution in embedded environments. C++17's if constexpr is much clearer than traditional SFINAE; if you really need to select different implementations based on type, prioritize using it:
template<typename T>
auto process(T val) {
if constexpr (std::is_integral_v<T>) {
return val * 2;
} else {
return val;
}
}Only consider these techniques when you truly need compile-time type constraints, and keep it as simple as possible. If template metaprogramming gets too complex in embedded systems, even you won't understand it two weeks later.
Features Requiring a Disclaimer
Some C++ features are not unusable, but require extra care. The following features are like double-edged swords in embedded projects—used well, they are sharp tools; used poorly, they are time bombs.
Constructors and Destructors
Simple, fast construction and destruction are perfectly fine. RAII-style resource management is the best example—acquire resources during construction and automatically release them during destruction, which is both safe and elegant:
class LockGuard {
Mutex& mtx;
public:
explicit LockGuard(Mutex& m) : mtx(m) { mtx.lock(); }
~LockGuard() { mtx.unlock(); }
};Usage is very simple; leaving the scope automatically releases it, and even if you return early, you won't forget to unlock:
void critical_task() {
LockGuard lock(mutex);
// ... do critical stuff ...
// Automatic unlock here
}⚠️ But if you do dynamic memory allocation (new), call hardware initialization that might fail, or create objects requiring destruction in an interrupt context within the constructor, you are asking for trouble. The correct approach is to keep the constructor simple and use an explicit init() function to handle initialization that might fail:
class SpiDriver {
public:
SpiDriver() = default; // Do nothing
Error init() { // Explicit initialization
// Setup hardware...
return Error::OK;
}
};Also, destructors must be marked noexcept—if an exception is thrown during destruction, the program will directly call std::terminate, which in embedded systems means a crash.
Exception Handling: Disabled by Default
In embedded projects, my advice is to directly turn off exceptions via the -fno-exceptions compiler flag. This isn't bias—turning off exceptions can reduce code size by 10% to 30%, eliminate unpredictable execution time, and avoid the extra RAM overhead from stack unwinding. Moreover, many embedded toolchains have incomplete support for exceptions themselves, making debugging impossible if problems arise.
So what about error handling? Use error codes. While not as elegant as exceptions, they are predictable, efficient, and easy to analyze for worst-case scenarios:
enum class Error { OK, Timeout, InvalidParam };
Error sensor_read(int& value) {
if (!sensor_ready()) return Error::Timeout;
value = *SENSOR_ADDR;
return Error::OK;
}For scenarios that need to return a value and an error status simultaneously, you can use a simple struct to separate the two:
struct Result {
Error err;
int value;
};⚠️ Unless your system has ample Flash and RAM, relaxed real-time requirements, full toolchain support for exceptions, and the team has extensive experience using exceptions—don't touch exceptions.
RTTI: Turn It Off
Run-Time Type Information (RTTI) should also be disabled by default, using the -fno-rtti compiler flag. RTTI increases code size, requires extra metadata storage, and brings performance overhead. In the vast majority of embedded scenarios, if you need to determine the type, adding a type ID enum to the base class is sufficient; you don't need dynamic_cast.
Virtual Functions: Restricted Use
Virtual functions provide runtime polymorphism and are indeed useful when designing driver abstraction layers. But the cost is real: every object containing virtual functions needs a virtual table pointer (vptr) (4 to 8 bytes), virtual function calls are 5% to 10% slower than direct calls, and they can hinder compiler inline optimization.
If you only need compile-time polymorphism, passing specific types via template parameters is sufficient, with zero runtime overhead. Virtual functions should only be used in scenarios that truly require runtime polymorphism, and avoid frequent calls on performance-critical paths.
Features to Avoid If Possible
The following features are recommended to be avoided in embedded environments if possible.
Dynamic Memory Allocation
new, delete, malloc, free—operations that are commonplace in desktop development are sources of risk in embedded systems. Heap fragmentation can cause memory allocation failures after running for a while, and such failures are extremely difficult to reproduce and debug. The unpredictable execution time of dynamic allocation also makes worst-case analysis impossible.
Alternatives are to use fixed-size data structures. The standard library's std::array is a safe choice; it involves no dynamic memory. If you need dynamically sized containers, you can implement static capacity versions, or use memory pools—pre-allocate a fixed number of equally sized memory blocks, where allocation and deallocation are both O(1) and fragmentation-free.
Most STL Containers
std::vector, std::list, std::map, std::string—these containers all rely on dynamic memory allocation and are not suitable for embedded environments. std::shared_ptr's reference counting involves atomic operations, which has significant overhead on some platforms. std::iostream should be completely avoided; a simple printf can introduce over 50KB of code.
But not all of the standard library is unusable. Algorithms in <algorithm> (note that some allocate temporary memory), compile-time tools like <type_traits>, and std::array and std::span in <array>—these are all zero-overhead or low-overhead good stuff.
If you really need containers, look at the Embedded Template Library (ETL), which provides fixed-size containers that don't use dynamic memory and are STL-compatible.
Standard Multithreading Library
std::thread, std::mutex—these components have large code volume and rely on specific operating system support. In embedded systems, it is usually better to use primitives provided directly by the RTOS—FreeRTOS tasks, semaphores, and queues, or CMSIS-RTOS standard interfaces—these have been optimized for the embedded environment and occupy fewer resources.
Final Words
Choosing the right features is just the first step. To truly implement it in a project, you need to establish clear coding standards, specifying what is allowed, what is forbidden, and what needs review. Code review should be standard, specifically watching for sneaky use of disabled features, templates that are too complex causing code bloat, and virtual functions appearing where they shouldn't. Static analysis tools can help us automatically detect many such problems; don't skip this step.
In terms of performance, periodically check the compiled binary size to ensure there is no unexpected bloat. Make actual measurements of performance-critical code paths; don't just rely on feeling. Compilers have many optimization options, but the effects need to be verified by actual measurement—don't try them directly in the production environment; make sure they work on the development board first.
Language choice is not a matter of faith, but an engineering problem. Let data speak, choose tools by module layer, establish and enforce constraints with automated means. Remember this one sentence: use the right tool for the right job, don't turn tools into beliefs.