“地址空间”

在之前讨论C++内存管理,以及平常写C/C++程序时,有如下的存储空间布局:

Linux平台下的进程地址空间_进程的独立性

虽然不是所有的实例都按照上图所示的分区排布,但这是一种最典型的做法,足以说明问题。这个示意图与在C++内存管理中所示的相似,但还是需要说明一下:(方便起见,暂时将这个空间称为程序的“地址空间”)

  • 在32位机器下,地址空间的范围是[0, 232),这是由地址总线排列组合的范围决定的。
  • 地址空间被大体划分为两个部分:内核空间和用户空间。内核空间存放了操作系统内核相关的数据和信息,用户无法访问内核空间。
  • 栈。临时变量,以及函数调用时所需要保存的信息都存放在此段。栈是向下增长的,栈具有FILO的性质。栈的空间一般较小。
  • 堆。通常在此段中进行动态内存分配。堆的可用空间一般较大。
  • 正文代码。代码部分是只读的,不允许用户修改。
  • 未初始化数据段。此段保存未初始化的全局数据与静态数据。与初始化的数据不同的是,未初始化数据段内容不存放在磁盘中,这是因为其中的数据会被内核同意设置为0。需要存放在磁盘中的只有代码段和初始化数据段。
  • 对于命令行参数和环境变量区域,在下文进行详细讨论。

了解了上述的地址空间后,进行代码测试如下:

/*
*代码 1.1
*平台:centos7.6 x86_64
*/
#include <stdio.h>
#include <stdlib.h>

int global_init_var1 = 10;
int global_init_var2 = 20;
int global_init_var3 = 30;

int global_uninit_var1;
int global_uninit_var2;

int main()
{
  int st_var1 = 10;
  int st_var2 = 20;
  int st_var3 = 20;
  
  int* hp_var1 = (int*)malloc(sizeof(int));
  int* hp_var2 = (int*)malloc(sizeof(int));
  int* hp_var3 = (int*)malloc(sizeof(int));

  printf(" \
      global_init_var1: %p\n \
      global_init_var2: %p\n \
      global_init_var3: %p\n \
      global_uninit_var1: %p\n \
      global_uninit_var2: %p\n \
      st_var1: %p\n \
      st_var2: %p\n \
      st_var3: %p\n \
      hp_var1: %p\n \
      hp_var2: %p\n \
      hp_var3: %p\n",
      &global_init_var1, &global_init_var2, &global_init_var3, &global_uninit_var1, &global_uninit_var2, 
      &st_var1, &st_var2, &st_var3,
      hp_var1, hp_var2, hp_var3);

  return 0;
}

运行结果为:

Linux平台下的进程地址空间_页表_02

将输出的地址大致映射到地址空间:

Linux平台下的进程地址空间_环境变量_03

虽然具体的输出情况在不同的平台下会有所不同,但是通过观察现象,可以验证我们所做的说明是正确的。

虽然已经了解了“地址空间”空间划分情况,但是这个”地址空间”到底是什么,我们并不清楚。编写下面的代码并运行,观察现象:

/*
*代码 1.2
*/
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>

int main()
{
  int var = 10;
  printf("father: the value of var:%d the address of var:%p\n", var, &var);
  pid_t id = fork(); 
  if(id < 0) { perror("fork err"); exit(1); }
  else if(id == 0)
  {
    var = 20;
    printf("child: the value of var:%d the address of var:%p\n", var, &var);
  }
  else 
  {
    int status = 0;
    waitpid(id, &status, 0);
  }
  return 0;
}

运行结果为:

Linux平台下的进程地址空间_进程地址空间_04

我们看到了这样的现象:

  • 父进程和子进程的var变量的值不相等。这很好理解,子进程被创建后,与父进程共用代码部分和数据部分,直到子进程对变量进行修改,此时进行写时拷贝,为子进程另外创建一块空间单独存储 var 变量,并对 var 进行修改。
  • 父进程和子进程的var变量的地址相同。令人费解!毋庸置疑的是,子进程已经对父进程的变量 var 进行了写时拷贝,即此时父进程的 var 变量与子进程的 var 变量分别占据不同的空间,但是输出的二者的地址却相同,唯一的解释是:程序所输出的地址,是一个“虚拟”的地址。也就是说,我们之前讨论的“地址空间”,其中的地址其实并不是真实的物理地址,因为如果是物理地址,绝对不可能存在两个不同的值占据同一个位置的情况。我们称这个虚拟的地址为线性地址(linear address)

