Linux进程信号

1. 信号快速认识

1-1⽣活⻆度的信号

•1.(信号没来前,你就知道如何处理信号) 你在⽹上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能"识别快递"

• (信号并不一定会立即处理)当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的⾏为并不是⼀定要⽴即执⾏,可以理解成"在合适的时候去取"。

•(记住信号) 在收到通知,再到你拿到快递期间,是有⼀个时间窗⼝的,在这段时间,你并没有拿到快递,但是你知道有⼀个快递已经来了。本质上是你"记住了有⼀个快递要去取"

•(三种信号处理方式) 当你时间合适,顺利拿到快递之后,就要开始处理快递了。⽽处理快递⼀般⽅式有三种:

1.执⾏默认动作(幸福的打开快递,使⽤商品)

2.执⾏⾃定义动作(快递是零⻝,你要送给你你的⼥朋友)

3.忽略快递(快递拿上来之后,扔掉床头,继续开⼀把游戏)

• 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话

基本结论:

• 你怎么能识别信号呢?

识别信号是内置的,进程识别信号,是内核程序员写的内置特性。

• 信号产⽣之后,你知道怎么处理吗?

知道。

如果信号没有产⽣,你知道怎么处理信号吗?

知道。所以,信号的处理⽅法,在信号产⽣之前,已经准备好了。

• 处理信号,⽴即处理吗?

我可能正在做优先级更⾼的事情,不会⽴即处理,什么时候?合

适的时候。

• 信号到来|信号保存|信号处理

• 怎么进⾏信号处理啊?

a.默认 b.忽略 c.⾃定义,后续都叫做信号捕捉。

1-2技术应⽤⻆度的信号

1-2-1⼀个样例

cpp 复制代码
#include <stdio.h>
#include <unistd.h>

int main()
{
	while (1){
		printf("hello signal!\n");
		sleep(1);
	}
	return 0;
}

这里可以看到当我们输入ctrl+c时,进程停止了打印。

为什么输入ctrl+c后,该进程停止运行

当我们输入ctrl c时,键盘会产生一个硬中断,会被操作系统获取并解释成信号,(Ctrl c的信号是2号信号),操作系统将2号信号发送到目标的前台进程,而前台进程收到2号信号会退出

我们可以用signal函数来补捉信号,证明ctrl c传递的是2号信号。

bash 复制代码
sighandler_t signal(int signum, sighandler_t handler);

参数说明:

signum:信号编号[后⾯解释,只需要知道是数字即可

handler:函数指针,表⽰更改信号的处理动作,当收到对应的信号,就回调执⾏handler⽅法

返回值

成功:返回 上一次 该信号的处理函数指针

失败:返回 SIG_ERR

例如,下面的代码中将2号信号进行了捕捉,当该进程运行起来后,若该进程收到了2号信号就会打印出收到信号的信号编号。

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <signal.h>

void handler(int sig)
{
    printf("get a signal %d\n",sig);
}

int main()
{
    signal(2,handler);
	while (1){
		printf("hello signal!\n");
		sleep(1);
	}
	return 0;
}

当我们输入ctrl c时会捕捉信号

有些信号是不可以被自定义的(eg:9号信号,用来杀死进程),否则进程将无法退出
注意

1.Ctrl+C产生的信号只能发送给前台进程。在一个命令后面加个&就可以将其放到后台运行,这样Shell就不必等待进程结束就可以接收新的命令,启动新的进程。

2.Shell可以同时运行一个前台进程和任意多个后台进程,但是只有前台进程才能接到像Ctrl+C这种控制键产生的信号。

3.前台进程在运行过程中,用户随时可能按下Ctrl+C而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都可能收到SIGINT信号而终止,所以信号相对于进程的控制流程来说是异步的。

4.信号是进程之间事件异步通知的一种方式,属于软中断。

信号是如何记录的?

实际上,当一个进程接收到某种信号后,该信号是被记录在该进程的进程控制块当中的。我们都知道进程控制块本质上就是一个结构体变量,而对于信号来说我们主要就是记录某种信号是否产生,因此,我们可以用一个32位的位图来记录信号是否产生。

一个进程收到信号,本质就是该进程内的信号位图被修改了,也就是该进程的数据被修改了,而只有操作系统才有资格修改进程的数据,因为操作系统是进程的管理者。也就是说,信号的产生本质上就是操作系统直接去修改目标进程的task_struct中的信号位图。

2. 产⽣信号

2-1通过终端按键产⽣信号

当面对死循环程序时,我们都知道可以按Ctrl+C可以终止该进程。

cpp 复制代码
#include <stdio.h>
#include <unistd.h>

int main()
{
	while (1){
		printf("hello signal!\n");
		sleep(1);
	}
	return 0;
}

2-1-2理解OS如何得知键盘有数据?

初步理解信号起源,这里先了解一下后面会详细解释

注意:

• 信号其实是从纯软件⻆度,模拟硬件中断的⾏为

• 只不过硬件中断是发给CPU,⽽信号是发给进程?

• 两者有相似性,但是层级不同,这点我们后⾯的感觉会更加明显

但实际上除了按Ctrl+C(3号信号)之外,按Ctrl+\也可以终止该进程。

按Ctrl+C终止进程和按Ctrl+\终止进程,有什么区别?

按Ctrl+C实际上是向进程发送2号信号SIGINT,而按Ctrl+\实际上是向进程发送3号信号SIGQUIT。查看这两个信号的默认处理动作,可以看到这两个信号的Action是不一样的,2号信号是Term,而3号信号是Core。

Term和Core都代表着终止进程,但是Core在终止进程的时候会进行一个动作,那就是核心转储。

什么是核心转储

核心转储就是程序崩溃时,系统把进程的内存保存到 core 文件中,方便调试崩溃位置。云服务器默认关闭是为了安全、防止磁盘占满、保证系统稳定。

接下来我们详细介绍

在云服务器中,核心转储是默认被关掉的,我们可以通过使用ulimit -a命令查看当前资源限制的设定。

第一行显示core文件的大小为0,即表示核心转储是被关闭的。

我们可以通过ulimit -c size命令来设置core文件的大小。

core文件的大小设置完毕后,相当于将核心转储功能打开了。

此时如果我们再使用Ctrl+\对进程进行终止,就会发现终止进程后会显示core dumped。

并且会在当前路径下生成一个core文件,该文件后缀是核心转储的进程的PID。

核心转储功能有什么用?

核心转储是进程崩溃时,系统将进程的内存、寄存器、堆栈等运行信息保存到 core 文件中,用于事后调试,定位程序崩溃的位置和原因,是 C/C++ 程序排查线上崩溃的重要工具。

如何运用核心转储进行调试?

使用gdb对当前可执行程序进行调试,然后直接使用core-file core文件命令加载core文件。

事后用调试器检查core文件以查清错误原因,这种调试方式叫做事后调试。

core dump标志

进程等待函数waitpid函数的第二个参数:

bash 复制代码
pid_t waitpid(pid_t pid, int *status, int options);

waitpid函数的第二个参数status是一个输出型参数,用于获取子进程的退出状态。status是一个整型变量,但status不能简单的当作整型来看待,status的不同比特位所代表的信息不同,具体细节如下(只关注status低16位比特位):

若进程是正常终止的,那么status的次低8位就表示进程的退出状态,即退出码。若进程是被信号所杀,那么status的低7位表示终止信号,而第8位比特位是core dump标志,即进程终止时是否进行了核心转储。

我们可以用下面的代码来看看core dump标志位

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>

int main()
{
    if(fork() == 0)
    {
        printf("I am runing...\n");
        int *p = NULL;
        *p = 100;
        exit(0);
    }

    int status = 0;
    waitpid(-1,&status,0);
    printf("exitcode:%d,coredump:%d,signal:%d\n",
    (status>>8)&0xff,(status>>7)&1,status & 0x7f);
    return 0;
}

可以看到,所获取的status的第7个比特位为1,即可说明子进程在被终止时进行了核心转储。


注意

有些信号是不能被捕捉的,比如9号信号。因为如果所有信号都能被捕捉的话,那么进程就可以将所有信号全部进行捕捉并将动作设置为忽略,此时该进程将无法被杀死,即便是操作系统。

2-2通过系统函数向进程发信号

当我们要使用kill命令向一个进程发送信号时,我们可以以kill -信号名 进程ID的形式进行发送。

实际上kill命令是通过调用kill函数实现的,kill函数可以给指定的进程发送指定的信号,kill函数的函数原型如下:

bash 复制代码
int kill(pid_t pid, int sig);

raise函数可以给当前进程发送指定信号,即自己给自己发送信号,raise函数的函数原型如下:

bash 复制代码
int raise(int sig);

代码

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <signal.h>

void handler(int signo)
{
	printf("get a signal:%d\n", signo);
}
int main()
{
	signal(2, handler);
	while (1){
		sleep(1);
		raise(2);
	}
	return 0;
}

abort函数可以给当前进程发送SIGABRT信号,使得当前进程异常终止,abort函数的函数原型如下:

bash 复制代码
void abort(void);

代码

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>

void handler(int signo)
{
	printf("get a signal:%d\n", signo);
}
int main()
{
	signal(6, handler);
	while (1){
		sleep(1);
		abort();
	}
	return 0;
}

与之前不同的是,虽然我们对SIGABRT信号进行了捕捉,并且在收到SIGABRT信号后执行了我们给出的自定义方法,但是当前进程依然是异常终止了。

abort函数的作用是异常终止进程,exit函数的作用是正常终止进程,而abort本质是通过向当前进程发送SIGABRT信号而终止进程的,因此使用exit函数终止进程可能会失败,但使用abort函数终止进程总是成功的。

2-3由软件条件产生信号

SIGPIPE信号实际上就是一种由软件条件产生的信号,当进程在使用管道进行通信时,读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么此时写端进程就会收到SIGPIPE信号进而被操作系统终止。

代码

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
	int fd[2] = { 0 };
	if (pipe(fd) < 0){ //使用pipe创建匿名管道
		perror("pipe");
		return 1;
	}
	pid_t id = fork(); //使用fork创建子进程
	if (id == 0){
		//child
		close(fd[0]); //子进程关闭读端
		//子进程向管道写入数据
		const char* msg = "hello father, I am child...";
		int count = 10;
		while (count--){
			write(fd[1], msg, strlen(msg));
			sleep(1);
		}
		close(fd[1]); //子进程写入完毕,关闭文件
		exit(0);
	}
	//father
	close(fd[1]); //父进程关闭写端
	close(fd[0]); //父进程直接关闭读端(导致子进程被操作系统杀掉)
	int status = 0;
	waitpid(id, &status, 0);
	printf("child get signal:%d\n", status & 0x7F); //打印子进程收到的信号
	return 0;
}

运行代码后,即可发现子进程在退出时收到的是13号信号,即SIGPIPE信号。

我们接下来详细说说''闹钟''和SIGALRM信号

调用alarm函数可以设定一个闹钟,也就是告诉操作系统在若干时间后发送SIGALRM信号给当前进程,alarm函数的函数原型如下:

bash 复制代码
unsigned int alarm(unsigned int seconds);

alarm函数的作用就是,让操作系统在seconds秒之后给当前进程发送SIGALRM信号,SIGALRM信号的默认处理动作是终止进程。

alarm函数的返回值:

若调用alarm函数前,进程已经设置了闹钟,则返回上一个闹钟时间的剩余时间,并且本次闹钟的设置会覆盖上一次闹钟的设置。

如果调用alarm函数前,进程没有设置闹钟,则返回值为0。

例如,我们可以用下面的代码,测试自己的云服务器一秒时间内可以将一个变量累加到多大。

cpp 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

int main()
{
	int count = 0;
	alarm(1);
	while (1){
		count++;
		printf("count: %d\n", count);
	}
	return 0;
}

运行代码后,可以发现我当前的云服务器在一秒内可以将一个变量累加到两万左右。

但实际上我当前的云服务器在一秒内可以执行的累加次数远大于两万,那为什么上述代码运行结果比实际结果要小呢?

主要原因有两个,首先,由于我们每进行一次累加就进行了一次打印操作,而与外设之间的IO操作所需的时间要比累加操作的时间更长,其次,由于我当前使用的是云服务器,因此在累加操作后还需要将累加结果通过网络传输将服务器上的数据发送过来,因此最终显示的结果要比实际一秒内可累加的次数小得多。

为了尽可能避免上述问题,我们可以先让count变量一直执行累加操作,直到一秒后进程收到SIGALRM信号后再打印累加后的数据。

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>

int count = 0;
void handler(int signo)
{
	printf("get a signal: %d\n", signo);
	printf("count: %d\n", count);
	exit(1);
}
int main()
{
	signal(SIGALRM, handler);
	alarm(1);
	while (1){
		count++;
	}
	return 0;
}

此时可以看到,count变量在一秒内被累加的次数变成了五亿多,由此也证明了,与计算机单纯的计算相比较,计算机与外设进行IO时的速度是非常慢的。

如何简单快速理解系统闹钟?

系统闹钟,其实本质是OS必须⾃⾝具有定时功能,并能让⽤⼾设置这种定时功能,才可能实现闹钟这

样的技术。

现代Linux是提供了定时功能的,定时器也要被管理:先描述,在组织。内核中的定时器数据结构是:

cpp 复制代码
struct timer_list {
struct list_head entry;
unsigned long expires;
void (*function)(unsigned long);
unsigned long data;
struct tvec_t_base_s *base;
};

操作系统管理定时器,采⽤的是时间轮的做法,但是我们为了简单理解,可以把它在组织成为"堆结构"。

2-4由硬件异常产生信号

当我们程序当中出现类似于除0、野指针、越界之类的错误时,为什么程序会崩溃?本质上是因为进程在运行过程中收到了操作系统发来的信号进而被终止,那操作系统是如何识别到一个进程触发了某种问题的呢?

1.除0

CPU当中有一堆的寄存器,当我们需要对两个数进行算术运算时,我们是先将这两个操作数分别放到两个寄存器当中,然后进行算术运算并把结果写回寄存器当中。此外,CPU当中还有一组寄存器叫做状态寄存器,它可以用来标记当前指令执行结果的各种状态信息,如有无进位、有无溢出等等。

当 CPU 执行除法指令时,

硬件电路检测到除数寄存器的值为 0,

立刻触发除法异常,

CPU 硬件自动把当前上下文压入内核栈保存,

强制切换到内核态,

再根据异常向量号(0号),

查询 IDT 中断描述符表,

跳转到操作系统对应的异常处理函数

2.野指针/越界

eax,ebx是用来存数据的寄存器

cr3是指向当前进程页表的物理起始地址

MMU相当于翻译器,根据cpu给的虚拟地址和靠cr3找到找到页表

判断这个虚拟地址是否存在对应的物理地址映射。

而野指针找不到对应的物理地址,MMU报错,触发硬件中断,cpu保存上下文

切内核态,眺到OS的缺页异常处理函数。

我们之前说过,malloc也不会在加载中就确定物理内存,而是会惰性加载,那怎么判断这个虚拟地址是否合法呢?

查进程的 vma 区域(虚拟内存区域表)

看这个地址是不是进程合法拥有的:

① 合法(比如 malloc 第一次用)eg:第一次读写:触发缺页异常

  • 地址在堆 / 栈 / 数据段范围内
  • OS:给你分配物理页,更新页表
  • 回到用户态,继续执行

② 不合法(野指针、空指针)

  • 地址不在任何合法区域
  • 或者权限错误
  • OS:直接发 SIGSEGV,进程段错误崩

总结

一、编译阶段(磁盘上的可执行文件)

  1. 编译器不知道物理内存在哪

  2. 编译器给代码、变量、函数分配 虚拟地址

  3. 可执行文件(ELF)里 全是虚拟地址

二、加载运行阶段(OS 启动进程)

  1. OS 先给进程创建 完整虚拟地址空间

  2. OS 创建 页表

  3. 把页表的物理起始地址放进 CR3

  4. 代码段、数据段:

  • 加载到物理内存
  • 建立好 虚拟地址 → 物理地址 映射
  1. 堆(malloc)、栈、未初始化数据:
  • 只分配虚拟地址范围
  • 不分配物理内存(懒分配)

三、CPU 执行指令(全程虚拟地址)

  1. CPU 的 EIP(指令指针)、指针变量 全是虚拟地址

  2. CPU 把虚拟地址发给 MMU

  3. MMU 读取 CR3 找到页表

  4. MMU 查表翻译:

  • 有映射 + 有权限 → 得到物理地址,访问内存

  • 无映射 → 触发 缺页异常,CPU 进入内核

四、缺页异常进入内核后,OS 判断两种情况

情况1:合法缺页(malloc 第一次使用)

  • 虚拟地址在进程合法区域

  • OS 分配物理页,更新页表

  • 回到用户态继续执行

  • 物理内存只分配 4KB 一页,不是全部

情况2:非法缺页(野指针、空指针)

  • 虚拟地址不属于该进程

  • OS 判断为非法访问

  • 发送 SIGSEGV 段错误,进程崩溃

五、除零错误

  1. CPU 执行除法指令

  2. 内部运算电路检测到除数为 0

  3. 硬件直接触发 除法异常

  4. CPU 保存现场、切内核态

  5. OS 发送 SIGFPE ,进程崩溃

C/C++程序会崩溃,是因为程序当中出现的各种错误最终一定会在硬件层面上有所表现,进而会被操作系统识别到,然后操作系统就会发送相应的信号将当前的进程终止。

3.保存信号

3-1信号其他相关常⻅概念

• 实际执⾏信号的处理动作称为信号递达(Delivery)

• 信号从产⽣到递达之间的状态,称为信号未决(Pending)。

• 进程可以选择阻塞(Block)某个信号。

• 被阻塞的信号产⽣时将保持在未决状态,直到进程解除对此信号的阻塞,才执⾏递达的动作.

• 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,⽽忽略是在递达之后可选的⼀种处理动作。

3-2在内核中的表⽰

信号在内核中的表⽰⽰意图

• 每个信号都有两个标志位分别表⽰阻塞(block)和未决(pending),还有⼀个函数指针表⽰处理动作。信号产⽣时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例⼦中,SIGHUP信号未阻塞也未产⽣过,当它递达时执⾏默认处理动作。

• SIGINT信号产⽣过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻

塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。

• SIGQUIT信号未产⽣过,⼀旦产⽣SIGQUIT信号将被阻塞,它的处理动作是⽤⼾⾃定义函数sighandler。

总结一下:

在block位图中,比特位的内容代表该信号是否被阻塞。

在pending位图中,比特位的内容代表是否收到该信号。

handler表本质上是一个函数指针数组,数组的下标代表某一个信号,数组的内容代表该信号递达时的处理动作,处理动作包括默认、忽略以及自定义。

3-3sigset_t

信号集本质

sigset_t = 一个 32 位整数(位图)

从上图来看,每个信号只有⼀个bit的未决标志,⾮0即1,不记录该信号产⽣了多少次,阻塞标志也是这样表⽰的。因此,未决和阻塞标志可以⽤相同的数据类型sigset_t来存储, 这个类型可以表⽰每个信号的"有效"或"⽆效"状态,在阻塞信号集中"有效"和"⽆效"的含义是该信号是否被阻塞,⽽在未决信号集中"有效"和"⽆效"的含义是该信号是否处于未决状态。

3-4信号集操作函数

sigset_t类型对于每种信号用一个bit表示"有效"或"无效",至于这个类型内部如何存储这些bit则依赖于系统的实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。

cpp 复制代码
#include <signal.h>

int sigemptyset(sigset_t *set);

int sigfillset(sigset_t *set);

int sigaddset(sigset_t *set, int signum);

int sigdelset(sigset_t *set, int signum);

int sigismember(const sigset_t *set, int signum);

sigemptyset / sigfillset / sigaddset / sigdelset / sigismember

都是用户态库函数,仅仅是对 sigset_t 信号集做二进制位的修改与判断。

它们不触发系统调用、不进入内核、不改变进程的信号屏蔽字。

sigemptyset:把信号集所有位清 0

sigfillset:把信号集所有位置 1

sigaddset:把某一个信号位置 1

sigdelset:把某一个信号位清 0

sigismember:判断某一个信号位是否为 1

3-5sigprocmask

真正进入内核、修改信号屏蔽字的系统调

sigprocmask函数可以用于读取或更改进程的信号屏蔽字(阻塞信号集),该函数的函数原型如下:

bash 复制代码
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

每个参数含义

how:怎么修改屏蔽字(3 种方式)

set:新的信号集(传入)

oldset:保存原来的屏蔽字(传出,方便恢复)

返回值:成功 0,失败 - 1

三个 how

1.SIG_BLOCK

把 set 里的信号 加到 屏蔽字中 mask = mask | set

2.SIG_UNBLOCK

把 set 里的信号 从 屏蔽字中 移除 mask = mask & ~set

3.SIG_SETMASK

直接设置 屏蔽字 = set mask = set

sigpending

sigpending函数可以用于读取进程的未决信号集,该函数的函数原型如下:

bash 复制代码
int sigpending(sigset_t *set);

sigpending函数读取当前进程的未决信号集,并通过set参数传出。该函数调用成功返回0,出错返回-1。

下面我们来做一个简单的实验

实验步骤如下:

1.先用上述的函数将2号信号进行屏蔽(阻塞)。

2.使用kill命令或组合按键向进程发送2号信号。

3.此时2号信号会一直被阻塞,并一直处于pending(未决)状态。

4.使用sigpending函数获取当前进程的pending信号集进行验证。

代码

cpp 复制代码
#include<stdio.h>
#include<unistd.h>
#include<signal.h>

void printPending(sigset_t *pending)
{
    for(int i =1;i<=31;i++)
    {
        if(sigismember(pending,i))
        {
            printf("1 ");
        }
        else
        {
            printf("0 ");
        }
    }
    printf("\n");
}

int main()
{
    sigset_t set,oset;
    //清空
    sigemptyset(&set);
    sigemptyset(&oset);
     
    //把二号信号设置成阻塞
    sigaddset(&set,2);
    //修改内核
    sigprocmask(SIG_SETMASK,&set,&oset);

    sigset_t pending;
    sigemptyset(&pending);
    
    while(1)
    {
        sigpending(&pending);//获取pending
        printPending(&pending);//打印pending位图
        sleep(2);
    }
    return 0;
   
}

