【C/C++ 跳转函数】setjmp 和 longjmp 函数的巧妙运用: C 语言错误处理实践

简介: 【C/C++ 跳转函数】setjmp 和 longjmp 函数的巧妙运用: C 语言错误处理实践

概述

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函数可能会导致不可预测的结果,因此必须非常小心地使用这些函数,并确保在信号处理函数中只执行最基本的操作。

结语

在我们的编程学习之旅中,理解是我们迈向更高层次的重要一步。然而,掌握新技能、新理念,始终需要时间和坚持。从心理学的角度看,学习往往伴随着不断的试错和调整,这就像是我们的大脑在逐渐优化其解决问题的“算法”。

这就是为什么当我们遇到错误,我们应该将其视为学习和进步的机会,而不仅仅是困扰。通过理解和解决这些问题,我们不仅可以修复当前的代码,更可以提升我们的编程能力,防止在未来的项目中犯相同的错误。

我鼓励大家积极参与进来,不断提升自己的编程技术。无论你是初学者还是有经验的开发者,我希望我的博客能对你的学习之路有所帮助。如果你觉得这篇文章有用,不妨点击收藏,或者留下你的评论分享你的见解和经验,也欢迎你对我博客的内容提出建议和问题。每一次的点赞、评论、分享和关注都是对我的最大支持,也是对我持续分享和创作的动力。

目录
相关文章
|
17天前
|
程序员 C语言
C语言库函数 — 内存函数(含模拟实现内存函数)
C语言库函数 — 内存函数(含模拟实现内存函数)
28 0
|
16天前
|
C语言 C++ 数据格式
【C++对于C语言的扩充】C++与C语言的联系,命名空间、C++中的输入输出以及缺省参数
【C++对于C语言的扩充】C++与C语言的联系,命名空间、C++中的输入输出以及缺省参数
|
1天前
|
C语言
【C语言】字符分类函数与字符转换函数
【C语言】字符分类函数与字符转换函数
7 1
|
1天前
|
程序员 编译器 C语言
C语言之函数与参数
C语言之函数与参数
5 0
|
2天前
|
机器学习/深度学习 开发框架 人工智能
探索C++的深邃世界:编程语言的魅力与实践
探索C++的深邃世界:编程语言的魅力与实践
|
2天前
|
C语言
C语言:内存函数(memcpy memmove memset memcmp使用)
C语言:内存函数(memcpy memmove memset memcmp使用)
|
2天前
|
C语言
C语言:字符函数和字符串函数(strlen strcat strcmp strncmp等函数和模拟实现)
C语言:字符函数和字符串函数(strlen strcat strcmp strncmp等函数和模拟实现)
|
4天前
|
存储 C语言
C语言函数的返回值
C语言函数的返回值
7 0
|
4天前
|
C语言 Windows
C语言中的fopen与fclose函数详解
C语言中的fopen与fclose函数详解
11 1
|
4天前
|
C语言
深入理解C语言中的printf函数及数据输出
深入理解C语言中的printf函数及数据输出
13 0