news 2026/10/13 10:56:29

Linux进程地址空间详解:MMU、页表与内存映射的奥秘

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux进程地址空间详解:MMU、页表与内存映射的奥秘

我一直觉得,进程地址空间是学习Linux内存管理的一道分水岭。前面研究物理内存、页表、伙伴系统的时候,你还能用“内存条上排布着什么”来想象;可一旦进入进程地址空间,一切就都变成了抽象概念——每个进程都“以为”自己独占着一块巨大的连续内存,实际上背后是MMU和页表在做一场盛大的戏法。

这篇文章是Linux内存管理系列的第四章,我会从硬件视角、核心数据结构、完整的内存地图、mmap系统调用的幕后旅程、缺页异常、fork时的写时拷贝,一直到实战观测手段,一层层把“进程地址空间”拆开揉碎。它适合正在读内核源码的开发者、做内存相关性能优化的人,以及所有想彻底搞懂“malloc出来的地址到底经历了什么”的工程师。读完你至少能清楚地回答这三个问题:一次mmap到底改了哪些内核数据结构?为什么分配了一堆内存RSS却不涨?fork一个进程为什么内存看起来没翻倍?

1. 为什么需要进程地址空间:先从MMU的“隔离”开始理解

1.1 地址空间是虚拟地址的“定义域”,不是内存本身

进程地址空间本质上是一张映射规则表:它把一组连续的虚拟地址,映射到物理内存的离散页面、文件页面,甚至是设备寄存器上。这里要强调整的是,所谓的“连续”只是虚拟地址连续,物理页面完全可以被拆得七零八落,这个连续性的承诺由页表来完成。

如果完全没有进程地址空间,让进程直接操作物理内存,会立刻撞上三大问题。第一是碎片问题:进程需要一个连续的大缓冲区,物理内存却被历史负载切得支离破碎,你没法给任何程序承诺“给你一块连续内存”。第二是隔离问题:进程之间可以互相读取,只要知道物理地址就能看别人的私有数据,安全无从谈起。第三是换页和按需加载没法透明实现:被换出到磁盘的页面,进程根本感知不到,更没法配合内核完成换入换出。

进程地址空间这套机制,把这些糟心事全部“骗”过去了。进程眼中的内存是连续、私有、随时可用的;内核在背后通过页表完成物理页分配、换出换入、共享、写时拷贝。这就是“虚拟地址到物理地址翻译层”的核心价值——它给每个用户态进程提供了一个独立、连续的视图,把物理内存的管理复杂性完全藏了起来。

1.2 MMU和页表:每次内存访问背后的“无声翻译”

地址翻译不是软件去查表,CPU里的MMU和页表硬件会在每次内存访问时自动完成。x86-64下有多级页表,四级或者五级,这个细节我们后面讲缺页时再展开。这里需要记住的关键点是:进程地址空间是页表的上一级逻辑视图,页表则按进程维护,并且会在进程切换时跟着切换。

进程切换时,内核会把新进程的页全局目录地址写入CR3寄存器(x86体系),之后每次访存都从这颗页表根部开始逐级往下翻译。多级页表本身由内核维护,mm_struct->pgd保存的就是这个进程的页全局目录。

地址空间和页表的区别很重要。页表只回答“某个虚拟页有没有映射、映射到哪个物理页、权限是什么”;而地址空间还包含更丰富的信息——哪些区域是堆、栈、文件映射、共享库、vdso,这些区域的权限、文件偏移、私有还是共享属性等等。等下我们会看到,页表是地址空间的“实物投影”,地址空间是页表的“设计蓝图”。

1.3 用户空间与内核空间的“地界划分”

x86-32经典的3GB/1GB划分大家应该不陌生;x86-64的4级页表模式则是有128TB用户空间和128TB内核空间,中间有巨大的空洞。我们日常说的“进程地址空间”,默认指的就是用户空间这一侧。

内核空间在大多数架构下是全局共享的,每个进程的页表里都保留一份对内核的映射;但用户空间完全不同——不同进程之间互不可见、各自独立。这就是“一个进程地址空间”最核心的概念边界:它是进程私有的地址范围和管理信息的总和。

