谈一谈setjmp和longjmp

八月即将过去,又快开学了,小小感慨下。

本月,最值得欣慰的就是XETN服务器能够用ab测试了,单进程下RPS差不多是nodejs的4倍,当然离预期依旧很遥远,也就只能欺负欺负node了。

开发的时候异步IO带来的EAGAIN甚是讨厌,而攻克这个难题的关键就是协程,之前写过关于Fiber的文章,只不过它并不是那么灵活,出于对内部机制的好奇,我准备再造一个轮子,自己实现协程。

本文先介绍setjmp和longjmp,它是实现协程的核心。不过通常情况下,大家只是用它来实现C的异常机制。

0x00 什么是jmp

想必各位一定都知道goto,也被灌输过诸如“goto是邪恶的”之类的偏见。setjmp和longjmp是加强版的goto,能够做到跨函数,由于涉及到栈的问题,它远比goto更加“邪恶”。

这里的“邪恶”是有原因的,如果你了解细节,那么你便更进一步;如果不了解,那么可能今后也只能做些告诫后生goto很“邪恶”的事,对此深信不疑,并觉得自豪。

那么,为何goto只是一个关键字,而setjmp和longjmp却成了两个函数?别急,这便是本文的内容。。。

0x01 使用方式

先是函数原型:

int setjmp(jmp_buf env);
void longjmp(jmp_buf env, int val);

由于寄存器的存在,在跳转之前,我们需要先将当前跳转点的寄存器内存保存下来,它们的存储位置就是jmp_buf。至少PC的内容总得保存吧,各位说是不?不过由于并不能直接操作PC寄存器,因此这里使用setjmp和longjmp来解决这个问题,思想和动态链接时get_pc_thunk一样。

由于jmp_buf保存的就是跳转点的寄存器内容,因此我们当前可以简单地认为它就表示我们希望跳转的目的地。

在默认情况下,setjmp返回0,表示初始jmp_buf,当使用longjmp跳转的时候,setjmp返回的就是val参数的内容,因此不要蠢蠢地将val设置成0。写到这里不知各位是不是有点好奇这个是怎么做到的?为何返回的内容和val有关?依旧是那句话,不急,接下来会介绍。

来一个示例:

#include <stdio.h>
#include <setjmp.h>
jmp_buf env;
void foo() {
    printf("before jmp\n");
    int ret = setjmp(env);
    if(ret == 0) {
        return;
    } else {
        printf("return %d\n", ret);
    }
    printf("after jmp\n");
}
int main() {
    foo();
    longjmp(env, 999);
    return 0;
}

和结果比对下,看看你是否猜对了。

before jmp
return 999
after jmp

为了说明它俩的“邪恶”,并以此引出栈的问题,我们稍微修改下代码,给大家来个“可爱”的SegmentFault。

#include <stdio.h>
#include <setjmp.h>
jmp_buf env;
void foo() {
    printf("before jmp\n");
    int ret = setjmp(env);
    if(ret == 0) {
        return;
    } else {
        printf("return %d\n", ret);
    }
    printf("after jmp\n");
}
int main(int argc, char* argv[]) {
    foo();
    longjmp(env, 999);
    return 0;
}

和之前的例子并没有什么不同啊,但这段代码却会“吐核”的。问题就在于,大家并没有很好地理解栈的概念,jmp_buf保存的可不是完整的上下文,只是寄存器的内容(由于种种原因,SP的保存有问题),那么栈里的内容怎么办?因为它俩使用的可不是什么正儿八经的call和ret,而是直接通过恢复PC来实现跳转,此时栈的内容早已面目全非,main尚未结束,就直接把它给弹了,关键是人家的参数和你不一致啊,让.finit如何是好?

那么我们该如何正确使用setjmp和longjmp呢?我的建议是让整个longjmp串起来最终回到最初调用的那个函数。

比如上面的例子可以这样修改一下:

#include <stdio.h>
#include <setjmp.h>
jmp_buf env;
jmp_buf ctx;
void foo() {
    printf("before jmp\n");
    int ret = setjmp(env);
    if(ret == 0) {
        return;
    } else {
        printf("return %d\n", ret);
    }
    printf("after jmp\n");
}
int main(int argc, char* argv[]) {
    foo();
    if(setjmp(ctx) == 0) {
        longjmp(env, 999);
    }
    return 0;
}

