前言
一、操作系统
1、概念
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。笼统的理解,操作系统包括:
内核(进程管理,内存管理,文件管理,驱动管理)
其他程序(例如函数库,shell程序等等)
2、设计OS的目的
与硬件交互,管理所有的软硬件资源
为用户程序(应用程序)提供一个良好的执行环境
3、总结
计算机管理硬件
1. 描述起来,用struct结构体
2. 组织起来,用链表或其他高效的数据结构
系统调用和库函数概念
在开发角度,操作系统对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发使用,这部分由操作系统提供的接口,叫做系统调用。
系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以,有心的开发者可以对部分系统调用进行适度封装,从而形成库,有了库,就很有利于更上层用户或者开发者进行二次开发。
二、进程
1、基本概念
在多道程序环境下,允许多个进程并发执行,此时它们将失去封闭性,并具有间断性及不可再现性的特征。为此引入了进程的概念,以便更好地描述和控制程序地并发执行,实现操作系统的并发性和共享性。
在课本的概念中:程序的一个执行实例,正在执行的程序等。
在内核观点中:担当分配系统资源(CPU时间,内存)的实体。
为了使参与并发执行的每个程序(含数据)都能独立地运行,必须为之配置一个专门地数据结构,称为进程控制块(Process Control Block,PCB)。系统利用PCB来描述进程的基本情况和运行状态,进而控制和管理进程。相应地,由程序段、相关数据段和PCB三部分构成了进程实体(又称进程映像)。所谓创建进程,实质上是创建进程实体中地PCB;而撤销进程,实质上是撤销进程的PCB。值得注意的是,进程映像是静态的,进程则是动态的。
课本上称之为PCB(process control block),Linux操作系统下的PCB是: task_struct,即在Linux中描述进程的结构体叫做task_struct。
task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息。
task_ struct内容分类:
标示符: 描述本进程的唯一标示符,用来区别其他进程。
状态: 任务状态,退出代码,退出信号等。
优先级: 相对于其他进程的优先级。
程序计数器: 程序中即将被执行的下一条指令的地址。
内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
其他信息
2、查看进程
2.1 使用ps axj命令
ps命令详解
ps命令最常用的两个选项ps aux、ps axjf 字母选项的含义:
a–显示1个终端所有进程。
u–显示进程的归属用户及内存使用情况。
x–显示没有关联控制终端的进程。
j–显示进程归属的进程组ID、会话ID、父进程ID。
f–以ASCII形式显示出进程的层次关系。
下面我们使用ps aux命令查看系统的当前进程。
打印出的信息表头的各项内容:
USER:进程是哪个用户产生的
PID:进程的身份证号码
%CPU:表示进程占用了cpu计算能力的百分比
%MEM:表示进程占用了系统内存的百分比
VSZ:进程使用的虚拟内存大小
RSS:进程使用的物理内存大小
TTY:进程关联的终端
STAT:表示进程当前状态,如下面表格所列进程的各种状态。
START:表示进程的启动时间
TIME:记录进程的运行时间
COMMAND:表示进程执行的具体程序
我们还可以使用pstree命令查看进程间的关系。
下面我们先写一个test.c文件,然后用gcc编译后生成可执行文件test,当我们运行这个程序时会循环打印hello world,此时就相当于创建了一个进程。
在Linux中可以通过ps axj来查看当前系统中的进程。此命令会将系统中所有的进程信息都显示出来。
ps axj
而当我们想要查看刚刚执行test程序创建的进程时,可以使用grep 命令来将包含字符串"test"的进程信息打印出来。
可以看到此时就打印出来了刚刚运行test程序而创建的进程。
我们还可以将每列信息的头部打印出来。
2.2 通过 /proc 系统文件夹查看
进程的信息可以通过 /proc 系统文件夹查看。在Linux中有一个/proc目录,/proc 目录是一个位于内存中的伪文件系统。该目录下保存的并不是真正的文件和目录,而是一些【运行时】的信息,如 CPU 信息、负载信息、系统内存信息、磁盘 IO 信息等。/proc 目录下有很多以数字命名的目录,这些目录与进程的 pid 相对应。通过这些目录,可以查看进程相关的信息。
此时我们再次将test程序执行,然后就会创建一个新进程。我们通过ps axj命令查看test程序创建的进程的PID,然后可以看到在/proc目录下就有一个以该PID为名字的目录。
我们可以看到在以进程PID命名的目录下就是该进程的一些信息。
3、通过系统调用获取进程标示符
3.1 getpid() 系统调用
getpid()系统调用可以返回当前进程的PID。在使用该系统调用时需要包含#include<sys/types.h>和#include<unistd.h>头文件。
下面我们在test.c中使用getpid()获得该进程的PID,然后显示出来。
当执行了test程序后,我们使用ps axj命令查看该程序在系统中的进程的PID,可以看到系统调用getpid()返回的PID和我们查看到的PID一致。
此时如果我们想要杀掉该进程,可以使用 kill -9 进程PID 这个命令来将PID为这个的进程杀掉。
kill -9 15811
3.2 getppid() 系统调用
getppid()系统调用可以返回该进程的父进程id。
下面我们在test.c中使用getpid()获得该进程的PID,并且使用getppid()获得该进程的父进程的PID,然后显示出来。
当执行了test程序后,我们使用ps axj命令查看该程序在系统中的进程的PID和该进程的父进程的PID,可以看到系统调用getpid()返回的PID和我们查看到的PID一致,还有getppid()返回的该进程的父进程的PID也和我们查看的PPID一致。
那么test程序的父进程是什么进程呢?我们可以使用ps axj命令查看PID为15101的进程的相关信息。可以看到PID为15101的进程是bash进程。而bash 是 Linux 标准默认的 shell,即bash为命令行解释器程序,正因为有了这个进程我们才可以在命令行中输入一些命令来与Linux系统进行交互。
并且每一次登录都有一个专属的bash进程被创建,我们可以看到在不同的终端中执行test程序创建的进程和该进程的父进程都是不同的。
4、通过系统调用fork创建子进程
4.1 使用fork创建子进程
系统调用fork可以创建一个子进程。fork()的返回值有两种情况:
(1). 当创建子进程失败时会返回-1。
(2). 当创建子进程成功过时会返回给父进程该子进程的PID,然后给子进程返回0。
我们写出如下代码,在该程序执行时会创建一个子进程,我们可以看到test程序在执行时打印了两次ret的值,其中第一次为test程序打印的,而另一次就是test程序使用系统调用fork()创建的子进程打印的。其中可以看到系统调用fork()创建子进程成功时会返回给父进程该子进程的PID,然后给子进程返回0。
我们再将上面的程序多打印一些数据,将上面的代码改成如下的代码。
我们可以看到PID为17815的进程为PID为27816进程的父进程。即PID为27816的进程就是test程序中使用系统调用fork()创建出来的子进程。
4.2 fork之后有两个不同的执行流
通过上面的分析我们可以知道在fork之后创建了一个子进程,所以代码就有了两个不同的执行流,我们就可以根据fork对父进程和子进程的返回值不同来进行判断,然后让父进程和子进程执行不同的代码。
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
int main()
{
pid_t id = fork();
if(id<0)
{
//子进程创建失败
perror("fork");
return 1;
}
//id在子进程中是0
else if(id == 0)
{
//child process
while(1)
{
printf("I am child,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
}
}
//id在父进程中是子进程的PID
else
{
//parent process
while(1)
{
printf("I am father,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
}
}
printf("you can see me\n");
sleep(1);
return 0;
}
然后执行下面的指令来一直循环打印进程信息。此时可以看到test程序中将if和else if中的语句都打印出来了。
while :; do ps axj | head -1 && ps axj | grep test | grep -v grep; sleep 1; done
那么我们就会有疑问了,c语言中if和else if不是值执行一个吗,为什么上面的程序中if和else if里面的语句都被执行了。
这是因为在fork创建之后就创建了子进程,并且在fork之后,代码是父进程和子进程共享的。当fork之后,fork给父进程的返回值为子进程PID,所以在父进程中的id变量的值为子进程的PID;而fork给子进程的返回值为0,所以在子进程中id变量的值为0。当父进程执行到fork后的代码时,因为id>0,所以就会执行else后的语句,而当子进程执行到fork后的代码时,因为id == 0,所以就会执行else if(i==0)后的代码。即在fork之后就有了两个不同的执行流。
经过上述的分析我们知道了为什么一份代码中为什么会同时执行if和else if后的语句,那么我们又有了一个疑问,为什么fork会有两个返回值呢?它是怎么实现的给父进程返回子进程pid,而给子进程返回0。
这是因为在fork中执行完创建子进程的代码后,此时就已经有了父进程和子进程两个进程,所以return id;语句父进程和子进程都会执行,并且父进程和子进程返回的值不一样,这就是为什么fork会有两个返回值。并且使用fork函数创建子进程后就有了两个进程,这两个进程被操作系统调度的顺序是不确定的,这取决于操作系统调度算法的具体实现。
5、进程状态
我们知道每当有一个新的进程被创建时操作系统就会产生一个新的task_struct,操作系统和cpu运行某一个进程,本质就是从task_struct形成的队列中挑选一个tack_struct来执行它的代码。那么CPU依靠什么来选择哪一个进程要进入CPU内开始执行呢,并且每个进程不是一进入CPU就可以运行的。这就需要每个进程都需要有自己的状态,然后CPU在进程调度时只调度那些状态已经可以运行的进程来进入CPU中执行。
通常进程有以下5种状态,前3种是进程的基本状态。
(1). 运行态。进程正在处理机上运行。在单处理机中,每个时刻只有一个进程处于运行态。
(2). 就绪态。进程获得了除处理机外的一切所需资源,一旦得到处理机,便可立即运行。系统中处于就绪状态的进程可能有多个,通常将它们排成一个队列,称为就绪队列。
(3). 阻塞态,又称等待态。进程正在等待某一事件而暂停运行,如等待某资源为可用(不包括处理机)或等待输入/输出完成。即使处理机空闲,该进程也不能运行。系统通常将处于阻塞态的进程也排成一个队列,甚至根据阻塞原因的不同,设置多个阻塞队列。
(4). 创建态。进程正在被创建,尚未转到就绪态。创建进程需要多个步骤:首先申请一个空白 PCB,并向 PCB 中填写用于控制和管理进程的信息;然后为该进程分配运行时所必须的资源;最后把该进程转入就绪态并插人就绪队列。但是,如果进程所需的资源尚不能得到满足,如内存不足,则创建工作尚未完成,进程此时所处的状态称为创建态。
(5). 终止态。进程正从系统中消失,可能是进程正常结束或其他原因退出运行。进程需要结束运行时,系统首先将该进程置为终止态,然后进一步处理资源释放和回收等工作。
挂起:
我们知道进程的代码和数据都是在内存中存的,当CPU通过进程调度算法选择一个进程的task_struct后,然后CPU会根据该进程的task_struct里面的信息去找到该进程的代码和数据然后执行。但是内存的空间是有限的,当内存中保存了很多进程的代码和数据时,此时进程的内存就会不足,此时操作系统就会适当的选择一些进程的代码和数据到磁盘中,此时进程的状态叫做挂起。
为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有几个状态(在Linux内核里,进程有时候也叫做任务)。下面的状态在kernel源代码里定义:
/*
* 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 */
};
R运行状态(running): 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。
S睡眠状态(sleeping): 意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠(interruptible sleep))。
D磁盘休眠状态(Disk sleep)有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。
T停止状态(stopped): 可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
X死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
R运行状态:
当我们写一个循环运行时,可以看到此时该进程的状态为R+态。即表示test01程序在运行队列中,R+表示该进程为前台进程,当执行前台进程时,bash命令行就不能执行了。
我们可以在执行程序时在后面加上&,就可以让进程变为后台进程。此时可以看到进程的状态变为了R。
S睡眠状态:
当我们执行下面的代码时,由于下面的代码有printf打印,则需要用到输出设备,所以意味着该程序在执行时需要等待输出设备,只有输出设备的资源可以使用了,该进程才可以被调到CPU上执行。由于CPU执行该代码的时间是很短的,所以我们看到的该进程基本上都是处于等待输出设备的S+状态。
当我们将test02执行时加上&,则表示将该进程变为后台进程,此时S+就变为了S。
D磁盘休眠状态:
我们可以使用kill命令来将刚刚执行处于S状态的进程杀掉。但是如果一个进程的状态为D时,操作系统就无法将这个进程杀掉,只能等进程自动唤醒自己。
当服务器压力过大的时候,操作系统会通过一定的手段,杀掉一些进程,以此来节省空间,但是这就会发生一些错误。例如进程A在运行时需要等待输入设备磁盘输入一些数据,此时磁盘就会去找A进程的数据,然后进程A等待磁盘时状态变为S状态。然后此时内存的空间被占用的太多了,操作系统需要选择一些进程杀掉,以节省空间,此时发现A进程并没有运行,且处于S阻塞态,就将A进程杀掉了。但是当磁盘找到A进程需要的数据时,此时发现A进程已经没有了,那么磁盘刚刚读取的数据就无效了。但是如果将A进程设为D磁盘休眠状态的话,当操作系统选择一些进程杀掉时,发生A进程是D状态,就不会杀掉A进程了。当A进程等待磁盘读取数据完成后,A进程会自己将自己唤醒。
T停止状态:
我们可以使用如下命令来查看kill命令常用的信号。
kill -l
下面为kill命令常用的信号。
1)| SIGHUP | 重新加载配置
2)| SIGINT | 键盘中断 crtl+c
3) | SIGQUIT |退出
9) | SIGKILL | 强制终止
15) | SIGTERM | 终止(正常结束),缺省信号
18) | SIGCONT | 继续
19) | SIGSTOP | 停止
20) | SIGTSTP | 暂停 crtl+z
T状态就是进程暂停的状态。可以使用kill -20将进程暂停,可以看到当执行了kill -20后该进程的状态从S+变为T状态,即此时进程就处于暂停状态。
此时可以使用kill -18来将进程继续执行。可以看到当执行了kill -18后,进程的状态从T状态变为了S状态,即此时进程又恢复了S等待输出设备的状态。
那么这个T状态有什么用呢?其实当我们在使用gdb调试程序时,当我们设置了一个断点后,然后程序运行到断点处时,此时这个进程就处于暂停态。
X终止态:
X为终止态,表示资源可以被回收,但是X瞬时性非常强,所以我们看不到进程处于X状态。
6、僵尸进程
当一个进程已经退出,但是还不允许被操作系统释放时,就处于一个被检测的Z状态——僵尸状态。
当一个进程处于此状态时,代表可以被回收,一般都是会被父进程或者操作系统回收,然后该进程就由Z状态变为终止态X状态。
我们写一个如下代码,当执行后会创建一个子进程,然后3秒后子进程运行结束。此时子进程就会处于Z状态。
僵尸进程危害:
进程的退出状态必须被维持下去,因为他要告诉关心它的进程(父进程),你交给我的任务,我办的怎么样了。可父进程如果一直不读取,那子进程就一直处于Z状态?是的!
维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,换句话说,Z状态一直不退出,PCB一直都要维护?是的!
那一个父进程创建了很多子进程,就是不回收,是不是就会造成内存资源的浪费?是的!因为数据结构对象本身就要占用内存,想想C中定义一个结构体变量(对象),是要在内存的某个位置进行开辟空间!
7、孤儿进程
父进程先退出,子进程就称之为“孤儿进程”。然后孤儿进程被1号init进程(系统本身)领养,并且此时孤儿进程也要由init进程回收。
为什么孤儿进程需要被领养呢?
这是因为当以后子进程退出后,它的父进程不在了,就需要领养的1进程来将该子进程的资源进行回收。
#include<stdio.h>
#include<unistd.h>
int main()
{
pid_t id = fork();
if(id==0)
{
//child
while(1)
{
printf("hello world\n");
sleep(1);
}
}
else
{
//father
int tmp = 5;
while(tmp)
{
printf("I am father: %d\n",tmp);
tmp--;
sleep(1);
}
}
return 0;
}
当执行上面的代码后,我们可以观察到当5秒后父进程结束了,而此时子进程就会变为孤儿进程,然后被1进程收养,此时孤儿进程的父进程就为1进程。
当右边的进程还在运行时,也可以输入kill -9 来杀掉这个进程,虽然命令会乱,但是系统还是会接收到的。
8、进程优先级
cpu资源分配的先后顺序,就是指进程的优先权(priority)。
优先权高的进程有优先执行权利。配置进程优先权对多任务环境的linux很有用,可以改善系统性能。
还可以把进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大大改善系统整体性能。
在linux或者unix系统中,可以用ps –l命令即可以查看当前环境下与bash相关的的进程信息。
ps -l
UID : 代表执行者的身份
PID : 代表这个进程的代号
PPID :代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
PRI :代表这个进程可被执行的优先级,其值越小越早被执行
NI :代表这个进程的nice值
PRI就表示进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小,进程的优先级别越高,即越先被CPU执行。
NI就是进程的nice值,其表示进程可被执行的优先级的修正数值。因为Linux中不可以直接修改进程的优先级,所以就使用nice值来修正进程的优先级。
PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为:PRI(new)=PRI(old)+nice。这样,当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行。所以在Linux下调整进程优先级,就是调整进程nice值。nice其取值范围是-20至19,一共40个级别。
需要强调一点的是,进程的nice值不是进程的优先级,他们不是一个概念,但是进程nice值会影响到进程的优先级变化。
所以当想要修改一个进程的优先级时,就需要修改它的nice值。这样该进程的优先级就会变了。
我们可以通过top命令来修改进程的nice值,然后改变进程的优先级。
输入top命令后按r,然后按回车后输入进程PID,然后按回车输入nice值。
可以看到没有修改前bash进程的PRI为80,NI为0。
当我们修改bash进程的neice值为10后,可以看到此时bash进程的PRI也增加了10。因为PRI的值变大了,所以此时进程bash的优先级就变小了。
当我们想修改nice值为负值时会发现操作不允许。这是因为普通用户只能将进程的优先级减小,而想要将nice的值为0或负值,需要使用sudo top,然后才可以将nice的值设为负值,即将进程优先级增加。
当我们使用sudo top命令将bash进程的nice值设为-10后发现该进程的PRI变为了70。这是因为PRI(new)=PRI(old)+nice,而PRI(old)每次都是初始的PRI,即每次都是80。所以80+(-10)=70。
使用renice也可以调整进程的nice值。
//将pid为11768的进程的nice值设为10
renice 10 -p 11768
9、其它概念
竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级
独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰
并行: 多个进程在多个CPU下分别,同时进行运行,这称之为并行
并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。
进程的切换:
三、环境变量
常见环境变量:
PATH : 指定命令的搜索路径
HOME : 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
SHELL : 当前Shell,它的值通常是/bin/bash。
1、环境变量
环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数如:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性。
PATH : 指定命令的搜索路径
HOME : 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
SHELL : 当前Shell,它的值通常是/bin/bash。
1.1 PATH环境变量
当我们在命令行中输入ls pwd等命令时,系统可以直接执行这些命令,但是当我们想要执行自己编译生成的test程序时需要./test才能执行。这其实就是因为ls pwd等命令的路径都在PATH环境变量中,而我们自己的程序所在的路径没有在PATH环境变量中。
我们可以使用 echo $PATH查看PATH环境变量的值。
echo $PATH
我们可以看到因为pwd gcc等程序都在/usr/bin目录下,而PATH的值中包含了/usr/bin这个目录,所以当在命令行中输入pwd gcc等命令时,就会去PATH的值中这些目录下去找命名为pwd gcc的可执行程序,如果找到了就会执行。而我们自己写的程序所在的目录并没有在环境变量PATH中,所以当在任意一个目录下执行时,系统会找不到这个可执行程序。
那么如果我们也想要将自己的程序可以在任意目录下输入名称都能运行有两种办法。
(1). 将自己的程序拷贝到PATH的任意一个路径下,那么当在命令行中执行自己的程序时,就会在这些路径下找该程序,而自己的程序在这些路径下,所以就会直接执行了。但是这个办法会污染系统自带的命令池,所以我们不会那么做。
(2). 我们可以将自己的程序所在的路径添加到环境变量PATH中,这样在搜索可执行程序时,也会来到我们自己的程序所在的路径下搜索。
可以使用export命令添加路径到PATH中。
//将路径/home/drh/linux-learning/test13添加到环境变量PATH中
export PATH=$PATH:/home/drh/linux-learning/test13
可以看到此时/home/drh/linux-learning/test13路径就被添加到了PATH中。
此时直接输入mytest命令就可以执行程序了。
还可以通过在PATH后面直接跟上路径来改变环境变量PATH的值。
注意:这样改变是覆盖式的改变。
//直接将环境变量的值变为/usr/bing,而不是在原来的值后面添加路径/usr/bin
PATH=/usr/bin
我们还可以将环境变量PATH的值改为根目录/,此时输入以前的命令就会显示找不到这个命令。在命令行中修改环境变量的值只在这次登录有效,当重新登录时,PATH的值又会变回默认的了。如果在配置文件中更改了环境变量,那么就会一直生效了。
1.2 其它环境变量
我们可以使用env命令显示所有环境变量
env
HOME : 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录。
即HOME就是记录当前登录用户的家目录是什么,当登录的用户变了时,此时该变量的值就会变。环境变量就是随着环境的改变,这个变量的值也会变。
可以看到在不同的用户中,环境变量HOME的值不同。
SHELL : 当前Shell,它的值通常是/bin/bash。
SHELL这个环境变量的值就是当前的shell程序,一般都是Linux系统默认的bash。
1.3 和环境变量相关的命令
- echo: 显示某个环境变量值
- export: 设置一个新的环境变量
- env: 显示所有环境变量
- unset: 清除环境变量
- set: 显示本地定义的shell变量和环境变量
2、通过代码如何获取环境变量
2.1 通过main函数的第三个参数
当我们在文档中查看main函数时,可以看到main函数其实是有3个形参的。
而main函数的第三个参数其实就和环境变量有关,可以看到main函数的第三个参数envp为一个指针数组,该数组中的元素都为char*类型的指针。
在每个程序执行时,都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串。即main函数的第三个参数envp里面存的就是这个环境表。并且这个指针数组的最后一个元素是NULL。
当我们使用如下的程序查看指针数组envp的值时,可以看到显示了很多环境变量和环境变量的值。
2.2 通过第三方变量environ获取
在libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时 要用extern声明。
可以看到environ指向的环境变量表中打印出来的内容和main函数第三个参数中的基本一致。
3、通过函数获取环境变量
我们可以通过函数getenv()来获取指定环境变量的值。
//获取环境变量PATH的值
getenv("PATH")
4、环境变量通常是具有全局属性的
4.1 全局环境变量
环境变量通常具有全局属性,可以被子进程继承下去。
我们可以使用系统调用getenv来得到环境变量PATH的值,那么当我们使用getenv来得到一个不存在的环境变量时,很显然它会报错。
这是因为该进程的环境变量是继承它的父进程,而它的父进程中没有test02环境变量,所以会显示错误。所有命令行进程的最终父进程都是bash这个进程,所以每个命令行进程的环境变量其实就是继承的bash进程的环境变量。如果我们给bash中添加一个test02环境变量时,此时再执行该程序就可以查看到这个环境变量的内容了。所以子进程的环境变量信息都是从父进程继承下来的。
我们可以使用export设置一个新的全局环境变量。
//添加一个全局环境变量test02,并赋值为"新建环境变量"
export test02=新建环境变量
所以子进程的环境变量是从父进程继承来的,并且默认所有的环境变量都会被子进程继承。所以环境变量具有全局属性。
4.2 局部环境变量
当我们在设置环境变量时,如果不在前面加上export,则就会创建一个局部环境变量。此时如果我们在env所有环境变量中查找局部环境变量dong会发现找不到,而使用set在本地定义的shell变量和环境变量中查找局部环境变量dong就可以找到。这是因为env中显示的是全部的全局环境变量,而set中显示的是本地定义的shell变量和环境变量。因为dong是在当前目录下定义的局部环境变量,所以在env中不会显示。
5、main函数的第一个和第二个参数
main函数的第一个和第二个参数为命令行参数,即为运行该程序时输入的一些参数。
例如使用如下的代码来打印指针数组argv的每一个元素的值
可以看到./test03就是指针数组的第一个元素指向的字符串,而argc就是在执行程序test03时输入的命令的字符串的个数。当我们只输入./test03时,此时argc就是1,argv指针数组中就只有一个元素,存的是指向./test03字符串的指针。
命令行参数的意义,是可以根据不同的选项来执行程序的不同子功能。例如ls 等命令使用不同的选项就有不同的功能,其实底层就是使用了命令行参数。
我们可以使用下面的程序来模拟像 ls 这样的命令后面使用不同的选项就实现不同的功能是怎样实现的。
四、程序地址空间
1、程序地址空间
一个程序在运行时的地址空间如下所示。[0,3GB]为用户空间,[3GB,4GB]为内核空间。
我们可以使用下面的代码来验证这个图中的各空间所在的位置是否正确。
我们可以使用下面的程序来验证栈区是先使用高地址的空间,再使用低地址的空间。而堆区是先使用低地址的空间,然后使用高地址空间。
我们使用下面的代码查看static修饰的局部变量的地址,可以看到static修饰的局部变量的空间在全局区域。所以static修饰局部变量的本质就是将该变量开辟在全局区域。
我们再使用下面的代码查看字符串常量所在的区域,可以看到字符串常量和代码都在代码区,并且这个区域的内容只允许读。
2、虚拟内存与物理内存
我们在c语言中使用malloc申请空间,使用free释放空间。可是我们有没有这样的疑问,我们使用malloc申请了10字节的空间,为什么free时就只释放10个字节的空间呢?我们在使用free时也没有告诉free释放空间的大小,那么free是怎么知道具体要释放多少的空间的呢?
这是因为在malloc申请空间时,操作系统时间给的空间比10字节要多,因为还需要记录这次申请空间的一些属性信息(cookie数据),例如空间大小等,这样free才能知道具体要释放多少空间。那么我们就明白了,操作系统对每一次申请空间的管理也是使用先描述再组织的方式,即每一次申请的空间的相关信息都存了下来。那么我们就猜测内核中的地址空间,本质也一定是一种数据结构,并且每一个地址空间的结构要和一个特定的进程关联起来。
我们知道计算机中是有虚拟内存的概念的,那么为什么要引入虚拟内存的概念呢?
这是因为如果直接访问物理内存的话,是非常不安全的。因为内存本身是随时可以被读写的,如果我们直接使用的是物理内存,那么一个进程可以通过物理内存的地址读写另一个进程的数据,这是特别不安全的。
所以才有了虚拟内存的概念,即进程使用的都是虚拟地址空间,然后虚拟地址空间映射到物理内存,这样就避免了进程直接访问物理内存。要访问物理内存,需要先进行映射,如果虚拟地址是一个非法地址,就会禁止映射,这样就不能修改物理内存的内容了。即例如进程1的虚拟内存空间为0x 00 - 0x 100,然后在进程1中访问并且修改了虚拟地址为0x 200的内容,想要修改0x 200映射的物理地址的内容,需要先进行映射,而在映射时发现进程1的虚拟内存空间的地址为0x 00 - 0x 100,但是想要修改虚拟地址为 0x 200的内容,就会判断这是一个非法地址,从而禁止0x 200映射到它的物理地址,这样就防止了进程1修改其它地址的数据。而这些进程的虚拟内存空间的划分就相当于使用一个start标识它的起始地址,使用一个end标识它的结束位置。这样就定义了一个进程的虚拟内存地址,如果想要改变进程的地址空间,只需要改变start和end的值即可。
每个进程都需要一个这样的标识来规定它的虚拟内存地址,并且每一个进程中都有代码段,堆、栈等区域,这些区域也需要一个类似start和end的标识来记录它们的地址空间。所以地址空间其实是一种内核数据结构,它里面至少要有:各个区域的划分。
我们可以看到在linux内核源码中声明了mm_struct结构体。并且还定义了各个区域的start标识和结束标识。
并且每一个进程的task_struct结构体都和一个地址空间结构体mm_struct相关联。地址空间和页表(用户级)是每一个进程都私有一份,只要保证每一个进程的页表映射的是物理内存的不同区域,就能做到进程之间不会互相干扰,这样就保证进程的独立性。
然后我们就可以解释下面的一个问题了。
我们在父进程中创建了一个全局变量g_val和一个局部变量id,但是我们发现id变量在父进程中为子进程的pid,在子进程中为0,一个变量为什么会有两个值呢。并且在下面的代码中我们在子进程中更改了g_val变量的值,然后我们打印g_val的地址发现子进程和父进程中g_val的地址相同,但是父进程和子进程中g_val的值确是不相同的。
这其实就是因为每一个进程都会对应一个tast_struct结构体,并且每一个tast_struct结构体又和一个mm_struct结构体相关联,而子进程的mm_struct是以父进程为模板而建的,所以在父进程和子进程打印出来的g_val的地址都是虚拟地址,所以会出现父进程和子进程中的g_val的地址相同,当子进程要修改其内容时,就会发生写时拷贝,即在物理内存中开辟一片空间,将父进程的内容拷贝到这片空间上,然后将子进程的页表的对应的物理地址指向这片空间。所以真正的g_val的值是存储在各自的进程的虚拟内存映射的物理内存中的,所以父进程和子进程的g_val的值不同。
当我们的程序在编译的时候,形成可执行程序的时候其实内部已经有地址了,这个地址就是编译器编译好的虚拟地址,因为地址空间不仅仅是操作系统内部需要遵守,其实编译器也要遵守。所以编译器在编译代码的时候就已经给各个区域,代码区、堆区、栈区等分配好了虚拟地址,并且采用了和Linux内核中一样的编址方式,这样每一个变量,每一行代码都进行了编址,故程序在编译的时候,每一个字段早已经具有了一个虚拟地址。
3、为什么要有地址空间
3.1 有效的保护物理内存
当我们写出如下的代码时,会发现并不能更改str[1]=‘a’,但是能更改str2[1]=‘a’,这是为什么呢?
这是因为str指针指向了常量字符串"hello world“的空间,而我们在上面测试了常量字符串在代码区,这个区域的内容只允许读,并不允许写。而str2指向的空间是存在栈区的,这片空间中将代码区的"hello world"字符串拷贝一份到该空间,而栈区是允许读写的,所以str2[1]='a’可以修改。
那么编译器是怎样判断这片空间能不能进行修改呢?这其实是在页表做的判断,当编译器将代码编译为可执行文件后,此时代码中的每一个片段都有了自己的虚拟地址,然后在页表中,每一块虚拟空间都会映射到一片物理内容中,在这时页表就会进行判断,如果这片虚拟空间为代码区,则就会在映射的物理内存中标明这片映射的物理内存只允许读,不允许写。这样页表就为每一块物理内存设置了相应的权限,当使用虚拟内存想要修改或者读映射的物理内存中的内容时,页表会先判断有没有相应的权限,如果有了才可以完成相应操作。所以有了虚拟内存和页表的存在,可以一定程度上的保护物理内存中的数据,而因为地址空间和页表是操作系统创建并维护的,所以有想要使用地址空间和页表进行映射时,需要经过操作系统的检查,如果操作系统识别到这个进程有非法访问或者映射,就会终止这个进程,这就是为什么非法访问的进程不能被执行的原因。所以地址空间的存在有效的保护了物理内存中的所有合法数据,包括各个进程以及内核的相关有效数据。
3.2 使内存管理模块与进程管理模块分开
因为有地址空间的存在,因为有页表的映射的存在,在物理内存中就可以对未来的数据进行任意位置的加载,这样物理内存的分配就可以和进程的管理分开处理了,即内存管理模块和进程管理模块就完成了解耦合。所以我们在c语言、c++中使用malloc和new申请空间时,本质是在虚拟内存中申请的空间。
如果有一个进程申请了虚拟空间,然后操作系统分配了对应的物理空间与这片虚拟空间相映射,但是这个进程并没有马上使用这片物理空间,那么这片空间就造成了浪费,所以操作系统采用了延迟分配的策略来提高整机的效率。本质上因为有地址空间的存在,所以进程申请空间时,其实是在地址空间上申请的,操作系统可能并没有给这个进程分配物理内存。而当这个进程真正的要对申请的空间进行访问时,此时操作系统才会为这个进程真正的分配物理内存,然后该进程就可以对物理内存进行访问了。这样操作的话,可以使内存能更有效的使用。即使内存使用效率为100%。
3.3 使内存分布在进程视角都是有序的
我们在上面使用代码验证了程序中代码或者变量在内存中的顺序。但是在物理内存中理论上是可以任意位置进行存储的,所以如果我们的程序直接使用物理内存的话,那么几乎所有的数据和代码在物理内存中是乱序的。但是因为有了页表的存在,它可以将地址空间上的虚拟地址和物理地址进行映射,而进程在地址空间上的虚拟地址是有序的,所以在进程视角所有的内存分布都是有序的,即代码区地址最小,堆区在栈区下面,堆区栈区相对而升等规则就可以依靠虚拟地址来验证了。所以地址空间+页表的存在可以将内存分布有序化。
其实地址空间就是操作系统给进程画的大饼,例如如果有4GB的空间,而因为有了地址空间的存在,所以每一个进程都认为自己拥有4GB空间,并且各个区域是有序的,但是实际可能进程要访问的物理内存中的数据和代码现在并没有在物理内存中,只有真的使用到时,操作系统才会为进程分配真正的物理空间。并且将不同的进程映射到不同的物理内存,这样每一个进程就都是独立存在的了,就实现了进程的独立性。
4、重新理解挂起
4.1 新建状态
一个可执行程序a.exe的代码和数据都是存储在磁盘上的。当执行这个程序时就会将这个程序的代码和数据拷贝到物理内存中,然后操作系统创建这个进程的task_struct和mm_struct等,然后再建立页表将a.exe的虚拟内存地址和物理内存地址建立对应的映射。但是有时候并不是必须要把程序的所有代码和数据加载到内存中,并且创建好内核数据结构建立映射关系。在最极端的情况下,甚至只有内核结构被创建出来了。此时这个进程就是处于新建状态。
4.2 挂机状态
有的大型程序几十G甚至几百G,那么我们的电脑才几G运行内存,这些程序是如何在电脑上执行的呢。
理论上,可以实现对程序的分批加载,每次只加载该程序的部分代码和数据到内存中进行执行,那么既然可以分批加载,那也一定可以分批换出,即将那些已经执行完的程序的代码和数据进行换出,这个程序中每次加载到内存中进行执行的代码和数据就是一个进程,当这个进程执行完并且下面不会再执行时,比如阻塞了,那么这个进程的代码和数据就会被换出了,此时这个进程就叫做挂起了。然后会有这个程序的其他部分的代码和数据加载到内存中形成一个新的进程。这样就分批执行了这个大型程序。页表是将进程的虚拟内存映射到物理内存中,但是页表可不仅仅只能将虚拟内存映射到物理内存中,页表还可以将虚拟内存映射到磁盘中的位置。