5.1 Linux基础
【必问】说说几个知道的Linux命令?
cd、pwd、mkdir、ls、touch、cp、mv、rm、cat、head、tail、find、tar、gzip、chmod、ps、top、kill。
Linux系统的文件系统的文件有哪几种?
-
普通文件
-
目录文件
-
字符设备
-
块设备
-
管道文件
-
链接文件
-
socket文件
Linux的/proc目录是干嘛的?
/proc 是 Linux 操作系统中的一个特殊的虚拟文件系统(Virtual Filesystem),它并不对应磁盘上的真实目录或文件,而是内核在运行时动态生成的,用于向用户空间提供关于系统运行状态、进程信息、硬件信息、内核配置等详细信息的“窗口”。
Linux如何查看某个进程的端口?
lsof -i -P -n | grep <PID>
Linux如何查看某个进程的磁盘占用?
iotop -p <PID>
Linux文件软链接和硬链接的区别是什么?
硬链接本质上是指向同一个inode的多个文件名,也就是说,硬链接和原文件共享同一个数据块,它们在文件系统里是完全平等的,删除其中一个不会影响其他硬链接,只有当所有硬链接都被删除,数据块才会真正被释放。硬链接不能跨文件系统创建,也不能对目录创建硬链接,因为这可能导致文件系统循环引用等复杂问题。
软链接,也叫符号链接,它更像是一个特殊的文件,里面存储的是另一个文件或目录的路径。软链接有自己独立的inode,它指向的是目标文件的路径字符串,而不是直接指向数据块。所以如果原文件被删除,软链接就会变成“悬空链接”,访问它会报错。软链接可以跨文件系统,也可以对目录创建,使用起来更灵活,但相对也更容易因为目标失效而出问题。
简单总结就是:硬链接是同一个文件的多个名字,共享数据;软链接是一个新文件,里面存着目标路径,更像快捷方式。实际开发中,比如做日志切割、版本切换时,软链接用得比较多,而硬链接更多用在需要保证文件不被误删的场景,比如某些备份策略。
静态链接 vs 动态链接有什么区别?
静态链接:由链接器在链接时将库的内容加入到可执行程序中。对运行环境的依赖性较小,具有较好的兼容性。但是生成的程序比较大,需要更多的系统资源,在装入内存时会消耗更多的时间库函数有了更新,必须重新编译应用程序。
动态链接:连接器在链接时仅仅建立与所需库函数的之间的链接关系,在程序运行时才将所需资源调入可执行程序。简化程序的升级;有着较小的程序体积。实现进程之间的资源共享(避免重复拷贝)。但是依赖动态库,不能独立运行。
5.2 文件IO
【必问】用户态 vs 内核态有什么区别?
-
用户态(User Mode)
-
权限级别:低,受限,不能直接访问硬件或关键资源
-
功能范围:只能执行普通程序逻辑,调用系统API/库函数
-
系统调用:通过系统调用(如 syscall)请求内核服务
-
稳定性:出错一般只影响当前程序
-
-
内核态(Kernel Mode)
-
权限级别:高,拥有全部权限,可访问硬件和系统资源
-
功能范围:可执行所有CPU指令,直接操作硬件和内存
-
系统调用:直接响应系统调用,执行底层功能
-
稳定性:出错可能导致系统崩溃(如蓝屏、死机)
-
系统调用 vs 库函数调用有什么区别?
系统调用和库函数调用看起来都像是程序去调用某种功能,但它们的层次和实现方式差别挺大的。
简单来说,系统调用是程序直接和操作系统内核打交道的一种方式,它是用户态进入内核态的入口,比如我们常用的read、write、fork这些,底层都是通过系统调用去让内核帮忙完成一些硬件相关或者权限敏感的操作,比如读写磁盘、创建进程、管理网络等。因为涉及到内核,所以系统调用的开销相对大一些,它需要从用户态切换到内核态,再切回来,这个过程涉及CPU上下文切换和权限检查。
而库函数调用,比如C标准库里的printf、malloc,或者C++标准库里的各种功能,它们大多数是在用户态实现的,不直接涉及内核。库函数本质上是对系统调用或者其他底层操作的封装,有时候一个库函数可能内部调用了多个系统调用,也可能完全不调用系统调用,只是做一些内存操作、数学计算或者数据格式化之类的工作。因为不涉及内核态切换,库函数调用通常比系统调用要轻量,执行效率更高。
【必问】请描述系统调用的整个流程?
-
用户程序发起系统调用
-
当用户程序需要使用操作系统提供的功能时(比如读取文件、创建进程等),它不会直接操作硬件或内核数据,而是通过调用一个由标准库(如C标准库)封装好的函数,这个函数最终会触发一个系统调用。
-
-
中断
-
方式一:软中断
-
方式二:专用系统调用指令(更高效,现代主流方式)
-
这些指令会直接切换 CPU 到内核态,并跳转到预设的系统调用入口点,比传统软中断更高效。
-
-
-
CPU 切换到内核态,跳转到系统调用入口
-
保存用户程序的执行上下文(如寄存器状态、程序计数器等)。
-
CPU 模式从用户态切换到内核态。
-
跳转到操作系统预设的系统调用处理入口点(由操作系统内核初始化时设置)。
-
内核根据系统调用号(比如从
rax寄存器中读取),查找系统调用表(syscall table),找到对应的内核函数(如sys_read)。
-
-
内核执行相应的系统调用服务例程
-
内核根据系统调用号,调用对应的内核函数,例如:如果是
read系统调用,内核会调用类似sys_read()的函数。 -
内核会验证参数合法性(比如文件描述符是否有效)。
-
执行相应功能(如从磁盘或缓冲区中读取数据)。
-
-
系统调用执行完毕,准备返回结果
-
将返回值(如读取的字节数,或错误码)放入约定的寄存器中(如 x86-64 的
rax)。 -
恢复用户程序的执行上下文(如寄存器状态、程序计数器等)。
-
从内核态切换回用户态,返回到用户程序中调用系统调用的下一条指令。
-
-
用户程序获取系统调用结果
系统调用能否引起进程、线程切换?
可能。
系统调用本身是用户态到内核态的切换,不必然导致线程切换,但在阻塞或调度时机合适时,系统调用过程中可能伴随线程切换。
原因:
-
系统调用可能阻塞(如读文件、等待网络数据等),此时当前线程可能被挂起,调度器会选择其他就绪线程运行,从而发生线程切换。
-
即使系统调用本身不阻塞(如获取时间、设置优先级等),如果发生了中断或时间片耗尽,也可能在系统调用返回前触发线程调度与切换。
MMU是什么?
MMU,全称是Memory Management Unit,也就是内存管理单元,它是CPU里一个很关键的硬件模块,主要作用是负责虚拟地址到物理地址的转换,同时对内存访问进行控制和保护。
简单来说,现代操作系统和程序运行的时候,我们写的代码里用的都是虚拟地址,而不是直接操作物理内存地址,这样做的好处是能让每个进程都觉得自己独占了一整块连续的内存空间,提高安全性和灵活性。而这个虚拟地址到物理地址的映射,就是由MMU来完成的。
当CPU执行指令访问内存时,比如读取某个变量或者跳转到一个函数地址,它给出的其实是虚拟地址,这个地址会先经过MMU,MMU根据当前进程的页表(Page Table)把它转换成对应的物理地址,然后再去真正的物理内存里存取数据。这个过程对程序来说是完全透明的,程序员一般不用关心底层的物理地址细节。
除了地址转换,MMU还承担了内存保护的功能,它可以设置不同的内存区域为只读、可读写或者不可访问,这样就能防止一个进程不小心或者恶意地去修改别的进程的内存,或者访问不该碰的内核区域,大大提升了系统的稳定性和安全性。
另外,MMU也是实现虚拟内存的基础,配合操作系统的页面置换机制,即使物理内存不够,也能把不常用的内存页换出到磁盘上,让程序感觉好像有更大的内存可用。
进程的文件描述符表是什么?
进程的文件描述符表(File Descriptor Table)是操作系统中用于管理一个进程打开的文件或其他I/O资源(如管道、套接字等)的一种重要数据结构。每个进程都有自己独立的文件描述符表,它记录了该进程当前打开的所有文件或I/O资源的引用。
多个进程可以打开同一个文件,它们会有不同的文件描述符,但可能指向相同的或不同的“打开文件表项”。如果两个进程各自打开同一个文件,一般会得到不同的文件描述符,且它们的文件偏移量是独立的,除非使用诸如 fork() 后继承或特殊方式共享。
每个进程的文件描述符表的0,1,2号文件都是什么?
标准输入、标准输出、标准错误输出设备文件。
对于fcntl系统调用,你在开发过程中一般用来做什么?
在开发服务器程序时,fcntl 这个系统调用我用的最多的场景主要是 文件描述符的控制和配置,尤其是 非阻塞 I/O 的设置。
比如,当我们需要处理大量网络连接时,通常会用 epoll 或 select 这类 I/O 多路复用机制,而要让这些机制高效工作,底层 socket 或文件描述符通常要设为 非阻塞模式,这样读写操作不会因为暂时没有数据就阻塞整个线程。设置非阻塞模式,我就会用 fcntl(fd, F_SETFL, O_NONBLOCK),把文件描述符的标志位加上 O_NONBLOCK,这样后续的 read/write 操作在数据没准备好时就会立刻返回,而不是傻等,这对于高并发服务器来说非常关键。
写文件时进程宕机,数据会丢失吗?
-
是否调用了
fsync()或fdatasync()
-
没有调用
fsync(fd):-
数据可能只写到了操作系统的 页缓存(Page Cache),而 尚未刷到磁盘。
-
如果进程在此时宕机,或者系统突然断电,这些还未刷盘的数据就会丢失。
-
-
调用了
fsync(fd):-
该函数会强制将文件描述符
fd对应的 脏页(修改过但未写入磁盘的数据) 刷到磁盘,包括数据和元数据(如文件大小、修改时间等)。 -
调用后可以较大程度保证数据已经持久化到磁盘,即使进程随后崩溃,数据一般也不会丢失。
-
注意:
fsync()是一个相对较慢的操作,因为它要等磁盘确认。
-
-
是否使用了缓冲(Buffered I/O vs 直接 I/O)
-
默认情况下,C 标准库和系统调用(如
write())使用的是带缓冲的 I/O(除非你打开文件时用了O_DIRECT标志)。-
使用
write()写入数据时,数据通常先进入 用户空间缓冲区(如果你用了fwrite()等 C 标准库函数),然后再进入 内核的页缓存。 -
只有数据进入页缓存后,
write()才会返回成功,但此时数据 未必已经写入磁盘。
-
-
如果你希望绕过页缓存,可以使用
O_DIRECT标志打开文件(需对齐内存和 I/O 大小),这样数据会直接写入磁盘(但仍受硬件缓存影响)。-
但使用
O_DIRECT较为复杂,一般用于数据库等对性能和一致性要求极高的场景。
-
【必问】fflush是什么?fsync是什么?他们有什么区别?
fflush是一个 标准 C 库函数(来自 <stdio.h>),用于 刷新(清空)C 标准 I/O 缓冲区,即强制将 用户空间缓冲区中的数据写入到内核缓冲区(但未必立即写到磁盘)。
fsync是一个 系统调用(Unix/Linux 系统,来自 <unistd.h>),用于 将文件数据以及元数据(如文件大小、修改时间等)从内核缓冲区同步到物理磁盘,确保数据真正落盘。
| 特性 | fflush | fsync |
| 所属层次 / 库 | C 标准库(<stdio.h>) | 系统调用(Unix/Linux,<unistd.h>) |
| 作用对象 | FILE*流(如 stdout, fp = fopen(...)) | 文件描述符 int fd(如通过 open()得到的) |
| 作用范围 | 刷新 用户态的 I/O 缓冲区(C 库 buffer) 到 内核缓冲区 | 将 内核缓冲区(如页缓存)中的数据刷到物理磁盘 |
| 是否保证数据落盘 | ❌ 不保证数据写到磁盘,只到内核 | ✅ 保证数据和元数据都写入磁盘(真正落盘) |
| 是否涉及磁盘 I/O | 一般不直接触发磁盘写入 | 会触发实际的磁盘同步操作 |
5.3 内存优化
5.3.1 Linux虚拟地址空间
简述Linux进程的虚拟地址空间布局?
32 位系统下的虚拟地址空间布局(典型情况):
在 32 位系统中,虚拟地址空间大小为 4GB(2^32 字节),通常划分为用户空间(User Space)和内核空间(Kernel Space)两部分:
-
用户空间(User Space):0x00000000 ~ 0xBFFFFFFF(约 3GB)
-
内核空间(Kernel Space):0xC0000000 ~ 0xFFFFFFFF(约 1GB)
用户空间(低 3GB)布局(从低地址到高地址)大致如下:
-
NULL 指针区域 / 保留区(0x00000000 ~ 0x08000000)
-
地址 0 或附近是不允许访问的,用于捕捉空指针引用。
-
通常还有一些保留或未映射的区域。
-
-
代码段(Text Segment / .text)
-
存放程序的可执行指令,是只读且可执行的。
-
对应编译后的程序代码。
-
-
数据段(Data Segment)
-
包括:
-
已初始化的全局变量和静态变量(.data)
-
未初始化的全局变量和静态变量(.bss,Block Started by Symbol),通常初始化为 0
-
-
-
堆(Heap)
-
由程序动态分配内存使用,比如通过
malloc()、calloc()、new等函数分配。 -
向高地址方向增长。
-
由程序员手动(或通过垃圾回收等机制)管理。
-
-
内存映射区域(Memory Mapping Region)
-
用于动态库加载(共享库,如 .so 文件)、文件映射、匿名映射等。
-
通过
mmap()系统调用实现。 -
位置通常在堆和栈之间,可变。
-
-
栈(Stack)
-
用于存放局部变量、函数参数、返回地址、保存的寄存器等。
-
向低地址方向增长(即栈顶向下扩展)。
-
每个线程都有自己的栈。
-
栈的大小有限,过大可能导致栈溢出。
-
虚拟内存是什么?虚拟内存 vs 物理内存有什么区别?
虚拟内存(Virtual Memory) 是现代计算机操作系统中的一种重要机制,它为每个进程提供了一种抽象:让每个进程认为自己“独占”了整个内存空间(通常是 4GB 或更多),而实际上这些进程的地址空间是被操作系统和硬件映射到有限的物理内存(RAM)甚至磁盘上的。
虚拟内存(Virtual Memory):
-
定义:是操作系统为每个进程提供的抽象的、私有的地址空间,并非真实存在的硬件内存。
-
地址类型:虚拟地址(Virtual Address),由 CPU 生成,进程看到的是虚拟地址。
-
大小:理论上可以非常大(如 32 位系统最多 4GB/进程,64 位系统可达 TB 级)。
-
是否连续:对进程而言,虚拟地址空间可以是连续且完整的(如从 0x00000000 到 0xFFFFFFFF)。
-
是否可交换:支持将不常用的部分换出到磁盘(Swap/Page File),以节省物理内存。
-
访问方式:CPU 生成的地址是虚拟地址,必须通过 MMU(内存管理单元) 翻译为物理地址后才能访问 RAM。
-
目的:提供内存抽象、隔离、保护和高效利用,让程序开发更简单、系统更安全稳定。
物理内存:
-
定义:是计算机系统中实际存在的硬件内存芯片(RAM),用于存储正在运行的数据和代码。
-
地址类型:物理地址(Physical Address),是内存芯片上的真实地址,由硬件使用。
-
大小:受限于实际安装的 RAM 容量(如 8GB、16GB、32GB 等)。
-
是否连续:物理内存通常是分散使用的,由操作系统动态分配。
-
是否可交换:物理内存是实实在在的硬件资源,不能“换出”,但可以被覆盖。
-
访问方式:CPU 最终是通过物理地址直接访问物理内存。
-
目的:是程序运行时真正存储和操作数据的地方,速度快但容量有限。
虚拟内存有什么好处?
-
进程地址空间隔离(安全性 & 稳定性)
-
简化程序开发与内存管理(对程序员透明)
-
支持比物理内存更大的程序(突破 RAM 限制)
-
内存保护(防止非法访问与越界)
-
支持内存映射文件(Memory-Mapped Files)
-
简化内存分配与管理(对操作系统更友好)
请描述一次CPU读内存的完整流程,从虚拟地址到拿到数据?
-
CPU 生成一个虚拟地址(VA, Virtual Address)
-
MMU 通过页表将虚拟地址转换为物理地址(PA, Physical Address)
-
期间可能通过 TLB(Translation Lookaside Buffer) 加速地址翻译
-
-
根据物理地址,到各级缓存(L1 → L2 → L3)中查找数据
-
若缓存命中(Cache Hit),则直接返回数据
-
若缓存未命中(Cache Miss),则从下一级缓存或主存(RAM)中加载
-
-
若数据不在缓存中,则从主存(RAM)中读取,并回填到缓存
-
最终数据返回给 CPU 寄存器,供指令使用
cache line是什么?一个cache line通常多大?
Cache Line(缓存行) 是 CPU 高速缓存(Cache) 中数据存储与传输的最小单位。
现代计算机体系结构中,CPU 访问内存的速度远远慢于其自身的运算速度,为了弥补这种速度差距,CPU 内部集成了多级高速缓存(如 L1、L2、L3 Cache),用于临时存放最近或频繁访问的内存数据,从而加速数据的读取与写入。
当 CPU 需要访问某一块内存数据时,并不是只将该单一数据加载进缓存,而是将这块数据所在的一块连续内存区域(通常是 64 字节或其它大小)一次性加载到缓存中,这个最小的加载/存储单位就叫做 Cache Line(缓存行)。
一个 Cache Line 的典型大小是:64 字节(最常见)。
但在不同的 CPU 架构和厂商中,Cache Line 的大小可能有所不同。
堆和栈在操作系统底层的实现、为什么栈的分配速度比堆快?
-
分配机制不同:移动指针 vs 复杂管理
-
栈分配:
-
只需要通过调整 栈指针(SP / RSP),比如在函数调用时,CPU 或编译器生成的汇编指令会自动将栈指针向下(或向上,依架构而定)移动若干字节,就完成了“分配”。
-
没有额外的查找、合并、系统调用开销,仅仅是移动一个寄存器,属于极轻量级操作。
-
-
堆分配:
-
需要通过
malloc()这样的函数,背后可能涉及:-
遍历空闲内存块链表,寻找合适大小的内存块
-
如果没有合适的块,可能需要向操作系统申请新的内存页(比如通过
brk或mmap系统调用) -
可能还要做内存对齐、拆分合并空闲块等操作
-
-
分配和释放都涉及复杂逻辑,甚至锁竞争(多线程环境下)
-
-
内存分配位置:连续 vs 可能分散
-
栈:
-
栈内存是连续的,分配时只是在当前栈顶继续向下(或向上)扩展,天然连续,无需额外寻址或映射。
-
-
堆:
-
堆内存通常是分散的,分配器需要在已分配和未分配的复杂内存块中找到一块“空闲”的,可能还需做内存分割与合并,甚至向操作系统申请新的物理内存页。
-
-
是否涉及系统调用
-
栈:
-
栈的扩展(比如线程栈初始分配)在创建线程时由操作系统一次性映射好虚拟内存,后续访问按需分配物理页(通过页错误机制),但日常的栈操作(分配局部变量)不涉及系统调用。
-
-
堆:
-
当堆空间不足时,
malloc可能需要调用如brk或mmap等系统调用向操作系统申请更多内存,系统调用本身就有较大的开销(用户态 ↔ 内核态切换)。
-
【必问】说说页面置换算法?
在操作系统中,当程序运行时,并不是所有的数据都能一下子全部装入物理内存(RAM)中,尤其是当物理内存有限,而程序使用的虚拟内存较多时,操作系统必须选择一部分暂时不用的内存页(Page)换出(Swap Out)到磁盘(如 Swap 分区或 Page File),以腾出空间加载当前需要使用的页。
这个过程就叫:页面置换(Page Replacement)。
而决定“换出哪一页”的规则或策略,就是 页面置换算法(Page Replacement Algorithm)。
-
先进先出(FIFO, First In First Out)
-
选择最早进入内存的页面进行置换,即维护一个队列,最先进入的页放在队头,换出时选择队头的页。
-
-
最优页面置换算法(OPT, Optimal Page Replacement)
-
选择未来最长时间不会被使用(或永远不会再使用)的页面进行置换。
-
是一种理论上的最优算法,用来作为其他算法的性能对比基准。现实中不可能做到。
-
-
最近最少使用(LRU, Least Recently Used)
-
选择最长时间没有被访问(最久未使用)的页面进行置换。
-
核心思想是:如果一个页面很久没被使用了,那它将来被使用的可能性也较低
-
-
时钟置换算法(Clock Algorithm,也称为 Second Chance)
-
是对 LRU 的一种近似实现,更加高效且容易实现
-
每个页有一个 “使用位”(Use Bit / Reference Bit),表示最近是否被访问过
-
算法维护一个类似钟表的循环链表(页面队列),有一个指针(时钟指针)循环检查页面
-
5.3.2 内存泄漏检测
C/C++中,内存泄漏可能是什么原因?
-
分配内存后忘记释放
-
在分配内存后、释放内存前发生了异常
-
基类析构函数非虚,导致派生类对象内存泄漏
-
shared_ptr循环引用
内存泄露是他杀还是自杀?
-
自杀(Suicide):指的是 对象自己没有主动释放自己占用的内存,也就是程序员自己分配了内存,但自己没有负责释放它,最终导致内存泄漏。
-
他杀(Homicide):指的是 本该负责释放内存的代码或机制(比如析构函数、智能指针、资源管理类等)没有正确执行,导致内存被“外部因素”阻止释放,进而泄漏。
程序员自己通过 new / malloc 主动申请了一块内存,但后来没有主动且正确地调用 delete / free 来释放它,导致这块内存“无人认领”、泄漏了。
程序员(或代码逻辑)自己分配了内存,却没负责清理,相当于自己“活着的时候没安排好后事”,导致资源一直占用,最终“自我了断式地浪费系统资源”。
【必问】说几个内存泄漏检测方法?
-
宏定义截获malloc/free(代码)
-
mtrace()(代码)
-
dlsym hook malloc(代码)
-
valgrind(工具)
-
bpf(工具)
-
ASan(工具)
内存泄漏检测之宏定义截获malloc/free方法是什么?
-
拦截所有的内存分配和释放调用,我们可以使用 C/C++ 的预处理器宏,将代码中的
malloc替换为我们自己定义的带跟踪功能的函数,比如debug_malloc,同理free替换为debug_free -
记录每次分配的内存地址、大小、调用位置等信息
-
在程序退出前(比如在
main函数结束时),检查哪些内存没有被释放,并输出泄漏信息
内存泄漏检测之mtrace()方法是什么?
-
在 C 语言中,
mtrace()是 GNU C 库(glibc)提供的一种 轻量级、简单易用的内存分配跟踪工具,用于帮助开发者检测内存泄漏(即分配了内存但未释放的情况)。 -
开启内存分配跟踪:mtrace();
-
关闭跟踪(可选,通常在程序退出前调用):muntrace();
-
设置环境变量
MALLOC_TRACE:在 运行程序之前,你需要设置一个环境变量,告诉 glibc 把内存跟踪信息写入到哪个文件中。
内存泄漏检测之dlsym hook malloc方法是什么?
在 C 语言中,使用 dlsym hook malloc 方法是一种通过动态链接库(.so)和 LD_PRELOAD 技术,在程序运行时动态拦截(Hook)标准 malloc 函数,从而实现内存分配跟踪、内存泄漏检测等目的的技术手段。
-
设一个变量存原始函数指针,通过 dlsym 获取
-
写一个自定义的 malloc 函数,第一次调用时,通过 dlsym 获取真正的 malloc 并存起来
-
调用真正的 malloc,并在调用前后把分配记录到数据结构中,free 函数也是同理
-
将上面的代码编译成一个共享库(
.so文件),使用LD_PRELOAD挂载 hook 库,运行时挂载你的 hook 库
宏定义 vs mtrace vs hook各自的优缺点?
宏定义:
优点:
-
简单直接,不需要依赖外部库或动态链接机制
-
可以获得额外信息,如调用位置(文件名、行号),方便定位问题
缺点:
-
侵入性强:必须修改代码或在公共头文件中引入宏,可能影响整个项目的构建
-
宏的副作用:宏不具有类型安全,容易引发难以察觉的 bug,特别是复杂的调用场景
-
不能拦截第三方库的 malloc/free:如果第三方库没有包含你的头文件,它们的内存操作不会被拦截
-
难以维护:宏在调试时不如函数直观,且容易与其他宏冲突
mtrace:
优点:
-
使用极其简单,无需修改代码逻辑,只需调用
mtrace()和muntrace() -
无需重新编译代码(一般情况下),只需设置环境变量即可
-
glibc 原生支持,兼容性好
缺点:
-
只能用于 glibc 环境,其他 libc(如 musl、Windows)不支持
-
功能有限:只能记录调用顺序,无法直接显示文件名和行号,需要结合地址映射分析,不够直观
-
不能实时检测,必须程序结束后才能分析日志
-
无法拦截第三方库在 mtrace() 之前调用的内存操作
hook:
优点:
-
运行时生效,无需修改源码,通过
LD_PRELOAD即可全局拦截 -
功能强大灵活,可以记录详细分配信息、统计、甚至修改行为
-
可以拦截所有调用,包括第三方库的 malloc/free
-
是很多高级内存检测工具(如 Valgrind 的简化版模拟)的基础技术
缺点:
-
实现复杂,需要熟悉动态链接、函数指针、
dlsym等底层机制 -
依赖动态链接环境(LD_PRELOAD 只在 Linux/Unix 下有效),不适用于 Windows 或静态链接程序
-
可能影响程序稳定性,如果 hook 逻辑有误,可能导致崩溃或性能下降
-
对多线程、异常安全要求高,需谨慎处理
内存泄漏检测之valgrind方法是什么?
见《性能分析》。
内存泄漏检测之bpf方法是什么?
-
eBPF 是一种在 Linux 内核中运行的轻量级、沙盒化的虚拟机。
-
它允许开发者编写小型程序(用 C 类似语法,通过 LLVM 编译为 eBPF 字节码),然后通过特定的钩子(hook)点注入到内核中,在不修改内核源码、不重启系统的情况下,安全地收集数据、跟踪事件、检测问题。
-
eBPF 程序受到严格验证,确保不会导致内核崩溃或死循环。
目前,已经有一些基于 eBPF 的成熟工具,可以实现或辅助进行 C 语言内存泄漏检测:
BCC 是一个强大的 eBPF 前端工具集,提供了很多开箱即用的内存分析工具,比如:
-
memleak:专门用于检测内存泄漏的 eBPF 工具!
-
可以跟踪用户态的
malloc/free(通过 uprobe) -
显示未释放的内存块以及调用栈
-
支持 C / C++ 程序
-
内存泄漏检测之ASan方法是什么?
它是由 Google 开发的,最早用于 Chrome 浏览器,后来被集成进 LLVM(Clang)和 GCC 编译器中,成为标准编译器选项之一。
ASan 在程序运行时,会:
-
跟踪所有的内存分配(如
malloc/calloc/realloc)和释放(如free) -
维护一个“分配但未释放”的内存块集合
-
在程序退出时(或通过 API 手动触发),扫描并报告所有仍然未释放的内存块
-
提供详细的泄漏信息,包括:
-
泄漏内存的大小
-
分配该内存的调用栈(如果编译时带有调试信息
-g) -
分配发生的位置(文件名 + 行号)
-
使用方法:你只需要在编译 C 程序时,加上 ASan 相关的编译选项即可
valgrind vs bpf vs ASan各自的优缺点?
Valgrind(以 Memcheck 为代表):
优点:
-
无需重新编译代码,直接对现有二进制运行即可,非常适合遗留系统或无法重新编译的场景
-
检测能力全面:能发现内存泄漏、越界访问、使用释放后内存、未初始化内存、重复释放等众多问题
-
使用简单:命令行工具,对开发者友好
-
不需要依赖特定编译器,与编译器无关
缺点:
-
性能极差:程序运行速度通常慢 10~20 倍,仅适合调试/测试,完全不适合生产环境或性能敏感场景
-
无法检测多线程数据竞争(需用 Helgrind,但也较慢)
BPF / eBPF:
优点:
-
运行在 Linux 内核中,性能开销低,可以做到接近原生性能
-
非常灵活强大:可以挂载到内核的几乎任何事件(系统调用、函数调用、内存分配、网络等),适合做高级监控、跟踪和定制化分析
-
可用于生产环境,常用于线上问题诊断、性能分析、安全审计
缺点:
-
学习曲线陡峭:需要了解 eBPF 工具链(如 bcc、libbpf、bpftrace)、内核知识、以及如何编写或使用现成的 BPF 脚本
-
对内存问题的检测粒度通常不如 ASan 或 Valgrind 精细
-
依赖 Linux 内核版本(需要较新内核,支持 eBPF)
AddressSanitizer (ASan):
优点:
-
检测能力强大:能够检测越界访问(读写)、Use-After-Free、Double Free、内存泄漏(部分)、未初始化内存等,覆盖了大部分常见内存问题
-
性能开销适中:相比 Valgrind 快很多(大约 2x~3x),适合开发和测试阶段使用
-
使用简单:只需在编译时加上
-fsanitize=address参数,无需复杂部署 -
精准定位问题:能给出出错代码的具体位置(文件、行号)和调用栈,便于快速修复
缺点:
-
需要重新编译代码,并且链接时也需带上 ASan 运行时库
-
不适用于生产环境(性能与内存开销仍较高)
-
对某些平台(如 Windows 或嵌入式)支持有限
-
不能检测所有类型的问题(比如某些逻辑错误、数据竞争等)
5.3.3 内存池
new的底层实现原理是什么?
new:
-
分配内存:通过
operator new分配足够容纳MyClass对象的内存。 -
构造对象:在分配好的内存上,调用
MyClass的构造函数,传入参数arg1, arg2,完成对象的初始化。
相对应的,delete 操作也会分两步:
-
析构对象:调用对象的析构函数。
-
释放内存:通过
operator delete将内存归还给系统。
这些是可以被重载的全局函数或类成员函数。
【必问】delete或free释放内存的时候并不知道内存大小,如何释放?
这个问题首先看new和delete的底层机制。
new的时候的分配内存的阶段使用malloc。malloc又根据你传入的size大小来决定由谁给你分配内存,若<=128KB,从内存池分配内存,若无内存池,则由系统调用brk()分配内存。若>128KB,则是使用mmap来做内存分配。
delete的时候的释放内存阶段又是调用的free,free又是如何得知释放的内存的大小呢?
free会按照你malloc的分配内存的方式,如果<=128KB,内存回收回内存池,若>128KB,则调用unmap释放内存。
那malloc又是在什么时候记录了要释放的内存的大小呢?
在glibc malloc的实现中,内存管理的基本单位是malloc_chunk。即使调用malloc(size)返回给用户一个指针p,glibc实际上在返回这个指针之前,已经分配了一个包含元数据头和用户可用空间的完整“块”(chunk)。
这个 chunk 包括:
-
元数据(metadata / chunk header): 存放在用户内存 之前(或之后)的隐藏区域,用于记录该 chunk 的大小、状态等信息。使用
brk()和mmap时的chunk header结构是不同的,不过我们无需关心。 -
用户可用数据区(payload): 就是你真正用
malloc(size)申请的那部分内存,比如你申请 100 字节,这里就给你 100 字节(可能会稍微多一点,用于对齐等)。
malloc 返回给你的指针,是指向用户可用内存区域的,不是整个 chunk 的起始位置。元数据位于你指针的“前面”(通常)。
每个 chunk 的起始位置(即用户内存的前面)会存储一个叫做 size + flags 的字段,通常是一个 size_t 类型的值(比如 32 位系统是 4 字节,64 位是 8 字节)。这里面就会有该 chunk 的大小。
所以free的细节:
-
用户调用
free(p) -
ptmalloc 根据 p,向前计算出 chunk header 的位置
-
读取 header 中的 size 等信息
-
判断是否可以和相邻的空闲 chunk 合并,以减少碎片
-
将 chunk 放回内存池,或者直接 unmap 返还给操作系统
delete[]不知道数组元素个数,如何释放?
delete[] 先逐个调用数组中每个元素的析构函数(如果存在的话),然后再释放整个数组的内存。
new[] 分配内存时,会多分配 size_t 大小的空间,用于存储数组元素个数。
delete[] p; 指针左偏移 sizeof(size_t) 就可以拿到数组元素个数,从而知道析构多少元素。

