Linux:进程地址空间

[复制链接]
发表于 2026-10-4 18:20:08 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×

同一个变量 id,在父进程里是 0 之后是子进程 PID,在子进程里又变回来——同一个地址读到两份数据。这不是编译器 bug,因为我们平时在 C/C++ 里看到的地址根本不是物理地址,而是虚拟地址(线性地址)。本文从 C 语言的内存分区出发,一路讲到进程地址空间、页表和缺页中断,解释这套机制为什么存在、怎么工作。

一、先看 C 语言那套内存划分

经典的 C 内存布局把地址空间按用途切成几块:内核区、用户区(栈、堆、全局/静态区、常量区、代码区)。几条可验证的规律:

  • 指向常量字符串的指针只读不可写,写就崩——因为常量区权限是只读;
  • 栈向低地址增长、堆向高地址增长,两者相向而行,用 &变量 打印地址就能看出方向;
  • 静态变量落在全局区(数据段),只是作用域受限,位置并不在栈上。

验证了这些以后,一个更绕的问题浮现出来:既然变量都有地址,那 fork 之后父子进程读同一个地址却拿到不同值,说明这个地址不可能是物理地址。结论很直接:用户看到的全是虚拟地址,OS 负责把它翻译成物理地址。

二、进程地址空间到底是什么

PCB(task_struct)内部有一个指针,指向一块「进程地址空间」这个内核数据结构。所谓地址空间,从硬件上讲就是:32 位机器有 32 根地址总线,每根要么高电平要么低电平,全组合起来就是 [0, 2^32) 这个范围。地址空间就是地址总线排列组合形成的地址范围。

区域划分靠结构体里的 start 与 end:想象一张 100cm 的桌子,中间画条「三八线」,小胖和小美各占 50cm。想调大小,改 start/end 就行。对应到代码层面,堆能长、栈能缩,都是改这两个字段。

所以进程地址空间本质上也是一个内核数据结构,和 PCB 一样遵循「先描述、再组织」:每个进程都有自己那份,PCB 里的指针指向它。

三、fork 之后的空间关系

子进程创建时,会拷贝一份和父进程一模一样的地址空间。之后任何一方要改数据,就触发写时拷贝:内核重新开辟一块物理内存,只改页表里的物理地址映射,左边的虚拟地址空间完全无感知。这就是「同一个变量、不同内容」的原因。

四、页表里到底有什么

页表是虚拟地址到物理地址的翻译表,典型条目包含四项:

  • 虚拟地址;
  • 物理地址;
  • 读写权限:常量区想被写会被内核直接拦下,非法请求根本到不了物理内存;
  • 标记位:指示这份代码数据是否已经进内存。

4.1 惰性加载与缺页中断

标记位解决的是「进程的代码数据可能还在磁盘/没加载」这个问题。现代操作系统几乎不做浪费的事情,于是有了惰性加载:需要多少加载多少,大文件分批进内存;新进程创建时可能只有 PCB 在内存里,代码数据还没进来。

执行到一半发现标记位显示「没加载」,就触发缺页中断:临时打断当前进程,把代码数据换入内存,恢复现场后继续跑。为什么不一锅端全加载?一方面可能是超大文件装不下,另一方面绝大多数代码根本走不到(典型的局部性),缺页中断正好按实际用量分配内存。

4.2 写时拷贝

数据区按理可写,但 fork 后被标记为只读(父子共享)。任一方真动手写时,内核发现只读却不报错,而是开辟新物理内存、改页表映射——这就是写时拷贝。整个过程对进程透明。

4.3 切换与创建

进程切换 = 切换进程地址空间。因为 PCB 里有地址空间指针;而页表存在 CPU 的 cr3 寄存器里,这属于进程上下文,切进程时被带走、切回来再恢复。

进程创建顺序也很清楚:先加载 PCB 和它内嵌的进程地址空间结构体,代码数据未必立刻进内存——前面缺页中断那套就是为这个兜底的。

五、进程独立性到底独立在哪

  1. 内核数据结构独立:各有一份 PCB 与地址空间;
  2. 物理内存只体现在页表上:虚拟地址可以一模一样,页表映射到不同物理页,父子就解耦了,各自释放互不影响;
  3. 虚拟地址可乱序:映射关系由软件决定,但呈现给进程的是一个整齐的线性空间。

六、为什么非要有虚拟地址空间

两个俗套但好用的故事,结论更实在:

  • 统一视角:进程不用关心自己该落在物理内存哪一块、也不用担心挤了别人,这些全交给 OS。像富翁给四个私生子各画一张「十亿」大饼——每个人都以为自己独占,实际要多少给多少。
  • 访问审查:多一层地址转换,就多一道审查。越界、写只读页、访问未映射页,全在这一层被拦下,请求根本到不了物理内存,物理内存得到保护。像妈妈替你保管压岁钱,买 1 元橡皮给,买 100 元游戏机不给。
  • 解耦合:进程管理模块和内存管理模块彻底分开,申请哪块物理内存、先加载可执行程序的哪一段、页表怎么填,都是 Linux 内存模块的活,上层语言不用管。

顺手纠正一个常见误区:变量名在定义时就已经是虚拟地址了,我们写 a 和 &a,只是区分「要变量的值」还是「要变量的地址」。

七、命令行参数与环境变量在栈之上

环境变量和命令行参数位于栈之上的一块独立空间。所以子进程天然能继承父进程的环境变量:父进程启动时已经把它们挂进了地址空间并建立页表映射,子进程复制父进程虚拟地址空间时,这块也一并复制了一份映射,即使你改着传参,子进程照样读得到。

env | grep PATH
cat /proc/$$/maps | head -20

/proc/<pid>/maps 可以直接看到某个进程的地址空间划分,配合 cat /proc/<pid>/status 看状态,调试进程问题时这两个文件比任何文档都直观。

八、小结

整条链条是:C 语言看到的地址是虚拟地址 → PCB 里有指针指向进程地址空间(由 start/end 划分区域)→ 页表把虚拟地址映射到物理地址 → 写时拷贝保证父子独立、缺页中断实现惰性加载。记住最后两条工程价值:这套机制让进程获得统一视角与访问审查,顺带把进程管理和内存管理解耦。日常排查里,看到「同一个地址两种值」,第一反应就应该是写时拷贝;看到「段错误」而地址看着怪怪的,八成是虚拟地址映射那一侧出了问题。

qidao123.com企服评测-应用市场 编辑提示

回复

使用道具 举报

登录后关闭弹窗

登录参与点评抽奖  加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表