1. 信号的概念
1.1 什么是信号?
信号是一种软件中断机制,是 Linux 系统提供的让用户(进程)给其他进程发送异步事件通知的一种方式,用于进程间通信或通知进程发生了特定事件(如硬件异常、软件条件等)。
注意:信号与之前 System V 的"信号量"没有关系,两者是完全不同的概念。
信号是一种异步通知机制,被广泛认为是进程间通信方式之一,但与管道、共享内存等数据传输型 IPC 有本质区别。
生活中处处有信号:
信号可以中断正在做的事,让你转而处理新的事件。
1.2 同步 vs 异步
信号的产生是异步的——信号的到来不会阻塞当前进程的执行流,进程继续做自己的事,在合适的时候再处理信号。
1.3 信号的识别与处理
即使闹钟还没响,我们也知道"闹钟响要起床"。同理,进程识别信号的能力是内置的——这是内核程序员为进程预设的特性。信号的处理方式,在信号产生之前就已经准备好了:
三种处理方式
1.4 进程如何看待信号?
进程具有识别并处理不同信号的能力。在进程启动时,OS 会为其分配一个信号处理表(handler 表,本质是函数指针表),用来记录每个信号对应的处理函数。信号的识别方式正是通过这张表。
1.5 信号的保存:pending 位图
当信号产生时,如果进程正在处理更重要的事情(如处于临界区或执行不可中断操作),暂时不能处理到来的信号。为了确保信号不会丢失,OS 会将这个信号暂时保存起来。
这种暂时保存是通过进程控制块(task_struct)中的 pending 位图来记录的:
-
pending 位图用来记录哪些信号已经被发送但尚未处理
-
位图中的每一位对应一个信号
-
如果该位为
1,表示该信号已经发生但尚未处理 -
一旦信号被处理完毕,OS 将此位从位图中清除(由
1置为0)
1.6 信号何时被处理?
信号产生时,如果进程正在内核态执行,一般不会立即处理。而是等从内核态切换回用户态之前,进行检查:
内核态 → 返回用户态之前 → 检查是否有未处理的信号?
├── 有,且满足处理条件 → 处理信号
└── 无 → 正常返回用户态
1.7 信号的异步特性
信号产生的根本原因:系统需要确保进程能够随时响应外部事件,并及时作出相应的处理。
2. 信号的产生
2.1 方式一:键盘产生
$ ./main
我一个死循环,请发信号中断我0
我一个死循环,请发信号中断我1
^C # 按下 Ctrl+C → 进程终止
Ctrl+C 实际上是向前台进程发送了 SIGINT(2 号信号),默认动作为终止进程。
查看信号列表
$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
...
31) SIGSYS 34) SIGRTMIN ... 64) SIGRTMAX
-
1~31:普通信号(非实时信号)
-
34~64:实时信号
普通信号在递达之前产生多次只计一次,实时信号可以依次放入队列中。本节重点讲解 1~31 号普通信号。
更改进程处理信号的默认行为 — signal 函数
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
-
功能:设置进程对信号
signum的处理方式。 -
参数:
-
signum:信号编号。 -
handler:自定义处理函数,或SIG_IGN(忽略)、SIG_DFL(恢复默认)。
-
-
返回值:返回原来的信号处理函数指针,失败返回
SIG_ERR。
示例:
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handerSig(int sig)
{
std::cout << "收到了一个信号:" << sig << std::endl;
}
int main()
{
signal(SIGINT, handerSig); // 等价于 signal(2, handerSig)
int cnt = 0;
while (1)
{
std::cout << "我一个死循环,请发信号中断我" << cnt++ << std::endl;
sleep(1);
}
return 0;
}
运行结果:
$ ./main
我一个死循环,请发信号中断我0
^C收到了一个信号:2 # Ctrl+C 不再终止进程,而是打印信息
我一个死循环,请发信号中断我1
^C收到了一个信号:2
^\Quit (core dumped) # 但可以用 Ctrl+\ (SIGQUIT) 终止
SIGKILL 和 SIGSTOP:不可被捕捉的信号
为什么不可被捕捉、忽略或阻塞?
这是内核的硬性规定,目的是保留系统对进程的终极控制权:
SIGKILL是操作系统给管理员预留的"最终审判权"——kill -9必定生效,这是内核的安全底线。
信号的默认处理动作
查看每个信号的默认动作:man 7 signal
进程接收到信号后默认处理动作为终止时,有两种常见方式:
注意:云服务器为了节省磁盘空间,默认关闭 core dump 功能。
相关操作:

将来写程序时如果找不到 bug,可以开启 core dump,用 GDB 的
core-file命令定位错误位置进行事后调试。waitpid的status参数中也有一个 bit 位(通过WCOREDUMP宏检查)专门表示子进程是否产生了 core dump。
键盘产生的信号汇总
前台进程与后台进程
- 前台进程:main是前台进程,shell自动变成了后台进程
- ./XXX---前台进程
xqq@ubuntu-server:~/linux/module5$ ./main
我一个死循环,请发信号中断我0
^C收到了一个信号:2
我一个死循环,请发信号中断我1
^C收到了一个信号:2
我一个死循环,请发信号中断我2
我一个死循环,请发信号中断我3
ls ---->收到命令没反应
我一个死循环,请发信号中断我4
我一个死循环,请发信号中断我5
pwd
我一个死循环,请发信号中断我6
^\Quit (core dumped)
- 后台进程:main是后台进程,shell还是前台进程
- ./YYY &--后台进程
xqq@ubuntu-server:~/linux/module5$ ./main &
[1] 69718
xqq@ubuntu-server:~/linux/module5$ 我一个死循环,请发信号中断我0
我一个死循环,请发信号中断我1
ls--->可以执行命令
main main.cc
xqq@ubuntu-server:~/linux/module5$ 我一个死循环,请发信号中断我2
我一个死循环,请发信号中断我3
pwd
/home/xqq/linux/module5
xqq@ubuntu-server:~/linux/module5$ 我一个死循环,请发信号中断我4
我一个死循环,请发信号中断我5
我一个死循环,请发信号中断我6
我一个死循环,请发信号中断我7
我一个死循环,请发信号中断我8
我一个死循环,请发信号中断我9
我一个死循环,请发信号中断我10
我一个死循环,请发信号中断我11
我一个死循环,请发信号中断我12
^C
xqq@ubuntu-server:~/linux/module5$ 我一个死循环,请发信号中断我13
我一个死循环,请发信号中断我14
[1]+ Killed ./main
可以看到,命令可以正常运行并且ctrl+c无法正常接收到信号,前台进程shell和后台进程main都向终端打印,所以有错位,这也证明了linux一切皆文件,好比两个进程都向显示器打印,所以显示器是共享资源,混在一起打印叫做数据不一致。
而我们的shell进程默认就是前台进程,后台进程无法处理信号无法从标准输入获取内容,而前台进程可以获取标准输入也就是说键盘产生的信号只能给前台,但是都可以向标准输出上打印,前台进程只有一个,后台进程可以有多个。因为前台进程就是要向键盘获取数据的,而键盘只有一个,输入数据一定是给一个确定的进程的。怎么终止后台进程:kill -signal 进程pid,这也就是向该进程发送某个信号
总结:
键盘产生的信号只能发给前台进程。因为前台进程就是要向键盘获取数据的,而键盘只有一个,输入数据一定是给一个确定的进程的。
补充命令:
示例:
xqq@ubuntu-server:~/linux/module5$ ./main
我一个死循环,请发信号中断我0
我一个死循环,请发信号中断我1
我一个死循环,请发信号中断我2
^Z
[1]+ Stopped ./main
xqq@ubuntu-server:~/linux/module5$ jobs
[1]+ Stopped ./main
^后台进程编号:1 状态Stopped
将后台暂停进程继续在后台运行
xqq@ubuntu-server:~/linux/module5$ bg 1
[1]+ ./main &
我一个死循环,请发信号中断我3
xqq@ubuntu-server:~/linux/module5$ 我一个死循环,请发信号中断我4
我一个死循环,请发信号中断我5
我一个死循环,请发信号中断我6
我一个死循环,请发信号中断我7
我一个死循环,请发信号中断我8
所以目标进程就是我们自己启动的前台进程
2.2 方式二:系统调用
kill — 向任意进程发送任意信号
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
-
功能:向指定进程发送指定信号。
-
参数:
-
pid:目标进程的 PID。 -
sig:要发送的信号编号。
-
-
返回值:成功返回
0,失败返回-1。
命令行
kill命令底层封装的就是kill系统调用。
案例:实现 mykill 命令
#include <iostream>
#include <sys/types.h>
#include <signal.h>
#include <string>
// use: ./mykill signal pid
int main(int argc, char *argv[])
{
if (argc != 3)
{
std::cerr << "use: ./mykill signal pid" << std::endl;
return 1;
}
int signal = std::stoi(argv[1]);
pid_t target_id = std::stoi(argv[2]);
int n = kill(target_id, signal);
if (n == 0)
{
std::cout << "send " << signal << " to " << target_id << " success" << std::endl;
}
return 0;
}
运行结果:
raise — 向当前进程发送信号
#include <signal.h>
int raise(int sig);
-
功能:给当前进程发送任意信号。等价于
kill(getpid(), sig)。 -
参数:
sig— 信号编号。 -
返回值:成功返回
0,失败返回非0值。
示例:
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handerSig(int sig)
{
std::cout << "收到了一个信号:" << sig << std::endl;
}
int main()
{
for (int i = 0; i < 32; i++)
signal(i, handerSig); // 9/19 号信号不能被捕捉
for (int i = 0; i < 32; i++)
{
sleep(1);
raise(i);
}
int cnt = 0;
while (1)
{
std::cout << "我一个死循环,请发信号中断我,pid:" << getpid() << " " << cnt++ << std::endl;
sleep(1);
}
return 0;
}
运行结果:
$ ./TestRaise
收到了一个信号:1
收到了一个信号:2
...
收到了一个信号:8
Killed # 9 号信号 SIGKILL 直接终止进程
abort — 终止进程并生成 core dump
#include <stdlib.h>
void abort(void);
-
功能:终止当前进程,向自身发送
SIGABRT(6 号信号),生成 core dump。
示例:
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handerSig(int sig)
{
std::cout << "收到了一个信号:" << sig << std::endl;
}
int main()
{
for (int i = 0; i < 32; i++)
signal(i, handerSig);
int cnt = 0;
while (1)
{
abort();
std::cout << "我一个死循环,请发信号中断我,pid:" << getpid() << " " << cnt++ << std::endl;
sleep(1);
}
return 0;
}
运行结果:
$ ./TestAbort
收到了一个信号:6
Aborted (core dumped) # 终止进程,生成 core dump
2.3 方式三:硬件异常产生信号
硬件异常被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。
示例代码:
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handerSig(int sig)
{
std::cout << "收到了一个信号:" << sig << std::endl;
exit(13);
}
int main()
{
for (int i = 0; i < 32; i++)
signal(i, handerSig);
int cnt = 0;
while (1)
{
std::cout << "我一个死循环,请发信号中断我,pid:" << getpid() << " " << cnt++ << std::endl;
int a = 10;
// a /= 0; // 除零错误 → SIGFPE (8)
int *p = nullptr;
*p = 100; // 野指针 → SIGSEGV (11)
sleep(3);
}
return 0;
}
1. 除零错误 → SIGFPE(8 号信号)
运行结果:
xqq@ubuntu-server:~/linux/module5$ ./main
我一个死循环,请发信号中断我,pid:745230
收到了一个信号:8--》SIGFPE(signal flaot point excepetion浮点数错误)
流程:
CPU 执行除法指令,除数为 0
→ CPU 检测到除零异常,更新 EFLAGS 寄存器标志位
→ OS 识别到标志位变化,捕获异常
→ OS 向进程发送 SIGFPE(8 号信号)
2. 野指针 → SIGSEGV(11 号信号)
运行结果:
xqq@ubuntu-server:~/linux/module5$ ./main
我一个死循环,请发信号中断我,pid:74959 0
收到了一个信号:11--》SIGSEGV(signal segement fault段错误)
流程:
进程访问非法虚拟地址
→ MMU 将虚拟地址转化为物理地址失败(无页表项或权限不匹配)
→ MMU 触发页面错误异常
→ CPU 将错误地址存入 cr2 寄存器,设置 EFLAGS 标志位
→ OS 捕获异常,向进程发送 SIGSEGV(11 号信号)
这也就解释了为什么我们的程序有时候会崩溃——就是因为进程收到了信号。CPU/MMU 检测硬件异常 → OS 解释为信号 → 发送给进程。
2.4 方式四:软件条件
如何理解软件条件?
在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产生机制。这些条件包括但不限于:
-
定时器超时(如
alarm函数设定的时间到达) -
软件异常(如向已关闭的管道写数据产生的
SIGPIPE信号)
当这些软件条件满足时,OS 会向相关进程发送相应的信号,以通知进程进行相应的处理。简而言之,软件条件是因 OS 内部或外部软件操作而触发的信号产生机制。
示例:管道破裂 — SIGPIPE
在管道通信中,当管道的读端关闭后,写端进程如果继续写入,就会收到 SIGPIPE(13 号信号)。这就是典型的软件条件——因为读端被关闭,写条件不满足,于是 OS 终止写进程。
SIGALRM 信号
一、概念
SIGALRM(14 号信号)是由软件条件触发的信号。产生条件:通过调用 alarm 函数来设置一个定时器,在指定秒数后定时器到期,OS 会给当前进程发送 SIGALRM 信号,其默认行为是终止进程。
二、系统调用接口 alarm
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
-
功能:设置一个定时器,
seconds秒后 OS 向当前进程发送SIGALRM信号。 -
参数:
seconds— 定时秒数。若为0,表示取消之前设置的定时器。 -
返回值:
-
调用成功,如果之前已有一个定时器在运行,则返回之前定时器的剩余秒数,并且alarm函数会取消之前的定时器,用新的定时器代替。
-
调用成功,如果之前没有定时器,则返回
0。 -
调用失败,返回
UINT_MAX并设置errno。
-
alarm是一次性的。如果想让其每隔指定秒数触发,需要在信号处理函数中重新调用alarm。
示例 1:无 IO 操作
#include <iostream>
#include <unistd.h>
#include <signal.h>
int cnt = 0;
void handerSig(int sig)
{
std::cout << "收到了一个信号:" << sig << " cnt:" << cnt << std::endl;
exit(13);
}
int main()
{
for (int i = 0; i < 32; i++)
signal(i, handerSig);
alarm(1); // 设定 1 秒闹钟
while (true)
{
cnt++; // 无 IO,计数极快
}
return 0;
}
运行结果:
$ ./alarm
收到了一个信号:14 cnt:580746318 # 1 秒内 cnt 累加到 5.8 亿
$ echo $?
13
示例 2:有 IO 操作
while (true)
{
std::cout << "cnt:" << cnt++ << std::endl; // IO 慢,cnt 增长慢
}
运行结果:
$ ./alarm
cnt:0
...
cnt:96930
收到了一个信号:14
示例 3:周期性定时(在信号处理函数中重新设置)
void handerSig(int sig)
{//执行信号处理时同一个进程
std::cout << "收到了一个信号:" << sig << " pid:" << getpid() << std::endl;
alarm(1); // 重新设定下一轮
}
int main()
{
signal(SIGALRM, handerSig);
alarm(1);
while (true)
{
std::cout << "x pid:" << getpid() << std::endl;
sleep(1);
}
return 0;
}
$ ./alarm
x pid:82199
收到了一个信号:14 pid:82199
x pid:82199
收到了一个信号:14 pid:82199
...
三、pause 接口
#include <unistd.h>
int pause(void);
-
功能:使调用进程挂起(进入睡眠状态),直到收到一个信号。
-
返回值:始终返回
-1,并设置errno为EINTR(被信号中断)。
示例:定时任务调度器(pause 版本)
////////func//////////
void Sched()
{
std::cout << "我是进程调度任务" << std::endl;
}
void MemManager()
{
std::cout << "我是周期性内存管理,正在检查是否存在内存问题" << std::endl;
}
void Fflush()
{
std::cout << "我是刷新程序,我正在定期刷新内存数据" << std::endl;
}
/////////////////////
using func_t = std::function<void()>;//新语法相当于typedef,功能更强大
std::vector<func_t> funcs;
void handerSig(int sig)
{
std::cout << "######################" << std::endl;
for (auto f : funcs)
{
f();
}
std::cout << "######################" << std::endl;
std::cout << "收到了一个信号:" << sig << " pid:" << getpid() << std::endl;
alarm(1); // 重新设定
}
int main()
{
funcs.push_back(Sched);
funcs.push_back(MemManager);
funcs.push_back(Fflush);
signal(SIGALRM, handerSig);
alarm(1); // 1 秒后发送信号
while (true)
{
pause(); // 进程挂起,信号到来时唤醒并执行 handler
}
return 0;
}
$ ./alarm
######################
我是进程调度任务
我是周期性内存管理,正在检查是否存在内存问题
我是刷新程序,我正在定期刷新内存数据
######################
收到了一个信号:14 pid:83056
######################
我是进程调度任务
我是周期性内存管理,正在检查是否存在内存问题
我是刷新程序,我正在定期刷新内存数据
######################
收到了一个信号:14 pid:83056
######################
我是进程调度任务
我是周期性内存管理,正在检查是否存在内存问题
我是刷新程序,我正在定期刷新内存数据
######################
收到了一个信号:14 pid:83056
...
pause + alarm vs sleep + 循环
pause版本更优雅:进程平时睡觉(pause),闹钟一响(SIGALRM)起来干活,干完重设闹钟继续睡。CPU 零消耗,定时更稳定。
四、如何理解闹钟?
OS 需要管理大量的定时任务,如:定期将数据从内核缓冲区刷新到外设,或执行其他需要定时控制的任务。alarm函数是一个系统调用接口,是用户空间和操作系统内核交互设置定时任务的一种方式,而在OS内部,必然存在大量的定时器,每个进程都可以设置自己的闹钟,有的闹钟刚刚设定,有的还在时间内,有的要超时了所以os里面存在很多状态不同的闹钟,所以要对闹钟管理,怎么管理
先描述,再组织:判断当前时间是否超过了定时器的超时时间
SIGALRM被视为软件条件,是因为它由 OS 内部的软件逻辑(定时器的管理、检查和超时处理)所控制,而不是由外部硬件事件直接触发。
内核中的定时器数据结构:
struct timer_list {
struct list_head entry;
unsigned long expires; // 超时时间
void (*function)(unsigned long); // 超时处理函数
unsigned long data;
struct tvec_t_base_s *base;
};
操作系统管理定时器,采用的是时间轮的做法(复杂场景),简单理解可以视为堆结构。
3. 信号的保存
当信号产生时,如果进程正在处理更重要的事情(如:处于临界区或执行不可中断操作),而暂时不能处理到来的信号,为了确保信号不会丢失,OS会将这个信号暂时保存起来,那我们就来看看内核中如何保存信号。
3.1 信号相关常见概念
阻塞和忽略是不同的:只要信号被阻塞就不会递达;而忽略是递达之后可选的一种处理动作。形象来说,忽略是视而不见,阻塞是看不到。
3.2 这些概念在内核中的表示
task_struct是Linux内核用于描述进程的数据结构,与信号相关的信息主要存储在task_struc的signal字段中。