在C/C++语言中,我们所看到的地址全部都是线性地址(虚拟地址)。在现代操作系统中,用户无法看到真实的物理地址,物理地址由操作系统进行统一管理,同时,操作系统负责将虚拟地址转化为物理地址。

进程地址空间

之前所称“程序的地址空间”,准确应该称为进程地址空间(process address space)。进程地址空间是从某个最小值的存储位置(通常是0)到某个最大值的存储位置的列表。在进程地址空间中,进程可以进行读写。进程地址空间中存放有可执行程序的代码、数据以及程序的堆栈。在本质上,进程地址空间是地址总线排列组合形成的地址范围(32位机器下为[0, 232)),是进程可以引用的地址的集合。同时我们知道,这些地址都是虚拟地址。

进程地址空间是与每个进程都相关的,每个进程都有一个自己的进程地址空间,并且独立于其他进程的地址空间(一些需要进程共享地址空间的特殊情况除外)。在内核层面,进程地址空间被操作系统抽象为一个mm_struct结构体,这个结构体中记录了进程地址空间的分区情况等信息。在进程的 task_struct 结构体中,包含了一个struct mm_struct*指针,这个指针即指向了进程所对应的 mm_struct 结构体。

/*
*代码 2.1
linux-2.6.1\linux-2.6.1\include\linux\sched.h
*/
struct task_struct {
  /*…………*/
  
  //task_struct中的mm_struct指针
  struct mm_struct *mm, *active_mm;
  
  /*…………*/
};

进程地址空间的区域划分

进程地址空间的区域划分,即是我们已经所理解的,对未初始化全局数据、已初始化数据以及堆栈等的区域划分。在代码层面,“区域”本质是用“边界”来描述的,即描述一块区域,只需要描述这块区域的边界即可;而调整区域的大小,即是调整边界的位置。同时,一块区域中的任何地方,拥有者都是可以自由访问和使用的。

/*
*代码 2.2
linux-2.6.1\linux-2.6.1\include\linux\sched.h
*/
struct mm_struct {
  /*…………*/
  
  //mm_struct中对区域的划分
  unsigned long start_code, end_code, start_data, end_data;
  unsigned long start_brk, brk, start_stack;
  unsigned long arg_start, arg_end, env_start, env_end;
  unsigned long rss, total_vm, locked_vm;
  unsigned long def_flags;
  cpumask_t cpu_vm_mask;
  
  /*…………*/
};

页表概述、缺页中断概述和cr3寄存器

现在对代码1.2运行过程中,操作系统的行为做一分析。先单独看父进程与物理内存之间的关系:

Linux平台下的进程地址空间_进程的独立性_05

如上文所说,父进程 task_struct 中的 mm_struct 指针指向了父进程的进程地址空间(的抽象),进程地址空间中有 val 的虚拟地址。val 的虚拟地址,包括进程地址空间中用户区的其他区域的虚拟地址,都通过页表(page table)映射到物理内存的物理地址。

关于页表的更多内容,将在内存管理部分进行详细讨论,这里只针对相关部分进行说明。页表中大致有以下内容:

  • 进程地址空间的虚拟地址 - 物理内存的物理地址的映射。虚拟地址通过页表和MMU找到物理内存中对象的实际存储位置。
  • 内存中数据的访问权限标志位。针对对内存的越权操作,会被页表拦截。在进程运行时,父、子进程的数据区的页表项权限会被修改为只读,一旦某个进程要对数据进行修改,此时不做异常处理,而是另外申请物理内存完成写时拷贝,完成后,将父、子进程数据区的页表项权限修改为可写。
  • 存在(exist)标志位。在运行一个程序时,程序的代码和数据并非全部加载到物理内存中,页表可以对虚拟内存进行标记,如果需要用到某些代码和数据,而对虚拟内存的标记显示这些代码和数据不在内存中,就会触发缺页中断(page fault),此时操作系统便会将对应的代码和数据加载到内存中。写时拷贝本质属于一种缺页中断。

