C语言错题集:指针、数组越界与内存隐患的实战避坑指南

发布时间:2026/10/11 3:16:46
C语言错题集:指针、数组越界与内存隐患的实战避坑指南 说到C语言错题集我第一个想到的不是学生时代的笔记本而是工作后第一次被段错误折磨到凌晨三点的那个晚上。C语言就是这样一门语言它语法简单自由度高但正是这份自由让错误变成了常态排查变成了硬功夫。写Java、Python时编译器会大声喊“你错了”写C语言时常常是它默不作声程序跑着跑着就崩了甚至不崩只是结果不对。这篇文章就是把我这些年踩过的坑、记过的错题翻出来重新整理适合三类人看刚学完C语言语法、正在做课程设计或小项目的初学者准备笔试面试、想避开经典陷阱的求职者以及想从“会写”走向“写稳”的开发者。1. 指针阵营的高频翻车点每一道都值得记进错题本指针是C语言的灵魂也是错题集里最厚的一沓。很多人学指针的时候觉得“不就是存地址吗”真到写代码才发现光是初始化、运算、生命周期三件事就够喝一壶。1.1 未初始化的野指针解引用之前先问三句话先看一个最经典的翻车现场int *p; *p 5;这段代码在部分编译器上能“正常”编译但运行大概率段错误更可怕的是小概率能跑通——因为它恰好写到了一个可写的内存区域。这种不确定性才是野指针最恶心的地方。我当年做链表插入时也干过类似的事声明了节点指针就直接用没想清楚它到底指向哪里。排查半天最后发现是初始化的问题。后来我给自己定了个规矩对每个指针变量都要回答三个问题它现在指向哪里那块内存还合法吗有没有可能已经被释放答不上来就先把指针置成NULL。这里要特别提一下free之后的指针。很多人知道free之后要置NULL但写起来总是忘。举个极端点的例子Node *node malloc(sizeof(Node)); free(node); if (node) { // node是野指针不是NULL node-next NULL; // 读写已释放内存 }free只是把内存还给堆管理器指针变量里存的地址没变。如果free之后不把node置NULL这个判断根本拦不住危险操作。正确的姿势是free(node); node NULL;还有一个高频错误是函数内修改指针本身。很多人写了个“交换两个指针”的函数结果传进去的是指针的副本函数里改来改去外面的指针纹丝不动。这种情况要么传二级指针要么通过返回值把新地址带出来没有第三条路。1.2 指针和数组名的“神似形不似”sizeof一测就露馅数组名和指针可以说是C语言里最容易被混淆的一对。很多教材说“数组名就是指针”这话说对了一半。数组名确实能隐式转换成指针但在两个场景下它们完全不同。第一个场景是sizeof。看这段int arr[10]; int *p arr; printf(%zu\n, sizeof(arr)); // 40整个数组的字节数 printf(%zu\n, sizeof(p)); // 8指针本身的字节数如果你在写代码时低估了sizeof(arr)的值后面很容易写出一个“差那么一点”的错误逻辑。我见过有人把sizeof(arr)当成数组元素个数用直接导致循环范围算错。第二个场景是取地址。arr和arr在数值上一样但类型完全不同。arr是int*arr是int(*)[10]也就是指向整个数组的指针。于是arr 1是跳到下一个int而arr 1是跨过整个数组。这个区别在很多指针与多维数组的题目里反复出现几乎年年考年年有人错。我自己的经验是当你对某个表达式类型拿不准时别硬猜。可以让编译器帮忙——故意给它一个不兼容的类型看编译报错信息或者用sizeof验证。编译器的报错虽然看起来吓人但它比人脑可靠得多。1.3 返回局部变量地址函数一结束栈上数据就不是你的了这个错误的名场面是int *get_num(void) { int x 100; return x; }调用方拿到指针后打印出来可能是100可能是垃圾值也可能直接段错误。关键在于函数返回后栈帧被回收那块地址上的数据随时可能被下一个函数调用覆盖。运气好的时候printf一执行正好把它自己的局部变量覆盖到这块地址于是你看到的是一个莫名其妙的大数。更隐蔽的是结构体版本Node *create_node(void) { Node n {0}; return n; }有人觉得“结构体拷贝慢我返回地址更高效”结果整个链表建出来全是同一个栈地址节点之间互相覆盖链表的最后一个节点“覆盖”了前面所有节点。这种问题特别难查因为当时代码逻辑看着完全没问题。正确的做法有三条路一是用malloc在堆上分配调用方负责free二是由调用方传入一块已分配的内存函数只负责填充三是用static局部变量——但这个方法要非常谨慎static变量是全局唯一的多线程场景下会互相踩踏而且在某些单片机环境下会让你误以为“函数可重入”。我个人默认用前两种static只在明确知道适用场景时才用。2. 数组与字符串边界问题为什么总是“表面正常、背后出事”数组和字符串的错题特点非常一致写的时候觉得天经地义跑起来也不报错但程序就像埋了颗定时炸弹指不定在哪个角落炸开。这也是C语言和高级语言最大的不同——它不帮你检查边界。2.1 下标越界编译器不报错不代表没发生经典问题开场int a[10]; for (int i 0; i 10; i) { a[i] i * i; }循环上界多写了一个“”a[10]被写入了a数组之外的内存。C语言编译器对此毫无反应因为下标越界属于运行时行为编译期通常无法检测。而越界之后的后果完全取决于那块相邻内存里存的是什么。我实际排障时最头疼的越界案例是这样数组越界写坏了一个完全不相干的局部变量导致那个变量在某些分支下变成奇怪的数字程序的崩溃点跟真正的错误点隔了十万八千里。比如前面声明了int flag 0;后面数组越界把flag覆盖成一个非零值于是程序走进了一个根本不该进的分支表现成“逻辑错乱”而你不会第一时间想到是数组越界。给初学者的建议很直接数组大小用宏或常量定义不要裸写数字循环边界写完后默念一遍“从0到n-1共n个”凡是涉及“”的边界都多留个心眼。#define ARRAY_SIZE 10 int a[ARRAY_SIZE]; for (int i 0; i ARRAY_SIZE; i) { a[i] i * i; }另外访问数组之前先做上界检查是个值得养成的好习惯尤其是处理用户输入或外部数据的时候。别嫌那一次判断慢它能省下的是一个晚上的调试时间。2.2 字符串结尾的\0一个字符引发的连锁崩溃字符串在C语言里其实是字符数组特殊之处在于它必须以\0结尾。这个字符不计入“长度”但占“空间”。char str[5] {h, e, l, l, o}; printf(%s\n, str);这段代码看起来没什么问题但str里根本没有\0。print会沿着这块内存一直读下去直到碰见一个碰巧为0的字节于是你输出的可能是“hello”后面跟着一堆乱码甚至直接段错误。更常见的写法是char str[5] hello;这同样翻车因为字符串字面量hello实际占6个字节第6个就是\0但数组只给了5个字节的空间\0被硬生生丢掉了。数组大小至少需要6。比这更危险的是字符串拷贝。用strcpy把一个长字符串拷进一个短数组它会心安理得地一路写下去把栈上其他变量全部踩碎。我见过一个案例函数里先声明了一个重要标志变量然后又在它后面声明了一个小字符数组接着用strcpy往数组里拷贝输入数据输入稍微长一点标志变量就被覆盖了程序后续行为完全失控。这个bug当时查了很久最后用内存检测工具一眼就定位到了。安全写法是这样char dest[16]; snprintf(dest, sizeof(dest), %s, src);snprintf会限制写入长度并且保证结尾有\0是处理字符串拼接和拷贝时的首选。如果你还习惯用strcpy和strcat我劝你抓紧改掉。2.3 二维数组传参形参写不对编译直接教做人函数里想接收一个二维数组新手最常见的写法是void fun(int **a) { ... } int arr[3][4]; fun(arr); // 编译警告甚至报错int **的含义是“指向指针的指针”而arr在这里其实是一个“指向长度为4的数组的指针”也就是int (*)[4]。两者类型根本不同。正确写法有几种最直白的是void fun(int a[][4], int rows) { ... } // 等价于 void fun(int (*a)[4], int rows) { ... }这里第二维的4必须写出来因为它决定了指针移动的步长。编译器需要知道“下一行在哪里”只知道“这是一个二维数组”是不够的。如果你要处理的是动态分配的二维数组也就是自己用“数组指针数组”方式建的那种那么形参用int **才合理。这两种内存布局完全不同写混了轻则编译不过重则编译能过但运行完全错乱。我自己在写这类函数前会先在草稿纸上画一遍内存布局连续的一片区域还是指针指向的散落区域。搞清楚这一点形参类型基本不会写错。3. 运算符优先级、隐式转换与控制流语法层面最阴的暗坑如果说指针和数组是“内存问题”那这一章就是“语法问题”。这一类错误的坑爹之处在于代码短看着简单但结果完全不符合直觉而且不同编译器可能给出不同结果。3.1 i i这类送命题为什么别去背答案先看这道经典题int i 1; int a i i;a是多少有人算3有人算4还有人算5。我去看过实际运行不同编译器、不同优化选项下结果真的不一样。原因在于对一个变量在同一个表达式里多次修改且中间没有序列点这在C语言标准里是未定义行为编译器怎么做都是合法的。换句话说这道题根本没有标准答案谁背谁傻。笔试里遇到这类题正确策略是直接指出“这是未定义行为结果依赖编译器”而不是去猜一个值。写代码时更要注意一条语句只做一件事不要为了显得“简洁”写出让编译器做阅读理解的表达式。// 拆开写谁都能看明白 int i 1; int a i; // a 1, i 2 a i; // i 3, a 3, 最终a 4还要提一个逗号表达式。很多人分不清int a (1, 2, 3);和int a 1, 2, 3;。前者a等于3因为逗号表达式从左到右求值最终结果是最右边的值后者在C语言里是语法错误。这种题目也是笔试常客但我更想说的是像这样的写法在工程里完全不推荐可读性太差了。如果面试官让你读这样的代码你最好的回答是“我会先把它改成正常写法再讨论功能”。3.2 有符号和无符号混算负数怎么就变成了天文数字下面这段代码几乎是错题集里出场率最高的unsigned int a 10; int b -20; if (a b) { printf(a大于b\n); } else { printf(a小于b\n); }直觉上10当然大于-20但实际上输出的却是“a小于b”。原因很简单当有符号和无符号出现在同一个表达式里时有符号数会被隐式转换成无符号数。-20的补码表示被解释成一个无符号数结果是4294967276一个巨大的正数。它当然比10大。这个坑在真实代码里最常见的变体是循环for (size_t i strlen(s) - 1; i 0; i--) { ... }当i等于0再执行i--时i变成一个巨大的无符号数循环条件i 0永远为真于是死循环。更恐怖的是size_t在64位系统下是无符号64位strlen(s) - 1在s为空字符串时直接变成18446744073709551615。应对策略很简单第一不混用有符号和无符号第二非混不可时显式强转并且自己想清楚边界情况第三遍历索引时如果可能到负数就别用无符号类型。除此之外我还会给自己加一条纪律凡是涉及size_t和int比较的地方都要停下来检查两边是否符号一致。3.3 赋值等于号与switch贯穿两个习惯性过失if (x 5)把比较写成了赋值这大概是C语言里最著名的低级错误了。问题是它合法能编译而且赋值表达式的值就是被赋的值所以if (x 5)永远为真。这种bug一旦出现几乎都是人眼扫描不到的级别。一个经典的防御技巧是把常量放左边if (5 x)这样一来如果手抖少写一个等号5 x会在编译期直接报错错误被提前拦截。缺点是有些人觉得这样读起来别扭但比起半夜排查这点别扭完全值得。再说说switch。忘了写break导致case贯穿也是一个极高频的错题switch (ch) { case a: printf(A\n); case b: printf(B\n); break; }当ch等于a时输出是“A”和“B”两行。这种“特性”有时被故意用作技巧但大多数情况是粗心的代价。我见过有人把case里的代码写得很长最后一个break被淹没在代码里导致后面case被意外贯穿输出了一堆不该出现的内容。我的建议是每个case结束处都写break即使最后一个case也写形成肌肉记忆。如果确实要故意贯穿在代码上加一行注释告诉后来的人“这是故意的”。4. 未定义行为与内存隐患编译器沉默的时候最危险C语言标准里有一类很特别的东西叫未定义行为意思是标准不约束这种情况下会发生什么。编译器可能给出任何结果可以崩可以不崩可以“看起来正常但结果错”一切皆有可能。这一章我们专门聊聊这类“编译器最沉默、程序员最抓狂”的场景。4.1 整数溢出计算器都没炸你程序先炸了乘法溢出大概是整型溢出里最隐蔽的。比如计算器里输入3000000000 * 2谁都算得出是60亿但int根本装不下。有符号整型溢出是未定义行为实际中通常表现为结果突然变成负数或乱跳int a 2000000000; int b a * 2; // 溢出可能是负数也可能是其他奇怪值面试和竞赛里有个经典案例是二分查找求中间值int mid (low high) / 2;当low和high都接近INT_MAX时low high先溢出mid就错了。标准解法是int mid low (high - low) / 2;这个写法不仅能避免溢出还能在大部分场景下保持同样的语义。类似的溢出问题在统计平均值、计算数组长度、做字符串拼接分配内存时都可能出现。我自己的习惯是写乘法之前先估算结果的范围超过int能装的范围就换long或long long再不行就考虑是否有必要用大数库。做除法、减法之前也同样过一遍脑。4.2 越界不崩的“灵异事件”最怕暂时正常第2章说的是数组越界在局部暴露的问题这一章我想强调一个更值得警惕的现象越界后程序没有立即崩溃而是“暂时正常”。这个阶段最坑人因为它会让人放弃怀疑内存问题转而去查逻辑。举个例子你在堆上分配了一个数组然后越界写了一个元素。那块内存恰好是堆管理器的空闲区没有被别的变量占用所以程序继续正常运行直到很久之后某次malloc管理器根据被破坏的元数据计算出错误的位置程序才猛然崩溃。这时候崩溃点和真正的越界点之间可能已经隔了几百次函数调用。排查这类问题靠肉眼读代码基本无效。首选手段是内存检测工具。把程序在内存工具下重跑一遍工具会精确报告“第几行访问了已释放内存”或者“第几行越界写入”定位速度比人肉猜测快几个数量级。我还想多说一句这类“灵异问题”在嵌入式或系统编程里更常见因为内存布局更紧凑越界写坏一个字节都可能引发蝴蝶效应。如果你在写这类代码边界检查不是可选配置而是强制要求。4.3 把防线前移编译器警告、断言与内存工具既然未定义行为和内存错误这么难缠最好的策略就是不让它们活到运行阶段。我的防线有三层。第一层是编译器。永远开启尽量完整的警告gcc -Wall -Wextra -Wpedantic -Werror program.c-Wall和-Wextra打开绝大多数常见警告-Werror把警告升级为错误迫使你修掉而不是跳过。说实话我第一次开-Werror编译旧代码时报错刷了整整一屏大部分是“比较时符号不同”“变量可能未初始化”这类问题——这些以前都可能是暗雷。第二层是断言。assert()用于检查逻辑前提比如函数要求传入的指针不为空#include assert.h void process(Node *node) { assert(node ! NULL); // ... }断言在调试版本里会立刻终止程序并报告位置正式发布时可以通过定义NDEBUG去掉。它的意义在于“快速失败”与其让错误带着扭曲的数据继续跑三小时不如在源头爆炸。第三层是内存工具。Linux环境下用valgrind跑一遍程序能查出绝大多数内存泄漏、越界访问、使用未初始化内存等问题。更轻量的是GCC自带的地址消毒器gcc -fsanitizeaddress program.c ./a.out编译时加上这个选项运行时代码会自动检查每次内存访问越界当场报告。效果和valgrind类似但开销更小更适合日常开发。我第一次用ASan跑一个“疑似灵异bug”的程序时报错直接指明了越界写入的那一行前后不到十分钟。5. 从“懵圈”到“秒定位”我的错误排查三板斧错题集的最终目的不是为了记录错误而是为了以后少花时间找错误。C语言的bug往往具有“延迟暴露”的特点所以排查方法比调试普通语言更需要章法。我自己的流程可以归纳成三板斧基本能覆盖九成场景。5.1 二分注释法不是瞎删代码而是切分嫌疑范围程序跑出错误结果但是不崩。此时最常见的错误做法是从头到尾读代码试图靠肉眼找出问题。我年轻时候也这么干过效率极低读到后面忘了前面。我现在用的是一招二分注释法把问题代码按函数或逻辑块切成前后两段先把后半段注释掉保持前半段跑一遍。如果问题消失说明bug在后半段继续把后半段二分缩小范围。如果问题还在说明bug在前半段往前查。这个办法看起来简单但关键细节在于注释的边界要按逻辑边界切而不是按行号切。比如一个计算流程前半段是数据准备后半段是数据计算那就把数据计算整体注释掉用假数据替代看输出是否正常。这样做能快速确定“是输入的问题还是处理逻辑的问题”而不是在茫茫代码里大海捞针。配合二分法的重要动作是打印关键变量。注释掉一段代码之后要在分界处把当时的中间变量打印出来确认前半段结束时的状态是否符合预期。只注释不打印就像闭着眼睛切了一段代码你还是不知道问题出在哪里。5.2 打印日志的四个实用姿势printf调试法虽然被很多人看不上但它仍然是C语言项目里最快上手、最快见效的调试方式。问题在于同样是printf有的人打印十行就能定位有的人打印一百行还在原地打转。区别在于姿势。第一个姿势是格式化。不要打印printf(%d\n, i);要打印printf([DEBUG] i %d\n, i);。带变量名和值的日志比你裸打一个数字强太多因为你不必在控制台里来回找“这个5到底是谁”。第二个姿势是用stderr而不是stdout。调试打印用fprintf(stderr, ...)这样调试信息和正常输出分离不会污染程序真正的输出内容在重定向日志时也更好处理。第三个姿势是在函数入口和出口各打一条日志。这样你能快速看到函数的调用顺序、参数和返回值很多“这个函数根本没被调用”或者“它返回了一个垃圾值”的问题通过这两个钩子一眼就看清了。第四个姿势是标记分支。当程序走错了分支时往往只有一句结果性日志而你看不到它为什么走错。正确做法是在每个关键if分支里打印一行表示当前走进了哪个分支。事后你回看日志能完整还原程序的执行路径而“执行路径”本身就是C语言bug里最缺的线索。日志用完要清干净。我踩过好几次坑调试输出忘删程序提交上线后控制台刷出几百行调试信息差点被当成故障。现在我会用一个宏来控制#ifdef DEBUG #define LOG(...) fprintf(stderr, __VA_ARGS__) #else #define LOG(...) do {} while (0) #endif日常开发编一个debug版跑日志交付时编release版自动屏蔽两全其美。5.3 十分钟学会的三大排查工具如果打印日志解决不了问题就该上工具了。这一节介绍三个我实际用得最多的工具每个都只需要掌握几个命令就能见效。第一个是调试器gdb。基础用法就三条break设断点print打印变量bt查看函数调用栈。程序崩溃时gdb ./prog core进入调试器后输入bt就能看到崩溃前调用了哪些函数这一条信息直接框住了排查范围。我很多“不知道在哪崩”的问题都是用这一招解决的。第二个是内存检测工具。Linux上用valgrind一条命令valgrind --leak-checkfull ./prog跑完后它会列出所有内存越界、使用未初始化内存、内存泄漏的位置。Windows或macOS用户可以考虑用AddressSanitizer编译时加-fsanitizeaddress就能获得类似能力。这类工具定位内存问题的速度比人力快上十倍完全不夸张。第三个是静态分析。GCC从10版本开始提供-fanalyzer选项能在编译期做路径敏感分析发现一些潜在的空指针解引用、内存泄漏问题。gcc -fanalyzer program.c它可能有一些误报但你花十分钟扫一遍多半能发现几个真实隐患。输出信息里会标明“这里可能发生了空指针解引用”配合行号修改成本极低。我自己排查问题时的顺序是先肉眼扫一遍可疑代码顺便打日志三分钟没思路就直接上内存工具内存工具没查到就上gdb看调用栈。这套流程下来大多数问题都在一小时内有个说法了。凭感觉乱试反而最浪费时间因为C语言的问题表象和根因往往相隔很远。最后再分享一个小习惯每解决一个让我印象深刻的bug我会把错误代码和修复后的代码存成一个独立的小文件注释里写清楚“为什么错”“为什么这样改”。这个文件夹我现在还在维护很多问题已经不止一次遇到每次翻出来都能立刻确认“哦上次就是这么解决的”。错题集的精髓不在于抄题而在于错一次就长一次记性让同样的坑没有机会再绊倒你第二次。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询