1. 项目缘起与整体设计思路
1.1 为什么选择这个项目作为入门练手
交通灯模拟几乎是每个接触单片机的人都会碰的一个经典项目,但很多人做完之后只是让几个LED轮流亮一下,就草草收场了。我当初也是这么干的,后来发现这个项目其实可以挖得很深——加上数码管读秒之后,它一下子从“点亮LED”升级成了“状态机+定时器+动态扫描显示”的综合练习,涉及的知识面覆盖了数字输出、定时中断、数码管驱动、状态切换逻辑这几个核心模块。
说白了,这个项目的本质是:用一块Arduino板子,模拟一个十字路口的红绿灯工作流程,同时用数码管把每个灯色的剩余秒数实时显示出来。听起来简单,但真正动手做的时候,你会发现几个绕不开的问题——数码管怎么驱动才不会闪烁?倒计时怎么和灯光状态精确同步?两个方向的红绿灯怎么互锁?这些问题解决了,你对Arduino的理解会上一个台阶。
适合谁来参考这篇内容?如果你已经能点亮一个LED、知道digitalWrite()和delay()怎么用,那就可以直接上手。如果你连面包板都没插过,也没关系,我会把接线和代码都讲清楚,照着做就行。但有一点要说在前面:这个项目涉及的知识点比较多,建议至少留出两个晚上的时间,第一晚搭硬件调通基本逻辑,第二晚优化显示效果和代码结构。
1.2 整体方案选型与核心考量
做交通灯模拟,方案其实有好几种。最简单的就是纯delay()轮询,几个LED加电阻,代码不到二十行就能跑起来。但一旦加上数码管读秒,delay()方案的致命缺陷就暴露了——数码管需要不断刷新才能保持显示,如果你用delay(1000)来计时,这一秒内数码管是灭的,你会看到数字一闪一闪,体验极差。
所以我的方案是:用millis()做非阻塞定时,主循环里同时处理倒计时逻辑和数码管动态扫描。这样数码管每2-3毫秒刷新一位,人眼看起来就是稳定常亮的,同时倒计时也能精确到秒。
硬件选型方面,我列了一个对比表,方便你根据自己的手头材料来选:
| 方案 | 数码管类型 | 驱动方式 | 占用引脚数 | 优缺点 |
|---|---|---|---|---|
| 方案A | 4位共阳数码管 | 直接IO驱动 | 12根 | 接线多但无需额外芯片,适合学习 |
| 方案B | 4位共阳数码管 | 74HC595移位寄存器 | 5根 | 接线简洁,但需要理解移位寄存器原理 |
| 方案C | TM1637模块 | 专用驱动芯片 | 2根 | 最省引脚,但封装了底层细节,学习价值略低 |
我最终选了方案A,原因很简单:直接IO驱动能让你彻底搞明白数码管的段选和位选是怎么回事,后面再用74HC595或者TM1637就是降维打击。如果你手头只有TM1637模块,也可以用,但建议至少先了解一下共阳数码管的内部结构。
LED部分没什么好纠结的,红黄绿三色LED各两个(一个方向一组),限流电阻用220Ω到1KΩ都可以,我用的470Ω,亮度适中不刺眼。面包板供电直接用Arduino的5V,整个系统电流不大,不需要外接电源。
2. 核心细节解析与实操要点
2.1 数码管的工作原理与引脚分布
先说说数码管这个东西。4位共阳数码管,顾名思义,就是四个数字位共用一组阳极(正极),每个数字位的阴极(负极)是独立的。内部结构可以这样理解:把四个数字的相同段(比如所有数字的a段)的阳极连在一起,然后每个数字的阴极单独引出来。
具体到引脚,常见的4位共阳数码管(比如3641AS)有12个引脚,上下各6个。你拿到手之后,先用万用表二极管档测一下,或者直接查型号对应的数据手册。我手头这个的引脚定义是这样的:
- 引脚1:e段
- 引脚2:d段
- 引脚3:共阳(COM1,对应第一位)
- 引脚4:c段
- 引脚5:dp(小数点)
- 引脚6:b段
- 引脚7:a段
- 引脚8:共阳(COM2)
- 引脚9:f段
- 引脚10:g段
- 引脚11:共阳(COM3)
- 引脚12:共阳(COM4)
注意,不同型号的引脚顺序可能完全不同,一定要先确认清楚再接线。接错了不会烧,但显示会乱,排查起来很烦。
段码表是必须提前算好的。共阳数码管的特点是:给段引脚低电平(0)时该段点亮,给高电平(1)时熄灭。所以数字0的段码是a~f亮,g和dp灭,二进制是11000000,十六进制就是0xC0。我直接把0-9的段码整理成表:
| 数字 | 段码(十六进制) | 二进制 |
|---|---|---|
| 0 | 0xC0 | 11000000 |
| 1 | 0xF9 | 11111001 |
| 2 | 0xA4 | 10100100 |
| 3 | 0xB0 | 10110000 |
| 4 | 0x99 | 10011001 |
| 5 | 0x92 | 10010010 |
| 6 | 0x82 | 10000010 |
| 7 | 0xF8 | 11111000 |
| 8 | 0x80 | 10000000 |
| 9 | 0x90 | 10010000 |
这个表建议直接存在代码的数组里,用的时候查表就行。
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 | 输出 |
| 数码管dp | A1 | 输出 |
| 数码管COM1 | A2 | 输出 |
| 数码管COM2 | A3 | 输出 |
| 数码管COM3 | A4 | 输出 |
| 数码管COM4 | A5 | 输出 |
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引脚驱动能力不足,或者限流电阻偏大 | 换小一点的电阻,或者用三极管驱动COM |
4.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,另一端接GND,D2配置为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、蓝色接数码管段选、绿色接数码管位选。这样排查问题的时候一眼就能看出哪根线是干什么的,效率会高很多。这个项目后续还可以扩展成带倒计时显示的完整路口模拟,甚至加上车流检测传感器,做成自适应配时系统,可玩性很高。