指针综合实战:实现一个简易内存池、一个字符串库、一个通用链表库
好,终于到了指针训练的最后一站。说实话,前面那些知识点,什么数组指针、函数指针、二级指针,都是“招式”。今天这三样东西——内存池、字符串库、通用链表——才是真正能拿到项目里用的“兵器”。
我当年刚入行的时候,写嵌入式代码最怕两件事:一是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_list 和 next 数组。我把所有空闲块串成一个单向链表,分配时从头取一个,释放时再插回去。你想想看,这不就是“空闲块栈”吗?
核心思想: 用索引代替指针,避免指针本身占用额外空间。在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 字符串库:自己动手,丰衣足食
标准库的 strcpy、strcat 不安全,这是共识。但很多嵌入式编译器压根不提供这些函数的安全版本。怎么办?自己写一个轻量字符串库。
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 知识体系总览
下面这张图,把今天三个模块的核心逻辑串起来了。你看一眼,心里就有谱了。
30.5 实战建议与避坑指南
- 内存池大小怎么定? 我一般先估算最大并发对象数,再乘以1.2作为安全余量。比如最多同时有10个任务,每个任务需要64字节,那就开 10 * 64 * 1.2 ≈ 768 字节。
- 字符串库要不要支持动态扩容? 看场景。如果字符串长度不确定,建议用“固定缓冲区+截断”策略,而不是动态分配。动态分配在嵌入式里容易出问题。
- 通用链表的线程安全怎么处理? 简单做法:在链表结构体里加一个
mutex,每个操作前后加锁。复杂做法:用无锁链表,但那个坑更深,新手不建议碰。
我的个人习惯: 这三个模块写好后,我会把它们放在一个 utils/ 目录下,每个模块一个 .c 和一个 .h。以后任何新项目,直接复制过去就能用。省去了重复造轮子的时间。
好了,指针训练营到这里就结束了。这三样东西,你如果能自己手写一遍,再在项目里用上几次,指针这块基本就通了。记住,看十遍不如写一遍,写十遍不如调一遍bug。动手吧。
公众号:蓝海资料掘金营,微信deep3321