程序装载与虚拟内存

前置知识

1. 内存(memory)与磁盘(disk)——两个”仓库”的区别

这是理解整节的底层物理事实,先讲:

  • 磁盘(硬盘/SSD):容量大、便宜、但慢(慢几个数量级)。程序文件、数据文件都躺在磁盘上。
  • 内存(RAM):容量小、贵、但快。CPU 只能直接从内存里取指令和数据,不能直接读磁盘——这是硬件铁律。

所以任何程序要跑,第一步必须是”从磁盘搬进内存”。这就是”装载”这件事存在的根本原因。

2. 地址(address)——给内存每个格子编号

内存被切成一个个存储单元(以字节为单位),每个单元有一个编号,这个编号就是地址。CPU 靠”给个地址”来定位要读写哪个字节。

3. 物理地址 vs 虚拟地址(本节最核心的一对概念)

  • 物理地址:内存条硬件上真实存在的存储单元编号。一萝卜一个坑。
  • 虚拟地址:程序”以为”自己用的地址。操作系统给每个进程一个假象:”你独占一整条内存,从 0 开始编号”。

程序里写的一切地址(变量地址、函数地址)全是虚拟地址,CPU 执行时要靠硬件把虚拟地址翻译成物理地址才真正读写。

4. MMU(内存管理单元)——翻译地址的硬件

干”虚拟地址 → 物理地址”翻译这件事的,是 CPU 芯片内部的一块硬件,叫 MMU(Memory Management Unit)。它像一个极快的”查表机器”:拿到虚拟地址,查一张对照表,吐出物理地址。整个过程对程序透明——程序完全不知道、也不需要知道自己被翻译到了哪。

5. 页(page)——内存管理的”最小单位”

操作系统管理内存不是按字节管的(太碎、太低效),而是按固定大小的块管,这个块叫页。x86-64 上常规页大小是 4KB(4096 字节)。虚拟地址空间和物理内存都被切成同样大小的页:虚拟地址空间里叫”页”,物理内存里对应的叫”页框/帧”。

6. 页表(page table)——那张对照表

MMU 翻译地址靠的对照表就是页表。它记录着”哪个虚拟页,对应哪个物理页框,有什么权限”。每个进程有自己的页表。

7. 缺页(page fault)——“发现书不在书桌上”

程序访问某个虚拟地址时,MMU 查页表发现这个页根本没被装进物理内存(可能还在磁盘上、或压根没分配),就会触发一次缺页异常,把控制权交给操作系统。操作系统去把对应的页从磁盘读进内存、更新页表,再让程序接着跑。

8. 页面置换 / 换入换出(swap in / swap out)

物理内存有限,书桌就那么大。当书桌满了还要放新书,就得把某本不常用的书先放回书柜(换出),腾出位置。这个过程就是页面置换。怎么决定”踢哪本”?靠置换算法(下面 LRU 就是最常用的一种)。

9. LRU(最近最少使用)算法

置换算法的一种。核心思想:最近这段时间最少被用到的页,最可能将来也用不到,优先踢它。

10. 装载(load)与装载器(loader)

  • 装载:把磁盘上的程序文件搬进内存、布置好,让它具备运行条件的过程。
  • 装载器:干这件事的系统组件。

11. 内存映射(mmap 映射)

把文件或内存的某块区域,直接”挂”到进程虚拟地址空间的某个地址范围上的操作。挂上之后,访问那段虚拟地址,就等于访问那个文件/内存。这是”程序装载”在实现层面的关键技术——不是把整个文件复制进内存,而是”建立文件与虚拟地址的对应关系”。

程序装载:怎么把一个 ELF 文件变成跑起来的进程

1. 为什么”装载”这件事非做不可

回到最底层的物理事实:CPU 只能直接读写内存,不能直接读磁盘。而刚学完的 ELF 文件(elfExample)此刻还静静躺在磁盘上。所以程序要运行,第一个动作必然是:把它从磁盘搬进内存,并按照它自己的”说明书”(就是上章的程序头表)布置好每一块。

