做STM32实验,很多人第一件事就是买开发板。但如果你只是想验证PWM的逻辑,或者手头暂时没有板子,Proteus仿真完全是够了。我在Proteus里用STM32F103C8T6做了一枚呼吸灯:LED从暗到亮、再从亮到暗,速度平滑自然,整个过程不碰真实硬件,只需要Keil编译出一个HEX文件,再在Proteus里搭一个最简单的电路就能跑起来。这篇就把从原理到代码,再到排查踩坑的完整过程写出来,给想用仿真快速验证PWM的读者做参考。
1. 为什么要做呼吸灯:它是理解PWM的最短路径
1.1 一个实验同时打通定时器和GPIO
现在回头看,呼吸灯这个实验最大的价值不是“灯会呼吸”这个结果,而是它逼着你把STM32的定时器、GPIO复用、PWM输出模式、系统时钟这几件事全部串起来。PWM是嵌入式里出镜率最高的外设功能之一,电机调速、舵机控制、屏幕背光、LED调色,底层全是同一套机制。你在STM32F103C8T6上把呼吸灯跑通,后面面临的只是换引脚、换定时器、换占空比计算方式而已。
STM32F103C8T6的定时器资源是TIM1到TIM4,其中TIM1是高级定时器,TIM2、TIM3、TIM4是通用定时器。随便拿一个通道输出PWM都可以。我用的是TIM2的通道1,对应PA0引脚。原因很简单:PA0在原理图上位置清楚,接LED方便,也方便用示波器探头去点。
1.2 仿真能帮你完成什么,不能帮你完成什么
先说清楚边界,免得你产生错误预期。Proteus仿真是功能仿真,它模拟寄存器行为、引脚电平变化、外设逻辑,速度上跟真实芯片并非完全一致。它适合验证:代码逻辑对不对、PWM波形能不能出来、引脚配置是否正确、占空比变化是否符合预期。
它不适合验证:真实晶振起振稳定性、GPIO驱动能力极限、PWM信号在长导线上的完整性、MOS管开关损耗。比如有的新手在仿真里直接用PA0点一个大功率LED,灯也能亮,但换到真实板子上,STM32 GPIO的输出电流根本拉不动,LED也不够亮。所以我的态度是:要学PWM逻辑,仿真效率很高;要学硬件设计,仿真只能帮你完成其中一半。
2. 软硬件准备:Proteus元件、Keil工程和最小文件清单
2.1 Proteus版本和元件搜索
Proteus 8 Professional 8.9以上版本对STM32F103系列支持得比较好。打开Proteus后,在Pick Devices面板搜索“STM32F103C8T6”,直接就能找到这个芯片模型。版本太老的话可能搜不到,最省事的办法是升级到新版本,别花太多时间折腾元件库。
其他会用到的元件也不复杂:LED就搜LED-RED,电阻搜RES,电源和地直接用右侧工具条里的POWER、GROUND端。有些教程会在原理图里画晶振电路、复位电路、104电容,如果你用的是Proteus自带的STM32F103C8T6模型,这些外部电路不是必须的,芯片模型内部已经把时钟和复位逻辑处理得比较理想化了。真正必须处理的反而是电源引脚和BOOT引脚,这部分我在第4节会专门说。
2.2 Keil MDK工程怎么建最省事
软件上我用的是Keil MDK5。新建工程后Device选择“STMicroelectronics -> STM32F1 Series -> STM32F103C8”,接着系统会弹出Manage Run-Time Environment窗口。对于本文这种只用一个main.c的实验,不需要把整个标准外设库都加进来,但需要保证三样东西齐了:
启动文件startup_stm32f10x_md.s必须存在,Cortex-M3核心文件core_cm3.h和stm32f10x.h要能被include到。最稳妥的办法是直接找一个能编译通过的“STM32F103C8T6标准库空工程模板”,把main.c换成我下面这份代码。
在Options for Target -> Output标签页里,一定要勾选Create HEX File。不然Proteus那边双击芯片加载Program File时找不到hex,整个项目就卡在这一步。
2.3 准备期最容易踩的三个坑
第一个坑:勾选Create HEX File之后,东西被编译到了Debug文件夹或Listings文件夹,很多人找不到,通常路径是工程目录下的Objects文件夹,名字叫工程名.hex。双击芯片后直接去那个目录选文件就行。
第二个坑:系统时钟假设不一致。Proteus里双击STM32芯片后,有一个Crystal Frequency设置项,我这里说的是8MHz,后面代码里也是按外部8MHz晶振倍频到72MHz的路径来写的。如果你把它设成72MHz,很可能会导致SystemInit里的PLL配置对不上,仿真表现异常。
第三个坑:头文件路径没配好。报错找不到stm32f10x.h时,八成是Include Paths没加,或者空工程模板本身不完整。建议别自己从零建工程,直接拿一个验证过的模板改,效率高很多。
3. 从寄存器到波形:PWM输出与呼吸效果的核心原理
3.1 定时器怎么产生一个可调的方波
PWM的底层逻辑其实很朴素:定时器内部有一个自由计数的计数器CNT,从0数到自动重装载值ARR,然后归零重新开始。同时还有一个比较寄存器CCR。CNT小于CCR时引脚输出一种电平,CNT大于等于CCR时引脚输出另一种电平,这样引脚上就出现了占空比可调的方波。
周期和占空比都有公式可以算。如果系统时钟是72MHz,定时器预分频PSC设为71,那么定时器计数频率就是72MHz除以72,等于1MHz。ARR设为999,计数器数完一轮需要1us乘以1000,也就是1ms,所以PWM频率是1kHz。CCR从0到999变化,对应占空比0%到100%。对LED来说,1kHz足够高,肉眼看不出闪烁,呼吸效果靠的是CCR周期性地从0增大到999,再从999减小到0。
3.2 呼吸感怎么来:线性渐变会显得生硬
很多人第一版呼吸灯代码是让CCR大步长地线性增、线性减。结果你会发现,LED在变化的过程中,亮灭两端的过渡很突然,中间变化一直很平缓,整体观感不像“呼吸”,更像一个节拍器。
原因在于人眼对暗部亮度的变化更敏感。你盯着一个LED看,它从1%亮度变到2%亮度,肉眼感知的“变化量”比从50%变到51%大得多。所以想让呼吸曲线舒服,占空比不能线性走,而应该按余弦规律走。我用的公式是:
duty = (1 - cos(theta)) / 2
theta从0均匀扫到2π时,duty在0到1之间平滑往返。算成CCR值之后,暗的时候变化缓慢,中间亮区变化快一些,整体就有了一种呼吸般的过渡感。在代码里我会预生成一张100点的正弦表,主循环只需要查表更新CCR,不需要做实时浮点运算。
3.3 定时器通道和IO引脚的对应关系
STM32F103C8T6的定时器输出通道对应关系是固定的,这部分信息建议收藏备用:
| 定时器 | 通道 | 引脚 |
|---|---|---|
| TIM1 | CH1 | PA8 |
| TIM2 | CH1 | PA0 |
| TIM2 | CH2 | PA1 |
| TIM3 | CH1 | PA6 |
| TIM3 | CH2 | PA7 |
| TIM4 | CH1 | PB6 |
| TIM4 | CH2 | PB7 |
我在这篇文章里选TIM2_CH1/PA0。如果你手头有其他工程模板,改成TIM3_CH1/PA6也可以,只要把GPIO配置、TIM选择、OC通道初始化改成对应的就行。
4. 在Proteus里搭建STM32呼吸灯电路并加载HEX
4.1 元件清单和必然要处理的引脚
电路部分其实非常简单,元件清单如下:
| 元件 | Proteus搜索名 | 参数 | 数量 |
|---|---|---|---|
| STM32F103C8T6 | STM32F103C8T6 | 无 | 1 |
| LED | LED-RED | 红色 | 1 |
| 电阻 | RES | 220欧姆或330欧姆 | 1 |
| 电源端 | POWER | VCC | 若干 |
| 接地端 | GROUND | GND | 若干 |
电源引脚是仿真新手最容易出错的地方。Proteus里的STM32F103C8T6模型有很多电源引脚,VDD_1和VDD_2要接到VCC,VDDA也要接到VCC,VREF+接VCC,VREF-接GND,VSS_1、VSS_2、VSSA全部接GND。这些引脚不接全,芯片在仿真里不会正常工作,表现就是点运行后一片死寂。
NRST引脚建议用一个10k欧姆电阻上拉到VCC,BOOT0和BOOT1都接GND,这样最接近真实板子的上电状态。虽然Proteus模型对这些引脚要求不算严格,但照着接不会错。
4.2 连线步骤和网络标号技巧
在原理图里先把STM32F103C8T6放在画布中央,LED放在右侧。从PA0引出一根线,接220欧姆电阻的一端,电阻另一端接LED阳极,LED阴极接GND。
不要试图把VDD、VSS这些线一根根拉得很长,那样原理图会乱成一团。直接放POWER和GROUND端,然后双击引脚,在Wire Label里把它们标成VCC、GND网络名,同名网络在原理图上自动相连,这是Proteus里最常用的方式。
接线完成后,在原理图空白处点鼠标可以检查所有连线。这个时候可以顺便看一眼LED的正负极,Proteus的LED符号带有箭头标记,箭头方向是电流方向,别接反了。
4.3 加载HEX文件并设置晶振
双击原理图上的STM32F103C8T6芯片,会弹出一个属性编辑窗口。在Program File那里选中Keil刚才生成的.hex文件,然后把Crystal Frequency改成8MHz。这里我要再强调一次,Crystal Frequency填8MHz,不是72MHz。道理在第2节说过,标准库的SystemInit会自己把8MHz倍频到72MHz,仿真模型可以模拟这个过程,前提是它认为外部晶振是8MHz。
点左下角运行按钮,如果一切正常,LED应该开始缓慢地亮暗变化。第一次跑通的时候你会发现仿真速度可能和真实时间有点差异,如果LED呼吸节奏明显偏快或偏慢,优先检查代码里的PSC、ARR、SysTick延时参数,而不是怀疑Proteus。
5. 完整代码:用标准外设库配置TIM2输出PWM呼吸效果
5.1 可以直接抄的main.c
下面这份代码是基于标准外设库写的,也就是大家常说的StdPeriph_Lib。新建工程模板后,把main.c内容全部替换成下面这段就行。
#include "stm32f10x.h" #include <math.h> #define PWM_ARR_VALUE 999 #define PWM_PSC_VALUE 71 #define BREATH_POINTS 100 #define BREATH_STEP_MS 15 uint16_t breathTable[BREATH_POINTS]; void GenerateBreathTable(void) { uint16_t i; for (i = 0; i < BREATH_POINTS; i++) { float theta = 2.0f * 3.14159265f * i / BREATH_POINTS; float duty = (1.0f - cosf(theta)) / 2.0f; breathTable[i] = (uint16_t)(duty * PWM_ARR_VALUE); } } void TIM2_PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period = PWM_ARR_VALUE; TIM_TimeBaseStructure.TIM_Prescaler = PWM_PSC_VALUE; TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 0; TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM2, &TIM_OCInitStructure); TIM_Cmd(TIM2, ENABLE); } void delayMs(uint32_t ms) { uint32_t i; for (i = 0; i < ms; i++) { SysTick->LOAD = 72000 - 1; SysTick->VAL = 0; SysTick->CTRL &= ~SysTick_CTRL_COUNTFLAG_Msk; SysTick->CTRL |= SysTick_CTRL_ENABLE_Msk; while ((SysTick->CTRL & SysTick_CTRL_COUNTFLAG_Msk) == 0) { } SysTick->CTRL &= ~SysTick_CTRL_ENABLE_Msk; } } int main(void) { uint16_t index = 0; GenerateBreathTable(); TIM2_PWM_Init(); while (1) { TIM_SetCompare1(TIM2, breathTable[index]); delayMs(BREATH_STEP_MS); index++; if (index >= BREATH_POINTS) { index = 0; } } }5.2 代码里每个关键配置为什么要这么写
GPIOA和TIM2的时钟要分别打开,因为GPIO挂在APB2总线上,TIM2挂在APB1总线上。遗漏任何一个使能,后面代码都跑不起来,这是新手经常漏的。
PA0配置成复用推挽输出,也就是GPIO_Mode_AF_PP。定时器PWM输出不是普通拉高拉低电平,它是把定时器内部产生的波形信号,通过GPIO的复用功能送到引脚上,所以模式必须是AF_PP,不能用GPIO_Mode_Out_PP。
TIM_TimeBaseInit里,TIM_Period填999,TIM_Prescaler填71,得到的PWM频率就是1kHz。如果你想把PWM频率改成500Hz,可以保持PSC=71,把ARR改成1999;想保持频率不变但提高占空比分辨率,可以降低PSC倍率和加大ARR,但两者之间要保证乘积不变。这个参数关系理解了,PWM基本就算入门了。
PWM模式我选的是PWM1模式。在这个模式下,CNT小于CCR时输出高电平,CNT大于等于CCR时输出低电平。当你把CCR拉到最大值999,LED就常亮;CCR设为0,LED就完全熄灭。
主循环里没有阻塞很久的大延时,每次只更新CCR,然后等15ms。100个点乘15ms等于1.5秒,所以一个呼吸周期大约1.5秒。想让呼吸变慢,比如变成3秒一个周期,直接把BREATH_STEP_MS改成30或者把点数改成200就行,不需要动定时器配置。
5.3 寄存器版本的核心,遇到精简工程时能救命
如果你嫌弃标准库工程文件太多,或者手头是一个特别精简的模板,用寄存器直接操作也可以完成同样的功能。核心就这几行:
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN; RCC->APB1ENR |= RCC_APB1ENR_TIM2EN; GPIOA->CRL &= ~(GPIO_CRL_MODE0 | GPIO_CRL_CNF0); GPIOA->CRL |= GPIO_CRL_MODE0 | GPIO_CRL_CNF0_1; TIM2->PSC = 71; TIM2->ARR = 999; TIM2->CCMR1 |= TIM_CCMR1_OC1M_1 | TIM_CCMR1_OC1M_2; TIM2->CCER |= TIM_CCER_CC1E; TIM2->CR1 |= TIM_CR1_CEN; TIM2->CCR1 = 500;这段代码本质上是把标准库函数展开成了寄存器操作,适合拿来对照理解标准库到底在做什么。我建议你至少手写一次寄存器版本,之后再回到标准库,你会突然明白TIM_OC1Init为什么要初始化那么多结构体成员。
5.4 编译报错快速定位
编译最常见的报错是找不到stm32f10x.h,这种问题基本是工程模板不完整或者头文件路径没加。另一个常见报错是链接时提示SystemInit未定义,说明工程里缺少system_stm32f10x.c文件,补上就行。
还有一种是cosf报错,原因是缺math.h头文件。我代码里已经写了include <math.h>。如果PWM波形出不来,先别怀疑PWM配置,用排除法:把CCR写死成500,看LED是不是半亮状态。如果是,说明定时器PWM工作正常,问题出在呼吸表生成或主循环更新逻辑上。
6. 仿真运行与示波器验证:把看不见的PWM可视化
6.1 用Proteus虚拟示波器观察PA0波形
Proteus自带虚拟示波器,这是比LED更可靠的调试仪器。在左侧工具箱里选Virtual Instruments,再选OSCILLOSCOPE,放到原理图空白处,把通道A接到PA0上,接地端接GND。运行仿真后打开示波器窗口,你会看到一条不断变化宽度的方波波形,这就是PWM。
第一次看到波形时,宽度变化会很直观:从很窄慢慢变宽,再慢慢变窄,和LED亮度变化完全对应。如果波形一直是一条高电平直线,大概率是配置错了引脚,比如PWM输出到了PA1但示波器探到了PA0。如果是一条低电平直线,优先检查PA0的IO模式,看是不是配成了GPIO_Mode_Out_PP。
6.2 常见现象排查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 点击运行后完全没反应 | 没有加载HEX文件,或者Crystal Frequency设置不对 | 双击芯片选中HEX,晶振设为8MHz |
| LED恒亮不呼吸 | CCR没在变,或者PWM输出被当成普通GPIO输出 | 检查TIM_SetCompare1是否在主循环被调用;确认GPIO是复用推挽 |
| LED完全不亮 | LED接反,或限流电阻太大 | 换LED极性,把电阻换成220欧姆 |
| 示波器有波形但LED不亮 | LED串联电阻太大 | 把电阻降到470欧姆以内 |
| 呼吸速度明显不对 | 系统时钟不是72MHz,或SysTick延时单位算错 | 检查工程里SystemInit是否执行,PSC和LOAD按当前时钟重算 |
还有一个现象值得单独说:在Proteus里仿真这种轻量级电路,遇到卡顿导致波形跳动是很正常的。可以在“System -> Animation Options”里降低动画帧率,仿真会更流畅,波形刷新也更稳定。
6.3 我建议你先看波形再盯LED
说实话,LED亮度变化很直观,但调试时容易被主观感受误导。波形才是客观证据。我之前在调呼吸曲线那段时间,每次改完参数,第一件事都是打开示波器看波形有没有按预期展宽和收窄,确认之后再看LED感受一下效果。这个顺序能帮你节省很多时间。
7. 呼吸灯之后还能做什么:PWM扩展方向与实物移植
7.1 从单路到多路:用PWM控制RGB调色
呼吸灯跑通后,PWM的扩展试验可以立刻做起来。STM32F103C8T6上TIM2有CH1到CH4共四个通道,PA0到PA3四个引脚都能输出PWM。搭一个RGB三色LED,三种颜色各用一路PWM,每个颜色单独维护一张呼吸表,就能调出各种渐变色。代码结构完全一样,只是要多初始化几个GPIO和OC通道。
7.2 用两个按键控制呼吸速度
在Proteus里放两个按键,接到PA4和PA5,配置成输入模式,按下时修改BREATH_STEP_MS或者修改主循环的跳变步长,就能实现按键调速。这个改动不需要动PWM初始化,只改主循环里的延时和索引递增逻辑。这个实验很适合进一步体会“外设配置一次,后续只改数据”这个嵌入式开发思路。
7.3 从仿真到实物:参数要重新核对的地方
Proteus验证通过后,你可能会想买一块真实的最小系统板把它跑起来。代码逻辑基本不用改,但有几个地方要重新确认。第一是外部晶振,STM32F103C8T6最小系统板一般板载8MHz晶振,Proteus里我让你填8MHz,实物板默认也是8MHz,所以SystemInit这段可以通用。第二是LED限流电阻,真实STM32GPIO高电平约3.3V,红色LED压降约1.8V,220欧姆电阻给到的电流大约是(3.3-1.8)/220=6.8mA,亮度合适,这个计算值在Proteus里不会体现,但实物必须按真实电流去选电阻。第三是如果用国产兼容MCU,比如GD32F103,主频上限可能不同,PSC和ARR参数建议按实际主频重新算一遍,别直接照搬。
7.4 关于仿真和实物我一直坚持的看法
用Proteus做仿真的最大收益,是我能把“改参数”和“看现象”之间的距离缩到最短。改一次PSC,立刻看LED亮度变化;改一次呼吸表点数,立刻看呼吸节奏变化。这种即时反馈对建立直觉特别有帮助。但仿真永远替代不了实物,尤其是涉及功率驱动、电机、LED亮度校准这些场景,最终还是得在真实电路上验证。建议你把Proteus当成一个低成本实验台,先在软件里把逻辑调顺,再带着明确的参数去碰硬件,这样的学习路径会顺畅很多。
我自己的经验是,仿真阶段多花十分钟把PWM波形和呼吸表搞清楚,后面做实物时基本一次点亮。PWM这个知识看着简单,但它贯穿了整个嵌入式入门前半段。呼吸灯只是把门推开的那道缝,真正推开之后,里面有太多值得折腾的东西。