简介:一套面向STM32F407嵌入式开发者的外部脉冲计数与频率测量方案,基于ARM Cortex-M4芯片定时器输入捕获功能,解决转速测量、频率检测、脉冲累计等实际需求。内容围绕HAL库与STM32CubeMX配置展开,涵盖定时器选型、预分频器设置、输入滤波器配置、捕获通道初始化以及中断服务程序中的计数处理,配套工程源码可直接导入Keil查看运行。压缩包共689个文件,6.84MB,以367个C源文件、90个头文件为主体,另有uvproj/uvopt工程配置、s启动汇编、hex/axf编译结果及PDF说明文档,便于代码对照、固件烧录与调试验证。已有4130人学习下载,适合具备一定STM32基础、希望快速掌握输入捕获原理与工程实现的开发者。通过学习可动手复现脉冲计数逻辑,理解滤波抗干扰、原子操作保护等设计细节,并迁移到其他定时器及更多应用场景中。
1. 从捕获中断到计数不丢:用 STM32F407 做外部脉冲计数
很多做嵌入式开发的朋友第一次接触外部脉冲计数,都是因为在电机测速、流量计脉冲采集、编码器反馈这类场景里,需要把外部信号变成 CPU 能处理的数字量。用 STM32F407 来做这件事,本质是利用定时器的输入捕获或者外部时钟模式,把脉冲边沿转成计数值。这种方式相比用 GPIO 中断去数脉冲,优势在于不占用 CPU 负载,哪怕 10kHz 的脉冲,主循环也完全无感。但这套方案有几个容易翻车的细节:捕获通道怎么选、滤波参数怎么设、溢出怎么处理、32 位和 16 位计数器怎么接。这篇文章就把我从捕获中断到 DMA 缓冲的一整套做法拆开讲,参数表和代码直接可抄。
2. 定时器输入捕获:先搞清三种“数脉冲”的方式
2.1 为什么不用 GPIO 中断,而用定时器捕获
新手通常最先想到的办法是外部中断 EXTI,每个上升沿触发一次中断,在中断服务函数里给变量自增。这个方案在脉冲频率低的时候没问题,但频率一旦超过 5kHz,中断频繁进出带来的开销就很可观了,而且一旦中断响应不及时,丢脉冲是必然的。我见过有人在 20kHz 的流量计脉冲上硬用 EXTI,结果丢计数达到 5%,这在实际项目里是没法接受的。
STM32F407 的做法完全不同。定时器有专门的输入捕获通道,边沿信号到来时硬件自动把 CNT 寄存器值锁存到 CCRx 寄存器,同时置位中断标志。整个过程不需要 CPU 干预,计数也不会因为中断延迟而丢失。如果你只需要累加脉冲数量,可以用定时器的外部时钟模式 1 (External Clock Mode 1),把 TIx 引脚当作时钟源,CNT 直接在边沿驱动下自增,最大计数范围取决于 CNT 位宽。F407 的通用定时器如 TIM2、TIM3、TIM4、TIM5 是 32 位计数器,基本不用担心溢出,而 TIM1、TIM8 是 16 位计数器,需要注意翻转处理。
2.2 引脚复用表:哪些通道能接外部脉冲
先确认一件事:不是所有引脚都能随便接定时器的捕获通道。F407 的数据手册里有一个 AFIO 复用映射表,比如 TIM2 的通道 1 可以映射到 PA0、PA5、PA15,TIM3 的通道 1 可以映射到 PA6、PB4、PC6。选择引脚时主要看两件事:一是信号路径,尽量选在同一组 GPIO 上,方便布局;二是看该引脚是否被其他外设占用,比如 PA0 同时也是 ADC 的输入引脚,如果你同时要用 ADC 采集,就得避开。
我一般会直接打开参考手册的 GPIO 复用表,把计划要用的引脚和功能对照一遍。常见的组合如下:
- 选择 TIM2_CH1,引脚 PA0,上升沿捕获,32 位计数
- 选择 TIM3_CH1,引脚 PA6,上升沿捕获,16 位计数
- 选择 TIM4_CH2,引脚 PB7,编码器模式,AB 相同时计数
这说明底层的选择会影响后续的配置和中断处理方式,芯片型号、封装、引脚分配、定时器编号、捕获极性、计数位宽必须一起考虑。这里有个容易被忽略的注意点:某些定时器的捕获通道有内部连接,比如 TIM2 的通道 1 和通道 2 可以做正交编码器输入,但脉冲计数场景里你用不到这个功能,只要把 SMCR 寄存器清空即可。
2.3 外部时钟模式 vs 捕获模式:两种计数架构的取舍
这是我反复强调的一个点:如果只是数“有多少个脉冲”,直接配置外部时钟模式 1 就够了,让硬件在每次上升沿时对 CNT 加 1;如果需要同时“测量脉冲宽度”或者“记录脉冲到达的时间戳”,就必须用输入捕获模式。
外部时钟模式大致是这样运作的:TIMx_SMCR 寄存器的 SMS 位段置 0b111,选中外部时钟模式 1;TS 位段选择 TI1 或 TI2 作为触发源;CCMR1 寄存器把 IC1F 位段配置为滤波;CCER 寄存器设置极性,上升沿有效。之后时钟选择器会把外部信号当成时基,CNT 跟着它走,CCR 寄存器不参与。它的优点是:计数的溢出处理更简单,因为只要读 CNT 寄存器就行;缺点是:频率太高时,需要先确认输入信号的电气稳定度,否则毛刺会被当成脉冲数进去。
输入捕获模式则是把外部信号连接到捕获通道,每次边沿触发时 CNT 值被锁存到 CCRx。中断里需要自己读 CCRx 并累加差值。这么做的好处是可以根据相邻两个边沿的 CCRx 差值推算出频率或周期;缺点是代码逻辑更复杂,而且如果某个边沿在中断还没读完 CCRx 之前就到了,新的值会覆盖旧值,计数就错乱了。
因此我的建议是:如果目标只有单一边沿计数,直接选外部时钟模式 1,配合 DMA 传输 CNT 值到内存,连中断都不需要。如果目标是“测频率+计数”双功能,那就上捕获模式。
3. 寄存器配置实战:外部时钟模式下的定时器参数与中断服务写法
3.1 初始化流程:RCC、GPIO、TIM、NVIC
以下代码以 TIM2 外部时钟模式为例,使用 PA0 作为输入引脚,上升沿计数,32 位 CNT。工程基于 STM32CubeF4 的 HAL 库,但时钟初始化和 GPIO 配置可以直接对寄存器操作,这样更直观,也不会被 CubeMX 自动生成的代码绕晕。
void TIM2_ExtClock_Init(void) { // 1. 使能 TIM2 和 GPIOA 时钟 RCC->APB1ENR |= RCC_APB1ENR_TIM2EN; RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN; // 2. 配置 PA0 为复用功能,AF1 对应 TIM2 GPIOA->MODER &= ~GPIO_MODER_MODER0; GPIOA->MODER |= GPIO_MODER_MODER0_1; // 10: AF mode GPIOA->OSPEEDR |= GPIO_OSPEEDR_OSPEEDR0; // 高速 GPIOA->PUPDR &= ~GPIO_PUPDR_PUPDR0; // 浮空 GPIOA->AFR[0] &= ~GPIO_AFRL_AFRL0; GPIOA->AFR[0] |= 1 << GPIO_AFRL_AFRL0_Pos; // AF1 // 3. 复位并配置 TIM2 RCC->APB1RSTR |= RCC_APB1RSTR_TIM2RST; RCC->APB1RSTR &= ~RCC_APB1RSTR_TIM2RST; TIM2->CR1 = 0; // 先关断 TIM2->SMCR = 0; // 清空从模式设置 TIM2->SMCR |= TIM_SMCR_SMS_2 | TIM_SMCR_SMS_1 | TIM_SMCR_SMS_0; // 0b111: 外部时钟模式1 TIM2->SMCR |= TIM_SMCR_TS_0 | TIM_SMCR_TS_2; // TS=0b101: TI1FP1 TIM2->CCMR1 = 0; TIM2->CCER = 0; TIM2->CCER |= TIM_CCER_CC1P; // 上升沿触发(若需要下降沿则清零) TIM2->ARR = 0xFFFFFFFF; // 32 位最大计数值 TIM2->CNT = 0; // 清零计数 // 4. 使能中断并配置 NVIC TIM2->DIER |= TIM_DIER_UIE; // 更新中断使能 NVIC_EnableIRQ(TIM2_IRQn); NVIC_SetPriority(TIM2_IRQn, 1); // 5. 使能计数器 TIM2->CR1 |= TIM_CR1_CEN; }关于这段配置,逻辑上有三个地方值得展开说明:
第一,外部时钟模式 1 的使能是通过 SMCR 寄存器的 SMS 位段实现的,当它不是 0 时,计数器时钟就不再来自内部预分频器,而是来自发送接口的触发信号。这意味关掉预分频,PSC 留默认值即可。
第二,TS 位段选取的是触发源,代码里用的是 TI1FP1,即经过滤波和极性选择后的 TI1 信号。如果接的是 TI2,则要改成 TS=0b110(TI2FP2),且 CCER 的极性配置要对到 C2P。
第三,中断只开了 UIF,也就是计数器溢出中断。外部时钟模式下溢出意味着 CNT 从 0xFFFFFFFF 回绕到 0,这时候计数还在继续,但记录累计值需要额外处理溢出标志,以防 32 位也绕回导致数据错误。
3.2 捕获滤波参数:防抖和丢脉冲之间的权衡
外部脉冲信号很少是理想方波。机械开关抖动、电机碳刷火花、电磁干扰耦合,都会在边沿附近产生毛刺。如果不做滤波,一个高电平时期可能被数成好几个脉冲。STM32 的捕获通道里有 ICxF 位段,也就是输入滤波器,它实质上是一个数字信号采样器:连续检测到 N 个相同电平才认为信号有效。这对低频脉冲非常友好,但对高频脉冲有致命影响——如果滤波采样时间大于脉冲周期,脉冲会被整个滤掉。
具体的滤波参数设定与定时器时钟频率相关。F407 的 APB1 定时器时钟通常是 84MHz,ICxF 位段从 0b0001 到 0b1111 对应不同采样频率和采样次数。我这里以 1kHz 脉冲信号为例,给出一个直接可用的配置,滤波采样的意思就是内部时钟经过某个分频之后再采样 N 次一致才翻转输出。
要避免拍脑袋设参数,应该按照信号最小脉宽来算。以 1kHz 脉冲来说,高电平时间最小 500us,把滤波造成的最坏延时控制在 100us 以内,也就是不超过脉宽的 20%,就不会影响计数。具体做法是把 ICxF 设在 0b0100 附近,对应 6 次采样,每次采样约 15ns 级别,整体滤除毛刺时间小于 100ns,完全不影响 1kHz 信号。但如果信号是 100kHz 方波,滤波延时超过 5us 就会开始出问题。遇到电磁干扰比较大的现场,建议先开示波器抓波形,看干扰尖峰宽度,再倒推滤波参数。
3.3 中断服务函数计数:更新溢出和读 CNT
中断服务函数要处理两件事:溢出时更新一个高位变量,而读取 CNT 使用当前值。下面是标准的写法:
volatile uint32_t timer2_overflow_count = 0; volatile uint32_t total_pulse_count = 0; volatile uint32_t last_cnt_value = 0; void TIM2_IRQHandler(void) { if (TIM2->SR & TIM_SR_UIF) { TIM2->SR = ~TIM_SR_UIF; // 清除更新标志 timer2_overflow_count++; // 每次溢出说明 32 位计数器走完一圈 } }这个写法看似简单,但有一个坑:在外部时钟模式下,溢出中断触发和捕获边沿几乎是同时出现的。比如 CNT 正好等于 0xFFFFFFFF 时来一个脉冲,加 1 变 0,同时置位 UIF。此时如果你在主循环里先读 total_pulse_count 再读 timer2_overflow_count,就可能出现总数正好差一个满量程的情况。原因是读 CNT 和读溢出计数不是原子操作。如果不想引入临界区保护,可以改成在主循环中直接计算差值,而不是保存一个绝对计数值。
我的习惯是在主循环里做如下计算,这比在中断里累加更稳,因为避免了一次 64 位累加的非原子操作问题:
uint64_t now = ((uint64_t)timer2_overflow_count << 32) | TIM2->CNT; uint64_t diff = now - last_cnt_value; last_cnt_value = now;在实际项目中,即使 32 位计数器回绕次数较多,只要保证两次读取之间时间足够短,这个差值计算结果仍然正确。这就是 32 位计数器配合溢出计数做 64 位扩展的常用做法。
4. 用 DMA 搬运 CNT:不靠中断也能连续记录脉冲时间戳
4.1 DMA 突发传输的配置逻辑
中断方式的问题是:每来一个脉冲都进一次中断,脉冲频率高的时候,中断频繁进入会影响其他实时任务。而想要测量相邻脉冲间隔,必须记录每个脉冲到达时的 CNT 值。这时让 DMA 在捕获事件时自动把 CCRx 存入内存,就是一个更优雅的方案。
DMA 传输的外部触发源是定时器的捕获比较事件。当 CC1DE 位使能时,捕获事件会申请一次 DMA 传输,把 CCR1 寄存器数据搬到指定的内存地址。用 CCM 模式的数组来存放时间戳,可以在不打断 CPU 的情况下连续记录几百组脉冲间隔。
先看 DMA 初始化代码:
void TIM2_DMA_Init(uint32_t *buf, uint32_t len) { RCC->AHB1ENR |= RCC_AHB1ENR_DMA1EN; DMA1_Stream5->CR = 0; DMA1_Stream5->PAR = (uint32_t)&TIM2->CCR1; // 外设地址 DMA1_Stream5->M0AR = (uint32_t)buf; // 内存地址 DMA1_Stream5->NDTR = len; // 传输次数 DMA1_Stream5->CR = DMA_SxCR_CHSEL_0 | // 通道 0:TIM2_CH1 DMA_SxCR_PL_1 | // 高优先级 DMA_SxCR_MSIZE_0 | // 内存 16 位(也可配 32 位) DMA_SxCR_PSIZE_0 | // 外设 16 位 DMA_SxCR_MINC | // 内存地址自增 DMA_SxCR_DIR_0 | // 从外设读 DMA_SxCR_TCIE; // 传输完成中断 NVIC_EnableIRQ(DMA1_Stream5_IRQn); TIM2->DIER |= TIM_DIER_CC1DE; // 捕获事件触发 DMA TIM2->DIER &= ~TIM_DIER_CC1IE; // 关捕获中断 }这段配置里有几个决定成败的参数需要特别留意:
第一,DMA 的通道选择不是随便写的。DMA1 的 Stream5 通道 0 映射到 TIM2_CH1,这个映射关系在 F407 的 DMA 请求映射表里有明确说明,如果配错,DMA 永远不会被触发。高效排查方式就是看 DMA 控制器的通道配置位段与定时器请求映射是否匹配。
第二,MSIZE 和 PSIZE 的位宽必须对齐。CCR1 在 F407 上是 16 位寄存器(即使定时器是 32 位计数器),所以外设侧设 16 位,内存侧如果你用 uint32_t 数组,可以设 32 位;但 NDTR 代表的是传输次数,是一次或半次取决于你按 8 位还是 16 位拆解。我实际测试下来,最稳妥的是两侧都设 16 位,内存用 uint16_t 数组,避免 DMA 写入内存时按半字对齐的问题。
第三,也是最关键的:DMA 工作在循环模式还是普通模式。上面这段设置的是普通模式,传输 len 次后停止。如果采集场景是连续不间断的脉冲流,应该加 CIRC 位,让 DMA 自动回绕从头开始覆盖旧数据,并且每次传输完成触发一次中断,方便上位机取走整块数据。
4.2 主循环里的数据处理示例
假设 DMA 配置为普通模式,缓冲区大小为 128 个 uint16_t,传输完成中断里处理时间戳:
volatile uint16_t timestamp_buf[128]; volatile uint8_t dma_complete_flag = 0; void DMA1_Stream5_IRQHandler(void) { if (DMA1->LISR & DMA_LISR_TCIF5) { DMA1->LIFCR = DMA_LIFCR_CTCIF5; dma_complete_flag = 1; } } void process_timestamps(void) { uint32_t i; float freq = 0.0f; if (!dma_complete_flag) return; // 相邻时间戳差值就是脉冲间隔(单位:定时器计数周期) // 本例子定时器时钟按内部 84MHz 计算,间隔 = (ts[i+1] - ts[i]) / 84000000 for (i = 0; i < 127; i++) { uint16_t delta = timestamp_buf[i+1] - timestamp_buf[i]; if (delta > 0) { freq = 84000000.0f / (float)delta; // 瞬时频率 } } dma_complete_flag = 0; }这里有个细节值得强调:dma_complete_flag 是 volatile 变量,主循环轮询它。DMA 完成中断里只是清标志,数据已经在 timestamp_buf 中。因为 DMA 普通模式下传输完成后会停下来,所以主循环在处理 128 个数据时,不用担心数据被覆盖。什么时候重新启动 DMA?处理完后重新设置 NDTR 并清 DMA 状态位,然后再把定时器的 CC1DE 打开。
4.3 DMA 循环模式 + 乒乓缓存:连续采集不丢数据的进阶姿势
如果脉冲是连续不断、且频率不低的,推荐用乒乓缓存。定义两个同样大小的缓冲区,DMA 用循环模式,每次传输完成中断时切换读端口。这里给出一个标准实现思路:
#define BUF_LEN 256 __attribute__((section(".ccmram"))) uint16_t buf_ping[BUF_LEN]; __attribute__((section(".ccmram"))) uint16_t buf_pong[BUF_LEN]; volatile uint8_t active_buf = 0; // DMA 传输完成中断里切换有效缓冲区乒乓缓冲最大的作用是避免“读和写同时操作一个缓冲区”。当 DMA 正在写入 buf_ping 时,主循环读 buf_pong,互不干扰。在 F407 上,CCM RAM 和 AHB 总线速度相同时,CPU 读 CCM RAM 不占用 AHB 带宽,DMA 传输与外设总线不受影响。这个技术在采集高频脉冲间隔时效果非常明显,我在某项目中用这种方式连续采集 50kHz 脉冲的间隔时间,连续运行一周,数据没有出现缺口。
5. 避坑指南:外部脉冲计数从原理图到现象的四个经典翻车点
5.1 引脚浮空导致上电乱计数
现象:STM32 刚上电,脉冲输入端什么都没接,串口打印的计数值一直在跳。
原因:引脚配置成浮空输入时,外部没有驱动信号的情况下电压是不确定的。如果恰好处于逻辑阈值边缘,内部施密特触发器会不断反转,定时器捕获到了大量的伪脉冲。
解决:把 GPIO 配置改成上拉输入。外部脉冲信号是开漏输出或没有强驱动能力时,默认高电平就不会产生误触发;如果信号源是推挽输出,则可以不改变,但悬空测试时一定要上拉或下拉。另外,在软件初始化中先配置 GPIO 再使能定时器,依次顺序不能反,否则定时器在复用功能还没有完全生效之前就已经开始偶尔计数。
5.2 滤波器参数过大,高频脉冲被直接吞掉
现象:100kHz 的方波信号从示波器上看很完美,但计数结果只有 30kHz。
原因:ICxF 滤波倍数设置过大。假设内部采样时钟 84MHz,采样次数 N=64,那么滤波延时约为 64/84MHz ≈ 0.76us,对周期为 10us 的 100kHz 信号似乎问题不大。但当 N=128 甚至 256 时,延时达到几微秒,信号高电平甚至低电平在滤波器判定完成之前就被另一极性覆盖了,于是输出直接保持不变。
解决:先用示波器确认原始信号最高频率,算最小脉宽,然后留出至少 20% 的余量来选择 ICxF 系数。滤波的目的只是砍毛刺,不是做低通滤波,宁缺毋滥。对典型编码器输出,ICxF=0b0011 足够干净,对闸门干簧管抖动,ICxF=0b1001 也可以。
5.3 32 位扩展读法不对:溢出中途被覆盖
现象:运行几个小时后,计数结果突然变成一个很小或很大的数,且没有规律。
原因:在中断里做 total_pulse_count = (timer2_overflow_count << 32) | TIM2->CNT,但如果先读了 CNT 后发生了溢出,再读 timer2_overflow_count,就会出现高位与低位不匹配。比如 CNT 已经回绕到 0,但 timer2_overflow_count 还没有更新,这时差值等于 0,数据直接就错了。
解决:要么把 64 位组合与读取做成临界区保护,要么在读取时把溢出中断里计数值更新和 CNT 读取顺序换一下。我的做法是:先读 timer2_overflow_count,再读 CNT,再读一次 timer2_overflow_count;如果不一致则重读一遍,直到两次一致。这在读 32 位计数器做 64 位扩展时是个保底稳妥方案。
5.4 DMA 数据传输完成中断标志清不掉
现象:DMA 配置没问题,但第一次传输完成后,中断不再触发,或者主循环里 dma_complete_flag 永远是 1。
原因:DMA 中断标志的清除方式不是写 0,而是写 1 清除。很多人习惯性地写 0,导致标志一直挂起,进入中断后马上再进,看似永远在执行中断,实际上逻辑已经乱了。
解决:清除 DMA 标志必须用写 1 的方式,这是因为 ST 的 DMA 寄存器设计时用了写 0 不生效、写 1 复位的机制。比如 DMA1->LIFCR = DMA_LIFCR_CTCIF5。这个坑几乎每个用 DMA 的人都会踩一次,在这里单独列出来,是为了提醒你在排查 DMA 问题时先看标志位清除动作。
6. 让计数区间更精确:基于单位时间闸门的频率测量方法
不管是用外部时钟模式直接读 CNT,还是用捕获中断记录时间戳,最终都要回答一个问题:怎么把计数值变成工程上能用的物理量?脉冲计数的最终目的多半是测转速或流量,核心公式无非是频率 = 计数 / 闸门时间。但闸门时间怎么定、什么时候开始什么时候结束,直接影响测量精度。
先说明一种常见但不够严谨的做法:主循环里延时 1 秒,读一次计数值,然后把这个计数值当作频率。这有两个隐患,一个是延时不准确,二是采样窗口和实际脉冲之间没有对齐,读到的值可能是跨周期的,导致结果忽大忽小。更好的做法是用定时器的从模式配合主模式,让主定时器产生 1Hz 的闸门信号,从定时器只在闸门内计数,这就是所谓的硬件闸门测量法。
以 TIM5 作为主定时器,输出 1Hz 的频率,TIM2 的外部时钟模式只接脉冲输入,并且使能 TIM2 的从模式门控,具体配置思路是:TIM2->SMCR 里 SMS 位段设 0b100,即外部门控模式,此时只有当主定时器输出有效的闸门高电平期间,外部脉冲才能驱动 CNT 计数。这样得到的计数值在每个闸门上升沿锁存一次,完全对齐时间窗口。
这种硬件闸门方式的优势在于,计数和计时完全由硬件同步,不存在软件读取延迟,短时间测量的精度可以做到很高。闸门时间越短,硬件同步优势越明显。对于转速测量,比如电机每圈输出 1024 个脉冲,闸门时间设为 10ms,那一次闸门内有 1024 个脉冲的 1% 精度,对于工程应用已经非常理想。
需要注意一点:外部门控模式下,输入脉冲和闸门信号之间必须满足建立时间要求,否则第一个脉冲可能被吃掉。这个建立时间通常以定时器时钟周期计算。如果你发现闸门开启初期计数偏小,可以在初始化时把闸门信号提前几个系统时钟,或者改用主从定时器的级联模式。
另外一个实际操作里的常见需求是“测量某一段时间内的脉冲数”,比如启动一个电机后,记录 5 秒内编码器输出的脉冲数。最直接的实现方式是用前面提到的 DMA 记录所有脉冲时间戳,然后在主循环里用二分查找找时间戳落在 5 秒窗口内的数量。这种方式的好处是,同一份数据可以同时算出频率、周期抖动、累计数量,而不需要额外硬件逻辑。
我个人的使用习惯:对于固定频率的脉冲测速,用 GPIO 外部中断校验一下有无丢数,再交给定时器外部时钟模式计数;对于需要精确时间戳的脉冲序列分析,一定走 DMA 方案。做 STM32 这类工程,思路理顺了,代码反而是次要的——定时器框图和 DMA 请求映射表才是最可靠的参考依据。
最近做一个脉冲采集项目时,我在软件写好之后花了一天时间排查一个间歇性丢数问题,最后发现是 DMA 普通模式下 NDTR 重新装载之前没有清传输完成标志导致的下一次传输没有启动。从那以后,我每次初始化 DMA 传输前,都强制走一遍完整的复位+清标志+重装地址的流程,确认状态位全清零才打开定时器的 DMA 请求。这套检查顺序后来救过我不少次,希望帮到你。
本文还有配套的精品资源,点击获取