Skip to content

Modern Embedded C++ Tutorial — Does C++ Always Bloat Your Code?

When it comes to performance evaluation and program size, I believe most developers have a good feel for the former, but might find the latter slightly unfamiliar — especially those working on host applications. In an era where storage seems increasingly cheap, few people care about the release package size of desktop applications anymore. In embedded systems, however, where every byte of Flash is as precious as gold, we absolutely need to consider program size.

This brings up a question. You know this is the Modern Embedded C++ Tutorial (though I sometimes accidentally call it the Embedded Modern C++ Tutorial), but this is an age-old, endlessly debated topic: Does C++ always bloat your code?

Before We Begin: Sharpen Your Tools First

Before we dive into our code battle, make sure you have these tools in your toolbox:

arm-none-eabi-gcc / arm-none-eabi-g++

This is the cross-compiler from x86_64 to the ARM platform. Let's give it a try:

cpp

[charliechen@Charliechen arm-linux]$ arm-none-eabi-gcc --version
arm-none-eabi-gcc (Arch Repository) 14.2.0
Copyright (C) 2024 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

[charliechen@Charliechen arm-linux]$ arm-none-eabi-g++ --version
arm-none-eabi-g++ (Arch Repository) 14.2.0
Copyright (C) 2024 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

If you see a version number, congratulations! If you see "command not found", you probably need to download the toolchain from the official ARM website. I use Arch Linux, so I just install it via pacman or yay.

By the way, the correct package name is gcc-arm-none-eabi. Otherwise, you'll be missing standard dependencies. Try installing arm-none-eabi-gcc first, and if the demo fails to build, install the standard EABI package.

bash

# 编译C语言代码的标准姿势
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -Os -c example.c -o example.o

# 编译C++代码的标准姿势,不要exception,也不要rtti,笔者之前就说过了
arm-none-eabi-g++ -mcpu=cortex-m4 -mthumb -Os -fno-exceptions -fno-rtti -c example.cpp -o example.o

# 查看你的代码到底有多"胖"
arm-none-eabi-size example.o

# 如果你想看看编译器到底把你的代码变成了啥样
arm-none-eabi-objdump -d example.o

-fno-exceptions and -fno-rtti are the "diet pills" for using C++ in embedded systems. Without these two flags, your firmware might bloat up like dough rising with yeast due to the exception handling mechanism code.


