Linux进程信号

信号初识

概念

信号 ≠ 信号量。

信号:面向进程的异步事件通知机制,用来打断进程正常执行、通知进程发生了特定事件。

信号量:是进程 / 线程间同步互斥的通信工具,用于资源竞争管控,和信号是两套独立概念。

信号机制本质:

  1. 预先内置处理规则:进程运行前,操作系统 / 程序代码就已经写好了各类信号该怎么处理,不是收到信号才临时想办法;就像人提前受过教育,知道敲门声要开门、火警要撤离。
  2. 信号不会立即处理:收到信号不会马上中断执行,会先把信号保存记录下来,等到进程合适的时机(如内核态切换回用户态时)才去处理。
  3. 进程天生可识别信号:和人能识别生活里的各类提醒一样,Linux 进程内核层面就内置了信号识别、处理的底层逻辑。
  4. 信号来源非常多:能给进程发信号的源头种类丰富,键盘操作、程序异常、系统指令、硬件故障等都可以产生信号。

kill -l 查看所有信号。

在这里插入图片描述

信号的 3 种处理动作:

  1. 默认处理动作(系统预设)
  2. 自定义捕捉:程序员写代码注册信号处理函数,收到信号后执行自己写的业务逻辑(比如收到退出信号前先保存数据)。
  3. 忽略处理:进程收到信号后直接无视,不做任何操作。

signal 是系统调用,给指定信号修改进程对它的处理方式。失败返回 SIG_ERR

在这里插入图片描述

  • 参数signum是要设置处理方式的信号编号。
  • 参数handler是函数指针,传入信号处理策略,绑定你自己写的信号处理函数,实现信号捕获自定义逻辑
#include <iostream>
#include <unistd.h>
#include <csignal>

void handler(int sig)
{
    std::cout<<"获得了一个信号:"<<sig<<std::endl;
}
int main()
{
    signal(SIGINT,handler);
    int cnt=0;
    while(1)
    {
        std::cout<<"Hello Signal, "<<cnt++<<std::endl;
        sleep(1);
    }

    return 0;
}

在这里插入图片描述

handler是信号回调函数,当信号触发时,Linux 内核自动调用这个函数,并且自动把触发的信号编号赋值给形参sig。信号本质是宏,SIGINT 就是按下 Ctrl+C 产生的2 号信号。调用signal(),告诉操作系统,只要本进程收到 SIGINT,不要执行默认 “终止进程” 的行为,转而执行handler()。所以打印结果如上。Ctrl+\ 触发的是SIGQUIT (3 号信号),退出进程。

man 7 signal 查看各种信号的详细说明。

在这里插入图片描述

Signal:信号宏名称。Standard:遵循的 POSIX 标准版本。Comment:信号的触发来源、场景说明。

Action:进程收到该信号的系统默认处理行为

  • Core:终止进程 + 生成 core 调试崩溃文件(用于排查程序异常)。
  • Term:直接终止进程。
  • Ign:系统默认忽略该信号。
  • Cont:唤醒恢复被暂停的进程。

前台和后台进程

启动方式

  1. ./XXX前台进程,独占当前终端前台。
  2. ./YYY &后台进程,启动后不占用终端前台。
  3. 默认登录交互的bash/shell本身属于前台进程。

输入输出特性

  • 标准输入(键盘):仅前台进程能读取键盘输入,后台进程无法获取键盘输入
  • 标准输出:前台、后台进程都可以向终端屏幕打印输出内容。

数量规则

  • 同一终端同一时刻:前台进程有且仅有 1 个
  • 同一终端同一时刻:可以同时存在多个后台进程。

键盘硬件只有一套,输入数据流同一时间只能定向给一个进程;前台进程的核心定位就是负责接收键盘交互数据。

在这里插入图片描述

运行testSig,testSig为前台进程,Shell变成后台进程,输入命令 lspwd 没有反应。

在这里插入图片描述

加上 & 让testSig变为后台进程,Shell仍为前台进程,此时输入命令 lspwd 能正常运行。后台进程不会收到键盘信号 Ctrl + c ,需要命令 kill -9 杀掉后台进程。

通过fork()创建父子进程,若父进程先退出,子进程就成为孤儿进程;孤儿进程会被系统进程领养,自动转入后台运行

jobs 查看所有后台任务。fg 任务号 把特定进程提到前台。

在这里插入图片描述

testSig进程后台运行,输入 Ctrl + c 没有反应。输入命令 jobs 显示后台进程testSig信息,使用 fg 1 把testSig提到前台,键盘信号 Ctrl + c 正常响应。

Ctrl + z 可以发送停止信号,将当前前台进程挂起到后台。bg 任务号 后台进程回复运行。

在这里插入图片描述

产生信号

信号处理时机:信号产生后不会立刻被进程处理,会先被暂存,等到进程合适的时机再处理,因此进程需要先把收到的信号记录下来。

发送信号的本质:操作系统向目标进程写入信号、修改进程内的信号位图,核心要素是目标进程 pid + 信号编号

发送权限约束:无论信号由键盘、程序异常、命令哪种方式产生,底层都必须通过操作系统完成发送,修改内核数据只能由 OS 执行。

信号存于进程的内核 PCB 结构体 struct task_struct 中,PCB 内成员 unsigned int sigs 专门存放信号标记。

位图作为存储结构,bit位下标 = 信号编号,bit取值 = 是否收到该信号,修改信号位图的本质即直接修改操作系统内核维护的进程数据。

信号属于异步通知机制,和常规 IPC 进程间通信形式不同,信号体量小、以事件通知为主。

函数产生信号

kill 是系统调用,向一个进程发送指定信号,成功返回0,失败返回-1。

在这里插入图片描述

用系统调用kill实现kill命令。

//Makefile
.PHONY:all
all:testSig mykill

testSig:testSig.cc
	g++ -o $@ $^ -std=c++11
mykill:mykill.cc
	g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
	rm -f testSig
        
//testSig.cc
#include <iostream>
#include <unistd.h>
#include <csignal>

