再谈操作系统与内核区
1、浅谈虚拟机和操作系统映射于地址空间的作用
我们调用任何函数(无论是库函数还是系统调用),都是在各自进程的地址空间中执行的。无论操作系统如何切换进程,它都能确保访问同一个操作系统实例。换句话说,系统调用方法的执行实际上是在进程的地址空间内完成的。
基于这种机制,如果让不同进程同时映射到不同的操作系统实例,是否就可以实现在多个“系统”环境中运行?这与虚拟机的实现密切相关。
虚拟机主要分为两种——内核级虚拟机和用户级虚拟机。
- 内核级虚拟机提供了硬件级别的资源隔离和环境模拟,允许在同一物理机器上运行多个操作系统实例。
- 用户级虚拟机通常指的是那些不需要操作系统层面支持的应用层隔离方案,如Java虚拟机。
Linux中的Docker就是一个例子,它利用了Linux的命名空间和资源隔离技术来实现类似虚拟机的功能。通过这种映射,我们可以创建多个相互隔离的应用环境,从而更好地理解为什么需要进行这样的映射以及它可以带来哪些有趣的可能性。
2、访问内核区需要软件和硬件层面的支持
此外,不论是通过哪个进程的地址空间,最终访问的都是同一个内核操作系统,并且是通过软中断进入内核区操作的。在进入内核区之前,进程会从用户态转变为内核态。这个转变不仅需要软件层面的许可,还需要硬件的支持,比如CPU的当前特权级别(CPL,Current Privilege Level)。CPL 为 0 表示内核态,为 3 表示用户态,存储在CS段寄存器中,占用 2 比特位。只有当CPL从 3 变为 0 时,进程才能访问内核 [3,4]GB 的空间位置。
CPU内部的内存管理单元(MMU)负责将虚拟地址转换为物理地址。在用户态下,只能访问[0,3]GB的空间,无法直接访问内核区的[3,4]GB地址空间。因此,用户程序不能直接访问内核数据;而是通过触发软中断(例如使用int 0x80
或syscall
指令)间接访问。这些指令会触发CPU的固定例程,执行完后恢复用户代码的执行上下文。
如果用户自己非法访问内核区代码,会触发访问内核内存的保护
- 内存保护:当用户程序试图访问内核空间的内存(例如
[3, 4] GB
区域)时,MMU 会检测到这是一个无效的内存访问,并触发一个页面错误。- 异常处理:内核会捕获这个页面错误,并根据情况进行处理,通常会终止违规的用户进程并生成一个错误报告。
假设用户程序试图直接访问内核内存:
void *kernel_ptr = (void *)0xC0000000; // 假设这是内核空间的一个地址 *(int *)kernel_ptr = 42; // 尝试写入内核内存
在这个例子中,当程序执行到
*(int *)kernel_ptr = 42;
时,MMU 会检测到这是一个无效的内存访问,并触发一个页面错误。内核会捕获这个错误,终止该进程,并生成一个段错误(Segmentation Fault)。
3、Linux 的两种权限等级
具体可以看这篇文章:【Linux系统】CPU指令集 和 Linux系统权限 ring 0 / ring 3
Linux 中只有 0 和 3 两种权限等级,没有 1 和 2,那为什么CPU设计不用 1 个比特位表示就好?
因为 Linux 系统如此,不代表其他系统,其它系统需要使用 1 和 2,就要留着,空间设计成 2 比特位大小
很多时候,这些“奇怪不统一”的设计,一般都是为了兼容不同系统等其他需求
拓展:
现代 CPU 通常有多个特权级别(也称为环或模式)。常见的特权级别有:
- Ring 0:最高特权级别,内核模式。操作系统内核代码在这里运行,可以访问所有硬件资源和内存。
- Ring 3:最低特权级别,用户模式。用户程序在这里运行,只能访问分配给它的内存和有限的硬件资源。
4、操作系统如何做到定期检查并刷新缓冲区?
操作系统通过创建特定的进程或线程来执行诸如定期检查和刷新缓冲区这样的固定任务。这些进程或线程专门用于处理一些系统级的维护工作,确保系统的高效运行。
- 内核固定例程:这类例程包括了用于执行刷新缓冲区等操作的进程或线程。它们是操作系统为了完成某些周期性或持续性的任务而设置的,比如刷新文件系统的缓冲区以确保数据的一致性和最新状态。
此外,操作系统还会安排特定的进程或线程来定期检查定时器是否超时。这种机制对于实现延迟执行、轮询或其他基于时间的操作至关重要。
- 定时器检查例程:这是另一类内核固定例程,专注于检测定时器是否已经到达预设的时间点。这有助于触发事件、执行预定的任务或者进行其他需要定时执行的操作。
在这些场景中,操作系统扮演的角色主要是调度这些固定例程的进程或线程,确保它们能够按时执行所需的任务而不干扰到其他用户进程的正常运行。通过这种方式,操作系统不仅保证了内部管理任务的有效执行,还维护了整个系统的稳定性和效率。
再谈细节:操作系统处理自定义信号
1、信号捕捉方法执行的时候,为什么还要做权限切换?直接内核权限执行不可以吗??
信号捕捉的方法是用户自定义的,如果允许以内核的权限执行这个方法,
这个方法里万一有:删除用户、删除 root的配置文件、非法赋权等非法操作指令怎么办
我们对应的操作系统不就助纣为虐了吗,岂不是会让某些用户钻漏洞,基于信号捕捉来利用操作性的内核的这种权限优先级更高的这种特性
因此如果不转回用户态执行用户自定义信号处理函数,则会有严重的安全风险
这些删除用户、删除 root的配置文件、非法赋权等操作指令,用户自己可以通过一些开放的允许的操作接口达到目的,这样只会影响到操作的用户本身,而不会影响整个系统的其他用户
这样达到了保护其他用户的目的
2、信号处理完,只能从内核返回:信号自定义处理函数执行完了,直接切换到同为用户态的 main 执行的主程序不行吗,为什么还要再切换回内核
答:若想从一个函数执行完毕返回到另一个函数,这两个函数必须曾经要有调用关系
因为只有我调你时,形成函数栈帧,主调用函数会把被调用函数所对应的返回值地址代码的地址入栈,将来调完再弹栈,就能回到原函数,而当前的 main函数和 信号自定义处理函数这 2 个函数现在有调用关系吗?答案是根本就没有
信号自定义处理函数是被内核中信号处理的相关程序所调用的,因此在信号自定义处理函数运行完,就需要回到内核的程序调用处,再从内核态回到用户态
3、回到 main 主程序,如何知道从哪条程序语句继续执行
PC 指针保存着下条指令地址,当中断陷入内核前就已经将PC指针的值作为上下文保护起来了
信号捕捉的补充
1、系统调用 sigaction
这个系统调用和 signal
差不多,只是这个功能更加丰富一点(wait/waitpid
的关系一样)
该结构体内
- 第一个属性
void(*sa handler)(int)
:指向我们信号的自定义函数Handler
- 第二个属性:用于处理实时信号,不用管,
- 第三个属性
sa_mask
:后面会讲解 - 第四个属性
sa_flags
: 一般置为 0 - 最后一个属性也不要管
使用该代码:就直接当作 signal
函数使用即可,只是稍稍使用形式上不同
#include<iostream>
#include <unistd.h>
#include <signal.h>
void Handler(int signum)
{
std::cout << "get a signal : " << signum << '\n';
exit(1);
}
int main()
{
struct sigaction act, oact;
act.sa_handler = Handler;
::sigaction(2, &act, &oact);
while(true)
{
pause();
}
return 0;
}
运行结果如下:
2、问题:信号是否会被嵌套处理?
问题:处理信号期间,有没有可能进行陷入内核呢?
答:有可能!因为信号自定义函数里面也可以调用系统调用
问题:同一信号会被嵌套处理吗?
当在自定义信号处理函数中处理信号期间,若有相同的信号出现,是否会触发中断,重新开始执行一轮该信号的自定义信号处理函数,导致形成一种嵌套递归呢?
如果此时有其他不同的信号产生,是否会嵌套进行多信号的自定义处理呢??
都不会,在一个信号处理期间,OS会自动的把所有后续产生的信号的 block 位设置为 1,以致于保证一个信号的完整处理完成
信号处理完成,会自动解除对其他信号的block位限制,然后就按顺序串行处理这些信号
证明:信号不会嵌套处理
代码如下:
自定义处理函数循环不停,该函数运行期间,我们不断键盘输入:ctrl+c
,发送 2 号信号
#include<iostream>
#include <unistd.h>
#include <signal.h>
void Handler(int signum)
{
static int cnt = 0;
cnt ++; // 每次处理信号,cnt 自增一次,用此证明是否会信号嵌套处理
while(true)
{
std::cout << "get a signal : " << signum << ", cnt: " << cnt << '\n';
sleep(1);
}
exit(1);
}
int main()
{
struct sigaction act, oact;
act.sa_handler = Handler;
::sigaction(2, &act, &oact);
while(true)
{
pause();
}
return 0;
}
运行结果如下:
可以发现,计数器 cnt
一直为 1 不变,证明了连续发送同一信号不会造成嵌套
同理,发送其他不同信号,也不会立刻被处理的(可以自己试试)
这一切的底层原理:操作系统会在一个信号处理期间,将后续的信号全都在 Block
中屏蔽掉
使得后续信号不会立即被处理
证明:其原理
1、当 2 号信号在处理时,循环打印当前进程的 Block 表
#include<iostream>
#include <unistd.h> // 提供sleep函数和pause函数
#include <signal.h> // 提供信号处理相关函数和结构体
// 打印当前进程屏蔽信号集中的阻塞信号
void PrintBlock()
{
sigset_t set, oldset; // 定义两个信号集,一个用于设置,另一个用于保存旧的状态
sigemptyset(&set); // 清空信号集set
sigemptyset(&oldset); // 清空信号集oldset
std::cout << "Block: "; // 打印提示信息
for(int signum = 31; signum >= 1; --signum) // 遍历信号编号从31到1
{
sigprocmask(SIG_BLOCK, &set, &oldset); // 获取当前进程的信号屏蔽字,并将其存储在oldset中
int ret = sigismember(&oldset, signum); // 检查信号signum是否在oldset中
if(ret != -1) // 如果检查成功(即ret不是错误码)
std::cout << ret; // 打印结果,1表示该信号被阻塞,0表示未被阻塞
}
std::cout << '\n'; // 打印换行符
}
// 信号处理函数
void Handler(int signum)
{
std::cout << "get a signal : " << signum << '\n'; // 打印接收到的信号编号
while(true)
{
PrintBlock(); // 调用PrintBlock函数打印当前进程的信号屏蔽状态
sleep(1); // 线程睡眠1秒
}
//exit(1); // 注释掉的退出语句
}
int main()
{
struct sigaction act, oact; // 定义信号行为结构体变量
act.sa_handler = Handler; // 设置信号处理函数为Handler
::sigaction(2, &act, &oact); // 修改信号2(SIGINT,通常是Ctrl+C产生的中断信号)的行为
while(true)
{
pause(); // 暂停执行,等待信号的到来
}
return 0;
}
运行结果如下:
如图,OS 将 2 号 3 号信号都给屏蔽了(至于为什么 3 号也被屏蔽了,后面解释)
2、当 2 号信号处理完后,即信号处理结束后,打印当前进程的 Block 表
我将该结束后打印语句 PrintBlock()
放到 main 函数内部的循环中
while(true)
{
PrintBlock();
pause();
}
完整代码: 我去掉了 2 号信号自定义处理函数中的循环,为了让处理函数能退出
#include<iostream>
#include <unistd.h>
#include <signal.h>
void PrintBlock()
{
// 循环打印本进程的 Block 表
//int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
sigset_t set, oldset;
sigemptyset(&set);
sigemptyset(&oldset);
std::cout << "Block: ";
for(int signum = 31; signum >= 1; --signum)
{
sigprocmask(SIG_BLOCK, &set, &oldset);
int ret = sigismember(&oldset, signum);
if(ret != -1)
std::cout << ret;
}
std::cout << '\n';
}
void Handler(int signum)
{
std::cout << "get a signal : " << signum << '\n';
PrintBlock();
sleep(2);
//exit(1);
}
int main()
{
struct sigaction act, oact;
act.sa_handler = Handler;
::sigaction(2, &act, &oact);
while(true)
{
PrintBlock();
pause();
}
return 0;
}
运行结果如下:
效果很明显了吧!
3、struct sigaction
的 sa_mask
我们使用代码打印出来看看,看一下默认创建的 struct sigaction
,其中的 sa_mask
会是什么样子的:
代码如下:
#include<iostream>
#include <unistd.h>
#include <signal.h>
void Handler(int signum)
{
std::cout << "get a signal : " << signum << '\n';
//PrintBlock();
sleep(2);
//exit(1);
}
int main()
{
struct sigaction act, oact;
act.sa_handler = Handler;
// 查看一下默认设置的屏蔽信号:发现确实默认在一个信号处理阶段,不能再收到 2 和 3 号信号
std::cout << "sa_mask: ";
for(int signum = 31; signum >= 1; --signum)
{
int ret = sigismember(&act.sa_mask, signum);
if(ret != -1)
std::cout << ret;
}
std::cout << '\n';
::sigaction(2, &act, &oact);
while(true)
{
pause();
}
return 0;
}
运行结果如下:
这串信号的 01 你是否觉得似曾相识,这个不就和前面测试:信号处理期间,系统默认在 Block
中屏蔽某些信号,而我们前面打印出来的 Block
表,刚好屏蔽了 2 号和 3 号!!
直说:这个属性就是使用 struct sigaction
来自定义处理某个信号时,设置在该信号处理期间,默认需要屏蔽的信号
如果想要屏蔽其他信号,也可以自己手动设置:
代码如下:打印默认的和设置后的 sa_mask
值,ctrl+c
发送 2 号信号,打印 block
表
#include<iostream>
#include <unistd.h>
#include <signal.h>
void PrintBlock()
{
// 循环打印本进程的 Block 表
//int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
sigset_t set, oldset;
sigemptyset(&set);
sigemptyset(&oldset);
std::cout << "Block: ";
for(int signum = 31; signum >= 1; --signum)
{
sigprocmask(SIG_BLOCK, &set, &oldset);
int ret = sigismember(&oldset, signum);
if(ret != -1)
std::cout << ret;
}
std::cout << '\n';
}
void Handler(int signum)
{
std::cout << "get a signal : " << signum << '\n';
PrintBlock();
sleep(2);
//exit(1);
}
int main()
{
struct sigaction act, oact;
act.sa_handler = Handler;
// 查看一下默认设置的屏蔽信号:发现确实默认在一个信号处理阶段,不能再收到 2 和 3 号信号
std::cout << "设置前默认的 sa_mask: ";
for(int signum = 31; signum >= 1; --signum)
{
int ret = sigismember(&act.sa_mask, signum);
if(ret != -1)
std::cout << ret;
}
std::cout << '\n';
// 手动设置 sa_mask
sigset_t myset;
// int sigaddset(sigset_t *set, int signum);
sigaddset(&myset, 3);
sigaddset(&myset, 4);
sigaddset(&myset, 5);
sigaddset(&myset, 6);
sigaddset(&myset, 7);
act.sa_mask = myset; //设置到 2 号信号的 sa_mask 中
std::cout << "设置后的 sa_mask: ";
for(int signum = 31; signum >= 1; --signum)
{
int ret = sigismember(&act.sa_mask, signum);
if(ret != -1)
std::cout << ret;
}
std::cout << '\n';
::sigaction(2, &act, &oact);
while(true)
{
//PrintBlock();
pause();
}
return 0;
}
运行结果如下:
4、问题:处理信号时,Pending是在处理信号之前就置为0,还是处理信号完成后才置为0
答:Pending是在处理信号之前就置为0,
1、从定义来看,Pending的意思为信号未决,即未被处理的信号,如果信号处理完成,岂不是处于pending表的这个信号定义不确定了:处理完了,还算做未被处理的pending吗???
2、从作用来看,这个也是根本原因,当你处理pending表的某个信号,在该信号处理期间,用户可能再向该进程发送相同编号的信号,此时如果 pending表的信号没有置为 0,那用户如何清楚这个信号是旧信号还是新信号?
证明阶段:打印pending表
代码如下:在 2 号信号的自定义处理函数中打印当前进程的信号 Pending表
void PrintPending()
{
// 循环打印本进程的 Pending 表
//int sigpending(sigset_t *set);
sigset_t set;
sigemptyset(&set);
std::cout << "Block: ";
for(int signum = 31; signum >= 1; --signum)
{
sigpending(&set);
int ret = sigismember(&set, signum);
if(ret != -1)
std::cout << ret;
}
std::cout << '\n';
}
void Handler(int signum)
{
std::cout << "get a signal : " << signum << '\n';
std::cout<<"开始处理2号信号的 pending: ";
PrintPending();
sleep(2);
//exit(1);
}
int main()
{
struct sigaction act, oact;
act.sa_handler = Handler;
::sigaction(2, &act, &oact);
while(true)
{
pause();
}
return 0;
}
运行结果如下:
可以发现,2 号信号的 pending位置已经被置为 0 了,说明根本不是在处理信号后才做处理
重入函数
这个情况我们不做演示,这种情况概率太低,暂时是做不出来的,
什么样的函数算作 :不可重入函数 和 可重入函数
不可重入函数
当该函数中使用一些全局的资源,如某些全局数据结构(全局链表、全局数组、全局红黑树…)
就是调一次这个函数,数据变化会随着你的调用而变化。
可重入函数
当该函数定义和使用的都是函数内的局部变量和数据,每次调用该函数都会为该函数创建一份单独的函数栈帧空间,则不同执行流重复调用该函数,互不干扰
但是要保证不能传同一个参数
可重入函数可以被中断并在相同的线程或者其他线程中重新进入(包括递归调用),而不会导致任何数据不一致或其他问题。这种特性对于编写并发程序非常重要
为了确保函数的可重入性,通常需要注意以下几点:
- 使用局部变量:函数内部使用的变量应该是局部变量,这样每次调用函数时都会为这些变量分配新的栈空间,不会影响其他调用。
- 避免全局变量和静态变量:全局变量和静态变量在多次调用之间会保持其值,这可能导致线程安全问题。
- 避免使用非可重入函数:如果函数内部调用了其他非可重入函数,那么整个函数也会变得不可重入。
- 参数传递:传入的参数应该是独立的,不能是共享的数据结构,除非这些数据结构本身是线程安全的。
不可重入函数 和 可重入函数一定是优点或缺点吗?
这两者仅仅是一种特性,没有好坏之分
内向不是缺点,这是人的一种特点,内向的人也有适合做的事情,没有优缺之分
可重入函数的例子
像是这种函数名带 _r
的基本都是可重入函数(系统设计好的)
volatile
这是C语言的关键字,平时比较少用,但是需要了解一下
1、演示不可重入函数的现象
代码:这段代码中存在两个执行流(一个是 main
函数的循环,一个是 信号处理函数),当接收到信号2 时,执行自定义信号处理函数,在自定义信号处理这个执行流中,将全局变量变成 1,使得 main 主执行流中的 while(!flag) {};
退出
#include<stdio.h>
#include<signal.h>
int flag = 0;
void handler(int signum)
{
printf("get a signal, signum: %d\n", signum);
flag = 1;
}
int main()
{
signal(2, handler);
while(!flag) {};
printf("我正常退出了\n");
}
2、编译器优化:O1、O2
CPU运行这段代码,CPU内部存在两种运算:算术运算和逻辑运算
逻辑运算就是判断真与假相关逻辑
执行这句代码 while(!flag) {};
,CPU需要不断循环的先从内存中取到 flag
的值,再在CPU中做逻辑运算这两步
我们可以为代码添加优化:如 O1
优化、 O2
优化、
现代编译器如 GCC 和 Clang 提供了多种优化级别,这些优化级别可以帮助编译器生成更高效的机器码。下面是这些优化级别的简要介绍和一些常见的使用场景:
优化级别
- -O0(默认)
- 不进行任何优化,编译速度快,调试信息完整。
- 适用于开发和调试阶段。
- -O1
- 启用基本的优化,包括常量折叠、死代码消除、简单的指令调度等。
- 平衡了编译时间和代码性能,适合快速构建和测试。
- -O2
- 启用更多的优化,包括函数内联、循环优化、更复杂的指令调度等。
- 在大多数情况下,这是推荐的优化级别,因为它提供了较好的性能提升而不会显著增加编译时间。
- -O3
- 启用所有可用的优化,包括激进的函数内联、循环展开、向量化等。
- 可能会增加编译时间和二进制文件的大小,但通常能提供最佳的性能。
- 适用于性能要求极高的应用。
编译器在启用优化(如 -O1
及更高级别)时,会尝试将常量或很少变化的变量优化为寄存器变量,以减少内存访问的开销
从 O1
优化开始,编译器会为代码添加各种优化,其中会将一些常量或整个程序中不变的量直接设置成寄存器数据,相当于使用 register
修饰该数据,表示既然你这个量不会变,干脆将其直接设置到寄存器中,这样在访问某个变量时,就不用频繁的访问内存获取该数据,如 while(!flag) {};
,不用频繁的访问内存获取 flag
,相当于直接和编译器说这个量我不常改动,你固定使用第一次定义的值即可,就不会去内存中获取了
这说明如果开启这个优化,你在程序中修改某个变量,编译器可能不会使用更新后的量
3、volatile
的作用
volatile
作用: 意思是可变的,保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。
说白了: 前面的编译器优化会将某些变量优化至寄存器中,让程序无需多次访问内存取数据,而这个 volatile
的作用就是不允许让编译器对该变量的优化
如果没有使用 volatile
关键字,编译器在启用优化(如 -O1
及更高级别)时,可能会将 flag
的值优化为寄存器中的值,从而导致 while
循环变成死循环。这是因为编译器假设 flag
的值在 while
循环中不会发生变化,因此会将 flag
的值加载到寄存器中,并在每次循环迭代中使用寄存器中的值,而不是重新从内存中读取。
当变量使用 关键字 volatile
修饰时,表示该变量我可能会修改他,编译器不能将其优化成寄存器变量,就不会出现开启编译器优化时导致的该变量被优化进入寄存器的情况
volatile int flag = 0;
CPU访问该变量就还需要从内存中取出,这叫做保存内存的可见性