还有一个容易被忽视的细节:64位下用户地址必须是规范地址(canonical form),高位一半属于内核空间,低位一半留给用户。平时我们调试时看到的栈一般在0x7f…附近,mmap分配在高位附近,堆在0x55…或者0x400000附近,这种分布不是巧合,而是内核特意设计的“从高往低、向中间增长”的布局模型。很多人做逆向或内存取证时觉得“地址怎么这么多随机”,其实底层布局规律非常固定,ASLR只是在这个固定骨架上加随机偏移而已。

2. 两张核心数据结构撑起地址空间:mm_struct与vm_area_struct

2.1 mm_struct:进程地址空间的“户口本”

每个进程(更准确说是每个独立的地址空间实例)都有一个mm_struct,由task_struct->mm指向。它记录了整个地址空间的全局信息,最重要的字段包括:

  • mmap:VMA的双向链表头,按地址升序串联。
  • mm_rb:VMA的红黑树根,用于快速查找。
  • pgd:进程的页全局目录指针,切换CR3时就是取它。
  • map_count:当前VMA数量,受vm.max_map_count限制。
  • mmap_base:mmap区域从哪个地址开始向下扩展。
  • start_brk和brk:堆的起始边界和当前上界。
  • start_stack、arg_start、arg_end、env_start、env_end:栈底以及命令行参数、环境变量的位置。
  • mm_users和mm_count:两个容易被搞混的引用计数。

mm_struct就是整个进程地址空间的总档案。它概括了“当前用到哪一步了”:堆顶在哪、mmap区下降到哪、栈有没有扩张过。没有它,内核就没法判断一个缺页地址是否合法、没法知道该为进程保留多少虚拟内存。

对源码新手来说,最容易绕晕的就是mm_users和mm_count。mm_users表示有多少个任务正在使用这个地址空间,也就是通过CLONE_VM共享它的线程数量,再加上创建它的那个进程;mm_count则是mm_struct对象本身的引用计数,包含内核里临时持有它的人(比如RCU读侧、某些异步遍历路径)。当所有线程退出,mm_users会先降到0,此时可以认为没有任务在用这个地址空间了;但mm_count可能还大于0,因为还有别的内核代码拿着这个指针。只有mm_count也归零,__mmdrop才会真正释放页表和顶层pgd。如果只盯着mm_users,你会漏掉很多内存“迟迟不释放”的真实原因。

2.2 vm_area_struct:一段地址范围的“使用说明书”

整个地址空间被切成很多段,每一段用一个vm_area_struct描述,通常叫VMA,用半开区间[vm_start, vm_end)表示。它承载的信息非常密集:

  • vm_flags:基本权限(VM_READ/VM_WRITE/VM_EXEC/VM_SHARED)加行为位(VM_GROWSDOWN、VM_DONTCOPY、VM_ACCOUNT等)。
  • vm_file和vm_pgoff:这一段来自哪个文件、文件内的起始页偏移。匿名映射的vm_file为NULL。
  • vm_ops:这段内存的具体操作回调,重点是fault、map_pages、page_mkwrite、open、close。
  • vm_mm:回指所属的mm_struct。
  • vm_next/vm_prev和vm_rb:链表节点和红黑树节点。

VMA不是页表。页表说“这一页映射到哪个物理页”,VMA说“这块地址区域是用来干什么的、权限是什么、缺页时怎么产生内容”。它才是地址空间真正的“设计单位”——反映了一段区域的性质,而不仅是地址映射关系。

2.3 双向链表和红黑树双组织:为什么要两套结构

mm_struct里同时维护了VMA的双向链表和红黑树,这两套结构各有用途。

链表适合“从低到高依次扫”的场景:写/proc/PID/maps、遍历相邻VMA做合并、fork时按顺序复制VMA,都是线性操作。红黑树则适合“给定一个地址,找到包含它的VMA”的查找场景——find_vma()用红黑树能在O(log n)内完成定位,而链表最坏要扫全表。

这里还有一个容易被忽略的精妙设计:红黑树节点里保存了rb_subtree_gap——右子树中能看到的“最大空闲空隙”。get_unmapped_area()在找一段没有映射的空洞时,就借助这个字段快速下探到足够大的空闲区域,而不是一个个VMA去试探。这是地址空间管理里非常经典的“空间换时间”,你在看mmap的实现时一定会反复碰到。

