目录
在日常生活中,信号随处可见,如交通信号灯,铃声等。通俗的来说信号是一种用户,操作系统,与其他进程向目标进程发送异步事件的一种方式
一些预备知识
1.如何识别信号? 进程识别信号,是程序员内置的特性
2.信号产生之后,处理信号的方式? 其实信号的处理方法,在信号产生之前已经准备好了
3.处理信号是立即处理吗?可能不是立即处理的,是需要在合适的时候处理的,那么在没有处理信号之前,其实进程是会记录下来对应的信号
4.处理信号的几种方式? a.默认行为 b.忽略信号 c.自定义动作(涉及信号的捕捉)
一:信号的产生
1.键盘产生
先引入一段小代码
#include <iostream>
#include <unistd.h>
int main()
{
while(true)
{
std::cout<<"Hello world"<<std::endl;
sleep(1);
}
return 0;
}
当直接通过./运行时,此时该进程就是一个前台进程,前台进程是可以通过ctrl+c终止进程的
当在./运行时后面添加&,就会变为一个后台进程,此时是不能通过ctrl+c终止的,且在命令行上依旧可以执行命令
这时就可以通过发送9号信号来终止它,也可以使用nohup+fg来转为前台进程使用ctrl+c
后台进程打印的内容都会存放到nohup.out文件中,使用fg 1便可转化为前台进程
其实对于ctrl+c终止前台进程,是向进程发送了信号,最终被os接受并解析为2号信号,对于2号信号的默认处理操作为终止进程自己,对于ctrl+\是3号信号,默认也是终止,接下来我们来进行验证
先介绍一个函数 signal
作用为捕捉指定信号,修改默认处理动作,执行自定义动作
#include <iostream>
#include <unistd.h>
#include <signal.h>
void hander(int signo)
{
//当对应的信号被触发,内核会将对应的信号编号,传递给自定义方法
std::cout<<"Get a signal,signal number is:"<<signo<<std::endl;
}
int main()
{
//signal:如果没有产生2/3号信号的话,hander函数是不会被调用的
signal(SIGINT,hander);
while(true)
{
std::cout<<"Hello world"<<std::endl;
sleep(1);
}
return 0;
}
运行后就会发现ctrl+c的方式是不能终止进程的,可以使用ctrl+\来终止进程
几个命令
查看信号: kill -l
查看信号的具体信息 man 7 signal
若捕捉3号信号的话,使用ctrl+\也是不能终止进程的,但没有捕捉2号信号,使用ctrl+c的可以终止进程
此时如果对前31个信号(后面的为实时信号不考虑)都进行捕捉的话,那么OS将无法终止进程,但事实并非如此,对于9号信号是不能进行捕捉的
从软硬件理解两个知识点
硬件:OS是如何得知键盘上有数据的?
在DeepSeek上咨询这个问题,得到的为
操作系统通过硬件和软件的协同工作来检测和处理键盘输入。以下是详细过程:
1. 硬件中断
当键盘有按键按下或释放时,键盘控制器会生成一个中断信号(通常是IRQ1),通知CPU有键盘事件发生。
2. 中断处理
CPU收到中断信号后,暂停当前任务,保存状态,并跳转到预定义的中断处理程序(ISR)来处理键盘事件。
3. 读取键盘数据
中断处理程序通过I/O端口从键盘控制器读取扫描码(表示按键状态的原始数据)。
4. 扫描码转换
读取的扫描码被转换为对应的ASCII码或 Unicode 字符,具体转换依赖于键盘布局和操作系统配置。
5. 输入缓冲区
转换后的字符被放入输入缓冲区,供操作系统或应用程序读取。
6. 通知应用程序
操作系统通过事件或消息机制通知应用程序有新输入,应用程序从输入缓冲区读取并处理这些字符。
7. 驱动程序
键盘驱动程序负责管理硬件中断、扫描码转换以及与操作系统的通信。
8. 用户空间处理
应用程序通过系统调用或API(如
read()
)从输入缓冲区读取字符,并进行进一步处理。总结
操作系统通过硬件中断、中断处理程序、扫描码转换、输入缓冲区和驱动程序等机制,检测并处理键盘输入,最终将数据传递给应用程序。
简单来说就是,当键盘上有按键按下,操作系统就会给CPU发送一个硬件中断信号,告知CPU键盘上有数据就绪,CPU就会将数据写入到内存中,以便后续的操作,这样硬件和OS就可以并行执行了
软件:如何理解信号处理
我们知道当键盘上按下按键后,在OS中解析为信号后转发给进程,但有时进程不会立即处理该信号,而是会先保存下来,那么该如何保存信号呢?
在进程PCB(task_struct)中,存在一位图结构,为uint32_t signalbits(包含32比特位)
比特位的位置为信号的编号,比特位的内容为0/1,表示是否收到对应的信号
进程中也存在sighander_t arr[32]的一函数指针数组,每个对应位置的内容为信号对应的处理方法
这样的话,其实发送信号的本质为写入信号,是OS修改目标进程的PCB的信号位图,将对应位置由0置为1,表示收到该信号
基于以上内容我们可以粗略的了解ctrl+c按下后发生了什么
当键盘上按下ctrl+c后,会给CPU发送硬件中断信号,当CPU收到中断信号后,就将键盘上的数据拷贝到内存中,执行进程对应的代码,将ctrl+c转化为2号信号,由OS将进程PCB中的位图对应位置由0置1,接着执行sighander_t arr[2]其中的方法(使用signal系统调用是将自定义函数的地址存放到这个函数指针数组当中)
2.系统指令产生
使用kill -number pid 来发送信号
3.使用系统调用产生
使用kill系统调用-----给指定进程发送对应信号
自己实现一个kill命令
其余的系统调用
给当前自己进程发送指定信号
给自己当前进程发送6号信号
4.软件条件
这里先介绍一个函数 alarm闹钟函数
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数
使用alarm函数可以实现一个定时器的功能,在OS中是有很多的闹钟的,对于这些闹钟就要先描述在组织起来进行管理,每个闹钟其实是个struct timer 对象,其中结构体中包含过期时间函数方法等
基于闹钟函数实现一个任务转换
using func_t=std::function<void()>;
int gcount=0;
std::vector<func_t> gfuncs;
void handler(int signo)
{
for(auto& f:gfuncs)
{
f();
}
std::cout<<"gcount:"<<gcount<<std::endl;
alarm(1);
}
int main()
{
gfuncs.push_back([]()
{ std::cout << "我是一个内核刷新操作" << std::endl; });
gfuncs.push_back([]()
{ std::cout << "我是一个检测进程时间片的操作,时间片到了,就会切换进程" << std::endl; });
gfuncs.push_back([]()
{ std::cout << "我是一个内存管理操作,定期清理操作系统内部的内存碎片" << std::endl; });
alarm(1);//一次性的闹钟,超时alarm会自动被取消
signal(SIGALRM,handler);
while(true)
{
pause();//等待信号
std::cout<<"我醒来了"<<std::endl;
gcount++;
}
return 0;
}
此时把信号更改为硬件中断即是操作系统的运行原理
通过alarm闹钟函数在设置seconds秒过后,向对应进程发送信号的这种产生信号的方式成为软件条件的产生信号
5.异常
当出现野指针错误时,就会出现段错误并终止进程
这是OS向对应目标进程发送了SIGSEGV信号,当我们捕捉这个信号,就不会报错,但是会重复打印内容,不退出进程
对于除0也是相同,是OS发送了SIGFPE信号
因此在C/C++中,常见的异常使进程崩溃了,本质是OS给目标进程发送对应错误信号,进而导致该进程退出
再补充一些知识
1.OS怎么知道我们的进程内部出错了?为什么会陷入死循环?
程序内部的错误,其实都会表现在硬件错误上,通过发送信号来解决
对于除0的操作
对于OS要不要知道CPU(硬件)内部出错了? 肯定是要的,寻找出来对应出现错误的进程,使用信号来杀掉该进程。当我们把SIGFPE信号捕捉后,进程是没有退出的,因此还要被调度,切换,执行等,且出错时将溢出标志位设置为了1,进程是没有退出的,标志位一直是1,OS检测到后,就会一直发送信号,就又会一直捕捉信号,因此陷入了死循环
对于野指针的操作
2.core vs term
在信号的终止中有core与term两种,通过man 7 signal查看
对于term是正常的退出,不需要进行debug,而core是使用了核心转储,会在当前目录下形成对应 core文件,这个文件在进程崩溃时,会将进程在内存中的部分信息保存起来,方便后续调试,但云服务器默认是关闭core功能的
使用ulimit -a 查看 ulimit -c 数字 来打开core功能
在使用gdb调试时,就可以直接使用core-file core 直接定位错误位置,这样调试也叫事后调试
如果是子进程异常的话,就会是
core dump标志为0/1是取决于退出信号终止动作是否是core && 服务器是否开启core功能
3.设置默认处理动作和忽略
二:信号保存
1.相关概念
1.实际执行信号的处理动作称为信号递达 (Delivery)2.信号从产生到递达之间的状态 , 称为信号未决 (Pending) 。3.进程可以选择阻塞 (Block ) 某个信号。4.被阻塞的信号产生时将保持在未决状态 , 直到进程解除对此信号的阻塞 , 才执行递达的动作 .注意 , 阻塞和忽略是不同的 , 只要信号被阻塞就不会递达 , 而忽略是在递达之后可选的一种处理动作。5.阻塞特定信号(也称为屏蔽信号),当信号一旦产生,这时就一定要把信号进行pending(保存),永远不递达,除非我们解除阻塞
2.在内核中的表示
对于调用signal函数执行自定义动作时,先将pending表的对应位置比特位置为1,表示收到信号,将自定义函数的地址保存在handler表中,当block表的对应位置比特位为0时,去执行对应的自定义函数
3.sigset_t及信号集操作函数
1.sigset_t
1.#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。
2.调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>int sigprocmask(int how, const sigset_t *set, sigset_t *oset);返回值 : 若成功则为 0, 若出错则为 -1
how的参数介绍
3.#include <signal.h>int sigpending(sigset_t*set)读取当前进程的未决信号集 , 通过 set 参数传出。调用成功则返回 0, 出错则返回 -1 。
4.对于handler表的处理其实在signal函数和产生信号中进行填充了,无需在进行处理
4.做一些练习
1.先屏蔽2号信号,再获取并打印pending表,再发送2号信号,看到2号信号的pending效果
void PrintPending(const sigset_t& pending)
{
std::cout<<"curr pending list ["<<getpid()<<"]";
for(int signo=31;signo>0;--signo)
{
if(sigismember(&pending,signo))
{
std::cout<<1;
}
else
{
std::cout<<0;
}
}
std::cout<<std::endl;
}
int main()
{
signal(2,SIG_IGN);
//1.对2号信号进行屏蔽
//栈上开辟空间
sigset_t block,oblock;
sigemptyset(&block);
sigemptyset(&oblock);
//添加2号信号
//此时我们并没有把2号信号的屏蔽设置进入内核中,只是在用户栈上设置了block的位图结构
//并没有设置进入内核中
sigaddset(&block,2);
//设置进入内核
sigprocmask(SIG_SETMASK,&block,&oblock);
int cnt=0;
while(true)
{
//2.获取pending表
sigset_t pending;
sigpending(&pending);
//打印
PrintPending(pending);
sleep(2);
cnt++;
if(cnt==10)
{
std::cout<<"解除对2号信号的屏蔽"<<std::endl;
sigprocmask(SIG_SETMASK,&oblock,nullptr);
}
}
return 0;
}
三:信号捕捉
在前面说过当处理信号时,可能不是立即处理的,而是在合适的时候,这个时候就是进程在从内核态切换回用户态的时候,检测当前进程的pending&&block,决定是否处理handler表中的处理方法
而操作系统的运行状态分为用户态(执行自己写的代码)和内核态(执行操作系统的代码)
两者之间的切换为
1.操作系统的运行原理
1.硬件中断
2.时钟中断
时钟源每隔一定时间向CPU发送中断。对应的CPU获取到中断号n,去执行中断向量表中的进程调度,这样操作系统就可以一直进行进程的调度,等时间片一到,切换进程,继续执行其他的进程,对于时间片其实是一个计数器,每次发送中断,计数器都会减减,直到为0,进行进程的切换
时钟源一秒内向CPU发送中断的数量称为主频,主频的频次越高,即CPU去执行中断向量表中的方法越快,调度的进程越多,越高效
3.软中断
4.用户态和内核态
2.sigaction系统调用
1.sigaction 函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回 0, 出错则返回- 1 。 signo是指定信号的编号。若act 指针非空 , 则根据 act 修改该信号的处理动作。若 oact 指针非 空 , 则通过 oact 传出该信号原来的处理动作。act 和 oact 指向 sigaction 结构体 :2.将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函 数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
3.当某个信号的处理函数被调用时 , 内核自动将当前信号加入进程的信号屏蔽字 , 当信号处理函数返回时自动恢复原来的信号屏蔽字, 这样就保证了在处理某个信号时 , 如果这种信号再次产生 , 那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时, 除了当前信号被自动屏蔽之外 , 还希望自动屏蔽另外一些信号 , 则用 sa_mask字段说明这些需要额外屏蔽的信号(自定义屏蔽信号的list) , 当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags 字段包含一些选项 , 本章的代码都把 sa_flags 设为 0,sa_sigaction 是实时信号的处理函数
使用
void PrintBlock()
{
sigset_t set,oset;
sigemptyset(&set);
sigemptyset(&oset);
sigprocmask(SIG_BLOCK,&set,&oset);
std::cout<<"block: ";
for(int signo=31;signo>0;--signo)
{
if(sigismember(&oset,signo))
{
std::cout<<1;
}
else
{
std::cout<<0;
}
}
std::cout<<std::endl;
}
void PrintPending()
{
sigset_t pending;
::sigpending(&pending);
std::cout<<"Pending: ";
for(int signo=31;signo>0;--signo)
{
if(sigismember(&pending,signo))
{
std::cout<<1;
}
else
{
std::cout<<0;
}
}
std::cout<<std::endl;
}
void handler(int signo)
{
static int cnt=0;
cnt++;
while(true)
{
std::cout<<"get a sig:"<<signo<<",cnt:"<<cnt<<std::endl;
//PrintBlock();
PrintPending();
sleep(1);
//break;
}
//exit(1);
}
int main()
{
struct sigaction act,oact;
act.sa_handler=handler;
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask,1);
sigaddset(&act.sa_mask,2);
sigaddset(&act.sa_mask,3);
::sigaction(2,&act,&oact);
while(true)
{
//PrintBlock();
PrintPending();
pause();
}
return 0;
}
证明OS不允许信号处理方法进行嵌套,当某个信号正在被处理,OS会自动的把对应信号的block位设置为1,当信号处理完成,会自动解除
四:补充知识
1.可重入函数
一个函数,同时被两个以上的执行流进入了称为重入
如果出现了问题,称为不可重入函数,没出问题的话,称为可重入函数
2.volatile
由一段代码引出
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
int flag=0;
void change(int signo)
{
(void)signo;
flag=1;
printf("change flag 0->1,pid:%d\n",getpid());
}
int main()
{
printf("I am main process,pid is %d\n",getpid());
signal(2,change);
while(!flag);//主执行流---flag我们没有做改变
printf("我是正常退出的\n");
return 0;
}
当直接没有优化编译运行时
开启优化时

