Qt C++/Go/Python 面试题(持续更新)

目录

------------------------C++ Windows Linux 八股--------------------------

封装、继承、多态是什么?

final标识符的作用是什么?

介绍一下虚函数

介绍一下智能指针

介绍一下左值、右值、左值引用、右值引用

指针和引用有什么区别?

7、define、const、inline 的区别是什么?

C++程序的内存划分

1. 代码区(Text Segment)

2. 全局/静态数据区(Data Segment)

3. 堆区(Heap)

4. 栈区(Stack)

5. 常量区(Read-Only Data)

6. 内存映射区(Memory Mapping Segment)

class与struct的区别

内存对齐是什么?

多线程为什么会发生死锁?

面向过程和面向对象

++i和i++哪个效率更高

介绍一下 vector、list、deque 的底层实现原理和优缺点

介绍一下 map 和 unordered_map

lamda表达式捕获列表捕获的方式有哪些?

空对象指针为什么能调用函数?

介绍一下 push_back 和 emplace_back

空类中有什么函数?

explicit 什么作用?

成员变量初始化的顺序是什么?

malloc和new的区别是什么?

迭代器和指针

线程有哪些状态

哈希碰撞的处理方法

动态链接和静态链接

C++ 强制类型转换

什么是 volatile 关键字

为什么需要 volatile?

什么是回调函数?

介绍一下栈溢出

介绍一下 C++ 协程

介绍一下锁

mutex 互斥量

read-write lock 读写锁

spinlock 自旋锁

一个函数,如何让它在main函数之前执行?

介绍一下工厂方法模式

介绍一下单例模式

单例模式适合的场景主要有这些:

介绍一下观察者模式

介绍一下 GTest 和 GMock 测试框架

介绍一下 POSIX 线程

简单的 POSIX 线程池

介绍一下 std::atomic

如何 Debug 一个死锁(GDB 的使用)?

为什么服务器更偏向于使用 Linux 系统而不是 Windows

Linux 中常用的命令

介绍一下如何判断内存泄漏?

有什么性能调优的办法?

介绍一下 Boost 库

介绍一下 fork 函数

介绍一下 exec 函数

介绍一下 wait 函数

介绍一下 mmap

进程之间的通信方式有哪些?

父子进程怎么用共享内存通信?

介绍一下 X11

介绍一下浏览器的大概架构

介绍一下 CEF

 如何获取软件窗口

 Windows 系统(使用 Win32 API)

Linux 系统(使用 X11)

windows/linux 模拟鼠标键盘输入

介绍一下按键精灵的原理

如何封装一个简单的界面库

目标:为什么要封装 GUI 库?

核心封装内容

------------------------------------计算机网络---------------------------------

介绍一下 I/O 多路复用

你知道 Reactor 和 Proactor 的区别吗?

简单介绍一下 Reactor 和 Proactor

介绍一下 muduo 网络框架

TCP 通信时如何处理粘包问题?

介绍一下 TCP 三次握手和四次挥手

为什么客户端进入 TIME_WAIT 后需要保留一段时间?

TCP 拥塞控制的四种算法分别做什么?

介绍一下 TCP 流量控制

介绍一下 HTTPS 的加密过程

介绍一下 TLS 协议

键入网址到网页显示,期间发生了什么?

HTTP 中 GET 和 POST 有什么区别

介绍一下串口通信

特点:

介绍一下 gRPC

优点

缺点

介绍一下 CANOpen 协议

优点:

缺点:

------------------------------------QT---------------------------------------

介绍一下 GTK

介绍一下 Qt 的 D 指针和 Q 指针

介绍一下 Qt 的信号槽机制

介绍一下Qt中的内存管理机制

介绍一下 Qt 事件循环机制

介绍一下 MVD 框架

------------------------------------数据库------------------------------------

执行一条 MySQL 语句的流程是怎样的?

介绍一下 MySQL 的存储

MySQL 并行事务会引发什么问题?

------------------------------------算法-----------------------------------------

冒泡排序

快速排序

二分查找(折半查找)

------------------------------------简历---------------------------------------

介绍上位机项目的通用模板

~面试官提问:为什么选择 MVD 架构?相比传统 MVC 有什么优势?

~面试官提问:QStyledItemDelegate 和 QItemDelegate 有什么区别?为什么要自己写 Delegate?

~面试官提问:在 QTableView 中如何实现单元格动态变化/代理类的变化

~面试官提问:为什么选择 MQTT 作为通信协议

~面试官提问:怎么处理 MQTT 断线重连?有做心跳机制吗?

~面试官提问:如果需要多表联合查询,还能用 QSqlTableModel 吗?怎么处理并发操作数据库时的冲突?

介绍中位机项目的通用模板

~面试官提问:请简单介绍一下canopen协议,为什么选用canopen协议?

~面试官提问:CanFestival协议栈你做了哪些方面的封装和修改?是如何设计的?是否解决了什么具体问题?

~面试官提问:CANOpen 中 PDO 和 SDO 的区别?你在项目中是如何使用它们的?

~面试官提问:如何处理 CAN 总线冲突或带宽占满的情况?有没有做过优化?

~面试官提问:为什么选择 QThreadPool 而不是 QThread 或std::thread ?

~面试官提问:系统如何处理下位机设备的异常或掉线?有没有重连机制?

~面试官提问:QThreadPool 的最大线程数你是如何设置的?怎么评估合适的线程数?

介绍 QML C++ 项目的通用模板

~面试官提问:如何使 QML 和 C++ 协同工作? 

1、在 QML 中通过 setContextProperty 注册 C++ 类

2、在 QML 中通过 qmlRegisterSingletonInstance 注册 C++ 类

3、QML 和 C++ 的数据交换

~面试官提问:Q_INVOKABLE 和 Q_PROPERTY 的区别和使用场景?

~面试官提问:在 QML 页面中如何异步访问 C++ 操作?

介绍 Python 升级工具的模板


------------------------C++ Windows Linux 八股--------------------------

封装、继承、多态是什么?

封装:将具体实现过程和数据封装成一个类,只能通过接口进行访问,降低耦合性。

继承:子类继承父类的特征和行为,复用了基类的全体数据成员函数,基类私有成员可被继承,但是无法被访问,其中构造函数、析构函数、友元函数、静态数据成员、静态成员函数都不能被继承。

多态静态多态通过函数重载实现,可以实现同一个函数名根据传入参数不同而具有不同实现,编译期间就已经确定;动态多态:通过虚函数表实现,在派生类中重写基类函数,实现同一个函数名不同实现,运行时根据虚函数表确定调用哪个函数。

final标识符的作用是什么?

放在类的后面表示该类无法被继承,也就是阻止了从类的继承,放在虚函数后面该虚函数无法被重写。

介绍一下虚函数

虚函数是通过虚函数表实现的,含有虚函数的类在构造函数中会初始化虚函数表指针,它存储在类内存中,指向虚函数表,虚函数表存储指向每个虚函数的指针,多个类对象共用一张虚函数表。虚函数和普通函数都存储在代码段。

构造函数不能为虚函数,因为虚函数表指针需要在构造函数中初始化。析构函数最好为虚函数,这样当一个指向派生类的基类指针被释放时,可以先调用派生类析构函数,再调用基类析构函数,否则只会调用指向的类的析构函数。

类中static函数不能声明为虚函数,因为类中的 static 函数是所有类实例化对象所共有的,没有 this 指针,此时无法访问到虚函数表指针。

介绍一下智能指针

智能指针可以自动管理内存,通过调用对象的析构函数来实现。

share_ptr:共享同一个资源,每增加一个智能指针,引用计数加1,当引用计数为0时释源。

unique_ptr:对其持有的堆内存具有唯一拥有权,也就是说引用计数永远是 1,禁止复制语义。

weak_ptr:不占用引用计数,用于解决 share_ptr 循环引用问题。比如有两个类,它们各有一个指向对方的 share_ptr,然后相互初始化,此时就会造成引用循环。将其中的一个 share_ptr 换成 weak_ptr,就可以解决。

实际开发中,有时候需要在类中返回包裹当前对象(this)的一个 std::shared_ptr对象给外部使用,C++ 新标准也为我们考虑到了这一点,有如此需求的类只要继承自 std::enable_shared_from_this模板对象即可。用法如下:

#include 
#include 
​
class A : public std::enable_shared_from_this
{
public:
    A()
    {
        std::cout << "A constructor" << std::endl;
    }
​
    ~A()
    {
        std::cout << "A destructor" << std::endl;
    }
​
    std::shared_ptr getSelf()
    {
        return shared_from_this();
    }
};
​
int main()
{
    std::shared_ptr sp1(new A());
​
    std::shared_ptr sp2 = sp1->getSelf();
​
    std::cout << "use count: " << sp1.use_count() << std::endl;
​
    return 0;
}

介绍一下左值、右值、左值引用、右值引用

左值:有地址的值,如下列代码中的变量a。

右值:临时值,如下列代码中的10。

int a = 10;

左值引用:就是普通的引用。

右值引用:一种引用类型,用&&表示,它专门用于绑定到临时对象(右值)上,主要用于实现完美转发移动语义

完美转发就是在传递参数时维持参数的类型(左值、右值)不变

移动语义就是允许资源的所有权转移而非复制。

std::move 的功能是将一个左值引用强制转化为右值引用,继而可以通过右值引用使用该值,以用于移动语义,从实现原理上讲基本等同一个强制类型转换

指针和引用有什么区别?

指针:指针是变量,内存中存储的是目标数据的地址。

引用:是变量的别名,不额外占用内存,必须初始化且只能初始化一次。

7、define、const、inline 的区别是什么?

define 是在预处理时进行简单的文本替换,不会进行类型安全检查,define 定义的宏常量,在程序中使用多少次就会进行多少次替换,内存中有多个备份,占用的是代码段的内存。

const 则是在编译时确定其值,会进行类型安全检查。

inline 是建议编译器在函数调用处将函数展开,适用于比较短、功能单一的函数,节省函数调用的开销。如果函数体过长,频繁使用内联函数会导致代码编译膨胀问题

在头文件中类内声明并定义的函数默认是 inline,类内声明类外初始化的必须加 inline,以解决多重定义错误(否则多个cpp文件包含了这个h文件,链接时会出现多重定义错误)。

在头文件中类内声明,cpp文件中定义的函数不需要加 inline,此时不会发生多重定义错误。

C++程序的内存划分

