Arduino交通灯模拟:数码管倒计时与状态机实战

发布时间:2026/10/13 1:03:01
Arduino交通灯模拟:数码管倒计时与状态机实战 1. 项目缘起与整体设计思路1.1 为什么选择这个项目作为入门练手交通灯模拟几乎是每个接触单片机的人都会碰的一个经典项目但很多人做完之后只是让几个LED轮流亮一下就草草收场了。我当初也是这么干的后来发现这个项目其实可以挖得很深——加上数码管读秒之后它一下子从“点亮LED”升级成了“状态机定时器动态扫描显示”的综合练习涉及的知识面覆盖了数字输出、定时中断、数码管驱动、状态切换逻辑这几个核心模块。说白了这个项目的本质是用一块Arduino板子模拟一个十字路口的红绿灯工作流程同时用数码管把每个灯色的剩余秒数实时显示出来。听起来简单但真正动手做的时候你会发现几个绕不开的问题——数码管怎么驱动才不会闪烁倒计时怎么和灯光状态精确同步两个方向的红绿灯怎么互锁这些问题解决了你对Arduino的理解会上一个台阶。适合谁来参考这篇内容如果你已经能点亮一个LED、知道digitalWrite()和delay()怎么用那就可以直接上手。如果你连面包板都没插过也没关系我会把接线和代码都讲清楚照着做就行。但有一点要说在前面这个项目涉及的知识点比较多建议至少留出两个晚上的时间第一晚搭硬件调通基本逻辑第二晚优化显示效果和代码结构。1.2 整体方案选型与核心考量做交通灯模拟方案其实有好几种。最简单的就是纯delay()轮询几个LED加电阻代码不到二十行就能跑起来。但一旦加上数码管读秒delay()方案的致命缺陷就暴露了——数码管需要不断刷新才能保持显示如果你用delay(1000)来计时这一秒内数码管是灭的你会看到数字一闪一闪体验极差。所以我的方案是用millis()做非阻塞定时主循环里同时处理倒计时逻辑和数码管动态扫描。这样数码管每2-3毫秒刷新一位人眼看起来就是稳定常亮的同时倒计时也能精确到秒。硬件选型方面我列了一个对比表方便你根据自己的手头材料来选方案数码管类型驱动方式占用引脚数优缺点方案A4位共阳数码管直接IO驱动12根接线多但无需额外芯片适合学习方案B4位共阳数码管74HC595移位寄存器5根接线简洁但需要理解移位寄存器原理方案CTM1637模块专用驱动芯片2根最省引脚但封装了底层细节学习价值略低我最终选了方案A原因很简单直接IO驱动能让你彻底搞明白数码管的段选和位选是怎么回事后面再用74HC595或者TM1637就是降维打击。如果你手头只有TM1637模块也可以用但建议至少先了解一下共阳数码管的内部结构。LED部分没什么好纠结的红黄绿三色LED各两个一个方向一组限流电阻用220Ω到1KΩ都可以我用的470Ω亮度适中不刺眼。面包板供电直接用Arduino的5V整个系统电流不大不需要外接电源。2. 核心细节解析与实操要点2.1 数码管的工作原理与引脚分布先说说数码管这个东西。4位共阳数码管顾名思义就是四个数字位共用一组阳极正极每个数字位的阴极负极是独立的。内部结构可以这样理解把四个数字的相同段比如所有数字的a段的阳极连在一起然后每个数字的阴极单独引出来。具体到引脚常见的4位共阳数码管比如3641AS有12个引脚上下各6个。你拿到手之后先用万用表二极管档测一下或者直接查型号对应的数据手册。我手头这个的引脚定义是这样的引脚1e段引脚2d段引脚3共阳COM1对应第一位引脚4c段引脚5dp小数点引脚6b段引脚7a段引脚8共阳COM2引脚9f段引脚10g段引脚11共阳COM3引脚12共阳COM4注意不同型号的引脚顺序可能完全不同一定要先确认清楚再接线。接错了不会烧但显示会乱排查起来很烦。段码表是必须提前算好的。共阳数码管的特点是给段引脚低电平0时该段点亮给高电平1时熄灭。所以数字0的段码是a~f亮g和dp灭二进制是11000000十六进制就是0xC0。我直接把0-9的段码整理成表数字段码十六进制二进制00xC01100000010xF91111100120xA41010010030xB01011000040x991001100150x921001001060x821000001070xF81111100080x801000000090x9010010000这个表建议直接存在代码的数组里用的时候查表就行。2.2 动态扫描的时序控制与消影处理动态扫描的原理不复杂每次只点亮一位数码管然后快速轮流切换利用人眼的视觉暂留效应看起来就是四位同时亮的。但这里有几个坑我踩过之后才搞明白。第一个坑是扫描频率。太快了亮度不够太慢了会闪烁。我实测下来每位显示1-2毫秒四位一轮下来4-8毫秒刷新率在125Hz到250Hz之间效果最好。低于50Hz就能看出闪烁了。第二个坑是消影。什么叫消影就是你在切换位选的时候如果段选数据还没更新上一位的数字会短暂地“串”到下一位上看起来就是数字有残影。解决办法很简单在切换位选之前先把段选全部置高共阳数码管全灭然后再切换位选、更新段选。这个操作在代码里就是几行的事但不做的话显示效果很脏。第三个坑是亮度不均。如果你每位显示的时长不一样或者限流电阻不一致会出现某一位特别亮、某一位特别暗的情况。我的做法是每位固定显示2毫秒用millis()或者micros()来计时保证时序均匀。代码里我是这样实现的void displayDigits(int num) { // 分解各位数字 digits[0] num / 10; digits[1] num % 10; digits[2] 0; digits[3] 0; for (int i 0; i 4; i) { // 消影先关闭所有段 digitalWrite(SEG_A, HIGH); digitalWrite(SEG_B, HIGH); // ... 其他段全部置高 // 切换位选 digitalWrite(COM_1, i 0 ? LOW : HIGH); digitalWrite(COM_2, i 1 ? LOW : HIGH); digitalWrite(COM_3, i 2 ? LOW : HIGH); digitalWrite(COM_4, i 3 ? LOW : HIGH); // 输出段码 byte segCode segTable[digits[i]]; digitalWrite(SEG_A, bitRead(segCode, 0)); // ... 其他段 delayMicroseconds(2000); } }这段代码是简化版实际用的时候我把段选接到了Arduino的PORTD上直接用端口操作速度更快。2.3 交通灯状态机的设计逻辑交通灯的核心是一个状态机。一个完整的十字路口周期包括南北方向绿灯东西方向红灯持续时间T1南北方向黄灯东西方向红灯持续时间T2南北方向红灯东西方向绿灯持续时间T3南北方向红灯东西方向黄灯持续时间T4然后循环。T1和T3通常设为相同值T2和T4设为3秒左右。我这里为了演示把T1和T3设为10秒T2和T4设为3秒。状态切换的触发条件是倒计时归零。所以代码里需要一个全局变量remainingTime每过一秒减一减到0就切换到下一个状态同时重置remainingTime为下一个状态的持续时间。这里有个细节要注意倒计时的秒数显示和实际灯光切换必须同步。如果你用delay(1000)来倒计时那灯光切换也会被阻塞数码管刷新就断了。所以必须用millis()做非阻塞计时unsigned long previousMillis 0; int remainingTime 10; void loop() { unsigned long currentMillis millis(); // 每秒更新一次倒计时 if (currentMillis - previousMillis 1000) { previousMillis currentMillis; remainingTime--; if (remainingTime 0) { // 切换到下一个状态 currentState (currentState 1) % 4; remainingTime stateDuration[currentState]; } } // 更新灯光输出 updateLights(currentState); // 刷新数码管 displayDigits(remainingTime); }这个结构的好处是数码管刷新和倒计时互不干扰灯光切换也不会影响显示。3. 实操过程与核心环节实现3.1 硬件接线与引脚分配接线是整个项目里最耗时的部分尤其是数码管那12根线。我建议你先在纸上画一个接线图标清楚每个引脚接到Arduino的哪个IO口然后再动手插线。不然插到一半忘了回头查很麻烦。我的引脚分配是这样的功能Arduino引脚说明南北红灯D2输出南北黄灯D3输出南北绿灯D4输出东西红灯D5输出东西黄灯D6输出东西绿灯D7输出数码管a段D8输出数码管b段D9输出数码管c段D10输出数码管d段D11输出数码管e段D12输出数码管f段D13输出数码管g段A0输出数码管dpA1输出数码管COM1A2输出数码管COM2A3输出数码管COM3A4输出数码管COM4A5输出LED的接线每个LED的正极长脚通过470Ω电阻接到Arduino的IO口负极接到GND。数码管的段引脚通过220Ω电阻接到IO口COM引脚直接接到IO口因为共阳数码管的COM需要拉低才能点亮Arduino的IO口灌电流能力足够驱动。注意数码管的段引脚一定要加限流电阻否则电流过大会烧毁数码管或Arduino的IO口。我一开始没加电阻数码管亮了一下就明显变暗了后来加了220Ω电阻才恢复正常。3.2 完整代码实现与关键注释代码我分成了几个模块引脚定义、段码表、状态机变量、初始化函数、主循环、数码管刷新函数、灯光更新函数。下面是我实际跑通的版本// 引脚定义 const int NS_RED 2; const int NS_YELLOW 3; const int NS_GREEN 4; const int EW_RED 5; const int EW_YELLOW 6; const int EW_GREEN 7; const int SEG_A 8; const int SEG_B 9; const int SEG_C 10; const int SEG_D 11; const int SEG_E 12; const int SEG_F 13; const int SEG_G A0; const int SEG_DP A1; const int COM_1 A2; const int COM_2 A3; const int COM_3 A4; const int COM_4 A5; // 共阳数码管段码表0-9 const byte segTable[10] { 0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8, 0x80, 0x90 }; // 状态机定义 // 0: 南北绿东西红 // 1: 南北黄东西红 // 2: 南北红东西绿 // 3: 南北红东西黄 int currentState 0; const int stateDuration[4] {10, 3, 10, 3}; int remainingTime 10; unsigned long previousMillis 0; int displayCounter 0; void setup() { // 初始化所有引脚为输出 pinMode(NS_RED, OUTPUT); pinMode(NS_YELLOW, OUTPUT); pinMode(NS_GREEN, OUTPUT); pinMode(EW_RED, OUTPUT); pinMode(EW_YELLOW, OUTPUT); pinMode(EW_GREEN, OUTPUT); pinMode(SEG_A, OUTPUT); pinMode(SEG_B, OUTPUT); pinMode(SEG_C, OUTPUT); pinMode(SEG_D, OUTPUT); pinMode(SEG_E, OUTPUT); pinMode(SEG_F, OUTPUT); pinMode(SEG_G, OUTPUT); pinMode(SEG_DP, OUTPUT); pinMode(COM_1, OUTPUT); pinMode(COM_2, OUTPUT); pinMode(COM_3, OUTPUT); pinMode(COM_4, OUTPUT); // 初始全部熄灭 digitalWrite(NS_RED, LOW); digitalWrite(NS_YELLOW, LOW); digitalWrite(NS_GREEN, LOW); digitalWrite(EW_RED, LOW); digitalWrite(EW_YELLOW, LOW); digitalWrite(EW_GREEN, LOW); // 共阳数码管COM置高熄灭 digitalWrite(COM_1, HIGH); digitalWrite(COM_2, HIGH); digitalWrite(COM_3, HIGH); digitalWrite(COM_4, HIGH); } void loop() { unsigned long currentMillis millis(); // 每秒倒计时 if (currentMillis - previousMillis 1000) { previousMillis currentMillis; remainingTime--; if (remainingTime 0) { currentState (currentState 1) % 4; remainingTime stateDuration[currentState]; } } // 更新灯光 updateLights(currentState); // 刷新数码管每次loop刷新一位 refreshDisplay(remainingTime); } void updateLights(int state) { // 先全部熄灭 digitalWrite(NS_RED, LOW); digitalWrite(NS_YELLOW, LOW); digitalWrite(NS_GREEN, LOW); digitalWrite(EW_RED, LOW); digitalWrite(EW_YELLOW, LOW); digitalWrite(EW_GREEN, LOW); switch (state) { case 0: digitalWrite(NS_GREEN, HIGH); digitalWrite(EW_RED, HIGH); break; case 1: digitalWrite(NS_YELLOW, HIGH); digitalWrite(EW_RED, HIGH); break; case 2: digitalWrite(NS_RED, HIGH); digitalWrite(EW_GREEN, HIGH); break; case 3: digitalWrite(NS_RED, HIGH); digitalWrite(EW_YELLOW, HIGH); break; } } void refreshDisplay(int num) { // 分解数字 int d0 num / 10; int d1 num % 10; int d2 0; int d3 0; int digits[4] {d0, d1, d2, d3}; // 消影先关闭所有段 digitalWrite(SEG_A, HIGH); digitalWrite(SEG_B, HIGH); digitalWrite(SEG_C, HIGH); digitalWrite(SEG_D, HIGH); digitalWrite(SEG_E, HIGH); digitalWrite(SEG_F, HIGH); digitalWrite(SEG_G, HIGH); digitalWrite(SEG_DP, HIGH); // 关闭所有COM digitalWrite(COM_1, HIGH); digitalWrite(COM_2, HIGH); digitalWrite(COM_3, HIGH); digitalWrite(COM_4, HIGH); // 根据displayCounter选择当前位 int pos displayCounter % 4; displayCounter; // 打开对应COM switch (pos) { case 0: digitalWrite(COM_1, LOW); break; case 1: digitalWrite(COM_2, LOW); break; case 2: digitalWrite(COM_3, LOW); break; case 3: digitalWrite(COM_4, LOW); break; } // 输出段码 byte segCode segTable[digits[pos]]; digitalWrite(SEG_A, bitRead(segCode, 0)); digitalWrite(SEG_B, bitRead(segCode, 1)); digitalWrite(SEG_C, bitRead(segCode, 2)); digitalWrite(SEG_D, bitRead(segCode, 3)); digitalWrite(SEG_E, bitRead(segCode, 4)); digitalWrite(SEG_F, bitRead(segCode, 5)); digitalWrite(SEG_G, bitRead(segCode, 6)); digitalWrite(SEG_DP, bitRead(segCode, 7)); // 短暂延时保证亮度 delayMicroseconds(2000); }这段代码里refreshDisplay()每次只刷新一位靠displayCounter变量轮流切换。因为loop()执行速度极快四位轮流刷新的周期大概在几毫秒人眼完全看不出闪烁。3.3 参数计算与调试过程限流电阻的计算LED的正向压降一般在1.8V到2.2V之间Arduino输出5V那么电阻上的压降是5V减去2V等于3V。LED的工作电流一般取10mA到20mA我取15mA那么电阻值就是3V除以15mA等于200Ω。标准电阻里220Ω最接近所以用220Ω。实际用470Ω也可以亮度稍暗但更安全。数码管段电流共阳数码管每个段的电流一般也是10mA左右但因为是动态扫描实际平均电流只有峰值电流的四分之一因为每位只亮四分之一的时间。所以峰值电流可以取大一点20mA到30mA都没问题。我用的220Ω电阻峰值电流大概15mA平均下来不到4mA亮度稍微有点暗但室内看完全够用。调试过程中我遇到的最大问题是数码管显示的数字和实际倒计时不同步。现象是灯光已经切换到下一个状态了数码管还在显示上一个状态的秒数。排查后发现是因为remainingTime在状态切换时重置了但数码管刷新用的是同一个变量理论上应该同步。后来发现是refreshDisplay()里的delayMicroseconds(2000)累积起来导致loop()周期变长millis()计时出现了偏差。解决办法是把delayMicroseconds(2000)改成delayMicroseconds(1000)减少单次刷新时间提高loop()执行频率。另一个问题是黄灯状态显示的数字不对。黄灯只有3秒但数码管显示的是10秒。查代码发现是stateDuration数组的索引写错了把stateDuration[currentState]写成了stateDuration[0]。这种低级错误在调试时很常见建议在状态切换的地方加一句Serial.println()打印当前状态和剩余时间方便排查。4. 常见问题与排查技巧实录4.1 数码管显示异常速查表现象可能原因解决方法数码管完全不亮COM引脚没接对或者段码表用错共阳/共阴搞混用万用表测COM引脚电压确认共阳数码管COM接低电平某一段常亮不灭该段引脚虚焊或短路检查面包板插线重新插拔显示的数字缺笔画对应段引脚接触不良或电阻损坏换一个电阻试试或者直接短接电阻测试数字闪烁严重刷新频率太低或者delay()阻塞了主循环减少每位显示时间改用millis()非阻塞计时数字有残影没有做消影处理在切换位选前先把所有段置高某一位特别暗该位的COM引脚驱动能力不足或者限流电阻偏大换小一点的电阻或者用三极管驱动COM4.2 状态机逻辑错误的排查思路状态机出问题的时候最有效的办法是加串口打印。在loop()里加一句Serial.print(State: ); Serial.print(currentState); Serial.print( Time: ); Serial.println(remainingTime);然后打开串口监视器看状态切换的时机对不对。如果状态切换太早或太晚检查millis()的计时逻辑如果状态跳过了某个环节检查currentState (currentState 1) % 4这句有没有写错。还有一个常见问题是两个方向的红绿灯互锁失效比如南北绿灯和东西绿灯同时亮了。这通常是updateLights()函数里忘记先全部熄灭或者switch语句里漏了break。我的建议是在updateLights()开头先把所有LED置低然后再根据状态点亮对应的灯这样即使逻辑有误也不会出现两个方向同时绿灯的危险情况。4.3 实操心得与避坑建议第一条心得先调通灯光再加数码管。很多人一上来就把所有东西都接好结果出了问题不知道是灯光的锅还是数码管的锅。正确的做法是先只接LED跑通状态机逻辑确认红黄绿切换正常然后再接数码管单独调通显示最后把两部分合并。第二条心得面包板上的线要短。尤其是数码管那12根线如果线太长太乱很容易接触不良。我建议用跳线剪到合适长度插紧之后用手轻轻拉一下确认不会松。第三条心得代码里多用const和数组。比如stateDuration数组、segTable数组用const修饰可以防止意外修改也方便后期调整参数。引脚定义也用const int不要直接用数字不然改引脚的时候要满代码找。第四条心得黄灯时间不要太短。我一开始把黄灯设成1秒结果数码管还没刷新完就切走了看起来很不自然。后来改成3秒视觉效果就好多了。实际交通灯的黄灯一般是3秒这个值是有依据的。第五条心得如果要用74HC595驱动数码管注意移位顺序。74HC595是串行输入、并行输出先移入的位会出现在Q7最后移入的出现在Q0。如果你直接把段码移进去显示会是反的。解决办法是在移入之前把字节反转一下或者调整接线顺序。5. 功能扩展与进阶玩法5.1 加入按键实现手动控制基础版本跑通之后可以加一个按键实现手动切换状态。接线很简单按键一端接D2另一端接GNDD2配置为INPUT_PULLUP。代码里加一个中断或者轮询检测按下按键时强制切换状态。const int BUTTON_PIN 2; void setup() { pinMode(BUTTON_PIN, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), buttonISR, FALLING); } void buttonISR() { currentState (currentState 1) % 4; remainingTime stateDuration[currentState]; }用中断的好处是不会漏掉按键但要注意在中断服务函数里不要做太耗时的操作否则会影响数码管刷新。5.2 用蜂鸣器增加提示音在黄灯切换的时候加一声蜂鸣提示效果会更好。蜂鸣器接一个IO口在状态切换到黄灯时让蜂鸣器响200毫秒。注意蜂鸣器要加三极管驱动Arduino的IO口直接驱动蜂鸣器电流可能不够。5.3 用OLED屏幕替代数码管如果你觉得数码管接线太麻烦可以用0.96寸OLED屏幕替代I2C接口只需要两根线。显示效果更丰富可以同时显示倒计时、当前状态、甚至动画。但OLED的刷新方式和数码管不同不需要动态扫描代码会更简单。5.4 加入夜间模式实际交通灯在夜间会切换成黄灯闪烁模式。你可以加一个光敏电阻检测环境亮度当亮度低于阈值时自动进入黄灯闪烁模式所有方向都是黄灯以1Hz频率闪烁。这个功能用millis()很容易实现if (nightMode) { if (currentMillis - previousMillis 500) { previousMillis currentMillis; yellowState !yellowState; digitalWrite(NS_YELLOW, yellowState); digitalWrite(EW_YELLOW, yellowState); } return; }这个扩展让项目更贴近真实场景也增加了光敏电阻和模拟输入的知识点。6. 项目总结与个人体会这个项目我从开始动手到最终调通大概花了三个晚上。第一个晚上搭硬件第二个晚上写代码调逻辑第三个晚上优化显示效果和加扩展功能。踩过的坑主要集中在数码管消影和状态机同步这两个地方但解决之后回头看其实都是很基础的问题。我个人在实际操作中的体会是不要怕接线麻烦先把原理搞清楚再动手。数码管的12根线看起来吓人但理解了共阳结构和动态扫描原理之后接线就是按图索骥的事。代码方面millis()非阻塞定时是必须掌握的技能一旦你习惯了这种写法就再也不想用delay()了。最后再分享一个小技巧如果你觉得面包板接线太乱可以用不同颜色的跳线来区分功能——红色接电源、黑色接GND、黄色接LED、蓝色接数码管段选、绿色接数码管位选。这样排查问题的时候一眼就能看出哪根线是干什么的效率会高很多。这个项目后续还可以扩展成带倒计时显示的完整路口模拟甚至加上车流检测传感器做成自适应配时系统可玩性很高。

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询