Linux:Linux进程信号
一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。比如,如图8-26展示了Linux系统上支持的30种不同类型的信号。
每种信号类型都对应于某种系统事件 。 低层的硬件异常是由内核异常处理程序处理的,正常情况下, 对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。比如,如果一个进程试图除以0,那么内核就发送给它一个SIGFPE信号 ( 号码 8 ) ,那么进程对这个信号的默认处理行为就是终止进程并转储内存。 如果一个进程执行一条非法指令,那么内核就发送给它一个SIGILL信号 ( 号码 4 ) ,那么进程对这个信号的默认处理行为就是终止进程。如果进程进行非法内存引用 ,内核就发送给它一个SIGSEGV信号 ( 号码 11),那么进程对这个信号的默认处理行为就是终止进程并转储内存。其他信号对应于内核或者其他用户进程中较高层的软件事件 。比如 ,如果当进程在前台运行时 , 你键人Ctrl+C ( 也就是同时按下 Ctrl键和 C 键 ),那么内核就会发送一个 SIGINT 信号 ( 号码 2 ) 给这个前台进程组中的每个进程 。 一个进程可以通过向另一个进程发送一个 SIGKILL 信号 ( 号码 9 ) 强制终止它 。 当一个子进程终止或者停止时 , 内核会发送一个 SIGCHLD 信号 ( 号码 17 ) 给父进程 。
前台进程:是在终端中运行的命令,那么该终端就为进程的控制终端,一旦这个终端关闭,这个进程也随之消失。
后台进程:也叫守护进程(Daemon),是运行在后台的一种特殊进程,不受终端控制,它不需要终端的交互,所以后台进程不可以用ctrl+c终止掉,只能把他变成前台进程才可以ctrl+c终止掉或者用kill命令。前台进程与后台进程的切换:
& :用在一个命令(包括我们写的可执行程序)的最后,可以把这个命令放到后台执行
jobs:可以用来查看后台进程的工作号和工作状态
fg:(front ground)加上后台进程的工作号可以把后台进程变成前台进程
ctrl + z:把前台进程暂停,放到后台,回到shell终端
bg:(back ground)把暂停的前台进程变成后台进程前台进程和后台进程在Shell终端的特点:
ctrl+c 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 ctrl+c这种控制键产生的信号。
前台进程在运行过程中用户随时可能按下 ctrl+c 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。
其他相关概念:
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
signal函数: typedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler);
- 功能:对指定信号signum进行捕捉
- 头文件:#include
- 参数: sighandler_t signal(int signum, sighandler_t handler);
- signum:要捕捉的信号编号(9号信号SIGKILL是不可以被捕捉的)
- handler:如果handler是SIG _ DFL , 那么编号为 signum的信号处理行为恢复为默认行为 ;如果handler是SIG _ IGN , 那么编号为 signum的信号处理行为恢复为忽略;否则 ,handler就是用户定义的函数的地址 ,这个函数被称为信号处理程序 , 只要进程接收到一个编号为signum的信号 ,就会调用这个程序 。 通过把处理程序的地址传递到signal函数从而改变默认行为 , 这叫做设置信号处理程序 ( installing the handler ) 。调用信号处理程序被称为捕获信号 。 执行信号处理程序被称为处理信号 。
- 返回值:signal()返回信号处理程序的上一个值,或出错时返回SIG_ERR。如果发生错误,设置errno以指示原因。
实例演示:
#include
#include #include using namespace std; void handler(int signo) { cout << "我是一个进程,刚刚获取了一个信号:" << signo << endl; } int main() { // 这里不是调用handler方法,这里只是设置了一个回调方法,只有当SIGINT信号产生时,该方法才会被调用 // 如果不产生SIGINT信号,该方法是不会被调用的 // ctrl + c: 本质就是给前台进程发送2号信号SIGINT,之前2号信号的处理是终止自己,现在是设置了用户自定义处理动作handler函数 signal(SIFINT, handler);// 9号信号SIGKILL是不可以被捕捉的 sleep(3); cout << "进程已经设置完了" << endl; sleep(3); while (true) { cout << "我是一个正在运行的进程:" << getpid() << endl; sleep(1); } return 0; } 可以看出,我们ctrl+c不可以终止进程了,而是执行的是handler方法。
硬件产生:
- 键盘产生:
- ctrl + c:SIGINT(2),发送给前台进程
- ctrl + z:SIGTSTP(20),发送给前台进程,一般不用,除非有特定场景
- ctrl + \:SIGQUIT(3),产生core dump文件
- 硬件异常产生:
- 除零错误:CPU内部存在一个 状态寄存器(硬件),当我们出0的时候,CPU内的状态寄存器会被设置,有报错:浮点数越界OS就会识别到CPU内有报错。 OS知道是状态寄存器报错 -> 报浮点数错误(OS->构建信号) -> OS向目标进程发送信号 -> 目标进程在合适的时候处理信号 -> 默认处理行为终止进程。
- 无效内存的引用:我们在语言层面使用的地址(指针), 其实都是这样一个流程:虚拟地址 -> 物理地址 -> 物理内存 -> 读取对应的数据和代码的。如果虚拟地址有问题,地址翻译的工作是由MMU(Memory Managemet Unit,内存控制单元,硬件)+页表(Page Table,软件))配合完成的, 翻译过程就会引起问题(野指针的访问,数组的越界…),表现在硬件MMU上。当MMU报错时,OS发现硬件MMU出现了问题 -> 报段错误(OS->构建信号) -> OS向目标进程发送信号 -> 目标进程在合适的时候处理信号 -> 默认处理行为终止进程。
- … …硬件错误
软件产生:
调用系统函数:
kill函数:int kill(pid_t pid, int sig);
功能:向任意进程发送任意信号
头文件:#include
#include 参数:int kill(pid_t pid, int sig);
pid:目的进程pid
sig:向目的进程发送信号的编号
返回值:success return 0,error return -1
#include
#include #include #include #include #include using namespace std; // kill 9 1234 void Usage(const string& str) { cerr << "Usage:\n\t" << str << "signo pid" << endl; } int main(int argc, char *argv[]) { if (argc != 3) { Usage(argv[0]); exit(1); } if (kill((pid_t)stoi(argv[2]), stoi(argv[1])) == -1) { cerr << "kill error: " << strerror(errno) << endl; exit(2); } return 0; } raise函数:int raise(int sig);
功能:给调用raise函数的进程发送任意信号
头文件:#include
参数:int raise(int sig);
- sig:要发送信号的编号
返回值:success return 0,error return -1
abort函数:void abort(void);
- 功能:向调用abort函数的进程发送SIGABRT信号,即6号信号,可以终止进程,和exit的最终进程一样,都可以终止进程
- 头文件:#include
- 参数:无参
- 返回值:无返回值
alarm函数:unsigned int alarm(unsigned int seconds);
功能:闹钟-设置用于发送信号(SIGALRM,14号信号)的闹钟,该信号可以终止进程
头文件:#include
参数:unsigned int alarm(unsigned int seconds);
- seconds:设置闹钟的时间seconds
返回值:alarm()返回任何先前计划的警报到期之前的剩余秒数如果没有先前计划的警报,则为零
- kill命令:kill -[信号编号] pid
进程控制中讲进程等待中,我们在进程异常退出的时候status的第7个比特位谈到了core dump标志,如下图:
正常退出时,status的低16位的状态:
异常退出时,status的低16位的状态:
进程在收到3、4、6、8、11信号的时候code dump标志位会置1,并且会在当前路劲下形成一个core.pid文件,这个pid就是引起产生core文件进程的怕pid。OS会把进程在运行中,对应异常上下文数据,core dump到磁盘上,方便调试(必须配合-选项使用)。但是在生产环境上,core dump一般会被关掉,可以用ulimit -a 查看
*
ulimit -c size 可以打开core dump,size是设置的可以形成多少字节个core-file文件。如何利用core-file文件定位问题:
- gdb 目标进程,回车
- 输入core-file core.pid,回车
- 然后直接定位到问题
task_struct中的三个数据结构:
pending表(未决表,是一个32位的位图结构):pending表中的第几个比特位代表着几号信号,对应比特位的内容代表是否有该信号产生,0表示否,1表示是。
如上图,表示2和4信号产生了,但是还没有处理。
handler表(是一个函数指针数组):数组下标index+1对应的就是处理index+1号信号的方式,一共有三种处理行为:SIG_DFL该信号的默认处理行为,SIG_IGN忽略该信号,自定义信号处理行为。
如上图,处理1号信号的行为位默认行为,2信号同样如此,而3号信号的处理行为为忽略。
block表(阻塞表,也是一个32位的位图结构):block表中的第几个比特位代表着几号信号,对应比特位的内容代表是否阻塞该信号(是否让该信号递达,完成处理动作),0表示否,1表示是。
如上图,表示2和4号信号被阻塞了,表示2信号和4号信号产生了也不可能被递达。
理解信号处理的过程:
根据上图分析:进程在查看OS是否给自己发送信号的时候,首先要查看block表,比如查看到SIGHUP在block表中的比特位内容,进程发现是0,所以该信号没有被阻塞,然后他会查看pending表,看看OS是否给他发送了该信号,如果对应pending中与该信号SIGHUP对应比特位的内容为1,说明OS向该进程发送了该信号,然后OS会在合适的时间,查看handler表获取处理该信号的行为是哪一种并处理该信号。比如在分析2号信号SIGINT,如上图,该信号对应block表中的比特位为1,说明该信号被阻塞了,然后查看pending表发现OS也向该进程发送了该信号,但是由于被阻塞了,所以OS不会递达该信号,除非该信号解除阻塞。
从上图(pending表)来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。
因此,未决和阻塞标志可以用相同的数据类型sigset_t(unsigned long int,4字节)来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。下一节将详细介绍信号集的各种操作。 阻塞信号集也叫做当
前进程的信号屏蔽字(Signal Mask),这里的**“屏蔽”应该理解为阻塞而不是忽略**,忽略是一种处理信号的行为,而阻塞是不让OS处理信号。所以再次以后,我们把block表叫做阻塞信号集或者信号屏蔽字,把pending表叫做未决信号集或者pending信号集。
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统。
实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的#include
int sigemptyset(sigset_t *set); int sigfillset(sigset_t *set); int sigaddset (sigset_t *set, int signo); int sigdelset(sigset_t *set, int signo); int sigismember(const sigset_t *set, int signo); // 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。 // 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置1,表示 该信号集的有效信号包括系统支持的所有信号。 // 注意,在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信这四个函数都是成功返回0,出错返回-1。 // sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。 sigpromask函数: int sigpromask(int how, const sigset_t *set, sigset_t *oset);
功能:可以获取或者更改进程的信号屏蔽字(阻塞信号集)
头文件:#include
参数:
how:信号屏蔽字做什么操作,有以下三种方式可以选择
SIG_BLOCK set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask | set SIG_UNBLOCK set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask & (~set) SIG_SETMASK 设置当前信号屏蔽字为set所指向的值,相当于mask=set set:输入型参数,要修改信号的信号屏蔽字
oset:输出型参数,输出之前还没有修改的信号屏蔽字
返回值:success return 0,error return -1。
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递
达。sigpending函数: int sigpending(sigset_t *set);
- 功能:获取当前进程的pending信号集(未决信号集)
- 头文件:#include
- 参数:
- set:输出型参数,输出未决信号集
- 返回值:success return 0,error return -1。
利用上面所学的函数编写代码:
#include
#include #include using namespace std; void handler(int signo) { sleep(1); cout << "我是一个进程,刚刚获取了一个信号:" << signo << endl; } void ShowPending(sigset_t *pendings) { for(int i = 1; i <= 31; ++i) { if (sigismember(pendings, i)) { cout << "1"; } else { cout << "0"; } } cout << endl; } int main() { cout << "pid : " << getpid() << endl; sleep(3); // 1.定义信号集 sigset_t bsig, obsig; // 2.初始化信号集 sigemptyset(&bsig); sigemptyset(&obsig); // 3.向信号集中添加信号并捕捉自定义信号处理行为 for (int i = 0; i <= 31; ++i) { sigaddset(&bsig, i); signal(i, handler); } // 4.设置用户级的信号屏蔽字到内核中,让当前进程阻塞1->31号信号 sigprocmask(SIG_SETMASK, &bsig, &obsig); // 5.获取并打印pending信号集 int cnt = 0; sigset_t pendings; while (true) { sigemptyset(&pendings); if (sigpending(&pendings) == 0) { ShowPending(&pendings); } sleep(1); cnt++; if (cnt == 20) { cout << "解除对所有信号的block...." << endl; sigprocmask(SIG_UNBLOCK, &obsig, nullptr); break; } } return 0; } 可以发现,9号信号是不可以被阻塞的。
OS处理信号有可能不会立即处理,而是在合适的时候被处理,这个合适的时候是什么时候呢?
答案是当当前进程从内核模式切换会用户模式的时候,进行信号的检测与处理。
用户模式与内核模式:
为了使操作系统内核提供一个无懈可击的进程抽象 , 处理器必须提供一种机制 , 限制一个应用可以执行的指令以及它可以访问的地址空间范围 。处理器通常是用某个控制寄存器中的一个模式位 ( mode bit ) 来提供这种功能的 , 该寄存器描述了进程当前享有的特权 。 当设置了模式位时 , 进程就运行在内核模式中 ( 有时叫做超级用户模式 ) 。 一个运行在内核模式的进程可以执行指令集中的任何指令 , 并且可以访问系统中的任何内存位置 。没有设置模式位时 , 进程就运行在用户模式中 。 用户模式中的进程不允许执行特权指令( privileged instruction ) , 比如停止处理器 、 改变模式位 , 或者发起一个I/O操作 。 也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据 。 任何这样的尝试都会导致致命的保护故障 。 反之 , 用户程序必须通过系统调用接口间接地访问内核代码和数据 。
运行应用程序代码的进程初始时是在用户模式中的 。 进程从用户模式变为内核模式的唯一方法是通过诸如中断 (时间中断,时间片结束)、 故障或者陷阱(陷入系统调用接口)这样的异常 。 当异常发生时 , 控制传递到异常处理程序 , 处理器将模式从用户模式变为内核模式 。 处理程序运行在内核模式中 ,当它返回到应用程序代码时 , 处理器就把模式从内核摸式改回到用户模式 。
系统调用的本质:
系统调用的本质是一种异常,它是一种有意而为之的异常。当调用一个系统调用时会触发 CPU 异常,CPU 进入异常处理流程。CPU 在异常处理流程中可以识别到本次异常是由于系统调用引起的,从而进入系统调用的异常处理流程中。
从程序员的角度来看 , 系统调用和普通的函数调用是一样的 。 然而 , 它们的实现非常不同 。 普通的函数运行在用户模式中 , 用户模式限制了函数可以执行的指令的类型 , 而且它们只能访问与调用函数相同的栈 。 系统调用运行在内核模式中 , 内核模式允许系统调用执行特权指令 , 并访问定义在内核中的栈 。
sigaction函数:int sigaction(int sigon, const struct sigaction *act, struct sigaction *oact);
将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号;赋值为常数SIG_DFL表示执行系统默认动作;否则 , handler 就是用户定义的函数的地址 , 这个函数被称为信号处理程序 , 只要进程接收到一个编号为 signum 的信号 , 就会调用这个程序 。 通过把处理程序的地址传递到 signal 函数从而改变默认行为 , 这叫做设置信号处理程序 (installing the handler ) 。 调用信号处理程序被称为捕获信号 。 执行信号处理程序被称为**。**
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags字段包含一些选项,本章的代码都
把sa_flags设为0,sa_sigaction是实时信号的处理函数,本章不详细解释这两个字段,有兴趣的同学可以在了解一下。
- 功能:sigaction函数可以读取和修改与指定信号相关联的处理动作。
- 头文件:#include
- 参数:
- signo:要处理信号的编号
- act:若act指针非空,则根据act修改该信号的处理动作
- oact:若oact指针非 空,则通过oact传出该信号原来的处理动作
- 返回值:success return 0,error return -1
利用sigaction函数编写代码:
#include
#include #include using namespace std; void handler(int signo) { int cnt = 8; while (cnt--) { cout << "我是一个进程,刚刚获取了一个信号:" << signo << endl; sleep(1); } } int main() { cout << "pid : " << getpid() << endl; struct sigaction act, oact; act.sa_handler = handler; act.sa_flags = 0; sigemptyset(&act.sa_mask); sigaddset(&act.sa_mask, 3); // 处理信号2的时候也要阻塞信号3,处理完以后2、3信号取消阻塞 sigaction(2, &act, &oact); while (true) { cout << "main running" << endl; sleep(1); } return 0; } 发送了三个信号,每次把当前信号处理完以后才解除阻塞处理下一个信号。
进程控制中讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进
程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父
进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号
的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作
置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽
略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。
信号的一个与直觉不符的方面是未处理的信号是不排队的 。 因为pending信号集中每种类型的信号只对应有一位 , 所以每种类型最多只能有一个未处理的信号 。 因此 , 如果
两个类型k的信号发送给一个目的进程 , 而因为目的进程当前正在执行信号k的处理程序 , 所以信号k被阻塞了 , 那么第二个信号就简单地被丢弃了 ; 它不会排队 。 关键思想是
如果存在一个未处理的信号就表明至少有一个信号到达了 。