第 3 篇:把逻辑写清楚——控制流、函数与枚举
回顾前两篇的「小车工厂模型」:第 1 篇看了工厂大门、工具箱、开工点名和永不下班的流水线;第 2 篇看了仓库里的储物盒(变量)和怎么选尺寸合适的盒子(数据类型)。
本篇走进车间,看「工人到底按什么规矩干活」:遇到分叉怎么选路(控制流)、怎么把一串动作打包成班组(函数)、怎么让小车随时清楚自己正在干啥(枚举状态机),以及怎么安全地摆弄文字(字符串函数)。
0. 引子:工人手里的"操作流程图"
光有存放数据的储物盒(变量)还不够,程序得有一套干活规则。
把代码逻辑比作车间操作图纸:遇到不同情况走不同路线、重复的动作循环执行、一整套固定操作打包统一调用。
通俗来讲:
- 控制流:决定程序什么时候走哪条路、什么时候重复干活;
- 函数:把重复操作打包成独立班组;
- 枚举:给小车每种运行状态起专属名字,方便管理。
1. 控制流:工人干活的路线规则
1.1 if / else if / else:多岔路口,范围判断专用
最基础的判断语句,适合判断区间、多段复杂条件。
生活化比喻:骑小车出门,速度大于 100 减速、速度 0 停车、其余正常行驶,分多种区间处理。
我们项目里ApplyMotionControl就是用一长串if / else if判断小车当前运动模式,再计算四个麦轮的转速。
适用场景:数值区间判断、逻辑条件比较复杂的场景。
1.2 switch-case:固定选项查表,干净清爽
如果同一个变量只有几种固定取值,用 switch 比一长串 if 好看太多。
比喻:遥控器按键对照表,按键1=前进、按键2=后退、按键3=原地旋转,一目了然,不用一层层判断。
项目里大量使用:
HandleNRFCommands接收遥控指令,切换小车运动模式;- 屏幕打印小车状态,数字对应文字;
- 根据当前工况,分别计算平移、旋转速度。
适用场景:单一变量、固定离散数值判断,编译器还会优化运行速度。
1.3 for:提前知道要循环多少次
比喻:给屏幕补空格,固定要填充16个空格,次数写死,跑完直接结束。
代码里OLED_ShowTrim使用 for 循环补空格,消除屏幕上一帧残留文字。
适用场景:写代码时就能确定循环总次数。
1.4 while:不知道循环几次,满足条件就一直干
比喻:不断读取摄像头数据,只要还有数据就持续读取,缓存空了立刻停下。
我们最熟悉的while(1)主循环就是无限 while;读取串口数据也会用 while 读完所有缓存字节。
适用场景:循环次数不确定,靠条件判断是否停止。
一句话快速区分:
固定几个选项判断用 switch;区间、复杂条件用 if;次数已知用 for;次数未知靠条件终止用 while。
2. 函数:把动作打包成"班组"
2.1 为什么要把代码拆成函数
看main.c,真正干活的几乎都是一个个static void函数:HandleNRFCommands(收遥控)、ApplyMotionControl(算速度)、SendReply(回应答)、AppendToDisplay(拼显示)、OLED_ShowTrim(刷屏)……
比喻:每个函数是一个"班组",只负责一件明确的事。
main像车间主任,只负责调度,不用管班组内部细节。好处是:可读性高、能复用、好改——改显示逻辑只动OLED_ShowTrim,不用翻遍整个文件。
2.2 static 函数:本车间私有的内部班组
这些函数名前面都带static(定义在 main.c:95、144、161、173、208、230,即AppendToDisplay/ModeName/OLED_ShowTrim/SendReply/HandleNRFCommands/ApplyMotionControl的定义处)。
比喻:
static函数 = 这个车间私有的班组,别的车间(别的.c文件)叫不到它。一来避免和别处同名函数打架,二来等于告诉读代码的人:“这函数只在这个文件内部用,别处不用管”。
(注意区分:第 2 篇讲过"函数内的 static 变量"是让局部变量保持值;这里的 static 是"文件内可见",是两码事。)
2.3 const 参数:只可读不可改的封条
OLED_ShowTrim(uint8_t row, uint8_t col, const char* s)(main.c:161)的最后一个参数有const。
比喻:
const等于在参数上贴了张"此物只可看、不可改"的封条。函数向你保证:我不会动你传进来的字符串。调用的人更放心,编译器也能帮你抓"不小心改了只读数据"的低级错误。
2.4 extern:在别车间挂个号
第 2 篇说过全局变量。有些全局变量"定义在 A 文件、用在 B 文件",B 文件里就要用extern声明一下"这东西在别处,这儿只是挂个号":
externfloatyaw;// 航向角,在角度环文件里定义 (main.c:53)externvolatileuint32_tTimer7_5ms_Count;// 节拍计数,在 Timer7 文件里定义 (main.c:54)比喻:
extern像在公告栏贴"本厂共用物资,存放在 3 号仓库"——不用搬过来,谁要用谁去那儿取。这就是多文件协作的基本套路。
伏笔提示:第 2 篇说过全局变量常带volatile,还提到static、extern都和"多文件/多任务"有关。本篇把static函数、extern讲完了;但volatile+ 中断是另一座大山——主循环和中断抢同一个变量会出什么乱子、volatile怎么破局,我单独放到第 4 篇专讲。
3. 枚举状态机:让小车随时清楚"自己正在干啥"
3.1 什么是枚举
小车有前进、后退、左移、右移、旋转、停车、发射好几种工况。代码里没有用数字 0/1/2 去记,而是用了枚举(main.c:117-126):
typedefenum{MODE_STOP,MODE_CANNON,MODE_FORWARD,MODE_BACKWARD,MODE_LEFT,MODE_RIGHT,MODE_ROTATE_LEFT,MODE_ROTATE_RIGHT}MotionMode;staticMotionMode current_mode=MODE_STOP;// 当前工况 (main.c:127)比喻:枚举就像工厂墙上的一排"工位指示灯",每个灯(名字)对应一个明确状态。你看到
MODE_FORWARD立刻懂是"前进";要是看到个光秃秃的2,还得翻表才知道啥意思。
3.2 状态机:按指令切状态,每帧按状态行动
- 遥控发指令→
HandleNRFCommands里switch把current_mode翻到对应模式(main.c:214-224); - 每帧主循环→
ApplyMotionControl按current_mode算出四个轮子目标速度(main.c:230-295); - 要显示时→
ModeName把模式转成 “FWD”/“BACK” 这种短名打在 OLED 上(main.c:144-158)。
比喻:这整套就是状态机——
current_mode是块"当前工况记事板"。遥控是调度室,发个命令就翻到对应工况;工人(主循环)每轮看记事板,决定这轮怎么干。
好处很明显:逻辑一眼看清、加个新工况只要添一个MODE_XXX、不容易出现"状态混乱"的诡异 bug。
4. 字符串函数:摆弄文字的"专用工具"
小车要跟 OpenMV 摄像头、遥控器交换文字(二维码、状态信息),C 语言的<string.h>、<stdio.h>提供了一堆工具。注意main.c里处理字符串很克制:能限长度的地方一律用带n的限度版本,sprintf也只用在缓冲区明确够大的地方:
4.1 strncpy:限量拷贝
strncpy((char*)&NRF24L01_TxPacket[1],openmv_tx_buf,30);// 最多拷 30 个 (main.c:182)比喻:普通
strcpy像"整箱倒",倒多了就溢出来;strncpy多了个n参数"最多倒 30 个",倒满就停,绝不越界。
4.2 sprintf:按模板填空
sprintf((char*)&NRF24L01_TxPacket[1],"M%d S%d %d %d %d",...);// 拼状态帧 (main.c:199)比喻:像一张填空模板,"M__ S__ __ __ __"把几个变量填进去,自动生成一串文字。OLED 显示也用它(main.c:506、516)。
4.3 strncmp:只比前 n 个字符
if(strncmp(last_openmv_line,last_beep_line,RX_LINE_WIDTH)!=0)// 去重 (main.c:474)比喻:只比对前 N 个字母,判断两行文字一不一样,避免整串比到天荒地老或越界。
4.4 memset:批量清零
memset(display_lines[current_line],0,RX_LINE_WIDTH+1);// 清空一行缓冲 (main.c:101)比喻:把一整排储物盒一次性清空,准备装新东西。
安全提醒:单片机内存小、还没有操作系统兜底,缓冲区溢出会直接覆盖别的变量、甚至程序跑飞。所以能限长度的(strncpy/strncmp/memset)一律用带n的版本,绝不碰strcpy、gets这类无上限的危险函数;sprintf本身不检查长度,本项目里只把它用在目标缓冲区明确够大的地方。
5. 本篇小结
记住这页"车间操作手册":
| 知识点 | 通俗理解 |
|---|---|
if / else | 多岔路口,适合区间、复杂条件判断 |
switch-case | 固定选项查表,多离散分支更清爽 |
for / while | for 循环次数固定;while 靠条件控制启停 |
static函数 | 本文件私有的内部班组 |
const参数 | "只可读不可改"的封条 |
extern | 在别文件挂个号,跨文件共用变量 |
enum枚举 | 工位指示灯,状态一目了然 |
strncpy/strncmp/memset(限度版)、sprintf | 摆弄文字的安全工具:strncpy等带n限长度,sprintf只用于够大的缓冲区 |
6. 第2篇小练习答案
练习1:把int16_t Speed1改成uint16_t,小车运行时会出什么异常?
轮子速度有正有负——前进是正、后退是负。而uint16_t只能装 0~65535。一旦小车后退产生负速度(比如 -50),存进uint16_t会按补码规则"绕"成 65536-50 = 65486 这种巨大正数。后果:PID 算出来以为轮子该以接近满速正转,电机反向狂转、小车乱冲,速度显示也变成几万,彻底失控。所以轮子速度必须用int16_t这种"能表示负数"的类型。
练习2:同样存储 0~200 的数字,分别用int和uint8_t,各占多少内存?哪种更适合单片机?
在 32 位 STM32 上,int固定占4 字节(32 位),uint8_t只占1 字节(8 位)。0~200 完全落在uint8_t的 0~255 范围内,用uint8_t足够且省下 3/4 的内存。单片机 RAM 很金贵(通常才几 KB 到几十 KB),能省则省,所以这种"小数"优先选uint8_t。
本篇趣味思考(答案见第4篇末尾)
- 如果把
MotionMode枚举删掉,改用#define MODE_FORWARD 1这类宏来表示模式,和用枚举相比,哪种更容易埋下 bug?为什么?(提示:想想枚举让编译器帮你做了哪些检查。) - 代码里为什么坚持用
strncpy/sprintf而不是strcpy?如果把 OpenMV 发来的不定长文本,直接strcpy进一个固定大小的数组,在单片机上可能出什么事?
7. 交流
我目前也是嵌入式自学新手,本篇内容是结合自己麦轮小车项目实操总结而来,部分知识点理解可能存在偏差,讲解也有不够严谨的地方。
- 技术大佬如果发现文中存在错误、逻辑漏洞,欢迎评论区指正,我会及时修正文章;
- 零基础小白有看不懂的知识点、实操踩坑问题,都可以留言提问,我看到会逐条回复;
- 大家也可以分享自己做智能小车的经验、代码优化思路,一起交流学习,共同进步!
祝愿各位大佬项目开发一路顺畅,调试零bug,思路源源不断;
祝愿所有入门小白少踩弯路,快速吃透STM32,早日做出属于自己的智能小车!
下篇预告
《第 4 篇:volatile 与中断——嵌入式 C 最关键的坑》。我们会讲中断到底是什么、为什么主循环和中断抢同一个变量会出乱子,volatile 怎么破局,以及中断里"查标志→处理→清标志"的标准动作。