目录
1. 环境变量
1.1 环境变量基本概念
环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数 如:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。 环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性。
继续用上一篇Linux_8写的代码:
当我们 ls 显示当前路径文件时,和运行自己写的代码时,比如 ./process,有没有想过:为什么我们的代码运行要带路径,而系统的指令不用带路径?
如果我们直接输入我们的可执行程序,会显示 bash: process: command not found。我们说过,执行系统的指令实际上也是程序,系统的指令你也是可以带上路径的:
系统中是存在相关的 环境变量,是保存了程序的搜索路径的。
为什么我们的代码运行要带路径,而系统的指令不用带?其本质是由环境变量 PATH引起的。
1.2 环境变量PATH
和环境变量相关的命令1. echo: 显示某个环境变量值2. export: 设置一个新的环境变量3. env: 显示所有环境变量4. unset: 清除环境变量5. set: 显示本地定义的shell变量和环境变量
输入 env 显示所有环境变量:
这些变量每一个都有它特殊的用途,系统中搜索可执行程序的环境变量叫做 PATH。
我们可以通过 grep 去抓一下:输入 env | grep PATH
如何查看环境变量的内容?我们可以使用 echo 去显示:输入 echo $PATH
($用来区别直接打出PATH这个字符串)
环境变量 PATH中会承载多种路径,中间用冒号 ( : ) 作为分隔符。
我们在执行某一个程序时,比如执行 ls 时,我们的系统识别到 ls 的输入时,会在上面路径中逐个搜索,只要在特定的路径下找到了 ls,就会执行特定路径下的 ls 并停止搜索。
换言之,PATH就提供了环境变量,可执行程序搜索的路径。我们的 ls 在 usr/bin 路径下,这说明当前的 ls 在 PATH中是可以被找到的,所以执行 ls 的时侯自然可以不带路径,所以我们自己的 process 不带路径自然就不能执行。
因为当前的 process 所在的路径并没有这里的环境变量,程序在搜索的时侯找了路径也没有找到你这个可执行程序,搜索完找不到,自然就报 "command not found" 了。
那我现在就想让我的可执行程序 process 不带路径直接执行起来,可以吗?可以。我们先讲述一种简单粗暴的方式,直接把我们的可执行程序 cp 拷贝到系统的路径中:转到root用户,然后输入:cp process /usr/bin/
既然系统的所有命令都在 usr/bin 路径下,那我们把我们的 process 拷进去就行了。实际上,刚才那个操作我们可以称之为 "软件被安装到系统上",但是我们不建议你去自己安装。也更不建议你将你的指令拷贝到 Linux 系统路径下,因为这会污染 Linux 下的命令池。
更好的方式是将 process 所处的路径也添加到环境变量中:
pwd查看当前路径,把当前路径添加到环境变量中:export PATH=$PATH:路径
添加完成后我们的程序就能不用输入路径直接运行了:
这里只要重新登录,环境变量又变成原本的了。
1.3 环境变量HOME和SHELL
常见环境变量:
PATH : 指定命令的搜索路径
HOME : 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
SHELL : 当前Shell,它的值通常是/bin/bash。
其他环境变量:
USER:当前用户名
PWD:当前所处路径
HOSTNAME:主机名
(环境变量实在多,全部讲完不太现实,所以就先讲到这)
1.4 获取环境变量(main函数参数)
环境变量是以数组的形式存储的,它的组织方式如下图:
其实main函数有三个参数:
前两个参数叫作命令行参数,第三个叫作环境变量参数。
- 第一个参数:int argc是个整型变量,表示命令行参数的个数(含第一个参数)。
- 第二个参数:char *argv[ ] 是个存放字符指针的数组,每一个元素一个字符指针,指向一个字符串。这些字符串就是命令行中的每一个参数(字符串)。
- 第三个参数:char *envp[ ] 也是存放字符指针的数组,数组的每一个原元素是一个指向一个环境变量(字符串)的字符指针。
1.4.1 main函数第三个参数
下面我们按照上面,先讲讲第三个参数:环境变量参数。
据图,看一段在Linux环境下运行的代码:
(根据图,env最后放的是空指针,退出for循环)编译运行:
这是在代码里获取环境变量的第一种方式。
根据这个图,我们还可以用environ获取环境变量,问一下man怎么用:
这是一个全局的第三方变量:
编译运行:
这是代码获取环境变量的第二种方式,但是一般不用前两种方式,用第三种:
这是一个函数接口,用来获取一种环境变量,这里获取上面讲的PATH:
编译运行:
现在我们学了这么多获取环境变量的方式,那么这个环境变量是谁设置的?main函数的第三个参数是谁传入的?
环境变量具有全局属性,环境变量是会被子进程继承下去的。
所以环境变量都是有这个进程的父进程设置的。
我们这里用gerenv顺便获取一个不存在的环境变量:
编译运行:
发生了段错误,因为根本没有这个环境变量。
1.4.2 设置普通变量和环境变量
在 Linux 命令行中,我们也是可以定义变量的,命令行变量分为两种:
- 普通变量
- 环境变量(具备全局属性)
命令行上直接写,变量名等于值:
你所定义的这个变量 abcdef,就是 普通变量。用系统查看环境变量的命令 env 去查看一下这个本地变量,会发现根本找不到,试试编译运行上面这个代码:
还是段错误,因为它不以环境变量的形式存在,不具有全局性,但是它是存在的。如果你想让一个变量变成环境变量,你可以通过 export 导出一个在系统中可以查看的环境变量:
env可以看到,代码也不用编译再运行也能打印。
所以:环境变量具有全局属性,环境变量是会被子进程继承下去的。
所以环境变量都是由这个进程的父进程设置的。
父进程只要有用父进程那也是继承的,直到头,一般是bash。
题外话:Linux 下大部分命令都是通过子进程的方式执行的,但是还有一部分命令不通过子进程的方式执行,而是由 bash 自己执行(调用自己的对应的函数来完成特定的功能,比如 cd 命令),我们把这种命令叫做 内建命令。
1.4.3 main函数前两个参数
前两个参数叫作命令行参数,第三个叫作环境变量参数。
- 第一个参数:int argc是个整型变量,表示命令行参数的个数(含第一个参数)。
- 第二个参数:char *argv[ ] 是个存放字符指针的数组,每一个元素一个字符指针,指向一个字符串。这些字符串就是命令行中的每一个参数(字符串)。
- 第三个参数:char *envp[ ] 也是存放字符指针的数组,数组的每一个原元素是一个指向一个环境变量(字符串)的字符指针。
前面讲了第三个参数,现在讲前两个参数,第二个参数也是指针数组,第一个应该是第二个参数指向的个数,先打印一下:
编译运行:
通过一顿乱敲,机智的同学已经发现什么了: 这就是Linux下命令设置的方式
第一个参数:int argc是个整型变量,表示命令行参数的个数(含第一个参数)。
第二个参数:char *argv[ ]是个存放字符指针的数组,每一个元素一个字符指针,指向一个字符串。这些字符串就是命令行中的每一个参数(字符串)。
所以,第二个参数的数组存放的指针指向的就是命令行参数。argc是含第一个参数的参数个数。
Linux下命令设置的方式再规范一点就是这样的:
编译运行:
再加上前面把process路径设置成不用绝对路径的形式,和 ls 命令差不多了。
至此,main函数的三个参数全部讲完。
2. 进程地址空间
进程地址空间可以叫作程序地址空间(不准确),也叫作内存地址空间,也叫作虚拟地址空间。
我们先基于是什么,为什么,怎么办,问三个问题:
① 什么是进程地址空间?(是什么)2.2
② 为什么要有进程地址空间?(为什么)2.3
③ 进程地址空间是如何设计的?(怎么办)2.1
2.1 验证进程地址空间的分步
进程地址空间是如何设计的?(怎么办)
之前在学习C和C++的时候,经常画过类似的空间布局图(其实就是进程地址空间的分步):我们先验证下:
但是真的理解它吗?物理内存中就是这样的吗?其实并不是这样的。来看一段代码(这里返回rtx2目录创建linux_9目录),
写一个新的Makefile,以前写的是这样的:
如果有很多文件要这样写:
一直有就一直要写两遍,所以就有一个符号可以这样写:
以后输入只有一个文件,我们也这样写了:(这里应该没配置过所以加上-std=c99)
验证最开始的那张内存图,写test.c:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int un_g_val;
int g_val = 100;
int main(int argc, char* argv[], char* env[])
{
printf("code addr : %p\n", main);
printf("init global addr : %p\n", &g_val);
printf("uninit global addr : %p\n", &un_g_val);
char* m1 = (char*)malloc(100);
printf("heap addr : %p\n", m1);
printf("stack addr : %p\n", &m1);
for (int i = 0; i < argc; i++)
{
printf("argv addr : %p\n", argv[i]);
}
for (int i = 0; env[i]; i++)
{
printf("env addr : %p\n", env[i]);
}
return 0;
}
编译运行:
验证成功,整体向高地址增长的,再验证一下堆像高地址增长,栈向低地址增长:
编译运行:
所以:堆向高地址增长,栈向低地址增长(堆,栈,相对而生)
我们再来理解一下 static 变量,如何理解 static 变量?
一个变量在函数内被定义,如果声明其为 static,(如果不声明应该是在栈上的)那么它的作用域不变,但它的生命周期会随着程序存在一直存在。
我们可以加入一个 static 变量进刚才的代码中,我们来观察观察:
int a = 77;
static int s_a = 777;
printf("%p\n",&a);
printf("%p\n",&s_a);
编译运行:
至此,成功验证进程地址空间的分步。
2.2 进程地址空间的引入
还是看一段代码:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int g_val = 100;
int main()
{
pid_t id = fork();
if (id == 0)
{
while (1)
{
printf("子进程: %d, ppid: %d, g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
}
else if (id > 0)
{
while (1)
{
printf("父进程: %d, ppid: %d, g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
}
else
{
printf("创建子进程失败\n");
}
return 0;
}
编译运行:
当父子进程没有人修改全局数据的时候,父子是共享该数据的。如果此时尝试写入,比如我们让子进程有一个修改的操作。让子进程执行五次之后给它改值:
编译运行:
父子进程打出来的地址是一样的,值却不一样!?所以我们在 C/C++ 中使用的地址,绝对不是真实物理内存的地址。如果是物理地址,上面出现的那种现象是不可能产生的。
不是物理地址,那是什么呢?就是下面进程地址空间的虚拟地址。
虚拟地址 在 Linux 下也称为 线性地址,有些教材中也称之为 逻辑地址。这三个概念实际上是不一样的,但是在 Linux 下它是一样的(这和其本身的空间布局有关系)。
2.3 进程地址空间是什么
① 什么是进程地址空间?(是什么)
进程地址空间是指每个进程在计算机内存中所占用的地址空间。地址空间是指能被访问的内存地址范围,它由若干个连续的内存块组成。每个进程都有自己的地址空间,这意味着每个进程都有自己的内存地址范围,不会与其他进程冲突。
进程地址空间通常被划分为几个部分:
代码段:存储程序代码的内存区域。
数据段:存储程序运行时所使用的数据的内存区域。
堆:动态分配内存的区域。
栈:存储函数调用时所需的数据(如参数、返回地址和临时变量)的区域。
每个进程在执行时,都会使用自己的地址空间。进程间通信时,必须通过操作系统提供的机制来实现,因为不同进程之间的地址空间是独立的。
一个列子:
有一个大富翁,他有十亿美金,他有三个私生子,这些私生子都不知道彼此的存在,大富翁为了鼓励这些私生子努力工作,分别与三个孩子单独会面并且许诺他们在自己死后把全部的资产继承给这个孩子,所以所有的私生子都非常努力的工作,而且所有的私生子都认为十亿美金迟早是自己的。这十个亿的美金只是大富翁给所有私生子画的一个大饼。
但是私生子也需要花钱,于是每一个私生子有需要就问大富翁要钱,每次只要几十或者几百万等等的美金。
我们以系统的方式进行理解:
大富翁就是操作系统,十亿美金真实的物理内存,画出的饼就是虚拟的进程地址空间,这三个私生子就是进程,所有的进程都认为自己会独占系统资源(实际上,这只是操作系统给进程画的饼)。所有私生子要的几十或者几百万等等美金只是进程申请的资源和空间。
操作系统怎么画饼呢?:
先描述,再组织。
内核中的地址空间,本质也是一种数据结构,要和一个个进程关联起来。
内核中的地址空间,本质也是一种数据结构,所以就要有区域的划分:
这样一来,进程地址空间就被描述了出来,也就是被划分了出来,而且可以通过修改结构体变量中的值来调整各个区域的大小。
每一个进程在启动的时侯都会让操作系统给它创建一个地址空间,该地址空间就是 进程地址空间。操作系统为了管理一个进程,给该进程维护一个 task_struct 叫做进程控制块。
虚拟地址空间是怎么和内存联系起来的?现代计算机,提出了下面的机制:
物理内存本身是可以被顺便读写的,非常的不安全,所以就有了上面虚拟地址空间的机制。
所以进程地址空间是内存吗?:不是,进程地址空间不是内存。
页表在讲线程的时候才会更好讲解,这里弱化一下,虚拟地址空间和页表每个进程都有一份,所以保证了进程之间的独立性,所以就有了这样的图:
2.4 为什么要有进程地址空间
为什么要有进程地址空间,三大原因:(进程地址空间的意义)
1. 上面不还是访问了物理内存吗?怎么保证安全?
进程地址空间会识别不安全的命令,并拒绝,这样就能保证物理内存的安全了:
凡是非法的访问或者映射,OS都会识别到,并终止你这个进程。因为地址空间和页表是OS创建并维护的,也就意味着凡是想使用地址空间和页表进行映射,也一定要在OS的监管之下来进行访问。
也便保护了物理内存中的所有的合法数据包括各个进程,以及内核的相关有效数据。
2. 进程地址空间的存在,可以更方便的进行进程和进程的数据代码之间的解耦合(减少模块与模块直接的关联性,以前说过开发要尽量:低耦合,高内聚)保证了进程的独立性。
不同的进程即使操作同一块空间,因为虚拟地址空间的存在,操作系统在将不同进程的相同地址和物理空间做映射的时候,就可以在物理内存中分配不同的物理空间供不同的进程使用。如此一来,即使进程操作的是相同的地址,但是映射到物理内存中后,操作的物理空间就不同了,并不会互相影响,保证了进程的独立性。
3. 因为在物理内存中理论上可以任意位置加载,那么物理内存中的几乎所有的数据和代码在内存中是乱序的。
但是,因为页表的存在,它可以将地址空间上的虚拟地址和物理地址进行映射,那么在进程视角所有的内存分布,都可以是有序的。地址空间+页表的存在可以将内存分布有序化。
结合第2个原因。让进程以统一的视角来看待进程对应的代码和数据等不同的区域,也方便编译器以统一的视角规则来进行编译。
进程空间地址的存在,让进程以为自己拥有所有的空间,从而可以随意进行操作,而不用考虑是否会影响到其他的进程。同样的,编译器在编译不同进程的时候,只需要按照进程地址空间一套规则编译即可,每个进程都一视同仁,不用考虑不同进制之间的影响。
总的来说,进程地址空间的存在,就是让各个进程只做自己的事而不用考虑其他人,编译器在编译的时候也只需要考虑一个进程。
不同进程的虚拟地址最后会由操作系统通过页表与物理内存映射起来。
因为有地址空间的存在,每一个进程都认为自己拥有4GB空间(32),并且各个区域是有序的,进而可以通过页表映射到不同的区域,来实现进程的独立性。
每一个进程不知道,也不需要知道其他进程的存在。(大富翁的故事)
所以为什么要有进程地址空间?三个关键:①保护物理内存,②解耦合,③有序地保证独立性。
本质上,(因为有地址空间的存在,所以上层申请空间(malloc,new),其实是在地址空间上申请的,物理内存可以甚至一个字节都不给你。
而当你真正进行对物理地址空间访问的时候,才执行内存的相关管理算法,帮你申请内存,构建页表映射关系),然后,在让你进行内存的访问。上面都是由操作系统自动完成的,用户和进程,完全零感知。这样的机制叫作缺页中断。(这个名词先知道就行)
3. 进程创建fork
3.1 fork函数概念和用法
在linux中fork函数是非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。前面《零基础Linux_7(进程)冯诺依曼结构+操作系统原理+进程的概念和基本操作》中已经讲了一部分fork的内容。
- 函数:pid_t fork(void);
- 头文件:<unistd.h>
- 返回值:子进程返回0, 父进程返回的是子进程的pid,创建失败返回-1
- 作用:创建一个子进程,如果创建成功,返回两个值。
进程调用fork,当控制转移到内核中的fork代码后,
内核:
- 分配新的内存块和内核数据结构给子进程,
- 将父进程部分数据结构内容拷贝至子进程,
- 添加子进程到系统进程列表当中,
- fork返回,开始调度器调度。
fork之前父进程独立执行,fork之后,父子两个执行流分别执行。
注意:fork之后,父子先执行完全由调度器决定。
进程调用 fork,当控制转移到内核中的fork代码后,操作系统会做什么? :
① 将给子进程分配新的内存块和内核数据结构,
创建 task_struct 和进程地址空间。
② 将父进程部分数据结构内容拷贝至子进程,
以父进程为模板,设置子进程的相关数据结构和父进程相关字段保持一致。
task_struct、地址空间、区域划分很多东西都是一样的。
但不是无脑拷贝,比如累计调度的时间片是不一样的。
③ 添加子进程到系统进程列表当中
取决于你进程是要做什么,创建后如果状态没问题就会直接链入运行队列中。
④ fork 返回,开始调度器调度
当准备返回时,上面三个工作都有了,父进程继续执行开始 return,子进程也可能执行 fork 的返回值,然后就会得到两次返回。
那么fork之后,是否只有fork之后的代码是被父子进程共享的?:
实际上,fork之后代码共享这样的说法并不准确。一般情况fork之后,父子共享所有的代码,子进程执行的后续代码不等于共享的所有代码,只不过子进程只能从这里开始执行。
子进程怎么知道从这里开始执行?靠的是eip:
eip 叫做 程序计数器,用来保存当前正在执行的指令的下一条指令。
eip 程序计数器会拷贝给子进程,子进程便从该 eip 所指向的代码处开始执行。
我们再来重新思考一下fork之后操作系统会做什么?:
" 进程 = 进程的数据结构 + 进程的代码和数据 "
创建子进程的内核数据结构:
(struct task_struct + struct mm_struct + 页表)+ 代码继承父进程,数据以写时拷贝的方式来进行共享或者独立。
所以:fork之后创建一批结构,代码以共享的方式,数据以写时拷贝的方式,两个进程保证 "独立性",做到互不影响。在这种共享机制下子进程或父进程任何一方挂掉,不会影响另一个进程。
3.2 写时拷贝
程序被编译出来,没有被加载的时候,程序内部有地址吗?有。有没有区域?也有。
程序内部的地址和内存的地址是没有关系的。编译程序的时候,我们就认为程序是按照 0000 0000到FFFF FFFF进行编址的。
虚拟地址空间,不仅仅是操作系统会考虑,编译器也会考虑。每个进程都会创建一个 task_struct,每一个进程都会维护一个 mm_struct,自己有对应的区域,当我们的程序加载到内存时,程序有自己的加载到物理内存的物理地址,虚拟地址和物理地址建立映射关系,进程访问某个区域当中的地址时,经过页表找到对应的代码和数据。
当找到代码和数据后,代码加入到对应的 CPU 中,代码中的地址在加载中就已经转化成了线性地址/虚拟地址,所以 CPU 可以继续照着这个逻辑向后运行。
所以刚才我们代码测试,打印看到的虚拟地址值是一样的,并且内容也是一样的。在没有人写入的时候,虚拟地址到物理地址之间映射的页表是一样的,所以指向的代码和数据都是一样的。
因为进程具有独立性,比如如果此时子进程把变量改了(写入),就会导致父进程识别的问题就出现了父进程和子进程不一的情况,因为进程是具有独立性的,所以我们就要做到互不影响。
我们的子进程要进行修改了,影响到父进程怎么办?当我们识别到子进程要修改时,操作系统会重新给子进程开辟一段空间,并且把 100 拷贝下来,重新给进程建立映射关系,所以子进程的页表就不再指向父进程所对应的 100 了,而直接指向新的 100。
你在做修改时又把它的值从 100 改成 777时,我们就出现了 "改的时候永远改的是页表的右侧,左侧不变" 的情况,所以最后你看到了父子进程的虚拟地址一样,但是经过页表映射到了不同的物理内存,所以了你看到了一个是 100 一个是 777,父子进程的数据不同的结果。
我们的操作系统当我们的父子对数据进行修改时,操作系统会给修改的一方重新开辟一块空间,并且把原始数据拷贝到新空间当中,这种行为就是 写时拷贝。
当父子有任何一个进程尝试修改对应变量时,有一个人想修改,就会触发写时拷贝,让他去拷贝新的物理内存,这只需要重新构建页表的映射关系,虚拟地址是不发生任何变化的,所以最终你看的结果是虚拟地址不变,而内容不同。
操作系统为什么要写时拷贝?创建子进程的时候就把数据分开不行吗?:
- 有浪费空间之嫌:父进程的数据,子进程不一定全用;即便使用,也不一定全部写入。
- 最理想的情况,只有会被父子修改的数据,进行分离拷贝。不需要修改的数据,共享即可。但是从技术角度实现复杂。
- 如果 fork 的时候,就无脑拷贝数据给子进程,会增加 fork 的成本(内存和时间)
最终采用写时拷贝:只会拷贝父子修改的、变相的,就是拷贝数据的最小成本。拷贝的成本依旧存在。
写时拷贝实际上以一种 延迟拷贝策略,延迟拷贝最大的价值:只有真正使用的时候才给你拷贝。其最大的意义在于,你想要,但是不立马使用的空间,先不给你,那么也就意味着可以先给别人。
反正拷贝的成本总是要有,早给你晚给你都是一样。万一我现在给你你又不用,那其实不很浪费所以我选择暂时先不给你,等你什么时候要用什么时候再给。这就变相的提高了内存的使用情况。
在前面:零基础Linux_7(进程)冯诺依曼结构+操作系统原理+进程的概念和基本操作,就提出过一个问题,关于 fork 为什么有两个返回值的问题。
当时还提出了两个问题,局限于当时还没有讲到进程地址空间,所以没有办法深入讲解,先穿越回去截个图:
fork 有两个返回值,pid_t id,同一个变量为什么会有两个返回值?现在我们就可以理解了,因为当它 return 的时候,pid_t id 是属于父进程的栈空间中定义的。
fork 内部 return 会被执行两次,return 的本质就是通过寄存器将返回值写入到接收返回值的变量中。当我们的 id = fork() 时,谁先返回,谁就要发生写时拷贝。所以,同一个变量会有不同的返回值,本质是因为大家的虚拟地址是一样的,但物理地址是不一样的。
还有如何理解,父子进程让if和else if同时执行?
fork之后的代码,父子进程是共享的。
也就是说,在fork之后的代码,父子进程是共同执行的,并且父子进程使用的是同一块物理空间中的代码。但是各自的id值是不同的,所以会父子进程会进入不同的条件判断中,并且执行不同的代码。
本篇完。
下一篇:零基础Linux_10(进程)进程终止(main函数的返回值)+进程等待。
再下一篇:进程程序替换+实现简单的shell。
穿越回来复习顺便贴个下篇链接:零基础Linux_10(进程)进程终止(main函数的返回值)+进程等待_终止main.c-优快云博客