控制流:让程序学会选择和重复
到目前为止我们写的程序都是从第一行一路跑到最后一行。但现实世界的逻辑不是这样的——"如果温度超过阈值就开风扇"、"重复读取传感器数据直到收到停止命令"。控制流语句就是干这个的:让程序根据条件选择不同的执行路径(分支),或者反复执行某段逻辑(循环)。
这些语句看着简单,但里面藏着不少容易踩的坑。这一篇我们把 C 语言的控制流从头到尾过一遍,重点关注那些"你以为是这样但实际不是"的地方。
学习目标 完成本章后,你将能够:
- [ ] 理解 if/else 的悬垂 else 问题和解决方法
- [ ] 掌握 switch 的穿透特性和 case 标签的限制
- [ ] 熟练使用三种循环结构及其适用场景
- [ ] 理解 break/continue 的行为和局限
- [ ] 用 switch 实现一个实用的状态机
环境说明
我们接下来的所有实验都在这个环境下进行:
- 平台:Linux x86_64(WSL2 也可以)
- 编译器:GCC 13+ 或 Clang 17+
- 编译选项:
-Wall -Wextra -std=c17
第一步——条件分支:if/else
基本语法
if/else 是最基本也是最高频使用的条件分支语句。条件为真(非零)执行 if 分支,否则执行 else 分支:
if (temperature > kTempHighThreshold) {
activate_cooling();
} else if (temperature < kTempLowThreshold) {
activate_heating();
} else {
maintain_temperature();
}这里有个小知识:else if 并不是 C 语言的一个独立关键字——它实际上是 else 后面跟了一个新的 if 语句。所以上面的代码在编译器眼里就是 else { if (...) { } else { } } 的嵌套结构。虽然理解成"多路分支"更直观,但编译器看到的就是一棵嵌套的二叉分支树。
悬垂 else——一个经典的坑
看这段代码:
if (a > 0)
if (b > 0)
result = 1;
else
result = -1;缩进看起来 else 是和第一个 if 配对的,但实际不是。C 语言的规则是:else 总是和最近的、尚未配对的 if 绑定。所以这段代码实际等价于:
if (a > 0) {
if (b > 0) {
result = 1;
} else {
result = -1;
}
}如果我们的本意是让 else 和外层 if 配对,那这段代码就是错的。解决方法很简单——永远用花括号明确界定每个分支的范围。
⚠️ 踩坑预警 即使分支只有一行代码,也要加花括号。这不是多打几个字符的问题,而是防止歧义和未来维护时引入 bug 的问题——你加一行代码的时候忘了补花括号,逻辑就完全变了。很多编码规范(包括 Linux 内核风格)都强制要求这一点。
= vs ==——另一个经典笔误
if (x = 5) 永远为真(因为赋值表达式的值是 5,非零即真),而且 x 被意外修改了。好的编译器遇到这种写法会发出警告,所以务必开启 -Wall 让编译器帮你盯着。有些程序员习惯把常量写在左边:if (5 == x),这样万一写成 if (5 = x) 编译器会直接报错。
第二步——多路分支:switch 语句
当分支条件是对同一个表达式做离散值的比较时,switch 比 if/else if 链更清晰,而且编译器通常会将 switch 优化为跳转表(jump table),查表的时间复杂度接近 O(1)。
展开代码 (共 26 行)收起代码
typedef enum {
kCmdStart = 0x01,
kCmdStop = 0x02,
kCmdPause = 0x03,
kCmdResume = 0x04
} Command;
void handle_command(Command cmd) {
switch (cmd) {
case kCmdStart:
start_operation();
break;
case kCmdStop:
stop_operation();
break;
case kCmdPause:
pause_operation();
break;
case kCmdResume:
resume_operation();
break;
default:
handle_unknown_command();
break;
}
}穿透特性:忘了 break 就会"漏水"
每个 case 分支末尾的 break 用来跳出 switch。如果忘了写 break,执行完当前 case 的代码后不会停下来——它会"穿透"到下一个 case 继续执行。这就是所谓的 fall-through。
switch (cmd) {
case kCmdStart:
start_operation();
// 忘了 break!会穿透到 kCmdStop 的逻辑
case kCmdStop:
stop_operation();
break;
}当 cmd 为 kCmdStart 时,start_operation() 执行完后不会停下来,而是继续执行 stop_operation()——一启动就停了,血压拉满。
⚠️ 踩坑预警 但有意识地利用穿透特性可以写出很优雅的代码——把多个 case 合并到同一个处理逻辑:
int days_in_month(int month, int is_leap_year) {
switch (month) {
case 1: case 3: case 5: case 7:
case 8: case 10: case 12:
return 31;
case 4: case 6: case 9: case 11:
return 30;
case 2:
return is_leap_year ? 29 : 28;
default:
return -1;
}
}如果你确实要利用穿透特性,建议加个 /* fall through */ 注释说明意图,否则后来维护代码的人会以为这是 bug。
case 标签的限制
switch 的 case 标签必须是整数常量表达式——编译时就能确定值的整数。这意味着不能用变量、浮点数或字符串。字面量(42)、enum 成员和 #define 宏都行。
养成习惯:写 switch 必须写 default,哪怕只是打一行日志。特别是当你的 enum 后来新增了成员但忘了更新 switch 的时候,default 就是你的安全网。
第三步——三种循环:for、while、do-while
for 循环——已知次数的重复
for 循环的三段式设计把初始化、条件判断和步进操作集中到了一行里,非常适合已知迭代次数的场景:
for (int i = 0; i < count; i++) {
process_item(items[i]);
}三个部分都可以省略。如果全部省略,就得到一个无限循环——在嵌入式系统的主循环中非常常见:
for (;;) {
read_sensors();
process_data();
update_outputs();
}逗号运算符可以在 for 中同时操作多个变量:
for (int i = 0, j = length - 1; i < j; i++, j--) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}while——先检查再决定
while 循环先检查条件,如果一开始就是假,循环体一次也不执行。适合那种"条件满足才需要处理"的场景:
while (!uart_data_available()) {
// 空转等待——实际项目中要加超时机制
}do-while——先干再说
do-while 至少执行一次循环体,然后再检查条件。适合"至少尝试一次"的逻辑:
do {
result = attempt_communication();
retry_count++;
} while (result != kSuccess && retry_count < kMaxRetries);不管条件怎样,通信至少会尝试一次。用普通 while 实现同样的逻辑需要把 attempt_communication() 写两次,不够优雅。
来验证一下三种循环的行为差异:
展开代码 (共 21 行)收起代码
#include <stdio.h>
int main(void)
{
int count = 0;
// while:条件一开始就是假,不执行
while (count > 0) {
printf("while: 不会打印这行\n");
count--;
}
// do-while:至少执行一次
count = 0;
do {
printf("do-while: count = %d\n", count);
count++;
} while (count < 3);
return 0;
}运行结果:
do-while: count = 0
do-while: count = 1
do-while: count = 2很好,while 循环体一次都没执行,do-while 执行了三次。
第四步——break、continue 和 goto
break——跳出最近一层
break 用于立即跳出当前循环或 switch 语句。它只影响最内层的循环或 switch,不会穿透多层嵌套:
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
if (matrix[i][j] == target) {
printf("Found at [%d][%d]\n", i, j);
break; // 只跳出内层 j 循环,外层 i 循环继续
}
}
}continue——跳过本次迭代
continue 跳过循环体中剩余的语句,直接进入下一次迭代:
for (int i = 0; i < count; i++) {
if (data[i] == kInvalidMarker) {
continue; // 跳过无效数据
}
process_valid_data(data[i]);
}goto——慎用但别妖魔化
goto 在编程界名声不太好,但在 C 语言中有一个被广泛认可的合理使用场景:错误处理中的资源清理。当你有一系列需要按顺序初始化的资源,任何一步失败都需要清理前面已成功的部分时,goto 能让代码非常清晰:
int initialize_system(void) {
if (!init_hardware()) {
goto error_hardware;
}
if (!init_peripherals()) {
goto error_peripherals;
}
if (!init_communication()) {
goto error_communication;
}
return kSuccess;
error_communication:
shutdown_peripherals();
error_peripherals:
shutdown_hardware();
error_hardware:
return kError;
}⚠️ 踩坑预警
goto的使用原则:只向后跳转(向下跳到后面的标签),且只用于错误处理或跳出嵌套。向前跳转(跳回前面的代码形成循环)是应该坚决避免的——那是for/while的工作。
第五步——实战:用 switch 实现状态机
状态机(State Machine)是嵌入式开发中最常见的设计模式之一——通信协议解析、外设控制序列、用户界面流程,到处都是状态机的影子。switch 语句是实现状态机最直接的工具。
我们来实现一个简单的通信协议解析器。假设协议格式是:帧头 0xAA + 长度 + 负载数据 + 校验和。
展开代码 (共 62 行)收起代码
typedef enum {
kStateIdle, // 空闲态:等待帧头 0xAA
kStateHeader, // 帧头态:帧头已收完,接下来等长度字节
kStatePayload, // 负载态:正在接收数据
kStateChecksum, // 校验态:准备校验数据
kStateDone, // 完成态:一帧解析成功
kStateError // 错误态:数据不对(比如长度超限或校验失败)
} ParseState;
typedef struct {
ParseState state; // 记录当前状态
unsigned char payload[64]; // 仓库:存放接收到的负载数据
unsigned char payload_len; // 记录:这帧要收多少个字节的负载
unsigned char index; // 计数器:当前已经收到了几个字节
} Parser;
void parser_init(Parser* p) {
p->state = kStateIdle;
p->payload_len = 0;
p->index = 0;
}
ParseState parser_feed(Parser* p, unsigned char byte) {
switch (p->state) {
case kStateIdle:
if (byte == 0xAA) { // 看到帧头了吗?
p->state = kStateHeader; // 看到了,进入下一个状态(等长度)
}
break;
case kStateHeader:
p->payload_len = byte; // 把收到的这个字节当作长度存起来
if (p->payload_len > 64) { // 长度太大了,仓库装不下怎么办?
p->state = kStateError; // 报错!
} else {
p->index = 0; // 准备开始收数据,计数器清零
p->state = kStatePayload; // 进入接收负载的状态
}
break;
case kStatePayload:
p->payload[p->index++] = byte; // 把字节存进仓库,并且计数器+1
if (p->index >= p->payload_len) { // 收够了吗?
p->state = kStateChecksum; // 收够了,进入校验状态
}
break;
case kStateChecksum: {
unsigned char calc = 0;
for (int i = 0; i < p->payload_len; i++) {
calc ^= p->payload[i]; // 把收到的所有数据按位异或一遍
}
p->state = (calc == byte) ? kStateDone : kStateError; // 算出来的和收到的校验码对比
break;
}
case kStateDone:
case kStateError:
break; // 什么都不做
}
return p->state; // 把当前状态告诉外面的人
}来验证一下,模拟接收一帧数据:
展开代码 (共 28 行)收起代码
#include <stdio.h>
int main(void)
{
Parser p;
parser_init(&p);
// 帧头 0xAA,长度 3,负载 {0x01, 0x02, 0x03},校验 0x00
unsigned char frame[] = {0xAA, 0x03, 0x01, 0x02, 0x03, 0x00};
for (int i = 0; i < (int)sizeof(frame); i++) {
ParseState s = parser_feed(&p, frame[i]);
printf("Byte 0x%02X → State %d\n", frame[i], s);
if (s == kStateDone) {
// 如果解析器说“完成”,就把收到的数据打印出来
printf("Frame OK, payload: ");
for (int j = 0; j < p.payload_len; j++) {
printf("0x%02X ", p.payload[j]);
}
printf("\n");
break;
} else if (s == kStateError) {
// 如果解析器报错,也停止
printf("Parse error at byte %d\n", i);
break;
}
}
return 0;
}编译运行:
gcc -Wall -Wextra -std=c17 parser.c -o parser && ./parser运行结果:
Byte 0xAA → State 1
Byte 0x03 → State 2
Byte 0x01 → State 2
Byte 0x02 → State 2
Byte 0x03 → State 3
Byte 0x00 → State 4
Frame OK, payload: 0x01 0x02 0x03很好,状态机正确地从 Idle 一路走到了 Done,每一步的状态转移都符合我们的预期。这种逐字节驱动的状态机模式在串口通信和网络协议解析中非常实用。
C++ 衔接
C++ 在控制流方面做了几个重要的扩展。C++11 引入了范围 for 循环,让遍历容器变得非常简洁:
int arr[] = {1, 2, 3, 4, 5};
for (int x : arr) {
std::cout << x << " ";
}
// 不需要手动管理索引、判断边界、递增计数器C++17 引入了 if constexpr,它在编译期评估条件,直接把不满足条件的分支从代码中剔除。还有 std::variant + std::visit,提供了一种类型安全的方式来替代传统 switch——编译器会检查你是否处理了所有类型,少处理一个就直接编译报错。
小结
控制流是程序逻辑的骨架。if/else 处理条件分支,加花括号消除悬垂 else 的歧义。switch 适合多路分支,穿透特性需要 break 来阻止,default 别忘加。for/while/do-while 三种循环各有适用场景。break 和 continue 只作用于最内层。goto 在错误处理的资源清理场景下是合理选择。用 switch 实现状态机是嵌入式开发的基本功。
接下来我们要学习函数——怎么把代码组织成可复用的模块。
练习
练习 1:月份天数
难度:基础 · 练 switch 的穿透特性
用 switch 实现一个函数,根据月份和是否闰年返回该月的天数。要求利用穿透特性合并同天数的月份。
参考答案
bool is_leap_year(int year) {
return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
}
int month_day(int year, int month) {
switch (month) {
case 1: case 3: case 5: case 7: case 8: case 10: case 12:
return 31;
case 4: case 6: case 9: case 11:
return 30;
case 2:
return is_leap_year(year) ? 29 : 28;
default:
return -1;
}
}练习 2:安全的矩阵搜索
难度:进阶 · 跳出多层循环的两种姿势
在二维矩阵中查找目标值。找到后用两种方式跳出多层循环:一种用标志变量,一种用 goto。
typedef struct {
int row;
int col;
int found;
} SearchResult;
SearchResult matrix_search(int** matrix, int rows, int cols, int target);练习 3:手写一个协议帧解析状态机
难度:进阶 · 用 switch 加状态变量实现状态机
本篇末尾演示过一个逐字节驱动的串口协议状态机(起始符 0xAA → 载荷长度 → 载荷 → 结束符 0x55)。请你自己实现一个等价的解析器:把收到的字节逐个喂给 frame_feed,它内部用 switch (state) 在状态之间转移,收完一帧后把载荷打印出来。
#include <stdint.h>
typedef enum { STATE_IDLE, STATE_LEN, STATE_PAYLOAD, STATE_DONE } FrameState;
/// @brief 逐字节喂入;收到完整一帧(含结束符)时返回 1,否则返回 0
int frame_feed(uint8_t byte);要求用 switch 加一个显式的状态变量来实现,别用一长串 if-else。再想一件事:如果对端发来的"载荷长度"字段被篡改成一个超出缓冲区大小的值,你的状态机会被带崩吗?该怎么防?
参考答案
展开代码 (共 47 行)收起代码
#include <stdio.h>
#include <stdint.h>
#define MAX_PAYLOAD 16
typedef enum { STATE_IDLE, STATE_LEN, STATE_PAYLOAD, STATE_DONE } FrameState;
static FrameState state = STATE_IDLE;
static uint8_t payload[MAX_PAYLOAD];
static uint8_t payload_len = 0;
static uint8_t payload_idx = 0;
int frame_feed(uint8_t byte) {
switch (state) {
case STATE_IDLE:
if (byte == 0xAA) { // 等到起始符才进入下一状态
payload_idx = 0;
payload_len = 0;
state = STATE_LEN;
}
break;
case STATE_LEN:
// 防御:长度字段可能被篡改,钳到缓冲区上限,避免后面越界写
payload_len = (byte <= MAX_PAYLOAD) ? byte : MAX_PAYLOAD;
state = (payload_len == 0) ? STATE_DONE : STATE_PAYLOAD;
break;
case STATE_PAYLOAD:
payload[payload_idx++] = byte;
if (payload_idx >= payload_len) {
state = STATE_DONE;
}
break;
case STATE_DONE:
if (byte == 0x55) { // 正常结束符
printf("Frame OK (%u bytes):", payload_len);
for (uint8_t i = 0; i < payload_len; i++) {
printf(" %02X", payload[i]);
}
printf("\n");
state = STATE_IDLE;
return 1;
}
state = STATE_IDLE; // 没等到结束符,帧出错,回空闲重新等 0xAA
break;
}
return 0;
}关键在每个 case 里都明确写出"下一个状态是谁",这就是状态机读起来比一长串 if-else 清爽的地方。STATE_LEN 里对长度做钳制,是协议解析里最基本的防御:永远别直接信任对端发来的长度字段。