抖音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为例)
- 用户程序发起系统调用
- 通过glibc封装的系统调用接口,如
read()。 - 内部使用
syscall指令触发CPU陷入(int 0x80用于x86早期)。
- 通过glibc封装的系统调用接口,如
- CPU切换到内核态
- CPU从用户态(Ring 3)切换到内核态(Ring 0)。
- 切换发生时,自动保存用户态上下文(CS、SS、EIP、ESP等)。
- 跳转到内核中的 syscall 处理函数
- Linux使用
syscall_entry(),定位系统调用表sys_call_table[]。 - 按 syscall 编号(如
__NR_read)跳转到具体内核函数,如sys_read()。
- Linux使用
- 内核执行系统服务
- 核心代码操作内核数据结构(如文件描述符、进程调度等)。
- 返回用户态
- 内核函数返回,CPU通过
sysret指令返回用户态。 - 恢复用户上下文,继续执行。
- 内核函数返回,CPU通过
图示(简化):
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之间的数据交换。
用户 ↔ 内核 之间的通信方法:
- IOCTL(I/O Control Code)机制
用户通过DeviceIoControl向驱动设备发送命令和数据。
DeviceIoControl(hDevice, MY_IOCTL_CODE, inBuf, inLen, outBuf, outLen, ...);
- ReadFile/WriteFile 对字符设备的读写
- 驱动中处理 IRP_MJ_READ / IRP_MJ_WRITE 请求
- 共享内存(Section/Mapped File)
- 进程通过
CreateFileMapping创建共享内存段,驱动映射其内核地址
- 进程通过
- 事件(Event)/信号量
- 用户创建事件对象,传入驱动;驱动中使用
KeSetEvent通知完成
- 用户创建事件对象,传入驱动;驱动中使用
- 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 示例流程:
- 创建完成端口:
CreateIoCompletionPort - 发起重叠I/O操作:
ReadFileEx/WSARecv - 内核完成后触发回调函数或 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++ 中用于定义不可修改量的关键字,它提高了代码的可读性与安全性,同时也影响函数重载和编译期优化。
常见用法分类:
- 常量定义
const int a = 10; // a不可修改
- 函数参数保护
void print(const std::string& s); // 防止修改 s
- 返回值保护
const std::string& getName() const; // 成员函数不修改 this
- 指针相关三种写法
const int* p; // 指向常量的指针(不能改 *p)
int* const p; // 常指针(不能改 p)
const int* const p; // 既不能改 *p,也不能改 p
- 成员函数尾部
const
class A {
int val;
public:
int getVal() const { return val; } // 保证不修改成员变量
};
- 顶层 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 使用 |
锁的底层原理:
- 用户态快速路径(无竞争):
- 使用 CAS(Compare-And-Swap)实现原子锁定
- CPU 原语如
lock cmpxchg,xchg,mfence
// 伪代码:
if (lock == FREE) {
if (CAS(lock, FREE, LOCKED)) {
// 成功进入临界区
}
}
- 内核态慢路径(有竞争):
- 线程阻塞进入等待队列
- 操作系统调度器在合适时机唤醒线程
- 自旋锁(Spinlock):
- 高性能锁,适用于临界区执行时间非常短的场景(内核)
while (!CAS(lock, 0, 1)) {
_mm_pause(); // 等待
}
死锁场景:
- 多线程循环等待资源且互不释放,程序陷入无限阻塞。
// 典型死锁
thread1: lock A → lock B
thread2: lock B → lock A
小结:
现代锁机制采用“快速路径锁+慢路径调度”的混合设计,既追求性能,又兼顾公平与安全。理解底层实现可避免死锁、提高多线程编程质量。
终于把18道题写完了,脑瓜子是不是嗡嗡的了?
对于如何学习操作系统和底层相关的知识,大家可以戳下面的往期推荐列表看一看。