亲爱的读者朋友们😃,此文开启知识盛宴与思想碰撞🎉。
快来参与讨论💬,点赞👍、收藏⭐、分享📤,共创活力社区。
在 Linux 系统编程的领域里,进程地址空间可是个相当重要的概念🤔。它就像是一个神秘的魔法盒子,藏着许多有趣又关键的知识。今天,就让我们一起打开这个盒子,看看里面都有什么奇妙的东西吧🧐!
目录
一、C 语言内存管理基础:内存的 “小秘密”
1. 内存区域大揭秘
2. 栈区和堆区的 “小脾气”
3. 静态变量的 “特殊待遇”
二、fork 遗留问题:变量的 “神奇分身术”
三、进程地址空间:进程的 “专属小世界”
1. 什么是地址空间
2. 地址空间的区域划分
3. 进程地址空间的奥秘
四、页表:进程地址空间的 “大管家”
1. 写时拷贝、缺页中断和惰性加载
2. 进程地址空间的切换
3. 进程创建的过程
4. 进程的独立性
五、为什么要有进程地址空间:进程的 “保护罩” 和 “便利贴”
六、命令行参数和环境变量的 “藏身之处”
一、C 语言内存管理基础:内存的 “小秘密”
在 C 语言的内存管理世界中,有一个有趣的现象😉。如果一个指针指向了常量字符串,这个字符串存放在常量区,是只读的,不能被修改。要是强行修改,程序就会崩溃哦😱,就像下面这段代码:
#include <stdio.h>
#include <stdlib.h>int main() {char *str = "hello bit";*str = 'H';printf("xxx=%s\n", getenv("xxx"));return 0;
}
1. 内存区域大揭秘
C 语言中的线性地址是有区域划分的,从高地址到低地址分别是栈区、堆区、未初始化全局变量区、全局数据区(初始化全局变量在这)、字符常量区和代码区🎯。
怎么验证这个划分是不是正确的呢?我们可以通过代码在不同区域创建变量,然后获取它们的地址并比较,就像这样:
#include <stdio.h>
#include <stdlib.h>int g_val_1;
int g_val_2 = 100;int main() {printf("code addr: %p\n", main);const char *str = "hello bit";printf("read only string addr: %p\n", str);printf("init global value addr: %p\n", &g_val_2);printf("uninit global value addr: %p\n", &g_val_1);char *mem = (char*)malloc(100);printf("heap addr: %p\n", mem);printf("stack addr: %p\n", &str);
}
运行这段代码,会得到不同区域变量的地址,从而验证内存区域的划分🧐。
运行结果如下👇
验证成功!
2. 栈区和堆区的 “小脾气”
栈区和堆区就像两个性格相反的小伙伴😜。栈区的地址是逐渐变低的(栈区向地址减少方向增长),我们可以通过在栈区定义多个变量,观察它们地址的变化来验证:
int main() {printf("code addr: %p\n", main);const char *str = "hello bit";printf("read only string addr: %p\n", str);printf("init global value addr: %p\n", &g_val_2);printf("uninit global value addr: %p\n", &g_val_1);char *mem = (char*)malloc(100);printf("heap addr: %p\n", mem);printf("stack addr: %p\n", &str);printf("stack addr: %p\n", &mem);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 main() {printf("code addr: %p\n", main);const char *str = "hello bit";printf("read only string addr: %p\n", str);printf("init global value addr: %p\n", &g_val_2);printf("uninit global value addr: %p\n", &g_val_1);char *mem = (char*)malloc(100);char *mem1 = (char*)malloc(100);char *mem2 = (char*)malloc(100);printf("heap addr: %p\n", mem);printf("heap addr: %p\n", mem1);printf("heap addr: %p\n", mem2);printf("stack addr: %p\n", &str);printf("stack addr: %p\n", &mem);int a;int b;int c;printf("stack addr: %p\n", &a);printf("stack addr: %p\n", &b);printf("stack addr: %p\n", &c);
}
运行结果如下👇
3. 静态变量的 “特殊待遇”
静态变量也很特别哦😎,它被定义在全局区,但只在作用域里使用。第一次使用时初始化,之后它的生命周期就不随着函数的调用和释放而变化啦,就像有自己的 “小天地” 一样。
用代码来验证:
说明static 修饰的局部变量,编译的时候已经被编译到全局数据区!
二、fork 遗留问题:变量的 “神奇分身术”
在使用fork
函数时,会遇到一个有趣的问题🤯:为什么一个变量可以同时等于 0 又大于 0 呢?看下面这个实验代码:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>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);if (cnt) cnt--;else {g_val = 200;printf("子进程change g_val:100->200\n");}}} else {// 父进程while (1) {printf("i am parent, pid : %d, ppid : %d, g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);sleep(1);}}
}
运行这段代码,会发现同一个地址竟然读到了不同的内容😱!这就说明,我们平时在 C/C++ 里使用的地址不是物理地址,而是虚拟地址🧐。用户是看不到物理地址的,操作系统会负责把虚拟地址转化成物理地址,就像有个 “翻译官” 在中间帮忙一样。
三、进程地址空间:进程的 “专属小世界”
为了理解上面这些现象,我们要引入一个新的概念 —— 进程地址空间😃。
1. 什么是地址空间
在 32 位机器中,有 32 位的地址和数据总线。每根地址总线可以是 0 或 1(其实计算机识别的是高低电平,1 代表高电平,0 代表低电平),32 根地址总线就有种组合方式,对应的地址范围就是,这个范围就是地址空间啦🎯,就像一个超级大的 “地址仓库”。
2. 地址空间的区域划分
地址空间是有区域划分的,就好比一张 100cm 长的桌子,小胖和小美坐在上面,为了避免小胖骚扰小美,在中间画了三八线,每人各占 50cm 的空间😏。用结构体来表示就是这样:
struct area {int start;int end;
};
struct destop_area {struct area xiaopang;struct area xiaohua;
};
struct destop_area line_area = {{1, 50}, {51, 100}};
如果想改变区域大小,修改结构体里的start
和end
就行啦。而且在地址空间的范围内,每一个最小单位都有地址,这些地址都可以被使用哦😎。
3. 进程地址空间的奥秘
进程地址空间本质上是一个描述进程可视化范围的地址空间,里面有各种区域划分,它是一个内核数据结构,和 PCB(进程控制块)一样,都需要被操作系统管理,也就是 “先描述再组织”🤗。每个进程都有自己的进程地址空间,PCB 里有个指针指向这块空间。在 32 位系统中,默认划分的区域大小是 4GB,这里面详细划分了代码区、只读数据区、初始化数据区等多个区域,每个区域都有自己的 “职责”。
四、页表:进程地址空间的 “大管家”
在现代操作系统中,页表起着至关重要的作用,它就像一个 “大管家”,管理着进程地址空间的各种事务😎。
1. 写时拷贝、缺页中断和惰性加载
- 页表里记录了虚拟地址、物理地址、读写权限、标志位(用于判断代码和数据是否被加载到内存中)等信息📋。
- 读写权限可以防止非法操作,比如你想修改常量字符区的内容,操作系统就会通过页表检查拦截这个非法请求,保护物理内存的安全🛡️。
- 标志位能帮助我们判断进程的代码和数据有没有被加载到内存中。因为进程的代码和数据可能处于挂起状态,还没被加载进来。
- 惰性加载就是 “按需加载”,操作系统不会一下子把大文件全部加载到内存,而是需要多少就加载多少,是不是很聪明呢😜?
- 缺页中断是指当执行进程时,如果发现标志位显示当前代码和数据没有加载,就会暂时中断这个进程,等代码和数据加载进来后,再恢复原来的状态继续运行。有人可能会问,为什么不一次全部加载进去呢?这是因为文件可能很大,一次加载不仅占用大量内存,而且你也不是一下子就把所有内容都用上呀,所以缺页中断能更合理地使用内存呢🧐。
- 写时拷贝也很有趣,数据区的数据本来是可写的,但一开始权限被设置成只读(这时父子进程共享数据)。一旦父子进程有一方想修改数据,发现是只读的,系统不会报错,而是会开辟一块新的物理内存,修改页表的映射,实现数据的分离,就像给每个进程都准备了一份属于自己的数据 “拷贝” 一样😃。
2. 进程地址空间的切换
进程 PCB 结构体里有指向进程地址空间的指针,进程切换就意味着进程地址空间也被切换啦。而页表会被存储在 CPU 的 cr3 寄存器中,这属于进程的上下文信息,在进程切换的时候会跟着进程 “走”,之后还能恢复过来,保证进程再次运行时一切正常🤗。
3. 进程创建的过程
进程创建时,会优先加载 PCB 结构体和对应的进程地址空间结构体,而它的代码和数据可能不会马上被加载进来,就像先搭建好 “房子框架”,里面的 “家具”(代码和数据)之后再慢慢摆放一样😉。
4. 进程的独立性
进程具有独立性,主要体现在以下几个方面:
- 在内核数据结构上是独立的,每个进程都有自己专属的 “小账本”(内核数据结构)。
- 物理内存中加载的代码和数据,通过页表映射不同的物理地址,让父子进程 “互不干扰”。即使虚拟地址看起来一样,但通过页表映射到不同的物理地址,就实现了解耦。这样一来,一旦某个进程出现异常,不会影响其他进程,各自释放各自的资源就行啦😎。
- 通过页表的虚拟地址映射物理地址,进程可以随便取地址,甚至是乱序的。但对于进程来说,看到的是一个线性的地址,就好像所有地址都是按顺序排列的,是不是很神奇呢🧐?
五、为什么要有进程地址空间:进程的 “保护罩” 和 “便利贴”
有了进程地址空间,好处可多啦😃!
- 让进程以统一的视角看待内存,进程不需要关心数据具体放在物理内存的什么位置,也不用担心会影响别人的数据,这些复杂的工作都交给操作系统去完成,进程只要 “安心使用” 就行啦,就像有个贴心的小助手帮你打理一切琐事一样🤗。
- 增加虚拟地址空间,在访问内存时多了一个转换过程。在这个过程中,操作系统可以对寻址进行审查,如果发现异常访问,直接拦截,不让请求到达物理内存,这就像给物理内存穿上了一层坚固的 “保护罩”🛡️,保护它不被非法访问。
- 地址空间和页表的存在,将进程管理模块和内存模块解耦合。进程不用关心申请物理内存的哪一块、优先加载可执行程序的哪一部分、页表填写到什么地方等问题,这些都由 Linux 的内存模块负责管理,进程只要专注于自己的 “本职工作” 就好啦😎。
- 变量名在定义的时候其实就已经被转化成虚拟地址了,我们使用
a
和&a
,本质上是为了区分获取变量的值还是地址,是不是有一种恍然大悟的感觉呢🧐?- 以前我们学习的 C 内存管理,本质上就是进程地址空间,而内存管理的具体细节是由 Linux 完成的。我们在写上层语言代码时,不需要关心这些细节,直接通过线性地址使用内存就行啦,就像用 “便利贴” 一样方便😜。
六、命令行参数和环境变量的 “藏身之处”
命令行参数和环境变量存放在栈的上面,是一个独立的空间🧐。子进程能够继承父进程的环境变量,是因为子进程启动时,父进程已经把环境变量信息加载进去了。它们也是地址空间的一部分,有页表帮助建立虚拟地址和物理地址的映射。子进程创建时,会复制父进程虚拟地址空间中环境变量的相关参数映射,所以即使传参数,子进程也能获取到父进程的环境变量信息啦😎。
怎么样,进程地址空间是不是很有趣呢😃?希望通过这篇文章,你能对它有更深入的了解🧐!
我将持续更新Linux的高质量内容,欢迎关注我👉【A charmer】!