Starting with Blinking an LED: GPIO Driver (It's Just an LED, How Hard Can It Be?)

Our first task is to put previous concepts into practice. Let's see what our code looks like across different languages and programming paradigms, and how they actually perform.

Task Overview

We want to implement a GPIO driver to control an LED. This is the "Hello World" of the embedded world, as classic as printing "Hello World" when learning to program. The features include:

  • Turn on/off (well...)
  • Toggle state
  • PWM dimming (just to show off)

C Version — Plain and Simple

c
// gpio_driver.c
#include <stdint.h>
#include <stdbool.h>

// 硬件寄存器定义(这是在和硬件对话的门牌号)
#define GPIO_BASE 0x40020000
#define GPIO_ODR  (*(volatile uint32_t*)(GPIO_BASE + 0x14))
#define GPIO_BSRR (*(volatile uint32_t*)(GPIO_BASE + 0x18))

// GPIO句柄结构体(把状态打包带走)
typedef struct {
    uint8_t pin;
    bool state;
    uint8_t pwm_duty;  // 0-100,就像电灯的亮度旋钮
} GPIO_Handle;

// 初始化GPIO(给我们的LED安个家)
void gpio_init(GPIO_Handle* handle, uint8_t pin) {
    handle->pin = pin;
    handle->state = false;
    handle->pwm_duty = 0;
}

// 设置输出状态(开灯关灯就靠它了)
void gpio_write(GPIO_Handle* handle, bool value) {
    if (value) {
        GPIO_BSRR = (1 << handle->pin);  // 原子操作,不怕中断捣乱
    } else {
        GPIO_BSRR = (1 << (handle->pin + 16));  // 高16位是复位位
    }
    handle->state = value;
}

// 切换状态(懒得记住当前是开还是关?用这个!)
void gpio_toggle(GPIO_Handle* handle) {
    gpio_write(handle, !handle->state);
}

// 设置PWM占空比(让LED可以调亮度)
void gpio_set_pwm(GPIO_Handle* handle, uint8_t duty) {
    if (duty > 100) duty = 100;  // 防止有人手滑输入101
    handle->pwm_duty = duty;
}

// 获取当前状态(查看灯现在到底是开还是关)
bool gpio_read(GPIO_Handle* handle) {
    return handle->state;
}

// 使用示例(三行代码搞定一个LED)
void example_c(void) {
    GPIO_Handle led;
    gpio_init(&led, 5);  // 用GPIO 5号引脚

    gpio_write(&led, true);   // 开灯
    gpio_toggle(&led);        // 切换(现在是关)
    gpio_set_pwm(&led, 75);   // 设置75%亮度
}

This is my C programming style. Of course, some folks don't seem to like using structs. Personally, I still recommend using structs, but don't pass them by value which triggers a copy; instead, pass a pointer to the object.

C++ Version — OOP

cpp
// gpio_driver.hpp
#include <cstdint>

class GPIO {
private:
    // 硬件寄存器定义(藏在private里,外人别乱碰)
    static constexpr uint32_t GPIO_BASE = 0x40020000;
    static volatile uint32_t& GPIO_ODR() {
        return *reinterpret_cast<volatile uint32_t*>(GPIO_BASE + 0x14);
    }
    static volatile uint32_t& GPIO_BSRR() {
        return *reinterpret_cast<volatile uint32_t*>(GPIO_BASE + 0x18);
    }

    uint8_t pin_;
    bool state_;
    uint8_t pwm_duty_;

public:
    // 构造函数(一出生就知道自己是谁)
    explicit GPIO(uint8_t pin) : pin_(pin), state_(false), pwm_duty_(0) {}

    // 禁用拷贝(硬件资源不能克隆,你能复制一个LED吗?)
    GPIO(const GPIO&) = delete;
    GPIO& operator=(const GPIO&) = delete;

    // 写入状态
    void write(bool value) {
        if (value) {
            GPIO_BSRR() = (1U << pin_);
        } else {
            GPIO_BSRR() = (1U << (pin_ + 16));
        }
        state_ = value;
    }

    // 切换状态
    void toggle() {
        write(!state_);
    }

    // 设置PWM占空比
    void setPWM(uint8_t duty) {
        pwm_duty_ = (duty > 100) ? 100 : duty;
    }

    // 读取状态
    bool read() const {
        return state_;
    }

    // 运算符重载:让代码看起来像在和LED聊天
    GPIO& operator=(bool value) {
        write(value);
        return *this;
    }

    operator bool() const {
        return read();
    }
};

// 使用示例(看起来更像是在"对话"而不是"操作")
void example_cpp() {
    GPIO led(5);  // 创建一个GPIO对象,它自己知道初始化

    led.write(true);
    led.toggle();
    led.setPWM(75);

    // 或者使用更直观的语法(就像在说"led你给我开!")
    led = true;
    if (led) {  // 可以直接当bool用!
        led = false;
    }
}

A classic use case in C++ is adopting the OOP (Object-Oriented Programming) paradigm.

Of course, some people might argue — who told you C++ is an OOP language? It's also a generic programming language. Fair point, I have no objection. My own GPIO library is written using templates, but here, we will stick to OOP for now.

Battle Analysis: Is There Really a Big Difference?

Let's reserve judgment and look at the differences first!

Let's save the C code above as demo.c, and use the following complete compilation command:

bash
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -Os -c demo.c -o demo_c.o

Huh? You say you just click a single button in your IDE? Alright, let's talk about what this command actually does.


arm-none-eabi-gcc

Specifies the ARM bare-metal cross-compiler:

  • arm: Target architecture is ARM
  • none: No operating system (bare-metal)
  • eabi: Embedded ABI

The generated code cannot run on Linux / Windows, but is meant for MCU Flash.


-mcpu=cortex-m4

Specifies the target CPU core model:

  • Generates instructions specific to Cortex-M4
  • Enables M4-specific features (like DSP instructions)
  • Ensures the instruction set exactly matches the actual MCU

Of course, if you want to try testing on an M1, that works too — just change it to cortex-m1 and give it a try.


-mthumb

Forces the use of the Thumb instruction set:

  • The Cortex-M series only supports Thumb
  • Instructions are more compact, yielding higher code density
  • It is the "default working mode" for the M series

For Cortex-M, this is a mandatory option, not an optimization.


-Os

Optimization level targeting minimum code size:

  • Prioritizes reducing Flash usage
  • On top of -O2 / -O3, it deliberately avoids code bloat
  • It is the most common and safest optimization level in embedded systems

  • Input: demo.c

  • Output: demo_c.o

  • Does not generate an executable

  • .o is required for arm-none-eabi-size

  • Allows precise evaluation of the code size of "a single source file itself"


-o demo_c.o

Specifies the output filename:

cpp

demo.c  →  demo_c.o

Avoids using the default demo.o, which is especially clear when doing multi-language / multi-version comparison experiments.


Let's Look at the Results

Implementationtext (Code Segment)databssTotal
C version96 bytes0096
C++ version24 bytes0024
Difference-72 bytes00-72

Surprised? Unexpected?

The C++ version is actually 72 bytes smaller, a 75% reduction in code size! This reduction comes with:

  • ✅ Better encapsulation (private members can't be accidentally modified)
  • ✅ Automatic initialization (no forgetting to call init)
  • ✅ Type safety (no passing the wrong pointer)
  • ✅ More intuitive syntax (led = true feels much better than gpio_write(&led, true))

Key finding: C++'s inline optimization makes the entire example_cpp function only 24 bytes, smaller than the sum of multiple functions in the C version! The compiler optimized all operations into direct register manipulations.

The Truth at the Assembly Level

If you don't believe it, let's look at the assembly code generated by the compiler (this is the compiler's "X-ray vision"):

C version example_c (96 bytes, containing multiple function calls):

asm
example_c:
  push    {r0, r1, r2, lr}
  movs    r3, #5
  strb.w  r3, [sp, #4]     ; 初始化pin
  ldr     r3, [pc, #20]
  movs    r2, #32
  str     r2, [r3, #24]    ; GPIO操作
  add     r0, sp, #4
  movs    r3, #1
  strb.w  r3, [sp, #5]     ; 设置state
  bl      gpio_toggle       ; 函数调用
  add     sp, #12
  ldr.w   pc, [sp], #4

C++ version example_cpp (only 24 bytes, fully inlined):

asm
example_cpp:
  ldr     r3, [pc, #16]
  movs    r1, #32
  mov.w   r2, #2097152     ; 直接计算bit mask
  str     r1, [r3, #24]    ; GPIO操作1
  str     r2, [r3, #24]    ; GPIO操作2
  str     r1, [r3, #24]    ; GPIO操作3
  str     r2, [r3, #24]    ; GPIO操作4
  bx      lr
  nop

See that? The C++ version is more concise and efficient!

The compiler inlined all of the C++ class methods, eliminating function call overhead and directly generating optimized register operations. The C version, due to function separation, requires extra stack operations and function jumps.

Conclusion: C++'s encapsulation is a "zero-overhead abstraction" — not just zero overhead, but in many cases, even more efficient! This isn't a marketing slogan; it's real!


Round Two: Ring Buffer (UART's Best Friend)

Task Overview

The ring buffer is the "Swiss Army knife" of embedded systems. When UART data floods in like a torrent, you need a place to temporarily store it. This is where the ring buffer comes in — a head-to-tail connected data container that never wastes space.

Imagine a sushi conveyor belt. Plates go around in a circle. You put plates down (write), and others take plates (read). As long as the belt isn't full, it keeps spinning.

C Version — Plain and Simple

c
// ring_buffer.c
#include <stdint.h>
#include <stdbool.h>
#include <string.h>

#define BUFFER_SIZE 64  // 64字节,不大不小刚刚好

typedef struct {
    uint8_t buffer[BUFFER_SIZE];
    volatile uint16_t head;   // 写指针(放盘子的位置)
    volatile uint16_t tail;   // 读指针(拿盘子的位置)
    volatile uint16_t count;  // 当前有多少盘子
} RingBuffer;

// 初始化(把转台清空)
void rb_init(RingBuffer* rb) {
    rb->head = 0;
    rb->tail = 0;
    rb->count = 0;
}

// 写入一个字节(放一个盘子上台)
bool rb_put(RingBuffer* rb, uint8_t data) {
    if (rb->count >= BUFFER_SIZE) {
        return false; // 转台满了,请稍后再试
    }

    rb->buffer[rb->head] = data;
    rb->head = (rb->head + 1) % BUFFER_SIZE;  // 绕圈圈
    rb->count++;
    return true;
}

// 读取一个字节(拿一个盘子)
bool rb_get(RingBuffer* rb, uint8_t* data) {
    if (rb->count == 0) {
        return false; // 转台空了,没东西可拿
    }

    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) % BUFFER_SIZE;  // 绕圈圈
    rb->count--;
    return true;
}

// 查询还有多少数据(还有多少盘子)
uint16_t rb_available(RingBuffer* rb) {
    return rb->count;
}

// 查询还有多少空间(还能放多少盘子)
uint16_t rb_free_space(RingBuffer* rb) {
    return BUFFER_SIZE - rb->count;
}

// 清空缓冲区(把转台上的盘子全部拿走)
void rb_clear(RingBuffer* rb) {
    rb->head = 0;
    rb->tail = 0;
    rb->count = 0;
}

// 使用示例
void example_c_rb(void) {
    RingBuffer uart_rx;
    rb_init(&uart_rx);

    // 写入数据(发送"Hello")
    const char* msg = "Hello";
    for (int i = 0; msg[i]; i++) {
        rb_put(&uart_rx, msg[i]);
    }

    // 读取数据(接收并处理)
    uint8_t byte;
    while (rb_get(&uart_rx, &byte)) {
        // 处理每个字节
    }
}

C++ Version — Generic

Alright, here we write it using generics — generics have a known issue with code bloat.

cpp
// ring_buffer.hpp
#include <cstdint>
#include <cstring>
#include <array>

// 模板参数:想要多大的转台,你说了算!
template<size_t Size = 64>
class RingBuffer {
private:
    std::array<uint8_t, Size> buffer_;  // 用std::array而不是C数组
    volatile uint16_t head_{0};
    volatile uint16_t tail_{0};
    volatile uint16_t count_{0};

public:
    // 构造函数(转台出厂就是干净的)
    RingBuffer() = default;

    // 写入一个字节
    bool put(uint8_t data) {
        if (count_ >= Size) {
            return false;
        }

        buffer_[head_] = data;
        head_ = (head_ + 1) % Size;
        count_++;
        return true;
    }

    // 读取一个字节(注意:这里用引用返回,避免指针)
    bool get(uint8_t& data) {
        if (count_ == 0) {
            return false;
        }

        data = buffer_[tail_];
        tail_ = (tail_ + 1) % Size;
        count_--;
        return true;
    }

    // 批量写入(一次放多个盘子)
    size_t write(const uint8_t* data, size_t len) {
        size_t written = 0;
        for (size_t i = 0; i < len && put(data[i]); i++) {
            written++;
        }
        return written;
    }

    // 批量读取(一次拿多个盘子)
    size_t read(uint8_t* data, size_t len) {
        size_t read_count = 0;
        for (size_t i = 0; i < len && get(data[i]); i++) {
            read_count++;
        }
        return read_count;
    }

    // 查询方法([[nodiscard]]告诉编译器:别忽略返回值!)
    [[nodiscard]] uint16_t available() const { return count_; }
    [[nodiscard]] uint16_t freeSpace() const { return Size - count_; }
    [[nodiscard]] bool isEmpty() const { return count_ == 0; }
    [[nodiscard]] bool isFull() const { return count_ >= Size; }

    // 清空
    void clear() {
        head_ = 0;
        tail_ = 0;
        count_ = 0;
    }

    // 获取容量(编译期常量,不占运行时间)
    static constexpr size_t capacity() { return Size; }
};

// 使用示例(注意模板参数可以在编译期指定大小)
void example_cpp_rb() {
    RingBuffer<64> uart_rx;  // 64字节的缓冲区

    // 写入数据
    const char* msg = "Hello";
    uart_rx.write(reinterpret_cast<const uint8_t*>(msg), strlen(msg));

    // 读取数据
    uint8_t byte;
    while (uart_rx.get(byte)) {
        // 处理每个字节
    }

    // 或者批量读取
    uint8_t buffer[32];
    size_t n = uart_rx.read(buffer, sizeof(buffer));
}

Part One: Ring Buffer Implementation Comparison

Let's look at the results:

Implementationtext (Code Segment)databssTotal
C version218 bytes00218
C++ version150 bytes00150
Difference-68 bytes00-68

Surprised? Unexpected?

The C++ version is actually 68 bytes smaller, a 31% reduction in code size! And this is while implementing full ring buffer functionality. This reduction comes with:

  • ✅ Better encapsulation (internal indices can't be modified externally)
  • ✅ Automatic constructor initialization (no forgetting to call init)
  • ✅ Type safety (no passing the wrong pointer)
  • ✅ More intuitive method calls (rb.put(data) feels much better than rb_put(&rb, data))

Key finding: Through inline optimization, C++ eliminates function call overhead, and the compiler can better optimize class methods. The C version requires multiple independent functions (rb_init, rb_put, rb_get, rb_available, rb_free_space, rb_clear), while the C++ version intelligently inlines these operations to be more compact.

The Truth at the Assembly Level

If you don't believe it, let's look at the assembly code generated by the compiler:

C version example_c_rb (relying on multiple functions):

asm
example_c_rb:
  push    {lr}
  sub     sp, #84
  movs    r3, #0
  ldr     r2, [pc, #44]
  strh.w  r3, [sp, #72]    ; 初始化
  strh.w  r3, [sp, #74]
  strh.w  r3, [sp, #76]
  ; ... 循环写入
  bl      rb_put            ; 函数调用开销
  ; ... 循环读取
  bl      rb_get            ; 函数调用开销
  add     sp, #84
  ldr.w   pc, [sp], #4

C++ version example_cpp_rb (fully inlined):

asm
example_cpp_rb:
  sub     sp, #72
  movs    r3, #0
  strh.w  r3, [sp, #64]    ; 内联初始化
  movs    r2, #5
  strh.w  r3, [sp, #66]
  strh.w  r3, [sp, #68]
  ; ... 直接操作,无函数调用
  ldrh.w  r3, [sp, #68]
  adds    r3, #1
  strh.w  r3, [sp, #68]    ; 内联的put操作
  ; ... 继续内联操作
  add     sp, #72
  bx      lr

See that? The C++ version eliminated all function calls!

The compiler inlined all methods together, reducing stack operations, function jumps, and register saving. Because of function separation, the C version requires extra bl instructions and stack frame setup every time rb_put and rb_get are called.


Round Three: State Machine (The Art of Button Debounce)

Task Overview

Button debounce is a "required course" for embedded engineers. Mechanical buttons generate bounce when pressed and released (like a spring vibrating back and forth). If not handled, a single button press might be registered as dozens of events.

We want to implement a state machine to:

  • Detect button press
  • Detect button release
  • Detect long press (holding for more than one second)
  • Debounce (ignore bounces within 50ms)

C Version: Classic State Machine

c
// button_fsm.c
#include <stdint.h>
#include <stdbool.h>

// 按键状态(状态机的"房间")
typedef enum {
    BTN_STATE_IDLE,        // 闲置状态(没人按)
    BTN_STATE_PRESSED,     // 按下状态(刚按下)
    BTN_STATE_RELEASED,    // 释放状态(刚松开)
    BTN_STATE_LONG_PRESS   // 长按状态(按了很久)
} ButtonState;

// 按键事件(状态机的"输出")
typedef enum {
    BTN_EVENT_NONE,
    BTN_EVENT_PRESS,       // 检测到短按
    BTN_EVENT_RELEASE,     // 检测到释放
    BTN_EVENT_LONG_PRESS   // 检测到长按
} ButtonEvent;

// 按键状态机结构体
typedef struct {
    ButtonState state;
    uint32_t timestamp;         // 时间戳(记录何时进入当前状态)
    uint8_t pin;                // GPIO引脚
    bool last_reading;          // 上次读数
    uint16_t debounce_time;     // 消抖时间(ms)
    uint16_t long_press_time;   // 长按时间(ms)
} ButtonFSM;

// 初始化
void btn_init(ButtonFSM* btn, uint8_t pin) {
    btn->state = BTN_STATE_IDLE;
    btn->timestamp = 0;
    btn->pin = pin;
    btn->last_reading = false;
    btn->debounce_time = 50;     // 50ms消抖
    btn->long_press_time = 1000; // 1s长按
}

// 硬件接口(假设这些函数已经实现)
extern bool hw_read_pin(uint8_t pin);
extern uint32_t hw_get_tick(void);

// 状态机更新(在主循环中不断调用)
ButtonEvent btn_update(ButtonFSM* btn) {
    bool reading = hw_read_pin(btn->pin);
    uint32_t now = hw_get_tick();
    ButtonEvent event = BTN_EVENT_NONE;

    // 状态机核心:根据当前状态和输入决定下一步
    switch (btn->state) {
        case BTN_STATE_IDLE:
            // 闲置状态:检测按下
            if (reading && !btn->last_reading) {
                btn->timestamp = now;
                btn->state = BTN_STATE_PRESSED;
            }
            break;

        case BTN_STATE_PRESSED:
            // 按下状态:等待释放或长按
            if (!reading) {
                // 松开了
                if ((now - btn->timestamp) >= btn->debounce_time) {
                    event = BTN_EVENT_PRESS;  // 确认是有效按下
                    btn->state = BTN_STATE_RELEASED;
                    btn->timestamp = now;
                } else {
                    btn->state = BTN_STATE_IDLE;  // 抖动,忽略
                }
            } else if ((now - btn->timestamp) >= btn->long_press_time) {
                // 按太久了!
                event = BTN_EVENT_LONG_PRESS;
                btn->state = BTN_STATE_LONG_PRESS;
            }
            break;

        case BTN_STATE_RELEASED:
            // 释放状态:确认释放
            if ((now - btn->timestamp) >= btn->debounce_time) {
                if (!reading) {
                    event = BTN_EVENT_RELEASE;
                    btn->state = BTN_STATE_IDLE;
                } else {
                    btn->state = BTN_STATE_PRESSED;  // 又按下了?
                    btn->timestamp = now;
                }
            }
            break;

        case BTN_STATE_LONG_PRESS:
            // 长按状态:等待释放
            if (!reading) {
                btn->state = BTN_STATE_IDLE;
            }
            break;
    }

    btn->last_reading = reading;
    return event;
}

// 使用示例
void example_c_button(void) {
    ButtonFSM power_button;
    btn_init(&power_button, 3);  // 使用GPIO 3

    // 在主循环中调用
    while (1) {
        ButtonEvent evt = btn_update(&power_button);
        switch (evt) {
            case BTN_EVENT_PRESS:
                // 短按:切换开关
                break;
            case BTN_EVENT_LONG_PRESS:
                // 长按:进入设置菜单
                break;
            case BTN_EVENT_RELEASE:
                // 释放:什么都不做
                break;
            default:
                break;
        }
    }
}

C++ Version: Object-Oriented State Machine

cpp
// button_fsm.hpp
#include <cstdint>
#include <functional>

// 硬件接口(假设这些函数已经实现)
extern bool hw_read_pin(uint8_t pin);
extern uint32_t hw_get_tick(void);

class Button {
public:
    // 事件类型(用enum class更安全)
    enum class Event {
        None,
        Press,
        Release,
        LongPress
    };

    // 回调函数类型(可以是lambda、函数指针等)
    using EventCallback = std::function<void(Event)>;

private:
    // 内部状态(外人看不到)
    enum class State {
        Idle,
        Pressed,
        Released,
        LongPress
    };

    State state_{State::Idle};
    uint32_t timestamp_{0};
    uint8_t pin_;
    bool last_reading_{false};
    uint16_t debounce_time_{50};
    uint16_t long_press_time_{1000};
    EventCallback callback_;  // 事件回调

    // 硬件接口(封装在内部)
    bool readPin() const {
        return hw_read_pin(pin_);
    }

    uint32_t getTick() const {
        return hw_get_tick();
    }

    // 通知事件(如果有回调的话)
    void notifyEvent(Event event) {
        if (callback_ && event != Event::None) {
            callback_(event);
        }
    }

public:
    // 构造函数(可以自定义消抖和长按时间)
    explicit Button(uint8_t pin,
                   uint16_t debounce_ms = 50,
                   uint16_t long_press_ms = 1000)
        : pin_(pin)
        , debounce_time_(debounce_ms)
        , long_press_time_(long_press_ms) {}

    // 设置回调函数(支持lambda表达式)
    void setCallback(EventCallback callback) {
        callback_ = callback;
    }

    // 状态机更新
    Event update() {
        bool reading = readPin();
        uint32_t now = getTick();
        Event event = Event::None;

        switch (state_) {
            case State::Idle:
                if (reading && !last_reading_) {
                    timestamp_ = now;
                    state_ = State::Pressed;
                }
                break;

            case State::Pressed:
                if (!reading) {
                    if ((now - timestamp_) >= debounce_time_) {
                        event = Event::Press;
                        state_ = State::Released;
                        timestamp_ = now;
                    } else {
                        state_ = State::Idle;
                    }
                } else if ((now - timestamp_) >= long_press_time_) {
                    event = Event::LongPress;
                    state_ = State::LongPress;
                }
                break;

            case State::Released:
                if ((now - timestamp_) >= debounce_time_) {
                    if (!reading) {
                        event = Event::Release;
                        state_ = State::Idle;
                    } else {
                        state_ = State::Pressed;
                        timestamp_ = now;
                    }
                }
                break;

            case State::LongPress:
                if (!reading) {
                    state_ = State::Idle;
                }
                break;
        }

        last_reading_ = reading;
        notifyEvent(event);  // 自动通知回调
        return event;
    }

    // 查询方法
    [[nodiscard]] bool isPressed() const {
        return state_ == State::Pressed || state_ == State::LongPress;
    }
};

// 使用示例(看看C++的回调有多爽)
void example_cpp_button() {
    Button power_button(3);

    // 使用lambda表达式作为回调
    power_button.setCallback([](Button::Event evt) {
        switch (evt) {
            case Button::Event::Press:
                // 短按:切换开关
                break;
            case Button::Event::LongPress:
                // 长按:进入设置菜单
                break;
            case Button::Event::Release:
                // 释放:什么都不做
                break;
            default:
                break;
        }
    });

    // 在主循环中调用
    while (true) {
        power_button.update();  // 自动调用回调
    }
}

// 如果不想用std::function(它有点重),可以用函数指针版本
class ButtonLite {
public:
    enum class Event { None, Press, Release, LongPress };
    using EventCallback = void (*)(Event);  // 函数指针,更轻量

    // ... 其他实现类似,但用函数指针代替std::function
};

Battle Analysis: The Cost of std::function

Implementationtext (Code Segment)databssTotal
C version172 bytes00172
C++ version (std::function)306 bytes00306
Difference+134 bytes00+134

This time the difference is obvious! The C++ version increased the code size by 78%. The cost of these 134 bytes comes from:

  • The type erasure mechanism of std::function (requires a vtable)
  • Extra overhead from lambda captures
  • Runtime support code for dynamic polymorphism

So, what I want to tell you here is that not all abstractions in C++ are zero-overhead. Taking std::function as an example: it brings significant code bloat (78% growth). Furthermore: lambda captures have hidden costs, because each lambda requires extra storage and management code. Those familiar with lambdas should know this — it generates a struct with a operator() call that stores every captured object:

The alternative here is very simple:

cpp
// 方案1:函数指针(接近C的开销)
using callback_t = void (*)(int);
void set_callback(callback_t cb);

// 方案2:模板回调(零开销,编译期展开)
template<typename Callback>
void process(Callback cb) {
    cb(42);  // 完全内联
}

// 方案3:直接调用(最优)
void process() {
    handle_event(42);
}

Final Thoughts

Code Size Comparison Table

Let's review:

Case One: GPIO Operation Encapsulation

In the GPIO operation scenario, C++ class encapsulation showed a surprising advantage. The C version required 96 bytes to implement multiple functions like gpio_init, gpio_write, and gpio_toggle, while the C++ version compressed the entire operation sequence to just 24 bytes through compiler inline optimization, reducing code size by 75%. This huge difference comes from the compiler's ability to completely inline C++ member function calls, eliminating function call overhead and stack frame management.

Case Two: Ring Buffer Implementation

The ring buffer implementation further validated C++'s advantages. The C version needed to implement six independent functions — rb_init, rb_put, rb_get, rb_available, rb_free_space, and rb_clear — totaling 218 bytes. The C++ version, through class encapsulation and method inlining, reduced the code size to 150 bytes, saving 31% of space. The key is that the compiler can see the complete call chain and thus perform more aggressive optimizations.

Case Three: A Warning About std::function

Not all C++ features are suitable for embedded development. When using std::function to implement callbacks, the code bloated from the C version's 172 bytes to 306 bytes, an increase of 78%. This is because std::function requires a type erasure mechanism, vtable support, and management code for lambda captures. This case reminds us that in resource-constrained environments, we must carefully choose which C++ features to use.

FeatureCode GrowthRecommendation
Class encapsulation (basic)-75% to -31%Highly recommended (actually smaller in practice)
Class encapsulation (with templates)+4%Highly recommended (nearly zero overhead)
Virtual functions+20-40%Use with caution (consider CRTP as an alternative)
Exception handling+50-100%Disable (-fno-exceptions)
RTTI+30-50%Disable (-fno-rtti)
std::function+78%Use with caution (replace with function pointers or templates)
Templates (generic containers)+4%Highly recommended (compile-time optimization)

Performance Comparison Table

Based on assembly-level cycle count analysis:

CategoryC ImplementationC++ ImplementationDifference
Single GPIO operation8-10 cycles8-10 cycles0%
Buffer read/write12-15 cycles12-15 cycles0%
Complete inlined operationRequires function callsFully inlinedC++ is faster

Key finding: With optimizations enabled, C++'s zero-overhead abstraction is not a marketing slogan, but a verifiable fact. The assembly code generated by the compiler shows that C++ class methods and C functions are identical at the single-operation level, and in complex operation scenarios, C++ is even faster due to inline optimization.


Best Practices: How to Elegantly Use C++ in Embedded Systems

1. Compiler Flags (Slimming Configuration)

The golden compiler configuration for embedded C++ development is as follows:

bash

# 基础优化
-Os                        # 优化代码大小而非速度
-mcpu=cortex-m4           # 指定目标处理器
-mthumb                   # 使用Thumb指令集(代码更紧凑)

# C++特性控制(关键)
-fno-exceptions           # 禁用异常处理(节省50-100%代码)
-fno-rtti                 # 禁用运行时类型信息(节省30-50%)
-fno-threadsafe-statics   # 禁用线程安全的静态初始化

# 代码段优化
-ffunction-sections       # 每个函数放入独立段
-fdata-sections          # 每个数据对象放入独立段
-Wl,--gc-sections        # 链接时删除未使用的段

# 进一步优化
-flto                    # 链接时优化(Link Time Optimization)
-fno-use-cxa-atexit     # 禁用全局析构函数注册

This configuration ensures that C++ code remains efficient and compact in embedded environments. Real-world tests show that properly configured C++ code can achieve a footprint equivalent to, or even smaller than, C.

The following features have been verified through testing and perform excellently in embedded systems:

Classes and Objects (Highly Recommended)

Class encapsulation is a core strength of C++, capable of abstracting hardware resources into objects. Tests show that simple class encapsulation not only doesn't increase code size, but actually reduces it due to compiler optimizations. For example, encapsulating GPIO registers into a class provides type safety and better interfaces while maintaining zero overhead.

Constructors and Destructors (Highly Recommended)

Constructors provide automatic initialization, and destructors enable the RAII pattern, which is C++'s most powerful resource management mechanism. In embedded systems, destructors can automatically disable peripherals and release resources, preventing resource leaks. Compilers can usually fully inline simple constructors.

Templates (Highly Recommended)

Templates provide compile-time code generation with absolutely zero runtime overhead. The ring buffer test showed that the template version increased code size by only 4%, while providing type safety and size parameterization. Compared to C macros, templates are safer and easier to debug.

constexpr (Highly Recommended)

constexpr functions are evaluated at compile time, and the results are embedded directly into the code. They can be used for calculating configuration parameters, lookup table generation, and other scenarios, with completely zero runtime overhead.

References and Inline Functions (Highly Recommended)

References avoid unnecessary copies, and inline functions eliminate function call overhead. In embedded systems, using references appropriately can significantly improve performance, especially when passing structs.

Operator Overloading (Moderately Recommended)

Operator overloading can make code more intuitive, for example, using gpio = true instead of gpio_write(&gpio, 1). As long as it's not abused, operator overloading introduces no extra overhead.

3. C++ Features to Use with Caution

The following features have certain overheads and need to be weighed against actual requirements:

Virtual Functions (Use with Caution)

Virtual functions introduce a vtable, adding a 4-byte pointer overhead to each object and requiring an indirect jump for every call. If you truly need polymorphism, consider using CRTP (Curiously Recurring Template Pattern) to achieve compile-time polymorphism and avoid runtime overhead.

std::function (Use with Caution)

Tests show that std::function causes 78% code bloat. If you need a callback mechanism, prioritize function pointers (same overhead as C) or template callbacks (zero overhead). Only consider std::function when you need lambdas that capture state.

Dynamic Memory Allocation (Use with Caution)

new and delete can lead to memory fragmentation in embedded systems. We recommend using placement new with a static memory pool, or using stack-allocated objects. If dynamic memory is absolutely necessary, consider a custom allocator.

STL Containers (Use with Caution)

Standard library containers like std::vector and std::map can have large implementations. We recommend testing the code size first, or using container libraries specifically optimized for embedded systems (like EASTL). For simple scenarios, hand-writing fixed-size containers might be more appropriate.

4. C++ Features to Prohibit

The following features should be completely avoided in embedded systems:

Exception Handling (Prohibited)

The exception handling mechanism bloats code by 50-100% and introduces unpredictable execution paths. Embedded systems require deterministic behavior; use error codes or assertions instead of exceptions. Always add the -fno-exceptions compiler flag.

RTTI (Prohibited)

Run-time type information increases code by 30-50% and is rarely needed in embedded systems. Disable it with -fno-rtti. If type identification is needed, you can manually implement a simple type tag system.

iostream Library (Prohibited)

std::cout and std::cin pull in huge amounts of code (tens of KB), far beyond what an embedded system can tolerate. Use traditional printf/scanf or specialized embedded logging libraries instead.

Multiple Inheritance (Prohibited)

Multiple inheritance increases complexity and code size, and can lead to the diamond problem. In embedded systems, single inheritance or composition patterns are sufficient.


Practical Advice: When to Use C, and When to Use C++?

Scenarios for Choosing C

Extremely Resource-Constrained Environments

When the target hardware has less than 8KB of Flash and less than 1KB of RAM, C is the safer choice. Such systems are usually simple sensor nodes or controllers that don't need complex abstractions.

Team Skillset Limitations

If team members are unfamiliar with C++, or if the project timeline is tight, forcing the use of C++ might do more harm than good. C has a gentler learning curve and is easier to master.

Pure C Codebase Integration

When you need to integrate a large amount of existing C code, using C avoids the hassle of mixed-language programming. Although C++ can call C code, in some cases, a pure C project is simpler.

Insufficient Toolchain Support

Some older or specialized compilers have incomplete C++ support and might generate inefficient code. In these cases, C is the more reliable choice.

Scenarios for Choosing C++

Moderately to Well-Resourced Systems

When Flash is greater than 16KB and RAM is greater than 2KB, C++'s advantages start to shine. Such systems have enough space to accommodate C++'s abstraction mechanisms while benefiting from encapsulation and type safety.

Complex State Management

When implementing complex logic like state machines, protocol stacks, or sensor fusion, C++ class encapsulation can significantly reduce complexity. Objects can encapsulate state and behavior, making code easier to maintain.

When Code Reuse is Needed

When you have multiple similar modules (like multiple UARTs, multiple timers), C++ templates are safer and easier to debug than C macros. Templates provide compile-time type checking and parameterization.

Modern Development Practices

If the team is familiar with modern C++ (C++11 and later) and can correctly use features like smart pointers, move semantics, and lambdas, development efficiency will significantly improve.

Mixed Usage (Best Practice)

Many successful embedded projects adopt a layered, mixed strategy:

Low-Level Driver Layer: Use C

Low-level drivers that directly manipulate registers are written in C, ensuring stability and portability. This code is usually not complex, and C is sufficient.

Middle Abstraction Layer: Use C++

Wrap low-level drivers into C++ classes to provide object-oriented interfaces. For example, wrapping a UART driver into a SerialPort class provides a safer, more user-friendly API.

Application Logic Layer: Use C++

Business logic, state machines, and data processing are implemented in C++, leveraging features like classes, templates, and RAII to simplify code.

Module Interfaces: Use extern "C"

Interfaces between modules use extern "C" declarations, ensuring that C and C++ modules can seamlessly collaborate. This approach maintains flexibility while avoiding name mangling issues.


Exercise Time: Give It a Try

Exercise 1: Hands-on Comparison

Implement the three examples above on your development board and measure:

  1. Flash usage (using arm-none-eabi-size)
  2. RAM usage (check the .bss and .data sections)
  3. Execution time (using the DWT cycle counter)

Exercise 2: Optimization Challenge

Try optimizing the ring buffer:

  1. When Size is a power of two, replace modulo with bitwise operations (% Size& (Size-1))
  2. Implement a zero-copy peek() operation
  3. Add an interrupt-safe version (by disabling interrupts or using atomic operations)

Exercise 3: Design Decisions

Choose C or C++ for the following scenarios:

  1. Simple UART driver (send and receive only) → Your choice?
  2. Sensor fusion algorithm (Kalman filter) → Your choice?
  3. 1ms real-time control loop → Your choice?
  4. OTA firmware upgrade module → Your choice?

Exercise 4: Code Review

Find the problems in the following C++ code:

cpp
class Sensor {
    std::vector<int> data;  // 问题1: ?
public:
    virtual void read() {   // 问题2: ?
        throw std::runtime_error("Not implemented"); // 问题3: ?
    }
};

Improved version:

cpp
template<size_t MaxSize>
class Sensor {
    std::array<int, MaxSize> data_;
    size_t size_{0};
public:
    [[nodiscard]] bool read() {  // 用bool表示成功/失败
        // 实现...
        return true;
    }
};

Final Words

To quote Bjarne Stroustrup (the creator of C++):

"C++ is not a language that you have to use entirely, but a language that you can choose to use."

In embedded systems, we need to be smart choosers, not blind followers. Use C++'s powerful features to improve code quality, while steering clear of features that are unsuitable for resource-constrained environments.

Built with VitePress