目录
生活中,信号相关的场景
红绿灯、闹钟、转向灯、狼烟……
- 对于生活中的信号,我们会潜移默化的记住对应场景下信号的含义,以及信号出现后要执行的动作。
- 即便特定的信号没有产生,但我们也知道应该如何处理这个信号。
- 我们在收到信号的时候,可能正在执行某个动作,并不会立刻去处理这个信号。
- 信号本身,在我们无法立即处理的时候,一定会临时的记住这个信号。
技术应用角度的信号
信号
- 本质是一种通知机制,用户or操作系统通过发送一定的信号,通知进程,某些事件已经发生,可以在后续进行处理。
- 进程要处理信号,必须具备信号的识别能力。
- 信号的产生是随机的,进程可能正在执行其他动作。所以,信号的后续处理,可能不是立即处理的。临时记录下信号,方便后续处理。
- 信号是进程之间事件异步通知的一种方式,属于软中断。
用户输入命令,在Shell下启动一个前台进程,并用Ctrl-c终止。
用户按[Ctrl c],这个键盘输入会产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程。
前台进程因为收到吸纳后,进而引起进程退出。
Ctrl-c本质就是发送2号信号。
#include<stdio.h>
int main()
{
while (1)
{
printf("I am a process, I am waiting signal\n");
sleep(1);
}
return 0;
}
- Ctrl-c产生的信号只能发给前台进程。一个命令后面加上&,就可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
- Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接收到像Ctrl-c这样控制键产生的信号。
- 前台进程在运行过程中用户随时可能安县Ctrl-c而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到SIGINT信号而终止。所以信号相对于进程的控制流程来说是异步的。
系统定义的信号列表
用kill -l命令可以查看系统定义的信号列表
信号常见的处理方式:
- 忽略此信号(忽略也是信号处理的一种方式)。
- 执行该信号的默认处理动作(进程自带的)。
- 自定义处理方式(捕捉信号):提供一个信号处理函数,要求内核在处理该信号时,切换到用户态执行这个处理函数。
如何理解信号被进程保存:
进程必须具有保存信号的相关数据结构(位图 , unsigned int)。unsigned int有三十二个比特位,发送的是什么信号用第几位bit位表示,是否产生该信号用对应bit位为0为1来表示。
PCB内部保存了信号位图字段。
信号位图是在task_struct中的,task_struct是内核数据结构,所以发送信号是由OS去发的,因为只有OS才有资格通过修改目标进程的task_struct的位图结构,来完成“发送”信号的过程。
信号的产生
通过终端按键产生的信号
SIGINT的默认处理动作是终止进程。
SIGQUIT的默认处理动作是终止进程并且Core Dump。
Core Dump核心转储
当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做Core Dump。
进程异常终止通常是因为有Bug,比如非法访问内存导致段错误。事后可以用调试器检查core文件,以查清错误原因,这叫做Post-moterm Debug(事后调试)
一个进程允许产生多大的core文件取决于进程的Resource Limit(这个消息保存在PCB中)。
默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感消息,不安全。
在开发调试阶段,可以用ulimit命令改变这个限制,允许产生core文件。
允许core文件最大文件为1024k: $ ulimit -c 1024
关闭或阻止core文件生成:$ulimit -c 0
du(disk usage)命令可以查看文件大小
-h选项可以显示单位
进程控制中获取子进程status中的core dump
core dump标志表示,进程异常退出时,是否发生核心转储。
如下代码故意让子进程除以0。
#include<iostream>
#include<signal.h>
#include<unistd.h>
#include<sys/wait.h>
#include<sys/types.h>
using namespace std;
int main()
{
pid_t id = fork();
if (id == 0)
{
//子进程
sleep(1);
int a = 100;
a /= 0;
exit(0);
}
int status = 0;
waitpid(id, &status, 0);
cout << "父进程:" << getpid() << "子进程:" << id << \
"exit sig:" << (status & 0x7F) << "is core:" << ((status >> 7) & 1)<< endl;
return 0;
}
调用系统函数向进程发送信号
用户调用系统接口->执行OS对应的系统调用代码->OS提取参数,或者设置特定的数值->OS向目标进程写信号->修改对应进程的信号标记->进程后续会处理信号->执行对应的处理动作
kill函数&raise函数
//kill函数 //raise函数
功能:给指定的进程发送指定的信号 功能:给当前进程发送指定信号(自己给自己发)
#include<sys/types.h> #include<signal.h>
#include<signal.h>
int kill(pid_t pid, int signo); int raise(int signo);
返回值:
这俩函数成功返回0;错误返回-1.
abort函数
功能:使当前进程接受到信号而异常终止。
#include<stdlib.h>
void abort(void);
如exit函数一般,abort函数总是会成功的,所以没有返回值。
- ./test &中的&代表后台执行,即执行这个程序的同时,终端还能同时做其他事情。
- 30282是test进程的id。之所以要再次回车才显示Segmentation fault,是因为在30282进程终止掉之前已经回到了Shell提示符等待用户输入下一条命令,Shell不希望Segmentation fault信息和用户的输入交错在一起,所以等待用户输入命令之后才显示。
- SIGSEGV: SIG 是信号名的通用前缀, SEGV 是segmentation violation,也就是存储器区段错误。而上面的死循环程序没有非法访问内存的错误,给他发送SIGSEGV也能产生段错误。
软件条件产生的信号
alarm函数
功能:设定一个闹钟,告诉内核在seconds秒后,给当前进程发送SIGALRM信号,该信号的默认处理动作是终止当前进程
#include<unistd.h>
unsigned int alarm(unsigned int seconds);
返回值是0或者之前设定的闹钟时间还余下的秒数。
硬件异常产生的信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。
如:当前进程执行了除以0的运行的指令,CPU运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。
再如:当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
信号捕捉
signal函数
#include<signal.h>
typedef void (*sighandler_t)(int);//返回值为void*,参数为int的函数指针
sighandler_t signal(int signum, sighandler_t handler);
参数:
signum:信号编号
信号SIGINT
产生方式:键盘Ctrl-c
产生结果:只对当前前台进程,和他所在的进程组的每个进程发送SIGINT信号。之后这些进程会执行信号处理再终止。
signal函数,仅仅是修改进程对特定信号的后续处理动作,不是直接调用对应的处理动作。
如下代码中,特定信号的处理动作一般只有一个。将进程对2号信号的处理动作修改为打印信号编号,致使Ctrl-c无法终止程序。
#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;
void catchSig(int signum)
{
cout << "进程捕捉到了一个信号,正在处理:" << signum << "Pid:" << getpid() << endl;
}
int main()
{
//signal的第一个参数写信号名称、信号编号都可以
//signal(2, catchSig);//#define SIGINT 2 /* Interrupt (ANSI). */
signal(SIGINT, catchSig);
//如果后续没有任何的SIGINT信号产生,catchSig永远也不会被调用。
while(true)
{
cout << "我是一个进程,我正在运行……,Pid:" << getpid() << endl;
sleep(1);
}
return 0;
}
- 运行程序时,进程对2号信号的处理动作已经被修改为打印信号编号,致使Ctrl-c无法终止程序。
- 这时可以用Ctrl-\,Ctrl-\是向目标进程发送3号信号。
阻塞信号
信号其他相关常见概念
- 实际执行信号的处理动作称为信号递达
- 信号从产生到递达之间的状态称为信号未决
- 进程可以选择阻塞某个信号
- 被阻塞的信号产生时将保持未决状态,直到进程接触对此信号的阻塞,才执行递达的动作
- 阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
信号在内核中的表示
- 每个信号都有两个标识位分别表示阻塞(block)和未决(pending)还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然他的处理动作是忽略,但是在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再接触阻塞。
- SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
信号集操作函数
sigset_t
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志 可以用相同的数据类型sigset_t来表示,sigset_t称为信号集。
这个类型可以表示每个信号的“有效”“无效”状态,在阻塞信号集中“有效”“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”“无效”的含义是该信号是否处于未决状态
阻塞信号集也叫做当前进程的信号屏蔽字。
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。
#include<signal.h>
int sigemptyset(sigset_t *set);//清空,把所有bit位全置0
int sigfillset(sigset_t *set);//把所有bit位全置1
int sigaddset(sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
返回值
头四个函数,成功返回0,出错返回-1。
sigismember是一个bool函数,用于判断一个信号集的有效信号中是否包含某种信号,
若包含则返回1,不包含则返回0,出错返回-1。
- 函数sigemptyset初始化set所指向的信号集,使其中所有信号对应的bit位置零,表示该信号集不包含任何有效信号。
- 函数sigfilllset初始化set所指向的信号集,使其中所有信号的对应bit位置一,表示该信号集的有效信号包括系统支持的所有信号。
- ps.在使用sigset_t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以调用sigaddset和sigdelset在该信号集合中添加或删除某种有效信号。
sigprocmask
功能:读取或更改进程的阻塞信号集(信号屏蔽字)
#include<signal.h>
int sigprocmask(int how, coonst sigset_t *set, sigset_t *oldset);
参数:
如果oldset是非空指针,则读取进程的当前阻塞信号集
如果set是非空指针,则更改进程的阻塞信号集,参数how指示如何更改信号屏蔽字
如果oldset和set都是非空指针,则先将原来的阻塞屏蔽字备份到oset里,然后根据set和how参数更改阻塞信号集
how参数的可选值:
1.SIG_BLOCK:set包含了我们希望添加到当前阻塞信号集的信号,相当于mask = mask|set
2.SIG_UNBLOCK:set包含了希望从当前阻塞信号集中解除阻塞的信号,相当于mask = mask&~set
3.SIG_SETMASK:设置当前阻塞信号集为set所指向的值,相当于mask=set
sigpending
功能:读取当前进程的未决信号集,通过set参数传出。
#include<signal.h>
int sigpending(sigset_t *set);
返回值:
调用成功返回0;出错返回-1.
如果对所有信号都进行自定义捕捉,那么该进程是否不会被异常或者用户杀掉?
并不是
9号信号属于管理员信号,无法设定自定义捕捉动作
对2号信号block且突然发送一个2号信号,并不断获取并打印当前进程的pending信号集
#include<iostream>
#include<assert.h>
#include<unistd.h>
#include<signal.h>
static void handler(int signum)
{
std::cout << "捕捉2号信号 " << signum << std::endl;
}
static void showPending(sigset_t &pending)
{
for (int sig = 1; sig <= 31; sig++)
{
if (sigismember(&pending, sig))
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout << std::endl;
}
int main()
{
//0. 为了更方便看到2号信号被恢复,这里进行对2号信号的捕捉,不要直接退出
signal(2, handler);
//1. 定义信号集对象
sigset_t bset, obset;
sigset_t pending;
//2. 初始化
sigemptyset(&bset);
sigemptyset(&obset);
sigemptyset(&pending);
//3. 添加要进行屏蔽的信号
sigaddset(&bset, 2/*SIGINT*/);
//4. 设置set到内核中对应的进程内部(默认情况下,进程不会对任何信号进行block)
int n =sigprocmask(SIG_BLOCK, &bset, &obset);
assert(n == 0);
(void)n;
std::cout << "block 2号信号成功" << getpid() << std::endl;
//5. 重复打印当前进行的pending信号集
int count = 0;
while(true)
{
//5.1 获取当前进行的pending信号集
sigpending(&pending);
//5.2 显示pending信号集中没有被递达的信号
showPending(pending);
sleep(1);
count++;
if(count == 10)
{
//默认情况下,恢复2号0信号的block的时候,确实会进行递达
//但是2好信号的默认处理动作是终止进程
//所以为了更方便看到2号信号被恢复,这里进行对2号信号的捕捉
int m = sigprocmask(SIG_SETMASK, &obset, nullptr);
(void*)m;
std::cout << "解除对于2号信号的block" << std::endl;
}
}
return 0;
}
可以通过pidof+进程名称的方式获取pid
捕捉信号
内核如何实现信号的捕捉
sigaction
sigaction函数
功能:可以读取和修改指定信号相关联的处理动作。
#include<signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oldact);
参数:
signo:指定信号的编号
若act指针非空,则根据act修改该信号的处理动作。
若oldact指针非空,则通过oldact传出该信号原来的处理动作。
act和oldact都指向sigaction结构体。;
返回值:
成功返回0;出错返回1-
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);
};
将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先后向链表中插入两个节点,而最后只有一个节点真正插入了链表中。
一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free。因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数
volatle
标准情况下,键入Ctrl-c,2号信号被捕捉,执行自定义动作,修改flag=1,while条件不满足,进程退出。
优化情况下,键入Ctrl-c,2号信号被捕捉,执行自定义动作,修改flag=1,但是while条件依旧满足,进程依旧继续运行。
很明显,while循环检查的flag,并不是内存中最新的flag,这就存在数据二异性问题。
volatile作用:保持内存的可见性,告知编译器,被改关键字修饰的变量不允许被优化,对该变量的任何操作,都不许在真是的内存中进行操作。