公众号

抖音C++面经分享,你能抗住几道题?

大家好,我是轩辕。

很快秋招又要开始了,很多小伙伴在努力准备面试。网上满大街的Java面经,今天给大家分享一篇C++,而且是偏系统安全的面经。

这是一位牛客网的网友分享的抖音C++面试题,大家可以快速浏览一下,看看自己能hold住几道题:

1.#include 头文件重复包含问题

2.详细解释栈帧

3.用户态到内核态穿越步骤,越详细越好

4.os整体架构-I/O管理器等六个执行体组件

5.进程地址空间

6.HTTPS协议

7.动静态多态实现、虚表

8.模板元编程

9.c++新特性-模块

10.x86 x64调用约定

11.MDL直接内存读写

12.虚拟内存-内存映射文件、页交换文件原理与区别

13.用户态与内核态间进程间通讯方法

14.线程、协程区别

15.异步I/O模型

16.const关键字

17.汇编层面 函数返回值如何传回

18.多线程同步机制,锁的底层原理等

这套题出的质量还是挺高的,涉及到了大量操作系统的知识。根据我以往的面试经历来说,我估计很多同学对编程语法知识、框架中间件啥的应该问题不大,但涉及操作系统,尤其是很多底层知识就一问三不知了。

接下来我们逐条来看一下这里面包含的知识点,内容居多,建议先收藏,面试的时候经常翻出来看看。

1. #include头文件重复包含问题

在C/C++中,如果一个头文件被多次包含,会导致重复定义错误,例如结构体、变量或函数声明重复。为了解决这个问题,通常有两种方式:

方式一:#ifdef/#ifndef/#define/#endif预处理器指令(传统做法)**

// myheader.h
#ifndef MYHEADER_H
#define MYHEADER_H

// 内容

#endif // MYHEADER_H

方式二:#pragma once(现代做法,非标准但广泛支持)

#pragma once

// 内容

两者原理类似,都是为了确保头文件只被编译一次,但#pragma once更简洁、易读,编译器优化也更好,推荐在项目中统一使用。

重复包含问题不仅仅是语法错误问题,更可能引发链接错误和代码维护困难,因此良好的头文件保护宏是编写健壮C/C++代码的重要基础。

2. 详细解释栈帧

栈帧(Stack Frame) 是函数调用过程中,在栈上为每个函数单独分配的一块空间。它用于保存函数的局部变量、函数参数、返回地址、保存的寄存器等。函数调用遵循“先进后出”原则,栈帧也呈现出“压栈-弹栈”的特性。

栈帧结构(以x86为例)

高地址
│
│   参数(调用者压入)
│   返回地址(call指令压入)
│   上一个函数的EBP
│   局部变量
│   临时保存的寄存器(如ESI, EDI等)
│
↓ 低地址

示例代码与汇编对应

int add(int a, int b) {
    int c = a + b;
    return c;
}

反汇编(简化)如下:

push ebp              ; 保存调用者的ebp
mov ebp, esp          ; 建立当前函数的栈帧
sub esp, 4            ; 分配局部变量空间
mov eax, [ebp+8]      ; 取a
add eax, [ebp+12]     ; 加b
mov [ebp-4], eax      ; 存入局部变量c
mov eax, [ebp-4]      ; 准备返回值
leave                 ; 等价于 mov esp, ebp; pop ebp
ret                   ; 弹出返回地址并跳转

小结

栈帧是函数调用过程中的“执行快照”,掌握其结构有助于理解函数参数传递、递归调用、调试符号(如backtrace)和栈溢出攻击等底层机制。


3. 用户态到内核态穿越步骤

用户态与内核态是操作系统中权限级别(Ring 3 vs Ring 0)的划分,用户程序不能直接访问内核资源,必须通过“系统调用”、“中断与异常”实现。

典型穿越路径(以Linux x86_64为例)

  1. 用户程序发起系统调用
    • 通过glibc封装的系统调用接口,如 read()
    • 内部使用 syscall 指令触发CPU陷入(int 0x80用于x86早期)。
  2. CPU切换到内核态
    • CPU从用户态(Ring 3)切换到内核态(Ring 0)。
    • 切换发生时,自动保存用户态上下文(CS、SS、EIP、ESP等)。
  3. 跳转到内核中的 syscall 处理函数
    • Linux使用 syscall_entry(),定位系统调用表 sys_call_table[]
    • 按 syscall 编号(如__NR_read)跳转到具体内核函数,如 sys_read()
  4. 内核执行系统服务
    • 核心代码操作内核数据结构(如文件描述符、进程调度等)。
  5. 返回用户态
    • 内核函数返回,CPU通过 sysret 指令返回用户态。
    • 恢复用户上下文,继续执行。

图示(简化):

User Mode (Ring 3)
↓
glibc → syscall → CPU trap (syscall instruction)
↓
Kernel Mode (Ring 0)
→ syscall_entry → sys_call_table[NR_read] → sys_read()
↓
返回值 → sysret → 用户态

小结

系统调用实现了用户程序与内核的“安全桥梁”,深入理解穿越过程对于掌握系统调试、内核开发、以及安全防护(如系统调用过滤)至关重要。


4. 操作系统整体架构:六个执行体组件

现代操作系统的核心功能模块可以划分为六个主要组件(执行体):

1. 进程管理(Process Manager)

  • 创建/销毁进程
  • 分配PID
  • 上下文切换、调度算法(如CFS)

2. 内存管理(Memory Manager)

  • 虚拟内存分页机制
  • 地址空间映射、段式管理
  • 页表、TLB、交换空间管理(Swap)

3. I/O 管理器(I/O Manager)

  • 驱动模型,设备抽象(统一接口)
  • 异步/同步I/O
  • 缓存系统、设备中断处理

4. 文件系统(File System)

  • 文件目录结构
  • 文件读写、权限控制
  • 支持多种文件系统(NTFS, ext4 等)

5. 网络栈(Networking Stack)

  • 套接字API(socket)
  • 协议栈实现(TCP/IP)
  • 多路复用机制(select/poll/epoll)

6. 安全与访问控制(Security & Access Control)

  • 用户权限/组/ACL机制
  • SELinux、AppArmor等增强机制
  • 系统调用过滤(如seccomp)

示例图:

sql


复制编辑
+------------------------------------------------+
|           应用程序 (User Mode)                |
+------------------------------------------------+
|         系统调用接口(Syscall API)           |
+------------------------------------------------+
| 内核组件:                                     |
|  进程管理 | 内存管理 | I/O管理 | 文件系统 | 网络 | 安全 |
+------------------------------------------------+
|       驱动程序 → 硬件抽象层 → 硬件            |
+------------------------------------------------+

5. 进程地址空间

操作系统为每个进程提供独立的虚拟地址空间,以实现内存隔离和安全性。一个典型的进程地址空间包括以下几个区域(以Linux x86_64为例):

区域 描述
Text段(代码段) 存放程序可执行指令,只读,可共享
Data段 已初始化的全局/静态变量
BSS段 未初始化的全局/静态变量,初始化为0
Heap堆 动态分配内存(如malloc/new),由brk/sbrk或mmap实现
Stack栈 局部变量、函数调用栈帧,向低地址增长
mmap 区域 共享库、内存映射文件、线程栈等
内核空间(高地址) 用户态不可访问,防止越权,系统调用时切换

进程地址空间通过MMU(内存管理单元)和页表机制映射到物理内存。每个进程都在自己的“虚拟世界”中运行,这种设计为多任务系统提供了安全与隔离的基础。

6. HTTPS 协议

**HTTPS(HyperText Transfer Protocol Secure)**是在HTTP基础上通过TLS(原SSL)加密的协议,用于保障Web通信的机密性、完整性和身份认证。

通信流程概览:

浏览器(客户端)
    ↓
与服务器建立 TCP 连接(三次握手)
    ↓
发起 TLS 握手(客户端Hello)
    ↓
服务器返回证书、公钥(Server Hello)
    ↓
客户端验证证书 → 生成对称密钥(PreMasterSecret)
    ↓
用公钥加密PreMasterSecret发送给服务器
    ↓
双方生成会话密钥(对称加密),TLS握手完成
    ↓
进入加密通信(HTTP 数据通过 TLS 通道传输)

关键技术点:

  • 非对称加密(RSA/ECDHE):保护密钥交换过程
  • 对称加密(AES/ChaCha20):保护传输内容
  • 数字证书(X.509):验证服务器身份
  • MAC算法(HMAC):确保数据完整性

TLS 握手图示建议:

Client                                  Server
  | --- Client Hello ---------------->   |
  |                                     |
  | <--- Server Hello + Cert ---------- |
  | --- Key Exchange (Encrypted) -----> |
  | <--- Finished --------------------- |
  | --- Finished ---------------------> |
开始加密传输

HTTPS的本质是通过“非对称密钥交换 + 对称加密通信 + 数字签名”来保障数据安全,是现代互联网通信的基石。


7. 动态多态与静态多态的实现、虚表机制

静态多态(编译期多态):

通过模板、函数重载、运算符重载实现,编译时就能确定调用哪个函数。

template<typename T>
T add(T a, T b) { return a + b; }

int main() {
    add<int>(1, 2); // 编译期决定
}

动态多态(运行期多态):

通过虚函数实现,依赖于**虚函数表(vtable)**机制。

class Base {
public:
virtual void say() { std::cout << "Base\n"; }
};

class Derived : public Base {
public:
void say() override { std::cout << "Derived\n"; }
};

虚函数表实现机制:

  • 每个带有虚函数的类在内存中都有一个vtable(虚函数表),存放指针数组。
  • 每个对象内部有一个vptr(虚表指针)指向对应的vtable。
  • 当通过基类指针调用虚函数时,实际通过vptr->vtable[函数偏移]跳转。

图示建议:

对象内存布局:
------------------
| vptr --> [vtable] → say() 指针 → Derived::say
------------------

小结

动态多态带来运行时灵活性,适合构建复杂框架;静态多态性能优越,适合模板设计。理解虚表是深入掌握C++面向对象的关键。


8. 模板元编程(Template Metaprogramming)

模板元编程是一种在编译期间执行逻辑运算的技术,常用于类型推导、静态断言、编译期计算等。

示例:编译期计算阶乘

template<int N>
struct Factorial {
static constexpr int value = N * Factorial<N - 1>::value;
};

template<>
struct Factorial<0> {
static constexpr int value = 1;
};

int main() {
    std::cout << Factorial<5>::value << std::endl; // 输出 120
}

