🌻个人主页:路飞雪吖~
🌠专栏:Linux
目录
一、信号保存
✨进程如何完成对信号的保存?
✨在内核中的表示
✨sigset_t
✨信号操作函数
🪄sigprocmask --- 获取或设置当前进程的 block表
🪄sigpending --- 获取当前的pending信号集
二、信号捕捉
✨信号捕捉的流程
✨sigaction编辑
✨操作系统是怎么运行的
🍔硬件中断
🍔时钟中断
🍔死循环
🪄时间片
🍔软中断
🍔缺页中断?内存碎片处理?除零野指针错误?
三、🌟如何理解内核态和用户态
一、信号保存
🌠信号相关常见概念
• 实际 执行信号 的处理动作称为 信号到达;
• 信号从 产生到递达 之间的状态,称为信号未决;
• 进程可以选择 阻塞某个信号【阻塞特定信号[叫屏蔽信号,与IO阻塞没有任何联系],信号产生了,一定把信号进行pending(保存),永远不递达,除非我们解除 阻塞】;
• 被阻塞的信号产生时,将保持在 未决状态,直到进程解除对此信号的阻塞,才执行递达的动作;
• 阻塞 和 忽略 是不同的,只要信号被阻塞 就不会递达,而忽略是在递达之后可选的一种处理动作。
✨进程如何完成对信号的保存?
• pending [信号]位图 :当前进程收到的信号列表;
• handler_t XXX[N] :函数指针数组,指向信号的处理方法
信号编号 -1 :就是函数指针数组的下标!
• block [屏蔽]位图:是否屏蔽信号;
• pending 例子: 当【kill -2】发送2号信号,从右往左的第二个 比特位 由 0 --> 1,此时就向该进程发了一个 2号 信号,从右往左的 第几个 比特位 信号就是几.
• block 例子:SIGINT(2) 信号2,当前的block位 为 1, 表示的是把2号信号屏蔽,即便pending 收到了 2号 信号,这个 2号 信号 也无法执行对应的 handler,即禁止2号信号进行递达,除非把 2号 信号 的 block 位 由 1 --> 0,pending 的2号信号才会执行对应的方法 。
• 屏蔽一个信号[block] 和 当前是否收到这个信号[pending] 两者是没有关系的,因为它们是两个位图,修改时没有联系。
• 进程能识别信号本质是:每一个进程的每一个信号都能横着看这三张表来识别信号,是程序员内置的特性【这些代码的数据结构内核都是程序员写的】。
✨在内核中的表示
// 内核结构 2.6.18
struct task_struct {
...
/* signal handlers */
struct sighand_struct *sighand;
sigset_t blocked
struct sigpending pending;
...
}
struct sighand_struct {
atomic_t count;
struct k_sigaction action[_NSIG]; // #define _NSIG 64
spinlock_t siglock;
};
struct __new_sigaction {
__sighandler_t sa_handler;
unsigned long sa_flags;
void (*sa_restorer)(void); /* Not used by Linux/SPARC */
__new_sigset_t sa_mask;
};
struct k_sigaction {
struct __new_sigaction sa;
void __user *ka_restorer;
};
/* Type of a signal handler. */
typedef void (*__sighandler_t)(int);
struct sigpending {
struct list_head list;
sigset_t signal;
};
✨sigset_t
从上图来看,每个信号只有⼀个bit的未决标志,非0即1, 不记录该信号产生了多少次,阻塞标志也是这样 表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t 称为信号集 ,这个类型 可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号 是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的 信号屏蔽字(Signal Mask), 这⾥的“屏蔽” 应该理解为阻塞而不是忽略。
✨信号操作函数
不建议直接使用 位操作 来对位图直接进行设置、查找、检测。Linux直接提供了一组接口,可以用来对该信号集直接进行比特位的操作:
对位图增删查改:
#include <signal.h>
• int sigemptyset(sigset_t *set); // 把对应的信号集 做 清空
• int sigfillset(sigset_t *set); // 信号集 全部 置 1
• int sigaddset(sigset_t *set, int signo); // 向指定的信号集当中,添加信号
• int sigdelset(sigset_t *set, int signo); // 从指定的集合当中,移出某个信号
• int sigismember(const sigset_t *set, int signo); // 判断一个信号是否在集合里
• 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号。
• 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号 包括系统支持的所有信号。
• 注意,在使用sigset_t类型的变量之前,⼀定要调用 sigemptyset 或 sigfillset 做初始化,使信号集处于 确定的状态。初始化sigset_t变量之后就可以在调用 sigaddset 和 sigdelset 在该信号集中添加或删 除某种有效信号。
🪄sigprocmask --- 获取或设置当前进程的 block表
读取或更改进程的信号屏蔽字【阻塞信号集】。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
🪄sigpending --- 获取当前的pending信号集
#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。
调⽤成功则返回0,出错则返回-1
🌠 为什么 pending表 不提供 修改的方法函数,只提供检查的方法呢?
OS不需要提供操作pending的方法,因为 信号产生的5种方式【键盘、指令、系统调用、软件条件、异常】全部都在修改pending表,所以不需要提供修改。
🌠handler 表 由谁来修改?
signal() 函数,一直都在修改这个表。
sigset_t 是OS提供的数据类型,这个数据类型定义的变量 是在哪里开辟的空间 --> 用户栈上开辟的空间。
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <string>
#include <signal.h>
#include <functional>
#include <vector>
#include <sys/wait.h>
void PrintPending(const sigset_t &pending)
{
std::cout << "curr pending list [" << getpid() << "] :";
for (int signo = 31; signo > 0; signo--)
{
if (sigismember(&pending, signo))
{
std::cout << 1;
}
else
{
std::cout << 0;
}
}
std::cout << std::endl;
}
void non_handler(int signo)
{
std::cout << "处理" << signo << std::endl;
}
int main()
{
// 不让 2号 信号 执行退出
::signal(2, SIG_IGN);
//::signal(2, non_handler);
// 1. 对2号信号进行屏蔽
// sigset_t OS提供的数据类型
// 栈上开辟的空间
sigset_t block, oblock;
// 对空间进行清0
sigemptyset(&block);
sigemptyset(&oblock);
// 1.1 添加2号信号
// 我们有没有把对2号信号的屏蔽,设置进入内核中?
// 只是在用户栈上设置了block的位图结构,并没有设置进入内核中
sigaddset(&block, 2);
// 1.2 设置进内核中
sigprocmask(SIG_SETMASK, &block, &oblock); // 把当前的信号集统一进行替换
int cnt = 0;
// 2. 获取并打印
while (true)
{
// 2.1 如何获取pending 表?
sigset_t pending;
sigpending(&pending);
// 2.2 打印
PrintPending(pending);
sleep(1);
cnt++;
if(cnt == 10)
{
std::cout << "解除对 2号 信号的屏蔽" << std::endl;
sigprocmask(SIG_SETMASK, &oblock, nullptr);
}
}
return 0;
}
二、信号捕捉
✨信号捕捉的流程
🪄操作系统运行状态:
1. 用户态 --- CPU开始调度执行我自己写的代码
2. 内核态 --- 执行操作系统的代码
• 处理信号?立即处理吗? 我在做我的事情,优先级很高,信号处理,可能并不是立即处理,是在合适的时候【信号到来,没有立即处理,进程记录下来对应的信号】,即 进程从 内核态 切换回 用户态 的时候,检测当前进程的 pending && block,决定是否处理 再结合 handler表 来处理信号。
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。
由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:
• 用户程序注册了 SIGQUIT 信号的处理函数 sighandler 。
• 当前正在执行 main 函数,这时发生中断或异常切换到内核态。
• 在中断处理完毕后要返回用户态的 main 函数之前检查到有信号 SIGQUIT 递达。
• 内核决定返回用户态后不是恢复 main 函数的上下文继续执行,而是执行 sighandler 函数, sighandler 和 main 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个 独立的控制流程。
• sighandler 函数返回后自动执行特殊的系统调用 sigreturn 再次进入内核态。
• 如果没有新的信号要递达,这次再返回用户态就是恢复 main 函数的上下文继续执行了。
从用户态 开始调用我们的代码当需要系统调用,进入内核 执行内核处理动作 进行检测 发现信号要自定义捕捉,进入到用户态 处理完用户态的方法,再返回到内核 执行剩下的动作,紧接着返回到 用户态 的历史代码处 继续向后运行。
🪄 执行 do_signal() 方法 为什么还要 从 内核态 切转到 用户态【void sighandler(int)】?直接内核执行完不行吗?
信号捕捉的方法是用户自定义的,即怎么处理这个方法是由用户自己写的,若让内核的权限直接执行这个方法,这个方法 若有删除用户 、删除root的配置文件、给用户赋权... 让内核执行用户的方法时 用户的代码 若有非法操作,用户就越权了,所以要切换 ---- 有安全风险。
🪄 为什么要从 用户态[void sighandler(int)] 切换到 内核态[sys_sigreturn()] ? 调用完直接切换到 int main() 的下一条指令不行吗?
从一个函数 调用 另一个函数 知道函数名就可以,但是想 从 一个函数 执行完毕 返回到 另一个函数 这两个函数 必须 曾经要有调用关系,即 信号处理完,只能从内核返回。
🪄 OS 怎么知道把信号处理完了,应该返回到用户空间的下一行代码呢?
CPU 有个寄存器【pc,正在执行指令的下一条地址】,信号处理完成之后,把PC指针恢复。
✨sigaction
#include <iostream>
#include <signal.h>
#include <unistd.h>
void handler(int signo)
{
std::cout << "get a sig: " << signo <<std::endl;
exit(1);
}
int main()
{
struct sigaction act, oact;
act.sa_handler = handler;
::sigaction(2, &act, &oact);
while(true)
{
pause();
}
}
发信号是直接在 pending表 中直接修改比特位,保存信号用的是位图,
当在信号没有被递达之前,同时来了多个同样的信号,此时当前进程只能记录其中的一个信号【最新的】,
假设 我们执行handler方法非常久,我们处在 处理2号信号之间,若这时又来了一个 2号信号,此时会发生什么?重复执行 handler,会导致 handler方法不断递归,栈就会一直被叠加,造成栈溢出,
#include <iostream> #include <signal.h> #include <unistd.h> void handler(int signo) { static int cnt = 0; cnt++; while (true) { std::cout << "get a sig: " << signo << "cnt: " << cnt << std::endl; sleep(1); } exit(1); } int main() { struct sigaction act, oact; act.sa_handler = handler; ::sigaction(2, &act, &oact); while (true) { pause(); } }
为了规避这种现象 OS 不允许信号处理的方法进行嵌套 --- 当我们某一个信号正在被处理时,假设信号准备被递达了,内核态 返回 用户态,检查测到 2号 信号要被处理/捕捉,OS 会自动的把对应信号的block位设置为1【屏蔽2号 信号】,当信号处理完 返回内核时 会自动的把2号信号的 block 解除。
在 OS 处理进程的信号捕捉方法时,对同一种信号 OS 只允许对每一个信号的方法进行串行处理,而不支持进行嵌套处理 一次。
#include <iostream> #include <signal.h> #include <unistd.h> //printBlockList void PrintBlock() { sigset_t set, oset; sigemptyset(&set); sigemptyset(&oset); // 读取或更改进程的信号屏蔽字 sigprocmask(SIG_BLOCK, &set, &oset); std::cout << "block: "; for(int signo = 31; signo > 0; signo--) { if(sigismember(&oset, signo))// 判断一个信号是否在集合里 { std::cout << 1; } else { std::cout << 0; } } std::cout << std::endl; } void handler(int signo) { static int cnt = 0; cnt++; while (true) { std::cout << "get a sig: " << signo << ", cnt: " << cnt << std::endl; PrintBlock(); sleep(1); } exit(1); } int main() { struct sigaction act, oact; act.sa_handler = handler; ::sigaction(2, &act, &oact); while (true) { PrintBlock(); pause(); } }
当我们正在处理某一个信号时,会把当前正在处理的信号给屏蔽掉。
• sigset_t sa_mask:
如果我想自定义屏蔽信号的list,就可以把屏蔽的信号加入到 sigset_t sa_mask 里。
#include <iostream> #include <signal.h> #include <unistd.h> //printBlockList void PrintBlock() { sigset_t set, oset; sigemptyset(&set); sigemptyset(&oset); // 读取或更改进程的信号屏蔽字 sigprocmask(SIG_BLOCK, &set, &oset); std::cout << "block: "; for(int signo = 31; signo > 0; signo--) { if(sigismember(&oset, signo))// 判断一个信号是否在集合里 { std::cout << 1; } else { std::cout << 0; } } std::cout << std::endl; } void handler(int signo) { static int cnt = 0; cnt++; while (true) { std::cout << "get a sig: " << signo << ", cnt: " << cnt << std::endl; PrintBlock(); sleep(1); } exit(1); } int main() { struct sigaction act, oact; act.sa_handler = handler; sigemptyset(&act.sa_mask); sigaddset(&act.sa_mask, 3); sigaddset(&act.sa_mask, 4); sigaddset(&act.sa_mask, 5); sigaddset(&act.sa_mask, 6); sigaddset(&act.sa_mask, 7); ::sigaction(2, &act, &oact); while (true) { PrintBlock(); pause(); } }
对相应的信号做屏蔽,处理完信号之后,设置的屏蔽号就会被自动恢复:
#include <iostream> #include <signal.h> #include <unistd.h> //printBlockList void PrintBlock() { sigset_t set, oset; sigemptyset(&set); sigemptyset(&oset); // 读取或更改进程的信号屏蔽字 sigprocmask(SIG_BLOCK, &set, &oset); std::cout << "block: "; for(int signo = 31; signo > 0; signo--) { if(sigismember(&oset, signo))// 判断一个信号是否在集合里 { std::cout << 1; } else { std::cout << 0; } } std::cout << std::endl; } void handler(int signo) { static int cnt = 0; cnt++; while (true) { std::cout << "get a sig: " << signo << ", cnt: " << cnt << std::endl; PrintBlock(); sleep(1); break; } // exit(1); } int main() { struct sigaction act, oact; act.sa_handler = handler; sigemptyset(&act.sa_mask); sigaddset(&act.sa_mask, 3); sigaddset(&act.sa_mask, 4); sigaddset(&act.sa_mask, 5); sigaddset(&act.sa_mask, 6); sigaddset(&act.sa_mask, 7); // for(int signo = 1; signo <= 31; signo++) // sigaddset(&act.sa_mask, signo); ::sigaction(2, &act, &oact); while (true) { PrintBlock(); pause(); } }
即 对于信号的处理过程,当我们正在处理 2号 信号 时,当前 2号 信号不可被递达,因为默认被屏蔽了。
🌠当有一个信号来时,我们正在处理这个信号,此时block表 所对应的该信号的比特位为1,而 pending表 在没有处理信号之前就已经 把对应的 正在处理的信号值的比特位 给置 0 了,原因:
当处理信号完,回来时,如何区分 pending表 比特位中的 1 是 历史的 1 还是 在处理信号期间又收到的 1 呢?区分不了,所以 这个 1 是在我们调用这个信号处理函数 之前 直接被清零了。
#include <iostream> #include <signal.h> #include <unistd.h> void PrintPending() { sigset_t pending; ::sigpending(&pending); std::cout << "pending: "; for(int signo = 31; signo > 0; signo--) { if(sigismember(&pending, signo))// 判断一个信号是否在集合里 { std::cout << 1; } else { std::cout << 0; } } std::cout << std::endl; } void handler(int signo) { static int cnt = 0; cnt++; while (true) { std::cout << "get a sig: " << signo << ", cnt: " << cnt << std::endl; // PrintBlock(); // 在信号处理期间,pending表的 2号 比特位 为 0, // 就说明 我们在执行 void handler(int signo) 【处理信号】之前 // 就已经 把pending表 清零了 PrintPending(); sleep(1); } } int main() { struct sigaction act, oact; act.sa_handler = handler; sigemptyset(&act.sa_mask); ::sigaction(2, &act, &oact); while (true) { PrintPending(); pause(); } }
✨操作系统是怎么运行的
🍔硬件中断
• OS是计算机开机之后,启动的第一个软件;
• OS启动之后,不退出,除非自己关机;
• 中断向量表就是操作系统的⼀部分,启动就加载到内存中了;
• 通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询;
• 由外部设备触发的,中断系统运行流程,叫做硬件中断;
• 所有的 外部设备,要被对应的OS访问到【键盘、显示器、磁盘......】,并不能直接让OS定期去轮询这些设备的状态【设备太多】,
• 所以 外部设备 一般 会在硬件层面上 发起中断,发起中断后,因外设过多,所以 设备并没有在物理上 直连CPU ,而是连在了 中断控制器这里,
• 当对应的某个设备 发生 中断时,中断控制器就会知道【1. 是哪一个设备发起的中断,就可以得到对应设备的中断号;2. 中断控制器 替发起中断的设备 直接向CPU 发起 中断请求[通知CPU]--->[向CPU特定的针脚触发 ,本质就是高电压]】,
• 通知CPU,CPU就知道有一个硬件中断了,CPU同时也就获得 设备的中断号,【每个设备一旦中断了,都会有自己唯一的中断号,】
• OS为了能够处理每一个设备,OS在编码设计的时候,就给我们提供了一个表结构【中断向量表,这个表的下标就为 中断号】,其中 硬件上 触发中断 让CPU拿到 硬件所匹配的中断号,中断号和中断对应处理的方法是固定的,都是硬件上写好的,所以 中断这一套机制 是软硬件结合的产物。
• CPU来处理中断的时候,可能CPU当前正在调度某个进程,所以CPU里面的各种寄存器,一定保存其他的临时数据,所以CPU的中断的固定处理历程,就要把CPU的寄存器数据保存在中断的上下文里 --- CPU保护现场,
• 在软件上 对应的操作系统 和 CPU 根据拿到的中断号 n,去查 中断向量表,包括 CPU保护现场 和 查表【找到对应的方法】这一系列的操作 --- 执行中断处理历程【1. 保存现场,2. 根据中断号 n,3. 调用对应的中断方法, 4. 恢复现场】。
• 执行完毕,恢复现场,处理完毕中断,继续之前的工作。
初始化中断向量表源码:
//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,&irq13);// 设置协处理器的陷阱⻔。
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 的中断⻔向量(硬件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 中断信号请
求。
}
🍔时钟中断
• 若外部设备没有一个就绪, 中断就不会被触发?
• 进程可以在OS的指挥下,被调度,被触发,那么OS自己被谁指挥,被谁推动执行呢?
• 外部设备可以触发硬件中断,但是这个是需要用户自己触发,有没有自己可以定期触发的设备?
• OS在计算机硬件上,利用中断的特性,在硬件上存在一个 时钟源【帮我们定期去触发时钟中断的硬件】,固定周期,持续给CPU发送中断,
• 在软件上,OS给时钟源一个固定的中断号,若时钟源一直通过中断控制器 给CPU发送硬件中断,CPU就要一直进行保护现场和查找中断向量表,执行中断方法,若给中断向量表特定的中断号为 n 的下标里,单独设置一个方法【进程调度】,外部设备 一直通过 中断 向CPU发送中断,就逼着 OS 不断的通过 中断向量表 执行中断方法,一直在进行任务调度 ---- 这就是 OS 能一直跑起来的原因【时钟中断,一直在推进OS进行调度】。
什么是操作系统?操作系统就是基于中断向量表,进行工作的!!!
当代的 x86芯片 CPU,因为觉得时钟源每次都占用中断控制器,影响运行速度,所以 已经把 时钟源 集成在CPU内部上了,所以CPU里面就会有一个 主频【每隔1s 向CPU自己发送n次硬件中断】 !这就是为什么 CPU的主频 越快 效率越高,调度的次数越频繁,CPU响应就越快。
操作系统在硬件的推动下,自动调度!!!
// Linux 内核0.11
// main.c
sched_init(); // 调度程序初始化(加载了任务0 的tr, ldtr) (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_call.s
_timer_interrupt:
...
;// do_timer(CPL)执⾏任务切换、计时等⼯作,在kernel/shched.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 的任务,并运⾏之。
}
🍔死循环
OS要调度 由时钟源查中断向量表,OS要处理IO 直接外部设备准备好 发送中断 OS直接根据中断向量表的方法把数据拿出来。
如果是这样,操作系统就可以躺平了,操作系统自己不做任何事情,需要什么功能,就向中断向量表里面添加方法即可。操作系统的本质:就是一个死循环!
void main(void) /* 这⾥确实是void,并没错。 */
{ /* 在startup 程序(head.s)中就是这样假设的。 */
...
/*
* 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到⼀个信号才会返
* 回就绪运⾏态,但任务0(task0)是唯⼀的意外情况(参⻅'schedule()'),因为任
* 务0 在任何空闲时间⾥都会被激活(当没有其它任务在运⾏时),
* 因此对于任务0'pause()'仅意味着我们返回来查看是否有其它任务可以运⾏,如果没
* 有的话我们就回到这⾥,⼀直循环执⾏'pause()'。
*/
for (;;)
pause();
} // end main
这样,操作系统,就可以在硬件时钟的推动下,自动调度了。
🪄时间片
每个进程可以分到一点时间片,当这个进程的时间片 被触发过来
时钟中断在触发时,是固定的时间间隔,给进程设置时间片【task_struct {int count = 1000;}】,每一个时间中断到来了,当前进程在调度【CPU是当前进程的相关数据】并且被 触发了时间中断,要调度中断,进程调度不是切换,即让当前进程对应的时间片进行--, 在当前进程运行期间 只做计数器--,若 当前进程的计数器 != 0,时钟中断就什么都不做;若 当前进程的计数器 == 0 ,就会进行进程切换。时间片的本质:就是PCB内部的计数器!每一次时钟中断触发时,只做调度【判断当前进程的计数器,是否减到 0,减到 0 ,进程时间片到了 就做切换】,不一定做切换。
时钟中断,固定时间间隔,1纳秒 task_struct{ int count = 1000; } 进程调度,task_struct -> count--; 不一定是切换! if(task_struct -> count) // do nothing else 切换!!
🍔软中断
• 上述外部硬件中断,需要硬件设备触发;
• 有没有可能,因为软件原因,也触发上面的逻辑?有!
• 为了让操作系统支持进行系统调用,CPU也设计了对应的汇编指令(x86 32位下为 int[0x80] 或者 x64位 下 syscall),可以让CPU内部触发中断逻辑。
• 汇编指令,就可以写在软件中了,所以 就可以采用类似的软件的原因 来触发CPU执行中断方法。
为了能让OS支持进行系统调用,任何CPU芯片,都设计了对应的内部指令集,有对应的汇编指令。
1. 在软件上使用syscall 或 int[0x80],让CPU拿到中断号[0x80],在中断向量表里面设计一个系统调用 的入口函数【void sys_function(int index) {},直接根据系统调用表,根据系统调用的index下标,就可以进行系统调用 】,把这个函数接口的地址放入 系统调用 里面,OS要提供很多的系统调用【fork、exit、close、open....】,OS在源代码设计上,有一大堆的系统调用,这些系统调用把所有的系统调用的方法全部放在一个数组当中 形成一个系统调用表!未来任何系统调用 在操作系统层面,要调用哪个系统调用 使用该数系统调用的数组下标:系统调用号!
2. 【void sys_function(int index) {}】这个方法干什么呢?给这个方法传入一个参数,根据系统调用表,根据index下标进行系统调用【sys_call_table()】
3. 操作系统有了这个表【void sys_function(int index) {}】,当我们想调用系统调用时 需要把调用的系统调用号 交给OS 并且 在执行syscall xxx 或 int[0x80] 可以让CPU进入到陷入内核的阶段,索引到系统调用,找到系统调用 中断向量表方法的调用逻辑【void sys_function(int index) {}】, 即syscall xxx 或 int[0x80] 让我们开始进入系统调用的 固定历程,【void sys_function(int index) {}】这个方法内部 会直接根据我们传给内核的中断号来执行中断向量表当中的方法,完成系统调用。
问题:
• 用户层怎么把系统调用号给操作系统?提前把系统调用号 写入到CPU的寄存器里面。【寄存器(比如EAX)】
• 操作系统怎么把返回值给用户?-寄存器或者用户传入的缓冲区地址
系统调用的传参 包括 系统调用号 全部都会通过寄存器来传递给OS,然后通过寄存器或者用户传入的缓冲区地址 传递给用户。
• 系统调用的过程,先把我们要调用的 系统调用号 写入到寄存器, 再执行int 0x80、syscall陷入内核,本质就是触发软中断,CPU就会自动执行系统调用的处理方法,而这个方法会根据系统调用号,自动查表,执行对应的方法。
• 系统调用号的本质:数组下标!
• 系统调用,也是通过中断完成的!!!
当我们想要调用系统调用,我们可以不用系统调用的名字,直接写一段汇编代码,把系统调用号 movl 到 寄存器【EAX】里面,接着直接调用 int 0x80 就可以让OS进入系统调用,OS中断方法会自动查表,查表后 自动会调用 指定的方法,然后把结果给我。可是我们用的系统调用 怎么没见过 int 0x80 或者 syscall 呢?都是直接调用 上层的函数的呢?
Linux内核提供的系统调用接口,根本就不是C函数,而是 系统调用号 + 约定的传递参数【返回值的寄存器】 + int 0x80 / syscall 触发软中断的机制 。所以OS给我们提供了 【GNU glibc】给我们把系统调用进行了封装【C语言封装版的系统调用】!所以我们用的所有系统调用,在底层采用的是 C语言 和 汇编语言 混编构成的 系统调用。
🍔缺页中断?内存碎片处理?除零野指针错误?
缺页中断?内存碎片处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断, 然后走中断处理例程,完成所有处理。有的是进行申请内存,填充页表,进行映射的。有的是用来 处理内存碎片的,有的是用来给目标进行发送信号,杀掉进程等等。
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,&irq13);// 设置协处理器的陷阱⻔。
outb_p(inb_p(0x21)&0xfb,0x21);// 允许主8259A 芯⽚的IRQ2 中断请求。
outb(inb_p(0xA1)&0xdf,0xA1);// 允许从8259A 芯⽚的IRQ13 中断请求。
set_trap_gate(39,¶llel_interrupt);// 设置并⾏⼝的陷阱⻔。
}
• 操作系统就是躺在中断处理例程上的代码块!
• CPU内部的软中断,比如int 0x80或者syscall,我们叫做 陷阱
• CPU内部的软中断,比如除零/野指针等,我们叫做 异常。
🪄OS 是基于中断的,是死循环,躺在中断上的,外部有设备就绪 OS 就运行,外部没有设备就绪 时钟中断 就会一直推动 OS ,时钟中断 每隔固定时间 触发一次,固定时间内 OS 的 CPU 就可以工作在调度进程的这件事,当固定时间到来 OS 就会直接中断 让 OS 执行 中断向量表中的 基于中断服务:进程调度, 调度的时候 就会检测 当前进程的时间片【计数器】,计数器--,减到0,就 再从进程列表里 选择进程执行【大 O(1) 调度算法】。
🪄 系统调用 --> 查 中断向量表 --> 执行软中断【执行系统调用】 --> 根据外部传输的 中断号 直接索引 OS 内的 系统调用函数指针表 执行对应的系统调用 --> 执行完毕,返回结果。
🪄有一个函数A 调度到 函数B,为什么 B返回后 能返回到 A 的下一行代码 并且 还能把返回值拿出来?
当A 在调用 B 时,在给 B 形成栈帧结构时,A函数 会把它的下一条指令的地址 先入栈,把形参各种实例化再入栈,即 B 在调用时,头部就已经有 A 的返回值 和 下一条要执行的指令,B 执行完之后,会弹栈出来。
🪄 普通人也可以使用 int 0x80 或 syscall 进入内核里,进行系统调用,这样子的话操作系统,会不会不安全?
OS只允许使用系统调用的方式来访问OS,当传入错误的系统调用号 或 陷入内核做其他的事情 访问其他数据结构,OS是不允许的,即 系统调用本质就是可以让OS安全访问!
🪄 最早期的OS,是进程加载切换的模块,后来基于 中断处理 设计出来一个大的程序块。
三、🌟如何理解内核态和用户态
1. OS有巨大的 中断向量表,在开机的时候 从外设 直接 拷贝到 内存 当中了,包括 系统调用表【函数方法】和 各种异常处理方法 和 OS 内的各种数据结构,
2. OS --- 其中 OS 内 不管是 系统调用、各种异常处理方法、打开文件、调度进程... 本质都是 通过系统调用方法 去访问 OS 里面的各种数据结构,把数据结构的操作方法 以函数的方式 提供出来,最后把 所有函数包装成 系统调用,让外部就能以硬件或软件中断的方式去调用。
3. Linux 操作系统 让 每一个进程 都有自己的 虚拟地址,[0,4GB] 的空间,其中 [0,3GB] 为 用户区【用户想要访问自己的代码和数据不用任何系统调用,能直接进行访问】,用户区 提供了 用户页表 把代码和数据 进行 虚拟地址 到 物理地址 的 映射,有了虚拟地址 编写可执行程序时 ,可执行程序在编译器编译时 形成对应的代码 全部以 虚拟地址统一 进行编址,加载这个可执行程序时,就可以用这个程序内部的虚拟地址 来初始化地址空间 和 构建页表 ;[3,4GB] 为 内核区;
4. 内核页表,映射关系单一【OS 是 开机后 加载的第一个软件块,所以 OS 在内存当中 所占据的内存位置 往往可以固定下来】,OS 本身 整体通过 内核页表 整体映射到 内核区 [3,4GB],在内核区里 用户最关心的就是 系统调用! 【只能通过系统调用访问OS[由内核用户态决定的]】
5. 用户不关心 系统调用的物理或虚拟地址,只需关心 系统调用号,OS 内部自己会进行 索引 查找系统调用,所以用户在虚拟地址的代码区里 编译我们调用的 系统调用函数 时,只要跟 glibc 合并 链接,C语言 告诉 用户 系统调用号 是多少,用户就可以直接调用系统调用了。接着在自己的代码区 跳转至 内核区 【跳转用 软中断 int 0x80 或 syscall】,陷入内核 通过 寄存器EAX 把 中断号 给 OS ,OS 内中断处理逻辑的代码 就会 寄存器 里读 中断号 索引 系统调用函数指针表 ,调用要用的方法,调用之前把返回值入栈, 调用完成后 把返回值 弹栈 返回到代码区。所以 我们调用任何函数(库、系统调用),都是我们自己进程的地址空间中进行调用。
6. 不同进程的虚拟地址空间中的 [0,3GB] 用的 用户页表 全都不一样 使用的是不同的物理内存 使用的是 自己的代码和数据;不同进程的虚拟地址空间中的 [3,4GB] 全部都是使用一样的 物理内存。即 OS 无论怎么切换进程,都能找到同一个 OS !换句话说,OS 系统调用方法的执行,是在进程的地址空间中执行的!
7. 不管是通过哪一个进程的地址空间【内核区】,进入内核,都是通过 软中断 进入 OS 的!
8. 用户态 VS 内核态
• 硬件上:【修改值】处于 用户态 或 内核态 不仅仅是由软件决定的【当前的内核页表、软件中断 都不重要】,主要是由 CPU 来决定的,CPU 里有一个 cs段寄存器【其中有两个比特位 为 CPL 当前权限级别,0:表示处于 内核态,3:表示处于 用户态】,从 内核态 切换到 用户态 ,让 CPU 修改自己的执行级别 由 用户态 3 --> 内核态 0。
• 软件上:调用 int 0x80 或 syscall。
9. 用户态 如何进入 内核态 ?
• 时钟/外设中断
• CPU内部出现异常
• 陷进【系统调用 int 0x80 / syscall】
进程会一直 从 用户态 转到 内核态,因为CPU一直都有 时钟中断!
• 关于特权级别,涉及到段,段描述符,段选择子,DPL,CPL,RPL等概念,而现在芯片为了保证 兼容性,已经非常复杂了,进而导致OS也必须得照顾它的复杂性。
• 用户态就是执行用户[0,3]GB时所处的状态;
• 内核态就是执行内核[3,4]GB时所处的状态;
• 区分就是按照CPU内的CPL决定,CPL的全称是Current Privilege Level,即当前特权级别。
• 一般执行 int 0x80 或者 syscall 软中断,CPL会在校验之后自动变更。
切换流程:
1. 从用户态切换到内核态时,首先用户态可以直接读写寄存器,用户态操作CPU,将寄存器的状态 保存到对应的内存中,然后调用对应的系统函数,传入对应的用户栈地址和寄存器信息,方便后 续内核方法调用完毕后,恢复用户方法执行的现场。
2. 从用户态切换到内核态需要提权,CPU 切换指令集操作权限级别为 ring 0。
3. 提权后,切换内核栈。然后开始执行内核方法,相应的方法栈帧时保存在内核栈中。
4. 当内核方法执行完毕后,CPU切换指令集操作权限级别为 ring 3,然后利用之前写入的信息来恢 复用户栈的执行。
如若对你有帮助,记得关注、收藏、点赞哦~ 您的支持是我最大的动力🌹🌹🌹🌹!!!
若有误,望各位,在评论区留言或者私信我 指点迷津!!!谢谢 ヾ(≧▽≦*)o \( •̀ ω •́ )/