目录
4.3 三表(block/peding/handler)内核结构理解
1. 整体学习思维导图
2. 认识信号
2.1 信号的概念
-
对于生活中来说,上课铃,肚子叫,电话响等等,信号是一种消息的提醒。
-
在Linux系统中,信号是对进程的一种提醒,进程会对信号进行识别和处理。
2.2 信号的认知
-
同步/异步机制
-
同步:进程收到信号,立即停下手中任务,去执行信号任务,执行完信号任务后才回来执行当下任务
-
异步:进程收到信号,不会立即停下当前任务,而是分出一个线程或者函数处理这个信号。
-
-
信号的产生是异步的
-
进程不知道自己何时会收到信号,因此在收到信号前进程会执行原来自己的任务。
-
-
信号是发给进程的
2.3 信号的基本结论(引导问题)
-
进程对于信号的处理具有先知性,即信号没来之前进程就知道如何处理信号。(我们看电视时定了外卖,即使外卖电话没来,我们也知道来之后我们该怎么做,进程也是如此)
-
为什么信号的处理具有先知性?
-
-
信号处理时,不会立即处理,而是等一会处理,合适的时候处理!
-
什么叫做合适的时候处理,什么时候是合适的时候?
-
-
(我们人知道信号会发生什么,是因为我们经过了
训练/经验
,比如红灯停是因为大人们教过)进程已经知道信号的处理方式,OS程序设计早就内置了对信号的识别和处理。-
OS内是怎么识别和处理的?
-
-
给进程产生信号的信号源会有很多。(生活中我们可能会出现母亲喊吃饭,父亲刚下班敲门两种信号的情况)
-
进程是怎么区别并且处理的?
-
2.4 见一见Linux中的信号
-
查看信号的指令:
kill -l # 查看信号种类 1-31普通信号 31- 实时信号
-
这些信号是由编号和大写字母的宏定义所描述的。
3. 信号的产生
3.1 信号的简单处理方式
-
默认处理(由OS内核源代码决定)
-
自定义处理(signal函数)
-
忽略处理
3.2 键盘产生信号
现在我们拥有一个死循环打印的进程,我们使用键盘对该前台进程产生一个ctrl + c
的信号,默认处理方式,OS会对该进程发送2号信号SIGINT
,该信号为终止类信号,进程会被终止杀掉。
#include <iostream>
#include <sys/types.h>
#include <unistd.h>
int main()
{
while(true)
{
int cnt = 1;
std::cout << "我是一个循环进程," << cnt++ << ", pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
问题一:场景中提到键盘为该前台进程发送一个信号,难道键盘发送的信号只要前台能收到,后台收不到吗?
-
命令行 ./XXX.exe --前台进程 | ./XXX.exe --后台进程
-
前台进程:能从键盘标准输出获取信号 后台进程:无法获取 前台进程只能存在一个,后台进程可以有多个;前后进程都可以向标准输出打印内容
命令行切换前后台
-
Jobs 查看所有后台进程
-
fg 任务号 后台-->前台 bg 任务号 (被暂停的进程)后台恢复运行
-
Ctrl + Z 将前台进程提到后台暂停
-
其中[1]就是任务号
- 后台进程收不到信号
问题二:场景中是默认处理,我们如何自定义处理信号?
-
signal函数
signum: 我们想要自定义信号的编号,如SIGINT为2号
handler: 函数指针,这个函数会在接收sig信号被调用,该函数必须有一个接收int类型的参数(用于接收信号编号)
,返回类型为void
handler 参数为SIG_DFL 默认处理
参数为SIG_IGN 忽略处理
源码:
-
自定义处理(信号捕捉)
一次捕捉,可以一直有效,因此我们在进入循环代码前只需要捕捉一次即可!
#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
void handler(int sig)
{
std::cout << "我捕捉到了信号:" << sig << std::endl;
exit(1);
}
int main()
{
/* 默认处理 */
signal(SIGINT, SIG_DFL);
/* 忽略处理 */
// signal(SIGINT, SIG_IGN);
/* 自定义处理 */
// signal(SIGINT, handler);
while(true)
{
int cnt = 1;
std::cout << "我是一个循环进程," << cnt++ << ", pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
-
默认/忽略/自定义:
-
部分信号不可捕捉
此两信号不让捕捉是为了保证系统对进程的控制性!
-
SIGKILL - 9
SIGSTOP - 19
-
3.3 kill命令产生信号
-
命令行
-
系统调用
int main()
{
while(true)
{
int cnt = 1;
std::cout << "我是一个循环进程," << cnt++ << ", pid: " << getpid() << std::endl;
sleep(1);
kill(getpid(), 2);
}
return 0;
}
-
自己制作一个Kill
#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
int main(int argc, char* argv[])
{
if(argc != 3)
{
perror(argv[0]);
exit(1);
}
// ./XXX.exe kill -9 pid
int signumber = std::stoi(argv[2]);
pid_t id = std::stoi(argv[3]);
int n = ::kill(id, signumber);
if(n < 0)
{
perror("Kill fail!");
exit(-1);
}
std::cout << "Kill success!" << std::endl;
return 0;
}
3.4 系统调用产生信号
其中一个就是上面的kill指令,还有两个指令:raise
, abort
。
-
发送一个信号给自己
raise
int main()
{
int cnt = 1;
while(true)
{
std::cout << "我是一个循环进程," << cnt++ << ", pid: " << getpid() << std::endl;
sleep(1);
raise(SIGINT);
}
return 0;
}
-
abort
函数使当前进程接收到信号而异常终止
int main()
{
int cnt = 1;
while(true)
{
std::cout << "我是一个循环进程," << cnt++ << ", pid: " << getpid() << std::endl;
sleep(1);
// raise(SIGINT);
if(cnt >= 3) abort();
}
return 0;
}
3.5 [硬件]异常产生信号
我们平时代码中出现的以下问题会引发硬件异常,除0
,野指针
,我们在执行这类代码时,程序会崩掉!
-
例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE 信号 发送给进程。
-
再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为 SIGSEGV信号 发送给进程。
-
问题:进程代码错误导致硬件异常,那么OS是怎么知道硬件出现了异常?
OS是硬件资源的管理者,硬件异常OS自然会收到来自硬件的信息。
-
如果出现了除0错误,CPU计算中的寄存器:EFLAGS 寄存器(状态寄存器)
-
该寄存器中有很多状态标志:这些标志表示了算术和逻辑操作的结果,如溢出(OF)、符号(SF)、零(ZF)、进位(CF)、辅助进位(AF)和奇偶校验(PF)。
-
除 0 操作就会触发溢出,就会标定出来运算在 cpu 内部出错了。OS 是软硬件资源的管理者!OS 就会处理这种硬件问题,向目标进程发送信号,默认终止进程。
-
-
如果出现了空指针访问错误CPU中的MMU在那种CR3中虚拟地址去到页表找寻对应映射的物理地址就会找不到,会触发一个页错误(page fault)
3.5.1 Core && Term
我们之前说过信号对于进程的作用存在着Core
,Term
,Stop
,我们着重于前两者,Core Term
都是终止进程的信号,但是却有不同之处。
-
如果是Core的终止信号会在当前路径下形成一个文件,这个文件中保存着进程异常退出时候在内存中的核心数据,从内存拷贝到磁盘,形成一个文件,这被称作为核心转储!
-
但是在云服务器上这项功能是被禁用的,我们如何开启呢?
我们分配给他1024
ouyang@iZ2ze0j6dd76e0o9qypo2rZ:~$ ulimit -c 1024
ouyang@iZ2ze0j6dd76e0o9qypo2rZ:~$ ulimit -a
core file size (blocks, -c) 1024
data seg size (kbytes, -d) unlimited
scheduling priority (-e) 0
file size (blocks, -f) unlimited
pending signals (-i) 14863
max locked memory (kbytes, -l) 65536
max memory size (kbytes, -m) unlimited
open files (-n) 65535
pipe size (512 bytes, -p) 8
POSIX message queues (bytes, -q) 819200
real-time priority (-r) 0
stack size (kbytes, -s) 8192
cpu time (seconds, -t) unlimited
max user processes (-u) 14863
virtual memory (kbytes, -v) unlimited
file locks (-x) unlimited
SIGQUIT(编号3)和 SIGSEGV(编号11)等信号的默认动作就是终止进程并生成 core dump,我们修改代码使其触发core dump生成。
int main()
{
int a = 1;
a /= 0;
raise(3);
return 0;
}
-
使用gdb加载目标程序,并且添加core文件
core-file core
就可以知道对应错误退出的行。
3.5.2 重谈进程控制的状态参数status
-
如果core dump标志位为1表示异常退出,为0表示正常退出!
-
demo代码实践
int main()
{
pid_t id = fork();
if (id == 0)
{
// 子进程
std::cout << "我是一个子进程," << "pid: " << getpid() << std::endl;
int a = 10;
a /= 0;
exit(1);
}
int status = 0;
waitpid(id, &status, 0);
printf("signal:%d exit code:%d core dump:%d\n", WTERMSIG(status), WEXITSTATUS(status), (status >> 7) && 0x1);
return 0;
}
-
异常退出的退出码无参考价值!
3.6 软件条件产生信号
最典型的例子就是我们进程在通信时,进程A向管道文件写入,进程B从管道文件读取,一旦进程B退出,进程A就会收到来自OS的信号SIGPIPE
终止。
我们来认识两个函数alarm
,pause
。
alarm函数
返回值:返回上一个闹钟剩余的描述,如果上一个闹钟是正常结束,这个返回值为0;如果是异常结束返回上一个
闹钟剩下的秒数。
这个函数产生的信号是14号信号SIGALRM
using Func_t = std::function<void(void)>;
std::vector<Func_t> v;
void handler(int sig)
{
for(const auto& e : v)
e();
int n = alarm(1);
std::cout << "上次剩下时间:" << n << std::endl;
}
int main()
{
int cnt = 1;
v.push_back([](){ std::cout << "我是一个内核操作!" << std::endl; });
v.push_back([](){ std::cout << "我是一个日志操作!" << std::endl; });
v.push_back([](){ std::cout << "我是一个上传操作!" << std::endl; });
alarm(1);
signal(SIGALRM, handler);
while(true)
{
pause();
std::cout << "我醒来了,cnt = " << cnt++ << std::endl;
}
}
在操作系统中,信号的软件条件指的是由 软件内部状态 或 特定软件操作触发 的信号产生机制。
3.6.1 简单理解系统闹钟
系统闹钟,其实本质是OS必须自身具有定时功能,并能让用户设置这种定时功能,才可能实现闹钟这样的技术,现代Linux是提供了定时功能的,定时器也要被管理:先描述,在组织。
内核中的定时器数据结构是:
struct timer_list {
struct list_head entry;
unsigned long expires;
void (*function)(unsigned long);
unsigned long data;
struct tvec_t_base_s* base;
};
-
操作系统管理定时器:采用的是时间轮的做法,但是我们为了简单理解,可以把它在组织成为"堆结构",时间小的闹钟在堆顶!
4. 信号的保存
前景引入:我们在前面谈到进程的基本结论不立即处理信号,那么总需要保存好信号吧,信号在未达到进程之前,进程已经知道如何处理是为什么?进程对信号的处理方式不同是如何区分信号的?更改信号的处理方式(捕捉)又是怎么实现的。
4.1 位图保存
通过我们之前的学习我们知道进程的信息管理在task_struct
,那么信号也是不是保存在里面呢?答案是对的。
源码:
unsigned long signal; /* 位图结构 */
... 0000 0000 0000 0000 0000 0000 0000 0000
比特位位置->信号编号
比特位内容1/0,表示是否保存信号
4.2 保存类型
-
信号递达:实际执行信号处理动作。
-
信号未决:处于产生到递达之间的状态(信号保存在位图中未处理)。
-
阻塞[屏蔽]信号:进程可以阻塞某个信号,直到条件满足解除阻塞,执行递达动作。
-
忽略信号:忽略是在递达之后的处理动作,如2号新号是终止,我忽略信号进程就不终止。
4.3 三表(block/peding/handler)内核结构理解
-
阻塞表:用于记录该信号是否被阻塞,位图结构(1/0)表示是否阻塞
-
保存表:用于保存还未递达的信号,位图结构(1/0)表示是否保存
-
识别表:记录对于某种信号对应的处理方式,让进程识别信号,这也是为什么进程提前知道信号处理方法的原因,数组下标表示信号的编号。
-
是否递达某个信号:
pending & (~block)
--> 保存未阻塞则可以递达。
-
我们前面使用signal
函数自定义处理方式,只需更改handler
表即可完成。
4.4 信号集操作函数
4.4.1 sigset_t
类型是unsigned long long
用于表示位图结构,使用一个比特位来表示有效和无效。
以下是一些信号集操作函数:
-
Sigempty
: 该函数初始化set所指的所有信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。 -
sigfillset
: 初始化 set 所指向的信号集,使其中所有信号的对应 bit 置位,表示 该信号集的有效信号包括系统支持的所有信号 -
注意 : 在使用 sigset_t 类型的变量之前,一定要调用 sigemptyset 或 sigfillset 做初始化,使信号集处于确定的状态。初始化 sigset_t 变量之后就可以在调用 sigaddset 和 sigdelset 在该信号集中添加或删除某种有效信号。
-
sigismember
:是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
-
sigprocmask
:可以读取或更改进程的信号屏蔽字(阻塞信号集)
参数说明:
how: 用于指定如何修改信号屏蔽字的操作方式。它可以取以下几个值之一:
1. SIG_BLOCK:将信号集 set 中的信号添加到当前信号屏蔽字中,阻止这些信号的传递。
2. SIG_UNBLOCK: 从当前信号屏蔽字中删除信号集 set 中的信号,允许这些信号的传递。
3. SIG_SETMASK:将信号屏蔽字设置为 set 中的信号集,完全替换掉当前的屏蔽字。
set:指向一个 sigset_t 类型的信号集,表示需要操作的信号集合。
可以使用 sigemptyset()、sigfillset()、sigaddset() 等函数来操作这个集合。
oldset: 指向一个 sigset_t 类型的变量,用于保存调用 sigprocmask 前的原始信号屏蔽字
(如果 oldset 不为 NULL)。如果不关心原始的屏蔽字,可以将其设置为 NULL。
返回值:成功时,返回 0,失败时,返回 -1,并将 errno 设置为相应的错误代码。
如果 oldset 是非空指针, 则读取进程的当前信号屏蔽字通过oset参数传出
如果 set 是非空指针, 则更改进程的信号屏蔽字, 参数 how 指示如何更改。
如果 oldset 和 set 都是非空指针, 则先将原来的信号屏蔽字备份到 oldset里,然后根据 set 和 how 参数更改信号屏蔽字。
how的可选参数介绍:
-
演示阻塞和解除阻塞的demo代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
void handler(int sig)
{
printf("Signal %d received\n", sig);
}
int main()
{
signal(SIGINT, handler);
sigset_t set, oldset;
sigemptyset(&set);
sigaddset(&set, SIGINT);
/* 阻塞 */
sigprocmask(SIG_BLOCK, &set, &oldset);
printf("SIGINT block, Please wait 5s....\n");
sleep(5);
/* 解除 */
sigprocmask(SIG_SETMASK, &oldset, NULL);
// sigprocmask(SIG_UNBLOCK, &set, NULL);
printf("SIGINT will unblock, Please wait 5s....\n");
sleep(5);
printf("I will send SIGINT to me!\n");
raise(SIGINT);
return 0;
}
-
sigpending
: 读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1
测试demo代码:
void Printpending(sigset_t &pending)
{
printf("我是一个进程,我的pid: %d ", getpid());
for (int sig = 31; sig >= 1; --sig)
{
if (sigismember(&pending, sig)) /* 如果存在这个信号就为1 */
std::cout << 1;
else
std::cout << 0;
}
std::cout << std::endl;
}
void handler(int sig)
{
printf("===============================\n");
std::cout << "递达" << sig << "信号" << std::endl;
printf("打印信号是否屏蔽\n");
sigset_t pending;
sigpending(&pending);
Printpending(pending); // 1. 0000 0010(handler执行完,2号才回被设置为0) 2. 0000 0000(执行handler方法之前,2对应的pending已经被清理了),这边情况是第二种
printf("===============================\n");
}
int main()
{
signal(SIGINT, handler);
sigset_t set, oldset;
/* 阻塞SIGINT信号 */
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigprocmask(SIG_BLOCK, &set, &oldset);
int cnt = 0;
while (true)
{
/* 获取当前进程的未决信号集 */
sigset_t pending;
sigpending(&pending);
Printpending(pending);
/* 解除阻塞 */
if (cnt == 10)
{
printf("解除对2号信号的屏蔽\n");
sigprocmask(SIG_SETMASK, &oldset, nullptr);
}
sleep(1);
cnt++;
}
return 0;
}
我们可以发现信号被阻塞后保存在pending表中,一旦解除阻塞信号递达执行对应的handler
函数之前会先将保存的信号置为0,再执行handler
函数。
5. 信号的处理
5.1 合适的处理时机
我们前面提到过进程对于信号的处理是等一会处理,合适的时候处理,现在我们就来谈一谈合适的时候是什么时候。在了解这个时机之前我们需要先梳理一下,我们用户层所写的代码进程接收信号是需要更改底层OS的task_struct
这就需要访问内核中的代码。也就是说进程从用户态
切换到内核用户态
的时候(系统调用/中断/异常等等),再由内核态
转而回到用户态
时,检测当前进程的peding
和block
表,查看是否需要处理handler
表所述的信号!
-
用户态:我们所写的代码
-
内核态:执行操作系统底层的代码
-
如果信号是默认的?忽略的呢?阻塞的呢?
-
默认的:此时我们已经由用户态到了内核态,OS按照默认的信号处理方式即可。
-
忽略的:OS将peding表中对应的信号由1置0即可。
-
阻塞的:OS将进程的状态由R(runing)置为S(sleep),并且链入到等待队列即可。
-
5.1.1 重谈自定义信号捕捉处理的过程:
-
sighandler
方法必须是由用户态执行,以防止用户在该方法内执行非法操作!也就是说跳转之前需要做用户切换(内核->用户)。
-
进程如果没有以上介绍的系统调用代码等等也会进入内核吗?为什么?
-
是的,只要是进程就会被调度,占有一定时间的CPU,但是我们知道每个进程在CPU上是存在时间片的,一旦时间到了,就会将进程从CPU上剥离下来,这个过程必须是OS来强制执行,因此进程在此时即使代码中没有系统调用也会进入内核!
-
5.2 了解操作系统的运行
5.2.1 硬件中断
-
什么叫做硬件中断,比如我们有一个进程代码中存在着键盘输入的
scanf
函数,一旦我们按下对应键盘的按键会给中断控制器发送一个高电平的信号,中断控制器收到信号就会找到对应针脚的硬件,并且在中断控制器中的寄存器会记录信息(键盘的操作in/键盘的输入信息),注意不只是CPU中存在寄存器
,此时中断控制器就会通知CPU,CPU再访问中断控制器就可以知道对应的中断号了。CPU依据中断号(下标)查找中断向量表查找对应的中断方法执行操作系统OS的方法即可! -
处理过程:发中断号-->保存中断号-->处理中断号(这不就是信号的处理过程吗?信号机制的参考来源就是硬件中断)。
-
CPU保护现场
-
当进程切换(调度算法)或者中断发生时,操作系统需要保存当前执行进程或者线程的状态,以便后面能够恢复到中断或切换之前的状态,比如说:我的作业做了一半我要记录我做到那一页了,我上个厕所回来继续做。
-
现场保护确保了进程之间的切换或中断处理的正确性,使得每个进程能够在被暂停后,继续从它中断的位置继续执行,而不会丢失任何上下文信息,而这些上下文信息会被保存在
task_struct
之中。
-
-
中断向量表就是操作系统的⼀部分,启动就加载到内存中了
-
通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询
-
由外部设备触发的,中断系统运行流程,叫做硬件中断
5.2.2 时钟中断
当中断没有来时,操作系统是暂停的。
那么我们平时操作系统好像不是在暂停的啊,它好像一直在做进程调度的工作,这又是怎么实现的呢?
-
进程可以在操作系统的指挥下,被调度,被执行,那么操作系统自己被谁指挥,被谁推动执行呢?
-
操作系统就是在硬件时钟中断的驱动下,进行调度的。
-
操作系统就是基于中断进行工作的软件!
-
-
外部设备可以触发硬件中断,但是这个是需要用户或者设备自己触发,有没有自己可以定期触发的设备?
- 时钟源(时钟信号):用来驱动操作系统的信号源,它以一个稳定的频率发生一个中断号,这个中断号对应到操作系统中的中断向量表就是进程调度!
- 主频:(也称为时钟频率)是衡量计算机中央处理单元(CPU)处理速度的一个重要指标。
-
主频由时钟源提供,决定了CPU每秒钟能完成多少次运算操作。主频越高,CPU的计算速度越快,理论上每秒可以处理更多的指令
-
表示CPU每秒钟能够执行的时钟周期次数,通常以赫兹(Hz)为单位,1赫兹等于每秒钟一个周期。
-
-
进程调度:让当前进程的时间片进行 --,每个进程执行一段时间后,操作系统会中断其执行并切换到下一个进程,直到进程的时间片耗尽,计数变为0就开始进行切换
-
时间片是什么?
- 时间片是操作系统中 用于实现任务调度的一个概念,本质就是 PCB 内部的一个计数器,它指的是分配给每个进程或线程的一小段时间,通常为毫秒级别。在时间片内,CPU会执行当前进程的指令,当时间片用完时,操作系统会暂停当前进程,切换到下一个进程或线程,这种机制用于实现多任务并发,确保多个进程能够共享CPU时间
额外知识:我们电脑在断电后启动电脑的时间准确也跟时间戳有关,而时间戳就是总的时间片计数。
5.2.3 软件中断
-
上述外部硬件中断,需要硬件设备触发。
-
有没有可能,因为软件原因,也触发上面的逻辑?有!
-
为了让操作系统支持进行系统调用,CPU也设计了对应的汇编指令(int 或者 syscal),可以让CPU内部触发中断逻辑。
-
软件中断(Software Interrupt)是一种由程序或操作系统主动触发的中断机制,用于实现进程间的通信、系统调用、异常处理等功能。与硬件中断不同,硬件中断是由外部设备(如输入设备、网络接口卡等)触发的,而软件中断则是程序内部通过特定的指令或操作触发的中断。
问题:
用户层面怎么调用操作系统,我们现在理解了操作系统就是通过中断号调用起来的,那么我们就需要将系统调用号传递给操作系统。我们在调用系统调用函数时,将系统调用号传给eax
寄存器,在发送一个中断号0x80
。
/* 用户 */
move eax 5
int 0x80
/* 系统 */
int n = 0;
move n eax
-
用户层怎么把系统调用号给操作系统? ---> 寄存器(比如EAX)
-
操作系统怎么把返回值给用户? ---> 寄存器或者用户传入的缓冲区地址
-
系统调用的过程,其实就是先int 0x80、syscall陷入内核,本质就是触发软中断,CPU就会自动执行系统调用的处理方法,而这个方法会根据系统调用号,自动查表,执行对应的方法
-
系统调用号的本质:数组下标!
我们在使用系统调用时,好像没有什么syscall/int 0x80,这是因为我们使用的系统调用接口已经被glibc库给封装过了!
5.2.4 缺页中断/内存碎片处理/除0野指针错误
-
缺页中断
-
什么是缺页中断?(malloc内存申请,需要使用时才申请)
-
缺页中断是指程序访问某个虚拟地址,通过页表映射关系没有找到对应真实的物理内存,需要操作系统将其从磁盘等外存设备中读入到内存中,再进行访问的过程,被内存映射的文件在这个过程中成为分页交换文件。这个操作系统处理的过程就是一种软件中断!
-
-
set_trap_gate(14,&page_fault);
-
内存碎片处理
-
什么是内存碎片?
-
内存碎片分为两类:一是外部碎片,指的是内存中存在空闲的,不连续的块。二是内部碎片,指的是已经分配的内存块中未使用的部分。操作系统的内存管理模块会使用软件中的来触发内存的回收,整理和分配。
-
-
-
除0野指针错误
-
什么是除0野指针错误
-
当我们在代码中涉及到除0/使用访问野指针时,涉及到使用CPU中的运算器检查到了异常,会发送一个中断号去调用中断向量表的对应服务。
-
-
总结:
-
操作系统依赖中断机制(硬件中断、陷阱、异常)实现核心功能.
-
CPU内部的软中断,比如
int 0x80
或者syscall
,我们叫做陷阱(如用户态陷入内核态,系统调用) -
CPU内部的软中断,比如除零/野指针等,我们叫做异常。(如“缺页异常”)
5.3 理解用户态和内核态
内核态和用户态的初识
-
内核态:[0, 4G]范围的虚拟空间地址都可以操作,但是在[3, 4G]范围的高位虚拟地址必须以内核态的身份进行访问!
-
用户态:[3, 4G]部分的内核虚拟地址是所有用户共享,每个用户存在着自己的用户区可以进行操作!也就是说[0, 3G]是属于用户自己的,[3, 4G]需要由用户态到内核态才可以进行跳转访问。
-
内核页表只存在一份,内核由多用户共享使用。
-
用户页表可以存在多份。
问题:
-
用户态和内核态处于同一个[0, 4G]的地址空间上,那么用户只要拿到内核的地址不就可以访问内核中的代码和数据了吗,这和OS的设计概念不符啊(OS为保护自己,不相信任何人,必须采用系统调用才可以访问)?
-
因此用户拿到内核地址需要访问时,会经过中间层CPU中的cs寄存器审核判断
代码地址段的最后三位比特位
,如果为0
表示身份为内核态可以访问,如果为3
表示身份为用户态禁止访问。 -
区分就是按照CPU内的CPL决定,CPL的全称是
Current Privilege Level
,即当前特权级别,CPL的作用就是保护操作系统免受恶意和错误的代码影响。其中CPL 0
表示内核模式;CPL 3
表示用户模式。当执行完内核代码操作后,CPL会由0->3
。
-
总结:
操作系统无论怎么切换进程,都能找到同一个操作系统
操作系统调用方法的执行是在进程的地址空间中执行的
6. 额外知识补充
6.1 sigaction函数
-
ignum: 信号的编号,传入需要操作的信号。如
SIGINT
... -
act:指向一个 struct sigaction 结构体的指针,定义了信号处理的行为。它包含信号处理的详细信息,如信号处理程序、信号屏蔽集等
-
oldact: 指向一个 struct sigaction 结构体的指针,用于存储之前信号的处理方式。如果不需要保存原先的信号处理方式,可以将其设置为 NULL
struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t*, void*);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};
/*
sa_handler: 指向信号处理函数的指针。当信号到达时,会调用该函数来处理信号。信号处理函数的原型为 void handler(int signum),其中 signum 是信号的编号。
sa_mask: 这个字段用于指定一个信号集,表示在信号处理程序执行期间应该被阻塞的信号。即,在信号处理期间,可以通过 sa_mask 阻止其他信号的处理。
*/
该函数可以说是signal的升级版,他拥有比signal更多的选项和功能,以下是使用sigaction实现的一个观察处理信号时会阻塞其他信号的demo代码!
#include <iostream>
#include <signal.h>
#include <unistd.h>
void handler(int sig)
{
while(true)
{
/* 获取peding表 */
sigset_t pending;
sigpending(&pending);
std::cout << "pending:";
for(int i = 31; i >= 1; --i)
{
if(sigismember(&pending, i))
std::cout << "1";
else
std::cout << "0";
}
std::cout << std::endl;
sleep(1);
}
}
int main()
{
struct sigaction act, oldact;
act.sa_handler = handler;
sigemptyset(&act.sa_mask);
/* 屏蔽3,4号信号 */
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
act.sa_flags = 0;
sigaction(SIGINT, &act, &oldact);
while(true)
{
std::cout << "我是一个进程, pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
Bash:
ouyang@iZ2ze0j6dd76e0o9qypo2rZ:~/linux_-git_-warehouse$ kill -2 108891
ouyang@iZ2ze0j6dd76e0o9qypo2rZ:~/linux_-git_-warehouse$ kill -3 108891
ouyang@iZ2ze0j6dd76e0o9qypo2rZ:~/linux_-git_-warehouse$ kill -4 108891
-
sigaction
和signal
的区别:-
sigaction
提供了更丰富的功能,比如更精细的控制信号屏蔽和信号处理标志。 -
signal
函数相对简单,但不支持某些高级功能(如信号屏蔽、信号恢复等),并且在一些系统中可能会受到实现差异的影响。 -
因此,
sigaction
是一种更现代、更稳定的信号处理方法,建议在新代码中使用它。
-
6.2 可重入函数
我们来看以上图片的场景,main
函数时执行了insert
函数,p->next = head
,正准备执行下一句代码时,这时收到一个信号转而处理信号执行自定义sighandler
时,这个自定义信号处理中也还有insert
,这就会导致有两个节点指向下一个节点,并且head
指向的节点由node1
转向node2
又再次转向node1
。以上这个过程就叫做可重入函数!
如果⼀个函数符合以下条件之⼀则是不可重入的:
-
调用了malloc或free,因为malloc也是用全局链表来管理堆的。
-
调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
注意:
-
main执行流/handler执行流都调用了insert方法,被两个以上的执行流重入了-->函数被重入了。
-
是否可重入是函数的特点!
6.3 volatile关键字
观察以下代码:
int flag = 0;
void handler(int sig)
{
std::cout << "Get a signal: " << sig << std::endl;
flag = 1;
std::cout << "change flag: " << flag << std::endl;
}
int main()
{
signal(2, handler);
while(!flag);
std::cout << "process exit success!" << std::endl;
return 0;
}
6.3.1 编译优化:
编译优化的设置:g++ -o $@ $^ -Ox ( x = 0 1 2 3 对优化等级的选择)
数字越大优化越厉害
Makefile:
Sigaction:Sigaction.cc
g++ -o $@ $^ -O1 -std=c++11
.PHONY:clean
clean:
rm -f Sigaction
我们发现我们只是加了一级编译器优化,即使修改了变量flag的值也无法终止循环了,这是为什么呢?
寄存器+优化 = 屏蔽内存可见性!
-
一旦优化屏蔽了内存可见性,CPU运算判断只会依据寄存器中的数据也就是
flag=0
,即使通过信号捕捉修改了内存的flag=1
,CPU也看不见,因此循环不会停下,而volatile
关键字可以保证内存可见性,不论编译优化开到数字几。
volatile int flag = 0;
void handler(int sig)
{
std::cout << "Get a signal: " << sig << std::endl;
flag = 1;
std::cout << "change flag: " << flag << std::endl;
}
int main()
{
signal(2, handler);
while(!flag);
std::cout << "process exit success!" << std::endl;
return 0;
}
Sigaction:Sigaction.cc
g++ -o $@ $^ -O3 -std=c++11
.PHONY:clean
clean:
rm -f Sigaction
6.4 SIGCHLD信号
-
什么是
SIGCHLD
信号,有什么作用?-
SIGCHLD
信号是由子进程退出时给父进程发送的一个信号 -
前面讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待⼦进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第⼀种方式,父进程阻塞了就不能处理自己的工作了;采用第⼆种方式,父进程在处理自己的工作的同时还要记得时不时地轮询⼀下,程序实现复杂。
-
其实,子进程在终止时会给父进程发
SIGCHLD
信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD
信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait
清理子进程即可。 -
代码测试
SIGCHLD
信号是否存在:
-
/* 测试SIGCHLD信号 */
void handler(int sig)
{
std::cout << "I Get a Signal: " << sig << std::endl;
}
int main()
{
/* 捕捉SIGCHLD信号 */
struct sigaction act;
act.sa_handler = handler;
sigaction(SIGCHLD, &act, nullptr);
pid_t id = fork();
if(id == 0)
{
// child
std::cout << "I am child, my pid: " << getpid() << std::endl;
sleep(1);
exit(1);
}
else
{
// father
waitpid(id, nullptr, 0);
sleep(3);
std::cout << "I am father, I will exit" << std::endl;
}
return 0;
}
-
通过自定义捕捉
SIGCHLD
信号实现子进程回收
/* 捕捉自定义信号回收子进程 */
void handler(int sig)
{
// father
pid_t rid = waitpid(-1, nullptr, 0);
std::cout << "I Get a Signal: " << sig << std::endl;
if(rid > 0)
{
std::cout << "子进程退出了, 回收成功, child id: " << rid << std::endl;
}
}
int main()
{
/* 捕捉SIGCHLD信号 */
struct sigaction act;
act.sa_handler = handler;
sigaction(SIGCHLD, &act, nullptr);
pid_t id = fork();
if(id == 0)
{
// child
std::cout << "I am child, my pid: " << getpid() << std::endl;
sleep(1);
exit(1);
}
else
{
while(true)
{
sleep(1);
}
std::cout << "I am father, I will exit" << std::endl;
}
return 0;
}
-
事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用 sigaction将 SIGCHLD 的处理动作置为 SIGIGN
-
这样 fork 出来的子进程在终止时会自动清理掉,不会产生僵尸进程也不会通知父进程
-
系统默认的忽略动作 和 用户用
sigaction
函数 自定义的忽略 通常是没有区别的,但这是一个特例。注意:此方法对于Linux
可用,但是不保证在其它UNIX系统上都可用int main() { signal(SIGCHLD, SIG_IGN); for(int i = 0; i < 5; ++i) { if(fork() == 0) { // child std::cout << "I am child, my pid: " << getpid() << std::endl; sleep(3); exit(1); } } // father while(true) { sleep(1); } std::cout << "I am father, I will exit" << std::endl; return 0; }