☰
大厂C语言笔试常考动态内存函数:malloc、free、realloc底层原理与改错题实战
2026/10/20 9:00:59 网站建设 项目流程

如果你看过几套大厂笔试题,一定会发现一个很奇怪的现象:C语言题目里那些压轴的、看起来像“改错题”的题,最后几乎都绕不开malloc、free、realloc这几个动态内存函数。比如“malloc(0)之后if判空为什么不为空?”、“getMemory之后主函数里的指针为什么还是NULL?”、“为什么一个看似正常的结构体排序,跑到一半就崩了?”——我第一次看到这些题时也觉得很刁钻,后来才想明白,面试官考的根本不是你能不能背出来API,而是你有没有真正理解内存模型。

这篇文章我整理了近两年笔试里出现频率最高的几道动态内存函数真题,从考点分布、调用原理、改错现场、结构体排序、realloc边界到内存泄漏排查,一条线讲透。无论你是正在准备校招的应届生,还是想补基础的在职开发,只要把这块啃下来,笔试里90%的内存题都能稳住。

1. 动态内存试题的“长相”:全家桶考点与底层语义

1.1 笔试里动态内存函数常见的题型分布

我不止一次说过,刷笔试真题最重要的是先摸清“对方到底想考什么”。观察了近三年的笔试和面经记录,动态内存函数的考法基本集中在下面这四类:

考察点经典问法容易掉的陷阱
malloc / calloc / realloc / free 语义“malloc(0)返回什么?”误以为返回NULL或一定崩溃
指针参数传递“getMemory为什么没生效?”搞混值传递与引用传递
动态数组与结构体“动态创建结构体数组并按成绩排序”sizeof计算、free顺序、strcpy越界
边界与泄漏“realloc失败怎么办?”、“如何找泄漏?”直接覆盖原指针、漏free

从表格里能看到一个大趋势:笔试很少单独问你“malloc的作用是什么”,而是会把动态内存函数嵌到一个“看似完整”的C程序里,让你找错、补全、推导输出。你要是不懂堆内存的底子,光会写ptr = (int*)malloc(n * sizeof(int)),遇到变体题就很容易翻车。

1.2 堆分配器的底层语义:malloc究竟返回了什么

要手撕这些题,第一步不是背API,而是搞懂堆区到底发生了什么。malloc(size)做的事,本质是从进程的堆空间里找一块“足够大”的空闲内存块,把它标记为已占用,然后返回这块内存的起始地址。

这里有一个关键点:分配器返回给用户的字节数,往往比我们申请的要大。因为堆块头部需要存储大小、状态、指向前后块的指针等元数据;而且为了对齐,实际分配的内存通常是16字节或8字节的整数倍。这就是为什么“越界访问几个字节不崩”是假象,不代表安全。

举个例子:

#include <stdio.h> #include <stdlib.h> #include <string.h> int main() { char *p = (char*)malloc(0); printf("p = %p\n", (void*)p); if (p) { strcpy(p, "abc"); // 未定义行为,但在多数实现上可能“看起来没崩” free(p); } return 0; }

标准并没有规定malloc(0)必须返回 NULL,它允许返回一个唯一的非空指针,也允许返回 NULL。在大多数主流平台的实际实现里,malloc(0)会返回一个合法的、可被free的指针,但“能 free”不等于“能解引用写入”。strcpy往里面写数据属于未定义行为,虽然很多实现因为分配器给了额外的填充空间而不立刻崩溃,但谁也无法保证它不把相邻堆块的管理信息踩烂。

笔试里问你这个问题,正确答案不是“一定崩溃”或“一定返回NULL”,而是“返回值未定义/不可依赖,绝对不应该对malloc(0)的结果做写入操作”。这才是考官想听的。

1.3 calloc与realloc的隐藏考点:初始化与扩容的代价

calloc(n, size)和malloc(n * size)的区别不止是“清零”。看一道常见追问:

int *a = calloc(10, sizeof(int)); int *b = malloc(10 * sizeof(int));

问:两者在什么情况下行为不同?不少人答“calloc会把内存清零,malloc不会”,这只对了一半。更深一层的考点是溢出检查:malloc(10 * sizeof(int))是直接做乘法,如果 n 很大导致乘法溢出,会申请到一块比预期小的内存,后续写数据就造成堆破坏;而calloc在内部会检查乘法是否溢出,如果溢出则直接返回 NULL。笔试里把它包装成“实现一个安全的内存池”或“解释calloc为什么存在”就是这个道理。

关于free,还有一个高频追问:为什么free(p)不需要传大小?因为分配器在p指向地址的前面(或附近)保存了元数据,free会根据这段信息知道这个块有多大、属于哪个空闲链表。这也解释了为什么“释放了栈上变量”“越界破坏元数据”会导致一系列诡异崩溃。

注意:这些底层语义不需要我们记到每条寄存器级别,但至少要知道“分配器是有状态的、堆块是有元数据的”,否则后面所有改错题都无从下手。

2. 改错题现场:getMemory为什么没生效

2.1 从一道高频笔试题讲起

先看这道出现频率极高的大厂笔试题,它经常以“指出下面代码的错误并修正”的形式出现:

#include <stdio.h> #include <stdlib.h> void getMemory(char *p, int n) { p = (char*)malloc(n); // 申请完就丢了 } int main() { char *ptr = NULL; getMemory(ptr, 100); strcpy(ptr, "hello"); // 危险:ptr仍然是NULL free(ptr); return 0; }

很多人第一眼觉得“没问题啊,函数里malloc了,外面也能用了吧”。但运行起来大概率直接段错误。原因非常基础:C语言参数传递是值传递。ptr本身是一个指针变量,调用getMemory(ptr, 100)时,函数拿到的是ptr的副本,也就是 NULL 的值。函数内p = malloc(...)修改的只是副本p的指向,主函数里的ptr不会发生任何变化。

我把这个道理用生活类比说一下:这就像你把家门钥匙复印了一份交给管家,管家在复印钥匙上磨了新齿,原钥匙当然不会变。想要让外面的钥匙变,你得直接把原钥匙本身交给管家,或者让管家把新钥匙给你。

2.2 三种修正方案与选择理由

这种题的修正方案不止一种,笔试里你最好能写全,并且说明区别。

方案代码写法适用场景优点缺点
返回指针char* getMemory(int n)最常见的修法用法直观要处理好失败返回NULL
二级指针void getMemory(char **p, int n)同时想获得多个变量时能改外部指针代码可读性一般
C++引用void getMemory(char *&p, int n)C++环境写法自然仅限C++

如果按二级指针写法,参考实现是:

void getMemory(char **p, int n) { if (p == NULL) { return; } *p = (char*)malloc(n); if (*p == NULL) { exit(1); // 笔试里也可以返回错误码 } } int main() { char *ptr = NULL; getMemory(&ptr, 100); if (ptr != NULL) { strcpy(ptr, "hello"); printf("%s\n", ptr); free(ptr); ptr = NULL; } return 0; }

注意两个细节:第一,getMemory里首先要判空p本身;第二,主函数里free之后把ptr置空。后者在笔试里容易被漏掉,但面试官几乎必追问“free之后为什么建议置NULL”。原因很简单:free只释放了内存,没有改变指针变量本身的值,这时候ptr成为一个悬空指针,如果后面再free一次或解引用,行为完全未定义,轻则脏数据,重则堆崩溃。

2.3 现场延伸出的“内存生命周期”追问

还有一个延伸考点值得提前准备:改完这道题后,面试官经常追加“那我为什么不能在函数里直接return局部数组?”这就要联系栈和堆的本质了。函数内创建的普通数组在栈上,函数返回后栈帧被销毁,返回的地址指向的是已经失效的数据;而malloc分配的内存来自堆,生命周期持续到free为止,跨函数传递天然成立。

笔试作答时建议先答主干:“由于值是拷贝传递,函数内部对形参指针的修改不会影响实参”,然后给出修正代码,最后主动说明“free后置空指针”。能主动说出这三层,基本就是满分回答了。

3. 结构体动态数组与排序:一道题覆盖八个考点

3.1 完整题目与读题拆解

笔试里动态内存函数的高阶考法,是把它和结构体、排序、字符串拷贝组合在一起。下面这道题,我见过好几个版本,核心逻辑大同小异:

定义一个学生结构体Student,包含姓名(不超过20字节)和成绩。请根据输入的学生人数n,动态创建学生数组,输入每个学生的姓名与成绩,然后按成绩从高到低排序,并打印结果。要求:全程使用动态内存,最后释放所有内存。

这道题看着不难,但把“动态创建n个学生”“结构体数组排序”“正确释放”三个环节全踩一遍,错误率非常高。先做一个读题拆解:

  • 学生数量 n 是运行时输入的,不能写死,所以要malloc(n * sizeof(Student));
  • 姓名是不定长字符串,但结构体里如果用char name[20],输入时要注意长度边界;
  • 排序如果用qsort,要在比较函数里正确处理 “成绩降序” 和 “成绩相等时的稳定输出”;
  • 最后释放只需要一次free(stu),因为所有学生是连续分配在一个数组里的,不需要逐个 free。

3.2 参考实现与逐步解释

#include <stdio.h> #include <stdlib.h> #include <string.h> typedef struct Student { char name[20]; int score; } Student; int cmp(const void *a, const void *b) { const Student *sa = (const Student*)a; const Student *sb = (const Student*)b; if (sa->score != sb->score) { return sb->score - sa->score; // 降序 } return strcmp(sa->name, sb->name); // 成绩相同按姓名升序 } int main() { int n = 0; printf("请输入学生人数: "); scanf("%d", &n); if (n <= 0) { return 1; } Student *stu = (Student*)malloc(n * sizeof(Student)); if (stu == NULL) { return 1; } for (int i = 0; i < n; i++) { printf("输入第%d个学生的姓名和成绩: ", i + 1); scanf("%19s%d", stu[i].name, &stu[i].score); } qsort(stu, n, sizeof(Student), cmp); printf("排序后的结果:\n"); for (int i = 0; i < n; i++) { printf("%s %d\n", stu[i].name, stu[i].score); } free(stu); stu = NULL; return 0; }

这里有一行值得展开:scanf("%19s%d", stu[i].name, &stu[i].score)里的%19s,目的是限制字符串最多读19个字符,给结尾的\0留位置。很多写崩的代码就是直接%s读入,用户输入一个超长姓名后,strcpy写入时越界,直接把堆里的元数据踩掉了,表现是“排序前没事,排序中崩,甚至退出时才崩”。

关于qsort的比较函数,cmp的返回值是 int。sb->score - sa->score在成绩相差不大的场景没问题,但如果成绩是很大的数或 unsigned 类型,减法溢出会有隐患。笔试里一般不会考到这个极端,但如果你能主动说“更稳妥的写法是先用 >/< 判断再返回 -1/0/1”,会加分。

3.3 五个高频翻车点与应对

第一,sizeof(Student)不是简单把name[20]和int score加起来等于24。由于内存对齐,编译器可能填充字节,实际 size 可能是24或28。这个细节在笔试改错题里常出现,正确做法是在代码里printf("%zu\n", sizeof(Student))确认,或者直接用sizeof而不是手写数字。

第二,strcpy、strcat这类函数不做边界检查。上面用%19s限制了输入,但如果题目要求用strcpy把某个可能超过长度的字符串赋给name,必须先用strlen判断。

第三,释放时机。结构体数组是连续分配的一个大块,只要free(stu)一次即可;但如果有char **names这种“先分配指针数组,再为每个字符串分配”的结构,释放顺序必须先逐个 free 每个字符串,最后 free 指针数组本身。

第四,排序后打印没问题,但如果题目要求“同时保留原始顺序”,就需要再定义一个索引数组,动态内存题里也喜欢借这个点考察多维动态数组。

第五,忘记stu = NULL。虽然程序结束会自动回收,但笔试代码里体现了这个习惯,面试官会认为你有防御性编程意识。

4. realloc的三态变化与重叠拷贝边界

4.1 realloc返回的三种情况与安全写法

realloc是动态内存题里的“欺诈师”,看起来只是扩容,实际上包含三种完全不同的行为:

情况分配器表现对原指针的影响
原地扩容当前块后面有足够空闲空间,扩展原块返回原地址
搬家扩容当前块后面不够,换一块更大的内存并拷贝原数据返回新地址,原指针变悬空
失败内存不足,返回NULL原内存仍然有效,未被释放

坑就在第二种和第三种。笔试中很经典的一段错误代码:

p = (int*)realloc(p, new_size); // 错误示范

如果realloc失败,它会返回 NULL,并让原指针p成为 NULL。此时原来的内存块既没有释放,也丢失了地址,等于“泄漏 + 数据丢失”双杀。正确写法是使用临时指针:

int *tmp = (int*)realloc(p, new_size); if (tmp != NULL) { p = tmp; // 成功才更新原指针 } else { // 处理失败:继续使用 p 的旧内存,或执行清理 }

还有一个边界问题:realloc(p, 0)。标准未定义具体行为,常见实现会把p释放掉并返回一个可能非空的极小指针,类似于malloc(0)的哨兵。笔试里问“realloc(NULL, size)”等价什么,答案是malloc(size),这个也要记牢。

4.2 memcpy与memmove重叠区域的考点

虽然标题是动态内存函数,但内存拷贝函数在笔试里经常和动态内存一起出现,因为真正的“内存题”整体都属于这一块。经典考题是:

char str[] = "hello world"; memcpy(str + 1, str, 4); // 重叠区域

memcpy不保证处理重叠区域,而复制的行为取决于内部是按字节还是按块拷贝。当源地址和目的地址有重叠时,结果可能不是你想象的那样,甚至可能因为重叠后的源数据被覆盖,导致后半段复制出乱码。memmove则专门设计了重叠处理,它会先判断前后关系,必要时从末尾开始反向拷贝。

笔试里如果你被要求“手动实现一个支持重叠区域的移动函数”,一个简洁思路是:先用memcmp确定两个区间是否重叠,若dest < src,从头往后拷贝;若dest > src,从尾往前拷贝。实际笔试作答时不用写很复杂,关键是点出“从尾往前”这个处理手法,并说明memmove的典型实现策略。

4.3 组合场景:扩容后漏判导致的连环坑

真正的大厂笔试不是单独问某一个函数,而是把 realloc 和字符串、结构体组合成一个“内存池扩容”问题。例如让实现“一个整数数组的动态追加功能”,你的思路应该是:

static int *data = NULL; static int count = 0; static int capacity = 0; void append(int value) { if (count >= capacity) { int new_cap = capacity == 0 ? 4 : capacity * 2; int *tmp = (int*)realloc(data, new_cap * sizeof(int)); if (tmp == NULL) { return; // 保留原数据,等待下次扩容 } data = tmp; capacity = new_cap; } data[count++] = value; }

这段代码最大的价值是演示了“扩容失败不丢失旧数据”的工程思想。笔试改错版本通常会把realloc直接赋值给data,或者在新容量计算时不乘sizeof(int),这两处都是送分陷阱。

5. 笔试里的内存泄漏排查:没有工具时怎么定位

5.1 一个典型的“看不出来”的泄漏现场

动态内存笔试另一类常见题是“检查下列代码是否存在内存泄漏”。给你看一个很典型的例子:

#include <stdio.h> #include <stdlib.h> #include <string.h> char* getPrefix(const char *s) { size_t len = strlen(s); char *p = (char*)malloc(len + 2); if (p == NULL) return NULL; memcpy(p, s, len); p[len] = '\0'; return p; } int main() { char *a = getPrefix("prefix"); char *b = getPrefix("abc"); a = b; // 泄漏点:a原来指向的内存没人释放了 printf("%s %s\n", a, b); return 0; }

这里的泄漏点很隐蔽:getPrefix每次分配一个字符串,主函数先后获得a、b两块内存。但a = b之后,原来的a内存块丢失了指针,再也无法释放。这是笔试里“指针覆盖导致泄漏”的典型结构。

在我改过的模拟面试里,很多同学能说出“泄漏了”,但说不清是“哪一行泄漏”。其实只要掌握一个排查思路,就能快速定位:跟踪每一块 malloc 内存,看它是否被 free,且在 free 之前是否有另一个赋值覆盖了它。

5.2 适当使用工具:Valgrind与ASan的报表行

现实中笔试环境不一定装了工具,但你心里要有一张“工具报表怎么看”的地图。如果本机可以跑,最常用的是开源内存调试工具 Valgrind:

valgrind --leak-check=full ./demo

它的输出会显示类似definitely lost: 9 bytes in 1 blocks,并指到getPrefix的 malloc 那行,然后显示 “by main” 的调用栈。要看definitely lost和still reachable的区别:前者是真正的泄漏,后者可能是程序退出前仍可访问但未释放的内存,某些场景不算严重。不过笔试里建议都解释成“程序退出前未释放”,不会错。

编译器的 AddressSanitizer 也很有用。编译时加-fsanitize=address -g,运行遇到越界、释放后再使用、泄漏会直接报告到具体行号,比从头翻代码快得多。

5.3 手工排查的“配对法”与变量三步走

没有工具时,笔试答题可以这样分步骤,既快又不容易漏:

  1. 列出所有 malloc / calloc / realloc 代码行,给每块内存起个名字:块1、块2、块3;
  2. 追踪每个指针变量在生命周期内的所有赋值操作,重点看是否出现“把指针A覆盖成指针B”的语句;
  3. 检查每个 free 调用对应的地址,是否覆盖到了所有块,顺序是否颠倒。

举个例子,对于“指针数组里存放多个字符串”的释放,很多同学会错误地只free(names),忘了先free(names[0])、free(names[1])……配对法能一眼看出:为每个names[i]分配的块都没有配对 free。这个检查清单在笔试时间紧张的时候价值很大。

提示:面试现场如果允许写代码,不要光口头说“这里泄漏了”,最好补一个最小示例,展示 free 之后指针置空的过程。这会让考官觉得你是真懂,不是在背结论。

6. 隐藏考点与备考策略:面试官真正在测的三层能力

6.1 表面是函数题,内里是三层能力模型

把上面的真题拆完,你会发现动态内存函数题只是一个载体。面试官真正在考察的是三层递进能力:

第一层,内存生命周期管理。你能不能一眼看出每块内存从分配到释放的完整路径,知道它什么时候存活、什么时候失效。这决定你写出的系统会不会泄漏、会不会悬空。

第二层,指针语义理解。值传递、二级指针、数组名退化为指针、sizeof(指针)与sizeof(数组)的区别等,本质上都是同一个知识模块。动态内存函数题只是把这些概念强制集中到一段小代码里。

第三层,错误恢复与健壮性。realloc失败怎么保底、malloc 失败要不要退出、free后要不要置空、函数入口要不要判空参数,这些在笔试“改错题”里是最容易拿分也最容易丢分的部分。

6.2 短期备考:把常见题拆成“模板”背熟

如果你的笔试时间只剩一两周,可以按模板复习:先掌握malloc/calloc/realloc/free的标准语义表格,再背熟“getMemory二级指针修正”和“realloc安全写法”两段代码,然后刷十道结构体排序题,最后练一遍 Valgrind 报告解读。

面试前可以做一个“错题本”,把每次写错的点按“指针值传递”“free后未置空”“realloc丢指针”“strcpy越界”分类。这个分类本身就是面试官常见的追问方向。

6.3 长期建议:读一遍分配器源码,画一遍堆图

如果想真正从根上拿稳动态内存这部分的分数,我建议你找个空闲时间,打开某个开源内存分配器的源码,只专注看三件事:空闲块怎么组织、malloc 如何找到合适块、free 如何把块挂回链表。看完之后,在纸上画出“申请两次内存后再 free 一次”的堆块与空闲链表变化图。

这个过程比刷几十道题还有用。因为笔试里的诡异行为——越界不崩、释放后又访问、realloc 搬家后旧指针变悬空——几乎都能用堆块图解释清楚。画图你会看到“越界踩的是相邻块的头部元数据”这件事有多危险,也会自然理解为什么free之后必须避免再碰那块内存。

我个人带新人时最强的感受是:能把动态内存函数讲清楚的,写业务代码时普遍很少犯资源泄漏的低级错误;讲不清楚的,即使背了很多题,遇到内存池、缓存模块、长连接队列这类场景依然会埋雷。所以这篇整理与其说是应付笔试,不如说是在帮自己补一门必修课。做题时多问几个为什么,比多做几套题重要得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询