引言
本篇知识点属于进程概念这个大章节。
请看完上一篇,详细了解操作系统概念 、进程概念 、进程状态 、进程调度 与进程切换。
环境变量
1. 概念
- 环境变量 (environment variables):⼀般指操作系统中⽤来指定操作系统运⾏环境的⼀些参数。
- 环境变量通常具有某些特殊⽤途,在系统当中通常具有全局特性。
例如:1、安装Python或者Java的时候,网上的安装教程,通常都会教我们配置环境变量;2、编译链接C/C++代码时,从来不知道我们所链接的动态静态库在哪,但照样可以链接成功,⽣成可执⾏程序,原因就是有相关的环境变量帮助gcc/g++编译器查找路径。
2. 命令行参数
我们知道,main函数是我们程序的入口。但其实,main函数也是要被调用的。我们编译得到的可执行程序,其实是被编译器动过手脚的。系统当中第一个被执行的函数并不是main函数。在Windows当中,叫_crt_startup函数;在Linux中,叫 _start 函数。
换言之:
- main函数也有参数 ,也要被别的函数调用,被start函数调用,调用时也需要传实参。main函数也有返回值,也需要返回给上一层调用main的函数。main函数返回值主要和进程控制相关。
main函数的两个形参是:一个 int 整型变量,一个 char* 字符串指针数组。
- int argc:代表argv数组中的元素个数。
- char* argv\[\]:元素是char*指针,数组指向了多个字符串。
编译code.c,打印argv数组中的字符串。
- 可以发现,我们的命令被解析,以空格为分隔符,argv数组依次获取了各个命令行参数:./code、a、b、c、d。
- 所以argv是一个变长数组。以空格为分隔符,我们在命令行输入的命令参数越多,数组的元素就越多,指向的字符串就越多。
- 例如argv0是"./code",argv1是"a",argv2是"b",argv3是"c",argv4是"d",最后以NULL结尾。整个数组中有效元素的个数就是5,即argc的值为5。
由此可知:
- 我们使用的系统命令,本质就是可执行程序。我们的命令一般也是用C语言写的,也有main函数。
对应的命令行参数ls、-a、-b、-c,都要被解析成字符串,存储到argv数组中,而argc则为4代表有效元素个数。
如上,我们就模拟实现了一条命令./code,可带有选项-a或选项-b或选项-c。
可以得出结论:
- 命令行参数的作用,是让一个程序可以通过参数选项表现出不同的子功能。
我们知道,用户登录Linux系统,默认会启动一个bash进程,即命令行解释器。用户输入命令,实际就是shell外壳程序调用了一个类似scanf的函数。因此shell程序获取了我们输入的命令,所以是shell解析、切分了系统命令。
我们的程序,都有main函数,启动后在系统内变成进程,即:
- 每个进程都拥有一张argv表,用来支持实现选项功能。
当然,不是每一个进程都用的上,例如我们之前默认忽略main函数参数,则argv表就是空的。进程中当然就是一张空表。
3. 环境变量---PATH
我们知道了系统命令的实现原理,但可以发现:执行我们模拟实现的命令 ./code 时,必须带路径
,例如相对路径 ./。可系统指令却不用带路径,例如 ls
。
必须声明:我们写的二进制程序,和系统指令没有本质区别。系统指令,本质就是在系统中预装的二进制程序。
但为什么系统指令可以不带路径呢?
要运行一个程序,我们必须先找到它。对于任何一个文件来说,文件路径是唯一的。因此,并不是系统指令不需要路径,而是系统会帮我们找到指令的文件路径。
- 如果将我们的二进制程序code,也拷贝到系统指令的默认路径/usr/bin目录下面,我们的程序也可以被系统自动查找到。因此,我们就可以不带路径运行code程序了,例如code -a或者code -b。
- 当然,该做法极其不推荐。因为我们的程序没有经过测试,没有经过严格的发布流程,没有经过时间的验证,容易有Bug,导致各种问题,污染系统原本的指令池。
Linux系统之所以能找到系统指令的默认路径,是通过环境变量PATH找到的。
- 环境变量PATH :默认情况下,用于标识系统路径,告诉系统去哪些路径下找二进制文件/系统指令。
可使用env命令查看系统中所有环境变量。
- 环境变量本质也是操作系统这个软件中的一个全局变量 。环境变量的构成就是名称 = 内容。也可以通过echo命令,根据环境变量的名称,输出环境变量的内容,例如echo $环境变量。
所以系统就是在PATH中的这些路径下去搜索指令。这些路径都是以绝对路径呈现的。
- 当我们要执行指令或者二进制程序,如果不显式带路径,系统默认会去查PATH环境变量。
- PATH环境变量中,以 : 作为分隔符,先在第一个路径,例如/usr/local/bin下找;如果没有,再在第二个路径,例如/usr/bin下找。以此类推,如果所有路径都没有找到,就会报错提示command not found。如果找到了就可以加载指令/程序,并调度执行进程。
其实就是以 : 作为分隔符,先把第一个路径剪切下来,和程序名拼接成完整路径,再判断路径是否有效,或者文件是否存在。
- 因此,如果我们将自己的路径添加到PATH环境变量中,就无需拷贝程序到系统路径了,系统同样会去我们自己的路径下找。
- 但如果直接对环境变量PATH进行赋值,原本默认的各种系统路径就会被覆盖。
- 想要恢复环境变量,最笨的方法就是如果有之前的内容,就重新把内容赋值回来。
但是环境变量都是内存级的变量,其实就在bash进程的上下文里。相当于bash进程内部,malloc了一段内存空间,把环境变量保存起来了。例如我们的C/C++程序,我们定义了一个全局的整型变量int a,这个变量a就在我们程序的进程上下文里。
- 我们只需要退出、重新登录,就会创建一个新的bash进程,环境变量就被重新加载了,全部恢复默认值。
- 如果不想覆盖原本的内容,就必须先拼接上原本的内容。先$PATH,再冒号:分隔,再加上新添加的路径
。当然,如果再重新登录,环境变量同样会再次恢复默认值。
一、从存储的角度如何理解环境变量?
环境变量有很多,都是如PATH一样的变量名+内容。我们也知道,所有环境变量其实就是bash程序中的变量。在用户登录的时候,系统就会创建bash进程。bash就必须从系统当中,读取所有环境变量的信息,在进程内部用一批变量保存。这一批变量就在bash进程内部,形成了一张环境变量表。
和命令行参数表argv一致,其实就是一个char*字符串指针数组,内部存储的字符串其实就是"环境变量名称=内容",例如"PATH=/usr/bin"。
- 当我们输入命令ls -a -b,先被bash进程拿到。bash就要解析命令,得到3个字符串"ls"、"-a"、"-b",构建得到第一张表命令行参数表argv。ls没有带路径,bash就要拿着程序名ls去查环境变量表,找到PATH,再根据一个个路径拼上程序名去判断是否存在,如果存在再创建子进程去执行命令。
因此,在bash进程内部,会有两张表:环境变量表和命令行参数表argv。
- 所以当我们env去查的时候,就会打印整张环境变量表。echo $环境变量,输出的时候,就会拿着名称在表中找环境变量的内容,再输出。
二**、环境变量最开始从哪里来的?**
如果我们不登录,就没有bash进程,此时环境变量在哪里呢?
- 环境变量最开始是从配置文件中来的。
Linux系统是存在相关的系统配置文件的。配置文件中,就包含所有环境变量的,直接或间接的内容。当bash进程启动时,就会从系统配置文件中,读取所有的环境变量值,然后在bash进程内部创建环境变量表。
三**、环境变量的配置文件在哪?**
每一个用户,在自己的家目录下,存在两个隐藏文件。这两个配置文件都是用shell脚本写的。
- 用户的.bashrc中,主要做的其实就是加载**.**系统配置文件/etc/bashrc,然后获取环境变量的值。
- 另一个是用户的.bash_profile。系统登录时,bash进程使用的就是这个配置文件。主要做的就是加载~/.bashrc文件,判断.bashrc配置文件存不存在。
这里面调用关系,其实就是bash进程先调用.bash_profile配置文件,再调用.bashrc文件,最终调用系统路径下的配置文件/etc/bashrc,获取环境变量的值。
- 在系统配置文件/etc/bashrc中,会包含所有环境变量相关的内容,同时还包括其他系统内容,例如文件权限掩码umask 。当然,这里面也会调用其他配置文件。
- 所以我们就知道了,如果我们将自己的路径,想办法添加到系统配置文件/etc/bashrc中,修改配置文件中的环境变量PATH,环境变量的修改就永久有效了,不会因为重新登录就恢复原本的默认值。
此时,再重新登录,再查看,PATH仍然还是我们修改的样子。
总结:
- 配置文件的修改操作不是重点,但我们必须牢记:环境变量是从系统的配置文件中来的。
- PATH环境变量,本质是系统中搜索指定命令的一个环境变量值。环境变量是内存级的,默认情况下,无论怎么修改重新登录就会恢复默认值。因为用户登录时,bash进程默认会读取配置文件,形成环境变量表,从配置文件中获取环境变量值,填入表中。同时,bash还可以解析用户输入的指令,形成第二张表命令行参数表argv。因此,任何一个bash进程中,会有两张表。不管是命令行参数表,还是环境变量表,本质都是一个char*指针为元素的字符串数组。
- 所以系统中如果有10个用户登录,就有10个bash进程。每一个bash进程都要从系统配置文件中获取环境变量,每一个bash进程内部都有两张表:环境变量表和命令行参数表。
- bash进程通过命令行参数表,可以拿到二进制程序名称,如果没有带路径,就会去环境变量表中找PATH,根据默认的指令路径进行查找。因此,指令的查找工作是由bash进程完成的。
实际上,在我们的Windows系统中,也存在环境变量。同样有环境变量PATH。我们执行的Windows的系统命令,同样需要去PATH记录的路径下找。刚开始,外壳shell程序同样需要从系统配置文件中获取环境变量值。只不过Windows可能将配置文件保护起来了,对Windows用户是隐藏的,我们看不到。
4. 更多环境变量
- 环境变量HOME:当前登录用户对应的家目录
。用户登录创建bash进程时,系统默认将HOME设置为当前用户的家目录。
cd命令可以用特殊字符 ~ 表示当前登录用户的家目录。但不同用户,家目录路径不同,一个字符是如何统一表示的。现在我们就知道了。
- 用户登录就会创建bash进程,读取配置文件。bash进程内部最终会形成一张环境变量表。每个用户家目录下都有两个隐藏文件.bashrc和.bash_profile,所以用户可以拥有自己个性化的环境变量配置。用户登录所创建的bash进程,当然就可以用环境变量表,记录当前用户的相关信息。当然,用户登录,系统自然会拥有用户的相关信息,创建bash进程时自然可以直接写入,未必需要读取配置文件。
- 我们输入的命令,就会被bash解析,分隔成不同字符串,形成一张命令行参数表argv。在bash内部,解析时遇到 ~ ,就可以去查环境变量表,获取环境变量HOME的内容,得到当前用户的家目录,就可以用真实路径直接替换 ~。接着,bash就创建子进程执行cd命令。
环境变量LOGNAME:登录时用户是谁
。bash进程创建时,系统默认将LOGNAME设置为当前登录的用户。
环境变量USER:当前用户是谁
。bash进程创建时,系统默认将USER设置为当前用户。
su命令是将当前用户的权限切换为root,类似于sudo是一次性提权,而su是切换后任意使用,不影响环境变量LOGNAME和USER。
su -命令相当于用root用户重新登录了,所以环境变量LOGNAME和USER都会切换成root。
LOGNAME和USER基本是一致的。
环境变量SHELL:
表明当前用户使用的哪个版本的shell,记录其绝对路径。所以我们当前使用的shell程序,就是对应路径下的命令行解释器程序bash。在Windows下,使用的shell程序就是图形化界面。
环境变量HISTSIZE:
bash可以记录的最大历史命令条数。
Linux系统会记录用户的历史命令。当然,我们输入的命令,是交给bash进程的,所以实际是bash进程将我们的历史命令记录了下来。例如:
- 我们可以用 ↑ 键或者 ↓ 键上下翻,依次查找历史命令。
- 我们也可以通过ctrl + r搜索匹配历史命令。
又或者 !v,可以快速匹配上一次执行的vim命令,快速打开文件。
或者history命令,可查看最近的HISTSIZE条历史命令。当然,第一条命令不一定从1开始编号,这里第一条命令编号是39,但当前总共有1000条。
我们可以认为,bash进程内部存在一个队列,或者链表等其他数据结构。总之bash进程内部,将命令以字符串的形式,保存在数据结构中,有命令就入队。但总共只会记录HISTSIZE条最近的指令,即bash只会记录最新的1000条指令。
环境变量TERM:
表示终端类型,xterm就是其中一种终端类型。和字符设备相关。
环境变量HOSTNAME:
表示当前主机的主机名。
环境变量SSH_CLIENT:
分别表示登录的客户端IP、进程号、端口号。
环境变量SSH_TTY:
表示当前是哪一个终端设备。进程打印数据时就默认往SSH_TTY对应设备中打印。
环境变量LANG:
表示当前默认的编码格式。
环境变量PWD:
表示当前的工作路径,即当前shell所在路径。
环境变量OLDPWD:
表示上一次所在的工作路径。
cd - 命令可以快速回到上一次所在路径,就是通过OLDPWD完成的,就是直接cd切换到OLDPWD所记录的路径下。当然,如果当前路径PWD=/home,切换到上一次所在路径OLDPWD=/home/A/B/C。那么当前路径PWD就变成了/home/A/B/C,上一次所在路径OLDPWD就变成了/home。所以我们通过
就可以在这两个路径下来回快速切换。
复盘总结:
- 每一种环境变量,都有它特定的功能。例如有的记录当前用户,有的记录当前用户家目录,有的记录主机名,有的记录系统命令的默认搜索路径,有的记录当前工作路径的,未来还有的会记录动静态库的默认搜索路径,等等。
- 环境变量表本质是OS自己的配置文件提供的,所以它叫做系统当中用来指定系统运行的一些参数。
- 环境变量是被提供给bash进程使用的,也就是被用户间接使用了。许多环境变量都和用户相关。
5. 获取环境变量的方法
命令行操作:
env命令查看所有环境变量。
echo $XXX查看指定环境变量的内容。
export命令导入一个新的环境变量,新增到bash进程的环境变量表里 (内存级,下次登录新创建bash进程则消失)。
unset命令取消新增的环境变量。在bash进程内的环境变量表中去掉MYENV。
代码操作:
一、获取方法1:main函数参数env
- main函数其实最多有3个参数 argc、argv 和 env。
- env 就是我们程序的环境变量表 ,与命令行参数表 argv 形式上是完全一致的,都是char*字符指针数组,存储字符串,最后一个元素都是以 NULL 结尾。
argv和env其实是由调用main函数的执行流或进程传入的。但我们程序的父进程就是bash进程,所以两张表都是由bash传入的。
bash内部本身就有两张表,由它做命令解析,形成命令行参数表argv,最终还要创建子进程执行命令,自然要将命令行参数表传给子进程,包括环境变量表。
如上图,我们的程序中,_start 函数调用了 __libc_start_main@plt,最终就会调用我们程序的 main 函数。
在_start函数内部,就会进行各种判断。arg_count会记录我们的main函数参数个数,如果是0个参数,就调用main();如果是2个参数,就调用main(argc, argv);如果是3个参数,就调用main(argc, argv, env)。
- 所以我们C语言程序最开始跑的时候,入口函数并不是main函数,而是_start函数。我们的main函数,不管是带几个参数,都是由编译器gcc/g++自己识别的。代码其实就是字符,编译时,通过词法或语法分析,就可以知道我们代码中有几个参数,所以_start函数中的arg_count就可以拿到参数个数,进行判断,决定怎么传参,调用不同的main函数。
总之,我们可以通过main函数参数 char* env\[\] 获取环境变量,来自父进程bash的环境变量表。
反之,如果我们export向bash进程导入几个新的环境变量,我们的程序通过main函数参数env也是可以看到的。
即:
- 环境变量可以被子进程直接继承。包括子进程创建的子进程。以bash为根,往下创建的子进程,都可以继承到环境变量。
所以环境变量具有全局性。
二、获取方法2:系统调用getenv
- 系统调用getenv :头文件<stdlib.h>。根据环境变量名称,获取指定环境变量的内容。如果获取成功,则返回一个字符串地址,否则返回空指针NULL。
或者设计一个程序,只能指定用户whb使用。所以可以getenv获取环境变量USER的内容。判断当前进程的USER是不是指定用户whb,如果是则执行正常逻辑,否则报错提示并退出。
- 将环境变量相关信息让子进程继承,子进程就可以结合环境变量进行个性化操作。
- 例如我们可以通过环境变量禁止运行某个程序,即在bash进程中export一个环境变量flag,为真的时候可以运行,为假时不可以运行。我们的程序继承后,就可以通过getenv获取flag的内容,判断真假。
- 因此,通过环境变量,就可以在系统中完成进程级的数据传递,控制子进程的一些程序逻辑。我们的程序就可以表现出不同功能或行为。这就是环境变量的意义。
- 当然,bash进程或者子进程默认是共享数据的,但任何一方想修改环境变量,就会发生写时拷贝。
三、获取方法3:environ
- 在C语言库函数中,还提供了一个全局指针,即 char** environ。我们只需要包含头文件就可以直接在全局使用该指针。
- 当我们的程序编译时,environ 指针就会指向环境变量表。它是char**类型的,一个二级指针,指向的是整个字符串数组。
包含头文件,使用前需要先声明 extern char** environ。
6. 本地变量
- shell程序不仅支持export定义新的环境变量,还支持在命令行中定义本地变量。例如i=10,变量名=变量值,中间不能有空格,否则被当成命令了。
- 本地变量i=10,并不在环境变量表中,所以env是查看不到的。
需要使用set 命令查看,它会显示所有环境变量 和本地变量。
即bash进程会记录两套变量:1、环境变量;2、本地变量。
- 本地变量不会被子进程继承,只在bash进程内部使用。我们可以理解成,在bash中还存在一个本地变量表,但不会被子进程继承。所以bash内部有3张表。
- 我们定义的本地变量,也可以export直接导成环境变量
。
本地变量和shell脚本的学习相关。
bash本身也是一个解释器 。它的工作模式,除了我们平时这种一行一行的命令行交互式模式,还有一种工作模式是基于bash自己的语法的,如shell脚本语言。本地变量是辅助shell语言所必要的,每一门语言都是需要可以定义变量的。
- 实际上,本地变量对于我们的bash进程也是有特殊用途的。
例如bash需要为用户打印
命令行提示符 ,等待用户输入命令。而本地变量PS1就可以帮助bash记录命令行提示符的格式,未来输出时就按照PS1中的格式进行输出。
包括我们在输入命令时,可以使用反斜杠 \进行续行,其续行提示符的格式 也记录在了本地变量PS2中。
7. 内建命令
我们知道,bash进程会创建子进程去执行命令,而 export 命令就是一个二进制程序,就是一个进程。如果export是bash的子进程的话,问题就来了。export需要执行的操作,是导入环境变量,但最终是要向bash进程的环境变量表中导入。可export是一个子进程,它怎么能向父进程中导入数据呢?
我们知道进程之间具有独立性,父进程虽然可以向子进程传递数据,因为它们默认共享数据,但任何一方想修改,都会触发写时拷贝。因此,本质上父向子传递数据还是互不影响的。但父进程早已经创建好了,父进程当然不可能从子进程继承数据。
因此,export绝对不可能是bash的子进程,否则是无法导入环境变量的。
- 我们一般将export这种命令,称为内建命令 (built-in command)。
- 内建命令在执行时,由bash进程亲自执行,不需要创建子进程。
bash自己可以解析命令,获得不同字符串,因此可以判断输入的命令是否是内建命令。对于内建命令,bash可以自己调用函数 或者系统调用进行执行。
- 我们之前使用过的内建命令,还有例如:pwd打印当前所处路径、cd切换工作路径。
进程地址空间
1. 研究平台
- Linux内核版本2.6.32。
- 32位平台。
2. 程序地址空间回顾
我们知道,C/C++程序的内存地址空间分为:代码区、字符常量区、初始化数据区、未初始化数据区、堆区、栈区、共享区。
上图是我们以前认识的程序地址空间。例如:
- 我们malloc或new动态申请的内存空间就是在堆区申请的;
- 全局变量在初始化数据区或未初始化数据区;
- 栈是向下增长的,地址依次减小;
- 堆是向上增长的,地址依次增大;
- 堆和栈中间还隔着一大段内存空间,叫作共享区。
像"hello world"这种字符串,是不能被修改的,因为它被放在字符常量区 ,但其实它和代码区是同一区域的。我们平时定义的字符串,其实是被硬编码到代码的,因为代码是只读的,所以字符串也是只读的,只不过字符串统一位于字符串常量区。
如int test是栈上的局部变量,但被static修饰,作用域虽然还是局部的函数作用域,但生命周期已经变成全局的了。因为被static修饰的变量,和全局变量是一个区域的,即已初始化数据区。所以static变量就是全局变量,只不过作用域还是被限制为局部作用域。
我们的问题是,程序地址空间是内存吗?
我们的程序,或者说进程,是严格遵守程序地址空间的规律的。它不可能是内存。因为我们的系统里,有众多进程,如果都共用程序地址空间,是不可能如此规律的使用的。系统里不断有进程创建,不断有进程退出,程序地址空间被不断划分,零零碎碎的使用,显然是不合理的。
- 因此,必须更正说明:程序地址空间,我们应该称为进程地址空间 ,或者说虚拟地址空间 。它是一个系统级 的概念,不是一个语言层的概念。
如何证明程序地址空间不是物理内存?
- 子进程进入if分支,不断打印gval变量的值和地址,每隔一秒gval++;
- 父进程进入else分支,只负责不断打印gval变量的值和地址,不做修改。
按照我们之前的认识,我们知道父进程和子进程之间是数据共享的,但任何一方尝试修改数据,都会发生写时拷贝,保证进程之间的独立性。所以子进程的gval,和父进程的gval,应该是不同的值。这都是我们已经知道的。
问题是,如果我们打印gval这个变量的地址 呢?gval全局变量是先被定义的,所以它的地址应该是在已初始化数据区 的,变量地址是确定的且唯一的。
现象:
- 父进程PID11886;
- 子进程PID11887,PPID11886;
- 父进程的gval始终等于100,子进程的gval不断修改++。但父子进程的变量地址**&gval都是相同的0x60104c**。
因此,如果程序地址空间是物理地址,被各个进程所共有,那么显然是极其不合理的。一个物理地址是怎么可能存储两个不同的值,妥妥的Bug。
- 因此,程序地址空间必然是虚拟地址 。我们之前C/C++程序里面,用到的指针全都是虚拟地址。虚拟地址空间,是软件层面的概念。
其实不管什么语言,包括Java等,在系统层面上都是虚拟地址,只要是进程就全是虚拟地址。
3. 新的内存地址概念
- 一个进程,一个虚拟地址空间。
我们知道,创建进程时,需要有对应的task_struct用于描述进程相关属性。
- 每一个task_struct,都对应一个虚拟地址空间。
- 虚拟地址空间的宽带是1字节。32位机器下,从低地址全0,到高地址全F,32个比特位,有
个地址,共4GB;64位机器下,从低地址全0,到高地址全F,64个比特位,有
个地址。
- 一个地址,表示一个字节。
若不加特殊说明,统一默认以32位为例进行讲解。64位太大,画图举例等都不方便。
- 0到3GB,称为用户空间;3到4GB,称为内核空间。
我们访问自己的代码、初始化数据、堆区、栈区或者命令行参数环境变量等,作为用户,我们拿着地址是可以任意访问用户空间的区域的。
在C/C++程序中,我们定义的变量名,编译代码形成二进制可执行后,有些变量名还存在,但大部分变量名经过编译,最终就变成了内存地址,或者说寻址方式。所以我们访问自己的代码、自己定义的命令行参数环境变量、自己栈上的数据、自己堆上的数据,基本都是拿着地址去访问的。这就是用户空间。
对于全局变量gval,两个不同的值,一定要对应到物理内存里两个不同的地址处,否则硬件里的数据怎么被CPU读取呢?
因此,我们需要引入一个概念: 在虚拟地址空间中,gval会存在一个虚拟地址;在物理内存上,gval会存在一个物理地址。同时,在OS内,每一个进程在创建的时候,要为进程构建一张页表。
- 一个进程,一套页表。
- 进程地址空间,可以找到页表。在页表的左侧,填充的是进程的某一个虚拟地址;在页表的右侧,填充的是对应变量所对应的物理地址。
当进程访问某一个虚拟地址时,OS会查表,将虚拟地址转化成对应的物理地址,进而访问到物理内存中实际存储的变量。
- 页表是用来做虚拟地址到物理地址映射的。可以理解为哈希表,通过虚拟地址快速查找物理地址。
在我们的程序中,代码有地址、变量有地址、函数有地址、命令行参数环境变量都有地址,每一个都有虚拟地址。一个进程,一个虚拟地址空间,整个虚拟空间的虚拟地址都供我们整个程序使用。
最终所有虚拟地址,都通过页表映射,对应到物理内存地址。
- 经过C/C++的学习,我们知道,在取地址时,我们最多拿到一个地址。前面我们特意强调,虚拟地址空间的宽度是1字节,但变量通常都是有多个字节的,例如整型int有4个字节、浮点型double有8个字节,包括各种结构体类型或者自定义类型。所以不管是虚拟地址空间,还是物理内存,都应该对应有多个地址。实际上,我们拿到的地址是变量占用的多个地址中的最低地址。又因此我们有了类型,虽然我们只拿到了代表一个字节的地址,但访问任何变量,只需要知道起始地址,就可以根据起始地址加偏移量完整的访问整个变量。
父子进程关系中的虚拟地址空间:
在之前讲进程创建时,我们知道,不管是进程PCB,还是代码和数据,子进程大多是拷贝自父进程的。子进程PCB只需要拷贝一份父进程PCB,更改个别进程属性即可。
因此,父进程有task_struct,对应一个虚拟地址空间,对应一套页表。子进程当然也要有自己的虚拟地址空间,和一套自己的页表。
- 子进程PCB拷贝自父进程,虚拟地址空间和页表也都是拷贝自父进程的。
因此,子进程和父进程默认是共享代码和数据的。从进程PCB,到虚拟地址空间,到页表,相当于发生了浅拷贝,在初始默认的情况下,子进程基本上是和父进程一致的。
- 父进程有gval,子进程在同样的虚拟地址处,同样有一个gval。
- 父进程gval的虚拟地址,在页表上对应一个物理地址;子进程gval是同一虚拟地址,子进程的页表默认也是完全相同的,因此对应的也完全是同一个物理地址。
因此,我们可以理解,为什么前面代码中,父进程和子进程中的gval的地址是相同的? 因为gval打印的地址是虚拟地址。
我们也可以理解,为什么gval默认是被父进程和子进程共享的? 因为子进程基本是拷贝自父进程的,从虚拟地址,到映射关系,全都是一样的,最终指向了同一个物理地址。变量如此,我们的代码也是如此,全部发生浅拷贝的话,在默认情况下代码和数据全部是共享的。
但如果子进程要对数据做修改?
子进程对gval做++,由于进程之间具有独立性,就不能直接修改gval对应的物理地址,因为父进程的gval也会被修改。
- 当父进程或子进程任何一方要修改数据,OS就会在物理内存中重新开辟一段空间,把变量内容拷贝过来,得到一个新的变量,得到一个新的物理地址。
- 此时是子进程要修改,于是OS就可以修改子进程自己的页表,gval虚拟地址不变,但映射一个新的物理地址。
- 这种机制,我们称之为写时拷贝。
因此,我们就可以解释,为什么之前的代码中,父进程和子进程中的gval地址相同,但是两个不同的值。包括之前学习fork时,为什么fork的返回值对于子进程来说是0,对于父进程是大于0的数。
因为子进程继承了父进程的虚拟地址空间,包括页表。后来子进程想修改,OS内部自动做了写时拷贝,把目标变量重新拷贝一份,申请新的物理内存,有新的物理地址,然后把对应的页表映射关系重新修改。所以变量的虚拟地址相同,但页表映射不同,物理地址不同,存储的值不同。
- 对于用户而言,我们看不到物理地址。操作系统把物理地址完全隐藏起来了,我们用户只能看到虚拟地址。
4. 虚拟地址与进程地址空间
现代计算机,物理内存一般都是8GB、16GB,甚至32GB往上。而实际上,就算有很多进程,零零散散的使用,也不可能用满。
- 虚拟地址空间,更像是操作系统给每一个进程画的大饼。每一个进程都以为自己拥有了4GB的虚拟地址空间,就是拥有了4GB的物理内存。
- 进程在上层随意的申请、使用虚拟地址,而OS将虚拟地址对应转化到物理内存上。进程无需关心底层如何,进程只知道整个4GB的虚拟地址空间它可以任意使用。一切转化工作,包括内存底层硬件如何管理、如何映射,都交给操作系统和页表。
这也是为什么,我们的程序在使用地址时,可以保证地址的绝对规律。因为上层是规律的,底层交给了地址转化工作。
每个进程一个虚拟地址空间,进程需要管理,虚拟地址空间同样需要管理。管理就需要先描述,再组织。
虚拟地址空间,本质上也是一个数据结构,在Linux当中叫做struct mm_struct,通常称为内存描述符。和task_struct一样,OS需要将各个进程统一管理为一个链表,也需要将各个虚拟地址空间管理为一个链表。
所以虚拟地址空间,本质是进程PCB中的一个结构体变量 ,虽然具体有哪些属性我们并不清楚。而进程PCB同样是一个结构体struct task_struct,因此虚拟地址空间和进程PCB,两个结构体之间,可以用指针互相指向。
总结:
- 虚拟地址空间是系统内核当中,给进程创建的一个结构体对象。
虚拟地址空间如何实现?具体有哪些属性?如何描述一个进程的地址空间?
按我们之前理解的,程序地址空间中,会有正文代码、初始化数据、未初始化数据、堆区、栈区、共享区以及命令行参数环境变量。相当于整个4GB空间,被划分成了很多的区域。
但归根到底,虚拟地址空间,本质就是
个地址。也就是从全0到全F的,一共
个的32个比特位的二进制数。
本质就是
个数字。每一个地址代表一个字节的空间。
如何理解区域划分呢?
比如小学的时候,我们和同桌之间,可能会存在一条**"三八线"**。如果谁越界,谁就会被打一下,或者被惩罚一次。而三八线的本质其实就是区域划分。
用计算机量化一下,其实就是一个struct Desktop结构体。例如张三和李四之间,我们只需要在结构体中,分别记录张三的起始位置和结束位置,李四的起始位置和结束位置,就可以完成整张桌子的区域划分。
- 即:区域划分,只需要定好区域的开始和结束即可。
在区域内,可以划分若干个刻度。
- 即:区域内的最小刻度,就是地址。
- 整张桌子100cm,桌子上面的刻度是线性连续的,从0到99,这叫桌子的统一编址。因此,我们想访问桌子上任何一个区域,只要拿着地址即可。
所以整张桌子有100cm,未来使用时,我们就能够以刻度为单位随意使用,例如在第3个刻度或者第5个刻度放一个粉笔头。同时,我们只需要知道自己属于哪个区域,区域的开始和结束是什么,我们就可以任意使用区域内任何一个刻度。
- 如果我们要做区域调整,本质就是调整区域的开始地址和结束地址。
对应到虚拟地址空间,就是:
- 桌子对应虚拟地址空间;
- 桌子的宽度对应虚拟地址空间共
个地址;
- 桌子上的刻度,就是虚拟地址空间中的一个个地址;
- 从正文代码往上,每一个区域都代表桌子上的一个同桌。因此整张桌子 (用户空间) 被7个小朋友划分成了7个区域。
- 因此,struct mm_struct中,一定会记录这7个区域的开始虚拟地址和结束虚拟地址,对应有7份start和end。将start和end对应的地址数字一填,区域就自动划分出来了。
- 如果要区域调整,只需要对不同区域的start或end,做简单的加减运算即可。
结论:
- OS要对进程的虚拟地址空间做管理。虚拟地址空间是系统内核的一种数据结构 ,其中的关键属性,一定包括各个区域的开始地址 与结束地址。
Linux内核版本2.6.18
在struct task_struct结构体中,就存在 struct mm_struct* 类型的成员变量,即进程PCB中指向虚拟地址空间的指针。
在struct mm_struct结构体中,就存在各种区域划分,即开始虚拟地址start和结束虚拟地址end。
- total_vm:虚拟地址空间总大小;
- start_code:正文代码区域的开始;
- end_code:正文代码区域的结束;
- start_data:数据区域的开始;
- end_data:数据区域的结束;
- start_brk:堆区域的开始;
- brk:堆区域的顶部,即结束;
- start_stack:栈区域的开始;
- arg_start:命令行参数区域的开始;
- arg_end:命令行参数区域的结束;
- env_start:环境变量区域的开始;
- env_end:环境变量区域的结束。
- 和task_struct一样,各个进程的虚拟地址空间,即各个mm_struct对象,通过list_head变量进行管理,组织为一个链表。链表结点就是各个虚拟地址空间。
其实,即使不对mm_struct做管理,进程本身就被操作系统管理为一个全局链表,我们通过进程同样可以找到对应的虚拟地址空间,通过进程即可管理虚拟地址空间。
5. 再理解程序加载到内存
- 虚拟地址空间是一个struct mm_struct结构体对象。在进程PCB中,有一个 struct mm_struct* mm 指针,指向当前进程自己的虚拟地址空间。
- 页表本质就是一个个虚拟地址和物理地址的映射关系。
- 在虚拟地址空间中,栈区和堆区都是动态形成的,在程序运行期间再动态申请,所以我们暂时不关心。
- 但我们的程序编译成二进制可执行,加载到内存,一定是有代码和数据的。而不同程序,其代码和数据的大小都各不相同。因此代码区 和数据区 ,一定会随不同程序的体积不同,对区域大小进行动态调整。
- 我们的程序在磁盘当中,假如代码正文有100个字节,就要100个物理地址,因此需要在物理内存中申请开辟100个字节大小的物理内存空间。同时,我们也必须在虚拟地址空间的正文代码区域,开辟相同大小的100个字节的虚拟内存空间。
- 所以页表左边,我们有了100个虚拟地址;页表右边,程序加载到物理内存中,我们就有了100个物理地址。这时我们再填充页表,全部代码的地址就一一建立了映射关系。
即:
- 程序加载到内存,需要:
- 在虚拟地址空间中申请指定大小的空间;
- 加载程序的代码和数据,申请物理空间;
- 通过页表,将虚拟地址和物理地址进行映射。
程序加载到物理内存,再对应在虚拟地址空间开辟相同大小的空间。相当于我们反向将物理地址转化成了虚拟地址。
- 上层用户就只需要关心如何使用虚拟地址。因为程序加载时,就需要提前建立好映射关系。所以当用户访问虚拟地址时,一定能对应找到程序在物理内存中的地址,最终找到对应的代码和数据。
程序加载到物理内存,在虚拟地址空间申请相同大小的空间,我们就称之为调整区域划分。只需要调整对应的start和end的值就可以。
结论:
- 虚拟地址空间mm_struct是一个结构体对象,需要:1、开辟空间;2、初始化各种属性,包括各种区域的start和end的初始化。
这些值应该如何初始化?从哪里来?有相对一部分是我们程序加载到内存时,从加载过程中来的。
- 至于堆区和栈区,可以初始化为0,即开始虚拟地址start和结束虚拟地址end,指向同一个位置。后面在程序运行过程中,动态申请,不断扩容,不断调整区域划分。栈就不断向下增长,堆就不断向上增长。
6. 总结
为什么要有进程地址空间?
- 1、将地址从"无序",变成"有序"。
通过页表映射,上层可以有无数个进程地址空间,每一个虚拟地址,都可以映射为物理内存上的一个地址。因为系统中各个进程,基本不会用满所有的物理内存资源。
反过来,程序加载到物理内存时,代码和数据加载到什么位置也不重要了。即使是无序的物理地址,也会被页表转化成有序的虚拟地址。物理内存中,不同程序的代码和数据交叉在一起、独立管理。
- 2、地址转化过程中,可以对地址或操作进行合法性判断,进而保护物理内存。
当用户拿着虚拟地址,想去访问代码或数据时,必须把虚拟地址转化成物理地址。这个转化工作实际是由硬件完成的,但我们目前可以理解为OS查找页表完成的,这个工作是自动的。所以上层用户可以随意访问,无需关心底层。
为什么CPU运行进程,执行代码时,不能直接去物理内存中找,非要经过一层转化呢?
例如我们小时候收到压岁钱,妈妈总要收走,说是替我们保管,承诺我们想买什么只要和她说就行。但当我们想买辣条时,妈妈就会说辣条是垃圾食品,不准吃,对身体不好。直接对我们的请求做拦截了。
- 在页表中,除了有虚拟地址和物理地址的映射关系,还有权限:可读r、可写w或可执行x。
我们,或者说进程,通过虚拟地址访问代码,OS就要查表。如果进程想对代码区进行写入,但代码区对进程来说是只读的,因此OS可以拦截进程的写入请求。此时,就可以实现对物理内存的保护。
野指针:例如我们在堆上动态申请了一块区域,用指针保存了堆上地址。使用之后,我们将堆空间释放。但现在我们错误操作,仍然拿着指针访问该地址。查页表时,就会发现页表中并不存在指定的虚拟地址,查页表就直接失败了。这就是野指针问题。
- 指向的堆空间被释放了,除了虚拟地址空间中要进行区域调整,释放空间。页表中的映射关系,以及物理内存都要释放。所以对一个已释放的内存区域进行访问时,页表中根本不存在对应的虚拟地址,更不可能存在对应的映射关系和物理内存,所以查页表会失败。系统知道后,就会直接把进程干掉。所以我们的程序存在野指针,进程就可能崩溃。除非野指针指向的仍然是有效的虚拟地址,进程就不会崩溃。但随意指向其他虚拟地址同样算是野指针。
我们知道,字符串常量不可被修改,所以一般要用const char*指针指向,但const一般是编译器级别的保护。现在的问题是,我们可以通过str指针修改字符串吗?实际上,程序可以通过编译,但运行时就会崩溃。因为字符串是被硬编码到正文代码区的,属于字符常量区,即靠近初始化数据区的位置。因此字符常量区的权限就是只读的。所以当我们想修改字符常量区时,查页表就会发现访问区域是只读的,但我们想进行写入,所以页表就会转化失败。即查找页表时我们的进程被权限拦截了。
野指针或者字符串常量的这种特性,和C/C++等语言无关,代码编译好都是二进制程序,运行起来就是进程,就会有进程PCB、虚拟地址空间和页表。所以在系统层面上,不管是Java、Pathon还是PHP等语言,全都要有地址空间,最终都是虚拟地址空间和页表这一套。
- 3、让进程管理和内存管理,进行一定程度的解耦合。
我们知道,不同软件程序,体积是不同的,代码和数据的大小都是不同的。
如果磁盘上的程序,代码体积太大了,可以选择分批加载到物理内存,提高加载效率并节省物理内存空间。因为虚拟地址空间本质就是数据结构,总共4GB,在进程看来都是自己的,想要多少虚拟地址随便填。至于实际有没有那么多物理内存,上层无需关心。
CPU调度运行进程,时间片到了,进程就被自动剥离下来,所以一次可以执行的代码一定是有限的。填写页表时,完全可以只填写虚拟地址,而物理地址分批填写,只建立一部分映射关系。进程先运行一批代码,如果查找页表时,突然发现虚拟地址存在,但物理地址不存在,就可以先暂停进程,OS进行动态加载。即同一个程序,让磁盘外设再向后加载一批代码到物理内存,重新填写页表中的映射关系,再把进程切换回来继续运行。
这种机制我们称为缺页中断。
所以,在我们程序运行时,刚开始只开辟所有虚拟地址,物理地址先不申请,需要的时候OS再动态加载。如果虚拟地址是合法的,但没有物理地址,OS就可以让外设重新执行加载任务。
因此,页表右侧物理内存和磁盘这一部分,我们可以称为OS的内存管理模块。比如访问的物理内存不存在,还在磁盘里,OS就可以自动帮我们加载。具体加载到什么地方,物理内存空间够不够,进程都无需关心。OS可以自动进行进程挂起等,释放暂时不用的资源,腾出内存空间,开辟好内存空间。这些都不需要上层的用户或进程关心。进程只需要知道,操作系统是否允许它运行。OS的内存管理模块,完全可以独立完成申请内存相关的一系列任务,根据页表自由构建映射关系。
页表左侧,我们称之为进程调度/进程切换,即进程管理模块。只需要简单的一张页表,进程的切换与调度,就和内存管理,互相之间完全解耦了。
如果没有虚拟地址空间和页表,进程直接指向物理内存,进程PCB中就必须有对应的指针,指向自己的代码和数据。此时进程要加载代码和数据,就和内存管理强相关了。一旦申请了物理内存,就必须修改进程PCB中的指针。但解耦之后,只需要更改页表即可。进程的切换与调度,和内存管理没有任何关系。
澄清一些问题:
创建进程时,我们可以只创建进程的PCB和虚拟地址空间。从磁盘中的可执行程序,知道代码和数据各自有多大,完成虚拟地址空间的初始化。在虚拟地址空间中开辟好虚拟内存,页表只填虚拟地址,物理地址不填。所以程序在加载时,可以只创建进程的PCB、虚拟地址空间和页表,但一行代码和数据都不加载。CPU拿到起始地址/入口地址/main函数的地址,直接访问虚拟地址,当OS发现有虚拟地址但没有物理地址时,所以页表中还有个标记位,表示对应的物理地址是否在内存里,如果不在,OS自动做缺页中断,自动完成程序再次加载的任务,并重新填写页表、构建映射关系。这个过程中进程是暂停的,缺页中断做完之后,进程再继续运行。
- 1、我们可以不加载代码和数据,只有task_struct、mm_struct和页表。
- 2、创建进程时,是先有task_struct、mm_struct等内核数据结构,然后再陆陆续续加载对应的代码和数据。
- 3、如何理解进程挂起?
整个系统内存资源严重不足,OS就要把当前进程所对应的代码和数据唤出到磁盘上,需要时再唤入。这就是进程挂起。比如阻塞挂起,内存空间严重不足,OS查找页表,发现虚拟地址都有,也有物理地址,就要把页表中的物理地址清空,把进程对应的代码和数据全部唤出到磁盘的swap分区。即只保留虚拟地址部分,清空物理地址部分,物理内存就腾出来了。这就是挂起的底层逻辑。所以在进程挂起时,进程还存在,代码和数据对应的虚拟地址也存在,但物理内存空间已经被释放出来了,这就是解耦的作用。
遗留问题:
- 注意,堆区是由用户动态申请的,往往都是一段段离散的堆空间。在虚拟地址空间中,堆区却只有一份start和end?整个堆空间不完全连续,被我们用户零零散散的动态申请,分成十几块、几十块堆空间,这怎么可能呢?
7. 关于堆区的认知拓展
vm_area_struct :virtual memory area。
- 在虚拟地址空间mm_struct中,还存在一个vm_area_struct*的结构体指针。
- 在vm_area_struct中,就有start 和end。
因此,我们并不担心堆区是离散的,我们完全可以将堆区看作一个链表管理,链表结点就是我们用户动态申请的一个个零散的堆空间。
- 在虚拟地址空间mm_struct中,有一个mmap指针,维护了一张vm_area_struct链表。
- 每个链表结点都是一个vm_area_struct结构体对象,都有一份start和end,即代表一个区域划分。
- 即便堆区是离散的,但每一个堆空间,都可以通过一个链表结点记录下来。以分段不连续的方式把整个地址空间划分好。
一个进程task_struct对应一个虚拟地址空间mm_struct。在虚拟地址空间中,有很多连续的区域,可以通过一份start和end记录下来。但像堆区,虽然也是在一段固定的虚拟地址范围中,但堆空间的使用是离散的,可能零零散散需要很多份start和end记录。
因此虚拟地址空间mm_struct 会通过vm_area_struct链表管理堆区。链表本身也是离散的,各个结点都是单独申请的。
在虚拟地址空间mm_struct中,会记录下来从正文代码区、初始化数据区、未初始化数据区、堆区、共享区、栈区、命令行参数环境变量区,各个区域的起始地址start和结束地址end。
- 虚拟地址空间mm_struct,是对各个区域的,整体的描述。
- 除了堆区会划分多份,被vm_area_struct链表管理起来。其实其他区域,同样会管理在vm_area_struct链表中。
- 通过一个vm_area_struct链表,将所有区域都管理了起来。上图中,堆区虽然只画了一份,但如果有多份,也可以创建多个vm_area_struct结点,直接链入管理。
因此虚拟地址空间mm_struct,实际是重复描述了各个区域的起始地址start和结束地址end的。主要是方便表示整个地址空间的整体排布。例如堆区可能有多份,但mm_struct只会记录堆区整体的区域的开始和结束,对于内部离散的堆空间不作描述。
- 因此,实际上,进程在查我们的代码或者其他区域时,是可以不用关心mm_struct中的start和end的。vm_area_struct链表中本身就详细记录了每个区域的起始和结束。
进程的独立性理解:
- 在Linux中,我们用户使用的都是虚拟地址,由进程地址空间提供,虚拟地址会经过页表映射到物理内存,完成虚拟地址到物理地址的转化。反过来,我们程序的代码和数据加载到物理内存,每一行代码和每一个变量,都会有对应的物理地址。有了物理地址,就需要在进程地址空间中等量的分配虚拟地址,再经过页表进行映射,进而完成物理地址到虚拟地址的转化。
- 进程 = 内核数据结构 + 自己的代码和数据。每一个进程都有自己独立的PCB,也有自己独立的地址空间,独立的页表,而页表映射到不同的物理内存空间。因此进程就具有了独立性。
因此,一个进程的内核数据结构 ,除了task_struct,今天还需要补充两个内核数据结构,即内存描述符mm_struct与vm_area_struct。




