指针综合实战:实现一个简易内存池、一个字符串库、一个通用链表库

好,终于到了指针训练的最后一站。说实话,前面那些知识点,什么数组指针、函数指针、二级指针,都是“招式”。今天这三样东西——内存池、字符串库、通用链表——才是真正能拿到项目里用的“兵器”。

我当年刚入行的时候,写嵌入式代码最怕两件事:一是malloc崩了,二是字符串拷贝越界。后来带我的老工程师甩给我一句话:“小子,别老依赖标准库,自己造个轮子,你就什么都懂了。” 嗯,今天我就把这三个轮子的造法,掰开了讲给你听。

30.1 简易内存池:告别malloc恐惧症

嵌入式环境里,动态内存分配是个敏感话题。标准库的malloc,碎片多、速度慢、还不确定什么时候就返回NULL。我个人习惯,在资源紧张的MCU上,直接上内存池。

说白了,就是提前划一块大数组,自己管理。分配和释放都是O(1)复杂度,没有碎片。

30.1.1 核心结构设计

typedef struct {
    unsigned char *pool;        // 内存池基地址
    size_t block_size;          // 每个块的大小
    int total_blocks;           // 总块数
    int free_list;              // 空闲块链表头索引
    int *next;                  // 空闲块索引数组
} MemPool;

这里的关键是 free_listnext 数组。我把所有空闲块串成一个单向链表,分配时从头取一个,释放时再插回去。你想想看,这不就是“空闲块栈”吗?

核心思想: 用索引代替指针,避免指针本身占用额外空间。在8位单片机上,一个int索引才2字节,比指针省一半。

30.1.2 初始化与分配释放

void MemPool_Init(MemPool *mp, void *buf, size_t blk_size, int total) {
    mp->pool = (unsigned char*)buf;
    mp->block_size = blk_size;
    mp->total_blocks = total;
    mp->next = (int*)malloc(total * sizeof(int));
    if (!mp->next) return;

    // 构建空闲链表
    for (int i = 0; i < total - 1; i++) {
        mp->next[i] = i + 1;
    }
    mp->next[total - 1] = -1;  // 链表尾
    mp->free_list = 0;
}

void *MemPool_Alloc(MemPool *mp) {
    if (mp->free_list == -1) return NULL;  // 没空闲块了

    int idx = mp->free_list;
    mp->free_list = mp->next[idx];
    mp->next[idx] = -1;  // 标记已分配
    return mp->pool + idx * mp->block_size;
}

void MemPool_Free(MemPool *mp, void *ptr) {
    if (!ptr) return;

    int idx = ((unsigned char*)ptr - mp->pool) / mp->block_size;
    // 插回空闲链表头部
    mp->next[idx] = mp->free_list;
    mp->free_list = idx;
}

我曾经踩过的坑: 释放时一定要检查指针是否属于这个内存池。有一次我传了个栈上的地址进去,结果索引算出来是负数,直接数组越界。后来我加了个范围检查:if (ptr < (void*)mp->pool || ptr >= (void*)(mp->pool + mp->total_blocks * mp->block_size)) return;

30.2 字符串库:自己动手,丰衣足食

标准库的 strcpystrcat 不安全,这是共识。但很多嵌入式编译器压根不提供这些函数的安全版本。怎么办?自己写一个轻量字符串库。

30.2.1 安全字符串复制

char *mystr_copy(char *dest, const char *src, size_t dest_size) {
    if (!dest || !src || dest_size == 0) return NULL;

    size_t i;
    for (i = 0; i < dest_size - 1 && src[i] != '\0'; i++) {
        dest[i] = src[i];
    }
    dest[i] = '\0';  // 保证总是以'\0'结尾
    return dest;
}

你看,核心就一句话:永远不要相信输入长度。我见过太多因为 strcpy 导致栈溢出的bug了。这个函数虽然简单,但能保证不越界。

30.2.2 字符串拼接与查找

char *mystr_cat(char *dest, const char *src, size_t dest_size) {
    if (!dest || !src || dest_size == 0) return NULL;

    size_t dest_len = strnlen(dest, dest_size);
    size_t i;
    for (i = 0; dest_len + i < dest_size - 1 && src[i] != '\0'; i++) {
        dest[dest_len + i] = src[i];
    }
    dest[dest_len + i] = '\0';
    return dest;
}

char *mystr_find(const char *str, char ch) {
    if (!str) return NULL;
    while (*str) {
        if (*str == ch) return (char*)str;
        str++;
    }
    return NULL;
}

