Home

嵌入汇编如何捕获异常

15 Jan 2015 by LelouchHe

缘由

最近工作上遇到一个比较少见的问题,特地记录一下,以免下次被坑.

情景是这样的,我们要动态的调用任意函数,函数的类型是不定的,有可能是普通函数,也有复杂的比如成员函数或虚函数之类.参数倒是可以统一规划为64位的整数.

一个初步的想法就是利用嵌入汇编,手动的将函数调用栈构建好,直接跳转到对应方法.这个方法比较暴力,但效率非常高,基本等同于直接的函数调用,而且不需要添加额外的抽象层.

但这就带来一个问题: 我们的函数是C++函数,是有可能抛出异常的.而gcc文档中明确说到,这样的异常会让程序abort的.

那如何解决呢?

C++异常处理机制简介

假定开发环境是x86-64 Linux gcc 4.4.7(and above).我们先来看下gcc是如何处理异常的.

gcc有2套处理异常的方案:

  1. setjmp/longjmp: 利用标准库中的远程跳转函数,try...catch时使用setjmp保存环境,throw时用longjmp完成跳转,顺便延调用栈执行需要的析构操作.这个方法的初级版本比较简单,很多人在C下实现的异常处理机制,就是这么做的.一个难点是如何执行需要的析构.当然,如果在C下,就不用考虑这个了.C++下的话,大体上都是在保存环境时,保存尚未析构的对象信息和显式的栈信息,从而可以在longjmp时获取,并最后真正调用.这个方法的缺点是成本太高.不论我们最后是否抛出异常,setjmp及之后都必须保存大量的信息.这也是最开始异常处理让人觉得性能低下的一个原因所在.
  2. dwarf: 这是目前gcc的默认实现方案.在编译时,将异常信息保存在特定的section中,包括try的起止范围,对应的catch语句之类.在程序正常运行时,通过一个简单的jmp跳过catch的代码,因此当没有异常抛出时,完全没有性能问题.当抛出异常后,会从该section中得到异常处理需要的信息,从而完成最后的栈回滚,析构调用并回到catch语句中.

很明显,第2种方案比第1种方案好,但由于异常信息以特定的数据结构编码到特定的section,从而导致我们无法简单的进行操作.

下面都是以第2种方法为前提的讨论.dwarf是gcc的默认实现,一般都推荐用这个.

嵌入汇编的困难

嵌入汇编之所以无法处理异常,是因为就算我们在调用潜入汇编的代码外围加上try,但由于嵌入汇编这层上没有编译器添加的异常结构,导致异常回滚时,到这里就无法再前进了,就只能当作没有try块从而abort

比如,我们的代码如下:

extern "C" int add(int a, int b) {
    throw "in add";
}

int inline_add(int a, int b) {
    int r = 0;
    __asm__ __volatile__ (
        "movl %1, %%edi\n\t"
        "movl %2, %%esi\n\t"
        "call add\n\t"
        "movl %%eax, %0\n\t"
        : "=r"(r)
        : "r"(a), "r"(b)
        : "%eax"
    );
    return r;
}

就算我们用try包住:

try {
    inline_add(1, 1);
} catch (...) {
    // ...
}

由于inline_add这层调用上是没有任何异常处理信息的,所以程序会abort.

那能不能直接内联到try块里呢?这个我试过,是没有用处的.查看生成的汇编代码,和不内联的基本一样,没有异常信息.

gcc如何生成异常信息

通过内联的实验可以看出,异常信息并不是以try为标志来生成的.相反,是以throw的出现来生成的.

当我们如下编码时:

void just_throw() {
    throw 0;
}

try {
    just_throw();
} catch (...) {
    // ...
}

gcc会正常的生成异常信息.这给我们一个提示,异常信息对于try来说是一个整体(可以查看生成的汇编),那我们强行给嵌入汇编的try添加一个会throw的调用不就行了么?

try {
    inline_add(1, 1);
    just_throw();
} catch (...) {
    // ...
}

这个样子确实可以处理异常了,因为just_throw的出现,使得gcc给这个try块增加了异常信息.

其实细说起来,并不是这样的.比如如下:

try {
    just_throw();
    inline_add(1, 1);
} catch (...) {
    // ...
}

我们就又无法处理了.一是因为just_throw直接就抛出了,根本运行不到inline_add,二来通过生成的汇编来看,根本就没包含到我们的汇编代码.

应该说,just_throw放在后面能够生效,主要是inline_add之后没有其他任何的操作,从inline_add抛出的异常,其实可以等同于从just_throw抛出的,所以最后都能catch住.上下颠倒过来,自然就没这个等价了.

构建异常信息

通过上面的实验,我们需要一个抛出异常,但不抛出异常的函数调用,紧跟在inline_add之后,帮助构建异常信息,使之可以被catch.

最后的代码是这样的:

void build_exception_frame(bool b) __attribute__((optimize("O0")));
void build_exception_frame(bool b) {
    if (b) {
        throw 0;
    }
}

try {
    inline_add(1, 1);
    build_exception_frame(false);
} catch (...) {
    // ...
}

解释2点:

我们可以通过查看生成的汇编代码来确定上述的正确性.此处不赘言了

结语

虽然我们得到了可以运行的代码,但gcc为什么会这样,我还没有彻底搞懂.毕竟,哪有像上文描述的那么感性的编译器.所以,解析来可能要深入gcc的代码一探究竟了.

另,这个问题是我第一个stackoverflow上回答的问题.没想到,我也可以解决别人的困惑了,感觉真不错.