1. 课程导入

嵌入式系统开发正从“寄存器直写 + 全局变量 + 大循环”快速走向“可复用组件 + 清晰接口 + 可测试架构”。在资源受限、实时性敏感、硬件差异巨大的嵌入式环境里,C++ 并不是“把 PC 端那套搬过来”,而是要用它更精确地表达硬件抽象、资源所有权、接口契约与组件边界。本课程围绕一个核心问题展开:如何用 C++ 设计出既贴近硬件、又能长期维护和复用的接口与组件。

一、什么是嵌入式C++接口与组件设计

接口是调用者与实现者之间的契约,它规定“能做什么”,隐藏“怎么做”。在嵌入式 C++ 中,接口通常表现为抽象基类、纯虚函数、模板概念、回调签名或编译期约束。组件则是围绕某一职责封装起来的一组接口、数据与实现,例如 GPIO 驱动组件、串口通信组件、传感器采集组件、日志组件、状态机组件。

与通用软件不同,嵌入式接口设计必须同时面对四类约束:

  • 资源约束:RAM/ROM 有限,避免不必要的虚函数表、动态分配和异常开销。
  • 实时约束:中断上下文、任务优先级、阻塞时间与响应延迟必须可预期。
  • 硬件约束:寄存器、DMA、时钟、引脚复用、外设差异需要被抽象但不能被“抽象掉”。
  • 生命周期约束:对象何时构造、何时初始化、何时销毁,必须与硬件上电时序一致。
接口设计的目标不是“看起来优雅”,而是让硬件差异被隔离、让上层逻辑可测试、让替换实现不破坏调用方。

二、学习目标:从会写 C++ 到会设计可复用组件

完成本课程后,学习者应能达成以下目标:

  1. 能够区分“接口”“实现”“组件”“模块”在嵌入式语境下的边界与职责。
  2. 能够使用抽象基类、模板、策略模式、依赖注入等手段设计低耦合接口。
  3. 能够为 GPIO、UART、SPI、I2C、ADC、定时器等常见外设设计统一抽象层。
  4. 能够控制 C++ 特性开销,理解虚函数、RTTI、异常、动态内存在 MCU 上的代价。
  5. 能够编写可在宿主机上单元测试的硬件无关组件,并通过 mock/fake 替换硬件实现。
  6. 能够识别并重构“上帝类”“全局状态”“中断与任务共享数据”等典型坏味道。

三、整体知识框架

本课程的知识框架可以理解为一条从“语言机制”到“硬件抽象”再到“系统集成”的链路。下图展示了接口与组件设计在嵌入式 C++ 工程中的位置:底层是语言与资源约束,中层是接口抽象与组件封装,上层是应用逻辑与测试验证。

嵌入式C++接口与组件设计 · 知识框架 应用与业务逻辑层 状态机 · 控制算法 · 协议解析 · 业务策略 · 事件分发 关注:可读性、可测试性、与硬件解耦 组件封装层 GPIO组件 · UART组件 · 传感器组件 · 日志组件 · 定时器组件 关注:职责单一、生命周期、线程/中断安全 接口抽象层 抽象基类 · 纯虚函数 · 模板概念 · 回调签名 · 依赖注入 关注:契约稳定、实现可替换、编译期约束 语言与资源约束层 零开销抽象 · 虚函数表 · 内存布局 · 异常/RTTI开关 · 编译期计算 关注:RAM/ROM占用、实时性、可预测性 验证与测试:宿主机单元测试 · Mock/Fake · 硬件在环 · 静态分析 · 覆盖率

四、典型应用场景

接口与组件设计并非“架构师才需要”,它直接决定日常开发效率。以下场景在嵌入式项目中非常常见:

场景 问题 接口/组件设计的作用
多型号 MCU 共用业务代码 换芯片就要改上层逻辑 用统一外设接口隔离寄存器差异
传感器驱动复用 不同传感器读写时序不同 抽象出 Sensor 接口,实现可替换
宿主机单元测试 硬件不在身边无法验证逻辑 依赖注入 + Fake 实现,脱离硬件运行
中断与任务共享数据 竞态、丢数据、难复现 用组件封装队列/双缓冲与临界区
产品线功能裁剪 条件编译遍布代码 用接口组合与策略模式替代宏开关

五、一个最小示例:接口如何隔离硬件

下面用 GPIO 抽象说明“接口—实现—调用方”的关系。上层只依赖 IGpio,不关心是 STM32 还是 ESP32。

class IGpio {
public:
    virtual ~IGpio() = default;
    virtual void write(bool level) = 0;
    virtual bool read() const = 0;
};

class Stm32Gpio final : public IGpio {
public:
    explicit Stm32Gpio(GPIO_TypeDef* port, uint16_t pin)
        : port_(port), pin_(pin) {}
    void write(bool level) override { /* HAL_GPIO_WritePin */ }
    bool read() const override { /* HAL_GPIO_ReadPin */ }
private:
    GPIO_TypeDef* port_;
    uint16_t pin_;
};

class LedController {
public:
    explicit LedController(IGpio& gpio) : gpio_(gpio) {}
    void on()  { gpio_.write(true); }
    void off() { gpio_.write(false); }
private:
    IGpio& gpio_;
};
注意:LedController 不包含任何寄存器头文件,因此可以在 PC 上用 FakeGpio 直接测试。

六、常见误区与复盘

  • 误区一:接口越抽象越好。过度抽象会带来虚函数开销和调试困难,嵌入式应优先“够用且稳定”的抽象。
  • 误区二:把所有东西都做成单例。全局单例破坏可测试性,也容易在中断与任务间产生隐藏依赖。
  • 误区三:在构造函数里初始化硬件。构造顺序不可控,硬件初始化应显式调用 init() 并检查返回值。
  • 误区四:忽略对象生命周期。静态对象、栈对象、堆对象在嵌入式中的代价完全不同,需明确所有权。
  • 误区五:接口没有错误处理。嵌入式接口应明确返回错误码或状态,而不是默默失败。
复盘建议:每设计一个组件,先问三个问题——它依赖什么硬件?谁拥有它的生命周期?如果换一块板子,哪些代码必须改?如果答案超过一处,说明接口边界还需要调整。

本课程后续将沿着“语言机制 → 接口抽象 → 组件封装 → 硬件适配 → 测试验证 → 系统集成”的路径逐步展开。掌握这套方法后,你写的将不再是一堆只能在一款板子上运行的代码,而是可以跨项目、跨芯片、跨团队复用的嵌入式 C++ 组件。