GCC - 修改函数 return 后继续执行的位置

GCC - modify where execution continues after function return

在 GCC 中可以做这样的事情吗?

void foo() {
    if (something()) returnSomewhereElse;
    else return;
}

void bar() {
    foo();
    return; // something failed, no point of continuing
    somewhereElse:
    // execution resumes here if something succeeds
    // ...
}

只是为了阐明意图 - 它是关于错误处理的。这个例子是一个最小的例子,只是为了说明事情。我打算在更深层次的上下文中使用它,以便在发生错误时停止执行。我还假设状态没有改变,我可能错了,因为在两个 return 点之间没有添加额外的局部变量,所以我希望编译器生成的代码在 foo 上执行此操作return 可以重复使用,节省使用 longjmp、设置和传递跳转缓冲区的开销。

示例 "does make sense" 因为它的目的是展示我想要实现的目标,而不是为什么以及如何在实际代码中有意义。

Why is your idea simpler of better then simply returning a value from foo() and having bar() either return or execute somewhereElse: conditionally?

它并不简单,你的建议在实践中不适用,只适用于一个简单的例子,但它更好,因为:

1 - 它不涉及额外的 return 值

2 - 它不涉及对值的额外检查

3 - 它不涉及额外的跳跃

在所有的澄清和解释之后,我可能错误地认为此时目标应该很明确。这个想法是在没有任何额外开销的情况下从深度调用链提供 "escape code path" 。通过重用编译器生成的代码来恢复先前调用帧的状态,并简单地修改函数 returns 之后恢复执行的指令。成功跳过"escape code path",出现的第一个错误进入

if (failure) return; // right into the escape code path
else {
    doMagickHere(); // to skip the escape code path
    return; // skip over the escape code path
}

//...
void bar() {
    some locals;
    foo();
    // enter escape code path here on foo failure so
    destroy(&locals); // cleanup
    return; // and we are done
    skipEscapeCodePath: // resume on foo success
    // escape path was skipped so locals are still valid
}

至于 Basile Starynkevitch 声称 longjmp 是 "efficient" 并且 "Even a billion longjmp remains reasonable" - sizeof(jmp_buf) 给了我 156 个字节,这显然是 space 需要保存几乎所有的寄存器和一堆其他东西,以便以后可以恢复。这些操作很多,做十亿次远远超出我个人对 "efficient" 和 "reasonable" 的理解。我的意思是,十亿个跳转缓冲区本身 超过 145 GB 的内存,然后还有 CPU 时间开销。没有多少系统甚至可以负担得起那种 "reasonable".

不,这在可移植性上是不可能的,我不确定你到底想达到什么目的。

术语

也许您想要 non-local 跳跃。仔细阅读 Scheme 中的 setjmp.h, coroutines, call stack, exception handling, continuations, and continuation-passing-style. Understanding what call/cc is 应该 非常 有益。

setjmplongjmp

setjmp and longjmp are standard C99 functions (and they are quite fast, because the saved state is actually quite small). Be quite careful when you use them (in particular to avoid any memory leak). longjmp (or the related siglongjmp in POSIX) 是可移植标准C99中的唯一方式 从某些函数中逃脱 并返回到某些调用方。

The idea is to provide an "escape code path" from a deep call chain without any additional overhead

正是longjmpsetjmp的作用。两者都是快速的 constant-time 操作(特别是使用 longjmp 展开数千个调用帧的调用堆栈需要很短且恒定的时间)。内存开销 实际上 每个捕获点一个本地 jmp_buf,没什么大不了的。 jmp_buf 很少放在调用堆栈之外。

一种有效使用它们的常见方法是将 setjmp-ed jmp_buf 放在本地 struct 中(因此在您的调用框架中)并将指针传递给它struct 到某些内部 static 函数,这些函数会在出错时间接调用 longjmp。因此,setjmplongjmp 可以通过 明智的编码约定 ,很好且有效地模仿 C++ 异常抛出和处理(或 Ocaml 异常,或Java 个异常,它们都具有与 C++ 不同的语义)。它们是足以满足此目的的便携式基本积木。

实际上,代码如下:

  struct my_foo_state_st {
    jmp_buf jb;
    char* rs;
    // some other state, e.g a ̀ FILE*` or whatever
  };

  /// returns a `malloc̀ -ed error message on error, and NULL on success
  extern const char* my_foo (struct some_arg_st* arg);

