PERSONAL LAB / interview/cpp-面试手册
Interview cpp-面试手册

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 / 串口 / SFTP1.0
NAPI / ArkTS / xterm.js / libssh21.0
分区 / 镜像 / 全量包 / 差分包 / flashd / hdc2.0
变砖 / 连续升级 / 签名 vs 哈希2.0、2.2
wheel / manylinux / glibc vs musl3.0
C++ 类
栈 / 堆 / 内存泄漏 / 野指针 / 悬垂指针2.1.0
所有权 / 智能指针怎么选 / 循环引用2.1.1
RAII / 异常安全2.1.2
左值右值 / move / 完美转发2.1.3
多态 / vtable / 虚析构2.1.4
内存序 / atomic / volatile2.1.7
系统类
用户态 / 内核态 / 系统调用2.2.0
进程 vs 线程 / 虚拟地址空间2.2.1
页 / 页表 / 缺页异常 / 代码段 / 段错误 / TLB2.2.1 的「内存是怎么被管起来的」
上下文切换2.2.1
fork / 写时复制 / 僵尸进程 / 守护进程 / 信号2.2.2 - 2.2.4
符号 / 静态库 / 动态库 / ELF / RPATH2.2.5
页缓存 / mmap / fsync2.2.6
文件系统 / 挂载 / GPT2.2.7、2.3(第一档)
并发与网络
数据竞争 / 临界区 / 条件变量2.3.0、2.3.1
线程池 / 死锁 / CAS / false sharing2.3.2 - 2.3.5
socket / 四元组 / TCP vs UDP2.4.0
握手挥手 / TIME_WAIT / 粘包2.4.1 - 2.4.3
fd / epoll / ET vs LT / Reactor2.4.4、2.4.5、4.2
心跳 / 超时 / 指数退避2.4.6
进程隔离 / IPC / 共享内存2.2.1、4.6
工程类
构建系统 / CMake / 交叉编译 / sysroot2.5.1、3.2
rebase vs merge2.5.2
core dump / backtrace / ASan / 火焰图2.5.3
单元测试 / 依赖注入 / mock2.6(第一档)
零散名词
句柄 / 调用约定 / ABI / 名字改编 / 符号版本1.7、2.5、3.1(第一档)
内存对齐 / SIMD / NEON3.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 上最有名的是 PuTTYXshell,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++,能脱离鸿蒙编译和测试
└───────────────────────────────────────────┘
       依赖方向:上层依赖下层,下层【绝不】反向依赖上层

✅ 对应到实际文件

文件
UIArkTS 侧,用 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++ 侧建会话是两条独立的时间线,谁先谁后不确定。 所以两边都缓存:会话侧缓存数据(数据先到、回调还没注册), 管理器侧缓存回调(回调先注册、会话还没建好)。」

三个追问都能答

追问✅ 事实
缓存粒度是什么按会话,pendingMessagesSession 的成员
有没有上限没有,是个无界 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) 谓词版本,处理正确
线程怎么退出✅ 析构里:置 stopnotify_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 statusMutexSshSession.h:167
atomic 用在哪✅ 用的是 atomic<bool>statusChangestopThread
SshPortForwardshouldStop / running / m_pfConnected
napiInterfaceisCallbackValid / 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            保护待投递消息缓存

两个一定会被问的点,提前想好

① 为什么 libssh2Mutexrecursive_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_SNDTIMEOconnect 的行为在不同平台上不一致, 不够可靠。

③ 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 而不是 closeclose(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 而不是 closeclose 之后 fd 号会被系统回收、可能立刻分配给新连接, 而工作线程还不知情,醒来后拿这个 fd 号继续读——读到别人的连接shutdown 只关数据传输方向,fd 号还在你手里。

⬜ 你的实现是哪种? 如果当时用的是”加个标志位 + detach”那种简化方案,如实说, 然后把上面这套讲出来说明你知道该怎么做。 承认边界 + 给出正确方案,比硬编强得多。


1.6 协议与 SFTP 双通道

Telnet / Serial 底层怎么建连?

Telnet

就是裸 socketsocket() + 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

这几行里有三个可讲的点

  1. 并行建连:两条连接同时握手,而不是串行——否则用户要等两次认证的时间
  2. 先局部 make_unique、成功才 move 到外层失败路径完全靠 RAII 自动清理,没有一行手写的 cleanup。 这是 RAII 在真实代码里最实用的形态,比背”栈展开”有说服力
  3. 三层 catch(具体异常 → std::exception...), 错误信息用 errorMutex 保护——因为两个线程可能同时写错误信息

⬜ 认证凭据是怎么在两个 session 之间复用的? (用户不可能输两遍密码,代码里 config 是传进去的, 具体是复用了明文密码还是复用了密钥/agent,回去确认——这是必然的追问。)

✅ 端口转发:简历上没写,但它是这个项目里最硬的一块

SshPortForward.cpp 1827 行,三种转发模式全实现了:

模式关键代码是什么
本地转发487-519socket → bind → listen(10) → accept本机开个端口,连它就等于连远端的某个地址
远程转发736libssh2_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.2unique_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:无状态仿函数
sizeof16 字节(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++ 侧不再往里投
tsfnCreatedtsfn 是否已创建——防止在创建前就调用,或重复创建
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 失败路径靠析构自动清理,没有一行 cleanup1.7、1.6
讲讲你的并发设计五把锁的职责划分 + keepalive 线程和关闭怎么不打架(检查与使用同锁内)1.4、1.5
你觉得自己代码哪里写得不好runAsyncInThreadPool 每次多起一个 detached 线程;status 漏了一处锁1.3、1.4
底层网络你做过什么非阻塞 connect 四步:O_NONBLOCKconnectselect 可写 → 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.7parted -lsgdisk -plsblk -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 标准库) 是所有程序的地基—— printfmallocopenread,还有线程、DNS 解析,全在里面。 任何一个 C/C++ 程序都要链接一份 libc。

Linux 上有两个主要实现:

glibcmusl
用在哪Ubuntu、CentOS、Debian…… 绝大多数发行版Alpine Linux、很多嵌入式系统(Yocto)
特点功能全、性能好、体积大、扩展多极小、极简、静态链接友好
为什么有人用 muslDocker 镜像能从几百 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_maingnu_get_libc_version, 以及带 @GLIBC_2.xx 版本标记的符号。

musl 既不提供这些扩展,也根本不支持”符号版本”这套机制。

💡 「符号版本」是什么:glibc 里同一个函数可以同时存在好几个版本, 符号名写成 memcpy@GLIBC_2.2.5memcpy@@GLIBC_2.14 这样。 这样 glibc 改了函数行为后,老程序还能绑到老版本,保证向后兼容。 你见过的 version GLIBC_2.34 not found 报错就是这套机制的产物。详见 2.2.5。 (符号版本是什么见 2.2.5。)

③ 行为差异 —— 就算强行链上也会在运行时炸

差异后果
LFS64 接口open64lseek64 这类)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.soDT_RPATH 的某些语义支持不完整
  • musl 没有 ld.so.cache(glibc 靠 ldconfig 生成的那个索引) → 库的搜索路径行为不一样,RUNPATH 必须设得更完整

iconv / locale 是最小化实现

musl 只支持 C locale。依赖多语言本地化的功能会有差异。


3.5 CPython 扩展 ABI 与平台标签

先搞清楚:Python 怎么加载 C++ 写的模块

import torch 的时候,Python 最终要加载一个 C++ 编译出来的 .sotorch/_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.soRUNPATH 没设对——_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, 这是我预期最可能出现性能回归的地方。」

这个答法的价值有三层

  1. 承认没做——不含糊、不吹
  2. 说清该怎么做——证明你懂基准测试的方法
  3. 给出有依据的技术判断——“分配器差异”这个点说明你真的理解 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 框架依赖
