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 的情况进行分析。

首先给出完整的代码:

#include <stdio.h>
#include <assert.h>
FILE *log_func_call;
typedef int (*func_t)(int arg);
int foo(int a) {
 return a + 1;
}
func_t create_wrap_function(func_t f) {
 int wrapped(int arg) {
 // call original function
 int val = f(arg);
 fprintf(log_func_call, "arg: %d ret: %d", arg, val);
 return val;
 }
 return wrapped;
}
int main(int argc, char* argv[]) {
 assert(log_func_call = fopen("log_func_call", "w"));
 func_t bar = create_wrap_function(foo);
 printf("%d\n", bar(2));
 return 0;
}

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

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

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

(gdb) b main
Breakpoint 1 at 0x40065b: file wrap-function.c, line 23.
(gdb) r
Starting program: /home/alex/tmp/wrap-function
Breakpoint 1, main (argc=1, argv=0x7fffffffea18) at wrap-function.c:23
23 assert(log_func_call = fopen("log_func_call", "w"));
(gdb) n 2
25 printf("%d\n", bar(2));
(gdb) p bar
1ドル = (func_t) 0x7fffffffe8c8
(gdb) x/3i bar
 0x7fffffffe8f8: mov 0ドルx400612,%r11d
 0x7fffffffe8fe: movabs 0ドルx7fffffffe8f0,%r10
 0x7fffffffe908: rex.WB jmpq *%r11
(gdb) x/8i 0x400612
 0x400612 <wrapped>: push %rbp
 0x400613 <wrapped+1>: mov %rsp,%rbp
 0x400616 <wrapped+4>: sub 0ドルx20,%rsp
 0x40061a <wrapped+8>: mov %edi,-0x14(%rbp)
 0x40061d <wrapped+11>: mov %r10,%rax
 0x400620 <wrapped+14>: mov (%rax),%rax
 0x400623 <wrapped+17>: mov -0x14(%rbp),%edi
 0x400626 <wrapped+20>: callq *%rax

在执行 “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 吧。)

#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <assert.h>
// This code can now only work on Linux system.
// It has some problem on OS X. Maybe I'll fix it someday.
#if __WORDSIZE != 64 || !defined(__x86_64__)
#error "This program only works on IA64 machine."
#endif
FILE *log_func_call;
typedef int (*func_t)(int arg);
int foo(int a) {
 return a + 1;
}
struct trampoline_code {
 char mov_target[2];
 uint32_t target;
 char mov_env[2];
 uint64_t env; // value stored in r10
 char jmp[3];
} __attribute__((packed));
typedef struct trampoline_code trampoline_code;
void print_trampline_code(const char *func_msg, trampoline_code *tramp) {
 printf("%s\t", func_msg);
 printf("trampoline_code: target = %x, env = %lx\n",
 tramp->target, tramp->env);
}
void *create_closure(void *f) {
 trampoline_code *tramp = (trampoline_code *)f;
 print_trampline_code("create_closure:tramp", tramp);
 // XXX I guess the value stored in r10 is the address of the variable
 // which resides on the top on the stack in all the variables used but
 // out of nested function's scope.
 // So we can guess which part of the stack env need to be copied
 int env_size = (uint8_t *)tramp - (uint8_t *)tramp->env;
 printf("env_size = %d\n", env_size);
 // XXX You may notice that tramp is changed here if create_closure is used
 // incorrectly
 print_trampline_code("create_closure:tramp", tramp);
 // The closure's "environment"
 uint8_t *env = malloc(env_size);
 assert(env);
 memcpy(env, (uint8_t *)tramp->env, env_size);
 // Copy trampoline code
 trampoline_code *new_tramp = malloc(sizeof(trampoline_code));
 assert(new_tramp);
 new_tramp->mov_target[0] = 0x41;
 new_tramp->mov_target[1] = 0xbb;
 // Get the target address from the trampoline code
 new_tramp->target = tramp->target;
 new_tramp->mov_env[0] = 0x49;
 new_tramp->mov_env[1] = 0xba;
 // Set new_trampironment
 new_tramp->env = (uint64_t) env;
 new_tramp->jmp[0] = 0x49;
 new_tramp->jmp[1] = 0xff;
 new_tramp->jmp[2] = 0xe3;
 print_trampline_code("create_closure:new_tramp", new_tramp);
 return new_tramp;
}
void destory_closure(void *f) {
 trampoline_code *tramp = (trampoline_code *)f;
 if (tramp) {
 free((void *)tramp->env);
 free(tramp);
 }
}
func_t create_wrap_function(func_t f) {
 // Nested function declaration should use "auto".
 auto int wrapped(int arg);
 int wrapped(int arg) {
 // call original function
 int val = f(arg);
 printf("Inside wrapped function\n");
 fprintf(log_func_call, "arg: %d ret: %d\n", arg, val);
 return val;
 }
 print_trampline_code("wrapped function", (trampoline_code *)wrapped);
 return (func_t)create_closure(wrapped);
 // XXX If we return the trampoline code and call create_closure outside this
 // function, the stack containing the trampoline code may be changed.
 // So must call create_closure inside this function.
 //return wrapped;
}
int main(int argc, char* argv[]) {
 assert(log_func_call = fopen("log_func_call", "w"));
 // XXX The next line does not work if create_wrap_function just returns the
 // wrapped function's trampoline code
 //func_t bar = create_closure(create_wrap_function(foo));
 func_t bar = create_wrap_function(foo);
 printf("%d\n", bar(2));
 destory_closure(bar);
 return 0;
}

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

Please enable JavaScript to view the comments powered by Disqus.

AltStyle によって変換されたページ (->オリジナル) /