简介:一份基于STM32F103C6单片机的交通红绿灯系统完整设计文档,面向单片机开发学习者、嵌入式课程设计及交通控制类项目实践者,重点解决路口红绿灯时序控制、倒计时显示与紧急车辆优先通行等典型问题。文档从方案选型讲起,系统介绍了主控制器选择、四位8段数码管倒计时电路、LED灯指示模块、电源管理与外部接口组成的总体电路图,并深入说明主程序流程、定时器中断配置等软件设计思路;同时提及YOLOv5目标检测算法在特殊车辆识别方面的扩展应用,具备完整的课程设计参考价值。资源为单个doc格式设计报告,压缩包体积6.32MB,图文并茂地包含设计背景、原理图说明、代码实例及调试要点,适合对照手册仿真或实物搭建学习。目前已有573人学习下载,可作为STM32实战入门、毕业设计或课程设计选题的重要参考资料。
1. 基于STM32的交通红绿灯系统:为什么说这是嵌入式入门最好的实战项目
很多刚开始接触STM32的人,第一反应是做流水灯或者点个OLED屏,但真正把一套完整的交通红绿灯系统跑起来,你会发现它几乎覆盖了嵌入式开发的所有基本功:GPIO控制、定时器中断、状态机设计、按键消抖,甚至还能往里面加倒计时数码管、行人按键、红外车辆检测这些传感器联动。这套系统并不复杂,但它的工程价值在于——逻辑上要严谨,时序上要准确,硬件上还要注意驱动能力和电平匹配。
我见过不少人在网上找现成的红绿灯代码,下载下来一烧录,发现灯全亮、乱跳或者按键没反应,最后归结为“硬件问题”。实际上,九成以上的问题出在状态机设计不严谨、中断与主循环共享变量没处理、以及LED限流电阻算错这三个地方。这篇文章不会去复刻某个现成项目,而是从最底层的设计思路开始,把一套可运行、可扩展的STM32红绿灯系统完整地讲清楚。新手可以按步骤在开发板上复现,熟手可以直接跳去避坑章节对号入座。
2. 整体方案框架:状态机是灵魂,选型和引脚规划先于代码
2.1 为什么选STM32做而不是纯逻辑电路或Arduino
交通红绿灯如果只用纯数字电路搭,用555定时器和计数器也能做,但时间精度差、改配时困难、扩展性几乎为零。用Arduino做,开发确实快,但如果你后续要接入实时操作系统、做多路口联网、或者需要多个高级定时器产生精确PWM,Arduino那套抽象层会有很多限制。STM32的优势在于:主频高(以常见的F103系列为例,72MHz)、定时器资源丰富、GPIO翻转速度快,而且可以从寄存器层面精确控制时序。
功耗和成本方面,STM32F103C8T6这类芯片单价已经压得很低,外加一个8MHz晶振和几个去耦电容就能跑起来,比用Arduino Nano还省。最重要的一点是,这套系统的核心是一个有限状态机——四个方向的红绿灯按固定顺序切换,这个模型放到任何嵌入式设备里都是通用思维。做一次红绿灯,你就掌握了状态迁移、超时触发、异常回退这三种嵌入式最常见的逻辑控制模式。
2.2 系统架构与状态迁移设计
一套标准的十字路口红绿灯,核心状态包括南北方向绿灯/东西方向红灯、南北方向黄灯/东西方向红灯、南北方向红灯/东西方向绿灯、南北方向红灯/东西方向黄灯。四个状态循环一遍,就是完整的一个信号周期。不要一上来就想着加左转箭头灯,先把基础的四相位跑通。
状态迁移需要定义两个核心参数:绿灯持续时间(常见25~60秒)和黄灯持续时间(常见3~5秒)。红灯时间是另一个方向的绿灯加黄灯时间之和。这里的状态机实现有两种常见做法:一种是在主循环里用HAL_Delay()阻塞延时,简单但没法实时响应按键和传感器;另一种是用定时器产生1ms或者1s的基础时基,在中断里更新一个计时变量,主循环检测计时变量是否达到阈值,达到就执行状态切换。我强烈推荐后者,因为后续你要加行人按键、加入口匝道检测时,阻塞延时会让整个系统变得极其被动。
状态机本身用枚举定义,状态切换用函数指针或者switch-case,在STM32这种资源下switch-case足够清晰。状态迁移表要放在代码最显眼的位置,每一行注释写清楚“进入该状态的条件”和“退出该状态的条件”。
2.3 引脚规划与GPIO分配策略
引脚分配是新手最容易忽视的步骤。很多人在面包板上飞线,一开始很随意,等到要加数码管倒计时时发现引脚被占满了,只能重写。我的习惯是先把外设列出来,再做分配。
基础版需要6个LED指示灯(南北红绿黄、东西红绿黄),扩展版再加2个数码管模块(各占4个引脚)和一个行人按键(1个引脚,需用外部中断或轮询)。GPIO分配要遵守几个原则:LED输出统一挂在一个GPIO端口的连续引脚上(比如PA0-PA5),方便后续用端口操作批量控制;按键输入优先分配到有EXTI能力的引脚,比如PA0就是EXTI0;如果用到I2C或SPI驱动的数码管,优先用硬件外设引脚,而不是软件模拟。
我的建议分配方案如下:
| 功能 | 引脚 | 配置模式 | 备注 |
|---|---|---|---|
| 南北绿灯 | PA0 | GPIO_MODE_OUTPUT_PP | 推挽输出,默认低 |
| 南北黄灯 | PA1 | GPIO_MODE_OUTPUT_PP | 同上 |
| 南北红灯 | PA2 | GPIO_MODE_OUTPUT_PP | 同上 |
| 东西绿灯 | PA3 | GPIO_MODE_OUTPUT_PP | 同上 |
| 东西黄灯 | PA4 | GPIO_MODE_OUTPUT_PP | 同上 |
| 东西红灯 | PA5 | GPIO_MODE_OUTPUT_PP | 同上 |
| 行人按键 | PB0 | GPIO_MODE_IT_RISING | 外部中断,内部上拉 |
PB0用内部上拉,按键另一端接GND,按下时产生上升沿。这个方案把PA0-PA5全部留空给LED,后续加数码管时可以用PB端口剩下的引脚或者PC端口,互不干扰。
3. 硬件搭建与驱动电路:LED限流电阻不是随便选的
3.1 最小系统板与外围电路设计
硬件部分我以最常见的STM32F103C8T6最小系统板为例。这块板子自带8MHz晶振、复位电路和USB转串口,供电用USB的5V经过板载AMS1117降到3.3V。LED不能直接挂在3.3V上,需要串联限流电阻。
LED的压降红色约1.8-2.0V,绿色和黄色约2.0-2.4V,工作电流取5-10mA就足够亮了,STM32的GPIO在推挽输出模式下能提供约20mA的驱动能力,但如果6个LED同时亮,总电流会超过芯片引脚的绝对最大额定值。因此千万不能把6个LED直接全部挂在GPIO上,要用灌电流方式——LED正极接3.3V,负极经过限流电阻接到GPIO,GPIO输出低电平时LED点亮。这样每个引脚的电流不超过8mA,整板功耗可控。
限流电阻的阻值按这个公式算:R = (VCC - V_LED) / I_LED。以红色LED和5mA工作电流为例:(3.3 - 1.9) / 0.005 = 280Ω,取标准值330Ω。绿色LED:(3.3 - 2.1) / 0.005 = 240Ω,取标准值270Ω。如果用的是共阳RGB灯或大功率LED,驱动电路要换成三极管或MOS管,GPIO只做开关控制。
3.2 数码管与按键扩展电路
如果要做倒计时显示,我建议用四位数码管模块(TM1650或TM1637驱动芯片),它们用I2C协议通信,只占2个引脚,不用自己动态扫描。TM1637是两线制(CLK和DIO),用GPIO模拟时序即可。接法上,模块的VCC接5V,GND接GND,CLK和DIO分别接两个空闲GPIO,注意模块板载上拉电阻,不需要额外添加上拉。
行人按键的电路非常关键,直接用一个按键接GPIO和GND,不开内部上拉的话,悬空时电平随机漂移,会产生无数个虚假中断。正确的做法是在STM32CubeMX里把GPIO配置为GPIO_PULL_UP,然后在GPIO_InitStruct里看到的Pull参数选择上拉。外部电路不需要再并联电阻,按下去时引脚被拉低,松开时恢复高电平。
3.3 电源完整性与去耦
用USB供电跑这套系统绰绰有余,但有一个坑:电机或继电器启动时会让电压瞬间跌落,STM32检测到低于复位阈值就会重启,红绿灯直接熄灭。如果系统里带了舵机或继电器,一定要在电源入口并联一个大电容,常见做法是100uF电解电容并联0.1uF陶瓷电容。C8T6芯片旁边的VDD引脚要放0.1uF去耦电容,离引脚尽量近。
4. 软件实现:从CubeMX初始化到状态机代码落地
4.1 CubeMX工程配置要点
STM32开发现在主流做法是用STM32CubeMX生成初始化代码再配合HAL库。打开CubeMX选芯片型号,按下面的方式配置:
RCC配置:HSE选择Crystal/Ceramic Resonator,系统时钟在Clock Configuration里把PLL倍频到72MHz。
GPIO配置:PA0-PA5全部设为Output,推挽模式,默认输出电平设为Low,速度设为Low(LED不需要高速翻转)。PB0设为External Interrupt Mode with Rising edge trigger,Pull-up开启,在NVIC选项卡里把EXTI0中断优先级设为2。
定时器配置:选用TIM2,Clock Source选Internal Clock,Prescaler设为71,Counter Period设为9999。APB1定时器时钟是36MHz,72MHz/72=1MHz,计数值10000就是10ms一个更新事件。使能TIM2全局中断,优先级设为1。
生成工程后,在main.c的main()里加一句HAL_TIM_Base_Start_IT(&htim2);启动定时器。
4.2 核心状态机代码实现
状态定义和全局变量放在main.h里,用一个结构体保存当前状态和剩余时间:
typedef enum { STATE_NS_GREEN_EW_RED, // 南北绿灯,东西红灯 STATE_NS_YELLOW_EW_RED, // 南北黄灯,东西红灯 STATE_NS_RED_EW_GREEN, // 南北红灯,东西绿灯 STATE_NS_RED_EW_YELLOW // 南北红灯,东西黄灯 } TrafficState; volatile TrafficState currentState = STATE_NS_GREEN_EW_RED; volatile uint32_t stateCounter = 0; // 当前状态已持续的10ms个数 volatile uint32_t nsGreenTime = 3000; // 南北绿灯:30秒,单位10ms volatile uint32_t nsYellowTime = 400; // 南北黄灯:4秒 volatile uint32_t ewGreenTime = 2500; // 东西绿灯:25秒 volatile uint32_t ewYellowTime = 400; // 东西黄灯:4秒定时间中断里做计时和状态切换:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { stateCounter++; switch (currentState) { case STATE_NS_GREEN_EW_RED: if (stateCounter >= nsGreenTime) { currentState = STATE_NS_YELLOW_EW_RED; stateCounter = 0; } break; case STATE_NS_YELLOW_EW_RED: if (stateCounter >= nsYellowTime) { currentState = STATE_NS_RED_EW_GREEN; stateCounter = 0; } break; case STATE_NS_RED_EW_GREEN: if (stateCounter >= ewGreenTime) { currentState = STATE_NS_RED_EW_YELLOW; stateCounter = 0; } break; case STATE_NS_RED_EW_YELLOW: if (stateCounter >= ewYellowTime) { currentState = STATE_NS_GREEN_EW_RED; stateCounter = 0; } break; } } }这段代码的逻辑本质是:stateCounter每秒加100次(10ms一次),当它累加到当前状态对应的时长阈值时,状态机迁移到下一个状态并清零计数器。为什么用10ms而不是1s作为基础时基?因为后续你要加数码管倒计时、按键响应、传感器检测,1s的粗粒度会让控制逻辑的实时性很差,而10ms足够流畅且不会给CPU带来明显负担。
nsGreenTime等四个参数全部用volatile修饰,因为它们会被中断回调函数修改,而主循环里可能也要读取(比如按键切换模式时改写时长)。不加volatile的后果是,编译器可能把变量优化进寄存器,导致中断里改的值在主循环里读不到——这是嵌入式里经典的魔鬼细节。
4.3 LED 输出控制函数
在中断里最好不要直接调用HAL库的GPIO写函数,因为中断里做太多事会影响其他中断的响应。常见做法是:中断只做状态迁移和计数,主循环里根据currentState刷新LED电平。
void TrafficLight_UpdateOutput(TrafficState state) { // 先全部熄灭,避免上一个状态的残影 HAL_GPIO_WritePin(LED_NS_GREEN_GPIO_PORT, LED_NS_GREEN_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(LED_NS_YELLOW_GPIO_PORT, LED_NS_YELLOW_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(LED_NS_RED_GPIO_PORT, LED_NS_RED_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(LED_EW_GREEN_GPIO_PORT, LED_EW_GREEN_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(LED_EW_YELLOW_GPIO_PORT, LED_EW_YELLOW_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(LED_EW_RED_GPIO_PORT, LED_EW_RED_PIN, GPIO_PIN_SET); // 根据状态点亮对应方向灯组 switch (state) { case STATE_NS_GREEN_EW_RED: HAL_GPIO_WritePin(LED_NS_GREEN_GPIO_PORT, LED_NS_GREEN_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(LED_EW_RED_GPIO_PORT, LED_EW_RED_PIN, GPIO_PIN_RESET); break; case STATE_NS_YELLOW_EW_RED: HAL_GPIO_WritePin(LED_NS_YELLOW_GPIO_PORT, LED_NS_YELLOW_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(LED_EW_RED_GPIO_PORT, LED_EW_RED_PIN, GPIO_PIN_RESET); break; case STATE_NS_RED_EW_GREEN: HAL_GPIO_WritePin(LED_NS_RED_GPIO_PORT, LED_NS_RED_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(LED_EW_GREEN_GPIO_PORT, LED_EW_GREEN_PIN, GPIO_PIN_RESET); break; case STATE_NS_RED_EW_YELLOW: HAL_GPIO_WritePin(LED_NS_RED_GPIO_PORT, LED_NS_RED_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(LED_EW_YELLOW_GPIO_PORT, LED_EW_YELLOW_PIN, GPIO_PIN_RESET); break; } }这里用GPIO_PIN_SET表示熄灭、GPIO_PIN_RESET表示点亮,原因前面说过,LED用的是灌电流接法,低电平有效。这个函数在main()的while(1)里循环调用,因为currentState是volatile的,主循环每次读到的最新值都是中断更新过的。
4.4 行人按键处理与模式切换
行人按键的终极诉求是:行人按下后,当前绿灯方向尽快结束,进入黄灯,然后切换到行人放行方向。实现方式是定义一个按键请求标志位,在外部中断回调函数里置位,在主循环中检测并响应该标志位。
volatile uint8_t pedRequestFlag = 0; void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == PED_BUTTON_PIN) { pedRequestFlag = 1; } }在主循环里:
while (1) { if (pedRequestFlag) { pedRequestFlag = 0; // 当前若处于绿灯或黄灯状态,直接跳到黄灯过渡状态 if (currentState == STATE_NS_GREEN_EW_RED || currentState == STATE_EW_GREEN_NS_RED) { currentState = STATE_NS_YELLOW_EW_RED; stateCounter = 0; } } TrafficLight_UpdateOutput(currentState); }按键消抖的问题在这里不需要做软件延时消抖,因为STM32的外部中断自带边沿检测,而且按键按下持续的时间远大于10ms,不会产生重复跳变。如果你的按键抖动非常严重,可以加一个计数器方案:连续检测到3次低电平才确认为有效按下。但注意不要在中断回调里用HAL_Delay()——中断里做延时阻塞会让系统彻底卡死。
5. 避坑指南:交通灯系统的5个典型故障与排查方法
5.1 现象:LED亮度异常,有的很暗有的完全不亮
原因:限流电阻取值过大或LED压降参数用错了。很多人随手拿1kΩ电阻,电流只有1.4mA,LED微亮几乎看不清。另外一个常见问题是,用共阴极LED但代码里用了共阳极的逻辑,导致灯永远不亮。
解决:先查原理图确认LED接法,再用万用表电流档串在回路里实测。红色LED配330Ω,黄绿配270Ω。如果亮度还是不够,可以降低电阻到220Ω,保证电流在8-10mA。用万用表测LED两端压降,对照数据手册做修正。
5.2 现象:状态跳变顺序错乱,绿灯直接切红灯,没有黄灯过渡
原因:状态机迁移条件写错了,或者stateCounter在进入新状态时没有清零。比如从南北绿灯切到南北黄灯时,如果没有执行stateCounter = 0,黄灯阶段会立即判定超时然后跳到下一个状态,视觉上就是绿灯一闪而过。
解决:检查每个状态分支里是否都在迁移前重置计数器。一个更稳妥的做法是在状态迁移函数里统一清理:新建一个TrafficState_SetNextState(state)函数,里面统一做currentState = next; stateCounter = 0;,这样不会漏。
5.3 现象:烧录程序后完全不工作,调试器连不上
原因:最常见的是BOOT引脚跳线帽没有接对。C8T6最小系统板上有BOOT0和BOOT1,BOOT0拉低才从Flash启动,如果BOOT0被接高,芯片会进入系统存储器引导模式,用户程序完全不执行。
解决:把BOOT0跳线帽接到0(低电平),BOOT1随意。如果在调试器下无法连接,先按住复位键再点下载,松开的瞬间芯片会重新进入调试模式。这个玄学操作在F103上非常常见。
5.4 现象:行人按键按下没反应,但用万用表量引脚电平变化正常
原因:外部中断没有在NVIC里使能,或者GPIO没有配置成中断模式。CubeMX里只是在GPIO选项卡选了外部中断,但忘记在NVIC选项卡打勾,中断回调永远不会被调用。
解决:检查CubeMX的NVIC Settings里是否勾选了EXTI0_IRQn。另外,HAL库的中断回调函数名必须严格是HAL_GPIO_EXTI_Callback,写错名字编译器不会报错,但回调不会被执行。建议在回调函数里加一个计数器变量,用调试器观察是否被触发。
5.5 现象:数码管倒计时与实际信号灯时间不对应
原因:倒计时的时间基准和灯状态的时间基准不同步。数码管用的可能是毫秒级HAL_Delay(),而灯用的是10ms计数,两边累计误差越来越大。
解决:数码管倒计时的数字直接从stateCounter换算。比如南北绿灯30秒,剩余秒数就是(nsGreenTime - stateCounter) / 100。用一个变量先算出还剩多少秒,再送给显示函数刷新。这样无论运行多久,显示都和实际状态严格同步。
6. 从红绿灯到感应式交通控制:一个能落地的进阶方向
基础版跑通之后,下一步的价值点在于让红绿灯具备“按需分配”的能力。很多城市的支路交叉路口,车流量有明显波峰波谷,固定配时的红绿灯在夜间低谷期会让主干道车辆干等红灯。解决办法是在系统里加入车辆检测——常见方案是红外对射传感器或者地磁感应线圈(模拟场景下用E18-D80NK红外避障传感器即可)。
硬件改动很简单:在支路方向的停止线前装一个传感器,当检测到有车辆等待时,才允许状态机在主干道绿灯末尾触发切换;若没有车辆,主干道绿灯可以延长5秒或10秒。这本质上是给状态机增加了一个“条件迁移路径”。代码实现上,在中断里已经知道stateCounter的值,主循环里判断:当currentState == STATE_NS_GREEN_EW_RED且stateCounter > nsGreenTime + 500(表示绿灯已超时5秒)且传感器检测支路有车,则立即切到黄灯。
另一个值得做的增强是故障报警。用STM32的看门狗定时器监控主循环运行状态,如果程序跑飞,看门狗超时后自动复位整个系统。再配合一个无源蜂鸣器,当看门狗复位后初始化时鸣叫一声,维护人员就知道发生过异常。我一般会在初始化代码里用一个标志位存储复位原因(从RCC的RCC_GetFlagStatus读取),区分是上电复位还是看门狗复位,这样远程排查时能直接判断程序是否发生过死循环。
我踩过最大的坑是:在中断回调里直接调用了printf()重定向到串口,结果调试时一切正常,拔掉串口线后系统死机。原因是在中断里阻塞等待串口发送完成,而USB转串口芯片还没准备好,足足等了半秒。从那以后,我的习惯是中断里只改状态、设标志位,所有耗时操作和输出全放到主循环,再简单的功能也不例外。这套交通灯项目也一样——你的代码越克制,系统越稳定。希望帮到你。
本文还有配套的精品资源,点击获取