一、进程创建
1.fork
进程 = 内核数据结构 + 进程代码和数据
fork之后,进程进入内核态,执行fork的代码,创建子进程,那么OS内核是怎么创建子进程的呢?
首先,需要给子进程分配对应的内核数据结构(为了保证进程间的独立型,必须每个进程独有一份)
第二,将父进程部分内核数据结构的内容拷贝到子进程的内核数据结构。
然后,将子进程添加到系统进程列表中
最后,开始调度器调度(注意,父子进程谁先执行由调度器决定)
理论上,子进程也要有自己的代码和数据,但是创建子进程并没有加载的过程,所以子进程只能“使用”父进程的代码和数据,子进程的代码和数据与父进程相同。
代码段:一般只有读取权限没有修改权限,所以可以共享
数据段:可能会被修改,所以必须分离。
内核数据结构拷贝了一份,映射到相同的物理内存
当子进程要修改数据时,此时系统会发生写时拷贝,把要修改的数据在物理内存重新开辟空间并通过页表映射到虚拟地址空间。
子进程会把拷贝父进程内核数据结构的什么内容?
内存映像(Memory Image):子进程会复制父进程的内存映像,包括代码段、数据段和堆栈。这样,子进程就可以在其自己的地址空间中执行,而不会影响父进程或其他进程。
文件描述符表(File Descriptor Table):子进程会复制父进程的文件描述符表。这样,子进程就可以继承父进程打开的文件描述符,可以继续读写这些文件或套接字,而无需重新打开。
进程上下文(Process Context):子进程会复制父进程的进程上下文,包括进程标识符(PID)、信号处理器、进程组和会话 ID 等信息。这样,子进程就可以在独立的进程上下文中执行。
资源限制和权限(Resource Limits and Permissions):子进程会继承父进程的资源限制和权限设置,例如打开文件的最大数量、CPU 时间限制等。
fork之后,父子进程代码共享,是所有的还是after之后的?
是所有的都共享。
那子进程从哪里开始执行?
从fork之后,因为CPU内有一种寄存器用来存放当前指令在内存中的地址(EIP寄存器,pc寄存器与EIP功能一样,只是在不同体系结构中的不同叫法)。我们的进程可能随时被中断,下次回来时必须从之前的位置继续运行,就需要CPU必须随时记录下当前进程执行的位置,所以CPU内有对应的寄存器来记录当前执行的位置。CPU的寄存器只有一份,但是寄存器数据可以有很多份。 寄存器的数据可以称作为进程的上下文数据。进程的上下文数据是需要拷贝一份给子进程的,此时子进程认为自己的EIP起始值就是fork之后的代码。
进程的上下文数据都包括什么?
程序计数器(Program Counter,PC):指向下一条要执行的指令地址。
寄存器的内容:包括通用寄存器(如 x86 架构中的 EAX、EBX、ECX、EDX 等)、栈指针(Stack Pointer,SP)、基址指针(Base Pointer,BP)等。
内存管理信息:包括页表、段表等,用于描述进程的内存布局和内存访问权限。
打开文件描述符表:描述了进程当前打开的文件或者网络连接。
信号处理器状态:包括当前信号掩码、待处理信号队列等。
进程 ID 和父进程 ID:用于唯一标识进程及其父进程。
进程状态:描述了进程当前的状态,如运行、就绪、阻塞等。
调度信息:包括进程的优先级、时间片大小等调度相关的信息。
栈和堆的状态:描述了进程的栈空间和堆空间的分配情况。
环境变量和命令行参数:描述了进程的运行环境和启动参数。
2.写时拷贝
写时拷贝是一种延迟复制技术,在这种技术下,当多个进程或线程共享同一份数据时,只有在其中一个进程或线程尝试修改数据时,才会真正复制数据。在修改之前,所有进程或线程都共享相同的数据。这样可以节省内存空间,并且减少了不必要的数据复制。
为什么使用写时拷贝?
1.用的时候再给你分配,是高效使用内存的表现
2.OS不知道哪份数据会被修改
因为有写时拷贝的存在,父子进程的以彻底分离,保证了进程的独立性。
二、进程终止
1.进程终止的常见方式
a.代码跑完结果正确
b.代码跑完结果错误
c.代码跑完程序崩溃
1.1怎么判断结果正确
main函数的返回值的意义是什么?
返回给上一级进程,用来评判执行结果。
return 0; 的含义是什么?为什么总是0?
0是退出码,返回0代表告诉上一级代码跑完结果正确。如果不是0,就代表结果错误,不同的非0值代表不同的错误。
1.2 结果错误时返回什么
通过 echo $? 可以获取最近一个退出进程的退出码。
下面的代码可以获取系统设置的退出码
#include <iostream>
#include <string.h>
#include <cstdio>
using namespace std;
int main()
{
for(int i = 0;i < 150;i++)
{
printf("error[%d]:%s\n",i,strerror(i));
}
return 0;
}
我们可以自己设置一套退出机制,退出码
1.3 程序崩溃
程序崩溃时退出码没有意义,因为执行不到return语句。
2.进程终止时,操作系统做了什么
释放进程申请的相关数据结构和对应的数据和代码。本质就是释放系统资源。
3.用代码如何终止掉一个进程
3.1 方法
在main函数内可以使用return语句退出整个程序,但在其余函数只能用于在该函数返回一个值,并将控制权交还给调用它的函数。
而exit语句在任何地方都可以用来退出整个程序。
_exit为系统接口。
3.2 exit和_exit
通过下列代码的结果
int main()
{
printf("代码开始了\n");
printf("aaaaaaaaaa");
sleep(3);
printf("代码结束了");
_exit(1);
}
int main()
{
printf("代码开始了\n");
printf("aaaaaaaaaa");
sleep(3);
printf("代码结束了");
exit(1);
}
为什么使用exit和_exit会有不同的结果呢?
我们知道printf的数据是会先保存在“缓冲区”中的,那么有几个问题,这个“缓冲区”在哪里?是由谁维护的?
首先一定不在操作系统内部,如果是在操作系统内部,_exit一定也能刷新出来。
所以就只能是C标准库给我们维护的。
三、进程等待
1.是什么
通过系统接口(wait/waitpid),让用户等待子进程的一种方案
2.为什么
获知子进程运行结果和回收子进程资源
3.怎么办
3.1 wait/waitpid
先介绍waitpid的几个参数
pid:
pid > 0 : 等待进程ID与pid相等的子进程
pid = -1:等待任意一个子进程,与wait等效
3.1.1 status
status:输出型参数,返回等待的子进程的执行状态信息
statu并不是按照整数整体使用,而是按bit位进行使用,我们只介绍低16位
通过上述位操作代码,可以获取次低八位和低七位的值,也可获得退出码和信号值
其实core dump为是否发生核心转储的标志,核心转储的文件core.xxx可以用于gdb调试
程序异常退出或者崩溃本质是操作系统杀掉了你的进程,是操作系统通过想进程写入信号的方式杀掉的进程,当进程被信号杀掉时,我们只关心是几号信号杀掉的,不关心退出码,因为此时根本没有运行到exit和return。
3.1.2 option(阻塞等待和非阻塞等待)
option默认为0,代表阻塞等待,为1代表非阻塞等待。
这里的0或者1我们称为魔法数字,因为我们无法通过这个数字获取有效的信息。
我们使用宏定义 #define WNOHANG 1 定义1的含义。
HANG(夯住了)即软件在运行时卡住不动了,在系统层面,这个进程没有被CPU调度,要么是在阻塞队列中,要么是在排队等待被调度(没有被CPU调度的原因很多)
WNOHANG就是WAIT NO HANG 不卡住等待
即我们使用waitpid等待子进程返回,如果子进程此时并没有结束,waitpid立马返回,不继续阻塞等待,这就叫非阻塞等待。
3.1.3 waitpid 内部实现
阻塞等待是在操作系统内核中等待,也就是在系统调用函数的内部,阻塞等待伴随着进程被切换,即CPU执行别的进程,此时进程好像卡住了(HANG住了)。
scanf和cin里也必定有系统调用,我么在没有输入时系统好像卡住了。
4.几个宏定义
WIFEXITED(status) 检查子进程是否退出,返回一个非零值表示进程正常退出,否则返回0
#define WIFEXITED(status) (((status) & 0xff) == 0)
这个宏会检查
status
中的低 8 位,如果为 0 则表示子进程正常退出。使用这个宏可以方便地判断子进程的退出状态,而无需直接操作status
。
WEXITSTATUS(status)用于获取子进程的退出状态码
#define WEXITSTATUS(status) (((status) >> 8) & 0xff)
WIFSIGNALED(status)用于检查子进程是否因为信号终止的宏
#define WIFSIGNALED(status) (((status) & 0x7f) && !WIFSTOPPED(status))
这个宏会检查
status
中的低 7 位是否非零,并且同时检查是否子进程不是被停止(stopped,SIGSTOP)而是被信号终止。如果满足这两个条件,则返回真,表示子进程因为信号而终止。
WTERMSIG(status)
:获取导致子进程终止的信号编号。如果子进程因为信号终止,则返回导致终止的信号编号。
WIFSTOPPED(status)
:检测子进程是否处于停止状态。如果子进程处于停止状态,则返回非零值。
WSTOPSIG(status)
:获取导致子进程停止的信号编号。如果子进程处于停止状态,则返回导致停止的信号编号。
四、进程程序替换
1.是什么
进程程序替换就是要子进程执行一个全新的程序,子进程拥有一个全新的代码。
子进程执行一个新的程序无论出现任何事情都不会影响父进程,父进程只会进行结果的回收与分析
程序替换,是通过特定的接口,加载磁盘上一个全新的程序(代码和数据),加载到调用进程的地址空间中。
所谓的exec*函数,本质就是如何加载程序的函数
OS内核部分几乎不发生变化,只是把磁盘中的可执行程序加载到物理内存,并且修改页表中的映射,然后再初始化新进程的堆栈。
当子进程加载新程序时不就是一种“写入”吗,此时代码要写时拷贝,父子代码分离
2.为什么
和应用场景有关,我们有时候必须让子进程执行新的程序
3.怎么办
3.1 exec*函数
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg,
..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[],
char *const envp[]);
这些函数在调用失败时会返回-1,调用成功无需返回。
3.2 使用接口函数
3.2.1 execl
3.2.2 execv
3.2.3 execlp
3.2.4 execvp
类似
3.2.5 execle
环境变量具有全局属性,父进程的环境变量可以从子进程继承。
可以设置环境变量,如果设置环境变量,那么你提供的环境变量数组将称为替换后程序的初始环境,使用execle替换进程后,你就完全控制了被替换后程序的环境变量,而不是从父进程继承了。
如果环境变量数组设置为nullptr,那么被替换后的进程还是会继承父进程的环境变量。
3.2.6 execvpe
类似
3.3 系统调用
int execve(const char *filename, char *const argv[],
char *const envp[]);
此为系统调用,上述exec*的六个函数底层都调用这个函数。
五、minishell
5.1 了解一下shell
shell执行的命令通常有两种
1.第三方提供的对应的在磁盘上的有具体二进制的可执行程序(由子进程执行)(./test,ls,pwd)
2.shell内部,自己实现的方法,由自己(父进程)执行 cd,export
export只有由父进程执行,才能给所有子进程,如果只导给单一的一个子进程,那么环境变量也就不是全局的了。
shell代表的是用户
shell的环境变量是从哪来的呢?
前面文章已经说过,简单说一下,环境变量是写在配置文件中的,shell启动的时候,通过配置文件获得起始环境变量。