一、main函数由谁调用?
操作系统下的应用程序在运行 main() 函数之前需要先执行一段引导代码,最终由这段引导代码去调用应用 程序的 main() 函数,我们在编写应用程序的时候,不用考虑引导代码的问题,在编译链接时,由链接器将引导代码链接到我们的应用程序当中,一起构成最终的可执行文件。
再来看看 argc 和 argv 传参是如何实现的呢?譬如 ./app arg1 arg2 ,这两个参数 arg1 和 arg2 是如何传递给应用程序的 main 函数的呢?当在终端执行程序时,命令行参数( command-line argument )由 shell 进程逐一进行解析,shell 进程会将这些参数传递给加载器,加载器加载应用程序时会将其传递给应用程序引导代码,当引导程序调用 main()函数时,在由它最终传递给 main() 函数,如此一来,在我们的应用程序当中便可以获取到命令行参数了。
二、进程号
Linux 系统下的每一个进程都有一个进程号( processID ,简称 PID ),进程号是一个正数,用于唯一标识系统中的某一个进程。在 Ubuntu 系统下执行 ps 命令可以查到系统中进程相关的一些信息,包括每个进程的进程号。
三、进程的虚拟地址
在 Linux 系统中,采用了虚拟内存管理技术,事实上大多数现在操作系统都是如此!在 Linux 系统中,每一个进程都在自己独立的地址空间中运行,在 32 位系统中,每个进程的逻辑地址空间均为 4GB ,这 4GB 的内存空间按照 3:1 的比例进行分配,其中用户进程享有 3G 的空间,而内核独自享有剩下的 1G 空间。
虚拟地址会通过硬件 MMU (内存管理单元)映射到实际的物理地址空间中,建立虚拟地址到物理地址的映射关系后,对虚拟地址的读写操作实际上就是对物理地址的读写操作。
Linux 系统下,应用程序运行在一个虚拟地址空间中,所以程序中读写的内存地址对应也是虚拟地址,并不是真正的物理地址,譬如应用程序中读写 0x80800000 这个地址,实际上并不对应于硬件的 0x80800000这个物理地址。
为什么需要引入虚拟地址呢?
⚫ 内存使用效率低 。内存空间不足时,就需要将其它程序暂时拷贝到硬盘中,然后将新的程序装入内存。然而由于大量的数据装入装出,内存的使用效率就会非常低。
⚫ 进程地址空间不隔离 。由于程序是直接访问物理内存的,所以每一个进程都可以修改其它进程的内存数据,甚至修改内核地址空间中的数据,所以有些恶意程序可以随意修改别的进程,就会造成一些破坏,系统不安全、不稳定。
⚫ 无法确定程序的链接地址 。程序运行时,链接地址和运行地址必须一致,否则程序无法运行!因为程序代码加载到内存的地址是由系统随机分配的,是无法预知的,所以程序的运行地址在编译程序时是无法确认的。
针对以上的一些问题,就引入了虚拟地址机制,程序访问存储器所使用的逻辑地址就是虚拟地址,通过 逻辑地址映射到真正的物理内存上。所有应用程序运行在自己的虚拟地址空间中,使得进程的虚拟地址空 间和物理地址空间隔离开来,这样做带来了很多的 优点 :
⚫ 进程与进程、进程与内核相互隔离。一个进程不能读取或修改另一个进程或内核的内存数据,这是 因为每一个进程的虚拟地址空间映射到了不同的物理地址空间。提高了系统的安全性与稳定性。
⚫ 在某些应用场合下,两个或者更多进程能够共享内存。因为每个进程都有自己的映射表,可以让不 同进程的虚拟地址空间映射到相同的物理地址空间中。通常,共享内存可用于实现进程间通信。
⚫ 便于实现内存保护机制。譬如在多个进程共享内存时,允许每个进程对内存采取不同的保护措施, 例如,一个进程可能以只读方式访问内存,而另一进程则能够以可读可写的方式访问。
⚫ 编译应用程序时,无需关心链接地址。前面提到了,当程序运行时,要求链接地址与运行地址一致,在引入了虚拟地址机制后,便无需关心这个问题。
四、环境变量的作用
环境变量常见的用途之一是在 shell 中,每一个环境变量都有它所表示的含义,譬如 HOME 环境变量表示用户的家目录,USER 环境变量表示当前用户名, SHELL 环境变量表示 shell 解析器名称, PWD 环境变量表示当前所在目录等,在我们自己的应用程序当中,也可以使用进程的环境变量。
五、fork()创建子进程
一个现有的进程可以调用 fork() 函数创建一个新的进程,调用 fork() 函数的进程称为父进程,由 fork() 函数创建出来的进程被称为子进程(child process ), fork() 函数原型如下所示( fork() 为系统调用):
#include <unistd.h>
pid_t fork(void);
理解 fork() 系统调用的关键在于,完成对其调用后将存在两个进程,一个是原进程(父进程)、另一个 则是创建出来的子进程,并且每个进程都会从 fork() 函数的返回处继续执行,会导致调用 fork() 返回两次值, 子进程返回一个值、父进程返回一个值。在程序代码中,可通过返回值来区分是子进程还是父进程。 fork()调用成功后,将会在父进程中返回子进程的 PID ,而在子进程中返回值是 0 ;如果调用失败,父进 程返回值-1 ,不创建子进程,并设置 errno 。
fork() 调用成功后,子进程和父进程会继续执行 fork() 调用之后的指令,子进程、父进程各自在自己的进程空间中运行。事实上,子进程是父进程的一个副本 ,譬如子进程拷贝了父进程的数据段、堆、栈以及继承 了父进程打开的文件描述符,父进程与子进程并不共享这些存储空间,这是子进程对父进程相应部分存储空间的完全复制,执行 fork() 之后,每个进程均可修改各自的栈数据以及堆段中的变量,而并不影响另一个进程。
虽然子进程是父进程的一个副本,但是对于程序代码段(文本段)来说,两个进程执行相同的代码段, 因为代码段是只读的,也就是说父子进程共享代码段,在内存中只存在一份代码段数据。
fork() 函数调用完成之后,父进程、子进程会各自继续执行 fork() 之后的指令,最终父进程会执行到 exit() 结束进程,而子进程则会通过**_exit()** 结束进程。
fork() 函数使用场景
fork() 函数有以下两种用法:
⚫ 父进程希望子进程复制自己,使父进程和子进程同时执行不同的代码段。这在网络服务进程中是常 见的,父进程等待客户端的服务请求,当接收到客户端发送的请求事件后,调用 fork() 创建一个子 进程,使子进程去处理此请求、而父进程可以继续等待下一个服务请求。
⚫ 一个进程要执行不同的程序。譬如在程序 app1 中调用 fork() 函数创建了子进程,此时子进程是要 去执行另一个程序 app2 ,也就是子进程需要执行的代码是 app2 程序对应的代码,子进程将从 app2 程序的 main 函数开始运行。这种情况,通常在子进程从 fork() 函数返回之后立即调用 exec 族函数来实现
六、系统调用****vfork()
除了 fork() 系统调用之外, Linux 系统还提供了 vfork() 系统调用用于创建子进程, vfork() 与 fork() 函数在 功能上是相同的,并且返回值也相同,在一些细节上存在区别。
之前可以将 fork() 认作对父进程的数据段、堆段、栈段以及其它一些数据结构创建拷贝,由此可以看出,使用 fork() 系统调用的代价是很大的,它复制了父进程中的数据段和堆栈段中的绝大部分内容,这将会消耗比较多的时间,效率会有所降低,而且太浪费,原因有很多,其中之一在于,fork() 函数之后子进程通常会调用 exec 函数,也就是 fork() 第二种使用场景下,这使得子进程不再执行父程序中的代码段,而是执行新程序的代码段,从新程序的 main 函数开始执行、并为新程序重新初始化其数据段、堆段、 栈段等;那么在这种情况下,子进程并不需要用到父进程的数据段、堆段、栈段(譬如父程序中定义的局部变量、全局变量等)中的数据,此时就会导致浪费时间、效率降低。
引入了 vfork() 系统调用,虽然在一些细节上有所不同,但其效率要高于 fork() 函数。类似于 fork() , vfork() 可以为调用该函数的进程创建一个新的子进程,然而, vfork() 是为子进程立即执行 exec() 新的程序而专门设计的,也就是 fork() 函数的第二个使用场景。
vfork()与 fork()函数主要有以下两个区别:
⚫ vfork() 与 fork() 一样都创建了子进程,但 vfork() 函数并不会将父进程的地址空间完全复制到子进程中,因为子进程会立即调用 exec (或 _exit ),于是也就不会引用该地址空间的数据。不过在子进程调用 exec 或 _exit 之前,它在父进程的空间中运行、子进程共享父进程的内存。这种优化工作方式 的实现提高的效率;但如果子进程修改了父进程的数据(除了 vfork 返回值的变量)、进行了函数
调用、或者没有调用 exec 或 _exit 就返回将可能带来未知的结果。
⚫ 另一个区别在于, vfork() 保证子进程先运行,子进程调用 exec 之后父进程才可能被调度运行。 虽然 vfork() 系统调用在效率上要优于 fork() ,但是 vfork() 可能会导致一些难以察觉的程序 bug ,所以尽 量避免使用 vfork() 来创建子进程,虽然 fork() 在效率上并没有 vfork() 高,但是现代的 Linux 系统内核已经采 用了写时复制技术来实现 fork() ,其效率较之于早期的 fork() 实现要高出许多,除非速度绝对重要的场合, 我们的程序当中应舍弃 vfork() 而使用 fork() 。
七、进程的诞生与终止
进程的诞生
一个进程可以通过 fork() 或 vfork() 等系统调用创建一个子进程,一个新的进程就此诞生!事实上, Linux 系统下的所有进程都是由其父进程创建而来。
进程号为 1 的进程便是所有进程的父进程,通常称为 init 进程,它是 Linux 系统启动之后运行的第一个进程,它管理着系统上所有其它进程,init 进程是由内核启动,因此理论上说它没有父进程。
进程的终止
通常,进程有两种终止方式:异常终止和正常终止
进程的正常终止有多种不同的方式,譬如在 main 函数中使用 return 返回、调用 exit() 函数结束进程、 调用_exit() 或 _Exit() 函数结束进程等。
exit() 函数会比 _exit() 会多做一些事情,包括执行终止处理函数、刷新 stdio 流缓冲以及调用_exit() ,在我们的程序当中,父、子进程不应都使用 exit() 终止,只能有一个进程使用 exit()、而另一个则使用 _exit() 退出,当然一般推荐的是子进程使用 _exit() 退出、而父进程则使用 exit() 退出。其原因就在于调用 exit() 函数终止进程时会刷新进程的 stdio 缓冲区。
异常终止通常也有多种不同的方式,譬如在程序当中调用 abort() 函数异常终止进程、当进程接收到某些 信号导致异常终止等。
八、监视子进程
在很多应用程序的设计中,父进程需要知道子进程于何时被终止,并且需要知道子进程的终止状态信息,是正常终止、还是异常终止亦或者被信号终止等,意味着父进程会对子进程进行监视。
1、wait()函数
系统调用 wait() 可以等待进程的任一子进程终止,同时获取子进程的终止状态信息。系统调用 wait()将执行如下动作:
⚫ 调用 wait() 函数,如果其所有子进程都还在运行,则 wait() 会一直阻塞等待,直到某一个子进程终止;
⚫ 如果进程调用 wait() ,但是该进程并没有子进程,也就意味着该进程并没有需要等待的子进程,那 么 wait() 将返回错误,也就是返回 -1 、并且会将 errno 设置为 ECHILD 。
⚫ 如果进程调用 wait() 之前,它的子进程当中已经有一个或多个子进程已经终止了,那么调用 wait() 也不会阻塞。wait() 函数的作用除了获取子进程的终止状态信息之外,更重要的一点,就是回收子 进程的一些资源,俗称为子进程"收尸"。所以在调用 wait() 函数之前,已经有子进程终止了,意味着正等待着父进程为其"收尸",所以调用 wait() 将不会阻塞,而是会立即替该子进程"收尸"、处理它的"后事",然后返回到正常的程序流程中,一次 wait() 调用只能处理一次。
2、waitpid() 函数
使用 wait() 系统调用存在着一些限制,这些限制包括如下:
⚫ 如果父进程创建了多个子进程,使用 wait() 将无法等待某个特定的子进程的完成,只能按照顺序等待下一个子进程的终止,一个一个来、谁先终止就先处理谁;
⚫ 如果子进程没有终止,正在运行,那么 wait() 总是保持阻塞,有时我们希望执行非阻塞等待,是否有子进程终止,通过判断即可得知;
⚫ 使用 wait() 只能发现那些被终止的子进程,对于子进程因某个信号(譬如 SIGSTOP 信号)而停止(注意,这里停止指的暂停运行),或是已停止的子进程收到 SIGCONT 信号后恢复执行的情况就无能为力了。
而设计 waitpid() 则可以突破这些限制
3、 SIGCHLD 信号
⚫ 当父进程的某个子进程终止时,父进程会收到 SIGCHLD 信号;
⚫ 当父进程的某个子进程因收到信号而停止(暂停运行)或恢复时,内核也可能向父进程发送该信号。