最后的话
最近很多小伙伴找我要Linux学习资料,于是我翻箱倒柜,整理了一些优质资源,涵盖视频、电子书、PPT等共享给大家!
资料预览
给大家整理的视频资料:
给大家整理的电子书资料:
如果本文对你有帮助,欢迎点赞、收藏、转发给朋友,让我有持续创作的动力!
网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
可以看到当这个程序运行起来以后,输入除了Ctrl c
之外的其他命令是没法运行的,这是因为这个程序是前台进程
,系统在一个终端中只允许有一个前台进程。因此,当这个程序在前台运行起来以后,bash
也是一个进程,此时其处在后台的,所以接收不到我们发送的其他命令。
但是Ctrl c
指令是通过硬件的输入方式中断进程,它的本质也是通过系统向进程发送信号。
所以如果把这个程序放在后台运行,Ctrl c
指令就无法终止整个程序。此时bash
处在前台,可以接收到我们输入的其他命令。
当进程被设置为后台进程时,我们在命令行输入的消息流会和后台进程的信息混合在一起,这是因为bash
进程是在前台的,我们可以输入信息,但是显示器只有一个,被两个进程同时使用,说明他是临界资源,而这个临界资源又没有被保护,因此它的数据会发生混乱。
此时输入fg 进程路径
就可以让进程从后台回到前台。
1.1.1.用signal系统调用接口验证ctrl c是信号
这个接口能够捕捉并重定向一个信号的默认处理动作,使信号不执行原来的动作,而是执行我们自定义的动作
#include <signal.h>
typedef void (\*sighandler\_t)(int);
sighandler\_t signal(int signum, sighandler\_t handler);
signum:对应信号的编号(普通信号编号1-31,实时信号编号34-64)
handler:回调函数(函数指针),传一个函数的地址。这个函数就是我们自定义的处理动作。
ctrl c
发送的是2号信号:
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
void handler(int signo){
printf("catch a signal:%d\n",signo);
}
int main(){
while(1){
signal(2,handler);//收到2号信号就执行我们设定的动作
printf("I am running..!\n");
sleep(1);
}
}
由于我们修改了其默认处理动作,所以输入Ctrl c
是不会退出的,而是执行我们设定好的动作。
此时可以使用Ctrl \
来退出,这个指令是发送的是3号信号SIGQUIT
SIGSTOP和SIGKILL不可捕获
9号信号SIGKILL和19号信号 SIGSTOP是不能被signal函数捕捉并修改默认动作的。原因也很简单:如果所有信号都可以被捕捉,病毒可以将所有信号捕捉更改掉,系统就瘫痪了,因此需要这俩信号不能被捕捉,即系统始终拥有对进程的终止能力。
1.1.2.小结
- Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
Ctrl c
产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。- 前台进程在运行过程中用户随时可能按下
Ctrl c
而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到SIGINT
信号而终止,所以信号相对于进程的控制流程来说是异步的。
二、信号的常见处理方式
当进程收到信号以后,可选的处理动作有以下三种:
- 忽略此信号。
- 执行该信号的默认处理动作。
- 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。
信号产生之后,不是立即被处理的:
信号从产生到处理的过程中是有时间窗口的,在这个时间窗口之内信号是要排队的,所谓的排队是将这个信号记录或保存下来。
三、信号的产生
3.1.通过终端按键产生信号
信号的第一种产生方式是通过键盘,常见的有以下几种:
- ctrl+c 2号信号 SIGINT
- ctrl+z 20号信号 SIGTSTP
- ctrl+\ 3号信号 SIGQUIT
3.2.通过调用系统函数向进程发信号
3.2.1.kill
这个系统调用函数可以给指定的进程发送信号。
#include <sys/types.h>
#include <signal.h>
int kill(pid\_t pid, int sig);
pid:代表目标进程的pid
sig:代表要发送几号信号
返回值:
调用成功返回0,失败返回-1
采用命令行参数的方式给进程发送信号:
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<stdlib.h>
void handler(int signo){
printf("catch a signal: %d\n",signo);
}
int main(int argc,char\*argv[]){
if(argc==3){
kill(atoi(argv[1]),atoi(argv[2])); }
}
这里的argv是char *类型,而kill的参数是int型,所以需要用atoi函数进行转化。
3.2.2.raise
这个系统调用函数是自己给自己发信号
#include <signal.h>
int raise(int sig);
sig:给自己发送信号的名称
返回值:
调用成功返回0,失败-1
自己给自己发送2号信号来验证这个系统调用函数:
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<stdlib.h>
void handler(int signo){
printf("catch a signal: %d\n",signo);
}
int main(){
signal(2,handler);
while(1){
sleep(1);
raise(2);
}
}
3.2.3.abort
abort使当前进程给自己发生6号信号SIGABRT
#include <stdlib.h>
void abort(void);
这个系统调用函数给自己的进程发6号信号,所以没有参数
并且这个函数总是能调用成功,因此也没有返回值
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<stdlib.h>
void handler(int signo){
printf("catch a signal: %d\n",signo);
}
int main(){
signal(6,handler);
while(1){
sleep(1);
abort();
}
}
这个信号虽然被捕捉了,但是该信号原来的动作还是被执行了。
3.3.由软件条件产生信号
3.3.1.alarm
alarm相当于系统的闹钟,在seconds秒后告诉内核,给当前进程发送一个SIGALRM信号,该信号默认处理动作是终止当前进程
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
seconds:秒数,如果seconds值为0,表示取消以前设定的闹钟,
函数的返回值仍然是以前设定的闹钟时间还余下的秒数
返回值:
0或者是以前设定的闹钟时间还余下的秒数
通过计数器来验证这个函数:
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<stdlib.h>
void handler(int signo){
printf("catch a signal: %d\n",signo);
}
int main(){
alarm(1);//1秒以后给自己发闹钟信号
int count=0;
while(1){
printf("count:%d\n",count++);
}
}
在一秒之中count打印了两万次左右,一秒钟到了就被14号SIGALRM
信号终止。
一个操作系统中可能有很多个闹钟,因此需要将它管理起来,因此clarm底层也会有对应的数据结构对它进行描述组织当闹钟时间到了,操作系统中也有计时器,比如有个链表,链表中存储着计时器,每隔一秒就将计时器减一,当计数器为0时,则找到对应的进程发送信号。
这种提前设置好时间点,时间点到了操作系统自动发送信号,这种发送信号的方式就叫做软件条件产生信号。
3.3.2.利用alarm验证IO对效率的影响
上面那个程序count被打印了两万次,如果我们只打印一次呢?
可以看到count是一个非常大的数字。
这是因为,count++操作是在CPU中进行的,之前每次输出都是I/O操作(将内存中的数据输出到显示器(外设)上),只有最后一次输出的I/O操作。
可见I/O是非常影响效率的。
3.4.由硬件异常产生信号
硬件的异常被检测到,并且通知内核,内核会向当前进程发送适当的信号。
例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为8号SIGFPE
信号发送给进程。再比如当前进程访问了非法内存地址(野指针),MMU会产生异常,内核将这个异常解释为11号SIGSEGV
信号发送给进程。
3.4.1.野指针解引用
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<stdlib.h>
void handler(int signo){
printf("catch a signal: %d\n",signo);
}
int main(){
signal(11,handler);
int\*p;
\*p=100;
return 0;
}
给野指针赋值,会发生段错误。
虚拟地址访问数据,虚拟地址需要先转换到物理地址,如果是野指针,那么在页表之个找不到对应的映射关系,这个地址转化就会发生错误。
MMU硬件转化该错误,操作系统就能识别到,然后发送信号,终止此进程。
3.4.2.除0
如果是除0操作,CPU在运算时会发现这个错误(CPU的状态寄存器会记录数据有没有溢出),此时操作系统就会给进程发送8号信号。
四、Core Dump
当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做
Core Dump
。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit
命令改变这个限制,允许产生core文件。 首先用ulimit
命令改变Shell进程的Resource Limit
,允许core文件最大为1024K:ulimit -c 1024
总结起来就是:因为操作系统是给进程发信号的,操作系统发现进程要崩溃了,在崩溃之前会将内存中的重要信息转储到磁盘上。并且会把core dump标志位设置为1。
云服务器由于是线上环境,这个功能是关闭的,并且生成的文件大小也是关闭的。这是因为:
- 保护服务器
云服务器默认是关闭Core Dump的,这是因为当发生段错误时,会在磁盘之中生成临时文件,而我们的服务器出现了错误,一般都是先让服务器先恢复使用再进行错误的调式。
如果服务器重启就发生错误,这样会导致生成很多临时文件,那么生成的临时文件将磁盘堆满,甚至将系统盘堆满,那么我们的系统就会出错,导致无法第一时间恢复使用。- 并不是所有的信号都需要Core Dump
我们进程退出的时候,会有一个输出型参数status,低8位中的低7位表示退出信号,第8位表示Core Dump,如果为0则表示不需要,为1表示需要。比如我们的kill -9
号信号,系统直接终止进程,是不需要调式的。
此时core file size
这个文件的大小是0,通过ulimit -c 10240
指令可以设置其大小为10240:
通过下面代码来验证,由于对空指针解引用,因此会在第5行崩溃:
这里其实可以看到从操作系统发现异常到发信号终止这个程序,这期间是有时间窗口的,进程并没有立即处理这个信号。
此时内存中的重要信息转储到磁盘上:
这个文件后面的数字代表的是形成这个core文件的进程pid。
这个文件是给调试器看的,在调试时输入core-file core文件
就可以定位到出错的位置和收到的信号了。这种调试方式叫做事后调试。
小结
先对上面的内容进行小结:
- 由于操作系统是进程的管理者,所有信号产生,最终都要由操作系统来进行执行。
- 信号的处理并不是立即处理的,而是在合适的时候。
- 信号如果不是被立即处理,那么信号是需要暂时被进程记录下来的。
- 一个进程在没有收到信号的时候,自己应该知道对合法信号作何处理。
五、信号的流程
- 实际执行信号的处理动作称为信号递达(Delivery)。
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
5.1.信号的保存和发送
信号是给进程发的,而进程中有task_struct
结构体,该结构体中存在一个32位的位图(默认初始化为0),如果操作系统想给该进程发送信号,只需要将该进程的位图指定的为修改成1即可:
5.2.信号在内核中的表示
我们的内核之中有两张结构一样的位图,来表示当前信号是否被阻塞,和是否处于未决状态,还有一个函数指针表示处理的动作信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达,信号标志才会被清除
- block表和pending表在结构上是一样的,但是比特位的含义不一样
- block位图:记录当前信号是否被阻塞(屏蔽),0表示未阻塞,1表示阻塞。阻塞意为着信号永远也不会递达,也就是说永远都不会执行handler表中的函数
- pending位图:保存当前的信号(未决),1表示信号已经产生(处于未决状态),0表示信号未产生
- handler:存储函数指针的数组,表示某个信号处理时的默认动作,SIG_DFL表示默认处理,SIG_IGN表示忽略该信号,其它表示自定义处理
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。
信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。
在上图的例子中:
SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
SIGQUIT3号信号会被阻塞,当前没有产生,如果信号产生了,并且解除阻塞,它的处理动作是用户自定义的处理动作。
信号也有可能处于未被阻塞(block表为0),但是未决(pending表为1)的状态,因为进程收到信号并不是立即被处理的。
综上,一个进程是通过三张表来完成一个信号处理的:前两张表是位图结构,后一张表示数组结构。
5.2.1.普通信号易丢失
由于普通信号是由位图记录的,只能记录一次,并不能记录个数,因此会发生信号的丢失。
而实时信号是由链表保存的,实时性比较强,信号来了就会立即处理,并且链表也会被管理起来,因此不易丢失
六、信号集操作函数
6.1.sigset_t信号集
每个信号的阻塞或未决都是由一个比特位来表示的,不是0就是1,因此未决和阻塞标志可以使用同一样数据类型sigset_t来进行存储
sigset_t被称为信号集,表示每个信号是有效还是无效
因此用户可以通过函数修改这个信号集然后填到block表或pending表中,修改这两个表。
在阻塞状态中,有效(1)、无效(0)表示是否被阻塞,阻塞信号集(block表)也被叫做当前进程的信号屏蔽字
在未决状态中,有效(1)、无效(0)表示信号是否处于未决状态
6.2.信号集操作函数
sigset_t是表示信号有效、无效状态的信号集,这个类型的实现是由系统来决定的,因此使用者只能调用下列函数的接口来操作sigset_t变量,而不是直接对其内部数据进行解释(比如,直接打印sigset_t类型变量,或者做位运算操作,这些都是没有意义的):
#include <signal.h>
int sigemptyset(sigset\_t \*set);//清空信号集,使其比特位全部置为0,表示该信号集不包含 任何有效信号
int sigfillset(sigset\_t \*set);//把所有比特位全部置为1,表示该信号集的有效信号包括系统支持的所有信号。
int sigaddset (sigset\_t \*set, int signo);//向信号集中添加signo信号(将比特位由0设置为1)
int sigdelset(sigset\_t \*set, int signo);//向信号集中删除signo信号(将比特位由1设置为0)
int sigismember(const sigset\_t \*set, int signo);//判断signo信号是否在信号集中
所有信号的处理动作不能使用按位与按位或这样的动作(因为不同的系统sigset_t的实现方式不同),必须使用这些操作函数。
6.3.信号屏蔽字更改函数sigprocmask
block表有个专业的名称叫做信号屏蔽字
#include <signal.h>
int sigprocmask(int how, const sigset\_t \*set, sigset\_t \*oldset);
how:一共会被设置为三种值:
1.SIG_BLOCK:set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask|set(老位图|新位图)
2.SIG _UNBLOCK: set包含了,当前希望从信号屏蔽字解除阻塞的信号,想当于mask=mask&~set
3.SIG_SETMASK:设置当前信号屏蔽字为set所指向的值,相当于mask=set
set:
新的信号屏蔽字位图
oldset:
输出型参数,老的位图,用来备份的
返回值:
成功返回0,失败返回-1
6.4.获取当前未决信号集函数sigpending
#include <signal.h>
int sigpending(sigset\_t \*set);
set:输出型参数,获取当前信号集的pending位图
返回值:
成功返回0,失败返回-1
以下面的代码演示这俩函数:
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
void handler(int signo){
printf("catch a signal: %d\n",signo);
}
void show\_pending(sigset\_t\* pending){
int sig=1;
for(;sig<=31;sig++){
if(sigismember(pending,sig)){//如果sig信号在pending中
printf("1");
}
else{
printf("0");
}
}
printf("\n");
}
int main(){
signal(2,handler);//捕捉并自定义2号信号
sigset\_t pending;
sigset\_t block,oblock;
sigemptyset(&block);
sigemptyset(&oblock);
sigaddset(&block,2);//向block信号集中添加2号信号
sigprocmask(SIG_SETMASK,&block,&oblock);//将当前的信号屏蔽字设置为2号
int count=1;
while(1){
sigemptyset(&pending);//清理信号集
sigpending(&pending);//获取当前pending信号集
show\_pending(&pending);//显示pending信号
sleep(1);
count++;
if(count==10){
//5秒后接触2号信号的阻塞
printf("recover sig mask!\n");
sigprocmask(SIG_SETMASK,&oblock,NULL);
}
}
}
七、信号的捕捉
操作系统向进程发出信号,进程并不是立即执行信号的,而是在合适的时候,这个合适的时候是信号被递达的时候。
一个信号递达的时间点,是在内核态切换回用户态时,进行信号相关检测的时候。
7.1.用户态、内核态
每个进程都有自己的用户区,用户区指向的映射区域是不一样的。但是内核区的代码和数据只有一份,所有进程的内核区映射是一样的,因为操作系统只有一个。
用户态:
当进程执行我们自己写的代码的时候,比如在栈上定义一个变量,写一个while循环,这时候进程就处于用户态
内核态:
当调用系统函数接口的时候,当前进程时间片到了,开辟一个空间分配内存等等都会切换到内核态。
用户态切换到内核态的三种情况:
- 系统调用,用户态进程主动要求切换到内核态的一种方式,用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作,系统调用本身就是中断,但是软件中断,跟硬中断不同。
- 异常:如果当前进程运行在用户态,如果这个时候发生了异常事件,就会触发切换。例如:缺页异常。
- 外设中断:当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等;时间片结束以后发生中断。
当一个进程执行系统调用接口时,该进程的状态会由用户态变成内核态,该进程不再是当前的用户进程,而是操作系统。换句话说,当处于内核态时,进程其实只是一个外壳,本质是操作系统。
CPU如何切换用户态和内核态:
CPU中有一个标志字段,标志着线程的运行状态。用户态和内核态对应着不同的值,用户态为3,内核态为0。所以当执行系统调用时,操作系统会将CPU的执行模式由用户改成内核。
7.2.信号捕捉流程
为什么需要这样来回的切换呢?
用户切内核:
因为操作系统的代码用户是没有权限去执行的,比如我们调用函数printf,但是底层实际是系统调用接口write在用户层面只能执行用户级页表映射的区域,而操作系统的内核页表映射的区域,用户态是无法访问的。
内核切用户:
内核是有权限执行用户态的代码的,如果signal信号处理动作是非法操作,而内核态的权限又很高,操作系统和其他程序就无法终止这个动作。
信号是如何检测的?
内核态(操作系统)的壳是当前进程,使用的是当前进程的的地址空间。因此可以通过当前地址空间找到当前进程,找到当前进程的PCB,然后访问block、pending位图,获取信号信息
这也就解释了当代码在上面就已经发生了段错误时,为什么还是以执行下面的输出语句呢?
这是因为,上面的代码都在用户区,发生错误的时候操作系统发送信号,信号的处理并不是及时的信号的处理,而是在内核态转化成用户态的时候被处理。
而printf
的底层也调用了操作系统接口wirte
,是在内核态时进行处理的,因此将“run here”能够被打印出来。
7.3.信号捕捉函数 sigaction
这个函数和signal
函数的定位是一样的,只是这个函数的功能更丰富一些。
网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
这是因为,上面的代码都在用户区,发生错误的时候操作系统发送信号,信号的处理并不是及时的信号的处理,而是在内核态转化成用户态的时候被处理。
而printf
的底层也调用了操作系统接口wirte
,是在内核态时进行处理的,因此将“run here”能够被打印出来。
7.3.信号捕捉函数 sigaction
这个函数和signal
函数的定位是一样的,只是这个函数的功能更丰富一些。
网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!