一、什么是信号
1. 信号本质上只是一个整数编号。
Linux 定义了很多信号:

信号就是 Linux 内核向进程发送的一种**"异步事件通知"**,它本身只携带事件类型(信号编号),用于告诉进程发生了什么事情。
2. 信号是异步的
信号是异步的 = 操作系统可以在任意时间给进程发通知,进程正在执行的任何代码都会被临时打断,去处理信号,处理完再恢复运行。
- 同步:按顺序、主动调用、必须等待
- 异步:随时来、被动接收、强制打断
3. 信号处理
进程必须处理产生的信号,处理方式有 3 种:
- 忽略(不理它)
- 默认动作(终止、暂停、继续...)
- 自定义处理(自己写函数处理)
4. 信号的生命周期
tex
1. 产生 → 内核发出信号
↓
2. 未决 → 信号被阻塞,挂起等待(存在未决集)
↓
3. 递达 → 阻塞解除,信号送达进程
↓
4. 处理 → 执行默认/忽略/自定义函数
↓
5. 结束 → 信号消失
5. 初步见识信号
c
#include <iostream>
#include <unistd.h>
int main() {
while (true) {
std::cout << "test sig..." << getpid() << std::endl;
sleep(1);
}
return 0;
}
通过命令 kill -信号 进程pid 给指定进程发送信号

此时进程776364接收到了信号 -2 做出了指定动作------停止。
函数 signal:
c
#include <signal.h>
// 1. 定义了一个类型:sighandler_t
typedef void (*sighandler_t)(int);
// 2. signal 函数本身
sighandler_t signal(int signum, sighandler_t handler);
作用:给指定的信号,注册一个 "处理函数",告诉内核:当这个信号来的时候,执行我指定的动作。
typedef void (*sighandler_t)(int);这行的意思:定义了一个叫 sighandler_t 的类型 ;它代表:一个函数指针
这个函数的格式是:
- 返回值:
void - 参数:
int(信号编号)
也就是:
c
void handler(int sig);
两个参数
-
int signum信号编号:
SIGINT、SIGTERM...(可以填字母符号,也可以用数字,因为二者一样,是通过宏定义的#define SIGINT 2) -
sighandler_t handler(传一个函数进来)信号处理方式:
SIG_IGN忽略SIG_DFL默认- 自定义函数名
返回值
- 成功:返回原来老的信号处理函数
- 失败:返回
SIG_ERR
函数位置:要写在 main 开头!!!
因为:
- signal 是注册动作,不是调用动作
- 必须先注册,后接收信号
- 如果你写在后面,前面的代码收不到信号
就像:
必须先安装门铃,有人按门铃你才听得见。
代码实例:
c
#include <iostream>
#include <unistd.h>
#include <signal.h>
// signo是被signal中的2传递进来的
void hander(int signo) {
std::cout << "收到了一个信号:" << signo << "执行自定义操作" << std::endl;
}
int main() {
signal(2, hander);
signal(3, hander);
while (true) {
std::cout << "test sig..." << getpid() << std::endl;
sleep(1);
}
return 0;
}


那这么说的话,当我把所有信号都自定义了,都不退出,那进程是不是杀不掉呢?并不是这样,因为其中的9、19号等一些信号不可被自定义!!!
二、信号产生
1. 什么叫「信号产生」?
信号产生 = 操作系统内核创建了一个信号,并准备发给某个进程。此时信号还没被处理,只是出生了。
2. 信号产生的 5 大来源
1)键盘产生(只能控制前台进程)
Ctrl + C→SIGINT(终止信号)Ctrl + \→SIGQUIT(退出信号)Ctrl + Z→SIGTSTP(暂停信号,变后台)
2)硬件异常产生(程序出错)
- 除 0 →
SIGFPE - 非法访问内存(段错误)→
SIGSEGV
3)系统调用 / 函数产生(代码主动发)
kill():发给指定进程
c
int kill(pid_t pid, int sig);
代码实例:
c
//文件 mykill.cpp
#include <iostream>
#include <string>
#include <signal.h>
#include <cstring>
void Usage(const std::string& cmd) {
std::cout << "Usage:" << cmd << " signo pid" << std::endl;
}
// ./mykill signo pid
int main(int argc, char* argv[]) {
if (argc != 3) {
Usage(argv[0]);
exit(1);
}
int signo = std::stoi(argv[1]);
pid_t pid = std::stoi(argv[2]);
int r = kill(pid, signo);
if (r == -1) {
std::cerr << "kill失败:" << strerror(errno) << std::endl;
exit(2);
}
return 0;
}
c
//文件 loop.cpp
#include <iostream>
#include <unistd.h>
int main() {
while (true) {
std::cout << "我是一个进程:" << getpid() << std::endl;
sleep(1);
}
return 0;
}

