前言
信号机制是 Linux 0.11 为进程提供的一套"局部的类中断机制",即在进程执行的过程
中,如果系统发现某个进程接收到了信号,就暂时打断进程的执行,转而去执行该进程的信
号处理程序,处理完毕后,再从进程"被打断"之处继续执行。
一、信号说明
1、信号支持的机制
系统需要具备以下三个功能,以支持信号机制。
- 系统要支持进程对信号的发送和接收
系统在每个进程 task_struct 中都设置了用以接收信号的数据成员 signal(信号位图),每个进程接收到的信号就"按位"存储在这个数据结构中。系统支持两种方式给进程发送信号:
- 一种方式是一个进程通过调用特定的库函数给另一个进程发送信号;
- 另一种方式是用户通过键盘输入信息产生键盘中断后,中断服务程程序给进程发送信号。
这两种方式的信号发送原理是相同的,都是通过设置信号位图(signal)上的信号位来实现的。本实例将结合第一种方式,即一个进程给另一个进程发送信号来展现系统对信号的发送和接收
2.系统要能够及时检测到进程接收到的信号
系统通过两种方式来检测进程是否接收到信号 :
- 一种方式是在系统调用返回之前检测当前进程是否接收到信号;
- 另一种方式是时钟中断产生后,其中断服务程序执行结束之前检测当前进程是否接收到信号。
这两种信号检测方式大体类似。本实例将结合第一种方式来展现系统对进程接收到的信号的检测。
- 系统要支持进程对信号进行处理
- 系统要能够保证,当用户进程不需要处理信号时,信号处理函数完全不参与用户进程的执行;当用户进程需要处理信号时,进程的程序将暂时停止执行,转而去执行信号处理函数,待信号处理函数执行完毕后,进程程序将从"暂停的现场处"继续执行。
2、signal.c 文件说明
signal.c 程序涉及内核中所有有关信号处理的函数。在 UNIX 系统中,信号是一种"软件中断"处理机制。有许多较为复杂的程序会使用到信号。信号机制提供了一种处理异步事件的方法。例如,用户在终端键盘上键入 ctrl-C 组合键来终止一个程序序的执行。该操作就会产生一个 SIGINT(Signal Interrupt)信号,并被发送到当前前台执行的进程中;当进程设置的一个报警时钟到期时,系统就会向进程发送一个 SIGALRM 信号;当发生硬件异常时,系统也会向正在执行的进程发送相应的信号。另外,一个进程也可以向另一个进程发送信号。例如使用 kill() 函数向同组的子进程发送终止执行信号。
信号处理机制在很早的 UNIX 系统中就已经有了,但那些早期 UNIX 内核中信号处理的方法并不是那么可靠。信号可能会被丢失,而且在处理紧要区域代码时进程有时很难关闭一个指定的信号,后来 POSIX 提供了一种可靠处理信号的方法。为了保持兼容性,本程序中还是提供了两种处理信号的方法。
在内核代码中通常使用一个无符号长整数(32 位)中的比特位来表示各种不同信号。因此最多可表示 32 个不同的信号。在本版 Linux 内核中,定义了 22 种不同的信号。其中 20 种信号是 POSIX.1 标准中规定的所有信号,另外 2 种是 Linux 的专用信号:SIGUNUSED(未定义)和 SIGSTKFLT(堆栈错),前者可表示系统目前还不支持的所有其他信号种类。这 22 种信号的具体名称和定义可参考程序后的信号列表,也可参阅 include/signal.h 头文件。
对于进程来说,当收到一个信号时,可以由三种不同的处理或操作方式。
1. 忽略该信号。大多数信号都可以被进程忽略。但有两个信号忽略不掉:SIGKILL 和 SIGSTOP。其原因是为了向超级用户提供一个确定的方法来终止或停止指定的任何进程。另外,若忽略掉某些硬件异常而产生的信号(例如被 0 除),则进程的行为或状态就可能变得不可知了。
2.捕获该信号。为了进行该操作,我们必须首先告诉内核在指定的信号发生时调用我们自定义的信号处理函数。在该处理函数中,我们可以做任何操作,当然也可以什么不做,起到忽略该信号的同样作用。自定义信号处理函数来捕获信号的一个例子是:如果我们在程序执行过程中创建了一些临时文件,那么我们就可以定义一个函数来捕获 SIGTERM(终止执行)信号,并在该函数中做一些清理临时文件的工作。SIGTERM 信号是 kill 命令发送的默认信号。
3.执行默认操作。内核为每种信号都提供一种默认操作。通常这些默认操作就是终止进程的执行。参
见程序后信号列表中的说明。
本程序给出了:(1)设置和获取进程信号阻塞码(屏蔽码)系统调用函数 sys_ssetmask() 和 sys_sgetmask() ,(2)信号处理系统调用 sys_signal() (即传统信号处理函数 signal()),(3)修改进程在收到特定信号时所采取的行动的系统调用 sys_sigaction()(既可靠信号处理函数 sigaction()),(4)以及在系统调用中断处理程序中处理信号的函数 do_signal()。有关信号操作的发送信号函数 send_sig() 和通知父进程函数 tell_father() 则被包含在另一个程序(exit.c)中。程序中的名称前缀 sig 均是信号 signal 的简称。
2.1 signal 函数
signal()和 sigaction()的功能比较类似,都是更改信号原处理句柄(handler ,或称为处理程序)。但 signal() 就是内核操作上述传统信号处理的方式,在某些特殊时刻可能会造成信号丢失。当用户想对特定信号使用自己的信号处理程序(信号句柄)时,需要使用 signal() 或 sigaction() 系统调用首先在进程自己的任务数据结构中设置 sigaction[] 结构数组项,把自身信号处理程序的指针和一些属性"记录"在该结构项中。当内核在退出一个系统调用和某些中断过程时会检测当前进程是否收到信号。若收到了用户指定的特定信号,内核就会根据进程任务数据结构中 sigaction[] 中对应信号的结构项执行用户自己定义的信号处理服务程序。
// 在 include/signal.h 头文件第 55 行上,signal()函数原型声明如下 void (*signal(int signr, void (*handler)(int)))(int);
这个 signal() 函数有两个参数。一个指定需要捕获的信号 signr;另外一个是新的信号处理函数指针(新的信号处理句柄)void (*handler)(int)。
新的信号处理句柄是一个无返回值且具有一个整型参数的函数指针,该整型参数用于当指定信号发生时内核将其传递给处理句柄。
signal() 函数的原型声明看上去比较复杂,但是若我们定义一个如下类型:
typedef void sigfunc(int);
那么我们可以把 signal()函数的原型改写成下面的简单样子:
sigfunc *signal(int signr, sigfunc *handler);
signal() 函数会给信号值是 signr 的信号安装一个新的信号处理函数句柄 handler,该信号句柄可以是用户指定的一个信号处理函数,也可以是内核提供的特定的函数指针 SIG_IGN 或 SIG_DFL。
当指定的信号到来时,如果相关的信号处理句柄被设置成 SIG_IGN,那么该信号就会被忽略掉。如果信号句柄是 SIG_DFL,那么就会执行该信号的默认操作。否则,如果信号句柄被设置成用户的一个信号处理函数,那么内核首先会把该信号句柄被复位成其默认句柄,或者会执行与实现相关的信号阻塞操作,然后会调用执行指定的信号处理函数。
signal() 函数会返回原信号处理句柄,这个返回的句柄也是一个无返回值且具有一个整型参数的函数指针。并且在新句柄被调用执行过一次后,信号处理句柄又会被恢复成默认处理句柄值 SIG_DFL。
在 include/signal.h 文件中(第 45 行起),默认句柄 SIG_DFL 和忽略处理句柄 SIG_IGN 的定义是:
#define SIG_DFL ((void (*)(int))0) /* default signal handling */ #define SIG_IGN ((void (*)(int))1) /* ignore signal */
都分别表示无返回值的函数指针,与 signal() 函数中第二个参数的要求相同。指针值分别是 0 和 1。这两个指针值逻辑上讲是实际程序中不可能出现的函数地址值。因此在 signal() 函数中就可以根据这两个特殊的指针值来判断是否使用默认信号处理句柄或忽略对信号的处理(当然 SIGKILL 和 SIGSTOP 是不能被忽略的)。参见下面程序列表中第 94-98 行的处理过程。
当一个程序被执行时,系统会设置其处理所有信号的方式为 SIG_DFL 或 SIG_IGN。另外,当程序 fork() 一个子进程时,子进程会继承父进程的信号处理方式(信号屏蔽码)。因此父进程对信号的设置和处理方式在子进程中同样有效。
为了能连续地捕获一个指定的信号,signal() 函数的通常使用方式例子如下。
void sig_handler(int signr) { // 信号句柄。 signal(SIGINT, sig_handler); // 为处理下一次信号发生而重新设置自己的处理句柄。 // ... } void main() { signal(SIGINT, sig_handler); // 主程序中设置自己的信号处理句柄。 }
signal() 函数不可靠的原因在于当信号已经发生而进入自己设置的信号处理函数中,但在重新再一次设置自己的处理句柄之前,在这段时间内有可能又有一个信号发生。但是此时系统已经把处理句柄设置成默认值。因此就有可能造成信号丢失。
2.2 sigaction 函数
sigaction() 函数采用了 sigaction 数据结构来保存指定信号的信息,它是一种可靠的内核处理信号的机制,它可以让我们方便地查看或修改指定信号的处理句柄。该函数是 signal() 函数的一个超集。该函数在 include/signal.h 头文件(第 66 行)中的声明为:
int sigaction(int sig, struct sigaction *act, struct sigaction *oldact);
其中参数 sig 是我们需要查看或修改其信号处理句柄的信号,后两个参数是 sigaction 结构的指针。当参数 act 指针不是 NULL 时,就可以根据 act 结构中的信息修改指定信号的行为。当 oldact 不为空时,内核就会在该结构中返回信号原来的设置信息。sigaction 结构见如下所示:
// include/signal.h struct sigaction { void (*sa_handler)(int); // 信号处理句柄。 sigset_t sa_mask; // 信号的屏蔽码,可以阻塞指定的信号集。 int sa_flags; // 信号选项标志。 void (*sa_restorer)(void); // 信号恢复函数指针(系统内部使用)。 };
当修改一个信号的处理方法时,如果处理句柄 sa_sandler 不是默认处理句柄 SIG_DFL 或忽略处理句柄 SIG_IGN,那么在 sa_handler 处理句柄可被调用前,sa_mask 字段就指定了需要加入到进程信号屏蔽位图中的一个信号集。如果信号处理句柄返回,系统就会恢复进程原来的信号屏蔽位图。这样在一个信号句柄被调用时,我们就可以阻塞指定的一些信号。当信号句柄被调用时,新的信号屏蔽位图会自动地把当前发送的信号包括进去,阻塞该信号的继续发送。从而在我们处理一指定信号期间能确保阻塞同一个信号而不让其丢失,直到此此次处理完毕。另外,在一个信号被阻塞期间而又多次发生时通常只保存其一个样例,也即在阻塞解除时对于阻塞的多个同一信号只会再调用一次信号处理句柄。在我们修改了一个信号的处理句柄之后,除非再次更改,否则就一直使用该处理句柄。这与传统的 signal() 函数不一样。signal() 函数会在一处理句柄结束后将其恢复成信号的默认处理句柄。
sigaction 结构中的 sa_flags 用于指定其他一些处理信号的选项,这些选项的定义请参见 include/signal.h 文件中(第 36-39 行)的说明。
sigaction 结构中的最后一个字段和 sys_signal() 函数的参数 restorer 是一函数指针。它在编译连接程序时由 Libc 函数库提供,用于在信号处理程序结束后清理用户态堆栈,并恢复系统调用存放在 eax 中的返回值,见下面详细说明。
2.3 do_signal() 函数
do_signal() 函数是内核系统调用(int 0x80)中断处理程序中对信号的预处理程序。在进程每次调用系统调用或者发生时钟等中断时,若进程已收到信号,则该函数就会把信号的处理句柄(即对应的信号处理函数)插入到用户程序堆栈中。这样,在当前系统调用结束返回后就会立刻执行信号句柄程序,然后再继续执行用户的程序,见图 8-10 所示。
在把信号处理程序的参数插入到用户堆栈中之前,do_signal() 函数首先会把用户程序堆栈指针向下扩展 longs 个长字(参见下面程序中 106 行),然后将相关的参数添入其中,参见图 8-11 所示。由于 do_signal() 函数从 104 行开始的代码比较难以理解,下面我们将对其进行详细描述。
在用户程序调用系统调用刚进入内核时,该进程的内核态堆栈上会由 CPU 自动压入如图 8-11 中所示的内容,也即:用户程序的 SS 和 ESP 以及用户程序中下一条指令的执行点位置 CS 和 EIP。在处理完此次指定的系统调用功能并准备调用 do_signal() 时(也即 system_call.s 程序 118 行之后,系统调用响应函数之后,返回用户态之前调用),内核态堆栈中的内容见图 8-12 中左边所示。因此 do_signal() 的参数即是这些在内核态堆栈上的内容。
在 do_signal() 处理完两个默认信号句柄(SIG_IGN 和 SIG_DFL)之后,若用户自定义了信号处理程序(信号句柄 sa_handler),则从 104 行起 do_signal() 开始准备把用户自定义的句柄插入用户态堆栈中。它首先把内核态堆栈中原用户程序的返回执行点指针 eip 保存为 old_eip 变量中,然后将该 eip 替换成指向自定义句柄 sa_handler,也即让图中内核态堆栈中的 eip 指向 sa_handler。接下来通过把内核态中保存的 “原 esp” 减去 longs (longs 是一个变量)值,把用户态堆栈向下扩展了 7 或 8 个长字空间。最后把内核堆栈上的一些寄存器内容复制到了这个空间中,见图中右边所示。
总共往用户态堆栈上放置了 7 到 8 个值,我们现在来说明这些值的含义以及放置这些值的原因。
old_eip 即是原用户程序的返回地址,它是在内核堆栈上 eip 被替换成信号句柄地址之前保留下来的。 eflags、edx 和 ecx 是原用户程序在调用系统调用之前的值,基本上也是调用系统调用的参数,在系统调用返回后仍然需要恢复这些用户程序的寄存器值。eax 中保存有系统调用的返回值。如果所处理的信号还允许收到本身,则堆栈上还存放有该进程的阻塞码 blocked。下一个是信号 signr 值。
最后一个是信号活动恢复函数的指针 sa_restorer。这个恢复函数不是由用户设定的,因为在用户定义 signal() 函数时只提供了一个信号值 signr 和一个信号处理句柄 handler。
系统所在的这些操作都是为了在用户堆栈上模拟一个调用信号处理函数的堆栈,用来执行信号处理函数,然后返回到原调用处。
Linux0.11 信号(十二)(下): https://developer.aliyun.com/article/1597333