目录
[2.4.2、除零、野指针 和缺页中断](#2.4.2、除零、野指针 和缺页中断)
[2.5、理解用户态 和内核态](#2.5、理解用户态 和内核态)
一、捕捉信号
1.1、信号捕捉流程
1.1.1、流程图:

正常我们的程序在运行时,会不断在用户和内核之间来回的切换:
1️⃣ 当进程收到一个信号,发送方就会就会修改pending(置位为1)。
2️⃣我们的程序正常在用户态运行,当遇到中断,异常或者系统调用时就会进入内核。
3️⃣内核处理完中断,异常或系统调用后,就会返回用户态,但在此之前会执行do_signal,检查block和pending。
- 当某个信号未被阻塞且对应pending位为1,先将pending清零,然后继续执行handler表中的处理方法:
SIG_DFL(默认):
|----------|--------------------------------|
| 五类默认行为 ||
| Term | 终止进程 |
| Core | 终止进程 + core dump(核心转储) |
| Ign | 忽略信号,直接返回用户态继续执行后续代码(主控制流) |
| Stop | 停止,挂起该进程 |
| Cont | 继续执行已停止的进程 |
***SIG_IGN(忽略):***忽略信号,直接返回用户态继续执行后续代码(主控制流)。
自定义: 切换到用户身份,跳转到 sighandler方法执行,并不返回主控制流。
4️⃣ 在跳转到sighandler之前,先在内核栈保存目标进程的上下文数据,然后将上下文数据拷贝到用户态栈,并且在用户态栈为sighandler创建栈帧,方便返回用户态执行sighandler。
5️⃣ 执行sighandler处理方法
注意: 在return时并不直接返回主控制流,而是执行一个特殊的系统调用
sigreturn()(这通常由 C 库自动处理,对程序员透明)。内核会在用户栈上构建一个特殊的帧(Frame),使得信号处理函数能够正常执行并在返回时调用sigreturn。
6️⃣ 恢复上下文数据
- 再次进入内核,执行sigreturn,内核从之前保存的上下文信息中恢复原来的用户态栈帧和寄存器状态
- 内核清除信号处理相关的临时数据结构。
7️⃣返回正常执行
- 如果没有新的信号要递达,内核这次真正地恢复 main 函数的上下文。
- 进程继续从原来被信号中断的地方执行,就像什么都没有发生过一样。

1.1.2、信号未阻塞时四次身份切换:
第一次:用户 -> 内核,中断,异常或系统调用。
第二次:内核 -> 用户,执行sighandler方法。
第三次:用户 -> 内核,执行sigreturn。
第四次:内核 -> 用户,返回主控制流。
++💥问题:++ 为什么处理信号要这么麻烦,四次身份切换?
① pending/block 在内核 PCB中,只能内核态检查;
② 信号处理方法是用户代码,如果内核直接去执行(权限放大),可能存在数据安全问题,所以必须有用户自己执行;
③ sighandler 方法执行完后,需要恢复现场,必须再次进入内核,恢复主控制流的上下文数据。
1.1.3、细节问题
++💥问题:++ ***可不可能不会进入内核,不对信号进行处理 ?***用户代码while(true) { } 会不会进入内核?
当遇到中断,异常或者系统调用,就会进入内核,但while只是个死循环,但你忘了,进程调度 ,时间片耗尽进程切换 ,缺页中断等, 这些都会进入内核**。**
++💥补充:++CPU根据代码段寄存器CS的低两位CPL(Current Privilege Level,当前特权级)来判断是用户还是内核。
CPL = 0,内核(权限高)
CPL = 3,用户(低权限)
1.2、sigaction接口
sigaction接口也是用来捕捉信号的,相比与signal有什么过人之处呢?
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

cpp
void handler(int signum)
{
std::cout << "收到一个信号signum: " << signum << std::endl;
exit(1);
}
int main()
{
struct sigaction act, oldact; // 结构体对象
act.sa_handler = handler; // 对handler方法初始化
sigaction(SIGINT, &act, &oldact); // 捕获2号信号
while (true)
{
sleep(1);
std::cout << "pid: " << getpid() << std::endl;
}
}


1~31号信号是非实时信号,处理 signum信号时,会将该信号屏蔽(阻塞),再收到该信号,直接丢弃,但是如果同时收到其他信号,未被屏蔽,依然会处理。
***实验:***通过对sa_mask就可以同时屏蔽多个信号,再当收到这些信号就会先在pending表中记录下来:
cpp
void handler(int signum)
{
std::cout << "来了一个信号signum: " << signum << std::endl;
while (true)
{
sleep(1);
sigset_t pending;
sigpending(&pending);
for (int i = 31; i >= 1; i--)
{
// 遍历找出未决信号
if (sigismember(&pending, i))
std::cout << 1;
else
std::cout << 0;
}
std::cout << std::endl;
}
}
int main()
{
struct sigaction act, oldact;
act.sa_handler = handler;
// 需要清空sa_mask(信号集)和sa_flags
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
// 当sa_mask = 0时,如果来其他信号仍可以在OS处理2号信号时被递达
// 就可以设置sa_mask来屏蔽其他信号,如3 4 5号信号
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
sigaddset(&act.sa_mask, 5);
// 捕获2号信号
sigaction(SIGINT, &act, &oldact);
while (true)
{
sleep(1);
std::cout << "pid: " << getpid() << std::endl;
}
return 0;
}

二、补充:操作系统是怎么运行的?
上一篇文章中其实对这个问题有所涉及,但也只是演示了其简单运行模式。即设置一个闹钟催促操作系统去完成一些任务。
2.1、硬件中断
❓️CPU是怎么知道键盘上有数据了?
就是因为当我们按下键盘时,中断控制器收到键盘中断,生成中断号并通知了CPU:"键盘来数据了",CPU就会从中断控制器获取中断号,然后CPU拿着中断号在中断向量表查找,找到对键盘的操作方法:内核从键盘IO 端口把按键数据读走,存入缓冲区,再映射成字符。处理完毕后,恢复现场,继续回到原来的执行流。
一张图弄清楚:

补充几点:
- 中断控制器:介于外设和CPU之间,负责将外设发起的中断有序的交给CPU处理。
- 有了中断控制器,操作系统就不再关心有没有中断(轮询),而是有中断发生会通知CPU,然后CPU自己找中断处理方法。
- 中断向量表(IDT):内核自己维护的一个函数指针数组,中断号即数组下标,指针指向对于中断的处理方法。
- 回想前面的信号处理:先在pending表标记,handler表存储信号处理方法,这里中断号不就对应pending的比特位,中断向量表对应handler表。
2.2、时钟中断
首先操作系统并不是自动去进程调度,那就有人推动OS去运行:时钟中断。
时钟中断也是硬件中断,发起中断让OS去运行:执行进程调度,时间片轮转等。
***时钟中断:***硬件定时器,以固定的周期向CPU发送硬件中断(电平信号),并通知CPU,让CPU陷入内核,唤醒操作系统去完成进程调度(中断处理方法)。
💡为什么遇到死循环系统不会挂掉?
每个进程都有自己的时间片(计数器),每来一次时钟中断,计数器--,然后判断,当时间片耗尽,OS切换下一个进程。
*细节:*时钟源不像其他外设,为了提高效率而是直接集成在CPU内部。
2.4、软中断
除了硬件中断的方式让操作系统运行,有没有软件原因触发中断?
CPU设计了对应的汇编指令让自己触发中断,陷入内核(主动):int 0x80(32位),syscall(64位)。
2.4.1、什么是系统调用?
系统调用的过程,其实就是先int 0x80、syscall陷入内核,本质就是触发软中断,CPU就会自动执行系统调用的处理方法,而这个方法会根据系统调用号,自动查系统调用函数指针数组,执行对应的方法。
- 系统调用号:如当我调用fork,在编译时就会确定。
bash
glibc 封装:
#define __NR_fork 2 // fork的系统调用号
用户代码:
fork() -> glibc -> mov eax, 2 -> syscall
- 系统调用函数指针表:内核中的系统调用表,根据系统调用号(数组下标),找到对应的内核函数让CPU执行。
bash
/*fn_ptr sys_call_table[] = {sys_setup, sys_exit, sys_fork, sys_read,
sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk,
sys_setgid, sys_getgid, sys_signal, sys_geteuid, sys_getegid,
sys_acct, sys_phys, sys_lock, sys_ioctl, sys_fcntl, sys_mpx,
sys_setpgid, sys_ulimit, sys_uname, sys_umask, sys_chroot,
sys_ustat, sys_dup2, sys_getppid, sys_getpgrp, sys_setsid,
sys_sigaction, sys_sgetmask, sys_ssetmask, sys_setreuid, sys_setregid}; */

***问题:***可是为什么我们用的系统调用,从来没有见过什么 int 0x80 或者 syscall 呢?都是直接调用上层的函数的啊?
那是因为Linux的gnu C标准库,给我们把几乎所有的系统调用全部封装了。
2.4.2、除零、野指针 和缺页中断
除零本质是CPU异常,也会生成中断号,查找中断向量表,而其中断处理方法就是向进程发送SIGFPE信号;
野指针是在虚拟到物理内存映射时,查页表发现不存在(缺页中断),也是异常,生成中断号,查中断向量表,执行中断处理方法,即向进程发送SIGEGV信号。
2.5、理解用户态 和内核态
进程的地址空间分为0~3GB用户空间和3~4GB内核空间。我们的代码正常运行时只会在0~3GB的空间上跳转执行,只有当系统调用,中断或异常发生时,才会进入内核。
CPU根据段寄存器CS的低2位 CPL来区分用户与内核:CPL = 0为内核,CPL = 3为用户。
***问题:***为什么所有进程都能够找到同一个操作系统?
内核3~4GB空间全局共享,内核页表都映射同一份内核物理内存,所以都能找到同一个操作系统。

三、volatile关键字
这个关键字可能大家比较陌生,这是C语言中的一个关键字。
他有什么应用场景呢?
假设我们定义一个全局变量flag,如果用到该变量,那么CPU就会从物理内存拿值,然后操作(算术运算/逻辑运算),最后写回物理内存。但当编译的优化级别比较高时,编译器把变量优化缓存到寄存器,导致程序读不到内存里的最新值。

cpp
int flag = 0;
void Over(int signum)
{
std::cout << "修改flag从0 -> 1" << std::endl;
flag = 1;
}
int main()
{
signal(SIGINT, Over);
while (!flag) // 死循环
{}
std::cout << "process quit normal!" << std::endl;
}
bash
// 使用较高的优化级别编译器编译:-o1 -o2 -o3
g++ -o3 test.cc -o a.out

用volatile修饰flag,让CPU强制访存:

四、SIGCHLD信号
子进程退出时回向父进程发送SIGCHLD信号。那么问题就来了,既然子进程都给父进程发送信号了,为什么还要我们调用waitpid去主动回收子进程?
因为SIGCHLD信号的默认处理方式为Ign(忽略),所以才会成为僵尸进程。

既然你默认处理方法忽略,我们就可以通过自定义处理方法来回收子进程:
cpp
void Wait(int signum)
{
std::cout << "收到一个信号: " << signum << std::endl;
waitpid(-1, nullptr, 0); // 阻塞方式等待任意一个进程
}
int main()
{
// father
// 捕获信号
signal(SIGCHLD, Wait);
pid_t id = fork();
if (id == 0)
{
// child
sleep(3);
exit(3);
}
// father
pause();
std::cout << "子进程回收成功" << std::endl;
}

进程信号的讲解到此结束!!!