简介:这份资源是面向嵌入式初学者与高校课程设计学生的STM32模拟交通灯项目完整资料包,基于Keil μVision开发环境,用C语言实现红绿黄灯定时切换,帮助读者掌握GPIO控制、定时器中断与状态机设计等核心技能。压缩包共199个文件,约5.82MB,包含31个C源文件、33个头文件及大量编译中间文件,另有1份Word设计文档、1份PPT演示文稿和若干说明文本,覆盖从源码到答辩展示的完整链路。目前已有4202人学习下载,热度较高。读者可借助源码理解STM32外设初始化与程序烧录流程,通过文档与PPT梳理设计思路、硬件连接与工作原理,并直接用于操作系统或电子工程相关课程的实验报告与成果展示,是一份理论结合实践、便于二次修改的参考方案。
1. 从一份 keil 基于 stm32 的模拟交通灯项目源码说起:它到底能帮你解决什么
如果你正在学 STM32,大概率会遇到这样一个尴尬期:GPIO 点灯、按键消抖、串口打印都跑通了,但一到“把几个外设串起来做一个完整东西”就卡住。模拟交通灯就是那个最经典的“缝合怪”练手项目——它不复杂,但把 GPIO 输出、定时器计时、状态机切换、按键外部中断这几件事全揉在了一起。一份 keil 基于 stm32 的模拟交通灯项目源码+文档+ppt,本质上是一套“从能编译到能讲清楚”的完整交付物:源码让你看到工程怎么组织,文档让你理解每个模块为什么这么写,PPT 则逼你把技术逻辑翻译成别人能听懂的话。
我带过几个刚入门的同学,他们最大的问题不是不会写代码,而是不知道一个“像样”的工程该长什么样。交通灯项目恰好卡在“太小没意思”和“太大做不动”之间,红绿灯配倒计时数码管,再加个紧急按钮,四个方向的状态迁移足够让你把定时器中断和状态机吃透。这套资料适合两类人:一类是课程设计或毕设需要快速搭出可演示系统的,另一类是想通过一个完整小项目把 STM32 标准库或 HAL 库串起来的新手。下面我按实际复现路径拆开讲,源码结构、定时器参数、状态机写法、数码管驱动、以及最容易翻车的地方,都会给到能直接抄的配置。
2. 工程结构与时钟配置:拿到源码后先别急着编译
2.1 先看清目录里有什么,再决定用哪个库
一份典型的交通灯工程压缩包解压后,通常包含这几个部分:Project文件夹放 keil 工程文件(.uvprojx),User放main.c和中断服务函数,Hardware放外设驱动(数码管、按键、LED),Library放标准外设库或 HAL 库,Doc和PPT单独存放。你拿到手第一件事不是双击打开工程,而是先确认它用的是标准库还是 HAL 库——这决定了你后面改代码的写法。
判断方法很简单:打开main.c,看初始化部分是GPIO_InitTypeDef配RCC_APB2PeriphClockCmd,还是HAL_GPIO_Init配__HAL_RCC_GPIOA_CLK_ENABLE。前者是标准库,后者是 HAL。两者没有绝对优劣,但标准库的交通灯例程更多、寄存器操作更直观,HAL 库则更容易迁移到 CubeMX 生成的工程里。如果你打算用 CubeMX 重新配引脚,建议直接选 HAL 版本;如果只是想快速改逻辑,标准库版本改起来更顺手。
提示:不要混用标准库和 HAL 库的延时函数。标准库常用
delay_ms自己写循环,HAL 库用HAL_Delay,混用会导致时序错乱,尤其是数码管动态扫描会闪。
2.2 时钟树配置:72MHz 不是随便写的
STM32F103 最常见的配置是外部 8MHz 晶振经 PLL 9 倍频到 72MHz。这个值直接决定定时器计数频率,进而决定红绿灯倒计时准不准。如果你用内部 HSI 8MHz,虽然也能跑,但串口波特率容易有偏差,倒计时也会慢慢漂移。配置时钟的代码在SystemInit里,标准库工程一般已经写好,你只需要确认stm32f10x.h里的HSE_VALUE是 8000000。
// system_stm32f10x.c 中关键配置(标准库) #define SYSCLK_FREQ_72MHz 72000000 // 实际调用在 SystemInit() 内,通过 RCC->CFGR 设置 PLLMUL=9, PLLSRC=HSE逻辑说明:SYSCLK_FREQ_72MHz宏决定了SystemInit里 PLL 的倍频系数。如果你板子上晶振是 12MHz,这个宏不改就会超频到 108MHz,虽然有些芯片能跑,但定时器全乱。参数说明:HSE_VALUE在stm32f10x.h中定义,必须和实际晶振一致;PLLMUL可选 2~16 倍频,但 STM32F103 最高主频 72MHz,超过会不稳定。
2.3 定时器选型:TIM2 做 1ms 基准,TIM3 做数码管扫描
交通灯需要两个时间维度:一个是秒级倒计时,一个是毫秒级数码管动态扫描。常见做法是用 TIM2 产生 1ms 中断,在中断里维护一个ms_count,每 1000 次就是 1 秒;TIM3 产生 2ms 中断专门刷数码管,或者干脆把数码管扫描也放在 TIM2 的 1ms 中断里分频执行。我一般会分开,因为数码管扫描对时序敏感,放在同一个中断里容易因为倒计时逻辑耗时导致闪烁。
// TIM2 配置为 1ms 中断(72MHz / 72 = 1MHz,重装载 1000) void TIM2_Init(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_TimeBaseStructure.TIM_Period = 999; // 自动重装载值 TIM_TimeBaseStructure.TIM_Prescaler = 71; // 预分频,72MHz/72=1MHz TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); TIM_Cmd(TIM2, ENABLE); NVIC_EnableIRQ(TIM2_IRQn); }逻辑说明:预分频 71 表示实际分频系数为 71+1=72,得到 1MHz 计数频率;重装载 999 表示计数 1000 次溢出,溢出周期 1ms。参数说明:TIM_Prescaler和TIM_Period都从 0 开始算,所以写 71 和 999 才是 72 和 1000。如果你改成 2ms 中断,把TIM_Period改成 1999 即可,但倒计时的ms_count阈值要同步改成 500。
3. 状态机与倒计时逻辑:红绿灯切换的核心不是延时
3.1 用状态机替代 delay,否则按键永远没反应
新手最容易犯的错是在主循环里写delay_ms(5000)来等红灯结束。这样写按键中断虽然能进,但主循环被阻塞,数码管刷新也会停。正确做法是把每个灯的状态定义成枚举,用定时器中断驱动状态迁移。下面是一个四状态交通灯的状态机骨架:
typedef enum { STATE_EW_GREEN, // 东西绿,南北红 STATE_EW_YELLOW, // 东西黄,南北红 STATE_NS_GREEN, // 南北绿,东西红 STATE_NS_YELLOW // 南北黄,东西红 } TrafficState; volatile TrafficState g_state = STATE_EW_GREEN; volatile uint16_t g_countdown = 10; // 当前状态剩余秒数 // 在 TIM2 的 1ms 中断里调用 void Traffic_Update(void) { static uint16_t ms = 0; ms++; if (ms >= 1000) { ms = 0; if (g_countdown > 0) { g_countdown--; } else { // 状态迁移 switch (g_state) { case STATE_EW_GREEN: g_state = STATE_EW_YELLOW; g_countdown = 3; break; case STATE_EW_YELLOW: g_state = STATE_NS_GREEN; g_countdown = 10; break; case STATE_NS_GREEN: g_state = STATE_NS_YELLOW; g_countdown = 3; break; case STATE_NS_YELLOW: g_state = STATE_EW_GREEN; g_countdown = 10; break; } } } }逻辑说明:g_countdown每秒减一,减到 0 时根据当前状态切换到下一个状态并重置倒计时。参数说明:绿灯 10 秒、黄灯 3 秒是常见配时,你可以按实际路口调整,但黄灯不建议低于 2 秒,否则演示时看不清。注意g_state和g_countdown都加了volatile,因为它们在中断和主循环之间共享,不加编译器可能优化掉。
3.2 数码管显示:动态扫描与段码表
交通灯倒计时一般用两位共阳或共阴数码管。动态扫描的意思是:先输出十位的段码,点亮十位,延时 2ms,再输出个位的段码,点亮个位,延时 2ms,循环。这样人眼看到的是两位同时亮。段码表根据共阳/共阴不同,下面以共阴数码管为例:
// 共阴数码管段码表:0~9 const uint8_t seg_code[] = {0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F}; void Display_Countdown(uint8_t num) { uint8_t tens = num / 10; uint8_t ones = num % 10; // 十位 GPIO_Write(GPIOB, seg_code[tens]); // 段码输出 GPIO_ResetBits(GPIOA, GPIO_Pin_0); // 选中十位 delay_ms(2); GPIO_SetBits(GPIOA, GPIO_Pin_0); // 个位 GPIO_Write(GPIOB, seg_code[ones]); GPIO_ResetBits(GPIOA, GPIO_Pin_1); // 选中个位 delay_ms(2); GPIO_SetBits(GPIOA, GPIO_Pin_1); }逻辑说明:seg_code把数字转成 7 段码,GPIO_Write一次性输出到数据端口,位选引脚控制哪一位亮。参数说明:delay_ms(2)不能太长,否则闪烁;也不能太短,否则亮度不够。如果数码管是共阳,段码表要取反,位选电平也要反过来。这个函数放在主循环里调用,不要放在中断里,因为delay_ms会占用中断时间。
3.3 按键紧急模式:外部中断里只改标志位
交通灯项目通常会加一个按键,按下后所有方向变红,再按恢复。这个按键用外部中断实现,但中断服务函数里不要做状态切换和数码管刷新,只置一个标志位,主循环检测到标志位再处理。
volatile uint8_t g_emergency = 0; void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) != RESET) { g_emergency = !g_emergency; EXTI_ClearITPendingBit(EXTI_Line0); } } // 主循环中 while (1) { if (g_emergency) { // 所有方向红灯,倒计时暂停 GPIO_SetBits(GPIOA, GPIO_Pin_2 | GPIO_Pin_3); // 红灯亮 GPIO_ResetBits(GPIOA, GPIO_Pin_4 | GPIO_Pin_5); // 绿灯灭 // 数码管显示 "--" } else { Traffic_Update(); // 正常状态机 } Display_Countdown(g_countdown); }逻辑说明:中断里只翻转g_emergency,主循环根据标志决定是走正常状态机还是紧急模式。参数说明:EXTI_Line0对应 PA0,需要配置GPIO_EXTILineConfig和EXTI_Init,触发方式选下降沿或上升沿取决于按键电路。注意按键要加消抖,硬件上并 104 电容,软件上在中断里加 10ms 延时判断,但延时不要用HAL_Delay,用简单的循环。
4. 避坑与常见问题:这五个地方我踩过三次以上
4.1 数码管闪烁严重,倒计时数字像在抖
现象:数码管显示的数字一直在抖,尤其是倒计时变化时更明显。原因:动态扫描的delay_ms(2)被主循环里其他耗时操作打断,或者扫描频率和定时器中断冲突。解决:把数码管扫描放到定时器中断里,用状态机分步执行,不要用阻塞延时。如果必须用主循环,确保Display_Countdown里没有超过 3ms 的延时,并且主循环里不要有delay_ms(1000)这种长延时。
4.2 按键按下去没反应,或者一按就连续触发
现象:按键按下后状态不切换,或者切换好几次。原因:外部中断触发方式选错,或者没有消抖。解决:按键接下拉电阻时选上升沿触发,接上拉电阻时选下降沿触发;在中断里加 10ms 软件消抖,确认电平后再改标志位。另外检查NVIC优先级,如果按键中断优先级低于定时器中断,可能被长时间阻塞。
4.3 倒计时到 0 后不切换状态,卡在红灯
现象:倒计时显示 0 后一直不变,灯也不换。原因:g_countdown是uint16_t,减到 0 后再减会变成 65535,状态机判断条件写成了if (g_countdown == 0)而不是<= 0。解决:在减之前判断if (g_countdown > 0),或者用int16_t并判断<= 0。这个坑我见过至少五个人踩,血泪经验。
4.4 编译通过但下载后灯全亮或全灭
现象:程序下载进去后所有 LED 常亮或常灭,不受控制。原因:GPIO 模式配错,推挽输出配成了开漏,或者时钟没使能。解决:检查RCC_APB2PeriphClockCmd是否使能了对应 GPIO 端口;LED 驱动方式如果是灌电流(低电平亮),初始化要设为GPIO_Mode_Out_PP并默认输出高电平。另外确认开发板 LED 的限流电阻是否焊好,有些板子 LED 是共阳接法,低电平亮。
4.5 用 HAL 库时HAL_Delay在中断里卡死
现象:在定时器中断里调用HAL_Delay导致程序卡死。原因:HAL_Delay依赖SysTick中断,而SysTick优先级默认最低,在定时器中断里调用会等待SysTick计数,但SysTick被当前中断屏蔽,形成死锁。解决:中断里不要用HAL_Delay,用简单的for循环延时,或者自己写一个基于DWT的微秒延时。标准库的delay_ms如果是自己写的循环,同样要注意中断里不要延时过长。
5. 从能跑到能讲:PPT 与文档的整理技巧
5.1 把状态迁移画成表格,比画流程图更清楚
很多人做 PPT 喜欢画流程图,但交通灯的状态迁移用表格更直观。下面这张表可以直接放进文档里,评审老师或面试官一眼就能看懂你的设计逻辑。
| 当前状态 | 东西方向 | 南北方向 | 持续时间 | 下一状态 |
|---|---|---|---|---|
| S0 | 绿灯 | 红灯 | 10s | S1 |
| S1 | 黄灯 | 红灯 | 3s | S2 |
| S2 | 红灯 | 绿灯 | 10s | S3 |
| S3 | 红灯 | 黄灯 | 3s | S0 |
表格说明:每一行对应一个状态,持续时间由g_countdown控制,下一状态在倒计时归零时切换。紧急模式下所有方向红灯,表格里可以单独加一行说明。这种表格比流程图更容易在 PPT 里排版,也方便你在答辩时指着讲。
5.2 文档里要写清楚“为什么选这个定时器”
文档不要只贴代码,要写选型理由。比如为什么用 TIM2 而不是 SysTick 做 1ms 基准?因为 SysTick 通常被 HAL 库占用做HAL_Delay,再用它做交通灯倒计时会冲突。为什么用状态机而不是switch嵌套delay?因为状态机不阻塞主循环,按键和数码管才能及时响应。这些理由写进文档,你的项目就从“抄的”变成“设计的”。
5.3 一个验证技巧:用串口打印状态迁移
调试交通灯时,最直接的方法是每切换一次状态就往串口发一行信息。这样你不用盯着灯看,打开串口助手就能确认状态机有没有跑飞。
// 在状态迁移后调用 printf("State: %d, Countdown: %d\r\n", g_state, g_countdown);逻辑说明:printf需要重定向fputc到串口,标准库工程里加#define PUTCHAR_PROTOTYPE int fputc(int ch, FILE *f)并实现发送即可。参数说明:波特率设 115200,串口助手选对应 COM 口。如果打印乱码,检查晶振和波特率是否匹配。这个技巧我每次调状态机都用,比逻辑分析仪还快。
5.4 最后说一个习惯:先跑通最小系统再加功能
我刚开始做交通灯时,总想一次性把数码管、按键、串口全加上,结果一个地方出错就要查半天。后来我养成了一个习惯:先让两个 LED 轮流闪,确认 GPIO 和定时器正常;再加数码管,确认扫描不闪;再加按键,确认中断能进;最后加串口打印。每一步只改一个变量,出错时范围就很小。这个习惯帮我省了无数个通宵,也希望帮到你。
本文还有配套的精品资源,点击获取