内核数据结构
// 内核结构 2.6.18
struct task_struct {
...
/* signal handlers */
struct sighand_struct *sighand; // 信号处理表(handler 表)
sigset_t blocked; // 阻塞信号集(block 表)
struct sigpending pending; // 未决信号集(pending 表)
...
};
struct sighand_struct {
atomic_t count;
struct k_sigaction action[_NSIG]; // #define _NSIG 64
spinlock_t siglock;
};
struct __new_sigaction {
__sighandler_t sa_handler; // 信号处理函数指针
unsigned long sa_flags;
void (*sa_restorer)(void);
__new_sigset_t sa_mask;
};
struct k_sigaction {
struct __new_sigaction sa;
void __user *ka_restorer;
};
typedef void (*__sighandler_t)(int);
struct sigpending {
struct list_head list;
sigset_t signal; // pending 位图
};
三张核心表:
① block 位图(信号屏蔽字)
存储当前进程阻塞(屏蔽)的信号集合。
-
比特位的位置 → 信号编号
-
比特位的内容 → 是否被阻塞。
1表示该信号被阻塞。 -
当一个信号被阻塞时,即使该信号被发送给进程,进程也不会立即处理它,而是将其暂时保存起来,直到进程解除对此信号的阻塞。
② pending 位图(未决信号集)
存储当前进程待处理的信号集合。
-
比特位的位置 → 信号编号
-
比特位的内容 → 是否收到信号。
1表示该信号已被发送但尚未处理。 -
当一个信号被发送时,它会被添加到此集合中,对应比特位由
0变为1。 -
直到信号递达,OS 才会将此信号从 pending 位图中清除(细节:pending 位图先被清零,然后才递达)。
-
普通信号在递达之前产生多次只计一次,实时信号在递达之前产生多次可以依次放入队列中。
③ handler 函数指针数组
存储信号处理函数的地址。可以理解为 sighander_t handler[31]。
-
数组下标 → 信号编号
-
数组的内容 → 信号递达时的处理动作,包括
SIG_DFL(默认)、SIG_IGN(忽略)、自定义捕捉函数。
当一个信号被发送、且未被阻塞、在合适的时候需要处理时,OS 根据信号编号在此数组中找到对应的处理函数,并调用该函数来处理信号。
signal(signum, handler)的本质就是:用signum作为 handler 表的下标,将新的函数指针覆盖掉原本的函数指针。
信号的本质:修改 pending 位图
发送信号 = 向目标进程写信号 = 修改 pending 位图
修改 pending 位图需要进程 PID 和信号编号。而 pending 位图是操作系统内核数据结构对象,修改内核数据的能力只有 OS 拥有。所以不管信号怎么产生(键盘、系统调用、硬件异常、软件条件),发送信号在底层必须是 OS 发送。因此 OS 提供了系统调用接口,也就有了 kill、raise、abort 等接口。
哪些信号可以递达?
可以递达的信号 = pending & (~block)
即:信号必须处于 pending 状态(已产生),且未被阻塞(block 位为 0),才能被递达。
三张表的关系:
三张表解释了为什么没有信号传递就知道怎么处理,以及进程识别信号的方法。系统调用(包括之前的
signal)就是围绕这三张表展开的。
示例:自定义捕捉后恢复默认
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handerSig(int sig)
{
std::cout << "收到了一个信号:" << sig << std::endl;
signal(SIGINT, SIG_DFL); // 恢复默认处理
std::cout << "恢复处理动作" << std::endl;
}
int main()
{
signal(SIGINT, handerSig); // 自定义捕捉
while (true)
{
sleep(1);
std::cout << "x" << std::endl;
}
return 0;
}
运行结果:
$ ./main
x
^C收到了一个信号:2
恢复处理动作
x
x
^C
$ # 第二次 Ctrl+C → SIG_DFL → 进程终止
第一次
Ctrl+C:执行自定义函数,并恢复为默认动作。第二次Ctrl+C:直接终止进程。
3.3 sigset_t — 信号集
从上图来看,每个信号只有一个 bit 的未决标志,非 0 即 1,不记录该信号产生了多少次。阻塞标志也是这样表示的。
因此,未决和阻塞标志可以用相同的数据类型 sigset_t 来存储,sigset_t 称为信号集。在阻塞信号集中"有效"表示该信号被阻塞;在未决信号集中"有效"表示该信号处于未决状态。
阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的"屏蔽"应理解为阻塞而不是忽略,类似于权限掩码。
内核实现:
typedef __sigset_t sigset_t;
#define _SIGSET_NWORDS (1024 / (8 * sizeof(unsigned long int)))
typedef struct {
unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;
位图访问示意:
假设 struct bits { int bitmap[10]; }; // 32*10=320 个 bit
访问第 39 个 bit:
int index = 39 / 32 = 1; // bitmap[1]
int pos = 39 % 32 = 7; // bitmap[1] 的第 7 位
3.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 所有位置 1(包含所有信号)
int sigaddset(sigset_t *set, int signo); // 将 set 中 signo 对应的位置 1(添加信号)
int sigdelset(sigset_t *set, int signo); // 将 set 中 signo 对应的位清零(删除信号)
int sigismember(const sigset_t *set, int signo); // 判断 signo 是否在 set 中
-
返回值:
sigemptyset、sigfillset、sigaddset、sigdelset成功返回0,出错返回-1。 -
sigismember:布尔函数,若包含则返回1,不包含返回0,出错返回-1。
注意:在使用
sigset_t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。
3.5 sigprocmask — 更改进程的信号屏蔽字
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
-
功能:读取或更改进程的信号屏蔽字(阻塞信号集 block 表)。
-
参数:
-
how:指定如何更改,见下表。 -
set:非空指针时,按how更改信号屏蔽字。 -
oset:非空指针时,将原来的信号屏蔽字备份到oset中(输出型参数)。
-
-
返回值:成功返回
0,失败返回-1。
假设当前的信号屏蔽字为 mask,how 参数的取值:
我们比较喜欢
SIG_SETMASK,因为mask = set直接覆盖,不用计算。如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。同理,为了防止恶意程序,9 号(SIGKILL)和 19 号(SIGSTOP)信号不可被屏蔽。
3.6 sigpending — 获取未决信号集
#include <signal.h>
int sigpending(sigset_t *set);
-
功能:获取当前进程的 pending 位图(未决信号集),存入
set中。只读,不修改。 -
返回值:成功返回
0,失败返回-1。
sigpending只提供读取功能,不提供修改功能。因为 pending 位图由五种信号产生机制自动修改,这个函数不需要再提供修改方法。
3.7 完整验证实验
void handerSig(int sig)
{
std::cout << "递达信号:" << sig << std::endl;
}
void PrintPending(sigset_t &peding)
{
std::cout << "我是一个进程pid:" << getpid() << " pending:";
for (int signal = 31; signal >= 1; signal--)
{
if (sigismember(&peding, signal))
std::cout << "1";
else
std::cout << "0";
}
std::cout << std::endl;
}
int main()
{
// 可选:自定义处理,避免解除屏蔽后进程退出
// signal(SIGINT,handerSig);
// 屏蔽2号信号
sigset_t set, oldset;
sigemptyset(&set);
sigemptyset(&oldset);
// int n = sigaddset(&set, SIGINT); // 将用户的set的第二个bit位置1
int n = -1;
for (int i = 1; i < 32; i++)
{
n = sigaddset(&set, i);
if (n < 0)
{
perror("sigaddset fail");
return 1;
}
}
n = sigprocmask(SIG_SETMASK, &set, &oldset); // 设置到block表里,并进行备份
if (n < 0)
{
perror("sigprocmask fail");
return 1;
}
// 4.重复获取,打印过程
// int cnt=0;
while (true)
{
// 2.获取pending信号集合
sigset_t peding;
sigemptyset(&peding);
n = sigpending(&peding);
if (n < 0)
{
perror("sigpending fail");
return 1;
}
// 3.打印
PrintPending(peding);
// 可选:10s后解除信号屏蔽,观察信号抵达过程
// if(cnt==10)//信号抵达过程
// {
// //5.恢复2号信号的block情况
// std::cout<<"解除对2号信号屏蔽"<<std::endl;
// sigprocmask(SIG_SETMASK, &oldset,nullptr);
// }
// cnt++;
sleep(1);
}
return 0;
}
运行结果:

实验验证了信号全链路:
信号产生 → 检查 block 表
├─ 未屏蔽 → 直接递达 → pending 位清零 → 执行 handler
└─ 被屏蔽 → 进入 pending 位图(置 1)
└─ 解除屏蔽 → 递达 → pending 位清零
关键结论:pending 位在信号递达的那一刻清零,然后才执行处理函数。不是"处理完才清",而是"决定递达就清"。POSIX.1 允许信号在递达之前产生多次,在 Linux 中,普通信号在递达之前产生多次只计一次,而实时信号可以依次放入队列中。
4. 信号捕捉
信号产生后不会立即处理,而是先保存(pending),在合适的时候处理。那么什么叫"合适的时候"?信号是怎么被处理的?这就是本章要讲的内容。
4.1 用户态与内核态
4.1.1 CPU 指令集与权限分级
CPU 指令集是 CPU 实现软件指挥硬件执行的媒介。每一条汇编语句都对应一条 CPU 指令,大量 CPU 指令在一起组成指令集。
CPU 指令集有权限分级。指令集可以直接操作硬件,如果因为指令操作不规范造成错误,会影响整个计算机系统。设想一下:如果你写程序时因为对硬件操作不熟悉,导致操作系统内核及其他所有正在运行的程序都因操作失误而受到不可挽回的错误,最终只能重启计算机。这对开发人员来说是艰巨的任务,会增加负担,同时开发人员在这方面也不被信任——所以操作系统内核直接屏蔽开发人员对硬件操作的可能,根本不让你碰到这些 CPU 指令集。因此,硬件设备商对 CPU 指令集设置了权限级别。以 Intel CPU 为例,权限由高到低划分为 4 级:
Linux 系统仅使用 ring 0 和 ring 3 两个权限。CPU 中有一个标志字段(CS 寄存器的低 2 位,即 CPL),标志着线程的运行状态——用户态为 3,内核态为 0。
-
ring 0 被叫做内核态,完全在操作系统内核中运行。执行内核空间的代码,具有对硬件的所有操作权限,可以执行所有 CPU 指令集,访问任意地址的内存。在内核模式下的任何异常都是灾难性的,将会导致整台机器停机。
-
ring 3 被叫做用户态,在应用程序中运行。代码没有对硬件的直接控制权限,也不能直接访问地址的内存,程序通过调用系统接口(System Call APIs)来达到访问硬件和内存。在这种保护模式下,即使程序发生崩溃也是可以恢复的。电脑上大部分程序都是在用户模式下运行的。
用户态与内核态的概念,本质上就是 CPU 指令集权限的区别。
4.1.2 内存空间的划分
在内存资源的使用上,操作系统对用户态与内核态也做了限制。以 Linux 32 位操作系统为例,寻址空间范围是 4GB(2 的 32 次方),操作系统将虚拟地址空间划分为两部分:
-
高位的 1GB(从虚拟地址 0xC0000000 到 0xFFFFFFFF)由内核使用
-
低位的 3GB(从虚拟地址 0x00000000 到 0xBFFFFFFF)由各个进程使用


3GB~4GB 部分是所有进程共享的,是内核态的地址空间,这里存放着整个内核的代码、所有的内核模块以及内核所维护的数据。
用户态就是以用户身份执行自己的
[0, 3GB)时所处的状态。内核态就是以内核身份,通过系统调用,执行内核[3GB, 4GB]时所处的状态。
4.1.3 内核页表
内核页表是操作系统内核使用的页表,映射内核虚拟地址到物理地址,所有进程共享。用户页表每个进程独立一份,而内核页表只有一份,映射到物理内存上唯一的 OS 代码和数据集合。
操作系统无论怎么切换进程,都能找到同一个操作系统。系统调用的执行是在进程的地址空间中执行的!与进程是否调用无关!!! 关于特权级别,涉及到段,段描述符,段选择⼦,DPL,CPL,RPL等概念, ⽽现在芯⽚为了保证 兼容性,已经⾮常复杂了,进⽽导致OS也必须得照顾它的复杂性,这块我们不做深究了。
这样会不会不安全? 如果用户随便用一个 3~4GB 的虚拟地址,不就可以随便访问 OS 的代码和数据了吗?所以我们引入了用户态和内核态的权限控制——即使地址在 3~4GB 范围,用户态代码也无权访问,必须通过系统调用进入内核态后,由内核代码来访问。
4.1.4 用户态与内核态的切换
什么情况会导致用户态到内核态切换?
切换时 CPU 需要做什么?
当进程需要操作硬件时,必然用到 ring 0 级别的 CPU 指令集,而此时 CPU 的指令集操作权限只有 ring 3。CPU 需要切换指令集操作权限级别为 ring 0(提权),再执行相应的 ring 0 级别的 CPU 指令集。
提权时,CPU 需要切换栈——从用户栈切换到内核栈。CPU 通过段寄存器(TR)确定 TSS(任务状态段)的位置,在 TSS 结构中有 SS0 和 ESP0,提权时 CPU 从 TSS 里把 SS0 和 ESP0 取出来,放到 SS 和 ESP 寄存器中。
切换流程:
-
用户态保存寄存器状态到内存中,调用对应的系统函数,传入用户栈地址和寄存器信息,方便后续内核方法执行完毕后恢复用户方法执行的现场。
-
切换到内核态需要提权,CPU 切换指令集操作权限级别为 ring 0。
-
提权后,切换内核栈。然后开始执行内核方法,相应的方法栈帧保存在内核栈中。
-
当内核方法执行完毕后,CPU 切换指令集操作权限级别为 ring 3,利用之前写入的信息恢复用户栈的执行。
从上述流程可以看出,用户态切换到内核态时,会牵扯到用户态现场信息的保存以及恢复,还要进行一系列的安全检查,还是比较耗费资源的。
4.1.5 状态切换时 CS 寄存器的变化
CPU 通过 CS 寄存器(代码段寄存器)的低 2 位(CPL,Current Privilege Level)来判断当前特权级别。
用户态 → int 0x80 / syscall → 内核态
CS 被硬件自动加载为内核代码段选择子(CPL=00)
内核态 → iret / sysret → 用户态
CS 被硬件自动恢复为用户代码段选择子(CPL=11)
OS 不是通过软件变量判断自己在什么态,而是直接读取 CPU 硬件寄存器中的 CPL 位。CPL的全称是Current Privilege Level,即当前特权级别。CPL=0 是内核态,CPL=3 是用户态。状态的切换由 CPU 硬件自动完成。
一般执行 int 0x80 或者 syscall 软中断,CPL 会在校验之后自动变更 CS 段寄存器里 的代码段地址指向为内核区地址,并且由 11 变为 00,这叫陷入内核。但 OS 依旧禁止 直接以地址访问代码数据,所以固定流程是执行 int/syscall 所绑定的方法——也就是我们 之前说的流程,必须提供系统调用号访问,否则一切皆被视为非法操作。
4.1.6 什么时候处理信号?
当我们执行 main 函数时,由于某些原因会进入到操作系统内部。其中一个进入方式就是系统调用。当系统调用执行完,OS 从内核态返回到用户态时,会检查当前进程是否收到信号,对应的信号是否被 block,如果没有就会转向去执行信号处理流程。
用户态执行 main
→ 系统调用 / 中断 / 异常 → 进入内核态
→ 内核处理完毕,准备返回用户态之前
→ 检查:是否有未处理的信号?
├─ 有,且满足处理条件 → 处理信号
└─ 无 → 正常返回用户态
4.2 信号捕捉的流程

简化图:

4.2.1 自定义捕捉
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码在用户空间,处理过程比较复杂:
-
用户程序注册了
SIGQUIT信号的处理函数sighandler。 -
当前正在执行
main函数,这时发生中断或异常切换到内核态。 -
在中断处理完毕后要返回用户态的
main函数之前,检查到有信号SIGQUIT递达。 -
内核决定返回用户态后不是恢复 main 函数的上下文继续执行,而是执行
sighandler函数。sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。 -
sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 -
如果没有新的信号要递达,这次再返回用户态就是恢复
main函数的上下文继续执行。
4.2.2 默认与忽略(比自定义捕捉流程简单)
忽略:信号检查 → 检测到 handler 是 SIG_IGN,OS 将 pending 对应的 bit 改为 0,返回用户层继续执行。
默认:信号检查 → 检测到 handler 是 SIG_DFL,当前进程根据对应的默认动作处理。比如默认是终止进程,就直接杀死进程,释放 PCB、地址空间等(权限也够,因为是 OS 做的,最高权限)。进程暂停时,OS 在内核找到 PCB 将进程状态由 R 改成 S,并链入到等待队列里。
4.2.3 重谈捕捉:为什么必须以用户身份执行 handler?
我们执行用户自定义方法时,OS 要做身份切换。必须以用户身份执行用户代码。 这是因为代码是用户写的,如果用户写了一些没有权限做的事,以内核身份就会无视权限——用户代码有一些危险非法操作的话,内核身份执行会导致严重事故。
内核态执行用户代码 = 把金库钥匙交给银行客户,他想干嘛就干嘛。
完整权限切换流程:
用户态 main
→ 系统调用 → 内核态(高权限)
→ 检查信号 → 决定执行 handler
→ 切换回用户态(低权限)→ 执行 sighandler ← 以用户身份执行!
→ sigreturn → 内核态(高权限)
→ 恢复 main 上下文
→ 切换回用户态(低权限)→ 继续执行 main
4.2.4 为什么需要 sigreturn?
信号处理函数 sighandler 是用户态代码,但它不是被 main 调用的,而是被内核"强行插入"到执行流中的。普通的函数调用靠栈帧链(call 指令压入返回地址,ret 指令弹出返回地址),但 sighandler 不是 call 过去的,栈上根本没有 main 的返回地址。
内核的做法:篡改用户栈——内核在用户栈上伪造一个返回地址指向 sigreturn,sighandler 执行完 ret 时自动跳到 sigreturn,再由内核恢复 main 的上下文。

延伸思考:进程即使执行没有任何系统调用的死循环像 while (true) {} ,也必然进入内核态。原因在于 CPU 上存在硬件定时器,每隔几毫秒产生一次时钟中断。中断是硬件级别的,不可屏蔽,一旦触发,CPU 立即暂停当前用户态指令流,强制切换到内核态执行中断处理程序。内核在中断处理中检查当前进程的时间片是否耗尽,若已耗尽则调用调度器,保存该进程上下文,将其移入就绪队列,选择另一进程运行。因此,不是进程主动通过系统调用进入内核,而是时钟中断强制打断用户态执行,使 CPU 周期性地回到内核态的掌控之下。这正是抢占式多任务调度机制的基础。
4.3 sigaction
sigaction 是 signal 的增强版,功能更强、行为更可控,推荐替代 signal。
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
-
功能:读取或修改与指定信号相关联的处理动作。
-
参数:
-
signum:信号编号。 -
act:非空时,根据act修改该信号的处理动作。 -
oldact:非空时,通过oldact传出该信号原来的处理动作。
-
-
返回值:成功返回
0,出错返回-1。
struct sigaction {
void (*sa_handler)(int); // 等同于 signal 的回调方法
void (*sa_sigaction)(int, siginfo_t *, void *); // 实时信号处理函数
sigset_t sa_mask; // 额外屏蔽信号集
int sa_flags; // 标志位
void (*sa_restorer)(void); // 已废弃
};
-
sa_handler:赋值为SIG_IGN表示忽略信号,赋值为SIG_DFL表示执行系统默认动作,赋值为函数指针表示自定义捕捉。即向内核注册了一个信号处理函数。该函数返回值为 void,可以带一个 int 参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。这也是一个回调函数,不是被 main 函数调用,而是被系统所调用。 -
sa_mask:除了当前信号被自动屏蔽之外,还希望额外屏蔽的信号。 -
sa_flags:标志位,通常设为0。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字。当信号处理函数返回时自动恢复原来的信号屏蔽字。这就保证了在处理某个信号时,如果这种信号再次产生,它会被阻塞到当前处理结束为止。
sa_mask用于额外屏蔽其他信号(signal只会屏蔽当前处理的信号)。
实验验证
#include <iostream>
#include <signal.h>
#include <unistd.h>
void handerSig(int sig)
{
std::cout << "收到了一个信号:" << sig << std::endl;
while (true)
{
sigset_t pending;
sigpending(&pending);
for (int signal = 31; signal >= 1; signal--)
{
if (sigismember(&pending, signal))
std::cout << "1";
else
std::cout << "0";
}
std::cout << std::endl;
sleep(1);
}
exit(0);
}
int main()
{
struct sigaction act, oact;
act.sa_handler = handerSig;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGINT, &act, &oact); // 捕捉二号信号
while (true)
{
std::cout << "hello world" << std::endl;
sleep(1);
}
return 0;
}
运行结果(Ubuntu 22.04):
$ ./catch
hello world
hello world
^C收到了一个信号:2
0000000000000000000000000000000
0000000000000000000000000000000
^C0000000000000000000000000000010
0000000000000000000000000000010
^C^C0000000000000000000000000000010
0000000000000000000000000000010
^\Quit (core dumped)
实验分析:
sa_mask为空时,只自动屏蔽当前正在处理的信号,其他信号畅通无阻。如果想额外屏蔽 3 号信号:sigemptyset(&act.sa_mask); sigaddset(&act.sa_mask, SIGQUIT);此时 handler 执行期间,Ctrl+\ 也会进入 pending,不会立刻终止进程。
4.4 操作系统是怎么运行的
在理解信号机制之前,先要搞清楚一个更底层、更古老的概念——硬件中断。因为信号的诞生,正是对硬件中断思想的一次软件化模仿。
4.4.1 硬件中断:操作系统的"敲门人"
问题起源:操作系统怎么知道键盘有数据了?
#include <iostream>
int main() {
int b = 0;
std::cin >> b;
std::cout << b << std::endl;
return 0;
}
当程序执行到 std::cin >> b 时,如果没有输入,进程就会"卡住"。原因是:
-
进程从 0 号文件描述符(标准输入)读取数据;
-
如果没有数据,操作系统会将该进程的状态从 R(运行) 改为 S(睡眠),并将其挂载到设备的等待队列中;
-
直到键盘有输入,进程才会被唤醒。
那么 OS 是怎么知道键盘有数据的呢?
轮询?不行,太忙了。 计算机中有太多设备:键盘、显示器、网卡、磁盘……如果 OS 要不断轮询所有设备,就没时间做别的事了。所以,计算机设计者采用了一种更聪明的方式:让设备主动通知 CPU。
硬件中断的诞生:设备主动敲门。 当键盘有数据时,通过硬件电路向 CPU 发送一个中断信号。

