当前位置: 首页 > news >正文

Linux -- 信号【下】

目录

一、操作系统是怎么运行的

1、硬件中断

1.1 概念

1.2 原理

1.3 中断向量表

1.4 CPU中断响应全流程

2、时钟中断

2.1 概念

2.2 Linux 0.11 时钟中断机制详解

2.3  时间片与调度机制

3、死循环

3.1 Linux 0.11 的主函数结构

3.2 pause() 的内部机制

4、软中断

4.1 概念

4.2 系统调用表

4.3 系统调用原理

4.4 缺页中断?内存碎片处理?除零野指针错误?

4.5 中断、陷阱、异常、终止

二、深入理解用户态、内核态

1、内核页表

2、特权级

3、用户态到内核态切换底层机制

三、可重入函数

四、volatile

五、SIGCHLD信号


一、操作系统是怎么运行的

# 我们先引入一些前置知识,如针脚、中断、中断控制器、中断向量表:

1、硬件中断

1.1 概念

# 硬件中断是外部设备(如键盘、磁盘、网卡)向 CPU 发出的信号,表示需要处理某个事件或请求服务。这是一种异步事件,可以在任何时候发生,打断 CPU  当前正在执行的任务,其核心目的是避免 CPU 轮询外设造成的资源浪费

  • 中断请求(IRQ:硬件设备发出的中断信号

  • 中断向量:唯一标识中断类型的编号

  • 中断处理程序:对应每个中断向量的处理函数

  • 中断控制器:管理和优先级排序多个中断请求的硬件

1.2 原理

# 触发原理:当设备完成操作(如磁盘读取结束)或状态变化(如按键按下),通过 中断控制器(如8259A) 向 CPU 发送中断请求(IRQ)。

# 硬件协作流程

📌 关键设计CPU仅在指令边界检查中断,确保指令原子性。

//Linux内核0.11源码
void trap_init(void)
{int i;set_trap_gate(0,&divide_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,&irq13);// 设置协处理器的陷阱⻔。outb_p(inb_p(0x21)&0xfb,0x21);// 允许主8259A 芯⽚的IRQ2 中断请求。outb(inb_p(0xA1)&0xdf,0xA1);// 允许从8259A 芯⽚的IRQ13 中断请求。set_trap_gate(39,&parallel_interrupt);// 设置并⾏⼝的陷阱⻔。void rs_init (void){set_intr_gate (0x24, rs1_interrupt); // 设置串⾏⼝1 的中断⻔向量(硬件IRQ4信号)。set_intr_gate (0x23, rs2_interrupt); // 设置串⾏⼝2 的中断⻔向量(硬件IRQ3信号)。init (tty_table[1].read_q.data); // 初始化串⾏⼝1(.data 是端⼝号)。init (tty_table[2].read_q.data); // 初始化串⾏⼝2。outb (inb_p (0x21) & 0xE7, 0x21); // 允许主8259A 芯⽚的IRQ3,IRQ4 中断信号请求。}
}

1.3 中断向量表

# 中断向量表(IDT 是操作系统启动时加载到内存的数据结构,实现中断号到处理程序的映射。

核心组成:

组件作用Linux 0.11示例
中断门(Interrupt Gate)处理外部硬件中断,自动禁用中断响应set_intr_gate(0x24, rs1_interrupt)(串口中断)
陷阱门(Trap Gate)处理内部异常(如除零错误),允许嵌套中断set_trap_gate(14, &page_fault)(缺页异常)
中断屏蔽寄存器控制中断使能状态outb(inb_p(0x21) & \~0x01, 0x21)(开启时钟中断)

1.4 CPU中断响应全流程

2、时钟中断

2.1 概念

# 当没有中断时,操作系统在做什么?什么都不做,一直在暂停,等待中断到来。

操作系统自己被谁指挥,被谁推动执行?被时钟中断指挥和推动执行。操作系统不是一个主动的"管理者",而是一个被动的"响应者"。它通过时钟中断这个规律性的心跳来获得执行机会,从而进行调度、管理和维护工作。

# 有没有可以定期触发中断的设备?有,这就是时钟源 / 系统定时器 / 时钟芯片。计算机中有专门的硬件定时器(如8253/8254 PITHPET)集成在 CPU 上,它们能够以固定的频率产生中断信号,这就是时钟中断的来源。

# 中断向量表上注册有一个 schedule 方法,配合时钟源发的中断,CPU 读取中断号来索引中断向量表,就会执行进程调度的方法,因此操作系统就在硬件时钟中断的驱动下完成调度,所以操作系统就是基于中断来运行的软件。

# 所以操作系统的本质就是一个死循环,当没有外部设备就绪时,就一直暂停,由时钟源定期发送中断进而完成调度。

2.2 Linux 0.11 时钟中断机制详解

初始化过程:

// 在sched_init()中设置时钟中断
void sched_init(void) {// ...set_intr_gate(0x20, &timer_interrupt);  // 设置时钟中断处理程序outb(inb_p(0x21) & ~0x01, 0x21);        // 允许时钟中断(IRQ0)// ...
}

时钟中断处理程序:

// 汇编代码:timer_interrupt
_timer_interrupt:push %dspush %eaxmovl $0x10, %eaxmov %ax, %dsmovb $0x20, %aloutb %al, $0x20      # 向8259A发送EOI(中断结束)信号movl $0, %eaxincl %eaxmovl %eax, jiffies   # 更新系统时钟滴答计数pushl $0x10          # 参数:CPL(当前特权级)call _do_timer       # 调用C函数处理定时任务popl %eaxpopl %eaxpop %dsiret

do_timer 函数:核心处理逻辑

// kernel/sched.c
void do_timer(long cpl) {extern int beepcount;extern void sysbeepstop(void);// 更新系统时间if (beepcount)if (!--beepcount)sysbeepstop();// 更新当前进程时间片if (--current->counter > 0)return;// 时间片用完,需要重新调度current->counter = 0;schedule();  // 调用调度程序
}

调度函数 schedule:

// kernel/sched.c
void schedule(void) {int i, next, c;struct task_struct **p;// 寻找就绪状态且counter值最大的进程while (1) {c = -1;next = 0;i = NR_TASKS;p = &task[NR_TASKS];while (--i) {if (!*--p)continue;if ((*p)->state == TASK_RUNNING && (*p)->counter > c)c = (*p)->counter, next = i;}if (c) break;  // 找到了可运行的进程// 所有进程的时间片都已用完,重新分配时间片for (p = &LAST_TASK; p > &FIRST_TASK; --p)if (*p)(*p)->counter = ((*p)->counter >> 1) + (*p)->priority;}// 切换到选中的进程switch_to(next);
}

2.3  时间片与调度机制

# 时间片(Time Quantum)

  • 每个进程被分配一个执行时间单位(时间片)

  • Linux 0.11中,时间片大小与进程的 counter值相关

  • 时钟中断每次发生时,当前进程的 counter减1

  • 当 counter减到0时,进程被剥夺CPU使用权

# 优先级与时间片分配

// 重新计算时间片的公式
(*p)->counter = ((*p)->counter >> 1) + (*p)->priority;

这个公式确保:

  1. 进程的剩余时间片会继承一半到下一个周期

  2. 加上固定的优先级值,保证每个周期都有基本的时间片

  3. 优先级高的进程获得更多CPU时间

3、死循环

# 如果是这样,操作系统不就可以躺平了吗?对,操作系统自己不做任何事情,需要什么功能,就向中断向量表里面添加方法即可。操作系统的本质:就是一个死循环!

3.1 Linux 0.11 的主函数结构

void main(void) {// 初始化阶段:设置整个系统的基础设施mem_init();        // 内存管理初始化trap_init();       // 中断向量表初始化blk_dev_init();    // 块设备初始化sched_init();      // 调度器初始化// ... 其他初始化工作// 创建init进程(用户空间的第一个进程)if (!fork()) {init();}// 主循环:操作系统的"休息"状态for (;;) {pause();  // 等待中断发生}
}

这个死循环的真正含义

  1. 不是忙等待pause() 系统调用会让 CPU 进入低功耗状态

  2. 中断驱动:只有在中断发生时,CPU才会跳出暂停状态

  3. 事件响应:操作系统作为中断处理程序的"调度中心"

注意:对于任何其它的任务,pause()将意味着我们必须等待收到⼀个信号才会返回就绪运⾏态,但任务0(task0)是唯⼀的意外情况( 参⻅schedule() ),因为任务 0 在任何空闲时间⾥都会被激活(当没有其它任务在运⾏时),因此对于任务0,pause()仅意味着我们返回来查看是否有其它任务可以运⾏,如果没有的话我们就回到这⾥,⼀直循环执⾏pause()

3.2 pause() 的内部机制

// pause() 的简化实现
int pause(void) {// 将当前进程状态设为可中断睡眠current->state = TASK_INTERRUPTIBLE;// 调用调度器,选择其他进程运行schedule();// 当信号到达时,从这里恢复执行return -EINTR;
}

为什么使用 pause() 而不是空循环?

方式CPU使用率功耗响应速度
空循环(while(1);)100%即时
pause()接近0%依赖中断响应时间

4、软中断

4.1 概念

# 述外部硬件中断,需要硬件设备通过特定信号线(如IRQ线)触发。例如,当键盘按键被按下时,键盘控制器会通过中断请求线向CPU发送电信号,CPU检测到后会暂停当前任务处理中断。

# 有没有可能,因为软件原因,也触发上面的逻辑?有!这被称为"软件中断"或"陷阱中断"。这种中断不是由外部设备产生,而是由CPU执行特定指令主动触发。常见的场景包括:

  1. 除零错误等异常情况
  2. 调试断点
  3. 系统调用

4.2 系统调用表

# 为了让操作系统支持进行系统调用,CPU 厂商设计了专门的汇编指令,可以让 CPU 内部触发中断逻辑:

  • x86 架构使用 int 指令(如int 0x80
  • x86_64 架构使用 syscall / sysenter 指令
  • ARM 架构使用 SWI / SVC 指令 这些指令会让 CPU 内部产生中断逻辑,切换到内核模式。

我们都知道 CPU 只有一个,那在进行系统调用的时候,是如何进入操作系统进而完成系统调用的呢?

  • 其实操作系统上所有的系统调用都是被写在一张系统调用表的函数指针数组里面的,每个系统调用都有一个唯一的下标,这个下标叫做系统调用号,是在内核中的。
  • 当用户调用 syscall ,就会执行中断向量表中的 0x80 方法来获取系统调用号,再通过系统调用号来调用系统调用方法

注意:我们现在得矫正一个观点,即操作系统并不会用户提供任何系统调用,只会提供系统调用号!而我们之前学习的所有系统调用,如 openfork...都是在 glibc 里面封装好的。

用户层怎么把系统调用号给操作系统? - 通过寄存器(比如 EAX )

4.3 系统调用原理

1. 系统调用的发起(以 openvfork 为例)

  • 在用户层(如 glibc 库中),先将系统调用名称(如 openvfork)通过宏 SYS_ify 转换为系统调用号(例如 SYS_ify(open) 会展开为 __NR_open,这个调用号由操作系统内核提供,而非 glibc)。
  • 把系统调用号存入寄存器(如 eax 寄存器),再通过 syscall 指令(或 int 0x80 软中断指令),触发从用户态到内核态的切换,发起系统调用。

2. 内核态的处理流程

  • 当 syscall 或 int 0x80 触发软中断后,CPU 会执行内核中的中断处理程序(如 system_call 方法)。
  • system_call 会从寄存器中拿到系统调用号,然后到系统调用表sys_call_table)中,以 “调用号 × 4”(因为 32 位系统中指针占 4 字节)为下标,找到对应的内核函数并调用,从而完成系统调用的核心逻辑(比如 open 对应的内核打开文件操作)。

4.4 缺页中断?内存碎片处理?除零野指针错误?

# 缺页中断、内存碎片处理、除零错误、野指针访问等系统级问题,在硬件层面都会被转换为CPU内部的软中断信号。这些中断信号会触发预先注册的中断处理例程(Interrupt Service Routine),由操作系统内核完成相应的处理逻辑。

# 所有异常统一走中断处理路径,实现事件驱动架构(操作系统本质是"躺在中断处理例程上的代码块"),有的是进行申请内存、填充页表、进行映射的,有的是用来处理内存碎片的,有的是用来给目标进程发送信号的...

4.5 中断、陷阱、异常、终止

类型触发源同步 / 异步可恢复性典型用途处理后是否返回原程序
中断(Interrupt)外部硬件(如网卡、键盘)异步无(处理外部事件)设备交互、进程调度是(返回被中断任务)
陷阱(Trap)主动触发(如系统调用指令)同步必然恢复系统调用、调试断点是(返回下一条指令)
异常(Exception)内部错误(如除零、缺页)同步部分可恢复错误处理(如缺页加载、信号通知)可能(如缺页重试)
中止(Abort)致命错误(如硬件故障、内核崩溃)同步 / 异步不可恢复致命错误处理(日志、重启)否(终止或重启)

中断门 vs 系统门

类型特权级切换典型应用注册函数
陷阱门(Trap)不自动关中断除零/缺页等异常set_trap_gate()
系统门(System)允许用户态触发系统调用(int 0x80)set_system_gate()

⚠️ 关键区别:陷阱门处理期间不屏蔽中断,允许更高优先级中断抢占


二、深入理解用户态、内核态

1、内核页表

  • 空间分配:虚拟地址空间被划分为0 ~ 3 GB的用户空间和3 ~ 4 GB的内核空间。
    • 用户空间(0 ~ 3 GB):进程以用户态运行时,只能访问此空间内的代码、数据(如未初始化数据、初始化数据、正代码等),受权限限制,无法直接访问内核空间。
    • 内核空间(3 ~ 4 GB):只有进程进入内核态(如通过系统调用、中断等),才能访问该空间,内核态下可操作系统核心代码、数据及硬件资源。
  • 地址转换
    • 用户页表:用户空间的虚拟地址通过用户页表,映射到物理内存中进程对应的实际地址,实现用户空间内的地址转换。
    • 内核页表:内核空间的虚拟地址通过内核页表,映射到物理内存中操作系统内核加载的位置,支撑内核态下的地址转换。

用户页表有多份,有多少个进程就有多少份,而内核页表只有一份,被所有进程共享。因此操作系统⽆论怎么切换进程,都能找到同⼀个操作系统!换句话说,操作系统系统调⽤⽅法的执⾏, 是在进程的地址空间中执⾏的。

2、特权级

# CPU 通过 cs 段寄存器的最后两个比特位来区分用户态和内核态,这两个比特位也叫做当前特权级(CPL),动态标记运行环境权限,x86 架构采用四级特权环(Ring 0-3):

  • 用户态(Ring 3)CPL=3,仅能访问用户空间(0x00000000 - 0xBFFFFFFF)
  • 内核态(Ring 0)CPL=0,可访问全部内存(包括内核空间 0xC0000000 - 0xFFFFFFFF)

# 当我们正在访问用户区的代码时,CPL为3,当代码想要访问内核时,CPU 就要寻址,寻址发现当前是用户态,所以 CPU 就直接走异常中断,终止进程。

# 所以int 0x80syscall 指令集的工作就是让cs 段寄存器指向操作系统的代码区,把 CPL 由3设为0,此时就称作陷入内核

3、用户态到内核态切换底层机制

# 用户态→内核态通过三类事件触发:

  • 软中断指令int 0x80(传统)或 syscall(现代)显式请求
  • 硬件自动校验CPU 比较 CPL(当前特权级)、DPL(目标段描述符特权级)、RPL(请求特权级),仅当 CPL ≤ DPL 时允许切换

# 安全检查机制:

# 从用户态到内核态的安全切换:

用户程序调用系统调用 → 执行int 0x80/syscall↓
CPU自动进行特权级检查(CPL vs DPL)↓
如果检查失败 → 触发#GP异常 → 杀死进程↓
如果检查通过 → 切换栈指针到内核栈↓
保存用户态寄存器状态↓
根据系统调用号查找系统调用表↓
执行对应的内核函数(进行参数验证)↓
完成操作后返回用户态


三、可重入函数

# 我们看一个场景:

# 像上例这,insert 函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入insert 函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数

# 如果一个函数符合以下条件之一则是不可重入的:

  • 调用了 malloc 或 new,因为 malloc 也是全局链表来管理堆的。
  • 调用了标准 I/O 库函数。标准 I/O 库函数的很多实现都以不可重入的方式使用全局的数据结构。

# 如果一个函数符合以下条件则是可重入的:

  • 只有自己的临时变量


四、volatile

volatile其实是C语言的一个关键字,该关键字的作用是保持内存的可见性。

# 比如如下这段代码:

#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>using namespace std;int flag = 0;
void handler(int signum)
{cout << "cat a signal: " << signum << endl;flag = 1;
}int main()
{signal(2, handler);while(!flag);cout << "process quit normal" << endl;return 0;
}

# 如果正常运行程序会陷入死循环,但是如果发送 2 号信号,flag被修改,程序就会正常结束,结果也应我们所料。

# 在优化条件下,由于 main 函数与 handler 函数分属两个不同的执行流。而 while 循环处于 main 函数中,此时编译器进行检测,若发现 main 函数中不存在对 flag 值进行修改的操作,那么 flag 变量就可能会被编译器直接优化到 CPU 内的寄存器中。后续在收到信号时调用 handler 方法对 flag 进行修改,修改的是内存中 flag 的值,并未修改寄存器中的 flag 值。而 CPU 一直使用的是寄存器中的 flag ,因此就可能陷入死循环中。

需要注意的是,g++ 编译器默认不进行优化,可带选项 -O0、-O1、-O2、-O3 进行这四种优化等级。

# 此时我们发现,程序陷入死循环当中了,这是因为编译器的优化,导致寄存器覆盖了进程看到变量的真实情况,导致内存不可见了。

# 为了解决这种问题,可以用 volatile修饰 flag变量,告诉编译器不要对该变量进行优化,每次访问都去内存加载该变量的值到 CPU ,保存进程对该变量的可见性,保证内存的可见性。


五、SIGCHLD信号

# 我们之前讲过用 wait waitpid 函数来管理子进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。

# 采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。

# 其实,子进程在终止时会给父进程发 SIGCHLD 信号,该信号的默认处理动作是忽略,父进程可以自定义 SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用 waitpid清理子进程。

  1. SIGCHLD 属于普通信号,记录该信号的 pending 位只有一个,如果在同一时刻有多个子进程同时退出,那么在 handler 函数当中实际上只清理了一个子进程,因此在使用 waitpid 函数释放子进程资源时需要使用循环不断进行清理。
  2. 使用 waitpid 函数时,需要设置WNOHANG选项,即非阻塞式 轮询等待,否则当所有子进程都已经清理完毕时,由于 while 循环,会再次调用 waitpid 函数,此时就会在这里一直阻塞住。

# 下面 我们来看一段代码:

#include<iostream>
#include<cstdlib>
#include<sys/wait.h>
#include<unistd.h>void WaitAll(int num)
{while(true){// pid_t n = waitpid(-1, nullptr, 0); // waitpid默认是阻塞的pid_t n = waitpid(-1, nullptr, WNOHANG);if(n == 0){break;}else if(n < 0){std::cout << "waitpid error" << std::endl;break;}}std::cout << "father get a signal: " << num << std::endl;
}int main()
{// 父进程signal(SIGCHLD, WaitAll);pid_t id = fork(); // 如果我们有10个子进程,6个退出了,4个没退?if(id == 0){std::cout << "I am a child, exit" << std::endl;sleep(3);exit(3);}// waitpid(id, nullptr, 0);while(true){std::cout << "I am a father, exit" << std::endl;sleep(1);}return 0;
}

# 事实上,由于 UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用 signal sigaction 函数将 SIGCHLD 信号的处理动作设置为 SIG_IGN,这样 fork 出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。此方法对于 Linux 可用,但不保证在其他 UNIX 系统上都可用。

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
int main()
{signal(SIGCHLD, SIG_IGN);if (fork() == 0){//childprintf("child is running, child dead: %d\n", getpid());sleep(3);exit(1);}//fatherwhile (1);return 0;
}

# 注意:上文说 SIGCHLD 信号的默认处理动作是 IGN ,那为什么在这还要手动设置为 SIG_IGN 呢?

# 这是因为 SIGCHLD 的默认处理动作是 SIG_DFL ,只不过 SIG_DFL 刚好是 IGN , 这两者并不相等。

http://www.dtcms.com/a/463586.html

相关文章:

  • 网站引导页下载互联网舆情监控系统
  • 如何做网站链接分析一站式网站开发服务平台
  • 【Spring Boot】Spring Boot 中常见的加密方案
  • 昭通网站seo团队拓展
  • 卡盟网站建设公司高清品牌网站设计建设
  • 网站模板的制作怎么做北京朝阳区哪里有网站开发
  • 好的网站开发培训深圳住房和建设局网站办事跟踪
  • 班级网站的建设wordpress升级流程
  • 上海的设计网站有哪些内容查看网站服务器ip
  • 面试被问到query不规范,导致召回难度大,如何解决呢
  • wordpress建站怎么上传做pc端网站咨询
  • 做一些网站犯法么网络服务设备有哪些
  • 企业网站建设 新天地网络咸阳公司做网站
  • 多线程并发篇面试题
  • 网站版面结构广州企业如何建网站
  • 化妆品公司网站源码网页制作软件大全
  • 用win2003做网站本科专业 网站开发
  • 网站ome系统怎么做装修公司网站怎么做的
  • 婚纱摄影网站优化技巧php wordpress 代码模板
  • 公司网站维护价格表2023网站关键词堆砌
  • 咸阳网站建设xymokj南宁庄关键词推广优化方案
  • 如何做网站系统海口网站建设q479185700棒
  • 德州北京网站建设seo优化大公司排名
  • html5网站动态效果软件工程就业方向和前景
  • 个人网站模板 html5合肥营销网站建设公司
  • 多种大连网站建设如何更换wordpress域名
  • 建设银行北京分行招聘网站php做网站安全
  • cms 企业网站技能培训有哪些科目
  • 绍兴市工程建设网站wordpress过去指定分类文章
  • 如何把jQuery特效做网站背景用php做的旅游网站