九、Linux信号机制(二)

5. 可重入函数

5-1 问题引入:链表插入的经典Bug

main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步:

刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到 sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,

插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。

结果是,main函数和sighandler先后 向 链表中插入两个节点,而最后只有一个节点真正插入链表中了

像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函 数,这称为重入 ,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可 重入函数,

反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一 下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?

如果一个函数符合以下条件之一则是不可重入的:

• 调用了malloc或free,因为malloc也是用全局链表来管理堆的。

• 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

复制代码
// 全局变量
node_t node1, node2, *head = NULL;

// 链表头插函数
void insert(node_t *p)
{
    p->next = head;   // 步骤1:新节点指向当前头节点
    // ...............  ← 如果这里被打断??
    head = p;         // 步骤2:头指针指向新节点
}

int main()
{
    signal(SIGINT, handler);  // 注册信号处理函数
    
    insert(&node1);  // 调用insert
    // 在步骤1执行完,步骤2执行前,信号到来!
    // ...
}

void handler(int signo)
{
    insert(&node2);  // 信号处理函数中也调用insert!
}

灾难性执行流程:

复制代码
时间线:
┌─────────────────────────────────────────────────────────────┐
│ main: insert(&node1)                                        │
│   step1: node1.next = NULL (head是NULL)                     │
│   ──────── 信号到来!跳转handler ────────                    │
│                                                             │
│ handler: insert(&node2)                                     │
│   step1: node2.next = NULL (head还是NULL!)                  │
│   step2: head = &node2                                      │
│   handler返回                                               │
│                                                             │
│ main: insert(&node1) 继续                                   │
│   step2: head = &node1  (覆盖了node2!)                      │
│                                                             │
│ 结果:node2丢失了!链表只有一个node1                          │
└─────────────────────────────────────────────────────────────┘

期望结果:head → node2 → node1 → NULL

实际结果:head → node1 → NULL
         node2 丢失!

5-2 重入与可重入函数的定义

重入(Reentrant):

一个函数被不同的控制流程调用,在第一次调用还没返回时就再次进入该函数,称为"重入"。

复制代码
控制流程1: 调用函数 → 执行中 → 被打断
                                    ↓
控制流程2: 调用同一函数 → 执行中 → 返回
                                    ↓
控制流程1: 继续执行 → 返回

可重入函数(Reentrant Function):

可以被安全重入的函数。即使在执行过程中被打断,再次进入也不会出错。

不可重入函数(Non-reentrant Function):

不能被安全重入的函数。被打断后再次进入可能导致数据错乱。


5-3 判断函数是否可重入的条件

以下条件满足任意一个,就是不可重入函数:

  1. 调用了malloc或free

    • malloc内部维护全局的堆链表,重入会破坏链表结构
  2. 调用了标准I/O库函数

    • printf、fprintf等内部有缓冲区,重入会导致输出混乱
    • 标准IO库的很多实现都使用了全局数据结构
  3. 使用了静态局部变量或全局变量

    • 静态变量和全局变量在函数调用间保持状态,重入会修改这些状态

可重入函数的特征:

  • 只使用自己的局部变量或参数

  • 不调用不可重入函数

  • 不访问全局数据结构

    // 可重入函数示例
    int add(int a, int b)
    {
    return a + b; // 只使用参数,完全安全
    }

    // 不可重入函数示例
    static int count = 0;
    int increment()
    {
    return ++count; // 使用静态变量,不可重入
    }


5-4 竞态条件(Race Condition)

什么是竞态条件?

多个执行流同时访问和修改共享数据,最终结果取决于执行的时序,称为竞态条件。

复制代码
// 经典竞态条件:银行账户
int balance = 1000;

void deposit(int amount)
{
    int temp = balance;      // 读取余额
    temp = temp + amount;    // 计算新余额
    balance = temp;          // 写回余额
}

// 线程A: deposit(100)
// 线程B: deposit(200)

// 期望结果:balance = 1300
// 竞态结果:可能是1100或1200!

竞态条件图示:

复制代码
线程A                    线程B
  │                        │
  │ temp = balance (1000)  │
  │                        │ temp = balance (1000)
  │ temp = temp + 100      │
  │                        │ temp = temp + 200
  │ balance = temp (1100)  │
  │                        │ balance = temp (1200)
  ↓                        ↓
  最终balance = 1200(应该是1300!)

在信号处理中的竞态条件:

信号处理函数和主程序共享全局变量,如果不当使用会导致竞态条件。


5-5 异步信号安全函数

什么是异步信号安全函数(Async-signal-safe Function)?

可以在信号处理函数中安全调用的函数。

复制代码
$ man 7 signal-safety

POSIX规定的异步信号安全函数(部分):

函数 说明
_exit() 退出进程
write() 写文件(不用printf!)
read() 读文件
kill() 发送信号
getpid() 获取进程ID
signal() 注册信号(某些系统)

常见的 不是 异步信号安全的函数:

函数 为什么不安全
printf/fprintf 内部有缓冲区和全局状态
malloc/free 内部有全局堆管理
sprintf 内部可能有静态缓冲区
标准IO函数 大多不是

在信号处理函数中应该怎么做:

复制代码
#include <signal.h>
#include <unistd.h>

volatile sig_atomic_t g_flag = 0;  // 信号安全的变量类型

void handler(int signo)
{
    // 正确:只设置标志,调用安全函数
    g_flag = 1;
    write(STDOUT_FILENO, "signal received\n", 16);  // write是安全的
    
    // 错误:调用不安全函数
    // printf("signal received\n");  // 不安全!
    // malloc(100);                  // 不安全!
}

int main()
{
    signal(SIGINT, handler);
    
    while(true)
    {
        if(g_flag)
        {
            // 在主程序中做复杂处理
            printf("处理信号\n");  // 这里可以安全使用printf
            g_flag = 0;
        }
        pause();
    }
}

sig_atomic_t 类型:

复制代码
// sig_atomic_t是一个整数类型,保证原子读写
volatile sig_atomic_t flag;
  • 保证对flag的读写是原子的(不会被信号打断)
  • 应该用volatile修饰,防止编译器优化

5-6 可重入函数的深入理解

为什么标准库函数大多不可重入?

strtok为例:

复制代码
// strtok使用静态变量保存状态
char *strtok(char *str, const char *delim)
{
    static char *last = NULL;  // 静态变量!
    
    if(str == NULL)
        str = last;  // 使用上次的位置
    
    // ... 分割逻辑 ...
    
    last = current_position;  // 保存状态
    return token;
}

// 如果在strtok执行过程中被信号打断,handler中也调用strtok
// 静态变量last会被修改,导致数据错乱

安全的替代方案:

复制代码
// 使用可重入版本
char *strtok_r(char *str, const char *delim, char **saveptr);
// saveptr是调用者提供的状态存储,不使用静态变量

6. volatile关键字

6-1 问题引入:编译器优化的陷阱

复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

int flag = 0;

void handler(int sig)
{
    printf("change flag 0 to 1\n");
    flag = 1;  // 在信号处理函数中修改flag
}

int main()
{
    signal(SIGINT, handler);
    
    // 等待flag被修改
    while(!flag)
    {
        // 空循环
    }
    
    printf("process quit normal\n");
    return 0;
}

运行结果:

复制代码
# 无优化编译
$ gcc -O0 test.c -o test
$ ./test
^Cchange flag 0 to 1
process quit normal     # 正常退出!

# 优化编译
$ gcc -O2 test.c -o test
$ ./test
^Cchange flag 0 to 1
                        # 卡住,不退出!

为什么优化后不退出?


6-2 编译器优化原理

编译器优化时,可能会把变量从内存加载到寄存器:

复制代码
// 源代码
while(!flag)
{
    // ...
}

// 无优化的汇编(每次都从内存读flag)
loop:
    mov eax, [flag]     // 从内存读取flag
    test eax, eax
    jnz exit
    jmp loop

// 优化后的汇编(只读一次,之后用寄存器)
    mov eax, [flag]     // 第一次从内存读取
loop:
    test eax, eax       // 之后都用寄存器中的值!
    jnz exit
    jmp loop

问题所在:

复制代码
编译器认为flag没有在循环中被修改,所以把它缓存到寄存器
      ↓
