目录

一、引言

1. 快速认识信号

2. 技术角度认识

(1)键盘终止信号

(2)系统调用函数 signal()

二、信号概念

1. 概念

2. 查看信号

(1)kill -l

 (2)man 7 signal

3. 信号处理

(1)忽略此信号

(2)捕捉信号(自定义信号处理动作)

(3)执行系统默认动作

三、产生信号

1. 如何理解向目标进程发信号?

2. 通过终端按键产生信号

(1)基本操作

(2)OS如何得知键盘有数据

(3)初步理解信号起源

 3. 使用函数产生信号

(1)kill()

(2)raise()

(3)abort()

4. 调用系统命令向进程发信号

5. 硬件异常产生信号

(1)模拟除0错误

(2)野指针

(3)core dump

(4)子进程退出core dump

6. 由软件条件产生信号

(1)alarm()

(2)基本alarm验证-体会IO效率问题 

(3)pause()

(4)基于信号的周期性任务调度器

(5)如何快速理解系统闹钟?

(6)如何理解软件条件?

四、保存信号

1. 信号其他相关常见概念

2. 在内核中的表示

(1)信号的三种属性

(2)信号的生命周期

(3)实例详解

(4)信号阻塞期间的多次产生

(5)关键机制详解

(6)内核数据结构

3. sigset_t

(1)基本概念

(2)sigset_t实现方式

(3)关键特性解释

4. 信号集操作函数

(1)sigprocmask

(2)sigpending

(3)代码理解

五、信号捕捉

1. 信号捕捉的流程

(1)信号捕捉流程

(2)身份切换

2. sigaction

3. 操作系统是怎么运行的?

(1)硬件中断

(2)时钟中断

(3)死循环

(4)软件中断

(5)缺页中断、内存碎片化、除0野指针都属于软中断

4. 理解用户态和内核态

五、可重入函数

1. 问题描述

2. 可重入函数

六、Volatile关键字

1. 内存可见性问题

2. volatile

七、SIGCHLD信号


一、引言

1. 快速认识信号

信号是软件中断。很多比较重要的应用程序都需要处理信号。信号提供了一种处理异步事件的方法。信号的产生相对于进程的运行是异步的。例如,终端用户键入中断键,会通过信号机制停止一个程序,或及早终止管道中的下一个程序。

结论:

• 信号处理:进程在信号没有产生的时候,早就知道信号如何处理了。

• 信号的处理,不是立即处理,而是可以等一会儿再处理,合适的时候进行信号处理。

• “人脸识别信号”是提前“教育”过的,进程也是如此,OS程序员设计的进程,其实进程早已内置了对于信号的识别和处理方式!

• 信号源非常多,给进程产生信号的信号源也非常多。

2. 技术角度认识

(1)键盘终止信号

// testSig.cc
#include <iostream>
#include <unistd.h>

int main()
{
    int cnt = 0;
    while(true)
    {
        std::cout << "hello world, " << cnt++ << std::endl;
        sleep(1);
    }
    return 0;
}
$ make
g++ -o testsig testSig.cc -std=c++11
$ ./testsig
hello world, 0
hello world, 1
hello world, 2
^C

• 用户输入命令,在Shell下启动一个前台进程
• 用户按下 Ctrl+C,这个键盘输入产生一个硬件中断,被OS获取,解释成信号(SIGINT),发送给目标前台进程。
• 前台进程因为收到信号,进而引起进程退出。

(2)系统调用函数 signal()

当指定函数地址时,则在信号发生时,调用该函数,我们称这种处理为捕捉该信号,称此函数为信号处理程序或信号捕捉函数。

#include <signal.h>

// 定义信号处理函数类型
typedef void (*sighandler_t)(int);

// 使用 typedef 后的 signal 声明
sighandler_t signal(int signum, sighandler_t handler);

• sighandler_t:信号处理函数的类型,接受一个 int(信号编号)并返回 void。

• signal():接收两个参数(信号编号 signum 和处理函数 handler),返回之前的处理函数。

• handler的值是当前接到此信号后要调用的函数的地址,或是常量SIG_IGN,或SIG_DEF。

如果指定SIG_IGN,表示内核忽略此信号(记住SIGKILL和SIGSTOP不能忽略)。如果指定SIG_DEF,则表示街道此信号后的动作时系统默认动作。

其实,Ctrl+C是向前台进程发送SIGINT(2号信号),验证如下: 

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

// 处理信号
void handlerSig(int sig)
{
    std::cout << "获得了一个信号:" << sig << std::endl;
}
int main()
{
    signal(SIGINT, handlerSig);
    int cnt = 0;
    while(true)
    {
        std::cout << "hello world, " << cnt++ << std::endl;
        sleep(1);
    }
    return 0;
}

$ make
g++ -o testsig testSig.cc -std=c++11
$ ./testsig
hello world, 0
hello world, 1
hello world, 2
^C获得了一个信号:2
hello world, 3
hello world, 4
^C获得了一个信号:2

补充:前台进程?后台进程?

① 前台进程:当前占用终端控制权的进程,直接与用户交互。

特点:

• 独占终端:输入(如 Ctrl+C)和输出会直接传递给前台进程。

• 受信号控制:
        Ctrl+C → 发送 SIGINT(默认终止)。
        Ctrl+Z → 发送 SIGTSTP(默认暂停)。

• 阻塞 Shell:前台进程运行时,Shell 会等待其结束才能输入新命令。前台进程只有一个!

② 后台进程: 在终端中启动但不占用控制权的进程,允许用户在进程运行时继续输入命令。

特点:

• 不占用终端:输入/输出默认不与终端交互(可重定向到文件)。后台进程可以有多个!

• 不受终端信号影响: Ctrl+C 不会影响后台进程。 但 SIGTERM/SIGKILL 仍可通过 kill 发送。

• 显式启动:命令后加 & 或通过 bg 将暂停的进程转为后台。

③ 区别:后台进程无法从标准输入(键盘)中获取内容;前台进程能从标准输入中获取内容。但是前后台进程都可以向标准输出上打印。

我们以前学的孤儿进程,用Ctrl+C无法杀死,其实就是父进程退出后,子进程被 PID 1接管,成为孤儿进程。孤儿进程被自动提到后台进程了。

④ 进程前后台移动

• jobs :查看所有的后台任务

• fg 任务号 :表示把特定的进程提到前台

• Ctrl + Z :通过暂停进程将进程切换到后台

• bg 任务号 :表示让后台进程恢复运行

总结:

• 要注意的是,signal函数仅仅是设置了特定信号的捕捉行为处理方式,并不是直接调用处理动作。如果后续特定信号没有产生,设置的捕捉函数永远也不会被调用。  

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

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

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

二、信号概念

1. 概念

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

每个信号都有一个名称,以 SIG 开头。例如:

SIGABRT(中止信号):当进程调用 abort() 时触发。

SIGALRM(闹钟信号):由 alarm() 设置的定时器超时后触发。

在 <signal.h> 头文件中,信号被定义为正整数(信号编号)。

注意:信号编号从 1 开始,0 是保留值。kill() 函数对信号 0 有特殊用途(用于检测进程是否存在)。

2. 查看信号

(1)kill -l

kill -l 列出当前系统支持的所有信号

【34-64】的信号是实时信号(产生后需要立即处理),不做介绍。

每个信号都有一个编号和一个宏定义名称,这些宏定义可以在 signal.h 中找到。

 (2)man 7 signal

man 7 signal 可以查看每一个信号的默认处理动作

信号的默认处理动作有:① 终止进程(Terminate)② 终止并生成 core dump(Terminate + Core)③ 忽略(Ignore)④ 暂停进程(Stop)⑤ 继续进程(Continue)

3. 信号处理

在某个信号出现时,可以告诉内核按照下列三种方式之一进行处理,我们称之为信号的处理或与信号相关的动作。

(1)忽略此信号

大多数信号都可使用这种方式进行处理,但有两种信号却决不能被忽略。它们是SIGKILL和SIGSTOP。这两种信号不能被忽略的原因是:它们向内核和超级用户提供使进程终止或停止的可靠方法。另外,如果忽略某些由硬件异常产生的信号(如非法内存引用或除以0),则进程的运行行为是未定义的。

#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
    std::cout << "我是: " << getpid() << ", 我获得了⼀个信号: " << signumber
              << std::endl;
}
int main()
{
    std::cout << "我是进程: " << getpid() << std::endl;
    signal(SIGINT /*2*/, SIG_IGN); // 设置忽略信号的宏
    while (true)
    {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
}
$ ./testsig
我是进程: 1378034
I am a process, I am waiting signal!
I am a process, I am waiting signal!
^C^CI am a process, I am waiting signal! # Ctrl+C无法终止进程

(2)捕捉信号(自定义信号处理动作)

捕捉信号。为了做到这一点,要通知内核在某种信号发生时,调用一个用户函数。在用户函数中,可执行用户希望对这种事件进行的处理。例如,若正在编写一个命令解释器,它将用户的输入解释为命令并执行之,当用户用键盘产生中断信号时,很可能希望该命令解释器返回到主循环,终止正在为该用户执行的命令。如果捕捉到SIGCHLD信号,则表示一个子进程已经终止,所以此信号的捕捉函数可以调用waitpid以取得该子进程的进程ID以及它的终止状态。又例如,如果进程创建了临时文件,那么可能要为SIGTERM信号编写一个信号捕捉函数以清除临时文件(SIGTERM是终止信号,kill命令传送的系统默认信号是终止信号)。注意,不能捕捉SIGKILL和SIGSTOP信号。

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

// 处理信号
void handlerSig(int sig)
{
    std::cout << "获得了一个信号:" << sig << std::endl;
}
int main()
{
    signal(SIGINT, handlerSig);
    int cnt = 0;
    while(true)
    {
        std::cout << "hello world, " << cnt++ << std::endl;
        sleep(1);
    }
    return 0;
}

$ make
g++ -o testsig testSig.cc -std=c++11
$ ./testsig
hello world, 0
hello world, 1
hello world, 2
^C获得了一个信号:2
hello world, 3
hello world, 4
^C获得了一个信号:2

(3)执行系统默认动作

执行系统默认动作。man 7 signal 查看每一个信号的默认处理动作。注意,对大多数信号的系统默认动作是终止该进程。

#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
    std::cout << "我是: " << getpid() << ", 我获得了⼀个信号: " << signumber
              << std::endl;
}
int main()
{
    std::cout << "我是进程: " << getpid() << std::endl;
    signal(SIGINT /*2*/, SIG_DFL);
    while (true)
    {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
}
$ ./testsig
我是进程: 1379097
I am a process, I am waiting signal!
I am a process, I am waiting signal!
^C  # 输⼊ctrl+c,进程退出,就是默认动作

三、产生信号

1. 如何理解向目标进程发信号?

信号产生之后,并不是立即处理的,而是被记录在进程的task_struct里,用unsigned int sigs的位图结构(比特位的内容表示收到信号,比特位的位置表示信号类型)。

// 内核中的简化表示(实际更复杂)
struct task_struct {
    // ...
    unsigned long signal;  // 信号位图
    struct sigpending pending;  // 待处理信号队列
    // ...
};

→ 发送信号的本质是向目标进程写信号,修改位图!那么就需要pid、信号的编号。修改位图本质就是修改内核数据。
→ 只有OS自己能产生、发送信号,OS提供发送信号的系统调用kill。

2. 通过终端按键产生信号

(1)基本操作

• Ctrl+C:(SIGINT 2号)已经验证过。

• Ctrl+\:(SIGQUIT 3号)可以发送终止信号并生成core dump文件,用于事后调试。

#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
    std::cout << "我是: " << getpid() << ", 我获得了⼀个信号: " << signumber << std::endl;
}
int main()
{
    std::cout << "我是进程: " << getpid() << std::endl;
    signal(SIGQUIT /*3*/, handler);
    while (true)
    {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
}
$ ./testsig
我是进程: 1380207
I am a process, I am waiting signal!
^\我是: 1380207, 我获得了⼀个信号: 3

# 注释掉signal(SIGQUIT /*3*/, handler);
$ make
g++ -o testsig testSig.cc -std=c++11
$ ./testsig
我是进程: 1380270
I am a process, I am waiting signal!
^\Quit (core dumped)

• Ctrl+Z(SIGTSTP 20号)可以发送停止信号,将当前前台进程挂起到后台。 

#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
    std::cout << "我是: " << getpid() << ", 我获得了⼀个信号: " << signumber << std::endl;
}
int main()
{
    std::cout << "我是进程: " << getpid() << std::endl;
    signal(SIGTSTP /*20*/, handler);
    while (true)
    {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
}

$ ./testsig
我是进程: 1380429
I am a process, I am waiting signal!
^Z我是: 1380429, 我获得了⼀个信号: 20

# 注释掉signal(SIGTSTP /*20*/, handler)行后执行
$ make
g++ -o testsig testSig.cc -std=c++11
$ ./testsig
我是进程: 1380509
I am a process, I am waiting signal!
^Z
[1]+  Stopped                 ./testsig
$ jobs # 查看所有后台任务
[1]+  Stopped                 ./testsig

(2)OS如何得知键盘有数据

1. 键盘按下(硬件触发):用户按下键盘按键时,键盘控制器检测到物理按键动作,将按键信息转换为 扫描码。
2. 发送硬件中断:键盘控制器通过 中断请求线(其实是CPU 针脚上的高电压) 向 CPU 发送硬件中断信号。
3. CPU 识别中断:CPU 在每个指令周期末尾检查中断引脚。发现中断后,CPU 暂停当前执行的程序,保存现场,找到对应的 中断处理程序。
4. CPU执行OS的键盘处理代码:CPU 跳转到 OS 预定义的键盘中断服务例程(OS接管)。
5. OS暂停当前工作并读取数据:读取扫描码,并存入内核的 输入环形缓冲区 。
6. 处理键盘数据逻辑:将扫描码转为 ASCII,处理组合键。Ctrl+C → 生成 SIGINT 信号。

(3)初步理解信号起源

• 信号其实是从纯软件角度,模拟硬件中断的行为。

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

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

 3. 使用函数产生信号

(1)kill()

kill() 函数将信号发送给进程或进程组。返回值:成功返回0,出错返回-1。

#include <signal.h>
int kill(pid_t pid, int sig);

• 进程调用kill(2)函数可将任意信号发送给另一个进程或进程组。自然,对此有所限制:接收信号进程和发送信号进程的所有者必须相同,或发送信号进程的所有者必须是超级用户。

• 用户可用kill(1)命令将信号发送给其他进程。此命令只是kill函数的接口。常用此命令终止一个失控的后台进程。

(2)raise()

raise() 函数允许进程向自身发送信号。返回值:成功返回0,出错返回-1。

#include <signal.h>
int raise(int sig);
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
// testSig.cc
// 处理信号
void handlerSig(int sig)
{
    std::cout << "获得了一个信号:" << sig << std::endl;
}
int main()
{
    // 对信号捕捉(除SIGKILL和SIGSTOP)
    for(int i = 1; i < 32; i++)
        signal(i, handlerSig);
    // 每隔一秒向自己发送一个信号
    for (int i = 1; i < 32; i++)
    {
        sleep(1);
        raise(i);
    }
    return 0;
}

// mykill.cc
// ./mykill signumber pid
int main(int argc, char *argv[])
{
    if(argc != 3)
    {
        std::cout << "./mykill signumber pid" << std::endl;
        return 1;
    }
    int signum = std::stoi(argv[1]); // 信号编号
    pid_t target = std::stoi(argv[2]); // 目标进程

    int n = kill(target, signum);
    if(n == 0)
    {
        std::cout << "send " << signum << " to " << target << " success!" << std::endl;
    }
}
$ make
g++ -o testsig testSig.cc -std=c++11
g++ -o mykill mykill.cc -std=c++11
$ ./testsig
获得了一个信号:1
获得了一个信号:2
获得了一个信号:3
获得了一个信号:4
获得了一个信号:5
获得了一个信号:6
获得了一个信号:7
获得了一个信号:8
Killed #每隔一秒向自己发送一个信号,直到发送到9号,杀死进程

(3)abort()

强制终止程序,并可能生成 SIGABRT 信号(信号编号 6)。

#include <stdlib.h>
void abort(void);
// 处理信号
void handlerSig(int sig)
{
    std::cout << "获得了一个信号:" << sig << std::endl;
}
int main()
{
    // 对信号捕捉(除SIGKILL和SIGSTOP)
    for(int i = 1; i < 32; i++)
        signal(i, handlerSig);
    int cnt = 0;
    while(true)
    {
        std::cout << "hello world, " << cnt++ << " , pid: " << getpid() << std::endl;
        abort();
    }
    return 0;
}
$ ./testsig
hello world, 0 , pid: 1424782
获得了一个信号:6
Aborted (core dumped)

4. 调用系统命令向进程发信号

kill 选项 PID   

eg: kill -15 5678  请求 PID=5678 的进程正常退出

下面我们可以用代码模拟一下mykill:

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

// ./mykill signumber pid
int main(int argc, char *argv[])
{
    if(argc != 3)
    {
        std::cout << "./mykill signumber pid" << std::endl;
        return 1;
    }
    int signum = std::stoi(argv[1]); // 信号编号
    pid_t target = std::stoi(argv[2]); // 目标进程

    int n = kill(target, signum);
    if(n == 0)
    {
        std::cout << "send" << signum << "to" << target << "success!" << std::endl;
    }
}

// testSig.cc
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>

// 处理信号
void handlerSig(int sig)
{
    std::cout << "获得了一个信号:" << sig << std::endl;
}
int main()
{
    signal(SIGINT, handlerSig);
    int cnt = 0;
    while(true)
    {
        std::cout << "hello world, " << cnt++ << " , pid: " << getpid() << std::endl;
        sleep(1);
    }
    return 0;
}
# 终端1
$ make
g++ -o testsig testSig.cc -std=c++11
g++ -o mykill mykill.cc -std=c++11
$ ./testsig
hello world, 0 , pid: 1383949
hello world, 1 , pid: 1383949
获得了一个信号:2
hello world, 2 , pid: 1383949
获得了一个信号:2
hello world, 3 , pid: 1383949
获得了一个信号:2
hello world, 4 , pid: 1383949
获得了一个信号:2
hello world, 5 , pid: 1383949
Killed

# 终端2
$ ./mykill 2 1383949
send2to1383949success!
$ ./mykill 2 1383949
send2to1383949success!
$ ./mykill 2 1383949
send2to1383949success!
$ ./mykill 2 1383949
send 2 to 1383949 success!

5. 硬件异常产生信号

硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以 0 的指令,CPU 的运算单元会产生异常,内核将这个异常解释为 SIGFPE 信号发送给进程。再比如当前进程访问了非法内存地址,MMU 会产生异常,内核将这个异常解释为 SIGSEGV 信号发送给进程。

(1)模拟除0错误

// 处理信号
void handlerSig(int sig)
{
    std::cout << "获得了一个信号:" << sig << std::endl;
    exit(13); // 设置退出码为13
}
int main()
{
    // 对信号捕捉(除SIGKILL和SIGSTOP)
    for(int i = 1; i < 32; i++)
        signal(i, handlerSig);
    int cnt = 0;
    while(true)
    {
        std::cout << "hello world, " << cnt++ << " , pid: " << getpid() << std::endl;
        int a = 10;
        a /= 0; // 除0错误
        sleep(3);
    }
    return 0;
}

 最后获得了8号信号SIGFPE,此信号表示一个算术运算异常,如除0、浮点数溢出等。

$ make
g++ -o testsig testSig.cc -std=c++11
testSig.cc: In function ‘int main()’:
testSig.cc:30:11: warning: division by zero [-Wdiv-by-zero]
   30 |         a /= 0; // 除0错误
      |         ~~^~~~
g++ -o mykill mykill.cc -std=c++11
$ ./testsig
hello world, 0 , pid: 1426715
获得了一个信号:8

(2)野指针

void handlerSig(int sig)
{
    std::cout << "获得了一个信号:" << sig << std::endl;
    exit(13); // 设置退出码为13
}
int main()
{
    // 对信号捕捉(除SIGKILL和SIGSTOP)
    for(int i = 1; i < 32; i++)
        signal(i, handlerSig);
    
    int cnt = 0;
    while(true)
    {
        std::cout << "hello world, " << cnt++ << " , pid: " << getpid() << std::endl;
        int *p = nullptr;
        *p = 100; // 野指针
        sleep(3);
    }
    return 0;
}

 最后获得11号信号SIGSEGV,表示段错误,进程进行了一次无效的内存引用。

$ ./testsig
hello world, 0 , pid: 1427430
获得了一个信号:11

如果注释掉设置退出码,我们可能发现:发现一直有 11 号信号产生被我们捕获,这是为什么呢?

上面我们只提到 CPU 运算异常后,如何处理后续的流程,实际上 OS 会检查应用程序的异常情况,其实在 CPU 中有一些控制和状态寄存器,主要用于控制处理器的操作,通常由操作系统代码使用。状态寄存器可以简单理解为一个位图,对应着一些状态标记位、溢出标记位。OS 会检测是否存在异常状态,有异常存在就会调用对应的异常处理方法。

除零异常后,我们并没有清理内存,关闭进程打开的文件,切换进程等操作,所以 CPU 中还保留上下文数据以及寄存器内容,除零异常会一直存在,就有了我们看到的一直发出异常信号的现象。访问非法内存其实也是如此。

(3)core dump

Core Dump(核心转储)是操作系统在进程异常终止时保存的一份"内存快照",相当于给崩溃的进程拍了一张"临终照片"。

• 当程序崩溃时,系统把程序当时的内存状态完整保存到一个文件(通常叫core或core.pid),包含变量值、函数调用栈、寄存器状态等调试信息。

• 进程异常终通常是因为有bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件已查清错误原因,这叫做Post-mortem Debug(事后调试)。

 • Ctrl+\ 会发送SIGQUIT信号,其默认行为是:终止进程+产生core dump。而SIGINT(Ctrl+C)只是终止进程,不产生core dump。

• 一个进程允许产生多大的core文件取决于进程的 Resource Limit(这个信息保存在PCB中)。云服务器默认禁用core dump:
        ① 安全考虑:core文件可能包含密码等敏感信息。
        ② 空间考虑:core文件可能很大(尤其是大型程序)。

• 在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。首先用ulimit命令改变Shell进程的 Resource Limit ,例如允许core文件最大为1024K:

$ ulimit -c 1024

• 测试程序,通过 ulimit 设置和触发 Core Dump: 
① 设置 Core Dump 限制

② 设计死循环程序

#include <stdio.h>
#include <unistd.h>
int main()
{
    printf("pid is %d\n", getpid());
    while (1); // 死循环
    return 0;
}

③ 触发 Core Dump

程序运行后:

Ctrl+C (SIGINT) 无法终止,因为程序不处理中断信号
Ctrl+\ (SIGQUIT) 成功终止程序并产生core dump

$ ./a.out
pid is 54582
^\Quit (core dumped)

④ 查看生成的core文件

ulimit作用是修改当前shell的资源限制,而子进程会继承这些限制(所以test进程也能生成core文件)。54582是进程的PID。

⑤ gdb 调试

(4)子进程退出core dump

实验验证:ulimit -c 对 core dump 的影响

#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <vector>
#include <sys/types.h>
#include <functional>
#include <cstdio>
#include <sys/wait.h>

int main()
{
    pid_t id = fork();
    if(id == 0)
    {   // child
        sleep(2);
        printf("hello pid\n");
        printf("hello pid\n");
        printf("hello pid\n");
        printf("hello pid\n");
        int a = 10;
        a /= 0;
        // int *p = NULL;
        // *p = 42;
        printf("hello pid\n");
        printf("hello pid\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);
        //  退出信号         退出码         core dump 标志位
    return 0;
}

ulimit -c 的双重作用:
• 控制 是否允许生成物理 core 文件
• 同时影响 内核是否设置 core dump 标志位(status >> 7 & 1)

内核行为差异:
• ulimit -c > 0:内核会设置 core dump 标志位(即使最终因其他原因未生成文件)
• ulimit -c == 0:内核完全不触发 core dump 机制(包括标志位)

6. 由软件条件产生信号

SIGPIPE 是一种由软件条件产生的信号,如果在管道的度进程已终止时写管道,则产生此信号。下面主要介绍 alarm 和 SIGALRM 信号。

(1)alarm()

使用 alarm() 函数可以设置一个定时器(闹钟时间),在将来某个时刻该定时器会超时。当定时器超时时,产生 SIGALRM 信号。如果忽略或不捕捉此信号,其默认动作时终止调用该alarm函数的进程。

#include <unistd.h>
unsigned int alarm(unsigned int seconds);

返回值:0或以前设置的闹钟时间的余留秒数。

• 每一个进程只能有一个闹钟时间。如果在调用 alarm 时,之前已经为该进程注册的时间闹钟还没有超时,则该闹钟时间的余留值作为本次 alarm 函数调用的返回值。以前注册的闹钟时间则被新值替代。

• 虽然SIGALRM的默认动作是终止进程,但是大多数使用闹钟的进程捕捉此信号。如果此时进程要终止,则在终止之前它可以执行所需的清理操作。如果我们想捕捉SIGALRM信号,则必须在调用alarm之前安装该信号的处理程序。如果我们先调用alarm,然后在我们能够安装SIGALRM处理程序之前已接到该信号,那么进程将终止。

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

// 处理信号
void handlerSig(int sig)
{
    std::cout << "获得了一个信号:" << sig << std::endl;
    exit(13); // 设置退出码为13
}
int main()
{
    // 对信号捕捉
    for(int i = 1; i < 32; i++)
        signal(i, handlerSig);
    alarm(1); // 设定一个1秒的闹钟, 当前进程会受到一个 SIGALRM 信号
    int cnt = 0;
    while (true)
    {
        std::cout << "count : " << cnt++ << std::endl;   
    }    
}

(2)基本alarm验证-体会IO效率问题 

为什么打印效率不高?

std::cout 本质是IO,xshell —> ./XXX —> 云服务器 —> 网络 —> 显示

下面是减少IO的代码: 

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

int cnt = 0;
// 处理信号
void handlerSig(int sig)
{
    std::cout << "获得了一个信号:" << sig << ", count : " << cnt << std::endl;
    exit(13); // 设置退出码为13
}
int main()
{
    // 对信号捕捉
    signal(SIGALRM, handlerSig);
    alarm(1); // 设定一个1秒的闹钟, 当前进程会受到一个 SIGALRM 信号
    while (true)
    {
        cnt++;
    }
}

(3)pause()

pause 函数使调用进程挂起直至捕捉到一个信号。

#include <unistd.h>
int pause(void);

只有执行了一个信号处理程序并从其返回时,pause才返回。在这种情况下,pause返回-1,errno设置为ETNTR。

(4)基于信号的周期性任务调度器

流程:

① 注册任务到 funcs 容器。

② 设置 SIGALRM 的信号处理函数为 handlerSig。

③ 启动定时器(alarm(1))。

④ 进入无限循环,通过 pause() 等待信号。

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <functional>
#include <vector>

///////////////func////////////////////
void Sche()
{
    std::cout << "我是进程调度..." << std::endl;
}

void MemManger()
{
    std::cout << "我是周期性的内存管理, 正在检查是否有内存问题..." << std::endl;
}

void Fflush()
{
    std::cout << "我是刷新程序, 我在定期刷新内存数据到磁盘..." << std::endl;
}
///////////////func////////////////////

// 函数指针类型
using func_t = std::function<void()>;
std::vector<func_t> funcs; // 定义到容器里

// 每隔一秒完成一些任务
void handlerSig(int sig)
{
    std::cout << "#########################" << std::endl;
    for (auto f : funcs)
    {
        f();
    }

    std::cout << "#########################" << std::endl;

    int n = alarm(1); // 闹钟1s
}
int main()
{
    funcs.push_back(Sche);
    funcs.push_back(MemManger);
    funcs.push_back(Fflush);

    signal(SIGALRM, handlerSig);
    alarm(1); // 设定一个1秒的闹钟, 当前进程会受到一个 SIGALRM 信号

    while (true)
    {
        pause();
    }
}

(5)如何快速理解系统闹钟?

系统闹钟的核心是 “定时expires + 回调function”:

• 定时:操作系统需要维护时间,并能判断某个时间点是否到达(比如 7:00)。

• 回调:时间到达时,执行预先设定的动作(比如播放铃声)。

在 Linux 中,这种功能由 定时器(timer) 实现,而内核需要高效管理成千上万个定时器。Linux 使用 timer_list 结构描述一个定时器:

struct timer_list {
    struct list_head entry;      // 链表节点,用于组织到管理结构(如时间轮)
    unsigned long expires;       // 到期时间(单位:jiffies,一种时间度量)
    void (*function)(unsigned long); // 回调函数(时间到了执行什么)
    unsigned long data;          // 回调函数的参数
    struct tvec_t_base_s *base;  // 指向定时器管理结构(如时间轮)
};

• expires:定时器的到期时间(比如 5 秒后触发)。

• function:时间到了要执行的函数(比如 play_alarm_sound())。

• entry:用于把定时器挂载到管理结构(如链表、时间轮等)。

定时器的管理方式:

① 时间轮(Timer Wheel)—— Linux 的默认方式
类似“时钟表盘”,把定时器按照到期时间分配到不同的“槽位”(bucket)。

• 优点:插入、删除、查找的平均时间复杂度是 O(1),适合大量短周期定时器(如网络超时)。

• 缺点:远期定时器(比如 1 年后)需要更复杂的层级时间轮。

② 最小堆(Min-Heap)—— 更简单的理解方式
虽然 Linux 实际不用堆,但堆的概念更容易理解:

• 堆是一种优先队列,总让最早到期的定时器在堆顶。

• 插入(add_timer):O(log n),把新定时器加入堆。

• 触发(run_timers):检查堆顶,如果到期就执行回调,并调整堆。

(6)如何理解软件条件?

软件条件是指由软件自身内部状态变化或特定软件操作引发的信号产生机制。例如,当使用alarm函数设定的定时时间到达时,就会触发定时器超时这一软件条件;再如,向已经关闭的管道写数据,会触发产生SIGPIPE信号的软件异常情况。这些都属于软件条件的范畴。当这些软件条件满足时,操作系统会向相关进程发送相应的信号,以此来通知进程进行处理。简单来说,软件条件就是因操作系统内部或外部软件操作而触发的信号产生。

四、保存信号

1. 信号其他相关常见概念

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

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

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

• 未决信号的处理:被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。

• 阻塞与忽略的区别:阻塞和忽略是不同的概念。只要信号被阻塞,就不会递达;而忽略是在信号递达之后可选的一种处理动作。

2. 在内核中的表示

(1)信号的三种属性

① 阻塞标志(block)
• 表示该信号是否被进程阻塞(屏蔽)
• 值为1表示被阻塞,0表示未被阻塞
• 被阻塞的信号将暂时不会被递达

② 未决标志(pending)
• 表示该信号是否已产生但尚未处理
• 值为1表示有待处理的信号,0表示没有
• 内核在信号产生时设置此标志

③ 处理动作(handler)
指定信号递达时的处理方式,可以是:
• SIG_DFL:默认处理动作(通常是终止或忽略)
• SIG_IGN:显式忽略该信号
• 用户自定义的函数指针:执行自定义信号处理函数

(2)信号的生命周期

① 信号产生:由内核、其他进程或自身产生
② 设置未决标志:内核在进程的task_struct中设置对应信号的pending=1
③ 信号递达:当信号未被阻塞时,执行处理动作
④ 清除未决标志:递达完成后,pending标志被清除(pending=0)

(3)实例详解

① SIGHUP(1)
 • block=1:信号被阻塞
 • pending=1:有未决的SIGHUP信号
 • handler=用户自定义函数
 • 解释:SIGHUP信号已产生但被阻塞,暂时不会递达。如果解除阻塞,将调用用户自定义处理函数。

② SIGINT(2)
 • block=0:信号未被阻塞
 • pending=0:没有未决的SIGINT信号
 • handler=SIG_DFL:默认处理动作
 • 解释:如果产生SIGINT信号(如按下Ctrl+C),将立即执行默认动作(通常是终止进程)。

③ SIGQUIT(3)
 • block=1:信号被阻塞
 • pending=1:有未决的SIGQUIT信号
 • handler=SIG_IGN:忽略该信号
 • 解释:虽然处理动作为忽略,但由于信号被阻塞,暂时不会处理。这给了进程修改处理动作的机会。

(4)信号阻塞期间的多次产生

当信号被阻塞期间多次产生时,处理方式取决于信号类型:

常规信号(1-31)
• 在阻塞期间无论产生多少次,只记录一次
• 解除阻塞后只递送一次
• 例如:连续多次Ctrl+C可能只处理一次SIGINT

实时信号(34-64)
• 内核维护一个队列,按顺序记录每次信号产生
• 解除阻塞后按顺序依次递送
• 需要使用sigqueue()发送

(5)关键机制详解

① 信号递达的时机
• 当进程从内核态返回用户态前,内核会检查未决信号
• 对于既未阻塞(pending=1且block=0)的信号,执行递达

② 忽略信号的特殊性
• 即使handler=SIG_IGN,被阻塞的信号也不会立即被忽略
• 这是为了允许进程先修改处理动作再解除阻塞

③ 信号处理函数执行
• 用户自定义处理函数在用户态执行
• 内核会先设置好调用帧,使得处理函数返回后能继续原程序执行

(6)内核数据结构

struct task_struct {
    ...
    struct sighand_struct *sighand;  // 信号处理函数
    sigset_t blocked;                // 阻塞信号集
    struct sigpending pending;       // 未决信号
    ...
};

struct sighand_struct {
    atomic_t count;
    struct k_sigaction action[_NSIG];  // 64个信号的处理动作
    spinlock_t siglock;
};

struct k_sigaction {
    struct __new_sigaction sa;
    void __user *ka_restorer;
};

struct __new_sigaction {
    __sighandler_t sa_handler;      // 信号处理函数指针
    unsigned long sa_flags;
    void (*sa_restorer)(void);
    __new_sigset_t sa_mask;         // 执行处理函数时阻塞的信号
};

struct sigpending {
    struct list_head list;         // 实时信号队列
    sigset_t signal;               // 未决信号集
};

3. sigset_t

(1)基本概念

sigset_t是Linux系统中用于表示信号集的数据类型,它可以表示每个信号的"有效"或"无效"状态。在信号处理机制中,它被用于两种不同的信号集:

• 未决信号集(pending set):记录哪些信号已产生但尚未处理
• 阻塞信号集(blocked set,也称信号屏蔽字):记录哪些信号当前被阻塞

(2)sigset_t实现方式

sigset_t本质上是一个位掩码(bitmask),每个信号对应其中的一个bit位:

• 对于常规信号(1-31号),通常使用32位无符号整数表示
• 对于实时信号(34-64号),可能需要更大的数据结构

位表示含义:

• 在阻塞信号集中:
        1(有效):该信号被阻塞
        0(无效):该信号未被阻塞

• 在未决信号集中:
        1(有效):该信号已产生但未处理
        0(无效):该信号未产生或已处理

(3)关键特性解释

① 为什么未决和阻塞都使用sigset_t?

• 两者都只需要记录每个信号的二元状态(是/否)
• 使用相同的数据类型可以简化内核实现
• 便于进行位操作和集合运算

② "屏蔽"与"阻塞"的明确区分

• 阻塞(Block):信号被暂时阻止递达,但仍记录在未决集中
• 忽略(Ignore):信号直接被丢弃,不记录也不处理

4. 信号集操作函数

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

基本操作: 

#include <signal.h>
int sigemptyset(sigset_t *set);    // 初始化set为空集
int sigfillset(sigset_t *set);     // 初始化set包含所有信号
int sigaddset(sigset_t *set, int signo);  // 添加信号到set
int sigdelset(sigset_t *set, int signo);  // 从set删除信号
int sigismember(const sigset_t *set, int signo); // 测试signo是否在set中

• 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
• 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
• 注意,在使用sigset_t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。

这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。 

(1)sigprocmask

调用sigprocmask函数,可以检测或更改,或同时进行检测和更改进程的信号屏蔽字(block表)。

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

① 参数说明:

<1> how 

• SIG_BLOCK(添加阻塞)
        这种模式将 set 中指定的信号添加到当前已阻塞的信号集合中,这是一个"或"操作,原有被阻塞的信号保持阻塞状态,新指定的信号也会被加入阻塞集合,相当于 mask=mask | set。

• SIG_UNBLOCK(解除阻塞)
        这种模式将 set 中指定的信号从阻塞集合中移除,这是一个"与非"操作,只清除指定的信号位,其他信号的阻塞状态不受影响,相当于 mask=mask&~set。【如果解除阻塞的信号有未决的(pending),会立即递达】

• SIG_SETMASK(直接设置)
        这种模式直接替换整个信号屏蔽字,完全忽略先前的阻塞状态,常用于精确控制信号阻塞状态或恢复之前保存的状态,相当于 mask=set。

<2> set 

set 指向一个信号集的指针,包含要修改的信号,则参数 how 指示如何修改当前屏蔽字;如果为 NULL,则 how 参数被忽略,函数只用于获取当前屏蔽字。

<3> oldset

oldset 是输出型参数,进程的当前信号屏蔽字通过 oldset 返回;如果为 NULL,则不返回旧值。

② 返回值:成功时返回 0,失败时返回 -1 并设置 errno。

注:如果调用 sigprocmask 解除了对当前若干个未决信号的阻塞,则在 sigprocmask 返回前,系统会立即检查这些刚被解除阻塞的信号,至少将一个信号递达,内核保证至少有一个符合条件的未决信号会被递达(即执行其对应的处理动作)。

(2)sigpending

读取当前信号的未决信号集(pending表),通过 set(指向 sigset_t 类型的指针)参数传出。调用成功返回0,否则返回-1。

#include <signal.h>
int sigpending(sigset_t *set);

(3)代码理解

#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <vector>
#include <sys/types.h>
#include <functional>
#include <cstdio>

// 打印pending表
void PrintPending(sigset_t &pending)
{
    printf("我是一个进程(%d), pending: ", getpid());
    for (int signo = 31; signo >= 1; signo--)
    {
        if (sigismember(&pending, signo)) // 判断signo是否在pending表里面
        {
            std::cout << "1";
        }
        else
        {
            std::cout << "0";
        }
    }
    std::cout << std::endl;
}

// 自定义捕捉函数
void handler(int sig)
{   // 结论:准备递达时,首先要清空pending信号集中对应的信号位置1——>0
    std::cout << "================================================" << std::endl;
    std::cout << "递达" << sig << "信号!" << std::endl;
    sigset_t pending;
    int n = sigpending(&pending); // 获取pending信号集
    PrintPending(pending); // 0010(处理完handler才恢复) , 0000(恢复后再处理handler)
    std::cout << "================================================" << std::endl;

}

int main()
{
    signal(SIGINT, handler); // 对2号新号做自定义捕捉
    // 1. 屏蔽2号信号
    sigset_t block, oblock;
    sigemptyset(&block);  // 初始化置空
    sigemptyset(&oblock); // 初始化置空

    sigaddset(&block, SIGINT);                         // 添加2号信号
    
    // 屏蔽所有信号(9号信号不可被捕捉, 是管理员信号)
    // for (int i = 1; i < 32; i++)
    //     sigaddset(&block, i);

    int n = sigprocmask(SIG_SETMASK, &block, &oblock); // 屏蔽2号(block表)
    (void)n;

    // 4. 重复获取打印过程
    int cnt = 0; // 计数器
    while (true)
    {
        // 2. 获取pending信号集合
        sigset_t pending;
        int m = sigpending(&pending);

        // 3. 打印
        PrintPending(pending);
        if(cnt == 10)
        {
            // 5. 恢复对2号信号的block情况
            std::cout << "解除对2号新号的屏蔽" << std::endl;
            sigprocmask(SIG_SETMASK, &oblock, nullptr);
        }


        sleep(1);
        cnt++;
    }

    return 0;
}
zyt@iZ2vcf9wvlgcetfeub9f11Z:~/linux-journey-log/code_25_7_7$ ./test
我是一个进程(46015), pending: 0000000000000000000000000000000
我是一个进程(46015), pending: 0000000000000000000000000000000
我是一个进程(46015), pending: 0000000000000000000000000000000
我是一个进程(46015), pending: 0000000000000000000000000000000
我是一个进程(46015), pending: 0000000000000000000000000000000
我是一个进程(46015), pending: 0000000000000000000000000000000
我是一个进程(46015), pending: 0000000000000000000000000000000
^C我是一个进程(46015), pending: 0000000000000000000000000000010
我是一个进程(46015), pending: 0000000000000000000000000000010
我是一个进程(46015), pending: 0000000000000000000000000000010
我是一个进程(46015), pending: 0000000000000000000000000000010
解除对2号新号的屏蔽
==============================================================
递达2信号!
我是一个进程(46015), pending: 0000000000000000000000000000000
==============================================================

        这段代码演示了Linux信号处理的全过程,从信号屏蔽、pending状态到最终递达处理的完整流程。程序首先设置SIGINT信号的处理函数为自定义的handler,然后通过sigprocmask屏蔽该信号。在主循环中,程序每秒打印一次当前pending信号集的状态,此时若收到SIGINT信号,信号会被阻塞并显示在pending集中。当计数器达到10时解除信号屏蔽,此时若有pending的SIGINT信号会立即递达执行handler函数。关键观察点是handler内打印的pending集显示信号递达时pending位会先被清0,这解释了信号处理机制如何保证信号不丢失且不重复处理。整个过程展示了信号从产生、阻塞、pending到最终处理的完整生命周期,特别是信号递达时pending位自动清空这一重要机制。 

结论:准备递达时,首先要会空 pending 信号集中对应的信号位置1→0

五、信号捕捉

1. 信号捕捉的流程

(1)信号捕捉流程

① 初始进入内核:用户模式下的主程序正在执行时,因为中断、异常或系统调用,CPU从用户模式切换到内核模式。

② 内核检查信号:内核处理完原始任务后,在即将返回用户模式之前,会调用do_signal()等函数检查当前进程是否有待处理且需要递送的信号(检查pending表)。

③ 执行用户态处理函数:如果发现信号的处理方式是用户自定义的函数,内核不会返回主程序,而是通过精心构造栈帧,让CPU直接返回到用户模式的信号处理函数(如sighandler)中执行。

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

• sighandler 和 main 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。

• 如果信号是忽略(SIG_IGN)?就将pending表由1改回0,并返回用户态继续执行main函数上下文。如果信号是默认呢?则信号检查后根据特定信号的约定执行默认动作。

• 所以信号执行忽略和默认动作要比执行自定义动作简单。

④ 重返内核进行清理:信号处理函数执行完毕返回时,会自动调用特殊的sigreturn系统调用,再次进入内核模式。

⑤ 恢复现场并返回:内核在sys_sigreturn()中清理为信号处理搭建的临时栈帧,恢复最初被中断的主程序的完整上下文。最后,内核返回用户模式,程序从主流程中上次被中断的地方继续向下执行,就像信号从未发生过一样。

(2)身份切换

信号捕捉过程中用户态和内核态之间的交互(4次)

2. sigaction

sigaction 函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回 0,出错则返回 -1。

#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);

• igno 是指定信号的编号。若 act 指针非空,则根据 act 修改该信号的处理动作。若 oact 指针非空,则通过 oact 传出该信号原来的处理动作。act 和 oact 指向 sigaction 结构体。

• struct sigaction 是一个可自定义捕捉函数,结构如下:

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 函数调用,而是被系统所调用。

当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字(修改block表),当信号处理函数返回时自动恢复原来的信号屏蔽字。也就是说操作系统不允许同一个信号被多次递达。这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用 sa_mask 字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。sa_flags 字段包含一些选项,本章的代码都把 sa_flags 设为 0,sa_sigaction 是实时信号的处理函数,本章不详细解释这两个字段。

• 简单使用实例:

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

void handler(int signum)
{
    std::cout << "hello signal " << signum << std::endl;
    // 不断获取pending表
    while(true)
    {
        sigset_t pending;
        sigpending(&pending); // 获取
        for(int i = 32; i >= 1; i--)
        {
            if(sigismember(&pending, i)) 
                std::cout << "1";
            else
                std::cout << "0";
        }
        std::cout << std::endl;
        sleep(1);
    }
    
    exit(0);
}
int main()
{
    struct sigaction act, oact;
    act.sa_handler = handler;
    act.sa_flags = 0; // 清空标志位
    sigemptyset(&act.sa_mask); // 清空额外屏蔽的信号

    sigaction(SIGINT, &act, &oact); // 对2号信号进行自定义捕捉

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

3. 操作系统是怎么运行的?

(1)硬件中断

由外部设备触发的中断系统运行流程叫硬件中断。

该流程图描述了计算机系统中的中断的完整处理流程:

① 外部数据 / 外部设备发起中断:各种外部硬件设备(如键盘、鼠标、网卡、磁盘控制器等)是中断的发起者。当它们完成一个任务或有新数据需要CPU处理时(例如,用户按下了键盘按键、网卡收到了一个新的数据包、磁盘完成了一次数据读取),就会发起一个中断请求。

② 中断控制器汇总通知:中断请求线非常多,为了有序管理,所有设备的中断请求会先发送到一个叫做中断控制器(如Intel的8259A或现代的APIC) 的硬件芯片。中断控制器负责接收所有中断信号,进行优先级仲裁(同时发生多个中断时,哪个更重要先处理哪个),然后统一通知CPU。

③ CPU感知中断:CPU在执行完当前指令后,会检查其中断引脚。如果中断控制器发出了信号,CPU便感知到有中断请求到来。

④ 获取中断号:CPU从中断控制器那里获取一个唯一的中断号(Interrupt Number)。这个号码就像是一个索引值。

⑤ 保护现场(上下文切换):这是非常关键的一步。为了在处理完中断后能准确地恢复之前的工作,CPU必须立即保存当前任务的执行状态(现场)。现场包括:程序计数器(PC)、状态寄存器、通用寄存器等。这个过程通常由硬件自动完成,直接压入内核栈。

⑥ 执行中断处理程序:
        • 查表:CPU根据拿到的那个中断号,去内存中一个叫做中断向量表(IDT) 的特定数据结构里查找对应的条目。
        • 跳转:IDT中的每个条目都包含了一个中断服务程序(ISR) 的入口地址。CPU会切换到内核态(最高特权级),并跳转到这个地址开始执行。
        • 执行:中断服务程序是操作系统内核的一部分,由它来执行处理该中断的具体工作(如从键盘控制器读取按键扫描码、从网卡缓冲区拷贝数据包等)。

⑦ 恢复现场并返回:当中断服务程序执行完毕后,会执行一条特殊的指令(如iret),这条指令会指示CPU将之前保存的现场信息从栈中恢复出来(还原寄存器等状态)。最后,CPU继续执行被中断的那个任务,就像什么都没有发生过一样。

• 中断向量表(IDT):这是存储在内存中的一个数组,是操作系统的核心数据结构之一。它在系统启动时由操作系统初始化。它的作用就是建立中断号与中断处理程序地址之间的映射关系,是CPU找到正确处理方法的“导航图”。

• 中断服务程序(ISR):这是真正处理中断事件的代码,是操作系统内核的一部分。每个中断号都对应一个独立的ISR。图表右侧的“处理键盘”、“处理网卡”等,就是指这些不同的ISR。

• 为什么需要中断:它实现了异步处理。CPU不用不停地询问每个设备“你有事吗?”,而是可以专注执行自己的任务,等设备主动来“敲门”,大大提高了效率。

(2)时钟中断

中断驱动下的操作系统调度流程:(现代操作系统中抢占式多任务的核心机制。)

① 中断发生:时钟源(硬件计时器)以预设的频率向CPU的中断控制器发送一个时钟中断信号(IRQ0)。(在x86架构上通常是IRQ0)

② CPU响应:CPU在执行完当前指令后,立即感知到该中断。它保存当前正在运行任务的上下文(所有寄存器状态到内核栈),然后通过中断控制器获取中断号(IRQ0)。

③ 查表跳转:CPU根据中断号IRQ0去查询中断描述符表(IDT),找到对应的中断服务程序(ISR)。

④ 执行中断处理程序(关键步骤):在这个特定的ISR中,操作系统会执行一系列至关重要的操作,而不仅仅是处理硬件。主要包括:
        • 更新系统时间:维护内核的jiffies、xtime等时间变量。
        • 更新资源统计:更新当前进程消耗的CPU时间、用户时间、内核时间等。
        • 触发调度检查:这是最关键的一步!处理程序会调用调度器(Scheduler) 的核心函数(如schedule())。

• 调度器会检查当前进程的时间片(counter)是否用尽。(时间片相当于计数器)
• 检查是否有更高优先级的进程就绪等待运行。
• 检查当前进程是否需要主动让出CPU(如等待I/O而阻塞)。

⑤ 决定是否调度:
        如果不需要调度:中断处理程序执行完毕,CPU恢复之前保存的现场,当前进程继续运行,它对自己被短暂中断一事毫无感知。
        如果需要调度:调度器就会启动进程上下文切换。

• 将当前进程的上下文完整保存到它的进程控制块(PCB) 中。
• 从另一个就绪进程的PCB中加载它的上下文到CPU寄存器。
• 切换地址空间(如果需要)。
• 然后跳转到新进程的指令地址开始执行。

⑥ 中断返回:
注意:中断返回时,CPU恢复的可能是另一个进程的现场(如果发生了调度),而不再是原来那个进程的现场。这样就巧妙地完成了任务的切换。

这个机制的优点:

• 实现了抢占:操作系统不再依赖进程自觉、主动地交出CPU(协作式多任务)。而是可以强制性地、定期地打断当前进程,收回CPU控制权,并决定下一步该谁运行。这保证了系统不会被一个恶意或异常的进程完全霸占。
• 创造了并发的假象:通过在极短的时间间隔内(毫秒级)快速切换多个进程,让所有用户感觉程序都在同时运行。
• 提供了公平性:调度器可以确保所有进程都能分到合理的CPU时间片,不会出现“饿死”现象。

(3)死循环

操作系统的本质就是一个死循环!需要什么功能就向中断向量表中添加方法即可,操作系统不需要做什么。

// 任务0(空闲任务)的主函数
// 注意:这里的 void main(void) 在head.s启动代码中就是这样约定的,并非错误。
void main(void)
{
    // ... (此处可能有一些初始化代码)

    /* 核心注释:
     * 注意!!对于任何其它的任务,'pause()' 意味着进程将进入可中断睡眠态,
     * 必须等待收到一个信号才会被唤醒并返回就绪运行态。
     * 
     * 但任务0(task0)是唯一的例外(参见 'schedule()' 的实现)。
     * 因为任务0 是空闲任务,在任何没有其它任务可运行的空闲时间里都会被调度器激活。
     * 
     * 因此,对于任务0 而言,'pause()' 仅意味着:
     * 1. 主动让出CPU,触发调度器去选择下一个要运行的任务。
     * 2. 如果调度器没有找到任何其他可运行的任务(系统空闲),它会再次选择任务0。
     * 3. 任务0 从pause()调用中返回,并继续执行这个循环。
     * 
     * 所以,任务0 最终就是在这里一直循环执行 'pause()'。
     */
    for(;;)
        pause();
}

任务0(idle进程)是内核启动后创建的第一个“任务”或“进程”,也称为空闲任务。当CPU无事可做时,就运行它。

与调度器配合的工作流程:

① 任务0 执行 pause(): 主动发起系统调用,进入内核态。

② 内核处理 pause(): 内核的 sys_pause() 函数将任务0的状态设置为可中断睡眠态,然后调用 schedule() 进行调度。

③ 调度器 schedule() 工作:遍历所有任务,寻找处于就绪态(TASK_RUNNING)的任务。

• 如果找到了:就切换到那个任务去执行。

• 如果没找到(没有任何其他任务需要运行):调度器会特意将任务0的状态重新设置为就绪态(TASK_RUNNING),然后选择它作为下一个要运行的任务。这就是注释中提到的“唯一的意外情况”。

④ 任务0 被再次切换回来: CPU控制权回来,从 pause() 系统调用中返回,继续执行下一条指令(即循环的下一个迭代),再次调用 pause()。

这段代码是操作系统内核的“idle循环”,其本质是:一个通过不断调用 pause() 来主动触发调度,并在没有其他工作时总是被调度器选中的死循环。

(4)软件中断

软件中断是程序主动执行一条指令(如 int 0x80)来触发的中断。

软件中断流程:

① 用户态准备:用户程序按照操作系统约定的调用规范,将系统调用号放入指定的寄存器eax (x86) / rax (x64) 。触发中断:程序执行 int 0x80(传统)或 syscall(现代)指令。这是从用户态进入内核态的唯一大门。
② 硬件自动处理:这是CPU设计好的一套自动流程,只要执行 int 或 syscall 指令就会触发

• 权限提升:CPU将当前特权级从用户态切换到内核态。
• 栈切换:CPU自动找到内核栈(每个进程都有一个),并将当前的用户栈地址保存到内核栈中。
• 保存现场:CPU将关键寄存器的值自动压入内核栈。这是为了将来能完美恢复,继续执行用户程序。
• 查找入口:CPU根据中断号(0x80)去查询中断描述符表(IDT),找到第 0x80 项对应的中断处理程序的入口地址。
• 跳转执行:CPU开始执行找到的入口程序,这个程序是操作系统内核代码的一部分。

③ 内核派发与执行:

• 统一入口:CPU执行的是系统调用的统一入口函数(如 system_call)。这个函数是所有系统调用的总入口。
• 获取调用号:入口函数从 eax 寄存器中取出用户事先准备好的系统调用号。
• 查表跳转:内核维护着一个叫 sys_call_table 的数组(系统调用表)。这个数组以系统调用号为索引,每个元素都是一个函数指针,指向真正实现该系统调用的内核函数(如 sys_read, sys_write)。
• 核心本质:call [sys_call_table + eax * 4]。这行代码完成了根据号码查找并调用对应函数的核心动作。
• 执行具体功能:CPU开始执行具体的系统调用函数(如 sys_write),完成用户请求的实际工作(如向屏幕打印字符)。

④ 返回用户态:

• 系统调用函数将执行结果(成功则返回0或正数,失败则返回错误码 -ERRNO)放入 eax 寄存器。(有时也通过用户传入的缓冲区地址传出返回值,比如需要返回大量数据)
• 恢复现场:内核代码执行 iret(或 sysret)指令。这条指令指示CPU:从内核栈中弹出之前保存的现场信息(EFLAGS, CS:EIP, 用户栈地址)。并将CPU特权级从内核态(Ring 0) 降回用户态(Ring 3)。
• 继续执行:CPU回到用户态,从 int 0x80 指令的下一条指令开始继续执行用户程序。

内核源代码:

① 系统调用表

// 定义函数指针类型 fn_ptr
typedef int (*fn_ptr)();

// 系统调⽤函数指针表。⽤于系统调⽤中断处理程序(int 0x80),作为跳转表。
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
sys_setreuid, sys_setregid
};

② 调度程序初始化

// 调度程序的初始化子程序。
void sched_init(void)
{
    // ... (其他初始化代码)
    // 设置系统调用中断处理程序入口地址为:&system_call
    set_system_gate(0x80, &system_call);
}

③ 系统调用入口程序

_system_call:
    ;// 1. 检查系统调用号的有效性
    cmp eax, nr_system_calls-1  ;// 将eax中的调用号与系统调用的总数-1比较
    ja bad_sys_call             ;// 如果大于(无符号比较),则跳转到错误处理

    ;// 2. 保护现场:保存用户态下的段寄存器和参数寄存器
    push ds
    push es
    push fs
    push edx ;// 系统调用参数:C函数调用时的第三个参数
    push ecx ;// 第二个参数
    push ebx ;// 第一个参数

    ;// 3. 设置内核数据段
    mov edx, 0x10 ;// 0x10 是内核数据段在GDT中的选择子
    mov ds, dx
    mov es, dx
    ;// 设置fs指向局部数据段(通常用于用户数据空间,便于内核访问用户内存)
    mov edx, 0x17 ;// 0x17 是用户数据段在LDT中的选择子
    mov fs, dx

    ;// 4. 查表并调用!这是最核心的一行代码。
    call [_sys_call_table + eax * 4] ;// 调用相应的系统调用处理函数
    ;// 解释:sys_call_table 是数组首地址。
    ;//      eax * 4:因为每个函数指针占4字节,所以索引需要乘以4。
    ;//      call [addr]:调用 addr 内存地址中存储的函数地址。

    ;// 5. 将返回值(存放在eax中)压栈保存。因为后续操作可能会覆盖eax。
    push eax

    ;// 6. 检查当前进程是否需要重新调度
    mov eax, _current ;// 获取当前任务(进程)结构体的指针
    ;// 6.1 如果状态 state != 0(非就绪态),则去调度
    cmp dword ptr [state + eax], 0 ;// state 是进程结构体中的偏移量
    jne reschedule
    ;// 6.2 如果时间片 counter == 0,则去调度
    cmp dword ptr [counter + eax], 0 ;// counter 是进程结构体中的偏移量
    je reschedule

;// 7. 从系统调用返回:处理信号,恢复现场,返回用户态。
ret_from_sys_call:
    ;// ... (这里会处理信号等其他工作)
    pop eax    ;// 弹出之前保存的系统调用返回值
    pop ebx    ;// 恢复寄存器
    pop ecx
    pop edx
    pop fs
    pop es
    pop ds
    iret       ;// 中断返回,CPU自动弹出EIP/CS/EFLAGS,切换回用户态

;// 需要重新调度
reschedule:
    push ret_from_sys_call ;// 将返回地址压栈,这样schedule()返回后会回到ret_from_sys_call
    jmp _schedule          ;// 跳转到调度函数

(5)缺页中断、内存碎片化、除0野指针都属于软中断

缺页中断、内存碎片化、除0野指针错误这些问题都会被转化成CPU内部的软中断,然后走中断处理历程,完成所有处理。有的是申请内存,填充页表,进行映射的。有的是用来处理内存碎片的,有的是用来给目标进程发送信号,杀掉进程等等。

• 操作系统就是躺在中断处理历程上的代码块!

• CPU内部的软中断,比如int 0x80或者syscall,我们叫做陷阱。

• CPU内部的软中断,比如除0/野指针等,我们叫做异常。

4. 理解用户态和内核态

• 每个进程都有自己的用户空间页表,但它们共享同一份内核页表。这意味着,无论系统切换到哪个进程,只要一进入内核态,看到的都是同一个、固定的内核地址空间。

• 用户空间 [0, 3] GB:存放进程独有的代码、数据、堆栈、库等。进程在用户态时,只能访问这块区域。

• 内核空间 [3, 4] GB:存放操作系统内核的代码、数据、以及所有进程共享的内核数据结构(如进程控制块、内核堆栈等)。进程在内核态时,可以访问整个4GB空间。

• 区分用户态和内核态,本质上是 CPU 当前正在执行的代码的特权级别(CPL) 不同。而这个特权级别,又由当前正在使用的代码段寄存器(CS) 中的段选择子 的低2位(RPL)来决定。

当 CPL == 0,CPU处于内核态。
当 CPL == 3,CPU处于用户态。

• 一般执行int 0x80或者syscall后,CPL会在校验之后变更。

五、可重入函数

信号处理函数中调用不可重入函数导致的竞态条件和内存泄漏问题:

1. 问题描述

        main函数调用 insert 函数向一个链表 head 中插入节点 node1 ,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到 sighandler 函数,sighandler 也调用 insert 函数向同一个链表 head 中插入节点 node2,插入操作的两步都做完之后从 sighandler 返回内核态,再次回到用户态就从 main 函数调用的 insert 函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main 函数和 sighandler 先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。导致 node2 节点丢失,内存泄漏。

2. 可重入函数

        像上例这样,insert 函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入。insert 函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数。反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant)函数。

        可重入函数可以被多个任务调用,每次函数调用时,都会为局部变量分配新的栈空间。所以并发调用也不会互相干扰。

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

• 已知它们使用静态数据结构。

• 调用了 malloc 或 free ,因为 malloc 也是用全局链表来管理堆的。
• 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

六、Volatile关键字

1. 内存可见性问题

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

int flag = 0;

void handler(int signum)
{
    std::cout << "更改全局变量, "  << flag << "-> 1"<< std::endl;
    flag = 1;
}

int main()
{
    // main执行流并不会对flag进行修改, 但handler执行流修改了。
    // 对于这种情况, 编译器优化代码后(-O1/-O2),会将flag缓存在寄存器中,
    // 导致主程序无法看到flag被修改!
    signal(2, handler);
    while(!flag); // 循环检查
    std::cout << "process quit normal!" << std::endl; // 正常退出

    return 0;
}

标准情况下,键入CTRL-C,执行自定义捕捉动作,修改flag值,while条件不满足,循环退出。

$ g++ test.cpp -O0
$ ./a.out
^C更改全局变量, 0-> 1
process quit normal!

但在编译器优化选项下(如-O1,-O2)编译,它看到在 main 函数的循环中,没有任何代码修改 flag 变量(从 main 函数的角度看)。因此,编译器会进行一种叫做 “将变量缓存在寄存器中” 的优化:

• 它第一次从内存中读取 flag 的值到某个 CPU 寄存器中。

• 后续的每次循环检查,它不再从内存中重新读取 flag 的值,而是直接使用寄存器中缓存的那个值。因为编译器认为这个值不会被改变。

这就导致,即使信号处理函数 handler 已经将内存中的 flag 值成功修改为 1,主执行流 while(!flag) 检查的仍然是寄存器中缓存的旧值 0。使循环永远不会退出,程序看起来就像“卡死”了一样,无法正常退出。

$ g++ test.cpp -O1
$ ./a.out
^C更改全局变量, 0-> 1
^C更改全局变量, 1-> 1
^C更改全局变量, 1-> 1

2. volatile

volatile关键字强制每次访问变量时从内存读取,保证内存空间的可见性,避免优化带来的不一致性。

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

volatile int flag = 0; // 使用 volatile 修饰, 保证内存空间的可见性

void handler(int signum)
{
    std::cout << "更改全局变量, "  << flag << "-> 1"<< std::endl;
    flag = 1;
}

int main()
{
    signal(SIGINT, handler); 
    while(!flag); // 现在编译器会每次都从内存读取 flag 的值
    std::cout << "process quit normal!" << std::endl;

    return 0;
}
$ g++ test.cpp -O1
$ ./a.out
^C更改全局变量, 0-> 1
process quit normal!

七、SIGCHLD信号

进程一章讲过用 wait 和 waitpid 函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。

其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略(SIG_IGN),父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。验证:

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

void Say(int num)
{
    std::cout << "Father get a signal: " << num << std::endl;
}

int main()
{
    signal(SIGCHLD, Say); // 父进程给SIGCHLD注册的自定义信号处理函数
    pid_t id = fork();
    if(id == 0) // child
    {
        std::cout << "I am chlid, exit" << std::endl;
        sleep(1);
        exit(3);
    }
    // father
    waitpid(id, nullptr, 0); // 进程等待
    std::cout << "I am father, exit" << std::endl;

    return 0;
}
$ ./a.out
I am chlid, exit
Father get a signal: 17
I am father, exit

解决方案1:非阻塞轮询

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

void Say(int num)
{
    while(true)
    {
        pid_t n = waitpid(-1, nullptr, WNOHANG); 
        // // WNOHANG: 非阻塞轮询。如果出现10个子进程中有6个退, 4个不退的情况,需要用到非阻塞轮询
        if(n == 0)
            break;
        else if(n < 0)
        {
            std::cout << "waitpid error" << std::cout;
            break;
        }
    }

    std::cout << "Father get a signal: " << num << std::endl;
}

int main()
{
    signal(SIGCHLD, Say); // 父进程给SIGCHLD注册的自定义信号处理函数
    for(int i = 0; i < 10; i++)
    {
        pid_t id = fork();
        if(id == 0) // child
        {
            std::cout << "I am chlid, exit" << std::endl;
            sleep(1);
            if(i <= 6) exit(3);
            else pause();
        }
    }
    // father
    while(true)
    {
        std::cout << "I am father, exit" << std::endl;
        sleep(1);
    }
    return 0;
}

解决方案2:

事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用 sigaction 将 SIGCHLD 的处理动作置为 SIG_IGN ,这样 fork 出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用 sigaction 函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。

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

int main()
{
    signal(SIGCHLD, SIG_IGN); 
    for(int i = 0; i < 10; i++)
    {
        pid_t id = fork();
        if(id == 0) // child
        {
            std::cout << "I am chlid, exit" << std::endl;
            sleep(1);
            exit(3);
        }
    }
    // father
    while(true)
    {
        std::cout << "I am father, exit" << std::endl;
        sleep(1);
    }
    return 0;
}
$ g++ test.cpp
$ ./a.out
I am chlid, exit
I am chlid, exit
I am chlid, exit
I am chlid, exit
I am chlid, exit
I am chlid, exit
I am chlid, exit
I am father, exit
I am chlid, exit
I am chlid, exit
I am chlid, exit
I am father, exit
I am father, exit
I am father, exit
Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