建议源码阅读路径是:先从include/linux/mm_types.h把这些结构体认全,然后跟一遍find_vma和vma_merge的代码,地址空间的骨架就立起来了。

3. 一张进程地址空间的完整地图:从text段到vdso/vsyscall页

3.1 四类经典区域:text、data、heap、stack

把一段地址空间想象成一张城市地图,整个进程的运行状态就是把各个功能区摆进去。程序通过exec加载ELF可执行文件时,内核会依次创建这些区域。

  • text段:代码加只读数据,权限通常是r-x--p,映射自可执行文件。x86-64下受ASLR影响,起点通常在0x55…或0x400000附近。
  • rodata/data/bss段:只读数据、已初始化全局变量(rw-p)、未初始化全局变量(rw-p且文件里没内容,所以bss是匿名映射)。
  • heap:起点在bss末尾上方start_brk,通过brk系统调用扩展。堆是一条VMA,但内部怎么分配完全由用户态分配器(比如glibc的malloc)自己管理。
  • stack:从很接近用户空间顶端的地址开始,向下增长,带VM_GROWSDOWN标志。初始VMA很小,靠缺页自动往下扩。

在城市地图里,这四块是“主干道”,它们的位置和顺序基本固定:text/data在低地址,堆向上,栈在高位向下,中间是mmap区。

3.2 中间地带:mmap区域与文件映射

栈以下、堆以上的大段空闲地址,是“动态映射区”。共享库、可执行文件的热补丁映射、线程栈、以及超过阈值(比如glibc默认128KB)的大块malloc,全都用mmap系统调用安插在这里。

mmap区域又分两类。匿名映射(MAP_ANONYMOUS)不关联文件,典型例子是malloc大块内存和线程栈,内容全零,靠写时才真正分配物理页。文件映射(传入fd)则把某个文件的一段内容映射进内存,比如共享库.so和可执行文件。文件映射还可以进一步分MAP_PRIVATE和MAP_SHARED:私有映射改动不落盘,写时拷贝;共享映射的改动最终会通过页面脏位写回磁盘。

3.3 特殊区域:[vdso]和[vsyscall]

进程启动后,maps里能看到一个固定地址0xffffffffff600000附近的[vsyscall],以及随机地址的[vdso]。这两个区域是内核故意映射给用户态的“只读窗口”,目的是让一些高频系统调用不必真正陷入内核。

vdso(virtual dynamic shared object)把内核里一小段代码直接映射到用户空间,gettimeofday、clock_gettime这类调用,在用户态就能读取内核维护的时钟数据页,省掉系统调用上下文切换的开销。vsyscall是更早期的实现,固定地址、改动不便,现代glibc已经主要走vdso路线,但内核为了兼容老掉牙的二进制仍然保留它。

这两个区域的存在说明:进程地址空间不完全是“物理内存的投影”,有少数页面是内核主动提供的“便民窗口”。它们的VMA属性特殊,权限通常是r-xp或r--p,并且不会参与普通匿名页的COW机制。

3.4 实战解剖:/proc/self/maps到底在说什么

拿一个最简单的C程序,运行后在内部读取/proc/self/maps,输出大致长这样:

55d47c100000-55d47c101000 r--p 00000000 08:03 234567 /path/to/program 55d47c101000-55d47c102000 r-xp 00001000 08:03 234567 /path/to/program 7f6d5c000000-7f6d5c200000 r--p 00000000 08:03 123456 /path/to/libc.so 7f6d5c400000-7f6d5c800000 r-xp 00002000 08:03 123456 /path/to/libc.so 7fff4d600000-7fff4d700000 rw-p [stack] 7fff4d780000-7fff4d7a0000 r-xp [vdso] ffffffffff600000-ffffffffff601000 r-xp [vsyscall]

每一行的字段顺序是:起始地址-结束地址、权限(r/w/x加p/s)、文件内偏移、设备号、inode、路径或特殊标注。

权限位里的p和s值得单独说:p是private,私有写时拷贝;s是shared,共享映射。偏移列也不是虚拟地址,而是文件内的页偏移——所以你会看到text段第二段通常偏移0x1000,因为ELF头占了第一页。inode为0或者路径为空的,基本是匿名映射。