handler在另一个上下文中修改了内存中的flag
      ↓
但循环中检查的是寄存器中的flag(旧值)
      ↓
循环永远不会退出!

6-3 volatile的作用

复制代码
// 使用volatile修饰
volatile int flag = 0;

volatile告诉编译器:

  • 这个变量可能被意外修改(信号处理、硬件、其他线程)

  • 不要对这个变量的访问做优化

  • 每次都必须从内存中读取,写操作必须立即写回内存

    // 使用volatile后的汇编
    loop:
    mov eax, [flag] // 每次都从内存读取!
    test eax, eax
    jnz exit
    jmp loop


6-4 volatile的使用场景

必须使用volatile的场景:

  1. 信号处理函数中的共享变量

    复制代码
    volatile sig_atomic_t flag = 0;
  2. 多线程共享变量(配合锁使用)

    复制代码
    volatile int counter = 0;
    pthread_mutex_t lock;
  3. 硬件寄存器映射

    复制代码
    volatile uint32_t *reg = (uint32_t *)0x40001000;
  4. 中断服务程序中的共享变量

    复制代码
    volatile bool data_ready = false;

不需要volatile的场景:

  • 普通的局部变量
  • 函数参数
  • 不会被异步修改的变量

6-5 volatile不保证原子性

重要警告: volatile只保证可见性,不保证原子性!

复制代码
volatile int counter = 0;

// 这仍然是不安全的!
void handler(int sig)
{
    counter++;  // 非原子操作!
}

// counter++ 实际上是三步:
// 1. 读取counter到寄存器
// 2. 寄存器值+1
// 3. 写回内存
// 信号可能在任何一步到来!

正确的做法:

复制代码
#include <signal.h>

volatile sig_atomic_t counter = 0;  // 使用sig_atomic_t

void handler(int sig)
{
    counter++;  // sig_atomic_t保证原子性
}

6-6 volatile的完整示例

复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

// 使用volatile和sig_atomic_t
volatile sig_atomic_t g_flag = 0;

void handler(int sig)
{
    const char msg[] = "SIGINT received\n";
    write(STDOUT_FILENO, msg, sizeof(msg) - 1);  // 使用write,不用printf
    g_flag = 1;
}

int main()
{
    signal(SIGINT, handler);
    
    printf("Waiting for Ctrl+C (PID: %d)...\n", getpid());
    
    while(!g_flag)
    {
        // 可以做其他事情
        sleep(1);
        printf("Running...\n");
    }
    
    printf("Exiting normally\n");
    return 0;
}

编译运行:

复制代码
$ gcc -O2 volatile.c -o volatile
$ ./volatile
Waiting for Ctrl+C (PID: 12345)...
Running...
Running...
^CSIGINT received
Exiting normally    # 即使-O2优化也能正常退出!

7. SIGCHLD信号

7-1 僵尸进程问题回顾

复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    pid_t pid = fork();
    
    if(pid == 0)
    {
        // 子进程
        printf("Child: %d\n", getpid());
        sleep(3);
        exit(0);
    }
    
    // 父进程:不等待子进程
    while(1)
    {
        printf("Father working...\n");
        sleep(1);
    }
    
    return 0;
}

问题: 子进程退出后变成僵尸进程!

复制代码
$ ps aux | grep Z
USER  PID  STAT  COMMAND
user  1234 Z+    [test] <defunct>   # 僵尸进程

7-2 传统解决方案的缺陷

方案1:阻塞等待

复制代码
wait(NULL);  // 阻塞,父进程什么都做不了

方案2:轮询等待

复制代码
while(1)
{
    pid_t ret = waitpid(-1, NULL, WNOHANG);
    if(ret > 0)
    {
        printf("Child %d exited\n", ret);
    }
    
    // 做其他事情
    do_something();
}

问题: 轮询效率低,且不优雅。


7-3 SIGCHLD信号的优雅方案

SIGCHLD信号: 子进程退出时,自动向父进程发送SIGCHLD信号。

复制代码
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>

