操作系统 : Linux进程信号
操作系统 : Linux进程信号
目录
- 操作系统 : Linux进程信号
- 引言
- 1. 信号快速认识
- 1.1 信号基本结论
- 1.2 关于`Ctrl + c`的信号本质
- 1.3 信号概念
- 1.3.1 查看信号
- 2. 信号产生
- 2.1 通过终端按键产生信号
- 2.2 调用系统命令向进程发信号
- 2.3 使用函数产生信号
- 2.4 由软件条件产生信号
- 2.5 硬件异常产生信号
- **核心结论**:
- 3. 信号保存
- 3.1 信号在内核中的表示
- 3.2 信号集操作函数
- 4. 信号捕捉
- 4.1 信号捕捉的流程
- 4.2 `sigaction`
- 4.3 操作系统的运行流程
- 4.3.1 硬件中断
- 4.3.2 时钟中断
- 4.3.3 死循环
- 4.3.4 软中断
- 4.4 如何理解内核态和用户态
- 5. 可重入函数
- 6. volatile
- 7. SIGCHLD信号
- 8. 附录 - 用户态内核态
引言
在Linux系统中,信号(Signal)是一种重要的进程间通信机制,用于通知进程发生了某种异步事件。信号的本质是软件中断,它允许进程在运行过程中被外部事件打断,并根据预设的处理方式作出响应。无论是用户按下Ctrl+C
终止程序,还是进程访问非法内存触发段错误,亦或是定时器超时提醒,背后都是信号机制在发挥作用。
理解信号的工作原理对于深入掌握Linux系统编程至关重要。信号的处理涉及多个层面:从信号的产生、保存到最终的捕捉与处理,每一步都体现了操作系统对进程管理的精巧设计。本文将系统性地介绍Linux信号机制,涵盖以下核心内容:
- 信号的基本概念:信号是什么?它是如何工作的?
- 信号的产生方式:从终端按键到系统调用,多种触发信号的途径。
- 信号的保存与阻塞:内核如何管理未决信号?信号屏蔽字的作用。
- 信号的捕捉流程:用户态与内核态的切换,信号处理函数的执行机制。
- 高级话题:可重入函数、
volatile
关键字、SIGCHLD
信号的应用等。
1. 信号快速认识
- 信号的本质是软件中断,用于通知进程发生了异步事件。信号的产生相对于进程的运行来说是异步的。
1.1 信号基本结论
- 信号的识别和处理方式是进程内置的,进程识别信号,是内核程序员写的内置特性。
- 信号的处理方式在信号产生之前就已经准备好了。
- 信号不一定会立即处理,可能会在合适的时候在处理。
1.2 关于Ctrl + c
的信号本质
-
用户输入命令在Shell下启动一个前台进程。当用户按下
Ctrl + c
,这个键盘输入会产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程。前台进程因为收到信号,进而引起进程退出。 -
signal
系统调用函数signal()
是 Unix/Linux 系统中用于设置信号处理方式的传统系统调用函数。它允许进程指定当接收到特定信号时要采取的操作。函数原型:
NAMEsignal - ANSI C signal handling SYNOPSIS#include <signal.h>typedef void (*sighandler_t)(int);sighandler_t signal(int signum, sighandler_t handler);
- 参数1
signum
:信号编号(如 SIGINT、SIGTERM或编号等) - 参数2
handler
:信号处理函数指针,表示更改信号的处理动作,当收到对应的信号,就回调执行handler方法。 - 返回值:返回之前的信号处理函数指针
- 参数1
-
其实,
Ctrl + c
的本质是向前台进程发送SIGINT
即2
号信号的。 -
代码示例:
#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 */, handler);while (true) {std::cout << "I am a process, I am waiting signal!" << std::endl;sleep(1);} }
编译运行:
$ g++ sig.cc -o sig $ ./sig 我是进程: 212569 I am a process, I am waiting signal! I am a process, I am waiting signal! ^C我是: 212569, 我获得了一个信号: 2 I am a process, I am waiting signal! I am a process, I am waiting signal! ^C我是: 212569, 我获得了一个信号: 2 I am a process, I am waiting signal! I am a process, I am waiting signal!
-
特别注意:
-
要注意的是,Signal函数仅仅是设置了特定信号的捕捉行为处理方式,并不是直接调用处理动作。如果后续特定信号没有产生,设置的捕捉函数永远也不会被调用。
-
Ctrl-C
产生的信号只能发给前台进程。一个命令后面加个&
可以放到后台运行,这样 Shell 不必等待进程结束就可以接受新的命令,启动新的进程。 -
Shell 可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像
Ctrl-C
这种控制键产生的信号(标准输入)。但是前台和后台进程都可以向屏幕(标准输出)打印 -
前台进程在运行过程中用户随时可能按下
Ctrl-C
而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到SIGINT
信号而终止,所以信号相对于进程的控制流程来说是异步的。 -
补充一些命令:
- 在 Linux 操作系统中,
bg + 任务号
(或bg %任务号
)通常用于将后台暂停的任务恢复运行 - 在 Linux Shell 中,
fg + 任务号
(或fg %任务号
)用于将后台任务或暂停的任务切换到前台继续运行 - 在 Linux Shell 中,
jobs
命令用于查看当前 Shell 会话中所有的后台任务或暂停任务
- 在 Linux 操作系统中,
-
1.3 信号概念
信号是进程之间事件异步通知的一种方式,属于软中断。
1.3.1 查看信号
每个信号都有⼀个编号和⼀个宏定义名称,这些宏定义可以在signal.h
中找到。
编号大于34的是实时信号,本章只讨论编号小于34的信号,不讨论实时信号。这些信号各⾃在什么条件下产⽣,默认的处理动作是什么,在signal(7)
中都有详细说明: man 7 signal
需要注意的是:9号信号,无法被自定义捕捉,无法被阻塞。
2. 信号产生
2.1 通过终端按键产生信号
信号其实是从纯软件角度模拟硬件中断的行为,只不过硬件中断是发给CPU,而信号是发给进程的。
-
Ctrl + c (SIGINT)
前边已经介绍过了,这里不再复述。 -
Ctrl + \ (SIGQUIT)
可以发送终止信号并产生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); // 注册 SIGQUIT (Ctrl+\) 信号处理函数while (true) {std::cout << "I am a process, I am waiting signal!" << std::endl;sleep(1);} }
编译运行:
$ g++ sig.cc -o sig $ ./sig 我是进程: 213056 I am a process, I am waiting signal! I am a process, I am waiting signal! ^\我是: 213056, 我获得了一个信号: 3 # 按下 Ctrl+\ 触发 SIGQUIT//注释掉signal(SIGQUIT, handler) 后的运行结果 $ ./sig 我是进程: 213146 I am a process, I am waiting signal! I am a process, I am waiting signal! ^\Quit # 按下 Ctrl+\ 触发默认行为(进程终止)
-
Ctrl + z (SIGTSTP)
可以发送停止信号,将当前前台进程挂起到后台等。代码示例:
#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); // 注册 SIGTSTP (Ctrl+Z) 信号处理函数while (true) {std::cout << "I am a process, I am waiting signal!" << std::endl;sleep(1);} }
编译运行:
$ ./sig 我是进程: 213552 I am a process, I am waiting signal! I am a process, I am waiting signal! ^Z我是: 213552, 我获得了一个信号: 20 # 按下 Ctrl+Z 触发 handler I am a process, I am waiting signal! # 进程继续运行(未被暂停)# 禁用自定义信号处理(注释掉第 13 行) $ ./sig 我是进程: 213627 I am a process, I am waiting signal! I am a process, I am waiting signal! ^Z [1]+ Stopped ./sig # 按下 Ctrl+Z 触发默认行为 whb@bite:~/code/test$ jobs [1]+ Stopped ./sig # 进程被暂停(进入后台)
2.2 调用系统命令向进程发信号
-
实验步骤:
#include <iostream> #include <unistd.h> #include <signal.h>int main() {// 实验步骤1:编译并后台运行此程序(见下方终端命令)while (true) {sleep(1); // 死循环保持进程运行} }
# 1. 编译代码(生成可执行文件 sig) g++ sig.cc -o sig# 2. 后台运行程序(& 表示后台运行) ./sig &# 3. 查看进程 PID(过滤出 sig 进程信息) ps ajx | head -1 && ps ajx | grep sig # 输出示例: PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND 211805 213784 213784 211805 pts/0 213792 S 1002 0:00 ./sig# 4. 向进程发送 SIGSEGV 信号(213784 替换为你的实际 PID) kill -SIGSEGV 213784 # 或使用信号编号(11 是 SIGSEGV 的编号): # kill -11 213784# 5. 按回车键查看结果(Shell 会显示段错误信息) # 输出示例: [1]+ Segmentation fault ./sig
-
结论:
- Shell 的延迟显示:
- 由于
./sig
在后台运行,kill
命令执行后,Shell 会立即返回提示符,而不会立即显示Segmentation fault
。 - 只有当用户再次按下回车时,Shell 才会输出错误信息,以避免与用户输入交错。
- 由于
- 段错误的来源:
- 通常,
Segmentation fault
是由于程序访问非法内存(如空指针解引用、越界访问)触发的。 - 但在这个实验中,程序本身没有错误,手动发送
SIGSEGV
信号也能强制产生段错误,无论是否真的发生内存错误。
- 通常,
- Shell 的延迟显示:
2.3 使用函数产生信号
-
kill
kill
函数可以给一个指定的进程发送指定的信号。NAMEkill - send signal to a process SYNOPSIS#include <sys/types.h>#include <signal.h>int kill(pid_t pid, int sig); RETURN VALUEOn success (at least one signal was sent), zero is returned. On error,-1 is returned, and errno is set appropriately.
- 参数:
pid
:目标进程的 ID(>0
表示特定进程,0
和-1
有特殊含义)。sig
:要发送的信号(如SIGTERM
、SIGKILL
,或0
测试进程是否存在)。
- 返回值:
- 成功时返回
0
,失败返回-1
并设置errno
。
- 成功时返回
- 参数:
-
raise
raise
是C标准库(定义在<signal.h>
中)提供的函数,用于给当前进程发送指定的信号(自己给自己发信号)。NAMEraise - send a signal to the caller SYNOPSIS#include <signal.h>int raise(int sig); RETURN VALUEraise() returns 0 on success, and nonzero for failure.
- 参数:
sig
:要发送的信号(如SIGINT
、SIGTERM
等)。
- 返回值:
- 成功时返回
0
,失败返回非零值(通常表示信号无效)。
- 成功时返回
- 用途:
- 程序主动终止自身(如检测到错误时发送
SIGABRT
)。 - 调试时模拟信号(如测试信号处理逻辑)。
- 实现协作式多任务(通过信号控制执行流)。
- 程序主动终止自身(如检测到错误时发送
- 参数:
-
abort
abort
是 C 标准库(定义在<stdlib.h>
中)提供的函数,用于立即终止当前进程,并生成核心转储(core dump)
文件(如果系统配置允许)。它通常用于处理不可恢复的错误(如内存损坏、断言失败等)。NAMEabort - cause abnormal process termination SYNOPSIS#include <stdlib.h>void abort(void); RETURN VALUEThe abort() function never returns.// 就像exit函数⼀样,abort函数总是会成功的,所以没有返回值。
- 无参数,无返回值(永远不会返回)。
- 行为:
- 向当前进程发送
SIGABRT
信号(信号编号6
)。 - 如果进程未捕获或忽略
SIGABRT
,则终止进程并可能生成core
文件。 - 如果进程捕获了
SIGABRT
但信号处理函数未退出,abort
仍会强制终止进程。
- 向当前进程发送
- 常见用途:
- 调试崩溃:
在开发中主动触发abort
生成 core dump,用于分析程序状态(如内存泄漏、非法指针访问)。 - 断言失败:
assert
宏在条件为假时调用abort
,快速暴露逻辑错误。 - 不可恢复错误:
当检测到数据损坏或致命错误时,直接终止进程避免进一步破坏。
- 调试崩溃:
2.4 由软件条件产生信号
-
软件条件的简述
在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产生机制。这些条件包括但不限于以下场景:
- 定时器超时
- 例如:
alarm
函数设定的时间到达时触发SIGALRM
信号。
- 例如:
- 软件异常
- 例如:向已关闭的管道写数据时触发
SIGPIPE
信号。
- 例如:向已关闭的管道写数据时触发
- 进程间通信(IPC)事件
- 例如:子进程终止时触发父进程的
SIGCHLD
信号。
- 例如:子进程终止时触发父进程的
当这些软件条件满足时,操作系统会向相关进程发送对应的信号,通知进程进行响应处理。简而言之,软件条件是由操作系统内部或外部软件操作触发的信号产生方式,与硬件中断(如
SIGSEGV
由非法内存访问触发)形成互补。 - 定时器超时
-
alarm
NAMEalarm - set an alarm clock for delivery of a signal SYNOPSIS#include <unistd.h>unsigned int alarm(unsigned int seconds); RETURN VALUEalarm() returns the number of seconds remaining until any previouslyscheduled alarm was due to be delivered, or zero if there was no previ‐ously scheduled alarm.
- 调用
alarm
函数可以设定⼀个闹钟,也就是告诉内核在seconds
秒之后给当前进程发SIGALRM
信号,该信号的默认处理动作是终止当前进程。 - 这个函数的返回值是
0
或者是以前设定的闹钟时间还余下的秒数。如果seconds
值为0
,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。
- 调用
-
pause
pause
是 Unix/Linux 系统中的一个系统调用(定义在<unistd.h>
),用于挂起当前进程,直到接收到任意信号(无论是捕获的信号还是终止信号)。它是实现进程同步和信号驱动编程的基础工具之一。NAMEpause - wait for signal SYNOPSIS#include <unistd.h>int pause(void); DESCRIPTIONpause() causes the calling process (or thread) to sleep until a signalis delivered that either terminates the process or causes the invoca‐tion of a signal-catching function. RETURN VALUEpause() returns only when a signal was caught and the signal-catchingfunction returned. In this case, pause() returns -1, and errno is setto EINTR.
- 参数:无。
- 返回值:
- 正常情况下不返回(进程被信号中断后才会返回)。
- 如果被信号中断,返回
-1
并设置errno
为EINTR
。
- 挂起进程:调用
pause
后,进程进入睡眠状态(不占用 CPU)。 - 信号唤醒:
- 如果进程收到任何信号(无论是否被捕获),
pause
会被中断。 - 若信号的处理函数正常返回(未终止进程),
pause
返回-1
(errno = EINTR
)。 - 若信号导致进程终止(如
SIGKILL
),则pause
不会返回。
- 如果进程收到任何信号(无论是否被捕获),
- 应用场景:
- 信号驱动编程:
- 等待外部事件(如定时器
SIGALRM
、子进程退出SIGCHLD
)。
- 等待外部事件(如定时器
- 进程同步:
- 父进程等待子进程信号后再继续。
- 简单守护进程:
- 用
pause
保持进程运行,直到收到终止信号。
- 用
- 信号驱动编程:
-
设置重复闹钟
#include <iostream> // 标准输入输出 #include <unistd.h> // 提供POSIX API(alarm, pause等) #include <signal.h> // 信号处理相关 #include <vector> // 向量容器 #include <functional> // 函数对象包装器// 定义函数类型:无参数无返回值的可调用对象 using func_t = std::function<void()>; //using == typedef | func_t 方法名 | void 返回类型 | ()参数列表// 全局变量 int gcount = 0; // 主循环唤醒计数器 std::vector<func_t> gfuncs; // 存储定时任务的容器 //vector<func_t> 装满方法的容器 | gfuncs 容器名/*** SIGALRM信号处理函数* @param signo 信号编号(此处应为SIGALRM)*/ void hanlder(int signo) {// 执行所有注册的定时任务for(auto &f : gfuncs) {f();}// 打印当前计数器状态std::cout << "gcount : " << gcount << std::endl;// 重置1秒定时器,并获取上次定时器剩余时间int remaining = alarm(1); std::cout << "剩余时间 : " << remaining << std::endl; }int main() {// 示例:注册定时任务(实际使用时取消注释)/*gfuncs.push_back([](){std::cout << "执行内核刷新操作" << std::endl;});gfuncs.push_back([](){std::cout << "检测进程时间片,必要时进行进程切换" << std::endl;});gfuncs.push_back([](){std::cout << "执行内存碎片整理" << std::endl;});*/// 设置首次定时器(1秒后触发)alarm(1);// 注册信号处理函数signal(SIGALRM, hanlder);// 主事件循环while (true) {pause(); // 挂起进程,等待信号std::cout << "进程被唤醒..." << std::endl;gcount++; // 更新唤醒计数器// 注意:此处gcount++与信号处理函数中的读取存在竞态条件// 实际项目中应使用原子操作或锁保护}return 0; // 理论上不会执行到此处 }
- 定时机制:
- 使用
alarm(1)
设置1秒定时器 - 每次触发后自动重置定时器(在handler中)
- 可获取上次定时器的剩余时间
- 使用
- 任务系统:
- 通过
gfuncs
向量存储多个任务 - 支持任意可调用对象(函数/lambda等)
- 定时同步执行所有注册任务
- 通过
- 定时机制:
-
简单理解系统闹钟
系统闹钟其本质是操作系统自身必须要具有定时功能,并能让用户设置这种定时功能,才可能实现闹钟这样的技术。
内核中的定时器数据结构:
struct timer_list {struct list_head entry;unsigned long expires;void (*function)(unsigned long);unsigned long data;struct tvec_t_base_s *base; };
操作系统管理定时器,采用的是时间轮做法,我们为了简单理解,可以将其组织成最小堆结构。
2.5 硬件异常产生信号
-
硬件异常
硬件异常指的是异常以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如:
- 当前进程执行了除以 0 的指令,CPU 的运算单元会产生异常,内核将这个异常解释为
SIGFPE
信号发送给进程。 - 当前进程访问了非法内存地址,MMU 会产生异常,内核将这个异常解释为
SIGSEGV
信号发送给进程。 - 异常信号的持续产生:
- 上面的两个例子观察到 8号信号(SIGFPE) 被持续捕获,这是因为 CPU运算异常(如除零) 发生后,OS检测到异常状态但未彻底处理(如未清理上下文或寄存器),导致异常状态一直存在,从而重复触发信号。
- OS与CPU的协作机制:
- CPU通过状态寄存器(位图形式)标记异常(如溢出、除零等),OS会检查这些寄存器并调用对应的异常处理方法。
- 若异常未被完全处理(如未重置寄存器状态或释放资源),硬件会持续通知OS,导致信号重复发送。
- 类比其他异常:
- 类似现象也出现在**非法内存访问(SIGSEGV)**等场景中,根本原因均是异常状态未被清除。
- MMU是CPU的集成器件,负责拿虚拟地址和物理地址去页表进行映射,如果映射不了会触发缺页异常。
核心结论:
异常信号的重复触发源于硬件(CPU/MMU)与OS的交互逻辑——若异常上下文未被清理,硬件会持续上报,OS则持续发送信号。需通过资源释放或状态重置终止这一循环。
- 当前进程执行了除以 0 的指令,CPU 的运算单元会产生异常,内核将这个异常解释为
-
子进程退出core dump
#include <iostream> #include <string> #include <unistd.h> #include <stdlib.h> #include <signal.h> #include <sys/wait.h>int main() {if (fork() == 0) {sleep(1);int a = 10;a /= 0; // 这将产生 SIGFPE 信号exit(0);}int status = 0;waitpid(-1, &status, 0);printf("exit signal: %d, core dump: %d\n", status & 0x7F, (status >> 7) & 1);return 0; }
$ man 7 signalSIGABRT P1990 Core Abort signal from abort(3)// SIGALRM P1990 Term Timer signal from alarm(2)// SIGBUS P2001 Core Bus error (bad memory access) SIGCHLD P1990 Ign Child stopped or terminated SIGCLD - Ign A synonym for SIGCHLD SIGCONT P1990 Cont Continue if stopped SIGEMT - Term Emulator trap SIGFPE P1990 Core Floating-point exception SIGHUP P1990 Term Hangup detected on controlling terminal or death of controlling process SIGILL P1990 Core Illegal Instruction SIGINFO - A synonym for SIGPWR SIGINT P1990 Term Interrupt from keyboard SIGIO - Term I/O now possible (4.2BSD) SIGIOT - Core IOT trap. A synonym for SIGABRT SIGKILL P1990 Term Kill signal SIGLOST - Term File lock lost (unused) SIGPIPE P1990 Term Broken pipe: write to pipe with no readers; see pipe(7) SIGPOLL P2001 Term Pollable event (Sys V).
$ ulimit -c 1024 $ ulimit -acore file size (blocks, -c) 1024 data seg size (kbytes, -d) unlimited scheduling priority (-e) 0 file size (blocks, -f) unlimited pending signals (-i) 7643 max locked memory (kbytes, -l) 65536 max memory size (kbytes, -m) unlimited open files (-n) 65535 pipe size (512 bytes, -p) 8 POSIX message queues (bytes, -q) 819200 real-time priority (-r) 0 stack size (kbytes, -s) 8192 cpu time (seconds, -t) unlimited max user processes (-u) 7643 virtual memory (kbytes, -v) unlimited file locks (-x) unlimited
-
Core Dump
- 当一个进程要终止异常时,可以选择把进程的用户空间内存数据全部保存到磁盘的
core
文件上,这个过程叫做Core Dump
。 - 进程异常终止通常是因为有 Bug,比如非法内存访问导致段错误,事后可以用调试器检查 core 文件以查清错误原因,这叫做 Post-mortem Debug(事后调试)。
- 一个进程允许产生多大的 core 文件取决于进程的 Resource Limit(这个信息保存在 PCB 中)。默认是不允许产生 core 文件的,因为 core 文件中可能包含用户密码等敏感信息,不安全。
- 在开发调试阶段可以用
ulimit
命令改变这个限制,允许产生 core 文件。首先用ulimit
命令改变 Shell 进程的 Resource Limit,如允许 core 文件最大为 1024K:$ ulimit -c 1024
。 - 在云服务器上,
Core Dump
功能是被禁止的。
- 当一个进程要终止异常时,可以选择把进程的用户空间内存数据全部保存到磁盘的
3. 信号保存
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(pending)
- 进程可以选择阻塞(Block)某个信号
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
3.1 信号在内核中的表示
-
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。
-
例如,
SIGHUP
信号未阻塞也未产生过,当它递达时执行默认处理动作;SIGINT
信号产生过但正在被阻塞,所以暂时不能递达,虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞;SIGQUIT
信号未产生过,一旦产生SIGQUIT
信号将被阻塞,它的处理动作是用户自定义函数sighandler
。 -
如果在进程解除对某信号的阻塞之前这种信号产生过多次,
POSIX.1
允许系统递送该信号一次或多次,Linux实现方式是常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里(本章不讨论实时信号)。 -
内核结构:
struct task_struct {/* ... 其他字段 ... *//* signal handlers */struct sighand_struct *sighand; // 指向信号处理程序描述符sigset_t blocked; // 被阻塞的信号掩码struct sigpending 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); // 恢复函数 (Linux/SPARC未使用)__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; // 待处理信号掩码 };
-
sigset_t
从上图来看,每个信号只有一个
bit
的未决标志,非0
即1
,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,**未决和阻塞标志可以用相同的数据类型sigset_t
来存储,这个类型可以表示每个信号的"有效"或"无效"状态。**在阻塞信号集中"有效"和"无效"的含义是该信号是否被阻塞,而在未决信号集中"有效"和"无效"的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字,这里的"屏蔽"应该理解为阻塞而不是忽略。
3.2 信号集操作函数
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
- 函数
sigemptyset
初始化set
所指向的信号集,使其中所有信号的对应bit
清零,表示该信号集不包含任何有效信号。- 函数
sigfillset
初始化set
所指向的信号集,使其中所有信号的对应bit
置位,表示该信号集的有效信号包括系统支持的所有信号。- 注意,在使用
sigset_t
类型的变量之前,一定要调用sigemptyset
或sigfillset
做初始化,使信号集处于确定的状态。初始化sigset_t
变量之后就可以在调用sigaddset
和sigdelset
在该信号集中添加或删除某种有效信号。- 这四个函数都是成功返回
0
,出错返回-1
。sigismember
是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1
,不包含则返回0
,出错返回-1
。
-
sigprocmask
调用函数
sigprocmask
可以读取或更改进程的信号屏蔽字(阻塞信号集)。#include <signal.h> int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为
0
,若出错则为-1
。参数列表:
如果
oset
是非空指针,则读取进程的当前信号屏蔽字通过oset
参数传出;如果set
是非空指针,则更改进程的信号屏蔽字,参数how
指示如何更改。如果
oset
和set
都是非空指针,则先将原来的信号屏蔽字备份到oset
里,然后根据set
和how
参数更改信号屏蔽字。假设当前的信号屏蔽字为
mask
,下表说明了how
参数的可选值。如果调用
sigprocmask
解除了对当前若干个未决信号的阻塞,则在sigprocmask
返回前,至少将其中⼀个信号递达。 -
sigpending
#include <signal.h> int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过输出型参数
set
传出。调用成功返回
0
,出错则返回-1
。 -
示例代码:
#include <iostream> #include <unistd.h> #include <cstdio> #include <sys/types.h> #include <sys/wait.h>// 打印当前进程的未决信号集(pending signals) void PrintPending(sigset_t &pending) {std::cout << "curr process[" << getpid() << "]pending: ";// 从31号信号遍历到1号信号(通常1~31是标准信号)for (int signo = 31; signo >= 1; signo--){// 检查信号 signo 是否在 pending 集合中if (sigismember(&pending, signo)){std::cout << 1; // 信号未决(pending)}else{std::cout << 0; // 信号未处于未决状态}}std::cout << "\n"; }// 信号处理函数 void handler(int signo) {std::cout << signo << " 号信号被递达!!!" << std::endl;std::cout << "-------------------------------" << std::endl;sigset_t pending;sigpending(&pending); // 获取当前未决信号集PrintPending(pending); // 打印未决信号std::cout << "-------------------------------" << std::endl; }int main() {// 0. 设置2号信号(SIGINT,通常由Ctrl+C触发)的处理方式signal(2, handler); // 自定义信号处理函数// signal(2, SIG_IGN); // 忽略该信号// signal(2, SIG_DFL); // 恢复默认处理方式(通常是终止进程)// 1. 屏蔽2号信号(SIGINT)sigset_t block_set, old_set;sigemptyset(&block_set); // 初始化空信号集sigemptyset(&old_set); // 初始化空信号集(用于备份旧的屏蔽字)sigaddset(&block_set, SIGINT); // 将 SIGINT(2号信号)加入 block_set// 1.1 将 block_set 中的信号加入进程的信号屏蔽字(Block表)sigprocmask(SIG_BLOCK, &block_set, &old_set); // 屏蔽 SIGINT,并备份旧的屏蔽字到 old_setint cnt = 15;while (true){// 2. 获取当前进程的未决信号集(pending signals)sigset_t pending;sigpending(&pending);// 3. 打印 pending 信号集PrintPending(pending);cnt--;// 4. 15秒后解除对2号信号的屏蔽if (cnt == 0){std::cout << "解除对2号信号的屏蔽!!!" << std::endl;// 恢复旧的屏蔽字(解除对 SIGINT 的屏蔽)sigprocmask(SIG_SETMASK, &old_set, &block_set);}sleep(1); // 每秒检查一次} }
-
代码执行流程说明:
- 信号处理设置:
signal(2, handler)
设置SIGINT
(2号信号,通常由Ctrl+C
触发)的处理方式为自定义函数handler
。- 如果使用
SIG_IGN
,则忽略该信号;如果使用SIG_DFL
,则恢复默认行为(终止进程)。
- 信号屏蔽:
sigemptyset(&block_set)
初始化空信号集。sigaddset(&block_set, SIGINT)
将SIGINT
加入block_set
。sigprocmask(SIG_BLOCK, &block_set, &old_set)
将SIGINT
加入进程的信号屏蔽字(Block 表),使其被阻塞(无法递达)。
- 循环检测 pending 信号:
sigpending(&pending)
获取当前未决信号集(已收到但被阻塞的信号)。PrintPending(pending)
打印未决信号状态(1
表示未决,0
表示无)。- 15秒后,
sigprocmask(SIG_SETMASK, &old_set, &block_set)
恢复旧的屏蔽字,解除对SIGINT
的屏蔽,使其可以递达。
- 信号递达:
- 如果在屏蔽期间按下
Ctrl+C
,SIGINT
会被阻塞,pending
集对应位变为1
。 - 解除屏蔽后,
SIGINT
递达,执行handler
函数,打印未决信号状态。
- 如果在屏蔽期间按下
- 信号处理设置:
-
输出示例:
curr process[448336]pending: 0000000000000000000000000000000 curr process[448336]pending: 0000000000000000000000000000000 ^Ccurr process[448336]pending: 0000000000000000000000000000010 # 按下 Ctrl+C,pending 位变为 1 ... 解除对2号信号的屏蔽!!! 2 号信号被递达!!! # 信号递达,执行 handler ------------------------------- curr process[448336]pending: 0000000000000000000000000000000 # pending 恢复为 0 -------------------------------
程序运⾏时,每秒钟把各信号的未决状态打印⼀遍,由于我们阻塞了
SIGINT
信号,按Ctrl-C
将会 使SIGINT
信号处于未决状态,按
Ctrl-\
仍然可以终⽌程序,因为SIGQUIT
信号没有阻塞。
-
4. 信号捕捉
4.1 信号捕捉的流程
- **如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。**由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:
- 用户程序注册了
SIGQUIT
信号的处理函数sighandler
。 - 当前正在执行
main
函数,这时发生中断或异常切换到内核态。 - 在中断处理完毕后要返回用户态的
main
函数之前检查到有信号SIGQUIT
递达。内核决定返回用户态后不是恢复main
函数的上下文继续执行,而是执行sighandler
函数,sighandler
和main
函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。 sighandler
函数返回后自动执行特殊的系统调用sigreturn
再次进入内核态。- 如果没有新的信号要递达,这次再返回用户态就是恢复
main
函数的上下文继续执行了。
4.2 sigaction
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
sigaction
函数可以读取和修改与指定信号相关联的处理动作,调用成功则返回0,出错则返回-1。signo
是指定信号的编号,若act
指针非空,则根据act
修改该信号的处理动作;若oact
指针非空,则通过oact
传出该信号原来的处理动作。act
和oact
指向sigaction
结构体:将sa_handler
赋值为常数SIG_IGN
传给sigaction
表示忽略信号,赋值为常数SIG_DFL
表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数。该函数返回值为void
,可以带一个int
参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。- 显然,这也是一个回调函数,不是被
main
函数调用,而是被系统所调用。当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。- 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用
sa_mask
字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
4.3 操作系统的运行流程
4.3.1 硬件中断
-
中断向量表就是操作系统的一部分,启动就加载到内存中了。
-
通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询。
-
允许外部设备或内部事件打断处理器当前的执行流程,以优先处理更紧急的任务叫做硬件中断。
-
中断向量表的内核源码:
// 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(.data 是端⼝号)。init(tty_table[2].read_q.data); // 初始化串⾏⼝2。outb(inb_p(0x21) & 0xE7, 0x21); // 允许主8259A 芯⽚的IRQ3,IRQ4 中断信号请求。 }
4.3.2 时钟中断
-
时钟中断是操作系统通过硬件定时器(时钟源)周期性(如每秒100次)触发的CPU中断,强制暂停当前任务并执行内核的中断处理程序,用于实现多任务切换(时间片轮转)、更新系统时间、统计资源使用情况等核心功能,是操作系统管理CPU和时间的基石机制。
-
时间戳可以根据时钟中断记录历史总频率
total
,total
每时每刻都在++
,这样我的计算机就算是离线的情况,也知道现在是几点了。 -
时钟中断内核源码:
// Linux 内核0.11 // main.c sched_init(); // 调度程序初始化(加载了任务0 的tr, ldtr) (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:...; // do_timer(CPL)执⾏任务切换、计时等⼯作,在kernel/shched.c,305 ⾏实现。call _do_timer ; // 'do_timer(long CPL)' does everything from// 调度⼊⼝ void do_timer(long cpl) {...schedule(); }void schedule(void) {...switch_to(next); // 切换到任务号为next 的任务,并运⾏之。 }
4.3.3 死循环
操作系统本身是不做任何事情的,需要什么功能,就向中断向量表里面添加方法即可。
操作系统的本质就是一个死循环,是基于中断进行工作的软件。
void main(void) /* 这⾥确实是void,并没错。 */
{ /* 在startup 程序(head.s)中就是这样假设的。 */...
/*
* 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到⼀个信号才会返
* 回就绪运⾏态,但任务0(task0)是唯⼀的意外情况(参⻅'schedule()'),因为任
* 务0 在任何空闲时间⾥都会被激活(当没有其它任务在运⾏时),
* 因此对于任务0'pause()'仅意味着我们返回来查看是否有其它任务可以运⾏,如果没
* 有的话我们就回到这⾥,⼀直循环执⾏'pause()'。
*/for (;;)pause();
} // end main
从上方代码就能看出,操作系统在硬件时钟的推动下,自动调度了。
CPU主频(如3.0GHz)是CPU内部时钟的速度,决定其每秒能执行多少条指令(十亿次周期/秒),决定其执行指令的绝对速度;时间片(如10ms)是操作系统分配给每个任务的CPU最长连续运行时间,由时钟中断(如100Hz,即每10ms触发一次)强制打断当前任务,检查是否需要切换进程。
时钟中断的触发与CPU主频无关,但时间片的实际执行时长会因CPU主频不同而影响任务完成的指令量(主频越高,同一时间片内能执行的指令越多)。
4.3.4 软中断
上述外部硬件中断,需要硬件外部触发。为了让操作系统支持系统调用,CPU也设计了对应的汇编指令int 或 syscall
,可以让CPU内部触发中断逻辑(软中断)。
-
用户层通过寄存器(如EAX)把系统调用号给操作系统。
-
操作系统通过寄存器或用户传入的缓存区地址把返回值返回给用户。
-
Linux的
gnu C
标准库,把int 0x80 或 syscall
封装到了几乎所有的系统调用中。 -
系统调用的过程:其实就是先
int 0x80 或 syscall
陷入内核(触发软中断),CPU就会自动执行系统调用的处理方法,而这个方法会根据系统调用号,自动查表,执行对应的方法。 -
系统调用的过程也是在进程地址空间上进行的,所有的函数调用,都是地址空间之间的跳转。
-
系统调用号的本质:数组下标
-
系统调用函数指针表:
// 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) extern int sys_read(); // 读⽂件。 (fs/read_write.c, 55) extern int sys_write(); // 写⽂件。 (fs/read_write.c, 83) extern int sys_open(); // 打开⽂件。 (fs/open.c, 138) extern int sys_close(); // 关闭⽂件。 (fs/open.c, 192) extern int sys_waitpid(); // 等待进程终⽌。 (kernel/exit.c, 142) extern int sys_creat(); // 创建⽂件。 (fs/open.c, 187) extern int sys_link(); // 创建⼀个⽂件的硬连接。 (fs/namei.c, 721) extern int sys_unlink(); // 删除⼀个⽂件名(或删除⽂件)。 (fs/namei.c, 663) extern int sys_execve(); // 执⾏程序。 (kernel/system_call.s, 200) extern int sys_chdir(); // 更改当前⽬录。 (fs/open.c, 75) extern int sys_time(); // 取当前时间。 (kernel/sys.c, 102) extern int sys_mknod(); // 建⽴块/字符特殊⽂件。 (fs/namei.c, 412) extern int sys_chmod(); // 修改⽂件属性。 (fs/open.c, 105) extern int sys_chown(); // 修改⽂件宿主和所属组。 (fs/open.c, 121) extern int sys_break(); // (-kernel/sys.c, 21) extern int sys_stat(); // 使⽤路径名取⽂件的状态信息。 (fs/stat.c, 36) extern int sys_lseek(); // 重新定位读/写⽂件偏移。 (fs/read_write.c, 25) extern int sys_getpid(); // 取进程id。 (kernel/sched.c, 348) extern int sys_mount(); // 安装⽂件系统。 (fs/super.c, 200) extern int sys_umount(); // 卸载⽂件系统。 (fs/super.c, 167) extern int sys_setuid(); // 设置进程⽤⼾id。 (kernel/sys.c, 143) extern int sys_getuid(); // 取进程⽤⼾id。 (kernel/sched.c, 358) extern int sys_stime(); // 设置系统时间⽇期。 (-kernel/sys.c, 148) extern int sys_ptrace(); // 程序调试。 (-kernel/sys.c, 26) extern int sys_alarm(); // 设置报警。 (kernel/sched.c, 338) extern int sys_fstat(); // 使⽤⽂件句柄取⽂件的状态信息。(fs/stat.c, 47) extern int sys_pause(); // 暂停进程运⾏。 (kernel/sched.c, 144) extern int sys_utime(); // 改变⽂件的访问和修改时间。 (fs/open.c, 24) extern int sys_stty(); // 修改终端⾏设置。 (-kernel/sys.c, 31) extern int sys_gtty(); // 取终端⾏设置信息。 (-kernel/sys.c, 36) extern int sys_access(); // 检查⽤⼾对⼀个⽂件的访问权限。(fs/open.c, 47) extern int sys_nice(); // 设置进程执⾏优先权。 (kernel/sched.c, 378) extern int sys_ftime(); // 取⽇期和时间。 (-kernel/sys.c,16) extern int sys_sync(); // 同步⾼速缓冲与设备中数据。 (fs/buffer.c, 44) extern int sys_kill(); // 终⽌⼀个进程。 (kernel/exit.c, 60) extern int sys_rename(); // 更改⽂件名。 (-kernel/sys.c, 41) extern int sys_mkdir(); // 创建⽬录。 (fs/namei.c, 463) extern int sys_rmdir(); // 删除⽬录。 (fs/namei.c, 587) extern int sys_dup(); // 复制⽂件句柄。 (fs/fcntl.c, 42) extern int sys_pipe(); // 创建管道。 (fs/pipe.c, 71) extern int sys_times(); // 取运⾏时间。 (kernel/sys.c, 156) extern int sys_prof(); // 程序执⾏时间区域。 (-kernel/sys.c, 46) extern int sys_brk(); // 修改数据段⻓度。 (kernel/sys.c, 168) extern int sys_setgid(); // 设置进程组id。 (kernel/sys.c, 72) extern int sys_getgid(); // 取进程组id。 (kernel/sched.c, 368) extern int sys_signal(); // 信号处理。 (kernel/signal.c, 48) extern int sys_geteuid(); // 取进程有效⽤⼾id。 (kenrl/sched.c, 363) extern int sys_getegid(); // 取进程有效组id。 (kenrl/sched.c, 373) extern int sys_acct(); // 进程记帐。 (-kernel/sys.c, 77) extern int sys_phys(); // (-kernel/sys.c, 82) extern int sys_lock(); // (-kernel/sys.c, 87) extern int sys_ioctl(); // 设备控制。 (fs/ioctl.c, 30) extern int sys_fcntl(); // ⽂件句柄操作。 (fs/fcntl.c, 47) extern int sys_mpx(); // (-kernel/sys.c, 92) extern int sys_setpgid(); // 设置进程组id。 (kernel/sys.c, 181) extern int sys_ulimit(); // (-kernel/sys.c, 97) extern int sys_uname(); // 显⽰系统信息。 (kernel/sys.c, 216) extern int sys_umask(); // 取默认⽂件创建属性码。 (kernel/sys.c, 230) extern int sys_chroot(); // 改变根系统。 (fs/open.c, 90) extern int sys_ustat(); // 取⽂件系统信息。 (fs/open.c, 19) extern int sys_dup2(); // 复制⽂件句柄。 (fs/fcntl.c, 36) extern int sys_getppid(); // 取⽗进程id。 (kernel/sched.c, 353) extern int sys_getpgrp(); // 取进程组id,等于getpgid(0)。(kernel/sys.c, 201) extern int sys_setsid(); // 在新会话中运⾏程序。 (kernel/sys.c, 206) extern int sys_sigaction();// 改变信号处理过程。 (kernel/signal.c, 63) extern int sys_sgetmask(); // 取信号屏蔽码。 (kernel/signal.c, 15) extern int sys_ssetmask(); // 设置信号屏蔽码。 (kernel/signal.c, 20) extern int sys_setreuid(); // 设置真实与/或有效⽤⼾id。 (kernel/sys.c,118) extern int sys_setregid(); // 设置真实与/或有效组id。 (kernel/sys.c, 51)// 系统调⽤函数指针表。⽤于系统调⽤中断处理程序(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) {...// 设置系统调⽤中断⻔。set_system_gate(0x80, &system_call); }_system_call:cmp eax,nr_system_calls-1 // 调⽤号如果超出范围的话就在eax 中置-1 并退出。ja bad_sys_callpush ds // 保存原段寄存器值。push espush fspush edx // ebx,ecx,edx 中放着系统调⽤相应的C 语⾔函数的调⽤参数。push ecx // push %ebx,%ecx,%edx as parameterspush ebx // to the system callmov edx,10h // set up ds,es to kernel spacemov ds,dx // ds,es 指向内核数据段(全局描述符表中数据段描述符)。mov es,dxmov edx,17h // fs points to local data spacemov fs,dx // fs 指向局部数据段(局部描述符表中数据段描述符)。// 下⾯这句操作数的含义是:调⽤地址 = _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 // 取当前任务(进程)数据结构地址??eax。// 下⾯97-100 ⾏查看当前任务的运⾏状态。如果不在就绪状态(state 不等于0)就去执⾏调度程序。// 如果该任务在就绪状态但counter[??]值等于0,则也去执⾏调度程序。cmp dword ptr [state+eax],0 // statejne reschedulecmp dword ptr [counter+eax],0 // counterje reschedule// 以下这段代码执⾏从系统调⽤C 函数返回后,对信号量进⾏识别处理。 ret_from_sys_call:
-
缺页中断,内存碎片处理,除零和野指针错误
// 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); // 设置并行⼝的陷阱⻔。 }
缺页中断,内存碎片处理,除零和野指针错误这些问题,全部都会被转换成为CPU内部的软中断,然后走中断处理例程,完成所有处理。有的是进行申请内存,填充页表,进行映射的。有的是用来处理内存碎片的,有的是用来给目标进行发送信号,杀掉进程的。
-
所以:
操作系统就是躺在中断处理例程上的代码块
CPU内部的软中断,比如
int 0x80 或 syscall
,我们叫做陷阱。CPU内部的软中断,比如除零、野指针等,我们叫做 异常。
4.4 如何理解内核态和用户态
-
结论:
- 操作系统系统调用方法的执行是在进程的地址空间中执行的!
- 用户态就是执行用户
[0,3]GB
时所处的状态,内核态就是执行内核[3,4]GB
时所处的状态,区分就是按照CPU内的CPL
决定,CPL
的全称是Current Privilege Level
,即当前特权级别。 - 一般执行
int 0x80或syscall
软中断,CPL
会在校验之后自动变更。
用户态和内核态详情请看附录
5. 可重入函数
-
过程说明:
main
函数调用insert
函数向一个链表head
中插入节点node1
,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler
函数,sighandler
也调用insert
函数向同一个链表head
中插入节点node2
,插入操作的两步都做完之后从sighandler
返回内核态,再次回到用户态就从main
函数调用的insert
函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main
函数和sighandler
先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。像上例这样,
insert
函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert
函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant
)函数。 -
如果一个函数符合以下条件之一则是不可重入的:
- 调用了
malloc
或free
,因为malloc
也是用全局链表来管理堆的。 - 调用了标准
I/O
库函数。标准I/O
库的很多实现都以不可重入的方式使用全局数据结构。
- 调用了
6. volatile
-
volatile
:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。 -
代码示例:
- 没有volatile的情况
#include <iostream> #include <thread> #include <chrono>bool flag = false; // 注意:这里没有volatilevoid thread_function() {while (!flag) {// 空循环}std::cout << "Thread 1: Flag changed to true" << std::endl; }int main() {std::thread t1(thread_function);std::this_thread::sleep_for(std::chrono::seconds(1));flag = true;std::cout << "Main thread: Changed flag to true" << std::endl;t1.join();return 0; }
可能出现的问题:
- 编译器可能会优化
while(!flag)
循环,将其变为只检查一次flag的值然后进入无限循环 - 由于没有内存屏障,线程可能看不到flag的更新值
- 使用volatile的情况
#include <iostream> #include <thread> #include <chrono>volatile bool flag = false; // 使用volatilevoid thread_function() {while (!flag) {// 空循环}std::cout << "Thread 1: Flag changed to true" << std::endl; }int main() {std::thread t1(thread_function);std::this_thread::sleep_for(std::chrono::seconds(1));flag = true;std::cout << "Main thread: Changed flag to true" << std::endl;t1.join();return 0; }
volatile的作用:
- 防止编译器优化:确保每次循环都从内存读取flag的值,而不是使用寄存器中的缓存值
- 可见性:确保flag的修改对其他线程可见(但在多线程环境中,volatile并不完全等同于内存屏障)
7. SIGCHLD信号
-
避免僵尸进程的方法:
- 父进程通过自定义SIGCHLD信号处理函数,在信号处理中调用wait来回收子进程。
- 将SIGCHLD信号的处理动作显式设置为SIG_IGN(忽略),使子进程终止时自动被系统回收。第一种方法更通用,第二种是Linux特有的方法。
-
代码示例:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <signal.h> #include <sys/wait.h>// 方法1:SIGCHLD信号处理函数 void sigchld_handler(int sig) {int status;pid_t pid = wait(&status);printf("Child %d terminated with exit status %d\n", pid, WEXITSTATUS(status)); }int main() {// 方法1示例signal(SIGCHLD, sigchld_handler);pid_t pid = fork();if (pid == 0) {exit(2); // 子进程退出} else {sleep(1); // 父进程继续工作}// 方法2示例:设置SIG_IGN自动回收struct sigaction sa;sa.sa_handler = SIG_IGN;sigemptyset(&sa.sa_mask);sa.sa_flags = 0;sigaction(SIGCHLD, &sa, NULL);pid = fork();if (pid == 0) {exit(3); // 子进程退出} else {sleep(1); // 父进程继续工作(不会产生僵尸进程)}return 0; }
8. 附录 - 用户态内核态
-
指令集:
- CPU指令集是CPU实现软件指挥硬件执行的媒介,具体来说每一条汇编语句都对应了一条CPU指令,而非常多CPU指令在一起可以组成一个甚至多个集合,指令的集合叫CPU指令集。
- CPU指令集是有权限分级的,由于CPU指令集可以直接操作硬件的,要是因为指令操作的不规范,造成的错误会影响整个计算机系统的。对开发人员来说是个艰巨的任务,还会增加负担,同时开发人员在这方面也不被信任,所以操作系统内核直接屏蔽开发人员对硬件操作的可能,不允许碰到这些CPU指令集。
-
CPL
当前特权级别针对上述的需求,硬件设备商直接提供硬件级别的支持,做法就是对 CPU 指令集设置了权限,不同级别权限能使用的 CPU 指令集是有限的。以 Intel CPU 为例,Intel 把 CPU 指令集操作的权限由高到低划分为 4 级:
- Ring 0:权限最高,可以使用所有 CPU 指令集。
- Ring 1
- Ring 2
- Ring 3:权限最低,仅能使用常规 CPU 指令集,不能使用操作硬件资源的 CPU 指令集,比如 I/O 读写、网卡访问、申请内存都不行。
**需要注意的是,Linux 系统仅采用
Ring 0
和Ring 3
这两个权限。CPU 中有一个标志字段,标志着线程的运行状态:用户态为 3,内核态为 0。 **- Ring 0(内核态):完全在操作系统内核中运行,执行内核空间的代码,具有 Ring 0 保护级别,拥有对硬件的所有操作权限,可以执行所有 CPU 指令集,访问任意地址的内存。在内核模式下的任何异常都是灾难性的,可能会导致整台机器停机。
- Ring 3(用户态):在应用程序中运行,具有 Ring 3 保护级别,代码没有对硬件的直接控制权限,也不能直接访问内存,程序通过调用系统接口(System Call APIs)来访问硬件和内存。在这种保护模式下,即使程序崩溃也是可恢复的。计算机上大部分程序都在用户态下运行。
低权限的资源范围较小,高权限的资源范围更大,因此用户态与内核态的本质区别就是 CPU 指令集权限的不同。
-
内存资源上的内核态与用户态:
-
-
在内存资源上的使⽤,操作系统对用户态与内核态也做了限制,为用户态与内核态划分了两块内存空间,给它们对应的指令集使⽤。每个进程创建都会分配虚拟空间地址,以Linux32位操作系统为例:
-
它的寻址空间范围是4G(2的32次⽅),而操作系统会把虚拟控制地址划分为两部分,⼀部分为内核空间,另⼀部分为用户空间,⾼位的1G(从虚拟地址0xC0000000到0xFFFFFFFF)由内核使用,而低位的3G(从虚拟地址0x00000000到0xBFFFFFFF)由各个进程使用。
-
-
-
用户态只能操作0-3G范围的低位虚拟空间地址,
-
而内核态可以操作0-4G范围的虚拟空间地址,尤其是对3-4G范围的⾼位虚拟空间地址必须由内核态去操作。
- 3G-4G部分⼤家是共享的(指所有进程的内核态逻辑地址是共享同⼀块内存地址)只有一份,是内核态的地址空间,这⾥存放在整个内核的代码和所有的内核模块,以及内核所维护的数据。
- 在内核运行的过程中,会涉及内核栈的分配,内核的进程管理的代码会将内核栈创建在内核空间中,当然相应的页表也会被创建。
-
-
用户态与内核态的切换:
-
什么情况会导致用户态到内核态切换?
- 系统调用是用户态进程主动切换到内核态的方式,用户态进程通过系统调用向操作系统申请资源完成工作,例如fork()就是一个创建新进程的系统调用。
- 操作系统提供了中断指令int 0x80来主动进入内核,这是用户程序发起的调用访问内核代码的唯一方式。调用系统函数时会通过内联汇编代码插入int 0x80的中断指令,内核接收到int 0x80中断后,查询中断处理函数地址,随后进入系统调用。
- 异常也会导致切换,当CPU在执行用户态的进程时,发生了一些没有预知的异常,这时当前运行进程会切换到处理此异常的内核相关进程中,也就是切换到了内核态,如缺页异常。
- 中断同样会导致切换,当CPU在执行用户态的进程时,外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令,转到与中断信号对应的处理程序去执行,也就是切换到了内核态,如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后边的操作等。
-
切换时CPU需要做什么?
- 当某个进程中要读写IO,必然会用到ring 0级别的CPU指令集。而此时CPU的指令集操作权限只有ring 3,为了可以操作ring 0级别的CPU指令集,CPU切换指令集操作权限级别为ring 0(可称之为提权),CPU再执行相应的ring 0级别的CPU指令集(内核代码)。
- 代码发生提权时,CPU是需要切换栈的,内核有自己的内核栈。CPU切换栈是需要栈段描述符(ss寄存器)和栈顶指针(esp寄存器),这两个值从哪里来?
- CPU通过一个段寄存器(tr)确定TSS(任务状态段,struct TSS)的位置。在TSS结构中存在这么一个SS0和ESP0。提权的时候,CPU就从这个TSS里把SS0和ESP0取出来,放到ss和esp寄存器中。
-
切换流程如下:
- 从用户态切换到内核态时,首先用户态可以直接读写寄存器,用户态操作CPU,将寄存器的状态保存到对应的内存中,然后调用对应的系统函数,传入对应的用户栈地址和寄存器信息,方便后续内核方法调用完毕后,恢复用户方法执行的现场。
- 从用户态切换到内核态需要提权,CPU切换指令集操作权限级别为ring 0。
- 提权后,切换内核栈。然后开始执行内核方法,相应的方法栈帧时保存在内核栈中。
- 当内核方法执行完毕后,CPU切换指令集操作权限级别为ring 3,然后利用之前写入的信息来恢复用户栈的执行。
-
从上述流程可以看出用户态切换到内核态的时候,会牵扯到用户态现场信息的保存以及恢复,还要进行一系列的安全检查,还是比较耗费资源的。