移植 LuaCoco

lua coroutine 协程 移植

2017-05-30 13:38 PM

lua 语言最大的卖点之一就是他的协程(coroutine)了。但是在 lua5.1 中有一个文档都没提到的一个坑:协程只能在 lua 中使用,当调用 yield 时,如果当前的调用栈上有 c 代码,则会报错 “attempt to yield across metamethod/C-call boundary”。目前有个第三方的 patch 叫做 luaCoco 可以让 lua 支持 “真协程”。本文研究了 luaCoco 的内部实现,并把它移植到了 xtensa 处理器上。

image_1bhbpomnrk0m58270a54k789.png-16.9kB


setjmp 的实现

在文章的开头需要先讲解一下c语言标准库中 setjmp 的内部实现,因为之后的 luaCoco 的实现就是对 setjmp 的数据结构的一个 hack。

先看看 setjmp 的用法

  1. #inlcude <stdio.h>
  2. #include <stdlib.h>
  3. #inlcude <setjmp.h>
  4. int main() {
  5. jmp_buf buf;
  6. int code = setjmp(buf);
  7. if (code == 0) {
  8. print("before jmp\n");
  9. longjmp(buf, 1024);
  10. } else {
  11. printf("jmp here, code: %d\n", code);
  12. }
  13. return 0;
  14. }

