下面按「C++ 里最常写/最常被问到」的实现来讲:缓冲几种形态 → 软件 Cache → 内存池。硬件 CPU Cache 不手写,文末单独说清边界。
一、缓冲几种形态:怎么存、怎么调
1. 单缓冲(Single Buffer)
一块数组,写满再读(或读完再写)。最简单。
cpp
#include <array>
#include <cstddef>
template <typename T, std::size_t N>
class SingleBuffer {
std::array<T, N> data_{};
std::size_t size_ = 0;
public:
bool write(const T* src, std::size_t n) {
if (n > N) return false;
for (std::size_t i = 0; i < n; ++i) data_[i] = src[i];
size_ = n;
return true;
}
// 处理完再允许下一次 write
const T* data() const { return data_.data(); }
std::size_t size() const { return size_; }
void clear() { size_ = 0; }
};
调法: write → 业务读 data()/size() → clear()。
短板: 收和处理不能真正并行。
2. 双缓冲 / Ping-Pong(DMA、音频最常用)
两块区:一块给生产者写,一块给消费者读,处理完交换角色。
cpp
#include <array>
#include <atomic>
#include <cstddef>
template <typename T, std::size_t N>
class PingPongBuffer {
std::array<T, N> buf_[2]{};
std::atomic<int> ready_{-1}; // 哪一块可读;-1 表示暂无
int write_idx_ = 0;
public:
T* write_buf() { return buf_[write_idx_].data(); }
std::size_t capacity() const { return N; }
// 生产者写完调用(ISR/DMA 回调里)
void publish() {
ready_.store(write_idx_, std::memory_order_release);
write_idx_ ^= 1;
}
// 消费者:拿到可读块;nullptr 表示还没新数据
T* try_read() {
int idx = ready_.exchange(-1, std::memory_order_acquire);
return idx < 0 ? nullptr : buf_[idx].data();
}
};
调法:
cpp
PingPongBuffer<uint8_t, 512> pp;
// DMA/ISR
auto* w = pp.write_buf();
// ... 填满 w ...
pp.publish();
// 任务
if (auto* r = pp.try_read()) {
process(r, 512);
}
这就是「双缓冲」;环形往往是它的流式升级版。
3. 环形缓冲(Ring Buffer)------C++ 最常手写的那种
单生产者单消费者(SPSC)最常见,嵌入式面试几乎必问。
cpp
#include <array>
#include <atomic>
#include <cstddef>
#include <optional>
template <typename T, std::size_t N> // N 建议 2 的幂,取模变位运算
class RingBuffer {
static_assert((N & (N - 1)) == 0, "N must be power of 2");
std::array<T, N> buf_{};
std::atomic<std::size_t> head_{0}; // 写
std::atomic<std::size_t> tail_{0}; // 读
static std::size_t mask(std::size_t i) { return i & (N - 1); }
public:
bool push(const T& v) {
auto h = head_.load(std::memory_order_relaxed);
auto next = h + 1;
if (mask(next) == mask(tail_.load(std::memory_order_acquire)))
return false; // 满(牺牲一格:最多存 N-1)
buf_[mask(h)] = v;
head_.store(next, std::memory_order_release);
return true;
}
std::optional<T> pop() {
auto t = tail_.load(std::memory_order_relaxed);
if (mask(t) == mask(head_.load(std::memory_order_acquire)))
return std::nullopt; // 空
T v = buf_[mask(t)];
tail_.store(t + 1, std::memory_order_release);
return v;
}
std::size_t size() const {
return mask(head_.load(std::memory_order_acquire) -
tail_.load(std::memory_order_acquire));
}
};
调法:
cpp
RingBuffer<uint8_t, 256> rx;
// UART ISR
rx.push(UART->DR);
// 任务
while (auto b = rx.pop()) {
parse(*b);
}
要点:
- 满/空用「牺牲一格」或另加
count/full标志二选一。 - SPSC +
atomic头尾指针:C++ 里最常用的无锁写法。 - 多生产者/多消费者要加锁,或换更重的队列。
字节流版常再包一层 write(const uint8_t*, n) / read(uint8_t*, n),逻辑一样,只是一次搬多字节。
4. 队列(Queue)------库里最常用
手写环形偏「字节/元素管道」;跨任务传消息,C++ 日常更常用标准库或现成组件。
通用 C++(有锁,最常见):
cpp
#include <queue>
#include <mutex>
#include <condition_variable>
#include <optional>
template <typename T>
class BlockingQueue {
std::queue<T> q_;
std::mutex m_;
std::condition_variable cv_;
public:
void push(T v) {
{
std::lock_guard lock(m_);
q_.push(std::move(v));
}
cv_.notify_one();
}
T pop() {
std::unique_lock lock(m_);
cv_.wait(lock, [&] { return !q_.empty(); });
T v = std::move(q_.front());
q_.pop();
return v;
}
std::optional<T> try_pop() {
std::lock_guard lock(m_);
if (q_.empty()) return std::nullopt;
T v = std::move(q_.front());
q_.pop();
return v;
}
};
底层: std::queue 默认用 std::deque;有界队列也可自己用 std::vector + 环形下标实现。
嵌入式 RTOS: 更常见是 FreeRTOS xQueueSend/Receive,概念对应,不是你在 C++ 里再写一套。
对照记:
| 形态 | 更偏 | C++ 常怎么写 |
|---|---|---|
| Ring | 数据结构、流式、常无锁 SPSC | 手写 RingBuffer |
| Queue | 同步语义(阻塞/通知) | BlockingQueue / FreeRTOS Queue |
5. 流缓冲 / 消息缓冲(概念对应)
- 流缓冲: 按字节进出,不保证一次 push 对应一次 pop → 就是带
read/write(n)的环形。 - 消息缓冲: 一次进一整帧/一条消息,出也是整条 → 更像
queue<Msg>或「长度前缀 + 环形字节区」。
变长消息 + 环形的常见存法:
text
[len:2][payload...][len:2][payload...] ...
push 时先看空闲是否够 2+len;pop 时先读 len 再读 payload。
二、Cache:代码里怎么「实现 / 存 / 调」
先分清两种,面试别混。
A. 硬件 CPU Cache ------ 不手写,只配合
你不会在 C++ 里实现 L1/L2。你只做:
- 正常读写变量(CPU 自动缓存)
- DMA 场景做一致性(你资料 P0-6)
- 需要时把缓冲标成 non-cacheable / 对齐到 cache line
伪代码(Cortex-M7 思路,具体 API 看 CMSIS/HAL):
cpp
alignas(32) uint8_t dma_rx[1024]; // 常按 cache line 对齐
// 发送前:把 Cache 里的脏数据刷到内存
SCB_CleanDCache_by_Addr(dma_tx, sizeof(dma_tx));
// 接收后:丢掉 Cache 旧副本,强制从内存再读
SCB_InvalidateDCache_by_Addr(dma_rx, sizeof(dma_rx));
存: 就是普通数组/结构体,在可缓存的 RAM 里。
调: 业务代码照常访问;和 DMA 交界处 clean/invalidate。
这不是「实现 cache」,是「正确使用带 cache 的 CPU」。
B. 软件 Cache(LRU)------C++ 里最常手写的那种
目的:用内存换时间,加速「相同 key 的重复查询」。
最常用实现:unordered_map + list(哈希找节点,链表维顺序)。
cpp
#include <unordered_map>
#include <list>
#include <optional>
#include <utility>
template <typename K, typename V>
class LruCache {
std::size_t cap_;
// list: 最前=最新,最后=最旧
std::list<std::pair<K, V>> order_;
std::unordered_map<K, typename std::list<std::pair<K, V>>::iterator> idx_;
public:
explicit LruCache(std::size_t capacity) : cap_(capacity) {}
std::optional<V> get(const K& key) {
auto it = idx_.find(key);
if (it == idx_.end()) return std::nullopt;
order_.splice(order_.begin(), order_, it->second); // 挪到最前
return it->second->second;
}
void put(const K& key, V value) {
auto it = idx_.find(key);
if (it != idx_.end()) {
it->second->second = std::move(value);
order_.splice(order_.begin(), order_, it->second);
return;
}
if (order_.size() >= cap_) {
idx_.erase(order_.back().first);
order_.pop_back();
}
order_.emplace_front(key, std::move(value));
idx_[key] = order_.begin();
}
};
调法:
cpp
LruCache<int, std::string> cache(128);
if (auto v = cache.get(id)) {
use(*v);
} else {
auto v = load_from_flash_or_net(id); // 慢路径
cache.put(id, v);
use(v);
}
和 Buffer 的差别在调用语义上:
- Buffer:
push/pop,数据一般用过就消费掉 - Cache:
get/put,数据可反复命中;满了按策略淘汰(LRU),丢了还能再算/再读
嵌入式 MCU 上软件 LRU 相对少见(RAM 紧);更常见于 Linux 应用、协议解析结果、配置表。MCU 岗被问到 cache,多数仍是 DMA + D-Cache 一致性。
三、内存池:怎么存、怎么调(C++ 最常用)
方案 1:固定对象池 + 空闲链表(嵌入式/面试最常手写)
存: 一大块静态存储 + 每个槽位要么是对象,要么串在 freelist 上。
cpp
#include <cstddef>
#include <new>
#include <bitset>
template <typename T, std::size_t N>
class ObjectPool {
alignas(T) unsigned char storage_[N * sizeof(T)];
T* free_list_ = nullptr;
std::bitset<N> used_{};
T* slot(std::size_t i) {
return reinterpret_cast<T*>(storage_ + i * sizeof(T));
}
public:
ObjectPool() {
// 启动时把所有槽串成 freelist
for (std::size_t i = 0; i < N; ++i) {
auto* p = slot(i);
*reinterpret_cast<T**>(p) = free_list_;
free_list_ = p;
}
}
template <typename... Args>
T* alloc(Args&&... args) {
if (!free_list_) return nullptr;
T* p = free_list_;
free_list_ = *reinterpret_cast<T**>(p);
std::size_t i = static_cast<std::size_t>(
(reinterpret_cast<unsigned char*>(p) - storage_) / sizeof(T));
used_.set(i);
return new (p) T(std::forward<Args>(args)...); // 定位 new
}
void free(T* p) {
if (!p) return;
p->~T();
std::size_t i = static_cast<std::size_t>(
(reinterpret_cast<unsigned char*>(p) - storage_) / sizeof(T));
used_.reset(i);
*reinterpret_cast<T**>(p) = free_list_;
free_list_ = p;
}
};
调法:
cpp
struct Packet { int id; uint8_t data[64]; };
ObjectPool<Packet, 32> pool;
Packet* pkt = pool.alloc();
if (!pkt) { /* 池耗尽 */ }
pkt->id = 1;
// 传给队列:传指针,不拷贝大块
queue.push(pkt);
// 消费完
Packet* p = queue.pop();
process(p);
pool.free(p);
为什么常用: O(1)、无碎片、时间确定;适合包、消息、传感器块。
方案 2:C++17 std::pmr 池(现代 C++ / Linux 应用最「标准」)
存: 上游给一块 buffer,或让 pool_resource 自己向上游要内存;下游用 polymorphic_allocator。
cpp
#include <memory_resource>
#include <vector>
#include <string>
void demo() {
std::byte buf[4096];
std::pmr::monotonic_buffer_resource arena(buf, sizeof(buf));
// 只进不出的竞技场:适合「一阶段算完整体丢弃」
std::pmr::unsynchronized_pool_resource pool(&arena);
// 按尺寸分桶复用;单线程用 unsynchronized_*
std::pmr::vector<std::pmr::string> msgs(&pool);
msgs.emplace_back("hello");
msgs.emplace_back("world");
// 用完:arena 析构或 release,整块回收
}
调法: 容器/字符串构造时传入 &pool;少直接 new/delete。
MCU 裸机: 有时没有完整 pmr 或成本偏高,面试仍常答「手写 ObjectPool / arena」。
方案 3:Bump / Arena(只分配不单个释放)
cpp
class Arena {
char* base_;
char* cur_;
char* end_;
public:
Arena(char* b, std::size_t n) : base_(b), cur_(b), end_(b + n) {}
void* alloc(std::size_t n, std::size_t align = alignof(std::max_align_t)) {
auto addr = reinterpret_cast<std::uintptr_t>(cur_);
auto aligned = (addr + align - 1) & ~(align - 1);
char* p = reinterpret_cast<char*>(aligned);
if (p + n > end_) return nullptr;
cur_ = p + n;
return p;
}
void reset() { cur_ = base_; } // 整帧/整请求结束一次性清
};
适合:解析一帧临时对象、一局游戏临时数据------和「对象池反复借还」不同。
四、对照表(怎么选、怎么调)
| 东西 | 怎么存 | 怎么调 | C++ 最常见写法 |
|---|---|---|---|
| 单缓冲 | 一块 array |
write → process → clear | 静态数组 |
| 双缓冲 | buf[2] |
write_buf / publish / try_read | Ping-pong 类 |
| 环形 | array + head/tail |
push / pop | 手写 RingBuffer |
| 队列 | queue/deque + 锁 |
push / pop(可阻塞) | BlockingQueue |
| 硬件 Cache | 普通内存(CPU 管) | 正常访问 + DMA 时 clean/invalidate | CMSIS/HAL |
| 软件 Cache | map + list | get / put | LRU |
| 对象池 | 静态槽位 + freelist | alloc / free | ObjectPool |
| 标准池 | pmr resource | 分配器传给容器 | std::pmr::* |
五、一条「组合拳」(嵌入式最常落地)
text
ObjectPool<Packet> ------ 管「块」的借还
RingBuffer / Queue<Packet*> ------ 管「谁先谁后、ISR→任务」
业务任务 pop 指针 → 用完 pool.free
有 D-Cache 时:DMA 用的那块 buffer 再单独做对齐 + clean/invalidate;池和环形解决调度与归属,Cache 维护解决 CPU/DMA 看见的数据是否同一份。
如果你要往面试深度再抠,下一步最有用的是二选一:
(1)环形满空判定 + 无锁内存序为什么这样写 ,或 (2)对象池 freelist 在 ISR 里能不能用、要加什么保护。你想先啃哪块?