
简介这是一套面向嵌入式初学者与电子类专业学生的STM32交通灯控制系统完整实践资料围绕ARM Cortex-M内核微控制器展开解决交叉路口信号灯智能化控制这一典型实时应用问题。压缩包共191个文件约9.28MB以C源码、头文件、编译中间文件与工程配置为主另含PPT演示文稿、Word报告及hex烧录文件覆盖从底层驱动到系统演示的完整链路。代码部分包含时钟与GPIO初始化、定时器中断切换灯态、车流量传感器数据处理、控制逻辑与LED显示驱动等模块PPT讲解系统框图、硬件连接、软件流程与控制算法报告则总结项目目标、实施步骤、挑战与解决方案。目前已有7027人学习下载适合希望理解微控制器在真实工程中如何落地、提升硬件设计与软件开发能力的读者参考借鉴。1. 基于STM32的交通灯控制系统从状态机到路口联动的完整落地路径很多人第一次接触嵌入式项目都是从交通灯开始的。看起来简单——红黄绿三个灯轮流亮但真正把它做成一个能演示、能扩展、能拿去答辩或面试讲清楚的项目中间要跨过的坎比想象中多。基于STM32的交通灯控制系统核心不是点灯而是用有限状态机管理灯态切换、用定时器保证时序精度、用中断处理紧急事件最后把单路口逻辑扩展成多路口联动。这套东西适合两类人一类是刚学完GPIO和定时器、想找个完整项目练手的嵌入式新手另一类是需要一个可演示、可扩展、能讲清楚设计思路的课程设计或面试项目的开发者。下面我按实际做项目的顺序把选型、状态机设计、代码实现、调试排错和进阶扩展一步步拆开讲。2. 硬件选型与状态机设计为什么不用延时函数点灯2.1 主控与外围器件的选型逻辑做交通灯控制系统主控选STM32F103C8T6是最常见的做法原因很直接Cortex-M3内核、72MHz主频、足够多的GPIO和定时器资源关键是资料多、价格低、最小系统板随手就能买到。如果你手头有STM32F407或者G0系列也完全能用代码逻辑基本一致只是时钟配置和寄存器名有差异。灯组方面用LED模拟红黄绿三色灯即可每个方向一组。如果是双方向十字路口就是两组灯共6个LED。实际接线时注意限流电阻一般220Ω到1kΩ之间取决于LED的额定电流。按键用来模拟紧急事件比如紧急车辆通行请求。如果你要做倒计时显示加一个四位共阳或共阴数码管用动态扫描驱动。这里有一个选型上的取舍用数码管还是OLED屏。数码管便宜、驱动简单、实时性好但只能显示数字OLED屏能显示倒计时和状态文字但驱动复杂一些刷新频率要注意。我一般建议新手先用数码管把核心逻辑跑通再换OLED做界面优化。2.2 有限状态机交通灯的逻辑骨架交通灯的本质是一个有限状态机。每个方向有四个状态红灯、红灯转绿灯的过渡红黄同时亮或全红清空、绿灯、黄灯。两个方向的状态互斥一个方向绿灯时另一个方向必须红灯。状态转移的条件是定时器计时到达。比如东西方向绿灯30秒时间到后转黄灯3秒黄灯结束后转红灯同时南北方向从红灯转绿灯。这个逻辑用switch-case或者状态表都能实现但状态表更清晰扩展性更好。下面是一个双方向交通灯的状态定义和转移表当前状态持续时间下一状态灯态东西/南北S030sS1绿/红S13sS2黄/红S22sS3红/红全红清空S330sS4红/绿S43sS5红/黄S52sS0红/红全红清空全红清空阶段是很多人会忽略的细节。它的作用是确保路口内残留车辆有时间通过实际路口信号配时中这是标准做法。加上这2秒整个周期是70秒。2.3 定时器配置用TIM而不是HAL_Delay新手最容易犯的错误是用HAL_Delay()做延时来切换灯态。这样做的问题很明显延时期间CPU被阻塞按键响应不了倒计时也更新不了整个系统就是个死循环。正确做法是用一个硬件定时器产生固定周期中断比如1ms中断一次在中断里维护一个毫秒计数器。主循环里根据计数器值判断是否到达状态切换时间。这样主循环可以同时处理按键扫描、数码管刷新和状态判断。以STM32F103的TIM2为例配置成1ms中断// TIM2 初始化72MHz主频预分频72-1自动重装载1000-1 // 中断频率 72MHz / 72 / 1000 1kHz即1ms一次中断 void TIM2_Init(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_TimeBaseStructure.TIM_Period 1000 - 1; // 自动重装载值 TIM_TimeBaseStructure.TIM_Prescaler 72 - 1; // 预分频值 TIM_TimeBaseStructure.TIM_ClockDivision 0; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); // 使能更新中断 NVIC_InitStructure.NVIC_IRQChannel TIM2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 0; NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); TIM_Cmd(TIM2, ENABLE); } // 中断服务函数每1ms进入一次 volatile uint32_t g_msTick 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { g_msTick; // 全局毫秒计数器 TIM_ClearITPendingBit(TIM2, TIM_IT_Update); } }预分频值72-1和重装载值1000-1的乘积除以72MHz就是中断周期。这里72MHz是STM32F103的标准主频如果你用的是其他型号先确认系统时钟频率再算这两个参数。中断优先级配置里抢占优先级设为0确保定时器中断不会被其他中断打断否则计时会累积误差。3. 代码实现状态机、倒计时与按键响应的完整代码3.1 状态机主循环的实现有了1ms的全局计数器状态机就可以在主循环里非阻塞地运行。核心思路是记录当前状态和进入该状态时的毫秒时间戳每次循环计算已经过去了多少毫秒和该状态的持续时间比较。// 交通灯状态枚举 typedef enum { STATE_EW_GREEN 0, // 东西绿南北红 STATE_EW_YELLOW, // 东西黄南北红 STATE_ALL_RED_1, // 全红清空 STATE_NS_GREEN, // 南北绿东西红 STATE_NS_YELLOW, // 南北黄东西红 STATE_ALL_RED_2 // 全红清空 } TrafficState_t; // 每个状态的持续时间毫秒 const uint32_t g_stateDuration[] { 30000, // 东西绿 30s 3000, // 东西黄 3s 2000, // 全红 2s 30000, // 南北绿 30s 3000, // 南北黄 3s 2000 // 全红 2s }; static TrafficState_t g_currentState STATE_EW_GREEN; static uint32_t g_stateStartTick 0; // 设置灯态根据当前状态点亮对应的LED void SetLights(TrafficState_t state) { // 先全部熄灭 GPIO_ResetBits(GPIOA, LED_EW_RED | LED_EW_YELLOW | LED_EW_GREEN | LED_NS_RED | LED_NS_YELLOW | LED_NS_GREEN); switch (state) { case STATE_EW_GREEN: GPIO_SetBits(GPIOA, LED_EW_GREEN | LED_NS_RED); break; case STATE_EW_YELLOW: GPIO_SetBits(GPIOA, LED_EW_YELLOW | LED_NS_RED); break; case STATE_ALL_RED_1: case STATE_ALL_RED_2: GPIO_SetBits(GPIOA, LED_EW_RED | LED_NS_RED); break; case STATE_NS_GREEN: GPIO_SetBits(GPIOA, LED_NS_GREEN | LED_EW_RED); break; case STATE_NS_YELLOW: GPIO_SetBits(GPIOA, LED_NS_YELLOW | LED_EW_RED); break; } } // 状态机更新函数在主循环中调用 void TrafficLight_Update(void) { uint32_t elapsed g_msTick - g_stateStartTick; if (elapsed g_stateDuration[g_currentState]) { // 切换到下一个状态 g_currentState (TrafficState_t)((g_currentState 1) % 6); g_stateStartTick g_msTick; SetLights(g_currentState); } }这里用g_msTick减去g_stateStartTick来计算经过时间而不是用g_stateStartTick加上持续时间再和g_msTick比较。这样写的好处是即使g_msTick溢出大约49天溢出一次减法结果仍然正确因为无符号整数的减法在溢出时自动回绕。这是嵌入式里处理时间戳的标准做法血泪经验用加法比较在长时间运行后一定会翻车。3.2 倒计时的计算与数码管动态刷新倒计时显示的逻辑是从当前状态的持续时间减去已经过去的时间得到剩余秒数。注意要向上取整否则最后一秒会显示0但灯还没切换。// 获取当前状态剩余秒数 uint8_t GetRemainSeconds(void) { uint32_t elapsed g_msTick - g_stateStartTick; uint32_t remainMs g_stateDuration[g_currentState] - elapsed; // 向上取整避免显示0但灯未切换 return (uint8_t)((remainMs 999) / 1000); }数码管动态刷新需要每2到5毫秒切换一位刷新频率太低会闪烁太高则亮度不足。我一般放在定时器中断里做或者主循环里用g_msTick判断间隔。下面是一个共阴数码管的段码表和刷新函数// 共阴数码管段码表0-9 const uint8_t g_segCode[] {0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F}; // 数码管刷新每2ms调用一次 void Display_Refresh(void) { static uint8_t digitIndex 0; uint8_t remain GetRemainSeconds(); uint8_t value; // 根据当前位取对应的数字 switch (digitIndex) { case 0: value remain / 10; break; // 十位 case 1: value remain % 10; break; // 个位 default: value 0; break; } // 消隐先关闭所有位选 GPIO_SetBits(GPIOB, DIGIT_SEL_ALL); // 输出段码 GPIO_Write(GPIOB, (GPIO_ReadOutputData(GPIOB) 0xFF00) | g_segCode[value]); // 打开当前位选 GPIO_ResetBits(GPIOB, (uint16_t)(1 digitIndex)); digitIndex (digitIndex 1) % 2; // 两位数码管 }段码表的值取决于你的数码管是共阴还是共阳以及段码线接到哪个GPIO口。如果显示乱码先检查段码表对不对再检查位选和段选的接线顺序。这个坑我踩过不止一次排查半天发现是段码表用错了。3.3 按键中断与紧急模式处理按键用来模拟紧急车辆通行请求。按下后当前方向如果已经是绿灯就延长绿灯时间如果是红灯就尽快切换到绿灯。实现方式可以用外部中断也可以在主循环里轮询。外部中断响应更快但要注意消抖。// 紧急按键外部中断PA0下降沿触发 void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) ! RESET) { // 简单消抖间隔小于200ms的触发忽略 static uint32_t lastPressTick 0; if (g_msTick - lastPressTick 200) { g_emergencyFlag 1; // 设置紧急标志 lastPressTick g_msTick; } EXTI_ClearITPendingBit(EXTI_Line0); } }紧急标志在主循环里处理。如果当前是东西绿灯就把东西绿灯的持续时间临时改成60秒如果当前是南北绿灯就等南北绿灯结束后立刻切到东西绿灯跳过南北的完整绿灯时间。具体策略可以根据你的演示需求调整关键是不要在中断里做复杂逻辑中断里只设标志主循环里处理。4. 避坑与排查交通灯项目里最容易翻车的五个地方4.1 灯态切换时出现瞬间全亮或全灭现象状态切换的瞬间所有LED闪一下或者出现不该亮的灯短暂亮起。原因在SetLights函数里先熄灭所有灯再点亮目标灯这两步之间有一个短暂的空档。如果主循环里还有其他地方也在操作GPIO就可能出现竞争。解决把SetLights做成原子操作先计算出目标灯态的值一次性写入GPIO的ODR寄存器。或者用BSRR寄存器分别设置和清除避免读-改-写的过程被打断。4.2 倒计时显示跳秒或卡顿现象数码管显示的倒计时偶尔跳过一个数字或者刷新明显卡顿。原因数码管刷新和状态机更新在同一个循环里如果状态机更新耗时较长比如里面有浮点运算或复杂判断就会挤占刷新时间。解决把数码管刷新放到定时器中断里保证每2ms准时执行。状态机更新留在主循环两者互不干扰。注意中断里的代码要尽量短不要在中断里做除法或浮点运算。4.3 按键按下后状态机不响应现象按键按下去没反应或者要按好几次才触发一次。原因可能是外部中断的触发边沿配置错了也可能是消抖逻辑太严格。还有一种情况是按键的GPIO时钟没使能或者中断优先级配置被其他中断屏蔽了。解决先用万用表或示波器确认按键按下时GPIO电平确实变化。然后检查EXTI的触发边沿和NVIC的优先级配置。消抖时间一般设20ms到50ms就够了设200ms太长了快速连按会被忽略。4.4 长时间运行后定时器计时不准现象运行几个小时后倒计时和实际时间偏差越来越大。原因如果用的是SysTick做延时中断优先级配置不当会被其他中断打断导致计时累积误差。另外如果中断服务函数里执行时间过长也会影响计时精度。解决用硬件定时器做全局毫秒计数中断优先级设为最高。中断服务函数里只做计数器自增和清中断标志其他逻辑全部放到主循环。这样计时误差只取决于晶振精度一般可以忽略。4.5 多路口联动时状态不同步现象两个路口的灯态切换时间对不上一个已经变红灯了另一个还是绿灯。原因两个路口各自维护独立的状态机没有统一的时钟基准。如果两个STM32之间没有同步信号时间一长必然漂移。解决常见做法是选一个主控作为主节点通过CAN总线或UART发送同步帧从节点收到同步帧后校准自己的状态。或者更简单的方式用一个主控同时控制两个路口的灯逻辑上分成两个独立的状态机实例但共用同一个毫秒计数器。5. 进阶扩展从单路口到多路口联动的实现思路单路口的交通灯跑通之后下一步自然是多路口联动。两个相邻路口需要协调绿灯时间避免一个路口放行的车流刚到下一个路口就遇到红灯。这个问题的核心是相位差控制。假设两个路口相距500米车辆平均速度40km/h那么车辆从一个路口到下一个路口大约需要45秒。如果两个路口的周期都是70秒那么第二个路口的绿灯相位应该比第一个路口滞后45秒这样车流到达时正好是绿灯。实现方式上如果两个路口用同一个主控直接在状态机里加一个相位偏移量就行。如果是两个独立主控就需要通信同步。下面是一个用UART做同步的简单方案// 主节点每周期发送一次同步帧包含当前状态和剩余时间 typedef struct { uint8_t header; // 帧头 0xAA uint8_t state; // 当前状态 uint16_t remainMs; // 当前状态剩余毫秒 uint8_t checksum; // 校验和 } SyncFrame_t; void Master_SendSync(void) { SyncFrame_t frame; frame.header 0xAA; frame.state (uint8_t)g_currentState; frame.remainMs (uint16_t)(g_stateDuration[g_currentState] - (g_msTick - g_stateStartTick)); frame.checksum frame.header ^ frame.state ^ (frame.remainMs 0xFF) ^ (frame.remainMs 8); UART_SendBytes((uint8_t *)frame, sizeof(frame)); } // 从节点收到同步帧后校准自己的状态 void Slave_OnSyncReceived(SyncFrame_t *frame) { if (frame-header ! 0xAA) return; // 校验 uint8_t calc frame-header ^ frame-state ^ (frame-remainMs 0xFF) ^ (frame-remainMs 8); if (calc ! frame-checksum) return; // 校准状态和计时 g_currentState (TrafficState_t)frame-state; g_stateStartTick g_msTick - (g_stateDuration[g_currentState] - frame-remainMs); SetLights(g_currentState); }这个同步方案的关键点是从节点收到同步帧后不是简单地把自己的计时清零而是根据主节点发来的剩余时间反推自己的g_stateStartTick。这样即使从节点之前有累积误差一次同步就能拉回来。校验和用来防止通信误码导致状态错乱实际项目中还可以加超时重同步机制如果超过两个周期没收到同步帧从节点就进入本地自主运行模式避免通信中断导致路口瘫痪。另一个值得做的扩展是加入车流量检测。用红外对管或者地磁传感器统计每个方向的等待车辆数动态调整绿灯时长。车多的方向多给绿灯车少的方向少给。这个逻辑用状态机实现也不复杂就是在状态切换时根据车流量重新计算g_stateDuration数组的值。不过要注意设置上下限绿灯最短不少于10秒最长不超过60秒否则会出现某个方向一直绿灯的情况。调试多路口联动时我习惯先用两个串口打印各自的g_msTick和状态对比时间戳看漂移量。如果漂移在100ms以内基本可以接受超过500ms就要检查同步帧的发送周期和UART的波特率是否匹配。还有一个容易忽略的点两个主控的晶振精度可能不同长时间运行后即使有同步也会缓慢漂移所以同步帧要周期性发送不能只发一次。最后说一个我自己的习惯每次改完状态机逻辑先用一个LED闪烁来指示当前状态编号闪1次是状态0闪2次是状态1以此类推。这样不用接数码管就能快速确认状态切换是否正确。等逻辑确认无误了再接数码管和按键做完整演示。这个习惯帮我省了很多来回插拔线的时间希望帮到你。本文还有配套的精品资源点击获取