文章目录
一.对信号的认识
在 Linux 系统中,信号(Signal) 是进程间通信的一种轻量级方式,用于通知进程发生了某种事件(如错误、用户操作、系统状态变化等)
。信号可以被内核、其他进程或进程自身发送,接收信号的进程会根据预设的处理方式做出响应(如终止、暂停、忽略等)
。
1.1信号的本质
信号是异步事件:进程不需要主动等待信号,当信号发生时,内核会中断进程的正常执行流程,转而去处理信号。
异步与同步事件是什么?
同步是指任务按顺序执行,前一个操作完成后,才能开始下一个操作,整个过程中调用方会一直等待结果返回,不会被其他任务打断。示例:日常场景:打电话时,你说一句话,必须等对方回应后才能说下一句,这就是同步。
异步是指任务无需等待前一个操作完成即可开始,调用方不会被阻塞,而是继续执行其他任务,当被调用的操作完成后,会通过回调、事件通知等方式告知调用方结果。示例:日常场景:发邮件时,你点击 “发送” 后无需等待对方收到,就可以继续写其他邮件,这就是异步。
1.2对于信号还需要以下认知:
1.进程必须识别+能够处理信号,即使信号没有产生,也要有处理信号的能力,这属于进程内置功能的一部分;
2.当进程收到一个具体的信号,可能不会立即处理,可以进行延时处理;(信号处理三种方式:默认动作/忽略/自定义动作)
3.一个进程从产生到处理,就一定会有时间窗口,进程具有临时保存已经收到的信号的能力;
4.kill -l有这些信号:信号编号范围从 1 到 64,其中 1 - 31 号是普通信号,34 - 64 号信号是实时信号,目前只考虑1-31普通信号
1.3常见信号及含义:
查看Linux中的信号可以使用:
kill -l
Linux 定义了几十种信号(如 SIGINT、SIGKILL 等),每种信号对应一种特定事件,用整数编号(1-64,其中 1-31 为传统信号,34-64 为实时信号),本篇只介绍1-31传统信号。这些信号默认的处理动作是在signal(7)中都有详细说明:
man 7 signal
- Term(Terminate,终止进程)
行为:收到这类信号时,进程会直接终止(退出)。
实例:SIGINT(用户按 Ctrl+C)、SIGTERM(默认 kill 命令发的终止信号 )。 - Ign(Ignore,忽略信号)
行为:收到这类信号时,进程啥都不做,直接忽略。
实例:SIGCHLD(子进程退出时给父进程发的信号,默认忽略,父进程可主动捕获它回收子进程 )、SIGURG(套接字紧急数据通知,很多程序默认不用就忽略 )。 - Core(终止进程并生成核心转储)
行为:进程终止,同时生成 core dump 文件(记录进程崩溃瞬间的内存、寄存器等信息 ),方便事后用调试工具(如 gdb )分析崩溃原因。
实例:SIGSEGV(非法内存访问,比如空指针、数组越界 )、SIGABRT(进程主动调用 abort() 触发 )。
注意:生成 core 文件需要系统开启相关配置(默认可能关闭,需用 ulimit -c 核心转储文件大小 命令打开限制 )。
- Stop(暂停进程)
行为:进程会被暂停(暂停后不执行代码,但保留资源 ),可以用 SIGCONT 恢复。
实例:SIGSTOP(强制暂停,不能被捕获 / 忽略 )、SIGTSTP(用户按 Ctrl+Z 触发,可被捕获 / 忽略 )。
特点:SIGSTOP 是 “强制暂停”,进程无法拒绝;而 SIGTSTP 是 “友好暂停”,程序可以自定义处理(比如暂停前保存进度 )。 - Cont(恢复暂停的进程)
行为:如果进程当前是 “暂停状态”(比如被 SIGSTOP、SIGTSTP 暂停 ),收到 SIGCONT 会恢复执行;如果进程没暂停,这个信号会被忽略。
典型场景:常用 fg/bg 命令(本质是给进程发 SIGCONT ),把后台暂停的任务调回前台 / 让它继续在后台运行。典型场景:常用 fg/bg 命令(本质是给进程发 SIGCONT ),把后台暂停的任务调回前台 / 让它继续在后台运行。
1.4对signal函数认识:
1.typedef void (*sighandler_t)(int);//函数指针
2.sighandler_t signal(int signum, sighandler_t handler);//设置信号动作,signum为kill中的信号名,后为自定义的动作函数),设置完毕后处理信号将切换为自定义动作,只需要在一开始设置,往后都有效;
eg.设置自定义动作/忽略/默认动作的三种方式:
signal(SIGINT, signalHandler); //signal(SIGINT, NULL); 空指针等于没有更改处理方式,还是默认动作
void signalHandler(int sig);是自定义动作函数,sig是要修改的信号名,方便收到信号后找到
signal(SIGINT,SIG_IGN); // 忽略SIGINT信号
signal(SIGINT,SIG_DFL); // 恢复SIGINT信号的默认处理方式
二.如何产生信号
2.1键盘产生信号
当我们输入指令时,可以向指定进程发送指定信号,比如:
ctrl c:代表二号信号 2 SIGINT 终止进程
ctrl z:代表二十号信号 20 SIGTSTP:可以发送停止信号,将当前前台进程挂起到后台
ctrl \ : 代表三号信号 3 SIGQUIT :使得进程退出产生core文件和coredump标记
这里需要了解前后台的相关知识:
键盘输入会被前台进程收到,一个终端对应一个会话对应一个bash对应一个前台进程对应多个后台进程,ctrl C实际上代表2号信号(SIGINT),可以使用jobs查看后台任务,fg 后台任务号可以将后台任务切到前台 ,ctrlZ暂停进程后会切换到后台,bg 后台任务号是让当前处于 “暂停状态” 的进程在后台继续执行
#此外
./xxx.exe #代表启动可执行程序,默认在前台运行,接收键盘输入
./yyy.exe & #代表在后台启动可执行程序,接收不到键盘输入,但可以正常输出
键盘是如何与操作系统关联起来的?
1.键盘通过封装读写函数与struct device形成虚拟文件系统,内存可以直接从键盘中读取数据;
2.中断针脚:CPU 有专门的针脚。当键盘需要 CPU 处理事务时,会通过中断单元向 CPU 的中断针脚发送中断号。硬件中断:当键盘有数据需要传输时,它会先将请求发送给中断单元,中断单元向 CPU 发送中断信号。CPU根据高低电流脉冲将中断号转换为0/1数据储存在寄存器中;CPU 响应中断:CPU 在每个指令周期结束时,会检查中断针脚是否有信号。如果检测到中断请求信号,并且 CPU 当前允许响应中断,CPU 会暂停当前正在执行的指令,保存当前的执行状态,然后根据中断号中断向量表(中断向量表存储了每个中断对应方法的地址 )
找到对应的读写方法,进行数据的读写,当输入ctrl+C时,OS会判断数据将其转换为2号信号发送给进程
3.键盘与显示器独自拥有各自的缓冲区,互相不影响
2.2系统指令
除了在键盘输入ctrl+组合键,还可以使用命令行的方式来发送信号:
kill -信号 + pid
kill 是命令本身,信号 表示要发送的信号类型,信号可以通过信号编号(如 9 代表 SIGKILL) 或者信号名称(如 SIGTERM) 来指定。当省略信号时,默认发送 SIGTERM(15 号信号),pid 是目标进程的进程 ID。
pkill -信号 + 文件名字
pkill 是一个基于名称、进程属性等条件来查找并向匹配的进程发送信号的命令,信号 的含义与 kill 命令中的一样,用于指定要发送的信号类型,文件名字 指的是目标进程对应的可执行文件名称(比如 a.out 是一个 C 语言程序编译后的可执行文件),pkill 会根据这个名字去查找相关进程。此外,pkill 还支持通过其他条件(如进程所属用户 -u、进程所在终端 -t 等)来筛选进程。
2.3系统调用
2.3.1 kill:
向指定进程发送信号:
返回值:给指定进程发信号;成功就返回0;否则返回-1.
2.3.2 raise:
向本进程发送信号:
返回值:成功返回0失败返回非0。
2.3.3 abort:
向本进程发6号信号;并发生核心转储:
如果 SIGABRT 信号被忽略,或者被一个会返回的处理函数捕获,abort() 函数仍然会终止进程。它会通过恢复 SIGABRT 的默认处置方式,然后再次发送该信号来实现这一点。
补充: 部分系统中 abort() 会直接绕过信号处理机制: 某些 Linux 系统的 abort() 实现中,即使注册了 SIGABRT 的处理函数,abort() 也可能直接调用内核接口强制终止进程(如使用 _exit()),完全不触发自定义处理函数。这是因为 SIGABRT 设计用于处理 “不可恢复的致命错误”,系统不允许程序通过自定义处理函数逃避终止。 |
程序测试:
void signalHandler(int sig)
{
cout << "Signal " << sig << " received." << endl;
}
void test1()
{
signal(SIGINT, signalHandler); //signal(SIGINT, NULL); 空指针等于没有更改处理方式,还是默认动作
signal(SIGABRT, signalHandler); // 注册SIGABRT信号处理函数
kill(getpid(), SIGINT); // 发送SIGINT信号给自己
raise(SIGINT); // 发送SIGINT信号给自己
abort(); // 触发SIGABRT信号,虽然自定义了SIGABRT的处理函数,但abort()还是会导致程序终止
while(1)
{
cout<<"Running... Press Ctrl+C to send SIGINT." << endl;
sleep(1);
}
}
int main()
{
test1();
return 0;
}
2.4软件条件
SIGPIPE是一种由软件条件产生的信号,在“管道”中已经介绍过了。什么是软件条件?
在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产⽣机制。这些条件包括但不限于定时器超时(如alarm函数设定的时间到达)、软件异常(如向已关闭的管道写数据产⽣的SIGPIPE信号)等。当这些软件条件满⾜时,操作系统会向相关进程发送相应的信号,以通知进程进⾏相应的处理。简⽽⾔之,软件条件是因操作系统内部或外部软件操作⽽触发的信号产⽣。
这里主要介绍alarm函数 和SIGALRM信号:
unsigned int alarm(unsigned int seconds);会设定一个seconds秒后的闹钟,到时间后向当前进程发送SIGALRM信号。返回值:alarm() 函数返回距离之前已设置的闹钟预定触发时间剩余的秒数;若之前未设置任何闹钟,则返回零。闹钟是一次性的,也就是说只能设定触发一次,操作系统会将其描述为struct alarm的结构体通过堆进行组织:
struct alarm {
struct list_head list; /* 闹钟链表节点,用于将所有闹钟链接到进程的alarm_list */
unsigned int pending; /* 标记闹钟是否已生效(1)或未生效(0) */
ktime_t expiry; /* 闹钟到期时间(基于ktime_t时间类型) */
struct task_struct *task; /* 指向拥有该闹钟的进程描述符 */
unsigned int interval; /* 周期性闹钟的间隔时间(单位:jiffies) */
void (*function)(unsigned long); /* 闹钟到期时调用的函数 */
unsigned long data; /* 传递给function函数的参数 */
struct rcu_head rcu; /* RCU(Read-Copy-Update)机制相关字段 */
};
程序测试:
void signalHandler(int sig)
{
int n=alarm(5);
if (n == 0)
{
cout << "Alarm was not set." << endl;
}
else
{
cout << "Alarm was set to go off in " << n << " seconds." << endl;
}
cout << "Signal " << sig << " received." << endl;
}
void test2()
{
alarm(5);
signal(SIGALRM, signalHandler); // 注册SIGALRM信号处理函数
while(1)
{
cout<<"Running... Waiting for SIGALRM." << endl;
sleep(1);
}
}
int main()
{
test2();
return 0;
}
2.5硬件异常
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如:当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
为什么除零错误会产生段错误?操作系统如何得知?
1.代码中发生/0时,CPU上的状态寄存器会标记溢出标志位(由0->1),由于OS和CPU密切关联,OS会向进程发送信号,进程可以选择处理方式;当前进程相关联的寄存器上的数据都是当前进程的上下文,当前进程发生异常,不会影响到操纵系统或者其他进程的运行,当进程没有退出时,CPU一直检测到溢出标志位的错误,操作系统也会一直向进程发送信号;
2.当发生野指针错误时,保存在内存上的页表和MMU地址转换失败,虚拟地址到物理地址转换失败,会将失败地址放在CPU的一个寄存器中,同样会被操作系统识别到,向进程发送信号
程序测试:
void test3()
{
pid_t pid = fork();
if(pid < 0)
{
cout << "Fork failed." << endl;
return;
}
else if(pid == 0) // 子进程
{
cout<<"Child process started with PID: " << getpid() << endl;
int a=10;
a/=0; // 故意制造一个除零错误,触发SIGFPE信号
exit(2);
}
else // 父进程
{
int status=0;
waitpid(pid, &status, 0); // 等待子进程结束
cout<<"exit_code:"<<((status>>8)&0xFF)<<" exit_signal:"<<(status&0x7F) << " core_dump:"<<((status>>7)&1) << endl;//这里将退出码,收到的信号以及coredump标志位 使用位运算提取出来看看!
}
}
int main()
{
test3();
return 0;
}
这时可以看到/0错误被检测出,但是core_dump标志位为0,这需要我们手动设置ulimit参数:
手动ulimit -c 大小 去开启这一功能,形成core.pid后-g使用gdb调试:core-file core.pid可以查看错误来源
这样就可以看到设置的coredump文件大小为10240字节,再次运行看看:
core_dump为1,代表已经生成核心转储文件,可以使用GDB来事后调试查看错误出处:
可以看到,根据核心转储,我们可以发现代码的错误在78行,除零错误!
三.如何保存信号
3.1准备工作:
普及下有关信号的相关背景:
1.操作系统将信号发送给进程,保存在task_struct中的int signal:0000 0000 0000 0000 0000 0000 0000 0000 32个比特位,第0位不用,剩下31个比特位对应31个普通信号;
信号位图将指定的信号对应比特位0->1;如果是实时信号,会将信号存入结构体用双向链表管理起来
2.实际执⾏信号的处理动作称为信号递达(Delivery);信号从产⽣到递达之间的状态,称为信号未决(Pending);
在task_struct中,存在block、pending、handler三个位图,block位图用1表示当前比特位对应的信号被屏蔽,pending位图用1表示收到当前比特位对应的信号,handler函数指针数组,下标对应信号对应着信号处理方法。
3.进程可以选择阻塞(Block)某个信号。(被阻塞的信号在接受到之后只会被标记,不会被执行)
4.被阻塞的信号产⽣时将保持在未决状态,直到进程解除对此信号的阻塞,才执⾏递达的动作.
注意: 阻塞和忽略是不同的,只要信号被阻塞就不会递达,⽽忽略是在递达之后可选的⼀种处理动作;而且当pending已经标记后;当阻塞解除;它会立即执行对应的信号(先清空对应pending表的信号然后再去执行)
5.如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理? Linux是这样实现的 : 常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。
3.2进程如何管理信号
来看看流程图:
从上图来看,每个信号只有一个比特位的未决标志,非0即1,不记录该信号产生了多少次。阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储, sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。后面详细介绍——
3.2.1相关系统调用:
前文已经知道了sigset_t是一个位图结构,下面介绍操作:
#include <signal.h>
int sigemptyset(sigset_t *set);//把这个信号集每一位都初始化为0
int sigfillset(sigset_t *set);//把这个信号集每一位都变成1
int sigaddset(sigset_t *set, int signo);//添加 0->1
int sigdelset(sigset_t *set, int signo);//删除 1->0
int sigismember(const sigset_t *set, int signo);//判断信号是否存在(存在返回1不存在0出错-1)
注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
sigprocmask(对block位图进行操作)
返回值:若成功则为 0,若出错则为 -1(9&&19号信号无法被block)
- 如果 oldset 是非空指针,则读取进程的当前信号屏蔽字通过 oset 参数传出。
- 如果 set 是非空指针,则更改进程的信号屏蔽字,参数 how 指示如何更改。
- 如果 oset 和 set 都是非空指针,则先将原来的信号屏蔽字备份到 oset 里,然后根据 set 和 how 参数更改信号屏蔽字。
如果调用 sigprocmask 解除了对当前若干个未决信号的阻塞,则在 sigprocmask 返回前,至少将其中一个信号递达。
操作符 | 说明 | 等效表达式 |
---|---|---|
SIG_BLOCK | set 包含了我们希望添加到当前信号屏蔽字的信号 | mask = mask |
SIG_UNBLOCK | set 包含了我们希望从当前信号屏蔽字中解除阻塞的信号 | mask = mask & ~set |
SIG_SETMASK | 设置当前信号屏蔽字为 set 所指向的值 | mask = set |
sigpending(对pending位图进行操作)
拿出pending位图,成功返回0,失败返回-1
程序测试:
void print(sigset_t *set)
{
for(int i=32;i>=1;i--)
{
if(i%4==0) cout<<" ";
if(sigismember(set, i)) cout<<1;
else cout<<0;
}
cout << endl;
}
void signalhandler(int sig)
{
cout << "Signal " << sig << " received." << endl;
}
void test2()
{
sigset_t set,oset,pset;
//练习使用各种信号集操作函数
signal(SIGINT, signalhandler);
sigemptyset(&set); // 初始化信号集为空
sigemptyset(&oset); // 初始化另一个信号集为空
sigemptyset(&pset); // 初始化第三个信号集为空
sigaddset(&set, 2); // 将SIGINT信号添加到信号集中
int n=sigprocmask(SIG_SETMASK, &set, &oset); // 将set信号集中的信号阻塞,并将之前的阻塞信号集保存到oset中
if(n == -1)
{
perror("sigprocmask");
return;
}
int cnt=0;
while(1)
{
int n=sigpending(&pset); // 获取当前进程的未决信号集
if(n == -1)
{
perror("sigpending");
return;
}
print(&pset); // 打印未决信号集
cnt++;
if(cnt==10)
{
int m=sigprocmask(SIG_SETMASK, &oset, nullptr); // 恢复之前的阻塞信号集
if(m == -1)
{
perror("sigprocmask");
return;
}
}
sleep(1);
}
}
int main()
{
test2();
return 0;
}
可以看到,当ctrl+C时,pending表收到2号信号,0->1,但由于block表中2号信号被设置为阻塞,因此不处理,当cnt==10时,block表取消对2号信号的阻塞,pending表中1->0,对应自定义处理函数生效!