第一阶段:编译连接过程
一个.c/.cpp文件变为可执行文件,需要经历4个阶段
1. 预处理 (Preprocessing)
-
动作:
- 展开宏定义:把所有
#define替换为具体的值或表达式 - 包含头文件 :把
#include指向的内容完整地复制到当前文件中。 - 处理条件编译 :根据
#if、#ifdef决定保留 - 删除注释:去掉所有的
//和/* */ - 添加行号和文件名标识:方便编译出错时显示行号。
- 展开宏定义:把所有
-
标记点:生成 .i 文件。此时代码依然是 C/C++ 源码。
2. 编译 (Compilation) ------ 核心逻辑
编译器将高级语言转化为汇编语言
- 动作 :
- 词法/语法分析:检查代码是否有语法错误,生成抽象语法树
- 语义分析:检查类型是否匹配、变量是否声明。
- 中间代码生成 (IR):生成一种平台无关的中间代码,方便进行跨平台优化。
- 代码优化:比如把 a = 1 + 2 直接优化成 a = 3。
- 生成汇编代码:将代码翻译成特定 CPU 架构(如 x86, ARM)的汇编指令。
- 静态变量标记:编译器在此阶段识别出 static 变量和全局变量,并在符号表中记录它们的属性(可见性、初始值等)。
- 静态变量的处理 :
- 编译器扫描到 static 变量时,会将其放入符号表 (Symbol Table)。
- 标记 (Marking):根据是否初始化,将其标记为进入 .data 段(已初始化)或 .bss 段(未初始化)。
- 作用域标记:如果是在函数内部的 static,符号会被"改名"(Name Mangling),确保全局唯一但链接时仅对本文件或本函数逻辑可见。
- 生成:
- 生成汇编代码文件(以 .s 结尾)
- 特点:人类依然能读懂,但已经变成了底层指令。
3. 汇编 (Assembly)
将汇编指令翻译成机器码。
-
动作 :将汇编代码翻译成机器指令,生成可重定位目标文件 (.o)。
- 指令转换:将汇编助记符(如 mov, push)翻译成二进制机器指令。
- 生成段信息:将数据划分到不同的段里(如 .text 存指令,.data 存已初始化数据)。
- 生成重定位表:对于当前文件里找不到地址的函数(如调用了 printf),先记录下来,留给链接器处理。
-
标记点:生成具体的段(Section),如 .text, .data, .bss。
4. 链接 (Linking) ------ 地址合并
-
动作:
-
符号解析:将代码中的函数调用、变量引用与具体的地址关联起来。
-
地址重定位 (Relocation):将多个 .o 文件合并。由于每个 .o 文件都假设自己从地址 0 开始,链接器负责给它们分配最终的虚拟地址。 修正代码中的跳转指令,让它们指向正确的地址。
-
库链接:加入静态库或指定动态库的跳转信息。
-
静态变量标记:链接器最后确定静态变量在整个可执行文件中的偏移量。
-
-
生成:
可执行目标文件(Linux:
a.outWindows:.exe),可以直接被操作系统加载运行
第二阶段:可执行文件的内存布局
当可执行程序加载到内存(进程空间)时,其布局如下(从高地址到低地址):
-
内核空间:用户不可访问。
-
栈区 (Stack):存储局部变量、函数参数、返回地址。从高地址向低地址增长。
-
共享内存/文件映射区:动态链接库(.so)载入的位置,mmap 申请的大块内存也在此。
-
堆区 (Heap):malloc/new 申请的地方。从低地址向高地址增长。
-
BSS 段 :存储未初始化(或初始化为0)的全局变量和静态变量。不占用磁盘空间,只在程序运行时由内核清零。
-
Data 段 :存储已初始化的全局变量和静态变量。
-
Text (Code) 段:只读,存储机器指令和常量(如 rodata 字符串常量)。
内存分配
分配内存的行为并不是在一个瞬间完成的,而是分成了"规划"、"预留"和"实际交付"**三个阶段。
编译链接阶段:规划布局(磁盘上)
在你的代码变成二进制可执行文件(如 ELF 文件)时,并没有分配真正的 RAM。
- 动作 :链接器(Linker)根据符号表,计算出每个段(
.text,.data,.bss)的大小。 - 结果 :它在 ELF 文件的**程序头表(Program Header Table)**中记录了:"如果这个程序运行,它需要多少内存,这些内存应该映射到虚拟空间的哪个地址"。
- 此时的状态:内存只是纸上的"蓝图",不占用任何 RAM。
加载阶段(execve):预留空间(虚拟内存)
当你双击运行程序或在 Shell 输入 ./app 时,操作系统内核介入。
- 系统调用 :内核调用
execve。 - 动作 :
- 读取 ELF 头:查看程序需要多少内存。
- 创建虚拟内存区域(VMA) :内核在进程的虚拟地址空间里"画地盘"。比如,地址
0x400000到0x401000属于代码段。 - 映射文件:建立虚拟地址和磁盘文件之间的映射关系(并不把文件内容读入内存)。
- 此时的状态 :操作系统只是在账本上记了一笔:"这块虚拟地址已经分给这个进程了"。此时依然没有分配真正的物理 RAM。
运行阶段:实际交付(物理内存 - 缺页中断)
这是最关键的一步,采用了**延迟分配(Lazy Allocation)策略。
- 触发点:当 CPU 执行第一条指令,去访问某个虚拟地址时。
- 过程 :
- 缺页中断(Page Fault):CPU 发现该虚拟地址对应的物理内存页(Page Frame)并不存在。
- 陷入内核:CPU 暂停程序,把控制权交给内核。
- 分配物理页:内核查找空闲的物理内存,拨出一个 4KB 的页。
- 填充数据 :
- 如果是 代码段或数据段:内核从磁盘文件中把对应的 4KB 内容读入这个物理页。
- 如果是 BSS 段(未初始化变量) :内核直接把这个物理页清零。
- 更新页表:建立虚拟地址到这个物理页的映射。
- 此时的状态:程序恢复运行,真正的物理内存(RAM)被占用了。
针对不同区域的内存分配时机总结
| 内存区域 | 什么时候分配虚拟地址? | 什么时候分配物理内存? |
|---|---|---|
| 代码段 (.text) | 进程启动加载时 (mmap) | 第一次执行到该页代码时 |
| 已初始化数据 (.data) | 进程启动加载时 (mmap) | 第一次读写该全局/静态变量时 |
| 未初始化数据 (.bss) | 进程启动加载时 (mmap) | 第一次读写该变量时(内核给个全0页) |
| 栈区 (Stack) | 进程创建时预留,随函数调用增长 | 访问超过当前已分配页的地址时 |
| 堆区 (Heap) | 显式调用 malloc / brk 时 |
malloc 返回后,第一次往指针里写数据时 |
补充:如果是一个普通文件(不是可执行文件)
如果你是写代码打开一个普通文件(fopen/read):
- 内核页缓存 (Page Cache) :当你调用
read时,内核先分配一块内存(Page Cache),把文件从磁盘读到这块内存里。 - 用户空间拷贝 :然后再把这块内存里的数据拷贝到你定义的数组(
char buf[])里。 - mmap 方式 :如果你用
mmap系统调用,则跳过拷贝,直接让你的指针指向内核的 Page Cache。此时分配时机也是 "第一次访问指针触发缺页中断" 时。
第三阶段:Malloc 与 堆区深度拆解
1. 堆区的本质
在进程的虚拟内存中,堆区位于 BSS 段之上,共享映射区(Stack 和 Heap 之间)之下。
- 增长方向:堆区是从低地址向高地址增长的。
- 管理权 :内核只负责拨地(通过 brk 或 mmap),而具体这块地怎么分(分给哪个 malloc 调用),由用户态的内存管理器(如 glibc 的 ptmalloc)负责。
- 治理边界 :堆区顶端有一个指针叫 program break(简称 brk)。移动这个指针,堆区就会变大或变小。
2. Malloc 的底层:ptmalloc
malloc 不是系统调用,而是 C 库(glibc)提供的内存管理器。它维护着一个内存池,防止频繁进入内核。
-
Chunks (内存块):
-
每一个被 malloc 分配出来的块,在内存里其实比你申请的 size 大一点。它包含两部分:
-
Metadata(元数据/头部):记录这个块的大小、前一个块是否空闲等信息。
-
User Data(用户数据):malloc 返回给你的指针指向这里。
-
关键点: 为什么 free(ptr) 不需要传大小?
因为 free 会把指针 ptr 往前偏移几个字节,读取 Metadata,里面记录了整个块的大小。
-
-
Bins (回收站):
-
为了防止频繁调用系统调用,malloc 维护了一系列链表,称为 Bins,用来管理掉被 free 掉的空闲块:
-
Fast Bins:存放小块内存(约 < 80字节),先进后出(LIFO),速度极快。
-
Unsorted Bin:最近释放的块先扔这,作为缓存。
-
Small Bins / Large Bins:按大小分类存放,方便寻找最合适的空闲块。
-
3. 内核底层机制:brk vs mmap
当 malloc 发现内存池不够用时,会通过系统调用向内核申请:
- brk/sbrk :针对小块内存 (通常 < 128KB)。通过移动堆顶指针 _edata 来增加堆空间。
- 碎片问题:如果堆顶有一个块没释放,下面的块即便 free 了,整个堆也不会缩减(内存没还给内核),导致"内存空洞"。
- mmap :针对大块内存 (通常 > 128KB)。直接在堆和栈中间的"共享映射区"找一块空闲虚拟内存,映射一个 匿名页 。
- free 时会立刻解除映射,把内存还给内核。
- 每次分配都会触发内核系统调用,效率稍低。
3. 关于 malloc(0) 与 realloc(ptr, 0)
- malloc(0):在标准中是行为未定义的。大多数实现(如 glibc)会返回一个最小分配单元(如 16 字节)的有效指针,虽然你不能存数据,但它是唯一的且可以被 free。
- realloc(ptr, 0):如果 realloc 的第二个参数为 0,效果等同于 free(ptr),并返回 NULL。
第四阶段:C++ 中的三种 New
-
new operator (new 表达式):
- 我们在代码里写的 A* p = new A();
- 步骤:调用 operator new 分配内存 -> 调用构造函数 -> 返回指针。
-
operator new (函数):
- 底层就是调用 malloc。可以被重载,用于实现自定义内存池。
-
placement new (定位 new):
- 语法:new (ptr) A();
- 特点 :不在堆上分配新内存,而是在已有的内存地址 ptr 上调用构造函数。常用于高性能场景或硬件驱动开发。
第五阶段:内存泄漏与解决
1. 产生原因
-
malloc/free 不配对。
-
类中有指针成员,但没有写析构函数或析构函数不是虚函数。
-
new\[\] 和 delete 混用(导致只释放了第一个对象)。
2. 解决方法
- RAII 思想:对象生命周期管理资源(构造时拿,析构时放)。
- 智能指针 (Smart Pointers) :
- unique_ptr:所有权唯一,离开作用域自动释放。
- shared_ptr:引用计数,计数归零时释放。
- 工具检测 :
- Valgrind:最强大的动态检测工具。执行 valgrind --leak-check=full ./app。
- AddressSanitizer (ASan):编译器内置工具,运行时如果发生越界或泄漏会立刻报错。
总结图:从代码到内核
| 层次 | 核心概念 | 静态变量位置/标记 | 内存分配方式 |
| 源码层 | C/C++ 代码 | static 关键字 | new / malloc |
| 编译层 | 符号表 / 段标记 | .data (有初值) / .bss (无初值) | 逻辑上的堆/栈分配 |
| 链接层 | 重定位 / 合并段 | 符号合并,分配最终虚拟地址 | - |
| 运行层 | 虚拟地址空间 | 映射至物理内存 | Heap (brk/mmap) |
| 内核层 | 页表 / VMA | 物理页面分配 | brk 指针移动 / 匿名映射 |