为了看到2号信号递达后pending表的变化,我们可以设置一段时间后,自动解除2号信号的阻塞状态,解除2号信号的阻塞状态后2号信号就会立即被递达。因为2号信号的默认处理动作是终止进程,所以为了看到2号信号递达后的pending表,我们可以将2号信号进行捕捉,让2号信号递达时执行我们所给的自定义动作。

此时就可以看到,进程收到2号信号后,该信号在一段时间内处于未决状态,当解除2号信号的屏蔽后,2号信号就会立即递达,执行我们所给的自定义动作,而此时的pending表也变回了全0。

4. 捕捉信号

4.1内核空间与用户空间

每一个进程都有自己的进程地址空间,该进程地址空间由内核空间和用户空间组成:

用户所写的代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系。

内核空间存储的实际上是操作系统代码和数据,通过内核级页表与物理内存之间建立映射关系。

内核级页表是一个全局的页表,它用来维护操作系统的代码与进程之间的关系。因此,在每个进程的进程地址空间中,用户空间是属于当前进程的,每个进程看到的代码和数据是完全不同的,但内核空间所存放的都是操作系统的代码和数据,所有进程看到的都是一样的内容。

注意:

当你访问用户空间时你必须处于用户态,

当你访问内核空间时你必须处于内核态

内核态不是靠指针就可以访问的,只能靠系统调用(后面会说)

4.2操作系统是怎么运⾏的

外设就绪后,向中断控制器发起中断请求;

中断控制器仲裁后通知 CPU,并传递中断号;

CPU 暂停当前任务,保护现场(保存寄存器);

根据中断号查询中断向量表(IDT),跳转到对应的中断服务例程处理;

处理完毕后恢复现场,继续执行原程序。


4-2-1硬件中断

• 中断向量表就是操作系统的⼀部分,启动就加载到内存中了

• 通过外部硬件中断,操作系统就不需要对外设进⾏任何周期性的检测或者轮询

• 由外部设备触发的,中断系统运⾏流程,叫做硬件中断

  • 中断号:是硬件中断的唯一标识,每个外设对应一个固定中断号,用于索引中断向量表。

  • 中断向量表(IDT):是内核维护的"中断处理函数地址表",存储了每个中断号对应的中断服务例程入口。

  • 现场保护/恢复:是中断处理的核心,保证中断不会破坏原任务的执行上下文,实现"无缝暂停-恢复"。

注意

硬件中断是CPU响应硬件异步事件的底层机制,信号是操作系统为进程模拟的"软件中断",二者流程相似、本质完全不同:硬件中断由硬件触发、CPU内核态处理;信号由软件/内核触发、进程用户态处理。

4-2-2时钟中断

问题:

1.进程可以在操作系统的指挥下,被调度,被执⾏,那么操作系统⾃⼰被谁指挥,被谁推动执⾏呢?

  1. 外部设备可以触发硬件中断,但是这个是需要⽤⼾或者设备⾃⼰触发,有没有⾃⼰可以定期触发的

设备?

• 进程是由操作系统来调度运行的,而操作系统本身也是软件,它不能自己主动跑,必须靠硬件来驱动。

• 驱动操作系统的,就是时钟硬件 + 时钟中断。

• CPU 外部有一个晶振,它以固定频率不停震动,每隔一段固定时间,就向 CPU 发送一次时钟中断。

•每次时钟中断触发,CPU 就会暂停当前正在运行的进程,进入内核态,让操作系统代码运行。

操作系统这时就会检查当前进程的时间片有没有用完。

•时间片就是系统分给每个进程的 CPU 执行时间。

如果时间片用完了,操作系统就会进行进程调度,把当前进程换下 CPU,让下一个进程上去运行。

如果时间片没用完return ,中断什么都没做

所以:

时钟中断负责唤醒操作系统,时间片用来控制进程运行多久,调度负责切换进程。

4-2-3死循环

如果是这样,操作系统不就可以躺平了吗?对,操作系统⾃⼰不做任何事情,需要什么功能,就向中

断向量表⾥⾯添加⽅法即可.操作系统的本质:就是⼀个死循环!

cpp 复制代码
void main(void) /* 这⾥确实是void,并没错。 */
{ /* 在startup 程序(head.s)中就是这样假设的。 */
...
/*
* 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到⼀个信号才会返
* 回就绪运⾏态,但任务0(task0)是唯⼀的意外情况(参⻅'schedule()'),因为任
* 务0 在任何空闲时间⾥都会被激活(当没有其它任务在运⾏时),
* 因此对于任务0'pause()'仅意味着我们返回来查看是否有其它任务可以运⾏,如果没
* 有的话我们就回到这⾥,⼀直循环执⾏'pause()'。
*/
for (;;)
pause();
} // end main

操作系统被唤醒后,会执行:

  1. 时间片检查:判断当前进程的时间片是否用完。
  • 时间片未到:直接切回用户态,让进程继续运行,操作系统再次休眠。

  • 时间片到了:将当前进程的上下文保存,放回就绪队列。

  1. 事件处理:处理各类硬件中断(键盘、网卡、磁盘)、软中断、异常、信号等。

  2. 进程调度:从就绪队列中选择下一个要运行的进程/线程,加载其上下文到CPU。

  3. 切回用户态,进程运行

  • 调度完成后,CPU切回用户态,把CPU控制权完全交给选中的进程/线程。

  • 进程/线程从自己的代码入口开始执行,真正跑起来,操作系统再次进入休眠( pause() ),不占用CPU。

  • 等待下一次时钟中断触发,重复上述流程,实现无限循环调度。

4-2-4软中断

• 上述外部硬件中断,需要硬件设备触发。

• 有没有可能,因为软件原因,也触发上⾯的逻辑?有!

• 为了让操作系统⽀持进⾏系统调⽤,CPU也设计了对应的汇编指令(int?或者?syscall),可以让CPU内

