1. 信号概述
1.1 什么是信号?
-
类比生活中的信号 :闹钟、红绿灯、上课铃声、狼烟、电话铃声、肚子叫、敲门声、脸色不好......
信号中断了我们正在做的事情,是一种事件异步通知机制。
-
定义 :信号是一种给进程发送的 ,用来进行事件异步通知 的机制。
信号的产生,相对于进程的运行,是异步的。
-
异步与同步的对比:
-
同步:"我们自习一会,等张三回来再讲" --- 进程的执行依赖某个条件。
-
异步:"我们继续上课,上课时张三去取快递" --- 信号的发生与当前进程执行流程无关。
-
1.2 关于信号处理的基本结论
-
预先知晓:进程在信号没有产生的时候,就已经知道信号该如何处理(就像人必须把要处理的事情记录下来)。
-
延迟处理 :信号的处理不是立即的,进程可以在合适的时候再进行信号处理。
-
内置识别 :人能识别信号是提前被"教育"过的,进程也是如此。操作系统程序员设计的进程,早已经内置了对于信号的识别和处理方式。
-
多源信号:信号源非常多,给进程产生信号的来源也非常多。
2. 信号的产生方式
2.1 键盘产生信号
-
典型示例 :
Ctrl + C是给目标进程发送信号,相当一部分信号的处理动作是终止进程。 -
信号有哪些?
- 使用
kill -l命令可以列出所有信号:
- 使用
text
kiana@111:~/merde/class/lesson26$ 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
(16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
(21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
(26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
(31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
...
常用信号及其宏定义(从 /usr/include/ 中摘录):
java
#define SIGINT 2 /* Interactive attention signal. */
#define SIGILL 4 /* Illegal instruction. */
#define SIGABRT 6 /* Abnormal termination. */
#define SIGFPE 8 /* Erroneous arithmetic operation. */
#define SIGSEGV 11 /* Invalid access to storage. */
#define SIGTERM 15 /* Termination request. */
#define SIGHUP 1 /* Hangup. */
#define SIGQUIT 3 /* Quit. */
#define SIGTRAP 5 /* Trace/breakpoint trap. */
#define SIGKILL 9 /* Killed. */
#define SIGBUS 10 /* Bus error. */
#define SIGSYS 12 /* Bad system call. */
#define SIGPIPE 13 /* Broken pipe. */
#define SIGALRM 14 /* Alarm clock. */
- 如何证明信号被发送和处理?
我们可以通过signal函数更改进程对特定信号的默认处理动作,来观察信号的处理过程。
2.2 前后台进程
-
前台进程:
-
启动方式:
./XXX(不带&)。 -
本质:必须从键盘获取数据的进程。
-
键盘产生的信号只能发给前台进程。
-
系统中只有一个前台进程。
-
-
后台进程:
-
启动方式:
./YYY &。 -
无法从标准输入(键盘)获取内容,但可以向标准输出打印。
-
后台进程可以有多个。
-
-
前后台任务管理命令:
-
jobs:查看所有的后台任务。 -
fg [任务号]:将特定的后台进程提到前台。 -
Ctrl + Z:将当前前台进程切换到后台并暂停。 -
bg [任务号]:让后台暂停的进程恢复运行。
-
-
孤儿进程:
- 当父进程先退出,子进程会成为孤儿进程,被自动提到后台运行,
Ctrl + C无法杀掉它。
- 当父进程先退出,子进程会成为孤儿进程,被自动提到后台运行,
2.3 系统调用产生信号
kill函数:向任意进程发送信号。
c
#include <signal.h>
int kill(pid_t pid, int sig);
raise 函数:向调用者自身发送信号。
c
#include <signal.h>
int raise(int sig);
abort 函数:使进程异常终止。
c
#include <stdlib.h>
void abort(void);
2.3.1 系统命令产生信号
bash
kill -<信号编号> <进程PID>
2.3.2 硬件异常产生信号
-
常见异常:
-
除0错误(
SIGFPE) -
野指针访问(
SIGSEGV) -
例如:
-
c
int a = 10;
a /= 0; // 除0错误
int *p = nullptr;
*p = 100; // 野指针
-
底层原理:
-
CPU执行指令时发现异常(如除0、内存访问越界),会设置状态寄存器(如EFLAGS)中的标志位(如溢出标志)。
-
操作系统作为软硬件资源的管理者,在捕获硬件异常后,识别出是哪个进程出错(通过当前进程的上下文)。
-
操作系统向该进程发送相应的信号。
-
-
寄存器相关知识(完整列表):
-
通用寄存器:
-
EAX、EBX、ECX、EDX:用于算术和逻辑运算,EAX还用于函数返回值。 -
ESI、EDI:用于字符串操作,ESI为源索引,EDI为目标索引。 -
ESP、EBP:ESP为栈指针,指向栈顶;EBP为基址指针,指向栈基址。 -
R8-R15:用于函数调用时的参数传递或其他用途。
-
-
段寄存器 :
CS、DS、ES、FS、GS、SS(现代CPU中较少使用)。 -
控制寄存器:
-
CR0、CR1、CR2、CR3、CR4:控制CPU的行为和状态。 -
CR3:保存页目录表的物理地址。
-
-
调试寄存器 :
DR0-DR7,用于调试。 -
系统地址寄存器 :
GDTR、IDTR、LDTR、TR,存储系统地址信息。 -
其他寄存器:
-
EIP:指令指针,存储下一条指令的地址。 -
TSC:时间戳计数器,用于计时。
-
-
-
关键流程:
-
CPU将虚拟地址交给MMU(内存管理单元)转换,如果转换失败(如页表项不存在或权限错误),硬件报错。
-
操作系统根据当前进程的
task_struct发送信号。
-
-
结论 :信号,全部都是操作系统发送的!
程序犯错了 → 操作系统识别错误 → 操作系统发送信号。
2.3.3 软件条件产生信号
-
管道破裂 (
SIGPIPE) :当进程向一个已经关闭读端的管道写入数据时,内核会向写进程发送SIGPIPE信号。 -
alarm系统调用 :设置一个闹钟,在指定的秒数后向自身发送SIGALRM信号。
c
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
-
alarm(0):取消之前的闹钟。 -
返回值:上一次调用
alarm剩余的秒数。 -
示例:
c
alarm(5); // 5秒后发送SIGALRM
alarm(0); // 取消闹钟
如果先调用 alarm(5),3秒后又调用 alarm(10),则闹钟时间重置为10秒,并返回上次剩余的秒数(2秒)。
pause系统调用:使进程挂起,直到收到一个信号。
c
#include <unistd.h>
int pause(void);
闹钟的管理:
-
操作系统内部可能同时存在很多闹钟,OS需要对闹钟进行管理(先描述,再组织)。
-
内核中使用
struct timer_list结构描述一个定时器:
c
struct timer_list {
unsigned long expires;
void (*function)(unsigned long);
unsigned long data;
struct tvec_base *tbase;
};
通常使用最小堆来管理所有定时器,以便最快找到即将超时的闹钟。
2.4 核心转储 (Core Dump)

2.4.1 什么是核心转储?
-
定义 :当进程异常退出时(如收到
SIGSEGV、SIGFPE等),操作系统可以将该进程在内存中的核心数据(进程上下文、堆栈、内存映像等)拷贝到磁盘,形成一个文件,称为核心转储(core dump)。 -
用途 :用于事后调试 。使用
gdb结合core文件可以快速定位程序崩溃时的位置和状态。 -
默认状态 :云服务器上通常禁止 core dump(
core file size为0)。
2.4.2 如何开启和查看核心转储
- 使用
ulimit命令查看和修改限制:
c
# 查看所有限制
kaian@111:~/code/merge_class/lesson27$ ulimit -a
core file size (blocks, -c) 0
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 file size为0表示禁用 core dump。
- 开启 core dump:
bash
kiana@111:~$ ulimit -c 40960 # 设置为40960个块(通常1块=512字节,所以约20MB)
kiana@111:~$ ulimit -a
core file size (blocks, -c) 40960
-
文件名格式:
-
CentOS 7:
core.pid -
Ubuntu:
core.XX
-
-
验证核心转储 :
运行一个会崩溃的程序,例如除0错误,会看到类似输出:
bash
kiana@111:~/code/merge_class/lesson27$ ./testsig
hello bit
hello bit
Floating point exception (core dumped)
之后当前目录下会生成 core 文件,用 gdb 调试:
bash
gdb ./testsig core
(gdb) bt # 查看调用栈,直接定位到出错行
2.4.3 进程退出状态与信号
-
正常终止:退出状态的高8位(15-8位)存储退出码,低8位(7-0位)为0。
-
被信号杀死 :退出状态的低7位(6-0位)存储终止信号编号,第7位(bit 7)表示是否发生了 core dump。
- 例如,进程被信号
SIGSEGV终止且发生了 core dump,则退出状态的第7位为1。
- 例如,进程被信号
-
验证方法 :
可以通过 shell 的
$?变量获取上一个命令的退出状态,然后解析其位来查看信号编号和 core dump 标志。
3. 信号的保存

3.1 相关概念
-
信号递达 (Delivery):实际执行信号的处理动作。
-
信号未决 (Pending):信号从产生到递达之间的状态。
-
信号阻塞 (Block):进程可以选择阻塞某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除对该信号的阻塞,才执行递达动作。
-
注意:阻塞和忽略是不同的。阻塞是信号不递达,忽略是递达后的一种处理动作。
3.2 内核中的数据结构
每个进程的 task_struct 中维护了与信号相关的三个核心表:
-
pending表:位图,记录当前收到了哪些信号(未决信号)。-
比特位的位置 → 信号编号。
-
比特位的内容 → 1表示收到,0表示未收到。
-
-
block表:位图,记录哪些信号被阻塞。-
比特位的位置 → 信号编号。
-
比特位的内容 → 1表示阻塞,0表示不阻塞。
-
-
handler表 :一个函数指针数组sighandler_t handler[31]。-
数组下标 → 信号编号。
-
数组内容 → 指向该信号的处理函数。
-
- 默认处理动作:
c
#define SIG_DFL ((__sighandler_t) 0) /* Default action. */
#define SIG_IGN ((__sighandler_t) 1) /* Ignore signal. */
typedef void (*__sighandler_t)(int);
- 用户自定义处理:通过
signal函数注册:
c
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
- 信号是否可以被递达的判断:
text
pending & (~block)
即信号已经产生(pending位为1)且没有被阻塞(block位为0)时,信号才能被递达。
3.3 信号集(sigset_t)
sigset_t 类型用于表示信号集合,每个信号用一个 bit 表示"有效"或"无效"状态 。其内部如何存储这些 bit 取决于系统实现,从使用者的角度不必关心 ,也不应直接操作其内部数据 (例如用 printf 直接打印 sigset_t 变量是没有意义的)。
头文件
#include <signal.h>
3.3.1 sigemptyset()
cpp
int sigemptyset(sigset_t *set);
| 项目 | 说明 |
|---|---|
| 功能 | 初始化信号集,将所有信号的对应 bit 清零,表示该信号集不包含任何有效信号 |
| 参数 | set:指向要初始化的 sigset_t 变量的指针 |
| 返回值 | 成功返回 0,出错返回 -1 |
3.3.2 sigfillset()
c
int sigfillset(sigset_t *set);
| 项目 | 说明 |
|---|---|
| 功能 | 初始化信号集,将所有信号的对应 bit 置位,表示该信号集包含系统支持的所有信号 |
| 参数 | set:指向要初始化的 sigset_t 变量的指针 |
| 返回值 | 成功返回 0,出错返回 -1 |
3.3.3 sigaddset()
c
int sigaddset(sigset_t *set, int signo);
| 项目 | 说明 |
|---|---|
| 功能 | 向信号集中添加一种有效信号 |
| 参数 | set:指向目标信号集的指针 signo:要添加的信号编号(如 SIGINT、SIGTERM 等) |
| 返回值 | 成功返回 0,出错返回 -1 |
3.3.4 sigdelset()
c
int sigdelset(sigset_t *set, int signo);
| 项目 | 说明 |
|---|---|
| 功能 | 从信号集中删除一种有效信号 |
| 参数 | set:指向目标信号集的指针 signo:要删除的信号编号 |
| 返回值 | 成功返回 0,出错返回 -1 |
3.3.5 sigismember()
c
int sigismember(const sigset_t *set, int signo);
| 项目 | 说明 |
|---|---|
| 功能 | 判断一个信号集中是否包含某种信号(布尔函数) |
| 参数 | set:指向要检查的信号集的指针 signo:要判断的信号编号 |
| 返回值 | 包含返回 1,不包含返回 0,出错返回 -1 |
3.3.6 sigprocmask:用于读取或更改进程的信号屏蔽字(block 表)
c
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
-
参数
how:-
SIG_BLOCK:将set中的信号添加到当前信号屏蔽字中(mask = mask | set)。 -
SIG_UNBLOCK:将set中的信号从当前信号屏蔽字中移除(mask = mask & ~set)。 -
SIG_SETMASK:将当前信号屏蔽字设置为set所指向的值(mask = set)。
-
-
set:输入型参数,指向要设置的信号集。 -
oldset:输出型参数,保存旧的信号屏蔽字。 -
返回值:成功返回0,失败返回-1。
3.3.7 sigpending :获取当前进程的未决信号集(pending 表)
c
#include <signal.h>
int sigpending(sigset_t *set);
位图操作细节 :
使用 struct bits 可以模拟位图:
c
struct bits {
int bitmap[10]; // 32*10 = 320,足以容纳所有信号(通常最多64个)
};
// 要定位某个信号的位置,例如信号编号39:
int index = 39 / 32; // 1,确定在bitmap[1]中
int pos = 39 % 32; // 7,确定第7个比特位
4. 信号处理(捕捉信号)
4.1 相关概述
信号不是立即处理的,而是等合适的时候再处理。
信号的生命周期:
信号产生 → 信号保存 → 信号处理
4.1.1 什么时候是"合适的时候"?
-
进程从内核态返回到用户态的时候,会进行信号检查。
-
如果信号处理方式是默认或忽略,处理比较简单。
-
如果信号处理方式是自定义捕捉,则过程更复杂,需要切换身份(以用户身份执行自定义函数)。
4.1.2 信号递送的方式
有三种方式:
-
默认(SIG_DFL)
-
忽略(SIG_IGN)
-
自定义捕捉(用户提供的信号处理函数)
signal() - 信号处理
为指定信号注册处理函数,当进程收到该信号时执行相应动作。
- 头文件
#include <signal.h> - 函数原型
c
void (*signal(int signum, void (*handler)(int)))(int);
- 或使用简化形式:
c
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
-
参数:
-
signum:信号编号,如SIGINT(中断)、SIGTERM(终止)、SIGKILL(强制终止,不可捕获)等。 -
handler:处理方式,可以是:-
函数指针:自定义处理函数,参数为信号编号。
-
SIG_IGN:忽略该信号。 -
SIG_DFL:恢复默认行为(通常是终止进程)。
-
-
-
返回值
-
成功:返回该信号之前的处理函数指针(或
SIG_IGN/SIG_DFL)。 -
失败:返回
SIG_ERR,并设置errno。
-
4.2 信号处理过程

-
用户态运行 :进程正在执行主控制流程(如
main中的代码)。 -
进入内核:发生中断、异常或系统调用,CPU 切换到内核态,保存用户态上下文。
-
内核处理 :处理完事件后,准备返回用户态之前,调用
do_signal()检查当前进程的pending信号表。 -
发现自定义信号 :如果
pending中有信号,且其处理方式是自定义函数,则:-
修改用户态堆栈(返回地址),使其返回到信号处理函数(而不是主流程)。
-
返回用户态时,执行信号处理函数(用户态代码)。
-
-
信号处理函数执行 :以用户身份执行用户写的
sighandler。 -
函数返回 :信号处理函数返回时,会调用特殊的系统调用
sigreturn,再次进入内核。 -
恢复上下文 :内核中
sys_sigreturn恢复用户态上下文,并返回用户态,从中断点继续执行主控制流程。
4.2 为什么进程会进入内核态?
即使像 while(true) {} 这样的代码,也会因为调度而进入内核(被时钟中断打断)。
进入内核的原因:
-
系统调用(主动)
-
中断(硬件中断、时钟中断等)
-
异常(缺页、除零等)
5. 操作系统是如何运行的

5.1 中断机制(硬件中断)
硬件中断是操作系统能够"感知"外部事件的基础。
5.1.1 硬件中断流程
-
外设就绪(键盘、网卡、磁盘等)
-
外设向中断控制器(如8259)发起中断请求
-
中断控制器通知CPU
-
CPU获取中断号
-
CPU保护现场(保存寄存器)
-
CPU根据中断号,从中断向量表(IDT)中找到中断处理例程并执行
-
处理完毕,恢复现场,继续之前的工作
5.1.2 中断向量表(IDT)
-
是一个函数指针数组,存放中断处理程序的入口地址。
-
每个中断号对应一个处理函数。
-
属于操作系统的一部分。
4.3.3 时钟中断
-
硬件时钟以固定频率(如1ns)向CPU发送中断。
-
时钟中断驱动进程调度:
-
每个进程的
task_struct中有counter字段,表示剩余时间片。 -
每次时钟中断处理程序会调用
do_timer,将当前进程的counter--。 -
若
counter == 0,则调用schedule()进行进程切换。
-
-
时间戳:每次中断累计
total++,用于记录系统时间。
5.2 操作系统的自动调度:硬件驱动下的死循环
- 操作系统在硬件时钟的推动下,自动完成调度,无需主动轮询。
5.2.1 调度初始化(sched_init)
在 main.c 中,系统启动时会调用 sched_init() 完成调度器的初始化:
c
void sched_init(void)
{
// 设置时钟中断门,中断号 0x20,处理函数 timer_interrupt
set_intr_gate(0x20, &timer_interrupt);
// 允许时钟中断(修改中断控制器屏蔽码)
outb(inb_p(0x21) & ~0x01, 0x21);
// 设置系统调用中断门,中断号 0x80,处理函数 system_call
set_system_gate(0x80, &system_call);
}
-
set_intr_gate将中断向量表(IDT)中第0x20号中断门指向timer_interrupt函数。 -
此后,每当硬件时钟(PIT)触发中断,CPU 就会自动跳转到
timer_interrupt执行。
5.2.2 时钟中断处理程序(_timer_interrupt)
在汇编文件 system_call.s 中,时钟中断入口 _timer_interrupt 最终调用 do_timer:
asm
_timer_interrupt:
...
call _do_timer ; 调用 do_timer,参数为 CPL(当前特权级)
...
5.2.3 调度入口(do_timer)
do_timer 位于 kernel/sched.c,每次时钟中断都会执行:
c
void do_timer(long cpl)
{
// 做时间片递减等操作
...
schedule(); // 调用调度器
}
-
do_timer中会递减当前进程的时间片(current->counter)。 -
如果时间片耗尽或需要抢占,就会调用
schedule()。
5.2.4 调度器(schedule)
schedule() 负责选择下一个要运行的进程:
c
void schedule(void)
{
// 寻找 counter 值最大的就绪进程
...
switch_to(next); // 切换到选中的进程
}
-
switch_to是宏或内联汇编,完成任务切换(保存当前进程上下文,恢复新进程上下文)。 -
通过任务状态段(TSS)或其它机制完成进程切换。
5.2.5 主函数的死循环(main)
操作系统初始化完成后,main 函数最终进入一个死循环:
c
void main(void)
{
...
for (;;)
pause(); // 任务 0 的特殊 pause
}
-
任务 0(空闲任务)在没有任何其他任务运行时,会反复调用
pause()。 -
pause()系统调用会使当前进程进入睡眠状态,直到有信号唤醒它。 -
但任务 0 是特例:
schedule()中如果没有其他就绪任务,就会选择任务 0 继续运行。因此任务 0 实际上充当了"空闲循环"。 -
时钟中断会打断
pause()或空闲循环,触发调度,使得操作系统能够响应外部事件和进程切换。
5.2.5 硬件如何推动操作系统?
plaintext
硬件时钟(8253/8254)以固定频率(如 100Hz)产生中断
↓
CPU 响应中断,自动跳转到 IDT[0x20] 的 timer_interrupt
↓
执行 do_timer → schedule → switch_to
↓
可能切换到另一个进程
↓
返回用户态继续执行
↓
再次被时钟中断打断,重复上述过程
-
操作系统本身不主动做任何事情,它就像一个大循环,挂在硬件中断上。
-
没有中断时,操作系统处于"暂停"状态(如空闲进程的
pause或hlt指令)。 -
中断发生时,操作系统才被唤醒,执行对应的处理函数(调度、设备驱动、系统调用等)。
-
因此,操作系统的本质是 事件驱动的死循环,硬件中断是驱动其运行的"心跳"。
-
这种设计使得操作系统可以"躺平"------它不需要主动轮询设备或进程,只需要在中断发生时响应即可。硬件时钟、外设中断、系统调用和异常共同构成了操作系统运行的核心驱动力。
5.3 异常(内部中断)
异常由CPU内部触发,例如:
-
除零(a/0)
-
野指针、指针重复释放(缺页异常)
-
溢出(EFLAGS)
异常会触发中断处理流程,并向目标进程发送信号(如 SIGSEGV、SIGFPE)。
缺页异常:page_fault() 处理,可能为进程分配空间。
5.4 系统调用(软中断)(陷阱)
系统调用是用户程序请求操作系统服务的唯一方式。
软中断指令
-
x86:
int 0x80 -
x86_64:
syscall
5.4.1 系统调用表
-
内核维护一个系统调用函数指针表
sys_call_table[]。 -
每个系统调用有一个唯一的系统调用号(下标)。
-
例如:
c
fn_ptr sys_call_table[] = {
sys_setup, sys_exit, sys_fork, sys_read, ...
};
5.4.2 系统调用过程(以 int 0x80 为例)
-
用户程序将系统调用号放入
eax寄存器。 -
执行
int 0x80,触发软中断。 -
CPU 保护现场,根据中断号(0x80)在 IDT 中找到
system_call处理程序。 -
system_call根据eax中的系统调用号,调用sys_call_table[eax]对应的内核函数。 -
执行完毕,返回用户态。
5.4.3 glibc 的封装
-
用户平时调用的
open()、fork()等函数,实际上是 glibc 封装的,内部执行系统调用指令。 -
操作系统本身不提供系统调用的函数接口,只提供系统调用号和调用机制。
5.5 信号与硬件中断的类比
信号本质上是用软件模拟硬件中断:
| 硬件中断 | 信号 |
|---|---|
| 外设发起中断 | 进程或内核发送信号 |
| CPU 保存中断号 | 信号保存在 pending 表中 |
| 根据中断号执行中断处理例程 | 根据信号编号执行信号处理动作(默认/忽略/自定义) |
| 中断向量表(IDT) | 信号的默认处理函数表(SIG_DFL / SIG_IGN) |
6. 内核态和用户态

6.1 操作系统在内存中,且所有进程共享
-
操作系统也是软件,它必须常驻在内存中才能运行。
-
系统在内存中只有一份 ,但所有用户进程都共享这一份操作系统代码和数据。
-
无论CPU如何切换进程,我们总能找到操作系统 。这是因为操作系统的内核部分被映射到了每个进程的虚拟地址空间的高位区域(通常是3GB~4GB)。
6.2 统一地址空间与特权级保护
-
地址空间布局:在32位Linux系统中,每个进程都拥有一个独立的、大小为4GB的虚拟地址空间。
-
[0, 3GB]:用户空间。存放用户进程自己的代码、数据、堆、栈和共享库。 -
[3GB, 4GB]:内核空间。存放操作系统的代码和数据,所有进程的这个区域都映射到同一份物理内存上的操作系统。
-
-
核心问题:既然用户进程也能访问3GB~4GB这个地址范围,那岂不是可以随便访问内核数据?
-
解决方案:CPU特权级(Ring)保护机制
-
CPU引入了**特权级(Ring)**的概念,用于区分代码的执行权限。
-
Ring 0(内核态):拥有最高权限,可以执行所有指令,访问所有内存。
-
Ring 3(用户态) :拥有最低权限,只能执行受限指令,只能访问自己的用户空间
[0, 3GB]。 -
关键 :用户进程虽然在地址空间上"看得到"内核区域,但由于CPU处于用户态,任何试图直接访问
[3GB, 4GB]的操作都会触发异常(缺页异常或保护异常),导致进程被操作系统杀死。
-
6.3 如何切换特权级?------系统调用
-
用户程序如果需要执行内核代码(如读写文件、创建进程),必须通过系统调用主动请求。
-
系统调用流程(以
fork()为例):-
用户程序调用库函数
fork()。 -
库函数准备系统调用号(例如
mov eax, n),然后执行一条特殊的陷入指令 ,如int 0x80(在x86架构中)或syscall(在x86_64架构中)。 -
执行陷入指令时,CPU会:
-
检查当前特权级(用户态 Ring 3)。
-
触发一个软中断 ,将CPU的特权级从Ring 3切换到Ring 0。
-
保存现场(将当前进程的寄存器、栈指针等保存到内核栈)。
-
-
CPU根据中断号(如
0x80)去查找中断描述符表(IDT) ,找到对应的内核入口点(如system_call函数)。 -
CPU跳转到该入口点,以内核态身份执行内核代码。
-
内核代码根据系统调用号,在系统调用表 中查找并执行对应的内核函数(如
sys_fork())。 -
执行完毕后,执行恢复现场指令,将CPU特权级从Ring 0切换回Ring 3,并返回到用户程序继续执行。
-
6.4 如何知道当前处于什么状态?------CPU寄存器
-
CPU通过代码段寄存器(CS)的低2位来记录当前特权级,这个值称为当前特权级(CPL)。
-
CPL = 0:表示当前在内核态。
-
CPL = 3:表示当前在用户态。
-
-
相关概念补充(理解即可):
-
段描述符表(GDT/LDT):描述内存段(如代码段、数据段)的基址、界限和权限的数据结构。
-
描述符特权级(DPL):访问某个段所需的最低特权级。
-
请求特权级(RPL):由段选择子指定,用于更精细的权限检查。
-
核心原则:操作系统通过硬件提供的特权级机制,确保用户程序无法非法访问内核资源,从而保证系统的安全和稳定。
-
7 信号捕捉:sigaction 函数
-
作用 :用于检查或修改与指定信号相关联的处理动作。它比
signal函数更强大、更可控。 -
函数原型:
c
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
-
-
signum:要处理的信号编号。-
act:输入型参数,指向一个struct sigaction结构体,用于设置新的信号处理方式。 -
oldact:输出型参数,用于保存旧的信号处理方式。 -
返回值 :成功返回0,失败返回-1并设置
errno。
-
-
-
struct sigaction结构体:
c
struct sigaction {
void (*sa_handler)(int); // 信号处理函数指针
void (*sa_sigaction)(int, siginfo_t *, void *); // 用于实时信号
sigset_t sa_mask; // 信号集,指定在处理本信号时需要额外屏蔽的信号
int sa_flags; // 修改信号处理行为的标志位
void (*sa_restorer)(void); // 已废弃
};
7.2 信号处理中的自动屏蔽行为
-
重要特性 :当某个信号的处理函数被调用时,内核会自动将该信号加入进程的信号屏蔽字。
-
目的:防止在处理一个信号期间,相同的信号再次产生并被递送,导致处理函数被嵌套调用,造成混乱。
-
恢复:当信号处理函数返回时,内核会自动恢复原来的信号屏蔽字。
-
额外屏蔽 :可以通过
sa_mask字段指定在处理当前信号时,需要额外屏蔽的其他信号。
8. 可重入函数

-
定义 :一个函数可以被多个执行流(如主程序和一个信号处理函数)同时或嵌套调用,而不会产生任何数据不一致或逻辑错误,这样的函数称为可重入函数。
-
背景 :信号处理函数(
handler)的执行流与主程序(main)的执行流是异步的。当主程序执行到某个函数时,如果被信号中断,而信号处理函数又调用了同一个函数,就发生了函数重入。 -
不可重入函数的例子:
-
使用了静态数据结构 或全局变量,且未做同步保护。
-
调用了
malloc或free(因为它们使用全局链表管理堆内存)。 -
调用了标准I/O库函数(如
printf,因为它们内部可能使用了全局缓冲区)。
-
-
可重入函数的特点:
-
只使用局部变量(存储在栈上,每个执行流有独立的副本)。
-
不修改全局变量,或对全局变量的修改是原子的。
-
不调用任何不可重入的函数。
-
-
重入导致的问题示例(链表插入):
-
主程序在
insert函数中,刚执行完p->next = head;(此时head还未被修改)。 -
此时发生信号,中断主程序,进入信号处理函数。
-
信号处理函数也调用了
insert,成功将新节点node2插入链表,并更新了head指向node2。 -
信号处理函数返回,主程序恢复执行,继续执行
head = p;,将head再次指向node1。 -
结果 :
node2丢失(内存泄漏),链表结构被破坏。
-
9. volatile 关键字
-
问题 :编译器为了优化性能,可能会将频繁访问的全局变量(如
flag)的值缓存在寄存器 中。这样,即使在信号处理函数中修改了内存中的flag值,主循环仍然从寄存器中读取旧的flag值,导致循环无法退出。 -
解决方案 :使用
volatile关键字修饰变量。
c
volatile int flag = 0;
-
作用:
-
告诉编译器,这个变量是"易变的",其值可能会在程序的控制流之外被改变(例如,被信号处理函数、硬件、其他线程修改)。
-
强制编译器每次访问该变量时,都从内存中重新读取,而不是使用寄存器中的缓存值。
-
这保证了内存的可见性,即对变量的修改能立刻被所有执行流看到。
-
10. SIGCHLD 信号
10.1 概述
-
信号名称 :
SIGCHLD -
信号编号:17
-
产生时机 :当一个子进程终止(terminated) 、**停止(stopped)或 恢复运行(continued)**时,内核会向其父进程发送
SIGCHLD信号。 -
默认动作 :忽略(Ignore)。即父进程默认不对此信号做任何处理。
10.2 典型用途:回收子进程资源
-
问题 :子进程终止后,会进入"僵尸(Zombie)"状态,等待父进程调用
wait()或waitpid()来读取其退出状态并释放其占用的资源。 -
解决方案 :父进程可以通过捕获
SIGCHLD信号,在信号处理函数中调用wait()或waitpid(),从而异步地回收子进程,避免父进程被阻塞等待。
10.3 忽略 SIGCHLD 的特殊行为(可选特性)
- 在一些Unix/Linux系统中(如Linux),如果父进程显式地将
SIGCHLD的信号处理动作设置为SIG_IGN(忽略),系统会自动将子进程的资源直接回收,而不会让其变为僵尸状态。
c
signal(SIGCHLD, SIG_IGN);
- 注意:这种行为并非所有Unix系统都支持,POSIX标准中未强制规定,但Linux支持。这是一种简便的避免僵尸进程的方法。