当前位置: 首页 > 科技观察

使用C语言中的Setjmp和Longjmp实现异常捕获和协程

时间:2023-03-14 19:51:56 科技观察

1.前言2.函数语法介绍及goto语句比较和fork函数与Python语言中yield/resume的比较3.使用setjmp/longjmp实现异常捕获4.使用setjmp/longjmp实现协程5.小结1.前言在C标准库中,有两个强大的函数:setjmp和longjmp。我想知道你是否在代码中使用过它们?问了好几个体内的同事,有的不知道这两个功能,有的知道这个功能,但是从来没用过。从知识点范围来看,这两个函数的功能都比较简单,简单的示例代码就可以说的一清二楚。但是,我们需要从这个知识点发散思考,在不同的维度上,将这个知识点与这个编程语言中其他类似的知识进行关联和比较;将其与其他编程语言中的类似概念进行比较;然后想想这个知识点可以用在什么地方,别人怎么用。今天,我们就来说说这两个功能。虽然它们不能用在一般的程序中,但在以后的某些场合,当你需要处理一些奇特的程序流程时,它们可能会给你带来意想不到的效果。例如:我们将setjmp/longjmp的功能与goto语句进行比较;在返回值方面与fork函数进行比较;在使用场景上与Python/Lua语言中的协程进行比较。二、函数语法介绍1、举个最简单的例子也不无道理。只看最简单的示例代码。看不懂也没关系。你再熟悉不过了:intmain(){//临时存储的缓冲区环境变量jmp_bufbuf;printf("line1\n");//保存此时的上下文信息intret=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");return0;}执行结果:执行顺序如下(不懂就别走进去,看下面解释后回头看):2.函数说明首先看这两个函数的签名:intsetjmp(jmp_bufenv);voidlongjmp(jmp_bufenv,intvalue);它们都在头文件setjmp.h中声明,维基百科解释如下:setjmp:设置本地jmp_buf缓冲区并为跳转初始化它。此例程将程序的调用环境保存在由env参数指定的环境缓冲区中,供lo??ngjmp稍后使用。如果返回来自直接调用,则setjmp返回0。如果返回来自对longjmp的调用,则setjmp返回非零值。longjmp:恢复通过在程序的同一调用中调用setjmp例程保存的环境缓冲区env的上下文。从嵌套信号处理程序调用longjmp是未定义的。value指定的值从longjmp传递到setjmp。longjmp完成后,程序继续执行,就好像相应的setjmp调用刚刚返回一样。如果传递给longjmp的值为0,setjmp的行为就好像它返回了1;否则,它的行为就好像它有返回值一样。接下来用自己的理解用英文解释一下上面这段话:setjmpfunction功能:保存执行这个函数时的各种上下文信息,主要是一些寄存器的值;参数:用于保存上下文信息的buffer,相当于对当前上下文信息进行快照并保存;返回值:有2个返回值,如果直接调用setjmp函数,返回值为0;如果调用longjmp函数跳转,返回值不为0;这里可以类比创建进程的函数fork。longjmp函数功能:跳转到参数env缓冲区中保存的上下文(快照)执行;parameter:env参数指定跳转到哪个context(snapshot)执行,value用于为setjmp函数提供返回判断信息,也就是说:调用longjmp函数时,参数值将作为setjmp函数的返回值;返回值:无返回值。因为调用这个函数的时候,直接跳转到其他地方的代码去执行,不会再回来了。总结:这两个函数配合使用,实现程序的跳转。3.setjmp:保存上下文信息我们知道,C代码编译成二进制文件后,在执行时会加载到内存中,CPU会依次取出代码段中的每条指令来执行。CPU中有很多寄存器用来保存当前的执行环境,比如:代码段寄存器CS,指令偏移量寄存器IP,当然还有很多其他的寄存器。我们称此执行环境上下文。当CPU获取到下一条执行指令时,可以通过CS和IP这两个寄存器获取到要执行的指令,如下图:补充知识点:上图中,代码段寄存器CS被看作是一个baseaddress,也就是说:CS指向代码段在内存中的起始地址,IP寄存器代表的是下一条要执行的指令地址相对于这个基地址的偏移量。因此,每次取指令时,只需要将这两个寄存器中的值相加即可得到指令的地址;实际上,在x86平台上,代码段寄存器CS并不是一个基地址,而是一个选择符。操作系统某处有一张表,里面存放着代码段真正的起始地址,而CS寄存器只存放了一个索引值,指向这张表中的一个表项,这就涉及到虚拟内存的相关知识了;IP寄存器获取到一条指令后,自动下移到下一条指令的起始位置。至于移动多少字节,要看当前取指令占用多少字节。.CPU是个大傻瓜,它没有任何想法,我们让它做什么它就做什么。比如取指令:我们只需要设置CS和IP寄存器,CPU就会使用这两个寄存器中的值来取指令。如果这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函数返回非零:表示原主进程,返回值为新进程的进程号。同样,setjmp函数具有不同的返回类型。用返回类型来表达可能不准确。可以这样理解:setjmp函数返回时一共有两种场景:主动调用setjmp时:返回0,主动调用的目的是为了保存上下文和创建快照。longjmp跳转时:返回非零,此时的返回值由longjmp的第二个参数指定。根据以上两个不同的值,我们可以进行不同的分支处理。通过longjmp跳转返回时,可以根据实际场景返回不同的非零值。有Python、Lua等脚本语言编程经验的朋友,有没有想过yield/resume函数?它们在参数和返回值方面的外在表现是一样的!总结:至此,基本上setjmp/longjmp这两个函数的使用就结束了,不知道我描述的够不够清楚。此时再看文章开头的示例代码,应该一目了然了。3、使用setjmp/longjmp实现异常捕获既然C函数库为我们提供了这个工具,那肯定有一定的使用场景。一些高级语言(Java/C++)在语法层面直接支持异常捕获,一般是try-catch语句,但需要用C语言实现。下面来演示一下最简单的异常捕获模型,一共56行代码:#include#include#include#includetypedefintBOOL;#defineTRUE1#defineFALSE0//Enumeration:Errorcodetypedefenum_ErrorCode_{ERR_OK=100,//没有错误ERR_DIV_BY_ZERO=-1//除数为0}ErrorCode;//保存context的bufferjmp_bufgExcptBuf;//可能引发异常的函数typedefint(*pf)(int,int);intmy_div(inta,intb){if(0==b){//发生异常,跳转到函数执行前的位置//第二个参数为异常代码longjmp(gExcptBuf,ERR_DIV_BY_ZERO);}//没有异常,返回正确结果return/b;}//执行本函数中可能引发异常的函数inttry(pffunc,inta,intb){//保存上下文,如果发生异常,它会跳到这里intret=setjmp(gExcptBuf);if(0==ret){//调用可能发生异常的哈希数func(a,b);//没有发生异常returnERR_OK;}else{//发生异常发生了,ret是Exceptioncodereturnret;}}intmain(){intret=try(my_div,8,0);//会发生异常//intret=try(my_div,8,2);//不会发生异常发生if(ERR_OK==ret){printf("tryok!\n");}else{printf("tryexcepton.error=%d\n",ret);}return0;}代码不用解释具体的,看代码中的注释就可以明白。此代码仅供说明,肯定需要更完整的包装器才能在生产代码中使用。需要注意一件事:setjmp/longjmp只是改变了程序的执行顺序。如果应用程序的某些数据需要回滚,需要我们自己手动处理。四、使用setjmp/longjmp实现协程1、什么是协程在C程序中,如果需要并发执行的序列一般都是用线程实现的,那什么是协程呢?维基百科对协同程序的解释是:更详细的信息在协同程序的这个页面上。网页具体描述了协程、线程、生成器的比较,以及各种语言的实现机制。下面我们用生产者和消费者来简单了解一下协程和线程的区别:2.线程中的生产者和消费者生产者和消费者是两个并行的执行序列,通常由两个线程执行;production后者在生产商品时,消费者处于等待状态(阻塞)。生产完成后,通过信号量通知消费者消费产品;当消费者消费产品时,生产者处于等待状态(阻塞)。消费结束后,通过信号量通知生产者继续生产商品。3、协程中的生产者和消费者生产者和消费者在同一个执行序列中执行,通过执行序列的跳转交替执行;生产者生产出产品后,让出CPU,让消费者执行;consumption消费者消费完产品后,生产者让出CPU,让生产者执行;4.C语言中协程的实现这里是最简单的模型,通过setjmp/longjmp实现协程的机制主要是理解协程的执行顺序,并没有解决传递参数和返回值的问题。typedefintBOOL;#defineTRUE1#defineFALSE0//用来存储主进程和协程上下文的数据结构typedefstruct_Context_{jmp_bufmainBuf;jmp_bufcoBuf;}Context;//上下文全局变量ContextgCtx;//恢复#definesume()\if(0==setjmp(gCtx.mainBuf))\{\longjmp(gCtx.coBuf,1);\}//等待#defineyield()\if(0==setjmp(gCtx.coBuf))\{\longjmp(gCtx.mainBuf,1);\}//协程中执行的函数voidcoroutine_function(void*arg){while(TRUE)//死循环{printf("\n***coroutine:working\n");//模拟耗时操作for(inti=0;i<10;++i){fprintf(stderr,".");usleep(1000*200);}printf("\n***coroutine:suspend\n");//放弃CPUyield();}}//启动一个协程//参数1:func在协程中执行的函数//参数2:func需要的参数typedefvoid(*pf)(void*);BOOLstart_coroutine(pffunc,void*arg){//保存主程序的跳转点if(0==setjmp(gCtx.mainBuf)){func(arg);//调用函数returnTRUE;}returnFALSE;}intmain(){//启动一个协程start_coroutine(coroutine_function,NULL);while(TRUE)//无限循环{printf("\n===main:working\n");//模拟耗时操作for(inti=0;i<10;++i){fprintf(stderr,".");usleep(1000*200);}printf("\n===main:suspend\n");//放弃CPU,让协程执行resume();}return0;},打印信息如下:如果想研究C语言的协程实现,可以看看Duff设备的概念,使用goto和switch实现分支跳转的语句,其中使用的语法比较奇葩,但是合法5.总结本文重点介绍setjmp/longjmp的语法和使用场景。当然,你也可以发挥你的想象力,通过执行序列跳转,实现更多花哨的功能,一切皆有可能!本文转载自微信公众号“IOT物联网小镇”,可通过以下二维码关注。转载本文请联系物联小镇公众号。