如此一来,我们就能够确保栈的内容和跳转前一致了,只不过这样用起来实在是略“丑”,不是吗?

顺便说下,不要妄想封装setjmp和longjmp,没用的。

0x02 setjmp的实现

重点来了,setjmp是如何实现的,这里我们选用的是state-threads中的setjmp实现,和glibc的差不多,只是重新定义了jmp_buf,这里给出的是i386的汇编,比较简单:

// jmp_buf中各寄存器的定位
#define JB_BX  0
#define JB_SI  1
#define JB_DI  2
#define JB_BP  3
#define JB_SP  4
#define JB_PC  5
// 符号定义,看不懂的略过即可
        /* _st_setjmp(__jmp_buf env) */
.globl _st_setjmp
        .type st_setjmp, @function
        .align 16
_st_setjmp:
        // 获取jmp_buf的地址
        movl 4(%esp), %eax
        // 保存ebx,esi,edi
        movl %ebx, (JB_BX*4)(%eax)
        movl %esi, (JB_SI*4)(%eax)
        movl %edi, (JB_DI*4)(%eax)
        // 保存esp
        leal 4(%esp), %ecx
        movl %ecx, (JB_SP*4)(%eax)
        // 保存返回地址,即调用处的PC
        movl 0(%esp), %ecx
        movl %ecx, (JB_PC*4)(%eax)
        // 保存ebp,调用者的ebp
        movl %ebp, (JB_BP*4)(%eax)
        // 返回0,通过eax返回
        xorl %eax, %eax
        ret
        .size md_cxt_save, .-md_cxt_save

通常,我们在进入一个新的函数后,会保存调用者的ebp,即如下代码:

pushl %ebp
movl %esp, %ebp

这里并不需要,因为setjmp的作用仅仅是获取调用处的PC及其关键寄存器的内容,并保存到jmp_buf。

0x03 longjmp的实现

同样来自state-threads,上汇编:

        /* _st_longjmp(__jmp_buf env, int val) */
.globl _st_longjmp
        .type _st_longjmp, @function
        .align 16
_st_longjmp:
        // 获取jmp_buf的地址
        movl 4(%esp), %ecx
        // 获取val的值
        movl 8(%esp), %eax
        // 设置返回地址
        movl (JB_PC*4)(%ecx), %edx
        // 恢复ebx,esi,edi,ebp,esp
        movl (JB_BX*4)(%ecx), %ebx
        movl (JB_SI*4)(%ecx), %esi
        movl (JB_DI*4)(%ecx), %edi
        movl (JB_BP*4)(%ecx), %ebp
        movl (JB_SP*4)(%ecx), %esp
        // 确保val不为0
        testl %eax, %eax
        jnz  1f
        incl %eax
        // 跳转到PC,即返回调用现场
1:      jmp *%edx
        .size _st_md_cxt_restore, .-_st_md_cxt_restore

eax作为返回值,实现了上面我们所说的通过val来确定返回值的机制,是不是比较好玩?

0x04 缺点及改进

为何st会选择自己实现一套setjmp和longjmp?因为自glibc2.4开始jmp_buf中的esp操作会有点问题,何况自己实现一套也很简单,除了获取PC,其余都是常用的现场保护及还原的概念。

如果你认为光靠setjmp和longjmp就能实现协程,那就错了,由于jmp_buf仅仅保存寄存器的内容,而栈又是共用的,那么栈被破坏的情况就难以避免,更加“邪恶”的是它还会乱弹栈。。。

为此我们需要实现一个完整的上下文保存机制,确保函数的栈和寄存器内容都被恰当保存,最好是在heap中,以支持海量的协程。同时还必须通过一定的设计以及巧妙的封装来保证一定的调用顺序。

0xFF 写在最后

很多时候,大家总是说“don't repeat the wheel”,但是技术的演进是有先后顺序的,这就是为何core space的开发者可以轻轻松松写user space的程序,而user space的开发者却觉得写个内核难如登天,毕竟缺少了一段必须的经历,正如开发者总会假设用户是傻子,只看界面不看内部实现。

阅读代码能够让我们获取更多奇技淫巧,而阅读后造个轮子,则可能为下一个程序提供基础。

一切的程序开发领域的问题都可以通过添加中间层来解决,不知各位是否认同这句话,关键就看你是在中间层之上还是之下,我希望自己位于这个中间层之下,同时希望这个中间层不是柏林墙。

说两句: