Linux 信号捕捉与软硬中断
一.信号捕捉与处理
1.信号捕捉
经过前两章的学习,对于信号,我们目前的阶段处于:
信号捕捉的流程如下:
1.信号处理要在合适的时候处理,什么叫做合适的时候?当进程从内核态返回到用户态的时候,会进行信号检查——do_signal,判定当前进程是否收到信号,通过检查pending表,block表,根据handler表执行动作。此时就转到用户写的sighandler处理信号,再返回内核态。
2.如果信号是默认,忽略处理方式,会怎么做?忽略时,只需要将pending表由1变0,返回用户态即可。默认时,当前进程会根据特定信号的约定进行特定动作(大部分信号的动作默认为终止)。处理这些要比自定义捕捉和更简单。
3.重谈捕捉过程:我们在执行自定义方法handler(用户写的)时,操作系统也要做身份切换!因为方法是用户写的,如果方法中存在非法操作,如果用内核身份直接执行,会出问题。
函数调用本质:函数栈帧,我们再调用handler方法时也要形成栈帧,我们可以把sigreturn设置在返回地址中,这样调用完handler会穿插执行sigreturn
整个信号调用的过程就像是:无限符号!涉及到四次身份变化!焦点处,就是信号检测的时间点!
进程凭什么进入内核?为什么系统调用就能进入内核?操作系统会给一个进程分配时间片,即使是一个死循环的代码,也需要被调度,他在运行时实际上在消耗自己的时间片,再时间片结束之后会被强制换下运行队列。
二.硬件中断与软件中断
1.硬件中断
1.操作系统是如何运行的
a.操作系统如何知道键盘上有数据?例如这个scanf,键盘数据没有准备好,进程就会阻塞;为什么会阻塞?因为进程PCB的状态被设置为S,将该进程从运行队列放到等待队列中。后来键盘中输入了数据,回车,进程被瞬间唤醒然后运行。进程时如何知道我们用键盘输入数据的呢?键盘这个设备属于硬件,操作系统需要管理,计算机中做了一个设计:
所有的外部设备,在硬件上会跟CPU特定的针脚进行间接链接;其中,当我们的外部设备(键盘)按下回车,就会向CPU发起一个叫做“硬件中断”的东西。
int main(){int a=0;scanf("%d",&a);printf("%d",a);return 0;}
根据冯诺依曼体系结构我们知道:从数据上来说,外设备并不会直接和CPU打交道。但是外设会在线路上回合CPU连在一起,不传输数据而传递信号。
我们要想把外设的数据喂到内存里,就需要发起所谓的硬件中断。
b.中断控制器会把中断信息的入口连上外设,中断信号由外设发起,由中断控制器通知CPU。中断控制器识别针脚,在自己内部生成中断号,每种设备都有叫做控制器的结构,就例如,磁盘控制器中也有对应的寄存器,有的寄存器存储指令,有的寄存器存储地址。
中断控制器也是设备,当外设就绪,发起高电平信号,中断控制器会识别出哪个针脚被点亮(例如0号),然后把这个数据写到自己的寄存器中,然后中断控制器给CPU的指定针脚发高电平信号,CPU知道有外设准备好了,此时CPU访问中断控制器的寄存器,拿到中断号。
CPU此时虽然知道哪个设备准备好了,但它并不知道怎么管理处理这些数据,所以需要软件进行处理。因此操作系统在内存中提供了一个叫中断向量表——类似函数指针数组的结构,指向了很多方法。其中数组的下标,我们可以理解为提取到的中断号,然后再中断向量表中进行索引,找到其中一个方法,将方法入口返回CPU,这样CPU就可以根据特定信号进行特定处理了。(让CPU执行中断处理方法,读取键盘数据!)
操作系统不会使用轮询方法查询外设信号,而是由外设主动通知操作系统自己准备好了数据。
上一章讲到的信号虽然是纯软件的概念,但是我们其实在用信号模拟硬件中断!
2.没有中断的时候,操作系统在干什么?什么都不做,在暂停等待!
操作的工作方式,我们可以用进程调度为例来讲解:
在中断向量表中,注册一个进程调度的方法,并且在硬件上引入一个时钟源,以固定的频率向CPU发送特定中断,实现了固定频率的进程调度。比如我们给每个进程的task_struct中设置一个时间片,当由时钟中断触发调度,此时会检查当前正在运行的进程时间片是否耗尽,如果进程的时间片耗尽,就执行调度。时间片的本质,就是计数器。
操作系统就在硬件时钟中断的驱动下进行调度了!
这样看来,操作系统其实是一个基于时间中断工作的死循环代码。
操作系统就是基于中断进行工作的软件。后来人们觉得时钟源在外设会跟其他外设竞争中断控制器,因此将时钟源设置在CPU内部,因此CPU有了一个概念叫主频——就是每秒触发中断的次数。
时间->时间戳->历史总频率total
一旦得到时间,除了时间片耗尽,我们也可以让历史总频率一直++,因此计算机可以在离线时知道时间多少,让计算机自带时间计数功能。
进程调度的内核结构如下:
在中断向量表中的下标:0x20,执行的方法:时钟中断
void sched_init(void)
{
...
set_intr_gate(0x20, &timer_interrupt);
// 修改中断控制器屏蔽码,允许时钟中断。
outb(inb_p(0x21) & ~0x01, 0x21);
// 设置系统调⽤中断⻔。
set_system_gate(0x80, &system_call);
...
}
时钟中断采用汇编编写:
时钟中断中有一个叫do_timer的方法。如果当前进程的时间片没有被耗尽,就不进行调度;如果耗尽了,就进行进程调度(schedule())!
schedule方法中就进行了进程切换。
// system_call.s
_timer_interrupt:
...
;// do_timer(CPL)执⾏任务切换、计时等⼯作,在kernel/shched.c,305 ⾏实现。
call _do_timer ;// 'do_timer(long CPL)' does everything from
// 调度⼊⼝
void do_timer(long cpl)
{
...
schedule();
} void schedule(void)
{
...
switch_to(next); // 切换到任务号为next 的任务,并运⾏之。
}
中断向量是操作系统的一部分,在系统启动时也会被载入内存
2.软中断
1.其实在操作系统中,也会通过软件触发上面的逻辑。
首先,我们来讲由软件引发的硬件错误——异常。
由上章讲的信号,我们知道除0错误:引发CPU的EFLAGS寄存器溢出,规定为CPU内部触发的中断!
中断服务:处理异常,向目标进程发送信号!
操作系统怎么知道硬件出异常?所有的异常都会转化为中断。
tips:
缺页中断:虚拟地址存在,但物理空间未开辟,自动触发缺页中断。在中断向量表中也会注册这个方法,触发这个中断后就会构建一个新的物理空间和映射。
2.CPU内部可以做到让软件主动触发中断,而不是间接引发硬件错误吗?
指令集的存在让这个行为成为可能,同时还支持了系统调用。
常见的指令集:
x86:int
x86_64:syscall
C/C++本质就是编译成为了指令集+数据
syscall自动让CPU触发中断,就需要有对应的编号和方法:0x80号中断号,执行方法
在中断向量表注册的CallSystem方法,需要想办法获取系统调用号用来调用系统调用方法。
问题
• ⽤⼾层怎么把系统调⽤号给操作系统? - 寄存器(⽐如EAX) • 操作系统怎么把返回值给⽤⼾?- 寄存器或者⽤⼾传⼊的缓冲区地址 • 系统调⽤的过程,其实就是先int 0x80、syscall陷⼊内核,本质就是触发软中断,CPU就会⾃动执⾏系统调⽤的处理⽅法,⽽这个⽅法会根据系统调⽤号,⾃动查表,执⾏对应的⽅法• 系统调⽤号的本质:数组下标!
系统调用函数指针表:
这个数组的下标,叫做系统调用号
但这些方法,好像跟我们使用的系统调用不太一样?
用户层面怎么调用系统调用?
用户传入系统调用号,让内核拿到,然后寄存器根据0x80触发软中断,自动索引方法,进行系统调用。
问题:open这种,本身不就是OS提供的方法吗?不是!操作系统不提供任何系统调用接口,OS只提供系统调用号!我们使用的这些,都是glibc封装过的!
直接用表地址+4倍eax值,call对应的系统调用方法,就实现了系统调用。
我们来看Linux的gnu C标准库:
• #define SYS_ify(syscall_name) __NR_##syscall_name :是⼀个宏定义,⽤于将系
统调⽤的名称转换为对应的系统调⽤号。⽐如: SYS_ify(open) 会被展开为 __NR_open • ⽽系统调⽤号,不是 glibc 提供的,是内核提供的,内核提供系统调⽤⼊⼝函数 man 2 syscall ,或者直接提供汇编级别软中断命令 int or syscall ,并提供对应的头⽂件或者开发⼊⼝,让上层语⾔的设计者使⽤系统调⽤号,完成系统调⽤过程
3.缺页中断,内存碎片处理,除0野指针错误
void trap_init(void)
{int i;set_trap_gate(0,÷_error);// 设置除操作出错的中断向量值。以下雷同。set_trap_gate(1,&debug);set_trap_gate(2,&nmi);set_system_gate(3,&int3); /* int3-5 can be called from all */set_system_gate(4,&overflow);set_system_gate(5,&bounds);set_trap_gate(6,&invalid_op);set_trap_gate(7,&device_not_available);set_trap_gate(8,&double_fault);set_trap_gate(9,&coprocessor_segment_overrun);set_trap_gate(10,&invalid_TSS);set_trap_gate(11,&segment_not_present);set_trap_gate(12,&stack_segment);set_trap_gate(13,&general_protection);set_trap_gate(14,&page_fault);set_trap_gate(15,&reserved);set_trap_gate(16,&coprocessor_error);
// 下⾯将int17-48 的陷阱⻔先均设置为reserved,以后每个硬件初始化时会重新设置⾃⼰的陷阱
⻔。for (i=17;i<48;i++)set_trap_gate(i,&reserved);set_trap_gate(45,&irq13);// 设置协处理器的陷阱⻔。outb_p(inb_p(0x21)&0xfb,0x21);// 允许主8259A 芯⽚的IRQ2 中断请求。outb(inb_p(0xA1)&0xdf,0xA1);// 允许从8259A 芯⽚的IRQ13 中断请求。set_trap_gate(39,¶llel_interrupt);// 设置并⾏⼝的陷阱⻔。
}
缺⻚中断?内存碎⽚处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断,
然后⾛中断处理例程,完成所有处理。有的是进⾏申请内存,填充⻚表,进⾏映射的。有的是⽤来
处理内存碎⽚的,有的是⽤来给⽬标进⾏发送信号,杀掉进程等等。
所以:
• 操作系统就是躺在中断处理例程上的代码块!
• CPU内部的软中断,⽐如int 0x80或者syscall,我们叫做 陷阱 • CPU内部的软中断,⽐如除零/野指针等,我们叫做 异常。
3.理解用户态和内核态
系统调用的过程,也是在进程地址空间上进行的!所有的函数调用都是地址空间之间的跳转。
操作系统作为软硬件协同的重要软件,它一定会被加载到物理内存中。
看上面的图我们知道,1-3GB属于用户区,3-4GB属于内核区。
这里存在两种页表结构:内核页表和用户页表。其实它们的核心工作和结构没有区别
用户页表:存在多份,因为每个进程都会有自己的进程地址空间,用于描述自己用户区的页表一定会存在多份。
内核页表:用于将当前进程映射到物理内存中操作系统所在的区域。操作系统只有一份,所有进程共享。
结论:
1.这就意味着,不管进程如何被调度,总能找到操作系统的内容。我们调用操作系统的系统调用方法,与进程是否被调度无关,因为只需要跳转进程地址空间到内核地址空间,就可以访问内核的所有代码和数据。
2.也就是说,当中断抵达,CPU要处理中断,不管当前运行的是哪些进程的代码数据,都可以通过地址空间的3-4GB内容查到操作系统中的中断向量表。
3.问题:用户和内核都在同一个0-4GB地址空间上了,用户难道可以随便访问内核中的代码和数据了吗?
操作系统为了保护自己,不相信任何人,必须采用系统调用的方式进行访问内核。
用户态:以用户身份只能访问自己的0-3GB数据
内核态:以内核身份,允许通过系统调用的方式,访问操作系统3-4GB数据
问题是:用户或者操作系统,怎么判断自己是什么状态?
CPU内有一个寄存器,cs段寄存器:用低两位标识(权限位:CPL)当前的状态。0标识内核态,3标识用户态——可以看到判断状态的方式是由硬件提供的
上回说到的指令集,最重要的作用之一就是,让cs段寄存器指向操作系统所对代码区,同时将权限标志位设为0,就叫做陷入内核。
用户态:CPU权限为3,并且只允许用户访问0-3GB
内核态:CPU权限为0,将系统调用号传递到eax寄存器,然后切换地址空间到3-4GB,通过内核页表映射找到操作系统,根据中断号查找系统调用表,执行对应系统调用
三.信号捕捉与其他知识
1.捕捉信号方法sigaction
之前我们学习过signal函数捕捉信号,今天我们学习一个新的捕捉信号的接口。
NAMEsigaction, rt_sigaction - examine and change a signal actionSYNOPSIS#include <signal.h>int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);
它的参数我们其实也比较熟悉,分别是信号编号,处理信号的方法(自定义),以及一个输出型参数oldact。
sigaction是一个结构体,它的结构如下:
struct sigaction {void (*sa_handler)(int);void (*sa_sigaction)(int, siginfo_t *, void *);sigset_t sa_mask;int sa_flags;void (*sa_restorer)(void);};
sa_handler即自定义信号处理函数,我们重点看第三个成员,可以看到它的类型是上章讲过的信号集。信号捕捉,怎么会和信号集有关系呢?我们来使用sigaction接口来一探究竟。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdlib>
#include <sys/wait.h>void handler(int signum){std::cout<<"hello signal"<<signum<<std::endl;exit(0);
}int main(){struct sigaction act,oct;act.sa_handler = handler;sigaction(SIGINT,&act,&oct);while(1){std::cout<<"hello world"<<std::endl;sleep(1);}return 0;
}
act中的信号集,是什么?进程在收到2号信号,准备抵达;操作系统此时会自动将block位图的2号对应位置填充为1,在处理完之后清0。这样的操作,就避免了在处理2号时有重复的二号传入,引发递归处理。
wujiahao@VM-12-14-ubuntu:~/TestSig$ ./test
hello world
hello world
^Chello signal2
既然可以令当前捕捉的信号屏蔽,那么自然可以想到:这里的信号集sa_mask可以向block中添加多个信号,在捕捉处理2号信号时这些信号都将被屏蔽。
当某个信号的处理函数被调⽤时,内核⾃动将当前信号加⼊进程的信号屏蔽字,当信号处理函数返回时⾃动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产⽣,那么 它会被阻塞到当前处理结束为⽌。 如果在调⽤信号处理函数时,除了当前信号被⾃动屏蔽之外,还希望⾃动屏蔽另外⼀些信号,则⽤sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时⾃动恢复原来的信号屏蔽字。 sa_flags字段包含⼀些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数,本章不详细解释这两个字段
我们可以接着验证上面的说法,通过sa_mask添加更多屏蔽字,并不断获取pending表:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdlib>
#include <sys/wait.h>void handler(int signum){std::cout << "hello signal: " << signum << std::endl;while(true){//不断获取pending表!sigset_t pending;sigpending(&pending);for(int i = 31; i >= 1; i--){if(sigismember(&pending, i))std::cout << "1";elsestd::cout << "0";}std::cout << std::endl;sleep(1);}exit(0);}int main(){struct sigaction act,oct;act.sa_handler = handler;sigemptyset(&act.sa_mask);sigaddset(&act.sa_mask,3);sigaddset(&act.sa_mask,4);act.sa_flags = 0;sigaction(SIGINT,&act,&oct);while(1){std::cout<<"hello world"<<std::endl;sleep(1);}return 0;
}
现象如下:当我们重复摁下Ctrl+c和Ctrl+\时,pending表就会更新,代表这些信号因阻塞而无法被递达。
wujiahao@VM-12-14-ubuntu:~/TestSig$ ./test
hello world
hello world
hello world
^Chello signal: 2
0000000000000000000000000000000
0000000000000000000000000000000
^C0000000000000000000000000000010
^C0000000000000000000000000000010
0000000000000000000000000000010
^@0000000000000000000000000000010
^\0000000000000000000000000000110
2.可重入函数
先有如下场景:当我们在进行链表的结点插入方法时
假如我们在执行完第一步,因为某些时钟中断或信号捕捉,导致链表node1并未完全插入链表中。不巧的时,信号捕捉方法也要插入一个node2,node2的insert完整执行完,返回原来node1的方法处,修改了head的指向,这就导致了node2结点丢失,内存泄漏。
main执行流,handler执行流,其实是串行的。但是会在某些时刻,insert方法被两个以上的执行流重复进入了。
这种场景叫做函数重入。这种由重入导致异常的函数,叫做不可重入函数——这是一种特点。日常使用的大部分函数,都是不可重入的。
如果⼀个函数符合以下条件之⼀则是不可重⼊的:
• 调⽤了malloc或free,因为malloc也是⽤全局链表来管理堆的。• 调⽤了标准I/O库函数。标准I/O库的很多实现都以不可重⼊的⽅式使⽤全局数据结构。
3.volatile关键字
代码引入
main的执行流中,并不会对flag做修改。比如在编译器优化级别较高的情况下,可能会把flag变量优化到寄存器中。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdlib>
#include <sys/wait.h>int flag = 0;void handler(int signu){std::cout << "更改全局变量, " << flag << "-> 1" << std::endl;flag = 1;}int main(){signal(2, handler);while(!flag);std::cout << "process quit normal!" << std::endl;return 0;}
完成计算,一般需要三个阶段:从物理内存拿数据,在寄存器做计算,有必要时写回内存。在上面的代码中,常规情况下会不断读取物理内存,并在CPU中做逻辑运算判断flag。
但是在某些优化场景下,CPU判断在main函数中flag并没有发生修改,就直接把flag值加载到CPU寄存器,提高运算速度。在gcc编译时,我们可以通过指定-Ox(x为数字)来指定优化程度
wujiahao@VM-12-14-ubuntu:~/TestSig$ g++ test.cc -O0
wujiahao@VM-12-14-ubuntu:~/TestSig$ ./a.out
^C更改全局变量, 0-> 1
process quit normal!
wujiahao@VM-12-14-ubuntu:~/TestSig$ g++ test.cc -O1
wujiahao@VM-12-14-ubuntu:~/TestSig$ ./a.out
^C更改全局变量, 0-> 1
^C更改全局变量, 1-> 1
^C更改全局变量, 1-> 1
^C更改全局变量, 1-> 1
^\Quit (core dumped)
但我们此时发现一个奇怪的问题:为什么main中没有正常执行return?
此时由于优化,寄存器中的值和代码中的值并不相符,flag=1被直接加载到了寄存器中,程序反倒不会退出了。
站在用户角度,就像是:寄存器覆盖了进程看到变量的真实情况,物理内存却不可见!
此时我们就用volatile来修饰flag。这样进程就可以正常结束了。由此可见,该关键字的作用是,保证内存空间的可见性。↓下面的代码展示,为flag加上volatile关键字,就算把编译器优化拉满也可以避免物理内存不可见的问题了。
wujiahao@VM-12-14-ubuntu:~/TestSig$ g++ test.cc -O3
wujiahao@VM-12-14-ubuntu:~/TestSig$ ./a.out
^C更改全局变量, 0-> 1
process quit normal!
4.sigchild信号——17号信号
子进程在退出时,会给父进程发送sigchild信号
以前感觉不到,是因为父进程处理该信号的方法是默认IGN忽略。验证这个结论,我们捕捉信号:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdlib>
#include <sys/wait.h>void Say(int num){std::cout << "father get a signal:" << num << std::endl;
}int main(){signal(SIGCHLD,Say);pid_t pid=fork();if (pid==0){std::cout<<"I am child,exit"<<std::endl;sleep(3);exit(3);}//父进程waitpid(pid,NULL,0);std::cout<<"I am father,wait child exit"<<std::endl;}
效果如下:
wujiahao@VM-12-14-ubuntu:~/TestSig$ ./a.out
I am child,exit
father get a signal:17
I am father,wait child exit
既然如此,我们可以通过信号捕捉方式回收子进程。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdlib>
#include <sys/wait.h>void WaitAll(int num){while (true){pid_t n = waitpid(-1, nullptr, WNOHANG); // 阻塞了!if (n == 0){break;}else if (n < 0){std::cout << "waitpid error " << std::endl;break;}}std::cout << "father get a signal: " << num << std::endl;}int main()
{// 父进程signal(SIGCHLD, WaitAll); // 父进程for (int i = 0; i < 10; i++){pid_t id = fork(); // 如果我们有10个子进程呢??6退出了,4个没退if (id == 0){sleep(3);std::cout << "I am child, exit" << std::endl;exit(3);// if(i <= 6) exit(3);// else pause();}}while (true){std::cout << "I am fater, exit" << std::endl;sleep(1);}return 0;
}
如果我们不关注退出码,仅仅想通过这种方法回收子进程避免僵尸进程,还可以将SIGCHILD信号的处理方式设置为SIG_IGN。
问题:一开始就说了父进程的默认方式是SIG_IGN,但这里还要再设置一次,是为啥?
它的处理方式其实是SIG_DFL,缺省方式恰好是IGN。缺省设置的IGN和手动设置的IGN效果就是不同的。