目录
技术应用角度的信号
信号本质是一种通知机制,用户或操作系统通过发送一定的信号,通知进程某些时间已经发生了,进程可以在后续进行信号处理
进程要处理信号,那么进程必须具备信号识别的能力(接收信号加上相对应的信号处理动作)
进程能够识别信号,是设计操作系统的程序员将常见的信号及信号处理动作内置到进程的代码和属性中。
信号产生是随机的,当信号产生时,进程可能正在处理某些任务。所以,信号可能不是立即被进程处理的。
信号会被临时地记录下来,在合适的时候进行处理。
一般而言,信号的产生相对于进程而言是异步的
异步指两个或两个以上的对象或事件不同时存在或发生(或多个相关事物的发生无需等待其前一事物的完成)。同步指两个或两个以上随时间变化的量在变化过程中保持一定的相对关系。
信号也有确定的信号,比如:定下闹钟的时间时,那么闹钟一定会在那个时间点响起来
信号常见的处理方式:
默认(进程自带的处理动作)
忽略 (不对信号进行处理,就好比闹钟响了之后关掉闹钟继续睡觉)
自动以动作(通过捕捉信号实现特定动作)
常见信号
使用 kill -l 命令可以查看常见的信号
Linux 内核支持 62 种不同的信号,这些信号都有一个名字,这些名字都以三个字符 SIG 开头。在头文件siganl.h中你能够,这些信号都被定义为正整数,称为信息编号。其中,编号 1 到 31 的信号称为普通信号,编号 34 到 64 的信号称为实时信号,实时信号对处理的要求比较高。
信号产生
通过终端按键产生信号
当我们在按下Ctrl + C 时,当前的程序就会停止运行,这是因为按下组合键 Ctrl + C 可以前台进程发送 2 号信号,我们可以通过 signal 函数来验证一下
signal
函数原型:
typedef void (*sighandler_t)(int);
sidhandler_t signal(int signum, sidhandler_t handler);
使用 signal 函数后,当进程接收到 signum 信号时,进程会调用 handler 函数(handler 是回调函数,handler 是 函数指针类型,该函数的返回值是 void,参数是 int)并将 signum 传递给 handler 函数,其实 signal 函数相当于可以自定义捕捉某个信号。signal 函数的返回值是对于 signum 信号的旧的处理方法。
代码示例:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void print(int signal)
{
printf("捕捉到了2号信号\n");
}
int main()
{
signal(2,print);//捕捉2号信号,特定信号的处理动作一般只有一个
while(1)//死循环不断运行
{
printf("我是一个进程,我的pid:%d\n",getpid());
sleep(1);
}
return 0;
}
程序运行后不断进行打印
当我们按下组合键 Ctrl + C 时,程序并没有停止,进程也捕捉到了2号信号,说明Ctrl + C 对应的就是2号信号
现在就无法通过 Ctrl + C(2 号信号)终止该进程了,那么我们可以通过 Ctrl + \ (3 号信号)终止该进程
signal 函数仅仅是修改进程对特定信号的后续处理动作,并不是直接调用对应的处理动作。而是当进程接收到特定信号时,才会去调用对应的处理动作。如果后续没有产生 2号 信号,print函数就不会被调用,signal 函数往往放在最前面,先注册特定信号的处理方法
核心转储(Core Dump)
首先解释什么是Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁 盘上,文件名通常是core,这叫做Core Dump。
进程异常终止通常是因为有Bug,比如非法内存访问导致段错误, 事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许 产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的, 因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许 产生core文件。 首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024K: $ ulimit -c 1024
一般而言,云服务器(生产环境)的核心转储功能是关闭的。
打开云服务器的核心转储功能后,我们来验证一下是否真的会产生 core 文件:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void print(int signal)
{
printf("捕捉到了2号信号\n");
}
int main()
{
signal(2,print);//捕捉2号信号,特定信号的处理动作一般只有一个
while(1)//死循环不断运行
{
printf("我是一个进程,我的pid:%d\n",getpid());
sleep(1);
}
return 0;
}
注:只有核心转储才会生成 core 文件。
可以看到 core 文件是以进程 ID 作为后缀,通常该文件是比较大的。为了防止生成大量的 core 文件占用磁盘空间。所以核心转储功能一般是关闭的
验证进程等待中的 core dump 标记位
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
//child
sleep(2);
int a = 10;
a /=0;//设置除0 错误
return 1;
}
int status = 0;
//等待子进程退出
int ret = waitpid(id,&status,0);
if(ret < 0)
{
perror("waitpid");
return 2;
}
printf("父进程 id: %d\n 子进程退出码:%d\n core标志位:%d\n",getpid(),status & 0x7F, (status >> 7) & 1);
return 0;
}
将核心转储功能关闭,就不会生成 core 文件,core dump 标记位始终为 0;当进程不是收到核心转储信号终止进程的,也不会生成 core 文件,core dump 的标记位也始终为 0
调用系统函数向进程发信号
kill命令
kill命令是调用kill函数实现的。kill函数可以给一个指定的进程发送指定的信号
int kill(pid_t pid, int signo);
成功返回0,失败返回-1;
代码示例:
先写一个死循环让程序跑起来
int main()
{
while(1)
{
printf("pid: %d\n",getpid());
sleep(2);
}
return 0;
}
再写一个mykill文件
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <string.h>
#include <stdlib.h>
int main(int argc,char* argv[])
{
if(argc !=3)
{
exit(1);
}
int signal = atoi(argv[1] +1);
int id = atoi(argv[2]);
kill(id,signal);
return 0;
}
运行程序时输入信号及对应的id
对应的id在接收到信号后就会做出反应
raise命令
raise函数可以给当前进程发送指定 的信号(自己给自己发信号)
其效果等价于 kill("本身id",int signo )
int raise(int signo);
成功返回0;失败返回-1
abort函数
使当前进程接收到信号而异常终止。
void abort(void);
就像exit函数一样,abort函数总是会成功的,所以没有返回值。
由软件条件产生信号
alarm函数
unsigned int alarm(unsigned int seconds);
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM(13号)信号, 该信号的默认处理动 作是终止当前进程
这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个比方,某人要小睡一觉,设定闹钟为30分钟之后 响,20分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为15分钟之后响,“以前设定的闹钟时间还余下的时间”就 是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数
通过这个函数我们可以简单地制作一个闹钟,看一看计算的算力
代码示例:
让count不停++,每隔1秒打印一次
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
unsigned int count =0;
void print(int signal)
{
printf("count: %d\n",count);
alarm(1);
}
int main()
{
signal(SIGALRM,print);//信号捕捉
alarm(1);//每隔一秒发送一次SIGALRM信号
while(1)
{
++count;
}
return 0;
}
如何理解软件条件给进程发送信号?操作系统先识别到某种软件条件触发或者不满足,然后操作系统构建信号发送给指定的进程
注意:闹钟也是结构体,操作系统通过特定的数据结构来管理闹钟。当闹钟超时了,操作系统就会给闹钟结构体中存储的进程 id 发送 SIGALRM 信号
硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号
例如当前进程执行了除 以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程
代码示例:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <stdlib.h>
#include <string.h>
void print(int signal)
{
sleep(1);
printf("捕捉到一个信号: %d\n",signal);
}
int main()
{
signal(SIGFPE,print);
int a = 100;
a /= 0;
return 0;
}
再比如当前进程访问了非 法内存地址,,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程
代码示例:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <stdlib.h>
#include <string.h>
void handler(int signum)
{
sleep(1);
printf("捕捉到一个信号: %d\n",signum);
}
int main()
{
// SIGSEGV 段错误(11号信号)
signal(11, handler);
int* a = NULL;//模拟野指针
*a = 100;
return 0;
}
阻塞信号
信号其他相关常见概念
实际执行信号的处理动作称为信号递达(Delivery)
信号从产生到递达之间的状态,称为信号未决(Pending)
进程可以选择阻塞 (Block )某个信号
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
在内核中的表示
信号在内核中的表示示意图
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号 产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子 中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作
SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前 不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞
SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次 或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可 以依次放在一个队列里
sigset_t
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。 因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号 的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有 效”和“无效”的含义是该信号是否处于未决状态。 阻塞信号集也叫做当 前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略
用户可以直接使用 sigset_t 类型,和使用内置类型和自定义类型没有任何差别
信号集操作函数
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所指向的信号集,pu
函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位1,表示该信号集的有效信号包括系统支持的所有信号
这四个函数都是成功返回0,出错返回-1
函数sigismember可以判断 signo 信号是否在信号集中,若包含则返回1,不包含则返回0,出错返回-1
注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的 状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号
sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信 号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后 根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达
sigpending
sigpending 读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1
下面用刚学的几个函数做个实验
如果我们将 2 号信号 block 掉,并且不断地获取并打印当前进程的 pending 信号集。如果我们突然发送一个 2 号信号,我们应该就能看到 pending 信号集中 2 号信号的比特位由 0 变成 1
代码示例:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>
void print(sigset_t* pending)
{
int i = 0;
for(;i<32;i++)
{
if(sigismember(pending ,i))
{
putchar('1');
}
else putchar('0');
}
printf("\n-----------------\n");
sleep(1);
}
int main()
{
//定义并初始化信号集
sigset_t set,oset;
//使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号
sigemptyset(&set);
sigemptyset(&oset);
//将2号信号添加到信号屏蔽集中
sigaddset(&set,2);
//将信号屏蔽集设置到当前PCB当中
//默认情况下,进程不会对任何信号进行block
int n = sigprocmask(SIG_BLOCK,&set,&oset);
assert(n ==0 );
(void)n;//消除assert被优化后n没有使用的警告
printf("block 2号信号成功\n");
//重复打印当前进程的pending信号集
sigset_t pending;
sigisemptyset(&pending);//初始化
while(1)
{
//获取当前进程的pending信号集
sigpending(&pending);
//打印
print(&pending);
}
return 0;
}
程序运行时,每秒钟把各信号的未决状态打印一遍,由于我们阻塞了SIGINT信号,按Ctrl-C将会 使SIGINT信号处于未决 状态,按Ctrl-\仍然可以终止程序,因为SIGQUIT信号没有阻塞
捕捉信号
信号产生之后,进程可能无法立即处理,进程需要在合适的时候去处理信号。那这个合适的时候是什么呢?
信号相关的数据字段是在进程的 PCB 内部,PCB 内部属于内核范畴,普通用户无法对信号进行检测和处理。那么要对信号进行处理,就需要在内核状态。当执行系统调用或被系统调度时,进程所处的状态就是内核态;不执行操作系统的代码时,进程所处的状态就是用户态。现在我们已经知道需要在内核态下进行信号处理,那究竟具体是什么时候呢?结论:在内核态中,从内核态返回用户态的时候,进行信号的检测和处理!
用户态:执行自己写的代码的时候,进程所处的状态
内核态:执行os的代码的时候,进程所处的状态
那为什么要从用户态到内核态呢? 因为操作系统是软硬件的管理者,只要访问硬件,就必须通过操作系统来进行访问,就必须从用户态转换到内核态,当执行系统调用,进程调度,处理异常等行为时,都要从用户态转换到内核态
为什么又要从内核态到用户态呢?因为用户的代码还没有执行完,用户的进程还没有调度完等原因
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码 是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行 main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号 SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler 和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。 sighandler函数返 回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复 main函数的上下文继续执行了
sigaction
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signo 是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非 空,则通过oact传 出该信号原来的处理动作。act和oact指向sigaction结构体:
将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动 作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函 数,该函数返回 值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信 号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来 的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 如果 在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需 要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
可重入函数
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因 为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函 数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从 sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步 之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只 有一个节点真正插入链表中了
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称 为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之, 如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数
总的来说:一个函数在被调用执行期间(尚未调用结束),由于某种时序有被重复调用,称之为重入
如果一个函数符合以下条件之一则是不可重入的:
调用了malloc或free,因为malloc也是用全局链表来管理堆的
调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构