每个进程都有自己的页表,每个进程的页表是相互独立的。页表是由cr3寄存器进行维护的。cr3是CPU中的一个寄存器,它记录了当前正在运行的进程的页表的起始地址。一个进程的页表本质属于进程的上下文,当进程结束时,cr3中的地址会以进程上下文的形式被进程带走。

承上,当 fork() 子进程时,子进程会拷贝父进程的task_struct、mm_struct和页表,此时子进程被创建:

Linux平台下的进程地址空间_缺页中断_06

由于子进程的各种内核数据结构都拷贝自父进程,所以父、子进程的 val 的虚拟地址,以及虚拟地址由页表映射到的物理地址都是相同的。当子进程尝试对val 的值进程修改时,操作系统会进行写时拷贝,在物理空间额外开一块内存,将数据拷贝到新空间,并将新的物理地址填入子进程的页表中,页表中的虚拟地址不变。写时拷贝完成后,子进程可以通过虚拟地址和页表找到新的物理地址,对值进行修改。这个过程对进程的地址空间和页表中的虚拟地址部分而言是0感知的,虚拟地址不关心这个过程。

完成上述过程后,父、子进程内核结构分布大概如下:

Linux平台下的进程地址空间_进程的独立性_07

至此,已经可以解释代码1.2的运行结果中,输出的父、子进程的 val 地址相同的原因:操作系统进行写时拷贝后,只是修改了页表中的物理地址部分,写时拷贝对于虚拟地址来说是0感知的,所以子进程val的虚拟地址仍然与父进程相同,而且我们所看到的只能是虚拟地址,所以输出的val的地址相同。

进程地址空间的意义

理解地址空间后,现在对进程地址空间的意义做一总结:

  • 进程地址空间可以让进程以统一的视角看待内存
  • 访问内存时,增加一个转换的过程,在这个转换过程中,由于有进程地址空间区域划分的存在,可以针对用户的越界寻址进行检查和非法拦截
  • 进程地址空间和页表的存在,将进程管理模块与内存管理模块进行了解耦

Linux平台下的进程地址空间_进程地址空间_08

环境变量

这里首先在使用和命令层面讨论环境变量,再在代码以及进程管理层面讨论环境变量。

环境变量概述和查看

变量就是以一组文字或符号等,来替换一些设置或一串保留的数据。

Linux平台下的进程地址空间_进程地址空间_09

环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数。环境变量通常具有某些特殊用途,环境变量在系统中具有全局属性

使用env命令查看系统中的所有环境变量:

[@shr Thu Nov 09 21:48:37 mail]$ env
#......
HOSTNAME=VM-24-8-centos
TERM=xterm
SHELL=/bin/bash
HISTSIZE=3000
#......
SSH_TTY=/dev/pts/1
USER=shr
#......
MAIL=/var/spool/mail/shr
PATH=/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/shr/.local/bin:/home/shr/bin
PWD=/var/spool/mail
LANG=en_US.utf8
SHLVL=1
HOME=/home/shr
LOGNAME=shr
#......
XDG_RUNTIME_DIR=/run/user/1001
HISTTIMEFORMAT=%F %T 
_=/usr/bin/env
OLDPWD=/home/shr

一些常见的环境变量有:

  • HOME 代表用户的家目录。使用cd ~命令时,就是使用这个变量进入对应的目录。
  • PATH 指明了指定命令的查找路径,目录与目录之间以冒号( : )隔开。
  • SHELL 记录了当前使用的shell是哪个程序。
  • LANG 记录了语系数据。

环境变量相关命令和接口

关于环境变量的常用命令有:

  • echo 显示某个环境变量值 : echo $PATH
  • env 显示所有环境变量
  • export 新增一个环境变量
  • unset 清除一个环境变量
  • set 显示所有环境变量和本地定义的shell变量

在C/C++代码层面,有两种方式获取环境变量:

  • main()函数的第三个参数。main()函数的参数有两张表:命令行参数表和环境变量表,环境变量表以NULL结束。这两张表是由操作系统维护的,进程被创建时,这两张表会被加载。