平台相关代码集中在少数几个文件
② 剥离内核 + 重接 UINotepadNext
(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_ptrshared_ptrweak_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

原因:如果允许拷贝,ab 就都认为自己是主人, 两个都会在析构时 delete 同一块内存 → double free,堆结构被破坏,程序崩溃。

所以 C++ 直接在类型层面禁止:拷贝构造和拷贝赋值被标记为 = delete写了就编译不过——这正是”把所有权写进类型系统”的意思。

💡 std::move 在这里的作用是”把 a 标记成可以被搬空的”, 它本身不搬任何东西,真正的搬运在 unique_ptr 的移动构造里。 详见 2.1.3。“move-only 类型” 这个说法就是指 “只能移动、不能拷贝”的类型,unique_ptrstd::threadstd::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_sharedshared_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:能看,但不算主人

它解决什么问题

有两种情况你需要”指向一个对象,但不想延长它的寿命”:

  1. 循环引用:A 持有 B,B 又持有 A → 计数永远不归零 → 泄漏
  2. 回调 / 缓存 / 观察者:我想在对象还活着时用它, 但它该死的时候就该死,不该因为我拿着就赖着不走

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(); });

这会创建一个全新的控制块!于是同一个对象有了两套独立的计数, 两边各自归零时都会 deletedouble 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_ptrshared_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();                               // ✅ 抛异常也会析构 → 解锁
}                                           // ✅ 正常结束也析构 → 解锁

标准库里到处都是 RAIIlock_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 = ... 也是万能引用

引用折叠规则(万能引用能同时接左右值的原理):

推导出的 TT&& 折叠成结果
传左值 → T = Widget&Widget& &&Widget&(左值引用)
传右值 → T = WidgetWidget&&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() 的实际过程

  1. 从对象里读出 vptr(一次内存访问)
  2. 从 vtable 的固定偏移处取出函数指针(第二次内存访问)
  3. 通过这个指针调用

所以说虚函数是「两次间接寻址」。

虚函数的三个代价(面试问「开销在哪」就答这三条)

  1. 空间:每个对象多一个指针(64 位上 8 字节)。 小对象很吃亏——一个只有 int 成员的类,加了虚函数体积翻三倍。
  2. 无法内联(⚠️ 这通常才是最大的代价): 编译期不知道会调到哪个函数,就没法把函数体展开到调用处。 一个本来可以内联的 3 行小函数,变成一次真实的函数调用。
  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 更快。原因有两个:

  1. 你得先找到插入位置。list 找位置要 O(n) 遍历, 这一步的开销远大于 vector 那次 memmove
  2. 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++ 里最抽象的部分, 所以我从「为什么需要它」讲起。

为什么会有”内存序”这种东西

你以为代码是按你写的顺序执行的。其实不是。 有两层重排:

  1. 编译器重排:编译器为了优化,可能调换两条不相干的语句的顺序
  2. 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 == truedata 还没写好。

