信号快速认识
从生活角度理解信号
想象你在网上购物,等待快递送达的场景:
-
识别信号:你知道快递到来时该如何处理,这是"内置"的能力
-
处理时机 :快递到了你在打游戏,可以5分钟后去取------不需要立即处理
-
记忆机制:从收到通知到拿到快递期间,你"记住了有一个快递要去取"
-
处理方式:
-
默认动作:开心拆快递(使用商品)
-
自定义动作:把零食送给女朋友
-
忽略动作:扔在床头继续打游戏
-
核心结论:
-
进程识别信号是内核程序员写好的内置特性
-
信号的处理方法在信号产生之前就已经准备好了
-
信号产生后不会立即处理,而是在"合适的时候"
技术角度:一个简单示例
#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;
// 对2号信号(SIGINT)设置自定义捕捉函数
signal(SIGINT, handler);
while(true) {
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}
按Ctrl+C时,进程不再退出,而是打印信号信息------这就是信号捕捉。
signal函数仅仅是设置了处理方式,并没有调用handler。如果信号没有产生,handler永远不会被调用!
信号的基本概念
什么是信号?
信号是进程之间事件异步通知的一种方式,属于软中断。
类比理解:
-
硬件中断:由硬件设备触发,发给CPU
-
信号:由软件模拟,发给进程
查看系统支持的信号
kill -l
常用信号速查表:
| 信号 | 编号 | 默认动作 | 产生条件 |
|---|---|---|---|
| SIGHUP | 1 | 终止 | 终端挂起或控制进程终止 |
| SIGINT | 2 | 终止 | Ctrl+C |
| SIGQUIT | 3 | 终止+Core | Ctrl+\ |
| SIGILL | 4 | 终止+Core | 非法指令 |
| SIGABRT | 6 | 终止+Core | abort()函数 |
| SIGFPE | 8 | 终止+Core | 浮点异常(除零) |
| SIGKILL | 9 | 终止 | 无法捕获/阻塞 |
| SIGSEGV | 11 | 终止+Core | 无效内存访问 |
| SIGPIPE | 13 | 终止 | 向无读端的管道写 |
| SIGALRM | 14 | 终止 | alarm()超时 |
| SIGTERM | 15 | 终止 | 终止请求 |
| SIGCHLD | 17 | 忽略 | 子进程状态改变 |
| SIGSTOP | 19 | 停止 | 无法捕获/阻塞 |
| SIGTSTP | 20 | 停止 | Ctrl+Z |
注意:SIGKILL(9)和SIGSTOP(19)无法被捕获、阻塞或忽略,这是内核保留的权力。
信号处理的三种方式
// 1. 默认处理
signal(SIGINT, SIG_DFL); // Ctrl+C终止进程
// 2. 忽略信号
signal(SIGINT, SIG_IGN); // Ctrl+C无反应
// 3. 自定义捕捉
signal(SIGINT, handler); // 执行用户定义函数
信号的产生方式
通过终端按键产生
| 按键 | 信号 | 效果 |
|---|---|---|
| Ctrl+C | SIGINT(2) | 中断前台进程 |
| Ctrl+\ | SIGQUIT(3) | 退出并产生Core Dump |
| Ctrl+Z | SIGTSTP(20) | 挂起前台进程到后台 |
调用系统命令
# 向进程发送信号
kill -SIGSEGV 12345
kill -11 12345 # 使用编号
使用函数产生信号
kill() - 向任意进程发信号
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
raise() - 给自己发信号
#include <signal.h>
int raise(int sig); // 成功返回0
// 每隔1秒给自己发SIGINT
signal(SIGINT, handler);
while(true) {
sleep(1);
raise(SIGINT);
}
abort() - 异常终止
#include <stdlib.h>
void abort(void); // 总是成功,不返回
// 发送的是SIGABRT(6号信号)
// 即使捕捉了,abort()仍会终止进程
signal(SIGABRT, handler);
abort(); // 输出"获取了一个信号:6"然后Aborted
软件条件产生
alarm() - 闹钟函数
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
基本用法:
#include <iostream>
#include <unistd.h>
#include <signal.h>
int count = 0;
void handler(int signo) {
std::cout << "count: " << count << std::endl;
exit(0);
}
int main() {
signal(SIGALRM, handler);
alarm(1); // 1秒后发送SIGALRM
while(true) {
count++;
}
}
运行结果可能显示计数达到4.9亿次(有IO时可能只有10万+),体现出:
-
纯CPU运算非常快
-
IO操作极其耗时
重复闹钟:
void handler(int signo) {
// 处理业务...
alarm(1); // 重设闹钟,实现周期性
}
硬件异常产生信号
除零错误
int a = 10;
a /= 0; // 产生SIGFPE(8号信号)
野指针访问
int* p = NULL;
*p = 100; // 产生SIGSEGV(11号信号)
为什么会持续收到信号?
CPU中有状态寄存器记录异常状态,除零后没有清理异常标志位,所以异常会一直存在,OS持续发送信号。
Core Dump详解
Core Dump是进程异常终止时将用户空间内存数据保存到磁盘(core文件)的机制。
# 允许生成core文件(默认大小为0,不允许生成)
ulimit -c 1024
# 编译时加-g保留调试信息
gcc -g test.c -o test
# 运行程序生成core
./test
Segmentation fault (core dumped)
# 使用gdb调试
gdb test core
Core Dump的作用:事后调试(Post-mortem Debug),定位程序崩溃原因。
// 查看子进程退出状态
int status;
waitpid(-1, &status, 0);
int exit_signal = status & 0x7F; // 终止信号
int core_dump = (status >> 7) & 1; // 是否生成core
信号的保存
三个关键术语:
-
递达(Delivery):实际执行信号处理动作
-
未决(Pending):信号从产生到递达之间的状态
-
阻塞(Block):进程选择阻塞某个信号,被阻塞的信号产生后将保持在未决状态
阻塞 ≠ 忽略!阻塞是"不让信号递达",忽略是"递达后什么都不做"。
内核中的表示
struct task_struct {
// ...
struct sighand_struct *sighand; // 信号处理函数
sigset_t blocked; // 阻塞信号集
struct sigpending pending; // 未决信号集
// ...
};
每个信号在内核中有三个属性:
-
block位:是否阻塞该信号
-
pending位:该信号是否处于未决状态
-
handler指针:处理动作(默认/忽略/自定义)
示意图:
信号: SIGHUP SIGINT SIGQUIT
block: 0 1 0
pending: 0 1 0
handler: SIG_DFL SIG_IGN user_func
sigset_t信号集操作
#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);
sigprocmask - 修改信号屏蔽字
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
| how参数 | 含义 | 公式 |
|---|---|---|
| SIG_BLOCK | 添加阻塞 | mask = mask | set |
| SIG_UNBLOCK | 解除阻塞 | mask = mask & ~set |
| SIG_SETMASK | 直接设置 | mask = set |
信号的捕捉
什么是信号捕捉?
信号捕捉(Signal Catch):进程注册一个自定义函数,当特定信号递达时,内核转而执行这个用户空间的函数,而不是执行信号的默认动作。
// 信号捕捉的三要素
void my_handler(int signo) { // 1. 自定义函数
printf("捕获信号: %d\n", signo);
}
int main() {
signal(SIGINT, my_handler); // 2. 注册(告诉内核:"收到SIGINT时调用my_handler")
// 3. 等待信号产生...
}
信号捕捉与普通函数调用的区别
// 普通函数调用:明确的调用关系
void func_a() {
func_b(); // 调用者明确知道要调用func_b
}
// 信号捕捉:异步回调
int main() {
signal(SIGINT, handler); // 注册:告诉内核"在合适的时候调用handler"
while(1); // 内核会在中断返回时检查并调用handler
}
| 特性 | 普通函数调用 | 信号捕捉 |
|---|---|---|
| 调用时机 | 开发者明确知道 | 异步的,由内核决定 |
| 调用者 | 用户代码 | 内核 |
| 堆栈 | 使用调用者堆栈 | 使用用户态堆栈(独立) |
| 返回方式 | 直接返回 | 通过sigreturn系统调用 |
信号捕捉流程详解
当信号处理函数是用户自定义函数时,处理过程非常特殊:
用户态执行main → 中断/异常 → 内核态处理
↓
检查到有信号递达
↓
返回用户态执行sighandler(不是恢复main)
↓
sighandler返回 → 执行sigreturn → 内核态
↓
恢复main的上下文继续执行
关键点:
-
sighandler和main使用不同的堆栈空间
-
它们之间不存在调用关系,是两个独立的控制流程
-
整个过程涉及4次用户态/内核态切换
宏观流程:四次用户态/内核态切换

