一、Linux进程的创建与管理
1、init进程
Linux系统启动后,会自动启动一个初始进程(init进程),后续该系统上创建的进程都会是这个init进程的子孙进程。
init进程:PID为1,操作系统被引导程序启动后,会启动init进程,然后将操作系统的控制权交给init进程。一般位于/sbin/init目录下。同时,通过下图可以看到,/sbin/init文件软链接到了/lib/systemd/systemd。
在较新的Linux系统上,都采用了systemd替代init,因为相比之下,init进程的启动时间更长、启动脚本也更加复杂。于是,我们通过pstree命令可以看到当前的进程树:
2、子进程的创建
在Linux系统中,创建子进程需要系统调用fork()。父子进程共享代码段,各自拥有自己数据段(采用写时复制)。
3、进程的状态
进程的状态主要有:创建、就绪、执行、阻塞、终止五种状态。
(1)创建态:a.父进程向OS申请一个空白PCB;b.OS向PCB填写用于控制和管理进程的信息;c.OS为该进程分配运行所需要的资源;d.OS将该进程转入就绪态并插入就绪队列中。
(2)就绪态:处于就绪态的进程等于CPU的调度,这涉及到CPU调度算法,常见的算法有:先来先服务算法、最短队列优先算法、非抢占式优先级调度算法、抢占式优先级调度算法、时间片轮转算法、多级队列调度等。
(3)执行态:调度器从就绪队列取出进程执行,并将该进程的状态转换为执行态。
(4)阻塞态:进程等待某个事件的发生(如I/O操作完成、信号到达等),暂时无法继续执行。进程从运行态或就绪态转为阻塞态,需要等待所依赖的事件完成。
(5)终止态:OS回收进程资源,并将进程的PCB从进程表移除。这可能会涉及到僵尸进程和孤儿进程的问题。
4、进程间通信
(1)管道:管道是一种半双工的通信方式,包括无名管道和有名管道两种形式。无名管道只能用于具有亲缘关系(即父子进程或兄弟进程)的进程之间,而有名管道可以用于无亲缘关系的进程之间。管道通信只能单向通信,如果需要双向通信的话,就需要创建两个管道。
(2)消息队列:提供了一个消息的链表,存放在内核中,并由消息队列标识符标识。允许一个进程向另一个进程发送消息,进程间的通信是通过读写消息队列来实现的。消息队列可以实现一对一、一对多、多对一、多对多的通信方式。但是消息队列存在 不适合大数据的传输 和 存在用户态和内核态的数据拷贝的开销 的缺点。
(3)共享内存:共享内存是最快的一种进程间通信方式,它允许多个进程共享同一块内存区域,即将不同进程的虚拟地址空间映射到相同的物理内存中,从而实现对共享数据的访问。但是需要使用同步机制(如信号量)来保护共享数据的一致性。
(4)信号:信号是一种异步的通信方式,用于通知进程发生了某种事件。例如,当进程收到一个信号时,可以触发信号处理函数来处理该事件。信号可以用于进程间的简单通信,但通常用于进程间的异步通知。可以通过kill -l命令来查看所有的信号。
(5)信号量:就是常说的PV操作,主要用于解决多进程共享资源的问题,防止发生冲突。
(6)Socket:Socket用于在不同主机间的进程进行通信。Socket通信包括TCPSocket、UDPSocket、本地Socket三种方式。
二、可执行程序的生成
主要有预处理、编译、汇编、链接四个主要阶段。
1、预处理:处理预处理指令,生成预处理后的源文件。例如:a.处理 #include 指令,插入头文件的内容;b.处理 #define 宏定义,展开宏;c.处理条件编译指令(如 #if、#ifdef、#endif);d.删除注释。输入为源代码文件(.c 文件),输出为预处理后的文件(通常以 .i 为扩展名)。
2、编译:将预处理后的源文件转换为汇编代码文件。主要有词法分析、语法分析、语义分析三个部门,此外还会进行中间代码优化。输入为预处理后的文件(.i 文件),输出为汇编代码文件(通常以 .s 为扩展名)
3、汇编:将汇编代码文件转换为目标文件。汇编代码被转化为机器指令。生成目标文件,包含机器代码和相关的元数据。输入为汇编代码文件(.s 文件),输出为目标文件(通常以 .o 为扩展名)
4、链接(Linking)将一个或多个目标文件和库文件链接在一起,生成最终的可执行文件。包括:解析符号(如函数和全局变量)、处理重定位,调整代码和数据的内存地址、合并目标文件和库文件的代码和数据段、生成最终的可执行文件。输入为目标文件(.o 文件)和库文件,输出为可执行文件。
三、可执行程序的加载
1、执行程序首先需要进行系统调用 (exec 系列函数),然后陷入OS内核。
2、OS内核会执行一系列检查,例如验证文件是否存在、是否具有执行权限、是否为有效的可执行文件格式(如 ELF)。
3、OS内核读取可执行文件的头部信息,以确定文件格式(例如 ELF)。文件头部包含程序入口点、段表信息等。
4、OS内核会清理当前进程的用户态内存空间,包括代码段、数据段、堆和栈。关闭除保持打开的文件描述符以外的所有文件描述符。然后根据可执行文件的头部信息,OS内核在内存中创建新进程的映像,加载如代码段、数据段到相应的内存区域。设置程序入口点为文件头中指定的入口地址。配置好堆栈。
5、使用 mmap 系统调用进行内存映射,将可执行文件和所需的共享库(如动态链接库)映射到进程地址空间。如果可执行文件是动态链接的,内核会加载动态链接器(通常是 /lib/ld-linux.so.*),并通过它来解析和加载所需的共享库。
6、动态链接器的工作(如果需要):如果程序是动态链接的,动态链接器会首先执行,负责加载并链接所有所需的共享库,解决符号引用。动态链接器完成工作后,控制权交给新程序的入口点,程序开始执行。
7、跳转到程序入口点:内核将程序计数器(PC)设置为可执行文件的入口点。进程开始执行新程序的第一条指令。
四、程序的执行
程序的执行过程从内核加载可执行程序并转交控制权给用户空间的程序开始。
1、OS内核执行完加载可执行程序的所有必要步骤后,从内核态转到用户态。
2、程序开始在入口点执行,通常是由编译器和链接器确定的。对于C/C++程序,入口点通常是 _start。
3、_start 函数是程序执行的起点,它负责初始化运行时环境,初始化全局和静态变量。并调用系统级的初始化函数(如构造函数)。
4、在完成必要的初始化后,调用用户定义的 main 函数。传递命令行参数(argc, argv)和环境变量(envp)给 main 函数。
5、main 函数是用户代码的入口,程序在这里开始执行用户定义的逻辑。期间可能会进行I/O操作、内存分配、文件操作、进程间通信、网络通信等。
6、main 函数执行完毕后,返回一个整数值作为退出状态。或者,程序可以调用 exit 函数来终止执行,并返回退出状态。并且调用系统级的清理函数(如析构函数),释放分配的资源。
7、系统调用 exit 终止进程内核会释放进程占用的所有资源,包括内存、文件描述符等。内核会将进程的退出状态报告给父进程,父进程可以通过 wait 系列系统调用来获取子进程的退出状态(如果不调用wait函数的话,会成为僵尸进程)。