ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

STM32输入捕获原理与实战:从定时器到PWM频率测量

STM32输入捕获原理与实战:从定时器到PWM频率测量 1. 从“数数”到“测速”为什么你需要掌握输入捕获玩过STM32的朋友对定时器肯定不陌生。我们最常干的事就是让它“数数”然后到点触发个中断去翻转个LED灯或者执行一段代码这就是最基本的定时功能。但定时器这个外设远不止是个“闹钟”。它更像一个多才多艺的“时间侦探”不仅能告诉你时间过去了多久还能精确测量外部世界发生事件的“时间间隔”。这个“侦探”的核心技能之一就是输入捕获。想象一个场景你手里有个旋转的编码器或者一个超声波模块它们会输出一连串的脉冲信号。你关心的是什么是脉冲的宽度高电平持续了多久还是脉冲的周期两个上升沿之间隔了多久。这些信息直接对应着电机的转速、超声波测得的距离。靠软件去轮询GPIO的电平变化来测量精度差、占用CPU资源脉冲一快就抓瞎。这时候输入捕获功能就该登场了。它的工作原理本质上是一种“时间戳”机制。当指定的GPIO引脚上发生你预设的事件比如上升沿时定时器这个“侦探”会立刻“咔嚓”一下把当前计数器CNT的值“拍”下来存到一个专门的捕获/比较寄存器CCR里并且可以产生中断通知你。你只需要在中断里读出这个被“冻结”的时间戳。通过连续捕获两个事件比如两个上升沿的时间戳做一次减法再乘以计数器的时钟周期就能得到精确的事件间隔时间。整个过程由硬件自动完成精度高不占用CPU这才是嵌入式系统处理高频信号的正确姿势。对于刚接触STM32CubeMX和HAL库的小白来说定时器配置本身就有一定门槛而输入捕获又涉及GPIO、定时器模式、中断、DMA等多个模块的联动容易让人摸不着头脑。这篇教程我就以最常用的测量PWM方波频率和占空比为例带你用STM32CubeMX和HAL库一步步把输入捕获功能调通并说清楚每个配置项背后的“为什么”以及实际调试中那些容易踩的坑。2. 需求分析与CubeMX工程创建在打开CubeMX之前我们必须先明确目标。本例的目标是测量一个外部输入的PWM信号的频率和占空比。假设这个PWM信号由另一个MCU或信号发生器产生连接到我们的STM32。要实现这个目标我们需要一个定时器作为时基它的计数器自由运行提供时间基准。我们用它来“打点”。至少两个输入捕获通道一个通道例如CH1捕获上升沿另一个通道例如CH2捕获下降沿。这样我们就能得到一个周期内高电平开始和结束的时间点。中断机制每次捕获成功产生中断以便我们在中断服务函数中读取时间戳并处理。为什么需要两个通道因为要测占空比。如果只用一个通道捕获上升沿只能得到周期值本次上升沿和上次上升沿的时间差。要得到高电平宽度就必须知道同一个周期内上升沿和下降沿的时刻。用两个通道分别捕捉上升沿和下降沿是最直接的方法。当然有些高级定时器支持一个通道同时捕获上升和下降沿并记录到不同的CCR寄存器但对于通用定时器用两个通道是标准做法。基于以上分析我们选择STM32F103C8T6核心板蓝色小板上常用的定时器TIM2。它的CH1对应PA0CH2对应PA1方便接线。CubeMX配置步骤如下选择MCU与时钟树创建新工程选择你的MCU型号。首先配置系统时钟RCC选择外部高速时钟HSE。在时钟配置Clock Configuration标签页将系统时钟SYSCLK设置为72MHz对于F103系列是常见最高值。APB1总线时钟PCLK1会自动分频注意定时器TIM2挂在APB1上如果APB1预分频系数不为1定时器时钟会倍频。这里保持默认APB1为36MHz则TIM2时钟为72MHz。配置定时器TIM2在左侧外设列表找到TIM2激活时钟源为“Internal Clock”。先不在这里配置通道模式。切换到Parameter Settings标签页Prescaler (PSC - 预分频器)这是第一个关键点。计数器时钟CK_CNT TIMx_CLK / (PSC 1)。我们的TIMx_CLK是72MHz。如果直接用它计数1秒会计数7200万次对于测量常见几百Hz到几十KHz的PWM计数器会溢出的非常快而且时间戳差值会非常大容易计算溢出。我们需要降低计数频率。例如设置PSC71则CK_CNT 72MHz / (711) 1MHz。此时计数器每计一个数代表1微秒1/1MHz方便计算量程也合适。Counter Mode (计数模式)选择“Up”向上计数。这是最常用的模式。Counter Period (ARR - 自动重装载值)这是第二个关键点。ARR决定了计数器从0数到多少后溢出归零。对于输入捕获我们通常希望计数器在两次捕获事件之间不要溢出否则处理起来麻烦。因此ARR应设置得足够大。假设我们要测的最低频率是10Hz周期100ms在1MHz计数频率下100ms需要计数100,000次。所以ARR必须大于100000。我们可以直接设置为最大值0xFFFF65535但65535个计数只对应65.535ms测100ms的信号就会溢出。因此为了保险和更大的测量范围我们可以将ARR设置为0xFFFFFFFF对于32位定时器或者0xFFFF对于16位定时器如TIM2。对于F103的TIM2它是16位的最大就是65535。这就引出了一个重要权衡如果要测低频信号要么降低计数频率增大PSC要么在软件中处理计数器溢出。本例为简化假设信号频率较高15Hz我们使用ARR65535。auto-reload preload (自动重装载预装载)使能Enable。这可以确保ARR的修改在下次更新事件时才生效防止在运行中修改ARR导致意外。配置输入捕获通道回到Pinout Configuration视图在TIM2的配置中选择通道1CH1。模式选择“Input Capture direct mode”输入捕获直接模式。IC Selection输入选择通常为“Direct”直接表示信号直接连接到该通道。Channel ParametersICPolarity (输入捕获极性)对于CH1选择“Rising Edge”上升沿。这意味着当PA0引脚上出现上升沿时硬件会捕获当前CNT值。IC Selection保持默认“Direct”。IC Prescaler (输入分频器)选择“No division”不分频。这个分频是针对捕获事件的例如设为“Every 2 events”则每2个边沿才捕获一次。我们每次边沿都需要所以不分频。IC Filter (输入滤波器)这是一个非常重要的抗干扰设置。滤波器基于采样频率对输入信号进行数字滤波。例如设置Filter值为“6”表示以f_DTS的频率由时钟分频决定连续采样6次只有6次采样值一致才认为边沿有效。这能有效滤除毛刺。对于信号质量好、频率高的场景可以设为0或较小值对于环境噪声大的场景如电机驱动建议设置为4-8。这里我们先设为6。用同样的方法配置通道2CH2将其模式也设为“Input Capture direct mode”。关键区别在于CH2的ICPolarity要设置为“Falling Edge”下降沿。这样CH1抓上升沿CH2抓下降沿。使能中断在TIM2的配置中切换到NVIC Settings标签页。使能“TIM2 global interrupt”全局中断。这样当捕获事件发生时才能进入中断服务函数。生成代码在Project Manager中设置好工程名、路径、IDE如MDK-ARM V5。在Code Generator中选择“Copy only necessary library files”以减小工程体积。最后点击“GENERATE CODE”生成工程。注意ARR的设置是输入捕获的一个关键点。如果信号周期可能超过(ARR1) / CK_CNT时间计数器就会溢出。在中断服务函数中我们必须考虑溢出情况通过记录溢出次数来扩展时间测量范围。本例为简化先不考虑但在实际产品中必须处理。3. 代码编写与核心逻辑剖析CubeMX生成的代码搭建了硬件框架但核心的测量逻辑需要我们自己在stm32f1xx_it.c的中断服务函数和用户主循环或回调函数中实现。首先在main.c的私人用户代码区/* USER CODE BEGIN PV */定义几个全局变量用于在中断和主程序间传递数据/* USER CODE BEGIN PV */ volatile uint32_t uwIC1Value1 0, uwIC2Value1 0; // 分别存储CH1和CH2捕获到的时间戳 volatile uint32_t uwDiffCapture 0; // 高电平脉宽CH2下降沿 - CH1上升沿 volatile uint32_t uwPeriodCapture 0; // 信号周期本次CH1上升沿 - 上次CH1上升沿 volatile uint8_t uwIsCaptured 0; // 捕获完成标志一次上升沿一次下降沿算一次完整捕获 volatile uint32_t uwLastCapture 0; // 用于计算周期记录上一次CH1上升沿的时间戳 /* USER CODE END PV */变量使用volatile关键字修饰至关重要因为它们会在中断中被修改在主循环中被读取防止编译器进行错误的优化。接下来在main.c的/* USER CODE BEGIN 2 */区域启动定时器和输入捕获/* USER CODE BEGIN 2 */ // 启动TIM2的输入捕获通道HAL库会同时启动计数器 HAL_TIM_IC_Start_IT(htim2, TIM_CHANNEL_1); // 启动CH1捕获并使能捕获中断 HAL_TIM_IC_Start_IT(htim2, TIM_CHANNEL_2); // 启动CH2捕获并使能捕获中断 /* USER CODE END 2 */现在来到最核心的部分中断处理与计算逻辑。我们不需要直接修改stm32f1xx_it.c中的TIM2_IRQHandler函数HAL库为我们提供了更清晰的回调函数机制。我们在main.c或单独的文件中重写输入捕获回调函数HAL_TIM_IC_CaptureCallback。这个函数会在任何一个输入捕获通道发生捕获事件时被调用。因此我们必须在函数内部判断是哪个通道触发的中断。/* USER CODE BEGIN 4 */ /** * brief 输入捕获中断回调函数 * param htim: TIM句柄 * retval None */ void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { /* 确保是TIM2触发的中断 */ if (htim-Instance TIM2) { /* 通道1处理逻辑捕获上升沿 */ if (htim-Channel HAL_TIM_ACTIVE_CHANNEL_1) { // 读取当前捕获值即上升沿发生时CNT的值 uwIC1Value1 HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); // 计算信号周期本次上升沿时间戳 - 上次上升沿时间戳 // 注意处理计数器溢出这里假设两次捕获间隔内CNT未溢出ARR足够大 if (uwLastCapture ! 0) { // 因为计数器是向上计数直接相减即可。如果发生溢出需要加上ARR1 uwPeriodCapture (uwIC1Value1 uwLastCapture) ? (uwIC1Value1 - uwLastCapture) : ((0xFFFF - uwLastCapture) uwIC1Value1 1); } // 更新上一次上升沿时间戳 uwLastCapture uwIC1Value1; } /* 通道2处理逻辑捕获下降沿 */ else if (htim-Channel HAL_TIM_ACTIVE_CHANNEL_2) { // 读取当前捕获值即下降沿发生时CNT的值 uwIC2Value1 HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_2); // 计算高电平脉宽下降沿时间戳 - 同周期上升沿时间戳 // 同样需要处理时间先后和溢出问题。由于CH1先触发CH2后触发且间隔时间小于一个周期通常直接减。 // 更严谨的做法判断uwIC2Value1和uwIC1Value1的大小如果下降沿时间戳小于上升沿时间戳说明计数器溢出了一次。 if (uwIC2Value1 uwIC1Value1) { uwDiffCapture uwIC2Value1 - uwIC1Value1; } else { // 发生了一次溢出 uwDiffCapture (0xFFFF - uwIC1Value1) uwIC2Value1 1; } // 设置捕获完成标志通知主循环可以读取计算频率和占空比了 uwIsCaptured 1; } } } /* USER CODE END 4 */代码逻辑深度剖析通道判断HAL_TIM_ACTIVE_CHANNEL_1和HAL_TIM_ACTIVE_CHANNEL_2是HAL库定义的宏用于标识是哪个通道触发了中断。这是区分上升沿和下降沿事件的关键。读取捕获值HAL_TIM_ReadCapturedValue()函数会读取对应通道的CCR寄存器值。这个值在硬件捕获事件发生时被自动锁存读取后不会清除。周期计算在CH1的中断里计算周期。原理是存储“上一次”上升沿的时间戳uwLastCapture当“这一次”上升沿发生时用新的时间戳减去旧的时间戳就得到了一个周期内计数器走过的计数。这里隐含了一个假设两次上升沿之间计数器没有发生溢出归零。如果ARR65535要测的周期对应计数小于65535这个假设成立。否则减法结果会是负数实际上由于是无符号数会变成一个很大的正数导致计算错误。因此代码中用了三元运算符进行简单的溢出判断和补偿如果新值大于等于旧值说明没溢出直接减否则说明计数器从旧值走到65535再归零走到新值需要分段计算。脉宽计算在CH2的中断里计算高电平脉宽。原理是用下降沿的时间戳减去同周期内上升沿的时间戳。这里同样存在溢出风险因为从上升沿到下降沿的时间可能超过ARR。代码中也做了类似的判断和处理。标志位在完成一次完整的“上升沿-下降沿”捕获后设置uwIsCaptured 1。主循环通过轮询这个标志位知道有一组新的数据周期和脉宽已经准备好可以用于计算频率和占空比了。最后在主循环while (1)中我们添加数据处理和打印逻辑假设已初始化串口/* USER CODE BEGIN WHILE */ while (1) { /* 如果一次完整的捕获上升沿下降沿完成 */ if (uwIsCaptured 1) { // 清除标志准备下一次捕获 uwIsCaptured 0; // 计算频率和占空比 // 已知计数器时钟 CK_CNT 72MHz / (711) 1MHz 即每个计数 1us // 周期 T uwPeriodCapture * (1 / 1000000) 秒 float frequency 0.0, duty_cycle 0.0; if (uwPeriodCapture 0) // 防止除零错误 { // 频率 1 / 周期 frequency 1000000.0 / (float)uwPeriodCapture; // 单位 Hz // 占空比 高电平时间 / 周期 * 100% duty_cycle ((float)uwDiffCapture / (float)uwPeriodCapture) * 100.0; } // 通过串口打印结果这里使用printf需要重定向 printf(Period: %lu us, Pulse: %lu us, Freq: %.2f Hz, Duty: %.2f%%\r\n, uwPeriodCapture, uwDiffCapture, frequency, duty_cycle); // 可以添加其他处理如控制LED、更新显示等 } /* USER CODE END WHILE */ /* USER CODE BEGIN 3 */ } /* USER CODE END 3 */4. 精度提升、溢出处理与进阶话题上面的基础代码在信号频率适中、且一次捕获间隔内计数器不溢出时可以工作得很好。但要想做出鲁棒性强的产品必须考虑更多边界情况和优化手段。4.1 计数器溢出的彻底解决方案之前我们只在计算时做了简单判断但更严谨的做法是主动记录溢出次数。STM32的定时器在溢出即发生更新事件Update Event时也会产生中断。我们可以使能更新中断并在更新中断的回调函数中将一个全局的溢出计数器加1。步骤在CubeMX中TIM2的NVIC Settings里除了输入捕获中断还要使能“TIM2 update interrupt”更新中断。在用户变量区定义volatile uint32_t uwOverflowCount 0;重写更新中断回调函数void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { uwOverflowCount; // 溢出次数加1 } }修改捕获回调函数中的计算逻辑。此时一个时间戳的“完整值”应该是完整时间戳 uwOverflowCount * (ARR 1) 当前CCR值。在CH1捕获时记录下此时的溢出计数overflow_snap_rising uwOverflowCount和CCR值ccr_rising。在CH2捕获时记录下overflow_snap_falling uwOverflowCount和ccr_falling。计算高电平脉宽时需要将两个“完整时间戳”相减。这需要小心处理溢出计数可能存在的“进位”问题。一个通用的方法是将两次捕获时的溢出计数和CCR值合并成64位整数再计算。uint64_t timestamp_rising (uint64_t)overflow_snap_rising * (htim2.Init.Period 1) ccr_rising; uint64_t timestamp_falling (uint64_t)overflow_snap_falling * (htim2.Init.Period 1) ccr_falling; uwDiffCapture (uint32_t)(timestamp_falling - timestamp_rising); // 得到高电平计数值周期计算同理需要用本次上升沿的完整时间戳减去上次上升沿的完整时间戳。这种方法彻底解决了测量时间范围受限于ARR的问题理论上可以测量任意长的脉冲只要溢出计数器不溢出32位的溢出计数器可以支持非常长的时间。4.2 使用从模式实现硬件自动复位对于周期性的信号测量还有一个更优雅的硬件方案利用定时器的从模式。我们可以将定时器配置为“复位模式”并将CH1的输入信号作为触发源。配置方法在CubeMX中在TIM2的从模式控制Slave Mode Control部分Slave Mode Selection: 选择 “Reset Mode”。Trigger Source: 选择 “TI1FP1” 即通道1的滤波后信号。这意味着每当CH1检测到一个上升沿触发信号定时器的计数器CNT就会立即被硬件清零。这样做的好处周期测量简化计数器在每个信号周期开始时都被清零那么在CH1捕获中断里读到的CCR1值本质上就是0或者一个很小的值因为从触发到捕获有微小延迟。要测周期我们不需要记录上次的时间戳了只需要在CH2捕获下降沿后或者在下一个CH1上升沿到来前去读取当前自由运行的CNT值吗不对。更精妙的用法结合从模式我们可以这样测量CH1上升沿触发计数器复位。CH2捕获下降沿此时CCR2的值就是从本周期开始到下降沿的时间即高电平脉宽。而信号的周期就是定时器的计数频率乘以从本次复位到下次复位的时间。但“下次复位”的时间点我们无法直接捕获因为计数器在那一刻被清零了。所以要测周期我们可以在CH1的捕获中断里去读取一个在自由运行的、不同时钟源的定时器比如SysTick的值或者使用下面介绍的双定时器方法。从模式虽然增加了硬件复杂度但消除了软件处理计数器溢出的麻烦特别适合测量固定频率或周期变化的信号。4.3 高频信号的测量与分频如果要测量频率非常高的信号比如几MHz1MHz的计数频率可能不够因为一个周期内只有几个计数误差会很大。这时我们需要提高计数频率即减小PSC的值。但PSC减小计数器溢出会更快对软件处理要求更高。另一种方法是使用定时器的输入分频器。之前我们设为了“No division”。你可以将其设置为“2 events”、“4 events”等。这意味着硬件会每2个或4个边沿才捕获一次。这样你捕获到的时间间隔实际上是N个信号周期的时间。在软件中将这个时间间隔除以N就得到了平均周期。这相当于一种硬件实现的频率分频测量法可以间接测量更高频率的信号但会损失实时性。4.4 使用DMA解放CPU在高速连续测量的场景下频繁进入中断每个边沿都进中断会消耗大量CPU资源。此时可以使用DMA将捕获到的CCR寄存器值自动搬运到内存中的一个数组中。配置思路在CubeMX中为TIM2的CH1和CH2配置DMA请求。设置DMA为循环模式目标地址是内存中的两个大数组。设置DMA的数据宽度为半字对应16位的CCR寄存器。使能定时器的捕获DMA请求。这样硬件会在每次捕获事件发生时自动将CCR值通过DMA存入数组完全不需要CPU干预。CPU只需要定期比如每收集1000个点去处理这个数组中的数据即可。这大大提高了系统效率并能实现非常高速的连续采样。不过DMA模式的配置和数据处理逻辑会更复杂一些需要仔细设计缓冲区管理和防止数据覆盖的机制。5. 实测调试与常见问题排查代码写好了下载到板子里接上信号发生器打开串口助手可能看到的不是规整的数据而是一堆乱码或者明显错误的值。别慌这是嵌入式开发的常态。下面是我在调试输入捕获功能时总结的几个常见坑点和排查思路。问题一完全没数据或者数据不动。检查1GPIO引脚配置。CubeMX生成的代码默认可能将PA0/PA1初始化为其他功能比如ADC。确保在main.c的MX_GPIO_Init函数里或之前没有代码改变这两个引脚的模式。最稳妥的方法是在CubeMX的Pinout视图除了在TIM2配置通道也确认一下PA0和PA1的图形化显示确实是“TIM2_CH1”和“TIM2_CH2”。检查2中断优先级与开关。确认HAL_TIM_IC_Start_IT被成功调用且没有因为其他地方关闭了全局中断而被阻塞。在调试初期可以在HAL_TIM_IC_CaptureCallback函数的最开始加一个翻转LED的语句看看中断是否真的进来了。检查3信号本身。用示波器或者逻辑分析仪看一下信号是否真的送到了MCU的引脚上电压电平是否符合要求STM32的IO通常是3.3V CMOS电平如果信号电压过高可能会损坏IO口过低可能无法触发边沿检测。问题二数据跳动很大不稳定。检查1滤波器设置。这是最常见的原因。如果输入信号有毛刺没有滤波器的边沿检测会非常敏感导致误触发。尝试在CubeMX中增大IC Filter的值比如从0调到6或8。滤波器值并不是越大越好过大的滤波器会延迟捕获时间并可能滤掉真正的高频信号。需要根据信号的实际质量权衡。检查2地线连接。确保信号源和STM32板子共地。不共地会导致参考电平不一致边沿检测不准。检查3定时器时钟。确认你的系统时钟和定时器时钟配置是否正确。如果PSC计算有误导致实际计数频率和预期不符计算出的时间自然不对。可以在代码中打印出htim2.Instance-PSC和htim2.Instance-ARR的值来核对。检查4中断处理时间。如果信号频率很高中断服务函数执行时间过长可能导致丢失下一次捕获中断。优化中断服务函数只做最必要的读取和标记操作把复杂的计算如浮点运算放到主循环中。可以使用__HAL_TIM_GET_FLAG和__HAL_TIM_CLEAR_FLAG来手动检查和处理中断标志而不是完全依赖HAL回调以获得更精确的时序控制。问题三测得的频率或占空比总是有固定偏差。检查1计算中的整数溢出。这是新手极易犯错的地方。在计算频率1000000.0 / uwPeriodCapture时如果uwPeriodCapture是整数在做除法前必须先将其转换为浮点数否则C语言会进行整数除法丢失所有小数部分导致巨大误差。我们的示例代码中使用了(float)强制转换是正确的。检查2时间基准误差。我们的计算基于一个假设定时器计数时钟精确等于1MHz。如果系统时钟源如外部晶振本身有偏差或者PSC设置导致计数频率不是整数都会引入系统误差。对于精度要求高的场合需要使用高精度晶振并校准时钟。检查3软件开销。从边沿触发到进入中断函数读取CCR值存在一段微小的、不固定的延迟中断响应时间。对于非常高频的信号100KHz这个延迟带来的误差比例就不可忽略了。此时硬件从模式或DMA方式是更好的选择。一个实用的调试技巧使用PWM输出功能自检。STM32的同一个定时器往往既能输出PWM也能输入捕获。我们可以先用代码配置TIM2的CH1输出一个已知频率和占空比的PWM比如1KHz 50%占空比然后用杜邦线将这个引脚PA0直接连接到另一个配置为输入捕获的引脚比如PA1。这样我们就有了一个完美的、同步的测试信号源。通过测量这个“自产自销”的信号可以快速验证你的输入捕获代码逻辑是否正确计算是否有误。如果测出来的结果和设置的一致说明代码基本没问题如果不一致就可以集中精力排查计算逻辑和定时器配置。
返回列表