1231231:基于状态机的LED蜂鸣器节奏提示器实现 这个名字确实是我随手敲的1231231。项目做的是一台“节奏提示器”把一串数字变成能感知的灯光和声音信号因此取名的时候也没怎么动脑子直接用这串数字当项目代号。它不是什么大工程也没有上报给谁纯粹是我桌面上一个能跑、能玩、能干活的小东西没有屏幕不弹通知只靠短闪、长闪、停顿把“该喝水了”“程序跑完了”“设备出问题了”这些信息用灯光和蜂鸣器表达出来。如果你对单片机、交互原型、或者想自己做个桌面提醒装置感兴趣这篇就正好是给你写的。我第一次想这件事是被自己的进度条搞烦了。编译一个稍大的工程时我总忍不住盯着终端看它到底跑完没有。后来就想能不能让设备在完成后主动发光提示于是就有了这样一串规则1 代表短闪2 代表长闪3 代表停顿。一圈播完用户就知道状态。而测试时我随手按出来的“1231231”就成了整个项目的灵魂密码。1. 从一串数字到一套节奏信号1.1 为什么用“1、2、3”来定义动作刚开始做的时候我纠结过是不是要用颜色、文字、甚至语音播报。后来发现目标场景根本不需要那么复杂——设备就摆在手边眼睛余光能扫到灯光耳朵能听到蜂鸣声就足够了。于是我把输出动作压缩成三个基本元素1 短闪一盏灯快速亮一下像眨了一下眼通常用来表示“正常”“执行了”“还在运行”。2 长闪一盏灯持续亮一会儿表示重点提示像“完成”“警告”“请关注我”。3 停顿灯完全熄灭一段时间用来分隔前后动作避免连在一起看不清。这三个元素组合起来就是一套极简的节奏协议。“1231231”按这个规则展开就变成了短闪 → 长闪 → 停顿 → 短闪 → 长闪 → 停顿 → 短闪 → 结束收尾看起来有点像摩斯码但没有那么严谨也不需要背表。任何人都能靠肉眼和耳朵分清“闪了两下”和“闪了一下”这就足够了。1.2 为什么偏偏用重复数字串做演示用“1231231”当演示有个很实际的原因它有明显的循环感。1、2 交替出现中间夹着 3从头到尾念一遍就是“短、长、顿、短、长、顿、短”。这种循环结构特别适合校准参数我只要盯着一轮一轮播完就能飞快判断短闪和长闪是否够清楚、停顿是不是太急促。如果换成“8772921”这种没有规律的序列排查的时候我自己都记不住下一拍该是什么根本无法判断设备是否忠实还原了原始指令。另外一个意外好处是这个字符串本身好记。测试时我经常直接把它写死在代码里循环播放不用每次重新输入。后来接口调试、给朋友演示、甚至改命名的时候大家都直接说“那个 1231231 的项目”比叫一个正经英文名还有辨识度。1.3 这串数字能用在什么场景别觉得它只能用来做个玩具。我实际用下来的几个场景程序编译完成提醒把“2”映射成一次长闪短蜂鸣编译结束时灯亮一下不用一直盯屏幕。设备异常提示把“222”映射为连续三次长闪异常发生时即使没看终端也能察觉。番茄钟休息“111”表示工作结束可以站起来喝水“333”表示休息结束该回去干活了。测试仪器状态某个端口有数据进来时短闪一下数据停止后闪长闪排查丢包很方便。这些场景的共同点是信息量不大但需要及时感知。用屏幕弹窗会打断当前工作用灯光反而能做到“看见了就知道不看见也不打扰”。2. 硬件准备一个LED也能起步2.1 器件清单与作用如果你也想照着做不用买太多东西。我列一下手头最低配置器件数量作用备注主控板1块跑解析和播放逻辑带数字IO即可LED发光二极管1~3颗输出短闪和长闪信号颜色无所谓220Ω限流电阻1~3只防止LED过流烧毁必须加轻触按钮1只手动触发播放序列可选但建议加蜂鸣器1个输出声音提示可选建议用有源型面包板、杜邦线若干连接电路没杜邦线直接焊也行我最早只用了“主控板LED面包板”三样东西连按钮都没有直接在代码里调用播放函数也能验证逻辑。后面为了方便演示才加了按钮和蜂鸣器。2.2 接线方式接线上没有太高门槛关键是把引脚记清楚避免代码和实物对不上LED 正极接主控板的 2 号引脚负极串联一个 220Ω 电阻再接到 GND。如果不串电阻LED 可能会因为电流过大而发烫甚至直接烧掉。蜂鸣器正极接 3 号引脚负极接 GND。一般有源蜂鸣器直接给高电平就会响不需要额外驱动电路。按钮一端接 4 号引脚另一端接 GND。代码里启用内部上拉这样按钮不按时读到高电平按下读到低电平省掉了外部上拉电阻。接好之后看起来很简单2 号管灯3 号管声音4 号管输入。主控板跑起来之后读到“1231231”就按节奏闪灯播音。2.3 选型时我踩过的几个点这里有一些不写进教程但很影响体验的细节蜂鸣器分两种有源和无源。有源蜂鸣器给个高电平就响频率固定控制最简单无源蜂鸣器需要给它一个方波或 PWM 才能发声代码复杂一些。做节奏提示就选有源省心。LED 颜色我建议选蓝或白视觉上比红色更醒目如果放在强光环境红色反而看不清。按钮不一定非要加但加了之后调试效率高很多不用每次重新上电。我甚至建议你把按钮直接做成“重播上一次序列”的功能比串口命令方便。主控板供电要稳。我试过用移动电源的劣质线电压掉到 LED 亮度明显变低节奏也显得“有气无力”后来换了短线就好了。3. 核心逻辑解析字符串、状态机、非阻塞播放3.1 用结构体描述一个动作代码层面最核心的是把“1231231”这种字符串变成能顺序播放的动作序列。我定义一个很轻量的结构体struct Step { char kind; // 1 短闪2 长闪3 停顿 unsigned long durationMs; // 这个动作持续多少毫秒 };短闪、长闪、停顿本来只是三个字符但运行时真正需要的是每个动作的持续时长。解析函数负责把字符转换成 Step 数组const unsigned long SHORT_MS 200; const unsigned long LONG_MS 600; const unsigned long GAP_MS 300; const unsigned long END_MS 800; Step steps[64]; int stepCount 0; bool parseSequence(const char* seq) { stepCount 0; for (int i 0; seq[i] ! \0; i) { if (stepCount 63) return false; char c seq[i]; if (c 1) { steps[stepCount] {c, SHORT_MS}; } else if (c 2) { steps[stepCount] {c, LONG_MS}; } else if (c 3) { steps[stepCount] {c, GAP_MS}; } else { return false; // 遇到不认识的字符直接拒绝 } } // 在最后补一个结束停顿让整套动作有一个明显的收尾缓冲 steps[stepCount] {x, END_MS}; return true; }这里有几个细节值得展开。第一我用了字符数组而不是 String 对象省内存逻辑也更直接。第二非法字符直接返回错误避免出现“解析了一半”的脏状态。第三末尾强制追加一个结束停顿否则序列播完的瞬间灯光会突兀地切断视觉上像出了 Bug。3.2 播放器状态机只用两个变量假装“多线程”很多第一次写播放逻辑的人会这样想解析完数组之后写一个 for 循环每个动作 delay 对应的毫秒数然后点亮或熄灭 LED。听起来没错但有个致命问题delay 是死等在 delay 期间按钮完全没响应主循环也别想干任何其他事。所以我把播放过程改成状态机。状态机里只需要记住两件事我现在播到第几步了这一步是从哪个时刻开始的。int currentStep 0; bool isPlaying false; unsigned long stepStartMs 0; void startPlayback() { if (stepCount 0) return; currentStep 0; stepStartMs millis(); isPlaying true; digitalWrite(LED_PIN, LOW); digitalWrite(BUZZER_PIN, LOW); } void tickPlayback() { if (!isPlaying) return; unsigned long now millis(); unsigned long elapsed now - stepStartMs; Step st steps[currentStep]; if (elapsed st.durationMs) { // 这一个动作结束先把输出全部关掉 digitalWrite(LED_PIN, LOW); digitalWrite(BUZZER_PIN, LOW); currentStep; stepStartMs now; if (currentStep stepCount) { isPlaying false; return; } return; } // 没到时长按当前动作类型控制输出 bool active (st.kind 1 || st.kind 2); digitalWrite(LED_PIN, active ? HIGH : LOW); digitalWrite(BUZZER_PIN, active ? HIGH : LOW); }每次主循环都调用 tickPlayback() 一次。它不阻塞只是在时间到了之后轻轻推进一档。只要把 tickPlayback() 放在主循环里播放逻辑就可以和按钮检测、串口接收并行存在。过了一段时间之后我回头看这段代码它其实就是一套很典型的有限状态机状态 当前步骤事件 时间耗尽动作 前进到下一步。理解了这个模型以后做交通灯、仪表盘提示、动画播放都会顺畅很多。3.3 三组时间参数怎么定播放节拍是否舒服全靠三个时间短闪时长、长闪时长、停顿时长。我默认的是 200ms、600ms、300ms。下面这张表是我调参时记录的范围参数默认值可调范围经验判断短闪200ms120~400ms低于 100ms 人眼容易看不清长闪600ms400~1000ms要明显长于短闪建议至少 3 倍停顿300ms200~600ms太短会糊成一团太长拖节奏收尾停顿800ms500~1500ms给循环播放留一个呼吸空间比例比绝对值重要。短闪和长闪如果是 200ms 和 400ms其实很难分清楚改成 200ms 和 600ms 之后视觉差距立刻拉开。停顿最少也得 200ms否则前一个灯光熄灭后下一个灯光马上亮起来人眼会把它们误认成一整个闪动。这个经验很像拍照时的快门节奏该停的一拍必须停干净。3.4 手动触发按钮检测要点把播放封装成 startPlayback() 之后触发入口就很简单了。一个按钮按下就调用一次 startPlayback()。最简单的检测长这样const int BUTTON_PIN 4; const unsigned long DEBOUNCE_MS 30; int lastButtonReading HIGH; int stableButtonState HIGH; unsigned long lastDebounceTime 0; void handleButton() { int reading digitalRead(BUTTON_PIN); if (reading ! lastButtonReading) { lastDebounceTime millis(); } if (millis() - lastDebounceTime DEBOUNCE_MS) { if (stableButtonState HIGH reading LOW) { startPlayback(); } stableButtonState reading; } lastButtonReading reading; }这段代码看着啰嗦但它是物理按钮接入嵌入式设备的必修课。我做的第一版没有防抖结果按一次按钮逻辑上被判定成三四次按下默认序列被反复重启灯光闪烁得跟抽风一样。加一个 30ms 的去抖窗口后问题立刻消失。核心思路就是不要一读到电平变化就立刻响应等几十毫秒确认按钮真的稳定按下了再触发。4. 实操记录三个让我挠头的坑4.1 delay 一时爽改起来火葬场第一版播放逻辑我图方便直接用 delay。当时测下来也能跑但我很快发现一个很实际的问题播放的时候按钮没有用。我按下按钮想重新触发序列结果设备正卡在 delay 里等它回过神来我的手指早就松开了。这种体验非常糟糕尤其当序列比较长时那个设备看起来就像“死机”了。后来我把主循环改成完整的状态机由时间驱动而不是由延迟驱动。主循环本身跑得飞快每次进来都看一眼当前步骤是否完成按钮则随时可以被检测。这样哪怕序列有 20 步按钮也能立刻打断并重新开始。这个改动并不复杂但属于“用过一次就再也回不去”的级别。4.2 没有防抖时按键像在被猫踩硬件按钮的本质是两个金属触点碰撞按下瞬间会产生抖动噪声。如果没有处理噪声会让主控板在 1 毫秒内读到“低-高-低-高”多次变化每次变化都被当成一次有效触发。我第一次接按钮时按下去的一瞬间听到了连续好几声蜂鸣LED 也闪了好几轮。解决方案就是上面的去抖逻辑。去抖的时间不需要太长30ms 足够太长反而会让人觉得按键迟钝。要特别注意去抖只影响“抖动期间”的判定不影响正常连按功能。如果你想让用户按住不放重复播放那又是另一种逻辑不要混在一起。4.3 millis() 溢出别写成绝对比较主控板上电久了毫秒计数器会回绕。这是自然现象大约几十天一轮回。如果代码里写的是if (millis() stepStartMs durationMs)一旦计数器回绕那个加法可能会溢出导致条件永远不成立设备就像卡死在某一拍上。我一开始写的正是这种绝对比较测试时没问题因为计数器没跑满等连续运行了一段时间后设备表现出一种极其隐蔽的“卡住”状态排查了很久才发现是溢出。正确的写法是用差值unsigned long elapsed now - stepStartMs;无符号整数回绕在减法里反而是安全的因为计数器从大数绕到小数时差值依然能正确表示“过去了多少毫秒”。这也是嵌入式代码最常见的稳定写法永远别拿两个绝对时间比大小只比差值。4.4 实测出来的参数调整记录我在实际测试中搭了一套非常简单的校准流程让设备循环播放“1231231”然后我坐在旁边看完整过程凭感觉调整参数。以下几条记录保留了原始数据短闪 100ms 时会觉得“好像闪了一下又不能确定”改成 200ms 之后才确定。长闪 400ms 和短闪 200ms 区分度不够视觉上接近改成 600ms 清晰多了。停顿 150ms 会让两个连续闪烁看起来像一次改成 300ms 后每个动作都分开。收尾停顿如果太短重新播放时第一下闪烁会显得像上一轮的延续我把它设成 800ms。这些数据不能算严格的人因实验但对个人工具已经足够。它只说明一件事调参要“整串播完再判断”不能只盯着单个动作半小时。5. 后续扩展把数字串变成通信协议5.1 通过串口动态修改序列“1231231”写死在代码里只是第一步。我后来加了一个串口入口让电脑可以随时发送新的数字串给设备播放。实现方式是在主循环里检查串口是否有数据读到一行就以换行符作为结束把字符串交给之前的 parseSequence 解析然后立刻 startPlayback。这样从电脑上就可以发111、222、31313等任意序列不用烧录新固件。之所以说它是“协议”而不是“黑客手段”是因为我加了两条规则一是只能包含 1、2、3 三种字符二是长度有限制超过数组上限拒绝解析。这两条规则保证了外部输入不会搞坏设备。如果你也想做类似功能一开始就给协议设边界别让解析函数无限吸收输入否则很容易出现缓冲区溢出。5.2 多色 LED从“闪不闪”到“什么颜色在闪”单色 LED 能表达“动作是否发生”但表达“是什么状态”还是吃力。扩展版可以引入第二、第三颗 LED比如 1红色短闪、2绿色长闪、3全部熄灭。于是一帧信号里除了时间和长短还有了颜色维度。比如红色表示失败、绿色表示成功、蓝色表示正在执行。这个扩展对代码改动很小把 Step 里加上一个颜色编号字段播放时根据编号点亮不同引脚就行。如果你用的是 RGB 灯珠甚至可以直接用 PWM 调整亮度把颜色当成第四个维度。5.3 蜂鸣器从“响一声”到“旋律化”蜂鸣器默认跟着 LED 同步响长时间播放会让耳朵很累。我后来把“1”和“2”分别对应短音和长音并且给音调做了变频短闪时用高一点的频率长闪时用低一点的频率。用户光听声音就能区分正常和告警不需要一直盯着灯。如果你愿意再进一步甚至可以定义 4一个音、5连续两个音、6空拍把数字串变成一个简单旋律编码。这个方向很好玩像是一种复古的“无屏短信”。5.4 低功耗和外壳化这套逻辑拿掉 LED、改用震动马达之后可以放在口袋里当通知震子。要做到这一点主要处理两件事一是用中断引脚唤醒主控板平时进入睡眠模式二是播放完毕后立刻关闭所有输出不要挂在 GPIO 上浪费电。项目后期我把它塞进了一个 3D 打印的盒子只露出 LED 和按钮桌面整理度立刻高了一个档次。一个原本看起来像裸露电路板的小装置变成了一个能摆在显示器旁边的小物件体验差别很大。5.5 与更大系统联动最后这个扩展是我最喜欢说的。因为整个项目本质上是“接收一串数字输出一段节奏”所以它可以很自然地接到其他系统后面。比如后台脚本在编译结束时通过串口发一个2网络请求异常时发一个222定时任务完成时发11211。设备不负责判断这些信息是什么意思它只负责忠实播放。这样的分层让职责非常清楚上层系统管语义设备只管执行。我在实际使用中的体会是这种“用节奏代替屏幕”的小项目价值不在硬件多高级而在于逼着你重新思考表达方式。当我去掉所有多余的 UI、颜色、文字最后发现信息还是能传达到人的感官里这是一种很有趣的减负。后来这个项目也一直没有改名字每次打开代码看到“1231231”这个标题我都觉得它像一个提醒第一版难看没关系能跑起来比什么都强。如果你也有一堆随手敲的数字或者乱码命名的项目不妨挑一个拆开看看给它的输出加一点看得见、听得到的节奏。把“1231231”变成实际运转的小设备比反复整理那些文件夹有意思得多。