
1. 为什么要在Proteus里折腾输入捕获这件事做过电机控制或者电源类项目的朋友应该都有体会PWM频率的测量精度直接决定了后续控制算法的效果。我在早期做无刷电机驱动的时候就吃过频率测不准的亏——转速环的反馈值一直在抖调了半天PID参数都没用最后发现是频率测量环节的误差太大把整个控制链路都带偏了。STM32F103C8T6这颗芯片大家太熟了性价比高、资料多、上手快但它的定时器输入捕获功能如果只照着参考手册配实际跑起来总会遇到各种细节问题。尤其是放到Proteus里仿真的时候虚拟信号源和真实硬件的差异会让不少人卡在“代码看着没问题但就是测不到值”的阶段。这篇内容就是把我自己在Proteus里用STM32F103C8T6的TIM输入捕获模式测PWM频率的完整过程拆开来讲。从定时器配置的底层逻辑到Proteus信号源的参数设置再到代码里几个容易翻车的细节都会覆盖到。不管你是刚接触STM32定时器的新手还是想找个靠谱的仿真验证方案的老手应该都能从里面找到有用的东西。核心思路其实不复杂用一个定时器产生PWM波另一个定时器的输入捕获通道去抓这个PWM的边沿通过计算两次捕获之间的计数值差来反推频率。但“不复杂”和“能跑通”之间隔着不少需要动手试才知道的坑。2. 输入捕获测频率的底层逻辑拆解2.1 捕获的到底是什么从边沿到计数值输入捕获的本质是在定时器运行的某个时刻把计数器CNT的当前值“冻结”到一个寄存器CCR里。这个动作由外部信号触发触发条件可以是上升沿、下降沿或者双边沿。拿测PWM频率来说最直接的做法是配置为上升沿捕获每次捕获到上升沿时记录CNT值两次上升沿之间的CNT差值就是PWM的一个完整周期对应的计数值。用定时器的计数频率除以这个差值就得到了PWM的频率。公式很简单PWM频率 定时器计数频率 / (第二次捕获值 - 第一次捕获值)但这里有个关键点定时器的计数频率怎么定它等于定时器时钟频率除以预分频系数PSC1。比如STM32F103C8T6的TIM2挂在APB1总线上时钟频率通常是72MHz如果PSC设为71那计数频率就是72MHz/721MHz也就是每1微秒计一个数。2.2 为什么不用PWM输入模式而用普通输入捕获STM32的定时器其实有一个专门的“PWM输入模式”硬件会自动帮你完成周期和占空比的测量看起来很方便。但我在实际使用中更倾向于用普通的输入捕获模式原因有几个。PWM输入模式会占用两个捕获通道一个测周期一个测占空比而且配置起来对通道的对应关系有硬性要求——比如TIM2的通道1和通道2必须配合使用。这在引脚分配紧张的时候就很被动。另外PWM输入模式在Proteus里的仿真表现不太稳定有时候捕获中断触发得莫名其妙。普通输入捕获模式虽然需要自己写中断处理逻辑但灵活性高得多。你可以只用一路捕获也可以在多路PWM测量时自由分配通道。而且理解了普通捕获的流程之后PWM输入模式那套东西自然就懂了。2.3 溢出问题绕不过去的计数器翻转16位定时器的CNT寄存器最大计到65535就会溢出归零。如果你的PWM频率比较低比如只有几十赫兹那两次捕获之间的计数值差可能超过65535这时候直接相减就会得到一个负数或者错误的值。处理溢出的标准做法是在定时器溢出中断里维护一个溢出计数变量。每次CNT溢出就加一计算周期时把溢出次数考虑进去总计数 溢出次数 × 65536 第二次捕获值 - 第一次捕获值在Proteus仿真里如果你测的是几百赫兹到几十千赫兹的PWM溢出问题基本不会遇到。但代码里最好还是把这个逻辑加上不然换到实际硬件上测低频信号就会出问题。3. Proteus仿真环境的搭建与信号源配置3.1 元件选型与电路连接在Proteus里搭建这个仿真需要的核心元件不多STM32F103C8T6最小系统Proteus元件库里的STM32F103C8信号发生器用DCLOCK或者PULSE源都可以虚拟示波器可选用来观察波形必要的电源和地连接上把信号发生器的输出接到STM32的某个定时器输入捕获引脚上。我一般用PA0对应TIM2的通道1。这个引脚在Proteus的STM32模型上位置好找接线也方便。信号发生器这边如果你用PULSE源需要设置几个关键参数初始电平、脉冲电平、延迟时间、上升时间、下降时间、脉冲宽度和周期。测频率的话脉冲宽度和周期决定了PWM的占空比和频率。比如周期设为1ms那就是1kHz的PWM。3.2 信号源参数设置的几个细节Proteus里的PULSE源有一个容易忽略的地方它的“周期”参数和“脉冲宽度”参数是独立的如果你把脉冲宽度设得比周期还大波形就会出问题。我一般习惯先设周期再设脉冲宽度为周期的一半这样占空比就是50%示波器上看也直观。另外Proteus的信号源在仿真开始后的前几个周期可能不太稳定这是仿真引擎的初始化过程导致的。所以代码里第一次捕获到的值最好丢弃从第二次开始计算。这个细节在后面讲代码的时候会具体说。如果你手头没有PULSE源也可以用Proteus自带的DCLOCK元件它产生的是方波频率直接设置就行更简单一些。但DCLOCK的占空比固定是50%没法调测频率倒是够用了。3.3 仿真速度与实时性的取舍Proteus仿真是软件模拟不是实时系统。当你把STM32的时钟设成72MHz的时候仿真速度会明显变慢。如果再加上定时器中断频繁触发整个仿真可能跑得像幻灯片一样。我的经验是在Proteus里做功能验证的时候可以把STM32的系统时钟适当降低比如用8MHz的内部时钟或者把外部晶振设成8MHz然后通过PLL倍频到72MHz。但如果你发现仿真太卡可以暂时把时钟降到16MHz或者24MHz先验证逻辑对不对逻辑通了再改回72MHz做最终测试。定时器的预分频系数也可以根据实际情况调整。如果测的是1kHz的PWM定时器计数频率1MHz就够了PSC设成7172MHz/721MHz。如果测的是100kHz的PWM那计数频率至少要1MHz以上才能保证精度这时候PSC就要设小一点。4. 定时器与输入捕获的代码实现细节4.1 定时器初始化的完整流程先看产生PWM的那个定时器我用TIM3来生成测试信号。TIM3挂在APB1上时钟72MHz。配置成PWM输出模式通道1输出频率1kHz占空比50%。void TIM3_PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period 999; TIM_TimeBaseStructure.TIM_Prescaler 71; TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse 500; TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM3, TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM3, ENABLE); TIM_Cmd(TIM3, ENABLE); }这里TIM3的计数频率是72MHz/(711)1MHz周期设999所以PWM频率是1MHz/10001kHz。比较值设500占空比50%。接下来是TIM2的输入捕获初始化用PA0作为捕获输入void TIM2_Capture_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_ICInitTypeDef TIM_ICInitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPD; GPIO_Init(GPIOA, GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period 65535; TIM_TimeBaseStructure.TIM_Prescaler 71; TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); TIM_ICInitStructure.TIM_Channel TIM_Channel_1; TIM_ICInitStructure.TIM_ICPolarity TIM_ICPolarity_Rising; TIM_ICInitStructure.TIM_ICSelection TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter 0x0; TIM_ICInit(TIM2, TIM_ICInitStructure); TIM_ITConfig(TIM2, TIM_IT_CC1 | TIM_IT_Update, ENABLE); NVIC_InitStructure.NVIC_IRQChannel TIM2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); TIM_Cmd(TIM2, ENABLE); }TIM2的PSC也设成71计数频率1MHzARR设成最大值65535这样在溢出之前能计到65.535ms对应最低可测频率约15Hz。对于1kHz的PWM来说绰绰有余。4.2 中断服务函数里的计算逻辑中断服务函数是整个测量逻辑的核心。每次捕获中断触发读取CCR1的值和上一次的值做差算出周期计数值再换算成频率。volatile uint32_t capture1 0; volatile uint32_t capture2 0; volatile uint8_t capture_flag 0; volatile uint32_t overflow_count 0; volatile uint32_t pwm_freq 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { overflow_count; TIM_ClearITPendingBit(TIM2, TIM_IT_Update); } if (TIM_GetITStatus(TIM2, TIM_IT_CC1) ! RESET) { if (capture_flag 0) { capture1 TIM_GetCapture1(TIM2); overflow_count 0; capture_flag 1; } else if (capture_flag 1) { capture2 TIM_GetCapture1(TIM2); uint32_t total_count; if (capture2 capture1) { total_count overflow_count * 65536 capture2 - capture1; } else { total_count (overflow_count 1) * 65536 capture2 - capture1; } if (total_count 0) { pwm_freq 1000000 / total_count; } capture_flag 0; } TIM_ClearITPendingBit(TIM2, TIM_IT_CC1); } }这段代码里有个细节值得说一下capture_flag的状态机设计。第一次捕获只记录值不计算第二次捕获才做差值运算。这样做的原因是第一次捕获的时候你并不知道上一个上升沿是什么时候来的只有连续两次捕获才能构成一个完整周期。另外overflow_count在第一次捕获时被清零这是为了确保溢出计数和当前捕获周期对齐。如果不做这个清零溢出计数可能包含了上一次测量周期的溢出导致计算错误。4.3 主函数里的初始化顺序与验证主函数里初始化的顺序有讲究。我一般先初始化串口用于打印调试信息再初始化TIM3产生PWM最后初始化TIM2做捕获。这样在TIM2开始捕获的时候PWM信号已经稳定输出了。int main(void) { USART1_Init(115200); TIM3_PWM_Init(); TIM2_Capture_Init(); printf(PWM Frequency Measurement Start...\r\n); while (1) { printf(Freq: %d Hz\r\n, pwm_freq); Delay_ms(500); } }在Proteus里跑这个代码串口终端应该能看到稳定的频率输出。如果输出是0或者乱跳那就要检查信号源有没有正确接到PA0以及TIM2的捕获极性配置对不对。5. 仿真跑通之后才发现的几个坑5.1 第一次捕获值不可信的问题前面提到过Proteus的信号源在仿真刚开始的时候输出不稳定。我实测下来前几个周期的波形会有毛刺或者电平不对导致第一次捕获到的值偏差很大。解决办法是在代码里加一个“预热”逻辑前两次捕获只记录不计算从第三次开始才输出频率值。或者更简单粗暴一点在初始化完成之后延时100ms再开始捕获等信号源稳定了再干活。这个坑在真实硬件上一般不会遇到因为真实的信号发生器上电就是稳定的。但Proteus的仿真引擎需要几个周期来“进入状态”所以做仿真验证的时候要特别注意。5.2 输入滤波参数对测量结果的影响TIM_ICInitStructure里的TIM_ICFilter参数控制输入滤波器的采样频率和长度。设成0x0就是不滤波设成0xF就是最长滤波。在Proteus仿真里如果信号源很干净不滤波也没问题。但如果信号源有毛刺适当加一点滤波能显著提高测量稳定性。我一般会设成0x3或者0x7对应采样频率是定时器时钟的1/4或1/8连续采样几次都一致才认为信号有效。这个参数在真实硬件上对付按键抖动或者电机换相噪声的时候特别有用。但要注意滤波会引入额外的延迟。滤波长度越长捕获响应越慢。对于高频PWM信号滤波参数不能设太大否则可能把有效的边沿也滤掉了。5.3 中断优先级与实时性的平衡TIM2的捕获中断和溢出中断共用一个中断向量。如果捕获中断频繁触发溢出中断可能会被延迟处理导致溢出计数不准确。在测高频PWM的时候捕获中断触发很频繁这时候溢出中断的优先级就很重要。我一般把捕获中断的抢占优先级设得比溢出中断高确保捕获事件能及时响应。但这样又可能导致溢出中断被“饿死”所以溢出中断的处理要尽量简短只做计数加一和清标志位。在Proteus仿真里因为不是实时系统中断响应的时序和真实硬件有差异。有时候你会看到频率值偶尔跳一下这大概率是仿真引擎的中断调度导致的不一定是代码逻辑有问题。多跑几个周期看平均值如果平均值稳定就说明逻辑是对的。5.4 串口打印对测量结果的干扰用串口打印频率值的时候printf函数本身会占用不少CPU时间。如果打印太频繁可能会影响中断的响应导致捕获值出现偏差。我的做法是在中断里只做计算把结果存到一个全局变量里主循环里每隔500ms打印一次。这样中断的负担最小测量精度最高。如果你发现打印频率值的时候数值会跳变可以试试把打印间隔拉长或者把串口波特率提高减少打印占用的时间。6. 从仿真到实物的迁移经验6.1 引脚和时钟的差异Proteus里的STM32模型和真实芯片在引脚功能上基本一致但时钟树的表现有差异。仿真里你可以随便设72MHz但真实硬件上如果外部晶振没起振或者PLL配置不对系统时钟就跑不到72MHz。从仿真迁移到实物的时候第一件事是确认时钟配置。用MCO引脚输出时钟信号或者通过串口打印SystemCoreClock的值确认系统时钟确实是72MHz。如果时钟不对所有基于计数值的计算都会错。另外真实硬件上PA0引脚如果接了其他外设可能会影响输入捕获。比如PA0同时是WKUP引脚如果外部有上拉或者下拉电阻可能会改变信号的边沿特性。迁移的时候要检查一下引脚有没有冲突。6.2 信号质量的现实考验仿真里的PWM信号是理想方波上升沿和下降沿都是瞬间完成的。但真实电路里信号经过导线、接插件之后边沿会变缓还可能叠加噪声。如果测的是电机驱动器的PWM输出那信号上会有很大的开关噪声。这时候输入滤波器就派上用场了。我一般会把TIM_ICFilter设成0x7或者0xF同时在硬件上加一个RC低通滤波把高频噪声滤掉。还有一个问题是电平匹配。如果PWM信号的幅值不是3.3V比如是5V或者12V那就需要电平转换电路。直接接到STM32的引脚上可能会损坏芯片。我在实际项目里用过光耦隔离和电阻分压两种方案光耦隔离更可靠但成本高电阻分压简单但要注意分压后的信号边沿会变缓。6.3 测量范围的扩展思路用16位定时器测频率测量范围受限于计数器的位数和计数频率。1MHz计数频率下最低可测频率约15Hz65535个计数对应65.535ms最高可测频率取决于你需要的精度。如果要求1%的精度那至少需要100个计数对应最高频率10kHz。如果需要测更低的频率可以增大预分频系数降低计数频率。比如PSC设成7199计数频率就是10kHz最低可测频率降到0.15Hz。但这样最高可测频率也相应降低。如果需要测更高的频率可以用定时器的外部时钟模式把PWM信号直接作为计数时钟用另一个定时器做门控。这种方法测量范围更宽但实现起来复杂一些。对于大多数应用场景输入捕获模式已经够用了。7. 代码调试中几个实用的排查手段7.1 用示波器确认信号源在Proteus里调试的时候第一件事是把虚拟示波器接到信号源输出上确认波形频率和幅值符合预期。我遇到过好几次信号源参数设错了比如周期设成了1秒而不是1毫秒结果代码怎么调都测不到值。示波器的另一个用处是观察信号的边沿质量。如果边沿有明显的振铃或者过冲那输入捕获可能会在边沿附近多次触发导致捕获值不稳定。这时候要么调整信号源参数要么在代码里加滤波。7.2 在中断里翻转IO口做标记如果串口打印不方便可以在捕获中断里翻转一个空闲的IO口然后用示波器观察这个IO口的波形。每次捕获中断触发IO口翻转一次这样就能直观地看到中断有没有正常触发以及触发的频率对不对。这个方法在排查“中断没进”或者“中断进得太频繁”的问题时特别有效。我在调试的时候经常用比看串口打印快得多。7.3 分步验证先测固定频率再测变化频率调试的时候不要一上来就测变化的PWM信号。先用固定频率的信号源比如1kHz把整个链路跑通。确认频率值稳定输出之后再改信号源的频率看测量值能不能跟着变。如果固定频率都测不准那问题大概率在代码配置或者信号源设置上。如果固定频率测准了但变化频率测不准那可能是溢出处理或者中断优先级的问题。分步验证能帮你快速定位问题所在的环节。8. 关于这个方案的一些个人体会这套输入捕获测频率的方案我从仿真到实物用了大概两三年中间换过不同的STM32型号也试过用其他单片机实现类似功能。整体感觉是STM32的定时器输入捕获功能确实强大但配置项多细节也多不踩几个坑很难真正掌握。Proteus仿真的价值在于它能让你在没有硬件的情况下验证代码逻辑。但仿真终究是仿真信号质量、时钟精度、中断响应这些方面和真实硬件有差距。我的建议是仿真里跑通之后一定要在真实硬件上再验证一遍特别是时钟配置和引脚分配这两块。另外输入捕获这个功能在很多场景下都能用得上不只是测PWM频率。比如测超声波传感器的回波时间、测红外遥控的脉宽、测旋转编码器的脉冲间隔底层逻辑都是相通的。把这套东西吃透了后面遇到类似的需求就能直接迁移过去。代码里那个溢出处理的逻辑我建议不管测什么频率都加上。虽然对于高频信号来说溢出计数永远是零但加上之后代码的通用性更好换到低频场景也不用改。这种“多写几行代码换通用性”的做法在嵌入式开发里很划算。