/*
代码 3.1
*/
#include <stdio.h>

int main(int argc, char* argv[], char* env[])
{
  for(int i = 0; env[i]; ++i) {
    printf("%s\n", env[i]);
  }
  return 0;
}
  • 第三方变量environ。libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时要用extern声明。
/*
代码 3.2
*/
#include <stdio.h>

int main()
{
  extern char** environ;
  for(int i = 0; environ[i]; ++i) {
    printf("%s\n", environ[i]);
  }
  return 0;
}

在C/C++中,可以使用getenv(3)putenv(3)获取环境变量的内容和设置环境变量:

/*
代码 3.3
#include <stdlib.h>
char *getenv(const char *name);
*/

#include <stdio.h>
#include <stdlib.h>
int main()
{
  putenv("NUM=9999");
  printf("PATH:%s\n", getenv("PATH"));
  printf("NUM:%s\n", getenv("NUM"));
  return 0;
}

关于 getenv 和 putenv 的行为会在下文讨论。

环境变量的传递性

正如在开篇所说的,进程地址空间中专门有一个区域用来存放当前进程的环境变量。承上,环境变量具有全局属性,即环境变量会被所有的子进程继承,环境变量在子进程被创建时就已经存在。每个进程都会收到一张环境变量表,环境变量表是一个字符指针数组,每个指针指向一个以'\0'为结尾的环境字符串,这即是环境变量在进程中的组织形式。

Linux平台下的进程地址空间_进程的独立性_10

/*
代码 3.4
*/
int main()
{
  putenv("NUM=9999"); //父进程设置一个环境变量
  pid_t id = fork();
  if(id < 0) { perror("fork err"); exit(1); }
  else if(id == 0)
  {
    printf("NUM=%s\n", getenv("NUM")); //子进程获取新的环境变量
    exit(0);
  }
  else
  {
    int status = 0;
    waitpid(id, &status, 0);
    exit(0);
  }
  return 0;
}

在用户层面,用户启动的进程都是shell的子进程,所以用户的环境变量表是从shell进程继承而来的。当用户登录时,系统会启动一个shell进程,shell会读取用户目录下的.bash_profile文件,里面保存了导入环境变量的方式,shell的环境变量表就此而来。

在进程地址空间中,环境变量的存储和组织可以抽象为如下结构:

Linux平台下的进程地址空间_进程的独立性_11

可见,环境变量的实际存储区域与 env[] 是分开的。当使用 getenv 获取环境变量的内容时,会以 env[] 为中转,在存储区域拿到实际的环境变量字符串的内容。同理,当使用 putenv 设置环境变量时,只会将字符串指针放入 env[],当字符串实际内容发生变化时,通过 getenv 拿到的对应的环境变量也会不同。

int main()
{
  char envVal[256] = "NUM=9999";
  putenv(envVal);
  printf("envVal:%s\n", getenv("NUM"));
  strcpy(envVal, "NUM=114514");
  //envVal的内容发生变化,通过getenv拿到的内容也会变化
  printf("envVal:%s\n", getenv("NUM"));
  return 0;
}

Linux平台下的进程地址空间_进程的独立性_12

反观进程

linux下的进程概念

至此,我们对进程有了更深刻的让认识,即 进程 = 进程的内核数据结构 + 代码和数据,其中已经谈到的内核数据结构有 task_struct, mm_struct 和 page table,在后续的讨论中,会接触到更多的关于进程的内核数据结构。

在Linux中,各模块的解耦是必要的,但是作为一个庞大的系统,会不可避免地存在耦合部分,这点在后续的讨论中也会体现。

如何做到进程的独立性?

各个进程是独立的:

  • 首先,各个进程的内核数据结构是独立的,每个进程都有一套属于自己的内核数据结构体系(内核数据结构终归也是在物理内存中存储的)。
  • 在物理内存中,各个进程的代码和数据是独立的,页表的存在使进程不必关心具体的物理内存分布细节。即使进程地址空间中的虚拟地址相同,只要页表将其映射到物理内存不同的位置,进程之间就不会相互干扰。