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或12mm | 2个 | 模式切换、行人过街 |
| 有源蜂鸣器模块 | 低电平触发 | 1个 | 状态切换提示 |
| OLED模块 | 0.96寸 I2C SSD1306 | 1个(可选) | 显示倒计时和状态 |
| 面包板 | 400孔或830孔 | 1块 | 搭建电路 |
| 杜邦线 | 公对公、公对母 | 若干 | 连接线路 |
| USB转TTL模块 | CH340 | 1个 | 程序下载与调试 |
电阻的阻值选择我后面会在避坑指南里详细算一笔账,这里先记住:220Ω到470Ω都是安全的,不要用100Ω以下的小阻值,也不要完全不加电阻。
2.2 引脚分配与接线说明
引脚分配是这类项目里最容易被忽略但最容易翻车的一步。我最终采用的分配方案如下,已经在实际工程中验证过,可以直接照着连:
| 功能 | 引脚 | 说明 |
|---|---|---|
| 主干道红灯 | PA0 | GPIO推挽输出 |
| 主干道黄灯 | PA1 | GPIO推挽输出 |
| 主干道绿灯 | PA2 | GPIO推挽输出 |
| 支干道红灯 | PA3 | GPIO推挽输出 |
| 支干道黄灯 | PA4 | GPIO推挽输出 |
| 支干道绿灯 | PA5 | GPIO推挽输出 |
| 模式切换按键 | PA6 | 内部上拉输入,按键接GND |
| 行人过街按键 | PA7 | 内部上拉输入,按键接GND |
| 蜂鸣器 | PB0 | GPIO推挽输出 |
| OLED SCL | PB6 | I2C1时钟 |
| OLED SDA | PB7 | I2C1数据 |
为什么这么选引脚?我重点避开了几个容易出问题的位置: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中断服务函数。在中断函数里,我们只需要做一个动作,把一个全局变量累加1:
volatile 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在落到纸面的那一刻就已经藏不住了。