1.信号的概念
- 信号没有产生的时候,其实我们已经能够知道,怎么处理这个信号了。
使用kill -l 可以查看所有信号信息

- 信号的到来,我们并不清楚具体什么时候,信号到来相对于我正在做的工作是异步产生的。
- 同步 :做完一件,再做下一件 ,调用方等待结果返回,全程阻塞。
- 异步 :发起请求就立刻返回,调用方不等待,结果后续通过通知 / 回调处理,全程不阻塞。
- 信号产生了,我们不一定要立即处理它,而是我们在合适的时候处理。
- 我们有一种能力,将已经到来的信号,进行暂时保存在PCB中。
进程在运行的时候,前台(命令行操作的时候,只能有一个),后台(./XXX &)
jobs 查看后台任务。使用fg + 后台任务的编号 命令将内存提到前台,shell 会自动把自己放到后台。
区分是前台进程还是后台进程主要是看有没有从键盘接收数据的能力。一般 ctrl + c 终止的是前台进程,shell 是做了一些操作保证自己不会被终止。OS会自动的把shell提到前台或者后台。
前台进程不能不能被暂停(ctrl + z) ,如果被暂停,由 shell 接管,该前台进程会立即被放到后台,状态为stopped。我们可以使用 bg + 任务编号 重启。

信号本质就是用软件模拟中断的行为的,处理进程之间的管理,而中断是处理外设与操作系统的交互IO关系。
crtl + c 等价 kill -2 
sighandler_t 是一个函数指针,signal 系统调用就是将默认的 signum 拦截并自定义信号行为,修改完成后一直有效,
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void handler(int signo)
{
std::cout << "获得了2号信号"<<endl;
exit(1);
}
int main()
{
signal(2,handler);//将2号信号改为打印一语句话再终止程序
while (true)
{
cout << "running..." << endl;
sleep(1);
}
return 0;
}

2.信号的产生
2.1 键盘输入
上面已经讲过可以通过键盘输入 ctrl + c ,向前台进程发送2号信号
既能使用名字也能使用编号,说明是信号是使用宏定义的。没有0号信号 ,因为进程退出时会有退出状态和终止信号两个信息,如果表示没有收到信号默认是0。
还没有32号和33号信号。1到31是普通信号,34到64是实时信号。实时信号一般需要及时处理,不会出现丢失的情况,我们这里重点讲一下普通信号。
(13)SIGPIPE: 读端关闭,写端一直写。写端就会收到这个信号然后关闭写端。
每一个进程都有一张自己的函数指针数组,数组的下标就和信号的编号强相关,内容指向处理该信号的方式。
对于普通信号来讲,进程收到信号后,进程要表示自己是否 收到了某种 信号。是否->位图(比特位的位置决定信号编号,比特位的内容决定是否收到信号 ),收到信号,相应位置变为1。
OS向目标发信号实质是写信号,直接根据进程 pid 找到该进程然后修改位图。
无论信号有多少种产生方式,永远只能让 OS 向目标进程发信号!因为 OS 是进程的管理者。所以之前 ctrl + c 被操作系统识别到了,然后被 OS 解释为2号信号,向目标进程的PCB 信号位图由0置1,合适的时候根据函数指针数组完成信号的处理。
每个进程对于信号
- 函数指针数组(对应不同信号的处理方式)
- 信号位图(信号的保存)
signal 就是将输入的下标在函数指针数组中对应的函数指针内容改变,指向自定义的处理方法。
cpp
void handler(int signo)
{
std::cout << "获得了"<< signo <<"号信号"<<endl;
exit(1);
}
int main()
{
//signal(2,handler);//将2号信号改为打印一语句话再终止程序
signal(19,handler);
signal(20,handler);
signal(3,handler);
while (true)
{
cout << "running...,pid:" << getpid() << endl;
sleep(1);
}
return 0;
}
ctrl + \ -> Quit 对应(3)SIGQUIT 信号
可以键盘输入的信号:
ctrl + z(进程暂停),ctrl + \(进程退出),ctrl + c(终止进程)