我自己在做内存问题排查时,第一步永远是看smaps里的Private_Dirty,而不是Rss——Rss会把共享库的页面也算进来,而真正属于进程本地、写脏且没有落盘的匿名内存,只有Private_Dirty最接近。这个习惯帮我避开了不少“看起来内存高其实是别人共享”的假象。

4. mmap系统调用的幕后旅程:从参数检查到VMA插入

4.1 六个参数与一堆前置检查

用户态调用mmap(addr, len, prot, flags, fd, offset),一路下来会进入内核的do_mmap()。内核做的第一件事不是找空间,而是一大堆前置校验:

  • len为0直接返回EINVAL。
  • 如果映射文件,offset必须按页对齐(offset & ~PAGE_MASK),否则EINVAL。这个经常被误解,因为按理说文件偏移应该可以是任意字节,但mmap的最低映射单位是页,所以只能整页映射。
  • addr + len不能溢出TASK_SIZE,否则ENOMEM。长度和偏移相加也要防溢出。
  • 使用MAP_FIXED时,新映射会覆盖指定区域内的已有映射,这是最危险的操作,只能由非常了解风险的程序使用。常见的合理用法是MAP_FIXED_NOREPLACE,它要求如果目标地址已有映射就返回失败,而不是覆盖。
  • 有一个隐蔽上限:map_count + 1 > sysctl_max_map_count时返回ENOMEM。这个默认值通常在65530左右,对普通程序来说很难触达,但对疯狂创建线程、JIT编译器、大量加载共享库的进程一点也不遥远。
  • 安全检查还包括mmap_min_addr、安全模块hook等。

这些前置检查看着繁琐,但每一个背后都有真实的攻击案例或者内核崩溃教训。读源码的时候别快速跳过这一段,符号验证是内核对用户态“第一次说不”的关口。

4.2 分配地址:get_unmapped_area的top-down搜索

合法的前置检查通过后,如果没有指定addr,或者addr只是提示(没设MAP_FIXED),内核进入get_unmapped_area()。x86-64的默认策略是top-down分配:从mmap_base开始向低地址搜索空洞。

搜索过程高度依赖红黑树的rb_subtree_gap字段:它能一下子判断当前节点的右子树里有没有足够大的空隙,必要时再往低地址走。内核还要给栈保留一个守护空隙(stack guard gap),默认是256页,防止栈无限往下扩展时直接把其他映射区“吃穿”。

这里有一个实用心得:addr参数只是提示。只要没设MAP_FIXED或MAP_FIXED_NOREPLACE,内核不保证返回地址等于addr,它会把地址按页对齐,优先考虑在hint附近寻找,找不到也不强求。很多人写代码时误以为“我传了0x10000就一定会落到0x10000”,结果程序一跑不是预期地址就去查文档,最后还是要回到“hint不等于fixed”这个基本规则上。内核这样做是为了避免强行占用某个区域而破坏已有的VMA布局。

4.3 mmap_region:插入VMA并尝试与邻居合并

地址敲定后,进入mmap_region(),这里完成VMA的实际创建和插入。如果区间与已有VMA重叠,且没设MAP_FIXED,内核会沿着已有VMA边界调整;设了MAP_FIXED的话,就先do_munmap()清掉旧映射,再插新的。

接下来是VMA的合并判断:如果新VMA和它前面的邻居在起始地址上正好首尾相接,并且权限一致、映射对象(文件/inode)一致、偏移能续上,那就把两段并成一个大VMA;和后面的邻居也做类似判断。不能合并时才vm_area_alloc()新建节点,再vma_link()插入链表和红黑树。

对文件映射,mmap_region还会调用file->f_op->mmap来设置vm_ops——普通文件会设置成generic_file_mmap,这样以后缺页就能走filemap_fault预读文件内容。匿名映射则不需要vm_ops,缺页时走do_anonymous_page。

顺便说明为什么你在maps里常看到一连串紧挨着的rw-p段:它们看似中间没有缝隙,但其实是若干个互不相干的匿名映射,因为权限一致但映射来源不同,不满足合并条件,只能各自成段。只要看到一排rw-p紧邻着,基本就能推断这是一个进程用mmap做了多次小规模匿名分配。

4.4 brk:堆的另类扩展路径

brk系统调用不经过mmap主路径,它直接调整mm->brk这个堆边界。如果新边界还在当前堆的VMA内,直接改vm_end就行;若跨出了原有堆VMA,则调用do_brk_flags(),本质上和mmap_region非常相似。

为什么malloc动辄就用mmap而不是brk?因为brk管理的堆是线性的:你可以在堆顶继续向上扩,但想从堆中间释放一大块,堆顶却收不回来,会造成“空洞”。而mmap出来的每一大块都是独立的VMA,可以被单独munmap掉,空间利用更灵活。像glibc这样成熟的分配器,小内存走brk堆区切块,超过阈值的大块走mmap,就是同时利用了两边的好处。

5. 缺页异常:地址空间真正落地的那一刻

5.1 一次缺页的完整链路

VMA只是“纸面规划”,真正的物理内存分配发生在缺页异常时。CPU访问某虚拟地址,查页表发现对应条目不存在,或者权限不匹配,就会触发缺页异常。x86处理器把出错地址写入CR2寄存器,然后进入do_page_fault(),接着走handle_mm_fault()、__handle_mm_fault(),最终到handle_pte_fault()。

这条链路里第一个关键动作是find_vma():根据出错地址,在红黑树里找包含它的VMA。找不到意味着“访问了完全不在地址空间内的地址”,对用户态程序来说就是经典的SIGSEGV段错误,直接进core dump。找到VMA后还要查权限:地址在区间内,但访问方式超出VMA允许范围(比如往只读段写),一样是SIGSEGV。

只有地址合法且权限匹配,内核才开始真正工作:分配各级页表目录、填充页表项、建立物理页映射。这也是“地址空间是设计图,页表是施工图”的体现——缺页时才施工。

5.2 匿名页、文件页、交换页的三路分流

handle_pte_fault()里分岔很多,但核心逻辑就三路:

  • do_anonymous_page:pte为空,且属于私有匿名映射。内核默认先用一个全局共享的只读零页(ZERO_PAGE)填pte,用户第一次写时触发写保护缺页,再分配真实物理页。这就是“malloc大内存RSS不涨”的根源,后面我会展开。
  • do_fault:pte为空,但VMA关联了文件,也就是文件映射缺页。这里还会细分读、私有写(COW)、共享写三种:读走filemap_fault()在page cache里找页,找不到就分配页并启动预读;私有文件映射的第一次写会分配新页并复制文件内容;共享写则把新页标记为脏返回文件层。
  • do_swap_page:pte的present位为0,但内容编码了一个swap entry,说明这页曾经被换出到磁盘或压缩区。此时要从swap区域读取数据换回来,并把pte改成present。

这三路共同围绕“present位”做文章。present为0并不等于“没映射”,它可能是匿名新页、文件页或换出页。阅读源码时顺着这几个函数走,会看到很多“反直觉但合理”的设计,比如ZERO_PAGE居然是所有进程共享的一只读页。

5.3 栈的自动扩展:一种特殊的按需分配

当出错地址不在任何现有VMA里,但正好位于某个带VM_GROWSDOWN标志的栈VMA下方,内核会尝试expand_downwards()扩展这个栈VMA。扩展前会检查下方还有没有足够的空隙(stack guard gap,默认256页),防止栈无限往下吞掉整个地址空间。

这里有个我踩过很深的坑:线程栈并不享受和主栈相同的无限自动扩展待遇。线程栈由mmap创建,创建时大小固定,即使带VM_GROWSDOWN,递归太深也会在guard gap处触发SIGSEGV,而不是自动增长。很多人以为“只要栈上访问就会自动长”——那只是主栈的福利。排查线程栈溢出时,不要指望内核帮你自动扩容,要在设计阶段就把栈大小规划好。

6. fork后的地址空间复制:写时拷贝与引用计数的微妙平衡

6.1 dup_mmap都复制了什么

fork一个进程时,地址空间的复制链是copy_mm()->dup_mm()->dup_mmap()。它会遍历父进程的每一个VMA,为子进程建立全新的VMA节点,同时复制整套页表结构。但注意,物理页并不会在fork时被复制。

子进程的VMA副本拥有自己的vm_start、vm_end、vm_flags,但vm_file和偏移与父进程指向同一个文件(或同一个匿名页来源)。页表复制时,所有非共享映射的页表项都会被清除写权限,父子和父进程的pte都指向同一个物理页,却都是只读的。这正是写时拷贝(COW)的开端:物理页在fork这一刻是共享的,但谁也别想改。

对于MAP_SHARED文件映射,复制时不做写保护,因为共享映射本来就允许各进程同时写并回写文件。可执行文件text段一般用MAP_PRIVATE映射,fork后自然处于COW保护中——这保证了即使子进程修改代码页,也不会破坏磁盘上的可执行文件。

6.2 页表的写保护与do_wp_page:父子双方都别想直接写

COW机制里最需要想明白的一点是:父进程的pte也要被写保护。原因很简单:如果只让子进程的pte只读,父进程随意写入,物理页内容一变化,子进程看到的就不是fork时刻的“快照”。所以内核把父子双方的pte都设成只读,谁先写谁就先复制。

当某一方真的写入,触发写保护缺页异常,进入do_wp_page()。它会验证pte真的被写保护、确认页面确实属于该映射,然后分配一个新物理页,把旧页内容复制过去,建立新的可写pte,更新反向映射,刷新TLB。整个过程只有一页粒度,所以fork后大量写入确实会带来明显的缺页开销,但只写一页就只复制一页,这就是COW的精髓。

6.3 mm_users与mm_count:线程共享地址空间的代价

线程通过CLONE_VM共享同一个mm_struct,所以同一个进程的所有线程,maps内容完全一致。代价是:某条线程崩溃,整个进程的地址空间都可能保不住;某条线程退出时,只要还有别的线程活着,mm_users就不会归零,地址空间就不会被拆掉。

当最后一个线程退出,mm_users降到0,内核才认为“没有活跃任务用这个地址空间了”,开始清理顶层页表;但mm_count要确保没有其他内核路径正拿着这个mm_struct的指针在遍历,只有它也降到0,对象才会被真正释放。在阅读内核源码时,这两个计数是理解fork、exit、CLONE_VM之间关系的关键钥匙。

7. 实战观察:用/proc、strace和内核参数看清内存布局

7.1 smaps比maps多出来的字段怎么看

maps是地址空间的地图,smaps则是每段区域的“体检报告”。smaps对每个VMA给出:

字段含义
Rss该段在本进程里占用的物理页总数,含共享和私有
Pss按共享比例分摊后的物理内存,更接近“其实属于本进程的量”
Shared_Clean / Shared_Dirty共享页中干净/脏的状态
Private_Dirty私有且写脏、还没写回文件或换出的页,衡量进程私有消耗最准
Swap该段内被换出到磁盘的页

区分Shared和Private非常关键。假如两个进程同时mmap同一个.so,同一物理页被两边共享,两边的Rss都会显示“1页”,但Pss只算半页。排查“哪个进程真正吃内存”时,只盯Rss会误伤共享库的“重复计算”,盯Pss和Private_Dirty才更接近真相。

7.2 三个容易踩坑的内核参数

  • vm.max_map_count:限制一个进程最多能拥有的VMA数量。默认值通常在65530附近,对正常程序够用,但使用大量线程、加载海量共享库、JIT编译器的进程很容易撞墙。报障时如果看到“cannot allocate memory”而free内存明明还有很多,第一反应就该看看/proc/PID/maps的行数,再查这个参数。
  • vm.mmap_min_addr:允许用户进程映射的最低地址,默认一般是64KB。老代码想映射到低地址,比如把某些结构体固定放在0x10000,会被内核拒绝,报EINVAL或EFAULT。排查这类问题时,先检查目标地址是不是低于这个阈值。
  • vm.overcommit_memory:控制“超额承诺”策略。0是启发式,1是永远允许,2是严格限制虚拟内存总量。在2模式下,即使物理内存足够,虚拟地址空间也可能因为总量超标而mmap失败,大型数据处理程序尤其容易遇到。很多内存问题不是真缺页,而是“虚拟地址额度”被用完了。

7.3 用strace跟踪一次mmap的真实行为

strace -e trace=memory ./demo可以清晰看到进程启动过程中所有的brk、mmap、mprotect、madvise调用。最有意思的是glibc的启动序列:先brk一小块做初始堆,然后第一次malloc大块时直接mmap匿名段,offset为0,len按页对齐。

