信号入门
从生活的角度理解信号
首先我们从生活的角度开始理解信号,先看看一下例子:
- 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能"识别快递"
- 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成"在合适的时候去取"。
- 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你"记住了有一个快递要去取"
- 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你你的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)
- 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
信号的特点
通过生活的例子,我们可以提炼出信号的基本特点:
- 信号在生活(OS)中可以随时产生,即信号的产生和进程是异步的
- 你(进程)要认识这个信号
- 我们知道信号产生了,并且知道信号怎么处理
- 我们可能正在作者更重要的事情,把到来的信号暂不处理。但我们要记得这个信号,并且在合适的时候处理。
信号的概念
信号其实就是进程间异步通信的一种方式,属于软中断。中断这个概念在后面会继续提及。
普通信号
我们用kill -l可以查看信号列表:

其中前三十一个信号是普通信号,是我们讨论的重点。后面的信号是实时信号,不做讨论。我们可以通过man 7 signal查看普通信号代表的含义,并看到其默认处理方式。

普通信号列表
原版:
| 序号 | 信号名 | Standard | Action | Comment |
|---|---|---|---|---|
| 1 | SIGHUP | P1990 | Term | Hangup detected on controlling terminal or death of controlling process |
| 2 | SIGINT | P1990 | Term | Interrupt from keyboard |
| 3 | SIGQUIT | P1990 | Core | Quit from keyboard |
| 4 | SIGILL | P1990 | Core | Illegal Instruction |
| 5 | SIGTRAP | P2001 | Core | Trace/breakpoint trap |
| 6 | SIGABRT | P1990 | Core | Abort signal from abort(3) |
| 7 | SIGBUS | P2001 | Core | Bus error (bad memory access) |
| 8 | SIGFPE | P1990 | Core | Floating-point exception |
| 9 | SIGKILL | P1990 | Term | Kill signal(不可捕获/屏蔽/忽略) |
| 10 | SIGUSR1 | P1990 | Term | User-defined signal 1 |
| 11 | SIGSEGV | P1990 | Core | Invalid memory reference |
| 12 | SIGUSR2 | P1990 | Term | User-defined signal 2 |
| 13 | SIGPIPE | P1990 | Term | Broken pipe: write to pipe with no readers; see pipe(7) |
| 14 | SIGALRM | P1990 | Term | Timer signal from alarm(2) |
| 15 | SIGTERM | P1990 | Term | Termination signal |
| 16 | SIGSTKFLT | - | Term | Stack fault on coprocessor (unused) |
| 17 | SIGCHLD | P1990 | Ign | Child stopped or terminated |
| 18 | SIGCONT | P1990 | Cont | Continue if stopped |
| 19 | SIGSTOP | P1990 | Stop | Stop process(不可捕获/屏蔽/忽略) |
| 20 | SIGTSTP | P1990 | Stop | Stop typed at terminal |
| 21 | SIGTTIN | P1990 | Stop | Terminal input for background process |
| 22 | SIGTTOU | P1990 | Stop | Terminal output for background process |
| 23 | SIGURG | P2001 | Ign | Urgent condition on socket (4.2BSD) |
| 24 | SIGXCPU | P2001 | Core | CPU time limit exceeded (4.2BSD); see setrlimit(2) |
| 25 | SIGXFSZ | P2001 | Core | File size limit exceeded (4.2BSD); see setrlimit(2) |
| 26 | SIGVTALRM | P2001 | Term | Virtual alarm clock (4.2BSD) |
| 27 | SIGPROF | P2001 | Term | Profiling timer expired |
| 28 | SIGWINCH | - | Ign | Window resize signal (4.3BSD, Sun) |
| 29 | SIGIO | - | Term | I/O now possible (4.2BSD) / Pollable event (Sys V) |
| 30 | SIGPWR | - | Term | Power failure (System V) / A synonym for SIGINFO |
| 31 | SIGSYS | P2001 | Core | Bad system call (SVr4); see also seccomp(2) / Synonymous with SIGUNUSED |
中文:
| 序号 | 信号名 | Standard | Action | Comment |
|---|---|---|---|---|
| 1 | SIGHUP | P1990 | Term | 检测到控制终端挂起,或控制进程终止 |
| 2 | SIGINT | P1990 | Term | 来自键盘的中断(如按下 Ctrl+C) |
| 3 | SIGQUIT | P1990 | Core | 来自键盘的退出信号(如按下 Ctrl+\) |
| 4 | SIGILL | P1990 | Core | 非法指令(程序执行了无效的CPU指令) |
| 5 | SIGTRAP | P2001 | Core | 跟踪/断点陷阱(调试器常用的信号) |
| 6 | SIGABRT | P1990 | Core | 由 abort(3) 函数触发的中止信号 |
| 7 | SIGBUS | P2001 | Core | 总线错误(内存访问无效,如地址未对齐) |
| 8 | SIGFPE | P1990 | Core | 浮点运算异常(如除0、溢出) |
| 9 | SIGKILL | P1990 | Term | 强制终止信号(不可捕获、屏蔽、忽略) |
| 10 | SIGUSR1 | P1990 | Term | 用户自定义信号1 |
| 11 | SIGSEGV | P1990 | Core | 无效内存引用(如访问未分配的内存、越界) |
| 12 | SIGUSR2 | P1990 | Term | 用户自定义信号2 |
| 13 | SIGPIPE | P1990 | Term | 管道破裂(向无读取方的管道写入数据,见 pipe(7)) |
| 14 | SIGALRM | P1990 | Term | 由 alarm(2) 触发的定时器信号 |
| 15 | SIGTERM | P1990 | Term | 终止信号(默认的进程终止信号,可被捕获) |
| 16 | SIGSTKFLT | - | Term | 协处理器栈故障(未实际使用) |
| 17 | SIGCHLD | P1990 | Ign | 子进程停止或终止(默认忽略) |
| 18 | SIGCONT | P1990 | Cont | 恢复被暂停的进程 |
| 19 | SIGSTOP | P1990 | Stop | 暂停进程(不可捕获、屏蔽、忽略) |
| 20 | SIGTSTP | P1990 | Stop | 终端输入的暂停信号(如按下 Ctrl+Z) |
| 21 | SIGTTIN | P1990 | Stop | 后台进程尝试读取终端输入 |
| 22 | SIGTTOU | P1990 | Stop | 后台进程尝试写入终端输出 |
| 23 | SIGURG | P2001 | Ign | 套接字上的紧急条件(4.2BSD) |
| 24 | SIGXCPU | P2001 | Core | CPU时间限制超出(见 setrlimit(2)) |
| 25 | SIGXFSZ | P2001 | Core | 文件大小限制超出(见 setrlimit(2)) |
| 26 | SIGVTALRM | P2001 | Term | 虚拟闹钟时钟(4.2BSD) |
| 27 | SIGPROF | P2001 | Term | 性能分析定时器超时 |
| 28 | SIGWINCH | - | Ign | 窗口大小调整信号(4.3BSD、Sun) |
| 29 | SIGIO | - | Term | I/O操作就绪(4.2BSD)/ 可轮询事件(Sys V) |
| 30 | SIGPWR | - | Term | 电源故障(System V)/ SIGINFO的别名 |
| 31 | SIGSYS | P2001 | Core | 错误的系统调用(SVr4,见 seccomp(2))/ SIGUNUSED的别名 |
注意到我们的9号信号和19号信号是不可捕获、屏蔽、忽略的,后面会继续谈论这一点。
看到我们的信号默认处理动作有term、core、stop、ign。
其中term和core都是关掉进程,后者还会形成核心转储文件。stop是暂停进程,ign则是忽略信号。
- 注意到我们的普通信号共有31个,这是个敏感的数字,我们容易想到信号大概率存储在pcb的一张位图上。信号前的序号是其对应比特位,也是其宏定义。
信号的产生
signal
接下里我们将从信号的产生,信号的存储再到信号的处理三个阶段来逐步理解信号。
在此之前,为了让我们看到明显的信号产生,我们要先知道信号处理的三种情况:
- 默认动作
- 忽略动作
- 自定义处理---信号的捕捉
为了看到明显的现象,我们要先对信号捕捉,定义其自定处理方式。linux也提供了信号捕捉的接口signal:

第一个参数是要捕捉的信号可以传数字也可以传对应的信号名,第二个则是void(int)的函数指针。自然第二个参数就是我们的自定义处理方式。

返回的是旧的处理方式的函数指针。
信号的产生方式的五种方式
- 用通过kill命令,向指定的进程发送指定的信号
我们先捕捉信号:


可以看到我们进程接收到了2号信号,并做出了我们自定义的处理方式。
- 键盘可以产生信号,事实上我们的ctrl+c和ctrl+\分别会产生2号信号和3号信号:


- 系统调用,我们在c语言中也可以用系统调用给其他进程发信号:

也可以直接给自己发送信号:

这里会给自己发送SIGABRT信号。
前面强调过9号信号和19号信号是不可以被捕捉的,这是防止一个进程怎么也退不出去,这可是相当严重的内存泄漏哦。
- 软件条件。如管道中读端关闭,写端一直在写入就会收到13号信号SIGPIPE。
此外我们还有也给库函数可以给自己发送14号信号SIGALRM:

可以看到这个函数就是一个倒计时,会在seconds秒后给进程发送14号信号,而且我们的14号信号默认处理方式是Term。
看看其返回值:

即:alarm() 会返回 "距离之前已调度的闹钟信号(alarm)原定触发所剩余的秒数";如果之前没有已调度的闹钟,则返回 0。
我们可以通过alarm(0)来查看上个闹钟触发剩余时间。
我们来用闹钟验证一些事情:

我们来看看正在处理信号时,又收到同样的信号会发生什么:

可以在处理一个信号时,会对该信号阻塞,即再次接受该信号时不会立刻处理而是等处理完当前信号,再处理。
注意我这里指的是同一个信号哦!不同信号在下文再做探讨。
我们再来比对一下io和cpu运算速度:




可以看到相较于cpu运算,io真是慢的不行。
- 异常
程序出现异常的时候就会收到OS的信号,之前我们试过除0和访问野指针都会收到异常信号:


这里我们就收到了OS发送给进程的SIGFPE即8号信号。
同样的我们可以对8号信号捕捉:


可以看到这里出现了不符合预期的现象,为什么handler重复执行了这么多次。
要了解这个我们就必须知道linux怎么捕捉异常的
捕捉异常
首先我们要知道OS是软硬件的管理者,因此也会对CPU进行管理。CPU内有一个寄存器eflag中一个标记位会记录运算是否正常,当除0发生时,该标记位就会置1.此时OS就会给进程发送8号信号。
前面进程调度的时候我们提过:CPU内部有一套寄存器会记录进程的上下文。因此会记录我们发生错误的代码,当该进程再次进入CPU中时,又会加载这段代码,然后继续报错!
简单来说,进程执行完8号信号的自定义处理方式后会回到错误的代码处,继续执行。这就导致了8号信号重复执行处理。
核心转储
term和core区别在于会不会形成核心转储文件,前者会后者不会。当然我们的云服务器会默认将核心转储关闭。
我们先看看wait的返回值:

之前说过wstatus的低七位是进程退出信号,次低八位是进程退出码。那么第八位就是标记该进程有没有形成核心转储文件。
其实核心转储文件就是用于调试时找到出异常的代码,这里不想过多赘述。
信号的保存
明确概念
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
sigset_t

正如前面所提到的,信号其实保存在pcb的位图当中。我们前面产生的信号就保存在位图表中的pending。当block对应的信号为0,即该信号没有被阻塞时,在合适的时候进程就会递达这个信号。
我们用户也有对应的系统调用接口来调整这些位图:

其中sigset_t其实就是一个无符号整型,就是记录信号的位图也称为信号集。
我们来看函数名就可以知道大部分函数的含义,sigemptyset就是将set全置0,sigfillset就是全置1,sigaddset和sigdelset自然是给对应信号置1或置0.sigismember自然是检查对应信号是否在信号集中。

int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 则是对block图操作。
其中how包括三种参数:SIG_BLOCK、SIG_UNBLOCK、SIG_SETMASK.第一个参数相当于将我们添加set中的信号到block中,第二个自然就是接触,第三个则是将block图设置成set的样子。
oset则返回旧的block图。

sigpending则是读取当前pending图。
上述函数成功返回0,失败返回-1.
那么我们来使用一下这些函数吧:
cpp
void handler(int signal)
{
std::cout<<"catch signal: "<<signal<<std::endl;
}
void Print(sigset_t set)
{
for(int i=31;i>=1;i--)
{
std::cout<<sigismember(&set,i);
}
std::cout<<std::endl;
}
int main()
{
signal(2,handler);
sigset_t set;
sigpending(&set);
//打印当前pending图
Print(set);
kill(getpid(),2);
sigpending(&set);
Print(set);
return 0;
}

