
简介一套基于STM32F103单片机实现的交通灯控制系统完整工程包面向单片机课程设计、电子竞赛及红绿灯逻辑入门学习者。工程以标准库函数编写主程序轮询控制东西与南北方向绿灯、黄灯交替包含GPIO初始化、延时函数、OLED显示及Light_ctrl、TIMER等硬件驱动模块配有Proteus仿真文件pdsprj/pdsbak和参考设计报告可帮助读者快速理解状态机式信号灯控制流程并直接移植或二次开发。资源包共88个文件以C语言源码和头文件为主38个头文件、34个C源文件另含Keil工程配置uvprojx/uvoptx、hex烧录文件、Proteus仿真备份与PDF设计说明整体仅1.73MB。目前已有850人学习下载适合需要完成交通灯课设或想掌握STM32 GPIO、定时器与中断协同应用的开发者。包内目录层次清晰CORE、SYSTEM、HARDWARE、USER模块分离可对照PDF文档逐步搭建基于STM32F103的信号灯控制系统。1. 交通灯控制系统是 STM32F103 入门的完整训练场交通灯控制系统几乎是每个单片机开发者绕不过去的课程设计题目但多数人把它当成一个点灯任务来做LED 亮灭按顺序排好delay 一嵌套仿真能跑就交差。真正把题目做扎实的人不多因为这个系统里藏着单片机开发的三个核心问题——定时器如何做非阻塞调度、GPIO 如何在中断与主循环之间安全共享、状态机如何设计才能让系统在异常输入下不混乱。用 STM32F103 做这个题目比用 51 单片机多出来的工作量集中在时钟树配置、定时器通道分配和库函数的初始化顺序上但换来的是一套可以扩展到无人机飞控、智能家居网关的工程骨架。这篇文章面向正在做课程设计或毕业设计的开发者也面向想把裸机状态机写法理清楚的在职工程师我会从最小系统开始把一套能在真实硬件上稳定运行的交通灯控制方案完整拆开讲。2. STM32F103 交通灯的最小系统与定时器选型2.1 最小系统电路要保留哪些器件STM32F103 最小系统的电路并不复杂但很多人在 Proteus 仿真里跑得好好的一焊实物就出问题。问题通常集中在三个地方复位电路、启动模式和晶振走线。复位电路推荐 10kΩ 上拉电阻加 100nF 对地电容NRST 引脚不要悬空BOOT0 和 BOOT1 必须通过 10kΩ 电阻下拉到地否则上电后芯片会进入串口下载模式而不是从 Flash 启动。晶振方面8MHz 主晶振的两个负载电容取 20pF 匹配走线尽量短且对称。电源部分如果系统里有 5V 供电的数码管或者蜂鸣器建议用 AMS1117-3.3 独立稳压STM32F103 的 VDD 引脚就近放置 100nF 去耦电容每 3 个引脚至少一个电容。很多手工焊接板出现随机复位排查到最后都是去耦电容缺失导致的电源噪声复位。调试接口按标准的 SWD 四线设计SWDIO、SWCLK、GND、3.3V不建议用 JTAG 占用过多 GPIO因为交通灯控制需要全部 GPIO 来操作 LED 和按键。2.2 为什么不能用 delay 嵌套做时间控制初学者最常见的写法是HAL_Delay或者delay_ms嵌套在状态切换里比如绿灯亮 5 秒就点亮绿灯然后延时 5000ms再关绿灯开黄灯。这种写法在纯 LED 展示时没有任何问题但一旦加入行人按钮或急救车优先功能系统就崩了。原因是delay是阻塞式等待CPU 在延时期间无法响应外部中断按键按下去要等当前状态跑完才能被处理这在交通灯场景里属于严重逻辑缺陷。更本质的问题是真实交通灯系统不是一个简单的顺序循环而是一个周期性驱动系统LED 需要驱动、数码管需要动态扫描、按键需要消抖检测、状态需要在精确时刻切换。这些任务共享一个 CPU就必须有一个统一的时间基准来调度。STM32F103 的 SysTick 或者通用定时器 TIM 就是这个时间基准。选择 TIM 而不是 SysTick 的原因在于SysTick 常用于操作系统节拍或简单延时而 TIM 可以输出 PWM 驱动调光、支持输入捕获检测车流量为后期扩展留出硬件层接口。2.3 定时器优先级与 GPIO 引脚分配方案交通灯控制系统通常需要两组信号灯东西方向、南北方向每组包含红黄绿三个 LED另外有行人按钮和数码管倒计时。以 STM32F103C8T6 为例引脚分配我习惯这样规划功能引脚配置模式说明东西方向红/黄/绿PA0/PA1/PA2推挽输出直接驱动 LED串 330Ω 限流电阻南北方向红/黄/绿PB0/PB1/PB10推挽输出同上行人请求按钮PA3上拉输入按下为低电平配合 EXTI 或轮询倒计时数码管段选PC0~PC7推挽输出共阴数码管段码经 100Ω 电阻数码管位选PA4/PA5/PA6推挽输出三极管驱动位选提高驱动能力定时器资源分配上TIM2 作为系统时基中断配置为 1ms 中断周期在中断服务函数里做状态计时和数码管刷新TIM3 留作 PWM 输出用于黄灯闪烁功能时调节亮度或者产生闪烁频率。两个定时器的优先级需要设置差值TIM2 抢占优先级设为 1TIM3 设为 2这样在 PWM 中断和时基中断同时发生时时基优先执行保证时间计数的准确性。3. 交通灯核心逻辑的状态机设计与源码骨架3.1 四态模型从通行权角度切分状态交通灯控制的本质是通行权管理因此状态设计不应该从灯亮什么颜色出发而应该从谁拥有通行权出发。常见的设计是四个主状态东西通行、东西过渡黄灯、南北通行、南北过渡黄灯。通行状态包含绿灯和倒计时过渡状态只包含黄灯和短暂全红间隔。这里有一个设计细节东西向绿灯结束后是否需要插入全红状态在真实路口绿灯结束到另一方向绿灯开始之间需要 2~3 秒全红用于清空路口内滞留车辆。课程设计如果不做这个全红状态评审老师会认为逻辑不完整。我推荐用枚举类型定义状态而不是用整型数字 0/1/2/3。枚举可以自文档化调试时在串口打印状态名清晰可读。状态机的驱动方式选择事件驱动超时事件主循环只负责检测按钮输入TIM2 中断负责产生时间事件状态迁移全部在中断外的函数中执行。这样可以避免在中断服务函数里写复杂的状态切换逻辑降低临界区资源冲突风险。3.2 可复现的裸机状态机代码骨架下面这段代码基于标准外设库StdPeriph编写适配 STM32F103 全系列核心逻辑不依赖 HAL 库方便移植到其他单片机。状态机数据结构、时间管理和状态切换逻辑全部在主循环中完成中断只做计数和标志位设置。typedef enum { STATE_EW_GREEN 0, STATE_EW_YELLOW, STATE_NS_GREEN, STATE_NS_YELLOW, STATE_EW_RED_CLEAR, STATE_NS_RED_CLEAR } TrafficState; typedef struct { TrafficState currentState; uint16_t remainingSeconds; uint8_t pedestrianRequest; uint16_t timer1ms; } TrafficController; void Traffic_StateUpdate(TrafficController *tc) { if (tc-remainingSeconds 0) { tc-remainingSeconds--; return; } switch (tc-currentState) { case STATE_EW_GREEN: tc-currentState STATE_EW_YELLOW; tc-remainingSeconds YELLOW_DURATION; break; case STATE_EW_YELLOW: tc-currentState STATE_EW_RED_CLEAR; tc-remainingSeconds ALL_RED_DURATION; break; case STATE_EW_RED_CLEAR: tc-currentState STATE_NS_GREEN; tc-remainingSeconds NS_GREEN_DURATION; break; case STATE_NS_GREEN: tc-currentState STATE_NS_YELLOW; tc-remainingSeconds YELLOW_DURATION; break; case STATE_NS_YELLOW: tc-currentState STATE_NS_RED_CLEAR; tc-remainingSeconds ALL_RED_DURATION; break; case STATE_NS_RED_CLEAR: tc-currentState STATE_EW_GREEN; tc-remainingSeconds EW_GREEN_DURATION; break; default: tc-currentState STATE_EW_GREEN; tc-remainingSeconds EW_GREEN_DURATION; break; } } void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); if (g_tc.timer1ms 0) { g_tc.timer1ms--; if (g_tc.timer1ms 0) { Traffic_StateUpdate(g_tc); g_tc.timer1ms 1000; } } } }这段代码的逻辑关键点有两个。第一Traffic_StateUpdate只在remainingSeconds减到 0 时才被调用每秒触发一次状态切换的瞬间和倒计时刷新是同步的不会出现显示 0 秒但灯还没切换的错位。第二中断服务函数里只做计数和函数调用没有阻塞操作Traffic_StateUpdate执行时间极短不会影响中断响应。3.3 倒计时数码管的动态扫描实现数码管显示倒计时需要占用额外的 CPU 时间所以不能单独写一个 delay 循环来扫描位选。把数码管刷新挂到同一个 1ms 时基中断里每 1ms 切换一个位选4 位数码管在 4ms 内完成一轮扫描人眼看到的就是稳定的数字。扫描代码要注意段码表与实际硬件的对应关系共阴和共阳数码管的段码完全不同。const uint8_t segCode[10] { 0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F }; void Display_Scan(TrafficController *tc) { static uint8_t scanPos 0; uint8_t tens tc-remainingSeconds / 10; uint8_t ones tc-remainingSeconds % 10; GPIO_Write(GPIOC, 0x00); // 关闭全部位选消隐 switch (scanPos) { case 0: GPIO_Write(GPIOC, segCode[tens]); GPIO_SetBits(GPIOA, GPIO_Pin_4); // 打开十位位选 break; case 1: GPIO_Write(GPIOC, segCode[ones]); GPIO_SetBits(GPIOA, GPIO_Pin_5); // 打开个位位选 break; default: break; } scanPos (scanPos 1) % 2; }这段扫描代码放在 TIM2 中断的最后调用每次中断执行一次扫描扫描周期 2ms刷新率 500Hz远高于人眼能察觉的 50Hz 闪烁阈值。段码通过GPIO_Write一次写入避免逐位操作产生的中间状态导致数码管出现乱码。GPIO_Write操作的是 ODCR 寄存器16 个引脚一次写入效率远高于连续调用 8 次GPIO_SetBits这在中断里是值得注意的性能细节。3.4 行人按钮抢占中断标志位与状态恢复行人按钮的处理不能直接中断状态机执行让灯立刻变红真实路口也不会这样做因为可能会导致车辆急刹。正确的做法是按下按钮后打上请求标志位状态机在当前至少运行了最小绿灯时间后才响应该请求提前进入黄灯过渡。这个最小绿灯时间一般是 10 秒防止行人一按就频繁打断车流。void EXTI3_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line3) ! RESET) { EXTI_ClearITPendingBit(EXTI_Line3); if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_3) Bit_RESET) { if (g_tc.currentState STATE_EW_GREEN g_tc.remainingSeconds PEDESTRIAN_MIN_GREEN) { g_tc.remainingSeconds PEDESTRIAN_MIN_GREEN; } } } }这里有一个关键的配置细节外部中断触发方式选择下降沿触发同时在中断服务函数里再读一次引脚电平做二次确认。机械按键按下瞬间会产生 10~20ms 的抖动引脚电平会在高低之间快速跳变仅靠下降沿触发可能会在一个按键周期内进入中断多次。二次读引脚能过滤掉大部分抖动。对于需要更严格消抖的场景可以在中断里启动一个 20ms 的定时器超时后再读取电平确认这个方案在后面的排错章节会展开讲。4. 从仿真到实物时钟配置、整定与串口排错4.1 定时器分频系数与自动重装值的计算STM32F103 的 TIM2 挂载在 APB1 总线上默认时钟为 72MHz在系统时钟 72MHz 且 APB1 预分频为 2 的情况下TIMxCLK 实际为 72MHz。要得到 1ms 中断周期需要配置预分频器和自动重装寄存器。计算过程如下定时器时钟 72MHz分频系数 PSC 设为 71则计数频率为 72MHz / (711) 1MHz即计数一次耗时 1μs自动重装值 ARR 设为 999则定时器周期为 (9991) × 1μs 1000μs 1ms。参数值说明PSC71实际分频系数为 PSC1即 72 分频ARR999计数范围 0~999共 1000 个数计数频率1MHz每个计数周期 1μs中断周期1msARR1 与计数频率的比值定时器初始化代码中还有一个频率陷阱。TIM 内部会在收到时钟信号后在预分频器生效前后有 1~2 个时钟周期的延迟这在毫秒级定时中无影响但如果做 PWM 输出叠加计数配置完成后应先清一次更新标志位再使能中断防止刚初始化就触发一次伪中断。这也是很多人在仿真中正常、实物运行频繁进入中断的原因。4.2 库函数版本与时钟树的关系用标准外设库做开发时SystemInit()函数会在跳转到 main 前把系统时钟配置为 72MHz。这个函数的实现位于system_stm32f10x.c由#define SYSCLK_FREQ_72MHz宏控制。确认该宏处于打开状态是排错的第一步。有人从网上拷贝的工程模板里这个宏是 36MHz导致定时器中断周期变成实际值的 2 倍界面显示倒计时 5 秒实际走了 10 秒。检查时钟是否真正跑到 72MHz可以开一个 GPIO 输出 1MHz 的方波并用示波器测量频率也可以在调试器中查看RCC-CFGR寄存器的实际值。SystemCoreClock全局变量保存了当前系统时钟值调试时在 Watch 窗口添加它即可快速确认。如果发现系统时钟不对优先检查外部晶振是否起振STM32F103 的 HSE 起振时间通常在几百微秒到几毫秒如果晶振两端电容不匹配或走线过长HSERDY 标志会一直不置位系统只能退回到 HSI 内部 8MHz 时钟运行。4.3 串口 1 打印状态机迁移信息状态机调试最怕的就是逻辑跳到哪里完全靠猜。在 STM32F103C8T6 上USART1 的 TX 引脚 PA9 被复用为输出通过串口转 USB 模块连接上位机后可以在线查看状态迁移和时间计数是否与预设一致。void Debug_PrintState(TrafficController *tc) { char buffer[64]; const char *stateNames[] { EW_GREEN, EW_YELLOW, NS_GREEN, NS_YELLOW, EW_RED_CLEAR, NS_RED_CLEAR }; sprintf(buffer, [%04d] State%s, Remaining%d\r\n, tc-timerDebugCounter, stateNames[tc-currentState], tc-remainingSeconds); USART_SendString(USART1, buffer); }把Debug_PrintState放在状态切换的时刻调用一次而不是每秒调用这样日志量小且每个状态切换点都有记录。波特率设置为 115200串口初始化与定时器初始化的顺序没有强制要求但注意不要在串口还没初始化完成时就开始发送字符串否则第一个字节可能丢失。调试完成后在正式代码中把串口使能关闭即可不影响状态机逻辑。关于 STM32F103 串口 1 和串口 3 的使用差异USART1 挂在 APB2 总线上72MHzUSART3 挂在 APB1 总线上36MHz如果配置相同的波特率寄存器值实际波特率会差一倍。做调试时固定用 USART1 可以避免这个陷阱。4.4 逻辑分析仪抓 GPIO 波形的验证方法对于没有示波器的环境逻辑分析仪是最便宜的时序验证工具。把逻辑分析仪的 8 个通道分别接到东西和南北方向的 6 个 LED 引脚上用 1MHz 采样率录一段 30 秒的运行波形便能直观看到绿灯时长、黄灯时长、全红间隔是否符合设计值。观察重点有两个黄灯时长是否精确、东西方向的绿灯结束瞬间到南北方向的绿灯开始瞬间之间是否有全红间隔。波形图上如果发现绿灯点亮的时间比设定的配时少了几秒排查方向是状态切换条件是否被外部中断提前打断。回顾 3.4 节的代码行人按钮触发会缩减剩余时间到最小绿灯值如果按钮误触发例如 GPIO 没有配置上拉导致引脚悬浮状态机就会频繁缩减配时。这时打开串口日志在中断里打印按钮事件的时间和状态很快就能定位出是硬件电平问题还是软件消抖问题。5. 用配时参数表把固定配时改造成多时段自适应前面的状态机代码里各个状态的持续时间以宏定义如#define YELLOW_DURATION 3的方式编码在源码里。这种方式在演示场景中够用但如果要做毕业设计答辩演示或者实际路口部署评审通常会追问一个问题早晚高峰的配时不一样你的系统怎么处理 此时把固定宏替换为可配置的参数表是性价比最高且最容易讲清楚的设计升级。我通常会引入一个二维数组作为配时方案表按时间段索引切换。定义如下typedef struct { uint8_t startHour; uint8_t endHour; uint16_t ewGreenSec; uint16_t nsGreenSec; uint8_t yellowSec; uint8_t allRedSec; } TimingPlan; const TimingPlan planTable[] { { 0, 6, 20, 20, 3, 2 }, // 深夜低峰双向等长 { 6, 9, 45, 30, 3, 3 }, // 早高峰东西主路优先 { 9, 17, 30, 30, 3, 2 }, // 平峰均衡设计 { 17, 21, 50, 25, 3, 3 }, // 晚高峰东西主路优先 { 21, 24, 25, 25, 3, 2 } // 夜间过渡缩短周期 }; #define PLAN_COUNT (sizeof(planTable) / sizeof(planTable[0]))在状态机运行的每分钟读取一次 RTC 或系统计时得到当前小时与planTable中的时段范围匹配后更新状态机的配时参数。这里需要注意一个边界问题状态切换的瞬间如果恰好处在配时方案更新的时刻可能出现某一状态被意外缩短的情况。常见做法是更新操作不在中断里做在主循环中检测到小时变化后再更新——即使延迟几秒执行对整体交通运行的影响也完全可以忽略。这个升级将原来的写死在代码里的交通灯变成了可配置的交通灯控制器为后续接入按键菜单调整配时、红外遥控配置方案提供了基础。你在源码包基础上做改动时保留原来用宏定义跳转的方案在Traffic_StateUpdate的状态切换处增加读取参数的函数大约 20 行代码就能完成全部改造。改造完的验证方式也很直接把当前系统时间调整到早高峰 7:00观察状态机是否在 3 秒内切换到 45 秒绿灯方案日志里打印的实际切换时间可以作为演示素材。本文还有配套的精品资源点击获取