内存序(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::atomicatomic 本身已经包含了 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 查页表时,可能发现两种情况处理不了:

  1. 这个虚拟地址在页表里根本没有映射
  2. 有映射,但权限不符(比如你要写一个标着”只读”的页)

这时 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  →  永久死锁 💀

而且 printfnew、几乎所有 C++ 标准库设施内部都可能 malloc

规则:多线程程序 fork 之后、exec 之前,只能调用 async-signal-safe 的函数write_exitexecve 这些, 完整清单见 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终止
SIGTERMkill PID 的默认信号终止(可以被捕获,用来做优雅退出
SIGKILLkill -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++ 程序分两步

  1. 编译(compile):每个 .cpp 单独变成一个 .o(目标文件)。 这时候如果 a.cpp 调用了 b.cpp 里的 foo(), 编译器不知道 foo 在哪,就在 a.o 里留一个标记: 「我需要一个叫 foo 的东西,地址待填」。
  2. 链接(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/writemmap
数据路径磁盘 → 页缓存 → 拷贝到用户缓冲区磁盘 → 页缓存 → 直接映射,不拷贝
系统调用每次读写都要一次 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(未定义行为)——不是”结果不确定”, 而是”整个程序的行为都无从谈起”,编译器可以做任何优化。 所以不能说「反正就是数少了点」,必须彻底消除。

三种消除办法(后面每一节各讲一个):

  1. 加锁(mutex)——让同一时刻只有一个线程能进去
  2. 原子操作(atomic)——让”读-改-写”变成不可分割的一步
  3. 根本不共享——每个线程用自己的数据,最后汇总(最好的办法

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 个会话的任务就永远排不上号。

可选答法(选一个你项目真实的说):

  1. 读操作用带超时的非阻塞读,定期返回让出线程
  2. 读线程和任务线程分开:每个会话有自己的读线程,线程池只跑短任务
  3. 限制并发会话数,保证线程数 > 最大会话数

⬜ 待填:你项目实际是哪种?这条在 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 重算

weakstrong 的区别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

TCPUDP
连接面向连接(要先握手)无连接,发了就走
可靠性保证送达、保证顺序(丢了会重传)不保证,可能丢、可能乱序
数据形态字节流(没有消息边界)数据报(一个包就是一个消息,有边界)
开销大(要维护状态、确认、重传)
用在哪SSH、HTTP、文件传输——你的项目全是 TCPDNS、视频通话、游戏

📌 “字节流没有消息边界”这一条,是理解”粘包”的关键,见 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);             // 消费掉,剩下的留着
}

三个必须说出来的实现要点

  1. 必须有累积缓冲区。一次 read 可能读到半个包,也可能读到两个半包, 不能假设”一次读到就是一条完整消息”。
  2. 必须循环处理。一次 read 的数据里可能包含好几条完整消息。
  3. ⚠️ 长度字段必须做上限校验。攻击者发一个 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 对比

selectpollepoll
fd 数量上限FD_SETSIZE,通常 1024无硬限制无硬限制
内部结构位图(一个 bit 代表一个 fd)数组内核红黑树 + 就绪链表
每次调用的开销把整个 fd 集合从用户态拷到内核同左只在 epoll_ctl 时注册一次
返回后怎么找就绪的遍历全部 fd,O(n)同左内核直接给就绪列表,O(就绪数)
跨平台POSIX,最通用POSIXLinux 特有

为什么 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、替换宏、处理 #ifg++ -Efatal error: xxx.h: No such file头文件路径不对
② 编译语法检查、生成汇编g++ -Serror: 'x' was not declared代码/声明问题
③ 汇编汇编 → 目标文件 .og++ -c基本不会在这挂
④ 链接把各个 .o 和库里的符号对起来ldundefined 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 时,手敲命令行会死在三件事上:

  1. 增量编译:改了一个头文件,到底要重编哪些 .cpp? (.o 不只依赖它的 .cpp,还依赖它 #include所有头文件,递归)
  2. 编译选项一致:一半文件用 -DNDEBUG 编、一半没用, 同一个类在两边大小不一样,链接能过,运行时诡异崩溃(ODR 违规)。
  3. 跨平台: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_runcheck_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” 之类压掉)
  • 合入主干:用 mergesquash 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 时 ourstheirs 是反的。 因为 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))
3LRU 缓存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 道题管用)

  1. 先说思路,再写代码。 直接埋头写是明确减分项——面试官看不到你的思考过程, 而且你写错方向了他也没机会拦你。 标准开场:「我想用滑动窗口做,左右指针分别……时间复杂度 O(n)。可以吗?」
  2. 主动说复杂度,时间和空间都要说。
  3. 写完主动测边界,当着面试官的面走一遍: 空输入、单元素、全相同元素、整数溢出、越界。 主动测边界比代码一次写对更能体现工程素养。
  4. 用 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 个,问不出来是明确减分。好问题的方向:

  1. 「这个岗位接手后最先要解决的问题是什么?」(显示你在想工作本身)
  2. 「团队现在的技术栈和代码规模大概是什么样?有没有单元测试和 CI?」 (工程成熟度,也是你在筛他们)
  3. 「这个岗位往后 1-2 年的技术方向是什么?」

不要一上来问:加班、调薪、几点下班。留到 HR 面。


第四档:高级岗系统设计题(从零讲起)

这一档假设你什么都忘了。 每道题都先用大白话讲清「这东西是干嘛的、 为什么需要它」,再给答题框架。看不懂就说明是我没写清楚,不是你的问题。

