尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32交通灯Proteus仿真:OLED倒计时上屏实战

STM32交通灯Proteus仿真:OLED倒计时上屏实战 简介面向嵌入式入门学习者与课程设计学生提供基于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走到051单片机实训里早就写过但题目一旦加上“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Ω 下拉电阻到地PB2BOOT1同样处理。仿真环境里的启动配置比实板更敏感这两脚悬空时模型有时会停在复位状态现象是程序能 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 位地址 0x3CHAL 库里写器件地址时要注意函数参数里传入的 0x78 是由 0x3C 左移一位得到的写入地址。SCL 和 SDA 各接一个 4.7kΩ 上拉电阻到 3.3V。实板上这个上拉必须加;仿真里如果不加有些 Proteus 版本的模型波形边沿不够陡OLED 初始化时丢掉第一个 ACK屏幕就一直不亮。下面这张表是我常用的仿真接线映射信号STM32引脚OLED模型引脚说明SCLPB6SCLI2C1 时钟线SDAPB7SDAI2C1 数据线VCC3.3VVDD屏幕供电GNDGNDVSS共地不要直接拿 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Ω一组灯接一个电阻到 GNDGPIO 完全拉得动。LED颜色典型压降220Ω时电流Proteus模型红色1.8V约6.8mAVLEDRED黄色1.9V约6.4mAVLEDYELLOW绿色2.0V约5.9mAVLEDGREENGPIO 分配我固定在 PC0 到 PC5PC0、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~716×8 字模正好占一页所以换行不需要跨页处理这是选这个尺寸的原因。需要注意OLED_ShowNumber本身不做清残影处理。同一个坐标从数字 2 变成 1 时宽度相同的字模没有残留;但如果整个倒计时区域重新拼装数字比如从十位有值变成十位无值就必须先把旧数字清掉。这个操作放在状态机每秒的刷新函数里用清屏行函数统一处理比在每个显示函数里单独清更干净。4. 交通灯状态机实现让倒计时与LED同步刷新4.1 定义状态枚举和状态-持续时间表交通灯逻辑用状态机表达最清晰四个状态循环南北绿灯加东西红灯南北黄灯加东西红灯南北红灯加东西绿灯南北红灯加东西黄灯。四个状态分别持续 30 秒、3 秒、30 秒、3 秒时长直接写进表里后面要改信号周期只改表不动代码逻辑。状态南北方向东西方向倒计时范围STATE_NS_GREEN绿灯红灯30 ~ 1STATE_NS_YELLOW黄灯红灯3 ~ 1STATE_EW_GREEN红灯绿灯30 ~ 1STATE_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 显示模块的坑基本一次就能清掉。本文还有配套的精品资源点击获取
返回列表