1. 代码区(Text Segment)

  • 存储内容:程序的机器指令(可执行代码)

  • 特点

    • 只读内存区域

    • 在程序启动时加载

    • 可能被多个进程共享(对于相同的可执行文件)

2. 全局/静态数据区(Data Segment)

分为两部分:

  • 已初始化数据段(.data)

    • 存储已初始化的全局变量和静态变量

    • 包括静态局部变量

  • 未初始化数据段(.bss)

    • 存储未初始化的全局变量和静态变量

    • 程序加载时由操作系统初始化为零

3. 堆区(Heap)

  • 存储内容:动态分配的内存(通过new/malloc分配)

  • 特点

    • 手动管理(需要delete/free释放)

    • 内存分配方向从低地址向高地址增长

    • 分配速度较慢(需要系统调用)

    • 大小受系统虚拟内存限制

4. 栈区(Stack)

  • 存储内容

    • 局部变量

    • 函数参数

    • 函数调用信息(返回地址、寄存器状态等)

  • 特点

    • 自动管理(函数结束时自动释放)

    • 内存分配方向从高地址向低地址增长

    • 分配速度快(只需移动栈指针)

    • 大小有限(通常几MB,可配置)

5. 常量区(Read-Only Data)

  • 存储内容

    • 字符串字面量

    • 编译期确定的常量表达式(如constexpr

  • 特点

    • 只读内存区域

    • 尝试修改会导致段错误

6. 内存映射区(Memory Mapping Segment)

  • 存储内容

    • 动态链接库

    • 内存映射文件

    • 共享内存区域

  • 特点

    • 由操作系统管理

    • 可用于进程间通信

class与struct的区别

class 默认是 private 继承,struct 默认是 public 继承。

内存对齐是什么?

CPU通常以固定大小的块(如4字节、8字节)从内存中读取数据。如果数据未对齐,可能需要多次内存访问才能读取完整数据,降低效率。

多线程为什么会发生死锁?

当各个线程想获取的资源,但是得不到满足,同时自身也不释放已有资源,就会产生死锁。

四个必要条件:互斥条件、请求和保持条件、不可剥夺条件、环路等待条件,破坏其一就可解除死锁。

面向过程和面向对象

面向过程:分析出解决问题的步骤,然后将这些步骤一步一步的实现,使用的时候一个一个调用就好。代码效率更高但是代码复用率低,不易维护。

面向对象:就是将问题分解为各个对象,每个对象描述某个事物在整个解决问题的步骤中的行为,相比面向过程,代码更易维护和复用。但是代码效率相对较低。

++i和i++哪个效率更高

++i 是左值,++i 直接修改 i 并返回 i 本身,因此它是可修改的具名对象

i++ 是右值,i++ 返回的是递增前的临时副本,该副本是临时对象,过程中会触发拷贝构造

介绍一下 vector、list、deque 的底层实现原理和优缺点

vector 是由一块连续的内存空间组成,因此可使用下标随机访问,尾插尾删效率高,当容器大小超过容量时,会自动申请一块更大的内存(原有内存的1至2倍),并且把所有数据拷贝到新内存中。

list 是由双链表实现的,不支持下标随机访问,按需申请内存,不会造成内存空间浪费。在任意位置的插入删除下效率高。

deque 是双端队列,它的特点是可以在容器的两端进行高效的插入和删除操作。因此,deque 适用于需要频繁从容器两端进行操作的场景。支持随机访问(像 vector 一样,可以通过索引访问元素)。在两端进行插入和删除的时间复杂度是常数时间(O(1)),但是在中间进行插入和删除时,复杂度是 O(n)

介绍一下 map 和 unordered_map

  • map:底层使用 红黑树(Red-Black Tree) 实现。红黑树是一种自平衡二叉搜索树,元素是按照键的升序排列的。插入元素时,会自动根据键的大小进行排序。查找、插入和删除操作的时间复杂度是 O(log n)。红黑树的节点存储需要额外的指针来维护树的结构,因此内存开销相对较大。

  • unordered_map:底层使用 哈希表(Hash Table) 实现。哈希表通过哈希函数将键映射到桶中,从而实现快速查找。元素的顺序是 不确定的,因为哈希表中的元素是根据哈希值存储的,而哈希值与元素的插入顺序没有关系。平均情况下,查找、插入和删除操作的时间复杂度是 O(1)。哈希表需要额外的内存来存储哈希桶和处理哈希冲突,但是内存开销小于 map。

lamda表达式捕获列表捕获的方式有哪些?

按值捕获:符号=,不能修改参数

引用捕获:符号&,可以修改传入的外部参数

默认的引用捕获可能会导致悬挂引用,引用捕获会导致闭包包含一个局部变量的引用或者形参的引用,如果一个由lambda创建的闭包的生命周期超过了局部变量或者形参的生命期,那么闭包的引用将会空悬。解决方法是对个别参数使用值捕获

空对象指针为什么能调用函数?

空对象指针可以调用成员函数,前提是成员函数中不涉及到成员变量。

介绍一下 push_back 和 emplace_back

push_back 接收左值时调用拷贝构造函数,接受右值时调用移动构造函数

emplace_back 则是在容器内部直接构造新对象,避免了复制操作

空类中有什么函数?

默认构造函数、默认拷贝构造函数、默认析构函数、默认赋值运算符

空类的大小为1B

explicit 什么作用?

只能用于修饰只有一个参数的类构造函数(有一个例外就是,当除了第一个参数以外的其他参数都有默认值的时候此关键字依然有效),它的作用是表明该构造函数是显示的,而非隐式的。作用是防止类构造函数的隐式自动转换,比如构造函数中接收 float 类型,结果传了 int 类型,此时不进行类型转换。

跟它对应的另一个关键字是implicit,意思是隐藏的,类构造函数默认情况下声明为implicit。

成员变量初始化的顺序是什么?

与构造函数中初始化成员列表的顺序无关,只与类中定义成员变量的顺序有关。

类中const成员常量必须在构造函数初始化列表中初始化。类中static成员变量,只能在类内定义、类外初始化。

malloc和new的区别是什么?

New/delete会调用构造析构函数,malloc/free不会,所以他们无法满足动态对象的要求。

迭代器和指针

迭代器返回的是对象引用,而指针是一个变量。

线程有哪些状态

创建,就绪,运行,阻塞,结束。

阻塞态/创建态 + 资源 = 就绪态

就绪态 + CPU调度 = 运行态

运行态 - 资源 = 阻塞态

阻塞态不可直接到达运行态,需要先转为就绪态。

哈希碰撞的处理方法

开放定址法:当遇到哈希冲突时,去寻找一个新的空闲的哈希地址。

再哈希法:同时构造多个哈希函数,等发生哈希冲突时就使用其他哈希函数知道不发生冲突为止,虽然不易发生聚集,但是增加了计算时间

链地址法:将所有的哈希地址相同的记录都链接在同一链表中

动态链接和静态链接

动态链接:不会将代码直接复制到自己程序中,只会留下调用接口,程序运行时根据代码中的路径将动态库加载到内存中,所有程序只会共享这一份动态库,因此动态库也被称为共享库。

静态链接:通俗来说就是把静态库打包到可执行程序中,如果有个多程序,每个程序链接静态库后,都会包含一份独立的代码,当程序运行起来时,所有这些重复的代码都需要占用独立的存储空间,显然很浪费计算机资源。

C++ 强制类型转换

static_cast 是一种最常见的强制类型转换,通常用于转换兼容类型之间的转换,例如基本数据类型之间的转换,类层次结构中的类型转换等。例如将 int 转换为 double。

dynamic_cast 主要用于多态类型的转换,特别是在类层次结构中,进行类对象的类型安全转换。它用于在类层次结构中进行向下转换(基类指针/引用转为派生类指针/引用),并且在运行时检查类型的安全性。

const_cast 用于移除或添加对象的 const 属性。它是唯一可以修改对象的常量性(constvolatile)的类型转换。

reinterpret_cast 是最强大也是最危险的类型转换方式,它可以将指针类型转换为任意其他指针类型,甚至是整数与指针之间的转换。这种转换可能导致严重的错误和未定义行为,应该谨慎使用。

什么是 volatile 关键字

volatile 关键字主要用于防止编译器优化某些变量。它告诉编译器,变量的值可能会在程序的其他地方发生变化,即使在程序代码中没有显式地修改该变量。

为什么需要 volatile

  1. 硬件寄存器的映射:
    在嵌入式系统中,通常会使用 volatile 来声明与硬件寄存器相关的变量。例如,某些硬件寄存器的值可能会随着外部事件而发生变化(如传感器输入、外部设备状态等),因此编译器必须在每次访问时都从内存中读取最新的值,而不是从寄存器缓存中读取旧值。

  2. 多线程并发:
    在多线程程序中,某个线程可能会在另一个线程正在运行时修改共享变量。如果没有使用 volatile,编译器可能会出于优化考虑,缓存该变量的值,导致读取到过时的数据。volatile 可以防止这种情况,确保每次访问共享变量时都从内存中读取最新的值。

  3. 信号处理:
    在信号处理程序中,变量的值可能会在任何时刻发生变化,使用 volatile 可以确保信号处理器和主程序中的访问行为一致。

什么是回调函数?

回调函数(Callback Function)是指将一个函数作为参数传递给另一个函数,并由后者在适当的时机调用该回调函数。回调函数在许多编程场景中都有应用,尤其是在事件驱动编程、异步编程以及接口设计中。

优点

程序更具灵活性,因为调用者可以定义自己的行为(即回调函数)。例如,某个框架或库提供了一个通用的接口,而具体的行为由用户自定义回调函数来决定。这样,开发者无需修改框架或库的内部代码就能扩展其功能。例如 CANOpen 通信中的心跳、节点状态。

回调函数有助于解耦代码。被调用的函数不需要知道回调函数的具体实现,只需要知道如何调用回调即可。这样,可以实现不同模块间的松耦合,降低模块间的依赖。

缺点

如果有多个嵌套的回调函数,尤其是在处理异步任务时,代码容易变得难以阅读和维护。回调嵌套深度过大时,代码层次混乱

调函数的执行顺序不确定,尤其是在异步编程中,可能会导致调试困难。

介绍一下栈溢出

是用于存储局部变量、函数调用信息、函数参数等数据的内存区域,如果在程序运行过程中,栈的空间被超出了其分配的最大空间,从而导致程序崩溃或者发生意外行为,就是栈溢出(Stack Overflow)。

溢出的原因和情况:

  1. 递归调用过深: 当程序中使用递归(一个函数直接或间接地调用自己)时,如果递归的深度过大,栈空间会被逐步消耗,最终导致栈溢出。

  2. 局部变量占用过多栈空间: 当一个函数声明了大量的局部变量(特别是大数组或大结构体)时,这些变量会占用栈上的大量空间。如果栈的空间不足以存储这些变量,也会导致栈溢出。

  3. 无限循环中的函数调用: 如果程序中有一个错误的循环结构,使得某个函数反复被调用,也可能导致栈空间的耗尽。与递归类似,只不过这种情况是在循环结构的控制下发生的。

介绍一下 C++ 协程

协程的本质就是处理自身挂起和恢复的用户态线程

协程的切换比线程的切换速度更快,在IO密集型任务情境下更适合。IO密集型任务的特点是CPU消耗少,其大部分时间都是在等待IO操作完成。

相比于函数,协程最大的特点就是支持挂起/恢复

  • 有栈协程(Stackful Coroutine):每个协程都有自己的调用栈,类似于线程的调用栈。
  • 无栈协程(Stackless Coroutine):协程没有自己的调用栈,挂起点的状态通过状态机或闭包等语法来实现。

介绍一下锁

mutex 互斥量

mutex 是睡眠等待类型的锁,当线程抢互斥锁失败的时候,线程会陷入休眠。优点就是节省CPU资源,缺点就是休眠唤醒会消耗一点时间。

read-write lock 读写锁

读写锁的特性:

  • 加了写锁时,其他线程加读锁或者写锁都会阻塞
  • 加了读锁时,其他线程加写锁会阻塞,加读锁会成功。

适用于多读少写的场景。

spinlock 自旋锁

自旋锁不会引起线程休眠。当共享资源的状态不满足时,自旋锁会不停地循环检测状态,不休眠就不会引起上下文切换,此时不释放CPU,俗称“忙等待”,适合锁时间短的场景。

一个函数,如何让它在main函数之前执行?

1、main之前声明一个全局对象,然后实例化,这样就可以调用其构造函数。

2、用关键字 __attribute__

介绍一下工厂方法模式

工厂方法模式是一种创建型设计模式,它的主要目的是将对象的创建过程延迟到子类中进行,这样可以让程序在不指定具体类的情况下创建对象。

传统方式里,如果你在代码里直接 new 一个对象,那么这个类就写死了,扩展起来很麻烦。

工厂方法模式把对象创建这件事交给子类或具体工厂去完成,父类只定义一个统一的接口,具体创建哪个对象,由子类决定。

介绍一下单例模式

单例模式是一种非常经典的创建型设计模式,它的目的是保证一个类在系统中只有一个实例,而且提供一个全局访问点来获取这个实例。

单例模式适合的场景主要有这些:

  1. 系统中只需要一个实例,且需要全局统一管理的场景
    比如:

    • 配置管理器(全局配置)

    • 日志系统(统一收集输出日志)

    • 数据库连接池(控制连接数量)

    • 线程池(统一管理线程资源)

  2. 需要控制资源的访问,避免出现竞争或者资源浪费
    单例可以确保大家用的是同一个资源对象,不用每次都重新创建,节省系统资源。

介绍一下观察者模式

简单说就是:一个对象(被观察者)状态变化时,所有依赖它的对象(观察者)都会收到通知并自动更新

主要有两个角色:

  1. 被观察者(Subject)

    • 提供注册(attach)、取消注册(detach)观察者的方法

    • 有状态变化时,通知(notify)所有观察者,调用观察者的更新接口

  2. 观察者(Observer)

    • 定义更新接口(update),当被观察者变化时执行更新操作

介绍一下 GTest 和 GMock 测试框架

GTest(Google Test)是由 Google 开发的一个 C++ 单元测试框架,测试代码是通过TEST() 编写的。

  • MyTestSuite:测试套件名。

  • Test1:具体的测试函数

#include 

TEST(MyTestSuite, Test1) {
    EXPECT_EQ(1 + 1, 2);
}

EXPECT_EQ(a, b):期望 a == b,失败不终止后续测试

ASSERT_EQ(a, b):断言 a == b,失败则终止测试函数

EXPECT_TRUE(cond):判断条件为真

在部署时可以通过 FetchContent 自动拉取 GTest

# 引入 FetchContent 模块
include(FetchContent)

# 拉取 GoogleTest 源码
FetchContent_Declare(
  googletest
  URL https://github.com/google/googletest/archive/refs/heads/main.zip
)

# 下载并添加子项目
FetchContent_MakeAvailable(googletest)

GMock 是指在测试中使用模拟的对象来替代真实的依赖对象(如类、函数等),从而隔离被测模块,更容易控制测试环境和行为。

可以继承于一个基类,并使用 MOCK_METHOD 重写其函数

#include 

class MockDatabase : public Database {
public:
    MOCK_METHOD(bool, Connect, (const std::string& url), (override));
    MOCK_METHOD(int, GetData, (int key), (override));
};

通过 EXPECT_CALL 设置函数期望,也可以通过匹配器对函数参数进行限制(如 Eq(x)、Gt(x)),然后调用(可以通过 ::testing::InSequence seq 来限制函数调用的顺序),再使用宏 EXPECT_TRUE  EXPECT_EQ 进行判断

#include 

TEST(MyTest, DatabaseUsage) {
    MockDatabase mock_db;

    // 设置期望:Connect("127.0.0.1") 被调用一次并返回 true
    EXPECT_CALL(mock_db, Connect("127.0.0.1")).Times(1).WillOnce(testing::Return(true));
    
    // 设置期望:GetData(42) 被调用一次并返回 100
    EXPECT_CALL(mock_db, GetData(42)).Times(1).WillOnce(testing::Return(100));

    // 执行被测代码
    EXPECT_TRUE(mock_db.Connect("127.0.0.1"));
    EXPECT_EQ(mock_db.GetData(42), 100);
}

介绍一下 POSIX 线程

POSIX 线程(POSIX Threads,简称 pthread)是在类 Unix 系统(如 Linux)下最常用的底层多线程编程方式。

常见函数

int pthread_create(pthread_t *thread,                 // 线程 ID
                   const pthread_attr_t *attr,        // 线程属性,常用为 nullptr 表示默认
                   void *(*start_routine)(void *),    // 线程函数
                   void *arg);                        // 传给线程函数的参数(封装为结构体)


// 等待线程结束,阻塞当前线程,回收资源
pthread_join()

// 设置线程为“分离”状态,线程结束后自动回收资源,不可再 join
pthread_detach()

// 在线程中退出
pthread_exit() 

// 获取当前线程ID
pthread_self()

互斥锁

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

pthread_mutex_lock(&mutex);
// 临界区代码
pthread_mutex_unlock(&mutex);

条件变量,当被阻塞线程被唤醒时,需要重新获取到 mutex 才能继续往下执行

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

pthread_mutex_lock(&mutex);
while (!ready) {
    pthread_cond_wait(&cond, &mutex);  // 会释放 mutex 并阻塞等待
}
pthread_mutex_unlock(&mutex);

// 通知方:
pthread_cond_signal(&cond);  // 唤醒一个等待线程

简单的 POSIX 线程池

大致思路:实例化线程池,创建了多个线程并启动,但由于线程没有传入任务而进入条件变量的阻塞,直到主线程中手动调用了 Submit 将一个具体的任务传递给 tasks 队列,并唤醒线程,解开条件变量继续往下执行。 

线程复用:当线程执行完当前任务后,会继续执行新的任务。

#include 
#include 
#include 
#include 
#include   // sleep

const int THREAD_COUNT = 4;

class ThreadPool {
public:
    ThreadPool();
    ~ThreadPool();

    void Submit(std::function task);

private:
    static void* Worker(void* arg);
    void Run();

    pthread_t workers[THREAD_COUNT];
    std::queue> tasks;
    pthread_mutex_t mutex;
    pthread_cond_t cond;
    bool stop;
};

ThreadPool::ThreadPool() : stop(false) {
    pthread_mutex_init(&mutex, nullptr);
    pthread_cond_init(&cond, nullptr);

    for (int i = 0; i < THREAD_COUNT; ++i) {
        pthread_create(&workers[i], nullptr, Worker, this);
    }
}

ThreadPool::~ThreadPool() {
    // 设置退出标志
    pthread_mutex_lock(&mutex);
    stop = true;
    pthread_cond_broadcast(&cond);
    pthread_mutex_unlock(&mutex);

    for (int i = 0; i < THREAD_COUNT; ++i) {
        pthread_join(workers[i], nullptr);
    }

    pthread_mutex_destroy(&mutex);
    pthread_cond_destroy(&cond);
}

void ThreadPool::Submit(std::function task) {
    pthread_mutex_lock(&mutex);
    tasks.push(task);
    pthread_cond_signal(&cond);
    pthread_mutex_unlock(&mutex);
}

void* ThreadPool::Worker(void* arg) {
    ThreadPool* pool = static_cast(arg);
    pool->Run();
    return nullptr;
}

void ThreadPool::Run() {
    while (true) {
        std::function task;

        pthread_mutex_lock(&mutex);
        while (tasks.empty() && !stop) {
            pthread_cond_wait(&cond, &mutex);
        }

        if (stop && tasks.empty()) {
            pthread_mutex_unlock(&mutex);
            break;
        }

        task = tasks.front(); tasks.pop();
        pthread_mutex_unlock(&mutex);

        task();  // 执行任务
    }
}

// 示例任务
void DoWork(int id) {
    std::cout << "Thread " << pthread_self() << " working on task " << id << std::endl;
    sleep(1);
}

int main() {
    ThreadPool pool;

    for (int i = 0; i < 10; ++i) {
        int task_id = i;
        pool.Submit([task_id]() {
            DoWork(task_id);
        });
    }

    sleep(5);  // 等待任务完成
    std::cout << "Main thread exit.\n";
    return 0;
}

介绍一下 std::atomic

std::atomic 用于多线程场景下实现简单(如加减法)的原子操作

无需进入内核、无上下文切换 → 更快、更轻量,效率比 std::mutex 高。

std::atomic 不支持拷贝构造或拷贝赋值

如果需要复杂操作,还需要 std::mutex,适合完成更复杂更灵活的场景

如何 Debug 一个死锁(GDB 的使用)?

通过 gdb 调试,观察现象:

  • 多个线程卡在 pthread_mutex_lock()

  • 没有线程在 pthread_mutex_unlock() 或执行逻辑代码

  • 堆栈中重复出现在锁等待的代码行

g++ -g -pthread your_program.cpp -o your_program

// 调试可执行程序
gdb ./your_program

// 调试 coredump
gdb ./your_program core

常见命令

// 在 main 函数设断点
(gdb) break main

// 在某个源文件的函数设置断点
(gdb) break myfunc.cpp:42


// 启动程序
(gdb) run        

// 单步执行(不进入函数)
(gdb) next

// 打印变量值
(gdb) print x

// 继续运行直到下一个断点
(gdb) continue

// 退出 GDB
(gdb) quit

为什么服务器更偏向于使用 Linux 系统而不是 Windows

原因分类 具体说明
技术 稳定、高性能、可裁剪、兼容开源服务
成本 免费,无需许可,维护成本低
运维 命令行强,自动化能力强,工具丰富
安全 攻击面小、权限机制灵活、安全透明
云原生 对容器与云支持全面,默认平台就是 Linux

Linux 中常用的命令

Q:程序崩溃了,你怎么排查?

A:分析 coredump,查看程序日志,使用 gdb 调试

Q:程序卡顿,CPU 飙高,怎么查?

A:使用 htop 查看 CPU 占用,定位到 PID,使用 ps -L -p 查看线程

Q:查看某个端口是否被占用

netstat -tunlp | grep 8080
ss -ltnp | grep 8080
lsof -i :8080

Q:查看某个文件是否被某个进程占用

lsof | grep filename
fuser -v filename

Q:查看磁盘使用情况

df -h        # 查看分区占用
du -sh *     # 查看当前目录占用

介绍一下如何判断内存泄漏?

使用 Valgrind 工具

g++ -g your_program.cpp -o your_program

valgrind --leak-check=full ./your_program


==12345== HEAP SUMMARY:
==12345== definitely lost: 48 bytes in 2 blocks
==12345== indirectly lost: 0 bytes in 0 blocks
==12345== possibly lost: 0 bytes in 0 blocks
==12345== still reachable: 0 bytes in 0 blocks
==12345== suppressed: 0 bytes in 0 blocks

definitely lost:确实泄漏了内存,未释放

indirectly lost:由于主对象未释放,间接泄漏

still reachable:还可以访问,但程序没释放(可能是全局对象)

leak summary:最重要的部分!

有什么性能调优的办法?

通过 htop 分析占用内存、占用 cpu 等

通过 valgrind 分析是否有内存泄漏

合理使用线程数

介绍一下 Boost 库

Boost.Filesystem:用于目录遍历、日志文件管理

Boost.Asio:实现 TCP 服务端,做异步通信,同时支持同步和异步

Boost.Program_options:写命令行工具时解析参数

介绍一下 fork 函数

用于创建一个进程,所创建的进程复制父进程的代码段/数据段/BSS段/堆/栈等所有用户空间信息;在内核中操作系统重新为其申请了一个PCB,并使用父进程的PCB进行初始化。

fork 调用的一个奇妙之处就是它仅仅被调用一次,却能够返回两次,它可能有三种不同的返回值:

  1. 在父进程中,fork返回新创建子进程的进程ID;
  2. 在子进程中,fork返回0;
  3. 如果出现错误,fork返回一个负值;

因此我们可以通过fork返回的值来判断当前进程是子进程还是父进程

介绍一下 exec 函数

将当前进程的内存内容替换成另一个程序,并开始执行它。

不会创建新进程,只是在当前进程中“加载另一个程序”,例如下列代码,执行到 execl 时强行打断原有程序的执行,转而去执行 ls -l 命令。

#include 
#include 

int main() {
    std::cout << "Before exec..." << std::endl;
    execl("/bin/ls", "ls", "-l", NULL);
    std::cerr << "Exec failed" << std::endl;
    return 1;
}

介绍一下 wait 函数

父进程调用 wait() 可以挂起自己,直到子进程终止,然后再继续执行父进程的后续代码。

  • 回收子进程的退出状态(避免僵尸进程)

  • 返回终止的子进程 PID

  • status 可用于判断子进程的退出码

介绍一下 mmap

mmap 是一个系统调用,用于将一个文件或者匿名区域映射到进程的虚拟内存中,从而可以像访问内存一样访问文件内容,也可用于分配特殊内存区域(如共享内存)。

传统 read() 会把数据从内核空间复制到用户空间(复制两次):

[磁盘] → [内核缓冲区] → [用户缓冲区]

mmap 会直接将文件内容映射到进程的虚拟地址空间:

[磁盘] ←→ [用户进程虚拟内存]  (内核页缓存共享)

访问数据就像读内存一样,省去了中间复制过程,实现零拷贝,特别适合大文件、频繁访问的场景。

mmap 不会一次性把整个文件读进来,而是采用 页级按需加载(demand paging)

  • 第一次访问某个内存页时才真正从磁盘加载

  • 提高了系统效率,避免了无谓的数据加载

  • 多个进程 mmap 同一个文件(使用 MAP_SHARED),内核会将它们映射到相同的物理页,节省物理内存,配合 MAP_SHARED + shm_open()(或 /dev/shm 匿名文件),实现高性能的进程间共享内存通信。

进程之间的通信方式有哪些?

管道:管道本质上是一个内核中的一个缓存,当进程创建管道后会返回两个文件描述符,一个写入端一个输出端。缺点:半双工通信,通信只能发生在有亲缘关系的进程之间(如父子)。不适合进程间频繁的交换数据。

  • 匿名管道:使用 pipe(int pipefd[2]) 系统调用,存储在内存中,只能是父子进程或兄弟进程(共享 pipefd)
  • 命名管道:使用 mkfifo() 创建一个管道文件,在文件系统中可见,可以在任意两个进程通信。

消息队列:内核维护的 FIFO 队列,支持消息分类,支持任意进程间通信。适合用于多进程间控制消息传递、任务分发等场景,比管道更灵活、安全,但性能略逊于共享内存。

共享内存:最快的 IPC,没有内核态/用户态之间的数据复制,适合大量数据,支持双向通信,

用户保证并发时的同步,用户管理生命周期。

套接字:例如QLocalSocket,可以创建一个类,继承于QLocalServer,接受QLocalServer::newConnection信号,在槽函数中获取客户端套接字,并调用其函数进行读取/写入。

特性 管道 消息队列 共享内存
是否结构化 否(字节流) 是(mtype + 数据) 是(结构体指针)
通信效率 ✅ 最高
双向通信 ❌(需两管道) ✅(用 mtype 控制)
跨进程
同步机制 内核调度 阻塞/唤醒 ❌ 需自行实现
内核中转 ❌(直接共享内存)

父子进程怎么用共享内存通信?

  • 父进程通过 shmget() 创建共享内存段。

  • 父进程使用 shmat() 映射共享内存到自己地址空间。

  • 创建子进程(使用 fork())。

  • 子进程再次使用 shmat() 映射同一共享内存段。

  • 父进程写入共享内存,子进程读取。

  • 最后都要使用 shmdt() 分离内存,并由父进程 shmctl() 删除共享段。

注意:通过 pid_t pid = fork(); 可以判断当前为子进程或父进程。

介绍一下 X11

X11(X Window System 11) 是 Linux / UNIX 系统中最核心的图形显示协议之一,它是传统 Linux 图形界面的基础,也是很多图形程序(包括 Qt、GTK、CEF 等)运行的底层平台。

[图形应用(客户端)]
        ↓ Xlib/XCB
[网络协议:X11 Protocol]
        ↓
[X Server(服务端)]
        ↙ 显示屏
        ↘ 输入设备(键盘/鼠标)
  • 客户端(图形程序):通过 X11 协议向 X Server 发送“画图命令”

  • 服务端(X Server):负责与显卡、鼠标、键盘打交道,渲染实际窗口内容

介绍一下浏览器的大概架构

浏览器开发随笔-CSDN博客

介绍一下 CEF

CEF(Chromium Embedded Framework) 是一个基于 Chromium 的开源项目,允许你将 Chrome 浏览器引擎嵌入到自己的 C++ 应用中,用于渲染网页、执行 JavaScript、构建混合界面。

通俗来说:

CEF 就是 把 Chrome 浏览器嵌入到你的程序中,让你可以在程序中加载网页、调用 JS、实现 HTML5 界面。

编译过程有点复杂,参考这篇文章:Chromium内核浏览器编译记(四)Linux版本CEF编译 - 简书

 如何获取软件窗口

 Windows 系统(使用 Win32 API)

可以用 Win32 API 中的 FindWindow 查找窗口标题为 ‘Google Chrome’ 的窗口,然后通过 GetWindowText GetClassName 获取信息。如果窗口标题不确定,也可以用 EnumWindows 枚举所有窗口进行模糊匹配。拿到句柄后,我可以通过 SendMessage SendInput 进行模拟点击和输入操作。

Linux 系统(使用 X11)

在 Linux X11 环境下,可以通过 XQueryTree 遍历所有顶层窗口,使用 XGetWindowProperty 获取 WM_NAMEWM_CLASS 属性,从而识别窗口的标题和所属应用程序。也可以结合 xwininfo xdotool 工具进行交互式分析。

本质

绝大多数图形界面软件在启动后都会有“窗口标题”等信息,这些信息是由程序在创建窗口时,通过操作系统提供的 GUI 接口显式设置的,主要用于:

  • 告诉操作系统“这个窗口叫什么”

  • 让窗口管理器(如 GNOME/KDE/Windows桌面)能正确显示窗口标题栏

  • 被其他程序识别、枚举、控制(比如你想模拟点击某个窗口)

windows/linux 模拟鼠标键盘输入

windows/linux 模拟鼠标键盘输入-CSDN博客

介绍一下按键精灵的原理

如何封装一个简单的界面库

目标:为什么要封装 GUI 库?

  • 原始的 Win32 或 X11 API 编程复杂、冗长、易错

  • 封装后可以提供一组统一接口,简化使用

  • 可跨平台(如 Windows + Linux)

  • 可隐藏平台差异,支持统一的事件处理机制

核心封装内容

一个最简单的 GUI 库,通常封装以下功能模块:

模块 封装内容
窗口管理 创建窗口、显示窗口、关闭窗口
控件系统 按钮、标签、输入框等控件的创建与布局
事件系统 鼠标点击、键盘输入、窗口关闭等事件监听
绘图支持(选) 提供绘图上下文(Canvas)支持绘制文字图形
.
├── main.cpp          // 跨平台入口
├── GUI.h             // 抽象接口
├── WinWindow.cpp     // Windows 实现
├── X11Window.cpp     // Linux 实现
// GUI.h 通用接口定义
#pragma once
#include 
#include 

class Window {
public:
    virtual void Create(int width, int height, const std::string& title) = 0;
    virtual void Show() = 0;
    virtual void RunEventLoop() = 0;
    virtual ~Window() {}
};

std::unique_ptr CreatePlatformWindow();  // 工厂函数
#ifdef _WIN32

#include "GUI.h"
#include 

class WinWindow : public Window {
private:
    HWND hwnd;
    static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
        if (msg == WM_DESTROY) {
            PostQuitMessage(0);
            return 0;
        }
        return DefWindowProc(hwnd, msg, wParam, lParam);
    }

public:
    void Create(int width, int height, const std::string& title) override {
        WNDCLASS wc = {0};
        wc.lpfnWndProc = WndProc;
        wc.hInstance = GetModuleHandle(NULL);
        wc.lpszClassName = "SimpleWinClass";
        RegisterClass(&wc);

        hwnd = CreateWindow("SimpleWinClass", title.c_str(),
                            WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT,
                            width, height, NULL, NULL, wc.hInstance, NULL);
    }

    void Show() override {
        ShowWindow(hwnd, SW_SHOW);
    }

    void RunEventLoop() override {
        MSG msg;
        while (GetMessage(&msg, NULL, 0, 0)) {
            TranslateMessage(&msg);
            DispatchMessage(&msg);
        }
    }
};

