☰
STM32双输入捕获+DMA实现高精度脉冲宽度测量方案
2026/10/5 10:57:43 网站建设 项目流程

做电机驱动、RC遥控解码、超声波测距这类项目时,脉冲宽度测量几乎是躲不掉的需求。我最早做这块用的是最常规的单通道输入捕获加中断,每次捕获进中断读一次CCR。PWM频率低的时候问题不大,一旦到10kHz以上,每秒钟要进两万次中断,CPU大量时间花在压栈出栈上;频率再往上走,捕获事件开始丢失,数据就完全没法用了。后来我把方案改成双输入捕获加DMA,也就是定时器的CH1抓上升沿、CH2抓下降沿,两个通道各自通过DMA把捕获值搬到内存数组,CPU只在DMA半满或者全满的时候做一次批量计算,整个系统才算真正跑稳。这篇笔记就把这个方案的底层原理、CubeMX配置、软件处理思路和实际踩坑记录完整过一遍,适合正在做STM32信号测量、PWM解码,或者想把捕获与DMA配合细节彻底搞清楚的开发者参考。

1. 为什么脉冲宽度计要选"双通道捕获+DMA"

1.1 脉宽测量到底用在哪些地方

脉冲宽度这个词看着抽象,实际项目里到处都是。超声波测距模块HC-SR04的Echo引脚输出高电平脉宽,宽度正好对应距离,MCU要把它解码成厘米;RC遥控接收机输出的信号是1到2ms的PWM脉宽,不同脉宽代表不同通道值;电机编码器或者转速传感器输出的方波,周期直接反映转速;电源测试里还要统计PWM的占空比和频率漂移。这些都是典型的脉宽测量需求。

共同点在于:要测的不只是单个脉冲的宽度,往往是一长串连续脉冲,并且还要在测量过程中保持CPU可用。单纯延时等待电平变化那种土办法完全不行,精度差、占用高,只能当教学demo。

1.2 单通道捕获方案卡在哪

很多入门教程教的是单通道输入捕获,配一个上升沿或者下降沿触发,进中断读CCR。这个方案对单个脉冲还行,但要同时测高电平宽度和低电平宽度就麻烦了。单个通道在一段时间内只能锁存一种边沿,你捕获到上升沿之后,如果不去改边沿极性,就等不到下降沿,而改极性这个操作本身会漏掉一个边沿。对于连续测量来说,漏一个边沿整个数据流就错乱了。

更现实的痛点是中断频率。假设被测信号是10kHz的方波,每秒有1万个上升沿加1万个下降沿,即使只捕获上升沿,每秒也有一万次中断。STM32处理一次中断的开销在1到2微秒级别,一万次中断就把CPU拖掉几个百分点,再叠加用户自己的业务代码,系统实时性很难保证。到了50kHz以上,中断频率超过每秒十万次,这时不仅CPU忙不过来,定时器也可能因为中断响应不及时而出现更新事件丢失。

1.3 双通道加DMA的整体思路

方案其实不复杂:同一个输入信号接定时器的一个引脚,通过定时器内部映射同时送到两个捕获通道。CH1配置上升沿捕获,CH2配置下降沿捕获,定时器计数器CNT从头到尾连续运行。每次发生捕获事件时,CNT的当前值会被硬件锁存进CCR1或CCR2,同时触发DMA请求,DMA自动把CCR的值搬到内存数组里。CPU全程不参与时间戳搬运,只在DMA传完半组或者一整组数据时收到中断,再做批量计算。

这里有个关键的设计权衡:为什么要用同一个定时器的两个通道,而不是两个定时器?因为同一个定时器的两个捕获通道共享同一个CNT计数器,上升沿和下降沿时间戳天然在同一个时间轴上,配对和差值计算都极其简单。如果用两个定时器,两个计数器之间的启动延迟、时钟相位差都要做对齐补偿,得不偿失。而用DMA而不是双通道中断,核心原因就是减少CPU负载。中断方式是"每次事件打扰一次CPU",DMA方式是"攒够了一批数据再打扰一次CPU"。