4.0 先搞清楚:什么是「系统设计题」

前面三档考的是有没有唯一答案的东西:智能指针怎么用、epoll 是什么。 系统设计题不一样——面试官给你一句很空的需求,比如:

「你来设计一个能扛住一万个连接的服务器,说说你怎么做。」

然后闭嘴,看你怎么展开。没有标准答案,考的是四件事:

考察点面试官心里在判断什么
会不会问清楚需求上来就写代码的人,工作中也会做错东西
会不会分层能不能把一坨需求拆成几个各管一摊的模块
想不想得到失败情况只会写「顺利的路」是中级,想得到「出错怎么办」才是高级
会不会说取舍「我选 A 不选 B,因为……」这句话是高级和中级的分水岭

为什么高级岗一定考这个

中级岗招的是「给他一个明确任务能做完」的人,高级岗招的是 「给他一个模糊问题能定方案」的人。所以八股背得再熟也不加分—— 面试官默认你会。他要看的是你有没有自己从头搭过东西

通用四步答法(每道题都套这个)

第一步:反问,把需求钉死(30 秒)

不要一上来就答。先问 2-3 个问题,问什么都行,重点是让面试官看到你不接受模糊需求。 万能三问:

  1. 规模多大?(多少连接 / 多大数据 / 多少设备)
  2. 最看重什么?(延迟低?吞吐大?绝对不能丢数据?)
  3. 跑在什么环境?(服务器还是嵌入式设备?内存多大?)

⚠️ 规模问题决定方案。100 个连接和 10 万个连接,正确答案完全不同。 不问就答,很容易答成「用最复杂的方案解决最简单的问题」,这是减分的。

第二步:画分层(2 分钟)

在白板上画三到四个横条,从下往上。每一层说一句「它负责什么、它不负责什么」。 这一步最重要——画出来的图比你说的话有说服力

第三步:走一遍正常流程(2 分钟)

拿一个具体的例子,从头到尾走一遍。比如「一个客户端连进来,数据是怎么流到业务代码的」。

第四步:讲失败路径和取舍(3 分钟,这一步决定评分)

主动说:「如果这里断电 / 这里超时 / 这里内存不够,会怎么样,我怎么处理。」 再说:「这个方案的代价是……,如果需求变成 XX,我会改成 YY。」

🔑 90% 的候选人止步于第三步。你只要主动走到第四步,就已经赢了大半。

三个禁忌

  1. 不要沉默着想。想不出来就把思路说出来:「我先想一下数据是怎么流的……」 面试官要听你的思考过程,安静 30 秒会被判定为不会。
  2. 不要编不存在的经验。可以说「这块我没做过,但我会这样设计,理由是……」
  3. 不要一上来堆技术名词。说「用 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(文件描述符)。后续把这个编号交给 readwrite 等函数,就能指定要操作哪个资源。文件、管道也使用 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 设成非阻塞

假设系统通知小李的连接可读,线程读走了当前已有的数据。程序再次读取时,小李还没有发下一批:

  • 阻塞模式:这次调用可能继续等待,小王和小张的事件处理又被耽误。
  • 非阻塞模式:暂时没数据就返回“现在不能继续”,线程可以先处理其他连接。

常见的“暂时不能继续”由 EAGAINEWOULDBLOCK 错误码表示。它不同于连接已经永久失败。

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 不看答案,检查你是否真的理解了

  1. 小王连着但没有发消息,连接还在吗?专门负责它的线程可能在干什么?
  2. listen_fdaccept 返回的 fd 有什么不同?
  3. handle_client 是系统函数还是你写的?它在哪个线程执行?
  4. 一个线程调用阻塞 read 等小王,为什么可能耽误小李?
  5. epoll 返回通知后,是系统自动完成业务,还是你的程序继续读取和处理?
  6. 一万条连接为什么不必对应一万个线程?

核对:连接可以保留,线程可能睡眠等待;监听 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),没到那个量级不值得。」