void handler(int sig)
{
    pid_t id;
    // 使用WNOHANG循环回收所有已退出的子进程
    // 因为SIGCHLD可能合并,一次handler可能需要回收多个子进程
    while((id = waitpid(-1, NULL, WNOHANG)) > 0)
    {
        printf("wait child success: %d\n", id);
    }
    printf("handler done, parent PID: %d\n", getpid());
}

int main()
{
    // 注册SIGCHLD处理函数
    signal(SIGCHLD, handler);
    
    pid_t pid;
    for(int i = 0; i < 5; i++)
    {
        pid = fork();
        if(pid == 0)
        {
            // 子进程
            printf("child: %d, will exit after %d seconds\n", getpid(), i + 1);
            sleep(i + 1);
            exit(0);
        }
    }
    
    // 父进程继续工作
    while(1)
    {
        printf("father working...\n");
        sleep(1);
    }
    
    return 0;
}

运行结果:

复制代码
child: 1001, will exit after 1 seconds
child: 1002, will exit after 2 seconds
child: 1003, will exit after 3 seconds
child: 1004, will exit after 4 seconds
child: 1005, will exit after 5 seconds
father working...
father working...
wait child success: 1001
handler done, parent PID: 1000
father working...
wait child success: 1002
handler done, parent PID: 1000
...

7-4 SIGCHLD的关键细节

为什么handler中要用while循环?

因为SIGCHLD信号可能会"合并"!

复制代码
子进程1退出 → 发送SIGCHLD
子进程2退出 → 发送SIGCHLD(可能被合并)
子进程3退出 → 发送SIGCHLD(可能被合并)
      ↓
父进程收到1个SIGCHLD
      ↓
handler必须循环回收所有已退出的子进程

为什么waitpid要加WNOHANG?

如果没有WNOHANG,当没有子进程退出时,waitpid会阻塞,handler就无法返回。


7-5 更简洁的方案:忽略SIGCHLD

复制代码
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>

int main()
{
    // 将SIGCHLD的处理动作设为SIG_IGN
    // 这样子进程退出时会自动清理,不会产生僵尸进程
    signal(SIGCHLD, SIG_IGN);
    
    pid_t pid;
    for(int i = 0; i < 5; i++)
    {
        pid = fork();
        if(pid == 0)
        {
            printf("child: %d\n", getpid());
            sleep(1);
            exit(0);
        }
    }
    
    // 父进程继续工作,不需要wait
    while(1)
    {
        printf("father working...\n");
        sleep(1);
    }
    
    return 0;
}

注意: 这是UNIX的历史特性,不是POSIX标准。在Linux上可用,但其他UNIX系统可能行为不同。


7-6 SIGCHLD的使用建议

场景 推荐方案
需要知道子进程退出状态 sigaction + waitpid(WNOHANG)
不关心子进程退出状态 signal(SIGCHLD, SIG_IGN)
只等待一次 wait() 或 waitpid()
需要异步处理 SIGCHLD信号处理

生产环境推荐写法:

复制代码
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>

volatile sig_atomic_t child_exit_count = 0;

void sigchld_handler(int sig)
{
    pid_t pid;
    int status;
    
    // 循环回收所有已退出的子进程
    while((pid = waitpid(-1, &status, WNOHANG)) > 0)
    {
        child_exit_count++;
        
        // 可以在这里记录子进程退出状态
        if(WIFEXITED(status))
        {
            // 正常退出
            write(STDOUT_FILENO, "Child exited normally\n", 22);
        }
        else if(WIFSIGNALED(status))
        {
            // 被信号杀死
            write(STDOUT_FILENO, "Child killed by signal\n", 23);
        }
    }
}

int main()
{
    // 使用sigaction,更可靠
    struct sigaction sa;
    sa.sa_handler = sigchld_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
    
    sigaction(SIGCHLD, &sa, NULL);
    
    // 创建子进程...
    
    return 0;
}

8. 用户态和内核态补充

CPU 指令集 :是 CPU 实现软件指挥硬件执行的媒介,具体来说每一条汇编语句都对应了一条 CPU 指令 ,而非常非常多的 CPU 指令 在一起,可以组成一个、甚至多个集合,指令的集合 叫 CPU 指令集

