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

目录

一、内存空间的分布

[1. 基本分布情况](#1. 基本分布情况)

[2. 验证分布的正确性](#2. 验证分布的正确性)

二、进程地址空间

[1. 虚拟地址](#1. 虚拟地址)

[2. 初步理解进程地址空间](#2. 初步理解进程地址空间)

[3. 理解进程地址空间的细节](#3. 理解进程地址空间的细节)

[4. 关于页表](#4. 关于页表)

三、进程地址空间的意义


一、内存空间的分布

1. 基本分布情况

之前,在【C++】动态内存管理中,我们浅浅的提到过程序内存区域的基本划分,也就是如下这个图(以32位计算机为例):

其中,从低地址到高地址,我们常常使用的到的区域如下:

  1. 代码段:通常存储的是可执行的代码和只读常量。
  2. 全局数据段 :分为初始化数据(存放程序中已显式 初始化的全局变量和静态局部变量)和未初始化数据(存放程序中已声明但未显式初始化的全局变量和静态局部变量)。其生命周期贯穿整个程序运行期间。
  3. 堆区:用于动态内存分配。使用地址时,堆区是从低地址向高地址增长的。
  4. 栈区:用于存储函数调用时的局部变量、函数参数和返回地址。使用地址时,栈区是由高地址向低地址增长的。当函数调用结束时,其对应的栈帧会自动被释放。
  5. 命令行参数环境变量:存储程序启动时传入的命令行参数(如 argv)和环境变量(如PATH)。

而共享区、内核空间以后再谈。

2. 验证分布的正确性

我们可以使用C语言代码,就可以来验证一下上面这5个区域的位置关系,使用的代码如下所示:

cpp 复制代码
#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(i=0; env[i]; i++)
        printf("env[%d]: %p\n", i, env[i]);
    return 0;
}

运行结果如下:

cpp 复制代码
[qjx@iZ2vc2jyw3kvrn4om8j39lZ 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的返回值)是怎么让父子进程看到不用的内容的?对于这个问题。我们可以来看下面这个代码:

cpp 复制代码
#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_val:100 --> 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 物理内存,会面临两个严重问题:

  1. 安全性缺失:一个程序可能会意外(或恶意)修改另一个程序的数据,甚至覆盖操作系统的核心代码,导致系统崩溃。
  2. 资源竞争 :如果有两个程序都想使用 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 拿着一个虚拟地址去访问内存时,它不能直接去物理内存找,必须先查页表。在这个过程中,会先在页表中检查这个地址是否有访问权限(比如是只读还是可读写)。如果程序试图访问不属于它的区域,或者以错误的方式访问(比如往只读的代码段写入数据),转换过程就会失败,触发异常,从而保护了物理内存和其他进程的数据。

  1. 将进程管理和内存管理解耦。

进程管理模块 只需要关注给进程分配了多少虚拟地址空间,不需要知道这它到底对应物理内存的哪一块,甚至不需要知道物理内存是否真的分配了;内存管理模块 则专注于物理内存的使用情况,它不在乎是哪个进程在使用,只在乎物理页框的分配与回收。如图所示:解耦后,进程启动时,内存管理模块甚至可以不分配任何物理内存。它只是在虚拟空间里建立了映射关系。 只有当 CPU 真正去访问某个虚拟地址,发现没有对应的物理页时(触发缺页中断),内存管理模块才会介入,临时分配一块物理内存并建立映射。 这意味着进程可以在物理内存极度紧张的情况下依然能够"存在"和"运行",极大地提高了系统的并发能力。

并且解耦之后,对进程而言, 它看到的永远是连续的、完美的线性地址空间。无论物理内存多么支离破碎,进程都不需要编写复杂的算法去适应物理内存的布局。


以上便是我们对进程地址空间进行的一次初步认识,解决了为什么一个变量会有两个不同的值的问题?知道了进程地址空间和页表是干什么的,以及关于Linux源代码中进程地址空间的mm_struct认识。后续还会有跟深入的认识...

感谢各位观看!希望大家多多支持!

相关推荐
wljy11 小时前
二、线程操作
linux·线程属性·线程操作
mounter6252 小时前
探索未来 AI 算力网络的基石:从传统 RoCE 走向 SRv6 驱动的弹性弹性网络(解析 Netdev 0x1A 创新实践)
linux·网络·人工智能·linux kernel·kernel·rdma·rocev2
x_x-F2 小时前
深入浅出 Linux 网络核心:sk_buff 的内存与指针流转
linux·运维·网络
潘正翔12 小时前
docker核心概念
linux·运维·服务器·docker·容器·centos·运维开发
weigangwin12 小时前
agent-workspace-linux 不是远程桌面:Linux AI 工作区的隔离边界验收
linux·xvfb·mcp·桌面隔离·权限边界·bubblewrap·agent-workspace
Dovis(誓平步青云)14 小时前
远程办公软件文件传输实测:6 款工具的速度、稳定性和办公体验对比
linux·运维·服务器·后端·生成对抗网络
Java小白笔记14 小时前
Docker 安装配置完全指南:MacOS 、Windows、Linux环境下的安装、配置与验证
linux·macos·docker
qetfw16 小时前
CentOS 7 搭建 LDAP 目录服务
linux·运维·centos
ALINX技术博客17 小时前
【黑金云课堂】FPGA技术教程Linux开发:系统进阶-PS DMA
linux·fpga开发