void handler(int sig)
{
    std::cout<<"获得了一个信号:"<<sig<<std::endl;
}
int main()
{
    signal(SIGINT,handler);
    int cnt=0;
    while(1)
    {
        std::cout<<"Hello Signal, "<<cnt++<<" pid: "<<getpid()<<std::endl;
        sleep(1);
    }

    return 0;
}

//mykill.cc
#include <iostream>
#include <string>
#include <signal.h>

//执行 ./mykill signumber pid
int main(int argc,char* argv[])
{
    if(argc != 3)
    {
        std::cout<<"failed"<<std::endl;
        return 1;
    }
    pid_t id = std::stoi(argv[2]);
    int signum = std::stoi(argv[1]);

    int n = kill(id,signum);
    if(n == 0)
        std::cout<<"send signal: "<<signum<<" success "<<id<<std::endl;
    else
        std::cout<<"failed"<<std::endl;

    return 0;
}

在这里插入图片描述

把1~31信号都用signal()自定义捕捉。

#include <iostream>
#include <unistd.h>
#include <csignal>

void handler(int sig)
{
    std::cout<<"获得了一个信号:"<<sig<<std::endl;
}
int main()
{
    for(int i=1;i<32;i++)
        signal(i,handler);
    int cnt=0;
    while(1)
    {
        std::cout<<"Hello Signal, "<<cnt++<<" pid: "<<getpid()<<std::endl;
        sleep(1);
    }

    return 0;
}

在这里插入图片描述

可以看到9号信号不能被自定义捕捉

raise给调用函数自己所在的进程发送信号,是进程自发向自身投递信号的接口。成功返回0。

#include <iostream>
#include <unistd.h>
#include <csignal>

void handler(int sig)
{
    std::cout<<"获得了一个信号:"<<sig<<std::endl;
}
int main()
{
    for(int i=1;i<32;i++) //自定义捕捉1~31号信号
        signal(i,handler);

    for(int i=1;i<32;i++) //向当前进程发送1~31号信号
    {
        sleep(1);
        raise(i);
    }

    int cnt=0;
    while(1)
    {
        std::cout<<"Hello Signal, "<<cnt++<<" pid: "<<getpid()<<std::endl;
        sleep(1);
    }

    return 0;
}

在这里插入图片描述

9号和19号信号不能被自定义捕捉,所以运行结果如上。

abort 触发进程异常终止,底层默认会向自身进程发送SIGABRT(6 号中止信号),以此结束程序运行。当 handler 执行 return 返回之后abort()再次把 SIGABRT 恢复为默认处理动作(终止 + 生成 core dump),再次向进程发送 SIGABRT。

在这里插入图片描述

#include <iostream>
#include <unistd.h>
#include <csignal>

void handler(int sig)
{
    std::cout<<"获得了一个信号:"<<sig<<std::endl;
}
int main()
{
    for(int i=1;i<32;i++)
        signal(i,handler);

    int cnt=0;
    while(1)
    {
        std::cout<<"Hello Signal, "<<cnt++<<" pid: "<<getpid()<<std::endl;
        abort();
        sleep(1);
    }

    return 0;
}

在这里插入图片描述

软件条件产生信号

alarm 设置闹钟时钟,超时后向进程发送信号 SIGALRM 终止进程。返回上一次 alarm 剩余未走完的秒数,无旧闹钟则返回 0。

在这里插入图片描述

  • 参数seconds是倒计时时长,单位为秒。
  • 多次调用alarm(n)会覆盖上一次的计时配置。例如先alarm(5),2 秒后再调用alarm(10),旧闹钟剩余 3 秒会作为返回值返回,新闹钟重新从 10 秒开始倒计时。
  • 调用alarm(0),直接清除当前已设置的闹钟,返回剩余计时秒数。
  • alarm 设置后函数立刻返回,进程可继续执行业务代码,不阻塞进程,内核后台异步倒计时。
  • SIGALRM 默认动作是终止进程

验证alarm,体会IO效率。

void handler(int sig)
{
    std::cout<<"获得了一个信号:"<<sig<<std::endl;
    exit(1);
}

int main()
{
    signal(SIGALRM,handler);
    alarm(1);   //1秒后进程收到SIGALRM信号,执行handler,退出

    int cnt=0;
    while(1)
    {
        std::cout<<cnt++<<std::endl;
    }

    return 0;
}

在这里插入图片描述

1秒内执行了14万次IO。1秒后再打印cnt,查看cnt的值。

int cnt=0;
void handler(int sig)
{
    std::cout<<"获得了一个信号:"<<sig<<"cnt: "<<cnt<<std::endl;
    exit(1);
}

int main()
{
    signal(SIGALRM,handler);
    alarm(1);   //1秒后进程收到SIGALRM信号,执行handler,退出

    while(1)
    {
        cnt++;
    }

    return 0;
}

在这里插入图片描述

可以看到1秒内执行了5亿次cnt++,IO 操作会极大拖慢程序执行效率。

pause主动让当前进程进入可中断睡眠阻塞状态,CPU 让出调度资源,进程暂停运行,直到任意一个能被捕获处理的信号递送到该进程、并执行完信号处理函数之后,pause() 才会返回,唤醒进程继续向下执行代码。只有信号触发并处理完成后才会返回,永远返回 -1;如果到来的信号是默认终止进程、且未自定义捕获处理,进程会直接退出,pause() 没有返回机会。

在这里插入图片描述

  • 零 CPU 占用等待:和 while(1) 死循环轮询不同,pause() 进入内核阻塞态,不占用 CPU 算力,是 Linux 优雅等待信号的方式。
  • 无超时机制:本身不会自动唤醒,必须依赖外部信号(如alarm定时信号、键盘中断信号、其他进程kill发送的信号)才能解除阻塞。
  • 典型搭配用法:alarm()+pause() 实现限时等待。先设置闹钟定时,再调用pause()阻塞,超时 SIGALRM 信号到来自动唤醒进程。
