简介:STM32F103脉冲捕获工程包,面向嵌入式开发者和STM32学习者,演示如何利用定时器输入捕获功能测量外部脉冲频率、周期与占空比。压缩包共274个文件,约6.91MB,以C源码(39个.c)和头文件(40个.h)为核心,配合Keil工程文件(.uvproj)、链接脚本(.sct)以及hex、axf等烧录调试产物,并包含map、lst等编译过程文件,便于直接打开工程查看、修改与编译。已有995人学习下载。工程覆盖TIM1~TIM5定时器选择、时钟分频、输入捕获通道配置、输入滤波器设置、捕获中断及中断服务函数编写等关键环节,并提供标准外设库相关驱动,可帮助理解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 设置分频系数后,计数器单步时间变为(PSC+1)/72MHz。比如 PSC=71,计数器每 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是 ARR=65535 时的回绕边界。如果修改了 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/(71+1) = 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); }这段代码里的参数直接决定测量结果。Prescaler=71让计数器以 1MHz 运行,每一步代表 1 微秒,周期在 65535 微秒以内的信号都能在一次捕获差值里完成。Period=0xFFFF对大多数电机转速和红外遥控解码足够。如果测的是 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 独立中断 | 两个通道必须共用一个 ARR |
4. 多路脉冲捕获、串口输出和系统联合调度的经验
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 预分频的影响更直接,误差更小。
| 串口 | 总线时钟 | 常见波特率上限 | 适用场景 |
|---|---|---|---|
| USART1 | APB2 72MHz | 460800 可用 | 高速打印、调试指令 |
| USART3 | APB1 36MHz | 230400 更稳 | 低速日志、与模块通信 |
中断优先级上,捕获中断属于高速事件,串口中断属于中速。如果把捕获中断优先级设得比串口高,捕获处理期间串口中断被阻断,简单打印没问题;一旦串口接收也开着,数据溢出位置起很容易发生。反过来设串口更高,捕获中断会被少量延迟,但 F103 的捕获事件由硬件锁存,延迟几十微秒通常不影响结果。实际项目里我会把捕获中断设为抢占优先级 1、子优先级 0,串口设为抢占优先级 2、子优先级 0;更新中断放在抢占优先级 0,避免溢出计数丢失。
4.3 脉冲捕获中常见的共地与输入整形问题
多路信号接入最小系统板时,共地问题比想象中更容易被忽略。两路线性编码器如果来自不同电源系统,VIL/VIH 参考地不一致,可能出现一路正常一路偶发跳变。捕获值偶发差一两个 tick,多半是信号边沿抖动;差出大几百 tick,就是地电位漂移或触点氧化。
输入整形是另一个容易被遗漏的点。F103 的 GPIO 输入不是无限带宽,脉冲超过几十兆时直接进 GPIO 可能采不到。电机编码器输出常带毛刺,需要外部 RC 滤波或在内部配置 ICFilter。如果捕获值偶尔跳变,先量一下输入波形上升沿是否在几十纳秒内,确认不是缓慢上升导致的重复触发。很多调试脚本把问题归结为“捕获中断没进”,实际是输入电平停在阈值附近,同一个脉冲被捕获了两三次。这种情况下,ICFilter 设成 1~7 比调中断优先级有效得多。
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 的两倍。如果系统时钟 72MHz,APB1 预分频为 2,则 APB1=36MHz,但定时器时钟仍然是 72MHz。很多人在 CubeMX 里直接写 PSC=71,没有确认HAL_RCC_GetPCLK1Freq()的返回值就当成 1MHz。正确做法是在初始化后打印实际定时器输入时钟,再反推 PSC。
验证的另一个切入点是测量时间戳差分。让一个 GPIO 在收到两个相邻捕获时翻转一次,用示波器测翻转间隔,再和串口打印的频率值比对。这个办法不依赖逻辑分析仪软件,适合现场没有上位机的情况。要注意的是,从ccr_buffer里读到的连续差值必须经过和中断回调相同的取模折算,不能直接相减,否则 DMA 方式会复现同一个溢出坑。
本文还有配套的精品资源,点击获取