一、信号的保存
1、信号其他相关常见概念
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
- 注意: 阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
2、信号在内核中的表示
信号在内核中的表示示意图
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。
从位图中我们理解pending位图和信号的关系, 操作系统发信号,本质也就是将信号写入到pending位图中。pending位图中的比特位的内容也就表征了是否收到该信号
对于block位图:比特位的位置也是代表,信号的编号,但是比特位的内容表示是否阻塞对应的信号。如果阻塞了就不在执行该信号,除非解除阻塞。
if((1<<(signo -1)) & pcb->block) { //signo信号被阻塞,不递达 } else { if((1<<(signo -1)) & pcb->pending) { //递达该信号 } }
上面我们写了一份伪代码,来理解内核是如何大致处理信号未决到递达的过程和信号阻塞。
其中在内核中还有一个handler的数组用来存放信号,其中数组的下标表示信号的编号,数组
下标对应的内容表示信号的内容。
所以:当一个信号没有产生这并不妨碍他可以先被阻塞。
3、sigset_t
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。
因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号 的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有 效”和“无效”的含义是该信号是否处于未决状态。
阻塞信号集也叫做当 前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略
4. 信号集操作函数
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所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有 效信号。
- 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示 该信号集的有效信号包括系 统支持的所有信号。
注意:在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的 状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号
这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
二、 模仿实现内核对信号的保存
为了更好的理解,内核如何进行对信号的保存的,下面我们自己写一份代码,去验证信号在内核中的保存。
1、信号函数
sigprocmask函数
功能:可以读取或更改进程的信号屏蔽字(阻塞信号集)。
原型:int sigprocmask(int how, const sigset_t *set, sigset_t *oset)
返回值:若成功则为0,若出错则为-1
- 如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。
- 如果set是非空指针,则 更改进程的信号屏蔽字,
- 参数how指示如何更改。
- 果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后 根据set和how参数更改信号屏蔽字。
上面一直说信号屏蔽字,那这到底有上面用?
信号屏蔽字是一个位掩码,用于指定哪些信号被阻塞(屏蔽)而不会被递送给进程。当某个信号被屏蔽时,它将被搁置,直到信号解除屏蔽为止。这允许进程在关键部分屏蔽某些信号,以确保在执行临界区代码时不会被中断。
对于sigprocmask函数,当假设当前屏蔽字为mask 下表说明了how参数的可选值.
SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask|set |
SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set |
SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值,相当于mask=se |
sigpending 函数
功能:读取当前进程的未决信号集,通过set参数传出
原型:int sigpending(sigset_t *set)
返回值:调用成功则返回0,出错则返回-1。
2、实验代码
这里我们验证2号信号为例(ctrl+c)
#include<iostream> #include<vector> #include<signal.h> #include<unistd.h> #include<stdio.h> using namespace std; #define MAX_SIGNUM 31 static vector<int> sigarr = {2}; static void show_pending(const sigset_t &pending) { for(int signo = MAX_SIGNUM; signo >= 1;signo--) { if(sigismember(&pending,signo)) { cout<< "1"; } else { cout << "0"; } } cout << endl; } static void myhandler(int signo) { cout << signo << " 号信号已经被递达!!" << endl; } int main() { for(const auto &sig : sigarr) signal(sig, myhandler); sigset_t block, oblock, pending; //初始化 sigemptyset(&block); sigemptyset(&oblock); sigemptyset(&pending); //添加要屏蔽的信号 for(const auto& sig : sigarr) sigaddset(&block,sig); //开始屏蔽,设置内核 sigprocmask(SIG_SETMASK,&block,&oblock); //遍历打印penging信号集 int cnt = 10; while(true) { //初始化 sigemptyset(&pending); //获取 sigpending(&pending); //打印 show_pending(pending); sleep(1); printf("%d\n",cnt); if(cnt-- == 0) { sigprocmask(SIG_SETMASK,&oblock,&block); cout << "恢复对信号的屏蔽,不屏蔽任何信号"<<endl; } } return 0; }
三、信号的的捕捉
1、内核态和用户态
在理解信号捕捉流程前,我们要明白什么是内核态和用户态:
- 内核态(Kernel Mode):
- 权限高: 内核态拥有系统的最高权限,可以执行特权指令和访问系统的所有资源。
- 操作系统内核运行: 在内核态下,操作系统的内核代码运行,可以执行对硬件的直接访问和控制。
- 敏感指令: 内核态可以执行一些敏感指令,如修改全局页表、禁止中断等。
- 特权级别: 通常,内核态运行在较高的特权级别(Ring 0或Supervisor Mode),这是计算机体系结构(如x86)中的一个常见术语。
- 用户态(User Mode):
- 权限低: 用户态拥有较低的权限,受到更多的限制,无法直接访问底层硬件资源。
- 用户应用程序运行: 在用户态下,用户应用程序运行,其执行受到操作系统的控制和限制。
- 受限指令: 用户态下的程序不能直接执行一些特权指令,如修改页表、禁止中断等。
- 特权级别: 用户态通常运行在较低的特权级别(Ring 3或User Mode)。
在正常的程序执行中,处理器在用户态和内核态之间进行切换。当应用程序需要执行需要更高权限的操作时(如访问硬件、执行特权指令),会触发一个从用户态到内核态的切换。这通常通过系统调用(system call)来实现,应用程序请求操作系统执行某些特权操作,操作系统会在内核态执行相应的服务例程。
在进行切换身份进行系统调用的时候,调用的人是进程,但是身份是内核。系统调用是比较占用时间的,所以我们应该尽量频繁的使用系统调用。
理解什么是内核态和用户态
那一个进程,是怎么跑到OS中去执行方法的呢?
这是因为每个进程都有自己的地址空间(用户独占的空间)内核空间(被映射到了每个进程的3~4G),
这时进程要访问OS的接口,只要在自己的地址空间上跳转就好了
注意:每个进程都会3~4GB地址空间,都会共享一个内核级页表,无论进程如何切换,都吧会更该这个页表
2、信号的捕捉流程
其实上面的进程捕捉流程,我们可以用倒写的8字来记忆
注意:
- 默认情况下:我们所以的信号是不被阻塞的
- 默认情况下:如果一个信号被屏蔽了,该信号就不会被递达
四、信号的补充知识
1、sigaction函数
功能:读取和修改与指定信号相关联的处理动作
原型: int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact)
参数:
- signum 是指定信号的编号
- 若act指针非空,则根据act修改该信号的处理动作
- 若oact指针非空,则通过oact传 出该信号原来的处理动作
返回:调用成功则返回0,出错则返回- 1
act和oact指向sigaction结构体 :
将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回 值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信 号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来 的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 如果 在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需 要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
下面我们通过代码来理解一下sigaction函数
#include<iostream> #include<cstdio> #include<signal.h> #include<unistd.h> using namespace std; void Count(int cnt) { while(cnt) { printf("cnt: %2d\r", cnt); fflush(stdout); cnt--; sleep(1); } printf("\n"); } void handler(int signo) { cout << "get a signo: " << signo << "正在处理中..." << endl; Count(20); } int main() { struct sigaction act, oact; act.sa_handler = handler; act.sa_flags = 0; sigemptyset(&act.sa_mask); // 当我们正在处理某一种信号的时候,我们也想顺便屏蔽其他信号,就可以添加到这个sa_mask中 sigaddset(&act.sa_mask, 3); sigaction(SIGINT, &act, &oact); while(true) sleep(1); return 0; }
这里我们观察到,我们一直给进程发2号信号,发现他不马上执行所育的2号信号的,而是一个一个的执行。
这里我们就知道了,正在递达某个信号期间,同类信号无法被递达,系统会自动将当前信号加入到进程的信号屏蔽字block,当信号完成捕捉动作,系统又会自动解除对该信号的屏蔽(一般一个信号被解除屏蔽的时候,会自动递达当前屏蔽信号,如果该信号已经被pending的话,就不做任何处理)。
2、可重入函数
为了理解可重写入函数,这里我们有一个链表,我们做如下操作:
这里我们发现node2丢失了, 我们代码也没有写错,而仅仅是在一个mian函数中执行了二个执行流。
这里在mian函数handler中该函数重复进入 ,出了问题,我们就称函数insert为不可以重入函数。
这里在mian函数handler中该函数重复进入 ,没有出问题,我们就称函数insert为可以重入函数。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准 比特科技 I/O库的很多实现都以不可重入的方式使用全局数据结构
3、 volatile关键字
下面我们看一个现象:
int quit = 0; void handler(int signo) { printf("pid: %d, %d 号信号,正在被捕捉!\n", getpid(), signo); printf("quit: %d", quit); quit = 1; printf("-> %d\n", quit); } int main() { signal(2,handler); while(!quit); printf("我是正常退出\n"); }
这个结果是显而易见。
当我们调整 gcc的优化程度,可用man gcc手册查看
//调整优化gcc g++ -o $@ $^ -O3 #-std=c++11
在次运行代码
发现代码进入了死循环,这是为什么呢?
这是因为main函数中有二个流,编译器认为在main执行流中quit没有改,所以编译器只将物理内存中的值由0变1,但是Cpu中寄存器中0没有变。(编译器的优化)
为了解决编译器的优化这就要用到volatile 关键字
volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
volatile int quit = 0;
在次运行代码:问题就解决了