struct my_foo_state_st私有状态。 my_foopublic 函数 (您将在某些 public header 中声明)。您确实记录了(至少在评论中)它 return 是失败时的堆分配错误消息,因此 调用者 负责释放它。成功时,您记录了它 returns NULL。当然,您可以有其他约定和其他参数 and/or 结果。

我们现在声明并实现一个错误函数,它将错误消息打印到状态中并使用 longjmp

转义
  static void internal_error_printf (struct my_foo_state*sta, 
       int errcode, 
       const char *fmt, ...) 
   __attribute__((noreturn, format(printf(2,3))));

  void internal_error_printf(struct my_foo_state*sta, 
       int errcode, const char *fmt, ...) {
    va_arg args;
    va_start(args, fmt);
    vasprintf(&sta->rs, fmt, args);
    va_end(args);
    longjmp(sta->jb, errcode);
  }

我们现在有几个可能很复杂的递归函数来完成大部分工作。我只是画出它们的草图,你知道你想让它们做什么。当然你可能想给他们一些额外的论据(这通常很有用,这取决于你)。

  static void my_internal_foo1(struct my_foo_state_st*sta) {
    int  x, y;
    // do something complex before that and compute x,y
    if (SomeErrorConditionAbout(sta))
       internal_error_printf(sta, 35 /*error code*/,
                            "errror: bad x=%d y=%d", x, y);
    // otherwise do something complex after that, and mutate sta
  }

  static void my_internal_foo2(struct my_foo_state_st*sta) {
    // do something complex 
    if (SomeConditionAbout(sta))
       my_internal_foo1(sta);
    // do something complex and/or mutate or use `sta`
  }

(即使你有几十个像上面这样的内部函数,你也不会在其中任何一个中使用 jmp_buf;你也可以在它们中进行相当深入的递归。你只是需要在所有这些中传递一个 pointer -to struct my_foo_state_st,如果你是 single-threaded 并且不关心重入,你可以将该指针存储在一些 static 变量...或一些 thread-local 变量,甚至没有在某些参数中传递它,我发现它仍然更可取 - 因为更多 re-entrant 和线程友好)。

最后,这里是 public 函数:它设置状态并执行 setjmp

  // the public function
  const char* my_foo (struct some_arg_st* arg) {
     struct my_state_st sta;
     memset(&sta, 0, sizeof(sta));
     int err = setjmp(sta->jb);
     if (!err) { // first call
       /// put something in `sta` related to ̀ arg̀ 
       /// start the internal processing
       //// later,
       my_internal_foo1(&sta);
       /// and other internal functions, possibly recursive ones
       /// we return NULL to tell the caller that all is ok
       return NULL;
     }
     else { // error recovery
       /// possibly release internal consumed resources
       return sta->rs;
     };
     abort(); // this should never be reached
  }

注意你可以调用你的 my_foo 十亿次,它不会在没有失败时消耗任何堆内存,并且堆栈会增长一百字节(在 returning 之前释放来自 my_foo)。即使您的私人代码调用了十亿次 internal_error_printf 失败了十亿次,也不会发生内存泄漏(因为您 记录了 my_foo 是 returning 一个错误字符串,如果编码正确,调用者 应该free)。

因此正确使用setjmplongjmp十亿次吃很多内存(一个单个本地jmp_buf的调用堆栈上只有几百个字节,它在my_foo函数上弹出return)。事实上,longjmp 比普通的 return 稍微贵一点(但它可以进行转义,而 return 不会),因此您更愿意在错误情况下使用它。

但是使用setjmplongjmp棘手但是高效 和可移植性,并使您的代码 难以理解 ,如 setjmp 所述。非常认真地评论它很重要。巧妙而明智地使用这些 setjmplongjmp 不需要 "gigabytes" 内存,正如编辑问题中错误所说的那样(因为你 只消耗一个 jmp_buf 在调用堆栈上 ,而不是数十亿)。如果您想要更复杂的控制流,您将在调用堆栈中的每个动态 "catch point" 处使用本地 jmp_buf(您可能会有几十个,而不是数十亿个)。只有在递归数百万个调用帧的假设情况下,您才需要数百万个 jmp_buf,每个调用帧都是一个捕获点,这是不现实的(您永远不会有深度为一百万的递归,即使没有任何异常处理)。

