Even after decades of C++ evolution, C remains the foundation of modern computing. Operating systems, embedded firmware, drivers, bootloaders, and performance-critical components rely heavily on C. Understanding Cβs role, its strengths, and how it interacts with C++ is essential for software engineers at all levels.
This article explores:
- Why C is still relevant
- Practical examples comparing C and C++
- Specific things C enables that C++ forbids or handles differently
- How to combine C and C++ effectively
- How Areg Framework leverages C and C++ together
Whether you are a beginner learning systems programming or a seasoned engineer, this guide strengthens your foundation.
TL;DR
Aspect | C | C++ | Notes |
---|---|---|---|
Runtime | Minimal, predictable | Adds constructors, destructors, RTTI | Embedded systems need minimal overhead |
ABI | Simple, stable, universal | Mangled for overloading / templates |
extern "C" can disable mangling |
Memory | Direct stack / heap access | Usually safer, more abstract | VLAs work in C, need vector in C++ |
Hardware access | Direct memory / register / interrupt | Requires workarounds or extern "C"
|
C is ideal for low-level firmware |
Abstraction | Low | OOP, templates | C++ is stronger at modular service layer |
1. C as the Foundation
C is often called the assembly language of high-level programming. It is lightweight, predictable, and gives developers direct control over memory and CPU resources.
Why systems rely on C:
- Stable ABI with minimal runtime overhead
- Direct access to memory, registers, and interrupts
- Extreme portability: microcontrollers, embedded Linux, Windows, macOS, and supercomputers
Modern C++ applications almost always depend on C libraries, OS kernels, or low-level firmware. Many frameworks build C++ layers on solid C foundations.
2. ABI and Linking: C vs C++
2.1 C: Simple, Predictable ABI
Cβs ABI is straightforward:
// addlib.c
int add(int a, int b) { return a + b; }
Compiled symbol (nm addlib.o
):
0000000000000000 T add
- Symbol
add
is unmangled, predictable across compilers - Can be linked from C, C++, Rust, Python, Go
- Memory layout is consistent; calling conventions are stable
This simplicity is crucial for cross-language libraries, embedded systems, and firmware hooks.
2.2 C++: Name Mangling Explained
Now letβs see how C++ achieves the same with stronger typing and linkage control. C++ supports function overloading, namespaces, and templates. To distinguish symbols, compilers mangle names:
// addlib.cpp
int add(int a, int b) { return a + b; }
double add(double a, double b) { return a + b; }
Symbol table:
0000000000000000 T _Z3addii // int add(int,int)
0000000000000010 T _Z3adddd // double add(double,double)
-
_Z3addii
encodes function name, namespace, and parameters - If C code calls
add(int,int)
directly, the linker cannot find_Z3addii
2.3 Using extern "C"
to Disable Mangling
extern "C" int add(int a, int b);
- Tells the C++ compiler to use C linkage
- Prevents mangling
- Required when C++ exposes functions to C, firmware, or interrupts
Example: C calling C++ function
/* demo.c */
#include <stdio.h>
extern int multiply(int,int);
int main() {
printf("3*4=%d\n", multiply(3,4));
return 0;
}
The C++ code implementation:
/* multiply.cpp */
extern "C" int multiply(int a, int b) { return a * b; }
Compile and run:
gcc -c demo.c
g++ -c multiply.cpp
g++ demo.o multiply.c.o -o demo_app
./demo_app
# Output: 3*4=12
Without extern "C"
, linker errors occur because C++ mangled symbols donβt match plain C symbols.
2.4 When You Can Ignore extern "C"
- Calling C++ functions from C++ code in the same project
- No overloading or templates required
- Internal C++ modules where symbol mangling is acceptable
3. Minimal Runtime Overhead
Cβs runtime is almost nonexistent:
#include <stdio.h>
int main(void) { puts("Hello C!"); return 0; }
Compile minimal binary:
gcc -nostdlib hello.c -o hello
C++ introduces runtime for:
- Constructors/destructors
- Exception handling
- RTTI (type information)
Even trivial C++ programs pull extra bytes. For embedded systems or performance-critical modules, this matters.
4. Direct Hardware Access and Interrupt
C allows memory-mapped I/O, register manipulation, and interrupts. Direct hardware access and interrupt handling are fully available in C++ low-level programming, though the syntax differs slightly. The following examples show how both C and C++ can achieve the same functionality.
4.1 C Version
#include <stdint.h>
#define LED_PORT (*(volatile uint32_t*)0x40021018U)
#define NVIC_ISER0 (*(volatile uint32_t*)0xE000E100U)
#define IRQ_LINE 5
// Interrupt Service Routine (ISR)
void __attribute__((interrupt)) my_irq_handler(void) {
LED_PORT ^= 0x1; // Toggle LED
}
// Interrupt vector table
__attribute__((section(".isr_vector")))
void (* const vector_table[])(void) = {
(void (*)(void))0x20001000, // Initial stack pointer
my_irq_handler // ISR for IRQ_LINE
};
int main(void) {
NVIC_ISER0 = (1U << IRQ_LINE); // Enable interrupt
LED_PORT = 0x1; // Turn LED on
while (1) {
// Main loop, do other tasks
}
return 0;
}
Explanation:
-
LED_PORT
is a direct memory-mapped register, declared asvolatile
to prevent compiler optimizations that could skip hardware writes. -
my_irq_handler
is the Interrupt Service Routine (ISR) -- a function automatically called when the interrupt occurs. - The
vector_table
is placed in a dedicated.isr_vector
section so the hardware knows where to find the ISR. - The main loop simply initializes the LED and keeps the program running.
4.2 C++ Version
#include <cstdint>
#define LED_PORT_ADDR 0x40021018U
volatile uint32_t& LED_PORT = *reinterpret_cast<volatile uint32_t*>(LED_PORT_ADDR);
#define NVIC_ISER0 (*(volatile uint32_t*)0xE000E100U)
constexpr uint32_t IRQ_LINE = 5;
// ISR must use extern "C" to prevent C++ name mangling
extern "C" void my_irq_handler() {
LED_PORT ^= 0x1; // Toggle LED
}
// Interrupt vector table
extern "C" void (* const vector_table[])(void) __attribute__((section(".isr_vector"))) = {
(void (*)(void))0x20001000, // Initial stack pointer
my_irq_handler // ISR for IRQ_LINE
};
int main() {
NVIC_ISER0 = (1U << IRQ_LINE); // Enable interrupt
LED_PORT = 0x1; // Initialize LED
while (true) {
// Main loop, do other tasks
}
return 0;
}
Explanation:
-
volatile uint32_t& LED_PORT
replaces the C macro with a typed reference, making the access safer and clearer. - The ISR is declared with
extern "C"
to avoid name mangling, which would otherwise make it invisible to the hardware interrupt vector. - Apart from that, the structure (vector table, interrupt setup, and main loop) is nearly identical to the C version.
4.3 Key Takeaways
- Direct memory access works identically in both C and C++.
-
Interrupt Service Routines (ISRs) require
extern "C"
in C++ for correct hardware linkage. - Program flow -- enabling interrupts, toggling LEDs, and looping -- remains exactly the same.
-
C++ syntax adds safety and clarity (e.g., references,
constexpr
), while maintaining the same low-level control.
These examples prove that C++ can handle the same bare-metal tasks as C -- direct register access, interrupt handling, and vector table definition, while giving developers more expressive tools for scaling complexity as projects evolve.
5. Predictability vs Abstraction
C code is explicit:
void print_message(const char *msg) { printf("%s\n", msg); }
int main(void){ print_message("C is predictable."); return 0; }
C++ adds hidden layers:
void print_message(const std::string &msg) { std::cout << msg << std::endl; }
- Constructors, destructors, iostream buffering, and locale handling execute automatically
- C gives deterministic behavior, ideal for debugging, low-level timing, and embedded systems
6. What C Can Do That C++ Cannot (or Does Differently)
Feature | C | C++ | Workaround / Alternative |
---|---|---|---|
Variable-length arrays (VLA) | β stack allocation | β |
std::vector (heap) |
Compound literals | β | β (C++11+) |
Point{...} in C++11+ |
Designated initializers | β | β (C++20+) | Pre-C++20: constructor or ordered init |
Implicit void* conversions |
β | β | Explicit cast (int*)malloc(...)
|
Flexible function pointer casts | β | β οΈ |
reinterpret_cast in C++ |
Examples
Example 1: Variable-length arrays (stack)
C Version:
void func(int n) { int arr[n]; } // stack allocation
C++ alternative (heap allocation):
#include <vector>
void func(int n){ std::vector<int> arr(n); } // heap allocation
Example 2: Compound literals
C Version:
struct Point { int x, y; };
draw((struct Point){1,2}); // legal in C
C++ Version (since C++11):
draw(Point{1,2});
Example 3: Designated initializers
C Version (since C99):
struct Config {
int baud;
int mode;
int parity;
};
int main(void) {
// Designated initializers in C
struct Config cfg = {
.baud = 9600,
.mode = 1,
.parity = 0
};
return 0;
}
C++ Version (since C++20):
int main() {
// Designated initializers in C++20
Config cfg = {
.baud = 9600,
.mode = 1,
.parity = 0
};
return 0;
}
The results of both examples are the same, but before C++20 Designated Initializers were not available.
Example 4: Implicit void*
C Version:
int *p = malloc(5 * sizeof(int)); // C implicit
C++ Version (explicit cast required):
int *p = static_cast<int*>(std::malloc(5 * sizeof(int))); // C++ explicit
β C++-preferred way: Use new
and delete
or standard containers (std::vector
, std::array
) for safer memory management.
Example 5: Flexible function pointer casting
C Version:
void say_hello(void) {
}
void (*fp1)(void) = (void(*)(void))0x08000000; // C direct
void (*fp2)(int) = (void (*)(int))say_hello; // cast between incompatible function pointer types
C++ Version (requires reinterpret_cast
):
void (*fp1)() = reinterpret_cast<void(*)()>(0x08000000);
void (*fp2)(int) = reinterpret_cast<void (*)(int)>(say_hello);
β Correct and portable C++ version:
#include <functional>
void say_hello() {
}
int main() {
// Define a wrapper to adapt signatures safely
auto wrapper = [] (int) { say_hello(); };
void (*safe_ptr)(int) = +wrapper; // use unary + to convert lambda to function pointer
safe_ptr(42); // safe and portable
}
7. Combining C and C++: Mini Demo
/* addlib.c */
int add(int a, int b) { return a + b; }
/* demo.cpp */
#include <iostream>
extern "C" int add(int,int);
int main() {
std::cout << "2+3=" << add(2,3) << std::endl;
}
Compile:
gcc -c addlib.c
g++ demo.cpp addlib.o -o demo_app
./demo_app
# Output: 2+3=5
- Shows predictable C ABI integration into C++
- Example is fully buildable
8. When and Why to Use C vs C++
- C: low-level, firmware, drivers, cross-language modules, predictable ABI
- C++: application logic, complex and distributed systems, modular applications and services
Insight: C is the βsteel frameβ; C++ is the architectural design layered on top. Use C for stability and minimal overhead; C++ for abstraction and modularity.
9. Promoting Areg SDK
Areg Framework is the heart of Areg SDK -- an open-source C++ framework designed to bring modern, service-oriented architecture to systems built on solid C foundations.
If your system runs C-based Linux, but you want a clean, modular, C++ layer for service-oriented distributed, multithreading and multiprocessing embedded and desktop applications, Areg SDK is your bridge. It lets you:
- Retain deterministic C performance
- Build reusable, modular C++ services
- Scale systems without rewriting the low-level firmware
For Zephyr RTOS, upcoming support will allow developers to create structured C++ services on real-time systems. The community is welcome to watch the repo, contribute, or help accelerate Zephyr integration.
Why Areg SDK is Different
- Predictable, minimal C foundation -- no hidden runtime overhead, ideal for firmware and embedded modules.
- Service-oriented C++ layers -- reusable service objects, clean separation of concerns, and easier scaling.
- Cross-platform design -- works across embedded Linux, desktops, Windows and virtualized environments.
- Future-proof architecture -- structured C++ services on RTOS, enabling safe and maintainable distributed systems.
Bottom line: With Areg SDK, you get the stability and control of C plus the abstraction and modularity of C++, enabling scalable, maintainable, and high-performance applications.
π Explore Areg SDK on GitHub, run examples to check features.
10. Final Thoughts
C is foundational; C++ builds upon it. Understanding both, and when to use each, makes you a more effective engineer. Build strong C foundations, then scale with C++ services for maintainable, high-performance software.
Top comments (0)