Linux系统编程Day13 -- 程序地址空间
往期内容回顾
环境变量(初识)
进程状态的优先级和特性
进程属性和常见进程
进程管理
理解计算机的软硬件管理
前言
在 C/C++ 程序的学习和调试过程中,你可能会遇到这样的问题:
-
为什么局部变量会“莫名其妙”被修改?
-
为什么访问一个随机指针会导致段错误(Segmentation Fault)?
-
为什么同样的程序运行在不同机器上,地址值可能不同?
这些现象都与 程序的地址空间(Program Address Space) 密切相关。
一、程序地址空间是什么?
所谓“程序地址空间”,指的是】。理解它能帮助我们更好地管理内存、优化性能、以及避免 Bug。
┌───────────────────────────┐ 高地址
│ 栈 Stack │ (局部变量、函数参数、返回地址等,向下增长)
├───────────────────────────┤
│ 空闲区 │ (堆和栈之间的空闲区域)
├───────────────────────────┤
│ 堆 Heap │ (malloc/new 分配的内存,向上增长)
├───────────────────────────┤
│ BSS 段 (.bss) │ (未初始化的全局变量、静态变量)
├───────────────────────────┤
│ 数据段 (.data) │ (已初始化的全局变量、静态变量)
├───────────────────────────┤
│ 代码段 (.text) │ (程序机器指令,常量字符串等)
└───────────────────────────┘ 低地址
C / C++ 中的典型程序地址示意图
高地址
│
│ 栈 Stack
│ ├── main 函数的局部变量
│ ├── 函数调用保存的返回地址
│ ├── 临时变量(C++ 里构造的临时对象)
│ └── 函数参数
│
│ (栈向低地址方向增长)
│
│ 共享库映射区(动态链接库)
│
│ 空闲区(堆和栈之间的未使用空间)
│
│ 堆 Heap
│ ├── C: malloc / free
│ ├── C++: new / delete
│ └── STL 容器动态分配的内存
│ (堆向高地址方向增长)
│
│ BSS 段 (.bss)
│ ├── 未初始化的全局变量: int g1;
│ └── static int s1;
│
│ 数据段 (.data)
│ ├── 已初始化的全局变量: int g2 = 10;
│ └── static int s2 = 20;
│
│ 代码段 (.text)
│ ├── 程序的机器指令
│ ├── 常量字符串("hello")
│ └── C++: 虚函数表、类型信息
│
低地址
为什么栈的内存向下、堆向上?
早期的操作系统设计者希望:
堆和栈从两端向中间增长,这样能最大限度利用进程的虚拟地址空间。
如果栈和堆都从同一方向增长,可能很快就会互相撞到,而另一半地址空间浪费掉。
安全性:不同增长方向可在中间加保护页,防止越界直接破坏另一块数据
二、程序地址空间与进程的关系
这里我们有一段c语言的代码
#include <complex.h> #include <stdatomic.h> #include <stdio.h> #include <unistd.h> #include <stdlib.h> int globalvalue = 100;int main(){pid_t id = fork();int cnt = 0;if(id < 0){printf("fork error!/n");}else if (id == 0) {while (1) {printf("Child process: pid = %d, ppid = %d, global value = %d, &globalvalue = %p\n",getpid(),getppid(),globalvalue,&globalvalue);sleep(1);cnt++;if(cnt == 10){globalvalue = 300;printf("The Child process has change the globalvalue!\n");}}}else {while(1){printf("Father process: pid = %d,ppid = %d, global value = %d, &globalvalue = %p\n",getpid(),getppid(),globalvalue,&globalvalue);sleep(2);}} }
这里我们创建了一个全局变量globalvalue,并且创建了父子进程,并且在子进程中修改了全局变量的值,然后分别在父子进程中打印输出:
运行程序:
我们查看进程和输出描述如下:
Child process: pid = 77763, ppid = 77762, global value = 100, &globalvalue = 0x104448000
Father process: pid = 77762,ppid = 25958, global value = 100, &globalvalue = 0x104448000
Child process: pid = 77763, ppid = 77762, global value = 100, &globalvalue = 0x104448000
Child process: pid = 77763, ppid = 77762, global value = 100, &globalvalue = 0x104448000
Father process: pid = 77762,ppid = 25958, global value = 100, &globalvalue = 0x104448000
Child process: pid = 77763, ppid = 77762, global value = 100, &globalvalue = 0x104448000
The Child process has change the globalvalue!
Child process: pid = 77763, ppid = 77762, global value = 300, &globalvalue = 0x104448000
Father process: pid = 77762,ppid = 25958, global value = 100, &globalvalue = 0x104448000
Child process: pid = 77763, ppid = 77762, global value = 300, &globalvalue = 0x104448000
Child process: pid = 77763, ppid = 77762, global value = 300, &globalvalue = 0x104448000
Father process: pid = 77762,ppid = 25958, global value = 100, &globalvalue = 0x104448000
Child process: pid = 77763, ppid = 77762, global value = 300, &globalvalue = 0x104448000
你会惊讶地发现,当子进程修改全局变量时,父进程的全局变量没有发生变化,但是它们全局变量的地址竟然是相同的!!
原因解释:
我们c语言/c++的程序地址并不是真实的地址-->虚拟地址
1. fork() 做了什么
操作系统会复制当前进程的整个地址空间,包括:
代码段(指令部分)
数据段(全局变量、静态变量)
堆(动态分配)
栈(局部变量、函数调用栈)
复制后的父子进程:
拥有完全相同的数据副本(初始值相同)
虚拟地址相同(所以 &globalvalue 打印出来一样)
但物理内存不同(写入时各用自己的副本)
2. 为什么地址一样但值不一样
这里的“地址”是虚拟地址,进程之间的虚拟地址可以相同,因为:
每个进程都有自己的虚拟地址空间。
虚拟地址通过页表映射到物理内存。
父子进程的虚拟地址相同,但映射到的物理页不同(在写时可能会分离)。
3. 为什么父进程没有看到子进程的修改
这是因为 Linux 在 fork() 后使用了 写时复制(Copy-On-Write,COW):
在 fork() 之后,父子进程会共享同一份物理内存,直到有一方写入这块内存。
当子进程 globalvalue = 300; 时,内核会:
分配一个新的物理页给子进程。
把原来的数据(值 100)复制过去。
子进程在新物理页上写入 300。
父进程依旧指向原来的物理页(值仍是 100)。
所以最终:
子进程:globalvalue = 300(新物理页)
父进程:globalvalue = 100(旧物理页)
4. 简单说明
fork 前:
父进程:
globalvalue(100) ---> [物理页 P]fork 后(初始):
父进程:globalvalue(100) --> [物理页 P] (共享, 只读)
子进程:globalvalue(100) --> [物理页 P] (共享, 只读)子进程写 globalvalue = 300 时:
父进程:globalvalue(100) --> [物理页 P]
子进程:globalvalue(300) --> [物理页 Q] (COW 分配的新页)
三、程序地址空间和进程管理的联系
1. 每个进程拥有独立的地址空间
-
操作系统为每个进程分配一个独立的虚拟地址空间,确保进程间的内存访问隔离。
-
进程只能访问自己的地址空间,不能直接访问其他进程的内存,保障系统安全与稳定。
-
这也是操作系统实现多任务的基础。
2. 虚拟内存机制是进程管理的核心
-
操作系统利用虚拟内存技术,实现程序地址空间到物理内存的映射。
-
每个进程看到的地址空间是虚拟的,物理内存由操作系统动态分配和管理。
-
通过页表、TLB(快表)等机制快速转换虚拟地址到物理地址。
3. 进程的创建和地址空间复制
-
使用 fork() 创建进程时,操作系统会复制父进程的地址空间(写时复制 Copy-On-Write)。
-
这保证了父子进程拥有独立的数据副本,避免相互干扰。
-
但因为是虚拟地址空间复制,父子进程看到相同的虚拟地址(例如变量的地址),但物理内存可能不同。
4. 进程切换时切换地址空间
-
CPU 调度时切换进程,需要切换页表,切换虚拟地址空间映射。
-
这确保切换到的进程访问的地址空间是正确的独立空间。
-
操作系统通过上下文切换保存和恢复地址空间映射状态。
5. 内存保护和进程隔离
-
程序地址空间利用硬件保护机制,防止进程访问未授权的内存区域。
-
如果进程访问越界,操作系统会产生异常(如段错误 SIGSEGV),保护系统安全。
6. 共享内存和映射文件
-
虽然进程地址空间相互独立,但操作系统允许通过特殊手段(共享内存、映射文件)实现进程间通信。
-
这些共享区在不同进程的地址空间中映射到相同的物理内存,实现数据共享。
四、总结
程序地址空间 | 进程管理 | 联系 |
---|---|---|
是每个进程独立的虚拟内存区域 | 负责进程的创建、调度、资源管理和销毁 | 操作系统通过管理程序地址空间实现进程的内存隔离与保护 |
包含代码段、数据段、堆、栈等 | 负责进程的内存分配与回收 | 进程创建时复制地址空间,调度时切换地址空间 |
利用虚拟内存技术映射到物理内存 | 通过页表、TLB等实现快速地址转换 | 地址空间映射是操作系统实现多进程并发运行的基础 |
进程间地址空间相互独立,互不干扰 | 进程切换时切换地址空间上下文 | 保障进程间安全与稳定 |
支持共享内存映射文件实现进程间通信 | 进程间通信的实现途径之一 | 共享内存作为特殊区域映射进多个地址空间,促进数据交换 |