简介:这是一套面向嵌入式初学者与电子类专业学生的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 的主频通常是 72MHz,TIM2 挂在 APB1 总线上,时钟频率也是 72MHz。要得到 1ms 中断,可以设置预分频器 PSC = 71,自动重装载寄存器 ARR = 999。计算过程是:定时器时钟 = 72MHz / (PSC+1) = 72MHz / 72 = 1MHz,也就是每计数一次 1 微秒;计数到 ARR+1 = 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 红灯 | PA0 | GPIO_Output | 推挽,低电平灭 |
| 方向 A 黄灯 | PA1 | GPIO_Output | 推挽 |
| 方向 A 绿灯 | PA2 | GPIO_Output | 推挽 |
| 方向 B 红灯 | PA3 | GPIO_Output | 推挽 |
| 方向 B 黄灯 | PA4 | GPIO_Output | 推挽 |
| 方向 B 绿灯 | PA5 | GPIO_Output | 推挽 |
| 行人按键 | PB0 | GPIO_Input | 上拉,按下为低 |
| 紧急按键 | PB1 | GPIO_Input | 上拉,按下为低 |
| 调试串口 TX | PA9 | USART1_TX | 可选,用于打印状态 |
| 调试串口 RX | PA10 | USART1_RX | 可选 |
TIM2 配置为内部时钟,PSC=71,ARR=999,开启更新中断。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, duration=xxxx 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 寄存器一次性写六个引脚。
我自己的习惯是,每次改完状态机逻辑,先看串口日志确认迁移顺序,再用逻辑分析仪确认硬件时序,两者都对得上,才认为这个版本可以拿去演示。这个习惯帮我省掉了很多次现场翻车的尴尬。希望帮到你。
本文还有配套的精品资源,点击获取