Linux之进程信号
目录
一、信号快速认识
1.1、生活角度的信号
1.2、技术应⽤⻆度的信号
1.2.1、一个样例
1.2.2、一个系统函数
1.3、信号概念
1.3.1、查看信号
1.3.2、信号处理
二、产生信号
2.1、通过终端按键产⽣信号
2.1.1、基本操作
2.1.2、理解OS如何得知键盘有数据
2.1.3、初步理解信号起源
2.2、调⽤系统命令向进程发信号
2.3、使⽤函数产⽣信号
2.3.1、kill
2.3.2、raise
2.3.3、abort
2.4、由软件条件产⽣信号
2.4.1、基本alarm验证-体会IO效率问题
2.4.2、设置重复闹钟
2.4.3、如何理解软件条件
2.4.4、如何简单快速理解系统闹钟
2.5、硬件异常产⽣信号
2.5.1、模拟除0
2.5.2、模拟野指针
2.5.3、⼦进程退出core dump
2.5.4、Core Dump
三、保存信号
3.1、信号其他相关常⻅概念
3.2、在内核中的表⽰
3.3、sigset_t
3.4、信号集操作函数
3.4.1、sigprocmask
3.4.2、sigpending
四、捕捉信号
4.1、信号捕捉的流程
4.2、sigaction
4.3、操作系统是怎么运⾏的
4.3.1、硬件中断
4.3.2、时钟中断
4.3.3、死循环
4.3.4、软中断
4.3.5、缺⻚中断?内存碎⽚处理?除零野指针错误?
4.4、如何理解内核态和⽤户态
五、可重⼊函数
六、volatile
七、SIGCHLD信号(了解)
八、附录
8.1、⽤⼾态和内核态
8.2、⽤⼾态与内核态的切换
一、信号快速认识
1.1、生活角度的信号
- 你在⽹上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”。
- 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。 那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的⾏为 并不是⼀定要⽴即执⾏,可以理解成“在合适的时候去取”。
- 在收到通知,再到你拿到快递期间,是有⼀个时间窗⼝的,在这段时间,你并没有拿到快递,但是你知道有⼀个快递已经来了。本质上是你“记住了有⼀个快递要去取”。
- 当你时间合适,顺利拿到快递之后,就要开始处理快递了。⽽处理快递⼀般⽅式有三种:1.执⾏默 认动作(幸福的打开快递,使⽤商品)2.执⾏⾃定义动作(快递是零⻝,你要送给你你的⼥朋友) 3.忽略快递(快递拿上来之后,扔掉床头,继续开⼀把游戏)。
- 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话。
基本结论:
- 识别信号是内置的,进程识别信号,是内核程序员写的内置特性。
- 信号产⽣之后,你知道怎么处理吗?知道。如果信号没有产⽣,你知道怎么处理信号吗? 知道。所以,信号的处理⽅法,在信号产⽣之前,已经准备好了。
- 处理信号,⽴即处理吗?我可能正在做优先级更⾼的事情,不会⽴即处理。什么时候?合适的时候。
- 怎么进⾏信号处理啊?a.默认 b.忽略 c.⾃定义,后续都叫做信号捕捉。
1.2、技术应⽤⻆度的信号
1.2.1、一个样例
代码:
#include
#include
int main()
{
while(true)
{
std::cout<<"hello,world"<
效果:
解释:
- 用户输⼊命令,在Shell下启动⼀个前台进程。
- 用户按下 Ctrl+C ,这个键盘输⼊产⽣⼀个硬件中断,被OS获取,解释成信号,发送给⽬标前台进程。
- 前台进程因为收到信号,进⽽引起进程退出。
1.2.2、一个系统函数
NAME
signal - ANSI C signal handling
SYNOPSIS
#include
typedef void (*sighandler_t) (int);
sighandler_t signal(int signum, sighandler_t handler);
参数说明:
signum:信号编号[后⾯解释,只需要知道是数字即可]
handler:函数指针,表⽰更改信号的处理动作,当收到对应的信号,就回调执⾏handler⽅法
⽽其实, Ctrl+C 的本质是向前台进程发送 SIGINT 即 2 号信号,我们证明⼀下,这⾥需要引⼊⼀ 个系统调⽤函数。
测试:
#include
#include
#include
void Handler(int signalnum)
{
std::cout<<"Get a signal,num: "<
效果:
注意:
- 要注意的是,signal函数仅仅是设置了特定信号的捕捉⾏为处理⽅式,并不是直接调⽤处理动作。如果后续特定信号没有产⽣,设置的捕捉函数永远也不会被调⽤!!
- Ctrl-C 产⽣的信号只能发给前台进程。⼀个命令后⾯加个 & 可以放到后台运⾏,这样 Shell不必等待进程结束就可以接受新的命令,启动新的进程。
- Shell可以同时运⾏⼀个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产⽣的信号。
- 后台进程可以通过在命令行输入kill -9 进程pid 的方式来终止指定进程,也可以通过fg 作业号,将后台进程变为前台进程,然后Ctrl-C终止该进程。使用 jobs 命令查看当前终端会话中的后台作业及其作业号
- 前台进程在运⾏过程中⽤⼾随时可能按下 Ctrl-C ⽽产⽣⼀个信号,也就是说该进程的⽤⼾空间代码执⾏到任何地⽅都有可能收到 SIGINT 信号⽽终⽌,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。
- nohup + 进程 + &:将该进程在后端启动,并且将该进程的输出结果向名为 nohup.out 的文件写入,不要向前台写入。
- 9号信号无法通过 signal 函数修改处理信号的动作。
1.3、信号概念
信号是进程之间事件异步通知的⼀种⽅式,属于软中断。
1.3.1、查看信号
每个信号都有⼀个编号和⼀个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定义
#define SIGINT 2
编号34以上的是实时信号,本文只讨论编号34以下的信号,不讨论实时信号。这些信号各⾃在什么条件下产⽣,默认的处理动作是什么,在signal(7)中都有详细说明:man 7 signal
1.3.2、信号处理
可选的处理动作有以下三种:
忽略此信号:
int main()
{
signal(2, SIG_IGN);
//...其他操作
return 0;
}
执⾏该信号的默认处理动作:(不做任何处理时就是调用默认处理动作)
int main()
{
signal(2, SIG_DFL);
//...其他操作
return 0;
}
提供⼀个信号处理函数,要求内核在处理该信号时切换到⽤户态执⾏这个处理函数,这种⽅式称为⾃ 定义捕捉(Catch)⼀个信号。
void handler(int signum)
{
std::cout << "get a signal:" << signum <
源码:
#define SIG_DFL ((__sighandler_t) 0) /* Default action. */
#define SIG_IGN ((__sighandler_t) 1) /* Ignore signal. */
/* Type of a signal handler. */
typedef void (*__sighandler_t) (int);
// 其实SIG_DFL和SIG_IGN就是把0,1强转为函数指针类型
二、产生信号
当前阶段:
2.1、通过终端按键产⽣信号
2.1.1、基本操作
- Ctrl+C (SIGINT) 已经验证过,这⾥不再重复
- Ctrl+(SIGQUIT)可以发送终⽌信号并⽣成core dump⽂件,⽤于事后调试
- Ctrl+Z(SIGTSTP)可以发送停⽌信号,将当前前台进程挂起到后台等。
2.1.2、理解OS如何得知键盘有数据
2.1.3、初步理解信号起源
- 信号其实是从纯软件⻆度,模拟硬件中断的⾏为
- 只不过硬件中断是发给CPU,⽽信号是发给进程
- 两者有相似性,但是层级不同。
- 发送信号的本质就是写入信号,OS修改目标进程的PCB中的信号位图,0->1。无论以什么方式发送信号,最终,都是转换到OS,让OS写入信号的,因为task_struct的唯一管理者是 OS 。
2.2、调⽤系统命令向进程发信号
示例代码:
#include
#include
int main()
{
while(true)
{
std::cout<<"hello,world"<
⾸先在后台执⾏死循环程序,然后⽤kill命令给它发SIGSEGV信号。
- 564773是 test 进程的pid。之所以要再次回⻋才显⽰ Segmentation fault ,是因为在564773进程终⽌掉之前已经回到了Shell提⽰符等待⽤⼾输⼊下⼀条命令, Shell 不希望 Segmentation fault 信息和⽤⼾的输⼊交错在⼀起,所以等⽤⼾输⼊命令之后才显⽰。
- 指定发送某种信号的 kill 命令可以有多种写法,上⾯的命令还可以写成 kill -11 564773,11 是信号 SIGSEGV 的编号。以往遇到的段错误都是由⾮法内存访问产⽣的,⽽这个程序本⾝没错,给它发SIGSEGV也能产⽣段错误。
2.3、使⽤函数产⽣信号
2.3.1、kill
kill 命令是调⽤ kill 函数实现的。 kill 函数可以给⼀个指定的进程发送指定的信号。
NAME
kill - send signal to a process
SYNOPSIS
#include
#include
int kill(pid_t pid, int sig);
RETURN VALUE
On success (at least one signal was sent), zero is returned. On error, -1 is returned, and errno is set appropriately.
实现⾃⼰的 kill 命令:
#include
#include
#include
void Usage(std::string proc)
{
std::cout<<"Usage: "<< proc << "signumber processid" << std::endl;
}
int main(int argc, char *argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
int signumber = std::stoi(argv[1]);
pid_t id = std::stoi(argv[2]);
int n = ::kill(id, signumber);
if(n < 0)
{
perror("kill");
exit(2);
}
return 0;
}
2.3.2、raise
raise 函数可以给当前进程发送指定的信号(⾃⼰给⾃⼰发信号)。
NAME
raise - send a signal to the caller
SYNOPSIS
#include
int raise(int sig);
RETURN VALUE
raise() returns 0 on success, and nonzero for failure.
示例:
#include
#include
#include
int main()
{
int cnt = 3;
while(true)
{
std::cout << "hahaha alive" << std::endl;
cnt--;
if(cnt <= 0)
raise(9);
sleep(1);
}
return 0;
}
效果:
2.3.3、abort
abort 函数使当前进程接收到信号⽽异常终⽌(自己给自己发6号信号)。
NAME
abort - cause abnormal process termination
SYNOPSIS
#include
void abort(void);
RETURN VALUE
The abort() function never returns.
// 就像exit函数⼀样,abort函数总是会成功的,所以没有返回值。
示例:
#include
#include
#include
int main()
{
int cnt = 3;
while(true)
{
std::cout << "hahaha alive" << std::endl;
cnt--;
if(cnt <= 0)
abort();
sleep(1);
}
return 0;
}
效果:
2.4、由软件条件产⽣信号
SIGPIPE 是⼀种由软件条件产⽣的信号,在“管道”中已经介绍过了,读端关闭,写端开启会因为软件条件不具备(即没有读端)而发送SIGPIPE 信号来终止写端进程。本文主要介绍 alarm 函数和 SIGALRM 信号。
NAME
alarm - set an alarm clock for delivery of a signal
SYNOPSIS
#include
unsigned int alarm(unsigned int seconds);
RETURN VALUE
alarm() returns the number of seconds remaining until any previously scheduled alarm was due to be delivered, or zero if there was no previ‐ ously scheduled alarm.
- 调⽤ alarm函数可以设定⼀个闹钟,也就是告诉内核在 seconds 秒之后给当前进程发 SIGALRM 信号,该信号的默认处理动作是终⽌当前进程。alarm 闹钟只会响一次。
- 这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个⽐⽅,某⼈要⼩睡⼀觉,设定闹钟为30分钟之后响,20分钟后被⼈吵醒了,还想多睡⼀会⼉,于是重新设定闹钟为15分钟之后响,“以 前设定的闹钟时间还余下的时间”就是10分钟。如果seconds值为0,表⽰取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。
验证代码:
#include
#include
#include
int main()
{
//设置一个十秒的闹钟
alarm(10);
//睡眠四秒
sleep(4);
//将闹钟设置为0秒,即取消闹钟
//接收返回值,即闹钟剩余秒数
int num = alarm(0);
std::cout << num << std::endl;
return 0;
}
效果:
2.4.1、基本alarm验证-体会IO效率问题
程序的作⽤是1秒钟之内不停地数数,1秒钟到了就被SIGALRM信号终⽌。必要的时候,对SIGALRM信号进⾏捕捉。
示例代码一:
#include
#include
#include
int main()
{
//设置一个一秒的闹钟,一秒后发送SIGALRM信号
alarm(1);
int number = 0;
while(true)
{
printf("number: %d
", number++);
}
return 0;
}
效果:(几次运行后基本稳定在这个数值附近)
示例代码二:
#include
#include
#include
long number = 0;
void die(int signalnum)
{
printf("Get a signal: %d,number: %ld
", signalnum, number);
exit(0);
}
int main()
{
//设置一个一秒的闹钟,一秒后发送SIGALRM信号
alarm(1);
signal(SIGALRM, die);
while(true)
{
number++;
}
return 0;
}
效果:
结论:闹钟一秒时会响⼀次,默认终⽌进程;两段代码对比可以看出,有IO效率低。
2.4.2、设置重复闹钟
NAME
pause - wait for signal -> 阻塞式等待信号
SYNOPSIS
#include
int pause(void);
DESCRIPTION
pause() causes the calling process (or thread) to sleep until a signal is delivered that either terminates the process or causes the invoca‐ tion of a signal-catching function. RETURN VALUE
pause() returns only when a signal was caught and the signal-catching function returned. In this case, pause() returns -1, and errno is set to EINTR.
示例代码:
#include
#include
#include
#include
#include
using func_t = std::function;
int gcount = 0;
std::vector gfuncs;
void hanlder(int signum)
{
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,hanlder);
while(true)
{
pause();
std::cout << "我醒来了..." << std::endl;
gcount++;
}
return 0;
}
效果:
结论:闹钟设置⼀次,起效⼀次。
2.4.3、如何理解软件条件
在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产⽣机制。这些条件包括但不限于定时器超时(如alarm函数设定的时间到达)、软件异常(如向已关闭的管道写数据产⽣的SIGPIPE信号)等。当这些软件条件满⾜时,操作系统会向相关进程发送相应的信号,以通知进程进⾏相应的处理。简⽽⾔之,软件条件是因操作系统内部或外部软件操作⽽触发的信号产⽣。
2.4.4、如何简单快速理解系统闹钟
系统闹钟,其实本质是OS必须⾃⾝具有定时功能,并能让⽤户设置这种定时功能,才可能实现闹钟这样的技术。现代Linux是提供了定时功能的,定时器也要被管理:先描述,在组织。内核中的定时器数据结构是:
struct timer_list {
struct list_head entry;
unsigned long expires;
void (*function)(unsigned long);
unsigned long data;
struct tvec_t_base_s *base;
};
我们不在这部分进⾏深究,为了理解它,我们可以看到:定时器超时时间expires和处理⽅法 function。操作系统管理定时器,采⽤的是时间轮的做法,但是我们为了简单理解,可以把它在组织成为"堆结构"。
2.5、硬件异常产⽣信号
硬件异常被硬件以某种⽅式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执⾏了除以0的指令,CPU的运算单元会产⽣异常,内核将这个异常解释为SIGFPE信号发送给进程。再⽐如当前进程访问了⾮法内存地址,MMU会产⽣异常,内核将这个异常解释为SIGSEGV信号发送给进程。
2.5.1、模拟除0
void handler(int signum)
{
std::cout << "get a signal:" << signum <
效果:(死循环打印)
结论:除0是通过发送8号信号终止进程的。
2.5.2、模拟野指针
#include
#include
#include
#include
#include
void handler(int signum)
{
std::cout << "get a signal:" << signum <
效果:(死循环打印)
结论:野指针是通过发送11号信号终止进程的。
由此可以确认,我们在C/C++当中除零,内存越界等异常,在系统层⾯上,是被当成信号处理的。
注意:
通过上⾯的实验,我们可以发现:
模拟除0实验中,发现⼀直有8号信号产⽣被我们捕获,这是为什么呢?上⾯我们只提到CPU运算异常后,如何处理后续的流程,实际上OS会检查应⽤程序的异常情况,其实在CPU中有⼀些控制和状态寄存器,主要⽤于控制处理器的操作,通常由操作系统代码使⽤。状态寄存器可以简单理解为⼀个位图,对应着⼀些状态标记位、溢出标记位。OS会检测是否存在异常状态,有异常存在就会调⽤对应的异常处理⽅法。
除零异常后,我们并没有清理内存,关闭进程打开的⽂件,切换进程等操作,所以CPU中还保留上下⽂数据以及寄存器内容,除零异常会⼀直存在,就有了我们看到的⼀直发出异常信号的现象。访问⾮法内存其实也是如此。
2.5.3、⼦进程退出core dump
进程退出时,保存进程退出信息的位图中有一位用来表示是否产生了core文件。如下图:
2.5.4、Core Dump
- ⾸先解释什么是Core Dump。当⼀个进程要异常终⽌时,可以选择把进程的⽤户空间内存数据全部保存到磁盘上,⽂件名通常是core,这叫做Core Dump。
- 云服务器一般会将 core 文件禁掉,我们可以通过 ulimit -a 查看 core 文件相关信息。如图:
- 在开发调试阶段可以⽤ ulimit 命令改变这个限制,允许产⽣ core ⽂件。⾸先⽤ ulimit 命令改变 Shell 进程的 Resource Limit ,如允许 core ⽂件最⼤为 1024K: $ ulimit -c 1024。如图:
- 进程异常终⽌通常是因为有Bug,⽐如⾮法内存访问导致段错误,事后可以⽤调试器检查core⽂件以查清错误原因,这叫做 Post-mortem Debug (事后调试)。例如,进入gdb调试一个代码后,使用 core-file core 命令,引入 core 文件,就可以看到 core 文件中记录的报错信息。或者使用命令 gdb <可执行程序>
。 - ⼀个进程允许产⽣多⼤的 core ⽂件取决于进程的 Resource Limit (这个信息保存在PCB 中)。默认是不允许产⽣ core ⽂件的,因为 core ⽂件中可能包含⽤户密码等敏感信息,不安全。
三、保存信号
当前阶段:
3.1、信号其他相关常⻅概念
- 实际执⾏信号的处理动作称为信号递达(Delivery)。
- 信号从产⽣到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞(Block)某个信号。
- 被阻塞的信号产⽣时将保持在未决状态,直到进程解除对此信号的阻塞,才执⾏递达的动作。
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,⽽忽略是在递达之后可选的⼀种处理动作。
3.2、在内核中的表⽰
信号在内核中的表⽰意图:
- pending表(位图):每个比特位的位置代表信号编号,每个比特位的内容->1表示收到对应的信号,0表示没有收到对应的信号。
- block表(位图):每个比特位的位置代表信号编号,每个比特位的内容->1阻塞 / 屏蔽 对应的信号,0表示没有阻塞 / 屏蔽 对应的信号。
- handle表:函数指针数组,存储每个信号的处理函数的指针,当我们自定义捕捉动作时,就是修改该进程的handle表。将对应下标的内容修改为我们指定的函数的指针。
- 每个信号都有两个标志位分别表⽰阻塞(block)和未决(pending),还有⼀个函数指针表⽰处理动 作。信号产⽣时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例⼦中,SIGHUP信号未阻塞也未产⽣过,当它递达时执⾏默认处理动作。
- SIGINT信号产⽣过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT信号未产⽣过,⼀旦产⽣SIGQUIT信号将被阻塞,它的处理动作是⽤户⾃定义函数 sighandler。
如果在进程解除对某信号的阻塞之前这种信号产⽣过多次,将如何处理?POSIX.1允许系统递送该信 号⼀次或多次。Linux是这样实现的:常规信号在递达之前产⽣多次只计⼀次,⽽实时信号在递达之 前产⽣多次可以依次放在⼀个队列⾥。本文不讨论实时信号。
3.3、sigset_t
从上图(3.2中的图)来看,每个信号只有⼀个bit的未决标志,⾮0即1,不记录该信号产⽣了多少次,阻塞标志也是这样表⽰的。因此,未决和阻塞标志可以⽤相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表⽰每个信号的“有效”或“⽆效”状态,在阻塞信号集中“有效”和“⽆效”的含义是该信号是否被阻塞,⽽在未决信号集中“有效”和“⽆效”的含义是该信号是否处于未决状态。下⼀节将详细介绍信号集的各种操作。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask)这⾥的“屏蔽” 应该理解为阻塞⽽不是忽略。
3.4、信号集操作函数
sigset_t类型对于每种信号⽤⼀个bit表⽰“有效”或“⽆效”状态,⾄于这个类型内部如何存储这些 bit则依赖于系统实现,从使⽤者的⻆度是不必关⼼的,使⽤者只能调⽤以下函数来操作sigset_t变量,⽽不应该对它的内部数据做任何解释,⽐如⽤printf直接打印sigset_t变量是没有意义的。
#include
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。
3.4.1、sigprocmask
调⽤函数 sigprocmask 可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include
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返回前,⾄少将其中⼀ 个信号递达。
3.4.2、sigpending
#include
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。
调⽤成功则返回0,出错则返回-1
示例代码:
#include
#include
#include
#include
#include
#include
#include
#include
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;
}
}
}
void no_handler(int signo)
{
std::cout << "处理" << std::endl;
}
int main()
{
//忽略2号信号
::signal(2, SIG_IGN);
// ::signal(2, no_handler);
//对2号信号进行屏蔽
sigset_t block, oblock;
//先将位图每一位都设置为0
sigemptyset(&block);
sigemptyset(&oblock);
//在我们创建的block表中添加2号信号
sigaddset(&block, 2); //此时还没有设置进入内核
//设置进入内核
sigprocmask(SIG_SETMASK, &block, &oblock);
int cnt = 0;
while(true)
{
//获取pending表
sigset_t pending;
sigpending(&pending);
//打印
PrintPending(pending);
sleep(1);
cnt++;
if(cnt == 10)
{
std::cout << "解除对2号信号的屏蔽" << std::endl;
sigprocmask(SIG_SETMASK, &oblock, nullptr);
}
}
return 0;
}
程序运⾏时,每秒钟把各信号的未决状态打印⼀遍,由于我们阻塞了SIGINT信号,按Ctrl-C将会使SIGINT 信号处于未决状态,按Ctrl-仍然可以终⽌程序,因为SIGQUIT信号没有阻塞。
四、捕捉信号
当前阶段;
4.1、信号捕捉的流程
如果信号的处理动作是⽤⼾⾃定义函数,在信号递达时就调⽤这个函数,这称为捕捉信号。
由于信号处理函数的代码是在⽤⼾空间的,处理过程⽐较复杂,举例如下:
- ⽤⼾程序注册了 SIGQUIT 信号的处理函数 sighandler 。
- 当前正在执⾏ main 函数,这时发⽣中断或异常切换到内核态。
- 在中断处理完毕后要返回⽤⼾态的 main 函数之前检查到有信号 SIGQUIT 递达。
- 内核决定返回⽤⼾态后不是恢复 main 函数的上下⽂继续执⾏,⽽是执⾏ sighandler 函数,sighandler 和 main 函数使⽤不同的堆栈空间,它们之间不存在调⽤和被调⽤的关系,是两个独⽴的控制流程。
- sighandler 函数返回后⾃动执⾏特殊的系统调⽤ sigreturn 再次进⼊内核态。
- 如果没有新的信号要递达,这次再返回⽤⼾态就是恢复 main 函数的上下⽂继续执⾏了。
4.2、sigaction
#include
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字段说明这些需要额外屏蔽的信号,当信号处理函数返回时⾃动恢复原来的信号屏蔽字。sa_flags字段包含⼀些选项,本文的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数,本文不详细解释这两个字段。pending表中的某个信号被处理时,该位图上对应的 1 是在调用处理函数前就被置回 0 的。
示例代码:
#include
#include
void handler(int signo)
{
std::cout << "get a sig: " << signo << std::endl;
exit(1);
}
int main()
{
struct sigaction act, oact;
act.sa_handler = handler;
//执行处理函数时,除了将该处理函数对应信号屏蔽外
//还将sa_mask位图中设置的bit位都屏蔽
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
sigaddset(&act.sa_mask, 5);
sigaddset(&act.sa_mask, 6);
sigaddset(&act.sa_mask, 7);
::sigaction(2, &act, &oact);
while(true)
{
pause();
}
return 0;
}
4.3、操作系统是怎么运⾏的
4.3.1、硬件中断
硬件中断流程如下图:(外部设备就绪后发送中断,CPU接收到中断后获取到中断号,通过中断号得知是哪个硬件发送的中断,然后根据中断号去查中断向量表,找到对应需要执行的方法,让OS去执行该方法)。
- 中断向量表就是操作系统的⼀部分,启动就加载到内存中了。
- 通过外部硬件中断,操作系统就不需要对外设进⾏任何周期性的检测或者轮询。
- 由外部设备触发的,中断系统运⾏流程,叫做硬件中断。
4.3.2、时钟中断
问题:
- 进程可以在操作系统的指挥下,被调度,被执⾏,那么操作系统⾃⼰被谁指挥,被谁推动执⾏呢?
- 外部设备可以触发硬件中断,但是这个是需要⽤户或者设备⾃⼰触发,有没有⾃⼰可以定期触发的设备?
硬件中有一个叫做时钟源的,它会以固定的频率持续向CPU发送中断,这样CPU就会不断查中断向量表,执行该中断对应的方法,而时钟源引发的中断对应的方法就是进程调度,这样OS就在中断的调控下运行起来了。
注意:
- 进程调度不等于切换正在运行的进程。
- 当代计算机已经将时钟源集成到CPU内部了。
4.3.3、死循环
如果是这样,操作系统不就可以躺平了吗?对,操作系统⾃⼰不做任何事情,需要什么功能,就向中断向量表⾥⾯添加⽅法即可。操作系统的本质:就是⼀个死循环!
void main(void) /* 这⾥确实是void,并没错。 */
{ /* 在startup 程序(head.s)中就是这样假设的。 */
...
/*
* 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到⼀个信号才会返
* 回就绪运⾏态,但任务0(task0)是唯⼀的意外情况(参⻅'schedule()'),因为任
* 务0 在任何空闲时间⾥都会被激活(当没有其它任务在运⾏时),
* 因此对于任务0'pause()'仅意味着我们返回来查看是否有其它任务可以运⾏,如果没
* 有的话我们就回到这⾥,⼀直循环执⾏'pause()'。
*/
for (;;)
pause();
} // end main
这样,操作系统,就可以在硬件时钟的推动下,⾃动调度了。
注意:时间片的本质就是一个计数器,进程运行时 计数器 --,当计数器减到0,时间片就用完了,就会切换进程。
4.3.4、软中断
- 上述外部硬件中断,需要硬件设备触发。
- 有没有可能,因为软件原因,也触发上⾯的逻辑?有!
- 为了让操作系统⽀持进⾏系统调⽤,CPU也设计了对应的汇编指令(int(32位)或者syscall(64位) ),可以让CPU内部触发中断逻辑。
所以:
问题:
- ⽤户层怎么把系统调⽤号给操作系统? - 寄存器(⽐如EAX)
- 操作系统怎么把返回值给⽤⼾?- 寄存器或者⽤户传⼊的缓冲区地址
- 系统调⽤的过程,其实就是先int 0x80、syscall陷⼊内核,本质就是触发软中断,CPU就会⾃动执⾏系统调⽤的处理⽅法,⽽这个⽅法会根据系统调⽤号,⾃动查表,执⾏对应的⽅法
- 系统调⽤号的本质:数组下标!
可是为什么我们⽤的系统调⽤,从来没有⻅过什么 int 0x80 或者 syscall 呢?都是直接调⽤上层的函数的啊?那是因为Linux的GNU glibc C标准库,给我们把⼏乎所有的系统调⽤全部封装了。例如下图:
4.3.5、缺⻚中断?内存碎⽚处理?除零野指针错误?
缺⻚中断?内存碎⽚处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断, 然后⾛中断处理例程,完成所有处理。有的是进⾏申请内存,填充⻚表,进⾏映射的。有的是⽤来 处理内存碎⽚的,有的是⽤来给⽬标进⾏发送信号,杀掉进程等等。
所以:
- 操作系统就是躺在中断处理例程上的代码块!
- CPU内部的软中断,⽐如int 0x80或者syscall,我们叫做陷阱。
- CPU内部的软中断,⽐如除零/野指针等,我们叫做异常。
4.4、如何理解内核态和⽤户态
结论:
- 操作系统⽆论怎么切换进程,都能找到同⼀个操作系统!换句话说操作系统系统调⽤⽅法的执⾏, 是在进程的地址空间中执⾏的!
- 关于特权级别,涉及到段,段描述符,段选择⼦,DPL,CPL,RPL等概念,⽽现在芯⽚为了保证兼容性,已经⾮常复杂了,进⽽导致OS也必须得照顾它的复杂性,这块我们不做深究了。
- ⽤户态就是执⾏⽤户[0,3]GB时所处的状态。
- 内核态就是执⾏内核[3,4]GB时所处的状态。
- 区分是内核态还是用户态就是按照CPU内的CPL决定,CPL的全称是Current Privilege Level,即当前特权级别。
- ⼀般执⾏ int 0x80 或者 syscall 软中断,CPL会在校验之后⾃动变更。
- 用户级页表每个进程一份,内核级页表一共只有一份。
五、可重⼊函数
- main函数调⽤insert函数向⼀个链表head中插⼊节点node1,插⼊操作分为两步,刚做完第⼀步的 时候,因为硬件中断使进程切换到内核,再次回⽤⼾态之前检查到有信号待处理,于是切换到 sighandler函数,sighandler也调⽤insert函数向同⼀个链表head中插⼊节点node2,插⼊操作的两步都做完之后从sighandler返回内核态,再次回到⽤⼾态就从main函数调⽤的insert函数中继续往下执⾏,先前做第⼀步之后被打断,现在继续做完第⼆步。结果是,main函数和sighandler先后向链表中插⼊两个节点,⽽最后只有⼀个节点真正插⼊链表中了。
- 像上例这样,insert函数被不同的控制流程调⽤,有可能在第⼀次调⽤还没返回时就再次进⼊该函 数,这称为重⼊,insert函数访问⼀个全局链表,有可能因为重⼊⽽造成错乱,像这样的函数称为不可重⼊函数,反之,如果⼀个函数只访问⾃⼰的局部变量或参数,则称为可重⼊(Reentrant)函数。想⼀ 下,为什么两个不同的控制流程调⽤同⼀个函数,访问它的同⼀个局部变量或参数就不会造成错乱?
如果⼀个函数符合以下条件之⼀则是不可重⼊的:
- 调⽤了malloc或free,因为malloc也是⽤全局链表来管理堆的。
- 调⽤了标准I/O库函数。标准I/O库的很多实现都以不可重⼊的⽅式使⽤全局数据结构。
六、volatile
示例代码:
#include
#include
#include
int flag = 0;
void change(int signo)
{
//防止因为没有使用参数报警告,强转一下
(void)signo;
flag = 1;
printf("change flag 0->1
");
}
int main()
{
signal(2, change);
while(!flag); //空语句
printf("我是正常退出的!
");
return 0;
}
效果:
标准情况下,键⼊CTRL-C ,2号信号被捕捉,执⾏⾃定义动作,修改 flag=1 , while 条件不满⾜,退出循环,进程退出。
还是使用上面代码,但是这次编译时我们通过选项进行优化,默认是O0的优化,我们优化到O1。
效果:
优化情况下,键⼊CTRL-C ,2号信号被捕捉,执⾏⾃定义动作,修改flag=1 ,但是while条件依旧满⾜,进程继续运⾏!但是很明显flag肯定已经被修改了,但是为何循环依旧执⾏?很明显,while 循环检查的flag,并不是内存中最新的flag,这就存在了数据⼆异性的问题。while 检测的flag其实已经因为优化,被放在了CPU寄存器当中。如何解决呢?很明显需要volatile。
示例代码三:
//易变关键字-volatile
volatile int flag = 0;
void change(int signo)
{
//防止因为没有使用参数报警告,强转一下
(void)signo;
flag = 1;
printf("change flag 0->1
");
}
int main()
{
signal(2, change);
while(!flag); //空语句
printf("我是正常退出的!
");
return 0;
}
volatile作⽤:保持内存的可⻅性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进⾏操作。
七、SIGCHLD信号(了解)
进程⼀章讲过⽤wait和waitpid函数清理僵⼫进程,⽗进程可以阻塞等待⼦进程结束,也可以⾮阻塞地查询是否有⼦进程结束等待清理(也就是轮询的⽅式)。采⽤第⼀种⽅式,⽗进程阻塞了就不能处理⾃⼰的⼯作了;采⽤第⼆种⽅式,⽗进程在处理⾃⼰的⼯作的同时还要记得时不时地轮询⼀下,程序实现复杂。
其实,⼦进程在终⽌时会给⽗进程发SIGCHLD信号,该信号的默认处理动作是忽略,⽗进程可以⾃定义 SIGCHLD信号的处理函数,这样⽗进程只需专⼼处理⾃⼰的⼯作,不必关⼼⼦进程了,⼦进程终⽌时会通知⽗进程,⽗进程在信号处理函数中调⽤wait清理⼦进程即可。
下面编写⼀个程序完成以下功能:⽗进程fork出⼦进程,⼦进程调⽤exit(0)终⽌,⽗进程⾃定义SIGCHLD信号的处理函数,在其中调⽤waitpid成功回收子进程。示例代码:
#include
#include
#include
#include
#include
#include
void handler(int signo)
{
std::cout << "get a sig: " << signo << " I am : " << getpid() << std::endl;
while (true)
{
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;
}
}
}
// 验证子进程退出,给父进程发送SIGCHLD
int main()
{
signal(SIGCHLD, handler);
// 问题1: 1个子进程,10个呢?
// 问题2: 10个子进程,6个退出了!
for (int i = 0; i < 10; i++)
{
if (fork() == 0)
{
sleep(5);
std::cout << "子进程退出" << std::endl;
// 子进程
exit(0);
}
}
while (true)
{
sleep(1);
}
return 0;
}
事实上,由于UNIX的历史原因,要想不产⽣僵⼫进程还有另外⼀种办法:⽗进程调⽤sigaction将 SIGCHLD的处理动作置为SIG_IGN,这样fork出来的⼦进程在终⽌时会⾃动清理掉,不会产⽣僵⼫进程,也不会通知⽗进程。系统默认的忽略动作和⽤⼾⽤sigaction函数⾃定义的忽略通常是没有区别的,但这 是⼀个特例。此⽅法对于Linux可⽤,但不保证在其它UNIX系统上都可⽤。下面编写程序验证这样做不会产⽣僵⼫进程。
示例代码:
#include
#include
#include
#include
#include
#include
int main()
{
// Linux下,将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉
::signal(SIGCHLD, SIG_IGN);
for (int i = 0; i < 10; i++)
{
if (fork() == 0)
{
sleep(5);
std::cout << "子进程退出" << std::endl;
// 子进程
exit(0);
}
}
while (true)
{
sleep(1);
}
return 0;
}
八、附录
8.1、⽤⼾态和内核态
- CPU 指令集 :是CPU实现软件指挥硬件执⾏的媒介,具体来说每⼀条汇编语句都对应了⼀条CPU 指令 ,⽽⾮常⾮常多的CPU 指令在⼀起,可以组成⼀个、甚⾄多个集合,指令的集合叫CPU 指令集 。
- CPU 指令集 有权限分级,⼤家试想, CPU 指令集 可以直接操作硬件的,要是因为指令操作的不规范,造成的错误会影响整个计算机系统的。好⽐你写程序,因为对硬件操作不熟悉,导致操作系统内核、及其他所有正在运⾏的程序,都可能会因为操作失误⽽受到不可挽回的错误,最后只能重启计算机才⾏。
- 对开发⼈员来说是个艰巨的任务,还会增加负担,同时开发⼈员在这⽅⾯也不被信任,所以 操作系统内核直接屏蔽开发⼈员对硬件操作的可能,都不让你碰到这些CPU 指令集 。
针对上⾯的需求,硬件设备商直接提供硬件级别的⽀持,做法就是对CPU 指令集 设置了权限,不同 级别权限能使⽤的CPU 指令集 是有限的,以Inter CPU为例,Inter把CPU 指令集 操作的权限由 ⾼到低划为4级:
- ring 0:权限最⾼,可以使⽤所有CPU 指令集
- ring 1
- ring 2
- ring 3:权限最低,仅能使⽤常规CPU 指令集 ,不能使⽤操作硬件资源的CPU 指令集 ,⽐如IO 读写、⽹卡访问、申请内存都不⾏
要知道的是,Linux系统仅采⽤ring 0 和 ring 3 这2个权限。CPU中有⼀个标志字段,标志着线程的运⾏状态,⽤⼾态为3,内核态为0。
- ring 0被叫做内核态,完全在操作系统内核中运⾏。
- 执⾏内核空间的代码,具有ring 0保护级别,有对硬件的所有操作权限,可以执⾏所有 CPU指令集 ,访问任意地址的内存,在内核模式下的任何异常都是灾难性的,将会导致整台机器停机。
- ring 3被叫做⽤⼾态,在应⽤程序中运⾏
- 在⽤⼾模式下,具有ring 3保护级别,代码没有对硬件的直接控制权限,也不能直接访问地址 的内存,程序是通过调⽤系统接⼝(System Call APIs)来达到访问硬件和内存,在这种保护模 式下,即时程序发⽣崩溃也是可以恢复的,在电脑上⼤部分程序都是在,⽤⼾模式下运⾏的。
低权限的资源范围较⼩,⾼权限的资源范围更⼤,所以⽤⼾态与内核态的概念就是CPU指令集权限的区别。
我们通过指令集权限区分⽤⼾态和内核态,还限制了内存资源的使⽤,操作系统为⽤⼾态与内核态划 分了两块内存空间,给它们对应的指令集使⽤。
在内存资源上的使⽤,操作系统对⽤⼾态与内核态也做了限制,每个进程创建都会分配虚拟空间地 址,以Linux32位操作系统为例,它的寻址空间范围是4G (2的32次⽅),⽽操作系统会把虚拟控制 地址划分为两部分,⼀部分为内核空间,另⼀部分为⽤⼾空间,⾼位的1G (从虚拟地址 0xC0000000到0xFFFFFFFF)由内核使⽤,⽽低位的3G (从虚拟地址0x00000000到0xBFFFFFFF)由各个进程使⽤。
- ⽤⼾态:只能操作0-3G 范围的低位虚拟空间地址
- 内核态: 0-4G 范围的虚拟空间地址都可以操作,尤其是对3-4G 范围的⾼位虚拟空间地址必须由内核态去操作。
- 3G-4G 部分⼤家是共享的(指所有进程的内核态逻辑地址是共享同⼀块内存地址),是内核态的地址空间,这⾥存放在整个内核的代码和所有的内核模块,以及内核所维护的数据。
- 在内核运⾏的过程中,会涉及内核栈的分配,内核的进程管理的代码会将内核栈创建在内核空间中,当然相应的⻚表也会被创建。
8.2、⽤⼾态与内核态的切换
什么情况会导致⽤⼾态到内核态切换??
- 系统调⽤ :⽤⼾态进程主动切换到内核态的⽅式,⽤⼾态进程通过系统调⽤向操作系统申请资 源完成⼯作,例如:fork()就是⼀个创建新进程的系统调⽤。
- 操作系统提供了中断指令int 0x80来主动进⼊内核,这是⽤⼾程序发起的调⽤访问内核代码的唯⼀⽅式。调⽤系统函数时会通过内联汇编代码插⼊int 0x80的中断指令,内核接收到int 0x80中断后,查询中断处理函数地址,随后进⼊系统调⽤。
- 异常 :当CPU在执⾏⽤⼾态的进程时,发⽣了⼀些没有预知的异常,这时当前运⾏进程会切换到处理此异常的内核相关进程中,也就是切换到了内核态,如缺⻚异常。
- 中断 :当CPU在执⾏⽤⼾态的进程时,外围设备完成⽤⼾请求的操作后,会向CPU发出相应 的中断信号,这时CPU会暂停执⾏下⼀条即将要执⾏的指令,转到与中断信号对应的处理程序去执⾏,也就是切换到了内核态。如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执⾏后边的操作等。
切换时 CPU 需要做什么??
- 当某个进程中要读写IO ,必然会⽤到ring 0级别的CPU指令集 。⽽此时CPU的指令集操作权限只有ring 3,为了可以操作ring 0级别的CPU指令集 ,CPU切换指令集操作权限级别为ring 0(可称之为提权),CPU再执⾏相应的ring 0级别的CPU指令集(内核代码)。
- 代码发⽣提权时,CPU是需要切换栈的!!前⾯我们提到过,内核有⾃⼰的内核栈。CPU切换栈是需要栈段描述符(ss寄存器)和栈顶指针(esp寄存器),这两个值从哪⾥来?
- CPU通过⼀个段寄存器(tr)确定 TSS(任务状态段,struct TSS)的位置。在TSS结构中存在这么⼀个SS0和ESP0。提权的时候,CPU就从这个TSS⾥把SS0和ESP0取出来,放到ss和esp寄存器中。
切换流程
- 从⽤⼾态切换到内核态时,⾸先⽤⼾态可以直接读写寄存器,⽤⼾态操作CPU,将寄存器的状态 保存到对应的内存中,然后调⽤对应的系统函数,传⼊对应的⽤⼾栈地址和寄存器信息,⽅便后续内核⽅法调⽤完毕后,恢复⽤⼾⽅法执⾏的现场。
- 从⽤⼾态切换到内核态需要提权,CPU切换指令集操作权限级别为ring 0。
- 提权后,切换内核栈。然后开始执⾏内核⽅法,相应的⽅法栈帧时保存在内核栈中。
- 当内核⽅法执⾏完毕后,CPU切换指令集操作权限级别为ring 3,然后利⽤之前写⼊的信息来恢复⽤⼾栈的执⾏。
从上述流程可以看出⽤⼾态切换到内核态的时候,会牵扯到⽤⼾态现场信息的保存以及恢复,还要进⾏⼀系列的安全检查,还是⽐较耗费资源的。