《从零入门Linux系统篇(四十四):信号篇·二——信号产生详解:从硬件异常到软件条件》
今天,我们继续追查信号的来路。上一篇聊了两种产生机制,键盘敲出来的,和系统调用、命令发出来的。今天要讲的,是剩下两条路径:硬件异常和软件条件。
硬件异常这条线,会把你带进CPU的报错现场,除零、野指针、非法访问,这些错误是怎么被CPU捕获,又怎么变成信号送到进程手里的。软件条件这条线,则要请出一个你可能天天见、却从没认真看过的东西,时钟。顺着它,我们会初步摸到“时钟中断”的门道。信号产生这四条路,今天走完最后两条。我们开始。
目录
一、硬件异常如何产生信号
1.1 硬件异常到底是什么
在 Linux 里,程序跑着跑着突然崩了,除零、野指针乱飞、访问了不该碰的内存,这些事故的第一现场,其实不在操作系统,而在CPU。
是CPU先撞见了这些异常。它执行指令的时候,硬件电路当场检测到问题,比如“除数竟然是零”“这个地址压根没有映射”。CPU不会替程序兜着,它立刻触发中断机制,把情况上报给操作系统内核。内核接到报告,再决定怎么处置这个闯祸的进程,通常是给它发一个信号,然后送它上路。
信号由操作系统发送。

1.2 两类常见的硬件异常
1.2.1 浮点运算错误
8号信号,名字叫SIGFPE,全称Floating-point exception,直译过来就是“浮点数异常”。这个名字很容易误导人,让人以为只有浮点运算出错才会触发。实际上,整数除零也会送这个信号。它管的其实是“算术异常”这一大类。看个最简单的例子:
#include <stdio.h>
int main()
{
volatile int a = 10;
volatile int b = 0;
int c = a / b; // 除零
printf("%d\n", c);
return 0;
}
编译运行,程序会当场崩溃,终端打印:
Floating point exception (core dumped)
内核发给进程的,就是8号信号SIGFPE。进程收到后,执行默认动作,终止,并生成core dump,把现场保存下来,方便事后调试。
为什么叫“浮点数异常”?历史原因。早期这个信号确实主要给浮点运算用,后来整数除零、算术溢出这类异常也归它管,名字就沿用了下来,一直没改。名字不重要,重要的是这条链路:CPU检测到算术异常,上报内核,内核发SIGFPE,进程倒下。
如果想捕捉SIGFPE,用signal注册自定义处理函数,理论上可以。但要注意一个坑:处理函数返回后,出错的那条指令往往还会被重新执行,于是又触发一次,陷入死循环。所以捕捉SIGFPE通常意味着你要么在里面exit,要么用longjmp跳走,不能简单返回。

1.2.2 段错误是怎么产生的
11号信号,大名鼎鼎的SIGSEGV,全称Segmentation violation,翻译过来就是“段错误”。这大概是所有C/C++程序员最熟悉的“死法”之一,程序跑着跑着,屏幕上蹦出一句Segmentation fault (core dumped),然后当场暴毙。看个最经典的例子:
#include <stdio.h>
int main()
{
int *p = NULL; // 空指针
*p = 100; // 往空指针里写数据
return 0;
}
编译运行,程序直接崩掉,终端打印:
Segmentation fault (core dumped)
内核发给进程的,就是11号信号SIGSEGV。
这个信号是怎么产生的?过程其实非常“硬件”。CPU执行到*p = 100这条指令时,要把数据写到p指向的地址。但p是 NULL,地址0。CPU拿着这个虚拟地址去问MMU:“这个地址对应哪块物理内存?”MMU一查页表,发现地址0压根没有映射。于是它当场拒绝,触发一次异常,上报给内核。
内核接管后,会判断:这是一次“合法的缺页”,还是“非法的越界访问”?如果是合法的缺页,比如你malloc了内存但还没碰过,内核就默默补一页给你,程序继续跑。但地址0这种,明显是非法访问,不属于正常缺页。内核就认定这是“段错误”,于是给进程发一个SIGSEGV。进程收到后,执行默认动作,终止,并生成core dump。
段错误的常见触发场景,无非这几个:
- 空指针解引用:*p = 100,p是 NULL。
- 野指针:指针指向了已经释放的内存,或者根本没初始化的随机地址。
- 数组越界:访问了数组范围之外的地址,尤其是越界到未映射的区域。
- 栈溢出:递归太深,把栈撑爆了,触碰到了保护页。
- 访问只读内存:比如试图修改字符串常量的内容。
理论上,SIGSEGV也能被捕捉。用signal注册一个处理函数,程序崩溃前能让你做点收尾工作。但说实话,大多数情况下,捕捉它意义不大。因为段错误一旦发生,往往意味着内存已经被写坏,进程的状态已经不可信了。你能做的,顶多是记录一行日志,然后老老实实退出。想在里面“挽救”程序,基本是徒劳。

1.3 CPU如何发现硬件异常
操作系统作为软硬件资源的大管家,之所以能第一时间知道“进程犯事了”,靠的可不是猜测,而是CPU内部一堆寄存器和硬件单元的实时盯梢。
CPU里这些寄存器,各有各的职责,像一支分工明确的哨兵队伍:
主要寄存器类别
-
指令寄存器(IR):存着当前正在执行的那条指令。CPU每次干活,先把指令从内存读进来,再送进 IR,然后照它执行。
-
程序计数器(PC):指着下一条要执行指令的地址。程序启动时,第一条指令的地址就塞进了 PC,之后它一路往前走,CPU就知道接下来该跑哪行。
-
地址寄存器(AR):保存CPU当前要访问的内存单元地址。只要CPU跟内存交换信息,AR 和数据寄存器就会出场。
-
数据寄存器(DR):CPU和内存、外设之间的“中转站”。速度差那么多,就靠它来缓冲协调。
-
累加寄存器(AC):也就是累加器,算术逻辑单元(ALU)干活时的工作台。加减乘除、逻辑运算,操作数都在这儿倒腾。
-
程序状态字寄存器(PSW):记录当前运算的状态和程序的工作方式。有没有溢出、结果是不是负数、进位没进位,这些条件码全由它兜着。
其他寄存器类别
除了上面这几位主角,还有一批配角也各有用处:
-
通用寄存器:AX、BX、CX、DX这些,平时存放数据、参与运算,是最常用的“万金油”。
-
变址寄存器:SI(源寄存器)和DI(目的寄存器),处理数组这类数据结构时特别顺手。
-
指针寄存器:SP(堆栈指针)和BP(基址指针),管堆栈相关的操作,函数调用、局部变量都离不开它们。
-
专用寄存器:IP(指令指针)和FLAGS(标志寄存器),一个控制执行流程,一个记录状态标志,各守一摊。
那这些跟硬件异常有什么关系?
关键就在于,CPU在执行指令的过程中,这些寄存器和硬件单元一直在实时监控。比如:
-
执行除法时,ALU发现除数是0,立刻置位状态标志,触发异常。
-
执行内存访问时,MMU拿着地址去查页表,发现映射不合法,也立刻上报。
-
运算结果溢出、非法指令、对齐错误……每一种都有对应的硬件检测机制。
这些异常一旦被CPU捕获,它就会通过中断机制,把控制权交给操作系统内核。内核接过现场,判断这是哪类异常,然后给进程发对应的信号,SIGFPE、SIGSEGV、SIGILL等等。
1.3.1 算术溢出与除零错误的触发过程
当程序执行a /= 0时,CPU里的算术逻辑单元(ALU)正打算做除法,结果一看除数,是零。这活儿它干不了。于是它当场触发异常,顺手把状态寄存器(比如x86的EFLAGS)里的某个标志位置位,相当于在案发现场插了一面小旗子,记下“这里出过事”。这个标志,就是后面内核判断该发哪个信号的依据。
1.3.2 非法内存访问与段错误
再看内存这边。程序里写了int *p = nullptr; *p = 100;,CPU要往地址0写数据。可它自己不会凭空找到物理内存,得靠一个专门的硬件,MMU(内存管理单元)。
MMU接过这个虚拟地址,再拿出CR3寄存器里保存的当前进程页表物理地址,开始查表。它把虚拟地址一层层翻译成物理地址,翻译的过程中,重点盯着页表项里的“权限和状态”字段:
- 这个地址有没有映射?
- 有没有写权限?
- 是用户态能碰的,还是内核专属的?
一旦发现不合法——比如地址0压根没映射,或者这块内存压根不允许写,MMU立刻拒绝,并触发异常。内核接到报告,判断这属于非法访问,于是给进程发一个SIGSEGV。进程收到,执行默认动作,终止,生成core dump。
所以,段错误的完整链路是这样的:程序发起访问 → MMU 查页表 → 发现非法 → 硬件触发异常 → 内核接管 → 发送 SIGSEGV → 进程倒下。 每一环都有硬件在盯着,想蒙混过关,门都没有。
1.4 MMU如何完成地址与权限检查
MMU在翻译地址时,并不是拿到虚拟地址就闷头去查,它还会逐项核对页表项里的权限和状态。一旦发现不对,硬件当场报警,根本不给进程继续往下跑的机会。
1.4.1 没有映射——空指针访问导致的异常
页表项里有一位最关键的标志,叫Present(存在位,通常是Bit 0):
-
Present = 1:这个虚拟地址已经映射到了真实的物理内存,可以继续翻译。
-
Present = 0:这个虚拟地址在物理内存里压根不存在,没有对应的页。
当MMU发现对应页表项的Present位是0时,硬件逻辑电路会立刻触发异常,把控制权交给内核。像空指针解引用这种,地址0通常没有任何映射,Present位自然为0,于是硬件直接报错。内核再一判断:这不是合法缺页,而是非法访问,于是给进程送上SIGSEGV。
1.4.2 存在映射但访问非法——越界与越权
就算地址有映射,也不代表你想怎么碰就怎么碰。页表项里还藏着两位“门禁”:
-
R/W(读写权限位):这一位决定这块内存能不能写。如果代码试图修改一个只读的常量字符串,而它的R/W位是0,MMU当场拒绝,触发异常。
-
U/S(用户/内核权限位):这一位区分这块内存是用户态能碰的,还是只有内核才能看的。如果普通应用程序想去访问内核专属的地址,而对应页表项的U/S位是0,MMU同样会报错。
1.5 异常发生后——操作系统接管进程
当MMU或CPU触发硬件报错的那一刻,CPU会瞬间踩下刹车,暂停当前正在执行的应用程序,强行把控制权切到操作系统的缺页异常处理程序。切换过去之后,操作系统要做的第一件事,就是立刻读取当前进程的上下文。它得搞清楚两件事:是谁,在哪儿,犯了什么错?
在Linux内核源码里,有一个出场率极高的宏(也可以看成指针)current。它永远指向此刻正霸占CPU的那个进程的task_struct。有了它,操作系统就锁定了“肇事者”。
接下来,操作系统会去读一组特殊的寄存器:
-
CR2寄存器:这位极其关键。MMU报错的时候,会自动把那个惹祸的虚拟地址塞进CR2。操作系统一读它,就知道程序刚才到底在访问哪块内存时翻了车。
-
程序计数器(CS:RIP/EIP):操作系统要看看是哪条指令触发了这次内存访问。是加载?是存储?还是取指?顺着这个地址,就能定位到出错的那一行。
-
通用寄存器和错误码(Error Code):这里藏着更多细节,程序当时是在“读”还是在“写”?它是在用户态还是内核态?错误码会把这些问题一一交代清楚。
1.6 从判断异常到产生对应信号
拿到上下文、读完CR2里的虚拟地址之后,操作系统手里就握着那个闯祸的地址了。但光有地址还不够,它得判断:这次报错,到底是“正常缺页”,还是“真出事了”?
怎么判断?靠对比当前进程的VMA,也就是虚拟内存区域,vm_area_struct链表。操作系统拿着那个地址,去进程的VMA链上一查:这个地址,在不在你合法的地盘里?
合法错误——真正的缺页:比如你用mmap分配了一块虚拟内存,但只是画了块地,物理页还没落实,Present位是0。现在你第一次去读写这块地址,MMU一查,没映射,触发异常。操作系统看完VMA就明白了:这地址确实在进程的合法区域内,只是物理页还没到位。于是它动手补齐页表,分配一块物理内存,建立映射,然后恢复上下文,重新执行刚才那条惹事的指令。程序一睁眼,跟没事人一样,继续往下跑。这种异常,叫“合法缺页”,是操作系统故意设计的“按需分配”机制,不是错误,是正常流程。
非法错误——真正的越界或野指针:但如果操作系统一查VMA,发现这个地址压根不在进程的任何合法区域里,或者虽然区域对,但你违反了读写权限,那性质就变了。操作系统毫不客气,直接判定为非法。接下来:如果是内存访问非法,发送 SIGSEGV(段错误)。如果是除零等算术异常,发送 SIGFPE(浮点异常)。信号一发,进程执行默认动作,终止,并生成core dump。屏幕上蹦出一句Segmentation fault (core dumped),程序当场毙命。
二、软件条件如何产生信号
2.1 管道异常如何触发信号
软件条件产生信号的方式有很多种,我们之前学过的管道,就是其中典型的一例。回顾一下管道的四种行为规则:
-
写慢,读快:管道里没数据,读端只能阻塞,干等着写端送货。
-
写快,读慢:管道写满了,写端也得阻塞,等读端腾出空间。
-
写关,继续读:read返回0,表示读到文件结尾,读端可以收工了。
-
读关,继续写:读端都关了,写端还在往管子里灌数据,毫无意义。操作系统绝不干没意义的事,它会直接给写端进程发送SIGPIPE信号,终止写进程。
前三条都是阻塞与唤醒的同步行为,唯独第四条,是“软件条件触发信号”的经典案例。读端关闭这个状态一出现,操作系统就判定:写端继续存在已经没有价值了。操作系统从不干浪费资源的事。读端一旦关闭,写端还闷头往里灌数据,就彻底失去了意义。这时候,操作系统会果断出手,发送SIGPIPE信号,直接终止写端进程。整个过程没有键盘参与,没有硬件报错,纯粹是软件层面的条件满足,信号就产生了。
2.2 定时器如何产生信号
2.2.1 先从一个简单例子认识闹钟
这块内容其实更适合放在时钟中断那部分讲,不过搁在这儿也无妨。


先补一个系统调用:pause()。它能让当前进程暂停下来,安安静静地等信号来敲门。

#include <iostream>
#include <unistd.h>
#include <signal.h>
void Hander(int i)
{
std::cout << "收到一个信号" << i << std::endl;
}
int main()
{
for (int i = 0; i < 32; i++)
signal(i, Hander);
alarm(10);
std::cout << "定一个闹钟" << std::endl;
std::cout << "开始睡觉" << std::endl;
pause();
std::cout << "睡醒" << std::endl;
return 0;
}
上面这段代码,让进程给自己定了个闹钟。时间一到,内核就会发送14号信号(SIGALRM)。
默认情况下,这个信号会终止进程。但因为我们在代码里提前给所有信号都注册了自定义处理函数,进程并不会死,而是打印一行“收到一个信号14”,然后从pause()里醒来,继续往下跑,最后输出“睡醒”。

所以,闹钟的本质,就是软件条件触发的信号:时间这个条件满足了,信号就产生了。
2.2.2 闹钟函数的使用方法详解
调用alarm(seconds) 之后,系统的倒计时就开始了。时间一到,会发生什么?系统会给你的程序发送一个SIGALRM信号。默认情况下,如果你没提前做任何准备,收到这个信号,程序就会直接退出,终止运行。
还有一点要注意:一次只能定一个闹钟。如果你先调了alarm(10),紧接着又调了alarm(3),那前一个闹钟会被直接覆盖掉。程序只会在3秒后收到闹钟信号。想取消闹钟?调用alarm(0)就行。倒计时立刻关闭,信号也不会再触发。
2.2.3 递归闹钟——从定时任务理解操作系统的运行机制
我们让闹钟触发时执行的自定义函数,顺手再定一个新的闹钟。每响一次,就随机抓一件任务来干。先看代码:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <time.h>
#include <vector>
#include <functional>
std::vector<std::function<void(int)>> work;
void func_1(int x) { std::cout << "我是一个网络任务,参数为: " << x << std::endl; }
void func_2(int x) { std::cout << "我是一个磁盘任务,参数为: " << x << std::endl; }
void func_3(int x) { std::cout << "我是一个日志任务,参数为: " << x << std::endl; }
void func_4(int x) { std::cout << "我是一个计算任务,参数为: " << x << std::endl; }
void func_5(int x) { std::cout << "我是一个数据库任务,参数为: " << x << std::endl; }
void Hander(int i)
{
std::cout << "###########################" << std::endl;
work[rand() % 5](rand() % 100);
alarm(1);
std::cout << "###########################" << std::endl;
}
int main()
{
work.push_back(func_1);
work.push_back(func_2);
work.push_back(func_3);
work.push_back(func_4);
work.push_back(func_5);
srand((unsigned int)time(NULL));
for (int i = 0; i < 32; i++)
signal(i, Hander);
alarm(1);
while (true)
pause();
return 0;
}
每过一秒,闹钟响一次,Hander函数被拉起来,随机选一个任务执行,然后立刻又定下一个一秒钟的闹钟。如此循环往复,进程就像一台被上了发条的机器,每隔一秒准时醒一次,干完活继续睡。
注意,主程序里那个while(true) pause();是个死循环。进程平时就瘫在pause()里暂停着,什么都不干,全靠外部信号把它叫醒。