CPU 指令集 有权限分级,试想一下, CPU 指令集 可以直接操作硬件的,要是因为指令操作 的不规范,造成的错误会影响整个计算机系统的。好比你写程序,因为对硬件操作不熟悉,导致 操作系统内核、及其他所有正在运行的程序,都可能会因为操作失误而受到不可挽回的错误,最 后只能重启计算机才行。

◦ 对开发人员来说是个艰巨的任务,还会增加负担,同时开发人员在这方面也不被信任,所以 操作系统内核直接屏蔽开发人员对硬件操作的可能,都不让你碰到这些 CPU 指令集 。

针对上面的需求,硬件设备商直接提供硬件级别的支持,做法就是对 CPU 指令集 设置了权限,不同 级别权限能使用的 CPU 指令集 是有限的,以 Inter CPU 为例,Inter把 CPU 指令集 操作的权限由 高到低划为4级:

8-1 CPU指令集权限分级

复制代码
Intel CPU特权级(Ring 0 ~ Ring 3):

Ring 0 (内核态)
├── 可以执行所有CPU指令
├── 可以访问所有内存
├── 可以操作硬件
└── OS内核运行在此级别

Ring 1, Ring 2 (中间态,Linux未使用)

Ring 3 (用户态)
├── 只能执行常规指令
├── 不能直接访问内核空间
├── 不能直接操作硬件
└── 用户程序运行在此级别

Linux只使用Ring 0和Ring 3:

  • Ring 0:内核态
  • Ring 3:用户态

8-2 CPL、DPL、RPL

CPL(Current Privilege Level):

  • 当前特权级
  • 存储在CS寄存器的低2位
  • 表示当前代码运行在哪个Ring

DPL(Descriptor Privilege Level):

  • 描述符特权级
  • 存储在段描述符或页表项中
  • 表示访问该段/页需要的最低特权级

RPL(Requested Privilege Level):

  • 请求特权级
  • 存储在段选择子中
  • 用于权限检查

权限检查规则:

复制代码
访问内存时,CPU检查:max(CPL, RPL) <= DPL
如果不满足,触发保护异常

8-3 用户态/内核态切换的详细过程

复制代码
用户态执行int 0x80
      ↓
CPU硬件自动完成:
  1. 读取IDT(中断描述符表),找到0x80对应的门描述符
  2. 检查权限:CPL <= 门描述符的DPL
  3. 切换到内核栈(从TSS中读取内核栈地址)
  4. 保存用户态SS、ESP、EFLAGS、CS、EIP到内核栈
  5. 加载内核CS、EIP(跳转到system_call入口)
  6. CPL变为0(内核态)
      ↓
执行内核代码(system_call → sys_call_table[eax])
      ↓
准备返回用户态(iret指令)
      ↓
CPU硬件自动完成:
  1. 从内核栈恢复用户态EIP、CS、EFLAGS、ESP、SS
  2. CPL变为3(用户态)
  3. 继续执行用户代码

8-4 中断描述符表(IDT)

复制代码
// IDT表项结构(简化)
struct idt_entry {
    uint16_t offset_low;    // 处理函数地址低16位
    uint16_t selector;      // 段选择子
    uint8_t  zero;          // 保留
    uint8_t  type_attr;     // 类型和属性
    uint16_t offset_high;   // 处理函数地址高16位
} __attribute__((packed));

// IDT寄存器
struct idt_ptr {
    uint16_t limit;  // IDT大小
    uint32_t base;   // IDT基地址
} __attribute__((packed));

Linux初始化IDT:

复制代码
// 内核启动时
void __init trap_init(void)
{
    // 设置0x80号中断为系统调用入口
    set_system_gate(0x80, &system_call);
    
    // 设置其他中断/异常处理函数
    set_trap_gate(0, &divide_error);
    set_trap_gate(13, &general_protection);
    // ...
}

8-5 系统调用的完整流程

复制代码
用户程序调用printf("hello")
      ↓
printf内部调用write(1, "hello", 5)
      ↓
glibc的write封装:
  1. 将系统调用号(__NR_write = 4)放入eax
  2. 将参数放入ebx, ecx, edx
  3. 执行int 0x80
      ↓
CPU进入内核态
      ↓