中断的流程大致如下:
-
设备就绪:键盘有输入,数据已准备好。
-
通知中断控制器:键盘通过电路向中断控制器(如经典的 8259 芯片)发送高电平信号。
-
中断控制器识别设备:每个设备连接在中断控制器的不同针脚上(例如 0 号针脚是键盘)。
-
生成中断号:中断控制器根据针脚号生成一个中断号。
-
通知 CPU:中断控制器向 CPU 的特定针脚发送信号。
-
CPU 响应:CPU 收到信号后,暂停当前工作,读取中断号。
-
执行中断处理程序:CPU 根据中断号,在操作系统预先维护的中断向量表中找到对应的处理函数并执行。
中断向量表:操作系统的"紧急通讯录"。 中断向量表本质上是一个函数指针数组。操作系统在启动时,为每个可能的中断号注册一个处理函数:
当 CPU 拿到中断号 0 时,跳转到 keyboard_handler() 执行读取键盘数据的操作。
注意:CPU 不会直接从外设读取数据。按照冯·诺依曼体系结构:
-
数据从外设 → 内存;
-
CPU 只和内存打交道。
中断处理程序的作用,就是启动数据搬运过程,比如通过 DMA(直接内存访问)技术,让设备控制器自己把数据写到内存中。完成后,再唤醒等待的进程。
中断与信号:思想一致,实现不同。 信号机制的设计思想,正是对硬件中断的软件模拟:
区别在于:硬件中断由外部设备触发,信号由软件(内核或进程)触发
中断来了,CPU 在忙怎么办?
CPU 可能正在执行某个进程或系统调用。当中断来临时,CPU 会:
-
保存当前上下文(寄存器、程序计数器等);
-
跳转到中断处理函数;
-
处理完中断后,恢复上下文,继续原来的工作。
这个过程叫做现场保护,是操作系统响应中断的关键能力。
内核源码:中断注册
操作系统在启动时,通过 trap_init 注册各种异常和中断的处理函数,同时也为外设(如串口)注册中断处理:
// Linux 内核 0.11
trap_init就是内核在启动时往中断向量表里注册各种异常和中断的处理函数
// Linux 内核 0.11 源码
void trap_init(void)
{
int i;
set_trap_gate(0, ÷_error); // 设置除操作出错的中断向量值
set_trap_gate(1, &debug);
set_trap_gate(2, &nmi);
set_system_gate(3, &int3); /* int3-5 can be called from all */
set_system_gate(4, &overflow);
set_system_gate(5, &bounds);
set_trap_gate(6, &invalid_op);
set_trap_gate(7, &device_not_available);
set_trap_gate(8, &double_fault);
set_trap_gate(9, &coprocessor_segment_overrun);
set_trap_gate(10, &invalid_TSS);
set_trap_gate(11, &segment_not_present);
set_trap_gate(12, &stack_segment);
set_trap_gate(13, &general_protection);
set_trap_gate(14, &page_fault);
set_trap_gate(15, &reserved);
set_trap_gate(16, &coprocessor_error);
// 下面将 int17-48 的陷阱门先均设置为 reserved
for (i = 17; i < 48; i++)
set_trap_gate(i, &reserved);
set_trap_gate(45, &irq13); // 设置协处理器的陷阱门
outb_p(inb_p(0x21) & 0xfb, 0x21); // 允许主8259A 芯片的IRQ2 中断请求
outb(inb_p(0xA1) & 0xdf, 0xA1); // 允许从8259A 芯片的IRQ13 中断请求
set_trap_gate(39, ¶llel_interrupt); // 设置并行口的陷阱门
}
// 串口中断注册,了解即可
void rs_init(void)
{
set_intr_gate(0x24, rs1_interrupt); // 设置串口1 的中断门向量(硬件IRQ4 信号)
set_intr_gate(0x23, rs2_interrupt); // 设置串口2 的中断门向量(硬件IRQ3 信号)
init(tty_table[1].read_q.data); // 初始化串口1
init(tty_table[2].read_q.data); // 初始化串口2
outb(inb_p(0x21) & 0xE7, 0x21); // 允许主8259A 芯片的IRQ3,IRQ4 中断请求
}
小结:
-
中断向量表就是操作系统的一部分,启动就加载到内存中了。
-
通过外部硬件中断,操作系统不需要对外设进行周期性的检测或轮询。
-
由外部设备触发的中断系统运行流程,叫做硬件中断。
操作系统就是在硬件的推动下自动调度的。
4.4.2 时钟中断
问题:进程可以被操作系统调度执行,那操作系统自己被谁指挥、被谁推动执行?外部设备可以触发硬件中断,但这需要用户或设备自己触发,有没有可以定期触发的设备?
时钟中断:硬件定时器以固定频率向 CPU 发送中断。整个系统就在硬件时钟中断的驱动下进行调度。OS 就是基于中断进行工作的软件。后来时钟源被集成到 CPU 内部,即使外设没有中断信号产生,CPU 自己定期触发中断,自己调用中断方法 schedule()。这也就是 CPU 主频的概念。
时间片的实现原理:
时钟源以固定频率向 CPU 发送中断。每次时钟中断触发时,内核执行 schedule() 中的逻辑:
// 简化示意
void schedule() {
current->count--; // 时间片计数器减 1
if (current->count == 0) { // 时间片耗尽
// 保存当前进程上下文
// 将当前进程移入就绪队列
// 选择下一个进程运行
}
}

