Callbacks
For English version of this post, see here .
一般来说,一个用 C 写的库可能会暴露接收一个函数指针作为回调函数,比如:
1 2 3 typedef void (*callback_t ) (void * data) ;void register_callback (callback_t callback, void * data) ;
然后我们可能会这样使用这个接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 typedef struct Context { int id; } Context; void callback (void * data) { Context* ctx = (Context*)data; printf ("Context id=%d\n" , ctx->id); } int main () { Context* ctx = malloc (sizeof (Context)); ctx->id = 114514 ; register_callback(callback, ctx); free (ctx); }
但是这样仍然不太方便,因为我们需要手动管理 data 指针的生命周期,一不小心就有可能会内存泄漏。
Closures
如果熟悉 C++ 的话,把这个接口用 std::function
包装一下基本上不会费太大的力气:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 typedef std::function<void ()> callback_fn;class CallbackManager {public : static CallbackManager* New (callback_fn fn) { return new CallbackManager (fn); } void operator () () { return this ->_fn(); } ~CallbackManager (){ delete this ; } private : callback_fn _fn; CallbackManager (callback_fn fn) : _fn(fn) {} }; void register_callback_wrapper (callback_fn callback) { CallbackManager* mgr = CallbackManager::New (callback); register_callback ([](void * data){ (*(CallbackManager*)data)(); }, mgr); }
然后我们就可以把之前的代码变得更加 modern 一些。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 typedef struct Context { int id; } Context; int main () { std::shared_ptr<Context> ctx = std::make_shared <Context>(); ctx->id = 114514 ; register_callback_wrapper ([ctx](){ printf ("Context id=%d\n" , ctx->id); }); }
现在我们可以传入一个 std::function
也就是一个闭包作为回调函数,并且享受到 C++ 各种 modern 的工具。
一个闭包一般来说就包括一个函数指针和对应的上下文(比如捕获的变量等等),而原来接口签名中的 callback
和 data
就对应了这两点。
裸函数指针
但是, 并不是所有的回调函数 API 都像这样设计。比如:
1 void register_callback_bad (callback_bad_t callback) ;
它只接受一个裸函数指针,意味着我们不能传入一个闭包因为没有地方存放闭包的上下文。类似的,C++
不会允许把 lambda
转换成函数指针如果它捕获了任意变量。
所以我们面临的挑战是:我们可以把一个闭包退化成函数指针吗?听起来不太可能但是最近我发现 libffi
确实实现 了这一点。
After calling ffi_prep_closure_loc
, you can cast codeloc
to the appropriate pointer-to-function type.
让我们看看 libffi
背后的技巧。
libffi 实现
Idea
正如上面所解释的,一个函数指针仅仅是一个地址,运行时并不携带任何和回调函数相关的上下文。因此,唯一的解决方案是分配一些静态内存然后在回调函数里访问,但是我们仍然需要办法区分不同的回调函数。你可能已经猜到了,没错,尽管不太常见,函数指针本身的地址可以作为一个唯一的标识。
基本上,libffi
的想法就是用额外的内存来存储闭包的数据,但是实际的设计非常有意思。
Trampoline
实现的核心概念是 Trampoline
,每个 trampoline
包括两个重要的字段 code
和 data
。其中 code
是注册的回调函数的入口点。它在内存里会有多份,所以会有多个不同的地址。data
是和回调函数对应的数据。下面是相关代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 #ifdef ENDBR_PRESENT # define X86_DATA_OFFSET 4077 # ifdef __ILP32__ # define X86_CODE_OFFSET 4069 # else # define X86_CODE_OFFSET 4073 # endif #else # define X86_DATA_OFFSET 4081 # ifdef __ILP32__ # define X86_CODE_OFFSET 4073 # else # define X86_CODE_OFFSET 4077 # endif #endif .align UNIX64_TRAMP_MAP_SIZE .globl trampoline_code_table FFI_HIDDEN(C(trampoline_code_table)) C(trampoline_code_table): .rept UNIX64_TRAMP_MAP_SIZE / UNIX64_TRAMP_SIZE _CET_ENDBR subq $16, %rsp /* Make space on the stack */ movq %r10, (%rsp) /* Save %r10 on stack */ #ifdef __ILP32__ movl X86_DATA_OFFSET(%rip), %r10d /* Copy data into %r10 */ #else movq X86_DATA_OFFSET(%rip), %r10 /* Copy data into %r10 */ #endif movq %r10, 8(%rsp) /* Save data on stack */ #ifdef __ILP32__ movl X86_CODE_OFFSET(%rip), %r10d /* Copy code into %r10 */ #else movq X86_CODE_OFFSET(%rip), %r10 /* Copy code into %r10 */ #endif jmp *%r10 /* Jump to code */ .align 8 .endr ENDF(C(trampoline_code_table)) .align UNIX64_TRAMP_MAP_SIZE #endif /* FFI_EXEC_STATIC_TRAMP */
所以秘密就是:code
和 data
之间的偏移是固定的。因此,函数指针在被当作回调函数使用的同时,它同样也是回调函数数据的地址(加上偏移后)。在准备好回调函数和回调函数对应数据之后,程序通过 jmp *%r10
跳转到其他的代码。
另外的问题是:这个偏移是怎么计算出来的?首先为了保持 code
和 data
之间固定的偏移,libffi
分配了两个连续的 4k (UNIX64_TRAMP_MAP_SIZE
) 内存区域。所以偏移就是 4k 减去从开始到现在所有指令长度的总和。假设我们在 Linux x86_64 上并且有 endbr64
支持,我们可以这样计算 X86_DATA_OFFSET
:
1 2 3 4 X86_DATA_OFFSET = 4096 - sizeof(`endbr64`) - sizeof(`subq $16, %rsp`) - sizeof(`movq %r10, (%rsp)`) - sizeof(`movq X86_DATA_OFFSET(%rip), %r10`) = 4096 - 4 - 4 - 4 - 7 = 4077
其中4077就是上面代码中定义的偏移。
更多细节
另外有意思的事情是 libffi
怎么找到 trampoline
表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 static int ffi_tramp_get_libffi (void ) { FILE *fp; char file[PATH_MAX], line[PATH_MAX+100 ], perm[10 ], dev[10 ]; unsigned long start, end, offset, inode; uintptr_t addr = (uintptr_t ) tramp_globals.text; int nfields, found; snprintf (file, PATH_MAX, "/proc/%d/maps" , getpid()); fp = fopen (file, "r" ); if (fp == NULL ) return 0 ; found = 0 ; while (feof (fp) == 0 ) { if (fgets (line, sizeof (line), fp) == 0 ) break ; nfields = sscanf (line, "%lx-%lx %9s %lx %9s %ld %s" , &start, &end, perm, &offset, dev, &inode, file); if (nfields != 7 ) continue ; if (addr >= start && addr < end) { tramp_globals.offset = offset + (addr - start); found = 1 ; break ; } } fclose (fp); if (!found) return 0 ; tramp_globals.fd = open (file, O_RDONLY); if (tramp_globals.fd == -1 ) return 0 ; if (!tramp_table_alloc ()) { close (tramp_globals.fd); tramp_globals.fd = -1 ; return 0 ; } return 1 ; }
libffi
读取了所有它自己的内存映射,定位了 text 内存并且计算了 trampoline
表的偏移。注意 tramp_globals.text
等于 &trampoline_code_table
。 然后,libffi
通过 mmap
来映射 trampoline
表。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 static int tramp_table_map (struct tramp_table *table) { char *addr; addr = mmap (NULL , tramp_globals.map_size * 2 , PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1 , 0 ); if (addr == MAP_FAILED) return 0 ; table->code_table = mmap (addr, tramp_globals.map_size, PROT_READ | PROT_EXEC, MAP_PRIVATE | MAP_FIXED, tramp_globals.fd, tramp_globals.offset); if (table->code_table == MAP_FAILED) { (void ) munmap (addr, tramp_globals.map_size * 2 ); return 0 ; } table->parm_table = table->code_table + tramp_globals.map_size; return 1 ; }
注意 trampoline
的代码在上述汇编代码里通过 .rept
重复了多次来填满 4k 的内存。
总结
用一张图总结 libffi
怎么把闭包变成函数指针。
更新(2021.08.01):这里 trampoline
机制实际上避免了分配可写+可执行权限的内存因为上述 tramp->code
的内容是在编译期生成的。
我的实现
为了检查我的理解是否正确,我也写了一个 demo。链接在这里 。
其中核心库不足100行,相比 libffi
来说应该很好理解。如果觉得有用的话可以给个 star。