1. 写在前面:为什么嵌入式开发绕不开引用计数
在很多嵌入式工程师的固有印象里,C++ 虽然表达能力更强,但一旦引入对象、继承、模板和 STL,似乎就离「可控的内存占用、确定性的运行时间」越来越远。尤其是在资源紧张的 MCU 上,动态内存分配往往被严格限制,甚至有些团队的编码规范直接禁止使用堆内存。于是很多人会得出一个结论:引用计数、智能指针这类东西,是桌面端和服务器端的玩具,和嵌入式无关。
如果只停留在「能用就行」的阶段,这个结论听上去成立;但一旦项目开始认真处理多模块共享资源、异步事件、外设句柄、网络会话、传感器数据缓存等真实问题,你会发现有一个绕不开的核心矛盾:资源不知道什么时候可以安全释放。一个数据缓冲区可能同时被通信模块、日志模块和上层业务模块持有;一个 DMA 传输还在进行时,发起方可能已经被销毁;一个传感器对象被定时器和主循环共同使用,谁都不确定对方是否已经访问完毕。
这些问题本质上是「对象生命周期管理」问题。可用的解法主要有三类:
- 全局/静态对象:生命周期与整个程序一样长,简单粗暴,但浪费常驻内存,且多实例场景难以扩展。
- 显式手动管理:由程序员约定「谁负责释放」,规则脆弱,容易踩空,尤其在中断、多任务和异常路径下常常变成悬空指针和内存泄漏的温床。
- 引用计数:记录「当前还有多少个使用者」,当使用者数量归零时自动释放。它把释放时机从「人脑约定」变成「运行时状态」,在不引入垃圾回收器的情况下,提供了一种确定性强、实现成本相对可控的自动生命周期管理方案。
之所以特别强调「不引入垃圾回收器」,是因为嵌入式系统通常对实时性和内存占用有严格约束。追踪式 GC 需要扫描对象图、短暂暂停业务逻辑,并且通常依赖较大的托管堆,这与很多 MCU 环境天然不兼容。相比之下,引用计数是一个对象级别的确定性策略:计数增减发生在构造函数、拷贝赋值、指针传递等明确位置,回收发生在计数归零的瞬间,不依赖扫描,不会出现全局暂停。
这篇文章会从引用计数的基本原理出发,深入探讨侵入式与非侵入式两种实现,拆解 std::shared_ptr 和 std::weak_ptr 的底层机制,分析原子操作、内存模型和缓存行为对性能的影响,并贯穿大量适合嵌入式场景的优化思路、代码示例和工程经验。全文将近两万字,目标是让读者不仅「会用智能指针」,而且能够理解引用计数在嵌入式环境下真正的成本来源、性能边界和优化手段。
阅读本文需要的基础包括:C++ 构造函数、拷贝构造、移动语义、运算符重载、模板、基本的多线程与原子操作概念,以及最基本的嵌入式开发常识。即使暂时不熟悉某些知识点,也可以在阅读过程中跟随示例逐步建立直观认识。接下来,我们先从引用计数最底层的模型讲起。
2. 引用计数的基本模型
2.1 一个最简单的思想实验
引用计数的核心思想只有一句话:给对象配一个计数器,每当有新的引用指向它时,计数器加一;每当一个引用不再指向它时,计数器减一;当计数器减到零时,销毁对象。
这句话听起来像废话,但它隐藏了引用计数方案中几乎所有复杂性的来源。我们可以用一个不依赖任何库的最小示例来观察其运作方式。假设有一个需要在多个模块之间共享的传感器样本对象,我们定义它的骨架如下:
#include <cstdint>
#include <cstdio>
struct SensorSample {
uint32_t id;
int16_t temperature;
uint16_t humidity;
};
class SharedSample {
public:
explicit SharedSample(SensorSample data)
: data_(data), ref_count_(nullptr) {
ref_count_ = new int(1); // 新对象初始只有创建者持有
}
// 拷贝构造:共享同一份数据和控制块
SharedSample(const SharedSample& other)
: data_(other.data_), ref_count_(other.ref_count_) {
if (ref_count_ != nullptr) {
++(*ref_count_);
}
}
~SharedSample() {
release();
}
SharedSample& operator=(const SharedSample& other) {
if (this != &other) {
release(); // 先释放当前持有的引用
data_ = other.data_;
ref_count_ = other.ref_count_;
if (ref_count_ != nullptr) {
++(*ref_count_); // 再持有新引用
}
}
return *this;
}
const SensorSample* get() const {
return &data_;
}
int use_count() const {
return ref_count_ == nullptr ? 0 : *ref_count_;
}
private:
void release() {
if (ref_count_ != nullptr) {
–(*ref_count_);
if (ref_count_ == 0) {
delete ref_count_; // 销毁控制块
// 这里仅演示,实际应为 data_ 单独分配时再释放
}
ref_count_ = nullptr;
}
}
SensorSample data_; // 简化:直接存对象
int ref_count_; // 指向共享计数器的裸指针
};
这个例子虽然代码粗糙,但已经能说明引用计数方案的几个关键特征:
- 对象数据和引用计数被分开管理。对象可以有多个持有者,但计数只有一个。
- 拷贝构造不再复制完整资源,而是复制「指向共享资源的指针和计数器」。
- 析构函数并不立即销毁资源,而是先把自己的引用注销,只有最后一个持有者离开时,才真正触发销毁。
- 赋值操作必须正确处理「先释放旧引用,再获取新引用」的顺序问题,尤其要避免自赋值。
它同时也暴露了引用计数模型中最容易出错、也是最核心的两个分离关系:业务数据的生命周期被共享计算所有权,而共享计数本身也需要生命周期管理。当计数归零时,不仅业务数据需要销毁,计数所在的「控制结构」也要销毁。如何合理组织这两块内存,直接决定了实现的复杂度、内存开销和性能表现。
2.2 所有权、生命周期与释放时机
在继续展开之前,必须先理清三个容易被混淆的概念。
所有权(Ownership):一个对象是否「拥有」某份资源,决定了它是否有责任在合适的时机释放这份资源。在普通裸指针模型中,所有权通常靠文档约定;在引用计数模型中,所有权具体化为「是否持有一个引用,从而把计数器加了一」。
生命周期(Lifetime):资源从创建到销毁的整个区间。引用计数并不能无限延长资源的生命周期,它只能保证只要还有人引用,资源就继续存活;一旦最后一个引用释放,生命周期立即结束。换句话说,引用计数是一种「精确的、由使用者状态决定的生命周期」。
释放时机:计数归零的那一刻,对象会被立刻销毁。这个「立刻」带来了引用计数最大的优势:确定性。它不会像 GC 那样把回收推迟到下一次扫描。但这份确定性也有代价,它会把你无法预料的销毁逻辑嵌进一条看起来只是「释放了一个指针」的普通调用路径里。
举例来说,如果一个 shared_ptr 指向的对象,其析构函数内部会去关闭一个串口、等待一个 RTOS 信号量,甚至间接再去释放另一个 shared_ptr,那么「最后一个引用离开作用域」这个看似无害的语句,就有可能触发一次相当重的调用链。嵌入式开发人员必须对这一点有清醒认识:引用计数不会消除析构成本,它只是把析构时机从显式调用转移到了计数归零时。这个特征在后续性能分析和工程项目中会被反复提及。
2.3 循环引用:引用计数最著名的软肋
引用计数有一个经典的、无法通过单纯计数方案自行解决的问题:循环引用。假设对象 A 持有一个指向对象 B 的共享引用,对象 B 又持有一个指向对象 A 的共享引用,那么即便外部已经没有任何代码再指向这两个对象,它们彼此的计数仍然至少为 1,永远不会归零,内存也就永远不会被释放。
这个问题的根源在于:引用计数只能发现「从对象出发可以到达哪些对象」,却无法判定「有哪些对象已经不可到达」。它不具备图遍历能力,而循环引用恰恰是对象图中「不可达但内部仍互相引用」的一类结构。
解决循环引用的常用手段有两类:
- 弱引用(weak reference):弱引用不增加强计数,只是提供一个「如果对象还活着,我就拿到一个强引用」的入口。用于打破环中的某一条边。
- 显式打破循环:在析构前或确定不再使用的某个时机,手动把环中的某条引用断开,例如把某个父对象中的子指针置空。这需要开发者对对象图有全局认识。
在嵌入式工程中,循环引用常常出现在父子对象、事件回调、观察者模式、会话与连接对象等双边关联里。后续讲到 weak_ptr 时,我们会给出完整方案和更贴近设备的例子。这里先记住结论:引用计数不是万能的内存管理策略,它只是在正确使用习惯下非常有用的一种确定性方案。
3. 引用计数 vs 其他内存管理策略
3.1 手动管理、作用域管理和引用计数
在嵌入式 C 和早期 C++ 项目中,内存管理方式通常是「谁申请、谁释放」的手工模式。这种模式在代码路径极其简单的场景下高效且直观:函数里分配一块缓冲区,使用完毕随即释放,不会额外付出计数开销。
但手工模式在多模块共享资源时迅速失控。一个经典场景是下面这段伪代码所描述的问题:
uint8_t* buffer = allocate(1024);
uart_send_async(uart1, buffer, 1024); // 异步发送,不知道何时完成
log_module_attach_buffer(buffer, 1024); // 日志模块也要读取
business_process(buffer, 1024); // 业务模块使用后准备释放
// 问题:到底谁能 free(buffer)?
// 如果业务模块在 DMA 发送完成前释放,uart 中断就会访问野指针。
要解决这个问题,要么引入状态机跟踪「谁还在用」,要么干脆永不释放。前者就是在手工实现一个残缺的引用计数;后者则在长期运行的设备上逐渐耗尽内存。可见,引用计数并不是给程序员增加负担,而是把本就必须存在的生命周期管理逻辑显式化、自动化了。
作用域管理则是 C++ 的 RAII 思想的直接体现:在对象离开作用域时自动析构,例如栈对象、std::unique_ptr、局部锁等。它的优势是零额外开销、完全确定、实现简单;局限是所有权必须唯一或者在明确的时间点转移。当所有权结构是「一棵树」时,unique_ptr 之类独占所有权方案非常优秀;当所有权是「一张共享的图」时,就必须引入引用计数。
引用计数恰好填补了独占所有权与垃圾回收之间的空白:它允许任意数量的共享拥有者,同时在计数归零时立即回收。它的成本是每次构造、拷贝、赋值、移动和析构都可能出现一次计数操作,以及一个额外的控制状态存储。
3.2 嵌入式工程师常见的顾虑
下面这张表总结了嵌入式环境下三种主流生命周期管理方案的特性对比,后文很多优化手段都是在补足引用计数相对弱项过程中发展出来的。
| 多模块共享 | 难以维护,靠约定 | 不适用 | 天然支持 |
| 释放时机 | 显式调用 | 离开作用域 | 最后一个引用释放时 |
| 运行时开销 | 基本为零 | 基本为零 | 每次指针复制有加减操作 |
| 内存占用 | 纯资源本身 | 纯资源本身 | 额外控制块 |
| 循环引用 | 人为控制 | 通常不出现 | 需要 weak 引用打破 |
| 代码可维护性 | 差 | 好,但仅限树形结构 | 好,适合图结构 |
| 实时确定性 | 很好,但可能出错 | 很好 | 很好,计数操作为小型确定性操作 |
从表中可以看到,引用计数最需要被认真对待的是两件事:原子计数的运行时代价和控制块带来的额外内存占用。在 RAM 只有几十 KB、CPU 主频只有几十 MHz 的低成本 MCU 上,这两个问题都会被放大。但这并不意味着应该彻底放弃引用计数,而是应该学会「按需选择实现层级」。
例如:当对象只会在单线程消息循环中被引用时,完全不需要原子计数,可以用普通 int 计数;当对象需要跨中断、跨 RTOS 任务共享时,再考虑原子操作;当系统内存非常紧张时,可以把引用计数嵌入对象内部,省去独立控制块的一次分配。后文会详细展开这些策略。
4. 侵入式引用计数:把计数器塞进对象里
4.1 侵入式与非侵入式的区别
引用计数实现可以按照「计数器存放在哪里」分为两大类:
- 侵入式(Intrusive):把引用计数作为被管理对象的一部分。对象本身就必须知道自己被引用了几次。典型例子是 Linux 内核的 kref,以及许多嵌入式框架中的 RefCounted 基类。
- 非侵入式(Non-intrusive):为被管理对象额外分配一个独立的控制块,控制块中保存计数,对象本身不需要做任何修改。典型例子是 std::shared_ptr。
侵入式的优势在于:不需要额外的控制块分配,也不会因为「控制块和对象分别释放」而增加复杂度和内存碎片;对象知道自己被引用了多少次,可以在某些框架里直接查询。它的劣势也很明显:被管理的类型必须配合设计,要么继承某个基类,要么在类型里显式包含计数字段,侵入性较强;另外,同一个对象通常只能实现一种计数协议,难以被不同的引用计数框架同时管理。
侵入式引用计数非常适合嵌入式系统的原因,恰恰是它对堆分配次数和内存布局的要求更为克制。在资源紧张且对象类型相对固定的嵌入式架构里,为对象内嵌一个计数器,比每次创建对象都要额外分配一个控制块要可靠得多。
4.2 一个基于基类的侵入式实现
下面给出一个嵌入式风格的侵入式引用计数实现。它不依赖动态内存分配控制块,但对象本身仍需通过一定方式创建,我们暂时先使用普通的 new,重点观察计数逻辑:
#include <cstdint>
class RefCounted {
public:
RefCounted() : ref_count_(0) {}
virtual ~RefCounted() = default;
void add_ref() const {
++ref_count_;
}
void release() const {
if (–ref_count_ == 0) {
delete this;
}
}
uint32_t use_count() const {
return ref_count_;
}
protected:
RefCounted(const RefCounted&) = delete;
RefCounted& operator=(const RefCounted&) = delete;
private:
mutable uint32_t ref_count_;
};
使用方法看起来很像经典 COM 风格的 AddRef / Release:
class IpcMessage final : public RefCounted {
public:
explicit IpcMessage(uint32_t id) : msg_id_(id) {
add_ref(); // 创建者立即持有一个引用
}
uint32_t id() const { return msg_id_; }
private:
uint32_t msg_id_;
};
void example_intrusive() {
IpcMessage* msg = new IpcMessage(42); // use_count = 1
msg->add_ref(); // use_count = 2
process_in_task_a(msg); // 任务 A 也使用
msg->release(); // 创建者不再直接使用, use_count = 1
// 任务 A 处理完后再调用 msg->release();
// 计数归零, delete this 自动销毁对象
}
这里的 release() 内部直接 delete this,是侵入式引用计数中最具争议也最常见的写法。它把「对象销毁自己」这个行为合法化了,但同时也埋下了一个陷阱:调用 release() 之后,当前代码不能再访问该对象的任何成员,因为对象可能已经销毁。
这是引用计数代码中最容易踩空的地方之一。很多资深 C++ 工程师甚至会避免在 release() 内直接 delete this,而采用某种延迟回收机制。但在嵌入式环境中,只要调用纪律足够严格,这种一次性回收方案完全可以工作,并且回收路径最短。
4.3 多线程安全问题:普通整数不够用
上面的实现使用了普通的 uint32_t 和 ++、–。这在单线程消息循环中是没问题的,甚至可以说性能最好。但只要对象可能被多个 RTOS 任务或者中断上下文访问,这个实现就存在数据竞争。
原因是:编译器和 CPU 并不保证普通整数的「读-改-写」操作是原子的。两次 ++ 可能交错执行,最终计数少加一次,导致对象在还有使用者时被提前销毁;或者少减一次,导致对象永远无法释放。
在嵌入式系统中,还有一个比桌面系统更隐蔽的风险:中断重入。即使只有单核单线程,如果主循环中执行 ++ref_count_ 时被一个同样会操作该对象的中断打断,也可能出现一致性问题。尤其是当整数操作被拆成多条指令时,这种风险更加实际。
因此,跨上下文的引用计数必须使用原子操作,或者先通过对系统并发模型的分析来确认是否真的存在并发。下面给出一个在 C++11 基础上支持原子计数的侵入式版本:
#include <atomic>
class AtomicRefCounted {
public:
AtomicRefCounted() : ref_count_(0) {}
virtual ~AtomicRefCounted() = default;
void add_ref() const {
ref_count_.fetch_add(1, std::memory_order_relaxed);
}
void release() const {
if (ref_count_.fetch_sub(1, std::memory_order_acq_rel) == 1) {
delete this;
}
}
private:
mutable std::atomic<uint32_t> ref_count_;
};
关于内存序的选择,这里使用了 relaxed 进行增加操作,使用 acq_rel 进行减少操作。核心逻辑是:
- 加引用时,我们只是单纯增加一个数值,不依赖读取到的旧值来做后续决策,也不需要与其他内存操作建立严格的先后关系,因此 relaxed 通常足够。
- 减引用时,我们必须在确认「初始计数是否为 1」的基础上决定是否销毁对象。这里需要保证:如果另一个线程在此之前已经写入过对象的新状态,我们在销毁对象之前能够看到这些状态;同时对象销毁后不能被其他线程继续访问。使用 acq_rel 可以在当前线程内部建立获取与释放语义。
严格来说,引用计数与内存序的关系非常微妙,C++ 标准库本身对 shared_ptr 的控制块采用了一套经过验证的实现。读者在自己的侵入式轮子中,建议要么使用最保守的 seq_cst 保证简单正确,要么深入理解内存序之后再放宽。性能收益不应以正确性为代价,这在嵌入式底层代码中尤其重要。
4.4 侵入式引用计数的内存布局优势
对于嵌入式系统,侵入式方案有一个不可忽视的优势:对象和控制信息在内存中是连续的。一次分配就可以同时容纳对象成员和引用计数器,从而避免另一块独立内存造成碎片。假设对象大小为 64 字节,非侵入式方案可能需要额外一个 8 到 16 字节的控制块,而且两者还未必相邻,这对缓存也不友好。
在需要高吞吐、多对象频繁创建销毁的设备端逻辑中,减少一次控制块分配意味着更少的堆操作、更稳定的内存布局和更可预测的延迟。很多商业嵌入式框架,比如某些 RTOS 的 C++ 封装、音频引擎、图形引擎,都选择侵入式引用计数,原因就在于此。
当然,侵入式方案也有明显代价:它要求被管理对象主动继承基类,这对某些没有源码控制权或者需要与 C 接口交互的对象来说比较麻烦。此外,虚析构函数在一些极致裁剪的嵌入式编译器上会引入虚表,增加对象大小。为避免虚析构,有些框架直接要求派生类「必须在析构函数中做清理」,基类只提供计数函数,不提供虚析构。这种做法虽然降低了类型安全,但在资源和工具链受限时仍然广泛存在。
5. 非侵入式引用计数:控制块与对象分离
5.1 为什么需要控制块
侵入式方案要求对象自己携带计数信息,但很多时候我们没有办法修改对象类型。比如某个传感器驱动库只提供一个 Sensor* 裸指针和对应的 sensor_destroy 函数,或者某个遗留模块返回一个普通结构体指针。此时,想把引用计数加进去,最自然的做法就是「在对象外面包一层」:用一个独立的控制块保存指针和计数。
控制块通常包含以下内容:
- 指向被管理对象的指针。
- 强引用计数。
- 弱引用计数(如果支持弱引用)。
- 自定义删除器或容器信息(在 STL 实现中常由类型擦除完成)。
非侵入式的优点很明显:被管理对象无需任何修改,甚至可以是由 C 接口创建的裸对象;引用计数器与对象生命周期解耦,能够支持更丰富的控制策略。它的缺点则是:对象创建时需要进行至少两次分配,或者至少一次「对象分配 + 控制块分配」的组合;对象可能先于控制块销毁,控制块也可能在对象之后仍然存活一段时间,这对内存管理提出了额外要求。
5.2 从头实现一个极简控制块
在深入研究 std::shared_ptr 之前,先手工实现一个只支持强引用、不支持弱引用的极简非侵入式引用计数指针,以便理解控制块的生命周期。示例代码如下:
#include <cstdint>
#include <utility>
struct ControlBlock {
uint32_t strong_count;
explicit ControlBlock(uint32_t count) : strong_count(count) {}
};
template <typename T>
class SimpleSharedPtr {
public:
explicit SimpleSharedPtr(T* ptr = nullptr)
: control_(nullptr), ptr_(nullptr) {
if (ptr != nullptr) {
control_ = new ControlBlock(1);
ptr_ = ptr;
}
}
SimpleSharedPtr(const SimpleSharedPtr& other)
: control_(other.control_), ptr_(other.ptr_) {
if (control_ != nullptr) {
++control_->strong_count;
}
}
SimpleSharedPtr(SimpleSharedPtr&& other) noexcept
: control_(other.control_), ptr_(other.ptr_) {
other.control_ = nullptr;
other.ptr_ = nullptr;
}
~SimpleSharedPtr() {
release();
}
SimpleSharedPtr& operator=(const SimpleSharedPtr& other) {
if (this != &other) {
release();
control_ = other.control_;
ptr_ = other.ptr_;
if (control_ != nullptr) {
++control_->strong_count;
}
}
return *this;
}
SimpleSharedPtr& operator=(SimpleSharedPtr&& other) noexcept {
if (this != &other) {
release();
control_ = other.control_;
ptr_ = other.ptr_;
other.control_ = nullptr;
other.ptr_ = nullptr;
}
return *this;
}
T* get() const { return ptr_; }
T& operator*() const { return ptr_; }
T operator->() const { return ptr_; }
uint32_t use_count() const {
return control_ == nullptr ? 0 : control_->strong_count;
}
private:
void release() {
if (control_ != nullptr) {
–control_->strong_count;
if (control_->strong_count == 0) {
delete ptr_;
delete control_;
}
control_ = nullptr;
ptr_ = nullptr;
}
}
ControlBlock* control_;
T* ptr_;
};
这个实现足够简单,也足够说明控制块方案的典型行为。每次拷贝都会把控制块指针复制过去,并递增计数;每次析构或赋值时旧引用都会减少计数;当强计数归零时,先销毁业务对象,再销毁控制块。
值得注意的一个细节是:释放顺序是先 delete ptr_,再 delete control_。这个顺序在只支持强引用时看似无关紧要,但一旦引入弱引用,就必须保证被管理对象离开,而控制块可以继续存活,为弱引用提供「对象是否仍然存在」的查询能力。
5.3 为什么 shared_ptr 还会保存两个指针
很多人在查看 std::shared_ptr 的内存布局时会发现,它内部通常保存了两个裸指针:一个指向被管理对象,另一个指向控制块。初看起来,有了控制块中的 T*,似乎已经足够,为什么 shared_ptr 自己还要再持有一个对象指针?
答案与「shared_ptr<T> 管理的是 T,但实际对象可能是 T 的派生类」有关。考虑下面这种情况:
class Base { public: virtual ~Base() = default; };
class Derived : public Base { /* … */ };
std::shared_ptr<Derived> d = std::make_shared<Derived>();
std::shared_ptr<Base> b = d; // 隐式转换
当 b 被赋值时,控制块仍然管理同一个 Derived 对象,但 b.get() 按语义应该返回 Base*。如果 shared_ptr 只保存控制块指针,再通过控制块里的对象指针去取对象,那么每次调用 get() 时都可能需要从 Derived* 转换到 Base*,而且这种转换可能不是简单偏移,在多重继承和虚继承场景下会非常复杂。因此,shared_ptr 通常会同时保存「作为 Base 视角的对象指针」和「作为控制块视角的原始指针」。对象指针用于快速访问,控制块指针用于引用计数和销毁。
这个细节也解释了为什么 std::shared_ptr 对象本身的大小通常不小于两个指针,在 32 位平台上为 8 字节,在 64 位平台上为 16 字节。对于嵌入式系统来说,这意味着如果你用大量 shared_ptr 作为函数参数或存进容器,它会带来两倍于裸指针的指针存储开销。
6. std::shared_ptr 的内部世界
6.1 make_shared 与一次分配优化
在 C++ 标准库中,创建一个 shared_ptr 通常有两种方式:
// 方式一:分别分配对象和控制块
std::shared_ptr<Sensor> p1(new Sensor());
// 方式二:把对象和控制块做一次合并分配
std::shared_ptr<Sensor> p2 = std::make_shared<Sensor>();
方式一先通过 new Sensor() 单独分配对象,然后 shared_ptr 构造函数再分配并管理控制块,一共至少两次内存分配。方式二则允许标准库把对象和控制块分配在同一块连续内存中,大多数实现下 make_shared 只需要一次内存分配。这种合并分配带来两个好处:
- 减少堆分配次数,缓解内存分配器压力。这对频繁创建销毁的中小对象尤其有价值,而且能降低分配失败的概率。
- 改善局部性。对象和控制块位于相邻内存,访问计数和访问对象可能命中同一条缓存行。
但 make_shared 也有一个嵌入式开发者应该知道的代价:由于对象和控制块位于同一块内存中,当强引用归零时,对象析构后,整块内存必须等到所有弱引用也释放后才能归还。如果程序大量使用 weak_ptr,而强引用已经归零,对象本身虽然已经析构,但其占用的内存块可能因为控制块仍然存活而无法立即释放。这个行为与「分别分配」方案不同。
在分别分配方案中,强引用归零时只有对象那一块内存被释放,控制块单独保留给弱引用。在嵌入式大对象场景中,这种差别可能很重要。假设一个摄像头帧缓冲对象占 1 MB,通过 make_shared 创建后,即便强引用已经归零,只要某个地方还残留一个 weak_ptr,这 1 MB 就无法还给系统。因此,针对大块资源的共享,很多嵌入式项目会有意识地避开 make_shared,或者严格限制 weak_ptr 的存活范围。
6.2 make_shared 在裸指针构造面前的优势
除了性能,make_shared 还有一个明显的正确性优势:它避免了构造可能失败时裸指针泄漏的问题。例如:
std::shared_ptr<Sensor> p(new Sensor(port, baud));
在这条语句中,先执行 new Sensor(port, baud) 创建对象,再把裸指针交给 shared_ptr 构造函数。在没有异常安全保证或者临时构造过程中出现异常时,就存在裸指针尚未被 shared_ptr 接管的窗口。虽然 C++ 标准库对此有相应保证,但从历史实现和阅读角度看,make_shared 把对象创建与控制块创建封装成一个不可分割的调用,更不容易写出资源泄漏代码。
在嵌入式环境中,虽然很多项目关闭了 C++ 异常,以换取更小的代码体积和更确定的控制流,但这个优势仍然有意义。即使不抛异常,make_shared 的封装也减少了手工写两次分配逻辑时的出错概率,让代码更清晰。
6.3 自定义删除器与嵌入式设备句柄
嵌入式系统经常需要管理的不只是内存,还有文件描述符、Socket、串口句柄、DMA 通道、信号量等系统资源。shared_ptr 支持自定义删除器,使我们能够把引用计数应用在这些非内存资源上。
例如,假设某个 RTOS 提供了如下消息队列接口:
typedef void* MsgQueueHandle;
MsgQueueHandle msg_queue_create(size_t depth, size_t item_size);
void msg_queue_destroy(MsgQueueHandle handle);
我们可以这样用 shared_ptr 管理它:
#include <memory>
using MsgQueuePtr = std::shared_ptr<void>;
MsgQueuePtr make_msg_queue(size_t depth, size_t item_size) {
return MsgQueuePtr(msg_queue_create(depth, item_size),
[](void* handle) {
if (handle != nullptr) {
msg_queue_destroy(handle);
}
});
}
这段代码让一个 C 接口资源获得了共享所有权语义。任何模块只要持有 MsgQueuePtr,就能保证队列在最后一个使用者离开前不被销毁。这是引用计数在嵌入式系统中非常实用的一个方向:把「资源生命周期」从手工调用管理函数中解放出来,统一纳入 C++ 对象模型。
自定义删除器的实现通常放在控制块中。这也再次验证了控制块存在的价值:它把「如何销毁资源」的知识统一封装起来,而不需要让每个持有者都知道资源的获取方式。
6.4 shared_ptr 与数组:一个容易踩空的地方
在 C++11 标准中,shared_ptr 不能直接安全地管理数组,因为其默认删除器使用 delete ptr,而不是 delete[] ptr。直到后来的标准才为 shared_ptr<T[]> 提供支持。但在很多旧的嵌入式编译器上,我们依然会看到下面这种危险写法:
std::shared_ptr<uint8_t> buf(new uint8_t[1024]); // 行为未定义
这会把数组首元素当作单个对象来销毁,造成未定义行为。解决办法是显式提供数组删除器:
std::shared_ptr<uint8_t> buf(new uint8_t[1024],
[](uint8_t* p) { delete[] p; });
对嵌入式程序员来说,这种问题还会和自定义内存池、对齐缓冲一起出现。即使使用支持数组的现代版本,也应谨慎评估其与硬件缓冲、DMA 对齐之间的关系。后续实战案例中,我们会采用一种更偏向底层控制的共享缓冲区设计。
7. enable_shared_from_this 与 this 的生命周期难题
7.1 this 指针不能直接构造 shared_ptr
在一个已经被 shared_ptr 管理的对象内部,经常需要把「指向自己的共享引用」交给外部。新手最容易犯的错误是直接这么做:
class Connection : public std::enable_shared_from_this<Connection> {
public:
void register_callback() {
std::shared_ptr<Connection> self(this); // 危险
event_loop.subscribe([self]() {
self->handle_event();
});
}
};
表面上看,这行代码确实创建了一个 shared_ptr<Connection>,并且计数为 1。但问题是,对象本身可能已经被另一个 shared_ptr 管理过了。现在又用同一个 this 创建了第二个互不知情的控制块。两个控制块分别认为自己拥有对象,只要其中一个计数归零,就会销毁对象,而另一个 shared_ptr 仍然存在,随后访问悬挂指针,导致灾难。
这就是著名的「多控制块问题」。正确做法是让对象参与到一个统一控制块的体系中,std::enable_shared_from_this 正是为此设计。
7.2 enable_shared_from_this 的正确用法
把类改成继承 std::enable_shared_from_this<Connection>,然后在需要共享自身的地方调用 shared_from_this():
#include <memory>
class Connection final : public std::enable_shared_from_this<Connection> {
public:
void register_callback() {
std::shared_ptr<Connection> self = shared_from_this();
event_loop.subscribe(self {
self->handle_event();
});
}
private:
void handle_event() {}
};
调用 shared_from_this() 时,对象内部会通过已经存在的控制块信息,构造一个与目前管理者共享同一控制块的 shared_ptr,而不是重新创建一个控制块。
使用它有几个重要前置条件:
- 对象必须确实由 shared_ptr 管理。如果对象是栈对象,或者只是被裸指针管理,却调用了 shared_from_this(),属于未定义行为。在禁用异常的环境下,这通常意味着崩溃或不可预测的结果。
- 调用 shared_from_this() 的时机不能早于对象被 shared_ptr 接管。一个容易出错的写法是在构造函数中直接调用 shared_from_this(),因为此刻对象还未完成接管,标准库通常没有已经登记的控制块引用。
- 不要在析构函数中调用 shared_from_this()。此时强计数已经归零,这会导致重新创建一个强引用,破坏生命周期逻辑。
在嵌入式工程中,enable_shared_from_this 常用于交互式对象:一个网络会话需要在事件循环中注册回调,而回调必须保证会话对象存活;一个状态机对象要把自身交给多个异步任务。这些场景下,「让对象把自身的共享所有权交出去」是保证异步逻辑不访问已销毁对象的关键手段。
7.3 enable_shared_from_this 的实现机制
enable_shared_from_this 通常通过一个指向控制块的弱引用指针来实现。当对象第一次被放到某个 shared_ptr 中时,shared_ptr 的构造函数检测到该类型继承自 enable_shared_from_this,就会把控制块信息登记到对象内部的弱引用成员中。之后调用 shared_from_this() 时,对象利用这个弱引用临时提升出一个 shared_ptr,从而与现存控制块保持一致。
也就是说,enable_shared_from_this 本身也会给对象增加一个小型指针成员,带来一次对象大小增加。对内存特别紧凑的嵌入式结构体,这个开销可能需要注意。不过在绝大多数要求异步共享的场景里,这个成员大小的代价与它带来的安全性相比是值得的。
另外,enable_shared_from_this 只能在同一类型下安全使用。若在多层继承中想以不同基类类型取得共享指针,就可能出现问题。因此建议把继承关系设计清楚,实际工程中通常只让最终业务对象继承一个 enable_shared_from_this 模板实例。
8. weak_ptr:打破循环引用的钥匙
8.1 弱引用为什么不增加计数
std::weak_ptr 是从 shared_ptr 构造出来的、不增加强计数的观察者。它的核心作用是:在不确定对象是否仍然存活的情况下,安全地获取一个强引用。其核心操作是 lock(),如果对象还活着,就返回一个有效 shared_ptr;否则返回空。
为了支持弱引用,控制块内部通常会拆成两个计数:
- 强计数:记录有多少个 shared_ptr 持有者。
- 弱计数:记录有多少个 weak_ptr 观察者与控制块本身。控制块只有在强计数和弱计数都归零时才彻底释放。
在这个模型下,即使强计数归零、被管理对象已经析构,控制块仍然继续存在,直到最后一个 weak_ptr 也释放。这个行为解释了我们在 make_shared 小节中提到的大对象内存延迟释放问题。
8.2 经典循环引用示例
下面是一个观察者模式中常见的循环引用。Task 类持有回调依赖,回调对象又反过来持有 Task:
class TaskA;
class Callback {
public:
Callback(std::shared_ptr<TaskA> task) : task_(std::move(task)) {}
private:
std::shared_ptr<TaskA> task_;
};
class TaskA {
public:
TaskA() : callback_(nullptr) {}
void set_callback(std::shared_ptr<Callback> cb) {
callback_ = std::move(cb);
}
private:
std::shared_ptr<Callback> callback_;
};
void create_loop() {
auto task = std::make_shared<TaskA>();
auto cb = std::make_shared<Callback>(task);
task->set_callback(cb);
// task 持有 cb, cb 持有 task,形成循环
// 函数返回后两个对象都不会被释放
}
修复办法是把其中一条边改成弱引用。通常,父对象持有子对象可以用强引用,子对象回指父对象应当用弱引用:
class TaskA;
class Callback {
public:
// 用 weak_ptr 持有上游任务
explicit Callback(std::weak_ptr<TaskA> task)
: task_(task) {}
void notify() {
if (auto task = task_.lock()) {
task->on_event();
}
}
private:
std::weak_ptr<TaskA> task_;
};
class TaskA {
public:
TaskA() : callback_(nullptr) {}
void set_callback(std::shared_ptr<Callback> cb) {
callback_ = std::move(cb);
}
void on_event() {}
private:
std::shared_ptr<Callback> callback_;
};
通过把 Callback 中对 TaskA 的引用改成 weak_ptr,环就被打断了:TaskA 强引用 Callback,而 Callback 只弱引用 TaskA。外部释放 TaskA 后,其强计数归零,顺带使回调的强计数也归零。
这个例子在嵌入式系统中非常现实。一个设备连接对象与它的协议处理器、一个上报定时器与它的处理任务、一个外设对象与它注册到驱动里的回调,都有可能出现这种双向关系。设计图结构时,养成「从长生命周期对象指向短生命周期对象用强引用,反向用弱引用」的习惯,能避免大量隐藏内存泄漏。
8.3 weak_ptr 的 lock 与竞态窗口
weak_ptr::lock() 的实现并不是简单地读取计数。它需要构造一个新的 shared_ptr,并完成一次强计数增加。这个操作必须和控制块中的强计数变化保持同步。C++ 标准库在 lock() 中使用的是内部原子操作,通常采用比较交换循环或专用指令,来保证「检查到非零后增加计数」这个过程与「其他线程把计数减到零」之间不出现竞态。
这也意味着 weak_ptr::lock() 会比一次简单的指针读取更昂贵,因为它在持有时会真正增加一个 shared_ptr。在嵌入式热路径中,如果频繁地通过 weak_ptr 去临时访问对象,性能损失可能明显。通常建议在 lock() 成功后,将强引用保存在局部变量中,避免在循环里反复做 lock()。
8.4 expired 与对象可能已经不在
weak_ptr 还提供 expired() 用来判断被观察对象是否已经销毁。但要注意,expired() 的结果并不是一个永久的承诺:
std::weak_ptr<Sensor> wp = sensor;
if (!wp.expired()) {
// 此处对象可能已经被其他线程销毁
}
在单线程逻辑中,这个窗口通常可以忽略;但在 RTOS 多任务环境中,expired() 查询之后、真正使用对象之前,其他任务可能恰好释放最后一个强引用。因此,判断和使用必须合并到一次 lock() 中,不要用 expired() 做跨线程安全访问。
9. 线程安全:原子操作与内存序
9.1 引用计数的线程安全边界
std::shared_ptr 的线程安全规则有两个容易混淆的层面:
- 引用计数本身是线程安全的。多个线程同时拷贝同一个 shared_ptr 或销毁各自的 shared_ptr,控制块的计数不会破坏。
- 被管理对象的内容不是天然线程安全的。如果多个线程同时通过 shared_ptr 访问同一个数据对象,数据竞争仍然可能发生,必须另行加锁或做线程隔离。
此外,同一个 shared_ptr 对象实例本身,如果被多个线程无同步地写入,也是有问题的。例如一个全局 shared_ptr,线程 A 正在改写它,线程 B 同时也在改写它,这就不是引用计数能解决的问题了。C++ 标准库对此的表述是:不同线程可以同时安全地操作指向同一控制块的不同 shared_ptr 实例,但不能在没有同步的情况下同时读写同一个 shared_ptr 实例。
在嵌入式系统中,这些边界很容易在任务间传递指针时被忽视。一个常用做法是:通过消息队列传递 shared_ptr 时,由发送端和接收端各自保留自己的 shared_ptr 实例,而不共享可变引用。关于对象的内部访问,则使用队列、事件标志或自旋锁再做一层保护。
9.2 原子加法和原子减法为什么这么慢
很多人对原子操作有一个误解:它只是硬件提供的一条指令,应该接近普通加减法。现实是,在多核系统中,原子操作可能涉及:
- 获取缓存行独占所有权。
- 总线锁或者缓存一致性协议握手。
- 串行化多个核对同一地址的修改。
- 阻止附近的普通内存访问重排。
即使有硬件原子指令,真正的开销往往不是指令执行时间,而是缓存行争用。多个核心同时增加或减少同一个控制块的计数时,缓存行会在核心之间不断弹跳。一次看似微小的 fetch_add,可能付出远比一条 add 指令高得多的代价。
对嵌入式开发者来说,还有两个额外因素:
- 很多低成本 MCU 是单核的,没有缓存一致性协议,但原子操作可能通过关闭中断来实现,导致临界区变长。
- 实时性要求高的系统里,原子操作内部如果隐含短暂的关中断或自旋等待,可能会影响中断延迟。
因此,优化引用计数的第一步不是研究更精巧的内存序,而是先确认:是否真的需要原子计数?如果一个对象只在单线程中被共享,使用侵入式的普通整数计数就可以,性能远优于原子计数。
9.3 内存序选择:从 seq_cst 到更宽松的序
在 C++11 内存模型中,std::atomic 支持多种内存序:seq_cst、acquire、release、acq_rel、relaxed。其中 seq_cst 语义最强、最容易理解,但价格也最高;relaxed 语义最弱,只保证对同一个原子变量的修改序列在所有线程中一致,不建立与其他内存操作的先后关系。
引用计数场景有一个通用模式:
- 增加引用时使用 relaxed,因为我们只关心计数本身。
- 减少引用时使用 acq_rel,因为最后一下减少可能要销毁对象。我们希望自己的线程在销毁对象之前,能看到该对象在“上一个持有者”最后一次访问时的所有写操作。通常实现为:当 fetch_sub 读到旧值为 1 时,使用 acquire 语义;当所有其他持有者都在安全释放后,最后执行销毁的线程才进入析构。
标准库对 shared_ptr 的控制块实现通常比这个更加细致,也因为不同的标准库版本可能存在差异。对于工程实践,最稳妥的建议是:先以正确性优先,使用标准库默认实现,只有在 profile 明确显示原子计数成为瓶颈后,才考虑自己实现拆分计数方案。
后续第 12 章会讨论一种减少原子操作频率的实用方法,它的前提是理解控制块一旦稳定下来就不会频繁变化。
10. 性能全景:引用计数究竟有多贵
10.1 微观基准:裸指针、普通计数、原子计数
为了对引用计数的开销有一个直观认识,我们可以设计一个简单的统计场景:反复增加和减少同一个引用。下面是一段用于测量的伪代码,实际工程中应结合目标板环境和编译器优化等级来评估。
#include <atomic>
#include <cstdint>
#include <cstdio>
volatile uint32_t sink = 0;
void bench_plain() {
uint32_t cnt = 0;
for (uint32_t i = 0; i < 1000000u; ++i) {
++cnt;
–cnt;
sink += (cnt & 0x1u);
}
}
void bench_atomic() {
std::atomic<uint32_t> cnt{0};
for (uint32_t i = 0; i < 1000000u; ++i) {
cnt.fetch_add(1, std::memory_order_relaxed);
cnt.fetch_sub(1, std::memory_order_relaxed);
sink += cnt.load(std::memory_order_relaxed) & 0x1u;
}
}
在单线程基准中,bench_plain 的计数成本几乎为零,编译器甚至可以把中间过程优化掉;bench_atomic 即使使用 relaxed,通常也会比普通计数慢不少,因为在硬件层面,原子操作仍然会禁止某些优化并引入内存屏障。
不过,单线程基准无法反映多核缓存争用的真实情况。在多核设备上,如果多个线程同时对一个共享对象做高强度引用传递,计数器的缓存行竞争会迅速成为瓶颈。这时性能问题已经不是「加减法慢」,而是「共享单一内存位置导致的串行化」。
10.2 缓存行弹跳:多核场景下的隐藏杀手
现代 CPU 的缓存一致性基于缓存行,通常大小为 32 到 64 字节。当一个核心要修改一条缓存行中的任何数据时,它需要保证该缓存行在自己核心中处于独占或修改状态。如果另一个核心也要修改同一控制块,缓存一致性协议会强制把缓存行在两个核心之间来回传输,这就是「缓存行弹跳」。
在一个多核 SoC 上,如果所有工作任务共享同一个引用计数非常频繁的对象,那么无论计数是 std::atomic 还是普通整数,这个共享计数所在缓存行都会成为系统瓶颈。优化思路包括:
- 降低计数变化频率,减少不必要的复制临时 shared_ptr。
- 使用线程局部所有权再汇总,把频繁变化的路径分散到线程局部变量,周期性地合并到共享计数。
- 使用无锁或分片计数结构,不过在嵌入式小核上应评估其复杂度是否值得。
- 把引用计数与其他会被频繁修改的数据分开,避免一个计数器拖累整条缓存行。例如不要让计数器和数据对象同处一条缓存行且同时被高频率修改。
对单核 MCU 来说,上述多核问题未必存在,但中断竞争和原子操作关中断带来的实时影响仍然存在。嵌入式优化要具体问题具体分析,不能照搬服务器端的结论。
10.3 copy 比 move 更容易被忽视
引用计数策略天然为「共享」设计,但共享不是免费的。每发生一次 shared_ptr 拷贝,就会发生一次计数增加;每离开一个作用域,就会发生一次计数减少。与之相比,std::move 只需要转移指针,不需要改动计数,因此移动语义具有明显性能优势。
在一段返回 shared_ptr 的函数中,编译器通常会应用返回值优化。但如果代码写成按值传递 shared_ptr 且从不修改,就会产生大量无意义的计数增减:
void process(std::shared_ptr<Frame> frame) {
// 进入函数 +1, 离开函数 -1
}
std::shared_ptr<Frame> frame = get_frame();
process(frame); // 产生一次拷贝和计数增加
如果 process 并不需要延长对象生命周期,只读取内容,更高效的做法是把参数改为 const shared_ptr<Frame>& 或直接传 const Frame&。嵌入式开发中,这些细节在中断、回调和高频消息分支里累积起来非常可观。
因此,性能调优的实践中有一个朴素原则:能够用引用和移动的地方,就不要复制;只有当函数确实需要保存所有权时,才应该复制 shared_ptr。这个原则对桌面和嵌入式同样成立,只是在资源有限的设备上更能体现出收益。
10.4 大端小端、对齐与原子操作支持
嵌入式平台还有一类与桌面端不同的问题:硬件和工具链可能不完全支持标准原子库。某些老旧的 8 位或 16 位 MCU 编译器可能没有完整的 std::atomic 支持,或者库实现会选择「用禁用中断模拟原子操作」,其性能和代码体积都与新平台差别很大。
此外,当引用计数与数据对象被放进同一块合并内存时,对齐会发生变化。make_shared 的一次分配实现必须正确对齐被管理对象,即使控制块本身只需要很小的对齐。这对一些需要严格对齐的硬件缓冲、DMA 描述符等场景需要特别小心。
如果目标平台不支持标准原子操作,一种可接受的工程退路是:在系统级保证对象只被单任务访问,从而使用普通计数;或者使用 RTOS 提供的临界区接口包装计数,但必须保证临界区足够短。无论选用哪种方案,都应该通过代码评审和文档把并发假设写清楚,而不是把正确性寄托在「碰巧能用」上。
11. 嵌入式优化实践:内存池与对象复用
11.1 用对象池降低分配次数
引用计数的对象创建和销毁都要和堆分配器打交道。在嵌入式系统中,频繁的小块分配存在两个风险:
- 碎片化:长期运行后,堆中可能没有足够大的连续块,即使总空闲量足够,也会导致大块分配失败。
- 耗时不确定:通用分配器为了保证通用性,可能有多级链表查找、合并和元数据维护,耗时并不完全固定。
对象池是一种经典解法:预先从堆申请一块连续内存,把它切分成固定大小的槽位,分配和释放都只是从空闲链表里取和还。由于槽位大小固定,不会产生外部碎片;由于结构简单,分配和释放时间也可以非常稳定。
下面展示一个极简的固定大小对象池,它可以存储已经析构但尚未归还的对象槽,供后续复用:
#include <cstddef>
#include <cstdint>
#include <new>
template <typename T, size_t N>
class FixedObjectPool {
public:
FixedObjectPool() : free_list_(nullptr) {
for (size_t i = 0; i < N; ++i) {
void* slot = &storage_[i];
push_free(slot);
}
}
T* allocate() {
if (free_list_ == nullptr) {
return nullptr;
}
void* slot = free_list_;
free_list_ = free_list_->next;
return ::new (slot) T();
}
void deallocate(T* ptr) {
if (ptr == nullptr) {
return;
}
ptr->~T();
push_free(ptr);
}
private:
union Slot {
T object;
Slot* next;
};
void push_free(void* p) {
Slot* slot = static_cast<Slot*>(p);
slot->next = free_list_;
free_list_ = slot;
}
Slot storage_[N];
Slot* free_list_;
};
注意,这个实现使用了 placement new 存储对象,并假设 Slot 联合体的大小和对齐能满足 T 的要求。
要将对象池与引用计数结合,常用的做法是:对象类型继承侵入式引用计数基类,同时通过对象池创建对象;或者为 shared_ptr 提供自定义分配逻辑。标准库的 allocate_shared 允许传入自定义分配器,这为与内存池对接提供了正式接口。在嵌入式项目中,是否真的需要 allocate_shared 的完全泛化能力,还是自己写一个简单的池化 shared_ptr 包装,取决于项目的复杂度和可维护性要求。
11.2 对象池与引用计数生命周期如何衔接
对象池与引用计数的结合有一个关键点:「对象析构」和「对象内存归还」不再一定是同一个动作。在标准 delete 语义中,销毁对象意味着同时释放它占用的内存;在对象池中,我们可能希望拆除对象后,把槽位放回池中留给下一个对象复用。
如果使用侵入式引用计数,这个需求非常容易实现:
class PooledObject : public RefCounted {
public:
void release() const {
if (–ref_count_ == 0) {
on_last_release();
pool_.deallocate(this);
}
}
private:
static FixedObjectPool<PooledObject, 32> pool_;
};
但更通用的方案是利用 shared_ptr 的自定义删除器:
std::shared_ptr<SensorReading> create_from_pool() {
SensorReading* ptr = pool.allocate();
if (ptr == nullptr) {
return nullptr;
}
return std::shared_ptr<SensorReading>(ptr, [this](SensorReading* p) {
p->reset();
pool.deallocate(p);
});
}
这样,对象在最后一个引用释放时会执行删除器,完成状态清理和槽位归还。它比侵入式方案更灵活,标准 shared_ptr 的使用者不需要知道对象池的存在。
不过要注意,allocate_shared 虽然支持自定义分配器,但其分配单位是「对象加控制块」的合并内存,不一定适合我们的固定槽位池。正因为如此,在嵌入式对象池方案里,往往更倾向于「分别分配对象和控制块」的普通构造方式,而不是 make_shared 的合并分配。
11.3 预分配与一次性把池打满
对很多嵌入式系统来说,系统启动阶段允许做一次性分配,运行期则尽量避免动态操作。因此可以在初始化阶段把对象池一次性创建好,之后对象创建和销毁都在池内完成,底层堆不再参与。这种预分配策略具备很好的实时特性,特别适合连接对象、消息对象、传感器样本和事件对象等类型。
预分配池的设计还需要关注槽位数量。槽位太少,运行期可能出现 allocate() 返回空,需要业务侧做降级处理;槽位太多,则浪费静态 RAM。通常在需求分析时,应结合系统最坏情况下的对象并发数量来设定,并保留一定余量。例如,一个设备最多同时存在 8 条网络会话,每条会话引用最多 4 个缓冲区,那么内存池容量可以按 32 以上设计,同时在上游增加流控。
这种「池 + 引用计数 + 流控」的组合,在许多成熟嵌入式通信栈和媒体框架中很常见,值得认真借鉴。
12. 嵌入式优化实践:降低原子操作开销
12.1 只在真正共享的边界使用原子计数
回到第 9 章的核心问题:原子操作贵在争用和屏障,而不只是加减指令。因此,降低原子操作开销的第一原则是减少它的使用范围。
很多对象在创建之后,只在单线程内部被引用,直到某个阶段才跨越任务边界。比如一个消息对象可能在生产者任务中组装,组装期间只被该任务使用;组装完成后,通过队列发送给消费者任务,从此进入共享阶段。对于这种对象,理想的设计是:
- 生产者单线程阶段使用普通引用计数或者不计数。
- 跨越任务边界时,切换为原子计数,或者使用消息队列的移动语义转移所有权。
实现这种动态切换会比较复杂。更实际的简化是:在对象创建时就确定其并发访问级别,而不是运行时切换。如果消息对象创建后最终一定会跨任务,那么从一开始就接受原子计数成本;如果永远不会跨任务,就使用普通计数。这样既简单,又能把不必要的原子操作降到最低。
12.2 减少 shared_ptr 临时副本
热路径优化中,最常见的问题是函数签名和局部变量写了太多不必要的 shared_ptr 拷贝。比如:
void handle_packet(std::shared_ptr<Packet> pkt) {
uint32_t len = pkt->length;
crc_check(pkt->payload, len);
forward_to (pkt); // 真正需要保存所有权
}
这里的 handle_packet 在入口处先进行一次计数加,末尾又进行一次计数减。如果 forward_to 内部确实要保存消息,那么函数开头为了保持生命周期而增加计数是合理的;但如果可以确认调用方生命周期覆盖整个处理过程,参数完全可以改为 const Packet& 或 Packet*。
类似地,在循环中反复 lock() 一个 weak_ptr 也会产生额外 shared_ptr。对于一个固定循环,应该把 lock 结果保存在循环外:
std::shared_ptr<DataSource> source = weak_source.lock();
if (!source) {
return;
}
for (size_t i = 0; i < n; ++i) {
source->process(i);
// 不要在循环里反复 lock
}
这些优化不是算法层面的技巧,而是工程纪律。它们对内存紧张、CPU 有限的设备帮助明显。
12.3 分片计数和 hazard pointer 是否适合嵌入式
在面对多核高频共享时,研究者提出过很多高级方案,例如分片引用计数、线程本地计数合并、hazard pointer、epoch reclamation 等。这些方案能在一定程度上降低单一计数器的争用,但实现复杂度高,验证成本大,很多还依赖动态内存、线程注册和复杂的回收逻辑。
对嵌入式项目来说,这些方案通常不是首选。大多数嵌入式多核任务数量有限,引用传递频率与服务器集群相比非常低,按任务数量做分片或直接串行化保护往往已经足够。只有在明确测量出控制块争用已经成为瓶颈后,才值得投入精力。即便引入分片计数,也要尽量保持实现简单,例如给每个核心一个本地增加缓冲,定期合并到共享计数,而不追求完全通用。
一个更适合嵌入式的折中是:把共享计数与数据分开后,使用单写多读模型减少原子更新次数。如果某个引用计数只在任务上下文中增加,在中断上下文中减少,那么可以让中断做轻量操作,复杂工作放到任务中处理。具体策略需要根据并发结构逐一分析。
13. 引用计数与 RAII 资源管理
13.1 RAII 与引用计数不是竞争关系
RAII 是 C++ 中管理资源的基础思想:在构造函数中获取资源,在析构函数中释放资源。典型例子包括栈上锁守卫、文件句柄封装、串口对象等。对于独占资源,RAII 配合 unique_ptr 通常就是最优解。
引用计数并不取代 RAII,而是为「共享资源」提供 RAII 友好的包装。一个 shared_ptr 本身仍然是 RAII 对象:当它走出作用域时,析构函数自动执行;只不过它内部做的是减少计数,而不是直接释放资源。理解这一点,有助于我们设计更一致的资源管理模式。把资源视作 RAII 对象,再用引用计数包一层共享所有权,是嵌入式 C++ 非常自然的组合。
13.2 混合使用 unique_ptr 和 shared_ptr
一个良好的设计应该优先选择 unique_ptr,只有在所有权确实需要共享时才转为 shared_ptr。具体地说:
- 模块内部的对象,如果没有离开模块的必要,使用 unique_ptr 或普通成员变量。
- 要通过接口交给其他模块、且其他模块也要保存持有权的对象,使用 shared_ptr。
- 对象内部之间只需要避免循环引用或者暂时观察,使用 weak_ptr。
把 unique_ptr 转成 shared_ptr 是单向的、低成本的:
std::unique_ptr<Thermometer> sensor = std::make_unique<Thermometer>();
std::shared_ptr<Thermometer> shared = std::move(sensor);
这意味着你可以在模块内部先用独占所有权构建对象,当确认它需要被多方共享时,再安全地转换,而不要一开始就让所有对象都带着 shared_ptr。后者会使每个对象无论是否需要共享都付出控制块成本,还可能掩盖本该独占的运行约束。
13.3 嵌入式外设句柄的统一管理
以一个串口类为例,它需要持有 UART 外设寄存器地址、中断号、DMA 配置和收发缓冲区。传统的 C 风格通常把句柄保存在全局结构体里,由各模块手工传入。在 C++ 里,我们可以先定义一个独占的 Uart 对象管理寄存器与中断,再在其中放置引用计数的缓冲区对象,从而形成分层资源管理:
class Uart {
public:
explicit Uart(uintptr_t base);
~Uart();
void send(std::shared_ptr<Buffer> data);
std::shared_ptr<Buffer> receive();
private:
uintptr_t base_;
// 其他外设状态
};
缓冲区 Buffer 的管理又可以使用引用计数:发送 DMA 持有它、日志模块持有它、业务模块也持有它。无论哪一方先完成,最后一个释放者才真正把它回收。这样就把原本散落在多个 C 模块之间的“谁释放缓冲区”问题,变成了 shared_ptr 的自动行为。
这个模式可以推广到 I2C、SPI、CAN、以太网 MAC 等许多外设。关键是识别哪些资源是长生命周期的、哪些是短生命周期的,并为短生命周期资源选择合适的共享或独占管理方式。
14. 常见陷阱、调试与最佳实践
14.1 陷阱一:this 制造多个控制块
这个陷阱在第 7 章已经展开。简单复述其要点:一旦用同一个普通指针构造两个 shared_ptr,就会产生两个控制块,最终导致双重释放或悬空。务必要通过 shared_from_this() 来获取已有控制块的引用。
在嵌入式代码审查中,这个问题经常以「把 this 传给容器」「注册回调时保存 shared_ptr<T>(this)」的形式出现。最好的防御方式是在规范中明确禁止直接使用 shared_ptr<T>(this),并在代码扫描中排查。
14.2 陷阱二:循环引用造成悄悄泄漏
循环引用是最难被单元测试覆盖的一类问题。两个双向关联对象在各自的析构函数中可能都设置了日志,但只要环没有断开,析构函数根本不会执行。因此,不能把「打印了析构日志」当作对象被销毁的唯一证据,还需要观察计数、运行期内存水位和资源句柄数量。
在嵌入式系统中,泄漏表现为长时间运行后 RAM 逐渐减少、连接无法建立、消息无法分配。排查时可以通过 use_count() 辅助定位,但最终修复通常需要理清对象图,把一条强引用边改成弱引用。
14.3 陷阱三:析构函数中的重入与反向依赖
引用计数把析构时机推迟到「最后一个引用离开时」,但最后一个引用离开的场景可能很复杂。例如:
class Device {
public:
~Device() {
// 想在析构时通知管理器
manager_->unregister(shared_from_this());
}
private:
Manager* manager_;
};
如果 Device 通过 shared_from_this() 在析构函数中再增加一次强引用,等于在销毁路径上重新复活对象,可能造成严重问题。此处的 shared_from_this() 通常也是不正确的,因为对象已经处于析构阶段。
更普通的反向依赖问题是:Device 析构时调用 Manager,而 Manager 的析构又可能间接接触本来应该已经释放的引用。当对象图比较复杂时,应该避免让析构函数承担太多「反向通知」工作,可以改为外部先显式解除关联,再让引用自然归零。
14.4 陷阱四:线程销毁顺序
当多个任务都持有同一个 shared_ptr 时,最后一个释放对象的线程决定了析构函数运行在哪个上下文中。析构函数可能关闭硬件、等待中断、释放互斥量。如果这些操作依赖于特定任务上下文,而最后释放却发生在中断或另一个优先级更低的任务中,就会产生难以复现的问题。
因此,在跨任务共享对象时,应明确「谁来负责最终释放」的策略。有的设计会约定只有某个管理者任务拥有最后一个强引用;有的会使用消息投递把对象送到固定任务中释放。相比依赖偶然性,显式约束更可靠。
14.5 最佳实践清单
下面把前文中的工程经验整理成一份可操作的清单:
- 优先使用 unique_ptr,只在真正需要共享所有权时使用 shared_ptr。
- 创建共享对象优先使用 make_shared,除非内存延迟释放或自定义池有特殊要求。
- 绝不用同一个裸指针重复构造多个 shared_ptr。
- 循环引用中,从子对象回指父对象使用 weak_ptr。
- weak_ptr::lock() 成功后保持强引用,不在循环中反复锁定。
- 函数只读取对象时,优先传递引用或裸指针,不要随意拷贝 shared_ptr。
- 非内存资源也用 shared_ptr 的自定义删除器管理。
- 明确对象操作的线程边界,避免在同一对象内部无保护地共享数据。
- 对高频对象使用对象池,减少堆分配和碎片。
- 在关键代码中测量计数开销,不要过早优化,也不要完全忽视。
- 用文档和类型约定明确析构函数运行上下文。
15. 实战案例:嵌入式设备的共享数据缓冲区
15.1 需求分析
设想一个典型的嵌入式网关设备,它需要完成以下任务:
- 从 UART、CAN 或网络接口接收数据帧。
- 对数据帧做协议解析、校验、日志记录。
- 把结果发给多个业务模块,包括本地显示、云端上报和规则引擎。
- 当所有消费者都完成处理后,数据帧占用的内存需要及时回收。
如果使用裸指针,整个系统需要约定一套复杂的收发完成协议。现在用引用计数来设计:每收到一帧数据,就创建一个共享缓冲区对象;解析器、日志模块、上报模块和规则引擎各自持有一个 shared_ptr;当最后一个模块释放引用时,缓冲区自动归还到预分配池。
这个设计有几个关键点:
- 帧缓冲区从对象池分配,避免接收热路径反复调用系统堆。
- 引用计数控制共享所有权,跨模块生命周期自动管理。
- 池和计数分离,业务对象析构后槽位返回池中,复用给下一帧。
15.2 缓冲区与对象池设计
下面给出一个适合固定 MTU 场景的帧缓冲区骨架。它内部带有状态重置函数,并通过自定义删除器接入对象池。
#include <cstddef>
#include <cstdint>
#include <cstring>
struct FrameBuffer {
static constexpr size_t kCapacity = 2048;
uint8_t data_[kCapacity];
size_t length_ = 0;
uint32_t sequence_ = 0;
void reset() {
length_ = 0;
sequence_ = 0;
// 安全起见可清空数据
std::memset(data_, 0, sizeof(data_));
}
};
接着可以基于第 11 章的 FixedObjectPool 构造 FrameBufferPool:
#include <memory>
class FrameBufferPool {
public:
std::shared_ptr<FrameBuffer> acquire() {
FrameBuffer* buf = pool_.allocate();
if (buf == nullptr) {
return nullptr;
}
// 把槽位归还逻辑封装进删除器
return std::shared_ptr<FrameBuffer>(buf, [this](FrameBuffer* p) {
p->reset();
pool_.deallocate(p);
});
}
size_t available() const {
// 根据空闲链表实现自行补充
return 0;
}
private:
FixedObjectPool<FrameBuffer, 64> pool_;
};
这样,接收端获得帧缓冲后,只需要像使用普通共享对象一样传递 shared_ptr<FrameBuffer>:
std::shared_ptr<FrameBuffer> frame = frame_pool.acquire();
if (!frame) {
handle_buffer_exhausted();
return;
}
parser.submit(frame);
logger.submit(frame);
uploader.submit(frame);
// frame 本身离开作用域,但其他模块仍持有引用
注意,这里用 shared_ptr 包装池化对象时,没有采用 make_shared,因为对象和控制块会分别存在,删除器只负责归还对象槽位,控制块则由 shared_ptr 正常管理。这个细节在池化场景中非常重要。
15.3 在上报模块中使用 weak_ptr
云端上报模块可能不想强持有某一帧数据,避免网络拥塞时内存被大量日志帧占满。此时可以使用 weak_ptr:
class Uploader {
public:
void submit_for_log(std::shared_ptr<FrameBuffer> frame) {
last_frame_ = frame; // weak_ptr
}
void try_upload_last_log() {
std::shared_ptr<FrameBuffer> frame = last_frame_.lock();
if (!frame) {
return; // 帧已不在,跳过
}
send_to_cloud(frame->data_, frame->length_);
}
private:
std::weak_ptr<FrameBuffer> last_frame_;
};
这种设计让日志模块「只观察最近一帧」而不阻止帧回收。它不会造成循环引用,这点也值得单独指出:这里 weak_ptr 并不是为了打破循环,而是为了表达较弱的生命周期关系。
15.4 单核与多核的不同选择
如果目标是一个单核 MCU,所有数据处理都在同一个循环中完成,那么 shared_ptr 控制块中的原子操作仍然存在,但不会出现多核争用。我们可以考虑自定义一个仅在本任务循环中使用的池化指针,直接复用池和引用计数的思路,避免标准库原子开销。
如果目标是带多核的多媒体 SoC 或异构处理器,那么标准 shared_ptr 的原子控制块可以简化实现,同时需要重点关注缓冲区在多个核之间的高效传递。某些高性能场景甚至选择「对象在某个核上创建,最终在该核上释放」,以保持内存池亲和性与缓存友好。
无论硬件特征如何,实战中通常先按标准 shared_ptr 完成功能,再对热路径进行剖析。很多优化中的细节,只有在真实设备上结合 DMA、中断和任务切换后才会暴露,过早地以理论代价做出取舍反而可能引入更多维护成本。
16. 结语
引用计数是嵌入式 C++ 中把对象生命周期交给运行时状态管理的重要手段。它不会像魔术一样消除资源管理问题,却能把原本散落在各个调用者之间的口头约定显式化、自动化。对于资源受限、强调确定性的嵌入式系统,引用计数相比追踪式 GC 更贴近需求;相比完全手工管理,它提供了更高的工程安全性和可维护性。
本文从基础模型讲到侵入式和非侵入式实现,再从 shared_ptr 内部机制讲到弱引用、原子操作和性能优化。如果把这些内容浓缩成几个最关键的工程结论,应该是:
- 理解所有权,而不是迷信智能指针。引用计数解决的是所有权共享问题;数据竞争、资源算法、模块依赖仍需结合并发设计处理。
- 能独占就独占,能引用就引用。不要把所有对象都塞进 shared_ptr。独占所有权更便宜、更清晰,共享所有权只在真正需要时使用。
- 关心控制块的生命周期,尤其是对象池和 make_shared 的差异。嵌入式内存紧张时,控制块何时释放、对象槽位何时归还,是决定内存水位的关键。
- 性能优化从减少不必要的引用增减开始。移动优于拷贝、引用优于存储、循环外的 lock 优于循环内。
- 让循环引用成为设计评审的固定项。只要涉及双向关联,就要明确哪条边是弱关系。
在真实项目中,引用计数会与对象池、任务模型、外设句柄管理和协议设计深度耦合。它不是一个孤立的语言特性,而是一套面向资源生命周期的系统化思路。当你开始把一个设备上复杂的共享关系画成对象图,并为每条边选择强引用、弱引用或独占所有权时,嵌入式 C++ 的路会因此清晰许多。
希望这篇两万字左右的详解,能帮助你在嵌入式 C++ 开发中更自信地使用引用计数,同时也能在团队设计阶段提出更准确的权衡判断。最终目标不是把代码写得像教科书,而是让设备在长期运行中始终保持可控的内存、可预期的行为和可维护的结构。
网硕互联帮助中心







评论前必须登录!
注册