如果你自己写一个几行的C程序,mmap一个本地文件,然后同时打开/proc/PID/maps和strace输出对照,你会发现mmap返回地址、maps里的起始-结束地址、以及文件内偏移三者严丝合缝地对应。这个验证方法比单纯看文档能更快建立“系统调用->地址空间”的直觉。自己动手做一次,比看十篇文章都有用。

7.4 经典疑问:malloc了一大块内存,为什么RSS没涨

这是群里最常被问的问题之一。程序malloc了1GB,top一看RSS几乎没动,第一反应怀疑内存泄漏。其实这正是进程地址空间的按需分配特性在起作用。

malloc超过阈值后走mmap分配匿名段,此时内核只是在进程的地址空间里刻画了一个VMA,页表可能还是空的,甚至映射到一个全局只读零页。直到程序真的往某个页写入数据,才触发缺页分配物理页。不同的页面会在不同时间被写,所以RSS的上升是渐进的。你malloc了1GB,可能只写了一个页,RSS就只涨4KB。判断“这段内存到底吃多少物理内存”,要看的是写了哪些页,而不是分配了多少虚拟地址。

同样的道理也适用于文件mmap:读第一页才建立页缓存,顺序读100页会逐渐触发预读填充。很多性能观测的误解,都源于没分清“虚拟地址空间”和“物理页分配”是两回事。

读到这一段时,建议你拿一个最简单的hello world程序,一边看maps一边用strace跟系统调用,把VMA当作城市地图、把缺页当作施工队,很快就能建立直觉。进程地址空间这一章是后续学习页表、swap、内存回收的基础,把它吃透,后面很多“无法解释”的现象都会有答案。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 10:56:29

AI提示系统从单体到分布式架构的转型实战

"AI提示系统"这四个字,在我过去两年里几乎成了工作群里的高频词。它干的事情听起来很简单:把业务请求翻译成大模型能理解的提示词,再调用模型接口拿结果。但真正把它做成一个稳定、高效、多人协作的服务,复杂度并不比一…

作者头像 李华
网站建设 2026/10/13 10:56:29

Flutter应用鸿蒙适配:http客户端与TLS证书审计实战

把Flutter应用迁到鸿蒙平台,最先遇见的硬骨头往往是网络。我接手某个跨平台App的鸿蒙化改造时,第一个冒出来的问题不是UI适配,而是所有网络请求在鸿蒙真机上全部失败。排查到最后发现,问题出在 http_client 的底层——dart:io Htt…

作者头像 李华
网站建设 2026/10/13 10:55:40

AlphaZero自博弈算法落地中国象棋:从零训练策略价值网络

简介:面向想在象棋领域复现AlphaZero零方法的强化学习开发者,该代码包完整实现了中国象棋CCZero的训练与对弈流程,包含蒙特卡洛树搜索、深度神经网络、自我对弈及GUI可视化界面,适合具备基础深度学习知识、希望动手实践前沿算法的…

作者头像 李华
网站建设 2026/10/13 10:55:40

链表基础三题详解:移除元素、设计链表与反转链表

1. 先把链表底子打好:203、707、206这三题到底在练什么 代码随想录算法训练营第三天,题目排得很有意思:LeetCode 203移除链表元素、707设计链表、206反转链表。三题放在同一天,不是随便凑数,它们刚好覆盖了链表操作最核…

作者头像 李华
网站建设 2026/10/13 10:55:39

Mac开发环境基石:Homebrew与RVM实战配置全解析

刚拿到一台新Mac,装开发环境最先要动的就是包管理器和语言版本管理器。Homebrew负责解决“软件从哪来、依赖怎么管”,RVM负责解决“Ruby版本怎么切、项目环境怎么隔离”。这两个工具虽然装起来都是一行命令,但背后涉及的目录权限、路径顺序、…

作者头像 李华
网站建设 2026/10/13 10:55:25

2026年GEO实战:如何让AI主动引用你的品牌内容

2026年还在按老办法做品牌内容投放的朋友,最近应该都有一种类似的焦虑:花了大价钱铺出去的内容,阅读量还在,但"被AI平台推荐"这件事,越来越像开盲盒。同一个产品卖点,自己官网写了三屏&#xff0…

作者头像 李华