学习系统编程的过程中遇到了两个函数,setjmp和longjmp。这两个函数比较神奇所以想要专门写一篇博客来对这两个函数进行详细的解析,最好可以理解底层的实现。
两个函数的初步解析
这时man手册中对两个函数的定义。然后我们先初步了解一下这两个函数。setjmp.h库中定义了一个新的数据结构jmp_buf。该结构体专门用来存储运行上下文的。什么是运行上下文?我们这样简单的理解,当我们的程序在执行的过程中,寄存器中存放了很多的数据。包括RBP存放的栈底、RSP存放的栈顶、还有通用寄存器中存放的数据和RIP中存放了下一条要执行的指令等等。这些也就是所谓的运行上下文。再说的直接一点,我们可以看一下setjmp的参数是env,也就是环境。其实就是用来存储程序运行到那里时整个计算机的环境。
setjmp函数的作用就是用来存储该环境,而longjmp则是恢复该环境。回到编程的角度来看,setjmp就相当于放置一个标签;longjmp函数的作用则是跳转到标签所在的那一条语句。我在网上找了一段代码
int main()
{
// 一个缓冲区,用来暂存环境变量
jmp_buf buf;
printf("line1 \n");
// 保存此刻的上下文信息
int ret = setjmp(buf);
printf("ret = %d \n", ret);
// 检查返回值类型
if (0 == ret)
{
// 返回值0:说明是正常的函数调用返回
printf("line2 \n");
// 主动跳转到 setjmp 那条语句处
longjmp(buf, 1);
}
else
{
// 返回值非0:说明是从远程跳转过来的
printf("line3 \n");
}
printf("line4 \n");
return 0;
}
然后我们来运行一下看看。
注意两个函数的返回值,setjmp函数返回值是int类型,而longjmp函数没有返回值。事实上当我们第一次使用setjmp函数的时候(也就是设置标签的时候)返回值为0;longjmp函数的第二个参数就是用来设置第二次回到setjmp函数时的返回值。因此根据返回值我们可以知道setjmp是我们刚开始设置的还是通过longjmp跳转过来的。
和goto语句进行比较
如果大家回想的话,会发现这不就是goto语句吗?但其实这两个语句有很大的差别。goto是不能在函数的层面进行跳转的,而这两个函数却可以
#include <stdio.h>
void A(){
int a = 10;
printf("%d\n",a);
goto label;
}
void B(){
label:
int a = 20;
printf("%d\n",a);
}
void main(){
A();
}
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
#include <setjmp.h>
jmp_buf env;
void A(){
int a = 10;
printf("%d\n",a);
longjmp(env,1);
}
void B(){
int ret = setjmp(env);
int a = 20;
printf("%d\n",a);
if(ret == 0){
printf("this is the first jmp\n");
}
else if(ret == 1){
printf("this is not the first jmp\n");
}
}
int main(){
B();
A();
}
同样都是跨函数的跳转,一个正常运行另一个报错了。那么这是为什么呢?我将会从语言的特性上进行讲解。
在C语言中,作用域和作用域之间的关系都是平级的。即便我们一个函数嵌套另外一个函数它们之间作用域的关系也是平级。从汇编的角度讲,C语言如何判定运行哪一个函数的语句?就是通过EBP和ESP。我们很容易的可以发现每一次对于变量的定义,都是通过ebp对变量进行定位。那么如果goto可以执行函数之间的跳转,本来运行在函数A中,根据EBP定位到的变量a。跳转以后B也定义了一个a,但是定位的时候还是根据A的ebp进行的定位。所以说goto在函数间进行跳转是不行的。简单来说,C语言进行函数调用其实根本不知道调用的是哪一个函数,只管在函数中用EBP定位变量。
然后我们来看一下goto的底层如何运行
#include <stdio.h>
void choose(){
int a;
scanf("%d",&a);
if(a == 1)
goto label1;
else if(a==2)
goto label2;
else if(a==3)
goto label3;
else if(a==4)
goto label4;
else
goto label5;
label1:
printf("you jmp the label1\n");
label2:
printf("you jmp the label2\n");
label3:
printf("you jmp the label3\n");
label4:
printf("you jmp the label4\n");
label5:
printf("you jmp the label5\n");
}
void main(){
choose();
}
然后我们进行一波调试
我输入了3
然后我们继续进行调试
也就是说goto其实就是单纯的跳转,没有对函数的上下文进行任何操作
再看上面这个图,就是我们定义函数的时候对于函数的定位,可想而知如果goto如果跳转函数将会造成什么样的后果。
但是在有些语言中goto就可以进行函数间的跳转,比方说pascal语言。该类型的语言存在作用域的嵌套。也就是说程序在编译的时候就可以事先判断作用域。
setjmp和longjmp底层的实现
我们回来看两个函数的汇编实现。首先我们调试一下setjmp函数
rdi就是我们的参数放置的地方,然后将寄存器的值放到env里面。那么我们再看longjmp的汇编是怎么样的
尤其要注意这里
这个是函数调用的核心。现在大家应该知道为什么setjmp为什么可以跨越函数进行跳转。这里就是原因。把环境上下文进行保存,然后跳转的时候再把环境上下文恢复。
甚至我们可以这样想,我们从家到学校可以通过两种方式:
一种是我们自己走路到学校
另外一种是我们不动,而是让学校主动到我们的面前。而根据参考系,我们走向学校本身就是学校靠近我们。
而goto其实就像是我们走到学校,而这两个函数就像是把学校周边的环境保存下来,然后直接放到我们身边。
总结:这两个函数的跳转过于强大,那么我们就和goto一样不推荐常用。但是对于程序设计来说要在安全性和运行速度之间找一个合理的平衡点。甚至我们可以自己利用这两个函数来实现异常捕获机制