std::unique_ptr CreatePlatformWindow() {
    return std::make_unique();
}

#endif
#ifndef _WIN32

#include "GUI.h"
#include 
#include 

class X11Window : public Window {
private:
    Display* display;
    Window window;

public:
    void Create(int width, int height, const std::string& title) override {
        display = XOpenDisplay(nullptr);
        if (!display) {
            std::cerr << "无法打开 X Display" << std::endl;
            exit(1);
        }

        int screen = DefaultScreen(display);
        window = XCreateSimpleWindow(display, RootWindow(display, screen),
                                     10, 10, width, height, 1,
                                     BlackPixel(display, screen),
                                     WhitePixel(display, screen));

        XStoreName(display, window, title.c_str());
        XSelectInput(display, window, ExposureMask | KeyPressMask | StructureNotifyMask);
        XMapWindow(display, window);
    }

    void Show() override {
        // XMapWindow 已经展示窗口
    }

    void RunEventLoop() override {
        XEvent event;
        while (true) {
            XNextEvent(display, &event);
            if (event.type == ClientMessage || event.type == DestroyNotify) {
                break;
            }
        }
        XDestroyWindow(display, window);
        XCloseDisplay(display);
    }
};

std::unique_ptr CreatePlatformWindow() {
    return std::make_unique();
}

