# stack overflow on cgocall

在go程序中调用c语言代码的场景中,有时候会出现more stack on g0的错误,这中错误十分常见,比如下面这个程序就可以触发。

/*
static int increase(int a) {
   return goIncrease(a++);
}
*/
import "C"

func main() {
   goIncrease(0)
}

//export goIncrease
func goIncrease(a C.int) C.int {
   a++
   if a > 2500 {
      return a
   }
   return C.increase(a)
}

该程序中goIncrease调用c语言定义的函数increase,increase又调用了go中的代码goIncrease,这样循环调用下去,直到a大于2500时退出。运行该程序,报出下面的错误

fatal: morestack on g0
SIGTRAP: trace trap
PC=0x4054832 m=0 sigcode=1
signal arrived during cgo execution

goroutine 1 [running, locked to thread]:
runtime.abort()
        /usr/local/Cellar/go/1.14.2_1/libexec/src/runtime/asm_amd64.s:859 +0x2 fp=0x7ffeefb19170 sp=0x7ffeefb19168 pc=0x4054832
runtime.morestack()
        /usr/local/Cellar/go/1.14.2_1/libexec/src/runtime/asm_amd64.s:416 +0x25 fp=0x7ffeefb19178 sp=0x7ffeefb19170 pc=0x4052ef5

rax    0x17
rbx    0x7ffeefb19140
rcx    0x40dc720
rdx    0x0
rdi    0x2
rsi    0x7ffeefb190e0
rbp    0xc000260388
rsp    0x7ffeefb19168
......

错误显示主M中的主goroutine也就是G0调用runtime.morestack()申请更多的栈空间,这个函数导致程序崩溃。

在smart chain的测试过程中也遇到了类似的问题,具体情况是这样:

evmone 是Ewasm团队用C++写的EVM实现。smart chain使用该项目来执行evm code。由于smart chain本身是go语言编写,这就引入了在go代码中调用c++代码的问题。

evmc项目实现了一个binding层,它对外暴露go语言接口,内部使用cgo来调用c代码,而c代码文件中又wrap了c++实现。

#evmc/bindings/go/evmc/evmc.go
package evmc

/*
#cgo CFLAGS:  -I${SRCDIR}/.. -Wall -Wextra

#include <evmc/evmc.h>
#include <evmc/helpers.h>
#include <evmc/loader.h>
......

static struct evmc_result execute_wrapper(struct evmc_vm* vm,
    uintptr_t context_index, enum evmc_revision rev,
    enum evmc_call_kind kind, uint32_t flags, int32_t depth, int64_t gas,
    const evmc_address* destination, const evmc_address* sender,
    const uint8_t* input_data, size_t input_size, const evmc_uint256be* value,
    const uint8_t* code, size_t code_size, const evmc_bytes32* create2_salt)
{
    struct evmc_message msg = {
        kind,
        flags,
        depth,
        gas,
        *destination,
        *sender,
        input_data,
        input_size,
        *value,
        *create2_salt,
    };

    struct evmc_host_context* context = (struct evmc_host_context*)context_index;
    return evmc_execute(vm, &evmc_go_host, context, rev, &msg, code, code_size);
}
*/
import "C"
func (vm *VM) Execute(ctx HostContext, rev Revision,
......
   result := C.execute_wrapper(vm.handle, C.uintptr_t(ctxId), uint32(rev),
      C.enum_evmc_call_kind(kind), flags, C.int32_t(depth), C.int64_t(gas),
      &evmcDestination, &evmcSender, bytesPtr(input), C.size_t(len(input)), &evmcValue,
      bytesPtr(code), C.size_t(len(code)), &evmcCreate2Salt)
   removeHostContext(ctxId)
......
}

VM的Execute方法中调用了C伪包中定义的execute_wrapper函数,execute_wrapper函数中调用的是evmc/bindings/go/evmc/helpers.h中定义的evmc_execute函数

static inline struct evmc_result evmc_execute(struct evmc_vm* vm,
                                              const struct evmc_host_interface* host,
                                              struct evmc_host_context* context,
                                              enum evmc_revision rev,
                                              const struct evmc_message* msg,
                                              uint8_t const* code,
                                              size_t code_size)
{
    return vm->execute(vm, host, context, rev, msg, code, code_size);
}

evmc_execute函数中wrap了vm.execute函数指针指向的代码段,这个指针在创建虚拟机时被赋值为如下函数

#lib/evmone/execution.cpp
evmc_result execute(evmc_vm* /*unused*/, const evmc_host_interface* host, evmc_host_context* ctx,
    evmc_revision rev, const evmc_message* msg, const uint8_t* code, size_t code_size) noexcept
{
......
   if(op!=OP_BEGINBLOCK) {
       for(int i = state->stack.size() - 1; i >= 0; i--) {
         ......
       }
   }
......
}

通过上面讲述的这样一系列binding,我们最终可以在go代码中调用c++库中的函数。

当我们尝试用smart chain运行以太坊官方项目中的智能合约测试用例时,在运行到stRandom2目录下的randomStatetest458.json时,程序崩溃,报出morestack on g0的错误。

这个用例中合约递归调用该合约自身,直到gas fee不足后再逐层退出调用。错误显示g0上当前栈空间不足,无法继续调用接下来的函数。

这里简单提一下go语言的栈管理。golang的调度器中有三个关键结构体,G,M,P。G代表一个goroutine,M对应一个os thread,P对应一个cpu核。当创建一个goroutine时,go运行时创建一个G,并分配kB量级的用户栈,这个栈是位于内存堆中的,如果G运行过程中需要更多的栈空间,最终会调用src/runtime/asm_amd64.s中的runtime·morestack方法来进行stack split。

