“setjmp、longjmp是函数吗?为什么使用起来跟其他函数有那么大的差异呢?这么奇怪的函数,它存在的意义是什么呢?”
01
提出问题
setjmp 和 longjmp,可能是C/C++语法中,最让人沮丧、望而却步的API函数。为什么有人说它们是大神行走江湖的必备技能?为什么也有人说:掌握了它们,就掌握了一半操作系统的秘密?
网上从来不缺对它们的解释,但无论如何解释,它们跟普通函数,还是有很大的不同,几乎所有的函数概念,在它们身上都是失效的。
例如setjmp作为一个函数,虽然从代码上看,它只被调用了一次,但却可以返回两次,而且每次的返回值还可以不同。
02
认识 setjmp 和 longjmp
首先简单介绍一下setjmp和longjmp这对API。打开Compiler Explorer,让我们写一个简单的main函数;然后去调用函数func1,最后再用函数func1调用函数func2;为了便于展示这个调用过程,我们分别在函数的开头和返回阶段;打印了一些信息,展示函数的运行过程。代码和运行结果,如图所示。
如我们所料,函数是逐层的调用,直到函数func2;然后,再逐层的返回,直到main函数。
好了,现在轮到setjmp和longjmp登场了,我们先在main函数中,通过setjmp函数设置一个跳转点;并为setjmp传递一个参数context,用来保存上下文。这里我们先不解释,只是无脑的按照API手册操作就好。
根据API的解释,setjmp第一次返回的时候,它的返回值是0;也就是正常流程,此时我们就正常的调用函数func1就好;为了便于观察setjmp的返回值,我们把value作为整个进程的返回值。如图所示。
很遗憾,输出没有任何变化!由于此时仅仅设置了跳转点,但并没有作跳转。所以,从输出结果上看,函数仍然是逐层调用,再逐层返回的。返回值value的值也是0,这看上去,跟如图所示的普通的函数调用,没有任何区别。
随后,我们在函数func2中,做一次longjmp。并规定了这次setjmp的返回值是第二个参数:100,如图所示。
发现有趣的事情了吗?相比刚才如图上所示的函数调用,longjmp直接跨越了常规的函数返回流程。没有依次从函数func2返回到函数func1,再返回到main函数;而是,直接隔空跳转到main函数,甚至setjmp的返回值也变成了我们刚刚设置的100。
为什么会出现如此反常的现象呢?如果试图用函数的概念去解释setjmp和longjmp的行为。一定会掉入一个死胡同,因为它们根本就不是一个普通意义的函数,甚至,它们根本不能用C/C++语言实现!还好C/C++语言的教程很少提及它,否则可能又是一场激烈、且没有结果的讨论。
03
实现自己的 setjmp 和 longjmp
好了,与其让阿布去瞎编背后的实现原理,抛出一些是似而非的概念。不如我们亲自实现一个setjmp和longjmp来的大快人心。
实现的方法也很简单,我们直接借用CPU眼里的:上下文 | Context中保存、恢复任务线程(任务)上下文的办法,通过自己的my_setjmp函数把当前线程(任务)的上下文,全部保存在数组context里面。
这样在未来发生my_longjmp的时候,恢复一下上下文,就可以让函数func2直接跳回到my_setjmp需要返回的地方,从而实现my_setjmp的第二次返回。
说干就干,先定义一个my_setjmp函数,参数context是一个数组,用来保存上下文,也就是CPU寄存器,不过这不是C/C++这种高级语言可以做到的。所以,我们使用内嵌的汇编语言来实现这个想法。
long context[3];
__attribute__((naked,returns_twice))
int my_setjmp(void* context)
{
asm("mov %%rbp, (%%rdi);"
"mov %%rsp, 8(%%rdi);"
"mov (%%rsp), %%rax;"
"mov %%rax, 16(%%rdi);"
"mov $0, %%rax;"
"ret;"
:::);
}
如上面的代码所示,首先把CPU中堆栈相关的寄存器rbp、rsp保存在数组context的第一和第二个数组元素里面;随后,为了让未来的my_longjmp,正确的跳转到my_setjmp的返回地址,我们需要把返回地址也保存一下。
如“CPU眼里的:函数调用”所说,此时的函数返回地址就保存在堆栈的栈顶处(其内存地址,就是rsp的值)所以,我们直接把栈顶的内容,也就是返回地址,保存在数组context的第三个元素里面就好。注意,由于指令集的限制,我们不得不通过寄存器rax中转一下。
如“CPU眼里的:参数传递”所说,寄存器rdi保存着context的内存首地址0x404020,分别经过0、8、16的偏移,正好对应着context的3个数组元素。如图所示。
简单起见,我们就不保存所有的CPU寄存器了,但完整的方案,是需要保存全部的CPU通用寄存器的。
最后,别忘了my_setjmp第一次的返回值是0。如“CPU眼里的返回值”所说,简单情况下CPU寄存器rax,用来保存函数的返回值。所以,我们把返回值0,写入到寄存器rax里面即可。一切就绪,现在就可以通过ret指令返回了。
由于,我们已经处理好了一切,为了避免编译器的画蛇添足,我们还需要添加一行编译选项:
__attribute__((naked,returns_twice))
至于my_longjmp,就是一个反向操作了,也就是恢复或重新设置当前线程(任务)的上下文:
__attribute__((naked,noreturn))
void my_longjmp(void* context, int value)
{
asm("mov (%%rdi), %%rbp;"
"mov 8(%%rdi), %%rsp;"
"mov %%rsi, %%rax;"
"jmp 16(%%rdi);"
:::);
}
如上面的代码所示,通过类似的方法,我们将保存在context数组中的CPU寄存器值,如数的写回到相应的CPU寄存器里面,如图所示。
随后,把保存在寄存器rsi里面的返回值value,写入到寄存器rax用来做返回值。
最后,就是最关键的跳转了,由于my_setjmp的返回地址存放在context的第三个数组元素里面,所以,直接jmp过去就好了。
如你所见,所谓的第二次返回,并不是传统意义上的函数返回,而是一次跟函数堆栈无关的CPU跳转操作。
同样,为了防止编译器画蛇添足,我们也要加上一行编译选项:
__attribute__((naked,noreturn))
它们对应的C语言是这样的,有兴趣的同学,可以细品一下:
void my_setjmp(long* context)
{
context[0] = rbp;
context[1] = rsp;
context[2] = return address;
return 0;
}
void my_setjmp(long* context, int value)
{
rbp = context[0];
rsp = context[1];
rax = value;
goto context[2];
}
好了,看看运行结果,如图所示,运行结果与官方的setjmp、longjmp完全一致!
04
运行自己的 setjmp 和 longjmp
如果这些代码,已经让人有些迷失方向的话,也没有关系。让我们化身成最慢的CPU,实际的跑一下整个过程。
方便起见,我们去掉代码中与核心无关的打印代码,调整一下代码的布局,一切从main函数中,调用my_setjmp函数的地方开始,如图所示。
先把参数context的内存首地址0x404020,存入参数寄存器edi(rdi的低32位),如“CPU眼里的:函数调用”所说,call指令会先把返回地址0x401161压入堆栈。
随后,跳转到函数my_setjmp里面,如图所示。
前两条指令,根据寄存器rdi的引导,分别把CPU寄存器rbp,rsp的值保存在context[0], contex[1]里面;
随后的两条指令,把栈顶处的函数返回地址0x401161,放在contex[2]里面,由于指令集的关系,需要通过寄存器rax中转一下;最后把返回值0,存入在寄存器rax里面。
最后的ret指令,会取出当前栈顶的0x401161,并以此引导CPU跳转到main函数中继续运行(函数返回的细节,也可以参看“CPU眼里的函数调用”如图所示。
忽略数据在寄存器和堆栈中的置换细节,由于此时eax寄存器的值是0,所以,显然会执行函数func1的调用,call指令还是会先把返回地址0x401179压入堆栈。
随后,跳转到函数func1里面,如图所示。
函数func1的前两条指令还是常规的栈帧保护工作,具体细节可以参看“CPU眼里的:函数括号”,略过随后无意义的mov eax, 0指令,就可以进行函数func2的调用了,call指令还是会先把返回地址0x40114c压入堆栈。
随后,跳转到函数func2里面,如图所示。
前两条指令,还是常规的栈帧保护工作。这里,我们不用在意栈帧保护的细节,和未来恢复栈帧的繁琐细节。因为,my_longjmp的大戏,就要开始上演了!
第三、四条指令是传递参数,分别把context数组的首地址0x404020和返回值100,传递给寄存器esi(rsi的低4位)和edi(rdi的低4位)。
万事具备,就可以执行call指令了。call指令,会先把返回地址0x401143压入堆栈,随后,跳转到函数my_longjmp里面,如图所示。
前两条指令,如数恢复寄存器rbp,rsp的值,然后通过参数寄存器rsi,把寄存器rax的值设置成:100。
此时,除了用于返回值的寄存器rax值(100)不同于此前的0外,所有的寄存器信息,跟我们第一调用my_setjmp时,所保存的信息,完全相同!
最后的jmp指令,从context[2]中获取上次my_setjmp的返回地址,从而,指引CPU第二次回到main函数中继续运行,如图所示。
不过这次的返回值,也就是寄存器eax的值不再是0,而是100。这就是为什么my_setjmp会返回两次,而且每次返回值都不相同的原因。
05
总结
- 简单的说,setjmp和longjmp是一个增强版的goto。只是goto只能在函数里面,自由跳转;但setjmp和longjmp,则可以实现函数之间反常规的跳转。
- 跟goto一样,由于这种大范围的跳转,可能跳过一些必要的代码,例如:资源回收、释放内存等。所以,longjmp可以跳过常规函数之间的返回流程,从而带来巨大的效率提升,但也可能增加代码的维护难度和可读性。
- setjmp和longjmp已经非常接近操作系统的“上下文”保护。如果加以利用,完全可以在一个单线程中,实现任务切换,也就是所谓的:纤程、协程。
最后,阿布认为:似乎除了用CPU的视角,配合代码,实际跑一下setjmp和longjmp。否则使用任何概念或者类比的方式,解释这个问题,都是在把简单问题,复杂化。
06
热点问题
Q1:不用汇编语言,只用纯C语言,可以实现setjmp和longjmp吗?
A1:由于setjmp和longjmp的实现过程,需要精确的读、写CPU寄存器,所以首选汇编语言来实现setjmp和longjmp。
C语言编译器从底层接管了CPU寄存器的各种操作,这样就不需要程序员关注太多CPU的运行细节,可以专心关注程序的算法和运算逻辑,从而提升编程效率。
但也让程序员失去了对CPU指令和运行细节的精确控制,因此与CPU底层相关的功能,往往由汇编语言实现的,例如:setjmp、longjmp、任务切换、原子操作等。
07
更多知识
基础不牢,地动山摇!如果喜欢阿布这种解读方式,希望更加系统学习这些编程知识的话,也可以考虑看看由阿布亲自编写,并由多位微软大佬联袂推荐的新书《CPU眼里的C/C++》
免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://itzsg.com/72493.html