部触发中断逻辑。

问题:

• ⽤⼾层怎么把系统调⽤号给操作系统?

• 操作系统怎么把返回值给⽤⼾?

1.用户层把系统调用号传给内核,是通过寄存器(比如 EAX/rax)来传递的。

2.操作系统把返回值带回用户层,也是通过寄存器,或者用户事先传入的缓冲区地址。

3.系统调用的整个过程,本质就是先通过 int 0x80 或 syscall 指令陷入内核,这其实就是触发一次软中断。

4.CPU 一旦进入内核,就会自动执行系统调用的总处理函数,这个函数会根据系统调用号去查表,找到并执行对应的内核函数。

所以一句话:

系统调用号,本质就是系统调用表的数组下标。

问题:

• 可是为什么我们⽤的系统调⽤,从来没有⻅过什么 int 0x80 或者 syscall 呢?

都是直接调⽤上层的函数的啊?

• 那是因为Linux的gnu C标准库,给我们把⼏乎所有的系统调⽤全部封装了。

后面会详细追踪fopen举例

4-2-5缺⻚中断 内存碎⽚处理 除零野指针错误

缺⻚中断?内存碎⽚处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断,

然后⾛中断处理例程,完成所有处理。有的是进⾏申请内存,填充⻚表,进⾏映射的。有的是⽤来

处理内存碎⽚的,有的是⽤来给⽬标进⾏发送信号,杀掉进程等等。

4-3sigaction

c 复制代码
#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结构体:

其中,参数act和oldact都是结构体指针变量,该结构体的定义如下:

c 复制代码
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

赋值为常数SIG_IGN传给sigaction表⽰忽略信号,

赋值为常数SIG_DFL表⽰执⾏系统默认动作,

赋值为⼀个函数指针表⽰⽤⾃定义函数捕捉信号,或者说向内核注册了⼀个信号处理函

数,该函数返回值为void,可以带⼀个int参数,通过参数可以得知当前信号的编号,这样就可以⽤同⼀个函数处理多种信号。显然,这也是⼀个回调函数,不是被main函数调⽤,⽽是被系统所调⽤。

当某个信号的处理函数被调⽤时,内核⾃动将当前信号加⼊进程的信号屏蔽字,当信号处理函数返回时**⾃动恢复原来的信号屏蔽字**,这样就保证了在处理某个信号时,如果这种信号再次产⽣,那么它会被阻塞到当前处理结束为⽌。如果在调⽤信号处理函数时,除了当前信号被⾃动屏蔽之外,还希望⾃动屏蔽另外⼀些信号,则⽤sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时⾃动恢复原来的信号屏蔽字。

4-4如何理解内核态和⽤⼾态

内核态与用户态:

内核态通常用来执行操作系统的代码,是一种权限非常高的状态。

用户态是一种用来执行普通用户代码的状态,是一种受监管的普通状态。

从用户态切换为内核态通常有如下几种情况:

1.需要进行系统调用时。

2.当前进程的时间片到了,导致进程切换。

3.产生异常、中断、陷阱等。

与之相对应,从内核态切换为用户态有如下几种情况:

1.系统调用返回时。

2.进程切换完毕。

3.异常、中断、陷阱等处理完毕。

其中,由用户态切换为内核态我们称之为陷入内核。每当我们需要陷入内核的时,本质上是因为我们需要执行操作系统的代码,比如系统调用函数是由操作系统实现的,我们要进行系统调用就必须先由用户态切换为内核态。

详细用户态/内核态本质

一行汇编代码 = 一条 CPU 指令

CPU 指令就是直接指挥硬件干活的命令。

• CPU 指令集 :是CPU实现软件指挥硬件执⾏的媒介,具体来说每⼀条汇编语句都对应了⼀条CPU 指令 ,⽽⾮常⾮常多的 CPU 指令 在⼀起,可以组成⼀个、甚⾄多个集合,指令的集合叫 CPU 指令集 。

• CPU 指令集 有权限分级,⼤家试想, CPU 指令集 可以直接操作硬件的,要是因为指令操作的不规范,造成的错误会影响整个计算机系统的。好⽐你写程序,因为对硬件操作不熟悉,导致操作系统内核、及其他所有正在运⾏的程序,都可能会因为操作失误⽽受到不可挽回的错误,最后只能重启计算机才⾏。

◦ 对开发⼈员来说是个艰巨的任务,还会增加负担,同时开发⼈员在这⽅⾯也不被信任,所以操作系统内核直接屏蔽开发⼈员对硬件操作的可能,都不让你碰到这些CPU 指令集 。

针对上⾯的需求,硬件设备商直接提供硬件级别的⽀持,做法就是对CPU 指令集 设置了权限,不同级别权限能使⽤的CPU 指令集 是有限的,以InterCPU为例,Inter把?CPU 指令集 操作的权限由

⾼到低划为4级:

• ring0:权限最⾼,可以使⽤所有?CPU 指令集

• ring1

• ring2

• ring3:权限最低,仅能使⽤常规 CPU 指令集 ,不能使⽤操作硬件资源的 CPU 指令集 ,⽐如 IO 读写、⽹卡访问、申请内存都不⾏

要知道的是,Linux系统仅采⽤ring0和ring3这2个权限。CPU中有⼀个标志字段,标志着线程的运⾏状态,⽤⼾态为3,内核态为0。

• ring0被叫做内核态,完全在操作系统内核中运⾏

◦ 执⾏内核空间的代码,具有ring0保护级别,有对硬件的所有操作权限,可以执⾏所有 CPU指令集 ,访问任意地址的内存,在内核模式下的任何异常都是灾难性的,将会导致整台机器停机

• ring3被叫做⽤⼾态,在应⽤程序中运⾏

