15、异常控制流:setjmp/longjmp安全使用、信号处理安全、异步信号安全函数、中断上下文防御
异常控制流,说白了就是程序在正常执行路径之外,突然被“拽”到另一个地方去处理事情。这在嵌入式系统里太常见了——硬件中断来了、信号到了、或者你想从一个深层嵌套的函数调用中直接跳出来。我做了这么多年嵌入式开发,可以负责任地告诉你:这部分要是处理不好,系统崩溃是迟早的事。
今天我们就来聊聊四个核心话题:setjmp/longjmp怎么安全用、信号处理有哪些坑、哪些函数在异步信号里是安全的、以及中断上下文里该怎么防御。
15.1 setjmp/longjmp:非局部跳转的双刃剑
setjmp和longjmp这对组合,允许你从一个函数直接跳回到之前保存的调用点。这有点像C语言里的“goto”,但它是跨函数的。我个人习惯是,不到万不得已不用它——因为用不好,资源泄漏和栈混乱就会找上门。
15.1.1 基本用法
#include <setjmp.h>
#include <stdio.h>
jmp_buf env;
void func(void) {
printf("进入func\n");
longjmp(env, 42); // 跳回setjmp处,返回值为42
printf("这行不会执行\n");
}
int main(void) {
int ret;
if ((ret = setjmp(env)) == 0) {
printf("第一次调用setjmp\n");
func();
} else {
printf("从longjmp跳回,返回值:%d\n", ret);
}
return 0;
}
运行结果:
第一次调用setjmp
进入func
从longjmp跳回,返回值:42
setjmp第一次调用返回0,之后如果longjmp被触发,它会带着一个非零值跳回来。嗯,这里要注意:longjmp跳回后,局部变量的值是不确定的——除非你用volatile修饰。
我曾经在一个项目中用longjmp跳出多层函数调用,结果忘了释放中间函数里malloc的内存。那一次内存泄漏排查了我整整两天。记住:longjmp不会帮你调用任何析构或清理代码。
15.1.2 安全使用原则
- 只在错误恢复场景使用:不要用它模拟异常处理,C语言不是C++。
- 确保所有资源在跳转前释放:锁、内存、文件描述符,一个都不能漏。
- 用volatile修饰可能被修改的局部变量:否则longjmp回来后,变量的值可能是旧的。
- 不要在信号处理函数里调用longjmp:除非你100%确定信号上下文是安全的——但说实话,我很少见到有人能保证这一点。
如果你非要用longjmp做错误恢复,我建议你封装一个清理函数,在longjmp之前调用它。比如:
#define TRY do { jmp_buf env; if (setjmp(env) == 0) #define CATCH else #define THROW longjmp(env, 1)但说实话,这种宏看着挺酷,实际维护起来很痛苦。慎用。
15.2 信号处理安全:别在信号里干重活
信号处理函数是在异步上下文中执行的。你想想看,主程序可能正在malloc里修改堆管理器的链表,这时候信号来了,你在信号处理函数里又调用了malloc——完了,堆链表被搞乱了,程序必崩。
为什么会这样?因为信号处理函数和主程序是并发执行的,但它们是单线程里的“伪并发”。信号处理函数会打断主程序的任何操作,包括临界区。
15.2.1 信号处理函数的设计原则
| 原则 | 说明 |
|---|---|
| 尽量简单 | 只设置一个volatile sig_atomic_t标志,然后立即返回 |
| 不要调用非异步信号安全函数 | 比如printf、malloc、free、互斥锁操作等 |
| 不要访问全局数据结构 | 除非你用volatile sig_atomic_t修饰,并且操作是原子的 |
| 不要调用longjmp | 除非你使用sigsetjmp/siglongjmp,并且清楚后果 |
#include <signal.h>
#include <stdio.h>
volatile sig_atomic_t g_flag = 0;
void sigint_handler(int signo) {
g_flag = 1; // 只做这一件事
}
int main(void) {
signal(SIGINT, sigint_handler);
while (1) {
if (g_flag) {
printf("收到SIGINT,退出\n");
break;
}
// 正常业务逻辑
}
return 0;
}
信号处理函数里,你唯一能安全做的事就是:
1. 给一个volatile sig_atomic_t变量赋值
2. 调用_Exit()或_Exit()
3. 调用signal()重新注册信号处理函数(某些系统)
其他操作,都是雷区。
15.3 异步信号安全函数:你的白名单
POSIX标准定义了一组“异步信号安全函数”。这些函数可以在信号处理函数里安全调用,因为它们要么是可重入的,要么不会被信号打断破坏内部状态。
我整理了一份常用列表,你最好把它记下来,或者贴在工位上:
| 类别 | 安全函数 |
|---|---|
| 文件操作 | read(), write(), open(), close(), lseek(), fstat() |
| 进程控制 | _Exit(), _exit(), getpid(), kill(), waitpid() |
| 信号操作 | signal(), sigaction(), sigprocmask(), sigpending() |
| 内存操作 | 无!malloc/free/realloc都不安全 |
| 其他 | sleep(), alarm(), time(), gettimeofday() |
printf、sprintf、fprintf这些你天天用的函数,在信号处理函数里调用是极其危险的。我在一个项目中见过同事在信号处理里用printf调试,结果程序在客户现场随机崩溃,最后定位到是printf内部锁了标准输出,而主程序也在用printf——死锁了。
15.4 中断上下文防御:裸机与RTOS下的铁律
中断上下文比信号上下文更严格。在裸机或RTOS环境下,中断服务程序(ISR)里能做的事非常有限。你想想看,中断是硬件触发的,它可能发生在任何时刻——包括CPU正在修改关键数据结构的时刻。
15.4.1 ISR里绝对不能做的事
- 调用不可重入的函数:比如标准库里的很多函数。
- 使用阻塞操作:比如等待信号量、延时循环。
- 访问非原子共享变量:除非你用volatile并且操作是单指令的。
- 调用RTOS的阻塞API:比如等待消息队列、获取互斥锁。
15.4.2 中断防御的典型模式
我常用的模式是“中断记录,主循环处理”。ISR只做最轻量的事,比如设置一个标志、往一个无锁缓冲区里写数据,然后立即返回。主循环或任务线程去轮询这些标志,处理实际业务。
// 伪代码:中断防御模式
volatile uint32_t g_irq_flag = 0;
uint32_t g_data_buffer[64];
volatile uint32_t g_buffer_index = 0;
void UART_IRQHandler(void) {
// ISR里只做两件事
g_data_buffer[g_buffer_index] = UART->DR; // 读数据
g_buffer_index = (g_buffer_index + 1) & 0x3F; // 环形缓冲
g_irq_flag = 1; // 通知主循环
}
void main_loop(void) {
while (1) {
if (g_irq_flag) {
g_irq_flag = 0;
// 在这里处理数据,可以放心调用printf、malloc等
process_data();
}
// 其他任务
}
}
在Cortex-M系列MCU上,我曾经踩过一个坑:ISR里直接调用了RTOS的队列发送函数,结果因为优先级反转导致系统卡死。后来我改成ISR只写一个无锁环形缓冲区,由任务线程去读取并发送到队列,问题就解决了。记住:ISR里越简单越好,最好不超过10行代码。
15.5 知识体系总览
下面这张图总结了本章的核心逻辑。你可以把它当作一个检查清单:遇到异常控制流时,先看看自己处在哪个上下文,再决定能做什么、不能做什么。
这张图把三种异常控制流场景放在一起对比。左边是setjmp/longjmp,中间是中断上下文,右边是信号处理。你会发现,它们的安全要求其实有共通之处:越是在异步或非正常上下文中,你的代码就越要简单、越要避免资源操作。
好了,关于异常控制流的安全使用,我们就聊到这里。记住一句话:在异常上下文中,你写的每一行代码都可能是最后一根稻草——要么撑住系统,要么压垮它。
公众号:蓝海资料掘金营,微信deep3321