概述
C标准库提供两个特殊的函数:
setjmp()
及longjmp()
,这两个函数是结构化异常的基础,正是利用这两个函数的特性来实现异常。
所以,异常的处理过程可以描述为这样:
首先设置一个跳转点(setjmp() 函数可以实现这一功能),
然后在其后的代码中任意地方调用 longjmp() 跳转回这个跳转点上,
以此来实现当发生异常时,转到处理异常的程序上,在其后的介绍中将介绍如何实现。
setjmp() 为跳转返回保存现场并为异常提供处理程序,longjmp() 则进行跳转(抛出异常),setjmp() 与 longjmp() 可以在函数间进行跳转,这就像一个全局的 goto 语句,可以跨函数跳转。
举个例子,程序在 main() 函数内使用 setjmp() 设置跳转,并调用另一函数A,函数A内调用B,B抛出异常(调用longjmp() 函数),则程序直接跳回到 main() 函数内使用 setjmp() 的地方返回,并且返回一个值。
简述流程
- 使用setjmp保存当前执行环境到jmp_buf,然后默认返回0。
- 程序继续执行,到某个地方调用longjmp,传入上面保存的jmp_buf,以及另一个值。
- 此时执行点又回到调用setjmp的返回处,且返回值变成longjmp设置的值。
jmp_buf 异常结构
使用 setjmp() 及 longjmp() 函数前,需要先认识一下 jmp_buf 异常结构。jmp_buf 将使用在 setjmp() 函数中,用于保存当前程序现场(保存当前需要用到的寄存器的值),jmp_buf 结构在 setjmp.h 文件内声明:
typedef struct { unsigned j_sp; // 堆栈指针寄存器 unsigned j_ss; // 堆栈段 unsigned j_flag; // 标志寄存器 unsigned j_cs; // 代码段 unsigned j_ip; // 指令指针寄存器 unsigned j_bp; // 基址指针 unsigned j_di; // 目的指针 unsigned j_es; // 附加段 unsigned j_si; // 源变址 unsigned j_ds; // 数据段 } jmp_buf;
jmp_buf 结构存放了程序当前寄存器的值,以确保使用 longjmp() 后可以跳回到该执行点上继续执行。
setjmp和longjmp 函数
int setjmp(jmp_buf env); //用于设置跳转的目的位置
setjmp的返回值:直接调用该函数,则返回0;
若由longjmp的调用,导致setjmp被调用,则返回val(longjmp的第二个参数)。
void longjmp(jmp_buf env, int val); //进行跳转。
参数:
env:保留了需要返回的位置的堆栈情况。
调用longjmp有一个问题。当捕捉到一个信号时,进入信号捕捉函数,此时当前信号被自动地加到进程的信号屏蔽字中。这阻止了后来产生的这种信号中断该信号处理程序。如果用longjmp跳出信号处理程序,那么,对此进程的信号屏蔽字会发生什么呢?
在FreeBSD 8.0和Mac OS X 10.6.8中,setjmp和longjmp保存和恢复信号屏蔽字。但是, Linux 3.2.0和Solaris 10并不执行这种操作,虽然Linux支持提供BSD行为的选项。FreeBSD 8.0和Mac OS X提供函数_setjmp和_longjmp,它们也不保存和恢复信号屏蔽字。
为了允许两种形式并存,POSIX.1并没有指定setjmp和longjmp对信号屏蔽字的作用,而是定义了两个新函数sigsetjmp和siglongjmp。在信号处理程序中进行非局部转移时应当使用这两个函数。
这两个函数和setjmp、longjmp 之间的唯一区别是sigsetjmp 增加了一个参数。如果savemask非0,则sigsetjmp在env中保存进程的当前信号屏蔽字。调用siglongjmp时,如果带非0 savemask的sigsetjmp调用已经保存了env,则siglongjmp从其中恢复保存的信号屏蔽字。
sigsetjmp和siglongjmp函数
#include <setjmp.h> int sigsetjmp(sigjmp_buf env, int savemask); //返回值:若直接调用则返回0,若从siglongjmp调用返回则返回非0值 void siglongjmp(sigjmp_buf env, int val);
参数:
env:保留了需要返回的位置的堆栈情况。
savemask:若为非0,则在env中保存进程的当前信号屏蔽字。(由siglongjmp恢复其中的保存的信号屏蔽字)
内部机理
在了解setjmp的内部机理前,需要先知道一点背景知识。
首先什么是执行环境,简单地说,就是CPU中的一些寄存器,这些寄存器保存了程序执行的必要信息,以x86为例:
- esp 保存当前栈顶的地址。
- ebp 保存当前函数栈帧的地址,在函数的进入点处,把esp保存到ebp,这样在函数任何位置,都可以通过ebp加偏移拿到函数的参数。
- eip 保存下一条指令的地址。
函数的调用约定,对寄存器也有影响,以x86的cdecl(这是C语言函数的调用约定)为例:
- 函数参数通过栈传递,顺序从右到左,并且由调用者负责清理栈中的参数。
- 整型值和内存地址通过eax返回。
- eax, ecx, edx由调用者负责保存,其余的由被调函数负责保存。
关于调用约定更详细的信息,你需要阅读维基百科x86 calling conventions,维基百科真是一个伟大的网站:)
最后再说一下汇编中call指令的含义,它被分解成两条语句:把下一条指令的地址压栈,然后跳到函数的入口地址;函数调用完之后,从栈中把指令地址恢复,这样就能正常的返回到函数调用的下一条指令处。
以上背景知识以x86为例,x64类似.
setjmp的实现
要实现执行环境的保存,只需要按上面的背景知识,把相关的寄存器保存到jmp_buf中,这就是setjmp要做的事情;然后在longjmp里,从jmp_buf恢复寄存器的值,恢复之后,执行点就回到setjmp返回的地方。
setjmp: 1 mov 4(%esp), %eax // eax = jmp_buf 2 mov %ebx, (%eax) // jmp_buf[0] = ebx 3 mov %esi, 4(%eax) // jmp_buf[1] = esi 4 mov %edi, 8(%eax) // jmp_buf[2] = edi 5 mov %ebp, 12(%eax) // jmp_buf[3] = ebp 6 lea 4(%esp), %ecx 7 mov %ecx, 16(%eax) // jmp_buf[4] = esp+4 8 mov (%esp), %ecx 9 mov %ecx, 20(%eax) // jmp_buf[5] = *esp 0 xor %eax, %eax // eax = 0 10 ret // return eax
我们需要时刻关注栈的信息,在进入setjmp之时,栈是这样的:
-----------------------high 参数:jmp_buf ----------------------- 调用者下一条指令的地址 <-- esp -----------------------low
第1行,将esp向上偏移4字节,取得jmp_buf,赋值给eax
第2行,保存ebx:jmp_buf[0] = ebx
第3行,保存esi:jmp_buf[1] = esi
第4行,保存edi:jmp_buf[2] = edi
第5行,保存ebp:jmp_buf[3] = ebp
第6, 7行,保存栈顶前一个地址:jmp_buf[4] = esp+4,也就是jmp_buf参数那一行的栈地址。
第8, 9行,保存栈顶的值:jmp_buf[5] = *esp
,也就是调用者函数下一条指令的地址。
第10, 11行,eax清零,函数返回,相当于返回0。
ret指令和call相反,从栈顶弹出调用者函数下一条指领的地址,然后跳过去,最后的栈信息是这样的:
-----------------------high jmp_buf参数 <-- esp -----------------------low
setjmp完成后,jmp_buf保存了这些信息:
[ebx, esi, edi, ebp, esp, eip]
longjmp的实现
longjmp: 1 mov 4(%esp),%edx // edx = jmp_buf 2 mov 8(%esp),%eax // eax = val 3 test %eax,%eax // val == 0? 4 jnz 1f 5 inc %eax // eax++ 6 1: 7 mov (%edx),%ebx // ebx = jmp_buf[0] 8 mov 4(%edx),%esi // esi = jmp_buf[1] 9 mov 8(%edx),%edi // edi = jmp_buf[2] 10 mov 12(%edx),%ebp // ebp = jmp_buf[3] 11 mov 16(%edx),%ecx // ecx = jmp_buf[4] 12 mov %ecx,%esp // esp = ecx 13 mov 20(%edx),%ecx // ecx = jmp_buf[5] 14 jmp *%ecx // eip = ecx
longjmp需要传递两个参数,所以调用longjmp时的栈信息是这样的:
-----------------------high 参数2:整型值 ----------------------- 参数1:jmp_buf ----------------------- 调用者下一条指令的地址 <-- esp -----------------------low
第1行,取得jmp_buf指针保存到edx
第2行,取得val保存到eax
第3,4,5行,判断val是否为0,如果是则要加1,因为setjmp返回0会走正常流程,这样就死循环了。
第7~10行,从jmp_buf恢复各个寄存器的值
第11,12行,从jmp_buf恢复esp的值,此时的esp就是在setjmp返回时的那个栈顶地址。
第13行,获得setjmp时,调用者函数下一条指令的地址。
第14行,跳到这个地址去。
在第14行执行后,执行流程就跳到setjmp的返回处,并且因为eax被设置成了val,所以返回值也变成val。
代码示例
#include <stdio.h> #include <stdlib.h> #include <pthread.h> #include <setjmp.h> #include <signal.h> // 定义线程数据结构 typedef struct { jmp_buf env; // 用于存储堆栈信息的jmp_buf int running; // 标记线程是否正在运行 } thread_t; // 线程函数 void *thread_func(void *arg) { thread_t *thread = (thread_t *) arg; int val = setjmp(thread->env); // 保存堆栈信息 if (val == 0) { thread->running = 1; // 标记线程正在运行 while (1) { // 线程运行的代码 } } else { thread->running = 0; // 标记线程已经停止运行 } pthread_exit(NULL); } // 信号处理函数 void signal_handler(int sig) { // 在信号处理函数中找到正在运行的线程 thread_t *thread; pthread_t self = pthread_self(); for (int i = 0; i < NUM_THREADS; i++) { if (pthread_equal(self, threads[i].thread_id)) { thread = &threads[i]; break; } } if (thread->running) { longjmp(thread->env, 1); // 恢复堆栈信息,继续线程的执行 } } // 主函数 int main() { // 注册信号处理函数 signal(SIGINT, signal_handler); // 创建多个线程 const int NUM_THREADS = 4; thread_t threads[NUM_THREADS]; for (int i = 0; i < NUM_THREADS; i++) { if (pthread_create(&threads[i].thread_id, NULL, thread_func, &threads[i]) != 0) { perror("pthread_create"); exit(EXIT_FAILURE); } } // 等待所有线程结束 for (int i = 0; i < NUM_THREADS; i++) { pthread_join(threads[i].thread_id, NULL); } return 0; }
在上面的示例代码中,我们定义了一个thread_t结构体,其中包含一个jmp_buf变量,用于保存线程的堆栈信息,以及一个running变量,用于标记线程是否正在运行。
在thread_func函数中,我们先调用setjmp函数来保存当前线程的堆栈信息。然后,如果返回值为0,表示这是线程第一次运行,我们将running变量设为1,然后进入一个无限循环,执行线程的代码。如果返回值不为0,表示线程是被longjmp函数唤醒的,我们将running变量设为0,表示线程已经停止运行。
在signal_handler函数中,我们首先找到正在运行的线程,然后检查它的running变量是否为1,如果是,就调用longjmp函数来恢复线程的堆栈信息,继续线程的执行。
在主函数中,我们首先注册了一个信号处理函数signal_handler,然后创建了多个线程,并等待所有线程结束。
需要注意的是,由于setjmp和longjmp函数只保存了堆栈信息,而没有保存线程的上下文信息,因此在使用这些函数时,必须保证线程的堆栈和寄存器状态是一致的。在上面的示例代码中,我们在thread_func函数中使用了一个无限循环来保持线程的状态,以确保在使用longjmp函数时,线程的寄存器状态和堆栈状态是一致的。
另外,由于在多线程环境下使用setjmp和longjmp函数可能会导致不可预测的结果,因此必须非常小心地使用这些函数,并确保在信号处理函数中只执行最基本的操作。
结语
在我们的编程学习之旅中,理解是我们迈向更高层次的重要一步。然而,掌握新技能、新理念,始终需要时间和坚持。从心理学的角度看,学习往往伴随着不断的试错和调整,这就像是我们的大脑在逐渐优化其解决问题的“算法”。
这就是为什么当我们遇到错误,我们应该将其视为学习和进步的机会,而不仅仅是困扰。通过理解和解决这些问题,我们不仅可以修复当前的代码,更可以提升我们的编程能力,防止在未来的项目中犯相同的错误。
我鼓励大家积极参与进来,不断提升自己的编程技术。无论你是初学者还是有经验的开发者,我希望我的博客能对你的学习之路有所帮助。如果你觉得这篇文章有用,不妨点击收藏,或者留下你的评论分享你的见解和经验,也欢迎你对我博客的内容提出建议和问题。每一次的点赞、评论、分享和关注都是对我的最大支持,也是对我持续分享和创作的动力。