malloc有线程安全问题吗?
在现代主流的 C 库实现中(如 glibc、musl、MSVC 的 CRT),malloc 本身是 线程安全的(thread-safe)。
glibc 的 malloc 实现是基于 ptmalloc(pthreads malloc),它专门为多线程环境设计!
-
使用线程本地缓存(Per-thread Arenas / Thread Local Cache)
-
glibc 的 malloc 实现(特别是较新版本)会为每个线程维护一个或多个独立的“内存分配区域”(arena),称为 per-thread arena。
-
当一个线程调用
malloc时:优先从该线程自己的 arena 中分配内存,不需要加锁,速度很快; -
如果当前线程的 arena 空间不足,再去全局区域或者其他线程的 arena 获取内存,并在必要时加锁。
-
锁机制(Mutex / Locking)
在必须访问共享的堆管理数据结构时(比如全局堆、公共的空闲链表等),glibc 会使用 互斥锁(mutex) 来保护这些数据,防止多个线程同时修改导致数据竞争。
malloc分配的内存分配到物理内存还是虚拟内存?何时才会拥有物理内存?
malloc 分配的是虚拟内存。
当你调用 malloc(size) 时,它主要做的是:
-
在进程的虚拟地址空间中分配一块连续的虚拟内存区域,并标记为“已分配”(即这块地址你可以用,不会被其他代码占用)。
-
并不会立刻分配实际的物理内存页。
物理内存何时真正分配?
物理内存的分配通常是 延迟(按需) 进行的。
当你的程序 第一次访问 malloc 返回的地址(比如读写该内存) 时,CPU 发现该虚拟地址对应的物理页还没有映射,就会触发一个 缺页异常(Page Fault)。
操作系统内核会捕获这个异常,然后:
-
检查该虚拟地址是否合法(即确实是通过
malloc合法分配的); -
如果合法,内核就会 分配一个或多个物理页帧(physical page frames),并建立虚拟地址到物理地址的映射;
-
之后,程序就可以正常访问这片物理内存了。
两个进程malloc可能会返回一个值吗?会映射到同一个物理地址吗?
是的,两个不同的进程,调用 malloc 完全有可能 返回相同的虚拟地址!
原因:每个进程都有自己 独立的虚拟地址空间,也就是说,进程 A 和进程 B 各自维护一套自己的虚拟地址映射表。
默认情况下,malloc 不会让不同进程的虚拟地址映射到同一个物理地址。但是在某些情况下,两个不同进程的 malloc 分配的内存,可能会映射到相同的物理内存地址,但这是有条件的,而且通常不是直接由 malloc 控制的。
例如:共享内存。
new可以重载吗?new重载一般是为了什么?
C++ 允许你重载以下几种 new 和 delete 形式:
全局的 operator new 和 operator delete。
类的成员 operator new 和 operator delete。
此外,还有对应的 数组版本 new[] 和 delete[],以及 带额外参数的 placement new 等变种。
重载 new 和 delete 主要目的是为了自定义内存管理行为,常见使用场景包括:
-
自定义内存分配策略(如内存池、对象池)
-
跟踪内存分配 / 调试内存问题
-
实现特殊的分配行为(如单例模式、禁止分配等)
new时可以不进行内核态和用户态的切换吗?
new底层先malloc分配内存,再构造。
构造不涉及内核态切换,所以只考虑malloc。
当你第一次调用 malloc 或请求新的内存时,它可能会通过系统调用(如 brk 或 mmap)向 操作系统内核申请一大块虚拟内存,这时候会发生 用户态 → 内核态的切换。
但是!后续的 malloc 请求,如果还有之前申请的、尚未使用的虚拟内存空间(堆空间或 mmap 区域),就直接在用户态管理这些内存,进行分配和释放,无需进入内核态。
placement new是什么?内存分配约束是什么?
placement new 只做一件事:在已有内存上构造对象(仅调用构造函数)。
#include <new> // 必须包含此头文件
void* memory = /* 某块已分配的内存 */;
MyClass* obj = new (memory) MyClass(); // placement new
它本质是一个带有额外参数的 new 表达式:void* operator new(std::size_t size, void* ptr) noexcept;。
使用场景:
-
内存池
-
对象池
-
自定义内存管理
-
嵌入式
-
STL 容器就地构造接口:
emplace,emplace_*
内存分配约束:
-
对齐要求:内存地址需要满足对象对齐要求(alignas)
-
大小要求:内存大小必须足够容纳对象
-
生命周期:必须显示调用析构函数
-
异常安全:构造失败时,回滚,不能内存泄露
STL默认的空间配置器的实现机制?
空间配置器实现了对空间分配与对象构建进行分离,以及对对象析构与空间释放进行分离。
默认的空间配置器allocator只是对空间分配、对象构建、对象析构、空间释放进行简单封装:
-
allocate 对 ::operator new 简单封装
-
deallocate 对 ::operator delete 简单封装
-
construct 对 placement new 简单封装
-
destroy 对 ~_Up() 简单封装
STL基于内存池的空间配置器的实现机制?有几级?
需要以下头文件:
#include <ext/pool_allocator.h>
使用方法:
std::vector<int, __gnu_cxx::__pool_alloc<int>> vec;
它主要用于频繁申请小块内存(<128B)导致的问题,基于内存池的空间配置器主要重写了 allocate 和 deallocate。
STL 实现中,空间配置器实际上有 两级(两层):
第一级配置器:__malloc_alloc_template
-
直接使用 C 标准库的
malloc()和free()来进行内存的分配与释放。 -
当内存分配失败时,会尝试调用用户提供的
oom_handler(out-of-memory handler),通过不断重试来解决内存不足的问题。 -
这一级是直接与操作系统打交道的,适合大块内存的分配,但对小对象频繁分配/释放效率不高,且可能产生大量内存碎片。
第二级配置器:__default_alloc_template
-
也称为 "内存池配置器" 或 "小块内存配置器"。
-
主要用于处理 小于 128 字节(具体数值因实现而异)的小对象的内存分配,以提升性能和减少内存碎片。
-
它不直接使用
malloc,而是自己维护一个 内存池(memory pool)和自由链表(free lists),预先向操作系统申请一大块内存(内存池),然后切割成不同大小的小块,用链表管理这些空闲块,以提高分配效率。 -
对于大于 128 字节的内存需求,第二级配置器会自动转交给第一级配置器(即调用
malloc)。
(追问)第二级配置器的工作机制简述?(内存池+自由链表)
-
将小对象按大小分类(8字节对齐):
-
比如 8, 16, 24, ..., 128 字节,共 16 种大小。
-
每个大小对应一个 自由链表(free list),链表中保存的是可复用的空闲内存块。
-
-
内存池(memory pool):
-
配置器会预先向系统申请一大块内存作为内存池。
-
当某个大小的自由链表为空时,从内存池中切出一块内存,分成多个该大小的块,加入自由链表供后续分配使用。
-
-
当内存池不足时:
-
会尝试多次调用
malloc获取更大的内存块补充到内存池,或者合并一些策略(比如调用oom_handler)。
-
-
释放时:
-
将内存块回收到对应的自由链表中,而不是立即返还给操作系统,以便后续复用。
-
malloc中维护了一个内存池,为什么还需要基于内存池的空间配置器?
-
malloc通用的内存分配器,适用于各种场景,但是内存碎片相较更高。而分配器针对特定对象大小进行优化,可以减小内存碎片。
-
分配器可以根据具体应用场景定制。
【必问】内存池是什么?为什么需要内存池?
内存池(Memory Pool) 是一种内存管理技术,它在程序运行时预先分配一大块内存,并在需要时从这块预先分配的内存中分配小块内存给应用程序,而不是每次都向操作系统请求分配内存。当这些小块内存不再使用时,它们会被归还到内存池中,而不是直接返还给操作系统。
-
减少内存分配和释放的开销: 频繁地向操作系统申请和释放内存(例如通过
malloc和free)会产生较大的开销,因为这些操作可能涉及系统调用和复杂的内存管理逻辑。内存池通过复用已分配的内存块来减少这些开销。 -
提高性能: 由于内存池中的内存分配和释放操作通常只是指针的简单操作,因此速度非常快,有助于提高整体程序性能,特别是在高性能、低延迟要求的场景中,如游戏、高频交易等。
-
减少内存碎片: 通过复用固定大小或有限几种大小的内存块,可以有效减少内存碎片问题,提升内存利用率。
-
更好的控制和管理: 内存池允许开发者对内存分配行为有更细粒度的控制,可以根据应用需求进行优化,比如预分配、缓存策略等。
说说你的高并发内存池项目是怎么做的?有哪些关键点?
在我的高并发内存池项目中,主要目标是实现一个支持多线程、多尺寸(如16字节和32字节)的高效内存分配与回收机制,同时保证线程安全和低延迟。
项目结构与关键点如下:
-
多尺寸支持(SizeClass):
-
定义了不同的尺寸类别,如
Size16和Size32,通过枚举类SizeClass表示,每个类别对应固定的内存块大小。 -
使用
GetSizeFromClass函数根据类别获取对应的字节数。
-
-
线程本地缓存(Thread Cache):
-
每个线程拥有自己的
ThreadCache,通过thread_local关键字实现,确保每个线程独立访问自己的缓存,避免多线程竞争。 -
ThreadCache内部维护了一个ThreadFreeLists结构,用于管理各个尺寸类别的空闲内存块链表(FreeList)。
-
-
自由链表(FreeList)管理:
-
每个尺寸类别都有一个自由链表,用于存储可重用的空闲内存块。分配时从链表头部取出,释放时将内存块插回链表头部,操作时间复杂度为 O(1)。
-
当某个尺寸的自由链表为空时,会尝试从全局后备池中获取一批内存块,如果全局池也没有,则向操作系统申请新的内存块并分割成小块填充到自由链表中。
-
-
全局后备内存池(Global Back-End Pool):
-
使用
moodycamel::ConcurrentQueue实现了一个无锁的并发队列,作为全局的后备内存池,存储预先分配的大块内存,供各个线程在本地缓存不足时获取。 -
提供了
Produce方法用于向队列中添加内存块,Consume方法用于从队列中取出内存块。
-
-
内存对齐:
-
使用
AlignedAlloc和AlignedFree函数确保分配的内存满足对齐要求(例如16字节对齐),提高内存访问效率,尤其是在 SIMD 指令集和某些硬件架构下,对齐内存访问能显著提升性能。
-
-
无锁并发:
-
通过线程本地缓存和
moodycamel::ConcurrentQueue实现了高效的无锁并发。每个线程主要操作自己的本地缓存,避免了多线程间的锁竞争;全局后备池使用无锁队列,多个线程可以并发地从中获取或存入内存块。
-
-
内存分配与释放接口:
-
提供了统一的
SizeClassMemoryPool::Allocate和SizeClassMemoryPool::Deallocate接口,内部通过线程本地缓存进行实际的分配与释放操作,简化了用户的使用。
-
如何实现多尺寸内存池而非固定大小?
-
定义尺寸类别(SizeClass):
-
使用枚举类型(如
SizeClass)定义多个固定的内存块大小,例如Size16和Size32,每个类别对应一个固定的字节数。
-
-
映射对象大小到尺寸类别:
-
根据对象的大小,将其映射到最合适的尺寸类别。例如,小于等于16字节的对象使用
Size16,小于等于32字节的对象使用Size32,依此类推。这可以通过简单的条件判断或查找表实现。
-
-
为每个尺寸类别维护独立的自由链表:
-
每个尺寸类别都有自己的一组自由链表,用于存储该尺寸的空闲内存块。这样,不同大小的内存块可以分开管理,避免了固定大小内存池只能分配特定大小的问题。
-
-
分配与释放时根据尺寸类别操作对应的自由链表:
-
在分配内存时,根据请求的大小选择合适的尺寸类别,然后从该类别的自由链表中获取内存块。
-
在释放内存时,根据内存块的大小确定其所属的尺寸类别,然后将其归还到对应类别的自由链表中。
-
内存池项目本地线程如何解决无锁并发的?
-
线程本地缓存(Thread Local Storage, TLS):
-
每个线程拥有独立的
ThreadCache,通过thread_local关键字实现。这意味着每个线程主要操作自己的本地缓存,无需与其他线程共享数据,从而避免了多线程间的锁竞争。 -
由于每个线程的本地缓存是独立的,分配和释放内存的操作可以在常数时间内完成,且无需加锁。
-
-
自由链表(FreeList)的原子操作:
-
虽然自由链表本身是每个线程本地的,但在需要从全局池中获取内存块填充自由链表时,通过无锁队列实现高效、无锁的批量获取。
-
自由链表的操作(如插入和删除节点)仅限于单个线程的本地缓存,不涉及多线程共享,因此不需要额外的同步机制。
-
全局的后备内存池是如何实现无锁并发的?
-
使用高效的并发队列库:
-
moodycamel::ConcurrentQueue是一个第三方库,提供了高性能的无锁并发队列实现,支持多生产者多消费者(MPMC)模式,允许多个线程同时进行入队和出队操作,而无需使用传统的互斥锁或条件变量。
-
-
队列存储内存块指针:
-
全局后备池的队列中存储的是内存块的指针(
void*),每个内存块通常是预先分配的一大块内存分割后的小块,符合特定尺寸类别(如16字节或32字节)。
-
-
无锁的生产与消费:
-
生产(Produce): 当有大量空闲内存块可用时(例如,线程本地缓存释放多余的内存块),可以将这些内存块指针无锁地入队到
ConcurrentQueue中,供其他线程使用。Produce方法负责分配大块内存,分割成小块,并将这些小块入队。 -
消费(Consume): 当线程的本地缓存不足时,可以无锁地从
ConcurrentQueue中出队获取内存块指针,用于满足当前的内存分配请求。Consume方法尝试从队列中取出内存块指针,如果成功则将其加入本地自由链表。
-
内存池项目如何解决内存碎片的?
-
有限尺寸类别的内存块:
-
内存池将内存分配限制在几个固定的尺寸类别(如16字节和32字节),而不是任意大小。通过这种方式,所有分配的内存块大小相同或相近,避免了因频繁分配和释放不同大小的内存块而导致的外部碎片。
-
-
自由链表管理空闲内存块:
-
每个尺寸类别维护一个自由链表,用于存储可重用的空闲内存块。当内存块被释放时,它们会被归还到对应尺寸类别的自由链表中,供后续的分配请求复用。这种复用机制减少了内存的频繁申请和释放,降低了碎片产生的可能性。
-
-
内存块的复用:
-
通过线程本地缓存和全局后备池,内存块在释放后不会立即返还给操作系统,而是保留在内存池中,供后续的分配请求使用。这种复用机制提高了内存的使用效率,减少了内存碎片。
-
-
批量分配与分割:
-
当某个尺寸类别的自由链表为空时,内存池会从操作系统申请一大块内存(例如分配多个连续的16字节或32字节块),然后将其分割成多个小块,并将这些小块加入到自由链表中。这种方式确保了分配的内存块是连续的,减少了内部碎片。
-
我看你做了内存对齐,为什么要有内存对齐?
-
提高内存访问效率:
-
现代处理器通常以特定的字节边界(如16字节、32字节)访问内存,对齐的内存访问可以在单个内存周期内完成,显著提高访问速度。未对齐的内存访问可能需要多次内存周期,甚至引发硬件异常,降低性能。
-
-
兼容硬件要求:
-
某些硬件架构和指令集(如SIMD指令集,例如SSE、AVX)要求数据必须按照特定的对齐方式存储,否则无法正确执行相关操作。内存对齐确保了这些硬件特性能够正常发挥。
-
-
优化缓存利用:
-
对齐的内存块更有可能与处理器的缓存行(Cache Line)对齐,减少了缓存行的浪费,提高了缓存的命中率,进一步优化了内存访问性能。
-
5.3.4 CAS锁
C++ atomic_flag是什么?
std::atomic_flag 是 C++ 标准库中提供的一个最简单的原子布尔类型,定义在 <atomic> 头文件中。它是 C++ 中唯一保证是无锁(lock-free)的原子类型。
-
状态:它只有两个状态:
-
clear(清除,通常表示 false)
-
set(设置,通常表示 true)
-
-
clear() -
test_and_set()
C++ atomic_flag的无锁是如何实现的?
通常通过 底层硬件支持的原子指令(如 x86 的 LOCK 前缀指令、ARM 的 LDREX/STREX 等)直接在硬件层面完成原子操作。
C++ atomic_flag常常用来做什么?
做自旋锁。
加锁:while 循环内不断 test_and_set。一开始起始值为 false 就立刻返回了,其他线程检测到已被改为 true 就一直自旋
释放锁:clear 把值设为 false,其他线程检测到 false 就完成 test_and_set 从而获得锁退出 while
std::atomic<T>的++等操作又是在底层如何做到无锁的?
当你写 x++ 或 x.fetch_add(1),编译器会生成代码,调用 std::atomic<int> 的成员函数,最终这些函数会在 底层调用 CPU 提供的原子指令 来完成“读取-修改-写入”(Read-Modify-Write, RMW)操作。
并非所有 std::atomic<T> 对所有类型 T 都是无锁的!
std::atomic_flag vs std::atomic<T>有什么关系?
-
std::atomic_flag是 C++ 中唯一一个被标准要求必须无锁的原子类型,并且它也是最轻量级的原子类型。 -
而
std::atomic<T>对于基本类型(如 int、bool)在大多数平台上也是无锁的,但对于复杂类型可能不是。 -
所以,如果你需要绝对保证无锁,优先考虑使用
std::atomic_flag,或者确认std::atomic<T>.is_lock_free()为 true。
【必问】CAS是什么?
CAS(Compare-And-Swap,比较并交换) 是一种原子操作,用于实现多线程同步的底层机制。它是实现无锁编程(lock-free programming)的基石之一。
CAS 指的是:在多线程环境下,"比较某个内存位置的值与期望值,如果匹配,则将该值更新为新值;如果不匹配,则不修改值"。整个操作是原子的(不可中断的)。
C++操作CAS的函数是什么?
-
bool compare_exchange_weak(T& expected, T desired, std::memory_order order = std::memory_order_seq_cst) noexcept;
-
bool compare_exchange_strong(T& expected, T desired, std::memory_order order = std::memory_order_seq_cst) noexcept;
比较原子对象当前的值与 expected(传引用!),如果相等,则将原子对象的值设为 desired;否则,将原子对象当前的值赋给 expected。
(追问)compare_exchange_weak vs compare_exchange_strong有什么区别?
compare_exchange_weak:可能出现 虚假失败(spurious failure),即即使当前值等于 expected,也可能返回 false。
compare_exchange_strong:不会出现虚假失败,行为更加直观和可靠。
CAS在底层是如何实现的?
CAS 是由 CPU 硬件直接提供支持的原子指令,比如:CMPXCHG,Compare And Exchange,最经典的 CAS 指令。
【必问】如何使用CAS实现无锁栈/队列?
-
push(T value):将元素压入栈顶 -
pop():从栈顶弹出一个元素(返回std::optional<T>,栈空时返回std::nullopt)
我们将使用 std::atomic<Node*> 来管理栈顶指针,并通过 compare_exchange_strong 实现无锁同步。
Push 操作(入栈)
-
创建新节点
new_node -
把新节点的
next指向当前top -
使用 CAS 操作尝试将
top从旧值更新为新节点-
如果 CAS 成功,说明没有人修改过栈顶,入栈成功
-
如果 CAS 失败(别人已经改了
top),重试
-
Pop 操作(出栈)
-
读取当前
top指针 -
如果栈为空(
top == nullptr),返回std::nullopt -
否则,尝试用 CAS 将
top从当前节点更新为top->next-
如果成功,返回该节点的数据
-
如果失败(别人已经改了
top),重试
-
(追问)ABA问题是什么?
在 CAS 操作中,你检查某个值是 A,然后准备把它改为 B,但在你读取 A 和执行 CAS 之间:
-
某个线程将 A → C → 又改回了 A
虽然值看起来一样,但中间的状态已经改变,可能导致逻辑错误,尤其是在使用指针时非常危险。
(追问)如何解决ABA问题?
使用带标记的指针:在指针上附加一个计数器或版本号,每次修改都递增版本号,CAS 同时检查指针和版本号。
【必问】内存序是什么?有哪些内存序?
在多线程编程中,线程之间的操作可能会被编译器或 CPU 乱序执行(出于性能优化考虑),并且不同线程对共享数据的修改可能不会立即对其他线程可见。
C++ 提供了“内存序(Memory Order)”的概念,用来定义原子操作在多线程环境下的执行顺序约束,以及内存访问的可见性规则。
-
std::memory_order_seq_cst(顺序一致性)-
最严格、最安全 的内存顺序,所有线程都看到相同的顺序。
-
线程1有语句1、语句2,从线程2的视角来看,线程1一定是先执行语句1再执行语句2。
-
-
std::memory_order_relaxed(宽松顺序)-
只保证原子性,不保证顺序、不保证同步。
-
案例:线程1有语句1、语句2,但他们都是先写CPU cache再写内存,导致从线程2的视角来看,线程1先执行语句2再执行语句1。
-
-
std::memory_order_acquire(获取语义)-
用于 读操作(如 load)。
-
保证该读操作之后的所有读写操作不会被重排到它之前(其本质为立刻从内存加载到cpu cache再读,阻止优化)。
-
-
std::memory_order_release(释放语义)-
用于 写操作(如 store)
-
保证该写操作之前的所有读写操作不会被重排到它之后(其本质为立刻从cpu cache刷到内存,阻止优化)。
-
-
std::memory_order_acq_rel(获取-释放语义)-
用于 读-修改-写操作,比如
fetch_add,compare_exchange_strong。 -
相当于同时具有 acquire 和 release 的语义。保证该读操作之后的所有读写操作不会被重排到它之前,且保证该写操作之前的所有读写操作不会被重排到它之后。
-
volatile关键字对内存序有影响吗?
volatile 并不能保证多线程安全,也不能提供任何原子性、内存可见性或操作顺序的保证。
Ring Buffer是什么?
Ring Buffer(环形缓冲区,也叫循环缓冲区或循环队列) 是一种数据结构,通常用于在生产者-消费者模型中高效地管理固定大小的缓冲区。它是一个首尾相连的、固定大小的数组,通过两个指针(通常是读指针和写指针)来追踪数据的读取与写入位置。
Ring Buffer的工作原理是什么?
-
有一个固定大小的数组,以及两个索引:
-
head / read index(读指针):指向最早放入但尚未被读取的数据。
-
tail / write index(写指针):指向下一个可以写入数据的位置。
-
-
当写入数据时,写入到
tail指向的位置,然后tail向前移动; -
当读取数据时,从
head指向的位置读取,然后head向前移动; -
如果
tail == head,通常表示缓冲区为空; -
如果
(tail + 1) % size == head,表示缓冲区已满(有些实现允许覆盖,即循环覆盖最老的数据)。
5.3.5 死锁检测
【必问】说说你的死锁检测方法?
-
hook pthread_mutex_lock 和 pthread_mutex_unlock。
-
在 pthread_mutex_lock 之前调用 lock_before,之后调用 lock_after。
-
在 pthread_mutex_unlock 之后调用 unlock_after。
-
为锁构建一个图。
-
检测图是否成环,如果有环,则发生死锁。
你的hook是怎么做的?
使用 dlsym 获取函数地址,并加以替换。
为锁构建一个图,图的结构如何?
-
lock_address[LOCK_MAX]:锁地址的数组。
-
lock_owner[LOCK_MAX]:对应的持有线程。
-
adj_list[THREAD_MAX][THREAD_MAX]:邻接表,如果线程 A 在等锁 L,而锁 L 被线程 B 持有,则有一条边 A -> B。
-
adj_count[THREAD_MAX]:每个线程的出度。
在pthread_mutex_lock的lock_before里,你做了什么?
-
遍历已记录的锁,查找当前 lockaddr 是否已经被某个线程持有。
-
若找到了这个锁,记录对应的持有锁的线程。
-
例如:当前线程 id 正在等待锁,而锁被 owner 持有。
-
若已存在这条边,避免重复,就不添加了,否则设置有向边 id -> owner。
-
-
如果没找到锁,说明是第一次出现,稍后在 lock_after 中记录。
在pthread_mutex_lock的lock_after里,你做了什么?
-
记录一下,这个锁被当前线程持有。
在pthread_mutex_unlock的lock_after里,你做了什么?
-
找到这个 lockaddr 对应的记录,设找到这个锁之前是被某个线程 i 持有的。
-
若 i 就是当前线程,则删掉持有锁的记录,表示该锁目前没有持有者。
那这个图本身是否有线程安全问题?
不要在task_graph里加锁,不然会锁递归无限加锁。其实每个线程只会操作图的自己id下的数据,不会干涉其他线程,所以无需考虑线程安全问题。
如何检测图是否成环?
使用DFS检查图,沿途标记节点为1,若再次遇到标记为1的节点,证明有环,死锁。
5.4 性能分析
5.4.1 调试
你使用什么调试工具?gdb是什么?
GDB(GNU Debugger) 是 GNU 项目推出的一个功能强大的命令行调试工具,主要用于调试 C、C++ 等编译型语言编写的程序。
GDB 的主要功能包括:
-
启动程序并控制执行
-
可以设置程序在启动时带参数,或在特定条件下运行。
-
-
设置断点(Breakpoint)
-
在某一行代码、函数入口或特定条件处暂停程序执行,方便查看当前状态。
-
例如:
break main或break file.cpp:123
-
-
单步执行(Step Into / Step Over)
-
逐行执行代码,观察程序流程。
-
next(跳过函数内部) /step(进入函数内部)
-
-
查看变量值
-
可以打印当前作用域中的变量值,观察数据变化。
-
例如:
print variable_name
-
-
查看调用栈(Backtrace)
-
当程序崩溃或中断时,可以查看函数调用关系,定位问题发生的位置。
-
例如:
bt(backtrace)
-
-
查看内存、寄存器
-
高级功能,可查看某块内存内容或 CPU 寄存器的值。
-
【必问】gdb的多线程调试如何进行?
在GDB里调试多线程程序,主要就是围绕线程的查看、切换和控制来进行的。
首先,启动GDB调试时,如果你的程序是多线程的,比如用了pthread或者C++11的std::thread,GDB默认就能识别这些线程。你可以通过info threads命令查看当前所有的线程,它会列出每个线程的ID、状态以及当前执行的代码位置,一般主线程是线程1,其他线程按创建顺序编号。
如果你想切换到某个线程去调试,比如查看线程2的调用栈或者局部变量,可以用thread <线程ID>命令,比如thread 2,这样之后的所有操作,比如bt(查看堆栈)、print、step这些,就都是针对这个线程了。
调试多线程时一个常见的问题是线程之间互相干扰,比如一个线程断点停了,但其他线程还在跑,可能影响调试。你可以用set scheduler-locking on,这样只有当前线程会执行,其他线程都暂停,方便你专心调试某一线程;调试完后再用set scheduler-locking off恢复默认调度。
另外,你还可以给特定线程设置断点,比如只在某个线程里触发断点,可以用break <位置> thread <线程ID>,这样只有该线程运行到那个位置才会停住。
实际开发中,比如服务器程序,往往每个请求处理在一个独立线程里,用GDB调试时,我通常先info threads看看哪个线程可能在处理关键逻辑,然后thread切过去,配合bt和print分析状态,必要时锁定调度避免其他线程干扰。这些操作基本能覆盖大部分多线程调试场景。
5.4.2 单元测试
【必问】你使用什么测试工具?gtest是什么?
Google Test (gtest) 是一个用于 C++ 的 单元测试框架,它的特点包括:
-
支持 自动测试发现
-
提供丰富的 断言(assertions) 宏,用于验证逻辑
-
支持 死亡测试(death tests)、参数化测试、类型参数化测试
-
跨平台(支持 Linux、Windows、macOS 等)
-
易于集成到构建系统(如 CMake)
gtest测试文件里面一般包含哪些内容?
-
测试(Test)
一个测试是一个具体的检查点,用来验证某段代码的行为。在 gtest 中,测试是通过 TEST() 宏定义的。
-
测试套件(Test Suite)
一组相关的测试的集合。在 gtest 中,测试套件通过第一个参数指定,通常对应一个类或者模块。
-
断言(Assertions)
用于验证条件是否为真的宏,比如 EXPECT_EQ, ASSERT_TRUE 等。
-
EXPECT_*:如果失败,测试继续执行。 -
ASSERT_*:如果失败,测试中止。
-
测试程序入口
gtest 提供了统一的 main() 函数入口,一般你不需要自己写,使用 gtest_main 库即可。
5.4.3 进程性能分析
【必问】你使用什么性能分析工具?
-
valgrind
-
gprof
-
perf
valgrind是什么?
Valgrind 自身是一个工具运行框架,它包含多个不同的工具(tools),每个工具专注于解决不同的问题。最常用的工具包括:
-
Memcheck(最常用)-> 检测内存问题
-
Callgrind -> 函数调用分析与性能分析
-
Massif -> 堆内存使用分析
-
Cachegrind -> CPU 缓存性能分析
valgrind如何使用?
-
编译程序(务必加上 -g 选项,以获取行号信息)
-
valgrind加程序名即可运行,例如:
valgrind ./game.out。程序退出后就可以显示内存泄漏情况。加上--leak-check=full查看内存泄漏详细情况。
gprof是什么?
gprof(GNU Profiler)是 GNU 工具链中的一个性能分析工具,用于分析 C、C++ 等程序的执行时间分布,帮助开发者找出程序中的性能瓶颈(如哪些函数消耗了最多的 CPU 时间)。它是 GNU Binutils 的一部分,通常与 gcc 或 g++ 一起使用。
gprof如何使用?
-
编译程序时加上
-pg选项 -
运行程序,程序运行结束后,会自动生成
gmon.out文件(存储性能数据) -
使用 gprof 分析数据:
gprof my_program gmon.out > analysis.txt,生成的分析报告会保存在analysis.txt中,也可以直接查看终端输出 -
gprof 报告的内容:
-
扁平分析
-
调用图分析
-
perf是什么?
perf(全称 perf_events)是 Linux 内核 提供的一个强大的性能分析工具,用于分析 CPU 性能、函数调用、缓存命中率、分支预测、硬件事件等。它是 Linux 性能分析的核心工具,由内核开发者维护,比传统的 gprof 更底层、更高效,适用于 C/C++、Rust、Go 等 编译型语言的性能优化。
perf如何使用?
-
perf stat + 你的程序:统计程序性能 -
perf record+perf report:记录程序运行时的 函数调用栈,找出 CPU 占用最高的函数(类似gprof但更强大)-
perf record -g + 你的程序 -
perf report
-
-
perf top:实时查看 CPU 占用最高的函数 -
perf record -g -a + 你的程序:分析多线程程序 -
生成 FlameGraph(火焰图)
火焰图是什么?perf怎么做火焰图?
火焰图(Flame Graph)是一种性能分析的可视化图表,用于直观展示程序在运行时各个函数(或代码路径)所占用的 CPU 时间(或其他资源)的分布情况。
它是由 Brendan Gregg(Linux 性能优化大师,也是 perf、DTrace 等工具的布道者)提出并广泛推广的一种可视化分析方法。
-
sudo perf record -F 99 -g -- 你的程序:用 perf 采集数据(带调用栈) -
sudo perf script > out.perf:导出采样数据 -
./stackcollapse-perf.pl out.perf > out.folded./flamegraph.pl out.folded > flamegraph.svg:使用 FlameGraph 工具生成火焰图 -
google-chrome flamegraph.svg:用浏览器查看 SVG
valgrind vs gprof vs perf各自的优缺点?
valgrind:
-
优点:
-
功能强大:能检测多种内存错误
-
对代码零侵入:不需要修改源码即可检测问题。
-
广泛使用:尤其在开发阶段,是发现 C/C++ 内存问题的利器。
-
-
缺点:
-
运行速度极慢:Valgrind 是通过模拟执行(emulate)程序的方式来监控内存,因此程序运行速度可能慢 10~30 倍,不适合生产环境或性能测试。
-
无法分析性能瓶颈(如 CPU 热点):它主要面向内存问题,而不是 CPU 使用、算法效率等。
-
不支持多线程程序的完美分析:对多线程的支持有限,某些情况下会漏报或误报。
-
gprof:
-
优点:
-
简单易用:GCC 自带,只需在编译时加
-pg参数,运行后自动生成分析数据。 -
函数级分析:可以统计每个函数被调用的次数和耗时占比,帮助找出 CPU 热点函数。
-
轻量级:相比 Valgrind,运行开销小很多。
-
输出直观:生成
gmon.out,可用gprof命令解析,查看函数耗时排名。
-
-
缺点:
-
精度较低:基于采样+插桩的方式,不是精确计时,而是通过统计调用图和估算,数据较为粗糙。
-
不支持多线程:对多线程程序的分析支持非常有限,甚至可能出错。
-
无法分析调用栈细节:只能到函数级别,看不到函数内部的调用路径或热点代码行。
-
需要重新编译:必须使用
-pg编译,且分析期间可能影响程序执行逻辑。 -
较老旧:功能相对基础,不如现代工具(如 perf、Valgrind Callgrind)强大。
-
perf:
-
优点:
-
功能非常强大:Linux 原生支持,由内核直接提供,支持:
-
CPU 性能采样(热点函数、调用栈)
-
硬件性能计数器(如缓存命中率、分支预测、指令数等)
-
火焰图生成(结合 FlameGraph 工具)
-
多线程、多核分析
-
系统调用、上下文切换、中断等系统级事件分析
-
-
低开销:相比 Valgrind,perf 的运行开销小很多,适合生产环境或测试环境使用。
-
无需修改代码:可直接对已有的二进制程序进行采样分析。
-
-
缺点:
-
使用门槛略高:需要学习基本命令(如
perf record、perf script),并对输出数据有一定解读能力。
-
5.4.4 MySQL性能分析
MySQL有哪些性能分析工具(语句级)?
-
EXPLAIN/EXPLAIN ANALYZE:分析 SQL 查询的执行计划,查看是否使用了索引、表连接顺序、预估行数等。 -
SHOW PROFILE:显示 SQL 语句执行的各个阶段耗时(如 sending data、sorting result 等)。 -
SHOW STATUS/SHOW VARIABLES:查看 MySQL 服务器状态信息,如连接数、缓存命中率、临时表使用情况等。
MySQL有哪些性能分析工具(一般工具级)?
-
慢查询日志(Slow Query Log):记录执行时间超过设定阈值的 SQL,是分析慢查询最直接的途径。
-
MySQL Workbench(官方 GUI 工具):提供图形化的 性能报告、慢查询分析、连接监控 等功能。适合开发者和 DBA 快速查看数据库运行状态。
MySQL有哪些性能分析工具(企业级)?
-
Percona Toolkit:命令行工具集,用于分析、优化、诊断 MySQL。包括
pt-query-digest(分析慢查询日志神器!) -
Percona PMM:开源的 MySQL 监控平台,提供性能指标、慢查询、主从复制、InnoDB 状态等全面监控。适合生产环境,有 Web UI,功能非常强大。
-
Prometheus + Grafana + mysqld_exporter:通过
mysqld_exporter把 MySQL 的状态数据导出成 Prometheus 格式,再用 Grafana 做可视化监控。适合云原生和大规模架构下的监控体系。
5.4.5 Redis性能分析
Redis有哪些性能分析工具(语句级)?
-
redis-cli --latency/--latency-history:用于测试客户端到 Redis 服务器的 网络延迟。 -
redis-cli --stat:实时查看 Redis 的运行状态,如连接数、内存、命中率等。 -
SLOWLOG:Redis 提供了 慢查询日志功能,记录执行时间超过设定阈值的命令。
-
INFO 命令:最常用! 返回 Redis 运行的各种统计信息。
Redis有哪些性能分析工具(一般工具级)?
-
RedisInsight(官方 GUI 工具):Redis 官方推出的 免费性能分析 & 可视化工具。适合开发和运维人员使用,有桌面客户端。
-
redis-rdb-tools(分析 RDB 文件):是一个 Python 工具,可以分析 Redis 的 RDB 快照文件。适合离线分析,比如排查内存泄露或大对象。
Redis有哪些性能分析工具(企业级)?
-
Redis Live / Prometheus + Grafana + redis_exporter:redis_exporter将 Redis 的
INFO数据导出为 Prometheus 格式。Grafana:用于可视化监控 Redis 的内存、QPS、延迟、命中率等指标。适合线上环境大规模监控。
5.4.6 网络性能分析
有哪些网络I/O与带宽监控工具?
-
iftop:实时查看网卡的 实时流量(按带宽排序),类似 top 命令之于 CPU。 -
nload:简单直观地显示每个网卡的 实时流入/流出流量。 -
ip -s link/ip -s addr:查看网卡统计信息,如接收/发送的数据包数、错误包、丢包等。 -
vnstat:历史流量统计,记录每天的网络流量,适合长期监控。
有哪些Socket分析工具?
-
netstat(逐渐被 ss 取代):查看网络连接、路由表、接口统计、Socket 状态等。 -
ss(推荐,更快更强大):ss是netstat的现代替代品,来自 iproute2 工具集,速度更快,信息更全。 -
lsof:列出打开的文件,常用于查看 哪些进程打开了哪些网络端口。
有哪些数据包捕获与协议分析工具(抓包)?
-
tcpdump:抓取网络接口上的数据包,可以过滤特定协议、IP、端口。 -
Wireshark(GUI,强大分析工具):图形化抓包分析工具,支持几乎所有协议,能深入分析 TCP/IP 包内容、时序、重传、RTT 等。
有哪些延迟、路由与连通性检测工具?
-
ping:测试目标主机的 网络连通性与延迟(RTT)。 -
traceroute/tracepath:查看数据包到达目标主机的 路由路径,分析哪一跳可能存在高延迟或丢包。 -
mtr(My Traceroute):结合了ping+traceroute,持续监测到目标主机的网络路径与丢包率、延迟,非常实用。
5.5 项目构建
5.5.1 gcc
C++的编译器有哪些?
-
GCC(GNU Compiler Collection):Linux、Windows[通过MinGW或Cygwin]、macOS等
-
Clang / LLVM:最初由 Apple 主导开发
-
Microsoft Visual C++ (MSVC):Windows平台最主流的编译器,深度集成于 Visual Studio
gcc和g++的区别是什么?
gcc 和 g++ 都是 GNU 编译器集合(GNU Compiler Collection)中的命令行工具,但它们的用途不同:
-
gcc:C 语言编译器,也可以编译 C++ 文件,但不会自动链接 C++ 标准库
-
g++:C++ 语言编译器,可以编译 C++ 源码,并自动链接 C++ 标准库(自动链接 libstdc++)
编译过程中的.o文件是什么?
.o 文件是 C/C++ 编译过程中由编译器(如 gcc/g++)生成的 目标文件(Object File),它包含了某个源文件(如 .cpp 或 .c)经过预处理、编译后所生成的机器代码(但尚未链接)。
编译时错误 vs 链接时错误有什么区别?
-
编译时错误:源代码中存在 语法错误、类型错误、关键字拼写错误、缺少分号、未定义的变量或函数(在当前编译单元内) 等问题,导致 编译器无法理解或生成正确的代码。
-
链接时错误:虽然每个源文件都单独编译成功了(生成了 .o 文件),但在链接时发现:
-
调用了某个函数,但链接器找不到它的具体实现(即未定义引用)
-
使用了某个变量,但链接器找不到其定义
-
没有正确链接所需的库文件(如 C++ 标准库、第三方库等)
-
为什么需要分开编译后再链接?这样做的好处是什么?
-
提高编译效率(最重要的工程优势):只重新编译修改过的文件:当你的项目很大、有成百上千个源文件时,如果你修改了其中一个
.cpp文件(比如utils.cpp),你只需要重新编译这一个文件,再重新链接即可,不需要重新编译整个项目! -
代码模块化 & 清晰的职责分离:每个
.cpp文件通常对应一个或多个相关的功能模块,这种模块化让代码更清晰、更易于理解和维护,也方便团队协作开发(每人负责不同模块)。 -
代码复用与分离编译促进库的构建
【必问】静态库 vs 动态库有什么区别?
静态库:
-
文件扩展名(常见):
.a(Linux)、.lib(Windows) -
加载时机:编译链接时直接嵌入到可执行文件中
-
优点:运行时不依赖外部库,部署简单
-
缺点:可执行文件体积大;更新库需重新编译整个程序
动态库:
-
文件扩展名(常见):
.so(Linux)、.dll(Windows) -
加载时机:程序运行时才加载
-
优点:节省磁盘和内存(多个程序可共享)
-
缺点:运行时依赖库文件,部署复杂;可能出现版本兼容问题
编译过程中链接静态库如何写命令?链接动态库如何写命令?
静态库:g++ main.cpp -L. -lmath -o main
-
-L.表示链接器在当前目录(.)查找库文件 -
-lmath表示链接名为libmath.a的静态库,注意:去掉前缀lib和后缀.a
动态库:和编译静态库一样
编译过程中的O2优化和O3优化有什么区别?
-
-O0:默认级别(若不指定 -O),不进行优化,编译速度快,便于调试
-
-O1:基础优化,开启少量优化选项,提升性能的同时保持编译速度和调试性相对较好
-
-O2:推荐使用的优化级别,开启大量安全且有效的优化,显著提升性能
-
-O3:更激进的优化,开启所有 -O2 的优化 + 更多高级优化(如循环展开、向量化等),追求极限性能
在 GCC/G++ 中,-O2 是推荐使用的安全高效的优化级别,适合大多数生产环境;-O3 是更激进的优化级别,追求极限性能,但可能增加编译时间、代码体积或带来潜在风险,适用于对性能要求极高的场景。选择优化级别时需权衡性能、安全性与可维护性。
5.5.2 Makefile
Makefile是什么?
Makefile 是一个用于自动化构建(编译、链接等)程序的脚本文件,通常与 make 命令一起使用。它定义了如何从源代码生成可执行文件或库,并规定了文件之间的依赖关系,使得只有修改过的文件才会被重新编译,从而提高编译效率。
Makefile vs gcc有什么优势?
-
自动化编译:通过
make命令自动执行编译、链接等操作,避免手动输入复杂的编译命令。 -
增量编译:只重新编译修改过的文件及其依赖项,减少不必要的编译时间。
-
管理依赖关系:明确源文件(
.c/.cpp)、目标文件(.o)和最终可执行文件之间的关系。 -
跨平台支持:可以在不同的操作系统(Linux、macOS、Windows with MinGW/Cygwin)上使用。
Makefile中的伪目标是什么?
在 Makefile 中,伪目标(Phony Targets) 是指不生成实际文件的目标,它们只是用来执行某些操作(如清理、编译所有文件等)。
由于 make 默认认为目标是一个要生成的文件,如果伪目标的名字恰好和某个文件名相同,make 可能会误判,导致意外行为。因此,我们需要用 .PHONY 显式声明这些目标,告诉 make:“这个目标不生成文件,只是一个命令!”
5.5.3 CMake
CMake是什么?
CMake 是一个跨平台的自动化构建工具,用于管理软件项目的编译过程。它本身不直接编译代码,而是生成各种构建系统(如 Makefile、Visual Studio 项目、Xcode 项目等),然后由这些构建系统(如 make、MSVC)完成实际的编译工作。
-
跨平台构建:支持 Windows、Linux、macOS、Android、iOS 等多种操作系统。支持多种编译器(如
gcc、clang、MSVC)。 -
管理复杂的项目结构:支持多目录、多模块项目(如库 + 可执行文件)。自动处理依赖关系(如静态库、动态库的链接)。
-
替代手写 Makefile:比直接写 Makefile 更简单、更可维护(特别是大型项目)。避免手动管理编译命令、依赖关系(CMake 自动处理)。
CMake vs Makefile有什么优势?
-
跨平台支持:Makefile 虽然支持跨平台,但是 Makefile 需要为不同平台编写不同的规则。CMake 自动适配不同平台和编译器,只需写一次
CMakeLists.txt,就能生成。 -
更简单的多目录/多库管理:Makefile 需要手动管理多个目录的依赖关系(如
../lib/utils.o)。CMake 原生支持多目录、多库、多目标,只需add_subdirectory()和target_link_libraries()。 -
自动依赖管理:Makefile 需要手动指定库路径(如
-I/usr/include/opencv4)。CMake 提供find_package(),自动查找并链接第三方库(如 OpenCV、Boost、Qt)。
CMake链接动态库,需要注意什么?
在cmake中指定要链接的动态库的时候,应该将命令写到生成了可执行文件之后!
CMake可以嵌套吗?
如果项目很大,或者项目中有很多的源码目录,在通过CMake管理项目的时候如果只使用一个CMakeLists.txt,那么这个文件相对会比较复杂,有一种化繁为简的方式就是给每个源码目录都添加一个CMakeLists.txt文件(头文件目录不需要),这样每个文件都不会太复杂,而且更灵活,更容易维护。
-
根节点CMakeLists.txt中的变量全局有效
-
父节点CMakeLists.txt中的变量可以在子节点中使用
-
子节点CMakeLists.txt中的变量只能在当前节点中使用
5.5.4 git
git是什么?
Git 是一个分布式版本控制系统(Distributed Version Control System, DVCS),用于跟踪和管理代码的变更历史,让开发者能够:
-
记录代码的每一次修改(谁改了什么、什么时候改的)。
-
回退到之前的版本(撤销错误修改)。
-
多人协作开发(合并不同人的代码)。
-
分支管理(并行开发不同功能)。
git提交代码的极简流程?
-
git init
-
git add <file>
-
git commit -m "描述这次修改"
-
git remote add origin <url>
-
git push -u origin main
git如何撤回已push的代码?
第一种情况,如果你只是想撤销最近的提交,但保留本地修改,可以用 git reset --soft HEAD~1 回退到上一个提交,然后 git push origin <branch> --force 或者 git push origin <branch> --force-with-lease 强制推上去覆盖远程。注意,强制推送会影响其他协作者,所以最好提前协调好。
第二种情况,如果你想彻底删除某次提交,也可以用 git rebase -i HEAD~n,进入交互式变基,把想要删除的提交前的 pick 改成 drop,保存退出后再强制推送。
git的rebase是什么?什么场景下会使用这个操作?
Rebase(变基) 是 Git 的一个操作,用于 修改提交历史,它的核心思想是:“把当前分支的提交,重新应用到另一个分支的最新提交之上”,从而使提交历史更线性、更清晰。
Rebase 的典型使用场景:
-
整理本地提交历史(推荐)
-
保持提交历史线性(避免 Merge Commit)
git rebase vs git merge有什么不同?
什么时候用 Rebase?
✅ 个人分支整理提交历史(如合并多个 commit)。
✅ 本地分支同步远程最新代码(避免合并提交)。
✅ 让提交历史变成一条直线(更易读)。
什么时候不用 Rebase?
❌ 公共分支(如 main/dev)(避免重写历史影响团队)。 ❌ 已经推送到远程的提交(除非你确定影响范围)。
git stash是什么?
Git Stash(储藏) 是 Git 的一个功能,用于 临时保存当前工作目录和暂存区的未提交修改,让你可以 切换分支或执行其他操作(如拉取最新代码),而不会丢失当前的修改。
-
保存未提交的修改(包括 已
git add的暂存区修改 和 未git add的工作区修改)。 -
临时清理工作区,让你可以 切换分支、拉取代码、修复紧急 Bug 等。
-
稍后恢复这些修改,继续之前的工作。
git cherry-pick是做什么的?
Git Cherry-Pick(拣选提交) 是 Git 的一个功能,用于 将某个分支上的 特定提交(commit) 应用到当前分支,而 不需要合并整个分支。
-
只挑选某个分支的某一个(或几个)提交,复制到当前分支。
-
不会合并整个分支的历史,只引入你需要的修改。
-
常用于修复 Bug、同步关键提交、跨分支复用代码。
3万+

被折叠的 条评论
为什么被折叠?



