C++/Qt 视觉算法岗位面试复盘

本次面试未录用。题目集中在 C++、Qt 多线程、PLC 高频采集、数据一致性、数据库、CI/CD 和开源协议。它们并不是孤立知识点,核心考察的是能否设计一条可靠的数据链路:

1
2
3
设备采集 -> 请求调度 -> 数据处理 -> 数据存储 -> UI展示
|
+-> 实时控制

回答这类问题时,先说结论,再说明线程边界、数据结构、失败策略和验证指标。

一、C++17 有什么新特性,用过什么

适合面试的回答

我常用的 C++17 特性包括:

  • 结构化绑定:拆分 pairtuple 或键值结果。
  • if constexpr:在模板中进行编译期分支。
  • std::optional:表示可能不存在的解析结果或配置项。
  • std::variant:表示多种可能的数据类型。
  • std::string_view:只读字符串视图,减少复制。
  • std::filesystem:处理配置、日志和归档文件路径。
  • std::scoped_lockstd::shared_mutex:进行 RAII 加锁和读写锁保护。
  • 折叠表达式、std::apply:用于通用模板处理。

在设备协议解析中,可以用 std::optional 表示解析失败,用结构化绑定读取结果,用 string_view 避免不必要的字符串拷贝;配置和日志目录可以使用 std::filesystem

只回答真正使用过的特性,不要把 C++20 的 std::jthread、协程说成 C++17 特性。

二、多线程数据冲突怎么办

数据竞争的条件是:多个线程访问同一块内存,至少一个线程写入,并且没有同步机制。

处理顺序:

  1. 优先采用线程所有权,数据只由一个线程修改,线程之间通过信号槽传递快照。
  2. 简单计数器、标志位使用 std::atomic
  3. 复合数据和容器使用 QMutexQReadWriteLock 或 C++ 标准库锁。
  4. 多生产者、多消费者场景使用有界线程安全队列。
  5. 使用 RAII 锁,例如 QMutexLockerstd::lock_guardstd::scoped_lock

不要把“所有地方都加锁”当作线程安全设计。锁的范围应尽量小,持锁时不能执行网络、数据库或长时间计算。

三、Qt 中多线程启动步骤

推荐使用 QObject + QThread,而不是把业务逻辑全部写进 QThread::run()

1
2
3
4
5
6
7
8
9
10
创建无父对象的 Worker
|
v
worker->moveToThread(thread)
|
v
thread.started -> worker.createDevice
|
v
Worker 在自己的事件循环中创建和使用 QModbusTcpClient

典型步骤:

  1. 创建 QThread
  2. 创建没有父对象的 Worker。
  3. 调用 moveToThread()
  4. QThread::started 连接到 Worker 的初始化槽。
  5. 使用跨线程信号槽发送操作请求。
  6. Worker 完成时发送结果信号。
  7. 停止时先通知 Worker 停止,再退出线程并 wait()
  8. thread.finished -> worker.deleteLater 管理 Worker 生命周期。

跨线程不能直接调用 QModbusTcpClient 的函数。客户端应该在所属线程创建,也只能在该线程使用。

四、怎么保证线程安全

核心方法是明确所有权:

数据 推荐所有者 是否需要锁
每台 PLC 的客户端 对应 Worker 线程 不需要,禁止跨线程调用
每台 PLC 的读写队列 对应 Worker 线程 不需要
每台 PLC 的解析缓存 对应 Worker 线程 不需要
UI 控件 主线程 不需要
全局请求数、失败数 多线程 std::atomic
共享快照容器 多线程读写 QReadWriteLock,或改成单线程所有权
数据库任务队列 多个采集者、一个写库者 QMutex + QWaitCondition

原子变量适合独立值:

1
2
std::atomic_uint64_t totalRequests{0};
std::atomic_bool stopRequested{false};

原子变量不能保证多个字段组成的快照一致。例如压力、温度、时间戳必须同时属于同一采样周期,应该复制完整的不可变快照,或用锁保护整个快照。

