C 语言中的 “笨笨” 智能指针:内存管理的新探索

C 语言中的 “笨笨” 智能指针:内存管理的新探索

编码文章call10242025-04-23 12:08:2012A+A-

在编程的世界里,C 语言以其高效、灵活的特性,一直是众多开发者手中的利器,在系统开发、嵌入式编程等领域占据着重要地位。然而,C 语言的内存管理机制却让不少程序员又爱又恨。手动管理内存虽然赋予了开发者极大的控制权,但也伴随着诸多风险,其中内存泄漏问题就像一颗随时可能引爆的 “定时炸弹”,让程序的稳定性和性能大打折扣。今天,我们就来聊聊在 C 语言中实现智能指针的一种有趣尝试,看看它是如何在这片内存管理的 “荆棘丛” 中开辟出一条新路径的。

C 语言内存管理的困境

在 C 语言的世界里,内存的分配和释放主要依靠malloc和free这两个函数。当我们需要在堆上分配一块内存时,就会调用malloc函数,它会在内存中找到一块合适的空间并返回一个指向该空间的指针。比如:

char *data = (char *)malloc(100);

这段代码为我们分配了 100 个字节的内存空间,data指针指向这块内存的起始位置。当我们使用完这块内存,不再需要它时,就必须调用free函数来释放它,否则这块内存就会一直占用着,无法被其他程序使用,这就是所谓的内存泄漏。

听起来似乎很简单,但实际情况却没那么容易。有时候,确定在哪里调用free并不困难,就像这样:

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

在这个简单的例子中,我们清楚地知道什么时候不再需要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;
    }
    // 后续还有其他操作
}

在这个函数中,每分配一个新的资源,我们都要在函数的多个返回点考虑释放之前分配的资源。随着分配资源的增多,释放资源的代码会变得越来越冗长和复杂,很容易出现遗漏,从而导致内存泄漏。

为了解决这个问题,在 C 语言中有一种常见的做法是利用goto语句来进行错误处理和资源释放。Eli Bendersky 在他的文章中介绍了这种方法,通过将所有的free调用放在函数的末尾,利用goto语句跳转到释放资源的位置,从而减少代码的冗余。改进后的代码如下:

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); 
free_resource_1:
    free(resource_1);
    return;
}

这种方法虽然在一定程度上简化了资源释放的代码,但仍然不够优雅和安全。

相比之下,C++ 语言为内存管理提供了更强大的工具 —— 智能指针,如std::unique_ptr和std::shared_ptr。在 C++ 中,对象有析构函数,智能指针可以将指针的生命周期与对象的生命周期绑定在一起。以std::unique_ptr为例,当std::unique_ptr对象超出作用域时,它会自动释放所指向的内存,大大减少了内存泄漏的风险。用 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;
    // 其他操作
}

可惜的是,C 语言没有像 C++ 那样的析构函数,也就没有原生的智能指针。但是,我们可以通过一些巧妙的方法来近似实现智能指针的功能。

智能指针的实现之旅

我们的目标是在 C 语言中实现一个简单的智能指针,让它能够在函数返回时自动释放所指向的内存。这个智能指针将通过一个名为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函数是如何实现的呢?这就需要我们深入了解函数调用栈的奥秘了。

探秘函数调用栈

函数调用栈是程序在运行时用于管理函数调用和局部变量的数据结构,它的布局取决于具体的计算机架构。这里我们以 32 位 x86 架构为例,它的布局和调用约定相对 64 位架构来说更简单一些。

当函数main调用函数sum时,调用栈的情况大致如下:

int sum(int x, int y) {
    int z = x + y;
    return z;
}

int main() {
    int value = sum(2, 3);
}

在函数调用过程中,调用者(如main函数)和被调用者(如sum函数)各自承担不同的责任。调用者负责保存当前的指令指针eip,而被调用者负责保存当前的栈基指针ebp。

劫持返回地址

要在 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) // 输出
            );

    // 修改返回地址
    *(base + 1) = (int)hijacked;

    printf("f ends\n");
}

int main() {
    printf("main starts\n");
    f();
    printf("main ends\n");
}

在这个代码中,我们在f函数里使用内联汇编获取了当前ebp的值,并将其存储在base变量中。然后,通过修改base + 1所指向的值,将函数的返回地址修改为hijacked函数的地址。这样,当f函数返回时,就不会返回到main函数,而是会跳转到hijacked函数。

运行这段代码后,我们会看到输出结果为:

main starts
f starts
f ends
hijacked
Bus error: 10

可以发现,f函数确实没有返回到main函数,而是执行了hijacked函数。不过,在hijacked函数返回后出现了错误,这是因为我们直接跳转到hijacked函数,绕过了正常的调用约定,导致返回时栈上没有正确的返回地址。

恢复返回地址

为了解决上述错误,我们需要让hijacked函数能够返回到main函数原来的返回地址。这可以通过一个纯汇编函数trampoline来实现,它绕过了编译后的 C 函数的常规调用和返回序列。

下面是trampoline函数的汇编代码:

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

然后,我们修改hijacked和f函数如下:

// 向前声明汇编函数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) // 输出
            );

    // 保存返回地址
    return_address = *(base + 1);
    // 修改返回地址
    *(base + 1) = (int)trampoline;

    printf("f ends\n");
}

重新编译并运行代码后,输出结果为:

main starts
f starts
f ends
hijacked 
main ends

现在,hijacked函数在执行后能够正确地恢复到原来的返回地址,返回到main函数。

实现单个智能指针

有了前面的基础,我们离实现智能指针就只有一步之遥了。我们将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) // 输出
            );

    // 保存并修改调用者的返回地址
    return_address = *(base + 1);
    *(base + 1) = (int)trampoline;
    tracked_pointer = ptr;
    return ptr;
}

void f() {
    char *resource = free_on_exit(malloc(1));
}

int main() {
    f();
}

当调用free_on_exit函数时,它会存储传入的指针,并将调用者的返回地址设置为trampoline。当调用者(如f函数)返回时,会自动调用do_free函数,释放之前通过malloc分配的内存,这样我们就实现了一个简单的智能指针。

支持多个智能指针

前面实现的free_on_exit函数只能处理单个指针,如果多次调用,它只会释放最后一次传入的指针。为了让它能够支持多个指针,我们需要存储一个跟踪指针的列表。对于每个函数调用,我们都创建一个新的栈条目,并将传入的指针添加到该条目中。当do_free函数被调用时,它会释放栈顶条目中的所有指针。

下面是完整的代码实现,代码量不到一百行:

// smart.h文件
#ifndef _SMART
#define _SMART
void *free_on_exit(void *);
#endif
// smart.c文件
#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) // 输出
            );

    // 检查是否已经存在针对该调用者(通过调用者的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;
}
// trampoline.S文件
# 可以使用`as --32`单独编译
# 这是GNU汇编语法(aka AT-T语法) (src, dest)
.section .text
.globl trampoline 
.type trampoline, @function
trampoline:
call do_free
jmp %eax # 直接跳回到原来的eip

这段代码实现了一个可以跟踪多个指针的智能指针,并且所有代码都可以在 GitHub 仓库中找到,方便大家研究和使用。

在这篇文章中,我们探索了在 32 位 x86 架构下,如何通过巧妙地操作调用栈和编写汇编代码,实现一个简单的智能指针。虽然这个智能指针还存在一些局限性,比如在某些情况下(如直接从main函数调用且 gcc 对栈进行对齐时)可能无法正常工作,但它为我们在 C 语言中解决内存管理问题提供了一种新的思路。

这种方法不仅让我们对 C 语言的底层机制有了更深入的理解,也展示了通过创新的方式可以在没有原生支持的情况下实现强大的功能。希望大家通过这篇文章,对 C 语言的内存管理和智能指针有了新的认识,也期待未来有更多更好的方法来解决 C 语言内存管理的难题。如果你在学习或使用 C 语言的过程中,对内存管理有什么有趣的想法或经验,欢迎在评论区分享!

科技脉搏,每日跳动。

与敖行客 Allthinker一起,创造属于开发者的多彩世界。

- 智慧链接 思想协作 -

点击这里复制本文地址 以上内容由文彬编程网整理呈现,请务必在转载分享时注明本文地址!如对内容有疑问,请联系我们,谢谢!
qrcode

文彬编程网 © All Rights Reserved.  蜀ICP备2024111239号-4