mcpField
C
cpp-embedded

cpp-embedded

github.com/bmad-labs开发1948 次安装6 天前更新
代码仓库

关于 cpp-embedded

面向嵌入式 C 和 C++ 开发的编码技能,涵盖内存管理、固件架构、调试与安全实践。通过具体模式和参考指南,帮助减少非确定性分配及 ISR、堆栈和硬件访问相关问题。

主要功能

  1. 01确定性内存与对象池模式
  2. 02ISR 与任务间通信模式
  3. 03C/C++ 编码约定与设计模式
  4. 04HardFault、堆栈及内存调试
  5. 05安全硬件与固件架构指南

适用场景

  1. 01设计无需堆分配的固件数据结构
  2. 02排查 HardFault 或堆损坏
  3. 03实现 ISR 到任务的数据传递

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/bmad-labs/skills --skill cpp-embedded

安装后在 Claude Code 中输入/cpp-embedded确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
architecture.md
c-patterns.md
coding-style.md
debugging.md
design-patterns.md
memory-patterns.md
safety-hardware.md
stm32-pitfalls.md

详细介绍

嵌入式 C 和 C++ 技能

快速导航

  • 内存模式、ESP32 堆碎片、ETL 容器 → references/memory-patterns.md
  • C99/C11 模式、UART 读后清除、_Static_assert → references/c-patterns.md
  • 调试工作流(栈溢出、堆损坏、HardFault) → references/debugging.md
  • 编码风格与约定 → references/coding-style.md
  • 固件架构、RTOS IPC、低功耗、ESP32 深度睡眠、CI/CD → references/architecture.md
  • STM32 易错点、CubeMX、难以调试的问题、代码审查 → references/stm32-pitfalls.md
  • 设计模式、嵌入式 SOLID、HAL 设计、C/C++ 回调 → references/design-patterns.md
  • MPU 保护、看门狗层级、安全关键系统、协议、链接器脚本 → references/safety-hardware.md

嵌入式内存理念

嵌入式系统没有操作系统的安全网。糟糕的指针解引用不会友好地触发段错误——它可能会悄悄破坏内存、数小时后才触发 HardFault,或让 ISR 陷入挂起。与托管环境相比,每一次分配决策都事关重大。

嵌入式内存遵循三项原则:

确定性优先于便利性。 动态分配(malloc/new)的耗时和失败模式都不确定。MISRA C Rule 21.3 和 MISRA C++ Rule 21.6.1 禁止在初始化后使用动态内存。即使不受 MISRA 约束,也应避免在生产路径中分配堆内存。

大小在编译时已知。 嵌入式软件中,每种对象类型的数量都有固定上限。应围绕这一点进行设计。如果需要 8 个 UART 消息缓冲区,就在编译时声明 8 个。不要等到运行时才确定最大值。

ISR 不容侵犯。 绝不要在 ISR 中分配内存、阻塞或调用非可重入函数。让 ISR 保持精简——设置标志或写入环形缓冲区,然后在任务中完成实际工作。


分配决策表

需求解决方案备注
短期局部数据栈每帧保持在 256 字节以内;使用 -fstack-usage 进行分析
固定的单例对象文件或函数作用域的 static在 main() 之前完成零初始化
固定数量的对象数组对象池(ObjectPool<T, N>)O(1) 分配/释放,无碎片
临时工作空间Arena / bump allocator操作结束时重置整个 Arena
可变大小的消息固定大小槽位的环形缓冲区最简单的 ISR 安全通信模式
自定义生命周期控制Placement new + 静态存储完全掌控,不涉及堆
ISR 中绝不可使用上述方案中除栈以外的任何方案分配器调用不适用于 ISR
完全避免malloc/new / std::vector不确定性;存在碎片风险

关键模式

RAII 资源守卫

构造时获取资源,析构时释放资源。即使提前返回或发生异常,也能保证释放资源(如果使用异常——在嵌入式中很少见,但在允许使用异常的 C++ 环境中并非不可能)。