在调用 setjmp 的时候,会返回 0,从而会执行 if (code == 0) { 为 true 的 block,会打印出 before jmp。当执行了 longjmp 之后,程序的执行会重新跳转到 setjmp 那一行(第七行)然而这次 setjmp 的返回值 code 不再是 0,而是 longjmp 的第二个参数(1024),这样就会打印出 jmp here, code: 1024

利用 setjmp 的这个功能能实现出很多有趣的东西,比如在c语言中做 Exception,但是 setjmp 是怎么实现的呢?我们去读一读源码。

libc 的实现有好多种,常见的比如 glibc、uclibc 和 musl-libc,但是我们这次读一读 newlib 的源码。newlib 也是一个 libc 的实现,常用于嵌入式开发中。

打开 inlcude/machine/setjmp-dj.h 文件,可以看到 jmp_buf 的定义:

  1. // from inlcude/machine/setjmp-dj.h
  2. typedef struct {
  3. unsigned long eax;
  4. unsigned long ebx;
  5. unsigned long ecx;
  6. unsigned long edx;
  7. unsigned long esi;
  8. unsigned long edi;
  9. unsigned long ebp;
  10. unsigned long esp;
  11. unsigned long eip;
  12. } jmp_buf[1];

发现这个结构体是用来存储 cpu 的寄存器的。这里很好理解,因为要实现 长跳转(longjmp),必须要首先把跳转的目的地的现场先保存下来。

setjmp 函数做的工作就是保存现场:

  1. // from machine/i386/setjmp.S
  2. /*
  3. ** jmp_buf:
  4. ** eax ebx ecx edx esi edi ebp esp eip
  5. ** 0 4 8 12 16 20 24 28 32
  6. */
  7. #include "i386mach.h"
  8. .global SYM (setjmp)
  9. .global SYM (longjmp)
  10. SOTYPE_FUNCTION(setjmp)
  11. SOTYPE_FUNCTION(longjmp)
  12. SYM (setjmp):
  13. pushl ebp
  14. movl esp,ebp
  15. pushl edi
  16. movl 8 (ebp),edi
  17. movl eax,0 (edi)
  18. movl ebx,4 (edi)
  19. movl ecx,8 (edi)
  20. movl edx,12 (edi)
  21. movl esi,16 (edi)
  22. movl -4 (ebp),eax
  23. movl eax,20 (edi) // edi
  24. movl 0 (ebp),eax
  25. movl eax,24 (edi) // ebp
  26. movl esp,eax
  27. addl $12,eax
  28. movl eax,28 (edi) // esp
  29. movl 4 (ebp),eax
  30. movl eax,32 (edi) // eip (PC)
  31. popl edi
  32. movl $0,eax
  33. leave
  34. ret


FullSizeRender 2.jpg-1881.4kB
setjmp 的栈帧

看图解释一下代码:

  • 17 行:push ebp 到栈上(esp 同时下移 4 字节)
  • 18 行:ebp 指向 esp
  • 20 行:push edi 到栈上(esp 同时再下移 4 字节)
  • 21 行:8(ebp) 保存的是 jmp_buf 的指针,先把它放到 edi 中
  • 23 - 27 行:分别把 eax、ebx、ecx、edx、esi 保存到 jmp_buf 里
  • 29 - 30 行:-4 (ebp) 是edi,如图
  • 32 - 33 行:(ebp) 就是之前的ebp,如图
  • 35 - 37 行:保存 esp
  • 39 - 40 行:如图,保存 return addr 到 jmp_buf->eip

longjmp 的实现是正好相反的:

  1. SYM (longjmp):
  2. pushl ebp
  3. movl esp,ebp
  4. movl 8(ebp),edi /* get jmp_buf */
  5. movl 12(ebp),eax /* store retval in j->eax */
  6. testl eax,eax
  7. jne 0f
  8. incl eax
  9. 0:
  10. movl eax,0(edi)
  11. movl 24(edi),ebp
  12. __CLI
  13. movl 28(edi),esp
  14. pushl 32(edi)
  15. movl 0(edi),eax
  16. movl 4(edi),ebx
  17. movl 8(edi),ecx
  18. movl 12(edi),edx
  19. movl 16(edi),esi
  20. movl 20(edi),edi
  21. __STI
  22. ret

代码就不赘述了,基本上就是恢复现场、把longjmp的第二个参数作为返回值返回(7 - 9 行还有一个判断:如果参数为0的话,会把它改成 1)。


LuaCoco 的实现

首先在看代码之前,先简单讲解一下我对 LuaCoco 的理解。lua 的协程其实也就是为每个协程维护了一个 context(所谓 context,既程序执行到某个地方时的状态,包括寄存器、callstack)。当协程之前相互 yield 的时候,切换一下 context。但是 lua 没做好的地方是 lua 仅保存了 lua 程序的 context,而 c 代码的 context 是没有保存的。这是因为 lua 很追求使用 pure c,不希望在源码中加入过多平台相关的东西。保存 lua 的 context 是比较简单的,因为所有 lua 程序相关的数据结构都放在 lua 虚拟机里,只需要每个 coroutine 保存一份就好了,而取得/保存 c 代码的上下文是需要操作平台相关的寄存器的。LuaCoco 就是为每个常见的平台都做了一份保存 c 程序 context 的实现。

下面看 LuaCoco 的源码。

LuaCoco 是对 lua 源码的一个 patch,coco 改动了 lua 的以下文件:

image_1bhcc3die5r3c7tg95ab069b1e.png-22.7kB

最主要的文件就是 lcoco.c 和 lcoco.h 了:

我看源码一般喜欢先看一下 header 文件,瞄一眼 coco 大体上做了什么事情。

  1. // from lcoco.h
  2. /* Exported C API to add a C stack to a coroutine. */
  3. LUA_API lua_State *lua_newcthread(lua_State *L, int cstacksize);
  4. /* Internal support routines. */
  5. LUAI_FUNC void luaCOCO_free(lua_State *L);
  6. LUAI_FUNC int luaCOCO_resume(lua_State *L, int nargs);
  7. LUAI_FUNC int luaCOCO_yield(lua_State *L);
  8. LUAI_FUNC int luaCOCO_cstacksize(int cstacksize);

lcoco.h 里声明了 coco 定义的 4 个函数。很明显,lua_newcthread 是为了取代原生 lua 中的 lua_newthread 函数的。这一点可以在 lbaselib.c 文件的改动中看到。

  1. // from lbaselib.c
  2. static int luaB_cocreate (lua_State *L) {
  3. #ifdef COCO_DISABLE
  4. lua_State *NL = lua_newthread(L);
  5. luaL_argcheck(L, lua_isfunction(L, 1) && !lua_iscfunction(L, 1),
  6. 1, "Lua function expected");
  7. #else
  8. int cstacksize = luaL_optint(L, 2, 0);
  9. lua_State *NL = lua_newcthread(L, cstacksize);
  10. luaL_argcheck(L, lua_isfunction(L, 1) &&
  11. (cstacksize >= 0 ? 1 : !lua_iscfunction(L, 1)),
  12. 1, "Lua function expected");
  13. #endif
  14. lua_pushvalue(L, 1); /* move function to top */
  15. lua_xmove(L, NL, 1); /* move function from L to NL */
  16. return 1;
  17. }

这里用宏判断了是否开启 coco,如果开启的话就使用新的 lua_newcthread,否则的话还使用原生的 lua_newthread。

那么,下一步就着重来看看 lua_newcthread 的实现。

  1. /* Add a C stack to a coroutine. */
  2. lua_State *lua_newcthread(lua_State *OL, int cstacksize)
  3. {
  4. lua_State *NL = lua_newthread(OL);
  5. if (cstacksize < 0)
  6. return NL;
  7. if (cstacksize == 0)
  8. cstacksize = defaultcstacksize;
  9. else if (cstacksize < COCO_MIN_CSTACKSIZE)
  10. cstacksize = COCO_MIN_CSTACKSIZE;
  11. cstacksize &= -16;
  12. COCO_NEW(OL, NL, cstacksize, ((coco_MainFunc)(coco_main)))
  13. return NL;
  14. }

先看函数签名,发现比原生的 newthread 多了一个 cstacksize 参数,因为现在需要为 c 程序保存上下文,c 程序的执行需要一个 stack,所以每个 coroutine 都要有一个自己的 stack。这个 cstacksize 参数就是用来控制这个 stack 的大小的。

继续看代码,几个 if 的意思也很清晰,不表。发现重要的功能都封装到了 COCO_NEW 这个宏里面了。再一翻代码,这个宏又套了好几层宏和宏判断,搞得我很郁闷。所以我就使出了我的编译器大法!

  1. gcc -E -DCOCO_USE_SETJMP -D__linux__ -D_I386_JMP_BUF_H -D__i386 lcoco.c > _lcoco.c

这个命令可以让编译器只执行预处理,说白了就是把 c 语言的宏全部展开。命令中定义的其他几个宏的意思分别是:使用 setjmp 实现协程、使用Linux架构、使用i386架构。

宏展开的结果如下:

  1. lua_State* lua_newcthread(lua_State* OL, int cstacksize) {
  2. // ...
  3. // ... if check
  4. {
  5. void* ptr = luaM_realloc_(OL, NULL, 0, (cstacksize));
  6. coco_State* coco = ((coco_State* )(((char* ) 0) + ((((char* )(ptr) - (char* ) 0) + (cstacksize) - sizeof(coco_State)) & -16)));
  7. coco->allocptr = ptr;
  8. coco->allocsize = cstacksize; {
  9. size_t* stackptr = & ((size_t*) coco)[-1];
  10. _setjmp(coco->ctx);
  11. coco->ctx->__pc = (((coco_MainFunc)(coco_main)));
  12. coco->ctx->__sp = (stackptr);
  13. coco->ctx->__bp = NULL;
  14. stackptr[1 - 1] = 0xdeadc0c0;
  15. coco->arg0 = (size_t)(NL);
  16. }(((coco_State**)(NL))[-1]) = coco;
  17. }
  18. return NL;
  19. }

可以看到,主要做的事情就是 alloc 了 coco_State 数据结构和 cstack。这里只申请了一次内存空间,然后通过指针操作分别把内存分成了 coco_State 和 cstack 两块。有点迷糊的可以看一下我画的图:

WechatIMG23.jpeg-99.9kB

申请好空间之后,11到16行初始化了 coco_State 的几个字段。

17行把 coco 的指针放到了 NL 的前面(我并不知道为什么可以这么做,反正就是可以,看的时候我一脸“还有这种操作?”的黑人问号)


IMG_0002.JPG-49.3kB

下面我们去看看 coco_State 的代码。

  1. struct coco_State {
  2. size_t arg0;
  3. jmp_buf ctx;
  4. jmp_buf back;
  5. void* allocptr;
  6. int allocsize;
  7. int nargs;
  8. };

经过宏替换之后,coco_State 长上面这样。

lua_newcthread里的代码初始化了 ctx,allocptr,allocsize,arg0 字段。其中,对 ctx 的初始化操作比较让人在意,首先是调用 setjmp 初始化了 ctx,之后对 ctx 内部的几个字段进行了魔改。

  1. coco->ctx->__pc = (((coco_MainFunc)(coco_main)));
  2. coco->ctx->__sp = (stackptr);
  3. coco->ctx->__bp = NULL;

有了之前 setjmp 的基础,这里就容易理解了,一旦对这个 ctx 调用 longjmp 的话,程序就会跳转到 coco_main 这个函数,并且把 stackptr 当作程序的 stack 来使用。这个 stack 的切换操作,其实就是程序 context 的切换

我们先不看 coco_main 做了什么,先看看这个 longjmp 会在什么时候调用,动脑子想一想,应该会是再执行 resume 的时候调用 longjmp,果然不出所料:

  1. int luaCOCO_resume(lua_State* L, int nargs) {
  2. coco_State* coco = (((coco_State**)(L))[-1]);
  3. coco->nargs = nargs;
  4. if (!_setjmp(coco->back)) _longjmp(coco->ctx, 1);
  5. if (L->status != 1) {
  6. luaM_realloc_(L, ((((coco_State**)(L))[-1])->allocptr), ((((coco_State**)(L))[-1])->allocsize), 0);
  7. (((coco_State**)(L))[-1]) = NULL;
  8. }
  9. return L->status;
  10. }

在 resume 的时候,首先使用 setjmp 把主线程的状态保存到 coco->back 里,然后调转到 coco_main

下面看 coco_main 做了什么。

  1. static void coco_main(lua_State* L) {
  2. coco_State* coco = (((coco_State**)(L))[-1]);
  3. for (;;) {
  4. L->status = luaD_rawrunprotected(L, coco_start, L->top - (coco->nargs + 1));
  5. if (L->status != 0) luaD_seterrorobj(L, L->status, L->top);
  6. if (!_setjmp(coco->ctx)) _longjmp(coco->back, 1);
  7. }
  8. }

main 首先通过L取到了coco的指针(又一次黑人问号)。然后通过 luaD_rawrunprotected 调用了 lua 程序(也就是lua子线程)。之后判断子线程时候出错,设置错误码。最后,保存子线程的状态到 ctx,然后跳转会 back(调转到了luaCOCO_resume 第6行)。

继续回来看 luaCOCO_resume 函数,第 6 行后面宏展开前其实是这样:

  1. if (L->status != LUA_YIELD) {
  2. COCO_FREE(L)
  3. }

展开前的代码比较容易懂:如果子线程执行完了,就 free 掉,没啥可说的。

最后看看 yield:

  1. int luaCOCO_yield(lua_State* L) {
  2. coco_State* coco = (((coco_State**)(L))[-1]);
  3. L->status = 1;
  4. if (!_setjmp(coco->ctx)) _longjmp(coco->back, 1);
  5. L->status = 0; {
  6. StkId base = L->top - coco->nargs;
  7. StkId rbase = L->base;
  8. if (rbase < base) {
  9. while (base < L->top) {
  10. const TValue* o2 = (base++);
  11. TValue* o1 = (rbase++);
  12. o1->value = o2->value;
  13. o1->tt = o2->tt;
  14. ((void) 0);
  15. };
  16. L->top = rbase;
  17. }
  18. }
  19. L->base = L->ci->base;
  20. return coco->nargs;
  21. }

跳转那里和之前讲的一样,多了的东西没太明白,好像是在复制 lua_State 内部的数据。

LuaCoco 内部实现的内容基本上就这些了。

题外话:其实除了魔改 setjmp,LuaCoco还有三种实现,直接内连汇编、使用ucontext和使用fiber,大家可以通过执行 gcc -E lcoco.c > _lcoco.c 加不同的宏定义来生成代码自行研究(其实原理都差不多)。


xtensa 架构 ABI

这部分就简单讲一下带过了,毕竟不是那么通用的内容。

经常做移植的同学应该大多知道,移植的时候最重要的是需要了解这个 architecture 的 ABI(application binary interface)。Google 一番之后发现了这个pdf,不过先不急着看 ABI,先看一下这个 CPU 的寄存器吧。

xtensa 有 16 个 32 位的通用寄存器,名字分别叫 A0 ~ A15,一个 PC 程序计数器,再加上一个 SAR 寄存器(不知道干嘛用的,不过好像移植的话用不上),还算挺简单的设计,没有奇奇怪怪的东西。

接下来,再看看 xtensa 的 ABI,在 PDF 的 chapter 8 里找到了 关于 Xtensa ABI 的部分。xtensa 使用了两种ABI,一种叫 Windowed Register 另一种叫 CALL0 。NodeMCU 只会用到 CALL0 所以我们简单讲一下 CALL0。

先看表格:

Register Use
a0 Return Address
a1 (sp) Stack Pointer (callee-saved)
a2 – a7 Function Arguments
a8 Static Chain (see Section 8.1.8)
a12 – a15 Callee-saved
a15 Stack-Frame Pointer (optional)

A0 保存了函数的返回地址,A1保存了栈指针,a2到a7一共6个寄存器用于传递函数的参数,更多的参数会在栈中传递,a12-a15需要被调用者自己保存。

看完了寄存器的使用,看一下 xtensa 的栈帧(stack frame)格式:

image_1bktgc5jiv8p1bqh1ot5pcf1v0i9.png-37.6kB

可以看出来,xtensa 的指针也是向低地址方向增长的。在 SP 的上面会保存依次 6个参数以外的参数局部变量


移植 LuaCoco

搞清楚 ABI 了,最后的工作就是 coding 了。

遇到的第一个问题是,因为我们要对 setjmp 进行 hack,而不是使用汇编,所以我们仅能操作内存,而不能操作寄存器,所以如何将 lua_State 传递给 coco_main 呢?最常见的方法就是使用 哑参数(dummy args)。

  1. #define COCO_MAIN_PARAM int _a, int _b, int _c, int _d, int _e, int _f, lua_State *L

我们把 coco_main 的参数列表定义成这样,前面a到f的参数都是用不上的,这样就不用管 a2 到 a7 这几个寄存器了。所以我们只需要把L指针放到SP指的位置就好了。

  1. #define COCO_PATCHCTX(coco, buf, func, stack, a0) \
  2. buf[0] = (int)(func); \
  3. buf[1] = (int)(stack); \
  4. stack[0] = (size_t)(a0);

stack[0] = (size_t)(a0); 这一句中的 a0 就是 lua_State 的指针,把他放到了 SP[0] 这里。

第二个问题就是对 jmp_buf 进行 hack 了,我们需要搞明白 jmp_buf 里怎么放东西的,这需要看这个平台编译器的源码了。https://github.com/pfalcon/esp-open-sdk 这里是编译器的源代码,他是使用 crosstool 的,会一边下载源代码,一遍编译,下载好的源代码放在 crosstool-NG/.build/src/newlib-2.0.0 ,正好我们看看 newlib 中 setjmp 的实现:

  1. #else /* CALL0 ABI */
  2. .text
  3. .align 4
  4. .literal_position
  5. .global setjmp
  6. .type setjmp, @function
  7. setjmp:
  8. s32i a0, a2, 0
  9. s32i a1, a2, 4
  10. s32i a12, a2, 8
  11. s32i a13, a2, 12
  12. s32i a14, a2, 16
  13. s32i a15, a2, 20
  14. movi a2, 0
  15. ret
  16. .size setjmp, . - setjmp
  17. .align 4
  18. .literal_position
  19. .global longjmp
  20. .type longjmp, @function
  21. longjmp:
  22. l32i a0, a2, 0
  23. l32i a12, a2, 8
  24. l32i a13, a2, 12
  25. l32i a14, a2, 16
  26. l32i a15, a2, 20
  27. l32i a1, a2, 4
  28. /* Return val ? val : 1. */
  29. movi a2, 1
  30. movnez a2, a3, a3
  31. ret
  32. .size longjmp, .-longjmp
  33. #endif /* CALL0 ABI */

上面的 else 宏代表这段代码是使用 CALL0 ABI 才会被编译。看一下代码,可以了解到以下对应关系:

jmp_buf Register Use
jup_buf[0] a0 Return Address
jmp_buf[1] a1 Stack Pointer (callee-saved)
jmp_buf[2] - jmp_buf[5] a12 - a15 Callee-saved

所以这段代码也就不难理解了:

  1. #define COCO_PATCHCTX(coco, buf, func, stack, a0) \
  2. buf[0] = (int)(func); \
  3. buf[1] = (int)(stack); \
  4. stack[0] = (size_t)(a0);

分别是把 buf[0]buf[1] 改成 coco_main 和 我们为协程新申请的栈。

其他代码基本上是 copy coco 的,详细的可以看我 github:

https://github.com/zwh8800/nodemcu-firmware/commit/1f31aa32901f07b6414c0471eb19f7bdd44d93a6

[EOF] 基本上这次移植涉及到的内容就这些,完。


发表于 2017-05-30 13:38 PM,最后更新于 2017-10-19 18:39:30 PM。

本文使用 署名 - 非商业性使用 - 相同方式共享 4.0 国际 协议


评论加载中...

首页