动态内存函数这几个字,在C/C++方向的笔试里几乎是从不缺席的常客。malloc、calloc、realloc、free四个函数,看起来就是申请内存、释放内存,可一到手写代码环节,能把这四个函数用对、用稳的人却并不算多。最近我在不少模拟笔试复盘里又看到了大量和动态内存函数相关的典型错误,比如返回局部数组地址、realloc失败后原指针丢失、free之后还在读数据……这些错误高度重复,也正因为如此,大厂笔试才特别喜欢用它们来筛选候选人对内存模型的理解程度。这篇文章想把动态内存函数的笔试题做一个完整的拆解。我不打算罗列API文档,而是对照真实考法,从四个函数的行为差异开始,逐步讲到局部变量生命周期、二级指针传参、realloc失败处理、调用内存检测工具的基本思路,最后再给一份可以直接抄的避坑清单。无论你是准备暑期实习笔试,还是社招刚刷到C语言基础题,又或者只是当年学指针时留下了阴影,都可以用这份内容把动态内存这一块彻底捋顺。1. 动态内存函数的笔试版图:搞懂考点才能有的放矢1.1 大厂笔试题都在考什么动态内存函数是C/C++笔试的“钉子户”,题库里几乎每个方向都会塞一两道。考法看起来千变万化,内核其实非常集中:四个函数的行为和区别、指针传参与二级指针、内存生命周期、内存泄漏与悬垂指针、内存越界与重复释放。只要能把这几条理解到位,大部分题目都能找到解题的主线。我见过很多候选人把注意力放在“背API签名”上,开口能背出“malloc分配size字节,返回void*”,但一写代码就漏了错误处理,或者在函数里返回局部数组。这恰恰说明笔试想考的不是记忆,而是你是否理解“内存从哪来到哪去”。同样一段代码,在本地跑一次可能完全没有异常,但面试官只要改一行,把分配放在循环里,内存泄漏就立刻出现;再多改一行,释放后没有把指针置空,double free又冒出来了。这些连环坑就是笔试官方用来拉差距的地方。1.2 为什么动态内存函数这么容易考崩动态内存问题之所以容易考崩,核心原因是它在编译阶段基本不报警。语法上完全合法,代码也能通过编译器,甚至运行时还能正常输出结果。等到内存越界把管理信息写坏了,或者悬垂指针在某个分支里被再次使用,错误才在完全意想不到的地方炸出来。这种“延迟爆发”的特性,让很多候选人在笔试时信心满满地提交,结果运行一测就翻车。再加上大部分笔试环境不会给你完整的堆检测工具,错误定位全靠读代码。这就逼着你在手写的时候就要把内存所有权、失败路径、释放时机都想清楚。平时写代码“能用就行”的同学,到这里往往第一个崩。所以这篇文章里我会按照笔试的节奏来走:先讲原理,再给错误代码,最后给修正答案,让你在脑子里直接建立一个“错误信号库”。2. 四个函数的核心差异,背清楚不如理解透2.1 malloc 到 free:最基础的内存申请与释放先看最基础的malloc。void *malloc(size_t size)做的事情是从堆区申请一块连续内存,返回首地址。注意两点:第一,它只做分配,不负责初始化,新内存里的内容是“乱七八糟的旧值”;第二,分配失败会返回NULL,所以拿到返回值后第一时间判断是否为NULL,是笔试里最基本但最容易被忽略的一步。释放用free。free(NULL)是合法的,什么都不会发生;但free一个不是通过malloc/calloc/realloc得到的指针,比如栈数组名、字符串常量、已经释放过的指针,行为都是未定义的。这里没有“看起来好像没事”的中间地带,一旦触碰就是雷。笔试常会给出char *p = "hello"; free(p);这种代码,考的就是你是否知道字符串常量不在堆上,不能free。光背这个概念还好,关键是配合代码场景。比如这段:char *p = (char *)malloc(100); strcpy(p, "data"); // ... 一系列操作 free(p);如果中间某个分支提前return,free就没执行,内存泄漏。如果free之后没有把p置为NULL,后面又执行一次free,double free。如果再往后有人继续读取p,那就是use-after-free。这就把三个高频考点全串起来了。2.2 calloc 与 realloc:容易被忽视的行为差异calloc和malloc最大的区别在于:calloc会先把内存清成0。函数签名是void *calloc(size_t nmemb, size_t size),参数是两个:元素个数和每个元素大小,所以它天然适合分配数组。因为内部涉及清零操作,性能会比malloc慢一点,但如果你的业务本来就要求初始化为0,用calloc反而省一次memset。realloc的语义是“调整已经分配的内存块大小”:void *realloc(void *ptr, size_t size)。它可能原地扩容,也可能因为空间不够而搬移到新地址。如果搬移,realloc会负责把旧数据复制过去,并释放旧块。这个过程中,返回值是新地址,旧指针就“失效”了。这里几乎每个笔试题库都会埋一个经典陷阱:p = realloc(p, new_size);。如果realloc失败,它会返回NULL,原内存块却依然存在。你把NULL直接赋给p,原地址就丢了,之后想释放都找不到它,内存泄漏就这么发生了。正确写法是先用一个临时指针接收返回值,确认非NULL后再更新原指针:char *new_p = realloc(p, new_size); if (new_p != NULL) { p = new_p; } else { // 处理失败,p仍然指向旧内存 }2.3 理解“栈、堆、全局区”是解题的地基动态内存函数题说到底考的是内存分区概念。局部变量在栈上,函数返回后栈帧销毁,地址内容不受保护;堆上内存由malloc申请,生命周期由程序员控制,直到free为止;全局变量和静态变量在静态区,程序结束才销毁;字符串常量在只读区,修改它是未定义行为。绝大多数动态内存笔试题的迷惑性都来自这几个区的混用。比如函数内定义一个局部数组,把数组名返回给调用方,从语法上编译能过,运行时可能也“碰巧”能打印出内容,但栈上那片内存早就不是你的了。而如果换成malloc分配后返回,语义就完全不同:你返回的是堆内存地址,只要不free,它在函数外依然有效。这正是考生命周期的意义。3. 手撕高频笔试题:从错误答案到满分答案3.1 第一类:返回局部数组的连环坑先看一道出现频率极高的题目:char *get_string(void) { char p[] = "hello"; return p; }主函数里调用char *str = get_string(); printf("%s\n", str);,问输出是什么。很多第一次接触的人看到“hello”三个字母就直接写了,但实际上这是未定义行为。因为p是栈上的局部数组,函数一返回,栈帧被回收,p指向的区域随时可能被别的调用改写。现场环境碰巧没改,能打出hello;多调用一次printf或者加一个函数,内容就花了。正确写法有很多。如果要返回可修改的字符串,可以用malloc加strcpy:char *get_string(void) { char *p = (char *)malloc(6); if (p != NULL) { strcpy(p, "hello"); } return p; }这样返回的是堆内存,调用方用完必须free。如果题目换成char *p = "hello"; return p;,情况又不一样:字符串常量是只读静态存储,函数返回后地址依然有效,可以打印,但不可修改。两种写法在笔试里经常混着出,你得在答案里把生命周期说清楚,才能拿全分。3.2 第二类:看似能跑实则泄漏的检查题还有一种常见考法是“找错”。题目给一段完整函数,功能是分配内存、处理数据、释放内存,看起来逻辑闭环,实际上在某个分支里漏了释放。比如:void process(int flag) { char *p = (char *)malloc(1024); if (flag) { return; } free(p); }只看后半段,释放没毛病;但flag为真时提前return,p就永远没有释放。笔试考的不是你看不出free,而是你能不能看到所有出口。修正思路有两个:一是每个出口都处理;二是把清理逻辑汇总到一处。C代码常用goto cleanup,C++则用RAII或智能指针,这种对比在面试追问时也经常出现。同时,分配后立刻检查NULL是双保险:char *p = (char *)malloc(1024); if (p == NULL) { return; }在笔试机考环境里,内存不足的情况很少见,但这一行代码能向面试官传递一个信号:你清楚malloc会失败,并知道失败后应该怎么办。这是平时写业务代码最容易漏掉的细节。3.3 第三类:二级指针修改实参的经典题这道题几乎是所有C/C++笔试的必考题:void get_memory(char *p, int num) { p = (char *)malloc(num); } int main(void) { char *str = NULL; get_memory(str, 100); strcpy(str, "hello"); return 0; }陷阱在于:C语言的参数传递是值传递。函数get_memory里的p是实参str的副本,给p赋值malloc结果,只修改了副本,外部的str依然是NULL。strcpy往NULL地址写,段错误几乎是板上钉钉的。解决办法有两种。一种是传二级指针,直接改外部指针变量的值:void get_memory(char **p, int num) { *p = (char *)malloc(num); if (*p == NULL) { // 处理失败 } }调用时写成get_memory(&str, 100);。另一种是让函数把新指针返回出来:char *get_memory(int num) { return ...; }。我个人在笔试手写时更愿意写返回值版本,逻辑更直观;但如果题目明确要求“通过参数返回内存”,就必须用二级指针。3.4 第四类:realloc 偷换概念与“新瓶装旧酒”realloc相关考法通常伪装成“数组扩容”或“追加字符串”。错误代码长这样:char *buf = (char *)malloc(16); strcpy(buf, "hello"); buf = (char *)realloc(buf, 32);如果realloc成功,一切都好;一旦失败,buf被写成NULL,原16字节的堆块变成“孤儿”,无法释放。笔试答案里必须写临时指针。比这再深一点的题目会问:realloc(ptr, 0) 会发生什么?标准里说这是实现定义的行为,可能释放ptr并返回NULL,也可能返回一个非NULL的“零字节块”,唯一正确的做法是不要依赖这种写法。还有realloc(NULL, size)等价于malloc(size),这个行为倒是标准允许的,不过正常代码里没必要靠这个炫技。3.5 第五类:拷贝、拼接与内存重叠动态内存函数经常和字符串、内存拷贝函数组合出题。比如你拿到一个动态分配的缓冲区,要把新内容追加进去,最常见的错误是直接用strcpy(buffer + strlen(buffer), src),没考虑缓冲区剩余空间。笔试的追问会让你计算所需字节数,再决定扩容。另一个容易翻车的是内存区域重叠。memcpy不保证处理重叠区域,memmove才保证。手写my_memmove就是经典的现场编程题,核心要点是:如果目标地址在源地址之前,从前往后拷;否则从后往前拷,避免把源数据覆盖掉。这类题看着和动态内存无关,但笔试常常把大块内存分配和拷贝放在一起考,所以我习惯把它归入动态内存综合题。4. 实操排查:没有运行环境也能一眼看出内存问题4.1 动态内存函数的六大高频报错信号笔试机考时你不可能立刻用完整的检测工具,但运行报错能给你很多信息。我整理了六个最常见信号:报错现象大概率原因排查方向Segmentation fault解引用NULL指针、访问已释放内存、越界检查malloc返回值、free后置NULL、数组边界glibc detected: corrupted double-linked listfree了非堆指针或内部管理信息被写坏检查越界写、double free、指针是否指向非法地址程序卡死或打印乱码悬垂指针、访问栈上失效地址查找返回局部地址的函数内存占用持续增长循环内漏free检查所有分支出口地址随机化后每次崩溃点不同use-after-free或未初始化指针借助ASan定位某些机器上正常,某些机器上崩溃未定义行为,比如返回局部数组、越界写按代码审查处理,不要试图复现每次拿到这类报错,我会先用“三个问题”过一遍代码:谁分配了这块内存?谁负责释放?分配后有没有可能走分支提前返回?这三个问题想清楚,大部分问题就定位到了。4.2 三个可以写进注释的防御习惯第一个习惯是分配后马上检查NULL:int *arr = (int *)malloc(n * sizeof(int)); if (arr == NULL) { return -1; }第二个习惯是free之后立刻把指针置为NULL:free(p); p = NULL;这样即使后续误用,至少会表现为“解引用NULL”,而不是一个飘忽不定的悬垂地址。第三个习惯是在接口注释里注明内存所有权。比如“get_name返回堆内存,调用方负责free”,这句话能避免很多跨模块的内存泄漏。写笔试代码时,我会把这三个习惯原样带到手写题目里,它们不会多花多少时间,但能给面试官留下很好的印象。4.3 本地自测时怎么用工具扫一遍笔试后有条件的话,应该用工具扫一遍自己的代码。Linux下推荐Valgrind,编译时加-g选项,运行valgrind --leak-check=full ./a.out,它会指出哪一行分配的内存没有释放。macOS和Windows也可以启用AddressSanitizer,编译时加-fsanitize=address,运行到错误时会直接打印出错栈,heap-use-after-free这类问题在十秒内就能暴露。工具不能替代对内存模型的理解,但它能逼你正视问题。我见过不少候选人笔试后自己跑一遍Valgrind才发现泄漏,就是因为平时写完不检查。5. 笔试题的进阶变体:内存池与柔性数组5.1 为什么大厂会在此基础上加一道内存池题如果笔试前面几道动态内存题答得顺利,紧接着可能会出现一个“超纲”题:让你设计一个简单的内存池。很多人第一反应是“我不是面C++底层库的,为什么要会这个”。原因是业务里高频、小尺寸、生命周期短的内存分配如果每次都走malloc,会有系统调用开销、锁竞争和碎片问题。内存池的本质是提前向系统申请一大块内存,然后在用户态自己切分、复用,减少malloc/free调用次数,提升稳定性和性能。大厂业务里很多场景,比如网络收包、协议解析、对象频繁创建销毁,都有这种需求。所以笔试出现内存池不是要你背现成代码,而是考察你有没有在大规模分配场景下思考过“系统内存管理到底贵在哪”。你只要能说清楚“一次大块分配+内部空闲链表复用”的基本思路,就能超过一批只会背API的人。5.2 手写简易内存池的思路最简单的一版内存池可以这样设计:初始化时用malloc申请一大块内存,把它按固定大小切分成节点,每个节点是一个空闲块,通过单向链表串起来。分配时从链表头部摘下一个节点,释放时再把节点放回链表头部。核心结构大概是:struct mem_pool_node { struct mem_pool_node *next; }; struct mem_pool { struct mem_pool_node *free_list; size_t block_size; // 其他统计信息 };实现时要注意内存对齐。如果你把节点直接塞给用户,用户拿去存一个double或一个结构体,对齐不对会出问题。最简单的做法是把节点大小按sizeof(double)或者16字节取整;更严格一点的平台还要考虑缓存行对齐。另外还要处理两个方向:池内内存用完之后怎么办,是返回错误还是继续向系统申请新块;多线程环境要不要加锁。笔试不会要求你把完整工程写完,但能把这几个设计的取舍点说出来,分数会明显高不少。5.3 柔性数组:藏在结构体尾部的小考点和动态内存相关的另一个进阶点是柔性数组(flexible array)。一个结构体最后一个成员可以写成char data[];,它不占结构体本身的空间,使用时通过malloc分配sizeof(struct) + extra_len。这样一来,结构体头和可变数据在同一个堆块里,分配一次、释放一次,避免额外指针和二次释放问题。有些笔试会问“为什么不用指针成员代替”,答案是指针会多一次malloc和free,还可能造成内存碎片;柔性数组则把头和体绑定在一起。这一题不是主考点,但在“动态内存函数”的大主题下出现得越来越频繁,值得顺手掌握。6. 常见问题速查与避坑清单6.1 动态内存函数笔试高频FAQ问题结论malloc(0)返回什么?标准允许返回NULL或非NULL的唯一指针,不要解引用free一个指针后还能读吗?不能,属于use-after-free未定义行为,结果是不可预测的calloc为什么能清零?内部会做置零处理,某些实现可能利用操作系统零页优化为什么free不需要传长度?堆管理器在分配时保存了元数据,free时查找对应记录结构体里有指针,free结构体能释放指针指向的数据吗?不能,需要先free内部指针,再free结构体,否则内部数据泄漏realloc失败后原指针还能用吗?能用,原块未被释放,但一定要先保存原指针再处理返回值这张表可以直接当笔试前的复习卡用。高频错点就那几个:返回局部数组、不检查NULL、直接赋值realloc、free后不置空、循环内漏释放。把这些背熟,比刷一百道偏题有用。6.2 我常用的避坑清单写动态内存代码时,我会在提交前按下面几条过一遍:每个malloc/calloc/realloc都有对应的free吗?malloc之后是否检查了NULL?realloc是否用了临时指针接收返回值?free之后指针是否置空?有没有在某个分支提前return,导致跳过free?函数返回值是堆内存吗?如果对外暴露,有没有注明调用方负责释放?数组和缓冲区操作有没有可能越界?结构体复制后,内部指针是浅拷贝还是深拷贝?会不会两个对象释放同一块内存?是否误free了栈变量、字符串常量或非malloc地址?内存分配的大小是否计算正确,比如字符串长度是否留了\0的位置?这些问题全部回答清楚,动态内存部分的正确率就能稳住。我个人检查时还会多问一句:这个函数被调用三次,内存状态会怎样?循环加泄漏最容易被这一问问出来。6.3 一个现场模拟:把错误全部埋进去为了让这套避坑清单落地,我再模拟一道综合题。char *append(char *src, const char *add) { int len1 = strlen(src); int len2 = strlen(add); char *tmp = (char *)malloc(len1 + len2); strcpy(tmp, src); strcat(tmp, add); return tmp; }这段代码至少有四个问题:malloc大小没有加1,放不下\0;没有检查NULL;调用方如果不free,泄漏;如果src本身也是堆内存,调用方可能误以为append会接管它,造成所有权混乱。修正时要另取临时指针,或者直接改接口设计。这种题就是要把前文所有考点堆在一起,读完能一条条列出来,笔试就不虚了。最后说一点个人习惯:我习惯在每道手写代码上交前,用两分钟快速画一下内存状态的草图。malloc后在草图上画一个块,free时划掉,realloc时画新块并标出旧块已失效。这个方法看起来原始,却能非常直观地发现泄漏和悬垂指针。动态内存题考到最后,考的不是技巧,而是你脑子里有没有一张清晰的内存地图。把这张地图练出来,再看到“动态内存函数”这几个字,你就知道那些大厂笔试题翻来覆去也跑不出这个圈。
动态内存函数这几个字,在C/C++方向的笔试里几乎是从不缺席的常客。malloc、calloc、realloc、free四个函数,看起来就是申请内存、释放内存,可一到手写代码环节,能把这四个函数用对、用稳的人却并不算多。最近我在不少模拟笔试复盘里又看到了大量和动态内存函数相关的典型错误,比如返回局部数组地址、realloc失败后原指针丢失、free之后还在读数据……这些错误高度重复,也正因为如此,大厂笔试才特别喜欢用它们来筛选候选人对内存模型的理解程度。这篇文章想把动态内存函数的笔试题做一个完整的拆解。我不打算罗列API文档,而是对照真实考法,从四个函数的行为差异开始,逐步讲到局部变量生命周期、二级指针传参、realloc失败处理、调用内存检测工具的基本思路,最后再给一份可以直接抄的避坑清单。无论你是准备暑期实习笔试,还是社招刚刷到C语言基础题,又或者只是当年学指针时留下了阴影,都可以用这份内容把动态内存这一块彻底捋顺。1. 动态内存函数的笔试版图:搞懂考点才能有的放矢1.1 大厂笔试题都在考什么动态内存函数是C/C++笔试的“钉子户”,题库里几乎每个方向都会塞一两道。考法看起来千变万化,内核其实非常集中:四个函数的行为和区别、指针传参与二级指针、内存生命周期、内存泄漏与悬垂指针、内存越界与重复释放。只要能把这几条理解到位,大部分题目都能找到解题的主线。我见过很多候选人把注意力放在“背API签名”上,开口能背出“malloc分配size字节,返回void*”,但一写代码就漏了错误处理,或者在函数里返回局部数组。这恰恰说明笔试想考的不是记忆,而是你是否理解“内存从哪来到哪去”。同样一段代码,在本地跑一次可能完全没有异常,但面试官只要改一行,把分配放在循环里,内存泄漏就立刻出现;再多改一行,释放后没有把指针置空,double free又冒出来了。这些连环坑就是笔试官方用来拉差距的地方。1.2 为什么动态内存函数这么容易考崩动态内存问题之所以容易考崩,核心原因是它在编译阶段基本不报警。语法上完全合法,代码也能通过编译器,甚至运行时还能正常输出结果。等到内存越界把管理信息写坏了,或者悬垂指针在某个分支里被再次使用,错误才在完全意想不到的地方炸出来。这种“延迟爆发”的特性,让很多候选人在笔试时信心满满地提交,结果运行一测就翻车。再加上大部分笔试环境不会给你完整的堆检测工具,错误定位全靠读代码。这就逼着你在手写的时候就要把内存所有权、失败路径、释放时机都想清楚。平时写代码“能用就行”的同学,到这里往往第一个崩。所以这篇文章里我会按照笔试的节奏来走:先讲原理,再给错误代码,最后给修正答案,让你在脑子里直接建立一个“错误信号库”。2. 四个函数的核心差异,背清楚不如理解透2.1 malloc 到 free:最基础的内存申请与释放先看最基础的malloc。void *malloc(size_t size)做的事情是从堆区申请一块连续内存,返回首地址。注意两点:第一,它只做分配,不负责初始化,新内存里的内容是“乱七八糟的旧值”;第二,分配失败会返回NULL,所以拿到返回值后第一时间判断是否为NULL,是笔试里最基本但最容易被忽略的一步。释放用free。free(NULL)是合法的,什么都不会发生;但free一个不是通过malloc/calloc/realloc得到的指针,比如栈数组名、字符串常量、已经释放过的指针,行为都是未定义的。这里没有“看起来好像没事”的中间地带,一旦触碰就是雷。笔试常会给出char *p = "hello"; free(p);这种代码,考的就是你是否知道字符串常量不在堆上,不能free。光背这个概念还好,关键是配合代码场景。比如这段:char *p = (char *)malloc(100); strcpy(p, "data"); // ... 一系列操作 free(p);如果中间某个分支提前return,free就没执行,内存泄漏。如果free之后没有把p置为NULL,后面又执行一次free,double free。如果再往后有人继续读取p,那就是use-after-free。这就把三个高频考点全串起来了。2.2 calloc 与 realloc:容易被忽视的行为差异calloc和malloc最大的区别在于:calloc会先把内存清成0。函数签名是void *calloc(size_t nmemb, size_t size),参数是两个:元素个数和每个元素大小,所以它天然适合分配数组。因为内部涉及清零操作,性能会比malloc慢一点,但如果你的业务本来就要求初始化为0,用calloc反而省一次memset。realloc的语义是“调整已经分配的内存块大小”:void *realloc(void *ptr, size_t size)。它可能原地扩容,也可能因为空间不够而搬移到新地址。如果搬移,realloc会负责把旧数据复制过去,并释放旧块。这个过程中,返回值是新地址,旧指针就“失效”了。这里几乎每个笔试题库都会埋一个经典陷阱:p = realloc(p, new_size);。如果realloc失败,它会返回NULL,原内存块却依然存在。你把NULL直接赋给p,原地址就丢了,之后想释放都找不到它,内存泄漏就这么发生了。正确写法是先用一个临时指针接收返回值,确认非NULL后再更新原指针:char *new_p = realloc(p, new_size); if (new_p != NULL) { p = new_p; } else { // 处理失败,p仍然指向旧内存 }2.3 理解“栈、堆、全局区”是解题的地基动态内存函数题说到底考的是内存分区概念。局部变量在栈上,函数返回后栈帧销毁,地址内容不受保护;堆上内存由malloc申请,生命周期由程序员控制,直到free为止;全局变量和静态变量在静态区,程序结束才销毁;字符串常量在只读区,修改它是未定义行为。绝大多数动态内存笔试题的迷惑性都来自这几个区的混用。比如函数内定义一个局部数组,把数组名返回给调用方,从语法上编译能过,运行时可能也“碰巧”能打印出内容,但栈上那片内存早就不是你的了。而如果换成malloc分配后返回,语义就完全不同:你返回的是堆内存地址,只要不free,它在函数外依然有效。这正是考生命周期的意义。3. 手撕高频笔试题:从错误答案到满分答案3.1 第一类:返回局部数组的连环坑先看一道出现频率极高的题目:char *get_string(void) { char p[] = "hello"; return p; }主函数里调用char *str = get_string(); printf("%s\n", str);,问输出是什么。很多第一次接触的人看到“hello”三个字母就直接写了,但实际上这是未定义行为。因为p是栈上的局部数组,函数一返回,栈帧被回收,p指向的区域随时可能被别的调用改写。现场环境碰巧没改,能打出hello;多调用一次printf或者加一个函数,内容就花了。正确写法有很多。如果要返回可修改的字符串,可以用malloc加strcpy:char *get_string(void) { char *p = (char *)malloc(6); if (p != NULL) { strcpy(p, "hello"); } return p; }这样返回的是堆内存,调用方用完必须free。如果题目换成char *p = "hello"; return p;,情况又不一样:字符串常量是只读静态存储,函数返回后地址依然有效,可以打印,但不可修改。两种写法在笔试里经常混着出,你得在答案里把生命周期说清楚,才能拿全分。3.2 第二类:看似能跑实则泄漏的检查题还有一种常见考法是“找错”。题目给一段完整函数,功能是分配内存、处理数据、释放内存,看起来逻辑闭环,实际上在某个分支里漏了释放。比如:void process(int flag) { char *p = (char *)malloc(1024); if (flag) { return; } free(p); }只看后半段,释放没毛病;但flag为真时提前return,p就永远没有释放。笔试考的不是你看不出free,而是你能不能看到所有出口。修正思路有两个:一是每个出口都处理;二是把清理逻辑汇总到一处。C代码常用goto cleanup,C++则用RAII或智能指针,这种对比在面试追问时也经常出现。同时,分配后立刻检查NULL是双保险:char *p = (char *)malloc(1024); if (p == NULL) { return; }在笔试机考环境里,内存不足的情况很少见,但这一行代码能向面试官传递一个信号:你清楚malloc会失败,并知道失败后应该怎么办。这是平时写业务代码最容易漏掉的细节。3.3 第三类:二级指针修改实参的经典题这道题几乎是所有C/C++笔试的必考题:void get_memory(char *p, int num) { p = (char *)malloc(num); } int main(void) { char *str = NULL; get_memory(str, 100); strcpy(str, "hello"); return 0; }陷阱在于:C语言的参数传递是值传递。函数get_memory里的p是实参str的副本,给p赋值malloc结果,只修改了副本,外部的str依然是NULL。strcpy往NULL地址写,段错误几乎是板上钉钉的。解决办法有两种。一种是传二级指针,直接改外部指针变量的值:void get_memory(char **p, int num) { *p = (char *)malloc(num); if (*p == NULL) { // 处理失败 } }调用时写成get_memory(&str, 100);。另一种是让函数把新指针返回出来:char *get_memory(int num) { return ...; }。我个人在笔试手写时更愿意写返回值版本,逻辑更直观;但如果题目明确要求“通过参数返回内存”,就必须用二级指针。3.4 第四类:realloc 偷换概念与“新瓶装旧酒”realloc相关考法通常伪装成“数组扩容”或“追加字符串”。错误代码长这样:char *buf = (char *)malloc(16); strcpy(buf, "hello"); buf = (char *)realloc(buf, 32);如果realloc成功,一切都好;一旦失败,buf被写成NULL,原16字节的堆块变成“孤儿”,无法释放。笔试答案里必须写临时指针。比这再深一点的题目会问:realloc(ptr, 0) 会发生什么?标准里说这是实现定义的行为,可能释放ptr并返回NULL,也可能返回一个非NULL的“零字节块”,唯一正确的做法是不要依赖这种写法。还有realloc(NULL, size)等价于malloc(size),这个行为倒是标准允许的,不过正常代码里没必要靠这个炫技。3.5 第五类:拷贝、拼接与内存重叠动态内存函数经常和字符串、内存拷贝函数组合出题。比如你拿到一个动态分配的缓冲区,要把新内容追加进去,最常见的错误是直接用strcpy(buffer + strlen(buffer), src),没考虑缓冲区剩余空间。笔试的追问会让你计算所需字节数,再决定扩容。另一个容易翻车的是内存区域重叠。memcpy不保证处理重叠区域,memmove才保证。手写my_memmove就是经典的现场编程题,核心要点是:如果目标地址在源地址之前,从前往后拷;否则从后往前拷,避免把源数据覆盖掉。这类题看着和动态内存无关,但笔试常常把大块内存分配和拷贝放在一起考,所以我习惯把它归入动态内存综合题。4. 实操排查:没有运行环境也能一眼看出内存问题4.1 动态内存函数的六大高频报错信号笔试机考时你不可能立刻用完整的检测工具,但运行报错能给你很多信息。我整理了六个最常见信号:报错现象大概率原因排查方向Segmentation fault解引用NULL指针、访问已释放内存、越界检查malloc返回值、free后置NULL、数组边界glibc detected: corrupted double-linked listfree了非堆指针或内部管理信息被写坏检查越界写、double free、指针是否指向非法地址程序卡死或打印乱码悬垂指针、访问栈上失效地址查找返回局部地址的函数内存占用持续增长循环内漏free检查所有分支出口地址随机化后每次崩溃点不同use-after-free或未初始化指针借助ASan定位某些机器上正常,某些机器上崩溃未定义行为,比如返回局部数组、越界写按代码审查处理,不要试图复现每次拿到这类报错,我会先用“三个问题”过一遍代码:谁分配了这块内存?谁负责释放?分配后有没有可能走分支提前返回?这三个问题想清楚,大部分问题就定位到了。4.2 三个可以写进注释的防御习惯第一个习惯是分配后马上检查NULL:int *arr = (int *)malloc(n * sizeof(int)); if (arr == NULL) { return -1; }第二个习惯是free之后立刻把指针置为NULL:free(p); p = NULL;这样即使后续误用,至少会表现为“解引用NULL”,而不是一个飘忽不定的悬垂地址。第三个习惯是在接口注释里注明内存所有权。比如“get_name返回堆内存,调用方负责free”,这句话能避免很多跨模块的内存泄漏。写笔试代码时,我会把这三个习惯原样带到手写题目里,它们不会多花多少时间,但能给面试官留下很好的印象。4.3 本地自测时怎么用工具扫一遍笔试后有条件的话,应该用工具扫一遍自己的代码。Linux下推荐Valgrind,编译时加-g选项,运行valgrind --leak-check=full ./a.out,它会指出哪一行分配的内存没有释放。macOS和Windows也可以启用AddressSanitizer,编译时加-fsanitize=address,运行到错误时会直接打印出错栈,heap-use-after-free这类问题在十秒内就能暴露。工具不能替代对内存模型的理解,但它能逼你正视问题。我见过不少候选人笔试后自己跑一遍Valgrind才发现泄漏,就是因为平时写完不检查。5. 笔试题的进阶变体:内存池与柔性数组5.1 为什么大厂会在此基础上加一道内存池题如果笔试前面几道动态内存题答得顺利,紧接着可能会出现一个“超纲”题:让你设计一个简单的内存池。很多人第一反应是“我不是面C++底层库的,为什么要会这个”。原因是业务里高频、小尺寸、生命周期短的内存分配如果每次都走malloc,会有系统调用开销、锁竞争和碎片问题。内存池的本质是提前向系统申请一大块内存,然后在用户态自己切分、复用,减少malloc/free调用次数,提升稳定性和性能。大厂业务里很多场景,比如网络收包、协议解析、对象频繁创建销毁,都有这种需求。所以笔试出现内存池不是要你背现成代码,而是考察你有没有在大规模分配场景下思考过“系统内存管理到底贵在哪”。你只要能说清楚“一次大块分配+内部空闲链表复用”的基本思路,就能超过一批只会背API的人。5.2 手写简易内存池的思路最简单的一版内存池可以这样设计:初始化时用malloc申请一大块内存,把它按固定大小切分成节点,每个节点是一个空闲块,通过单向链表串起来。分配时从链表头部摘下一个节点,释放时再把节点放回链表头部。核心结构大概是:struct mem_pool_node { struct mem_pool_node *next; }; struct mem_pool { struct mem_pool_node *free_list; size_t block_size; // 其他统计信息 };实现时要注意内存对齐。如果你把节点直接塞给用户,用户拿去存一个double或一个结构体,对齐不对会出问题。最简单的做法是把节点大小按sizeof(double)或者16字节取整;更严格一点的平台还要考虑缓存行对齐。另外还要处理两个方向:池内内存用完之后怎么办,是返回错误还是继续向系统申请新块;多线程环境要不要加锁。笔试不会要求你把完整工程写完,但能把这几个设计的取舍点说出来,分数会明显高不少。5.3 柔性数组:藏在结构体尾部的小考点和动态内存相关的另一个进阶点是柔性数组(flexible array)。一个结构体最后一个成员可以写成char data[];,它不占结构体本身的空间,使用时通过malloc分配sizeof(struct) + extra_len。这样一来,结构体头和可变数据在同一个堆块里,分配一次、释放一次,避免额外指针和二次释放问题。有些笔试会问“为什么不用指针成员代替”,答案是指针会多一次malloc和free,还可能造成内存碎片;柔性数组则把头和体绑定在一起。这一题不是主考点,但在“动态内存函数”的大主题下出现得越来越频繁,值得顺手掌握。6. 常见问题速查与避坑清单6.1 动态内存函数笔试高频FAQ问题结论malloc(0)返回什么?标准允许返回NULL或非NULL的唯一指针,不要解引用free一个指针后还能读吗?不能,属于use-after-free未定义行为,结果是不可预测的calloc为什么能清零?内部会做置零处理,某些实现可能利用操作系统零页优化为什么free不需要传长度?堆管理器在分配时保存了元数据,free时查找对应记录结构体里有指针,free结构体能释放指针指向的数据吗?不能,需要先free内部指针,再free结构体,否则内部数据泄漏realloc失败后原指针还能用吗?能用,原块未被释放,但一定要先保存原指针再处理返回值这张表可以直接当笔试前的复习卡用。高频错点就那几个:返回局部数组、不检查NULL、直接赋值realloc、free后不置空、循环内漏释放。把这些背熟,比刷一百道偏题有用。6.2 我常用的避坑清单写动态内存代码时,我会在提交前按下面几条过一遍:每个malloc/calloc/realloc都有对应的free吗?malloc之后是否检查了NULL?realloc是否用了临时指针接收返回值?free之后指针是否置空?有没有在某个分支提前return,导致跳过free?函数返回值是堆内存吗?如果对外暴露,有没有注明调用方负责释放?数组和缓冲区操作有没有可能越界?结构体复制后,内部指针是浅拷贝还是深拷贝?会不会两个对象释放同一块内存?是否误free了栈变量、字符串常量或非malloc地址?内存分配的大小是否计算正确,比如字符串长度是否留了\0的位置?这些问题全部回答清楚,动态内存部分的正确率就能稳住。我个人检查时还会多问一句:这个函数被调用三次,内存状态会怎样?循环加泄漏最容易被这一问问出来。6.3 一个现场模拟:把错误全部埋进去为了让这套避坑清单落地,我再模拟一道综合题。char *append(char *src, const char *add) { int len1 = strlen(src); int len2 = strlen(add); char *tmp = (char *)malloc(len1 + len2); strcpy(tmp, src); strcat(tmp, add); return tmp; }这段代码至少有四个问题:malloc大小没有加1,放不下\0;没有检查NULL;调用方如果不free,泄漏;如果src本身也是堆内存,调用方可能误以为append会接管它,造成所有权混乱。修正时要另取临时指针,或者直接改接口设计。这种题就是要把前文所有考点堆在一起,读完能一条条列出来,笔试就不虚了。最后说一点个人习惯:我习惯在每道手写代码上交前,用两分钟快速画一下内存状态的草图。malloc后在草图上画一个块,free时划掉,realloc时画新块并标出旧块已失效。这个方法看起来原始,却能非常直观地发现泄漏和悬垂指针。动态内存题考到最后,考的不是技巧,而是你脑子里有没有一张清晰的内存地图。把这张地图练出来,再看到“动态内存函数”这几个字,你就知道那些大厂笔试题翻来覆去也跑不出这个圈。