#endif
#include "GUI.h"

int main() {
    auto window = CreatePlatformWindow();
    window->Create(640, 480, "跨平台窗口");
    window->Show();
    window->RunEventLoop();
    return 0;
}

同时还要封装一个简单的事件循环

// windows 中
MSG msg;
while (GetMessage(&msg, NULL, 0, 0)) {
    TranslateMessage(&msg);   // 转换键盘消息
    DispatchMessage(&msg);    // 调用窗口过程函数(WndProc)
}

也可以捕获这个按下键盘的事件

#include 
#include 

LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
    switch (msg) {
    case WM_KEYDOWN:
        if (wParam == 'A') {  // A 键(注意是大写字母)
            MessageBox(hwnd, "你按下了 A 键 (WM_KEYDOWN)", "提示", MB_OK);
        }
        break;

    case WM_CHAR:
        if (wParam == 'a' || wParam == 'A') {  // 字符 'a' 或 'A'
            MessageBox(hwnd, "你输入了字符 A (WM_CHAR)", "提示", MB_OK);
        }
        break;

    case WM_DESTROY:
        PostQuitMessage(0);
        break;

    default:
        return DefWindowProc(hwnd, msg, wParam, lParam);
    }
    return 0;
}

------------------------------------计算机网络---------------------------------