TEXT runtime·morestack(SB),NOSPLIT,$0-0
   // Cannot grow scheduler stack (m->g0).
   get_tls(CX)
   MOVQ   g(CX), BX
   MOVQ   g_m(BX), BX
   MOVQ   m_g0(BX), SI
   CMPQ   g(CX), SI
   JNE    3(PC)
   CALL   runtime·badmorestackg0(SB)
   CALL   runtime·abort(SB)

   // Cannot grow signal stack (m->gsignal).
   MOVQ   m_gsignal(BX), SI
   CMPQ   g(CX), SI
   JNE    3(PC)
   CALL   runtime·badmorestackgsignal(SB)
   CALL   runtime·abort(SB)

但是要注意的是g0是不允许stack split的,g0不同于其他的g,它的栈是系统堆栈,当执行系统调用,cgocall,调度等任务时,会从普通g的栈上切换到g0的栈,这时的任务是不可抢占的,也不被垃圾收集器扫描,同时,系统堆栈是不支持split的,它在线程初始化时指定,一般是MB量级的。例如,开启了cgo后,创建一个新的M时会调用_cgo_sys_thread_start启动一个线程,可以看到栈大小已经在创建时指定。

#src/runtime/cgo/gcc_darwin_amd64.c
void _cgo_sys_thread_start(ThreadStart *ts)
{
    pthread_attr_t attr;
    sigset_t ign, oset;
    pthread_t p;

    pthread_attr_init(&attr);
    pthread_attr_getstacksize(&attr, &size);
    // Leave stacklo=0 and set stackhi=size; mstart will do the rest.
    ts->g->stackhi = size;
    err = _cgo_try_pthread_create(&p, &attr, threadentry, ts);
......
}

cgo生成的桩代码中使用cgocall调用cgo工具生成的c函数,cgocall最终会调用汇编代码.asmcgocall

TEXT ·asmcgocall(SB),NOSPLIT,$0-20

   // Switch to system stack.
   MOVQ   m_g0(R8), SI
   CALL   gosave<>(SB)
   MOVQ   SI, g(CX)
   MOVQ   (g_sched+gobuf_sp)(SI), SP

   // Now on a scheduling stack (a pthread-created stack).
   // Make sure we have enough room for 4 stack-backed fast-call
   // registers as per windows amd64 calling convention.
   SUBQ   $64, SP
   ANDQ   $~15, SP   // alignment for gcc ABI
   MOVQ   DI, 48(SP) // save g
   MOVQ   (g_stack+stack_hi)(DI), DI
   SUBQ   DX, DI
   MOVQ   DI, 40(SP) // save depth in stack (can't just save SP, as stack might be copied during a callback)
   MOVQ   BX, DI    // DI = first argument in AMD64 ABI
   MOVQ   BX, CX    // CX = first argument in Win64
   CALL   AX

可以看出c代码都是在g0栈上执行的,且在真正执行c代码之前go运行时会保存当前的g的地址,g的上下文,栈深度等信息到系统栈中。

CALL AX后在evmone的代码中又回调了smart chain中的go语言函数。这个是通过cgocallbackg实现的,它保证了这些调用都在同一个M上。

到目前为止,我们可以知道当代码不停的在go和c之间互相调用时,c语言的函数执行和go的运行时代码是执行在g0栈上的,栈上的内容除了c函数的调用栈外还有go运行时保存的g上下文信息等。而栈的大小在创建线程时指定,在运行中不会split,所以,如果不加以栈深度等限制的情况下,栈一定会溢出。而go不允许g0扩增栈的大小,这就是报出morestack on g0的原因。

如何解决呢?

一方面,由于evmone中虚拟机执行函数是固定的,它的栈大小可以近似成一个常量,同时go的运行时在进入到c代码执行前保存的信息也基本是常量,所以我们可以通过控制递归调用c代码的深度来防止栈溢出。

另一方面,当M被锁定在某个G上时,如果该G处于不可执行状态,那么M会释放掉P,runtime寻找新的M绑定P来运行P中的G。在cgocallbackg中runtime会将当前G与M锁定,我们只需要找到一种方式使得当前的G不可执行,就能释放P让新的M运行接下来的代码。例如下面的代码所示:

   f1 := func() {
      output, gasLeft, err = v.vm.Execute(...)
   }
   if v.isNewRound(depth) {
      var mtx sync.Mutex
      mtx.Lock()
      go func() {
         defer mtx.Unlock()
         //will call c function
         f1()
      }()
      mtx.Lock()
      mtx.Unlock()
      return
   }
   f1()
   return
}

我们通过在go回调函数中创建新的goroutine,在新建的goroutine中执行这部分被c代码调用的go代码。当前G在第二次尝试获取锁时失败,导致当前M释放掉P,新建的goroutine被绑定到新的M上运行。由于每个M都有一个独立的g0栈,这就相当于变相的动态扩充了系统栈的大小。这里的互斥锁同时也保证了最初的cgocall的调用顺序,程序还是顺序执行的。

结论:利用cgo调用c代码时要格外注意栈溢出问题,这点对于区块链应用更加重要。如果不加以合适的栈保护,一些恶意或有bug的合约代码在执行时会击穿g0栈,导致节点崩溃,链的活性就会受到影响。

**本文由CoinEx Chain开发团队成员helldealer撰写。CoinEx Chain是全球首条基于Tendermint共识协议和Cosmos SDK开发的DEX专用公链,借助IBC来实现DEX公链、智能合约链、隐私链三条链合一的方式去解决可扩展性(Scalability)、去中心化(Decentralization)、安全性(security)区块链不可能三角的问题,能够高性能的支持数字资产的交易以及基于智能合约的Defi应用。