Linux 进程详解:从冯诺依曼体系到进程控制
Linux 进程详解:从冯诺依曼体系到进程控制
一、冯诺依曼体系结构
我们常见的计算机,如笔记本、服务器,大部分都遵守冯诺依曼体系。
硬件组成:
- 输入单元:键盘、鼠标、扫描仪、写板等。
- 中央处理器(CPU):含有运算器和控制器等。
- 输出单元:显示器、打印机等。
- 存储器:通常指内存。
核心特点:
- 这里的存储器指的是内存,不是磁盘。内存之外的叫外存(如磁盘),内存掉电易失,磁盘永久存储。
- 不考虑缓存情况,CPU 能且只能对内存进行读写,不能访问外设(输入或输出设备)。
- 外设要输入或输出数据,也只能写入内存或从内存中读取。
- 所有设备都只能直接和内存打交道。
- CPU 很快,内存较快,外设较慢。CPU 进行计算需要数据,数据可能来自内存或外设。因为外设比内存慢很多,CPU 不会从磁盘读取数据,而是从内存读取,以保证效率。
- 内存是掉电易失的,数据来自磁盘。内存一开始没有数据,CPU 向内存要数据时,需要先把外设中的数据写入内存。所以内存是一个巨大的缓存,用来适配 CPU 和磁盘的速度差。
- 谁把数据从磁盘写到内存,谁把内存中的数据写到 CPU?是操作系统。外设输入数据到内存,和内存输出数据到外设的过程叫做 IO 过程。
数据流动示例:
从登录 QQ 开始和某位朋友聊天,数据的流动过程:打开窗口,发消息,对方收到消息。如果发送文件,数据流动过程类似。所有设备都只能直接和内存打交道。
二、操作系统
概念:
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。笼统理解,操作系统包括:
- 内核(进程管理、内存管理、文件管理、驱动管理)
- 其他程序(例如函数库、shell 程序等等)
设计 OS 的目的:
- 与硬件交互,管理所有的软硬件资源。
- 为用户程序(应用程序)提供一个良好的执行环境。
定位:
在整个计算机软硬件架构中,操作系统的定位是:一款纯正的“搞管理”的软件。
如何理解“管理”:
- 管理的例子:描述被管理对象,组织被管理对象。
- 计算机管理硬件:
- 描述起来,用 struct 结构体。
- 组织起来,用链表或其他高效的数据结构。
- 操作系统本质上是一个对软硬件资源进行管理的软件。对下对进程、文件等软件进行管理,对硬件通过硬件的驱动程序管理,对上对用户提供优良的服务。
- 管理者不一定要和被管理者交互,依旧可以管理对象,本质就是对数据进行管理。操作系统拿到对应的数据就可以根据数据对对象进行管理。管理的本质就是对数据做管理。
- 在管理者和被管理者之间还有一个角色——执行者(在这个模型中是驱动),负责根据管理者的要求去被管理者拿到数据给管理者做决策。
- 虽然操作系统要管理的硬件种类非常多,但要的数据种类是一样的(例如是否正常运行、状态等等)。可以使用一个结构体来描述所有的硬件,然后把这些硬件形成结构体,使用一种数据结构进行管理。所以对硬件进行管理变成了对这种数据结构进行管理。做管理的本质就是:先描述,再组织。
系统调用和库函数概念:
- 在开发角度,操作系统对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发使用,这部分由操作系统提供的接口,叫做系统调用。
- 系统调用在使用上,功能比较基础,对用户的要求相对也比较高。有心的开发者可以对部分系统调用进行适度封装,从而形成库。有了库,就既有利于更上层用户或者开发者进行二次开发。
- 操作系统不相信任何人,所以要保护自己并且又要和外界进行交互。操作系统打开了一个个小窗口——接口,通过接口向外界提供服务、交互。这样一来不仅可以防止外界的恶意危害,又给外界提供了交互的机会,这个就是系统调用。因为 Linux 是使用 C 语言写的,所以这里的系统调用可以简单理解为 C 式的函数接口。不仅这样,在使用一些 C 标准库函数的时候也会调用系统调用,例如 printf 向显示器打印,显示器显然是外设,也就是硬件。根据冯诺依曼体系,和硬件进行交互的是操作系统,而操作系统和硬件通过接口(系统调用)交互。
三、进程
基本概念:
- 课本概念:程序的一个执行实例,正在执行的程序等。
- 内核观点:担当分配系统资源(CPU 时间、内存)的实体。
- 一个运行起来的(加载到内存的)程序就是进程,这句话是不准确的。一个写的 C/C++ 程序本质上就是磁盘的一个文件。如果有很多加载到内存的程序,操作系统显然要对这些程序进行管理。之前说了管理的本质就是先描述再组织,操作系统为了描述组织就有了 PCB 的概念。
- 进程 = 内核数据结构(PCB)+ 进程对应的磁盘代码。
描述进程——PCB:
- 进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。
- 课本上称之为 PCB(process control block),Linux 操作系统下的 PCB 是:
task_struct。 - 在 Linux 中描述进程的结构体叫做
task_struct。task_struct是 Linux 内核的一种数据结构,它会被装载到 RAM(内存)里并且包含着进程的信息。 - 在
task_struct里面会存放进程的所有属性,该进程对应的代码地址等等。 - 一般来说要对某些东西进行管理,首先就是要确定名字。在 Linux 中每一个进程都是独一无二的,所以每一个进程都要有自己的 id,一个进程的运行状态等等属性。这些属性在程序里面是没有的,是操作系统通过这个 PCB 描述程序的属性。
- 所以加载到内存中的不仅是代码,还有描述代码的 PCB。在 CPU 向操作系统要进程的时候,操作系统会遍历这个 PCB(注意遍历的是 PCB 不是代码),找到代码把代码给到 CPU 进行执行。最终对进程的管理就变成了对进程 PCB 的管理。
task_struct 内容分类:
- 标示符:描述本进程的唯一标示符,用来区别其他进程。
- 状态:任务状态,退出代码,退出信号等。
- 优先级:相对于其他进程的优先级。
- 程序计数器:程序中即将被执行的下一条指令的地址。
- 内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
- 上下文数据:进程执行时处理器的寄存器中的数据。
- I/O 状态信息:包括显示的 I/O 请求,分配给进程的 I/O 设备和被进程使用的文件列表。
- 记账信息:可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
- 其他信息。
组织进程:
可以在内核源代码里找到它。所有运行在系统里的进程都以 task_struct 链表的形式存在内核里。
查看进程:
- 进程的信息可以通过
/proc系统文件夹查看。如:要获取 PID 为 1 的进程信息,需要查看/proc/1这个文件夹。 - 大多数进程信息同样可以使用
top和ps这些用户级工具来获取。 - 命令:
ps axj | grep "进程的名字"。其中ps axj就是查看系统中所有进程的命令。注意可能会查询到两个进程,因为使用了grep去筛选,所以指令在进行筛选的时候也会以进程的方式运行,所以另一个是grep的进程。如果想要知道打印出来的每一项是什么,可以ps axj | head -1 && ps axj | grep "进程名",ps axj | head -1可以打印出每一项的名字。
通过系统调用获取进程标示符:
- 进程 id(PID),父进程 id(PPID)。
getpid()获取当前进程的 pid,也就是描述该进程的唯一标识符的 id。注意 pid 是在加载的时候给的,只要该进程还在内存,pid 就不会变。但是如果把该进程删除了再次加载到内存,操作系统分配的 pid 是可能会变的,并不是一成不变的。getppid()获取当前进程的父进程。当运行一个进程查看进程的 pid 和 ppid,并且不断地退出和加载到内存,我们发现 pid 是会变的,但是 ppid 没有变。然后通过ps axj查询这个 ppid,发现 ppid 其实是 bash(shell)。所以命令行上启动的进程,一般来说它的父进程没有特殊情况下就是 bash。这样解释了之前说的命令是以子进程的方式执行的。
通过系统调用创建进程——fork 初识:
fork是一个函数。在函数执行前,代码只有一个进程(父进程);在fork执行后,变成了父进程 + 子进程的方式,所以就从一个执行流变成了两个执行流。- 直接
fork然后打印 pid 和 ppid,会发现打印了两句,一个是子进程打印的,一个是父进程打印的。但实际上并不会这样直接用fork。 fork的返回值:子进程 id 会返回给父进程,0 会返回给子进程。C 语言的函数不支持两个返回值,但打印这个返回值时,父进程和子进程打印的 id 的确不一样。一个变量只能对应一个值,为什么在同一个代码中在后续不修改的情况下同一个变量名里面有两个值?这个原因目前没法解释,后续再解释。- 一般
fork的使用方法是通过if/else if判断来把父进程和子进程进行区分。一个代码中一般来说if和else if不会同时执行,但是在这里就是同时执行了。不仅如此,还可以看到两个死循环同时在跑。所以fork之后代码本来是一个执行流(父进程),变成了两个执行流(子进程 + 父进程)。 fork有两个返回值,父子进程代码共享,数据各自开辟空间,私有一份(采用写时拷贝)。fork之后通常要用if进行分流。
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
int ret = fork();
if(ret < 0)
{
perror("fork");
return 1;
}
else if(ret == 0)
{
//child
printf("I am child : %d!, ret: %d\n", getpid(), ret);
}
else
{
//father
printf("I am father : %d!, ret: %d\n", getpid(), ret);
}
sleep(1);
return 0;
}
四、进程状态
看看 Linux 内核源代码怎么说:
为了弄明白正在运行的进程是什么意思,需要知道进程的不同状态。一个进程可以有几个状态(在 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 信号让进程继续运行。
- t 追踪停止:在对调试进程代码进行调试的时候在断点处停下,进程的状态,一种被追踪的状态。
- X 死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
- Z 僵尸状态(zombie):进程退出但父进程未读取退出状态,PCB 保留。
进程状态查看:
ps aux / ps axj 命令。
状态本质:
- 先搭建 OS 的宏观概念:OS 要对硬件做管理就要对硬件进行先描述再组织。每一种设备都要有一种描述该硬件的结构体(里面包含硬件的各种属性,主要里面包含各种方法,通过这些方法调用驱动程序去使用对应的硬件)。在磁盘上有各种写好的二进制程序,例如
bin.exe。要运行这个程序要先加载到内存,并且用一个 PCB(进程的所有属性)去描述这个程序。CPU 会通过 PCB 选择进程去执行。 - 所有进程状态的本质就是为了满足不同的运行场景。一个进程想要在 CPU 上运行,为了保证效率或者安全都要进行运行的管理(既然是管理就要先描述再组织)。CPU 都要让内核维护一个运行队列。因为无论 CPU 是多核的还是单核的,进程的数量在绝大多数时候都是大于 CPU 的数量的,导致一定会有多个进程在同一个 CPU 上跑,所以 CPU 就会要求内核维护一个队列,方便 CPU 来获取进程资源。所以就有了一个进程队列,里面包括了能指向进程的 PCB 的指针(这个指针会形成一个链表来保证可以挨着拿 PCB),还有这个队列的属性(例如队列里面有多少个进程等)。一般而言一个 CPU 对应一个队列。所以本质让进程在 CPU 上跑,就是把进程加入到运行队列。
- 状态就是描述进程的,自然就是进程内部的属性。既然是属性,那么就会保存在进程的 PCB 里面。而 PCB 实际上就是一个结构体,那么这个状态就可以抽象成一个成员变量,可能是用 int 来表示,不同的数字就表明了该进程的不同状态。
- 所谓的不同状态其实就是进程(PCB)在不同的队列中等待不同的资源。
运行状态(R):
- CPU 很快,CPU 可能指向运行队列里面的进程会非常快,所以处在运行队列里面的进程需要随时保证自己可以提供数据代码,所以处在运行队列里面的进程的状态称为运行状态。
- 所以不是在 CPU 上跑的才是运行状态,而是在运行队列里面的就是运行状态。运行状态表明的不是进程正在被执行。
阻塞状态:
- 在写代码的时候或多或少都会去访问硬件,例如 printf 在显示器上打印,通过网卡向网络里面写数据等等。所以进程可能会去访问硬件。而硬件非常慢,但是硬件的数量相比于进程的数量来说也是很少的,但是访问同一个硬件的进程却不止一个,就会导致进程需要硬件资源的时候另外的进程仍然还在占用这个硬件资源。这个时候进程就需要等待。既然有很多进程要访问同一个硬件,那么硬件就要管理这些进程。既然管理就要先描述再组织,就会在描述硬件的结构体中维护一个等待队列。
- 当 CPU 在执行某个进程发现进程需要访问硬件资源,然后假设硬件资源已经被其他进程占用了,而 CPU 因为要考虑效率所以不会一直卡在这个进程这里,操作系统就会把这个进程拿出运行队列,然后加入到硬件的等待队列中,直到要获取的硬件资源准备好了,操作系统会知道的,然后把该进程拿出等待队列,重新加入到运行队列中。在进程处于等待队列里面的状态就称为阻塞状态。
挂起状态:
- 所有的进程(PCB + 代码)都是在内存中,而内存中的进程都有不同的状态。假设一个进程处于阻塞状态,那么就不会马上就被 CPU 进行调度,往往对于 CPU 来说要等待很长的时间,所以代码不会立刻被执行。这个时候万一内存满了怎么办?所以操作系统不会允许这种事情发生,所以操作系统会把代码和数据暂时保存到磁盘中(并不一定放回原位,而是在磁盘的某一块空间里面保存),进程的 PCB 依然在内存中,这就节省了一部分空间,这部分空间就可以供其他进程使用了。称这种代码被放到磁盘中的进程叫做被挂起了。直到要的资源准备好了,操作系统再进行调度。把进程的相关数据加载到内存或者保存到磁盘叫做内存数据的换入和换出。但是操作系统不一定会暴露出这种状态。
- 阻塞 vs 挂起:挂起一定阻塞了,而阻塞不一定挂起了。
Linux 中具体的状态:
- 在进程的 PCB 中,会有一个
task_state_array的数组,里面保存了 Linux 下进程的所有状态。这个数组是 char 类型的数组,里面每一个状态对应一个数组,但是这个数组对应于二进制来说就是在某一位上是 1,其他位是 0 或者全 0。例如 R 对应 0,二进制表示为全 0;S(sleep)对应 1,二进制为00000001。就是八位二进制数,八个位置上不同的 1 的位置对应不同的状态,类似于位图结构,这样会方便计算。 - S 浅度睡眠:可以通过
ps axj操作来查看对应的进程状态。查询一个正处于死循环计算的进程的时候,发现进程正处于R+状态,这个+后面再解释。但是加上循环打印数据时再查看状态,发现进程处于S+状态。这里可能有疑问,为什么正在打印也就是执行代码状态却不是 S?这是因为打印数据要访问硬件(显示器),对应 CPU 来说非常慢,所以绝大部分时间都在等待硬件资源,所以自然是 S 状态。但是如果能正好在 CPU 执行代码的时候进行查看,那么就是 R。但是因为概率太小了,所以查看一般都是 S。S 就是阻塞的一种。所以所有的进程阻塞了都不知道挂起没,也不需要知道,这个只有操作系统知道。 - Linux 中的 T 暂停状态:通过
kill -19 进程pid命令发送暂停信号给对应进程,进程的状态就是暂停状态了,也是属于阻塞的一种,但是不知道是不是挂起了。如果想继续运行就发送 18 号命令。这里有个细节,发现 R 后面的+没有了。如果一个进程在S+状态被发送暂停信号,然后恢复运行,发现S+变成了 S。然后发现居然可以在打印的过程中使用指令,不仅如此,不能在使用ctrl+c结束进程了。这个进程其实已经变成一个后台进程了,所以状态后带+的是前台进程,不带是后台进程,后台进程可以通过发送 kill 信号结束进程。 - D 状态深度睡眠(非常难见到):和 S 不一样,S 可以被终止,但是 D 状态没法被终止。那些情况会出现深度睡眠?例如等待 IO 结束。
- t 也是一种等待,是在对调试进程代码进行调试的时候在断点处停下,进程的状态,一种被追踪的状态。
- X 代表该进程死亡了,但是很难看到这个状态,因为进程死亡操作系统会立即回收进程相关资源。
- Z 僵尸状态:进程创建出来,目的一定是完成某种任务。在进程结束后要指导它完成的怎么样。可以不关心运行结果,但是 Linux 不管你关不关心最后的结果,操作系统都要保存结果完成的如何。所以在进程退出的时候,不能立即释放进程对应的资源,要保存一段时间,让父进程或者操作系统来进行回收。所以当一个进程已经退出了,不会立即释放,而是把它的状态从被检测状态更改为可以被回收的状态。所以从进程退出到被操作系统检测完的这个状态就叫做僵尸状态。想手动实现一个僵尸进程,只需要让进程退出但是没有被回收,对应就是创建子进程,让父进程不要退出并且什么也不做(也就是让父进程一直忙,不要管回收的事)。如果这个时候结束父进程,那么回收工作就由操作系统回收父子进程。注意僵尸状态不能被指令杀死,因为这个进程已经死了。
僵尸进程:
- 僵死状态(Zombies)是一个比较特殊的状态。当进程退出并且父进程(使用
wait()系统调用,后面讲)没有读取到子进程退出的返回代码时就会产生僵死进程。 - 僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。
- 所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入 Z 状态。
来一个创建维持 30 秒的僵死进程例子:
#include <stdio.h>
#include <stdlib.h>
int main()
{
pid_t id = fork();
if (id < 0) {
perror("fork");
return 1;
}
else if(id > 0){
//parent
printf("parent[%d] is sleeping...\n", getpid());
sleep(30);
}
else{
printf("child[%d] is begin Z...\n", getpid());
sleep(5);
exit(EXIT_SUCCESS);
}
return 0;
}
僵尸进程危害:
- 进程的退出状态必须被维持下去,因为它要告诉关心它的进程(父进程),你交给我的任务,我办的怎么样了。可父进程如果一直不读取,那子进程就一直处于 Z 状态?是的!
- 维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在
task_struct(PCB)中,换句话说,Z 状态一直不退出,PCB 一直都要维护?是的! - 那一个父进程创建了很多子进程,就是不回收,是不是就会造成内存资源的浪费?是的!因为数据结构对象本身就要占用内存,想想 C 中定义一个结构体变量(对象),是要在内存的某个位置进行开辟空间!
- 内存泄漏?是的!如何避免?后面讲。
孤儿进程:
- 父进程如果提前退出,那么子进程后退出,进入 Z 之后,那该如何处理呢?
- 父进程先退出,子进程就称之为“孤儿进程”。
- 孤儿进程被 1 号 init 进程领养,当然要有 init 进程回收。
- 父进程比子进程退出的早,这个时候子进程被称为孤儿进程。子进程结束后进入僵尸进程,该由谁来回收呢?可以让父子进程同时死循环,然后手动发送
kill -9给父进程让父进程退出,这个时候发现子进程的父进程变成了 pid 为 1 的进程,这个 1 就是操作系统。所以在孤儿进程后会由操作系统进行接管。为什么要这样干?因为如果不管,子进程退出后对应的僵尸进程将没有人去回收。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t id = fork();
if(id < 0){
perror("fork");
return 1;
}
else if(id == 0){
//child
printf("I am child, pid : %d\n", getpid());
sleep(10);
}
else{
//parent
printf("I am parent, pid: %d\n", getpid());
sleep(3);
exit(0);
}
return 0;
}
五、进程优先级与调度
基本概念:
- CPU 资源分配的先后顺序,就是指进程的优先权(priority)。
- 优先权高的进程有优先执行权利。配置进程优先权对多任务环境的 Linux 很有用,可以改善系统性能。
- 还可以把进程运行到指定的 CPU 上,这样一来,把不重要的进程安排到某个 CPU,可以大大改善系统整体性能。
- 什么叫做优先级?优先级不是权限,权限是你能不能做这件事,而优先级是在已经能做这件事的前提下,是先做还是后做的问题。
- 为什么会存在优先级?因为系统的资源是有限的,没法满足所有的进程同时执行,所以当有急需系统资源的进程要运行的时候,没有优先级只能排队,所以就有了优先级。
- 优先级本质上就是 PCB 中的一个整数(可以理解为就是一个成员变量)。
查看系统进程:
在 Linux 或者 Unix 系统中,用 ps -l 命令则会类似输出以下几个内容。重要信息:
- UID:代表执行者的身份。
- PID:代表这个进程的代号。
- PPID:代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号。
- PRI:代表这个进程可被执行的优先级,其值越小越早被执行。
- NI:代表这个进程的 nice 值。
PRI and NI:
- PRI 即进程的优先级,或者通俗点说就是程序被 CPU 执行的先后顺序,此值越小进程的优先级别越高。
- NI 就是所要求的 nice 值,其表示进程可被执行的优先级的修正数值。
- PRI 值越小越快被执行,那么加入 nice 值后,将会使得 PRI 变为:
PRI(new) = PRI(old) + nice。 - 这样,当 nice 值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行。
- 所以,调整进程优先级,在 Linux 下,就是调整进程 nice 值。
- nice 其取值范围是 -20 至 19,一共 40 个级别。
- 需要强调一点的是,进程的 nice 值不是进程的优先级,他们不是一个概念,但是进程 nice 值会影响到进程的优先级变化。可以理解 nice 值是进程优先级的修正修正数据。
- 在 Linux 中会使用两个值来代表优先级:PRI 和 NI。PRI 就是 priority 优先级,NI 是 nice,就是谦让度的意思。这个值越高谦让度就越高,优先级就低;反之谦让度低,优先级高。所以最终优先级 = 老的优先级 + nice 值。在 Linux 中进行优先级调整的策略是通过更改 nice 值完成的。因为优先级是由 nice 来决定的,所以优先级的范围跟 nice 的范围息息相关。nice 其取值范围是 -20 至 19,一共 40 个级别,而老的优先级都是 80,所以就决定了优先级的范围
[80-20, 80+19]这个范围。
查看进程优先级的命令:
用 top 命令更改已存在进程的 nice:top 进入后按 r -> 输入进程 PID -> 输入 nice 值。
其他概念:
- 竞争性:系统进程数目众多,而 CPU 资源只有少量,甚至 1 个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级。
- 独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰。即使是父子进程,子进程崩溃了不会影响父进程,父进程崩溃了子进程变孤儿进程,但是子进程不会因为父进程崩溃而崩溃。
- 并行:多个进程在多个 CPU 下分别,同时进行运行,这称之为并行。
- 并发:多个进程在一个 CPU 下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。
进程切换:
- 在 CPU 中,有很多寄存器,但是整体上只有一套寄存器(也就是所有的进程公用这一套寄存器)。当一个进程要在 CPU 运行,首先要把进程 PCB 的地址加载到某个寄存器中,然后 CPU 就可以通过 PCB 的地址找到 PCB,再通过 PCB 里面存放的代码地址找到代码。寄存器中有一个 pc/eip 的寄存器,CPU 要执行某行代码就需要这个 pc 指向要执行的具体某行代码,所以 pc 中存放的就是当前 CPU 中执行的指令的下一条指令,方便 CPU 找到指令执行。所以当进程在执行的时候,一定会产生很多的临时数据,但是这些数据是属于当前进程。所以即使 CPU 只有一套寄存器,但是寄存器中存储的数据是属于进程的。所以寄存器的硬件不等于寄存器中的数据。进程在运行的时候要占用 CPU,进程并不是一直要占用,到进程结束后就不用占用了。但是这里的占用也不是就是在运行时间内 CPU 就只属于你了,因为之前父子进程中看到了父子进程同时死循环执行打印,就知道了即使你在运行,CPU 也不是只属于你一个人。所以这里就引出了时间片的概念。在进程在 CPU 执行的时候都有自己的时间片,只有在你的时间片的时候,CPU 才执行你的代码,CPU 资源才属于你的进程。所以当你的时间片结束后,要保存你进程的上下文,存储在寄存器中的数据(代码执行到哪了,当前的数据等等),方便下一次你的时间片的时候直接恢复,不再重新执行。所以恢复执行的第一件事就是恢复上下文,这就叫做进程切换。注意进程的上下文不是保存到 PCB 里面,而是操作系统帮你管理,如果理解不了就简单理解为保存在 PCB 里面。还有
fork之后是不清楚是父进程先运行还是子进程运行,因为这个属于到谁的时间片就该谁执行。
六、环境变量
基本概念:
- 环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数。
- 如:在编写 C/C++ 代码的时候,在链接的时候,从来不知道所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
- 环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性。
常见环境变量:
- PATH:指定命令的搜索路径。
- HOME:指定用户的主工作目录(即用户登陆到 Linux 系统中时默认的目录)。
- SHELL:当前 Shell,它的值通常是
/bin/bash。
查看环境变量方法:
echo $NAME // NAME:环境变量名称。
测试 PATH:
- 创建 hello.c 文件。
- 对比
/hello执行和直接hello执行。 - 为什么有些指令可以直接执行,不需要带路径,而二进制程序需要带路径才能执行?
- 将程序所在路径加入环境变量 PATH 当中:
export PATH=$PATH:hello程序所在路径。 - 对比测试。
- 还有什么方法可以不用带路径,直接就可以运行呢?
- 在执行代码的时候要加上路径才能执行,但是例如
ls等命令也是一个可执行程序,但是为什么不用加上对应的路径?然后直接执行代码,命令行会输出没有找到对应指令。所以想要直接执行代码,就要让操作系统能直接找到可执行程序。在执行程序的时候加上路径就是为了让操作系统能找到程序。ls不用带路径是因为ls在一个特殊的目录下,操作系统会在这个目录下找对应的指令。 - 如果想直接执行代码:
- 可以把程序拷贝到安装应用的目录下。
- 系统会存在一个全局的变量叫做环境变量。查看环境变量:
echo $PATH。打印出环境变量可以看到一串路径用:分割。所以操作系统在执行程序时如果没有路径,会在环境变量的路线找,没有找到才打印错误信息。所以可以把要执行的代码的路径加到环境变量中:export PATH=$PATH:路径。
- 在执行代码的时候要加上路径才能执行,但是例如
- bash 可以支持定义临时变量,例如
a=100(注意中间不要加上空格),然后echo $a可以输出对应数据,也可以定义两个变量进行计算。 - 可以通过
env来查询系统中预先默认自带的一些环境变量,全局性的。不同的用户有些环境变量可能不一样。也可以通过echo $具体的环境变量。 - bash 会自己去维护每个程序都会收到一张环境表。
测试 HOME:
- 用 root 和普通用户,分别执行
echo $HOME,对比差异。 - 执行
cd、pwd,对应和 HOME 的关系。
和环境变量相关的命令:
echo:显示某个环境变量值。export:设置一个新的环境变量。env:显示所有环境变量。unset:清除环境变量。set:显示本地定义的 shell 变量和环境变量。
环境变量的组织方式:
每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以 0 结尾的环境字符串。
通过代码如何获取环境变量:
- 命令行第三个参数:
#include <stdio.h>
int main(int argc, char *argv[], char *env[])
{
int i = 0;
for(; env[i]; i++){
printf("%s\n", env[i]);
}
return 0;
}
- 通过第三方变量
environ获取:
#include <stdio.h>
int main(int argc, char *argv[])
{
extern char **environ;
int i = 0;
for(; environ[i]; i++){
printf("%s\n", environ[i]);
}
return 0;
}
- libc 中定义的全局变量
environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时要用extern声明。
通过系统调用获取或设置环境变量:
putenv,后面讲解。getenv,本次讲解。
#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("%s\n", getenv("PATH"));
return 0;
}
- 常用
getenv和putenv函数来访问特定的环境变量。 getenv("环境变量名")返回值是char*的,成功返回对应的变量,失败返回 NULL。通过这个就可以知道以前那些指令要求是 root 才能执行的就是通过获取环境变量中的 USER 来判断当前用户是不是 root。想取消这个变量可以使用unset。- 注意在命令行里面对应的变量是本地变量,是没法在
env里面看到的,类似于语言中的局部变量,所以自然就没法使用getenv来获取。可以通过export把对应的本地变量变成全局变量,那么代码就可以打印这个变量了。
环境变量通常是具有全局属性的:
- 环境变量通常具有全局属性,可以被子进程继承下去。
#include <stdio.h>
#include <stdlib.h>
int main()
{
char * env = getenv("MYENV");
if(env){
printf("%s\n", env);
}
return 0;
}
直接查看,发现没有结果,说明该环境变量根本不存在。
导出环境变量:export MYENV="hello world"。
再次运行程序,发现结果有了!说明:环境变量是可以被子进程继承下去的!想想为什么?
- 如果只进行
MYENV="helloworld",不调用export导出,再用程序查看,会有什么结果?为什么?普通变量。 - 环境变量具有全局属性这个的原理是什么呢?在之前的学习中知道 bash 也是一个进程,程序是 bash 的子进程,环境变量是会被继承的。所以就理解了本地变量为什么不能在程序中打印了,因为本地变量只在当前进程有效,类比语言中的局部变量和全局变量。
命令行参数:
- 主函数
int main(int argc, char *argv[], char *env[])这三个参数是什么意思呢?argc就是argv这个指针数组的个数。直接打印这个数组,发现打印的就是在运行这个程序的指令也就是./程序名,这个是数组的 0 下标的。如果带上选项-a -b,就可以在argv中看到下标 1 的是-a,下标 2 的是-b。所以这个数组就可以把运行程序时的选项和程序名带入到程序中。实际上就是运行指令是一串字符串,shell 以空格为分割符把这个字符串拆分到指针数组中。 - 第三个参数
char *env[]这个里面就指向了一个个的 bash 环境变量,不需要手动传入,只要带了这个参数,操作系统会自动的把那张表依次放到这指针数组中。环境表是一个字符指针数组,每个指针指向一个以\0结尾的环境字符串。extern char** environ;只要包含了unistd.h头文件就会包含这个全局二级指针,并把这个env指向这个environ。所以env[0]其实就是*(environ+0)。
七、进程地址空间
研究背景:
kernel 2.6.32 32 位平台。
程序地址空间回顾:
在讲 C 语言的时候,老师给大家画过这样的空间布局图:
高地址:内核空间 1G,命令行参数环境变量,栈,共享区,用户空间 3G,堆,未初始化数据,初始化数据,正文代码,低地址。
来段代码感受一下:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{
pid_t id = fork();
if(id < 0){
perror("fork");
return 0;
}
else if(id == 0){
//child
g_val = 100;
printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
else{
//parent
sleep(3);
printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
sleep(1);
return 0;
}
输出结果:
child[3046]: 100 : 0x80497e8
parent[3045]: 0 : 0x80497e8
发现,父子进程,输出地址是一致的,但是变量内容不一样!能得出如下结论:
- 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量。
- 但地址值是一样的,说明,该地址绝对不是物理地址!
- 在 Linux 地址下,这种地址叫做虚拟地址。
- 在用 C/C++ 语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由 OS 统一管理。OS 必须负责将虚拟地址转化成物理地址。
进程地址空间:
所以之前说程序的地址空间是不准确的,准确的应该说成进程地址空间。
分页 & 虚拟地址空间:
说明:同一个变量,地址相同,其实是虚拟地址相同,内容不同其实是被映射到了不同的物理地址!
虚拟地址空间:
- 感性理解虚拟地址空间:每个进程都会以为自己独占了整个系统资源,而事实并非如此。由于时间片轮转机制,时间片用完后进程会被休眠,但进程自身对此毫无感知,它只会觉得自己在运行期间一直占用着系统资源。因此,每个进程都认为自己拥有的地址空间就是系统的全部地址空间。这就好比操作系统给每个进程“画了一张饼”。
- 那么操作系统是如何“画饼”的?画饼的本质,就是在进程的视角里构建一套完整的地址布局蓝图,而这套蓝图在内核中对应一个结构体对象。因为进程数量很多,操作系统要为每一个进程都维护这样一张“饼”,就必须用结构体来描述它——毕竟不仅进程要知道这张饼的布局,操作系统也要知道,以便管理进程、管理地址空间。遵循“先描述,再组织”的原则,进程地址空间的本质,就是内核中一个名为
mm_struct的结构体。 - 以 32 位机器为例,进程地址空间的基本单位是字节,32 位下就有 2 的 32 次方个地址,也就是 4GB 空间,每一个字节都要有自己独一无二的地址。
mm_struct是通过区域划分的方法来实现对地址空间的区域进行管理的,事实上就是使用两个数来标定某一个区间的开始和结束地址。那么调整某个区域的划分就是把这个开始和结束的地址更改。因为 32 位下unsigned int就是 32 个 bits,所以可以一一对应地址,一个数字对应一个地址。所以堆和栈区的区域调整就是更改这个开始和结束的数字。因为是用unsigned int来对应地址,所以进程会认为理论上自己独占全部 2 的 32 次方个地址。那么进程是怎么知道这个mm_struct呢?其实就是 PCB 里面有一个指向这个结构的指针。- 程序加载到内存中时,本质上确实会占用物理内存地址,但进程看到的并不是物理地址,而是自己的进程虚拟地址空间。虚拟地址要找到对应的物理地址,依靠的是操作系统维护的页表来完成虚拟地址与物理地址之间的映射。另外需要注意,内存与磁盘进行 I/O 操作的最小单位不是字节,而是 4KB,也就是说数据读写都是以 4KB 为基本单元进行的。因此内存会按 4KB 大小分块管理,每一块称为一页(page),可以把整个内存想象成一个由许多 4KB 元素组成的大数组。不需要深入理解页表的复杂原理,只需要知道操作系统会通过某种机制完成虚拟地址到物理地址的映射即可。进程本身无法直接看到物理地址,并且在虚拟地址空间里,栈和堆中尚未实际申请的空间,在使用前并不真正属于该进程,只有在显式申请后,操作系统才会为其分配对应的物理内存并建立映射。
为什么存在地址空间?
- 如果让进程直接访问物理内存,一旦进程出现地址越界、非法读写等操作,就能够随意访问甚至篡改其他进程的数据、内核数据,系统会变得极其不安全。而引入虚拟进程地址空间后,所有内存访问都必须经过页表校验,当进程访问非法地址、越界地址或没有访问权限的地址时,会在页表查询阶段直接被硬件拦截并触发异常,进程根本无法接触到物理内存。因此页表并不只是一个简单的虚拟地址到物理地址的映射工具,它同时承担着访问权限控制、地址合法性检查、内存隔离保护的作用。
- 这也可以回答之前的问题:为什么同一个虚拟地址可以存在两个不同的变量。父进程在创建子进程时,父子进程各自拥有独立的进程地址空间,通过各自的页表映射到物理内存。在子进程没有对共享数据进行修改操作之前,父子进程的页表会映射到同一块物理内存,以此节省空间、提高效率。但进程之间具有独立性,任何一个进程的修改都不应该影响另一个进程,否则就破坏了进程独立性。因此当其中一个进程尝试对这块共享的数据进行写入修改时,操作系统就会执行写时拷贝机制:先开辟一块与原数据大小相同的物理内存,把原数据完整拷贝过去,再修改当前进程的页表,将其虚拟地址重新映射到这块新的物理地址上,之后的修改只在新物理内存中进行。这样一来,从虚拟地址空间看地址依旧相同,但实际对应的物理地址已经不同,彼此互不干扰,这一过程完全由操作系统自动完成。
- 再思考一个问题:程序编译形成可执行文件,在还没有加载到内存的时候,里面有没有地址?答案是有的。在程序编译到汇编环节时,查看汇编代码就可以看到,每条汇编指令、每个函数、每个全局变量都已经被分配了地址,这个地址就是逻辑地址,也叫虚拟地址。并不是只有操作系统在运行时才遵守虚拟地址规则,编译器在编译链接阶段就已经按照虚拟地址空间的布局规则对代码、数据进行编址。程序在执行函数调用、指令跳转时,使用的都是程序内部的逻辑地址,而不是物理地址。当程序被加载到内存时,可执行文件中代码区、数据段的逻辑地址范围,会被填写到进程的
mm_struct结构中对应区域的 start 和 end 成员里,操作系统再以此为基础建立页表,完成虚拟地址到物理内存的映射。而 CPU 在执行时,通过 PCB 找到mm_struct,使用程序计数器 PC 指针读取的始终是虚拟地址,再由 MMU 结合页表完成地址转换,最终找到对应的物理地址来读取指令或访问数据。所以 CPU 也没有见物理地址,所以mm_struct中的地址除了栈和堆,其他的是由代码里面的地址填的。
fork 的返回值问题:
- 怎么理解 fork 有两个返回值的问题?在调用 fork 的时候,在 fork 返回之前,核心代码一定是执行完了的,也就是说在返回之前就已经存在了一个子进程,并且子进程有自己的进程地址空间,设置了页表映射,还有可能已经放入到了进程执行的运行列表中了,准备被调度了。所以在返回之前就有了两个执行流,分别是子进程和父进程的。也就是说两个返回值分别是两个进程分别返回的。
- 如何理解 fork 之后,给父进程返回子进程 pid,给子进程返回 0?因为父进程对子进程一定是 1 对 1 或者 1 对多,也就是子进程的父进程是唯一的,而父进程的子进程不一定唯一,所以要给父进程返回子进程 pid。
- 如何理解同一个返回值怎么有两个不同的值,并且让 if 和 else if 同时执行?id 在接受了 fork 的返回值时候就有了两个执行流,之前说过对共享数据进行修改会发生写时拷贝,所以一份 id 对于父子进程来看是不一样的,又因为是两个执行流,虽然执行的是一份代码,但是 id 里面的值不一样,对应执行的结果也会不一样。
八、进程控制
退出码:
- 在写程序的时候往往会在 main 函数结束时返回 0,那么这个 0 是什么呢?其实这个 0 是程序结束的退出码。一般来说 0 就是正常退出,如果设置了一个错误退出就可以设置返回值为 1。
- 怎么知道返回值是什么呢?因为程序结束后是看不到什么现象的,这里可以使用
echo $?,$?永远记录最近一个进程在命令行中执行完毕对应的退出码。所以不关心退出码的话就return 0即可,如果要关心退出码就要返回特定的数据表明是否或者是什么错误。 - 但是如果别人来使用代码,不知道退出码的对应关系怎么办呢?所以一般要用对应退出码的文字描述:1. 可以自定义;2. 可以使用系统的映射关系(不太频繁)。可以使用
strerror,给这个函数一个数字会返回对应文字描述(char*)。
基础退出的情况:
- 代码跑完了,结果正确,
return 0。 - 代码跑完了,结果不准确,
return !0,退出码在这个时候起作用。 - 代码没跑完,程序异常退出,退出码无意义。
进程如何退出:
- main 函数
return返回。 - 任意地方调用
exit库调用(退出码)。 - 系统调用
_exit(退出码)。
exit 库调用和系统调用的区别:库调用会在终止进程前会刷新缓冲区,系统调用不会刷新缓冲区,而是直接退出。根据名字也可以知道这两个一定是有上下级关系的。根据经验,系统调用一定是最底层的,所以库是封装的系统调用。从这也可以知道缓冲区一定不是在操作系统那一层的,因为系统调用没有刷新缓冲区,而库的刷新了,而库和系统调用是有上下级关系的,所以一定是在库在调用系统调用之前就刷新了,所以这个缓冲区一定是在用户区的。
进程等待:
- 之前了解什么是进程的僵尸状态,之前没有办法直接杀死僵尸进程,那么有没有正常的回收方法?所以有了进程等待的方式回收僵尸进程。
- 还有进程等待不仅可以回收子进程的资源,还可以回收子进程的退出码。
- 获取子进程 status:
wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。 - 如果传递 NULL,表示不关心子进程的退出状态信息。否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下(只研究status低 16 比特位):其中第 8-15 位对应的就是进程的退出状态,即进程退出的退出码;0-7 位表示进程终止的信号。一个进程如果出现某种异常一定是该进程收到了某种信号,就例如野指针或者除 0,都是操作系统再识别你的进程这些错误然后向你的进程发送指定的信号导致你的进程退出。信号是 1 开始的,所以如果是 0 就代表正常退出,不是异常退出。进程退出信号可以通过status & 0x7F来获取,也就是获取第 7 位;退出码可以通过(status >> 8) & 0xFF来获取 8-15 位。所以等待的本质就是:操作系统回收子进程资源,然后保留子进程的 PCB 并且写入退出码和退出信号,父进程检测子进程的退出信息并且通过status拿到对应子进程的 PCB 中的退出码和信号。
非阻塞等待:
- 阻塞式等待:也就是
waitpid的第三个参数为 0 的时候的等待方式,只有当子进程进入僵尸状态父进程才回收,不然一直卡在那里直到子进程死亡进入僵尸状态。 - 非阻塞:也就是
waitpid的第三个参数为WNOHANG,子进程没有退出,父进程检测子进程没有退出,该函数不再阻塞等待而是直接返回。假设返回值是 0,就是子进程还没有退出,但是这个不代表waitpid等待失败了,而是仅仅检测到了子进程没有退出而已,不是等待失败。调用失败是返回 -1(一般是子进程的 id 写错了)。这个时候就可以让父进程去做别的事了。而假设返回值大于 0,或者也可以写对应的子进程的 pid,就是waitpid调用成功并且子进程结束退出了,这个时候就可以拿到子进程退出的退出码和退出信号了,并且操作系统回收子进程资源。通过这个可以知道无论调用的结果如何都会直接返回,然后会执行后面的代码。但是如果往后执行了父进程退出了,子进程就会进入僵尸状态被操作系统接管并且变成后台进程。所以要轮询检测,就是把waitpid和判断的过程写成循环。 - 非阻塞的优点:不会占用父进程的所有精力,可以在轮询期间做其他的事情(例如采用回调函数的方式去调用别的函数)。
九、进程切换与 exec 函数族
创建子进程的目的:
a. 想让子进程执行一部分父进程的代码。虽然平时写代码 fork 之后父进程永远也不执行子进程的要执行的那些代码,但是不可否认的是这些代码都是属于父进程的,因为这个是父进程对应磁盘中代码的一部分。
b. 想让子进程执行一个全新的程序,让子进程想办法加载磁盘上指定的程序,执行新程序代码和数据,这就是进程切换。
见见代码:
int execl(const char *path, const char *arg, ...);
将指定程序加载到代码中,让指定进程执行。所以要先找到要执行的程序在哪,然后是怎么执行(假设这个程序有选项呢)。简单来说怎么找到就是写程序的路径,写成字符串;怎么执行就是你在命令行里面怎么写的,就把怎么写的写成字符串。要拆开:./exec ls -a -l -> "./exec" "ls" "-a" "-l"。注意只要是 exec 系列的函数都要以 NULL 为最后一个参数,因为可变参数列表的缘故。
剩下的:
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 main(int argc, char *argv[])
{
printf("process is running...\n");
pid_t id = fork();
assert(id != -1);
if(id == 0)
{
sleep(1);
//main函数的参数 argv[0]是我们的程序名 第二个是ls 第三及后面就是选项
// ./exec ls -a -l -> "./exec" "ls" "-a" "-l"
execvp(argv[1], &argv[1]);
//execl("/usr/bin/ls","ls","-a","-l",NULL);
//execlp("ls","ls","-a","-l",NULL);
//不用再写程序的完整路径了,只写程序名,函数会自动在环境变量里面找对应名字的程序
//char* const argv_[]={
// "ls",
// "-a",
// "-l",
// NULL
//};
//execv("/usr/bin/ls",argv_);
//将所有的执行参数写到数组里面,而不用进行可变参数的方案
//execvp("ls",argv_);
//只要是程序就可以去替换,无论是什么语言写的,只要是可运行的程序就可以
//execl("./mybin","mybin",NULL);
char* const envp_[]={
(char*)"MYENV=112233445566",
NULL
};
extern char** environ;
// execle("./mybin","mybin",NULL,envp_); // 自定义环境变量
// putenv((char*)"MYENV=11223344");//将指定环境变量添加到系统中,也就是environ指向的对应的环境变量表中
// execle("./mybin","mybin",NULL,environ);//系统环境变量,实际上默认环境变量就算你不传,子程序也能获取
exit(1);
}
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if(ret>0)
printf("wait success: exit code: %d, sig: %d\n", (status>>8)&0xFF, status & 0x7F);
// printf("process is ruuning\n");
//execl("/usr/bin/ls","ls","-a","-l",NULL);//第一个参数是要执行的程序的路径,第二个参数是执行程序名,第三或者以上个参数是执行程序的参数(可省略),例如 ls 是程序名,-a 是参数
//所有的 exec 函数都要以 NULL 的参数结尾
//execl 只要返回了就一定错误了,因为成功的话就会替换,就不会有返回值
//perror("execl");//打印错误信息
//在 execl 执行后,程序替换后代码已经被全部覆盖了,执行的是新的代码,所以这个 printf 不执行
//printf("process ruuning done...\n");
}
原理:
用 fork 创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种 exec 函数以执行另一个程序。当进程调用一种 exec 函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。也就是在调用 exec 系列函数后,就把原本子进程在内存中对应的代码和数据替换成了想进程替换的程序的代码和数据了(在有些时候页表也可能会被修改)。因为进程具有独立性,所以在进程替换的时候发生了对共享代码和数据的修改,所以这个时候发生了写时拷贝。调用 exec 并不创建新进程,所以调用 exec 前后该进程的 id 和进程的 PCB 并未改变。因为进程的代码和数据都被替换了,所以 exec 后的代码都不会执行了。
调用失败:只要是函数调用就有可能失败,失败就是没有替换成功,也就是没有替换,也就返回之后继续执行后面的代码了。
int execle(const char *path, const char *arg, ..., char *const envp[]); 是 execle 函数先执行呢还是 main 函数先执行呢?在没有学习操作系统的人可能会认为既然 execle 是函数就应该在 main 函数后执行。但是知道程序在执行前要先加载到内存中,这个工作知道是操作系统做的,但是操作系统是怎么做的呢?事实上操作系统也是调用 exec 系列的函数把程序加载到内存中的,所以 exec 系列的函数也叫加载器。所以是先加载后执行 main。又因为 main 也是函数,也要被调用,也要被传参,所以可以看到 execle 的 const char *arg, ..., char *const envp[] 后两个参数其实就是 main 的后两个参数。所以即使不传第三个环境变量的参数,操作系统也会自动帮你传。
应该还有 execvp 和 execve(真正的系统调用,其他的都是对这个的封装)。
十、代码示例与注释知识点
fork 示例:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
//创建子进程 -- fork是一个函数 -- 函数执行前:只有一个父进程 -- 函数执行后:父进程+子进程一起运行
//fork();
pid_t id = fork();
//printf("我是一个进程!,我的id是:%d,父进程的pid:%d,返回值为%d\n ",getpid(),getppid(),id);
//sleep(2);
if(id == 0)
{
while(1)
{
printf("我是子进程!,我的id是:%d父进程的pid:%d,id:%d\n ", getpid(), getppid(), id);
sleep(1);
}
}
else if(id > 0)
{
while(1)
{
printf("我是父进程!,我的id是:%d父进程的pid:%d,id:%d\n ", getpid(), getppid(), id);
sleep(2);
}
}
else
{}
return 0;
}
waitpid 示例:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
//子进程
int cnt = 5;
while(cnt)
{
printf("我是子进程: %d, 父进程: %d, cnt: %d\n", getpid(), getppid(), cnt--);
sleep(1);
int *p = NULL;
*p = 100;
}
// 运行完
// 1. 代码完,结果对
// 2. 代码完,结果不对
// 异常
// 3. 代码没跑完,出异常了
exit(12); //进程退出
}
// 父进程
//sleep(15);
//pid_t ret = wait(NULL);
int status = 0; // 不是被整体使用的,有自己的位图结构
pid_t ret = waitpid(id, &status, 0);
if(id > 0)
{
printf("wait success: %d, sig number: %d, child exit code: %d\n", ret, (status & 0x7F), (status>>8)&0xFF);
}
sleep(5);
}
简易 shell 示例:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <assert.h>
#define NUM 1024
#define OPT_NUM 64
char line_command[NUM];
char* myargv[OPT_NUM];
int main()
{
while(1){
char cwd[1024]={0};
strcpy(cwd, getenv("PWD"));
//找到最后一个/的位置
char* last = strrchr(cwd,'/');
last++;//指向目录名的第一个字符
//处理如果是根目录的情况
if(last[0]=='\0')
last[0] = '/';
printf("[%s@%s %s]", getenv("USER"), "host", last);
fflush(stdout);
//获取一行
char* s = fgets(line_command, sizeof(line_command)-1, stdin);
(void*)s;//防止未使用的警告
//因为我们在输入结束的时候会按回车导致 line_command的最后一个字符是\n,我们要去掉这最后一个\n;
line_command[strlen(line_command)-1]='\0';
//处理输入 分割
myargv[0] = strtok(line_command, " ");//第一个特殊处理后面的传入 NULL(表示继续分割上一次的字符串)
int i = 1;
while(myargv[i++] = strtok(NULL, " "));//直到返回 NULL,表示分割结束
//进程替换
pid_t id = fork();
assert(id != -1);//子进程没有创建成功
if(id == 0)
{
execvp(myargv[0], myargv);
exit(1);
}
int status = 0;
waitpid(id, &status, 0);
}
return 0;
}
非阻塞等待示例:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <assert.h>
#define NUM 10
typedef void (*func_t)(); //函数指针
func_t handlerTask[NUM];
//样例任务
void task1()
{
printf("handler task1\n");
}
void task2()
{
printf("handler task1\n");
}
void task3()
{
printf("handler task1\n");
}
void loadTask()
{
memset(handlerTask, 0, sizeof(handlerTask));
handlerTask[0] = task1;
handlerTask[1] = task1;
handlerTask[2] = task1;
}
void addtask()
{}
int main()
{
pid_t id = fork();
assert(id != -1);
if(id == 0)
{
//child
int cnt = 10;
while(cnt)
{
printf("child running, pid: %d, ppid: %d, cnt: %d\n", getpid(), getppid(), cnt--);
sleep(1);
// int *p = 0;
// *p = 100; //野指针问题
}
exit(10);
}
loadTask();
// parent
int status = 0;
while(1)
{
pid_t ret = waitpid(id, &status, WNOHANG); //WNOHANG: 非阻塞-> 子进程没有退出,父进程检测时候,立即返回
if(ret == 0)
{
// waitpid调用成功 && 子进程没退出
//子进程没有退出,我的waitpid没有等待失败,仅仅是监测到了子进程没退出。
printf("wait done, but child is running...., parent running other things\n");
for(int i = 0; handlerTask[i] != NULL; i++)
{
handlerTask[i](); //采用回调的方式,执行我们想让父进程在空闲的时候做的事情
}
}
else if(ret > 0)
{
// 1.waitpid调用成功 && 子进程退出了
printf("wait success, exit code: %d, sig: %d\n", (status>>8)&0xFF, status & 0x7F);
break;
}
else
{
// waitpid调用失败
printf("waitpid call failed\n");
// break;
}
sleep(1);
}
//int status = 0;
//// 1. 让OS释放子进程的僵尸状态
//// 2. 获取子进程的退出结果
//// 在等待期间,子进程没有退出的时候,父进程只能阻塞等待
//int ret = waitpid(id, &status, 0);
//if(ret > 0)
//{
// // 是否正常退出
// if(WIFEXITED(status))
// {
// // 判断子进程运行结果是否ok
// printf("exit code: %d\n", WEXITSTATUS(status));
// }
// else{
// //TODO
// printf("child exit not normal!\n");
// }
// //printf("wait success, exit code: %d, sig: %d\n", (status>>8)&0xFF, status & 0x7F);
//}
return 0;
}
注释知识点融入:
fork是一个函数,执行前只有一个父进程,执行后父进程+子进程一起运行。getpid()获取当前进程 PID,getppid()获取父进程 PID,fork返回值:父进程返回子进程 PID,子进程返回 0。- 进程退出三种情况:代码跑完结果正确、代码跑完结果不正确、代码没跑完异常退出。
status不是被整体使用的,有自己的位图结构,低 16 位有效,第 8-15 位退出码,第 0-7 位终止信号。fgets会读取换行符,需要将最后一个字符替换为\0。strtok第一次传字符串,后续传NULL继续分割同一字符串。execvp用新程序替换子进程,自动在 PATH 中查找。成功不返回,失败返回 -1 后执行exit(1)。WNOHANG非阻塞,子进程未退出立即返回 0,父进程可继续执行其他任务。- 回调函数方式:父进程在子进程运行期间,通过函数指针数组调用预定义任务,实现并发处理。
execl需要完整路径,execlp只需程序名自动在 PATH 查找,execv将参数放入数组,execle可自定义环境变量。putenv将环境变量添加到当前进程环境表中。exec成功不返回,失败返回 -1。exec替换用户空间代码和数据,原后续代码不再执行。exit是库函数,会刷新缓冲区;_exit是系统调用,不刷新缓冲区。waitpid第三个参数 0 表示阻塞等待,WNOHANG表示非阻塞。exec系列函数也叫加载器,操作系统加载程序也调用exec。先加载后执行 main,execle的后两个参数就是 main 的后两个参数。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)