函数与信号处理:signal函数的注册,信号处理函数的限制,异步信号安全函数
信号处理,说白了就是操作系统给进程发了个“软中断”。你正在跑一个循环,突然来了个SIGINT(Ctrl+C),程序怎么响应?这就靠signal函数来注册处理逻辑了。
我个人习惯把信号处理比作“紧急电话”。平时程序按部就班执行,信号一来,就像电话铃响了,你得放下手头的事去接。但这个“接电话”有很多规矩,搞不好就会出大问题。
signal函数的注册机制
signal函数的原型很简单:
#include <signal.h>
void (*signal(int sig, void (*func)(int)))(int);
嗯,这个声明看着有点绕。我拆开给你看:
- sig:要捕获的信号编号,比如SIGINT、SIGTERM
- func:处理函数的指针,参数是int,返回void
- 返回值:之前的处理函数指针
实际用的时候,我们通常这样写:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void handler(int sig) {
// 注意:这里不能做太多事
write(STDOUT_FILENO, "Caught signal!\n", 15);
}
int main() {
// 注册信号处理函数
signal(SIGINT, handler);
while(1) {
pause(); // 等待信号
}
return 0;
}
我在项目中遇到过一个问题:有些系统上signal的行为不一致。比如早期的System V,signal注册后处理完一次就恢复成默认行为。而BSD则保持注册状态。所以我现在更推荐用sigaction,它行为明确。
关键点:signal注册的是“信号到来时做什么”,不是“信号到来后恢复什么”。处理函数执行期间,同类型信号可能会被阻塞,也可能不会——这取决于实现。
信号处理函数的限制
这是个大坑。信号处理函数不是在正常的程序流中执行的。它可能发生在任何时刻——你正在malloc分配内存,正在printf输出,甚至正在修改全局链表。
所以,信号处理函数里能做的事非常有限:
- 不能调用不可重入函数——比如malloc、free、printf、fprintf
- 不能操作全局或静态数据——除非这些数据是volatile sig_atomic_t类型
- 不能修改errno——除非你保存和恢复它
- 不能调用任何标准I/O函数——因为它们内部有缓冲区,可能正在被主程序使用
为什么会这样?你想想看,如果主程序正在malloc里操作堆链表,信号来了,处理函数里又调了malloc——堆链表就乱套了。这就是不可重入。
我曾经在一个嵌入式项目中,信号处理函数里调了printf来打日志。结果程序偶尔会莫名其妙地卡死。查了两天才发现,printf内部用了全局锁,主程序在printf里被信号打断,处理函数又去抢同一把锁——死锁了。
那信号处理函数里到底能做什么?我总结了几条:
- 设置一个volatile sig_atomic_t标志变量
- 调用异步信号安全函数(后面讲)
- 直接return(啥也不做)
- 调用_exit()或_Exit()终止程序
看个例子:
#include <signal.h>
#include <stdio.h>
volatile sig_atomic_t flag = 0;
void handler(int sig) {
flag = 1; // 唯一能做的事
}
int main() {
signal(SIGINT, handler);
while(!flag) {
// 主循环做自己的事
}
printf("Got signal, cleaning up...\n");
// 这里可以安全地清理资源
return 0;
}
注意flag用了volatile sig_atomic_t。volatile告诉编译器不要优化掉对flag的读写,sig_atomic_t保证读写是原子的。在大多数平台上,sig_atomic_t就是int。
异步信号安全函数
POSIX标准定义了一批“异步信号安全”的函数。这些函数可以在信号处理函数里安全调用。说白了,它们是可重入的,或者内部不依赖全局状态。
我整理了一份常用列表:
| 类别 | 函数 | 说明 |
|---|---|---|
| 文件I/O | read, write, open, close | 注意:不是stdio的fread/fwrite |
| 进程控制 | _exit, _Exit, getpid, kill | _exit不会刷新缓冲区 |
| 信号操作 | signal, sigaction, sigprocmask | 但小心递归调用 |
| 内存操作 | memcpy, memset, memmove | 前提是操作的不是共享数据 |
| 其他 | sleep, alarm, time, gettimeofday | sleep在信号处理后可能提前返回 |
我个人习惯:在信号处理函数里只用write和_exit。write直接调用系统调用,不经过缓冲区,安全可靠。其他操作都通过标志变量交给主循环处理。
看一个实际例子:
#include <unistd.h>
#include <signal.h>
#include <string.h>
volatile sig_atomic_t got_signal = 0;
void safe_handler(int sig) {
const char *msg = "Signal received, will exit\n";
// write是异步信号安全的
write(STDOUT_FILENO, msg, strlen(msg));
got_signal = 1;
}
int main() {
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = safe_handler;
sigaction(SIGINT, &sa, NULL);
while(!got_signal) {
// 正常工作
}
_exit(0);
}
这里我用sigaction代替了signal。sigaction更可控,可以指定sa_flags,比如SA_RESTART让被中断的系统调用自动重启。
避坑指南:errno是个全局变量。如果信号处理函数里调用了可能设置errno的函数(比如write),它会覆盖主程序的errno。所以要么在信号处理函数里保存和恢复errno,要么确保主程序不依赖errno的精确值。
嗯,说到这我想起一个经典问题:信号处理函数里能不能调用longjmp?技术上可以,但非常危险。如果信号是在某个不完整的操作中到达的,longjmp跳走后,那个操作的状态就悬空了。我建议你除非万不得已,别这么干。
最后,我画了一张图来总结信号处理的整个流程:
这张图把整个流程串起来了。主程序正常执行,信号来了就跳转到处理函数。处理函数里只能走左边的安全路径,不能走右边的危险路径。处理完要么设置标志返回,要么直接_exit。
记住一句话:信号处理函数里,能少做就少做。把复杂逻辑留给主循环去处理。这是我在多年嵌入式开发中总结出的最实用的经验。