1. 项目概述从SPWM到纯净正弦波最近在做一个需要1kHz正弦波信号源的小项目手头正好有块STM32F103的开发板就琢磨着能不能直接用MCU来生成。直接让DAC输出正弦波表当然可以但频率精度和动态调整能力有限。于是想到了SPWM正弦脉宽调制这条路子——用高频的PWM波通过改变占空比来模拟正弦波的幅度再经过一个简单的低通滤波器就能还原出相当不错的正弦波。这方法在逆变器、电机驱动、D类功放里很常见但用在信号源上对波形的纯净度要求更高。这次我就用STM32CubeMX和HAL库从零开始配置目标是输出一个稳定的1kHz正弦波总谐波失真THD尽量低。整个过程涉及定时器的高级用法、DMA传输、以及滤波器的计算与选型算是把STM32的PWM功能玩出点花样了。2. 核心思路与方案选型2.1 为什么选择SPWM方案首先得明确生成正弦波不止SPWM一种方法。直接DAC播放、DDS芯片、甚至用运放搭文氏电桥振荡器都可以。但我选择SPWM基于几个考虑一是充分利用STM32强大的定时器资源PWM输出频率和占空比可以非常精确地数字控制频率稳定度极高二是便于后续扩展比如想改成频率可调、幅度可调或者生成三相正弦波只需修改软件参数硬件几乎不用动三是成本极低主控加几个阻容元件就能搞定非常适合嵌入式场景。SPWM的本质是用一个远高于目标正弦波频率的载波PWM波其占空比按照正弦规律变化。经过低通滤波器后高频的PWM开关分量被滤除留下的平均电压值就是跟随正弦规律变化的模拟量。这里的关键在于载波频率必须足够高远高于目标正弦波频率和滤波器截止频率否则滤波后波形阶梯感严重失真大。对于1kHz的正弦波我计划将PWM载波频率设定在20kHz以上这样既能保证滤波效果又不会对定时器造成过大的计算压力。2.2 硬件平台与工具链确定主控芯片我选用最常见的STM32F103C8T6蓝色药丸板它完全够用。核心工具是STM32CubeMX进行图形化引脚和时钟初始化开发环境是Keil MDK调试器用ST-Link。这里有个小坑要注意CubeMX的版本和对应的HAL库版本要匹配。我一开始用最新的CubeMX v6.12.0生成了代码后发现一些宏定义和旧教程对不上后来退回到v6.9.2就一切正常了。如果你遇到类似问题检查一下库版本是否兼容你的芯片包。滤波电路部分我选择二阶无源LC低通滤波器。相比一阶RC滤波器二阶的滚降特性更陡峭对PWM载波的抑制效果更好。电感电容的值需要根据目标截止频率计算。我计划将滤波器截止频率设定在2kHz左右略高于1kHz的正弦波以保证基波通过的同时尽可能滤除20kHz的载波及其谐波。3. CubeMX工程配置详解3.1 时钟树配置一切的基础是时钟。STM32F103C8T6最高主频72MHz我们得把它跑满这样才能让定时器产生更高频率、更精细的PWM。在CubeMX的Clock Configuration标签页里进行如下设置选择HSE外部高速时钟为时钟源假设你的板子外部晶振是8MHz。在PLL倍频设置里将PLL输入源选为HSE倍频系数设为9。这样PLL输出就是8MHz * 9 72MHz。将系统时钟源SYSCLK选择为PLL输出这样APB1和APB2总线时钟都会基于72MHz进行分频。APB1总线时钟最高36MHzAPB2总线时钟最高72MHz。我们的定时器如果挂在APB2上如TIM1就能直接获得72MHz的时钟源这对于生成高频PWM至关重要。配置完成后你会看到SYSCLK、HCLK、PCLK1、PCLK2分别显示为72MHz、72MHz、36MHz、72MHz。这一步是性能的基石务必确认无误。3.2 定时器与PWM通道配置生成SPWM波的核心是定时器。我选择高级定时器TIM1因为它功能强大支持互补输出等高级功能虽然本项目用不上而且挂在APB2上时钟频率高。激活TIM1在Pinout Configuration标签页的左侧找到Timers - TIM1。选择时钟源在Clock Source中选择Internal Clock。配置参数切换到Parameter Settings子标签。Prescaler预分频器设置为0即不分频定时器时钟为72MHz。Counter Mode计数模式Up向上计数。Counter Period自动重装载值ARR这是决定PWM载波频率的关键。载波频率Fpwm 72MHz / ((Prescaler 1) * (ARR 1))。如果我们想要一个24kHz的载波频率计算ARR值ARR 72MHz / 24kHz - 1 2999。这里我设置为2999。auto-reload preload使能Enable。这可以确保在更新ARR值时新值在下一次更新事件时才生效避免产生毛刺。配置PWM通道在下方找到Channel1选择PWM Generation CH1。模式选择PWM mode 1这是最常用的模式计数器小于比较值CCR时输出有效电平。CH Polarity通道极性选择High即有效电平为高。这样配置后PA8引脚TIM1_CH1的默认引脚就会被复用为PWM输出。使能DMA这是实现SPWM的关键。我们需要DMA来自动更新比较寄存器CCR的值从而改变占空比。在DMA Settings标签页点击Add选择TIM1_CH1方向为Memory To Peripheral。模式选择Circular循环模式这样DMA会周而复始地搬运正弦波表数据无需CPU干预。数据宽度都选择Word32位因为CCR是16位寄存器但STM32的DMA传输最好对齐到32位总线宽度以提高效率实际我们传输的是16位数据。注意计算ARR值时务必理解“1”的原因。定时器从0计数到ARR总共是ARR1个计数周期。所以公式分母是ARR1。很多新手在这里出错导致实际频率差了一倍。3.3 GPIO与工程生成PWM引脚PA8的配置CubeMX已经自动完成了默认是复用推挽输出无需额外修改。为了观察波形我还会开启一个串口USART1用于打印调试信息但这并非必需。最后在Project Manager标签页设置好项目名称、路径、工具链MDK-ARM V5在Code Generator里选择“生成独立的.c/.h文件”这样代码结构更清晰。点击GENERATE CODE一个完整的HAL库工程骨架就创建好了。4. 软件设计正弦波表与DMA驱动4.1 生成正弦波表数据SPWM的核心是一个预先计算好的正弦波表这个表的值将被DMA循环搬运到TIM1-CCR1寄存器从而实时改变PWM的占空比。首先要确定正弦波表的大小即一个正弦周期分成多少个点。点数越多生成的SPWM波合成的正弦波阶梯数越多波形越平滑失真越小但需要的存储空间和DMA传输带宽也越大。对于1kHz正弦波和24kHz载波一个正弦波周期内载波周期数 载波频率 / 正弦波频率 24。这意味着如果我们用24个点那么每个载波周期切换一个占空比值理论上可以合成正弦波。但24点太粗糙了我通常选择载波频率的正整数倍比如120点或240点这样计算方便。这里我选择120点即每个正弦波周期由120个不同占空比的PWM周期构成。其次计算每个点的占空比值。CCR寄存器的值决定了PWM高电平的持续时间其范围是0到ARR2999。占空比 CCR / (ARR 1)。我们希望输出的正弦波幅度最大化即充分利用PWM的电压范围那么正弦波的表值公式为CCR_value[n] (ARR 1) / 2 * (1 sin(2 * π * n / N))其中n是点的索引0到N-1N是总点数120。(ARR1)/2 是中间值相当于零电平。这个公式生成的值范围在0到(ARR1)之间对应占空比0%到100%。在实际编程中我们提前计算好这个表存放到一个数组中。为了节省RAM特别是点数很多时可以加上const关键字将其存放到Flash中。// 在main.c的USER CODE BEGIN PV区域定义 #define PWM_ARR 2999 #define SINE_TABLE_SIZE 120 static const uint16_t SineTable[SINE_TABLE_SIZE] { // 这里是通过计算生成的值示例为前几个 (uint16_t)((PWM_ARR1)/2 * (1 sin(2*3.1415926*0/120))), // 约等于1500 (uint16_t)((PWM_ARR1)/2 * (1 sin(2*3.1415926*1/120))), // 约等于1577 // ... 后续118个点 };你可以用任何工具如Excel、Python、MATLAB甚至在线计算器来生成这120个数值然后复制粘贴到代码里。这是最直接的方法。4.2 初始化与启动DMA传输CubeMX生成的代码已经为我们配置好了DMA但我们需要在合适的地方启动它。通常我们在定时器PWM初始化之后主循环开始之前启动。在main.c文件中找到/* USER CODE BEGIN 2 */和/* USER CODE END 2 */之间添加启动代码/* USER CODE BEGIN 2 */ // 启动TIM1的PWM输出 HAL_TIM_PWM_Start(htim1, TIM_CHANNEL_1); // 启动DMA传输将正弦波表数据循环搬运到TIM1-CCR1寄存器 // 参数定时器句柄DMA请求源对应通道源数据地址目标地址外设寄存器地址数据数量数据宽度 if (HAL_DMA_Start_IT(htim1.hdma[TIM_DMA_ID_CC1], (uint32_t)SineTable, (uint32_t)htim1.Instance-CCR1, SINE_TABLE_SIZE) ! HAL_OK) { Error_Handler(); } // 使能TIM1的CCR1寄存器DMA更新请求 __HAL_TIM_ENABLE_DMA(htim1, TIM_DMA_UPDATE); /* USER CODE END 2 */这段代码做了几件事首先启动PWM输出此时占空比是默认值。然后启动DMA告诉DMA控制器请从SineTable这个内存地址搬运SINE_TABLE_SIZE个数据到TIM1-CCR1这个外设寄存器地址。TIM_DMA_ID_CC1是CubeMX为TIM1_CH1的DMA请求定义的标识符。最后使能定时器的DMA更新功能这样每次定时器计数器更新产生PWM周期时都会触发一次DMA请求DMA就会自动把下一个正弦波表值搬运到CCR1从而改变下一个PWM周期的占空比。实操心得HAL_DMA_Start_IT中的_IT表示启动带中断的DMA传输这里我们其实用不到传输完成中断因为设置的是循环模式。也可以使用HAL_DMA_Start。但使用_IT版本更通用如果后续想在半传输或传输完成时做点别的事情比如双缓冲留有余地。另外务必检查htim1.hdma[TIM_DMA_ID_CC1]这个指针是否有效有时CubeMX生成的DMA句柄命名可能略有不同可以在tim.c文件中查看。4.3 主循环与调试启动之后CPU就解放了DMA会和定时器自动配合持续输出SPWM波。我们的主循环可以空着或者加一些调试代码。例如我通过串口打印当前的载波频率和正弦波频率用于验证/* USER CODE BEGIN WHILE */ while (1) { // 计算并打印频率信息仅调试用 float pwm_freq 72000000.0f / (1 * (PWM_ARR 1)); // 单位Hz float sine_freq pwm_freq / SINE_TABLE_SIZE; // 单位Hz printf(PWM载波频率: %.2f Hz, 输出正弦波频率: %.2f Hz\r\n, pwm_freq, sine_freq); HAL_Delay(1000); // 每秒打印一次 /* USER CODE END WHILE */ /* USER CODE BEGIN 3 */ } /* USER CODE END 3 */这段代码每秒计算并打印一次频率。理论上PWM载波频率应为72MHz / 3000 24kHz正弦波频率应为24kHz / 120 200Hz。等等200Hz我们的目标是1kHz这里就发现了一个关键问题。5. 关键参数计算与频率校准5.1 频率偏差分析与修正上面的计算暴露了最初设计的一个误区。我设定的参数ARR2999N120产生的是200Hz正弦波而非1kHz。问题出在关系式上正弦波频率 载波频率 / 正弦波表点数。要得到1kHz正弦波有两个可调参数载波频率Fpwm和表点数N。方案一提高载波频率。保持N120不变则需要的Fpwm 1kHz * 120 120kHz。计算ARR 72MHz / 120kHz - 1 599。这个ARR值较小PWM分辨率占空比调节的精细度会下降。分辨率 1 / (ARR1) ≈ 0.167%对于合成正弦波来说勉强够用但可能引入量化噪声。方案二减少表点数。保持Fpwm24kHz不变则需要的N 24kHz / 1kHz 24。点数太少会导致合成的正弦波阶梯状非常明显即使经过滤波波形失真也会很大THD可能超标。方案三折中方案。这是更实际的做法。首先为了保证滤波效果载波频率Fpwm不能太低至少是正弦波频率的15-20倍这里我们至少需要15kHz。为了保证波形质量表点数N不能太少建议在60以上。我们可以先确定一个合适的载波频率比如36kHz这样定时器时钟72MHz除以2000得到然后计算N 36kHz / 1kHz 36。36个点有点少。那我们反过来先确定一个合适的点数比如100点然后计算Fpwm 1kHz * 100 100kHz再计算ARR 72MHz / 100kHz - 1 719。这个ARR值719提供的分辨率约为0.14%尚可接受。我最终选择方案三的变种为了计算方便和较高的载波频率我设定目标正弦波频率为1kHz载波频率为40kHz表点数为40。这样ARR 72MHz / 40kHz - 1 1799。40个点虽然不算多但配合一个设计良好的二阶滤波器输出1kHz正弦波的波形质量在一般应用中是完全可以接受的。如果你对THD有严苛要求可以增加点数到80或100并相应提高载波频率可能需要降低定时器时钟分频。修改步骤在CubeMX中将TIM1的Counter PeriodARR修改为1799。在代码中将PWM_ARR宏定义改为1799。重新生成正弦波表点数SINE_TABLE_SIZE改为40。重新计算SineTable数组的40个值。5.2 滤波器设计与计算SPWM波是数字方波含有丰富的高次谐波。低通滤波器的任务就是无损地通过1kHz的基波并最大限度地衰减40kHz的载波及其边带谐波。我选择二阶巴特沃斯低通滤波器因为它通带平坦设计简单。其截止频率公式为fc 1 / (2π * √(L * C))。我们的目标是让fc略高于1kHz比如1.5kHz同时要对40kHz有足够的衰减。二阶滤波器在远离截止频率处的衰减斜率是-40dB/十倍频意味着频率增加到10倍衰减增加40dB。从1.5kHz到40kHz频率比约26.7倍大约是1.4个十倍频程理论衰减约为56dB这通常足够了。接下来选取L和C的值。有两个约束一是LC乘积由截止频率决定二是电感值和电容值本身要合理电感不宜过大体积大、DCR大电容也不宜过大成本、ESR。一个常用的起点是令L 100µH然后计算CC 1 / ( (2π * fc)^2 * L ) 1 / ( (2*3.1416*1500)^2 * 0.0001 ) ≈ 1.126e-4 F 112.6µF这个电容值偏大我们可以调整。反过来如果选C 10µF则计算LL 1 / ( (2π * fc)^2 * C ) 1 / ( (2*3.1416*1500)^2 * 1e-5 ) ≈ 0.001126 H 1.126mH1.126mH的电感是更常见的选择。最终我确定参数L 1.2mH取标称值C 10µF。根据公式反算实际截止频率fc 1 / (2π * √(0.0012 * 1e-5)) ≈ 1455 Hz接近1.5kHz符合要求。滤波器电路很简单PWM输出引脚PA8串联一个1.2mH电感后作为输出电感到地接一个10µF电容再从电感电容的连接点引出滤波后的正弦波。负载如示波器探头或后续电路也接在此处。注意电容应选用低ESR的陶瓷电容或钽电容电感要选择饱和电流足够的类型。6. 系统调试与波形观测6.1 硬件连接与上电将STM32开发板的PA8引脚通过一个1kΩ的限流电阻防止意外短路连接到我们搭建的LC滤波器输入端。滤波器输出端连接到示波器探头。开发板、示波器共地。使用ST-Link连接开发板进行供电和程序下载。编译并下载修改好参数的程序到STM32。复位运行。6.2 示波器观测与问题排查首先在滤波器输入端即PA8引脚观察波形。你应该能看到一个频率为40kHz的PWM波并且其占空比在不断变化变化周期就是1kHz因为40kHz/40点1kHz。用示波器的自动测量功能确认PWM频率是否为40kHz±误差误差主要来自晶振精度。然后将示波器探头移到滤波器输出端。调整示波器垂直档位和时基。你应该能看到一个频率为1kHz的正弦波。但初始波形可能不理想常见问题及解决方法如下波形幅度太小或失真严重检查PWM占空比范围用示波器单次触发抓取PWM波看看最大和最小占空比是否接近100%和0%。如果占空比变化范围窄正弦波幅度自然就小。检查SineTable数组的计算公式确保其值范围覆盖了0到ARR1799。检查滤波器截止频率如果截止频率设得太低比如接近1kHz会衰减正弦波基波本身导致幅度下降和相位失真。尝试将电容减小如改为4.7µF提高截止频率到2kHz以上再观察。负载影响示波器探头通常是高阻抗1MΩ或10MΩ影响不大。但如果后面接了低阻抗负载会严重改变滤波器特性导致波形畸变。可以在滤波器输出后加一个电压跟随器运放进行缓冲。正弦波上有明显的高频毛刺或台阶载波频率不够高这是最可能的原因。40kHz载波对于1kHz正弦波比率是40倍理论上滤波后残余的开关噪声已经很小。如果毛刺明显可以尝试提高载波频率到80kHz或更高需同步修改ARR和正弦波表点数N并确保定时器时钟支持。滤波器阶数或性能不足二阶滤波器对40kHz的衰减可能在某些情况下不够。可以尝试升级为三阶滤波器多一级RC或者检查电感和电容的实际参数是否与标称值相符特别是电感的自谐振频率是否远高于40kHz。PCB布局或导线引入噪声PWM信号是高速开关信号布线不当会辐射噪声干扰滤波后的模拟信号。尽量让滤波电路靠近MCU引脚使用短而粗的接地线。正弦波频率不准检查计算再次核对公式正弦波频率 定时器时钟 / ( (PSC1) * (ARR1) * N )。我的参数72MHz / (1 * 1800 * 40) 1000 Hz。用示波器频率测量功能验证。检查时钟源确认系统时钟确实是72MHz。可以在代码中通过读取SystemCoreClock变量或者通过MCO引脚输出时钟用示波器测量来验证。6.3 优化与进阶玩法当基本波形稳定后可以考虑以下优化和扩展动态调整频率要改变输出正弦波频率无需重新计算整个正弦波表只需改变定时器的ARR值或预分频器PSC从而改变载波频率Fpwm。因为正弦波频率 Fpwm / N。例如想输出500Hz正弦波将ARR改为3599Fpwm20kHz即可。注意改变ARR后正弦波表的最大值对应峰值幅度也应同步调整为(新ARR1)/2否则幅度会变。更精细的做法是动态重算正弦波表或使用幅度系数。动态调整幅度在DMA传输前对正弦波表的每个值乘以一个幅度系数0~1之间即可实现幅度调节。注意乘法结果不能超过ARR值。使用高分辨率定时器一些STM32系列如G4系列带有高分辨率定时器可以提供皮秒级的时间分辨率能产生频率极其精确、占空比调节非常细腻的PWM特别适合对波形质量要求极高的场合。加入死区时间如果驱动的是H桥等需要互补PWM的电路TIM1的高级功能可以方便地插入死区时间防止上下管直通。使用DAC进行后级校准对于要求极高的场合可以用一个高精度ADC采样滤波后的波形通过软件FFT分析其谐波成分然后微调正弦波表的值进行预失真补偿从而进一步降低THD。这就是一个简单的闭环校正系统了。7. 常见问题与避坑指南在实际操作中我踩过不少坑这里总结一下希望能帮你节省时间DMA传输不启动PWM占空比不变检查DMA配置确保在CubeMX中为正确的定时器通道TIMx_CHy添加了DMA并且方向是Memory To Peripheral。检查启动顺序一定要先HAL_TIM_PWM_Start()再启动DMA。顺序反了可能无法触发DMA请求。检查DMA数据宽度和外设地址TIMx-CCRx是16位寄存器但STM32F1是32位总线。使用uint16_t类型的数组但DMA传输宽度设置为Word32位时DMA会一次传输32位数据其中高16位是无效的。这可能导致CCR被写入错误的值。最稳妥的办法是将正弦波表定义为uint32_t类型并将计算好的16位值存放在每个32位数据的低16位。或者DMA数据宽度设置为Half Word16位。我通常选择后者配置更直观。检查正弦波表对齐如果使用uint16_t数组且DMA宽度为Half Word确保数组在内存中的地址是半字对齐的。通常编译器会处理但可以加上__attribute__((aligned(2)))确保。输出正弦波有周期性失真或杂波检查正弦波表数据可能是计算或手动输入正弦波表时出错。用调试器或串口打印出前10个和后10个表值检查其变化是否符合正弦规律数值是否在0-ARR范围内。检查DMA传输数量确保HAL_DMA_Start_IT中指定的传输数量SINE_TABLE_SIZE与实际数组大小一致。如果设大了DMA会访问非法内存如果设小了一个正弦周期没走完就回头会导致波形拼接处出现跳变。电源噪声PWM是开关信号会对电源产生高频噪声。确保MCU和滤波电路的电源退耦良好在VDD和GND之间靠近芯片引脚处放置一个0.1µF和一个10µF的电容。想改变频率或幅度但修改参数后波形不对同步修改所有相关参数改变ARR必须重新生成正弦波表因为表值计算公式依赖(ARR1)。改变正弦波频率通过改变ARR或PSC如果希望幅度不变正弦波表不需要重新生成因为占空比百分比的变化规律不变但需要确认新的载波频率与表点数N的比值是否等于目标正弦波频率。理解定时器重载时机修改ARR或PSC等定时器参数后需要产生一次更新事件或手动软件触发才能使新值生效。HAL库提供了__HAL_TIM_SET_AUTORELOAD()和__HAL_TIM_SET_PRESCALER()等宏但修改后最好调用一次HAL_TIM_GenerateEvent(htim1, TIM_EVENTSOURCE_UPDATE)。使用高级定时器TIM1或TIM8的注意事项需要额外使能主输出高级定时器的PWM输出CHx/CHxN需要额外使能一个“主输出”位MOE才能实际输出。CubeMX生成的代码通常会在HAL_TIM_PWM_MspInit中自动调用__HAL_TIM_MOE_ENABLE()。如果没有你需要在启动PWM后手动加上__HAL_TIM_MOE_ENABLE(htim1);。刹车和死区功能如果不需要确保相关寄存器已禁用否则可能意外关闭输出。这个项目从原理到实现涵盖了STM32的时钟、定时器、PWM、DMA以及模拟电路滤波的知识。成功的关键在于精确计算参数并理解每个环节软件表、DMA、定时器、滤波器是如何串联起来最终产生纯净正弦波的。调试时耐心地用示波器观察每个节点的波形从数字域的PWM到模拟域的正弦波一步步排查问题总能解决。当你最终在示波器上看到那条光滑的1kHz正弦曲线时那种成就感就是嵌入式开发的乐趣所在。