我们使用kill -2 -3 -20都可以使用我们自定义的方法,但是19号信号不行,因为Linux 系统设计中有两个特殊信号是绝对的、不可违抗的:
- SIGKILL:强制终止进程,无法捕获。
- SIGSTOP (19):暂停(停止)进程,同样无法捕获。
使用signal 无法为这两个信号注册自定义处理函数。当你试图这样做时,系统会忽略你的注册请求,信号的默认行为仍然会执行。
我们可以试试把9号命令修改为什么都不做,但结果9号命令仍然可以终止进程。
cpp
void handler(int signo)
{
std::cout << "获得了"<< signo <<"号信号"<<endl;
}
int main()
{
signal(9,handler);
while (true)
{
cout << "running...,pid:" << getpid() << endl;
sleep(1);
}
return 0;
}
man 7 signal 列出了系统支持的所有信号(Signal),并详细说明了每个信号的编号、默认行为和含义
2.2 系统调用
kill向目标进程发送一个信号

cpp
static void Usage(const std::string&proc)
{
std::cout<<"\nUsage:"<<proc<<" -signumber process\n"<<std::endl;
}
void handler(int signo)
{
std::cout << "获得了"<< signo <<"号信号"<<endl;
exit(1);
}
int main(int argc,char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(0);
}
int signumber = std::stoi(argv[1]+1);//说如果加- 就字符+1第2个字符
int processpid = std::stoi(argv[2]);
kill(processpid,signumber);
}
bash
./mykill -9 989343
raise 给自己发送一个信号

cpp
void handler(int signo)
{
std::cout << "获得了"<< signo <<"号信号"<<endl;
}
int main()
{
signal(2,handler);
while(1)
{
raise(2);
sleep(1);
}
return 0;
}

abort 自己引起进程异常终止

cpp
void handler(int signo)
{
std::cout << "获得了"<< signo <<"号信号"<<endl;
}
int main()
{
signal(SIGABRT,handler);
abort();
return 0;
}

2.3 异常
发生除0错误(8号信号)时,CPU内部寄存器溢出标记位会置1,在硬件上是能表示出来的,然后通知OS系统,向目标进程发送终止信号,CPU执行其他任务,由硬件问题转换为信号问题。
cpp
void handler(int signo)
{
std::cout << "获得了"<< signo <<"号信号"<<endl;
}
//异常
int main()
{
signal(8,handler);
int a = 10;
a /= 0;
return 0;
}
这样之后只会警报,不会终止进程,但会一直打印收到8号信号。
CPU内的寄存器 != 寄存器里的内容,内容属于当前进程,出现异常时,把进程杀掉,默认就是处理问题的方式之一,杀掉进程溢出标志位恢复。如果进程没有被杀掉,溢出标志位不会恢复,每次调度都因为CPU发现溢出标志位为1 ,CPU都会让OS将异常转换为信号 发送给相应进程,可以是处理进程的方式就是打印一句话,依旧不被杀死,所以会不断打印。
- CPU识别到我们的进程有异常,不再继续执行后续代码。
- 通知操作系统处理,cpu调度其他进程
- 操作系统打印一句话,没有终止
下面方式写就不会循环打印。
cpp
void handler(int signo)
{
std::cout << "获得了"<< signo <<"号信号"<<endl;
exit(1);
}
野指针
零号地址在页表中并没有真正的映射,在页表中转换失败,会有mmu内容管理单元这个硬件的标志位表示这次失败,然后通知CPU,CPU通知OS。本质是虚拟到物理出现的硬件问题,由操作系统识别,然后向进程写信号
cpp
void handler(int signo)
{
std::cout << "获得了"<< signo <<"号信号"<<endl;
sleep(1);
}
//异常
int main()
{
signal(11,handler);
int *p = nullptr;
*p = 1;
return 0;
}
也是不断打印(11)SIGSEGV 信号。
所有代码出现异常进程不是一定要退出的,要看用户想怎么处理这个信号。在vs中发生异常会直接被终止,是因为这个异常被 windows 操作系统识别出来了,然后强制终止。另外产生信号的方式可以有很多,但是发送信号只能是操作系统。
2.4 软件条件
管道通信中,当读端关闭后,操作系统识别到写端还在一直写,会发送 (13)SIGPIPE 直接关闭写端。这不属于硬件,因为这些空间进程可以访问。软件也可以产生信号,操作系统是软硬件资源的管理者,出现问题都需要处理
大部分信号都是异常产生,还有让进程暂停,继续和其他事情的信号,非异常情况也会产生信号。
闹钟:使用 alarm 系统调用可以在系统中设置闹钟。对应(14)SIGALRM 信号。

