【Linux学习笔记】信号的产生和用户态和内核态
🔥个人主页:大白的编程日记
🔥专栏:Linux学习笔记
文章目录
前言
哈喽,各位小伙伴大家好!上期我们讲了Linux基本指令及其分析(一) 今天我们讲的是Linux基本指令及其分析(二)。话不多说,我们进入正题!向大厂冲锋!
1.保存信号
当前阶段
1.1信号其他相关常见概念
. 实际执行信号的处理动作称为信号递达(Delivery)
: 信号从产生到递达之间的状态,称为信号未决(Pending)。
进程可以选择阻塞 (Block)某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
. 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
1.2在内核中的表示
信号在内核中的表示示意图
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,
SIGHUP
信号未阻塞也未产生过,当它递达时执行默认处理动作。SIGINT
信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。SIGQUIT
信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler
。
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。
Linux是这样实现的:
- 常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。。
1 // 内核结构 2.6.18
2 struct task_struct {
3
4 /\* signal handlers \*/
5 struct sighand_struct \*sighand;
6 sigset_t blocked
7 struct sigpending pending;
8
9 }
10
11 struct sighand_struct {
12 atomic_t count;
13 struct k_Sigaction action[_NSIG]; // #define _NSIG 64
14 spinlock_t siglock;
15 };
16
17 struct __new_sigaction {
18 __sighandler_t sa_handler;
19 unsigned long sa_flags;
20 void (\*sa_restorer)(void); /\* Not used by Linux/SPARC \*/
21 _new_sigset_t sa_mask;
22 };
23
24 struct k_sigaction
25 struct __new_sigaction sa;
26 void _user \*ka_restorer;
27 };
28
29 /\* Type of a signal handler.
30 typedef void (\*__sighandler_t)(int)
31
32 struct sigpending
33 struct list_head list;
34 sigset_t signal;
35 };
1.3 sigset_t
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,
- 这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,
- 而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
- 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
1.4信号集操作函数
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。
1 #include <signal.h>
2 int sigemptyset(sigset_t \*set);
3 int sigfillset(sigset_t \*set);
4 int Sigaddset(sigset_t \*set, int signo);
5 int sigdelset(sigset_t \*set, int signo);
6 int sigismember(const sigset_t \*set, int signo);
.函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。函数sigfilset初始化set所指向的信号集,使其中所有信号的对应bit置位,
表示该信号集的有效信号包括系统支持的所有信号。
. 注意,在使用sigset_t类型的变量之前,一定要调 用sigemptyset
或sigfillset
做初始化,使信号集处于确定的状态。初始化sigset_t
变量之后就可以在调用sigaddset
和sigdelset
在该信号集中添加或删除某种有效信号。
这四个函数都是成功返回0,出错返回-1。sigismember
是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。
1.4.1 sigprocmask
调用函数 sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
1 #include <signal.h>int
sigprocmask(int how, const sigset_t \*set, sigset_t \*oset);
3 返回值:若成功则为0,若出错则为-1
- 如果set是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。
- 如果set是非空指针,则 更改进程的信 号屏蔽字,参数how指示如何更改。
- 如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号,相当 于mask=mask|set |
SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当 于mask=mask&~set |
SIG_SETMAsK设置当前信号屏蔽字为set所指向的值,相当于mask=set |
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
1.4.2 sigpending
1 #include <signal.h>
2 int sigpending(sigset_t \*set);
3
4 读取当前进程的未决信号集,通过set参数传出。
5 调用成功则返回0,出错则返回-1
下面用刚学的几个函数做个实验。程序如下:
1 #include <iostream>
2 #include <unistd.h>
3 #include <cstdio>
4 #include <sys/types.h>
5 #include <sys/wait.h>
6
7 void PrintPending(sigset_t &pending)
8{
9 $< <$
10 $=$ $> = ~ 1$
11 {
12
13 {
14 std::cout << 1
15 }
16 else
17 {
18
19 }
20 }
21 std::cout <<
22 }
23
24 void handler(int signo)
25{
26 std::cout << signo << " 号信号被递达!!!" << std::endl;
27
28 std::cout << - - << std::endl;
29 sigset_t pending;
30 sigpending(&pending);
31 PrintPending(pending);
32 std::cout << - << std::endl;
33}
34
35 int main()
36{
37 //0。捕捉2号信号
38 signal(2,handler);// 自定义捕捉
39 //signal(2,SIG_IGN);// 忽略-个信号
40 //signal(2,SIG_DFL);// 信号的默认处理动作
41
42 //1。屏蔽2号信号
43 sigset_t block_set,old_set;
44 sigemptyset(&block_set);
45 sigemptyset(&old_set);
46 sigaddset(&block_set,SIGINT);// 我们有没有修改当前进行的内核
47 //1.1设置进入进程的Block表中
48 sigprocmask(SIG_BL0cK,&block_set,&old_set);// 真正的修改当前进行的内核block
表,完成了对2号信号的屏蔽!
49
50 int cnt = 15;
51 while (true)
52 {
53 //2。获取当前进程的pending信号集
54 sigset_t pending;
55 sigpending(&pending) ;
56
57 //3。打印pending信号集
58 PrintPending(pending);
59 cnt--;
60
61 //4。解除对2号信号白
62 if (cnt $\begin{array} { r l } { \mathbf { \omega } = } & { { } \odot } \end{array}$ )
63 {
64
65 sigprocmask(SIG_SETMASK, &old_set, &block_set);
66 }
67
68 sleep(1);
69 }
70}
71 \$ ./run
72 curr process[448336]pending: 000000000000000000000000000000
73 curr pr0cess[448336]pe0nding: 0000000000000000000000000
74 ^Ccurr process[448336]pending: 0000000000000000000000000000010
75 curr process[448336]pending: 0000000000000000000000000000010
76 cur pr0ocess[448336]pending: 000000000000000000000000010
77 cur process[448336]pending: 000000000000000000000000010
78 curr pr0ocess[448336]pending: 00000000000000000000000010
79 cur pr0cess[448336]pending: 000000000000000000000000010
80 curr process[448336]pending: 0000000000000000000000000000010
程序运行时,每秒钟把各信号的未决状态打印一遍,由于我们阻塞了SIGINT信号,按Ctrl-C将会使SIGINT信号处于未决状态,按Ctrl-\仍然可以终止程序,因为SIGQUIT
信号没有阻塞。
2.捕捉信号
当前阶段
2.1信号捕捉的流程
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。
由于信号处理函数的代码是在用户空间的,处理过程比较较复杂,举例如下:
- 用户程序注册了 SIGQUIT 信号的处理函数 sighandler。
- 当前正在执行 main 函数,这时发生中断或异常切换到内核态。
- 在中断处理完毕后要返回用户态的 main 函数之前检查到有信号 SIGQUIT 递达。
- 内核决定返回用户态后不是恢复 main 函数的上下文继续执行,而是执行 sighandler 函数, sighandler 和 main 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
- sighandler 函数返回后自动执行特殊的系统调用 sigreturn 再次进入内核态。
- 如果没有新的信号要递达,这次再返回用户态就是恢复 main 函数的上下文继续执行了。
2.2 硬件中断
2.2.1 硬件中断
- 中断向量表就是操作系统的一部分,启动就加载到内存中了
- 通过外部硬件中断,操作系统不需要对外设进行任何周期性的检测或者轮询
- 由外部设备触发的,中断系统运行流程,叫做硬件中断
// Linux内核0.11源码
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,&irq); // 设置协处理器的陷阱门。
outb_p(inb_p(0x21)&0xfb,0x21); // 允许主8259A 芯片的IRQ2 中断请求。
outb(inb_p(0xA1)&0xdf,0xA1); // 允许从8259A 芯片的IRQ13 中断请求。
set_trap_gate(39,¶llel_interrupt); // 设置并行口的陷阱门。
}
void rs_init (void)
{
set_intr_gate (0x24, rs1_interrupt); // 设置串行口1 的中断门向量(硬件IRQ3 值)。
set_intr_gate (0x23, rs2_interrupt); // 设置串行口2 的中断门向量(硬件IRQ3 值)
int (*tty_table[1]) (read_q.data); // 初始化串行口1 (.data 是端口号)。
init (tty_table[0] = rs_init);
outb (0x21 & mcr_t, 0x21); // 允许主8259A 芯片的IRQ3 , IRQ4 中断信号请求
}
- 进程可以在操作系统的指挥下,被调度,被执行,那么操作系统自己被谁指挥,被谁推动执行呢?
- 外部设备可以触发硬件中断,但是这个是需要用户或者设备自己触发,有没有自己可以定期触发的设备?
2.3.2时钟中断
问题: 进程可以在操作系统的指挥下,被调度,被执行,那么操作系统自己被谁指挥,被谁推动执行呢?.
外部设备可以触发硬件中断,但是这个是需要用户或者设备自己触发,有没有自己可以定期触发的设备?
// Linux内核0.11
sched_init(); // 调度程序初始化(闭断了任务0 的tr, ldt)。(kernel/sched.c)
void sched_init(void)
{
set_intr_gate(0x20, timer_interrupt);
// 修改中断控制器屏蔽码,允许时钟中断。
outb(inb_p(0x21) & ~0x01, 0x21);
// 设置系统调用中断门。
set_system_gate(0x80, &system_call);
}
// system_interrupt.s
_timer_interrupt:
...
// do_timer(CPL)执行任务切换、计时等工作,在kernel/sched.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.3.3 循环
如果是这样,操作系统不就可以躺平了吗?对,操作系统自己不做任何事情,需要什么功能,就向中断向量表里面添加方法即可。
- 操作系统的本质:就是一个死循环!
void main(void) /* 这里确实是void,并没错。 */
{
...
* 注意!!对于任何其它的函数,'pause()'将意味着我们必须等待收到一个信号才会返回
* 回到被运行状态,但任务0 (task0),是唯一的系统情况(参见'schedule()'),因为任
* 务0 在任何空闲时间里都会被激活(当没有其它任务在运行时),
* 因此对于任务0'pause()'仅仅意味着我们返回来查看是否有其它任务可以运行,如果没有
* 有的话我们就回到这里,一直循环执行'pause()'。
*/
for (;;)
pause();
}
2.3.4 软中断
- 上述外部硬件中断,需要硬件设备触发。
- 有没有可能,因为软件原因,也触发上面的逻辑?有!
- 为了让操作系统支持进行系统调用,CPU也设计了对应的汇编指令(int 或者 syscall),可以让CPU内部触发中断逻辑。
所以:
问题:
用户层怎么把系统调用号给操作系统? - 寄存器(比如EAX)
操作系统怎么把返回值给用户? - 寄存器或者用户传入的缓冲区地址
系统调用的过程,其实就是先int 0x80、syscall陷入内核,本质就是触发软中断,CPU就会自动执行系统调用的处理方法,而这个方法会根据系统调用号,自动查表,执行对应的方法
系统调用号的本质:数组下标!
这样,操作系统,就可以在硬件时钟的推动下,自动调度了。
所以,什么是时间片?CPU为什么会有主频?为什么主频越快,CPU越快?
可是为什么我们用的系统调用,从来没有见过什么
int 0x80
或者syscall
呢?都是直接调用上层的函数的啊?那是因为Linux的gnu C标准库,给我们把几乎所有的系统调用全部封装了。
2.3.5 缺页中断:内存碎片处理?除零/野指针错误?
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,&irq); // 设置协处理器的陷阱门。
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内部的软中断,比如除零/野指针等,我们叫做异常。(所以,能理解“缺页异常”为什么这么叫了吗?)
2.4 加何理解内核态和用户态
结论:
- 操作系统无论怎么切换进程,都能找到同一个操作系统!换句话说就是操作系统系统调用方法的执行,是在进程的地址空间中执行的!
关于特权级别,涉及到段,段描述符,段选择子,DPL,CPL,RPL等概念,而现在芯片为了保证兼容性,已经非常复杂了,进而导致OS也必须得照顾它的复杂性,这块我们不做深究了。
- 用户态就是执行用户[0,3]GB时所处的状态
- 内核态就是执行内核[3,4]GB时所处的状态。
- 区分就是按照CPU内的CPL决定,CPL的全称是Current Privilege Level,即当前特权级别。
- 一般执行 int 0x80 或者 syscall 软中断,CPL会在核心态后自动变更
- 这样会不会不安全?
3. 可重入函数
- main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核态,再次回到用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态继续从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
4. volatile
该关键字在C当中我们已经有所涉猎,今天我们站在信号的角度重新理解一下
[hb@localhost code_test]$ cat sig.c
#include <stdio.h>
#include <signal.h>
volatile int flag = 0;
void handler(int sig)
{
printf("change flag 0 to 1\n");
flag = 1;
}
int main()
{
signal(2, handler);
while(1){
printf("process quit normal\n");
return 0;
}
}
[hb@localhost code_test]$ cat Makefile
sig:sig.c
gcc -o sig sig.c -O2
.PHONY:clean
clean:
rm -f sig
[hb@localhost code_test]$ .(sig
^Cchange flag 0 to 1
^Cchange flag 0 to 1
- volatile 作用:保持内存的实时性,告知编译器,该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
5. SIGCHLD信号 - 选学了解
- 第一章讲过用wait和waitpid函数来管理子进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。其实,子进程在终止时会给父进程发
SIGCHLD
信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用waitpid清理子进程。
[hb@localhost code_test]$ cat sig.c
#include <stdio.h>
#include <signal.h>
volatile int flag = 0;
void handler(int sig)
{
printf("change flag 0 to 1\n");
flag = 1;
}
int main()
{
signal(2, handler);
while(1){
if(flag){
printf("process quit normal\n");
return 0;
}
}
}
[hb@localhost code_test]$ cat Makefile
sig:sig.c
gcc -o sig sig.c -O2
.PHONY:clean
clean:
rm -f sig
[hb@localhost code_test]$ .(sig
^Cchange flag 0 to 1
^Cchange flag 0 to 1
后言
这就是信号的产生和用户态和内核态。大家自己好好消化!今天就分享到这! 感谢各位的耐心垂阅!咱们下期见!拜拜~