Appearance
运行时内存映像与地址空间
2026 大纲 一(三)5 程序运行时的内存映像与地址空间。
程序装进内存之后,长什么样
上一节的装入程序把可执行文件搬进了内存。搬完之后,这块内存里是怎么摆的?
不是把文件原样拷一份进去。可执行文件里存的是"静态的描述",内存里要的是"能跑的现场"—— 两者的差别不只是位置:文件里几个 KB 的东西,跑起来可能占好几 MB; 文件里根本不存在的堆和栈,跑起来才出现;而每个进程还都能看到一整套内核。
要把这些说清,先得分开两个一直被混着用的词:地址空间有多大和内存有多大。 它们由完全不同的东西决定,而正是它们的不相等,逼出了后面整章的虚拟内存。
这一节讲三件事:一个进程能编出多少个地址、这些地址被切成了哪几段、 每一段里装的东西从哪来、以及 CPU 送出的地址是怎么变成真正的内存单元的。 最后一件事里藏着一处顺序问题——越界检查在地址变换之前——它是本节唯一会算错的地方。
一、地址空间
| 概念 | 定义 | 谁产生、给谁看 |
|---|---|---|
| 逻辑地址(相对地址、虚拟地址) | 程序地址空间中相对某个基准(通常为 0)连续编号的地址 | CPU 执行指令时产生,程序里能看到 |
| 物理地址(绝对地址) | CPU 执行指令和处理数据时面向的实际主存地址 | 送上地址总线、直接选中内存单元 |
| 逻辑地址空间 | 一个进程全部逻辑地址的集合 | 每个进程一份,互不相干 |
| 物理地址空间 | 全部物理地址的集合,大小取决于实际安装的主存容量 | 全机唯一 |
地址空间有多大是一个纯粹的计数问题:
内核空间出现在每一个进程顶部不是浪费,而是省掉一次地址空间切换:进程随时可能因中断、异常、系统调用进入内核 ⇒ 进入后要立刻访问内核代码与数据 ⇒ 内核若不在当前地址空间里就得先切换整套页表并刷新 TLB ⇒ 而中断和系统调用极其频繁,这个代价无法接受。
二、进程的内存映像
高地址 ┌──────────────────────┐
│ 内核空间 │ ← 各进程共享同一份物理内存;用户态访问即触发保护异常
├──────────────────────┤
│ 栈 Stack │ ← 向低地址增长:函数栈帧(局部变量/参数/返回地址)
│ ↓ │
├──────────────────────┤
│ 共享库 / 内存映射区 │ ← 动态链接库(.so/.dll)、mmap 映射的文件与匿名内存
├──────────────────────┤
│ ↑ │
│ 堆 Heap │ ← 向高地址增长:malloc/new 动态申请
├──────────────────────┤
│ BSS 段 │ ← 未初始化的全局/静态变量;文件里不存内容,装入时清零
├──────────────────────┤
│ 数据段 Data │ ← 已初始化的全局/静态变量;内容来自可执行文件
├──────────────────────┤
│ 代码段 Text │ ← 机器指令,只读、可共享
低地址 └──────────────────────┘中间那段常被画成"空闲区域",其实它并不空——动态链接库和 mmap 映射的内容就落在这里,这也解释了"为什么两个进程各自 malloc 得到的地址会差很远"以及内存映射文件映到了哪儿。
| 区域 | 判据:内容从哪来 | 在可执行文件中占空间吗 | 读写权限 | 大小变化 |
|---|---|---|---|---|
| 代码段 | 编译产生的机器指令 | 占(原样存着) | 只读、可执行 | 固定 |
| 数据段 | 有初值的全局/静态变量 | 占(初值要存下来) | 可读写 | 固定 |
| BSS 段 | 无初值的全局/静态变量 | 不占(只记长度) | 可读写 | 固定 |
| 堆 | 运行时 malloc/new 申请 | 不占(运行时才有) | 可读写 | 向高地址增长 |
| 共享库/映射区 | 装入或运行时映射进来 | 不占(只记依赖了哪些库) | 按段而定 | 映射时变动 |
| 栈 | 函数调用自动产生 | 不占 | 可读写 | 向低地址增长 |
BSS 这条最能体现"文件布局 ≠ 内存布局":int b[1000000]; 在文件里只留一句"BSS 段长 4 000 000 B",跑起来却实打实占满 4 MB——一个几 KB 的可执行文件占用几 MB 内存就是这么来的。
栈帧里存了什么、堆和栈除增长方向外还差在哪(想弄清递归为什么耗栈、栈溢出为什么能改写返回地址、堆为什么有碎片时展开)
栈不是一堆散乱的局部变量,它以栈帧(stack frame)为单位组织——每发生一次函数调用就压入一个栈帧,返回时整帧弹出。一个栈帧自高地址向低地址通常含:
| 栈帧内容 | 作用 | 谁写进去的 |
|---|---|---|
| 实参 | 传给被调用函数的参数 | 调用者 |
| 返回地址 | 被调用函数执行完要跳回哪 | 调用指令(call)自动压栈 |
| 上一帧的帧指针 | 用于返回时恢复调用者的栈帧 | 被调用函数入口处压栈 |
| 被调用者保存的寄存器 | 保证返回后调用者看到的寄存器不变 | 被调用函数 |
| 局部变量 | 本次调用的私有数据 | 被调用函数 |
由此可以立刻推出三件事:① 递归为什么会耗尽栈——每层递归一个栈帧,深度乘以帧长就是栈的消耗;② 局部变量为什么函数一返回就失效——整帧被弹出,那块空间随时会被下一次调用覆盖;③ 栈溢出为什么可能改写返回地址——返回地址就压在局部变量的相邻位置,写越界的数组会覆盖到它。
| 比较 | 栈 | 堆 | 判据 |
|---|---|---|---|
| 谁分配、谁回收 | 编译器生成的代码自动完成 | 程序员显式申请与释放 | 看"释放这件事写不写在源码里" |
| 分配动作 | 移动一次栈指针 | 在空闲块链表里查找合适的块 | 前者 |
| 增长方向 | 高 → 低 | 低 → 高 | 相向增长,共享中间空闲区 |
| 生命期 | 随函数调用结束 | 到显式释放为止 | |
| 碎片 | 无(严格后进先出) | 有(释放顺序任意) | 后进先出 ⇒ 不可能在中间挖洞 |
堆栈相向增长不是巧合:把两个大小都无法预知的区域放在同一段空闲空间的两端,谁需要谁往中间长,比给两者各划一个固定上限更省空间——只有当两者之和撑满中间空闲区时才真正耗尽。
三、地址变换与内存保护
逻辑地址转成物理地址的过程叫地址重定位(地址映射、地址变换)。按转换时机分两种:
| 方式 | 时机 | 硬件支持 | 能否移动程序 |
|---|---|---|---|
| 静态地址重定位 | 装入时一次性改写 | 无需硬件 | 否 |
| 动态地址重定位 | 运行时每次访存 | 必需(重定位寄存器 + 界地址寄存器 / MMU 与页表) | 是,且便于共享 |
现代系统一律用动态重定位,承担这件事的部件是 MMU(内存管理单元)——它位于 CPU 与地址总线之间,CPU 送出的每一个逻辑地址都必须先过 MMU。这件事只能做在硬件里:交给软件,每条访存指令都要额外跑一段程序,速度无法接受。
图上两个寄存器 / 后面的名字是同一条逻辑在分页系统下的对应物,不要按字面直接替换:分页把逻辑地址拆成页号 P + 页内偏移 W,比较的两端随之降到"页"这一级。判据本身没变——先用"这个进程合法到哪"这个上界挡一道,通过了才做变换,变的只是上界的计量单位(连续分配按字节计,分页按表项个数计)。分页/分段各自的地址划分与检查次数见内存管理基本概念;异常的分类见中断和异常的处理。
内存保护有两条路,最容易搞反的就是"检查的是转换前还是转换后的地址":
| 机制 | 检查的是哪个地址 | 怎么判 | 用在哪 |
|---|---|---|---|
| 上下限(界限)寄存器 | 物理地址 | 转换后的物理地址是否落在 [下限, 上限] 内 | 早期连续分配 |
| 基址 + 界地址寄存器 | 逻辑地址 | 逻辑地址是否 < 界地址值,通过后才加基址 | 动态重定位、分区式管理 |
一次合法访问、一次越界访问、一次换出重装的地址算法(想在具体数字上确认"先检查后变换"这个顺序时展开)
某系统采用动态重定位,某进程的重定位寄存器值为 0x00200000,界地址寄存器值为 0x00010000。
(1)访问逻辑地址 0x00008000。 先做越界检查,不要急着做加法:
(2)访问逻辑地址 0x00012000。 0x00212000 是错的,硬件在比较这一步就已中止了变换。这正是把检查放在变换之前的价值。
(3)该进程被换出后重新装入到物理地址 0x00500000。 新物理地址
考点速记
- 逻辑地址空间大小
,物理地址空间大小取决于实际装了多少主存,两者无关。32 位机装 2 GB 内存,每个进程的逻辑地址空间仍是 4 GB——这个不相等正是虚拟内存存在的理由。 - 判断一个地址属于哪类只有一条:需要经过 MMU 才能用的就是逻辑地址,MMU 输出的那一端才是物理地址。用户程序全程只接触逻辑地址。
- 内存映像自低到高是代码段 → 数据段 → BSS 段 → 堆(向高增长)→ 共享库/映射区 → 栈(向低增长)→ 内核空间。PCB 在内核区,用户看不到也改不了。
- 系统里只有一份内核:各进程顶部那段虚拟地址映射到同一份物理内存,为的是进内核时不必切页表、不必刷 TLB;安全由页表项的特权级位保证。
- BSS 段在文件里只记长度、在内存里占满全长——一个几 KB 的可执行文件占用几 MB 内存就是这么来的。
- 越界检查先于地址变换,且查的是逻辑地址。顺序不能反:先加基址再检查,得到的物理地址已经可能指向别人。分页下判据不变,只是降到页一级——比的是页号 < 页表长度(以表项个数计,不是字节数)。
- 两套保护机制查的不是同一个地址:上下限寄存器查转换后的物理地址,基址 + 界地址寄存器查转换前的逻辑地址。后者更优——非法地址在做加法前就被挡住,且程序移动时只需改基址。
- 越界产生的是异常(程序性中断事件),处理原则是终止进程;同一步被发现的缺页却可恢复,调页后重新执行原指令。
这一节在真题里被考过的形式:
这一节没有属于自己标签的真题,所以本页下方没有练习区。 但它不是不考——恰恰相反,它出过一整道 8 分大题,只是那道题的标签挂在了进程那一侧。
- 给一张虚拟地址空间布局图和一段 C 程序,逐个问各对象位于哪个区域(os-2025-46,标签挂在
process-thread-concepts下,可在进程基本概念页面的练习区找到)。这道题把本节第三条速记完整考了一遍:PCB 在操作系统内核区(它是 OS 的私有数据,用户进程不能直接访问);main()的代码在只读代码段;全局指针变量ptr在读写数据段(有初值或无初值的全局变量都归数据段一侧,不在栈上);局部变量length若不在寄存器则在用户栈;而ptr指向的那串字符在堆(malloc(100)分配的)。⚠️ 最容易错的是把ptr和ptr指向的内容混为一谈——指针变量本身和它指向的空间在两个不同的区域,这正是这道题设计的分辨点。 - 除此之外,本节的结论是大量其他题目的前置认知,只是不单独设问:判断线程共享哪些区域(栈私有、堆与数据段共享)、判断进程切换要换掉哪些东西、以及内存管理整章的地址变换计算,用的都是这一节的地址空间模型。
复习优先级:按"给对象定位区域"这一种问法来准备就够。把内存映像那张图从低到高背熟, 再把"指针变量 vs 指针指向的内容""全局变量 vs 局部变量""PCB 在内核区"这三对分辨点记牢, 2025-46 那类题就能拿满。第六、七条那两个顺序问题(先检查后变换、查的是转换前还是转换后的地址) 在内存管理章会被反复使用,这里先建立印象即可。
易错:把指针变量本身和它指向的内容放在同一个区域。全局指针
ptr在数据段,malloc出来的空间在堆。
易错:认为 PCB 在进程的用户空间里。它是 OS 的私有数据,在内核区。
易错:把逻辑地址空间大小和物理内存容量混为一谈。前者由地址位数定,后者由实际装了多少内存定。
易错:把越界检查放在地址变换之后。必须先检查再变换,否则算出的物理地址可能已指向别的进程。
易错:分页时拿页号和"页表字节数"比。界限以表项个数计。
易错:认为 BSS 段在可执行文件里也占同样大的空间。它只记一个长度,装入时才实打实开辟并清零。
易错:把地址越界当成可恢复的故障。它属程序性中断事件,处理原则是终止进程;缺页才是重新执行原指令。
教材出处
- 孙钟秀、费翔林《操作系统教程》(第 6 版)3.1.1 存储管理的主要模式(p82~p83):逻辑地址(相对地址)"总是相对某个基准(通常为 0)开始连续编号",物理地址(绝对地址)是"CPU 执行指令和处理数据时面向的实际主存地址",且"物理地址空间……其大小取决于实际安装的主存容量"。
- 同书 3.1.2 存储管理的功能(p84~p85):地址转换(重定位)分静态地址重定位与动态地址重定位,动态地址重定位"具有允许程序的主存移动、便于程序共享、主存利用率高等优点";存储保护一节给出"CPU 检查是否允许访问,如果不允许则产生地址保护异常,由操作系统进行相应处理"。
- 同书 2.2.2 中断源(p46):程序性中断事件的处理原则中,"非法指令、用户态使用特权指令、地址越界、非法存取等指令异常:终止进程";"虚拟地址异常:发现了不在实际主存的地址,需要调整主存后重新执行指令"。
- 汤小丹《计算机操作系统》1.4.2 存储器管理功能(p18):内存保护"一种比较简单的内存保护机制是设置两个界限寄存器,分别用于存放正在执行程序的上界和下界。在程序运行时,系统须对每条指令所要访问的地址进行检查,如果发生越界,便发出越界中断请求,以停止该程序的执行"。