介绍一下 I/O 多路复用

这三个都是 I/O 多路复用技术,用于同时监控多个文件描述符(fd)的状态变化(可读、可写、异常等),以尽量小的开销实现处理更多的请求。

select:每次调用select都需要把 fd 集合从用户区拷贝到内核区,让内核遍历 fd 集合,检查是否有事件发生,并对其进行标记,之后再把fd集合从内核区拷贝到用户区,再进行遍历,找出标记,因为没有内存存储结构,期间发生了两次拷贝和两次遍历,开销较大,并且存在最大 fd 数量限制,一般为 1024。

poll:和select基本相同,但采用了链表存储,没有最大fd数量限制。

epoll在内核中使用了红黑树来跟踪进程所有待检测的 fd,把需要监控的 fd 通过 epoll_ctl() 加入到内核的红黑树中,因此不用将 fd 重复拷贝进内核区,支持水平触发和边缘触发。

同时 epoll 采用事件驱动机制,内核里维护了一个链表来记录就绪事件,当事件发生时会通过回调函数将其加入到链表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的 fd 的个数。

边缘触发:当被监控的 fd 上有可读事件时,只会从 epoll_wait 中苏醒一次,不管有没有取到数据

水平触发:当被监控的 fd 上有可读事件时,不断地从 epoll_wait 中苏醒,知道内核缓冲区中的数据被读取完

你知道 Reactor 和 Proactor 的区别吗?

Reactor 和 Proactor 是两种 I/O 多路复用模型。

  • Reactor 模型中,事件循环只负责监听 I/O 是否就绪,数据读写还是由应用线程自己完成,是同步非阻塞方式,适合 Linux epoll。

  • Proactor 模型中,事件循环提交异步 I/O 后由内核负责完成,应用收到通知即可处理结果,是异步 I/O模型,常见于 Windows IOCP 或 Boost.Asio。

  • Reactor 灵活、适合高并发服务器;Proactor 简洁但依赖操作系统支持。

简单介绍一下 Reactor 和 Proactor

它们本质就是对 I/O 多路复用的封装,将处理连接部分与业务部分独立,提高了性能

Reactor = epoll + 事件回调:框架自己封装 epoll,然后让你注册处理函数。

比如:单 Reactor 多线程:主线程 Reactor 处理请求,将连接请求分发给 Acceptor 用于处理连接,将业务请求分发给多个 Handler 并使用线程池处理业务请求。

Proactor = 异步 I/O 调度 + 回调:框架封装异步系统调用(如 Windows IOCP、POSIX AIO),系统内核会自动完成读取工作,完成之后在通知应用进程。Linux 下的异步 I/O 是不完善的,aio 是由 POSIX 定义的异步操作接口,不是真正的操作系统级别支持的,而是在用户空间模拟出来的异步,仅仅支持本地文件的 aio 异步操作,不支持网络编程中的 socket,因此基于 Linux 的高性能网络都使用Reactor。

注意:Reactor 模式是基于待完成的 I/O 事件,需要自行处理数据, Proactor 模式则是基于已完成的 I/O 事件,不需要处理数据。

介绍一下 muduo 网络框架

muduo 是一个用 C++ 编写的高性能网络编程框架,封装了 Linux 下的 epoll 和多线程机制,采用 Reactor 模型(多 Reactor 多线程),核心模块包括 EventLoop、Channel、Poller、TcpServer、TcpConnection,用户只需要注册回调函数即可专注业务逻辑,适合构建高并发的 TCP 服务器。

它的主要特点有:

  • 基于 非阻塞 IO + epoll + 事件回调机制,提供高效的 IO 事件分发;

  • 提供了完整的 事件循环(EventLoop)、连接管理(TcpConnection)、线程池(EventLoopThreadPool) 等模块;

  • 框架设计清晰,使用现代 C++ 特性(如智能指针、std::function),易于扩展和二次开发;

  • 支持多 Reactor 模式,主线程处理连接,子线程处理业务;

  • 用户只需注册回调函数即可关注业务逻辑,不必操心 socket 管理和多路复用;

缺点:它并不内建粘包拆包机制、序列化机制,也没有提供 HTTP、RPC 等高层协议,需要手动实现;另外,它不是跨平台的,仅限 Linux。

muduo::Buffer 结构如下:

|---已读取区域---|---可读区域---|---可写区域---|
                ↑              ↑
           readerIndex     writerIndex

TCP 通信时如何处理粘包问题?

TCP 是面向字节流的协议,没有消息边界,可能发生以下问题:

粘包:多个消息连续发送,接收方一次 read() 收到多个

拆包:一个消息太长,被分为多次 read() 才能完整收到

常见的解决方案:定长消息头,前 4 字节:表示消息体长度,后面的字节表示实际消息内容

介绍一下 TCP 三次握手和四次挥手

三次握手

第一次:客户端发送给服务端,syn = 1 表示建立连接,同时也会发送 seq,消耗一个序号,不携带数据。

服务器确认了对方发送正常,自己接收正常

第二次:服务端收到第一次握手后,发送给客户端,syn = 1 表示建立连接,ack = seq + 1,同时也会发送 seq 消耗一个序号,不携带数据。

户端确认了:自己发送、接收正常,对方发送、接收正常

服务器确认了对方发送正常,自己接收正常

第三次:客户端发送 ack = seq + 1,发送 seq 消耗一个序号,可以携带数据。

客户端确认了:自己发送、接收正常,对方发送、接收正常;

服务器确认了:自己发送、接收正常,对方发送、接收正常

四次挥手

第一次:客户端发送 FIN = 1 给服务端,表示客户端不再发送数据,但是可以接收数据。

第二次:服务端发送 ACK 给客户端,表示已接收。

第三次:服务端处理完当前数据后发送 FIN = 1 给客户端,表示不再发送数据,此时客户端进入 TIME_WAIT 状态(一段时间后自动进入关闭状态),服务端进入 LAST_ACK 状态。

第四次:客户端发送 ACK 给服务端,表示已接收,此时服务端完全关闭。

为什么客户端进入 TIME_WAIT 后需要保留一段时间?

一般出现在主动关闭连接的一方

原因一:确保“最后的 ACK”能被对方收到

处于 TIME_WAIT 的客户端能重新发送 ACK,服务器接收到后会进入 LAST_ACK 状态。

原因二:防止“旧连接数据”影响新连接(防止端口重用问题)

