目录
- 前言
- 1 认识进程
- 1.1 进程的概念
- 1.2 进程的管理
- 1.3 查看进程的两种方法
- 1.4 getpid、getppid和fork函数
- 2 进程状态
- 2.1 普遍概念下的进程状态
- 2.2 Linux下的进程状态
- 2.2.1 测试Linux的各种进程状态
- 2.2.2 僵尸进程
- 2.3 孤儿进程
- 3 进程切换与进程优先级
- 3.1 并行、并发
- 3.2 进程切换
- 3.3 进程的优先级
- 4 环境变量
- 4.1 常见的环境变量
- 4.2 环境变量相关的一些指令
- 4.3 环境变量的组织方式
- 4.4 环境变量的获取方式
- 4.4.1 通过代码获取
- 4.4.2 通过系统调用函数获取
- 4.5 环境变量的全局属性
- 5 进程地址空间
- 5.1 引入
- 5.2 进程地址空间的概念分析
前言
💭 我们常说,程序要加载到内存中才能运行,其原因已经在《冯诺依曼体系结构、操作系统的认识》一文中探讨过了。那么,程序加载到内存后,会发生什么呢?程序如何运行呢?这就涉及到本文将要讨论的重点——进程
🔎本文将初步认识进程以及在Linux操作系统下进程的特性。
1 认识进程
1.1 进程的概念
- 进程是一个程序的执行实例,正在执行中的程序,从内核的角度看,它是分配内存资源(CPU时间,内存空间)的实体。也就是说,当程序加载到内存中并开始运行时,就成为了一个进程。
1.2 进程的管理
💭由常识我们知道,计算机几乎不可能在一个时刻只运行一个程序,就像我们平时用电脑,会开着各种app,它们是同时运行的。也就是说,内存中的进程不止有一个,而多个进程同时在工作时,操作系统必然要对它们进行管理,使得计算机中的工作有序地进行。怎么管理呢?显然还是操作系统一贯的管理模式——先描述,再组织!
- 描述
- 进程的信息被存储在一个叫做进程控制块的结构体上,可以理解为是进程信息的集合。
进程控制块简称为PCB(process control block)
,在Linux操作系统中,这个结构体命名为task_struct。
📝Linux内核PCB结构体的部分代码
-
每当一个程序
(一个二进制可执行文件)
被加载到内存中,形成进程,操作系统都会生成一个该进程对应的PCB,我们对进程操作基本都是对PCB进行操作,而不是直接对加载入内存的二进制可执行文件进行操作。 -
PCB中有一个指针,用于指向进程对应的(可执行文件中的)代码和数据。
-
task_ struct内容分类
- 标示符: 描述本进程的唯一标示符,用来区别其他进程。
- 状态: 任务状态,退出代码,退出信号等。 优先级: 相对于其他进程的优先级。
- 程序计数器: 程序中即将被执行的下一条指令的地址。
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
- 上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
- I/ O状态信息:包括显示的I/O请求,分配给进程的I/ O设备和被进程使用的文件列表。
- 记账信息:可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。 其他信息
- 组织
很显然,操作系统不会对进程的代码和数据做组织,而是对进程的PCB做组织。在linux下,所有运行进程的PCB以链表的形式存储在内核中。
💡总结:
进程的概念可以简化为:进程 = 内核数据结构+程序的代码和数据
1.3 查看进程的两种方法
🔎我们通常需要查看某个进程的属性、状态等信息,以确定对其下一步的操作。在Linux下如何查看进程?
- 用ps命令获取
ps ajx
:查看所有进程
ps ajx | grep ...
:ps ajx加上管道和grep筛选,获取我们想要查看的进程
💨写一个能够死循环运行的程序
💨使其运行起来后,查看该进程
💨想要前面这些数字和字符是什么意思?试着显式ps ajx中的第一行文本(各个数字、字符的意义)
PID:描述该进程的唯一标识符,用于区分其它进程。每个进程都有一个独一无二的PID。
PPID:该进程的父进程的PID。
STAT:该进程的状态,后面展开讨论
- 通过/proc系统文件夹查看,该文件夹其实是内存级的,但在Linux中它作为根目录下一个文件夹,可以查看进程信息。
该文件夹中,有许多以数字命名的子文件夹,其实这些文件夹每个的名称对应一个进程,里面存放着对应进程的信息。
通过ps命令得到hello进程的PID是1274,我们来看看/proc/1274里面都藏着什么?
其中大多数我们尚且不认识,但是其中
exe -> /home/ckf/lesson5/hello
其实就是当前进程对应的二进制文件。 如果进程运行时,我们将对应的在磁盘上的可执行文件删掉,该进程还能运行吗?答案是可以的,因为在磁盘上的可执行文件已经加载到内存中了,外部无法对内存造成影响,CPU依然能找到当前进程的代码和数据。
1.4 getpid、getppid和fork函数
💬这三个函数属于系统调用函数
函数 | 功能 |
---|---|
getpid | 获取当前进程的PID |
getppid | 获取当前进程的父进程的PID |
fork | 以当前进程为父进程,为父进程创建子进程 |
-
所谓父子进程,子进程是由父进程创建的完全独立于父进程的一个进程,父子进程共享一段代码和数据,子进程相当于父进程的副本
-
我们需要注意fork进程的返回值,在父子进程中有所不同。
🎈man手册中关于fork返回值的详细介绍
RETURN VALUE
On success, the PID of the child process is returned in the parent, and 0 is returned in the
child. On failure, -1 is returned in the parent, no child process is created, and errno is set
appropriately.
利用这三个函数,我们可以对父子进程进行验证:
运行程序,发现两个循环同时运行,说明父进程成功创建了子进程。且子进程的ppid是父进程的pid也得到了验证。
运行过程中,通过ps命令也可以查看父子进程
2 进程状态
💭上面我们初步了解了进程是什么,综上所述我们可以得到,进程是程序加载到内存后的一个执行实体,我们通常称进程在内存中运行。而进程运行过程中,总会出现一些特殊情况,就像人有工作状态、休息状态等等,进程也会有不同的状态。下面我们要探究进程有哪些状态,本着普遍到特殊的探究理念,先看普遍概念的进程状态,再看Linux下的进程状态。
2.1 普遍概念下的进程状态
🔎进程状态有很多,运行、就绪、阻塞、挂起、等待、新建等等,这里我们先讨论运行状态、阻塞状态和挂起状态
- 运行状态
进程的任务是由CPU执行的,进程需要从内存载入CPU才能运行,因此,进程的运行状态通常会被误认为是当一个进程占有CPU资源并执行其任务时的状态,其实不然。内存中的进程有多个,而CPU往往只有一个,为了保证每个进程都能运行,CPU在内核中有一个对应的等待队列,进程的PCB会进入到这个队列中等待CPU资源准备运行,而进程PCB在CPU等待队列中准备运行的状态就称为运行状态。
⭕PS:这里进程PCB和进程的代码数据在内存中的地址空间不会改变,只是建立了指针链接关系。
CPU通过等待队列中的头指针找到当前“队头”的进程PCB,便可找到其对应的代码和数据从而执行任务,当然CPU与进程的“交涉过程”没这么简单,涉及到了进程切换、进程地址空间,后面再作了解,这里我们只先掌握进程运行的状态。
- 阻塞状态
进程运行过程中,除了需要占有CPU资源,有时候也需要其他资源(下面拿外设资源来举例)
,如显示器、磁盘、网卡和键盘等等。因此各种外设资源也需要在内核中有自己的等待队列,供进程排队等待资源的占有使用。我们知道,操作系统通过先描述再组织的方式管理外设,在内核中为每个外设建立了一个结构体存储其属性、信息、操作方法等,类似进程的PCB,而外设的等待队列也就在该结构体当中。
在《冯诺依曼体系结构、操作系统的认识》一文中我们探讨过,CPU与外设的执行速度差距非常大,当CPU在运行进程时,发现进程需要访问外设,但外设资源不能立即使用需要等待,这对CPU来说是个非常漫长的过程,CPU不可能陪着进程一起等待外设资源就绪。
因此操作系统是这样做的:我们将当前需要访问外设的进程称为进程A,将进程A从CPU运行队列转移到外设资源等待队列,然后CPU继续执行下一个进程,而进程A在外设资源等待队列中等待资源就绪的状态就称为阻塞状态。当进程A获取了外设资源时,操作系统将其调离外设等待队列,状态从阻塞状态改回运行状态,再入CPU的运行队列准备运行。
综上所述,阻塞状态就是进程在等待某种其它资源(非CPU)就绪时的状态
总的来说,所谓进程状态,本质上就是进程在不同队列中等待某种资源,而进程何时前往哪个队列,依靠的是操作系统的调度。
- 挂起状态
挂起是操作系统节省内存空间一种手段。给一个场景:若在某一时刻内存中有多个进程处于阻塞状态,正在等待某种资源的就绪,这些进程的代码数据都在内存中保存着,短期内不会被使用。随着阻塞状态的进程数量增多,内存可能会空间不足。既然这些进程的代码数据暂时不会被使用,何不将它们移出内存呢?
操作系统是这样做的:将处于阻塞状态的进程的代码数据暂时移出内存,重新挪动到磁盘上,而该进程的PCB依然留存在内存中的资源等待队列,待到资源就绪之时,再通过PCB找到相应的代码数据,从磁盘中再加载入内存。
以上操作我们称其为进程代码数据的换入换出操作,节省了内存空间,供正在运行的进程使用。而挂起状态就是进程代码数据被暂时换出到磁盘上的状态。
⭕PS:挂起不只是在进程处于阻塞状态下会发生,可能是阻塞挂起,也可能是就绪挂起、新建挂起……只要是需要节省空间的场景都可能发生挂起。挂起是操作系统节省空间的一种策略,它会帮我们自动完成,因此通常我们不关心。
2.2 Linux下的进程状态
🔎了解了抽象的三个进程状态概念,接下来我们要具体化地了解Linux下的进程状态
📝下面是关于进程状态的Linux内核代码,由一个指针数组(指向字符串)存储,每个字符串表示一个进程状态,而后面的数字是在进程控制块中的状态标识。
/*
* The task state array is a strange "bitmap" of
* reasons to sleep. Thus "running" is zero, and
* you can test for combinations of others with
* simple bit tests.
*/
static const char * const task_state_array[] = {
"R (running)", /* 0 */
"S (sleeping)", /* 1 */
"D (disk sleep)", /* 2 */
"T (stopped)", /* 4 */
"t (tracing stop)", /* 8 */
"X (dead)", /* 16 */
"Z (zombie)", /* 32 */
};
2.2.1 测试Linux的各种进程状态
- R (running):运行状态,并不一定意味着正在运行,它要么表示进程正在运行要么表示进程在运行队列中等候。
写一个简单的死循环C程序(程序1)
#include <stdio.h>
int main()
{
int a = 0;
while(1)
{
++a;
--a;
}
return 0;
}
运行起来,查看进程,可以看到该进程当前状态为R+,R我们理解了,是运行状态,但为什么会有个+
号呢?
💡这里涉及到前台进程与后台进程的概念,简单了解一下:
前台进程(带+)
:和用户交互,需要较高的响应速度。前台进程运行时,命令行解析无效。能用ctrl+c结束前台进程。
后台进程(不带+)
:基本上不和用户交互,后台进程运行时,命令行解析依然有效,但不能用ctrl+c结束前台进程。
- S(sleeping):睡眠状态,意味着进程在等待事件完成,对应阻塞状态。
写一个访问显示器(printf)的C程序(程序2)
#include <stdio.h>
int main()
{
while(1)
{
printf("hello world\n");
}
return 0;
}
进程状态为 S 睡眠状态
但我们看到的现象却是进程一直在运行,不断往显示器打印文本,按理来说进程状态应该是R,这里为什么是S呢?
💡原因很简单,进程执行printf时需要获取显示器资源,会从运行状态(R)变为阻塞状态(S),进程会到显示器资源等待队列中。因为显示器(外设)的读取速度远远慢于CPU的处理速度,所以进程绝大部分时间都是在等待显示器资源,也就是S状态。因此我们会看到进程在我们查看的时刻处于S状态,也有可能是R状态,不过是小概率事件。
-
D(disk sleep):磁盘休眠状态,有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束,IO结束前不会被操作系统自动回收。
-
T(stopped):停止状态,可以通过发送SIGSTOP信号(kill -19)使进程停止运行,也可以发送SIGCONT(kill -18)信号使进程继续运行。
📝关于kill指令
用法:kill -选项 进程PID
⭕让程序2运行起来后,进行如下操作,可以观察到状态T,且SIGSTOP信号会让前台进程转为后台
- t(tracing stop):也是一种停止状态,只不过t状态的进程会被追踪,比如gdb调试时在断点处停下时,进程也停下了,此时的进程就处于t状态。
gdb调试test(程序2),然后打一个断点,运行程序,会在该断点处停下。
⭕查看进程状态,观察到当前进程处于t状态。
- X(dead):死亡状态,这个状态只是一个返回状态,你不会在任务列表里看到这个状态,因此当一个进程变成死亡状态时,操作系统会立即或延时将其回收。
Z(zombie)是僵尸状态,我们需要重点关注一下。
2.2.2 僵尸进程
- 什么是僵尸进程?
💀僵尸进程指的是处于僵尸状态的进程,子进程为了保留进程退出状态,在退出之后不会立刻被回收,而是处于僵尸(Z)状态,等待父进程(或OS)读取它的退出状态代码。所以,只要子进程退出,父进程还在运行,但父进程尚未读取子进程状态,子进程就会进入Z状态。
子进程退出的方式有多种:程序崩溃退出、调用exit退出、kill指令退出等等
通俗理解就是子进程退出之后要告诉父进程任务完成得怎么样,所以还需留存一段时间,等待父进程获知它的完成情况,这段时间里子进程就处于僵尸状态。
💬写出如下代码以测试僵尸进程
int main()
{
int id = fork();
if(id > 0) // 父进程
{
while(1)
{
printf("I am parent,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
}
}
else if(id == 0) // 子进程
{
int a = 10;
while(1)
{
if(a == 0)
{
int* p;
*p = 10;// 当子进程运行10s后,会因程序崩溃而退出
}
--a;
printf("I am child,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
}
}
return 0;
}
⭕观察到僵尸状态
僵尸进程可不是好事,僵尸进程的存在具有危害
- 僵尸进程的危害
💀父进程如果一直不回收已退出的子进程,读取其退出状态代码,那么子进程的退出状态就要一直被维护下去,一直是Z状态。维护退出状态本身就是要用数据维护,也属于进程基本信息,保存在子进程的PCB中,PCB一直存在,肯定会消耗内存空间。所以,如果父进程创建了多个子进程,又不回收,就可能会导致内存泄漏。
那么如何避免这个问题?在进程控制模块再详谈。
2.3 孤儿进程
💭僵尸进程是子进程先退出父进程后退出的情况。而孤儿进程则是父进程先退出子进程后退出的情况,这种情况下的子进程称为孤儿进程。 因为子进程要被父进程回收,所有孤儿进程并不是真正的“孤儿”,在其父进程退出后,它会被1 号进程(pid为1,又称init进程,Liinux操作系统启动后自动创建) 领养,并最终由1号进程回收。
孤儿不是一种进程状态,孤儿进程是一种进程
⭕以如下C程序测试孤儿进程
void test()
{
int id = fork();
if(id>0)
{
int cnt = 5;
while(1) // 父进程
{
if(cnt == 0)
{
printf("父进程已退出\n"); // 父进程运行5s后退出
exit(1);
}
printf("I am parent,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
--cnt;
}
}
else if(id == 0)
{
int cnt = 10;
while(1) // 子进程
{
if(cnt == 0)
{
printf("子进程已退出\n"); // 子进程运行10s后退出
exit(1);
}
printf("I am child,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
--cnt;
}
}
}
🕚分三个时间节点观察进程状态。
-
父进程退出之前
-
父进程退出后,子进程退出后
可以看到此时的子进程PPID变为1,即父进程变为1号进程。并且从前台转到后台。 -
子进程退出后
查无相关进程,子进程被1号进程回收。
3 进程切换与进程优先级
上文讨论的是进程是什么,下面要展开谈谈进程加载到内存后,是如何运行的?
💭进程需要加载到CPU中才能运行,由CPU负责运算工作。而从前面进程状态中运行状态的阐述我们知道,CPU只有一个而进程有多个,因此将要运行的进程需要在CPU的运行队列中等待。可是这样一来,不是每个时刻只能运行一个进程吗?这就与我们的认知相悖了,我们平时使用计算机时,往往会开着多个应用,同时运行,这是为什么呢?
3.1 并行、并发
这里需要了解CPU的两个概念——并行、并发
参考文章:不懂并行和并发?一文彻底搞懂并行和并发的区别
- 并行
多个进程在多个CPU下分别同时运行,称为并行。
- 并发
多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称为并发。
💡真正的“多个进程同时运行”实际上只有多核CPU通过并行的方式才能做到,而单核CPU都是采用并发的方式运行进程的。对于单核CPU来说,每一时刻只能运行一个进程,但是由于进程切换的速度很快,所以用户看起来是“多个进程同时运行”,这是一种OS欺骗用户的现象。
那么进程切换到底如何进行?接下来我们来探讨
3.2 进程切换
- 什么是进程切换?
🔎进程切换是并发式单核CPU采取时间片轮转的策略,给每个进程分配时间片,快速切换时间片以营造进程同时运行的假象。在每一个时间片内,进程不一定会全部运行完,时间片结束后进程上下文信息会被保存,然后重新参与轮转,CPU运行下一个进程。
🌰举个栗子,若当前CPU运行队列中有五个进程,给每个进程分配时间片10ms,那么,五个进程都分别进行一次需要50ms,若CPU工作1s,则每个进程都会被CPU执行20次。
每个进程的执行时长不同,OS为其分配的时间片数量也不同,CPU运行进程是以时间片为单位而不是以进程为单位。
- 进程切换如何进行?
💭CPU内有一套寄存器,用以存储当前进程的临时数据。
进程切换时,为了保存当前进程上下文信息数据,保证下次轮转到该进程时正常进行,当前CPU寄存器上的数据会被存入该进程的PCB中。
进程恢复运行时,要进行上下文信息的恢复,即从PCB中读取上下文数据到CPU的寄存器中。
3.3 进程的优先级
💭进程需要排队等待CPU运行它,要排队必然就有先后顺序,有先后顺序就会有优先级,就像平时我们到车站、医院等场景都会有军人优先的窗口,这表明在排队过程中军人的优先级高于普通人。进程也有优先级,OS会根据进程的优先级调度进程。
- 进程优先级就是进程占用CPU资源的先后顺序,优先级高的进程有优先执行的权利。
- 为什么会存在优先级?因为资源有限!进程之间具有竞争性,为了高效完成任务,更合理竞争相关资源,OS要根据优先级为进程分配资源,
- 优先级的本质就是进程PCB中一个数字,用这个数字表示该进程的优先级
Linux中,可以用ps -l指令查看进程的优先级
🔎Linux中的进程优先级比较特殊,又PRI和NI两个数值组成。
优先级 = 老优先级+nice值(NI)(老优先级值得是未作修改前进程的PRI值)
-
可以通过top工具修改nice值,从而修改进程的优先级。步骤:进入top后按“r”–>输入进程PID–>输入nice值
-
nice值的取值范围是-20至19,一共40个级别
-
PRI越小,进程优先级越高。
4 环境变量
概念
🌍环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数。
4.1 常见的环境变量
- PATH:指定命令的搜索路径
💭在Linux下,我们平时运行自己写的可执行程序,需要在程序名前面加上
./
如(./test
),这是为了通过相对路径找到对应的程序,能找到才能运行。而平时我们用的指令(如:ls、cd、which等等)说到底也都是程序,执行它们的时候也是运行对应的程序,但用这些指令时却不用加上./
这样的路径去寻找对应的程序。这是为什么呢?
原因就是操作系统中具有PATH环境变量,它储存了一系列的路径,对于直接调用的程序,系统会到PATH中的路径下查找对应程序。而平时用的指令的路径就在PATH中。
⭕测试
使用which指令查看指令所在路径,发现是 /usr/bin
[ckf@VM-8-3-centos lesson6]$ which pwd cd
/usr/bin/pwd
/usr/bin/cd
使用echo指令可以查看PATH环境变量,发现/usr/bin
在其中。(注意:各个路径间以冒号分隔)
[ckf@VM-8-3-centos lesson6]$ echo $PATH
/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/ckf/.local/bin:/home/ckf/bin
可以使用export指令对PATH做修改,加上我们自己写的程序的路径,这样就可以直接运行该程序
[ckf@VM-8-3-centos lesson6]$ ls // 我们在lesson6路径下有如下程序
mycmd process
[ckf@VM-8-3-centos lesson6]$ pwd // 查看lesson6的绝对路径
/home/ckf/lesson6
[ckf@VM-8-3-centos lesson6]$ export PATH=$PATH:/home/ckf/lesson6 // PATH添加lesson6的绝对路径
[ckf@VM-8-3-centos lesson6]$ echo $PATH // 新的PATH值
/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/ckf/.local/bin:/home/ckf/bin:/home/ckf/lesson6
[ckf@VM-8-3-centos lesson6]$ mycmd // 程序现在可以直接运行
hello world
hello world
hello world
^C
[ckf@VM-8-3-centos lesson6]$ process
I am parent,pid:24709,ppid:21254
I am child,pid:24710,ppid:24709
I am parent,pid:24709,ppid:21254
I am child,pid:24710,ppid:24709
^C
- HOME:指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
[ckf@VM-8-3-centos lesson6]$ echo $HOME // 普通用户
/home/ckf
[root@VM-8-3-centos lesson6]# echo $HOME // root
/root
4.2 环境变量相关的一些指令
- echo $name :显式名为name的环境变量值
- export:设置新的环境变量值
- env:查看系统中所有环境变量
- unset:清除环境变量
- set:显示本地定义的shell变量和环境变量
4.3 环境变量的组织方式
💭系统中的环境变量以一张环境表组织起来。环境表是一个字符串指针数组,数组中每个元素都是一个指向环境变量字符串(以’\0’结尾)的指针。
每个进程都会收到一张环境变量表。那么这个变量表中的变量怎么获取的?变量表又是从哪来的?下面主要探讨这两个问题。
4.4 环境变量的获取方式
💭了解环境如何获取环境变量,先要认识main函数的三个参数,这是我们平时不太注意的。
🔎main函数其实有三个隐含的参数
int main(int argc,char* argv[],char* env[])
- argv是命令行参数字符串指针数组,即平时使用指令时的选项,每个指针指向一个命令行参数字符串
- argc是argv数组的元素个数
- env便是环境变量表。
写出以下程序
// test2.c
#include <stdio.h>
int main(int argc,char* argv[],char* env[])
{
printf("%d\n",argc);
for(int i = 0;i<argc;++i)
{
printf("argv[%d]->%s\n",i,argv[i]);
}
return 0;
}
⭕加上一些选项运行程序,观察现象
4.4.1 通过代码获取
1️⃣ main函数的第三个参数:env
- 证明env数组是进程接收到的环境遍历表。
⭕修改test2.c以便于观察env数组,假设env数组就是环境变量表,那么最后一个元素是NULL指针,我们可以以此为结束标志进行遍历数组。
// test2.c
#include <stdio.h>
int main(int argc,char* argv[],char* env[])
{
for(int i = 0;env[i];++i)
{
printf("env[%d]->%s\n",i,env[i]);
}
return 0;
}
运行程序,与env指令查看系统中所有环境变量对比,发现env数组中指向的内容就是系统中的环境变量,证明env数组就是环境变量表。
因此,我们可以在程序中通过env数组获取环境变量,这是一种方法。
2️⃣ 第三方变量environ
系统中有一个第三方变量environ,指向环境变量表的首元素,因此我们也可以直接用environ来获取环境变量。
#include <stdio.h>
int main(int argc,char* argv[],char* env[])
{
extern char** environ;
int i = 0;
while(environ[i])
{
printf("%s\n",environ[i]);
++i;
}
return 0;
}
⭕程序运行结果与第一种方法相同(截取部分)
4.4.2 通过系统调用函数获取
🔎通过getenv函数也可以在程序中获取环境变量
// man手册中关于getenv函数的介绍
NAME
getenv, secure_getenv - get an environment variable
SYNOPSIS
#include <stdlib.h> // 头文件stdlib.h
char *getenv(const char *name);// 参数:表示某环境变量名的字符串
//...
RETURN VALUE // 返回值:成功匹配参数,返回指向参数环境变量值的指针。失败则返回NULL指针
The getenv() function returns a pointer to the value in the environment, or NULL if there is no match.
💬根据 getenv
的特性,写出以下C程序,功能:若当前用户为root,则成功访问,若为普通用户则禁止访问。
// test.c
int main()
{
char* name = getenv("HOME");
if(strcmp(name,"/root") == 0)
{
printf("success!\n");
}
else
{
printf("not permitted!\n");
}
return 0;
}
⭕测试
[ckf@VM-8-3-centos lesson6]$ ./test // 普通用户下测试
not permitted!
[ckf@VM-8-3-centos lesson6]$ sudo ./test // sudo提权测试(相当于在root下测试)
success!
4.5 环境变量的全局属性
🔎 前面提到,每个进程都会收到一张环境变量表。这是为什么呢?
答:因为环境变量通常具有全局属性,会被子进程继承下去!
也就是说,子进程会接收父进程的环境变量表,这样一层层下去,使环境变量具有全局属性。
💡 事实上,我们平时在shell窗口下输入指令以及各种操作,都是基于有一个进程在此运行——bash。bash是一个命令行解释器,而我们在bash上运行的进程都属于bash的子进程。bash从系统登入时就开始运行了,系统会载入一个环境变量表到bash中,而bash的环境变量又会被子进程继承,这样一来,环境变量便具有全局属性。
⭕测试
导出一个MY_ENY环境变量
[ckf@VM-8-3-centos lesson6]$ export MY_ENV=200
写出如下程序,观察是否继承了环境变量MY_ENV
// myenv.c
#include <stdio.h>
#include <stdlib.h>
int main()
{
char* envname = getenv("MY_ENV");
printf("%s\n",envname);
return 0;
}
验证成功。
[ckf@VM-8-3-centos lesson6]$ ./myenv
200
与之相反的是,若我们直接在bash上定义变量,这个变量是不能被子进程继承的,只在bash有效。我们称这种变量为本地变量,无全局属性。
5 进程地址空间
5.1 引入
💭之前我们讨论的程序的空间、地址等概念,是以下图的布局为标准的。我们默认程序独占了内存空间。
💭但我们了解了进程概念之后,就必须进一步地了解进程地址空间的概念,很多地方才能解释得通,先来看一段代码。
#include <stdio.h>
#include <unistd.h>
int g_val = 10;
int main()
{
int id = fork();
if(id > 0) // 父进程
{
while(1)
{
printf("I am parent %d %p\n",g_val,&g_val);
sleep(1);
}
}
else if(id == 0) // 子进程
{
int time = 3;
while(1)
{
printf("I am child %d %p\n",g_val,&g_val);
sleep(1);
if(time == 0)
{
g_val = 20;
printf("子进程修改了g_val!!\n");
}
--time;
}
}
return 0;
}
g_val变量是父子进程共享的数据,试着在运行过程中,子进程修改g_val,看看会发生什么。
⭕运行程序
🔎可以看到,子进程修改g_val前,很好理解,父子进程中的g_val值相同,地址也相同。子进程修改g_val后,子进程中的g_val值变化,但父进程的g_val不变,而g_val的地址依然不变,在父子进程中都相等。一个相同的物理地址,怎么可能存储两个不同的变量值呢?
综上所述我们可以得出结论:
- 变量内容不一样,所以父子进程输出的变量绝不是同一个变量。
- 同一个地址存放不同的变量内容,该地址绝不是物理地址
⭕ 事实上,在Linux下,这种地址称之为虚拟地址。我们用C/C++语言写代码时,所用到的内存地址就是这个虚拟地址!物理地址一般是用户看不到的,由OS统一管理,OS负责将虚拟地址转化为物理地址。
5.2 进程地址空间的概念分析
🔎每个程序都有两套地址:
- 虚拟地址(又称逻辑地址):程序内部使用的地址
- 物理地址:程序加载入内存中,代码数据的地址。
⭕*虚拟地址从编译器生成可执行文件时就已经在使用了,此时我们一般称其为逻辑地址。逻辑地址是指程序内部用于函数跳转、数据寻址等操作的地址,是用户所能看见的地址。
可以通过反汇编来观察逻辑地址的作用。
写出以下C程序。
#include <stdio.h>
int g_val = 100;
void fun()
{
printf("hello world\n");
g_val = 200;
}
int main()
{
fun();
return 0;
}
运行,并转到反汇编观察。
💡可以看到,程序内部做跳转、寻址时,都会用到地址,这个地址就是程序的逻辑地址(虚拟地址)。当程序从磁盘中加载到内存时,这个地址依然存在,进程开始运行时用的也是这个地址,在内存中,我们称之为进程的虚拟地址!
当然,以上这些数据都是存储在数据区、代码区上的,所以在编译时就已经完成了逻辑地址的布局,而堆区、栈区上的数据则是运行时才载入的。
每个进程都会有属于自己的虚拟地址,此处抛出两个问题
- 进程的虚拟地址如何管理?
答:根据操作系统先描述再组织的管理思路,进程的虚拟地址会以一个数据结构
mm_struct
来管理。该数据结构中以区间的形式存放进程的虚拟地址。进程一旦被加载到内存中,操作系统会给进程创建一个mm_struct,并与进程的PCB建立链接关系。
- 虚拟地址与物理地址如何转换?
答:页表。
页表是一个建立虚拟地址与物理地址关系的表,OS用其完成将虚拟地址转化为物理地址的工作。每个进程都会有一个页表,页表也是在进程载入时由OS构建的。如下图。
💨页表不仅能完成虚拟地址和物理地址的映射,还能起到拦截的作用,若程序访问到非法的地址(如野指针、数组越界等),在页表处就会被直接拦截,不会访问到物理空间。
💭这样一来,当CPU运行进程时,会通过进程PCB找到进程的虚拟内存块,获取进程代码数据的虚拟地址,操作系统负责转换为物理地址,使得CPU获取到进程代码数据。总的来说,CPU是不会见到物理地址的,只是在虚拟地址上运行,代码中有需要寻址的操作也是到虚拟内存中找。
📝总流程图:
💡至此,我们已经可以回答 5.1引入 中的问题,不过这还涉及到另外一个概念 —— 写时拷贝!
子进程未修改g_val时,父子进程虚拟内存与物理内存关系如下,二者的g_val虚拟地址与物理地址都相同。
⭕由于进程的独立性,各个进程在运行期间互不干扰,而父子进程又共享数据(这里的g_val就是父子进程的共享数据)。因此,父子进程任一方对共享数据做修改时,就会发生写时拷贝,OS在物理空间上开辟一块新的空间,并将欲修改数据拷贝过去,修改数据方对应的虚拟地址不变,物理地址指向新的物理内存空间,然后再做修改。
子进程修改g_val的值为20的过程如下:
完。