微观流程:七步走
第1步:用户程序执行到某个点
int main() {
// 1. 用户态执行main函数
int x = 0;
while(1) {
x++; // 执行到某个指令时...
// 可能被中断打断
}
}
第2步:发生中断,进入内核态
// 可能触发的原因:
// 1. 硬件中断:时钟中断、键盘中断
// 2. 系统调用:用户主动int 0x80/syscall
// 3. 异常:缺页异常、除零异常
// 4. 信号产生:其他进程调用kill()
CPU自动完成:
-
保存当前上下文(寄存器、栈指针等)
-
切换到内核栈
-
特权级从ring3提升到ring0
-
跳转到中断向量表对应的处理函数
第3步:内核处理中断,检查信号
// 内核中断处理伪代码
void do_IRQ(int irq) {
// 处理硬件中断
handle_irq(irq);
// 检查当前进程是否有待处理信号
if (current->pending.signals) {
// 有未决信号!
do_signal();
}
}
void do_signal() {
sigset_t pending = current->pending & ~current->blocked;
// 找到第一个未被阻塞的未决信号
int signo = find_first_signal(pending);
// 获取信号处理动作
struct k_sigaction *ka = ¤t->sighand->action[signo];
if (ka->sa_handler == SIG_DFL) {
// 默认处理
do_default_action(signo);
} else if (ka->sa_handler == SIG_IGN) {
// 忽略:清除pending位
clear_pending(signo);
} else {
// 用户自定义处理 → 信号捕捉!
handle_signal(signo, ka);
}
}
第4步:准备执行用户态handler
这是最关键的步骤!内核需要:
-
保存main函数的上下文(以便恢复)
-
构造handler的栈帧
-
修改返回地址(不返回main,而是执行handler)
// 内核信号处理伪代码
void handle_signal(int signo, struct k_sigaction *ka) {
struct pt_regs *regs = current->thread.regs; // 用户态寄存器
unsigned long *stack = (unsigned long *)regs->esp;// 1. 保存main函数的返回地址 unsigned long old_eip = regs->eip; // 2. 在用户栈上压入返回地址(sigreturn) // 这样handler返回时会执行sigreturn系统调用 *(--stack) = (unsigned long)stub_sigreturn; // 3. 设置新的执行地址为handler regs->eip = (unsigned long)ka->sa_handler; // 4. 参数传递:通过寄存器传递信号编号 regs->eax = signo; // 对应handler的第一个参数 // 5. 保存原上下文到用户栈(用于sigreturn恢复) // (实际实现中会构造一个sigcontext结构) // 6. 修改栈指针 regs->esp = (unsigned long)stack;}
第5步:返回用户态执行handler
CPU自动完成:
-
从内核栈切换回用户栈
-
特权级从ring0降回ring3
-
开始执行handler函数
void handler(int signo) { // signo来自EAX寄存器
// 用户空间代码
printf("捕获信号: %d\n", signo);
// 执行完后返回
}
重要 :handler执行时使用用户态堆栈 ,与main函数使用不同的栈帧。
第6步:handler返回 → 执行sigreturn
当handler执行完return语句时:
// 编译器生成的代码
handler:
push ebp
mov ebp, esp
// ... 处理逻辑
mov esp, ebp
pop ebp
ret // 返回到栈顶保存的地址(stub_sigreturn)
// sigreturn系统调用的简化实现
asmlinkage void sys_sigreturn(void) {
struct pt_regs *regs = current->thread.regs;
// 从用户栈恢复之前保存的上下文
struct sigcontext *sc = (struct sigcontext *)regs->esp;
// 恢复所有寄存器
regs->eax = sc->eax;
regs->ebx = sc->ebx;
// ... 恢复所有通用寄存器
regs->eip = sc->eip; // 恢复到main函数
regs->esp = sc->esp; // 恢复到main的栈
// 恢复信号屏蔽字
current->blocked = sc->oldmask;
// 清除信号(已递达)
clear_pending(signo);
}
第7步:恢复main函数执行
sigreturn返回 → 内核态再次切换到用户态 → 继续执行main函数
main函数从被中断的地方继续执行,完全不知道刚才发生了信号处理!
sigaction - 更强大的信号设置
#include <signal.h>
int sigaction(int signo, const struct sigaction *act,
struct sigaction *oact);
struct sigaction {
void (*sa_handler)(int); // 信号处理函数
sigset_t sa_mask; // 处理期间额外屏蔽的信号
int sa_flags; // 选项,通常设为0
void (*sa_sigaction)(int, siginfo_t*, void*); // 实时信号
};
自动屏蔽特性 :
当某个信号的处理函数被调用时,内核自动将该信号加入屏蔽字,处理完再恢复。这保证了在处理某个信号时,如果这种信号再次产生,会被阻塞到当前处理结束。
操作系统运行原理
信号捕捉不仅仅是"注册一个函数等内核调用"这么简单。它涉及硬件中断、CPU特权级切换、内核调度、用户态/内核态转换等一系列操作系统核心机制。
操作系统的本质:躺在中断上的代码
操作系统是什么?
很多人以为操作系统是一个"主动运行"的程序,但实际上:
操作系统本质上是躺在中断处理例程上的代码块,它自己不主动做任何事情,而是被各种中断事件"唤醒"来执行相应操作。
// Linux 0.11 内核的 main.c
void main(void) {
// ... 初始化各种硬件、数据结构 ...
// 最后进入死循环
for (;;)
pause(); // 让出CPU,等待中断
}
操作系统启动后,会进入一个无限循环,等待各种事件的发生。这些事件包括:
-
硬件中断(键盘、鼠标、网卡、磁盘等)
-
时钟中断(定时触发)
-
软件中断/异常(系统调用、缺页、除零等)
中断向量表:操作系统的大门
// 中断向量表就是操作系统所有入口函数的地址表
// 启动时加载到内存固定位置
void trap_init(void) {
// 硬件中断
set_trap_gate(0x20, &timer_interrupt); // 时钟中断
set_trap_gate(0x21, &keyboard_interrupt); // 键盘中断
set_trap_gate(0x2E, &harddisk_interrupt); // 硬盘中断
// 异常
set_trap_gate(0, ÷_error); // 除零异常
set_trap_gate(14, &page_fault); // 缺页异常
// 系统调用
set_system_gate(0x80, &system_call); // int 0x80
}
中断驱动模型
+------------------+
| 用户程序执行 |
+------------------+
|
| 发生了什么事件?
v
+------------------+
| CPU保存现场 | ← 自动完成
| 查中断向量表 |
+------------------+
|
v
+------------------+
| 执行中断处理例程 | ← 操作系统代码
| 处理具体事件 |
+------------------+
|
v
+------------------+
| 恢复现场 | ← 自动完成
| 返回用户程序 |
+------------------+
硬件中断:操作系统的"心跳"
硬件中断的本质
硬件中断是外部设备告诉CPU"我有事情需要处理"的机制。
外部设备(键盘、鼠标、网卡、磁盘、时钟...)
|
| 发送电信号到中断控制器(8259A/PIC)
v
中断控制器
|
| 向CPU发送中断请求(IRQ)
v
CPU
|
| 保存当前执行状态
| 查中断向量表
| 跳转到中断处理函数
v
操作系统中断处理
Linux中断向量分配
中断向量号(x86):
0-31: CPU异常(除零、缺页、非法指令等)
32-47: 硬件中断(IRQ0-IRQ15)
32 (0x20): 时钟中断(PIT)
33 (0x21): 键盘中断
46 (0x2E): 硬盘中断
...
0x80: 系统调用(int 0x80)
键盘中断完整流程
// 1. 用户按下键盘按键
// 2. 键盘控制器发送扫描码到中断控制器
// 3. 中断控制器通知CPU(IRQ1,向量33)
// 4. CPU保存当前进程上下文
// 5. CPU跳转到键盘中断处理函数
void keyboard_interrupt(void) {
unsigned char scancode = inb(0x60); // 读取扫描码
// 将扫描码转换为字符
char key = scancode_to_char(scancode);
// 如果是Ctrl+C,需要给前台进程发送SIGINT
if(key == 'c' && ctrl_pressed) {
// 获取前台进程的PID
pid_t fg_pid = get_foreground_pid();
// 发送信号
send_signal(fg_pid, SIGINT);
}
// 通知中断控制器处理完成
outb(0x20, 0x20);
}
系统调用:用户主动进入内核
系统调用的本质
系统调用是用户程序主动请求内核服务的唯一方式。它本质上是一个软件中断。
// 用户程序调用open函数
int fd = open("/tmp/test.txt", O_RDWR);
// 经过glibc封装后,变成:
asm volatile (
"mov $2, %%eax\n" // 系统调用号:__NR_open = 2
"mov %1, %%ebx\n" // 参数1:文件名
"mov %2, %%ecx\n" // 参数2:标志
"int $0x80\n" // 触发软中断
: "=a" (result)
: "r" (filename), "r" (flags)
: "memory"
);
系统调用表
// Linux 0.11 系统调用表(函数指针数组)
fn_ptr sys_call_table[] = {
sys_setup, // 0
sys_exit, // 1
sys_fork, // 2
sys_read, // 3
sys_write, // 4
sys_open, // 5
sys_close, // 6
sys_waitpid, // 7
sys_create, // 8
sys_link, // 9
sys_unlink, // 10
sys_execve, // 11
sys_chdir, // 12
sys_time, // 13
sys_mknod, // 14
sys_chmod, // 15
// ... 共72个
};
系统调用完整流程
用户态 内核态
| |
| 调用库函数 |
| 设置参数到寄存器 |
| 触发 int $0x80 |
|------------------------------>| CPU保存上下文
| | 切换到内核栈
| | 查系统调用表
| | 执行 sys_open
| | ↓
| | 打开文件
| | 分配文件描述符
| | 返回结果
|<------------------------------| 恢复上下文
| 获取返回值 | 回到用户态
| 继续执行 |
时钟中断:进程调度的驱动力
时钟中断的设置
// Linux初始化时设置时钟中断
void sched_init(void) {
// 设置时钟中断处理函数
set_intr_gate(0x20, &timer_interrupt);
// 允许时钟中断(IRQ0)
outb_p(inb_p(0x21) & ~0x01, 0x21);
}
// 时钟中断处理函数
void timer_interrupt(void) {
// 1. 更新系统时间
jiffies++;
// 2. 检查时间片是否用完
if (--current->counter > 0)
return; // 时间片还有剩余
// 3. 时间片用完,进行调度
do_timer(1);
}
// 调度入口
void do_timer(long cpl) {
// ... 更新各种统计信息 ...
// 调用调度器
schedule();
}
// 调度器
void schedule(void) {
// 1. 在就绪队列中找到优先级最高的进程
int next = find_best_task();
// 2. 切换到该进程
if (next != current) {
switch_to(next);
}
}
时间片轮转
时间轴(每10ms一个时钟中断):
+----+----+----+----+----+----+----+----+
| P1 | P1 | P1 | P2 | P2 | P2 | P3 | P1 |
+----+----+----+----+----+----+----+----+
10ms 20ms 30ms 40ms 50ms 60ms 70ms 80ms
每个进程的时间片:30ms
P1: 0-30ms, 70-80ms
P2: 30-60ms
P3: 60-70ms
时钟中断与信号的关系
// 时钟中断处理中也会检查信号
void timer_interrupt(void) {
// ... 更新jiffies,检查时间片 ...
// 检查当前进程是否有信号待处理
if (current->pending.signals & ~current->blocked) {
// 有未决信号!
// 在中断返回用户态时会处理
current->need_resched = 1;
}
}
信号捕捉:中断驱动的完整流程
信号捕捉发生的时机
信号捕捉不是在信号产生时立即执行,而是在从内核态返回用户态之前进行检查。
信号捕捉的时机:
1. 系统调用返回
2. 中断处理返回
3. 异常处理返回
4. 进程调度返回
→ 任何从内核态回到用户态的时刻
**信号处理需要满足两个条件:
- 必须在内核态执行(修改进程栈、设置返回地址等)
- 信号处理函数在用户态执行(用户的代码)**
**因此,最自然的时机就是:
"刚从内核态出来,还没回到用户态"的时刻
这样内核可以修改返回路径,让程序先执行handler再回到main**
完整流程拆解
阶段1:信号产生(以键盘中断为例)
键盘中断 → 读扫描码 → 识别Ctrl+C → 发送SIGINT
// 键盘中断处理
void keyboard_interrupt(void) {
// ... 读取扫描码,识别按键 ...
if (is_ctrl_c()) {
// 获取前台进程
struct task_struct *p = get_foreground_process();
// 发送信号
send_signal(p, SIGINT);
}
}
// 发送信号
void send_signal(struct task_struct *p, int signo) {
// 设置pending位
p->pending.signal |= (1 << signo);
// 如果进程在可中断睡眠状态,唤醒它
if (p->state == TASK_INTERRUPTIBLE) {
wake_up_process(p);
}
}
阶段2:时钟中断触发
时钟中断 → 更新系统时间 → 检查时间片 → 可能触发调度
void timer_interrupt(void) {
jiffies++;
// 检查当前进程的时间片
if (--current->counter > 0) {
// 时间片还有,但也要检查信号
if (current->pending.signals & ~current->blocked) {
// 有信号待处理,标记需要调度
current->need_resched = 1;
}
return;
}
// 时间片用完,调度
do_timer(1);
}
阶段3:进程调度(可能发生)
void schedule(void) {
// 选择下一个要运行的进程
int next = pick_next_task();
if (next != current) {
// 上下文切换
switch_to(next);
}
// 如果没有切换,也要处理信号
do_signal();
}
阶段4:中断返回前检查信号
// 中断返回时的处理
void ret_from_interrupt(void) {
// 检查是否需要调度
if (current->need_resched) {
schedule();
}
// 检查是否有信号需要处理
if (current->pending.signals & ~current->blocked) {
do_signal(); // 处理信号!
}
}
// 处理信号
void do_signal(void) {
// 找到第一个未阻塞的未决信号
int signo = find_first_pending();
// 获取该信号的处理动作
struct k_sigaction *ka = ¤t->sighand->action[signo];
if (ka->sa_handler == SIG_DFL) {
// 默认动作
do_default_action(signo);
} else if (ka->sa_handler == SIG_IGN) {
// 忽略
clear_pending(signo);
} else {
// 信号捕捉!开始执行用户处理函数
handle_signal(signo, ka);
}
}
阶段5:信号捕捉的准备
void handle_signal(int signo, struct k_sigaction *ka) {
struct pt_regs *regs = current->thread.regs; // 用户寄存器
unsigned long *stack = (unsigned long *)regs->esp;
// 1. 保存用户态上下文到栈(用于sigreturn)
struct sigcontext *sc = (struct sigcontext *)--stack;
sc->eip = regs->eip;
sc->esp = regs->esp;
sc->eflags = regs->eflags;
sc->oldmask = current->blocked; // 保存信号屏蔽字
// 2. 压入sigreturn地址
*(--stack) = (unsigned long)&stub_sigreturn;
// 3. 设置新的执行地址
regs->eip = (unsigned long)ka->sa_handler;
// 4. 设置参数(signo通过eax传递)
regs->eax = signo;
// 5. 切换栈
regs->esp = (unsigned long)stack;
// 6. 阻塞当前信号(自动)
sigaddset(¤t->blocked, signo);
// 加上sa_mask中指定的额外信号
current->blocked |= ka->sa_mask;
}
阶段6:返回用户态执行handler
CPU执行iret指令:
1. 从内核栈恢复用户态上下文
2. CPL从0变为3
3. 跳转到handler的地址
4. 开始执行用户定义的信号处理函数
void my_handler(int signo) { // signo从eax获取
printf("捕获信号: %d\n", signo);
// 这里在用户态执行,使用用户栈
// 可以调用普通函数(但要小心重入问题)
// 可以访问用户数据
// 处理完成后return
}
阶段7:handler返回 → sigreturn
// 当handler执行return时:
// 1. ret指令弹出stub_sigreturn的地址到EIP
// 2. 跳转到stub_sigreturn
stub_sigreturn:
mov eax, __NR_sigreturn // 系统调用号
int $0x80 // 触发系统调用
// sigreturn系统调用
asmlinkage void sys_sigreturn(void) {
struct pt_regs *regs = current->thread.regs;
struct sigcontext *sc = (struct sigcontext *)regs->esp;
// 恢复所有寄存器
regs->eip = sc->eip;
regs->esp = sc->esp;
regs->eflags = sc->eflags;
// ... 恢复其他寄存器 ...
// 恢复信号屏蔽字
current->blocked = sc->oldmask;
// 清除该信号的pending位
clear_pending(signo);
}
阶段8:返回到main函数
// sigreturn返回后,再次执行iret:
// CPU从内核态回到用户态
// 从之前保存的上下文恢复main的执行
// main继续执行,完全不知道刚才发生了什么
用户态和内核态
用户态和内核态是CPU的两种工作状态(特权级),用来限制不同代码对硬件资源和敏感数据的访问权限。用户态权限低,内核态权限高。
直观类比
想象一栋大楼的安保系统:
大楼 = 计算机系统
一楼大厅 = 用户态(任何人都可以进入)
核心机房 = 内核态(只有授权人员可以进入)
- 普通访客(用户程序)只能在一楼大厅活动
- 系统管理员(内核)可以进入核心机房
- 要进入核心机房必须通过安检通道(系统调用)
- 核心机房有最贵重的设备(硬件)和最重要的数据(内核数据结构)
通俗理解
| 用户态 | 内核态 |
|---|---|
| 普通模式 | 特权模式 |
| 权限低 | 权限高 |
| 执行用户程序 | 执行操作系统代码 |
| 不能直接访问硬件 | 可以直接操作硬件 |
| 不能访问内核空间内存 | 可以访问所有内存 |
| 受限的CPU指令集 | 全部CPU指令集 |
| 程序崩溃可恢复 | 内核崩溃导致系统宕机 |
CPU特权级:硬件层面的支撑
Intel x86的四环模型
Intel x86架构设计了4个特权级(Ring 0-3),用于保护系统资源:
┌─────────────────────┐
│ Ring 0 │ ← 内核态
│ 最高权限 │ Linux使用
│ 所有指令都可执行 │
│ 访问所有内存 │
├─────────────────────┤
│ Ring 1 │ ← 设备驱动(某些OS使用)
│ │
├─────────────────────┤
│ Ring 2 │ ← 设备驱动(某些OS使用)
│ │
├─────────────────────┤
│ Ring 3 │ ← 用户态
│ 最低权限 │ Linux使用
│ 受限指令集 │
│ 只能访问用户空间 │
└─────────────────────┘
关键事实:
-
Linux只使用Ring 0 (内核态)和Ring 3(用户态)
-
Ring 1和Ring 2在Linux中基本不使用
-
CPU通过CPL(Current Privilege Level,当前特权级) 来判断当前处于哪个环
当前特权级(CPL)
段选择子的低2位表示CPL
CS寄存器(代码段选择子)的位0-1
内核代码段:CS=0x10 → CPL=0
用户代码段:CS=0x1B → CPL=3
内核态时:
CS = 0x10 (二进制: 10000) → 低2位=00 → 特权级0
用户态时:
CS = 0x1B (二进制: 11011) → 低2位=11 → 特权级3
内存空间划分
虚拟地址空间布局
以32位Linux系统为例,每个进程拥有4GB的虚拟地址空间:
32位系统:4GB虚拟地址空间(2^32 = 4GB)
地址从低到高:
┌──────────────────────────────────────┐ 0xFFFFFFFF
│
│ 内核空间 (1GB) 内核态可访问
│ (0xC0000000 - 0xFFFFFFFF) 所有进程共享
│
│ ┌──────────────────────────┐
│ │ 内核代码和数据
│ │ 中断向量表
│ │ 内核栈
│ │ task_struct
│ └──────────────────────────┘
├──────────────────────────────────────┤ 0xC0000000
│
│ 用户空间 (3GB) 用户态可访问
│ (0x00000000 - 0xBFFFFFFF) 每个进程独有
│
│ ┌──────────────────────────┐
│ │ 用户代码 (.text)
│ │ 用户数据 (.data/.bss)
│ │ 堆 (Heap)
│ │ 共享库
│ │ 栈 (Stack)
│ └──────────────────────────┘
│
└──────────────────────────────────────┘ 0x00000000
地址空间的特点
// 用户态视角:只能访问0-3GB
void user_code() {
// 可以访问自己的代码段
// 可以访问自己的数据段
// 可以访问自己的堆和栈
// 不能访问3-4GB的内核空间
}
// 内核态视角:可以访问所有内存
void kernel_code() {
// 可以访问当前进程的用户空间
// 可以访问所有进程的内核空间(共享)
// 可以访问硬件设备内存
// 可以访问物理内存
}
用户态和内核态的切换
什么时候发生切换?
┌─────────────────────────────────────────────────────────────────┐
│ 用户态 → 内核态的切换时机
├─────────────────────────────────────────────────────────────────┤
│
│ 1. 系统调用
│ └── 用户主动请求内核服务(read、write、fork等)
│
│ 2. 硬件中断
│ └── 外部设备通知CPU(键盘、鼠标、时钟等)
│
│ 3. CPU异常
│ └── 指令执行出错(除零、缺页、非法指令等)
│
│ 4. 进程调度
│ └── 时间片用完或进程主动让出CPU(schedule)
│
└─────────────────────────────────────────────────────────────────┘
信号处理中的切换次数:
正常执行:用户态(main)
产生信号:用户态 → 内核态(中断/系统调用)
设置pending:内核态
返回检查:内核态 → 用户态(发现信号)
执行handler:内核态 → 用户态(handler)(修改返回路径)
handler返回:用户态(handler) → 内核态(sigreturn)
恢复main:内核态 → 用户态(main)
总共:4次用户态↔内核态切换!
用户态和内核态是CPU为了系统安全而设计的两种特权模式:用户态运行普通程序,权限受限;内核态运行操作系统,拥有全部权限。用户程序通过系统调用、中断和异常这三种方式,在需要时暂时切换到内核态获取服务,完成后再返回用户态继续执行。
可重入函数
定义
可重入函数(Reentrant Function)是指可以被多个执行流(如信号处理函数、线程)同时调用,而不会产生数据错乱或未定义行为的函数。
简单来说:函数可以在执行过程中被中断并再次进入,而不会造成任何问题。
核心概念图解
同一函数的多次调用:
不可重入函数:
┌─────────────────────────────────────────────────────────────────┐
│ 不可重入函数
│
│ 调用1 ──► ┌─────────────┐
│ │ 修改全局 │ ← 共享数据!
│ 调用2 ──► │ 变量/链表 │ ← 数据被覆盖/损坏
│ └─────────────┘
│
└─────────────────────────────────────────────────────────────────┘
可重入函数:
┌─────────────────────────────────────────────────────────────────┐
│ 可重入函数
│
│ 调用1 ──► ┌─────────────┐
│ │ 局部变量 │ ← 栈上独立
│ 调用2 ──► │ 局部变量 │ ← 互不干扰
│ └─────────────┘
│
└─────────────────────────────────────────────────────────────────┘
为什么会导致问题
问题的根源:共享状态
函数的四种数据区域
**1. 参数(通常安全)
每个调用都有自己的参数副本**
**2. 局部变量(安全)
存储在栈上,每次调用独立**
**3. 静态变量(危险!)
存储在数据段,所有调用共享**
**4. 全局变量(危险!)
存储在数据段,所有调用共享**