Linux系统编程第六章学习笔记

6. 信号与信号处理

摘要
本章涵盖了信号和信号处理。它提供了信号和中断的统一处理方式,有助于正确理解信号。它将信号视为进程的中断,将进程从正常执行中转移到信号处理。它解释了信号的来源,包括来自硬件、异常和其他进程的信号。然后,它使用示例说明了Unix/Linux中信号的常见用法。它详细解释了Unix/Linux中的信号处理,包括信号类型、信号向量位、信号掩码位、进程PROC结构中的信号处理程序以及信号处理步骤。它使用示例展示了如何安装信号捕获器以处理程序异常,例如分段错误,在用户模式下。它还讨论了将信号用作进程间通信(IPC)机制的适用性。编程项目是让读者使用信号和管道来实现进程之间交换消息的IPC机制。

6.1 信号与中断

中断是来自I/O设备或协处理器的外部请求,发送给CPU,将CPU从正常执行中转移到中断处理。与CPU的中断类似,信号是发送给进程的请求,将进程从正常执行中转移到信号处理。在讨论信号和信号处理之前,我们将回顾中断的概念和机制,以帮助正确理解信号。

(1) 首先,我们概括了"进程"的概念,指的是:带引号的“进程”是一系列活动。泛化的“进程”示例包括

  • 一个进行日常例行工作的人。

  • 在Unix/Linux中以用户模式或内核模式运行的进程。

  • 执行机器指令的CPU。

(2) "中断"是发送给"进程"的事件,将"进程"从其正常活动中转移,执行称为"中断处理"的其他操作。"进程"可以在完成"中断处理"后恢复其正常活动。

(3) “中断”这个术语可以适用于任何“进程”,不仅仅是计算机中的CPU。例如,我们可以谈论以下种类的“中断”。

(3).1 个人中断

当我在办公室阅读、评分、白日梦等活动时,可能会发生一些真实事件,例如:
1
(3).2. 进程中断

这些是发送给进程的中断。当进程执行时,它可能来自三个不同来源的中断:
来自硬件
: 终端的Control_C键,定时器间隔等。

