程序的诞生

第一阶段:编译连接过程

一个.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.out Windows:.exe),可以直接被操作系统加载运行

第二阶段:可执行文件的内存布局

当可执行程序加载到内存(进程空间)时,其布局如下(从高地址到低地址):

  1. 内核空间:用户不可访问。

  2. 栈区 (Stack):存储局部变量、函数参数、返回地址。从高地址向低地址增长。

  3. 共享内存/文件映射区:动态链接库(.so)载入的位置,mmap 申请的大块内存也在此。

  4. 堆区 (Heap):malloc/new 申请的地方。从低地址向高地址增长。

  5. BSS 段 :存储未初始化(或初始化为0)的全局变量和静态变量。不占用磁盘空间,只在程序运行时由内核清零。

  6. Data 段 :存储已初始化的全局变量和静态变量。

  7. Text (Code) 段:只读,存储机器指令和常量(如 rodata 字符串常量)。

内存分配

分配内存的行为并不是在一个瞬间完成的,而是分成了"规划"、"预留"和"实际交付"**三个阶段。

编译链接阶段:规划布局(磁盘上)

在你的代码变成二进制可执行文件(如 ELF 文件)时,并没有分配真正的 RAM。

  • 动作 :链接器(Linker)根据符号表,计算出每个段(.text, .data, .bss)的大小。
  • 结果 :它在 ELF 文件的**程序头表(Program Header Table)**中记录了:"如果这个程序运行,它需要多少内存,这些内存应该映射到虚拟空间的哪个地址"。
  • 此时的状态:内存只是纸上的"蓝图",不占用任何 RAM。
加载阶段(execve):预留空间(虚拟内存)

当你双击运行程序或在 Shell 输入 ./app 时,操作系统内核介入。

  • 系统调用 :内核调用 execve
  • 动作
    1. 读取 ELF 头:查看程序需要多少内存。
    2. 创建虚拟内存区域(VMA) :内核在进程的虚拟地址空间里"画地盘"。比如,地址 0x4000000x401000 属于代码段。
    3. 映射文件:建立虚拟地址和磁盘文件之间的映射关系(并不把文件内容读入内存)。
  • 此时的状态 :操作系统只是在账本上记了一笔:"这块虚拟地址已经分给这个进程了"。此时依然没有分配真正的物理 RAM
运行阶段:实际交付(物理内存 - 缺页中断)

这是最关键的一步,采用了**延迟分配(Lazy Allocation)策略。

  • 触发点:当 CPU 执行第一条指令,去访问某个虚拟地址时。
  • 过程
    1. 缺页中断(Page Fault):CPU 发现该虚拟地址对应的物理内存页(Page Frame)并不存在。
    2. 陷入内核:CPU 暂停程序,把控制权交给内核。
    3. 分配物理页:内核查找空闲的物理内存,拨出一个 4KB 的页。
    4. 填充数据
      • 如果是 代码段或数据段:内核从磁盘文件中把对应的 4KB 内容读入这个物理页。
      • 如果是 BSS 段(未初始化变量) :内核直接把这个物理页清零
    5. 更新页表:建立虚拟地址到这个物理页的映射。
  • 此时的状态:程序恢复运行,真正的物理内存(RAM)被占用了。
针对不同区域的内存分配时机总结
内存区域 什么时候分配虚拟地址? 什么时候分配物理内存?
代码段 (.text) 进程启动加载时 (mmap) 第一次执行到该页代码时
已初始化数据 (.data) 进程启动加载时 (mmap) 第一次读写该全局/静态变量时
未初始化数据 (.bss) 进程启动加载时 (mmap) 第一次读写该变量时(内核给个全0页)
栈区 (Stack) 进程创建时预留,随函数调用增长 访问超过当前已分配页的地址时
堆区 (Heap) 显式调用 malloc / brk malloc 返回后,第一次往指针里写数据时
补充:如果是一个普通文件(不是可执行文件)

如果你是写代码打开一个普通文件(fopen/read):

  1. 内核页缓存 (Page Cache) :当你调用 read 时,内核先分配一块内存(Page Cache),把文件从磁盘读到这块内存里。
  2. 用户空间拷贝 :然后再把这块内存里的数据拷贝到你定义的数组(char buf[])里。
  3. mmap 方式 :如果你用 mmap 系统调用,则跳过拷贝,直接让你的指针指向内核的 Page Cache。此时分配时机也是 "第一次访问指针触发缺页中断" 时。

第三阶段:Malloc 与 堆区深度拆解

1. 堆区的本质

在进程的虚拟内存中,堆区位于 BSS 段之上,共享映射区(Stack 和 Heap 之间)之下

  1. 增长方向:堆区是从低地址向高地址增长的。
  2. 管理权 :内核只负责拨地(通过 brk 或 mmap),而具体这块地怎么分(分给哪个 malloc 调用),由用户态的内存管理器(如 glibc 的 ptmalloc)负责。
  3. 治理边界 :堆区顶端有一个指针叫 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

  1. new operator (new 表达式)

    • 我们在代码里写的 A* p = new A();
    • 步骤:调用 operator new 分配内存 -> 调用构造函数 -> 返回指针。
  2. operator new (函数)

    • 底层就是调用 malloc。可以被重载,用于实现自定义内存池。
  3. 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 指针移动 / 匿名映射
相关推荐
basketball6161 小时前
C++ static_cast 完全解析
开发语言·c++
Lumbrologist1 小时前
【C++】零基础入门 · 第 12 节:模板与 STL 入门
开发语言·c++
wanghu20242 小时前
ABC460_E题题解
c++·算法
智者知已应修善业3 小时前
【51单片机象棋快棋赛 电子裁判器】2023-12-27
c++·经验分享·笔记·算法·51单片机
晚风予卿云月3 小时前
二分算法练习
数据结构·c++·算法·竞赛·算法随笔
lilili也3 小时前
C++:文件操作
c++
Lhan.zzZ3 小时前
C++多线程——std::thread与condition_variable形象理解
c++
头歌实践平台3 小时前
C++面向对象 - 运算符重载的应用
开发语言·c++·算法
思麟呀3 小时前
C++11并发编程:互斥锁
linux·开发语言·c++·windows