如何利用C语言中的setjmp和longjmp实现异常捕获和协程?

道哥分享
关注

3. setjmp:保存上下文信息

我们知道,C 代码在编译成二进制文件之后,在执行时被加载到内存中,CPU 按照顺序到代码段取出每一条指令来执行。在 CPU 中有很多个寄存器,用来保存当前的执行环境,比如:代码段寄存器CS、指令偏移量寄存器IP,当然了还有其他很多其它寄存器,我们把这个执行环境称作上下文。

CPU 在获取下一条执行指令时,通过 CS 和 IP 这 2 个寄存器就能获取到需要执行的指令,如下图:

补充一下知识点:

上图中,把代码段寄存器 CS 当做一个基地址来看待了,也就是说:CS 指向代码段在内存中的开始地址,IP 寄存器代表下一个要执行的指令地址距离这个基地址的偏移量。因此每次取指令时,只需要把这 2 个寄存器中的值相加,就得到了指令的地址;其实,在 x86 平台上,代码段寄存器 CS 并不是一个基地址,而是一个选择子。在操作系统的某个地方有一个表格,这个表格里存储了代码段真正的开始地址,而 CS 寄存器中 只是存储了一个索引值,这个索引值指向这个表格中的某个表项,这里涉及到虚拟内存的相关知识了;IP 寄存器在获取一条指令之后,自动往下移动到下一个指令的开始位置,至于移动多少个字节,那就要看当前取出的这条指令占用了多少个字节。

CPU 是一个大傻瓜,它没有任何的想法,我们让它干什么,它就干什么。比如取指令:我们只要设置 CS 和 IP 寄存器,CPU 就用这 2 个寄存器里的值去获取指令。如果把这 2 个寄存器设置为一个错误的值,CPU 也会傻不拉几的去取指令,只不过在执行时就会崩溃。

我们可以简单的把这些寄存器信息理解为上下文信息,CPU 就根据这些上下文信息来执行。因此,C 语言为我们准备了 setjmp 这个库函数来把当前的上下文信息保存起来,暂时存储到一个缓冲区中。

保存的目的是什么?为了在以后可以恢复到当前这个地方继续执行。

还有一个更简单的例子:服务器中的快照。快照的作用是什么?当服务器出现错误时,可以恢复到某个快照!

4. longjmp: 实现跳转

说到跳转,脑袋中立刻跳出的概念就是 goto 语句,我发现很多教程都对 goto 语句很有意见,认为在代码中应该尽量不要使用它。这样的观点出发点是好的:如果 goto 使用太多,会影响对代码执行顺序的理解。

但是如果看一下 Linux 内核的代码,可以发现很多的 goto 语句。还是那句话:在代码维护和执行效率上要寻找一个平衡点。

跳转改变了程序的执行序列,goto 语句只能在函数内部进行跳转,如果是跨函数它就无能为力了。

因此,C 语言中为我们提供了 longjmp 函数来实现远程跳转,从它的名字就可以额看出来,也就是说可以跨函数跳转。

从 CPU 的角度看,所谓的跳转就是把上下文中的各种寄存器设置为某个时刻的快照,很显然,上面的 setjmp 函数中,已经把那个时刻的上下文信息(快照)存储到一个临时缓冲区中了,如果要跳转到那个地方去接着执行,直接告诉 CPU 就行了。

怎么告诉 CPU 呢?就是把临时缓冲区中的这些寄存器信息覆盖掉 CPU 中使用的那些寄存器即可。

5. setjmp:返回类型和返回值

在某些需要多进程的程序中,我们经常使用 fork 函数来从当前的进程中"孵化"一个新的进程,这个新进程从 fork 这个函数的下一条语句开始执行。

对于主进程来说,调用 fork 函数之后返回,也是继续执行下一条语句,那么如何来区分是主进程还是新进程呢? fork 函数提供了一个返回值给我们来进行区分:

fork 函数返回 0:代表这是新进程;
fork 函数返回非 0:代表是原来的主进程,返回数值是新进程的进程号。

类似的,setjmp 函数也有不同的返回类型。也许用返回类型来表述不太准确,可以这样理解:从 setjmp 函数返回,一共有 2 个场景:

主动调用 setjmp 时:返回 0,主动调用的目的是为了保存上下文,建立快照。通过 longjmp 跳转过来时:返回非 0,此时的返回值是由 longjmp 的第二个参数来指定的。

根据以上这 2 种不同的值,我们就可以进行不同的分支处理了。当通过 longjmp 跳转返回的时候,可以根据实际场景,返回不同的非 0 值。有过 Python、Lua 等脚本语言编程经验的小伙伴,是不是想到了 yield/resume 函数?它们在参数、返回值上的外在表现是一样的!

小结:到这里,基本上把 setjmp/longjmp 这 2 个函数的使用方法讲完了,不知道我描述的是否足够清楚。此时,再看一下文章开头的示例代码,应该一目了然了。

三、利用 setjmp/longjmp 实现异常捕获

既然 C 函数库给我们提供了这个工具,那就肯定存在一定的使用场景。异常捕获在一些高级语言中(Java/C++),直接在语法层面进行了支持,一般就是 try-catch 语句,但是在 C 语言中需要自己去实现。

我们来演示一个最简单的异常捕获模型,代码一共 56 行:

#include <unistd.h>#include <stdio.h>#include <stdlib.h>#include <setjmp.h>
typedef int     BOOL;#define TRUE    1#define FALSE   0
// 枚举:错误代码typedef enum _ErrorCode_ {    ERR_OK = 100,         // 没有错误    ERR_DIV_BY_ZERO = -1  // 除数为 0} ErrorCode;
// 保存上下文的缓冲区jmp_buf gExcptBuf;
// 可能发生异常的函数typedef int (*pf)(int, int);int my_div(int a, int b){    if (0 == b)    {        // 发生异常,跳转到函数执行之前的位置        // 第2个参数是异常代码        longjmp(gExcptBuf, ERR_DIV_BY_ZERO);    }    // 没有异常,返回正确结果    return a / b;}
// 在这个函数中执行可能会出现异常的函数int try(pf func, int a, int b){    // 保存上下文,如果发生异常,将会跳入这里    int ret = setjmp(gExcptBuf);    if (0 == ret)    {        // 调用可能发生异常的哈数        func(a, b);        // 没有发生异常        return ERR_OK;    }    else    {        // 发生了异常,ret 中是异常代码        return ret;    }}
int main(){    int ret = try(my_div, 8, 0);     // 会发生异常    // int ret = try(my_div, 8, 2);  // 不会发生异常    if (ERR_OK == ret)    {        printf("try ok ! ");    }    else    {        printf("try excepton. error = %d ", ret);    }        return 0;}

代码就不需要详细说明了,直接看代码中的注释即可明白。这个代码仅仅是示意性的,在生产代码中肯定需要更完善的包装才能使用。

有一点需要注意:setjmp/longjmp 仅仅是改变了程序的执行顺序,应用程序自己的一些数据如果需要回滚的话,需要我们自己手动处理。

四、利用 setjmp/longjmp 实现协程 

1. 什么是协程

在 C 程序中,如果需要并发执行的序列一般都是用线程来实现的,那么什么是协程呢?维基百科对于协程的解释是:

更详细的信息在这个页面 协程,网页中具体描述了协程与线程、生成器的比较,各种语言中的实现机制。

我们用生产者和消费者来简单体会一下协程和线程的区别:

2. 线程中的生产者和消费者生产者和消费者是 2 个并行执行的序列,通常用 2 个线程来执行;生产者在生产商品时,消费者处于等待状态(阻塞)。生产完成后,通过信号量通知消费者去消费商品;消费者在消费商品时,生产者处于等待状态(阻塞)。消费结束后,通过信号量通知生产者继续生产商品。3. 协程中的生产者和消费者生产者和消费者在同一个执行序列中执行,通过执行序列的跳转来交替执行;生产者在生产商品之后,放弃 CPU,让消费者执行;消费者在消费商品之后,放弃 CPU,让生产者执行;4. C 语言中的协程实现

这里给出一个最最简单的模型,通过 setjmp/longjmp 来实现协程的机制,主要是目的是来理解协程的执行序列,没有解决参数和返回值的传递问题。

typedef int     BOOL;#define TRUE    1#define FALSE   0
// 用来存储主程和协程的上下文的数据结构typedef struct _Context_ {    jmp_buf mainBuf;    jmp_buf coBuf;} Context;
// 上下文全局变量Context gCtx;
// 恢复#define resume()     if (0 == setjmp(gCtx.mainBuf))     {         longjmp(gCtx.coBuf, 1);     }
// 挂起#define yield()     if (0 == setjmp(gCtx.coBuf))     {         longjmp(gCtx.mainBuf, 1);     }
// 在协程中执行的函数void coroutine_function(void *arg){    while (TRUE)  // 死循环    {        printf("*** coroutine: working ");        // 模拟耗时操作        for (int i = 0; i < 10; ++i)        {            fprintf(stderr, ".");            usleep(1000 * 200);        }        printf("*** coroutine: suspend ");                // 让出 CPU        yield();    }}
// 启动一个协程// 参数1:func 在协程中执行的函数// 参数2:func 需要的参数typedef void (*pf)(void *);BOOL start_coroutine(pf func, void *arg){    // 保存主程的跳转点    if (0 == setjmp(gCtx.mainBuf))    {        func(arg); // 调用函数        return TRUE;    }
   return FALSE;}
int main(){    // 启动一个协程    start_coroutine(coroutine_function, NULL);        while (TRUE) // 死循环    {        printf("=== main: working ");
       // 模拟耗时操作        for (int i = 0; i < 10; ++i)        {            fprintf(stderr, ".");            usleep(1000 * 200);        }
       printf("=== main: suspend ");                // 放弃 CPU,让协程执行        resume();    }
   return 0;}
打印信息如下:

如果想深入研究 C 语言中的协程实现,可以看一下达夫设备这个概念,其中利用 goto 和 switch 语句来实现分支跳转,其中使用的语法比较怪异、但是合法。

五、总结

这篇文章的重点是介绍 setjmp/longjmp 的语法和使用场景,在某些需求场景中,能达到事半功倍的效果。

当然,你还可以发挥想象力,通过执行序列的跳转来实现更加花哨的功能,一切皆有可能!

不吹嘘,不炒作,不浮夸,认真写好每一篇文章!

欢迎转发、分享给身边的技术朋友,道哥在此表示衷心的感谢!转发的推荐语已经帮您想好了:

道哥总结的这篇总结文章,写得很用心,对我的技术提升很有帮助。好东西,要分享!

最后,祝您:面对代码,永无bug;面对生活,春暖花开!


声明: 本文由入驻OFweek维科号的作者撰写,观点仅代表作者本人,不代表OFweek立场。如有侵权或其他问题,请联系举报。
侵权投诉

下载OFweek,一手掌握高科技全行业资讯

还不是OFweek会员,马上注册
打开app,查看更多精彩资讯 >
  • 长按识别二维码
  • 进入OFweek阅读全文
长按图片进行保存