raise():发给自己
c
int raise(int sig);
// 相当于
kill(getpid(), sig);
代码实例:
c
#include <iostream>
#include <signal.h>
#include <cstring>
void hander(int signo) {
std::cout << "进程捕捉到信号:" << signo << "; 捕捉进程pid:" << getpid() << std::endl;
}
int main() {
signal(2, hander);
while (true) {
std::cout << "进程正在运行:" << getpid() << std::endl;
sleep(2);
int r = raise(2);
if (r == -1) {
std::cerr << "raise失败:" << strerror(errno) << std::endl;
exit(1);
}
}
}

abort():自己崩溃,产生SIGABRT
c
#include <stdlib.h>
void abort(void);
// 完全等价于
kill(getpid(), SIGABRT); //6 (core)
// 也相当于
raise(SIGABRT);
但比 raise 更狠 :会立刻终止程序 ,不会执行后续代码,不会执行析构函数、清理函数,一定会触发 core dump(只要开了 ulimit -c unlimited)。
代码实例:
c
#include <iostream>
#include <signal.h>
#include <cstring>
void hander(int signo) {
std::cout << "进程捕捉到信号:" << signo << "; 捕捉进程pid:" << getpid() << std::endl;
}
int main() {
signal(6, hander);
while (true) {
std::cout << "进程正在运行:" << getpid() << std::endl;
sleep(2);
abort();
}
}

4)软件条件产生
-
管道读端关闭,继续写 →
SIGPIPE -
子进程退出 →
SIGCHLD -
alarm()定时器c#include <unistd.h> unsigned int alarm(unsigned int seconds);时间到 → 内核自动发 SIGALRM → 软件条件产生(不是函数调用产生);SIGALRM 默认动作:终止进程,程序会直接退出。若在倒计时的时间内,又遇到了alarm,会重置倒计时,重置时间为新的alarm的参数。
参数seconds:秒数。> 0:N 秒后发信号;0:取消之前设置的闹钟;
返回值:返回重置时,上一次倒计时剩余的时间。

5)命令产生(shell 里用)
kill 进程号→SIGTERMkill -9 进程号→SIGKILL
信号产生后保存在进程的 task_struct 通过位图 unsigned int sigs; 0000 ... 0000 储存 ;信号产生也就是写信号(修改位图),那么这样修改 task_struct 的操作只能是OS来做的!
3. 不同信号会做什么
命令 man 7 signal 可以查看信号

其中圈出标注的是不同的终止流程: Term 表示进程终止; Core 表示进程退出,核心转储(进程出错,需要检查)为了便于用户调试错误,内存会把错误信息转储到磁盘中(核心转储,为了后续Debug)!


手动开启核心转储功能后:
c
int main() {
// signal(2, hander);
// signal(3, hander);
while (true) {
std::cout << "test sig..." << getpid() << std::endl;
sleep(1);
// 模拟除零
int a = 10;
a /= 0;
}
return 0;
}

(在Ubuntu系统下不会在当前目录下生成core-file文件,不好观察)

**为什么会默认关闭核心转储呢?**因为当软件服务挂掉之后,异常往往非常复杂,会将磁盘占满。
**进程不是挂掉了吗?那Floating point exception (core dumped)这些是谁打出来的?**是bash打印在屏幕上的。因为我们的代码是bash的子进程,代码终止时,会标识 core dump 传递给父进程(bash)。

三、信号保存
1. 相关状态
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之前的状态,称为信号未决(Pending)
- 进程可以选择阻塞某个信号(Block)

2. 信号的存储

3. 信号控制
OS需要用户控制信号,本质就是访问和操作上面的三张表!(操作内核数据结构,就得用系统调用!就像 signal())
pending 叫未决信号集 ;而 阻塞信号集 block 一般叫做信号屏蔽字;
sigset_t 就是一个 "信号集合" 的类型 :它专门用来存放一堆信号 ,本质就是一个位图(bit mask) ,用来表示哪些信号要阻塞、哪些要忽略、哪些要处理 。可以把它理解成:一个开关面板,每一位代表一个信号的开关
c
// 伪代码
struct sigset_t {
unsigned long signals[...]; // 存一堆信号:要不要阻塞、要不要响应
};
4. 相关系统调用
(1)sigprocmask
c
#include <signal.h>
// 成功返回0,失败返回-1并设置errno
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
作用:是阻塞 / 解除阻塞指定信号,让进程临时忽略某些信号(防止被信号中断关键代码)。

