0%

把闭包变成函数指针——libffi 闭包原理解析

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;

// some operation

register_callback(callback, ctx);

// other operation

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](){
// play with Context without any concern.
printf("Context id=%d\n", ctx->id);
});

// no need to free context.
}

现在我们可以传入一个 std::function 也就是一个闭包作为回调函数,并且享受到 C++ 各种 modern 的工具。

一个闭包一般来说就包括一个函数指针和对应的上下文(比如捕获的变量等等),而原来接口签名中的 callbackdata 就对应了这两点。

裸函数指针

但是, 并不是所有的回调函数 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 包括两个重要的字段 codedata。其中 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 */

所以秘密就是:codedata 之间的偏移是固定的。因此,函数指针在被当作回调函数使用的同时,它同样也是回调函数数据的地址(加上偏移后)。在准备好回调函数和回调函数对应数据之后,程序通过 jmp *%r10 跳转到其他的代码。

另外的问题是:这个偏移是怎么计算出来的?首先为了保持 codedata 之间固定的偏移,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;

/*
* Allocate a trampoline table just to make sure that the trampoline code
* table can be mapped.
*/
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;

/*
* Create an anonymous mapping twice the map size. The top half will be used
* for the code table. The bottom half will be used for the parameter table.
*/
addr = mmap (NULL, tramp_globals.map_size * 2, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

if (addr == MAP_FAILED)
return 0;

/*
* Replace the top half of the anonymous mapping with the code table mapping.
*/
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。