2. 装载分三步(这是本节”程序装载”的主线)

把 Linux 下 ELF 的装载归纳为三步:

第一步:读 ELF 文件头,确认”这是什么文件”

装载器拿到一个文件,先读最开头的 ELF 文件头。这步的目的就一个——验明正身、摸清底细:它是 32 位还是 64 位?是小端还是大端?是哪种类型(REL/EXEC/DYN)?入口点在哪?

必须先看头不同类型的文件,装载方式完全不同

第二步:按程序头表,把各个段映射进虚拟内存

确认是可执行文件后,装载器读程序头表,重点是里面的 PT_LOAD 段(只有 PT_LOAD 是真正要占内存的段)。每一个 PT_LOAD 表项都写着:”从文件第 X 字节搬、搬到虚拟地址 Y、多大、什么权限”。

装载器据此做的是内存映射,而不是傻乎乎地把整个文件复制一遍:把文件的某段(比如代码段)映射到虚拟地址空间的某段,把数据段映射到另一段。代码段落在低地址的可执行区,数据段落在稍高地址的可写区。

关键点:这一步建立的是”对应关系”,不一定是”立即搬运”。真正的内容可能等程序访问到那里才按页读进来(这就是”缺页”要讲的)。

第三步:重定位(如果需要)

如果程序里有”地址引用”是相对位置不确定的(尤其共享库),装载器要修正这些引用,让它们指向正确的内存地址。这一步和之前学的”.rela 重定位表”是同一机制在运行时的体现。

这里有个容易混的点:对普通的可执行文件 elfExample 来说,地址在链接阶段就已经定死了(上一课看到入口点固定是 0x400430),所以装载时其实不需要做大的重定位。真正需要运行时重定位的是共享库 .so——因为它被加载到哪个地址,要到运行时才知道。所以”第三步”严格说是”视情况而定”。可执行文件基本不用重定位,共享库才需要。

3. 装载完成后的”进程内存全景”

装载结束后,进程的虚拟地址空间长什么样?用一句话概括:

从低地址到高地址,大致是:代码段(只读可执行)→ 数据段(可读写)→ 堆(往上长)→ …… → 栈(往下长)→ 内核保留区。

  • 代码段:.text,只读可执行(RX),装着机器指令。
  • 数据段:.data + .bss,可读写(RW),装着全局/静态变量。
  • 堆(heap):程序运行时用 malloc 动态要来的内存,从低往高长。
  • 栈(stack):函数的临时开销(局部变量、返回地址),从高往低长。
  • 共享库映射区:libc.so 等被加载进来的位置(通常在堆和栈之间)。

这张”地图”是后面漏洞利用的地基

虚拟内存:操作系统怎么”骗”程序

1. 虚拟内存要解决的核心问题

一句话:让每个进程以为自己独占一整条内存,而且可以比真实物理内存还大。它同时解决三个问题:

  • 隔离:进程 A 摸不到进程 B 的内存,一个崩了不拖累别人。
  • 省内存:很多程序共用同一份库代码,内存里只放一份。
  • 够大:物理内存可能只有 8GB,但程序可以”以为”自己有几十 GB 甚至更多(多出来的靠磁盘顶上)。

2. 地址翻译:MMU + 页表,全程

这是虚拟内存的心脏:

程序跑起来,CPU 要访问某个地址——它拿到的永远是虚拟地址。流程是:

  1. CPU 把虚拟地址交给 MMU;
  2. MMU 拿这个虚拟地址去查页表;
  3. 页表给出”这个虚拟页对应哪个物理页框”,再加上页内偏移,得到物理地址;
  4. 用物理地址去真正读写内存。

