
简介这是一套面向嵌入式初学者与电子类专业学生的STM32交通灯控制系统完整实践资料围绕城市交叉路口信号灯的智能化控制展开涵盖从硬件搭建到软件实现的完整链路。压缩包共191个文件约9.28MB以C源码、头文件、工程配置与编译中间文件为主另含PPT演示文稿、Word报告及hex烧录文件便于直接编译验证与学习参考。代码部分包含时钟与GPIO初始化、定时器中断切换灯态、车流量传感器处理、控制逻辑与LED显示驱动等模块PPT讲解系统框图、硬件电路、软件流程与控制算法报告则总结项目目标、实施步骤、挑战与解决方案。目前已有7027人学习下载适合希望理解微控制器在实时控制场景中应用、提升硬件设计与软件开发能力的读者参考借鉴。1. 基于 STM32 的交通灯控制系统从实验室 Demo 到路口逻辑的最后一公里很多人第一次接触嵌入式状态机都是从基于 STM32 的交通灯控制系统开始的。它看起来简单——红黄绿三个灯轮流亮但真正把它做成一个能稳定跑在路口、能处理行人按键、能应对紧急车辆优先、还能在掉电后恢复相位的系统中间隔着的不是几行HAL_GPIO_WritePin而是一整套关于定时器精度、状态迁移安全、中断优先级和故障降级的工程思维。我见过太多同学用HAL_Delay写出来的版本在仿真里跑得挺好一接上真实负载就出现相位错乱甚至两个方向同时亮绿灯。这篇文章面向的是已经能点亮 LED、但想把交通灯做成一个可演示、可扩展、可复现项目的嵌入式学习者。我会把选型理由、状态机设计、定时器配置、按键消抖、紧急模式降级这些环节拆开讲参数怎么算、坑在哪、怎么验证都落到具体代码和表格里。读完你至少能拿到一个能稳定运行、逻辑自洽、方便向别人展示的完整方案。2. 硬件选型与状态机建模为什么不用 HAL_Delay 搭交通灯2.1 主控与驱动选型STM32F103C8T6 加 ULN2003 的经典组合做交通灯演示主控选型不需要追求高性能但一定要有足够多的定时器和可靠的 GPIO 驱动能力。我一般会选 STM32F103C8T6也就是常说的蓝板或最小系统板原因有三个第一它有三个通用定时器 TIM2、TIM3、TIM4可以一个做系统时基、一个做相位计时、一个做按键扫描或蜂鸣器提示互不抢占第二GPIO 数量足够驱动两个方向的红黄绿共六路输出外加行人按键和紧急按键第三资料多、工具链成熟用 STM32CubeMX 生成初始化代码能省掉大量查手册的时间。驱动部分要注意STM32 的 GPIO 单脚输出电流一般不超过 20mA而一个普通 5mm LED 的工作电流在 10mA 左右如果直接驱动六路 LED总电流可能接近 60mA虽然分摊到不同引脚但为了留余量、也为了以后换成大功率灯珠或继电器我会在 GPIO 和 LED 之间加一级 ULN2003 达林顿管阵列。ULN2003 每路能承受 500mA输入兼容 3.3V 逻辑输出拉低有效正好匹配 STM32 的推挽输出。接线逻辑是STM32 引脚接 ULN2003 输入端ULN2003 输出端接 LED 负极LED 正极通过限流电阻接 5V。这样 STM32 输出高电平时 ULN2003 导通LED 亮。限流电阻怎么算假设 LED 正向压降 2V目标电流 10mA供电 5V那么电阻 R (5V - 2V) / 10mA 300Ω取标准值 330Ω 即可。这个参数不是随便定的电阻太小会烧 LED 或拉低 ULN2003 寿命太大则亮度不足在演示现场看不清。2.2 用有限状态机替代延时四个相位与迁移条件交通灯的本质是一个有限状态机状态数量固定迁移条件明确。最常见的十字路口两方向控制可以抽象成四个稳定相位相位编号方向 A方向 B持续时间迁移条件S0绿灯红灯8 秒计时到且无紧急请求S1黄灯红灯2 秒计时到S2红灯绿灯8 秒计时到且无紧急请求S3红灯黄灯2 秒计时到如果加入行人过街请求可以在 S0 和 S2 的绿灯末尾插入一个行人绿灯相位但为了不把逻辑搞复杂我建议第一版先把行人按键做成“请求延长当前方向绿灯”或“在下一个周期插入行人相位”而不是随时打断。紧急模式则是一个更高优先级的状态一旦触发无论当前在哪个相位都强制进入“全红”状态持续若干秒后回到 S0 重新开始。为什么不能用HAL_Delay因为HAL_Delay是忙等待它会阻塞整个主循环。一旦你在延时期间按下按键CPU 根本来不及响应紧急请求会被漏掉。更严重的是如果以后要加串口输出、数码管倒计时、或者无线模块HAL_Delay会让这些任务全部卡死。正确做法是用定时器中断产生固定时基比如 1ms 一次然后在主循环里根据时基计数来驱动状态迁移。这样按键扫描、倒计时刷新、状态判断都能并行推进。2.3 定时器时基配置1ms 心跳与相位计时分离我一般会把 TIM2 配置成 1ms 中断作为整个系统的心跳。STM32F103C8T6 的主频通常是 72MHzTIM2 挂在 APB1 总线上时钟频率也是 72MHz。要得到 1ms 中断可以设置预分频器 PSC 71自动重装载寄存器 ARR 999。计算过程是定时器时钟 72MHz / (PSC1) 72MHz / 72 1MHz也就是每计数一次 1 微秒计数到 ARR1 1000 次就是 1ms。这个参数在 CubeMX 里可以直接填也可以手动写寄存器。相位计时则不需要额外定时器直接用一个全局变量phase_tick在 1ms 中断里自增主循环里判断它是否达到当前相位所需毫秒数。比如绿灯 8 秒就是 8000 个 tick。这样做的好处是修改相位时间只需要改一个宏定义不用重新配置硬件。同时紧急按键可以在中断里直接置位一个标志主循环检测到标志后立即切换状态响应时间在毫秒级。// tim.c 中的中断回调1ms 触发一次 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { g_sys_tick; // 系统总 tick用于按键消抖和超时 g_phase_tick; // 当前相位已持续 tick if (g_key_scan_tick 20) { g_key_scan_tick; // 20ms 扫描一次按键 } } }这段代码里g_sys_tick和g_phase_tick都是volatile uint32_t因为它们在中断里被修改、在主循环里被读取不加volatile编译器可能优化掉读取动作导致主循环永远看不到变化。g_key_scan_tick用来控制按键扫描频率20ms 一次足够覆盖机械按键抖动又不会太占 CPU。3. 从零搭建CubeMX 配置、状态机代码与按键消抖3.1 CubeMX 关键配置项与引脚分配表打开 CubeMX 后先选芯片型号 STM32F103C8T6然后按下面这张表配置引脚。注意我用的引脚编号是常见最小系统板引出排针上的编号实际接线时以你手头板子的丝印为准。功能引脚模式备注方向 A 红灯PA0GPIO_Output推挽低电平灭方向 A 黄灯PA1GPIO_Output推挽方向 A 绿灯PA2GPIO_Output推挽方向 B 红灯PA3GPIO_Output推挽方向 B 黄灯PA4GPIO_Output推挽方向 B 绿灯PA5GPIO_Output推挽行人按键PB0GPIO_Input上拉按下为低紧急按键PB1GPIO_Input上拉按下为低调试串口 TXPA9USART1_TX可选用于打印状态调试串口 RXPA10USART1_RX可选TIM2 配置为内部时钟PSC71ARR999开启更新中断。NVIC 里把 TIM2 中断优先级设为 1不要设成 0因为 0 通常留给系统滴答或硬件错误设成 1 可以保证它比普通外设中断优先又不会抢占系统关键中断。GPIO 输出速度统一设为 Low因为 LED 不需要高速翻转低速可以减小电磁干扰。生成代码时工具链选 MDK-ARM 或 STM32CubeIDE 都行我这里以 HAL 库为例因为 HAL 的可读性对新手更友好。生成后先编译一次确认没有报错再开始写业务逻辑。3.2 状态机主循环相位切换与输出映射状态机的核心是一个switch-case每个 case 对应一个相位进入时设置灯态和持续时间然后等待g_phase_tick达到阈值。为了代码清晰我把灯态控制封装成一个函数用位掩码表示六个灯的组合。// traffic_light.h #define PHASE_GREEN_A 8000 // 方向 A 绿灯 8 秒 #define PHASE_YELLOW_A 2000 // 方向 A 黄灯 2 秒 #define PHASE_GREEN_B 8000 #define PHASE_YELLOW_B 2000 #define PHASE_ALL_RED 3000 // 紧急模式全红 3 秒 typedef enum { STATE_GREEN_A 0, STATE_YELLOW_A, STATE_GREEN_B, STATE_YELLOW_B, STATE_EMERGENCY } LightState; void set_lights(uint8_t red_a, uint8_t yellow_a, uint8_t green_a, uint8_t red_b, uint8_t yellow_b, uint8_t green_b) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, red_a ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, yellow_a ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, green_a ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_3, red_b ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, yellow_b ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, green_b ? GPIO_PIN_SET : GPIO_PIN_RESET); }主循环里每次状态切换时先把g_phase_tick清零然后根据当前状态设置灯态。下面这段是主循环的骨架LightState state STATE_GREEN_A; uint32_t phase_duration PHASE_GREEN_A; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_TIM2_Init(); HAL_TIM_Base_Start_IT(htim2); set_lights(0, 0, 1, 1, 0, 0); // 初始A 绿 B 红 while (1) { if (g_emergency_flag) { g_emergency_flag 0; state STATE_EMERGENCY; phase_duration PHASE_ALL_RED; g_phase_tick 0; set_lights(1, 0, 0, 1, 0, 0); // 全红 } if (g_phase_tick phase_duration) { g_phase_tick 0; switch (state) { case STATE_GREEN_A: state STATE_YELLOW_A; phase_duration PHASE_YELLOW_A; set_lights(0, 1, 0, 1, 0, 0); break; case STATE_YELLOW_A: state STATE_GREEN_B; phase_duration PHASE_GREEN_B; set_lights(1, 0, 0, 0, 0, 1); break; case STATE_GREEN_B: state STATE_YELLOW_B; phase_duration PHASE_YELLOW_B; set_lights(1, 0, 0, 0, 1, 0); break; case STATE_YELLOW_B: state STATE_GREEN_A; phase_duration PHASE_GREEN_A; set_lights(0, 0, 1, 1, 0, 0); break; case STATE_EMERGENCY: state STATE_GREEN_A; phase_duration PHASE_GREEN_A; set_lights(0, 0, 1, 1, 0, 0); break; default: state STATE_GREEN_A; break; } } } }这段代码的逻辑说明g_emergency_flag在紧急按键中断里被置 1主循环检测到后立即切到全红状态并重置计时。紧急状态结束后回到绿灯 A而不是回到之前的状态这是为了安全——路口重新启动时应该从一个确定的相位开始。phase_duration和state是配对的每次切换状态必须同时更新这两个变量否则会出现计时和灯态不匹配的 bug。参数方面PHASE_GREEN_A等宏定义可以根据实际路口流量调整但黄灯时间不建议低于 2 秒这是为了给驾驶员足够的反应时间。3.3 按键消抖与紧急请求20ms 扫描加标志位机械按键按下时触点会在几毫秒内多次通断如果直接在中断里响应一次按下可能触发多次。我一般用 20ms 周期扫描连续两次读到相同电平才确认。下面这个函数放在主循环里每 20ms 调用一次void key_scan(void) { static uint8_t last_ped 1, last_emg 1; static uint8_t ped_cnt 0, emg_cnt 0; uint8_t cur_ped HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0); uint8_t cur_emg HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_1); if (cur_ped last_ped) { if (ped_cnt 2) ped_cnt; if (ped_cnt 2 cur_ped 0) { g_pedestrian_request 1; // 确认按下 } } else { ped_cnt 0; } last_ped cur_ped; if (cur_emg last_emg) { if (emg_cnt 2) emg_cnt; if (emg_cnt 2 cur_emg 0) { g_emergency_flag 1; } } else { emg_cnt 0; } last_emg cur_emg; }逻辑说明last_ped保存上一次读取的电平ped_cnt记录连续相同次数。当连续两次读到低电平按下才置位请求标志。这样即使有抖动也不会误触发。g_pedestrian_request在主循环里可以用来延长当前绿灯或插入行人相位具体策略取决于你的演示需求。注意这个函数不是中断而是主循环轮询所以不会阻塞其他任务。参数上20ms 扫描周期和连续两次确认是我在多个项目里验证过的平衡点再短会增加 CPU 负担再长则按键响应变迟钝。4. 避坑与排查交通灯项目里最容易翻车的五个点4.1 现象两个方向同时亮绿灯。原因状态切换时没有先灭旧灯。解决每次切换先调用全灭或直接设置完整灯态。这是最危险也最常见的 bug。很多新手在切换相位时只写了“点亮新灯”忘了关掉旧灯结果 A 方向绿灯还没灭B 方向绿灯就亮了。在真实路口这是严重事故在演示现场也会让人质疑你的代码质量。我的习惯是每次状态切换都调用set_lights并传入完整的六个参数而不是单独调用HAL_GPIO_WritePin去改某一个灯。这样即使参数写错也能从代码上一眼看出当前相位的完整灯态。另外可以在set_lights开头先全部置为熄灭再设置目标灯态虽然多几条指令但能避免残留状态。4.2 现象按键按下去没反应或者要按很多次才触发。原因按键扫描被HAL_Delay阻塞或者消抖计数逻辑写反。解决确保主循环没有长延时消抖计数在电平变化时清零。如果你在状态机里用了HAL_Delay(8000)来等绿灯结束那按键扫描根本不会被执行。必须把延时改成 tick 计数。另一个常见错误是消抖逻辑写成了“连续两次不同才确认”这样反而会把正常按下过滤掉。正确的逻辑是连续两次读到相同电平且该电平是有效电平低电平才确认。如果你发现按键偶尔触发两次检查一下ped_cnt在电平变化时有没有清零没有清零的话下一次按下会直接累加到 2导致连击。4.3 现象黄灯时间明显不对要么一闪而过要么卡住不切换。原因g_phase_tick在中断里自增但主循环里读取时没有关中断或者变量没加volatile。解决给 tick 变量加volatile必要时在读取前关中断。g_phase_tick是 32 位变量在 32 位 MCU 上读写本身是原子的但编译器优化可能导致主循环一直读寄存器缓存而不是内存。加volatile是最低要求。如果你发现黄灯时间偶尔变成 1 秒或 3 秒那可能是中断里自增和主循环里清零发生了竞争主循环判断g_phase_tick phase_duration后清零但清零前中断又自增了一次导致下一次计时少了一个 tick。解决办法是在主循环里关中断再读再清零或者用__disable_irq()和__enable_irq()包住临界区。对于 1ms 中断来说关中断几十个周期不会影响系统响应。4.4 现象紧急按键触发后系统卡在全红状态不恢复。原因紧急标志置位后没有清除或者紧急状态没有设置退出条件。解决在进入紧急状态时清除标志并给紧急状态一个明确的持续时间。紧急按键的中断服务函数里只应该置位g_emergency_flag不应该做任何耗时操作。主循环检测到标志后先清除标志再切换状态。如果你忘了清除主循环每次都会重新进入紧急状态看起来就像卡死。另外紧急状态本身也需要一个计时器比如全红 3 秒后自动回到正常周期。这个 3 秒不是随便定的太短起不到清空路口的作用太长会让正常交通停滞。我一般用 3 到 5 秒根据演示场景调整。4.5 现象LED 亮度不均匀或者某些灯完全不亮。原因限流电阻阻值不一致或者 ULN2003 输出端接错。解决用万用表测每路电流确保在 8 到 12mA 之间。硬件问题排查起来比软件更直接。先不接 STM32直接用 5V 电源串电阻接 LED确认每颗 LED 都能正常发光并且亮度一致。然后接上 ULN2003用万用表测输出端对地电压导通时应该接近 0V截止时接近 5V。如果某一路一直导通可能是 ULN2003 输入悬空STM32 初始化时要把所有 GPIO 设为推挽输出并默认低电平避免上电瞬间全部灯亮。如果某一路一直不亮检查 ULN2003 对应输入引脚有没有虚焊或者 STM32 引脚有没有被复用成其他功能。5. 进阶技巧用串口打印状态迁移日志与参数在线调整5.1 串口日志格式设计一行一条迁移记录当你的交通灯系统跑起来之后最想知道的是“它到底在什么时间切换到了什么状态”。用串口打印日志是最低成本的可观测手段。我一般会定义一个简单的文本协议每行格式为[tick] STATE_A - STATE_B, durationxxxx ms。这样在串口助手里一眼就能看出状态迁移顺序和实际持续时间如果发现某个相位持续时间不对直接看日志就能定位。// 在状态切换的 switch-case 里每次切换后调用 void log_transition(LightState from, LightState to, uint32_t duration) { printf([%lu] %d - %d, duration%lu ms\r\n, (unsigned long)g_sys_tick, from, to, (unsigned long)duration); }逻辑说明g_sys_tick是系统启动以来的毫秒数用来给日志打时间戳。from和to是状态枚举值打印成数字方便解析。duration是目标状态的持续时间。printf需要重定向fputc到 USART1这个在 CubeMX 生成的代码里加一段重定向函数即可。参数上串口波特率建议 115200太高容易丢数据太低打印会拖慢主循环。如果你发现日志打印影响了状态机 timing可以把日志改成环形缓冲区在主循环空闲时批量发送。5.2 用串口命令在线修改相位时长演示的时候经常需要临时调整绿灯时间比如从 8 秒改成 5 秒看看效果。如果每次都要重新编译下载效率太低。我一般会在串口接收中断里解析简单命令比如SET GA 5000表示把方向 A 绿灯设为 5000ms。命令解析不需要很复杂用一个固定缓冲区收到换行符后比较前缀和参数。// 串口接收回调收到一行后解析 void parse_command(char *cmd) { if (strncmp(cmd, SET GA , 7) 0) { uint32_t val atoi(cmd 7); if (val 1000 val 30000) { g_phase_green_a val; // 全局变量替代宏定义 } } // 类似处理 SET YA、SET GB、SET YB }逻辑说明strncmp比较前 7 个字符确认是设置绿灯 A 的命令。atoi把后面的数字转成整数。参数范围限制在 1000 到 30000 毫秒防止输入 0 导致状态机疯狂切换或者输入过大导致演示卡住。这些全局变量在状态机里替代原来的宏定义这样在线修改立即生效。注意串口接收中断里不要做atoi和字符串比较应该把数据放进缓冲区在主循环里解析避免中断执行时间过长。5.3 用逻辑分析仪验证时序抓取 GPIO 翻转波形软件日志能告诉你状态机认为自己在做什么但硬件上灯到底有没有按预期亮灭需要用逻辑分析仪抓 GPIO 波形。把六个 GPIO 接到逻辑分析仪的通道上采样率设 1MHz 就够了因为交通灯最窄的脉冲是黄灯 2 秒1MHz 采样能看清毫秒级抖动。抓一段 30 秒的波形然后测量每个相位的高电平持续时间和代码里的设定值对比。如果发现某个灯有毛刺比如切换瞬间出现几微秒的窄脉冲那可能是set_lights里六个HAL_GPIO_WritePin不是原子操作中间被中断打断。解决办法是在set_lights开头关中断设置完再开中断或者直接用 BSRR 寄存器一次性写六个引脚。我自己的习惯是每次改完状态机逻辑先看串口日志确认迁移顺序再用逻辑分析仪确认硬件时序两者都对得上才认为这个版本可以拿去演示。这个习惯帮我省掉了很多次现场翻车的尴尬。希望帮到你。本文还有配套的精品资源点击获取