典型用途:

  • 类型推导(SFINAE、traits)
  • 静态断言(static_assert
  • 编译期优化(constexpr、if constexpr)
  • 容器设计(如std::tuple

小结

模板元编程使C++拥有了接近函数式语言的编译期计算能力,是泛型编程和STL库的基石。


9. C++新特性:模块(Modules)

模块是C++20引入的特性,旨在替代传统的头文件机制,提升编译速度模块化管理代码封装性

为什么引入模块?

  • 传统头文件机制存在重复解析、预处理膨胀的问题
  • #include本质是“文本拷贝”
  • 大型项目编译时间长、易出错

模块使用示例:

// math.ixx(模块接口)
export module math;

export int add(int a, int b) {
    return a + b;
}
// main.cpp
import math;
int main() {
    return add(1, 2);
}

编译:

g++ -std=c++20 -fmodules-ts math.ixx -c -o math.o
g++ main.cpp math.o -std=c++20 -o app

模块优势:

  • 只编译一次,提升大型项目编译性能
  • 显式导出接口,隐藏实现
  • 避免宏污染和依赖地狱

小结

模块是C++20迈向现代编译体系的重要一步,有望终结“头文件诅咒”,使C++具备Java/Go等语言的模块化能力。


10. x86/x64 调用约定(Calling Conventions)

调用约定定义了函数调用时参数如何传递、谁负责清理栈帧、返回值如何传回等规则。

x86 调用约定(32位)常见有:

  • cdecl:参数右到左压栈,调用者清理栈(默认C)
  • stdcall:参数右到左压栈,callee清理栈(Windows API)
  • fastcall:前两个参数通过寄存器(如ECX、EDX),其余压栈
int __cdecl add(int a, int b);    // 调用者清理栈
int __stdcall sub(int a, int b);  // 被调函数清理栈

x64 调用约定(微软 Windows 平台):

  • 前四个参数通过寄存器 RCX, RDX, R8, R9
  • 返回值通过 RAX
  • 额外参数通过栈传递,调用者清理
  • 被调函数必须保留某些寄存器(RBX, RBP, RDI…)

图示建议:

调用栈(x86):
| 参数2 |
| 参数1 |
| 返回地址 |
| old EBP |
| 局部变量 |

调用栈(x64):
寄存器传参(RCX、RDX、R8、R9)
栈:spill 参数、返回地址、对齐空间

小结

掌握调用约定对于调试汇编、逆向工程、跨语言接口、内联Hook等底层技术至关重要。x64的寄存器传参更高效,但也更复杂。

11. MDL 直接内存读写(Memory Descriptor List)

**MDL(内存描述符列表)**是 Windows 内核中用于描述物理内存页的结构,驱动开发中经常用于实现对用户态缓冲区的安全访问,尤其用于 DMA 操作、内核态与用户态之间数据交换。

使用场景:

  • 访问用户空间缓冲区(在 IRP_MJ_READ/WRITE 中)
  • 驱动进行物理页锁定,避免分页出错
  • 用于零拷贝传输

MDL 操作流程(典型):

// 1. IoAllocateMdl 分配MDL结构
PMDL mdl = IoAllocateMdl(
    userBuffer,              // 用户空间缓冲区
    length,
    FALSE,                   // 不构建链表
    FALSE,                   // 不分页锁定
    NULL
);

// 2. MmProbeAndLockPages 锁定物理内存
MmProbeAndLockPages(mdl, UserMode, IoReadAccess);

// 3. 获取内核可访问指针
PVOID sysAddr = MmGetSystemAddressForMdlSafe(mdl, NormalPagePriority);

// 4. 操作内存(读/写)

// 5. 解锁 & 释放
MmUnlockPages(mdl);
IoFreeMdl(mdl);

小结:

MDL 是 Windows 内核中操作跨用户/内核空间内存的核心机制,掌握其使用对于编写稳定、安全的驱动程序尤为重要。


12. 虚拟内存:内存映射文件、页交换文件原理与区别

虚拟内存基础:

操作系统将每个进程提供一套独立的虚拟地址空间,映射到物理内存和磁盘,实现:

  • 内存隔离
  • 超出物理内存的“扩展”
  • 懒加载、按需分页

内存映射文件(Memory-Mapped File):

将磁盘文件映射到进程虚拟内存中,实现文件 I/O 与内存读写的融合。

// Linux 示例
int fd = open("data.txt", O_RDONLY);
char* map = (char*)mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0);
// map 类似访问数组一样操作文件内容
  • 使用场景:大文件读取、共享内存通信、数据库缓存(SQLite)。
  • 优点:操作系统自动管理页面加载与缓存,减少系统调用。

页交换文件(Pagefile / Swap):

  • 当物理内存不足时,内核将不常用内存页写入硬盘(swap)。
  • 提供“虚拟”扩展能力,但性能下降明显。
  • Windows 使用 Pagefile.sys,Linux 使用 swap 分区或 swapfile。

区别总结:

项目 内存映射文件 页交换文件
控制者 用户程序主动 mmap 映射 系统内核自动控制
作用对象 映射磁盘文件 页表中的匿名内存页
典型用途 快速读写文件、共享内存 扩展内存、避免 OOM

13. 用户态与内核态间进程间通信(IPC)方法

IPC(Inter-Process Communication)是实现多个进程间协作的核心机制,用户态与内核态通信则是Ring3 Ring0之间的数据交换。

用户 ↔ 内核 之间的通信方法:

  1. IOCTL(I/O Control Code)机制
    用户通过 DeviceIoControl 向驱动设备发送命令和数据。
DeviceIoControl(hDevice, MY_IOCTL_CODE, inBuf, inLen, outBuf, outLen, ...);
  1. ReadFile/WriteFile 对字符设备的读写
    • 驱动中处理 IRP_MJ_READ / IRP_MJ_WRITE 请求
  2. 共享内存(Section/Mapped File)
    • 进程通过 CreateFileMapping 创建共享内存段,驱动映射其内核地址
  3. 事件(Event)/信号量
    • 用户创建事件对象,传入驱动;驱动中使用 KeSetEvent 通知完成
  4. Filter Communication Port(WDF/UMDF)
    • 更现代、安全的通信接口,常用于防火墙、过滤驱动。

小结:

IOCTL 是最通用的方式,简单直接;共享内存适合高频大数据传输;事件机制适合状态同步。


14. 线程与协程的区别

对比项 线程(Thread) 协程(Coroutine)
调度 操作系统内核调度(抢占式) 用户空间调度(主动让出)
创建/销毁 成本较高,涉及内核对象 成本极低,仅创建栈和上下文
切换代价 高,需上下文切换、内核态 ↔ 用户态转换 低,仅需保存/恢复寄存器
并行能力 可真正并行(多核) 不能并行,适合IO密集型
使用场景 多核计算、高并发服务 高并发异步I/O(如Nginx、Go协程)

示例:C++20协程(简化)

task<> myCoroutine() {
    co_await something_async(); // 异步等待
}

图示建议:

线程池(内核调度):
[Thread 1] ←→ [Thread 2] ←→ [Thread 3]

协程池(用户调度):
[Coroutine A] → yield → [Coroutine B] → yield → A

小结:

线程适合CPU密集并行任务,协程适合I/O密集型异步编程。协程因低开销、可控调度,正成为现代并发编程主流。


15. 异步 I/O 模型

异步 I/O 是 I/O 操作的非阻塞执行机制,避免阻塞线程以提高系统并发处理能力。不同操作系统实现方式不同。

常见模型(以网络I/O为例):

模型 描述
阻塞 I/O 调用阻塞,直到完成
非阻塞 I/O 调用立即返回,需轮询是否完成
select/poll 注册 fd,轮询检测状态(Linux)
epoll 高效事件通知,回调机制(Linux)
IOCP 完全异步,完成端口通知(Windows)
io_uring Linux 5.1+ 提供的零拷贝新一代高性能异步模型

Windows 中 IOCP 示例流程:

  1. 创建完成端口:CreateIoCompletionPort
  2. 发起重叠I/O操作:ReadFileEx / WSARecv
  3. 内核完成后触发回调函数或 GetQueuedCompletionStatus 唤醒

Linux 中 epoll 示例流程:

int epfd = epoll_create1(0);
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &event);
epoll_wait(epfd, events, MAX_EVENTS, timeout);

图示建议:

Client Requests → [epoll/IOCP queue] → Worker Threads ← Results Callback

小结:

异步 I/O 极大提升了高并发系统性能,是服务器端编程(如Nginx、Redis、Windows IOCP服务器)的核心机制。

16. const 关键字详解

const 是 C++ 中用于定义不可修改量的关键字,它提高了代码的可读性与安全性,同时也影响函数重载和编译期优化。

常见用法分类:

  1. 常量定义
const int a = 10; // a不可修改
  1. 函数参数保护
void print(const std::string& s); // 防止修改 s
  1. 返回值保护
const std::string& getName() const; // 成员函数不修改 this
  1. 指针相关三种写法
const int* p;     // 指向常量的指针(不能改 *p)
int* const p;     // 常指针(不能改 p)
const int* const p; // 既不能改 *p,也不能改 p
  1. 成员函数尾部 const
class A {
int val;
public:
int getVal() const { return val; } // 保证不修改成员变量
};
  1. 顶层 vs 底层 const
const int x = 5;       // 顶层 const(变量本身是 const)
const int* p = &x;     // 底层 const(所指对象是 const)

编译优化与函数重载:

  • const 能帮助编译器做 常量传播(Constant Folding) 优化。
  • 可构成函数重载条件:
void f(int* p);
void f(const int* p); // 合法重载

小结:

const 是现代 C++ 安全与高质量代码的重要特性,建议尽可能使用 const 来修饰不该修改的数据,提高代码鲁棒性。


17. 汇编层面:函数返回值如何传回

函数返回值的传递方式取决于 平台的调用约定返回值类型,在汇编层表现为 通过寄存器或内存 返回数据。

返回方式(以 x86/x64 为例):

类型 x86 寄存器 x64 寄存器
整数/指针 EAX RAX
浮点值 ST0(FPU栈) XMM0(SSE寄存器)
结构体/大对象 通过内存地址 通过隐藏参数传递(通常RCX)

示例:简单整数返回

int add(int a, int b) {
    return a + b;
}

编译后简化汇编(x64):

add:
    lea eax, [ecx + edx] ; ecx, edx 是传入参数
    ret                  ; 返回值在 eax(x64用 rax)

示例:结构体返回值(x64)

struct Data { int x, y; };
Data foo() {
    return {1, 2};
}

编译器会隐式添加一个返回结构体的临时缓冲区地址作为**隐藏第一个参数(RCX)**传入,被调用函数把结构体填入这块内存。

图示建议:

Caller:
    sub rsp, 32            ; 分配结构体返回缓冲区
    lea rcx, [rsp]         ; 将地址传给被调用者
    call foo

小结:

理解返回值在汇编层的传递方式,有助于掌握调试原理、跨语言接口、二进制分析与漏洞利用等底层技术。


18. 多线程同步机制,锁的底层原理

多线程环境下,同步机制用于保护临界区资源,防止竞态条件(Race Condition)。C++ 提供多种同步工具,其底层实现通常依赖于原子指令 + 操作系统调度

常见同步机制:

类型 特点
std::mutex 基本互斥锁,使用 Pthread/Windows 内核对象
std::recursive_mutex 允许同一线程多次加锁
std::shared_mutex 读写锁,C++17 引入
std::atomic<T> 原子变量,基于无锁机制
std::condition_variable 条件变量,需配合 mutex 使用

锁的底层原理:

  1. 用户态快速路径(无竞争):
    • 使用 CAS(Compare-And-Swap)实现原子锁定
    • CPU 原语如 lock cmpxchg, xchg, mfence
// 伪代码:
if (lock == FREE) {
    if (CAS(lock, FREE, LOCKED)) {
        // 成功进入临界区
    }
}
  1. 内核态慢路径(有竞争):
    • 线程阻塞进入等待队列
    • 操作系统调度器在合适时机唤醒线程
  2. 自旋锁(Spinlock)
    • 高性能锁,适用于临界区执行时间非常短的场景(内核)
while (!CAS(lock, 0, 1)) {
    _mm_pause(); // 等待
}

死锁场景:

  • 多线程循环等待资源且互不释放,程序陷入无限阻塞。
// 典型死锁
thread1: lock A → lock B
thread2: lock B → lock A

小结:

现代锁机制采用“快速路径锁+慢路径调度”的混合设计,既追求性能,又兼顾公平与安全。理解底层实现可避免死锁、提高多线程编程质量。

终于把18道题写完了,脑瓜子是不是嗡嗡的了?

对于如何学习操作系统和底层相关的知识,大家可以戳下面的往期推荐列表看一看。

往期推荐