2018年3月18日,Kevin Albertson 发布

C 语言中的简易智能指针(Stupid Smart Pointers in C)

[GitHub 仓库]

在 C 语言中管理内存既困难又容易出错。C++ 通过智能指针(如 std::unique_ptrstd::shared_ptr)解决了这个问题。本文展示了一个 C 语言中的概念验证(也就是简易版)智能指针,代码量非常少。期间,我们将了解 32 位 x86 调用栈的布局,并在 C 程序中编写汇编代码。

§1 C 语言中的内存管理

在 C 语言中,堆内存通过调用 malloc 进行分配,通过调用 free 进行释放。程序员有责任在不再使用已分配的内存时释放它。否则,内存泄漏会增加程序的内存使用量,耗尽宝贵的系统资源。

有时,很清楚在哪里调用 free

char *data = (char *) malloc (100);
// 对 data 进行一些操作,不再需要它
free (data);

但即使是简单的情况也可能难以正确释放。例如,假设函数 f 按顺序分配资源,并在返回之前释放它们。

void f () {
  char *resource_1 = get_resource ();
  if (resource_1 == NULL) return;
  char *resource_2 = get_resource ();
  if (resource_2 == NULL) {
   free (resource_1);
   return;
  }
  char *resource_3 = get_resource ();
  if (resource_3 == NULL) {
   free (resource_2);
   free (resource_1);
   return;
  }
  // 等等
}

每次返回都必须释放之前分配的所有内容。对于分配的每个额外资源,对 free 的调用列表都会增长。有一些方法可以组织它以减少一些冗余。但问题的根源仍然存在:已分配资源的生命周期与 f 返回的位置绑定。每当 f 返回时,我们需要保证所有这些资源都被释放。

Eli Bendersky 的文章中描述了一个不错的 C 语言解决方案:Using goto for error handling in C。它使用 goto 语句并将所有 free 调用放在函数的末尾。

void f () {
  char *resource_1 = NULL, *resource_2 = NULL, *resource_3 = NULL;
  resource_1 = get_resource ();
  if (resource_1 == NULL) return;
  resource_2 = get_resource ();
  if (resource_2 == NULL) goto free_resource_1;
  resource_3 = get_resource ();
  if (resource_3 == NULL) goto free_resource_2;
// 等等
free_resource_2:
  free (resource_2); // fall through
free_resource_1:
  free (resource_1);
  return;
}

但是 C++ 有更好的解决方案。由于对象有析构函数,我们可以显式地将指针的生命周期绑定到对象的生命周期。

void f () {
  auto resource_1 = std::unique_ptr<char> (get_resource ());
  if (resource_1.get () == nullptr) return;
  auto resource_2 = std::unique_ptr<char> (get_resource ());
  if (resource_2.get () == nullptr) return;
  auto resource_3 = std::unique_ptr<char> (get_resource ());
  if (resource_3.get () == nullptr) return;
  /* ... */
}

unique_ptr 对象包装已分配的指针,并在 unique_ptr 超出范围时释放它。

不幸的是,C 语言没有可以挂钩的析构函数,因此没有原生的智能指针。但是我们可以创建一个非常简单的近似值。

§2 实现

智能指针将仅包含一个函数 free_on_exit,用于在当前函数返回时释放传递的指针。这将允许我们重写上面的示例,而无需任何 free 调用。

void f () {
  char *resource_1 = free_on_exit (get_resource ());
  if (resource_1 == NULL) return;
  char *resource_2 = free_on_exit (get_resource ());
  if (resource_2 == NULL) return;
  char *resource_3 = free_on_exit (get_resource ());
  if (resource_3 == NULL) return;
}

无论 f 在哪里返回,它都会释放之前分配的所有内容。但是我们如何才能实现 free_on_exit 呢?我们如何知道 f 何时返回并释放所有先前的分配?诀窍是操作调用栈。我们可以操作堆栈,让它返回到我们自己的自定义函数,而不是 f 返回到其原始调用者。

§2.1 调用栈

让我们回顾一下调用栈的结构。调用栈的布局取决于架构。我们将使用 32 位 x86 作为我们的目标架构(它具有比 64 位更简单的布局和调用约定)。Eli Bendersky 还有另一篇很棒的文章,Where the top of the stack is on x86,其中包含更多深度,但以下是一个简要概述。

这是一个示例,说明在 32 位 x86 架构中函数 main 调用函数 sum 时堆栈的样子。

int sum (int x, int y) {
  int z = x + y;
  return z;
}
int main () {
  int value = sum (2, 3);
}

函数调用期间的调用栈。