3.SIGCHLD信号
void handler(int signo)
{
std::cout<<" get a sig:"<<signo<<" I am:"<<getpid()<<std::endl;
//当有多个子进程需要回收僵尸状态时,由于保存信号的为位图结构,当多个信号发送过来时
//最多只能保存一个,导致一些子进程的僵尸状态没有回收
//这就可以使用循环的方式一直等待回收
while(true)
{
//如果有十个进程,6个退出,回收完后,那么到第七个进程依然会执行waitpid
//此时就不再正确,因此等待方式可以改为WNOHANG
pid_t rid=::waitpid(-1,nullptr,WNOHANG);
if(rid>0)
{
std::cout<<"子进程退出了,回收成功,child id:"<<rid<<std::endl;
}
else if(rid==0)
{
std::cout<<"退出的子进程已经被全部回收了"<<std::endl;
break;
}
else
{
std::cout<<"wait error"<<std::endl;
break;
}
}
}
int main()
{
::signal(SIGCHLD,handler);
for(int i=0;i<10;i++)
{
if(fork()==0)
{
sleep(5);
std::cout<<"子进程退出"<<std::endl;
exit(0);
}
}
}
事实上 , 由于 UNIX 的历史原因 , 要想不产生僵尸进程还有另外一种办法 : 父进程调 用 sigaction 将 SIGCHLD 的处理动作置为SIG_IGN, 这样 fork 出来的子进程在终止时会自动清理掉 , 不 会产生僵尸进程 , 也不会通知父进程。系统默认的忽略动作和用户用sigaction 函数自定义的忽略 通常是没有区别的 , 但这是一个特例。此方法对于 Linux 可用 , 但不保证在其它UNIX 系统上都可用