using fun_t = std::function<void()>;
std::vector<fun_t> funcs;
void Sched()
{
    std::cout << "我是进程调度" << std::endl;
}
void MemManger()
{
    std::cout << "我是周期性的内存管理,正在检查有没有内存问题" << std::endl;
}
void Fflush()
{
    std::cout << "我是刷新程序,我在定期刷新内存数据,到磁盘" << std::endl;
}
void handler(int sig)
{
    for(auto f:funcs)
        f();
    std::cout<<std::endl;
    alarm(1);
}

int main()
{
    funcs.push_back(Sched);
    funcs.push_back(MemManger);
    funcs.push_back(Fflush);

    signal(SIGALRM,handler);
    alarm(1);   //1秒后进程收到SIGALRM信号,执行handler

    while(1)
    {
        pause();
    }

    return 0;
}

在这里插入图片描述

信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产生机制。这些条件包括但不限于定时器超时(如alarm函数设定的时间到达)、软件异常(如向已关闭的管道写数据产⽣的SIGPIPE信号)等。当这些软件条件满足时,操作系统会向相关进程发送相应的信号,以通知进程进行相应的处理。即软件条件是因操作系统内部或外部软件操作而触发的信号产生

系统闹钟本质是操作系统内核自带定时能力,对外提供接口让用户进程自定义定时任务,到期触发信号通知进程。内核管理闹钟的结构体如下。

struct timer_list 
{
    struct list_head entry;
    unsigned long expires; //定时器到期的时间戳
    void (*function)(unsigned long); //超时后要执行的回调处理函数
    unsigned long data; 
    struct tvec_t_base_s *base;
    //...
};

内核真实实现是通过时间轮机制高效批量管理海量定时器。可以简易理解为最小堆,堆顶永远是剩余超时时间最短的闹钟,优先到期触发。

硬件异常产生信号

C/C++ 代码里除零、野指针 / 内存越界这类程序错误,底层本质是硬件触发异常 → 内核识别异常 → 向进程发送对应信号,系统层面统一以信号机制处理程序硬件类故障。

除零错误 → 8 号 SIGFPE(浮点异常):

  1. 进程执行除法指令时,CPU 运算单元检测到除数为 0,硬件主动抛出运算异常
  2. CPU 状态寄存器(EFLAGS)会标记溢出 / 异常标志位,操作系统读取寄存器识别故障类型;
  3. 内核向当前进程投递 SIGFPE(8) 信号;
  4. 默认行为:Core dumped(终止进程并生成崩溃转储文件)。

非法内存访问(野指针、越界)→ 11 号 SIGSEGV(段错误):

  1. 进程访问未授权 / 无效虚拟地址,MMU 内存管理单元地址转换失败(如访问0号地址,页表没有0号地址的映射),硬件报错
  2. 内核解析 MMU 异常事件,判定为非法内存引用;
  3. 内核向当前进程投递 SIGSEGV(11) 信号;
  4. 默认行为:Core dumped。

OS 识别硬件异常的底层依据:

  1. CPU 寄存器分工。
    • 通用寄存器:存储运算、字符串、栈指针等业务上下文;
    • 控制 / 状态寄存器(如 EFLAGS):以位图形式标记溢出、运算异常、内存访问异常等硬件状态,是 OS 判断故障的核心依据;
    • CR 系列控制寄存器、段寄存器、调试寄存器:辅助管理 CPU 运行模式、页表基址、内存权限;
  2. 上下文绑定关系:每个进程调度切换时,task_struct会保存全套 CPU 寄存器上下文,异常发生时内核可直接读取当前进程的寄存器状态定位问题。

当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做Core Dump

进程异常终止通常是因为有Bug,如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做 Post-mortem Debug (事后调试)。gdb命令 core-file core 直接定位出错。

一个进程允许产生多大的 core 文件取决于进程的 Resource Limit (这个信息保存 在PCB中)。默认是不允许产生 core 文件的,因为 core 文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用 ulimit 命令改变这个限制,允许产生 core 文件。

在这里插入图片描述

int main()
{
    pid_t id = fork();
    if (id == 0)
    {
        sleep(2);
        printf("hello bit\n");
        printf("hello bit\n");
        printf("hello bit\n");
        int a = 10;
        a /= 0;
        printf("hello bit\n");

        exit(1);
    }
    int status = 0;
    waitpid(id, &status, 0);
    printf("signal: %d, exit code: %d, core dump: %d\n",
           (status & 0x7F), (status >> 8) & 0xFF, (status >> 7) & 0x1);
}

a /= 0整数除零属于硬件异常,Linux 内核会立即给子进程发送 SIGFPE(浮点异常,信号编号 8),终止进程,尝试生成 core dump。

打印结果:

  1. signal: 8:子进程被8 号 SIGFPE 信号杀死;
  2. exit code: 0:进程是信号终止、并非正常 return/exit 退出,退出码字段无有效数据;
  3. core dump: 1或0
    • 若系统 core 配置允许生成 core → 输出1;
    • 若 Ubuntu 默认systemd-coredump托管 /ulimit 限制 → 输出0。

在这里插入图片描述

保存信号

实际执行信号的处理动作称为信号递达。信号从产生到递达之间的状态,称为信号未决(Pending)。

进程可以选择阻塞(Block)某个信号被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

task_struct进程控制块内嵌三大信号管理结构:

  1. block 表(阻塞信号集 / 信号屏蔽字,sigset_t 位图)
  • 按信号编号按位存储,比特位 = 1 代表对应信号被阻塞屏蔽
  • 类比文件权限 umask,管控哪些信号禁止递送。
  1. pending 表(未决信号集,sigset_t 位图)
  • 比特位 = 1 代表该信号已产生、处于待处理的未决状态
  1. handler 表(信号处理动作数组)
  • 数组下标 = 信号编号,存储处理方式:
    • SIG_DFL:执行系统默认动作。
    • SIG_IGN:递送后忽略该信号。
    • 自定义函数指针:跳转执行用户编写的信号处理函数。

在这里插入图片描述

