今天,我们继续追查信号的来路。上一篇聊了两种产生机制,键盘敲出来的,和系统调用、命令发出来的。今天要讲的,是剩下两条路径:硬件异常软件条件

硬件异常这条线,会把你带进CPU的报错现场,除零、野指针、非法访问,这些错误是怎么被CPU捕获,又怎么变成信号送到进程手里的。软件条件这条线,则要请出一个你可能天天见、却从没认真看过的东西,时钟。顺着它,我们会初步摸到“时钟中断”的门道。信号产生这四条路,今天走完最后两条。我们开始。

目录

一、硬件异常如何产生信号

1.1 硬件异常到底是什么

1.2 两类常见的硬件异常

1.2.1 浮点运算错误

1.2.2 段错误是怎么产生的

1.3 CPU如何发现硬件异常

1.3.1 算术溢出与除零错误的触发过程

1.3.2 非法内存访问与段错误

1.4 MMU如何完成地址与权限检查

1.4.1 没有映射——空指针访问导致的异常

1.4.2 存在映射但访问非法——越界与越权

1.5 异常发生后——操作系统接管进程

1.6 从判断异常到产生对应信号

二、软件条件如何产生信号

2.1 管道异常如何触发信号

2.2 定时器如何产生信号

2.2.1 先从一个简单例子认识闹钟

2.2.2 闹钟函数的使用方法详解

2.2.3 递归闹钟——从定时任务理解操作系统的运行机制

2.2.4 从时间戳理解时间片

2.2.5 从“先描述,再组织”理解操作系统中的闹钟


一、硬件异常如何产生信号

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位的信号位图。而这四条路里,最耐人寻味的,是最后那条。闹钟让我们窥见了操作系统最底层的运作方式:它不是一个事必躬亲的劳模,而是一个被事件推着走的响应者。闹钟到点了,信号来了,它才醒过来干活。时钟中断、时间片、进程调度,这一整套机制,都是从这个朴素的原点长出来的。这部分内容,后面讲时钟中断时还会展开。

信号产生讲完了,但信号的故事才刚刚开始。信号产生之后,进程怎么把它保存下来?什么时候处理?处理的时候有哪几种选择?这些,就是下一篇要拆的内容,信号的保存与处理

如果这篇文章对你有帮助,别忘了点个赞、点个收藏、点个关注。你的每一次反馈,都是我继续硬核输出的最大动力。我们下篇见。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