对应的命令行参数ls、-a、-b、-c,都要被解析成字符串,存储到argv数组中,而argc则为4代表有效元素个数。


,例如相对路径 ./。可系统指令却不用带路径,例如 ls
。

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


。当然,如果再重新登录,环境变量同样会再次恢复默认值。








。用户登录创建bash进程时,系统默认将HOME设置为当前用户的家目录。
。bash进程创建时,系统默认将LOGNAME设置为当前登录的用户。
。bash进程创建时,系统默认将USER设置为当前用户。
su命令是将当前用户的权限切换为root,类似于sudo是一次性提权,而su是切换后任意使用,不影响环境变量LOGNAME和USER。
su -命令相当于用root用户重新登录了,所以环境变量LOGNAME和USER都会切换成root。
表明当前用户使用的哪个版本的shell,记录其绝对路径。所以我们当前使用的shell程序,就是对应路径下的命令行解释器程序bash。在Windows下,使用的shell程序就是图形化界面。
bash可以记录的最大历史命令条数。
又或者 !v,可以快速匹配上一次执行的vim命令,快速打开文件。
或者history命令,可查看最近的HISTSIZE条历史命令。当然,第一条命令不一定从1开始编号,这里第一条命令编号是39,但当前总共有1000条。
表示终端类型,xterm就是其中一种终端类型。和字符设备相关。
表示当前主机的主机名。
分别表示登录的客户端IP、进程号、端口号。
表示当前是哪一个终端设备。进程打印数据时就默认往SSH_TTY对应设备中打印。
表示当前默认的编码格式。
表示当前的工作路径,即当前shell所在路径。
表示上一次所在的工作路径。
cd - 命令可以快速回到上一次所在路径,就是通过OLDPWD完成的,就是直接cd切换到OLDPWD所记录的路径下。当然,如果当前路径PWD=/home,切换到上一次所在路径OLDPWD=/home/A/B/C。那么当前路径PWD就变成了/home/A/B/C,上一次所在路径OLDPWD就变成了/home。所以我们通过
env命令查看所有环境变量。
echo $XXX查看指定环境变量的内容。
export命令导入一个新的环境变量,新增到bash进程的环境变量表里 (内存级,下次登录新创建bash进程则消失)。
unset命令取消新增的环境变量。在bash进程内的环境变量表中去掉MYENV。