时间片 = 计数器。时间片不是以"秒"为单位直接度量的,而是以时钟中断次数来表示的。若时钟频率为 1000Hz(每次中断间隔 1ms),count 初始值设为 10,则时间片 = 10 × 1ms = 10ms。
CPU 主频越高,时钟中断越密集,调度粒度越细。主频越快,CPU 越快。
内核源码: sched_init() + timer_interrupt + do_timer + schedule
// Linux 内核 0.11 - main.c
sched_init(); // 调度程序初始化(加载了任务0 的tr, ldtr)(kernel/sched.c)
// 调度程序的初始化子程序。kernel/sched.c
void sched_init(void)
{
...
set_intr_gate(0x20, &timer_interrupt); // 在这里
// 修改中断控制器屏蔽码,允许时钟中断。
outb(inb_p(0x21) & ~0x01, 0x21);
// 设置系统调用中断门。
set_system_gate(0x80, &system_call);
...
}
// system_call.s - 纯汇编文件
_timer_interrupt:
// 定时器中断处理入口
// 由 C 内核代码间接调用或由中断向量表直接跳转
call _do_timer// do_timer(CPL)执⾏任务切换、计时等⼯作,在kernel/shched.c,305 ⾏实现。
// 调度入口
void do_timer(long cpl)
{
...
if ((--current->counter) > 0)
return; // 时间片还没用完,退出
current->counter = 0;
if (!cpl) return;
schedule(); // 进程调度
}
void schedule(void)
{
...
//前面的工作就是遍历并选择一个进程
switch_to(next); // 切换到任务号为 next 的任务
}

