“地址空间”
在之前讨论C++内存管理,以及平常写C/C++程序时,有如下的存储空间布局:
虽然不是所有的实例都按照上图所示的分区排布,但这是一种最典型的做法,足以说明问题。这个示意图与在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;
}
运行结果为:
将输出的地址大致映射到地址空间:
虽然具体的输出情况在不同的平台下会有所不同,但是通过观察现象,可以验证我们所做的说明是正确的。
虽然已经了解了“地址空间”空间划分情况,但是这个”地址空间”到底是什么,我们并不清楚。编写下面的代码并运行,观察现象:
/*
*代码 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;
}
运行结果为:
我们看到了这样的现象:
- 父进程和子进程的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运行过程中,操作系统的行为做一分析。先单独看父进程与物理内存之间的关系:
如上文所说,父进程 task_struct 中的 mm_struct 指针指向了父进程的进程地址空间(的抽象),进程地址空间中有 val 的虚拟地址。val 的虚拟地址,包括进程地址空间中用户区的其他区域的虚拟地址,都通过页表(page table)映射到物理内存的物理地址。
关于页表的更多内容,将在内存管理部分进行详细讨论,这里只针对相关部分进行说明。页表中大致有以下内容:
- 进程地址空间的虚拟地址 - 物理内存的物理地址的映射。虚拟地址通过页表和MMU找到物理内存中对象的实际存储位置。
- 内存中数据的访问权限标志位。针对对内存的越权操作,会被页表拦截。在进程运行时,父、子进程的数据区的页表项权限会被修改为只读,一旦某个进程要对数据进行修改,此时不做异常处理,而是另外申请物理内存完成写时拷贝,完成后,将父、子进程数据区的页表项权限修改为可写。
- 存在(exist)标志位。在运行一个程序时,程序的代码和数据并非全部加载到物理内存中,页表可以对虚拟内存进行标记,如果需要用到某些代码和数据,而对虚拟内存的标记显示这些代码和数据不在内存中,就会触发缺页中断(page fault),此时操作系统便会将对应的代码和数据加载到内存中。写时拷贝本质属于一种缺页中断。
每个进程都有自己的页表,每个进程的页表是相互独立的。页表是由cr3寄存器进行维护的。cr3是CPU中的一个寄存器,它记录了当前正在运行的进程的页表的起始地址。一个进程的页表本质属于进程的上下文,当进程结束时,cr3中的地址会以进程上下文的形式被进程带走。
承上,当 fork() 子进程时,子进程会拷贝父进程的task_struct、mm_struct和页表,此时子进程被创建:
由于子进程的各种内核数据结构都拷贝自父进程,所以父、子进程的 val 的虚拟地址,以及虚拟地址由页表映射到的物理地址都是相同的。当子进程尝试对val 的值进程修改时,操作系统会进行写时拷贝,在物理空间额外开一块内存,将数据拷贝到新空间,并将新的物理地址填入子进程的页表中,页表中的虚拟地址不变。写时拷贝完成后,子进程可以通过虚拟地址和页表找到新的物理地址,对值进行修改。这个过程对进程的地址空间和页表中的虚拟地址部分而言是0感知的,虚拟地址不关心这个过程。
完成上述过程后,父、子进程内核结构分布大概如下:
至此,已经可以解释代码1.2的运行结果中,输出的父、子进程的 val 地址相同的原因:操作系统进行写时拷贝后,只是修改了页表中的物理地址部分,写时拷贝对于虚拟地址来说是0感知的,所以子进程val的虚拟地址仍然与父进程相同,而且我们所看到的只能是虚拟地址,所以输出的val的地址相同。
进程地址空间的意义
理解地址空间后,现在对进程地址空间的意义做一总结:
- 进程地址空间可以让进程以统一的视角看待内存。
- 访问内存时,增加一个转换的过程,在这个转换过程中,由于有进程地址空间区域划分的存在,可以针对用户的越界寻址进行检查和非法拦截。
- 进程地址空间和页表的存在,将进程管理模块与内存管理模块进行了解耦。
环境变量
这里首先在使用和命令层面讨论环境变量,再在代码以及进程管理层面讨论环境变量。
环境变量概述和查看
变量就是以一组文字或符号等,来替换一些设置或一串保留的数据。
环境变量(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'为结尾的环境字符串,这即是环境变量在进程中的组织形式。
/*
代码 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的环境变量表就此而来。
在进程地址空间中,环境变量的存储和组织可以抽象为如下结构:
可见,环境变量的实际存储区域与 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下的进程概念
至此,我们对进程有了更深刻的让认识,即 进程 = 进程的内核数据结构 + 代码和数据,其中已经谈到的内核数据结构有 task_struct, mm_struct 和 page table,在后续的讨论中,会接触到更多的关于进程的内核数据结构。
在Linux中,各模块的解耦是必要的,但是作为一个庞大的系统,会不可避免地存在耦合部分,这点在后续的讨论中也会体现。
如何做到进程的独立性?
各个进程是独立的:
- 首先,各个进程的内核数据结构是独立的,每个进程都有一套属于自己的内核数据结构体系(内核数据结构终归也是在物理内存中存储的)。
- 在物理内存中,各个进程的代码和数据是独立的,页表的存在使进程不必关心具体的物理内存分布细节。即使进程地址空间中的虚拟地址相同,只要页表将其映射到物理内存不同的位置,进程之间就不会相互干扰。