影石 | C++开发工程师 二面题目
1. 介绍一下你做过的最有技术含量的项目,重点说架构设计和你的核心贡献。
答:二面开场必问,考察的是系统思维和技术主导能力,不是简历复读。
回答结构:项目背景(一句话说清楚是什么、规模、你的角色)→ 核心技术挑战(为什么难,不是"功能多"这种废话)→ 你的架构决策(为什么这么设计,有没有考虑过其他方案,为什么否定)→ 结果和反思(数据支撑,以及如果重来会怎么改)。
面试官会顺着你说的细节追问,所以只说你真正主导过的部分,每个技术决策背后要有理由,不能说"当时就这么做了"。
2. 你在项目中如何处理多个硬件设备并发上报数据时的竞态问题?用了什么同步策略,为什么这么选?
答:这道题考察实际工程中的并发设计,不是背八股。
常见的竞态场景:多个设备线程同时往同一个状态表写数据,同时有读线程在查询状态,读写不加保护会导致数据撕裂(读到一半更新的数据)。
同步策略的选择逻辑:如果读多写少,用读写锁(std::shared_mutex),读操作并发执行,写操作独占,比普通mutex吞吐量高;如果数据是单个原子类型(int、bool、指针),直接用std::atomic,完全无锁,性能最好;如果是复杂结构体,考虑copy-on-write,写时复制一份修改,原子替换指针,读操作不需要加锁;如果设备数量固定,可以给每个设备分配独立的状态槽,完全消除竞争。
实际项目里要避免的坑:锁的粒度太粗(整个状态表一把锁,所有设备串行化);锁持有时间太长(锁内做了IO或复杂计算);加锁顺序不一致导致死锁。
3. 请解释C++中的对象生命周期管理,weak_ptr是如何解决shared_ptr循环引用问题的?它的lock()操作是线程安全的吗?
答:shared_ptr通过引用计数管理对象生命周期,计数归零时析构对象。循环引用时A持有B的shared_ptr,B持有A的shared_ptr,两者引用计数永远不为0,内存永久泄漏。
weak_ptr是对shared_ptr管理的对象的弱引用,不增加引用计数,不影响对象的生命周期。需要访问对象时调用lock(),如果对象还存在返回一个有效的shared_ptr,如果已经析构返回空的shared_ptr。这样打破了循环,持有weak_ptr的一方不阻止对象析构。
lock()的线程安全性:lock()操作本身是线程安全的,内部用原子操作检查引用计数并尝试增加,不会有竞态。但lock()返回的shared_ptr是局部的,多个线程各自lock()得到各自的shared_ptr副本,互不干扰。
需要注意的是:weak_ptr不能直接访问对象,必须先lock(),这是强制的,防止在检查和使用之间对象被析构(TOCTOU问题)。如果确定对象一定存在(比如观察者模式里观察者比被观察者生命周期短),可以用原始指针,但要确保生命周期管理正确。
4. Linux进程间通信中,你在项目里用过信号(signal)吗?信号处理函数有哪些限制,为什么?
答:信号是异步通知机制,内核在任意时刻打断进程执行信号处理函数,这个"任意时刻"是它最大的危险所在。
信号处理函数的限制:只能调用异步信号安全(async-signal-safe)的函数,POSIX定义了一个白名单,常用的有write、_exit、sem_post等。不能调用malloc/free(内部有锁,信号打断时可能持有锁,再次调用会死锁)、printf(同样有锁)、C++的new/delete、STL容器操作等。
正确的信号处理模式:信号处理函数只做最少的事,通常是设置一个volatile sig_atomic_t标志位,主循环检查这个标志再做实际处理;或者用self-pipe trick,信号处理函数往管道写一个字节,主循环用select/epoll监听管道,收到数据再处理,这样把信号转化成了IO事件,可以在正常代码路径里处理;Linux特有的signalfd也是类似思路,把信号变成可poll的fd。
实际项目里信号常用于:SIGTERM/SIGINT优雅退出(收到信号后设置退出标志,主循环完成当前任务后退出);SIGCHLD处理子进程退出;SIGUSR1/SIGUSR2做运行时配置热重载。
5. 请解释C++模板的两阶段编译(two-phase lookup)是什么,它会导致哪些让人困惑的编译错误?
答:模板编译分两个阶段:第一阶段在模板定义时,编译器检查不依赖模板参数的语法和名称;第二阶段在模板实例化时,编译器检查依赖模板参数的名称。
这导致一个反直觉的行为:模板基类的成员在派生类模板里不能直接访问。比如派生类模板继承自模板基类,在派生类里直接用基类的成员函数名,第一阶段编译器不知道T是什么,不会去基类里查找,直接报"未声明的标识符"。解决方法是用this->前缀或Base<T>::显式指定,告诉编译器这是依赖名称,推迟到第二阶段查找。
另一个常见困惑:模板函数的定义必须在头文件里(或者显式实例化),因为实例化发生在调用点,编译器需要看到完整定义。把模板定义放在.cpp文件里,链接时会报"未定义的符号",初学者经常踩这个坑。
还有dependent name的typename和template关键字:在模板里访问依赖类型的嵌套类型,必须加typename前缀(typename T::iterator);调用依赖类型的模板成员函数,必须加template关键字(obj.template func<int>()),否则编译器会把<解析成小于号。
6. 你在嵌入式项目中如何做单元测试?在没有真实硬件的情况下,如何测试依赖硬件的代码?
答:嵌入式单元测试的核心挑战是硬件依赖,解决方案是依赖注入和Mock。
架构层面:业务逻辑不直接调用硬件接口,而是依赖抽象接口(纯虚类或函数指针表),硬件实现和Mock实现都实现这个接口。测试时注入Mock,生产时注入真实硬件实现。这要求代码设计时就考虑可测试性,不能把硬件调用写死在业务逻辑里。
Mock的实现:用Google Mock可以快速生成Mock类,定义期望调用次数、参数、返回值;也可以手写简单的Stub,返回预设的测试数据。比如测试MQTT消息处理逻辑,Mock掉网络层,直接注入构造好的消息,验证业务逻辑的处理结果。
测试框架选择:Google Test是C++项目的主流选择,支持参数化测试、测试夹具、死亡测试;嵌入式资源受限时可以用Unity(纯C,体积极小)或CppUTest。
CI集成:在没有硬件的x86机器上跑所有
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
本专栏系统梳理C++技术面试核心考点,涵盖语言基础、面向对象、内存管理、STL容器、模板编程及经典算法。从引用指针、虚函数表、智能指针等底层原理,到继承多态、运算符重载等OOP特性从const、static、inline等关键字辨析,到动态规划、KMP算法、并查集等手写实现。每个知识点以面试答题形式呈现,注重原理阐述而非冗长代码,帮助你快速构建完整知识体系,从容应对面试官提问,顺利拿下offer。