◦ 在⽤⼾模式下,具有ring3保护级别,代码没有对硬件的直接控制权限,也不能直接访问地址的内存,程序是通过调⽤系统接⼝(System?Call?APIs)来达到访问硬件和内存,在这种保护模式下,即时程序发⽣崩溃也是可以恢复的,在电脑上⼤部分程序都是在,⽤⼾模式下运⾏的低权限的资源范围较⼩,⾼权限的资源范围更⼤,所以⽤⼾态与内核态的概念就是CPU指令集权限的区别。

我们通过指令集权限区分⽤⼾态和内核态,还限制了内存资源的使⽤,操作系统为⽤⼾态与内核态划分了两块内存空间,给它们对应的指令集使⽤。

在内存资源上的使⽤,操作系统对⽤⼾态与内核态也做了限制,每个进程创建都会分配虚拟空间地址,以Linux32位操作系统为例,它的寻址空间范围是4G (2的32次⽅),⽽操作系统会把虚拟控制地址划分为两部分,⼀部分为内核空间,另⼀部分为⽤⼾空间,⾼位的 1G (从虚拟地址0xC0000000?到0xFFFFFFFF)由内核使⽤,⽽低位的 3G (从虚拟地址0x00000000到0xBFFFFFFF)由各个进程使⽤。

切换时 CPU 做了什么?

1.保存用户态现场

把用户态寄存器、栈信息保存到内存,方便后面恢复。

2.权限提升(提权)

CPU 从 Ring3 切换到 Ring0,获得执行所有指令、操作硬件的权限。

3.切换到内核栈

CPU 从 TSS 中取出 SS0、ESP0,切换到内核栈,内核函数在 kernel stack 上运行。

执行内核处理函数

4.系统调用、异常、中断对应的内核代码开始执行。

5.处理完恢复现场

恢复寄存器、切回 Ring3、切回用户栈,回到用户进程继续执行。

一句话总结

用户态到内核态切换,本质是CPU 权限从 Ring3 升到 Ring0 + 保存现场 + 切换内核栈,因为涉及保存、恢复、权限检查,所以开销比较大

4-5内核如何实现信号的捕捉

1.当我们在执行主控制流程的时候,可能因为某些情况而陷入内核,当内核处理完毕准备返回用户态时,就需要进行信号pending的检查。(此时仍处于内核态,有权力查看当前进程的pending位图)

2.在查看pending位图时,如果发现有未决信号,并且该信号没有被阻塞,那么此时就需要该信号进行处理。

3.(1)

如果待处理信号的处理动作是默认或者忽略,则执行该信号的处理动作后清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,从主控制流程中上次被中断的地方继续向下执行即可。

3.(2)

如果信号的处理动作是⽤⼾⾃定义函数,在信号递达时就调⽤这个函数,这称为捕捉信号。

4.函数返回后⾃动执⾏特殊的系统调⽤ sigreturn 再次进⼊内核态。

  1. 如果没有新的信号要递达,这次再返回⽤⼾态就是恢复 main 函数的上下⽂继续执⾏了。

由于信号处理函数的代码是在⽤⼾空间的,处理过程⽐较复杂,举例如下:

• ⽤⼾程序注册了 SIGQUIT 信号的处理函数 sighandler 。

• 当前正在执⾏ main 函数,这时发⽣中断或异常切换到内核态。

• 在中断处理完毕后要返回⽤⼾态的 main 函数之前检查到有信号 SIGQUIT 递达。

• 内核决定返回⽤⼾态后不是恢复 main 函数的上下⽂继续执⾏,⽽是执⾏ sighandler 函数, sighandler 和 main 函数使⽤不同的堆栈空间,它们之间不存在调⽤和被调⽤的关系,是两个独⽴的控制流程。

• sighandler 函数返回后⾃动执⾏特殊的系统调⽤ sigreturn 再次进⼊内核态。

• 如果没有新的信号要递达,这次再返回⽤⼾态就是恢复 main 函数的上下⽂继续执⾏了。

注意

第四步回内核态干嘛,直接回main上下文不行吗,都是用户态

不行,原因非常关键:

sighandler 不是被 main 调用的,是内核 "硬跳" 过去的

没有 call,没有栈帧记录

用户态根本不知道原来的上下文在哪

原来的上下文在内核手里

内核当时为了让你跑 sighandler,把:

eip

esp

寄存器

栈状态

全改了。
只有内核能把它们原样改回去。

必须检查新信号

回到内核才能判断:

还有没有别的信号排队?

有就继续处理,没有才回 main。

信号掩码需要内核恢复

进入 sighandler 时内核自动屏蔽同类信号,

退出时必须内核帮你解除。

5. 可重⼊函数

• main函数调⽤insert函数向⼀个链表head中插⼊节点node1,插⼊操作分为两步,刚做完第⼀步的时候,因为硬件中断使进程切换到内核,再次回⽤⼾态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调⽤insert函数向同⼀个链表head中插⼊节点node2,插⼊操作的两步都做完之后从sighandler返回内核态,再次回到⽤⼾态就从main函数调⽤的insert函数中继续往下执⾏,先前做第⼀步之后被打断,现在继续做完第⼆步。结果是,main函数和sighandler先后向链表中插⼊两个节点,⽽最后只有⼀个节点真正插⼊链表中了。

• 像上例这样,insert函数被不同的控制流程调⽤,有可能在第⼀次调⽤还没返回时就再次进⼊该函数,这称为重⼊,insert函数访问⼀个全局链表,有可能因为重⼊⽽造成错乱,像这样的函数称为不可重⼊函数,反之,如果⼀个函数只访问⾃⼰的局部变量或参数,则称为可重⼊(Reentrant)函数。想⼀下,为什么两个不同的控制流程调⽤同⼀个函数,访问它的同⼀个局部变量或参数就不会造成错乱

