DEV Community

Cover image for πŸš€ Why C Still Matters Even in a C++ World?
Artak Avetyan
Artak Avetyan

Posted on

πŸš€ Why C Still Matters Even in a C++ World?

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; }
Enter fullscreen mode Exit fullscreen mode

Compiled symbol (nm addlib.o):

0000000000000000 T add
Enter fullscreen mode Exit fullscreen mode
  • 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; }
Enter fullscreen mode Exit fullscreen mode

Symbol table:

0000000000000000 T _Z3addii   // int add(int,int)
0000000000000010 T _Z3adddd   // double add(double,double)
Enter fullscreen mode Exit fullscreen mode
  • _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);
Enter fullscreen mode Exit fullscreen mode
  • 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;
}
Enter fullscreen mode Exit fullscreen mode

The C++ code implementation:

/* multiply.cpp */
extern "C" int multiply(int a, int b) { return a * b; }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

Compile minimal binary:

gcc -nostdlib hello.c -o hello
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Explanation:

  1. LED_PORT is a direct memory-mapped register, declared as volatile to prevent compiler optimizations that could skip hardware writes.
  2. my_irq_handler is the Interrupt Service Routine (ISR) -- a function automatically called when the interrupt occurs.
  3. The vector_table is placed in a dedicated .isr_vector section so the hardware knows where to find the ISR.
  4. 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;
}
Enter fullscreen mode Exit fullscreen mode

Explanation:

  1. volatile uint32_t& LED_PORT replaces the C macro with a typed reference, making the access safer and clearer.
  2. The ISR is declared with extern "C" to avoid name mangling, which would otherwise make it invisible to the hardware interrupt vector.
  3. 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; }
Enter fullscreen mode Exit fullscreen mode

C++ adds hidden layers:

void print_message(const std::string &msg) { std::cout << msg << std::endl; }
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

C++ alternative (heap allocation):

#include <vector>
void func(int n){ std::vector<int> arr(n); } // heap allocation
Enter fullscreen mode Exit fullscreen mode

Example 2: Compound literals

C Version:

struct Point { int x, y; };
draw((struct Point){1,2}); // legal in C
Enter fullscreen mode Exit fullscreen mode

C++ Version (since C++11):

draw(Point{1,2});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

C++ Version (since C++20):

int main() {
    // Designated initializers in C++20
    Config cfg = {
        .baud = 9600,
        .mode = 1,
        .parity = 0
    };
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

C++ Version (explicit cast required):

int *p = static_cast<int*>(std::malloc(5 * sizeof(int))); // C++ explicit
Enter fullscreen mode Exit fullscreen mode

❗ 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
Enter fullscreen mode Exit fullscreen mode

C++ Version (requires reinterpret_cast):

void (*fp1)() = reinterpret_cast<void(*)()>(0x08000000);
void (*fp2)(int) = reinterpret_cast<void (*)(int)>(say_hello);
Enter fullscreen mode Exit fullscreen mode

❗ 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
}
Enter fullscreen mode Exit fullscreen mode

7. Combining C and C++: Mini Demo

/* addlib.c */
int add(int a, int b) { return a + b; }
Enter fullscreen mode Exit fullscreen mode
/* demo.cpp */
#include <iostream>
extern "C" int add(int,int);

int main() {
    std::cout << "2+3=" << add(2,3) << std::endl;
}
Enter fullscreen mode Exit fullscreen mode

Compile:

gcc -c addlib.c
g++ demo.cpp addlib.o -o demo_app
./demo_app
# Output: 2+3=5
Enter fullscreen mode Exit fullscreen mode
  • 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)