五、100 台 PLC 同时读取,Qt 采用什么策略

不建议简单创建100个永久轮询线程。

如果通信库是异步接口,可以使用少量 I/O 线程管理多个 ModbusWorker

1
2
3
2~4 个 I/O 线程
└── 每个线程管理 5~10 个 Worker
└── 每个 Worker 独占一个客户端和一台 PLC 的队列

如果通信库是阻塞接口,则使用固定大小线程池或分组线程。每个设备仍然要有独立的超时、重试、在线状态、序列号和统计信息。

采集策略:

  • 长连接,避免每次轮询重新连接。
  • 合并连续寄存器,减少请求数。
  • 错开设备轮询时间,避免同时发送100个请求。
  • 同一连接上保持请求顺序,避免并发使用非线程安全客户端。
  • 慢设备不能阻塞其他设备。
  • 采集结果进入有界队列或快照存储。
  • UI只读取最新快照,不直接参与设备通信。

如果需要严格的同一时刻数据,PC轮询无法保证,需要 PLC 统一触发、时钟同步和采样时间戳。

六、需要快速频率读取数据怎么办

先确认目标频率是“设备数据刷新频率”“通信轮询频率”还是“控制执行频率”,三者不能混淆。

优化方向:

  • 使用设备支持的异步、订阅或主动上报机制。
  • 保持连接,批量读取连续寄存器。
  • 使用二进制协议和预分配缓冲区。
  • 减少字符串转换、日志和频繁内存分配。
  • 采集、计算、存储和 UI 分开。
  • QElapsedTimer 测量周期、平均延迟、P95/P99 延迟和抖动。
  • 使用截止时间调度,避免简单连续 msleep(1) 造成漂移。

普通 QTimer、Windows线程和 Qt Widgets 不能提供硬实时的1ms保证。

七、Qt 跟不上设备读取频率怎么办

这是生产速度大于消费速度的问题,需要明确过载策略,而不是无限堆积队列。

  • 只关心最新状态:覆盖旧快照。
  • 历史数据不能丢:使用有界环形缓冲区和批量落盘。
  • UI使用降采样数据,例如最大值、最小值、平均值。
  • 计算很慢:使用 QThreadPool,结果必须带序号。
  • 队列超过阈值:限流、丢弃低优先级数据并报警。
  • 数据库变慢:采集与写库解耦,批量写入。

先分别测量采集、解析、计算、数据库和 UI 耗时,不能只凭感觉判断瓶颈。

八、读取后还要处理,数据对不上怎么办

异步处理可能乱序返回,不能用“谁最后返回谁覆盖”的方式更新数据。每条采样消息都应携带设备编号、采样时间和序列号:

1
2
3
4
5
6
7
struct Sample {
int deviceId;
quint64 sequence;
qint64 sampleTime;
qint64 receiveTime;
QByteArray rawData;
};

处理结果必须继续携带相同的 deviceId + sequence。更新时:

  • 丢弃比当前序号更旧的结果。
  • 检测序号跳变,统计丢包。
  • 检测重复序号。
  • 多设备融合时按采样时间窗口匹配,而不是按到达顺序匹配。
  • 处理输入使用不可变快照,不能读取持续变化的全局变量。

九、数据能读取但 UI 卡顿怎么办

设备可以1000Hz采集,但 UI 没有必要1000Hz重绘。

推荐:

1
2
3
采集线程:按设备频率读取
处理线程:完成计算和数据校验
主线程:每30~100ms刷新一次UI

Qt Widgets 中使用 QTimer 控制 UI 更新周期:

1
2
3
4
5
6
QTimer *timer = new QTimer(this);
timer->setInterval(100); // 100ms,约10Hz
timer->setTimerType(Qt::PreciseTimer);
connect(timer, &QTimer::timeout,
this, &MainWindow::updateUI);
timer->start();

