首页 / 资讯中心 / 文章详情

Keil STM32模拟交通灯项目:GPIO与定时器中断实战

Keil STM32模拟交通灯项目:GPIO与定时器中断实战 ★ FEATURED ARTICLE
简介这份资源是面向嵌入式初学者与高校课程设计学生的STM32模拟交通灯项目完整资料包基于Keil μVision开发环境用C语言实现红绿黄灯定时切换帮助读者掌握GPIO控制、定时器中断与状态机设计等核心技能。压缩包共199个文件约5.82MB包含31个C源文件、33个头文件及大量编译中间文件另有1份Word设计文档、1份PPT演示文稿和若干说明文本覆盖从源码到答辩展示的完整链路。目前已有4202人学习下载热度较高。读者可借助源码理解STM32外设初始化与程序烧录流程通过文档梳理硬件连接与设计思路配合PPT快速完成项目汇报适合作为操作系统或电子工程相关课程的实践参考也便于自学者动手复现并加深对单片机编程的理解。1. 从一份 keil 基于 stm32 的模拟交通灯项目源码说起为什么它值得你花一个周末很多人第一次接触 STM32 的 GPIO 和定时器都是从交通灯开始的。这个标题里的东西很朴素——一份 Keil 工程、一套 STM32 的模拟交通灯源码外加文档和 PPT。但恰恰是这种“麻雀虽小五脏俱全”的项目能把 GPIO 输出、定时器中断、状态机、按键输入、甚至串口调试全部串一遍。你如果正在找第一个能写进简历、能在答辩时讲清楚、能自己动手改参数的嵌入式小项目这个方向比点灯和串口打印有意思得多。它解决的不是“让灯亮”而是“让灯按规则亮、按时间切换、按按键响应”这三件事分别对应输出控制、时间基准和外部事件是嵌入式开发最核心的三根柱子。适合谁适合刚学完 51 单片机想过渡到 STM32 的人适合课程设计需要交东西的人也适合工作里要快速验证一个状态机逻辑的工程师。下面我把这个项目从工程搭建到代码落地拆开讲参数怎么设、坑在哪都按我实际做过的路子来。2. 用 Keil 建 STM32 交通灯工程从新建工程到 GPIO 配置的完整路径2.1 为什么选标准外设库而不是 HAL 或寄存器做这个模拟交通灯常见做法有三种直接写寄存器、用标准外设库、用 HAL 库。我一般会选标准外设库原因很实际。寄存器方式对新手不友好一个 GPIO 配置要翻手册算位偏移调试时容易怀疑人生。HAL 库抽象层太厚交通灯这种逻辑简单的项目用 HAL 反而要引入一堆回调函数和句柄结构代码量上去了但你对底层时序的理解没跟上。标准外设库处在中间函数名直观比如GPIO_Init、RCC_APB2PeriphClockCmd同时你还能看到寄存器操作的影子。对于交通灯这种需要精确控制切换时间的场景标准库的定时器配置也更直接不用绕 HAL 的时基。选型定下来之后工程结构要清晰。我习惯把代码分成三层硬件层放 GPIO 和定时器的初始化逻辑层放交通灯状态机应用层放主循环和按键扫描。这样后面改灯的数量或者切换时间只动逻辑层就行不用满工程找引脚定义。2.2 新建工程与时钟配置的具体步骤打开 Keil新建工程选好芯片型号。这里有个细节如果你用的是某款常见的 STM32F103 系列芯片工程建好后第一件事是改时钟。默认内部时钟是 8MHz但交通灯定时器如果按 8MHz 算预分频值会很大不方便调试。我一般把系统时钟配到 72MHz这样定时器分频更灵活。具体操作在工程里添加启动文件然后在system_stm32f10x.c里确认SYSCLK_FREQ_72MHz宏是打开的。如果你用的是标准库模板这一步通常已经做好了。接着在main.c里调用RCC_APB2PeriphClockCmd打开 GPIO 和定时器时钟。注意GPIO 挂在 APB2 上定时器有的在 APB1 有的在 APB2开错总线时钟是新手最常见的翻车点——灯不亮查半天代码最后发现是时钟没开。// 打开 GPIOB 和 GPIOA 的时钟以及 TIM3 的时钟 // 注意TIM3 挂在 APB1 上但标准库函数名统一用 APB1Periph RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB | RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);这段代码里RCC_APB2Periph_GPIOB和RCC_APB2Periph_GPIOA是位掩码用按位或可以一次开两个端口。RCC_APB1Periph_TIM3单独一行是因为 TIM3 在 APB1 总线上函数名不同。参数说明ENABLE就是使能对应寄存器置位。逻辑上时钟没开后面配置再对引脚也不会有反应。2.3 GPIO 初始化推挽输出与上下拉的选择交通灯用到的引脚分两类输出引脚接 LED输入引脚接按键。输出引脚配成推挽输出速度选 50MHz 还是 2MHz 都行交通灯切换频率低2MHz 足够还能减少一点噪声。输入引脚接按键我一般配成上拉输入这样按键另一端接地按下时读到低电平松开是高电平逻辑直观。GPIO_InitTypeDef GPIO_InitStructure; // 配置 LED 输出引脚PB0、PB1、PB2 分别接红黄绿 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; // 推挽输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_2MHz; GPIO_Init(GPIOB, GPIO_InitStructure); // 配置按键输入引脚PA0 接按键上拉输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; // 上拉输入 GPIO_Init(GPIOA, GPIO_InitStructure);这里GPIO_Mode_Out_PP是推挽输出能输出强高低电平直接驱动 LED 没问题。GPIO_Mode_IPU是上拉输入内部上拉电阻打开省掉外部电阻。参数GPIO_Speed_2MHz对交通灯足够如果你后面要加 PWM 调光可以改成 50MHz。逻辑说明先配置结构体再调用GPIO_Init把参数写进寄存器。注意结构体是复用的第二次配置前只改了GPIO_Pin和GPIO_ModeGPIO_Speed没改但输入模式下速度参数无效所以不影响。3. 定时器中断驱动状态机让红绿灯按秒切换的核心逻辑3.1 用 TIM3 做 1 秒基准的配置与计算交通灯的核心是时间。红灯亮 30 秒、绿灯亮 25 秒、黄灯亮 3 秒这些时间不能靠delay死等否则按键按下去没反应。正确做法是用定时器中断产生固定时间基准比如每 1 秒进一次中断在中断里减计数减到零就切换状态。TIM3 挂在 APB1 上时钟频率是 72MHz。要得到 1 秒中断需要设置预分频器和自动重装载值。公式是中断频率 72MHz / ((预分频1) * (重装载1))。我一般把预分频设成 7199这样定时器时钟变成 72MHz / 7200 10kHz。然后重装载值设成 9999中断频率就是 10kHz / 10000 1Hz正好 1 秒。TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; NVIC_InitTypeDef NVIC_InitStructure; // 定时器时钟 72MHz预分频 7199 得到 10kHz TIM_TimeBaseStructure.TIM_Period 9999; // 自动重装载值 TIM_TimeBaseStructure.TIM_Prescaler 7199; // 预分频值 TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, TIM_TimeBaseStructure); // 使能更新中断 TIM_ITConfig(TIM3, TIM_IT_Update, ENABLE); // 配置 NVIC NVIC_InitStructure.NVIC_IRQChannel TIM3_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 0; NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); // 启动定时器 TIM_Cmd(TIM3, ENABLE);参数说明TIM_Period是自动重装载值计数器数到 9999 后溢出产生中断。TIM_Prescaler是预分频实际分频系数是 719917200。TIM_CounterMode_Up是向上计数。NVIC 里PreemptionPriority设 0 是最高抢占优先级交通灯对时间敏感但也没到硬实时程度设 0 或 1 都行。逻辑上这段代码配置好定时器后每 1 秒会进一次TIM3_IRQHandler。3.2 中断服务函数里的状态机写法状态机是交通灯的灵魂。我见过不少同学用一堆if-else堆在主循环里结果按键一按就卡住。正确做法是把状态切换放在中断里主循环只负责按键扫描和状态更新。状态定义用枚举红灯、红灯转绿灯、绿灯、绿灯转黄灯、黄灯。每个状态有个倒计时变量中断里减 1减到 0 就切下一个状态。注意中断里不要做耗时操作比如刷新 LCD 或者打印串口那些放主循环。typedef enum { STATE_RED, STATE_RED_GREEN, // 红绿过渡实际就是红灯最后几秒 STATE_GREEN, STATE_GREEN_YELLOW, STATE_YELLOW } TrafficState; volatile TrafficState currentState STATE_RED; volatile uint16_t stateCounter 30; // 红灯 30 秒 void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); if (stateCounter 0) { stateCounter--; } else { // 状态切换 switch (currentState) { case STATE_RED: currentState STATE_GREEN; stateCounter 25; break; case STATE_GREEN: currentState STATE_YELLOW; stateCounter 3; break; case STATE_YELLOW: currentState STATE_RED; stateCounter 30; break; default: currentState STATE_RED; stateCounter 30; break; } } } }这段代码里volatile关键字很重要因为currentState和stateCounter在中断和主循环里都会被访问不加volatile编译器可能优化掉。TIM_GetITStatus判断是不是更新中断TIM_ClearITPendingBit清标志位这两步顺序不能反否则会反复进中断。状态切换逻辑简单直接每个状态设好下一个状态和倒计时。注意STATE_RED_GREEN这个状态我定义了但没用到实际项目里可以用来做红灯最后 3 秒闪烁这里留个扩展点。3.3 主循环里怎么根据状态刷新 LED中断只管改状态和倒计时LED 刷新放主循环。这样中断执行时间短不会影响其他中断响应。主循环里用switch根据currentState设置 GPIO 引脚高低电平。while (1) { switch (currentState) { case STATE_RED: GPIO_SetBits(GPIOB, GPIO_Pin_0); // 红灯亮 GPIO_ResetBits(GPIOB, GPIO_Pin_1 | GPIO_Pin_2); // 黄绿灭 break; case STATE_GREEN: GPIO_SetBits(GPIOB, GPIO_Pin_2); // 绿灯亮 GPIO_ResetBits(GPIOB, GPIO_Pin_0 | GPIO_Pin_1); break; case STATE_YELLOW: GPIO_SetBits(GPIOB, GPIO_Pin_1); // 黄灯亮 GPIO_ResetBits(GPIOB, GPIO_Pin_0 | GPIO_Pin_2); break; default: break; } // 按键扫描后面章节讲 KeyScan(); }GPIO_SetBits把对应引脚置高GPIO_ResetBits置低。这里用按位或一次操作多个引脚减少函数调用次数。逻辑上主循环不断轮询状态状态变了灯就变。注意如果 LED 是低电平点亮那SetBits和ResetBits要反过来这个看硬件原理图别抄代码不看电路。4. 按键输入与状态强制切换外部事件怎么打断自动流程4.1 按键消抖的两种做法与选择交通灯项目里按键通常有两个作用手动切换状态、紧急模式。按键必须消抖否则按一次会触发多次。消抖分硬件和软件硬件加电容软件用延时或定时器。我一般用软件消抖在定时器中断里每 10ms 扫描一次按键连续两次读到相同状态才确认。但这里有个冲突TIM3 已经用来做 1 秒基准了再开一个定时器做 10ms 扫描有点浪费。常见做法是在 1 秒中断里做按键扫描但 1 秒太慢按键按下去要等 1 秒才响应体验差。折中方案用 TIM3 的更新中断但把预分频改小比如 10ms 中断一次然后在中断里维护一个计数器数到 100 就是 1 秒。这样按键扫描和状态计时共用一个定时器。// 修改定时器配置10ms 中断一次 TIM_TimeBaseStructure.TIM_Period 999; // 10kHz / 1000 10Hz即 100ms // 等等10kHz / 1000 10Hz 是 100ms不是 10ms // 要 10ms 中断10kHz / 100 100Hz重装载值设 99 TIM_TimeBaseStructure.TIM_Period 99; // 10kHz / 100 100Hz10ms TIM_TimeBaseStructure.TIM_Prescaler 7199; // 保持 10kHz上面注释里我故意留了个计算过程因为这是最容易搞错的地方。预分频 7199 得到 10kHz重装载值 99 得到 100Hz也就是 10ms 一次中断。然后在中断里加个msCounter每 100 次就是 1 秒。4.2 按键扫描与状态强制切换的实现按键接在 PA0上拉输入按下为低电平。在 10ms 中断里扫描用状态机消抖。volatile uint8_t keyPressed 0; static uint8_t keyLastState 1; // 上次状态1 为松开 static uint8_t keyDebounceCnt 0; void KeyScanInTimer(void) { uint8_t currentKey GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0); if (currentKey ! keyLastState) { keyDebounceCnt; if (keyDebounceCnt 2) { // 连续两次相同20ms 消抖 keyLastState currentKey; keyDebounceCnt 0; if (currentKey 0) { // 按下 keyPressed 1; } } } else { keyDebounceCnt 0; } }这段代码在 10ms 中断里调用keyDebounceCnt 2表示连续 20ms 读到相同状态才确认。keyPressed标志位在主循环里处理处理完清零。逻辑说明消抖不是一次判断而是多次采样。参数 2 可以根据按键质量调整差按键可以设 3 或 4。主循环里检测keyPressed如果按下就强制切换状态。比如当前红灯按下后直接跳绿灯倒计时重置。if (keyPressed) { keyPressed 0; // 强制切换到下一个状态 switch (currentState) { case STATE_RED: currentState STATE_GREEN; stateCounter 25; break; case STATE_GREEN: currentState STATE_YELLOW; stateCounter 3; break; case STATE_YELLOW: currentState STATE_RED; stateCounter 30; break; default: break; } }注意这里改currentState和stateCounter时中断可能也在改所以要么关中断要么保证操作原子性。简单做法是在主循环里改之前先__disable_irq()改完__enable_irq()。但更优雅的是用标志位让中断去改状态主循环只发请求。5. 避坑与排查交通灯项目里最容易翻车的 5 个地方5.1 灯不亮但代码看着没问题现象下载程序后 LED 完全不亮用万用表测引脚有电压变化但灯就是不亮。原因LED 极性接反或者限流电阻太大。解决先确认 LED 正负极长脚是正极短脚是负极。如果引脚输出高电平灯亮那 LED 正极接引脚负极接地。如果输出低电平灯亮那 LED 负极接引脚正极接电源。限流电阻一般 220Ω 到 1kΩ太大电流不够太小烧 LED。5.2 定时器中断进不去现象程序卡在主循环状态不切换调试发现中断服务函数没执行。原因NVIC 配置漏了或者中断标志没清。解决检查NVIC_Init是否调用TIM_ITConfig是否使能更新中断TIM_Cmd是否启动定时器。还有一个隐蔽原因中断服务函数名写错比如写成TIM3_IRQHandler但启动文件里是TIM3_IRQHandler大小写或拼写不对编译器不报错但中断向量表对不上。5.3 按键按一次跳好几个状态现象按一下按键状态连续切换好几次。原因消抖没做好或者keyPressed标志没及时清零。解决增加消抖次数从 2 改成 4。检查主循环里keyPressed 0是否在状态切换之前执行。如果状态切换代码耗时较长可以在切换前先清零标志。5.4 倒计时不准越走越慢现象红绿灯切换时间比设定的长比如设 30 秒实际走了 35 秒。原因中断里做了耗时操作比如串口打印导致中断响应延迟。解决中断里只做计数和状态切换打印放主循环。另外检查系统时钟是否真的配到 72MHz如果还是默认 8MHz定时器计算全错。5.5 编译通过但下载后没反应现象Keil 编译无错误下载成功但板子没反应。原因启动模式不对或者复位电路问题。解决检查 BOOT0 和 BOOT1 引脚通常都接 GND 从 Flash 启动。按一下复位键。如果用的是某款最小系统板检查供电是否稳定USB 供电电流不够会导致芯片反复复位。6. 从模拟交通灯到多路口联动一个可扩展的验证方法这个项目做完基础版之后最值得花时间的是把它扩展成多路口联动。两个路口一个方向车多一个方向车少怎么配时这就从状态机变成了调度问题。我一般会先在单路口代码上抽象出一个TrafficLight结构体把状态、倒计时、引脚组都封进去然后开两个实例用不同的定时器或者同一个定时器分时调度。typedef struct { TrafficState state; uint16_t counter; GPIO_TypeDef* port; uint16_t redPin; uint16_t yellowPin; uint16_t greenPin; } TrafficLight; TrafficLight lightA {STATE_RED, 30, GPIOB, GPIO_Pin_0, GPIO_Pin_1, GPIO_Pin_2}; TrafficLight lightB {STATE_GREEN, 25, GPIOB, GPIO_Pin_3, GPIO_Pin_4, GPIO_Pin_5};结构体封装后刷新函数和状态切换函数都接收TrafficLight*参数代码复用率上去了。验证方法用串口打印每个路口的剩余时间观察两个路口是否互锁——一个红灯时另一个必须绿灯不能同时绿。如果同时绿说明状态机有竞争需要加互斥逻辑。我自己的习惯是每改一次配时参数就用秒表掐一次实际切换时间连续测 10 个周期误差超过 5% 就回去查定时器配置。这个笨办法帮我抓过好几次预分频算错的问题。交通灯项目看着简单但把时间精度、按键响应、状态互锁都做对对嵌入式基本功的锻炼比想象中大。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站