STM32F4 DMA驱动PWM双通道同步更新:原理与寄存器实现

发布时间:2026/10/14 14:21:52
STM32F4 DMA驱动PWM双通道同步更新:原理与寄存器实现
简介面向STM32F4系列嵌入式开发者这份工程演示了借助STM32CubeIDE平台利用DMA与TIM3通道1/2的PWM输出驱动WS2812智能灯带的方法覆盖定时器PWM模式配置、DMA传输通道搭建、单线时序控制等关键环节适合具备一定基础并希望进阶学习单片机高级外设的读者。包内共152个文件大小约4.74MB其中.ioc是CubeMX初始化配置工程21个.c与45个.h为HAL库源码和用户代码.ld链接脚本定义了存储布局.elf/.map便于调试时查看符号与内存分布其余编译中间文件则完整还原了工程构建过程。已有706人浏览学习。通过研读此工程可以梳理DMA与定时器协同工作的完整思路理解WS2812灯带所需精确时序的生成方式同时熟悉STM32CubeIDE的工程组织方式与HAL/LL驱动调用习惯为后续开发电机控制、信号发生等实时性要求高的项目提供可直接参考的模板。1. F4xx_DMA_PWM_TIM3ch1ch2.rar 在解决什么问题这个压缩包名字干的事一句话说就是让 STM32F4 的 TIM3 两个通道输出占空比自动变化的 PWM而且 CPU 全程不参与改寄存器。做 LED 渐变、步进电机细分、双路调光、波形发生这类需求时最直觉的做法是在定时器中断里改 CCR等 PWM 频率抬到几十 kHz 就会发现 CPU 被中断拖到没法干别的。DMA 方案把「搬数」这件事完全交给硬件CPU 只管维护一张内存表。这篇拆的就是这条触发链定时器事件产生 DMA 请求DMA 把内存里的占空比数据搬进 CCR1 和 CCR2。受众是已经会配 GPIO 复用和基本 PWM 输出、但对 DMA 请求映射和双通道同步还比较模糊的开发者目标是看完能直接在自己的板子上跑通。2. DMA 驱动 PWM 的原理与选型为什么不用中断触发链怎么走2.1 定时器事件触发 DMA更新事件与比较事件的差别中断方式改占空比每个 PWM 周期都要进一次中断现场保护、查表、写 CCR、恢复现场。1kHz 时还能忍到 20kHz 就变成每 50 微秒打断一次 CPU这还没算其他任务切换和总线抢占。DMA 搬运是纯硬件行为不占用 CPU代价是你要先把数据表准备好剩下的全自动。F4 的定时器本身不主动搬数据它只负责发请求。请求源在 TIM3 的 DIER 寄存器里选有两个常用位UDE 对应更新事件CCxDE 对应比较事件。更新事件发生在计数器溢出、也就是 PWM 周期边界上比较事件发生在计数值匹配 CCR 的瞬间。对 PWM 输出来说更新事件更适合作 DMA 触发源因为它发生在周期开始的边界DMA 写入的新值不会落在当前周期中间从根源上避免了一个周期内 CCR 突变造成的波形毛刺。比较事件触发也有它的用处中心对齐模式下需要在一个周期内多次改变比较值或做逐点电流控制时比较事件能更精确地对齐到 PWM 的某个相位点。代价是触发时刻和写入时刻之间存在 DMA 响应延迟这个延迟在高频下可能落到下一个周期反而产生错位。所以我的选择标准很简单慢变化渐变、调光用更新事件逐周期微调且频率不高用比较事件频率高又要精确对齐就直接降低 PWM 频率或换硬件。2.2 DMA 请求映射哪条流、哪个通道决定你能不能搬起来F4 的 DMA 不是任意组合都能用。DMA1 和 DMA2 各带 8 个 Stream每个 Stream 同一时刻只能绑定一个外设请求而且外设请求和 Stream、Channel 的组合是出厂定死的。TIM3 的更新事件请求映射到 DMA1 的 Stream2 Channel5通道比较事件各自映射到不同的 Stream 组合。你要是随手挑一个 Stream 和 Channel配置写得再对也不会触发。这里的坑在于很多人把 CHSEL 当成随便填的位段实际上它必须与外设请求映射表对应。我一般直接查参考手册的「DMA request mapping」表格或者在 CubeMX 的 DMA 配置界面选 TIM3_UP让它自动分配再把生成的组合抄回手写工程。CHSEL 是 3 位Channel5 对应二进制 101在代码里就是 DMA_SxCR_CHSEL_2 | DMA_SxCR_CHSEL_0。这个值你只要错一位NDTR 永远不动PWM 就像没配 DMA 一样死板。还有一个容易忽视的点DMA 请求使能之后如果 DMA 流本身还没使能请求会直接丢掉。所以初始化顺序应该是先配完 DMA 所有寄存器但不置 EN 位再打开 TIM3 的 DIER 请求位最后置 EN 和启动定时器。反过来操作第一个周期的事件可能丢失表现起来就是波形开头少一拍的占空比跳变。2.3 数据宽度、地址递增和优先级选型决定双通道能不能同步DMA 搬运的数据粒度由 MSIZE内存宽度和 PSIZE外设宽度共同决定这两个值必须一致。TIM3 的 CCR 是 16 位寄存器所以最直接的选择是半字对半字内存侧一个 uint16_t 数组一次搬一个占空比值。内存地址递增、外设地址固定这是单通道的标准姿势。问题出现在 ch1 和 ch2 同时需要更新时。方案一两个独立 DMA 流各配一条请求同时响应同一个更新事件。但 DMA 仲裁器会按优先级和流编号排序两个流不可能在同一时刻完成写寄存器微小的先后差在控制严格的应用里就是相位误差。方案二单流 突发传输每次触发搬两个半字需要配 MBURST 和 PBURST外设端突发访问 TIM 的寄存器组还要注意地址对齐配置稍有不慎就出界。方案三把 MSIZE 和 PSIZE 改成 32 位内存放 uint32_t 数组低 16 位给 CH1高 16 位给 CH2外设地址只指向 CCR1。因为 CCR1 和 CCR2 寄存器在地址上连续一次 32 位写操作同时落进两个寄存器同步性最好。我推荐方案三这是 F4 上双通道 DMA PWM 最稳的写法也符合标题里 TIM3ch1ch2 这个组合的实际诉求。DMA 优先级这块默认 medium 在独占 DMA1 时够用如果系统里还有 ADC、串口在抢 DMA优先级要提到 high否则 PWM 的 DMA 请求可能被延后到周期中段波形出现周期性的宽窄抖动。3. 把第一个 DMA PWM 跑起来TIM3_CH1 的最小寄存器工程3.1 引脚与时钟PA6 的复用功能和 APB1 定时器时钟先交代硬件前提。TIM3 挂在 APB1 总线上但 APB1 的频率不等于定时器时钟。以常见的 F407 配置为例系统主频 168MHzAPB1 分频系数为 4得到 42MHz而 APB1 上的定时器时钟会再乘 2所以 TIM3 的实际输入时钟是 84MHz。很多人算 PWM 频率差一倍就是在这里少乘了这个 2。PA6 在 F4 上是 TIM3_CH1 的复用引脚复用功能编号 AF2。配置分两步MODER 设成复用模式AFR 寄存器低 8 位写入 2。F4 没有 F1 那种 AFIO 重映射寄存器换用 AFR 编号选择别拿 F1 的习惯套。想让通道改变方向输出就查封装对应的 AF 表格逻辑完全一样只是 AFR 位段不同。3.2 单通道 DMA从内存表到 CCR1下面是最小可复现代码寄存器风格方便你在任何 HAL 工程里对照理解。HAL 用户留意 CubeMX 生成的 dma.c 和 tim.c底层是同一套寄存器。/* 用 TIM3_CH1 DMA 输出周期变化的 PWM实现呼吸灯效果 * 前提系统时钟 168MHzAPB1 定时器时钟 84MHz */ #define PWM_POINTS 32 uint16_t duty_table[PWM_POINTS] { /* 32 点一条上升 下降的占空比曲线最大值不超过 ARR */ 0, 64, 128, 192, 256, 320, 384, 448, 512, 576, 640, 704, 768, 832, 896, 960, 960, 896, 832, 768, 704, 640, 576, 512, 448, 384, 320, 256, 192, 128, 64, 0 }; void dma_pwm_ch1_init(void) { /* 1. 时钟DMA1、TIM3、GPIOA */ RCC-AHB1ENR | RCC_AHB1ENR_DMA1EN | RCC_AHB1ENR_GPIOAEN; RCC-APB1ENR | RCC_APB1ENR_TIM3EN; /* 2. PA6 复用为 TIM3_CH1AF 编号 2 */ GPIOA-MODER ~GPIO_MODER_MODE6_Msk; GPIOA-MODER | GPIO_MODER_MODE6_1; /* 10 复用功能 */ GPIOA-AFR[0] ~GPIO_AFRL_AFSEL6_Msk; GPIOA-AFR[0] | (2 GPIO_AFRL_AFSEL6_Pos); /* AF2 */ /* 3. TIM3PSC83 得到 1MHz 计数频率ARR999 得到 1kHz PWM */ TIM3-PSC 83; TIM3-ARR 999; TIM3-CCMR1 ~TIM_CCMR1_OC1M_Msk; TIM3-CCMR1 | (6 TIM_CCMR1_OC1M_Pos); /* PWM1 模式 */ TIM3-CCMR1 | TIM_CCMR1_OC1PE; /* 预装载使能 */ TIM3-CCER | TIM_CCER_CC1E; /* CH1 输出使能 */ /* 4. DMA1 Stream2, Channel5 TIM3 更新事件 */ DMA1_Stream2-CR 0; while (DMA1_Stream2-CR DMA_SxCR_EN) {} DMA1_Stream2-NDTR PWM_POINTS; /* 搬运点数 */ DMA1_Stream2-PAR (uint32_t)TIM3-CCR1; /* 外设地址固定 */ DMA1_Stream2-M0AR (uint32_t)duty_table; /* 内存地址递增 */ DMA1_Stream2-CR | DMA_SxCR_CHSEL_2 | DMA_SxCR_CHSEL_0 /* Channel5 */ | DMA_SxCR_MDIR /* 内存到外设 */ | DMA_SxCR_MSIZE_0 | DMA_SxCR_PSIZE_0 /* 半字对半字 */ | DMA_SxCR_MINC /* 内存递增 */ | DMA_SxCR_CIRC; /* 循环模式 */ /* 5. 先使能 DMA 请求源再使能 DMA 流最后启动定时器 */ TIM3-DIER | TIM_DIER_UDE; DMA1_Stream2-CR | DMA_SxCR_EN; TIM3-CR1 | TIM_CR1_CEN; }这段代码的逻辑链TIM3 每计数 1000 次溢出一次溢出时 DIER 的 UDE 位产生一个 DMA 请求DMA1 Stream2 响应请求把 duty_table 里的一个半字搬到 CCR1。NDTR 初始为 32每搬一次减 1减到 0 时循环模式自动重装回 32 并从头开始所以 CPU 完全不用管。这就是循环模式的价值数据在表里循环流动PWM 占空比跟着表走。参数调整上PSC83 把 84MHz 变成 1MHz 计数ARR999 决定 PWM 周期 1ms。占空比分辨率由 ARR 决定999 意味着千分级调节想要更高分辨率就调大 ARR同时表里的最大值也要同步改。想改变波形循环速度改点数即可32 点配 1kHz 就是 32ms 一个完整呼吸周期。3.3 循环模式让占空比自己动起来呼吸灯效果上面的 duty_table 其实就是一条呼吸灯曲线0 到 960 快速上升再降回 0。循环模式下 DMA 自动循环搬运PA6 上接一个 LED就能看到亮度以 32ms 为周期变化CPU 全程零参与。觉得阶梯感强就把点数加到 128 或 256同时把相邻点的差值改小肉眼基本看不出量化台阶。但呼吸灯这种应用 32 点已经足够没必要为观感浪费 Flash。这里有个值得细说的寄存器位OC1PE 预装载使能。置位后DMA 写 CCR1 时新值先进入预装载寄存器等下一个更新事件才加载到实际比较寄存器。好处是避免在一个周期中途改 CCR 导致脉冲宽度突变代价是新值延迟一个周期生效。呼吸灯场景无所谓延迟但如果你后面做高频波形合成必须清楚这个「推迟一拍」的行为否则示波器上看到的波形会比预期慢一个周期。4. 双通道 ch1ch2 同步更新三种方案与最优实现4.1 方案对比双 DMA 流、单流顺序搬运、32 位合并写入先给结论表再逐个说边界。方案配置复杂度双通道同步性占用 DMA 资源适用场景两个独立 DMA 流中差仲裁先后不一致2 个 Stream对同步无要求的双路渐变单流 突发传输高中一次触发搬多次1 个 Stream数据量大的连续外设32 位合并写入低最好一次写两个 CCR1 个 Stream相位差、正交输出、波形合成双独立 DMA 流的问题在仲裁器。即使两个流优先级相同硬件也会按流编号排序响应先后会有几十到几百纳秒的差异。示波器上看不出但对相位敏感的场合不能接受。单流突发需要配置 MBURST 和 PBURSTF4 的突发对齐规则复杂度高而且 CCR1 和 CCR2 连续地址其实不需要「突发」一次宽传输就能搞定属于大材小用。4.2 32 位合并写入的代码实现32 位合并写入的思路利用了两个事实第一TIM3 的 CCR1 和 CCR2 寄存器地址连续第二Cortex-M 是小端序uint32_t 的低 16 位存在低地址、高 16 位存在高地址。所以一次 32 位 DMA 写操作低半字自然落到 CCR1高半字落到 CCR2两个通道同步更新误差为零。/* 双通道同步更新一次 DMA 传输同时写 CCR1 和 CCR2 * 数据结构uint32_t低 16 位给 CH1高 16 位给 CH2 */ #define PHASE_POINTS 64 uint32_t dual_table[PHASE_POINTS]; /* 构造表CH1 和 CH2 互为 90 度相位差的正弦波 */ void build_dual_table(void) { for (int i 0; i PHASE_POINTS; i) { uint16_t ch1 (uint16_t)(500 400 * sin(2 * 3.14159 * i / PHASE_POINTS)); uint16_t ch2 (uint16_t)(500 400 * sin(2 * 3.14159 * i / PHASE_POINTS 3.14159 / 2)); dual_table[i] ((uint32_t)ch2 16) | ch1; /* 低半字CH1, 高半字CH2 */ } } void dma_pwm_ch1_ch2_init(void) { /* 1. 时钟DMA1、TIM3、GPIOA */ RCC-AHB1ENR | RCC_AHB1ENR_DMA1EN | RCC_AHB1ENR_GPIOAEN; RCC-APB1ENR | RCC_APB1ENR_TIM3EN; /* 2. PA6TIM3_CH1, PA7TIM3_CH2都是 AF2 */ GPIOA-MODER ~(GPIO_MODER_MODE6_Msk | GPIO_MODER_MODE7_Msk); GPIOA-MODER | (2 GPIO_MODER_MODE6_Pos) | (2 GPIO_MODER_MODE7_Pos); GPIOA-AFR[0] ~(GPIO_AFRL_AFSEL6_Msk | GPIO_AFRL_AFSEL7_Msk); GPIOA-AFR[0] | (2 GPIO_AFRL_AFSEL6_Pos) | (2 GPIO_AFRL_AFSEL7_Pos); /* 3. TIM3PWM1 模式CH1 和 CH2 都配置 */ TIM3-PSC 83; /* 84MHz / 84 1MHz */ TIM3-ARR 999; /* 1kHz PWM */ TIM3-CCMR1 ~(TIM_CCMR1_OC1M_Msk | TIM_CCMR1_OC2M_Msk); TIM3-CCMR1 | (6 TIM_CCMR1_OC1M_Pos) | (6 TIM_CCMR1_OC2M_Pos); TIM3-CCMR1 | TIM_CCMR1_OC1PE | TIM_CCMR1_OC2PE; TIM3-CCER | TIM_CCER_CC1E | TIM_CCER_CC2E; /* 4. DMA数据宽度改成 32 位外设地址指向 CCR1 */ DMA1_Stream2-CR 0; while (DMA1_Stream2-CR DMA_SxCR_EN) {} DMA1_Stream2-NDTR PHASE_POINTS; DMA1_Stream2-PAR (uint32_t)TIM3-CCR1; DMA1_Stream2-M0AR (uint32_t)dual_table; DMA1_Stream2-CR | DMA_SxCR_CHSEL_2 | DMA_SxCR_CHSEL_0 /* Channel5 */ | DMA_SxCR_MDIR | DMA_SxCR_MSIZE_1 | DMA_SxCR_PSIZE_1 /* 32 位 */ | DMA_SxCR_MINC | DMA_SxCR_CIRC; /* 5. 触发源不变仍用 TIM3 更新事件 */ TIM3-DIER | TIM_DIER_UDE; DMA1_Stream2-CR | DMA_SxCR_EN; TIM3-CR1 | TIM_CR1_CEN; }和单通道代码相比核心差异就两处数组类型换成 uint32_tDMA 的 MSIZE 和 PSIZE 改成 32 位。NDTR 的单位相应变成「字」64 表示 64 次传输每次 4 字节内存地址自动递增。外设地址仍然指向 CCR1但写入宽度覆盖到相邻的 CCR2。两个通道的相位关系完全由表数据决定CH2 比 CH1 超前 90 度就是上面代码的效果改成同相、反相、任意偏移都一样改表不改配置。4.3 双缓冲 DBM 模式波形表可以在后台换循环模式的问题在于它反复搬同一张表。你想换波形就要停 DMA、改 M0AR、再开 DMA期间 PWM 输出中断波形出现一段空白。F4 的 DMA 提供双缓冲模式解决这个问题M0AR 和 M1AR 各指向一张表DMA 搬完一张自动切到另一张并在切换时产生传输完成中断。CPU 在中断里判断哪张表刚用完往那张表填新数据DMA 继续搬另一张无缝衔接。DBM 模式下 DMA 不再自动循环每搬完 NDTR 个数据翻转一次所以它和 CIRC 互斥不能同时置位。切换到另一张表时修改表内容的时机有讲究中断里读 DMA_SxCR 的 CT 位判断当前目标CT 的语义在不同参考手册上有相反说法调试时打一个断点看寄存器最直接。音频播放是 DBM 的典型应用CPU 准备下一段占空比数据的同时 DMA 正在搬当前段中间没有任何间隙这个特性在低频波形合成时容易被忽略等做到连续变频才想起来。5. 避坑指南DMA PWM 最容易翻车的 5 个细节5.1 现象PWM 输出固定DMA 一次都没搬现象程序跑起来 PA6 有 PWM 波形但占空比永远是一个固定值改 duty_table 里任何一个数都没反应。原因DMA 请求没到或者到了但 DMA 流没响应。最常见的是 TIM3-DIER 的 UDE 没置位其次是 DMA_SxCR 的 CHSEL 填错。解决先看 NDTR——DMA 搬运时 NDTR 递减如果一直是初始值说明没有请求再停住 CPU 检查 DIER 和 CHSEL 两个寄存器逐位对照参考手册。这是我调试时最先看的两个地方。5.2 现象搬一次就停NDTR 归零后不再工作现象上电瞬间 PWM 占空比变了一拍然后回到固定值DMA 停摆以后再也不动。原因用普通模式而不是循环模式NDTR 减到 0 后 DMA 硬件自动清掉 EN 位流关闭。解决确认 CR 里有没有 CIRC 位。如果业务要求普通模式必须在 DMA 传输完成中断里重新赋 NDTR、重装 M0AR、再置 EN。注意顺序先清 EN等位被硬件确认清零再改 NDTR最后置 EN。跳过等待那一步偶发性地会在旧状态上直接重启玄学得很。5.3 现象两个通道占空比错位、出现毛刺现象CH1 和 CH2 各自波形单独看都对但相对位置偶尔抖动或者一个通道在周期中段出现一段异常电平。原因用了两个独立 DMA 流仲裁先后导致写入时间差另一个常见原因是开了 CCR 预装载的同时用比较事件触发新值被推迟加载造成错位。解决双通道同步需求一律用 32 位合并写入一个流、一次传输、两个 CCR 同时更新用更新事件触发并保留预装载给新值留出加载窗口。这俩组合能覆盖绝大多数毛刺场景。5.4 现象PWM 频率和预期差一倍现象PSC83、ARR999 配置完期望 1kHz示波器测出来 500Hz 或 2kHz。原因APB1 定时器时钟倍频算错。主频 168MHz 时 APB1 是 42MHz但定时器时钟要乘 2 变成 84MHz。按 42MHz 算PSC83 就是 500Hz于是有人把 PSC 减半去凑 1kHz结果计数分辨率被压了一半。解决先确认时钟树里 APB1 定时器时钟的最终值再倒推 PSC 和 ARR。时序参数错了后面所有波形都对不上。5.5 现象改了内存数组波形纹丝不动现象程序运行时修改 duty_table 数组的数值示波器上波形没有任何变化。原因最常见的是数组被放在 Flash 而 CPU 改的是 RAM 里的副本DMA 搬的始终是 Flash 里的旧数据其次是表所在内存区域被 DMA 和 CPU 同时竞争写但 DMA 先读到旧值。解决把表定义成非 const 全局数组确认链接脚本把它放进 RAM。F4 没有 D-Cache一般不存在缓存一致性问题但养成「DMA 缓冲区放进 RAM、大小对齐」的习惯后面迁到带 Cache 的芯片能少踩一个更大的坑。6. 验证方法与进阶用法从示波器确认到正弦波合成6.1 三个验证手段示波器最直接PA6 和 PA7 同时接两路边沿触发观察占空比曲线是否在表定义的周期内连续变化。如果看到固定高或固定低先按第 5 章前两条查。逻辑分析仪更适合查数据采样率 24MHz 够看 1kHz PWM用解码功能测高电平时间和数组里的值逐个对着看能快速确认 DMA 搬的数对不对。手上没示波器时万用表也能做冒烟测试——占空比 50% 在 3.3V 供电下平均电压约 1.65V但这只能确认有输出确认不了波形质量。6.2 用 DMA 合成正弦波表大小与 PWM 频率的配合把占空比表换成正弦值这套代码就变成一个简易波形发生器。输出正弦频率等于 PWM 频率除以表的点数例如 20kHz PWM 配 256 点表输出约 78Hz想要 1kHz 正弦可以用 20 点表但波形阶梯感非常明显。实测下来每周期不少于 32 点谐波才不明显低于这个数波形边缘能看到锯齿。建表时我的习惯是在 PC 上把数组算好直接贴进工程跑通了再改成芯片上运行时计算减少调试变量。这个思路也适用于三角波、S 曲线、自定义任意波形改表就行DMA 配置一个都不用动。F4 的 DMA 和 TIM 行为非常固定只要跑通一次最小工程后面换到 TIM1、TIM8或者改到其他通道流程完全一样查引脚复用、查请求映射、查定时器时钟然后套这套模板。我自己做多路输出时会先把一路用最小代码验证再快速复制到其他通道一次只引入一个变量避免两路同时出问题时不知道先查谁。希望这些经验能帮你在自己的板子上少花点调 DMA 的时间。本文还有配套的精品资源点击获取