seconds 想设置几秒后的闹钟,返回值一般为0,如果闹钟提前返回(再定义一个闹钟时 alarm 的返回值),返回值是曾经设置闹钟值的剩余时间。如果 seconds 为 0,则取消所有已设置的闹钟,并且不会产生 SIGALRM 信号,返回已设置闹钟的剩余时间。
cpp
int cnt = 0;
void handler(int signo)
{
std::cout << "get a signo:"<< signo <<" alarm: "<< cnt <<std::endl;
exit(0);
}
int main()
{
signal(14,handler);
alarm(1);
while(true)
{
cnt++;
}
return 0;
}

cpp
int count = 0;
int main()
{
alarm(1);
while(true)
{
std::cout << "alarm:" << count++ << std::endl;
}
return 0;
}

下面那种方式是因为 std::endl 强制刷新内存缓冲区导致缓冲区失效,每次都调用系统调用 write。第一段代码快 ,是因为它完全不涉及任何类型的缓冲区
seconds 为 0,则取消所有已设置的闹钟,并且不会产生 SIGALRM 信号,返回已设置闹钟的剩余时间:
cpp
int cnt = 0;
int result = 0;
void handler(int signo)
{
result = alarm(0);//历史闹钟取消掉,会返回历史闹钟还剩多少时间
cout << "result:" << result << endl;
exit(0);
}
int main()
{
signal(14,handler);
cout<< "pid:" << getpid() <<endl;
alarm(30);
return 0;
}

每个两秒重新定一个闹钟:打印两次runing
cpp
int cnt = 0;
int result = 0;
void handler(int signo)
{
result = alarm(2);//历史闹钟取消掉,设置新的闹钟时长
cout << "result:" << result << endl;
//std::cout << "get a signo:"<< signo <<" alarm: "<< cnt <<std::endl;
//exit(0);
}
//异常
int main()
{
signal(14,handler);
cout<< "pid:" << getpid() <<endl;
alarm(2);
while(true)
{
cout<<"runing"<<endl;
sleep(1);
}
}
这里没有exit(0)。信号处理函数本质上就是一个普通的 C++ 函数,它的执行流是这样的:
- 内核收到闹钟信号,暂停 main 中正在执行的代码(比如正在 sleep(1))。
- CPU 跳转去执行 handler 函数。
- handler 函数执行完最后一条语句,遇到结尾的 },函数正常返回。
- CPU 跳转回 main 函数,恢复执行刚才被打断的那行代码。这里第二次打印runing后sleep(1)其实是没有执行完被中断的,因为cout result 是需要时间的,时间不够。

操作系统中的时间问题
- 所有用户的行为,都是以进程的形式在OS中表现的
- 操作系统只要把进程调度好,就能完成所有的用户任务
- CMOS可以周期性的,高频率的向CPU发送时钟中断。
- 朴素地对操作系统进行理解:操作系统的本质是处理好前置工作,各种中断的陷阱的初始化工作,然后死循环暂停。
- COMS通过向CPU发送时钟中断号,CPU调用中断向量表中相应的操作系统调度方法,此时我们的进程就在硬件驱动的情况下开始调度执行(轮转),操作系统在COMS的驱动下开始运行。不同外设可以绑定不同的中断向量号,对应不同的方法。 进程启动起来硬件就会推动操作系统执行下去,操作系统的执行是基于硬件中断的。进程运行期间,COMS一直在工作,检查当前进程的时间片是否到了。
核心转储:把错误的原因概括给我们
通过man 7 signal可以发现 Action 中有 core,指更严重的报错终止,终止的原因需要用户进一步排查

