ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32F103C8T6智能红绿灯实战:状态机与定时器中断设计

STM32F103C8T6智能红绿灯实战:状态机与定时器中断设计 STM32F103C8T6最近在课程设计和DIY圈子里真的是太常见了无论你是电子专业的本科生、准备做毕设的工程师还是刚入手最小系统板的自学党大概率都会在某个阶段想用它做一个和“交通”“控制”“状态机”相关的项目。而我今天要聊的这套智能红绿灯正好就是这类项目里最经典、也最能串起STM32核心知识点的一个GPIO、定时器中断、按键扫描、状态机切换、外设显示一个都不少做完之后你会对整个单片机开发的套路有完全不一样的感觉。这套系统我用F103C8T6最小系统板实测验证过配套的完整代码可以直接拿去过编译下载不需要额外的付费库也不需要多么复杂的电路。它能实现的东西也远不止“红绿灯交替闪烁”主干道和支干道自动按时间轮换、黄灯过渡、行人按钮申请过街、交警手动干预模式、倒计时显示这些功能放在一起才真正配得上“智能”两个字。如果你是正在找课程设计题目或者刚学完点灯实验想往前再走一步这篇从思路到接线再到完整代码和坑位的实操记录可以直接当作你的项目模板来用。1. 项目概述与整体设计思路1.1 这个智能红绿灯到底解决了什么问题很多人刚听到“红绿灯”三个字会觉得这不就是个LED按顺序闪吗有什么可做的。但你把场景换成真实的十字路口就会发现事情没那么简单。两个方向的车流不能同时放行主路和支路放行时间要不一样红灯结束之前必须有黄灯过渡行人要过马路得能给系统发请求交警来了还得有办法手动干预。这一连串需求堆在一起整个控制逻辑就不再是“延时然后翻转”的玩具级代码了。这个项目本质上做的是一套“多状态自动调度系统”。系统内部维护一个当前状态每隔一段固定时间根据状态转移规则自动切换到下一个状态。状态和状态之间不能直接乱跳比如主干道绿灯结束之后必须先切黄灯再切支干道绿灯否则就违反真实交通规则了。还有状态切换的瞬间所有灯不能同时亮也不能瞬间全灭切换时序必须可控。这些都是真实工程里面非常典型的问题红绿灯只是它们的载体。做完这个项目你能带走的最有价值的东西不是“我会做红绿灯了”而是“我知道了怎么用状态机来管理一个流程”这个思路以后做密码锁、做温控系统、做智能门禁的时候会反反复复用上。1.2 为什么非要用STM32F103C8T6来做搜索“智能红绿灯”或者“单片机课设”的时候会看到有用51单片机做的版本那也是一个方案但和STM32做出来完全不是一个层次的东西。51单片机的红绿灯项目通常靠delay()硬延时跑流程代码写起来简单缺点也很明显延时期间CPU被占死按键按下去没反应显示刷新会卡顿稍微一复杂就无从下手。F103C8T6有72MHz主频、20KB RAM、64KB Flash关键是有完整的定时器中断系统和足够多的GPIO可以让我们用完全不一样的方式来组织代码主循环只负责扫描按键和刷新状态时间控制全部交给定时器中断系统始终不阻塞。现在的F103C8T6最小系统板价格也压得很低而且市面上大量国产兼容芯片GD32F103、APM32F103等在引脚和寄存器层面都能直接替代资料互通买不到原厂芯片也完全不影响学习和开发。这也是我在这个项目里推荐它的重要原因之一生态太成熟了任何你遇到的问题基本都能在网上找到别人踩过的坑和解决方案。1.3 系统整体框架和三种“智能”设计我设计的这套红绿灯系统不是简单的“双色对闪”而是用四个核心模块撑起了整个功能框架。第一个模块是自动调度模块。系统默认运行在自动模式下主干道绿灯持续10秒然后黄灯2秒再切换为支干道绿灯8秒和支干道黄灯2秒如此循环。这套时间参数完全可以按实际场景调整我在代码里用宏定义将它们集中管理。第二个模块是行人申请模块。在主干道绿灯期间如果行人按下过街按钮系统会在当前绿灯周期结束后插入一个“行人过街状态”两个方向全部红灯持续4秒方便行人通过。这个功能实际上就是真实交通信号控制系统里的“行人相位”虽然我们用的是按键模拟但逻辑完全一致。第三个模块是手动干预模块。按下模式切换键后系统进入手动模式不再自动切换状态由用户通过按键手动控制到指定状态。这一功能模拟的是交通警察在路口进行手动指挥的场景。第四个模块是状态显示模块。通过I2C接口的OLED屏幕实时显示当前状态和剩余倒计时秒数让运行过程不再是“盲调”开发调试的时候信息一目了然。2. 硬件准备与接线方法2.1 元器件清单这套系统需要用到的硬件并不复杂我列一个实际采购时可以照抄的清单。这里所有器件加起来成本很低而且大部分是可以重复利用的基础模块。器件名称规格/型号数量作用最小系统板STM32F103C8T6蓝色板1块主控LED发光二极管红色2个主干道、支干道红灯LED发光二极管黄色2个主干道、支干道黄灯LED发光二极管绿色2个主干道、支干道绿灯电阻220Ω或330Ω6个LED限流轻触按键6mm或12mm2个模式切换、行人过街有源蜂鸣器模块低电平触发1个状态切换提示OLED模块0.96寸 I2C SSD13061个可选显示倒计时和状态面包板400孔或830孔1块搭建电路杜邦线公对公、公对母若干连接线路USB转TTL模块CH3401个程序下载与调试电阻的阻值选择我后面会在避坑指南里详细算一笔账这里先记住220Ω到470Ω都是安全的不要用100Ω以下的小阻值也不要完全不加电阻。2.2 引脚分配与接线说明引脚分配是这类项目里最容易被忽略但最容易翻车的一步。我最终采用的分配方案如下已经在实际工程中验证过可以直接照着连功能引脚说明主干道红灯PA0GPIO推挽输出主干道黄灯PA1GPIO推挽输出主干道绿灯PA2GPIO推挽输出支干道红灯PA3GPIO推挽输出支干道黄灯PA4GPIO推挽输出支干道绿灯PA5GPIO推挽输出模式切换按键PA6内部上拉输入按键接GND行人过街按键PA7内部上拉输入按键接GND蜂鸣器PB0GPIO推挽输出OLED SCLPB6I2C1时钟OLED SDAPB7I2C1数据为什么这么选引脚我重点避开了几个容易出问题的位置PB2默认是BOOT1引脚PB3、PB4默认是JTAG调试引脚PA11和PA12默认是USB引脚这些引脚如果没有经过特殊配置作为普通GPIO用的时候会有各种意外问题。新手最容易踩的坑就是把LED接到了PB3上结果怎么都无法点亮。PA0到PA7和PB0、PB6、PB7都是安全区域可以放心当作普通IO使用而且PA0到PA7在底层寄存器操作时还能享受到连续端口的便利代码写起来简洁很多。接线的时候LED的正极接对应的GPIO引脚负极串接一个220Ω电阻后接GND。按键一端接GND另一端接对应的GPIO引脚利用芯片内部上拉电阻平时引脚读到高电平按下时读到低电平。OLED模块的四根线分别是VCC接3.3V、GND接GND、SCL接PB6、SDA接PB7需要注意部分OLED模块的VCC可以直接接5V但为了和单片机逻辑电平统一我建议都接3.3V。2.3 面包板实操蓝板怎么插进去很多刚接触最小系统板的朋友都会问F103C8T6的开发板应该怎么装到面包板上这个疑问非常实际。蓝色板子是双排排针宽度正好跨在面包板中间的凹槽上把板子轻轻按压下去让两排排针分别插入凹槽两侧的插孔里就可以了。插入之前务必确认方向所有排针对准后再用力否则很容易把针脚压弯。布局方面我有一个习惯把LED集中放在面包板左半区按键和蜂鸣器放在右半区OLED通过杜邦线悬浮连接。这样做的原因是LED的接线最多放在一侧可以减少跳线交叉出现问题时排查也更快。供电部分也建议做一个简单的“电源岛”用红色杜邦线把3.3V引出到面包板最外侧的电源轨用黑色杜邦线把GND引出到另一侧的电源轨之后所有模块的电源都从导轨上取避免从板子上乱飞电源线。有条件的话建议全程用万用表的二极管蜂鸣档测一下接触是否良好尤其是GND线路。嵌入式开发里很多奇怪的问题追到最后都是线松了或者虚接提前做好物理连接检查能省下一大把调试时间。3. 核心代码思路状态机、定时器、按键消抖3.1 开发环境与工程搭建代码开发我选择的是Keil MDK5配合标准外设库为什么不用HAL库最主要的原因是标准库在国内的教程生态实在太庞大了绝大多数STM32初学者的第一个工程都是基于标准库搭起来的用它做教学项目大家理解起来最快。F103C8T6也并不是一个特别复杂的芯片标准库的代码量完全可控而且执行效率比HAL库高寄存器访问的逻辑也更透明。工程搭建步骤我在之前的文章里详细写过这里只说关键点新建一个空工程后需要添加标准外设库里的核心文件至少包含core_cm3.c、system_stm32f10x.c以及需要用到的外设源文件比如stm32f10x_gpio.c、stm32f10x_rcc.c、stm32f10x_i2c.c。另外必须把stm32f10x_conf.h这个头文件包含进工程并做相应配置。如果这些概念听起来有点陌生说明你对标准库的工程结构还不够熟可以先找“STM32标准库新建工程”之类的教程走一遍流程再回来做这个项目就顺了。芯片型号在工程配置里要选择STM32F103C8。有人说既然要追求现代化不如直接用STM32CubeMX自动生成代码。这当然也是个好路子用CubeMX配置好引脚后自动生成HAL库代码逻辑清晰还好维护。不过考虑到这个项目里状态机才是重点我不希望读者把时间花在理解IDE自动生成的初始化流程上所以下面的代码我以标准库手写的方式呈现逻辑更明确、更适合手工复现。3.2 状态机设计红绿灯的灵魂红绿灯的核心控制逻辑根本不是“怎么让灯亮”而是“什么时候让哪几颗灯亮以及如何安全地切换”。我画过一条很直白的转移规则主干道绿灯结束后进入主干道黄灯黄灯结束后进入支干道绿灯支干道绿灯结束后进入支干道黄灯然后回到主干道绿灯如此往复。行人按钮按下时如果当前是主干道绿灯就插入一个全红状态持续结束后回到主干道绿灯。我用一个枚举类型来定义所有状态typedef enum { ST_MAIN_GREEN, // 主干道绿灯 支干道红灯 ST_MAIN_YELLOW, // 主干道黄灯 支干道红灯 ST_BRANCH_GREEN, // 支干道绿灯 主干道红灯 ST_BRANCH_YELLOW, // 支干道黄灯 主干道红灯 ST_PED_CROSSING // 行人过街双向全红 } TrafficState;状态切换函数是这个项目中最关键的函数它负责完成三件事关闭上一状态的灯、点亮下一状态的灯、记录新状态的起始时间。这里有一个特别重要的设计细节我在网上见过不少代码直接往端口写新状态的值看起来没问题但如果在切换瞬间出现电源干扰或者代码被中断打断就可能导致红绿同时亮的尴尬场面。我在代码里采用“先全部关闭再打开指定灯”的顺序从硬件行为上杜绝了这种可能。状态时长的管理也很讲究。我不推荐用delay()来做状态等待因为一旦进入延时整个CPU都被锁死按键扫描和OLED刷新全部停下来体验非常差。正确的做法是把状态切换的判断放到主循环里每次循环查询当前时间用当前时间和状态起始时间的差值决定是否需要切换。这套思路叫“非阻塞延时”是嵌入式开发中极其重要的编程习惯。3.3 定时器SysTick的1ms心跳整个系统需要一个稳定可靠的时钟节拍这是所有时间控制的基础。F103C8T6内核自带SysTick定时器配置成每1ms触发一次中断再合适不过。SysTick的配置只需要一行核心代码SysTick_Config(SystemCoreClock / 1000)。这一行的意思是把SysTick的计数频率设置为系统主频除以1000也就是每1ms溢出一次溢出后自动进入SysTick_Handler中断服务函数。在中断函数里我们只需要做一个动作把一个全局变量累加1volatile uint32_t g_msTicks 0; void SysTick_Handler(void) { g_msTicks; }有了这个g_msTicks变量整个系统就有了统一的时间基准。任何地方需要延时或者计时直接用“当前值减去起始值”的方式来计算就能做到既不阻塞CPU又能精确计时。这也是整个项目中最重要的编程范式之一。需要特别说明一下g_msTicks是volatile类型这个关键字不能省。它告诉编译器这个变量可能会被中断函数修改不要优化到寄存器里去每次访问都必须从内存地址读取否则主循环里可能死活看不到变量更新。3.4 按键扫描与消抖软件防抖是必须的轻触按键在按下和释放的瞬间由于金属弹片的机械抖动会产生一串高频脉冲时间大约在5到20毫秒。如果不处理这个抖动系统可能把一次按键误判成多次触发模式切换按钮按一下就会在自动和手动模式之间来回跳好几次直接导致整个系统失控。硬件上可以加RC滤波电路解决抖动但我们在面包板上做实验最简单的方案是软件消抖。我的做法很朴素检测到按键电平变化后不立即响应而是等待50毫秒再确认一次状态如果确认按下才执行相应操作。因为检测到按下后要记录时间戳所以这个消抖过程和整个系统的非阻塞思路是一致的。3.5 OLED显示模块让调试不再摸黑如果你只满足于看LED闪那OLED确实可以不接。但我要强烈建议加装一个因为当你调试状态机的时候单凭眼睛观察LED很难判断当前系统卡在哪个状态而一块OLED屏幕直接显示“当前状态剩余秒数”后整个系统的运行轨迹变得一目了然。OLED模块选用0.96寸SSD1306驱动I2C接口只需两根数据线接线极其简单。代码方面不需要自己从零写驱动网上有大量免费的SSD1306库选择支持I2C模式的即可。初始化的代码和显示字符串的函数基本是现成的我们要做的只是在主循环里把状态和倒计时转换成字符串然后调用显示函数刷新屏幕。需要注意刷新频率不要过高10Hz左右的刷新频率对OLED来说已经非常流畅过高的刷新反而白占CPU资源。4. 完整实战代码可以直接抄的main.c这一节给出整个项目的核心代码。为了便于理解我把代码写得相对完整只要你的工程里已经有了标准外设库将下面的内容替换到main.c中基本就能编译通过。OLED的驱动部分因为库文件较长没有完全贴出来我在代码里给出了调用的接口说明你可以使用任意一款SSD1306的I2C驱动替代。#include stm32f10x.h #include oled.h // OLED驱动头文件假设提供 OLED_Init/OLED_ShowString/OLED_ShowNum 接口 // ---------------- 引脚定义 ---------------- #define LED_MAIN_PORT GPIOA #define LED_MAIN_RED GPIO_Pin_0 #define LED_MAIN_YELLOW GPIO_Pin_1 #define LED_MAIN_GREEN GPIO_Pin_2 #define LED_BRANCH_PORT GPIOA #define LED_BRANCH_RED GPIO_Pin_3 #define LED_BRANCH_YELLOW GPIO_Pin_4 #define LED_BRANCH_GREEN GPIO_Pin_5 #define KEY_PORT GPIOA #define KEY_MODE GPIO_Pin_6 #define KEY_PED GPIO_Pin_7 #define BEEP_PORT GPIOB #define BEEP_PIN GPIO_Pin_0 // ---------------- 状态时长配置单位毫秒 ---------------- #define MAIN_GREEN_TIME 10000 #define MAIN_YELLOW_TIME 2000 #define BRANCH_GREEN_TIME 8000 #define BRANCH_YELLOW_TIME 2000 #define PED_CROSSING_TIME 4000 // ---------------- 状态枚举 ---------------- typedef enum { ST_MAIN_GREEN, ST_MAIN_YELLOW, ST_BRANCH_GREEN, ST_BRANCH_YELLOW, ST_PED_CROSSING } TrafficState; // ---------------- 全局变量 ---------------- static volatile uint32_t g_msTicks 0; static TrafficState g_state ST_MAIN_GREEN; static uint32_t g_stateStartTick 0; static uint8_t g_manualMode 0; // ---------------- 函数声明 ---------------- void DelayMsNonBlock(uint32_t ms); void GPIOConfig(void); void SysTickConfig(void); void SwitchState(TrafficState newState); void StateUpdate(void); void KeyScan(void); void DisplayUpdate(void); void BeeperBeep(uint16_t durationMs); // SysTick中断服务函数1ms心跳 void SysTick_Handler(void) { g_msTicks; } // 非阻塞延时 void DelayMsNonBlock(uint32_t ms) { uint32_t start g_msTicks; while ((g_msTicks - start) ms) { // 空转等待 } } void GPIOConfig(void) { GPIO_InitTypeDef GPIO_InitStructure; // 使能GPIOA和GPIOB时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); // 两个方向的红黄绿LED全部配置为推挽输出 GPIO_InitStructure.GPIO_Pin LED_MAIN_RED | LED_MAIN_YELLOW | LED_MAIN_GREEN | LED_BRANCH_RED | LED_BRANCH_YELLOW | LED_BRANCH_GREEN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // 两个按键配置为带上拉输入 GPIO_InitStructure.GPIO_Pin KEY_MODE | KEY_PED; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // 蜂鸣器配置为推挽输出 GPIO_InitStructure.GPIO_Pin BEEP_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure); // 所有LED初始化为熄灭状态 LED_MAIN_PORT-BRR LED_MAIN_RED | LED_MAIN_YELLOW | LED_MAIN_GREEN; LED_BRANCH_PORT-BRR LED_BRANCH_RED | LED_BRANCH_YELLOW | LED_BRANCH_GREEN; } // 蜂鸣器短鸣 void BeeperBeep(uint16_t durationMs) { BEEP_PORT-BSRR BEEP_PIN; // 输出高电平 DelayMsNonBlock(durationMs); BEEP_PORT-BRR BEEP_PIN; // 输出低电平 } // 状态切换核心函数 void SwitchState(TrafficState newState) { uint8_t needBeep 1; // 先全部熄灭避免红绿同时亮 LED_MAIN_PORT-BRR LED_MAIN_RED | LED_MAIN_YELLOW | LED_MAIN_GREEN; LED_BRANCH_PORT-BRR LED_BRANCH_RED | LED_BRANCH_YELLOW | LED_BRANCH_GREEN; // 根据新状态点亮对应灯 switch (newState) { case ST_MAIN_GREEN: LED_MAIN_PORT-BSRR LED_MAIN_GREEN; LED_BRANCH_PORT-BSRR LED_BRANCH_RED; break; case ST_MAIN_YELLOW: LED_MAIN_PORT-BSRR LED_MAIN_YELLOW; LED_BRANCH_PORT-BSRR LED_BRANCH_RED; break; case ST_BRANCH_GREEN: LED_MAIN_PORT-BSRR LED_MAIN_RED; LED_BRANCH_PORT-BSRR LED_BRANCH_GREEN; break; case ST_BRANCH_YELLOW: LED_MAIN_PORT-BSRR LED_MAIN_RED; LED_BRANCH_PORT-BSRR LED_BRANCH_YELLOW; break; case ST_PED_CROSSING: LED_MAIN_PORT-BSRR LED_MAIN_RED; LED_BRANCH_PORT-BSRR LED_BRANCH_RED; break; default: needBeep 0; break; } g_state newState; g_stateStartTick g_msTicks; // 状态切换时蜂鸣器提示 if (needBeep) { BeeperBeep(60); } } // 自动状态机更新在主循环中调用 void StateUpdate(void) { if (g_manualMode) { return; // 手动模式下不自动切换 } uint32_t elapsed g_msTicks - g_stateStartTick; switch (g_state) { case ST_MAIN_GREEN: if (elapsed MAIN_GREEN_TIME) { SwitchState(ST_MAIN_YELLOW); } break; case ST_MAIN_YELLOW: if (elapsed MAIN_YELLOW_TIME) { SwitchState(ST_BRANCH_GREEN); } break; case ST_BRANCH_GREEN: if (elapsed BRANCH_GREEN_TIME) { SwitchState(ST_BRANCH_YELLOW); } break; case ST_BRANCH_YELLOW: if (elapsed BRANCH_YELLOW_TIME) { SwitchState(ST_MAIN_GREEN); } break; case ST_PED_CROSSING: if (elapsed PED_CROSSING_TIME) { SwitchState(ST_MAIN_GREEN); } break; default: break; } } // 按键扫描带软件消抖 void KeyScan(void) { static uint32_t lastModeTime 0; static uint32_t lastPedTime 0; // 模式切换按键 if (GPIO_ReadInputDataBit(KEY_PORT, KEY_MODE) Bit_RESET) { if ((g_msTicks - lastModeTime) 100) { g_manualMode !g_manualMode; lastModeTime g_msTicks; BeeperBeep(100); } } // 行人过街按键 if (GPIO_ReadInputDataBit(KEY_PORT, KEY_PED) Bit_RESET) { if ((g_msTicks - lastPedTime) 100) { // 自动模式下且主干道绿灯亮时允许插入行人过街 if (!g_manualMode g_state ST_MAIN_GREEN) { SwitchState(ST_PED_CROSSING); } lastPedTime g_msTicks; } } } // OLED显示更新显示当前状态和剩余秒数 void DisplayUpdate(void) { static uint32_t lastDisplayTime 0; if ((g_msTicks - lastDisplayTime) 100) { return; // 10Hz刷新 } lastDisplayTime g_msTicks; uint32_t elapsed g_msTicks - g_stateStartTick; uint32_t remainMs 0; const char* stateStr ; switch (g_state) { case ST_MAIN_GREEN: stateStr Main Green; remainMs MAIN_GREEN_TIME - (elapsed % MAIN_GREEN_TIME); break; case ST_MAIN_YELLOW: stateStr Main Yellow; remainMs MAIN_YELLOW_TIME - (elapsed % MAIN_YELLOW_TIME); break; case ST_BRANCH_GREEN: stateStr Branch Green; remainMs BRANCH_GREEN_TIME - (elapsed % BRANCH_GREEN_TIME); break; case ST_BRANCH_YELLOW: stateStr Branch Yellow; remainMs BRANCH_YELLOW_TIME - (elapsed % BRANCH_YELLOW_TIME); break; case ST_PED_CROSSING: stateStr Ped Crossing; remainMs PED_CROSSING_TIME - (elapsed % PED_CROSSING_TIME); break; default: break; } // 这里假设OLED驱动提供了以下两个基础接口 OLED_ShowString(1, 1, (uint8_t*)stateStr); OLED_ShowNum(3, 1, (remainMs 999) / 1000, 2); // 显示剩余秒数 } int main(void) { GPIOConfig(); SysTick_Config(SystemCoreClock / 1000); // 1ms中断 OLED_Init(); // 上电默认进入主干道绿灯 SwitchState(ST_MAIN_GREEN); while (1) { KeyScan(); // 按键扫描 StateUpdate(); // 状态机更新 DisplayUpdate(); // OLED显示刷新 } }代码中SysTick_Config会自动把SysTick中断使能并且设置好优先级所以你只需要在main里调用它就可以不需要额外的初始化代码。OLED驱动我引用了一个oled.h头文件你可以在网上找到任意一款标准库版SSD1306 I2C驱动只要它提供OLED_Init、OLED_ShowString和OLED_ShowNum这几个接口上面的代码就能正常运行。如果实在不想用OLED直接把OLED_Init、DisplayUpdate相关的调用删掉系统照样能跑只是少了状态和倒计时的可视化显示。5. 编译下载与运行验证5.1 Keil工程的关键配置当你把代码复制进工程之后有几个配置项必须检查一遍否则编译器很可能报一堆莫名其妙的错误警告。第一是芯片型号。在魔法棒Options for Target的Device选项卡里选择STMicroelectronics - STM32F103C8千万不能选成F103ZET6之类的大容量型号虽然它们同属F103系列但Flash和RAM地址范围不一样编译出来的目标文件在烧录时会出问题。第二是在C/C选项卡的Define一栏里定义USE_STDPERIPH_DRIVER这是标准外设库使用的编译开关。如果不加这个宏头文件里的函数声明会被条件编译拦掉调用GPIO库函数的地方全部会报未声明错误。第三是在Debug选项卡里配置好你的下载器。如果你用的是ST-Link选择ST-Link Debugger然后进入Settings把Port选为SW因为ST-Link默认可能是JTAG方式而大多数F103C8T6最小系统板只引出了SWD接口不匹配会提示找不到目标设备。如果你用串口下载那需要用FlyMcu之类的工具在Keil里就不需要做Debug相关配置了直接编译生成hex文件后用串口工具烧录。下载之前还要检查BOOT0跳线。串口下载时必须把BOOT0接到高电平烧录完成后再把BOOT0拉回低电平并复位系统才会从Flash启动运行。用ST-Link SWD下载则不需要动BOOT0这也是为什么我更推荐SWD方式。5.2 预期运行现象程序下载后先观察上电状态系统默认进入主干道绿灯此时主干道绿色LED点亮支干道红色LED点亮蜂鸣器短鸣一声OLED上显示Main Green和倒计时数值。10秒后主干道绿灯关闭黄灯亮起2秒后黄灯关闭支干道绿灯亮起同时主干道红灯点亮。这个切换过程里你会发现蜂鸣器在每次状态切换时都会响一下这是我在SwitchState里设置的提示音方便你在不看屏幕的情况下知道系统发生了状态变化。测试行人按钮时在主干道绿灯期间按下PA7对应的按键系统会马上进入Ped Crossing状态两个方向的红灯同时点亮持续4秒后回到主干道绿灯。注意这个功能只在主干道绿灯期间有效如果当前已经在黄灯或者支干道绿灯阶段按键不会生效这是代码里为了符合真实交通逻辑专门做的限定。最后测试模式切换键。按下PA6对应的按键后系统进入手动模式此时自动状态机会停止工作LED保持当前状态不再变化再次按下按键则恢复自动模式。手动模式下如果操作行人按键同样不会插入过街状态避免交警干预时出现意料之外的自动跳变。6. 避坑指南这些问题我全替你踩过了6.1 供电问题是最隐蔽的坑这是整个项目里我遇到的第一大坑没有之一。F103C8T6最小系统板可以通过USB口供电但很多USB转TTL模块的稳压芯片输出电流有限当板上同时挂着6个LED、一个蜂鸣器、一块OLED时3.3V电压会被明显拉低。最典型的现象是LED亮度突然变暗OLED屏幕花屏蜂鸣器声音发闷严重的时候单片机会直接复位重启。实测下来用USB接口单独给最小系统板供电再让整个系统从板载3.3V取电在LED全亮、蜂鸣器鸣叫的情况下会存在压降风险。稳妥的做法是使用外部独立的3.3V稳压模块给所有外设供电让最小系统板和外设电源分开。如果暂时手头没有独立稳压模块至少要把LED的驱动电流降低一些串接470Ω电阻而不是220Ω能明显缓解电源压力。还有一个容易被忽略的点USB转TTL模块输出的5V和3.3V不要同时接到系统里尤其注意不要用同一个电源模块给OLED的VCC接5V、给逻辑引脚接3.3V这种混接会通过OLED模块内部的保护二极管反灌电压时间长了可能损坏IO口。6.2 PB3和PB4默认不听话别用它们当普通IO无数新手第一次用F103C8T6做项目默认以为每个GPIO都可以随意使用于是把LED接到了PB3或者PB4上结果发现完全没有反应最后折腾半天才知道这两个引脚默认被JTAG占用。STM32F103上电后PB3、PB4、PA13、PA14、PA15默认作为JTAG调试引脚使用如果你要拿它们当普通GPIO必须在初始化代码里执行GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE)把JTAG功能关闭只保留SWD。但即便是这样这几个引脚用起来也总有麻烦所以我在这个项目里从一开始就避开了它们建议你也记住一句口诀能用普通PA/PB脚就不碰复用调试脚。6.3 按键抖动是真能让你怀疑人生的如果你跳过消抖直接写按键检测运行时会发现一个特别荒诞的场景你明明只按了一下模式切换键系统却像抽风一样在自动模式和手动模式之间反复横跳或者行人按钮按一次后触发了五六次过街流程。原因就是我前面提到的机械抖动。我的解决办法是软件消抖加“事件触发”而非“电平触发”在主循环里不断读取按键电平只有检测到从按下状态变成稳定按下时才认为是一次有效按键而且两次按键之间的间隔至少要大于100毫秒这样才能把一次按压过滤成单一事件。如果按键在程序里表现得还是不听话可以用示波器或者逻辑分析仪看一下按下瞬间的波形你会亲眼看到那串抖动脉冲长什么样理解了原理之后就不会再被它坑到了。6.4 LED不亮或过亮多半是电阻选错了LED不亮的时候第一反应不要怀疑单片机引脚坏了先量一下引脚电平是否正常再看LED方向是否接反。LED是有极性的长脚是正极短脚是负极如果你把正负极接反电流根本流不过去。另一个极端是LED过亮甚至烧掉这种基本是限流电阻没接或者阻值太小。我按实际参数算给你看STM32 GPIO高电平输出3.3V红色LED正向压降大约2V如果直接接在引脚和地之间回路电流会达到几十甚至上百毫安完全超过GPIO的最大输出能力。单片机引脚不是你超载使用的理由。串接220Ω电阻的情况下电流大约在(3.3-2)/220也就是5.9毫安左右这个值对普通LED来说已经足够亮对GPIO来说也非常安全。所以我从一开始就建议用220Ω以上如果你用的是蓝色或者白色LED正向压降更高电阻也可以相应增大到330Ω。6.5 OLED不显示别急着怀疑屏幕坏了OLED模块第一次上电经常什么都不显示。百分之七八十的情况不是屏幕坏了而是I2C地址不对。SSD1306的I2C地址通常是0x3C但有些模块的地址跳线焊到了另一边变成了0x3D。驱动库里改一下地址宏定义问题瞬间消失。另外I2C总线上拉电阻也很关键。很多便宜的OLED模块板载了上拉电阻直接接就能用但也有一些模块没有这时候你需要在SCL和SDA两根线上分别接一个4.7kΩ或10kΩ的上拉电阻到3.3V否则数据信号根本拉不上去。在面包板上补两个电阻非常容易但如果不知道这个原理会白白排查很久。6.6 国产兼容F103芯片的几个差异点现在市面上大量F103C8T6的最小系统板板上芯片实际上是国产兼容型号比如GD32F103C8T6、APM32F103C8T6等它们的引脚和主要寄存器都兼容直接烧录STM32的标准库程序基本能跑。但我在实际测试中发现几个需要注意的差异。第一个是ADC采样的参考电压和精度方面部分国产芯片的ADC内部参考有微小偏差如果你后续要把这个红绿灯项目扩展成带光照强度检测的光控信号灯就需要针对实际情况做校准。第二个差异是某些国产芯片对时钟树做了调整默认主频可能不是72MHz需要用数据手册核对一下。第三个差异比较隐蔽国产芯片在Win环境下用ST-Link烧录偶尔会遇到识别速度慢或者校验失败这时候把SWD速度从4MHz降到1MHz基本就能解决。如果你不确定自己的板子里装的是原厂还是国产芯片出问题的时候干脆直接按国产芯片的调法去排查反正大部分工程上的解决办法是通用的。最后分享一点个人体会这套智能红绿灯做完之后我一直把它放在工作台的角落偶尔通电跑一下。它已经不仅是一个课程设计了更像是一个随时可以拿出来做演示的硬件平台。后来我基于这套框架加过光敏电阻做夜间模式加过DS1302做定时优先级还被同学拿去改成了带密码锁功能的门禁系统底层全是同一套状态机思路。你如果也想拿这个项目作为课程设计我的建议是把状态转移逻辑写清楚再把硬件设计图和数据手册相关页面打印出来附在报告里答辩的时候基本不会有什么问题。最后再分享一个小技巧如果你在调状态机的时候发现逻辑乱了不要急着改代码先在纸上把每个状态和转移条件画出来标清楚每个状态的持续时间和出口条件然后再回头对照代码。你会惊讶地发现绝大多数bug在落到纸面的那一刻就已经藏不住了。
返回列表