目录
0.预备知识
0.1.基本概念
0.2.信号的捕捉
0.3.理解信号的发送与保存
1.信号的产生(阶段一)
1.通过kill命令,向指定进程发送指定的信号
2.通过终端按键产生信号:ctrl+c(信号2),ctrl+\(信号3)。
3.通过系统调用:
4.由软件条件产生信号
接下来我们来认识一个新的案例--alarm(闹钟)函数。
5.硬件异常产生信号
6.关于term和core
2.信号的保存(阶段二)
3.信号的处理(阶段三)
1.信号集sigset_t 编辑
2.信号集操作函数
3.sigprocmask(操作block表的函数)
4.sigpending(检查pending信号集,获取当前进程pending位图)
5.代码样例
6.再谈信号的捕捉
7.对内核态和用户态的进一步理解
4.关于信号捕捉的细节部分(sigaction函数)
5使用信号可能导致的问题
1.可重入函数
2.使用信号对全局变量进行操作出现的问题(volatile)
3.SIGCHLD信号
0.预备知识
信号的三个阶段,信号产生,信号保存,信号处理
0.1.基本概念
信号,又称为软中断信号,是Linux系统响应某些条件而产生的一个事件。它是操作系统向一个进程或者线程发送的一种异步通知,用于通知该进程或线程某种事件已经发生,需要做出相应的处理。
当进程接收到信号时,可以采取以下处理方式:
- 忽略信号:进程可以选择忽略某些信号,即不对该信号进行任何处理。
- 捕捉信号(自定义处理):进程可以注册一个信号处理函数来捕捉特定的信号,并在接收到该信号时执行相应的处理逻辑。
- 默认处理:如果进程没有注册信号处理函数且没有选择忽略信号,则系统会按照默认的处理方式来处理该信号。通常情况下,默认处理方式会导致进程终止或停止。
使用kill -l就可以看到所有的信号 ,我们把前三十一个称为普通信号(也称为不可靠信号),都是大写的宏,前面的数字就是宏对应的值
使用man 7 signal往下翻就可以看到一张表,包含了,我们的大多数信号所对应的默认动作
大多数都是终止。
0.2.信号的捕捉
捕捉信号是操作系统中进程处理信号的一种方式,允许进程在接收到信号时执行自定义的操作,而不是执行信号的默认动作。
引入系统调用signal:
signal接口是Unix和Linux系统中用于处理信号的一个系统调用。它允许程序指定一个函数,当接收到特定信号时,该函数会被自动调用。通过这种方式,程序可以对信号进行自定义处理,或者忽略某些信号。
- signum:要处理的信号类型,它是一个整数,对应于某个具体的信号。
- handler:函数指针类型,用来接收自定义的函数。执行调用的函数就是执行自定义的操作。
使用示例;
对信号的自定义捕捉,我们只要捕捉一次,后续就一直有效。(在这里我们使用2号信号来做测试,2默认动作就是终止,就是CTRL+c)
#include <iostream> #include <signal.h> #include <unistd.h> void hander(int sig) {std::cout<<"get a sig"<<sig<<std::endl; } int main() {signal(2,hander);while(true){std::cout << "hello bit, pid: " << getpid() << std::endl;sleep(1);} }
现象:我们每发送一次2号信号就执行一次hander函数。
当然你可以捕捉更多其它的信号。
你也可以使用ctrl+c发送2号信号:
9号信号是不允许被捕捉的,是用来异常终止的。为了维护系统的安全性和稳定性,某些命令可能不允许或限制用户自定义信号捕捉。
0.3.理解信号的发送与保存
那么信号又是如何保存的呢?
Linux内核为每个进程维护了与信号相关的数据结构,主要包括三张表:
- Block表(阻塞表):一个位图,用于表示进程当前阻塞了哪些信号。每个信号都对应一个比特位,如果该位为1,则表示该信号被阻塞;为0则表示该信号未被阻塞。
- Pending表(未决表):同样是一个位图,用于表示进程当前有哪些信号处于未决状态。当一个信号产生且未被处理时,Pending表中对应的位会被设置为1。
- Handler表(处理函数表):一个函数指针数组,用于表示当信号递达时应该执行的处理动作。每个信号都对应一个处理函数指针,可以是默认处理函数、忽略函数或用户自定义的处理函数。
当信号产生时,内核会执行以下操作来保存信号:
- 设置Pending表:在进程控制块(PCB)的Pending表中,将对应信号位的值从0设置为1,表示该信号已经产生且处于未决状态。
- 检查Block表:内核会检查Block表,查看该信号是否被阻塞。如果被阻塞(即Block表中对应位为1),则信号会保持在未决状态,不会被立即处理。
- 更新Handler表(如果需要):如果进程为该信号设置了自定义处理函数,内核会更新Handler表中对应信号的处理函数指针为用户自定义的函数地址。
那么发送信号:就是修改进程pcb中的信号指定的位图,由0设置为1.
pcb是内核数据结构对象,只有操作系统有权限去修改。
1.信号的产生(阶段一)
信号的产生主要有下面几种方式;
1.通过kill命令,向指定进程发送指定的信号
2.通过终端按键产生信号:ctrl+c(信号2),ctrl+\(信号3)。
3.通过系统调用:
这里引入系统调用kill
向指定信号发送指定信号。
pid
:指定接收信号的进程的ID。如果pid
小于-1,那么信号将被发送到进程组ID等于abs(pid)
的所有进程。如果pid
等于0,那么信号将被发送到与发送进程属于同一进程组的所有进程,且发送进程的进程ID不等于sig
。如果pid
等于-1,那么信号将被发送到有权限发送信号给的所有进程(通常是发送进程有写权限的所有进程)。sig
:要发送的信号。我们借助kill函数,模拟实现kill命令:
#include <iostream> #include <cstdlib> #include <sys/types.h> #include <signal.h> #include <unistd.h> int main(int argc, char *argv[]) {if(argc != 3){std::cerr << "Usage: " << argv[0] << " signum pid" << std::endl;return 1;}pid_t pid = std::stoi(argv[2]);int signum = std::stoi(argv[1]);kill(pid, signum); }
调用
kill
函数,将转换后的pid
和signum
作为参数,向指定的进程发送信号。接着我们在写一个测试进程,我们通过自己写的kill命令向改进程发送指定的信号
#include <iostream> #include <unistd.h> #include <signal.h>int main() {while (true){std::cout << "hello bit, pid: " << getpid() << std::endl;sleep(1);} }
现象:成功通过自己写的kill命令终止了该进程。
raise
函数是一个系统调用,用于向当前进程发送一个信号。
- 参数:
sig
:要发送的信号编号。例如,SIGTERM
(信号编号为15)通常用于请求程序正常终止。- 返回值:
- 如果成功,返回0。
- 如果失败,返回-1并设置
errno
以指示错误。
raise
函数在单线程环境中类似于kill(getpid(), sig)
,即发送信号给当前进程;而在多线程环境中,其行为可能类似于pthread_kill(pthread_self(), sig)
,但这不是严格等价的,因为pthread_kill
允许发送信号给特定线程。
还有abort,向当前进程发生6号信号,异常终止进程:
void handler(int sig) {std::cout << "get a sig: " << sig << std::endl; }int main() {signal(6, handler);while (true){sleep(1);std::cout << "hello bit, pid: " << getpid() << std::endl;abort();} }
现象:我们发现abort可以被捕捉,但还是会终止进程(例外情况)
9号信号是不允许被捕捉的,也是用来异常终止的。为了维护系统的安全性和稳定性,某些命令可能不允许或限制用户自定义信号捕捉。
4.由软件条件产生信号
我们都知道,在管道通信时,读端关闭,写端一直进行,写就没有意义了,这时候操作系统,就会向进程发送SIGPIPE(13号信号),终止进程。
接下来我们来认识一个新的案例--alarm(闹钟)函数。
alarm()
是一个用于设置一个定时器的库函数,该定时器在指定的秒数后发送一个SIGALRM
信号给调用进程。参数
seconds
指定了定时器在多少秒后到期。如果seconds
为0,则任何当前设置的定时器都会被取消,并返回剩余的秒数直到前一个定时器到期(如果定时器已经被取消或未设置,则返回0)。当
alarm()
设置的定时器到期时,如果进程没有忽略SIGALRM(14号)
信号,也没有为该信号指定一个处理函数(信号处理程序),那么进程的行为是未定义的。通常,进程会终止,但这不是必须的。为了处理
SIGALRM
信号,你可以使用signal()
来设置一个信号处理函数。当定时器到期时,这个函数就会被调用。先看一下案例:1s后,
int main() {int cnt=1;alarm(1);// 设定1S后的闹钟 -- 1S --- SIGALRM//signal(SIGALRM, handler);while(true){std::cout<<"cnt:"<<cnt<<std::endl;cnt++;} }
#include <iostream> #include <cstdlib> #include <sys/types.h> #include <signal.h> #include <unistd.h> void handler(int sig) {std::cout << "get a sig: " << sig << std::endl;exit(1); } int main() {int cnt=1;alarm(1);// 设定1S后的闹钟 -- 1S --- SIGALRMsignal(SIGALRM, handler);while(true){std::cout<<"cnt:"<<cnt<<std::endl;cnt++;} }
如何理解闹钟?
在底层,
alarm
函数通常与内核的定时器机制(如ITIMER_REAL
类型的 POSIX 定时器)相关联。当alarm
被调用时,内核会设置一个软定时器(soft timer),该定时器在指定的秒数后到期。当定时器到期时,内核会生成SIGALRM
信号,并将其发送给调用alarm
的进程。当操作系统中多处要用到alarm的时候,OS就会借助最小堆,进行判断,要先向谁发送SIGALRM信号。
闹钟的返回值
如果之前已经设置了一个 alarm:如果在调用
alarm
函数之前已经为进程设置了一个闹钟时间(即之前已经调用过alarm
),那么alarm
函数将返回之前闹钟时间的剩余秒数。这个返回值表示的是从调用alarm
函数的时刻起,到之前设置的闹钟时间到期还剩下的秒数。int main() {int cnt=1;alarm(10);sleep(4);int n=alarm(2);//上一个闹钟的剩余时间std::cout<<"n:"<<n<<std::endl; }
如果之前没有设置 alarm:如果调用
alarm
函数之前没有为进程设置过闹钟时间(即这是第一次调用alarm
),那么alarm
函数将返回 0。错误情况:在正常情况下,
alarm
函数不会返回错误,因为它只是简单地设置或取消闹钟时间。然而,在极少数情况下,如果系统资源不足(如内存不足),那么alarm
函数可能会失败,但这种情况在实际应用中很少发生。如果alarm
函数失败,它将返回一个错误码,但标准的alarm
函数并没有定义特定的错误码;实际上,它通常只是返回 -1 来表示错误,但具体的错误原因需要通过查看 errno 来确定。alarm(0)表示取消闹钟
5.硬件异常产生信号
比如,空指针解引用问题,除0问题。
int main() {// int *p = nullptr;// *p = 100; int a = 10;a /= 0; while (true){std::cout << "hello bit, pid: " << getpid() << std::endl;sleep(1);} }
以下操作都是非法操作或访问数据导致的:
除0的报错:收到了SIGFPE(8号信号),被异常终止
空指针解引用的报错:收到了SIGSEGV(11号信号),被迫异常终止
收到这些信号,进程必须退出吗?不是,可以捕捉以上的异常信号,但是我们推荐终止进程,为什么呢?
1.除0问题
关于进程中的计算问题,一般都是交由cpu完成的,在计算的过程中,难免会出现错误的计算,比如说除0,那么cpu又是如何知道的呢?
这就要提到cpu中的寄存器了,cpu中是有很多的寄存器的,其中有一个寄存器:EFLAGS寄存器(状态寄存器)。该寄存器中有很多状态标志:这些标志表示了算术和逻辑操作的结果,如溢出(OF)、符号(SF)、零(ZF)、进位(CF)、辅助进位(AF)和奇偶校验(PF)。
除0操作就会触发溢出,就会标定出来运算在cpu内部出错了。OS是软硬件资源的管理者!OS就会处理这种硬件问题,向目标进程发送信号,默认终止进程。
我们要知道cup内部是只有一套寄存器的,寄存器中的数据是属于每一个进程的,是需要对进程上下文进行保存和恢复的。
如果进程因为除0操作而被操作系统标记为异常状态,但没有被终止,那么它可能会被挂起,等待操作系统的进一步处理。
当操作系统决定重新调度这个进程时,会进行上下文切换,即将当前进程的上下文保存到其PCB(进程控制块)中,并加载异常进程的上下文到CPU寄存器中。
上下文切换是一个相对耗时的过程,包括保存和恢复寄存器、堆栈等信息。当切换回这个进程的时候,溢出标志位的错误信息同样会被恢复,会频繁的导致除0异常而触发上下文切换,会大大增加系统的开销。
为什么推荐呢?因为要释放进程上下文的数据,包括溢出标志数据或其他的异常数据。
2.空指针解引用(野指针)问题
这个问题就与页表,MMU及CR2,CR3寄存器有关联了。
MMU和页表是操作系统实现虚拟内存管理和内存保护的关键机制,它们通过虚拟地址到物理地址的转换来确保程序的正确运行和内存安全。CR2和CR3寄存器在内存管理和错误处理中扮演着重要角色。CR3寄存器用于切换不同进程的页表,而CR2寄存器则用于存储引起页错误的虚拟地址,帮助操作系统定位和处理错误。
CR2寄存器用于存储引起页错误的线性地址(即虚拟地址)。当MMU无法找到一个虚拟地址对应的物理地址时(例如,解引用空指针或野指针),会触发一个页错误(page fault)。此时,CPU会将引起页错误的虚拟地址保存到CR2寄存器中,并产生一个异常,此时就会向进程发送11号信号。
6.关于term和core
term
和core
是某些信号默认动作的一种表示。它们之间的区别如下:
- 默认动作:
term
:这是“terminate”的缩写,表示信号的默认动作是终止进程。例如,SIGTERM(编号15)信号的默认操作就是请求进程正常退出。这给了进程一个机会来清理并正常终止。core
:这个动作表示在终止进程的同时,还会生成一个core dump文件。这个文件包含了进程在内存中的信息,通常用于调试。例如,SIGQUIT(编号3)和SIGSEGV(编号11)等信号的默认动作就是终止进程并生成core dump。- 文件生成:
- 当一个进程因某个信号而
term
(终止)时,通常不会生成额外的文件。- 但当进程因某个信号而
core
(终止并转储核心,这个动作在云服务器下是被默认关掉的)时,会生成一个core dump文件。这个文件包含了进程在内存中的状态信息,对于程序员来说是非常有用的调试工具。- 使用场景:
term
动作通常用于请求进程正常退出,比如当你想要优雅地关闭一个服务时。core
动作则更常用于在进程崩溃时生成调试信息,帮助程序员找出崩溃的原因。(以gbd为例,先使用gdb打开目标文件,然后将core文件加载进来,就直接可以定位到错误在哪一行)- 信号示例:
- SIGTERM(编号15):默认动作为
term
,即请求进程正常退出。- SIGQUIT(编号3)和SIGSEGV(编号11):默认动作为
core
,即终止进程并生成core dump。当进程退出时,如果core dump为0就表示没有异常退出,如果是1就表示异常退出了。
eg:关于core dump的演示:
如果你是云服务器,那么就需要手动的将core dump功能打开
#include <iostream> #include <unistd.h> #include <cstdio> #include <sys/types.h> #include <sys/wait.h>int Sum(int start, int end) {sleep(100);int sum = 0;for(int i = start; i <= end; i++){sum /= 0; // coresum += i;}return sum; }int main() {pid_t id = fork();if(id == 0){sleep(1);// childSum(0, 10);exit(0);}// fatherint status = 0;pid_t rid = waitpid(id, &status, 0);if(rid == id){printf("exit code: %d, exit sig: %d, core dump: %d\n", (status>>8)&0xFF, status&0x7F, (status>>7)&0x1);}return 0; }
core dump为1,异常退出了。
- 可更改性:
- 这些默认动作可以通过编程来改变。例如,使用
signal
函数或sigaction
函数来注册自定义的信号处理函数,从而改变信号的行为。
2.信号的保存(阶段二)
两张位图+一张函数指针数组 == 让进程识别信号!
信号保存是指在信号产生后,将其状态和信息保存起来,以便在适当的时机进行递达和处理。这一阶段确保了信号不会丢失,并且能够在目标进程准备好时被正确处理。
在这个阶段有以下几种情况:
信号未决:信号产生后,在未被处理之前,处于未决状态。这意味着信号已经被发送,但目标进程尚未对其作出响应。操作系统会检查目标进程的Pending表,确定哪些信号处于未决状态。(每个进程都有一个Pending位图,用于记录哪些信号处于未决状态。这个位图由32个比特位组成,分别代表32个不同的信号,如果对应的比特位为1,表示该信号已经产生但尚未处理。)
信号阻塞:如果目标进程阻塞了某些信号,那么这些信号会保持在未决状态,直到进程解除对这些信号的阻塞。(与Pending位图类似,Block位图用于记录哪些信号被进程阻塞。当信号被阻塞时,对应的比特位会被设置为1。)
handler表:是一个函数指针数组,每个下标都是一个信号的执行方式(有31个普通信号,信号的编号就是数组的下标,可以采用信号编号,索引信号处理方法!)如signal函数在进行信号捕捉的时候,其第二个参数就是,提供给handler的
如果进程选择阻塞某个信号,操作系统会在block表中设置对应信号的比特位为1。此时,即使信号已经产生(pending表中对应比特位为1),进程也不会立即处理该信号。
被阻塞的信号将保持在pending表中,直到进程解除对该信号的阻塞(即block表中对应比特位被重置为0)。
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
信号阻塞和未决的区别
- 信号阻塞(Blocking):是一个开关动作,指的是阻止信号被处理,但不是阻止信号产生。它使得系统暂时保留信号留待以后发送。阻塞只是暂时的,通常用于防止信号打断敏感的操作。
- 信号未决(Pending):是一种状态,指的是从信号的产生到信号被处理前的这一段时间。信号产生后,如果未被处理且没有被阻塞,则处于未决状态,等待被处理。
3.信号的处理(阶段三)
1.信号集sigset_t
在阶段二我们了解到,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。(该类型只在Linux系统上有效,是Linux给用户提供的一个用户级的数据类型)
2.信号集操作函数
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的
#include <signal.h> 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);
- 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号。
- 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示 该信号集的有效信号包括系统支持的所有信号。
- 注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。
3.sigprocmask(操作block表的函数)
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)
#include <signal.h> int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 返回值:若成功则为0,若出错则为-1
how:指定对信号屏蔽集的操作方式,有以下几种方式:
SIG_BLOCK
:将set
所指向的信号集中包含的信号添加到当前的信号屏蔽集中,即信号屏蔽集和set
信号集进行逻辑或操作。SIG_UNBLOCK
:将set
所指向的信号集中包含的信号从当前的信号屏蔽集中删除,即信号屏蔽集和set
信号集的补集进行逻辑与操作。SIG_SETMASK
:将set
的值设定为新的进程信号屏蔽集,即set
直接对信号屏蔽集进行了赋值操作。set:指向一个
sigset_t
类型的指针,表示需要修改的信号集合。如果只想读取当前的屏蔽值而不进行修改,可以将其置为NULL
。oldset:指向一个
sigset_t
类型的指针,用于存储修改前的内核阻塞信号集。如果不关心旧的信号屏蔽集,可以传递NULL
。如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
4.sigpending(检查pending信号集,获取当前进程pending位图)
#include <signal.h> int sigpending(sigset_t *set);
- 参数:
set
是一个指向sigset_t
类型的指针,用于存储当前进程的未决信号集合。- 返回值:函数调用成功时返回 0,失败时返回 -1,并设置
errno
以指示错误原因。
5.代码样例
基于上面的操作方法我们来做一个实验:我们把2号信号block对应的位图置为1,那么2号信号就会被屏蔽掉了,此时我们给当前进程发送2号信号,但2号信号已经被屏蔽了,2号信号永远不会递达,发完之后我们再不断的获取当前进程的pending表,我们就能肉眼看见2号信号被pending的效果:验证
1.第一步实现对2号信号的屏蔽
int main() {sigset_t block_set,old_set;sigemptyset(&block_set);sigemptyset(&old_set);sigaddset(&block_set,SIGINT);//向block_set信号集中添加SIGINT信号(编号为2)。//1.屏蔽2号信号// 1.1 设置进入进程的Block表中sigprocmask(SIG_BLOCK, &block_set, &old_set); // 真正的修改当前进行的内核block表,完成了对2号信号的屏蔽!while(true) sleep(1); }
当我们运行程序的时候,对进程发送2号信号是没有作用的,因为2号信号此时已经被屏蔽了。
2.下一步我们打印pending表,之后我们给该进程发送2号信号
void PrintPending(sigset_t &pending) {std::cout << "curr process[" << getpid() << "]pending: ";for (int signo = 31; signo >= 1; signo--){if (sigismember(&pending, signo))//如果存在就返回1{std::cout << 1;}else{std::cout << 0;}}std::cout << "\n"; } int main() {sigset_t block_set,old_set;sigemptyset(&block_set);sigemptyset(&old_set);sigaddset(&block_set,SIGINT);//向block_set信号集中添加SIGINT信号(编号为2)。//1.屏蔽2号信号// 1.1 设置进入进程的Block表中sigprocmask(SIG_BLOCK, &block_set, &old_set); // 真正的修改当前进行的内核block表,完成了对2号信号的屏蔽!while(true){//2.获取当前进程的pending信号集sigset_t pending;sigpending(&pending);//3.打印pending信号集PrintfPending(pending);sleep(1);} }
对该进程发送2号信号,pending表对应位置被置为1
3.解除对2号信号的屏蔽,并且捕捉2号信号,我们来看一下现象:
#include <iostream> #include <unistd.h> #include <cstdio> #include <sys/types.h> #include <sys/wait.h>void PrintPending(sigset_t &pending) {std::cout << "curr process[" << getpid() << "]pending: ";for (int signo = 31; signo >= 1; signo--){if (sigismember(&pending, signo))//如果存在就返回1{std::cout << 1;}else{std::cout << 0;}}std::cout << "\n"; } void handler(int signo) {std::cout << signo << " 号信号被递达!!!" << std::endl;std::cout << "-------------------------------" << std::endl;sigset_t pending;sigpending(&pending);PrintPending(pending);std::cout << "-------------------------------" << std::endl; } int main() {// 0. 捕捉2号信号signal(2, handler); // 自定义捕捉sigset_t block_set,old_set;sigemptyset(&block_set);sigemptyset(&old_set);sigaddset(&block_set,SIGINT);//向block_set信号集中添加SIGINT信号(编号为2)。//1.屏蔽2号信号// 1.1 设置进入进程的Block表中sigprocmask(SIG_BLOCK, &block_set, &old_set); // 真正的修改当前进行的内核block表,完成了对2号信号的屏蔽!int cnt =10;while(true){//2.获取当前进程的pending信号集sigset_t pending;sigpending(&pending);//3.打印pending信号集PrintPending(pending);//4.解除对2号信号的屏蔽cnt--;if(cnt==0){std::cout << "解除对2号信号的屏蔽!!!" << std::endl;//使用直接重置的方法//我们之前是保存了old_set,老的屏蔽字,直接使用就行了sigprocmask(SIG_SETMASK, &old_set, &block_set);}sleep(1);} }
我们不难发现,解除屏蔽后,信号会立即递达,pending对应位置由1置为0(这个过程,是在执行handler方法之前完成的,也就是在信号递达之前,位图就由1转为0了)。
6.再谈信号的捕捉
关于信号捕捉有三种方式:
signal(2, handler); // 自定义捕捉signal(2, SIG_IGN); // 忽略一个信号signal(2, SIG_DFL); // 信号的默认处理动作
SIG_IGN
是一个特殊的宏,用于指示系统忽略该信号。信号可能不会被立即处理,而是在合适的时候处理,那么合适的时候是什么时候呢?
先给结论:从进程的内核态返回到用户态的时候,进行处理。
简单来说,执行自己的代码,访问自己的数据,这就叫做用户态。
当我们进入系统调用时,我们以操作系统的身份来执行时,此时就进入了内核态,操作系统把我们的底层工作做完,做完这些工作后返回到我们的调用处,继续执行下面的代码,但是操作系统,由内核态返回到用户态时,在返回的这个时候信号的检测和处理
- 这是因为管理信号的数据结构(也就是我们的三张表)都位于进程的控制块(PCB)内,而PCB属于内核数据。因此,信号的检测和处理必须在内核态下进行。
- 当进程从内核态返回用户态时,内核会检查是否有待处理的信号,并根据信号的处理方式(默认处理、忽略或自定义处理)进行相应的操作。但操作系统不能直接转过去执行用户提供的handler方法,这是出于对安全性的考虑。
简化一下:
7.对内核态和用户态的进一步理解
1.再谈地址空间:
在操作系统中,虚拟内存地址空间被划分为内核态和用户态两个主要部分。这种划分是出于安全性和管理便利性的考虑。
在32位系统上,内核态通常占据虚拟地址空间的高地址部分,如从0xC000 0000到0xFFFF FFFF(共1GB),用户态则是后面的3GB。在64位系统上,这个范围要大得多,但基本概念相似。
无论进程如何的切换,我们总能找到OS!我们访问OS,其实还是在我们的地址空间中进行的!和我们访问库函数没区别!OS不相信任何人,用户访问内核态的地址空间时,要收到一定的约束,只能通过系统调用访问!
2.谈谈键盘输入数据的过程:
当我们在键盘上输入命令或数据时,键盘上的电路会检测到按键的按下或释放,并生成相应的电信号。这些电信号随后被转化为中断信号,通过硬件连线(如总线)传递到CPU的中断控制器。中断控制器根据信号的优先级和当前CPU的状态,决定是否向CPU发送中断请求。
CPU通过中断处理机制来响应来自硬件设备的中断请求。在保护模式下,CPU会维护一个中断描述符表(IDT),该表包含了所有可能的中断向量及其对应的中断服务例程的地址。当中断发生时,CPU会根据中断向量在IDT中找到对应的中断服务例程的地址,并跳转到该地址执行中断处理程序。
一旦CPU接收到来自键盘的中断请求,它会暂停当前正在执行的程序(保存当前的状态,如程序计数器、寄存器值等),然后跳转到特定的中断处理程序或中断服务例程来响应这个中断。中断处理程序会执行必要的操作来处理该中断,这可能包括读取键盘的状态、更新数据、发送响应等。处理完中断后,CPU会恢复之前保存的状态,并继续执行原来的程序。
3.时钟中断
定义:Linux时钟中断是指在Linux操作系统中,系统定时器周期性地触发中断,这个中断被称为时钟中断。时钟中断源于硬件定时器,通常由计算机的主板芯片或处理器芯片提供,通过定时器计数器来实现定时中断功能。
功能:
维护系统时间:每当一个时钟中断发生时,内核会更新系统时间的计数值。这个计数值可以是自世界时间开始的毫秒数,也可以是自系统启动以来的滴答数(tick)。通过定时更新系统时间,系统可以保持时间的准确性,为用户提供可靠的时间信息。
任务调度:在多任务操作系统中,内核需要决定哪个进程将获得CPU的控制权。时钟中断提供了一个计时器,每当中断发生时,内核会检查当前运行的进程是否到达了它应该运行的时间片。如果一个进程的时间片用完了,内核就会重新选择下一个要运行的进程,并切换上下文,将控制权交给新的进程。这样保证了系统中进程的公平调度,提高了系统的整体性能。
计算进程执行时间:每当一个进程或线程被抢占,切换到另一个进程或线程时,时钟中断记录下了抢占发生的时间。通过记录不同进程和线程的执行时间,可以分析其调度情况,了解系统中进程的运行情况,为性能优化提供依据。
4.OS时如何正常运行的:
操作系统的本质就是一个死循环+时钟中断(不断调度系统的任务):操作系统中的进程调度依赖于时钟来分配处理器时间。时钟中断定期触发,使操作系统能够检查当前进程的运行状态,并根据需要进行进程切换或调整进程的优先级。时钟通过产生时钟中断来实现进程的时间片管理。每个进程被分配一个固定的时间片来执行,当时钟中断发生时,如果当前进程的时间片已经用完,则操作系统会将其挂起,并选择另一个进程来执行。这种方式确保了每个进程都有机会获得处理器资源,从而提高了系统的整体性能。因此,时钟通过提供稳定的时间基准、实现进程调度、处理中断以及提高系统稳定性与可靠性等方面来推动操作系统的运行。它是操作系统中不可或缺的一部分,对于保证系统的正常运行和任务的有序执行具有重要意义。
5.如何理解系统调用:
我们只要找到特定数组下标的方法,就能执行系统调用了。
用户程序在代码中调用系统调用时,会执行一个特殊的中断指令,如int 0x80(在x86架构中)或syscall指令。在执行中断指令前,将系统调用号放入特定的寄存器中(如eax寄存器)。CPU暂停当前执行的代码,根据中断的中断号,在中断向量表中找到对应的中断处理程序(如Linux中的system_call),并调用它。中断处理程序会检查系统调用号的有效性,并从系统调用表中找到相应的系统调用函数进行调用。
4.关于信号捕捉的细节部分(sigaction函数)
- signum:指定要设置或获取处理程序的信号编号。可以指定SIGKILL和SIGSTOP以外的所有信号。
- act:指向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); // 已废弃,不用关心 };
我们暂时只考虑第一个和第三个参数。
一个示例:
#include <iostream> #include <signal.h> #include <unistd.h>void handler(int signum) {std::cout<<"get a sig"<<signum<<std::endl;exit(1); } int main() {struct sigaction act,oact;act.sa_handler = handler;sigemptyset(&act.sa_mask);act.sa_flags=0;sigaction(2,&act,&oact);while(true){std::cout<<"I am a process,pid: "<<getpid()<<std::endl;sleep(1);}return 0; }
细节:当前如果正在对2号信号进行处理,默认2号信号会被自动屏蔽,对2号信号处理完成的时候,会自动解除对2号信号的屏蔽。为什么?这是因为,操作系统不允许同一个信号被连续处理。
此段代码,用于观测,pending表的变化。
void Print(sigset_t &pending) {for(int sig = 31; sig > 0; sig--){if(sigismember(&pending, sig)){std::cout << 1;}else{std::cout << 0;}}std::cout << std::endl; } void handler(int signum) {std::cout<<"get a sig"<<signum<<std::endl;while(true){sigset_t pending;sigpending(&pending);Print(pending);sleep(1);}exit(1); }
我们发现,当我们使用两次CTRL后,pending由0置为1了。如果2号信号处理完毕后,会自动解除对2号信号的屏蔽
如果你还想在处理2号信号(OS对2号自动屏蔽0),同时,对其它型号也进行屏蔽,你可以设置sa_mask变量。
下面是一段示例:
void Print(sigset_t &pending) {for(int sig = 31; sig > 0; sig--){if(sigismember(&pending, sig)){std::cout << 1;}else{std::cout << 0;}}std::cout << std::endl; } void handler(int signum) {std::cout<<"get a sig"<<signum<<std::endl;while(true){sigset_t pending;sigpending(&pending);Print(pending);sleep(1);}exit(1); } int main() {struct sigaction act,oact;act.sa_handler = handler;sigemptyset(&act.sa_mask);sigaddset(&act.sa_mask,3);act.sa_flags=0;sigaction(2,&act,&oact);while(true){std::cout<<"I am a process,pid: "<<getpid()<<std::endl;sleep(1);}return 0; }
我对三号信号也同时做了屏蔽,此时发送三号信号,pending值也是由0置为1的。
能否将所有的信号给屏蔽掉?
当然是不可以的,有些信号默认是不可以被屏蔽的(例如9号信号)
5使用信号可能导致的问题
1.可重入函数
- main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
- 像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
如果一个函数符合以下条件之一则是不可重入的:(大部分函数是不可被重入的,可重入或者不可重入,描述的是函数的特征)
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
2.使用信号对全局变量进行操作出现的问题(volatile)
int gflag = 0;void changedata(int signo) {std::cout << "get a signo:" << signo << ", change gflag 0->1" << std::endl;gflag = 1; }int main() // 没有任何代码对gflag进行修改!!! {signal(2, changedata);while(!gflag); // while不要其他代码std::cout << "process quit normal" << std::endl; }
我们使用信号捕捉了2号信号,当我们执行了2号信号后,全局变量gflag就会被更改为1,那么main函数中的while就会停止执行,因为cpu在执行while循环的时候,实时的从内存中取gflag来进行比较,但在这里我们对编译进行优化,这会让cpu保存之前在内存中取的gflag的值,只在内存中取最开始的一次值,这就会导致gflag的变化无法被在while中实时更新,导致while循环无法结束:g++ -o test test.cc -O1(O1,是基础优化)
如果想让gflag在此优化下生效,就要使用volatile(
volatile
关键字可以确保变量的可见性(即确保变量每次访问时都直接从内存中读取)关键字调整后,程序成功退出:
volatile int gflag = 0;
3.SIGCHLD信号
子进程退出时,不是静悄悄的退出的,会给父进程发送信号--SIGCHLD信号。
下面是一段示例:#include <iostream> #include <unistd.h> #include <signal.h> #include <sys/types.h> #include <sys/wait.h>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;}} }void DoOtherThing() {std::cout << "DoOtherThing~" << std::endl; } int main() {signal(SIGCHLD, notice);for (int i = 0; i < 10; i++){pid_t id = fork();if (id == 0){std::cout << "I am child process, pid: " << getpid() << std::endl;sleep(3);exit(1);}}// fatherwhile (true){DoOtherThing();sleep(1);}return 0; }
这段代码创建了多个子进程,并在子进程结束时通过SIGCHLD信号进行处理。
当SIGCHLD信号被捕获时,
notice
函数会被调用。这个函数会进入一个无限循环,尝试使用waitpid
以非阻塞方式(WNOHANG
)等待任何已终止的子进程。这是合理的,因为它允许父进程在子进程终止时及时回收资源,同时不阻塞父进程的其他操作。
signal(SIGCHLD, SIG_IGN);
这行代码的作用是设置信号处理函数,以便当子进程结束时(即发送SIGCHLD信号给父进程时),父进程忽略这个信号。通常,当子进程结束时,父进程需要处理这个信号以回收子进程的资源,但在这里,通过将其设置为SIG_IGN
,父进程选择忽略这个信号,这意味着子进程的资源将由操作系统自动回收(这通常被称为“僵尸进程”的避免,尽管在这种情况下,由于子进程正常退出并设置了退出码,它实际上不会成为僵尸进程,因为操作系统会注意到并清理它)。(如果你不关心子进程的退出信息就可以使用这种方法,否则还是要进行等待)int main() {signal(SIGCHLD, SIG_IGN); // 收到设置对SIGCHLD进行忽略即可pid_t id = fork();if (id == 0){int cnt = 5;while (cnt){std::cout << "child running" << std::endl;cnt--;sleep(1);}exit(1);}while (true){std::cout << "father running" << std::endl;sleep(1);} }