Term 指从键盘输入导致的终止
cpp
man 7 signal:
Core Default action is to terminate the process and dump core (see core(5)).
进程终止触发核心转储,生成进程 core.pid 文件,保存进程运行时的数据上下文。使用ulimit 查看系统基本配置项,core file size 默认被设置为0,不保存数据,但我们可以打开。

ulimit -a 查看所有, ulimit -c 改 core file size 。

这个设置只对当前终端会话有效。如果想永久生效,需要把这条命令加到用户启动时的配置文件中。为什么云服务系统默认关闭core dump呢,我们多执行几次除0错误,可以发现这个文件的大小是非常大的。如果我们不小心写出bug,且没有及时处理,磁盘会被写满,所以线上云服务器一般禁止core dump。

为什么还要有 core dump ,可以支撑我们的程序员执行后续的调试。使用gdb test_signal ,然后 (gdb) core-file core.18881 就可以看到错误的原因。
3.信号的保存
信号不被立即处理时,被纪录在PCB中
- 实际执行信号的处理动作称为信号递达(处理)(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
- 注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。忽略就是一种信号的处理,信号的递达。
信号的递达分为三种,信号的忽略,信号的默认,信号的自定义捕捉,信号的自定义我们前面已经讲过了。
**信号的默认:signal(2,SIG_DFL)**恢复默认的2号信号处理方式。我们还发现 sleep(10) 是可中断的系统调用,使用下面这种方式可以实现休息10秒。
cpp
void handler(int signo)
{
std::cout << "handler:" << signo << std::endl;
//exit(100);
}
int main()
{
cout << "getpid:" << getpid() << endl;
signal(2,handler);
sleep(10);//可中断的系统调用
//执行你注册的 handler。
//handler 返回后,sleep 被中断,不再继续睡眠,而是立即返回。
//返回值为剩余未睡的秒数(如果没有剩余,返回 0)。
int remain = 10;
while ((remain = sleep(remain)) > 0) {
// 如果被信号打断,继续睡剩余时间
}
signal(2,SIG_DFL);//恢复默认的2号信号处理方式
cout << "getpid:" << getpid() << endl;
cout << "getpid:" << getpid() << endl;
cout << "getpid:" << getpid() << endl;
while(true)
{
sleep(1);
}
return 0;
}

信号的忽略:signal(2,SIG_IGN) ,更改代码后结果如下,忽略代表已经对这个信号进行了处理。

SIG_DFL 和 SIG_IGN 是什么:

不是真的让你访问地址,而是可以通过强转为 int 去判断是默认还是忽略,而用户自定义的处理方法地址是很大的。
信号未决(Pending):
信号从产生到递达之间的状态,称为信号未决,就是指在进程信号位图中的时候,信号等待处理。
阻塞(Block):
信号是可以被阻塞的,就是信号可以暂时不进行递达,一直处于未决状态,直到解除阻塞。
阻塞的信号一定是未决的。未决的信号不一定是阻塞的。没有收到信号前可以设置对信号的处理,也可以设置阻塞和解除阻塞该信号。信号在内核中的表示示意图:

从上到下编号表示不同信号 ,block位图记录不同信号是否阻塞,pending位图记录是否收到 该信号(是否处于未决状态),handler是一个函数指针数组,指向不同信号的处理方式,有SIG_DFL(默认方式),SIG_IGN(忽略)还有自定义的处理方式。三张表要横着去看 ,这里block可以提前设置为1(比如3号信号,这里pending是0),表示收到该信号后阻塞,所以出现了上面的情况,2号3号信号都不会执行。当block为0,pending为1,该信号会在合适的时间被处理。
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
- **SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。**虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞 ,它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理? POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号 在递达之前产生多次只计一次 ,而实时信号 在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号
sigset_t是操作系统提供的一种类型,
从上图来看。每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t 称为信号集 ,这个类型可以表示每个信号的"有效"或"无效"状态,在阻塞信号集中"有效"和"无效"的含义是该信号是否被阻塞,而在未决信号集中"有效"和"无效"的含义是该信号是否处于未决状态。下一节将详细介绍信号集的各种操作。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask) ,这里的"屏蔽"应该理解为阻塞而不是忽略。
位图 sigset_t本质是位图结构:

信号集操作函数
cpp
#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 置1,表示该信号集的有效信号包括系统支持的所有信号。
- 注意,在使用 sigset_ t 类型的变量之前,一定要调用 sigemptyset 或 sigfillset 做初始化,使信号集处于确定的状态。初始化 sigset_t 变量之后就可以在调用 sigaddset 和 sigdelset 在该信号集中添加或删除某种有效信号
这四个函数都是成功返回0,出错返回- 1。sigismember 是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
sigprocmask
调用函数 sigprocmask 可以读取或更改 进程的信号屏蔽字(阻塞信号集)。
cpp
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
如果 oset 是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,o->old ,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
|-------------|--------------------------------------------------------|
| SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号,相当与mash=mask|set |
| SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set |
| SIG_SETMASK | 设置当前信号屏蔽字为set所有值,相当于mask=set |
cpp
void handler(int signo)
{
std::cout << "handler:" << signo << std::endl;
//exit(100);
}
int main()
{
std::cout << "getpid:" << getpid() << std::endl;
signal(2,handler);
sigset_t block,oblock;//属于用户空间局部变量,并没有给操作系统
sigemptyset(&block);
sigemptyset(&oblock);
sigaddset(&block,2);//在这里将2号信号添加到信号集里,还没有设置屏蔽
//老屏蔽字保存在oblock
sigprocmask(SIG_BLOCK,&block,&oblock);//系统调用,将用户层设置好的block表,设置进当前调用的进程的PCBblock表中,成功修改内核
while(1)
{
sleep(1);
}
return 0;
}

我们试试将所有信号都屏蔽:
cpp
for(int signo = 1;signo <= 31; signo++)
sigaddset(&block,signo);//所有信号都屏蔽
//老屏蔽字保存在oblock
sigprocmask(SIG_SETMASK,&block,&oblock);//直接替换
结果发现9号19号仍然无法被屏蔽。
sigpending

pending位图修改,已经学过一部分,信号的产生中都是在修改pending表。sigpending是用来检查pending表的,参数是输出型参数,读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
下面用刚学的几个函数做个实验,自定义2号信号,先屏蔽2号信号,打印pending表,然后发送2好信号,打印pending表,然后解除屏蔽:
cpp
void handler(int signo)
{
std::cout << "handler:" << signo << std::endl;
}
void PrintPending(const sigset_t &pending)
{
for(int signo = 31; signo >= 1; signo--)
{
if(sigismember(&pending,signo))//判断
{
cout<< "1";
}
else
{
cout << "0";
}
}
cout << endl;
}
int main()
{
signal(2,handler);
cout << "pid:" << getpid() <<endl;
//1.屏蔽2号信号q
sigset_t block,oblock;
sigemptyset(&block);//初始化
sigemptyset(&oblock);
sigaddset(&block,2);//添加
sigprocmask(SIG_BLOCK,&block,&oblock);//屏蔽
//2.不断读取pending表
sigset_t pending;
int cnt = 0;
while(true)
{
sigpending(&pending);
PrintPending(pending);
sleep(1);
cnt++;
if(cnt == 5)
{
std::cout << "解除对2号信号的o屏蔽,2号信号准备递达"<<std::endl;
sigprocmask(SIG_SETMASK,&oblock,nullptr);//使用之前的block表
}
}
return 0;
}
可以发现未决信号会一直保存直到不被屏蔽。

那处理未决信号时什么时候修改pending表呢,我们可以执行下面代码测试一下,可以发现在处理该信号之前pending表已经被修改,而不是中间和处理完。
cpp
void handler(int signo)
{
std::cout<<"############################"<<endl;
sigset_t pending;
sigpending(&pending);
PrintPending(pending);
cout<<"#############################"<<endl;
std::cout << "handler:" << signo << std::endl;
}
int main()
{
signal(2,handler);
while(1);
return 0;
}