2. 输入捕获和DMA底层是怎么配合的

2.1 从引脚到CCR再到内存的完整链路

先把捕获链路讲透。信号从定时器输入引脚进来,先经过可选的输入滤波器,再进入边沿检测器。边沿检测器检测到设定好的极性变化后,会立刻把CNT计数器的值复制到对应的捕获比较寄存器CCR里。这个过程全部由硬件完成,和CPU响应速度完全无关。

也就是说,即使CPU正在忙别的事,边沿到来那一刻CNT的值也已经锁存好了。这一点是输入捕获精度高的根本原因,中断方式读取CCR之所以还算准,靠的也是这个硬件锁存机制,而不是中断响应有多快。

CCR一旦更新,定时器就会产生一个DMA请求。DMA控制器收到请求后,从源地址(CCR寄存器地址)读取数据,写入目的地址(内存数组)。整个过程不需要CPU参与寄存器搬运,也不会被中断嵌套打断。定时器这里有两种DMA模式可以选择,普通模式传一次就停,循环模式传完指定长度后自动回到数组头部继续覆盖,这也是我们用循环模式的原因,保证内存里永远有最新的一段时间戳。

2.2 同一定时器双通道独立工作的原理

很多新手会担心:CH1和CH2共用同一个CNT,会不会互相干扰?实际上不会。捕获通道的关系是"读同一个计数器,但各自独立检测边沿、独立触发DMA请求"。也就是说CNT只有一个,但CCR1和CCR2是两个独立的寄存器,分别由CH1和CH2的捕获事件锁存。

还需要注意一个选型层面的细节:F103系列这种老一代芯片上,同一个定时器的多个DMA请求往往会合并到一路DMA通道上,做双通道双DMA搬运时容易出现请求冲突或资源不够用的现象,实际操作起来很别扭。F4和H7系列则把定时器的CH1、CH2、CH3、CH4等捕获DMA请求独立映射到不同的DMA流上,双通道同时搬运互不干扰。所以这个方案我最推荐F407及以上系列,如果你手头是F103,要么改用两个定时器分别捕获,要么纯中断方式,别硬上双DMA。

2.3 时基分辨率与计数器回绕

所谓高精度,第一步要搞清楚分辨率能到多少。定时器计数时钟频率决定了每个计数周期代表的时间长度,也就是分辨率。STM32定时器时钟不是直接等于APB外设时钟,在APB分频系数不为1的时候,定时器时钟往往是APB时钟的2倍。

以F407为例,系统时钟168MHz时,APB1最高42MHz,但挂在APB1上的定时器实际时钟是84MHz,对应分辨率约11.9ns;APB2是84MHz,挂在APB2上的TIM1和TIM8实际时钟是168MHz,分辨率约5.95ns。F103在72MHz系统时钟下,定时器时钟72MHz,分辨率约13.89ns。分辨率越高,单次测量误差理论上越小,所以追求极致精度时,优先选TIM1或者TIM8这类挂在APB2上的定时器。

计数器回绕则是另一个必修课。16位定时器CNT最大到65535,比如72MHz下大约910微秒就回绕一次。如果被测脉冲宽度接近或者超过这个时间,比如测一个20ms的伺服PWM信号,16位定时器不加处理就会算错。解决办法有三个方向:一是用32位定时器TIM2或者TIM5,84MHz下回绕周期超过51秒,绝大多数脉宽测量场景都不会碰到回绕;二是用更新中断做溢出计数,扩展成64位时间戳;三是给预分频器加分频,但这样会牺牲分辨率,不推荐。

2.4 澄清一个关于"高精度"的误解

这里想多说一句。很多人以为DMA方案比中断方案"更精确",其实并不完全对。中断方式里CCR也是硬件锁存的,中断延迟并不会污染时间戳本身。DMA真正的价值是搬运过程不打断CPU,从而支撑更高频率的连续捕获,并且避免中断响应延迟对后续软件计算造成的抖动干扰。

