STM32实现SPWM生成正弦波:从原理到硬件滤波实战
1. 项目缘起:为什么用STM32做SPWM?
最近在做一个需要精密交流信号源的小项目,核心需求是生成一个1kHz、波形质量不错的正弦波。市面上专用的信号发生器模块要么太贵,要么频率、幅度调整不够灵活。手头正好有块STM32F103的开发板,就琢磨着能不能用它的定时器和DAC(或者PWM加滤波)来搞定。一番调研下来,发现用SPWM(正弦脉宽调制)配合RC滤波,是一个在嵌入式领域非常经典且成本极低的方案。
这个方案的思路很直接:单片机本身很难直接输出模拟量,但它擅长输出高精度的数字PWM波。SPWM的本质,就是让PWM波的占空比按照正弦函数的规律变化。当这个高频的PWM波通过一个低通滤波器后,高频的开关分量被滤除,剩下的就是其占空比包络所对应的平均电压,也就是我们想要的正弦波了。STM32的定时器功能强大,用HAL库和CubeMX配置起来非常直观,能快速搭建出SPWM的生成框架。而最终波形质量的好坏,关键就落在了“滤波”这一步上。网上很多教程只讲到生成SPWM,对滤波电路的设计和参数计算往往一笔带过,导致实际做出来波形毛刺多、失真大。这次我就把从CubeMX配置到硬件滤波调试的完整过程,特别是那些容易踩坑的细节,彻底梳理一遍。
2. 核心原理拆解:从PWM到正弦波的魔法
在开始配置之前,我们必须先搞清楚SPWM是怎么变出正弦波的。这不是玄学,其数学基础是面积等效原理:冲量相等而形状不同的窄脉冲,加在具有惯性的环节上,其效果基本相同。
说得直白点,对于一个低通滤波器(比如RC电路),它反应慢,跟不上PWM快速的变化,只能感受到一段时间内电压的平均值。如果我们能控制PWM在一个周期内高电平的“面积”(即脉冲宽度)随时间呈正弦变化,那么滤波器输出端得到的平均电压,也会是正弦变化的。
2.1 SPWM的生成方法:查表法
对于单片机来说,最常用的方法是“查表法”。我们预先计算好一个正弦函数周期内,各个相位点所对应的PWM占空比值,把这些值存成一个数组(正弦表)。程序运行时,定时器以固定的频率(载波频率)触发中断,在每次中断中,按顺序从表中取出下一个占空比值,更新到定时器的比较寄存器中。这样,PWM输出的占空比就会循环地、阶梯状地逼近正弦波。
这里有几个关键参数决定了生成波形的质量:
- 正弦表点数(N):把一个正弦波周期分成多少份。点数越多,生成的阶梯波越平滑,经过滤波后的正弦波失真越小。但点数越多,对定时器中断频率的要求也越高,消耗的CPU资源和内存也越多。对于1kHz正弦波,通常取64点或128点已经能获得很好的效果。
- 载波频率(Fc):即PWM本身的频率。它必须远高于我们想要的正弦波频率(1kHz),这样低通滤波器才能有效滤除PWM开关噪声。通常要求载波频率至少是基波频率的20倍以上,对于1kHz,载波频率选择20kHz-50kHz是比较合适的起点。载波频率也决定了定时器中断的频率,因为每个PWM周期我们至少要更新一次占空比(实际上,在中心对齐模式下,一个PWM周期会触发两次中断事件,这点后面会细说)。
- 调制比(M):正弦表幅值与计数器最大值之比。它决定了输出正弦波的幅度。调制比不能超过1,否则会导致过调制,波形顶部会被削平。
2.2 滤波器的角色与挑战
SPWM波是幅值不变、宽度变化的方波,它包含:
- 我们需要的低频正弦分量(1kHz)。
- 高频的载波分量及其边带谐波(比如20kHz, 40kHz等)。
RC低通滤波器的任务,就是尽可能无衰减地通过1kHz的信号,同时极大地衰减20kHz及以上的噪声。滤波器的截止频率(-3dB点)需要精心设计。如果截止频率设得太高(比如10kHz),那么高频噪声滤不干净,输出正弦波上会有明显的锯齿或毛刺。如果截止频率设得太低(比如500Hz),虽然滤得干净,但我们想要的1kHz正弦波本身也会被严重衰减,导致输出幅度大幅下降。
因此,滤波器的设计是在“波形纯度”和“信号幅度”之间寻找最佳平衡点。这也是本项目调试的核心和难点所在。
3. CubeMX工程配置详解
我们以STM32F103C8T6(蓝色pill开发板)为例,使用TIM1的通道1产生PWM,并通过一个简单的RC滤波器输出。
3.1 时钟树配置
这是确保定时器计数频率准确的基础。在RCC设置中,选择外部高速晶振(HSE)。然后进入Clock Configuration标签页。
- 将HCLK(系统时钟)设置为72MHz(这是F103的常见最高值)。
- 找到APB2定时器时钟(APB2 Timer Clocks)。因为TIM1挂载在APB2总线上,当APB2预分频器不为1时,它的时钟会倍频。在我们默认配置下(APB2 prescaler = 1),TIM1的时钟就是72MHz。记下这个频率,后面计算PWM频率要用。
3.2 定时器PWM模式配置
- 在Pinout视图,找到TIM1,展开其通道,选择
CH1为PWM Generation CH1。这会在PA8引脚上产生PWM输出。 - 进入
Configuration->TIM1进行参数设置。- Prescaler(预分频器):设置为
71。定时器时钟是72MHz,经过(71+1)=72分频,得到1MHz的计数时钟。这样计数器每微秒计数一次,方便计算。 - Counter Mode(计数模式):选择
Center Aligned 1(中心对齐模式1)。这是关键!中心对齐模式下,计数器先向上计数到自动重载值(ARR),然后向下计数到0。PWM输出更加对称,谐波特性更好,非常适合用于生成SPWM。而且,它会在计数器向上和向下计数时都触发比较事件,这意味着我们可以在一个PWM周期内更新两次占空比数据,理论上可以使等效的载波频率翻倍,或者用同样的中断频率获得更平滑的正弦波。 - Counter Period(自动重载值ARR):这是我们调节PWM频率的主要参数。PWM频率的计算公式为:
Fpwm = Ftim_clk / ((ARR + 1) * 分频比)。对于中心对齐模式,公式稍有不同,但CubeMX会直接显示计算结果。我们先设为999,即ARR=999。此时,Fpwm = 1MHz / 1000 = 1kHz。注意,这是PWM的基频,但我们最终要通过查表法让这个基频“抖动”起来,形成SPWM的载波。实际上,ARR值决定了PWM的计数分辨率,更大的ARR值意味着我们可以用更精细的阶梯(正弦表值)去逼近正弦波。 - Pulse(初始脉冲值):先设为0。
- CH Polarity(通道极性):保持默认
High即可。 - 在
NVIC Settings中,使能TIM1的更新中断(TIM1 update interrupt)。这是我们执行查表更新操作的地方。
- Prescaler(预分频器):设置为
3.3 生成代码与基础框架
点击GENERATE CODE生成工程。在生成的tim.c文件中,我们找到HAL_TIM_PWM_MspInit函数,可以看到中断已经配置好。在main.c中,我们需要启动PWM和定时器更新中断。
// 在main函数初始化部分后启动 HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); // 启动PWM输出 HAL_TIM_Base_Start_IT(&htim1); // 启动定时器基中断(更新中断)4. 软件实现:正弦表与中断服务程序
4.1 生成正弦表
我们使用一个简单的Python脚本(或任何计算工具)来生成正弦表。这里以64点为例。
import math POINTS = 64 # 正弦表点数 MAX_VAL = 1000 # 对应定时器的ARR值,这里为999,我们取1000便于计算 AMPLITUDE = 0.8 # 调制比,0.8避免过调制 sine_table = [] for i in range(POINTS): # 计算0到2π之间的正弦值,范围[-1, 1] radian = 2 * math.pi * i / POINTS sine_value = math.sin(radian) # 将正弦值映射到PWM比较寄存器值,并考虑调制比 # 先归一化到[0, 1]: (sine_value + 1) / 2 # 再乘以幅度和最大值 pwm_value = int(((sine_value + 1) / 2) * AMPLITUDE * MAX_VAL) # 确保值在[0, ARR]范围内 pwm_value = max(0, min(pwm_value, MAX_VAL)) sine_table.append(pwm_value) print("const uint16_t sine_table[%d] = {" % POINTS) for i, val in enumerate(sine_table): if i % 8 == 0: print(" ", end="") print("%4d," % val, end="") if i % 8 == 7 or i == len(sine_table)-1: print() print("};")将生成的数组复制到main.c或一个头文件中。
4.2 实现更新中断回调函数
定时器更新中断发生时,我们需要更新比较寄存器的值。这里有一个非常重要的细节:在中心对齐模式下,为了避免在计数器正在与旧比较值匹配时更新寄存器导致输出异常,通常会在计数器为0时(即更新事件发生时)进行更新。HAL库的中断回调函数HAL_TIM_PeriodElapsedCallback正是在此时被调用。
// 在main.c的用户代码区定义全局变量 uint16_t sine_index = 0; #define SINE_TABLE_SIZE 64 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM1) { // 更新TIM1通道1的比较值(占空比) __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, sine_table[sine_index]); // 更新索引,指向下一个点 sine_index++; if (sine_index >= SINE_TABLE_SIZE) { sine_index = 0; } } }4.3 计算实际输出频率
现在我们来算一下实际输出的正弦波频率是多少。
- 定时器更新中断的频率 = 定时器时钟 / ((PSC+1) * (ARR+1))? 这里要注意,对于中心对齐模式,计数器要经历从0->ARR->0一个完整周期才产生一次更新事件。所以更新事件的频率是:
F_update = Ftim_clk / ((PSC+1) * (ARR+1) * 2)。 - 代入我们的值:
F_update = 72MHz / (72 * 1000 * 2) = 72,000,000 / 144,000 = 500 Hz。 - 这意味着我们每秒能更新500次占空比。而我们一个完整的正弦波有64个点。所以,最终输出的正弦波频率为:
F_sine = F_update / SINE_TABLE_SIZE = 500 Hz / 64 ≈ 7.81 Hz。
这离我们目标的1kHz差得太远了!问题出在哪?在于我们混淆了PWM载波频率和正弦波生成频率。ARR=999决定了PWM的周期是1000个计数时钟(1ms,即1kHz),这个频率对于后续滤波来说太低了(仅比目标正弦波频率高一点,根本无法有效滤波)。我们需要大幅提高PWM的载波频率。
核心调整:我们的目标是生成1kHz正弦波,载波频率至少需要20kHz。因此,我们需要重新设定ARR值,让PWM基频(载频)在20kHz以上。同时,我们需要提高定时器更新中断的频率,使其能够驱动足够多的点(比如64点)来合成1kHz的正弦波。
4.4 参数重新设计与优化
让我们重新设定目标:
- 期望正弦波频率 Fsine = 1kHz。
- 期望载波频率 Fcarrier ≥ 20kHz。
- 正弦表点数 N = 64。
- 定时器更新中断频率 Fupdate = Fsine * N = 1kHz * 64 = 64kHz。这是为了每秒能输出64k个点,组成1000个完整的正弦波。
计算步骤:
- 步骤一:确定ARR值以满足载波频率。
Fcarrier = Ftim_clk / ((PSC+1) * (ARR+1))(边沿对齐模式公式,中心对齐频率是它的一半,我们先按边沿对齐算,取更保守的值)。为了留有余地,我们设Fcarrier_target = 40kHz。ARR = Ftim_clk / (Fcarrier_target * (PSC+1)) - 1 = 72MHz / (40kHz * 72) - 1 ≈ 25 - 1 = 24。- 取整,设
ARR = 24。此时实际载波频率Fcarrier = 72MHz / (72 * 25) = 40kHz(边沿对齐)。在中心对齐模式下,PWM频率为20kHz,仍然满足 >20kHz 的要求。
- 步骤二:验证在此ARR下,能否实现64kHz的更新中断频率。中心对齐模式的更新中断频率公式为:
Fupdate = Ftim_clk / ((PSC+1) * (ARR+1) * 2)。Fupdate = 72MHz / (72 * 25 * 2) = 72,000,000 / 3600 = 20,000 Hz。- 这只有20kHz,远达不到我们需要的64kHz。这意味着我们无法在每次PWM周期都更新一个正弦表点。如果我们坚持用64点表,那么能生成的正弦波频率最高为
Fupdate / N = 20kHz / 64 = 312.5 Hz。
矛盾出现了:高载波频率(需要小的ARR)和高正弦波分辨率/频率(需要高的更新中断频率)对ARR的要求是冲突的。解决方案有两种:
- 降低正弦表点数:例如改用32点或16点。这样在20kHz更新频率下,可以生成
20kHz / 32 = 625Hz或20kHz / 16 = 1250Hz的正弦波。对于1kHz目标,16点表勉强可用,但波形失真会比较大。 - 使用定时器的DMA或触发输出模式:这是更专业的方法。可以预先将整个正弦表通过DMA循环搬运到定时器的比较寄存器,完全不需要CPU中断干预。这样,更新速率可以达到定时器时钟的极高频率,不受中断处理能力限制。但配置更为复杂。
为了简化,本例我们采用折中方案:使用32点正弦表,目标生成约625Hz正弦波,验证SPWM生成和滤波的基本流程。理解了原理后,你可以通过提高主频(如果芯片支持)、使用更高性能的定时器(如STM32G4系列的高级定时器)、或者采用DMA方式来逼近1kHz的目标。
调整CubeMX配置:
- 将TIM1的
Counter Period (ARR)修改为24。 - 重新生成代码,并更新正弦表生成脚本中的
MAX_VAL为24(ARR值)。 - 将
SINE_TABLE_SIZE改为32,重新生成正弦表。
此时理论计算:
- PWM载波频率(中心对齐):
Fcarrier_center = 72MHz / (72 * 25 * 2) = 20kHz。 - 定时器更新中断频率:
Fupdate = 20kHz(中心对齐模式下,一次完整计数周期产生一次更新中断)。 - 输出正弦波频率:
Fsine = Fupdate / 32 = 20kHz / 32 = 625 Hz。
5. 硬件滤波电路设计与调试
这是将数字SPWM变为干净模拟正弦波的关键一步。我们使用最经典的无源RC低通滤波器。
5.1 滤波器参数计算
RC低通滤波器的截止频率公式为:Fc = 1 / (2πRC)。
- 我们的目标是通过625Hz的正弦波,滤除20kHz的载波及其谐波。
- 需要在625Hz和20kHz之间选择一个合适的截止频率。如果选得太靠近625Hz,正弦波会有衰减;选得太靠近20kHz,滤波效果不佳。通常取载波频率的1/10到1/20作为截止频率,能取得较好的效果。我们取
Fc_target = 2kHz。 - 选择常见的电阻值,例如
R = 1kΩ。 - 计算电容值:
C = 1 / (2π * Fc * R) = 1 / (2 * 3.1416 * 2000 * 1000) ≈ 79.6 nF。 - 选择最接近的标准电容值,例如
82nF(0.082uF)。此时实际截止频率Fc = 1 / (2π * 1000 * 82e-9) ≈ 1940 Hz。
5.2 电路搭建与实测问题
按照计算,在PA8引脚和地之间串联一个1kΩ电阻,然后在电阻后(输出端)对地接一个82nF的电容,就构成了一个简单的RC低通滤波器。
用示波器探头连接到滤波器的输出端(电容两端)。你可能会看到以下情况:
- 现象A:波形幅度非常小。这是因为RC滤波器在截止频率附近对信号已有衰减。我们的正弦波频率625Hz,滤波器截止频率约1.94kHz,衰减不算大,但PWM的占空比平均电压范围是0到3.3V(假设单片机供电为3.3V),经过调制,正弦波的峰值幅度会小于1.65V。实测可能在1Vpp左右。如果需要更大电压,可以在滤波后加一级运算放大器进行放大。
- 现象B:波形上有明显的高频毛刺。这说明20kHz的载波没有被完全滤除。可能的原因:
- 截止频率还是太高:尝试将截止频率降低。例如,将电阻改为2.2kΩ,电容改为100nF,此时
Fc ≈ 723 Hz。重新测量,你会发现毛刺少了很多,但正弦波幅度也进一步下降了。这就是之前提到的权衡。 - 单级滤波不够:可以考虑使用两级RC滤波(两个电阻和两个电容组成)。两级滤波的滚降斜率是-40dB/十倍频,比一级的-20dB/十倍频更陡峭,滤波效果更好。设计时,每级的截止频率可以设为相同,比如都是2kHz,或者第二级稍低一些。
- 负载效应:示波器探头本身有输入阻抗(通常是1MΩ或10MΩ)和输入电容(通常十几pF)。这个负载并联在你的滤波电容上,会改变实际的滤波特性。对于高阻抗节点,探头电容的影响尤其明显。
- 截止频率还是太高:尝试将截止频率降低。例如,将电阻改为2.2kΩ,电容改为100nF,此时
5.3 进阶优化:使用有源滤波器
当对波形质量要求较高时,无源RC滤波器可能力不从心。可以考虑使用运算放大器搭建有源低通滤波器,例如Sallen-Key或多重反馈(MFB)拓扑。有源滤波器可以提供更陡峭的衰减斜率、更好的带内平坦度,并且由于运放的高输入阻抗和低输出阻抗,可以隔离前后级,避免负载影响。例如,一个二阶Sallen-Key低通滤波器,可以轻松实现-40dB/十倍频的衰减,在20kHz处提供远超无源RC电路的抑制能力。
实操心得:在面包板上搭建RC滤波器时,元件布局和引线长度非常关键。尽量让电阻和电容的引脚剪短,紧靠在一起,并且靠近单片机的输出引脚。过长的引线会引入额外的寄生电感和电容,影响高频滤波效果,甚至可能形成天线辐射噪声。如果条件允许,直接在设计PCB时集成滤波电路是最好选择。
6. 关键问题排查与性能提升思路
在实际操作中,你可能会遇到一些意想不到的问题。
6.1 输出波形频率不准或抖动
- 检查时钟源:确保系统时钟(HSE)准确。如果使用内部RC振荡器(HSI),精度较差,可能导致频率漂移。
- 检查中断响应时间:如果主程序中还有其他高优先级中断或耗时任务,可能会延迟定时器中断的响应,导致更新占空比的时刻不准确,引起波形抖动。确保SPWM更新中断具有足够高的优先级。
- 使用DMA:这是消除中断延迟影响、获得最稳定波形的终极方案。将正弦表配置到DMA循环模式,让DMA自动在定时器更新事件时将下一个数据搬运到比较寄存器,完全解放CPU,精度和稳定性极高。
6.2 滤波器输出有直流偏置
理想的SPWM波形,其占空比在0%到100%之间对称变化,平均电压会在电源电压的一半上下波动。但如果你的正弦表计算映射时,最小值不是0,或者PWM极性配置问题,可能导致输出有一个固定的直流偏移。用示波器的直流耦合档测量,观察正弦波是否关于1.65V(3.3V/2)对称。如果不是,检查正弦表生成算法和__HAL_TIM_SET_COMPARE设置的值是否在0到ARR之间。
6.3 追求更高的正弦波频率(如1kHz)
如前所述,受限于定时器中断频率,用查询法很难在保持高载波频率和高点数的情况下生成高频正弦波。解决方案包括:
- 换用更高主频的MCU:如STM32F4、H7系列,主频可达几百MHz,能大幅提升定时器时钟和中断处理能力。
- 使用定时器的PWM DMA模式:这是最推荐的方法。以STM32F1为例,虽然TIM1的DMA功能可能不如高级系列强大,但依然可以尝试。配置DMA循环模式,将正弦表首地址指向
TIM1->CCR1(通道1比较寄存器)。在CubeMX中配置DMA请求来自TIM1_CH1,方向为存储器到外设。这样,每次定时器更新事件(或比较匹配事件)会自动触发DMA搬运,无需中断。 - 使用芯片内置的DAC:如果只是要产生正弦波,且对功率没有要求,使用DAC直接输出是更简单纯净的方案。可以结合DMA和定时器触发,同样能产生高质量波形,且无需外部滤波。但DAC的输出驱动能力通常较弱。
6.4 测量与验证技巧
- 观察SPWM原始波形:在连接滤波器之前,先用示波器看PA8引脚的原始输出。你应该能看到一个PWM波,其脉冲宽度在缓慢地、周期性地变化。打开示波器的测量功能,测量其频率,应该等于你计算的载波频率(20kHz左右)。
- 使用示波器的FFT功能:这是分析滤波器效果的利器。在滤波器的输出端,打开FFT。你会看到一个主要的尖峰在625Hz(基波),在20kHz、40kHz等处应该还有较小的尖峰(谐波)。对比滤波前后,这些高频谐波的幅度衰减了多少dB,可以直观地评价滤波器的性能。一个好的滤波器应该让20kHz处的谐波幅度比基波低40dB以上。
- 负载测试:如果你的正弦波需要驱动后续电路(如运放、功率放大器),一定要在带负载的情况下测试波形。负载可能会拉低输出幅度,甚至影响滤波特性。
