目录
1. 内核态和用户态
1.1 内核态和用户态概念
1.2 内核态和用户态转化
2. 处理信号
2.2 捕捉信号
2.2 系统调用sigaction
3. 不可重入函数
4. volatile关键字
5. SIGCHLD信号(了解)
6. 笔试选择题
答案及解析
本篇完。
前一篇问到信号的处理是否是立即处理的?在合适的时候才处理。
合适的时候是什么时候?这首先就要先看看什么是内核态和用户态:
用户为了访问内核或者硬件资源,必须通过系统调用才能完成访问。虽然系统调用是在我们的代码中写的,也就是用户在使用,但是具体的执行者是内核,也就是操作系统。现在是知道了什么是用户态,什么是内核态,但是操作系统是怎么知道当前进程的身份状态的呢?
CPU中的寄存器虽然只有一套,但是有很多,有可见寄存器,如eax,ebx等等,还有很多的不可见寄存器,凡是和当前进程强相关的,都属于当前进程的上下文数据。
如上图,有专门用来存放当前进程PCB指针的寄存器。也有专门存放当前进程页表指针的寄存器。
CR3寄存器:专门用来表征当前进程的运行级别的。
0:表示内核态,此时访问的是内核资源或者硬件。
3:表示用户态,此时执行的是用户层的代码。
操作系统是一个进行软硬件资源管理的软件,它很容易就可以获取到CPU中CR3寄存器中是0还是3,从而知道当前是用户态还是内核态。
执行系统调用时,执行者是操作系统,而不是用户。那么又存在一个问题,一个进程是怎么跑到操作系统中执行代码的呢?
对进程地址空间进行一个补充介绍:
我们之前一直所说的页表都是用户级页表,每个进程都有一个。
进程地址空间的大小一共有4GB,我们之前谈论的只有0~3GB,这3GB的空间属于用户空间,用来存放用户的代码,数据等。为了保证进程的独立性,每个进程都有一个进程地址空间,都有一个用户级页表。
还有一共内核级页表,所有进程共用一份。
进程地址空间中的3~4GB空间,是不允许用户访问的,因为这1GB空间中的数据等,通过内核级页表和内存中的操作系统相映射,属于内核级别的。因为内存中只存在一份内核,所以所有进程的虚拟地址空间的这1GB空间都通过同一份内核级页表和内存中的内核相映射。
- 每一个进程地址空间中的3~4GB的内容都是一样的,因为它们都通过同一个内核级页表和内存中的内核相映射。
还记得动态链接吗?通过代码段的位置无关码跳转到共享区从内存中映射过来的动态库来执行相应的方法。系统调用和它的原理一样:
此时又有一个问题,为什么我们的代码中不能访问这3~4GB的空间,而系统调用就跳转到这1GB的内核空间中进行访问了呢?我们都是用户的代码啊?
因为从代码段跳转到内核空间中后,CPU中的CR3寄存器从3变成了0。意味着进程运行级别从用户态变成了内核态,也就是执行者从用户变成了操作系统,所以可以对这1GB的内核空间进行访问。
系统调用接口的起始位置,会将CR3寄存器中的数据从3变成0,完成从用户态向内核态的转变。
所以说,系统调用前一部分是由用户在执行,其余部分由操作系执行。
此时再来理解信号处理的时机:从内核态返回到用户态,这句话的含义:必然曾经进入到了内核态,而进入内核态的方式很多,比如进程切换,只有操作系统才有权力将进程从CPU上剥离下来换上另一个进程。还有系统调用,等等方式。
以我们最熟悉的系统调用为例:
以黑色长线为界,上面是用户态,下面是内核态。
上面过程的伪代码形式:
上面过程中存在一个问题,在执行自定义处理方式的时候,为什么必须从内核态切换成用户态去执行用户定义的处理方式呢?不能直接以内核态的身份去执行吗?
不可以。理论上是绝对可以实现的,因为内核态比用户态高,高级别去处理低级别肯定是可以的。但是操作系统不相信任何人,如果自定义处理方式中有用户的恶意代码,而此时又以操作系统身份去执行,那么就会导致问题。所以必须得切换到用户身份去执行自定义处理方式才能保证系统的安全。
两个独立的流程:
此时就存在了两个流程,一个是main函数所在的执行流程,一个是自定义处理方式的执行流程:
上面整个过程可以看成一个无穷大符号加一条线,线的上边是用户态,下边是内核态。每经过一次黑线就会发生一次身份状态的改变,一共改变了四次。
(这个图面试画出来并能讲解的话,上大分)
上面这种自定义处理方式是最复杂的情况,如果是SIG_DFL(默认处理方式)和SIG_IGN(忽略方式),以内核态身份就可以处理,然后就可以直接返回到用户代码中系统调用的位置,少了两次身份的转变。因为默认方式和忽略方式是被写入到操作系统中的,被操作系统所信任的方式。
如果信号的处理动作是用户自定义函数, 在信号递达时就调用这个函数, 这称为捕捉信号。由于信号处理函数的代码,是在用户空间的, 处理过程比较复杂,举例如下: 用户程序注册了 SIGQUIT 信号的处理函数 sighandler 。当前正在执行 main函数, 这时发生中断或异常切换到内核态。在中断处理完毕后要返回用户态的 main 函数之前检查到有信号SIGQUIT递达。内核决定返回用户态后不是恢复 main 函数的上下文继续执行, 而是执行 sighandler 函数, ighandler和main 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系, 是两个独立的控制流程。sighandler 函数返回后自动执行特殊的系统调用sigreturn 再次进入内核态。 如果没有新的信号要递达, 这次再返回用户态就是恢复 main函数的上下文继续执行了。
捕捉信号和内核态和用户态关系很大。
sigaction函数可以读取和修改与指定信号相关联的处理动作。man sigaction:
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags字段包含一些选项,这里的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数,这里不详细解释这两个字段。
这里创建个Linux_20在里面写代码捕捉一下2号信号:
Makefile:(-fpermissive是取消下面代码强转报错)
mysignal:mysignal.cc
g++ -o $@ $^ -std=c++11 -fpermissive
.PHONY:clean
clean:
rm -f mysignal
mysignal.cc:
#include
#include
#include
using namespace std;
void handler(int signum)
{
cout << "获取了一个信号: " << signum << endl;
}
int main()
{
signal(2, SIG_IGN);
cout << "getpid: " << getpid() << endl;
// 内核数据类型,用户栈定义的
struct sigaction act, oact;
act.sa_flags = 0; // 把这个赋为0,其它不用管
sigemptyset(&act.sa_mask); // 清空
act.sa_handler = handler;
// 设置进当前调用进程的pcb中
sigaction(2, &act, &oact);
cout << "default action : " << (int)(oact.sa_handler) << endl;
while (true)
{
sleep(1);
}
return 0;
}
在handler里睡眠10秒:
在进程开始运行后,我们在10s内发送了很多次2号信号,但发现只能10秒捕捉一次。
当递达第一个2号信号的时候,同类型的信号无法被递达。
因为当前信号在被捕捉的时候,系统会自动将当前信号加入到进程的信号屏蔽字,也就是将block对应的比特位置位,然后将pending表对应比特位清空,再去进行递达。
但是第二个2号信号在第一个信号被捕捉的时候会将对应pending位图的比特位置位。
所以当第一个2号信号处理完毕以后,解除对2号信号的屏蔽后,第二个2号信号就会被递达。除了这两个2号信号,其余的2号信号都被舍弃了。
进程处理信号的原则是串行的处理同类型的信号,不允许递归,所以同类型的多个信号同时产生,最多可以处理两个。上面内容,系统调用signal也可以实现,那么sigaction相对于signal有什么优势呢?刚刚代码中,由于在2号信号的自定义处理中没有结束进程,所以只能用其他信号来结束这个进程,如上面使用的是3号信号,如果想要在捕获2号信号以后,将3号信号也屏蔽了呢?此时就需要设置结构体变量act中的sa_mask成员。
再加一些代码演示一下:
#include
#include
#include
using namespace std;
void showPending(sigset_t *pending)
{
for(int sig = 1; sig <= 31; sig++)
{
if(sigismember(pending, sig)) cout << "1";
else cout << "0";
}
cout << endl;
}
void handler(int signum)
{
cout << "获取了一个信号: " << signum << endl;
sigset_t pending;
int c = 20;
while(true)
{
sigpending(&pending);
showPending(&pending);
c--;
if(c == 0)
{
break;
}
sleep(1);
}
}
int main()
{
signal(2, SIG_IGN);
cout << "getpid: " << getpid() << endl;
// 内核数据类型,用户栈定义的
struct sigaction act, oact;
act.sa_flags = 0; // 把这个赋为0,其它不用管
sigemptyset(&act.sa_mask); // 清空
act.sa_handler = handler;
sigaddset(&act.sa_mask, 3); // 处理2号信号期间随便把一些信号屏蔽
sigaddset(&act.sa_mask, 4);
sigaddset(&act.sa_mask, 5);
sigaddset(&act.sa_mask, 6);
sigaddset(&act.sa_mask, 7);
sigaddset(&act.sa_mask, 8);
// 设置进当前调用进程的pcb中
sigaction(2, &act, &oact);
cout << "default action : " << (int)(oact.sa_handler) << endl;
while (true)
{
sleep(1);
}
return 0;
}
如果只用2号信号和3号信号演示:
当第一次2号信号被捕获后,第二个2号信号虽然被阻塞了,但是它还是让pending位图置位了。
当第一次2号信号被递达完成后,就会递达第二个2号信号。
当第二次2号信号被递达完成后,2号信号的pending位图的比特位是0,所以才递达3号信号。
虽然在捕获2号信号的同时会阻塞3号信号,但是3号的pending位图的比特位仍然被置位了。
在第一个2号信号被捕获的时候,同时阻塞了第二个2号信号和3号信号,此时pending位图的第二个和第三个比特位都是1,但是当第一个2号信号递达完成后,先处理的是第二个2号信号而不是3号信号。
如上图所示链表,在插入节点的时候捕获到了信号,并且该信号的自定义处理方式中也调用了插入节点的函数。
在main函数中,使用insert向链表中插入一个节点node1,在执行insert的时,刚让头节点指向node1以后(如上图序号1),捕获到了信号,进入到了该信号的自定义处理方式中。
在自定义处理方式中,同样调用了insert函数向链表中插入一个节点node2,此时完整的执行了insert函数,但是在头节点和最开始那个节点之间同时有了node1和node2(如上图序号2和3)。
当第二次调用insert中让头节点指向node2后(如上图序号3),流程返回到信号的自定义处理函数中,然后再返回到第一次调用insert处,头节点指向node1(如上图序号4)。
最后可以看到,该链表是丢了一个节点的。
insert函数访问的是一个全局链表,有可能会因为重入和造成错乱,像insert这样的函数就称为不可重入函数。如果一个函数只访问自己的局部变量或参数,则不会造成错乱,此时这样的函数就称为可重入函数。
注意: 可重入和不可重入是函数的特性,是中性的,并不是问题,所以也不需要被解决。我们目前使用的大部分结构都是不可以重入函数。符合以下条件之一的就是不可重入函数:
看段代码:
Makefile:
mysignal:mysignal.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f mysignal
mysignal.cc:
定义全局变量quit,当quit是0的时候,一直进行while循环,当quit变成1的时候,结束循环,进程正常退出。信号2注册自定义处理方式,在函数中将全局变量改成1,让main函数控制的流程正常结束。
#include
#include
#include
using namespace std;
int quit = 0;
void change_quit(int signum)
{
(void)signum;
cout <<"change quit: "<< quit;
quit = 1;
cout << "->" << quit << endl;
}
int main()
{
signal(2, change_quit);
while(!quit);
{
cout << "进程正常退出后:" << quit << endl;
}
return 0;
}
在接收到2号信号后,quit从0变成1,所以main流程也正常结束了,不再循环。
我们的编译器会进行很多的优化,比如debug版本和relase版本中的assert就会被优化。在使用g++编译器的时候,可以指定g++的优化级别。(-O3是最高的优化级别,是大写O字母)
Makefile:
mysignal:mysignal.cc
g++ -o $@ $^ -std=c++11 -O3
.PHONY:clean
clean:
rm -f mysignal
重新编译运行上面代码;
为什么发送2号信号没有退出?
此时可以肯定quit被改成了1,但是while(!quit)还是在循环,没有停下来。
上诉现象的原因是什么?肯定是和优化有关,因为我们加了-O3选项。
quit在物理内存中一定有一块空间,最开始是0。当CPU指向while(!quit);指令的时候,会通过虚拟地址和页表的映射将物理内存中的quit数据取到CPU的寄存器中。当quit被修改后,物理空间中的数据就会从0变成1。
在没有优化前,CPU每次都是从物理内存中拿到quit的数据,再去指向while循环,所以当quit从0变成1后,CPU中寄存器的数据也会及时从0变成1,所以while循环会停下来。
但是采用优化方案后:在main控制的执行流中,quit没有进行修改,也没有写入,只是被读取,所以在第一次将从物理空间读取到寄存器中便不再读取了,每次执行while时候都是使用的寄存器中的quit值,所以始终都是0。在handler执行流中,对quit进行了修改,所以物理内存中的quit从0变成了1。
导致上面现象的原因就是CPU执行while时的quit和物理内存中的quit不是一个值。
为了让CPU的寄存器每次都从物理内存中取数据,使用volatile关键字来修饰这个quit变量。
给英语不好的铁汁贴个图:(我可不说我英语不好.jpg)
volatile作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。
编译运行:
可以看到,此时在handler的执行流中修改了quit值,并且CPU中该值也得到了及时更新,所以程序可以正常结束。
在学习进程控制的时候,使用wait和waitpid系统调用何以回收僵尸进程,父进程可以阻塞等待,也可以非阻塞等待,采用轮询的方式不停查询子进程是否退出。
实际上,子进程的退出并不是悄无声息的,在子进程退出时,会发出SIGCHLD信号给父进程。
写段代码证明一下在子进程退出时,会发出SIGCHLD信号给父进程,mysignal.cc:
#include
#include
#include
using namespace std;
void handler(int signum)
{
cout << "子进程退出: " << signum << " father pid: " << getpid() << endl;
}
int main()
{
signal(SIGCHLD, handler);
if(fork() == 0)
{
cout << "child pid: " << getpid() << endl;
sleep(7); // 7秒后子进程退出
exit(0);
}
while(true)
sleep(1);
return 0;
}
可以看到,子进程在退出时,发出了编号为17的SIGCHLD信号,被父进程捕捉到了。
(如果子进程睡眠很久,然后子进程发送19号暂停信号,子进程也会发出编号为17的SIGCHLD信号)还是证明一下:
以前学的:当子进程退出后,父进程什么都没有干,子进程就会变成僵尸状态。
如果有10个子进程都退出呢? -> while(wait())
如果有10个子进程有5个子进程退出呢? -> vector
如果我们不想等待子进程,并且还想让子进程退出之后,自动释放僵尸子进程呢?:
#include
#include
#include
using namespace std;
// 如果我们不想等待子进程,并且还想让子进程退出之后,自动释放僵尸子进程
int main()
{
signal(SIGCHLD, SIG_IGN); // 手动设置对子进程进行忽略
// 和OS默认是忽略的的不一样 -> 该僵尸就僵尸
if(fork() == 0)
{
cout << "child: " << getpid() << endl;
sleep(7);
cout << "子进程已经退出" << endl;
exit(0);
}
while(true) // 父进程,不关心子进程
{
cout << "father: " << getpid() << " 执行自己的任务" << endl;
sleep(1);
}
return 0;
}
man 7 signal下滑:(看到17号SIGCHLD信号,Ign就是ignore忽略的意思)
当显式SIGCHLD信号使用忽略方式(SIG_IGN)时,退出的子进程就会被自动回收。
1. 以下哪些信号无法被阻塞 [多选]
A.SIGKILL
B.SIGINT
C.SIGSTOP
D.SIGQUIT
2. 以下描述正确的有:[多选]
A.使用kill -l命令可以查看Linux系统中信号的种类
B.使用kill -p命令可以查看Linux系统种的信号种类
C.使用Ctrl+C会使当前终端前台进程退出
D.使用Ctrl+Z会使当前终端前台进程退出
3. 以下描述正确的有
A.未决信号指的是已经被处理的信号
B.未决信号指的是还未被处理的信号
C.同一个信号可以在未决信号集合中添加多次
D.每一个信号处理完毕后都会从pending集合中移除
4. 以下描述正确的有:[多选]
A.若信号被阻塞,则信号将无法添加到未决信号集合中
B.若信号被阻塞,则信号依然可以添加到未决信号集合中
C.若信号被忽略,则信号将无法添加到未决信号集合中
D.若信号被忽略,则信号依然可以添加到未决信号集合中
5. 一个进程无法被kill杀死的可能有哪些?[多选]
A.这个进程阻塞了信号
B.用户有可能自定义了信号的处理方式
C.这个进程有可能是僵尸进程
D.这个进程当前状态是停止状态
6. 以下描述正确的有: [多选]
A.程序运行过程中产生异常会产生信号
B.通过终端键盘Ctlr+C可以产生一个SIGQUIT信号
C.通过系统调用接口raise只能产生一个SIGABRT信号
D.通过alarm接口可以在n秒钟之后产生一个SIGALRM信号
7. 以下描述正确的有:
A.只能使用signal函数自定义信号捕捉函数
B.若当前进程处于阻塞状态,则此时到来的信号暂时无法处理。
C.使用signal函数进行信号捕捉函数修改时,可以指定SIG_DFL设置为忽略处理
D.使用signal函数进行信号捕捉函数修改时,可以指定SIG_IGN设置为忽略处理
8. 以下描述正确的有:
A.所有的未决信号都会立即被处理
B.自定义处理方式的信号会返回用户态执行信号捕捉函数
C.所有的信号处理方式都是在用户态完成信号捕捉的
D.自定义处理方式的信号会在执行完信号捕捉函数后直接返回用户态主控流程
9. 下列选项中,会导致用户进程从用户态切换到内核态的操作是()。
I. 整数除以零
II. sin()函数调用
III. read 系统调用
A.仅 I、 II
B.仅 I、 III
C.仅 II、 III
D.I、 II 和 III
10. 两个线程并发执行以下代码,假设a是全局变量,初始为1,那么以下输出______是可能的?[多选]
void foo(){
a=a+1;
printf("%d ",a);
}
A.3 2
B.2 3
C.3 3
D.2 2
11. 以下描述正确的有 [多选]
A.函数可重入指的是函数中可以在不同的执行流中调用函数会出现数据二义问题
B.函数不可重入指的是函数中可以在不同的执行流中调用函数会出现数据二义问题
C.函数不可重入指的是函数中可以在不同的执行流中调用函数而不会出现数据二义问题
D.函数可重入指的是函数中可以在不同的执行流中调用函数而不会出现数据二义问题
12. 以下描述正确的有:
A.在一个函数中若对局部变量进行了操作,则这个函数一定是不可重入的
B.在一个函数中若对全局变量进行了操作,则这个函数一定是可重入的
C.在一个函数中若对全局变量进行了原子操作,则这个函数一定是可重入的
D.在一个函数中若对局部变量进行了原子操作,则这个函数一定是不可重入的
1. AC
SIGKILL and SIGSTOP不能被捕捉,阻塞,忽略,这里9号信号就是管理员信号,就是防止你把所有信号都设定自定义动作,导致进程不能退出的情况
2. AC
A正确,B错误, 因为kill -l 选项才是查看信号种类的选项方式
C正确,Ctrl+C会向当前终端的前台进程发送SIGINT信号,中断正在运行在前台的程序
D错误 Ctrl+Z会使当前终端的前台进程进入停止态而不是退出
3. B
未决信号指的是,进程收到了信号,被添加到未决信号集合中,但是暂时还没有被处理的信号。因此A错误,B正确
非可靠信号在进行注册时,会查看是否已经有相同信号添加到未决集合中,如果有则什么都不做,因此非可靠信号只会添加一次,因此处理完毕后会直接移除(准确来说是先移除,后处理)。而可靠信号会重复添加信号信息到sigqueue链表中,相当于可靠信号可以重复添加,处理完毕后,因为有可能还有相同的信号信息待处理,因此并不会直接移除,而是检测没有相同信号信息后才会从pending集合中移除
因此C和D选项错误
4. BD
信号阻塞就是可以继续接受信号,但是暂时不处理指定信号,实现原理就是在block阻塞信号集合中标记指定的信号,被标记的信号收到后暂时不处理
因此A错误,B正确
信号忽略,只是说信号的处理方式就是忽略处理,并不表示不再接收指定信号
因此C错误,D正确
5. ABCD
A正确 信号被阻塞,则暂时不被处理(SIGKILL/SIGSTOP除外,因为无法被阻塞,这里说的是可能性,因此不做太多纠结)
B正确 自定义处理之后,信号的处理方式有可能不再是进程退出
C正确 僵尸进程因为已经退出,因此不做任何处理
D正确 进程停止运行,则将不再处理信号
6. AD
A正确,程序运行异常退出,也是系统检测到异常后向进程发送指定的异常信号,程序对信号做出的处理方式就是退出所导致的
B错误 Ctrl+C产生SIGINT信号,而并非SIGQUIT信号
C错误 raise接口可以向当前调用进程发送任意信号
D正确 alarm函数相当于设置一个定时器,在n秒钟后向进程发送SIGALRM信号
7. D
A sigaction接口也可以自定义信号处理方式
B 信号会打断进程当前的阻塞状态去处理信号
C SIG_DFL为信号默认处理方式
8. B
A错误 未决信号是在从程序运行在内核态返回用户态的时候进行处理
B正确 自定义处理方式会执行用户定义的处理函数,因此会返回用户态运行
C错误 只有自定义处理方式的信号会在用户态进行处理
D错误 自定义处理方式的信号会在执行完信号捕捉函数后先返回内核态
9. B
程序运行从用户态切换到内核态的操作:中断/异常/系统调用
I 会导致程序异常(分母不能为0)
II 库函数并不会引起运行态的切换
III 系统调用接口
因此只有 I 和 III 符合条件。
10. ABCD
当函数内的操作非原子时因为竞态条件造成的数据二义
a=a+1 和 printf("%d", a) 之间有可能会被打断
a初始值为1
当A线程执行完a=a+1后a是2,这时候打印会打印2, 线程B执行时+1打印3
当A线程执行完a=a+1后a是2,这时候时间片轮转到B线程,进行+1打印3, 然后时间片轮转回来也打印3
这两个是比较显而易见的,但是还有一些 特殊情况需要注意
- printf函数实际上并不是直接输出,而是把数据放入缓冲区中,因此有可能A线程将打印的2放入缓冲区中,还没来得及输出,这时候B线程打印了3,时间片轮转回来就会后打印2
- a=a+1本身就不是原子操作因此有可能同时进行操作,都向寄存器中加载1进去,然后进行+1后,将2放回内存,因此有可能会打印2和2
11. BD
函数的重入指的是一个函数在不同执行流中同时进入运行,其中不可重入指的是一旦重入就有可能会出问题,而可重入就是不管怎么重入都不会有特殊影响
根据对函数可重入与不可重入的理解分析,正确选项为 B和D 选项
12. B
函数是否可重入的关键在于函数内部是否对全局数据进行了不受保护的非原子操作,其中原子操作指的是一次完成,中间不会被打断的操作,表示操作过程是安全的
因此如果一个函数中如果对全局数据进行了原子操作,但是因为原子操作本身是不可被打断的,因此他是可重入的
A错误,函数的重入对局部变量并无影响
B错误,全局变量的操作若并非原子操作,则有可能会出问题
C正确
D错误,局部变量的操作本身就不影响重入,所以函数是可重入的
下一部分开始Linux系统的最后一大部分:多线程。
下一篇:零基础Linux_21(多线程)页表详解+轻量级进程+pthread_create。