在上篇文章中,我们在讲信号的处理的时候,牵涉出了用户态与内核态的概念。
在那之后,为了方便大家理解,我为大家讲解了计算机组成原理中,关于中断的知识点。
今天,我们就承接上文的内容,继续为大家解答,用户态与内核态具体是什么的内容。
我们之前说,处理信号不是立即处理的,而是要等到合适的时候。
那么什么是合适的时候呢? :内核态切换为用户态的时候!!
所以就由此扯出了用户态与内核态,而如果要给大家说清楚用户态与内核态,我们就不得不谈论起我们的老朋友:虚拟地址空间。
我们之前说过:虚拟地址空间是操作系统为每个进程提供的抽象内存视图,使得每个进程认为自己独占整个内存。
我们这里(后面也是)都以32位操作系统为例子,zai32位操作系统下,虚拟地址空间的大小通常是4GB(因为32位地址可以寻址2^32 = 4GB的空间)。
而在这4gb的虚拟地址空间中,我们又把32位的虚拟地址空间被划分为两部分:
我们之前说过,每一个进程都以为自己拥有着完整的这4GB大小的空间,而每个进程中又含有独属于自己的一个页表,负责帮助该进程由虚拟地址空间转化为物理地址。
但实际上,这个页表包含两部分:用户级页表和内核级页表。用户级页表负责的是用户级虚拟地址空间,也就是0-3GB大小的空间,这部分是每个进程私有的,不同进程的相同用户虚拟地址会映射到不同的物理页面。
剩下的1GB空间,也就是我们说的内核空间,实际上是所有进程共享的。这部分空间的内核级页表在所有进程中都指向相同的映射关系,每个进程的内核级页表内容是完全一样的。内核级页表映射的正是操作系统的代码和数据,所以不同的进程都可以通过这个共享的内核映射找到同一个操作系统,也都可以被同一个操作系统调度。
当进程切换时,虽然会切换整个页表(包括用户部分和内核部分),但内核部分的映射关系始终保持不变,这样就能保证所有进程看到的操作系统内核都是一致的,同时也保证了操作系统可以统一管理所有进程。
所以,对于任何进程,无论你如何进行调度,任何进程,都可以找到同一个操作系统!!!(题外话,那如果我们让3-4gb的虚拟内存映射到不同的地址上,不就可以找到不同的操作系统了吗?这就是我们虚拟机的原理)
讲到这里,我们就可以牵引出CPL的概念了。
CPL(Current Privilege Level,当前特权级)是CPU中CS(代码段寄存器)的最低2位,表示当前正在执行的代码的特权级别:
当我们的进程在用户态运行时,CPL=3,只能访问用户空间的页表映射(0-3GB),无法执行特权指令(如修改CR3寄存器)。
而当我们的进程通过系统调用/中断进入内核时,CPU自动将CPL切换到0,此时可以访问全部内存(包括内核空间3-4GB)和执行特权指令。
所以:
用户态就是执行用户[0,3]GB时所处的状态,内核态就是执行内核[3,4]GB时所处的状态
一般来说,从用户态切换为内核态,我们有以下几种方法:系统调用(如int 0x80或syscall指令),硬件中断(如时钟中断),异常(如缺页异常),此时CPU自动将CPL从3→0
而通过iret或sysret指令返回时,CPL将自动从0→3。
我们都只到write是一个C语言封装的系统调用,以我们调用write为例:
当进程调用write()时,会经过以下步骤:
syscall指令。
iret将CPL恢复为3。

现在我们就更加理解这幅图了。
由于我们的时钟中断(其它中断)或者某些异常,或者系统调用,我们在执行一个进程的main函数时随时可能从用户态进入内核态。
之后,我们会处理这个异常信号,如果是默认的处理动作(大部分会是终止进程),就执行默认的处理,随后会回到用户态。
如果是自定义的处理动作,我们也会回到用户态进行处理。
为什么不在内核态进程处理呢???
因为权限,为了安全!!!内核态权限太大了,万一你自定义的处理是删掉什么重要的系统文件呢?
所以我们得回到用户态。
那为什么在这里之后我们还要通过特殊的系统调用回到内核态呢?
为什么你就不能执行完你的自定义处理函数后直接回到原本的执行的代码上呢?
同学们,在函数的栈帧创建的时候我们可以知道,我们之所以能从一个函数返回到另外一个函数上,是因为这两个函数存在调用关系,递归调用,所以我们可以递归回去。
但是自定义的处理方法与我们原本执行的代码可没有任何调用关系,所以我们需要回到内核态,找到调用关系,才能回到我们之前执行的代码上面去。

我们之前学过signal函数,这里我们再介绍一个函数:sigaction,他的作用与signal是一样的。
它的结构类似于我们上篇文章所说的sigprocmask函数,也具有三个参数。其中,第二个第三个也都是一样的作用,第二个参数是我们想要改变成的配置,第三个是保存旧的信号处理配置。第一个参数,就是信号的编号,返回值为成功返回0,失败返回-1。
这里我们出现了一个新的结构体,叫做struct sigaction。
struct sigaction {
void (*sa_handler)(int); // 简单信号处理函数
void (*sa_sigaction)(int, siginfo_t *, void *); // 高级信号处理函数
sigset_t sa_mask; // 阻塞的信号集
int sa_flags; // 控制信号行为的标志
void (*sa_restorer)(void); // 已废弃,不再使用
};我们这里只需要知道这个结构体中的第一个与第三个成员变量就行了。
首先是第一个成员变量,这个就是我们使用signal时用到的处理方法。
我们可以简单写一个例子来使用一下:
void handler(int signo)
{
std::cout<<"get a signal"<<signo<<std::endl;
exit(1);
}
int main()
{
struct sigaction act ,oact;
act.sa_handler=handler;
::sigaction(2,&act,&oact);
while(true)
{
pause();
}
return 0;
}
诶,这个时候,同学们,我想问一下大家,在我们进入自定义处理方法进行处理的时候,这个时候会不会陷入内核中呢?
答案是:会的。
那我们一直来2号信号,那他岂不是会一直递归,最后栈溢出吗?
所以,为了防止这个问题出现,OS不允许信号处理方法进行嵌套。
:当某一个信号在处理的时候,操作系统会自动把对应信号的blcok位设置为1。当信号完成时,会自动解除1为0.
所以我们的信号处理方法只需要串行(即把这个处理方法执行完接着执行下一个处理方法),而不允许嵌套。
我们之前讲信号的保存的时候曾经说过:

所以,sigaction中有一个成员变量: sa_mask,这个玩意就包含我们执行该信号时,同时自动会进行屏蔽的信号集合:
我们可以实验一下:
void PirintBLock()
{
std::cout<<"pid:"<<getpid()<<std::endl;
sigset_t set, oset;
sigemptyset(&set);
sigemptyset(&oset);
//我们这里什么信号也没有进行屏蔽,只是单纯调用sigprocmask获取就的信号集
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;
PirintBLock();
sleep(1);
// break;
}
//std::cout<<"get a signal"<<signo<<std::endl;
//exit(1);
}
int main()
{
struct sigaction act ,oact;
act.sa_handler=handler;
::sigaction(2,&act,&oact);
while(true)
{
std::cout<<"pid:"<<getpid()<<std::endl;
//我们这里打印block位图
PirintBLock();
pause();
}
return 0;
}
后面的循环打印的过程,是全程在信号处理函数中的,也就是说,打印这个的过程全程是信号处理阶段的blcok表。
可以看见,我们的blcok表一开始全部都是0,当我们发送2号信号后,2,5,8,10号信号也跟着一起被屏蔽了,此时就佐证了会把自己屏蔽避免递归的情况,同时,也有可能会屏蔽其他信号。值得注意的是,由于我们一开始没有给sigaction结构体的sa_mask传递要屏蔽的信号,所以它内部信号集全是0.
但就算是这样,进程仍然会屏蔽你当前正在处理的信号,哪怕你的sa_mask为0,这个自动阻塞是内核内部行为,不会修改你设置的 sa_mask。
所以我们可以先设置几个参数:
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)
{
std::cout << "pid:" << getpid() << std::endl;
// 我们这里打印block位图
PirintBLock();
pause();
}
return 0;
}
再次运行代码,发现我们自己设置的信号全部也都被屏蔽了。
blcok表有所变化,那么pending表呢?
同学们,我想问一下,当我们去处理2信号时,是因为检测到了2信号的pending为1,我们后面会把1置为0,那么我们是什么时候置为0的呢?
是在处理信号之前,还是处理信号之后呢?
答案是处理之前,因为只有这样,你后面发过来的信号2我们才能把他的pending置为1,在此次的2信号处理完后,串式处理信号。
我们可以同样打印一下pending表:
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;
//PirintBLock();
PrintPending();
sleep(1);
// break;
}
// std::cout<<"get a signal"<<signo<<std::endl;
// 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)
{
std::cout << "pid:" << getpid() << std::endl;
// 我们这里打印block位图
//PirintBLock();
PrintPending();
pause();
}
return 0;
}
可以看见,当我们第一次发送2号信号后 ,开始进行处理流程,此时打印的pending表的2号位为0,当我们再次发送2信号,2号位就变成了1.这也就验证了我们之前的结论。

大家请看上图,当我们在执行插入节点的操作是,当我们刚刚把新节点的next指向了一个节点,此时就传来信号了。
我们进入信号处理流程,发现里面仍然是一个插入函数,于是我们执行插入函数,结束后回到第一个insert中执行。
最后如图4,我们可以发现,第二次插入的node2已经成了一个野指针,我们已经找不到他了,这就造成了内存泄漏。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,就被称为重入。
insert函数访问一个全局链表,有可能因为重入而造成混乱,像这样的函数被称为不可重入函数,反之,如果一个函数只访问自己的局部变量与参数,则被称为可重入函数。
为什么两个不同的控制流程调用同一个函数,访问他的同一个局部变量或者参数就不会造成混乱?
因为当一个函数被调用时,它的局部变量和参数都存储在当前控制流程的栈帧(Stack Frame)中。而在不同控制流程(如两个线程、信号处理函数与主程序)的栈是完全隔离的,即使调用同一个函数,它们的局部变量也位于不同的内存地址。
在操作系统中,父进程可以通过阻塞调用 wait 或非阻塞轮询 waitpid 来清理子进程,但这两种方式各有不足:阻塞会暂停父进程工作,非阻塞则需主动轮询增加复杂度。
更高效的做法是用 SIGCHLD 信号机制——子进程终止时会自动向父进程发送该信号,父进程只需自定义信号处理函数并在其中调用 wait 即可异步清理子进程,无需轮询或阻塞。需要注意的是,处理函数中应循环调用 waitpid(-1, &status, WNOHANG) 以避免多个子进程同时退出时的信号合并问题。
事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法就是:父进程调用sigaction将SIGCHLD的处理动作设置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。
系统默认的忽略动作和用户用sigaction函数自定义的忽略,通常是没有区别的。当然这只是一个特例,在linux系统上可用,但是其他操作系统就不保证了。
我们信号部分的学习就到这里结束了,希望对大家有所帮助。
明天我们将会开始线程部分的学习!!