1. 什么是信号
1.1 信号的作用
我们之所以能理解生活中各种各样的信号,是因为我们知道各种的信号背后蕴含的信息,这些各式各样的信号指导着世界运作。不同信号对应着不同动作的执行。
在计算机中,信号是一种进程间通讯的有限制的方式。它们用于在进程之间传递信息或通知进程发生了某个事件的机制。例如在Linux中,信号是一种软件中断,它为Linux提供了一种处理异步事件的方法。例如,当终端用户输入Ctrl+C来中断程序时,它会通过信号机制使进程终止。
1.2 异步和同步
在进程间信号传递中,异步指的是信号可以在任何时候发送给某个进程,而不需要等待进程处于某种特定状态。信号是进程间通信机制中唯一的异步通信机制。
通过发送指定信号来通知进程某个异步事件的发送,以迫使进程执行信号处理程序。信号处理完毕后,被中断进程将恢复执行 。
和异步相对的是同步:
同步信号传递指的是:多个进程或线程之间通过某种方式协调它们的执行顺序,以便在正确的时间执行正确的操作。
以上课为例理解同步和异步,假设上课时小明有事出去了:
- 同步:全班暂停,直到小明回来以后才继续上课;
- 异步:继续上课,各忙各的,互不影响。
1.3 处理信号的方式
当一个进程收到一个信号时,它可以采取以下几种方式之一来处理该信号:
- 执行默认操作:每种信号都有一个默认操作,当进程收到该信号时,如果没有定义信号处理程序或选择忽略该信号,则会执行默认操作。例如,当进程收到
SIGINT
信号时,默认操作是终止进程。 - 忽略信号:进程可以选择忽略某些信号,这意味着当这些信号到达时,进程不会采取任何行动。
- 捕获信号并执行信号处理程序:进程可以为特定的信号定义一个信号处理程序。当该信号到达时,进程会暂停当前的执行流程,转而执行信号处理程序。当信号处理程序执行完毕后,进程会恢复原来的执行流程。
- 阻塞信号:进程可以阻塞某些信号,这意味着当这些信号到达时,它们不会立即被传递给进程。相反,它们会被挂起,直到进程解除对它们的阻塞。
注意:忽略信号本身就是一种处理信号的方式。
1.4 信号的种类
根据不同的需求,信号被分为实时信号和非实时信号:
- 非实时信号(不可靠/普通/标准信号):是 Linux 系统最初定义的信号,它们的编号从 1 到 31。每种标准信号都有一个预定义的含义和默认操作。
- 实时信号(可靠信号):是 Linux 系统后来引入的一种新型信号,它们的编号从 34 到 64。
标准信号和实时信号之间的区别主要是为了满足不同的应用需求。标准信号适用于简单的进程间通信,而实时信号则提供了更多的功能和灵活性,以满足复杂应用程序的需求。它们的区别在于:
- 标准信号不支持排队,这意味着如果一个进程在短时间内收到多个相同的信号,它只能处理其中一个,而其他的都会被丢弃。
- 实时信号支持排队和优先级,这使得它们能够更好地满足复杂应用程序的需求。此外,实时信号还提供了更多的信号编号,这使得应用程序可以定义更多的自定义信号。
可以通过man 7 signal
指令查看信号相关信息,其中 Action 列就是不同信号的默认处理动作(在1.8中会对 Action 列介绍):
可能出现的错误:No manual entry for signal in section 7
意味着系统中没有安装第 7 章的
signal
手册页,centos可以通过命令安装:
sudo yum install man-pages
在此仅讨论非实时信号。
编号 | 名称 | 解释 | 默认动作 |
1 | SIGHUP | 挂起 | 终止进程 |
2 | SIGINT | 中断 | 终止进程 |
3 | SIGQUIT | 退出 | 终止进程 |
4 | SIGILL | 非法指令 | 终止进程 |
5 | SIGTRAP | 断点或陷阱指令 | 终止进程 |
6 | SIGABRT | abort发出的信号 | 终止进程 |
7 | SIGBUS | 非法内存访问 | 终止进程 |
8 | SIGFPE | 浮点异常 | 终止进程 |
9 | SIGKILL | kill信号 | 不能被忽略、处理和阻塞 |
10 | SIGUSR1 | 用户信号1 | 终止进程 |
11 | SIGSEGV | 无效内存访问 | 终止进程 |
12 | SIGUSR2 | 用户信号2 | 终止进程 |
13 | SIGPIPE | 管道破损,没有读端的管道写数据 | 终止进程 |
14 | SIGALRM | alarm发出的信号 | 终止进程 |
15 | SIGTERM | 终止信号 | 终止进程 |
16 | SIGSTKFLT | 栈溢出 | 终止进程 |
17 | SIGCHLD | 子进程退出 | 默认忽略 |
18 | SIGCONT | 进程继续 | 终止进程 |
19 | SIGSTOP | 进程停止 | 不能被忽略、处理和阻塞 |
20 | SIGTSTP | 进程停止 | 终止进程 |
21 | SIGTTIN | 进程停止,后台进程从终端读数据时 | 终止进程 |
22 | SIGTTOU | 进程停止,后台进程想终端写数据时 | 终止进程 |
23 | SIGURG | I/O有紧急数据到达当前进程 | 默认忽略 |
24 | SIGXCPU | 进程的CPU时间片到期 | 终止进程 |
25 | SIGXFSZ | 文件大小的超出上限 | 终止进程 |
26 | SIGVTALRM | 虚拟时钟超时 | 终止进程 |
27 | SIGPROF | profile时钟超时 | 终止进程 |
28 | SIGWINCH | 窗口大小改变 | 默认忽略 |
29 | SIGIO | I/O相关 | 终止进程 |
30 | SIGPWR | 关机 | 默认忽略 |
31 | SIGSYS | 系统调用异常 | 终止进程,核心转储 |
作为查询补充:Linux中的31个普通信号
1.6 信号的保存
在Linux中,进程的PCB包含了进程的所有信息,操作系统使用了两个掩码和一个函数指针数组来保存控制进程的信号。这在下文会详细介绍。
掩码(mask)和位图(bitmap)都是使用二进制位来表示信息的数据结构。它们之间的区别在于用途不同。
掩码通常用于通过按位与或按位或运算来设置或清除某些位。例如,如果我们想要设置一个整数的第k位为1,我们可以使用按位或运算:
x = x | (1 << k)
。而位图通常用于表示一组元素的存在性。例如,如果我们想要表示编号为k的元素存在,我们可以设置位图中的第k位为1:
bitmap[k] = 1
。
1.7 信号发送的本质
所有信号都由操作系统发送,因为掩码存在于进程的PCB中,说明它属于内核数据结构,从掩码这种数据结构的角度看:
OS向目标进程发送信号,就是修改掩码中某一个位置的比特位,“发送”信号是形象的理解,实际上信号是被“写”入的。
那么我们使用组合键 Ctrl + C ,操作系统解释了这个组合键对应的信号编号,然后查找进程列表,让正在前台运行的进程的PCB中的掩码中的某个比特位变化。其中信号的编号就对应着掩码中的位置。
2. 产生信号
在 Linux 中,信号可以通过多种方式产生:
- 通过终端按键产生信号,例如用户按下 Ctrl + C 时会发送 SIGINT 信号 。
- 调用系统函数向进程发信号,例如使用
kill
函数向指定进程发送信号 。 - 由软件条件产生,例如当程序出现错误(如除零或非法内存访问)时会产生相应的信号 。
上面已经以常见产生信号的方式作为引入,硬件中断、系统调用、软件条件和硬件异常都是产生信号的手段。
2.1 硬件中断产生信号
终端按键产生信号的本质是硬件中断。当用户在终端按下某些键时,键盘输入产生一个硬件中断,被操作系统获取,解释成信号,发送给目标前台进程。
除了Ctrl + C 可以终止进程的运行外,还可以用 Ctrl + \ 组合键终止进程,实际上,它对应着信号编号为 3 的 SIGQUIT 信号。通过查阅man手册可以看到:
注意到 SIGQUIT 的 Action 和 SIGINT 的不同,SIGQUIT 和 SIGINT 都是用来终止进程的信号,但它们之间有一些区别:
- SIGQUIT 通常由 QUIT 字符(通常是 Ctrl + \)控制,当进程因收到 SIGQUIT 而退出时,会产生 core 文件,在这个意义上类似于一个程序错误信号。
- SIGINT 是程序终止(interrupt)信号,在用户键入 INTR 字符(通常是Ctrl + C)时发出,用于通知前台进程组终止进程。
Term 和 Core 都表示终止进程。Term 表示正常终止,而 Core 表示异常终止并生成core 文件。
核心转储
当进程出现异常时,重要的内容就会被加载到磁盘中,生成core.id。例如当以 Ctrl + \ 终止刚才的程序时:
在云服务器中,core.id 是默认不会生成的,原因是云服务器的生产环境的核心转储是关闭的:
通过指令 ulimit -c size 设置 core 文件的大小:
如果再次用 Ctrl + \ 终止进程,可以看到提示语句:
在可执行程序的目录中还能看到 core 文件:
它的后缀和进程的 pid 对应。
ulimit 命令改变的是 Shell 进程的 Resource Limit,但 signalTest 进程的 PCB 是由 Shell 进程复制而来的,所以也具有和 Shell 进程相同的 Resource Limit 值。这种方法是内存级的修改,再次开启终端便会回到默认状态。
事后调试[了解]
核心转储(core dump)是操作系统在进程收到某些信号而终止运行时,将此时进程地址空间的内容以及有关进程状态的其他信息写出的一个磁盘文件。这种信息往往用于调试中定位问题。
可以通过一个简单的除零错误让OS生成 core 文件:
#include <iostream> #include <unistd.h> using namespace std; int main() { cout << "即将发生除零错误" << endl; sleep(1); int a = 1 / 0; return 0; }
g++加上 -g 选项,以开发模式编译:
g++ -o $@ $^ -std=c++11 -g
在此之前,我们在Linux中对程序调试用 gdb + 可执行程序名,必须要自己打断点定位错误,费时费力,有了 core 文件以后就能直接定位到问题位置:
code-FILE + core.id
core dump 标记位
core dump即核心转储,进程等待接口 waitpid 的第二个参数 status 是一个输出型参数,它的第 7 个比特位就是标识是否发生核心转储的位置:
pid_t waitpid(pid_t pid, int *status, int options);
man signal 手册的 Action 列中的 Core 就是让OS判断是否发生核心转储的意思。
- 如果进程正常终止,core dump 标志位也就没有它存在的意义,因此 status 的次低 8 位表示进程的退出状态;
- 如果进程被信号终止,status 的低7位表示终止信号,第 8 位比特位就是 core dump 标志位,表示进程终止时是否(1/0)进行了核心转储。
有了终止信号之后,还需要让操作系统接收到这个终止信号是否会发生核心转储。下面将不捕捉信号,直接获取到进程的 status 输出型参数,并通过位运算获取到第七位的 core dump 标志位。
#include <iostream> #include <unistd.h> #include <signal.h> #include <sys/wait.h> #include <sys/types.h> using namespace std; int main() { pid_t id = fork(); if(id == 0) // 子进程 { cout << "即将发生除零错误" << endl; sleep(1); int a = 1 / 0; exit(0); } int status = 0; waitpid(id, &status, 0); cout << "父进程[" << getpid() << "]:子进程[" << id << "], exit signal: " << (status & 0x7F) << " core dump: " << ((status >> 7) & 1) << endl; return 0; }
可以得到遇到除零错误时,OS给进程发送的终止信号是 8 ,发生了核心转储,生成了core 文件。
除此之外,我们还可以用 kill [信号编号] [PID] 在终止进程的同时发送信号,例如:
int main() { pid_t id = fork(); if(id == 0) // 子进程 { while(1) { sleep(1); cout << "child process is running" << endl; } exit(0); } int status = 0; waitpid(id, &status, 0); cout << "父进程[" << getpid() << "]:子进程[" << id << "], exit signal: " << (status & 0x7F) << " core dump: " << ((status >> 7) & 1) << endl; return 0; }
可以验证,2 号信号是不会发生核心转储的。
2.2 系统调用产生信号
系统调用可以产生信号。当进程为某个信号注册了信号处理程序后,当接收到该信号时,内核就会调用注册的函数。例如注册信号处理函数可通过系统调用signal()或sigaction()来实现。
signal 函数
在Linux中,信号可以通过几种不同的方式产生,首先以熟悉的键盘组合键产生的信号作为引入。处理信号要有具体的逻辑实现,在Linux中通过回调函数实现,各种对信号的操作被打包为一个个函数,对于我们而言,信号的操作就是代码的逻辑。
对信号的处理通过函数 signal 完成,它的原型(可通过man 2 signal查看):
#include <signal.h> typedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler); // 等价↕ void (*signal(int signum, void (*handler)(int)))(int)
参数:
- signum:信号的编号;
- handler:是一个函数指针,指向一个带有一个整数参数且返回值为void的函数。这个函数就是信号处理函数。
返回值:
返回传入的参数 handler,即函数指针。
我们在使用 Ctrl + C 终止进程时,实际上是这个组合键对应的信号被操作系统获取后,让前台进程杀死了当前进程。实际上这个信号就是2号信号 SIGINT。
下面将用 signal 接口捕获2号信号:
#include <iostream> #include <unistd.h> #include <signal.h> using namespace std; void catchSignal(int signum) { cout << "进程[" << getpid() << "]捕捉到信号:[" << signum << "]" << endl; } int main() { // 捕捉2号信号 signal(SIGINT, catchSignal); // 用循环让进程一直运行 while(1) { cout << "进程[" << getpid() << "]正在运行" << endl; sleep(1); } return 0; }
运行起来后,即使多次使用组合键 Ctrl + C 也无法杀死进程,而且进程还按照写的处理方式打印了提示语句:
这个现象说明进程signal接口成功捕获了2号信号,并且验证了键盘组合键 Ctrl + C 就是2号信号。要杀死进程,只能使用kill -9 pid
终止进程了。
值得注意的是,signal的第二个参数虽然是函数的地址,但是调用signal并不代表它会立刻调用第二个参数对应的函数,它是一种注册行为,只有捕获到它的第一个参数即信号编号时才会调用自定义函数,修改进程对特定信号默认的处理动作。在这里,2号信号SIGINT的默认处理动作就是中断(interrupt)进程的运行。
在实际情况下,可能捕捉的信号不是2号,signal后的逻辑也不一定是死循环,也可能是长时间执行的代码,总之signal的调用表示它之后的逻辑中一旦遇到了指定的signum信号的编号,那么就会执行对应的操作(第二个参数)。 在这里为了保证signal一定能捕捉到指定的信号,使用了死循环。假如后续没有任何指定信号编号(第一个参数)被进程接收,第二个参数也就不会被调用。
- Ctrl + C 产生的信号只能发送给前台进程。在一个命令后面加上 & 就可以让它后台运行,这样 Shell 就不必等待进程结束就可以接收新的命令,启动新的进程。
- Shell 可以同时运行一个前台进程和任意多个后台进程,但是只有前台进程才能接到像 Ctrl + C 这种控制键产生的信号。
在这里也验证了信号对于进程的控制流程是异步的,因为信号一旦被发送就会被进程立刻处理,即进程一旦接收到信号,就会暂停当前正在执行的逻辑,优先执行信号对应的处理操作。而signal接口就起着导向作用。
kill 函数
实际上 kill 命令是封装了 kill 系统调用实现的,我们可以自己实现一个 mykill 命令。
kill
用于向任何进程组或进程发送信号。函数原型:
#include <signal.h> int kill(pid_t pid, int sig)
参数:
pid
:进程 ID;sig
:要发送的信号的编号。如果sig
的值为 0,则没有任何信号送出,但是系统会执行错误检查,通常会利用sig
值为 0 来检验某个进程是否仍在执行。
返回值:
- 成功:返回 0;
- 失败:返回 -1 。
命令行参数其实就是一个字符串,main
函数作为程序的入口,它是有参数的,被称之为命令行参数。在 C 语言中,main
函数通常有两种形式:int main(void)
和 int main(int argc, char *argv[])
,这两种形式的参数是隐藏的。其中,argc
是命令行参数的个数,argv
是一个指向字符串数组的指针,其中包含了命令行参数。
尽管 main 函数不是一个可变参数函数,但是它可以通过 argc 和 argv 来接收命令行参数,这些参数的个数和内容是不确定的。因此,可以认为 main 函数通过 argc 和 argv 来接收可变数量的命令行参数,并自动以空格分隔放进数组。
假设我们要输入的命令是这样的:./mykill -2 pid ,那么参数个数 argc 为3,我们使用字符串转整数 atoi,提取出传入的命令编号和进程 PID。然后将它们作为参数传入系统调用 kill ,完成手动终止进程的操作。
#include <iostream> #include <cstring> #include <string> #include <signal.h> using namespace std; static void Usage(string proc) { cout << "format:\t\n\t" << proc << " [sigNum] [procId]" << endl; } // 示例命令: ./mykill -2 pid int main(int argc, char* argv[]) { if(argc != 3) { Usage(argv[0]); exit(1); } int sigNum = atoi(argv[1]); int procId = atoi(argv[2]); int ret = kill(procId, sigNum); if(ret < 0) { cout << "kill failed" << endl; } else { cout << "killed" << endl; } return 0; }
让 Shell 运行一个 sleep,时间足以让我们观察现象:
raise 函数
raise() 用于向程序发送信号。原型:
int raise(int sig);
参数 sig:是要发送的信号码。
返回值:
- 成功:返回0;
- 失败:返回非零。
#include <iostream> #include <signal.h> #include <unistd.h> using namespace std; int main() { int count = 5; while(count--) { cout << "process is running" << endl; sleep(1); } raise(8); return 0; }
打印的内容就是 8 号信号对应的 Comment。
在上面的例子中,自己给自己发送8号信号也是一种产生信号的方式。向程序发送信号,以便在程序运行过程中触发某些事件或操作。例如,你可以使用 raise(SIGINT) 来模拟用户按下 Ctrl + C 来中断程序的执行。它也可以用于测试程序对特定信号的响应。
abort 函数
abort 的作用是异常终止一个进程,它向目标进程发送一个 SIGABRT 信号,意味着 abort 后面的代码将不再执行。原型:
void abort(void);
它没有参数,也不返回任何值。
#include <iostream> #include <signal.h> #include <stdlib.h> #include <unistd.h> using namespace std; int main() { int count = 5; while(count--) { cout << "process is running" << endl; sleep(1); } abort(); return 0; }
注意它没有参数。
当调用 abort 函数时,会导致程序异常终止,而不会进行一些常规的清除工作。和 exit 函数的区别是:后者会正常终止进程。由于 abort 本质是暴力地通过向当前进程发送 SIGABRT 信号而终止进程的,因此使用 exit 函数终止进程可能会失败,使用 abort 函数终止进程总能成功。
小结
当这些产生信号的系统接口被调用时,机器执行对应的内核代码,操作系统提取参数或设置为特定的数值,向目标进程发送信号。而发送信号的本质就是修改进程 PCB 中掩码的某个标记位,位置和信号编号对应。当进程发现它的 PCB 中的掩码的某个比特位被修改了以后,就会执行事先规定好的操作。
注意:当一个进程正在执行一个系统调用时,如果向该进程发送一个信号,那么对于大多数系统调用来说,这个信号在系统调用完成之前将不起作用,因为这些系统调用不能被信号打断。
2.3 软件条件产生信号
软件中断能产生信号。信号本质上是在软件层次上对中断机制的一种模拟,它可以由程序错误、外部信号或显式请求产生。例如,当程序执行过程中发生除零错误时,操作系统会向该程序发送一个 SIGFPE 信号。
在管道中,如果管道的读端被关闭,而写端一直写,那么在写入一定量的数据后,写端会收到一个 SIGPIPE 信号(13号)。这个信号的默认行为是终止进程。如果进程忽略了这个信号或者捕获了这个信号并从其处理程序返回,那么写操作会返回-1,errno 被设置为 EPIPE。
在这种情况下,软件条件产生的信号是 SIGPIPE
信号。
通过提取输出型参数 status 的信号码的操作在 2.1 中已经演示过了。 实现的代码在这里。
对管道文件只写不读,操作系统会识别到这个情况,称之为软件条件不足。这是可以理解的,因为管道本身就是一种文件。
SIGALRM 信号
SIGALRM
信号通常用于实现定时器功能。当你希望在一段时间后执行某个操作时,可以使用 alarm
函数来设置一个定时器,当定时器到期时,内核会向你的进程发送 SIGALRM
信号。你可以通过捕获这个信号并在信号处理函数中执行相应的操作来实现定时功能。
原型:
#include <unistd.h> unsigned int alarm(unsigned int seconds);
seconds 参数:无符号整数,作为表示定时器的秒数。
返回值:返回上一个定时器剩余的秒数,如果没有上一个定时器,则返回0。
如果你没有为 SIGALRM
信号设置信号处理函数,那么当进程收到这个信号时,它会执行默认操作,即终止进程。
下面将测试我的服务器在 1s 内能计算多少次++操作,结果用 count 保存并输出:
#include <iostream> #include <unistd.h> #include <signal.h> using namespace std; int main() { alarm(1); int count = 0; while (1) { count++; cout << "count:" << count << endl; } return 0; }
现在的CPU每秒计算数以亿次,这里却只累加了近2万次,造成这种情况主要是IO太慢了,包括:
cout
语句可能会影响程序的性能。每次循环迭代时,都会调用cout
来输出信息,这会增加额外的开销。- 网络传输带来的开销:每次打印的数据都会通过网络传输到本地,实际显示的结果比1秒内累加的次数要少得多。
- [非重要原因]这个程序可能不是唯一在计算机上运行的程序。操作系统会在多个进程之间共享 CPU 时间,因此此程序可能无法获得全部 CPU 时间。
每计算一次,进程都会被阻塞(停下来),IO(包括上面两方面)完成以后才会再计算下一次,可见 IO 非常费时间。如果要单纯计算算力,我们可以用 signal 捕捉信号:
#include <iostream> #include <unistd.h> #include <signal.h> #include <stdlib.h> using namespace std; int count = 0; void Handler(int sigNum) { cout << "final count:" << count << endl; exit(1); } int main() { signal(SIGALRM, Handler); alarm(1); while(1) { count++; } return 0; }
当1秒后触发 alarm 后它就会被自动移除,如果想周期性地每秒打印,可以在函数中再次设置 alarm:
long long count = 0; void Handler(int sigNum) { cout << "final count:" << count << endl; alarm(1); }
将 count 定义为 long long 以避免溢出。
这样就用 alarm 实现了一个基本的定时器功能,能够每秒打印 count。
为什么不用sleep 实现周期性打印?(alarm和sleep的区别)
- alarm 函数用于设置信号 SIGALRM 在经过指定秒数后传送给当前进程。如果忽略或不捕捉此信号,则其默认动作是终止调用该 alarm 函数的进程。每个进程只能有一个闹钟时间。如果在调用 alarm 之前已设置过闹钟时间,则任何以前的闹钟时间都被新值所代替。
- sleep 函数用于使调用的进程睡眠指定秒数。调用 sleep 的进程如果没有睡眠足够的秒数,除非收到信号后才会返回。sleep 的返回值是0,或剩余的睡眠秒数。
alarm和sleep的关系?
sleep 是在库函数中实现的,它是通过 alarm 来设定报警时间,使用 sigsuspend 将进程挂起在信号 SIGALRM 上。
小结
如何理解软件条件给进程发信号:
- 操作系统首先识别到某种软件条件触发或不满足
- 操作系统构建信号,发送给指定进程
2.4 硬件异常产生信号
在 Linux 中,硬件异常指的是一些硬件错误,例如除零错误或访问进程地址空间以外的存储单元等。这些事件通常由硬件(如CPU)检测到,并将其通知给Linux操作系统内核,然后内核生成相应的信号,并把信号发送给该事件发生时正在进行的进程。如果进程没有捕获并处理这些信号,操作系统会采取默认行为,通常是杀掉进程。
注意区分硬件中断:
硬件中断是指由计算机硬件设备产生的中断信号。它通常用于通知操作系统有新的外部事件发生,需要进行处理。硬件中断完全是随机产生的,与处理器的执行并不同步。
例如键盘输入通常是通过硬件中断实现的。当用户在键盘上按下一个键时,键盘控制器会向计算机发送一个中断信号。这个信号会通知计算机有新的输入需要处理。然后,计算机会暂停当前正在执行的任务,转而执行中断处理程序来处理这个输入。
硬件中断还可以用于其他外部事件的处理,例如鼠标移动、网络数据到达、磁盘读写完成等。它们都通过向 CPU 发送中断信号来通知操作系统进行处理。
进程崩溃的本质
C/C++程序崩溃通常是由于程序运行时出现错误导致的。这些错误可能包括内存问题,例如内存越界,访问空指针,野指针等,而这些错误通常是内核接收到由硬件异常产生的信号才能确定程序崩溃的原因的。
下面以访问空指针为例,说明硬件异常产生的信号是如何让程序崩溃的:
- 用 signal 捕捉信号,并注册了一个函数 handler;
- handler 函数会打印捕捉到信号的编号;
- 设计一个访问空指针,那么 signal 捕捉的是 SIGSEGV(11)信号;
- 在死循环中 sleep,以便能观察现象。
void Handler(int sigNum) { sleep(1); cout << "捕捉到信号: " << sigNum << endl; } int main() { signal(SIGFPE, Handler); int *p = nullptr; *p = 1; while(1) { sleep(1); } return 0; }
在 main
函数中,首先使用 signal
函数将 SIGSEGV
信号与 Handler
函数关联起来。然后,程序执行了一个访问空指针的操作,这会触发 SIGSEGV
信号。
但是程序进入了一个无限循环,这是不符合预期的,因为访问空指针应该会让进程终止,原因是我们修改了11号进程的默认动作,处理信号的方式被改成自定义的 Handler 函数。
这样的话,是不是1-31信号都能这样被修改处理信号的默认动作呢?
首先答案是否定的,大多数信号都可以被捕获并由用户定义的处理程序进行处理,但是有些信号(如 SIGKILL(9号) 和 SIGSTOP)不能被捕获或忽略。
下面通过使用 signal 注册多个信号,并将它们和自定义的 Handler 绑定,再用 kill 命令验证:
void Handler(int sigNum) { sleep(1); cout << "捕捉到信号: " << sigNum << endl; } int main() { signal(1, Handler); signal(2, Handler); signal(3, Handler); signal(4, Handler); signal(9, Handler); while(1) { sleep(1); } return 0; }
通过这个例子可以验证,即使用户修改了 9 号进程的默认处理方式,对于操作系统而言是无效的。
补充:
while(1)
是一个无限循环,它会一直执行循环体中的代码。而while(1) {sleep(1)}
也是一个无限循环,但是每次执行完循环体中的代码后,程序会暂停1秒钟再继续执行下一次循环。这样可以减少程序对CPU的占用。
如何理解除零错误
CPU对两个操作数执行算术运算时,会将它们放在两个寄存器中,计算完毕后将结果放到寄存器中写回。其中,有一个叫做“状态寄存器”的家伙,它类似一个掩码,用某个位置的比特位标记当前指令执行的状态信息(进位、溢出等)。
操作系统位于硬件之上、软件之下,是软硬件资源的管理者,如果 OS 发现程序运行时CPU中的状态寄存器出现异常(通过比特位),由于当前 CPU 处理的数据的上下文属于某个进程,因此 OS 可以通过它找到目标进程。CPU 中有个寄存器保存着这个进程信息,内核中有个指针 correct,指向当前运行进程的 PCB,它也会被load到 CPU 的寄存器中,所以 OS 可以通过这个指针找到进程的 PCB ,进而找到进程的 PID,通过指令将识别到的硬件异常封装为信号打包发送给目标进程。因此除零错误的信息传递是通过寄存器耦合实现的。
那么对于除零错误,硬件会触发中断,OS 就会将硬件上传的除零错误信息包装成信号,找到进程的 task_struct,向其中的掩码的第 8 比特位写入 1,这时 OS 就会被进程(在合适的时候)终止(注意这里的措辞)。
中断机制:
中断机制是现代计算机系统中的基本机制之一,它在系统中起着通信网络的作用,以协调系统对各种外部事件的响应和处理。中断是实现多道程序设计的必要条件,中断是CPU 对系统发生的某个事件作出的一种反应。
简单来说,中断机制可以让计算机暂时停止某程序的运行,然后转到另外一个程序,CPU会运行该程序的指令。
结论
因此,除零错误的本质是硬件异常。
一旦出现了硬件异常,进程不一定会立马退出(我们能修改部分信号的默认行为),默认行为是退出的原因是:捕捉了异常信号但是不退出,程序员也拿它没办法,进程终止以后也会释放资源,所以捕获到异常信号的默认处理方式就是直接退出。
出现死循环的原因是:在寄存器中的异常信息一直没有被解决。
如何理解野指针问题
我们知道,操作系统提供给进程的地址并非真实的物理地址,而是通过页表映射的虚拟地址,进程要访问某个变量的内存,必须用自身的虚拟地址,内核会根据页表找到物理地址。
MMU(Memory Management Unit,内存管理单元),它是一种硬件电路单元,负责将虚拟内存地址转换为物理内存地址,而页表是 MMU 完成上述功能的主要手段,即 MMU 是虚拟地址到物理地址的桥梁。
MMU 现在已经被集成在 CPU 中,它作为一个硬件,信息也会被操作系统管理。当访问了不属于进程的虚拟地址时, MMU 转换成物理地址就会出现错误,它的状态就会被操作系统识别,操作系统就会向目标进程发送 SIGSEGV 信号。
因为操作系统也属于一种软件,所以页表是一种软件映射关系,那么处理野指针的过程就是软件结合向进程发送信号。代码执行时,CPU 的调度比较温和,它会保存数据和恢复进程。
总结
- 所有信号都有它的来源,但最终都是被操作系统识别、解释并发送给进程的。
- 给进程发送信号的本质是修改进程 PCB 中掩码中的某个标记位,信号编号对应掩码中的位置。
- 对于自定义捕捉动作(函数),当触发信号是,才会调用(回调)我们自定义的函数,signal 函数是一种注册机制。这个函数可能会被延后调用,这取决于信号何时产生,如果永远不产生该信号,那么这个回调方法也不会被调用。
- 了解信号的基本原理可以帮助我们更好地理解代码中的信号处理部分。信号是一种软件中断,它提供了一种处理异步事件的方法。例如在程序运行过程中接收到终止信号时如何优雅地退出程序。这样,我们就能够更好地看待代码,并编写出更健壮、更可靠的程序。
3. 阻塞信号
信号阻塞就是让系统暂时保留信号待以后发送。
3.1 信号的状态
- 实际执行信号的处理动作,称为信号递达(Delivery)。
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞(Block)某个信号。
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。注意:信号阻塞和信号忽略是不同的。只要信号被阻塞就不会递达,除非解除阻塞,而忽略是在递达之后可选的一种处理动作,它们在时间线上是一前一后的关系。
不是所有的信号都被处理为信号未决。也不是所有的信号都处理(递达)。具体取决于需求。
3.2 相关数据结构
task_struct 是 Linux 内核的一种数据结构,它会被装载到RAM中并且包含着进程的信息。每个进程都把它的信息放在 task_struct 这个数据结构体中。在Linux操作系统中,每个进程都有一个唯一的进程控制块(Process Control Block, PCB),它包含了所有该进程的运行状态信息以及所需的所有资源。task_struct 结构是进程控制块中最重要的一个结构,它包含了该进程的所有信息。
内核中 task_struct 的定义在:/usr/src/kernels/3.10.0-1160.83.1.el7.x86_64/include/linux/sched.h
中,可以通过 vim 使用:\task_struct
查找关键字找到,这个路径中的3.10.0-1160.83.1.el7.x86_64
是当前机器安装的 Linux 版本号。关于信号部分的定义:
struct task_struct { /* ... */ int sigpending; sigset_t blocked; struct signal_struct *sig; struct sigpending pending; /* ... */ };
这些字段用于存储进程与信号处理相关的信息,首先了解它们的类型。
类型说明:
- sigset_t:可以被实现为整数(即掩码)或结构类型,用于表示信号集。
- struct signal_struct:主要作用是存储信号与处理函数之间的映射,其定义如下:
struct signal_struct { atomic_t count; struct k_sigaction action[_NSIG]; spinlock_t siglock; wait_queue_head_t signalfd_wqh; };
- 其中,action 成员是一个长度为 _NSIG 的数组,下标为 k 的元素代表编号为 k 的信号的处理函数。
- struct sigpending:用于存储进程接收到的信号队列,其定义如下:
struct sigpending { struct list_head list; sigset_t signal; };
- 其中,list 成员是一个双向链表,用于存储接收到的信号队列;signal 成员是一个信号集,用于保存接收到的信号的编号掩码。
在 Linux 内核中:
- blocked:掩码结构,表示被屏蔽的信息,每个比特位代表一个被屏蔽的信号;
- sig:表示信号相应的处理方法,它指向的结构体中有一个函数指针数组,保存着函数的地址,这个数组是专门用来存储用户自定义的函数地址的(内核设置的处理信号的默认动作不会存储在这个数组中);
- pending:存储着进程接收到的信号队列,其成员 signal 类型和 blocked 相同,也是一个掩码。
为了更好地理解,将以上后两个结构中最重要的成员代表代表整个结构,即 sig 指向对象中的数组,暂称为 handler;pending 中的 signal 成员。以数据结构分组:
- 两个掩码:blocked,pending;
- 一个数组:handler。
阻塞信号集也叫信号屏蔽字(Signal Mask),即表示处理信号的方式是阻塞。
3.3 信号如何被处理
总结一下信号相关数据结构:
- blocked:表示信号是否被阻塞;
- pending:表示进程是否接收到该型号,每一个位置都对应着一个信号;
- handler:表示信号被递达时的处理动作,下标和信号编号对应。
每个信号都有两个标志位分别表示阻塞(block,1)和未决(pending,0),还有一个函数指针用来表示处理动作。信号产生时,内核在 PCB 中设置该信号的未决状态,直到信号递达才清除该标志。
blocked 和 pending 搭配工作,在 blocked 中标志位为1的信号是被阻塞的,要让进程处理它,必须让标志位被置为0,即解除阻塞。
图中的 SIGUP 信号既未被进程阻塞,也未被接收,因为 blocked 和 pending 掩码的第1比特位是0,因此它在被递达时会执行内核设置的默认动作,而不会调用 handler 数组中的自定义处理方式(如果注册的话),即函数1。
图中 SIGINT 信号的两个掩码都被设置为1,表示 SIGINT 信号被进程接收后被阻塞,保持在未决状态,即使处理这个信号的默认处理方式是忽略,进程也必须在解除阻塞以后再忽略,因为忽略是处理信号的一种方式。
图中 SIGILL 信号未被接收,它一旦被接收就会被阻塞,处理动作被修改为用户自定义的处理方式,即函数4。如果在进程解除对某个信号的阻塞状态之前,这种信号产生过多次,在 Linux 内核中:普通信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。
操作系统使用 handler 数组的规则
函数指针数组的下标即信号的编号,这使得我们可以以O ( 1 ) O(1)O(1)的速度查找到信号对应的比特位,实现信号的写入。操作系统要捕捉信号,首先要知道信号编号 signum,然后通过 handler 函数指针数组索引到对应的函数,然后将它强转为 int,如果为0,则执行默认动作,如果为1,则执行忽略动作。如果都不满足才会调用这个位置指向的函数。
所以通过这个流程可以知道,我们通过 signal 函数传送信号,并不一定会立刻调用它的第二个参数,而是将这个函数的地址放到第一个参数对应的下标位置上。
小结
操作系统把信号发送到 pending 中后,首先要看 blocked 对应的标志位是否被置为1,只有是0的时候才会去 handler 数组中调用对应函数。
体会:
学习 OS 的过程,能更加体会到数据结构和算法存在的意义,体会计算机科学的哲学思想,结构决定算法!
理解某个知识点“在做什么”是非常重要的,它往往不是高深的,抽丝剥茧地学习某个知识,不仅仅是把这个知识“搬”到我们脑子里,而是学习它(底层)的思想,这能改变我们看待知识的视角,提高我们对事物的认知能力。
例如我们学高数,即使会做题,也不知道它在干嘛。即使知道有寄存器这个玩意,如果我们不知道寄存器、操作系统存在的意义,学起来就会一头雾水。现在回过头看,以前闷着头学习的概率论、线性代数和离散数学,就是我们学习数据结构与算法的基础。
3.4 系统级类型
在 Linux 操作系统中,内核级类型是指为内核设计的数据类型。这些类型通常用于内核程序中,以便更好地管理和操作内核数据结构。例如C语言的 struct_file 和 FILE ,如果某些操作访问了硬件,那么它一定是通过内核系统调用实现的,因为操作系统是软硬件的中间层。因此所有的语言都要调用操作系统接口才能正常工作。
这些类型通常在内核头文件中定义,可以在内核程序中使用。
sigset_t
sigset_t
是一个内核级类型,是由操作系统提供的数据类型,它用于表示信号集。
信号集是什么?
在 Linux 操作系统中,信号集是一个数据类型,用于表示一组信号。它通常用于阻塞或解除阻塞一组信号,或检查一组信号是否处于未决状态,信号集由 sigset_t 类型表示。
- 在阻塞信号集中,“有效”和“无效”的含义是该信号是否被阻塞。
- 在未决信号集中,“有效”和“无效”的含义是该信号是否处于未决状态。
sigset_t 是一个不透明的数据类型,它的具体实现取决于操作系统和编译器。在 Linux 操作系统中,sigset_t 通常定义为一个位掩码,其中每个位表示一个特定的信号。例如,在 Linux 的 glibc 库中,sigset_t 的定义如下:
// 在头文件<signa.h>中 #define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int))) typedef struct { unsigned long int __val[_SIGSET_NWORDS]; } __sigset_t;
请注意,这只是 sigset_t
的一个实现,它可能会因操作系统和编译器的不同而有所不同。应该避免直接访问 sigset_t
的内部结构,而应该使用相关的函数来操作它,例如 sigemptyset
、sigfillset
、sigaddset
和 sigdelset
等。
- sigset_t 不允许用户自己进行位运算,所以 OS 给程序员提供了对应的操作方法(体现了结构决定算法)。
- sigset_t 是用户可以直接使用的类型,和内置类型及自定义类型是同等地位的。
- sigset_t 需要对应的系统接口来完成对应功能,其中参数可能就包含了sigset_t定义的对象或变量。
3.5 信号集操作函数
sigpending
sigpending 返回进程的 pending 信号集,即在阻塞时已经被触发的信号。挂起信号的掩码将返回到变量 set 中。原型:
#include <signal.h> int sigpending(sigset_t *set);
set 参数:输出型参数,用于存储 pending 信号集。
返回值:成功返回0,失败返回-1。
sigpromask
sigprocmask 用于获取或更改 blocked 掩码。该调用的行为取决于 how 的值。原型:
#include <signal.h> int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数:
- how:更改 blocked 掩码的方式。
- set:表示要更改的信号集。
- oldset:输出型参数,如果不为 NULL,则在其中存储信号掩码的先前值,即返回修改之前的 blocked 掩码。
其中,how 有三种方式:
- SIG_BLOCK:set 包含了我们希望添加到当前信号屏蔽字的信号,相当于
mask=mask|set
- SIG_UNBLOCK:set 包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于
mask=mask|~set
- SIG_SETMASK:设置当前信号屏蔽字为 set 所指向的值,相当于
mask=set
返回值:成功返回0,失败返回-1。
注意:如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask函数返回前,至少将其中一个信号递达。
测试
需要验证的问题:
- 如果对所有信号自定义捕捉,是不是就相当于写了一个不会被异常或被用户杀掉的进程?这在 2.4 中已经被初步验证了,是不行的。
- 如果将2号信号 blocked,并且不断获取当前进程的 pending 信号集,然后突然发送一个2号信号,2号信号则无法被递达,它将一直被保存在 pending 信号集中,此时的现象是 pending 信号集中有一个比特位0->1
- 如果对所有信号 blocked,也就是阻塞所有信号,这样是不是也写了一个永远不会被异常或被用户杀掉的进程?答案是否定的。
测试1
用循环将1->31的信号通过 signal 注册为阻塞,并绑定函数 catchSig,函数会打印捕捉到的信号编号:
#include <iostream> #include <unistd.h> #include <signal.h> using namespace std; void catchSig(int signum) { cout << "捕捉到信号: " << signum << endl; } int main() { for(int i = 1; i <= 31; i++) { signal(i, catchSig); } while(1) { sleep(1); } return 0; }
signal
函数用于设置信号处理函数,但并不是所有的信号都可以被阻塞。例如,SIGKILL
和SIGSTOP
这两个信号是不能被阻塞的。因为它们是用来强制终止或暂停一个进程的。如果这两个信号可以被阻塞,那么就可能出现无法终止或暂停一个进程的情况,这会影响系统的稳定性和安全性。所以,操作系统设计者决定不允许这两个信号被阻塞。
其中 while(1) 的作用是让进程一直运行,能不断读取信号,加上 sleep 的原因是减少对内存资源的占用。