探秘 Linux 系统编程:进程地址空间的奇妙世界

亲爱的读者朋友们😃,此文开启知识盛宴与思想碰撞🎉。

快来参与讨论💬,点赞👍、收藏⭐、分享 📤,共创活力社区。

在 Linux 系统编程的领域里,进程地址空间可是个相当重要的概念🤔。它就像是一个神秘的魔法盒子,藏着许多有趣又关键的知识。今天,就让我们一起打开这个盒子,看看里面都有什么奇妙的东西吧🧐!


目录

[一、C 语言内存管理基础:内存的 "小秘密"](#一、C 语言内存管理基础:内存的 “小秘密”)

[1. 内存区域大揭秘](#1. 内存区域大揭秘)

[2. 栈区和堆区的 "小脾气"](#2. 栈区和堆区的 “小脾气”)

[3. 静态变量的 "特殊待遇"](#3. 静态变量的 “特殊待遇”)

[二、fork 遗留问题:变量的 "神奇分身术"](#二、fork 遗留问题:变量的 “神奇分身术”)

[三、进程地址空间:进程的 "专属小世界"](#三、进程地址空间:进程的 “专属小世界”)

[1. 什么是地址空间](#1. 什么是地址空间)

[2. 地址空间的区域划分](#2. 地址空间的区域划分)

[3. 进程地址空间的奥秘](#3. 进程地址空间的奥秘)

[四、页表:进程地址空间的 "大管家"](#四、页表:进程地址空间的 “大管家”)

[1. 写时拷贝、缺页中断和惰性加载](#1. 写时拷贝、缺页中断和惰性加载)

[2. 进程地址空间的切换](#2. 进程地址空间的切换)

[3. 进程创建的过程](#3. 进程创建的过程)

[4. 进程的独立性](#4. 进程的独立性)

[五、为什么要有进程地址空间:进程的 "保护罩" 和 "便利贴"](#五、为什么要有进程地址空间:进程的 “保护罩” 和 “便利贴”)

[六、命令行参数和环境变量的 "藏身之处"](#六、命令行参数和环境变量的 “藏身之处”)


一、C 语言内存管理基础:内存的 "小秘密"

在 C 语言的内存管理世界中,有一个有趣的现象😉。如果一个指针指向了常量字符串,这个字符串存放在常量区,是只读的,不能被修改。要是强行修改,程序就会崩溃哦😱,就像下面这段代码:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>

int main() {
    char *str = "hello bit";
    *str = 'H';

    printf("xxx=%s\n", getenv("xxx"));
    return 0;
}

1. 内存区域大揭秘

C 语言中的线性地址是有区域划分的,从高地址到低地址分别是栈区、堆区、未初始化全局变量区、全局数据区(初始化全局变量在这)、字符常量区和代码区🎯。

怎么验证这个划分是不是正确的呢?我们可以通过代码在不同区域创建变量,然后获取它们的地址并比较,就像这样:

cpp 复制代码
#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. 栈区和堆区的 "小脾气"

栈区和堆区就像两个性格相反的小伙伴😜。栈区的地址是逐渐变低的(栈区向地址减少方向增长),我们可以通过在栈区定义多个变量,观察它们地址的变化来验证:

cpp 复制代码
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);
}

运行结果如下👇

堆区的地址则是逐渐变高的(堆区向地址增大方向增长),同样可以用代码来验证:

cpp 复制代码
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 呢?看下面这个实验代码:

cpp 复制代码
#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 的空间😏。用结构体来表示就是这样:

cpp 复制代码
struct area {
    int start;
    int end;
};
struct destop_area {
    struct area xiaopang;
    struct area xiaohua;
};
struct destop_area line_area = {{1, 50}, {51, 100}};
复制代码

如果想改变区域大小,修改结构体里的startend就行啦。而且在地址空间的范围内,每一个最小单位都有地址,这些地址都可以被使用哦😎。

3. 进程地址空间的奥秘

进程地址空间本质上是一个描述进程可视化范围的地址空间,里面有各种区域划分,它是一个内核数据结构,和 PCB(进程控制块)一样,都需要被操作系统管理,也就是 "先描述再组织"🤗。每个进程都有自己的进程地址空间,PCB 里有个指针指向这块空间。在 32 位系统中,默认划分的区域大小是 4GB,这里面详细划分了代码区、只读数据区、初始化数据区等多个区域,每个区域都有自己的 "职责"。


四、页表:进程地址空间的 "大管家"

在现代操作系统中,页表起着至关重要的作用,它就像一个 "大管家",管理着进程地址空间的各种事务😎。

1. 写时拷贝、缺页中断和惰性加载

  • 页表里记录了虚拟地址、物理地址、读写权限、标志位(用于判断代码和数据是否被加载到内存中)等信息📋。

  • 读写权限可以防止非法操作,比如你想修改常量字符区的内容,操作系统就会通过页表检查拦截这个非法请求,保护物理内存的安全🛡️。

  • 标志位能帮助我们判断进程的代码和数据有没有被加载到内存中。因为进程的代码和数据可能处于挂起状态,还没被加载进来。

  • 惰性加载就是 "按需加载",操作系统不会一下子把大文件全部加载到内存,而是需要多少就加载多少,是不是很聪明呢😜?

  • 缺页中断是指当执行进程时,如果发现标志位显示当前代码和数据没有加载,就会暂时中断这个进程,等代码和数据加载进来后,再恢复原来的状态继续运行。有人可能会问,为什么不一次全部加载进去呢?这是因为文件可能很大,一次加载不仅占用大量内存,而且你也不是一下子就把所有内容都用上呀,所以缺页中断能更合理地使用内存呢🧐。

  • 写时拷贝也很有趣,数据区的数据本来是可写的,但一开始权限被设置成只读(这时父子进程共享数据)。一旦父子进程有一方想修改数据,发现是只读的,系统不会报错,而是会开辟一块新的物理内存,修改页表的映射,实现数据的分离,就像给每个进程都准备了一份属于自己的数据 "拷贝" 一样😃。

2. 进程地址空间的切换

进程 PCB 结构体里有指向进程地址空间的指针,进程切换就意味着进程地址空间也被切换啦。而页表会被存储在 CPU 的 cr3 寄存器中,这属于进程的上下文信息,在进程切换的时候会跟着进程 "走",之后还能恢复过来,保证进程再次运行时一切正常🤗。

3. 进程创建的过程

进程创建时,会优先加载 PCB 结构体和对应的进程地址空间结构体,而它的代码和数据可能不会马上被加载进来,就像先搭建好 "房子框架",里面的 "家具"(代码和数据)之后再慢慢摆放一样😉。

4. 进程的独立性

进程具有独立性,主要体现在以下几个方面:

  • 在内核数据结构上是独立的,每个进程都有自己专属的 "小账本"(内核数据结构)。
  • 物理内存中加载的代码和数据,通过页表映射不同的物理地址,让父子进程 "互不干扰"。即使虚拟地址看起来一样,但通过页表映射到不同的物理地址,就实现了解耦。这样一来,一旦某个进程出现异常,不会影响其他进程,各自释放各自的资源就行啦😎。
  • 通过页表的虚拟地址映射物理地址,进程可以随便取地址,甚至是乱序的。但对于进程来说,看到的是一个线性的地址,就好像所有地址都是按顺序排列的,是不是很神奇呢🧐?

五、为什么要有进程地址空间:进程的 "保护罩" 和 "便利贴"

有了进程地址空间,好处可多啦😃!

  • 让进程以统一的视角看待内存,进程不需要关心数据具体放在物理内存的什么位置,也不用担心会影响别人的数据,这些复杂的工作都交给操作系统去完成,进程只要 "安心使用" 就行啦,就像有个贴心的小助手帮你打理一切琐事一样🤗。
  • 增加虚拟地址空间,在访问内存时多了一个转换过程。在这个过程中,操作系统可以对寻址进行审查,如果发现异常访问,直接拦截,不让请求到达物理内存,这就像给物理内存穿上了一层坚固的 "保护罩"🛡️,保护它不被非法访问。
  • 地址空间和页表的存在,将进程管理模块和内存模块解耦合。进程不用关心申请物理内存的哪一块、优先加载可执行程序的哪一部分、页表填写到什么地方等问题,这些都由 Linux 的内存模块负责管理,进程只要专注于自己的 "本职工作" 就好啦😎。
  • 变量名在定义的时候其实就已经被转化成虚拟地址了,我们使用a&a,本质上是为了区分获取变量的值还是地址,是不是有一种恍然大悟的感觉呢🧐?
  • 以前我们学习的 C 内存管理,本质上就是进程地址空间,而内存管理的具体细节是由 Linux 完成的。我们在写上层语言代码时,不需要关心这些细节,直接通过线性地址使用内存就行啦,就像用 "便利贴" 一样方便😜。

六、命令行参数和环境变量的 "藏身之处"

命令行参数和环境变量存放在栈的上面,是一个独立的空间🧐。子进程能够继承父进程的环境变量,是因为子进程启动时,父进程已经把环境变量信息加载进去了。它们也是地址空间的一部分,有页表帮助建立虚拟地址和物理地址的映射。子进程创建时,会复制父进程虚拟地址空间中环境变量的相关参数映射,所以即使传参数,子进程也能获取到父进程的环境变量信息啦😎。


怎么样,进程地址空间是不是很有趣呢😃?希望通过这篇文章,你能对它有更深入的了解🧐!

我将持续更新Linux的高质量内容,欢迎关注我👉【A charmer】

相关推荐
A小辣椒21 小时前
TShark:Wireshark CLI 功能
linux
A小辣椒1 天前
TShark:基础知识
linux
AlfredZhao1 天前
OCI 明明分配了 200G 系统盘,为什么 df 只看到 30G?
linux·oci
AlfredZhao2 天前
vi 删除指定范围的行,不用再反复按 dd
linux·vi
用户9718356334662 天前
银河麒麟 KY10 申威(SW64) 安装 nginx-1.16.1-2.p01.ky10.sw_64.rpm 详细步骤
linux
猪脚踏浪2 天前
linux 拷贝文件或目录到指定的位置
linux
摇滚侠3 天前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
bush43 天前
嵌入式linux学习记录十四、术语
linux·嵌入式
载数而行5203 天前
Linux 11 动态监控指令top
linux
不会C语言的男孩3 天前
Linux 系统编程 · 第 8 章:进程基础
linux·c语言