这说明我们信号处理玩后pending图对应的信号就置0了。事实上当我们进入handler的时候pending图就已经置空了,不然进程怎么继续接受相同的信号呢?
我们来试着将信号阻塞一下:


可以看到2号信号就没有被处理,并且pending图上也有2号咯。
我们来详细看看处理2号信号过程中,pcb的信号位图会发生什么吧:


可以看到处理该信号时会自动对该信号进行阻塞,这也是为什么我们处理这个信号的时候接收到同一个信号不会立刻处理。然后收到相同的信号时就会放到pending图上。最后处理结束之后再处理该信号。
那么根据这个原理我们料想到,处理该信号时接收到不同的信号,如果该信号没阻塞,会先执行这个信号的处理。
其实这是必然的,不然我在自定义处理方式中写个死循环不就没有信号可以中断这个进程了吗。
信号的处理
我们前文常说,信号会在合适的时候处理也就是递达。那么什么时候是合适的时候呢?
一句话:进程从内核态进入用户态的时候
- 硬件中断处理完毕
- 系统调用处理完毕(软中断)
- 信号处理函数执行完毕
- 进程时间片结束后重新进入运行队列
这些就是常见的执行时刻,我们先来讲讲硬件中断。
内核态
在计算机开机的时候,OS会将其接口加载到内存中,并生成一个函数指针数组管理。

在32位的Linux系统上,通常虚拟地址有4G大小,其中高地址的1G对应的是内核空间,这个内核空间其实也是个共享内存。因为所有进程的内核空间都指向同一个内核级页表。
当进程执行1~3G的代码就是用户态,执行内核区的代码就是内核态。
OS如何获取键盘的数据

当按下按键的时候,键盘会通过硬件给cpu发送中断信号。CPU将中断信号给OS,OS就会停下正在运行的进程,执行中断号在函数指针数组上对应的函数,拷贝键盘数据到内存上。
这就是所谓的硬件中断,我们的信号其实就是模拟中断,所以也称为软中断。
除此之外还有个常见的中断硬件就是时钟:

我们的时钟会定时给CPU发送中断信号,OS接受到时钟信号就会调度进程,切换运行队列之类。
那么硬件能给OS发送中断信号,软件自然也可以。
系统调用
此时我们就能理解系统调用是怎么执行的了,当执行系统调用接口时。该函数就会给发送中断信号如:int 0x80
这里的int 0x80是汇编操作不是整型。
OS接收到中断信号,就会从用户态转换成内核态 去执行相关的接口。
同时CPU内部的寄存器code segmgment会记录进程当前是用户态还是内核态。
OS如何运行
现在我们就知道OS本质就是一个死循环,然后时钟不断发送中断信号,然后执行调度任务和对应接口。
sigation


sigaction和signal类似,也是捕捉信号,只不过可以传入更详细的参数。这里就不做赘述。
可重入函数

如上这是一个简单的链表,如果我们执行链表头插:


要执行两步操作。
可如果我们在执行第一步操作的时候:

收到了信号,这个信号也是头插,就会:

然后回到头插函数:

可以看到我们就丢失了3号结点,也就是内存泄漏。
因此这个函数是不可重入函数。
可重入函数自然就是可重入的函数。
volatile
volatile是C语言的一个关键字,该关键字的作用是保持内存的可见性
其实前面在讲类型转换的时候也提到过,我们来看一个代码:


这里由于编译器的优化,将常值放到了寄存器中,也就是寄存器隐藏了内存中的真实值 。
加入关键字:


符合预期
SIGCHLD
最后说一下,子进程结束时会给父进发送SIGCHLD。只不过默认处理动作是忽略:
隐藏我们可以捕捉这个函数来等待子进程:
cpp
void notice(int signo)
{
std::cout << "get a signal: " << signo << " pid: " << getpid() << std::endl;
while (true)
{
pid_t rid = waitpid(-1, nullptr, WNOHANG); // 阻塞啦!!--> 非阻塞方式
if (rid > 0)
{
std::cout << "wait child success, rid: " << rid << std::endl;
}
else if (rid < 0)
{
std::cout << "wait child success done " << std::endl;
break;
}
else
{
std::cout << "wait child success done " << std::endl;
break;
}
}
}
signal(SIGCHLD, notice);
当然我们等待子进程目的是获取子进程退出信息和回收资源,如果只想回收资源还有最简单粗暴的做法:
cpp
signal(SIGCHLD, notice);
直接手动忽略该信号即可。