一、 实验目的与要求
通过进程的创建、撤销和运行加深对进程概念和进程并发执行的理解,明确进程与程序之间的区别。
二、 实验内容与方法
掌握在linux中编程编译运行的方法,试验你的第一个helloworld程序。
学习预备材料和后面的阅读例程,理解函数fork()、execl()、exit()、getpid()和waitpid()的功能和用法
编写hello-loop.c程序(在helloworld例程基础上加一个死循环)。使用gcc hello-loop.c –o helloworld生成可执行文件hello-loop。并在同一个目录下,通过命令“./hello-loop”执行之。使用top和ps命令查看该进程,记录进程号以及进程状态。(5分) 查看运行着的hello-loop进程对应的/porc/PID/maps,记录其输出,并绘制出进程用户空间的布局图。 指出可执行文件的用户代码和数据所在区间,并告知你的判断依据。(10分)
使用kill命令终止hello-loop进程。(5分)
使用fork()创建子进程,形成以下父子关系:(5+10+15分)
通过检查进程的pid和ppid证明你成功创建相应的父子关系并用pstree验证其关系。注意在执行前后,分别检查/proc/slabinfo中进程控制块PCB的数量是否能反映出你创建的进程数量变化?
提示使用cat /proc/slabinfo |grep task_struct命令输出的第一个整数.
编写代码实现孤儿进程,用pstree查看孤儿进程如何从原父进程位置转变为init进程所收养的过程(注意如果是ubuntu你看到的有可能不是被init接收的情况,请如实记录);编写代码创建僵尸进程,用ps j查看其运行状态。(10分)
(选做,可加10分直至加满)
编写一个代码,使得进程循环处于以下状态5秒钟运行5秒钟阻塞(例如可以使用sleep( )),并使用top或ps命令检测其运行和阻塞两种状态,并截图记录;
三、 实验步骤与过程
1 Linux下程序运行与编译
图 1 创建一个C文件
打开Linux,在cmd窗口中通过如图 1的指令创建一个c文件。
图 2 C程序代码
打开vim编辑器后,在文本框中输入如图 2的代码。
图 3 使用gcc编译c文件
完成代码编写后,使用gcc命令编译代码并运行,运行结果如下。
图 4 代码运行结果
至此,我的第一个helloword程序成功编译并执行。
2 fork()、execl()、exit()、getpid()与waitpid()的函数用法
2.1 fork(建立一个新的进程)
函数形式:pid_t fork(void);
函数作用:fork()会产生一个新的子进程,其子进程会复制父进程的数据与堆栈空间,并继承父进程的用户代码,组代码,环境变量、已打开的文件代码、工作目录和资源限制等。
返回值:如果fork()成功则在父进程会返回新建立的子进程代码(PID),而在新建立的子进程中则返回0。如果fork 失败则直接返回-1,失败原因存于errno中。
2.2 execl(执行文件)
函数形式:int execl(const char * path,const char * arg,…);
函数作用:execl()用来执行参数path字符串所代表的文件路径,接下来的参数代表执行该文件时传递过去的argv(0)、argv[1]……,最后一个参数必须用空指针(NULL)作结束。
返回值:如果执行成功则函数不会返回,执行失败则直接返回-1,失败原因存于errno中。调用ls命令范例: execl(“/bin/ls”, “/bin/ls”, “-l” , “/etc”, NULL);
2.3 exit(正常结束进程)
函数形式:void exit(int status);
函数作用:exit()用来正常终结目前进程的执行,并把参数status返回给父进程,而进程所有的缓冲区数据会自动写回并关闭未关闭的文件。
返回值:无返回值
2.4 getpid(取得进程识别码)
函数形式:pid_t getpid(void);
函数作用:getpid()用来取得目前进程的进程识别码,许多程序利用取到的此值来建立临时文件,以避免临时文件相同带来的问题。
返回值:目前进程的进程识别码
2.5 waitpid(等待子进程中断或结束)
函数形式:pid_t waitpid(pid_t pid,int * status,int options);
函数作用:waitpid()会暂时停止目前进程的执行,直到有信号来到或子进程结束。如果在调用waitpid()时子进程已经结束,则wait()会立即返回子进程结束状态值。子进程的结束状态值会由参数status返回,而子进程的进程识别码也会一快返回。如果不在意结束状态值,则参数status可以设成NULL。参数pid为欲等待的子进程识别码,其他数值意义如下:
pid<-1 等待进程组识别码为pid绝对值的任何子进程。
pid=-1 等待任何子进程,相当于wait()。
pid=0 等待进程组识别码与目前进程相同的任何子进程。
pid>0 等待任何子进程识别码为pid的子进程。
返回值:如果执行成功则返回子进程识别码(PID),如果有错误发生则返回-1。失败原因存于errno
3 编写并观察hello-loop.c程序
3.1 编译并执行hello-loop.c文件
重新使用vim在c文件中修改成如图 5的代码:
图 5 hello-loop程序
使用gcc进行编译并执行
图 6 使用gcc进行编译并运行
运行之后可以看到如图 7的输出,由于代码中包含死循环,程序会一直输出“loop”。
图 7 hellp_loop的输出
3.2 使用top观察hello_loop进程
进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
进程的概念主要有两点:第一,进程是一个实体。每一个进程都有它自己的地址空间,一般情况下,包括文本区域(text region)、数据区域(data region)和堆栈(stack region)。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。第二,进程是一个“执行中的程序”。程序是一个没有生命的实体,只有处理器赋予程序生命时(操作系统执行之),它才能成为一个活动的实体,我们称其为进程。
在操作系统中,与进程匹配的数据结构称为进程控制块(Process Control Block, PCB),系统利用PCB来描述进程的基本情况和活动过程,进而控制和管理进程。这样,由程序段、相关数据段以及PCB三部分构成的实体便是所谓——进程实体(又称进程映像(process image))。通常所说的进程即是进程实体。
在接下来的实验中,创建进程指的是创建进程实体中的PCB;撤销进程指的是撤销进程的PCB。
在本节中,将仔细观察我们hello_loop的进程执行情况。首先,我们将使用top命令来观察进程的执行状态。在cmd中输入“top”,如下图所示:
图 8 执行top之后
如图 8,第一行显示了系统的一些状态。从左到右依次为:当前系统时间,系统运行的时间,当前在线用户数,系统在之前1min、5min和15min内cpu的平均负载值。如图 8即表示当前的系统时间为03:23:18,系统已经运行了35分钟,当前有1个用户在线,1min、5min和15min内cpu的平均负载值分别为2.47%,0.82%和0.47%。
下方的Task行中依次表示各个状态下的线程数量,分别为总进程数,运行中的进程数,休眠进程数,停止进程以及僵尸进程数。如图 8即表示统计周期内共有283个线程,4个线程正在运行。279个正在休眠,没有停止的进程以及僵尸进程。
接下来是Cpu行,依次表示了用户态下进程、系统态下进程占用cpu时间比,nice值大于0的进程在用户态下占用cpu时间比,cpu处于idle状态、wait状态的时间比,以及处理硬中断、软中断的时间比以及虚拟 CPU 等待实际 CPU 的时间的百分比。如图 8即表示用户态下进程占14.3%,系统态下进程占比82.1%,cpu处于idle状态的时间比为3.6%,其余都为0%。
接下来为Mem行,该行提供了内存统计信息,包括物理内存总量、空闲内存、已用内存以及用作缓冲区的内存量。如图 8即表示物理内存总量为3906.9MB,空闲内存为2284.1MB,已用内存为910.2MB,用作缓冲区的内存量为712.6buff/cache。
最后是Swap行,为交换分区统计信息,包括交换空间总量、空闲交换区大小、已用交换区大小以及用作缓存的交换空间大小。如图 8即表示交换空间总量为923.3MB,空闲交换区为923.3MB,已用交换区为0MB,用作缓冲区的内存量为2765.2MB。
下半部分即为进程信息表,表头从左到右依次为:
PID:进程号
USER:用户名
PR:优先级
NI:nice值。负值表示高优先级,正值表示低优先级
VIRT:进程使用的虚拟内存总量
RES:进程使用的、未被换出的物理内存大小,单位kb
SHR:共享内存大小,单位kb
S 进程状态。其中,D表示不可中断的睡眠状态,R表示运行,S表示睡眠,T表示跟踪/停止,Z表示僵尸进程
%CPU:CPU使用率
%MEM:进程使用的物理内存百分比
TIME+:进程使用的CPU时间总计,分度值为1/100秒
COMMAND:执行的命令
因此如图 8中展示,我们运行的hello_loop的c程序的进程的进程号为2308,用户名为dongyunhao_2019284073,优先级为20,nice值为0,使用的虚拟内存总量为2496kb,使用的、未被换出的物理内存大小为584kb,共享内存大小为516kb,CPU使用率为53.3%,物理内存使用百分比为0%,程序已经运行44.48,秒的CPU时间,程序执行的命令是hello_loop。
此外,由于当前系统中存在很多进程,直接使用top查看所有进程常常不利于观察,因此可以使用-p参数指定要观察进程的编号实现过滤。
图 9 使用-p过滤进程
3.3 使用ps观察hello_loop进程
除了top命令,还可以使用ps命令查看进程的状态。但与top不同的是,ps只能显示某一时刻的信息。输出的进程信息不能实时更新。
图 10 使用ps的j格式显示进程
如图 10显示了hello_loop进程的相关信息。从左到右依次为:
PPID:父进程号
PID:进程号
PGID:进程组ID
SID:会话组ID
TTY:终端设备
TPGID:控制终端进程组ID
STAT:进程当前的状态
UID:用户ID
TIME:进程执行起到现在总CPU占用时间
COMMAND:启动命令
因此如图 10中展示,我们运行的hello_loop的c程序进程的父进程号为2096,进程号为2308,进程组号为2308,会话组号为2096,终端设备是pts/0,控制终端进程组号为2308,进程的运行状态为运行中,发起进程的用户id为1001,进程已经运行了0.17秒的CPU时间,启动进程的命令为“./hello_loop”。
3.4 绘制进程用户空间布局图
在进程运行过程中,操作系统使用线性结构为运行中的进程存储各种信息,如下图 11列举了操作系统中的线性结构为进程保存的各个信息。
图 11 进程线性空间图
由低地址到高地址依次为保留区,代码段,数据段,BSS段,堆区,内存映射段,栈,命令行参数区和环境变量区。接下来依次介绍:
保留区(Reserved):
位于虚拟地址空间的最低部分,未赋予物理地址。任何对它的引用都是非法的,用于捕捉使用空指针和小整型值指针引用内存的异常情况。
它并不是一个单一的内存区域,而是对地址空间中受到操作系统保护而禁止用户进程访问的地址区域的总称。大多数操作系统中,极小的地址通常都是不允许访问的,如NULL。C语言将无效指针赋值为0也是出于这种考虑,因为0地址上正常情况下不会存放有效的可访问数据。
在32位X86架构的Linux系统中,用户进程可执行程序一般从虚拟地址空间0x08048000开始加载。该加载地址由ELF文件头决定,可通过自定义链接器脚本覆盖链接器默认配置,进而修改加载地址。0x08048000以下的地址空间通常由C动态链接库、动态加载器ld.so和内核VDSO(内核提供的虚拟共享库)等占用。通过使用mmap系统调用,可访问0x08048000以下的地址空间。
代码段(Text Segment):
代码段也称正文段或文本段,通常用于存放程序执行代码(即CPU执行的机器指令)。一般C语言执行语句都编译成机器代码保存在代码段。通常代码段是可共享的,因此频繁执行的程序只需要在内存中拥有一份拷贝即可。代码段通常属于只读,以防止其他程序意外地修改其指令(对该段的写操作将导致段错误)。
代码段指令根据程序设计流程依次执行,对于顺序指令,只会执行一次(每个进程);若有反复,则需使用跳转指令;若进行递归,则需要借助栈来实现。
代码段指令中包括操作码和操作对象(或对象地址引用)。若操作对象是立即数(具体数值),将直接包含在代码中;若是局部数据,将在栈区分配空间,然后引用该数据地址;若位于BSS段和数据段,同样引用该数据地址。
数据段(Data Segment):
数据段通常用于存放程序中已初始化且初值不为0的全局变量和静态局部变量。数据段属于静态内存分配(静态存储区),可读可写。
数据段保存在目标文件中(在嵌入式系统里一般固化在镜像文件中),其内容由程序初始化。例如,对于全局变量int gVar = 10,必须在目标文件数据段中保存10这个数据,然后在程序加载时复制到相应的内存。
BSS段(Block Started by Symbol Segment):
BSS段中通常存放未初始化的全局变量和静态局部变量,初始值为0的全局变量和静态局部变量(依赖于编译器实现)和未定义且初值不为0的符号(该初值即common block的大小)。
C语言中,未显式初始化的静态分配变量被初始化为0(算术类型)或空指针(指针类型)。由于程序加载时,BSS会被操作系统清零,所以未赋初值或初值为0的全局变量都在BSS中。BSS段仅为未初始化的静态分配变量预留位置,在目标文件中并不占据空间,这样可减少目标文件体积。但程序运行时需为变量分配内存空间,故目标文件必须记录所有未初始化的静态分配变量大小总和(通过start_bss和end_bss地址写入机器代码)。当加载器加载程序时,将为BSS段分配的内存初始化为0。
堆区(Heap):
堆用于存放进程运行时动态分配的内存段,可动态扩张或缩减。堆中内容是匿名的,不能按名字直接访问,只能通过指针间接访问。当进程调用malloc©/new(C++)等函数分配内存时,新分配的内存动态添加到堆上(扩张);当调用free©/delete(C++)等函数释放内存时,被释放的内存从堆中剔除(缩减) 。
分配的堆内存是经过字节对齐的空间,以适合原子操作。堆管理器通过链表管理每个申请的内存,由于堆申请和释放是无序的,最终会产生内存碎片。堆内存一般由应用程序分配释放,回收的内存可供重新使用。若程序员不释放,程序结束时操作系统可能会自动回收。
内存映射段(Memory Mapping Segment):
此处,内核将硬盘文件的内容直接映射到内存, 任何应用程序都可通过Linux的mmap()系统调用请求这种映射。内存映射是一种方便高效的文件I/O方式,因而被用于装载动态共享库。用户也可创建匿名内存映射,该映射没有对应的文件, 可用于存放程序数据。在 Linux中,若通过malloc()请求一大块内存,C运行库将创建一个匿名内存映射,而不使用堆内存。”大块”意味着比阈值 MMAP_THRESHOLD还大,缺省为128KB,可通过mallopt()调整。
该区域用于映射可执行文件用到的动态链接库。在Linux 2.4版本中,若可执行文件依赖共享库,则系统会为这些动态库在从0x40000000开始的地址分配相应空间,并在程序装载时将其载入到该空间。在Linux 2.6内核中,共享库的起始地址被往上移动至更靠近栈区的位置。
栈(Stack):
栈又称堆栈,由编译器自动分配释放,行为类似数据结构中的栈(先进后出)。堆栈主要有三个用途,为函数内部声明的非静态局部变量提供存储空间,记录函数调用过程相关的维护性信息,称为栈帧或过程活动记录。它包括函数返回地址,不适合装入寄存器的函数参数及一些寄存器值的保存。除递归调用外,堆栈并非必需。因为编译时可获知局部变量,参数和返回地址所需空间,并将其分配于BSS段。
临时存储区,用于暂存长算术表达式部分计算结果或函数分配的栈内内存。持续地重用栈空间有助于使活跃的栈内存保持在CPU缓存中,从而加速访问。进程中的每个线程都有属于自己的栈。向栈中不断压入数据时,若超出其容量就会耗尽栈对应的内存区域,从而触发一个页错误。