简介:面向嵌入式入门学习者与课程设计学生,提供基于STM32的交通灯控制系统完整仿真方案。核心功能覆盖红绿灯时序切换、OLED屏倒计时显示,并在Proteus环境中实现硬件级模拟,可帮助读者理解GPIO、定时器中断及显示驱动的综合应用。压缩包共包含265个文件,以C语言源码(.c/.h)、Proteus工程(.pdsprj)和Keil工程文件(.uvprojx)为主,另含已编译的hex、axf及调试辅助文件,整体约8.09MB,目录结构便于直接打开工程运行仿真。目前已有208人学习下载。通过这套方案,读者可获得可直接导入的Proteus仿真模型、完整STM32固件代码及OLED倒计时显示思路,尤其适合毕业设计或智能交通类课题的快速起步与验证。
1. 从OLED倒计时上屏谈起:STM32交通灯在Proteus里到底难在哪
做单片机课程设计的人都有这种感觉:交通灯逻辑本身不复杂,两组红黄绿LED按固定时序切换,倒计时从30走到0,51单片机实训里早就写过;但题目一旦加上“OLED屏显示倒计时”,难度立刻不在灯上,而在屏幕。Proteus仿真里STM32元件选型、OLED显示模块的I2C接线方向、HAL库驱动OLED代码的初始化顺序,任何一个环节没对齐,结果都是灯在闪、屏全黑,整个项目看起来像没通电。
这篇文章按我实际会做的顺序来写:先在 Proteus 元件库里选对 STM32 和 SSD1306 驱动芯片的 OLED 显示模块,把最小系统搭建出来;再写 STM32 HAL 库工程,让倒计时真正上屏;然后设计交通灯状态机,使 LED 和 OLED 同步刷新;最后聊几个只有仿真环境才会遇到、实板上反而碰不到的坑。适合课程设计、单片机实训,以及想用一个完整项目把 STM32、OLED、Proteus仿真串起来的人。这套东西改改字模和状态时长,变成秒表、电子钟都不难。
2. Proteus仿真的STM32最小系统搭建:元件选型与OLED接线
2.1 在Proteus元件库里选对STM32型号
打开 Proteus 8 Professional,在元件拾取对话框的 Keywords 栏输入 STM32,会出来一批带具体后缀的型号。常见做法是直接选 STM32F103C8,封装不大,引脚排列在仿真图上容易看清。元件库里同一颗芯片常有 STM32F103C8 和 STM32F103C8T6 两种写法,仿真模型差异不大,但原理图里选型要固定,后面加载 .hex 时不会指向错目标。
把芯片放到画布后,默认已经带了 VDD/VDDA 和 VSS/VSSA 引脚,不需要像实板那样额外接电源网络。不过我会把 STM32 的 BOOT0 接一个 10kΩ 下拉电阻到地,PB2(BOOT1)同样处理。仿真环境里的启动配置比实板更敏感,这两脚悬空时模型有时会停在复位状态,现象是程序能 Load 进去但 GPIO 和定时器完全不工作。
晶振这里要格外留意。如果代码里用 HSE 做系统时钟,Proteus 的原理图必须放一个晶振接到 PD0/PD1,频率要和 CubeMX 初始化一致,否则 HAL_RCC_ClockConfig 里的超时判断会受影响,后面 HAL_Delay 和倒计时秒数全部漂移。我一般直接用内部 HSI 跑 64MHz,这样原理图少放一只晶振,仿真启动更快,也不会因为晶振频率配错导致倒计时变成 0.7 秒一次。
2.2 OLED屏的仿真选型:从SSD1306到I2C接口
OLED 显示模块在 Proteus 元件库里可以搜 SSD1306,这是 0.96 寸、1.3 寸、1.44 寸小屏最常见的驱动 IC。仿真模型只关心驱动 IC 一不一致,不关心屏幕物理尺寸,所以不管实板习惯用哪块屏,Proteus 里统一选 SSD1306 模型就好,初始化命令完全通用。
接线方式有两类:I2C 接口只要 SCL 和 SDA 两根信号线;SPI 接口要接 CS、DC、SCLK、MOSI 四根以上。仿真阶段我建议用 I2C,一是走线少,出问题好查;二是 STM32 的硬件 I2C1 正好在 PB6/PB7,代码可以直接搬到实板;三是题目里“OLED 屏显示倒计时”的实板做法,十有八九也是 I2C,仿真和实物对得上。
SSD1306 的 I2C 地址是固定的,7 位地址 0x3C,HAL 库里写器件地址时要注意,函数参数里传入的 0x78 是由 0x3C 左移一位得到的写入地址。SCL 和 SDA 各接一个 4.7kΩ 上拉电阻到 3.3V。实板上这个上拉必须加;仿真里如果不加,有些 Proteus 版本的模型波形边沿不够陡,OLED 初始化时丢掉第一个 ACK,屏幕就一直不亮。下面这张表是我常用的仿真接线映射:
| 信号 | STM32引脚 | OLED模型引脚 | 说明 |
|---|---|---|---|
| SCL | PB6 | SCL | I2C1 时钟线 |
| SDA | PB7 | SDA | I2C1 数据线 |
| VCC | +3.3V | VDD | 屏幕供电 |
| GND | GND | VSS | 共地 |
不要直接拿 5V 给 OLED 的 VDD。仿真的 STM32F103 是 3.3V 器件,电平匹配问题仿真里不会烧芯片,但 I2C 总线上 5V 和 3.3V 混在一起,上拉电阻计算很别扭,后续调试时反而多一个变量。
2.3 LED和限流电阻的仿真参数设置
交通灯部分用两组红黄绿,南北方向一组,东西方向一组,一共 6 个 LED。Proteus 元件库里取 VLEDRED、VLEDYELLOW、VLEDGREEN 三种模型即可,颜色不要混,否则仿真图上对照状态表检查时很乱。
LED 限流电阻,实板上要根据压降和 IO 驱动能力算,仿真里没有烧毁风险,但阻值取得太离谱,逻辑电平会变得很不干净。常见做法按 3.3V 高电平初算:红色 LED 压降约 1.8V,绿色约 2.0V,电流取 5mA 到 10mA,算下来阻值在 130Ω 到 300Ω。我统一用 220Ω,一组灯接一个电阻到 GND,GPIO 完全拉得动。
| LED颜色 | 典型压降 | 220Ω时电流 | Proteus模型 |
|---|---|---|---|
| 红色 | 1.8V | 约6.8mA | VLEDRED |
| 黄色 | 1.9V | 约6.4mA | VLEDYELLOW |
| 绿色 | 2.0V | 约5.9mA | VLEDGREEN |
GPIO 分配我固定在 PC0 到 PC5:PC0、PC1、PC2 接南北方向的红黄绿,PC3、PC4、PC5 接东西方向的红黄绿。这样两组灯落在连续端口区间,状态输出可以用一个函数集中处理,不用在状态机里逐个写 GPIO。写灯光的要点是先全部熄灭再点亮需要的灯,避免状态切换瞬间两组灯同时亮出错误的组合:
// PC0~PC5控制两组交通灯,bit0=南北红 bit1=南北黄 bit2=南北绿 // bit3=东西红 bit4=东西黄 bit5=东西绿,GPIO高电平点亮 static void Traffic_SetLights(uint8_t light_bits) { uint16_t all = GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3 | GPIO_PIN_4 | GPIO_PIN_5; HAL_GPIO_WritePin(GPIOC, all, GPIO_PIN_RESET); // 先全部灭灯 if (light_bits & 0x01) HAL_GPIO_WritePin(GPIOC, GPIO_PIN_0, GPIO_PIN_SET); if (light_bits & 0x02) HAL_GPIO_WritePin(GPIOC, GPIO_PIN_1, GPIO_PIN_SET); if (light_bits & 0x04) HAL_GPIO_WritePin(GPIOC, GPIO_PIN_2, GPIO_PIN_SET); if (light_bits & 0x08) HAL_GPIO_WritePin(GPIOC, GPIO_PIN_3, GPIO_PIN_SET); if (light_bits & 0x10) HAL_GPIO_WritePin(GPIOC, GPIO_PIN_4, GPIO_PIN_SET); if (light_bits & 0x20) HAL_GPIO_WritePin(GPIOC, GPIO_PIN_5, GPIO_PIN_SET); }light_bits的低 6 位分别对应 6 个灯,调用方只需传一个 0~63 的整数,状态机内部不用关心具体哪个引脚。HAL_GPIO_WritePin的第三个参数是电平状态,先RESET全部熄灭,再按掩码逐位SET,这样即使light_bits被传错,最多也只是灯亮错,不会出现所有引脚都点亮的危险状态。
3. STM32 HAL库工程框架:RCC、GPIO与OLED驱动代码
3.1 用一个宏定义把管脚映射固定下来
写代码之前,先把引脚分配固化成头文件宏,这是我搭 STM32 开发环境的第一步。CubeMX 生成的代码里管脚名全是 GPIO_PIN_x,状态机里直接引用容易记混,在头文件里重新定义成“南北红”“东西绿”这样的业务含义,代码看起来就像需求描述。
/* pin_map.h */ #define OLED_SCL_PORT GPIOB #define OLED_SCL_PIN GPIO_PIN_6 #define OLED_SDA_PORT GPIOB #define OLED_SDA_PIN GPIO_PIN_7 #define LED_NS_RED GPIO_PIN_0 /* 南北红 PC0 */ #define LED_NS_YELLOW GPIO_PIN_1 #define LED_NS_GREEN GPIO_PIN_2 #define LED_EW_RED GPIO_PIN_3 #define LED_EW_YELLOW GPIO_PIN_4 #define LED_EW_GREEN GPIO_PIN_5这样做的收益在 Proteus 仿真里最明显:仿真图改一次引脚,程序里只需要调宏定义,不用全局搜索 GPIO_PIN_x。实板如果换了一块板子,引脚变了,也只动这个文件。OLED 的 SCL/SDA 固定在 PB6/PB7,这是 STM32F103 的 I2C1 默认映射,仿真里不需要重映射,CubeMX 里配置 I2C1 后引脚会自动出现在这两个位置。
3.2 HAL库驱动OLED代码:从I2C时序到刷屏命令
SSD1306 用 I2C 通信时有两条基本规则:控制字节 0x00 表示后面是命令,0x40 表示后面是数据;写命令和写数据都要先发器件地址 0x78。HAL 库里最省事的写法是用HAL_I2C_Mem_Write,把控制字节当作寄存器地址传进去,一条语句完成“发控制字节 + 发内容”两个动作。
void OLED_WriteCmd(uint8_t cmd) { HAL_I2C_Mem_Write(&hi2c1, 0x78, 0x00, I2C_MEMADD_SIZE_8BIT, &cmd, 1, 10); } void OLED_WriteData(uint8_t dat) { HAL_I2C_Mem_Write(&hi2c1, 0x78, 0x40, I2C_MEMADD_SIZE_8BIT, &dat, 1, 10); }参数里hi2c1是 CubeMX 生成的 I2C 句柄,0x78是器件地址,0x00和0x40是控制字节,最后的10是超时毫秒数。仿真里 I2C 速率如果选 400kHz,有些模型响应不过来会返回 HAL_TIMEOUT,表现为 OLED 显示一半或完全黑屏。我一般先在 CubeMX 里把 I2C 速率降到 100kHz,等整个交通灯功能跑通后再试着调回 400kHz。这不是降低标准,而是仿真模型对时序的容忍度和真芯片不完全一致,先排掉时序变量。
初始化顺序是固定的:先发 0xAE 关显示,设置显示时钟分频 0xD5 + 0x80,设置多路复用比 0xA8 + 0x3F,再选择页寻址模式 0x20 + 0x02,最后送 0xAF 开显示。这一段 HAL 库驱动 OLED 代码在网上很常见,差别只在于后面要不要加反色、滚动等特效,交通灯倒计时用最基础的部分就够。
void OLED_Init(void) { HAL_Delay(100); OLED_WriteCmd(0xAE); /* 关闭显示 */ OLED_WriteCmd(0xD5); /* 设置时钟分频 */ OLED_WriteCmd(0x80); OLED_WriteCmd(0xA8); /* 设置驱动路数 */ OLED_WriteCmd(0x3F); OLED_WriteCmd(0x20); /* 设置内存寻址模式 */ OLED_WriteCmd(0x02); /* 页寻址,方便从任意位置写字 */ OLED_WriteCmd(0xAF); /* 开显示 */ OLED_Clear(); }很多人在仿真里 OLED 黑屏,第一反应是怀疑初始化命令,我遇到最多的情况反而是OLED_Init()里的OLED_Clear()没实现或者寻址模式不对。页寻址模式下清屏要按 8 页循环,每页写满 128 个 0x00,写成两重循环,后面刷倒计时时才不会出现数字残影。OLED_Clear()的实现就是循环调用OLED_WriteCmd(0xB0 + row)和 128 次OLED_WriteData(0x00),第 4 章里的OLED_ClearRow(2)同理,只是只清第 2 页。
3.3 倒计时刷屏的显示函数与字模方向
倒计时显示用 16×8 的字模比较合适,一个字符占 16 字节。取模软件里把格式设为“列行式,逐列取模,每列一个字节”,和 SSD1306 页寻址的写入顺序对应。如果数字方向反了,多半是取模设置里的“字节倒序”没勾,和代码没关系。
static const uint8_t FONT[][16] = { /* '0' 16x8 */ {0x00,0x00,0x3C,0x42,0x42,0x42,0x42,0x42, 0x42,0x42,0x42,0x42,0x3C,0x00,0x00,0x00}, /* '1' 到 '9' 按相同格式补全 */ }; void OLED_ShowNumber(uint8_t x, uint8_t y, uint8_t num) { if (num > 9) return; for (uint8_t i = 0; i < 16; i++) { OLED_WriteCmd(0xB0 + y); /* 设置页地址 */ OLED_WriteCmd(0x00 + ((x + i) & 0x0F)); /* 列低四位 */ OLED_WriteCmd(0x10 + ((x + i) >> 4)); /* 列高四位 */ OLED_WriteData(FONT[num][i]); } }这段代码每写一列都重新设置页地址和列地址,速度不快,但逻辑最简单,仿真里每秒刷新一次完全没有压力。如果想更流畅,可以改成设置一次页地址后连续写 16 字节,等倒计时逻辑稳定后再优化。x是起始列坐标,y是页号(0~7),16×8 字模正好占一页,所以换行不需要跨页处理,这是选这个尺寸的原因。
需要注意OLED_ShowNumber本身不做清残影处理。同一个坐标从数字 2 变成 1 时,宽度相同的字模没有残留;但如果整个倒计时区域重新拼装数字,比如从十位有值变成十位无值,就必须先把旧数字清掉。这个操作放在状态机每秒的刷新函数里,用清屏行函数统一处理比在每个显示函数里单独清更干净。
4. 交通灯状态机实现:让倒计时与LED同步刷新
4.1 定义状态枚举和状态-持续时间表
交通灯逻辑用状态机表达最清晰,四个状态循环:南北绿灯加东西红灯,南北黄灯加东西红灯,南北红灯加东西绿灯,南北红灯加东西黄灯。四个状态分别持续 30 秒、3 秒、30 秒、3 秒,时长直接写进表里,后面要改信号周期只改表,不动代码逻辑。
| 状态 | 南北方向 | 东西方向 | 倒计时范围 |
|---|---|---|---|
| STATE_NS_GREEN | 绿灯 | 红灯 | 30 ~ 1 |
| STATE_NS_YELLOW | 黄灯 | 红灯 | 3 ~ 1 |
| STATE_EW_GREEN | 红灯 | 绿灯 | 30 ~ 1 |
| STATE_EW_YELLOW | 红灯 | 黄灯 | 3 ~ 1 |
我把状态枚举和时长放在同一个模块里,状态转移不散落在主循环各处。注意黄灯阶段的倒计时从 3 到 1,不是从 2 到 0,因为现实中黄灯亮满 3 秒才算完整;OLED 显示 0 的场景在这里不会出现,既然倒计时走到 1 就切换,那就没有必要为显示 0 特判。
4.2 主循环扫描状态机:非阻塞切换
控制逻辑不能靠HAL_Delay(1000)做秒计时。原因有两个:一是HAL_Delay期间 OLED 刷屏、按键扫描全部卡住,显示刷新会被推迟;二是仿真里HAL_Delay依赖 SysTick,一旦系统时钟配置和 Proteus 模型不一致,延时时间就跑偏,倒计时会变成 0.7 秒一次或者 1.3 秒一次。我用HAL_GetTick()做非阻塞计时,主循环每转一圈检查一次是否到 1 秒。
typedef enum { STATE_NS_GREEN = 0, STATE_NS_YELLOW, STATE_EW_GREEN, STATE_EW_YELLOW } TrafficState; static TrafficState s_state = STATE_NS_GREEN; static uint8_t s_remain = 30; static uint32_t s_last_tick = 0; void Traffic_Update(void) { if (HAL_GetTick() - s_last_tick < 1000) { return; /* 未到1秒,直接返回 */ } s_last_tick = HAL_GetTick(); if (--s_remain == 0) { /* 当前状态时间到 */ s_state = (TrafficState)((s_state + 1) % 4); switch (s_state) { case STATE_NS_GREEN: case STATE_EW_GREEN: s_remain = 30; break; case STATE_NS_YELLOW: case STATE_EW_YELLOW: s_remain = 3; break; } OLED_ClearRow(2); /* 清掉旧数字 */ } Traffic_SyncLights(); }这段代码的关键是s_last_tick的赋值放在s_remain == 0判断之前。每过 1 秒,先更新基准时间,再判断剩余秒数。如果把HAL_GetTick()的赋值放到判断之后,状态切换和倒计时减一会差 1 毫秒,平时看不出问题,但用逻辑分析仪抓 LED 翻转沿时会看到偶尔有一拍偏差。状态切换时执行OLED_ClearRow(2),把倒计时数字所在页清掉,避免上一位数字的残影叠加到新数字上。OLED_ClearRow的实现和OLED_Clear类似,只对第 2 页执行整行清零,这部分代码和清屏函数放一起即可。
4.3 倒计时输出与LED信号的同步处理
Traffic_SyncLights()干两件事:把当前状态的灯位组合写到 GPIO,把s_remain显示到 OLED。两个动作放同一个函数里,同步性最好。常见错误是各写各的:状态机更新了 LED,显示函数在另一个地方读取s_remain,由于主循环是顺序执行的,LED 和 OLED 之间总会差几个主循环周期。仿真里 LED 亮灭是瞬时的,OLED 刷屏占用明显更久,不同步就会出现“灯已经变绿,屏幕还停在红灯倒计时”的观感。
#define BIT(n) (1U << (n)) void Traffic_SyncLights(void) { static const uint8_t state_light[4] = { BIT(2) | BIT(3), /* 南北绿 + 东西红 */ BIT(1) | BIT(3), /* 南北黄 + 东西红 */ BIT(0) | BIT(5), /* 南北红 + 东西绿 */ BIT(0) | BIT(4) /* 南北红 + 东西黄 */ }; Traffic_SetLights(state_light[s_state]); if (s_remain >= 10) OLED_ShowNumber(10, 2, s_remain / 10); /* 十位 */ OLED_ShowNumber(18, 2, s_remain % 10); /* 个位 */ }位映射的约定和 2.3 节里Traffic_SetLights一致:bit0 南北红、bit1 南北黄、bit2 南北绿、bit3 东西红、bit4 东西黄、bit5 东西绿。BIT(2) | BIT(3)就是南北绿和东西红同时点亮,和 4.1 的状态表对应。如果改了引脚排列,这两个地方的位序必须同步改,否则会出现“红灯亮但倒计时却是南北绿的状态”。
倒计时的十位为 0 时不调用OLED_ShowNumber,因为OLED_ClearRow(2)已经把旧数字清掉,十位位置自然空白。如果确实需要显示“09、08”这种补零格式,只要把if (s_remain >= 10)这个条件去掉即可,两种显示习惯都保留了入口,不用改字模。
5. Proteus仿真联调技巧:示波器测I2C与STM32启动配置排错
5.1 用虚拟示波器确认I2C波形和OLED地址
OLED 黑屏时,我第一件事不是改代码,而是接一个虚拟示波器到 PB6 和 PB7。Proteus 的虚拟仪器面板里有 OSCILLOSCOPE,接上运行,观察 SCL 上是否有连续方波,SDA 上是否出现 0x78 的写地址波形。排查序列可以固定成三步:
- 看 SCL 频率和波形密度,确认 I2C 在正常发包而不是卡死在初始化循环;
- 看 SDA 每帧开头是不是 ACK 之后跟控制字节 0x00;
- 把 I2C 速率从 400kHz 改成 100kHz,再跑一遍初始化。
如果 SCL 根本没波形,说明OLED_Init()没有执行或 I2C 时钟配置没生效;如果 SCL 正常但 SDA 地址不对,检查是不是把 7 位地址 0x3C 直接传给了 HAL 函数,导致写入时地址被重复左移。这个步骤在 Proteus仿真里比实板还有用,因为虚拟示波器不会受探头接触不良影响,波形干净,问题定位非常快。
5.2 启动配置与芯片包安装的几个坑
跑仿真时如果 Keil 弹出 “error: no stm32 target found” 一类的提示,先别急着怀疑调试器。这个错误在纯 Proteus 仿真里出现,通常不是调试连接问题,而是工程根本没有生成 .hex 文件。打开 Keil 的 Options for Target,在 Output 页勾选 Create HEX File,重新编译;再去 Proteus 里双击 STM32 芯片,把 Program File 指向生成的 .hex。
这个过程最容易漏的是 Keil 输出目录和 Proteus 里填的路径不一致。直接把 .hex 文件拖进 Proteus 图经常不生效,手动浏览到绝对路径最稳。要确认芯片型号能被仿真器识别,可以删除 Proteus 原理图里的芯片重新放一次,Proteus 重新加载芯片模型后,元件库里的类型定义会和当前 KEIL 工程对齐,很多“程序不跑”的问题在这一步后自动消失。STM32芯片包安装是否完整,看 CubeMX 能不能正常生成初始化代码就知道,Proteus 侧不直接依赖 CubeMX 的固件包。
5.3 OLED偏暗时的对比度命令与实板移植提示
OLED 显示正常但整体偏暗,问题不在硬件,而在 SSD1306 的对比度寄存器。初始化命令末尾加一条 0x81 加 0xCF,这是默认对比度;数值调到 0x7F 变暗,调到 0xFF 最亮。仿真模型没有真实背光,但这个寄存器会影响模型输出的像素浓度,改完能直接看出区别。我一般在OLED_Clear()之前把对比度命令放进去,以后移植到实板,屏幕硬件差异基本不用改主逻辑。
最后针对这个交通灯项目补一个小技巧:Proteus 运行后,LED 正常而 OLED 全亮成白色块,多半是清屏没执行;OLED 全黑,先查 I2C 地址;LED 和 OLED 都正常但倒计时乱码,去查取模软件里的扫描方向设置。排查顺序固定成“看波形、查地址、降速率、加对比度命令”,OLED 显示模块的坑基本一次就能清掉。
本文还有配套的精品资源,点击获取