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 判断函数是否可重入的条件
以下条件满足任意一个,就是不可重入函数:
-
调用了malloc或free
- malloc内部维护全局的堆链表,重入会破坏链表结构
-
调用了标准I/O库函数
- printf、fprintf等内部有缓冲区,重入会导致输出混乱
- 标准IO库的很多实现都使用了全局数据结构
-
使用了静态局部变量或全局变量
- 静态变量和全局变量在函数调用间保持状态,重入会修改这些状态
可重入函数的特征:
-
只使用自己的局部变量或参数
-
不调用不可重入函数
-
不访问全局数据结构
// 可重入函数示例
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的场景:
-
信号处理函数中的共享变量
volatile sig_atomic_t flag = 0; -
多线程共享变量(配合锁使用)
volatile int counter = 0; pthread_mutex_t lock; -
硬件寄存器映射
volatile uint32_t *reg = (uint32_t *)0x40001000; -
中断服务程序中的共享变量
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, ÷_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:什么是可重入函数?为什么信号处理函数中要调用可重入函数?
答:
可重入函数: 可以被安全重入的函数。即使在执行过程中被打断,再次进入也不会出错。
可重入函数的条件:
- 只使用局部变量或参数
- 不调用不可重入函数
- 不访问全局/静态数据结构
信号处理函数中必须调用可重入函数的原因:
信号会在任何时候打断主程序。如果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的场景:
- 信号处理函数中的共享变量
- 多线程共享变量(配合锁)
- 硬件寄存器映射
- 中断服务程序中的共享变量
注意: volatile只保证可见性,不保证原子性!
面试题3:SIGCHLD信号有什么用?为什么waitpid要用WNOHANG?
答:
SIGCHLD的作用: 子进程退出时,内核自动向父进程发送SIGCHLD信号,通知父进程回收子进程。
使用SIGCHLD可以避免:
- 阻塞等待(wait)
- 轮询等待(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);
}
}
避免竞态条件的方法:
-
使用原子类型
volatile sig_atomic_t counter = 0;
-
在临界区屏蔽信号
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);sigprocmask(SIG_BLOCK, &set, NULL); // 屏蔽信号
// 临界区代码
counter++;
sigprocmask(SIG_UNBLOCK, &set, NULL); // 解除屏蔽 -
使用信号安全函数
void handler(int sig)
{
// 只调用异步信号安全函数
write(STDOUT_FILENO, "signal\n", 7);
}
面试题6:解释用户态和内核态的区别,以及什么时候会发生切换?
答:
用户态 vs 内核态:
| 对比项 | 用户态 | 内核态 |
|---|---|---|
| 特权级 | Ring 3 | Ring 0 |
| 地址空间 | 0-3GB | 0-4GB(全部) |
| 指令权限 | 常规指令 | 所有指令 |
| 访问硬件 | 不能 | 能 |
| 运行代码 | 用户程序 | OS内核 |
切换时机:
- 系统调用:用户主动请求OS服务(如read、write、fork)
- 异常:程序错误触发(如除零、缺页、野指针)
- 外部中断:硬件设备请求处理(如键盘、时钟、磁盘)
切换过程:
用户态 → 触发事件 → 保存用户态上下文 → 切换到内核栈 → CPL=0 → 执行内核代码
← 恢复用户态上下文 ← 检查信号 ← CPL=3 ← 返回用户态
总结:
- 可重入函数:定义、判断条件、链表插入Bug
- 竞态条件:定义、产生原因、解决方法
- 异步信号安全函数:哪些函数在handler中可以安全调用
- volatile:编译器优化问题、使用场景、与原子性的关系
- SIGCHLD:僵尸进程处理、WNOHANG、信号合并
- 用户态/内核态:特权级、IDT、系统调用流程
面试题(6道):
- 可重入函数的定义和重要性
- volatile的作用和使用场景
- SIGCHLD的使用方法
- 避免僵尸进程的5种方法
- 竞态条件的产生和避免
- 用户态/内核态切换机制