STM32 DMA自动更新PWM占空比:解放CPU实现高精度波形生成

发布时间:2026/6/7 12:38:15

STM32 DMA自动更新PWM占空比:解放CPU实现高精度波形生成 1. 项目概述与核心价值在嵌入式开发中尤其是电机控制、LED调光、逆变器等领域我们经常需要生成精确且动态变化的PWM信号。传统做法是使用定时器中断在中断服务程序里手动更新比较寄存器CCR的值来改变占空比。这种方法虽然直观但存在两个明显短板一是频繁的中断会占用大量CPU时间影响系统处理其他任务的能力二是当PWM更新频率要求很高或更新序列很复杂时CPU可能不堪重负导致PWM输出抖动或系统响应延迟。这次分享的项目正是为了解决这个痛点。我将通过一个基于STM32F103的实际案例详细拆解如何利用直接存储器访问DMA功能实现PWM占空比的自动、无CPU干预更新。简单来说就是让DMA充当一个“勤劳的数据搬运工”它根据定时器的更新事件自动将我们预先存储在内存数组中的一组占空比值依次搬运到定时器的捕获/比较寄存器中。整个过程完全由硬件完成CPU只需在初始化时“交代”好任务之后就可以“喝茶休息”或者去处理更重要的逻辑了。这个技巧的价值在于解放CPU和提高精度。对于需要复杂PWM波形序列如正弦波SPWM、SVPWM矢量合成、呼吸灯效果的应用你可以预先计算好一个周期的占空比数组然后交给DMA去自动循环播放。CPU被彻底解放出来系统实时性得到质的提升。本文将以生成一个可自动变化占空比的PWM波为例从时钟树配置、GPIO与定时器设置到DMA通道的详细配置手把手带你实现这一功能并分享我在调试过程中踩过的坑和总结的经验。2. 硬件平台与核心思路解析2.1 硬件环境与目标本次实验使用的硬件核心是一块基于STM32F103C8T6的开发板文中提到的DZY2.PCB。该MCU属于STM32F1系列的“增强型”产品主频最高可达72MHz拥有丰富的外设包括高级定时器TIM1和DMA控制器完全满足我们的需求。项目目标使用STM32的TIM1通道3CH3输出一路PWM波并通过DMA1的通道5实现PWM占空比即TIM1_CCR3寄存器的值的自动更新。我们将预先定义一个占空比数组SRC_Buffer[]DMA会在这个数组和TIM1_CCR3寄存器之间建立一条传输通道每当TIM1产生一次更新事件Update Event就触发一次DMA传输将数组中的下一个值搬运到CCR3从而改变下一个PWM周期的占空比。2.2 系统框架与数据流理解整个系统的数据流是成功配置的关键。其核心交互关系如下图所示概念图------------------- 触发 ------------------- | | --------- | | | TIM1 定时器 | | DMA1 通道5 | | (产生更新事件) | | (数据搬运工) | | | --------- | | ------------------- DMA请求 ------------------- | | | 更新比较寄存器 | 从内存读取数据 v v ---------------- ------------------ | TIM1_CCR3 | -------- | SRC_Buffer[] | | (PWM占空比) | 写入 | (占空比数组) | ---------------- ------------------ | v 输出引脚PA10 (产生PWM波形)初始化阶段CPU配置好TIM1的PWM模式、DMA的源地址内存数组和目标地址TIM1_CCR3寄存器。运行阶段TIM1作为时基每个PWM周期结束时计数器溢出会产生一个“更新事件”。这个更新事件会向DMA控制器发出一个传输请求DMA Request。DMA控制器收到请求后自动执行一次数据传输从SRC_Buffer数组中读取一个16位数据并将其写入TIM1_CCR3寄存器。TIM1在下一个PWM周期就会使用这个新的CCR3值来生成PWM占空比由此改变。由于DMA被设置为循环模式Circular Mode当它搬运完数组最后一个数据后会自动跳回数组开头继续搬运从而实现PWM波形的循环播放。这种硬件协作的方式其精度仅取决于时钟和定时器不受软件中断延迟的影响非常适合对实时性要求高的场景。3. 关键外设配置详解3.1 时钟系统配置稳定的时钟是一切外设工作的基础。STM32的时钟树相对复杂但对于本项目我们关注以下几点static void RCC_Configuration(void) { // ... 使能HSE外部8MHz晶振 RCC_HSEConfig(RCC_HSE_ON); // ... 等待HSE稳定 // 配置PLLHSE作为源9倍频 - 8MHz * 9 72MHz RCC_PLLConfig(RCC_PLLSource_HSE_Div1, RCC_PLLMul_9); RCC_PLLCmd(ENABLE); // ... 等待PLL就绪 // 选择PLL输出作为系统时钟SYSCLK RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK); // ... 等待切换完成 // 使能后续要用到的外设时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM1 | RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // DMA时钟在AHB总线上 }关键点解析TIM1的时钟TIM1属于APB2总线上的外设。在STM32F1中如果APB2的预分频器不为1则定时器的时钟是APB2时钟的2倍。我们通常将APB2设为不分频72MHz因此TIM1的时钟TIM1CLK就是72MHz。这是计算PWM频率和占空比的基础。DMA的时钟DMA控制器挂在AHB总线上AHB时钟通常与系统时钟SYSCLK同速即72MHz。必须使能RCC_AHBPeriph_DMA1时钟DMA才能工作。AFIO时钟当我们需要重映射引脚功能时本例未使用需要使能AFIO时钟。为保险起见通常一并开启。3.2 GPIO与定时器TIM1配置我们的目标是让TIM1的通道3输出PWM对应引脚是PA10CH3和PB15CH3N互补输出本例未使用。static void GPIO_Configuration(void) { GPIO_InitTypeDef GPIO_InitStructure; // 使能GPIOA和AFIO时钟已在RCC中使能 // 配置PA10为复用推挽输出AF_PP GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; // 关键必须为复用推挽 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; // 建议高速输出波形更干净 GPIO_Init(GPIOA, GPIO_InitStructure); } 注意PWM输出引脚必须配置为复用推挽输出GPIO_Mode_AF_PP。如果错误地配置为通用推挽输出GPIO_Mode_Out_PP定时器将无法控制该引脚输出PWM。接下来是重头戏——TIM1的配置。我们需要将其配置为PWM模式并启用更新事件的DMA请求。void Tim1_Configuration(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; uint16_t TIM_Prescaler, TIM_Period; uint32_t utemp; // 假设我们目标PWM频率 Freq_PWM 20kHz (20,000 Hz) #define Freq_PWM 20000 // 计算预分频器和自动重载值ARR // TIM1CLK 72,000,000 Hz utemp (uint32_t)(72000000) / Freq_PWM; // 一个PWM周期需要的计数次数 TIM_Prescaler utemp / 65536; // 因为ARR是16位寄存器最大65535 TIM_Prescaler; // 确保分频后频率不低于目标值 utemp (uint32_t)(72000000) / TIM_Prescaler; // 分频后的定时器计数频率 TIM_Period utemp / Freq_PWM; // 计算ARR值 // 时基单元配置 TIM_TimeBaseStructure.TIM_Period TIM_Period - 1; // ARR TIM_TimeBaseStructure.TIM_Prescaler TIM_Prescaler - 1; // PSC TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; // 向上计数模式 TIM_TimeBaseStructure.TIM_RepetitionCounter 0; // 重复计数器高级定时器特有用于控制更新速率 TIM_TimeBaseInit(TIM1, TIM_TimeBaseStructure); // PWM模式配置通道3 TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM2; // PWM模式2 TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; // 使能主输出 TIM_OCInitStructure.TIM_OutputNState TIM_OutputNState_Disable; // 禁用互补输出 TIM_OCInitStructure.TIM_Pulse 72; // 初始CCR值决定初始占空比 TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_Low; // 输出极性低 TIM_OC3Init(TIM1, TIM_OCInitStructure); // 初始化通道3 // 使能通道3的CCR预装载寄存器 TIM_OC3PreloadConfig(TIM1, TIM_OCPreload_Enable); // !!! 最关键的一步使能TIM1更新事件的DMA请求 !!! TIM_DMACmd(TIM1, TIM_DMA_Update, ENABLE); // 使能定时器 TIM_Cmd(TIM1, ENABLE); // 对于高级定时器TIM1/TIM8必须使能主输出MOE才能输出PWM TIM_CtrlPWMOutputs(TIM1, ENABLE); }配置要点与避坑指南频率计算PWM频率 TIM1CLK / ((PSC1) * (ARR1))。代码中的计算逻辑是先估算需要的总计数值然后根据16位ARR的最大值决定是否需要分频PSC。PWM模式选择TIM_OCMode_PWM1和TIM_OCMode_PWM2决定了计数器和比较值匹配时输出电平的极性。结合TIM_OCPolarity可以灵活配置有效电平是高还是低。我常用PWM2低极性这样CCR值越大高电平时间越长更符合直觉。预装载寄存器TIM_OC3PreloadConfig(TIM1, TIM_OCPreload_Enable)这一行至关重要。它开启了CCR寄存器的预装载功能。意味着我们通过DMA写入的是“影子寄存器”只在定时器更新事件发生时影子寄存器的值才会被真正加载到当前寄存器中。这避免了在PWM周期中间修改CCR值可能导致的脉冲宽度异常。主输出使能对于高级定时器TIM1/TIM8配置好PWM后必须调用TIM_CtrlPWMOutputs(TIM1, ENABLE)来开启主输出否则引脚不会有任何波形。这是新手常忘的一步。重复计数器TIM_RepetitionCounter是高级定时器独有的。它可以用来降低更新事件的频率。例如设为N则定时器需要溢出N1次才会产生一次真正的更新事件和DMA请求。本例中设为0表示每次溢出都更新。3.3 DMA通道配置这是实现自动更新的核心。我们需要将DMA1的通道5对于STM32F103TIM1_UP事件对应DMA1通道5配置为从内存到外设的传输。#define TIM1_CCR3_Address 0x40012C3C // TIM1_CCR3寄存器的绝对地址 uint16_t SRC_Buffer[] {72*5, 72*10, 72*20, 72*40, 72*10}; // 占空比数组值CCR void DMA_Configuration(void) { DMA_InitTypeDef DMA_InitStructure; // 1. 使能DMA1时钟 RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // 2. 复位并初始化DMA1通道5 DMA_DeInit(DMA1_Channel5); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)TIM1_CCR3_Address; // 目标地址外设寄存器 DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)SRC_Buffer; // 源地址内存数组 DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralDST; // 传输方向内存-外设 DMA_InitStructure.DMA_BufferSize sizeof(SRC_Buffer) / sizeof(SRC_Buffer[0]); // 传输数据项数量 DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; // 外设地址不递增 DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; // 内存地址递增 DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; // 外设数据宽度16位 DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; // 内存数据宽度16位 DMA_InitStructure.DMA_Mode DMA_Mode_Circular; // 循环模式 DMA_InitStructure.DMA_Priority DMA_Priority_High; // 优先级高 DMA_InitStructure.DMA_M2M DMA_M2M_Disable; // 禁用内存到内存模式 DMA_Init(DMA1_Channel5, DMA_InitStructure); // 3. 可选使能传输完成中断用于处理数组传输完一轮的情况 DMA_ClearFlag(DMA1_IT_TC5); // 清除传输完成标志 DMA_ITConfig(DMA1_Channel5, DMA_IT_TC, ENABLE); // 使能传输完成中断 // 4. 使能DMA通道 DMA_Cmd(DMA1_Channel5, ENABLE); }DMA配置深度解析外设基地址必须是要写入的寄存器的确切地址。TIM1_CCR3_Address就是TIM1-CCR3的地址。可以通过查看数据手册或库文件中的定义获得。缓冲区大小DMA_BufferSize指的是数据项的数量而不是字节数。因为我们设置数据宽度为半字16位所以用数组元素个数sizeof(array)/sizeof(array[0])来计算。地址递增外设地址固定因为我们始终是往同一个寄存器TIM1_CCR3写数据。内存地址需要递增以便DMA依次读取数组中的每个元素。循环模式DMA_Mode_Circular是自动更新的关键。在此模式下当DMA传输完指定数量的数据后会自动将传输计数器重置为初始值并从头开始下一轮传输从而实现PWM波形的无限循环输出。M2M模式必须禁用。我们是由外设TIM1更新事件触发DMA传输而不是由软件手动触发内存到内存的传输。 重要心得计算CCR值的技巧CCR值决定了PWM的占空比。占空比 CCR / (ARR 1)。在我的代码中SRC_Buffer数组的值是72 * n。这里的72是怎么来的 因为我的定时器时钟是72MHzPWM频率是20kHz所以 ARR 72MHz / 20kHz - 1 3599。但我为了方便演示预分频器可能不为0ARR值会变化。72这个因子实际上来源于TIM1CLK / Freq_PWM的一个简化计算它表示计数器每微秒的计数次数72MHz - 72次/微秒。72*5就代表5微秒的高电平时间。这是一种实用的工程简化便于直观控制脉冲宽度。在实际项目中你需要根据精确的ARR值来计算CCR。4. 系统集成与调试流程4.1 主程序与初始化顺序正确的初始化顺序是稳定工作的前提。推荐顺序如下int main(void) { // 第1步配置系统时钟RCC这是所有外设的脉搏 RCC_Configuration(); // 第2步配置GPIO将引脚初始化为复用功能 GPIO_Configuration(); // 第3步短暂延时让电源和时钟稳定尤其是使用外部晶振时 Delay_ms(100); // 第4步配置定时器TIM1但先不使能或使能但不使能MOE Tim1_Configuration(); // 内部最后才使能TIM1和MOE也可以拆分 // 第5步配置DMA建立数据传输链路 DMA_Configuration(); // 第6步配置NVIC如果需要DMA中断的话 NVIC_Configuration(); // 第7步配置SysTick等系统定时器可选 SysTick_Config1(); // 初始化完成指示灯可选 for(int i0; i6; i) { LED_Toggle(); Delay_ms(100); } // 第8步主循环此时CPU已“解放” while(1) { // CPU可以在这里执行其他任务如按键扫描、通信、算法计算等 // PWM的更新完全由TIM1和DMA硬件协作完成 Process_Other_Tasks(); } }顺序逻辑必须先有时钟和GPIO然后配置DMA的“目的地”TIM1相关寄存器最后再启动DMA和定时器。如果顺序颠倒可能会导致DMA在定时器未就绪时就开始写寄存器或者定时器使用了错误的初始CCR值。4.2 使用示波器进行调试验证硬件调试离不开示波器。将探头连接到PA10引脚你可以验证以下内容基础PWM首先在不开启DMA自动更新功能的情况下手动设置一个固定的CCR值检查PWM频率应为20kHz和占空比是否与计算值相符。这能排除定时器基础配置的错误。DMA自动更新然后启用DMA和TIM1的更新DMA请求。你应该能看到PWM的占空比按照SRC_Buffer数组中定义的序列周期性变化。用示波器的单次触发或余晖模式可以清晰捕捉到占空比变化的瞬间。测量更新时机将另一个探头连接到某个GPIO如PC7在DMA传输完成中断如果使能了里翻转该引脚。通过测量两个波形的时间差可以精确知道DMA传输发生的时刻它应该紧跟在定时器更新事件之后。调试技巧如果看不到PWM波形一个万能的排查步骤是检查时钟树配置确认TIM1和DMA时钟已使能。检查GPIO模式是否为GPIO_Mode_AF_PP。检查TIM_CtrlPWMOutputs是否被调用。检查DMA的源/目标地址、数据长度、传输方向是否正确。使用调试器在运行时查看TIM1-CCR3寄存器的值是否在变化DMA1_Channel5-CNDTR寄存器剩余数据项数是否在递减。5. 进阶应用与优化思考5.1 生成复杂波形SPWM示例DMA自动更新PWM的真正威力在于生成复杂波形。以生成50Hz正弦波SPWM为例#define SINE_TABLE_SIZE 200 // 一个正弦周期采样200个点 #define PWM_ARR_VALUE 3599 // 假设ARR3599对应20kHz载波 uint16_t SPWM_Buffer[SINE_TABLE_SIZE]; void Generate_SPWM_Table(void) { for(int i 0; i SINE_TABLE_SIZE; i) { // 计算0~2π范围内的正弦值幅度在0~1之间 float sine_value 0.5 0.5 * sin(2 * 3.1415926f * i / SINE_TABLE_SIZE); // 将幅度映射为CCR值限制在合理范围如ARR的5%~95% SPWM_Buffer[i] (uint16_t)(sine_value * PWM_ARR_VALUE * 0.9) (PWM_ARR_VALUE * 0.05); } } // 然后将DMA的源地址指向 SPWM_Buffer并设置 BufferSize SINE_TABLE_SIZE这样DMA就会循环地将这个正弦表写入CCR寄存器在PWM输出端经过一个低通滤波器LC滤波后就能得到平滑的50Hz正弦波。通过改变正弦表的生成算法你可以轻松产生三角波、锯齿波甚至任意波形。5.2 双缓冲技术与动态更新在循环模式下DMA会周而复始地读取同一块内存区域。如果我们想在波形播放中途动态更换这个波形数组怎么办直接修改SRC_Buffer是危险的因为DMA可能正在读取它会导致数据冲突产生毛刺。解决方案是使用双缓冲Ping-Pong Buffer准备两个缓冲区Buffer_A和Buffer_B。初始时DMA从Buffer_A读取数据。当DMA传输完成一半或使用半传输完成中断DMA_IT_HT时在中断服务程序里将新的波形数据填充到Buffer_B。当DMA传输全部完成使用传输完成中断DMA_IT_TC时在中断服务程序里通过DMA_MemoryTargetConfig()函数或重新初始化DMA将DMA的存储地址切换到Buffer_B同时将新数据填充到Buffer_A。如此往复实现波形的无缝动态更新。这常用于音频播放、图形显示等需要连续流数据的场景。5.3 性能考量与资源管理DMA通道资源STM32F103的DMA1有7个通道每个通道与特定的外设请求源绑定如TIM1_UP对应通道5。在规划项目时需要统筹分配DMA通道避免冲突。总线带宽DMA传输会占用AHB总线带宽。虽然它解放了CPU但如果DMA传输过于频繁例如超高频率PWM更新可能会影响到CPU访问Flash或SRAM的速度。在极端性能要求的系统中需要评估。中断使用本例中我们使能了DMA传输完成中断。虽然中断处理函数可以很短仅设置一个标志位但在实时性要求极高的控制系统中任何中断都可能引入抖动。如果只是循环播放固定波形完全可以禁用中断让DMA完全静默工作。内存对齐为了获得最佳的DMA传输性能源数据地址最好按照数据宽度对齐例如16位数据对齐到偶数地址。使用__align(2)关键字可以确保数组在内存中对齐。6. 常见问题与排查实录在实际操作中你可能会遇到以下问题。这里记录了我的排查过程和解决方法。6.1 问题一完全没有PWM输出现象引脚没有任何波形保持高电平或低电平。排查步骤查时钟使用调试器或通过点灯延时确认系统时钟和APB2时钟是否正确配置为72MHz。检查RCC-CFGR寄存器。查GPIO确认引脚模式是GPIO_Mode_AF_PP而不是GPIO_Mode_Out_PP。检查GPIOA-CRH寄存器对应位的配置。查定时器使能确认TIM1-CR1寄存器的CEN位为1。确认TIM1-BDTR寄存器的MOE位为1这是高级定时器特有的。查基本PWM先注释掉DMA配置在main循环里手动给TIM1-CCR3赋值看是否有固定占空比的PWM。如果没有问题就在TIM1基础配置上。6.2 问题二PWM输出正常但占空比不变化现象有稳定的PWM波但占空比是固定的不是数组中的序列。排查步骤查DMA使能确认DMA1_Channel5-CCR寄存器的EN位为1。查TIM1 DMA请求确认TIM1-DIER寄存器的UDE位更新DMA请求使能为1。查DMA传输在调试模式下观察DMA1_Channel5-CNDTR寄存器。它应该从数组长度开始递减减到0后如果处于循环模式会重载初始值。如果这个值不变说明DMA没有被触发。查地址和数据核对DMA1_Channel5-CPAR外设地址是否为0x40012C3CDMA1_Channel5-CMAR内存地址是否为SRC_Buffer的地址。检查SRC_Buffer数组在内存中的值是否正确。6.3 问题三PWM变化但波形有毛刺或跳动现象占空比在变化但变化瞬间有时会产生一个极窄或极宽的脉冲。原因与解决未启用预装载这是最常见原因。必须确保TIM1-CCMR2寄存器中对应通道的OC3PE位为1即调用TIM_OC3PreloadConfig(TIM1, TIM_OCPreload_Enable)。这能确保新CCR值只在更新事件时生效。数组值超出范围确保SRC_Buffer中的每一个值都小于TIM1-ARR。如果CCR值大于ARR在一个PWM周期内比较匹配永远不会发生输出将保持无效电平。DMA与CPU访问冲突确保在DMA传输过程中CPU没有同时修改SRC_Buffer数组。如果需要动态修改应使用双缓冲机制或确保在DMA传输完成中断的安全期内修改。6.4 问题四输出频率不对现象实测PWM频率远高于或低于20kHz。排查重新计算TIM_Prescaler和TIM_Period。确保计算时考虑到TIM_Period和TIM_Prescaler写入寄存器时是值-1。检查系统时钟FCLK的定义是否正确。在代码开头有#define FCLK 72要确保与实际系统时钟一致。用示波器测量一个完整的PWM周期时间 T 1 / Freq。然后反推计数次数Count T * (TIM1CLK / (PSC1))。这个Count应该等于ARR 1。通过这套组合拳基本上能定位和解决DMA更新PWM遇到的大部分问题。最关键的是理解数据流TIM1更新事件触发 - DMA搬运数据 - 写入CCR影子寄存器 - 下次更新事件加载生效。任何一个环节断裂功能都会失效。掌握了这项技能你的STM32应用开发能力会上一个台阶。它不仅仅是让PWM自动变化更代表了一种设计思想将重复性、周期性的数据搬运工作交给DMA这类专用硬件让CPU专注于决策、通信和复杂计算从而构建出更高效、更可靠的嵌入式系统。下次当你需要控制多路步进电机、生成精密模拟信号或者实现LED炫酷灯效时不妨优先考虑DMA这个得力助手。

相关新闻