前情提要
信号快速认识
一个样例
cpp
#include <iostream>
#include <unistd.h>
int main()
{
while(true){
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}


在命令行中输入./test,shell进程解析命令行,创建子进程,子进程setpgid设置新进程组,shell进程将新进程组设为前台进程组,然后自己就成为了后台进程,等待子进程退出,等到子进程退出后再shell进程组重新变成前台进程组,子进程替换为test进程,子进程是前台进程,所以可以在从文件中读东西,我们键盘按ctrl和c,键盘向中断控制器发送中断请求,中断控制器生成中断号,向指定cpu设置中断引脚,然后cpu在指令中断周期检查开中断IF寄存器,检查中断引脚,获取中断号,cpu切换成内核态,保存寄存器到内核栈中,然后执行对应中断函数,扫描键盘的输入,然后转换成ascill字符写进输入事件缓冲区,然后中断函数执行完毕,cpu从内核栈中恢复寄存器组,切换回用户态,之后守护进程wayland可读事件就绪,将输入事件缓冲区中的字符再IPC拷给进终端模拟器进程,终端模拟器进程将字符显示在屏幕上,然后将字符写进主设备文件,终端驱动一看字符是ctrl+c,于是给该终端的前台进程组的进程设置SIGINT信号,然后前台进程在cpu从内核态切换用户态的时候处理信号,默认异常终止进程
⼀个系统函数
cpp
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
参数说明:
signum:信号编号[后⾯解释,只需要知道是数字即可]
handler:函数指针,表⽰更改信号的处理动作,当收到对应的信号,就回调执⾏handler⽅法
signal可以设置信号的捕捉行为处理方式,而不是直接调用处理动作
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
std::cout << "我是: " << getpid() << ", 我获得了⼀个信号: " << signumber <<
std::endl;
}
int main()
{
std::cout << "我是进程: " << getpid() << std::endl;
signal(SIGINT/*2*/, handler);
while(true){
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}

修改了SIGINT信号的处理函数,于是前台进程./test就不再异常终止了,而是打印信息
查看信号
1、kill -l 命令

2、每个信号都有⼀个编号和⼀个宏定义名称,这些宏定义可以在signal.h中找到
3、编号34以上的是实时信号,我们只讨论编号34以下的普通信号,不讨论实时信号。
4、信号各⾃在什么条件下产⽣,默认的处理动作是什么,可通过 man 7 signal命令查看

信号处理
一个信号可选的处理动作有以下三种:
1、忽略此信号。
cpp
int main()
{
std::cout << "我是进程: " << getpid() << std::endl;
signal(SIGINT/*2*/, SIG_IGN); // 设置忽略信号的宏
while(true){
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}

如果一个信号的处理函数被设置为SIG_IGN,那么这个信号被设置的时候直接不会进sigset_t signal未决信号集里面
2、执⾏该信号的默认处理动作
cpu执行信号默认处理方法是在内核态就可以执行的,不用切换用户态
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
std::cout << "我是进程: " << getpid() << std::endl;
signal(SIGINT/*2*/, SIG_DFL); // 设置忽略信号的宏
while(true){
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}

3、执行自定义信号处理函数
如果是自定义信号处理函数,内核在处理该信号时,就需要切换到⽤⼾态执⾏这个处理函数
以源码角度理解:
cpp
#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强转为函数指针类型
SIG_DFL宏就是值为0的void (*)(int)函数指针,SIG_IGN宏就是值为1的函数指针
信号
一个信号可以分成三步:

产⽣信号
1、通过终端按键产⽣信号
• Ctrl+C (SIGINT) 给前台进程组的进程设置SIGINT信号,默认行为异常终止进程,包括设置pcb中信号码为2,进程状态为Z,释放进程资源如文件描述符表和进程地址空间
• Ctrl+\(SIGQUIT)给前台进程组的进程设置SIGQUIT信号,默认行为⽣成core dump核心转储⽂件,在pcb中设置cwore dump标志,异常终止进程
• Ctrl+Z(SIGTSTP)给前台进程组的进程设置SIGTSTP信号,默认行为挂起进程,即将前台进程切换为后台进程并停止,新前台进程组也会变成shell进程组。

可以通过fg将后台进程组作业重新切换为前台进程组,还可以通过jobs命令查看作业情况
理解OS如何得知键盘有数据

键盘按下会给中断控制器发送中断请求,中断控制器通知指定cpu,然后cpu执行中断函数将键盘输入给转成ascii码,写进输入事件缓冲区,该中断函数结束,然后Wayland守护进程可读事件就绪,将输入事件缓冲区中的数据通过进程间通信发送给终端模拟器进程,后面的就是终端模拟器进程将字符显示出来,然后写进主设备文件里,终端驱动将数据从主设备拷进从设备,终端的前台进程组会从从设备文件中读取数据
2、调⽤命令向进程发信号
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
while(true){
sleep(1);
}
}


SIGSEGV是cpu执行代码出现段错误时给进程设置的信号,同样也可以使用kill命令给特定进程设置信号
3、使⽤函数产⽣信号
1、kill系统调用
kill 命令是一个内建命令,其实就是shell进程调⽤ kill 系统调用 实现的, kill 系统调用 可以给⼀个指定的进程发送指定的信号。
cpp
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 命令
test.cc
cpp
#include <iostream>
#include <string>
#include <signal.h>
using namespace std;
int main(int argc,char** argv)
{
int sig= stoi(argv[1]+1);
int pid= stoi(argv[2]);
kill(pid, sig);
return 0;
}
cpp
#include <iostream>
#include <string>
#include <signal.h>
using namespace std;
int main(int argc,char** argv)
{
while(1);
return 0;
}


2、raise库函数
raise 库函数可以给调用进程设置指定的信号。
cpp
NAME
raise - send a signal to the caller
SYNOPSIS
#include <signal.h>
int raise(int sig);
RETURN VALUE
raise() returns 0 on success, and nonzero for failure.
3、abort
abort 函数给当前进程设置SIGABRT信号。
cpp
NAME
abort - cause abnormal process termination
SYNOPSIS
#include <stdlib.h>
void abort(void);
RETURN VALUE
The abort() function never returns.
// 就像exit函数⼀样,abort函数总是会成功的,所以没有返回值。
4、由软件条件产⽣信号
alarm 函数 和 SIGALRM 信号。
cpp
NAME
alarm - set an alarm clock for delivery of a signal
SYNOPSIS
#include <unistd.h>
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 信号,该信号的默认处理动作是终⽌当前进程。
• 这个函数的返回值是0(如果以前没有设置定时器)或者是以前定时器还余下的秒数。定时器只有1个,如果定时器还没有到期,再次调用alarm设置了一个倒计时,那就会刷新定时器,并返回先前定时器剩下的时间。alarm(0)会取消先前设置的定时器
案例1:
cpp
#include <iostream>
#include <string>
#include <signal.h>
#include <unistd.h>
using namespace std;
int main(int argc,char** argv)
{
alarm(10);
pause();
return 0;
}

./test1进程设置了10s的定时器后,调用了pause,于是pcb挂到了pause的等待队列上,然后10s过后时钟源发送中断请求给中断控制器,中断控制器给指定cpu设置中断引脚,触发时钟中断,cpu切换为内核态,然后执行中断方法,给定时器更新时间,到期后给对应进程设置SIGALRM信号,然后检查在pause等待队列,于是将pcb从等待队列移除,时钟中断到此结束,等到进程重新被调度,cpu从内核态切换用户态时检查并处理信号,于是进程异常退出,信号码14,$?为142
案例2:
cpp
#include <iostream>
#include <string>
#include <signal.h>
#include <unistd.h>
using namespace std;
int main(int argc,char** argv)
{
int n= alarm(10);
cout<<"n: "<<n<<endl;
sleep(3);
n= alarm(10);
cout<<"n: "<<n<<endl;
return 0;
}

如果没有未到期的定时器,那alarm就返回0,如果有,那就重置定时器的时间,并返回先前定时器的剩余时间。这里进程直接退出了所以都没有等到定时器到期SIGALRM信号被设置
5、硬件异常产⽣信号
硬件异常会触发异常中断,然后cpu切换成内核态执行中断方法,向进程发送适当的信号。
例如当前进程执⾏了除以0的指令, CPU的运算单元会异常, 然后触发异常中断,cpu切换内核态,执行中断方法,将SIGFPE信号发送给进程。
再例如⽐如当前进程访问了内存地址, MMU检查VMA链表时没有对应内存地址的vma,mmu就在cpu内部,所以cpu直接触发异常中断,切换内核态,执行中断函数,发送SIGSEGV信号发送给进程。
模拟除0案例:
cpp
#include <stdio.h>
#include <signal.h>
void handler(int sig)
{
printf("catch a sig : %d\n", sig);
}
// v1
int main()
{
signal(SIGFPE, handler); // 8) SIGFPE
sleep(1);
int a = 10;
a/=0;
return 0;
}

mmu处理a/=0会异常,进而cpu触发异常中断,切换内核态执行中断方法给进程设定SIGFPE信号,然后cpu内核态切换用户态时会检查并处理信号,但由于SIGFPE被自定义捕获,所以进程并不会退出,而是打印个信息,该除零异常一直在cpu状态寄存器中存在,所以会死循环处理该异常
⼦进程退出core dump
下面是wait和waitpid函数的status参数的结构

man 7 signal查看信号详细情况

1、行为是Core的,就代表信号的默认处理是创建核心转储文件,然后在进程pcb中设置信号码,core dump标志,释放文件描述符表和进程地址空间资源
2、然后父进程wait子进程,status参数会返回子进程的退出信息,比如信号码和core dump标志
3、进程异常终⽌通常是因为有Bug,⽐如⾮法内存访问导致段错误,事后可以⽤调试器检查core⽂件以查清错误原因,这叫做 Post-mortem Debug (事后调试)。
4、⼀个进程允许 产⽣多⼤的 core ⽂件取决于进程pcb的 Resource Limit 字段。默认是不允许产⽣ core ⽂件的, 因为 core ⽂件中可能包含⽤⼾密码等敏感信息,不安全。可以⽤ ulimit 命令以会话级别改变shell进程的这个限制,允许产⽣ core ⽂件。 ⽤ ulimit 命令设置core ⽂件最⼤为 1024K: $ ulimit -c 1024
core文件使用案例:
我们可以看到SIGSEGV段错误默认也是core行为
cpp
#include <stdio.h>
#include <signal.h>
int main()
{
int* p= nullptr;
*p=10;
return 0;
}

禁用apport守护进程,不然core文件会被自动收集,不会出现传统core文件

将shell进程的core文件的大小限制设置为1024kB,子进程继承父进程的pcb中的属性字段,所以子进程./test1的ulimit也是1024kb




使用gdb调试原可执行程序,然后core-file查看core dump文件
保存信号
当前阶段

信号其他相关常⻅概念
• 实际执⾏信号的处理动作称为信号递达(Delivery)
• 信号从产⽣到递达之间的状态,称为信号未决(Pending)。
• 进程可以选择阻塞 (Block )某个信号。
• 被阻塞的信号产⽣后将保持在未决状态,直到进程解除对此信号的阻塞,才执⾏递达的动作
• 阻塞和忽略是不同的,信号被阻塞只是当前不会递达,⽽忽略是信号连sigset_t signal未决信号集都不进
在内核中的表⽰

struct task_struct {
struct thread_info *thread_info;
struct sighand_struct *sighand;
sigset_t blocked
struct sigpending pending;
//...
};
struct sigpending {
sigset_t signal;//对应待处理的信号
....
};
• 每个信号都有两个标志位分别表⽰阻塞(block)和未决(pending),分别在pcb的sigset_t blocked阻塞信号集和struct sigpending中的sigset_t signal未决信号集,还有⼀个函数指针表⽰处理动
作,在struct sighand_struct中。信号产⽣时,内核在进程控制块pcb中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例⼦中,SIGHUP信号未阻塞也未产⽣过,当它递达时执⾏默认处理动作。
• SIGINT信号的递达动作是忽略,所以SIGINT信号就不会进未决信号集,图中的是错的
• SIGQUIT信号未产⽣过,⼀旦产⽣SIGQUIT信号将被阻塞,它的处理动作是⽤⼾⾃定义函数
sighandler。 如果在进程解除对某信号的阻塞之前这种信号产⽣过多次,则产⽣多次只记一次在未决信号集中
sigset_t
在未决信号集中每个信号只有⼀个bit的未决标志, ⾮0即1, 不记录该信号产⽣了多少次。
未决和阻塞标志⽤相同的数据类型sigset_t来存储, sigset_t称为信号集, 在阻塞信号集中"有效"和"⽆效"的含义是该信号是否被阻塞, ⽽在未决信号集中"有 效"和"⽆效"的含义是该信号是否处于未决状态。
阻塞信号集也叫做当前进程的信号屏蔽字, 这⾥的"屏蔽" 应该理解为阻塞⽽不是忽略。
信号集操作函数
sigset_t类型对于每种信号⽤⼀个bit表⽰"有效"或"⽆效"状态, ⾄于这个类型内部如何存储这些
bit则依赖于系统实现, 从使⽤者的⻆度是不必关⼼的, 使⽤者只能调⽤以下函数来操作sigset_ t变量, ⽽不应该对它的内部数据做任何解释, ⽐如⽤printf直接打印sigset_t变量是没有意义的。
cpp
#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置成1
• 注意,在使⽤sigset_ t类型的变量之前,⼀定要调 ⽤sigemptyset或sigfillset做初始化,使信号集处于
确定的 状态。初始化sigset_t变量之后就可以在调⽤sigaddset和sigdelset在该信号集中添加或删
除某种有效信号。
这四个函数都是成功返回0,出错返回-1。
sigismember是⼀个布尔函数,⽤于判断⼀个信号集的有效信号中是否包含 某种信号,若包含则返回1,不包含则返回0,出错返回-1
sigprocmask
调⽤函数 sigprocmask 可以读取或更改进程的信号屏蔽字(阻塞信号集)。
cpp
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
如果oset是⾮空指针,则读取进程的当前信号屏蔽字通过oset参数传出。
如果set是⾮空指针,则更改进程的信号屏蔽字,参数how指⽰如何更改。如果oset和set都是⾮空指针,则先将原来的信号屏蔽字备份到oset⾥,然后根据set和how参数更改信号屏蔽字。假设当前进程的信号屏蔽字为mask,下表说明了how参数的可选值

sigpending
cpp
#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。
调⽤成功则返回0,出错则返回-1
案例:
cpp
#include <stdio.h>
#include <signal.h>
#include <iostream>
using namespace std;
void handle(int signo)
{
cout<< "信号:"<<signo<<" 递达"<<endl;
}
void PrintPending()
{
sigset_t signal;
sigpending(&signal);
for(int i=34;i>=1;i--)//退出码有0,信号码是没有0的
{
printf("%d",sigismember(&signal, i));
}
printf("\n");
}
int main()
{
signal(SIGINT, handle);
sigset_t block,oblock;
sigemptyset(&block);
sigemptyset(&oblock);
sigaddset(&block, SIGINT);
sigprocmask(SIG_BLOCK, &block, &oblock);
int cnt= 15;
while(cnt--)
{
PrintPending();
if(cnt==5)
{
sigprocmask(SIG_SETMASK, &oblock, nullptr);
}
sleep(1);
}
return 0;
}

信号处理
当前阶段

信号处理的流程

cpu在执行主函数逻辑时,会遇到硬件中断、异常中断、软中断这些,这时cpu会切换为内核态,处理完中断后,在从内核栈中恢复cpu寄存器组前会检查pcb中struct thread_info中的标志位,如果有信号处于未决,那就会将struct sigpending中的sigset_t signal未决信号集和pcb中的sigset_t blocked组合得到未阻塞,也就是需要递达的信号集,然后在struct sighand_struct查看信号的处理方法,cpu切换用户态,然后在用户栈中执行信号处理方法,执行完后cpu返回内核态继续处理下一个信号,直到所有信号被处理完,cpu从内核栈中恢复寄存器组,然后切换成用户态继续执行主函数逻辑
如果自定义信号的处理方法,称为信号捕捉
操作系统是怎么运⾏的
硬件中断

外设向中断控制器发送中断请求,然后中断控制器生成中断号,设置指定cpu的中断引脚,然后cpu进入指令的中断周期后检查if开中断寄存器和中断引脚,然后cpu获取中断号,切换内核态,保存寄存器组到内核栈中,更新寄存器组,根据中断号查中断向量表在内核栈中执行中断方法,执行完后从内核栈中恢复寄存器组,切换回用户态
• 中断向量表就是操作系统的⼀部分,启动就加载到内存中了
• 通过硬件中断,操作系统就不需要对外设进⾏任何周期性的检测或者轮询
• 由外部设备触发的,中断系统运⾏流程,叫做硬件中断
时钟中断

时钟源是一个非常重要的外设,时钟源有两个作用,一个是ns级别的时钟脉冲,这个是用来提供给cpu执行指令使用的,另一个作用则是ms级别的时钟中断,时钟源会向中断控制器发送中断请求,然后中断控制器生成中断号,向全部cpu设置中断引脚,然后cpu触发时钟中断,切换内核态,然后执行对应时钟中断方法,更新当前调度进程的时间片,如果时间片还有那就继续调度该进程,如果没有了那就将该进程设置到cpu的struct runqueue运行队列的过期队列中,并调度一个新进程
软中断
为了让操作系统⽀持系统调⽤,CPU也设计了对应的汇编指令(int 0x80或者 syscall),可以让CPU主动触发中断逻辑。

使用系统调用,会被编译成int 0x80或syscall汇编指令,触发软中断,如果是int 0x80,那把系统调用号保存到寄存器,然后cpu切换内核态,保存寄存器组到内核栈,更新寄存器组,执行80号中断函数,利用系统调用号执行对应系统调用。如果是syscall则同样会将系统调用号直接保存到寄存器,然后cup切换内核态,保存寄存器组到内核栈中,但是syscall无需查中断函数表,因为会提前保存80号中断函数地址到特定寄存器中,然后执行中断函数,根据系统调用号执行对应系统调用
int 0x80和syscall都需要保存系统调用号到寄存器中,但int 0x80还需要查中断函数表,找到80号中断函数,而syscall提前保存80号中断函数地址,少了一步查中断函数表,后面就都是执行80号中断函数,根据系统调用号执行对应系统调用
异常中断
缺⻚异常?除零错误?这些都会触发异常中断
mmu进行虚拟地址转换时,如果虚拟地址对应页表不存在,或者页表项没有对应物理页,或者有物理页但权限不够,这些情况mmu都会触发异常中断。运算器除0时,运算器也会触发异常中断。
mmu和运算器都是cpu的内部硬件,触发异常中断后,cpu切换为内核态,然后保存寄存器组到内核栈中,更新寄存器组,执行中断函数
异常中断和硬件中断相比,硬件中断是外部硬件触发,异常中断是cpu内部硬件触发,但无论是硬件中断还是异常中断,都是强加给cpu的任务
异常中断和软中断相比,软中断是汇编指令int 0x80或syscall触发,异常中断是cpu内部硬件触发,软中断是cpu执行汇编指令主动请求的,异常中断是内部硬件强加给cpu的
volatile
volatile用来告诉编译器,一个变量的值可能会以编译器无法预知的方式被改变,从而使编译器编译代码时,对该值的使用不要优化,比如使用寄存器中的值,而是每次都从内存中读取
案例:
cpp
#include <stdio.h>
#include <signal.h>
int flag = 0;
void handler(int sig)
{
printf("chage flag 0 to 1\n");
flag = 1;
}
int main()
{
signal(2, handler);
while(!flag);
printf("process quit normal\n");
return 0;
}
bash
test1 : test1.cc
g++ -o $@ $^ -O1 #-O1是优化等级

编译器在编译代码时做了优化,因为flag在while循环中被一直判断使用,所以直接将flag的值保存在了寄存器中,使用时不再利用虚拟地址访问物理内存而是直接从寄存器里面取值使用,这就出现了一个问题,当出现信号这种编译器无法预知的情况时,信号会将内存中的flag的值改成1,但是cpu依旧是从寄存器中取flag的值,就会导致程序不退出
所以volatile关键字就是向编译器标志这些可能会出现出乎意料被修改的变量,使编译器对这些变量的使用不要做优化,而是每次从内存中读取
加了volatile后:
cpp
#include <stdio.h>
#include <signal.h>
volatile int flag = 0;
void handler(int sig)
{
printf("chage flag 0 to 1\n");
flag = 1;
}
int main()
{
signal(2, handler);
while(!flag);
printf("process quit normal\n");
return 0;
}

SIGCHLD信号
当进程的状态发生变化时,会向父进程发送SIGCHLD信号,比如进程退出时状态从R变成Z,再比如进程被挂起时,状态从R变成T(暂停),并且进程被设置为后台进程,同样当进程从后台暂停被切换成前台继续运行时,从T状态变成R,这三种情景最常见,当进程状态变化时就会给父进程发SIGCHLD信号。
wait和waitpid是用来回收子进程的,当调用这两个系统调用时,cpu会检查父进程的子进程pcb链表,看哪个子进程状态是Z,那就用子进程pcb中的退出信息构建返回参数status,并释放子进程pcb
SIGCHLD信号的默认处理动作是忽略,也就是子进程给父进程发信号,结果连父进程的未决信号集都进不去,可以捕捉SIGCHLD信号来实现一些功能
案例:
cpp
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>
void handler(int sig)
{
pid_t id;
if((id = waitpid(-1, NULL, WNOHANG)) > 0)
{
printf("wait child success: %d\n", id);
}
}
int main()
{
signal(SIGCHLD, handler);
pid_t cid;
if ((cid = fork()) == 0)
{ // child
printf("child : %d\n", getpid());
sleep(3);
exit(1);
}
while (1)
{
printf("father proc is doing some thing!\n");
sleep(1);
}
return 0;
}

父进程捕捉SIGCHLD信号,用来回收子进程,然后父进程去做其他事情,等到子进程退出,给父进程发SIGCHLD信号,父进程处理SIGCHLD信号就把子进程回收了,然后处理完信号后继续做事情