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 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 = &current->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

这是最关键的步骤!内核需要:

  1. 保存main函数的上下文(以便恢复)

  2. 构造handler的栈帧

  3. 修改返回地址(不返回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, &divide_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. 进程调度返回

→ 任何从内核态回到用户态的时刻
**信号处理需要满足两个条件:
  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 = &current->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(&current->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. 全局变量(危险!)

存储在数据段,所有调用共享**

可重入函数的判定标准

三个安全条件

条件1:不使用静态数据
条件2:不修改全局数据
条件3:不调用不可重入函数
相关推荐
盟道科技1 小时前
电商小程序SKU数据模型设计:SPU、规格组合、库存与价格的工程实践
服务器·小程序·apache
Lethehong1 小时前
服务器越来越多怎么统一监控?Beszel 接入 Agent、SMTP 告警与公网访问
运维·服务器
gs801401 小时前
Docker Desktop 报 Wsl/CommandTimedOut、wsl -l -v 卡死、0x80080005 的完整排查与解决
运维·docker·容器
其实防守也摸鱼1 小时前
CVE / NVD 漏洞数据库详解:从入门到实战
大数据·运维·人工智能·web安全·自动化
翼龙云_cloud2 小时前
阿里云国际代理商:2026零基础如何使用轻量应用服务器搭建独立站?
运维·服务器·阿里云·云计算
土星云SaturnCloud2 小时前
VILA 视觉语言模型边缘部署实战
服务器·人工智能·语言模型·自然语言处理·边缘计算
木卫四科技2 小时前
从函数劫持到智能体控制平面:Hook 如何从 Linux-Android 演化到 Agent Runtime
android·linux·人工智能·安全
新时代牛马2 小时前
Linux 内存映射mmap 完整篇:从用户态 API、VMA 到缺页与驱动实现
linux·运维·服务器
吹什么轩2 小时前
linux网络:UDP原理
linux·网络·udp