目录
[②Ctrl+\](#②Ctrl+)
[a.基本alarm验证 - IO效率问题](#a.基本alarm验证 - IO效率问题)
[①pause() 函数](#①pause() 函数)
[c.子进程退出core dump](#c.子进程退出core dump)
[d.Core Dump](#d.Core Dump)
认识信号
1>生活角度
a.示例
• 你在⽹上买了很多件商品,然后等不同商品快递的到来,即使快递还没有到,你也知道快递准备到时,你该怎么处理快递 ---> 也就是说你能 "识别快递"
• 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需要5min之后才能去取快递。那么在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了 ---> 也就是取快递的⾏为并不是⼀定要⽴即执⾏,可以理解成 "在合适的时候去取"
• 在收到通知,再到你拿到快递期间,是有⼀个时间窗⼝的,在这段时间内你并没有拿到快递,但是你知道有⼀个快递已经到了 ---> 本质上是你 "记住了有⼀个快递要去取"
• 当你时间合适,顺利拿到快递之后,就要开始处理快递了 ---> 处理快递⼀般⽅式有三种:1. 执⾏默认动作(拆开快递,使⽤商品)2. 执⾏⾃定义动作(你把快递作为礼物送给你的⼥朋友)3. 忽略快递(扔到床头,继续打游戏)
• 快递到来的整个过程,对你来讲是异步的,因为你不能准确知道快递员什么时候给你打电话
异步 --> 允许你同时处理多个任务,通过事件或通知机制在任务完成时响应
同步 --> 要求你按顺序执行任务,必须等待当前任务完成后才能开始下一个任务
b.回归进程
信号是一种给进程发送的,用来进行事件异步通知的机制
信号的产生,相对于进程的运行,是异步的
信号是发给进程的
c.基本结论
信号处理,进程在信号没有产生的时候,早就知道信息该如何处理了
信号的处理,不是立即处理,而是可以等一会再处理,在合适的时候,进行信号的处理(信号到来 -> 信号保存 -> 信号处理)
人能识别信号,是提前被"教育"过的,同样进程也如此,OS程序员设计的进程,进程早已经内置了对于信号的识别和处理方式
现实中的信号源非常多,而给进程产生信号的信号源也非常多
2>技术应用角度
a.示例
下面这段代码中我启动了一个进程,当我按下Ctrl+c,这个键盘产生一个硬件中断,被OS获取,解释成信号,发送给目标进程,目标进程收到信号,进而引起进程退出

b.前台&&后台进程
目标进程就是前台进程,同样以上面的代码为例

对于命令,也只会交给前台进程处理

总结:前台进程的本质,就是要从键盘获取数据的!
①相关指令
jobs
--> 查看所有的后台任务
fg [任务号]
--> 将特定进程,提到前台

ctrl+z
--> 将进程切换到后台
bg [任务号]
--> 让后台进程恢复运行

c.重谈给进程发送信号

d.signal系统函数
cpp
NAME
signal - ANSI C signal handling
SYNOPSIS
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
参数说明:
signum: 信号编号
handler: 函数指针,表示更改信号的处理动作,当收到对应的信号,就回调执行handler方法
补充:
signum:指明了所要处理的信号类型,它可以取除了
SIGKILL
和SIGSTOP
外的任何一种信号handler:描述了与信号关联的动作,它可以取以下三种值
cpp1. 一个指向用户定义的信号处理函数的指针 2. SIG_IGN:表示忽略该信号 3. SIG_DFL:表示恢复对信号的系统默认处理

SIGINT
的默认动作是终止进程,但signal
函数设置了捕捉行为处理方式(相对来说,比较多信号的处理动作,就是让自己终止)
e.总结归纳
signal
函数仅仅是设置了特定信号的捕捉⾏为处理⽅式,并不是直接调⽤处理动作,如果后续特定信号没有产⽣,设置的捕捉函数永远也不会被调⽤
Ctrl+c
产⽣的信号只能发给前台进程,⼀个命令后⾯加个&可以放到后台运⾏,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程Shell可以同时运⾏⼀个前台进程和任意多个后台进程,只有前台进程才能接到像
Ctrl+C
这种控制键产⽣的信号前台进程在运⾏过程中⽤⼾随时可能按下
Ctrl-C
⽽产⽣⼀个信号,也就是说该进程的⽤⼾空间代码执⾏到任何地⽅都有可能收到SIGINT
信号⽽终⽌,所以信号相对于进程的控制流程来说是异步的
3>概念
信号是进程之间事件异步通知的⼀种⽅式,属于软中断
a.查看信号
主要关注 1-31 普通信号,而 34-64 实时信号,后续再介绍

每个信号都有一个编号和一个宏定义名称,可以在
signal.h
中找到

man 7 signal
--> 可以查看这些信号各自在什么条件下产生,默认的处理动作是什么

b.信号处理
处理动作有三种:(后面再介绍
sigaction
函数)
①忽略此信号

②执行该信号的默认处理动作

③自定义捕捉一个信号
提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数

在源码中,其实
SIG_DFL
和SIG_IGN
就是把 0,1 强转为函数指针类型
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);
产生信号

1>通过终端按键产生信号
a.基础操作
①Ctrl+c
可以发送终⽌信号
cpp
还是以上面的代码为例子
修改 signal 函数为 signal(SIGINT, handler);

注释掉 signal 函数

②Ctrl+\
可以发送终⽌信号并⽣成core dump⽂件,⽤于事后调试(后面详细介绍)
cpp
修改 signal 函数为 signal(SIGQUIT, handler);

注释掉 signal 函数

③Ctrl+z
可以发送停⽌信号,将当前前台进程挂起到后台
cpp
修改 signal 函数为 signal(SIGTSTP, handler);

注释掉 signal 函数

b.理解OS如何得知键盘有数据

c.理解信号起源
信号其实是从纯软件⻆度,模拟硬件中断的⾏为
只不过硬件中断是发给CPU,⽽信号是发给进程
两者有相似性,但是层级不同,这点我们后⾯的感觉会更加明显
2>调用系统命令向进程发信号
cpp
int main()
{
while(true)
{sleep(1);}
}

• 4023443 是
testsig
进程的pid
,之所以要再次回⻋才显⽰Segmentation fault
,是因为在 4023443 进程终⽌掉之前已经回到了 Shell 提⽰符等待⽤⼾输⼊下⼀条命令,Shell 不希望Segmentation fault
信息和⽤⼾的输⼊交错在⼀起,所以等⽤⼾输⼊命令之后才显⽰• 指定发送某种信号的 kill 命令可以有多种写法,上⾯的命令还可以写成 kill -11 4023443 , 11 是信号
SIGSEGV
的编号(以往遇到的段错误都是由⾮法内存访问产⽣的,⽽这个程序本⾝没错,给它发SIGSEGV
也能产⽣段错误)
3>使用函数产生信号
a.kill
kill
命令是调⽤ kill 函数实现的,kill 函数可以给⼀个指定的进程发送指定的信号

cpp
void handler(int sig)
{
std::cout << "我获得了一个信号: " << sig << std::endl;
}
int main()
{
// signal(SIGTSTP, handler);
int cnt = 0;
while(true)
{
std::cout << "hello world, " << cnt++ << " ,pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
cpp
int main(int argc, char* argv[])
{
if(argc != 3)
{
std::cout << "./mykill signumber pid" << std::endl;
return 1;
}
int signum = std::stoi(argv[1]);
pid_t target = std::stoi(argv[2]);
int n = kill(target, signum);
if(n == 0)
std::cout << "send" << signum << " to " << target << "success.";
return 0;
}

b.raise
raise
函数可以给当前进程发送指定的信号(⾃⼰给⾃⼰发信号)

cpp
void handler(int sig)
{
std::cout << "我获得了一个信号: " << sig << std::endl;
}
int main()
{
for(int i = 1; i < 32; i++)
signal(i, handler);
for(int i = 1; i < 32; i++)
{
sleep(1);
raise(i);
}
return 0;
}

c.abort
abort
函数使当前进程接收到信号⽽异常终⽌
cpp
void handler(int sig)
{
std::cout << "我获得了一个信号: " << sig << std::endl;
}
int main()
{
for(int i = 1; i < 32; i++)
signal(i, handler);
int cnt = 0;
while(true)
{
std::cout << "hello world, " << cnt++ << " ,pid: " << getpid() << std::endl;
abort();
sleep(1);
}
}

明明我们的自定义动作中并没有退出这个进程,但进程依旧终止了,这是因为
abort
函数对我们的自定义动作进行了处理,它的目的是接收信号并终止进程
4>由软件条件产生信号
SIGPIPE
是一种由软件条件产生的信号,在管道那篇文章我介绍过,就是当读取数据的进程关闭了读端,OS会直接将进程终止,因为另一个进程再往里面写数据并没有意义,OS不会做任何浪费时间和空间的事情!
而这里我主要介绍 alarm
函数和 SIGALRM
信号

• 调⽤
alarm
函数可以设定⼀个闹钟,也就是告诉内核在seconds
秒之后给当前进程发SIGALRM
信号,该信号的默认处理动作是终⽌当前进程• 这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。 比如说我定了一个5s的闹钟,5s后闹钟响了就返回0,而如果过了3s后,我又重新设置了一个10s的闹钟,10s后闹钟响了就返回上次闹钟的余数,也就是2s(注意:如果
seconds
值为0,就表⽰取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数)
a.基本alarm验证 - IO效率问题
程序的作⽤是1秒钟之内不停地数数,1秒钟到了就被
SIGALRM
信号终⽌(必要的时候,对SIGALRM
信号进⾏捕捉)

从结果可以看出,闹钟会响一次,默认终止进程,并且有IO效率低
b.设置重复闹钟

①pause() 函数
作用:使调用进程挂起(暂停执行),直到捕获到一个信号,当信号到达并被处理后,返回-1,并将
errno
设置为EINTR
,以指示函数调用是由于信号中断而返回的

cpp
/////////////function/////////////
void Sched()
{
std::cout << "我是进程调度" << std::endl;
}
void MemManger()
{
std::cout << "我是周期性的内存管理,正在检查有没有内存问题" << std::endl;
}
void Fflush()
{
std::cout << "我是刷新程序,我在定期刷新内存数据,到磁盘" << std::endl;
}
/////////////function/////////////
using func_t = std::function<void()>;
std::vector<func_t> funcs;
void handler(int sig)
{
std::cout << "####################################" << sig << std::endl;
for(auto f : funcs)
f();
std::cout << "####################################" << sig << std::endl;
alarm(1);
}
int main()
{
funcs.push_back(Sched);
funcs.push_back(MemManger);
funcs.push_back(Fflush);
signal(SIGALRM, handler);
alarm(1);
while(true)
{
pause();
}
}

这里通过
alarm
函数,用信号驱动进程执行任务,模拟了操作系统是如何运行的,因为操作系统也是牛马,并不是自己想去做的

c.理解软件条件
在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产⽣机制。这些条件包括但不限于定时器超时(如
alarm
函数设定的时间到达)、软件异常(如向已关闭的管道写数据产⽣的SIGPIPE
信号)等。当这些软件条件满⾜时,操作系统会向相关进程发送相应的信号,以通知进程进⾏相应的处理。简⽽⾔之,软件条件是因操作系统内部或外部软件操作⽽触发的信号产⽣
d.理解系统闹钟
系统闹钟,其实本质是OS必须⾃⾝具有定时功能,并能让⽤⼾设置这种定时功能,才可能实现闹钟这样的技术
现代Linux是提供了定时功能的,意味着定时器也要被管理 --> 先描述,再组织,而内核中的定时器数据结构是:
cpp
struct timer_list {
struct list_head entry;
unsigned long expires;
void (*function)(unsigned long);
unsigned long data;
struct tvec_t_base_s *base;
}
可以看到,定时器超时时间
expires
和处理⽅法function
,另外操作系统管理定时器,采⽤的是时间轮的做法,可以简单理解成把它再组织成为"堆结构"
5>硬件异常产生信号
硬件异常被硬件以某种⽅式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号
a.模拟除0

如果没有捕捉信号,则显示

当前进程执⾏了除以0的指令, CPU的运算单元会产⽣异常, 内核将这个异常解释为
SIGFPE
信号发送给进程
b.模拟野指针

如果没有捕捉信号,则显示

当前进程访问了⾮法内存地址,MMU会产⽣异常,内核将这个异常解释为
SIGSEGV
信号发送给进程
c.子进程退出core dump
在进程等待那篇文章我也把这张图拿出来了,但是并没有介绍低八位,现在终于可以介绍了,后七位表示退出信号现在也能理解了,而
core dump
标志究竟是什么东西


但是我们好像到现在都没有见过Core相关文件,这是因为在云服务器上,core dump
功能是被禁止掉的,原因也很简单,崩溃的程序一直运行会导致磁盘被这些文件写满,所以云服务器在根源上直接解决问题
可以通过
ulimit -a
命令查看core dump

d.Core Dump
•
SIGINT
的默认处理动作是终⽌进程,SIGQUIT
的默认处理动作是终⽌进程并且Core Dump
• 当⼀个进程要异常终⽌时,可以选择把进程的⽤⼾空间内存数据全部保存到磁盘上,⽂件名通常是
core
,这就叫做Core Dump
• 进程异常终⽌通常是因为有
Bug
,⽐如⾮法内存访问导致段错误,事后可以⽤调试器检查core
⽂件以查清错误原因,这叫做Post-mortem Debug
(事后调试)• ⼀个进程允许产⽣多⼤的
core
⽂件取决于进程的Resource Limit
(这个信息保存在PCB中)默认是不允许产⽣core
⽂件的, 因为core
⽂件中可能包含⽤⼾密码等敏感信息,不安全• 在开发调试阶段可以⽤
ulimit
命令改变这个限制,允许产⽣core
⽂件。 ⾸先⽤ulimit
命令改变 Shell 进程的Resource Limit
,如允许core
⽂件最⼤为 4096KB ->$ ulimit -c 4096
下面来一个demo代码

再来一个demo代码,这次从退出码中获取信息
cpp
int main()
{
pid_t id = fork();
if (id == 0)
{
sleep(3);
printf("hello ubuntu\n");
printf("hello ubuntu\n");
printf("hello ubuntu\n");
// 直接让子进程异常退出
int a = 13;
a /= 0;
printf("hello ubuntu\n");
}
int status = 0;
waitpid(id, &status, 0);
printf("signal: %d, exit code: %d, core dump: %d\n",
(status & 0x7F), (status >> 8) & 0xFF, (status >> 7) & 0x1);
return 0;
}

可重入函数

•
main
函数调⽤insert
函数向⼀个链表head
中插⼊节点node1
,插⼊操作分为两步,刚做完第⼀步的时候,因为硬件中断使进程切换到内核,再次回⽤⼾态之前检查到有信号待处理,于是切换到sighandler
函数,sighandler
也调⽤insert
函数向同⼀个链表head
中插⼊节点node2
,插⼊操作的两步都做完之后从sighandler
返回内核态,再次回到⽤⼾态就从main
函数调⽤的insert
函数中继续往下执⾏,先前做第⼀步之后被打断,现在继续做完第⼆步。结果是,main
函数和sighandler
先后向链表中插⼊两个节点,⽽最后只有⼀个节点真正插⼊链表中了• 像上例这样,
insert
函数被不同的控制流程调⽤,有可能在第⼀次调⽤还没返回时就再次进⼊该函数,这称为重⼊,insert
函数访问⼀个全局链表,有可能因为重⼊⽽造成错乱,像这样的函数称为不可重⼊函数,反之,如果⼀个函数只访问⾃⼰的局部变量或参数,则称为可重⼊函数(另外想想为什么两个不同的控制流程调⽤同⼀个函数,访问它的同⼀个局部变量或参数就不会造成错乱)
如果⼀个函数符合以下条件之⼀则是不可重⼊的:
• 调⽤了
malloc
或free
,因为malloc
也是⽤全局链表来管理堆的• 调⽤了标准
I/O
库函数,标准I/O
库的很多实现都以不可重⼊的⽅式使⽤全局数据结构
本篇文章到这里就结束啦,希望这些内容对大家有所帮助!
下篇文章见,希望大家多多来支持一下!
感谢大家的三连支持!