换句话说,如果你只是偶尔测一个脉冲,中断方式完全够用;如果你要在高频PWM下持续测量,并且CPU还要同时跑显示、通信、控制算法,那DMA就成了刚需。清楚这一点,遇到测量数据异常的时候才不会把问题归因到错误的位置。

3. CubeMX配置:两个捕获通道加两路DMA

3.1 定时器和通道的选择

进入CubeMX之后,第一步是选择定时器。我建议直接选32位的TIM2或者TIM5,因为省去处理16位回绕的麻烦。如果项目里TIM2、TIM5已经被占用,退而求其次用TIM3或TIM4,软件里面做好溢出处理。想要极限分辨率,可以考虑TIM1或TIM8,它们挂在APB2上,计数时钟能到168MHz,不过高级定时器有一些额外配置项,新手容易搞混。

通道配置上,CH1和CH2都选择Input Capture direct mode。两路捕获的极性分别设置为Rising和Falling,这样同一引脚上的信号,上升沿锁存到CCR1,下降沿锁存到CCR2。

这里有个容易忽略的重映射问题:定时器的输入引脚往往有多个复用选项,CubeMX会根据配置自动分配,但如果引脚和调试器SWD引脚冲突,程序会下载不进去或者运行异常。遇到这种情况,先在System View里把调试口改成Serial Wire或关掉,再分配定时器引脚。

3.2 DMA的参数怎么填

定时器配置好后,切到DMA Settings标签页,为TIMx_CH1和TIMx_CH2分别添加一路DMA。关键参数如下:

  • Direction:PeripheralToMemory,外设到内存
  • Mode:Circular,循环模式
  • Peripheral Increment:关闭,CCR寄存器地址固定
  • Memory Increment:开启,数组地址递增
  • Data Width:16位定时器选HalfWord,32位定时器选Word
  • Priority:可以选High,测量实时性优先

数据宽度这个参数特别容易出错。定时器CCR寄存器是16位就用HalfWord,是32位就用Word。如果32位定时器配了HalfWord,计数超过65535之后时间戳的高位丢了,算出来的脉宽会莫名奇妙地偏小或者跳变。数组元素的类型也要跟着匹配,16位选uint16_t,32位选uint32_t。

还有一个坑在DMA中断这里。很多人在CubeMX里只勾了Half Transfer和Transfer Complete,但忘了到NVIC设置里使能对应的DMA中断通道,程序跑起来回调永远不触发。务必在NVIC页面确认对应DMA Stream的中断优先级是Enabled状态。

3.3 时钟树里最容易被忽略的倍频关系

配置时钟树时,要特别留意定时器输入时钟。CubeMX里选中定时器后,右侧可以看到Timer Input Frequency。F407跑168MHz主频时,APB1定时器显示84MHz,APB2定时器显示168MHz。有些开发者想当然地认为APB1是42MHz定时器就是42MHz,结果所有时间戳换算全部偏了一倍,这种错误很难肉眼发现。

判断方法很简单:打开Clock Configuration界面,点击对应的定时器时钟源,看CubeMX自动算出来的频率。别嫌这一步麻烦,时基错了后面所有脉宽数据都白算。

3.4 生成代码后的启动调用

CubeMX生成代码后,在main函数里启动捕获DMA。假设用TIM4,数组定义为uint16_t类型:

#define PW_BUF_SIZE 256 uint16_t rising_buf[PW_BUF_SIZE]; uint16_t falling_buf[PW_BUF_SIZE]; HAL_TIM_IC_Start_DMA(&htim4, TIM_CHANNEL_1, (uint32_t *)rising_buf, PW_BUF_SIZE); HAL_TIM_IC_Start_DMA(&htim4, TIM_CHANNEL_2, (uint32_t *)falling_buf, PW_BUF_SIZE);

注意HAL库这个接口的pData参数类型是uint32_t指针,我们传uint16_t数组时需要强转。只要DMA配置里的数据宽度正确,底层搬运还是16位,不会出错。如果你定义uint32_t数组配合Word宽度,那直接传数组名就行。

