文章目录
一、信号概念
1.1 信号的定义
信号(signal)是一种软中断,它本质上是在软件层次上对硬件中断机制的一种模拟。信号可用于进程间通信、处理异常,它通过操作系统向一个进程或者线程发送一种异步通知,用于通知该进程或线程某种事件已经发生,需要做出相应的处理。同时,进程收到信号到处理这个信号之前,必须具备保存这个信号的能力。
1.2 信号的编号及其描述
要处理信号,我们必需要先知道每种信号的类型及其对应的作用。在 Linux 中,信号被分类为标准信号和实时信号,每个信号都有一个唯一的编号(就是一个宏定义)。标准信号是最基本的信号类型,由整数编号表示,编号范围是 1 到 31。实时信号是 Linux 中的扩展信号类型,由整数编号表示,编号范围是 34 到 64,这里我们主要学习标准信号。
我们可以通过 kill -l
命名来查看信号的编号:
下面是常见的信号编号及其对应的描述:
信号编号 | 信号名称 | 描述 |
---|---|---|
1 | SIGHUP | 控制终端挂起或者断开连接 |
2 | SIGINT | 中断信号,通常由 Ctrl+C 发送 |
3 | SIGQUIT | 退出信号,通常由 Ctrl+\ 发送 |
4 | SIGILL | 非法指令信号 |
5 | SIGTRAP | 跟踪异常信号 |
6 | SIGABRT | 中止信号 |
7 | SIGBUS | 总线错误信号 |
8 | SIGFPE | 浮点错误信号 |
9 | SIGKILL | 强制退出信号 |
10 | SIGUSR1 | 用户定义信号1 |
11 | SIGSEGV | 段错误信号 |
12 | SIGUSR2 | 用户定义信号2 |
13 | SIGPIPE | 管道破裂信号 |
14 | SIGALRM | 闹钟信号 |
15 | SIGTERM | 终止信号 |
16 | SIGSTKFLT | 协处理器栈错误信号 |
17 | SIGCHLD | 子进程状态改变信号 |
18 | SIGCONT | 继续执行信号 |
19 | SIGSTOP | 暂停进程信号 |
20 | SIGTSTP | 终端停止信号 |
21 | SIGTTIN | 后台进程尝试读取终端输入信号 |
22 | SIGTTOU | 后台进程尝试写入终端输出信号 |
23 | SIGURG | 套接字上的紧急数据可读信号 |
24 | SIGXCPU | 超时信号 |
25 | SIGXFSZ | 文件大小限制超出信号 |
26 | SIGVTALRM | 虚拟定时器信号 |
27 | SIGPROF | 分析器定时器信号 |
28 | SIGWINCH | 窗口大小变化信号 |
29 | SIGIO | 文件描述符上就绪信号 |
30 | SIGPWR | 电源失效信号 |
31 | SIGSYS | 非法系统调用信号 |
34 | SIGRTMIN | 实时信号最小编号 |
... | ... | ... |
64 | SIGRTMAX | 实时信号最大编号 |
二、信号的产生
2.1 按键产生
我们在终端按下 Ctrl+C
组合键时,操作系统会识别这个动作并生成一个中断信号。键盘硬件通过中断控制器向 CPU 发送这个信号,然后操作系统的中断处理程序会捕获这个信号,并将其转换为软件信号 SIGINT。随后,操作系统的信号分发机制随后将 SIGINT 信号发送给当前终端的前台进程,执行该信号的处理函数。类似的还有其它键盘组合键 Ctrl+\
、Ctrl+Z
等
2.2 系统调用
void abort(void):
发送 SIGABRT 信号终止调用进程int raise(int sig):
向调用进程发送一个信号int kill(pid_t pid, int sig):
向指定的进程ID发送一个信号
2.3 硬件异常
信号产生,不一定非得用户显示的发送,有些情况下信号会在 OS 内部自动产生。比如程序中存在 除0和野指针错误
,除0会使结果无穷大,引起状态寄存器溢出,标记位由 0 变成 1。OS 得知 CPU 发生运算异常,就会找到使状态寄存器的标记位变为 1 的进程,并发送信号终止这个进程了。同样的,野指针,CPU 在拿着这个虚拟地址进行页表映射时,就会发现相关的权限问题或者越界访问,然后发送信号终止这个进程。
程序出现问题时,发送信号是一种有效的方式,让进程了解到发生了错误或异常。信号机制允许进程以受控的方式响应异常情况,避免错误扩散到操作系统的其他部分。通过信号,操作系统能够将异常隔离在单个进程中,同时允许进程采取适当的措施来处理错误,如记录日志、释放资源或优雅地退出。这样,即使某个进程遇到问题,也不会波及到整个操作系统的稳定性
2.4 软件条件
比如当管道的读端被关闭,而写端进程仍然尝试写入数据时,操作系统会采取措施阻止这种无效的写操作。由于没有进程读取数据,继续写入变得没有意义。为了避免资源浪费和潜在的错误,操作系统会向写入进程发送一个 SIGPIPE (即 13 号信号)。这个信号的发送是由读端关闭这一软件条件触发的,目的是通知写进程其写入操作不再被需要,从而可以安全地终止写入操作,避免产生无效的输出。
定时器设定闹钟 : unsigned int alarm(unsigned int seconds)
alarm 函数用于设置一个定时器,告诉操作系统在指定的 seconds 秒后向当前进程发送 SIGALRM 信号,其默认行为是终止该进程。该函数的返回值是上一次闹钟设置的剩余时间。如果 seconds 设置为 0,alarm 函数将取消之前设置的闹钟
三、信号的保存和处理
3.1 信号的保存与阻塞
上面我们提到,进程收到信号到处理这个信号之前,必须具备保存这个信号的能力。而保存这个信号就是对描述信号结构体的特定字段进行修改,它是通过内核设定的位图结构来实现的。对此我们还要引入以下一组概念,因为信号还可以被阻塞的,阻塞后不会执行信号对应的处理方法
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)
- 进程可以选择阻塞某个信号,被阻塞的信号产生时将保持在未决状态,直到解除对此信号的阻塞,才执行递达的动作
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
所以我们学习信号主要看这三张表,收到一个信号时,先看信号有没有被阻塞,如果被阻塞了,进入信号未决状态,即在 pending 表中将该信号保存下来。没有被阻塞,就递达执行对应的方法。
block 表和 pending 表都是使用内核中的位图结构 sigset_t
来实现,比特位的位置代表信号的编号。对此要对内核中的位图进行修改,我们必须要使用系统调用,即调用信号集操作函数
将信号集清空:
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)
获取未决信号集:sigpending(sigset_t *set)
函数详解:int sigprocmask(int how, const sigset_t *set, sigset_t *oldset)
成功返回0,失败返回-1,并设置错误码
参数解释:
how:表示信号屏蔽字的操作方式,以下三选一
SIG_BLOCK:将set中的信号集合添加到当前的信号屏蔽字中
SIG_UNBLOCK:将set中的信号集合从当前的信号屏蔽字中移除
SIG_SETMASK:将set中的信号集合替换为当前的信号屏蔽字。
set:要设置的信号集合
oldset:保存之前的信号集合,便于恢复工作的展开
3.2 信号的处理方式(捕捉)
进程收到信号有以下三种处理方式:
1、忽略此信号
2、执行该信号的默认处理动作
3、自定义处理动作(即捕捉信号),提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数
typedef void (*sighandler_t)(int)
sighandler_t signal(int signum, sighandler_t handler)
要执行自定义处理动作,必须通过捕捉信号来实现。这需要为特定信号注册一个 signal 信号处理函数。如果在进程中成功注册了信号处理函数,当指定的信号被触发时,操作系统会中断进程的当前执行流程,不再执行默认的信号处理动作,而是调用这个注册的函数,即回调函数。这个回调函数接收一个参数,即触发的信号编号,并且没有返回值。
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact)
struct sigaction { void (*sa_handler)(int); void (*sa_sigaction)(int, siginfo_t *, void *); sigset_t sa_mask; int sa_flags; void (*sa_restorer)(void); };
sigaction 既是函数名又是一个结构体对象,在这里我们关注的是结构体中的两个关键部分,第一个和第三个成员。当某个信号的处理函数被调用时 ,内核自动将当前信号加入进程的信号屏蔽字 ,当信号处理函数返回时,会自动恢复原来的信号屏蔽字。这样就可以防止同一信号的重复触发,如果在调用信号处理函数时, 还希望自动屏蔽另外一些信号 , 则在 sa_mask 字段中添加即可。同时需注意,如果信号未决时,解除了该信号的屏蔽,那么执行 handler 方法之前,会把 pending 表中的该信号由 1 置 0
并不是所有信号都能被捕捉,一些信号的设计具有强制性和即时性,以确保操作系统在遇到严重错误时,能够立即采取行动。比如 SIGKILL 9号信号和 SIGSTOP 19信号,它们用于立即终止或暂停进程,不能被捕捉或忽略,以此保持了操作系统对进程的最终控制权。不然所有信号被捕捉之后,进程将不再受操作系统控制
3.3 用户态和内核态
我们编写的用户态代码虽独立于操作系统内核,但常需通过系统调用来访问系统资源或硬件设备。系统调用是用户态程序与内核态之间的桥梁,它们允许进程在用户态和内核态之间切换。由于身份转换和执行内核代码需要时间,频繁的系统调用可能导致性能开销。
进程在执行时,其上下文信息,包括寄存器状态,必须载入 CPU。寄存器中有些是可见的,如 eax
、ebx
等,负责存储临时数据;还有些不可见寄存器,如状态寄存器,控制着 CPU 的状态。特别地,CR3
寄存器存储用户级页表的起始地址,其值的变化标志着进程运行模式的切换,0 代表内核态,3 代表用户态,确保了系统能够有效区分当前执行的是用户态还是内核态的代码。
下面我们来进一步理解进程地址空间:
操作系统中,每个进程地址空间顶部的 3-4GB 空间是专门保留给内核使用的,这片空间对于所有进程都是一样的。它们共享同一份内核级页表,映射到操作系统的物理内存。在这 3-4GB 的内核空间内,进程通过执行系统调用来请求操作系统服务,这些服务包括文件操作、进程控制、网络通信等。当系统调用被触发时,CPU 的控制权从用户态转移到内核态,操作系统执行相应的服务,然后再将控制权安全地交还给用户态程序。这个过程虽然涉及到从用户态到内核态的切换,但在逻辑上,每个进程都可以通过自己的地址空间独立访问内核资源。这种设计使得内核资源可以高效地被所有进程利用,同时保持了内存使用的优化。
3.4 信号处理流程
在用户空间中,信号处理函数的执行涉及复杂的上下文切换。通过系统调用,陷入内核,如果当用户程序注册了信号的处理方法 handler
,就会转回用户态,执行自定义的 handler
函数。值得注意的是,sighandler
和main
函数使用不同的堆栈空间,它们之间不存在直接的调用关系,而是作为两个独立的控制流程并行存在,所以我们可以在程序的任意位置注册某个信号的方法。当 handler
函数执行完毕,它将通过一个特殊的系统调用 sigreturn
重新陷入内核态。在内核态即将返回用户态的时候,会对信号进行检测,如果没有新的信号要递达,就会返回 main
函数,执行程序剩下的代码