一、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
信号;
⚫
当父进程的某个子进程因收到信号而停止(暂停运行)或恢复时,内核也可能向父进程发送该信号。