4. 软件架构:环形时间戳数组与脉冲配对

4.1 环形缓冲和双缓冲处理

DMA循环模式会把时间戳源源不断写进数组,写满后从头开始覆盖。这时候不能让CPU每来一个数据就处理一次,而是要让DMA在半满和全满时各产生一次中断,CPU在这两个时间点批量处理一个半区的数据。

数组长度选256,半个区就是128个数据,也就是一次中断最多处理128个脉冲的时间戳。假设被测信号是100kHz方波,半区填充一次需要约1.28ms,CPU在这个时间内处理128个时间戳绰绰有余,负载极低。

回调函数这样写:

void HAL_TIM_IC_CaptureHalfCpltCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM4) { process_timestamps(0, PW_BUF_SIZE / 2 - 1); } } void HAL_TIM_IC_CaptureCpltCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM4) { process_timestamps(PW_BUF_SIZE / 2, PW_BUF_SIZE - 1); } }

有个细节:回调函数里面尽量不要做重活,尤其不要在这个函数里调用printf、HAL_Delay这类阻塞操作,否则半区数据处理时间可能超过另一个半区的填充时间,DMA就会覆盖正在处理的数据。稳妥的做法是在回调里只置一个标志位,主循环检测到标志位后再集中处理。

4.2 上升沿和下降沿的配对规则

DMA数组里有上升沿时间戳和下降沿时间戳,关键问题是怎么把属于同一个脉冲的一对边沿对上。如果被测信号从低电平开始,那么第一次捕获是上升沿进rising_buf[0],紧接着下降沿进falling_buf[0],第二次上升沿进rising_buf[1],第二次下降沿进falling_buf[1],以此类推。这时候配对关系就是:

高电平宽度 = falling_buf[i] - rising_buf[i] 周期 = rising_buf[i+1] - rising_buf[i] 占空比 = 高电平宽度 / 周期

麻烦的情况是测量开始时信号恰好处于高电平,第一个事件是下降沿,这时falling_buf[0]对应的是半个旧脉冲,rising_buf[0]对应的是新脉冲的上升沿,直接相减会得到负数或者一个完全不合理的值。

处理思路有两个。一是启动DMA后先等待几个脉冲,把最前面的错位数据丢弃;二是在计算时做合理性判断,如果高电平宽度的计算结果大于等于周期,或者出现异常负值,就丢弃当前配对。我实际工程里两种方法都在用,初始化阶段用前者快速收敛,后续持续用后者做数据保护。

4.3 计数器回绕处理

16位定时器下,时间戳差值可能因为CNT回绕而变成负数。比如上升沿在60000,下降沿在1000,实际高电平宽度是1000加65536再减60000,等于6536个计数周期。代码里这样处理:

uint16_t diff = (uint16_t)(falling - rising);

利用uint16_t无符号回绕特性,直接做减法再转回无符号数,就能自动处理回绕。这个写法成立的前提是脉宽本身不超过65536个计数周期,如果超过,16位定时器就不够用了。

更省心的方式还是用32位定时器。TIM2/TIM5的CNT是32位,用uint32_t做差值计算,84MHz下可以覆盖51秒以上的脉冲宽度,绝大多数应用根本不需要额外处理回绕。这也是我在项目里最终选定32位定时器的原因。

4.4 跨半区边界的数据衔接

处理半区数据时有个边界问题:假设rising_buf[127]是当前半区最后一个上升沿,但和它配对的下降沿可能落在rising_buf[128],也就是下一个半区的起始位置。这种情况下,当前半区处理不到这半个脉冲,下一半区也不知道这个脉冲的前半段信息。

解决方案很简单:在处理函数里保存本半区最后一个上升沿时间戳,下次处理时先把这个"残留脉冲"和当前半区开头的下降沿配对。我代码里这样处理:

