
简介国民N32G435系列单片机循环方波输出项目面向嵌入式开发者和单片机初学者演示如何利用定时器中断与RTC模块在1小时、2小时、4.5小时、0.5小时等时间区间内循环输出7.83Hz、5Hz、2.5Hz三种频率方波可应用于时钟信号生成、PWM控制、信号测试等场景。压缩包共2000个文件以932个h头文件、778个c源文件为主另有166个txt说明、80个pdf文档及少量md、cpp、json等源码结构完整便于对照工程文件梳理定时器配置与中断逻辑。包体约136.34MB已有131人学习。包内不仅包含N32G435与N32L435的定时器/RTC驱动文件还提供了工程级代码与文档可帮助读者理解定时器周期计算、中断服务程序编写、低功耗与长时间计时处理等关键细节并可直接在开发板上烧录验证。 前阵子帮朋友调一块驱动板需求说得很直白板子上的国民技术N32G435要循环输出好几组方波每组频率不一样、占空比不一样持续时间也不一样。听着像是基础操作真正做起来才发现牵扯到定时器配置、DMA搬运、预装载同步还有一堆容易出毛刺的坑。我把整套做法和踩坑过程整理出来给同样在用N32G435系列做波形输出的朋友一个参考。这套方法不只适用国民技术逻辑拿到其他厂家CM内核MCU上基本也能直接平移。1. 项目背景与应用场景1.1 循环发送不同方波到底在解决什么问题很多人第一反应是方波嘛GPIO翻转就行了。确实如果只需要一路固定频率的方波随便一个定时器中断里翻转电平就能做。但“循环发送不同方波”这个需求的关键词在于“循环”和“不同”也就是说系统要能按预设顺序自动切换频率、占空比而且切换动作发生在运行过程中不是重新烧录代码。这类需求在工程里出现频率很高。比如蜂鸣器要做多段提示音不同音调就是不同频率的方波传感器激励源在扫描阶段需要扫频输出电机驱动器在做出厂测试时要按固定时序输出不同占空比的PWM来验证功率级电源老化设备需要在不同负载波形之间循环。这些场景有一个共同要求波形切换要准、要稳最好不占用CPU太多时间。如果单纯用延时和GPIO翻转频率稍微高点定时器中断频繁进场主流程就被拖垮了。所以工程上通常用MCU的定时器PWM输出作为硬件发生器再配合DMA让参数切换自动化。N32G435这个系列定时器资源丰富带有高级定时器和DMA控制器非常适合做这类“波形序列发生器”。1.2 为什么选N32G435而不是普通8位机或555电路先看模拟方案。用555定时器加电阻电容确实能产生方波成本低但频率由RC常数决定想要在运行中切换频率就得切电阻或者电容电路复杂度立刻上来精度还受温漂影响。方波三角波发生电路、基于三极管的放大电路这类模拟方案也有同样问题固定波形输出没问题灵活切换就力不从心。普通8位MCU也能做但主频低、定时器位数少、DMA资源有限。比如要输出100kHz方波8位机如果主频只有16MHz一个周期才160个时钟中断进出开销就占了相当比例。N32G435是Cortex-M4F内核主频最高108MHz定时器带预装载寄存器DMA支持循环模式这些硬件特性决定了它在方波输出任务上更从容。这块芯片另一个优势是外设组合适合电机控制类应用内置比较器配合高级定时器的互补PWM输出可以做无感方波驱动。如果后续项目从“循环发方波”升级到“驱动一个电机”同一个平台可以延续使用不用换芯片重新学外设。从项目长期维护角度看这也是当初选型考虑进去的因素。1.3 总体设计思路定时器负责出波DMA负责喂参数我的实现思路一句话就能说清让定时器工作在PWM模式由硬件自动在引脚上产生方波方波的频率和占空比分别由ARR自动重装载值和CCR比较值决定运行过程中DMA在定时器更新事件的触发下把内存里预先放好的参数不断搬运到ARR和CCR从而实现方波参数的自动循环切换。整个系统可以分成三块参数表在RAM里预先定义多组ARR、CCR值以及每组波形持续的时间定时器负责产生PWM方波同时产生更新事件作为DMA的触发源DMA在更新事件到来时自动把参数表中的下一个ARR和CCR值写入定时器寄存器。这三块配合好之后CPU基本可以撒手不管。切换方波参数这个过程是由定时器事件驱动DMA完成的不需要中断服务程序逐条修改寄存器因此切换时刻和硬件计数周期严格对齐不会出现参数写到一半、计数器已经越界的情况。注意这里的关键是“更新事件触发DMA”而不是“定时器中断触发DMA”。前者是纯硬件链路延迟固定后者要经过中断响应流程时序上会有不确定性。2. 硬件资源与参数计算2.1 定时器通道与引脚分配N32G435有多个定时器TIM1是高级定时器通道1的PA8引脚可以作为PWM输出。如果只是输出单路方波用TIM1的任意一个通道都行如果后面要接半桥驱动电路做正负方波输出就需要用TIM1的互补输出通道比如CH1和CH1N硬件会自动插入死区时间避免上下管直通。我这次用PA8作为TIM1_CH1输出PA8这个引脚默认复用功能就是TIM1_CH1配置成复用推挽输出即可。不同封装下同一外设的引脚映射可能不同具体以数据手册的AF映射表为准。引脚配置部分要确认三件事GPIO时钟打开、引脚复用功能切换到TIM1_CH1、定时器时钟打开。这三件事漏掉任何一件引脚上都不会有波形而且很难排查。2.2 频率和占空比的计算公式定时器PWM频率由三个参数决定定时器时钟频率、PSC预分频值、ARR自动重装载值。公式如下fPWM timer_clock / ((PSC 1) * (ARR 1))占空比由CCR和ARR决定占空比 (CCR 1) / (ARR 1) * 100%这里有个细节经常有人踩坑PSC分频之后得到的是计数时钟频率ARR决定计数多少个时钟后溢出两者是乘法关系。很多新手习惯把ARR当作“周期”把PSC当作“频率”实际上PSC是颗粒度、ARR是周期长度两者共同决定最终频率。我以timer_clock108MHz为例目标1kHz方波占空比50%。先选PSC107这样计数时钟就是108MHz/(1071)1MHz。然后ARR999得到PWM频率1MHz/10001kHz。CCR500占空比50%。参数组合写成PSC107ARR999CCR500。如果目标频率变成100kHz接着用PSC107就不行因为1MHz的计数时钟下ARR9只能实现10个档位的占空比调节。所以高频时要减小PSC让计数时钟更高。PSC0时计数时钟为108MHzARR1079CCR540。反过来低频输出时ARR会超过16位上限65535这时要加大PSC。比如1Hz方波PSC16499ARR6545两者都落在16位范围内。给几个常用组合参考目标频率PSCARRCCR50%占空比占空比分辨率1kHz1079995000.10%10kHz175993000.17%100kHz010795400.09%500Hz107215910800.05%2.3 方波切换的同步问题为什么必须有预装载如果直接在运行中给ARR寄存器写新值定时器计数器可能已经跑到一个比较大的数值新ARR比当前计数值还小计数器立刻溢出输出波形的第一个周期就被截断了于是波形上出现一个不完整的窄脉冲。这就是频率切换毛刺的来源之一。解决这个问题靠的是预装载机制。N32G435定时器的ARR和CCR都有影子寄存器使能预装载之后软件写入的值先放在预装载寄存器里等到更新事件发生时才一次性锁存到影子寄存器。这样ARR和CCR的更新时刻就和计数器溢出点对齐了切换过程中不会出现半个周期的异常波形。这里要特别注意如果修改了ARR但没改CCR虽然两者都在更新事件时生效但占空比会随着ARR一起变。因为占空比是由CCR/ARR这两个值共同决定的单独切ARR而不切CCR即使你主观上想保持占空比不变实际输出也会发生变化。所以频率和占空比要成对切换代码里也要把ARR和CCR当作一个整体来处理。3. 软件实现与核心代码解析3.1 参数表的设计既然要循环发送不同方波第一步是把所有方波参数组织成一个表。我的做法是用一个结构体数组每组参数包含ARR值、CCR值、持续时间。PSC放在初始化时固定下来不参与动态切换这样DMA只需要搬运两个16位参数逻辑简洁很多。#define STAGE_NUM 5 typedef struct { uint16_t arr; // 自动重装载值决定频率 uint16_t ccr; // 比较值决定占空比 uint32_t duration_ms; // 该波形持续毫秒数 } WaveStage_t; const WaveStage_t wave_stage[STAGE_NUM] { {999, 500, 1000}, // 1kHz, 50%, 持续1秒 {1999, 1000, 1500}, // 500Hz, 50%, 持续1.5秒 {2999, 500, 800}, // 333Hz, 约17%, 持续0.8秒 { 499, 250, 500}, // 2kHz, 50%, 持续0.5秒 { 99, 30, 200}, // 10kHz, 约30%, 持续0.2秒 };注意看第二组和第三组ARR变了CCR并不一定跟着等比例变化。方波不要求一定是50%占空比不同的占空比正好体现“不同方波”的需求。只是占空比如果偏差较大后面接半桥驱动或感性负载时要留意电流应力问题。参数表设计好之后在RAM里为DMA准备两个独立数组分别存放当前正在使用的ARR序列和CCR序列。这里只需要给当前阶段准备一个元素因为DMA循环模式下它会不断把同一个值搬运到寄存器。uint16_t current_arr[1]; uint16_t current_ccr[1];阶段切换时把对应参数抄进这两个数组。用数组而不是两个简单变量是为了让DMA的内存地址指向合法的可寻址RAM区域配置上更规范。3.2 定时器初始化定时器初始化的核心是配置PWM模式、使能预装载、打开更新事件对应的DMA请求。以标准外设库风格为例代码大概是这个样子void TIM1_PWM_Init(void) { TIM_TimeBaseInitType base; TIM_OCInitType oc; base.Period 999; // ARR base.Prescaler 107; // PSC base.ClockDivision 0; base.CntMode TIM_CNT_MODE_UP; TIM_InitTimeBase(TIM1, base); oc.OcMode TIM_OCMODE_PWM1; oc.Pulse 500; // CCR oc.OcPolarity TIM_OC_POLARITY_HIGH; TIM_InitOc1(TIM1, oc); // 使能自动重装载预装载和输出比较预装载 TIM_EnableAutoReloadPreload(TIM1, ENABLE); TIM_EnableOc1Preload(TIM1, ENABLE); // 让更新事件触发DMA请求 TIM_SelectDmaRequest(TIM1, TIM_DMA_UPDATE, ENABLE); TIM_EnableDma(TIM1, ENABLE); TIM_EnableOc1Output(TIM1, ENABLE); TIM_EnableCounter(TIM1, ENABLE); }这里有两个细节。第一PWM1模式的含义是计数值小于CCR时输出有效电平大于等于CCR时输出无效电平。我用的极性是高有效所以计数开始时引脚为高到CCR后翻低形成方波。如果反过来想要默认低电平可以用PWM2模式或把极性改成低有效效果一样但别搞混。第二ARPE和OCxPE这两个预装载位是分开控制的必须都打开。只开ARR预装载、不开CCR预装载切换时CCR会立即生效ARR却延迟到更新事件两者不同步照样出毛刺。这也是我实际调试中踩过的坑。3.3 DMA配置把参数自动喂给定时器DMA部分的目标是每次定时器更新事件到来DMA把current_arr中的值搬运到TIM1的ARR寄存器把current_ccr中的值搬运到TIM1的CCR1寄存器。需要两个DMA通道分别处理。配置要点外设地址固定为定时器寄存器地址不发地址递增内存地址指向参数数组一个元素循环模式数据宽度16位因为ARR和CCR都是16位寄存器传输模式必须是循环模式否则搬运一次后DMA通道就关闭了。void DMA_Wave_Init(void) { DMA_InitType dma; // ARR通道 dma.PeriphAddr (uint32_t)TIM1-ARR; dma.MemAddr (uint32_t)current_arr; dma.Direction DMA_MEM_TO_PERIPH; dma.BufferSize 1; dma.PeriphInc DMA_PINC_DISABLE; dma.MemInc DMA_MINC_DISABLE; dma.PeriphDataWidth DMA_PDATAWIDTH_HALFWORD; dma.MemDataWidth DMA_MDATAWIDTH_HALFWORD; dma.Mode DMA_MODE_CIRCULAR; dma.Priority DMA_PRIORITY_HIGH; DMA_Init(DMA1_CH2, dma); DMA_Enable(DMA1_CH2); // CCR通道 dma.PeriphAddr (uint32_t)TIM1-CCR1; dma.MemAddr (uint32_t)current_ccr; DMA_Init(DMA1_CH3, dma); DMA_Enable(DMA1_CH3); }注意不同型号的MCUDMA通道与定时器请求的映射表不一定一样。我代码里写的DMA1_CH2对应TIM1更新请求是基于手头这颗芯片的映射关系你拿到不同类型芯片时务必对照参考手册里的DMA请求映射表确认千万别照抄。3.4 阶段切换逻辑SysTick负责定时换挡到这里定时器和DMA已经在硬件层面把波形稳定输出了。剩下的问题是每段方波要持续一定时间之后切换到下一组参数。这个“时间到切换”的动作我用SysTick来完成。SysTick配置成1ms中断在中断服务函数里对当前阶段做倒计时。时间到了就把下一组参数写入current_arr和current_ccr并重新加载DMA的内存地址。由于DMA处于循环模式搬运源地址改变后下一个更新事件到来时就会自动搬运新值波形切换就在那个时刻完成。volatile uint8_t stage_index 0; volatile uint32_t stage_tick 0; void SysTick_Handler(void) { if (--stage_tick 0) { stage_index (stage_index 1) % STAGE_NUM; // 写内存参数 current_arr[0] wave_stage[stage_index].arr; current_ccr[0] wave_stage[stage_index].ccr; // 更新DMA源地址 DMA_Disable(DMA1_CH2); DMA_Disable(DMA1_CH3); DMA_SetMemAddr(DMA1_CH2, (uint32_t)current_arr[0]); DMA_SetMemAddr(DMA1_CH3, (uint32_t)current_ccr[0]); DMA_Enable(DMA1_CH2); DMA_Enable(DMA1_CH3); // 重新计时 stage_tick wave_stage[stage_index].duration_ms; } }这种做法的好处是阶段切换的时机由SysTick软件决定而真正改变ARR和CCR的时刻由硬件更新事件决定。软件只是把新值“放到传送带上”硬件在下一个同步点自动取用两者天然解耦。即使SysTick中断在某些极端情况下延迟了几微秒DMA拿到的也是完整的一组新参数不会把两组参数混着用。修改DMA内存地址前先关闭DMA通道改完再打开。这一步别省否则可能在DMA刚好搬运的瞬间改地址导致搬走一个不完整的值。4. 常见问题与排查技巧实录4.1 波形切换瞬间出现毛刺和窄脉冲这是做方波切换最容易遇到的问题。现象是示波器上看正常波形中间偶尔冒出一个特别窄的脉冲或者有一两个周期占空比异常。原因基本就是ARR或CCR被“半路”修改了。计数器的当前计数值大于新写入的ARR计数器立刻溢出或者CCR被改了但ARR还没改占空比瞬间算出一个奇怪的值。排查思路先确认ARPE和OCxPE两个预装载位都打开了再检查DMA写入的寄存器是不是对的。还有一个容易忽略的点如果直接在中断服务里调用TIM_SetAutoreload这类函数修改ARR一定要保证这些函数内部写的是预装载寄存器而不是直接操作影子寄存器。库函数通常设计正确但直接操作寄存器的宏定义就可能踩坑。另外修改参数之前可以先关闭DMA、修改完再打开虽然会让切换延迟一个周期但能避免DMA在SysTick写入参数的半中间触发搬运把新旧值拼在一起。4.2 DMA搬运一次之后波形不再变化这个问题非常典型。现象是上电后第一段方波输出正常但是到了该切换的时候波形纹丝不动。第一反应查DMA的工作模式看看是不是配成了正常模式而不是循环模式。正常模式下DMA传输完成一次就自动关闭通道Buffer Size计数到零之后不再重载通道停在那里等软件重新使能。第二个排查点是外设请求源。定时器要产生DMA请求必须正确配置DMA请求选择不同定时器事件可能对应不同的请求源。我用的更新事件对应的请求源是TIM_DMA_UPDATE。如果配置成比较事件或者触发事件更新事件就不会触发DMA搬运。第三个排查点稍微隐蔽一点DMA通道的中断标志。循环模式虽然不会自动关闭通道但如果某个中断标志没有清理可能在特定库函数版本下影响下一次传输。遇到诡异现象时把所有DMA标志都清一遍再跑往往能解掉。4.3 引脚没有波形输出先量引脚电平如果一直是高或者低没有翻转大概率不是定时器问题而是引脚复用没有配好。检查GPIO时钟是否打开引脚是否配置成复用推挽输出复用功能是否切换到TIM1_CH1。再有就是遗漏了TIM_EnableOc1Output这一句。高级定时器的输出比较通道在默认状态下可能不输出必须显式使能OC输出。这一点和普通定时器不一样很多人从普通定时器切到高级定时器时在这里卡很久。如果引脚有波形但频率不对别急着改代码先拿公式算一遍实际频率等于定时器时钟除以(PSC1)再除以(ARR1)。注意APB总线分频比不等于1时定时器时钟可能是总线时钟的2倍这个倍数关系在不同芯片上表现不同拿示波器实测最安心。4.4 低频方波输出不了ARR超过16位想把频率做到很低比如1Hz甚至0.1Hz直接加大ARR会超出16位上限。此时应该调整PSC而不是ARR。PSC最大也能到65535两者配合能实现很宽的频率范围。计算时注意一点PSC加1、ARR加1之后代入公式。计数时钟是timer_clock除以(PSC1)不是除以PSC。公式里这个“加1”经常被忽略导致算出来的频率总是差一点。4.5 配合半桥电路和外部比较器做无感方波控制这个题目本身是循环发方波但很多做无感方波电机控制的朋友会搜到这里多说一句。N32G435的高级定时器产生互补PWM输出可以直接驱动半桥正负方波电路。互补输出会自动插入死区时间编程上要配置死区寄存器防止上下桥臂直通。外部比较器法无感方波控制的核心逻辑是PWM驱动电机旋转时非通电相绕组的反电动势过零点可以通过比较器检测到处理器根据过零信号换相。这和本文的关系在于PWM的占空比、频率以及换相时序都需要精确控制本质上就是在“循环发送不同方波”只是每个方波段不是简单定时切换而是由比较器触发换相。如果之前没接触过无感方波建议把本文的定时器DMA机制吃透再去看比较器过零检测和换相表会容易很多。5. 一点实操体会这套方案我自己测试下来最直观的感受是从“改代码控制方波”变成“配参数表控制方波”。以后要调整频率组合或者持续时间直接改wave_stage数组不用动逻辑代码。对量产设备来说这种参数与逻辑分离的结构后期维护非常舒服。还有个小技巧调试阶段可以把SysTick中断里的DMA更新操作临时注释掉先用示波器确认每一组参数单独输出时波形都正确再放开循环切换。这样如果波形出问题就能快速区分是参数算错了还是切换逻辑写错了。盲目直接跑全流程示波器上一团乱麻排查起来反而慢。最后再分享一个和“外部比较器法无感方波”有关的小建议如果项目后续会从纯方波输出走向电机控制一开始写DMA和定时器代码时尽量把定时器通道、DMA通道、中断优先级这些资源分配规划好别把所有定时器都占满。无感方波控制需要至少一个高级定时器出PWM、一个比较器做反电动势检测、一个通用定时器做换相时间基准资源预留好后面扩展就不用推翻重来。本文还有配套的精品资源点击获取