一个虚拟地址在结构上被拆成两截:虚拟页号(高位)+ 页内偏移(低位)。页号用来查页表找物理页框,偏移不变直接拼上。比如:虚拟地址”3 栋 502 室”——“3 栋”是页号(查表看 3 栋实际是哪栋楼),”502”是偏移(楼内位置不变)。

3. 缺页:虚拟内存”骗人”的关键戏法

这是”程序以为内存很大”的真相所在:

程序访问某个虚拟地址时,MMU 查页表,可能出现一种情况——这个页根本没在物理内存里(要么在磁盘上,要么压根还没分配)。这时:

  1. MMU 触发缺页异常,把控制权交给操作系统;
  2. 操作系统暂停这个进程,去磁盘把需要的页读进物理内存;
  3. 更新页表,让这个虚拟页指向刚读进来的物理页框;
  4. 进程从刚才断掉的地方继续执行,好像什么都没发生过。

关键洞察:进程从头到尾都不知道自己被暂停过、不知道自己的数据其实有一部分躺在磁盘上。它眼里的”我有一大片连续内存”,其实是一堆”有的在内存、有的在磁盘”的页拼出来的幻觉。

4. 内存不够了:置换算法

书桌(物理内存)满了,还要放新书,就得换出一本旧的。怎么选?这就是置换算法。比如 LRU(最近最少使用):

  • 维护”每页上次被用是什么时候”的记录;
  • 要腾位置时,踢掉”最久没被用过”的那一页;
  • 理由:最近不用的,大概率接下来也不用。

拓展:置换如果做得不好,会出现”抖动”(thrashing)——程序频繁访问的页反复被换出又换入,系统花在磁盘搬运上的时间比真正干活还多,表现就是电脑卡成狗。磁盘比内存慢几个数量级,这是虚拟内存的代价:换来”够大”,付出”可能变慢”。

5. 内存保护:虚拟内存的第二重身份

虚拟内存不只是”让内存变大”,还顺带实现了权限隔离,这正是它和安全直接挂钩的地方(下面拓展展开)。页表里每个条目除了记”映射到哪”,还记着权限位:可读 R、可写 W、可执行 X。程序试图访问没有权限的地址,CPU 直接报错、杀死进程——“段错误(Segmentation Fault)”就是这么来的。

配套工具

1. pmap ——看进程的内存地图

1
pmap 1234          # 1234 换成真实进程号

会打印这个进程的整张内存布局表:每一段从哪个地址开始、多大、什么权限、对应哪个文件/库。这就是把第一部分”装载后的进程内存全景”亲眼打印出来。做 PWN 时第一步往往就是 pmap 看一眼目标进程的布局。

那进程号(PID)怎么来?先用 ps 或直接记下程序运行时的进程号。或者配合 pgrep 程序名 查。

2. gdb 的 info proc mappings——调试器里看内存

1
2
gdb ./elfExample
(gdb) info proc mappings

和 pmap 一个作用,但在调试器里,可以和断点配合,边跑边看内存当前状态。gdb 之前讲过用法,这里不再重复它的基础。

3. valgrind——内存错误检测器

1
valgrind --leak-check=full ./elfExample

它会模拟一个带严格检查的内存环境跑你的程序,抓出”非法内存访问、内存泄漏”这类错误。它把”内存保护”这件事变得可观察——你写错越界,它直接告诉你在哪一行越的界。注意:valgrind 跑得比正常慢很多,因为它全程软件模拟,别拿它测性能。

4. lscpu 和 top

1
2
lscpu    # 看 CPU 架构、核心数、线程数
top # 实时看各进程 CPU、内存占用,按 q 退出

lscpu 一眼看清你的 CPU 是不是 64 位(关系到 ELF 是 ELF32 还是 ELF64);top 看进程实况。



程序装载与虚拟内存
https://asleo814.github.io/2026/09/24/程序装载与虚拟内存/
作者
AsLeo
发布于
2026年9月24日
许可协议