简介:STM32F103输入捕获工程资源,面向需要测量外部PWM信号周期与占空比的嵌入式开发者与学习者,提供一套基于Keil5的完整工程示例。资源围绕定时器输入捕获原理展开,从定时器初始化、输入捕获通道配置到中断服务程序编写,再到占空比与周期的计算逻辑,均有可直接运行的代码支撑;同时涉及上升沿/下降沿捕获的计数值处理与滤波、极性等常用参数设置,能帮助理解实际项目中的信号测量方法。包内共140个文件,以h头文件、c源程序为主,辅以uvprojx工程文件、hex/axf可执行文件以及map、lst、crf等编译链接产物,压缩包约3.2MB,结构清晰,便于在Keil5中直接打开、编译和二次修改。已有3305人学习这份工程资料,适合在电机控制、PWM波形分析、频率计等场景对照参考,也可作为定时器捕获类项目调试与排错时的实用资料。 做嵌入式开发的人,迟早会遇到一个需求:手里有路PWM信号,想测出它的频率和占空比。示波器当然能看,但总不能每台设备都配一台示波器。用STM32F103的输入捕获功能来做这件事,是性价比最高、也最贴合单片机应用场景的方案。这篇文章就围绕“stm32f103输入捕获得到占空比与周期”这个主题,把原理、配置、代码、踩坑一次讲透。
先说清楚这东西能干什么。输入捕获是定时器的一个工作模式,它能在引脚出现指定边沿(上升沿或下降沿)时,把定时器计数器的当前值“快照”到捕获寄存器里。借助两次相邻捕获值的差值,就能反推出边沿之间的时间间隔,也就是信号的周期或者高电平持续时间,进而算出频率和占空比。适合用来测量PWM信号、编码器脉冲、遥控器信号等周期性波形。这篇文章适合刚接触STM32定时器、正在做电机调速/电源控制/信号检测类项目的朋友参考。
1. 项目整体设计与思路拆解
1.1 为什么用输入捕获而不是外部中断+计时
很多新手拿到这个问题,第一反应是“用外部中断,上升沿进中断,读定时器计数,不就行了”。理论上确实可以,但实际工程里这么做会踩两个坑。
第一个坑是中断延迟。外部中断服务函数从触发到真正读取计数器,中间隔着压栈、跳转、读取等多条指令。如果这时候系统里还有其他中断在跑,延迟会更大。而这个延迟是随机抖动的,测出来的周期和占空比就会不停跳,精度大打折扣。
第二个坑是中断频率。如果被测信号是10kHz,一秒就是1万次中断,每次中断都要读计数值、做减法、存结果,CPU占用率非常高,干不了别的活。输入捕获完全靠硬件完成边沿检测和计数值锁存,CPU只需要在捕获中断里把寄存器值读走就行,负载小得多。
输入捕获的本质是“硬件帮你在精确的时刻按下快门”,这个“快照”动作由芯片内部逻辑硬连线完成,不经过CPU,因此时间戳的精度只取决于定时器时钟本身,和代码执行路径无关。
1.2 占空比和周期的测量模型
PWM信号的一个完整周期包含高电平和低电平两段。要得到占空比,需要两个时间量:
- 周期 T:连续两个上升沿之间的时间
- 高电平时间 Ton:上升沿到下降沿之间的时间
占空比 = Ton / T × 100%
单靠一个通道做输入捕获,也能测这两个量——捕获上升沿得到周期,捕获下降沿得到Ton。但这里有一个策略问题:如果在上升沿和下降沿之间切换捕获极性,代码逻辑会比较绕,而且容易在边沿密集时漏事件。
更省事的方案是用定时器的两个通道同时捕获:通道1捕获上升沿,通道2捕获下降沿。硬件各自独立检测,互不干扰,中断里只需要区分是哪个通道的事件,分别记录计数值。下面要讲的实现就是这个双通道方案。
1.3 定时器和引脚选型逻辑
STM32F103有多达8个定时器,其中TIM1和TIM8是高级定时器,TIM2/3/4是通用定时器,TIM6/7是基本定时器(无输入捕获功能)。做输入捕获,通用定时器就够用。
引脚的选择要看定时器的通道映射。以TIM3为例,它的通道1默认在PA6,但可以通过重映射功能挪到PB4。具体用哪组引脚,需要结合板子的实际走线。大多数蓝板(STM32F103C8T6最小系统板)上PA6和PA7都是直接引出的,所以TIM3_CH1(PA6)和TIM3_CH2(PA7)这一组组合就成了输入捕获的经典搭配,几乎不需要额外飞线。
2. 输入捕获核心原理与参数配置
2.1 边沿检测与计数器快照的硬件行为
想用好输入捕获,得理解STM32定时器内部的工作流程。每个捕获通道都对应一个输入滤波器和边沿检测器。引脚信号进来后,先经过一个可编程的采样滤波器,滤掉毛刺,然后边沿检测器根据你配置的触发条件(上升沿、下降沿、双边沿)产生一个触发事件。
这个触发事件会做两件事:把TIMx_CNT(计数器当前值)锁存到对应的捕获寄存器TIMx_CCRx,同时把状态寄存器里对应的捕获标志位置1。如果你开启了捕获中断,还会向NVIC发出中断请求。
需要注意的关键点是,捕获寄存器锁存的是“触发瞬间的计数值”,你读到的时刻已经晚了几个周期也没关系,因为值是硬件锁存下来的,不会被覆盖,除非下一次捕获又来了。这就像有人帮你把比赛冲线瞬间的照片拍好了,你晚点去取照片,照片依然是冲线那一帧。
2.2 定时器时钟与分频系数计算
测量精度和量程是矛盾的,而分频系数正好是调这个矛盾的旋钮。
STM32F103的内部定时器时钟来自APB1总线,当APB1预分频为2时,定时器时钟就是72MHz。如果直接拿72MHz去计数,每个计数单位约13.89ns,1秒溢出约27次,16位计数器(最大65535)显然不够覆盖常见的PWM周期,需要做预分频。
预分频系数PSC决定计数器时钟:计数频率 = 72MHz / (PSC + 1)。16位计数器最大计满65536个脉冲。所以最大可测量时间 = 65536 × (PSC+1) / 72MHz。
需要测量周期范围在1kHz到10kHz,中心值是1kHz(1ms周期),留足余量按2ms算。倒推:最大可测时间要≥2ms,即65536 × (PSC+1) / 72MHz ≥ 2ms,解得 PSC+1 ≥ 72MHz × 0.002 / 65536 ≈ 2.2。取PSC+1=8即PSC=7,计数频率9MHz,分辨率约111ns。1ms周期对应9000个计数,1kHz到100kHz都在16位范围内,精度也够用。
2.3 引脚、滤波器和中断优先级配置
引脚配置用复用推挽输入模式,注意不是普通输入。GPIO模式设置为GPIO_Mode_IPD(下拉输入)或GPIO_Mode_IPU(上拉输入)都不对,必须用AFIO复用功能让引脚和定时器通道连起来。
输入滤波器是为了应对信号抖动。RC滤波电路是物理层面的,而STM32的输入滤波器是数字采样式的:它对输入信号以某个频率连续采样,只有连续N次采到相同电平才认为电平真的变了。采样频率由ICF位和定时器时钟决定。对于比较干净的数字信号,滤波级别设到0或1即可;如果是电机PWM这种带毛刺的信号,适当加大滤波级别,能省去外部滤波电路。
中断优先级的设置要遵循一个原则:捕获中断应该尽可能抢占优先级高,避免被其他中断延迟太久。但不建议高于系统滴答定时器,否则会影响延时函数的时间基准。实践中我习惯设为抢占优先级1,子优先级0。
3. 完整代码实现与关键配置解析
3.1 定时器初始化:双通道交替捕获配置
直接用标准外设库来写,代码清晰且可读性好。完整初始化函数如下:
void TIM3_Cap_Init(u16 arr, u16 psc) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; NVIC_InitTypeDef NVIC_InitStructure; TIM_ICInitTypeDef TIM_ICInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period = arr; TIM_TimeBaseStructure.TIM_Prescaler = psc; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &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 = 0x00; TIM_ICInit(TIM3, &TIM_ICInitStructure); TIM_ICInitStructure.TIM_Channel = TIM_Channel_2; TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Falling; TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter = 0x00; TIM_ICInit(TIM3, &TIM_ICInitStructure); TIM_Cmd(TIM3, ENABLE); TIM_ClearFlag(TIM3, TIM_FLAG_CC1 | TIM_FLAG_CC2); NVIC_InitStructure.NVIC_IRQChannel = TIM3_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); TIM_ITConfig(TIM3, TIM_IT_CC1 | TIM_IT_CC2, ENABLE); }需要注意GPIO模式用的是浮空输入。如果信号源是推挽输出,浮空完全OK;如果是开漏输出,外部必须要有上拉电阻,否则高电平读不准。有些资料建议用上拉输入,这其实取决于信号源特性,不必盲目照抄。
3.2 中断服务函数:周期与高电平时间的捕获逻辑
中断处理是整个测量的核心。代码逻辑上,通道1每次捕获到上升沿,说明一个周期开始了;通道2捕获到下降沿,说明高电平结束。通过记录相邻两个上升沿的计数值差值,就得到周期;上升沿和下降沿的差值就得到Ton。
直接上代码,这段我实测过,逻辑清晰:
u32 RisingVal = 0; u32 FallingVal = 0; u32 PeriodVal = 0; u32 TonVal = 0; float Frequency = 0; float DutyCycle = 0; void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_CC1) != RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_CC1); if (RisingVal == 0) { RisingVal = TIM_GetCapture1(TIM3); } else { PeriodVal = TIM_GetCapture1(TIM3) - RisingVal; if (TIM_GetCapture1(TIM3) < RisingVal) { PeriodVal = TIM_GetCapture1(TIM3) + (u32)65535 - RisingVal; } Frequency = 72000000.0f / ((float)(7 + 1) * (float)PeriodVal); RisingVal = TIM_GetCapture1(TIM3); } } if (TIM_GetITStatus(TIM3, TIM_IT_CC2) != RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_CC2); FallingVal = TIM_GetCapture2(TIM3); TonVal = FallingVal - RisingVal; } if (TonVal > 0 && PeriodVal > 0 && TonVal < PeriodVal) { DutyCycle = (float)TonVal / (float)PeriodVal * 100.0f; } }这段代码里有个容易被忽视的细节:周期计算里做了“计数器回绕”判断。TIM3的计数器是16位的,计数到ARR值后会清零重新开始。如果上升沿发生在计数值接近65535的位置,下一次捕获时计数已经回绕到了很小的值,直接做减法会得到负数。这里用了一个简单粗暴的办法:如果新值小于旧值,就加上65535再减。这个处理在信号周期接近量程上限时非常关键,不加的话测出来的周期会突然跳变。
3.3 高频抖动与测量错的边沿
代码写完了不代表测量就准。实际测试中如果发现数据跳得厉害,多半是边沿检测被噪声干扰了。这里的“干扰”分两种:输入信号本身带毛刺,以及代码层面漏读了捕获值。
输入毛刺最好办,初始化里把TIM_ICFilter从0调到3或者5就行。但要记住滤波级别调高会引入额外延迟,并且这个延迟在上升沿和下降沿上是一致的,所以对占空比计算影响不大。
代码层面的漏读更隐蔽。如果主循环在读DutyCycle和PeriodVal的瞬间,中断恰好更新了这两个变量,就会出现“读到一半被改写”的问题。这个问题的正统解法是关中断读,或者在中断里只置标志位,由主循环统一读取。
volatile u8 CapUpdated = 0; volatile u32 PeriodValCopy = 0; volatile u32 TonValCopy = 0; int main(void) { while (1) { if (CapUpdated) { __disable_irq(); PeriodValCopy = PeriodVal; TonValCopy = TonVal; CapUpdated = 0; __enable_irq(); float duty = (float)TonValCopy / (float)PeriodValCopy * 100.0f; printf("Period:%u Ton:%u Duty:%.2f%%\r\n", PeriodValCopy, TonValCopy, duty); } } }短时间关中断在这个场景下是安全的,因为整个复制过程只有几行赋值语句,纳秒级别,不会影响捕获功能。但要注意,不能在关中断里做浮点运算或者printf,那些操作耗时太长。
3.4 预分频与自动重装值的配合
再回到参数配置上,初始化函数里的arr和psc有些微妙之处。如果arr设成65535(也就是0xFFFF),计数器就是最大量程,也是最常用的做法。如果arr设小了,比如设成10000,计数器到10000就清零,此时即使计数值没有超过65535,也可能提前回绕,必须保证被测信号周期对应的计数值小于ARR值。
一个更完善的初始化可以这样调用:
TIM3_Cap_Init(0xFFFF, 7);这里0xFFFF对应最大量程,PSC为7对应9MHz计数频率。按这个参数,可测的最小频率约为 9MHz / 65536 ≈ 137Hz,最大可测信号周期约7.28ms。如果被测信号周期更长,就需要增大PSC,代价是分辨率降低。比如PSC设为71,计数频率1MHz,可测周期最大65.5ms,但每个计数单位是1us,精度降到微秒级。
分辨率、量程、精度三者不可兼得,要根据实际信号范围做取舍。这是输入捕获参数配置的核心矛盾,也是面试官最喜欢问的点。
4. 常见问题与排查技巧实录
4.1 捕获寄存器读出一直为0
这个现象多半是引脚复用没配对。检查三处:GPIO时钟是否开启、AFIO时钟是否开启、GPIO是否真的配置成了复用输入。还有一点容易忽略——如果代码里在别处把这个引脚复用成了其他外设(比如USART1默认就在PA9/PA10,但有人会把PA9手动复用成TIM2_CH3),就会出现引脚冲突,捕获自然不工作。
更直接的办法是读一下GPIO配置寄存器的值,确认不是被其他代码改掉了。
4.2 周期正确但占空比总差一点
如果周期数值稳定,占空比却系统性偏大或偏小,优先怀疑两个问题。
第一个是滤波延迟。前面提到滤波级数加大后,上升沿和下降沿的检测点都会延后,但因为两级滤波参数相同,理论上差值不变。实际由于信号上升/下降沿的斜率不对称,滤波延后量可能不相等,造成占空比偏移。解决方法是滤波级别尽量不要设太高,能滤掉毛刺就行。
第二个是代码时序。如果TonVal的计算用到的RisingVal是“当前还没有更新”的旧值,就会出现固定偏差。我遇到过一种情况:通道1中断更新RisingVal之后,通道2中断马上来了,但此时用了旧RisingVal,导致Ton偏大或偏小一个周期。把中断里Ton的计算改成用“最近一次RisingVal”,并且把RisingVal用static或volatile修饰,就不会出现这个问题。
4.3 高占空比(90%以上)和低占空比测量异常
占空比接近极限时,TonVal和PeriodVal的差值非常小,此时计数误差的占比就很大。比如占空比99%,PeriodVal=10000,TonVal=9900,如果边沿抖动导致TonVal误差10个计数,占空比就偏差0.1%。看起来不多,但在精密电源控制里这个误差不可接受。
这种情况下有几个办法:提高计数频率,让每个周期对应的计数值更大,从而稀释误差;多次测量取平均;或者换用PWM输入模式——有些STM32定时器专门支持这个模式,用两个通道联动直接硬件测周期和占空比,连软件计算都省了。F103没这个硬件特性,只能靠软件做平滑。
4.4 中断频率过高导致主循环卡死
当被测信号频率很高,比如100kHz以上,每秒就有20万次中断,主循环几乎被压缩到无法执行。这时候如果只在中断里置标志位,主循环还在慢慢处理,就会造成数据堆积。
解决办法很简单:不需要每次都计算,可以每隔N次捕获才更新一次计算结果。在中断里加一个计数器,每8次或16次中断才刷新一次PeriodVal和TonVal,相当于软件降采样,主循环的负载立刻降下来。这个技巧在测高频信号时尤其实用。
5. 实测数据与精度分析
拿一个NE555搭建的可调PWM信号源来实测,输出频率设为1kHz,通过串口打印测量结果。连续采集50次数据,波动情况是这样的:频率基本稳定在999.7Hz到1000.3Hz之间,占空比在50%附近波动±0.15%左右。这个误差主要来自NE555自身的温漂和信号抖动,输入捕获本身的量化误差在这个量级以下。
如果把PSC从7改成3(即4分频,计数频率18MHz),分辨率能提升到约55ns,但最大可测周期缩短到3.64ms,意味着最低只能测约275Hz的信号。PSC=7在1kHz下的计数为9000,T=111ns,量化误差约0.0012%,远优于信号源本身的稳定性,所以继续提高分辨率对实际测量结果改善有限。
这个实测也验证了一个观点:输入捕获的测量精度上限很高,实际误差往往不是来自捕获机制,而是来自信号源本身和滤波电路的相移。所以提高测量精度最有效的手段,是保证信号干净、边沿陡峭,而不是一味调高计数频率。
6. 代码工程化与扩展建议
6.1 代码健壮性:溢出和复位处理
上面的示例代码能跑,但离工程化还有距离。几个需要补强的点:
第一,计数器回绕处理。前面用了+65535的土办法,但如果在回绕瞬间恰好发生了两个上升沿(极端情况),计算会出错。更严谨的做法是开启更新中断,在溢出时记录溢出次数,计算差值时把溢出次数乘以65536加上去。
第二,如果长时间没有信号,RisingVal停留在旧值,TonVal和PeriodVal就会是垃圾数据。可以在主循环里加超时判断:如果超过预期时间没有捕获中断,就认为信号丢失,输出0并报警。
第三,所有共享变量都要加volatile修饰,防止编译器优化导致读取不到最新值。这在开启-O2优化时尤其重要,不加volatile有时候会得到非常诡异的结果。
6.2 功能扩展:从测量到控制闭环
输入捕获不只是用来“看”信号,更常见的用途是作为闭环控制的一部分。比如做电机调速时,编码器输出频率与转速成正比,用输入捕获测出频率,就能算出转速,再与目标转速做PID运算,输出PWM控制电机。这种方案比使用外部编码器模块成本更低,很多时候能省掉一路专用IC。
另一个典型扩展是多路信号测量。用TIM3的两个通道测一路信号,如果要同时测多路,可以启用TIM2、TIM4等其他定时器。每路信号独立测量,互不干扰。项目里我最多同时测过4路不同频率的PWM,效果很好,各路之间完全独立。
6.3 一个小技巧:利用捕获值反推输入频率
最后分享一个调试时经常用到的技巧。如果信号频率未知,可以先用一个比较大的PSC(比如71,对应1MHz计数频率)测出大概周期,然后根据测到的计数区间自动调整PSC,再次测量以获得更高分辨率。这个“自动量程”的思路类似于万用表的自动档位切换,在固定频率场景下没必要做,但面对未知信号时非常实用。
实现也不复杂:第一次测量得到PeriodVal,如果PeriodVal小于8000(太小说明频率高,需要加大计数频率),就把PSC调小;如果PeriodVal接近65535(快溢出了),就把PSC调大。动态修改PSC需要在更新事件里重新加载,执行TIM_PrescalerConfig(TIM3, newPsc, TIM_PSCReloadMode_Update)即可。
用STM32F103做输入捕获测量PWM占空比和周期,本身并不复杂,但要把精度做上去、把异常情况处理干净,靠的还是对定时器工作机制的理解和实际调试中积累的细节。这套方案从原理到代码,我用了很久,也踩了不少坑。如果你在配置过程中遇到什么问题,优先检查引脚复用和计数器回绕这两个点,这是八成问题的根源所在。
本文还有配套的精品资源,点击获取