how 的 3 种取值
SIG_BLOCK:添加阻塞信号 → 新屏蔽字 = 旧屏蔽字 | set(set 中的信号都会被阻塞)SIG_UNBLOCK:解除阻塞信号 → 新屏蔽字 = 旧屏蔽字 & (~set)(set 中的信号不再阻塞)SIG_SETMASK:直接覆盖屏蔽字 → 新屏蔽字 = set(完全替换原有屏蔽字)
**第2、3个参数都是要自己事先声明出来的!!!**声明出来之后肯定要初始化呀,但是系统自带的类型 sigset_t 怎么初始化?当然有系统函数调用来初始化
(2)sigemptyset(&set)
作用:清空信号集 → 全部变成 0
那初始化之后肯定也要设置将哪一个信号block阻塞吧!
(3)sigaddset(&set, 信号)
作用:往空集合里添加一个你要阻塞的信号
(4)sigpending
c
#include <signal.h>
// 成功返回0,失败返回-1并设置errno
int sigpending(sigset_t *set);
作用:查看 "被pending" 的信号
参数:set 只是输出型参数,获取当前的pending表。
那我们可以获取pending表,那怎么修改pending表呢?其实修改pending表就是产生信号嘛!!!信号产生的5中方法已经讲过了。就像 kill() 之类的操作。
(5)sigismember
作用:判断某个信号在不在信号集里
c
int sigismember(const sigset_t *set, int signum);
返回值:
- 1 → 在里面(真)
- 0 → 不在(假)
- -1 → 出错
5. 代码综合测试
c
#include <iostream>
#include <cstring>
#include <signal.h>
#include <unistd.h>
void PrintPending(const sigset_t pending) {
for (int signo = 31; signo > 0; --signo) {
if (sigismember(&pending, signo)) {
std::cout << "1";
}
else {
std::cout << "0";
}
}
std::cout << std::endl;
}
int main() {
// 1. 屏蔽2号信号
sigset_t block_set, old_set;
sigemptyset(&block_set);
sigemptyset(&old_set);
sigaddset(&block_set, SIGINT);
int r = sigprocmask(SIG_SETMASK, &block_set, &old_set);
if (r == -1) {
std::cerr << "sigprocmask失败:" << strerror(errno) << std::endl;
exit(1);
}
while (true) {
sigset_t pending;
sigemptyset(&pending);
// 2. 获取pending表
int r = sigpending(&pending);
if (r == -1) {
std::cerr << "sigpending失败:" << strerror(errno) << std::endl;
exit(2);
}
// 3. 打印pending表
PrintPending(pending);
sleep(2);
}
return 0;
}
屏蔽了2号信号,但是pending表能接收,所以pending表中会显示000...0010,但是不会终止进程

测试解除信号
c
while (true) {
sigset_t pending;
sigemptyset(&pending);
// 2. 获取pending表
int r = sigpending(&pending);
if (r == -1) {
std::cerr << "sigpending失败:" << strerror(errno) << std::endl;
exit(2);
}
// 3. 打印pending表
PrintPending(pending);
if (cnt == 15) {
// 4. 解除2号信号的屏蔽
int r = sigprocmask(SIG_SETMASK, &old_set, nullptr);
if (r == -1) {
std::cerr << "sigprocmask解除2号屏蔽失败:" << strerror(errno) << std::endl;
exit(3);
}
std::cout << "2号信号屏蔽已解除" << std::endl;
}
++cnt;
sleep(1);
}

我们发现程序最后并没有将2号信号屏蔽已解除 打印到屏幕上,因为一旦信号解除了屏蔽,就会立即被递达,进行处理;所以2好信号直接将进程终止,没有执行之后的代码。那么我们可以修改代码,捕捉2号信号,改为打印内容:
c
void handler(int signo) {
std::cout << "处理了" << signo << "号信号" << std::endl;
}
// 0. 捕捉2号信号
signal(2, handler);

注意:只要被信号解除了屏蔽,pending表中即刻变为0,也就是在调用处理方法前就已经变为0了
四、信号处理
1. 用户态 && 内核态

