【Linux系统】程序地址空间(初识)

📅 2026/7/18 13:42:25
【Linux系统】程序地址空间(初识)
目录一、内存空间的分布1. 基本分布情况2. 验证分布的正确性二、进程地址空间1. 虚拟地址2. 初步理解进程地址空间3. 理解进程地址空间的细节4. 关于页表三、进程地址空间的意义一、内存空间的分布1. 基本分布情况之前在【C】动态内存管理中我们浅浅的提到过程序内存区域的基本划分也就是如下这个图以32位计算机为例其中从低地址到高地址我们常常使用的到的区域如下代码段通常存储的是可执行的代码和只读常量。全局数据段分为初始化数据存放程序中已显式初始化的全局变量和静态局部变量和未初始化数据存放程序中已声明但未显式初始化的全局变量和静态局部变量。其生命周期贯穿整个程序运行期间。堆区用于动态内存分配。使用地址时堆区是从低地址向高地址增长的。栈区用于存储函数调用时的局部变量、函数参数和返回地址。使用地址时栈区是由高地址向低地址增长的。当函数调用结束时其对应的栈帧会自动被释放。命令行参数环境变量存储程序启动时传入的命令行参数如 argv和环境变量如PATH。而共享区、内核空间以后再谈。2. 验证分布的正确性我们可以使用C语言代码就可以来验证一下上面这5个区域的位置关系使用的代码如下所示#include stdio.h #include stdlib.h int g_val1 1; int g_val2; int main(int argc, char *argv[], char *env[]) { // 代码段 printf(code addr: %p\n, main); // 可执行代码 const char* str hello world; printf(only read string addr: %p\n, str); // 只读常量 // 全局数据区 printf(init global value addr: %p\n, g_val1); // 初始化全局变量 printf(uninit global value addr: %p\n, g_val2); // 未初始化全局变量 // 堆-向上增长 char *mem1 (char*)malloc(100); char *mem2 (char*)malloc(100); char *mem3 (char*)malloc(100); printf(heap addr: %p\n, mem1); printf(heap addr: %p\n, mem2); printf(heap addr: %p\n, mem3); // 栈-向下增长 int a; int b; int c; printf(stack addr: %p\n, a); printf(stack addr: %p\n, b); printf(stack addr: %p\n, c); // 命令行参数和环境变量 int i 0; for(; argv[i]; i) printf(argv[%d]: %p\n, i, argv[i]); for(i0; env[i]; i) printf(env[%d]: %p\n, i, env[i]); return 0; }运行结果如下[qjxiZ2vc2jyw3kvrn4om8j39lZ address_space]$ ./myproc -a -b -c -d code addr: 0x40055d only read string addr: 0x4007cf init global value addr: 0x601034 uninit global value addr: 0x60103c heap addr: 0x1ea1010 heap addr: 0x1ea1080 heap addr: 0x1ea10f0 stack addr: 0x7ffe2742ff24 stack addr: 0x7ffe2742ff20 stack addr: 0x7ffe2742ff1c argv[0]: 0x7ffe274317e7 argv[1]: 0x7ffe274317f0 argv[2]: 0x7ffe274317f3 argv[3]: 0x7ffe274317f6 argv[4]: 0x7ffe274317f9 env[0]: 0x7ffe274317fc env[1]: 0x7ffe27431811 env[2]: 0x7ffe27431832 env[3]: 0x7ffe2743183d env[4]: 0x7ffe2743184d env[5]: 0x7ffe2743185b env[6]: 0x7ffe2743187d env[7]: 0x7ffe2743188e env[8]: 0x7ffe274318a1 env[9]: 0x7ffe274318aa env[10]: 0x7ffe274318ed env[11]: 0x7ffe27431e89 env[12]: 0x7ffe27431ea2 env[13]: 0x7ffe27431efc env[14]: 0x7ffe27431f23 env[15]: 0x7ffe27431f34 env[16]: 0x7ffe27431f4b env[17]: 0x7ffe27431f53 env[18]: 0x7ffe27431f62 env[19]: 0x7ffe27431f6e env[20]: 0x7ffe27431fa2 env[21]: 0x7ffe27431fc5 env[22]: 0x7ffe27431fe4说明可以看到从代码段到栈各个区域地址是从低地址到高地址的符合上面那个图的结构。并且堆区地址在增大栈区地址在减小。对于命令行参数和环境变量可以得出从低到高先是命令行参数再是环境变量。除此之外如果我们把static修饰的变量地址也打印出来的话上面没写可以自己验证就会发现它位于全局数据区中所以我们也可以得到一个语法验证static修饰的局部变量为什么可以一直保存就是因为它被保存到了全局数据区生命周期贯穿整个程序运行期间。二、进程地址空间注意之后说的进程地址空间程序地址空间虚拟地址空间其实都是一个东西。1. 虚拟地址之外我们在说fork的时候说到同一个变量名也就是fork的返回值是怎么让父子进程看到不用的内容的对于这个问题。我们可以来看下面这个代码#include stdio.h #include stdlib.h #include unistd.h int g_val 100; int main() { pid_t id fork(); if(id 0) { // 子进程 int cnt 5; while(1) { printf(I am child, pid : %d, ppid : %d, 全局变量- g_val: %d, g_val: %p\n, getpid(), getppid(), g_val, g_val); sleep(1); // 5秒后改变g_val的值 cnt--; if(cnt 0) { g_val 200; printf(子进程改变g_val100 -- 200\n); } } } else { // 父进程 while(1) { printf(I am father, pid : %d, ppid : %d, 全局变量- g_val: %d, g_val: %p\n, getpid(), getppid(), g_val, g_val); sleep(1); } } return 0; }这段代码就让父子进程同时执行时当子进程运行了5秒后就修改这个全局变量的值在观察对应的值与地址变化。运行结果如下所示我们知道如果在fork之后子进程要修改父进程的变量就必须进行写时拷贝理论上100和200这个两个变量的地址一个是不一样的。但这是就会发现很奇怪的现象修改前后全局变量的值确实发生修改了但是地址却是同一个。但是怎么可能同一个变量同一个地址读取到了不同的值呢所以这个地址就一定不是物理地址如果这个地址是真实的物理地址那么同一块物理内存不可能同时存储 100 和 200 两个值。因此我们可以断定程序打印出的地址一定不是物理地址。在Linux下这种地址叫做虚拟地址。我们编写代码时看到的所有地址本质上都是虚拟地址。真正的物理地址由操作系统内核统一管理对用户而言是不可见的。这也解释了为什么父子进程拥有独立的内存空间写时拷贝机制却可以使用相同的虚拟地址。所以本篇文章最开头的内存空间分布的图片并不是物理内存的分布而是进程中的虚拟内存分布图。2. 初步理解进程地址空间对应每一个进程都有它自己的一套进程地址空间在它申请使用空间的时候并不是直接使用物理地址上的空间而是会间接通过进程地址空间上的虚拟地址和物理地址进行映射映射的方法就是通过一个页表页表可以理解成事一种映射关系。因此在进程的视角中只能看到虚拟地址而物理内存的具体分配与管理则完全由操作系统内核负责。通过下面这个图就可以很清晰的看出进程是怎么使用地址的了注意这里的“写时拷贝”机制当子进程试图修改原本与父进程共享的数据时操作系统会在物理内存中分配一块新空间并复制数据随后更新子进程页表的映射关系使其指向这块新物理内存。在此过程中子进程的虚拟地址保持不变。3. 理解进程地址空间的细节地址空间是什么如果是在32位的计算机中则有32位的地址和数据总线每一根总线只有0和1两个状态。这意味着CPU发出的地址信号也是32位的。因此CPU能够寻址的内存范围是个字节即 4GB。这个 0 到的编号范围就被称为该系统的物理地址空间。虽然 CPU 理论上能寻址 4GB 的物理空间但在现代多任务操作系统中如果让所有程序直接操作这 4GB 物理内存会面临两个严重问题安全性缺失一个程序可能会意外或恶意修改另一个程序的数据甚至覆盖操作系统的核心代码导致系统崩溃。资源竞争如果有两个程序都想使用0x00001000这个物理地址谁该获得使用权为了解决这些问题操作系统引入了进程地址空间。进程地址空间在现代操作系统中操作系统给每一个进程都画了一个“大饼”每个运行的进程都认为自己独占了整个 4GB 的内存空间但实际上每个进程都只是用多少申请多少。这就是进程地址空间。每个进程都有自己独立的页表。进程 A 访问虚拟地址0x1000和进程 B 访问虚拟地址0x1000通过各自的页表映射后实际上指向的是完全不同的物理内存块。关于进程地址空间的区域划分在典型的 32 位 Linux 系统中虽然每个进程都有 4GB 的虚拟空间但这并不意味着进程可以随意使用全部 4GB这 4GB 空间被划分为两部分内核空间和用户空间就是开头的那个图。在Linux中进程地址空间本质就是内核中的一个数据结构体对象名字叫mm_struct。和PCB类似它也是被操作系管理的先描述再组织。而其中各个区域则都是通过start和end来标记范围进行区分的关于mm_struct的源代码内容如下图所示这些start和end都是在 mm_struct 结构体中定义的。在Linux2.6的源码中进程地址空间是在task_strcut中通过 mm_struct* mm 指针来访问中。通过上面的理解我们就知道了进程地址空间中是有区域划分的。4. 关于页表关于页表这里有以下需要注意页表保存在哪在CUP中有一个cr3寄存器它保存了当前进程的页表的地址它页属于进程的上下文。所以当进程切换的时候页面页会跟着切换。页表的标记位页表除了虚拟地址和物理地址的映射还有一些其他的标记位权限位在不在内存等。对于权限位比如常量区和代码区都是只读的所以虚拟地址处于常量区和代码区的地址在页表中的权限位都是r又比如全局数据区它的数据是可度可写的所以是 rw......对于在不在内存的标志如果通过虚拟地址来查物理地址发现该标志位在不在内存的标志表示的是不在内存则会触发 缺页中断 具体的在内存管理模块再谈。若在则正常访问。三、进程地址空间的意义最后来看于进程地址空间和页表的意义。1. 让进程一统一的视角看待内存结构。每个进程都觉得自己拥有这个空间因为每个进程都拥有自己独立的地址空间这让它们产生了一种“独占内存”的错觉。例如进程 A 和进程 B 访问同一个地址0x100虽然编号相同但通过页表映射后指向的是完全不同的物理位置互不干扰。如果没有虚拟地址空间操作系统必须在 PCB 中记录代码和数据在物理内存中的具体碎片位置管理极其复杂如果有了虚拟地址空间进程只需面对一个连续、线性的逻辑视图无需关心底层物理存储的细节了。2. 进程地址空间和页表可以进程安全检查。当 CPU 拿着一个虚拟地址去访问内存时它不能直接去物理内存找必须先查页表。在这个过程中会先在页表中检查这个地址是否有访问权限比如是只读还是可读写。如果程序试图访问不属于它的区域或者以错误的方式访问比如往只读的代码段写入数据转换过程就会失败触发异常从而保护了物理内存和其他进程的数据。3.将进程管理和内存管理解耦。进程管理模块只需要关注给进程分配了多少虚拟地址空间不需要知道这它到底对应物理内存的哪一块甚至不需要知道物理内存是否真的分配了内存管理模块则专注于物理内存的使用情况它不在乎是哪个进程在使用只在乎物理页框的分配与回收。如图所示解耦后进程启动时内存管理模块甚至可以不分配任何物理内存。它只是在虚拟空间里建立了映射关系。 只有当 CPU 真正去访问某个虚拟地址发现没有对应的物理页时触发缺页中断内存管理模块才会介入临时分配一块物理内存并建立映射。 这意味着进程可以在物理内存极度紧张的情况下依然能够“存在”和“运行”极大地提高了系统的并发能力。并且解耦之后对进程而言 它看到的永远是连续的、完美的线性地址空间。无论物理内存多么支离破碎进程都不需要编写复杂的算法去适应物理内存的布局。以上便是我们对进程地址空间进行的一次初步认识解决了为什么一个变量会有两个不同的值的问题知道了进程地址空间和页表是干什么的以及关于Linux源代码中进程地址空间的mm_struct认识。后续还会有跟深入的认识...感谢各位观看希望大家多多支持