其中图片中的时钟源会以特定的频率向cpu发送特定的中断,于是整个系统就在硬件时钟中断的驱动下进行调度了,也就是说os就是基于中断进行工作的软件,后来时钟源被设计到集成到cpu里面,即使外设没有中断信号产生,cpu定期自己触发中断,然后cpu定期自己调用中断方法schdule(),所以就有了cpu主频的概念
4.4.3 死循环
操作系统的本质:就是一个死循环。
void main(void) /* 这⾥确实是void,并没错。 */
{ /* 在startup 程序(head.s)中就是这样假设的。 */
...
/*
* 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到⼀个信号才会返
* 回就绪运⾏态,但任务0(task0)是唯⼀的意外情况(参⻅'schedule()'),因为任
* 务0 在任何空闲时间⾥都会被激活(当没有其它任务在运⾏时),
* 因此对于任务0'pause()'仅意味着我们返回来查看是否有其它任务可以运⾏,如果没
* 有的话我们就回到这⾥,⼀直循环执⾏'pause()'。
*/
for (;;)
pause();
} // end main
进程可以在操作系统的指挥下被调度、被执行。操作系统自己在硬件时钟的推动下自动调度。OS 自己不做任何事情,需要什么功能,就向中断向量表里面添加方法即可。
之前 2.4 节
pause接口里的定时任务调度器示例,模拟的就是 OS 的行为:进程平时睡觉(pause),时钟中断一来(SIGALRM)起来干活,干完重设定时器继续睡。
4.4.4 软中断
上述外部硬件中断,需要硬件设备触发。有没有可能因为软件原因也触发中断?有。
除零错误:CPU 内的运算单元检测到除零,EFLAGS 寄存器溢出标志位被置位。CPU 将其规定为一种内部触发的异常中断,自动生成中断号。OS 响应中断,执行已注册的处理函数,向当前进程发送 SIGFPE(8 号信号)。
set_system_gate(4, &overflow); // 注册除零中断处理函数
野指针 / 非法地址访问:MMU 地址转换失败,触发缺页异常中断。OS 执行 page_fault 处理函数,判定为非法地址访问后,发送 SIGSEGV(11 号信号)。
set_system_gate(14, &page_fault); // 注册缺页中断处理函数
缺页中断的本质:虚拟地址合法但物理页尚未分配时,MMU 地址转换失败触发缺页异常。page_fault 检查后,若地址合法只是尚未映射,则分配物理页并建立页表;若地址非法,则发送 SIGSEGV 终止进程。
缺页中断的两种结果:
├─ 地址合法,物理页未分配 → 分配物理页,建立页表,返回重试
└─ 地址非法(野指针)→ 发送 SIGSEGV,终止进程
OS 怎么知道硬件出异常了? 通过中断。CPU 检测到异常后自动生成中断号,OS 在初始化时已注册好对应的中断处理函数,异常发生时自动调用。
三种进入内核态的方式:
4.4.5 系统调用的底层实现
- 为了让操作系统支持进行系统调用,CPU 也设计了对应的汇编指令(
int或者syscall),可以让 CPU 内部触发中断逻辑。 - 用户层通过寄存器(比如 EAX/RAX)把系统调用号给操作系统。OS 通过寄存器或者用户传入的缓冲区地址把返回值给用户。
- 系统调用的过程,其实就是先
int 0x80或syscall陷入内核,本质就是触发软中断。CPU 自动执行系统调用的处理方法,这个方法根据系统调用号自动查表,执行对应的方法。
类似于:
void system_call()
{
int nr = current->eax; // 1. 获取系统调用号
check_permission(nr); // 2. 安全检查
sys_call_table[nr](); // 3. 查表调用对应函数
}
系统调用号的本质:数组下标。
// sys.h
// 系统调用函数指针表。用于系统调用中断处理程序(int 0x80),作为跳转表。
extern int sys_setup(); // 系统启动初始化设置函数 (kernel/blk_drv/hd.c,71)
extern int sys_exit(); // 程序退出 (kernel/exit.c, 137)
extern int sys_fork(); // 创建进程(kernel/system_call.s, 208)
// ...
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
// ...
sys_setreuid, sys_setregid
};
// 调度程序的初始化⼦程序。
void sched_init(void)
{
...
// 设置系统调⽤中断⻔。
set_system_gate(0x80, &system_call);
}
; system_call.s
_system_call:
cmp eax, nr_system_calls-1 ; 调用号超出范围则置-1并退出
ja bad_sys_call
push ds ; 保存原段寄存器值
push es
push fs
push edx ; ebx,ecx,edx 中放着系统调用的参数
push ecx
push ebx
mov edx, 10h ; ds,es 指向内核数据段
mov ds, dx
mov es, dx
mov edx, 17h ; fs 指向局部数据段
mov fs, dx ;(局部描述符表中数据段描述符)。
;// 下⾯这句操作数的含义是:调⽤地址 = _sys_call_table + %eax * 4。参⻅列表后的说明。
;// 对应的C 程序中的sys_call_table 在include/linux/sys.h 中,其中定义了⼀个包括72 个
;// 系统调⽤C 处理函数的地址数组表。
call [_sys_call_table+eax*4] ; 在这里!!!!!
push eax ; 把系统调用号入栈
mov eax, _current
cmp dword ptr [state+eax], 0 ; state
jne reschedule
cmp dword ptr [counter+eax], 0 ; counter
je reschedule
ret_from_sys_call:
; 从系统调用返回后,对信号进行识别处理
call [_sys_call_table+eax*4] 就是直接将系统调用函数指针表的地址 + 4(指针大小 4 字节)× 偏移量
32 位程序:
mov eax, 5 → int 0x80 → 中断向量表 → system_call → sys_call_table[5]
64 位程序:
mov rax, 5 → syscall → MSR 寄存器 → entry_SYSCALL_64 → sys_call_table[5]
两条路,同一个目的地。32 位老路走中断向量表,64 位高速公路走 MSR 寄存器,最终都到
sys_call_table。
可是为什么我们用系统调用时,从来没见过什么 int 0x80 或者 syscall 呢? 因为 Linux 的 GNU C 标准库(glibc)把几乎所有的系统调用全部封装了。用户调用 fork、write 等函数时,glibc 内部通过内联汇编插入 int 0x80 或 syscall 指令。
int/syscall二者都是 libc 封装vfork系统调用的汇进入口 __vfork,区别是CPU 架构、系统调用进入内核的指令不同:
os不提供任何系统调用接口,只提供系统调用号(其实就是数组下标)。而系统调用号,不是 glibc 提供的,是内核提供的,内核提供系统调用入口函数 man 2 syscall,或者直接提供汇编级别软中断命令 int or syscall,并提供对应的头文件或者开发入口,让上层语言的设计者使用系统调用号,完成系统调用过程
#define SYS_ify(syscall_name) __NR_##syscall_name
- 是一个宏定义,用于将系统调用的名称转换为对应的系统调用号。比如:
SYS_ify(open)会被展开为__NR_open - 系统调用号不是 glibc 提供的,是内核提供的。内核提供系统调用入口函数(
man 2 syscall),提供对应的头文件或者开发入口,让上层语言的设计者使用系统调用号完成系统调用过程。
//源码路径:linux-2.6.18/linux-2.6.18/include/asm-x86_64/unistd.h
/* at least 8 syscall per cacheline */
#define __NR_read 0
__SYSCALL(__NR_read, sys_read)
#define __NR_write 1
__SYSCALL(__NR_write, sys_write)
#define __NR_open 2
__SYSCALL(__NR_open, sys_open)
。。。。
总结:用户调用系统调用 → glibc 封装 → 系统调用号入寄存器 → int 0x80/syscall 陷入内核 → 查表跳转 sys_call_table[n] → 执行内核函数 → 检查信号 → 返回用户态。
4.4.6 缺页中断?内存碎片处理?除零野指针错误?
缺页中断、内存碎片处理、除零野指针错误——这些问题全部都会被转换成为 CPU 内部的软中断,然后走中断处理例程完成所有处理。有的是申请内存、填充页表、进行映射;有的是用来处理内存碎片;有的是给目标进程发送信号、杀掉进程等。
总结:
-
操作系统就是躺在中断处理例程上的代码块——整个操作系统是中断驱动的。
-
CPU 内部的软中断,比如
int 0x80或syscall,我们叫做陷阱(跑着跑着陷入内核)。 -
CPU 内部的软中断,比如除零/野指针等,我们叫做异常(所以能理解"缺页异常"为什么这么叫了)。
4.5再次理解用户态和内核态

