九、Linux信号机制(二) 5. 可重入函数5-1 问题引入链表插入的经典Bugmain函数调用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或freemalloc内部维护全局的堆链表重入会破坏链表结构调用了标准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-safetyPOSIX规定的异步信号安全函数部分函数说明_exit()退出进程write()写文件不用printfread()读文件kill()发送信号getpid()获取进程IDsignal()注册信号某些系统常见的不是异步信号安全的函数函数为什么不安全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 loop6-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 CtrlC (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 CtrlC (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 3Ring 0内核态Ring 3用户态8-2 CPL、DPL、RPLCPLCurrent Privilege Level当前特权级存储在CS寄存器的低2位表示当前代码运行在哪个RingDPLDescriptor Privilege Level描述符特权级存储在段描述符或页表项中表示访问该段/页需要的最低特权级RPLRequested 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什么是可重入函数为什么信号处理函数中要调用可重入函数答可重入函数可以被安全重入的函数。即使在执行过程中被打断再次进入也不会出错。可重入函数的条件只使用局部变量或参数不调用不可重入函数不访问全局/静态数据结构信号处理函数中必须调用可重入函数的原因信号会在任何时候打断主程序。如果handler调用了不可重入函数如malloc而主程序正好也在调用malloc就会导致数据结构损坏。// 错误示例 void handler(int sig) { char *p malloc(100); // 不安全 free(p); // 不安全 } // 正确示例 void handler(int sig) { write(STDOUT_FILENO, signal\n, 7); // write是异步信号安全的 }面试题2volatile关键字的作用是什么什么时候需要使用答volatile的作用告诉编译器该变量可能被意外修改禁止编译器对该变量的访问做优化。编译器优化的问题int flag 0; while(!flag) { ... } // 编译器可能把flag缓存到寄存器只读一次 // 如果flag在信号处理函数中被修改while检测不到使用volatile后volatile int flag 0; while(!flag) { ... } // 编译器每次都从内存读取flag // 能检测到信号处理函数中的修改必须使用volatile的场景信号处理函数中的共享变量多线程共享变量配合锁硬件寄存器映射中断服务程序中的共享变量注意volatile只保证可见性不保证原子性面试题3SIGCHLD信号有什么用为什么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信号可能合并。多个子进程退出可能只发送一次SIGCHLDhandler必须循环回收所有已退出的子进程。面试题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 { /* 没有子进程 */ } // 做其他事情 }方法3SIGCHLD信号处理推荐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 3Ring 0地址空间0-3GB0-4GB全部指令权限常规指令所有指令访问硬件不能能运行代码用户程序OS内核切换时机系统调用用户主动请求OS服务如read、write、fork异常程序错误触发如除零、缺页、野指针外部中断硬件设备请求处理如键盘、时钟、磁盘切换过程用户态 → 触发事件 → 保存用户态上下文 → 切换到内核栈 → CPL0 → 执行内核代码 ← 恢复用户态上下文 ← 检查信号 ← CPL3 ← 返回用户态总结可重入函数定义、判断条件、链表插入Bug竞态条件定义、产生原因、解决方法异步信号安全函数哪些函数在handler中可以安全调用volatile编译器优化问题、使用场景、与原子性的关系SIGCHLD僵尸进程处理、WNOHANG、信号合并用户态/内核态特权级、IDT、系统调用流程面试题6道可重入函数的定义和重要性volatile的作用和使用场景SIGCHLD的使用方法避免僵尸进程的5种方法竞态条件的产生和避免用户态/内核态切换机制