Linux --进程信号
信号快速认识
从生活角度理解信号
想象你在网上购物,等待快递送达的场景:
识别信号:你知道快递到来时该如何处理,这是"内置"的能力
处理时机:快递到了你在打游戏,可以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 coreCore Dump的作用:事后调试,定位程序崩溃原因。
// 查看子进程退出状态 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_funcsigset_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; } }核心认知:时钟中断 ≠ 强制切换进程
时钟中断是“心跳”和“夺权”:硬件定时器每次发出电信号,都是强制把 CPU 的控制权从用户程序手里夺过来,交给操作系统内核。
时间片是“额度”:只是操作系统用来防止某个进程过度霸占 CPU 的防御机制(兜底策略)。
结果:触发时钟中断后,操作系统夺回控制权,看了一眼当前进程的“额度”还没用完,于是又把控制权还给了它。这完全合法,且极其必要。
既然要继续跑,那“夺权”的这一下(时钟中断)意义何在?
时钟中断绝不是白白中断一下,操作系统在这个微秒级的瞬间干了极其重要的活:
处理紧急硬件事件:网卡来了数据包、你按了鼠标,都是靠中断机制响应的。没有时钟中断强制打断,系统无法响应。
维护系统时间与定时器:每次中断系统时间加一点;检查有没有
sleep(1)到点了、TCP 重传超时了。系统监控与记账:计算每个进程的真实 CPU 占用率(比如
top命令)。防止死循环(最重要的兜底):如果用户程序写了个
while(1)死循环,没有时钟中断把 CPU 强行抢回来,整个系统就会直接死机。
难道所有进程都死板地按时间片跑吗?
绝对不是。死板地按时间片轮转是极度低效的。
真实情况是:如果进程卡在 I/O(比如等键盘输入、读文件),它会主动让出 CPU 进入休眠;如果突然有高优先级任务(比如你刚点了播放音乐),操作系统会主动抢占当前的进程。
信号捕捉:中断驱动的完整流程
信号捕捉发生的时机
信号捕捉不是在信号产生时立即执行,而是在从内核态返回用户态之前进行检查。
信号捕捉的时机: 1. 系统调用返回 2. 中断处理返回 3. 异常处理返回 4. 进程调度返回 → 任何从内核态回到用户态的时刻信号处理需要满足两个条件:
1. 必须在内核态执行(修改进程栈、设置返回地址等)
2. 信号处理函数在用户态执行(用户的代码)
因此,最自然的时机就是:
"刚从内核态出来,还没回到用户态"的时刻
这样内核可以修改返回路径,让程序先执行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次用户态↔内核态切换!
如果你的代码里没有用signal()去注册自定义的handler函数(即执行的是系统默认行为,比如直接杀死进程、忽略信号)就只有两次切换。
用户态和内核态是CPU为了系统安全而设计的两种特权模式:用户态运行普通程序,权限受限;内核态运行操作系统,拥有全部权限。用户程序通过系统调用、中断和异常这三种方式,在需要时暂时切换到内核态获取服务,完成后再返回用户态继续执行。
可重入函数
定义
可重入函数(Reentrant Function)是指可以被多个执行流(如信号处理函数、线程)同时调用,而不会产生数据错乱或未定义行为的函数。
简单来说:函数可以在执行过程中被中断并再次进入,而不会造成任何问题。
核心概念图解
同一函数的多次调用: 不可重入函数: ┌─────────────────────────────────────────────────────────────────┐ │ 不可重入函数 │ │ 调用1 ──► ┌─────────────┐ │ │ 修改全局 │ ← 共享数据! │ 调用2 ──► │ 变量/链表 │ ← 数据被覆盖/损坏 │ └─────────────┘ │ └─────────────────────────────────────────────────────────────────┘ 可重入函数: ┌─────────────────────────────────────────────────────────────────┐ │ 可重入函数 │ │ 调用1 ──► ┌─────────────┐ │ │ 局部变量 │ ← 栈上独立 │ 调用2 ──► │ 局部变量 │ ← 互不干扰 │ └─────────────┘ │ └─────────────────────────────────────────────────────────────────┘为什么会导致问题
问题的根源:共享状态
函数的四种数据区域