static uint16_t last_rising = 0; static uint8_t has_last_rising = 0; void process_timestamps(uint16_t start, uint16_t end) { if (has_last_rising) { if (falling_buf[start] > last_rising) { uint16_t high_width = falling_buf[start] - last_rising; // 计算并记录高电平宽度 } has_last_rising = 0; } for (uint16_t i = start; i < end; i++) { // 常规配对计算 } last_rising = rising_buf[end]; has_last_rising = 1; }

这个细节在网上的教程里很少被提到,但实际跑起来就会遇到。不处理的话,每个半区边界都会丢掉一个脉冲的宽度数据,看着不多,频率统计却会有微小偏差。

5. 高精度测量里容易忽略的细节

5.1 分辨率和量化误差

高精度不等于无限精度。定时器计数是离散的,边沿落在两个计数时钟之间时,只能取到其中一个计数时刻,所以单次测量的量化误差最大为一个计数周期。84MHz定时器时钟对应约11.9ns,168MHz对应约5.95ns,这就是该方案在F407上的理论极限。

要缩小量化误差,最直接的方法是提高计数时钟频率。如果条件允许,选一个TIM1或TIM8这类APB2上的定时器。另一个思路是对多个周期求平均,比如测100个脉冲的周期再除以100,随机误差会明显下降。我在验证精度时就是先测100个脉宽求均值再和信号发生器比对,效果比单次数据稳定得多。

5.2 输入滤波器会吃掉多少时间

定时器输入滤波器可以用来滤除信号毛刺,但它是把双刃剑。滤波器会让边沿信号延迟若干个采样时钟周期才到达边沿检测器,并且这个延迟是固定的。对于单个脉宽测量,上升沿和下降沿经同一通道进入时延迟基本相同,很多情况下影响不大;但如果信号本身有抖动或者噪声,滤波器设置不当反而会把真正的边沿滤掉。

我的建议是:先用逻辑分析仪确认信号质量。信号干净就直接把IC Filter设为0,不做滤波;信号在恶劣环境里必须滤波时,再根据噪声宽度设置合适的采样频率和滤波长度,同时把固定延迟计入误差预算。

5.3 不要用预分频器来防溢出

我见过不少代码为了"防止计数太快溢出",把TIM_IC_Prescaler设置成2分频或者更高。这个操作会把捕获事件砍掉一半以上,上升沿和下降沿触发次数不对等了,脉冲配对直接崩溃。预分频器会让捕获事件每N次才触发一次,只适合低频计数应用,不适合连续脉宽测量。

正确做法是PSC保持0,让计数器满速运行,把回绕问题交给32位定时器或者溢出计数去解决。只要软件结构合理,回绕并不难处理,没必要牺牲量测精度。

5.4 溢出计数扩展的实战做法

如果你只能用16位定时器,又需要测量较长的脉宽,可以考虑在更新中断里累加溢出次数。思路是TIMx产生更新中断时,软件对overflow_cnt加1,计算真实时间戳时把溢出次数和CCR拼接起来:

volatile uint32_t overflow_cnt = 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { overflow_cnt++; } } uint32_t get_full_timestamp(uint16_t ccr) { uint32_t ts; __disable_irq(); ts = (overflow_cnt << 16) | ccr; __enable_irq(); return ts; }

需要注意的是,读取overflow_cnt和CCR时必须在关中断的保护下进行,否则可能读到溢出次数已更新但CCR还未更新的中间状态。这个方法能用,但代码复杂度明显上升,所以我还是推荐能用32位定时器就用32位定时器。

5.5 批量计算对实时性的实际提升

最后说一个很直观的数据。纯中断方式下,100kHz方波每秒20万个边沿事件,每个事件一次中断,CPU基本上被拖死;DMA方式下,256长度的数组半满中断一次处理128个上升沿或下降沿,每秒中断次数下降到约781次,CPU占用率从接近满载降到可以忽略不计。这个差距在跑控制算法、LCD刷新、多路通信的项目里是决定性的。

6. 实测数据与踩坑修复记录

6.1 自测的搭建方法

拿到代码后怎么验证方案是否真的准?我的做法是让STM32自己产生PWM信号,再自己测量。用TIM1产生可调频率和占空比的PWM,把TIM1的PWM输出引脚和TIM4的捕获输入引脚用跳线短接。这样就能在完全可控的条件下,把设定值和实测值做比对。串口打印实时测量结果,边改参数边观察。

6.2 实测数据参考

下面是我在F407上跑的一组数据,定时器用TIM4,84MHz计数时钟,32位计数器,PSC为0:

信号发生器设定实测频率实测占空比备注
1kHz / 50%1.000 kHz49.98%100个周期均值
10kHz / 50%10.001 kHz50.00%100个周期均值
50kHz / 25%50.002 kHz25.01%100个周期均值
100kHz / 75%100.004 kHz74.98%100个周期均值

这个精度对大部分信号测量场景完全够用。需要注意的是,占空比误差一部分来自信号源本身,如果信号发生器自身抖动偏大,实测值会和设定值有一点点偏离,这是正常的,不要怀疑代码。

6.3 坑一:DMA数据不更新或者乱序

表现是数组里一部分是0,一部分是旧数据,甚至两个数组数据错位。排查时先用调试器暂停程序,查看DMA控制器的NDTR寄存器,看它是不是在变化。如果NDTR根本不动,说明DMA请求没触发,多半是DMA的Request配置选错了通道,比如TIM4_CH1的DMA请求被手动改成了别的映射。把CubeMX里DMA请求重新核对一遍,重新生成代码即可。

如果NDTR在变化但数据还是乱,检查数据宽度和数组类型是否匹配。32位定时器配了HalfWord,或者uint16_t数组配了Word,都会出现高低字节错乱的问题。

6.4 坑二:脉宽计算出现负值或突变的极大值

这个坑几乎每个做双捕获的人都会遇到,原因就是开头的脉冲配对错位。信号从高电平开始时,第一个上升沿还没来,第一个下降沿已经先到了,两组时间戳的索引关系被整体错开了一位。我最初的解决办法是计算时判断差值合理性,如果高电平宽度大于等于周期就把该点丢弃;后来又加了启动后先丢弃前几个脉冲的处理逻辑,数据稳定性明显提高。

6.5 坑三:高级定时器TIM1/TIM8输出与捕获互相干扰

TIM1和TIM8配置PWM输出时,需要在BDTR寄存器里使能主输出MOE位,否则引脚没有波形。而如果同一时间还让TIM1做输入捕获,配置要仔细核对,避免把捕获通道配到和输出相同的引脚上。我遇到过TIM1_CH1做PWM输出,又误把捕获也配到TIM1_CH1,结果引脚逻辑冲突,信号直接异常。解决办法是把测输入信号的捕获放到另一个定时器上,比如TIM2或TIM4,这样两个功能互不干扰,排查问题也容易。

6.6 坑四:DMA循环模式下判断新数据的技巧

如果你不想用中断,想在主循环里周期性检查DMA有没有新数据,可以读取DMA的NDTR寄存器。每次读到的NDTR值表示还有多少数据没传,对比上次读到的值就能算出这段时间DMA写入了多少新时间戳。我在某个需要把测量和显示完全解耦的项目里用过这个办法,效果很好。

uint16_t get_new_data_count(uint16_t last_ndtr) { uint16_t current_ndtr = __HAL_DMA_GET_COUNTER(&hdma_tim4_ch1); uint16_t diff = (uint16_t)(last_ndtr - current_ndtr); return diff; }

这样做的好处是数据处理完全由主循环调度,不会在中断上下文里做耗时操作;坏处是实时性比中断方式差一些,适合对CPU占用要求极苛刻、对响应实时性要求不高的场合。

最后再分享一个我自己用得很顺的小技巧:如果测量结果偶尔出现一个异常点,不要急着调滤波器,先在软件里加入简单的阈值判断,把明显不合理的数据直接丢弃。很多现场干扰导致的异常都是偶发的,用一两个if就能挡住,比硬件层面大动干戈省事得多。这套双输入捕获加DMA方案跑起来之后,我基本再也没回去用纯中断测脉宽,CPU省下来的资源让项目整体稳定性上了一个台阶。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询