深入理解操作系统(21)第八章:异常控制流(2)系统调用和错误处理+进程控制(400个系统调用/errno/exit参数和返回值说明/fork/execve/waitpid/用户栈的组织结构)
1. 系统调用和错误处理
1.1 目前,linux提供了大约400个系统调用
Unix系统提供了大量的系统调用,当应用程序想向内核请求服务时,比如读取一个文件,或者创建一个新的进程,都可以使用这些系统调用。
例如,linux提供了大约160个系统调用(现在已经400个了)。输入"man syscalls",你将得到完整的列表。
[root@localhost 10]# man syscalls
SYSCALLS(2) Linux Programmer's Manual
NAME
syscalls - Linux system calls
……
──────────────────────────────────────────────────────
_llseek(2) 1.2
_newselect(2) 2.0
_sysctl(2) 2.0
accept(2) 2.0 See notes on socketcall(2)
accept4(2) 2.6.28
access(2) 1.0
acct(2) 1.0
add_key(2) 2.6.11
adjtimex(2) 1.0
alarm(2) 1.0
alloc_hugepages(2) 2.5.36 Removed in 2.5.44
bdflush(2) 1.2 Deprecated (does nothing)
…… ……
1.2 包装函数+errno检查
标准C库提供了一组针对最常用系统调用的方便的包装(wrapper)函数。包装函数将参数打好包,通过适当的系统调用陷入内核,然后系统调用的
返回状态传递给调用程序。在我们下面章节的讨论中,我们把系统调用和它们相关的包装函数可互换地称为系统级函数
当linux系统级数遇到错误时,它们典型地会返回一并设置全局整数变量errno来表示什么出错了。
程序员应该总是检查这些错误,但是不幸的是,许多人都忽略了错误检查,因为它使代码变得臃肿,而且难以读懂。
例子:
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
int main ()
{
//查看abc文件是否存在 成功0,失败-1
if(0 != access("abc",F_OK))
{
printf("errno=%d\n",errno);
printf("Error: %s\n", strerror(errno));
}
return(0);
}
结果:
errno=2
Error: No such file or directory
2. 进程控制
2.1 获取进程ID
每个进程都有一个惟一的正数(非零)进程ID(PID)。
getpid函数返回调用进程的PID
getppid函数返回它的父进程的PID〔也就是,创建调用进程的进程)。
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void);
pid_t getppid(void);
//pid_t 其实就是 int
2.2 创建进程 fork
从程寻员的角度,我们可以认为进程总是处于下面三种状态之一:
1. 运行:进程要么在CPU上执行,要么在等待被执行且最终会被调度。
2. 暂停:进程的执行被挂起,且不会被调度。
当收到SIGSTOP、SIGTSTP、SIDTTIN或者SIDTTOU信号时,进程就暂停,
并且保持暂停直到它收到一个SIGCONT信号,在这个时刻,进程再次开始运行。
3. 终止:进程永远地停止了。进程会因为3种原因终止:
收到一个信号,该信号的默认行为是终上进程
从主程序返回
调用exit函数
fork:
pid_t fork(void)
2.2.1 fork返回值
子进程返回0;
父进程返回子进程的pid;
错误返回-1
2.2.2 fork父子进程的异同
新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份拷贝,包括文本、数据和bss段、堆以及用户栈。子进程还获得与父进程任何打开文件述符相同的拷贝,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。
父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
fork函数是有趣的是它只被调用一次,却会返回两次:一次是在调用进程(父进程)中,一次是在新创建的子进程中。
在父进程中,fork返回子进程的PID。
在子进程中,fork返回零。
2.2.3 例子1:简单fork例子说明:
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <stdlib.h>
int main ()
{
pid_t pid;
int x = 1;
pid = fork();
if(0 == pid)
{
printf("child=%d\n",++x);
exit(0);
}
printf("parent=%d\n",--x);
exit(0);
}
结果:
[root@localhost 8]# ./a.out
parent=0
[root@localhost 8]# child=2
[root@localhost 8]#
2.2.4 fork 详细说明
这个简单的例子有一些微妙的方面:
1. 调用一次,返回两次
fork函数被父进程调用一次,但是却返回两次:一次是返回到父进程,一次是返回到新创建的子进程。
对于只创建一个子进程的程序来说,这还是相当简单的。
但是含有多个fork实例的程序可能就会令人迷惑,需要仔细地推敲了。
2. 并发执行
父进程和子进程是并发运行的独立进程。
内核能够以任意方式交替执行它们逻辑控制流中的指令。
当我们在系统上运行这个程序时,父进程先完成它的printf语句,然后是子进程。
然而,在另一个系统上可能正好相反。
一般而言,作为程序员,我们无法对不同进程中的指令交执行做任何假设。
3. 相同的但是独立的地址空间
如果我们能够在fork函数在父进程和子进程中返回后立即终止这两个进程,我们会看到每个进程的地址空间都是相同的。
每个进程有相同的用户栈、相同的本地变量值、相同的堆、相同的全局变量值,以及相同的代码。
因此,在我们的示例程序中,当fork函数返回时,本地变量x在父进程和子进程中都为1。
然而,因为父进程和子进程是独立的进程,它们每个都有自己的私有地址空间。
后面,父进程和子进程对x所做的任何改变都是独立的,不会反映在另一个进程的存储器中。
这就是为什么当父进程和子进程调用它们各自的printf函数时,它们中的变量x会有不同的值。
4. 共享文件
当我们运行示例程序时,我们注意到父进程和子进程都把它们的输出显示在屏幕上。
原因是子迅程继承了父进程所有的打开文件。当父进程调用fork时,stdout文件是被打开的,并指向屏幕。
了进程继承了这个文件,因此它的输出也是指向屏暮的。
关于fork有个cow
参考:Linux:COW 写时拷贝技术
https://blog.csdn.net/lqy971966/article/details/118784913
2.2.5 例子2:多个fork说明
图8.14
2.3 终止进程 exit
从程寻员的角度,我们可以认为进程总是处于下面三种状态之一:
1. 运行:进程要么在CPU上执行,要么在等待被执行且最终会被调度。
2. 暂停:进程的执行被挂起,且不会被调度。
当收到SIGSTOP、SIGTSTP、SIDTTIN或者SIDTTOU信号时,进程就暂停,
并且保持暂停直到它收到一个SIGCONT信号,在这个时刻,进程再次开始运行。
3. 终止:进程永远地停止了。进程会因为3种原因终止:
收到一个信号,该信号的默认行为是终上进程
从主程序返回
调用exit函数
2.3.1 exit函数
void exit(int status);
1.功能:
关闭所有文件,终止正在执行的进程。
2.参数:
exit以status退出状态来终止进程。
exit() 里面的参数,是传递给其父进程的
3.使用:
exit(0)表示正常退出
2.3.2 exit参数说明!!!
每个运行着的程序都是进程,而进程就会有父进程,父进程通常是直接启动你的进程,父进程死亡的进程会被 init 收养,其父进程变为 init,而 init 的父进程是进程 0,进程 0 则是系统启动时启动的第一个进程。
exit() 里面的参数,是传递给其父进程的。
对父进程来说,你的进程仿佛是一个函数,而函数可以有返回值。
2.3.3 为什么要使用 exit() 函数?
是历史原因,虽然现在大多数平台下,直接在 main() 函数里面 return 可以退出程序。但是在某些平台下,在 main() 函数里面 return 会导致程序永远不退出(因为代码已经执行完毕,程序却还没有收到要退出的指令)。
换句话说,为了兼容性考虑,在特定的平台下,程序最后一行必须使用 exit() 才能正常退出,
这是 exit() 存在的重要价值。
2.3.4 exit和return的区别:
按照ANSI C,在最初调用的main()中使用return和exit()的效果相同。
但要注意这里所说的是“最初调用”。
区别1:如果main()在一个递归程序中,exit()仍然会终止程序;
但return将控制权移交给递归的前一级,直到最初的那一级,此时return才会终止程序。
区别2:return和exit()的另一个区别在于,即使在除main()之外的函数中调用exit(),它也将终止程序。
2.5 回收子进程(僵尸进程+waitpid)
2.5.1 僵尸进程
当一个进程由于某种原因终上时,内核并不是立即把它从系统中清除。取而代之的是,进程被保持在一种终止状态中,直到被它的父进程回收(reaped)。当父送程回收己终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃己终止的进程,从此时开始该进程就不存在了。
一个终止了但还未被回收的进程称为僵死进程(zombie)
为什么已终止的子进被称为僵死进程?
在民间传说中,僵尸是活着的尸体,一种半生半死的实体。僵死进程已经终止了,而内核仍保留着它的某些状态(内核栈,进程号,hread_info结构和task_struct结构等)直到父进程回收它为止,从这个意义上说它们是类似的。
如果父进程没有回收它的僵死子程就终止了,那么内核就会安排init进程来回收它们。
init进程的PID为1,并且是在系统初始化时由内核创建的。长时间运行的程序,比如shell或者服务器,总是应该回收它们的僵死子进程。即使僵死子进程没有运行,它们仍然消耗系统的存储器资。
僵尸进程 & 孤儿进程
参考:
https://blog.csdn.net/lqy971966/article/details/119116896
2.5.1 waitpid
一个进程可以通过调用waitpid函数来等待它的子进程终止或者暂停。
pid_t waitpid(pid_t pid,int *status,int options)
说明:
1. 从本质上讲,系统调用waitpid和wait的作用是完全相同的。
wait就是经过包装的waitpid
2. 但waitpid多出了两个可由用户控制的参数pid和options,从而为我们编程提供了另一种更灵活的方式。
参数:
1. pid
pid>0时,只等待进程ID等于pid的子进程,其他不管
pid=-1时,等待任何一个子进程退出,没有任何限制
pid=0时,等待同一个进程组中的任何子进程
pid<-1时,等待一个指定进程组中的任何子进程,这个进程组的ID等于pid的绝对值
2. status 一般置0
参数status用来保存被收集进程退出时的一些状态,它是一个指向int类型的指针。
但如果我们对这个子进程是如何死掉的毫不在意,只想把这个僵尸进程消灭掉,
(事实上绝大多数情况下,我们都会这样想),我们就可以设定这个参数为NULL
3. options: options提供了一些额外的选项来控制waitpid
目前在Linux中只支持 WNOHANG 和 WUNTRACED 两个选项
可以通过用常量WNOHANG和WUNTRACED的不同组合来设置options,修改默认行为:
1. WNOHANG :如果没有等待集合中的任何子进程终止,那么就立即返回(返回值为0)0
2. WUNTRACED :挂起调用进程的执行,直到等待集合中的一个进程变成终止的或者被暂停。
返回的PID为导致返回的终止或暂停了进程的PID。
3. WNOHANG | WUNTRACED:立即返回,如果没有等待集合中的任何子进程停止或终上,
那么返回值为0,或者返回值等于那个被停止或者终止子进程的PID
返回值:
1. 成功返回子进程pid
2. 如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0
3. 错误,返回-1
wait 和 waitpid 详解及代码示例
参考:https://blog.csdn.net/lqy971966/article/details/110818165
2.6 让进程休眠(sleep pause)
unsigned int sleep(unsigned int secs)
参数:秒
返回:还要休眠的秒数
sleep就是阻塞式等待 线程中使用就卡死了
sleep返到0(如果请求的时间量己经到了),或者返回剩下的要休眠的秒数。
后一种情况是可能的,例如当sleep函数被一个信号中断过早返回时。
我们发現很有用的另一个函数是pause函数,该数让调用函数休眠,直到该进程收到一个信号为止。
int pause(void)
2.6.1 sleep单位, linux 秒,windows 毫秒
Windows:
Sleep(1); //停留1毫秒
Linux:
sleep(1); //秒
2.7 加载并运行程序
2.7.1 execve,参数列表和环境变量列表
execve函数在当前进程的上下文中加载并允许一个新程序。
#include<unistd.h>
int execve(char *filename, char *argv[], char *envp);
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。
只有当出现错误时,例如不能发现filename,execve才会返回到调用程序。
所以,不像fork会一次调用返回两次,execve调用一次并从不返回。
如图8.17所示,参数列表是用数据结构表示的。
argv变量指向一个以null结尾的指针数组,其中每个指针都指向一个参数串。按照习俗,argv[0]是可执行目标文件的名字。
图8.17
环境变量的列表是由一个类似的数据结构表示的,如图8.18所示。
envp变量指向一个以null结尾的指针数组,其中每个指针指向一个环境变量串,其中每个串都是形如"NAME=VALUE"的名字一值对。
int main(int argc, char *argv[], char *envp[]);
图8.18
2.7.2 用户栈的组织结构
当main开始在一个Linux系统上执行时,用户栈有如图8.19所示的组织。
让我们从栈底(高地址)往栈顶(低地址),依次看一看。
1. 首先是参数是环境字符串,它们都是连续地存放在栈中的,一个接一个,没有分隔。
2. 紧随其后,在栈的更上层里,是以null结尾的指针数组,其中每个指针都指向栈中的一个环境变量串。
全局变量environ指向这些指针中的第一个envp[0]。
3. 紧随环境变量数组其后的是以null结尾的argv[]数组,其中每个元素都指向栈中一个参数串。
4. 在栈的顶部是main函数的3个参数:
envp它指向envp[]数组;
argv它指向argv[]数组,
argc,它给出argv[]中非空指针的数量。
图8.19
2.7.3 程序与进程
这是一个适当的地方,停下来,确认一下你是否理解了程序和进程之间的区别。
1. 程序是代码和数据的集合;
程序可以作为目标模块存在于磁盘上,或者作为段存在于地址空间中。
2. 进程是执行中程序的一个特殊实例
程序总是运行在某个进程的上下文中。
如果你想要理解fork和execve函数,理解这个差异是很重要的。
fork函数在新的子进程中运行相同的程序,新的子进程是父进程的一个复制品。
execve函数在当前进裎的上下文中加载并运行一个新的程序,它会覆益当前迸程的地址空间,但并没有创建一个新进租。新的程序仍然有相同的PID,并且继承了调用execve函数时打开的所有文件描迷符。
2.7.4 execve 例子
参考:
Linux exec 系列函数:execl execv等
https://blog.csdn.net/lqy971966/article/details/110532621
linux system 和 execl 函数对比
https://blog.csdn.net/lqy971966/article/details/110532718