
如果你和我一样经常要用GD32定时器产生一个频率精确、占空比稳定在50%的方波大概率会在两个方案之间摇摆要么用PWM模式硬调比较值要么开中断手动翻转引脚。前者在ARR为奇数时占空比永远到不了50%后者在中断频繁时大概率被系统其他任务拖累输出沿全是抖动。后来我在一次信号发生器项目里改用输出比较通道的翻转模式Toggle Mode这两个问题一次全解决了。所谓Toggle Mode就一句话当定时器计数器的值到达通道比较值时输出引脚的电平由硬件自动翻转一次。整个过程不需要中断、不需要CPU参与翻转的精度就是计数器时钟的精度。它在GD32的通用定时器和高级定时器上都有哪怕是入门级型号也基本支持。这篇文章我把自己从原理、配置、踩坑到联动ADC的完整经验写一遍适合正在调GD32定时器、或者刚被定时器频率不准和空指针折磨过的人参考。1. Toggle Mode能解决什么问题PWM都给不了的50%方波1.1 当PWM模式开始让位先说说我为什么放着好好的PWM不用偏要折腾翻转模式。我手头那个项目需要一个频率可变的基准方波源占空比必须稳定在50%并且频率切换的时候不允许出现异常脉宽。用PWM模式做理论上把比较值设成ARR的一半就行但实际一调就发现问题ARR是偶数还好ARR是奇数的时候比较值要么是floor、要么是ceil占空比就是49.9%或者50.1%这类不干净的值。虽然很多场景不在乎这0.1%但基准源在乎。另外PWM模式在运行中改频率有个隐患如果ARR和比较值的更新不是同一个时刻生效可能出现一个计数周期里比较值比ARR还大输出就直接锁死在高电平或者低电平直到下一次更新。这种瞬间的异常脉冲在驱动后级电路时非常讨厌。换成Toggle Mode之后ARR决定计数周期比较值CHxCV只决定翻转发生的相位占空比天然是50%这个问题就不存在了。1.2 翻转模式的硬件行为每次比较匹配翻一次Toggle Mode的底层行为很直接。计数器从0向上计数到ARR期间每次CNT等于CHxCV比较匹配事件就会触发输出引脚翻转一次。以上行计数模式为例一个计数周期内只会匹配一次所以电平在周期内翻转一次真正完整的高电平和低电平加起来需要两个计数周期也就是说输出频率等于定时器溢出频率除以2。这里经常有人栽跟头配置的时候算的是定时器溢出频率测出来的却是溢出频率的一半还以为自己时钟配错了。不是是模式本身就是这个特性。如果是中心对齐计数模式情况会有一点不同。计数器先向上到ARR再向下回0一个周期内CHxCV会匹配两次输出会翻转两次输出频率就不再是溢出频率的一半而更像 PWM 的频率行为。但中心对齐模式下的占空比和CHxCV的具体位置强相关不是所有场景都推荐我一般只在上行计数模式下用Toggle Mode。计数模式每周期匹配次数输出频率关系占空比向上计数1次溢出频率 ÷ 2固定50%中心对齐2次与CHxCV位置有关随CHxCV变化1.3 什么项目最需要Toggle Mode根据我自己的应用经验下面这几类项目最适合用Toggle Mode方波基准源、信号发生器频率准确、占空比50%不依赖软件干预。红外载波输出很多红外遥控载波就是38kHz方波用翻转模式直接输出稳定还不占CPU。步进电机的方向时钟、脉冲计数源只需要干净方波频率由定时器决定。与ADC联动的触发源翻转时刻就是比较匹配时刻采样点可以做到和输出波形严格同步这个后面专门讲。一句话总结当你要的是一个“干净的、频率可控的方波”而不是“占空比可调的PWM”的时候Toggle Mode比PWM模式更合适。2. GD32上的Toggle Mode配置从GPIO复用跑到方波输出2.1 外设与引脚先查AF映射再动手我以GD32F303为例定时器用TIMER1的通道0。很多人上来就抄代码GPIO配错了输出死活没波形最后发现是AF号不对。GD32的GPIO复用功能要通过gpio_af_set()指定AF号TIMER1_CH0在PA0上是AF2在别的引脚可能是AF1或者其他编号一定要先查对应型号数据手册里的AF映射表不要凭感觉填。时钟和GPIO配置代码如下基本是GD32标准外设库的固定套路rcu_periph_clock_enable(RCU_TIMER1); rcu_periph_clock_enable(RCU_GPIOA); gpio_af_set(GPIOA, GPIO_AF_2, GPIO_PIN_0); gpio_mode_set(GPIOA, GPIO_MODE_AF, GPIO_PUPD_NONE, GPIO_PIN_0); gpio_output_options_set(GPIOA, GPIO_OTYPE_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_0);这里有个小细节GPIO模式必须设置成复用模式不是普通的输出模式。我曾经见过有人用GPIO_MODE_OUTPUT去配这个引脚结果波形根本出不来因为引脚的输出开关没有连接到定时器外设而是被GPIO控制器接管了。2.2 时间基准初始化频率计算先过一遍假设GD32F303系统时钟120MHzTIMER1挂在APB1上APB1预分频为4PCLK1为30MHz根据GD32的规则定时器时钟等于2倍PCLK1也就是60MHz。我目标输出50kHz方波按照Toggle Mode输出频率等于溢出频率一半的关系定时器溢出频率需要100kHz。计算方式计数时钟 60MHz / (PSC 1) 溢出频率 计数时钟 / (ARR 1) 目标输出频率 溢出频率 / 2 50kHz 所以 (PSC 1) × (ARR 1) 60MHz / 100kHz 600我用PSC299ARR1这样溢出频率刚好100kHz输出就是50kHz。初始化代码如下timer_parameter_struct timer_init_para; timer_init_para.prescaler 299; timer_init_para.period 1; timer_init_para.clock_division TIMER_CKDIV_DIV1; timer_init_para.counter_alignment TIMER_COUNTER_UP; timer_init_para.repetition_counter 0; timer_init(TIMER1, timer_init_para);注意如果你的应用需要更高分辨率可以把PSC调小、ARR调大这样计数器步进更细频率调节档位也更细。比如PSC149、ARR3输出频率同样是12.5kHz这里不展开总之根据目标频率倒推即可。2.3 输出通道配置模式、脉宽值、预装载通道配置是核心。GD32标准外设库里通道输出模式的配置主要靠timer_channel_output_config()函数里面传入输出比较模式然后单独设置比较值CHxCVtimer_channel_output_config(TIMER1, TIMER_CH_0, TIMER_OC_MODE_TOGGLE); timer_channel_output_pulse_value_config(TIMER1, TIMER_CH_0, 1); timer_channel_output_preload_config(TIMER1, TIMER_CH_0, TIMER_OC_PRELOAD_ENABLE); timer_auto_reload_preload_enable(TIMER1);我解释一下这个CHxCV为什么是1而不是0因为ARR1计数器只在0和1两个值之间走CHxCV设为1时计数器从0跳到1的那一瞬间触发比较匹配输出翻转一次如果CHxCV设成0相当于计数器刚回0就翻转相位会整体提前一个计数时钟不影响频率但会影响信号和更新事件的相对位置。预装载一定要开尤其你打算在运行中改变频率。预装载的意思是比较值和ARR都先写进缓存等到更新事件再一次性生效避免在计数周期中间改参数导致输出一个异常宽的脉冲。这个坑我在实际项目里踩过后面展开。然后如果是高级定时器比如TIMER0还要打开主输出使能否则通道输出被主输出控制位锁住引脚上不会有波形timer_primary_output_config(TIMER1, ENABLE);这个问题在通用定时器上不存在只有高级定时器有MOEN主输出控制位。但我在项目里习惯了TIMER1它是通用定时器这行多余但写上也无害。2.4 启动与实测一个完整示例配置完成后启动定时器使能通道输出timer_enable(TIMER1); timer_channel_output_state_config(TIMER1, TIMER_CH_0, ENABLE);完整代码串起来我的习惯是把初始化封装成一个函数void toggle_output_init(void) { rcu_periph_clock_enable(RCU_TIMER1); rcu_periph_clock_enable(RCU_GPIOA); gpio_af_set(GPIOA, GPIO_AF_2, GPIO_PIN_0); gpio_mode_set(GPIOA, GPIO_MODE_AF, GPIO_PUPD_NONE, GPIO_PIN_0); gpio_output_options_set(GPIOA, GPIO_OTYPE_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_0); timer_parameter_struct timer_init_para; timer_init_para.prescaler 299; timer_init_para.period 1; timer_init_para.clock_division TIMER_CKDIV_DIV1; timer_init_para.counter_alignment TIMER_COUNTER_UP; timer_init_para.repetition_counter 0; timer_init(TIMER1, timer_init_para); timer_channel_output_config(TIMER1, TIMER_CH_0, TIMER_OC_MODE_TOGGLE); timer_channel_output_pulse_value_config(TIMER1, TIMER_CH_0, 1); timer_channel_output_preload_config(TIMER1, TIMER_CH_0, TIMER_OC_PRELOAD_ENABLE); timer_auto_reload_preload_enable(TIMER1); timer_enable(TIMER1); timer_channel_output_state_config(TIMER1, TIMER_CH_0, ENABLE); }实测的时候用逻辑分析仪或者示波器看PA0应该能直接看到频率50kHz的方波。你如果发现频率正好差一半不要慌先确认是不是忘了乘以2也就是Toggle Mode输出频率等于溢出频率一半这个关系。3. 频率总是慢一倍时钟树倍频规则和Toggle除法效应3.1 现象一按120MHz算分频定时器却走60MHz这个坑属于典型“按惯性思维算时钟”导致的。我之前给GD32F303初始化定时器想在PA0上输出一个1kHz方波按120MHz的系统时钟去算分频结果实测只有500Hz慢了一倍。一开始怀疑PSC配错了检查代码没问题又怀疑外部晶振频率不对测了HXTAL是25MHz也没问题。最后把时钟树捋了一遍才发现TIMER1挂在APB1而APB1预分频不是1定时器时钟不是等于PCLK1而是等于2倍PCLK1。如果按120MHz直接配实际喂给定时器的计数时钟只有60MHz那最终输出频率自然只有预期的一半。这正好对应网上常搜的“gd32单片机timer定时器慢了一倍”。3.2 牢记GD32定时器时钟的倍频规则GD32的时钟树里有一个非常容易被忽略的倍频规则如果APB1预分频系数为1则TIMERxCLK PCLK1。如果APB1预分频系数不为1则TIMERxCLK 2 × PCLK1。挂在APB2上的定时器比如TIMER0、TIMER7同样遵循这个规则。这个设计的目的是让定时器能拿到更高的时钟频率。APB1外设本身可能只跑30MHz但定时器内核可以跑到60MHz甚至120MHz两者独立。很多人只记得APB1分频了却忘了定时器有倍频于是所有挂在APB1上的定时器时间计算全都错了一倍。我实际排查的时候第一步不是改代码而是先把RCU_CFG0寄存器读出来看系统时钟、AHB分频、APB分频的实际配置。GD32库里有RCU_CFG0寄存器的位定义直接在调试器里看就行。常见组合下的定时器时钟如下表系统时钟AHB分频APB1分频PCLK1TIMERxCLKAPB1定时器120MHz/1/1120MHz120MHz120MHz/1/260MHz120MHz120MHz/1/430MHz60MHz108MHz/1/254MHz108MHz72MHz/1/236MHz72MHz一句话APB分频不是1的时候定时器时钟自动翻倍你别在PSC里再乘个2。3.3 现象二把溢出频率当成输出频率这个更隐蔽就是Toggle Mode本身的除法效应。用户配置的时候说“我要输出50kHz”于是他把定时器溢出频率配成50kHz结果测出来25kHz。不是时钟错了不是分频错了是Toggle Mode下输出频率天然等于溢出频率除以2。所以正确做法是先把目标输出频率乘以2得到溢出频率再去算PSC和ARR。我在项目里的习惯是在代码注释里强制写上/* 目标输出频率50kHzToggle Mode下溢出频率需为100kHz */这个注释救了后来的维护者很多次也包括我自己。3.4 如何快速定位是哪种“慢了一倍”当你发现输出频率正好是设定值的一半时按这个顺序排查先确认计数器溢出频率是否等于理论值。直接读定时器寄存器或者开一个溢出中断看跳变频率如果溢出频率正确问题在Toggle Mode的除以2。如果溢出频率本身就不对再算时钟。确认TIMERxCLK到底是PCLKx还是2倍PCLKx。如果还是不对查APB分频配置是否真的生效有些启动代码里system_clock_config()和后面手动改RCU分频的代码产生了覆盖关系。4. timer执行查询报空指针生命周期、回调与中断的连锁问题4.1 一段会崩的中断代码“timer执行查询是报空指针”这个热搜关键词让我很眼熟因为我确实在项目里见过类似崩溃。典型代码如下typedef struct { uint32_t toggle_count; uint32_t period_us; } pulse_status_t; pulse_status_t *g_status; void TIMER1_IRQHandler(void) { if (timer_interrupt_flag_get(TIMER1, TIMER_INT_FLAG_UP) ! RESET) { timer_interrupt_flag_clear(TIMER1, TIMER_INT_FLAG_UP); g_status-toggle_count; /* 空指针崩溃位置 */ } }g_status是一个全局指针在main里被赋值为某个结构体数组的地址但如果你在定时器中断使能之后、g_status赋值之前恰好来了一次更新中断那中断服务函数访问的就是一个未初始化的指针。在很多编译器的启动环境下未初始化的全局指针是0直接解引用就崩了。还有一种更隐蔽的g_status被赋成了某个函数的局部变量地址函数退出后栈空间被其他数据覆盖定时器中断一来访问的就是一块悬挂内存表现是“有时候崩、有时候不崩”。4.2 崩溃背后的三个根因我在调试这类问题时发现原因往往不是单一层面的常见的有三个第一初始化顺序错。数据对象应该先于外设中断创建和赋值但很多人的代码是先初始化定时器Timer使能中断再在后面的初始化函数里给指针分配内存、给结构体填初值。第二中断服务函数做的事太多。在中断里做状态查询、结构体字段操作、甚至调用耗时库函数都容易踩到资源未就绪的问题。中断的优先级越高越可能在系统还在启动过程中就进来。第三库函数参数传了无效值。有时候不是业务指针为空而是调用查询函数时把定时器外设指针写成了NULL或者0库函数内部对这个地址做寄存器访问直接HardFault报的表现也类似“空指针”。4.3 防御式写法与初始化顺序我现在的代码风格是状态结构体尽量用静态全局变量不搞裸指针动态分配如果必须用指针随时判空。static pulse_status_t g_pulse_status {0}; pulse_status_t *g_status g_pulse_status; void TIMER1_IRQHandler(void) { if (timer_interrupt_flag_get(TIMER1, TIMER_INT_FLAG_UP) ! RESET) { timer_interrupt_flag_clear(TIMER1, TIMER_INT_FLAG_UP); if (g_status ! NULL) { g_status-toggle_count; } } }初始化顺序上我坚持两条铁律先初始化所有数据结构和全局对象再开外设中断。把中断里的事减到最少能放主循环的查询逻辑绝不放中断。如果你一定要在中断里响应查询至少保证查询回调函数指针是有效的或者在注册回调时判空。GD32和STM32的库在回调函数上一般没有统一抽象很多项目自己用函数指针封装函数指针为NULL时调用同样会空指针崩溃。4.4 关闭中断也会崩溃那就要查寄存器指针参数还有一种情况中断关闭了但程序执行到timer_counter_read(TIMER1)这类查询函数时仍然报空指针。这时候多半是外设参数传错了比如封装函数的时候漏传了TIMER1传了个0进去。GD32库函数底层会拿这个参数去计算寄存器地址0作为基址直接访问不崩才怪。防御性封装可以参考uint32_t safe_timer_read_counter(uint32_t timer_periph) { if (timer_periph 0) { return 0; } return timer_counter_read(timer_periph); }5. 让ADC跟着Toggle走定时器触发采样的实战组合5.1 为什么采样点要跟着波形走很多应用不只是要一个方波还要在方波的特定相位点上采集模拟量。比如电机控制里测相电流、开关电源里测电感电流采样点必须放在PWM波形的中间点或者翻转点才能避开开关噪声最严重的时刻。Toggle Mode在这种场景下特别好用因为翻转事件本身就是一个比较匹配事件它在时间轴上的位置天然精确你可以让这个事件同时去触发ADC采样。这样波形输出和采样点之间的时间关系不是软件估算出来的而是硬件保证的误差只有定时器时钟周期级别。5.2 外部触发源与TRGO配置GD32的定时器可以把特定事件映射到TRGO主输出ADC可以把TRGO作为自己的外部触发源。配置思路分三步第一步设置定时器主输出触发源。如果希望每次计数溢出都触发一次采样就选更新事件如果希望只在比较匹配时触发就选通道比较事件timer_master_output_trigger_config(TIMER1, TIMER_TRIG_MODE_UPDATE);第二步配置ADC外部触发源。GD32的ADC有不同的触发输入其中一路就是TIMER1_TRGOadc_external_trigger_source_config(ADC_0, ADC_EXT_TRIG_SOURCE_TIMER1_TRGO); adc_external_trigger_config(ADC_0, ADC_EXT_TRIG_EDGE_RISING, ENABLE);第三步使能ADC并启动软件触发adc_enable(ADC_0); adc_software_trigger_enable(ADC_0, ADC_REGULAR_CHANNEL);从这里开始每次定时器事件到来ADC会自动开始转换不需要CPU干预。TRGO配置好之后采样频率就由定时器的溢出频率决定非常稳定。5.3 相位微调技巧采样点可以精确到比较值如果我希望采样点比翻转点稍微晚一点比如等电路稳定后再采样就要调整时间关系。一个常用办法是用两个通道一个通道做Toggle输出另一个通道专门用来产生比较匹配触发。两个通道的CHxCV可以分别设置一个控制波形相位一个控制ADC采样点两者之间只差一个比较值的差。举个例子TIMER1_CH0做输出CHxCV1波形在计数到1时翻转TIMER1_CH1做ADC触发CHxCV2那么采样点就比翻转点晚了1个计数时钟。你如果把CH1的CHxCV设成0那采样点就比翻转早一个时钟。这个精度等级软件靠轮询是绝对做不到的。需要注意CHxCV不能大于ARR。所以如果ARR很小相位调节的范围就很窄设计时要把ARR适当放大。这也意味着频率步进会变细对应用而言通常是好事。5.4 转换时间够不够别让ADC拖后腿定时器触发再准ADC来不及转换也是白搭。GD32F303的ADC时钟典型配置是10MHz左右单次转换大约需要十几到几十个ADC时钟周期换算下来微秒级别。如果定时器溢出频率太高两次触发之间的间隔小于ADC总转换时间ADC就来不及完成上一次转换触发标志被覆盖转换结果就乱了。我一般这样估算先查数据手册里ADC的转换周期数规则通道单次转换一般是采样时间加上固定周期数。假设总转换时间1.2us那么触发间隔至少要大于1.5us到2us才安全对应定时器溢出频率在500kHz以下。如果你要更高的采样率得改ADC时钟、缩短采样时间或者换多通道交替采样。6. 调试验证波形、频率计时与上位机陷阱6.1 用示波器验证波形该看哪些参数把Toggle Mode输出接入示波器我第一眼会看三个东西频率、占空比、抖动。频率直接用示波器的频率测量功能和设定值对比。占空比应该是50%如果偏离很多多半是探头或触发电平设置有问题而不是代码问题。抖动看波形边缘的累积情况如果开启余辉模式边沿应该是一条细线如果变成很宽的光带说明时序不稳可能是时钟源用了内部RC或者系统里存在优先级更高的中断频繁打断。Toggle Mode本身是硬件翻转不依赖中断所以正常抖动应该在计数器时钟周期量级。如果抖动明显优先检查时钟源和电源噪声而不是怀疑定时器配置。6.2 WinForms Timer为什么测不准微秒级周期调试过程中经常需要写个简单的上位机来测量板卡输出的方波周期。很多人图省事直接拖一个WinForms Timer控件在Tick事件里计数然后算出周期。结果数据跳得离谱甚至完全不对最后怀疑下位机定时器有问题其实是被上位机坑了。WinForms Timer依赖Windows消息循环WM_TIMER消息的触发精度非常差默认情况下大概只能保证十几毫秒的粒度而且消息队列一忙触发就滞后。用这种工具测量微秒甚至毫秒级的脉冲周期纯属拿米尺量纳米。正确做法是用Stopwatch。它基于高精度计数器在Windows上通常可以到微秒级分辨率。下面是一段简化的测量代码var sw Stopwatch.StartNew(); // 等待外部信号触发比如通过串口数据的上升沿标记 sw.Stop(); double period_ms sw.Elapsed.TotalMilliseconds;如果你的信号频率比较高、要求更精确建议直接用逻辑分析仪或示波器测量上位机只负责记录和展示不要承担精确计时任务。6.3 触发电平引起的误读数还有一个我自己踩过的小坑Toggle Mode输出的是方波示波器如果触发电平设得过于接近波形幅值的中间尤其是信号带一点噪声的时候可能一个周期触发两次示波器显示频率变成实际的两倍。反之如果触发电平超出波形范围又可能完全触发不了。遇到这种问题把触发电平调到波形幅值的50%附近或者直接选上升沿触发并把触发电平设到幅值的25%左右读数就正常了。这算是一个测试层面的经验但很实用很多时候项目组吵了半天“频率不对”最后发现是示波器触发电平的问题。我自己在GD32上调Toggle Mode回头再看这个过程其实真正难的从来不是“把寄存器配出来”而是搞清楚这个模式在硬件层面到底怎么工作翻转事件发生在哪个时刻、输出频率和溢出频率是几倍关系、时钟从哪里来。这几个点一旦在脑子里立住了后续不管换哪个型号的单片机翻来覆去都是同一套逻辑。最后再分享一个小技巧如果项目需要多路频率同步的方波可以把几个定时器的TRGO串起来让一个主定时器去触发另一个从定时器的时钟这样从定时器的翻转输出会和主定时器严格保持相位关系比软件同步靠谱得多。