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

结语

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

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

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

目录
相关文章
|
1月前
|
C语言 C++
C语言 之 内存函数
C语言 之 内存函数
34 3
|
6天前
|
C语言
c语言调用的函数的声明
被调用的函数的声明: 一个函数调用另一个函数需具备的条件: 首先被调用的函数必须是已经存在的函数,即头文件中存在或已经定义过; 如果使用库函数,一般应该在本文件开头用#include命令将调用有关库函数时在所需要用到的信息“包含”到本文件中。.h文件是头文件所用的后缀。 如果使用用户自己定义的函数,而且该函数与使用它的函数在同一个文件中,一般还应该在主调函数中对被调用的函数做声明。 如果被调用的函数定义出现在主调函数之前可以不必声明。 如果已在所有函数定义之前,在函数的外部已做了函数声明,则在各个主调函数中不必多所调用的函数在做声明
22 6
|
26天前
|
存储 缓存 C语言
【c语言】简单的算术操作符、输入输出函数
本文介绍了C语言中的算术操作符、赋值操作符、单目操作符以及输入输出函数 `printf` 和 `scanf` 的基本用法。算术操作符包括加、减、乘、除和求余,其中除法和求余运算有特殊规则。赋值操作符用于给变量赋值,并支持复合赋值。单目操作符包括自增自减、正负号和强制类型转换。输入输出函数 `printf` 和 `scanf` 用于格式化输入和输出,支持多种占位符和格式控制。通过示例代码详细解释了这些操作符和函数的使用方法。
34 10
|
20天前
|
存储 算法 程序员
C语言:库函数
C语言的库函数是预定义的函数,用于执行常见的编程任务,如输入输出、字符串处理、数学运算等。使用库函数可以简化编程工作,提高开发效率。C标准库提供了丰富的函数,满足各种需求。
|
25天前
|
机器学习/深度学习 C语言
【c语言】一篇文章搞懂函数递归
本文详细介绍了函数递归的概念、思想及其限制条件,并通过求阶乘、打印整数每一位和求斐波那契数等实例,展示了递归的应用。递归的核心在于将大问题分解为小问题,但需注意递归可能导致效率低下和栈溢出的问题。文章最后总结了递归的优缺点,提醒读者在实际编程中合理使用递归。
53 7
|
25天前
|
存储 编译器 程序员
【c语言】函数
本文介绍了C语言中函数的基本概念,包括库函数和自定义函数的定义、使用及示例。库函数如`printf`和`scanf`,通过包含相应的头文件即可使用。自定义函数需指定返回类型、函数名、形式参数等。文中还探讨了函数的调用、形参与实参的区别、return语句的用法、函数嵌套调用、链式访问以及static关键字对变量和函数的影响,强调了static如何改变变量的生命周期和作用域,以及函数的可见性。
29 4
|
27天前
|
程序员 C++ 容器
在 C++中,realloc 函数返回 NULL 时,需要手动释放原来的内存吗?
在 C++ 中,当 realloc 函数返回 NULL 时,表示内存重新分配失败,但原内存块仍然有效,因此需要手动释放原来的内存,以避免内存泄漏。
|
30天前
|
存储 编译器 C语言
C语言函数的定义与函数的声明的区别
C语言中,函数的定义包含函数的实现,即具体执行的代码块;而函数的声明仅描述函数的名称、返回类型和参数列表,用于告知编译器函数的存在,但不包含实现细节。声明通常放在头文件中,定义则在源文件中。
|
1月前
|
算法 C++
2022年第十三届蓝桥杯大赛C/C++语言B组省赛题解
2022年第十三届蓝桥杯大赛C/C++语言B组省赛题解
34 5
|
22天前
|
存储 C语言
【c语言】字符串函数和内存函数
本文介绍了C语言中常用的字符串函数和内存函数,包括`strlen`、`strcpy`、`strcat`、`strcmp`、`strstr`、`strncpy`、`strncat`、`strncmp`、`strtok`、`memcpy`、`memmove`和`memset`等函数的使用方法及模拟实现。文章详细讲解了每个函数的功能、参数、返回值,并提供了具体的代码示例,帮助读者更好地理解和掌握这些函数的应用。
19 0