这恰恰就是操作系统最基础的运行原理。
进程可以一直处于暂停状态,靠外部信号传来传去,触发不同功能的调用。操作系统做进程管理、内存管理这些事,底层骨架跟这个闹钟程序一模一样。
拿进程管理举例:进程创建好之后,PCB被push_back进一个管理结构(比如运行队列)。操作系统运行的时候,就遍历这个结构,找到时间片该轮到的下一个进程,然后让它执行。
看明白了吗?操作系统本质上就像这个进程,登录之后,就陷入了一个死循环。 它不会主动跳出来干活,而是安安静静地待在那儿,等着被“叫醒”。闹钟的间隔时间是0.0几秒,操作系统就是以这种极高的频率,被一次次唤醒,然后去执行那些它该做的任务。操作系统居然不是主动干活的,它是个被动的响应者,全靠外部事件推着走。
后面我们会专门讲这个机制:时钟中断。
2.2.4 从时间戳理解时间片
时间戳的本质,就是一个不知疲倦的计数器。外部世界每过一秒,就轻轻推操作系统一下;操作系统每被推一次,计数器就加一。它就这样默默累加,实时记录着系统从启动那一刻起,究竟走过了多少光阴。

2.2.5 从“先描述,再组织”理解操作系统中的闹钟
操作系统里会不会同时存在很多闹钟?当然会。多个进程各自定了闹钟,内核里就躺着一堆定时器。那操作系统要不要管它们?当然要。怎么管?还是那句老话先描述,再组织。
闹钟结构体的伪代码大概长这样:
struct timer_list {
struct list_head entry; // 链表节点,用来串起来
unsigned long expires; // 到期时间(系统自启动后的时间间隔)
void (*function)(unsigned long); // 到期后要调用的函数
unsigned long data; // 传给函数的参数
struct tvec_t_base_s *base; // 所属的时间轮盘
};
每个闹钟都记着自己的到期时间expires。操作系统手里也有一个当前时间值,它拿着这两个数一比:
-
当前时间1000,闹钟到期时间1005,1000 < 1005,说明还没到点,不响。
-
当前时间1006,闹钟到期时间1005,1006 > 1005,到点了,响。
一响,操作系统就调用这个闹钟挂着的function,在里面发送SIGALRM信号,把对应的进程叫醒。

