目录
[一 信号处理剩余部分](#一 信号处理剩余部分)
[3 穿插话题---操作系统是怎么运行起来的](#3 穿插话题---操作系统是怎么运行起来的)
[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_read、sys_write、sys_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_table[eax*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 系统上都可用。请编写程序验证这样做不会产生僵尸进程。
信号本质是模拟硬件中断
信号编号就相当于中断向量表中的下标
信号和中断是完全不一样的!!!