从这一课起,我们进入第四阶段:内存管理。前面我们一直在问"谁先跑、怎么不打架",现在要问一个更底层的问题:程序到底"住"在内存的哪里? 你在 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,向上长)、数据段(全局/静态)、代码段(只读指令)。

留三个问题:

  1. 虚拟地址和物理地址的区别?
  2. 局部变量、malloc 变量、全局变量分别在哪一段?
  3. 为什么现代 OS 用"运行时地址绑定"?
这一课我们看到了"虚拟地址"这个假象,也知道了 MMU 在中间翻译。但为什么要费这么大劲造这个假象? 它到底带来什么好处?下一课,我们走进虚拟内存的核心——它如何让每个进程拥有"独立世界"、如何"假装"内存比实际大、又如何让多个进程共享同一段代码。翻译的机制(页表、缺页、TLB),我们一并揭晓。

标签: 计算机基础, 操作系统, Linux

添加新评论