进程都认为自己独占系统内存资源,操作系统给进程画了一张饼,这张饼就是进程地址空间(操作系统让进程以为自己拥有整个物理内存),实际上进程拥有的只是虚拟地址。
进程地址空间里有区域划分,那我们在mm_struct可以怎么实现?我们就只需要控制区域的上下界即可,即我们只要维护两个边界。如下所示的方式就可以实现区域的划分
struct mm_struct { unsigned long code_start; unsigned long code_end; unsigned long init_start; unsigned long init_end; unsigned long uninit_start; unsigned long uninit_end; ...... };
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
我们常常把进程地址空间比作一把尺子,根据尺子刻度来划分空间
PCB是进程描述符,在linux下的具体表现就是task_struct
虚拟地址可以完全一样,映射到的物理内存可能不同。
代码一般都是只读的不更改。
如果有一些野指针强行修改了一些内存数据,就可能造成一些未知的错误。
显然,将操作一块空间的内容和标识一块空间的状态进行对比,显然后者效率更高。
可以,需要一段就加载一段。(并不需要一次性全部加载完毕,但可能有点卡…)
小知识:可执行程序,本身就已经划分成为了一个个的区域,这样做利于链接,且划分大小都是4KB的整数倍,利于系统操作。
进程是动态的,程序是静态的,且进程有生命周期,而程序没有。
一个程序可以有多个进程,而一个进程只能对应一个程序。(比如一个程序可以创建父子进程,但父子进程都对应同一个程序)
组成上,进程包括了程序,而程序是指令的集合
进程的创建有两种方式,分别是命令行启动命令和通过程序fork创建子进程。
学习前先了解 写时拷贝
fork()是操作系统给我们创建进程的接口
让我们先问问linux里的man
命令:man fork
调用fork后,内核会分配新的内存块和内核数据给子进程,将父进程中的部分数据结构(比如PCB)拷贝给子进程。子进程假如到系统的进程列表中,fork返回,调度器开始调度。
简单来说,子进程以父进程为模板拷贝了一些必要的数据结构,并且加入了系统的进程列表等待调度。
fork之后父子进程都有自己的pid,因为父子进程的返回值不同会发生写时拷贝,但因为虚拟地址没变,导致看起来像有两个返回值
我们可以看到fork有“两个”返回值“,并不是return了两次,而是值的写入引起了写时拷贝 (实际上页表映射的物理内存不是同一块)
观察下面的if和else if同时执行的现象
#include<unistd.h>
#include<stdio.h>
int main()
{
int ret=fork();
while(1)
{
if(ret==0)
{
//child
printf("i am child ,ret=%d ,addr=%p\n",ret,&ret);
sleep(1);
}
else if(ret>0)
{
//father
printf("i am father,ret=%d ,addr=%p\n",ret,&ret);
sleep(1);
}
else
{
//fork err
perror("fork");
}
}
}
解释:我们看到的地址是虚拟地址,实际上的物理地址是不同的,这是因为发生了写时拷贝。
fork()创建子进程后子进程返回了一个值,返回一个值等同于子进程修改了数据,要发生写时拷贝,所以看起来就有了一个变量存储了两个值(fork返回两个值)的现象。
这里值得注意的是:fork函数先创建的进程再返回一个值,有了父子进程才有了后来的写时拷贝。
子进程会以父进程为模板拷贝代码和数据,子进程更改数据就会发生写时拷贝。(写时拷贝维护了父子进程的独立性,利于运行多进程)
创建子进程本质是系统多了一个进程(进了调度列表),也就是多了一套进程相关的数据结构(如PCB)
在不写入的情况下,用户的代码和数据是父子共享的
写这里指修改
写时拷贝可以保证进程的独立性。子进程如果没用到父进程所有的资源却拷贝了父进程所有的资源,这就是浪费,如果这么做了还会导致fork()效率的降低,提高fork失败的概率(比以前需要申请更多的资源,自然更容易失败)
有可能,比如后面会提到的进程替换。
代码运行完,结果正确。
代码运行完,结果不正确。
代码没运行完就结束。(异常终止,如野指针导致的越界问题等)
前两种属于进程正常终止,第三种属于非正常终止。
进程的正常退出会有退出码,非正常退出一般与代码错误和信号有关,比如kill信号导致进程直接终止。
以下只讨论正常退出的情况,非正常退出会在信号那节提到。
系统通过接口调用main函数,main函数执行完后要给系统一个执行完成的信息,main函数的return 0就充当了这个功能,return 0告诉系统正常结束且结果正确,这里的0就是退出码。(非0一般表示结果不正确)
echo $? 查看最近一次执行程序的退出码
例子
随笔记录:如make clean;make ,中间用分号隔开,一次可以运行多条命令
退出码可以人为定义,也可以用系统或者库里提供的错误码列表。
比如C语言提供的strerror(在vim界面下怎么查询函数)
我们为什么要创建子进程?因为我们需要子进程完成我们给它的任务。
父进程想知道子进程有没有完成任务,就得借助退出码了。
main函数return
任何函数exit。
非main函数return只是结束函数,而不是结束进程
举个栗子
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
void show()
{
printf("i am show\n");
exit(10);
}
int main()
{
show();
printf("i am main\n");
return 0;
}
所有的进程都是操作系统创建的,fork不过是一个接口。
两者的区别在于,exit会在进程结束做一些收尾工作(比如刷新缓冲区),而_exit不会,看下面的对比。
exit函数
_exit函数
为什么要等待?
回收僵尸进程,解决内存泄漏(僵尸进程是杀不死了,kill -9不能干掉僵尸进程)
获取子进程的运行结束状态(交给子进程的工作做的怎么样了)
父进程晚于子进程退出,规范化的进行资源回收。
等待的本质也是管理的一种方式,OS的核心就在于管理二字。
简而言之,进程等待的原因:获取子进程的信息和防止内存泄漏
wait和waitpid函数
头文件是sys/types.h和sys/wait.h
wait的返回值:等待成功返回子进程的id,等待失败返回-1
waitpid的返回值:等待成功返回子进程的id,如果指定了WNOHANG选项,且子进程正在运行(没有已退出的子进程可收集)则返回0,等待失败返回-1
例子1:wait的简单使用
pid_t wait(int*status); wait参数列表里有一个参数status,这个参数的作用和waitpid参数列表中的status作用相同
wait里的参数设为NULL,表示等待任意一个子进程。如果不设置为空可以带出子进程的状态,比如是否是正常退出(后面会举例)
#include<stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
pid_t id=fork();
if(id<0)
{
perror("fork");
}
else if(id==0)
{
//child
int cnt=5;
while(cnt--)
{
printf("child is running,pid:%d\n",getpid());
sleep(1);
}
printf("child finished...\n");
exit(0);
}
else
{
//father
printf("father is waiting\n");
pid_t ret=wait(NULL);
printf("father is wait done,ret:%d\n",ret);
}
return 0;
}
例子2:waitpid的简单使用
pid_ t waitpid(pid_t pid, int *status, int options);
waitpid有三个参数,第一个是等待的子进程id,第二个是子进程返回的状态,第三个是等待的方式。其中第二个参数status也被称为输出型参数。
第三个参数option一般是一些宏,比如
不关心子进程的返回状态则用NULL,option如果不想用提供的宏就设为0
#include<stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
pid_t id=fork();
if(id<0)
{
perror("fork");
}
else if(id==0)
{
//child
int cnt=5;
while(cnt--)
{
printf("child is running,pid:%d\n",getpid());
sleep(1);
}
printf("child finished...\n");
exit(0);
}
else
{
//father
printf("father is waiting\n");
pid_t ret=waitpid(id,NULL,0);//waitpid
printf("father is wait done,ret:%d\n",ret);
}
return 0;
}
例子3:waitpid参数中status的使用
status的组成
#include<stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
pid_t id=fork();
if(id<0)
{
perror("fork");
}
else if(id==0)
{
//child
int cnt=5;
while(cnt--)
{
printf("child is running,pid:%d\n",getpid());
sleep(1);
}
printf("child finished...\n");
exit(10);
}
else
{
//father
int status=0;
printf("father is waiting\n");
pid_t ret=waitpid(id,&status,0);
if(ret>0&&(status&0x7f)==0)
{
printf("正常退出\n");
}
else
{
printf("被信号所杀\n");
}
printf("father is wait done,ret:%d,\n",ret);
}
return 0;
}
正常运行
运行中杀掉进程
日常中我们并不用位运算去拿到status,而是调用宏
比如WEXITSTATUS(status)就可以拿到status的退出码
为什么要通过waitpid拿到子进程的结果,能不能用全局变量?
不可以,每个进程都有自己独立的进程地址空间,如果用了全局变量会导致虚拟地址看起来一样但是实际指向的物理内存不同(因为全局变量更改时发生了写时拷贝)
那waitpid里的status是从哪里拿到的呢?这是个系统接口,自然是从task_struct里面
一般进程提前终止是收到了OS发送的信号。
退出码有效的前提是正常终止,即正常终止才去看退出码,异常终止看信号(排除掉代码错误后)
阻塞等待,注意等待的主体是父进程,是父进程等子进程。阻塞等待就是父进程一直在那等啥事也不干,等到子进程干完了自己的事父进程再去干自己的事,比如我们将wait和waitpid的选项设置为0就是阻塞等待。
非阻塞等待:就是父进程在等待的同时也在做自己的事,在子进程退出后再去读取子进程的退出信息。
实现非阻塞等待,把waitpid里的第三个参数设置为WNOHANG即可
理解记忆:计算机资源在即将被吃完的时候,我们称其为hang住了,NOHANG就是别HANG住的意思
非阻塞轮询方案:采用多次检测,过一段时间就检测一次子进程的状态,直到检测到子进程完成自己的任务。
//非阻塞轮询方案
#include<stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
pid_t id=fork();
if(id<0)
{
perror("fork");
}
else if(id==0)
{
//child
int cnt=5;
while(cnt--)
{
printf("child is running,pid:%d\n",getpid());
sleep(2);
}
printf("child finished...\n");
exit(10);
}
else
{
//father
while(1)//一直检测
{
int status=0;
printf("father is waiting\n");
pid_t ret=waitpid(id,&status,WNOHANG);//根据返回值来确定是否等待成功
if(ret==0)
{
printf("father does other things\n");
sleep(1);
}
else if (ret>0)
{
printf("father wait success\n");
printf("正常结束\n");
return 0;
}
else
{
printf("wait err\n");
break;
}
}
}
return 0;
}
待了解:等待队列
父进程等待子进程时被放进等待队列,等待队列中的进程都处于阻塞状态,之后等子进程完成任务后再唤醒父进程。–结合网上给出的理解…
为什么需要进程替换?
我们创建子进程肯定让子进程来执行我们的任务,这个任务在没讲进程替换之前都是父进程给它的,那如果现在我们需要执行别的程序呢?此时就需要进程替换了。
比如我们想要在我们的程序里面执行ls,top等命令
之前我们说过,一般情况下写时拷贝是针对数据的,一般不会改变代码,但我们说的是一般情况下。进程替换下就可能更改了代码,进而引发代码的写时拷贝。
下面简单将程序理解为代码和数据。
进程替换并不是创建了新的进程,而是一种替换。以磁盘上的一个程序去替代子进程为例,是将磁盘上程序的代码和数据去替代子进程的代码和数据,此时就会有写时拷贝。
值得注意的一点是,因为进程替换并没有创建进程,所以该进程的pid和数据结构都是不变的。
实现进程替换需要通过exec系列的库函数。
让我们问问linux的man。
man 2 execl
这几个函数都是对execve的封装,因为系统真正调用的execve函数,execve函数与这几个函数是同一个头文件
因为进程替换的原因,我们一般不用考虑这几个函数的返回值,因为替换后直接从替换的代码开始执行,现有的这个进程就被替换掉了,一旦返回就说明替换失败,函数出错了,此时会返回-1。
execl
int execl(const char *path, const char *arg, …);
参数列表末尾的…表示可变参数列表,说明可以有任意多个参数,以NULL结尾
which ls可以知道ls命令的路径,所以which常与exec系列的函数连用
path表示替换程序的路径,arg表示你要如何执行这个程序,以ls -a -l为例
execl(“/usr/bin/ls”,“ls”,“-a”,“-l”,NULL);
#include <stdio.h>
#include <unistd.h>
int main()
{
execl("/usr/bin/ls","ls","-a","-l",NULL);
printf("hello world\n");
return 0;
}
execlp
int execlp(const char *file, const char *arg, …);
file表示要执行的程序,arg表示要如何执行,与execl的不同点在于这个函数会在PATH环境变量下自动寻找程序。
与execl相比函数名多了一个p,这个p表示path。
execle
int execle(const char *path, const char *arg, …, char *const envp[]);
与execl相比多了最后一个参数,envp这个字符指针数组表示我们自己定义的环境变量。
作用是传入默认或自定义的环境变量给目标可执行程序
传入的环境变量是不是永久的,只是在当前可用
下面的例子用mycmd程序替代当前进程,并且传入环境变量。
mycmd.c
#include <stdio.h>
#include <stdlib.h>
int main()
{
for(int i=0;i<5;i++)
{
printf("cmd:%d\n",i);
}
printf("获取自己定义的环境变量:%s\n",getenv("MYENV"));
return 0;
}
getenv是一个库函数,获取这个环境变量的值,MYENV是我们自己定义的环境变量,系统里找不到,此时要用到我们自己定义的环境变量就用execle,换句话说execle可以传递环境变量
replace.c
#include <stdio.h>
#include <unistd.h>
int main()
{
char* const envp[]={"MYENV=11",NULL};
execle("./mycmd","mycmd",NULL,envp);
printf("hello world\n");
return 0;
}
现在我们有两个文件,怎么在Makefile里面一次性编译多个程序?写法如下
.PHONY:all all:replace mycmd replace:replace.c gcc -o $@ $^ --std=c99 mycmd:mycmd.c gcc -o $@ $^ --std=c99 .PHONY:clean clean: rm -f replace mycmd
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
运行结果
execv
int execv(const char *path, char *const argv[]);
与execl相比,execl的arg表示要怎么直线这个程序,execv则是通过数组实现相同的功能,在字符指针数组里以字符串来表示要执行的选项,也是以NULL结尾。
#include <stdio.h>
#include <unistd.h>
int main()
{
char* const argv[]={"ls","-a","-l",NULL};
execv("/usr/bin/ls",argv);
printf("hello world\n");
return 0;
}
运行结果
execve和execvp
这两个函数与execv相比,一个多了p(在PATH下自动找程序),一个多了e(传递需要的环境变量信息),他们的区别就是execl和execle,execlp的区别。这里就不举例了
我们知道程序需要运行前需要先通过加载器加载到内存,exec就像一个特殊的加载器加载程序达到替换的效果。
这个小功能整合了以上许多的知识点。
真正的实现一个Shell需要很多字符操作,这里简易版本只涉及简单的命令解析。
这个Shell的功能
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>
#define NUM 128
#define SIZE 32
char command_line[NUM];//命令行
char* command_parse[SIZE];//切分命令行
int main()
{
while(1)
{
memset(command_line,'\0',sizeof(command_line));
printf("[我的Shell]$ ");
fflush(stdout);
if(fgets(command_line,NUM-1,stdin))
{
command_line[strlen(command_line)-1]='\0';//处理\n
int index=0;
command_parse[index]=strtok(command_line," ");
while(1)
{
index++;
command_parse[index]=strtok(NULL," ");
if(command_parse[index]==NULL)
{
break;
}
}
if(fork()==0)
{
execvp(command_parse[0],command_parse);
exit(1);//进程替换失败 成功这句代码会被替换掉
}
int status=0;
pid_t ret=waitpid(-1,&status,0);
if(ret>0 && WIFEXITED(status))
{
printf("EXIT Code:%d\n",WEXITSTATUS(status));
}
}
}
return 0;
}
bug:如cd …的功能为什么不能在子进程中实现?cd …的主体是父进程,父子进程是独立的,子进程去更改并不能去影响父进程,需要在子进程里影响到父进程肯定不是我们能做的,我们需要系统提供给我们的一些接口才能完成,即我们需要调用系统接口来实现cd…。echo $PATH也是一个道理
此外这个Shell不能输入特殊字符,比如backspace
调试可以结合/proc目录,/proc目录下有着进程的相关信息。