用户态:进程执行代码,访问数据都在**0, 3GB**地址空间到时候,就是访问用户自己的代码,自己的数据
内核态:都在访问**3GB, 4GB**地址空间的时候,就是访问OS的过程
内核态权限级别更高
2. 信号处理时的转移


处理时刻:就是do_signal()的时刻(分三种情况的时刻)
3. 补充"中断"
(1)当调用 scanf 时,OS怎么知道键盘被按下了?
有没有可能是轮询?不对,效率太低了;其实是通过一种**"硬件中断"**技术;

也就是CPU是很忙的,当外设就绪(键盘写入了),会通知CPU停止正进行的进程并保护现场 ,寄存器保存的就是进程上下文;CPU根据中断信号,去执行中断方法,这些方法都是安排在OS中(如果在硬件层面的话太复杂)的中断向量表中,中断向量表就是一些函数指针的集合,它是OS的一部分,启动就加载到内存中了。执行完毕之后恢复现场,继续之前的进程。
(2)操作系统OS由谁来"调度"?
"时钟中断"
CPU 上安装了一个闹钟,每隔固定时间响一次,强制 CPU 暂停当前进程,转而执行操作系统代码。
进程需要被调度,因为多个进程要竞争CPU;而OS(内核)不需要被调度,因为它本身就是CPU在处理系统调用、中断、异常时执行的代码。CPU从用户态切到内核态,本质上不是"调度OS",而是"开始执行内核代码"。
(3)为什么OS能计算时间?
OS 能计算时间,不是因为它自己有时间概念,而是因为硬件定时器会周期性地产生时钟中断,OS 通过统计这些中断次数来计算时间。
(4)什么是时间片?
OS 通过硬件定时器产生的时钟中断来统计进程已经运行了多久,当发现当前进程占用 CPU 达到一定额度时,就触发调度,把 CPU 切换给其他进程,这个额度就是时间片。
(5)什么叫时间片耗尽?
c
struct task_struct {
// 1. 时间片 → 就在这里!
int time_slice; // <--- 剩余时间片(核心!)
// 2. 进程状态
volatile long state; // 运行/就绪/睡眠...
// 3. 优先级、调度类等
// ...
};
当进程运行时:
- 时钟中断到来(每 10ms 一次)
- 内核执行:
current->time_slice--;当前进程剩余时间片 -1 - 直到:
current->time_slice == 0这就叫:时间片耗尽!
时间片耗尽后,内核立刻调用 schedule() 切换进程
c
if (current->time_slice == 0) {
// 1. 触发调度
schedule();
}
(6)CPU主频是什么?
主频 = CPU 内部时钟脉冲频率,1GHz = 109次 / 秒
和时钟中断、时间片区分
- CPU 主频:CPU 芯片本身运行快慢(微观:指令执行速度)
- 系统时钟 (HZ,100Hz) :主板定时器,10ms 一次时钟中断(宏观:操作系统节拍、扣时间片)
时钟发生器:3.2GHz;
时钟计数器(系统可配置):值设置成3.2GHz->时钟中断为1s触发;设置成3.2GHz/10->时钟中断为100ms触发。
时钟发生器+时钟计数器 = 时钟源
(7)系统调用
用户程序想调用内核 → 必须用软中断跳进去!
在内核层面,每一个系统调用,都是一个系统调用号!也就是数组下标

tex
用户程序
↓
调用系统调用(read、sigprocmask...)
↓
触发 软中断 (int 0x80 / syscall)
↓
CPU 切换到 内核态
↓
内核执行服务
↓
返回用户态
C 函数封装真正的系统调用
c
sigprocmask(...);
signal(...);
fork();
这些都不是 "真正的系统调用"!
它们只是:C 语言库(glibc)给你包的一层 "壳函数"
系统调用是内核提供的功能编号
c
// 伪代码
int sigprocmask(...) {
把参数放进寄存器
把系统调用号 120 放进寄存器
执行 **软中断指令 int 0x80** // 进入内核!
}
(8)操作系统的本质
操作系统就像是一个被各种中断、异常、系统调用不断唤醒的躺在那的代码块。
软中断:比如int 0x80或者syscall等,叫做"陷阱"
软中断:比如除零/野指针等,叫做"异常"
(9)用户态 (Ring3)↔内核态 (Ring0)
用户态 = 受限制的普通程序(你的代码)
内核态 = 拥有最高权限的操作系统(管家)
CS = Code Segment 代码段寄存器 ,它是CPU 硬件级的寄存器
CS 寄存器的 最低 2 位 = CPL (Current Privilege Level)表示当前 CPU 处于什么态!
- 00 → Ring 0(内核态)
- 11 → Ring 3(用户态)
调用系统调用、发生中断时:CPU 硬件自动把 CS 最低两位从 11 → 00(不是软件改的,是硬件强制改!)