所以环境变量具有全局性。







需要使用set 命令查看,它会显示所有环境变量 和本地变量。
。
例如bash需要为用户打印
命令行提示符 ,等待用户输入命令。而本地变量PS1就可以帮助bash记录命令行提示符的格式,未来输出时就按照PS1中的格式进行输出。



像"hello world"这种字符串,是不能被修改的,因为它被放在字符常量区 ,但其实它和代码区是同一区域的。我们平时定义的字符串,其实是被硬编码到代码的,因为代码是只读的,所以字符串也是只读的,只不过字符串统一位于字符串常量区。
如int test是栈上的局部变量,但被static修饰,作用域虽然还是局部的函数作用域,但生命周期已经变成全局的了。因为被static修饰的变量,和全局变量是一个区域的,即已初始化数据区。所以static变量就是全局变量,只不过作用域还是被限制为局部作用域。














我们知道,字符串常量不可被修改,所以一般要用const char*指针指向,但const一般是编译器级别的保护。现在的问题是,我们可以通过str指针修改字符串吗?实际上,程序可以通过编译,但运行时就会崩溃。因为字符串是被硬编码到正文代码区的,属于字符常量区,即靠近初始化数据区的位置。因此字符常量区的权限就是只读的。所以当我们想修改字符常量区时,查页表就会发现访问区域是只读的,但我们想进行写入,所以页表就会转化失败。即查找页表时我们的进程被权限拦截了。




