在实际嵌入式开发中,直接控制GPIO引脚输出高低电平来控制LED亮度或电机转速是远远不够的。当我们需要实现呼吸灯、舵机角度控制、直流电机调速等精细化的模拟量控制时,PWM(脉冲宽度调制)技术就成为了核心手段。本文将以STM32微控制器为例,深入讲解如何从零开始配置并实现PWM输出,涵盖从基本概念、寄存器理解,到使用标准外设库(SPL)和HAL库进行工程配置的完整流程。无论你是刚开始接触STM32,还是需要在项目中快速实现一个可调的PWM信号,都能通过本文获得清晰、可复现的指导。
1. 理解PWM:从概念到硬件实现
PWM,即脉冲宽度调制,是一种用数字信号来模拟模拟信号的技术。其核心思想是通过调整一个周期固定的方波信号中,高电平(导通时间)所占整个周期的比例(即占空比),来等效地获得不同的平均电压或功率。
1.1 PWM的核心参数
一个PWM波由三个关键参数定义:
- 频率(Frequency):指信号在1秒钟内完成周期性变化的次数,单位为赫兹(Hz)。它决定了PWM周期的长短,
周期(T) = 1 / 频率(f)。例如,1kHz的PWM,其周期为1ms。 - 占空比(Duty Cycle):指在一个信号周期内,高电平时间占总周期的百分比。占空比直接决定了输出等效电压的大小。计算公式为:
占空比 = (高电平时间 / 周期时间) * 100%。 - 分辨率(Resolution):指占空比可以被调节的最小精度。通常由产生PWM的定时器的计数器位数决定。例如,一个16位的定时器,其分辨率就是1/65535。分辨率越高,对占空比的控制就越精细。
1.2 STM32如何产生PWM
在STM32中,PWM功能主要由其高级/通用定时器(TIM1, TIM2, TIM3, TIM4等)的“输出比较”模式实现。其硬件工作原理可以简化为以下几步:
- 计数器计数:定时器内部的计数器(CNT)在时钟驱动下,从0开始向上(或向下,或上下)计数。
- 比较与匹配:用户预先设置一个“比较/捕获寄存器”(CCR)。当计数器的值(CNT)与CCR的值相等时,硬件会自动触发一个“匹配”事件。
- 输出电平翻转:根据定时器通道配置的“输出比较模式”(如PWM模式1或模式2),这个匹配事件会控制对应的输出引脚(如PA8, PB5等)的电平发生跳变(例如从低变高或从高变低)。
- 周期控制:计数器的计数上限由“自动重装载寄存器”(ARR)决定。当CNT计数到ARR值时,产生溢出/更新事件,CNT复位,并开始下一个周期,同时输出引脚电平也可能根据模式再次跳变。
通过精确设置ARR(决定频率)和CCR(决定占空比)的值,我们就能在指定引脚上得到稳定、精确的PWM波形。
注意:STM32的同一个定时器的不同通道(CH1, CH2, CH3, CH4)共享同一个ARR值(即频率相同),但每个通道有自己独立的CCR寄存器,因此可以输出不同占空比的PWM波。
2. 环境准备与工程创建
在开始编码前,需要准备好软硬件环境,并创建一个干净的工程。
2.1 硬件与软件准备
- 硬件:
- STM32开发板一块(本文以STM32F103C8T6核心板为例,其通用定时器TIM2的CH1对应PA0引脚)。
- LED灯一个(或示波器、逻辑分析仪用于观察波形),电阻(220Ω-1kΩ)。
- 杜邦线若干。
- 软件:
- 集成开发环境(IDE):Keil MDK-ARM(uVision5)或 STM32CubeIDE。本文示例将兼容两种开发方式。
- 固件库:标准外设库(SPL)或HAL库。SPL更贴近寄存器,有助于理解原理;HAL库抽象程度高,开发快捷。本文将分别展示。
- 串口调试助手(可选):用于接收调试信息。
2.2 创建工程框架
无论使用SPL还是HAL库,工程都应包含以下核心部分:
main.c:主程序文件。stm32f10x_conf.h(SPL)或stm32f1xx_hal_conf.h(HAL):外设配置文件。startup_stm32f10x_md.s:启动文件。- 对应的外设库源文件和头文件。
使用STM32CubeMX创建HAL库工程(推荐新手):
- 打开STM32CubeMX,选择对应型号(STM32F103C8Tx)。
- 在
Pinout & Configuration视图下,配置系统核心(SYS)、时钟(RCC)。 - 找到
TIM2,激活通道1(CH1)为“PWM Generation CH1”。此时,PA0引脚会自动被配置为复用推挽输出(Alternate Function Push Pull)。 - 在右侧配置视图
Parameter Settings中,设置PWM参数(后文详解)。 - 生成代码,选择IDE为MDK-ARM或STM32CubeIDE。
手动创建SPL库工程: 需要手动添加库文件,并正确配置编译路径和预定义宏(如USE_STDPERIPH_DRIVER,STM32F10X_MD)。对于新手,建议先从CubeMX生成工程学习结构。
3. 配置定时器产生PWM信号
这是最核心的步骤,我们需要计算并设置ARR和CCR的值,并配置定时器的工作模式。
3.1 确定PWM频率与计算参数
假设我们的需求是:在TIM2_CH1(PA0)上产生一个频率为1kHz,初始占空比为50%的PWM波。系统主频(APB1定时器时钟)为72MHz(这是STM32F103常见配置)。
计算定时器时钟(CK_CNT):定时器通常挂在APB1总线上。如果APB1预分频系数不为1,定时器时钟会倍频。在72MHz系统时钟下,APB1常配置为36MHz,而定时器时钟(CK_INT)是其2倍,即72MHz。
计算预分频系数(PSC):计数器CNT的计数时钟
CK_CNT = CK_INT / (PSC + 1)。为了得到合适的计数频率,我们需要先确定ARR。计算自动重装载值(ARR):PWM频率
f_PWM = CK_CNT / ((ARR + 1) * (PSC + 1))。为了计算方便,我们通常先设定一个目标计数器周期。例如,我们希望计数器从0计数到999(即ARR=999),那么每个计数周期的时间是1 / (CK_CNT / (PSC+1))。 更直接的方法是:ARR = (CK_CNT / f_PWM) - 1。但CK_CNT通常很高(72M),直接除以1k会得到72000,ARR过大。因此我们引入预分频PSC来降低计数频率。常用计算步骤:- 设定目标PWM频率
f_PWM = 1000 Hz。 - 设定计数器周期
ARR = 1000 - 1(这样便于直观理解占空比,占空比 = CCR / 1000)。 - 计算所需的计数器时钟:
CK_CNT_need = f_PWM * (ARR + 1) = 1000 * 1000 = 1,000,000 Hz = 1 MHz。 - 计算预分频值:
PSC = (CK_INT / CK_CNT_need) - 1 = (72,000,000 / 1,000,000) - 1 = 72 - 1 = 71。 因此,配置PSC = 71,ARR = 999,即可得到1kHz的PWM频率。
- 设定目标PWM频率
计算比较寄存器值(CCR):占空比 =
(CCR / (ARR + 1)) * 100%。对于50%占空比,CCR = (ARR + 1) * 0.5 = 1000 * 0.5 = 500。
3.2 使用HAL库配置代码
如果使用STM32CubeMX生成代码,上述计算大部分已通过图形化界面完成。我们只需在生成的main.c的用户代码区补充启动PWM的代码。
/* 在main函数初始化部分之后,主循环之前添加 */ /* USER CODE BEGIN 2 */ HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1); // 启动TIM2的通道1输出PWM /* USER CODE END 2 */关键配置在MX_TIM2_Init()函数中,由CubeMX生成:
static void MX_TIM2_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; TIM_OC_InitTypeDef sConfigOC = {0}; htim2.Instance = TIM2; htim2.Init.Prescaler = 71; // 预分频系数 PSC htim2.Init.CounterMode = TIM_COUNTERMODE_UP; // 向上计数模式 htim2.Init.Period = 999; // 自动重装载值 ARR htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; // 使能ARR预装载 if (HAL_TIM_Base_Init(&htim2) != HAL_OK) { Error_Handler(); } sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL; if (HAL_TIM_ConfigClockSource(&htim2, &sClockSourceConfig) != HAL_OK) { Error_Handler(); } if (HAL_TIM_PWM_Init(&htim2) != HAL_OK) { Error_Handler(); } sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET; sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; if (HAL_TIMEx_MasterConfigSynchronization(&htim2, &sMasterConfig) != HAL_OK) { Error_Handler(); } sConfigOC.OCMode = TIM_OCMODE_PWM1; // PWM模式1 sConfigOC.Pulse = 500; // 比较值 CCR,决定初始占空比 sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; // 输出极性:高电平有效 sConfigOC.OCFastMode = TIM_OCFAST_DISABLE; if (HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1) != HAL_OK) { Error_Handler(); } /* USER CODE BEGIN TIM2_Init 2 */ /* 可以在这里调用HAL_TIM_PWM_Start,也可以在主函数调用 */ /* USER CODE END TIM2_Init 2 */ }3.3 使用标准外设库(SPL)配置代码
对于使用SPL的工程,需要手动编写初始化函数。
#include "stm32f10x.h" void TIM2_PWM_Init(u16 arr, u16 psc, u16 ccr) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; // 1. 开启时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 开启GPIOA时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); // 开启TIM2时钟 // 2. 配置GPIOA.0为复用推挽输出 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); // 3. 初始化定时器时基单元 TIM_TimeBaseStructure.TIM_Period = arr; // 自动重装载值 ARR TIM_TimeBaseStructure.TIM_Prescaler = psc; // 预分频系数 PSC TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; // 向上计数 TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); // 4. 初始化PWM输出比较模式 TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; // PWM模式1 TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; // 使能输出 TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; // 输出极性高 TIM_OCInitStructure.TIM_Pulse = ccr; // 设置比较值 CCR TIM_OC1Init(TIM2, &TIM_OCInitStructure); // 初始化通道1 TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable); // 使能CCR预装载 // 5. 使能定时器的ARR预装载寄存器 TIM_ARRPreloadConfig(TIM2, ENABLE); // 6. 使能定时器 TIM_Cmd(TIM2, ENABLE); }在main函数中调用初始化并启动:
int main(void) { // 系统初始化... TIM2_PWM_Init(999, 71, 500); // 1kHz, 50%占空比 while(1) { // 主循环 } }4. 动态调整PWM占空比与验证
生成固定占空比的PWM只是第一步,实际应用需要动态调整。
4.1 动态修改占空比
修改占空比,本质就是修改对应通道的比较寄存器CCR的值。
HAL库方式:
// 将TIM2通道1的占空比修改为75% __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 750); // ARR=999, CCR=750 对应75% // 或者使用函数 TIM2->CCR1 = 750; // 直接操作寄存器,速度更快SPL库方式:
// 将TIM2通道1的占空比修改为25% TIM_SetCompare1(TIM2, 250); // ARR=999, CCR=250 对应25%重要:为了使修改立即生效或在下一个周期生效,并防止在修改CCR时产生毛刺,务必在初始化时使能了CCR的预装载功能(
TIM_OCxPreloadConfig和TIM_ARRPreloadConfig)。修改CCR值实际上是修改了影子寄存器,在下一个更新事件(UEV)时才会被真正加载到工作寄存器中。
4.2 硬件连接与现象验证
将开发板的PA0引脚通过一个220Ω的限流电阻连接到LED的正极,LED负极接GND。
- 编译下载程序后,LED应被点亮,但亮度约为最大亮度的一半(50%占空比)。
- 在程序中动态修改CCR值,例如在循环中递增CCR,你会观察到LED亮度逐渐变亮(呼吸灯效果);递减则变暗。
4.3 使用仪器验证波形
为了精确验证PWM参数,最好使用示波器或逻辑分析仪。
- 将探头连接到PA0引脚。
- 测量波形的周期,应为
1 / 1000Hz = 1ms。 - 测量高电平脉宽,在50%占空比时应为
0.5ms。 - 动态修改CCR值,观察脉宽是否随之线性变化。
如果没有硬件仪器,可以尝试使用串口打印出当前的CCR值进行辅助调试。
5. 常见问题与深度排查
在实际操作中,你可能会遇到以下问题。下面是一个系统的排查路径。
| 问题现象 | 可能原因 | 检查与解决步骤 |
|---|---|---|
| 完全无输出,引脚一直是低电平或高电平 | 1. 定时器或GPIO时钟未开启。 2. 定时器未使能( TIM_Cmd或HAL_TIM_Base_Start)。3. PWM通道未使能输出( TIM_CCxCmd或HAL_TIM_PWM_Start)。4. GPIO引脚模式配置错误(应为复用推挽输出 AF_PP)。5. 引脚复用映射未开启(对于重映射引脚)。 | 1. 检查RCC_APBxPeriphClockCmd或__HAL_RCC_TIMx_CLK_ENABLE是否调用。2. 确认调用了 TIM_Cmd(TIMx, ENABLE)或HAL_TIM_Base_Start(&htimx)。3. 确认调用了 TIM_CtrlPWMOutputs(TIMx, ENABLE)(仅高级定时器TIM1/TIM8需要)或HAL_TIM_PWM_Start。4. 使用CubeMX检查引脚配置,或检查代码中GPIO的 GPIO_Mode。5. 检查 GPIO_PinRemapConfig是否配置正确。 |
| 有输出但频率不对 | 1. 系统时钟(SYSCLK)配置错误。 2. APB1/APB2总线时钟分频配置错误,导致定时器时钟(CK_INT)计算错误。 3. 预分频器(PSC)或自动重载值(ARR)计算或设置错误。 | 1. 确认系统时钟树配置,使用SystemCoreClock变量或RCC_GetClocksFreq函数验证时钟频率。2. 重新计算: 定时器输入时钟 = SystemCoreClock / (APB分频系数),若分频系数≠1,则定时器时钟会倍频。3. 使用公式 f_PWM = CK_INT / ((PSC+1)*(ARR+1))复核PSC和ARR值。 |
| 占空比不可调或调节不线性 | 1. 修改CCR的代码未生效(未操作正确寄存器)。 2. CCR预装载未使能,导致修改时产生毛刺或错误。 3. 修改CCR的时机不当,在计数器接近CCR值时修改可能导致当前周期异常。 | 1. 确认修改的是对应通道的CCR寄存器(TIMx->CCRy)。 2. 在初始化时确保调用了 TIM_OCxPreloadConfig(TIMx, TIM_OCPreload_Enable)。3. 建议在计数器为0(更新事件时)或远离当前CCR值的区域修改CCR。可以使用 TIM_SetComparex函数,它内部会处理同步。 |
| 高级定时器(TIM1/TIM8)无输出 | 高级定时器需要额外使能主输出(MOE)。 | 在SPL中调用TIM_CtrlPWMOutputs(TIM1, ENABLE);在HAL中,HAL_TIM_PWM_Start会自动处理。 |
| 使用逻辑分析仪看到波形抖动 | 1. 系统中有更高优先级的中断频繁打断。 2. 计数器模式配置有误(如中心对齐模式计算更复杂)。 3. 时钟源不稳定。 | 1. 检查中断优先级,确保PWM定时器中断(如果有)优先级合理,或避免在PWM中断中执行冗长操作。 2. 初学建议使用简单的“向上计数”模式。 3. 使用内部时钟源(HSI)通常很稳定。 |
6. 进阶应用与最佳实践
掌握了基础PWM输出后,可以探索更复杂的应用场景。
6.1 实现呼吸灯效果
呼吸灯的本质是让占空比随时间平滑变化。通常使用一个变量(pwm_val)存储当前CCR值,在主循环或定时器中断中周期性修改它。
// 简单呼吸灯示例(在主循环中,实际项目建议用定时器中断) uint16_t pwm_val = 0; int8_t dir = 1; // 方向,1为递增,-1为递减 while(1) { HAL_Delay(10); // 延时10ms,控制变化速度 pwm_val += dir; if(pwm_val > 900) dir = -1; // 增加到90%占空比后递减 if(pwm_val < 10) dir = 1; // 减少到1%占空比后递增 __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, pwm_val); }注意:在主循环中使用
HAL_Delay会阻塞CPU,仅适用于演示。实际产品中,应在定时器更新中断中修改CCR值,以实现更精确、不阻塞主程序的平滑控制。
6.2 驱动舵机(Servo)
舵机需要周期为20ms(50Hz),脉宽在0.5ms到2.5ms之间的PWM信号来定位。以180度舵机为例:
- 0.5ms高电平对应0度。
- 1.5ms高电平对应90度。
- 2.5ms高电平对应180度。
配置计算(系统时钟72MHz,定时器时钟72MHz):
- 周期20ms,即频率50Hz。
- 设定ARR值,使计数器周期对应20ms。
ARR = (CK_CNT / f_PWM) - 1。若PSC=71(将时钟分频至1MHz),则ARR = (1,000,000 / 50) - 1 = 19999。 - 0度对应的CCR值:
CCR_0 = 0.5ms / (20ms / (ARR+1)) = 0.5 / 20 * 20000 = 500。 - 180度对应的CCR值:
CCR_180 = 2.5 / 20 * 20000 = 2500。
因此,将CCR值在500到2500之间线性变化,即可控制舵机在0到180度之间转动。
6.3 驱动直流电机(通过H桥)
直流电机调速需要双向的PWM信号控制H桥电路。通常需要两个互补的PWM通道,并设置死区时间(Dead Time)防止上下桥臂直通短路烧毁电路。这需要使用定时器的互补输出和刹车死区功能,通常由高级定时器(TIM1, TIM8)支持。配置较为复杂,需要仔细查阅参考手册中关于“刹车和死区寄存器(BDTR)”的部分。
6.4 最佳实践总结
- 预装载是关键:始终使能ARR和CCRx的预装载功能(
TIM_ARRPreloadConfig和TIM_OCxPreloadConfig),以确保参数更改在下一个更新周期生效,避免毛刺。 - 时钟配置是基础:在项目开始时就确认好系统时钟树,明确定时器的输入时钟频率,这是所有计算的基石。
- 优先使用CubeMX:对于复杂的定时器功能(如互补输出、编码器接口、霍尔传感器接口),使用STM32CubeMX进行图形化配置可以极大减少底层寄存器配置错误。
- 善用中断和DMA:对于需要频繁、精确更新PWM波形的应用(如复杂灯光序列、音频合成),考虑使用定时器更新中断或DMA来搬运CCR数据,而不是在主循环中修改。
- 测试与验证:务必使用示波器或逻辑分析仪验证实际产生的PWM波形,确保频率、占空比、极性符合预期,这是硬件调试不可替代的一步。
- 文档与计算:将重要的参数计算(如PSC, ARR, CCR范围)以注释形式写在代码旁边,便于后期维护和调整。
从理解PWM的原理,到在STM32上通过配置定时器寄存器产生精确的波形,再到动态控制占空比实现实际功能,这个过程是嵌入式开发中连接软件与硬件的典型桥梁。尝试修改代码,让一个LED实现快慢两种呼吸模式,或者尝试控制舵机精确旋转到指定角度,这会让你对定时器和PWM的理解更加深刻。当遇到问题时,按照第5部分的排查清单,从时钟、使能、配置、引脚四个层面逐一检查,大部分问题都能迎刃而解。