system_call入口:
  1. 保存所有寄存器
  2. 检查eax中的系统调用号
  3. 调用sys_call_table[eax] → sys_write
      ↓
sys_write执行:
  1. 根据fd找到文件结构
  2. 调用文件的write方法
  3. 返回结果
      ↓
system_call出口:
  1. 将返回值放入eax
  2. 恢复寄存器
  3. 执行iret
      ↓
CPU返回用户态
      ↓
glibc检查eax中的返回值
      ↓
返回到用户程序

面试题与详细解答

面试题1:什么是可重入函数?为什么信号处理函数中要调用可重入函数?

答:

可重入函数: 可以被安全重入的函数。即使在执行过程中被打断,再次进入也不会出错。

可重入函数的条件:

  1. 只使用局部变量或参数
  2. 不调用不可重入函数
  3. 不访问全局/静态数据结构

信号处理函数中必须调用可重入函数的原因:

信号会在任何时候打断主程序。如果handler调用了不可重入函数(如malloc),而主程序正好也在调用malloc,就会导致数据结构损坏。

复制代码
// 错误示例
void handler(int sig)
{
    char *p = malloc(100);  // 不安全!
    free(p);                // 不安全!
}

// 正确示例
void handler(int sig)
{
    write(STDOUT_FILENO, "signal\n", 7);  // write是异步信号安全的
}

面试题2:volatile关键字的作用是什么?什么时候需要使用?

答:

volatile的作用: 告诉编译器该变量可能被意外修改,禁止编译器对该变量的访问做优化。

编译器优化的问题:

复制代码
int flag = 0;
while(!flag) { ... }
// 编译器可能把flag缓存到寄存器,只读一次
// 如果flag在信号处理函数中被修改,while检测不到

使用volatile后:

复制代码
volatile int flag = 0;
while(!flag) { ... }
// 编译器每次都从内存读取flag
// 能检测到信号处理函数中的修改

必须使用volatile的场景:

  1. 信号处理函数中的共享变量
  2. 多线程共享变量(配合锁)
  3. 硬件寄存器映射
  4. 中断服务程序中的共享变量

注意: volatile只保证可见性,不保证原子性!


面试题3:SIGCHLD信号有什么用?为什么waitpid要用WNOHANG?

答:

SIGCHLD的作用: 子进程退出时,内核自动向父进程发送SIGCHLD信号,通知父进程回收子进程。

使用SIGCHLD可以避免:

  1. 阻塞等待(wait)
  2. 轮询等待(waitpid + WNOHANG循环)

waitpid要用WNOHANG的原因:

复制代码
void handler(int sig)
{
    // 如果不用WNOHANG
    pid_t id = waitpid(-1, NULL, 0);  // 可能阻塞!
    // 如果没有子进程退出,handler会阻塞在这里
    // 主程序就无法继续执行
}

// 正确做法
void handler(int sig)
{
    pid_t id;
    while((id = waitpid(-1, NULL, WNOHANG)) > 0)
    {
        // 回收所有已退出的子进程
    }
}

为什么要用while循环?

因为SIGCHLD信号可能合并。多个子进程退出可能只发送一次SIGCHLD,handler必须循环回收所有已退出的子进程。


面试题4:如何避免产生僵尸进程?

答:

方法1:阻塞等待

复制代码
pid_t pid = fork();
if(pid > 0)
{
    wait(NULL);  // 父进程阻塞等待
}

方法2:非阻塞轮询

复制代码
while(1)
{
    pid_t ret = waitpid(-1, NULL, WNOHANG);
    if(ret > 0) { /* 子进程已回收 */ }
    else if(ret == 0) { /* 子进程还在运行 */ }
    else { /* 没有子进程 */ }
    
    // 做其他事情
}

方法3:SIGCHLD信号处理(推荐)

复制代码
void handler(int sig)
{
    while(waitpid(-1, NULL, WNOHANG) > 0);
}
signal(SIGCHLD, handler);

方法4:忽略SIGCHLD(最简单)

复制代码
signal(SIGCHLD, SIG_IGN);
// 子进程退出时自动清理,不产生僵尸
// 但这是UNIX历史特性,不是POSIX标准

方法5:两次fork(高级)

