【C++ 内存申请】从 C++ new 到内核:虚拟内存、VMA 与内存泄漏的全链路解析

【C++ 内存申请】从 C++ new 到内核:虚拟内存、VMA 与内存泄漏的全链路解析

目录标题


在这里插入图片描述

1. 从 C++ new 到物理内存:堆、虚拟内存和 VMA 究竟发生了什么

很多人第一次听到“new 只是申请虚拟内存、物理内存在首次访问时才分配”都会有点抽象感。正如一些认知心理学研究常说的那样,我们的大脑很容易把“调用时刻”和“真正发生的时刻”混成一件事,这恰好就是理解内存管理的第一层障碍。

本章从三个视角拆开:C++ 运行库 → 操作系统 → 硬件 MMU,把“第一次访问新堆区时系统到底做了什么”讲清楚。


1.1 C++ 视角:new / malloc 并不等于系统调用

在 C++ 代码里,你看到的是:

int* p =newint[1000];

直觉会以为“马上向操作系统申请了一块物理内存”,但实际上更接近:

  • C++ 运行库(malloc 实现)维护一个 堆池
    • 已经向 OS 申请过的一些 大块虚拟地址区间
    • 内部有 free list、元数据等管理结构。
  • 小额分配:
    • 只是在堆池里切一小块,更新元数据;
    • 不一定发生任何系统调用。
  • 只有堆池不够时:
    • 才会通过 brk/sbrkmmap 向 OS 要新的虚拟地址范围。

所以从 C++ 层面记住一句话就够:

new / malloc 返回的只是“一块属于这个进程的虚拟地址”,
至于物理内存什么时候真正到位,还得看 OS 的策略(通常是按需分配)。

1.2 OS 视角:VMA、页表和按需分配(demand paging)

操作系统不会一页一页地“记账”,而是按区间管理地址空间,这就是 VMA(Virtual Memory Area,虚拟内存区域) 的概念:

  • 每个 VMA 描述一段连续虚拟地址:
    • 起止地址:start ~ end
    • 权限:读/写/执行
    • 类型:匿名内存(堆、栈)、文件映射(mmap 文件)等
  • 进程的所有 VMA 构成其虚拟地址空间布局:
    • 代码段、数据段、堆、栈、共享库映射区等。

当 C 运行库向 OS 申请堆空间时(例如通过 mmap):

  1. 通常不会当场为每一页分配物理页
    • 页表项可以是“未驻留”状态;
    • 真正访问时再触发缺页处理(demand paging)。

内核创建/扩展一个 VMA,表示:

“从 X 到 Y 这一段虚拟地址,可以读写,属于这个进程的匿名内存(堆)”

这就解释了为什么“只申请不访问再立即释放,有可能没有任何物理页被真正分配”:
你只是多了一条 VMA 记录,然后又删掉了。


1.3 硬件视角:第一次访问堆区、page fault 和 MMU 流程

当你第一次写入一个新堆区:

int* p =newint[1000]; p[0]=42;// 很可能在这里触发该页的第一次 page fault

可能发生的流程是:

  1. CPU 执行 p[0] = 42
    • 生成一个虚拟地址 VA。
  2. MMU 查 TLB:
    • 没有这个 VA 的缓存 → 继续查页表。
  3. 查页表:
    • 发现虚拟页还没有对应物理页(标记为“未驻留”或根本没有 PTE);
    • 触发 page fault 异常,陷入内核。
  4. 内核 page fault handler:
    • 查 VMA:确认 VA 落在某个合法、可写的 VMA 中;
    • 分配一个物理页;
    • 建立 VA → PA 的页表映射;
    • 更新 TLB。
  5. 返回用户态,重新执行这条写操作:
    • 这次变成一次普通的内存写入。

如果在这之后代码因为其他 bug 崩溃(除 0、别的非法访问、abort 等),
也只是这个进程被终止,OS 会在回收阶段把为它分配的物理页连同页表一并清理掉,不会“污染系统内存管理”。


1.4 难点对比:VMA / 页表 / 虚拟地址 / 物理页

这些概念容易混,所以用一张表来多角度对比一下:

概念粒度谁管理的?作用典型数量级
虚拟地址字节/指令级别硬件 + OS程序看到的“地址”;cpu发出的地址每条 load/store 都用
页(虚拟页)通常 4KB/2MB硬件 + OS页表映射的基本单位每进程成千上万
VMA多页组成的区间OS(内核)描述一段连续虚拟地址的属性每进程几十到几百
页表项(PTE)针对某一虚拟页OS 填、MMU 使用将虚拟页映射到物理页或磁盘每页一个,数量巨大
物理页物理内存中的页OS真正存放数据、指令的地方受物理内存大小限制

记忆窍门:VMA 是“段”的描述,页表是“页”的映射,而程序只看到“地址”。


2. 销毁与并发:free / munmap、线程和页表更新

在理解了“分配”的故事之后,另一个很自然的问题是:“如果在第一次访问时,另一个线程刚好释放了这块内存,会不会把 OS 搞乱?”

这里需要严格区分三个层次:

  • C++ 对象生命周期(new / delete
  • 分配器行为(malloc / free 与内部堆池)
  • OS 地址空间操作(munmap、缩堆、VMA/页表/TLB)

心理学上有个有趣的说法:我们对“同时发生”的事往往会脑补出各种诡异的中间状态,其实在操作系统里,大部分关键操作都被设计成“要么成功、要么失败”的原子效果,不给你“半截状态”的机会。


2.1 C++ 语义:一旦 free / delete,并发访问就是 UB

先看标准层面:

int* p =newint[100]; std::thread t1([&]{ p[0]=1;});// 线程 A std::thread t2([&]{delete[] p;});// 线程 B

没有任何同步的前提下:

  • 线程 A 读写 p 指向的对象;
  • 线程 B 调用 delete[] p 结束对象生命周期。

标准立刻把这归为未定义行为(Undefined Behavior)

  • 不关心 OS 会不会已经回收物理页;
  • 不关心 page fault 先发生还是 delete 先发生;
  • 只要对象已经被销毁,任何读写都是非法的。

也就是说,从 C++ 的世界观里,这个问题已经判死刑了,你不能靠“OS 恰好怎么做”来指望它“刚好工作”。


2.2 分配器视角:free 不等于 munmap

现实里,多数 free 做的是:

  • 更新用户态堆管理元数据;
  • 把这块区域挂回 free list;
  • 通常不立刻调用 munmap 或缩堆(除非这块非常大,或实现有特殊策略)。

这意味着:

  • 虚拟地址映射(VMA + 页表)还在;
  • 物理页还在;
  • OS 完全不知道这块内存已经“逻辑上被 C++ 回收”。

如果此时另一个线程继续访问这块地址:

  • 从 OS / 硬件视角看:就是普通合法内存访问;
  • 真正的危险来自 C++ 层面
    • 这些数据可能已经被分配器复用了;
    • 下一次 malloc 可能会把同一块区域分配给别的对象;
    • 于是你在用一块“已被重用、类型不匹配”的内存——典型 use-after-free/双重释放风险。

总结一句:大部分内存 bug,其实死在用户态逻辑上,而不是 OS 地址空间管理上。


2.3 OS 视角:munmap、VMA 更新和 TLB shootdown

只有在以下情况时,才真正会影响 VMA / 页表:

  • free 内部针对大块分配调用 munmap
  • 手动调用 munmap(addr, len)
  • 进程退出时内核清理整个地址空间。

munmap 为例,内核大致会做:

  1. 获取 mmap 相关锁(保证 VMA/页表修改的互斥性)。
  2. 在该进程的 VMA 树中查找并删除/拆分对应区间。
  3. 遍历对应页表项,释放物理页,清理 PTE。
  4. 对相关虚拟地址范围做 TLB shootdown
    • 当前 CPU 清空对应 TLB 条目;
    • 向运行该进程的其他 CPU 发送 IPI,令其同步清空;
    • 等确认刷新完成后才继续。
  5. 系统调用返回。

因此:

一旦 munmap 返回,对该进程所有线程而言,
这一段地址就不再有有效的映射——任何访问要么 SIGSEGV,要么已经被别的映射重用,不会存在“某个线程还在看旧页表”的合法状态。

2.4 场景总结:访问 vs 释放 vs 进程退出

难点其实在于很多行为混在一起,我们用表总结一下不同层面的“销毁”会有什么效果:

行为发生在谁那一层是否改变 VMA/页表是否回收物理页是否影响其他进程
delete / free 小块C++ 运行库(用户态)
free 导致大块 munmapC++ 运行库 + OS是(删 VMA、清 PTE)
munmap 文件映射/匿名区OS(内核)视情况回收或写回磁盘
进程正常退出/崩溃OS(内核)是(全清)是(全部回收)只释放资源,不破坏其他

记忆要点:

  • 只要进程还活着,内存泄漏/并发错误会影响这个进程及系统负载
  • 一旦进程退出,OS 会把它的地址空间“整块切掉”,不会留残骸给其它进程

3. 内存泄漏与系统稳定性:进程级泄漏 vs 内核级泄漏

谈完分配、销毁和并发后,最后一个常见问题就是你刚问的:

“进程内存泄漏是不是关闭进程就能恢复,会不会对系统有长期影响?”

这就进入了错误分类实际危害的层面。就像心理学里常说的那句:“真正危险的不是你看到的伤口,而是你没意识到的感染”,很多人把“进程内堆泄漏”和“系统长期吃掉内存”混在一起,其实它们是两类问题。


3.1 普通进程内存泄漏:进程退出后资源都会回收

典型代码:

voidfoo(){int* p =newint[1000];// 忘了 delete[]}// p 丢失,这 1000 个 int 永远不会再被这个进程使用

如果进程持续运行:

  • 堆会不断膨胀,消耗物理内存/交换空间;
  • 系统可能变慢(频繁换页)、甚至触发 OOM Killer。

只要进程退出(无论正常退出、崩溃,还是被 kill):

  • OS 回收该进程所有 VMA;
  • 释放所有物理页、页表等数据结构;
  • 文件描述符、socket、管道等也会被内核关闭。

所以结论非常明确:

✅ 普通意义上的“进程内堆泄漏”,在进程退出后不会长期占用系统内存,
影响只存在于进程存活期间。

但这并不意味着可以“不管泄漏”,因为:

  • 长时间运行的服务进程可能永远不重启;
  • 泄漏会逐渐拖垮系统,影响其它服务;
  • 线上环境需要的是稳定长期运行而非“崩了再说”。

3.2 泄漏真的有多危险:性能、OOM 与系统级影响

在进程存活期间,泄漏的危害包括:

  • 自己变慢
    • 堆越来越大,缓存命中率下降;
    • GC 型语言(如果用的话)扫描成本上升。
  • 拖累全系统
    • 占据大量物理内存,其他进程被迫频繁换页;
    • page cache 被挤掉,I/O 性能变差。
  • 触发 OOM
    • 当物理内存和 swap 都吃紧,内核可能启动 OOM Killer;
    • 挑选“看起来占内存多/优先级低”的进程杀掉;
    • 可能是你,也可能是完全不相干的另一个服务。

因此,工程实践中 查泄漏 的目标不是“释放退出后的内存”,而是:

  • 避免长时间运行的服务压力积累;
  • 减少尾部延迟、系统抖动;
  • 提升整体可靠性。

3.3 真正会“关了进程还占内存”的:内核泄漏与驱动问题

只有一种情况会导致“进程死了很久,系统可用内存却越来越少”:

🧨 内核态/驱动代码泄漏了内存或其他非进程关联资源

一些可能的例子:

  • 内核模块在处理系统调用时分配了内存,却没有在错误路径释放;
  • 驱动为某个设备分配 DMA 缓冲区、pinned 内存,没有按 refcount 归还;
  • 文件系统、网络栈的内核缓存管理有 bug。

这类问题的特征是:

  • 某个动作持续发生(例如频繁打开/关闭设备、发请求);
  • 即使发起请求的用户进程退出,内核里仍有资源悬挂;
  • 系统 free 内存持续下降,只能靠重启或卸载模块才能恢复。

它们实质上是 OS/驱动的 bug,而不是普通应用开发者能通过“写对 delete”解决的。

下面用一张表,区分几种“泄漏”:

类型发生层次进程退出后是否释放对系统长期影响谁负责修?
C++ 堆泄漏(忘 delete用户态只在进程存活时影响应用开发者
文件描述符泄漏用户态 + OS是(进程退出即关闭)存活期间耗尽 fd / 句柄应用开发者
shared memory 不释放用户态 + OS视具体 API/标志而定可能需要手动清理应用+系统管理员
内核内存泄漏(驱动 bug)内核态重启前系统内存不断被吃掉OS/驱动开发者
GPU/设备驱动泄漏内核态/驱动通常否导致显存不足/设备不可用设备厂商/驱动开发者

3.4 收束:从“进程视角”到“系统视角”的内存观

把全文压缩成几句方便写在博客结尾的要点:

  1. 分配
    • C++ new / malloc → 先是拿到一块虚拟地址(堆池里的子区间);
    • OS 只是登记了 VMA,不一定立刻分配物理页;
    • 第一次访问时才通过 page fault 分配物理页并建立页表映射。
  2. 销毁与并发
    • free/delete 决定的是“对象生命周期”和“堆池逻辑”,不是直接对 VMA/页表动刀
    • 真正改变地址空间的是 munmap、缩堆和进程退出;
    • 任何“释放后再访问”的并发都属于 C++ 层面的 UB,不能指望 OS 帮你兜底。
  3. 泄漏与系统稳定性
    • 进程内堆泄漏会拖垮自身和系统性能,但进程一退出,OS 会回收所有资源;
    • 关掉进程还能持续吃内存的,往往是内核/驱动泄漏,属于系统级问题;
    • 工程实践中既要用工具(ASan、Valgrind、各种 profiler)盯住用户态泄漏,也要对系统异常行为保持警觉。

结语

在我们的编程学习之旅中,理解是我们迈向更高层次的重要一步。然而,掌握新技能、新理念,始终需要时间和坚持。从心理学的角度看,学习往往伴随着不断的试错和调整,这就像是我们的大脑在逐渐优化其解决问题的“算法”。

这就是为什么当我们遇到错误,我们应该将其视为学习和进步的机会,而不仅仅是困扰。通过理解和解决这些问题,我们不仅可以修复当前的代码,更可以提升我们的编程能力,防止在未来的项目中犯相同的错误。

我鼓励大家积极参与进来,不断提升自己的编程技术。无论你是初学者还是有经验的开发者,我希望我的博客能对你的学习之路有所帮助。如果你觉得这篇文章有用,不妨点击收藏,或者留下你的评论分享你的见解和经验,也欢迎你对我博客的内容提出建议和问题。每一次的点赞、评论、分享和关注都是对我的最大支持,也是对我持续分享和创作的动力。

最后,想特别推荐一下我出版的书籍——《C++编程之禅:从理论到实践》。这是对博主C++ 系列博客内容的系统整理与升华,无论你是初学者还是有经验的开发者,都能在书中找到适合自己的成长路径。从C语言基础到C++20前沿特性,从设计哲学到实际案例,内容全面且兼具深度,更加入了心理学和禅宗哲理,帮助你用更好的心态面对编程挑战。
本书目前已在京东、当当等平台发售,推荐前往“清华大学出版社京东自营官方旗舰店”选购,支持纸质与电子书双版本。希望这本书能陪伴你在C++学习和成长的路上,不断精进,探索更多可能!感谢大家一路以来的支持和关注,期待与你在书中相见。


阅读我的ZEEKLOG主页,解锁更多精彩内容:泡沫的ZEEKLOG主页

Read more

FPGA:高速接口JESD204B以及FPGA实现

FPGA:高速接口JESD204B以及FPGA实现

本文将先介绍JESD204B高速接口的基本概念和特性,然后详细说明如何基于Xilinx Kintex-7系列FPGA实现JESD204B高速接口。 一、JESD204B高速接口介绍 JESD204B是由JEDEC(固态技术协会)制定的一种高速串行通信标准,主要用于数据转换器(如ADC、DAC)与数字处理单元(如FPGA、ASIC)之间的高速数据传输。以下是JESD204B的主要特点和优势: 1. 高速串行通信: * JESD204B采用差分对(SerDes)进行高速串行数据传输,单通道速率可达12.5 Gbps(JESD204C进一步提升至32 Gbps)。 * 通过多通道(lanes)并行传输,支持更高的总带宽,适合高采样率、高分辨率的数据转换器。 2. 主要特性: * 同步性:提供确定性延迟(Deterministic Latency),通过子类(Subclass 0/1/2)支持不同同步需求,Subclass 1广泛用于需要精确同步的应用。 * 多设备同步:支持多个ADC/DAC与FPGA之间的同步,SYSREF信号用于对齐时钟和帧。

By Ne0inhk

【GitHub项目推荐--AI-Goofish-Monitor:闲鱼智能监控机器人完全指南】

简介 AI-Goofish-Monitor 是一个基于 Playwright 和 AI 技术的闲鱼(Goofish)多任务实时监控与智能分析工具。该项目由 dingyufei615 开发,通过先进的浏览器自动化技术和多模态大语言模型,为用户提供智能化的闲鱼商品监控解决方案。该工具不仅具备强大的数据采集能力,还配备了功能完善的 Web 管理界面,让用户能够轻松管理和配置监控任务。 🔗 GitHub地址 : https://github.com/dingyufei615/ai-goofish-monitor ⚡ 核心价值 : AI智能分析 · 多任务监控 · 实时通知 · Web管理界面 技术特色 : * AI驱动 :集成多模态大语言模型(GPT-4o、Gemini等),深度分析商品信息 * Web管理 :完整的可视化界面,无需命令行操作 * 多平台通知 :支持 ntfy.sh、企业微信、Bark 等多种通知方式 * 智能过滤 :基于自然语言的任务创建和AI分析标准生成 * 云原生支持 :提供

By Ne0inhk
DAY4 基于 OpenClaw + 飞书开放平台实现 AI 新闻推送机器人

DAY4 基于 OpenClaw + 飞书开放平台实现 AI 新闻推送机器人

DAY4 基于 OpenClaw + 飞书开放平台实现 AI 新闻推送机器人 目录 DAY4 基于 OpenClaw + 飞书开放平台实现 AI 新闻推送机器人 前  言 1 环境准备 1.1 华为云开发环境 1.2 ModelArts 代金券与模型服务 1.3 启动 OpenClaw 网关 2 飞书开放平台配置 2.1 创建企业自建应用 2.2 添加机器人能力 2.3 配置应用权限 2.4 发布应用版本 3 OpenClaw 与飞书集成 3.1 配置 OpenClaw

By Ne0inhk

Modelsim仿真软件的,安装/破解/使用教程大全

仿真前言         作为一名FPGA工程师,在做FPGA开发时,使用仿真一定是最重要的,有些人喜欢写完代码直接上板子调试,根本不会做一点点仿真;如果是简单的逻辑代码,有十足的把握,那就不用仿真,可以直接上板子调试,但是,如果您是在做工程的开发,很多代码都是第一次编写调试,那么,代码的仿真是一定要做的,你要问我为啥,我个人觉得,每次把自己写完的代码,放到modelsim上面仿真看一下波形,就像考试的时候,拿着参考答案在做题一样的感觉,各个波形的变化你都会看的一清二楚,但是如果你用在线逻辑分析仪看RTL的仿真,那真的是太耗费时间;         我知道这个时候就会有人说了,Modelsima仿真有啥用呀,和下板子调试完全是两个概念,包括信号延迟,信号质量,眼图等都不一样,说的也对,但是实际情况是,这些人眼高手低,觉得仿真这种操作太麻烦;仿真虽然不能完全模拟真实的硬件信号,硬件延迟也没法准确仿真,但是他能让你在开发的时候,规避掉95%的因为代码引起的错误,这会让你在调试阶段节省很多时间;然后剩下的调试你必须 要在硬件调试时才会发现并且解决;        在调试阶段,FPGA为

By Ne0inhk