假设一个 TCP 连接刚刚关闭,你立刻用相同的四元组(源 IP/源端口/目标 IP/目标端口)建立了新的连接,如果旧连接的残余数据包在网络中还未消失,可能被误认为是新连接的数据,导致数据混乱。 TIME_WAIT 确保旧连接的数据在网络中完全消失后,才能重用这个端口,保证新连接的正确性。

TCP 拥塞控制的四种算法分别做什么?

TCP 拥塞控制(Congestion Control)是确保网络在传输大量数据时不会发生“拥堵”的关键机制。它通过动态调整发送方的发送速率,在保证网络资源高效利用的同时避免过载

  • 慢启动:新连接时从小窗口开始,拥塞窗口(cwnd)每 RTT 指数增长,快速探测可用带宽;

  • 拥塞避免:当拥塞窗口(cwnd)达到门限 ssthresh(取上一个网络拥塞时的 ssthresh 的一半) 后,转为线性增长,避免过快增长导致拥塞;

  • 快重传:接收到 3 个重复 ACK 后立即重传丢失数据包;

  • 快恢复:丢包后不重新进入慢启动,而是减半拥塞窗口(cwnd)并进入拥塞避免,提升恢复效率。

介绍一下 TCP 流量控制

本质:让发送方发慢点,使得接收方来得及接收。

通过接收窗口实现,由接收方在 ACK 报文中通告给发送方。如果接收方缓冲区满了,就会通告 rwnd = 0,使发送方暂停发送。发送窗口min(cwnd, rwnd) 决定。这个机制确保了发送速度不会超过接收能力。

介绍一下 HTTPS 的加密过程

  1. 客户端(发送者)提交 HTTPS 请求
  2. 服务器(接收者)响应客户,并把信息通过证书公钥加密后发给客户端(此时产生了公钥和私钥,只把公钥给客户端)
  3. 客户端验证证书公钥的有效性
  4. 有效后,会生成一个会话密钥
  5. 用证书公钥加密这个会话密钥后,发给服务器
  6. 服务器收到证书公钥加密的会话密钥后,用证书密钥的私钥解密,获取会话密钥
  7. 客户端与服务器双方利用这个会话密钥加密要传输的数据进行通信

介绍一下 TLS 协议

TLS 是现代互联网通信的安全基础,负责加密、认证与完整性保护,是实现 HTTPS、加密邮件和 VPN 等安全通信的核心协议。

键入网址到网页显示,期间发生了什么?

1、浏览器做的第一步工作就是要对 URL 进行解析,从而生成发送给 Web 服务器的请求信息;

2、根据这些信息来生成 HTTP 请求消息;

3、客户端首先会发出一个 DNS 请求,发给本地 DNS 服务器,如果能在缓存中找到域名的 IP,则直接返回,如果没有,则本地 DNS 服务器去询问它的根域名服务器;

4、根 DNS 收到本地 DNS 的请求后,返回顶级域名服务器地址;

5、本地 DNS 再去访问 顶级域名服务器,顶级域名服务器返回权威 DNS 服务器地址;

6、本地 DNS 再去访问 权威 DNS 服务器,权威 DNS 服务器返回 IP 地址

HTTP 中 GET 和 POST 有什么区别

GET 是从服务器获取指定的资源,这个资源可以是文本、页面、图片、视频等,GET 的请求参数一般是写在 URL 中。GET 是等幂的,即多次执行相同操作,结果都是相同的,所以浏览器可以缓存 GET 请求。

POST 是根据请求负荷(报文 body)对指定资源做出处理,携带数据的位置一般是报文 body 中,可以为任意形式。POST 不是等幂的,因为可能会新增或提交一些新数据,会修改服务器上的数据,因此浏览器一般不会缓存 POST 请求。

介绍一下串口通信

串口通信是指数据一位一位按顺序在一条线上进行传输的方式(对比并口是多个位同时传输)。

串口通信需约定参数如波特率、数据位、停止位和校验方式。串口本质上是点对点的通信方式,不依赖网络协议,编程时可使用 /dev/ttySx 设备进行读写。调试串口是嵌入式系统开发中的重要手段。

特点:

  • 通信简单、成本低

  • 通信速率相对较低(9600bps~1Mbps 常见)

  • 可用于短距离点对点通信

  • 可以全双工(如 RS-232)或半双工(如 RS-485)

Qt 中可以使用 QSerialPort 类进行串口通信。

介绍一下 gRPC

RPC(Remote Procedure Call,远程过程调用)是一种通信机制,它允许像调用本地函数一样去调用远程服务器上的函数或方法,让分布式系统中的服务之间协作变得更简单。

非常适合微服务之间的通信

优点

  • 性能极高(HTTP/2 + Protobuf)

  • 自动生成代码,开发效率高

  • 原生支持认证、压缩、超时、负载均衡

  • 多语言支持,易于系统集成

  • 支持流式 RPC(适合音视频流、日志流)

缺点

  • 上手略复杂,需学习 Protobuf 和生成代码流程

  • 浏览器直接调用不便(需 gRPC-Web)

  • Protobuf 不如 JSON 可读,调试成本较高

介绍一下 CANOpen 协议

CANopen 就是对底层 CAN 总线的一种高层协议封装,是 CAN 总线上的一个应用层协议。

每个 CANopen 设备都维护一个“对象字典”,用于描述自身的功能、参数、状态。

优点:

  • 实时性强:基于 CAN 硬件中断,控制周期可达毫秒级

  • 高可靠性:硬件 CRC、错误校验机制

  • 资源占用低:适合低功耗 MCU 和实时设备

  • 设备模型标准化:对象字典 + 状态机 + PDO/SDO 协议

  • 抗干扰能力强:总线电平差分传输(CAN 特性)

缺点:

  • ❌ 带宽有限(CAN 总线最高 1Mbps,报文最大 8 字节)

  • ❌ 不支持远程通信(仅用于物理上连接的节点)

  • ❌ 配置复杂(对象字典、映射表、TPDO/RPDO 配置较多)

  • ❌ 学习曲线略高于 Modbus 等简单协议

------------------------------------QT---------------------------------------

介绍一下 GTK

GTK(GIMP Toolkit) 是一个用于创建图形用户界面(GUI)的开源跨平台开发工具包,最初为 GIMP 图像处理软件开发,如今已广泛用于 Linux 桌面应用。

介绍一下 Qt 的 D 指针和 Q 指针

D 指针通常使用 QScopedPointer d_ptr,通常在类中声明一个指向私有类的指针,所有私有数据成员和实现细节都放在私有类中。

在一个类的构造函数初始化列表中初始化 D 指针,并通过其访问私有类的成员函数

MyClass::MyClass() : d_ptr(new MyClassPrivate) {}
MyClass::~MyClass() {}

void MyClass::someFunction() {
    Q_D(MyClass); // 定义一个局部变量 d,指向 d_ptr
    d->someData = 10;
}

这样可以隐藏实现细节,私有的结构体可以随意改变,而不需要重新编译整个工程项目

q_ptr 是指向“外部类”的指针,用于私有类中访问其公共类(外部类)

介绍一下 Qt 的信号槽机制

元对象编译器 MOC 负责解析 signals、slot、emit 等标准 C++ 不存在的关键字,以及处理Q_OBJECT、Q_PROPERTY、Q_INVOKABLE 等相关的宏。

connect 的第五个参数:

  • Qt::AutoConnection: 默认值,使用这个值则连接类型会在信号发送时决定。如果接收者和发送者在同一个线程,则自动使用 Qt::DirectConnection 类型。如果接收者和发送者不在一个线程,则自动使用 Qt::QueuedConnection 类型。

  • Qt::DirectConnection:槽函数会在信号发送的时候直接被调用,槽函数运行于信号发送者所在线程。效果看上去就像是直接在信号发送位置调用了槽函数。这个在多线程环境下比较危险,可能会造成奔溃。

  • Qt::QueuedConnection:槽函数在控制回到接收者所在线程的事件循环时被调用,槽函数运行于信号接收者所在线程。发送信号之后,槽函数不会立刻被调用,等到接收者的当前函数执行完,进入事件循环之后,槽函数才会被调用。多线程环境下一般用这个。

  • Qt::BlockingQueuedConnection:槽函数的调用时机与 Qt::QueuedConnection 一致,不过发送完信号后发送者所在线程会阻塞,直到槽函数运行完。接收者和发送者绝对不能在一个线程,否则程序会死锁。在多线程间需要同步的场合可能需要这个。

  • Qt::UniqueConnection:这个flag可以通过按位或(|)与以上四个结合在一起使用。当这个flag设置时,当某个信号和槽已经连接时,再进行重复的连接就会失败。也就是避免了重复连接

介绍一下Qt中的内存管理机制

Qt 中主要通过父子关系来进行内存管理。

  • 当一个 QObject 派生对象被创建时,可以指定一个父对象(QObject* parent)。

  • 当父对象被销毁时,会自动销毁它的所有子对象(调用子对象的析构函数并释放内存)。

  • 使用 Qt::AutoConnection 或者 Qt::QueuedConnection 连接的信号与槽,不会延长对象生命周期。

  • 如果对象被销毁,Qt内部机制会自动将与其相关的连接断开,避免“悬挂指针”。

介绍一下 Qt 事件循环机制

事件循环是 Qt 中处理各种事件(比如鼠标点击、键盘输入、定时器超时、网络消息等)的核心机制。它不断地从事件队列中提取事件,并将它们分发到对应的对象进行处理。

可以通过重写 event(QEvent *event) 函数来捕获事件,例如

bool event(QEvent *event) override {
    if (event->type() == QEvent::MouseButtonPress) {
        qDebug() << "鼠标点击事件被捕获!";
        return true;  // 返回true表示事件已经处理
    }
    return QWidget::event(event);  // 其他事件继续交给父类处理
}

通过 installEventFilter(QObject *filterObj),可以让一个对象去监视另一个对象的事件,即使它不是那个对象的父类也可以

#include 
#include 
#include 

class MyFilter : public QObject
{
    Q_OBJECT
protected:
    bool eventFilter(QObject *watched, QEvent *event) override {
        if (event->type() == QEvent::KeyPress) {
            qDebug() << "捕获到键盘按下事件!";
            return true;  // 事件被拦截,不再继续传递
        }
        return QObject::eventFilter(watched, event);
    }
};


MyFilter *filter = new MyFilter(this);
someWidget->installEventFilter(filter);    // 在 MyFilter 类中监视 someWidget 的事件



介绍一下 MVD 框架

  • 模型(Model) :模型负责管理数据,并提供数据的访问接口。Qt中有一些预定义的模型类,如 QAbstractTableModelQSqlTableModel 等,也可以自定义模型类来满足特定的数据结构和需求。
  • 视图(View) :视图是用于显示模型中的数据的部件,常见的视图类包括 QTableViewQTreeViewQListView 等。视图负责数据的可视化,并提供用户与数据进行交互的接口。
  • 代理(Delegate) :代理用于控制数据的显示方式和编辑行为。通过自定义代理类,可以实现对数据的定制化显示和编辑效果。

------------------------------------数据库------------------------------------

执行一条 MySQL 语句的流程是怎样的?

1、如果是查询语句 select,会先查询缓存(MySQL 8.0 后已经删除了缓存),命中则返回;

2、未命中则在解析器中解析语法,生成语法树;

3、通过预处理、优化后执行 SQL 语句。

介绍一下 MySQL 的存储

MySQL 使用 InnoDB 存储引擎,使用 B+ 树来组织数据,树中每一层通过双链表进行连接,非叶子节点存储索引,叶子节点存储数据。

InnoDB 一般使用 Compact 行格式,Compact 行格式的首端会记录三个重要信息:变长字段长度列表、NULL 值列表、记录头信息

变长字段长度列表:存储 varchar 类型的列占用的字节数;

NULL 值列表:大小为 1 KB,每个位为1表示该列的值为 NULL,为0表示该列的值不为 NULL;

记录头信息:delete_mask 为1时表示此条数据被删除,next_record 记录下一条记录的位置(记录之间通过链表组织起来)等。

注意,一行记录最大能存储 65535 字节的数据,具体存储大小还要减去 变长字段长度列表 NULL 值列表。

MySQL 并行事务会引发什么问题?

脏读:一个事务读到了另一个“未提交事务修改过的数据“

不可重复读:在一个事务内多次读取同一个数据,出现了前后数据不一致的情况

幻读:在一个事务内多次查询符合某个条件的记录数量,出现了前后数量不一致的情况。

MySQL InnoDB 引擎的默认隔离级别是可重复读,此时可能会发生幻读,不可能发生脏读和不可重复读。

------------------------------------算法-----------------------------------------

冒泡排序

稳定,即相同大小的元素排序后不会改变其前后顺序。

最好时间复杂度:O(n),原数组(链表)有序时,比较次数 = n - 1,交换次数 = 0

最坏时间复杂度:O(n^2),原数组(链表)逆序时,比较次数 = n(n - 1) / 2 = 交换次数

for(int i = 0; i < n - 1; ++i)
    {
        bool flag = false;
        for(int j = n-1; j > i; --j)
        {
            if(a[j] < a[j - 1])
            {
                swap(a[j], a[j - 1]);
                flag = true;
            }
        }

        if(!flag)
        {
            break;
        }
    }

快速排序

不稳定,即相同大小的元素排序后可能会改变其前后顺序。

递归层数可以看作是二叉树的高度,当枢纽元素可以均匀地将数组(链表)划分为左右两个不分时,高度最小为  log_{2}n,不均匀时最大为 n

时间复杂度:O(n * 递归层数)

空间复杂度:O(递归层数)

// 返回枢纽元素的位置
int Partition(int a[], int low, int hight)
{
    int pivot = a[low]; // 选择第一个元素作为枢纽

    while(low < hight)
    {
        while(low < hight && a[hight] >= pivot)
        {
            --hight;
        }

        a[low] = a[hight];

        while(low < hight && a[low] < pivot)
        {
            ++low;
        }

        a[hight] = a[low];
    }

    a[low] = pivot;

    return low;
}

void quickSort(int a[], int low, int high)
{
    if(low < high)
    {
        int pivotpos = Partition(a, low, high);
        quickSort(a, low, pivotpos - 1);
        quickSort(a, pivotpos + 1, high);
    }
}

二分查找(折半查找)

int binarySearch(int a[], int n, int key)
{
    int low = 0;
    int high = n - 1;
    int mid;

    while(low <= high)
    {
        mid = (low + high) / 2;
        if(a[mid] == key)
        {
            return mid;
        }
        else if(a[mid] > key)
        {
            high = mid - 1;
        }
        else
        {
            low = mid + 1;
        }
    }

    return -1;
}

------------------------------------简历---------------------------------------

介绍上位机项目的通用模板

使用 Qt C++ 作为开发工具,采用了 MVD 架构实现表格数据显示与逻辑分离,在界面展示方面,基于 QAbstractTableModel,使用 QTableView 进行表格数据的呈现,并自定义了 QStyledItemDelegate,实现了复杂单元格的渲染与编辑逻辑。例如,根据不同的数据类型动态切换编辑控件(如文本输入、下拉框、日期选择器等),从而提升了用户交互体验。

数据交互方面,通过 MQTT协议 与中位机建立实时通信,实现了设备状态监控、指令下发与数据同步。我对 MQTT 客户端进行了封装管理,支持断线重连、消息主题订阅与发布。

项目中有一些简单的数据,例如日志等不涉及复杂数据结构的表,我选用了 QSqlTableModel 进行单表映射。这种方式可以将数据库表直接映射为模型对象,极大地简化了数据库操作。具体来说,我通过调用 select()insertRow()setData()setSort()submitAll() 等 API,实现了数据的增删改查功能。

一些复杂的数据则是使用 QSqlDatabaseQSqlQuery 来执行自定义 SQL,根据每个线程的线程ID,分别创建和管理独立的 QSqlDatabase 连接对象,通过事务保证了线程间数据库操作的安全性。

比如,用户可以直接在 QTableView 中编辑单元格内容,系统在内部自动同步到模型中,我只需要统一调用 submitAll(),就可以将所有改动一次性提交到数据库,大幅度简化了业务逻辑和代码量。

~面试官提问:为什么选择 MVD 架构?相比传统 MVC 有什么优势?

我选择 MVD 架构,主要是项目中涉及到了一些复杂表格数据,不同数据的显示方式有一定的要求,比如下拉框、输入框、勾选框。

相比传统 MVC(Controller 比较重,界面逻辑和业务逻辑容易耦合在一起),MVD更细粒度

  • 更方便地自定义单元格外观(比如不同单元格使用不同的控件);

  • 更容易实现数据与界面同步,比如 Model 数据变化,View 可以自动响应;

  • 便于后期维护和扩展,比如新增一种新的渲染方式,只需要修改 Delegate,而不影响其他模块。

  • 而且Qt 本身的控件体系(比如 QTableViewQTreeView)就是基于 MVD 架构设计的

~面试官提问:QStyledItemDelegate 和 QItemDelegate 有什么区别?为什么要自己写 Delegate?

QStyledItemDelegateQItemDelegate 都是 Qt 中用于自定义 View(比如 QTableViewQTreeView)中单元格的渲染和编辑行为的。

QStyledItemDelegate 更现代、符合Qt样式系统,自定义Delegate 主要是为了让表格界面既美观又符合实际业务逻辑。

~面试官提问:在 QTableView 中如何实现单元格动态变化/代理类的变化

创建一个继承于 QStyledItemDelegate 类的自定义代理类,之后要在 QTableView 中使用 setItemDelegateForColumn 函数设置自定义代理,并重写 createEditorsetEditorData 改变自定义代理类的具体表现,并且通过重写 setModelData 函数将改变提交到 QAbstractTableModel 类或者其派生类中,实现视图与模型数据的统一。

~面试官提问:为什么选择 MQTT 作为通信协议

MQTT 协议本身非常轻量,相较于HTTP 和 WebSocket,MQTT的数据包头非常小,特别适合资源受限的设备或需要频繁通信的场景,比如我项目中的中位机通信。

而且 Qos = 2 时,传输机制为仅一次(最安全,保证不丢不重),HTTP 和 WebSocket 本身并没有内置这种传输质量控制,需要自己额外处理重发或确认。

MQTT 基于 Pub/Sub 模式,天然支持一对多、多对多的数据通信,可以支持多台客户端对设备信息的监控和数据处理。

~面试官提问:怎么处理 MQTT 断线重连?有做心跳机制吗?

在连接 MQTT Broker 前,我通过 QMqttClient::setKeepAlive(60) 设置了心跳间隔,比如60秒。

这样客户端会定时发送心跳包(PINGREQ),Broker响应 PINGRESP,双方可以及时检测连接是否活跃。
如果心跳失败,Broker可以及时断开连接,客户端也可以根据 disconnected() 信号触发自动重连机制。

~面试官提问:如果需要多表联合查询,还能用 QSqlTableModel 吗?怎么处理并发操作数据库时的冲突?

在需要多表联合查询的场景下,我使用了 QSqlDatabaseQSqlQuery 来执行自定义 SQL 查询。

  • 通过 QSqlQuery 可以直接编写多表 JOIN 查询,灵活处理复杂数据结构。

  • 同时,由于项目中存在多线程访问数据库的需求,我根据每个线程的线程ID,分别创建和管理独立的 QSqlDatabase 连接对象,保证了线程间数据库操作的安全性(因为Qt的数据库连接对象不是线程安全的)。

  • 每个线程内部通过自己的 QSqlDatabase 执行 QSqlQuery,完成查询,并将结果映射到界面上展示,确保了查询的正确性和系统的稳定性。

介绍中位机项目的通用模板

主要使用 C++ 进行开发。项目中通过MQTT协议与上位机实现稳定通信,负责多个下位机的控制任务,采用 CANopen 协议与其进行数据交互。我对 CanFestival 协议栈源码进行了封装与改造,并将其构建为公司内部可复用的通用库,提升了开发效率和代码维护性。为实现多个部件的并行调用,引入了 QThreadPool 线程池机制,有效优化了系统响应速度和资源利用率。系统还具备故障报警、设备状态实时上报等功能,为上位机提供全面的数据支持和异常处理能力。

将多个下位机设备封装成类,对外提供调用接口,在每个类中重写控制逻辑,调用底层驱动。

~面试官提问:请简单介绍一下canopen协议,为什么选用canopen协议?

CANopen 是基于 CAN 总线的高层通信协议,广泛应用于工业自动化、机器人、医疗设备等嵌入式场景。

它的核心特点有:

  1. 标准化对象字典(Object Dictionary):统一的数据结构管理机制,使得不同设备间的数据交换变得规范且易于扩展,。

  2. 支持多种通信机制:包括 PDO(Process Data Object,实时数据)、SDO(Service Data Object,配置数据)、NMT(网络管理)、Heartbeat 等,便于实现设备的状态监控和配置管理。

  3. 轻量、实时性强:协议开销小,传输效率高,适合嵌入式控制系统,特别是在对实时性有要求的应用中。

  4. 多设备协调控制能力强:通过Node-ID机制和优先级调度,方便多个下位机设备在同一总线中协调工作。

