
做嵌入式开发的人迟早都会碰到一个需求测一个PWM信号的频率和占空比。不管是读遥控器接收机信号、测电机转速反馈、还是分析某个传感器的输出PWM测量都是最基础也最常被问起的一块。如果你用的是STM32Cube HAL库那输入捕获Input Capture就是官方给你铺好的路但说实话HAL库封装得太温柔很多人照着例程抄完代码却搞不清楚自己到底在测什么一出问题就抓瞎。这篇文章就围绕STM32F4系列把输入捕获测PWM这件事从头到尾掰开揉碎讲清楚原理、配置、代码、计算公式、实测误差、还有那些文档里不会写的坑。1. 先把输入捕获的原理讲透一个计数器和一个寄存器的故事1.1 输入捕获到底在干一件什么事STM32的定时器里有个自由运行的计数器CNT它像秒表一样按你设置的时钟频率不停往上加或者加到头溢出归零。输入捕获干的事就是当引脚上出现你配置的边沿上升沿或下降沿时硬件自动把当前CNT的值拷贝到捕获寄存器CCRx里然后触发一个中断或者事件。注意自动这两个字——整个过程不需要CPU参与你不用在中断里读计数器也不用轮询引脚电平。硬件在边沿到达的那一瞬间完成照相把当时的计数值定格下来。这就是输入捕获比外部中断测量精度高的根本原因外部中断从边沿触发到CPU进入中断服务函数读取计数器中间有中断响应延迟几十个时钟周期就出去了而输入捕获是纯硬件动作边沿一到CCR寄存器里的值就是那个时刻CNT的精确快照。1.2 为什么测PWM要用输入捕获而不是外部中断很多人刚接触时会有个疑问我用外部中断测高电平时间不行吗行但精度和资源开销都不划算。外部中断测PWM的思路是上升沿中断里开启一个定时器计时下降沿中断里读定时器值。问题在于中断进出有开销而且高频率PWM下中断频率翻倍CPU被频繁打断系统其他任务就别想跑了。输入捕获只需要一个定时器配置两个通道或者一个通道捕获双边沿硬件自动记录边沿时刻CPU只在需要计算时读寄存器或者在回调里做个简单处理。以测量一个20kHz的PWM为例如果用外部中断每秒钟要进40000次中断用输入捕获你甚至可以不进中断全靠硬件完成CPU完全解放。还有一点很关键输入捕获记录的是边沿发生的时刻而不是电平持续的时间。这意味着你可以在事后通过相邻两个边沿的时刻差算出周期也可以通过上升沿到下降沿的时刻差算出高电平时间。这正是测量PWM频率和占空比的基础。2. CubeMX里怎么配每个参数都是在回答一个计算题2.1 定时器时钟所有计算的起点用CubeMX配置时很多人第一步就栽在时钟上。STM32F4的定时器时钟不是随便来的APB1和APB2总线的定时器时钟频率可能和总线频率不同取决于时钟树里的分频配置。拿STM32F407举例如果系统时钟配置为168MHzAPB1总线频率是42MHz但APB1上的定时器时钟自动倍频为84MHzAPB2总线频率是84MHzAPB2上的定时器时钟就是168MHz。CubeMX里你可以在Clock Configuration页面看到TIMx时钟频率务必在配置定时器之前确认这个值因为你后面所有计算都基于它。注意TIM2、TIM3、TIM4、TIM5、TIM6、TIM7挂在APB1上TIM1、TIM8、TIM9~TIM11挂在APB2上。选定时器之前先查它挂在哪个总线上这决定了你的时基精度上限。我当时实测过如果用错了时钟频率假设算出来的PWM频率误差可能高达一倍而且你还没法从代码层面察觉只能拿示波器对照才发现不对。2.2 预分频和自动重装载决定你测量的量程CubeMX里配置定时器时有几个关键参数Prescaler预分频、Counter Period自动重装载值ARR、Internal Clock Division时钟分频以及计数模式。Prescaler的本质是把定时器时钟分频后再喂给计数器。比如定时器时钟84MHzPrescaler设为84-183计数器就按1MHz计数每个计数代表1微秒。这里有个初学者容易忽略的点硬件里预分频器是值1生效你写入83实际分频是84。所以Prescaler 期望计数频率对应的分频值 - 1。Counter PeriodARR决定计数器从0加到多少算一个周期。这个值直接决定你的测量量程。计数器溢出一次的时间是溢出周期 (Prescaler 1) × (ARR 1) / 定时器时钟频率比如分频后计数频率1MHzARR设为10000-1即9999那么每10ms溢出一次。你的PWM周期必须小于这个溢出周期否则一个周期内计数器可能溢出两次你在计算时就会得到错误结果。别急着把ARR设到最大65535量程和分辨率是矛盾的后面详细说。2.3 捕获通道配置边沿、滤波器和映射在CubeMX中让某个定时器通道进入输入捕获模式后你会看到几个配置项Polarity Selection捕获边沿有上升沿、下降沿、双边沿三个选项。测PWM通常选Both Edges双边沿这样一次配置就能得到周期和高电平时间。IC SelectionDirect或Indirect选Direct直接映射就行。这个选项的意思是捕获信号来自哪个通道Direct就是本通道引脚。Prescaler这个是信号分频不是时基分频。设为1不分频就好它用于高频下降低中断频率测普通PWM用不上。Filter输入滤波器设置为0到15之间的值。这个滤波器是数字滤波器用于滤除引脚上的毛刺。后面避坑部分专门讲。配置第一步就是选择定时器的某个通道然后在Pinout页面确认引脚已经分配给这个定时器通道。很多人配置完发现引脚是灰色的是因为没有在Pinout页面的Timers里找到对应TIMx并把Channel打开这两步缺一不可。3. HAL代码实现捕获回调里到底干了什么3.1 初始化的HAL函数调用链CubeMX生成代码后定时器和通道的初始化在MX_TIMx_Init函数里完成参数都是你在图形界面里填的那些。但真正让输入捕获跑起来你还需要两步HAL_TIM_IC_Start_IT(htim2, TIM_CHANNEL_1); // 启动通道1输入捕获并开启中断如果你只配置了一个通道来做双边沿捕获这一步就够了。但要注意HAL库的HAL_TIM_IC_Start_IT只启动了你指定的通道如果配置在CubeMX里开了两个通道你得分别启动HAL_TIM_IC_Start_IT(htim2, TIM_CHANNEL_1); HAL_TIM_IC_Start_IT(htim2, TIM_CHANNEL_2);这个函数内部做的事情远比名字看起来多它使能了捕获比较中断CCIE对应的通道位使能了定时器更新中断UIE最后使能定时器计数器。也就是说计数器从现在开始跑边沿到来会进中断。3.2 捕获回调函数区分上升沿和下降沿HAL库把输入捕获的中断处理统一到了一个弱函数里void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)你需要覆盖这个函数。在这个回调里你会面临第一个关键区分这次捕获是上升沿还是下降沿HAL在中断里会通过sIC_CaptureIndex来标记当前捕获到的是哪个边沿但对用户来说更直观的判断方式是看回调时的通道捕获值来源。具体来说当配置为双边沿捕获时同一个通道的CCR寄存器会依次记录上升沿、下降沿、上升沿……你需要在回调里记录每次的捕获值和当前是第几次捕获。一个常见的实现volatile uint32_t capture_buf[4]; volatile uint8_t capture_index 0; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { if (htim-Channel HAL_TIM_ACTIVE_CHANNEL_1) { capture_buf[capture_index] HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); capture_index; if (capture_index 4) { capture_index 0; } } } }这里用了4个缓冲区是为了凑齐一个完整PWM周期所需的两个完整时刻点。你看第一次上升沿在T0第一次下降沿在T1第二次上升沿在T2。周期 T2 - T0高电平时间 T1 - T0。你必须等到第三次捕获T2才能算出一个完整周期所以缓冲区至少需要3个值用4个做循环缓冲更稳健。提示HAL库还有一个HAL_TIM_IC_CaptureHalfChipCallback配合DMA使用能处理高频PWM但这是进阶玩法普通应用用不到。3.3 关于更新事件的另一个回调前面说了计数器溢出是个大问题。HAL库还有一个回调专门处理计数器溢出void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)这意味着你的系统里所有使用同一个定时器的功能都会进这个回调包括时基、PWM输出等。所以回调里的第一件事永远是判断htim-Instance最好再判断是什么功能触发的不然多个外设共用定时器时会互相干扰。4. 频率和占空比计算公式背后那几个容易踩的坑4.1 基本计算公式拿到三次捕获值后计算就简单了周期 (T2 - T0) × (Prescaler 1) / 定时器时钟频率 高电平时间 (T1 - T0) × (Prescaler 1) / 定时器时钟频率 频率 1 / 周期 占空比 高电平时间 / 周期 × 100%举个例子定时器时钟84MHzPrescaler 83也就是计数频率1MHz每个计数代表1微秒。如果T0 1000T1 1300T2 1500那么高电平时间 (1300 - 1000) × 1μs 300μs周期 (1500 - 1000) × 1μs 500μs频率 2kHz占空比 300/500 60%这个例子里T0、T1、T2都是单次计数范围内的值直接相减没问题。但现实中可能不是这么理想化。4.2 计数器溢出时的修正两个方案假设你的PWM是100Hz也就是周期10ms而你设定的计数器每5ms就溢出一次。那么从第一次上升沿到第二次上升沿之间计数器可能溢出一到两次直接T2 - T0会得到负数或者一个完全错误的值。解决办法有两个思路。方案一把量程做大。让(Prescaler1) × (ARR1)对应的时长大于你要测的最大PWM周期比如测100Hz的PWM计数器溢出周期至少要大于10ms。你可以加大ARR或者加大Prescaler但注意Prescaler加大意味着分辨率变差每个计数代表的微秒数变大ARR加大到65535是16位定时器的上限。对于更慢的信号就得用32位定时器TIM2和TIM5在F4上是32位。方案二在溢出回调里记录溢出次数计算时把溢出补偿进去volatile uint32_t overflow_count 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { overflow_count; } }然后在计算时实际时间差 捕获值差 overflow_count变化量 × (ARR 1)。但这里有个麻烦你必须在捕获回调里立刻读取overflow_count并且每次计算完要清零否则下次计算会把历史累计值也算进去。理清这个时序需要细心很多人调试很久才发现是溢出补偿逻辑写错了。我个人的建议是优先用方案一把量程设计好让计数器在测量期间尽量不溢出。只有当你要测的信号范围特别宽比如从几Hz到几十kHz都要测才上溢出补偿。因为溢出补偿里涉及临界区保护溢出回调修改overflow_count主循环读取它处理不当还会遇到竞态问题。4.3 占空比的第二个算法陷阱有些人图省事用一个通道捕获上升沿和下降沿再用另一个通道也捕获这样可以得到更多数据点。但要注意如果PWM信号的周期大于计数器溢出周期单纯依赖捕捉值计算高电平时长同样会出错。别看到公式简单就觉得稳了所有公式的前提都是捕获值在同一个溢出周期内或者你正确处理了溢出。还有一种常见做法是使用定时器的PWM输入模式Slave Mode设为Reset Mode把两个通道分别配置为捕获周期和高电平时间硬件自动在上升沿时把CNT清零。这样好处是不用管溢出问题周期直接读CCR2高电平时间读CCR1。但这种模式下有一个限制如果占空比是0%或100%也就是引脚上没有电平变化定时器收不到复位信号CCR里的值会残留上一次的数据你无法区分信号真的没了和没有新数据。后面避坑会讲到这个。5. 实测几个典型波形误差主要来自哪里5.1 测试环境搭建写代码之前先搭一个可复现的测试环境。我用了一块STM32F407最小系统板TIM4的CH1PB6作为输入捕获引脚信号源用另一个定时器TIM3的CH1PA6输出PWM这样完全基于板载资源不需要外接信号发生器就能自测。用杜邦线把PA6和PB6连起来。引脚方面有几个细节要确认PB6在F407上默认是I2C1的SCL但CubeMX里配置为TIM4_CH1后复用功能会被正确设置不用手动改。有些板子PB6上有其他外设或者LED注意看原理图避免被板载负载影响信号质量。输入捕获引脚不需要上拉如果信号源是开漏输出才需要普通推挽输出直接连就行。5.2 用自产PWM验证测量结果测试程序里TIM3输出一个预设频率和占空比的PWMTIM2的输入捕获端测这个信号。我配置时钟84MHzPrescaler 83计数频率1MHzARR 65535因为只测高电平时间和周期量程很宽。实测数据TIM3输出2kHz占空比40%测量次数高电平计数值周期计数值高电平时长周期误差1200500200μs500μs0.1%2200500200μs500μs0.1%3199500199μs500μs~0.5%为什么会有1个计数的误差因为捕获的边沿和计数器时钟不同步边沿落在两个计数时钟之间硬件只能取离它最近的整数计数值这就天然引入了±1个时钟周期的量化误差。计数频率1MHz时每个计数1μs所以误差最大1μs。对500μs的周期来说相对误差约0.2%。如果你觉得误差太大可以降低Prescaler倍数让计数频率更高。比如Prescaler 0计数频率就是84MHz每个计数约12ns误差微乎其微。但代价是ARR最大值下的量程只有65535 × 12ns ≈ 786μs只能测频率高于约1.3kHz的信号。这就是量程和分辨率的权衡你得根据被测信号的频率范围来选。5.3 高占空比和低占空比的实测表现我还测了几个极端占空比。占空比90%时高电平时间测量很准因为高电平持续期间有很多计数点但占空比5%时高电平时间只有25μs2kHz信号量化误差1μs对它的影响就放大到了4%。所以一句话信号频率越高占空比越极端对计数分辨率的要求就越高。这时候你就要权衡是否要牺牲量程换取分辨率。6. 实战避坑溢出、毛刺、信号丢失和那些玄学问题6.1 输入滤波器的正确打开方式CubeMX中输入捕获配置里的Filter选项是很多人忽略但非常实用的参数。它的本质是对引脚信号做数字滤波只有信号稳定持续若干个采样周期后才认为电平变化是有效的。这个滤波器能滤除接触不良、线路干扰产生的毛刺。Filter值和时间的关系采样频率 定时器时钟频率 / (2 × (Filter值 1)) 有效条件信号必须稳定超过该采样周期才被确认如果定时器时钟84MHzFilter设为8那么采样频率约4.67MHz毛刺宽度小于约214ns就会被滤掉。实际项目中如果信号线较长或者环境有电磁干扰给Filter设个非零值能省很多事。但要注意Filter值太大会让真实边沿也被延迟甚至滤掉尤其对于高频PWM需要根据信号边沿的陡峭程度合理设置。6.2 占空比0%和100%怎么处理前面提到PWM输入模式Reset Mode在0%和100%占空比下会失效其实普通双边沿捕获模式也面临类似问题。如果占空比是0%引脚一直为低你永远等不到上升沿回调不再触发如果占空比是100%引脚一直为高你等不到下降沿。对需要识别这种极端占空比的应用一个稳妥的方案是另开一个定时器做超时检测在一段时间内没收到捕获中断就认为信号丢失主动把占空比置为0或100%通过GPIO读引脚电平来判断。这个超时逻辑虽然简单但在实际产品中非常关键否则你的系统会在信号消失时一直显示上一次的计算结果看起来就像卡死了一样。6.3 HAL回调进不去查中断优先级一个很容易忽视的坑STM32CubeMX生成的代码里定时器中断优先级默认可能和你其他外设冲突或者被其他中断长时间抢占。如果HAL_TIM_IC_CaptureCallback一直不触发先检查HAL_NVIC_EnableIRQ是否真的被调用了再检查中断优先级分组配置——如果多个外设共享优先级组配置错误会直接导致中断不响应。还有一个所有HAL库用户都会遇到的低级错误忘了在主循环或者初始化里调用HAL_TIM_IC_Start_IT。CubeMX只会在MX_TIMx_Init里初始化寄存器但不会自动启动捕获。我看到很多人在论坛上问为什么回调和例程一样却没反应九成是这个原因。6.4 两路PWM同时测量怎么处理如果要同时测两路PWM可以用同一个定时器的两个通道分别配置输入捕获也可以两个定时器各测一路。用同一个定时器的好处是时基一致省资源坏处是如果两路信号的边沿相隔很近HAL的回调里要快速处理不能做耗时操作。我实际测过两路信号TIM2_CH1和TIM2_CH2分别接一路PWM两路频率不同一个2kHz一个1kHz。在回调里通过htim-Channel区分是哪一路捕获到了逻辑清晰实测完全没问题。如果你用DMA搬运捕获值那更是优化过的方案CPU可以长时间不参与。6.5 一个容易忽略的初始化顺序问题最后再分享一个细节如果你在系统里同时使用PWM输出和输入捕获而且它们用的是同一个定时器那就得特别小心初始化顺序。比如你用TIM3既输出PWM又做输入捕获不同通道CubeMX生成代码时会按一定顺序调用各个MX_TIMx_Init如果你在上电初始化后立刻调用HAL_TIM_IC_Start_IT但此时PWM输出还没开始捕获功能依然会正常启动。但如果反过来让你捕获的通道混用了PWM输出的配置寄存器可能不对。我的经验是把PWM输出和输入捕获分开用不同的定时器除非你对这个定时器的所有通道资源有十足把握。定时器在STM32里是最宝贵的资源之一但F4系列定时器数量足够日常项目里一个功能一个定时器的配置思路能帮你少踩很多资源共享的坑。测PWM这件事原理就那么点但真正落地时涉及时钟、寄存器、中断、量程、滤波这些环环相扣的细节。把这套流程完整跑通一次你对STM32定时器的理解会明显上一个台阶。以后不管是测频率、测脉宽、还是接各种编码器信号都能举一反三。如果你在实现过程中遇到什么怪问题也可以按这个思路排查先确认信号是否真实到达引脚再确认定时器时钟和分频是否符合预期最后看回调是否被触发以及缓冲区数据是否合理——这三步走完大部分问题都能定位到根因。