从管理的角度看,新建一个闹钟,本质上就是往管理结构里插入一个新的 timer_list 结构体。理论上,这些闹钟会按到期时间排成一个小堆,最早到期的排在堆顶。每次检查,只看堆顶那个闹钟就行,效率最高。不过要注意,实际的内核实现里,并没有用堆来管闹钟,而是用了另一种更高效的数据结构(时间轮盘)。但“先描述、再组织”这个思想,是一模一样的。
闹钟产生信号的整个过程,没有键盘敲击,没有硬件报错,纯纯粹粹是软件条件满足,时间到了,信号就产生了。这也是软件条件触发信号的另一种方式。
扯得有点远了,但信号产生这块,到这里就算讲完了。


到这里,信号是怎么产生的,四条路全部走完了。键盘敲出来的是信号,系统调用和命令发出来的是信号,CPU遇到除零、野指针这些硬件异常时送出来的是信号,管道读端关闭、闹钟到点这类软件条件满足时,同样也是信号。四条路,殊途同归,最终都汇入同一个地方,进程PCB里那张32位的信号位图。而这四条路里,最耐人寻味的,是最后那条。闹钟让我们窥见了操作系统最底层的运作方式:它不是一个事必躬亲的劳模,而是一个被事件推着走的响应者。闹钟到点了,信号来了,它才醒过来干活。时钟中断、时间片、进程调度,这一整套机制,都是从这个朴素的原点长出来的。这部分内容,后面讲时钟中断时还会展开。
信号产生讲完了,但信号的故事才刚刚开始。信号产生之后,进程怎么把它保存下来?什么时候处理?处理的时候有哪几种选择?这些,就是下一篇要拆的内容,信号的保存与处理。
如果这篇文章对你有帮助,别忘了点个赞、点个收藏、点个关注。你的每一次反馈,都是我继续硬核输出的最大动力。我们下篇见。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)