转载:http://tieba.baidu.com/p/1393753521
灌水的时候从goto一路拐到了setjmp, 顺便也试了试貌似这东西确实是没有析构效果的。之前并没有看过setjmp的实现,也有一些错误的理解,于是实现了一个简单的版本澄清一下。其实setjmp让人感兴趣的地方大概也就是它和fork在使用方式上的相似之处(对一条c语言看来的调用语句,检查了两次返回值),不过如果稍加考据的话,会发现其实我们可以使用setjmp和longjmp来实现简单的协程——不过比起setjmp和longjmp这种东西,现有的协程库是更好的选择。
很容易写出一个使用setjmp-longjmp的例子:
#include <iostream>
#include <setjmp.h>
static jmp_buf buf;
class test_class {
private:
int val;
public:
test_class(int v = 0) : val(v) {
std::cout << "constructor " << val << std::endl;
}
~test_class() {
std::cout << "destructor" << val << std::endl;
}
};
void rtn(void) {
test_class t(5);
longjmp(buf, 1);
}
int main(void) {
if (!setjmp(buf)) {
rtn();
} else {
std::cout << "main" << std::endl;
while (1);
}
return 0;
}
编译之后运行的情况是:
varna@iflit:~/backup/essay$ ~/workspace/langtest/cc/testjmp
constructor 5
main
^C
varna@iflit:~/backup/essay$
这里我们注意到if语句的两个分支都得到了执行,看起来像是初次调用setjmp的时候返回了0,而rtn调用longjmp时setjmp又返回了longjmp指定的1。读者可能会感到奇怪,毕竟程序是逐条语句执行的,longjmp是否真的调用了setjmp也没法确定。总之,setjmp像我们熟悉的fork一样返回了两个值,当然与fork的差别在于fork在同一进程之内只有一种返回值,无论是0,一个pid或是错误。
那么下面我们来实现一个简单的setjmp-longjmp. 这里的实现是体系相关的,下面的例子都假设是IA-32;出于方便起见也使用了GCC内联汇编而不是独立的汇编单元,并没有考虑到线程安全的因素因而不要真的拿来做协程。同时我也不保证c标准库的实现是否是这样,这只是一个〔来自钟表外侧的猜测〕。
首先我们假设我们在使用cdecl调用约定。实际上在涉及栈桢废弃和构造的废止性返回当中,最好明确调用约定以确保在被调函数的栈桢上(这个说法并不严格,实际上我们会向调用者的栈桢顶方向稍微走几步)能取得调用者的栈桢范围以及被调函数的返回地址。cdecl保证了由调用者完成栈桢的清理(因而很容易实现不定长参数列表),所以我们在返回的时候只需要恢复调用者的栈桢并跳转到返回地址就可以。
为了获得调用者的栈桢范围,考虑函数对应的汇编代码:
push ebp
mov ebp, esp
...调整esp给出参数、局部变量和临时变量的空间
...函数体
mov esp, ebp
pop ebp
ret
这里我们能看出,调用者的栈桢底被压到栈上,如果读者对call指令有了解的话,会意识到次栈顶此时是call压入的返回地址。之后把ebp置为运行时栈的栈顶esp, 同时也是被调函数的栈桢底。当函数决定返回的时候,从ebp中恢复出曾经的esp, 弹出栈顶以恢复调用者的栈桢底,执行ret指令将弹出目前的栈顶,也就是被调函数的返回地址。这时,esp已经恢复成了调用者真实的栈顶。
如果要简单地图解一下,在被调函数完成esp调整之后的时刻,栈上的状况是
(高址)
(调用者的栈桢)
返回地址(四字节)
被调函数的栈桢底(四字节),此处的地址也是当前ebp的地址
(被调函数的栈桢,此处位于最低地址的有效数据被esp所指向)
这样,当我们拿到ebp之后,0x4(ebp)的位置就是返回地址,而ebp的值加0x8就是调用者的栈桢顶。同时我们知道,cdecl调用使用eax来传递返回值,因此我们可以给出下面的结构用于辅助完成废止性跳转:
struct c_jmpbuf {
unsigned long esp;
unsigned long ebp;
unsigned long eip;
unsigned long eax;
};
这是完成跳转所需要的最少数量的硬件上下文。接着我们可以根据前面的描述实现setjmp, 完成在某一点上对上下文结构的填写:
int c_setjmp(struct c_jmpbuf *buf) {
buf->eax = 0;
asm volatile ("mov %%ebp, %0" : "=r" (buf->ebp));
asm volatile ("mov %%esp, %0" : "=r" (buf->esp));
asm volatile ("mov 0x4(%%ebp), %0" : "=r" (buf->eip));
buf->esp = buf->ebp + 0x8;
buf->ebp = *((unsigned long *) buf->ebp);
return 0;
}
首先获得ebp和esp(这里的实现并没有真的用到esp),之后获得0x4(ebp)处的返回地址填入断点eip. 之后调整出调用者栈桢需要的esp和ebp填入上下文,注意如果懒得用局部变量的话就必须先调整esp, 因为它的值直接依赖于当前栈桢使用的ebp值。
最麻烦的工作已经完成了,下面我们可以轻易实现跳转本身:
int c_longjmp(struct c_jmpbuf *buf, int retval) {
asm volatile ("mov %0, %%eax" : : "r" (retval));
asm volatile ("mov %0, %%ecx" : : "d" (buf->eip));
asm volatile ("mov %0, %%esp" : : "d" (buf->esp));
asm volatile ("mov %0, %%ebp" : : "d" (buf->ebp));
asm volatile ("jmp *%ecx");
}
我们只需要根据上下文恢复四个寄存器:作为返回值的eax, 作为返回地址的eip, 标识栈桢范围的ebp和esp. 之后强行跳转到eip给出的返回地址就可以了。但是这段代码本身只是个简单例子,读者会注意到eip/esp/ebp的恢复过程中都指定了edx作为中间寄存器以避开ebx, 但esi和edi并没有恢复。我并不确定这样的实现在绝大部分情况下都能正常工作,所以如果真的要实现最好使用独立的汇编单元来做,当然可以考虑约定fastcall或者扩展regparm, 不然在上下文突变区里清理栈桢绝对不是什么容易测试的工作。
两小段程序,完成了一套基本的废止性跳转操作。下面我们编写一个样例来测试看:
#include <stdio.h>
#include <stdlib.h>
struct c_jmpbuf {
unsigned long esp;
unsigned long ebp;
unsigned long eip;
unsigned long eax;
};
int c_setjmp(struct c_jmpbuf *buf) {
buf->eax = 0;
asm volatile ("mov %%ebp, %0" : "=r" (buf->ebp));
asm volatile ("mov %%esp, %0" : "=r" (buf->esp));
asm volatile ("mov 0x4(%%ebp), %0" : "=r" (buf->eip));
buf->esp = buf->ebp + 0x8;
buf->ebp = *((unsigned long *) buf->ebp);
return 0;
}
int c_longjmp(struct c_jmpbuf *buf, int retval) {
asm volatile ("mov %0, %%eax" : : "r" (retval));
asm volatile ("mov %0, %%ecx" : : "d" (buf->eip));
asm volatile ("mov %0, %%esp" : : "d" (buf->esp));
asm volatile ("mov %0, %%ebp" : : "d" (buf->ebp));
asm volatile ("jmp *%ecx");
}
struct c_jmpbuf buf, buf1, buf2;
void rtn() {
printf("world\n");
c_longjmp(&buf2, 1);
}
void rtn2() {
if (c_setjmp(&buf2) == 0) {
printf("hello rtn2\n");
rtn();
} else {
printf("again rtn2\n");
c_longjmp(&buf, 1);
}
}
void rtn1() {
if (c_setjmp(&buf1) == 0) {
printf("hello rtn1\n");
rtn2();
} else {
printf("again rtn1\n");
c_longjmp(&buf, 1);
}
}
int main(void) {
unsigned long ret;
asm volatile ("mov %%ebp, %0" : "=r" (ret));
printf("main ebp = %x\n", ret);
if (c_setjmp(&buf) == 0) {
printf("hello main\n");
rtn1();
} else {
printf("again main\n");
}
return 0;
}
下面是运行结果。注意到这里rtn2的废止性返回越过了rtn1返回main. 读者可以修改样例进行测试,如果有失败的情况欢迎提出。
varna@iflit:~/backup/essay$ ~/workspace/langtest/spec/sjmp
main ebp = bfe7c408
hello main
hello rtn1
hello rtn2
world
again rtn2
again main
varna@iflit:~/backup/essay$
最后是一点编写和调试方面的建议。如果读者并不习惯操作程序的运行时结构,可以练习使用一种反汇编工具和一种调试工具,逐步改正自己的程序。反汇编工具可以用来确定实际的返回地址,而调试器则可以显示出各寄存器和各变量的值,从而可以逐个发现并修正错误的地址。