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:不调用不可重入函数
相关推荐
爱签AI电子合同44 分钟前
电子合同上手成本怎么测?易用性维度专项测评
服务器·人工智能·智能合约·企业微信
aixingkong9213 小时前
Nvidia为例 XPU的Multi-Host、Socket Direct 、GPUDirect、RDMA 技术点分析
服务器·网络·硬件架构·硬件工程
雯宝6 小时前
mnt_init 函数
linux
律宏阔11 小时前
WSL Docker 端口明明空闲,但就是绑不上端口
linux·windows
律宏阔11 小时前
WSL 突然断网,无法 ping 内网或外网
linux·windows
穷人小水滴11 小时前
用容器编译 VirtualBox 虚拟机软件 (ArchLinux, podman)
linux·容器·virtualbox
乱码三千12 小时前
如何优雅地直连无公网 IP 的远程 GPU 服务器
linux·人工智能·程序员
yunwei3712 小时前
使用 eBPF 跟踪 Nginx 请求
linux·后端·性能优化
yunwei3712 小时前
使用 eBPF 跟踪 MySQL 查询
linux·后端·性能优化
yunwei3712 小时前
eBPF 示例教程:使用 XDP 捕获 TCP 信息
linux·后端·性能优化