4. 指针与数组、字符串

动态内存管理,说白了就是让程序在运行时按需申请和释放内存。嵌入式开发里,这玩意儿用好了是利器,用不好就是定时炸弹。我见过太多项目因为内存问题在产线翻车,嗯,咱们今天就把这块彻底讲透。

核心认知:栈是编译器替你管,堆是程序员自己管。谁管得好,谁就少加班。

4.1 堆与栈:两个世界

栈和堆,本质都是内存区域,但管理方式天差地别。我刚开始做嵌入式时,总觉得堆就是大一点的栈,结果踩坑踩到怀疑人生。

对比项 栈 (Stack) 堆 (Heap)
分配方式 编译器自动分配/释放 程序员手动申请/释放
速度 极快(一条指令) 较慢(查找空闲块)
大小 固定(几KB~几MB) 受限于RAM总量
碎片问题 无碎片 容易产生外部碎片
生命周期 函数结束即释放 直到手动free()
典型用途 局部变量、函数参数 大块数据、变长结构

为什么会这样?栈是后进先出,分配释放就像叠盘子,干净利落。堆呢?你申请一块,释放一块,中间可能留下空洞,时间长了就变成瑞士奶酪。

我的习惯:能用栈绝不用堆。局部变量、小数组、固定大小的结构体,统统放栈上。只有那些生命周期不确定、大小运行时才知的数据,才考虑堆。

4.2 malloc/calloc/realloc/free:四兄弟

这四位是C标准库给的动态内存接口。我用它们写过不下十万行代码,踩过的坑能写一本小册子。

4.2.1 malloc:最基础的申请

void *malloc(size_t size);
// 返回:成功→指向size字节内存的指针;失败→NULL

注意,malloc不初始化内存。你拿到的那块区域里,可能是上次别人留下的残骸。我曾经在一个通信协议栈里,因为没初始化malloc出来的缓冲区,结果解析出了上一条报文残留的校验码,排查了整整两天。

铁律:malloc之后必须检查返回值是否为NULL。嵌入式环境内存紧张,分配失败不是小概率事件。

4.2.2 calloc:带初始化的申请

void *calloc(size_t nmemb, size_t size);
// 分配 nmemb * size 字节,并全部清零

calloc比malloc多做了清零操作。如果你需要初始化为0的数组,用calloc更安全。但注意,清零需要时间,实时性要求高的场景慎用。

4.2.3 realloc:调整大小

void *realloc(void *ptr, size_t new_size);
// 调整ptr指向的内存块大小为new_size

realloc是个双刃剑。它可能原地扩容,也可能搬移到新地址。我建议你永远不要假设realloc后指针不变。正确的做法是:

int *new_ptr = (int *)realloc(old_ptr, new_size);
if (new_ptr == NULL) {
    // 失败处理,old_ptr仍然有效
} else {
    old_ptr = new_ptr;  // 更新指针
}

4.2.4 free:还回去

void free(void *ptr);
// 释放ptr指向的内存块

free之后,ptr变成野指针。我见过有人free之后还继续读写那块内存,结果程序跑得时好时坏,查bug查到崩溃。

最佳实践:free之后立即将指针置为NULL。这样再误用时,至少会立即崩溃,而不是随机出错。

4.3 内存泄漏:看不见的杀手

内存泄漏就是申请了内存但没释放。嵌入式设备跑几天、几周后突然死机,十有八九是泄漏。

4.3.1 泄漏的典型场景

  • 忘记free:最直接,也最常见
  • 丢失指针:重新赋值前没释放原指针
  • 异常路径:函数中途return,没走到free
  • 循环泄漏:每次循环都malloc,但只在循环外free一次

4.3.2 检测方法

我常用的三板斧:

  1. 静态分析:用PC-Lint、Coverity扫一遍,能发现明显的泄漏路径
  2. 封装malloc/free:自己写包装函数,记录每次申请释放的地址和大小
  3. 运行时统计:在关键节点打印当前已分配但未释放的内存总量
// 简易内存跟踪示例
#define TRACK_MALLOC(size)  track_malloc(size, __FILE__, __LINE__)
#define TRACK_FREE(ptr)     track_free(ptr)

void *track_malloc(size_t size, const char *file, int line) {
    void *p = malloc(size);
    // 记录 p, size, file, line 到链表
    return p;
}

void track_free(void *ptr) {
    // 从链表中移除记录
    free(ptr);
}

我曾经:在一个RTOS项目里,用这种封装方法抓到了一个隐藏了三周的泄漏。那个bug只在特定消息序列下触发,跑压力测试才复现。没有跟踪机制,根本定位不到。

4.4 野指针与悬空指针:两个魔鬼

这两个概念经常被混用,但本质不同。野指针是未初始化,悬空指针是释放后未置空。

类型 成因 表现 预防
野指针 声明后未赋值 指向随机地址 声明即初始化
悬空指针 free后未置NULL 指向已释放内存 free后置NULL

4.4.1 野指针的典型场景

int *p;          // 野指针!p的值是随机的
*p = 10;         // 写入了未知地址,可能立刻崩溃,也可能悄悄破坏数据

正确的做法:

int *p = NULL;   // 明确指向空
// 或者
int *p = (int *)malloc(sizeof(int));
if (p != NULL) {
    *p = 10;
}

4.4.2 悬空指针的陷阱

int *p = (int *)malloc(sizeof(int));
free(p);
// 此时p是悬空指针
*p = 20;  // 危险!可能修改了堆管理器的内部数据结构

血的教训:我曾经在一个电机控制固件里,free之后忘了置NULL。后来另一段代码检查if(p != NULL)时通过了,然后写入了已释放的内存,导致堆管理器的空闲链表被破坏。下一次malloc直接死机。这个bug查了整整一周。

4.5 知识体系总览

下面这张图把本章的核心逻辑串起来了。你想想看,从内存区域划分,到四个API的使用,再到三种常见问题的预防,其实是一条线。

动态内存管理知识体系 动态内存管理知识体系 动态内存管理 内存区域 栈 (Stack) 堆 (Heap) 核心API malloc calloc realloc 常见问题 内存泄漏 野指针 悬空指针 核心原则:谁申请谁释放,释放后即置空 栈由编译器管理,堆由程序员管理——管好堆,才能睡好觉

4.6 嵌入式环境下的特殊考量

在嵌入式系统里,动态内存管理比PC上更敏感。我总结了几条铁律:

  • 避免频繁malloc/free:每次分配释放都有开销,还会加剧碎片。能一次分配大块、重复使用的,就别小碎块频繁操作
  • 考虑静态分配替代:如果最大大小已知,用静态数组比动态分配更可靠
  • 使用内存池:对于固定大小的对象(如网络报文、任务控制块),用内存池管理,分配释放都是O(1)
  • 监控堆使用率:在产品中保留堆统计接口,定期检查剩余空间

我的建议:在嵌入式项目里,把动态内存分配看作一种「特权操作」。只有那些确实需要运行时决定大小的数据,才配使用堆。其他情况,栈或静态分配更安全、更高效。

嗯,动态内存管理这块,说白了就是「谁的孩子谁抱走」。申请了就要释放,释放了就要忘记。做到这两点,内存问题至少能减少八成。