Linux C++ 面试手册(项目追问 + 基础知识 + 系统设计)
更新:2026-09-09。适合做过项目、准备 Linux C++ / 系统软件岗面试的人。
本文只管面试:项目怎么被追问、基础知识怎么讲清楚、设计题怎么答。不需要从头读完,按需要跳转。
阅读导航
| 你现在要做什么 | 直接跳转 |
|---|---|
| 先看四档材料怎么用 | 怎么用后面的面试材料 |
| 准备项目追问 | 第一档:简历上写了,一定会被追问 |
| 补基础知识 | 第二档:通用基础题 |
| 面试前查缺 | ⬜ 待填清单 |
每章固定三段:① 原来有什么问题 → ② 这个东西怎么解决 → ③ 📌 面试怎么答。卡在某个名词上,用「怎么用后面的面试材料」里的名词索引表跳转。
配套:
docs/profile.md(事实底稿)、resume/resume_cpp_v2.md(简历)、resume/wuhan_cpp_boss_jobs_2026-09-04.md(岗位调研)
怎么用后面的面试材料
下面四档用于复习和项目追问,四档的用法分别是:
| 档位 | 内容性质 | 本文提供 |
|---|---|---|
| 第一档 · 项目追问 | 你自己的代码,只有你知道 | 项目是什么(零基础)+ 面试官想验证什么 + 答题框架 + ⬜ 待填 |
| 第二档 · 基础知识 | 语言与系统知识;部分结论受版本、平台、配置和前提影响 | 概念讲解 + 面试答题参考,标 📌 的用于练习表达 |
| 第三档 · 短板应对 | 话术 | 可直接用的答法 |
| 第四档 · 系统设计 | 开放题,没有标准答案 | 从零讲背景 + 答题框架 + 失败路径(高级岗的分水岭) |
第一档里凡是标 ⬜ 的地方,必须你自己回去翻代码填。不要背我编的实现细节——
面试官往下追一层就穿,比答不上来伤害大得多。
本文讲解先补背景,再进入追问
每个知识点固定三段: ① 原来有什么问题 → ② 这个东西怎么解决 → ③ 📌 面试怎么答。 这些背景解释有助于复习,但不构成完整的入门课程:某个前置概念完全没听过时,先去补那一块再回来。
- 基础生疏:从第二档 2.1.0 顺着读,先写代码验证再看答题部分;不要从项目追问一路硬读
- 已经掌握、快速复习:用标 📌 的段落自测,并补充结论的适用条件,不逐字背成通用答案
- 卡在某个名词上:查下面这张表
卡住的词 去哪看 项目类 终端客户端 / SSH / Telnet / 串口 / SFTP 1.0 NAPI / ArkTS / xterm.js / libssh2 1.0 分区 / 镜像 / 全量包 / 差分包 / flashd / hdc 2.0 变砖 / 连续升级 / 签名 vs 哈希 2.0、2.2 wheel / manylinux / glibc vs musl 3.0 C++ 类 栈 / 堆 / 内存泄漏 / 野指针 / 悬垂指针 2.1.0 所有权 / 智能指针怎么选 / 循环引用 2.1.1 RAII / 异常安全 2.1.2 左值右值 / move / 完美转发 2.1.3 多态 / vtable / 虚析构 2.1.4 内存序 / atomic / volatile 2.1.7 系统类 用户态 / 内核态 / 系统调用 2.2.0 进程 vs 线程 / 虚拟地址空间 2.2.1 页 / 页表 / 缺页异常 / 代码段 / 段错误 / TLB 2.2.1 的「内存是怎么被管起来的」 上下文切换 2.2.1 fork / 写时复制 / 僵尸进程 / 守护进程 / 信号 2.2.2 - 2.2.4 符号 / 静态库 / 动态库 / ELF / RPATH 2.2.5 页缓存 / mmap / fsync 2.2.6 文件系统 / 挂载 / GPT 2.2.7、2.3(第一档) 并发与网络 数据竞争 / 临界区 / 条件变量 2.3.0、2.3.1 线程池 / 死锁 / CAS / false sharing 2.3.2 - 2.3.5 socket / 四元组 / TCP vs UDP 2.4.0 握手挥手 / TIME_WAIT / 粘包 2.4.1 - 2.4.3 fd / epoll / ET vs LT / Reactor 2.4.4、2.4.5、4.2 心跳 / 超时 / 指数退避 2.4.6 进程隔离 / IPC / 共享内存 2.2.1、4.6 工程类 构建系统 / CMake / 交叉编译 / sysroot 2.5.1、3.2 rebase vs merge 2.5.2 core dump / backtrace / ASan / 火焰图 2.5.3 单元测试 / 依赖注入 / mock 2.6(第一档) 零散名词 句柄 / 调用约定 / ABI / 名字改编 / 符号版本 1.7、2.5、3.1(第一档) 内存对齐 / SIMD / NEON 3.3(第一档) 均摊复杂度 / 红黑树 / cache line / 分支预测 2.1.4、2.1.6 编译单元 / 实例化 / 仿函数 / 空基类优化 2.1.1、2.1.5、2.2.5 自旋锁 / futex / 可重入 / 内存屏障 / 背压 2.1.7、2.3.2 - 2.3.6 inode / 块设备 / 预读 / 零拷贝 2.1、2.3(第一档)、2.2.6 大端小端 / 字节序 / 位图 2.4.3、2.4.4
答不上来的标准回答:
「这块当时是 XX 同学做的 / 我没深入到这一层。我的理解是……,如果让我做我会……」
承认边界 + 给出你的判断,比硬编强。
第一档:简历上写了,一定会被追问
这一档也按零基础重写了。 每个项目先用大白话讲清「这东西到底是什么、 为什么要做」,再讲面试官想验证什么、你该怎么答。
⚠️ 但有一点和第二、四档不同:这里的技术背景我可以给, 但「你当时具体怎么做的」只有你自己知道。 凡是标
⬜的地方必须回去翻代码填,不要背我写的实现细节—— 面试官往下追一层就穿,比答不上来伤害大得多。
1. CrossShellNext
1.0 前置:这个项目到底是什么
一句话
它是一个终端客户端——就是运维和开发人员用来连服务器、敲命令的那种软件。 Windows 上最有名的是 PuTTY 和 Xshell,Mac 上是 iTerm2。 你做的是鸿蒙 PC 上的那一个,从零开始。
你的电脑(跑 CrossShellNext) 远程服务器 / 嵌入式设备
┌──────────────────┐ ┌──────────────┐
│ $ ls -l │ ←── 网络 ──→ │ Linux 系统 │
│ total 24 │ │ │
│ $ █ │ └──────────────┘
└──────────────────┘
你在这里敲的每个字符发过去,
对面执行完把结果发回来显示
它支持的四种”连法”,各是什么
| 协议 | 干什么用的 | 通俗理解 |
|---|---|---|
| SSH | 加密地连远程服务器敲命令 | 现在的标准做法,所有内容都加密 |
| Telnet | 连远程服务器敲命令,但不加密 | SSH 的老前辈,明文传输,现在只在内网老设备上还有 |
| Serial(串口) | 通过一根物理线缆连设备 | 设备网络还没起来时唯一的通道,嵌入式开发的命根子。 刷机、看启动日志全靠它 |
| SFTP | 在本机和远程之间传文件 | 跑在 SSH 之上的文件传输,可以浏览远程目录、上传下载 |
💡 为什么一个软件要支持这四种:因为运维和嵌入式开发的实际工作就是这四件事。 设备起不来看串口,起来了用 SSH 进去,老设备只有 Telnet,要传文件用 SFTP。
为什么是”从 0 到 1 重新设计”,而不是移植 PuTTY
这是你项目最重要的一个决策,面试一定会问:
- PuTTY 是纯 C 写的,而且和 Win32 API 深度耦合—— 它的网络代码、界面代码、事件循环全搅在一起,没有清晰的分层
- 想移植就得把 Win32 的每个调用都找一个鸿蒙的对应物, 等于把整个项目重写一遍,还要背着 20 年的历史包袱
- 所以判断是:不移植,重新设计,但可以复用它的协议知识和交互设计
📌 这个判断本身就是你在德科总结的三条迁移路线里的第三条 (「完全重新设计」),见 1.4 章。 面试时把这两处串起来讲,说明你有方法论,不是碰运气。
鸿蒙这一侧的三个词
你的技术栈里有几个鸿蒙特有的名词,先搞清楚:
- ArkTS:鸿蒙应用的开发语言,本质上是 TypeScript 的一个方言。 写界面用它,跑在一个 JS 引擎里。
- NAPI(Node-API):让 C++ 代码和 JS 代码互相调用的一套 C 接口。 你的协议逻辑是 C++ 写的,界面是 ArkTS 写的,中间必须靠 NAPI 打通。 (名字来自 Node.js,鸿蒙沿用了这套接口。)
- xterm.js:一个用 JS 写的终端模拟器组件。 它负责把服务器传回来的一堆字节(包含各种控制字符,比如「光标上移一行」 「把这段文字变成红色」)渲染成你看到的终端画面。 你不用自己实现终端渲染,交给它。
你敲键盘 → xterm.js 捕获 → ArkTS → NAPI → C++ 协议层 → 网络 → 服务器
服务器输出 → 网络 → C++ 协议层 → NAPI → ArkTS → xterm.js 渲染 → 你看到
libssh2 是什么
一个 C 语言写的 SSH 客户端库。SSH 协议非常复杂(密钥交换、多种加密算法、 认证、多路复用……),没人会自己从头实现,都是用现成的库。 你用的就是它。
⚠️ 面试官会追问的两个 libssh2 特性(后面 1.6、1.7 会展开): ① 它的 session 对象不是线程安全的; ② 它在非阻塞模式下很多函数会返回
EAGAIN要求你重试。 这两条决定了你的架构。
代码地图:后面的结论都能落到文件和行号
1.1—1.8 里标 ✅ 的事实,来自 2026-09-06 对
gitcode.com/OpenHarmonyPCDeveloper/CrossShellNext的源码核查 (详见同目录的crossshellnext-code-analysis-2026-09-06.md,行号对应那次克隆的版本)。 标⬜的仍然需要你自己回去确认,不要拿我写的实现细节去面试。
自写 C++ 约 1.4 万行(不含 entry/libs/include 下的 openssl / libssh2 头文件):
entry/src/main/cpp/
├── protocol/
│ ├── Session.h 抽象基类 + 待投递消息缓存
│ ├── SessionFactory/ 按协议类型创建
│ ├── SessionManager/ 单例,map<int, shared_ptr<Session>>
│ ├── ssh/
│ │ ├── SshSession.cpp 2170 行 含内联的 ThreadPool 实现
│ │ ├── SftpSession.cpp 2048 行 含 keepAliveThread
│ │ ├── SshPortForward.cpp 1827 行 本地 / 远程 / 动态三种转发
│ │ └── LogManager.cpp 688 行
│ ├── telnetSession.cpp 1151 行 自建 socket + 非阻塞 connect
│ └── serialSession.cpp 634 行
├── napiSftp.cpp 2349 行
├── napiInterface.cpp 2007 行
├── napiPortForward.cpp 555 行
├── key_manager.cpp OpenSSL 密钥生成,RAII 包装
└── command/ jsonAnalyse/
📌 报规模要报这个:不是”做了个终端”,而是 「协议层 1.4 万行自写 C++,SSH 单文件 2170 行,四种协议 + 三种端口转发」。 数字具体,可信度完全不一样。
1.1 三层架构
先搞清楚:为什么要分层
“分层”的意思是:把程序切成几块,规定每块只能依赖它下面那块。
不分层会怎样?所有代码搅在一起:处理 SSH 协议的函数里直接调用了刷新界面的函数, 于是——
- 想给协议逻辑写单元测试?跑不了,因为它一执行就要去找界面
- 想换个界面框架?协议代码全要改
- 出了 bug,不知道是协议问题还是界面问题
你的三层是这样切的:
┌───────────────────────────────────────────┐
│ ArkTS UI 层 │ 画界面、响应点击和键盘
│ (xterm.js 渲染终端画面) │
├───────────────────────────────────────────┤
│ NAPI 桥接层 │ 只干三件事:
│ │ ① 线程切换 ② 生命周期 ③ 类型转换
├───────────────────────────────────────────┤
│ C++ 协议层 │ SSH/Telnet/Serial/SFTP 怎么说话
│ (平台无关,不知道鸿蒙的存在) │ 纯 C++,能脱离鸿蒙编译和测试
└───────────────────────────────────────────┘
依赖方向:上层依赖下层,下层【绝不】反向依赖上层
✅ 对应到实际文件:
| 层 | 文件 |
|---|---|
| UI | ArkTS 侧,用 xterm.js 渲染 |
| NAPI 桥接 | napiInterface.cpp 2007 行、napiSftp.cpp 2349 行、napiPortForward.cpp 555 行 |
| C++ 协议 | protocol/ 整个目录:Session.h + SessionFactory/ + SessionManager/ + ssh/ + telnetSession.cpp + serialSession.cpp |
数据在两个方向上怎么流(这张图能画出来,1.1 就过了):
%%{init: {"flowchart": {"htmlLabels": true, "nodeSpacing": 40, "rankSpacing": 46}}}%%
flowchart TD
subgraph JS["JS 线程(单线程)"]
UI["ArkTS UI + xterm.js"]
end
subgraph BR["NAPI 桥接层"]
DOWN["napi_async_work<br/>投递任务"]
UP["napi_threadsafe_function<br/>投递数据回 JS 线程"]
end
subgraph CPP["C++ 工作线程"]
PROTO["协议层<br/>SSH / Telnet / Serial / SFTP"]
end
NET(["网络 / 串口"])
UI -->|"① 你敲键盘"| DOWN
DOWN -->|"② 交给线程池"| PROTO
PROTO -->|"③ 阻塞 write"| NET
NET -->|"④ 阻塞 read 到数据"| PROTO
PROTO -->|"⑤ 排队投递"| UP
UP -->|"⑥ JS 线程取出"| UI
🔑 看这张图要抓住一件事:⑤ 那个箭头是唯一的跨线程点。 C++ 线程绝不能直接调 JS 回调,必须经
threadsafe_function排队, 由 JS 线程自己来取。原因见 1.8。
📌 面试官想验证什么
你是真设计过分层,还是跟着模板抄的。 核心考点是:边界在哪、为什么在这。
判断方法很简单——面试官会问「协议层能不能知道 NAPI 的存在」, 你要能立刻说出为什么不能,而且理由要是工程上的,不是”书上说要解耦”。
答题框架:三个问题答清楚这题就满分
① 协议层为什么不能知道 NAPI 的存在?
「因为平台无关是可测试性的前提。协议层不依赖任何鸿蒙的东西, 我就能在 PC 上用 gtest 直接测它——起一个本地 SSH 服务, 跑连接、认证、收发、异常这些用例,几秒钟出结果。 如果协议层里混进了 NAPI 调用,测它就必须起一个完整的鸿蒙应用环境, 那单元测试基本就写不动了。」
⬜ 待填:协议层有没有真的做过脱离 NAPI 的单测?
有就是明确的加分项,一定要说出来;没有就说「当时没做,但架构上是留了口子的」。
② 桥接层到底承担什么?
这一层最容易讲空,要具体说出三件事:
| 职责 | 具体是什么 |
|---|---|
| 线程切换 | C++ 的工作线程读到数据了,但不能直接调 JS 的回调函数—— JS 引擎是单线程的,只能在它自己的线程里执行。 所以要用 napi_threadsafe_function 把数据”投递”到 JS 线程 |
| 生命周期 | JS 那边的对象是被 GC(垃圾回收) 管的,你不知道它什么时候被回收; C++ 这边是手动管的。两边的持有关系必须显式声明,否则一边没了另一边还在用 → 崩溃 |
| 类型转换 | C++ 的 std::string、结构体,和 JS 的 napi_value 是两套完全不同的东西,要来回翻译 |
关键补一句:它不含业务逻辑。 桥接层里不应该出现”如果是 SSH 就怎样”这种判断。
③ 数据往两个方向怎么流?
下行(你敲键盘 → 发到服务器):
UI 调用 → NAPI → 投递到线程池 → 协议层执行阻塞 I/O(write)
上行(服务器输出 → 显示到屏幕):
协议层阻塞读到数据 → 经 napi_threadsafe_function 排队
→ JS 线程被唤醒,取出数据 → 调用 ArkTS 回调 → xterm.js 渲染
💡 “阻塞 I/O”是什么:调用
read()时如果对方还没发数据来, 这个调用就卡在那里不返回,直到有数据为止。 卡住的这段时间,这个线程什么也干不了。 所以阻塞 I/O 必须放在专门的工作线程里,绝不能放在 UI 线程—— 否则界面就整个卡死了。
⬜ 待填 + 一个可以主动交代的点
桥接层三个文件加起来将近 5000 行——比协议层里任何单个协议文件都大。 纯粹做线程切换、生命周期和类型转换,不该有这个体量。 所以大概率有业务逻辑漏进去了(比如 SFTP 的目录结构组装、参数校验)。
⬜ 回去翻一遍 napiSftp.cpp,看这 2349 行里有多少是"业务"。
📌 这题主动说出来比被问出来强: 「分层的意图是清楚的,但桥接层实际膨胀到了 5000 行, 说明有一部分本该在协议层的逻辑漏到了桥接层。 当时是为了赶功能,代价是这部分逻辑脱离鸿蒙就没法测。 如果重做,我会先定一条硬规则:桥接层不允许出现
if (协议类型 == ...)。」
1.2 Session / SessionFactory / SessionManager
先搞清楚:这三个类各干什么
你支持四种协议,它们的行为不一样(SSH 要握手认证,串口要配波特率), 但对外要提供一样的接口(都是”连上、发数据、收数据、断开”)。 这就是多态的经典场景(见 2.1.4)。
// ① Session:抽象基类,规定"一个会话必须能做哪些事"
class Session {
public:
virtual ~Session() = default;
virtual bool connect() = 0; // = 0 表示纯虚:我不实现,子类必须实现
virtual bool write(const char*, size_t) = 0;
virtual void disconnect() = 0;
// ...
};
// ② 四个子类各自实现
class SshSession : public Session { /* 用 libssh2 */ };
class TelnetSession : public Session { /* 裸 socket */ };
class SerialSession : public Session { /* termios 配串口 */ };
class SftpSession : public Session { /* SSH 之上的文件传输 */ };
// ③ SessionFactory:根据类型造出对应的子类
// 调用方只说"我要一个 SSH 会话",不需要知道 SshSession 这个类名
std::unique_ptr<Session> SessionFactory::create(Type t);
// ④ SessionManager:管着所有活着的会话(单例)
// id → Session 的一张表,负责查找、遍历、统一关闭
这三个分别对应三个设计模式:
Session基类 → 策略模式 / 多态SessionFactory→ 工厂模式SessionManager→ 单例模式
📌 但不要上来就报模式名。面试官听多了”我用了工厂模式单例模式”, 他真正想听的是这些抽象解决了什么问题。先讲问题,模式名最后带一句就够。
📌 面试官想验证什么
你懂不懂「为什么要抽象」,还是背了设计模式名词。
必答的三个点
① 基类的纯虚函数清单
✅ 已确认:Session 是抽象基类,SessionManager 是单例,
内部是一张 std::map<int, std::shared_ptr<Session>>——
用 shared_ptr 而不是 unique_ptr 存,这本身就是个可讲的点:
说明会话的所有权是共享的,工作线程和 NAPI 层都可能持有它。
⬜ 完整的纯虚函数清单要回去抄一遍。典型的是 connect() / disconnect() /
write() / setDataCallbacks() / resize(),加上状态查询。
💡
resize()是什么:终端窗口大小变了(比如你拖拽窗口), 要告诉远端”我现在是 120 列 40 行”,否则 vim 之类的全屏程序会显示错乱。 SSH 协议里对应的是发一个窗口变更消息。这个细节说出来很显专业。
② 新增一个协议实际要改哪些文件
⬜ 实际走一遍。
理想答案:「实现一个新子类 + 在工厂里注册一行,不改任何已有代码。」 这就是开闭原则(对扩展开放,对修改关闭)的实证—— 比你说一百遍”我的设计可扩展”都有力。
如果实际上要改好几处(比如还得改 NAPI 层的类型判断、改 UI 的下拉菜单), 就如实说,然后指出「这几处就是抽象没做干净的地方, 如果重做我会把协议的元信息也放进注册表」。坦白 + 有改进方案 = 加分。
③ 为什么用单例管理会话
先讲支持的理由:
- 会话 ID 需要全局唯一
- NAPI 层需要一个稳定的入口来按 id 查找会话(JS 那边只能传 id 这种简单类型过来)
- 进程内本来就只有一份会话表
再讲代价(这才是高分点,面试官一定会追问「有没有考虑不用单例」):
「单例的代价主要有两个。一是难以单测——它是全局状态, 上一个用例创建的会话会污染下一个用例。 二是多实例场景会卡住,比如以后要支持多窗口、每个窗口一个独立会话池, 单例就改不动了。 如果重做,我会把它做成一个可注入的 SessionRegistry, 单例只是它的一个默认实例——这样既保留了便利,又留了扩展和测试的口子。」
🔑 这题的高分要点:能说出自己方案的代价。 只讲好处的人,一听就是背的。
✅ 简历没写、但更值得讲的:两个方向的”先缓存后投递”
简历上写了「回调先缓存后投递」,实际代码做的比这句话完整—— 两个方向都做了缓存,而且两个方向的时序问题不是同一个。
方向一:数据早于回调到达(缓存在 Session 里)
// Session.h
std::vector<std::string> pendingMessages;
std::mutex pendingMessagesMutex;
void setDataCallbacks(std::function<void(int, std::string, bool)> dataCb) {
std::lock_guard<std::mutex> lock(pendingMessagesMutex);
if (dataCb == nullptr) return;
dataCallback = dataCb;
for (const auto& msg : pendingMessages) {
dataCallback(sessionId, msg, false); // 补投
}
pendingMessages.clear();
}
方向二:回调早于会话创建(缓存在 SessionManager 里)
// SessionManager
std::vector<std::function<void(int, const std::string&, bool)>> pendingDataCallbacks;
std::vector<std::function<void(int, const std::string&)>> pendingDirChangeCallbacks;
void applyPendingCallbacks(std::shared_ptr<Session> session);
void applyDataCallbackToExistingSessions(...);
📌 一句话讲清这个设计: 「JS 侧注册回调和 C++ 侧建会话是两条独立的时间线,谁先谁后不确定。 所以两边都缓存:会话侧缓存数据(数据先到、回调还没注册), 管理器侧缓存回调(回调先注册、会话还没建好)。」
三个追问都能答:
| 追问 | ✅ 事实 |
|---|---|
| 缓存粒度是什么 | 按会话,pendingMessages 是 Session 的成员 |
| 有没有上限 | 没有,是个无界 vector。如实说,并补一句「输出量大的会话(比如 cat 一个大文件)在回调注册前会一直堆内存;正确做法是设上限 + 超限丢最旧的并标记’有丢失’」 |
| 补投和直投会不会交叉 | 不会。补投和 dataCallback 赋值在同一把锁内完成,不存在”补投还没完就开始直投”导致的乱序 |
🔑 最后这条是高分点:锁的范围正好覆盖了”赋值 + 补投”这个复合操作, 说明你理解的是时序,不是”加了个锁”。
1.3 线程池
先搞清楚:这里为什么需要线程池
回到 1.1 说的:阻塞 I/O 会把线程卡住。
- 如果在 UI 线程读 → 界面卡死,这是绝对不行的
- 如果每个会话开一个线程 → 会话多了线程爆炸(每个线程默认 8MB 栈)
- 所以:开固定几个工作线程,任务放队列,谁空谁取 —— 这就是线程池
%%{init: {"flowchart": {"htmlLabels": true, "nodeSpacing": 32, "rankSpacing": 38}}}%%
flowchart LR
U["调用方<br/>Submit(任务)"] -->|"持 mutex 入队<br/>然后 notify_one"| Q["任务队列<br/>std::queue"]
Q -.->|"持 mutex 取出<br/>队列空就在 cv 上睡"| W1["工作线程 1"]
Q -.-> W2["工作线程 2"]
Q -.-> W3["工作线程 N"]
W1 -->|"⚠️ 在锁外执行"| R1["task()"]
W2 --> R2["task()"]
W3 --> R3["task()"]
🔑 图里那句「在锁外执行」是这题最容易被挑的点: 如果
task()在锁里跑,一个任务跑 3 秒整个池子就锁 3 秒, 多线程直接退化成单线程。
线程池的完整原理和手写实现见 2.3.2,这里只讲你项目特有的问题。
✅ 你的实现:自研 ThreadPool,内联在 SshSession.h:58-118
不是用的现成库,是自己写的一个教科书式线程池。已核实的事实:
| 问题 | ✅ 事实 |
|---|---|
| 几个线程,怎么定的 | 固定 8 个,构造时写死(SshSession.cpp:20)。不是 hardware_concurrency() |
| 任务队列 | std::queue + mutex + condition_variable,没有上限 |
| 虚假唤醒 | ✅ 用的是 condition.wait(lock, pred) 谓词版本,处理正确 |
| 线程怎么退出 | ✅ 析构里:置 stop → notify_all() → 逐个 join(),优雅退出 |
| 语言基线 | 用了 std::result_of——它 C++17 弃用、C++20 移除,说明基线是 C++17 |
enqueue 里有个值得主动讲的技巧:
// 用 shared_ptr 包一层 packaged_task,再塞进 std::function
auto task = std::make_shared<std::packaged_task<R()>>(...);
tasks.emplace([task]() { (*task)(); });
📌 为什么要包这一层:
std::function要求它保存的可调用对象可拷贝, 而std::packaged_task只能移动、不能拷贝。 直接塞进去编译不过,所以用shared_ptr包一层——shared_ptr是可拷贝的。 这是个经典技巧,能讲出来是明确的加分点(C++11 时代没有std::move_only_function, 现在 C++23 有了就不用这么绕)。
被问到「线程数为什么是 8」,如实说:
「当时按经验定的固定值,没有按核数或会话数算。
更合理的做法是至少跟 hardware_concurrency() 挂钩,或者做成可配置。」
📌 这题最狠的追问:线程池会不会被占满
这是通用知识,可以直接用。
问题是这样的:假设线程池有 8 个线程,用户开了 8 个会话,
每个会话都有一个”一直在读”的任务卡在 read 上。
这时候池子里 8 个线程全被占死了,第 9 个任务——哪怕只是”发一个字符出去”
这种一毫秒的活——也永远排不上号。
线程池(8 个线程)
线程 1: 卡在 会话A 的 read ← 可能卡几分钟(用户没敲键盘,服务器没输出)
线程 2: 卡在 会话B 的 read
...
线程 8: 卡在 会话H 的 read
─────────────────────────
队列: [会话A 要发个字符] [会话C 要断开] ... ← 全都执行不了 💀
%%{init: {"flowchart": {"htmlLabels": true, "nodeSpacing": 34, "rankSpacing": 42}}}%%
flowchart LR
Q["任务队列<br/>(排满了,谁也取不走)"]
subgraph POOL["线程池 · 8 个线程"]
T1["线程1<br/>卡在 会话A read"]
T2["线程2<br/>卡在 会话B read"]
T8["线程8<br/>卡在 会话H read"]
end
Q -.->|"取不到空闲线程"| POOL
T1 --> X1(["永久阻塞"])
T2 --> X2(["永久阻塞"])
T8 --> X8(["永久阻塞"])
三种常见解法:
| 方案 | 做法 | 优缺点 |
|---|---|---|
| ① 每会话一个独立 I/O 线程 | 读操作不进池子,每个会话自己一个读线程 | 简单直接; 但会话多了线程爆炸,几百个会话就撑不住 |
| ② I/O 事件驱动 | 用 select/poll/epoll 同时监听所有会话的 fd,就绪了才把任务交给线程池 | 最优雅,扩展性最好; 但要改成非阻塞 + 事件循环,工作量大(见第四档 4.2) |
| ③ 读写分离 | 读用专用线程,写和控制操作走线程池 | 折中方案,改动小 |
⬜ 你的实现属于哪种(8 线程池 + 每会话读任务,具体怎么排的要确认)
📌 如果是第 ① 种,坦白说: 「我用的是每会话一个读线程,会话数上不去是这个方案的已知限制。 当时判断桌面终端同时开几十个标签页就到头了,这个量级线程完全撑得住。 如果要支持成百上千个会话,就得改成 ② 那种 epoll 事件驱动的模型。」
这个答法比装作没这个问题好得多——它同时展示了: 你知道限制在哪、你知道当时为什么可以接受、你知道怎么改。
⚠️ 你代码里一个真实缺陷:主动讲出来,别等被问
// SshSession.cpp:48-52
void SshSession::runAsyncInThreadPool(std::function<void()> task) {
std::thread([future = threadPool->enqueue(std::move(task))]() mutable {
future.get();
}).detach();
}
任务确实进了线程池,但又额外起了一个 detached 线程,只为了等 future.get()。
本意: 提交任务 → 线程池里的 8 个线程复用着跑
实际发生: 提交任务 → 线程池跑任务
+ 另起 1 个线程,什么也不干,只是阻塞等结果
→ 每调用一次,净多一个线程 → 线程池"复用"的意义被抵消了
→ detach 之后无法 join,析构时序也不可控
📌 讲法(照这个说): 「当时是为了拿到任务里抛出的异常,实现上退化成了每次多起一个线程, 线程池的复用意义就没了;而且
detach()之后没法 join,析构时序不可控。 如果重写,要么让任务自己 catch 异常再走错误回调, 要么把 future 收集起来统一等待——不该在这里 detach。」
🔑 主动指出自己代码的问题,是这一整档里性价比最高的动作。 面试官对”能审视自己代码”的评价,远高于”代码写得完美”。
1.4 状态机与 atomic / mutex
先搞清楚:什么是状态机,为什么会话需要它
一个会话在它的一生里会处在不同的状态:还没连、正在连、连上了、正在断、已断开、出错了。
状态机就是把这些状态和”哪些状态可以变到哪些状态”明确写出来。
Idle ──connect()──> Connecting ──成功──> Connected ──close()──> Closing ──> Closed
│ │ ↑
└───失败───> Error ───┴───────────────────────┘
为什么必须有它:因为很多 bug 的根源就是”在错误的状态下做了某个操作”。 比如还在 Connecting 就调 write(socket 还没建好 → 崩溃), 或者已经 Closed 了还去 close 一次(重复释放 → 崩溃)。 有了明确的状态机,每个操作先检查状态,非法就直接拒绝。
🚨 先纠正一件事:你的代码里没有 atomic<SessionStatus>
这一条最重要,说错了对方翻一眼源码就穿帮。
简历和个人网站上写的是「用 atomic<SessionStatus> + mutex 实现连接/断开互斥」。
源码核查结果:不存在。 实际情况是——
| 说法 | 实际代码 |
|---|---|
atomic<SessionStatus> 管会话状态 | ❌ Session.h 里是裸成员 SessionStatus status; |
| —— | ✅ 保护它的是 std::mutex statusMutex(SshSession.h:167) |
atomic 用在哪 | ✅ 用的是 atomic<bool>:statusChange、stopThread、SshPortForward 的 shouldStop / running / m_pfConnected、napiInterface 的 isCallbackValid / tsfnCreated / g_directoryCallbackRegistered |
✅ 正确的说法(就这么答):
「会话状态用
mutex保护,atomic<bool>用在线程停止标志和回调有效性标志上。 选择的依据是:状态转换是复合操作(检查 + 动作 + 改值),必须用锁; 停止标志是单个 bool 的置位和读取,用 atomic 就够, 而且它经常要在持有别的锁的时候读,用 atomic 能避免锁嵌套。」
⚠️ 两处必须同步改掉(不改就是简历造假风险): ①
resume/resume_cpp_v4.md项目一「并发控制」条; ②website/src/content/projects/crossshellnext.mdx。
✅ 你代码里的五把锁:锁的职责划分是必然的追问
libssh2Mutex std::recursive_mutex 保护所有 libssh2 调用(SshSession.h:130)
statusMutex std::mutex 保护会话状态
bufferMutex std::mutex 保护读缓冲
callBackDataMutex std::mutex 保护回调数据
pendingMessagesMutex std::mutex 保护待投递消息缓存
两个一定会被问的点,提前想好:
① 为什么 libssh2Mutex 是 recursive_mutex?
用递归锁通常意味着存在嵌套加锁的调用路径—— 比如某个已经持锁的函数里又调用了另一个也要加锁的函数。 诚实的答法:「用 recursive 是因为有嵌套调用路径,它让当时的改动最小。 代价是掩盖了调用关系——普通 mutex 会直接死锁给你看,recursive 不会, 于是你永远不知道自己的锁范围到底有多大。 如果重构,我会把加锁的函数拆成’带锁的公开版’和’不加锁的内部版’,改回普通 mutex。」
② 五把锁的加锁顺序是什么?(防死锁的关键)
⬜ 这个必须回去理一遍:把”哪些函数会同时持有两把及以上的锁”找出来,
确认它们的顺序一致。五把锁只要有两条路径顺序相反,就是潜在死锁。
⚠️ 一个真实的数据竞争:主动讲
// SshSession.cpp:status 的写入
772 行、834 行、1236 行 → ✅ 都持有 statusMutex
639 行 status = SessionStatus::CONNECTED; → ❌ 没加锁
// SshSession.h:202
bool isConnected() { return status == SessionStatus::CONNECTED; } // ❌ 无锁读
一处无锁写 + 一个无锁读接口 = 货真价实的 data race,
TSan(-fsanitize=thread)跑一下就会报。
📌 讲法: 「状态大部分路径都走
statusMutex,但连接成功那一处漏了锁,isConnected()也是无锁读。这是个真实的数据竞争,只是因为 枚举赋值在 x86 上通常是单条指令,没有暴露成崩溃。 修法有两种:统一加锁,或者把status本身改成std::atomic<SessionStatus>—— 对这种单一枚举值,后者更合适,读侧也不用加锁了。」🔑 注意这里的顺序:改成 atomic 之后,简历上原来那句话才成立。 但现在还没改,所以现在不能那么写。
📌 通用知识:atomic 保护什么,mutex 保护什么
💡 前置:
atomic(原子变量)和mutex(互斥锁)是什么、 数据竞争为什么会发生,见 2.3.0 和 2.3.1。这里只讲怎么选。
标准答案的形状:
-
atomic<State>管的是「单个状态字的读写」。 UI 线程随时可能问一句「这个会话连上了吗」, 用 atomic 它就能无锁读取,不用等,也不会读到一个”改了一半”的值。 -
mutex管的是「状态转换这个复合操作」。 「检查当前是 Connected → 执行断开动作 → 置为 Disconnected」 是三步。atomic 只能保证每一步各自原子,保证不了这三步中间不被插入。 如果两个线程同时走到这里,可能两个都读到 Connected,都去执行断开 → 重复释放。这类 bug 有个名字叫 check-then-act 竞态(先检查再动作), 必须用锁把整段包起来。
一句话版本(背这句):
atomic 保证「读到的不是撕裂值」,mutex 保证「转换过程不被插入」。
⬜ 待填:把你的状态图画出来(Idle / Connecting / Connected / Disconnecting / Closed / Error),标出哪些转换是合法的。
参考形状(⬜ 请按你代码里的真实状态改,不要照抄):
%%{init: {"themeVariables": {"fontSize": "14px"}}}%%
stateDiagram-v2
[*] --> Idle
Idle --> Connecting: connect()
Connecting --> Connected: 握手 + 认证成功
Connecting --> Error: 超时 / 认证失败
Connecting --> Closing: 用户取消
Connected --> Closing: close() / 用户关标签页
Connected --> Error: 心跳超时 / 对端断开
Error --> Connecting: 重连(指数退避)
Error --> Closing: 用户放弃
Closing --> Closed: 工作线程已 join
Closed --> [*]
note right of Connected
只有这个状态下 write() 才合法
其他状态调 write 必须直接拒绝
end note
📌 画完之后要能回答三个追问: ① 哪些转换是非法的(比如
Closed → Connected)? ② 非法转换发生时你怎么处理——拒绝并返回错误,还是断言崩溃? ③Error → Connecting这条边谁来触发?重连线程还是用户点击? 面试白板上能画出来,这题就过了。
1.5 三个简历没写但必问的问题
这三个是终端类项目的必考题。你简历上没写,面试官会认为可能没做, 所以提前想好怎么答。 每题我都给了完整的技术背景, 即使你当时没做,也要能说出”该怎么做”。
(1) 断线了怎么办?
先搞清楚:为什么”断线”这么难检测
你可能觉得:网线拔了,程序不就报错了吗?不会。
TCP 是个”沉默”的协议:如果对方正常关闭,会发一个 FIN 包,你能立刻知道。
💡 FIN / SYN / RST 是什么:TCP 包头里有几个标志位,用来表达这个包的意图。 SYN(synchronize)= 「我想建立连接」; FIN(finish)= 「我这边没数据要发了,准备关闭」; RST(reset)= 「出错了,立刻强制断开」。 建立和断开连接的完整过程见 2.4.1。 但如果对方是突然断电、网线被拔、路由器挂了—— 它根本没机会发任何东西,你这边的连接看起来完全正常,
read就一直阻塞着,可以卡几十分钟。
三种检测手段,层次不同
| 手段 | 在哪一层 | 问题 |
|---|---|---|
| TCP KeepAlive ( SO_KEEPALIVE + TCP_KEEPIDLE/INTVL/CNT) | 内核 | 默认 2 小时才开始探测,必须调参数才有用 |
| SSH 协议层心跳 ( libssh2_keepalive_config) | SSH 协议 | 发 SSH 层的 keepalive 报文。 能穿透 NAT 和中间设备,这是它比 TCP KeepAlive 强的地方 |
| 应用层超时 | 你自己 | 读操作长时间无数据 + 发了探测也无响应 → 判定断线。 最灵活,粒度最细 |
💡 “穿透 NAT”是什么意思:中间的路由器/防火墙维护着一张连接表, 一段时间(常见 5-30 分钟)没有流量就会把这条连接的记录删掉, 之后你的包就发不过去了。定期发点东西能让这条记录保持活着。 TCP KeepAlive 默认 2 小时才发,早就被删了;SSH 层心跳你可以设成 30 秒。
重连策略的标准答案
指数退避 + 随机抖动 + 上限:1s → 2s → 4s → 8s …… 封顶 30s, 每次加一个随机量。
- 为什么指数退避:服务端可能正在重启或过载,所有人每秒重试是雪上加霜
- 为什么加随机抖动:服务端宕机 10 秒,所有客户端都是同一时刻断的, 退避曲线完全一样 → 会在同一秒一起重连 → 把刚起来的服务端再打死。 加随机量把它们错开
- 必须区分:「用户主动点了断开」→ 不重连;「网络异常断开」→ 重连。 分不清这两者,用户点了关闭你还在后台疯狂重连,是很常见的 bug
(更详细的见 2.4.6。)
✅ 你的实现:做到了第二层(SSH 协议层心跳),有专用线程
// SftpSession.cpp:481
libssh2_keepalive_config(session.get(), 1, config.keepAliveInterval);
// ↑ want_reply=1,要求对端回应
配一个专用的 keepAliveThread(),循环里做了四件事——每一件都能对上一个考点:
| 代码里做的 | 对应的考点 |
|---|---|
循环开头先检查 sessionValid,无效就 break | 线程退出条件 |
发送前持 sessionMutex,再检查 session 非空 | 检查和使用在同一段锁内(不是先检查后用,避免 check-then-use 竞态) |
libssh2_keepalive_send 返回 LIBSSH2_ERROR_EAGAIN 视为正常,稍后再试 | 非阻塞模式下 EAGAIN 不是错误 |
析构里 stopThread.store(true) 停线程 | atomic 停止标志 |
📌 这段就是”心跳线程和关闭怎么不打架”的现成答案: 「退出标志用
atomic<bool>,因为它要被另一个线程随时读; 而会话指针的检查和使用必须在同一把锁里—— 如果先检查非空、放锁、再发送,中间会话可能已经被销毁了。」
⬜ 重连做没做,如实说。 代码里没看到指数退避的重连逻辑, 如果确实没做,就答「检测做了(心跳 + EAGAIN 处理), 自动重连当时没做,交给用户手动重连;要做的话就是上面那套指数退避 + 抖动」。
(2) connect 卡住怎么处理?
先搞清楚:为什么 connect 会卡很久
你调 connect() 去连一个不可达的地址(IP 写错了、对方防火墙把包丢了)。
TCP 不会立刻放弃——它会重传 SYN 包,按 1s、2s、4s、8s…… 的间隔重试,
走完整个重传流程可能要 75 秒以上。
这 75 秒里,如果你是阻塞式 connect,这个线程就死在那里。 用户点了”连接”,界面转圈转一分钟才告诉他失败——体验极差。
三种解法
① 非阻塞 connect + select/poll 超时(最通用,推荐)
fcntl(fd, F_SETFL, O_NONBLOCK); // 先设成非阻塞
int r = connect(fd, addr, len);
// 非阻塞的 connect 会【立刻返回 -1】,errno = EINPROGRESS,意思是"我开始连了"
if (r < 0 && errno != EINPROGRESS) return fail();
// 然后用 select/poll/epoll 等这个 fd 变成"可写",带上你自己的超时时间
// 可写了 → 用 getsockopt(fd, SOL_SOCKET, SO_ERROR, ...) 取真正的结果
// ⚠️ 因为"可写"也可能是因为【连接失败了】,必须再查一次
// 超时了 → 关掉 fd,报"连接超时"
② SO_SNDTIMEO / TCP_USER_TIMEOUT
简单,但粒度粗,而且 SO_SNDTIMEO 对 connect 的行为在不同平台上不一致,
不够可靠。
③ libssh2 层的超时
libssh2_session_set_timeout() 控制的是 SSH 握手阶段的超时。
⚠️ 它管不了底层的 TCP connect。
📌 完整答案要分两段(这是这题的关键)
「TCP 连接超时和 SSH 握手/认证超时是两个独立的阶段,两个都要设。 只设 SSH 层的,TCP 连不上时还是会卡 75 秒; 只设 TCP 层的,TCP 通了但服务端不回 banner(比如服务卡死了), 一样会永远等下去。」
💡 “banner”是什么:SSH 服务端在连上后会先发一行版本标识, 比如
SSH-2.0-OpenSSH_8.9。收不到它就说明对面不是 SSH 服务,或者服务有问题。
✅ 你的 Telnet 代码里,非阻塞 connect 四步全做对了
这题不用”该怎么做”来答,直接讲你写过的代码(telnetSession.cpp):
264 行 socket(ptr->ai_family, ptr->ai_socktype, ptr->ai_protocol) ← 配合 getaddrinfo 遍历地址
59 行 fcntl(socket, F_GETFL, 0)
65 行 fcntl(..., flags | O_NONBLOCK) ← ① 设非阻塞
278 行 ::connect(connectSocket, ptr->ai_addr, ...) ← ② 立刻返回 EINPROGRESS
105 行 select(socket + 1, NULL, &writefds, NULL, &tv) ← ③ 带超时等可写
296 行 getsockopt(connectSocket, SOL_SOCKET, SO_ERROR, &error, &len) ← ④ 可写≠成功,取真实结果
328 行 fcntl(connectSocket, F_SETFL, flags & ~O_NONBLOCK) ← 连上后改回阻塞
🔑 第 ④ 步是这题的分水岭:很多人写到”select 说可写了就是连上了”就停了。 可写也可能是因为连接失败(收到 RST 也会让 fd 变可写), 必须再用
getsockopt(SO_ERROR)取一次真正的结果。你代码里做对了,一定要说。
📌 简历这块写保守了:只写了「socket、TCP/IP」。 实际能讲的是四层——
getaddrinfo遍历多个地址族、O_NONBLOCK切换、select超时控制、SO_ERROR判定成败。这是能撑起 3 分钟的具体内容。
⬜ SSH 侧的两段超时(TCP connect 超时 + libssh2_session_set_timeout 握手超时)
是不是也都设了? Telnet 这条路是确认做对的,SSH 那条要回去确认。
(3) 会话销毁时工作线程还在读数据怎么办?
这是最容易暴露真实水平的一题
场景:用户点了”关闭标签页”。UI 线程想立刻把这个 Session 对象删掉。
但是——工作线程此刻正卡在 libssh2_channel_read() 上,手里还攥着这个对象的指针。
UI 线程: delete session; ← 对象没了
工作线程: 还阻塞在 read 里…… 醒来后 session->onData(...)
↑ 💥 悬垂指针,use-after-free,崩溃
💡 悬垂指针是什么见 2.1.0。 这类 bug 的特点是偶现——手快的时候崩,手慢的时候不崩,极难复现。
技术背景:标准解法有四层,要层层递进地讲
第 1 层:状态位 + join —— 不够
stopping_ = true; // 告诉工作线程该退出了
worker_.join(); // 等它退出
问题:工作线程正阻塞在 read 上,根本没机会去看 stopping_。
read 不返回,线程就不退出,join 就永远死等。
第 2 层:必须先把阻塞的 read 叫醒
这是整题的核心。两种手段:
// 手段 A:shutdown
shutdown(fd, SHUT_RDWR); // 让阻塞的 recv 立即返回 0 或错误
⚠️ 为什么用
shutdown而不是close:close(fd)之后,这个 fd 号会立刻被系统回收,可能马上分配给一个新连接。 而工作线程还不知情,醒来后继续拿这个 fd 号去读—— 读到的是别人的连接! 这就是经典的”关错 fd”bug, 而且症状诡异到几乎查不出来。shutdown只关闭数据传输方向,fd 号还在你手里,不会被复用。 等 join 完了再 close。
// 手段 B:self-pipe / eventfd
// 读循环里不直接 read,而是用 select/poll 同时监听【socket】和【一个内部管道】
// 销毁时往管道里写一个字节 → select 立刻返回 → 工作线程发现是管道,就退出
这个手段更通用,串口、管道之类没有 shutdown 的场景也能用。
(信号处理里的 self-pipe trick 见 2.2.4,是同一个技巧。)
第 3 层:⚠️ 不要在持锁的时候 join
{
std::lock_guard lk(mu_); // ❌ 持着会话锁
stopping_ = true;
worker_.join(); // 等工作线程退出……
} // 但工作线程退出前要回调,回调里也要拿这把锁
// → 互相等待 → 死锁
会话锁 + join 的组合是死锁高发点。 正确做法是在锁外 join。 (对应 2.3.3 那条「绝不在持锁时调用回调或未知代码」。)
第 4 层:引用计数兜底
用 shared_ptr<Session> 管理会话,工作线程和回调只持 weak_ptr,
用之前 lock()。拿不到就说明会话已经销毁了,直接返回。
(见 2.1.1 的 weak_ptr 部分。)
📌 最完整的答法(把顺序说出来)
「顺序是:①
shutdown唤醒阻塞的 read → ② 工作线程醒来, 发现 stopping 标志,退出循环 → ③ 主线程(在锁外)join → ④ 确认线程真的退出了,最后才释放资源和 close fd。 这个顺序不能反——先释放资源再 join,就是 use-after-free。」
⚠️ 错误的做法(时序图看一眼就明白为什么崩):
%%{init: {"themeVariables": {"fontSize": "14px"}}}%%
sequenceDiagram
participant UI as UI 线程
participant W as 工作线程
participant S as Session 对象
W->>S: 正阻塞在 read()
UI->>S: delete session
Note over S: 对象已销毁 💀
W-->>S: read 返回,回调 session->onData()
Note over W,S: 💥 悬垂指针 use-after-free
✅ 正确的四步顺序:
%%{init: {"themeVariables": {"fontSize": "14px"}}}%%
sequenceDiagram
participant UI as UI 线程
participant W as 工作线程
participant S as Session 对象
UI->>S: ① stopping_ = true
UI->>S: ② shutdown(fd, SHUT_RDWR)
Note over W: 阻塞的 read 立刻返回错误
W->>W: ③ 检查 stopping_,退出循环
UI->>W: ④ join()(⚠️ 必须在锁外)
Note over W: 线程真的结束了
UI->>S: ⑤ 这时才 close(fd) + 释放资源
🔑 为什么第 ② 步用
shutdown而不是close:close之后 fd 号会被系统回收、可能立刻分配给新连接, 而工作线程还不知情,醒来后拿这个 fd 号继续读——读到别人的连接。shutdown只关数据传输方向,fd 号还在你手里。
⬜ 你的实现是哪种? 如果当时用的是”加个标志位 + detach”那种简化方案,如实说, 然后把上面这套讲出来说明你知道该怎么做。 承认边界 + 给出正确方案,比硬编强得多。
1.6 协议与 SFTP 双通道
Telnet / Serial 底层怎么建连?
Telnet
就是裸 socket:socket() + connect(),然后收发明文字节。
没有加密,没有握手。
但它有一个东西叫 IAC 协商:
💡 IAC(Interpret As Command):Telnet 的字节流里,
0xFF这个字节是特殊的,表示”接下来是一条命令,不是数据”。 双方用它来协商一些选项,比如:
- ECHO:字符回显由谁负责(你本地显示,还是等服务器把它发回来再显示)
- SGA(Suppress Go Ahead):切换到全双工模式,不用等对方”轮到你了”
- NAWS(Negotiate About Window Size):告诉服务器终端窗口多大
不处理 IAC 的表现是:屏幕上出现乱码字符(把
0xFF当数据显示了), 或者每个字符显示两遍(本地和服务器都回显了)。
⬜ 你处理了哪些 IAC 选项?ECHO / SGA / NAWS?
Serial(串口)
串口是通过一根物理线连设备,在 Linux 上表现为一个设备文件
(/dev/ttyUSB0、/dev/ttyS0 这种)。
int fd = open("/dev/ttyUSB0", O_RDWR | O_NOCTTY | O_NONBLOCK);
// ↑ O_NOCTTY:别把这个串口当成我的"控制终端",
// 否则串口上来一个 Ctrl+C 会把你自己的进程杀掉
struct termios tty;
tcgetattr(fd, &tty);
cfsetispeed(&tty, B115200); // 波特率:每秒传多少位。两边必须一致,
cfsetospeed(&tty, B115200); // 不一致就是满屏乱码——这是串口最常见的问题
cfmakeraw(&tty); // ⚠️ raw 模式:关掉行缓冲和本地回显
tcsetattr(fd, TCSANOW, &tty);
💡 为什么必须 raw 模式:Linux 的终端驱动默认是”熟模式”(cooked), 它会帮你做行缓冲(攒够一整行按了回车才交给程序)、 处理退格、本地回显。 但终端模拟器要的是每个字符立刻原样传过去—— 你按方向键、按 Tab 补全,这些都不能等回车。 所以必须
cfmakeraw把这些”贴心功能”全关掉。
⬜ 鸿蒙侧的设备节点路径和权限怎么处理的?
SFTP 为什么用两个 session 而不是一个 session 多 channel?
先搞清楚:SSH 里的 session 和 channel 是什么
SSH 协议是分层的:
一条 TCP 连接
└── SSH transport 层(加密、认证) ←── libssh2 里叫一个 "session"
├── channel 1:交互式 shell(你敲命令的那个)
├── channel 2:SFTP 文件传输
└── channel 3:端口转发
所以协议上完全支持”一条连接里跑多个 channel”——这是 SSH 的设计目标之一, 省一次 TCP 握手和一次认证。
那你为什么用了两条独立的连接(两个 session)? 这题答好了很加分,因为它考的是对协议和库限制的真实理解, 而不是照抄协议文档。
三个理由
① libssh2 的 session 不是线程安全的(这是最实际的理由)
同一个 LIBSSH2_SESSION 被多个线程并发调用,必须外部加锁。
那么问题来了:
线程 A:持锁,正在传一个 500MB 的文件(要占用锁很久)
线程 B:想发一个按键出去 → 拿不到锁 → 【交互界面卡住】
多 channel 本来是为了并发,结果被一把大锁变回了串行——优势完全没了。
② SSH 有全局的流控窗口
SSH transport 层有窗口机制来控制发送速率。 传大文件会把这个窗口占满,交互 channel 的按键响应会明显变卡—— 你敲一个字符要等半秒才显示。
③ 故障隔离
文件传输出错导致整个 session 断开时,不会连带干掉你正在用的交互会话。 你 SSH 连着设备在改配置,传个文件失败把 shell 也断了,那太糟糕了。
📌 记得主动说代价
「代价是:多一条 TCP 连接、多一次完整的认证握手、服务端的连接数翻倍。 对桌面终端来说这个代价可以接受——用户同时开的会话数是几十个量级。 如果是要连几千台设备的批量工具,我就会重新权衡, 可能改成多 channel + 更细粒度的锁。」
✅ 已核实:确实是两条独立的 SSH 连接,而且是并行建的
// SshSession.cpp:1461 和 1493:两个 std::thread 并行建两个独立 SftpSession
auto sftpSession = std::make_unique<SftpSession>(config, true);
if (sftpSession->connect()) {
tempInteractiveSession = std::move(sftpSession); // 成功才移交所有权
interactiveSuccess.store(true);
}
// 失败时 sftpSession 出作用域自动析构,不需要写 cleanup
这几行里有三个可讲的点:
- 并行建连:两条连接同时握手,而不是串行——否则用户要等两次认证的时间
- 先局部
make_unique、成功才move到外层: 失败路径完全靠 RAII 自动清理,没有一行手写的 cleanup。 这是 RAII 在真实代码里最实用的形态,比背”栈展开”有说服力 - 三层
catch(具体异常 →std::exception→...), 错误信息用errorMutex保护——因为两个线程可能同时写错误信息
⬜ 认证凭据是怎么在两个 session 之间复用的?
(用户不可能输两遍密码,代码里 config 是传进去的,
具体是复用了明文密码还是复用了密钥/agent,回去确认——这是必然的追问。)
✅ 端口转发:简历上没写,但它是这个项目里最硬的一块
SshPortForward.cpp 1827 行,三种转发模式全实现了:
| 模式 | 关键代码 | 是什么 |
|---|---|---|
| 本地转发 | 487-519:socket → bind → listen(10) → accept | 本机开个端口,连它就等于连远端的某个地址 |
| 远程转发 | 736:libssh2_channel_forward_accept(...) | 反过来:远端开端口,转回你本机 |
| 动态转发 | 973+:SOCKS5 握手是自己解析的 | 本机变成一个 SOCKS5 代理,浏览器可以直接挂上来 |
SOCKS5 那段是自己按协议写的:校验 greeting(VER=0x05、方法数)→
回 {0x05, 0x00} 选”无认证” → select 等请求 → 校验 n >= 10 && request[0] == 0x05。
📌 这里有个现成的排障故事,直接能用
代码里有这么一条错误日志:
"Client may be using HTTP CONNECT instead of SOCKS5. Please ensure your
browser/application is configured for SOCKS5 proxy, not HTTP proxy."
这条日志说明你真的调过这个坑。按五步讲出来就是一个完整的排障故事:
① 现象:浏览器挂上动态转发之后打不开网页,连接直接断。 ② 定位:抓握手的第一个字节,发现不是
0x05——对面根本没在说 SOCKS5。 ③ 根因:浏览器/应用把代理类型配成了 HTTP 代理,发过来的是CONNECT host:port这种明文请求。 ④ 修法:这不是代码 bug,是可诊断性问题—— 于是在握手校验失败的错误路径上加了明确提示,直接告诉用户”你配成 HTTP 代理了”。 ⑤ 收益:同类问题不用再抓包,看日志就知道。
🔑 这个故事的价值不在技术难度,在于它展示了”协议级排查 + 把经验固化进错误信息”。 面试官问”讲一个你排查过的问题”,这是最容易讲清楚的一个。
1.7 libssh2 句柄的 RAII 封装
💡 「句柄」(handle)是什么:C 库不想把内部结构暴露给你, 就给你一个不透明的指针或整数代表”你申请到的那个东西”。 你不能碰它里面的字段,只能把它传回给库的各个函数。
LIBSSH2_SESSION*、FILE*、Linux 的 fd,都是句柄。 句柄必须配对释放——申请了就要还,这就是本节的主题。
先搞清楚:这题在问什么
libssh2 是个 C 库,它给你的是裸句柄(LIBSSH2_SESSION*、
LIBSSH2_CHANNEL*),必须手动调对应的 free 函数释放。
// ❌ 手动管理:每个出口都要记得释放,还挡不住异常
LIBSSH2_SESSION* s = libssh2_session_init();
LIBSSH2_CHANNEL* c = libssh2_channel_open_session(s);
if (!c) { libssh2_session_free(s); return; } // 出口 1
if (auth_failed()) {
libssh2_channel_free(c);
libssh2_session_free(s); // 出口 2,顺序还不能错
return;
}
// ... 中间任何一行抛异常,上面这些 free 全都执行不到 → 资源泄漏
RAII 就是把这些 free 塞进析构函数,让编译器保证一定执行。
(RAII 的完整讲解见 2.1.2,unique_ptr + 自定义 deleter 见 2.1.1。)
📌 面试官想验证什么
你是真懂 RAII,还是只会说「智能指针自动释放」。 区分方法:他会往下问销毁顺序和释放函数本身会失败怎么办。
四个要点
① libssh2 的对象有销毁顺序依赖:channel → session → socket
不能先关 session 再关 channel——channel 是挂在 session 上的, session 没了 channel 就成了野对象。
RAII 怎么保证顺序:C++ 规定成员变量按声明顺序构造、按逆序析构。
所以只要声明顺序是 socket, session, channel,
析构就自动是 channel, session, socket,正好是对的。
class SshSession {
SocketPtr sock_; // 先声明 → 后析构
SessionPtr sess_;
ChannelPtr chan_; // 后声明 → 先析构 ✅ 顺序正确
};
📌 “靠成员声明顺序保证逆序析构”这句话说出来, 面试官就知道你是真用过 RAII 而不是背的。
② unique_ptr + 自定义 deleter 是标准做法
// 写法 A:用函数指针(简单,但有开销)
using SessionPtr = std::unique_ptr<LIBSSH2_SESSION,
decltype(&libssh2_session_free)>;
// ⚠️ sizeof 是【两个指针】,因为要存那个函数指针
// 写法 B:用无状态仿函数(推荐,零开销)
// 💡 "仿函数"(functor)= 一个重载了 operator() 的结构体,
// 用起来像函数(obj(x)),但它是个类型,编译器能内联掉、能做空类优化
struct SessionDeleter {
void operator()(LIBSSH2_SESSION* s) const { if (s) libssh2_session_free(s); }
};
using SessionPtr = std::unique_ptr<LIBSSH2_SESSION, SessionDeleter>;
// ✅ sizeof 就是【一个指针】,和裸指针一样大
③ ⚠️ 非阻塞模式的大坑:释放函数会返回 EAGAIN
这个细节说出来很加分,因为只有真踩过的人才知道。
libssh2 在非阻塞模式下,连 libssh2_session_free() 这种释放函数
也可能返回 LIBSSH2_ERROR_EAGAIN——
意思是”我还有数据没发完(比如断开消息),现在还释放不了,你待会再调”。
// ❌ 错误:调一次就走 → 资源根本没释放
libssh2_session_free(s);
// ✅ 正确:必须循环调用直到不返回 EAGAIN
while (libssh2_session_free(s) == LIBSSH2_ERROR_EAGAIN) { /* 可以稍微 sleep */ }
不循环的表现是:连接开开关关几百次后, 内存和 fd 缓慢泄漏——又是一个极难定位的问题。
④ libssh2_init / libssh2_exit 是全局的
它们初始化的是整个库的全局状态(比如加密库的初始化),
整个进程只能调一次。多线程下要用 std::call_once 保证:
static std::once_flag g_once;
std::call_once(g_once, [] { libssh2_init(0); });
✅ 你的实际实现:同一个项目里两种写法都有
这是这题最好的素材——不用编,你代码里正好有一个对照组。
写法 A:函数指针 deleter(SshSession.h:123-125)
std::unique_ptr<LIBSSH2_SESSION, decltype(&libssh2_session_free)> session;
std::unique_ptr<LIBSSH2_CHANNEL, decltype(&libssh2_channel_free)> channelShell;
std::unique_ptr<LIBSSH2_KNOWNHOSTS, decltype(&libssh2_knownhost_free)> knownHosts;
// SshSession.cpp:16-18 构造函数初始化列表——这三行就是这种写法的代价
: session(nullptr, &libssh2_session_free),
channelShell(nullptr, &libssh2_channel_free),
knownHosts(nullptr, &libssh2_knownhost_free),
写法 B:无状态仿函数(key_manager.cpp:19-24)
struct BIOFree { void operator()(BIO* bio) { if (bio) BIO_free_all(bio); } };
struct RSAFree { void operator()(RSA* rsa) { if (rsa) RSA_free(rsa); } };
struct EVPKeyFree { void operator()(EVP_PKEY* k) { if (k) EVP_PKEY_free(k); } };
struct BIGNUMFree { void operator()(BIGNUM* bn) { if (bn) BN_free(bn); } };
struct EVPKeyCtxFree{ void operator()(EVP_PKEY_CTX* c){ if (c) EVP_PKEY_CTX_free(c); } };
using BIOPtr = std::unique_ptr<BIO, BIOFree>; // sizeof == 8,可默认构造
📌 被问到就讲这三个取舍点(有实物对照,比背概念强得多):
| 写法 A:函数指针 | 写法 B:无状态仿函数 | |
|---|---|---|
sizeof | 16 字节(8 资源指针 + 8 函数指针) | 8 字节,和裸指针一样 |
| 能否默认构造 | ❌ 必须在构造时显式传 deleter(就是上面那三行初始化列表) | ✅ 可以 |
| 类型名 | unique_ptr<T, decltype(&free_fn)> 又长又难读 | using BIOPtr = ...,可读 |
讲法:「libssh2 那三个是早期写的,用了函数指针;后来写 key_manager 时改成了 无状态仿函数,
sizeof从 16 降到 8,也不用每次在初始化列表里传 deleter。 如果统一,我会把 libssh2 那三个也改成仿函数。」
⚠️ 你的销毁顺序是对的,但它是”靠声明顺序撑着”的
声明顺序: session → channelShell → knownHosts
析构顺序: knownHosts → channelShell → session (成员按声明【逆序】析构)
channel 先于 session 释放,结果是对的。但——
📌 主动补这一句就是加分: 「这个正确性完全依赖成员的声明顺序。 谁为了对齐或者可读性调整了这三行的顺序,就会悄悄引入 use-after-free, 编译器一句警告都不会给。所以这里应该加一条注释说明顺序不能动, 或者干脆用一个显式的
close()按顺序释放,不依赖声明顺序。」
⬜ 待填:有没有真踩过 EAGAIN 释放那个坑?
(源码核查里没看到循环调用 libssh2_session_free 的写法,
如果确实没处理,就答”知道这个问题但当时没处理,是个隐患”,不要说成做过。)
1.8 NAPI 回调时序竞争
先搞清楚:为什么 C++ 不能直接调 JS 的回调
JS 引擎是单线程的。 它内部的所有数据结构(对象、GC 状态) 都假设”只有一个线程在动我”。
所以:C++ 的工作线程绝对不能直接去调 JS 函数—— 那等于两个线程同时动 JS 引擎的内部状态,必崩。
正确做法是”投递”:C++ 线程把数据放进一个队列,
然后通知 JS 线程”有活了”,由 JS 线程自己在合适的时机取出来执行。
napi_threadsafe_function(简称 tsfn)就是这个队列 + 通知机制。
C++ 工作线程 JS 线程(单线程)
读到数据
↓
napi_call_threadsafe_function(数据) → [ tsfn 队列 ]
↓ JS 线程空闲时自己取
执行 JS 回调 → xterm.js 渲染
%%{init: {"themeVariables": {"fontSize": "14px"}}}%%
sequenceDiagram
participant N as 网络
participant W as C++ 工作线程
participant Q as tsfn 队列
participant JS as JS 线程(单线程)
N->>W: 数据到达
W->>Q: napi_call_threadsafe_function(数据)
Note over W: 立刻返回,继续读下一批
Q-->>JS: JS 线程空闲时自己来取
JS->>JS: 执行 JS 回调 → xterm.js 渲染
Note over W,JS: ❌ W 绝不能直接调 JS 函数<br/>JS 引擎是单线程的,两个线程同时动它必崩
✅ 代码里实际用来防这个的三个标志
napiInterface.cpp 里有三个 std::atomic<bool>,正好对应上面说的三类风险:
| 标志 | 防什么 |
|---|---|
isCallbackValid | 回调是否还有效——JS 侧已经注销/销毁后,C++ 侧不再往里投 |
tsfnCreated | tsfn 是否已创建——防止在创建前就调用,或重复创建 |
g_directoryCallbackRegistered | 目录变更回调是否已注册 |
📌 为什么这三个必须是
atomic<bool>而不是普通bool: 它们天然是跨线程读写的——JS 线程注册/注销时写,C++ 工作线程投递前读。 普通bool在这里就是数据竞争;而它们又只是单个标志位, 上锁反而会和别的锁产生嵌套,所以 atomic 正合适。 这就是 1.4 里”atomic 用在哪”的实际答案。
⬜ 还要回去翻代码 / 提交记录
这题面试官会问「具体怎么复现的、怎么修的」—— 上面三个标志是”防住了什么”,但你当时遇到的那个具体 bug 是什么现象, 只有提交记录里有。简历上写了这条,就一定被追问细节。
技术背景:三种经典竞态
① 生命周期倒挂(最常见的崩溃)
C++ 侧:还在调 napi_call_threadsafe_function 往里投数据
JS 侧:对象已经被 GC 回收了 / 整个环境已经销毁了
→ 💥 崩溃
💡 **GC(垃圾回收)**是什么:JS 不需要手动释放对象, 引擎会自动检测”没人引用它了”然后回收。 问题是你不知道它什么时候回收——这就和 C++ 侧的手动管理对不上了。
解法:引用计数配对
napi_acquire_threadsafe_function(tsfn); // 我要用了,别销毁
// ... C++ 线程持续使用 ...
napi_release_threadsafe_function(tsfn, napi_tsfn_release); // 我用完了
只要还有人没 release,tsfn 就不会真正销毁。 这和 shared_ptr 的思路一样。
② 回调乱序 → 终端输出错乱
tsfn 的队列本身是 FIFO(先进先出)的。 但如果多个工作线程各自往里投,那么”投递的顺序”不等于”数据产生的顺序”:
时刻 1:线程 A 从网络读到 "hello"
时刻 2:线程 B 从网络读到 "world"
时刻 3:线程 B 先抢到 CPU,投递了 "world"
时刻 4:线程 A 才投递 "hello"
→ JS 侧收到的是 "worldhello" 💥 终端显示乱了
解法:
- 每个会话只用一个读线程投递(最简单,也是终端场景的常规做法)
- 或者在数据里带序号,JS 侧按序重组(复杂,一般不值得)
③ 销毁时机:最后一批输出会不会丢
napi_release_threadsafe_function(tsfn, napi_tsfn_abort);
// ↑ 丢弃队列里所有还没处理的回调
napi_release_threadsafe_function(tsfn, napi_tsfn_release);
// ↑ 等队列排空,全部处理完再销毁
终端场景通常要 release 而不是 abort——
否则用户执行了一条命令,输出还没渲染完就关了标签页,
最后那批输出就丢了。有时候恰恰是最关键的报错信息。
📌 这题如果你确实修过,一定要讲具体的: 什么现象(是崩溃还是丢数据还是乱序)、怎么复现的(快速开关会话? 大量输出时关闭?)、定位用了什么手段(日志?gdb?)、最后怎么改的。 一个讲得清楚的真实 bug,胜过十条背下来的知识点。
1.9 这个项目能直接用的五个故事
面试官问「讲一件你做过的具体的事」时,从这张表里挑,不要临场想。 每一个都有代码依据,都能撑 3 分钟。
| 他问什么 | 你讲哪个 | 落到哪 |
|---|---|---|
| 讲一次真实的排障 | SOCKS5 握手失败,实为客户端配成 HTTP CONNECT,最后把结论写进错误提示 | 1.6 |
| RAII 到底带来了什么 | 两种 deleter 写法的取舍 + SFTP 失败路径靠析构自动清理,没有一行 cleanup | 1.7、1.6 |
| 讲讲你的并发设计 | 五把锁的职责划分 + keepalive 线程和关闭怎么不打架(检查与使用同锁内) | 1.4、1.5 |
| 你觉得自己代码哪里写得不好 | runAsyncInThreadPool 每次多起一个 detached 线程;status 漏了一处锁 | 1.3、1.4 |
| 底层网络你做过什么 | 非阻塞 connect 四步:O_NONBLOCK → connect → select 可写 → SO_ERROR 判定 | 1.5 |
🔑 第四行是最容易被忽略、又最能拉开差距的一题。 大多数人被问”你代码有什么问题”会说”没什么问题”或者说些无关痛痒的。 你有两个真实的、能讲清机制、并且知道怎么改的缺陷——准备好,主动讲。
2. 升级子系统
2.0 前置:这段经历到底在做什么
一句话
让一台已经卖出去的设备,能把自己的系统换成新版本,而且不能换坏。
这就是 OTA(Over The Air,空中升级)。你手机每次”系统更新”, 背后就是这套东西。
📌 这是你简历上最硬的一段经历,因为: 大部分 C++ 候选人写的是业务代码,能刷机、能定位升级失败的人极少。 第四档 4.1 把这个做成了一道完整的系统设计题,两处一定要对照着看—— 那边讲的是”如果让你设计”,这边讲的是”你当时具体做了什么”。
必须先搞清的六个词
① 分区(partition) 设备的存储(一般是 eMMC/UFS 闪存芯片,相当于手机里的”硬盘”) 被切成好几块,每块放不同的东西:
┌──────┬────────┬────────┬────────┬──────────┐
│ boot │ system │ vendor │ patch │ userdata │
│ 启动 │ 系统本体│ 厂商驱动│ 补丁 │ 用户数据 │
└──────┴────────┴────────┴────────┴──────────┘
② 镜像(image) 一个分区里的内容,打包成的一个文件。 你装电脑系统用的 ISO 就是一种镜像。 “刷机”就是把镜像文件写进对应的分区。
③ 全量包 vs 差分包
- 全量包:新版本的完整镜像,几百 MB。简单可靠,但用户流量心疼
- 差分包:只包含”新版和旧版的差异”,可能只有几 MB。 设备拿”旧镜像 + 差异”算出新镜像。 ⚠️ 代价是必须知道设备当前是哪个版本——用 V1 生成的差分包 打到 V2 设备上,会得到一个损坏的镜像
④ 连续升级 设备停在 V1,最新是 V4。让它依次打 V1→V2→V3→V4。 好处是服务器不用为每一对版本组合都生成一个包(那是 N² 个包), 坏处是中间任何一步失败都要有兜底。
⑤ flashd 设备端负责”刷写”的那个服务进程。 主机上敲一条刷机命令,最终就是它在设备里执行真正的分区写入。 你这段经历的主要产出就是它。
⑥ hdc
主机和设备之间的通信工具(HarmonyOS Device Connector)。
如果你用过安卓的 adb,它就是鸿蒙版的 adb——
主机上敲 hdc xxx,命令通过 USB 或网络送到设备里执行。
整件事的全景
开发者的电脑 设备
┌──────────────┐ ┌────────────────────────┐
│ 打包工具 │ ① 生成升级包 │ │
│ (算差分、 │────────────────> │ │
│ 算哈希、 │ │ │
│ 打包签名) │ │ │
└──────────────┘ │ │
┌──────────────┐ ② 传输命令和包 │ ┌──────────────────┐ │
│ hdc 命令 │───── USB/网络 ───> │ │ hdc daemon │ │
└──────────────┘ │ └────────┬─────────┘ │
│ ↓ │
│ ┌──────────────────┐ │
│ │ flashd ← 你做的 │ │
│ │ 校验、定位分区、 │ │
│ │ 写入、回传状态 │ │
│ └────────┬─────────┘ │
│ ↓ │
│ [ 各个分区 ] │
└────────────────────────┘
2.1 flashd 整体流程
📌 面试官想验证什么
你是不是真的从头到尾走过这条链路,还是只改过其中一个函数。 判断方法:他会挑链路上的某一环往下问细节。
⬜ 这是你的项目,必须能在白板上画出完整链路。 参考骨架:
① 主机侧敲 hdc 命令(flash / format / erase / update)
↓ USB 或网络传输
② 设备侧 hdc daemon 收到,转给 flashd
↓
③ flashd 解析命令,判断要做哪种操作
↓
④ 升级包校验(签名 → 防篡改;哈希 → 防损坏)
↓
⑤ 分区定位(查分区表 / GPT,找到目标分区的起始偏移和大小)
↓
⑥ 写入(直接写块设备?还是挂载后按文件写?)
💡 "块设备"= 按【固定大小的块】读写的存储设备(磁盘、eMMC、U 盘),
在 Linux 上表现为 /dev/sda、/dev/mmcblk0 这样的文件。
「直接写块设备」= 把它当成一个大字节数组,按偏移量写原始数据,不经过文件系统。
↓
⑦ 状态回传给主机
%%{init: {"flowchart": {"htmlLabels": true, "nodeSpacing": 34, "rankSpacing": 40}}}%%
flowchart TD
subgraph HOST["开发者的电脑"]
CMD["hdc 命令<br/>flash / format / erase / update"]
end
subgraph DEV["设备"]
D["hdc daemon"]
F["flashd ← 你做的"]
CK{"校验<br/>签名 + 哈希"}
LOC["查分区表 GPT<br/>定位目标分区偏移与大小"]
WR["写入分区"]
RB["回滚 / 报错"]
P[("各个分区")]
end
CMD -->|"USB / 网络"| D
D --> F
F --> CK
CK -->|"通过"| LOC
CK -->|"不通过"| RB
LOC --> WR
WR --> P
WR -->|"失败"| RB
WR -->|"成功"| ST["状态回传给主机"]
RB --> ST
你要能说清的四件事
| 问题 | 为什么问这个 |
|---|---|
| 命令怎么下发的 | 考你懂不懂 hdc 那一层的协议和进程间通信 |
| 包怎么传的 | 是先整个传到设备存起来,还是边传边写? |
| 写入是流式还是先落盘 | 这决定了断电风险有多大——见下 |
| 失败了怎么回滚 | 这是必问,也是最能拉开差距的一问 |
💡 “流式写入 vs 先落盘”是什么意思,为什么重要:
- 先落盘:先把整个升级包完整传到设备的某个分区存好, 校验通过之后再开始往目标分区写。 ✅ 传输过程中断电完全没事,包还在,重来就行 ❌ 需要额外一块和包一样大的存储空间
- 流式:边收边写,不落盘。 ✅ 省空间 ❌ 传输中断电,目标分区就是写了一半的状态 → 高风险
这个取舍面试官很爱问,因为它直接关系到设备会不会变砖。
💡 “变砖”(brick):升级升坏了,设备开不了机,像块砖一样。 这是 OTA 领域唯一真正的红线——宁可升级失败,不可开不了机。
⬜ 你们是哪种?为什么?
分区定位那一步:GPT 是什么
💡 分区表就是记录”这块存储被切成了几块、每块从哪到哪、叫什么名字”的一张表。 GPT(GUID Partition Table)是现在的标准格式(老的叫 MBR)。 flashd 要把镜像写到
patch分区,就得先查分区表, 知道patch分区从第几个字节开始、有多大。相关命令见 2.2.7:
parted -l、sgdisk -p、lsblk -f。
2.2 差分升级校验链路
先搞清楚:差分升级为什么危险
差分包的本质是:「基于版本 A 生成的补丁,只能打在版本 A 上」。
正确: [设备是 V1] + [V1→V2 的差分包] = 完整正确的 V2 ✅
错误: [设备是 V1.5] + [V1→V2 的差分包] = 一个【损坏的】镜像 ❌
↑
最可怕的是:它往往【不会立刻崩】。
系统照常开机,直到运行到某个功能才炸 —— 极难定位,
而且这时候设备已经在用户手里了
所以差分升级必须有两道校验。
📌 为什么要校验目标版本 SHA256?防什么?
标准答案是三个层次,要全说出来:
① 打之前,验证「基线版本对不对」 先确认设备当前的版本确实是 A,不是就直接拒绝,不允许打。
② 打之后,验证「结果对不对」 这才是”目标版本 SHA256”的作用。 差分算法(bsdiff、xdelta、Android 的 imgdiff)本身不保证输出一定正确: 传输错误、闪存坏块、算法边界 bug 都可能让结果偏差一点点。
做法:工具侧在打包时就算好”打完之后应该是什么哈希”,写进包的元信息; 端侧打完补丁后把结果读回来重新算一遍哈希,和记录的对比。 不一致就判失败,回滚。
📌 这就是你简历上「工具侧用 Python 计算目标版本 SHA256, 端侧解析并扩展校验」那条的完整价值—— 别只说”我加了个校验”,要说清它堵住了什么风险。
③ 和签名的区别(这一条能答出来就超过大多数人)
| 防什么 | 怎么工作 | 挡得住谁 | |
|---|---|---|---|
| 哈希(SHA256) | 损坏(完整性) | 算一遍比对 | 传输出错、坏块 |
| 签名 | 篡改(安全性) | 厂商私钥签,设备内置公钥验 | 恶意攻击者 |
⚠️ 哈希本身不防篡改——攻击者既然能改镜像,当然也能把哈希一起改掉。 只有签名能防,因为攻击者没有厂商的私钥。两者都要,缺一不可。
💡 还有第三种攻击叫「版本回滚攻击」: 攻击者把一个旧的、签名完全合法的固件推给设备(比如某个有已知漏洞的老版本)。 签名和哈希全对,但设备被降级到了有漏洞的版本。 防这个要靠防回滚版本号(anti-rollback counter): 设备里存一个只增不减的计数,低于它的固件直接拒绝。 这条说出来很加分,见第四档 4.1 的高频追问。
连续升级:把单步校验串成链条
场景:设备停留在很旧的版本,要连着打多个差分包(V1→V2→V3→V4)。 不做全量是因为差分包小得多,省流量和存储。
怎么保证中间每一版都正确:
V1 --打补丁--> 校验哈希 == V2 的哈希? --通过--> V2
↓ 不通过
停止 + 回滚
V2 --打补丁--> 校验哈希 == V3 的哈希? --通过--> V3
...
每一步打完立刻校验,通过才允许进入下一步。
%%{init: {"flowchart": {"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 34}}}%%
flowchart TD
V1(["设备当前 V1"]) --> P1["打 V1→V2 差分包"]
P1 --> C1{"回读算哈希<br/>== 包里记的 V2 哈希?"}
C1 -->|"✅ 一致"| V2(["V2"])
C1 -->|"❌ 不一致"| F["判失败 → 回滚<br/>⬜ 你们选的是哪种兜底?"]
V2 --> P2["打 V2→V3 差分包"]
P2 --> C2{"回读算哈希<br/>== V3 哈希?"}
C2 -->|"✅"| V3(["V3"])
C2 -->|"❌"| F
V3 --> P3["…直到最新版本"]
📌 这张图就是你那件事的价值所在: 没有「目标版本哈希」这个能力,中间每一步是对是错根本无从判断, 错了要到最后才发现,那时已经回不去了。 是它把单步验证变成了一条可以串联的链条。
📌 这正是你那件事的价值: 「计算目标版本 SHA256 + 端侧扩展校验」这个能力, 把单步的正确性验证变成了一条可以串联的链条—— 没有它,连续升级就没法保证中间态是对的, 一旦错了要到最后才发现,那时候已经没法回退了。
这句话是把”我加了个哈希校验”讲成”我支撑了一个能力”的关键。
⬜ 待填:你们中间某一步失败了是怎么处理的?
三选一:① 停在中间版本,下次继续 ② 回滚到起点 ③ 降级为全量包兜底。
这是必问题,profile 待填清单里也有这条。
2.3 ext4 → EROFS(高频追问)
先搞清楚:文件系统是什么,为什么要换
💡 文件系统就是”在一块存储上组织文件的格式”。 同样一块闪存,用 ext4 格式和用 EROFS 格式, 里面文件的排布方式、元数据结构、能不能压缩,全都不一样。 就像同样一堆数据,可以存成 CSV 也可以存成 Parquet。
你做的事:把补丁分区的文件系统从 ext4 换成 EROFS。
⚠️ 先记住一个坑(说错就露怯)
面试官很可能问:「ext4 又不支持压缩,你说的提升 50% 是跟什么比?」
准确说法是:
「ext4 镜像不带透明压缩,EROFS 镜像默认开 lz4 块压缩。 我对比的是同样的内容分别打成两种镜像后的文件体积。」
❌ 不要说成「ext4 的压缩效率低」—— ext4 主线根本没有透明压缩这个特性,这么说等于暴露你没搞清楚。
为什么切 EROFS
EROFS = Enhanced Read-Only File System, “Read-Only”是关键——它从设计上就是只读的。
为什么”只读”能带来这么大的好处: 可读写的文件系统(ext4)必须时刻准备着”有人要往里写”, 所以要预留一堆空闲块、维护复杂的元数据、留出碎片整理的余地。 而这些对一个打好就再也不改的镜像来说全是浪费。
| 特性 | 说明 | 对补丁分区意味着什么 |
|---|---|---|
| 只读设计 | 不用考虑写入 | 补丁分区本来就不需要写,语义天然匹配 |
| 块级压缩(lz4) | 固定大小的压缩块,可随机访问 | 镜像体积显著下降(你的 50% 就来自这里) |
| 元数据紧凑 | inode 紧凑编码,无冗余预留 | 小文件多的场景省得特别多 |
💡 inode 是什么:文件系统里记录「一个文件的元信息」的结构体—— 大小、权限、时间戳、以及内容实际存在哪些块上。 文件名不在 inode 里,文件名只是目录里的一条「名字 → inode 号」的记录。 每个文件都要占一个 inode,所以小文件特别多时,inode 本身就是可观的开销—— 这就是”元数据紧凑”能省下体积的原因。 | 低内存开销 | 解压缓存策略比 squashfs 省 | 端侧内存受限,这点很关键 | | 随机读性能好 | 只解压真正需要的那个块 | 优于 squashfs 的大块压缩 |
📌 面试官可能追问:为什么不用 squashfs
squashfs 是更老牌的只读压缩文件系统,很多嵌入式项目在用。
「squashfs 的压缩块更大,随机读一个小文件也要解压整个大块, 延迟和内存占用都更差。EROFS 是专门为移动端只读场景重新设计的, 用固定大小的压缩块换随机读性能,这正好是端侧要的取舍。」
兼容挂载:怎么判断一个镜像是什么文件系统
为什么需要判断:老版本的设备上补丁分区还是 ext4, 新版本是 EROFS。端侧的代码必须两种都能挂载, 所以先得知道手上这个是哪种。
💡 **“挂载”(mount)**是什么:把一个分区”接”到目录树的某个位置上, 之后就能通过路径访问里面的文件。见 2.2.7。
三种方式,说清楚区别:
| 方式 | 怎么做 | 优缺点 |
|---|---|---|
| ① 读 superblock magic | 直接读特定偏移的几个字节比对: EROFS magic = 0xE0F5E1E2(偏移 1024)ext4 magic = 0xEF53(偏移 1024 + 0x38) | 最可控,不依赖任何外部组件, 出错也好定位 |
② mount 不指定 fstype | 内核会依次尝试所有已注册的文件系统 | 简单;但不可控, 而且失败时错误信息很含糊 |
③ blkid / libblkid | 用户态库,探测逻辑成熟 | 稳妥;但引入依赖, 嵌入式上不一定有 |
💡 “superblock magic”是什么:每种文件系统在镜像的固定位置 写了一个特定的数字当”身份证”。读那几个字节一比对就知道是什么格式。 这是所有文件系统探测的底层原理,
blkid内部也是这么干的。
⬜ 你们用的哪种?
收益怎么测的
简历上删掉了 50% 这个数,但口头必须能给出量级和测量方式:
测量方式:同一份补丁内容,分别打成 ext4 镜像和 erofs 镜像,比较文件大小
⬜ 待填:典型样本的绝对数值(例如 ext4 XX MB → erofs XX MB)
⬜ 待填:有没有测过挂载耗时和随机读性能的变化?
(如果没测,如实说"当时只关注了体积",不要编)
2.4 镜像与分区解耦
先搞清楚:这个问题是怎么产生的
传统做法是:镜像文件的大小 == 分区的大小。
于是问题来了:patch 分区被划成了 128MB,
但这个版本实际只有几十 KB 的补丁内容。
按传统做法,你也得生成一个128MB 的镜像文件,
后面 127.9MB 全是零填充。
patch 分区(128MB)
┌────────┬──────────────────────────────────────────┐
│ 20KB │ 0 0 0 0 0 0 0 0 ... 全是零 ... │
│ 真内容 │ ← 这 127.98MB 的零也要打进升级包 │
└────────┴──────────────────────────────────────────┘
这些零虽然能压缩(一堆零压缩率极高),但依然浪费: 打包时要处理它、传输时要传压缩后的它、写入时要真的把 128MB 写进闪存 (闪存写入是有寿命的,也是最慢的一步)。
你做的事:解除这个绑定,镜像只包含实际内容。 结果就是简历上那条:空镜像从 32MB / 128MB 降到 20KB。
📌 必然的追问:分区剩下的空间怎么办
这是这题的真正考点。你只写有效数据了,那分区后面那 127MB 是什么状态?
三个要点:
① 镜像里只放有效数据,同时在元数据里记录目标分区大小 写入程序需要知道”我要写到哪个分区、那个分区多大”, 这个信息从镜像本身挪到了元数据里。
② 写入时只写有效部分,剩余区域要么不动、要么显式擦除
💡 “擦除”(erase / TRIM):闪存有个特性——写之前必须先擦。 显式擦除还能告诉闪存控制器”这块我不要了”, 有利于它做磨损均衡。对应的是
ioctl(fd, BLKDISCARD, ...)。
③ ⚠️ 最关键的风险:残留数据会不会被当成有效数据读出来
分区里现在是:[新的 20KB 内容][ 上个版本残留的 127MB 旧数据 ]
↑
如果挂载时按【分区大小】去解析文件系统,
就可能把这堆旧数据当成文件系统的一部分读进来 → 脏数据、挂载失败、
或者更糟:读到上个版本的文件,症状诡异到查不出来
解法:必须保证文件系统的 superblock 里记录的大小, 是镜像的实际大小,而不是分区大小。 挂载时以文件系统自己声明的大小为准,而不是以分区大小为准。 这样后面的残留数据就在文件系统的边界之外,根本不会被碰到。
📌 这一段是整个 2.4 的核心。 能把”为什么解耦之后不会读到脏数据”讲清楚, 说明你真的理解了分区、镜像、文件系统这三层的关系, 而不只是改了个参数。
⬜ 待填:你们怎么处理残留区域的?擦除,还是靠 fs 元数据边界挡住?
2.5 C++ → Rust 接口迁移
先搞清楚:这件事是在做什么
原来 flashd 和 hdc 之间的接口层是 C++ 写的,你把它换成了 Rust, 精简了 1k+ 行代码。
为什么要用 Rust:Rust 在编译期就能杜绝内存安全问题 (野指针、悬垂指针、数据竞争), 而升级模块是出错就变砖的地方,对安全性要求极高。 所以近几年系统软件领域普遍在往 Rust 迁移。
💡 FFI(Foreign Function Interface):不同语言之间互相调用的机制。 C++ 调 Rust、Rust 调 C++,都要走 FFI。 而 FFI 的通用桥梁是 C ABI——因为 C 的调用约定最简单、最稳定, (💡 「调用约定」= 调用一个函数时,参数放在哪几个寄存器、 多余的参数怎么压栈、返回值放哪、谁负责清理栈。 两边约定不一致,参数就会串位,直接崩。 ABI = Application Binary Interface,就是这一整套二进制层面的约定, 除了调用约定还包括结构体怎么排布、名字怎么编码。) 几乎所有语言都支持和 C 互操作。
C++ 自己反而不能直接跨语言暴露,因为它有名字改编(💡 C++ 支持重载,
foo(int)和foo(double)是不同函数, 所以编译器会把函数名编成_Z3fooi这种带类型信息的名字,详见 2.2.5)、 有异常、有模板,这些东西没有跨语言的统一表示。
⬜ 这题必须翻代码确认
下面是你要能回答的三个具体问题,我给出可能的答案形态供你对号入座:
| 问题 | 可能的答案形态 |
|---|---|
| 交互方式是什么 | ① Rust 侧用 #[no_mangle] extern "C" 导出函数,C++ 直接调② 用 cbindgen 从 Rust 代码自动生成 C 头文件 ③ 用 cxx crate(提供双向的安全绑定) ④ C++ 侧保留一层薄封装,实现全部换成 Rust |
| 数据结构怎么跨边界 | 只传 POD(简单数据类型)或 #[repr(C)] 标记的结构体;字符串传 *const c_char + 长度(不要传 std::string 或 Rust String);所有权必须明确:谁分配谁释放,或者提供配对的 free 函数 |
| 错误怎么传递 | 返回整型错误码——不能跨 FFI 抛 C++ 异常,也不能让 Rust panic 穿出去; Rust 侧必须用 catch_unwind 把 panic 兜住,否则 panic 跨越 FFI 边界是 UB(未定义行为,见下) |
💡 POD(Plain Old Data):就是”朴素的老式数据”—— 只有基本类型成员、没有虚函数、没有自定义构造析构的结构体。 它的内存布局是确定的,所以能安全地跨语言传。 而
std::string内部有指针、有可能的小字符串优化, 它的内存布局是编译器实现相关的,Rust 那边根本没法正确解析。
📌 「平滑过渡」指什么——这个词会被追着问
简历上写了”平滑过渡”,面试官一定会问”怎么个平滑法”。 答不出具体策略,这个词就是水词。
标准答案的形状是:接口先行、双轨并存、逐步切换。
① 先定义一个稳定的 C ABI 接口
← 关键:C++ 实现和 Rust 实现【都满足同一个接口】
② 双轨并存:通过编译开关或运行时开关切换
← 同一份测试跑两边,对比行为是否完全一致
③ 验证通过后,下线 C++ 实现
这样做的好处:任何一步出问题都能立刻切回去, 不会出现”改完发现有问题但回不去了”的局面。
⬜ 你们实际是哪种?
如果是「直接重写替换」,也如实说,但要能讲清怎么保证行为一致的
——有回归测试吗?有对比验证吗?还是靠 code review?
2.6 gtest 相关
先搞清楚:为什么这题难
💡 gtest(Google Test)是 C++ 最主流的单元测试框架。 单元测试就是给一个函数/类单独写测试代码, 每次改完自动跑一遍,确保没改坏。
但你这个项目有个特殊困难: 你的核心逻辑是往真实分区写数据。 单元测试总不能每跑一次就真的把开发机的分区刷一遍吧?
所以必答题是:「分区写入这种硬件相关操作怎么测?」
📌 标准答案:依赖注入 + 接口抽象
核心思路:让被测代码不要直接调硬件,而是调一个接口。 测试时给它换一个假的实现。
// ① 把硬件操作抽成一个接口(纯虚基类)
class IBlockDevice {
public:
virtual ~IBlockDevice() = default;
virtual int Write(uint64_t offset, const void* buf, size_t len) = 0;
virtual int Read (uint64_t offset, void* buf, size_t len) = 0;
};
// ② 生产环境的实现:真的去操作 /dev/blockX
class RealBlockDevice : public IBlockDevice { /* open + pwrite */ };
// ③ 测试环境的实现:写到一块内存里
class FakeBlockDevice : public IBlockDevice {
std::vector<uint8_t> data_; // 用内存假装成一个块设备
public:
int Write(uint64_t off, const void* buf, size_t len) override {
memcpy(data_.data() + off, buf, len);
return 0;
}
// ...
};
// ④ 被测逻辑依赖【接口】,不依赖具体实现
class Flasher {
IBlockDevice& dev_; // ← 注入进来的
public:
explicit Flasher(IBlockDevice& d) : dev_(d) {}
bool WriteImage(const Image& img);
};
这就叫「依赖注入」:被测对象需要什么,从外面传给它,
而不是它自己去 new 一个。这样测试时就能换成假的。
📌 这个手法和 1.1 讲的分层是同一个道理: 可测试性来自”不依赖具体的东西”。 面试时把这两处串起来说,能体现你有一贯的设计观念。
进阶:用 gmock 验证调用序列
MockBlockDevice dev;
EXPECT_CALL(dev, Write(0x1000, _, 4096)).Times(1); // 期望被这样调用一次
Flasher f(dev);
f.WriteImage(img);
这比只验证最终结果更强——它能验证过程对不对: 有没有写到正确的偏移、有没有多写一次、参数对不对。
其他可说的手段
① 临时文件 + loop device
dd if=/dev/zero of=fake.img bs=1M count=128 # 造一个假的"分区"
losetup -f --show fake.img # 变成 /dev/loop0,看起来就是个块设备
这样能测到真实的块设备路径,介于单元测试和集成测试之间。 (命令见 2.2.7。)
② ⚠️ 注入故障 —— 这一条最能体现工程成熟度
在 Fake 实现里故意制造失败:
class FaultyBlockDevice : public IBlockDevice {
int failAtOffset_;
public:
int Write(uint64_t off, const void* buf, size_t len) override {
if (off >= failAtOffset_) return -EIO; // 写到一半就失败
// ...
}
};
这样就能测:
- 写到一半失败,回滚路径对不对
- 短写(只写进去一部分)能不能被正确检测
- 校验失败时会不会正确地拒绝并回滚
📌 升级模块最重要的代码恰恰是异常路径—— 正常升级成功大家都会写,失败了能不能安全地退回去才是命根子。 如果你真的做过故障注入测试,这一定要讲, 它比”我写了 50 个用例”有说服力得多。
⬜ 待填:实际写了哪些用例?覆盖了哪些异常路径?
3. PyTorch musl 移植
3.0 前置:这件事到底在做什么
一句话
让 PyTorch 能在一种官方不支持的 Linux 上跑起来。
为什么会有这个需求
平常你装 PyTorch 就一行 pip install torch,它会下载一个预编译好的包。
但这个包只能在特定的系统上用。
💡 wheel 是什么:Python 的预编译安装包格式(
.whl文件)。 里面是编译好的二进制,不用你在本地重新编译。💡 manylinux 是什么:PyPI 上 Linux wheel 的标准。 它规定”这个包只依赖某个版本以上的 glibc 和一小组基础库”, 这样绝大多数主流 Linux 发行版(Ubuntu、CentOS、Debian)都能装。
问题在于 manylinux 是绑死 glibc 的。
glibc 和 musl 是什么关系
💡 libc(C 标准库) 是所有程序的地基——
printf、malloc、open、read,还有线程、DNS 解析,全在里面。 任何一个 C/C++ 程序都要链接一份 libc。
Linux 上有两个主要实现:
| glibc | musl | |
|---|---|---|
| 用在哪 | Ubuntu、CentOS、Debian…… 绝大多数发行版 | Alpine Linux、很多嵌入式系统(Yocto) |
| 特点 | 功能全、性能好、体积大、扩展多 | 极小、极简、静态链接友好 |
| 为什么有人用 musl | — | Docker 镜像能从几百 MB 降到几 MB; 嵌入式设备存储紧张 |
它们不兼容。 用 glibc 编的二进制放到 musl 系统上根本跑不起来 (具体原因见 3.1)。
所以这件事的价值在哪
Alpine 容器 / K8s ┐
ARM64 服务器(鲲鹏、 │
倚天、Graviton) ├──> 都跑 musl 或需要 aarch64 ──> 官方 wheel 装不上
端侧、边缘(Yocto) ┘ ↑
(PyTorch issue #71381 就是在说这事)
你做的:从源码开始,用交叉编译的方式, 编出一份能在 aarch64 + musl 上跑的 PyTorch CPU 推理运行时。
💡 “交叉编译”:在 x86 的 PC 上,编出 aarch64 能跑的程序。见 2.5.1。 你这里是双重困难:既要换架构(x86 → aarch64), 又要换 libc(glibc → musl)。
📌 这段经历在面试里的定位: 它证明的是你能啃硬骨头、能在没有现成答案的地方自己趟出一条路。 但要小心一个边界——“PyTorch 运行时移植” ≠ “熟悉模型训练和算法优化”。 面试官如果往算法方向问,要主动划清: 「我做的是构建和运行时这一层,模型和算法本身不是我的工作范围。」
3.1 为什么 manylinux wheel 在 Alpine 上不能用
📌 完整答案是三个层次,要全说出来
① 动态链接器路径不同 —— 连第一步都过不去
💡 动态链接器(也叫加载器 ld.so)是什么:见 2.2.5。 简单说:你敲
./app,内核不会直接跑你的代码, 而是先启动一个”加载器”程序,由它去加载所有.so依赖, 全部就位了才跳到你的main。 每个可执行文件的 ELF 头里写死了要用哪个加载器(PT_INTERP字段)。
readelf -l app | grep interpreter
# glibc 编的: [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
# musl 系统上:只有 /lib/ld-musl-aarch64.so.1
# ↑ 路径根本对不上 → 内核找不到加载器 → 直接失败
表现是:No such file or directory —— 而文件明明就在那儿。
这个报错极其迷惑人,因为缺的不是你的程序,是它要求的加载器。
② 符号依赖 glibc —— 就算加载器对了也链不上
manylinux 的 .so 里引用了一堆 glibc 特有的符号:
__libc_start_main、gnu_get_libc_version,
以及带 @GLIBC_2.xx 版本标记的符号。
musl 既不提供这些扩展,也根本不支持”符号版本”这套机制。
💡 「符号版本」是什么:glibc 里同一个函数可以同时存在好几个版本, 符号名写成
memcpy@GLIBC_2.2.5、memcpy@@GLIBC_2.14这样。 这样 glibc 改了函数行为后,老程序还能绑到老版本,保证向后兼容。 你见过的version GLIBC_2.34 not found报错就是这套机制的产物。详见 2.2.5。 (符号版本是什么见 2.2.5。)
③ 行为差异 —— 就算强行链上也会在运行时炸
| 差异 | 后果 |
|---|---|
LFS64 接口(open64、lseek64 这类) | musl 里不存在(它本来就全是 64 位的),链接报未定义符号 |
strerror_r 签名不同 | GNU 版返回 char*,POSIX 版返回 int。用错了拿到的错误信息是垃圾 |
| DNS 解析:musl 不支持 NSS | 依赖 /etc/nsswitch.conf 的场景行为不同 |
| ⚠️ 线程栈大小:musl 默认 128KB,glibc 是 8MB | 深递归、大栈数组直接段错误。 而且现象诡异——栈溢出不一定当场崩,可能悄悄改坏别的数据 |
💡 「段错误」(segmentation fault)是什么:你访问了一个 操作系统没分配给你、或者权限不允许的内存地址,内核只能把你的进程杀掉。 野指针、悬垂指针、数组越界、栈溢出,最终都是通过这条路崩的。 完整机制(页表 + 缺页异常)见 2.2.1。
📌 线程栈这一条最值得讲,因为它是运行时才暴露的差异, 编译链接全过,跑起来才随机崩。 解法是显式
pthread_attr_setstacksize()调大。
PEP 656 的意义
正因为上面这些差异是根本性的,Python 社区专门定了一个标准(PEP 656),
给 musl 平台一个独立的 wheel 标签:musllinux_1_2_aarch64。
这样 pip 就不会在 Alpine 上错装 manylinux 包了—— 装不上会明确报”没有匹配的 wheel”,而不是装上之后神秘崩溃。
3.2 Toolchain File 的关键设置
💡 Toolchain File 是什么、为什么需要它:见 2.5.1 的交叉编译部分。 简单说:它是告诉 CMake「目标是什么架构、用哪个编译器、 去哪找头文件和库」的一个配置文件。
set(CMAKE_SYSTEM_NAME Linux) # ← 设了它,CMake 才知道这是交叉编译
set(CMAKE_SYSTEM_PROCESSOR aarch64)
set(CMAKE_C_COMPILER clang) # 用 Clang,因为它天生支持多目标
set(CMAKE_CXX_COMPILER clang++)
set(CMAKE_C_COMPILER_TARGET aarch64-linux-musl) # 相当于 --target
set(CMAKE_CXX_COMPILER_TARGET aarch64-linux-musl)
set(CMAKE_SYSROOT /path/to/aarch64-musl-sysroot) # 目标平台的头文件和库在这
# 查找规则:程序在宿主机找,库和头文件只在 sysroot 里找
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM BEFORE)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
💡 为什么用 Clang 而不是 GCC: GCC 是”一个编译器只能编一个目标”——要编 aarch64 就得装一个
aarch64-linux-gnu-gcc,换个目标就得换个编译器。 Clang 是天生的交叉编译器,同一个 clang 通过--target就能切换目标平台。 你只要准备好对应的 sysroot 就行。这是选 Clang 的实际理由。
📌 --target 和 --sysroot 分别解决什么(必问的区分题)
这两个非常容易混,面试官就爱考这个。
--target | --sysroot | |
|---|---|---|
| 管什么 | 代码生成 | 依赖查找 |
| 具体是 | 生成哪种指令集(ARM64 还是 x86)、 用什么 ABI、 预定义哪些宏( __aarch64__、要不要定义 __GLIBC__) | 去哪里找头文件和库 ( <sysroot>/usr/include、<sysroot>/usr/lib) |
一句话(背这句):
--target决定「产出长什么样」,--sysroot决定「拿什么去拼」。
两个都对才能编出能跑的东西:
target 对,sysroot 错 → 生成的是 aarch64 指令,但链上了 x86 的 glibc 库 💥
sysroot 对,target 错 → 库是对的,但生成的是 x86 指令,设备上跑不了 💥
📌 补一句更精确的:
--target里的musl那部分很关键—— 它决定了编译器要不要定义__GLIBC__这个宏。 大量三方库靠#ifdef __GLIBC__来选代码分支, 这个宏不对,编出来的代码逻辑就是错的(见 3.4 第一条)。
3.3 三方库适配 —— 挑 2~3 个讲
先搞清楚:为什么三方库是最大的工作量
PyTorch 本身依赖十几个第三方库(protobuf、Eigen、cpuinfo、sleef……)。 PyTorch 自己的代码反而好编——难的是这十几个依赖,每一个都可能有自己的坑。
⬜ 从你实际踩过的里选 2-3 个讲。下面列的是这几个库的典型问题, 用来唤起你的记忆,不要直接背成”我遇到的”—— 面试官对这类库多半比你熟,编了会穿帮。
| 库 | 干什么的 | 常见交叉编译问题 |
|---|---|---|
| protobuf | 数据序列化,PyTorch 用它存模型 | 它需要一个 protoc 代码生成器在构建时运行。交叉编译时必须分两次构建:先编一份 x86 的 protoc 给构建机用, 再编 aarch64 的运行时库给设备用。 这是所有 codegen 类依赖的通病,能讲清楚很加分 |
| cpuinfo | 运行时探测 CPU 有哪些特性 | 它的构建过程会在宿主机上探测 CPU—— 但宿主机是 x86,探测出来的结果对目标机毫无意义。 通常要手动指定目标架构宏,或打补丁跳过探测 |
| sleef | 数学函数的向量化实现(sin/cos 等) | 构建期也要跑 host 工具生成查找表,同样是 host/target 分离问题; 另外 NEON 相关的编译选项要对上 |
| Eigen | 线性代数库(矩阵运算) | 纯头文件库,问题主要在内存对齐和 NEON 内联汇编 在 Clang 下的兼容性(很多代码是按 GCC 写的) |
💡 「内存对齐」是什么:CPU 读一个 8 字节的 double 时, 要求它的地址是 8 的倍数(对齐),否则要么变慢,要么某些架构上直接崩。 SIMD 指令要求更严(常常是 16 或 32 字节对齐), 所以数值计算库里到处都是对齐相关的代码——换个编译器/架构就容易出问题。
💡 NEON 是什么:ARM 的 SIMD 指令集 (一条指令同时算 4 个浮点数),相当于 x86 的 SSE/AVX。 数值计算库都要用它才能跑得快。
📌 通用规律(说出来显得有体系,比列举具体库更值钱)
「交叉编译里最难的从来不是『编译目标代码』——那个配好 toolchain 就行了。 真正难的是区分 host 工具和 target 产物。 凡是构建过程中要『跑一下自己刚生成的东西』的依赖, 在交叉编译时都会出问题——因为刚生成的是目标架构的,在构建机上跑不了。 protobuf 的 protoc、sleef 的表生成器、cpuinfo 的探测,全是同一类问题。」
这段话的价值:它把一堆零散的踩坑,总结成了一条能预测下一个坑的规律。 面试官要的就是这个。
3.4 glibc 与 musl 的具体差异
⬜ 挑你真踩过的说。 常见的有:
① __GLIBC__ 宏不存在(影响面最大的一条)
#ifdef __GLIBC__
// 大量三方库在这里走 glibc 的高效实现
#else
// musl 下走进了这个 fallback 分支 —— 可能根本没人测过
#endif
后果不是编不过,而是走进了一条几乎没人验证过的代码路径, 问题往往在运行时才暴露。
② gnu_get_libc_version / backtrace() 不存在
backtrace() 是 glibc 的扩展,用来在崩溃时打印调用栈。
musl 不提供 → 很多库的”崩溃时打印栈”功能要整个禁掉。
代价是:以后在 musl 上崩了,排查更难。
③ strerror_r 签名不同
char* strerror_r(int, char*, size_t); // GNU 版:返回 char*
int strerror_r(int, char*, size_t); // POSIX 版:返回 int(musl 用这个)
用错了拿到的错误信息是垃圾,而且编译时只是个警告,容易漏掉。
④ ⚠️ 线程栈大小:musl 默认 128KB,glibc 是 8MB
这是最容易炸、也最难查的一条:
- 深递归、大的栈上数组,在 glibc 下没事,musl 下直接段错误
- 而且栈溢出不一定当场崩——它可能先悄悄踩坏相邻的内存, 程序继续跑,过一会儿在一个完全不相干的地方崩掉
解法:显式设置 pthread_attr_setstacksize()。
⑤ 动态链接器行为不同
- musl 的
ld.so对DT_RPATH的某些语义支持不完整 - musl 没有
ld.so.cache(glibc 靠ldconfig生成的那个索引) → 库的搜索路径行为不一样,RUNPATH必须设得更完整
⑥ iconv / locale 是最小化实现
musl 只支持 C locale。依赖多语言本地化的功能会有差异。
3.5 CPython 扩展 ABI 与平台标签
先搞清楚:Python 怎么加载 C++ 写的模块
import torch 的时候,Python 最终要加载一个 C++ 编译出来的 .so
(torch/_C.***.so),这个叫扩展模块。
关键在于:这个 .so 的文件名不是随便起的。
torch/_C.cpython-312-aarch64-linux-musl.so
↑ ↑ ↑
│ │ └─ 平台标识
│ └─ Python 版本(3.12)
└─ 模块名
Python 在 import 时,会按 importlib.machinery.EXTENSION_SUFFIXES
里列的后缀去匹配文件名。
⚠️ 名字不对,Python 根本不会把它当成扩展模块—— 报的是
ModuleNotFoundError(“找不到模块”), 而不是加载错误。 这个误导性极强:文件明明在那儿,Python 说找不到。
权威来源:python -c "import sysconfig; print(sysconfig.get_config_var('SOABI'))"
——这条命令告诉你这个 Python 解释器期望的后缀到底是什么。
wheel 的平台标签:musllinux_1_2_aarch64 里的 1_2
指的是要求 musl 的最低版本是 1.2。
📌 torch._C 加载失败怎么排查 —— 标准流程,能背出来很加分
# 1. 先看【真实报错】,不要停在 "ImportError" 就完事
python -c "import torch._C"
# 2. 看有没有依赖找不到
ldd torch/_C.*.so # 出现 "not found" 就是缺库
# 3. 看这个 .so 自己声明了什么
readelf -d torch/_C.*.so | head # NEEDED(依赖谁)/ RUNPATH / SONAME
# 4. 看加载器【实际】搜了哪些路径 ← 最有用的一条
LD_DEBUG=libs python -c "import torch"
# 5. 看每个符号最终绑到了哪个库
LD_DEBUG=bindings python -c "import torch"
# 6. 看有哪些未定义符号
nm -D --undefined-only torch/_C.*.so
(这些命令的详细说明见 2.2.5。)
最常见的两类失败:
| 失败 | 原因 | 解法 |
|---|---|---|
找不到 libtorch_cpu.so | RUNPATH 没设对——_C.so 和它在同一个目录,但加载器不会自动去”当前目录”找 | -Wl,-rpath,'$ORIGIN'⚠️ 单引号必须有,否则 shell 会先展开 $ORIGIN |
undefined symbol: xxx | 链接顺序不对,或某个库根本没链上 | nm -D 两边对一下,看谁该提供这个符号 |
💡
$ORIGIN是什么:一个占位符,加载器在运行时把它替换成 “这个 .so 文件自己所在的目录”。 这样整个 Python 包目录搬到哪都能跑,不需要装到系统目录,也不用设环境变量。 做可重定位的分发包必用,见 2.2.5。
3.6 性能问题的诚实答法
面试官会问:「性能怎么样?和 glibc 版本比有没有回归?」
直接答(这段可以背下来):
「这个项目我的验证范围是功能可运行——
import torch、Tensor 运算、torch.nn、autograd、torch.jit.trace和 TorchScript 推理跑通。 我没有做和 glibc 版本的性能基准对比。如果要做,我会用固定的模型和固定输入,对比单次推理延迟和吞吐量, 重点关注 malloc 密集的场景—— musl 的内存分配器是为小内存占用和低碎片设计的, 在多线程大块分配上通常弱于 glibc 的 ptmalloc, 这是我预期最可能出现性能回归的地方。」
这个答法的价值有三层:
- 承认没做——不含糊、不吹
- 说清该怎么做——证明你懂基准测试的方法
- 给出有依据的技术判断——“分配器差异”这个点说明你真的理解 glibc/musl 的区别, 而不只是”编过一次”
📌 比含糊说”性能还行”强得多。 而且分配器差异这条,在 2.2.8 排查内存问题那里也用得上, 是你能反复复用的一个知识点。
4. 德科:迁移方法论与 20+ 工具
4.0 前置:这段经历在做什么
把 20 多款原本跑在 Windows/x86 上的开发者工具, 改成能在鸿蒙 PC(ARM64)上跑。
工具包括:cppcheck(静态检查)、tokei(代码统计)、git-lfs、 NotepadNext(编辑器)、HexEdit(十六进制编辑器)、 Appium / JMeter / Gatling(测试工具)等等。
📌 这段经历的正确讲法(profile 里已经定了调): 不要逐个罗列 20 款工具——那听起来像流水账, 而且会让人觉得”每个都只是浅浅碰了一下”。
要讲的是方法论:你怎么判断一个工具该走哪条路线。 “能给一类问题定标准”是高级工程师最直接的信号, 比”我干了 20 个项目”值钱得多。
4.1 三条路线各举一例
核心:三条路线的判定标准
这张表是你这段经历最值钱的产出:
| 路线 | 例子 | 判断依据(这才是要讲的核心) |
|---|---|---|
| ① 直接交叉编译 | cppcheck / tokei / git-lfs | 纯命令行工具,没有 GUI 框架依赖; 平台相关代码集中在少数几个文件 |
| ② 剥离内核 + 重接 UI | NotepadNext (Qt + Scintilla) | UI 和业务内核之间有清晰的边界; 内核(Scintilla 文本引擎)本身是平台无关的 |
| ③ 完全重新设计 | CrossShellNext (对标 PuTTY) | 与平台 API 深度耦合、没有分层; 移植成本 > 重写成本 |
📌 把第 ③ 条和 1.0 讲的 PuTTY 决策串起来讲—— 那不是一个孤立的决定,而是这套方法论的一次应用。 有方法论 vs 碰运气,面试官一听就分得出来。
⬜ 每条准备一个能讲 3 分钟的例子,
重点讲你当时看到了什么才做出这个判断,而不是后来干活的过程。
📌 面试官一定会追问:你怎么量化「移植成本 > 重写成本」
这题答不好,整套方法论就变成了拍脑袋。 提前想好,可以说:
① 扫描平台相关调用的【数量和分散度】
—— 是集中在 3 个文件里,还是散在 100 个文件里?
grep 一下 Win32 API 的调用点就能有个量级判断
② UI 与业务逻辑的【耦合程度】
—— 有没有一个"内核"是可以单独拿出来的?
比如 Scintilla 就是;PuTTY 的网络代码和 Win32 消息循环搅在一起,就不是
③ 有没有【可复用的核心资产】
—— 算法、协议实现、数据结构。
PuTTY 的协议知识可以复用,但代码不行
④ 上游是否还在维护
—— 如果上游活跃,改造版以后每次同步上游都要重新打补丁,
这个长期成本要算进去
💡 第 ④ 条很多人想不到,但它是真实的工程成本。 说出来能体现你考虑的是长期维护而不只是”这次能不能编过”。
NotepadNext 那条要注意的边界
⚠️ profile 里有明确记录:NotepadNext 是 Qt + Scintilla 架构, 你做过 Qt UI 层与编辑内核的解耦—— 但这不等于”我会写 Qt”。
面试被问到时要主动划清: 「我做的是架构层面的拆分——判断哪部分是 UI、哪部分是内核, 然后把内核抽出来重接界面。 Qt 本身的开发我没有系统做过,简历技能栏里也没写 Qt。」
你已经明确放弃 Qt 方向,这个边界一定要守住, 不然会招来一堆你不想要的岗位。
⬜ 待填:NotepadNext 那个项目,你到底是只做了拆解替换,
还是实际写过 Qt 代码?这决定了你能不能说”Qt 应用架构解耦”这句话。
4.2 基础设施与复用
这一节要证明什么
不是”我做了 20 个项目”,而是”我做完第 3 个之后,第 4 到第 20 个变快了”。
⬜ 待填(这三个都要有具体内容):
① 统一 CMake Toolchain 里具体包含什么?
参考清单:编译器路径、--target、sysroot、CMAKE_FIND_ROOT_PATH_MODE_*
四条查找规则、公共编译选项(优化级别、警告级别、-fvisibility=hidden)、
公共依赖的安装路径。
(每一项是什么见 2.5.1。)
② NAPI 模板包含什么?
参考清单:模块注册的样板代码、异步任务的封装(napi_async_work /
threadsafe_function 的常用包装)、C++ ↔ napi_value 的类型转换工具、
统一的错误处理约定。
(NAPI 是什么见 1.0。)
③ ⚠️ 被复用了几次?
📌 这个数字比”20+“更有说服力。 「我做了 20 个工具」听起来像体力活; 「我沉淀的 toolchain 被后面 15 个项目直接复用, 新工具接入从 X 天降到 Y 天」——这才是工程价值。
如果没有精确数字,给个量级也行,但一定要有这个维度。
4.3 挑一个失败或返工的案例
📌 这是整个第一档里最能拉开差距的一题
面试官问「有没有做砸过的 / 有没有返工的」时, 说「没有」是最差的答案——它只说明两件事之一: 你没做过有难度的事,或者你不愿意反思。
💡 面试官问这个不是要挑你毛病, 他在看两件事:① 你能不能客观评价自己的判断; ② 教训有没有变成可复用的机制。
⬜ 准备一个真实案例,按这个结构讲
1. 当时的判断是什么,基于什么信息做的
—— 要说清"在当时那个信息条件下,这个判断是合理的"
(否则就变成了单纯的疏忽,那是另一回事)
2. 实际遇到了什么,判断为什么错了
—— 具体,有细节。含糊的失败故事听起来像编的
3. 怎么补救的,代价多大
—— 时间成本、人力成本,能给数字最好
4. ⭐ 之后流程上改了什么
—— 【这一条最重要】。
前三条讲的是"我犯了个错",第四条讲的才是"我把错变成了资产"
举例说明第 4 条该长什么样(不要照抄,用你自己的):
「后来我在迁移评估清单里加了一条: 上手前必须先扫一遍平台相关 API 的调用点数量和分散度, 而不是凭对项目的印象判断。 这条后来挡住了另外两个看着简单、实际耦合很深的项目。」
这样讲的效果:一个失败案例,最后落到了”我改进了团队的流程”, 这正是面试官在高级岗上要找的东西。
第二档:通用基础题(零基础讲解 + 完整答案)
来源:岗位调研第五节「第一梯队」。这些不看简历也会问。
这一档已按零基础重写。 每个知识点的结构固定是三段: ① 原来有什么问题 → ② 这个东西怎么解决 → ③ 📌 面试怎么答。 赶时间就只看 📌,那是能直接说出口的成品答案; 想真正弄懂就从 ① 顺着读,前面的铺垫都是为后面服务的。
2.1 C++
本节按零基础重写:每个知识点先讲「原来有什么问题」,再讲「这个特性怎么解决」, 最后才是面试答法。想快速复习就只看每小节末尾的 📌 面试怎么答。
2.1.0 前置:C++ 的内存模型(先搞懂这个,后面全通)
C++ 里的变量分三个地方放,面试里 80% 的坑都源于搞混这三个。
int g = 1; // ① 全局/静态区:程序启动到结束一直在
void f() {
int a = 2; // ② 栈(stack):函数进入时分配,函数返回时自动销毁
int* p = new int(3); // ③ 堆(heap):你手动申请,必须你手动 delete
delete p;
} // ← 到这里 a 自动没了,但如果忘了 delete p,那块内存就永远回不来
栈:像叠盘子。函数调用就叠一层,返回就撤一层。自动,不用你管。 但栈很小(Linux 上默认 8MB),而且函数返回后里面的东西就没了。
堆:像仓库租位。你 new 一块,它就一直归你,直到你 delete。
好处是能活过函数返回,坏处是全靠你自觉。
于是产生了 C++ 最经典的四种 bug:
| Bug | 什么意思 | 例子 |
|---|---|---|
| 内存泄漏 | new 了忘了 delete,内存一直被占着 | 循环里 new,跑一天内存涨到 OOM |
| 野指针(wild pointer) | 指针从来没被正确初始化过,值是栈上的垃圾 | int* p; *p = 1; |
| 悬垂指针(dangling pointer) | 指针曾经是合法的,但它指向的对象已经没了 | delete p; p->foo();或返回局部变量的地址 |
| 重复释放(double free) | 同一块内存 delete 两次 | 两个指针指同一块,各自 delete → 堆结构被破坏 |
⚠️ 野指针和悬垂指针是两回事,别混用(口语里常被笼统地都叫”野指针”, 但面试里最好分清):
野指针:从未指向过有效对象。根源是没初始化。
int* p; // ❌ p 的值是栈上遗留的垃圾 *p = 1; // 往一个随机地址写 → 可能崩,也可能悄悄改坏别人的数据防法很简单:声明即初始化(
int* p = nullptr;),或者干脆别用裸指针。悬垂指针:曾经有效,但对象的生命周期结束了。根源是生命周期管理错误。
Widget* p = new Widget(); delete p; // 对象没了,但 p 还保存着那个地址 p->foo(); // 💥 use-after-free int* bad() { int x = 42; return &x; } // 返回局部变量地址,函数一返回 x 就没了这个才是真正难的——因为”什么时候该释放”往往牵涉多个线程、多个模块。 智能指针、RAII、weak_ptr 这一整套机制,解决的都是悬垂问题,不是野指针问题。
两者的共同后果都是 UB,表现上也都常是
SIGSEGV, 所以现象上分不出来——要靠 ASan 报告里的”这块内存在哪分配、在哪释放”来区分: 悬垂会告诉你freed by thread T at ...,野指针则是访问了一个根本没被分配过的地址。
💡 Java / Python / JS 没有这些问题,因为它们有 GC(垃圾回收器) 自动帮你收。C++ 没有 GC——这就是为什么 C++ 面试永远绕着「生命周期」打转。 面试官问你智能指针、RAII、移动语义,本质上都在问同一件事: 这块内存归谁管,什么时候还。
2.1.1 智能指针
这一节是 C++ 面试问得最多的一块,也是最容易背了却讲不清的一块。 所以我按这个顺序讲:为什么需要 → 所有权这个核心概念 → 三种指针各自解决什么 → 怎么用 → 面试怎么答。
第一步:先看没有智能指针时有多难受
void bad() {
Widget* w = new Widget();
if (!w->init()) {
delete w; // ← 出口 1,要记得 delete
return;
}
if (w->isEmpty()) {
delete w; // ← 出口 2,又要写一遍
return;
}
w->doSomething(); // ⚠️ 如果这里抛异常,函数直接跳走,
// 下面那行 delete 根本执行不到 → 泄漏
delete w; // ← 出口 3
}
三个出口就要写三次 delete,而且挡不住异常——
doSomething() 一抛,函数直接退出,delete 被跳过了。
智能指针的全部意义就一句话: 把「什么时候释放」从「程序员记得写」变成「编译器保证执行」。
它是怎么做到的:智能指针本身是一个栈上的对象,
里面包着一个堆指针。而 C++ 保证——
栈对象离开作用域时,析构函数一定会被调用,正常 return 会调,
抛异常导致的栈展开也会调。所以只要把 delete 写进析构函数,就一定跑得到。
void good() {
auto w = std::make_unique<Widget>(); // w 是栈对象,内部持有堆指针
if (!w->init()) return; // ✅ w 析构 → 自动 delete
if (w->isEmpty())return; // ✅ 同上
w->doSomething(); // ✅ 抛异常也会析构 → 自动 delete
} // ✅ 正常结束也析构
这个「用栈对象的析构来管理资源」的模式就叫 RAII, 是 C++ 的核心思想,2.1.2 会专门讲。智能指针只是 RAII 的一个具体应用。
第二步:搞懂「所有权」——理解智能指针的钥匙
这是整节最重要的概念。所有权(ownership)= 谁负责释放这块内存。
裸指针 Widget* p 完全不表达所有权。看到这么一行代码:
void handle(Widget* w);
你根本不知道:
- 这个函数会不会
delete它? - 我传进去之后还能不能用?
- 函数会不会把它存起来长期持有?
这些信息只能靠注释和口头约定——而注释会过期,人会忘。
智能指针把所有权写进了类型系统,让编译器帮你检查:
| 你想表达 | 用什么 | 编译器帮你保证什么 |
|---|---|---|
| 「这块内存只有我一个主人」 | unique_ptr<T> | 不允许拷贝,你想复制一份出来就编译不过 |
| 「多个人共同拥有,最后一个走的关灯」 | shared_ptr<T> | 自动计数,计数归零才释放 |
| 「我要能看它,但我不算主人」 | weak_ptr<T> | 不影响生死;用之前必须先检查还在不在 |
| 「我只是借来用一下,不负责释放」 | T* 或 T& | (无保证,靠约定:看到裸指针就意味着”不拥有”) |
📌 现代 C++ 的裸指针约定: 裸指针 = 非拥有的观察者(non-owning observer)。 也就是说,现代代码里看到
T*,默认它只是”借看一下”, 绝不能对它delete。所有权一律用智能指针表达。 这个观念说出来,比背 API 更能证明你真的在用现代 C++。
第三步:三种智能指针一眼对比
unique_ptr | shared_ptr | weak_ptr | |
|---|---|---|---|
| 所有权 | 独占,只能有一个 | 共享,可以有多个 | 不拥有 |
| 能拷贝吗 | ❌ 只能 move | ✅ 可以拷贝(计数 +1) | ✅ |
| 大小 | 1 个指针(8 字节) | 2 个指针(16 字节) | 2 个指针 |
| 运行时开销 | 零,和裸指针一样快 | 有:计数是原子操作 | 有:lock() 有原子操作 |
| 能直接用吗 | ✅ p->foo() | ✅ p->foo() | ❌ 必须先 lock() |
| 什么时候用 | 默认就用它 | 确实需要多方共享时 | 打破循环引用;回调里持有对象 |
🔑 选型口诀(先背这个,后面的细节都是为它服务): 默认
unique_ptr→ 确实要共享才升级到shared_ptr→ 「不该延长对象寿命」的地方用weak_ptr。
一、unique_ptr:独占所有权
基本用法
#include <memory>
// ✅ 创建:永远用 make_unique(C++14 起),不要写 unique_ptr<T>(new T)
auto a = std::make_unique<Widget>(arg1, arg2); // 参数会转发给 Widget 的构造函数
a->foo(); // 像裸指针一样用
(*a).foo(); // 也行
if (a) { } // 判空:直接当 bool 用
Widget* raw = a.get(); // 拿到裸指针(⚠️ 不改变所有权,你不能 delete 它)
a.reset(); // 立刻释放当前对象,a 变空
a.reset(new Widget()); // 释放旧的,接管新的
Widget* p = a.release(); // ⚠️ 放弃所有权,返回裸指针,a 变空
// 从这一刻起【你自己负责 delete p】
为什么不允许拷贝
auto a = std::make_unique<Widget>();
// auto b = a; // ❌ 编译错误
auto b = std::move(a); // ✅ 转让所有权
// 此后:b 是主人,a 变成 nullptr
原因:如果允许拷贝,a 和 b 就都认为自己是主人,
两个都会在析构时 delete 同一块内存 → double free,堆结构被破坏,程序崩溃。
所以 C++ 直接在类型层面禁止:拷贝构造和拷贝赋值被标记为 = delete,
写了就编译不过——这正是”把所有权写进类型系统”的意思。
💡
std::move在这里的作用是”把a标记成可以被搬空的”, 它本身不搬任何东西,真正的搬运在unique_ptr的移动构造里。 详见 2.1.3。“move-only 类型” 这个说法就是指 “只能移动、不能拷贝”的类型,unique_ptr、std::thread、std::future都是。
零开销:这是它成为默认选择的原因
static_assert(sizeof(std::unique_ptr<Widget>) == sizeof(Widget*)); // ✅ 成立
unique_ptr 里就只有一个裸指针,没有计数、没有额外分配、
所有函数都能内联。用它不比用裸指针慢,
所以没有任何理由为了性能去用裸指针。
⚠️ 一个例外:如果给它自定义 deleter,而 deleter 是函数指针,
unique_ptr就要多存这个函数指针,变成两个指针大小。 用无状态的仿函数(struct +operator())做 deleter 可以保持零开销—— 因为空类型可以被编译器优化掉(空基类优化 EBO)。💡 空基类优化(Empty Base Optimization)是什么: C++ 规定「任何对象的大小至少是 1 字节」(否则两个对象会有同一个地址)。 所以一个没有任何成员的空 struct,
sizeof也是 1。 但如果它是作为基类被继承的,这 1 字节可以被省掉。unique_ptr内部就是把 deleter 当基类继承, 于是无状态的 deleter 一个字节都不占,sizeof(unique_ptr)还是一个指针。
自定义 deleter:管那些不是 new 出来的资源
这是你项目里最直接用得上的。libssh2 的句柄不是 new 出来的,
不能用 delete 释放,必须调 libssh2_session_free()。
// ✅ 推荐:无状态仿函数,零开销
struct SessionDeleter {
void operator()(LIBSSH2_SESSION* s) const {
if (s) libssh2_session_free(s);
}
};
using SessionPtr = std::unique_ptr<LIBSSH2_SESSION, SessionDeleter>;
SessionPtr sess(libssh2_session_init());
// ... 用它 ...
// 作用域结束 → 自动调 libssh2_session_free
// 【正常路径和抛异常路径都会释放】← 这就是价值所在
同样的手法可以管任何 C 风格资源:FILE*(fclose)、
socket fd、malloc 的内存(free)、互斥量、设备句柄。
📌 面试可以这样说:「
unique_ptr不只是管new出来的内存。 我在项目里用它 + 自定义 deleter 封装了所有 libssh2 的 C 句柄, 这样正常路径和异常路径的释放逻辑就统一了, 不需要在每个错误分支里手写libssh2_xxx_free。」
数组版本
auto arr = std::make_unique<int[]>(100); // 100 个 int
arr[0] = 42; // 支持下标,不支持 operator->
unique_ptr<T[]> 是一个特化,它析构时调 delete[] 而不是 delete。
(new[] 出来的必须用 delete[] 释放,用错是 UB。)
实践中:优先用
std::vector,除非你在对接 C API 必须要一块裸数组。
二、shared_ptr:共享所有权
什么时候才真的需要它
不是”想省事就用 shared_ptr”。只有当确实说不清谁应该负责释放时才用:
- 多个模块同时持有同一个对象,谁先用完不确定
- 对象要被放进异步任务/回调里,而调用方可能先退出
如果所有权是明确的(比如”这个对象归 Manager 管”),就用 unique_ptr。
引用计数怎么工作的
auto a = std::make_shared<Widget>(); // 计数 = 1
{
auto b = a; // 拷贝一份 → 计数 = 2
auto c = b; // 再拷一份 → 计数 = 3
std::cout << a.use_count(); // 输出 3
} // b、c 析构 → 计数 = 1
// a 析构 → 计数 = 0 → 【真正 delete Widget】
关键点:shared_ptr 的拷贝很廉价(只是原子地把计数 +1),
但它不是免费的——原子操作在多核上有 cache 一致性开销。
高频拷贝 shared_ptr 是真实存在的性能问题。
📌 一个能加分的细节:函数传参时不要传
shared_ptr的值拷贝, 除非这个函数真的要”拿走一份所有权”。见下面的”参数怎么传”。
内部结构:两个指针 + 一个控制块(面试爱问)
static_assert(sizeof(std::shared_ptr<Widget>) == 2 * sizeof(void*)); // ✅ 16 字节
shared_ptr 变量(栈上,16 字节)
├── ptr ──────────────> ┌──────────────┐
│ │ Widget 对象 │ (堆上)
│ └──────────────┘
└── ctrl ──────────────> ┌──────────────────────┐
│ 控制块(堆上) │
│ strong count : 3 │ ← 有几个 shared_ptr
│ weak count : 1 │ ← 有几个 weak_ptr
│ deleter │
│ allocator │
└──────────────────────┘
为什么要两个计数、分两次释放(这是精确答法):
strong count 归零 → 调用 Widget 的【析构函数】,对象逻辑上死了
weak count 也归零 → 才【释放控制块那块内存】
为什么控制块要多活一会儿?
因为 weak_ptr 需要能回答”那个对象还在不在”,
这个查询要读控制块里的 strong count。
所以只要还有一个 weak_ptr 活着,控制块就不能销毁——
否则 weak_ptr 去查的时候就读到野内存了。
📌 make_shared vs shared_ptr(new T)(高频)
auto a = std::make_shared<Widget>(); // ✅ 默认用这个
std::shared_ptr<Widget> b(new Widget()); // ⚠️ 有理由才用
make_shared | shared_ptr(new T) | |
|---|---|---|
| 堆分配次数 | 1 次:对象和控制块连在一起分配 | 2 次:对象一次,控制块一次 |
| 速度 / 缓存 | 更快;两块数据挨着,cache 友好 | 慢一点 |
| 异常安全 | 安全 | C++17 前有坑(见下) |
| 自定义 deleter | ❌ 不支持 | ✅ 支持 |
| 私有构造函数 | ❌ 不能用(make_shared 在类外,访问不到) | ✅ 能(可配合 friend) |
| 内存归还时机 | ⚠️ 对象那块内存要等 weak count 也归零 才还给系统 | 对象析构就立刻还 |
那个异常安全的坑(C++17 之前):
f(std::shared_ptr<Widget>(new Widget()), g());
C++17 之前编译器可以按 new Widget() → g() → shared_ptr 构造 的顺序执行。
如果 g() 抛异常,那个已经 new 出来的 Widget 就再也没人管了 → 泄漏。
C++17 收紧了求值顺序,这个坑基本消失了,但 make_shared 依然是更好的默认。
make_shared 的内存浪费坑(反过来的一面):
对象和控制块是同一块内存,所以只要还有一个 weak_ptr 活着,
整块内存(包括那个可能有 1MB 的大对象)都还不回系统。
大对象 + 长期存活的 weak_ptr 的场景要注意。
📌 线程安全边界(必问,最容易答错)
标准答案要分三层说,缺一层就不完整:
① 引用计数的增减是原子的 ✅
// 线程 A: auto x = g; ← 各自拷贝出自己的副本
// 线程 B: auto y = g;
// 这是安全的,计数不会算错
② 同一个 shared_ptr 变量被多线程同时读写,不安全 ❌
std::shared_ptr<Widget> g; // 全局变量
// 线程 A: g = std::make_shared<Widget>(); ← 写 g
// 线程 B: auto local = g; ← 读 g
// 💥 数据竞争!因为 g 内部有【两个指针成员】,
// 改的时候不是一步完成的,B 可能读到"改了一半"的状态
解法:C++20 的 std::atomic<std::shared_ptr<T>>,或者自己加锁。
③ 它指向的那个对象,完全不受保护 ❌
shared_ptr 只管”内存什么时候释放”,完全不管对象内部数据的线程安全。
对象里的成员要不要加锁,是你自己的事。
📌 一句话总结(背这句): 「shared_ptr 的线程安全说的是控制块的线程安全—— 多个线程各拿各的副本是安全的; 多个线程动同一个 shared_ptr 变量不安全; 被管理的对象本身完全不受保护。」
引用计数用什么内存序(答出来明显加分)
- 递增用
memory_order_relaxed:只要计数别丢就行,谁先加谁后加无所谓 - 递减必须用
acq_rel:因为减到 0 的那个线程要去析构对象, 它必须能看到其他线程在放手之前对这个对象做的所有写入, 否则析构函数里读到的是过期数据。这就是 release 语义的作用。
(内存序本身见 2.1.7。)
三、weak_ptr:能看,但不算主人
它解决什么问题
有两种情况你需要”指向一个对象,但不想延长它的寿命”:
- 循环引用:A 持有 B,B 又持有 A → 计数永远不归零 → 泄漏
- 回调 / 缓存 / 观察者:我想在对象还活着时用它, 但它该死的时候就该死,不该因为我拿着就赖着不走
weak_ptr 就是干这个的:指向对象,但不增加 strong count。
怎么用(只有一种正确写法)
std::shared_ptr<Session> sp = ...;
std::weak_ptr<Session> weak = sp; // 不影响 sp 的计数
// ✅ 唯一正确的用法:先 lock() 提升,再用
if (auto s = weak.lock()) { // lock() 尝试变成 shared_ptr
s->send(data); // 在 s 存活期间,对象一定不会被销毁
} // s 析构,计数回落
else {
// 对象已经没了,安全地什么都不做
}
为什么必须 lock():weak_ptr 自己不保证对象活着。
lock() 做的是一个原子操作:
「如果 strong count 还大于 0,就把它 +1 并返回一个 shared_ptr;
否则返回空」。加 1 之后,对象在你手里这段时间就一定不会被销毁。
⚠️ 绝对不要用 expired() 做判断
if (!weak.expired()) { // ❌ 检查完这一行之后……
weak.lock()->send(); // 另一个线程可能刚好把对象销毁了 → 这里就崩了
}
这类 bug 有个名字叫 TOCTOU(Time-Of-Check to Time-Of-Use,
检查与使用之间的时间差)。
expired() 只适合用来做统计或打日志,永远不要用它做安全判断。
循环引用:shared_ptr 唯一会泄漏的情况
struct Child;
struct Parent { std::shared_ptr<Child> child; };
struct Child { std::shared_ptr<Parent> parent; }; // ❌ 互相持有
{
auto p = std::make_shared<Parent>(); // Parent 计数 = 1
auto c = std::make_shared<Child>(); // Child 计数 = 1
p->child = c; // Child 计数 = 2
c->parent = p; // Parent 计数 = 2
} // 出作用域:局部变量 p、c 析构 → 两边计数都从 2 降到 1
// 谁都不为 0 → 两个对象都不析构 → 【永久泄漏】
图示:
┌──────────┐ shared_ptr ┌──────────┐
│ Parent │ ────────────> │ Child │
│ count=1 │ <──────────── │ count=1 │
└──────────┘ shared_ptr └──────────┘
两个都在等对方先放手,谁也不放 → 死锁式泄漏
%%{init: {"flowchart": {"htmlLabels": true, "nodeSpacing": 46, "rankSpacing": 40}}}%%
flowchart LR
subgraph BAD["❌ 循环引用:两边都不归零"]
direction LR
P1["Parent<br/>strong = 1"] -->|"shared_ptr"| C1["Child<br/>strong = 1"]
C1 -->|"shared_ptr"| P1
end
subgraph GOOD["✅ 一边改成 weak_ptr"]
direction LR
P2["Parent<br/>strong = 0 → 析构"] -->|"shared_ptr"| C2["Child<br/>随之析构"]
C2 -.->|"weak_ptr<br/>(不计数)"| P2
end
解法:非拥有的那个方向改成 weak_ptr。
struct Child { std::weak_ptr<Parent> parent; }; // ✅ 孩子不"拥有"父亲
🔑 判断谁该用 weak 的原则(背这句): 问自己「如果只剩下这一个引用,对象还应该活着吗?」 —— 不应该,就用
weak_ptr。
📌 你项目里最相关的场景:回调
// ❌ 回调里捕获 shared_ptr,而 Session 又持有这个回调 → 循环引用,Session 永不释放
session->setCallback([session](Data d) { session->handle(d); });
// ✅ 捕获 weak_ptr
std::weak_ptr<Session> weak = session;
session->setCallback([weak](Data d) {
if (auto s = weak.lock()) s->handle(d); // 对象还在才处理,没了就自然跳过
});
这个写法一举两得:既打破了循环引用,又让回调在对象已销毁时能安全地什么都不做, 而不是拿着悬垂指针崩溃。这是你 CrossShellNext 的核心场景,一定要能讲。
四、enable_shared_from_this:在成员函数里拿到自己的 shared_ptr
问题
class Session {
void start() {
pool.post([this] { this->run(); }); // ⚠️ 捕获裸 this
} // 如果任务还没跑,Session 就被销毁了
}; // → 悬垂,崩溃
你想在任务执行期间”保住”自己,就需要一个指向自己的 shared_ptr。
❌ 错误做法
pool.post([self = std::shared_ptr<Session>(this)] { self->run(); });
这会创建一个全新的控制块!于是同一个对象有了两套独立的计数,
两边各自归零时都会 delete → double free。
✅ 正确做法
class Session : public std::enable_shared_from_this<Session> {
public:
void start() {
pool.post([self = shared_from_this()] { self->run(); });
// ↑ 拿到的是【和现有控制块共享】的 shared_ptr,计数正确 +1
// 任务跑完 self 析构,计数 -1
}
};
原理:enable_shared_from_this 基类里藏了一个 weak_ptr<Session>。
当这个对象第一次被 shared_ptr 接管时,shared_ptr 的构造函数会检测到
这个基类,顺手把控制块的信息填进那个 weak_ptr。
shared_from_this() 就是把它 lock() 一下。
两个必须记住的坑
① 构造函数里不能调 shared_from_this()
因为那个内部 weak_ptr 是在 make_shared / shared_ptr 构造完成之后
才被填上的,构造函数执行时它还是空的。
② 对象必须本来就由 shared_ptr 管理
Session s; // 栈对象
s.shared_from_this(); // 💥 C++17 起抛 std::bad_weak_ptr
推论:用了 enable_shared_from_this 的类,
应该把构造函数设为 private,只提供一个返回 shared_ptr 的工厂函数,
从源头上杜绝有人在栈上创建它。你项目里的 SessionFactory 正好该干这件事。
五、📌 智能指针怎么做函数参数(高频,但很多人没想过)
面试官问「你怎么传智能指针」,是在看你懂不懂所有权语义。
| 你的意图 | 参数怎么写 | 说明 |
|---|---|---|
| 我只是用一下,不涉及所有权 | void f(Widget& w)void f(Widget* w) | ✅ 最常见,也是默认选择。 不要因为对象是智能指针管的,就非得传智能指针 |
| 我要拿走独占所有权 | void f(std::unique_ptr<Widget> p) | 按值传,调用方必须 f(std::move(p)),类型本身就说明了”你的指针会被拿走” |
| 我要留一份共享所有权(存起来) | void f(std::shared_ptr<Widget> p) | 按值传,计数 +1 |
| 我只是读一下 shared_ptr 本身 | void f(const std::shared_ptr<Widget>& p) | 避免不必要的计数原子操作 |
⚠️ 最常见的错误:不管三七二十一,所有参数都写
const shared_ptr<T>&。 这会把”所有权”这个信息糊掉,而且逼着所有调用方都必须持有 shared_ptr—— 明明只是要读一下对象的内容,却传不进一个栈上的 Widget。正确的习惯:函数只关心”用对象”时,参数就写
const T&或T&。 只有函数确实要参与所有权管理时,才让智能指针出现在签名里。
// ❌ 过度约束:调用方必须有 shared_ptr 才能调
void print(const std::shared_ptr<Widget>& w) { std::cout << w->name(); }
// ✅ 谁都能调,语义也更清楚:"我只是读一下"
void print(const Widget& w) { std::cout << w.name(); }
print(*sp); // shared_ptr 能调
print(*up); // unique_ptr 能调
print(stackWidget); // 栈对象也能调
六、📌 常见错误清单(面试可能直接给代码让你挑错)
// ❌ 1. 用同一个裸指针构造两个 shared_ptr → 两套控制块 → double free
Widget* raw = new Widget();
std::shared_ptr<Widget> a(raw);
std::shared_ptr<Widget> b(raw); // 💥
// ❌ 2. 对 get() 返回的裸指针 delete
delete sp.get(); // 💥 sp 析构时还会再 delete 一次
// ❌ 3. 用 expired() 做安全判断(TOCTOU,见上)
// ❌ 4. 回调里捕获 shared_ptr 造成循环引用(见上)
// ❌ 5. 智能指针管理数组却用了单对象版本
std::shared_ptr<int> p(new int[10]); // 💥 析构时调 delete 而非 delete[](C++17 前)
// 正确:make_shared<int[]>(10)(C++20)或用 vector
// ❌ 6. 循环里高频拷贝 shared_ptr → 大量原子操作,性能问题
for (auto sp : vec) { ... } // 每次循环都 +1 -1
for (const auto& sp : vec) { ... } // ✅ 加 & 就没了
七、📌 面试怎么答:结合你的项目讲选型
面试官问完智能指针,几乎一定会说「结合你项目说说」。准备好这段:
SessionManager 持有会话 → shared_ptr<Session>
理由:会话对象要被 UI 线程、工作线程、回调多方同时引用,
谁先用完不确定,所有权说不清 → 这正是 shared_ptr 存在的意义
工作线程 / 回调持有会话 → weak_ptr<Session>,用时 lock()
理由:用户可能在工作线程还在读的时候关掉标签页。
如果回调持有 shared_ptr,会话就会被回调"续命",
用户点了关闭却关不掉;持裸指针又会悬垂崩溃。
weak_ptr 让回调能安全地发现"对象已经没了"并跳过。
独占的 C 库句柄(libssh2) → unique_ptr + 自定义 deleter
理由:句柄只归这一个 Session 管,所有权明确,不需要计数的开销;
自定义 deleter 让正常路径和异常路径统一走 libssh2_xxx_free,
不用在每个错误分支里手写释放。
这段话的价值在于:每一句都在说”为什么这么选”,而不是”我用了什么”。 面试官要的就是这个。
最后再背一遍选型口诀:
默认
unique_ptr。确实要共享才升级到shared_ptr。 「不该延长对象寿命」的地方用weak_ptr。 只是借用不涉及所有权,就用裸指针或引用。
2.1.2 RAII 与异常安全
RAII 是什么:C++ 最核心的一个思想
RAII = Resource Acquisition Is Initialization, 中文一般翻成「资源获取即初始化」,但这个翻译很烂,容易看不懂。
真正的意思是:把资源的生命周期,绑定到一个栈对象的生命周期上。
- 构造函数里获取资源(开文件、加锁、连数据库、new 内存)
- 析构函数里释放资源(关文件、解锁、断连接、delete)
- 因为 C++ 保证栈对象离开作用域时析构函数一定被调用 (正常 return 会调,抛异常导致的栈展开也会调), 所以资源一定会被释放。
// 手动管理:容易漏
void bad() {
mtx.lock();
if (cond) return; // ❌ 忘了解锁 → 死锁
doWork(); // ❌ 抛异常也解不了锁
mtx.unlock();
}
// RAII:不可能漏
void good() {
std::lock_guard<std::mutex> lk(mtx); // 构造 → 加锁
if (cond) return; // ✅ lk 析构 → 解锁
doWork(); // ✅ 抛异常也会析构 → 解锁
} // ✅ 正常结束也析构 → 解锁
标准库里到处都是 RAII:lock_guard(锁)、unique_ptr(内存)、
fstream(文件)、jthread(线程 join)。你自己写的 SessionDeleter 也是。
📌 面试怎么答:「RAII 把’记得释放’从人的纪律变成了语言的保证。 它是 C++ 在没有 GC 的情况下还能安全管理资源的根本机制, 而且它管的不只是内存——锁、文件、socket、C 库句柄全都适用。」
异常安全的三个级别(必背,问得很直白)
「你这个函数异常安全吗?」问的就是下面三档的哪一档。
| 级别 | 保证什么 | 生活化理解 |
|---|---|---|
| 基本保证 | 不泄漏资源,对象仍是有效但状态未指定 | 转账失败了,钱可能扣了也可能没扣,但账户没坏 |
| 强保证 | 要么完全成功,要么状态完全不变 | 转账失败,就跟没转过一样(事务) |
| 不抛保证(nothrow) | 保证绝不抛异常 | 一定成功 |
哪些函数「应当」是 nothrow 的:析构函数、swap、移动构造/移动赋值。
标记方法是加 noexcept 关键字。
实现强保证的标准手法:copy and swap
class Buffer {
char* data_;
public:
// 注意:参数是按值传的,拷贝发生在这里
Buffer& operator=(Buffer other) {
swap(*this, other); // swap 保证不抛 → 走到这一步就一定成功
return *this;
} // other 析构,把旧数据带走
};
为什么这样就有强保证:所有可能失败(抛异常)的操作
(分配内存、拷贝数据)都发生在参数传递那一步,那时候 *this 还没被动过。
一旦进入函数体,剩下的只有不会失败的 swap。所以:
要么在拷贝阶段就抛了(*this 完好无损),要么就一定成功。
为什么析构函数不能抛异常
抛异常时,C++ 会做「栈展开」(stack unwinding):一层层往回退,
依次调用每个局部对象的析构函数。如果这时候某个析构函数又抛了新异常,
就有两个异常同时在飞——C++ 处理不了这种情况,直接调 std::terminate()
把整个程序干掉。
所以 C++11 起,析构函数默认就带 noexcept。
如果析构里的操作可能失败(比如关闭文件时 flush 失败),
正确做法是:在析构里吞掉异常并记日志,同时另外提供一个显式的 close()
让调用者能拿到错误。(std::fstream 就是这么设计的。)
2.1.3 移动语义
先看它解决什么问题
std::vector<std::string> makeData() {
std::vector<std::string> v(1000000); // 一百万个字符串
return v; // ← C++11 之前:整个拷贝一遍!
}
auto d = makeData();
v 是个局部变量,函数返回后它就要死了。它内部那块堆内存本来可以直接交给
调用方,完全不用拷贝——但 C++11 之前没有语法能表达「这个对象快死了,
你把它的东西直接拿走吧」。移动语义就是补上这个表达能力。
移动 vs 拷贝的区别(关键就在这张图):
拷贝: 移动:
源 [ 数据 ] 源 [ 数据 ] ──┐
↓ 复制一份 ↑ │ 只改指针,不复制内容
目标[ 数据副本] 源 [ nullptr ]│
目标 [ 数据 ] <┘
拷贝一个装了 1MB 数据的 vector,要 new 1MB 再 memcpy。
移动只要把三个指针(begin / end / capacity)挪过去,再把源置空。
O(n) 变成 O(1)。
左值、右值:一句话分清
- 左值(lvalue):有名字、能取地址的东西。
int a;里的a。 - 右值(rvalue):临时的、马上就要消失的东西。
字面量
42、函数返回的临时对象makeData()。
判据很简单:你能对它取地址(&x)就是左值,不能就是右值。
右值引用 T&&:一种只能绑定右值的引用。
它的含义是「这个对象是临时的,你可以放心偷它的资源」。
void f(const std::string& s); // 绑左值和右值都行,但只能读
void f(std::string&& s); // 只绑右值,可以随便掏空它
std::move 其实什么都没做
最容易误解的一点:std::move 不移动任何东西。
它就是一个类型转换:
template<class T>
constexpr std::remove_reference_t<T>&& move(T&& t) noexcept {
return static_cast<std::remove_reference_t<T>&&>(t);
}
它只是把一个左值强行当成右值看待,好让编译器去挑移动构造那个重载。 真正的移动动作发生在移动构造函数 / 移动赋值函数里面。
std::string a = "hello";
std::string b = std::move(a); // move 只是转类型,真正搬运在 string 的移动构造里
// 此后 a 处于"有效但未指定"状态——可以赋新值,但不要假设它还是 "hello"
⚠️ 被 move 走的对象不是”废了”,它处于有效但未指定的状态。 你可以给它重新赋值、可以析构它,但不要读它的值。
万能引用(转发引用):长得一样,含义完全不同
void f(Widget&& w); // 这是右值引用:只接受右值
template<class T> void g(T&& x); // 这是万能引用:左值右值都接受!
判据只有一个:T&& 中的 T 是不是正在被推导。
Widget&&里Widget是具体类型,没有推导 → 右值引用T&&里的T由实参推导 → 万能引用vector<T>&&也是右值引用(T虽在推导,但形式不是裸T&&)auto&& x = ...也是万能引用
引用折叠规则(万能引用能同时接左右值的原理):
| 推导出的 T | T&& 折叠成 | 结果 |
|---|---|---|
传左值 → T = Widget& | Widget& && | Widget&(左值引用) |
传右值 → T = Widget | Widget&& | Widget&&(右值引用) |
口诀:只要有一个左值引用参与,结果就是左值引用(「& 有传染性」)。
std::forward:把值类别原样传下去
template<class T>
void wrapper(T&& x) {
real(std::forward<T>(x)); // ✅ 传进来是左值就当左值传,是右值就当右值传
// real(std::move(x)); // ❌ 不管传进来是什么,一律当右值 → 把调用者的左值偷空了
}
记法:move 是无条件转成右值,forward 是有条件(按 T 的推导结果)转。
写模板转发参数用 forward,明确要偷资源用 move。
📌 移动构造为什么必须写 noexcept(高频,一定要会)
class Buf {
public:
Buf(Buf&& o) noexcept; // ← 这个 noexcept 不是可有可无的
};
原因在 vector 扩容。vector 满了要换更大的内存,得把老元素搬过去。
它面临一个选择:用移动(快)还是用拷贝(慢)?
- 如果移动构造是 noexcept → 用移动。因为保证不抛,搬到一半不会出事。
- 如果移动构造可能抛异常 → 只能用拷贝! 因为万一搬到第 500 个时抛了,前 499 个已经被掏空了, 老 vector 也回不去了 → 破坏了强异常保证。
vector 内部用 std::move_if_noexcept 做这个判断。
📌 一句话答:「少写一个
noexcept,vector 扩容就会从移动退化成拷贝, 你辛辛苦苦写的移动构造等于白写。所以移动构造和移动赋值必须标 noexcept。」
RVO / NRVO:编译器帮你省掉的那次拷贝
Widget make() {
return Widget{}; // RVO:C++17 起强制省略,根本不会构造临时对象
}
Widget make2() {
Widget w;
return w; // NRVO:可选优化,主流编译器都会做
}
RVO(Return Value Optimization):返回一个纯右值(匿名临时对象)时, 编译器直接在调用方的内存上构造,一次拷贝/移动都没有。 C++17 起这不是优化而是语言规定。
NRVO(Named RVO):返回一个具名局部变量时的同类优化。 标准没强制要求,但 GCC/Clang/MSVC 都实现了。
⚠️ 不要写
return std::move(local);你以为在帮忙,其实是在帮倒忙:std::move(local)的类型是Widget&&, 不再是「具名局部变量」,NRVO 的条件被破坏了, 编译器只能老老实实做一次移动构造。直接return local;更快。
2.1.4 虚函数与多态
多态是什么(零基础版)
多态 = 同一个调用,根据对象的实际类型跑不同的代码。
class Session {
public:
virtual ~Session() = default;
virtual bool connect() = 0; // = 0 表示纯虚:本类不实现,派生类必须实现
};
class SshSession : public Session { bool connect() override { /* SSH 握手 */ } };
class TelnetSession : public Session { bool connect() override { /* Telnet */ } };
void go(Session* s) { s->connect(); } // ← 这里不知道 s 到底是哪种,运行时才定
go() 里的 s->connect() 具体调哪个,是运行时才决定的,这叫动态分发。
这就是你 CrossShellNext 里 Session 基类 + 各协议派生类的机制。
vtable:动态分发是怎么实现的
编译器给每个有虚函数的类生成一张虚函数表(vtable),
表里是这个类所有虚函数的函数指针。
然后给每个对象的开头塞一个隐藏指针 vptr,指向本类的 vtable。
SshSession 对象(内存布局)
┌──────────────┐
│ vptr ────────┼──────> SshSession 的 vtable
├──────────────┤ ┌────────────────────────┐
│ 其他成员... │ │ [0] ~SshSession │
└──────────────┘ │ [1] SshSession::connect│
└────────────────────────┘
调用 s->connect() 的实际过程:
- 从对象里读出
vptr(一次内存访问) - 从 vtable 的固定偏移处取出函数指针(第二次内存访问)
- 通过这个指针调用
所以说虚函数是「两次间接寻址」。
虚函数的三个代价(面试问「开销在哪」就答这三条)
- 空间:每个对象多一个指针(64 位上 8 字节)。
小对象很吃亏——一个只有
int成员的类,加了虚函数体积翻三倍。 - 无法内联(⚠️ 这通常才是最大的代价): 编译期不知道会调到哪个函数,就没法把函数体展开到调用处。 一个本来可以内联的 3 行小函数,变成一次真实的函数调用。
- 分支预测和 cache 不友好:函数指针是间接跳转,CPU 猜不准。 💡 「分支预测」:现代 CPU 是流水线式的,会提前几十条指令开始准备。 遇到分支(if / 函数调用)时它必须猜下一步走哪,猜对了流水线不断, 猜错了要把已经做的工作全丢掉重来(十几到几十个周期)。 虚函数是间接跳转,目标不固定,猜错率高; vtable 在别的内存区域,多一次 cache miss。
📌 进阶答法:「虚函数在热路径上的代价主要不是那两次寻址, 而是丢掉了内联,连带丢掉了内联之后的所有优化机会。 所以在极热的循环里,有时会用 CRTP(静态多态)替代。」
📌 必问:为什么基类析构函数要是虚的
class Base { public: ~Base() {} }; // ❌ 非虚
class Derived : public Base { std::string s; };
Base* p = new Derived();
delete p; // ❌ UB!只调了 ~Base(),Derived 里的 string s 泄漏了
delete p 时,编译器看 p 的静态类型是 Base*。
如果 ~Base 不是虚函数,它就直接调 ~Base(),
~Derived() 根本不会被调用 → 派生类的成员全部泄漏。
标准上这是 UB(未定义行为),不只是泄漏。
改成 virtual ~Base() = default; 就对了:
析构变虚函数后走 vtable 分发,会先调 ~Derived() 再调 ~Base()。
加分的例外情况:如果这个类不打算被多态删除
(比如只是给别人继承复用代码,从来不用 Base* 指向 Derived 再 delete),
可以把析构函数写成 protected 且非虚——
这样 delete basePtr; 编译就不通过,从源头杜绝错误,
同时还省下了 vtable 的开销。
📌 必问:构造/析构函数里调用虚函数会怎样
答案:调到的是当前这一层的版本,不会分发到派生类。
class Base {
public:
Base() { init(); } // ⚠️ 调的是 Base::init,不是 Derived::init
virtual void init() { puts("Base"); }
};
class Derived : public Base {
std::string s_;
public:
void init() override { puts(s_.c_str()); } // 💥 如果真被调到,s_ 还没构造,直接崩
};
原因:构造是从基类往派生类一层层建的。执行 Base 的构造函数时,
Derived 那部分内存还没初始化,vptr 此刻指向的是 Base 的 vtable。
C++ 这么设计正是为了保护你——如果真的分发到 Derived::init(),
它会去读还没构造的 s_,那是必崩的。
析构反过来(从派生类往基类拆),到 ~Base() 时 Derived 部分已经拆完了,
同样只能调 Base 的版本。
实践建议:需要「构造完成后做初始化」的场景, 用两阶段初始化——构造函数只做最基本的赋值, 另提供一个
init()由工厂函数在构造完成后显式调用。 你项目里的SessionFactory就适合承担这个职责。
多继承(了解即可,被问到能说两句就行)
每个基类子对象各自有一个 vptr。通过第二个基类的指针调用时,
this 指针需要做偏移调整(编译器生成的调整代码叫 thunk)。
虚继承为了解决菱形继承的重复子对象问题,还要额外的 vbptr 和运行时偏移查找,
更复杂也更慢。实践中优先用「单继承 + 组合」代替多继承。
2.1.5 C++17 常用特性
简历写了 C++17,就一定会被问「你用了哪些」。 不要背清单——说 2-3 个,每个说清「在哪用的、解决了什么问题」。
| 特性 | 干什么 | 你项目里的用法 |
|---|---|---|
std::optional<T> | 表达「可能没有值」 | 查找会话:找不到返回 std::nullopt,比返回 -1 或空指针语义清楚 |
std::string_view | 只读字符串视图,零拷贝(不复制内容,只记录”从哪开始、多长”) | 解析协议报文、日志格式化 |
| 结构化绑定 | auto [k, v] : map | 遍历 map、接多返回值 |
if constexpr | 编译期分支,不成立的分支不实例化 | 平台差异分支(Windows/Linux) |
💡 「实例化」(instantiation)是什么:模板本身不是代码,是「代码的模具」。 你写
template<class T> void f(T),编译器不会为它生成任何机器码; 直到你写了f(42),编译器才把T换成int,真正生成一份f(int)的代码—— 这个”按模具造出一份具体代码”的过程就叫实例化。 “不实例化”就意味着那段代码根本不会被编译,所以里面就算写了 当前平台不存在的 API 也不会报错——这正是if constexpr能处理平台差异的原因。 |std::variant| 类型安全的 union | 协议消息的多种载荷 | |std::filesystem| 跨平台路径与文件操作 | 配置查找、构建脚本 | |[[nodiscard]]| 返回值被忽略时报警告 | 返回错误码的函数 |
详细讲两个最容易被追问的:
std::optional<T>
// 以前:找不到怎么表达?
Session* find(int id); // 返回 nullptr?调用者可能忘了判空
int findIndex(int id); // 返回 -1?那 -1 是"魔法值",语义靠注释
// 现在:类型本身就说明了"可能没有"
std::optional<SessionInfo> find(int id) {
if (!exists(id)) return std::nullopt;
return info;
}
if (auto r = find(3)) { use(*r); } // 编译器逼你先检查再解引用
std::string_view(⚠️ 有个必须知道的坑)
它是「指针 + 长度」的组合,不持有字符串内容,只是”看”着别人的内存。 好处是传参不拷贝:
void log(std::string_view msg); // 传 const char*、std::string 都不产生拷贝
坑是悬垂:
std::string_view sv = getName(); // ❌ getName() 返回临时 string,
// 这一行结束临时对象就没了,sv 变悬垂
use(sv); // 💥 读已释放的内存
规则:string_view 只能用作函数参数和局部变量,
不要存进成员变量,不要从函数返回。
if constexpr
template<class T>
void dump(const T& v) {
if constexpr (std::is_integral_v<T>) {
printf("%d\n", v); // T 不是整数时,这一段根本不会被编译
} else {
printf("%s\n", v.c_str()); // T 是整数时,这一段也不会被编译
}
}
和普通 if 的区别:普通 if 两个分支都要能编译通过,
if constexpr 不成立的分支直接不实例化。这就是它能处理平台差异的原因。
2.1.6 STL 容器:底层与复杂度
| 容器 | 底层结构 | 查找 | 插入/删除 | 什么时候用 |
|---|---|---|---|---|
vector | 连续数组 | O(n) | 尾 O(1) 均摊 中间 O(n) | 默认首选。连续内存 → cache 友好 |
deque | 分段数组 + 索引表 | O(n) | 两端 O(1) 中间 O(n) | 需要两头进出(队列) |
list | 双向链表 | O(n) | O(1)(已知位置) | 很少真的更快,见下 |
map / set | 红黑树 | O(log n) | O(log n) | 需要有序遍历、范围查询 |
unordered_map / set | 哈希桶 + 链地址法 | 均摊 O(1) 最坏 O(n) | 均摊 O(1) | 只按 key 查,不关心顺序 |
💡 表里两个词先解释一下:
- 「均摊 O(1)」:
vector尾部插入大多数时候是 O(1), 但空间满了要扩容——申请两倍大的新内存、把老元素全搬过去,那一次是 O(n)。 因为每次扩容都翻倍,扩容的次数很少,把这个代价分摊到每次插入上, 平均下来还是 O(1)。这就叫”均摊”(amortized)。 ⚠️ 它的含义是平均很快,但个别一次会很慢——对延迟敏感的场景要注意。- 「红黑树」:一种能自动保持平衡的二叉搜索树。 普通二叉搜索树如果按顺序插入 1,2,3,4… 会退化成一根链表(查找变 O(n)), 红黑树通过插入时的旋转和变色保证树高始终是 O(log n)。
std::map就是用它实现的,所以它天然有序。
为什么「链表插入 O(1)」在现实中经常是假的
理论上 list 中间插入 O(1)、vector 中间插入 O(n),
所以频繁插删应该用 list——但实测经常是 vector 更快。原因有两个:
- 你得先找到插入位置。list 找位置要 O(n) 遍历,
这一步的开销远大于 vector 那次
memmove。 - cache(CPU 缓存)。 💡 内存比 CPU 慢得多(差两个数量级),所以 CPU 里有几层小而快的缓存。 CPU 从内存取数据时不是取 1 个字节,而是一次取一整条「cache line」(通常 64 字节)。 vector 的元素紧挨着,一次加载一条 cache line 就能拿到好几个元素;list 的节点散落在堆各处,每访问一个节点就是一次 cache miss, 而一次 cache miss 大约相当于几百条指令的时间。
📌 面试答法:「我默认用
vector。只有在元素很大、 且需要保持迭代器/引用长期有效的时候才考虑list。 大 O 只算了操作次数,没算每次操作的常数, 而在现代 CPU 上内存访问模式的常数差异可以有一个数量级。」
📌 迭代器失效规则(必背,写错就是崩溃)
「迭代器失效」的意思是:容器变动之后,你手里那个迭代器可能指向了 已经被释放或者搬走的位置,再用它就是 UB。
| 容器 | 什么时候失效 |
|---|---|
vector | 扩容 → 全部失效(内存整体搬家) 中间插入/删除 → 插入点之后的全部失效 |
deque | 中间插入/删除 → 所有迭代器失效 两端操作 → 迭代器失效,但引用和指针不失效 |
list / forward_list | 只有被删除那个元素的迭代器失效 |
map / set | 只有被删除那个元素的迭代器失效 (红黑树旋转只改指针,不搬节点地址) |
unordered_* | rehash → 所有迭代器失效,但引用和指针不失效 |
最常见的踩坑写法:
for (auto it = v.begin(); it != v.end(); ++it) {
if (*it == target) v.erase(it); // ❌ erase 后 it 已失效,++it 是 UB
}
// ✅ 正确写法一:用 erase 的返回值
for (auto it = v.begin(); it != v.end(); ) {
if (*it == target) it = v.erase(it); // erase 返回下一个有效迭代器
else ++it;
}
// ✅ 正确写法二(vector 批量删更推荐):erase-remove
v.erase(std::remove(v.begin(), v.end(), target), v.end());
// C++20 起可以直接 std::erase(v, target);
为什么 erase-remove 更好:std::remove 只做一趟遍历,
把要保留的元素往前挪,返回新的逻辑末尾;再一次性 erase 掉尾巴。
总共 O(n)。而循环里逐个 erase 是 O(n²)。
2.1.7 内存模型与 atomic
写了 atomic 就一定被问。 这节是 C++ 里最抽象的部分, 所以我从「为什么需要它」讲起。
为什么会有”内存序”这种东西
你以为代码是按你写的顺序执行的。其实不是。 有两层重排:
- 编译器重排:编译器为了优化,可能调换两条不相干的语句的顺序
- CPU 重排:现代 CPU 乱序执行;而且每个核有自己的 cache, 一个核写的值不一定马上被另一个核看到
单线程里你察觉不到(编译器和 CPU 保证单线程结果不变)。 但多线程里,另一个线程能看到重排后的中间状态:
// 线程 A
data = 42; // (1)
ready = true; // (2)
// 线程 B
if (ready) // (3)
use(data); // (4) ← 有可能读到 data 还是 0!
因为 (1) 和 (2) 在编译器/CPU 看来互不相干,完全可能被调换顺序。
线程 B 就可能看到 ready == true 但 data 还没写好。
内存序(memory order)就是你告诉编译器和 CPU: “这两个操作的顺序对我有意义,不许调换。“
三个必须能区分的内存序
① memory_order_relaxed——只保证原子,不保证顺序
counter.fetch_add(1, std::memory_order_relaxed);
「原子」的意思是:这次加法不会被打断,不会出现两个线程同时加导致丢一次的情况。 但它不约束周围任何其他操作的顺序。
适用场景:纯计数。比如统计处理了多少条消息——你只关心最后总数对,
不关心谁先谁后。shared_ptr 的引用计数递增也用它。
② acquire / release——成对使用,建立跨线程的”可见性”
这是最有用的一对。规则是:
一个线程用
release写了某个变量,另一个线程用acquire读到了那个值, 那么写线程在 release 之前的所有写入,读线程在 acquire 之后都能看到。
std::atomic<bool> flag{false};
int data = 0;
// 线程 A(生产者)
data = 42; // (1) 普通写
flag.store(true, std::memory_order_release); // (2) ← (1) 不能被重排到 (2) 之后
// 线程 B(消费者)
if (flag.load(std::memory_order_acquire)) { // (3) ← (4) 不能被重排到 (3) 之前
assert(data == 42); // (4) ✅ 保证成立
}
记忆方法:
- release 像”发布”:把之前所有的准备工作关在门里,不许漏到门后
- acquire 像”获取”:把之后所有的使用关在门外,不许提到门前
适用场景:发布-订阅、状态标志、自己实现锁、无锁数据结构。
③ memory_order_seq_cst——默认值,最强也最贵
flag.store(true); // 不写第二个参数,默认就是 seq_cst
seq_cst = sequentially consistent(顺序一致)。保证所有 seq_cst 操作 在全世界所有线程眼里有一个统一的先后顺序。最符合直觉,最不容易写错。
💡 「内存屏障」(memory barrier / fence)是什么:一条特殊的 CPU 指令, 意思是「屏障两边的内存操作不许跨过我重排,而且屏障之前的写必须先对其他核可见」。 你写
memory_order_*,编译器就是根据它决定要不要插、插哪种屏障指令。
代价:在 ARM 这类弱内存模型的 CPU 上,编译器必须插入完整的内存屏障指令
(dmb ish),开销明显。x86 上因为硬件本身就接近顺序一致,代价小得多。
📌 结合项目的标准答法
「CrossShellNext 里的会话状态字用的是默认的
seq_cst。 理由是状态转换不在热路径上,一个会话的生命周期里也就发生几次, 正确性优先于性能,没必要为了几纳秒去冒写错内存序的风险。 如果是高频计数场景我会用relaxed; 如果需要用一个标志位来发布一批数据,那就必须用 acquire-release 配对。」
这个答法的好处:既证明你懂三种序的区别,又体现了「不过度优化」的工程判断。
📌 为什么 x86 上测不出问题,ARM 上就崩(你的主场)
- x86 是强内存模型(TSO,Total Store Order):硬件只允许 StoreLoad 重排,
其余三种重排都不会发生。所以你在 x86 上写
relaxed, 行为经常碰巧和 acquire-release 一样,测不出 bug。 - ARM 是弱内存模型:四种重排都可能发生。同一份代码搬到 ARM64 上, 隐藏的内存序 bug 就会暴露出来,而且是偶现的、极难复现的。
你做 ARM64 移植,这段讲出来非常有说服力: 「x86 上跑得好好的并发代码,交叉编译到 ARM64 之后出现偶现问题, 很多时候根源就是内存序写得不够严格,被 x86 的强模型掩盖了。」
顺带:volatile 不是用来做多线程同步的
这是 C++ 里最常见的误解之一(从 Java 带过来的)。
volatile的作用是:告诉编译器这个变量可能被程序之外的东西改变 (硬件寄存器、信号处理函数),每次都必须真的去内存读,不许缓存到寄存器。- 它不保证原子性(
volatile int x; x++;依然是读-改-写三步,会丢更新) - 它不建立任何跨线程的顺序保证
结论:嵌入式里访问 MMIO 硬件寄存器用 volatile,
多线程同步一律用 std::atomic。(atomic 本身已经包含了 volatile 的效果。)
2.2 Linux / 系统
本节同样按零基础重写。动态库那一节是你的强项,写得最厚—— 但也从「什么是符号」讲起,确保你能把每一句话的来龙去脉说清楚。
2.2.0 前置:用户态、内核态、系统调用
这三个词后面到处都是,先搞清楚。
操作系统把 CPU 的运行状态分成两档:
- 内核态(kernel mode):能做任何事——直接读写物理内存、操作硬件、改页表
- 用户态(user mode):受限——碰不到硬件,只能访问自己那份内存
你写的程序全部跑在用户态。为什么要这样?因为如果每个程序都能直接操作硬件, 一个有 bug 的程序就能把整台机器搞崩,也能随便偷别的程序的数据。
那用户态程序怎么读文件、发网络包? 只能”求”内核帮忙, 这个”求”的动作就叫系统调用(system call,syscall)。
ssize_t n = read(fd, buf, 1024);
// ↑ 这是 glibc 提供的一个 C 函数,
// 它内部执行一条特殊 CPU 指令(x86-64 上是 syscall,ARM64 上是 svc)
// → CPU 切到内核态 → 内核去真正读数据 → 切回用户态
系统调用是”贵”的:一次切换要保存/恢复寄存器、切换栈、可能刷 cache, 大约几百纳秒到几微秒。所以性能优化的一大方向就是”减少系统调用次数” ——缓冲区、批量提交、mmap 都是为了这个。
💡 怎么看一个程序在做什么系统调用:
strace ./app。 这是 Linux 上最有用的排查命令之一,后面会详细说。
2.2.1 进程与线程
进程是什么
进程 = 一个正在运行的程序 + 它独占的一套资源。 最关键的资源是虚拟地址空间。
虚拟地址空间:内核给每个进程编造一套”假的”内存地址(0 到 2^47 之类), 再通过页表(page table)映射到真实的物理内存。
进程 A 的虚拟地址 0x400000 ──页表A──> 物理内存 0x1A000000
进程 B 的虚拟地址 0x400000 ──页表B──> 物理内存 0x3F000000
↑ 地址一样,但映射到完全不同的物理内存 → 互相看不见
这就是进程隔离:A 崩了不影响 B,A 也偷不到 B 的数据。
前置:内存是怎么被管起来的(页、页表、缺页异常)
后面的 COW、mmap、页缓存、段错误全都建立在这一节上,一定要先看懂。
① 内存按「页」来管
物理内存条上有几十亿个字节。操作系统不可能一个字节一个字节地管—— 记录表会大到比内存本身还大。
所以内存被切成固定大小的块,一块叫一个「页」(page),Linux 上默认 4KB。
物理内存(比如 16GB)
┌────┬────┬────┬────┬────┬────┬────┬─── … ───┐
│页0 │页1 │页2 │页3 │页4 │页5 │页6 │ │ 每块 4KB
└────┴────┴────┴────┴────┴────┴────┴─── … ───┘
所有的内存分配、映射、权限保护,都是以页为单位的。
你 malloc(1) 只要 1 个字节,底层依然是按页向内核申请的。
② 页表是一张「对照表」
前面说了:每个进程看到的地址是假的(虚拟地址), 要翻译成真实的物理地址才能用。
页表就是干这个翻译的对照表。每个进程有自己的一张。
进程 A 的页表 物理内存
┌────────┬──────────┬──────────┐
│ 虚拟页 │ 物理页 │ 权限 │
├────────┼──────────┼──────────┤
│ 0 │ 1024 │ 可读可写 │ ────────> 物理内存第 1024 页
│ 1 │ 3312 │ 可读可写 │ ────────> 物理内存第 3312 页
│ 2 │ 777 │ 只读 │ ────────> 物理内存第 777 页
└────────┴──────────┴──────────┘
表里的一行,叫一个【页表项】(page table entry, PTE)
🔑 注意最后那一列:权限。 每一页可以被单独标记成「只读 / 可读可写 / 可执行」。 这一列是理解写时复制的关键,也是”往只读内存写就崩”的原因。
CPU 每访问一次内存,都要先查这张表: 把你给的虚拟地址翻译成物理地址,顺便检查权限对不对。
③ 一个进程的地址空间长什么样
页表把虚拟地址映射到物理内存,而虚拟地址空间本身是分段布局的:
高地址
┌──────────────────┐
│ 栈(stack) │ 局部变量、函数调用帧。【向下】增长
│ ↓ │ ⚠️ 每个线程各有一个栈
├──────────────────┤
│ │ ← 中间这一大片没映射。
│ (未映射) │ 访问这里 = 访问了一个页表里没有的地址 → 段错误
│ │
├──────────────────┤
│ ↑ │
│ 堆(heap) │ malloc / new 出来的。【向上】增长
├──────────────────┤
│ .bss │ 未初始化的全局变量(清零)
├──────────────────┤
│ .data(数据段) │ 已初始化的全局变量。【可读可写】
├──────────────────┤
│ .text(代码段) │ 你的程序指令本身。【只读 + 可执行】
└──────────────────┘
低地址
这里就解释了两个后面反复出现的词:
- 代码段(.text):程序的机器指令。它是只读的—— 程序运行时不该修改自己的指令(能改的话病毒就太好写了)。 正因为只读,它永远不会被写,这一点在 COW 里很关键。
- 数据段(.data):已初始化的全局变量,可读可写。
④ 缺页异常:CPU 说「这事我办不了,内核你来」
CPU 查页表时,可能发现两种情况处理不了:
- 这个虚拟地址在页表里根本没有映射
- 有映射,但权限不符(比如你要写一个标着”只读”的页)
这时 CPU 会触发一个「缺页异常」(page fault): 暂停当前这条指令 → 切进内核态 → 让内核来处理。
⚠️ “异常”这个词有误导性——它不是”出错了”。 缺页异常在 Linux 里每秒发生成千上万次,是完全正常的核心机制。 内核处理完之后,会让 CPU 重新执行刚才那条指令, 你的程序完全不知道刚才发生过什么。
内核收到缺页异常后,可能做这几件事:
| 情况 | 内核怎么处理 |
|---|---|
这页是 malloc 申请了但还没真正用过的 | 现在才真的分配一页物理内存,填进页表 → 正常继续 (这叫延迟分配: malloc 成功不等于物理内存已经给你了) |
| 这页的内容被换到磁盘上了(swap) | 从磁盘读回来,填进页表 → 正常继续 |
| 这页是 mmap 的文件,还没读进来 | 从文件读那一页进来 → 正常继续(见 2.2.6) |
| 这页是 COW 的共享页,有人要写 | 复制一份给写入方(见下一节 2.2.2) |
| 这个地址压根没映射,纯属乱访问 | 没救了 → 给进程发 SIGSEGV 信号 → 段错误崩溃 |
💡 「段错误」(segmentation fault)到底是什么: 就是最后那一行——你访问了一个页表里没有、或者权限不允许的地址, 内核救不了,只能杀掉你的进程。 野指针、悬垂指针、数组越界,最终都是通过这条路崩的。 (见 2.1.0。)
⑤ TLB:为了不让查表拖慢一切
每访问一次内存都要查一次页表,而页表本身也在内存里—— 那不就变成”访问内存前先访问内存”了吗? 而且现代页表是多级的, 查一次要访存 4 次左右。
所以 CPU 里有一小块专用高速缓存叫 TLB(Translation Lookaside Buffer), 缓存最近用过的「虚拟页 → 物理页」映射结果。 命中 TLB 就不用查页表了。
这就是下面”进程切换为什么贵”的原因。
线程是什么
线程 = 同一个进程里的多条执行流,共享同一套地址空间。
进程
├── 地址空间(代码、全局变量、堆)——所有线程共享
├── 打开的文件 fd 表 ——所有线程共享
├── 线程 1:自己的栈、自己的寄存器
├── 线程 2:自己的栈、自己的寄存器
└── 线程 3:自己的栈、自己的寄存器
共享带来好处(通信不用拷贝,直接读同一个变量), 也带来全部麻烦(数据竞争、需要加锁)。
对比表(面试直接问「进程和线程的区别」就答这个)
| 维度 | 进程 | 线程 |
|---|---|---|
| 地址空间 | 独立 | 共享 |
| 内核里的表示 | 都是 task_struct——Linux 内核其实不区分进程和线程,区别只在 clone() 时传了哪些共享标志 | 同左 |
| 通信方式 | 管道、共享内存、socket、消息队列 | 直接读写同一个变量(必须自己加锁) |
| 崩溃影响 | 隔离:一个崩了别的没事 | 一个线程崩,整个进程死 |
| 创建/切换开销 | 大 | 小 |
为什么进程切换比线程切换贵(能答出 TLB 就加分)
💡 先说清「上下文切换」是什么(这个词后面会反复出现): CPU 只有几个核,但系统里有成百上千个线程。 操作系统的做法是让每个线程轮流用一小会儿 CPU(几毫秒), 时间到了就换下一个。
换的那一下就叫「上下文切换」(context switch): 把当前线程的寄存器、程序计数器(执行到哪一行了)等状态保存起来, 再把下一个线程的状态恢复回去。 一次大约几微秒——听起来很短,但如果线程多到 CPU 大部分时间都在切换而不是干活, 系统就废了(这就是 2.4 讲的”一连接一线程”扛不住一万连接的原因之一)。
线程切换只需要换寄存器和栈指针,地址空间不用动(同一个进程的线程共享地址空间)。 进程切换还要换页表——而换页表会导致 TLB 全部作废。
TLB 是什么见上面的分页前置 ⑤。换进程 = 换页表 = 之前缓存的所有映射全作废, TLB 要重新预热,接下来一段时间每次访存都要重新走完整的查表流程,明显变慢。
📌 面试答法:「线程切换只换执行上下文,进程切换还要换地址空间, 代价主要不在保存寄存器,而在TLB 和 cache 失效带来的后续访存变慢。 现代 CPU 有 ASID/PCID 能缓解一部分,但依然比线程切换贵。」
2.2.2 fork / exec
fork:把当前进程复制一份
先看它到底干了什么
普通函数是「调用 → 执行 → 返回一个值」。fork 不是。
它执行的动作是**「把当前这个进程整个复制一份」**,
复制完就有两个进程了,两个进程都从 fork() 的下一行继续往下跑。
看这段代码:
printf("A\n");
pid_t pid = fork();
printf("B, pid=%d\n", pid);
实际输出是三行:
A
B, pid=12345 ← 父进程打的
B, pid=0 ← 子进程打的
A 只打了一遍,B 打了两遍。
因为执行到 fork() 那一瞬间,进程变成了两个,
这两个进程手里的代码、变量、执行到哪一行全都一模一样,
所以它们都会去执行下一行的 printf。
🔑 所谓「一次调用,两次返回」,不是同一个进程返回了两次, 是【两个进程各自返回了一次】。
那怎么知道我是哪一个
两个进程内存完全相同、代码位置也相同,
唯一的区别就是 fork() 给它们的返回值不一样:
fork() 返回给它什么 | |
|---|---|
| 父进程 | 子进程的 PID(一个大于 0 的数,比如 12345) |
| 子进程 | 0 |
所以下面这个 if/else 的真相是:
两个进程都在执行同一个 if/else 语句,
但因为 pid 的值不同,走进了不同的分支。
pid_t pid = fork();
if (pid == 0) {
// 只有【子进程】会走进来(它拿到的是 0)
} else if (pid > 0) {
// 只有【父进程】会走进来(它拿到的是 12345)
} else {
// fork 失败(内存不够、进程数超限)
}
不是”你写的代码被执行了两遍”,而是”有两个进程在各自执行这份代码”。
为什么是这么分的
- 子进程拿 0:子进程想知道父进程是谁,随时可以调
getppid(), 不需要fork告诉它。而 0 不是任何一个合法的 PID, 拿它当”你是子进程”的标记不会有歧义。 - 父进程拿子进程的 PID:父进程没有别的办法知道刚生出来的孩子是谁。
后面要
waitpid等它、要kill它,全靠这个号。
写时复制(COW,Copy-On-Write)
问题
fork 要复制一个进程。如果进程占了 4GB 内存,
老老实实复制 4GB 内容太慢了——而且很多时候纯属浪费,
因为 fork 之后最常见的下一步就是 exec,整个内存马上要被换掉。
解法:先不复制,等有人要写的时候再说
第 1 步:fork 时只复制页表,不复制内容
父进程页表 物理内存
虚拟页0 → 物理页1024 │【只读】 ──┐
虚拟页1 → 物理页3312 │【只读】 │
├──> 物理页 1024、3312 …(只有这一份)
子进程页表 │
虚拟页0 → 物理页1024 │【只读】 ──┘
虚拟页1 → 物理页3312 │【只读】
两张表指向【同一批物理页】,
而且 ⚠️ 【父子两边的权限都被内核改成了只读】
复制一张对照表,比复制 4GB 内容快好几个数量级。 而这时两个进程看到的内容当然完全一样——它们看的本来就是同一块内存。
第 2 步:谁要写,谁触发缺页异常
假设子进程执行 x = 5,而 x 正好在虚拟页 0 上:
① CPU 查页表:虚拟页 0 → 物理页 1024,权限【只读】
② 你要写一个只读页 → 触发缺页异常,切进内核
③ 内核一看:哦,这是 COW 标记的页,不是真的出错
④ 内核找一块空闲物理页(比如物理页 5000),
把物理页 1024 的内容【复制】过去
⑤ 把【子进程】页表里那一项改成:虚拟页 0 → 物理页 5000,权限【可读可写】
⑥ 返回用户态,【重新执行】 x = 5 这条指令 → 这次成功了
结果:
父进程页表 │虚拟页0 → 物理页1024│ ← 还是老的那一份
子进程页表 │虚拟页0 → 物理页5000│ ← 自己的私有副本
只有【被写的那一页】被复制了。
其他成千上万页仍然共享着,一个字节都没动。
🔑 “写时复制”这个名字就是这个意思:写的时候才复制。
为什么这个优化如此有效
因为绝大多数页根本不会被写:
- 代码段(
.text,你的程序指令)——只读,永远不会被写,永远共享 - 只读数据、以及大量分配了但从没碰过的堆内存——同理
- 而且
fork之后最常见的操作是立刻exec,整个地址空间马上被替换掉, 要是真复制了 4GB,那是 100% 白费
📌 面试怎么答
「
fork不真的复制内存,只复制页表,并把父子双方的页表项都标记成只读。 谁先尝试写某一页,就触发缺页异常,内核在异常处理里才真正复制那一页, 把写入方的页表指向新副本、权限改回可写,然后重新执行那条指令。 所以复制的粒度是页,而且只复制真正被改过的页。 代价是第一次写每一页都会有一次缺页异常的开销, 好处是fork本身几乎是 O(1) 的,而且配合exec时零浪费。」
exec:把自己换成另一个程序
execve("/bin/ls", argv, envp);
// 代码段、数据段、堆、栈 全部被 /bin/ls 的内容替换
// 但 PID 不变,打开的 fd 默认也保留(除非设了 FD_CLOEXEC)
exec 不创建新进程,是把当前进程的”内容”整个换掉。
所以 exec 成功后,它下面的代码永远不会执行——因为那些代码已经不存在了。
fork + exec 是 Unix 创建新程序的标准套路:
pid_t pid = fork();
if (pid == 0) {
execve("/bin/ls", argv, envp); // 子进程变成 ls
_exit(127); // 只有 exec 失败才会执行到这
}
waitpid(pid, &status, 0); // 父进程等子进程结束
为什么要分成两步?因为在 fork 之后、exec 之前那个空档里,
子进程可以做一些准备工作:重定向 stdin/stdout(这就是 shell 里 | 和 > 的实现方式)、
改变工作目录、降权限。
📌 多线程程序里 fork 的大坑(高频,且和你做的东西相关)
fork 之后,子进程里只有”调用 fork 的那一个线程”存在,
其余线程凭空消失了——但它们持有的锁还留在锁着的状态。
父进程:线程 A 正在 malloc(持有 malloc 的内部锁)
线程 B 调用了 fork()
↓
子进程:只有线程 B 存在。malloc 的内部锁是"锁着"的,
而唯一能解锁它的线程 A 在子进程里根本不存在
↓
子进程一调 malloc → 永久死锁 💀
而且 printf、new、几乎所有 C++ 标准库设施内部都可能 malloc。
规则:多线程程序 fork 之后、exec 之前,只能调用
async-signal-safe 的函数(write、_exit、execve 这些,
完整清单见 man 7 signal-safety)。
正确做法:用 posix_spawn()。它内部做了正确的处理,
就是为了绕开这个问题而设计的。
2.2.3 僵尸进程与孤儿进程
僵尸进程(zombie)
子进程退出后,内核不会立刻清空它的进程表项——因为父进程可能还想知道 「这孩子是正常退出还是被信号杀的、退出码是多少」。 这些信息保存在进程表项里,等父进程来取。
如果父进程一直不来取,这个表项就一直留着 → 这就是僵尸进程。
ps aux | grep defunct # 僵尸进程显示为 <defunct>
危害:僵尸本身几乎不占内存(只剩一个表项),
但占用 PID。系统 PID 数量有上限(/proc/sys/kernel/pid_max,通常 32768 或 4194304),
僵尸攒多了就 fork 不出新进程了。
四种解决办法:
// 1. 父进程主动等(阻塞)
waitpid(pid, &status, 0);
// 2. 非阻塞轮询
while (waitpid(-1, &status, WNOHANG) > 0) { /* 收掉所有已退出的 */ }
// 3. 装 SIGCHLD 处理函数(子进程退出时内核会发这个信号)
void on_sigchld(int) {
while (waitpid(-1, nullptr, WNOHANG) > 0) {} // ⚠️ 必须用 while,
} // 因为信号会合并,可能一次代表多个子进程
signal(SIGCHLD, on_sigchld);
// 4. 明确告诉内核"我不关心子进程的退出状态,你自己收"
signal(SIGCHLD, SIG_IGN); // 简单,但可移植性稍差
// 5. double fork:子进程再 fork 一个孙进程后立刻退出
// → 孙进程的父亲死了,被 PID 1 收养,由 init/systemd 负责回收
// 这是写守护进程(daemon)的经典手法
// 💡 守护进程 = 在后台长期运行、不绑定任何终端的服务进程
// (sshd、systemd、你的 flashd 都是)。关掉终端它也不会死。
孤儿进程(orphan)
父进程先死了,子进程还活着。这个子进程会被 PID 1(init 或 systemd)收养, 以后由 PID 1 负责回收它。
📌 孤儿是正常现象,不是问题;僵尸才是问题。 这两个名字容易搞混,面试问到别答反了。
2.2.4 信号(signal)
信号是内核或其他进程给一个进程发的”通知”,比如:
| 信号 | 触发场景 | 默认行为 |
|---|---|---|
SIGSEGV | 访问了非法内存(野指针 / 悬垂指针 / 越界) | 崩溃 + 生成 core dump |
SIGINT | 你按了 Ctrl+C | 终止 |
SIGTERM | kill PID 的默认信号 | 终止(可以被捕获,用来做优雅退出) |
SIGKILL | kill -9 | 立即杀死,不能被捕获也不能被忽略 |
SIGCHLD | 子进程退出了 | 忽略 |
SIGPIPE | 往一个对端已关闭的 socket/管道写 | 终止(网络程序必须处理,见下) |
📌 信号处理函数里能做的事极少
信号是异步打断你的程序的——可能打断在任何一条指令上,
包括打断在 malloc 内部持有锁的时候。
如果你的信号处理函数这时候再去调 malloc,就死锁了。
所以处理函数里只能调用 async-signal-safe 的函数:
write ✅、_exit ✅、signal ✅;
printf ❌、malloc ❌、new ❌、几乎所有 C++ 标准库 ❌。
实战做法:把信号变成一个普通事件
既然处理函数里啥也干不了,标准做法是在处理函数里只留个记号, 真正的活儿放到主循环里做。
做法一:设个标志位
volatile sig_atomic_t g_quit = 0; // 只能用这个类型,它保证读写是原子的
void on_term(int) { g_quit = 1; } // 处理函数只做这一件事
// 主循环
while (!g_quit) { doWork(); }
cleanup(); // 在主循环里安全地做清理
做法二:self-pipe trick(能和 epoll 配合,更实用)
int pipefd[2];
pipe(pipefd);
void on_term(int) { write(pipefd[1], "x", 1); } // write 是 signal-safe 的 ✅
// 主循环把 pipefd[0] 挂进 epoll,信号就变成了一个普通的"可读事件"
做法三:signalfd(Linux 特有,最干净)
sigset_t mask;
sigemptyset(&mask); sigaddset(&mask, SIGTERM);
sigprocmask(SIG_BLOCK, &mask, nullptr); // 先屏蔽,不走传统处理函数
int sfd = signalfd(-1, &mask, 0); // 信号直接变成一个 fd
// 挂进 epoll,read(sfd) 就能读出信号信息,全程在主循环里,不需要处理函数
SIGPIPE:网络程序必须处理的一个
你往一个对端已经关闭的 socket 写数据,内核会给你发 SIGPIPE,
默认行为是直接把你的进程杀掉。写服务端不处理它,
客户端一断开你的服务就死了。
三种处理方式:
signal(SIGPIPE, SIG_IGN); // ① 全局忽略,然后靠 write 返回 EPIPE 判断
send(fd, buf, len, MSG_NOSIGNAL); // ② 单次调用禁止产生信号(推荐)
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, ...);// ③ macOS/BSD 上的做法
2.2.5 动态库 —— 你的强项,要讲得比一般人深
这一节是你在面试里最容易碾压别人的地方。 大多数写业务 C++ 的人只会用
ldd看看依赖,你能讲到 RUNPATH 传递性和符号版本。 但前提是你自己得先把底层概念串起来,所以我从头讲。
前置:静态库 vs 动态库,「符号」是什么
编译一个 C++ 程序分两步:
- 编译(compile):每个
.cpp单独变成一个.o(目标文件)。 这时候如果a.cpp调用了b.cpp里的foo(), 编译器不知道 foo 在哪,就在a.o里留一个标记: 「我需要一个叫foo的东西,地址待填」。 - 链接(link):把所有
.o拼起来,把那些”待填”的地址真正填上。
「符号」(symbol)就是函数名和全局变量名在目标文件里的表示。 分两类:
- 定义的符号(defined):我这里有这个东西
- 未定义符号(undefined):我需要这个东西,别人得提供
nm -C a.o # 看一个目标文件里的符号,U 开头的就是未定义符号
💡 C++ 的名字改编(name mangling):C++ 支持重载, 两个
foo参数不同是不同函数,所以编译器会把void foo(int)编成_Z3fooi这种带类型信息的名字。nm -C的-C就是把它还原成人看得懂的。 这也是为什么 C++ 调 C 库要用extern "C"——告诉编译器别改编名字。
静态库(.a):就是一堆 .o 打包在一起。链接时把用到的部分直接复制进最终程序。
- 优点:程序独立,不依赖外部文件
- 缺点:每个用它的程序都存一份 → 磁盘和内存浪费;库升级要重新编译所有程序
动态库(.so,shared object):链接时只记下”我需要 libfoo.so 里的 bar”,
真正的代码留在 .so 文件里,程序启动时才加载。
- 优点:多个程序共享同一份内存中的库;库能单独升级
- 缺点:运行时必须找得到那个
.so——你所有排障问题的来源
ELF 是什么
Linux 上的可执行文件和 .so 都是 ELF 格式(Executable and Linkable Format)。
你可以把它理解成一个有固定结构的文件,里面分成若干”节”(section):
代码在 .text,全局变量在 .data,动态符号表在 .dynsym,
依赖信息在 .dynamic……
readelf -h foo.so # 看文件头:架构(x86-64 / AArch64)、类型、字节序
readelf -d foo.so # 看 .dynamic 段:依赖了谁、SONAME、RPATH/RUNPATH
⚠️
readelf -h是你排交叉编译问题的第一个命令: 「架构错」的问题一眼就能看出来(Machine 那一行写的是AArch64还是X86-64)。
加载流程(完整链路,能背下来很加分)
你敲 ./app → shell 调用 execve("./app")
① 内核读 ELF 头,发现有 PT_INTERP 段,里面写着解释器路径
(glibc 系统是 /lib64/ld-linux-x86-64.so.2,musl 是 /lib/ld-musl-aarch64.so.1)
② 内核加载这个"动态链接器"(ld.so),把控制权交给它,而不是直接跑你的 main
③ ld.so 读主程序的 DT_NEEDED 列表 → 知道要哪些 .so → 递归展开成完整依赖图
④ 按"搜索顺序"(见下)找到每个 .so 的实际路径,mmap 进地址空间
⑤ 重定位:修正所有"地址待填"的位置(R_*_RELATIVE、GOT 表项等)
⑥ 符号解析:把未定义符号绑定到提供它的库(默认是延迟绑定,见下)
⑦ 执行各库的初始化代码(DT_INIT / .init_array)
← ⚠️ C++ 的全局对象构造函数就在这里跑,早于 main
⑧ 跳转到 main
%%{init: {"flowchart": {"htmlLabels": true, "nodeSpacing": 26, "rankSpacing": 30}}}%%
flowchart TD
A["你敲 ./app<br/>shell 调 execve"] --> B["内核读 ELF 头<br/>发现 PT_INTERP"]
B --> C["加载动态链接器 ld.so<br/>把控制权交给它(不是你的 main)"]
C --> D["读 DT_NEEDED<br/>递归展开完整依赖图"]
D --> E["按搜索顺序找到每个 .so<br/>RPATH → LD_LIBRARY_PATH → RUNPATH<br/>→ ld.so.cache → 默认路径"]
E --> F["mmap 进地址空间"]
F --> G["重定位<br/>修正所有地址待填的位置"]
G --> H["符号解析<br/>默认延迟绑定,走 PLT"]
H --> I["执行各库初始化 .init_array<br/>⚠️ C++ 全局对象构造在这里"]
I --> J["跳转到 main"]
📌 一个高频追问:「全局对象的构造函数什么时候执行?」 答:在
main之前,由.init_array驱动。 跨编译单元的全局对象构造顺序是未定义的—— (💡 「编译单元」= 一个.cpp文件加上它#include进来的所有内容, 编译器一次处理的就是一个编译单元,产出一个.o。 同一个编译单元内的全局对象按声明顺序构造,但不同.o之间谁先谁后没有规定。) 这就是著名的 “static initialization order fiasco”, 解法是把全局对象改成函数内的 static 局部变量(懒汉单例)。
📌 搜索顺序(必背,面试高频)
程序启动时,ld.so 按这个顺序找 .so,先找到先用:
1. DT_RPATH (仅当二进制里没有 DT_RUNPATH 时才生效;已废弃但仍到处都是)
2. LD_LIBRARY_PATH (环境变量)
3. DT_RUNPATH (现代做法,写在二进制里)
4. /etc/ld.so.cache (由 ldconfig 扫描 /etc/ld.so.conf 生成的索引)
5. 默认路径 /lib、/usr/lib(64 位再加 /lib64、/usr/lib64)
三个能让你超过大多数人的细节:
① RPATH 和 RUNPATH 的位置差异
DT_RPATH 排在 LD_LIBRARY_PATH 之前,DT_RUNPATH 排在之后。
这意味着:用 RUNPATH 时,用户可以用 LD_LIBRARY_PATH 覆盖你的设置;
用 RPATH 则覆盖不了。这就是 RPATH 被废弃的原因——它太霸道。
② ⚠️ RUNPATH 不传递(这是从 RPATH 迁移时最常踩的坑)
app (RUNPATH=$ORIGIN/../lib) → 依赖 libA.so
libA.so → 依赖 libB.so
RPATH 时代:查找 libB.so 时,app 的 RPATH 依然生效 ✅ 能找到
RUNPATH 时代:查找 libB.so 时,只看 libA.so 自己的 RUNPATH ❌ 找不到
DT_RUNPATH 只作用于”设置它的那个文件的直接依赖”,不传递给间接依赖。
所以现代打包要求每个 .so 都自己设 RUNPATH。
③ $ORIGIN:做可重定位分发包的关键
$ORIGIN 是一个占位符,ld.so 在运行时把它替换成
“当前这个 ELF 文件所在的目录”。
g++ ... -Wl,-rpath,'$ORIGIN/../lib'
# ↑ 必须用单引号,否则 shell 会先把 $ORIGIN 展开成空字符串
这样整个目录树搬到任何地方都能跑,不需要装到 /usr/lib,也不用设环境变量。
你做 PyTorch 分发包必然用到这个。
符号解析的四个进阶点
① 全局符号表:先加载的优先(symbol interposition)
所有库的符号会汇入一张全局表。如果两个库都定义了 malloc,
先加载的那个赢,后面的被”遮住”了。
这就是 LD_PRELOAD 能劫持函数的原理:
LD_PRELOAD=./mymalloc.so ./app # 强制先加载 mymalloc.so,它的 malloc 会替代 libc 的
内存检测工具(如 jemalloc、tcmalloc、部分 leak checker)就是这么工作的。
② -Bsymbolic / -Bsymbolic-functions
让库内部的调用直接绑定到自己的实现,不走全局查找。
- 好处:避免被意外劫持、少一次符号查找、加载更快
- 坏处:破坏了需要 interposition 的场景(比如你就是想让别人替换你的 malloc)
③ 符号版本(symbol versioning)—— 你 musl 移植的核心知识点
glibc 的符号带版本标记,长这样:
nm -D /lib/x86_64-linux-gnu/libc.so.6 | grep memcpy
# memcpy@@GLIBC_2.14
# memcpy@GLIBC_2.2.5 ← 老版本的实现还留着,保证老程序能跑
好处:glibc 可以改变一个函数的行为,同时保留旧版本给老二进制用,
实现向后兼容。这就是为什么在新系统上编的程序,
往老系统上一放就报 version GLIBC_2.34 not found——
新系统的 glibc 有新版本符号,老系统没有。
⚠️ musl 完全不支持符号版本。这是 manylinux(基于 glibc)的 wheel 在 Alpine(基于 musl)上跑不了的直接原因之一—— 不是”缺个库”那么简单,是符号模型根本不同。 你 PyTorch musl 移植那段,这条是核心论据,一定要讲。
④ 符号可见性 -fvisibility=hidden
默认情况下,.so 里所有非 static 的符号都被导出到动态符号表。
一个大库可能导出几万个符号,后果是:
- 动态符号表巨大 → 加载时符号解析变慢
- 内部实现细节泄漏 → 可能和别的库符号冲突
标准做法:
// 编译时加 -fvisibility=hidden,默认全部隐藏
#define API __attribute__((visibility("default")))
API int public_func(); // 只有显式标记的才导出
int internal_func(); // 隐藏,外部看不见也调不到
📌 你做库移植,这条讲出来很有分量: 「大型 C++ 库不加
-fvisibility=hidden,动态符号表能到几十万条, 启动时的符号解析开销非常可观,而且容易和其他库撞符号。」
延迟绑定:PLT 和 GOT
问题:一个程序可能引用了几千个外部函数,但一次运行也许只调用其中几十个。 启动时把几千个全部解析完,太浪费。
解法:延迟绑定(lazy binding)——第一次调用时才去解析。
GOT(Global Offset Table,全局偏移表):一张"函数真实地址"的表
PLT(Procedure Linkage Table,过程链接表):一段跳板代码
第一次调用 foo():
call foo@plt → PLT 桩(一小段跳板代码)发现 GOT 里还是空的
→ 调用 _dl_runtime_resolve 去查找 foo 的真实地址
→ 把真实地址填进 GOT
→ 跳过去执行
第二次及以后:
call foo@plt → PLT 桩直接读 GOT,一跳就到(几乎没开销)
两个可以改变这个行为的开关:
LD_BIND_NOW=1 ./app # 运行时环境变量:启动就全部解析
g++ ... -Wl,-z,now -Wl,-z,relro # 链接时固化这个行为
- 好处一:运行时没有”第一次调用变慢”的抖动,对实时性敏感的场景重要
- 好处二(安全):配合
-z relro(RELocation Read-Only), 解析完之后可以把 GOT 设成只读, 攻击者就没法通过改写 GOT 来劫持函数调用。-z now -z relro是现在安全加固的标准配置(Full RELRO)。 - 代价:启动变慢
dlopen 家族:运行时手动加载库
void* h = dlopen("./plugin.so", RTLD_NOW | RTLD_LOCAL);
auto f = (int(*)())dlsym(h, "entry");
f();
dlclose(h);
几个必须能区分的标志:
| 标志 | 含义 |
|---|---|
RTLD_LAZY | 延迟解析符号(同上面的延迟绑定) |
RTLD_NOW | 立即解析,有缺失符号马上报错。调试插件时用这个,错误更早暴露 |
RTLD_LOCAL(默认) | 加载的符号不进全局表,别的库看不到 |
RTLD_GLOBAL | 加载的符号进全局表,后续加载的库可以用它的符号 |
RTLD_GLOBAL的典型场景:Python 扩展模块之间要互相调用符号时必须用它。 你做 PyTorch 移植时,torch._C和其他扩展的符号共享就依赖这个。
dlsym(RTLD_NEXT, "malloc"):在”我的后面”查找下一个同名符号。
写 hook 库的标准手法——先拦截,做完自己的事再调原版。
📌 排查工具箱(每条都要知道什么时候用)
# ── 依赖 ──────────────────────────────────────────────
ldd libfoo.so # 依赖树 + 每个依赖解析到了哪个文件
# ⚠️ ldd 会真的运行加载器,不要对不可信的文件用
readelf -d libfoo.so # 看 NEEDED(依赖谁)/ SONAME / RPATH / RUNPATH
readelf -h libfoo.so # 架构、ABI、类型 ← 排交叉编译"架构错"第一个用它
# ── 符号 ──────────────────────────────────────────────
nm -D --undefined-only foo.so # 我需要哪些符号(U)
nm -D --defined-only foo.so # 我提供哪些符号(T)
nm -DC foo.so # -C 顺便把 C++ 改编名还原成人话
objdump -T foo.so # 动态符号表(能看到符号版本)
# ── 运行时到底发生了什么 ──────────────────────────────
LD_DEBUG=libs ./app # 加载器实际搜了哪些路径、按什么顺序 ← 最有用
LD_DEBUG=bindings ./app # 每个符号最终绑到了哪个库 ← 排"调错函数"用
LD_DEBUG=help ./app # 看所有可用选项
# ── 打包 ──────────────────────────────────────────────
patchelf --print-rpath foo.so # 查看 RPATH/RUNPATH
patchelf --set-rpath '$ORIGIN/../lib' foo.so # 改,做分发包常用
📌 三类典型故障怎么区分(这是最实用的一段)
| 报错 | 说明什么 | 怎么查 |
|---|---|---|
error while loading shared libraries: libX.so.1: cannot open shared object file | 文件根本没找到 | LD_DEBUG=libs 看搜了哪些路径;readelf -d 看 RUNPATH 设了没 |
undefined symbol: _ZN3Foo3barEv | 文件找到了,但里面没这个符号 | nm -D 看目标库到底导没导出;常见原因:版本不匹配、忘了 extern "C"、被 -fvisibility=hidden 隐藏了 |
wrong ELF class / cannot execute binary file: Exec format error | 架构不对(32/64 位或 x86/ARM 混了) | readelf -h 看 Class 和 Machine |
version 'GLIBC_2.34' not found | 符号版本太新,目标系统 glibc 太老 | 换更老的构建环境,或静态链接,或用 musl |
决策树(面试白板上画这个,比背命令强):
%%{init: {"flowchart": {"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 34}}}%%
flowchart TD
S(["程序跑不起来"]) --> Q1{"报错里提到<br/>某个 .so 的文件名?"}
Q1 -->|"是:cannot open<br/>shared object file"| A1["**文件没找到**<br/>LD_DEBUG=libs 看搜了哪些路径<br/>readelf -d 看 RUNPATH 设了没"]
Q1 -->|"否"| Q2{"报错里提到<br/>某个符号名?"}
Q2 -->|"是:undefined symbol"| A2["**符号没找到**<br/>nm -D 看目标库导没导出<br/>常见:版本不匹配 / 忘了 extern C /<br/>被 -fvisibility=hidden 隐藏"]
Q2 -->|"否"| Q3{"报错是 Exec format error<br/>或 wrong ELF class?"}
Q3 -->|"是"| A3["**架构不对**<br/>readelf -h 看 Class 和 Machine<br/>32/64 位或 x86/ARM 混了"]
Q3 -->|"否:version GLIBC_x not found"| A4["**符号版本太新**<br/>换老的构建环境 / 静态链接 / 用 musl"]
这张表建议直接背下来。面试官问「动态库找不到怎么排查」, 你能先把问题分成三类再分别给方法,档次立刻不一样。
2.2.6 文件 I/O 与 mmap
前置:页缓存(page cache)
你 read 一个文件,数据不是直接从磁盘到你的内存。中间隔着页缓存:
内核在物理内存里留一块地方,缓存最近访问过的文件内容。
第一次 read:磁盘 → 页缓存 → 你的缓冲区 (慢,要真的读磁盘)
第二次 read: 页缓存 → 你的缓冲区 (快,磁盘没动)
写也一样:write 只是把数据写进页缓存就返回了,
内核稍后才真正落盘。这叫「回写」(write-back)。
⚠️ 这就是为什么
write返回成功不代表数据安全了。 这时候断电,数据就没了。你做升级,这一点是生死攸关的。
read/write vs mmap
mmap 把文件直接映射进你的地址空间,之后像访问数组一样访问文件:
void* p = mmap(nullptr, len, PROT_READ, MAP_PRIVATE, fd, 0);
char c = ((char*)p)[1000]; // 像读内存一样读文件的第 1000 字节
munmap(p, len);
read/write | mmap | |
|---|---|---|
| 数据路径 | 磁盘 → 页缓存 → 拷贝到用户缓冲区 | 磁盘 → 页缓存 → 直接映射,不拷贝 |
| 系统调用 | 每次读写都要一次 syscall | 只在映射时一次,之后是普通内存访问 |
| 适合 | 顺序读写、小文件、流式处理 | 随机访问、大文件、多进程共享同一文件 |
| 代价 | syscall + 一次内存拷贝 | 缺页中断、TLB 压力; 文件被别人截断后访问会 SIGBUS 崩溃 |
📌 不要无脑说 mmap 更快。顺序读大文件时
read常常更快, 因为内核的预读(readahead)做得很好,而 mmap 会有大量缺页异常。 (💡 「预读」:内核发现你在顺序读一个文件时,会 提前把后面的内容也读进页缓存。等你真的读到那里,数据已经在内存里了。) 正确答法是”看访问模式”。
fsync / fdatasync:把数据真正写到磁盘
write(fd, buf, len); // 只到页缓存
fsync(fd); // 强制刷到物理介质,返回后才算安全
fsync:刷数据 + 刷元数据(文件大小、修改时间等)fdatasync:只刷数据和”必要的”元数据,更快。 如果文件大小没变(比如覆盖写),用它就够了
📌 升级场景必须理解这个:写完镜像不
fsync就重启/断电, 数据可能还在页缓存里没落盘 → 镜像不完整 → 变砖。 你讲 OTA 的可靠性时,「写完必须 fsync 再切启动标记」是必答点。
O_DIRECT:绕过页缓存
int fd = open("img.bin", O_WRONLY | O_DIRECT);
数据不经过页缓存,直接在用户缓冲区和磁盘之间传输。
- 要求:缓冲区地址、偏移量、长度都要按块大小(通常 512 或 4096 字节)对齐,否则报
EINVAL - 场景:数据库(自己管缓存,不想被内核缓存干扰)、大镜像写入(避免污染页缓存)
2.2.7 分区与挂载(你的主场,命令要熟)
概念:分区、文件系统、挂载
- 分区:把一块存储设备(磁盘、eMMC)在物理上切成几段
- 文件系统:在一个分区里组织文件的格式(ext4、EROFS、FAT32……)
- 挂载:把一个分区”接”到目录树的某个位置上,之后就能通过路径访问
mount /dev/sda1 /mnt # 把 sda1 这个分区挂到 /mnt 目录
# 之后 /mnt/foo 就是 sda1 里的 foo 文件
常用命令
lsblk -f # 看所有块设备 + 分区 + 文件系统类型 + 挂载点
blkid /dev/sda1 # 探测某个分区的文件系统类型和 UUID
mount -t erofs -o loop img.erofs /mnt # 挂载一个镜像文件(-o loop 关键)
losetup -f --show img.bin # 把镜像文件变成一个块设备 /dev/loopN
# ← 做镜像的单元测试/集成测试非常有用
umount /mnt
dd if=/dev/zero of=x.img bs=1M count=10 # 造一个 10MB 的空文件
mkfs.ext4 x.img # 格式化成 ext4
mkfs.erofs -zlz4hc out.erofs src/ # 从一个目录打成 EROFS 镜像(带 lz4hc 压缩)
parted -l # 看分区表
sgdisk -p /dev/sda # 看 GPT 分区表详情
系统调用层面
mount(source, target, "erofs", flags, data); // mount(2)
umount2(target, MNT_DETACH); // 懒卸载:正在用的话等用完再卸
// ioctl 上的块设备操作(写刷机工具会用到)
ioctl(fd, BLKGETSIZE64, &size); // 取块设备总字节数
ioctl(fd, BLKDISCARD, range); // TRIM / 擦除一段区域
2.2.8 排查命令速查(按”我遇到什么现象”组织)
# ── 程序卡住了,不知道在干嘛 ──────────────────────────
strace -f -p PID # 看它在调什么系统调用(-f 跟踪子线程)
strace -f -e trace=openat,read,write ./app # 只看关心的那几类
cat /proc/PID/stack # 内核栈(需要权限)
gdb -p PID 然后 thread apply all bt # 所有线程的用户态栈 ← 查死锁必用
# ── CPU 高 ────────────────────────────────────────────
top -H -p PID # 按线程看,先定位是哪个线程在烧
perf top -p PID # 实时热点函数
perf record -g -p PID -- sleep 30 && perf report # 采样 30 秒再看
# ── 内存涨 ────────────────────────────────────────────
cat /proc/PID/status | grep -E 'VmRSS|VmSize|Threads'
# VmRSS=实际占的物理内存,VmSize=虚拟地址空间大小
pmap -x PID # 内存分布:哪块映射占得多
valgrind --leak-check=full ./app # 精确但慢 10-50 倍
g++ -fsanitize=address ./app # ASan:快得多,推荐
# ── 文件/网络相关 ─────────────────────────────────────
lsof -p PID # 这个进程打开了哪些文件和 socket
ls -l /proc/PID/fd # 同上,更轻量;fd 泄漏时能看到数量暴涨
ss -tnp # 看 TCP 连接和对应进程(netstat 的现代替代)
cat /proc/PID/limits # 资源上限(fd 数量上限常常是坑)
# ── 动态库 ────────────────────────────────────────────
cat /proc/PID/maps # 实际加载了哪些 .so,加载到什么地址
LD_DEBUG=libs ./app # 见 2.2.5
📌 面试实用技巧:被问「XX 问题你怎么查」时, 先说排查思路的分层,再说具体命令。 比如内存涨:「先分清是真泄漏还是分配器没还给系统—— 看 RSS 和分配器统计。是真泄漏就上 ASan, 不是的话就查是不是 glibc 的 arena 碎片问题。」 这样答比背命令列表强得多,而且分配器差异正好是你 musl 移植的知识领域。
2.3 并发
这一节和你的项目(线程池 + 状态机 + 心跳线程)直接对应, 面试问得最细。同样从「为什么需要」讲起。
2.3.0 前置:数据竞争到底是怎么发生的
先看一段看起来毫无问题的代码:
int counter = 0;
void worker() { for (int i = 0; i < 100000; ++i) counter++; }
// 两个线程各跑一遍 worker
// 期望:counter == 200000
// 实际:可能是 137482、189203……每次都不一样
原因:counter++ 不是一条指令,是三步:
① 从内存把 counter 读进寄存器 (load)
② 寄存器加 1 (add)
③ 把寄存器写回内存 (store)
两个线程交错执行时:
时刻 线程 A 线程 B counter 内存值
1 load → 拿到 5 5
2 load → 也拿到 5 5
3 add → 寄存器变 6 5
4 add → 寄存器变 6 5
5 store → 写回 6 6
6 store → 又写回 6 6 ← 加了两次,只涨了 1!
这就叫数据竞争(data race): 多个线程同时访问同一块内存,至少有一个是写,且没有同步措施。
⚠️ 在 C++ 标准里,数据竞争是 UB(未定义行为)——不是”结果不确定”, 而是”整个程序的行为都无从谈起”,编译器可以做任何优化。 所以不能说「反正就是数少了点」,必须彻底消除。
三种消除办法(后面每一节各讲一个):
- 加锁(mutex)——让同一时刻只有一个线程能进去
- 原子操作(atomic)——让”读-改-写”变成不可分割的一步
- 根本不共享——每个线程用自己的数据,最后汇总(最好的办法)
2.3.1 mutex 与 condition_variable:先把工具搞明白
mutex(互斥锁)
std::mutex mu;
int counter = 0;
void worker() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lk(mu); // 构造 → 加锁
counter++; // 这一段同一时刻只有一个线程能进
} // 析构 → 解锁(RAII,见 2.1.2)
}
被锁保护的那段代码叫临界区(critical section)。 临界区要尽可能短——里面的代码是串行的,越长并发度越低。
⚠️ 永远不要手写
mu.lock()/mu.unlock(), 中间只要有一个return或者一次抛异常,锁就永远不释放了。 一律用lock_guard(简单场景)或unique_lock(需要中途解锁/配合条件变量)。
condition_variable(条件变量)
它解决什么问题:消费者线程要等队列里有数据。笨办法是不停地查:
while (q.empty()) { } // ❌ 忙等:CPU 100% 空转,什么也没干
条件变量让线程睡过去,等有人叫它才醒:
std::mutex mu;
std::condition_variable cv;
std::queue<Task> q;
// 消费者
{
std::unique_lock<std::mutex> lk(mu);
cv.wait(lk, []{ return !q.empty(); }); // 睡着,不占 CPU
auto t = q.front(); q.pop();
}
// 生产者
{
std::lock_guard<std::mutex> lk(mu);
q.push(task);
}
cv.notify_one(); // 叫醒一个在等的
cv.wait(lk, pred) 内部做的三件事(理解这个才能理解为什么要传锁):
① 检查 pred(),成立就直接返回
② 不成立 → 原子地【解开锁 + 让本线程睡】 ← 必须原子,否则会丢唤醒
③ 被 notify 叫醒后 → 重新加锁 → 回到 ①
为什么第 ② 步必须原子:如果先解锁再睡,那在”解锁后、睡着前”这个空档里, 生产者可能刚好 push 完并 notify——而此时你还没睡,收不到这个通知, 然后你睡下去就再也没人叫你了。这叫丢失唤醒(lost wakeup)。
📌 为什么 wait 必须带谓词(或写成 while 循环)——必答题
cv.wait(lk, []{ return !q.empty(); }); // ✅
// 等价于:
while (q.empty()) cv.wait(lk); // ✅
if (q.empty()) cv.wait(lk); // ❌ 用 if 是错的
两个原因:
① 虚假唤醒(spurious wakeup)
POSIX 标准明确允许 pthread_cond_wait 在没有任何人 notify 的情况下返回。
为什么允许?因为在某些平台上,让实现严格保证”不虚假唤醒”需要额外的同步开销,
标准选择把这个负担交给使用者(反正你写 while 循环也不费事)。
② 更常见的是”条件被别人抢走了”
线程 A、B 都在等队列非空
生产者 push 一个任务 → notify_all → A 和 B 都醒了
A 先抢到锁,取走了任务,解锁
B 拿到锁,醒来一看:队列又空了
→ 如果用 if,B 会以为有数据,去取空队列 → 崩溃
→ 用 while,B 发现条件不成立,继续睡 ✅
📌 一句话答:「wait 必须配合循环检查条件,一是防虚假唤醒, 二是防被唤醒后条件又被别的线程抢走。谓词版本的 wait 内部就是个 while。」
📌 notify 要不要持着锁调用
// 写法 A:持锁 notify
{
std::lock_guard<std::mutex> lk(mu);
q.push(t);
cv.notify_one(); // 还在锁里
}
// 写法 B:解锁后 notify(推荐)
{
std::lock_guard<std::mutex> lk(mu);
q.push(t);
} // 先解锁
cv.notify_one();
- 写法 A 的问题:被唤醒的线程醒来第一件事是加锁, 但锁还在你手里,它立刻又被挡住睡回去。这叫 “hurry up and wait”, 多一次无谓的上下文切换。
- 写法 B 更好,但前提是:修改条件的那一步必须已经在锁里完成了。
实践建议:持锁改条件 → 解锁 → notify。
notify_one 还是 notify_all
notify_one:只叫醒一个。提交一个任务就用它——反正只有一个任务, 叫醒一堆线程去抢,白白浪费。notify_all:叫醒所有。改变的是”全局状态”时必须用它, 典型场景就是线程池要关闭了(stop_ = true), 所有等待的线程都得醒来看看然后退出。
2.3.2 手写线程池(白板高频,必须练到 15 分钟无 bug)
先想清楚:线程池解决什么问题
不用线程池:每来一个任务就 std::thread t(task)。
- 创建线程要向内核申请栈、注册调度实体,大约几十微秒
- 任务本身可能只跑 5 微秒 → 开销比干活还大
- 任务多了会创建成千上万个线程 → 内存爆炸、CPU 忙于切换
线程池:预先创建 N 个线程,它们都在一个循环里「取任务 → 执行 → 再取」。 任务放进一个共享队列。线程复用,创建开销只付一次。
Submit(任务) N 个工作线程
用户 ──────────> [ 任务队列 ] <──────── 循环:取任务 → 执行
↑ mutex 保护 (队列空就在 cv 上睡)
完整实现(逐行都能解释)
#include <vector>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <functional>
#include <future>
class ThreadPool {
public:
explicit ThreadPool(size_t n) {
workers_.reserve(n);
for (size_t i = 0; i < n; ++i) {
workers_.emplace_back([this] {
for (;;) { // 每个工作线程的主循环
std::function<void()> task;
{
std::unique_lock<std::mutex> lk(mu_);
// 睡到「要停了」或者「有任务了」为止
cv_.wait(lk, [this] { return stop_ || !tasks_.empty(); });
// 要停 且 任务做完了 → 退出线程
if (stop_ && tasks_.empty()) return;
task = std::move(tasks_.front());
tasks_.pop();
} // ← 锁在这里就放掉了
task(); // ⚠️ 关键:在锁外执行
}
});
}
}
template <class F, class... Args>
auto Submit(F&& f, Args&&... args)
-> std::future<std::invoke_result_t<F, Args...>> {
using R = std::invoke_result_t<F, Args...>;
// packaged_task:把一个可调用对象包起来,执行结果(或异常)塞进 future
auto task = std::make_shared<std::packaged_task<R()>>(
std::bind(std::forward<F>(f), std::forward<Args>(args)...));
std::future<R> fut = task->get_future();
{
std::lock_guard<std::mutex> lk(mu_);
if (stop_) throw std::runtime_error("submit on stopped pool");
tasks_.emplace([task] { (*task)(); }); // 擦除类型,统一成 function<void()>
}
cv_.notify_one();
return fut;
}
~ThreadPool() {
{
std::lock_guard<std::mutex> lk(mu_);
stop_ = true; // ⚠️ 改标志必须持锁,否则和 wait 有竞争
}
cv_.notify_all(); // ⚠️ 必须 all,要叫醒所有等待的线程
for (auto& t : workers_) if (t.joinable()) t.join();
}
ThreadPool(const ThreadPool&) = delete; // 池子不可拷贝
ThreadPool& operator=(const ThreadPool&) = delete;
private:
std::vector<std::thread> workers_;
std::queue<std::function<void()>> tasks_;
std::mutex mu_;
std::condition_variable cv_;
bool stop_ = false;
};
📌 面试官一定会挑的六个点
1. 为什么 task() 必须在锁外执行?
如果在锁里执行,那一个任务跑 3 秒,整个池子被锁 3 秒,
其他线程连队列都访问不了 → 多线程退化成单线程。
所以取任务时持锁(保护队列),拿到任务后立刻放锁,再执行。
2. 退出条件为什么是 stop_ && tasks_.empty()?
这决定了关闭语义:
stop_ && tasks_.empty()→ 优雅退出:先把队列里剩下的任务做完再退- 只判
stop_→ 立即退出:丢弃未执行的任务
两种都合理,关键是你得说清你选了哪种、为什么。 面试官问这个就是在看你有没有想过关闭语义。
3. 为什么改 stop_ 要持锁?
不持锁会有一个经典竞争:
工作线程:cv_.wait 内部检查完谓词(stop_ 还是 false),准备睡下去
主线程: stop_ = true; notify_all(); ← 就在这个空档
工作线程:睡下去了 —— 但 notify 已经发过了,再也没人叫它 → join 永远卡住
持锁修改就能杜绝这个空档,因为 wait 检查谓词时也持有同一把锁。
4. notify_one 还是 notify_all?
提交任务用 notify_one(只有一个任务,叫一个人就够);
关闭时必须 notify_all(所有线程都得醒来退出)。
5. 队列没有上限——生产快于消费会撑爆内存。
生产环境要加有界队列 + 背压:队列满时 Submit 阻塞,
(💡 「背压」(backpressure)= 下游处理不过来时,主动把压力反推给上游,
让上游慢下来或者直接拒绝,而不是无限堆积到内存爆掉。)
或者返回失败让调用方决定(降级、丢弃、重试)。
📌 这一条主动说出来,是很强的工程成熟度信号。
6. 任务里抛异常会怎样?
- 用
packaged_task包装 ✅:异常被捕获存进future, 调用fut.get()时才重新抛出,工作线程不会死。 - 如果直接用裸
std::function<void()>且任务抛异常 ❌: 异常从线程函数里逃出去 →std::terminate,整个进程崩溃。 这是很常见的 bug。
📌 和你项目的连接点(必被追问)
面试官看到你简历写”线程池执行阻塞 I/O”,一定会问:
「阻塞 I/O 放进线程池,池子被占满了怎么办?」
这是真问题:8 个线程如果全卡在 libssh2_channel_read() 上,
第 9 个会话的任务就永远排不上号。
可选答法(选一个你项目真实的说):
- 读操作用带超时的非阻塞读,定期返回让出线程
- 读线程和任务线程分开:每个会话有自己的读线程,线程池只跑短任务
- 限制并发会话数,保证线程数 > 最大会话数
⬜ 待填:你项目实际是哪种?这条在 v4 答案文档第 07 题也有,对照着确认。
2.3.3 死锁
死锁是什么
两个线程各持有一把锁,又都在等对方手里那把 → 谁也动不了。
// 线程 A // 线程 B
lock(mu1); lock(mu2);
lock(mu2); // 等 B 放 mu2 lock(mu1); // 等 A 放 mu1
// ↓ // ↓
// 永远等下去 // 永远等下去
%%{init: {"flowchart": {"htmlLabels": true, "nodeSpacing": 50, "rankSpacing": 40}}}%%
flowchart LR
TA["线程 A"] -->|"已持有"| M1["mutex 1"]
TA -.->|"正在等"| M2["mutex 2"]
TB["线程 B"] -->|"已持有"| M2
TB -.->|"正在等"| M1
🔑 看图找死锁的方法:把「谁持有什么」画成实线,「谁在等什么」画成虚线, 只要能从一个线程沿着箭头绕回它自己,就是死锁。
thread apply all bt的输出就是用来画这张图的。
四个必要条件(同时满足才死锁,破坏任一条即可解除)
| 条件 | 意思 | 怎么破坏 |
|---|---|---|
| 互斥 | 资源同一时刻只能被一个人占 | 一般破坏不了(锁的本质) |
| 持有并等待 | 拿着 A 的同时去要 B | 一次性申请所有锁(scoped_lock) |
| 不可剥夺 | 不能强行抢走别人的锁 | 用超时锁,拿不到就放弃已有的重来 |
| 循环等待 | 形成 A→B→A 的环 | 固定加锁顺序(最常用) |
工程上最实用的四个手段
① 固定加锁顺序(首选) 给所有锁定义一个全局顺序(比如按地址、按编号),永远按这个顺序加。 只要人人守规矩,就不可能形成环。
② std::scoped_lock(C++17,最省事)
std::scoped_lock lk(mu1_, mu2_); // 一次锁多把,内部用死锁避免算法,顺序无关
它内部会反复尝试并在失败时全部释放重来,保证不死锁。
C++11 的写法是 std::lock(mu1, mu2) + lock_guard(mu, std::adopt_lock)。
③ 超时锁
std::unique_lock lk(mu, std::defer_lock);
if (lk.try_lock_for(std::chrono::milliseconds(100))) { /* 拿到了 */ }
else { /* 退避,稍后重试,避免死等 */ }
④ ⚠️ 缩小临界区:绝不在持锁时调用回调或未知代码
{
std::lock_guard lk(mu_);
callback_(data); // ❌ 危险!你不知道 callback 里会干什么
} // 如果它又来拿同一把锁 → 自锁(std::mutex 不可重入)
// 💡 "不可重入" = 同一个线程再次 lock 同一把已持有的锁,会把自己锁死。
// (std::recursive_mutex 可重入,但需要它通常说明设计有问题)
正确做法:在锁内把要用的数据拷贝出来,解锁后再调回调。
Callback cb;
{ std::lock_guard lk(mu_); cb = callback_; }
cb(data); // ✅ 锁外调用
📌 这一条是最容易在真实项目里踩的,你项目里有回调机制, 面试官很可能顺着问。主动说出来是加分项。
怎么排查死锁
gdb -p PID
(gdb) thread apply all bt # 打印所有线程的调用栈
# 找那些卡在 pthread_mutex_lock / __lll_lock_wait 上的线程,
# 看它们各自持有什么、在等什么,画出等待环 ↓ 就是下面这种环
预防性工具:ThreadSanitizer
g++ -fsanitize=thread -g app.cpp && ./a.out
它能在测试阶段就报出数据竞争和潜在的加锁顺序反转(lock order inversion), 哪怕这次运行没真的死锁。代价是慢 5-15 倍,只在测试环境开。
2.3.4 锁粒度与读写锁
粒度的取舍
// 粗粒度:一把大锁保护整个 map
std::mutex mu_;
std::map<int, Session> sessions_;
- ✅ 简单、不容易出错
- ❌ 所有操作串行,并发度低
// 细粒度:分片,每片一把锁
struct Shard { std::mutex mu; std::map<int, Session> data; };
Shard shards_[16];
Shard& pick(int id) { return shards_[id % 16]; }
- ✅ 不同分片的操作可以真并行
- ❌ 复杂度上升;跨分片操作(比如”统计总数”)会引入多锁 → 死锁风险
📌 答法:「我默认用粗粒度,先把正确性做对。 只有在压测证明这把锁是瓶颈时,才拆细。 锁竞争是可以测出来的(
perf lock、或看线程在 futex 上的等待时间), (💡 futex = Fast Userspace muTEX,Linux 上实现 mutex 的底层机制。 没有竞争时纯用户态跑,一条原子指令就拿到锁,不进内核; 只有真的要阻塞等待时才进内核。所以「线程在 futex 上等了多久」 直接等于「它为了抢锁被阻塞了多久」。) 不该靠猜。」
读写锁 std::shared_mutex(C++17)
std::shared_mutex mu_;
void read() { std::shared_lock lk(mu_); /* 多个读者可同时进入 */ }
void write() { std::unique_lock lk(mu_); /* 写者独占 */ }
适用条件:读远多于写,且读操作本身有一定耗时。
⚠️ 不要无脑用读写锁。它内部要维护读者计数, 单次加解锁的开销比普通 mutex 大。 如果读操作只是读一个 int(几纳秒),加锁开销远大于临界区本身, 普通 mutex 反而更快,甚至直接用
atomic最好。
2.3.5 无锁编程(概念题,能讲清原理即可)
CAS:无锁的基础
CAS = Compare And Swap(比较并交换),一条 CPU 指令, 原子地完成「如果这个位置的值还是我期望的那个,就把它改成新值」。
std::atomic<int> v{5};
int expected = 5;
if (v.compare_exchange_strong(expected, 10)) {
// 成功:v 原来确实是 5,现在被改成了 10
} else {
// 失败:v 已经被别人改过了,expected 会被写成 v 的当前实际值
}
无锁算法的通用套路:
int old = v.load();
int neu;
do {
neu = compute(old); // 基于当前值算出新值
} while (!v.compare_exchange_weak(old, neu)); // 失败就用最新的 old 重算
weak 和 strong 的区别:weak 允许”伪失败”
(值明明对,但因为硬件原因返回失败),在某些架构(如 ARM 的 LL/SC)上更高效。
放在循环里用 weak,不在循环里用 strong。
ABA 问题
CAS 只比较值。如果值从 A → B → 又变回 A,CAS 会认为”没人动过”, 但实际上中间发生了变化。
无锁栈:线程 1 准备把栈顶从 A 弹出,读到 top = A,A->next = B
(被打断)
线程 2 弹出 A、弹出 B、又把 A 压回去。此时 top = A,但 A->next 已经是 C 了
线程 1 恢复:CAS 比较 top == A ✅ 成功,把 top 设成它记的 B
→ 但 B 已经被释放了!💥
三种解法:
- 带版本号的指针(tagged pointer):把指针和一个计数器打包成 128 位一起 CAS
- hazard pointer:每个线程声明”我正在读这个节点”,别人就不能释放它
- epoch-based reclamation:按纪元批量回收,确认没人还在旧纪元里才释放
最难的部分是内存回收
在有锁的数据结构里,delete 一个节点很简单——你持着锁,没人在读。
无锁结构里,你永远无法确定”是不是还有线程正拿着这个节点的指针”。
上面三种方案都是在解决这一个问题,而且都不简单。
📌 诚实的答法(比硬吹得分高)
「原理我清楚:CAS 是基础,主要难点是 ABA 和内存回收, 常见方案是 tagged pointer、hazard pointer 和 epoch-based reclamation。 但生产代码里我会优先用成熟实现(folly、moodycamel 这些), 自己写无锁结构的正确性验证成本太高—— 它的 bug 往往是极低概率的偶现问题,测试很难覆盖。 除非有明确的性能数据证明锁确实是瓶颈,否则不值得。」
这个答法同时展示了知识深度和工程判断,比说”我会写无锁队列”强得多。
2.3.6 并发问题的排查方法(面试爱问,你也用得上)
| 现象 | 可能原因 | 工具 |
|---|---|---|
| 结果偶尔算错 | 数据竞争 | -fsanitize=thread(TSan) |
| 程序卡死不动 | 死锁 / 丢唤醒 | gdb -p + thread apply all bt |
| CPU 100% 但没进展 | 忙等、自旋锁竞争 | perf top 看热点在不在锁上 |
💡 「自旋锁」(spinlock)是什么:拿不到锁时不睡觉,而是原地循环重试。 好处是省掉了睡眠/唤醒的上下文切换开销,适合临界区极短的场景; 坏处是等待期间CPU 一直在空转烧着。临界区一长就是灾难。
std::mutex在 Linux 上通常是”先自旋几下,还拿不到再进内核睡”的混合策略。 | 多线程比单线程还慢 | 锁竞争 / false sharing |perf lock、看临界区是否过大 | | 只在 ARM 上出问题 | 内存序写得不够严格 | 见 2.1.7,x86 强模型会掩盖问题 |
false sharing(伪共享)——值得单独说,是很好的加分点:
struct Counters {
std::atomic<int> a; // 线程 1 频繁改
std::atomic<int> b; // 线程 2 频繁改
}; // a 和 b 挨着,很可能在同一条 64 字节 cache line 上
两个线程改的是不同变量,逻辑上毫无关系, 但因为它们在同一条 cache line 上,CPU 的缓存一致性协议会让这条 line 在两个核之间反复来回失效 → 性能暴跌,可能比单线程还慢。
解法:填充到独立的 cache line。
struct alignas(64) Padded { std::atomic<int> v; };
Padded a, b; // 各占一条 cache line,互不干扰
// C++17 起也可以用 std::hardware_destructive_interference_size
2.4 网络
你简历写了 SSH / SFTP / Telnet 客户端,网络这块是必问区。 本节从「socket 是什么」讲起。第四档 4.2 讲了服务端怎么设计,两处对照着看。
2.4.0 前置:socket、四元组、TCP 和 UDP
socket 是什么
socket(套接字)就是”网络通信的 fd”。 你在 2.2.0 学过 fd 是 Linux 里代表”一个打开的东西”的小整数—— socket 就是其中一种,代表”一条网络连接”。
int fd = socket(AF_INET, SOCK_STREAM, 0); // 创建一个 TCP socket,拿到一个 fd
connect(fd, addr, len); // 连到对端
write(fd, "hello", 5); // 像写文件一样发数据
read(fd, buf, sizeof(buf)); // 像读文件一样收数据
close(fd);
这就是 Unix “一切皆文件” 的威力:网络通信和文件读写用的是同一套 API。
四元组:怎么区分不同的连接
一条 TCP 连接由四个值唯一确定:
(源 IP, 源端口, 目的 IP, 目的端口)
192.168.1.5 : 54321 → 10.0.0.1 : 22
IP 定位到哪台机器,端口 定位到那台机器上的哪个程序。 一台服务器的 22 端口能同时服务上千个客户端,就是因为 每个客户端的(源 IP, 源端口)不同,四元组就不同。
💡 所以”一个端口只能有一个连接”是错的。 服务端的 22 端口上可以有无数条连接,只要客户端侧不重复。
TCP vs UDP
| TCP | UDP | |
|---|---|---|
| 连接 | 面向连接(要先握手) | 无连接,发了就走 |
| 可靠性 | 保证送达、保证顺序(丢了会重传) | 不保证,可能丢、可能乱序 |
| 数据形态 | 字节流(没有消息边界) | 数据报(一个包就是一个消息,有边界) |
| 开销 | 大(要维护状态、确认、重传) | 小 |
| 用在哪 | SSH、HTTP、文件传输——你的项目全是 TCP | DNS、视频通话、游戏 |
📌 “字节流没有消息边界”这一条,是理解”粘包”的关键,见 2.4.3。
服务端和客户端的标准流程
服务端 客户端
socket() 创建 socket socket()
bind() 绑定 IP:端口
listen() 开始监听
accept() 阻塞等连接 ←────────── connect() 发起连接(三次握手在这里发生)
read/write() 收发数据 ←────────→ read/write()
close() close()
2.4.1 三次握手与四次挥手
三次握手(建立连接)
客户端 服务端
│ │ (listen 中)
│──── ① SYN, seq=x ─────────────────────────>│ 我想连你,我的初始序号是 x
│ │
│<─── ② SYN+ACK, seq=y, ack=x+1 ─────────────│ 好,我的初始序号是 y,
│ │ 我收到你的 x 了
│──── ③ ACK, ack=y+1 ───────────────────────>│ 我收到你的 y 了
│ │
连接建立(ESTABLISHED) 连接建立
%%{init: {"themeVariables": {"fontSize": "14px"}}}%%
sequenceDiagram
participant C as 客户端
participant S as 服务端(listen 中)
C->>S: ① SYN, seq=x
Note right of S: 我知道「客户端能发,我能收」
S->>C: ② SYN + ACK, seq=y, ack=x+1
Note left of C: 我知道「我能发能收,对方也能发能收」
C->>S: ③ ACK, ack=y+1
Note right of S: 现在我才知道「我能发,客户端能收」<br/>← 只有第三次握手能确认这一条
Note over C,S: 连接建立 ESTABLISHED
为什么是三次,不是两次? 两个理由:
① 双方都要确认”我发得出去,也收得到”。
- 第 ① 步之后:服务端知道「客户端能发,我能收」
- 第 ② 步之后:客户端知道「我能发,我能收;服务端能发,能收」
- 第 ③ 步之后:服务端才知道「我能发,客户端能收」 ← 这一条只有第三次握手能确认,所以少一次不行
② 防止旧的重复 SYN 建立无效连接。 客户端发的一个 SYN 在网络里绕远路迟到了,客户端早就放弃重发了新的。 如果只握手两次,服务端收到这个迟到的 SYN 就直接建连接, 傻等着客户端发数据——但客户端根本不知道有这条连接,服务端资源就白占了。 三次握手时,客户端收到对这个旧 SYN 的响应会发 RST 拒绝,连接建不起来。
四次挥手(断开连接)
客户端(主动关闭方) 服务端
│──── ① FIN ───────────────────────────────>│ 我没数据要发了
│<─── ② ACK ────────────────────────────────│ 知道了
│ (此时服务端可能还有数据要发) │
│<─── ③ FIN ────────────────────────────────│ 我也没数据要发了
│──── ④ ACK ───────────────────────────────>│
│ │
TIME_WAIT(等 2MSL) CLOSED
%%{init: {"themeVariables": {"fontSize": "14px"}}}%%
sequenceDiagram
participant C as 客户端(主动关闭方)
participant S as 服务端
C->>S: ① FIN
Note right of S: 「我这个方向没数据了」
S->>C: ② ACK
Note over S: 服务端可能还有数据要发<br/>所以 ACK 和 FIN 必须分开
S->>C: ③ FIN
C->>S: ④ ACK
Note left of C: 进入 TIME_WAIT,等 2MSL(Linux 固定 60s)
Note right of S: CLOSED
为什么挥手要四次,握手只要三次? 因为 TCP 是全双工——两个方向是独立的两条通道。 一方发 FIN 只表示”我这个方向没数据了”, 对方可能还有一大堆数据要发给你,所以不能立刻跟着关。 于是 ACK(我知道了)和 FIN(我也关了)必须分成两个包。
如果服务端也正好没数据了,ACK 和 FIN 可以合并发,就变成三次挥手。 这在实际抓包里很常见。
2.4.2 TIME_WAIT(高频,必答)
谁进入,等多久
主动关闭的那一方(先发 FIN 的)会进入 TIME_WAIT 状态,
持续 2MSL(MSL = Maximum Segment Lifetime,报文最大生存时间)。
Linux 上这个值固定是 60 秒,写死在内核里,改不了(除非改内核重编译)。
📌 为什么需要它(两个理由,都要说)
① 保证最后那个 ACK 能送到对方。 你发的第 ④ 个 ACK 可能丢包。对方收不到,会重传 FIN。 如果你已经把连接彻底关了,收到重传的 FIN 只会回一个 RST(复位), 对方就会认为连接是异常断开的。 处于 TIME_WAIT 时,你还能正常响应这个重传的 FIN。
② 让本次连接的”迷路报文”在网络中彻底消失。 上一条连接的某个包在网络里绕路,两分钟后才到。 如果你立刻用同样的四元组建了一条新连接, 这个迟到的旧包就会被新连接当成合法数据收下 → 数据错乱。 等满 2MSL,保证所有旧包都已经超时死掉了。
大量 TIME_WAIT 怎么办
现象:ss -tan | grep TIME-WAIT | wc -l 几万条,
新连接报 Cannot assign requested address(本地端口耗尽)。
| 手段 | 作用 | 注意 |
|---|---|---|
SO_REUSEADDR | 允许 bind 一个还在 TIME_WAIT 的本地地址 | 服务端重启必备,几乎所有服务端都该设 |
SO_REUSEPORT | 多个进程/线程绑同一端口,内核负载均衡 | Linux 3.9+,见第四档 4.2 的”惊群” |
net.ipv4.tcp_tw_reuse=1 | 允许把 TIME_WAIT 的连接复用于新的出站连接 | 只对客户端方向有效 |
| 减少连接数本身 | 用长连接 / 连接池,别频繁建断 | 这才是根本解法 |
| 让对端主动关闭 | TIME_WAIT 就落在对端 | 协议设计层面的选择 |
⚠️ 千万不要建议
tcp_tw_recycle。 它在 NAT 环境下会导致同一个 NAT 后面的多个客户端连不上(丢 SYN), 而且 Linux 4.12 已经把这个选项整个移除了。 面试时说这个会露怯——说明你在背过时的博客。
2.4.3 粘包与拆包
这不是 bug,是 TCP 的语义
TCP 是字节流协议。它只保证:「你发的字节,按原顺序、不丢不重地到达对方」。
它不保证「你调用一次 write,对方就对应一次 read」。
你连续发两条消息:
write(fd, "HELLO", 5);
write(fd, "WORLD", 5);
对方可能:
read → "HELLOWORLD" (粘在一起了,叫"粘包")
read → "HEL" read → "LOWORLD" (拆开了,叫"拆包"/"半包")
read → "HELLO" read → "WORLD" (刚好,但你不能指望)
为什么会这样:发送端有 Nagle 算法会把小包合并, 接收端的内核缓冲区也是一个连续的字节流。中间没有任何东西记录”消息边界”。
📌 面试标准答法:「粘包不是 TCP 的缺陷,是它作为字节流协议的正常语义。 消息边界必须由应用层协议自己定义,UDP 就没这个问题,因为它是数据报。」
三种划定边界的方案
① 固定长度 每条消息都是固定的 N 字节。简单,但浪费(短消息也占 N 字节),不灵活。
② 分隔符
用特殊字符标记结束,如 HTTP 头部用 \r\n\r\n,Redis 协议用 \r\n。
- 优点:可读性好,方便调试
- 缺点:内容里如果出现分隔符就要转义,解析要逐字节扫描
③ 长度前缀(最常用)
[4 字节长度][payload ......]
// 接收侧的标准处理逻辑
std::string buf; // 累积缓冲区,必须有!
buf.append(newData, n); // 把这次读到的追加进去
while (true) {
if (buf.size() < 4) break; // 连长度字段都不够,等下次
uint32_t len = ntohl(*(uint32_t*)buf.data()); // ⚠️ 字节序转换
if (len > MAX_MSG_SIZE) { // ⚠️ 必须做上限校验!
closeConnection(); // 否则恶意的超大长度会让你分配几个 G → OOM
return;
}
if (buf.size() < 4 + len) break; // 半个包,等下次
handleMessage(buf.data() + 4, len);
buf.erase(0, 4 + len); // 消费掉,剩下的留着
}
三个必须说出来的实现要点:
- 必须有累积缓冲区。一次
read可能读到半个包,也可能读到两个半包, 不能假设”一次读到就是一条完整消息”。 - 必须循环处理。一次
read的数据里可能包含好几条完整消息。 - ⚠️ 长度字段必须做上限校验。攻击者发一个
len = 0xFFFFFFFF, 你直接resize就 OOM 了。这是很实际的安全问题,主动说出来加分。
字节序(endianness):不同 CPU 存多字节整数的顺序不同 (x86 是小端,网络协议规定用大端)。
💡 大端 / 小端是什么:整数
0x12345678占 4 个字节, 这 4 个字节在内存里的排列顺序有两种流派: 大端(big-endian)= 高位在前:12 34 56 78(和人读数字的顺序一致) 小端(little-endian)= 低位在前:78 56 34 12(x86、ARM 默认都是这个) 两台字节序不同的机器直接传二进制整数,收到的就是完全错误的数值。 所以网络协议统一规定用大端(叫”网络字节序”),发之前转、收到后转回来。 所以整数字段发送前要htonl(host to network long),接收后要ntohl。 忘了转换的表现是:本机测试没问题,跨架构就全乱—— 你做 ARM64 移植,这个坑很值得一提。
2.4.4 阻塞 / 非阻塞 / I/O 多路复用
阻塞和非阻塞的区别
// 阻塞(默认):没数据就一直等在这里,线程什么也干不了
ssize_t n = read(fd, buf, len);
// 非阻塞:没数据立刻返回 -1,errno = EAGAIN
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK);
ssize_t n = read(fd, buf, len);
if (n < 0 && errno == EAGAIN) { /* 现在没数据,待会再来 */ }
非阻塞单独用没意义——你总不能写个死循环不停地问”有数据了吗”(那是忙等,烧 CPU)。 非阻塞必须配合 I/O 多路复用:让内核告诉你”哪些 fd 现在有数据了”, 你再去读那几个。
💡 I/O 多路复用的完整背景(为什么”一连接一线程”不行) 在第四档 4.2 讲得更细,建议先看那里。
select / poll / epoll 对比
select | poll | epoll | |
|---|---|---|---|
| fd 数量上限 | FD_SETSIZE,通常 1024 | 无硬限制 | 无硬限制 |
| 内部结构 | 位图(一个 bit 代表一个 fd) | 数组 | 内核红黑树 + 就绪链表 |
| 每次调用的开销 | 把整个 fd 集合从用户态拷到内核 | 同左 | 只在 epoll_ctl 时注册一次 |
| 返回后怎么找就绪的 | 遍历全部 fd,O(n) | 同左 | 内核直接给就绪列表,O(就绪数) |
| 跨平台 | POSIX,最通用 | POSIX | Linux 特有 |
为什么 epoll 快,一句话总结:
select/poll 每次调用都要”告诉内核我关心哪些 fd”并”回来自己一个个查”, epoll 把”关心哪些”注册一次就够了,返回时内核直接给出”哪些就绪”。
epoll 的三个系统调用:
int ep = epoll_create1(0); // 建一个 epoll 实例
epoll_ctl(ep, EPOLL_CTL_ADD, fd, &ev); // 注册/修改/删除关心的 fd
int n = epoll_wait(ep, events, maxevents, timeout); // 等事件,返回就绪的 fd 列表
📌 跨平台的话怎么说:「epoll 是 Linux 特有的, macOS/BSD 上对应的是 kqueue,Windows 上是 IOCP(模型还不一样, IOCP 是”完成通知”而不是”就绪通知”)。 要跨平台一般用 libuv / asio 这类库封装掉差异。」
2.4.5 epoll 的 ET 与 LT(必问)
两种模式
-
LT(Level Triggered,水平触发,默认) 只要 fd 上还有数据没读完,每次
epoll_wait都会继续报告它。 像水位报警器:水位高着就一直响。 -
ET(Edge Triggered,边沿触发) 只在状态发生变化的那一刻通知一次(从”没数据”变成”有数据”)。 像门铃:按一下响一下,你不去开门它也不会再响。
ET 的正确用法(说出来就是懂行)
// ⚠️ 前提:fd 必须设成非阻塞!
for (;;) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) { process(buf, n); continue; } // 读到了,继续读
if (n == 0) { /* 对端正常关闭 */ closeConn(); break; }
if (errno == EAGAIN || errno == EWOULDBLOCK) break; // ✅ 读干净了,正常退出
if (errno == EINTR) continue; // 被信号打断,重试
/* 其他 errno = 真错误 */ closeConn(); break;
}
两个必须解释清楚的点:
① 为什么 ET 必须循环读到 EAGAIN?
ET 只通知一次。如果你只 read 了一次就走,缓冲区里剩下的数据
再也不会触发新的通知(因为状态没有再次”变化”)。
结果就是这个连接看起来”卡住了”——有数据但你永远收不到。
这是 ET 模式最经典的 bug,而且是偶发的(数据小的时候没事,大了才出)。
② 为什么 ET 必须配非阻塞 fd?
上面那个循环,最后一次 read 必然是”没数据了”。
如果 fd 是阻塞的,这次 read 就会永久卡在那里——
而你还在 event loop 线程里,整个循环全死,其他几千个连接一起卡住。
只有非阻塞 fd 才会返回 EAGAIN 让你跳出循环。
写操作同理:ET 模式下 write 也要循环写到 EAGAIN,
没写完的数据要自己缓存起来,注册 EPOLLOUT,等可写事件再继续。
📌 你选哪个(这才是面试官想听的)
「我默认用 LT。 ET 的收益是减少
epoll_wait的返回次数,但它把’必须一次读干净’ 这个责任交给了应用层,很容易写出漏读的 bug,而且这类 bug 是偶发的、难复现的。 只有在压测确实证明epoll_wait的调用次数成了瓶颈时,我才会换 ET, 并且要保证配套的非阻塞 fd 和循环读写全都写对。」
这个答法体现的是工程判断,比背 ET/LT 的定义高一个档次。
2.4.6 心跳、超时、重连(你项目做过,必被追问)
心跳:为什么不能只靠 TCP
问题:对端主机突然断电、网线被拔——
TCP 连接不会立刻感知到,因为对端没机会发 FIN。
你这边的 socket 看起来完全正常,read 就一直阻塞着。
TCP 自带的 KeepAlive 为什么不够用:
cat /proc/sys/net/ipv4/tcp_keepalive_time # 默认 7200,即 2 小时!
- 默认 2 小时才开始探测,太慢了,业务等不起
- 更麻烦的是中间设备:NAT 网关、防火墙的连接表有老化时间 (常见 5-30 分钟),超时就静默丢弃这条连接的映射, 你两小时后才探测,早就没用了
所以要做应用层心跳:
每 N 秒发一个 ping → 期望在 M 秒内收到 pong
连续 K 个周期没收到 → 判定断线,主动关闭并触发重连
参数怎么选:心跳间隔要明显小于中间设备的老化时间(常见取 30-60 秒), 且心跳包本身要小。
超时:四个不同的超时,别混为一谈
| 超时 | 管什么 | 实现 |
|---|---|---|
| 连接超时 | connect 多久没建立成功就放弃 | 非阻塞 connect + select/epoll 等可写事件,超时就放弃 |
| 读超时 | 多久没收到任何数据 | SO_RCVTIMEO,或在 event loop 里配定时器 |
| 写超时 | 数据多久没发出去 | SO_SNDTIMEO,或看写缓冲是否长期不降 |
| 整体请求超时 | 一次完整的请求-响应最多耗时多久 | 应用层定时器,通常是最外层的兜底 |
非阻塞 connect 的标准写法(连接超时的经典实现):
fcntl(fd, F_SETFL, O_NONBLOCK);
int r = connect(fd, addr, len);
// 非阻塞 connect 会立刻返回 -1,errno = EINPROGRESS,表示"正在连"
if (r < 0 && errno != EINPROGRESS) return fail();
// 用 select/epoll 等这个 fd 变成"可写",带上超时
// 可写了 → 用 getsockopt(fd, SOL_SOCKET, SO_ERROR, ...) 取真正的结果
// (因为"可写"也可能是因为连接失败了)
// 超时了 → 关掉 fd,判定连接超时
⚠️
connect成功不等于对端服务正常。 TCP 层连上了,SSH 握手可能还是失败。 你项目里至少有两层超时:TCP 连接超时 + SSH 握手超时, 面试被问到要分开讲。
重连:指数退避 + 随机抖动
第 1 次失败 → 等 1s 重试
第 2 次失败 → 等 2s
第 3 次失败 → 等 4s
第 4 次失败 → 等 8s
... 封顶 30s,之后一直 30s
为什么要指数退避:服务端可能正在重启或者过载。 如果所有客户端都每秒重试,就是在给它雪上加霜。
为什么要加随机抖动(jitter):
如果服务端宕机 10 秒,那所有客户端都是在同一时刻断的,
退避曲线完全一致 → 它们会在同一秒同时重连 → 服务端刚起来就被打死。
这叫惊群/雪崩。
解法:在等待时间上加一个随机量,比如 wait = base * (0.5 + rand(0,1))。
两个必须区分的情况:
if (closedByUser) {
// 用户主动关闭 → 绝不重连
} else {
// 异常断开 → 触发重连
}
分不清这两者,用户点了”断开”你还在后台疯狂重连——这是很常见的 bug。
重连成功之后呢(这一层想到了很加分):
- 会话状态能不能续?(SSH 不能,必须重新认证;有些协议支持 session resume)
- 要不要重新认证?密码/密钥还在不在内存里?
- 断开期间用户输入的内容怎么办?丢弃还是缓存重发?
- UI 上要不要告诉用户”正在重连”?
⬜ 待填:CrossShellNext 的重连做到哪一层?profile 待填清单有这条。
2.4.7 网络问题排查命令
# ── 看连接状态 ────────────────────────────────────────
ss -tanp # 所有 TCP 连接 + 状态 + 对应进程(netstat 的现代替代)
ss -s # 汇总:各状态各有多少条
ss -tan state time-wait | wc -l # 数 TIME_WAIT
# ── 抓包(定位"到底发出去没有"的终极手段)─────────────
tcpdump -i any -nn port 22 -w cap.pcap # 抓到文件,拿 Wireshark 看
tcpdump -i any -nn 'tcp[tcpflags] & (tcp-syn|tcp-fin|tcp-rst) != 0'
# 只看握手挥手和复位包,排查连不上/断连很有用
# ── 连通性 ────────────────────────────────────────────
ping host # 通不通(有些网络禁 ICMP,ping 不通不代表端口不通)
telnet host 22 # 端口通不通(最直接)
nc -zv host 22 # 同上,更现代
traceroute host # 路径上哪一跳断了
curl -v --connect-timeout 3 http://host/ # 看 HTTP 层
# ── 内核参数 ──────────────────────────────────────────
sysctl net.ipv4.tcp_keepalive_time # KeepAlive 起始时间
cat /proc/net/sockstat # socket 用量统计
ulimit -n # 当前进程能开多少 fd ← 连接数上不去先查这个
📌 「客户端连不上服务端」的排查顺序(这个思路比命令重要): ①
ping看网络层通不通 → ②telnet host port看端口通不通 → ③ 服务端ss -tlnp看有没有在监听、监听的是127.0.0.1还是0.0.0.0→ ④ 看防火墙(iptables -L、云上的安全组) → ⑤tcpdump两端同时抓,看包到底走到哪一步没了。
2.5 工程
这一节考的不是知识点,是**「你平时是怎么干活的」。 你简历上写了 CMake、交叉编译、Git、GDB / Valgrind / perf, 这四样面试官都会挑一样让你讲一个具体的、你自己经历过的场景**。 所以本节每一小块都按同一个顺序展开:这东西是干什么的 → 它的模型是什么 → 常见的坑 → 面试怎么答。
2.5.0 前置:一次构建里到底发生了什么
从一行 g++ 说起
g++ main.cpp foo.cpp -o app
这一行其实是四个阶段连在一起跑:
main.cpp ──①预处理──> main.i ──②编译──> main.s ──③汇编──> main.o ─┐
├─④链接──> app
foo.cpp ──①预处理──> foo.i ──②编译──> foo.s ──③汇编──> foo.o ─┘
| 阶段 | 干什么 | 单独跑 | 这一步的报错长什么样 |
|---|---|---|---|
| ① 预处理 | 展开 #include、替换宏、处理 #if | g++ -E | fatal error: xxx.h: No such file → 头文件路径不对 |
| ② 编译 | 语法检查、生成汇编 | g++ -S | error: 'x' was not declared → 代码/声明问题 |
| ③ 汇编 | 汇编 → 目标文件 .o | g++ -c | 基本不会在这挂 |
| ④ 链接 | 把各个 .o 和库里的符号对起来 | ld | undefined reference to 'foo()' → 少链了库,或只有声明没有实现 |
📌 一个非常实用的判断:看报错属于哪个阶段,直接就能定位方向。
No such file是找头文件(-I/target_include_directories),undefined reference是找不到实现(-l/target_link_libraries),error while loading shared libraries是运行时加载器找不到.so(见 2.2.5)。 这三句话说清楚,工程基本功这一关就过了。
为什么需要构建系统
项目有几百个 .cpp 时,手敲命令行会死在三件事上:
- 增量编译:改了一个头文件,到底要重编哪些
.cpp? (.o不只依赖它的.cpp,还依赖它#include的所有头文件,递归) - 编译选项一致:一半文件用
-DNDEBUG编、一半没用, 同一个类在两边大小不一样,链接能过,运行时诡异崩溃(ODR 违规)。 - 跨平台:Linux 用 g++,Windows 用 MSVC,命令行完全不一样。
这几个名字的关系(一句话各就各位)
你写的 配置阶段生成的 构建阶段执行的
CMakeLists.txt ──cmake──> build.ninja / Makefile ──ninja/make──> 一条条编译器命令
| 名字 | 是什么 | 定位 |
|---|---|---|
| Make | 构建执行器。你写 Makefile 描述”谁依赖谁、怎么生成” | 最老,手写大项目是灾难 |
| Ninja | 也是执行器,但只为快设计,规则文件不给人手写 | 大项目一律用它 |
| CMake | 不是构建工具,是生成器:读 CMakeLists.txt,生成 Makefile / build.ninja / VS 工程 / Xcode 工程 | 事实标准 |
| Autotools | ./configure && make,老牌开源项目常见 | 交叉编译时你会碰到 |
| Bazel | 大厂自用,强制可复现、分布式缓存 | 了解即可 |
cmake -S . -B build -G Ninja # 配置阶段:生成构建文件
cmake --build build -j8 # 构建阶段:真正编译
2.5.1 CMake
📌 第一件事:分清「配置阶段」和「构建阶段」
很多人用了两年 CMake 都没分清这两个阶段,一问就露馅。
┌─ 配置阶段(cmake -S . -B build)───────────────────────┐
│ · 把 CMakeLists.txt 当【脚本】从上到下执行一遍 │
│ · 探测编译器:这个 g++ 支持 C++17 吗?sizeof(long) 多大?│
│ · 跑 find_package:Protobuf 在哪?版本够吗? │
│ · 结果写进 build/CMakeCache.txt(缓存) │
│ · 生成 build.ninja │
└────────────────────────────────────────────────────────┘
↓
┌─ 构建阶段(cmake --build build)──────────────────────┐
│ · ninja 读依赖图,只重编需要重编的 │
│ · 真正调用 g++ / ld │
└────────────────────────────────────────────────────────┘
分清了才能理解这几件事:
if(CMAKE_BUILD_TYPE STREQUAL "Debug")是在配置阶段判断的。 所以用 VS / Xcode 这种多配置生成器时它是空的—— 那些生成器在构建阶段才决定编 Debug 还是 Release。 → 这正是生成器表达式存在的原因(下面讲)。message()只在配置阶段打印,add_custom_command是构建阶段才跑。- 改了
CMakeLists.txt,构建时 CMake 会自动重跑配置,不用你手动配。
⚠️ CMakeCache.txt:最容易踩的坑
配置阶段的结果全缓存在 build/CMakeCache.txt 里。
缓存变量只有第一次 -D 才写进去,之后你再传新值,
有些变量(尤其是编译器、toolchain)根本不会生效。
cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=aarch64.cmake # 第一次,生效
cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=riscv.cmake # ❌ 不生效!编译器早缓存了
📌 规则:换编译器 / 换 toolchain / 换 sysroot,一律
rm -rf build重来。 只改普通选项(-DCMAKE_BUILD_TYPE、自定义option)才可以原地重配。 做交叉编译的人被这个坑过一定有印象,讲出来很真实。
target 到底是什么
target 就是构建依赖图上的一个节点,节点上挂着一堆属性(property)。 CMake 的现代用法,本质就是”往节点上挂属性 + 在节点之间连边”。
add_executable(app src/main.cpp) # 可执行文件 target
add_library(foo src/foo.cpp) # 库 target(STATIC / SHARED / MODULE)
add_library(hdr INTERFACE) # 纯头文件库:没有源文件,只有"使用要求"
add_library(ext SHARED IMPORTED) # IMPORTED:不是我编的,是外面已经有的库
节点上的属性大致是这几类:
target: foo
├── 编译时用:include 路径、宏定义、编译选项、C++ 标准
└── 链接时用:要链哪些库、链接选项
老式写法为什么被淘汰——因为它是往目录上挂,不是往 target 上挂:
# ❌ 老式:对当前目录下【所有】目标生效,还会往子目录传染
include_directories(/path/to/include)
add_definitions(-DFOO=1)
link_libraries(bar)
项目一大就完全失控:你不知道某个编译选项是从哪儿来的,也不知道去掉它会影响谁。
# ✅ 现代:所有设置都挂在具体 target 上
add_library(foo src/foo.cpp)
target_include_directories(foo PUBLIC include PRIVATE src)
target_compile_definitions(foo PRIVATE FOO_INTERNAL=1)
target_link_libraries(foo PUBLIC bar PRIVATE baz)
target_compile_features(foo PUBLIC cxx_std_17)
📌 PUBLIC / PRIVATE / INTERFACE(必问,一定要能解释清楚)
这三个词回答的是同一个问题: 「这条设置,是我自己编译时需要,还是用我的人也需要,还是两者都要?」
| 关键字 | 我自己编译时用吗 | 依赖我的人用吗 | 典型场景 |
|---|---|---|---|
PRIVATE | ✅ | ❌ | 实现里用的第三方库、内部头文件目录、内部宏 |
INTERFACE | ❌ | ✅ | 纯头文件库(自己不需要编译,用的人才需要) |
PUBLIC | ✅ | ✅ | 出现在你公开头文件里的依赖 |
判断口诀:
看你的公开头文件(
include/foo.h)里有没有#include它。 有 →PUBLIC(因为用你的人编译时也会展开到那个头文件) 没有,只在.cpp里用 →PRIVATE
// include/foo.h ← 公开头文件
#include <json/json.h> // ← 用 foo 的人也会被迫 include 它 → json 必须 PUBLIC
class Foo { Json::Value v; };
// src/foo.cpp
#include <zlib.h> // ← 只在实现里用,外面看不见 → zlib 是 PRIVATE
传播是怎么发生的(说清这个就比别人深一层):
每个 target 其实存了两套属性——
COMPILE_DEFINITIONS(我自己编译用)和 INTERFACE_COMPILE_DEFINITIONS(传给别人的)。
写 PRIVATE → 只写进 自己那套
写 INTERFACE → 只写进 INTERFACE_ 那套
写 PUBLIC → 两套都写 ← PUBLIC 就是 PRIVATE + INTERFACE
链接时,target_link_libraries(app PRIVATE foo) 会把 foo 的 INTERFACE_* 属性
合并进 app 的编译命令行:
app 的编译命令 = app 自己的选项
+ foo 的 INTERFACE_* (直接依赖)
+ bar 的 INTERFACE_* (foo PUBLIC 链接了 bar,所以继续传)
← baz 的不传,因为 foo 是 PRIVATE 链的 baz
这套机制叫依赖传播 / 使用要求(usage requirements)。
写对了,依赖你的项目只要写 target_link_libraries(app PRIVATE foo) 一行,
头文件路径、编译选项、间接依赖全都自动带上。
⚠️ 一个容易误解的点:
PRIVATE链接的静态库, 在最终链接可执行文件时仍然会出现在链接命令行上 (CMake 用$<LINK_ONLY:baz>传下去),否则符号根本解析不了。 不传播的是”使用要求”(头文件路径、宏),不是”链接这个动作本身”。 说清这一条,说明你真的理解了传播模型,而不是背了张表。
find_package 的两种模式
find_package(Protobuf REQUIRED)
- Module 模式:找一个叫
FindProtobuf.cmake的脚本 (CMake 自带一批,或项目自己提供)。脚本里用一堆find_library/find_path去猜库在哪。猜的,所以不一定准,版本信息也常常缺。 - Config 模式:找
ProtobufConfig.cmake/protobuf-config.cmake。 这个文件是库自己安装时生成的,精确记录了它导出的 target、依赖、编译选项。 更准,优先用。 找到的结果通常是protobuf::libprotobuf这种带命名空间的 target。
找不到时的标准排查顺序(能背出来很实用):
cmake -S . -B build --debug-find # ① 看它到底去哪些路径找了
-DCMAKE_PREFIX_PATH=/opt/mylibs # ② 告诉它去哪找(最常用的一招)
-DProtobuf_DIR=/opt/.../lib/cmake/protobuf # ③ 直接指定 Config 文件所在目录
pkg-config --cflags --libs protobuf # ④ 没有 CMake 配置文件时的兜底(配合 FindPkgConfig)
⚠️ 交叉编译时最常见的失败是:它找到了宿主机
/usr/lib里的库, 编出来的东西在设备上跑不了。防这个的就是下面的CMAKE_FIND_ROOT_PATH_MODE_*。
生成器表达式:为什么会有这种奇怪语法
target_compile_options(foo PRIVATE $<$<CONFIG:Debug>:-fsanitize=address>)
$<...> 看着别扭,但它存在的理由很硬:
配置阶段有些信息还不知道(多配置生成器里 Debug/Release 是构建时才选的),
所以要留一个”到生成命令行那一刻再求值”的表达式。
常用的就三类:
$<$<CONFIG:Debug>:-g3> # 按构建类型
$<$<CXX_COMPILER_ID:GNU>:-Wall> # 按编译器
$<BUILD_INTERFACE:${CMAKE_SOURCE_DIR}/include> # 在本仓库里构建时用这个路径
$<INSTALL_INTERFACE:include> # 被 install 出去以后用这个路径
最后两个是写可被别人 find_package 的库时必须的:
你的源码目录路径不能被写进安装后的配置文件里,否则别人机器上根本没这个路径。
📌 交叉编译与 Toolchain File(你的主场)
什么是交叉编译:在 A 架构的机器上,编出 B 架构能跑的程序。 比如在 x86_64 的 PC 上,编 aarch64 设备上跑的程序。
Toolchain File 就是告诉 CMake「目标是什么架构、用哪个编译器、 头文件和库去哪找」的一个文件:
# aarch64-musl.cmake
set(CMAKE_SYSTEM_NAME Linux) # ← 设了它,CMAKE_CROSSCOMPILING 才为 TRUE
set(CMAKE_SYSTEM_PROCESSOR aarch64)
set(CMAKE_C_COMPILER clang)
set(CMAKE_CXX_COMPILER clang++)
set(CMAKE_C_FLAGS "--target=aarch64-linux-musl")
set(CMAKE_CXX_FLAGS "--target=aarch64-linux-musl")
set(CMAKE_SYSROOT /opt/aarch64-musl-sysroot) # 目标平台头文件和库的根
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) # find_* 的搜索根
# ⚠️ 这四行是交叉编译的核心,见下面的解释
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) # 程序:只在【宿主机】找
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) # 库: 只在【sysroot】找
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) # 头: 只在【sysroot】找
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=$PWD/aarch64-musl.cmake
📌 为什么 PROGRAM 要单独设成 NEVER(这题能问出水平)
构建过程中经常要运行一些工具:protoc(protobuf 代码生成器)、
flex / bison、各种自研代码生成脚本。
这些工具必须是【宿主机架构】的 —— 因为它们要在【构建机上运行】
被编译的库必须是【目标架构】的 —— 因为它们要在【设备上运行】
如果 CMAKE_FIND_ROOT_PATH_MODE_PROGRAM 设成 ONLY,
CMake 会去 sysroot 里找 protoc,找到的是 aarch64 版的 protoc,
在 x86 构建机上根本跑不起来(Exec format error)。
📌 这就是 protobuf 交叉编译必须构建两次的原因: 先用宿主机编译器编一份 x86 的
protoc供构建时使用, 再用交叉编译器编一份 aarch64 的libprotobuf供设备使用。 你做 PyTorch 移植必然踩过这个,讲出来非常具体。
⚠️ 交叉编译的第二个坑:try_compile 跑不起来
CMake 在配置阶段会编一个小程序来试编译器行不行(try_compile),
有些检查还会运行它(try_run、check_c_source_runs)。
交叉编译时这个小程序是目标架构的,在构建机上跑不了,配置直接失败。
# 解法一:只让它编成静态库,不链接、不运行
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)
# 解法二:某些项目的 try_run 检查,手动把结果塞进缓存
set(HAVE_XXX_EXITCODE 0 CACHE STRING "")
这两条是真正做过移植的人才知道的细节, 比背
PUBLIC/PRIVATE更能证明你干过活。
其他实用点
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # 生成 compile_commands.json,给 clangd/IDE 用(必开)
set(CMAKE_POSITION_INDEPENDENT_CODE ON) # 静态库要被链进 .so 时需要(否则报 recompile with -fPIC)
-DCMAKE_BUILD_TYPE=Debug # -g -O0
-DCMAKE_BUILD_TYPE=Release # -O3 -DNDEBUG
-DCMAKE_BUILD_TYPE=RelWithDebInfo # -O2 -g ← 生产环境推荐,出问题还能调
-DBUILD_SHARED_LIBS=ON # 让 add_library() 默认产出 .so
排错速查表
| 报错 | 阶段 | 多半是什么原因 |
|---|---|---|
Could NOT find Xxx | 配置 | CMAKE_PREFIX_PATH 没设 / 交叉编译时被 ONLY 挡住 |
No such file or directory: xxx.h | 编译 | 依赖方少了 PUBLIC include,或你写成了 PRIVATE |
undefined reference to ... | 链接 | 少 target_link_libraries,或链接顺序问题(静态库) |
relocation R_X86_64_32 ... recompile with -fPIC | 链接 | 静态库要进 .so,缺 POSITION_INDEPENDENT_CODE |
| 改了 toolchain 但编译器没变 | 配置 | CMakeCache.txt 缓存了 → rm -rf build |
Exec format error | 构建 | 用了目标架构的工具在构建机上跑 → MODE_PROGRAM NEVER |
2.5.2 Git
前置:Git 的数据模型(搞懂这个,命令就不用背了)
Git 存的不是”差异”,是一系列快照。
- commit = 一次完整的目录快照 + 作者/时间/message + 指向父 commit 的指针
- branch = 一个可以移动的指针,指向某个 commit
- HEAD = 指向”你现在在哪个分支”的指针
A ←── B ←── C ← 箭头是"父指针",指向过去
↑
main ←── HEAD
再提交一次,只是 main 这个指针往前挪一格:
A ←── B ←── C ←── D
↑
main ←── HEAD
📌 理解了这个,很多事就顺了: 建分支为什么是 O(1)?——只是写了个 41 字节的文件(一个指针)。
git reset为什么”很危险”?——它在移动分支指针, 被落下的 commit 没有分支指着,就”看不见”了(但还在,reflog能找回)。
三个区:改动是怎么一步步进仓库的
工作区 暂存区(index) 仓库(.git)
(你编辑的文件) (下次要提交的内容) (commit 历史)
│ │ │
│──── git add ───────>│ │
│ │──── git commit ─────>│
│<─── git restore ────│ │ 丢弃暂存区的改动,回到工作区状态
│<─────────── git checkout/restore ──────────│ 用仓库里的版本覆盖工作区
git reset 的三个模式,就是”回退到哪一层为止”:
| 命令 | 分支指针 | 暂存区 | 工作区 | 什么时候用 |
|---|---|---|---|---|
reset --soft HEAD~1 | 回退 | 保留 | 保留 | 想改上一个提交的 message / 重新组织提交 |
reset --mixed HEAD~1(默认) | 回退 | 清空 | 保留 | 提交拆得不对,想重新 add |
reset --hard HEAD~1 | 回退 | 清空 | 丢弃 | ⚠️ 真的不要这些改动了 |
git commit --amend # 只改最后一个提交(message 或补一点内容)
git restore <file> # 丢弃工作区某个文件的改动(老写法:git checkout -- <file>)
git restore --staged <file> # 把文件从暂存区拿出来(老写法:git reset HEAD <file>)
📌 rebase vs merge(必问)
merge:把两条分支的历史合并,产生一个新的 merge commit。
main A───B───────M
\ /
feature C───D
- ✅ 保留真实历史:什么时候分叉、什么时候合并,一清二楚
- ❌ 分支多了以后,历史图变成一团麻
rebase:把你的提交重新在目标分支上”播放”一遍,历史变成一条直线。
rebase 前: rebase main 后:
main A───B main A───B───C'───D'
\ ↑
feature C───D (C' D' 是 C D 的【副本】,内容一样但 SHA 变了)
它的实际动作是:把 C、D 依次当成 patch,重新打在 B 上面。
所以 —— 每一个 commit 都可能各冲突一次,这就是为什么
rebase 时你会被要求解好几轮冲突,而 merge 只解一次。
(git rerere 可以让 Git 记住你解过的冲突,重复时自动套用。)
- ✅ 历史线性,
git log好读,git bisect好用 - ❌ 改写了提交的 SHA
⚠️ 黄金法则:已经推送到公共分支的提交,不要 rebase。 因为 rebase 生成新 SHA,别人本地的旧提交对不上, 他们下次 pull 会产生大量重复提交和冲突。 万一必须推:
git push --force-with-lease(比--force安全, 它会检查远端是不是你上次看到的样子,别人推过东西就拒绝)。
常见团队规范:
- 自己的功能分支:随便 rebase,保持整洁(
git rebase -i把 “fix typo” 之类压掉) - 合入主干:用 merge 或 squash merge(整个分支压成一个提交)
cherry-pick其实是同一个机制的单点版本:把某个 commit 当 patch 重打一遍, 所以它也会产生新 SHA。rebase ≈ 连续的 cherry-pick。
冲突处理
git status # 看哪些文件冲突了
# 手动编辑,删掉 <<<<<<< ======= >>>>>>> 标记,保留正确内容
git add <file>
git rebase --continue # 或 git merge --continue
git rebase --abort # 搞不定就整个退回去(或 git merge --abort)
⚠️ 一个高频踩坑:rebase 时
ours和theirs是反的。 因为 rebase 是”把你的提交打到目标分支上”, 此时目标分支才是当前被检出的那一方:--ours= 你要 rebase 到的分支(比如 main),--theirs= 你自己的提交。 merge 时才是符合直觉的(ours= 当前分支)。 搞反过一次的人一定记得,这是个很好的”你真的用过”的信号。
常用命令(按用途分组)
# ── 整理历史 ──
git rebase -i HEAD~3 # 交互式整理最近 3 个提交(合并 squash / 改 message / 删除 / 调序)
git rebase --onto A B C # 把 B..C 这段提交挪到 A 上(拆分支、摘错基分支时用)
git cherry-pick <sha> # 只摘一个提交过来
# ── 救命 ──
git reflog # 🆘 HEAD 的所有移动记录,能找回 reset/rebase 丢掉的提交
git reset --hard <reflog里的sha>
git bisect start / bad / good # 二分查找是哪个提交引入了 bug(可配脚本 git bisect run)
# ── 日常 ──
git stash / git stash pop # 临时收起未提交的改动
git log --oneline --graph --all # 看分支图
git log -S "某段代码" # 找哪个提交引入/删除了这段代码 ← 排查历史神器
git blame <file> # 每一行是谁、哪个提交改的
git diff --staged # 看暂存区里到底要提交什么(提交前的好习惯)
找回丢失提交的完整套路(面试问”你误删过提交吗”就答这个):
git reflog # 找到出事之前 HEAD 指向的那个 sha
git branch rescue <sha> # 在那个 commit 上建个分支,立刻就"看得见"了
开源社区 PR 流程(你有 OpenHarmony 提交经历,要能讲)
① fork 上游仓库到自己账号
② 建一个功能分支(不要在 master 上改)
③ 提交,遵守项目的 commit message 规范
(常见是 Conventional Commits:feat: / fix: / refactor:)
④ 推到自己的 fork
⑤ 提 PR / MR,写清楚:改了什么、为什么、怎么验证的
⑥ CI 自动跑构建和测试
⑦ Reviewer 提意见 → 你修改(小改用 --amend,大改追加提交)
⑧ 通过后由 maintainer 合入
⬜ 待填:OpenHarmony 走的是 Gitee PR 流程,
它对 Signed-off-by / DCO(开发者原创声明) 有要求,
CI 里还有代码规范检查。你实际提交时的完整流程要自己回忆一遍——
面试官问”你怎么给开源项目提代码的”,这是你的加分项,不能答得含糊。
2.5.3 调试
⚠️ 只写你确实用过的工具。 简历技能栏写了 GDB / Valgrind / perf, 面试官一定会问「你用它定位过什么具体问题」。 答不出具体案例,比不写更伤——现在补一个真实案例,或者从简历里删掉。
先看全景:按现象选工具
%%{init: {"flowchart": {"htmlLabels": true, "nodeSpacing": 26, "rankSpacing": 32}}}%%
flowchart TD
S(["出问题了"]) --> Q{"什么现象?"}
Q -->|"崩了"| C1["core dump + gdb bt<br/>看崩在哪、this 是不是 0"]
Q -->|"内存一直涨"| C2["先分清:真泄漏,还是<br/>分配器没还给系统<br/>→ ASan / heaptrack / 看 RSS"]
Q -->|"CPU 100%"| C3["perf top → 热点在哪<br/>在锁上就是竞争,<br/>在业务函数就是算法问题"]
Q -->|"卡住不动"| C4["gdb -p + thread apply all bt<br/>找互相等待的线程 → 死锁"]
Q -->|"CPU 不高但就是慢"| C5["⚠️ on-CPU 火焰图看不出来<br/>是在等锁或等 I/O<br/>→ off-CPU 分析 / strace"]
Q -->|"结果偶尔算错"| C6["TSan(-fsanitize=thread)<br/>→ 数据竞争"]
Q -->|"起不来"| C7["见 2.2.5 的动态库决策树"]
程序崩溃了怎么定位
什么是 core dump:程序崩溃的那一刻,内核把它当时的 完整内存镜像(所有变量、所有线程的栈)dump 成一个文件。 有了它,你可以事后用 gdb “回到崩溃现场”,看每个变量的值。
# ── 第 1 步:确保 core dump 能生成 ──────────────────
ulimit -c unlimited # 解除 core 文件大小限制(默认常常是 0,即不生成)
cat /proc/sys/kernel/core_pattern # 看 core 存到哪、叫什么名字
# 现代发行版常被 systemd 接管,那就用:
coredumpctl list # 列出最近的崩溃
coredumpctl gdb # 直接用 gdb 打开最近那个
# ── 第 2 步:用 gdb 分析 ────────────────────────────
gdb ./app core.1234
gdb 里的核心命令:
(gdb) bt # backtrace:崩溃时的函数调用栈 ← 最重要的一条
(gdb) bt full # 带上每一帧的局部变量
(gdb) thread apply all bt # 所有线程的栈 ← 多线程程序必用
(gdb) frame 2 / up / down # 在调用帧之间切换
(gdb) info locals # 当前帧的局部变量
(gdb) print var # 打印变量(可以 p *ptr、p obj.field、p arr[0]@10 打印一段)
(gdb) info registers
(gdb) x/16xb ptr # 以十六进制按字节查看 ptr 指向的 16 字节内存
(gdb) info sharedlibrary # 加载了哪些 .so、加载到什么地址 ← 排动态库问题
活着的进程怎么调(不只有 core 一种用法):
gdb -p PID # 附到正在跑的进程上(卡死时最常用)
(gdb) break foo.cpp:88 # 打断点,可以加条件:break foo.cpp:88 if id == 42
(gdb) watch g_count # 数据断点:这个变量被改写时停下 ← 查"谁改了我的值"神器
(gdb) finish # 跑完当前函数回到上一层
(gdb) continue / next / step
怎么读 backtrace(很多人不会看):
#0 0x... in Session::read (this=0x0, buf=...) at session.cpp:88
#1 0x... in Worker::loop () at worker.cpp:42
#2 0x... in std::thread::_State_impl<...>::_M_run ()
#0是崩溃发生的那一帧,从这里开始看this=0x0一眼就是空指针——Session对象已经被销毁了, 但工作线程还在用它。这正是你项目里”会话销毁 vs 工作线程正在读”那个问题- 往下的
#1 #2是调用链,告诉你是谁调过来的
前提:编译时必须带 -g(生成调试符号)。
Release 包不想带符号的话,标准做法是分离符号文件:
g++ -g -O2 -o app app.cpp
objcopy --only-keep-debug app app.debug # 把符号抽出来单独存
objcopy --strip-debug app # 发布的二进制不带符号,体积小
objcopy --add-gnu-debuglink=app.debug app # 记录关联
# 调试时:gdb 会自动找 app.debug,或手动 (gdb) symbol-file app.debug
⚠️ 调
-O2的程序时经常看到<optimized out>和”行号乱跳”—— 变量被优化进寄存器了、函数被内联了。 这不是 gdb 坏了。生产环境用RelWithDebInfo(-O2 -g)+ 分离符号, 实在读不懂就针对那个文件临时降到-O0重编。
内存问题怎么查
# 方式一:valgrind —— 不用重新编译,但慢 10-50 倍
valgrind --leak-check=full --show-leak-kinds=all ./app
# 方式二:ASan(AddressSanitizer)—— 需要重新编译,但只慢 2 倍左右 ✅ 推荐
g++ -fsanitize=address -fno-omit-frame-pointer -g app.cpp && ./a.out
# 只查泄漏(更轻量)
g++ -fsanitize=leak ...
ASan 能抓到的问题(比 valgrind 少一些,但覆盖了最常见的):
- 堆/栈/全局区缓冲区溢出(读写越界)
- use-after-free(用了已释放的内存)
- double-free
- 内存泄漏
ASan 报告怎么看:它会同时告诉你 「在哪里出错」和「这块内存是在哪里分配的、在哪里释放的」—— 这三个栈一对照,问题基本就定位了。
📌 一个能加分的观点:「在日常开发和 CI 里常态跑 ASan, 比出了问题再用 valgrind 事后追划算得多。 ASan 只慢 2 倍,CI 完全跑得起;valgrind 慢几十倍,只能针对性地用。 而且 ASan 是在出错的那一刻就报,valgrind 常常只能告诉你’有泄漏’, 定位还要花时间。」
⚠️ ASan 和 TSan 不能同时开(互相冲突),要分两次跑。
死锁 / 数据竞争怎么查
# 已经死锁了 → 直接看现场
gdb -p PID
(gdb) thread apply all bt # 找卡在 pthread_mutex_lock / __lll_lock_wait 的线程
# 预防性 → 测试期就发现
g++ -fsanitize=thread -g ... # TSan,能在【没真死锁】时就报出潜在的加锁顺序问题
死锁现场怎么读:thread apply all bt 出来后,找两个及以上卡在加锁上的线程,
看每个线程的栈里”已经持有什么锁、正在等什么锁”,
形成环(A 持 1 等 2,B 持 2 等 1)就是死锁。见 2.3 的锁顺序讨论。
性能问题怎么查
perf 的基本思路是采样:每秒中断程序 N 次, 记录当前在执行哪个函数、调用栈是什么。采样多了, 哪个函数出现次数多,它就是热点。
perf top -p PID # 实时看热点,最快的第一眼
perf record -g ./app && perf report # 录一段再慢慢看(-g 记录调用栈)
perf record -F 99 -g -p PID -- sleep 30 # 以 99Hz 采样 30 秒
# 火焰图(把采样结果可视化,一眼看出热点)
perf script | stackcollapse-perf.pl | flamegraph.pl > out.svg
火焰图怎么看:
- 横轴不是时间,是”这个函数在所有采样里出现的比例”,越宽越热
- 纵轴是调用栈深度,上面的函数是被下面的函数调用的
- 找又宽又平的顶部平台——那就是真正在烧 CPU 的地方
⚠️ 一个重要区分:on-CPU 和 off-CPU。
perf默认只采样”正在用 CPU”的时间。 如果你的程序是卡在锁上或者等 I/O(CPU 占用很低但就是慢), 火焰图上什么都看不出来。这时要用 off-CPU 分析 (perf sched、或者直接多次gdb -p抓栈看它总卡在哪)。 能主动区分这两种,是很强的性能分析信号。
📌 讲案例的固定结构(面试真正在考的东西)
工具会用是及格线,能把一次排查讲成一个完整故事才是加分项。按这五步说:
① 现象:什么场景下、多久出现一次、影响是什么
("压测跑 20 分钟必崩一次" 比 "有个 bug" 强十倍)
② 缩小范围:先排除了什么?怎么排除的?
③ 用什么工具、看到了什么关键证据(贴出那一行 backtrace / ASan 报告)
④ 根因是什么(讲清机制,不是"加个锁就好了")
⑤ 怎么修的 + 怎么证明修好了(回归测试、跑多久没复现)
⬜ 待填:至少准备两个——一个崩溃/内存类(用 gdb 或 ASan),
一个并发或性能类。你项目里”会话销毁时工作线程还在读”那个,
天然就是第一类的好素材,按上面五步整理成能讲 3 分钟的版本。
2.6 算法
武汉 3-5 年 C++ 岗的实际强度:LeetCode 中等为主, 白板或共享编辑器手写。核心考察的不是解难题, 是能不能写出没有边界 bug 的代码。
必须手写到肌肉记忆的四个
| # | 题目 | 关键点 |
|---|---|---|
| 1 | 快速排序 | 必须随机 pivot 或三数取中——否则遇到已排序输入退化成 O(n²)。 面试官经常就等着你踩这个 |
| 2 | 归并排序 | 稳定排序;链表排序首选(不需要随机访问,空间 O(1)) |
| 3 | LRU 缓存 | unordered_map + 双向链表;get 和 put 都要 O(1)。C++ 里可以直接用 std::list + map<key, list::iterator> |
| 4 | 线程池 | 见 2.3.2。很多 C++ 岗把它当算法题考,比排序更常出现 |
高频题型清单
| 类型 | 典型题 | 套路 |
|---|---|---|
| 链表 | 反转、环检测、合并 K 个有序链表、找中点 | 快慢指针、哑结点(dummy head)避免头节点特判 |
| 二叉树 | 层序遍历、最近公共祖先、路径和、序列化、非递归中序 | 递归为主;层序用队列;非递归用栈 |
| 双指针 | 三数之和、盛最多水的容器、删除重复元素 | 先排序,再首尾夹逼 |
| 滑动窗口 | 无重复最长子串、最小覆盖子串、长度最小子数组 | 右指针扩、左指针缩,维护窗口内的统计量 |
| BFS / DFS | 岛屿数量、单词接龙、课程表(拓扑排序) | BFS 用队列求最短;DFS 用递归/栈求连通 |
| 简单 DP | 爬楼梯、最长递增子序列、编辑距离、背包 | 先定义状态,再写转移方程,最后想初始值 |
| Top K | 第 K 大、前 K 个高频元素 | priority_queue(堆)或快速选择 |
| 字符串 | 字符串转整数(atoi)、最长回文子串 | atoi 的边界最多:空格、正负号、溢出、非法字符 |
📌 答题技巧(这四条比多刷 20 道题管用)
- 先说思路,再写代码。 直接埋头写是明确减分项——面试官看不到你的思考过程, 而且你写错方向了他也没机会拦你。 标准开场:「我想用滑动窗口做,左右指针分别……时间复杂度 O(n)。可以吗?」
- 主动说复杂度,时间和空间都要说。
- 写完主动测边界,当着面试官的面走一遍: 空输入、单元素、全相同元素、整数溢出、越界。 主动测边界比代码一次写对更能体现工程素养。
- 用 C++ 写就要体现 C++ 素养:
const&传参、emplace_back而不是push_back(T(...))、reserve预分配、避免不必要的拷贝。 面试官在看你写代码的习惯,这些细节比算法本身更能说明你的水平。
📌 对你的特别提醒:你是转 C++ 的, 算法题里每一个 C++ 细节都会被放大解读。 一个
for (auto s : vec)(少了&,每次循环拷贝一个 string) 就可能被判定成”C++ 基础不扎实”。练题时刻意注意这些。
第三档:短板应对(可直接用的答法)
通用原则:承认 → 给替代证据 → 说清上手路径。 三样东西不要做:不辩解、不含糊、不说「差不多」。
面试官问短板不是为了淘汰你,是在测你对自己能力边界的认知是否准确。 一个能清楚说出「我不会什么」的人,说「我会什么」时才可信。
Q1:为什么两个项目阶段看起来只有 8 个月?
「我从 2024 年 3 月起一直与德科签约,中间是派驻项目变化。 前一个阶段做 OpenHarmony 升级子系统,之后转向 PC Native 工具链适配。」
这是个人画像中本人已确认的事实,不是换签约公司或承接方变更。回答清楚后,再讲两个阶段的实际职责和产出,不沿用旧版本话术。
Q2:前两年做前端,为什么转 C++?
不要说「前端太卷」「想学更底层」这种空话。 用具体经历:
「做三维平台时,性能瓶颈基本都在底层——模型分级加载、视锥剔除、 空间索引(BVH)、Shader 优化,这些问题的解决方式和上层业务开发完全不同, 更接近数据结构和内存布局的思路。做多了发现我真正感兴趣的是这一层, 所以 2024 年转到了系统方向。转过来之后是连续 2.5 年的 C++ 系统开发, 升级子系统、终端客户端、PyTorch 移植都是这段时间的。」
这个答法的价值:把「转行」讲成「顺着技术兴趣走深」, 而且用了真实的技术细节做支撑。
Q3:非计算机专业会不会有影响?
「专业确实不是计算机。我的补法是把系统层的东西做扎实—— 动态链接、文件系统、交叉编译、并发这些,都是在真实项目里踩出来的。 具体到哪一块您可以随便挑着问。」
不辩解,不说「我自学能力强」这类空话。直接把话头递回技术。 这题的最优解就是短。
Q4:没有 Qt / OpenGL / ROS / CAN 经验
「这几个我确实没有生产项目经验。 Qt 我读过它的应用代码——做 NotepadNext 适配时剥离过它的 UI 层, 对信号槽和 Qt 的对象树模型有基本了解,但没写过 Qt 业务代码。 图形方面我用的是 WebGL/GLSL,渲染管线、Shader 这些概念是通的, 但原生 OpenGL 的 API 和上下文管理没实际写过。 以我做跨平台适配的经验,上手一个新框架大概需要 ⬜ 你自己给一个诚实的估计。」
关键:分清「读过 / 用过 / 做过生产项目」三个层次,不要模糊。 说 WebGL 时主动划清和 OpenGL 的界限——面试官会因为这个诚实加分。
Q5:没有服务端 / 数据库经验(腾讯后台岗)
「服务端业务开发我没做过,数据库也停留在基本使用。 我能对上的是底层部分:网络编程、多线程模型、Linux 系统调用、 连接生命周期管理——终端客户端那个项目本质上就是长连接 + 并发会话管理。 数据库我最近在补索引、事务隔离级别这些基础。」
投这类岗前必补(各花半天):
- MySQL:B+ 树索引、聚簇 vs 二级索引、回表、最左前缀、 事务 ACID、四种隔离级别、MVCC、间隙锁
- Redis:五种数据结构、持久化 RDB/AOF、过期策略、缓存穿透/击穿/雪崩
Q6:项目没有性能数据
「这个项目我做到的是功能验证,没有做基准测试。 如果要补,我会 ⬜(说出具体方案:测什么指标、怎么控制变量、和什么对比)。」
承认 + 说清该怎么测,比含糊说「性能挺好」强得多。 参考 3.6 节 PyTorch 那个答法的结构。
Q7:没有内核 / 驱动经验(海康、移远可能问)
「我的工作在用户态。但因为做升级和刷机,和内核态的边界是打过交道的: 分区表、块设备节点、文件系统挂载、
ioctl拿设备信息这些。 BSP 和驱动本身我没做过。」
主动划清边界反而显专业。做过 OTA 的人对这条边界的位置很清楚, 这本身就是证据。
补充:这四个也一定会问,提前想好
用户给的清单里没有,但每一场都会问。
为什么想换工作?
最忌讳:说钱、说加班、说领导。
安全答法的结构是「现在的处境 + 想要的下一步」,且必须往技术上落:
「现在做的方向偏适配和迁移,比较广但不够深。 我想找一个能长期在设备侧系统软件上做深的岗位—— 升级、启动、文件系统这条线是我最感兴趣、也最有积累的。 另外也希望能到甲方,参与到产品完整的生命周期里。」
⬜ 按你的真实想法调整,但保持「奔着什么去」而不是「逃离什么」的框架。
未来 3 年规划?
「往设备侧系统软件的纵深走。近期是把升级、启动、文件系统这条链路做透, 中期希望能负责一个完整的系统组件,从设计到上线。 我不太想往纯管理走,更希望在技术上有明确的深度。」
期望薪资?
- 武汉这批岗位区间:嵌入式 Linux 15-30K,系统软件 20-40K
- 你 4 年经验 + 系统层材料,合理区间 20-30K
- 答法:先问对方的区间;一定要你先说就给区间 + 一句「具体可以谈」
- 不要报一个精确数字,也不要低于现在的薪资
有什么想问我们的?
必须准备 2-3 个,问不出来是明确减分。好问题的方向:
- 「这个岗位接手后最先要解决的问题是什么?」(显示你在想工作本身)
- 「团队现在的技术栈和代码规模大概是什么样?有没有单元测试和 CI?」 (工程成熟度,也是你在筛他们)
- 「这个岗位往后 1-2 年的技术方向是什么?」
不要一上来问:加班、调薪、几点下班。留到 HR 面。
第四档:高级岗系统设计题(从零讲起)
这一档假设你什么都忘了。 每道题都先用大白话讲清「这东西是干嘛的、 为什么需要它」,再给答题框架。看不懂就说明是我没写清楚,不是你的问题。
4.0 先搞清楚:什么是「系统设计题」
前面三档考的是有没有唯一答案的东西:智能指针怎么用、epoll 是什么。 系统设计题不一样——面试官给你一句很空的需求,比如:
「你来设计一个能扛住一万个连接的服务器,说说你怎么做。」
然后闭嘴,看你怎么展开。没有标准答案,考的是四件事:
| 考察点 | 面试官心里在判断什么 |
|---|---|
| 会不会问清楚需求 | 上来就写代码的人,工作中也会做错东西 |
| 会不会分层 | 能不能把一坨需求拆成几个各管一摊的模块 |
| 想不想得到失败情况 | 只会写「顺利的路」是中级,想得到「出错怎么办」才是高级 |
| 会不会说取舍 | 「我选 A 不选 B,因为……」这句话是高级和中级的分水岭 |
为什么高级岗一定考这个
中级岗招的是「给他一个明确任务能做完」的人,高级岗招的是 「给他一个模糊问题能定方案」的人。所以八股背得再熟也不加分—— 面试官默认你会。他要看的是你有没有自己从头搭过东西。
通用四步答法(每道题都套这个)
第一步:反问,把需求钉死(30 秒)
不要一上来就答。先问 2-3 个问题,问什么都行,重点是让面试官看到你不接受模糊需求。 万能三问:
- 规模多大?(多少连接 / 多大数据 / 多少设备)
- 最看重什么?(延迟低?吞吐大?绝对不能丢数据?)
- 跑在什么环境?(服务器还是嵌入式设备?内存多大?)
⚠️ 规模问题决定方案。100 个连接和 10 万个连接,正确答案完全不同。 不问就答,很容易答成「用最复杂的方案解决最简单的问题」,这是减分的。
第二步:画分层(2 分钟)
在白板上画三到四个横条,从下往上。每一层说一句「它负责什么、它不负责什么」。 这一步最重要——画出来的图比你说的话有说服力。
第三步:走一遍正常流程(2 分钟)
拿一个具体的例子,从头到尾走一遍。比如「一个客户端连进来,数据是怎么流到业务代码的」。
第四步:讲失败路径和取舍(3 分钟,这一步决定评分)
主动说:「如果这里断电 / 这里超时 / 这里内存不够,会怎么样,我怎么处理。」 再说:「这个方案的代价是……,如果需求变成 XX,我会改成 YY。」
🔑 90% 的候选人止步于第三步。你只要主动走到第四步,就已经赢了大半。
三个禁忌
- 不要沉默着想。想不出来就把思路说出来:「我先想一下数据是怎么流的……」 面试官要听你的思考过程,安静 30 秒会被判定为不会。
- 不要编不存在的经验。可以说「这块我没做过,但我会这样设计,理由是……」
- 不要一上来堆技术名词。说「用 Reactor + 无锁队列 + 内存池」而讲不清为什么, 是典型的背过没做过。
4.1 【主场题】设计一套 OTA 升级方案
为什么这题排第一:这是你唯一一道「比大多数面试官还熟」的设计题。 别人只能答理论,你能答踩过的坑。嵌入式 Linux 岗(移远、海康、六环、联讯) 几乎必问升级,你要主动把话题引到这里。
零基础背景:先搞清这几个词
OTA:Over The Air,空中升级。就是设备(手机、路由器、车机、工业网关) 不用拆机、不用插线,自己从服务器下载新固件把自己更新掉。
固件 / 镜像(image):设备上的「一整块系统」打成的文件。 你电脑装系统用的 ISO 就是一种镜像。设备的硬盘(一般是 eMMC / UFS 闪存) 被切成好几块,每块叫一个分区(partition),每个分区放一个镜像:
┌──────────┬──────────┬──────────┬──────────┐
│ boot │ system │ vendor │ userdata │
│ 启动引导 │ 系统本体 │ 厂商驱动 │ 用户数据 │
└──────────┴──────────┴──────────┴──────────┘
全量包 vs 差分包:
- 全量包:把新版本的完整镜像发下去。简单可靠,但可能几百 MB,用户流量心疼。
- 差分包(差分升级):只发「新版和旧版的不同之处」,设备拿旧镜像 + 差分数据 算出新镜像。可能只有几 MB。代价是:必须知道设备当前是哪个版本, 从 V1 生成的差分包打到 V2 设备上会直接变砖。
连续升级:设备停在 V1,最新是 V4。要么直接发 V1→V4 的差分包, 要么让它依次打 V1→V2→V3→V4。后者叫连续升级,好处是包体积小、 服务器不用为每对版本组合都生成一个包,坏处是中间任何一步失败都要有兜底。
变砖(brick):升级升坏了,设备开不了机,跟块砖一样。 这是 OTA 领域唯一真正的红线——宁可升级失败,不可开不了机。
这题的核心:掉电原子性
面试官问 OTA,真正想听的就一句话:
用户在升级到一半时拔电源 / 没电关机了,设备下次开机必须能正常启动。
「原子性」这个词意思是:要么全做完,要么当没发生过,不能停在中间。 写一半的镜像是最危险的状态——系统文件新旧混着,一开机就崩。
两种主流解法:
方案 A:A/B 双分区(现在的主流)
准备两套系统分区,A 和 B。当前跑在 A 上,升级时把新系统整个写进 B, 写完并校验通过后,才去改一个标记位说「下次从 B 启动」,然后重启。
升级前: [A: V1 运行中] [B: 旧的 V0,随便覆盖] 启动标记 → A
写入中: [A: V1 运行中] [B: 正在写 V2 ...] 启动标记 → A ← 此时断电,重启还是跑 A,没事
写完校验:[A: V1 运行中] [B: V2 完整且校验通过] 启动标记 → A
切换: [A: V1] [B: V2] 启动标记 → B ← 只改这一个标记,是原子的
%%{init: {"flowchart": {"htmlLabels": true, "nodeSpacing": 26, "rankSpacing": 30}}}%%
flowchart TD
S(["开始升级<br/>当前跑在 A 分区"]) --> DL["下载升级包到独立分区"]
DL --> V1{"验签 + 整包哈希"}
V1 -->|"❌"| REJ["拒绝,不进入写入阶段<br/>(A 分区毫发无损)"]
V1 -->|"✅"| WR["把新系统写入 【B 分区】<br/>⚠️ 此时断电:启动标记仍指向 A<br/>重启照常跑旧系统"]
WR --> V2{"回读校验 B 分区哈希"}
V2 -->|"❌"| REJ2["判失败,B 分区作废<br/>下次重来"]
V2 -->|"✅"| FS["fsync 确保真的落盘<br/>(不 fsync 就切标记 = 变砖风险)"]
FS --> SW["切换启动标记 → B<br/>⚠️ 只改这一个标记,是原子的"]
SW --> RB["重启,从 B 启动"]
RB --> OK{"新系统启动成功?"}
OK -->|"✅ 上报成功"| DONE(["清掉回滚标记,完成"])
OK -->|"❌ 连续 N 次失败"| BACK["Bootloader 自动把标记切回 A"]
- 优点:断电永远安全;升级失败可以直接回滚(把标记改回 A);升级时系统还能正常用
- 缺点:占双倍存储空间。成本敏感的设备(比如只有 8GB 闪存的模组)用不起
方案 B:单分区 + 恢复模式(recovery)
只有一套系统。升级时先重启进一个很小的「恢复系统」(recovery / updater), 由它来覆盖主系统分区。中途断电就还在恢复系统里,下次开机继续升级或回退。
- 优点:省空间
- 缺点:升级期间设备不可用;恢复系统本身如果坏了就真砖了(所以它一般是只读、 且极少更新的)
🔑 面试时的杀手锏:主动说出「A/B 的代价是双倍存储,所以低成本设备用不起, 这时候就得走 recovery 路线」。能说出方案的代价,比能说出方案本身值钱得多。
完整答题框架
第一步:反问
- 设备存储多大?能不能吃得起 A/B 双分区?
- 需要支持跨多个版本升级吗?(决定要不要做连续升级)
- 有没有安全要求?(要不要签名防止刷入伪造固件)
第二步:分层
① 云端 / 打包工具侧
├─ 差分包生成(拿 V1、V2 两个镜像算 diff)
├─ 计算目标版本哈希(SHA256)并写进包元信息
└─ 打包 + 签名
↓ 下载
② 设备端 · 升级服务(常驻进程)
├─ 版本检查、下载、断点续传
├─ 落盘到独立分区,做完整性校验
└─ 置「待升级」标记,触发重启
↓ 重启
③ 设备端 · 升级执行体(flashd / updater)
├─ 解析包,逐分区写入
├─ 每个分区写完立即回读校验
└─ 全部成功 → 切启动标记;任一失败 → 回滚
↓
④ Bootloader(最底层的启动引导)
└─ 读启动标记,决定从哪个分区启动;启动失败计数超限则自动回退
第三步:走一遍正常流程
「设备检查到新版本 → 下载差分包到独立分区 → 校验包本身的签名和哈希 → 写标记重启 → 执行体读取包,对每个分区做 patch,写完立刻读回来算哈希, 和包里记的目标版本哈希比对 → 全部一致 → 切启动标记 → 重启进新系统 → 新系统启动成功后上报,清掉回滚标记。」
第四步:失败路径(重点讲,讲满 5 条)
| 失败点 | 处理 |
|---|---|
| 下载中断电 | 包在独立分区,重来即可,不影响系统。支持断点续传 |
| 包被篡改 / 传输损坏 | 写入前先验签名 + 整包哈希,不通过直接拒绝,不进入写入阶段 |
| 写分区时断电 | A/B:启动标记还指向旧分区,重启照常跑旧系统 单分区:重启仍进 recovery,重新升级或回退 |
| 差分算错 / 基线版本不对 | 写完回读比对目标哈希,不一致就判失败并回滚 |
| 新系统起不来 | Bootloader 记启动失败次数,连续 N 次失败自动切回旧分区 |
| 连续升级中间一步失败 | 三选一:① 停在中间版本,下次继续 ② 回滚到起点 ③ 降级为全量包兜底 |
⬜ 待填:你们当时选的是哪一种?为什么?——这题面试必追,profile 里也标了缺口。
取舍表述(背下来)
「差分省流量,但引入了版本耦合——包和设备当前版本必须严格对应, 一旦对不上就是变砖风险。所以我们在端侧加了目标版本哈希校验: 写完回读,哈希对不上就判失败回滚。这是拿一点点时间开销换掉了变砖风险, 我认为这个交换是划算的。」
高频追问
Q:为什么校验用 SHA256 不用 CRC32? CRC 是防传输出错的(比特翻转),不防恶意篡改——攻击者能轻易构造出 CRC 相同的假固件。SHA256 是密码学哈希,构造碰撞在计算上不可行。 但注意:哈希本身也不防篡改,因为攻击者可以连哈希一起改掉。 真正防篡改要靠签名:厂商用私钥签,设备用内置公钥验,攻击者没有私钥就伪造不了。
Q:哈希能防止版本回滚攻击吗? 不能。攻击者把一个旧的、合法签名的固件推给设备,签名和哈希全对。 防这个要靠防回滚版本号(anti-rollback counter):设备里存一个只增不减的版本号, 低于它的固件直接拒绝。
Q:为什么补丁分区从 ext4 换成 EROFS?(你的真实经历,务必讲)
- ext4 是可读写的通用文件系统,为了支持随时写入,要预留一堆元数据和空闲块
- EROFS(Enhanced Read-Only File System)是只读文件系统,专为「打好就不改」 的镜像设计。因为不用考虑写,元数据可以压得很紧,还能整块做 LZ4 压缩
- 补丁分区的内容本来就是只读的,用 ext4 是杀鸡用牛刀 → 换 EROFS 压缩效率提升 50%(你的实测数据,一定要报出来)
- 端侧要同时兼容两种格式挂载,因为老版本设备上还是 ext4
Q:镜像大小和分区大小解耦是什么意思? 原来的做法是:分区多大,镜像就打多大,不够的地方用 0 填满。 所以一个 128MB 的空补丁分区,升级包里就要塞 128MB 的无用数据。 解耦后按实际内容打包,空镜像从 32MB / 128MB 降到 20KB。 这条数据非常有冲击力,一定要说。
4.2 设计一个能扛一万连接的 TCP 服务器
网络基础生疏时,先读网络从零开始图解:先用一小课理解客户端与服务端,后续概念、配图和实验放在后半篇查阅。
这一节从“两个程序怎么说话”开始。先理解连接、线程和等待,再看代码,最后才讨论一万连接和服务器设计。暂时不需要背 epoll、Reactor 这些名称。
4.2.1 先把题目换成一个具体场景
假设你写了一个聊天服务。小王、小李各在电脑上打开一个聊天客户端,连接到你运行在另一台电脑上的服务程序。
小王电脑上的客户端 ──连接 A──┐
├── 你的聊天服务程序
小李电脑上的客户端 ──连接 B──┘
客户端是主动发起连接的程序。服务端是等待别人连接、接收消息并提供服务的程序。日常说的“服务器”有时指电脑,有时指服务程序;这道题主要设计的是服务程序。
TCP 是操作系统提供的一种网络通信协议。对这一节,先把它理解成:两个程序建立连接后,可以沿着连接向对方发送字节,接收端按 TCP 提供的顺序读取。连接可能失败或中断,应用仍需处理错误。
一条连接不是一根专门拉过去的网线,也不是一个线程。它是通信双方维持的一段通信关系。小王发完一句话,不必马上断开;过一分钟还可以用同一条连接继续发。
因此,“一万连接”可能表示一万人都在线,但这一秒只有二十个人说话。也可能是一万人同时不断上传数据。这两种工作量相差很大,不能只凭连接数判断机器能不能处理。
先检查:在线但不发消息的人,也可以占有一条连接;但他不一定正在让 CPU 处理消息。
4.2.2 服务端怎样区分小王和小李:socket 与 fd
Linux 为网络通信提供 socket(套接字)。它是程序进行网络收发时使用的通信端点,由操作系统管理。
程序调用相关接口后,会拿到一个整数编号,称为 fd(文件描述符)。后续把这个编号交给 read、write 等函数,就能指定要操作哪个资源。文件、管道也使用 fd,不只有网络连接。
在这个例子中,可以先记为:
fd 4 → 服务端与小王通信的 socket
fd 5 → 服务端与小李通信的 socket
4 和 5 只是举例,实际数值由系统分配。它们不是用户 ID,也不保证小王下次连接还是 4。连接关闭后,编号可以被其他资源复用。
服务端还需要一个专门用来接收新连接的 socket,称为监听 socket。假设它的 fd 是 3:
listen_fd = 3 → 用来接收新连接
client_fd = 4 → 用来和小王收发数据
client_fd = 5 → 用来和小李收发数据
监听 socket 不承担这里的小王聊天数据收发。接待新连接的入口,与已经接好的通信端点,是不同的东西。
4.2.3 accept 做什么,read 又做什么
服务端先建立监听入口。相关函数的用途按顺序是:
| 调用 | 这一刻在做什么 |
|---|---|
socket | 创建一个通信端点 |
bind | 把它绑定到本机的指定地址和端口;端口帮助系统区分通信入口 |
listen | 把它变成接收新连接的监听入口 |
accept | 从等待接收的连接中取出一条,返回用于通信的新 fd |
read / recv | 从这条已连接的 socket 读取字节 |
write / send | 通过这条已连接的 socket 发送字节 |
close | 释放本进程持有的这个描述符 |
先看这一行。它只是解释 API 的代码片段,前提是 listen_fd 已经完成上述监听准备,不能单独编译运行:
int client_fd = accept(listen_fd, nullptr, nullptr);
逐项拆开:
int client_fd:创建一个整数变量,保存返回的编号。accept(...):向操作系统取一条等待接收的连接。listen_fd:告诉系统,从哪个监听入口接收。- 两个
nullptr:本次不要求函数另外填写对端地址信息。 - 成功时返回非负 fd;失败返回
-1,必须检查原因,不能继续当正常连接用。
小王先连接时,这次可能返回 4;小李随后连接时,下一次可能返回 5。原来的监听 fd 3 仍保留,可以继续接收其他人。接口语义见 accept(2)。
accept 接收连接,read 读取消息字节。 小王已连上但没说话,不代表需要对小王再次 accept;只需等待已有连接上的数据。
4.2.4 什么是线程,为什么 read 会让后面的代码等着
一个运行中的服务程序通常是一个进程。先把线程理解成这个进程中一条执行代码的流程:它按程序逻辑调用函数、计算、等待。一个进程可以有多条这样的执行流程,并共享一些数据。
一个线程不等于独占一个 CPU 核。操作系统安排哪些可运行线程获得 CPU;当线程等待网络数据时,可以让它睡眠,把 CPU 让给其他可运行线程。
假设一个线程执行下面的过程:
读取小王的数据
处理小王的数据
读取小李的数据
处理小李的数据
如果第一步使用阻塞读取,而小王一直不发消息,这次调用就等待,不往下返回。于是这个线程走不到“读取小李”那一步,即使小李已经发送了消息。
这里的“阻塞”表示调用暂时不返回,当前执行流程继续不了。它通常不是 CPU 在不停地读一个空位置,也不是整个程序的所有线程都被停止。
这就产生第一个问题:怎样避免等一个人的消息时,把其他人也耽误了?
4.2.5 最容易想到的办法:每个人分一个线程
可以让主线程只接收新连接,每接到一条连接,就启动一个工作线程负责它:
主线程:接收小王 → 安排线程 A → 继续接收小李 → 安排线程 B
线程 A:等小王的数据 → 处理 → 再等小王的数据
线程 B:等小李的数据 → 处理 → 再等小李的数据
这样小王不说话,只会让线程 A 等待;线程 B 仍能处理小李的数据。
原来的代码把多个动作挤在一行。展开后看它的含义,仍只是展示流程的片段,省略了线程创建失败和完整收尾:
int client_fd = accept(listen_fd, nullptr, nullptr);
if (client_fd >= 0) {
std::thread worker(handle_client, client_fd);
worker.detach();
}
std::thread 是 C++ 标准库创建线程的类型。worker 是当前代码中管理新线程关联关系的对象。
handle_client 不是系统自带函数,而是你自己要写的函数名。它接收一个连接 fd,负责那条连接的收发与清理。创建线程这一行的意思是:让新线程运行 handle_client(client_fd),当前线程则继续执行后面的代码。
detach() 表示解除 worker 与执行线程的关联,让执行线程继续独立运行。它不会等线程完成,不会替你关闭连接,也不会自动解决程序退出时如何收尾。真正的练习项目宜先保存工作线程并安排等待结束;这里保留 detach 只是解释截图里的原写法。
外层再包一层 while (true),就是反复“接一条连接、安排一个线程”。while (true) 是永远继续的循环,实际服务还要增加停止条件。
先检查:新线程执行的是你写的 handle_client;不是 accept 自动帮你处理聊天业务。
4.2.6 为什么连接多了,要考虑另一种办法
每条连接一个线程不是任何情况下都不能用。连接规模较小、阻塞操作和退出策略明确时,它容易理解。问题是让线程数量一直跟着连接数量增长,资源和管理成本也会增长。
假设一万条连接大部分时间没有消息。如果一连接一线程,就维持着大量主要用于等待的线程。
每个线程需要保存自己的执行状态,并使用自己的栈。栈在这里可以先理解为保存函数调用过程和局部数据的一部分存储。默认栈大小受环境与配置影响,不能统一说每线程必定占 8 MiB 物理内存;虚拟地址空间预留与实际驻留内存也不是一回事。具体创建规则参见 pthread_create(3)。
当很多线程同时变得可运行,调度、唤醒和切换会增加开销。上下文切换是保存当前执行状态,再让另一个线程继续运行。睡眠中的线程并不是不停轮流占用 CPU。
所以不能背“100 个必定没事、一万个必定崩”“线程多了 CPU 大部分时间一定在切换”。应该提出更准确的问题:
能不能保留一万条连接,但不用专门创建一万个等待线程?
4.2.7 epoll 解决的是:一起等,返回有事可处理的连接
可以把一批 fd 交给操作系统,让系统在它们出现所关注的情况时,返回一批通知。线程拿到通知后,再处理相关连接。
想象服务端已经保存了三条连接:
小王:fd 4,暂时没发消息
小李:fd 5,发来了 hello
小张:fd 6,暂时没发消息
线程请求:等这些连接,出现相关事件再告诉我
系统返回:fd 5 现在可以尝试读取
线程处理:读取小李的数据,再回去等待
这个线程没有先在小王的阻塞 read 上卡住。它等的是这一批连接中有可处理的事件。
这类方式叫 I/O 多路复用。I/O 是输入输出,这里主要指网络收发;“多路”是多条连接,“复用”是让同一个执行线程处理多路 I/O 的就绪情况。epoll 是 Linux 提供的一套实现接口。
不是一个线程在同一瞬间执行一万段代码;它通常是拿到一批事件后依次处理。操作系统和网络设备仍在完成各自的工作。
只需要先记住三个调用的职责:
| 调用 | 对上面例子的作用 |
|---|---|
epoll_create1 | 创建一个 epoll 实例,用来管理关注的 fd 和事件 |
epoll_ctl | 加入、修改或删除关注项,例如关注 fd 5 的可读事件 |
epoll_wait | 等待并取出一批已就绪事件;也可能超时或失败 |
它们不替程序读取消息,也不处理聊天业务。接口和就绪通知规则见 epoll(7)。
4.2.8 为什么用了 epoll,还要把 socket 设成非阻塞
假设系统通知小李的连接可读,线程读走了当前已有的数据。程序再次读取时,小李还没有发下一批:
- 阻塞模式:这次调用可能继续等待,小王和小张的事件处理又被耽误。
- 非阻塞模式:暂时没数据就返回“现在不能继续”,线程可以先处理其他连接。
常见的“暂时不能继续”由 EAGAIN 或 EWOULDBLOCK 错误码表示。它不同于连接已经永久失败。
epoll 帮你决定什么时候尝试 I/O;非阻塞模式帮助你在当前无法推进时及时返回。 二者配合,才能让一个线程比较从容地照顾多条连接。
读取还有另外两种情况:对本节的 TCP 连接,在请求读取正数字节时返回 0 表示对端发送方向已结束;其他错误则按原因处理。不能把所有非正返回都当成“等一等再读”。
4.2.9 有事件,不等于有一条完整消息
小李想发 hello,本次读取可能只拿到 he,下一次才拿到 llo。也可能他连续发两条消息,程序一次读到它们合在一起的字节。
所以每条连接需要自己的接收缓冲区:一块由程序管理的存储,先放下收到但尚未处理完的字节。
再给消息约定格式。例如“前 4 字节写消息体长度,后面跟正文”。程序先确认头够不够,再确认正文够不够。够了才交给业务处理,不够就保留已有字节,等待后续输入。
收到 he → 保存,当前消息不完整
收到 llo → 与此前数据合并,组成完整消息
处理 hello → 生成响应
这里省略了示例长度头,只展示分段到达的现象。真正实现时必须限制消息长度,不能因为对端声明一个巨大长度就无限申请内存。
发送也可能只完成一部分。发送缓冲区保存尚未发完的字节,之后继续发送。可写通知表示可以尝试推进发送,不等于对方应用已经读到响应。
4.2.10 到这里,再给整个循环起名字:Reactor
现在已有一套完整过程:
等待事件
↓
取出这次通知的连接
↓
有新连接就接收,有数据就读取,有待发送内容就尝试发送
↓
更新各连接的缓冲、状态与错误信息
↓
回到等待
反复执行这个过程,叫事件循环。把“等待事件”和“根据事件调用对应处理逻辑”组织起来,是 Reactor 模式的核心思想。
先理解过程,再记名字即可。epoll 是系统接口;Reactor 是应用组织处理逻辑的一种方式,两者不是同一个东西。
4.2.11 一个线程处理不过来时,才考虑增加线程
首先要分清它为什么处理不过来。
情况 A:收发和解析本身已让一个线程很忙。 可以使用多个 I/O 线程,每个线程运行自己的事件循环,分管一部分连接。例如三条线程分别管理不同的连接集合。
这就是常说的 one loop per thread:每个线程一个事件循环。不是每个连接一个线程,也不是要求线程数必须等于某个固定数字。
%%{init: {"flowchart": {"htmlLabels": true, "nodeSpacing": 30, "rankSpacing": 36}}}%%
flowchart TD
ML["main loop(主线程)<br/>只干一件事:accept 新连接"]
ML -->|"轮流分配<br/>round-robin"| L1
ML --> L2
ML --> L3
L1["sub loop 1<br/>epoll_wait<br/>管一部分连接"]
L2["sub loop 2<br/>epoll_wait<br/>管一部分连接"]
L3["sub loop 3<br/>epoll_wait<br/>管一部分连接"]
L1 --> N1["这些连接的收发缓冲与状态<br/>只由本线程碰 ✅ 不用加锁"]
L2 --> N2["同上"]
L3 --> N3["同上"]
🔑 这张图的价值全在最下面一行: 一条连接从头到尾只被一个线程碰,所以连接内部的缓冲区不需要锁。 这是这个模型最值钱的一点,面试一定要说出来。
情况 B:某个请求要做很久的计算或等待慢操作。 不要让 I/O 线程在这里耗很久,否则它管理的其他连接也要等。把这项工作交给单独的任务线程,完成后通知原 I/O 线程发送结果。
约定每条连接的收发缓冲与状态只由所属 I/O 线程修改,可以减少连接内部的锁。跨线程任务队列仍需要同步;工作结果回来时,也要检查原连接是否仍存在。
eventfd 可以作为 Linux 下的唤醒工具:别的线程先把结果放进受同步保护的队列,再通知 I/O 线程醒来取结果。它只负责通知,不能替队列自动加锁。
4.2.12 怎样判断真的能承受一万连接
不能因为代码里用了 epoll 就认为完成了。先定义测试条件,再观察结果:
| 要明确什么 | 为什么影响结果 |
|---|---|
| 多少连接同时发数据、每秒发多少 | 在线数量不等于每秒需要处理的工作量 |
| 每条消息多大、处理要多久 | 决定网络、内存和计算需求 |
| fd 上限 | 每条已接收的连接通常需要一个描述符,其他资源也占用描述符 |
| 每连接缓冲与全局缓冲上限 | 慢接收方可能使待发送数据不断积压 |
| 空闲与请求超时 | 不能永远保存已不再有用的连接或任务 |
| CPU、内存、延迟、错误率 | 连接建立成功不等于业务处理符合要求 |
背压就是积压过多时限制继续进入的工作,比如暂停接收更多请求、拒绝任务或按策略关闭慢连接。它的目的,是让有限资源不被无限增长的队列耗尽。
正常退出也需要顺序:停止接收新工作 → 按约定排空或取消在途工作 → 收尾连接 → 让线程结束并等待它们。排空期间事件循环可能仍需继续工作来发送剩余响应,不能先彻底停掉它再等响应发完。还要设置退出期限,避免永远等一个无响应的对端。
4.2.13 理解前面之后,再看面试回答
「我会先确认一万连接中有多少同时活跃、消息大小、处理耗时和延迟要求。 对大量等待数据的连接,可以先用非阻塞 socket 与 epoll,让少量 I/O 线程管理多条连接。 每条连接单独保存收发缓冲和状态,收到完整消息再交给业务。 慢任务交给受限的任务线程池,结果回到连接所属 I/O 线程处理。 同时限制消息长度、连接数量和队列大小,处理超时、断连与退出。 最后用指定负载测连接数、吞吐、延迟、内存和错误率,确认是否达到目标。」
这段是表达参考,不是让你跳过前面的内容背下来。没有实际做过的部分说“如果让我实现,我会……”,不能冒充已有压测结果。
4.2.14 两个后续追问:现在理解大意即可
LT 与 ET:事件通知的方式不同。 LT 下条件仍满足时,后续等待仍可报告它;ET 下不能依赖未处理完的旧就绪状态必然再次通知。常见 ET 写法使用非阻塞 I/O,持续处理到 EAGAIN;如果为公平性提前停下,需要主动安排后续处理。先写正确的 LT 版本,再通过实验理解 ET,不用背“ET 一定更快”。
惊群:一次事件唤醒了多个等待者,但其中一些没有工作可做。 是否发生取决于具体等待方式和内核机制,不能说任何多个线程 accept 都会全部醒来。单独安排接收线程是一种设计;后续再研究 SO_REUSEPORT 等分配连接的方式。
4.2.15 不看答案,检查你是否真的理解了
- 小王连着但没有发消息,连接还在吗?专门负责它的线程可能在干什么?
listen_fd和accept返回的 fd 有什么不同?handle_client是系统函数还是你写的?它在哪个线程执行?- 一个线程调用阻塞 read 等小王,为什么可能耽误小李?
- epoll 返回通知后,是系统自动完成业务,还是你的程序继续读取和处理?
- 一万条连接为什么不必对应一万个线程?
核对:连接可以保留,线程可能睡眠等待;监听 fd 用于接新连接,返回的新 fd 用于收发;处理函数由你编写,在示例中新启动的线程执行;同一线程卡住就无法继续后面的代码;epoll 只报告事件,后续工作仍由程序执行;少量线程可以在多条连接之间按就绪情况安排处理。
最小动手顺序:先写单连接的收发 → 增加两个客户端 → 观察其中一个不发数据对另一个的影响 → 再改成非阻塞 + epoll LT 版本。每一步只解决一个新问题。
4.3 设计一个定时器(超时 / 心跳 / 重连都靠它)
为什么要准备:一旦你讲了心跳、超时、重连,面试官很可能追一句 「这些超时你是怎么管理的?」——这就是在问定时器。 你 CrossShellNext 里有心跳线程,属于直接命中。
零基础背景:为什么需要「定时器」这个东西
设备侧和网络程序里,到处都是「N 秒后做某事」:
- 发了个请求,5 秒没回就算超时
- 每 30 秒发一次心跳,确认对方还活着
- 断线后 1 秒、2 秒、4 秒、8 秒地重试(叫指数退避,防止把服务器打死)
最笨的做法是每件事开一个线程 sleep。一万个连接就一万个线程,
和上一题的问题一模一样。所以要有一个统一的定时器组件:
所有「N 秒后干什么」都登记到它这里,它一个线程就能管完。
两种实现,各有各的场合
① 小顶堆(最小堆 / priority_queue)
把所有定时任务按「到期时间」排序,堆顶永远是最早到期的那个。
主循环:
看堆顶任务还有多久到期 → 假设 200ms
epoll_wait(..., timeout = 200ms) ← 用 epoll 的超时参数来"睡"
醒来后:把所有已经到期的任务从堆顶弹出来执行
- 加一个任务:O(log n);取最早的:O(1)
- 优点:实现简单,能和 event loop 天然结合(用
epoll_wait的 timeout 参数) - 缺点:任务量极大(几十万)时,堆操作的 log n 开始有感觉
- Linux 上更省事的做法:用
timerfd_create造一个「到时间就变可读」的 fd, 直接挂进 epoll,定时就变成了一个普通的 I/O 事件
② 时间轮(timing wheel)
想象一个钟表盘,比如分成 60 格,每格代表 1 秒,指针每秒走一格。 要「5 秒后执行」,就把任务挂到指针前方第 5 格的链表上。 指针走到哪格,就把那格链表上的任务全执行掉。
格子: 0 1 2 3 4 5 ... 59
[ ] [ ] [x] [ ] [ ] [x] [ ]
↑ ↑
指针 5秒后到期的任务挂这
- 加一个任务:O(1)(算出格子编号,挂链表尾);到期取出:O(1)
- 优点:常数时间,任务量再大也不慌
- 缺点:精度受格子大小限制(1 秒一格就只能做到秒级); 超过一圈的时间要么记圈数,要么做多层时间轮(像时针分针秒针那样级联)
答题框架
反问:需要多高的精度?(毫秒还是秒)同时存在多少个定时任务? 定时器回调里会不会做耗时操作?
选型表述(这句是得分点):
「几千个连接的超时管理,我用小顶堆 + epoll_wait 的 timeout就够了, 实现简单、和事件循环天然融合。只有在任务数上到十万量级、 而且压测确认堆操作成了热点时,我才会换成时间轮—— 时间轮是拿精度和实现复杂度换 O(1),没到那个量级不值得。」
必须主动讲的坑:
- 取消问题:任务还没到期,但对应的连接已经关了。
如果定时器还持着这个连接的裸指针,执行时就是悬垂指针崩溃(use-after-free)。
解法:定时器只存一个
weak_ptr或者一个 id,执行前先检查对象还在不在。 - 回调不能耗时:定时器线程在跑回调时,后面所有任务都被推迟。 耗时的活要丢给线程池。
- 不要用系统墙上时钟:用户改系统时间、NTP 对时,都会让
system_clock跳变,导致定时器要么提前触发要么永远不触发。 必须用std::chrono::steady_clock(单调时钟,只会往前走)。
🔑 第 3 条是很多人不知道的,说出来加分明显。
4.4 【主场题】设计一个多会话管理器 / 连接池
为什么排第二重要:CrossShellNext 就是这个东西。 面试官问「你怎么管理多个会话的生命周期」,问的就是这题。 这题答不好,你简历上的核心项目就塌了。
零基础背景:这题到底在问什么
用户开了 8 个标签页,每个连着一台不同的服务器。程序内部要有个东西 统一管着这 8 条连接:谁在连、谁断了、关窗口时怎么收尾、后台线程还在读怎么办。
最难的不是建连接,是销毁。 因为销毁的时候,很可能有别的线程正在用它。
核心矛盾:谁负责销毁
UI 线程:用户点了"关闭标签页" → 想立刻 delete 这个 Session
工作线程:正卡在 libssh2_channel_read() 上,还持有这个 Session 的指针
↑
这时候 delete,工作线程手里的指针立刻悬垂 → use-after-free
三种解法,面试就是要你说出为什么选其中一种:
① 引用计数(shared_ptr)——最常用
谁在用谁就持有一份 shared_ptr,最后一个放手的负责析构。
- 优点:在访问期间正确持有共享所有权,可以防止被管理对象提前析构;裸指针、成员引用和内部数据仍需检查寿命与同步
- 陷阱 1:析构可能发生在工作线程里,如果析构函数里要回调 UI,就跨线程了
- 陷阱 2:循环引用(Session 持有 callback,callback 又捕获了 Session 的
shared_ptr)→ 可能形成不释放的循环。需要打破拥有环;观察型回调可捕获weak_ptr,调用时先lock()并检查结果
② 两阶段关闭(推荐,也是工程上最稳的)
把「停止使用」和「释放内存」拆成两步:
第 1 步 requestClose():按同步协议进入 Closing,停止接受新工作
通知 I/O 线程退出,让等待中的操作能够返回
第 2 步 join + 析构:确认工作线程退出后,由明确的拥有者释放资源
%%{init: {"themeVariables": {"fontSize": "14px"}}}%%
sequenceDiagram
participant UI as UI 线程
participant S as Session
participant W as 工作线程
UI->>S: close():状态置为 Closing(atomic)
UI->>S: 关闭底层 socket / 唤醒阻塞的 read
Note over W: 阻塞的 read 立刻返回错误
W->>W: 发现状态是 Closing,退出循环
UI->>W: join()(在锁外)
Note over W: 线程确实结束了
UI->>S: 这时才释放资源
Note over UI,S: 顺序反了就是 use-after-free
- 优点:销毁时机明确,不依赖引用计数的隐式时机
- 关键点:必须有办法把阻塞的读操作叫醒。否则
join会卡死。 对连接 socket 可结合shutdown(fd, SHUT_RDWR)设计;事件循环可以监听自管道/eventfd,阻塞接口也可以采用有界超时。 通知 fd 必须被等待机制实际监听,单独写 eventfd 不能唤醒任意阻塞的库函数。 Linux 上不能假设另一线程close(fd)会立刻中断已阻塞的 I/O,还存在 fd 复用风险,见 close(2)。
③ 延迟回收:标记为死亡,扔进回收队列,由管理器在安全的时机统一清理。 简单,但资源释放不及时。
⬜ 待填:CrossShellNext 实际用的是哪种?profile 待填清单里 「会话销毁 vs 工作线程正在读」这条就是它。这是必问题,必须回去看代码。
答题框架
反问:会话数量级?会话建立成本高吗(需不需要复用)? 断线要不要自动重连?
分层
SessionManager(单例)
├─ id → shared_ptr<Session> 的表 + 一把 mutex 保护
├─ create / get / closeAll
└─ 只管"有哪些会话",不管"会话内部怎么跑"
│
↓
Session(抽象基类)
├─ connect / disconnect / send / setCallback ← 纯虚
├─ atomic<State> 状态机
└─ SshSession / TelnetSession / SerialSession / SftpSession 各自实现
│
↓
线程池:执行阻塞 I/O,读到数据后经回调投递出去
状态机(一定要能在白板上画出来)
Idle ──connect()──> Connecting ──成功──> Connected ──close()──> Closing ──> Closed
│ │ ↑
└──失败──> Error ────┴────────────────────────┘
atomic<State> 可以保护单次状态访问,但“检查状态后建立连接”这样的复合操作仍需要同步协议,可能使用 CAS、锁或单线程归属。
状态若与 socket、任务队列和回调一起变化,还要保护这些数据之间的一致性,不能只因状态是一个枚举就认定不需要锁。
失败路径
| 场景 | 处理 |
|---|---|
| 连接建立到一半,用户点了取消 | 状态置 Closing,让 connect 线程在下一个检查点自己退出 |
| 网络断了但 TCP 没感知 | 心跳超时判定 → 转 Error → 触发重连策略 |
| 重连风暴 | 指数退避(1s→2s→4s→8s,封顶 30s)+ 加随机抖动,防止所有会话同时重连 |
| 程序退出时还有活跃会话 | closeAll() 逐个两阶段关闭,等所有工作线程 join 完再退出 |
| 线程池被阻塞 I/O 占满 | 这是真实风险:8 个线程全卡在 read 上,第 9 个会话就发不出去了。 解法:读操作用带超时的非阻塞读,或者读专用线程与任务线程分开 |
🔑 最后一条是面试官最爱追的:「你把阻塞 I/O 丢进线程池, 池子被占满了怎么办?」这题在你的 v4 答案文档第 07 题也有,对着看。
4.5 设计一个线程安全的日志库
为什么要准备:这题几乎是「C++ 设计题里的默认题」, 因为它同时考并发、性能、文件 I/O 和接口设计,成本又低。
零基础背景:日志为什么会成为性能瓶颈
朴素写法:每条日志直接 fprintf 写文件,外面套一把锁。
void log(const std::string& msg) {
std::lock_guard<std::mutex> lk(mtx);
fprintf(fp, "%s\n", msg.c_str()); // 这里可能触发真正的磁盘写,几毫秒
}
问题:磁盘写是慢操作。业务线程调一次 log,就可能被卡几毫秒。 更糟的是所有线程抢同一把锁,日志一多,整个程序都在排队写日志。
核心思路:前端和后端分开
业务线程(前端) 日志线程(后端)
只做一件事: 只做一件事:
把日志塞进内存缓冲 ──────> 把缓冲刷到磁盘
(微秒级,立刻返回) (慢,但不阻塞任何人)
**双缓冲(double buffering)**是最经典的做法:
准备两块大缓冲 A 和 B。
业务线程往 A 里写。
时间到了 / A 写满了 → 在锁里把 A 和 B 一交换(只是换两个指针,极快)
日志线程慢慢地把 B 写到磁盘,业务线程同时继续往新的 A 里写。
如果多个业务线程共同追加同一块缓冲,追加操作本身也需要同步。常见做法是在锁内追加或交换缓冲,在锁外执行文件写入。是否几乎不等待,要靠负载下的锁竞争和延迟测量判断。
答题框架
反问:日志量多大(每秒几条还是几万条)?崩溃时能不能容忍丢最后几条?
(这个问题决定要不要每条都 fsync,代价差几个数量级)
分层
① 接口层 LOG_INFO("x=%d", x) 入口可配置编译期裁剪和运行时级别过滤
② 格式化层 时间戳、级别、线程 id、文件名:行号
③ 缓冲层 双缓冲 / 每线程一个本地缓冲(更快,但顺序会乱)
④ 落盘层 后台线程写文件、滚动切分、压缩、删旧
必须主动讲的点
- 日志级别过滤:编译期裁剪与运行时过滤分开设计。运行时条件判断可以跳过参数求值,但不自动等于编译期删除代码;是否保留 release 的调试日志由需求决定。
- 崩溃时怎么办:不能把
SIGSEGV处理器中刷新普通异步日志缓冲作为通用方案;崩溃现场可能持有日志锁,缓冲也可能处于不一致状态。 正常退出时在普通执行上下文排空日志;崩溃诊断优先保留 core dump。确需信号处理器输出时,只使用预先设计的最小应急通道。write(2)是信号安全函数,不表示围绕它读取共享队列、加锁和格式化也安全,见 signal-safety(7)。 - 日志滚动:按大小或按天切分,超过 N 个文件就删最老的, 否则设备上的 flash 会被写满——嵌入式场景这条特别重要,一定要说。
- 取舍:
「普通诊断日志可以采用异步缓冲、定期写出、正常退出排空,并说明崩溃时可能丢失尚未保存的数据。 审计或升级流水另行定义持久化确认点,检查写入和同步错误,必要时批量同步。 丢失范围和性能代价都要实测,不能只凭调用
fsync就承诺任何故障下绝对不丢。」
4.6 设备上的多个进程要通信,你怎么选(IPC 选型)
为什么要准备:嵌入式 Linux 岗高频。设备上一定是多进程的 (升级服务、通信服务、UI……),选型题跑不掉。
零基础背景:IPC 是什么
IPC = Inter-Process Communication,进程间通信。
为什么进程之间不能像线程那样直接共享变量? 因为操作系统给每个进程
分了独立的虚拟内存空间——进程 A 里地址 0x1000 和进程 B 里的 0x1000
是两块完全不同的物理内存。这是为了隔离:一个进程崩了不会带崩别人。
代价就是它们要说话必须走操作系统提供的通道。
五种方式对比
| 方式 | 一句话 | 速度 | 适合 |
|---|---|---|---|
| 管道 / FIFO | 一根单向水管,write 一头 read 另一头 | 中 | 父子进程、简单的命令传递 |
| Unix domain socket | 和网络 socket 用法一模一样,但不走网络协议栈 | 中 | 本机通用首选,能传结构化消息,还能传 fd |
| 共享内存(shm) | 两个进程映射同一块物理内存,直接读写 | 最快 | 大数据量、高频(图像、音频缓冲) |
| 消息队列 | 系统维护的一个消息列表,有边界 | 中 | 需要消息边界又不想自己切包 |
| 信号(signal) | 只能通知「发生了某事」,带不了数据 | — | 通知退出、重载配置 |
答题框架
反问:数据量多大?频率多高?是一对一还是一对多? 要不要跨设备(要就直接上 TCP,别用本机 IPC)?
决策树(说出来很显专业)
数据量小、频率低(配置、命令)
└─> Unix domain socket。先按需求选择 SOCK_STREAM 或 SOCK_SEQPACKET 等类型;
STREAM 不保留消息边界,SEQPACKET 保留。Linux 可按接口条件用 SO_PEERCRED 检查对端身份。
改为跨设备通信时还要处理地址族、身份认证、消息协议与故障语义,不只是替换地址
数据量大、频率高(视频帧、传感器流)
└─> 共享内存 + 一个轻量通知通道(eventfd / socket)
共享内存负责搬数据,通知通道负责说"我放好了"
只是通知一下,没数据
└─> 信号,或者 eventfd
Unix socket 类型与边界语义参见 unix(7)。
共享内存必须主动讲的三个坑(这是这题的得分点):
- 共享内存本身不带同步。两个进程同时写就是数据竞争。
必须自己配同步:进程间信号量(
sem_open)或者 放在共享内存里的pthread_mutex(要设PTHREAD_PROCESS_SHARED属性)。 - 进程崩溃会留下烂摊子。持锁的进程崩了,锁永远不释放,
别的进程全卡死。要用健壮互斥锁(
PTHREAD_MUTEX_ROBUST), 持锁者死亡时后来者可能收到EOWNERDEAD;获得锁之后还必须检查、修复共享数据并声明一致,不能将拿到锁等同于数据已经恢复。 - 指针不能直接放进共享内存。同一块共享内存在两个进程里的 映射地址可能不同,A 存的指针在 B 那边是无效的。 只能存偏移量(offset),用的时候加上本进程的基地址。
🔑 第 3 条几乎能筛掉一半候选人,是很好的加分点。
「零拷贝」怎么理解:普通 socket 传数据,要从 用户空间 → 内核缓冲 → 用户空间,拷两次。共享内存两边直接读同一块物理内存, 零次拷贝。这就是它快的根本原因,不是因为”绕过了协议栈”这么笼统。
4.7 白板速记:三张必须能默画的图
面试前把这三张练到闭眼能画。设计题一开口先画图,气场完全不同。
图 1 · 分层图(万能模板)
┌──────────────────────────────┐
│ 接口层 / UI 层 │ 对外暴露什么
├──────────────────────────────┤
│ 业务层 │ 核心逻辑,不依赖平台
├──────────────────────────────┤
│ 平台适配层 │ 把平台差异挡在这一层
└──────────────────────────────┘
配套台词:「每一层只依赖下一层,依赖方向单向,所以上层可以脱离下层单独测试。」
图 2 · 状态机图(用 CrossShellNext 的连接状态,见 4.4)
图 3 · 数据流图:画箭头,标清楚在哪个线程。
[UI 线程] --请求--> [任务队列] --取任务--> [工作线程] --阻塞I/O--> [网络]
│
[UI 线程] <--回调投递--┘
配套台词:「跨线程的地方只有这两处,其余都在单线程内,所以不需要额外加锁。」
4.8 设计题自查表(答完对照,缺哪条补哪条)
- 我有没有先反问,把规模和约束问清楚?
- 我有没有画图?(哪怕手画歪的也比不画强)
- 我有没有说清楚每一层不负责什么?
- 我有没有走一遍完整的数据流,而不是只讲组件?
- 我有没有主动讲至少 3 条失败路径?
- 我有没有说出这个方案的代价,以及什么情况下我会换方案?
- 涉及多线程时,我有没有说清楚哪些数据被哪个线程碰、为什么不用加锁?
- 涉及资源时,我有没有说清楚谁创建、谁销毁、销毁时别人还在用怎么办?
后两条是 C++ 岗独有的评分点。别的语言有 GC,C++ 没有—— 生命周期讲得清楚,是 C++ 高级工程师最直接的标志。
4.9 这一档怎么练
不要读,要说。方法:
- 每道题给自己 8 分钟,对着白纸(或者手机录音)从头讲一遍
- 讲完回来对照 4.8 的自查表打勾,缺哪条下次补哪条
- 重点练 4.1(OTA) 和 4.4(会话管理)——这两道是你的主场, 要练到能讲 10 分钟不重复
- 4.2(Reactor)练到能画图 + 说清 one loop per thread 为什么不用锁即可, 不用背细节
- 4.3 / 4.5 / 4.6 各准备一个 3 分钟版本,够用
分公司准备侧重
| 目标公司 | 重点准备 | 必答题预判 |
|---|---|---|
| 移远、海康、六环、联讯 (嵌入式 Linux,A 级主投) | 动态库加载与排查、交叉编译、开源组件移植案例、串口/设备通信、问题定位流程 | 升级流程全链路、分区与文件系统、崩溃/死锁怎么查 |
| 星融元、卡比特、御渡 (系统软件 / SDK) | 接口抽象与分层、多平台适配、CrossShellNext 架构 | 为什么这么分层、新增协议要改什么、单例的代价 |
| 迈瑞、中望 (软件设计) | 设计模式、UML、单元测试、可维护性 | 工厂/单例/观察者的实际使用、gtest mock 硬件、迈瑞可能问图形学基础 |
| 腾讯后台 | 网络编程、epoll、线程模型;算法强度更高;补数据库 | epoll ET/LT、TIME_WAIT、粘包、手写线程池 + 中等偏难算法题 |
| 东风状态机 | 把 CrossShellNext 状态机讲透;超时、降级、故障设计 | 状态图、atomic vs mutex、超时怎么设计、故障降级策略 |
准备顺序:基础恢复与面试冲刺分开
知识点基本忘光时,不要直接进冲刺安排。 按下表的顺序推进:每一行都要先把 「先完成的实践」写出来跑通,再回来读对应章节,否则读到的全是背下来的话术。
| 补哪一块 | 本文配套阅读 | 先完成的实践 |
|---|---|---|
| 语言基础 | 先不连续通读,卡住时查 2.1.0 | 编译、输入处理、函数、指针、类、文本统计 |
| 现代 C++ | 2.1.0—2.1.6,2.5.1 | 资源封装、STL、LRU、多文件工程 |
| Linux | 2.2、第一档构建与移植问题 | 文件复制、子进程、动态库故障 |
| 并发 | 2.3,再读 2.1.7 | 阻塞队列、逐级线程池、退出和竞态验证 |
| 网络 | 2.4、4.2—4.4 | 协议解码、多连接、超时、背压 |
| 工程 | 2.5、第一档 2.6 | 故障报告、自动测试、性能测量 |
| 项目与设计 | 第一、三、四档 | 真实代码核实、综合项目、模拟答辩 |
已经通过基础验收,才进入面试冲刺
- 核实三个项目的真实链路、职责与实现细节,准备一页事实稿;无法核实的标明未知。
- 针对目标岗位练基础解释和编码变体,不逐字默写现成实现。
- 系统设计从规模、资源和失败约束讲起,拿自己的练习或项目证据说明取舍。
- 模拟面试后记录具体缺口,回到对应练习修复,再做一次不同输入或要求的验证。
算法从数组、字符串和基础复杂度开始,逐级增加难度。不把“每天两三道中等题”和“15 分钟无 bug 线程池”作为恢复基础阶段的入场要求。
第三档只用于整理真实经历的表达,不能覆盖事实;公司、职责和日期与最新个人画像核对。技术章节也要区分语言保证、平台实现和经验判断,本文不是标准文档的替代品。
⬜ 待填清单(面试前必须清零)
CrossShellNext
2026-09-06 已做过一次源码核查,下面打 ✅ 的是核查确认过的,不用再查。 依据:
interview/crossshellnext-code-analysis-2026-09-06.md。
🚨 必须先改的两处(说法与代码不符)
-
resume/resume_cpp_v4.md:「使用 atomic 管理会话状态」→ 改成 mutex(见 1.4) -
website/src/content/projects/crossshellnext.mdx:同一处atomic<SessionStatus>的说法要改
✅ 已由源码核查确认(1.x 各节已写入,不必再翻)
- 线程池:自研、内联在
SshSession.h:58-118、固定 8 线程、谓词版 wait、析构 join -
runAsyncInThreadPool每次多起一个 detached 线程(要主动讲的缺陷) - 状态用
mutex statusMutex保护;SshSession.cpp:639漏锁 +isConnected()无锁读 - connect 超时:Telnet 侧非阻塞 connect 四步齐全(
telnetSession.cpp) - 心跳:
libssh2_keepalive_config+ 专用 keepAliveThread + EAGAIN 处理 - SFTP 双通道 = 两条独立 SSH 连接,
SshSession.cpp:1461/1493并行建立 - libssh2 句柄 RAII:函数指针 deleter(
SshSession.h:123-125)vs 仿函数(key_manager.cpp:19-24) - 回调「先缓存后投递」是双向的(Session 缓存数据 / SessionManager 缓存回调)
- 端口转发三种模式 + SOCKS5 自解析 + HTTP CONNECT 误配的排障故事
⬜ 仍需你自己确认
- 协议层有没有脱离 NAPI 的单元测试
-
Session基类的纯虚函数完整清单 - 新增一个协议实际要改哪些文件
- 五把锁的加锁顺序(
libssh2Mutex递归锁为什么是递归的、有没有顺序相反的路径) - 状态机完整状态图(能白板画出)
- 自动重连做没做(代码里没看到指数退避,没做就如实说)
- SSH 侧的两段超时(TCP connect +
libssh2_session_set_timeout)是不是都设了 - 会话销毁 vs 工作线程正在读,怎么处理的
- Telnet 处理了哪些 IAC 选项
- Serial 在鸿蒙侧的设备节点路径与权限
- SFTP 双 session 的认证凭据怎么复用(密码?密钥?)
- EAGAIN 释放那个坑到底踩没踩过(没踩过就别说成踩过)
- NAPI 时序竞争的具体复现和修法(翻提交记录)
- 桥接层近 5000 行里,有多少是本该在协议层的业务逻辑
- 并发会话数上限的实测数据(简历也要用)
升级子系统
- flashd 完整链路图(hdc → flashd → 分区写入)
- 连续升级中间步骤失败后的策略(回滚 / 停止 / 转全量)
- EROFS vs ext4 的实测体积数值
- 挂载时怎么判断文件系统类型
- 分区解耦后残留区域怎么处理
- C++ → Rust 的具体 FFI 方式、数据结构、错误传递
- 「平滑过渡」的具体策略
- gtest 写了哪些用例、异常路径覆盖了吗
PyTorch
- 挑 2-3 个真实踩过的三方库问题
- 真实遇到的 glibc/musl 差异(不要背通用清单)
德科
- 三条路线各一个能讲 3 分钟的例子
- CMake Toolchain / NAPI 模板的具体内容 + 被复用次数
- 一个失败或返工的案例(含流程改进)
简历相关
- 签约公司:2024.03 起一直为德科,中间为派驻项目变化(已由本人确认)
- 加华地学岗位名:软件开发工程师(2026-09-06 本人确认)
- GDB / Valgrind / perf 是否真用过——没有就从简历删掉
第四档设计题(投高级岗才需要)
- 4.1:你们的 OTA 是 A/B 分区还是 recovery 路线?为什么?
- 4.1:连续升级中间一步失败,实际选的是停住 / 回滚 / 转全量?
- 4.4:CrossShellNext 的会话销毁用的是引用计数还是两阶段关闭?
- 4.4:阻塞的 read 是怎么被叫醒的(shutdown / 自管道 / 超时)?
- 三张白板图(分层、状态机、数据流)各默画一遍
话术
- 为什么换工作(真实版本)
- 期望薪资区间
- 反问的 2-3 个问题