~面试官提问:CanFestival协议栈你做了哪些方面的封装和修改?是如何设计的?是否解决了什么具体问题?

当时封装了两种类型,一种是以太网转 can,另一种是 usb 转 can,以适应不同场景下的需求。

以太网转 can 需要周立功公司提供的CANNET盒以及编译好的库,然后我在此基础上将这个库、CanFestival 源码、以及源码调用层进行一个封装。

大概的调用步骤为:初始化计时器 ----> 初始化 SDO 客户端 ---> 调用源码 canOpen 函数打开can ---> 调用zlg 的 canOpen_driver,通过 socket 保证网口端的连接正常 ---> 启动计时器监控每个 node_id 心跳和返回 ---> 注册回调函数监控每个动作的上报。

usb 转 can 同理,只是将调用zlg 的 canOpen_driver 这一步改为了调用 usb版本的 canOpen_driver。

目前我熟练掌握 linux/windows 平台下 C++/python中canFestival 的部署和控制。

~面试官提问:CANOpen 中 PDO 和 SDO 的区别?你在项目中是如何使用它们的?

PDO 属于过程数据用来传输实时数据,即单向传输,无需接收节点回应 CAN 报文来确认。

SDO 主要用于 CANopen 主站对从节点的参数配置,为每个消息都生成一个应答,确保数据传输的准确性。

因此我们当时选用的是 SDO。

SDO中,发送方(客户端)发送 CAN-ID 为 600h + Node-ID 的报文, 其中 Node-ID 为接收方(服务器)的节点地址,数据长度均为 8 字节; 接收方(服务器)成功接收后,回应 CAN-ID 为 580h + Node-ID 的报文。这里的 Node-ID 依然是接收方(服务器)的节点地址,数据长度均为8字节

其中第一个字节表示命令符,0x2F表示写一个字节,0x2B表示写两个字节,0x27表示写三个字节,0x23表示写四个字节,0x60表示写成功应答,0x40表示读取等等。

~面试官提问:如何处理 CAN 总线冲突或带宽占满的情况?有没有做过优化?

这个没参与过,不过知道大概流程。

CAN总线冲突和带宽占满主要表现在通信延迟变大、PDO报文丢失以及部分节点响应超时。

首先通过使用笔记本连接 CAN 分析仪(CanTest等工具)对总线流量进行抓包,然后观察具体发送流程,看一下是不是重复发送数据了呀,或者是有消息超时了,一直在报80错误,然后对不合理的 node_id 进行重新分配等。

~面试官提问:为什么选择 QThreadPool 而不是 QThread 或std::thread ?

因为QThreadPool和QRunnable用起来方便,使用线程池技术,可以复用线程资源,避免每次任务都创建/销毁线程所带来的性能开销。

~面试官提问:系统如何处理下位机设备的异常或掉线?有没有重连机制?

在开启 can 通信时注册过心跳回调函数,可以通过回调函数来判断,当回调函数被触发,中位机会主动获取一次该 node_id 设备的状态,一般为 0x1000 0x00 处的存储值,如果获取失败,则确定为设备离线,此时会向上位机发送故障信息用于 UI 显示。

~面试官提问:QThreadPool 的最大线程数你是如何设置的?怎么评估合适的线程数?

首先获取系统的逻辑核心数作为参考值,使用 QThread::idealThreadCount() 来动态获取CPU核心数量。通常会以 核心数 × 1.5 ~ 2 的范围作为上限估算,既避免线程竞争过多,又能提升并发利用率。

介绍 QML C++ 项目的通用模板

这个工具是一个Qt桌面应用,前端使用 QML 开发界面,后端用 C++ 实现业务逻辑。主要实现了设备在线检测、参数设置、状态读取和故障诊断等功能。     

技术上,我重点使用了 QML 与 C++ 的交互机制,通过继承 QObject 并使用 Q_INVOKABLE 和属性绑定,让 QML 页面可以直接访问 C++ 的方法和数据,同时也用到了信号槽来做界面状态同步,确保了界面响应的实时性和稳定性。

底层通信依然使用 CanFestival 协议栈,我对其做了二次封装,用于统一管理多个下位机的 SDO 通信。调试工具可以并行操作多个节点,大大提高了固件调试效率。

这个工具已经在我们团队内部广泛使用,帮助我们节省了大量调试时间,也让我积累了实际的 QML 项目开发经验。

~面试官提问:如何使 QML 和 C++ 协同工作? 

整体思路:QML 中有一个单例类用来管理数据,比如当前选中的设备、是否连接成功等,并通过这个类与 C++ 进行交互,可以在 QML 单例类中调用 C++ 函数,C++ 中同样有一个用交互的类。

1、在 QML 中通过 setContextProperty 注册 C++ 类

通过 setContextProperty 直接将一个对象实例绑定到一个全局可访问的属性名上,这使得它在所有 QML 文件中都可以直接通过给定的属性名访问。

//main.cpp
QQmlApplicationEngine engine;
engine.rootContext()->setContextProperty("MyObject", MyObject::getInstance());



//.qml
import QtQuick
import QtQuick.Controls
 
Window {
    id: window
    width: MyObject.iValue    //iValue为访问私有变量iValue的public函数
    height: 480
    visible: true
    title: qsTr("Hello World")
}

2、在 QML 中通过 qmlRegisterSingletonInstance 注册 C++ 类

通过 qmlRegisterSingletonInstance 注册一个C++类,这个类在 QML 中以模块的形式出现,可以像使用 QML 的其他类型那样使用它,在 QML 中直接调用的 C++ 函数需要加上关键字 Q_INVOKABLE。

//.cpp
#include 


RegisterToQml::RegisterToQml(QObject *parent)
    : QObject{parent}
{

    //注册C++类
    qmlRegisterSingletonInstance("DataHandler", 1, 0, "DataHandler", DataHandler::getInstance());
}

// DataHandler.cpp
class DataHandler : public QObject
{
    Q_OBJECT
public:
    explicit DataHandler(QObject *parent = nullptr);
    static DataHandler *getInstance();

    /***************************QML接口函数***************************/

    Q_INVOKABLE void loadingDevicesConfig();

}

//.qml
import DataHandler 1.0

function changeCurrentConnectionType(canChecked) {
    // 调用 DataHandler 中的函数
    DataHandler.loadingDevicesConfig()
}



3、QML 和 C++ 的数据交换

最好使用 QVariant、QVariantMap、QVariantList 等来传递数据,如果涉及到结构体,需要通过 qRegisterMetaType 函数注册,使用 Q_PROPERTY 注册成员函数,结构体声明完了要加上 Q_DECLARE_METATYPE(Data)。


qRegisterMetaType("Data");


// 结构体声明
struct Data {

    Q_GADGET
    Q_PROPERTY(int32_t can_id MEMBER can_id)
    Q_PROPERTY(int32_t heatlid_kp MEMBER heatlid_kp)
    Q_PROPERTY(int32_t heatlid_ki MEMBER heatlid_ki)
    Q_PROPERTY(int32_t heatlid_kd MEMBER heatlid_kd)
    Q_PROPERTY(int32_t block_kp MEMBER block_kp)
    Q_PROPERTY(int32_t block_ki MEMBER block_ki)
    Q_PROPERTY(int32_t block_kd MEMBER block_kd)
    Q_PROPERTY(int32_t lid_move_distance MEMBER lid_move_distance)
    Q_PROPERTY(int32_t baundrate MEMBER baundrate)
    Q_PROPERTY(int32_t block1 MEMBER block1)
    Q_PROPERTY(int32_t block2 MEMBER block2)
    Q_PROPERTY(int32_t block3 MEMBER block3)
    Q_PROPERTY(int32_t heatLid MEMBER heatLid)
    Q_PROPERTY(int32_t radiator MEMBER radiator)
    Q_PROPERTY(int32_t k MEMBER k)
    Q_PROPERTY(int32_t b MEMBER b)

public:
    int32_t can_id = -1;
    int32_t heatlid_kp = -1;
    int32_t heatlid_ki = -1;
    int32_t heatlid_kd = -1;
    int32_t block_kp = -1;
    int32_t block_ki = -1;
    int32_t block_kd = -1;
    int32_t lid_move_distance = -1;
    int32_t baundrate = -1;
    int32_t block1 = -1;
    int32_t block2 = -1;
    int32_t block3 = -1;
    int32_t heatLid = -1;
    int32_t radiator = -1;
    int32_t k = -1;
    int32_t b = -1;
};
Q_DECLARE_METATYPE(Data)

~面试官提问:Q_INVOKABLE 和 Q_PROPERTY 的区别和使用场景?

Q_INVOKABLE:用于暴露函数(方法),让某个 C++ 成员函数在 QML 中可以被直接调用。

Q_PROPERTY:用于暴露属性(数据),把某个成员变量包装成可绑定的“属性”,支持读写方法、通知信号。QML 可以直接进行属性绑定,UI 会在数据变化时自动更新。

~面试官提问:在 QML 页面中如何异步访问 C++ 操作?

有一种方法是:通过在 QML 中调用 C++ 函数,并通过 QtConcurrent::run 进行实现,并通过 QFutureWatcher监视其返回值,当触发 QFutureWatcher::finished 信号时发送信号,并在 QML 中接收信号。

Connections {
        target: DataHandler
        function onParseDevicesConfigCompleted(result) {

        }
    }

介绍 Python 升级工具的模板

这个项目主要用于管理和升级公司设备的上位机、中位机以及多个固件模块。支持查看各个模块的版本,并且可以进行升级/回退。

上位机、中位机通过 makeself 工具打包为 sh 脚本,其中包括了升级文件安装脚本,固件模块则是通过 CANOpen 协议进行升级。

并且设计了失败回滚机制,如果升级过程中出现异常,可以恢复到升级前的版本。

目前工具有两种形式,一种是持久运行在后台,当用户启动软件时其实启动的是升级工具,当升级工具检测到目标文件夹中存在升级脚本时,与主程序进行通信,告知有可升级的版本,再显示到前端,由用户判断是否升级。

另一种则不需要修改上位机、中位机的代码,升级工具以独立软件运行,通过用户点击按钮后唤醒。

你可能感兴趣的:(c++,开发语言,数据结构,面试,qt,mysql,linux)