4、内存安全:malloc/calloc/realloc返回值检查、内存泄漏检测、野指针防范、缓冲区溢出防护

内存安全这个话题,说实话,是C语言里最容易翻车的地方。我见过太多项目,功能跑得好好的,上线一周就开始随机崩溃。查到最后,十有八九是内存问题。你想想看,一个指针指错了地方,整个系统都可能跟着遭殃。

今天我们就来聊聊内存安全的四个核心问题。每个问题我都踩过坑,有些坑到现在想起来还心有余悸。

4.1 malloc/calloc/realloc返回值检查

很多人写代码时,malloc完直接就用。我年轻时也这么干过。直到有一次,一个嵌入式设备在内存不足时,malloc返回了NULL,程序直接往NULL地址写数据——嗯,系统当场死机。

从那以后,我养成了一个习惯:每次动态分配内存,必须检查返回值。

核心原则:malloc、calloc、realloc 返回 NULL 时,绝对不能继续使用返回的指针。

来看一个典型的错误写法:

// 错误示范——别学我
int *p = (int *)malloc(100 * sizeof(int));
*p = 42;  // 如果malloc失败,这里就崩了

正确的做法是这样:

// 正确做法
int *p = (int *)malloc(100 * sizeof(int));
if (p == NULL) {
    // 处理错误,比如返回错误码或记录日志
    fprintf(stderr, "malloc failed!\n");
    return -1;
}
*p = 42;

这里有个细节我想提一下。calloc 和 realloc 同样需要检查。calloc 会帮你把内存清零,但分配失败时一样返回 NULL。realloc 更特殊——它失败时,原来的内存块还在,但返回 NULL。所以你不能直接 p = realloc(p, new_size),否则失败后原指针就丢了。

我的习惯:realloc 时先用临时变量接返回值,确认成功后再赋值给原指针。

// realloc 的安全写法
int *tmp = (int *)realloc(p, new_size * sizeof(int));
if (tmp == NULL) {
    // 处理错误,p 仍然有效
    fprintf(stderr, "realloc failed!\n");
    return -1;
}
p = tmp;  // 确认成功后再赋值

4.2 内存泄漏检测

内存泄漏,说白了就是申请了内存没释放。一次两次没事,但嵌入式设备跑个几天几周,内存慢慢被吃光,系统就挂了。

我曾经维护过一个通信网关项目,设备运行三天后必重启。查了整整一周,最后发现是一个消息队列的处理函数里,每次处理完消息都忘了 free 掉 payload。每次泄漏 256 字节,三天下来累积了几十 MB,系统就扛不住了。

怎么检测内存泄漏?我总结了几种方法:

  • 静态代码审查:每次 malloc 都要对应一个 free,成对出现。我习惯在代码里用注释标记 /* malloc *//* free */,方便对照。
  • 工具检测:Linux 下用 Valgrind,嵌入式环境可以用 mtrace 或者自己写内存分配钩子。
  • 手动计数:写一个简单的内存分配计数器,每次 malloc 加一,free 减一。程序退出时检查计数是否为 0。

这里给一个轻量级的内存泄漏检测示例,适合嵌入式环境:

// 简易内存泄漏检测
static int alloc_count = 0;

void *my_malloc(size_t size) {
    void *p = malloc(size);
    if (p != NULL) {
        alloc_count++;
    }
    return p;
}

void my_free(void *p) {
    if (p != NULL) {
        free(p);
        alloc_count--;
    }
}

// 程序退出时调用
void check_leak(void) {
    if (alloc_count != 0) {
        printf("Warning: %d memory blocks not freed!\n", alloc_count);
    }
}

注意:这个简易方法只能检测数量是否匹配,不能定位具体泄漏位置。生产环境建议用更专业的工具。

4.3 野指针防范

野指针,就是指向无效内存的指针。它比 NULL 指针更危险——NULL 指针至少会立刻崩溃,野指针可能悄悄破坏数据,等到你发现时已经晚了。

野指针的三大来源:

  1. 未初始化:局部指针变量没赋初值,里面是随机值。
  2. 释放后未置空:free 之后指针还在,指向的内存已经归还系统。
  3. 指向栈变量:函数返回后,栈上的局部变量就不存在了。

我自己的防御策略很简单:

  • 指针定义时立即初始化为 NULL。
  • free 之后立即把指针置为 NULL。
  • 绝不返回局部变量的地址。
// 野指针防范示例
void safe_free(void **p) {
    if (p != NULL && *p != NULL) {
        free(*p);
        *p = NULL;  // 置空,防止野指针
    }
}

// 使用方式
int *data = (int *)malloc(100 * sizeof(int));
// ... 使用 data ...
safe_free((void **)&data);
// 此时 data == NULL,再使用就会立刻崩溃,方便排查

一个小技巧:我习惯在 free 之后加一行 data = NULL;,虽然多打几个字,但能省去后面几小时的调试时间。

4.4 缓冲区溢出防护

缓冲区溢出,是C语言里最臭名昭著的问题之一。往一个固定大小的数组里写入了超出容量的数据,就会覆盖相邻内存。轻则数据错乱,重则被利用执行恶意代码。

我记得有一次,一个串口通信模块的接收缓冲区只开了 64 字节,但协议里最长的一帧数据有 128 字节。结果呢?数据一多,缓冲区后面的变量全被覆盖了,系统状态机直接跑飞。

防护措施其实不复杂,核心就一句话:永远不要相信输入的长度。

常用的防护手段:

方法 说明 示例
使用带长度限制的函数 用 strncpy 代替 strcpy,用 snprintf 代替 sprintf strncpy(dst, src, dst_size - 1);
手动检查长度 写入前先判断数据长度是否超过缓冲区 if (len > buf_size) { /* 截断或报错 */ }
使用安全封装 自己封装带边界检查的读写函数 safe_memcpy(dst, src, dst_size, src_size);

来看一个实际例子:

// 缓冲区溢出防护示例
#define BUF_SIZE 64

char buffer[BUF_SIZE];
char *input = "这是一段很长的数据,可能会超过缓冲区大小...";

// 错误做法
strcpy(buffer, input);  // 如果 input 超过 63 字节,就溢出了

// 正确做法
strncpy(buffer, input, BUF_SIZE - 1);
buffer[BUF_SIZE - 1] = '\0';  // 确保字符串以 '\0' 结尾

注意:strncpy 不会自动添加 '\0',如果源字符串长度大于等于目标缓冲区大小,目标字符串就不会以 '\0' 结尾。所以一定要手动加。

知识体系总览

下面这张图,是我自己整理的内存安全知识框架。四个问题环环相扣,任何一个环节出问题,都可能让整个系统崩溃。

内存安全四大核心问题 内存安全 malloc/calloc/realloc 返回值检查 分配失败返回 NULL 必须检查后再使用 内存泄漏检测 malloc/free 成对出现 工具:Valgrind / mtrace 手动计数 + 日志 野指针防范 初始化 = NULL free 后立即置 NULL 不返回局部变量地址 缓冲区溢出防护 使用带长度限制的函数 手动检查数据长度 封装安全读写接口

这四个问题,说白了就是内存管理的四个维度。返回值检查是入口把关,内存泄漏是出口管理,野指针是过程控制,缓冲区溢出是边界防护。任何一个维度出问题,都会导致内存安全漏洞。

我个人建议,在项目初期就把这些检查机制写进代码规范里。不要等到出了问题再补,那时候代价就大了。你想想看,一个内存泄漏查三天,够你写多少行代码了?

总结一句话:内存安全没有捷径,只有养成好习惯,才能少踩坑。

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