参见 this for a better explanation of setjmp for "exception" handling in C (and SFTW for other ones). FWIW, Chicken Scheme has a very inventive usage of longjmp and setjmp (related to garbage collectioncall/cc !)


备选方案

setcontext(3) 可能是 POSIX 但现在已经过时了。

[=118=GCC has several useful extensions (some of them understood by Clang/LLVM) : statement exprs, local labels, labels as values and computed goto, nested functions, constructing function calls

(我的感觉是你误解了一些概念,特别是调用堆栈的确切作用,所以你的问题很不清楚;我给了一些有用的参考)

returning a small struct

另请注意,在某些 ABI 上,特别是 Linux 上的 x86-64 ABI,returning small struct(例如两个指针,或一个指针和一个 intlongintptr_t 数字)是 非常有效 (因为两个指针或整数通过寄存器),你可以利用它:决定你的函数 return 是一个指向主要结果的指针和一些错误代码,两者都打包在一个小的 struct:

struct tworesult_st {
 void* ptr;
 int err;
};

struct towresult_st myfunction (int foo) {
  void* res = NULL;
  int errcode = 0;
  /// do something
  if (errcode) 
    return (struct tworesult_st){NULL, errcode};
  else
    return (struct tworesult_st){res, 0};
}       

在 Linux/x86-64 上,上面的代码在两个寄存器中被优化(当使用 gcc -Wall -O 编译时)到 return(没有为 returned struct).

使用这样的函数简单且高效(不涉及内存,两个成员 ̀ struct` 将在处理器寄存器中传递)并且可以像这样简单:

struct tworesult_st r = myfunction(34);
if (r.err) 
  { fprintf(stderr, "myfunction failed %d\n", r.err); exit(EXIT_FAILURE); }
else return r.ptr;

当然你可以有更好的错误处理(这取决于你)。

其他提示

阅读更多关于 semantics 的内容,特别是操作语义。

如果可移植性不是主要问题,请研究 calling conventions of your system and its ABI and the generated assembler code (gcc -O -Wall -fverbose-asm foo.c then look inside foo.s) , and code the relevant asm instructions

也许 libffi 可能相关(但我仍然不明白你的目标,只是猜测)。

您可以尝试使用标签 exprs 和计算的 goto,但除非您理解生成的汇编代码,否则结果可能不是您所期望的(因为堆栈指针在函数调用和 returns 时发生变化)。

Self-modifying code is frowned upon (and "impossible" in standard C99), and most C implementations put the binary code in a read-only code segment. Read also about trampoline functions. Consider perhaps JIT compiling techniques, à la libjit, asmjit, GCCJIT.

(我坚信,对于您的担忧,实用的答案是 longjmp 合适的编码约定 ,或者只是 return ing 一个小 struct;两者都可以以非常有效的方式便携使用,我无法想象它们不够有效的情况)

某些语言:具有 call/cc 的 Scheme、具有回溯功能的 Prolog 可能更适合(比 C99)OP 的需要。

经过深思熟虑,它并不像最初看起来那么简单。有一件事阻止它工作——函数代码不是上下文感知的——没有办法知道调用它的框架,这有两个含义:

1 - 如果不可移植,修改指令指针很容易,因为每个实现都为它定义了一个一致的位置,它通常是堆栈上的第一件事,但是修改它的值以跳过逃逸陷阱也会跳过恢复前一帧状态的代码,因为该代码在那里,而不是在当前帧中 - 它无法执行状态恢复,因为它没有它的信息,如果要进行额外的检查和跳转,则可以采取补救措施省略是在两个位置复制状态恢复代码,不幸的是,这只能在汇编中完成

2 - 需要跳过的指令数量也是未知的,并且取决于前一个堆栈帧,根据需要销毁的局部变量的数量,它会有所不同,它不会是一个统一的值,补救措施是在调用函数时将错误和成功指令指针压入堆栈,这样它就可以根据是否发生错误来恢复一个或另一个。不幸的是,这也只能在汇编中完成。

似乎这样的方案只能在level编译器上实现,需要自己的调用约定,压入两个return位置,并在两个位置插入状态恢复代码。这种方法的潜在节省几乎不值得编写编译器的努力。