
写 C 语言这些年如果要选一个“撑起整个程序骨架”的语法我会把票投给循环结构。条件判断只决定程序走哪条路循环却决定这条路要走多少遍从数组遍历、文件读取到状态机轮询、协议解析几乎每天都在循环里打转。新手起初容易把循环当成一句 for 语法来背背完发现代码是能跑了但一旦要求改条件、加嵌套、调性能就开始心里没底。这篇内容我打算从设计思路、语法细节、控制语句、嵌套场景、性能优化到排错经验完整过一遍循环结构尽量把我这些年实际写代码时积累的判断依据和踩坑教训都写出来给你一套拿来就能用的思考方式。1. 循环结构的设计思路与选型逻辑1.1 循环的本质是“重复加变化”很多初学者觉得循环就是让一段代码反复执行这个理解没错但不够完整。一个真正有价值的循环一定同时具备三个特征重复、变化、终止。重复是表面形态变化才是灵魂终止则是底线。先说变化。你定义一个循环变量 i每次循环 i 的值都不一样这个变量可以控制数组下标、控制累计次数、控制算法步进。没有变化的循环执行一百遍和只执行一遍效果完全一样那是死循环或者无意义空转。所以写循环的时候第一个要想的不是“我要循环几次”而是“每一次循环之间什么在发生变化”。这个是决定循环体怎么写、循环条件怎么设的根本问题。再说终止。终止条件不是可有可无的收尾工作它是循环设计的第一步。很多 bug 都出在终止条件不明确要么永远进不去循环要么进去就出不来。我的经验是写出循环体之前先在注释里把循环结束时要满足的状态写清楚再倒推条件。比如“要把一个数组的所有元素加起来”结束状态就是“下标已经走到数组末尾”那循环条件就可以写成 i n。条件写对了循环体怎么写都平稳条件写错了后面调逻辑全是浪费时间。1.2 三种循环语句的真实差异for、while、do-while 不是三套等价的写法它们各自适合不同的场景。很多人觉得 for 能写的 while 也能写while 能写的 do-while 也能写从语法层面看确实如此但从设计意图看选哪一种是你在提前告诉读代码的人这个循环的控制思路是什么。for 适合“计数”明确的场景。初始化、条件判断、步进更新都在同一行一眼就能看出循环的次数是受什么控制的。这种结构天然适合按下标遍历数组、按次数执行累加这类确定性任务代码读起来最顺。while 适合“条件”优先的场景。你事先不一定知道要循环多少次只知道什么时候该停。比如读取文件直到末尾、等待某个状态位变成有效、轮询缓冲区直到有数据到达这些都属于条件驱动的循环。while 把条件放在最前面语义非常直白只要这个条件成立就继续干活。do-while 最容易被忽略但它是三种循环里唯一能保证循环体至少执行一次的。什么时候需要这种语义典型的就是菜单程序先把菜单打出来再问用户选什么然后根据选择判断是否继续。无论用户第一次输入什么菜单都得先显示一次。后置判断的写法的价值就在这里。选循环语句的时候还有一条我常用的判断链如果循环次数能用整数计数来描述选 for如果循环次数取决于外部条件、且可能一次都不执行选 while如果循环体必须无条件先跑一次、再根据结果决定是否继续选 do-while。这个习惯看着简单但真能让代码的意图清晰很多。2. 三种循环的语法细节与关键注意事项2.1 for 循环的三个表达式没有你想得那么死先看一个标准的 for 结构#include stdio.h int main(void) { int i; for (i 0; i 10; i) { printf(%d , i); } printf(\n); return 0; }这段代码输出 0 到 9。很多人背下了 for 的三个部分初始化、条件、更新但容易忽略两点。第一三个表达式都可以省略。for (;;) 是合法的它等价于一个无限循环退出逻辑要写在循环体里。你可能觉得这种写法奇怪但我在嵌入式项目里见过很多协议解析代码就是这么写的循环体里读一个字节、判断是否到了数据包结尾到了就 break。这种情况下for (;;) 比 while (1) 写起来简洁语义上也强调“靠内部逻辑退出”。第二第三个表达式不一定非是 i它可以是任意更新操作。比如移动指针、缩小范围、推进下标// 倒序遍历数组 for (i n - 1; i 0; i--) { // ... }再比如每轮迭代前进两个位置for (i 0; i n; i 2) { // 处理偶数下标 }这些都是 for 的常见变形。掌握这个灵活性写代码时就不用硬套 i 一种模式。2.2 while 循环的重点是“把变化放在合适的位置”while 的语法很简单但实际写的时候有个特别容易翻车的地方循环条件依赖的变量到底在哪更新。#include stdio.h int main(void) { int count 10; while (count 0) { printf(count %d\n, count); count--; // 这行才是循环能停下来的关键 } return 0; }如果把 count-- 删掉这就是一个标准的死循环。所以 while 的设计思路和 for 不一样for 把更新写在最上面while 则必须自己在循环体里找地方更新条件状态。越复杂的循环体越容易写到后面忘了更新条件变量这是新手最常见的死循环来源之一。还有一种 while 使用场景值得注意用指针遍历字符串时判断条件就是指针指向的字符是否为结束符。#include stdio.h int main(void) { const char *str hello; const char *p str; while (*p ! \0) { putchar(*p); p; } putchar(\n); return 0; }这段代码里变化本质上就是 p而变化的结果恰好就是下一次循环条件的输入。这种模式在 C 里非常常见理解它之后你对 while “条件驱动”的体会会更深。2.3 do-while 的常见应用和隐藏坑位do-while 至少执行一次这个特性用得好很出彩但方向一反过来就容易出问题。比如你原本期望的是“条件满足才进入循环”却误用了 do-while结果在边界条件下多执行了一次不该执行的操作。一个典型场景是用户输入校验#include stdio.h int main(void) { int num; do { printf(请输入一个 1 到 100 之间的数字); scanf(%d, num); } while (num 1 || num 100); printf(你输入的是 %d\n, num); return 0; }这个逻辑必须用 do-while因为用户至少需要输入一次然后再判断合不合理。如果换成 while你就要在循环之前先给 num 赋一个错的值来强制进入循环代码反而别扭。还有一个很有意思的应用很多人不知道宏定义里用 do-while(0)。这个单纯是为了把多条语句包成一条“逻辑上的语句”。#define SAFE_FREE(p) do { if (p) { free(p); (p) NULL; } } while (0)这样做的好处是宏在 if 语句里使用时不会出现悬空 else 之类的语法问题。虽然这看起来不是循环但它借用了 do-while 的结构特性属于循环结构知识的一个高阶加分项。3. 循环控制关键字break、continue、goto 的正确打开方式3.1 break 只跳出最近的一层循环break 的作用有两个从循环里跳出来、从 switch 里跳出来。很多人对 switch 里的 break 很熟但对循环里的 break 认识得比较模糊特别是嵌套循环的时候。关键规则一句话说清楚break 只能终止最内层的那一次循环。外层循环不会因为内层有 break 自动停下来。#include stdio.h int main(void) { int i, j; for (i 1; i 3; i) { for (j 1; j 3; j) { if (j 2) { break; // 只跳出内层 j 循环 } printf(i%d, j%d\n, i, j); } } return 0; }这段代码里每次内层循环都会在 j 2 时退出但外层循环依然继续执行 3 次。如果你想从内层一次性跳出所有嵌套只靠 break 是不够的。一种通行做法是加标志位#include stdio.h int main(void) { int i, j; int found 0; for (i 1; i 3 !found; i) { for (j 1; j 3; j) { if (i * j 4) { found 1; break; } } } return 0; }把“是否结束”的状态放到外层循环条件里配合内层 break才能干净利落地退出多层循环。3.2 continue 结束的是“本次迭代”不是整个循环continue 和 break 经常被放在一起说但含义完全不同。continue 是跳过当前这一轮循环体的剩余语句直接进入下一轮迭代。它不会让循环终止只是提前结束“当前这一次”。看一个过滤奇偶数的例子#include stdio.h int main(void) { int i; for (i 1; i 10; i) { if (i % 2 0) { continue; // 偶数直接跳过打印 } printf(%d , i); } printf(\n); return 0; }输出结果是 1 3 5 7 9。continue 让偶数那一轮不再继续执行打印语句。不过continue 在 while 里有一个著名的大坑如果 continue 跳过了更新条件变量的语句循环就可能变成死循环。比如int i 0; while (i 10) { if (i 3) { continue; // 没有 i条件永远不变 } // 处理逻辑 i; }当 i 等于 3 时continue 直接把后面的 i 跳过了于是 i 永远停在这里程序卡死。这种问题在 for 里不太会出现因为 for 的更新表达式在每轮迭代结束时都会执行。这也是我建议“能用 for 就用 for”的另一个原因它天然少一个坑。3.3 goto 在嵌套循环里是合法且高效的工具很多人一听到 goto 就摇头觉得是坏味道。确实随意跳转容易把代码变成意大利面条但在一种场景下 goto 非常值得用那就是多层嵌套循环里的统一退出。#include stdio.h int main(void) { int i, j; int matrix[3][3] { {1, 2, 3}, {4, 5, 6}, {7, 8, 9} }; for (i 0; i 3; i) { for (j 0; j 3; j) { if (matrix[i][j] 5) { goto found; // 直接跳出两层循环 } } } printf(没有找到\n); return 0; found: printf(找到了位置是 matrix[%d][%d]\n, i, j); return 0; }用 break 加标志位也能实现同样的效果但代码要多写几行而且退出后还要判断标志位状态。goto 在这里清晰、直接、高效是非常合理的用法。我的态度是不要在循环里乱用 goto 做向后跳转但像这种“只进不退”的结构化跳转完全可以放心用。很多系统源码、内核代码里都有类似写法。4. 嵌套循环与典型场景的实现方法4.1 嵌套循环的复杂度得一眼能看穿嵌套循环就是把一个循环放在另一个循环的内部内层循环每执行一轮外层循环执行一次。复杂度分析通常是这样如果外层循环 n 次、内层循环 m 次那么总执行次数大约是 n 乘 m。举个例子打印乘法表#include stdio.h int main(void) { int i, j; for (i 1; i 9; i) { for (j 1; j i; j) { printf(%d*%d%-3d, j, i, i * j); } printf(\n); } return 0; }这段代码里的内层循环次数不是固定的它跟着外层 i 在变。总输出次数是 123…9 45 次。整体上当外层和内层都接近 n 时复杂度是 O(n²)这是最基础的嵌套循环复杂度模型。看代码的时候我一般建议先找两个东西外层循环的次数由什么控制内层循环的次数是否依赖外层变量。这两个答案一出来复杂度基本就清楚了。4.2 冒泡排序里的双层循环讲循环结构不谈排序算法等于白谈。冒泡排序是一个刚好能全面测试你对循环理解程度的例子。#include stdio.h void bubble_sort(int arr[], int n) { int i, j, tmp; for (i 0; i n - 1; i) { for (j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { tmp arr[j]; arr[j] arr[j 1]; arr[j 1] tmp; } } } } int main(void) { int arr[] {5, 2, 9, 1, 5, 6}; int n 6; int i; bubble_sort(arr, n); for (i 0; i n; i) { printf(%d , arr[i]); } printf(\n); return 0; }这个排序里有两个容易理解错的点。第一外层循环 n-1 次就够了因为最后一轮只剩一个元素不需要再比较。第二内层循环的范围 n-1-i 是动态缩小的每排好一个最大值它就在末尾定住一个位置后面几轮就不需要再碰它。这两个细节全跟循环边界设计有关如果你能把冒泡排序的每个边界都讲清楚说明你对嵌套循环已经过关了。4.3 数组遍历和查找的循环写法数组遍历最标准的模式就是 for 循环加下标访问。但查找的时候有个可以优化的点把“找到后停止”这个意图直接写进循环条件。#include stdio.h int main(void) { int arr[] {3, 7, 2, 8, 4}; int n 5; int target 8; int i; int pos -1; for (i 0; i n; i) { if (arr[i] target) { pos i; break; } } if (pos 0) { printf(找到目标下标是 %d\n, pos); } else { printf(没找到\n); } return 0; }这种写法很务实找到就 break不再继续浪费剩余循环。循环结束后用 pos 是否等于 -1 来判断结果。注意下标变量 i 一旦在循环里 break 掉就只能通过额外变量把位置记下来不能依赖循环结束后的 i因为在 C 里它可能是未定义或不符合预期的值这个细节也值得记住。5. 循环性能优化与编译器优化思路5.1 把“不变的东西”挪到循环外面这是最经典也是收益最明显的循环优化方式。循环体每一轮都要执行的代码如果其中有一部分计算结果与循环变量无关那把它提出来就能省掉大量重复计算。一个最简单的例子判断一个数是不是素数#include math.h #include stdio.h int main(void) { int n 97; int i; int limit (int)sqrt((double)n); // 只算一次 for (i 2; i limit; i) { if (n % i 0) { printf(%d 不是素数\n, n); return 0; } } printf(%d 是素数\n, n); return 0; }如果每次循环都调用 sqrt代价就完全不一样了。这个原则叫“循环不变量的外提”它不只是编译器面试题在实际项目里非常常见。写循环体的时候我习惯每写完一行就问自己这行代码依赖 i 吗不依赖的话能不能提到循环外面5.2 警惕循环条件里的隐晦开销有些代码看起来简单但循环条件里藏了函数调用。最常见的例子// 不要这样写 for (i 0; i strlen(s); i) { // 每一轮都重新计算 strlen }strlen 本身要遍历一次字符串你把 strlen 放在循环条件里每轮都要重新扫一遍字符串总复杂度直接变成 O(n²)。正确写法是提前把长度保存下来size_t len strlen(s); for (i 0; i len; i) { // 处理 s[i] }这是一个很典型的经验教训光优化这一句话在大字符串场景下性能差距非常可观。5.3 编译器在循环优化上做了什么现代编译器在开优化选项时会做不少循环优化工作比如循环展开、循环向量化、消除边界检查等。你在写代码时不用全手工去模拟这些优化但理解它们能帮你选择更适合编译器发挥的写法。举个例子for (i 0; i n; i) 这种标准的连续递增循环编译器很容易做向量化。反过来如果你用 while 循环并且循环里改指针改得奇形怪状编译器分析难度就大很多优化空间也被压缩。所以我说很多关于循环的“性能经验”并不是让你憋出什么骚操作而是让你把循环写得规规矩矩编译器自然能帮你跑得快。但有一点要注意如果你用 volatile 修饰变量或者用指针改写了循环即使逻辑上看起来不变的数据编译器会被迫保守处理不敢轻易优化。这种情况下性能问题和你的数据别名alias设计有关需要在更高层面重新设计。6. 常见循环 bug 与排查技巧记录6.1 差一错误多一次和少一次差一错误英文叫 off-by-one是循环里最经典的 bug。典型场景就是数组初始化int arr[5]; for (i 0; i 5; i) { // 错数组越界 arr[i] i; }C 数组下标从 0 开始所以 5 个元素的下标范围是 0 到 4。写成 i 5最后一次写入的就是 arr[5]这个位置已经超出数组边界了。也许它不会立刻崩溃但它可能悄悄改写了内存里的其他变量制造出很难定位的诡异问题。我现在排查这类问题有个固定套路拿循环的边界值实际带入一下。比如 n 0 和 n 1 时这个循环会不会执行执行几次循环结束后的 i 值是多少把这些边界情况想清楚差一错误基本都能拦下大半。6.2 死循环的排查方法死循环的常见原因有循环条件永远为真、循环体没有改变条件依赖的变量、浮点数精度导致条件永远不能满足、无符号整数下溢。无符号整数下溢是一个非常有意思的坑#include stdio.h int main(void) { unsigned int n 5; while (n 0) { n--; printf(%u\n, n); } return 0; }unsigned int 永远大于等于 0这个循环条件在数学上永远为真。而且 n 减到 0 后再执行 n--并不会变成 -1而是回绕到很大的正整数。这就完了。排查死循环我一般是先打印循环变量看变化趋势如果循环变量不动优先怀疑 continue 跳过了更新如果循环变量变化了但条件还是真就得检查条件本身是否永远成立。6.3 循环条件里混入浮点数浮点数的比较不适合直接用于循环条件。举个例子float f; for (f 0.0f; f ! 1.0f; f 0.1f) { // 这个循环可能停不下来 }0.1 在二进制里是无限循环小数存储成浮点后会有精度损失。每次累加之后f 可能不是我们期望的 0.1、0.2、0.3 这样精确的值而是一堆近似值最后可能永远不等于 1.0。正确做法是循环用整数计数需要浮点数值时再根据整数计算int i; float f; for (i 0; i 10; i) { f i * 0.1f; // 使用 f }这个思路在工程里很常用凡是控制次数的变量尽量用整数浮点只存数值不控制流程。6.4 循环变量被循环体内部悄悄修改有一种很难发现的 bug循环体里可能通过指针或函数副作用修改了循环变量的值。比如#include stdio.h void change(int *x) { *x 10; } int main(void) { int i; for (i 0; i 5; i) { change(i); // 循环变量被改了 printf(%d\n, i); } return 0; }这个循环的运行轨迹完全不是你想象的 0 到 4。循环体中的操作可能通过地址、全局变量、宏展开等方式影响循环变量导致循环行为失控。排查思路是如果循环执行次数和预期不符先检查循环体里有没有谁碰过循环变量的内存。这也是我建议循环变量的定义和作用域尽量窄的原因范围越小被外部修改的概率越低。7. 我在实际项目里沉淀的几个循环习惯写循环其实是在写约束告诉你和读代码的人这件事会在什么条件下启动、在什么条件下停止、每轮变化什么。最后一个部分我不讲大道理就说几个我个人保持了很久的小习惯。第一个习惯循环变量的类型能用 int 用 int尽量不要用 unsigned int 做递减循环。原因是 unsigned 到 0 以后继续减会回绕循环条件 if (i 0) 在这种类型下永远不会假。如果确实需要非负语义就把边界写成 i 0 的同时小心处理 i 等于 0 的访问。第二个习惯循环体里能不用 continue 就不用 continue尤其是 while 循环。continue 会把循环里后续的更新语句跳过这是很多死循环的源头。第三当循环嵌套超过三层时我大多数会主动重构。三层以上不管是阅读还是排查心智负担都会急剧增大通常意味着函数设计需要拆分。还有一个小技巧复杂循环正式运行之前可以先加一个临时的计数变量看循环总执行次数是否符合预期。这个调试手段比打印一堆中间值更直接特别是当你怀疑循环次数过多或过少时。循环结构在 C 里是最基础的内容但基础不等于简单。真正把循环用好靠的是对边界条件的敏感、对控制语句语义的清晰、以及对性能和分析的综合把握。希望你写循环的时候也能感受到它背后那股“用有限代码驱动无限重复”的踏实感。