class SpiGuard {
public:
    explicit SpiGuard(SPI_HandleTypeDef* spi) : spi_(spi) {
        HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET);
    }
    ~SpiGuard() {
        HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET);
    }
    // Non-copyable, non-movable — guard is tied to this scope
    SpiGuard(const SpiGuard&) = delete;
    SpiGuard& operator=(const SpiGuard&) = delete;
private:
    SPI_HandleTypeDef* spi_;
};

// Usage: CS deasserts automatically at end of scope
void read_sensor() {
    SpiGuard guard(&hspi1);
    // ... transfer bytes ...
}  // CS deasserts here
ISR 到任务的通信:环形缓冲区与乒乓 DMA

将数据从 ISR/DMA 传递给任务时,主要有两种模式:

  • SPSC 环形缓冲区(容量为 2 的幂,使用位掩码索引)——最适合速率可变的数据流。完整实现请参阅 references/memory-patterns.md §3。务必使用 _Static_assert 或 static_assert 验证容量为 2 的幂。
  • 乒乓(双缓冲)——最适合固定突发的 DMA 传输。ISR 填充一个缓冲区时,任务处理另一个缓冲区。

如果用户询问 ISR 到任务的通信,请将使用 2 的幂位掩码索引的环形缓冲区作为首选模式,即使也提到了乒乓缓冲区作为特定 DMA 场景下的替代方案。

静态对象池

预分配 N 个 T 类型的对象,具有 O(1) 分配/释放,且不涉及堆。 完整的 Arena 分配器请阅读 references/memory-patterns.md §1;CRTP 模式请阅读 §2。

template<typename T, size_t N>
class ObjectPool {
public:
    template<typename... Args>
    T* allocate(Args&&... args) {
        for (auto& slot : slots_) {
            if (!slot.used) {
                slot.used = true;
                return new (&slot.storage) T(std::forward<Args>(args)...);
            }
        }
        return nullptr;  // Pool exhausted — handle at call site
    }

    void free(T* obj) {
        obj->~T();
        for (auto& slot : slots_) {
            if (reinterpret_cast<T*>(&slot.storage) == obj) {
                slot.used = false;
                return;
            }
        }
    }

private:
    struct Slot {
        alignas(T) std::byte storage[sizeof(T)];
        bool used = false;
    };
    Slot slots_[N]{};
};
易变硬件寄存器访问

volatile 会告诉编译器,该值可能在编译器无法感知的情况下发生变化(硬件可以写入该值)。 如果没有 volatile,编译器可能会将读取值缓存到寄存器中,之后便无法看到硬件更新。

// Define register layout matching the hardware manual
struct UartRegisters {
    volatile uint32_t SR;   // Status register
    volatile uint32_t DR;   // Data register
    volatile uint32_t BRR;  // Baud rate register
    volatile uint32_t CR1;  // Control register 1
};

// Map to the hardware base address
auto* uart = reinterpret_cast<UartRegisters*>(0x40011000U);

// Read status — volatile ensures each read hits the hardware
if (uart->SR & (1U << 5)) {   // RXNE bit
    uint8_t byte = static_cast<uint8_t>(uart->DR);
}
中断安全访问

在 ISR 和任务之间共享数据,需要使用临界区或 std::atomic。 当类型可通过一次加载/存储操作完成访问时(通常 ≤ 指针大小),请使用原子操作。对于较大的结构体, 请使用临界区。

#include <atomic>

// Atomic: ISR and task can access without disabling interrupts
std::atomic<uint32_t> adc_value{0};

// In ISR:
void ADC_IRQHandler() {
    adc_value.store(ADC1->DR, std::memory_order_relaxed);
}

// In task:
uint32_t val = adc_value.load(std::memory_order_relaxed);

// Critical section for larger structures (ARM Cortex-M):
struct SensorFrame { uint32_t timestamp; int16_t x, y, z; };
volatile SensorFrame latest_frame{};

void update_frame_from_isr(const SensorFrame& f) {
    __disable_irq();
    latest_frame = f;
    __enable_irq();
}

智能指针策略

指针类型是否用于嵌入式?指导意见
原始指针(观察型)是用于非拥有型引用;通过命名明确所有权
原始指针(拥有型)谨慎使用仅用于生命周期明确的静态存储或内存池存储
std::unique_ptr是,需谨慎零开销;用于内存池对象时搭配自定义删除器
std::unique_ptr + 自定义删除器是销毁时将内存池对象归还到其所属内存池
std::shared_ptr避免使用引用计数会使用堆,且行为不确定
std::weak_ptr避免使用与 shared_ptr 绑定;有相同问题
// unique_ptr with pool deleter — zero heap, automatic return to pool
ObjectPool<SensorData, 8> sensor_pool;

auto deleter = [](SensorData* p) { sensor_pool.free(p); };
using PooledSensor = std::unique_ptr<SensorData, decltype(deleter)>;

PooledSensor acquire_sensor() {
    return PooledSensor(sensor_pool.allocate(), deleter);
}

编译期偏好

优先选择编译期计算和验证,而不是运行时检查:

// constexpr: computed at compile time, no runtime cost
constexpr uint32_t BAUD_DIVISOR = PCLK_FREQ / (16U * TARGET_BAUD);
static_assert(BAUD_DIVISOR > 0 && BAUD_DIVISOR < 65536, "Baud divisor out of range");

// std::array: bounds info preserved, unlike raw arrays
std::array<uint8_t, 64> tx_buffer{};

// std::span: non-owning view, no allocation, C++20 but often available via ETL
// std::string_view: for string literals and buffers, no heap

// CRTP replaces virtual dispatch — zero runtime overhead
// See references/memory-patterns.md §2 for full example

嵌入式开发中应避免的 C++ 特性:

避免使用原因替代方案
-fexceptions代码体积增加(10-30%),栈展开行为不确定std::optional、std::expected(C++23/兼容实现)、错误码
-frtti / typeid / dynamic_cast运行时类型表会增加 ROM 占用CRTP、显式类型标签
std::vector、std::string、std::map堆分配std::array、ETL 容器
std::thread需要操作系统原语RTOS 任务
std::function捕获对象会导致堆分配函数指针、模板
深层继承体系中的虚析构函数虚函数表占用、间接分发、阻碍内联CRTP 或扁平继承体系(≤2 层);非关键路径中可使用虚函数

面向 ARM 目标编译时使用 -fno-exceptions -fno-rtti。请使用 嵌入式模板库(ETL), 以获得固定大小的 etl::vector、etl::map、etl::string 替代方案。


常见反模式

这些模式会在生产固件中导致真实故障。了解它们可以节省数小时的调试时间。

反模式问题修复方法
带捕获的 std::function当捕获内容超过小缓冲区阈值(约 16-32 字节,取决于实现)时会在堆上分配使用函数指针 + void* context、模板可调用对象,或直接将 lambda 传给模板参数
在循环中使用 Arduino String每次拼接或转换都会在堆上分配;10Hz × 4 个传感器 = 每天 340 万次分配 → 内存碎片化使用固定大小的 char[] 和 snprintf;复杂字符串使用 etl::string<N>
在周期性任务中使用 new/delete同样会造成内存碎片化;违反 MISRA C++ 规则 21.6.1使用对象池、静态数组或 ETL 固定容量容器
在任何地方使用 std::shared_ptr原子引用计数开销,控制块位于堆上使用带池删除器的 std::unique_ptr,或使用所有权明确的原始指针
深层虚函数层次结构每个类都需要一个 vtable(ROM),间接分派,并阻碍内联使用 CRTP 或扁平层次结构(≤2 层)
隐式使用 std::string/std::vector每次操作都会进行动态分配——这通常隐藏在库 API 中使用 ETL 容器(etl::string<N>、etl::vector<T,N>)
使用 volatile 进行线程同步volatile 只会阻止编译器缓存或消除读写操作——它不会阻止线程间重排序,也不提供原子性。对 volatile 变量的数据竞争仍属于未定义行为使用带显式内存顺序的 std::atomic(对于单核 Cortex-M 上从 ISR 到任务的通信,memory_order_relaxed 已足够)
ISR 处理函数名称不匹配拼错 ISR 名称会静默落入 Default_Handler——系统挂起或重置对照启动文件 .s 中的向量表核对名称;使用 -Wl,--undefined=USART1_IRQHandler 在链接时捕获缺失符号
在 ISR 中使用 printf/sprintf堆分配、加锁、不可重入——会导致崩溃或死锁在 ISR 外使用 snprintf 写入预分配缓冲区,或使用 ITM/SWO 跟踪输出
无界递归MCU 的栈只有 1–8KB,可能导致栈溢出;MISRA C 规则 17.2 禁止递归使用显式栈改为迭代实现

隐式分配检查清单: 使用任何 STL 类型之前,先检查它是否会进行分配。常见的意外情况包括:std::string::operator+=、std::vector::push_back(重新分配)、std::function(类型擦除的捕获)、std::any、std::regex。


常见内存错误诊断

症状可能原因首要措施
运行数小时后崩溃堆碎片化改用内存池/ETL 容器;引用 MISRA C 规则 21.3;ESP32 专用指南见 references/memory-patterns.md §9
BFAR/MMFAR 有效时发生 HardFault空指针/野指针解引用或总线故障读取 CFSR 子寄存器;使用地址寄存器前检查 MMARVALID/BFARVALID;参见 references/debugging.md §4
栈指针位于错误区域栈溢出检查 .su 文件;添加 MPU 保护区域;参见 references/debugging.md §1
ISR 数据看起来过时缺少 volatile为共享变量添加 volatile;审查 ISR 数据路径
ISR 附近发生随机损坏数据竞争使用原子操作或临界区;参见 references/debugging.md §3
释放后使用对象仍被引用时就已归还对象池确认不存在别名;使用带池删除器的 unique_ptr
任务中发生 MPU 故障任务栈溢出到相邻区域增大栈空间或减少栈帧深度
读取未初始化值局部变量在赋值前就被使用启用 -Wuninitialized;初始化所有局部变量
首次执行 float 运算时发生 HardFault未在 CPACR 中启用 FPU在任何浮点代码运行前执行 `SCB->CPACR
ISR 不执行 / 默认处理函数运行ISR 函数名称拼写错误对照启动文件 .s 中的向量表核对名称

调试工具决策树

Is the bug reproducible on a host (PC)?
├── YES → Use AddressSanitizer (ASan) + Valgrind
│         Compile embedded logic for PC with -fsanitize=address
│         See references/debugging.md §5
└── NO  → Is it a memory layout/access issue?
          ├── YES → Enable MPU; add stack canaries; read CFSR on fault
          │         See references/debugging.md §1, §4
          └── NO  → Is it a data-race between ISR and task?
                    ├── YES → Audit shared state; apply atomics/critical section
                    │         See references/debugging.md §3
                    └── NO  → Use GDB watchpoint on the corrupted address
                              See references/debugging.md §6

静态分析:运行 clang-tidy,启用 clang-analyzer-* 和 cppcoreguidelines-* 检查。 对 C 代码运行 cppcheck --enable=all。这两种工具都能在目标硬件上运行之前发现许多问题。


错误处理理念

分为四层,分别对应不同的故障类别:

可恢复错误(附带上下文) — 当你需要说明失败原因时,使用 std::expected(C++23;对于 C++17,可使用仅头文件的兼容实现 tl::expected/etl::expected)。零堆分配、零异常、类型安全的错误传播:

enum class SensorError : uint8_t { not_ready, crc_fail, timeout };

[[nodiscard]] std::expected<SensorReading, SensorError> read_sensor() {
    if (!sensor_ready()) return std::unexpected(SensorError::not_ready);
    auto raw = read_raw();
    if (!verify_crc(raw)) return std::unexpected(SensorError::crc_fail);
    return SensorReading{.temp = convert(raw)};
}

可恢复错误(简单) — 如果唯一的失败情况是“无值”,则使用 std::optional:

[[nodiscard]] std::optional<SensorReading> read_sensor() {
    if (!sensor_ready()) return std::nullopt;
    return SensorReading{.temp = read_temp(), .humidity = read_humidity()};
}

[[nodiscard]] 强制检查: 对所有返回错误代码、状态或已分配指针的函数标记 [[nodiscard]]。如果调用方忽略返回值,编译器就会发出警告——这能捕获固件中排名第一的错误处理问题(丢弃错误代码)。在 C 中,使用 [[nodiscard]](C23)或 __attribute__((warn_unused_result))。

编程错误 — 使用 assert 或能以调试信息停机的陷阱:

void write_to_pool(uint8_t* buf, size_t len) {
    assert(buf != nullptr);
    assert(len <= MAX_PACKET_SIZE);  // Trips in debug, removed in release with NDEBUG
    // ...
}

不可恢复的运行时错误 — 将故障原因和寄存器信息记录到非易失性存储器(闪存/EEPROM)中,然后让看门狗重置系统。如果重置前没有日志记录,现场故障就不会留下事后分析数据。将 CFSR、堆栈中的 PC 和 LR 写入专用闪存扇区,然后调用 NVIC_SystemReset(),或循环等待看门狗触发。


可测试性架构

编写无需目标硬件、可在 PC 上测试的固件。关键在于:在清晰的 HAL 边界处,将业务逻辑与硬件 I/O 分离。

编译期依赖注入 — 硬件在编译期已知,因此使用模板而非虚分派。零运行时开销,完全可测试:

// HAL interface as a concept (or just template parameter)
template<typename Hal>
class SensorController {
public:
    explicit SensorController(Hal& hal) : hal_(hal) {}
    std::optional<float> read_temperature() {
        auto raw = hal_.i2c_read(SENSOR_ADDR, TEMP_REG, 2);
        if (!raw) return std::nullopt;
        return convert_raw_to_celsius(*raw);
    }
private:
    Hal& hal_;
};

// Production: uses real hardware
SensorController<StmHal> controller(real_hal);

// Test: uses mock — same code, no vtable, no overhead in production
SensorController<MockHal> test_controller(mock_hal);

固件测试金字塔:

  • 单元测试(主机 PC): 使用模拟 HAL 测试业务逻辑 — 通过 ASan/UBSan 运行,CI 速度快
  • 集成测试(QEMU): 在模拟的 Cortex-M 上运行完整固件 — 可发现链接器/启动问题
  • 硬件在环(HIL): 在真实目标设备上测试 — 可发现时序、外设和电气问题

固件架构选择

选择满足需求的最简单架构:

架构复杂度最适合
Superloop(裸机轮询)最低< 5 个任务、时序要求宽松、完全确定
协作式调度器(时间触发)低硬实时、安全关键(IEC 61508 SIL 1–2)、可分析
RTOS 抢占式调度(FreeRTOS/Zephyr)中复杂的多任务、基于优先级的调度
主动对象(QP 框架)最高事件密集、层次状态机、协议处理

有关 FreeRTOS IPC 选择(任务通知与队列、流缓冲区)、低功耗模式、CI/CD 流水线设置和二进制体积预算 → 请参阅 references/architecture.md。

有关 STM32 CubeMX 常见陷阱、HAL 与 LL 驱动程序的选择、难以调试的嵌入式问题和代码审查清单 → 请参阅 references/stm32-pitfalls.md。


ESP32 平台指南

回答任何 ESP32/ESP32-S2/S3/C3 相关问题时,始终考虑这些平台特定注意事项:

**内存架构——ESP32 回复中必须始终说明:**ESP32 有多个不连续的内存区域。每次回复 ESP32 相关问题时,都要明确说明缓冲区应放在哪里:

  • DRAM(约 320KB,速度快):环形缓冲区、DMA 缓冲区、中断服务程序(ISR)数据、FreeRTOS 栈。所有实时和对延迟敏感的数据都应放在这里。
  • PSRAM(4-8MB,速度约慢 10 倍,在 -WROVER/S3 上可选):较大的非实时数据,例如 SD 卡写入缓冲区、CSV 格式化缓冲区、Web 服务器缓冲区、显示帧缓冲区。使用 heap_caps_malloc(size, MALLOC_CAP_SPIRAM) 或 ps_malloc() 分配。切勿在 ISR 或紧凑控制循环中使用 PSRAM——其访问延迟约为 100ns,而 DRAM 约为 10ns。
  • 如果 ESP32 变体带有 PSRAM,建议将较大的格式化/写入缓冲区移至 PSRAM,以便为实时用途释放 DRAM。如果没有 PSRAM,请说明 DRAM 压力和大小预算。务必提及这两个区域,让用户了解其中的权衡。

**ESP32 上的 ETL:**在 ESP32 上替换 std::vector、std::string 或 std::map 时,始终按名称推荐 Embedded Template Library (ETL),并给出具体类型:etl::vector<T, N>、etl::string<N>、etl::map<K, V, N>、etl::queue_spsc_atomic<T, N>。ETL 可在 ESP32 上配合 Arduino(lib_deps = ETLCPP/Embedded Template Library)和 ESP-IDF(作为组件添加)使用。即使提供自定义实现(例如环形缓冲区),也要提及 ETL 是值得用户考虑的、适合生产环境的替代方案。

**深度睡眠和快速唤醒:**对于电池供电的 ESP32 传感器节点,请参阅 references/architecture.md,了解 RTC 内存中的 WiFi 缓存、静态 IP 和传感器强制模式模式。

**ESP32 上的任务栈大小:**WiFi 任务需要 4096-8192 字节,BLE 需要 4096-8192 字节,TLS/SSL 需要 8192-16384 字节。使用 uxTaskGetStackHighWaterMark() 进行监控。详情请参阅 references/memory-patterns.md §9。

有关 ESP32 堆碎片问题的完整诊断、监控和 ETL 集成 → 请参阅 references/memory-patterns.md §9。


编码约定摘要

完整指南请参阅 references/coding-style.md。主要规则:

  • 变量和函数:snake_case
  • 类和结构体:PascalCase
  • 常量:kConstantName(Google 风格),宏使用 ALL_CAPS
  • 成员变量:trailing_underscore_
  • 包含保护:#pragma once(优先)或 #ifndef HEADER_H_ 保护
  • const 正确性:所有不修改状态的方法都标记为 const,所有不会被修改的参数都标记为 const
  • [[nodiscard]]:用于返回值不得被静默丢弃的函数(错误代码、池分配)

参考文件索引

文件何时阅读
references/memory-patterns.md实现 arena、环形缓冲区、DMA 缓冲区、无锁 SPSC、单例、链接器段、ESP32 堆碎片整理、ETL 容器时
references/c-patterns.md编写 C99/C11 固件、C 内存池、C 错误处理、C/C++ 互操作、MISRA C 规则、UART 读取即清除机制、使用 _Static_assert 验证缓冲区时
references/debugging.md诊断栈溢出、堆损坏、HardFault、数据竞争、NVIC 优先级问题,或运行 ASan/GDB 时
references/coding-style.md命名约定、功能用法表、结构体打包、属性、包含保护时
references/architecture.md选择固件架构、FreeRTOS IPC 模式、低功耗模式、ESP32 深度睡眠与快速唤醒、设置 CI/CD 流水线、二进制大小预算时
references/stm32-pitfalls.mdCubeMX 代码生成问题、HAL 与 LL 的选择、难以调试的问题(缓存、优先级反转、闪存停顿)、代码审查清单时
references/design-patterns.mdHAL 设计模式(CRTP/模板/虚拟/不透明)、依赖注入策略、嵌入式 SOLID、回调与 trampoline 模式时
references/safety-hardware.mdMPU 保护模式、看门狗层级、IEC 61508 故障恢复、外设协议选择(UART/I2C/SPI/CAN)、链接器脚本内存布局时

评论

支持 Markdown