小技巧:strnlen 而不是 strlen,防止源字符串没有'\0'导致死循环。我习惯在嵌入式代码里,所有字符串操作都带上长度参数。

30.3 通用链表库:用void*打通一切

通用链表,说白了就是链表节点里不存具体数据,只存一个 void *data 指针。这样任何类型的数据都能挂上去。我在项目里用这个模式实现过任务队列、消息缓冲、传感器数据缓存……

30.3.1 节点与链表结构

typedef struct Node {
    void *data;
    struct Node *next;
} Node;

typedef struct {
    Node *head;
    Node *tail;
    int size;
    void (*free_data)(void *);  // 用户自定义数据释放函数
} LinkedList;

这里有个设计点:free_data 函数指针。因为链表不知道你存的是什么类型的数据,释放时必须由你告诉它怎么释放。这就是回调函数的典型应用。

30.3.2 创建、插入与遍历

LinkedList *List_Create(void (*free_fn)(void*)) {
    LinkedList *list = (LinkedList*)malloc(sizeof(LinkedList));
    if (!list) return NULL;
    list->head = list->tail = NULL;
    list->size = 0;
    list->free_data = free_fn;
    return list;
}

int List_Append(LinkedList *list, void *data) {
    if (!list || !data) return -1;

    Node *node = (Node*)malloc(sizeof(Node));
    if (!node) return -1;

    node->data = data;
    node->next = NULL;

    if (list->tail) {
        list->tail->next = node;
    } else {
        list->head = node;
    }
    list->tail = node;
    list->size++;
    return 0;
}

void List_ForEach(LinkedList *list, void (*visit)(void*)) {
    if (!list || !visit) return;
    Node *cur = list->head;
    while (cur) {
        visit(cur->data);
        cur = cur->next;
    }
}

为什么这样设计? 把“怎么遍历”和“遍历时做什么”分开了。你只需要写一个 visit 函数,想打印就打印,想累加就累加,链表本身不关心。这就是面向接口编程的思想。

30.3.3 释放整个链表

void List_Destroy(LinkedList *list) {
    if (!list) return;
    Node *cur = list->head;
    while (cur) {
        Node *tmp = cur;
        cur = cur->next;
        if (list->free_data) {
            list->free_data(tmp->data);  // 释放用户数据
        }
        free(tmp);  // 释放节点本身
    }
    free(list);
}

注意顺序: 先释放数据,再释放节点。我曾经反过来写过,结果数据释放后节点里的指针变成野指针,再想释放节点就崩了。血的教训。

30.4 知识体系总览

下面这张图,把今天三个模块的核心逻辑串起来了。你看一眼,心里就有谱了。

指针综合实战三大模块 简易内存池 核心结构:MemPool 空闲链表:free_list 分配:O(1) 取头节点 释放:O(1) 插回头部 特点:无碎片、速度快 适用:固定大小对象 索引代替指针,省空间 安全字符串库 mystr_copy:安全复制 mystr_cat:安全拼接 mystr_find:字符查找 核心原则:带长度参数 保证以'\0'结尾 防止缓冲区溢出 不依赖标准库不安全函数 通用链表库 节点:void* data 链表:head/tail/size 回调:free_data List_Append:尾部插入 List_ForEach:遍历 List_Destroy:释放 支持任意数据类型 核心思想:用指针抽象数据,用回调解耦逻辑

30.5 实战建议与避坑指南

  • 内存池大小怎么定? 我一般先估算最大并发对象数,再乘以1.2作为安全余量。比如最多同时有10个任务,每个任务需要64字节,那就开 10 * 64 * 1.2 ≈ 768 字节。
  • 字符串库要不要支持动态扩容? 看场景。如果字符串长度不确定,建议用“固定缓冲区+截断”策略,而不是动态分配。动态分配在嵌入式里容易出问题。
  • 通用链表的线程安全怎么处理? 简单做法:在链表结构体里加一个 mutex,每个操作前后加锁。复杂做法:用无锁链表,但那个坑更深,新手不建议碰。

我的个人习惯: 这三个模块写好后,我会把它们放在一个 utils/ 目录下,每个模块一个 .c 和一个 .h。以后任何新项目,直接复制过去就能用。省去了重复造轮子的时间。

好了,指针训练营到这里就结束了。这三样东西,你如果能自己手写一遍,再在项目里用上几次,指针这块基本就通了。记住,看十遍不如写一遍,写十遍不如调一遍bug。动手吧。


公众号:蓝海资料掘金营,微信deep3321