Momenta C++ 智驾 二面 面经
1. 自我介绍,介绍一下你觉得最有挑战性的项目
2. 你提到用过多任务架构,任务数量多了之后内存压力怎么处理的?静态分配和动态分配你怎么选择?
答:任务多了之后内存压力主要来自两块:每个任务的栈空间,以及任务间通信用的队列和信号量。
栈空间的处理:
- 先用
uxTaskGetStackHighWaterMark跑一段时间,看每个任务实际用了多少栈,按实际用量加一定余量分配,不要每个任务都给一个很大的默认值 - 对于简单任务(只做状态机跳转、没有深层函数调用)可以给很小的栈,比如 128 字
- 对于有 printf、sprintf 或者复杂字符串处理的任务,栈需要大一些
静态分配 vs 动态分配:
- 嵌入式项目里我倾向于全部用静态分配,
xTaskCreateStatic、xSemaphoreCreateMutexStatic这些接口 - 原因是动态分配在运行时可能失败,而且堆碎片化问题在长时间运行的设备上会越来越严重
- 静态分配在编译期就能知道内存布局,链接器会报错如果超出 RAM 限制,比运行时崩溃好排查得多
- 动态分配适合原型阶段快速开发,量产前最好改成静态
3. 讲一下 C++ 的内存模型,std::atomic 的几种内存序分别是什么含义?
答:C++ 内存模型定义了多线程程序中内存操作的可见性和顺序规则。
六种内存序:
memory_order_relaxed:
- 只保证原子性,不保证顺序
- 不阻止编译器和 CPU 重排
- 适合只需要原子性的计数器,比如统计次数,不关心和其他操作的顺序关系
memory_order_acquire:
- 用于读操作,保证该操作之后的所有读写不会被重排到该操作之前
- 配合 release 使用,保证看到 release 写入的值之后,也能看到 release 之前的所有写入
memory_order_release:
- 用于写操作,保证该操作之前的所有读写不会被重排到该操作之后
- 典型用法:写完数据后 release 写一个标志,另一个线程 acquire 读到标志后能看到完整数据
memory_order_acq_rel:
- 同时具有 acquire 和 release 语义,用于读改写操作(fetch_add 等)
memory_order_seq_cst:
- 最强,所有线程看到的操作顺序完全一致
- 默认值,性能开销最大,在 ARM 等弱内存序架构上会插入内存屏障指令
memory_order_consume:
- 比 acquire 弱,只保证依赖链上的顺序,实践中编译器通常把它当 acquire 处理,基本不用
实际使用:
std::atomic<bool> ready{false};
std::string data;
// 生产者
data = "hello";
ready.store(true, std::memory_order_release); // 保证 data 写入在 ready 之前
// 消费者
while (!ready.load(std::memory_order_acquire)); // 看到 true 后能看到完整的 data
std::cout << data;
4. 手写题:实现一个无锁的单生产者单消费者队列
答:
#include <atomic>
#include <vector>
#include <optional>
template<typename T>
class SPSCQueue {
public:
explicit SPSCQueue(size_t capacity)
: capacity_(capacity + 1), // 多一个槽位区分满和空
buffer_(capacity + 1),
head_(0),
tail_(0) {}
// 生产者调用,队列满返回 false
bool push(T value) {
size_t tail = tail_.load(std::memory_order_relaxed);
size_t next = (tail + 1) % capacity_;
// 队列满:下一个位置是 head
if (next == head_.load(std::memory_order_acquire)) {
return false;
}
buffer_[tail] = std::move(value);
tail_.store(next, std::memory_order_release);
return true;
}
// 消费者调用,队列空返回空
std::optional<T> pop() {
size_t head = head_.load(std::memory_order_relaxed);
// 队列空:head == tail
if (head == tail_.load(std::memory_order_acquire)) {
return std::nullopt;
}
T value = std::move(buffer_[head]);
head_.store((head + 1) % capacity_, std::memory_order_release);
return value;
}
bool empty() const {
return head_.load(std::memory_order_acquire) ==
tail_.load(std::memory_order_acquire);
}
private:
const size_t capacity_;
std::vector<T> buffer_;
// 两个指针分别由生产者和消费者独占写,避免伪共享
alignas(64) std::atomic<size_t> head_; // 消费者写
alignas(64) std::atomic<size_t> tail_; // 生产者写
};
关键点:
head_只有消费者写,tail_只有生产者写,不需要锁alignas(64)让两个原子变量在不同缓存行,避免伪共享(false sharing)导致性能下降- push 里读 head 用 acquire,写 tail 用 release;pop 里读 tail 用 acquire,写 head 用 release
- 容量加一是为了用空槽位区分队列满和队列空的状态
5. 虚函数在性能敏感场景下有什么替代方案?
答:虚函数的开销:间接寻址(通过 vptr 找 vtable 再找函数)、无法内联、可能导致 icache miss。
替代方案:
CRTP(奇异递归模板模式):
template<typename Derived>
class Base {
public:
void process() {
static_cast<Derived*>(this)->processImpl(); // 编译期确定,可内联
}
};
class Derived : public Base<Derived> {
public:
void processImpl() { /* 具体实现 */ }
};
- 编译期多态,零运行时开销,可以内联
- 缺点:不能把不同类型放在同一个容器里,失去运行时多态能力
std::variant + std::visit:
using Shape = std::variant<Circle, Rectangle, Triangle>;
std::vector<Shape> shapes;
for (auto& s : shapes) {
std::visit([](auto& shape) { shape.draw(); }, s);
}
- 编译期生成跳转表,比虚函数快
- 类型集合固定,不能运行时扩展
函数指针 / std::function:
- 函数指针开销和虚函数类似,但没有 vtable 查找
std::function有额外的类型擦除开销,比虚函数还慢,不推荐热路径使用
实际选择:
- 自动驾驶感知模块的热路径(每帧处理)用 CRTP 或直接模板
- 插件系统、策略模式等需要
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
本专栏系统梳理C++技术面试核心考点,涵盖语言基础、面向对象、内存管理、STL容器、模板编程及经典算法。从引用指针、虚函数表、智能指针等底层原理,到继承多态、运算符重载等OOP特性从const、static、inline等关键字辨析,到动态规划、KMP算法、并查集等手写实现。每个知识点以面试答题形式呈现,注重原理阐述而非冗长代码,帮助你快速构建完整知识体系,从容应对面试官提问,顺利拿下offer。