如果⼀个函数符合以下条件之⼀则是不可重⼊的:

• 调⽤了malloc或free,因为malloc也是⽤全局链表来管理堆的。

• 调⽤了标准I/O库函数。标准I/O库的很多实现都以不可重⼊的⽅式使⽤全局数据结构。

6.追踪fopen

c 复制代码
# define _IO_new_fopen fopen //宏
// 转到定义
_IO_FILE *
_IO_new_fopen (filename, mode)
const char *filename;
const char *mode;
{
return __fopen_internal (filename, mode, 1);
}
// 转到定义
_IO_FILE *
__fopen_internal (filename, mode, is32)
const char *filename;
const char *mode;
int is32;
{
if (INTUSE(_IO_file_fopen) ((_IO_FILE *) new_f, filename, mode, is32)
}
// 搜索:_IO_file_fopen
INTDEF2(_IO_new_file_fopen, _IO_file_fopen)
// 为了保证软件兼容性,给_IO_file_fopen 起别名:_IO_new_file_fopen,也就是底层调⽤的
就是_IO_new_file_fopen
// INTDEF2:这个宏⽐较难看,忽略
// 搜索:_IO_new_file_fopen
_IO_FILE *
_IO_new_file_fopen (fp, filename, mode, is32not64)
_IO_FILE *fp;
const char *filename;
const char *mode;
int is32not64;
{
result = _IO_file_open (fp, filename, omode|oflags, oprot, read_write,
is32not64);
}
// 转到定义
_IO_FILE *
_IO_file_open (fp, filename, posix_mode, prot, read_write, is32not64)
_IO_FILE *fp;
const char *filename;
int posix_mode;
int prot;
int read_write;
int is32not64;
{
fdesc = open (filename, posix_mode, prot); // 系统调⽤
}
// open,被__open替换
#define open(Name, Flags, Prot) __open (Name, Flags, Prot)
// _open之后,因为glibc做了很多隐藏机制,我们直接看伪代码就可以了
int __open(const char *file, int oflag, mode_t mode)
{
return INLINE_SYSCALL(open, 3, file, oflag, mode);
}
# define INLINE_SYSCALL(name, nr, args...) \
({ \
unsigned long int resultvar = INTERNAL_SYSCALL (name, , nr, args);
\
if (__builtin_expect (INTERNAL_SYSCALL_ERROR_P (resultvar, ), 0)) \
{ \
__set_errno (INTERNAL_SYSCALL_ERRNO (resultvar, )); \
resultvar = (unsigned long int) -1; \
} \
(long int) resultvar; })
# define INTERNAL_SYSCALL(name, err, nr, args...) \
INTERNAL_SYSCALL_NCS (__NR_##name, err, nr, ##args)
// #define __NR_open 2
// 这个前⾯⻅过了
# define INTERNAL_SYSCALL_NCS(name, err, nr, args...) \
({ 
unsigned long int resultvar; \
LOAD_ARGS_##nr (args) \
LOAD_REGS_##nr \
asm volatile ( \
"syscall\n\t" \
: "=a" (resultvar) \
: "0" (name) ASM_ARGS_##nr : "memory", "cc", "r11", "cx"); \
(long int) resultvar; })
// "0" (__NR_##name):系统调⽤号存⼊ %eax
  1. 你写的 fopen () 其实是宏包装
c 复制代码
#define _IO_new_fopen fopen

你调用 fopen,实际调用 _IO_new_fopen

  1. 一层层往里跳,最后一定会走到:
c 复制代码
_IO_new_file_fopen -> _IO_file_open -> open()

最终一定会调用 open ()

  1. open () 不是真正的系统调用,它还是 glibc 包装
c 复制代码
#define open(...) __open(...)

真正的系统调用藏在:

c 复制代码
INLINE_SYSCALL(open, 3, file, oflag, mode);
  1. INLINE_SYSCALL 干了什么?

    它干三件事:

    把 系统调用号 __NR_open(值 = 2) 放进 EAX/RAX

    把参数放进其他寄存器

    执行汇编指令:

    asm

    syscall

    → 触发软中断,进入内核!

  2. 最终进入内核的是:

    asm

    syscall // 指令,让CPU从用户态 → 内核态

    内核根据

    EAX = 2(系统调用号)

    去查系统调用表,找到内核的 sys_open 执行。

fopen 是库函数,一层层包装,最后调用 open,

open 再把系统调用号 2 放进 EAX,

执行 syscall 指令,

CPU 切内核态,执行内核的 sys_open。

相关推荐
逸Y 仙X2 小时前
文章九:ElasticSearch索引字段常见属性
java·大数据·服务器·数据库·elasticsearch·搜索引擎
野犬寒鸦2 小时前
JVM垃圾回收机制深度解析(G1篇)(垃圾回收过程及专业名词详解)
java·服务器·jvm·后端·面试
清水白石0082 小时前
协程不是线程:深入理解 Python async/await 运行机制
java·linux·python
va学弟2 小时前
Java 网络通信编程(7):完善视频通信
java·服务器·网络
fengpan20042 小时前
ubuntu下vscode使用串口
linux·运维·服务器
IMPYLH2 小时前
Linux 的 cut 命令
linux·运维·服务器·数据库
草莓熊Lotso2 小时前
MySQL 内置函数指南:日期、字符串、数学函数实战
android·java·linux·运维·数据库·c++·mysql
牛十二2 小时前
智能体框架开发实战
运维·服务器·前端
艾莉丝努力练剑2 小时前
【Linux信号】Linux进程信号(上):信号产生方式和闹钟
linux·运维·服务器·c++·人工智能·ubuntu·云原生