操作系统学习笔记 · 第 15 课 · 内存与地址——程序住在哪
从这一课起,我们进入第四阶段:内存管理。前面我们一直在问"谁先跑、怎么不打架",现在要问一个更底层的问题:程序到底"住"在内存的哪里? 你在 C 里打印出来的那个变量地址,是真实的内存条地址吗?答案会颠覆直觉——不是。这一课,我们就来搞懂"程序住在哪"这件事。
从"你以为的地址"说起
写 C 程序时,你会打印变量的地址:
int x = 10;
printf("%p", &x); // 比如输出 0x7ffc1234abcd你可能会想:这个 0x7ffc... 就是内存条上的真实位置吧?
其实不是。 这是虚拟地址——是操作系统"假装"给程序看的地址。真实的物理内存地址,和你看到的完全不是一回事,而且中间隔着一层"翻译官"。
这个"翻译官"机制,就是现代内存管理的基石。我们从头理清。
15.1 三种地址
一句话理解:程序用的是虚拟地址,硬件访问的是物理地址,MMU 在中间做翻译——这是现代内存管理的基石。
内存管理里有三种地址,容易混淆,一次分清:
- 物理地址:内存条上真实的硬件地址,由内存芯片识别。这是硬件真正读写的位置;
- 虚拟地址:程序"以为"自己用的地址。每个进程都觉得自己独占一大片连续内存,其实那是假象;
- 逻辑地址:编译/链接阶段生成的、相对程序起点的偏移地址(早期概念,现代和虚拟地址高度重叠,可先不用纠结)。
三者关系一句话:程序用虚拟地址,硬件认物理地址,中间的 MMU(内存管理单元,CPU 里的一块硬件)负责把虚拟地址翻译成物理地址。
为什么要多此一举加一层翻译?因为好处巨大——隔离、超卖、共享。这正是下一课"虚拟内存"的主角,这里先埋个伏笔。
15.2 从源码到运行:四个阶段
一个 .c 文件,是怎么一步步变成"跑在内存里的程序"的?地址在这个过程里又是怎么定的?
| 阶段 | 干什么 | 地址变化 |
|---|---|---|
| 编译 | 源码 → 汇编 | 生成符号 / 逻辑地址 |
| 链接 | 多个目标文件合成一个可执行 | 确定各段布局、符号重定位 |
| 装载 | 把可执行文件搬进内存 | 建立虚拟地址空间 |
| 运行 | CPU 取指令执行 | MMU 翻译 虚拟→物理 |
- 编译:把
hello.c变成汇编,变量、函数都被标记成"符号"(名字),地址还是相对的; - 链接:把你写的
main.c、引用的lib.c等多个目标文件合并成一个可执行文件,此时确定各个段(代码段、数据段)的布局,把符号地址"重定位"好; - 装载:操作系统把这个可执行文件读进内存,为它建立一套虚拟地址空间;
- 运行:CPU 真正取指令执行,每次访存都由 MMU 把虚拟地址翻译成物理地址。
记住主线:编译定符号 → 链接定布局 → 装载建空间 → 运行做翻译。
15.3 地址绑定时机
程序里的地址,什么时候"定死"成真实地址?有三种时机:
一句话理解:地址绑定可以在编译时、装载时、运行时。现代 OS 用运行时绑定,因为灵活(能换页、能换位、能共享)。
- 编译时绑定:程序一编译好,地址就写死了。早期的裸机程序、嵌入式固件用这种。缺点明显——程序一挪位置就崩,因为地址全硬编码了;
- 装载时绑定:装载进内存那一刻,把地址定下来。比编译时灵活一点,但一旦装好,位置还是固定的;
- 运行时绑定:程序跑起来后,每次访存才由 MMU 动态翻译。这是现代操作系统的做法。
为什么现代 OS 选运行时绑定?因为灵活:
- 程序随时可以被换出、换进(换页);
- 程序在内存里可以被挪位置;
- 多个进程可以共享同一段物理内存(共享库)。
一句话:运行时绑定靠"页表"这个翻译工具,让地址"随时可换",这是它能支撑虚拟内存的关键。
15.4 一个进程的内存布局
那么一个进程的虚拟地址空间,长什么样?大致分成几段,从高地址到低地址:
高地址 → 栈(stack) ← 向下增长
↓(空闲)
堆(heap) ← 向上增长
数据段(全局/静态变量)
代码段(text) ← 只读,存指令
低地址各段放什么:
- 栈(stack):存局部变量、函数调用的返回地址。向下增长(地址越来越小);
- 堆(heap):
malloc动态分配的内存。向上增长; - 数据段:存全局变量和静态变量;
- 代码段(text):存指令,只读(防止程序自己篡改自己的代码)。
一句话记:局部变量在栈,malloc 的在堆,全局/静态变量在数据段,函数代码在代码段。 栈和堆从两头往中间长,中间的空闲区就是它们各自"增长"的战场。动手实验:C 语言打印地址,观察布局
// lesson15.c —— 观察进程内存布局
#include <stdio.h>
#include <stdlib.h>
int global_var = 42; // 数据段
static int static_var = 7; // 数据段
int main(void) {
int local_var = 1; // 栈
int *heap_var = malloc(100);// 堆
printf("代码段(函数地址) : %p\n", (void*)main);
printf("数据段(全局变量) : %p\n", (void*)&global_var);
printf("数据段(静态变量) : %p\n", (void*)&static_var);
printf("堆(malloc) : %p\n", (void*)heap_var);
printf("栈(局部变量) : %p\n", (void*)&local_var);
free(heap_var);
return 0;
}编译运行:
gcc lesson15.c -o lesson15 && ./lesson15观察输出地址的大致高低关系:
- 栈地址最高(
0x7fff...这种大地址); - 堆次之;
- 数据段(全局/静态)地址较低;
- 代码段(函数)地址最低。
注意:这里看到的是虚拟地址,不是物理地址。它们反映的是"进程虚拟地址空间"里的相对位置关系——这正是"布局"的含义。
深入点:两个进阶话题
① ASLR(地址空间布局随机化)
多跑几次 ./lesson15,你会发现地址每次都变。为什么?因为现代系统开了 ASLR:
每次运行时,栈、堆、共享库的起始地址都会随机偏移,让攻击者无法预测地址,从而更难发动"缓冲区溢出"之类利用固定地址的攻击。这是操作系统的一道安全防线。
② 静态链接 vs 动态链接
还记得 15.2 的"链接"阶段吗?它有两种做法:
- 静态链接:把所有用到的库代码全部打包进可执行文件 → 文件大,但独立、不依赖外部库;
- 动态链接:运行时才去加载共享库(
.so)→ 文件小、多个程序可共享同一份库代码。
动态链接引入了"共享库 + 运行时重定位"的概念,这也和后面要讲的 mmap、共享内存密切相关。
小结与思考题
这一课,我们回答了"程序住在哪":
- 三种地址:虚拟地址(程序用)、物理地址(硬件用)、逻辑地址(早期偏移概念),MMU 在中间翻译;
- 四个阶段:编译定符号 → 链接定布局 → 装载建空间 → 运行做翻译;
- 地址绑定:编译时/装载时/运行时,现代 OS 用运行时(灵活);
- 内存布局:栈(局部变量,向下长)、堆(malloc,向上长)、数据段(全局/静态)、代码段(只读指令)。
留三个问题:
- 虚拟地址和物理地址的区别?
- 局部变量、
malloc变量、全局变量分别在哪一段? - 为什么现代 OS 用"运行时地址绑定"?
这一课我们看到了"虚拟地址"这个假象,也知道了 MMU 在中间翻译。但为什么要费这么大劲造这个假象? 它到底带来什么好处?下一课,我们走进虚拟内存的核心——它如何让每个进程拥有"独立世界"、如何"假装"内存比实际大、又如何让多个进程共享同一段代码。翻译的机制(页表、缺页、TLB),我们一并揭晓。