5. 可重入函数
5.1 问题场景

main 函数调用 insert 向链表 head 中插入节点 node1。插入操作分为两步,刚做完第一步时,硬件中断使进程切换到内核,再次返回用户态之前检查到有信号待处理,于是切换到 sighandler 函数。sighandler 也调用 insert 向同一个链表 head 中插入节点 node2,两步都做完之后从 sighandler 返回内核态,再回到用户态从 main 函数调用的 insert 中继续往下执行,做完第二步。结果是:main 和 sighandler 先后向链表中插入两个节点,而最后只有一个节点真正插入链表中,另一个节点丢失。
5.2 执行流与重入
一个进程有两条执行流:
两条执行流串行执行,但在 insert 函数内部发生了切换。insert 被两条执行流重复进入,这就是函数被重入了。
如果函数被重入后出现异常(数据错乱),叫不可重入函数;没有异常就叫可重入函数。两者没有谁对谁错,只是一种特点。
5.3 不可重入函数的常见条件
如果一个函数符合以下条件之一,则是不可重入的:
-
调用了
malloc或free——因为malloc也是用全局链表来管理堆的。 -
调用了标准 I/O 库函数——标准 I/O 库的很多实现以不可重入的方式使用全局数据结构。
后面讲解多线程时会详细展开。
6. volatile
该关键字在 C 语言中我们已经有所涉猎,今天站在信号的角度重新理解一下。
6.1 问题场景
#include <iostream>
#include <signal.h>
int flag = 0;
void handler(int signum)
{
std::cout << "更改全局变量," << flag << "->1" << std::endl;
flag = 1;
}
int main()
{
signal(SIGINT, handler); // main 执行流并不会对 flag 修改
// 正常情况下,键入 CTRL-C,2 号信号被捕捉,执行自定义动作,
// 修改 flag=1,while 条件不满足,退出循环,进程退出
// 但在编译器优化程度高的情况下,可能会把 flag 变量直接优化
// 到 register 寄存器里,将来就不会访问内存,直接检查寄存器
while (!flag);
std::cout << "process quit normal" << std::endl;
return 0;
}
6.2 为什么优化会导致死循环?
CPU 进行逻辑运算和算术运算需要数据,完成计算需要三步:
-
将数据从内存加载到 CPU 的寄存器
-
在寄存器内进行运算
-
需要时写回内存
在上面的案例中,CPU 不断重复第 1、2 步。但在某些优化级别下,编译器会将 flag 变量加载到 CPU 寄存器后,之后不再从内存读取——相当于寄存器覆盖了进程看到变量的真实情况,即内存不可见了。
6.3 编译器优化级别
6.4 实验验证
-O0 无优化:正常退出
$ g++ volatile.cc -O0 -o volatile
$ ./volatile
^C更改全局变量,0->1
process quit normal
-O1 及以上优化:死循环
$ g++ volatile.cc -O1 -o volatile
$ ./volatile
^C更改全局变量,0->1
^C更改全局变量,1->1
^C更改全局变量,1->1
^\Quit (core dumped)
-O1 优化下的执行流分析:
main 执行流:
1. 从内存加载 flag=0 到寄存器(比如 eax=0)
2. while(!eax) → eax=0 → 条件为真 → 死循环
3. 每次都检查 eax,不再从内存读取 flag(不会访存)
信号执行流:
Ctrl+C → handler → flag=1(修改了内存,但 eax 还是 0)
→ main 继续死循环 → 再 Ctrl+C → flag 已经是 1,handler 打印 1->1
三次
Ctrl+C的详细过程:
第一次:
flag从 0 改为 1,打印 "0->1",但 main 循环中的 eax 还是 0,继续死循环第二次:
flag已经是 1,打印 "1->1"(没变化)第三次:同上
Ctrl+\→SIGQUIT未捕获 → Core dump → 进程终止
6.5 解决方案:volatile
将 flag 用 volatile 修饰后:
volatile int flag = 0;
即使使用 -O3 也不会出现问题了:
$ g++ volatile.cc -O3 -o volatile
$ ./volatile
^C更改全局变量,0->1
process quit normal
6.6 结论
volatile强制 CPU 每次从内存读取变量,阻止编译器将其缓存到寄存器。即使最激进的-O3优化,信号处理函数对flag的修改也能被主执行流立即感知。这就是volatile存在的全部意义。
7. SIGCHLD 信号
7.1 概念
在进程一章中我们讲过用 wait 和 waitpid 函数清理僵尸进程。父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(轮询的方式)。
实际上,子进程在终止时会向父进程发送 SIGCHLD 信号(17 号信号)。该信号的默认处理动作是忽略。父进程可以自定义 SIGCHLD 信号的处理函数,这样父进程只需专心处理自己的工作,子进程终止时会通知父进程,父进程在信号处理函数中调用 wait 清理即可。
7.2 基础示例
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
void handerSig(int signum)
{
std::cout << "father get a signal:" << signum << std::endl;
}
int main()
{
signal(SIGCHLD, handerSig); // 捕捉子进程退出时发送的信号
pid_t id = fork();
if (id == 0)
{
std::cout << "i am child, exit in 3s" << std::endl;
sleep(3);
exit(1);
}
waitpid(id, nullptr, 0);
std::cout << "i am father, exit " << std::endl;
return 0;
}
运行结果:
$ ./sigchld
i am child, exit in 3s
father get a signal:17
i am father, exit
7.3 在信号处理函数中回收子进程
我们可以把进程等待的工作放在信号捕捉函数里处理:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <cstdlib>
void WaitAll(int signum)
{
while (true)
{
//pid_t n = waitpid(-1, nullptr, 0);//waitpid默认是阻塞的,这样父进程就会阻塞
pid_t n = waitpid(-1, nullptr, WNOHANG); // 非阻塞轮询,等待任意一个退出的子进程
if (n > 0)
{
std::cout << "wait " << n << " success" << std::endl;
}
else if (n < 0)
{
std::cerr << "wait error" << std::endl;
break;
}
else // n==0,没有子进程状态改变
{
break;
}
}
}
int main()
{
signal(SIGCHLD, WaitAll);
for (int i = 0; i < 10; i++)
{
pid_t id = fork();
if (id == 0)
{
sleep(3);
std::cout << "i am child, exit in 3s" << std::endl;
if (i > 6)
{ // 第 8,9,10 个进程暂停
pause();
}//1234567进程退出
exit(0);
}
}
while (true)
{
std::cout << "i am father,wait all childs exit" << std::endl;
sleep(1);
}
return 0;
}
运行结果:
注意事项:
-
多个子进程几乎同时退出时,
SIGCHLD可能被合并(普通信号只计一次),导致WaitAll只触发一次,回收不全。 -
解决方法:在
WaitAll中用while(waitpid(-1, nullptr, WNOHANG) > 0)循环回收,或在父进程主循环中定期调用waitpid作为兜底。
7.4 显式忽略 SIGCHLD
事实上,由于 UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用 sigaction 将 SIGCHLD 的处理动作置为 SIG_IGN。这样 fork 出来的子进程在终止时会自动清理掉(但也就无法读到子进程退出信息),不会产生僵尸进程,也不会通知父进程。此方法对于 Linux 可用,但不保证在其它 UNIX 系统上都可用。
int main()
{
signal(SIGCHLD, SIG_IGN); // 显式忽略 SIGCHLD
for (int i = 0; i < 10; i++)
{
pid_t id = fork();
if (id == 0)
{
sleep(3);
std::cout << "i am child, exit in 3s" << std::endl;
exit(0);
}
}
while (true)
{
std::cout << "i am father,wait all childs exit" << std::endl;
sleep(1);
}
return 0;
}
运行结果:
7.5 为什么需要显式设置 SIG_IGN?
子进程结束时向父进程发送 SIGCHLD,默认动作就是 Ignore。为什么我们又要手动设置一遍?
因为 SIGCHLD 的默认动作虽然是 Ignore,但这个 Ignore 和 SIG_IGN 有本质区别。
即这里的 Ignore 不是信号处理动作是
SIG_IGN,而是SIG_DFL。SIG_DFL对于SIGCHLD这个信号的处理动作是忽略,什么都不干,但子进程变僵尸。SIG_IGN也是忽略,什么都不干,但内核额外帮你自动回收子进程。两者处理动作相同,副作用不同。



3384

被折叠的 条评论
为什么被折叠?