图示解读:

  • SIGHUP (1):block=0 未阻塞、pending=0 未产生 → 若信号到来直接默认处理。
  • SIGINT (2):block=1 被阻塞、pending=1 已产生暂存 → 持续未决,解阻塞前绝不递送,即便处理策略是忽略也暂不生效。
  • SIGQUIT (3):block=1 被阻塞、pending=0 未产生 → 未来信号产生后会先进入未决态,最终走自定义处理函数。

有效待递送信号 = pending位图 & (~block位图) 只有信号在 pending 置 1、且 block 未置 0,才满足递送条件,进而执行 handler 数组里对应的处理逻辑。

POSIX 标准规定:常规信号在阻塞期间多次触发,内核至多递送 1 次。实时信号可排队多次依次递送,

int main()
{
    signal(2,SIG_IGN);//屏蔽SIGINT信号

    int cnt = 0;
    while(1)
    {
        std::cout<<"cnt: "<<cnt++<<std::endl;
        sleep(1);
    }

    return 0;
}

在这里插入图片描述

sigset_t是信号集位图类型,内部bit存储由系统实现,禁止直接读写内部成员,必须使用系统提供的 API 操作;所有函数头文件统一为 <signal.h>

在这里插入图片描述

  1. int sigemptyset(sigset_t* set)

    初始化set指向的信号集,把所有信号对应的 bit 全部清零,集合初始为空、无有效信号。

  2. int sigfillset(sigset_t* set)

    初始化set指向的信号集,把所有信号对应的 bit 全部置 1,集合初始包含系统全部信号。

  3. int sigaddset(sigset_t* set, int signo)

    向已初始化的信号集中,添加signo指定的信号(对应 bit 置 1)。

  4. int sigdelset(sigset_t* set, int signo)

    从已初始化的信号集中,删除signo指定的信号(对应 bit 清零)。

以上4个函数成功返回0,失败返回-1。

  1. int sigismember(const sigset_t* set, int signo)

    布尔判断函数:检测signo是否在该信号集中,是返回 1、否返回 0,失败返回-1。

sigprocmask 修改 / 读取进程信号屏蔽字。成功返回0,失败返回-1。

在这里插入图片描述

参数set是输入型参数,oldset是输出型参数。oldset非空时,把当前旧的阻塞信号集拷贝到oldset传出备份。set非空时,按照参数how规则,用set更新进程阻塞集。二者都非空则先备份旧屏蔽字,再执行修改。参数how取值如下。