4.信号的处理,递达
4.1 处理时间
信号在合适的时候被处理---什么时候?进程从内核态返回到用户态的时候,进行信号的检测和信号的处理,用户态是一种受控的状态,能访问的资源是有限的,内核态是一种操作系统的工作状态,能访问大部分系统资源 ,系统调用背后,就包含了身份的变化。
每个进程都有自己的进程地址空间,前0到3G是用户空间,3到4G是内核空间。用户空间是属于用户态的,进程可以直接访问,通过用户级页表映射,每个进程都有,且私有一份。内核空间属于操作系统,通过内核级页表映射。进程启动时要加载数据和代码,操作系统内核空间比进程更早被加载到地址空间。每个进程都有3GB的地址空间,内核级页表只需要共用一张就够了,大家对操作系统的代码共享,cpu进行进程调度时可以通过进程的地址空间直接找到操作系统。

CPU内部有寄存器CS来区分是内核还是用户,两个bit,1指内核,3指用户态。还有CR系类寄存器,CR2指向当前调用进程的用户级页表,这一套机制是操作系统帮我们维持的,操作系统才认识虚拟地址,CPU寄存器CR2中是物理地址,可以直接访问。CR1是保存访问内存时页表发生缺页中断的虚拟地址。
从用户态转换到内核态不仅需要从0到3GB跳到3GB到4GB,还需要CPU寄存器CS的改变。所以对于系统调用除了帮我们完成功能还完成了不同状态的跳转。
从内核态返回到用户态我们会进行信号的处理,说明我们之前已经进入内核态 。当进程调用系统调用时,会进入内核态,完成相应的工作后,我们会转到当前进程对应的信号(属于内核空间)中,来对信号进行处理,当发现 block 信号为0,pending 信号为1时,会调用处理方法。忽略直接pending 由1改为0,自定义的需要用户自定义捕捉:
我们先将pending调为 0 ,然后返回用户态身份调用sighandler,因为sighandler 是用户自定义的,可以在其中做了非法的操作,如果使用内核身份的话,就绕过了权限限制。然后我们通过特殊的 sigreturn 再次进入内核。信号捕捉中,一共会涉及4次状态切换,交点是信号检测。

访问操作系统只能由系统调用,它在内核中存在数组中,因为数组下标就那么多,所有只能用系统调用。我们写的代码一定会调用系统调用吗,当然不是,所以我们的代码不是一定要通过系统调用进入内核态,比如 while(1) 死循环时 ctrl+c 仍然能使进程停止。因为你的进程在执行中一定会由很多的进程间切换(时间片),注定了一定会从用户态(代码)返回到内核态(从cpu上剥离)。
4.2 信号的捕捉


c语言中允许结构体与函数名相同,结构体中我们只关心第1个和第3个,第一个就是handler处理方法,与signal 不同的是在处理期间可以自动屏蔽正在处理的信号,防止信号的嵌套,sa_mask选项来添加处理期间额外屏蔽的其他信号。
- sigaction 函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signo 是指定信号的编号。若 act 指针非空,则根据 act 修改该信号的处理动作。若 oact 指针非 空,则通过oact 传出该信号原来的处理动作。act 和 oact 指向 sigaction结构体:
- 将 sa_handler 赋值为常数 SIG_IGN 传给 sigaction 表示忽略信号,赋值为常数 SIG_DFL 表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为 void,可以带一个 int 参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被 main 函数调用,而是被系统所调用
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags字段包含一些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数,本章不详细解释这两个字段,有兴趣的同学可以在了解一下。
处理时自动暂时屏蔽正在处理的一些信号(pending为1,还没处理),sa_mask添加额外屏蔽的信号:
cpp
void Print(const sigset_t& pending)
{
for(int signo = 31; signo >= 1; signo--)
{
if(sigismember(&pending,signo))
cout << "1";
else
cout << "0";
}
cout << endl;
}
void handler(int signo)
{
cout << "get a sig:" << signo << endl;
while (1)
{
sigset_t pending;
sigpending(&pending);
Print(pending);
sleep(1);
}
}
int main()
{
cout << "pid:" << getpid() << endl;
struct sigaction act,oact;
act.sa_handler = handler;
sigaction(2,&act,&oact);//自定义2号信号
sigset_t block,oblock;
sigemptyset(&block);
sigemptyset(&oblock);
sigaddset(&block,3);//将3号信号加入信号集
sigprocmask(SIG_BLOCK,&block,&oblock);//将3号信号加入屏蔽
while(true) sleep(1);
return 0;
}