4. sigaction
c
int sigaction(int signum, // 你要捕捉哪个信号
const struct sigaction *act, // 新的处理方式
struct sigaction *oldact); // 保存旧的处理方式(输出)
// 返回值:成功 → 0;失败 → -1
作用:注册一个信号处理函数,当信号来的时候:
- 不执行默认动作
- 执行你自己写的函数
- 自带屏蔽信号、防止重入等高级功能
核心结构体 struct sigaction 彻底解析
c
// 老样子,得先自己声明一个结构体变量,再给这个变量赋值
struct sigaction {
void (*sa_handler)(int); // 信号处理函数
sigset_t sa_mask; // 信号屏蔽集
int sa_flags; // 行为标志,不管
void (*sa_restorer)(void); // 废弃,不用管
};
① sa_handler ------ 处理函数指针
三种取值:
SIG_IGN→ 忽略信号SIG_DFL→ 使用默认动作- 自己写的函数 → 自定义捕捉
c
act.sa_handler = my_handler;
② sa_mask ------ 信号屏蔽集(超级重要)
作用:屏蔽 sa_mask 里的所有信号(屏蔽额外的,不设置时默认一定会屏蔽自身),防止被嵌套打断。
补充 :当某个信号的处理函数被调用时,内核自动将当前进程信号加入到信号屏蔽字,当信号处理函数返回时,自动恢复原来的信号屏蔽字。这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。防止信号处理函数被 "嵌套打断",保证它能完整、安全、不被干扰地执行完!
使用步骤:
c
sigemptyset(&act.sa_mask); // 先清空
sigaddset(&act.sa_mask, SIGINI); // 处理信号时屏蔽 SIGINI
五、可重入函数
什么是重入函数?假如一个函数 insert(),main函数在调用insert,一个信号来了,执行handler,也调用insert,两个执行流都执行insert导致出了问题,那么insert() 函数就是不可重入函数 ;一些函数也发生这样的多执行流同时执行时,但是没有影响,那就是可重入函数。
不可重入函数:一般是用了内存管理,比如malloc、free或者I/O的库函数(标准I/O库的很多实现都以不可重入的方式使用全局数据结构)。
六、volatile关键字
volatile = 告诉编译器:这个变量随时可能被意外改变 ,不许优化,每次都必须去内存里读最新值!
c
// 全局变量,被信号和 main 同时访问
int flag = 0;
void handler(int sig) {
flag = 1; // 信号来了,把 flag 改成 1
}
int main() {
// 注册信号...
while (flag == 0) {
// 死等信号
}
printf("退出循环\n");
}
不加 volatile 会发生什么灾难?
编译器一看:
flag在 main 里根本没被修改!我帮你优化成:直接读寄存器,不用去内存读!
结果:
就算信号把 flag 改成 1,main 循环也永远看不见!→ 死循环卡死!
c
// 修改代码
volatile int flag = 0;
作用:
- 告诉编译器:这个变量会被别人偷偷改(信号 / 硬件 / 其他线程)
- 不许优化!
- 每次使用都必须去内存里重新读取最新值!
七、SIGCHLD信号
SIGCHLD:子进程退出时,内核发给父进程的信号
作用 :通知父进程来回收子进程尸体
标准做法:
- 用
sigaction注册 - 处理函数里
while(waitpid(...WNOHANG)) - 彻底杜绝僵尸进程
代码示例:
c
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>
// 信号处理函数:回收僵尸子进程
void handler(int sig) {
// -1,循环回收所有退出的子进程
// WNOHANG = 非阻塞,没有僵尸就立刻返回
while (waitpid(-1, NULL, WNOHANG) > 0);
}
int main() {
// 1. 注册 SIGCHLD 捕捉
struct sigaction act;
act.sa_handler = handler;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGCHLD, &act, NULL); // 监听子进程死亡
// 2. 创建子进程
pid_t pid = fork();
if (pid == 0) {
// 子进程
printf("我是子进程 %d,马上退出\n", getpid());
sleep(1);
exit(0); // 子进程退出 → 发 SIGCHLD
}
// 父进程继续干自己的活,不用死等 wait
while (1) {
printf("父进程干活中...\n");
sleep(1);
}
return 0;
}