Random Tech Thoughts

The title above is not random

在 C 语言中包装函数 -- Closure 和 GCC Nested Function

NOTE:gist 上的代码是不是没法输出到 RSS?如果看不到代码麻烦直接在浏览器打开吧。

最近在项目中遇到一个问题,我希望对程序中的一些函数添加日志记录功能(只需记录类似参数这样与函数内部逻辑无关信息),这些函数都存放在一个函数指针数组中,所有对这些函数的调用都通过从这个数组中取出对应的项来完成。

为完成这个任务有如下几种方案:

  1. 修改每一个需要添加日志记录的函数,这需要修改很多的函数
  2. 找到所有调用函数的代码,记录日志。这个方案可行性很差,由于函数指针可能被赋值给其他变量,找出所有的调用很困难,非常容易遗漏
  3. 在将函数存入函数指针数组时,对原先的函数进行包装(wrap function),添加日志记录的功能。之后通过函数指针数组的调用其实都是调用这个包装过的函数。由于函数指针数组赋值在项目代码中只有一处,所以这个方法需要进行的改动最小。

第三个方案有点 AOP 的影子,可以做的很漂亮,但是很难在 C 语言中实现。如果 C 提供 closure,下列代码即可对函数进行包装达到目的:

1
2
3
4
5
6
7
8
9
10
11
12
// 被包装函数的原型
typedef int (*func_t)(int arg);

// 创建包装过函数
func_t create_wrap_function(func_t f) {
    int wrapped(int arg) {
        int val = f(arg); // 调用原函数
        // 记录日志
        return val; // 返回原函数的返回值
    }
    return wrapped; // 返回包装过的函数
}

GCC nested function

其实上述代码在 GCC 中能够编译通过,这是由于 GCC 支持 nested function 扩展。Jserv‘s blog 中有对此进行介绍的文章,不过他是对 32bit linux 系统进行分析。(推荐 Jserv 的博客,有很多不错的技术文章。)可能由于术语使用差异的原因,我觉得他对跳板代码的解释有点难以理解。我参考他的文章和例子代码,根据我自己的探索和理解过程在此对 64bit 的情况进行分析。

首先给出完整的代码:

主要要考虑的问题有两个:

  1. create_wrap_function 如何将内嵌的函数的地址返回出去
  2. 包装函数 wrapped 如何访问其作用域外的变量,在例子中是 f,这个变量是“父函数” create_wrap_function 中的变量

用 gcc -g 编译以后再用 gdb 来执行,过程如下:

在执行 “n 2” 之后,函数指针 bar 的值是一个栈上的地址,这说明可能内嵌函数的代码是存放在栈上的。于是反汇编 bar,从得到的三条指令来看,这个栈上的内容不是一个函数。注意到最后一条指令跳转到另一段代码执行,我们反汇编跳转的目标,gdb 给出的信息说明这段代码是内嵌的 wrapped 函数。由此可以看出,对内嵌函数,gcc 将其编译成一个普通的函数;当我们获取内嵌函数的地址时,我们得到的是一段跳板代码,执行这段代码时会跳到内嵌函数执行。通过在 gdb 中执行 “x/30i create_wrap_function” 可以看到,wrapped 函数紧跟在 create_wrap_function 之后。跳板代码其他指令的作用等我们回答完第二个问题是也就清楚了。

为回答第二个问题,我们看 wrapped 函数是如何去调用原函数的。该函数中的第一个 call 指令完成对 wrapped 的调用,从反汇编的代码来看,这是一个 call to absolute address,而这个绝对地址从 rax 寄存器中得到。因此追溯 raxwrapped 的操作情况即可知道原函数地址的来源。可以看到 mov %r10,%raxrax 的来源,而 r10 寄存器应该是在执行 wrapped 函数之前就已经设置好的。回顾 bar 的反汇编代码,可以看到第二条指令就是设置 r10 寄存器的,而且是将一个栈地址存入 r10。通过 gdb 跟踪,可以发现这个栈地址是 create_wrap_function 运行时栈上的一个地址,而且保存了它的第一个参数。(修改例子代码,让内嵌函数访问多个 scope 外的变量,从反汇编的代码推测 r10 寄存器中存放的应该是内嵌函数所有用到的 scope 外变量中在栈最顶部变量的地址。)

第二个问题的答案到此也就清楚了,gcc 将内嵌函数需要访问的 scope 之外的变量的地址存放在 r10 寄存器中,然后再调用内嵌函数。跳板代码在执行跳转指令之前就会完成这个设置。

如果我们在 create_wrap_function 中直接调用 wrapped 函数,也可以在反汇编代码中看到调用 wrapped 之前有设置 r10 的指令,但是这里是不会有跳板代码。跳板代码在取函数地址的时候才会生成在栈上,如果没有跳板代码,通过函数指针进行函数调用是就会因为 r10 没有正确设置而出错。

利用跳板代码来实现 closure

简单来说,closure = function + data,这些 data 是函数正常运行所需要的。GCC nested function 不是闭包的原因在于内嵌函数需要访问的 scope 之外的变量存放于栈上,一旦栈被修改,内嵌函数就不能正常运行。

Jserv 的博客中使用的技巧是自己分配一块内存来保存函数需要的数据(用 SICP 里的模型来看类似函数求值的环境),利用 GCC 生成的跳板代码来为函数设置好环境以便能够访问到这些数据。跳板代码关键的部分是两个,一是跳转地址,二是内嵌函数 scope 外变量的地址。下面给出按照 Jserv 的博客中的思路,在 64bit 系统上实现类似 closure 功能的代码。(在 Linux 下测试通过,在 OS X 下有点问题,以后或许会 fix 吧。)

create_closure 将 nested function 需要用到的 scope 外的变量拷贝到 heap 上分配的内存,并且将跳板代码也拷贝到 heap 上。这样得到的函数指针(其实是跳板代码)就可以在任何地方使用而不用担心栈被破坏。

为获取跳板代码的二进制编码,在 gdb 中可以用 dump memory 命令将跳板代码保存到文件,然后用 objdump -m i386:x86-64 -b binary 反汇编得到。

在实现这段代码时遇到过一个 bug,create_closure 一定要在定义 nested function 的函数中调用,否则跳板代码所在的栈可能被修改而导致 create_closure 中不能得到正确的 target。

一种失败的尝试

我还尝试过另外一种包装函数的方法。先创建一个单独的普通函数,在其中对原函数的调用通过一个 magic number 来完成。创建包装函数时,通过 memcpy 将上述函数的二进制代码拷贝到 heap 上,用 memmem (gcc 扩展函数) 找到这个 magic number 然后修改成被包装函数的地址。但是不幸的是 64bit 系统上的变量寻址都是通过 rip relative 的方式进行的(包括函数调用,用函数指针虽然可以使得编译器用绝对地址调用,但取得函数指针要访问的变量还是没法绕过 rip relative 的寻址方式),所以这种方式只能完成最最简单的包装函数,而且代码有点丑陋。

关于 64bit 系统上的寻址模式,可以参考 Most data references in x64 are RIP-relative

结论

这种 closure 的实现方式是体系结构,OS,编译器相关的,非常不通用。不到必要的时候还是不要随便使用。

包装函数也可通过用 Tiny C Compiler (TCC) 动态编译代码的方式来实现,这在下一篇文章中介绍。

Comments