how 常量作用等价位运算
SIG_BLOCK将 set 里的信号新增进阻塞集(叠加阻塞)`mask = mask
SIG_UNBLOCK将 set 里的信号从阻塞集中解除(解除屏蔽)mask = mask & ~set
SIG_SETMASK(常用)直接把当前阻塞集整体替换为 setmask = set

如果本次调用解除了若干处于未决状态信号的阻塞,函数返回前内核会至少递送其中一个信号。

sigpending 读取进程未决信号集。成功返回0,失败返回-1。

在这里插入图片描述

参数set是输出型参数,把当前进程pending 未决信号集整体写入set传出,用来查看哪些信号已产生、因阻塞暂未递送。

void PrintPending(sigset_t& pending)
{
    std::cout<<"进程 "<<getpid()<<" ";
    for(int i=31;i>=1;i--)
    {
        if(sigismember(&pending,i))
            std::cout<<"1";
        else
            std::cout<<"0";
    }
    std::cout<<std::endl;
}
int main()
{
    //屏蔽2号信号
    sigset_t block,oldblock;
    sigemptyset(&block);
    sigemptyset(&oldblock);

    sigaddset(&block,2); //把block中2号信号对应bit位置为1
    sigprocmask(SIG_SETMASK,&block,&oldblock);

    while(1)
    {
        sigset_t pending;
        sigpending(&pending);
        PrintPending(pending);
        sleep(1);
    }

    return 0;
}

在这里插入图片描述

上述代码屏蔽了2号信号,键盘按 Ctrl + c 发出信号被阻塞,一直处于未决状态,pending位图对应位置bit位为1。9号信号等无法被阻塞。

解除对2号信号的屏蔽。

void handler(int sig)
{
    std::cout<<"2号信号递达"<<std::endl;
}
int main()
{
    signal(2,handler);

    //屏蔽2号信号
    sigset_t block,oldblock;
    sigemptyset(&block);
    sigemptyset(&oldblock);

    sigaddset(&block,2); //把block中2号信号对应bit位置为1
    sigprocmask(SIG_SETMASK,&block,&oldblock);

    int cnt = 0;
    while(1)
    {
        sigset_t pending;
        sigpending(&pending);
        PrintPending(pending);

        if(cnt == 10)
        {
            std::cout<<"解除对2号信号的屏蔽"<<std::endl;
            sigprocmask(SIG_SETMASK,&oldblock,nullptr);
        }
        sleep(1);
        cnt++;
    }

    return 0;
}

在这里插入图片描述

没有屏蔽后,信号递达前,会先把pending对应位置置0。

void handler(int sig)
{
    std::cout<<"2号信号递达"<<std::endl;
    sigset_t pending;
    sigpending(&pending);

    // 0000 0010(处理完,2号才回被设置为0),0000 0000(执行handler方法之前,2对应的pending已经被清理了)
    PrintPending(pending);
    std::cout<<std::endl;
}
int main()
{
    signal(2,handler);

    //屏蔽2号信号
    sigset_t block,oldblock;
    sigemptyset(&block);
    sigemptyset(&oldblock);

    sigaddset(&block,2); //把block中2号信号对应bit位置为1
    sigprocmask(SIG_SETMASK,&block,&oldblock);

    int cnt = 0;
    while(1)
    {
        sigset_t pending;
        sigpending(&pending);
        PrintPending(pending);

        if(cnt == 10)
        {
            std::cout<<"解除对2号信号的屏蔽"<<std::endl;
            sigprocmask(SIG_SETMASK,&oldblock,nullptr);
        }
        sleep(1);
        cnt++;
    }

    return 0;
}

在这里插入图片描述

捕捉信号

信号捕捉流程

在这里插入图片描述

信号的处理,不是立即处理的,而是需要等待合适时机再进行信号处理。

信号完整生命周期:信号产生 → 信号保存(pending 未决信号集暂存) → 信号递达(处理执行)

三种递达处理方式:默认处理、忽略、自定义捕捉。

捕捉信号图解释:

阶段 1:用户态执行int main()业务代码

普通用户**进程运行在 User Mode (用户态)**特权隔离环境,无法直接操作内核硬件资源;若触发中断 / 异常 / 系统调用,**CPU 自动切换 Kernel Mode (内核态)**保存寄存器现场。

阶段 2:内核善后和 do_signal 信号筛查

内核完成中断 / 系统调用业务逻辑后,执行do_signal()遍历当前进程 PCB(task_struct)的pending位图、blocked阻塞位图:

  • 信号被阻塞:保留在 pending 位图,跳过本次处理。

  • 信号默认 / 忽略:内核直接在内核态完成处置,不切换用户态 handler。

  • 信号已注册自定义处理函数:进入捕捉分支。

do_signal() 是内核即将返回用户态时执行的信号分发函数,扫描进程阻塞与未决信号集并按默认 / 忽略 / 捕捉三种方式处理就绪信号。

仅当内核态处理完中断 / 异常 / 系统调用,准备返回用户态的临界节点,才会调用do_signal()扫描进程pending未决信号表;内核态自身运行代码期间,永远不会响应处理信号。

阶段 3:内核上下文置换

内核把main函数当前栈帧、程序计数器 PC、通用寄存器完整保存到内核栈,修改 CPU 执行入口为用户态的自定义捕捉 void sighandler(int) 函数地址。

关键区别:handler 与 main 是两条独立执行流、两套独立栈空间,绝非普通函数嵌套调用,不能直接 return 返回 main。

阶段 4:切回用户态运行自定义 handler

CPU 降级回用户特权级,执行开发者编写的信号处理函数。

阶段 5:隐式 sys_sigreturn 回内核恢复现场

handler 函数执行完毕时,编译器会自动插入sigreturn系统调用触发软中断再次进入内核:

① 清除对应信号 pending 标记;② 从内核栈还原 main 原始 CPU 上下文;③ 若无新待处理信号,返回用户态断点,main 继续向下执行。

流程简图如下。

在这里插入图片描述

系统会进行4次身份切换,对于默认处理(如终止进程、core 转储),所有逻辑都是内核原生代码,直接在内核态就能全部做完,不需要切回用户态执行任何用户代码,省去两次来回态切换,流程大幅简化。

对于自定义捕捉,系统为了系统安全隔离,先在内核态发现待捕捉信号,主动降权切到用户态跑 handler(身份切换),handler 跑完再升权回到内核态恢复主程序现场,全程把用户代码隔离在低权限环境,避免用户代码破坏操作系统内核。

硬件中断

在这里插入图片描述

中断向量表是操作系统的一部分,启动就加载到内存中了。通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询。由外部设备触发的,中断系统运行流程,叫做硬件中断。

硬件中断完整流程:

  1. 外设就绪:比如键盘按下按键、磁盘完成读写,外设硬件自身数据缓存准备完毕。
  2. 发起中断:外设向中断控制器发送中断请求信号,同时附带专属中断号。中断号就是 CPU 给每一类中断事件编的唯一序号,核心作用是作为中断向量表IDT 数组下标,快速匹配对应的中断处理代码,让 CPU 精准响应不同硬件、异常、系统调用事件。
  3. 控制器转发通知 CPU:中断控制器汇总多路外设中断请求,按优先级排序后,把有效中断号传递给 CPU。
  4. CPU 识别中断号:CPU 拿到中断编号,以编号为下标索引内存里的IDT(IDT本质:函数指针数组,每个下标对应一个中断处理函数地址)。
  5. CPU 保护现场:立刻把当前正在执行进程的寄存器值、程序执行位置(PC)压栈保存,避免后续处理中断破坏原有程序运行状态,随即切换进入内核态执行中断服务程序。
  6. 执行中断服务 + 恢复现场:CPU 跳转执行 IDT 匹配的内核中断处理函数(比如读键盘缓存、操作磁盘控制器寄存器);处理结束后还原寄存器现场,切回用户态继续执行被打断的原有程序。

硬件中断是硬件设备异步通知 CPU,信号是系统 / 进程异步通知目标进程(系统:Ctrl+c,进程:终端执行kill命令),二者底层异步触发、延后处理的设计思路完全类似。

硬件中断环节软件信号对应环节
外设发中断请求内核 / 进程发送信号(发中断≈发信号)
中断控制器记录中断号暂存进程 PCB 的 pending 位图暂存未决信号(保存中断号≈记录信号)
CPU 拿到中断编号索引 IDT内核识别信号编号匹配处理方式(中断号≈信号编号)
CPU 执行内核中断服务程序内核执行信号默认处理 / 忽略 / 用户自定义捕捉(处理中断≈处理信号)

时钟中断

在这里插入图片描述

  1. 无中断时 OS 处于暂停休眠状态:CPU 执行 for(;;) pause(); 循环挂起,操作系统代码不会主动运行,全程被动等待中断唤醒。
  2. 操作系统本质:基于中断才能工作的软件,所有调度、外设管理、异常处理都依赖中断触发。
  3. 时钟中断是 OS 心跳:硬件定时器以固定主频频率周期性向 CPU 发时钟中断,是进程时间片轮转调度的核心驱动力。
  4. 时间片轮转的本质:靠固定频率的时钟中断递减计数器,计数器归零触发进程切换。

串联暂停 - 唤醒 - 调度完整运行闭环。

  1. 空闲无中断阶段:CPU 执行pause()进入休眠,OS 代码静止不运行。

  2. 周期性时钟中断到来:CPU 被硬件信号唤醒,切入内核态跑timer_interruptdo_timer

    IDT 本身只是一片内存空间,初始状态大多是空 / 无效的,set_intr_gate就是帮内核把各类中断(时钟、键盘、异常等)和各自处理函数,登记进 IDT。

    调用 set_intr_gate(0x20, &timer_interrupt);

    • 第一个参数0x20时钟中断专属中断号(十进制 32),CPU 收到时钟硬件中断时会拿到这个编号;
    • 第二个参数&timer_interrupt:取时钟中断处理函数的内存地址;
    • 函数执行效果:在 IDT 数组下标0x20的位置,写入一个中断门描述符,明确标记:当中断号 0x20 触发时,CPU 必须跳转到timer_interrupt函数执行。即把函数timer_interrupt登记IDT。

    do_timer:实现计时、时间片扣减、进程调度判断这些核心功能。

  3. 分支判断

    • 分支 counter>0:时间片剩余,恢复现场,回到原进程继续执行,OS 再次等待下一次中断。
    • 分支 counter==0:时间片耗尽,执行schedule()调度 +switch_to()上下文切换,换新进程运行。
  4. 其他外设中断:键盘、磁盘等硬件中断到来时,同样走 IDT 查表执行对应外设服务程序,处理完回到进程。

软中断

软中断(软件触发中断):CPU 专门设计了int(32 位)、syscall(64 位)汇编指令,仅靠软件代码执行指令,就能手动触发和硬件中断完全一致的 CPU 中断流程,这就是软中断。

int 0x80:32 位 Linux 经典系统调用软中断号int是触发软中断的汇编指令,0x80是固定中断编号。

syscall:64 位 CPU 替代int的更快的软中断指令,现代 64 位 Linux 主流用它做系统调用。

在这里插入图片描述

  1. 系统调用号:内核给每一个内核功能编的唯一整数编号(比如 read=0、write=1、open=2),编号由内核定义,不是 C 标准库提供
  2. sys_call_table:内核里一个函数指针数组,数组下标 = 系统调用号,数组元素就是对应内核函数地址。
  3. glibc(GNU C 标准库):我们写 C 代码调用open()/read()/fork()这些函数,不是直接调用内核,而是调用 glibc 封装好的库函数,glibc 内部帮我们完成汇编、传参、触发软中断的底层操作,所以看不到int 0x80指令。

以 C 语言调用 open 打开文件举例:

阶段 1:用户态(普通程序层级)

  1. 写 C 代码:open("test.txt", 0)
  2. 编译链接后实际调用glibc 的 open 封装函数
  3. glibc 内部做两件事:
    • open对应的系统调用号__NR_open=2存入 CPU 寄存器eax
    • 把文件名、权限这些参数依次存入 ebx/ecx 等寄存器。
    • 执行int 0x80汇编指令,主动触发软中断

阶段 2:CPU 自动切换 + 查表(硬件层级)

  1. CPU 收到 0x80 软中断信号,立刻保护现场(保存当前程序寄存器、执行位置),从用户态切换到高权限内核态。
  2. 0x80为下标查 IDT 表,找到预先登记的总入口system_call函数并跳转执行。

阶段 3:内核分发执行(操作系统层级)

  1. system_call先做安全校验:判断 eax 里的系统调用号是否合法。
  2. eax里的编号作为下标,去sys_call_table数组里取出对应的内核函数sys_open
  3. 执行sys_open内核逻辑:校验权限、在内存创建文件映射、操作磁盘硬件。
  4. 函数执行结果 / 返回值放回寄存器,准备返回用户态。

阶段 4:原路返回

  1. 内核恢复之前保存的程序现场,切回用户态。
  2. glibc 从寄存器取出返回值,交给你的 C 代码,你就拿到了 open 的执行结果。

本质总结:系统调用号就是数组下标,软中断是进内核的大门,sys_call_table 是按下标找内核函数的对照表

操作系统不直接对外暴露零散接口,只提供系统调用号 + 软中断入口这一套统一标准;glibc 帮开发者屏蔽了汇编、寄存器传参、软中断触发这些复杂底层细节。

陷阱:主动执行int 0x80/syscall人为触发的软中断(系统调用)。

异常:CPU 运行代码出错自动触发的内部软中断,被动触发,同样走 IDT 中断机制处理。

trap_init 函数初始化所有异常,trap_init 里调用 set_trap_gate/set_system_gate 把每一种异常编号和处理函数绑定进 IDT:

  1. 除零错误(中断号 0):代码做除法除以 0 触发,内核发送信号杀死进程。

  2. 缺页中断(中断号 14,page_fault):程序访问的虚拟内存还没加载到物理内存,内核自动分配物理内存、建立页表映射;如果是非法野指针访问不属于自己的内存,内核直接发段错误信号终止进程。

    你程序想用一块内存,系统只给了你虚拟地址、没分配真实物理内存;第一次访问这块地址时 CPU 触发缺页异常,内核临时帮你申请物理内存绑定地址;如果访问的地址根本不在你的合法内存范围,就判定为野指针报错杀进程。

  3. 栈溢出、非法指令、段越界等:均由 CPU 触发异常,内核查表执行对应处理逻辑。

3 大类 CPU 中断触发方式,三者最终都走同一套 IDT 中断向量表机制处理。

类别触发来源典型例子通俗叫法
硬件中断外部键盘 / 磁盘 / 时钟等硬件设备主动发信号按键、磁盘读写完成、周期性时钟滴答硬件中断(外部中断)
软中断(陷阱)软件主动执行 CPU 专属指令人为触发32 位 Linux int 0x80、64 位syscall(系统调用专用)陷阱,用来主动从用户态进内核态
CPU 异常CPU 运行代码时内部出错自动触发除零运算、野指针非法访问内存、缺页、栈溢出异常,被动报错触发的内部软中断

内核态和用户态

在这里插入图片描述

32 位虚拟地址总范围:0~4GB,所有进程共用这套分段规则:

  1. 用户空间(用户态专属):0GB ~ 3GB

    普通程序代码、数据、堆、栈都放这里,进程之间这部分相互隔离,每个进程有独立用户页表,A 进程不能直接读写 B 进程的用户空间数据。

  2. 内核空间(内核态专属):3GB ~ 4GB

    操作系统内核代码、驱动、内存管理、进程调度核心逻辑全部存放于此;所有进程共享同一份内核页表,不管切换到哪个进程,3GB~4GB 看到的内核内容完全一致,这就是进程切换永远能找到同一个操作系统的根本原因。

操作系统内核本身就常驻在每个进程的虚拟地址空间高位段里,系统调用的内核代码,就是在当前进程的地址空间内运行的,不是跑到另一个独立程序里执行。

用户态:CPU 执行 0~3GB 用户空间代码时的运行状态,权限受限。不能直接操作硬件、不能随意修改内存页表、不能访问 3~4GB 内核区域,普通 C/C++ 写的业务程序默认都跑在用户态。

内核态:CPU 执行 3~4GB 内核空间代码时的运行状态,拥有 CPU 最高权限可以操控硬件、修改页表、管理所有进程资源,只有操作系统内核能运行在这个状态。

CPL = Current Privilege Level 当前特权级别,是 CPU 寄存器里实时记录的硬件标记:

  • CPL=3 → 用户态(低权限)
  • CPL=0 → 内核态(最高权限)

CPU 不是靠代码在哪个地址得知状态,是直接读取 CPL 数值判定,硬件强制校验,程序无法私自篡改。

用户态→内核态流程:

用户程序调用 open/read/write 等库函数,最终会执行 int 0x80 / syscall 软中断指令,主动向 CPU 发起进入内核的申请。

CPU硬件校验 CPL、RPL、DPL 权限,确认是合法用户态发起请求;自动把 CPL 从 3 修改为 0,CPU 切换为内核态;CPU 栈从用户栈切换为内核栈,跳转到内核预设的系统调用入口函数,执行内核代码完成文件读写、内存分配等操作。

内核处理完任务后,把结果放回用户进程寄存器,CPL 自动切回 3,CPU 回到用户态继续运行普通程序代码。

sigaction

sigaction 读取 / 修改指定信号的处理方式,是 Linux 下替代简易 signal()、功能更强大稳定的信号注册系统调用。成功返回0,失败返回-1。

在这里插入图片描述

参数signo:要操作的信号编号

参数act(新配置)**:非空 → 用这个结构体里的配置,**更新该信号的处理规则。传NULL则只查不修改。

参数oact(旧配置回传):输出型参数,非空 → 内核把该信号原来的旧处理配置填充进这个结构体返回。传NULL则不保留旧配置。

结构体sigaction。

struct sigaction {
    void     (*sa_handler)(int); //自定义信号处理函数
    void     (*sa_sigaction)(int, siginfo_t *, void *);
    sigset_t   sa_mask; //信号屏蔽集
    int        sa_flags;//通常初始化为0
    void     (*sa_restorer)(void);
};

void (*sa_handler)(int) 常规信号处理回调函数指针,三种用法:

  • 赋值 SIG_IGN:忽略目标信号;
  • 赋值 SIG_DFL:恢复系统默认信号行为;
  • 赋值自定义函数:接收信号编号作为唯一入参,完成基础信号捕捉逻辑,是最常用的成员。
#include <iostream>
#include <signal.h>
#include <unistd.h>

void handler(int sig)
{
    std::cout<<"Hello signal: "<<sig<<std::endl;
    exit(1);
}
int main()
{
    struct sigaction act,oact;
    act.sa_handler = handler;

    sigaction(SIGINT,&act,&oact);

    while(1)
    {
        std::cout<<"hello world"<<std::endl;
        sleep(1);
    }

    return 0;
}

在这里插入图片描述

当开始执行某信号的处理函数时,内核自动把当前正在处理的这个信号,加入进程临时信号屏蔽字。处理函数执行结束后,自动还原屏蔽状态。防止同一种信号频繁连发、嵌套打断自身处理逻辑,保证单次信号处理完整跑完。

如果希望处理 A 信号时,额外再屏蔽 B、C、D 等其他指定信号,就把这些信号写入 sa_mask 集合,同样在信号回调执行结束后,内核自动解除这些额外信号的屏蔽。

下面代码基于 sigaction 默认特性实现信号阻塞与未决状态观测,完整机制如下:

  1. 代码通过 sigaction 注册 SIGINT(信号编号2) 的自定义处理函数 handlersa_mask 置空、sa_flags=0,启用内核默认规则:执行该信号的处理函数时,内核会自动将 SIGINT 加入进程信号阻塞集(屏蔽字)
  2. 首次发送 SIGINT 后,进程进入 handler 回调执行阶段。此时阻塞集中 SIGINT 对应位置 1;若在此期间再次发送 SIGINT,该信号无法被立即递送处理,只会在未决信号集(pending 位图) 中把 SIGINT 对应标记位设为 1,信号处于未决阻塞状态。
  3. handler 内部死循环每秒调用 sigpending 读取未决信号集并逐位打印,因此会持续输出 SIGINT 对应的第 2 位为 1,直观验证信号阻塞延迟投递的效果。
  4. 由于 handler 是无限循环不会退出,内核不会清空阻塞集里的 SIGINT 标记,未决信号会一直保留,直到进程终止。
#include <iostream>
#include <signal.h>
#include <unistd.h>

void handler(int sig)
{
    std::cout<<"Hello signal: "<<sig<<std::endl;

    while(1)
    {
        sigset_t pending;
        sigpending(&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);
    }

    exit(1);
}
int main()
{
    struct sigaction act,oact;
    act.sa_handler = handler;
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;

    sigaction(SIGINT,&act,&oact);

    while(1)
    {
        std::cout<<"hello world"<<", pid: "<<getpid()<<std::endl;
        sleep(1);
    }

    return 0;
}

在这里插入图片描述

处理SIGINT信号期间,还想把3、4号信号屏蔽,代码如下。

int main()
{
    struct sigaction act,oact;
    act.sa_handler = handler;
    sigemptyset(&act.sa_mask);

    sigaddset(&act.sa_mask,3);
    sigaddset(&act.sa_mask,4);
    
    act.sa_flags = 0;

    sigaction(SIGINT,&act,&oact);

    while(1)
    {
        std::cout<<"hello world"<<", pid: "<<getpid()<<std::endl;
        sleep(1);
    }

    return 0;
}

在这里插入图片描述

可重入函数

同一个函数被多个独立控制流(主函数执行流、信号处理函数执行流等)调用,第一次调用还未执行完毕返回时,就再次进入该函数执行,这种场景叫做重入

函数依赖全局变量 / 全局数据结构维护逻辑,发生重入时多个执行流会争抢修改全局资源,导致数据错乱、逻辑异常,这类函数就是不可重入函数

如下图,链表头插分两步 p->next=headhead=p,主流程刚做完第一步就被信号打断(时间片用完,发生时钟中断,也就是硬件中断),信号处理函数又调用 insert 修改全局head,返回后主流程继续执行第二步,直接覆盖信号函数的插入结果,最终仅一个节点生效、另一个节点丢失。

在这里插入图片描述

函数仅访问自身局部变量、传入参数,不共享全局资源;多个控制流同时调用时,各自栈上私有数据互不干扰,重入也不会出错,即为可重入函数

局部变量存储在每个执行流独立的栈空间里,不同调用的局部变量相互隔离;而全局变量在进程全局数据区,所有执行流共用同一份内存,并发修改就会错乱,所以仅使用局部变量 / 参数的函数不会因重入出错。

典型不可重入判定条件(满足其一即不可重入)

  1. 调用malloc/free:堆内存由全局链表统一管理,并发操作链表会产生冲突。
  2. 调用标准 I/O 库函数:标准 I/O 内部依赖全局缓冲区、全局结构体,多执行流调用会数据错乱。
  3. 读写全局变量、全局链表等共享数据结构(示例 insert 函数属于这类)。

现实情况是绝大多数库函数默认都是不可重入的,只有纯局部变量运算、无全局依赖的函数天然可重入。

volatile

观察代码,无优化下,全局 int flag=0while(!flag); 循环能正常感知信号处理函数里 flag=1 的修改,按下Ctrl+C触发信号后循环退出、打印正常退出。

开启优化 g++ -O1,编译器优化寄存器缓存机制,main 函数里只读取 flag、从不修改 flag,开启编译器优化后,编译器会做激进优化。第一次读取 flag=0 放入 CPU 寄存器,后续 while(!flag) 循环直接反复读取寄存器里缓存的 0,不再每次访问物理内存。信号处理函数 handler 修改的是物理内存里的 flag 值,寄存器里的旧值不会自动同步更新。main 主线程看不到内存里 flag 已经变为 1,就会永久卡在死循环,这就是内存可见性问题

#include <iostream>
#include <signal.h>
#include <unistd.h>

int flag = 0;
void handler(int sig)
{
    std::cout<<"flag = "<<flag<<std::endl;
    flag = 1;
}
int main()
{
    signal(2,handler);

    while(!flag);
    std::cout<<"正常退出"<<std::endl;

    return 0;
}

在这里插入图片描述

volatile强制要求:每次读写该变量,必须直接访问物理内存,禁止编译器把变量缓存到 CPU 寄存器、禁止编译器对该变量做读写优化,保证多执行流(主线程 + 信号处理函数)对变量修改的内存可见性。

加 volatile后,强制切断寄存器缓存优化通路,主线程每轮循环都直接从物理内存取值,能立刻感知 handler 对内存中 flag 的修改。

volatile int flag = 0;
void handler(int sig)
{
    std::cout<<"flag = "<<flag<<std::endl;
    flag = 1;
}
int main()
{
    signal(2,handler);

    while(!flag);
    std::cout<<"正常退出"<<std::endl;

    return 0;
}

SIGCHLD信号

子进程在终止时会给父进程发17号SIGCHLD信号,该信号的默认处理动作是忽略。

#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>

void handler(int sig)
{
    std::cout<<"父进程收到信号: "<<sig<<std::endl;
}
int main()
{
    signal(SIGCHLD,handler);//父进程注册

    pid_t id = fork();
    if(id == 0)
    {
        std::cout<<"子进程: "<<getpid()<<std::endl;
        sleep(3);
        exit(1);
    }

    waitpid(id,nullptr,0);

    return 0;
}

在这里插入图片描述

把信号SIGCHLD设置为自定义捕捉,使子进程一退出就被回收,避免出现僵尸进程。waitpid使用非阻塞模式 WNOHANG ,使父进程不用阻塞在waitpid等待子进程退出。

void WaitAll(int sig)
{
    std::cout<<"父进程收到信号: "<<sig<<std::endl;

    while(1)
    {
        pid_t n = waitpid(-1,nullptr,WNOHANG); //非阻塞模式
        if(n == 0) //当前无更多退出的子进程
        {
            break;
        }
        else if(n < 0)
        {
            std::cout<<"waitpid error"<<std::endl;
            break;
        }
        else
        {
            std::cout<<"回收子进程: "<<n<<"\n"<<std::endl;
        }
    }
}
int main()
{
    signal(SIGCHLD,WaitAll);

    for(int i=0;i<10;i++) //创建10个子进程,让其中6个退出
    {
        pid_t id = fork();
        if(id == 0)
        {
            sleep(1);
            std::cout<<"子进程: "<<getpid()<<std::endl;
            if(i<6)
            {
                std::cout<<"子进程: "<<getpid()<<" 退出"<<std::endl;
                exit(1);
            }
            else
                pause();
        }
    }

    while(1)
    {
        std::cout<<"父进程: "<<getpid()<<std::endl;
        sleep(1);
    }

    return 0;
}

在这里插入图片描述

要想不产生僵尸进程还有另外一种办法:父进程调用 sigaction / signal 将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。

int main()
{
    signal(SIGCHLD,SIG_IGN);

    for(int i=0;i<10;i++)
    {
        pid_t id = fork();
        if(id == 0)
        {
            sleep(1);
            std::cout<<"子进程: "<<getpid()<<std::endl;
            if(i<6)
            {
                std::cout<<"子进程: "<<getpid()<<" 退出"<<std::endl;
                exit(1);
            }
            else
                pause();
        }
    }

    while(1)
    {
        std::cout<<"父进程: "<<getpid()<<std::endl;
        sleep(1);
    }

    return 0;
}

在这里插入图片描述

Logo

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

更多推荐