【Linux】信号处理(3)信号处理&&valatile关键字

目录

[一 信号处理剩余部分](#一 信号处理剩余部分)

[3 穿插话题---操作系统是怎么运行起来的](#3 穿插话题---操作系统是怎么运行起来的)

(1)硬件中断

(2)时钟中断

(3)死循环

(4)软中断

[4 重谈用户态和内核态](#4 重谈用户态和内核态)

[二 可重入函数](#二 可重入函数)

[三 valatile 关键字](#三 valatile 关键字)

[四 SIGCHLD信号](#四 SIGCHLD信号)


一 信号处理剩余部分

3 穿插话题---操作系统是怎么运行起来的

(1)硬件中断

操作系统在被编写时,就已经针对各种中断情况(键盘,网卡等等外设),有处理方法

所有的处理方法,形成表结构,叫做中断向量表IOT;把处理方法,当成函数指针数组有下标

所有的外设和中断控制器连接,中断控制器和CPU连接

中断向量表是由操作系统提供

由外部设备触发的中断,叫做硬件中断

主板上的时钟源晶振是一个硬件外设,它会以固定的时间周期(本质就是时间,通常 1ms,即1/1000HZ),向 CPU 发送硬件中断。
操作系统本身并非持续执行调度任务,而是以 "死循环" 的方式等待事件触发;所有进程调度、任务切换等核心工作,本质上都是由各类硬件中断(尤其是时钟中断)驱动 完成的。

因此,操作系统的核心工作原理,就是基于中断机制运行的软件集合。

而时钟中断触发的固定时间间隔,就是进程调度的时间片

时钟源晶振可以自己触发时钟中断;时钟中断是让操作系统调度的动力源头

计数器的本质就是时间,在软件层面上,把时间进行量化成次数

进程task_struct,都会有时间片的概念;时间片本质就是一个计数器 int counter

当代计算机系统中有一个全局变量jiffiies嘀嗒,中断一次,jiffies就++,累计中断多少次

(2)时钟中断

当代很多计算机会把晶振集成在CPU内部,就不用走外部了;晶振可以通过硬件计数器分频,实现稳定输出1m/s

问题:

• 进程可以在操作系统的指挥下,被调度,被执行,那么操作系统自己被谁指挥,被谁推动执行呢?

• 外部设备可以触发硬件中断,但是这个是需要用户或者设备自己触发,有没有自己可以定期触发的设备?

这样,操作系统不就在硬件的推动下,自动调度了么!!

时钟中断 是由主板 / CPU 上的时钟源晶振(定时器硬件) 以固定时间周期(通常为 1ms,即 1/1000Hz)主动触发的硬件中断,是操作系统的「系统心跳」,也是所有时间相关机制的硬件基础。
核心本质:
它是唯一能定期、自动触发的硬件中断,不依赖用户 / 外部设备操作,是操作系统能自主运行的根本动力。
本质是把连续的时间量化为离散的时间片,让操作系统能精准管理时间、调度进程

CPU是中断处理的核心

(3)死循环

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

cpp 复制代码
void main(void) /* 这⾥确实是void,并没错。 */ 
{ /* 在startup 程序(head.s)中就是这样假设的。 */ 
 ...
 /*
 * 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到⼀个信号才会返 
 * 回就绪运⾏态,但任务0(task0)是唯⼀的意外情况(参⻅'schedule()'),因为任 
 * 务0 在任何空闲时间⾥都会被激活(当没有其它任务在运⾏时), 
 * 因此对于任务0'pause()'仅意味着我们返回来查看是否有其它任务可以运⾏,如果没 
 * 有的话我们就回到这⾥,⼀直循环执⾏'pause()'。 
 */
 for (;;)
 pause();
} // end main

(4)软中断

仅依靠硬件触发中断的场景过于单一,因此 CPU 指令集中设计了int或syscall这类汇编指令,用于由软件主动触发中断,这类中断被称为软中断

例如:你用一个硬的东西打小孩,小孩就会自己躲,但是现在你给小孩说,听到我说躲,你就躲起来,小孩说我知道了;等你说躲时,小孩就躲起来了。硬件中断是 "被动挨打才反应",软中断是"收到指令主动触发",二者本质都是中断机制,只是触发源和时机不同

接收来自外部的中断号,触发中断,软件上也可以;在X86下,int对应中断号-->0x80,是32位下常用;systall对应0x80,是64位下常用

软件中断有什么用?

操作系统内部有很多系统调用,会形成系统调用表,这个表是一个指针数组;在表里对应的下标,叫做对应的系统调用的系统调用号

我们自己写一个方法:call_sys(int number)number是系统调用号,放到中断向量表中系统调用位置,当未来系统调用时,int 0x80就会触发软中断,就会查中断向量表,执行这个方法

而我们自己写的这个方法,在源码中,实际是:

cpp 复制代码
extern int system_call(void);  // 系统调用中断处理程序
_system_call:
    call [_sys_call_table+eax*4]
部分 含义
_sys_call_table 系统调用表 ,是内核中一个全局的函数指针数组,数组的每个元素对应一个系统调用的内核实现函数(如 sys_readsys_writesys_fork)。
eax 32 位 x86 架构下,用户态程序会把系统调用号 存入 eax 寄存器(比如 read 对应调用号 3,write 对应 4)。
*4 因为 32 位系统中,每个函数指针占 4 字节,所以用 eax*4 计算目标函数在数组中的字节偏移量
[_sys_call_table+eax*4] 计算出目标系统调用函数的内存地址,从数组中取出该函数指针。
call 跳转到该地址,执行对应的内核系统调用函数,完成用户请求的操作。

软中断完整执行流程拆分:
1 用户态发起系统调用

Linux 把系统调用封装成 C 库函数(如 fork()、read()、write()),用户无需直接操作汇编:

底层本质:C 库会帮用户执行汇编指令,把系统调用号存入 eax 寄存器,再执行 int 0x80(32 位)/syscall(64 位)软中断指令。
2. 软中断触发,CPU 切换内核态

CPU 收到 0x80 软中断后,保护现场(保存用户态寄存器、程序计数器等上下文),然后查询中断向量表 (IDT)。中断向量表中,0x80 中断号绑定了系统调用的总入口处理函数 system_call()(定义在 kernel/system_call.c 中)。

CPU 跳转到 system_call(),正式进入内核态执行。
3. 内核态查表执行对应系统调用

system_call() 会根据用户传入 eax 寄存器的系统调用号,在 sys_call_table(系统调用表)中索引对应的内核函数。

执行核心逻辑:call *sys_call_tableeax\*4,根据调用号跳转到具体的内核函数,完成用户请求的操作(如读文件、创建进程)。
4. 执行完毕,恢复现场,返回用户态

系统调用执行完成后,内核恢复之前保存的用户态上下文,把结果存入对应寄存器,切换回用户态。

用户程序从 int 0x80 指令的下一条继续执行,拿到系统调用的返回结果,完成整个流程。

操作系统提供系统调用:系统调用号+软中断方式

所以我们看到的系统调用,是被C封装过的

4 重谈用户态和内核态

页表在逻辑上存在用户页表和内核页表;用户页表是每个用户都有一份,而内核页表只存在一份

页表在物理上是混在一起的,又标志位表示映射到用户还是内核空间

结论:
核心结论:Linux 中所有函数跳转(包括系统调用的内核态执行),都在当前进程的地址空间内完成。

系统调用本质是「当前进程从用户态切到内核态,在自己的地址空间里执行内核代码」,不是跳转到操作系统的独立地址空间。
1 进程切换的本质:进程切换时,只是切换了进程的地址空间上下文,内核代码(系统调用实现)会被映射到每个进程的地址空间的高地址部分,因此无论切到哪个进程,都能执行同一套内核系统调用

2 关于特权级别,涉及到段,段描述符,段选择子,DPL,CPL,RPL 等概念,这块我们不做深究了,现在芯片为了保证兼容性,已经非常复杂了,进而导致 OS 也必须得照顾它的复杂性

CPU中,用CS寄存器中低2位比特位表示当前CPU的执行级别

CS寄存器只用两种:0表示内核态,3表示用户态

用户必须调用int 0x80(只能通过设置eax的方式调用)或systall,才能自动把执行级别由3-->0

细节:把CS寄存器中0,3这样的数字叫做CPL(Current Privilege Level)

如何进行信号操作:(1)signal (2)sigaction

cpp 复制代码
#include <signal.h> 
int sigaction(int signo, const struct sigaction *act, struct sigaction 
*oact)

sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回-1。signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体: - 将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。

pause函数:作用是wait for signal,否则一直阻塞

如果进程收到了大量重复信号?

操作系统处理同一个信号时,是串行处理的--->被递归调用,否则容易栈溢出

当你对应进程正在处理某种信号时,特定的信号自动被屏蔽,意味着收到大量重复循环,操作系统是串行处理的;sigaction比signal多了信号屏蔽的功能


二 可重入函数

上述图片灾难发生过程:

main 正在插 node1:它刚做完第 1 步,node1->next 已经指向 node1 了,正准备执行第 2 步把 head 换成 node1。

此时链表状态:head 指向 node1,node1 指向 node1。

神仙打断(信号触发):这时候信号来了,CPU 暂停了 main,立马跑去执行 sighandler。

sighandler 插 node2:sighandler 调用 insert 插 node2。

它非常顺利,两步都做完了!

此时链表状态:head 指向 node2,node2 指向 node1,node1 指向 node1。

回到 main 继续做:sighandler 执行完返回,CPU 回到 main,继续执行第 2 步 head = p;。

main 把 head 直接改成了 node1。

结局:node2 被覆盖了,彻底找不到了,链表上只有 node1

为什么会这样?

因为 insert 操作了全局变量 head。这个 head 是大家共有的,main 和 sighandler 两个流同时去改它,就会发生 "插队" 事故。这里的insert就是不可重入函数

可重入函数:
一个函数可以被多个执行流同时调用(比如主流程 + 信号处理函数同时调用),不会出现数据混乱、错误、崩溃,这样的函数叫可重入函数

核心特点:
不使用全局变量、静态变量
不调用 malloc /free
不调用标准 I/O 库函数(如 printf、scanf)
只使用自己栈上的局部变量
可以在信号处理函数中安全调用

信号处理函数必须使用可重入函数

可重入和不可重入只是特点不同,没有好坏之分

当一个函数只使用了局部变量,可能是可重入函数


三 valatile 关键字

这个关键字我们之前在c中接触过,是C80/C90的32个关键字的一个

在编译不同的代码时,编译器可能使用不用的编译选项,从而让编译器表现出不同的优化级别

在gcc中,如果我们想使用编译器优化,有相应的优化级别,默认是-O0(没有优化)

如果在编译代码时,采取不同的优化策略和优化级别时,可以带上优化级别

同一份代码,在不同的优化级别下,可能会有不同的表现。例如:运行结果不同

我们下边站在信号的角度重新理解一下啊volatile关键字

cpp 复制代码
#include <stdio.h> 
#include <signal.h> 
int flag = 0; 
void handler(int sig) 
{ 
 printf("chage flag 0 to 1\n"); 
 flag = 1; 
} 
int main() 
{ 
 signal(2, handler); 
 while(!flag); 
 printf("process quit normal\n"); 
 return 0; 
} 
bash 复制代码
[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 
^Cchage flag 0 to 1 
process quit normal

上述第一个代码:

变量是一定在内存中存在的,while循环的本质:是对flag做逻辑判断(逻辑判断的工作是CPU完成的);CPU能做到两种最常见的计算类型:(1)数据计算 (2)逻辑运算(逻辑运算与,或,取反....)

所以flag在while循环中的判断,本质是计算

必须先把flag加载到CPU中,次啊能做判断;判断步骤:(1)载入数据(2)逻辑运算及判断

while对flag没有修改逻辑,只有检查和判断逻辑

当我们开启-O2优化时,GCC 会做一个非常 "聪明" 的优化:把flag直接缓存到 CPU 寄存器里,等价于给flag加了register关键字,后续循环只读取寄存器里的flag,再也不访问内存

这时候,汇编伪代码就变成了这样:

bash 复制代码
start:
    mov  eax, flag    ; 仅在循环开始时,把flag从内存加载到寄存器eax
loop:
    cmp  !eax         ; 每次循环只判断寄存器里的eax,不再读内存
    if(true)
        goto loop
    else
        {}

编译器就会把flag优化成:register int flag = 0;

我们按下Ctrl+C触发SIGINT信号时,内核会调用handler函数,把 内存中的flag 从 0 改成 1。

但此时的问题是:

  • while循环里,CPU 一直读取的是寄存器里缓存的旧值(0),根本不会去内存读取新值
  • 哪怕内存里的flag已经变成 1,循环里的判断永远是!0为真,程序就会永远卡死在循环里,永远无法退出

因为有寄存器的原因,会导致内存变量不可见;但是有了volatile,告诉编译器不要过度优化,不要优化,保持内存变量的可见性

所以volatile修饰的变量不要优化成内存器变量


四 SIGCHLD信号

问题:子进程退出的时候,是安安静静推出的吗?

不是!子进程会给父进程发送SIGSHLD信号(17号信号),该信号的默认处理动作是忽略

父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程。子进程终止时会主动通知父进程,父进程在信号处理函数中调用 wait 清理子进程即可。

会不会有问题?如果存在10个子进程呢?

我们使用的是普通信号,在使用这个信号时,其他的这个信号会被屏蔽

SIGCHLD+while+非阻塞:对任意个数的子进程进行回收

测试代码:

bash 复制代码
#include <stdio.h> 
#include <stdlib.h> 
#include <signal.h> 
void handler(int sig) 
{ 
 pid_t id; 
 while( (id = waitpid(-1, NULL, WNOHANG)) > 0) { 
 printf("wait child success: %d\n", id); 
 } 
 printf("child is quit! %d\n", getpid()); 
} 
int main() 
{ 
 signal(SIGCHLD, handler); 
 pid_t cid; 
 if((cid = fork()) == 0){//child 
 printf("child : %d\n", getpid()); 
 sleep(3); 
 exit(1); 
 } 
 while(1){ 
 printf("father proc is doing some thing!\n"); 
 sleep(1); 
 } 
 return 0; 
}

父进程必须等待子进程吗?

必须!(大多数情况)

特殊情况:在Linux中,系统允许父进程在一定场景下,可以不用等待子进程退出

事实上,由于 UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用 sigaction 将 SIGCHLD 的处理动作置为 SIG_IGN,这样 fork 出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用 sigaction 函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于 Linux 可用,但不保证在其它 UNIX 系统上都可用。请编写程序验证这样做不会产生僵尸进程。

信号本质是模拟硬件中断

信号编号就相当于中断向量表中的下标

信号和中断是完全不一样的!!!

相关推荐
wabs6663 小时前
关于贪心算法的思考
算法·贪心算法
YXXY3133 小时前
线程的介绍(四)
linux
李白你好3 小时前
AI Agent 架构的自动化渗透测试工具
运维·人工智能·自动化
社交怪人3 小时前
【判断大小】信息学奥赛一本通C语言解法(题号1043)
算法
Snasph3 小时前
GNU Make 用户手册(中文版)
服务器·算法·gnu
广州灵眸科技有限公司3 小时前
瑞芯微RV1126B开发板(EASY-EAI-PI2) Easy-Eai编译环境准备与更新
服务器·前端·人工智能·python·深度学习
江澎涌4 小时前
拆解与 AI 的一次对话
人工智能·算法·程序员
一勺菠萝丶4 小时前
Docker Desktop 启动后容器自动启动怎么办?如何关闭容器自启动
运维·docker·容器
Esaka_Forever4 小时前
uv init 完整用法(Python 最快包管理器)
服务器·python·uv
sheeta19984 小时前
LeetCode 每日一题笔记 日期:2026.06.02 题目:3635. 最早完成陆地和水上游乐设施的时间 II
笔记·算法·leetcode