update() 是异步请求重绘,Qt会合并重复的重绘请求;repaint() 是同步重绘,高频调用容易阻塞主线程。不要每个采样点发送一个 UI 信号,也不要在主线程执行数据库、文件和复杂计算。

当前项目中的 timerUpdateUI 只控制 updateUI(),不控制 Modbus 采集频率。采集信号、CSV和HTTP信号仍可能高频进入主线程,需要额外进行快照合并或限频。

十、机械臂1000Hz、PLC500Hz,5N时退回

500Hz 的 PLC 每2ms更新一次数据,机械臂1000Hz每1ms执行一次。再叠加采样、通信、PLC扫描、软件处理和执行器响应延迟,等当前力值达到5N再退回必然会超调。

面试官提出的斜率预测是有效的延迟补偿方法:

1
2
k = 最近一段力曲线的斜率
F_predict = F_current + k * T_delay

F_predict >= 5N 时提前发送退回信号。

更成熟的方案:

  • 用最近多个采样点做线性拟合,而不是只用相邻两个点。
  • 通过滤波减少噪声,但滤波窗口不能过大。
  • 根据力增长速度、机械臂速度和制动距离动态提前触发。
  • 接触前采用快速接近、低速接触的两段式策略。
  • 机械臂控制器运行1kHz本地状态机,PLC作为外环或流程控制。
  • 使用时间戳补偿数据已经产生的延迟。
  • 对安全动作增加硬件限位、安全PLC或硬件比较器。

可以使用更复杂的状态观测器、Smith预测器或MPC,但软件预测不能消除不可控的网络抖动,也不能替代硬实时安全链路。

十一、设备采集数据特别多,数据库如何优化

数据库优化要同时考虑“写得进去”和“查得出来”。推荐架构:

1
采集线程 -> 有界队列 -> 批量写库线程 -> 原始表/聚合表 -> 查询服务/UI

写入优化

  • 不要每条数据单独开启事务。
  • 使用预编译语句、多行插入或数据库批量接口。
  • 一个数据库连接由写库线程独占。
  • 控制批量大小和提交周期,例如按条数或几十毫秒提交。
  • 队列有上限,数据库变慢时执行限流或丢弃低价值数据。

查询优化

  • 只查询需要的列,避免 SELECT *
  • 按设备和时间范围查询。
  • 建立符合实际查询的联合索引,例如 (device_id, timestamp)
  • 按天或按月进行时间分区。
  • 最近数据保存原始精度,历史数据使用秒级或分钟级聚合。
  • 曲线查询按屏幕分辨率降采样,不要一次返回百万个点。
  • 深度翻页使用游标或主键翻页,避免深度 OFFSET
  • 使用 EXPLAIN 检查是否发生全表扫描。

游标翻页的本质是记录上一页最后一条的排序位置:

1
2
3
4
5
6
SELECT *
FROM device_data
WHERE device_id = 1
AND id < 901
ORDER BY id DESC
LIMIT 100;

id < 901 不是取第901条,而是从上一页最后一条记录之后继续读取。前端只保存并传回 next_cursor,不计算偏移量。

数据生命周期

  • 热数据保留在线快速查询。
  • 旧数据压缩或迁移到历史表。
  • 很久以前的数据归档到文件或对象存储。
  • 根据业务要求设置保留周期。

十二、CI/CD 了解吗

CI/CD 是持续集成、持续交付和持续部署。

CI

每次提交代码后自动执行:

  1. 拉取代码。
  2. 安装或缓存依赖。
  3. 编译 Debug/Release。
  4. 运行单元测试和集成测试。
  5. 执行静态检查、格式检查和安全扫描。
  6. 保存测试报告和构建产物。

CD

  • 持续交付:构建出可发布版本,由人工确认上线。
  • 持续部署:通过自动化流程直接部署到目标环境。