复制代码
pid_t pid1 = fork();
if(pid1 == 0)
{
    // 子进程
    pid_t pid2 = fork();
    if(pid2 == 0)
    {
        // 孙子进程:真正工作的进程
        do_work();
        exit(0);
    }
    exit(0);  // 子进程立即退出
}
waitpid(pid1, NULL, 0);  // 父进程等待子进程
// 孙子进程变成孤儿,被init收养,init会自动回收

面试题5:什么是竞态条件?如何避免?

答:

竞态条件: 多个执行流同时访问和修改共享数据,最终结果取决于执行时序。

在信号处理中的竞态条件:

复制代码
int counter = 0;

void handler(int sig)
{
    counter++;  // 信号处理函数中修改
}

int main()
{
    signal(SIGINT, handler);
    
    while(1)
    {
        printf("%d\n", counter);  // 主程序中读取
        sleep(1);
    }
}

避免竞态条件的方法:

  1. 使用原子类型

    volatile sig_atomic_t counter = 0;

  2. 在临界区屏蔽信号

    sigset_t set;
    sigemptyset(&set);
    sigaddset(&set, SIGINT);

    sigprocmask(SIG_BLOCK, &set, NULL); // 屏蔽信号
    // 临界区代码
    counter++;
    sigprocmask(SIG_UNBLOCK, &set, NULL); // 解除屏蔽

  3. 使用信号安全函数

    void handler(int sig)
    {
    // 只调用异步信号安全函数
    write(STDOUT_FILENO, "signal\n", 7);
    }


面试题6:解释用户态和内核态的区别,以及什么时候会发生切换?

答:

用户态 vs 内核态:

对比项 用户态 内核态
特权级 Ring 3 Ring 0
地址空间 0-3GB 0-4GB(全部)
指令权限 常规指令 所有指令
访问硬件 不能
运行代码 用户程序 OS内核

切换时机:

  1. 系统调用:用户主动请求OS服务(如read、write、fork)
  2. 异常:程序错误触发(如除零、缺页、野指针)
  3. 外部中断:硬件设备请求处理(如键盘、时钟、磁盘)

切换过程:

复制代码
用户态 → 触发事件 → 保存用户态上下文 → 切换到内核栈 → CPL=0 → 执行内核代码
      ← 恢复用户态上下文 ← 检查信号 ← CPL=3 ← 返回用户态

总结:

  • 可重入函数:定义、判断条件、链表插入Bug
  • 竞态条件:定义、产生原因、解决方法
  • 异步信号安全函数:哪些函数在handler中可以安全调用
  • volatile:编译器优化问题、使用场景、与原子性的关系
  • SIGCHLD:僵尸进程处理、WNOHANG、信号合并
  • 用户态/内核态:特权级、IDT、系统调用流程

面试题(6道):

  1. 可重入函数的定义和重要性
  2. volatile的作用和使用场景
  3. SIGCHLD的使用方法
  4. 避免僵尸进程的5种方法
  5. 竞态条件的产生和避免
  6. 用户态/内核态切换机制
相关推荐
野熊佩骑6 小时前
一文读懂Nginx 之 Ubuntu使用apt方式安装Nginx官方最新版本
linux·运维·服务器·nginx·ubuntu·http
闫记康7 小时前
Linux学习day3
linux·服务器·学习
皆圥忈7 小时前
Linux 进程管理从入门到实战(一)
linux
雪度娃娃7 小时前
Asio——socket的创建和连接
linux·运维·服务器·c++·网络协议
剑神一笑7 小时前
Linux tar 归档命令深度解析:从文件打包到压缩算法的完整实现
linux·运维·服务器
coolwaterld7 小时前
Linux 移动硬盘挂载不上 wrong fs type, bad option, bad superblock
linux·服务器
J2虾虾7 小时前
Linux tar 命令详解
linux·运维·服务器
阳光九叶草LXGZXJ7 小时前
达梦数据库-学习-52-DmDrs参数介绍(Manager模块)
linux·运维·数据库·sql·学习
corpse20107 小时前
CentOS Linux release 8.5.2111下的CVE-2026-31431 Linux内核提权漏洞处置 过程问题记录
linux·运维·centos