在函数调用期间,调用者和被调用者分担将哪些数据推送到堆栈上的责任。调用者 main 负责保存当前的 eip,但被调用者 f 负责保存当前的 ebp

§2.2 劫持返回地址

但是如何在 C 程序中修改堆栈?一种方法是使用汇编来获取堆栈地址,然后更改它们指向的值。以下代码使用内联汇编来更改函数的返回地址。

#include <stdio.h>
void hijacked () {
  printf ("hijacked\n");
}
void f () {
  printf ("f starts\n");
  int *base = NULL;
  // 获取 ebp 的值。
  __asm__("movl %%ebp, %0 \n"
      : "=r"(base) // output
      );
  // 更改返回地址。
  *(base + 1) = (int) hijacked;
  printf ("f ends\n");
}
int main () {
  printf ("main starts\n");
  f ();
  printf ("main ends\n");
}

hijack.c 要运行此程序:

$ gcc -O0 hijack.c -m32 -o hijack
$ ./hijack
main starts
f starts
f ends
hijacked
Bus error: 10

我们可以看到 f 永远不会返回到 main,而是返回到被劫持的函数!这是如何运作的?

f 中,我们使用内联汇编函数 __asm__base 中获取 ebp 寄存器的当前值。

__asm__ (
"movl %%ebp, %0 \n" 
: "=r" (base) // output
);

"movl %%ebp, %0 \n" 是我们运行的汇编指令。寄存器用 %% 表示。%0 是第一个输出变量的占位符。

"=r" (base) 表示使用 C 变量 base 作为第一个输出变量。"=r" 表示在复制到输出变量之前将操作数存储在寄存器中。

有关 __asm__ 的更多信息,请参见 Eric Woroshow 的文章 A Tiny Guide to GCC Inline Assembly

一旦我们在 base 中获得了 ebp 的值,我们就可以像使用任何指针一样使用它。

*(base + 1) = (int) hijacked;

由于 base 的类型为 int*,因此加 1 会使地址递增一个 int 的大小(在本例中为 4 个字节)。因此,此行将堆栈上保存的 eipmain 更改为函数 hijacked 的地址。

注意,在我们从 hijacked 返回后,会出现一个错误(您的错误可能是段错误)。接下来,我们将看到如何修复该错误。

§2.3 恢复返回地址

之前的示例以错误结束。当 hijacked 返回时,没有地址可以从堆栈中弹出,因此它会跳转到无效地址。

调用者负责推送返回地址。当我们直接跳转到 hijacked 时,我们绕过了通常的调用约定。

相反,我们希望 hijacked 返回到 main 中的原始返回地址。为此,我们可以使用纯汇编函数来避免编译后的 C 函数的典型函数调用和返回序列。

.section .text
.globl trampoline
.type trampoline, @function
trampoline:
# call hijacked. 这会将下一条指令的地址压入堆栈。
# 当 hijacked 返回时,我们直接跳转到 eax 中的地址。
# eax 包含 hijacked 的返回值。
call hijacked
jmp %eax

trampoline.S

这个名为 trampoline 的汇编函数绕过了编译 C 函数生成的通常调用序列。我们不弹出返回地址来返回,而是 jmp 直接跳转到存储在 eax 中的值。由 hijacked 返回的值存储在 eax 中。我们按如下方式修改 hijackedf

// 向前声明汇编 trampoline。
void trampoline ();
int return_address;
int hijacked () {
  printf ("hijacked\n");
  return return_address;
}
void f () {
  printf ("f starts\n");
  int *base;
  // 获取 ebp 的值。
  __asm__("movl %%ebp, %0 \n"
      : "=r"(base) // output
      );
  // 保存返回地址。
  return_address = *(base + 1);
  // 更改返回地址。
  *(base + 1) = (int) trampoline;
  printf ("f ends\n");
}

使用以下命令编译并运行:

$ gcc -o hijack -O0 -m32 hijack.c trampoline.S
$ ./hijack
main starts
f starts
f ends
hijacked 
main ends

现在,我们的劫持函数在执行后恢复了原始返回地址。我们将使用相同的技术来实现我们的智能指针。

§2.4 一个智能指针

我们离创建一个智能指针只有一小步之遥。让我们将 hijacked 重命名为 do_free,并添加函数 free_on_exit,该函数现在劫持_调用者_的返回地址。

#include <stdio.h>
#include <stdlib.h>
/* 向前声明汇编 trampoline。 */
void trampoline ();
int return_address;
void *tracked_pointer;
int do_free () {
  free (tracked_pointer);
  return return_address;
}
void *free_on_exit (void *ptr) {
  int *base;
  // 通过解引用 ebp 获取调用者的 ebp 值。
  __asm__("movl (%%ebp), %0 \n"
      : "=r"(base) // output
      );
  // 保存并更改调用者的返回地址。
  return_address = *(base + 1);
  *(base + 1) = (int) trampoline;
  return ptr;
}
void f () {
  char *resource = free_on_exit (malloc (1));
}
int main () {
  f ();
}

调用 free_on_exit 存储传递的指针,并将调用者的返回地址设置为 trampoline。在调用者 f 返回后,它会自动释放其 malloc 化的字节。我们现在有了一个智能指针!

§2.5 多个智能指针

上面的 free_on_exit 只是一个单次使用的函数。如果多次调用,它只会释放最近一次调用中传递的指针。幸运的是,只需再一小步即可使 free_on_exit 与任意数量的重复调用一起使用。

为此,我们可以为每个函数调用存储一个跟踪指针列表。堆叠这些列表,每次新函数调用 free_on_exit 时,添加一个新的堆栈条目。当调用 do_free 时,它会释放堆栈顶部条目上的指针列表。

冒着在本文中包含过多代码的风险,以下是不到一百行代码的完整实现:

#ifndef _SMART
#define _SMART
void *free_on_exit (void *);
#endif

smart.h

#include "smart.h"
#include <stdlib.h> // free
#include <string.h> // memset
/* 这些限制是任意的。 */
#define STACK_SIZE 256
#define MAX_PER_FRAME 32
typedef struct {
  int caller_ebp; /* 调用者的 ebp。这标识了帧。 */
  int caller_eip; /* 调用者的原始返回 eip。 */
  void *tracked_pointers[MAX_PER_FRAME];
  int tail; /* 指向最后一个条目之后的位置。 */
} tracked_stack_entry_t;
typedef struct {
  tracked_stack_entry_t stack[STACK_SIZE];
  int tail; /* 指向最后一个条目之后的位置。 */
} tracked_stack_t;
/* 向前声明汇编 trampoline。 */
void trampoline ();
tracked_stack_t tracked = {0};
int do_free () {
  tracked_stack_entry_t *entry = tracked.stack + (tracked.tail - 1);
  tracked.tail--; /* 弹出。 */
  for (int i = 0; i < MAX_PER_FRAME; i++) {
   if (entry->tracked_pointers[i] == 0) break;
   free (entry->tracked_pointers[i]);
  }
  return entry->caller_eip;
}
void *free_on_exit (void *entry) {
  int ret_addr = 0;
  int do_free_addr = (int) &do_free;
  int *caller_ebp;
  /* 获取 ebp 的值。 */
  __asm__("movl (%%ebp), %0 \n"
      : "=r"(caller_ebp) /* output. */
      );
  /* 检查此调用者是否存在预先存在的堆栈条目
  * (由调用者的 ebp 标识)。 */
  tracked_stack_entry_t *tracked_entry;
  if (tracked.tail > 0 &&
    tracked.stack[tracked.tail - 1].caller_ebp == (int) caller_ebp) {
   /* 重用。 */
   tracked_entry = tracked.stack + tracked.tail - 1;
  } else {
   /* 创建一个新条目。 */
   tracked_entry = tracked.stack + tracked.tail++;
   memset (tracked_entry, 0, sizeof (*tracked_entry));
   tracked_entry->caller_ebp = (int) caller_ebp;
   /* 劫持调用者的返回 eip 以返回到 do_free。 */
   tracked_entry->caller_eip = *(caller_ebp + 1);
   *(caller_ebp + 1) = (int) trampoline;
  }
  /* 推送传递的指针。 */
  tracked_entry->tracked_pointers[tracked_entry->tail++] = entry;
  return entry;
}

smart.c

# 这可以使用 `as --32` 单独编译
# 这是 GNU 汇编器语法(也就是 AT-T 语法)(src, dest)
.section .text
.globl trampoline 
.type trampoline, @function
trampoline:
call do_free
jmp %eax # 直接跳回到旧的 eip。

trampoline.S

此外,该代码位于 GitHub 仓库 中。

§3 结论

在本文中,我们展示了如何在 32 位 x86 架构上构建一个简单且不完整的智能指针。我们研究了调用栈,劫持了返回地址,并在过程中编写了一些汇编代码。

我最近发现,如果 gcc 对齐堆栈,则直接从 main 调用 free_on_exit 的实现将不起作用。在这种情况下,main 会在保存的 eip 和保存的 ebp 之间添加填充,(示例)。我认为可以通过一些调整来修复此问题,并在修复后更新本文。

有关更多阅读,请查看以下文章: