STM32F103脉冲捕获原理与工程实践:从溢出补偿到DMA采集

STM32F103脉冲捕获原理与工程实践:从溢出补偿到DMA采集 简介STM32F103脉冲捕获工程包面向嵌入式开发者和STM32学习者演示如何利用定时器输入捕获功能测量外部脉冲频率、周期与占空比。压缩包共274个文件约6.91MB以C源码39个.c和头文件40个.h为核心配合Keil工程文件.uvproj、链接脚本.sct以及hex、axf等烧录调试产物并包含map、lst等编译过程文件便于直接打开工程查看、修改与编译。已有995人学习下载。工程覆盖TIM1TIM5定时器选择、时钟分频、输入捕获通道配置、输入滤波器设置、捕获中断及中断服务函数编写等关键环节并提供标准外设库相关驱动可帮助理解STM32F103的脉冲捕获机制。通过阅读源码和实际编译调试开发者可以理清捕获时间戳、计算脉冲周期、频率和占空比的具体方法。对于工业控制、电机驱动、通信系统等需要精确测量信号参数的开发者该实例能快速上手并方便移植到实际项目中。1. STM32F103 脉冲捕获先跳过例程里那个“测频率”的坑很多人拿到 stm32f103.zip 里的脉冲捕获例程第一反应就是打开中断回调把捕获值打印出来。但真正把 STM32F103 的脉冲捕获用在转速测量、PWM 解调或者脉冲计数上时最先遇到的不是库函数不熟而是两个连工程师也常忽略的边界捕获到的到底是一个边沿的时间戳还是一个完整的周期定时器计数器的溢出补偿做了没有如果只会在回调里做减法换到 72MHz 主频、测量 100Hz 信号时误差能到几十微秒。这篇文章先讲清楚捕获的工作模型再给出可以直接改着用的初始化代码、参数表和排错方法适合正在调Stm32f103 capture相关项目的开发者。2. 定时器捕获原理与计数溢出STM32F103 捕获值要这样折算2.1 捕获通道不是“测脉宽”是“记录边沿时刻”STM32F103 的输入捕获本质上不是测量高电平或低电平持续了多久而是把定时器计数器 CNT 在特定边沿跳变那一瞬间的值锁存到捕获/比较寄存器 CCRx。脉冲捕获里的“捕获”意思是拿到一个时间戳。之后想测频率就用两个相邻上升沿的 CCR 差值作为周期想测脉宽就要用上升沿到下降沿的差值。很多人在调Stm32f103 capture例程时误以为捕获成功后 CCR 直接是脉宽把捕获值当成时间长度去用这属于概念错位。更关键的是分辨率和量程由定时器输入时钟决定。F103 的 APB1 定时器时钟最高为 72MHz但给定时器预分频器 PSC 设置分频系数后计数器单步时间变为(PSC1)/72MHz。比如 PSC71计数器每 1 微秒加一能测的周期范围是 1 微秒到 65535 微秒前提是 ARR 设为 0xFFFF。PSC 越大低频信号越不容易溢出但高频信号量化误差变大。例程里常把 PSC 写成 71 或 0要贴合实际信号频段。在写代码之前我一般先用信号发生器扫一遍输入频率范围算清理论最大计数间隔再定 PSC 和 ARR。如果完全不知道频率做法是先用大预分频粗测再用小预分频精测这样能兼顾溢出与分辨率。实际调三相电机编码器时转速从 1Hz 到 1kHz 都有单靠一组 PSC 很难覆盖全程通常先确定最低转速必须在量程内再尽量减小 PSC。2.2 计数器溢出的折算方法捕获值本身是 CNT 的瞬时快照而 CNT 会从 0 增加到 ARR再回绕到 0。如果相邻两次捕获之间发生过溢出直接用 CCR2 - CCR1 会得到负数或一个很小的正数。正确的周期计数应该是uint16_t ccr1, ccr2; uint32_t period_cnt; uint8_t overflow_cnt 0; /* 假设已经通过中断读取到两个捕获值 */ ccr1 TIM2-CCR1; ccr2 TIM2-CCR1; if (ccr2 ccr1) { period_cnt ccr2 - ccr1; } else { /* 计数器发生了一次回绕补上一个 ARR 循环 */ period_cnt (uint32_t)ccr2 (0xFFFF - ccr1) 1; } /* 如果启用了更新中断并在其中累加则需要加上溢出次数 */ period_cnt (uint32_t)overflow_cnt * (0xFFFF 1);代码里的0xFFFF是 ARR65535 时的回绕边界。如果修改了 ARR这里要跟着改成 ARR。overflow_cnt必须用volatile修饰它在更新中断里累加在主循环或捕获中断里读取否则编译器可能把这个读取优化掉。另一种等价的写法是(uint32_t)(ccr2 - ccr1 0x10000) % 0x10000但在已经存在overflow_cnt的场景里显式分支更容易理解。这里最容易踩的坑是直接调用 HAL 库的__HAL_TIM_GET_COUNTER去读 CNT偶尔会读到跳变瞬间的中间值。因为 16 位计数器的回绕不是原子对齐的连续读取差值大于某个阈值时大概率夹带了旧值。F103 的捕获寄存器 CCR 由硬件在边沿到来时锁存所以读取捕获结果应当始终从 CCR 读而不是从 CNT 读。场景做法边界条件测周期相邻上升沿 CCR 差值差值可能为正或负需要取模或分支折算测脉宽上升沿与下降沿 CCR 差值两次捕获必须来自同一周期否则脉宽会漂低频测量更新中断累加溢出次数溢出中断与捕获中断的优先级要协调好2.3 捕获事件与更新事件的中断竞争当信号周期比较长时捕获中断和更新中断几乎同时到达。如果更新中断优先级低于捕获中断捕获中断处理过程中计数器再次发生溢出时会错过更新标志导致overflow_cnt偏小周期被算大。常用解法是让更新中断的抢占优先级高于捕获中断确保溢出计数先落到变量里或者在捕获中断里读取 CNT判断 CCR 与 CNT 的关系来推断是否刚发生过溢出。后一种做法的判断条件是在上升沿捕获发生后CNT 应该与 CCR 相差不大。如果 CNT 已经明显超过了 CCR说明捕获之前的那个时刻发生过溢出。这个办法省一个中断入口但多几条比较逻辑适合资源紧张时用。对于大多数 F103 项目直接开更新中断更省事毕竟更新中断频率就是信号周期远低于 72MHz 的处理能力。提示越靠近定时器硬件的场景越不要依赖绝对时间计数要把所有差值都统一折算成“计数器 ticks”最后再换算成秒或 Hz。3. 使用 STM32F103 的 HAL 库快速跑通单通道脉冲捕获3.1 初始化定时器与 GPIO 的完整配置在 STM32F103 上做脉冲捕获最常用的是 TIM2 或 TIM3 的通道 1/2。下面是一套基于 HAL 库的初始化代码可以直接替换到 CubeMX 生成的MX_TIM2_Init里在常见的 stm32f103 最小系统板上用 PA0 引出信号。static void MX_TIM2_Init(void) { TIM_IC_InitTypeDef sConfigIC {0}; GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_TIM2_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_AFIO_REMAP_TIM2_DISABLE(); /* 不重映射用 PA0 */ GPIO_InitStruct.Pin GPIO_PIN_0; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_NOPULL; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); htim2.Instance TIM2; htim2.Init.Prescaler 71; /* 72MHz/(711) 1MHz */ htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.Period 0xFFFF; /* 65535量程约65ms */ htim2.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; HAL_TIM_IC_Init(htim2); sConfigIC.ICPolarity TIM_INPUTCHANNELPOLARITY_RISING; sConfigIC.ICSelection TIM_ICSELECTION_DIRECTTI; sConfigIC.ICPrescaler TIM_ICPSC_DIV1; /* 每个边沿都捕获不分频 */ sConfigIC.ICFilter 0; /* 不滤波有噪声再调 */ HAL_TIM_IC_ConfigChannel(htim2, sConfigIC, TIM_CHANNEL_1); }这段代码里的参数直接决定测量结果。Prescaler71让计数器以 1MHz 运行每一步代表 1 微秒周期在 65535 微秒以内的信号都能在一次捕获差值里完成。Period0xFFFF对大多数电机转速和红外遥控解码足够。如果测的是 4MHz 晶振信号把 Prescaler 改成 0分辨率到 72MHz但最大可测周期变成约 0.91 毫秒。ICFilter是数字滤波器的采样周期个数设得太大反而会把窄脉冲滤掉测量占空比极小或边沿极快的 PWM 时建议从 0 开始确认稳定后再慢慢调大。3.2 在中断回调里计算频率和占空比HAL 库的捕获中断最终会进到HAL_TIM_IC_CaptureCallback。最常见的写法是记录第一次上升沿的 CCR第二次上升沿算周期再用下降沿算脉宽。先给一个只测频率的版本volatile uint32_t freq_hz; static uint16_t last_ccr; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { uint16_t ccr; uint32_t diff; if (htim-Instance ! TIM2) return; ccr htim-Instance-CCR1; diff (uint32_t)(ccr - last_ccr 0x10000) % 0x10000; if (diff ! 0) { freq_hz 1000000UL / diff; } last_ccr ccr; }diff的计算同样用了取模折算计数器回绕后也能得到正确的周期计数。1000000 是 1MHz 计数频率如果改了 Prescaler这里也要跟着改。这里有一个隐藏的边界如果两次捕获之间出现了一次以上溢出这个简化回调算出的diff会比真实周期小频率会偏大。所以它只适合周期小于 ARR 的场景。开始捕获前需要使能定时器和捕获中断__HAL_TIM_ENABLE(htim2); __HAL_TIM_ENABLE_IT(htim2, TIM_IT_CC1); HAL_TIM_IC_Start_IT(htim2, TIM_CHANNEL_1);可以说只要调用了HAL_TIM_IC_Start_IT前两行往往可以由 HAL 内部完成。需要注意重复调用Start_IT会清掉状态标志可能造成捕获事件丢失所以不要在中断里或者每次读取条件判断里反复启动。3.3 测占空比时采集“成对边沿”的思路如果要测占空比一个定时器的两个通道可以分别捕获上升沿和下降沿。配置第二个通道时把TIM_Input_Capture_Polarity设为TIM_INPUTCHANNELPOLARITY_FALLING然后在同一个回调里根据两个通道的 CCR 差值算出高电平时间。必须确认两个通道共用一个时基不然上升沿和下降沿对应的时间轴不一致。这里有一个经常出现的顺序问题第一个事件可能是上升沿也可能是下降沿取决于信号初始电平。处理时不要假设上升沿一定先在回调里出现最好在本地变量里记录上一次边沿极性再计算高电平时间。下面是简化的成对边沿处理框架static uint16_t last_timestamp; static uint8_t last_edge; /* 0 未知1 上升沿2 下降沿 */ void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { uint16_t ts; uint32_t high_ticks; if (htim-Instance TIM2) { if (__HAL_TIM_GET_IT_SOURCE(htim, TIM_IT_CC1) ! RESET) { ts htim-Instance-CCR1; if (last_edge 1) { high_ticks (uint32_t)(ts - last_timestamp 0x10000) % 0x10000; /* 这里得到高电平时间 */ } last_timestamp ts; last_edge 1; __HAL_TIM_CLEAR_IT(htim, TIM_IT_CC1); } } }代码里的__HAL_TIM_GET_IT_SOURCE用来区分这次回调来自哪个通道。如果两个通道都开中断推荐直接在回调里判断通道标志位而不是用htim-Channel判断因为 HAL 在某些版本里不会把Channel传到这个回调。high_ticks的单位同样是微秒与预分频配置保持一致。测量目标推荐通道组合注意事项频率CH1 上升沿直接取相邻 CCR 差值占空比CH1 上升沿 CH2 下降沿需要记录上一次边沿极性周期与脉宽同时输出CH1 CH2 独立中断两个通道必须共用一个 ARR4. 多路脉冲捕获、串口输出和系统联合调度的经验4.1 多路脉冲捕获的时基共享与通道差异需要同时测几路脉冲时很多人第一反应是多开几个定时器。F103 里定时器固然多但 TIM2/TIM3/TIM4 各有 4 个捕获通道如果只是测周期可以用同一个定时器的两个通道捕获两路信号两个通道共享 CNT时间戳天然同步这是多路测量里最推荐的方式。两路信号频率可以不同只要各自相邻边沿的差值不超过 ARR 范围。多路配置时特别要注意引脚映射。F103 的捕获输入来自 GPIO 的复用功能映射PA0、PA1、PA2 分别对应 TIM2_CH1、CH2、CH3但 PB3 也可以通过重映射变成 TIM2_CH2。于是出现了一个典型错误在 CubeMX 里 PA1 已经配置成输入捕获又想把 PB3 配成第二路以为__HAL_AFIO_REMAP_TIM2_PARTIAL_1能把 PA1 空出来。实际上这个重映射把 TIM2_CH2 的输入换到了 PB3同时 PA1 上的捕获功能被断开。所以多路通道分配时必须先查 AFIO 映射表再决定用哪几个引脚。下面是给 TIM2 两个通道做输入捕获的关键配置片段两路信号分别从 PA0 和 PA1 进入/* CH1 用于 A 路PA0 */ sConfigIC.ICPolarity TIM_INPUTCHANNELPOLARITY_RISING; sConfigIC.ICSelection TIM_ICSELECTION_DIRECTTI; sConfigIC.ICPrescaler TIM_ICPSC_DIV1; sConfigIC.ICFilter 1; HAL_TIM_IC_ConfigChannel(htim2, sConfigIC, TIM_CHANNEL_1); /* CH2 用于 B 路PA1 */ sConfigIC.ICSelection TIM_ICSELECTION_DIRECTTI; HAL_TIM_IC_ConfigChannel(htim2, sConfigIC, TIM_CHANNEL_2);重点在于ICSelection。DIRECTTI表示使用本通道对应的输入引脚如果希望 CH2 采集 CH1 的同一路信号则应该使用TIM_ICSELECTION_INDIRECTTI。两种用法对应完全不同的接线方式很多现场串信号的问题都出在这里。配置完成后两个通道会在同一个回调里触发但TIM_IT_CC1和TIM_IT_CC2是独立标志通过标志位来区分。4.2 串口1和串口3使用差异以及捕获中断优先级设置多路捕获后要把频率数据发到上位机串口选型本身就能引出差异。F103 的串口1挂在 APB2 总线串口3 挂在 APB1 总线。系统时钟 72MHz 时APB1 最高 36MHz但 APB1 上的定时器时钟会倍频到 72MHz。串口3 的波特率是从 APB1 时钟算的串口1 是从 APB2 算的所以在相同分频设置下两者实际波特率并不相同。高速打印时我一般优先选串口1它受 APB 预分频的影响更直接误差更小。串口总线时钟常见波特率上限适用场景USART1APB2 72MHz460800 可用高速打印、调试指令USART3APB1 36MHz230400 更稳低速日志、与模块通信中断优先级上捕获中断属于高速事件串口中断属于中速。如果把捕获中断优先级设得比串口高捕获处理期间串口中断被阻断简单打印没问题一旦串口接收也开着数据溢出位置起很容易发生。反过来设串口更高捕获中断会被少量延迟但 F103 的捕获事件由硬件锁存延迟几十微秒通常不影响结果。实际项目里我会把捕获中断设为抢占优先级 1、子优先级 0串口设为抢占优先级 2、子优先级 0更新中断放在抢占优先级 0避免溢出计数丢失。4.3 脉冲捕获中常见的共地与输入整形问题多路信号接入最小系统板时共地问题比想象中更容易被忽略。两路线性编码器如果来自不同电源系统VIL/VIH 参考地不一致可能出现一路正常一路偶发跳变。捕获值偶发差一两个 tick多半是信号边沿抖动差出大几百 tick就是地电位漂移或触点氧化。输入整形是另一个容易被遗漏的点。F103 的 GPIO 输入不是无限带宽脉冲超过几十兆时直接进 GPIO 可能采不到。电机编码器输出常带毛刺需要外部 RC 滤波或在内部配置 ICFilter。如果捕获值偶尔跳变先量一下输入波形上升沿是否在几十纳秒内确认不是缓慢上升导致的重复触发。很多调试脚本把问题归结为“捕获中断没进”实际是输入电平停在阈值附近同一个脉冲被捕获了两三次。这种情况下ICFilter 设成 17 比调中断优先级有效得多。5. 用 DMA 替代中断捕获并用逻辑分析仪验证捕获结果5.1 用 DMA 把连续捕获值搬进内存在中断回调里做减法适合通道少、频率不高的场景。脉冲频率高或者有多路通道时可以改用输入捕获的 DMA 功能。F103 的捕获事件会触发 DMA 请求把TIMx-CCRx寄存器直接搬运到内存数组CPU 不再每个边沿都进中断。启动捕获前需要配置一个 DMA 通道数据方向是从外设到内存外设地址固定为 CCR 寄存器地址内存地址是数组。DMA_HandleTypeDef hdma_tim2_ch1; static uint16_t ccr_buffer[64]; hdma_tim2_ch1.Instance DMA1_Channel5; /* TIM2_CH1 对应 */ hdma_tim2_ch1.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_tim2_ch1.Init.PeriphInc DMA_PINC_DISABLE; hdma_tim2_ch1.Init.MemInc DMA_MINC_ENABLE; hdma_tim2_ch1.Init.PeriphDataAlignment DMA_PDATAALIGN_HALFWORD; hdma_tim2_ch1.Init.MemDataAlignment DMA_MDATAALIGN_HALFWORD; hdma_tim2_ch1.Init.Mode DMA_CIRCULAR; HAL_DMA_Init(hdma_tim2_ch1); __HAL_LINKDMA(htim2, hdma[TIM_DMA_ID_CC1], hdma_tim2_ch1); HAL_TIM_IC_Start_DMA(htim2, TIM_CHANNEL_1, (uint32_t *)ccr_buffer, 64);代码里的 64 表示 DMA 传输 64 次后触发传输完成回调。环形模式下到达末尾会自动回到开头CPU 不需要每次都响应。由于 F103 的 DMA 地址和数据宽度都是 16 位使用uint16_t数组能避免字节组合错误。需要注意的是TIM2 通道1对应的 DMA 请求可能随重映射变化照抄其他 STM32 型号的映射表会拿不到数据。定时器在 CubeMX 里初始化后要确认 DMA 请求确实连接到了正确的 DMA1 通道。5.2 用逻辑分析仪验证 STM32F103 捕获结果最后的验证手段是把捕获算出的频率与外部信号发生器设定值做比对。我的做法是信号发生器分别设 10Hz、1kHz、100kHz 三档在串口上观察打印数据同时让逻辑分析仪测量 F103 引脚上的实际信号。如果捕获值只在最后一两个 LSB 跳动说明时钟源和 PSC 配置没问题如果整体偏移比如输入 100Hz 显示 99.8Hz通常是预分频系数和 APB 时钟没对应好。F103 定时器时钟的陷阱在于 APB1 预分频不为 1 时定时器时钟会是 APB1 的两倍。如果系统时钟 72MHzAPB1 预分频为 2则 APB136MHz但定时器时钟仍然是 72MHz。很多人在 CubeMX 里直接写 PSC71没有确认HAL_RCC_GetPCLK1Freq()的返回值就当成 1MHz。正确做法是在初始化后打印实际定时器输入时钟再反推 PSC。验证的另一个切入点是测量时间戳差分。让一个 GPIO 在收到两个相邻捕获时翻转一次用示波器测翻转间隔再和串口打印的频率值比对。这个办法不依赖逻辑分析仪软件适合现场没有上位机的情况。要注意的是从ccr_buffer里读到的连续差值必须经过和中断回调相同的取模折算不能直接相减否则 DMA 方式会复现同一个溢出坑。本文还有配套的精品资源点击获取