必须主动讲的坑

  1. 取消问题:任务还没到期,但对应的连接已经关了。 如果定时器还持着这个连接的裸指针,执行时就是悬垂指针崩溃(use-after-free)。 解法:定时器只存一个 weak_ptr 或者一个 id,执行前先检查对象还在不在。
  2. 回调不能耗时:定时器线程在跑回调时,后面所有任务都被推迟。 耗时的活要丢给线程池。
  3. 不要用系统墙上时钟:用户改系统时间、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、文件名:行号
③ 缓冲层    双缓冲 / 每线程一个本地缓冲(更快,但顺序会乱)
④ 落盘层    后台线程写文件、滚动切分、压缩、删旧

必须主动讲的点

  1. 日志级别过滤:编译期裁剪与运行时过滤分开设计。运行时条件判断可以跳过参数求值,但不自动等于编译期删除代码;是否保留 release 的调试日志由需求决定。
  2. 崩溃时怎么办:不能把 SIGSEGV 处理器中刷新普通异步日志缓冲作为通用方案;崩溃现场可能持有日志锁,缓冲也可能处于不一致状态。 正常退出时在普通执行上下文排空日志;崩溃诊断优先保留 core dump。确需信号处理器输出时,只使用预先设计的最小应急通道。 write(2) 是信号安全函数,不表示围绕它读取共享队列、加锁和格式化也安全,见 signal-safety(7)
  3. 日志滚动:按大小或按天切分,超过 N 个文件就删最老的, 否则设备上的 flash 会被写满——嵌入式场景这条特别重要,一定要说
  4. 取舍

「普通诊断日志可以采用异步缓冲、定期写出、正常退出排空,并说明崩溃时可能丢失尚未保存的数据。 审计或升级流水另行定义持久化确认点,检查写入和同步错误,必要时批量同步。 丢失范围和性能代价都要实测,不能只凭调用 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)

共享内存必须主动讲的三个坑(这是这题的得分点):

  1. 共享内存本身不带同步。两个进程同时写就是数据竞争。 必须自己配同步:进程间信号量(sem_open)或者 放在共享内存里的 pthread_mutex(要设 PTHREAD_PROCESS_SHARED 属性)。
  2. 进程崩溃会留下烂摊子。持锁的进程崩了,锁永远不释放, 别的进程全卡死。要用健壮互斥锁PTHREAD_MUTEX_ROBUST), 持锁者死亡时后来者可能收到 EOWNERDEAD;获得锁之后还必须检查、修复共享数据并声明一致,不能将拿到锁等同于数据已经恢复。
  3. 指针不能直接放进共享内存。同一块共享内存在两个进程里的 映射地址可能不同,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 这一档怎么练

不要读,要。方法:

  1. 每道题给自己 8 分钟,对着白纸(或者手机录音)从头讲一遍
  2. 讲完回来对照 4.8 的自查表打勾,缺哪条下次补哪条
  3. 重点练 4.1(OTA)4.4(会话管理)——这两道是你的主场, 要练到能讲 10 分钟不重复
  4. 4.2(Reactor)练到能画图 + 说清 one loop per thread 为什么不用锁即可, 不用背细节
  5. 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、多文件工程
Linux2.2、第一档构建与移植问题文件复制、子进程、动态库故障
并发2.3,再读 2.1.7阻塞队列、逐级线程池、退出和竞态验证
网络2.4、4.2—4.4协议解码、多连接、超时、背压
工程2.5、第一档 2.6故障报告、自动测试、性能测量
项目与设计第一、三、四档真实代码核实、综合项目、模拟答辩

已经通过基础验收,才进入面试冲刺

  1. 核实三个项目的真实链路、职责与实现细节,准备一页事实稿;无法核实的标明未知。
  2. 针对目标岗位练基础解释和编码变体,不逐字默写现成实现。
  3. 系统设计从规模、资源和失败约束讲起,拿自己的练习或项目证据说明取舍。
  4. 模拟面试后记录具体缺口,回到对应练习修复,再做一次不同输入或要求的验证。

算法从数组、字符串和基础复杂度开始,逐级增加难度。不把“每天两三道中等题”和“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 个问题
⎇ main interview/cpp-面试手册 144 节 253 notes UTF-8