同时存在多个信号时,处理完一个信号操作系统会判断有没有其他信号,如果有不一定按信号顺序处理,因为有优先级,把所有信号全部处理后再返回用户态,
5.可重入函数
一个函数被 main 和 sighandler 两个执行流同时进入了,这种状态被成为该函数被重入了,重复进入。因重入可能出现问题,那么这个函数称为不可重入函数。没有好坏之分,只是描述函数的特点,可重入函数在能在多执行流下运行,不可重入函数不在多执行流函数下运行就行。

- main函数调用 insert 函数向一个链表 head 中插入节点 node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler 也调用 insert 函数向同一个链表 head中插入节点 node2,插入操作的两步都做完之后从 sighandler 返回内核态,再次回到用户态就从 main 函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
- 像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert 函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?因为代码使用同一份,数据使用了写时拷贝。
如的果一个函数符合以下条件之一则是不可重入:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
6.volatile
volatile (英语:可变的)告知编译器,该变量的值可能随时被程序控制流之外的未知因素改变,禁止编译器对其进行任何优化,每次访问都必须直接从内存地址中读取(或写入),保持内存的可见性。
gcc/g++ 默认没有优化 -O0,我们可以加 -O1 选项,提高优化级别
cpp
int flag = 0;
void handler(int signo)
{
std::cout << "signo:" << signo << std::endl;
flag = 1;
std::cout << "change flag to:" << flag << std::endl;
}
int main()
{
signal(2,handler);
while(!flag);
std::cout << "quit normal!" << std::endl;
return 0;
}
优化前

优化后:

可以发现优化后程序并不会停止,是因为执行 while 循环时,CPU要读取数据然后检测判断,优化前每次判断都重新读取一遍内存中的 flag。优化后汇编会变化,只会有第一次读入,以后都检测这个保存 flag 的寄存器,因为main函数中没有人修改和查看 flag,所有直接优化到了寄存器,不保存在内存。形成了一层内存屏障,CPU只访问寄存器不访问内存 。此时我们就可以通过volatile修饰,保证当前 flag 这个变量内存当中的可见性。所有使用flag的操作,都要从内存拿数据!
cpp
volatile int flag = 0;
7.SIGCHLD(选学了解)
进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程会收到,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用 wait 清理子进程即可。
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction 将 SIGCHLD 的处理动作置为 SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认对17号信号忽略和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。
第一种如果 handler 使用阻塞等待
cpp
void handler(int signo)
{
std::cout << "signo:" << signo << std::endl;
pid_t id = 0;
while((id = waitpid(-1,nullptr,0)))//阻塞等待,基于信号对子进程回收
{
if(id < 0) break;//没有子进程了,子进程全部退出了
cout << "回收进程id:" << id << endl;
}
}
int main()
{
signal(SIGCHLD,handler);
for (int i = 10; i < 10; i++)
{
pid_t id = fork();
if (id == 0)
{
cout << "child is runing" << endl;
sleep(5);
exit(10);
}
}
return 0;
}

当10个子进程都会退出的时候是没有问题,我们可以阻塞到他们全部退出,此时父进程是不工作的,但6个子进程退出,4个没退出waitpid永远不返回了,父进程不再进行,这里就会出现问题,所有使用WNOHANG 。这样就实现了基于信号使用 waitpid 非阻塞对子进程进行回收。
cpp
void handler(int signo)
{
std::cout << "signo:" << signo << std::endl;
pid_t id = 0;
while((id = waitpid(-1,nullptr,WNOHANG)))//阻塞等待,基于信号对子进程回收
{
if(id < 0) break;//没有子进程了,子进程全部退出了
cout << "回收进程id:" << id << endl;
}
}
如果我们不想自己释放子进程,Linux支持手动忽略SIGCHLD,所有的子进程都不再需要父进程进行等待了!!退出自动由系统回收,不会产生僵尸进程。当然如果需要获取子进程退出信息那必须手动等待。
cpp
signal(SIGCHLD, SIG_IGN);
另外fork() 会继承,exec() 不会继承自定义处理函数。

本篇结束!