上一篇我们讲了 Linux 信号捕捉的底层机制,其中有个关键约束:信号处理函数必须使用可重入函数 。如果在处理函数中调用printf()、malloc()等不可重入函数,可能导致数据错乱、内存泄漏甚至程序崩溃。本文将从定义、原理到实操,帮你彻底搞懂可重入函数,避开信号处理的 "致命陷阱"
一、什么是可重入函数?核心定义与形象类比
1. 官方定义
可重入函数(Reentrant Function)指:函数在执行过程中被异步打断(如信号、中断、多线程调度),再次调用(重入)后,原执行流程和新执行流程都能正确完成,结果不受打断影响
2. 形象类比
- 可重入函数:像自动售货机 ------ 你投币买水到一半被人打断去买零食,回来继续投币,售货机仍能正确给你水(逻辑独立,不依赖 "半完成" 状态)
- 不可重入函数:像手工记账本 ------ 你记到一半(写了 "收入 100" 但没写 "元")被打断去记另一笔账,回来忘了之前的进度,账本直接混乱(依赖全局状态,操作非原子)
3. 与线程安全的区别
很多人会混淆 "可重入" 与 "线程安全",但二者是完全不同的概念:
| 对比维度 | 可重入函数 | 线程安全函数 |
|---|---|---|
| 核心场景 | 单线程中被异步打断后重入 | 多线程并发调用 |
| 依赖资源 | 仅使用局部变量 / 函数参数(私有) | 可能使用共享资源(需同步机制) |
| 实现方式 | 无状态设计,不依赖全局资源 | 可能通过锁、原子操作保护共享资源 |
| 关系逻辑 | 可重入未必线程安全,线程安全未必可重入 | ------ |
示例:fprintf()是线程安全的(内部有锁保护),但不可重入 ------ 若信号处理函数打断主程序的fprintf(),再调用fprintf()会导致全局输出缓冲区错乱
二、不可重入函数的 "致命陷阱":为什么不能在信号处理中使用?
不可重入函数的核心问题是 "依赖非私有资源"(全局变量、静态变量、共享内存等)或 "执行非原子操作"。在信号处理场景中,这种依赖会被无限放大,因为信号的触发时机完全不可预测
典型案例:strtok () 函数的重入灾难
strtok()是 C 标准库中的字符串分割函数,其不可重入的根源是使用静态变量保存 "上次分割的位置":
#include <stdio.h>
#include <string.h>
#include <signal.h>
void handle_sigint(int signum) {
char str[] = "x,y,z";
// 信号处理函数中调用strtok(),覆盖静态变量
char *p = strtok(str, ",");
printf("信号处理中分割结果:%s\n", p);
}
int main() {
signal(SIGINT, handle_sigint);
char str[] = "a,b,c,d";
// 主程序第一次分割,静态变量保存"b,c,d"的起始地址
char *p = strtok(str, ",");
printf("主程序第一次分割结果:%s\n", p);
printf("按Ctrl+C触发信号...\n");
pause(); // 等待信号
// 主程序继续分割,静态变量已被信号处理函数修改
p = strtok(NULL, ",");
printf("主程序第二次分割结果:%s\n", p); // 预期输出"b",实际输出"y"
return 0;
}
运行结果 :主程序第二次分割的结果完全错误,因为strtok()的静态变量被信号处理函数覆盖 ------ 这就是不可重入函数在信号处理中的典型灾难
不可重入函数的常见特征
只要满足以下任一条件,大概率是不可重入函数:
- 使用全局变量、静态变量或静态缓冲区(如
strtok()、asctime()) - 调用动态内存分配 / 释放函数(
malloc()、free(),操作全局内存池) - 操作共享资源(如文件、硬件寄存器,无同步机制)
- 调用其他不可重入函数(如
printf()调用malloc(),间接依赖全局资源)
三、常见可重入 / 不可重入函数清单(开发必备)
1. 安全可用:可重入函数(信号处理中放心用)
这类函数仅依赖参数和局部变量,无全局状态,操作原子:
- 内存操作:
memcpy()、memset()、strcpy()、strcmp()(仅操作传入参数) - 系统调用:
write()、read()、_exit()、close()(内核级原子操作,不依赖用户态全局资源) - 基础运算:
abs()、sqrt()、atoi()(仅处理参数,无副作用) - 可重入变体:
strtok_r()(strtok()的可重入版)、localtime_r()(localtime()的可重入版)
2. 绝对禁用:不可重入函数(信号处理中严禁使用)
这类函数依赖全局 / 静态资源,或有分步操作,易导致错乱:
- 标准 IO:
printf()、fprintf()、puts()、scanf()(依赖全局输出缓冲区) - 内存管理:
malloc()、free()、calloc()、realloc()(操作全局内存池链表) - 字符串处理:
strtok()、asctime()、ctime()(使用静态变量 / 缓冲区) - 其他:
rand()(静态随机数种子)、getenv()(全局环境变量表)、sleep()(修改全局定时器状态)
四、如何编写安全的可重入函数?5 个核心原则
编写可重入函数的核心是 "无状态化" 和 "数据隔离",具体遵循以下原则
- 禁用全局 / 静态变量:所有数据通过参数传入(传值而非传指针共享),或使用函数内局部变量(栈上分配,每个调用有独立栈帧)
- 避免动态内存操作 :若需内存分配,改用栈上缓冲区或内存池(提前分配固定大小内存),不调用
malloc()/free() - 仅调用可重入函数:函数内部依赖的所有函数必须是可重入的,形成 "安全调用链"
- 原子操作处理共享资源 :若必须操作共享资源(如全局标志位),使用
volatile sig_atomic_t类型(保证读写原子性),或通过信号屏蔽避免并发访问 - 拒绝递归依赖:避免函数递归调用自身(除非能保证栈空间充足,且无共享状态)
实操示例:将不可重入函数改为可重入
以 "统计字符串长度" 为例,对比不可重入与可重入实现:
// 不可重入版本:使用静态变量保存结果
static int g_len = 0;
int unsafe_strlen(const char *str) {
g_len = 0;
while (str[g_len] != '\0') {
g_len++;
}
return g_len;
}
// 可重入版本:使用局部变量,无全局依赖
int safe_strlen(const char *str) {
int len = 0;
while (str[len] != '\0') {
len++;
}
return len;
}
五、信号处理函数的安全编写规范
结合可重入原则,信号处理函数需满足以下规范,避免踩坑:
- 仅执行必要逻辑:优先设置全局标志位(
volatile sig_atomic_t类型),复杂逻辑交给主程序处理 - 严格使用可重入函数:仅调用
write()、memcpy()等安全函数,禁用printf()、malloc() - 控制函数长度:处理函数应短小精悍,避免长时间阻塞(如循环等待)
- 保护共享资源:若需修改全局变量,使用
volatile sig_atomic_t保证原子性,或在处理前屏蔽相关信号
规范示例:安全的信号处理函数
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
// 全局标志位:使用volatile sig_atomic_t保证原子读写
volatile sig_atomic_t g_quit = 0;
// 信号处理函数:仅设置标志位,无复杂操作
void handle_sigterm(int signum) {
g_quit = 1;
// 安全调用可重入函数write()
const char *msg = "收到SIGTERM信号,准备退出...\n";
write(STDOUT_FILENO, msg, strlen(msg));
}
int main() {
struct sigaction act;
sigemptyset(&act.sa_mask);
act.sa_handler = handle_sigterm;
act.sa_flags = 0;
sigaction(SIGTERM, &act, NULL);
printf("进程运行中,PID:%d,等待SIGTERM信号(kill PID)...\n", getpid());
while (!g_quit) {
sleep(1); // 主程序循环,检测标志位
}
// 主程序处理退出逻辑(释放资源等)
printf("开始释放资源,优雅退出...\n");
return 0;
}
可重入函数是 Linux 信号处理的 "安全底线",其核心设计思想是 "不依赖外部状态,仅靠输入参数保证逻辑正确"。记住三个关键要点:信号处理函数必须用可重入函数,禁用printf()/malloc()等危险函数,通过volatile sig_atomic_t处理全局标志位,就可以啦>w<