Qt项目可以在流水线中配置:

  • Windows、Linux等构建矩阵。
  • Qt版本和编译器版本固定。
  • CMake/qmake自动构建。
  • Qt Test测试。
  • AddressSanitizer、ThreadSanitizer或静态分析。
  • 打包依赖库、配置文件和安装程序。
  • 版本号、Git提交号和构建时间写入产物。

面试时可以说:我理解CI/CD的重点不是“自动打包”本身,而是让每次提交可重复编译、可测试、可追溯、可回滚。

十三、软件中的开源协议

使用开源代码前要看对应项目的 LICENSENOTICE 和第三方依赖清单,不能只看项目名称。

协议类型 主要特点
MIT/BSD 宽松,可商用和闭源,通常需要保留版权和许可声明
Apache-2.0 宽松,包含专利授权和专利终止条款,需要保留NOTICE等声明
GPL 强Copyleft,分发衍生作品时通常需要按GPL提供源代码
LGPL 比GPL宽松,通常允许动态链接闭源使用,但要满足重新链接、声明等义务;静态链接要求更严格
AGPL 在GPL基础上关注网络服务场景,通过网络提供服务也可能触发源代码提供义务

还要关注:

  • 开源协议是否允许商业使用。
  • 是否要求公开修改后的源代码。
  • 是否需要保留版权和NOTICE。
  • 静态链接和动态链接的区别。
  • 第三方依赖是否带有传染性协议。
  • Qt具体版本使用的是LGPL、GPL还是商业授权。

面试回答可以是:我会逐个确认依赖的许可证,保存许可证和NOTICE,维护第三方清单;商业产品中优先选择合规的MIT、BSD、Apache或满足条件的LGPL方案,GPL/AGPL依赖需要在引入前评估分发方式和源代码义务。

十四、结合现有 ModbusWorker 的代码复盘

当前单Worker的线程封闭思路是可以保留的,但扩展到20台PLC前必须处理这些问题:

  1. readRequestCount 不能是文件级 static,必须是每个Worker的成员。
  2. PLC地址、端口、Unit ID必须进入 PlcConfig,不能硬编码。
  3. 所有结果信号携带 deviceId,否则20台设备的数据无法区分。
  4. 读队列和写队列分离,写队列使用FIFO,不能用 prepend() 导致写命令倒序。
  5. 读取失败不能直接计入完整周期,需要成功掩码、序列号和数据质量标记。
  6. 读请求不能无限快速循环,需要轮询周期、超时和退避策略。
  7. 队列要有容量上限,写命令需要超时、确认和失败反馈。
  8. UI只接收最新快照,不能让每次采样都触发控件刷新。
  9. 数据库和CSV写入不能放在I/O线程中。
  10. 关闭程序时先停止Worker,再退出线程并等待,确保客户端和未完成请求安全释放。

十五、最终口述模板

遇到这类综合题,可以用下面的顺序回答:

我的设计会把采集、处理、存储和UI分层。每个PLC有独立的连接状态、请求队列、序列号和快照,异步通信通过少量I/O线程管理。线程之间使用信号槽或有界队列传递不可变数据,简单统计使用原子变量,复合数据使用锁或线程所有权。采集速度大于处理速度时采用批处理、限流、降采样和丢弃策略。所有数据携带设备编号、时间戳和序列号,避免异步处理后数据错位。UI只按固定频率显示最新快照。对于1kHz机械臂和500Hz PLC的力控问题,我会测量总延迟,根据力曲线斜率预测未来力值并提前触发,同时把硬实时闭环和安全保护放在实时控制器或硬件侧。数据库采用批量写入、时间分区、联合索引、聚合和游标分页。代码通过CI/CD自动编译、测试、检查和打包,并按许可证要求管理第三方依赖。

这段回答的重点不是堆砌名词,而是说明数据如何流动、哪里可能阻塞、哪里可能竞争、失败后如何恢复,以及如何验证系统确实可靠。

 

失利总结

基础知识不够扎实,开发流程规范不够了解。
失败是人生的主旋律,但是如何面对失败,把我们分成了不同的人