Linux操作系统学习之---进程信号的捕捉(version1)
一.信号捕捉的过程 :
1.用户态和内核态:
信号递达之后有三种处理方式:
- 默认行为 : 通常是终止当前进程
- 忽略 : 字面意思 , 收到信号但是不做处理.
- 自定义捕捉 : 执行
信号处理函数表
里对应的函数.
将产生的信号保存起来并最后抵达的工作是由操作系统来完成的 , 可是自定义捕捉的函数却是用户自己写的 . 这就引出了一个事实 : 自定义捕捉的过程涉及
用户态
和内核态
的交互
[!粗略理解用户态和内核态的概念]
- 用户态就是用来执行我们main函数里的代码 , 如while循环和自定义的函数 , 但是权限比较低 .
- 内核态就是执行操作系统的代码 , 如读取键盘输入和持久化数据到磁盘 , 权限至高无上 .
可以这样理解:
用户态就是美国公民大街上 , 大家可以在兜里塞把枪(系统调用) . 但是真的想用枪还是得到射击场去玩(内核态)
2.用户态和内核态的交互:
- 当程序运行出现异常 , 就会暂时中断程序 , 陷入内核态.
- 操作系统处理完异常后 , 不会继续运行程序 , 而是看看当前进程是否存在可递送的信号.
- 这个过程涉及对进程Block表以及Pending表的查看 .
- 如果需要递送信号 , 就会返回用户态 执行信号对应的Handler表里的函数.
- 执行完毕后回到内核态 , 向操作系统报备 .
- 确认无误后返回用户态 , 继续执行上次中断之前的代码
3.简明的交互图:∞
4.交代细节:
[! 1.为什么在执行信号对应函数的时候非得返回用户态?]
防止用户层面的恶意程序在内核态胡作非为!!!
[! 2.在用户态执行完函数后如何回到内核态?]
- 类似于函数栈帧的开辟和销毁 , 在用户态开辟函数栈帧前也是会保留返回内核的地址.
- 实际上是存在一个叫做sigreturn的函数 , 用于执行返回内核态的操作.
5.小结 :
操作系统不会将收到的信号立马递达给进程 , 而是会在合适的时刻递达!!!
通过上面的认知 , 我们可以知道 : 这个合适的时刻指的是进程进入内核态后 , 准备返回用户态的前一刻 . 信号捕捉可以这样理解 : 最初只是进程闯入了内核态 , 但操作系统留了个心眼检查了进程待处理的信号 , 接着再查询Block
表和Pending
表 , 然后回到用户态执行Handle
, 再然后通过sighreturn
返回内核 , 最后返回用户态.
二.操作系统运作本质 : 本质
[!操作系统怎么知道有信号来了?]
- 最直接的想法是操作系统一直苦哈哈的轮询检查所有软件程序和外部硬件 , 但不现实.
- 而事实上 , 并非操作系统主动知道 , 而是在刺激源的提醒下知道的 .
- 这个刺激源就叫作中断
1. 外界督促: 硬件中断
- 中断控制器 : 存在于一切外部硬件 , 和CPU触点相连 , 用于传输简单的信号.
- 当用户在键盘上输入内容并按下回车 , 此时的操作系统正在解决各种各样系统调度问题 , 毫不知情.
- 键盘通过中断控制器向CPU发送中断信号.
- CPU收到中断信号 , 暂停手中的活计 , 转而从中断器处获取中断号 .
- CPU拿着中断号 , 到中断向量表里调用对应的函数 , 并执行这个中断程序
- 完事后继续回去执行刚才的任务.
[!补充说明]
- 中断向量表可以理解为一个*函数指针数组 *.
- 中断号就是这个数组的下标.
2. 自驱力 : 时钟中断
[!question]
其实操作系统是自律的孩子 , 它在诞生之时 , 就内置(集成在电路板上)了自己的"生物钟"—可编程中断计时器 , 简称为时钟 . 这个时钟会以固定的频率来发送中断信号 , 让操作系统动起来.
试想 : 当程序里执行while死循环 , 操作系统岂不是,忙死了???
- 尽管上层程序是死循环 , 但还有定时发送信号的内部时钟.
- 当时钟信号到来 , 操作系统立马停止执行while死循环 , 执行进程调度的任务 , 深入到内核检查进程PCB
- 先将进程的时间片– , 如果时间片未耗尽 , 则继续返回用户态执行while死循环; 如果时间片耗尽 , 将此进程从运行队列上剥离 , 放到过期队列 , 接着执行下一个进程.
就这样 , 操作系统在内部时钟的驱动下 , 定期执行各种预先设定的任务.
3.逆子捣乱 : 软件中断
异常:
用户态的软件程序最终都是以指令的形式让CPU来执行 , 但是有些属于非法操作的指令 , 底层相关硬件无法执行 , 就会向操作系统发送中断信号.
- 如果访问非法地址 , 负责查询页表来进行虚拟到物理地址转换的硬件MMU会产生段错误异常.
- 如果产生除零操作, CPU里的寄存器EFLAGS就会将错误标志记录下来 , 会触发浮点数异常.
陷阱(系统调用的实现原理):
想要和硬件交互 , 就必须使用系统调用函数 , 但它毕竟还是在用户态 , 如何让不信任用户的操作系统放心的按我们的指示来呢? 靠的就是操作系统开的后门—汇编指令
0x80
或syscall
内核里会维护一张系统调用表 , 也就是一个指针数组 , 记录了所有系统调用函数的地址 .
- 我们使用的c语言系统调用函数里 , 本身也是被glibc封装的 , 内部其实并没有我们以为的完整代码逻辑 .
- 操作系统会向用户态提供一种发送中断信号的指令 , 在x84下叫做
0X80
, x86_64下叫做syscall
. - 而系统调用函数本身的主逻辑 , 是用上述的指令来给操作系统发送对应的
系统调用号
- 最后操作系统拿着系统调用号 , 到系统调用表里去找 , 这本质就是通过下标访问函数指针数组
[!安全性的保障]
可以发现调用系统调用本质上只是发送了一个下标以及参数 . 因此 , 恶意程序自然就会被拦截 , 因为只要下标在系统调用表里不存在 , 就无法调用 , 也就无法通过篡改硬件数据来破坏系统.
三.再谈用户态与内核态:
上面谈到的都是进程在用户态和内核态之间的行为本身 . 但疑点在于 :
- 操作系统怎么辨别进程当前处在那个状态?
- 所谓陷入内核态以及返回用户态具体是怎么做到的?
1.操作系统的小本本—CPL
操作系统管理软硬件 , 实际的执行者是CPU . 而有一个集成在CPU上的寄存器叫做 CS(code segment)
, 它用于记录当前正在调度的进程究竟是属于用户态还是内核态.
而这个状态的值 , 在操作系统里以CPL(current privilege level)
的形式存在.
[!Linux下的CPL]
- Linux下的CPL可以理解为一个整形的两个比特位.
- 00表示内核态 , 即CPL=0
- 1 1 表示用户态 , 即CPL=3
2.用户内核态切换的本质:
- 前面我们知道了一个涉及内核的函数调用会在会在用户-内核态间来回跳跃
- 也知道了来回跳跃的方式—发送软中断或硬件终端信号
- 接下来就可以补全这块拼图—操作系统如何知道进程跳转到了哪里.
[!完整逻辑链]
- 进程在用户态产生硬件终端/软中断/时钟中断 ,** CPL的值修改为0 , 陷入内核**.
- 进程返回用户态前 , 操作系统进行待处理信号检查,揪出潜在可执行的信号函数.
- 如果有函数可执行 , CPL的值修改为3,去往用户态 , 开辟函数栈帧完成执行.
- 执行完成 , 执行sigreturn , CPL的值修改为0,重返内核态 ,报告操作系统.
- 检查无误 , CPL的值修改为3 , 回到用户态 , 执行中断产生之前的代码.
因此用户态到内核态的切换本质是寄存器值的改变
四. sigaction : 更强大的信号捕捉
五.细节补充 :
1. 可重入函数:
2.c关键字:volatile:
基本概念:
volatile
单词的英文含义是 :易变的/易失的
.
而会被可重入函数影响到的变量 , 就是易变/易失
的变量 , 定义时加上volatile就可以避免这种情况.
volatile关键字的原理:
- 首先要知道 , CPU进行算术运算时 , 要现将内存里的数据拷贝到寄存器里 , 才能完成.
- 而这个拷贝的过程需要耗费一定时间 , 因此在编译器优化时有可能将运行是不会被改变的变量作特殊标记—让CPU第一次将这个变量拷贝到寄存器后 就不在去内存重复的拷贝 , 而是直接使用寄存器的值.
volatile关键字的作用就是禁止这样的优化!!!
gcc下优化的开关:
选项 | 优化等级 | 说明 |
---|---|---|
-O0 | 无优化 | 默认级别,编译速度最快,生成的代码未优化(适合调试) |
-O1 | 基础优化 | 在保证编译速度的同时进行基础优化(如删除未使用的代码) |
-O2 | 中等优化 | 启用更多优化(如指令重排、内联函数),会牺牲部分编译速度 |
-O3 | 激进优化 | 最高级别的优化(包括循环展开、向量化等),可能增加代码体积 |
-Os | 优化代码大小 | 在 -O2 基础上,优先减少生成代码的大小 |
-Ofast | 超激进优化 | 在 -O3 基础上,放宽标准合规性(可能影响浮点精度) |
优化的结果…:
int local_val = 1;
void handler(int signal)
{local_val = 0; //接受信号后将值设为0,期望是主函数里的while循环能结束.cout << "local_val = 0" << endl;
}
int main()
{signal(SIGINT,handler);while(local_val){}return 0;
}
下面是
同一个程序
,无优化
/一级优化
/二级优化
下的执行效果
an@mycloud:.$ g++ -o code mykill.cpp #无优化
an@mycloud:.$ ./code
^Clocal_val = 0 #通过信号递达的动作修改全局变量 , 程序结束an@mycloud:.$ g++ -o code mykill.cpp -O1 #一级优化
an@mycloud:.$ ./code
^Clocal_val = 0
^Clocal_val = 0
^Clocal_val = 0
^Clocal_val = 0
^Clocal_val = 0
^Clocal_val = 0
^Clocal_val = 0
^Clocal_val = 0 #信号可以抵达,内存里的变量也已修改,但此时CPU只认寄存器,不认内存!!!an@mycloud:.$ g++ -o code mykill.cpp -O2 #二级优化
an@mycloud:.$ ./code
an@mycloud:.$ #空的while循环直接被当作垃圾代码裁掉了....
3.信号SIGCHLD:控制进程退出:
- 子进程在退出时需要由父进程调用wait函数来释放资源 , 否则成为僵尸进程.
- 这样来讲 , 岂不是父进程总得苦哈哈的等着子进程退出 , 自己都抽不开身??
- 其实不然
情况一:不正常回收多个子进程
下面的程序中 , 父进程一直在做自己的事 , 而子进程不用父进程操心 , 自动退出 .
但问题在于 , 先于父进程退出的子进程是孤儿进程 , 由1号进程init接管并释放 .
最大的坏处在于父进程拿不到子进程的退出信息.
int main()
{for (size_t i = 0; i < 10; i++){int pid = fork();if (pid == 0){sleep(3);exit(1);}}while (true){cout << "父进程退出,,,," << endl;sleep(1);}return 0;
}
情况二:借助信号提示父进程回收的子进程:
![引入一个新的信号SIGCHLD]
- 当子进程退出 , 会由操作系统向父进程发送一个SIGCHLD信号 .
- 但是默认情况下SIGCHLD信号的默认递达动作是忽略.
- 于是我们可以通过自定义捕捉来做一些功夫 , 让父进程能相应子进程的退出,做回收资源的工作.
- 下面的代码中首先让父进程创建多个子进程后执行自己的逻辑 .
- 子进程自己结束后像发进程发送SIGCHLD信号 .
- 父进程收到信号执行自定义捕捉函数handler_child回收子进程资源
- 这样就实现了父进程在和子进程并行运作的同时依然能够接受进程退出信息!!!
void handler_child(int signal)
{while(true){int ret = waitpid(-1,nullptr,0);if(ret > 0){cout << "wait a child";break;}else if (ret == 0){break;}else{cout << "wait error!!!" << endl;break;}}
}int main()
{signal(SIGCHLD,handler_child);int count = 1;for (size_t i = 0; i < 10; i++){int pid = fork();if (pid == 0){while(true){sleep(3);exit(1);}}}while (true){cout << "父进程运行中,,," << endl;sleep(1);}return 0;
}
情况三: 非阻塞等待的妙用:
- 上面的情况是子进程一次性全部退出 , 因此父进程执行一次自定义捕捉函数就可以完成所有回收工作
- 但是 , 当一部分子进程退出 , 另一部分继续运行 , waitpid函数默认会阻塞在哪里等待子进程退出 , 就会浪费父进程资源
- 所以把
waitpid
函数的第三个参数改为WNOHANG
, 当第一批子进程回收完成 ,waitpid函数
返回0 , 退出捕捉函数.- 此后 , 父进程回收第一批子进程完成后 , 会结束捕捉函数的运行 , 下一次子进程退出产生信号时再重新调用捕捉函数完成回收任务.
void handler_child(int signal)
{while (true){int ret = waitpid(-1, nullptr, WNOHANG); //非阻塞等待if (ret > 0){cout << "wait a child";}else if (ret == 0){break;}else{cout << "wait error!!!" << endl;break;}}
}int main()
{signal(SIGCHLD, handler_child); for (size_t i = 0; i < 10; i++)//创建10个子进程{int pid = fork();if (pid == 0){if (i < 6) //让留个进程先退出{sleep(2);exit(0);}else //剩下四个进程过几秒再退出{sleep(6);exit(0);}}}while (true){cout << "父进程退出,,,," << endl;sleep(1);}return 0;
}
特殊情况 : SIGCHLD的小猫腻
命令行执行 man 7 signal
,找到信号SIGCHLD的词条后, 会发现SIGNAL的默认处理动作是忽略.
#.........
SIGCHLD P1990 Ign Child stopped or terminated #Ign(ignore)
#.......
但是 , 如果显式的在代码里吧SIGNAL设置为忽略 , 就会实现一个效果 :
子进程退出后不通知父进程 ,而是自己退出和销毁资源.
int main()
{sigignore(SIGCHLD); //忽略SIGCHLD信号for (size_t i = 0; i < 10; i++)//创建10个子进程{int pid = fork();if (pid == 0){if (i < 6) //让留个进程先退出{sleep(2);exit(0);}else //剩下四个进程过几秒再退出{sleep(6);exit(0);}}}while (true){cout << "父进程退出,,,," << endl;sleep(1);}return 0;
}
图中可以看到 :
- 程序刚刚运行时新建了很多子进程.
- 有几个子进程提前退出 , 资源得到了释放.
- 过了几秒剩下的子进程也退出 , 资源同样释放 .
- 这个过程中父进程全然不知 , 一直在执行自己的逻辑.
[!为啥会这样???]
- unix系统早期 , SIGCHLD的默认行为的确是忽略且会自动清理资源 .
- 但是 , 后来大部分信号的的忽略动作都是不做处理 .
- 为了保持一致 , 就让SIGCHLD的忽略动作也是啥也不做了.
因此,我们显式的将SIGCHLD忽略 , 本质上是在唤醒SIGCHLD那死去的灵魂 , 让他在一次执行自己曾经习惯的子进程资源释放的任务. #Linux/进程信号/SIGCHLD的ignore