来自其他进程
kill(pid, SIG#),子进程的死亡等。
自我引发
除零操作,无效地址等。

每个进程中断都转换为一个唯一的ID号,然后发送给进程。与个人中断不同,个人中断种类繁多,但我们可以始终限制发送给进程的中断的数量。在Unix/Linux中,进程中断被称为信号(SIGNALS),编号从1到31。对于每个信号,进程在其PROC结构中都有一个操作函数,进程可以在接收信号后执行。与个人类似,进程可以屏蔽掉某些信号以推迟其处理。如有需要,进程还可以更改其信号操作函数。

(3).3. 硬件中断

这些是发送给处理器或CPU的信号。它们也来自三个可能的来源:
来自硬件
: 定时器、I/O设备等。

来自其他处理器
浮点处理器(FFP)、直接内存访问(DMA)、多处理器系统中的其他CPU。
自我引发
除零操作、保护错误、INT指令等。

每个中断都有一个唯一的中断向量号。操作函数是中断向量表中的中断处理程序。请注意,CPU始终在执行进程。CPU不会引发任何自我引发的中断(除非出现故障)。这些中断是由正在使用或大多数情况下滥用CPU的进程引起的。前者包括INT n或等效指令,它们导致CPU从用户模式切换到内核模式。后者包括CPU识别的所有陷阱错误作为异常。
我们可以排除CPU自身引发的中断,只留下那些来自CPU外部的中断。
(3).4. 进程的陷阱错误

进程可能会自身引发中断。这种中断是由于错误引起的,例如除以0、无效地址、非法指令、特权违规等,这些错误由CPU识别为异常。当进程遇到异常时,它会陷入操作系统内核,将陷阱原因转换为信号编号并将该信号发送给自己。如果异常发生在用户模式下,进程的默认操作是终止,可以选择进行内存转储以进行调试。正如我们将在后面看到的,进程可以替换默认操作函数为信号捕获器,允许它在用户模式下处理信号。如果陷入发生在内核模式下,这必定是由于硬件错误或更有可能是内核代码中的错误,内核无能为力。在Unix/Linux中,内核只会打印PANIC错误消息并停止运行。希望问题可以在下一个内核发布中得到追踪和修复。

6.2 Unix/Linux信号示例

(1). 按下Control_C键通常会导致正在运行的进程终止。原因如下:Control_C键生成一个键盘硬件中断。键盘中断处理程序将Control_C键转换为一个SIGINT(2)信号发送给终端上的所有进程,如果它们正在等待键盘输入,它还会唤醒这些进程。在内核模式下,每个进程都需要检查和处理未决信号。对于大多数信号,进程的默认操作是调用内核的kexit(exitValue)函数来终止。在Linux中,exitValue的低字节是导致进程终止的信号编号。

(2). 用户可以使用nohup a.out &命令在后台运行一个进程。即使用户退出登录,进程也会继续运行。nohup命令会导致shell fork一个子进程来执行程序,但子进程会忽略SIGHUP(1)信号。当用户退出登录时,shell会向与终端关联的所有进程发送SIGHUP信号。收到这样的信号后,后台进程会简单地忽略它并继续运行。为了防止后台进程使用终端进行I/O操作,后台进程通常会断开与终端的连接(通过将文件描述符0,1,2重定向到/dev/null),从而使其完全免疫于任何终端相关的信号。

(3). 也许几天后,用户再次登录并发现(通过ps -u UID)后台进程仍在运行。用户可以使用sh命令

kill pid(或kill -s 9 pid)
来终止它。具体操作如下:执行kill命令的进程向pid标识的目标进程发送一个SIGTERM(15)信号,请求它终止。被定位的进程将遵循请求并终止。如果该进程选择忽略SIGTERM信号,它可能拒绝终止。在这种情况下,可以使用kill -s 9 pid,这将确保终止它。这是因为进程不能改变对于编号9的信号的操作。读者可能会疑惑,为什么是编号9?在原始的Unix系统中,只有9个信号。编号9的信号被保留作为终止进程的最后手段。尽管后来的Unix/Linux系统扩展了信号编号到31,但信号编号9的含义仍然保留。

6.3 Unix/Linux中的信号处理

6.3.1 信号类型

Unix/Linux支持31种不同的信号,这些信号在signal.h文件中定义。

#define SIGHUP 1
#define SIGINT 2
#define SIGQUIT 3
#define SIGILL 4
#define SIGTRAP 5
#define SIGABRT 6
#define SIGIOT 6
#define SIGBUS 7
#define SIGFPE 8
#define SIGKILL 9
#define SIGUSR1 10
#define SIGSEGV 11
#define SIGUSR2 12
#define SIGPIPE 13
#define SIGALRM 14
#define SIGTERM 15
#define SIGSTKFLT 16
#define SIGCHLD 17
#define SIGCONT 18
#define SIGSTOP 19
#define SIGTSTP 20
#define SIGTTIN 21
#define SIGTTOU 22
#define SIGURG 23
#define SIGXCPU 24
#define SIGXFSZ 25
#define SIGVTALRM 26
#define SIGPROF 27
#define SIGWINCH 28
#define SIGPOLL 29
#define SIGPWR 30
#define SIGSYS 31

每个信号都有一个符号名称,例如SIGHUP(1)、SIGINT(2)、SIGKILL(9)、SIGSEGV(11)等。

6.3.2 信号的来源

  • 来自硬件中断的信号:当进程执行时,一些硬件中断被转换为发送给进程的信号。硬件信号的示例包括:

    • 中断键(Control-C),导致SIGINT(2)信号。
    • 定时器,当其计时到期时生成SIGALRM(14)、SIGVTALRM(26)或SIGPROF(27)信号。
    • 其他硬件错误,如总线错误、I/O陷阱等。
  • 来自异常的信号:当用户模式下的进程遇到异常时,它会陷入内核模式并向自身生成信号。常见的陷阱信号示例包括SIGFPE(8)用于浮点异常(除以0),以及最常见和可怕的SIGSEGV(11)用于分段错误等。

  • 来自其他进程的信号:进程可以使用kill(pid, sig)系统调用向由pid标识的目标进程发送信号。读者可以尝试以下实验。在Linux下,运行以下简单的C程序:

main(){ while(1); }

这将导致进程永远循环。从另一个(X窗口)终端中,使用ps -u命令查找循环进程的pid。然后输入sh命令:

kill -s 11 pid

循环进程将以分段错误而死。读者可能会疑惑:为什么会这样?进程只是在while(1)循环中执行,它怎么会生成分段错误?答案是:这并不重要。无论进程如何死亡,它的exitValue中都包含信号编号。父sh进程只是将死掉的子进程的信号编号转换为错误字符串,无论是什么信号。

6.3.3 进程PROC结构中的信号

每个进程PROC都有一个32位的位向量,记录发送给进程的信号。在位向量中,每个位(除了位0)代表一个信号编号。此外,它还有一个信号MASK位向量,用于屏蔽相应的信号。一组系统调用,如sigmask、sigsetmask、siggetmask、sigblock等,可用于设置、清除和检查MASK位向量。挂起的信号仅在未被屏蔽时才生效。这允许进程延迟处理被屏蔽的信号,类似于CPU屏蔽某些中断。

6.3.4 信号处理程序

每个进程PROC都有一个信号处理程序数组int sig[32]。sig[32]数组的每个条目指定如何处理相应的信号,其中0表示默认,1表示忽略,其他非零值表示由用户模式中预安装的信号捕获器(处理程序)函数处理。图6.1显示了信号位向量、屏蔽位向量和信号处理程序。

6.3.5 安装信号捕获器

进程可以使用系统调用:

int r = signal(int signal_number, void *handler);

来更改所选信号编号的处理函数,但不能更改SIGKILL(9)和SIGSTOP(19)的处理函数。如果安装的处理函数不是0或1,那么它必须是用户空间中信号捕获函数的入口地址,形式如下:

void catcher(int signal_number){..............}

signal()系统调用在所有类Unix系统中都可用,但它具有一些不太理想的特性。

(1). 在执行安装的信号捕获器之前,信号处理程序通常会被重置为默认。为了捕获同一信号的下一个发生,必须再次安装捕获器。这可能导致下一个信号和重新安装信号处理程序之间的竞争条件。相比之下,sigaction()在执行当前捕获器时会自动阻塞下一个信号,因此不会出现竞争条件。

(2). signal()不能阻塞其他信号。如果需要,用户必须使用sigprocmask()显式阻塞/解除阻塞其他信号。相比之下,sigaction()可以指定要阻塞的其他信号。

(3). signal()只能将信号编号传递给捕获器函数。Sigaction()可以传递有关信号的其他信息。

(4). signal()可能不适用于多线程程序中的线程。Sigaction()适用于线程。

(5). signal()在不同版本的Unix上可能会有所不同。Sigaction()是POSIX标准,更具可移植性。

因此,出于这些原因,signal()已被POSIX sigaction()函数取代。在Linux中(Bovet和Cesati 2005),sigaction()是一个系统调用。它的原型如下:

int sigaction (int signum, const struct sigaction *act, struct sigaction *oldact);

sigaction结构被定义为:

struct sigaction{
    void (*sa_handler)(int);
    void (*sa_sigaction)(int, siginfo_t *, void *);
    sigset_t sa_mask;
    int sa_flags;
    void (*sa_restorer)(void);
};

最重要的字段包括:

  • sa_handler:这是指向与signal()的处理程序具有相同原型的处理程序函数的指针。

  • sa_sigaction:这是运行信号处理程序的替代方法。除了信号编号外,它还有两个附加参数,其中siginfo_t *提供有关接收到的信号的更多信息。

  • sa_mask:允许在处理程序执行期间设置要阻塞的信号。

  • sa_flags:允许修改信号处理过程的行为。要使用sa_sigaction处理程序,sa_flags必须设置为SA_SIGINFO。

有关sigaction结构字段的详细描述,请参阅sigaction手册页面。以下是使用sigaction()系统调用的简单示例。

(6). 进程可以使用系统调用:

int r = kill(pid, signal_number);

向另一个由pid标识的进程发送信号。sh命令

kill -s signal_number pid

使用kill系统调用。一般来说,只有相关进程,例如具有相同uid的进程,可以相互发送信号。但是,超级用户进程(uid=0)可以向任何进程发送信号。kill系统调用使用无效的pid来表示传递信号的不同方式。例如,pid=0将信号发送给同一进程组中的所有进程,pid=-1将信号发送给所有pid>1的进程,等等。读者可以查阅Linux信号/kill的手册页面以获取更多详细信息。

6.4 信号处理步骤

(7). 进程在内核模式下检查信号并处理未决信号。如果信号具有用户安装的捕获函数,进程首先清除信号,获取捕获函数的地址,并对于大多数与陷阱相关的信号,将已安装的捕获函数重置为默认。然后,它以一种方式操作返回路径,使其返回到用户模式中执行捕获函数。当捕获函数完成时,它将返回到最后一次进入内核模式的原始中断点。因此,进程首先走了一条弯路以执行捕获函数,然后恢复正常执行。

(8). 重置用户安装的信号捕获器:陷阱相关的信号的用户安装的捕获函数旨在处理用户代码中的陷阱错误。由于捕获函数也在用户模式下执行,它可能再次出现相同类型的错误。如果是这样,进程将陷入无限循环,不断在用户模式和内核模式之间跳转。为了防止这种情况发生,Unix内核通常在让进程执行捕获函数之前将处理程序重置为默认。这意味着用户安装的捕获函数仅适用于信号的一次发生。要捕获同一信号的另一个发生,必须再次安装捕获函数。然而,用户安装的信号捕获器的处理方式并不统一,因为在不同版本的Unix中有所不同。例如,在BSD Unix中,信号处理程序不会被重置,但在执行信号捕获器时将阻止同一信号。感兴趣的读者可以查阅Linux的signal和sigaction的man页面以获取更多详细信息。

(9). 信号和唤醒:Unix/Linux内核中有两种类型的SLEEP进程;安全睡眠进程和可中断睡眠进程。前者不可中断,而后者可被信号中断。如果进程处于不可中断的SLEEP状态,到达的信号(必须来自硬件中断或另一个进程)不会唤醒进程。如果它处于可中断的SLEEP状态,到达的信号将唤醒它。例如,当一个进程等待终端输入时,它以低优先级进入睡眠状态,这是可中断的,此时信号(如SIGINT)将唤醒它。

6.5 信号和异常

(10). 信号的正确使用:Unix信号最初是为以下目的而设计的。
. 作为进程异常的统一处理:当进程遇到异常时,它陷入内核模式,将陷阱原因转换为信号编号并将信号发送给自身。如果异常发生在内核模式下,内核会打印PANIC消息并停止。如果异常发生在用户模式下,进程通常会以进行调试的内存转储方式终止。

. 允许进程通过预安装的信号捕获器在用户模式中处理程序错误。这类似于MVS中的ESPIE宏[IBM MVS]。

. 在不寻常情况下,允许一个进程通过信号来杀死另一个进程。请注意,kill不能直接杀死一个进程;它只是对目标进程的一种“请死”的请求。为什么我们不能直接杀死一个进程?鼓励读者考虑一下原因(提示:瑞士银行中大量未认领的匿名账户)。

6.6 信号作为IPC

(11). 信号的滥用:在许多操作系统书中,信号被归类为进程间通信的机制。其理由是一个进程可以向另一个进程发送信号,导致它执行预安装的信号处理程序函数。但是,这种分类是非常值得争议的,如果不是不适当的,原因如下。
. 该机制不可靠,可能会导致信号丢失。每个信号在位向量中用单个位表示,只能记录一个信号的发生。如果一个进程向另一个进程发送两个或更多相同的信号,它们可能只在接收进程的PROC中显示一次。实时信号被排队并保证按发送顺序传递,但操作系统内核可能不支持实时信号。

. 竞争条件:在处理信号之前,进程通常会将信号处理程序重置为默认。为了捕获同一信号的下一个发生,进程必须在下一个信号到达之前重新安装捕获函数。否则,下一个信号可能会导致进程终止。尽管可以通过在执行信号捕获器时阻塞相同的信号来防止竞争条件,但无法防止信号丢失。

. 大多数信号具有预定义的含义。不加区分地使用信号可能不会实现通信,而只会引起混乱。例如,向一个不断循环的进程发送SIGSEGV(11)分段错误信号就像对在水中游泳的人喊:“你的裤子着火了!”。

因此,试图将信号作为进程间通信的手段是过度拉伸信号的原始目的,应该避免这样做。

苏格拉底挑战

Linux Signal Study

posted @ 2023-11-11 22:07  20211120  阅读(9)  评论(0编辑  收藏  举报